[
  {
    "path": ".editorconfig",
    "content": "# EditorConfig helps developers define and maintain consistent\n# coding styles between different editors and IDEs\n# editorconfig.org\n\nroot = true\n\n[*]\n\n# Change these settings to your own preference\nindent_style = space\nindent_size = 2\n\n# We recommend you to keep these unchanged\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newline = true\n\n# https://github.com/editorconfig/editorconfig-core-go/blob/master/.editorconfig\n[{Makefile,go.mod,go.sum,*.go}]\nindent_style = tab\nindent_size = 4\n\n[*.py]\nindent_style = space\nindent_size = 4\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/---bug-report.md",
    "content": "---\nname: \"\\U0001F41E Bug report\"\nabout: Report a bug encountered while operating external-dns\ntitle: ''\nlabels: kind/bug\nassignees: ''\n\n---\n\n<!--\nPlease 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!\n\nMake sure to validate the behavior against latest release https://github.com/kubernetes-sigs/external-dns/releases as we don't support past versions.\n\nBug Report Guide: https://kubernetes-sigs.github.io/external-dns/latest/docs/contributing/bug-report/\n-->\n\n**What happened**:\n\n**What you expected to happen**:\n\n**How to reproduce it (as minimally and precisely as possible)**:\n\n<!--\nPlease 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.\n\nProvide live objects from the API server — not Helm/Terraform/Flux templates. Include ALL fields. The status section is often critical.\n\nkubectl get <resource> -o yaml   # ingress, service, gateway, dnsendpoint, nodes, …\n                                 # before and after the change if reporting a regression\n-->\n\n**Anything else we need to know?**:\n\n**Environment**:\n\n- External-DNS version (use `external-dns --version`):\n- DNS provider:\n- Others:\n\n## Checklist\n\n- [ ] I have searched existing issues and tried to find a fix myself\n- [ ] I am using the [latest release](https://github.com/kubernetes-sigs/external-dns/releases),\n  or have checked the [staging image](https://kubernetes-sigs.github.io/external-dns/latest/release/#staging-release-cycle) to confirm the bug is still reproducible\n- [ ] I have provided the actual process flags (not Helm values)\n- [ ] I have provided `kubectl get <resource> -o yaml` output including `status`\n- [ ] I have provided full external-dns debug logs\n- [ ] I have described what DNS records exist and what I expected\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/--enhancement-request.md",
    "content": "---\nname: \"✨ Enhancement Request\"\nabout: Suggest an enhancement to external-dns\ntitle: ''\nlabels: kind/feature\nassignees: ''\n\n---\n\n<!-- Please only use this template for submitting enhancement requests. This can be something like a new provider or a new gateway.  -->\n\n**What would you like to be added**:\n\n**Why is this needed**:\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/-support-request.md",
    "content": "---\nname: \"❓Support Request\"\nabout: Support request or question relating to external-dns\ntitle: ''\nlabels: kind/support\nassignees: ''\n\n---\n\n<!--\nSTOP -- PLEASE READ!\n\nGitHub is not the right place for support requests.\n\nIf you're looking for help, check our [docs](https://github.com/kubernetes-sigs/external-dns/tree/HEAD/docs).\n\nYou can also post your question on the [Kubernetes Slack #external-dns](https://kubernetes.slack.com/app_redirect?channel=external-dns).\n\n-->\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/create-release.md",
    "content": "---\nname: Create Release\nabout: Release template to track the next release\ntitle: Release x.y\nlabels: area/release\nassignees: ''\n\n---\n\nThis Issue tracks the next `external-dns` release. Please follow the guideline below. If anything is missing or unclear, please add a comment to this issue so this can be improved after the release.\n\n## Preparation Tasks\n\n- [ ] Release [steps](https://github.com/kubernetes-sigs/external-dns/blob/master/docs/release.md#steps)\n\n### Release Execution\n\n- [ ] Branch out from the default branch and run scripts/version-updater.sh to update the image tag used in the kustomization.yaml and in documentation.\n- [ ] Create the PR with this version change.\n- [ ] Create an issue to release the corresponding Helm chart via the chart release process (below) assigned to a chart maintainer\n\n### After Release Tasks\n\n- [ ] Announce release on `#external-dns` in Slack\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "# To get started with Dependabot version updates, you'll need to specify which\n# package ecosystems to update and where the package manifests are located.\n# Please see the documentation for all configuration options:\n# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates\n\nversion: 2\nupdates:\n  - package-ecosystem: \"gomod\" # See documentation for possible values\n    directory: \"/\" # Location of package manifests\n    schedule:\n      interval: \"weekly\"\n    groups:\n      dev-dependencies:\n        patterns:\n          - \"*\"\n    ignore:\n      - dependency-name: \"github.com/openshift/api\"\n      - dependency-name: \"github.com/openshift/client-go\"\n      # miss tag on v1.19.0 in 2019\n      # See https://pkg.go.dev/github.com/exoscale/egoscale?tab=versions\n      - dependency-name: \"github.com/exoscale/egoscale\"\n  - package-ecosystem: \"github-actions\"\n    directory: \"/\"\n    schedule:\n      interval: \"monthly\"\n    groups:\n      dev-dependencies:\n        patterns:\n          - \"*\"\n\n  # Dependencies listed in requirements.txt\n  - package-ecosystem: \"pip\"\n    directory: \"docs/scripts\"\n    schedule:\n      interval: \"monthly\"\n    groups:\n      mkdocs-deps:\n        patterns:\n          - \"*\"\n    labels:\n      - python\n      - dependencies\n      - ok-to-test\n      - release-note-none\n"
  },
  {
    "path": ".github/labeler.yml",
    "content": "# Add 'docs' to any changes within 'docs' folder or any subfolders\ndocs:\n  - docs/**/*\n\n# Add 'provider/alibaba' in file which starts with alibaba\nprovider/alibaba: provider/alibaba*\n\n# Add 'provider/aws' in file which starts with aws\nprovider/aws: provider/aws*\n\n# Add 'provider/azure' in file which starts with azure\nprovider/azure: provider/azure*\n\n# Add 'provider/bluecat' in file which starts with bluecat\nprovider/bluecat: provider/bluecat*\n\n# Add 'provider/cloudflare' in file which starts with cloudflare\nprovider/cloudflare: provider/cloudflare*\n\n# Add 'provider/coredns' in file which starts with coredns\nprovider/coredns: provider/coredns*\n\n# Add 'provider/designate' in file which starts with designate\nprovider/designate: provider/designate*\n\n# Add 'provider/dnssimple' in file which starts with dnssimple\nprovider/dnssimple: provider/dnssimple*\n\n# Add 'provider/dyn' in file which starts with dyn\nprovider/dyn: provider/dyn*\n\n# Add 'provider/exoscale' in file which starts with exoscale\nprovider/exoscale: provider/exoscale*\n\n# Add 'provider/transip' in file which starts with transip\nprovider/transip: provider/transip*\n\n# Add 'provider/rfc2136' in file which starts with rfc2136\nprovider/rfc2136: provider/rfc2136*\n\n# Add 'provider/rdns' in file which starts with rdns\nprovider/rdns: provider/rdns*\n\n# Add 'provider/powerdns' in file which starts with pdns\nprovider/powerdns: provider/pdns*\n\n# Add 'provider/google' in file which starts with google\nprovider/google: provider/google*\n\n# Add 'provider/infoblox' in file which starts with infoblox\nprovider/infoblox: provider/infoblox*\n\n# Add 'provider/linode' in file which starts with linode\nprovider/linode: provider/linode*\n\n# Add 'provider/ns1' in file which starts with ns1\nprovider/ns1: provider/ns1*\n\n# Add 'provider/oci' in file which starts with oci\nprovider/oci: provider/oci*\n\n# Add 'provider/vinyldns' in file which starts with vinyldns\nprovider/vinyldns: provider/vinyldns*\n\n# Add 'provider/vultr' in file which starts with vultr\nprovider/vultr: provider/vultr*\n\n# Add 'provider/ultradns' in file which starts with ultradns\nprovider/ultradns: provider/ultradns*\n"
  },
  {
    "path": ".github/pull_request_template.md",
    "content": "## What does it do ?\n\n<!-- A brief description of the change being made with this pull request. -->\n\n## Motivation\n\n<!-- What inspired you to submit this pull request? -->\n\n## More\n\n- [ ] Yes, this PR title follows [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)\n- [ ] Yes, I added unit tests\n- [ ] Yes, I updated end user documentation accordingly\n\n<!--\n    Please read https://github.com/kubernetes-sigs/external-dns#contributing before submitting\n    your pull request. Please fill in each section below to help us better prioritize your pull request. Thanks!\n-->\n"
  },
  {
    "path": ".github/renovate-config.js",
    "content": "\"use strict\";\n// https://github.com/renovatebot/github-action/blob/main/.github/renovate.json\n// https://docs.renovatebot.com/configuration-options/\n\nmodule.exports = {\n  \"extends\": [\":disableRateLimiting\", \":semanticCommits\"],\n  \"assigneesFromCodeOwners\": true,\n  \"gitAuthor\": \"Renovate Bot <bot@external-dns.com>\",\n  \"onboarding\": false,\n  \"platform\": \"github\",\n  \"repositories\": [\n    \"kubernetes-sigs/external-dns\"\n  ],\n  \"printConfig\": false,\n  \"prConcurrentLimit\": 0,\n  \"prHourlyLimit\": 0,\n  \"stabilityDays\": 3,\n  \"pruneStaleBranches\": true,\n  \"recreateClosed\": true,\n  \"dependencyDashboard\": false,\n  \"requireConfig\": false,\n  \"rebaseWhen\": \"behind-base-branch\",\n  \"baseBranches\": [\"master\", \"main\"],\n  \"recreateWhen\": \"always\",\n  \"semanticCommits\": \"enabled\",\n  \"pre-commit\": {\n    \"enabled\": true\n  },\n  \"labels\": [\"{{depType}}\", \"datasource::{{datasource}}\", \"type::{{updateType}}\", \"manager::{{manager}}\"], // can be overridden per packageRule\n  \"addLabels\": [\"renovate-bot\"], // cannot be overridden, any packageRule config extends this\n  \"packageRules\": [\n    {\n      \"groupName\": \"pre-commit\",\n      \"matchManagers\": [\"pre-commit\"],\n      \"addLabels\": [\"pre-commit\", \"skip-release\"]\n    },\n  ],\n  \"enabledManagers\": [ // supported managers https://docs.renovatebot.com/modules/manager/\n    \"regex\",\n    \"pre-commit\"\n  ],\n  \"customManagers\": [ // https://docs.renovatebot.com/modules/manager/regex/\n    {\n      // to capture registry.k8s.io/external-dns/external-dns:<version> in *.md files\n      \"customType\": \"regex\",\n      \"fileMatch\": [\n        \".*\\\\.md$\"\n      ],\n      \"matchStrings\": [\n        \"(?<depName>registry.k8s.io\\/external-dns\\/external-dns):(?<currentValue>.*)\"\n      ],\n      \"depNameTemplate\": \"kubernetes-sigs/external-dns\",\n      \"datasourceTemplate\": \"github-releases\",\n      \"versioningTemplate\": \"semver\"\n    },\n    {\n      \"customType\": \"regex\",\n      \"fileMatch\": [\".*\"],\n      \"matchStrings\": [\n        \"datasource=(?<datasource>.*?) depName=(?<depName>.*?)( versioning=(?<versioning>.*?))?\\\\s.*?_VERSION=(?<currentValue>.*)\\\\s\"\n      ],\n      \"versioningTemplate\": \"{{#if versioning}}{{{versioning}}}{{else}}semver{{/if}}\",\n    },\n  ]\n};\n"
  },
  {
    "path": ".github/renovate.json",
    "content": "{\n  \"$schema\": \"https://docs.renovatebot.com/renovate-schema.json\"\n}\n"
  },
  {
    "path": ".github/workflows/OWNERS",
    "content": "# See the OWNERS docs at https://go.k8s.io/owners\n\nlabels:\n- github_actions\n"
  },
  {
    "path": ".github/workflows/ci.yaml",
    "content": "name: Go\n\non:\n  push:\n    branches: [ master ]\n  pull_request:\n    branches: [ master ]\n\npermissions:\n  contents: read  #  to fetch code (actions/checkout)\n\njobs:\n\n  test:\n    permissions:\n      contents: read  #  to fetch code (actions/checkout)\n      checks: write  #  to create a new check based on the results (shogo82148/actions-goveralls)\n\n    name: Test\n    runs-on: ${{ matrix.os }}\n    strategy:\n      matrix:\n        # tests for target OS\n        os: [ubuntu-latest, macos-latest]\n    steps:\n\n    - name: Check out code into the Go module directory\n      uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n\n    - name: Set up Go 1.x\n      uses: actions/setup-go@v6.3.0\n      with:\n        go-version-file: go.mod\n        check-latest: true\n      id: go\n\n    - name: Install Dependencies\n      run: |\n        go get -v -t -d ./...\n\n    - name: Test\n      env:\n        GOMAXPROCS: 4\n        GOMEMLIMIT: 8192MiB\n      run: make go-test\n\n    - name: Send coverage\n      uses: coverallsapp/github-action@v2\n      with:\n        file: profile.cov\n        format: golang\n        flag-name: run-${{ join(matrix.*, '-') }}\n        parallel: true\n      continue-on-error: true\n\n  finish:\n    needs: test\n    if: ${{ always() }}\n    runs-on: ubuntu-latest\n    steps:\n    - name: Coveralls Finished\n      uses: coverallsapp/github-action@v2\n      with:\n        parallel-finished: true\n        carryforward: \"run-ubuntu-latest,run-macos-latest\"\n      continue-on-error: true\n"
  },
  {
    "path": ".github/workflows/codeql-analysis.yaml",
    "content": "name: \"CodeQL analysis\"\n\non:\n  push:\n    branches: [ master]\n  pull_request:\n    # The branches below must be a subset of the branches above\n    branches: [ master ]\n  schedule:\n    - cron: '35 13 * * 5'\n  workflow_dispatch:\n\njobs:\n  analyze:\n    name: Analyze\n    runs-on: ubuntu-latest\n    permissions:\n      actions: read\n      contents: read\n      security-events: write\n\n    strategy:\n      fail-fast: false\n      matrix:\n        language: [ 'go' ]\n\n    steps:\n    - name: Checkout repository\n      uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n    - name: Install go version\n      uses: actions/setup-go@v6.3.0\n      with:\n        go-version-file: go.mod\n\n    # Initializes the CodeQL tools for scanning.\n    - name: Initialize CodeQL\n      uses: github/codeql-action/init@v4\n      with:\n        languages: ${{ matrix.language }}\n        # If you wish to specify custom queries, you can do so here or in a config file.\n        # By default, queries listed here will override any specified in a config file.\n        # Prefix the list here with \"+\" to use these queries and those in the config file.\n        # queries: ./path/to/local/query, your-org/your-repo/queries@main\n\n    - run: |\n        make build\n\n    - name: Perform CodeQL Analysis\n      uses: github/codeql-action/analyze@v4\n"
  },
  {
    "path": ".github/workflows/dependency-update.yaml",
    "content": "name: update-versions-with-renovate\n\non:\n  push:\n    branches: [main, master]\n  schedule:\n    # https://crontab.guru/\n    # once a day\n    - cron: '0 0 * * *'\n\njobs:\n  update-versions-with-renovate:\n    runs-on: ubuntu-latest\n    if: github.repository == 'kubernetes-sigs/external-dns'\n    steps:\n      - name: checkout\n        uses: actions/checkout@v6\n      # https://github.com/renovatebot/github-action\n      - name: self-hosted renovate\n        uses: renovatebot/github-action@v46.1.4\n        with:\n          # https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication\n          token: ${{ secrets.GITHUB_TOKEN }}\n          configurationFile: .github/renovate-config.js\n        env:\n          LOG_LEVEL: info\n"
  },
  {
    "path": ".github/workflows/docs.yaml",
    "content": "name: Release Docs\n\non:\n  push:\n    tags:\n      - \"v*\"\n  # See https://docs.github.com/fr/webhooks/webhook-events-and-payloads#workflow_dispatch\n  # Can be used to update doc with latest tag\n  workflow_dispatch:\n\npermissions: {}\n\njobs:\n  release_docs:\n    permissions:\n      contents: write  #  for mike to push\n\n    name: Release Docs\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n        with:\n          fetch-depth: 0\n\n      - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0\n        with:\n          python-version: \"3.12\"\n          cache: \"pip\"\n          cache-dependency-path: \"./docs/scripts/requirements.txt\"\n\n      - run: |\n          pip install -r docs/scripts/requirements.txt\n\n      - name: Configure Git user\n        run: |\n          git config --local user.email \"github-actions[bot]@users.noreply.github.com\"\n          git config --local user.name \"github-actions[bot]\"\n\n      - name: build and push\n        run: |\n          VERSION=\"${{ github.ref_name }}\"\n          if [[ ${{ github.event_name }} == \"workflow_dispatch\" ]]; then\n            VERSION=\"latest\"\n          fi\n          mike deploy $VERSION --push --update-aliases\n          mike set-default --push latest\n"
  },
  {
    "path": ".github/workflows/end-to-end-tests.yml",
    "content": "name: end to end test\n\non:\n  push:\n    branches:\n  pull_request:\n    branches: [ master ]\n  workflow_dispatch:\n\njobs:\n  e2e-tests:\n    runs-on: ubuntu-latest\n\n    steps:\n    - name: Checkout code\n      uses: actions/checkout@v6\n    - name: e2e\n      run: |\n        ./scripts/e2e-test.sh\n"
  },
  {
    "path": ".github/workflows/gh-workflow-approve.yaml",
    "content": "name: Approve GH Workflows\n\non:\n  pull_request_target:\n    types:\n      - labeled\n      - synchronize\n    branches:\n      - master\n\njobs:\n  approve:\n    name: Approve ok-to-test\n    if: contains(github.event.pull_request.labels.*.name, 'ok-to-test')\n    runs-on: ubuntu-latest\n    permissions:\n      actions: write\n    steps:\n      - name: Update PR\n        uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0\n        continue-on-error: true\n        with:\n          github-token: ${{ secrets.GITHUB_TOKEN }}\n          debug: ${{ secrets.ACTIONS_RUNNER_DEBUG == 'true' }}\n          script: |\n            const result = await github.rest.actions.listWorkflowRunsForRepo({\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              event: \"pull_request\",\n              status: \"action_required\",\n              head_sha: context.payload.pull_request.head.sha,\n              per_page: 100\n            });\n\n            for (var run of result.data.workflow_runs) {\n              await github.rest.actions.approveWorkflowRun({\n                owner: context.repo.owner,\n                repo: context.repo.repo,\n                run_id: run.id\n              });\n            }\n"
  },
  {
    "path": ".github/workflows/json-yaml-validate.yml",
    "content": "name: json-yaml-validate\non:\n  push:\n    branches: [ master ]\n  pull_request:\n    branches: [ master ]\n  workflow_dispatch:\n\npermissions:\n  contents: read\n  pull-requests: write # enable write permissions for pull requests\n\njobs:\n  json-yaml-validate:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n\n      - name: json-yaml-validate\n        uses: GrantBirki/json-yaml-validate@v4.0.0\n        with:\n          # ref: https://github.com/GrantBirki/json-yaml-validate?tab=readme-ov-file#inputs-\n          comment: \"true\" # enable comment mode\n          yaml_exclude_regex: \"(charts/external-dns/templates.*|mkdocs.yml)\"\n          allow_multiple_documents: \"true\"\n"
  },
  {
    "path": ".github/workflows/lint-test-chart.yaml",
    "content": "name: Lint and Test Chart\n\non:\n  pull_request:\n    branches:\n      - master\n    paths:\n      - \"charts/external-dns/**\"\n\nconcurrency:\n  group: chart-pr-${{ github.ref }}\n  cancel-in-progress: true\n\npermissions: read-all\n\njobs:\n  lint-test:\n    name: Lint and Test\n    if: github.repository == 'kubernetes-sigs/external-dns'\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n    defaults:\n      run:\n        shell: bash\n    steps:\n      - name: Checkout\n        uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n        with:\n          fetch-depth: 0\n\n      - name: Install Helm\n        uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1\n        with:\n          version: latest\n\n      - name: Configure Helm\n        run: |\n          set -euo pipefail\n\n          helm plugin install https://github.com/losisin/helm-values-schema-json.git --verify=false\n          helm plugin install https://github.com/helm-unittest/helm-unittest.git --verify=false\n\n      - name: Run Helm Schema check\n        working-directory: charts/external-dns\n        run: |\n          set -euo pipefail\n\n          helm schema\n          if [[ -n \"$(git status --porcelain --untracked-files=no)\" ]]\n          then\n            echo \"Schema not up to date. Please run helm schema and commit changes!\" >&2\n            exit 1\n          fi\n\n      - name: Install Helm Docs\n        uses: action-stars/install-tool-from-github-release@1fa61c3bea52eca3bcdb1f5c961a3b113fe7fa54 # v0.2.6\n        with:\n          github_token: ${{ secrets.GITHUB_TOKEN }}\n          owner: norwoodj\n          repository: helm-docs\n          arch_amd64: x86_64\n          os_linux: Linux\n          check_command: helm-docs --version\n          version: latest\n\n      - name: Run Helm Docs check\n        run: |\n          set -euo pipefail\n          helm-docs\n          if [[ -n \"$(git status --porcelain --untracked-files=no)\" ]]\n          then\n            echo \"Documentation not up to date. Please run helm-docs and commit changes!\" >&2\n            exit 1\n          fi\n\n      - name: Run Helm Unit Tests\n        run: |\n          set -euo pipefail\n\n          helm unittest -f 'tests/*_test.yaml' charts/external-dns\n\n      - name: Install YQ\n        uses: action-stars/install-tool-from-github-release@1fa61c3bea52eca3bcdb1f5c961a3b113fe7fa54 # v0.2.6\n        with:\n          github_token: ${{ github.token }}\n          owner: mikefarah\n          repository: yq\n          extract: false\n          filename_format: \"{name}_{os}_{arch}\"\n          check_command: yq --version\n          version: latest\n\n      - name: Install MDQ\n        uses: action-stars/install-tool-from-github-release@1fa61c3bea52eca3bcdb1f5c961a3b113fe7fa54 # v0.2.6\n        with:\n          github_token: ${{ github.token }}\n          owner: yshavit\n          repository: mdq\n          arch_amd64: x64\n          filename_format: \"{name}-{os}-{arch}.{ext}\"\n          check_command: mdq --version\n          version: latest\n\n      - name: Run CHANGELOG check\n        run: |\n          set -euo pipefail\n\n          chart_file_path=\"./charts/external-dns/Chart.yaml\"\n          changelog_file_path=\"./charts/external-dns/CHANGELOG.md\"\n          version=\"$(yq eval '.version' \"${chart_file_path}\")\"\n          entry=\"$(mdq --no-br --link-format inline \"# v${version}\" <\"${changelog_file_path}\" || true)\"\n          if [[ -z \"${entry}\" ]]\n          then\n            echo \"No CHANGELOG entry for ${chart} version ${version}!\" >&2\n            exit 1\n          fi\n\n          added=\"$(mdq --output plain \"# v${version} | # Added | -\" <\"${changelog_file_path}\" || true)\"\n          changed=\"$(mdq --output plain \"# v${version} | # Changed | -\" <\"${changelog_file_path}\" || true)\"\n          deprecated=\"$(mdq --output plain \"# v${version} | # Deprecated | -\" <\"${changelog_file_path}\" || true)\"\n          removed=\"$(mdq --output plain \"# v${version} | # Removed | -\" <\"${changelog_file_path}\" || true)\"\n          fixed=\"$(mdq --output plain \"# v${version} | # Fixed | -\" <\"${changelog_file_path}\" || true)\"\n          security=\"$(mdq --output plain \"# v${version} | # Security | -\" <\"${changelog_file_path}\" || true)\"\n\n          changes_path=\"./charts/external-dns/changes.txt\"\n          rm -f \"${changes_path}\"\n\n          old_ifs=\"${IFS}\"\n          IFS=$'\\n'\n\n          for item in ${added}; do\n            printf -- '- kind: added\\n  description: \"%s\"\\n' \"${item%.*}.\" >> \"${changes_path}\"\n          done\n\n          for item in ${changed}; do\n            printf -- '- kind: changed\\n  description: \"%s\"\\n' \"${item%.*}.\" >> \"${changes_path}\"\n          done\n\n          for item in ${deprecated}; do\n            printf -- '- kind: deprecated\\n  description: \"%s\"\\n' \"${item%.*}.\" >> \"${changes_path}\"\n          done\n\n          for item in ${removed}; do\n            printf -- '- kind: removed\\n  description: \"%s\"\\n' \"${item%.*}.\" >> \"${changes_path}\"\n          done\n\n          for item in ${fixed}; do\n            printf -- '- kind: fixed\\n  description: \"%s\"\\n' \"${item%.*}.\" >> \"${changes_path}\"\n          done\n\n          for item in ${security}; do\n            printf -- '- kind: security\\n  description: \"%s\"\\n' \"${item%.*}.\" >> \"${changes_path}\"\n          done\n\n          IFS=\"${old_ifs}\"\n\n          if [[ -f \"${changes_path}\" ]]; then\n            echo \"::group::Changes\"\n            cat \"${changes_path}\"\n            echo \"::endgroup::\"\n\n            changes=\"$(cat \"${changes_path}\")\" yq eval --inplace '.annotations[\"artifacthub.io/changes\"] |= strenv(changes)' \"${chart_file_path}\"\n            rm -f \"${changes_path}\"\n          fi\n\n      - name: Install Artifact Hub CLI\n        uses: action-stars/install-tool-from-github-release@1fa61c3bea52eca3bcdb1f5c961a3b113fe7fa54 # v0.2.6\n        with:\n          github_token: ${{ github.token }}\n          owner: artifacthub\n          repository: hub\n          name: ah\n          check_command: ah version\n          version: latest\n\n      - name: Run Artifact Hub lint\n        run: ah lint --kind helm --path ./charts/external-dns || exit 1\n\n      - name: Install Python\n        uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0\n        with:\n          token: ${{ github.token }}\n          python-version: \"3.x\"\n\n      - name: Set-up chart-testing\n        uses: helm/chart-testing-action@6ec842c01de15ebb84c8627d2744a0c2f2755c9f # v2.8.0\n\n      - name: Run chart-testing lint\n        run: ct lint --charts=./charts/external-dns --target-branch=${{ github.event.repository.default_branch }} --check-version-increment=false\n\n      - name: Create Kind cluster\n        uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # v1.14.0\n        with:\n          wait: 120s\n\n      - name: Run chart-testing install\n        run: ct install --charts=./charts/external-dns --target-branch=${{ github.event.repository.default_branch }}\n"
  },
  {
    "path": ".github/workflows/lint.yaml",
    "content": "name: Lint\n\non:\n  pull_request:\n    branches: [ master ]\n\njobs:\n  lint:\n    name: Markdown and Go\n    runs-on: ubuntu-latest\n    permissions:\n      # Required: allow read access to the content for analysis.\n      contents: read\n      # For go lang linter\n      pull-requests: read\n    steps:\n\n    - name: Check out code into the Go module directory\n      uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n\n    - name: Lint markdown\n      uses: nosborn/github-action-markdown-cli@v3.5.0\n      with:\n        files: '.'\n        config_file: \".markdownlint.json\"\n\n    - name: Set up Go\n      uses: actions/setup-go@v6.3.0\n      with:\n        go-version-file: go.mod\n\n    - name: Go formatting\n      run: |\n        if [ -z \"$(gofmt -l .)\" ]; then\n          echo -e \"All '*.go' files are properly formatted.\"\n        else\n          echo -e \"Please run 'make go-lint' to fix. Some files need formatting:\"\n          gofmt -d -l .\n          exit 1\n        fi\n\n    # https://github.com/golangci/golangci-lint-action?tab=readme-ov-file#verify\n    - name: Verify linter configuration and Lint go code\n      uses: golangci/golangci-lint-action@v9\n      with:\n        verify: true\n        args: --timeout=30m\n        version: v2.7\n\n    - uses: actions/setup-python@v6\n    # https://github.com/pre-commit/action\n    - name: Verify with pre-commit\n      uses: pre-commit/action@v3.0.1\n"
  },
  {
    "path": ".github/workflows/release-chart.yaml",
    "content": "name: Release Chart\n\non:\n  push:\n    branches:\n      - master\n    paths:\n      - \"charts/external-dns/Chart.yaml\"\n\nconcurrency:\n  group: chart-release\n  cancel-in-progress: false\n\npermissions: read-all\n\njobs:\n  release:\n    name: Release\n    if: github.repository == 'kubernetes-sigs/external-dns'\n    runs-on: ubuntu-latest\n    permissions:\n      contents: write\n    defaults:\n      run:\n        shell: bash\n    steps:\n      - name: Checkout\n        uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n        with:\n          fetch-depth: 0\n\n      - name: Install YQ\n        uses: action-stars/install-tool-from-github-release@1fa61c3bea52eca3bcdb1f5c961a3b113fe7fa54 # v0.2.6\n        with:\n          github_token: ${{ github.token }}\n          owner: mikefarah\n          repository: yq\n          extract: false\n          filename_format: \"{name}_{os}_{arch}\"\n          check_command: yq --version\n          version: latest\n\n      - name: Install MDQ\n        uses: action-stars/install-tool-from-github-release@1fa61c3bea52eca3bcdb1f5c961a3b113fe7fa54 # v0.2.6\n        with:\n          github_token: ${{ github.token }}\n          owner: yshavit\n          repository: mdq\n          arch_amd64: x64\n          filename_format: \"{name}-{os}-{arch}.{ext}\"\n          check_command: mdq --version\n          version: latest\n\n      - name: Get chart version\n        id: chart_version\n        run: |\n          set -euo pipefail\n          chart_version=\"$(grep -Po \"(?<=^version: ).+\" charts/external-dns/Chart.yaml)\"\n          echo \"version=${chart_version}\" >> $GITHUB_OUTPUT\n\n      - name: Get changelog entry\n        id: changelog_reader\n        uses: mindsers/changelog-reader-action@32aa5b4c155d76c94e4ec883a223c947b2f02656 # v2.2.3\n        with:\n          path: charts/external-dns/CHANGELOG.md\n          version: \"v${{ steps.chart_version.outputs.version }}\"\n\n      - name: Process changelog\n        id: changelog\n        run: |\n          set -euo pipefail\n\n          package_dir=\"./.cr-release-packages\"\n          mkdir -p \"${package_dir}\"\n\n          release_notes_file=\"RELEASE.md\"\n          release_notes_path=\"./charts/external-dns/${release_notes_file}\"\n\n          cat <<\"EOF\" > \"${release_notes_path}\"\n          ${{ steps.changelog_reader.outputs.changes }}\n          EOF\n\n          added=\"$(mdq --output plain '# Added | -' <\"${release_notes_path}\" || true)\"\n          changed=\"$(mdq --output plain '# Changed | -' <\"${release_notes_path}\" || true)\"\n          deprecated=\"$(mdq --output plain '# Deprecated | -' <\"${release_notes_path}\" || true)\"\n          removed=\"$(mdq --output plain '# Removed | -' <\"${release_notes_path}\" || true)\"\n          fixed=\"$(mdq --output plain '# Fixed | -' <\"${release_notes_path}\" || true)\"\n          security=\"$(mdq --output plain '# Security | -' <\"${release_notes_path}\" || true)\"\n\n          changes_path=\"./charts/external-dns/changes.txt\"\n          rm -f \"${changes_path}\"\n\n          old_ifs=\"${IFS}\"\n          IFS=$'\\n'\n\n          for item in ${added}; do\n            printf -- '- kind: added\\n  description: \"%s\"\\n' \"${item%.*}.\" >> \"${changes_path}\"\n          done\n\n          for item in ${changed}; do\n            printf -- '- kind: changed\\n  description: \"%s\"\\n' \"${item%.*}.\" >> \"${changes_path}\"\n          done\n\n          for item in ${deprecated}; do\n            printf -- '- kind: deprecated\\n  description: \"%s\"\\n' \"${item%.*}.\" >> \"${changes_path}\"\n          done\n\n          for item in ${removed}; do\n            printf -- '- kind: removed\\n  description: \"%s\"\\n' \"${item%.*}.\" >> \"${changes_path}\"\n          done\n\n          for item in ${fixed}; do\n            printf -- '- kind: fixed\\n  description: \"%s\"\\n' \"${item%.*}.\" >> \"${changes_path}\"\n          done\n\n          for item in ${security}; do\n            printf -- '- kind: security\\n  description: \"%s\"\\n' \"${item%.*}.\" >> \"${changes_path}\"\n          done\n\n          IFS=\"${old_ifs}\"\n\n          if [[ -f \"${changes_path}\" ]]; then\n            changes=\"$(cat \"${changes_path}\")\" yq eval --inplace '.annotations[\"artifacthub.io/changes\"] |= strenv(changes)' ./charts/external-dns/Chart.yaml\n            rm -f \"${changes_path}\"\n          fi\n\n          echo \"release_notes_file=${release_notes_file}\" >> \"${GITHUB_OUTPUT}\"\n\n      - name: Install Artifact Hub CLI\n        uses: action-stars/install-tool-from-github-release@1fa61c3bea52eca3bcdb1f5c961a3b113fe7fa54 # v0.2.6\n        with:\n          github_token: ${{ github.token }}\n          owner: artifacthub\n          repository: hub\n          name: ah\n          check_command: ah version\n          version: latest\n\n      - name: Run Artifact Hub lint\n        run: ah lint --kind helm --path ./charts/external-dns || exit 1\n\n      - name: Configure Git\n        run: |\n          git config user.name \"$GITHUB_ACTOR\"\n          git config user.email \"$GITHUB_ACTOR@users.noreply.github.com\"\n\n      - name: Install Helm\n        uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1\n        with:\n          version: latest\n\n      - name: Run chart-releaser\n        uses: helm/chart-releaser-action@cae68fefc6b5f367a0275617c9f83181ba54714f # v1.7.0\n        env:\n          CR_TOKEN: \"${{ github.token }}\"\n          CR_RELEASE_NAME_TEMPLATE: \"external-dns-helm-chart-{{ .Version }}\"\n          CR_RELEASE_NOTES_FILE: \"${{ steps.changelog.outputs.release_notes_file }}\"\n          CR_MAKE_RELEASE_LATEST: \"false\"\n"
  },
  {
    "path": ".github/workflows/staging-image-tester.yaml",
    "content": "name: Build all images\n\non:\n  push:\n    branches: [ master ]\n  pull_request:\n    branches: [ master ]\n\npermissions:\n  contents: read  #  to fetch code (actions/checkout)\n\njobs:\n\n  build:\n    permissions:\n      contents: read  #  to fetch code (actions/checkout)\n      checks: write  #  to create a new check based on the results (shogo82148/actions-goveralls)\n\n    name: Build\n    runs-on: ubuntu-latest\n    steps:\n\n    - name: Check out code into the Go module directory\n      uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n\n    - name: Set up Go 1.x\n      uses: actions/setup-go@v6.3.0\n      with:\n        go-version-file: go.mod\n      id: go\n\n    - name: Install CI\n      run: |\n        go get -v -t -d ./...\n\n    - name: Test\n      run: make build.image/multiarch\n"
  },
  {
    "path": ".github/workflows/validate-crd.yml",
    "content": "name: Validate CRD Generation\n\n# This workflow validates that generated CRD files are up-to-date when tool\n# dependencies change. It ensures that if go.tool.mod or go.tool.sum are updated,\n# the corresponding generated files (CRDs and deepcopy code) are also regenerated\n# and committed in the same PR.\n#\n# Why this is needed:\n# - controller-gen (from go.tool.mod) generates CRD YAML and deepcopy Go code\n# - Different versions of controller-gen may produce different output\n# - When tool versions change, generated code must be regenerated\n# - This prevents CI failures and runtime issues from stale generated code\n\non:\n  pull_request:\n    paths:\n      - 'go.tool.mod'\n      - 'go.tool.sum'\n      - 'scripts/generate-crd.sh'\n      - '**/dnsendpoints.externaldns.k8s.io.yaml'\n\npermissions:\n  contents: read\n\njobs:\n  validate-crd:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n\n      - name: Set up Go\n        uses: actions/setup-go@def8c394e3ad351a79bc93815e4a585520fe993b # v6.2.0\n        with:\n          go-version-file: 'go.mod'\n\n      - name: Regenerate CRDs\n        run: ./scripts/generate-crd.sh\n\n      - name: Check for uncommitted changes\n        id: check_changes\n        run: |\n          # Check if there are any changes to generated files\n          if ! git diff --quiet; then\n            echo \"::error::Generated CRD files are out of sync with go.tool.mod\"\n            echo \"\"\n            echo \"The following files have uncommitted changes after running 'make crd':\"\n            git diff .\n            echo \"\"\n            echo \"This usually means:\"\n            echo \"1. go.tool.mod or go.tool.sum was updated (new controller-gen version)\"\n            echo \"2. The generated CRD files were not regenerated\"\n            echo \"\"\n            echo \"To fix this:\"\n            echo \"  make crd\"\n            echo \"  git diff .\"\n            echo \"  commit, push and update your PR:\"\n            exit 1\n          fi\n\n      - name: Success\n        if: success()\n        run: |\n          echo \"✅ Generated CRD files are up-to-date\"\n"
  },
  {
    "path": ".gitignore",
    "content": "# OSX leaves these everywhere on SMB shares\n._*\n\n# OSX trash\n.DS_Store\n\n# Eclipse files\n.classpath\n.project\n.settings/**\n\n# Files generated by JetBrains IDEs, e.g. IntelliJ IDEA\n.idea/\n*.iml\n\n# Vscode files\n.vscode\n__debug_*\n\n# This is where the result of the go build goes\n/output*/\n/_output*/\n/_output\n/build\n\n# Emacs save files\n*~\n\\#*\\#\n.\\#*\n\n# Vim-related files\n[._]*.s[a-w][a-z]\n[._]s[a-w][a-z]\n*.un~\nSession.vim\n.netrwhist\n\n# cscope-related files\ncscope.*\n\n/bazel-*\n\n# coverage output\ncover.out\ncoverage.html\n*.coverprofile\nexternal-dns\n\n# vendor dir\nvendor/\n\nprofile.cov\n\n# github codespaces\n.venv/\n\n# Helm charts\n!/charts/external-dns/\n\ndocs/LICENSE.md\ndocs/code-of-conduct.md\ndocs/CONTRIBUTING.md\ndocs/index.md\ndocs/redirect\nsite\n_scratch\nPipfile\n"
  },
  {
    "path": ".golangci.yml",
    "content": "# https://golangci-lint.run/docs/configuration/\nversion: \"2\"\nlinters:\n  default: none\n  enable: # golangci-lint help linters\n    - copyloopvar # A linter detects places where loop variables are copied. https://golangci-lint.run/docs/linters/configuration/#copyloopvar\n    - dogsled # Checks assignments with too many blank identifiers. https://golangci-lint.run/docs/linters/configuration/#dogsled\n    - dupword # Duplicate word. https://golangci-lint.run/docs/linters/configuration/#dupword\n    - goprintffuncname\n    - govet\n    - ineffassign\n    - misspell\n    - revive # https://golangci-lint.run/docs/linters/configuration/#revive\n    - recvcheck # Checks for receiver type consistency. https://golangci-lint.run/docs/linters/configuration/#recvcheck\n    - rowserrcheck # Checks whether Rows.Err of rows is checked successfully.\n    - errchkjson # Checks types passed to the json encoding functions. ref: https://golangci-lint.run/docs/linters/configuration/#errchkjson\n    - errorlint # Checking for unchecked errors in Go code https://golangci-lint.run/docs/linters/configuration/#errorlint\n    - staticcheck\n    - unconvert\n    - unused # https://golangci-lint.run/docs/linters/configuration/#unused\n    - unparam # https://golangci-lint.run/docs/linters/configuration/#unparam\n    - usestdlibvars # A linter that detect the possibility to use variables/constants from the Go standard library. https://golangci-lint.run/docs/linters/configuration/#usestdlibvars\n    - whitespace\n    - decorder # Check declaration order and count of types, constants, variables and functions. https://golangci-lint.run/docs/linters/configuration/#decorder\n    - tagalign # Check that struct tags are well aligned. https://golangci-lint.run/docs/linters/configuration/#tagalign\n    - predeclared # Find code that shadows one of Go's predeclared identifiers\n    - sloglint # Ensure consistent code style when using log/slog\n    - asciicheck  # Checks that all code identifiers does not have non-ASCII symbols in the name\n    - nilnil # Checks that there is no simultaneous return of nil error and an nil value. ref: https://golangci-lint.run/docs/linters/configuration/#nilnil\n    - nonamedreturns # Checks that functions with named return values do not return named values. https://golangci-lint.run/docs/linters/configuration/#nonamedreturns\n    - cyclop # Checks function and package cyclomatic complexity. https://golangci-lint.run/docs/linters/configuration/#cyclop\n    - gocritic # Analyze source code for various issues, including bugs, performance hiccups, and non-idiomatic coding practices. https://golangci-lint.run/docs/linters/configuration/#gocritic\n    - gochecknoinits # Checks that there are no init() functions in the code. https://golangci-lint.run/docs/linters/configuration/#gochecknoinits\n    - goconst # Finds repeated strings that could be replaced by a constant. https://golangci-lint.run/docs/linters/configuration/#goconst\n    - modernize # A suite of analyzers that suggest simplifications to Go code, using modern language and library features. https://golangci-lint.run/docs/linters/configuration/#modernize\n\n    # tests\n    - testifylint # Checks usage of github.com/stretchr/testify. https://golangci-lint.run/docs/linters/configuration/#testifylint\n    - usetesting # Reports uses of functions with replacement inside the testing package.\n  settings:\n    exhaustive:\n      default-signifies-exhaustive: false\n    misspell:\n      locale: US\n    revive:\n      rules:\n        - name: confusing-naming\n          disabled: true\n        - name: unused-parameter # https://github.com/mgechev/revive/blob/HEAD/RULES_DESCRIPTIONS.md#unused-parameter\n          disabled: false\n    cyclop: # Lower cyclomatic complexity threshold after the max complexity is lowered\n      max-complexity: 32 # See https://github.com/kubernetes-sigs/external-dns/issues/5419\n    goconst:\n      min-occurrences: 3\n      # Ignore well-known DNS record types, boolean strings, and common values\n      ignore-string-values:\n        - \"^(A|AAAA|ALIAS|CNAME|MX|NS|PTR|SRV|TXT)$\"  # DNS record types\n        - \"^(true|false)$\"  # Boolean strings\n        - \"^none$\"  # Common null/empty indicator\n        - \"^(aws-sd|noop)$\"  # Registry types - can be ignored for consistency\n    testifylint:\n      # Enable all checkers (https://github.com/Antonboom/testifylint#checkers).\n      # Default: false\n      enable-all: true\n      # Disable checkers by name\n      # (in addition to default\n      #   suite-thelper\n      # ).\n      # TODO: enable in follow-up\n      disable:\n        - require-error\n    usetesting:\n      # Enable/disable `context.Background()` detections.\n      context-background: true\n      # Enable/disable `context.TODO()` detections.\n      context-todo: true\n  exclusions:\n    generated: lax\n    presets:\n      - comments\n      - common-false-positives\n      - legacy\n      - std-error-handling\n    rules:\n      - linters:\n          - deadcode\n          - depguard\n          - dogsled\n          - goprintffuncname\n          - govet\n          - ineffassign\n          - misspell\n          - nolintlint\n          - rowserrcheck\n          - staticcheck\n          - structcheck\n          - unconvert\n          - varcheck\n          - whitespace\n          - goconst\n        path: _test\\.go\n      # TODO: skiip as will require design changes\n      - linters:\n          - nilnil\n        path: istio_virtualservice.go|fqdn.go|cloudflare_custom_hostnames.go\n      - linters:\n          - gochecknoinits\n        path: ^(internal/.*/init\\.go|.*/metrics\\.go|.*/webhook\\.go|.*/http\\.go|apis/.*\\.go|.*/cached_provider\\.go)$\n      - linters:\n          - modernize\n        path: ^(apis/.*\\.go)$\n    paths:\n      - endpoint/zz_generated.deepcopy.go\n      - third_party$\n      - builtin$\n      - examples$\nformatters:\n  enable:\n    - gofmt\n    - goimports\n  settings:\n    goimports:\n      local-prefixes:\n        - sigs.k8s.io/external-dns\n  exclusions:\n    generated: lax\n    paths:\n      - third_party$\n      - builtin$\n      - examples$\n"
  },
  {
    "path": ".ko.yaml",
    "content": "defaultBaseImage: gcr.io/distroless/static-debian12:latest\nbuilds:\n- env:\n  - CGO_ENABLED=0\n  flags:\n  - -v\n  ldflags:\n  - -s\n  - -w\n  - -X sigs.k8s.io/external-dns/pkg/apis/externaldns.Version={{.Env.VERSION}}\n"
  },
  {
    "path": ".markdownlint.json",
    "content": "{\n    \"default\": true,\n    \"MD010\": { \"code_blocks\": false },\n    \"MD013\": { \"line_length\": \"300\" },\n    \"MD033\": false,\n    \"MD036\": false,\n    \"MD024\": false,\n    \"MD041\": false,\n    \"MD029\": false,\n    \"MD034\": false,\n    \"MD038\": false,\n    \"MD046\": false\n}\n"
  },
  {
    "path": ".pre-commit-config.yaml",
    "content": "---\ndefault_language_version:\n  node: system\n\nrepos:\n  - repo: https://github.com/pre-commit/pre-commit-hooks\n    rev: v6.0.0\n    hooks:\n      - id: check-added-large-files\n      - id: check-case-conflict\n      - id: check-executables-have-shebangs\n      - id: check-merge-conflict\n      - id: check-shebang-scripts-are-executable\n      - id: check-symlinks\n      - id: destroyed-symlinks\n      - id: end-of-file-fixer\n      - id: fix-byte-order-marker\n      - id: forbid-new-submodules\n      - id: mixed-line-ending\n      - id: trailing-whitespace\n\n  - repo: https://github.com/igorshubovych/markdownlint-cli\n    rev: v0.47.0\n    hooks:\n    - id: markdownlint\n      args: [\"--fix\"]\n\nminimum_pre_commit_version: !!str 3.2\n"
  },
  {
    "path": ".spectral.yaml",
    "content": "extends: [\"spectral:oas\"]\n"
  },
  {
    "path": ".zappr.yaml",
    "content": "X-Zalando-Team: teapot\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing Guidelines\n\nWelcome to Kubernetes. We are excited about the prospect of you joining our [community](https://git.k8s.io/community)! The Kubernetes community abides by the CNCF [code of conduct](code-of-conduct.md). Here is an excerpt:\n\n_In the interest of fostering an open and welcoming community, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or other activities._\n\n## Getting Started\n\nWe have full documentation on how to get started contributing here:\n\n- [Contributor License Agreement](https://git.k8s.io/community/CLA.md) Kubernetes projects require that you sign a Contributor License Agreement (CLA) before we can accept your pull requests\n- [Kubernetes Contributor Guide](https://git.k8s.io/community/contributors/guide) - Main contributor documentation, or you can just jump directly to the [contributing section](https://git.k8s.io/community/contributors/guide#contributing)\n- [Contributor Cheat Sheet](https://git.k8s.io/community/contributors/guide/contributor-cheatsheet) - Common resources for existing developers\n\n## Developer Documentation\n\nFor more detailed contribution guides, see [Developer Documentation](docs/contributing) which includes:\n\n- [Development Guide](docs/contributing/dev-guide.md) - Setting up development environment, building, and testing\n- [Chart Development](docs/contributing/chart.md) - Working with Helm charts\n- [Design Documentation](docs/contributing/design.md) - Architecture and design decisions\n- [Sources and Providers](docs/contributing/sources-and-providers.md) - Adding new sources and providers\n- [Source Wrappers](docs/contributing/source-wrappers.md) - Source wrapper implementation details\n\nThis project follows the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification on PR title. The explicit commit history is used, among other things, to provide a readable changelog in release notes.\n\n## How to test a PR\n\nOn Linux (or WSL), a PR can be tested following this instruction with [gh](https://cli.github.com/) and [golang](https://go.dev/):\n\n```bash\ngh repo clone kubernetes-sigs/external-dns\ncd external-dns\ngh pr checkout XXX # <=== Set PR number here\ngo run main.go \\\n  --kubeconfig=<kubeconfig_path> \\\n  --log-format=text \\\n  --log-level=debug \\\n  --interval=1m\n  --provider=xxx\n  --source=yyy\n```\n\n## Mentorship\n\n- [Mentoring Initiatives](https://git.k8s.io/community/mentoring) - We have a diverse set of mentorship programs available that are always looking for volunteers!\n\n## Contact Information\n\n- [Slack channel](https://kubernetes.slack.com/messages/external-dns)\n- [Mailing list](https://groups.google.com/forum/#!forum/kubernetes-sig-network)\n"
  },
  {
    "path": "LICENSE.md",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"{}\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright {yyyy} {name of copyright owner}\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "Makefile",
    "content": "# Copyright 2017 The Kubernetes Authors.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n#? cover: Creates coverage report for whole project excluding vendor and opens result in the default browser\n.PHONY: cover cover-html\n.DEFAULT_GOAL := build\n\ncover:\n\t@go test -cover -coverprofile=cover.out -v ./...\n\n#? cover-html: Run tests with coverage and open coverage report in the browser\ncover-html: cover\n\t@go tool cover -html=cover.out\n\n#? go-tools: list installed go tools\ngo-tools:\n\t@echo \">> go tools installed in go.mod\"\n\t@go tool  -n\n\t@echo \">> go tools installed in go.tool.mod\"\n\t@go tool -modfile=go.tool.mod\n\n#? golangci-lint-install: Install golangci-lint tool\ngolangci-lint-install:\n\t@scripts/install-tools.sh --golangci\n\n#? go-lint: Run the golangci-lint tool\n.PHONY: go-lint\ngo-lint: golangci-lint-install\n\tgolangci-lint config verify\n\tgofmt -l -s -w .\n\tgolangci-lint run --timeout=30m --fix ./...\n\n#? licensecheck: Run the to check for license headers\n.PHONY: licensecheck\nlicensecheck:\n\t@echo \">> checking license header\"\n\t@licRes=$$(for file in $$(find . -type f -iname '*.go' ! -path './vendor/*') ; do \\\n\t\t\tawk 'NR<=5' $$file | grep -Eq \"(Copyright|generated|GENERATED)\" || echo $$file; \\\n\t\tdone); \\\n\t\tif [ -n \"$${licRes}\" ]; then \\\n\t\t\techo \"license header checking failed:\"; echo \"$${licRes}\"; \\\n\t\t\texit 1; \\\n\t\tfi\n\n#? lint: Run all the linters\n.PHONY: lint\nlint: licensecheck go-lint\n\n#? crd: Generates CRD using controller-gen and copy it into chart\n.PHONY: crd\ncrd:\n\t@./scripts/generate-crd.sh\n\n# Required as long as dependabot does not support go.tool.mod https://github.com/dependabot/dependabot-core/issues/12050\n#? update-tools-deps: Update go tools defined in go.tool.mod to latest versions\nupdate-tools-deps:\n\t@go get -modfile=go.tool.mod tool\n\n#? test: The verify target runs tasks similar to the CI tasks, but without code coverage\n.PHONY: test\ntest:\n\tgo test -race ./...\n\n\n.PHONY: test\ngo-test:\n\tgo test -race -coverprofile=profile.cov ./...\n\tgo tool cover -func=profile.cov > coverage.summary\n\t@tail -n 1 coverage.summary\n\n#? build: The build targets allow to build the binary and container image\n.PHONY: build\n\nBINARY        ?= external-dns\nSOURCES        = $(shell find . -name '*.go')\nIMAGE_STAGING  = gcr.io/k8s-staging-external-dns/$(BINARY)\nREGISTRY      ?= us.gcr.io/k8s-artifacts-prod/external-dns\nIMAGE         ?= $(REGISTRY)/$(BINARY)\nVERSION       ?= $(shell git describe --tags --always --dirty --match \"v*\")\nGIT_COMMIT    ?= $(shell git rev-parse --short HEAD)\nBUILD_FLAGS   ?= -v\nLDFLAGS       ?= -X sigs.k8s.io/external-dns/pkg/apis/externaldns.Version=$(VERSION) -w -s\nLDFLAGS       += -X sigs.k8s.io/external-dns/pkg/apis/externaldns.GitCommit=$(GIT_COMMIT)\nARCH          ?= amd64\nSHELL          = /bin/bash\nIMG_PLATFORM  ?= linux/amd64,linux/arm64,linux/arm/v7\nIMG_PUSH      ?= true\nIMG_SBOM      ?= none\n\nbuild: build/$(BINARY)\n\nbuild/$(BINARY): $(SOURCES)\n\tCGO_ENABLED=0 go build -o build/$(BINARY) $(BUILD_FLAGS) -ldflags \"$(LDFLAGS)\" .\n\nbuild.push/multiarch: ko\n\tKO_DOCKER_REPO=${IMAGE} \\\n\tVERSION=${VERSION} \\\n\tko build --tags ${VERSION} --bare --sbom ${IMG_SBOM} \\\n\t\t--image-label org.opencontainers.image.source=\"https://github.com/kubernetes-sigs/external-dns\" \\\n\t\t--image-label org.opencontainers.image.revision=$(shell git rev-parse HEAD) \\\n\t\t--platform=${IMG_PLATFORM}  --push=${IMG_PUSH} .\n\nbuild.image/multiarch:\n\t$(MAKE) IMG_PUSH=false build.push/multiarch\n\nbuild.image:\n\t$(MAKE) IMG_PLATFORM=linux/$(ARCH) build.image/multiarch\n\nbuild.image-amd64:\n\t$(MAKE) ARCH=amd64 build.image\n\nbuild.image-arm64:\n\t$(MAKE) ARCH=arm64 build.image\n\nbuild.image-arm/v7:\n\t$(MAKE) ARCH=arm/v7 build.image\n\nbuild.push:\n\t$(MAKE) IMG_PLATFORM=linux/$(ARCH) build.push/multiarch\n\nbuild.push-amd64:\n\t$(MAKE) ARCH=amd64 build.push\n\nbuild.push-arm64:\n\t$(MAKE) ARCH=arm64 build.push\n\nbuild.push-arm/v7:\n\t$(MAKE) ARCH=arm/v7 build.push\n\nbuild.arm64:\n\tCGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o build/$(BINARY) $(BUILD_FLAGS) -ldflags \"$(LDFLAGS)\" .\n\nbuild.amd64:\n\tCGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o build/$(BINARY) $(BUILD_FLAGS) -ldflags \"$(LDFLAGS)\" .\n\nbuild.arm/v7:\n\tCGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -o build/$(BINARY) $(BUILD_FLAGS) -ldflags \"$(LDFLAGS)\" .\n\nclean:\n\t@rm -rf build\n\t@go clean -cache\n\n.PHONY: release.staging\n#? release.staging: Builds and push container images to the staging bucket.\nrelease.staging: test\n\tIMAGE=$(IMAGE_STAGING) $(MAKE) build.push/multiarch\n\nrelease.prod: test\n\t$(MAKE) build.push/multiarch\n\n.PHONY: ko\nko:\n\tscripts/install-ko.sh\n\n.PHONY: generate-flags-documentation\n#? generate-flags-documentation: Generate documentation (docs/flags.md)\ngenerate-flags-documentation:\n\tgo run internal/gen/docs/flags/main.go\n\n.PHONY: generate-metrics-documentation\n#? generate-metrics-documentation: Generate documentation (docs/monitoring/metrics.md)\ngenerate-metrics-documentation:\n\tgo run internal/gen/docs/metrics/main.go\n\n.PHONY: generate-sources-documentation\n#? generate-sources-documentation: Generate documentation (docs/sources/index.md)\ngenerate-sources-documentation:\n\tgo run internal/gen/docs/sources/main.go\n\n#? pre-commit-install: Install pre-commit hooks\npre-commit-install:\n\t@pre-commit install\n\t@pre-commit gc\n\n#? pre-commit-uninstall: Uninstall pre-commit hooks\npre-commit-uninstall:\n\t@pre-commit uninstall\n\n#? pre-commit-validate: Validate files with pre-commit hooks\npre-commit-validate:\n\t@pre-commit run --all-files\n\n.PHONY: help\n#? help: Get more info on available commands\nhelp: Makefile\n\t@sed -n 's/^#?//p' $< | column -t -s ':' |  sort | sed -e 's/^/ /'\n\n#? helm-test: Run unit tests\nhelm-test:\n\tscripts/helm-tools.sh --helm-unittest\n\n#? helm-template: Run helm template\nhelm-template:\n\tscripts/helm-tools.sh --helm-template\n\n#? helm-lint: Run helm linting (schema,docs)\nhelm-lint:\n\tscripts/helm-tools.sh --schema\n\tscripts/helm-tools.sh --docs\n\n.PHONY: go-dependency\n#? go-dependency: Dependency maintanance\ngo-dependency:\n\tgo mod tidy\n\n.PHONY: mkdocs-serve\n#? mkdocs-serve: Run the builtin development server for mkdocs\nmkdocs-serve:\n\t@$(info \"contribute to documentation docs/contributing/dev-guide.md\")\n\t@mkdocs serve\n"
  },
  {
    "path": "OWNERS",
    "content": "# See the OWNERS file documentation:\n#  https://github.com/kubernetes/community/blob/HEAD/contributors/guide/owners.md\n\n## These OWNERS files should stay in sync:\n# https://github.com/kubernetes/test-infra/blob/master/config/jobs/kubernetes-sigs/external-dns/OWNERS\n# https://github.com/kubernetes/k8s.io/blob/master/registry.k8s.io/images/k8s-staging-external-dns/OWNERS\n\n## with this GitHub teams file:\n# https://github.com/kubernetes/org/blob/main/config/kubernetes-sigs/sig-network/teams.yaml\n\napprovers:\n  - ivankatliarchuk\n  - mloiseleur\n  - raffo\n  - szuecs\n\nreviewers:\n  - ivankatliarchuk\n  - mloiseleur\n  - raffo\n  - szuecs\n  - vflaux\n\nemeritus_approvers:\n  - hjacobs\n  - johngmyers\n  - linki\n  - njuettner\n  - seanmalloy\n"
  },
  {
    "path": "README.md",
    "content": "---\nhide:\n  - toc\n  - navigation\n---\n\n<p align=\"center\">\n <img src=\"docs/img/external-dns.png\" width=\"40%\" align=\"center\" alt=\"ExternalDNS\">\n</p>\n\n# ExternalDNS\n\n[![Build Status](https://github.com/kubernetes-sigs/external-dns/workflows/Go/badge.svg)](https://github.com/kubernetes-sigs/external-dns/actions)\n[![Coverage Status](https://coveralls.io/repos/github/kubernetes-sigs/external-dns/badge.svg)](https://coveralls.io/github/kubernetes-sigs/external-dns)\n[![GitHub release](https://img.shields.io/github/release/kubernetes-sigs/external-dns.svg)](https://github.com/kubernetes-sigs/external-dns/releases)\n[![go-doc](https://godoc.org/github.com/kubernetes-sigs/external-dns?status.svg)](https://godoc.org/github.com/kubernetes-sigs/external-dns)\n[![Go Report Card](https://goreportcard.com/badge/github.com/kubernetes-sigs/external-dns)](https://goreportcard.com/report/github.com/kubernetes-sigs/external-dns)\n[![ExternalDNS docs](https://img.shields.io/badge/docs-external--dns-blue)](https://kubernetes-sigs.github.io/external-dns/)\n[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/kubernetes-sigs/external-dns)\n\nExternalDNS synchronizes exposed Kubernetes Services and Ingresses with DNS providers.\n\n## Documentation\n\nThis README is a part of the complete [documentation, available here](https://kubernetes-sigs.github.io/external-dns/) and [DeepWiki](https://deepwiki.com/kubernetes-sigs/external-dns).\n\n## What It Does\n\nInspired by [Kubernetes DNS](https://github.com/kubernetes/dns), Kubernetes' cluster-internal DNS server, ExternalDNS makes Kubernetes resources discoverable via public DNS servers.\nLike KubeDNS, it retrieves a list of resources (Services, Ingresses, etc.) from the [Kubernetes API](https://kubernetes.io/docs/api/) to determine a desired list of DNS records.\n_Unlike_ KubeDNS, however, it's not a DNS server itself, but merely configures other DNS providers accordingly—e.g. [AWS Route 53](https://aws.amazon.com/route53/) or [Google Cloud DNS](https://cloud.google.com/dns/docs/).\n\nIn a broader sense, ExternalDNS allows you to control DNS records dynamically via Kubernetes resources in a DNS provider-agnostic way.\n\nThe [FAQ](docs/faq.md) contains additional information and addresses several questions about key concepts of ExternalDNS.\n\nTo see ExternalDNS in action, have a look at this [video](https://www.youtube.com/watch?v=9HQ2XgL9YVI) or read this [blogpost](https://codemine.be/posts/20190125-devops-eks-externaldns/).\n\n## The Latest Release\n\n- [current release process](./docs/release.md)\n\nExternalDNS allows you to keep selected zones (via `--domain-filter`) synchronized with Ingresses and Services of `type=LoadBalancer` and nodes in various DNS providers:\n\n- [Google Cloud DNS](https://cloud.google.com/dns/docs/)\n- [AWS Route 53](https://aws.amazon.com/route53/)\n- [AWS Cloud Map](https://docs.aws.amazon.com/cloud-map/)\n- [AzureDNS](https://azure.microsoft.com/en-us/services/dns)\n- [Civo](https://www.civo.com)\n- [CloudFlare](https://www.cloudflare.com/dns)\n- [DNSimple](https://dnsimple.com/)\n- [PowerDNS](https://www.powerdns.com/)\n- [CoreDNS](https://coredns.io/)\n- [Exoscale](https://www.exoscale.com/dns/)\n- [Oracle Cloud Infrastructure DNS](https://docs.cloud.oracle.com/iaas/Content/DNS/Concepts/dnszonemanagement.htm)\n- [Linode DNS](https://www.linode.com/docs/networking/dns/)\n- [RFC2136](https://tools.ietf.org/html/rfc2136)\n- [NS1](https://ns1.com/)\n- [TransIP](https://www.transip.eu/domain-name/)\n- [OVHcloud](https://www.ovhcloud.com)\n- [Scaleway](https://www.scaleway.com)\n- [Akamai Edge DNS](https://learn.akamai.com/en-us/products/cloud_security/edge_dns.html)\n- [GoDaddy](https://www.godaddy.com)\n- [Gandi](https://www.gandi.net)\n- [Plural](https://www.plural.sh/)\n- [Pi-hole](https://pi-hole.net/)\n- [Alibaba Cloud DNS](https://www.alibabacloud.com/help/en/dns)\n- [Myra Security DNS](https://www.myrasecurity.com/en/saasp/application-security/secure-dns/)\n\nExternalDNS is, by default, aware of the records it is managing, therefore it can safely manage non-empty hosted zones.\nWe strongly encourage you to set `--txt-owner-id` to a unique value that doesn't change for the lifetime of your cluster.\nYou might also want to run ExternalDNS in a dry run mode (`--dry-run` flag) to see the changes to be submitted to your DNS Provider API.\n\nNote that all flags can be replaced with environment variables; for instance,\n`--dry-run` could be replaced with `EXTERNAL_DNS_DRY_RUN=1`.\n\n## New providers\n\nNo new provider will be added to ExternalDNS _in-tree_.\n\nExternalDNS has introduced a webhook system, which can be used to add a new provider.\nSee PR #3063 for all the discussions about it.\n\nSome known providers using webhooks are the ones in the table below.\n\n**NOTE**: The maintainers of ExternalDNS have not reviewed those providers, use them at your own risk and following the license\nand usage recommendations provided by the respective projects. The maintainers of ExternalDNS take no responsibility for any issue or damage\nfrom the usage of any externally developed webhook.\n\n| Provider              | Repo                                                                 |\n| --------------------- | -------------------------------------------------------------------- |\n| Abion                 | https://github.com/abiondevelopment/external-dns-webhook-abion       |\n| Adguard Home Provider | https://github.com/muhlba91/external-dns-provider-adguard            |\n| Anexia                | https://github.com/anexia/k8s-external-dns-webhook                   |\n| Bizfly Cloud          | https://github.com/bizflycloud/external-dns-bizflycloud-webhook      |\n| ClouDNS               | https://github.com/rwunderer/external-dns-cloudns-webhook            |\n| deSEC                 | https://github.com/michelangelomo/external-dns-desec-provider        |\n| DigitalOcean          | https://github.com/amoniacou/external-dns-digitalocean-webhook       |\n| Dreamhost             | https://github.com/asymingt/external-dns-dreamhost-webhook           |\n| Efficient IP          | https://github.com/EfficientIP-Labs/external-dns-efficientip-webhook |\n| Gcore                 | https://github.com/G-Core/external-dns-gcore-webhook                 |\n| GleSYS                | https://github.com/glesys/external-dns-glesys                        |\n| Hetzner               | https://github.com/mconfalonieri/external-dns-hetzner-webhook        |\n| Huawei Cloud          | https://github.com/setoru/external-dns-huaweicloud-webhook           |\n| IONOS                 | https://github.com/ionos-cloud/external-dns-ionos-webhook            |\n| Infoblox              | https://github.com/AbsaOSS/external-dns-infoblox-webhook             |\n| Infomaniak            | https://github.com/M0NsTeRRR/external-dns-webhook-infomaniak         |\n| Mikrotik              | https://github.com/mirceanton/external-dns-provider-mikrotik         |\n| Myra Security         | https://github.com/Myra-Security-GmbH/external-dns-myrasec-webhook   |\n| Netcup                | https://github.com/mrueg/external-dns-netcup-webhook                 |\n| Netic                 | https://github.com/neticdk/external-dns-tidydns-webhook              |\n| OpenStack Designate   | https://github.com/inovex/external-dns-designate-webhook             |\n| OpenWRT               | https://github.com/renanqts/external-dns-openwrt-webhook             |\n| PS Cloud Services     | https://github.com/supervillain3000/external-dns-pscloud-webhook     |\n| SAKURA Cloud          | https://github.com/sacloud/external-dns-sacloud-webhook              |\n| Simply                | https://github.com/uozalp/external-dns-simply-webhook                |\n| STACKIT               | https://github.com/stackitcloud/external-dns-stackit-webhook         |\n| Unbound               | https://github.com/guillomep/external-dns-unbound-webhook            |\n| Unifi                 | https://github.com/kashalls/external-dns-unifi-webhook               |\n| UniFi                 | https://github.com/lexfrei/external-dns-unifios-webhook              |\n| Volcengine Cloud      | https://github.com/volcengine/external-dns-volcengine-webhook        |\n| Vultr                 | https://github.com/vultr/external-dns-vultr-webhook                  |\n| Yandex Cloud          | https://github.com/ismailbaskin/external-dns-yandex-webhook/         |\n\n## Status of in-tree providers\n\nExternalDNS supports multiple DNS providers which have been implemented by the [ExternalDNS contributors](https://github.com/kubernetes-sigs/external-dns/graphs/contributors).\nMaintaining all of those in a central repository is a challenge, which introduces lots of toil and potential risks.\n\nThis mean that `external-dns` has begun the process to move providers out of tree. See #4347 for more details.\nThose who are interested can create a webhook provider based on an _in-tree_ provider and after submit a PR to reference it here.\n\nWe define the following stability levels for providers:\n\n- **Stable**: Used for smoke tests before a release, used in production and maintainers are active.\n- **Beta**: Community supported, well tested, but maintainers have no access to resources to execute integration tests on the real platform and/or are not using it in production.\n- **Alpha**: Community provided with no support from the maintainers apart from reviewing PRs.\n\nThe following table clarifies the current status of the providers according to the aforementioned stability levels:\n\n| Provider                        | Status | Maintainers      |\n|---------------------------------| ------ |------------------|\n| Google Cloud DNS                | Stable |                  |\n| AWS Route 53                    | Stable |                  |\n| AWS Cloud Map                   | Beta   |                  |\n| Akamai Edge DNS                 | Beta   |                  |\n| AzureDNS                        | Stable |                  |\n| Civo                            | Alpha  | @alejandrojnm    |\n| CloudFlare                      | Beta   |                  |\n| DNSimple                        | Alpha  |                  |\n| PowerDNS                        | Alpha  |                  |\n| CoreDNS                         | Alpha  |                  |\n| Exoscale                        | Alpha  |                  |\n| Oracle Cloud Infrastructure DNS | Alpha  |                  |\n| Linode DNS                      | Alpha  |                  |\n| RFC2136                         | Alpha  |                  |\n| NS1                             | Alpha  |                  |\n| TransIP                         | Alpha  |                  |\n| OVHcloud                        | Beta   | @rbeuque74       |\n| Scaleway DNS                    | Alpha  | @Sh4d1           |\n| GoDaddy                         | Alpha  |                  |\n| Gandi                           | Alpha  | @packi           |\n| Plural                          | Alpha  | @michaeljguarino |\n| Pi-hole                         | Alpha  | @tinyzimmer      |\n| Alibaba Cloud DNS               | Alpha  |                  |\n\n## Kubernetes version compatibility\n\nBreaking changes were introduced in external-dns in the following versions:\n\n- [`v0.10.0`](https://github.com/kubernetes-sigs/external-dns/releases/tag/v0.10.0): use of `networking.k8s.io/ingresses` instead of `extensions/ingresses` (see [#2281](https://github.com/kubernetes-sigs/external-dns/pull/2281))\n- [`v0.18.0`](https://github.com/kubernetes-sigs/external-dns/releases/tag/v0.18.0): use of `discovery.k8s.io/endpointslices` instead of `endpoints` (see [#5493](https://github.com/kubernetes-sigs/external-dns/pull/5493))\n- [`v0.19.0`](https://github.com/kubernetes-sigs/external-dns/releases/tag/v0.19.0): don't expose internal ipv6 by default (see [#5575](https://github.com/kubernetes-sigs/external-dns/pull/5575)) and disable legacy listeners on `traefik.containo.us` API Group (see [#5565](https://github.com/kubernetes-sigs/external-dns/pull/5565))\n\n| ExternalDNS                  |      ≤ 0.9.x       | ≥ 0.10.x and ≤ 0.17.x |      ≥ 0.18.x      |\n| ---------------------------- | :----------------: | :-------------------: | :----------------: |\n| Kubernetes ≤ 1.18            | :white_check_mark: |          :x:          |        :x:         |\n| Kubernetes 1.19 and 1.20     | :white_check_mark: |  :white_check_mark:   |        :x:         |\n| Kubernetes 1.21              | :white_check_mark: |  :white_check_mark:   | :white_check_mark: |\n| Kubernetes ≥ 1.22 and ≤ 1.32 |        :x:         |  :white_check_mark:   | :white_check_mark: |\n| Kubernetes ≥ 1.33            |        :x:         |          :x:          | :white_check_mark: |\n\n## Running ExternalDNS\n\nThere are two ways of running ExternalDNS:\n\n- Deploying to a Cluster\n- Running Locally\n\n### Deploying to a Cluster\n\nThe following tutorials are provided:\n\n- [Akamai Edge DNS](docs/tutorials/akamai-edgedns.md)\n- [Alibaba Cloud](docs/tutorials/alibabacloud.md)\n- AWS\n  - [AWS Load Balancer Controller](docs/tutorials/aws-load-balancer-controller.md)\n  - [Route53](docs/tutorials/aws.md)\n    - [Same domain for public and private Route53 zones](docs/tutorials/aws-public-private-route53.md)\n  - [Cloud Map](docs/tutorials/aws-sd.md)\n  - [Kube Ingress AWS Controller](docs/tutorials/kube-ingress-aws.md)\n- [Azure DNS](docs/tutorials/azure.md)\n- [Azure Private DNS](docs/tutorials/azure-private-dns.md)\n- [Civo](docs/tutorials/civo.md)\n- [Cloudflare](docs/tutorials/cloudflare.md)\n- [CoreDNS](docs/tutorials/coredns.md)\n- [DNSimple](docs/tutorials/dnsimple.md)\n- [Exoscale](docs/tutorials/exoscale.md)\n- [ExternalName Services](docs/tutorials/externalname.md)\n- Google Kubernetes Engine\n  - [Using Google's Default Ingress Controller](docs/tutorials/gke.md)\n  - [Using the Nginx Ingress Controller](docs/tutorials/gke-nginx.md)\n- [Headless Services](docs/tutorials/hostport.md)\n- [IONOS Cloud](docs/tutorials/ionoscloud.md)\n- [Istio Gateway Source](docs/sources/istio.md)\n- [Linode](docs/tutorials/linode.md)\n- [Myra Security](docs/tutorials/myra.md)\n- [NS1](docs/tutorials/ns1.md)\n- [NS Record Creation with CRD Source](docs/sources/ns-record.md)\n- [MX Record Creation with CRD Source](docs/sources/mx-record.md)\n- [TXT Record Creation with CRD Source](docs/sources/txt-record.md)\n- [Oracle Cloud Infrastructure (OCI) DNS](docs/tutorials/oracle.md)\n- [PowerDNS](docs/tutorials/pdns.md)\n- [RFC2136](docs/tutorials/rfc2136.md)\n- [TransIP](docs/tutorials/transip.md)\n- [OVHcloud](docs/tutorials/ovh.md)\n- [Scaleway](docs/tutorials/scaleway.md)\n- [GoDaddy](docs/tutorials/godaddy.md)\n- [Gandi](docs/tutorials/gandi.md)\n- [Nodes as source](docs/sources/nodes.md)\n- [Plural](docs/tutorials/plural.md)\n- [Pi-hole](docs/tutorials/pihole.md)\n\n### Running Locally\n\nSee the [contributor guide](docs/contributing/dev-guide.md) for details on compiling\nfrom source.\n\n#### Setup Steps\n\nNext, run an application and expose it via a Kubernetes Service:\n\n```console\nkubectl run nginx --image=nginx --port=80\nkubectl expose pod nginx --port=80 --target-port=80 --type=LoadBalancer\n```\n\nAnnotate the Service with your desired external DNS name. Make sure to change `example.org` to your domain.\n\n```console\nkubectl annotate service nginx \"external-dns.alpha.kubernetes.io/hostname=nginx.example.org.\"\n```\n\nOptionally, you can customize the TTL value of the resulting DNS record by using the `external-dns.alpha.kubernetes.io/ttl` annotation:\n\n```console\nkubectl annotate service nginx \"external-dns.alpha.kubernetes.io/ttl=10\"\n```\n\nFor more details on configuring TTL, see [advanced ttl](docs/advanced/ttl.md).\n\nUse the internal-hostname annotation to create DNS records with ClusterIP as the target.\n\n```console\nkubectl annotate service nginx \"external-dns.alpha.kubernetes.io/internal-hostname=nginx.internal.example.org.\"\n```\n\nIf the service is not of type Loadbalancer you need the --publish-internal-services flag.\n\nLocally run a single sync loop of ExternalDNS.\n\n```console\nexternal-dns --txt-owner-id my-cluster-id --provider google --google-project example-project --source service --once --dry-run\n```\n\nThis should output the DNS records it will modify to match the managed zone with the DNS records you desire.\nIt also assumes you are running in the `default` namespace. See the [FAQ](docs/faq.md) for more information regarding namespaces.\n\nNote: TXT records will have the `my-cluster-id` value embedded. Those are used to ensure that ExternalDNS is aware of the records it manages.\n\nOnce you're satisfied with the result, you can run ExternalDNS like you would run it in your cluster: as a control loop, and **not in dry-run** mode:\n\n```console\nexternal-dns --txt-owner-id my-cluster-id --provider google --google-project example-project --source service\n```\n\nCheck that ExternalDNS has created the desired DNS record for your Service and that it points to its load balancer's IP. Then try to resolve it:\n\n```console\ndig +short nginx.example.org.\n104.155.60.49\n```\n\nNow you can experiment and watch how ExternalDNS makes sure that your DNS records are configured as desired. Here are a couple of things you can try out:\n\n- Change the desired hostname by modifying the Service's annotation.\n- Recreate the Service and see that the DNS record will be updated to point to the new load balancer IP.\n- Add another Service to create more DNS records.\n- Remove Services to clean up your managed zone.\n\nThe **tutorials** section contains examples, including Ingress resources, and shows you how to set up ExternalDNS in different environments such as other cloud providers and alternative Ingress controllers.\n\n# Note\n\nIf using a txt registry and attempting to use a CNAME the `--txt-prefix` must be set to avoid conflicts. Changing `--txt-prefix` will result in lost ownership over previously created records.\n\nIf `externalIPs` list is defined for a `LoadBalancer` service, this list will be used instead of an assigned load balancer IP to create a DNS record.\nIt's useful when you run bare metal Kubernetes clusters behind NAT or in a similar setup, where a load balancer IP differs from a public IP (e.g. with [MetalLB](https://metallb.universe.tf)).\n\n## Contributing\n\nAre you interested in contributing to external-dns? We, the maintainers and community, would love your\nsuggestions, contributions, and help! Also, the maintainers can be contacted at any time to learn more\nabout how to get involved.\n\nWe also encourage ALL active community participants to act as if they are maintainers, even if you don't have\n\"official\" write permissions. This is a community effort, we are here to serve the Kubernetes community. If you\nhave an active interest and you want to get involved, you have real power! Don't assume that the only people who\ncan get things done around here are the \"maintainers\". We also would love to add more \"official\" maintainers, so\nshow us what you can do!\n\nThe external-dns project is currently in need of maintainers for specific DNS providers. Ideally each provider\nwould have at least two maintainers. It would be nice if the maintainers run the provider in production, but it\nis not strictly required. Provider listed [status](https://github.com/kubernetes-sigs/external-dns#status-of-in-tree-providers)\nthat do not have a maintainer listed are in need of assistance.\n\nRead the [contributing guidelines](CONTRIBUTING.md) and have a look at [the contributing docs](docs/contributing/dev-guide.md) to learn about building the project, the project structure, and the purpose of each package.\n\nFor an overview on how to write new Sources and Providers check out [Sources and Providers](docs/contributing/sources-and-providers.md).\n\n## Heritage\n\nExternalDNS is an effort to unify the following similar projects in order to bring the Kubernetes community an easy and predictable way of managing DNS records across cloud providers based on their Kubernetes resources:\n\n- Kops' [DNS Controller](https://github.com/kubernetes/kops/tree/HEAD/dns-controller)\n- Zalando's [Mate](https://github.com/linki/mate)\n- Molecule Software's [route53-kubernetes](https://github.com/wearemolecule/route53-kubernetes)\n\n### User Demo How-To Blogs and Examples\n\n- A full demo on GKE Kubernetes. See [How-to Kubernetes with DNS management (ssl-manager pre-req)](https://medium.com/@jpantjsoha/how-to-kubernetes-with-dns-management-for-gitops-31239ea75d8d)\n- Run external-dns on GKE with workload identity. See [Kubernetes, ingress-nginx, cert-manager & external-dns](https://blog.atomist.com/kubernetes-ingress-nginx-cert-manager-external-dns/)\n- [ExternalDNS integration with Azure DNS using workload identity](https://cloudchronicles.blog/blog/ExternalDNS-integration-with-Azure-DNS-using-workload-identity/)\n"
  },
  {
    "path": "SECURITY_CONTACTS",
    "content": "# Defined below are the security contacts for this repo.\n#\n# They are the contact point for the Product Security Team to reach out\n# to for triaging and handling of incoming issues.\n#\n# The below names agree to abide by the\n# [Embargo Policy](https://github.com/kubernetes/sig-release/blob/HEAD/security-release-process-documentation/security-release-process.md#embargo-policy)\n# and will be removed and replaced if they violate that agreement.\n#\n# DO NOT REPORT SECURITY VULNERABILITIES DIRECTLY TO THESE NAMES, FOLLOW THE\n# INSTRUCTIONS AT https://kubernetes.io/security/\n\nnjuettner\nhjacobs\nraffo\n"
  },
  {
    "path": "api/webhook.yaml",
    "content": "---\nopenapi: \"3.0.0\"\ninfo:\n  version: v0.15.0\n  title: External DNS Webhook Server\n  description: >-\n    Implements the external DNS webhook endpoints.\n  contact:\n    url: https://github.com/kubernetes-sigs/external-dns\n  license:\n    name: Apache 2.0\n    url: https://www.apache.org/licenses/LICENSE-2.0.html\ntags:\n  - name: initialization\n    description: Endpoints for initial negotiation.\n  - name: listing\n    description: Endpoints to get listings of DNS records.\n  - name: update\n    description: Endpoints to update DNS records.\nservers:\n  - url: http://localhost:8888\n    description: Server url for a Kubernetes deployment.\npaths:\n  /:\n    get:\n      summary: >-\n        Initialisation and negotiates headers and returns domain\n        filter.\n      description: |\n        Initialisation and negotiates headers and returns domain\n        filter.\n      operationId: negotiate\n      tags: [initialization]\n      responses:\n        '200':\n          description: |\n            The list of domains this DNS provider serves.\n          content:\n            application/external.dns.webhook+json;version=1:\n              schema:\n                $ref: '#/components/schemas/filters'\n              example:\n                filters:\n                  - example.com\n        '500':\n          description: |\n            Negotiation failed.\n\n  /records:\n    get:\n      summary: Returns the current records.\n      description: |\n        Get the current records from the DNS provider and return them.\n      operationId: getRecords\n      tags: [listing]\n      responses:\n        '200':\n          description: |\n            Provided the list of DNS records successfully.\n          content:\n            application/external.dns.webhook+json;version=1:\n              schema:\n                $ref: '#/components/schemas/endpoints'\n              example:\n                - dnsName: \"test.example.com\"\n                  recordTTL: 10\n                  recordType: 'A'\n                  targets:\n                    - \"1.2.3.4\"\n        '500':\n          description: |\n            Failed to provide the list of DNS records.\n\n    post:\n      summary: Applies the changes.\n      description: |\n        Set the records in the DNS provider based on those supplied here.\n      operationId: setRecords\n      tags: [update]\n      requestBody:\n        description: |\n          This is the list of changes that need to be applied.  There are\n          four lists of endpoints.  The `create` and `delete` lists are lists\n          of records to create and delete respectively.  The `updateOld` and\n          `updateNew` lists are paired.  For each entry there's the old version\n          of the record and a new version of the record.\n        required: true\n        content:\n          application/external.dns.webhook+json;version=1:\n            schema:\n              $ref: '#/components/schemas/changes'\n            example:\n              create:\n                - dnsName: \"test.example.com\"\n                  recordTTL: 10\n                  recordType: 'A'\n      responses:\n        '204':\n          description: |\n            Changes were accepted.\n        '500':\n          description: |\n            Changes were not accepted.\n\n  /adjustendpoints:\n    post:\n      summary: Executes the AdjustEndpoints method.\n      description: |\n        Adjusts the records in the provider based on those supplied here.\n      operationId: adjustRecords\n      tags: [update]\n      requestBody:\n        description: |\n          This is the list of changes to be applied.\n        required: true\n        content:\n          application/external.dns.webhook+json;version=1:\n            schema:\n              $ref: '#/components/schemas/endpoints'\n            example:\n              - dnsName: \"test.example.com\"\n                recordTTL: 10\n                recordType: 'A'\n                targets:\n                  - \"1.2.3.4\"\n      responses:\n        '200':\n          description: |\n            Adjustments were accepted.\n          content:\n            application/external.dns.webhook+json;version=1:\n              schema:\n                $ref: '#/components/schemas/endpoints'\n              example:\n                - dnsName: \"test.example.com\"\n                  recordTTL: 0\n                  recordType: 'A'\n                  targets:\n                    - \"1.2.3.4\"\n        '500':\n          description: |\n            Adjustments were not accepted.\n\ncomponents:\n  schemas:\n    filters:\n      description: |\n        external-dns will only create DNS records for host names (specified in ingress objects and services with the external-dns annotation) related to zones that match filters. They can set in external-dns deployment manifest.\n      type: object\n      properties:\n        filters:\n          type: array\n          items:\n            type: string\n            example: \"foo.example.com\"\n          example:\n            - \".example.com\"\n      example:\n        filters:\n          - \".example.com\"\n          - \".example.org\"\n\n    endpoints:\n      description: |\n        This is a list of DNS records.\n      type: array\n      items:\n        $ref: '#/components/schemas/endpoint'\n      example:\n        - dnsName: foo.example.com\n          recordType: A\n          recordTTL: 60\n\n    endpoint:\n      description: |\n        This is a DNS record.\n      type: object\n      properties:\n        dnsName:\n          type: string\n          example: \"foo.example.org\"\n        targets:\n          $ref: '#/components/schemas/targets'\n        recordType:\n          type: string\n          example: \"CNAME\"\n        setIdentifier:\n          type: string\n          example: \"v1\"\n        recordTTL:\n          type: integer\n          format: int64\n          example: 60\n        labels:\n          type: object\n          additionalProperties:\n            type: string\n            example: \"foo\"\n          example:\n            foo: bar\n        providerSpecific:\n          type: array\n          items:\n            $ref: '#/components/schemas/providerSpecificProperty'\n          example:\n            - name: foo\n              value: bar\n      example:\n        dnsName: foo.example.com\n        recordType: A\n        recordTTL: 60\n\n    targets:\n      description: |\n        This is the list of targets that this DNS record points to.\n        So for an A record it will be a list of IP addresses.\n      type: array\n      items:\n        type: string\n        example: \"::1\"\n      example:\n        - \"1.2.3.4\"\n        - \"test.example.org\"\n\n    providerSpecificProperty:\n      description: |\n        Allows provider to pass property specific to their implementation.\n      type: object\n      properties:\n        name:\n          type: string\n          example: foo\n        value:\n          type: string\n          example: bar\n      example:\n        name: foo\n        value: bar\n\n    changes:\n      description: |\n        This is the list of changes send by `external-dns` that need to\n        be applied.  There are four lists of endpoints.  The `create`\n        and `delete` lists are lists of records to create and delete\n        respectively.  The `updateOld` and `updateNew` lists are paired.\n        For each entry there's the old version of the record and a new\n        version of the record.\n      type: object\n      properties:\n        create:\n          $ref: '#/components/schemas/endpoints'\n        updateOld:\n          $ref: '#/components/schemas/endpoints'\n        updateNew:\n          $ref: '#/components/schemas/endpoints'\n        delete:\n          $ref: '#/components/schemas/endpoints'\n      example:\n        create:\n          - dnsName: foo.example.com\n            recordType: A\n            recordTTL: 60\n        delete:\n          - dnsName: foo.example.org\n            recordType: CNAME\n"
  },
  {
    "path": "apis/OWNERS",
    "content": "# See the OWNERS docs at https://go.k8s.io/owners\n\nlabels:\n- apis\n"
  },
  {
    "path": "apis/api.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage apis\n"
  },
  {
    "path": "apis/v1alpha1/api.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\n// Package v1alpha1 contains API Schema definitions for the externaldns.k8s.io v1alpha1 API group\n// +kubebuilder:object:generate=true\n// +groupName=externaldns.k8s.io\npackage v1alpha1\n"
  },
  {
    "path": "apis/v1alpha1/dnsendpoint.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage v1alpha1\n\nimport (\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\n// +genclient\n// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object\n\n// DNSEndpoint is a contract that a user-specified CRD must implement to be used as a source for external-dns.\n// The user-specified CRD should also have the status sub-resource.\n// +k8s:openapi-gen=true\n// +groupName=externaldns.k8s.io\n// +kubebuilder:resource:path=dnsendpoints\n// +kubebuilder:subresource:status\n// +kubebuilder:metadata:annotations=\"api-approved.kubernetes.io=https://github.com/kubernetes-sigs/external-dns/pull/2007\"\n// +versionName=v1alpha1\ntype DNSEndpoint struct {\n\tmetav1.TypeMeta   `json:\",inline\"`\n\tmetav1.ObjectMeta `json:\"metadata,omitempty\"`\n\n\tSpec   DNSEndpointSpec   `json:\"spec,omitempty\"`\n\tStatus DNSEndpointStatus `json:\"status,omitempty\"`\n}\n\n// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object\n// DNSEndpointList is a list of DNSEndpoint objects\ntype DNSEndpointList struct {\n\tmetav1.TypeMeta `json:\",inline\"`\n\tmetav1.ListMeta `json:\"metadata,omitempty\"`\n\tItems           []DNSEndpoint `json:\"items\"`\n}\n\n// DNSEndpointSpec defines the desired state of DNSEndpoint\ntype DNSEndpointSpec struct {\n\tEndpoints []*endpoint.Endpoint `json:\"endpoints,omitempty\"`\n}\n\n// DNSEndpointStatus defines the observed state of DNSEndpoint\ntype DNSEndpointStatus struct {\n\t// The generation observed by the external-dns controller.\n\t// +optional\n\tObservedGeneration int64 `json:\"observedGeneration,omitempty\"`\n}\n"
  },
  {
    "path": "apis/v1alpha1/groupversion_info.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\n// Package v1alpha1 contains API Schema definitions for the externaldns.k8s.io v1alpha1 API group\n// +kubebuilder:object:generate=true\n// +groupName=externaldns.k8s.io\npackage v1alpha1\n\nimport (\n\t\"k8s.io/apimachinery/pkg/runtime/schema\"\n\t\"sigs.k8s.io/controller-runtime/pkg/scheme\"\n)\n\nconst (\n\t// DNSEndpointKind is the kind name for DNSEndpoint resources\n\tDNSEndpointKind = \"DNSEndpoint\"\n)\n\nvar (\n\t// GroupVersion is group version used to register these objects\n\tGroupVersion = schema.GroupVersion{Group: \"externaldns.k8s.io\", Version: \"v1alpha1\"}\n\n\t// SchemeBuilder is used to add go types to the GroupVersionKind scheme\n\tSchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}\n\n\t// AddToScheme adds the types in this group-version to the given scheme.\n\tAddToScheme = SchemeBuilder.AddToScheme\n)\n\nfunc init() {\n\tSchemeBuilder.Register(&DNSEndpoint{}, &DNSEndpointList{})\n}\n"
  },
  {
    "path": "apis/v1alpha1/zz_generated.deepcopy.go",
    "content": "//go:build !ignore_autogenerated\n\n// Code generated by controller-gen. DO NOT EDIT.\n\npackage v1alpha1\n\nimport (\n\truntime \"k8s.io/apimachinery/pkg/runtime\"\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\n// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.\nfunc (in *DNSEndpoint) DeepCopyInto(out *DNSEndpoint) {\n\t*out = *in\n\tout.TypeMeta = in.TypeMeta\n\tin.ObjectMeta.DeepCopyInto(&out.ObjectMeta)\n\tin.Spec.DeepCopyInto(&out.Spec)\n\tout.Status = in.Status\n}\n\n// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSEndpoint.\nfunc (in *DNSEndpoint) DeepCopy() *DNSEndpoint {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(DNSEndpoint)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\n// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.\nfunc (in *DNSEndpoint) DeepCopyObject() runtime.Object {\n\tif c := in.DeepCopy(); c != nil {\n\t\treturn c\n\t}\n\treturn nil\n}\n\n// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.\nfunc (in *DNSEndpointList) DeepCopyInto(out *DNSEndpointList) {\n\t*out = *in\n\tout.TypeMeta = in.TypeMeta\n\tin.ListMeta.DeepCopyInto(&out.ListMeta)\n\tif in.Items != nil {\n\t\tin, out := &in.Items, &out.Items\n\t\t*out = make([]DNSEndpoint, len(*in))\n\t\tfor i := range *in {\n\t\t\t(*in)[i].DeepCopyInto(&(*out)[i])\n\t\t}\n\t}\n}\n\n// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSEndpointList.\nfunc (in *DNSEndpointList) DeepCopy() *DNSEndpointList {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(DNSEndpointList)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\n// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.\nfunc (in *DNSEndpointList) DeepCopyObject() runtime.Object {\n\tif c := in.DeepCopy(); c != nil {\n\t\treturn c\n\t}\n\treturn nil\n}\n\n// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.\nfunc (in *DNSEndpointSpec) DeepCopyInto(out *DNSEndpointSpec) {\n\t*out = *in\n\tif in.Endpoints != nil {\n\t\tin, out := &in.Endpoints, &out.Endpoints\n\t\t*out = make([]*endpoint.Endpoint, len(*in))\n\t\tfor i := range *in {\n\t\t\tif (*in)[i] != nil {\n\t\t\t\tin, out := &(*in)[i], &(*out)[i]\n\t\t\t\t*out = new(endpoint.Endpoint)\n\t\t\t\t(*in).DeepCopyInto(*out)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSEndpointSpec.\nfunc (in *DNSEndpointSpec) DeepCopy() *DNSEndpointSpec {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(DNSEndpointSpec)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\n// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.\nfunc (in *DNSEndpointStatus) DeepCopyInto(out *DNSEndpointStatus) {\n\t*out = *in\n}\n\n// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSEndpointStatus.\nfunc (in *DNSEndpointStatus) DeepCopy() *DNSEndpointStatus {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(DNSEndpointStatus)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n"
  },
  {
    "path": "charts/OWNERS",
    "content": "labels:\n  - chart\napprovers:\n  - stevehipwell\nreviewers:\n  - stevehipwell\n"
  },
  {
    "path": "cloudbuild.yaml",
    "content": "# See https://cloud.google.com/cloud-build/docs/build-config\ntimeout: 5000s\noptions:\n  substitution_option: ALLOW_LOOSE\n  machineType: 'N1_HIGHCPU_8'\nsteps:\n  - name: 'docker.io/library/golang:1.25-bookworm'\n    entrypoint: make\n    env:\n      - VERSION=$_GIT_TAG\n      - PULL_BASE_REF=$_PULL_BASE_REF\n    args:\n      - release.staging\nsubstitutions:\n  # _GIT_TAG will be filled with a git-based tag for the image, of the form vYYYYMMDD-hash, and\n  # can be used as a substitution\n  _GIT_TAG: \"12345\"\n  _PULL_BASE_REF: 'master'\n"
  },
  {
    "path": "code-of-conduct.md",
    "content": "# Kubernetes Community Code of Conduct\n\nPlease refer to our [Kubernetes Community Code of Conduct](https://git.k8s.io/community/code-of-conduct.md)\n"
  },
  {
    "path": "config/crd/standard/dnsendpoints.externaldns.k8s.io.yaml",
    "content": "apiVersion: apiextensions.k8s.io/v1\nkind: CustomResourceDefinition\nmetadata:\n  annotations:\n    api-approved.kubernetes.io: https://github.com/kubernetes-sigs/external-dns/pull/2007\n    controller-gen.kubebuilder.io/version: v0.20.1\n  name: dnsendpoints.externaldns.k8s.io\nspec:\n  group: externaldns.k8s.io\n  names:\n    kind: DNSEndpoint\n    listKind: DNSEndpointList\n    plural: dnsendpoints\n    singular: dnsendpoint\n  scope: Namespaced\n  versions:\n    - name: v1alpha1\n      schema:\n        openAPIV3Schema:\n          description: |-\n            DNSEndpoint is a contract that a user-specified CRD must implement to be used as a source for external-dns.\n            The user-specified CRD should also have the status sub-resource.\n          properties:\n            apiVersion:\n              description: |-\n                APIVersion defines the versioned schema of this representation of an object.\n                Servers should convert recognized schemas to the latest internal value, and\n                may reject unrecognized values.\n                More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources\n              type: string\n            kind:\n              description: |-\n                Kind is a string value representing the REST resource this object represents.\n                Servers may infer this from the endpoint the client submits requests to.\n                Cannot be updated.\n                In CamelCase.\n                More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds\n              type: string\n            metadata:\n              type: object\n            spec:\n              description: DNSEndpointSpec defines the desired state of DNSEndpoint\n              properties:\n                endpoints:\n                  items:\n                    description: Endpoint is a high-level way of a connection between a service and an IP\n                    properties:\n                      dnsName:\n                        description: The hostname of the DNS record\n                        type: string\n                      labels:\n                        additionalProperties:\n                          type: string\n                        description: Labels stores labels defined for the Endpoint\n                        type: object\n                      providerSpecific:\n                        description: ProviderSpecific stores provider specific config\n                        items:\n                          description: ProviderSpecificProperty holds the name and value of a configuration which is specific to individual DNS providers\n                          properties:\n                            name:\n                              type: string\n                            value:\n                              type: string\n                          type: object\n                        type: array\n                      recordTTL:\n                        description: TTL for the record\n                        format: int64\n                        type: integer\n                      recordType:\n                        description: RecordType type of record, e.g. CNAME, A, AAAA, SRV, TXT etc\n                        type: string\n                      setIdentifier:\n                        description: Identifier to distinguish multiple records with the same name and type (e.g. Route53 records with routing policies other than 'simple')\n                        type: string\n                      targets:\n                        description: The targets the DNS record points to\n                        items:\n                          type: string\n                        type: array\n                    type: object\n                  type: array\n              type: object\n            status:\n              description: DNSEndpointStatus defines the observed state of DNSEndpoint\n              properties:\n                observedGeneration:\n                  description: The generation observed by the external-dns controller.\n                  format: int64\n                  type: integer\n              type: object\n          type: object\n      served: true\n      storage: true\n      subresources:\n        status: {}\n"
  },
  {
    "path": "controller/OWNERS",
    "content": "# See the OWNERS docs at https://go.k8s.io/owners\n\nlabels:\n- controller\n"
  },
  {
    "path": "controller/controller.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage controller\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"sync\"\n\t\"time\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/pkg/events\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n\t\"sigs.k8s.io/external-dns/registry\"\n\t\"sigs.k8s.io/external-dns/source\"\n)\n\n// Controller is responsible for orchestrating the different components.\n// It works in the following way:\n// * Ask the DNS provider for the current list of endpoints.\n// * Ask the Source for the desired list of endpoints.\n// * Take both lists and calculate a Plan to move current towards the desired state.\n// * Tell the DNS provider to apply the changes calculated by the Plan.\ntype Controller struct {\n\tSource   source.Source\n\tRegistry registry.Registry\n\t// The policy that defines which change to DNS records is allowed\n\tPolicy plan.Policy\n\t// The interval between individual synchronizations\n\tInterval time.Duration\n\t// The DomainFilter defines which DNS records to keep or exclude\n\tDomainFilter endpoint.DomainFilterInterface\n\t// The nextRunAt used for throttling and batching reconciliation\n\tnextRunAt time.Time\n\t// The runAtMutex is for atomic updating of nextRunAt and lastRunAt\n\trunAtMutex sync.Mutex\n\t// The lastRunAt used for throttling and batching reconciliation\n\tlastRunAt    time.Time\n\tEventEmitter events.EventEmitter\n\t// MangedRecordTypes are DNS record types that will be considered for management.\n\tManagedRecordTypes []string\n\t// ExcludeRecordTypes are DNS record types that will be excluded from management.\n\tExcludeRecordTypes []string\n\t// MinEventSyncInterval is used as a window for batching events\n\tMinEventSyncInterval time.Duration\n\t// Old txt-owner value we need to migrate from\n\tTXTOwnerOld string\n}\n\n// RunOnce runs a single iteration of a reconciliation loop.\nfunc (c *Controller) RunOnce(ctx context.Context) error {\n\tlastReconcileTimestamp.Gauge.SetToCurrentTime()\n\n\tc.runAtMutex.Lock()\n\tc.lastRunAt = time.Now()\n\tc.runAtMutex.Unlock()\n\n\tregRecords, err := c.Registry.Records(ctx)\n\tif err != nil {\n\t\tregistryErrorsTotal.Counter.Inc()\n\t\tdeprecatedRegistryErrors.Counter.Inc()\n\t\treturn err\n\t}\n\n\tregistryEndpointsTotal.Gauge.Set(float64(len(regRecords)))\n\n\tcountAddressRecords(regRecords, registryRecords)\n\n\tctx = context.WithValue(ctx, provider.RecordsContextKey, regRecords)\n\n\tsourceEndpoints, err := c.Source.Endpoints(ctx)\n\tif err != nil {\n\t\tsourceErrorsTotal.Counter.Inc()\n\t\tdeprecatedSourceErrors.Counter.Inc()\n\t\treturn err\n\t}\n\n\tsourceEndpointsTotal.Gauge.Set(float64(len(sourceEndpoints)))\n\n\tcountAddressRecords(sourceEndpoints, sourceRecords)\n\tcountMatchingAddressRecords(sourceEndpoints, regRecords, verifiedRecords)\n\n\tendpoints, err := c.Registry.AdjustEndpoints(sourceEndpoints)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"adjusting endpoints: %w\", err)\n\t}\n\tregistryFilter := c.Registry.GetDomainFilter()\n\n\tplan := &plan.Plan{\n\t\tPolicies:       []plan.Policy{c.Policy},\n\t\tCurrent:        regRecords,\n\t\tDesired:        endpoints,\n\t\tDomainFilter:   endpoint.MatchAllDomainFilters{c.DomainFilter, registryFilter},\n\t\tManagedRecords: c.ManagedRecordTypes,\n\t\tExcludeRecords: c.ExcludeRecordTypes,\n\t\tOwnerID:        c.Registry.OwnerID(),\n\t\tOldOwnerID:     c.TXTOwnerOld,\n\t}\n\n\tplan = plan.Calculate()\n\n\tif plan.Changes.HasChanges() {\n\t\terr = c.Registry.ApplyChanges(ctx, plan.Changes)\n\t\tif err != nil {\n\t\t\tregistryErrorsTotal.Counter.Inc()\n\t\t\tdeprecatedRegistryErrors.Counter.Inc()\n\t\t\temitChangeEvent(c.EventEmitter, plan.Changes, events.RecordError)\n\t\t\treturn err\n\t\t}\n\t\temitChangeEvent(c.EventEmitter, plan.Changes, events.RecordReady)\n\t} else {\n\t\tcontrollerNoChangesTotal.Counter.Inc()\n\t\tlog.Info(\"All records are already up to date\")\n\t}\n\n\tlastSyncTimestamp.Gauge.SetToCurrentTime()\n\n\treturn nil\n}\n\nfunc earliest(r time.Time, times ...time.Time) time.Time {\n\tfor _, t := range times {\n\t\tif t.Before(r) {\n\t\t\tr = t\n\t\t}\n\t}\n\treturn r\n}\n\nfunc latest(r time.Time, times ...time.Time) time.Time {\n\tfor _, t := range times {\n\t\tif t.After(r) {\n\t\t\tr = t\n\t\t}\n\t}\n\treturn r\n}\n\n// ScheduleRunOnce makes sure execution happens at most once per interval.\nfunc (c *Controller) ScheduleRunOnce(now time.Time) {\n\tc.runAtMutex.Lock()\n\tdefer c.runAtMutex.Unlock()\n\tc.nextRunAt = latest(\n\t\tc.lastRunAt.Add(c.MinEventSyncInterval),\n\t\tearliest(\n\t\t\tnow.Add(5*time.Second),\n\t\t\tc.nextRunAt,\n\t\t),\n\t)\n}\n\nfunc (c *Controller) ShouldRunOnce(now time.Time) bool {\n\tc.runAtMutex.Lock()\n\tdefer c.runAtMutex.Unlock()\n\tif now.Before(c.nextRunAt) {\n\t\treturn false\n\t}\n\tc.nextRunAt = now.Add(c.Interval)\n\treturn true\n}\n\n// Run runs RunOnce in a loop with a delay until context is canceled\nfunc (c *Controller) Run(ctx context.Context) {\n\tticker := time.NewTicker(time.Second)\n\tdefer ticker.Stop()\n\tvar softErrorCount int\n\tfor {\n\t\tif c.ShouldRunOnce(time.Now()) {\n\t\t\tif err := c.RunOnce(ctx); err != nil {\n\t\t\t\tif errors.Is(err, provider.SoftError) {\n\t\t\t\t\tsoftErrorCount++\n\t\t\t\t\tconsecutiveSoftErrors.Gauge.Set(float64(softErrorCount))\n\t\t\t\t\tlog.Errorf(\"Failed to do run once: %v (consecutive soft errors: %d)\", err, softErrorCount)\n\t\t\t\t} else {\n\t\t\t\t\tlog.Fatalf(\"Failed to do run once: %v\", err) // nolint: gocritic // exitAfterDefer\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif softErrorCount > 0 {\n\t\t\t\t\tlog.Infof(\"Reconciliation succeeded after %d consecutive soft errors\", softErrorCount)\n\t\t\t\t}\n\t\t\t\tsoftErrorCount = 0\n\t\t\t\tconsecutiveSoftErrors.Gauge.Set(0)\n\t\t\t}\n\t\t}\n\t\tselect {\n\t\tcase <-ticker.C:\n\t\tcase <-ctx.Done():\n\t\t\tlog.Info(\"Terminating main controller loop\")\n\t\t\treturn\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "controller/controller_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage controller\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"reflect\"\n\t\"sort\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/internal/testutils\"\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\t\"sigs.k8s.io/external-dns/pkg/events\"\n\t\"sigs.k8s.io/external-dns/pkg/events/fake\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n\t\"sigs.k8s.io/external-dns/provider/fakes\"\n\tregistryfactory \"sigs.k8s.io/external-dns/registry/factory\"\n\t\"sigs.k8s.io/external-dns/registry/noop\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\t\"github.com/stretchr/testify/require\"\n)\n\n// mockProvider returns mock endpoints and validates changes.\ntype mockProvider struct {\n\tprovider.BaseProvider\n\tRecordsStore  []*endpoint.Endpoint\n\tExpectChanges *plan.Changes\n}\n\ntype filteredMockProvider struct {\n\tprovider.BaseProvider\n\tdomainFilter      *endpoint.DomainFilter\n\tRecordsStore      []*endpoint.Endpoint\n\tRecordsCallCount  int\n\tApplyChangesCalls []*plan.Changes\n}\n\nfunc (p *filteredMockProvider) GetDomainFilter() endpoint.DomainFilterInterface {\n\treturn p.domainFilter\n}\n\n// Records returns the desired mock endpoints.\nfunc (p *filteredMockProvider) Records(_ context.Context) ([]*endpoint.Endpoint, error) {\n\tp.RecordsCallCount++\n\treturn p.RecordsStore, nil\n}\n\n// ApplyChanges stores all calls for later check\nfunc (p *filteredMockProvider) ApplyChanges(_ context.Context, changes *plan.Changes) error {\n\tp.ApplyChangesCalls = append(p.ApplyChangesCalls, changes)\n\treturn nil\n}\n\n// Records returns the desired mock endpoints.\nfunc (p *mockProvider) Records(_ context.Context) ([]*endpoint.Endpoint, error) {\n\treturn p.RecordsStore, nil\n}\n\n// ApplyChanges validates that the passed in changes satisfy the assumptions.\nfunc (p *mockProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {\n\tif err := verifyEndpoints(changes.Create, p.ExpectChanges.Create); err != nil {\n\t\treturn err\n\t}\n\n\tif err := verifyEndpoints(changes.UpdateNew, p.ExpectChanges.UpdateNew); err != nil {\n\t\treturn err\n\t}\n\n\tif err := verifyEndpoints(changes.UpdateOld, p.ExpectChanges.UpdateOld); err != nil {\n\t\treturn err\n\t}\n\n\tif err := verifyEndpoints(changes.Delete, p.ExpectChanges.Delete); err != nil {\n\t\treturn err\n\t}\n\n\tif !reflect.DeepEqual(ctx.Value(provider.RecordsContextKey), p.RecordsStore) {\n\t\treturn errors.New(\"context is wrong\")\n\t}\n\treturn nil\n}\n\nfunc verifyEndpoints(actual, expected []*endpoint.Endpoint) error {\n\tif len(actual) != len(expected) {\n\t\treturn errors.New(\"number of records is wrong\")\n\t}\n\tsort.Slice(actual, func(i, j int) bool {\n\t\treturn actual[i].DNSName < actual[j].DNSName\n\t})\n\tfor i := range actual {\n\t\tif actual[i].DNSName != expected[i].DNSName || !actual[i].Targets.Same(expected[i].Targets) {\n\t\t\treturn errors.New(\"record is wrong\")\n\t\t}\n\t}\n\treturn nil\n}\n\n// newMockProvider creates a new mockProvider returning the given endpoints and validating the desired changes.\nfunc newMockProvider(endpoints []*endpoint.Endpoint, changes *plan.Changes) provider.Provider {\n\tdnsProvider := &mockProvider{\n\t\tRecordsStore:  endpoints,\n\t\tExpectChanges: changes,\n\t}\n\n\treturn dnsProvider\n}\n\nfunc getTestSource() *testutils.MockSource {\n\t// Fake some desired endpoints coming from our source.\n\tsource := new(testutils.MockSource)\n\tsource.On(\"Endpoints\").Return([]*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"create-record\",\n\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"update-record\",\n\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\tTargets:    endpoint.Targets{\"8.8.4.4\"},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"create-aaaa-record\",\n\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t\tTargets:    endpoint.Targets{\"2001:DB8::1\"},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"update-aaaa-record\",\n\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t\tTargets:    endpoint.Targets{\"2001:DB8::2\"},\n\t\t},\n\t}, nil)\n\n\treturn source\n}\n\nfunc getTestConfig() *externaldns.Config {\n\tcfg := externaldns.NewConfig()\n\tcfg.Registry = externaldns.RegistryNoop\n\tcfg.ManagedDNSRecordTypes = []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME}\n\treturn cfg\n}\n\nfunc getTestProvider() provider.Provider {\n\t// Fake some existing records in our DNS provider and validate some desired changes.\n\treturn newMockProvider(\n\t\t[]*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"update-record\",\n\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"delete-record\",\n\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\tTargets:    endpoint.Targets{\"4.3.2.1\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"update-aaaa-record\",\n\t\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t\t\tTargets:    endpoint.Targets{\"2001:DB8::3\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"delete-aaaa-record\",\n\t\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t\t\tTargets:    endpoint.Targets{\"2001:DB8::4\"},\n\t\t\t},\n\t\t},\n\t\t&plan.Changes{\n\t\t\tCreate: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"create-aaaa-record\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:DB8::1\"}},\n\t\t\t\t{DNSName: \"create-record\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"update-aaaa-record\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:DB8::2\"}},\n\t\t\t\t{DNSName: \"update-record\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"8.8.4.4\"}},\n\t\t\t},\n\t\t\tUpdateOld: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"update-aaaa-record\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:DB8::3\"}},\n\t\t\t\t{DNSName: \"update-record\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"8.8.8.8\"}},\n\t\t\t},\n\t\t\tDelete: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"delete-aaaa-record\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:DB8::4\"}},\n\t\t\t\t{DNSName: \"delete-record\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"4.3.2.1\"}},\n\t\t\t},\n\t\t},\n\t)\n}\n\n// TestRunOnce tests that RunOnce correctly orchestrates the different components.\nfunc TestRunOnce(t *testing.T) {\n\tsource := getTestSource()\n\tcfg := getTestConfig()\n\tprovider := getTestProvider()\n\n\temitter := fake.NewFakeEventEmitter()\n\n\tr, err := registryfactory.Select(cfg, provider)\n\trequire.NoError(t, err)\n\n\t// Run our controller once to trigger the validation.\n\tctrl := &Controller{\n\t\tSource:             source,\n\t\tRegistry:           r,\n\t\tPolicy:             &plan.SyncPolicy{},\n\t\tManagedRecordTypes: cfg.ManagedDNSRecordTypes,\n\t\tEventEmitter:       emitter,\n\t}\n\n\tassert.NoError(t, ctrl.RunOnce(t.Context()))\n\n\t// Validate that the mock source was called.\n\tsource.AssertExpectations(t)\n\t// check the verified records\n\n\ttestutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 1, verifiedRecords.Gauge, map[string]string{\"record_type\": \"a\"})\n\ttestutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 1, verifiedRecords.Gauge, map[string]string{\"record_type\": \"aaaa\"})\n\n\temitter.AssertNumberOfCalls(t, \"Add\", 6)\n}\n\n// TestRun tests that Run correctly starts and stops\nfunc TestRun(t *testing.T) {\n\tsource := getTestSource()\n\tcfg := getTestConfig()\n\tprovider := getTestProvider()\n\n\tr, err := registryfactory.Select(cfg, provider)\n\trequire.NoError(t, err)\n\n\t// Run our controller once to trigger the validation.\n\tctrl := &Controller{\n\t\tSource:             source,\n\t\tRegistry:           r,\n\t\tPolicy:             &plan.SyncPolicy{},\n\t\tManagedRecordTypes: cfg.ManagedDNSRecordTypes,\n\t}\n\tctrl.nextRunAt = time.Now().Add(-time.Millisecond)\n\tctx, cancel := context.WithCancel(t.Context())\n\tstopped := make(chan struct{})\n\tgo func() {\n\t\tctrl.Run(ctx)\n\t\tclose(stopped)\n\t}()\n\ttime.Sleep(1500 * time.Millisecond)\n\tcancel() // start shutdown\n\t<-stopped\n\n\t// Validate that the mock source was called.\n\tsource.AssertExpectations(t)\n\n\ttestutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 1, verifiedRecords.Gauge, map[string]string{\"record_type\": \"a\"})\n\ttestutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 1, verifiedRecords.Gauge, map[string]string{\"record_type\": \"aaaa\"})\n}\n\nfunc TestShouldRunOnce(t *testing.T) {\n\tctrl := &Controller{Interval: 10 * time.Minute, MinEventSyncInterval: 15 * time.Second}\n\n\tnow := time.Now()\n\n\t// First run of Run loop should execute RunOnce\n\tassert.True(t, ctrl.ShouldRunOnce(now))\n\tassert.Equal(t, now.Add(10*time.Minute), ctrl.nextRunAt)\n\n\t// Second run should not\n\tassert.False(t, ctrl.ShouldRunOnce(now))\n\tctrl.lastRunAt = now\n\n\tnow = now.Add(10 * time.Second)\n\t// Changes happen in ingresses or services\n\tctrl.ScheduleRunOnce(now)\n\tctrl.ScheduleRunOnce(now)\n\n\t// Because we batch changes, ShouldRunOnce returns False at first\n\tassert.False(t, ctrl.ShouldRunOnce(now))\n\tassert.False(t, ctrl.ShouldRunOnce(now.Add(100*time.Microsecond)))\n\n\t// But after MinInterval we should run reconciliation\n\tnow = now.Add(5 * time.Second)\n\tassert.True(t, ctrl.ShouldRunOnce(now))\n\n\t// But just one time\n\tassert.False(t, ctrl.ShouldRunOnce(now))\n\n\t// We should wait maximum possible time after last reconciliation started\n\tnow = now.Add(10*time.Minute - time.Second)\n\tassert.False(t, ctrl.ShouldRunOnce(now))\n\n\t// After exactly Interval it's OK again to reconcile\n\tnow = now.Add(time.Second)\n\tassert.True(t, ctrl.ShouldRunOnce(now))\n\n\t// But not two times\n\tassert.False(t, ctrl.ShouldRunOnce(now))\n\n\t// Multiple ingresses or services changes, closer than MinInterval from each other\n\tctrl.lastRunAt = now\n\tfirstChangeTime := now\n\tsecondChangeTime := firstChangeTime.Add(time.Second)\n\t// First change\n\tctrl.ScheduleRunOnce(firstChangeTime)\n\t// Second change\n\tctrl.ScheduleRunOnce(secondChangeTime)\n\n\t// Executions should be spaced by at least MinEventSyncInterval\n\tassert.False(t, ctrl.ShouldRunOnce(now.Add(5*time.Second)))\n\n\t// Should not postpone the reconciliation further than firstChangeTime + MinInterval\n\tnow = now.Add(ctrl.MinEventSyncInterval)\n\tassert.True(t, ctrl.ShouldRunOnce(now))\n}\n\nfunc testControllerFiltersDomains(t *testing.T, configuredEndpoints []*endpoint.Endpoint, domainFilter *endpoint.DomainFilter, providerEndpoints []*endpoint.Endpoint, expectedChanges []*plan.Changes) {\n\tt.Helper()\n\tcfg := externaldns.NewConfig()\n\tcfg.Registry = externaldns.RegistryNoop\n\tcfg.ManagedDNSRecordTypes = []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME}\n\n\tsource := new(testutils.MockSource)\n\tsource.On(\"Endpoints\").Return(configuredEndpoints, nil)\n\n\t// Fake some existing records in our DNS provider and validate some desired changes.\n\tprovider := &filteredMockProvider{\n\t\tRecordsStore: providerEndpoints,\n\t}\n\tr, err := registryfactory.Select(cfg, provider)\n\trequire.NoError(t, err)\n\n\tctrl := &Controller{\n\t\tSource:             source,\n\t\tRegistry:           r,\n\t\tPolicy:             &plan.SyncPolicy{},\n\t\tDomainFilter:       domainFilter,\n\t\tManagedRecordTypes: cfg.ManagedDNSRecordTypes,\n\t}\n\n\tassert.NoError(t, ctrl.RunOnce(t.Context()))\n\tassert.Equal(t, 1, provider.RecordsCallCount)\n\trequire.Len(t, provider.ApplyChangesCalls, len(expectedChanges))\n\tfor i, change := range expectedChanges {\n\t\tassert.Equal(t, *change, *provider.ApplyChangesCalls[i])\n\t}\n}\n\nfunc TestControllerSkipsEmptyChanges(t *testing.T) {\n\ttestControllerFiltersDomains(\n\t\tt,\n\t\t[]*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"create-record.other.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"some-record.used.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t},\n\t\t},\n\t\tendpoint.NewDomainFilter([]string{\"used.tld\"}),\n\t\t[]*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"some-record.used.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t},\n\t\t},\n\t\t[]*plan.Changes{},\n\t)\n}\n\nfunc TestWhenNoFilterControllerConsidersAllDomains(t *testing.T) {\n\ttestControllerFiltersDomains(\n\t\tt,\n\t\t[]*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"create-record.other.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"some-record.used.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t},\n\t\t},\n\t\t&endpoint.DomainFilter{},\n\t\t[]*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"some-record.used.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t},\n\t\t},\n\t\t[]*plan.Changes{\n\t\t\t{\n\t\t\t\tCreate: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:    \"create-record.other.tld\",\n\t\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t)\n}\n\nfunc TestWhenMultipleControllerConsidersAllFilteredComain(t *testing.T) {\n\ttestControllerFiltersDomains(\n\t\tt,\n\t\t[]*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"create-record.other.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"some-record.used.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\tTargets:    endpoint.Targets{\"1.1.1.1\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"create-record.unused.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t},\n\t\t},\n\t\tendpoint.NewDomainFilter([]string{\"used.tld\", \"other.tld\"}),\n\t\t[]*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"some-record.used.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t},\n\t\t},\n\t\t[]*plan.Changes{\n\t\t\t{\n\t\t\t\tCreate: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:    \"create-record.other.tld\",\n\t\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tUpdateOld: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:    \"some-record.used.tld\",\n\t\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t\t\tLabels:     endpoint.Labels{},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:    \"some-record.used.tld\",\n\t\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\t\tTargets:    endpoint.Targets{\"1.1.1.1\"},\n\t\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\t\"owner\": \"\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t)\n}\n\ntype toggleRegistry struct {\n\tnoop.NoopRegistry\n\tfailCount   int\n\tfailCountMu sync.Mutex // protects failCount\n}\n\nconst toggleRegistryFailureCount = 3\n\nfunc (r *toggleRegistry) Records(_ context.Context) ([]*endpoint.Endpoint, error) {\n\tr.failCountMu.Lock()\n\tdefer r.failCountMu.Unlock()\n\tif r.failCount < toggleRegistryFailureCount {\n\t\tr.failCount++\n\t\treturn nil, provider.SoftError\n\t}\n\treturn []*endpoint.Endpoint{}, nil\n}\n\nfunc (r *toggleRegistry) ApplyChanges(_ context.Context, _ *plan.Changes) error {\n\treturn nil\n}\n\nfunc TestToggleRegistry(t *testing.T) {\n\tsource := getTestSource()\n\tcfg := getTestConfig()\n\tr := &toggleRegistry{}\n\n\tinterval := 10 * time.Millisecond\n\tctrl := &Controller{\n\t\tSource:             source,\n\t\tRegistry:           r,\n\t\tPolicy:             &plan.SyncPolicy{},\n\t\tManagedRecordTypes: cfg.ManagedDNSRecordTypes,\n\t\tInterval:           interval,\n\t}\n\tctrl.nextRunAt = time.Now().Add(-time.Millisecond)\n\tctx, cancel := context.WithCancel(t.Context())\n\tstopped := make(chan struct{})\n\tgo func() {\n\t\tctrl.Run(ctx)\n\t\tclose(stopped)\n\t}()\n\n\t// Wait up to 1 minute for failCount to reach at least 3\n\t// The timeout serves as a safety net against infinite loops while being\n\t// sufficiently large to accommodate slow CI environments\n\tdeadline := time.Now().Add(15 * time.Second)\n\tfor {\n\t\tr.failCountMu.Lock()\n\t\tcount := r.failCount\n\t\tr.failCountMu.Unlock()\n\t\tif count >= toggleRegistryFailureCount {\n\t\t\tbreak\n\t\t}\n\t\tif time.Now().After(deadline) {\n\t\t\tbreak\n\t\t}\n\t\t// Sleep for the controller interval to avoid busy waiting\n\t\t// since the controller won't run again until the interval passes\n\t\ttime.Sleep(interval)\n\t}\n\tcancel()\n\t<-stopped\n\n\tr.failCountMu.Lock()\n\tfinalCount := r.failCount\n\tr.failCountMu.Unlock()\n\tassert.Equal(t, toggleRegistryFailureCount, finalCount, \"failCount should be at least %d\", toggleRegistryFailureCount)\n}\n\nfunc TestRunOnce_EmitChangeEvent(t *testing.T) {\n\ttests := []struct {\n\t\tname           string\n\t\tapplyErr       error\n\t\texpectedReason events.Reason\n\t\texpectErr      bool\n\t}{\n\t\t{\n\t\t\tname:           \"emits RecordReady on success\",\n\t\t\texpectedReason: events.RecordReady,\n\t\t},\n\t\t{\n\t\t\tname:           \"emits RecordError on failure\",\n\t\t\tapplyErr:       errors.New(\"apply failed\"),\n\t\t\texpectedReason: events.RecordError,\n\t\t\texpectErr:      true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tsource := new(testutils.MockSource)\n\t\t\tsource.On(\"Endpoints\").Return([]*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"dot.com\", endpoint.RecordTypeA, \"1.2.3.4\").\n\t\t\t\t\tWithRefObject(&events.ObjectReference{}),\n\t\t\t}, nil)\n\n\t\t\tr, err := registryfactory.Select(getTestConfig(), &fakes.MockProvider{ApplyChangesErr: tt.applyErr})\n\t\t\trequire.NoError(t, err)\n\n\t\t\temitter := fake.NewFakeEventEmitter()\n\t\t\tctrl := &Controller{\n\t\t\t\tSource:             source,\n\t\t\t\tRegistry:           r,\n\t\t\t\tPolicy:             &plan.SyncPolicy{},\n\t\t\t\tManagedRecordTypes: []string{endpoint.RecordTypeA},\n\t\t\t\tEventEmitter:       emitter,\n\t\t\t}\n\n\t\t\terr = ctrl.RunOnce(t.Context())\n\t\t\tassert.Equal(t, tt.expectErr, err != nil)\n\n\t\t\temitter.AssertCalled(t, \"Add\", mock.MatchedBy(func(e events.Event) bool {\n\t\t\t\treturn e.Reason() == tt.expectedReason\n\t\t\t}))\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "controller/events.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage controller\n\nimport (\n\t\"sigs.k8s.io/external-dns/pkg/events\"\n\t\"sigs.k8s.io/external-dns/plan\"\n)\n\n// emitChangeEvent emits a Kubernetes event for each DNS record change.\n// Deletes use RecordDeleted on success and RecordError on failure.\nfunc emitChangeEvent(e events.EventEmitter, ch *plan.Changes, reason events.Reason) {\n\tif e == nil {\n\t\treturn\n\t}\n\tfor _, ep := range ch.Create {\n\t\te.Add(events.NewEventFromEndpoint(ep, events.ActionCreate, reason))\n\t}\n\tfor _, ep := range ch.UpdateNew {\n\t\te.Add(events.NewEventFromEndpoint(ep, events.ActionUpdate, reason))\n\t}\n\tdeleteReason := events.RecordDeleted\n\tif reason == events.RecordError {\n\t\tdeleteReason = events.RecordError\n\t}\n\tfor _, ep := range ch.Delete {\n\t\te.Add(events.NewEventFromEndpoint(ep, events.ActionDelete, deleteReason))\n\t}\n}\n"
  },
  {
    "path": "controller/events_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage controller\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/pkg/events\"\n\t\"sigs.k8s.io/external-dns/pkg/events/fake\"\n\t\"sigs.k8s.io/external-dns/plan\"\n)\n\nfunc TestEmit_RecordReady(t *testing.T) {\n\trefObj := &events.ObjectReference{}\n\n\ttests := []struct {\n\t\tname    string\n\t\tchanges plan.Changes\n\t\tasserts func(em *fake.EventEmitter, ch plan.Changes)\n\t}{\n\t\t{\n\t\t\tname: \"create, update and delete endpoints\",\n\t\t\tchanges: plan.Changes{\n\t\t\t\tCreate: []*endpoint.Endpoint{\n\t\t\t\t\tendpoint.NewEndpoint(\"one.example.com\", endpoint.RecordTypeA, \"10.10.10.0\").WithRefObject(refObj),\n\t\t\t\t\tendpoint.NewEndpoint(\"two.example.com\", endpoint.RecordTypeA, \"10.10.10.1\").WithRefObject(refObj),\n\t\t\t\t},\n\t\t\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\t\t\tendpoint.NewEndpoint(\"three.example.com\", endpoint.RecordTypeA, \"10.10.10.2\").WithRefObject(refObj),\n\t\t\t\t\tendpoint.NewEndpoint(\"four.example.com\", endpoint.RecordTypeA, \"10.10.10.3\").WithRefObject(refObj),\n\t\t\t\t},\n\t\t\t\tDelete: []*endpoint.Endpoint{\n\t\t\t\t\tendpoint.NewEndpoint(\"five.example.com\", endpoint.RecordTypeA, \"192.10.10.0\").WithRefObject(refObj),\n\t\t\t\t},\n\t\t\t},\n\t\t\tasserts: func(em *fake.EventEmitter, ch plan.Changes) {\n\t\t\t\tfor _, ep := range ch.Create {\n\t\t\t\t\tem.AssertCalled(t, \"Add\", events.NewEventFromEndpoint(ep, events.ActionCreate, events.RecordReady))\n\t\t\t\t}\n\t\t\t\tfor _, ep := range ch.Delete {\n\t\t\t\t\tem.AssertCalled(t, \"Add\", events.NewEventFromEndpoint(ep, events.ActionDelete, events.RecordDeleted))\n\t\t\t\t}\n\t\t\t\tem.AssertNotCalled(t, \"Add\", mock.MatchedBy(func(e events.Event) bool {\n\t\t\t\t\treturn e.EventType() == events.EventTypeWarning\n\t\t\t\t}))\n\t\t\t\tem.AssertNumberOfCalls(t, \"Add\", 5)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"delete endpoints\",\n\t\t\tchanges: plan.Changes{\n\t\t\t\tCreate:    []*endpoint.Endpoint{},\n\t\t\t\tUpdateNew: []*endpoint.Endpoint{},\n\t\t\t\tDelete: []*endpoint.Endpoint{\n\t\t\t\t\tendpoint.NewEndpoint(\"five.example.com\", endpoint.RecordTypeA, \"192.10.10.0\").WithRefObject(refObj),\n\t\t\t\t},\n\t\t\t},\n\t\t\tasserts: func(em *fake.EventEmitter, ch plan.Changes) {\n\t\t\t\tfor _, ep := range ch.Delete {\n\t\t\t\t\tem.AssertCalled(t, \"Add\", events.NewEventFromEndpoint(ep, events.ActionDelete, events.RecordDeleted))\n\t\t\t\t}\n\t\t\t\tem.AssertCalled(t, \"Add\", mock.MatchedBy(func(e events.Event) bool {\n\t\t\t\t\treturn e.EventType() == events.EventTypeNormal &&\n\t\t\t\t\t\te.Action() == events.ActionDelete &&\n\t\t\t\t\t\te.Reason() == events.RecordDeleted\n\t\t\t\t}))\n\n\t\t\t\tem.AssertNumberOfCalls(t, \"Add\", 1)\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\temitter := fake.NewFakeEventEmitter()\n\n\t\t\temitChangeEvent(emitter, &tt.changes, events.RecordReady)\n\n\t\t\ttt.asserts(emitter, tt.changes)\n\t\t\tmock.AssertExpectationsForObjects(t, emitter)\n\t\t})\n\t}\n}\n\nfunc TestEmit_NilEmitter(t *testing.T) {\n\tassert.NotPanics(t, func() {\n\t\temitChangeEvent(nil, &plan.Changes{}, events.RecordError)\n\t})\n}\n\nfunc TestEmit_RecordError(t *testing.T) {\n\trefObj := &events.ObjectReference{}\n\n\ttests := []struct {\n\t\tname    string\n\t\tchanges plan.Changes\n\t\tasserts func(em *fake.EventEmitter, ch plan.Changes)\n\t}{\n\t\t{\n\t\t\tname: \"create, update and delete endpoints\",\n\t\t\tchanges: plan.Changes{\n\t\t\t\tCreate: []*endpoint.Endpoint{\n\t\t\t\t\tendpoint.NewEndpoint(\"one.example.com\", endpoint.RecordTypeA, \"10.10.10.0\").WithRefObject(refObj),\n\t\t\t\t},\n\t\t\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\t\t\tendpoint.NewEndpoint(\"two.example.com\", endpoint.RecordTypeA, \"10.10.10.1\").WithRefObject(refObj),\n\t\t\t\t},\n\t\t\t\tDelete: []*endpoint.Endpoint{\n\t\t\t\t\tendpoint.NewEndpoint(\"three.example.com\", endpoint.RecordTypeA, \"10.10.10.2\").WithRefObject(refObj),\n\t\t\t\t},\n\t\t\t},\n\t\t\tasserts: func(em *fake.EventEmitter, ch plan.Changes) {\n\t\t\t\tem.AssertCalled(t, \"Add\", events.NewEventFromEndpoint(ch.Create[0], events.ActionCreate, events.RecordError))\n\t\t\t\tem.AssertCalled(t, \"Add\", events.NewEventFromEndpoint(ch.UpdateNew[0], events.ActionUpdate, events.RecordError))\n\t\t\t\tem.AssertCalled(t, \"Add\", events.NewEventFromEndpoint(ch.Delete[0], events.ActionDelete, events.RecordError))\n\t\t\t\tem.AssertNumberOfCalls(t, \"Add\", 3)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"delete endpoints emit RecordError not RecordDeleted\",\n\t\t\tchanges: plan.Changes{\n\t\t\t\tCreate:    []*endpoint.Endpoint{},\n\t\t\t\tUpdateNew: []*endpoint.Endpoint{},\n\t\t\t\tDelete: []*endpoint.Endpoint{\n\t\t\t\t\tendpoint.NewEndpoint(\"five.example.com\", endpoint.RecordTypeA, \"192.10.10.0\").WithRefObject(refObj),\n\t\t\t\t},\n\t\t\t},\n\t\t\tasserts: func(em *fake.EventEmitter, ch plan.Changes) {\n\t\t\t\tem.AssertCalled(t, \"Add\", events.NewEventFromEndpoint(ch.Delete[0], events.ActionDelete, events.RecordError))\n\t\t\t\tem.AssertNotCalled(t, \"Add\", mock.MatchedBy(func(e events.Event) bool {\n\t\t\t\t\treturn e.Reason() == events.RecordDeleted\n\t\t\t\t}))\n\t\t\t\tem.AssertNumberOfCalls(t, \"Add\", 1)\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\temitter := fake.NewFakeEventEmitter()\n\n\t\t\temitChangeEvent(emitter, &tt.changes, events.RecordError)\n\n\t\t\ttt.asserts(emitter, tt.changes)\n\t\t\tmock.AssertExpectationsForObjects(t, emitter)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "controller/execute.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage controller\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/signal\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/go-logr/logr\"\n\t\"github.com/prometheus/client_golang/prometheus/promhttp\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"k8s.io/klog/v2\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns/validation\"\n\t\"sigs.k8s.io/external-dns/pkg/events\"\n\t\"sigs.k8s.io/external-dns/pkg/metrics\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n\tproviderfactory \"sigs.k8s.io/external-dns/provider/factory\"\n\twebhookapi \"sigs.k8s.io/external-dns/provider/webhook/api\"\n\tregistryfactory \"sigs.k8s.io/external-dns/registry/factory\"\n\t\"sigs.k8s.io/external-dns/source\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n\t\"sigs.k8s.io/external-dns/source/wrappers\"\n)\n\nfunc Execute() {\n\tcfg := externaldns.NewConfig()\n\tif err := cfg.ParseFlags(os.Args[1:]); err != nil {\n\t\tlog.Fatalf(\"flag parsing error: %v\", err)\n\t}\n\tlog.Infof(\"config: %s\", cfg)\n\tif err := validation.ValidateConfig(cfg); err != nil {\n\t\tlog.Fatalf(\"config validation failed: %v\", err)\n\t}\n\n\t// Set annotation prefix (required since init() was removed)\n\tannotations.SetAnnotationPrefix(cfg.AnnotationPrefix)\n\tif cfg.AnnotationPrefix != annotations.DefaultAnnotationPrefix {\n\t\tlog.Infof(\"Using custom annotation prefix: %s\", cfg.AnnotationPrefix)\n\t}\n\n\tconfigureLogger(cfg)\n\n\tif cfg.DryRun {\n\t\tlog.Info(\"running in dry-run mode. No changes to DNS records will be made.\")\n\t}\n\n\tif log.GetLevel() < log.DebugLevel {\n\t\t// Klog V2 is used by k8s.io/apimachinery/pkg/labels and can throw (a lot) of irrelevant logs\n\t\t// See https://github.com/kubernetes-sigs/external-dns/issues/2348\n\t\tdefer klog.ClearLogger()\n\t\tklog.SetLogger(logr.Discard())\n\t}\n\n\tlog.Info(externaldns.Banner())\n\n\tctx, cancel := context.WithCancel(context.Background())\n\n\tgo serveMetrics(cfg.MetricsAddress)\n\tgo handleSigterm(cancel)\n\n\tsCfg := source.NewSourceConfig(cfg)\n\tendpointsSource, err := buildSource(ctx, sCfg)\n\tif err != nil {\n\t\tlog.Fatal(err) // nolint: gocritic // exitAfterDefer\n\t}\n\n\tdomainFilter := endpoint.NewDomainFilterWithOptions(\n\t\tendpoint.WithDomainFilter(cfg.DomainFilter),\n\t\tendpoint.WithDomainExclude(cfg.DomainExclude),\n\t\tendpoint.WithRegexDomainFilter(cfg.RegexDomainFilter),\n\t\tendpoint.WithRegexDomainExclude(cfg.RegexDomainExclude),\n\t)\n\n\tprvdr, err := providerfactory.Select(ctx, cfg, domainFilter)\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\n\tif cfg.WebhookServer {\n\t\twebhookapi.StartHTTPApi(prvdr, nil, cfg.WebhookProviderReadTimeout, cfg.WebhookProviderWriteTimeout, \"127.0.0.1:8888\")\n\t\tos.Exit(0)\n\t}\n\n\tctrl, err := buildController(ctx, cfg, sCfg, endpointsSource, prvdr, domainFilter)\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\n\tif cfg.Once {\n\t\terr := ctrl.RunOnce(ctx)\n\t\tif err != nil {\n\t\t\tlog.Fatal(err)\n\t\t}\n\n\t\tos.Exit(0)\n\t}\n\n\tif cfg.UpdateEvents {\n\t\t// Add RunOnce as the handler function that will be called when ingress/service sources have changed.\n\t\t// Note that k8s Informers will perform an initial list operation, which results in the handler\n\t\t// function initially being called for every Service/Ingress that exists\n\t\tctrl.Source.AddEventHandler(ctx, func() { ctrl.ScheduleRunOnce(time.Now()) })\n\t}\n\n\tctrl.ScheduleRunOnce(time.Now())\n\tctrl.Run(ctx)\n}\n\nfunc buildController(\n\tctx context.Context,\n\tcfg *externaldns.Config,\n\tsCfg *source.Config,\n\tsrc source.Source,\n\tp provider.Provider,\n\tfilter *endpoint.DomainFilter,\n) (*Controller, error) {\n\tpolicy, ok := plan.Policies[cfg.Policy]\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown policy: %s\", cfg.Policy)\n\t}\n\treg, err := registryfactory.Select(cfg, p)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\teventsCfg := events.NewConfig(\n\t\tevents.WithEmitEvents(cfg.EmitEvents),\n\t\tevents.WithDryRun(cfg.DryRun))\n\tvar eventEmitter events.EventEmitter\n\tif eventsCfg.IsEnabled() {\n\t\tkubeClient, err := sCfg.ClientGenerator().KubeClient()\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\teventCtrl, err := events.NewEventController(kubeClient.EventsV1(), eventsCfg)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\teventCtrl.Run(ctx)\n\t\teventEmitter = eventCtrl\n\t}\n\n\treturn &Controller{\n\t\tSource:               src,\n\t\tRegistry:             reg,\n\t\tPolicy:               policy,\n\t\tInterval:             cfg.Interval,\n\t\tDomainFilter:         filter,\n\t\tManagedRecordTypes:   cfg.ManagedDNSRecordTypes,\n\t\tExcludeRecordTypes:   cfg.ExcludeDNSRecordTypes,\n\t\tMinEventSyncInterval: cfg.MinEventSyncInterval,\n\t\tTXTOwnerOld:          cfg.TXTOwnerOld,\n\t\tEventEmitter:         eventEmitter,\n\t}, nil\n}\n\n// This function configures the logger format and level based on the provided configuration.\nfunc configureLogger(cfg *externaldns.Config) {\n\tif cfg.LogFormat == \"json\" {\n\t\tlog.SetFormatter(&log.JSONFormatter{})\n\t}\n\tll, err := log.ParseLevel(cfg.LogLevel)\n\tif err != nil {\n\t\tlog.Fatalf(\"failed to parse log level: %v\", err)\n\t}\n\tlog.SetLevel(ll)\n}\n\n// buildSource creates and configures the source(s) for endpoint discovery based on the provided configuration.\n// It initializes the source configuration, generates the required sources, and combines them into a single,\n// deduplicated source. Returns the combined source or an error if source creation fails.\nfunc buildSource(ctx context.Context, cfg *source.Config) (source.Source, error) {\n\tsources, err := source.ByNames(ctx, cfg, cfg.ClientGenerator())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\topts := wrappers.NewConfig(\n\t\twrappers.WithDefaultTargets(cfg.DefaultTargets),\n\t\twrappers.WithForceDefaultTargets(cfg.ForceDefaultTargets),\n\t\twrappers.WithNAT64Networks(cfg.NAT64Networks),\n\t\twrappers.WithTargetNetFilter(cfg.TargetNetFilter),\n\t\twrappers.WithExcludeTargetNets(cfg.ExcludeTargetNets),\n\t\twrappers.WithMinTTL(cfg.MinTTL),\n\t\twrappers.WithProvider(cfg.Provider),\n\t\twrappers.WithPreferAlias(cfg.PreferAlias))\n\treturn wrappers.WrapSources(sources, opts)\n}\n\n// handleSigterm listens for a SIGTERM signal and triggers the provided cancel function\n// to gracefully terminate the application. It logs a message when the signal is received.\nfunc handleSigterm(cancel func()) {\n\tsignals := make(chan os.Signal, 1)\n\tsignal.Notify(signals, syscall.SIGTERM)\n\t<-signals\n\tlog.Info(\"Received SIGTERM. Terminating...\")\n\tcancel()\n}\n\n// serveMetrics starts an HTTP server that serves health and metrics endpoints.\n// The /healthz endpoint returns a 200 OK status to indicate the service is healthy.\n// The /metrics endpoint serves Prometheus metrics.\n// The server listens on the specified address and logs debug information about the endpoints.\nfunc serveMetrics(address string) {\n\thttp.HandleFunc(\"/healthz\", func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t\t_, _ = w.Write([]byte(\"OK\"))\n\t})\n\n\tlog.Debugf(\"serving 'healthz' on '%s/healthz'\", address)\n\tlog.Debugf(\"serving 'metrics' on '%s/metrics'\", address)\n\tlog.Debugf(\"registered '%d' metrics\", len(metrics.RegisterMetric.Metrics))\n\n\thttp.Handle(\"/metrics\", promhttp.Handler())\n\n\tlog.Fatal(http.ListenAndServe(address, nil))\n}\n"
  },
  {
    "path": "controller/execute_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage controller\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"testing\"\n\t\"time\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\tprovider \"sigs.k8s.io/external-dns/provider/factory\"\n\t\"sigs.k8s.io/external-dns/source\"\n)\n\n// Logger\nfunc TestConfigureLogger(t *testing.T) {\n\ttests := []struct {\n\t\tname       string\n\t\tcfg        *externaldns.Config\n\t\twantLevel  log.Level\n\t\twantJSON   bool\n\t\twantErr    bool\n\t\twantErrMsg string\n\t}{\n\t\t{\n\t\t\tname: \"Default log format and level\",\n\t\t\tcfg: &externaldns.Config{\n\t\t\t\tLogLevel:  \"info\",\n\t\t\t\tLogFormat: \"text\",\n\t\t\t},\n\t\t\twantLevel: log.InfoLevel,\n\t\t},\n\t\t{\n\t\t\tname: \"JSON log format\",\n\t\t\tcfg: &externaldns.Config{\n\t\t\t\tLogLevel:  \"debug\",\n\t\t\t\tLogFormat: \"json\",\n\t\t\t},\n\t\t\twantLevel: log.DebugLevel,\n\t\t\twantJSON:  true,\n\t\t},\n\t\t{\n\t\t\tname: \"Invalid log level\",\n\t\t\tcfg: &externaldns.Config{\n\t\t\t\tLogLevel:  \"invalid\",\n\t\t\t\tLogFormat: \"text\",\n\t\t\t},\n\t\t\twantLevel:  log.InfoLevel,\n\t\t\twantErr:    true,\n\t\t\twantErrMsg: \"failed to parse log level\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif tt.wantErr {\n\t\t\t\t// Capture and suppress fatal exit; restore logger after test\n\t\t\t\tlogger := log.StandardLogger()\n\t\t\t\tprevOut := logger.Out\n\t\t\t\tprevExit := logger.ExitFunc\n\t\t\t\tb := new(bytes.Buffer)\n\t\t\t\tvar captureLogFatal bool\n\t\t\t\tlogger.ExitFunc = func(int) { captureLogFatal = true }\n\t\t\t\tlogger.SetOutput(b)\n\t\t\t\tt.Cleanup(func() {\n\t\t\t\t\tlogger.SetOutput(prevOut)\n\t\t\t\t\tlogger.ExitFunc = prevExit\n\t\t\t\t})\n\n\t\t\t\tconfigureLogger(tt.cfg)\n\n\t\t\t\tassert.True(t, captureLogFatal)\n\t\t\t\tassert.Contains(t, b.String(), tt.wantErrMsg)\n\t\t\t} else {\n\t\t\t\t// Save and restore logger state to avoid leaking between tests\n\t\t\t\tlogger := log.StandardLogger()\n\t\t\t\tprevFormatter := logger.Formatter\n\t\t\t\tprevLevel := log.GetLevel()\n\t\t\t\tt.Cleanup(func() {\n\t\t\t\t\tlog.SetLevel(prevLevel)\n\t\t\t\t\tlogger.SetFormatter(prevFormatter)\n\t\t\t\t})\n\n\t\t\t\tconfigureLogger(tt.cfg)\n\t\t\t\tassert.Equal(t, tt.wantLevel, log.GetLevel())\n\n\t\t\t\tif tt.wantJSON {\n\t\t\t\t\tassert.IsType(t, &log.JSONFormatter{}, log.StandardLogger().Formatter)\n\t\t\t\t} else {\n\t\t\t\t\tassert.IsType(t, &log.TextFormatter{}, log.StandardLogger().Formatter)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestBuildSourceWithWrappers(t *testing.T) {\n\tsvr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusNotImplemented)\n\t}))\n\tdefer svr.Close()\n\n\ttests := []struct {\n\t\tname    string\n\t\tcfg     *externaldns.Config\n\t\tasserts func(*testing.T, *externaldns.Config)\n\t}{\n\t\t{\n\t\t\tname: \"configuration with target filter wrapper\",\n\t\t\tcfg: &externaldns.Config{\n\t\t\t\tAPIServerURL:    svr.URL,\n\t\t\t\tSources:         []string{\"fake\"},\n\t\t\t\tTargetNetFilter: []string{\"10.0.0.0/8\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"configuration with nat64 networks\",\n\t\t\tcfg: &externaldns.Config{\n\t\t\t\tAPIServerURL:  svr.URL,\n\t\t\t\tSources:       []string{\"fake\"},\n\t\t\t\tNAT64Networks: []string{\"2001:db8::/96\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"default configuration\",\n\t\t\tcfg: &externaldns.Config{\n\t\t\t\tAPIServerURL: svr.URL,\n\t\t\t\tSources:      []string{\"fake\"},\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t_, err := buildSource(t.Context(), source.NewSourceConfig(tt.cfg))\n\t\t\trequire.NoError(t, err)\n\t\t})\n\t}\n}\n\n// Helper used by runExecuteSubprocess.\nfunc TestHelperProcess(_ *testing.T) {\n\tif os.Getenv(\"GO_WANT_HELPER_PROCESS\") != \"1\" {\n\t\treturn\n\t}\n\t// Parse args after the \"--\" sentinel.\n\tidx := -1\n\tfor i, a := range os.Args {\n\t\tif a == \"--\" {\n\t\t\tidx = i\n\t\t\tbreak\n\t\t}\n\t}\n\tvar args []string\n\tif idx >= 0 {\n\t\targs = os.Args[idx+1:]\n\t}\n\tos.Args = append([]string{\"external-dns\"}, args...)\n\tExecute()\n}\n\n// runExecuteSubprocess runs Execute in a separate process and returns exit code.\nfunc runExecuteSubprocess(t *testing.T, args []string) (int, error) {\n\tt.Helper()\n\t// make sure the subprocess does not run forever\n\tctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)\n\tdefer cancel()\n\n\t// TODO: investigate why -test.run=TestHelperProcess\n\tcmdArgs := append([]string{\"-test.run=TestHelperProcess\", \"--\"}, args...)\n\tcmd := exec.CommandContext(ctx, os.Args[0], cmdArgs...)\n\tcmd.Env = append(os.Environ(), \"GO_WANT_HELPER_PROCESS=1\")\n\tvar buf bytes.Buffer\n\tcmd.Stdout = &buf\n\tcmd.Stderr = &buf\n\terr := cmd.Run()\n\tif errors.Is(ctx.Err(), context.DeadlineExceeded) {\n\t\treturn -1, ctx.Err()\n\t}\n\tif err == nil {\n\t\treturn 0, nil\n\t}\n\tee := &exec.ExitError{}\n\tif errors.As(err, &ee) {\n\t\treturn ee.ExitCode(), nil\n\t}\n\treturn -1, err\n}\n\nfunc TestExecuteOnceDryRunExitsZero(t *testing.T) {\n\t// Use :0 for an ephemeral metrics port.\n\tcode, err := runExecuteSubprocess(t, []string{\n\t\t\"--source\", \"fake\",\n\t\t\"--provider\", \"inmemory\",\n\t\t\"--once\",\n\t\t\"--dry-run\",\n\t\t\"--metrics-address\", \":0\",\n\t})\n\trequire.NoError(t, err)\n\tassert.Equal(t, 0, code)\n}\n\nfunc TestExecuteUnknownProviderExitsNonZero(t *testing.T) {\n\tcode, err := runExecuteSubprocess(t, []string{\n\t\t\"--source\", \"fake\",\n\t\t\"--provider\", \"unknown\",\n\t\t\"--metrics-address\", \":0\",\n\t})\n\trequire.NoError(t, err)\n\tassert.NotEqual(t, 0, code)\n}\n\nfunc TestExecuteValidationErrorNoSources(t *testing.T) {\n\tcode, err := runExecuteSubprocess(t, []string{\n\t\t\"--provider\", \"inmemory\",\n\t\t\"--metrics-address\", \":0\",\n\t})\n\trequire.NoError(t, err)\n\tassert.NotEqual(t, 0, code)\n}\n\nfunc TestExecuteFlagParsingErrorInvalidLogFormat(t *testing.T) {\n\tcode, err := runExecuteSubprocess(t, []string{\n\t\t\"--log-format\", \"invalid\",\n\t\t// Provide minimal required flags to keep errors focused on parsing\n\t\t\"--source\", \"fake\",\n\t\t\"--provider\", \"inmemory\",\n\t\t\"--metrics-address\", \":0\",\n\t})\n\trequire.NoError(t, err)\n\tassert.NotEqual(t, 0, code)\n}\n\n// Config validation failure triggers log.Fatalf.\nfunc TestExecuteConfigValidationErrorExitsNonZero(t *testing.T) {\n\tcode, err := runExecuteSubprocess(t, []string{\n\t\t\"--source\", \"fake\",\n\t\t// Choose a provider with validation that fails without required flags\n\t\t\"--provider\", \"azure\",\n\t\t// No --azure-config-file provided\n\t\t\"--metrics-address\", \":0\",\n\t})\n\trequire.NoError(t, err)\n\tassert.NotEqual(t, 0, code)\n}\n\n// buildSource failure triggers log.Fatal.\nfunc TestExecuteBuildSourceErrorExitsNonZero(t *testing.T) {\n\t// Use a valid source name (ingress) and an invalid kubeconfig path to\n\t// force client creation failure inside buildSource.\n\tcode, err := runExecuteSubprocess(t, []string{\n\t\t\"--source\", \"ingress\",\n\t\t\"--kubeconfig\", \"this/path/does/not/exist\",\n\t\t\"--provider\", \"inmemory\",\n\t\t\"--metrics-address\", \":0\",\n\t})\n\trequire.NoError(t, err)\n\tassert.NotEqual(t, 0, code)\n}\n\n// RunOnce error exits non-zero.\nfunc TestExecuteRunOnceErrorExitsNonZero(t *testing.T) {\n\t// Connector source dials a TCP server; use a closed port to fail.\n\tcode, err := runExecuteSubprocess(t, []string{\n\t\t\"--source\", \"connector\",\n\t\t\"--connector-source-server\", \"127.0.0.1:1\",\n\t\t\"--provider\", \"inmemory\",\n\t\t\"--once\",\n\t\t\"--metrics-address\", \":0\",\n\t})\n\trequire.NoError(t, err)\n\tassert.NotEqual(t, 0, code)\n}\n\n// Run loop error exits non-zero.\nfunc TestExecuteRunLoopErrorExitsNonZero(t *testing.T) {\n\tcode, err := runExecuteSubprocess(t, []string{\n\t\t\"--source\", \"connector\",\n\t\t\"--connector-source-server\", \"127.0.0.1:1\",\n\t\t\"--provider\", \"inmemory\",\n\t\t\"--metrics-address\", \":0\",\n\t})\n\trequire.NoError(t, err)\n\tassert.NotEqual(t, 0, code)\n}\n\n// buildController registry-creation failure triggers log.Fatal.\nfunc TestExecuteBuildControllerErrorExitsNonZero(t *testing.T) {\n\tcode, err := runExecuteSubprocess(t, []string{\n\t\t\"--source\", \"fake\",\n\t\t\"--provider\", \"inmemory\",\n\t\t\"--registry\", \"dynamodb\",\n\t\t// Force NewDynamoDBRegistry to fail validation by using empty owner id\n\t\t\"--txt-owner-id\", \"\",\n\t\t\"--metrics-address\", \":0\",\n\t})\n\trequire.NoError(t, err)\n\tassert.NotEqual(t, 0, code)\n}\n\n// Controller run loop stops on context cancel.\nfunc TestControllerRunCancelContextStopsLoop(t *testing.T) {\n\t// Minimal controller using fake source and inmemory provider.\n\tcfg := &externaldns.Config{\n\t\tSources:    []string{\"fake\"},\n\t\tProvider:   \"inmemory\",\n\t\tLogLevel:   \"error\",\n\t\tLogFormat:  \"text\",\n\t\tPolicy:     \"sync\",\n\t\tRegistry:   \"txt\",\n\t\tTXTOwnerID: \"test-owner\",\n\t}\n\tsCfg := source.NewSourceConfig(cfg)\n\tctx, cancel := context.WithCancel(t.Context())\n\tdefer cancel()\n\tsrc, err := buildSource(ctx, sCfg)\n\trequire.NoError(t, err)\n\tdomainFilter := endpoint.NewDomainFilterWithOptions(\n\t\tendpoint.WithDomainFilter(cfg.DomainFilter),\n\t\tendpoint.WithDomainExclude(cfg.DomainExclude),\n\t\tendpoint.WithRegexDomainFilter(cfg.RegexDomainFilter),\n\t\tendpoint.WithRegexDomainExclude(cfg.RegexDomainExclude),\n\t)\n\tp, err := provider.Select(ctx, cfg, domainFilter)\n\trequire.NoError(t, err)\n\tctrl, err := buildController(ctx, cfg, sCfg, src, p, domainFilter)\n\trequire.NoError(t, err)\n\n\tdone := make(chan struct{})\n\tgo func() {\n\t\tctrl.Run(ctx)\n\t\tclose(done)\n\t}()\n\tcancel()\n\tselect {\n\tcase <-done:\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"controller did not stop after context cancellation\")\n\t}\n}\n"
  },
  {
    "path": "controller/metrics.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage controller\n\nimport (\n\t\"github.com/prometheus/client_golang/prometheus\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/pkg/metrics\"\n)\n\nvar (\n\tregistryErrorsTotal = metrics.NewCounterWithOpts(\n\t\tprometheus.CounterOpts{\n\t\t\tSubsystem: \"registry\",\n\t\t\tName:      \"errors_total\",\n\t\t\tHelp:      \"Number of Registry errors.\",\n\t\t},\n\t)\n\tsourceErrorsTotal = metrics.NewCounterWithOpts(\n\t\tprometheus.CounterOpts{\n\t\t\tSubsystem: \"source\",\n\t\t\tName:      \"errors_total\",\n\t\t\tHelp:      \"Number of Source errors.\",\n\t\t},\n\t)\n\tsourceEndpointsTotal = metrics.NewGaugeWithOpts(\n\t\tprometheus.GaugeOpts{\n\t\t\tSubsystem: \"source\",\n\t\t\tName:      \"endpoints_total\",\n\t\t\tHelp:      \"Number of Endpoints in all sources\",\n\t\t},\n\t)\n\tregistryEndpointsTotal = metrics.NewGaugeWithOpts(\n\t\tprometheus.GaugeOpts{\n\t\t\tSubsystem: \"registry\",\n\t\t\tName:      \"endpoints_total\",\n\t\t\tHelp:      \"Number of Endpoints in the registry\",\n\t\t},\n\t)\n\tlastSyncTimestamp = metrics.NewGaugeWithOpts(\n\t\tprometheus.GaugeOpts{\n\t\t\tSubsystem: \"controller\",\n\t\t\tName:      \"last_sync_timestamp_seconds\",\n\t\t\tHelp:      \"Timestamp of last successful sync with the DNS provider\",\n\t\t},\n\t)\n\tlastReconcileTimestamp = metrics.NewGaugeWithOpts(\n\t\tprometheus.GaugeOpts{\n\t\t\tSubsystem: \"controller\",\n\t\t\tName:      \"last_reconcile_timestamp_seconds\",\n\t\t\tHelp:      \"Timestamp of last attempted sync with the DNS provider\",\n\t\t},\n\t)\n\tcontrollerNoChangesTotal = metrics.NewCounterWithOpts(\n\t\tprometheus.CounterOpts{\n\t\t\tSubsystem: \"controller\",\n\t\t\tName:      \"no_op_runs_total\",\n\t\t\tHelp:      \"Number of reconcile loops ending up with no changes on the DNS provider side.\",\n\t\t},\n\t)\n\tdeprecatedRegistryErrors = metrics.NewCounterWithOpts(\n\t\tprometheus.CounterOpts{\n\t\t\tSubsystem: \"registry\",\n\t\t\tName:      \"errors_total\",\n\t\t\tHelp:      \"Number of Registry errors.\",\n\t\t},\n\t)\n\tdeprecatedSourceErrors = metrics.NewCounterWithOpts(\n\t\tprometheus.CounterOpts{\n\t\t\tSubsystem: \"source\",\n\t\t\tName:      \"errors_total\",\n\t\t\tHelp:      \"Number of Source errors.\",\n\t\t},\n\t)\n\n\tregistryRecords = metrics.NewGaugedVectorOpts(\n\t\tprometheus.GaugeOpts{\n\t\t\tSubsystem: \"registry\",\n\t\t\tName:      \"records\",\n\t\t\tHelp:      \"Number of registry records partitioned by label name (vector).\",\n\t\t},\n\t\t[]string{\"record_type\"},\n\t)\n\n\tsourceRecords = metrics.NewGaugedVectorOpts(\n\t\tprometheus.GaugeOpts{\n\t\t\tSubsystem: \"source\",\n\t\t\tName:      \"records\",\n\t\t\tHelp:      \"Number of source records partitioned by label name (vector).\",\n\t\t},\n\t\t[]string{\"record_type\"},\n\t)\n\n\tverifiedRecords = metrics.NewGaugedVectorOpts(\n\t\tprometheus.GaugeOpts{\n\t\t\tSubsystem: \"controller\",\n\t\t\tName:      \"verified_records\",\n\t\t\tHelp:      \"Number of DNS records that exists both in source and registry (vector).\",\n\t\t},\n\t\t[]string{\"record_type\"},\n\t)\n\n\tconsecutiveSoftErrors = metrics.NewGaugeWithOpts(\n\t\tprometheus.GaugeOpts{\n\t\t\tSubsystem: \"controller\",\n\t\t\tName:      \"consecutive_soft_errors\",\n\t\t\tHelp:      \"Number of consecutive soft errors in reconciliation loop.\",\n\t\t},\n\t)\n)\n\nfunc init() {\n\tmetrics.RegisterMetric.MustRegister(registryErrorsTotal)\n\tmetrics.RegisterMetric.MustRegister(sourceErrorsTotal)\n\tmetrics.RegisterMetric.MustRegister(sourceEndpointsTotal)\n\tmetrics.RegisterMetric.MustRegister(registryEndpointsTotal)\n\tmetrics.RegisterMetric.MustRegister(lastSyncTimestamp)\n\tmetrics.RegisterMetric.MustRegister(lastReconcileTimestamp)\n\tmetrics.RegisterMetric.MustRegister(deprecatedRegistryErrors)\n\tmetrics.RegisterMetric.MustRegister(deprecatedSourceErrors)\n\tmetrics.RegisterMetric.MustRegister(controllerNoChangesTotal)\n\n\tmetrics.RegisterMetric.MustRegister(registryRecords)\n\tmetrics.RegisterMetric.MustRegister(sourceRecords)\n\tmetrics.RegisterMetric.MustRegister(verifiedRecords)\n\n\tmetrics.RegisterMetric.MustRegister(consecutiveSoftErrors)\n}\n\ntype dnsKey struct {\n\tname       string\n\trecordType string\n}\n\n// countMatchingAddressRecords counts records that exist in both endpoints and registry.\nfunc countMatchingAddressRecords(endpoints []*endpoint.Endpoint, registryRecords []*endpoint.Endpoint, metric metrics.GaugeVecMetric) {\n\tmetric.Gauge.Reset()\n\n\tregistry := make(map[dnsKey]struct{}, len(registryRecords))\n\tfor _, r := range registryRecords {\n\t\tregistry[dnsKey{r.DNSName, r.RecordType}] = struct{}{}\n\t}\n\n\tcounts := make(map[string]float64)\n\tfor _, ep := range endpoints {\n\t\tif _, found := registry[dnsKey{ep.DNSName, ep.RecordType}]; found {\n\t\t\tcounts[ep.RecordType]++\n\t\t}\n\t}\n\n\tfor recordType, count := range counts {\n\t\tmetric.AddWithLabels(count, recordType)\n\t}\n}\n\n// countAddressRecords counts each record type in the provided endpoints slice.\nfunc countAddressRecords(endpoints []*endpoint.Endpoint, metric metrics.GaugeVecMetric) {\n\tmetric.Gauge.Reset()\n\n\tcounts := make(map[string]float64)\n\tfor _, ep := range endpoints {\n\t\tcounts[ep.RecordType]++\n\t}\n\n\tfor recordType, count := range counts {\n\t\tmetric.AddWithLabels(count, recordType)\n\t}\n}\n"
  },
  {
    "path": "controller/metrics_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage controller\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/internal/testutils\"\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\tregistryfactory \"sigs.k8s.io/external-dns/registry/factory\"\n)\n\nfunc TestVerifyARecords(t *testing.T) {\n\ttestControllerFiltersDomains(\n\t\tt,\n\t\t[]*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"create-record.used.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"some-record.used.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t},\n\t\t},\n\t\tendpoint.NewDomainFilter([]string{\"used.tld\"}),\n\t\t[]*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"some-record.used.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"create-record.used.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t},\n\t\t},\n\t\t[]*plan.Changes{},\n\t)\n\n\ttestutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 2, verifiedRecords.Gauge, map[string]string{\"record_type\": \"a\"})\n\n\ttestControllerFiltersDomains(\n\t\tt,\n\t\t[]*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"some-record.1.used.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"some-record.2.used.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"some-record.3.used.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\tTargets:    endpoint.Targets{\"24.24.24.24\"},\n\t\t\t},\n\t\t},\n\t\tendpoint.NewDomainFilter([]string{\"used.tld\"}),\n\t\t[]*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"some-record.1.used.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"some-record.2.used.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t},\n\t\t},\n\t\t[]*plan.Changes{{\n\t\t\tCreate: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"some-record.3.used.tld\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"24.24.24.24\"},\n\t\t\t\t},\n\t\t\t},\n\t\t}},\n\t)\n\n\ttestutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 2, verifiedRecords.Gauge, map[string]string{\"record_type\": \"a\"})\n\ttestutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 0, verifiedRecords.Gauge, map[string]string{\"record_type\": \"aaaa\"})\n}\n\nfunc TestVerifyAAAARecords(t *testing.T) {\n\ttestControllerFiltersDomains(\n\t\tt,\n\t\t[]*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"create-record.used.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t\t\tTargets:    endpoint.Targets{\"2001:DB8::1\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"some-record.used.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t\t\tTargets:    endpoint.Targets{\"2001:DB8::2\"},\n\t\t\t},\n\t\t},\n\t\tendpoint.NewDomainFilter([]string{\"used.tld\"}),\n\t\t[]*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"some-record.used.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t\t\tTargets:    endpoint.Targets{\"2001:DB8::2\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"create-record.used.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t\t\tTargets:    endpoint.Targets{\"2001:DB8::1\"},\n\t\t\t},\n\t\t},\n\t\t[]*plan.Changes{},\n\t)\n\n\ttestutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 2, verifiedRecords.Gauge, map[string]string{\"record_type\": \"aaaa\"})\n\n\ttestControllerFiltersDomains(\n\t\tt,\n\t\t[]*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"some-record.1.used.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t\t\tTargets:    endpoint.Targets{\"2001:DB8::1\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"some-record.2.used.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t\t\tTargets:    endpoint.Targets{\"2001:DB8::2\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"some-record.3.used.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t\t\tTargets:    endpoint.Targets{\"2001:DB8::3\"},\n\t\t\t},\n\t\t},\n\t\tendpoint.NewDomainFilter([]string{\"used.tld\"}),\n\t\t[]*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"some-record.1.used.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t\t\tTargets:    endpoint.Targets{\"2001:DB8::1\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"some-record.2.used.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t\t\tTargets:    endpoint.Targets{\"2001:DB8::2\"},\n\t\t\t},\n\t\t},\n\t\t[]*plan.Changes{{\n\t\t\tCreate: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"some-record.3.used.tld\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"2001:DB8::3\"},\n\t\t\t\t},\n\t\t\t},\n\t\t}},\n\t)\n\n\ttestutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 0, verifiedRecords.Gauge, map[string]string{\"record_type\": \"a\"})\n\ttestutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 2, verifiedRecords.Gauge, map[string]string{\"record_type\": \"aaaa\"})\n}\n\nfunc TestARecords(t *testing.T) {\n\ttestControllerFiltersDomains(\n\t\tt,\n\t\t[]*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"record1.used.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"record2.used.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"_mysql-svc._tcp.mysql.used.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeSRV,\n\t\t\t\tTargets:    endpoint.Targets{\"0 50 30007 mysql.used.tld\"},\n\t\t\t},\n\t\t},\n\t\tendpoint.NewDomainFilter([]string{\"used.tld\"}),\n\t\t[]*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"record1.used.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"_mysql-svc._tcp.mysql.used.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeSRV,\n\t\t\t\tTargets:    endpoint.Targets{\"0 50 30007 mysql.used.tld\"},\n\t\t\t},\n\t\t},\n\t\t[]*plan.Changes{{\n\t\t\tCreate: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"record2.used.tld\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t}},\n\t)\n\ttestutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 1, verifiedRecords.Gauge, map[string]string{\"record_type\": \"a\"})\n\ttestutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 0, verifiedRecords.Gauge, map[string]string{\"record_type\": \"aaaa\"})\n}\n\nfunc TestAAAARecords(t *testing.T) {\n\ttestControllerFiltersDomains(\n\t\tt,\n\t\t[]*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"record1.used.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t\t\tTargets:    endpoint.Targets{\"2001:DB8::1\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"record2.used.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t\t\tTargets:    endpoint.Targets{\"2001:DB8::2\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"_mysql-svc._tcp.mysql.used.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeSRV,\n\t\t\t\tTargets:    endpoint.Targets{\"0 50 30007 mysql.used.tld\"},\n\t\t\t},\n\t\t},\n\t\tendpoint.NewDomainFilter([]string{\"used.tld\"}),\n\t\t[]*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"record1.used.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t\t\tTargets:    endpoint.Targets{\"2001:DB8::1\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"_mysql-svc._tcp.mysql.used.tld\",\n\t\t\t\tRecordType: endpoint.RecordTypeSRV,\n\t\t\t\tTargets:    endpoint.Targets{\"0 50 30007 mysql.used.tld\"},\n\t\t\t},\n\t\t},\n\t\t[]*plan.Changes{{\n\t\t\tCreate: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"record2.used.tld\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"2001:DB8::2\"},\n\t\t\t\t},\n\t\t\t},\n\t\t}},\n\t)\n\n\ttestutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 0, sourceRecords.Gauge, map[string]string{\"record_type\": \"a\"})\n\ttestutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 2, sourceRecords.Gauge, map[string]string{\"record_type\": \"aaaa\"})\n\n\ttestutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 0, verifiedRecords.Gauge, map[string]string{\"record_type\": \"a\"})\n\ttestutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 1, verifiedRecords.Gauge, map[string]string{\"record_type\": \"aaaa\"})\n}\n\nfunc TestGaugeMetricsWithMixedRecords(t *testing.T) {\n\tctrl := newMixedRecordsFixture()\n\n\tassert.NoError(t, ctrl.RunOnce(t.Context()))\n\n\ttestutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 534, sourceRecords.Gauge, map[string]string{\"record_type\": \"a\"})\n\ttestutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 324, sourceRecords.Gauge, map[string]string{\"record_type\": \"aaaa\"})\n\ttestutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 2, sourceRecords.Gauge, map[string]string{\"record_type\": \"cname\"})\n\ttestutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 11, sourceRecords.Gauge, map[string]string{\"record_type\": \"srv\"})\n\n\ttestutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 5334, registryRecords.Gauge, map[string]string{\"record_type\": \"a\"})\n\ttestutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 324, registryRecords.Gauge, map[string]string{\"record_type\": \"aaaa\"})\n\ttestutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 0, registryRecords.Gauge, map[string]string{\"record_type\": \"mx\"})\n\ttestutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 43, registryRecords.Gauge, map[string]string{\"record_type\": \"ptr\"})\n}\n\nfunc newMixedRecordsFixture() *Controller {\n\tconfiguredEndpoints := testutils.GenerateTestEndpointsByType(map[string]int{\n\t\tendpoint.RecordTypeA:     534,\n\t\tendpoint.RecordTypeAAAA:  324,\n\t\tendpoint.RecordTypeCNAME: 2,\n\t\tendpoint.RecordTypeTXT:   56,\n\t\tendpoint.RecordTypeSRV:   11,\n\t\tendpoint.RecordTypeNS:    3,\n\t})\n\n\tproviderEndpoints := testutils.GenerateTestEndpointsByType(map[string]int{\n\t\tendpoint.RecordTypeA:     5334,\n\t\tendpoint.RecordTypeAAAA:  324,\n\t\tendpoint.RecordTypeCNAME: 23,\n\t\tendpoint.RecordTypeTXT:   6,\n\t\tendpoint.RecordTypeSRV:   25,\n\t\tendpoint.RecordTypeNS:    1,\n\t\tendpoint.RecordTypePTR:   43,\n\t})\n\n\tcfg := externaldns.NewConfig()\n\tcfg.Registry = externaldns.RegistryNoop\n\tcfg.ManagedDNSRecordTypes = endpoint.KnownRecordTypes\n\n\tsource := new(testutils.MockSource)\n\tsource.On(\"Endpoints\").Return(configuredEndpoints, nil)\n\n\tprovider := &filteredMockProvider{\n\t\tRecordsStore: providerEndpoints,\n\t}\n\tr, _ := registryfactory.Select(cfg, provider)\n\n\treturn &Controller{\n\t\tSource:             source,\n\t\tRegistry:           r,\n\t\tPolicy:             &plan.SyncPolicy{},\n\t\tDomainFilter:       endpoint.NewDomainFilter([]string{}),\n\t\tManagedRecordTypes: cfg.ManagedDNSRecordTypes,\n\t}\n}\n\nfunc BenchmarkGaugeMetricsWithMixedRecords(b *testing.B) {\n\tctrl := newMixedRecordsFixture()\n\n\tfor b.Loop() {\n\t\tif err := ctrl.RunOnce(b.Context()); err != nil {\n\t\t\tb.Fatal(err)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "docs/20190708-external-dns-incubator.md",
    "content": "# Move ExternalDNS out of Kubernetes incubator\n\n<!-- TOC depthFrom:1 depthTo:6 withLinks:1 updateOnSave:1 orderedList:0 -->\n\n- [Summary](#summary)\n- [Motivation](#motivation)\n  - [Goals](#goals)\n- [Proposal](#proposal)\n- [Details](#details)\n  - [Graduation Criteria](#graduation-criteria)\n    - [Maintainers](#maintainers)\n  - [Release process, artifacts](#release-process-artifacts)\n  - [Risks and Mitigations](#risks-and-mitigations)\n\n<!-- /TOC -->\n\n## Summary\n\n[ExternalDNS](https://github.com/kubernetes-sigs/external-dns) is a project that synchronizes Kubernetes' Services, Ingresses and other Kubernetes resources to DNS backends for several DNS providers.\n\nThe project was started as a Kubernetes Incubator project in February 2017 and being the Kubernetes incubation initiative officially over, the maintainers want to propose the project to be moved to the kubernetes GitHub organization or to kubernetes-sigs, under the sponsorship of sig-network.\n\n## Motivation\n\nExternalDNS started as a community project with the goal of unifying several existing projects that were trying to solve the same problem: create DNS records for Kubernetes resources on several DNS backends.\n\nWhen the project was proposed (see the [original discussion](https://github.com/kubernetes/kubernetes/issues/28525#issuecomment-270766227)), there were at least 3 existing implementations of the same functionality:\n\n- Mate - [https://github.com/linki/mate](https://github.com/linki/mate)\n\n- DNS-controller from kops - [https://github.com/kubernetes/kops/tree/HEAD/dns-controller](https://github.com/kubernetes/kops/tree/HEAD/dns-controller)\n\n- Route53-kubernetes - [https://github.com/wearemolecule/route53-kubernetes](https://github.com/wearemolecule/route53-kubernetes)\n\nExternalDNS' goal from the beginning was to provide an officially supported solution to those problems.\n\nAfter two years of development, the project is still in the kubernetes-sigs.\n\nThe incubation has been officially discontinued and to quote @thockin \"Incubator projects should either become real projects in Kubernetes,\nshut themselves down, or move elsewhere\" (see original thread [google group](https://groups.google.com/forum/#!topic/kubernetes-sig-network/fvpDC_nxtEM)).\n\nThis KEP proposes to move ExternalDNS to the main Kubernetes organization or kubernetes-sigs. The \"Proposal\" section details the reasons behind it.\n\n### Goals\n\nThe only goal of this KEP is to establish consensus regarding the future of the ExternalDNS project and determine where it belongs.\n\n## Proposal\n\nThis KEP is about moving External DNS out of the Kubernetes incubator. This section will cover the reasons why External DNS is useful and what the community would miss in case the project would be discontinued or moved under another organization.\n\nExternal DNS...\n\n- Is the de facto solution to create DNS records for several Kubernetes resources.\n\n- Is a vital component to achieve an experience close to a PaaS that many Kubernetes users try to replicate on top of Kubernetes, by allowing to automatically create DNS records for web applications.\n\n- Supports already 18 different DNS providers including all major public clouds (AWS, Azure, GCP).\n\nGiven that the kubernetes-sigs organization will eventually be shut down, the possible alternatives to moving to be an official Kubernetes project are the following:\n\n- Shut down the project\n\n- Move the project elsewhere\n\nWe believe that those alternatives would result in a worse outcome for the community compared to moving the project to any of the other official Kubernetes organizations.\nIn fact, shutting down ExternalDNS can cause:\n\n- The community to rebuild the same solution as already happened multiple times before the project was launched. Currently ExternalDNS is easy to be found, referenced in many articles/tutorials and for that reason not exposed to that risk.\n\n- Existing users of the projects to be left without a future proof working solution.\n\nMoving the ExternalDNS project outside of Kubernetes projects would cause:\n\n- Problems (re-)establishing user trust which could eventually lead to fragmentation and duplication.\n\n- It would be hard to establish in which organization the project should be moved to.\n\n- Lack of resources to test, lack of issue management via automation.\n\nFor those reasons, we propose to move ExternalDNS out of the Kubernetes incubator, to live either under the kubernetes or kubernetes-sigs organization to keep being a vital part of the Kubernetes ecosystem.\n\n## Details\n\n### Graduation Criteria\n\nExternalDNS is a two years old project widely used in production by many companies. The implementation for the three major cloud providers (AWS, Azure, GCP) is stable, not changing its logic and the project is being used in production by many company using Kubernetes.\n\nWe have evidence that many companies are using ExternalDNS in production, but it is out of scope for this proposal to collect a comprehensive list of companies.\n\nThe project was quoted by a number of tutorials on the web, including the [official tutorials from AWS](https://aws.amazon.com/blogs/opensource/unified-service-discovery-ecs-kubernetes/).\n\nExternalDNS can't be considered to be \"done\": while the core functionality has been implemented, there is lack of integration testing and structural changes that are needed.\n\nThose are identified in the project roadmap, which is roughly made of the following items:\n\n- Decoupling of the providers\n\n  - Implementation proposal\n\n  - Development\n\n- Bug fixing and performance optimization (i.e. rate limiting on cloud providers)\n\n- Integration testing suite, to be implemented at least for the \"stable\" providers\n\nFor those reasons, we consider ExternalDNS to be in Beta state as a project. We believe that once the items mentioned above will be implemented, the project can reach a declared GA status.\n\nThere are a number of other factors that need to be covered to fully describe the state of the project, including who are the maintainers, the way we release and manage the project and so on.\n\n#### Maintainers\n\nThe project has the following maintainers:\n\n- hjacobs\n\n- Raffo\n\n- linki\n\n- njuettner\n\nThe list of maintainers shrunk over time as people moved out of the original development team (all the team members were working at Zalando at the time of project creation) and the project required less work.\n\nThe high number of providers contributed to the project pose a maintainability challenge: it is hard to bring the providers forward in terms of functionalities or even test them.\nThe maintainers believe that the plan to transform the current Provider interface from a Go interface to an API will allow for enough decoupling and to hand over the maintenance of those plugins to the contributors themselves, see the risk and mitigations section for further details.\n\n### Release process, artifacts\n\nThe project uses the free quota of TravisCI to run tests for the project.\n\nThe release pipeline for the project is currently fully owned by Zalando. It runs on the internal system of the company (closed source) which external maintainers/users can't access and that pushes images to the publicly accessible docker registry available at the URL `registry.opensource.zalan.do`.\n\nThe docker registry service is provided as best effort with no sort of SLA and the maintainers team openly suggests the users to build and maintain their own docker image based on the provided Dockerfiles.\n\nProviding a vanity URL for the docker images was considered a non goal till now, but the community seems to be wanting official images from a GCR domain, similarly to what is available for other parts of official Kubernetes projects.\n\nExternalDNS does not follow a specific release cycle. Releases are made often when there are major contributions (i.e. new providers) or important bug fixes. That said, the default branch is considered stable and can be used as well to build images.\n\n### Risks and Mitigations\n\nThe following are risks that were identified:\n\n- Low number of maintainers: we are currently facing issues keeping up with the number of pull requests and issues giving the low number of maintainers. The list of maintainers already shrunk from 8 maintainers to 4.\n\n- Issues maintaining community contributed providers: we often lack access to external providers (i.e. InfoBlox, etc.) and this means that we cannot verify the implementations and/or run regression tests that go beyond unit testing.\n\n- Somewhat low quality of releases due to lack of integration testing.\n\nWe think that the following actions will constitute appropriate mitigations:\n\n- Decoupling the providers via an API will allow us to resolve the problem of the providers. Being the project already more than 2 years old and given that there are 18 providers implemented, we possess enough information to define an API that we can be stable in a short timeframe.\n\n  - Once this is stable, the problem of testing the providers can be deferred to be a provider's responsibility. This will also reduce the scope of External DNS core code, which means that there will be no need for a further increase of the maintaining team.\n\n- We added integration testing for the main cloud providers to the roadmap for the 1.0 release to make sure that we cover the mostly used ones.\n\n  - We believe that this item should be tackled independently from the decoupling of providers as it would be capable of generating value independently from the result of the decoupling efforts.\n\n- With the move to the Kubernetes incubation, we hope that we will be able to access the testing resources of the Kubernetes project.\n"
  },
  {
    "path": "docs/OWNERS",
    "content": "# See the OWNERS docs at https://go.k8s.io/owners\n\nlabels:\n- docs\n"
  },
  {
    "path": "docs/advanced/configuration-precedence.md",
    "content": "## Annotations vs. CLI Flags Precedence\n\nExternalDNS configuration can come from these sources: resource annotations, CLI flags, environment variables, and defaults.\nThe effective value is determined by the following precedence order:\n\n```mermaid\nflowchart TD\n    A[1. Resource Annotations] -->|Override| Result\n    B[2. CLI Flags] -->|Used if no annotation| Result\n    C[3. Environment Variables] -->|May override defaults<br/>and in some cases flags/annotations| Result\n    D[4. Defaults] -->|Fallback| Result\n\n    subgraph Flags\n        B1[Filter Flags: --flag-with-filter] -->|Define scope<br/>Annotations outside scope ignored| B\n        B2[Non-filter Flags] -->|Apply if no annotation| B\n    end\n\n    Result[Effective ExternalDNS Configuration]\n\n    A --> Result\n    B --> Result\n    D --> Result\n```\n\n1. **Annotations**\n   - Most configuration options can be set via annotations on supported resources.\n   - When present, annotations override the corresponding CLI flags and defaults.\n     - Exception: should be documented.\n     - Exception: ignored when applied to `kind: DNSEndpoint`\n     - Exception: filter flags (e.g. `--service-type-filter`, `--source`) define the *scope* of resources considered.\n\n2. **CLI Flags**\n   - Non-filter flags apply if no annotation overrides them.\n   - Filter flags (`--source`, `--service-type-filter`, `--*-filter`) limit which resources are processed.\n     - Annotations outside the defined scope are ignored.\n     - If a resource is excluded by a filter, annotations configured on the resource or defaults will not be applied.\n\n3. **Environment Variables**\n   - May override defaults, and in some cases may take precedence over CLI flags and annotations.\n   - Behavior depends on how the variable is mapped in the code. Whether or not it replicates CLI flag or provider specific. Example: `kubectl` or `cloudflare`.\n\n4. **Defaults**\n   - If none of the above specify a value, ExternalDNS falls back to its defaults.\n"
  },
  {
    "path": "docs/advanced/domain-filter.md",
    "content": "---\ntags:\n  - advanced\n  - area/domain-filter\n  - domain-filter\n---\n\n# Domain Filter\n\n> **Important:** Domain filter flags express application-level intent — they are not an\n> enforcement boundary. Credentials (IAM policies, API token scopes, ACLs) are the real\n> enforcement boundary. A misconfigured or missing flag will expose all zones the credentials\n> can reach. Always scope API keys or IAM roles to only the specific zones external-dns manages;\n> use these flags to complement that boundary, not replace it.\n\nExternalDNS has two modes for selecting which domains it manages: **plain domain filter** and **regex domain filter**.\n\nUsing any of the regex filter flags enables the **regex domain filter** mode, which overrides and ignores the **plain domain filter** flags.\n\n**Domain filter flags**:\n\n| Flag                       | Mode  | Semantics                                                                              |\n| -------------------------- | ----- | -------------------------------------------------------------------------------------- |\n| `--domain-filter`          | plain | Suffix match — includes a domain and all its subdomains                                |\n| `--exclude-domains`        | plain | Suffix match — excludes a domain or subdomain from `--domain-filter`                   |\n| `--regex-domain-filter`    | regex | Full regex match — **overrides `--domain-filter`** when set                            |\n| `--regex-domain-exclusion` | regex | Regex that removes matches from `--regex-domain-filter`; can also be used standalone   |\n\nAll of these flags are applied to DNS record names. Providers that partition zones before managing records\n(e.g., PowerDNS) also apply the filter to zone names.\n\n## Plain domain filter\n\nSpecify one or more domain suffixes. ExternalDNS will manage any record whose name ends with one of\nthe provided values.\n\n```sh\n--domain-filter=example.com\n--domain-filter=other.org\n```\n\nTo exclude specific subdomains use `--exclude-domains`:\n\n```sh\n--domain-filter=example.com\n--exclude-domains=staging.example.com\n```\n\n## Regex domain filter\n\n`--regex-domain-filter` accepts a Go RE2 regular expression. Use it when suffix matching is not\nexpressive enough — for example, to select zones by region name pattern.\n\n```sh\n--regex-domain-filter='\\.org$'\n```\n\nUse `--regex-domain-exclusion` to reject zones that would otherwise match:\n\n```sh\n--regex-domain-filter='^([\\w-]+\\.)*example\\.com$'\n--regex-domain-exclusion='^staging\\.'\n```\n\n### Matching logic\n\nExclusion is always checked first:\n\n1. If `--regex-domain-exclusion` matches → **rejected**\n2. If `--regex-domain-filter` matches → **accepted**\n3. If only `--regex-domain-exclusion` is set (the domain did not match) → **accepted** (exclusion-only mode)\n4. If `--regex-domain-filter` is set (the domain did not match) → **rejected**\n\n```mermaid\nflowchart TD\n    A[\"Domain candidate\"] --> B{\"Is regex filter<br/>or exclusion set?\"}\n    B -- \"No (use plain filters)\" --> C{\"Matches<br/> --domain-filter?\"}\n    C -- \"No\" --> REJECT[\"❌ Rejected\"]\n    C -- \"Yes\" --> D{\"Matches<br/> --exclude-domains?\"}\n    D -- \"Yes\" --> REJECT\n    D -- \"No\" --> ACCEPT[\"✅ Accepted\"]\n    B -- \"Yes (regex mode)\" --> E{\"Matches<br/>--regex-domain-exclusion?\"}\n    E -- \"Yes\" --> REJECT\n    E -- \"No\" --> F{\"--regex-domain-filter set?\"}\n    F -- \"No (exclusion-only mode)\" --> ACCEPT\n    F -- \"Yes\" --> G{\"Matches<br/>--regex-domain-filter?\"}\n    G -- \"Yes\" --> ACCEPT\n    G -- \"No\" --> REJECT\n```\n\n### Examples\n\n**Include only `.org` domains:**\n\n```sh\n--regex-domain-filter='\\.org$'\n```\n\n**Include a specific set of domains:**\n\n```sh\n--regex-domain-filter='(?:foo|bar)\\.org$'\n```\n\n**Include with exclusion:**\n\n```sh\n# foo.org, bar.org, a.example.foo.org → accepted\n# example.foo.org, example.bar.org    → rejected\n--regex-domain-filter='(?:foo|bar)\\.org$'\n--regex-domain-exclusion='^example\\.(?:foo|bar)\\.org$'\n```\n\n**Production environment with temp exclusion:**\n\n```sh\n--regex-domain-filter='\\.prod\\.example\\.com$'\n--regex-domain-exclusion='^temp-'\n```\n\n**Exclusion-only (accept everything except a pattern):**\n\n```sh\n--regex-domain-exclusion='test-v1\\.3\\.example-test\\.in'\n```\n\n**Exclude a complex pattern:**\n\n```sh\n--regex-domain-exclusion='^(internal|private)-.*\\.example\\.com$'\n```\n\n### Zone-partitioning pitfall: `+` vs `*`\n\nThe most common misconfiguration when filtering zones (not just records) is using `[\\w-]+` (one or\nmore) instead of `([\\w-]+\\.)*` (zero or more) for the label-prefix group. Because `+` requires at\nleast one repetition:\n\n- The **apex zone** (`example.com`) has no label prefix and will never match.\n- **Multi-label subdomain zones** (`long.sub.example.com`) contain dots that `[\\w-]+` cannot span.\n\nBoth zone types end up unmanaged, causing ExternalDNS to log `Ignoring Endpoint` for every record\nthey contain with no other indication of what went wrong.\n\n| Regex                       | Matches                                                  | Misses                                |\n|-----------------------------|----------------------------------------------------------|---------------------------------------|\n| `^[\\w-]+\\.example\\.com$`    | `sub.example.com`                                        | `example.com`, `long.sub.example.com` |\n| `^([\\w-]+\\.)*example\\.com$` | `example.com`, `sub.example.com`, `long.sub.example.com` | —                                     |\n\nAlways use `*` so the apex matches on zero repetitions and subdomain zones match on one or more.\n\n### Multi-region example\n\n```sh\n--regex-domain-filter='^([\\w-]+\\.)*(?:us-east-1|eu-central-1)\\.example\\.com$'\n--regex-domain-exclusion='^staging\\.'\n```\n\n| Zone                            | Result      |\n|---------------------------------|-------------|\n| `us-east-1.example.com`         | managed     |\n| `prod.us-east-1.example.com`    | managed     |\n| `eu-central-1.example.com`      | managed     |\n| `staging.us-east-1.example.com` | excluded    |\n| `other.com`                     | not managed |\n\n## Notes\n\n- **Regex syntax**: Standard Go RE2. Escape dots (`\\.`) and use anchors (`^`, `$`) where precision matters.\n- **Case sensitivity**: Matching is case-sensitive. Domains are lowercased and trailing dots stripped before matching.\n- **IDN / Unicode**: Domains are converted to Unicode form (IDNA) before matching, so patterns against emoji or Unicode labels work as expected.\n- **Mutual exclusivity**: Once a regex flag is non-empty, list-based filters are ignored entirely.\n\n## Debugging\n\nIf records are silently dropped, look for `Ignoring Endpoint` in the logs — this means no managed\nzone matched the record. To isolate whether the domain filter is the cause, temporarily switch to\n`--domain-filter` with the plain suffix; if records reappear, the regex is the problem.\n\n## Testing your regex\n\nBefore deploying, validate the regex against real zone names:\n\n- [regex101.com](https://regex101.com/) — interactive tester; select the **Golang** flavour to match Go's RE2 engine exactly. Paste each zone name on a separate line and enable the **global** flag.\n- AI assistants (ChatGPT, Claude, DeepWiki, etc.) — describe the zones you want to match/exclude and ask for a regex; always verify the output in regex101 before use.\n\n## See Also\n\n- [Flags reference](../flags.md) — `--domain-filter`, `--exclude-domains`, `--regex-domain-filter`, `--regex-domain-exclusion`\n- [AWS filters tutorial](../tutorials/aws-filters.md) — filter flag interaction table\n- [FAQ](../faq.md) — general configuration questions\n"
  },
  {
    "path": "docs/advanced/events.md",
    "content": "---\ntags: [\"advanced\", \"area/events\", \"events\"]\n---\n# Kubernetes Events in External-DNS\n\nExternal-DNS manages DNS records dynamically based on Kubernetes resources like Services and Ingresses.\nEmitting Kubernetes Events provides a lightweight observable way for users and systems to understand what External-DNS is doing, especially in production environments where DNS correctness is essential.\n\n> Note; events is currently alpha feature. Functionality is limited and subject to change\n\n## ✨ Why Events Matter\n\nKubernetes Events enable External-DNS to provide real-time feedback to users and controllers, complementing logs with a simpler way to track DNS changes. This enhances debugging, monitoring, and automation around DNS operations.\n\n### Use Cases of Emitting Events\n\n| Use Case                                          | Description                                                                                                  |\n|---------------------------------------------------|--------------------------------------------------------------------------------------------------------------|\n| **DNS Record Visibility**                         | Events show what DNS records were created, updated, or deleted (e.g., `Created A record \"api.example.com\"`). |\n| **Developer Feedback**                            | Users deploying Ingresses or Services can see if External-DNS processed their resource.                      |\n| **Surface Errors, Debugging and Troubleshooting** | Easily identify resource misannotations, sync failures, or IAM permission issues.                            |\n| **Error Reporting**                               | Emit warning events when record sync fails due to provider issues, duplicate records, or misconfigurations.  |\n| **Integration with Alerting/Automation/Auditing** | This enables automated remediation or notifications when DNS sync fails or changes unexpectedly.             |\n| **Observability**                                 | Trace why a DNS record wasn’t created.                                                                       |\n| **Alerting/automation**                           | Trigger actions based on failed events.                                                                      |\n| **Operator and Developer feedback**               | It removes the black-box feeling of \"I deployed an Ingress, but why doesn’t the DNS work?\"                   |\n\n## Consuming Events\n\nYou can observe External-DNS events using:\n\n```sh\nkubectl describe service <name>\nkubectl get events --field-selector involvedObject.kind=Service\nkubectl get events --field-selector type=Normal|Warning\nkubectl get events --field-selector reason=RecordReady|RecordDeleted|RecordError\nkubectl get events --field-selector reportingComponent=external-dns\n```\n\nOr integrate with tools like:\n\n- Prometheus (via event exporters)\n- Loki/Fluentd for log/event aggregation\n- Argo CD / Flux for GitOps monitoring\n\n### Practices for Understanding Events\n\n- **Action field**: Events include a short label describing the `Action`, such as `Created`, `Updated`, `Deleted`, or `FailedSync`\n- **Reason field**: Events include a short label `Reason` is why the action was taken, such as `RecordReady`, `RecordDeleted`, or `RecordError`.\n- **Type field**:\n  - `Normal` means the operation succeeded (e.g., a DNS record was created).\n  - `Warning`  indicates a problem (e.g., DNS sync failed due to configuration or provider issues).\n- **Linked** resource: Events are attached to the relevant Kubernetes resource (like an `Ingress` or `Service`), so you can view them with tools like `kubectl describe`.\n- **Event noise**: If you see repeated identical events, it may indicate a misconfiguration or an issue worth investigating.\n\n### Sequence Overview: External-DNS Endpoint Reconciliation and Event Emission\n\nThe following sequence diagram illustrates the core workflow of how External-DNS processes endpoints, applies DNS changes, and emits Kubernetes events:\n\n1. **Endpoint Collection**\n   The `Source` component generates `Endpoint` objects, each linked to a `ReferenceObject` (such as a Service or Ingress).\n\n2. **Plan Construction**\n   A `Plan` aggregates multiple `Endpoints` and prepares a list of desired DNS changes.\n\n3. **Change Application**\n   The `Plan` sends the changes to a DNS `Provider`, which attempts to apply them. Each `Endpoint` is labeled with the result: `Success`, `Failed`, or `Skip`.\n\n4. **Event Emission**\n   Based on the result, an `Event` is created for each `Endpoint`, referencing the original `ReferenceObject`. These events are then emitted via the `EventEmitter`.\n\nThis sequence ensures DNS records are managed declaratively and provides real-time visibility into the system’s behavior through Kubernetes Events.\n\n```mermaid\nsequenceDiagram\n  participant Source\n  participant ReferenceObject\n  participant Endpoint\n  participant Plan\n  participant Event\n  participant Provider\n  participant EventEmitter\n\n\n  loop Process each Endpoint\n    Source->>Endpoint: Add Endpoint with Reference\n  end\n\n  Endpoint-->>ReferenceObject: Contains\n  Plan-->>Endpoint: Contains multiple\n\n  loop Apply Changes\n    Plan->>Provider: Apply Endpoint Changes\n    Provider-->>Endpoint: Label with Skip/Success/Failed\n    Provider-->>Plan: Return Result\n  end\n\n\n  loop Process each Event\n    Provider->>Plan: Label with Skip/Success/Failed\n    Plan-->>Event: Construct Event\n    Event-->>ReferenceObject: Contains\n    Event->>EventEmitter: Emit\n  end\n```\n\n### Caveats\n\n- Events are ephemeral (default retention is ~1 hour).\n- They are best-effort and not guaranteed to be delivered or stored long-term.\n- Not a substitute for logging or metrics, but complementary.\n\n## Supported Sources\n\nEvents are emitted for all sources that External-DNS supports. Event support is being rolled out\nprogressively — if a source does not yet emit events, it may in the future.\n\nSee the [sources reference](../sources/index.md#available-sources) for the full list and\nper-source event support status.\n"
  },
  {
    "path": "docs/advanced/fqdn-templating.md",
    "content": "---\ntags: [\"advanced\", \"area/fqdn\", \"fqdn\", \"templating\"]\n---\n\n# FQDN Templating Guide\n\n## What is FQDN Templating?\n\n**FQDN templating** is a feature that allows to dynamically construct Fully Qualified Domain Names (FQDNs) using a Go templating engine.\nInstead of relying solely on annotations or static names, you can use metadata from Kubernetes objects—such as service names, namespaces, and labels—to generate DNS records programmatically and dynamically.\n\nThis is useful for:\n\n- Creating consistent naming conventions across environments.\n- Reducing boilerplate annotations.\n- Supporting multi-tenant or dynamic environments.\n- Migrating from one DNS scheme to another\n- Supporting multiple variants, such as a regional one and then one that doesn't or similar.\n\n## How It Works\n\nExternalDNS has a flag: `--fqdn-template`, which defines a Go template for rendering the desired DNS names.\n\nThe template uses the following data from the source object (e.g., a `Service` or `Ingress`):\n\n| Field         | Description                                      | How to Access                                          |\n|:--------------|:-------------------------------------------------|:-------------------------------------------------------|\n| `Kind`        | Object kind (e.g., `Service`, `Pod`, `Ingress`)  | `{{ .Kind }}`                                          |\n| `APIVersion`  | API version (e.g., `v1`, `networking.k8s.io/v1`) | `{{ .APIVersion }}`                                    |\n| `Name`        | Name of the object (e.g., service)               | `{{ .Name }}`                                          |\n| `Namespace`   | Namespace of the object                          | `{{ .Namespace }}`                                     |\n| `Labels`      | Map of labels applied to the object              | `{{ .Labels.key }}` or `{{ index .Labels \"key\" }}`     |\n| `Annotations` | Map of annotations                               | `{{ index .Annotations \"key\" }}`                       |\n| `Spec`        | Object spec with type-specific fields            | `{{ .Spec.Type }}`, `{{ index .Spec.Selector \"app\" }}` |\n| `Status`      | Object status with type-specific fields          | `{{ .Status.LoadBalancer.Ingress }}`                   |\n\nTo explore all available fields for an object type, use `kubectl explain`:\n\n```bash\n# View all fields for a Service recursively.\nkubectl explain service --api-version=v1 --recursive\n\n# View all fields for a Ingress recursively.\nkubectl explain ingress --api-version=networking.k8s.io/v1 --recursive\n\n# View a specific field path. The dot notation is for field path.\nkubectl explain service.spec.selector\nkubectl explain pod.spec.containers\n```\n\n## Supported Sources\n\n<!-- TODO: generate from code -->\n\n| Source                 | Description                                                     | FQDN Supported | FQDN Combine |\n|:-----------------------|:----------------------------------------------------------------|:--------------:|:------------:|\n| `ambassador-host`      | Queries Ambassador Host resources for endpoints.                |       No       |      No      |\n| `connector`            | Queries a custom connector source for endpoints.                |       No       |      No      |\n| `contour-httpproxy`    | Queries Contour HTTPProxy resources for endpoints.              |      Yes       |     Yes      |\n| `crd`                  | Queries Custom Resource Definitions (CRDs) for endpoints.       |       No       |      No      |\n| `empty`                | Uses an empty source, typically for testing or no-op scenarios. |       No       |      No      |\n| `f5-transportserver`   | Queries F5 TransportServer resources for endpoints.             |       No       |      No      |\n| `f5-virtualserver`     | Queries F5 VirtualServer resources for endpoints.               |       No       |      No      |\n| `fake`                 | Uses a fake source for testing purposes.                        |       No       |      No      |\n| `gateway-grpcroute`    | Queries GRPCRoute resources from the Gateway API.               |      Yes       |      No      |\n| `gateway-httproute`    | Queries HTTPRoute resources from the Gateway API.               |      Yes       |      No      |\n| `gateway-tcproute`     | Queries TCPRoute resources from the Gateway API.                |      Yes       |      No      |\n| `gateway-tlsroute`     | Queries TLSRoute resources from the Gateway API.                |       No       |      No      |\n| `gateway-udproute`     | Queries UDPRoute resources from the Gateway API.                |       No       |      No      |\n| `gloo-proxy`           | Queries Gloo Proxy resources for endpoints.                     |       No       |      No      |\n| `ingress`              | Queries Kubernetes Ingress resources for endpoints.             |      Yes       |     Yes      |\n| `istio-gateway`        | Queries Istio Gateway resources for endpoints.                  |      Yes       |     Yes      |\n| `istio-virtualservice` | Queries Istio VirtualService resources for endpoints.           |      Yes       |     Yes      |\n| `kong-tcpingress`      | Queries Kong TCPIngress resources for endpoints.                |       No       |      No      |\n| `node`                 | Queries Kubernetes Node resources for endpoints.                |      Yes       |     Yes      |\n| `openshift-route`      | Queries OpenShift Route resources for endpoints.                |      Yes       |     Yes      |\n| `pod`                  | Queries Kubernetes Pod resources for endpoints.                 |      Yes       |     Yes      |\n| `service`              | Queries Kubernetes Service resources for endpoints.             |      Yes       |     Yes      |\n| `skipper-routegroup`   | Queries Skipper RouteGroup resources for endpoints.             |      Yes       |     Yes      |\n| `traefik-proxy`        | Queries Traefik IngressRoute resources for endpoints.           |       No       |      No      |\n\n## Custom Functions\n\n<!-- TODO: generate from code -->\n\n| Function     | Description                                           | Example                                                                            |\n|:-------------|:------------------------------------------------------|:-----------------------------------------------------------------------------------|\n| `contains`   | Check if `substr` is in `string`                      | `{{ contains \"hello\" \"ell\" }} → true`                                              |\n| `isIPv4`     | Validate an IPv4 address                              | `{{ isIPv4 \"192.168.1.1\" }} → true`                                                |\n| `isIPv6`     | Validate an IPv6 address (including IPv4-mapped IPv6) | `{{ isIPv6 \"2001:db8::1\" }} → true`<br/>`{{ isIPv6 \"::FFFF:192.168.1.1\" }} → true` |\n| `replace`    | Replace `old` with `new`                              | `{{ replace \"l\" \"w\" \"hello\" }} → hewwo`                                            |\n| `trim`       | Remove leading and trailing spaces                    | `{{ trim \"  hello  \" }} → hello`                                                   |\n| `toLower`    | Convert to lowercase                                  | `{{ toLower \"HELLO\" }} → hello`                                                    |\n| `trimPrefix` | Remove the leading `prefix`                           | `{{ trimPrefix \"hello\" \"h\" }} → ello`                                              |\n| `trimSuffix` | Remove the trailing `suffix`                          | `{{ trimSuffix \"hello\" \"o\" }} → hell`                                              |\n| `hasKey`     | Check if a key exists in a map                        | `{{ hasKey .Labels \"app\" }} → true`                                                |\n| `fromJson`   | Parse a JSON string into a value                      | `{{ index (fromJson \"{\\\"env\\\":\\\"prod\\\"}\") \"env\" }} → prod`                         |\n\n---\n\n## Example Usage\n\n> These examples should provide a solid foundation for implementing FQDN templating in your ExternalDNS setup.\n> If you have specific requirements or encounter issues, feel free to explore the issues or update this guide.\n\n### Basic Usage\n\n```yml\napiVersion: v1\nkind: Service\nmetadata:\n  name: my-service\n  namespace: my-namespace\n```\n\n```sh\nexternal-dns \\\n  --provider=aws \\\n  --source=service \\\n  --fqdn-template=\"{{ .Name }}.example.com,{{ .Name }}.{{ .Namespace }}.example.tld\"\n\n# This will result in DNS entries like\n>route53> my-service.example.com\n>route53> my-service.my-namespace.example.tld\n```\n\n### With Namespace\n\n```yml\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: my-service\n  namespace: default\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: other-service\n  namespace: kube-system\n```\n\n```yml\nargs:\n  --fqdn-template=\"{{.Name}}.{{.Namespace}}.example.com\"\n\n# This will result in DNS entries like\n# route53> my-service.default.example.com\n# route53> other-service.kube-system.example.com\n```\n\n### Using Labels  in Templates\n\nYou can also utilize labels in your FQDN templates to create more dynamic DNS entries. Assuming your service has:\n\n```yml\napiVersion: v1\nkind: Service\nmetadata:\n  name: my-service\n  labels:\n    environment: staging\n```\n\n```yml\nargs:\n  --fqdn-template=\"{{ .Labels.environment }}.{{ .Name }}.example.com\"\n\n# This will result in DNS entries like\n# route53> staging.my-service.example.com\n```\n\n### Multiple FQDN Templates\n\nExternalDNS allows specifying multiple FQDN templates, which can be useful when you want to create multiple DNS entries for a single service or ingress.\n\n> Be cautious, as this will create multiple DNS records per resource, potentially increasing the number of API calls to your DNS provider.\n\n```yml\nargs:\n  --fqdn-template={{.Name}}.example.com,{{.Name}}.svc.example.com\n```\n\n### Conditional Templating combined with Annotations processing\n\nIn scenarios where you want to conditionally generate FQDNs based on annotations, you can use Go template functions like or to provide defaults.\n\n```yml\nargs:\n  - --combine-fqdn-annotation # this is required to combine FQDN templating and annotation processing\n  - --fqdn-template={{ or .Annotations.dns \"invalid\" }}.example.com\n  - --exclude-domains=invalid.example.com\n```\n\n### Using Annotations for FQDN Templating\n\nThis example demonstrates how to use annotations in Kubernetes objects to dynamically generate Fully Qualified Domain Names (FQDNs) using the --fqdn-template flag in ExternalDNS.\n\nThe Service object includes an annotation dns.company.com/label with the value my-org-tld-v2. This annotation is used as part of the FQDN template to construct the DNS name.\n\n```yml\napiVersion: v1\nkind: Service\nmetadata:\n  name: nginx-v2\n  namespace: my-namespace\n  annotations:\n    dns.company.com/label: my-org-tld-v2\nspec:\n  type: ClusterIP\n  clusterIP: None\n```\n\nThe --fqdn-template flag is configured to use the annotation value (dns.company.com/label) and append the namespace and a custom domain (company.local) to generate the FQDN.\n\n```yml\nargs:\n  --source=service\n  --fqdn-template='{{ index .ObjectMeta.Annotations \"dns.company.com/label\" }}.{{ .Namespace }}.company.local'\n\n# For the given Service object, the resulting FQDN will be:\n# route53> my-org-tld-v2.my-namespace.company.local\n```\n\n### DNS Scheme Migration\n\nIf you're transitioning from one naming convention to another (e.g., from svc.cluster.local to svc.example.com), --fqdn-template allows you to generate the new records alongside or in place of the old ones — without requiring changes to your Kubernetes manifests.\n\n```yml\nargs:\n- --fqdn-template='{{.Name}}.new-dns.example.com'\n```\n\nThis helps automate DNS record migration while maintaining service continuity.\n\n### Using Kind for Conditional Templating\n\nWhen processing multiple resource types, use `.Kind` to apply templates conditionally:\n\n```yml\nargs:\n  --fqdn-template='{{ if eq .Kind \"Service\" }}{{ .Name }}.svc.example.com{{ end }}'\n\n# Only Services will get DNS entries, Pods and other resources will be skipped\n```\n\nYou can also handle multiple kinds in one template:\n\n```yml\nargs:\n  --fqdn-template='{{ if eq .Kind \"Service\" }}{{ .Name }}.svc.example.com{{ end }}{{ if eq .Kind \"Pod\" }}{{ .Name }}.pod.example.com{{ end }}'\n```\n\n### Using Spec Fields\n\nAccess type-specific spec fields for advanced filtering:\n\n```yml\n# Only ExternalName services\nargs:\n  --fqdn-template='{{ if eq .Kind \"Service\" }}{{ if eq .Spec.Type \"ExternalName\" }}{{ .Name }}.external.example.com{{ end }}{{ end }}'\n```\n\n```yml\napiVersion: v1\nkind: Service\nmetadata:\n  name: web-frontend\nspec:\n  selector:\n    app: nginx        # This selector will be used in the FQDN\n    tier: frontend\n  ports:\n    - port: 80\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: database\nspec:\n  selector:\n    tier: backend     # Won't generate FQDN - no \"app\" key in selector\n  ports:\n    - port: 5432\n```\n\n```yml\n# Services with specific selector\nargs:\n  --fqdn-template='{{ if eq .Kind \"Service\" }}{{ if index .Spec.Selector \"app\" }}{{ .Name }}.{{ index .Spec.Selector \"app\" }}.example.com{{ end }}{{ end }}'\n\n# Result for web-frontend: web-frontend.nginx.example.com\n# Result for database: (no FQDN generated - selector has no \"app\" key)\n```\n\n### Iterating Over Labels with Range\n\nUse `range` to iterate over labels and generate multiple FQDNs:\n\n```yml\nargs:\n  --fqdn-template='{{ if eq .Kind \"Service\" }}{{ range $key, $value := .Labels }}{{ if contains $key \"app\" }}{{ $.Name }}.{{ $value }}.example.com{{ printf \",\" }}{{ end }}{{ end }}{{ end }}'\n```\n\nThis generates an FQDN for each label key containing \"app\". Note:\n\n- `$key` and `$value` are the label key/value pairs\n- `$.Name` accesses the root object's Name (use `$` inside `range`)\n- `{{ printf \",\" }}` separates multiple FQDNs\n\n### Working with Annotations\n\nAccess a specific annotation:\n\n```yml\nargs:\n  --fqdn-template='{{ index .Annotations \"dns.example.com/hostname\" }}.example.com'\n```\n\nIterate over annotations and filter by key:\n\n```yml\napiVersion: v1\nkind: Service\nmetadata:\n  name: my-service\n  annotations:\n    dns.example.com/primary: api.example.com\n    dns.example.com/secondary: api-backup.example.com\n    kubernetes.io/ingress-class: nginx  # Won't match - key doesn't contain \"dns.example.com/\"\n```\n\n```yml\nargs:\n  --fqdn-template='{{ range $key, $value := .Annotations }}{{ if contains $key \"dns.example.com/\" }}{{ $value }}{{ printf \",\" }}{{ end }}{{ end }}'\n\n# Captures all annotations with keys containing \"dns.example.com/\"\n# Result: api.example.com, api-backup.example.com\n```\n\nFilter annotations by value:\n\n```yml\napiVersion: v1\nkind: Service\nmetadata:\n  name: my-service\n  annotations:\n    custom/hostname: api.example.com\n    custom/alias: www.example.com\n    custom/internal: internal.local  # Won't match - value doesn't contain \".example.com\"\n```\n\n```yml\nargs:\n  --fqdn-template='{{ range $key, $value := .Annotations }}{{ if contains $value \".example.com\" }}{{ $value }}{{ printf \",\" }}{{ end }}{{ end }}'\n\n# Captures all annotation values containing \".example.com\"\n# Result: api.example.com, www.example.com\n```\n\nCombine annotation key and value filters:\n\n```yml\napiVersion: v1\nkind: Service\nmetadata:\n  name: my-service\n  annotations:\n    dns/primary: api.example.com\n    dns/secondary: api-backup.example.com\n    other/hostname: internal.other.org  # Won't match - value doesn't contain \"example.com\"\n    logging/level: debug                # Won't match - key doesn't contain \"dns/\"\n```\n\n```yml\nargs:\n  --fqdn-template='{{ if eq .Kind \"Service\" }}{{ range $k, $v := .Annotations }}{{ if and (contains $k \"dns/\") (contains $v \"example.com\") }}{{ $v }}{{ printf \",\" }}{{ end }}{{ end }}{{ end }}'\n\n# Result: api.example.com, api-backup.example.com\n```\n\n### Combining Kind and Label Filters\n\nFilter by both Kind and label values:\n\n```yml\nargs:\n  --fqdn-template='{{ if eq .Kind \"Pod\" }}{{ range $k, $v := .Labels }}{{ if and (contains $k \"app\") (contains $v \"my-service-\") }}{{ $.Name }}.{{ $v }}.example.com{{ printf \",\" }}{{ end }}{{ end }}{{ end }}'\n\n# Generates FQDNs only for Pods with labels like app1=my-service-123\n# Result: pod-name.my-service-123.example.com\n```\n\n### Multi-Variant Domain Support\n\nYou can also support regional variants or multi-tenant architectures, where the same service is deployed to different regions or environments:\n\n```yaml\n--fqdn-template='{{ .Name }}.{{ .Labels.env }}.{{ .Labels.region }}.example.com, {{ if eq .Labels.env \"prod\" }}{{ .Name }}.my-company.tld{{ end }}'\n\n# Generates FQDNs for resources with labels env and region\n# For a Service named \"api\" with labels env=prod, region=us-east-1:\n# Result: api.prod.us-east-1.example.com, api.my-company.tld\n\n# For a Service named \"api\" with labels env=staging, region=eu-west-1:\n# Result: api.staging.eu-west-1.example.com\n```\n\nThis is helpful in scenarios such as:\n\n- Blue/green deployments across domains\n- Staging vs. production resolution\n- Multi-cloud or multi-region failover strategies\n\n## Tips\n\n- If `--fqdn-template` is specified, ExternalDNS ignores any `external-dns.alpha.kubernetes.io/hostname` annotations.\n- You must still ensure the resulting FQDN is valid and unique.\n- Since Go templates can be error-prone, test your template with simple examples before deploying. Mismatched field names or nil values (e.g., missing labels) will result in errors or skipped entries.\n\n## FAQ\n\n### Can I specify multiple global FQDN templates?\n\nYes, you can. Pass in a comma separated list to --fqdn-template. Beware this will double (triple, etc) the amount of DNS entries based on how many services, ingresses and so on you have and will get you faster towards the API request limit of your DNS provider.\n\n### Where to find template syntax\n\n- [Go template syntax](https://pkg.go.dev/text/template) - Official reference for template syntax, actions, and pipelines\n- [Go func builtins](https://github.com/golang/go/blob/master/src/text/template/funcs.go#L39-L63)\n\n### FQDN Templating, Helm and improper templating syntax\n\nThe user encountered errors due to improper templating syntax:\n\n```yml\nextraArgs:\n  - --fqdn-template={{name}}.uat.example.com\n```\n\nThe correct syntax should include a dot prefix: `{{ .Name }}`.\nAdditionally, when using Helm's `tpl` function, it's necessary to escape the braces to prevent premature evaluation:\n\n```yml\nextraArgs:\n  - --fqdn-template={{ `{{ .Name }}.uat.example.com` }}\n```\n\n### Handling Subdomain-Only Hostnames\n\nIn [Issue #1872](https://github.com/kubernetes-sigs/external-dns/issues/1872), it was observed that ExternalDNS ignores the `--fqdn-template` when the ingress host field is set to a subdomain (e.g., foo) without a full domain.\nThe expectation was that the template would still apply, generating entries like `foo.bar.example.com.`\nThis highlights a limitation to be aware of when designing FQDN templates.\n\n> :warning: This is currently not supported ! User would expect external-dns to generate a dns record according to the fqdnTemplate\n> e.g. if the ingress name: foo and host: foo is created while fqdnTemplate={{.Name}}.bar.example.com then a dns record foo.bar.example.com should be created\n\n```yml\napiVersion: extensions/v1beta1\nkind: Ingress\nmetadata:\n  name: foo\nspec:\n  rules:\n  - host: foo\n    http:\n      paths:\n      - backend:\n          serviceName: foo\n          servicePort: 80\n        path: /\n```\n\n### Combining FQDN Template with Annotations\n\nIn [Issue #3318](https://github.com/kubernetes-sigs/external-dns/issues/3318), a question was raised about the interaction between --fqdn-template and --combine-fqdn-annotation.\nThe discussion clarified that when both flags are used, ExternalDNS combines the FQDN generated from the template with the annotation value, providing flexibility in DNS name construction.\n\n### Using Annotations for Dynamic FQDNs\n\nIn [Issue #2627](https://github.com/kubernetes-sigs/external-dns/issues/2627), a user aimed to generate DNS entries based on ingress annotations:\n\n```yml\nargs:\n  - --fqdn-template={{.Annotations.hostname}}.example.com\n  - --combine-fqdn-annotation\n  - --domain-filter=example.com\n```\n\nBy setting the hostname annotation in the ingress resource, ExternalDNS constructs the FQDN accordingly. This approach allows for dynamic DNS entries without hardcoding hostnames.\n\n### Using a Node's Addresses for FQDNs\n\n```yml\nargs:\n  - --fqdn-template=\"{{range .Status.Addresses}}{{if and (eq .Type \\\"ExternalIP\\\") (isIPv4 .Address)}}{{.Address | replace \\\".\\\" \\\"-\\\"}}{{break}}{{end}}{{end}}.example.com\"\n```\n\nThis is a complex template that iternates through a list of a Node's Addresses and creates a FQDN with public IPv4 addresses.\n\n### Using `hasKey` for Safe Label and Annotation Access\n\nUnlike `index`, which returns an empty string for both a missing key and a key with an empty value, `hasKey` explicitly checks for key existence. This matters for Kubernetes marker labels (e.g., `service.kubernetes.io/headless: \"\"`), where an empty value is meaningful.\n\nCheck for a label before using it in a template:\n\n```yml\napiVersion: v1\nkind: Service\nmetadata:\n  name: my-service\n  labels:\n    app: nginx\n```\n\n```yml\nargs:\n  - --fqdn-template={{ if hasKey .Labels \"app\" }}{{ .Name }}.{{ index .Labels \"app\" }}.example.com{{ end }}\n\n# Result: my-service.nginx.example.com\n```\n\nThis only generates an FQDN when the `app` label is present. Without `hasKey`, `{{ index .Labels \"app\" }}` would silently return `\"\"` for unlabelled resources, producing an invalid FQDN like `my-service..example.com`.\n\nCombine with `Kind` for targeted rules:\n\n```yml\nargs:\n  - --fqdn-template={{ if and (eq .Kind \"Service\") (hasKey .Labels \"tier\") }}{{ .Name }}.{{ index .Labels \"tier\" }}.example.com{{ end }}\n```\n\n### Using `fromJson` to Parse Structured Labels\n\n`fromJson` parses a JSON string stored in a label or annotation into a Go value, enabling templates to iterate over structured data.\n\nGiven a Service with a JSON array of DNS entries in a label:\n\n```yml\napiVersion: v1\nkind: Service\nmetadata:\n  name: my-service\n  labels:\n    records: '[{\"dns\":\"entry1.internal.tld\",\"target\":\"10.10.10.10\"},{\"dns\":\"entry2.example.tld\",\"target\":\"my.cluster.local\"}]'\n```\n\nUse `hasKey` to guard against a missing label, then iterate with `range` to emit one FQDN per entry:\n\n```yml\nargs:\n  - --fqdn-template={{ if hasKey .Labels \"records\" }}{{ range $entry := (index .Labels \"records\" | fromJson) }}{{ index $entry \"dns\" }},{{ end }}{{ end }}\n\n# Result: entry1.internal.tld, entry2.example.tld\n```\n"
  },
  {
    "path": "docs/advanced/import-records.md",
    "content": "# Import Existing DNS Records\n\nSometimes DNS records are created manually (e.g., through Route53, CloudDNS, or AzureDNS), but you still want ExternalDNS to take ownership of them for ongoing management. This tutorial shows how to “import” such records into ExternalDNS by creating the appropriate TXT records.\n\n---\n\n## Prerequisites\n\n* A working Kubernetes cluster\n* ExternalDNS installed and configured with your DNS provider\n* Manually created DNS records that you want to manage\n\n---\n\n## Example: Importing a Manually Created A Record\n\nLet’s assume you already have the following A record created manually in Route53:\n\n```text\ngrafana.dev.example.com  → A record → pointing to NLB\n```\n\nThis entry is referenced in an Istio Gateway resource but was not created by ExternalDNS.\n\nThis is how a gateway.yaml file looks like:\n\n```yaml\napiVersion: networking.istio.io/v1\nkind: Gateway\nmetadata:\n  name: gateway\n  namespace: istio-system\nspec:\n  selector:\n    istio: gateway\n  servers:\n  - hosts:\n    - grafana.dev.example.com\n    port:\n      name: http\n      number: 80\n      protocol: HTTP\n```\n\nExternal-dns deployment file:\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\n  namespace: kube-system\nspec:\n  minReadySeconds: 15\n  replicas: 2\n  revisionHistoryLimit: 10\n  selector:\n    matchLabels:\n      app: external-dns\n  strategy:\n    rollingUpdate:\n      maxSurge: 50%\n      maxUnavailable: 25%\n    type: RollingUpdate\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      automountServiceAccountToken: true\n      containers:\n      - args:\n        - --source=service\n        - --source=ingress\n        - --source=istio-gateway\n        - --domain-filter=dev.example.com.\n        - --provider=aws\n        - --policy=sync\n        - --aws-zone-type=private\n        - --registry=txt\n        - --events\n        - --txt-owner-id=dev.example.com\n        - --log-level=info\n        env:\n        - name: AWS_DEFAULT_REGION\n          value: us-west-2\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        imagePullPolicy: IfNotPresent\n        name: external-dns\n      securityContext:\n        fsGroup: 65534\n        runAsNonRoot: false\n      serviceAccount: external-dns\n```\n\n---\n\n## Step 1: Create Corresponding TXT Records\n\nTo let ExternalDNS take ownership of the existing A record, you must add TXT records that follow the ExternalDNS format. For example:\n\n```text\naaaa-grafana.dev.example.com  → TXT → \"heritage=external-dns,external-dns/owner=dev.example.com,external-dns/resource=gateway/istio/gateway\"\ncname-grafana.dev.example.com → TXT → \"heritage=external-dns,external-dns/owner=dev.example.com,external-dns/resource=gateway/istio/gateway\"\n```\n\nNote: The easiest way to determine the correct TXT value is to create a dummy record with ExternalDNS. This will generate the required TXT entries, which you can then copy and apply to your manually created records.\n\nThese TXT records tell ExternalDNS:\n\n* Which resource owns the record (`external-dns/resource=...`) (in this case, it's istio)\n* Which owner identifier is managing it (`external-dns/owner=...`)\n\n---\n\n## Step 2: Verify ExternalDNS Behavior\n\nAfter creating the TXT records, wait for the next reconciliation loop. You should now see ExternalDNS managing the record without errors.\n\n* With `policy=sync`: if you remove the entry from the Kubernetes resource (e.g., Istio Gateway), ExternalDNS will also remove the corresponding DNS record from your provider.\n* With `policy=upsert-only`: ExternalDNS will not delete existing records, even if you remove them from Kubernetes resources.\n\n---\n\n## Notes\n\n* TXT records are required because they serve as ownership markers, preventing conflicts between multiple ExternalDNS controllers.\n* This approach is especially useful during migrations, where DNS records pre-exist but you want to avoid downtime or duplication.\n\n---\n\nWith this setup, ExternalDNS will manage both newly created and previously existing records in a consistent way.\n"
  },
  {
    "path": "docs/advanced/nat64.md",
    "content": "# Configure NAT64 DNS Records\n\nSome NAT64 configurations are entirely handled outside the Kubernetes cluster, therefore Kubernetes does not know anything about the associated IPv4 addresses. ExternalDNS should also be able to create A records for those cases.\nTherefore, we can configure `nat64-networks`, which **must** be a /96 network. You can also specify multiple `nat64-networks` for more complex setups.\nThis creates an additional A record with a NAT64-translated IPv4 address for each AAAA record pointing to an IPv6 address within the given `nat64-networks`.\n\nThis can be configured with the following flag passed to the operator binary. You can also pass multiple `nat64-networks` by using a comma as seperator.\n\n```sh\n--nat64-networks=\"2001:db8:96::/96\"\n```\n\n## Setup Example\n\nWe use an external NAT64 resolver and SIIT (Stateless IP/ICMP Translation). Therefore, our nodes only have IPv6 IP adresses but can reach IPv4 addresses *and* can be reached via IPv4.\nOutgoing connections are a classic NAT64 setup, where all IPv6 addresses gets translated to a small pool of IPv4 addresses.\nIncoming connnections are mapped on a different IPv4 pool, e.g. `198.51.100.0/24`, which can get translated one-to-one to IPv6 addresses.\nWe dedicate a `/96` network for this, for example `2001:db8:96::/96`, so `198.51.100.0/24` can translated to `2001:db8:96::c633:6400/120`. Note: `/120` IPv6 network has exactly as many IP addresses as `/24` IPv4 network.\n\nTherefore, the `/96` network can be configured as `nat64-networks`. This means, that `2001:0DB8:96::198.51.100.10` or `2001:db8:96::c633:640a` can be translated to `198.51.100.10`.\nAny source can point a record to an IPv6 address within the given `nat64-networks`, for example `2001:db8:96::c633:640a`.\nThis creates by default an AAAA record and - if `nat64-networks` is configured - also an A record with `198.51.100.10` as target.\n"
  },
  {
    "path": "docs/advanced/operational-best-practices.md",
    "content": "---\ntags:\n  - advanced\n  - operations\n  - performance\n  - configuration\n---\n\n# Operational Best Practices\n\nThis guide covers configuration recommendations for running external-dns reliably in production.\nIt focuses on the interaction between flags and real-world deployment scenarios — scope, memory,\nscale, and observability — and complements the per-feature reference pages linked throughout.\n\n> If you have operational experience or best practices not covered here, please open a proposal\n> PR to share it with the community.\n\n## Production Readiness Checklist\n\nUse this as a quick review before deploying to production. Each item links to the relevant\nsection below.\n\n**Resource scope**\n\n- [ ] Set [`--service-type-filter`](#reducing-the-informer-scope) to only the service types you\n  actually publish (e.g., `LoadBalancer`). The default watches Pods, EndpointSlices, and Nodes\n  unnecessarily for most deployments.\n- [ ] Add [`--label-filter` or `--annotation-filter`](#reducing-the-informer-scope) to further\n  limit which objects are cached.\n\n**Source configuration**\n\n- [ ] Only configure [`--source=`](#source-configuration-and-preflight-validation) types whose\n  CRDs are fully installed and established on the cluster. A missing CRD does not always produce\n  a clear error — it can manifest as a `context deadline exceeded` timeout or silent informer\n  staleness.\n- [ ] Grant RBAC `list` **and** `watch` for every resource type each configured source requires.\n  A missing `watch` permission lets external-dns start cleanly but freezes its view of the\n  cluster — DNS records drift silently with no crash and no log warning.\n- [ ] Scope RBAC to only the sources that are configured. Excess permissions hide misconfiguration\n  rather than surfacing it.\n- [ ] In multi-cluster deployments, use per-cluster source lists rather than a shared\n  configuration.\n- [ ] Validate against a staging environment that mirrors production CRD and RBAC profiles before\n  rolling out changes.\n\n**Scaling**\n\n- [ ] Scope resources at every level — service type, label, annotation, domain, zone ID. See\n  [Scope resources](#scope-resources).\n- [ ] Split into multiple instances for large zone sets or source mixes, each with a distinct --txt-owner-id` and non-overlapping domain scope. See [Split instances](#split-instances).\n- [ ] Tune reconcile frequency and raise `--request-timeout` on large clusters. See [Reduce reconcile pressure](#reduce-reconcile-pressure).\n\n**Observability**\n\n- [ ] Alert on [`external_dns_controller_consecutive_soft_errors`](#key-metrics) greater than 0\n  for more than one reconcile cycle.\n- [ ] Alert on a sustained increase in [`external_dns_source_errors_total`](#key-metrics) or\n  [`external_dns_registry_errors_total`](#key-metrics).\n- [ ] Enable [`--events-emit=RecordError`](#kubernetes-events-for-invalid-endpoints) to surface\n  misconfigured endpoints on the responsible Kubernetes resource.\n\n**Registry and ownership**\n\n- [ ] Set a unique `--txt-owner-id` per external-dns instance and avoid overlapping\n  `--domain-filter` scopes. Multiple instances writing to the same zone without distinct owner\n  IDs can produce conflict errors and, if a conflict causes a hard exit, a crashloop.\n  See [State Conflicts and Ownership](#state-conflicts-and-ownership).\n\n**Provider**\n\n- [ ] Configure batch change size and interval for your provider if you manage large or\n  frequently-changing zones. See [DNS provider API rate limits](rate-limits.md) for per-provider\n  flags.\n- [ ] Enable zone caching if your provider supports it. Zone enumeration is an API call on\n  every reconcile; caching it reduces provider API pressure significantly for stable zone sets.\n  See [Zone caching](#zone-list-caching) for supported providers and flags.\n- [ ] Scope provider credentials (API keys, IAM roles) to only the zones external-dns manages.\n  Zone filtering flags express intent but are not an enforcement boundary — the credentials are.\n  See [Scope provider credentials to specific zones](#scope-provider-credentials-to-specific-zones).\n\n---\n\n## Resource Scope and Memory\n\n### The service source watches more than just Services\n\nBy default the `service` source registers Kubernetes informers for **Services, Pods, EndpointSlices,\nand Nodes**. Which informers are active depends on the service types in scope:\n\n| Active informers      | Triggered when                                  |\n|:----------------------|:------------------------------------------------|\n| Services              | Always                                          |\n| Pods + EndpointSlices | `NodePort` or `ClusterIP` services are in scope |\n| Nodes                 | `NodePort` services are in scope                |\n\nWith no `--service-type-filter` set (the default), all service types are in scope and all four\ninformers are started. On large clusters this has two consequences:\n\n1. **Steady-state memory**: external-dns holds an in-memory cache of every Pod, EndpointSlice,\n   and Node in the cluster — not only the ones relevant to DNS.\n2. **Startup memory burst**: the classic Kubernetes LIST code path fetches all objects of a type\n   into memory at once during initial informer sync. A Pod transformer is applied\n   ([`source/service.go:159`](../../source/service.go)) to reduce stored size, but as the comment\n   there notes:\n   > \"If watchList is not used it will not prevent memory bursts on the initial informer sync.\"\n\n### Reducing the informer scope\n\nThe most effective mitigation is to restrict the service types external-dns watches:\n\n```sh\n# Most clusters only need LoadBalancer — eliminates Pod, EndpointSlice, and Node informers entirely\n--service-type-filter=LoadBalancer\n```\n\nCombine with label or annotation filters to further limit the set of Service objects listed:\n\n```sh\n--label-filter=external-dns/enabled=true\n--annotation-filter=external-dns.alpha.kubernetes.io/hostname\n```\n\nThe table below shows which informers are eliminated by `--service-type-filter`:\n\n| Filter value                | Informers removed                 |\n|:----------------------------|:----------------------------------|\n| `LoadBalancer`              | Pods, EndpointSlices, Nodes       |\n| `LoadBalancer,ExternalName` | Pods, EndpointSlices, Nodes       |\n| `ClusterIP`                 | Nodes                             |\n| `NodePort`                  | *(none — all informers required)* |\n\n> **Note:** The informer scope reduction is a side-effect of type filtering, not its primary\n> purpose. Always choose filters based on what DNS records you actually need to publish; the\n> memory reduction is a bonus.\n\n### Reducing startup memory bursts\n\nThe memory burst during initial sync is a known limitation of the classic LIST code path in\n`client-go`. A streaming alternative called **WatchList** (`WatchListClient` feature gate) avoids\nthe burst by receiving objects one-at-a-time via a Watch with `SendInitialEvents=true` rather than\nfetching all objects at once.\n\nThe `WatchListClient` feature gate defaults to `true` in recent versions of client-go, so the\nburst is effectively eliminated when running the latest release of external-dns. On older\nreleases, `--service-type-filter` is the primary mitigation. **Use the latest release.**\n\n> **Note:** Even with WatchList enabled, transformers and indexers on all informer types are\n> needed to reduce steady-state memory. Work is ongoing to add them consistently across sources.\n\n## Source Configuration and Preflight Validation\n\nExternal-dns fails fast when a configured source cannot initialize — **this is intentional.**\nA crashloop is a clear, explicit signal that the configuration is wrong. Silently skipping a\nbroken source would mask the problem and make failures much harder to diagnose.\n\nProduction safety should come from correct configuration and preflight validation, not from\nexternal-dns guessing what the user meant.\n\n### Failure modes are not always obvious\n\nThe difficulty is that RBAC problems and missing CRDs do not always surface as a clean, explicit\nerror. Depending on what is misconfigured, the failure mode can be a timeout, an empty result,\nor silent staleness rather than a crash:\n\n| Misconfiguration                 | Typical symptom                                            | Why it's subtle                                                                                       |\n|:---------------------------------|:-----------------------------------------------------------|:------------------------------------------------------------------------------------------------------|\n| CRD not installed                | `context deadline exceeded` after ~60s                     | Informer blocks waiting for cache sync; no \"CRD not found\" message                                    |\n| No LIST permission               | `403 Forbidden` → exit                                     | Usually clean, but error may reference an internal API path that is hard to map back to the source    |\n| LIST allowed, WATCH denied       | Informer starts, never receives updates                    | DNS records appear stale; no crash, no error logged after startup                                     |\n| Admission webhook misconfigured  | Source initializes successfully, changes silently rejected | External-dns sees no error; records are never created or updated                                      |\n\nThe LIST-without-WATCH case is particularly dangerous: external-dns starts cleanly, reports\nhealthy, but its view of the cluster is frozen at the point of last successful LIST. DNS records\nwill drift from actual cluster state without any indication in logs or metrics.\n\n### Practices\n\n**Explicitly scope enabled sources.**\nOnly configure `--source=` types that are fully supported on the target cluster — the right CRDs\ninstalled, RBAC granted, and any admission webhooks configured. Do not rely on any form of\nbest-effort or graceful degradation.\n\n```sh\n# Configure only what is present and authorized on this cluster\n--source=service\n--source=ingress\n```\n\n**Install CRDs before enabling dependent sources.**\nFor sources that depend on custom resources (Gateway API, Istio, CRD source), install the CRDs\nand verify they are established (`kubectl get crd <name>` shows `ESTABLISHED`) before adding the\ncorresponding `--source=` flag. A missing CRD does not always produce a \"not found\" error — it\ncan cause the informer cache sync to block and time out, surfacing as a generic `context deadline\nexceeded` at startup with no indication of which CRD is missing.\n\n**Use per-cluster source lists in multi-cluster deployments.**\nWhen managing clusters with different CRD profiles via Helm or ArgoCD, define source lists per\ncluster rather than sharing a single configuration. A value that works on a cluster with Gateway\nAPI installed will crash-loop on one without it.\n\n```yaml\n# values-cluster-a.yaml  (Gateway API installed)\nsources:\n  - service\n  - gateway-httproute\n\n# values-cluster-b.yaml  (no Gateway API)\nsources:\n  - service\n  - ingress\n```\n\n**Validate configuration early — fail in CI, not in production.**\nAdd a startup check to your CI or pre-deployment pipeline using `--dry-run --once` against a\nstaging cluster that mirrors the production CRD and RBAC profile. `--once` alone will apply real\nDNS changes; always pair it with `--dry-run` for validation. A crash in staging is cheap; a\ncrashloop in production affects DNS for all managed records until the pod restarts.\n\n**Use minimal RBAC.**\nGrant external-dns only the API access it needs for the configured sources. Excess permissions\nare a security concern: if a source is accidentally added to the configuration, external-dns\nwill silently start watching resources it was never intended to manage. Insufficient permissions\nfor a configured source cause a crash on startup, which is the intended signal — but only if\nRBAC is scoped tightly enough to surface it.\n\n## Scaling on Large Clusters\n\nScaling external-dns comes down to three principles applied in combination:\n\n### Scope resources\n\nThe fewer Kubernetes objects external-dns watches and the fewer DNS zones it manages, the lower\nits steady-state memory, API call volume, and reconcile duration. Apply filters at every\navailable level — service type, label, annotation, domain, and zone ID.\n\nSee [Resource Scope and Memory](#resource-scope-and-memory) and\n[Domain Filter](domain-filter.md) for details.\n\n### Split instances\n\nA single external-dns instance managing a large number of zones or sources will have a large\nreconcile surface and long reconcile cycles. Splitting into multiple instances — each responsible\nfor a distinct zone set, namespace, or source type — reduces per-instance load and makes\nfailures smaller in blast radius.\n\nEach instance must have a distinct `--txt-owner-id` and non-overlapping `--domain-filter` or\n`--zone-id-filter` scopes to avoid ownership conflicts. See\n[State Conflicts and Ownership](#state-conflicts-and-ownership).\n\n### Reduce reconcile pressure\n\nTune reconcile frequency to match your actual change rate rather than running at the default\ninterval. Use event-driven reconciliation to react quickly to real changes while keeping\nbackground polling infrequent. Raise `--request-timeout` if informer cache sync exceeds the\ndefault on large clusters.\n\nFor per-provider flags covering batch change sizing, record caching, and zone list caching, see\n[DNS provider API rate limits](rate-limits.md) and [Provider Notes](#provider-notes).\n\n## Observability\n\n### Key metrics\n\nThe following metrics are the first place to look when diagnosing operational problems:\n\n| Metric                                             | When to alert                                                       |\n|:---------------------------------------------------|:--------------------------------------------------------------------|\n| `external_dns_controller_consecutive_soft_errors`  | > 0 for more than one reconcile cycle                               |\n| `external_dns_source_errors_total`                 | Sustained increase (Kubernetes API errors from informers)           |\n| `external_dns_registry_errors_total`               | Any increase (TXT / DynamoDB registry failures)                     |\n| `external_dns_controller_verified_records`         | Unexpected drop (records no longer owned by this instance)          |\n\nSee [Available Metrics](../monitoring/metrics.md) for the full list.\n\n> **Future:** Work is in progress to add an `external_dns_source_invalid_endpoints` gauge\n> (partitioned by `record_type` and `source_type`) that resets and refills each reconcile cycle,\n> making it straightforward to alert on dropped endpoints without log grepping. Until that lands,\n> watch `external_dns_source_errors_total` and enable `--events-emit=RecordError` (see below).\n\n### Kubernetes Events for invalid endpoints\n\nInvalid endpoints — CNAME self-references, malformed MX/SRV records, unsupported alias types —\nare silently dropped by the dedup layer with only a log warning at default log levels. Without\nstructured observability, the only way to discover them is to grep logs.\n\nEnable `RecordError` events to surface invalid endpoints directly on the responsible Kubernetes\nresource:\n\n```sh\n--events-emit=RecordError\n```\n\n```sh\n# Inspect invalid endpoints across the cluster\nkubectl get events --field-selector reason=RecordError\n\n# Or scoped to a specific resource\nkubectl describe ingress my-ingress\n```\n\nSee [Kubernetes Events in External-DNS](events.md) for full documentation and the list of\nsupported event types.\n\n## State Conflicts and Ownership\n\nExternal-dns detects desired vs. current state, computes a plan, and applies it — assuming the\nplan is internally consistent. When a provider returns a conflict error (HTTP 409 or equivalent),\nit means the current DNS state does not match what external-dns expects. **This is a state\nproblem, not a software bug.** The correctness of annotations and desired state is the\noperator's responsibility. External-dns cannot auto-correct user-defined configuration: any\nautomated correction risks removing or replacing DNS records that services depend on, making\nthose services unreachable. Instead, external-dns does its best to make these problems visible\nso operators can fix them deliberately. It has no general conflict-resolution policy: it drops\nsome well-known invalid records (such as CNAME self-references), but does not apply a subset,\nauto-correct arbitrary conflicts, or attempt partial best-effort behavior. Retrying the same\nrequest without changing the input or reconciling the external state will deterministically fail.\n\n```mermaid\nflowchart TD\n    A[Kubernetes resources] --> B[external-dns computes plan]\n    B --> C{Apply to DNS provider}\n    C -- Success --> D[DNS records updated]\n    D --> A\n    C -- Conflict error --> E[State mismatch detected]\n    E --> F[\"No auto-correction<br>auto-fix risks removing records<br>services depend on\"]\n    F --> G[Problem surfaced<br>via logs / metrics / events]\n    G --> H[Operator fixes state]\n    H --> A\n```\n\n**Crashloop amplification.** A hard error that causes external-dns to exit leads to a crashloop:\nkubelet restarts the pod, informers resync with full LIST calls to the Kubernetes API for every\nwatched resource type (Services, Pods, EndpointSlices, Nodes), and the same conflicting batch is\nattempted again. Each restart repeats the cycle, progressively increasing LIST traffic against\nthe Kubernetes API server. On large clusters or at high restart frequency this can contribute to\nKubernetes API throttling that affects other controllers and workloads — not just external-dns.\n\n```mermaid\nflowchart LR\n    A[Conflict error<br>from provider] --> B[Hard exit]\n    B --> C[kubelet restarts pod]\n    C --> D[Informers resync<br>full LIST calls to<br>Kubernetes API]\n    D --> E[Same conflicting<br>batch applied]\n    E --> A\n    D -. \"pressure accumulates\\non each restart\" .-> F[Kubernetes API<br>throttling]\n```\n\nA hard error that kills the process does not increment `external_dns_controller_consecutive_soft_errors`\n— that metric tracks soft errors only. Monitor pod restarts via\n`kube_pod_container_status_restarts_total` and alert on crashloop backoff (`CrashLoopBackOff`\nstatus) to catch this early.\n\n**When you observe conflict errors, fix the state:**\n\n- Ensure a single external-dns instance owns each zone or record set. When using the TXT or\n  DynamoDB registry, use a distinct `--txt-owner-id` per instance and avoid overlapping\n  `--domain-filter` scopes.\n- Remove or update conflicting records in the DNS provider directly.\n- Review annotations and desired state for invalid record definitions — for example, mixing CNAME\n  with A/AAAA records for the same hostname, or a CNAME that points to itself.\n- Check for other controllers or automation writing to the same zone.\n- If the environment is in an inconsistent state during a migration or incident, scale\n  external-dns to zero until the state is reconciled, then scale it back up.\n\n> **Visibility:** Work is ongoing to make state problems as visible as possible before they\n> become incidents. Planned improvements include per-record-type metrics for rejected endpoints\n> and Kubernetes Events emitted directly on the responsible resource, so operators can alert on\n> conflicts without grepping logs. If you encounter a conflict or misconfiguration that is not\n> surfaced by existing metrics or events, please open an issue or submit a PR.\n\n## Provider Notes\n\n### Zone list caching\n\nOn every reconcile, external-dns calls the provider API to enumerate the zones it is allowed to\nmanage. For accounts with many zones, or providers with strict API rate limits, this enumeration\ncan be a significant source of API traffic even when no DNS records are changing.\n\nSeveral providers support a zone list cache that stores the zone list in memory for a configurable\nTTL and re-fetches only after it expires. Set the TTL to reflect how often your zone list\nactually changes — for most deployments zones are added or removed rarely, so a value of `1h` or\nlonger is appropriate.\n\n> **Note:** Zone list caching is distinct from record caching (`--provider-cache-time`), which\n> caches the DNS records within a zone. Both can be used together. See\n> [DNS provider API rate limits](rate-limits.md) for per-provider flags.\n\n### Scope provider credentials to specific zones\n\nProvider API keys or IAM roles should be scoped to only the zones external-dns is expected to\nmanage. Granting access to all zones in an account has two consequences:\n\n- **Operational:** external-dns will enumerate and potentially modify every zone the credentials\n  can reach. A misconfigured filter or a missing `--txt-owner-id` can cause unintended changes\n  to zones outside the intended scope.\n- **Security:** a credential leak exposes every zone in the account, not just the ones\n  external-dns manages.\n\nZone filtering flags express application-level intent and reduce API call volume, but they are\nnot an enforcement boundary — the credentials are. See [Domain Filter](domain-filter.md) for\ndetails.\n\n### Batch API\n\nFor zones with frequent or large change sets, individual per-record API calls can exhaust\nprovider rate limits quickly. Where supported, a batch API significantly reduces call volume.\nThe exact reduction varies by provider, but the general pattern is:\n\n| Approach   | API calls per sync                              |\n|:-----------|:------------------------------------------------|\n| Individual | Grows linearly with number of records changed   |\n| Batch      | Grows with number of batches, not record count  |\n\nWhen a batch submission fails (e.g., one record in the batch is misconfigured), providers\ntypically fall back to individual per-record calls for that sync cycle, so a single bad record\ndoes not block DNS updates for the rest of the zone. See\n[DNS provider API rate limits](rate-limits.md) for per-provider batch flags.\n\n## See Also\n\n- [Flags reference](../flags.md) — complete flag listing with defaults\n- [DNS provider API rate limits](rate-limits.md) — batch sizing, provider cache, and rate-limit tuning\n- [Domain Filter](domain-filter.md) — domain and zone filtering, and the credential boundary distinction\n- [Kubernetes Events in External-DNS](events.md) — event types, sources, and consumption\n- [Available Metrics](../monitoring/metrics.md) — full metrics reference\n"
  },
  {
    "path": "docs/advanced/rate-limits.md",
    "content": "# DNS provider API rate limits considerations\n\n## Introduction\n\nBy design, external-dns refreshes all the records of a zone using API calls.\nThis refresh may happen peridically and upon any changed object if the flag `--events` is enabled.\n\nDepending on the size of the zone and the infrastructure deployment, this may lead to external-dns\nhitting the DNS provider's rate-limits more easily.\n\nIn particular, it has been found that with 200k records in an AWS Route53 zone, each refresh triggers around\n70 API calls to retrieve all the records, making it more likely to hit the AWS Route53 API rate limits.\n\nTo prevent this problem from happening, external-dns has implemented a cache to reduce the pressure on the DNS\nprovider APIs.\n\nThis cache is optional and systematically invalidated when DNS records have been changed in the cluster\n(new or deleted domains or changed target).\n\n## Trade-offs\n\nThe major trade-off of this setting relies in the ability to recover from a deleted record on the DNS provider side.\nAs the DNS records are cached in memory, external-dns will not be made aware of the missing records and will hence\ntake a longer time to restore the deleted or modified record on the provider side.\n\nThis option is enabled using the `--provider-cache-time=15m` command line argument, and turned off when `--provider-cache-time=0m`\n\n## Monitoring\n\nYou can evaluate the behaviour of the cache thanks to the built-in metrics\n\n* `external_dns_provider_cache_records_calls`\n  * The number of calls to the provider cache Records list.\n  * The label `from_cache=true` indicates that the records were retrieved from memory and the DNS provider was not reached\n  * The label `from_cache=false` indicates that the cache was not used and the records were retrieved from the provider\n* `external_dns_provider_cache_apply_changes_calls`\n  * The number of calls to the provider cache ApplyChanges.\n  * Each ApplyChange systematically invalidates the cache and makes subsequent Records list to be retrieved from the provider without cache.\n\n## Related options\n\nThis global option is available for all providers and can be used in pair with other global\nor provider-specific options to fine-tune the behaviour of external-dns\nto match the specific needs of your deployments, with the goal to reduce the number of API calls to your DNS provider.\n\n* Google\n  * `--google-batch-change-interval=1s` When using the Google provider, set the interval between batch changes. ($EXTERNAL_DNS_GOOGLE_BATCH_CHANGE_INTERVAL)\n  * `--google-batch-change-size=1000` When using the Google provider, set the maximum number of changes that will be applied in each batch.\n* AWS\n  * `--aws-batch-change-interval=1s` When using the AWS provider, set the interval between batch changes.\n  * `--aws-batch-change-size=1000` When using the AWS provider, set the maximum number of changes that will be applied in each batch.\n  * `--aws-batch-change-size-bytes=32000` When using the AWS provider, set the maximum byte size that will be applied in each batch.\n  * `--aws-batch-change-size-values=1000` When using the AWS provider, set the maximum total record values that will be applied in each batch.\n  * `--aws-zones-cache-duration=0s` When using the AWS provider, set the zones list cache TTL (0s to disable).\n  * `--[no-]aws-zone-match-parent` Expand limit possible target by sub-domains\n* Cloudflare\n  * `--cloudflare-dns-records-per-page=100` When using the Cloudflare provider, specify how many DNS records listed per page, max possible 5,000 (default: 100)\n* OVH\n  * `--ovh-api-rate-limit=20` When using the OVH provider, specify the API request rate limit, X operations by seconds (default: 20)\n\n* Global\n  * `--registry=txt` The registry implementation to use to keep track of DNS record ownership.\n    * Other registry options such as dynamodb can help mitigate rate limits by storing the registry outside of the DNS hosted zone (default: txt, options: txt, noop, dynamodb, aws-sd)\n  * `--txt-cache-interval=0s` The interval between cache synchronizations in duration format (default: disabled)\n  * `--interval=1m0s` The interval between two consecutive synchronizations in duration format (default: 1m)\n  * `--min-event-sync-interval=5s` The minimum interval between two consecutive synchronizations triggered from kubernetes events in duration format (default: 5s)\n  * `--[no-]events` When enabled, in addition to running every interval, the reconciliation loop will get triggered when supported sources change (default: disabled)\n\nA general recommendation is to enable `--events` and keep `--min-event-sync-interval` relatively low to have a better responsiveness when records are\ncreated or updated inside the cluster.\nThis should represent an acceptable propagation time between the creation of your k8s resources and the time they become registered in your DNS server.\n\nOn a general manner, the higher the `--provider-cache-time`, the lower the impact on the rate limits, but also, the slower the recovery in case of a deletion.\nThe `--provider-cache-time` value should hence be set to an acceptable time to automatically recover restore deleted records.\n\n✍️ Note that caching is done within the external-dns controller memory. You can invalidate the cache at any point in time by restarting it (for example doing a rolling update).\n"
  },
  {
    "path": "docs/advanced/split-horizon.md",
    "content": "# Split Horizon DNS\n\nSplit horizon DNS allows you to serve different DNS responses based on the client's location - internal clients receive private IPs while external clients receive public IPs. External-DNS supports split horizon DNS by running multiple instances with different annotation prefixes.\n\n## Overview\n\nBy default, all external-dns instances use the same annotation prefix: `external-dns.alpha.kubernetes.io/`. This means all instances process the same annotations. To enable split horizon DNS, you can configure each instance to use a different annotation prefix via the `--annotation-prefix` flag.\n\n## Use Cases\n\n- **Internal/External separation**: Internal DNS points to private IPs (ClusterIP), external DNS points to public Load Balancer IPs\n- **Multiple DNS providers**: Route different services to different DNS providers (e.g., internal to CoreDNS, external to Route53)\n- **Geographic split**: Different DNS records for different regions\n\n## Configuration\n\n### Basic Split Horizon Setup\n\n**Internal DNS Instance:**\n\n```bash\nexternal-dns \\\n  --annotation-prefix=internal.company.io/ \\\n  --source=service \\\n  --source=ingress \\\n  --provider=aws \\\n  --aws-zone-type=private \\\n  --domain-filter=internal.company.com \\\n  --txt-owner-id=internal-dns\n```\n\n**External DNS Instance:**\n\n```bash\nexternal-dns \\\n  --annotation-prefix=external-dns.alpha.kubernetes.io/ \\  # default, can be omitted\n  --source=service \\\n  --source=ingress \\\n  --provider=aws \\\n  --aws-zone-type=public \\\n  --domain-filter=company.com \\\n  --txt-owner-id=external-dns\n```\n\n### Service with Both Annotations\n\n```yaml\napiVersion: v1\nkind: Service\nmetadata:\n  name: myapp\n  annotations:\n    # Internal DNS reads this\n    internal.company.io/hostname: myapp.internal.company.com\n    internal.company.io/ttl: \"300\"\n    internal.company.io/target: 10.0.1.50  # Private IP\n\n    # External DNS reads this\n    external-dns.alpha.kubernetes.io/hostname: myapp.company.com\n    external-dns.alpha.kubernetes.io/ttl: \"60\"\n    # No target = uses LoadBalancer IP automatically\nspec:\n  type: LoadBalancer\n  clusterIP: 10.0.1.50\n  ports:\n  - port: 80\n    targetPort: 8080\n  selector:\n    app: myapp\n```\n\n**Result:**\n\n- **Internal DNS** (Route53 Private Zone `internal.company.com`): `myapp.internal.company.com → 10.0.1.50`\n- **External DNS** (Route53 Public Zone `company.com`): `myapp.company.com → 203.0.113.10` (LoadBalancer IP)\n\n### Helm Chart Configuration\n\nYou can use the Helm chart to deploy multiple instances:\n\n**values-internal.yaml:**\n\n```yaml\nannotationPrefix: \"internal.company.io/\"\n\nprovider:\n  name: aws\n\naws:\n  zoneType: private\n\ndomainFilters:\n  - internal.company.com\n\ntxtOwnerId: internal-dns\n\nsources:\n  - service\n  - ingress\n```\n\n**values-external.yaml:**\n\n```yaml\n# annotationPrefix defaults to \"external-dns.alpha.kubernetes.io/\"\n# can be omitted or set explicitly:\n# annotationPrefix: \"external-dns.alpha.kubernetes.io/\"\n\nprovider:\n  name: aws\n\naws:\n  zoneType: public\n\ndomainFilters:\n  - company.com\n\ntxtOwnerId: external-dns\n\nsources:\n  - service\n  - ingress\n```\n\n**Deploy:**\n\n```bash\n# Internal instance\nhelm install external-dns-internal external-dns/external-dns \\\n  --namespace external-dns-internal \\\n  --create-namespace \\\n  --values values-internal.yaml\n\n# External instance\nhelm install external-dns-external external-dns/external-dns \\\n  --namespace external-dns-external \\\n  --create-namespace \\\n  --values values-external.yaml\n```\n\n## Advanced Examples\n\n### Three-Way Split (Internal / DMZ / External)\n\n```yaml\napiVersion: v1\nkind: Service\nmetadata:\n  name: api\n  annotations:\n    # Internal (private network only)\n    internal.company.io/hostname: api.internal.company.com\n    internal.company.io/ttl: \"300\"\n\n    # DMZ (accessible from office network)\n    dmz.company.io/hostname: api.dmz.company.com\n    dmz.company.io/ttl: \"120\"\n\n    # External (public internet)\n    external-dns.alpha.kubernetes.io/hostname: api.company.com\n    external-dns.alpha.kubernetes.io/ttl: \"60\"\n    external-dns.alpha.kubernetes.io/cloudflare-proxied: \"true\"\nspec:\n  type: LoadBalancer\n  # ...\n```\n\n**Deploy three instances:**\n\n```bash\n# Internal\n--annotation-prefix=internal.company.io/ --provider=aws --aws-zone-type=private\n\n# DMZ\n--annotation-prefix=dmz.company.io/ --provider=aws --aws-zone-type=private\n\n# External\n--annotation-prefix=external-dns.alpha.kubernetes.io/ --provider=cloudflare\n```\n\n### Different Providers Per Instance\n\n```yaml\napiVersion: v1\nkind: Service\nmetadata:\n  name: webapp\n  annotations:\n    # Route53 for AWS internal\n    aws.company.io/hostname: webapp.aws.company.com\n    aws.company.io/aws-alias: \"true\"\n\n    # Cloudflare for public\n    cf.company.io/hostname: webapp.company.com\n    cf.company.io/cloudflare-proxied: \"true\"\nspec:\n  type: LoadBalancer\n  # ...\n```\n\n**Deploy:**\n\n```bash\n# AWS instance\n--annotation-prefix=aws.company.io/ --provider=aws\n\n# Cloudflare instance\n--annotation-prefix=cf.company.io/ --provider=cloudflare\n```\n\n## Important Notes\n\n1. **Annotation prefix must end with `/`** - The validation will fail if the prefix doesn't end with a forward slash.\n2. **Backward compatibility** - If you don't specify `--annotation-prefix`, the default `external-dns.alpha.kubernetes.io/` is used, maintaining full backward compatibility.\n3. **All annotations use the same prefix** - When you set a custom prefix, ALL external-dns annotations (hostname, ttl, target, cloudflare-proxied, etc.) must use that prefix.\n4. **TXT ownership records** - Each instance should have a unique `--txt-owner-id` to avoid conflicts in ownership tracking.\n5. **Provider-specific annotations** - Provider-specific annotations (like `cloudflare-proxied`, `aws-alias`) also use the custom prefix:\n\n```yaml\ncustom.io/hostname: example.com\ncustom.io/cloudflare-proxied: \"true\"  # NOT external-dns.alpha.kubernetes.io/cloudflare-proxied\n```\n\n## Troubleshooting\n\n### Both instances processing the same resources\n\n**Problem:** Both internal and external instances are creating records for the same service.\n\n**Solution:** Make sure you're using different annotation prefixes and that your services have the correct annotations:\n\n```yaml\n# ✅ Correct - different prefixes\ninternal.company.io/hostname: internal.example.com\nexternal-dns.alpha.kubernetes.io/hostname: example.com\n\n# ❌ Wrong - same prefix\nexternal-dns.alpha.kubernetes.io/hostname: internal.example.com\nexternal-dns.alpha.kubernetes.io/hostname: example.com  # Second one overwrites first\n```\n\n### Validation error: \"annotation-prefix must end with '/'\"\n\n**Problem:** The annotation prefix doesn't end with a forward slash.\n\n**Solution:** Always end your custom prefix with `/`:\n\n```bash\n# ✅ Correct\n--annotation-prefix=custom.io/\n\n# ❌ Wrong\n--annotation-prefix=custom.io\n```\n\n### Provider-specific annotations not working\n\n**Problem:** Cloudflare/AWS-specific annotations are not being applied.\n\n**Solution:** Provider-specific annotations must use the same prefix as the hostname:\n\n```yaml\n# If using custom prefix\ncustom.io/hostname: example.com\ncustom.io/cloudflare-proxied: \"true\"\ncustom.io/ttl: \"60\"\n```\n\n## See Also\n\n- [Configuration Precedence](configuration-precedence.md) - Understanding how external-dns processes configuration\n- [FAQ](../faq.md) - Frequently asked questions\n- [AWS Provider](../tutorials/aws.md) - AWS Route53 configuration\n- [Cloudflare Provider](../tutorials/cloudflare.md) - Cloudflare configuration\n"
  },
  {
    "path": "docs/advanced/ttl.md",
    "content": "# Configure DNS record TTL (Time-To-Live)\n\n> To customize DNS record TTL (Time-To-Live) in a DNS record`, you can use the `external-dns.alpha.kubernetes.io/ttl: <duration>` annotation or flag `--min-ttl=<duration>`. TTL is specified as an integer encoded as string representing seconds. Example; `1s`, `1m2s`, `1h2m11s`\n\nBehaviour:\n\n- If the `external-dns.alpha.kubernetes.io/ttl` annotation is set, it overrides the default TTL(0) value.\n- If the annotation is not set, the default TTL value is used, unless the `--min-ttl` flag is provided.\n- If the annotation is set to `0`, and the `--min-ttl=1s` flag is provided, the value from `--min-ttl` will be used instead.\n- Not all providers support the custom TTL value, and some may override it with their own default values.\n\nTo configure it, annotate a service/ingress, e.g.:\n\n```yaml\napiVersion: v1\nkind: Service\nmetadata:\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: nginx.external-dns-test.my-org.com.\n    external-dns.alpha.kubernetes.io/ttl: \"60\"\n  ...\n```\n\nTTL can also be specified as a duration value parsable by Golang [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration):\n\n```yaml\napiVersion: v1\nkind: Service\nmetadata:\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: nginx.external-dns-test.my-org.com.\n    external-dns.alpha.kubernetes.io/ttl: \"1m\"\n  ...\n```\n\nBoth examples result in the same value of 60 seconds TTL.\n\nTTL must be a positive value.\n\n## TTL annotation support\n\n> Note: For TTL annotations to work, the `external-dns.alpha.kubernetes.io/hostname` annotation must be set on the resource and be supported by the provider as well as the source.\n\n### Providers\n\n| Provider       | Supported |\n|:---------------|:---------:|\n| `Akamai`       |    Yes    |\n| `AlibabaCloud` |    Yes    |\n| `AWS`          |    Yes    |\n| `AWSSD`        |    Yes    |\n| `Azure`        |    Yes    |\n| `Civo`         |    No     |\n| `Cloudflare`   |    Yes    |\n| `CoreDNS`      |    No     |\n| `DNSSimple`    |    Yes    |\n| `Exoscale`     |    Yes    |\n| `Gandi`        |    Yes    |\n| `GoDaddy`      |    Yes    |\n| `Google GCP`   |    Yes    |\n| `InMemory`     |    No     |\n| `Linode`       |    No     |\n| `NS1`          |    No     |\n| `OCI`          |    Yes    |\n| `OVH`          |    No     |\n| `PDNS`         |    No     |\n| `PiHole`       |    Yes    |\n| `Plural`       |    No     |\n| `RFC2136`      |    Yes    |\n| `Scaleway`     |    Yes    |\n| `Transip`      |    Yes    |\n| `Webhook`      |    Yes    |\n\n### Sources\n\n| Source                 | Supported |\n|:-----------------------|:---------:|\n| `ambassador-host`      |    Yes    |\n| `connector`            |    No     |\n| `contour-httpproxy`    |    Yes    |\n| `crd`                  |    No     |\n| `empty`                |    No     |\n| `f5-transportserver`   |    Yes    |\n| `f5-virtualserver`     |    Yes    |\n| `fake`                 |    No     |\n| `gateway-grpcroute`    |    Yes    |\n| `gateway-httproute`    |    Yes    |\n| `gateway-tcproute`     |    Yes    |\n| `gateway-tlsroute`     |    Yes    |\n| `gateway-udproute`     |    Yes    |\n| `gloo-proxy`           |    Yes    |\n| `ingress`              |    Yes    |\n| `istio-gateway`        |    Yes    |\n| `istio-virtualservice` |    Yes    |\n| `kong-tcpingress`      |    Yes    |\n| `node`                 |    Yes    |\n| `openshift-route`      |    Yes    |\n| `pod`                  |    Yes    |\n| `service`              |    Yes    |\n| `skipper-routegroup`   |    Yes    |\n| `traefik-proxy`        |    Yes    |\n\n## Notes\n\nWhen the `external-dns.alpha.kubernetes.io/ttl` annotation is not provided, the TTL will default to 0 seconds and `endpoint.TTL.isConfigured()` will be false.\n\n### AWS Provider\n\nThe AWS Provider overrides the value to 300s when the TTL is 0.\nThis value is a constant in the provider code.\n\n### Azure\n\nTTL value should be between 1 and 2,147,483,647 seconds.\nBy default it will be 300s.\n\n### CloudFlare Provider\n\nCloudFlare overrides the value to \"auto\" when the TTL is 0.\n\n### DNSimple Provider\n\nThe DNSimple Provider default TTL is used when the TTL is 0. The default TTL is 3600s.\n\n### Google Provider\n\nPreviously with the Google Provider, TTL's were hard-coded to 300s.\nFor safety, the Google Provider overrides the value to 300s when the TTL is 0.\nThis value is a constant in the provider code.\n\nFor the moment, it is impossible to use a TTL value of 0 with the AWS or Google Providers.\nThis behavior may change in the future.\n\n### Linode Provider\n\nThe Linode Provider default TTL is used when the TTL is 0. The default is 24 hours\n\n### TransIP Provider\n\nThe TransIP Provider minimal TTL is used when the TTL is 0. The minimal TTL is 60s.\n\n## Use Cases for `external-dns.alpha.kubernetes.io/ttl` annotation and `--min-ttl` flag`\n\nThe `external-dns.alpha.kubernetes.io/ttl` annotation allows you to set a custom **TTL (Time To Live)** for DNS records managed by `external-dns`.\n\nUse the `external-dns.alpha.kubernetes.io/tt` annotation to fine-tune DNS caching behavior per record, balancing between update frequency and performance.\n\nThis is useful in several real-world scenarios depending on how frequently DNS records are expected to change.\n\n---\n\n### Fast Failover for Critical Services\n\nFor services that must be highly available—like APIs, databases, or external load balancers—set a **low TTL** (e.g., 30 seconds) so DNS clients quickly update to new IPs during:\n\n- Node failures\n- Region failovers\n- Blue/green deployments\n\n```yaml\nannotations:\n  external-dns.alpha.kubernetes.io/ttl: \"30s\"\n```\n\n---\n\n### Long TTL for Static Services\n\nIf your service’s IP or endpoint rarely changes (e.g., static websites, internal dashboards), you can set a long TTL (e.g., 86400 seconds = 24 hours) to:\n\n- Reduce DNS query load\n- Improve cache performance\n- Lower cost with some DNS providers\n\n```yml\nannotations:\n  external-dns.alpha.kubernetes.io/ttl: \"24h\"\n```\n\n---\n\n### Canary or Experimental Services\n\nUse a short TTL for services under test or experimentation to allow fast DNS propagation when making changes, allowing easy rollback and testing.\n\n---\n\n### Provider-Specific Optimization\n\nSome DNS providers charge per query or have query rate limits. Adjusting the TTL lets you:\n\n- Reduce costs\n- Avoid throttling\n- Manage DNS traffic load efficiently\n\n---\n\n### Regulatory or Contractual SLAs\n\nCertain environments may require TTL values to align with:\n\n- Regulatory guidelines\n- Legacy system compatibility\n- Contractual service-level agreements\n\n---\n\n### Autoscaling Node Pools in GCP (or Other Cloud Providers)\n\nIn environments like Google Cloud Platform (GCP) using private node IPs for DNS resolution, ExternalDNS may register node IPs with a default TTL of 300 seconds.\n\nDuring autoscaling events (e.g., node addition/removal or upgrades), DNS records may remain stale for several minutes, causing traffic to be routed to non-existent nodes.\n\nBy using the TTL annotation you can:\n\n- Reduce TTL to allow faster DNS propagation\n- Ensure quicker routing updates when node IPs change\n- Improve resiliency during frequent cluster topology changes\n- Fine-grained TTL control helps avoid downtime or misrouting in dynamic, autoscaling environments.\n"
  },
  {
    "path": "docs/annotations/annotations.md",
    "content": "# Annotations\n\nExternalDNS sources support a number of annotations on the Kubernetes resources that they examine.\n\nThe following table documents which sources support which annotations:\n\n| Source       | controller | hostname | internal-hostname | target  | ttl     | (provider-specific) |\n|--------------|------------|----------|-------------------|---------|---------|---------------------|\n| Ambassador   |            |          |                   | Yes     | Yes     | Yes                 |\n| Connector    |            |          |                   |         |         |                     |\n| Contour      | Yes        | Yes[^1]  |                   | Yes     | Yes     | Yes                 |\n| CRD          |            |          |                   |         |         |                     |\n| F5           |            |          |                   | Yes     | Yes     |                     |\n| Gateway      | Yes        | Yes[^1]  |                   | Yes[^4] | Yes     | Yes                 |\n| Gloo         |            |          |                   | Yes     | Yes[^5] | Yes[^5]             |\n| Ingress      | Yes        | Yes[^1]  |                   | Yes     | Yes     | Yes                 |\n| Istio        | Yes        | Yes[^1]  |                   | Yes     | Yes     | Yes                 |\n| Kong         |            | Yes[^1]  |                   | Yes     | Yes     | Yes                 |\n| Node         | Yes        |          |                   | Yes     | Yes     |                     |\n| OpenShift    | Yes        | Yes[^1]  |                   | Yes     | Yes     | Yes                 |\n| Pod          |            | Yes      | Yes               | Yes     |         |                     |\n| Service      | Yes        | Yes[^1]  | Yes[^1][^2]       | Yes[^3] | Yes     | Yes                 |\n| Skipper      | Yes        | Yes[^1]  |                   | Yes     | Yes     | Yes                 |\n| Traefik      |            | Yes[^1]  |                   | Yes     | Yes     | Yes                 |\n\n[^1]: Unless the `--ignore-hostname-annotation` flag is specified.\n[^2]: Only behaves differently than `hostname` for `Service`s of type `ClusterIP` or `LoadBalancer`.\n[^3]: Also supported on `Pods` referenced from a headless `Service`'s `Endpoints`.\n[^4]: For Gateway API sources, annotation placement differs by type. See [Gateway API Annotation Placement](#gateway-api-annotation-placement) for details.\n[^5]: The annotation must be on the listener's `VirtualService`.\n\n## external-dns.alpha.kubernetes.io/access\n\nSpecifies which set of node IP addresses to use for a `Service` of type `NodePort`.\n\nIf the value is `public`, use the Nodes' addresses of type `ExternalIP`, plus IPv6 addresses of type `InternalIP`.\n\nIf the value is `private`, use the Nodes' addresses of type `InternalIP`.\n\nIf the annotation is not present and there is at least one address of type `ExternalIP`,\nbehave as if the value were `public`, otherwise behave as if the value were `private`.\n\n## external-dns.alpha.kubernetes.io/controller\n\nIf this annotation exists and has a value other than `dns-controller` then the source ignores the resource.\n\n## external-dns.alpha.kubernetes.io/endpoints-type\n\nSpecifies which set of addresses to use for a [`headless Service`](https://kubernetes.io/docs/concepts/services-networking/service/#headless-services).\n\nSupported values:\n\n- `NodeExternalIP`. Required `--service-type-filter=ClusterIP` and `--service-type-filter=Node` or no `--service-type-filter` flag specified.\n- `HostIP`.\n\nIf the value is `NodeExternalIP`, use each relevant `Pod`'s `Node`'s address of type `ExternalIP`\nplus each IPv6 address of type `InternalIP`.\n\nOtherwise, if the value is `HostIP` or the `--publish-host-ip` flag is specified, use\neach relevant `Pod`'s `Status.HostIP`.\n\nOtherwise, use the `IP` of each of the `Service`'s `Endpoints`'s `Addresses`.\n\n## external-dns.alpha.kubernetes.io/hostname\n\nSpecifies additional domains for the resource's DNS records.\n\nMultiple hostnames can be specified through a comma-separated list, e.g.\n`svc.mydomain1.com,svc.mydomain2.com`.\n\nFor `Pods`, uses the `Pod`'s `Status.PodIP`, unless they are `hostNetwork: true` in which case the NodeExternalIP is used for IPv4 and NodeInternalIP for IPv6.\n\nNotes:\n\n- This annotation can override or add extra hostnames alongside any automatically derived hostnames (e.g., from Ingress.spec.rules[].host).\n- The [`ingress-hostname-source`](#external-dnsalphakubernetesioingress-hostname-source) annotation may be used to specify where to get the domain for an `Ingress` resource.\n- Hostnames must match the domain filter set in ExternalDNS (e.g., --domain-filter=example.com).\n- This is an alpha annotation — subject to change; newer versions may support alternatives or deprecate it.\n- This annotation is helpful for:\n  - Services or other resources without native hostname fields.\n  - Explicit overrides or multi-host situations.\n  - Avoiding reliance on auto-detection or heuristics.\n\n### Use Cases for `external-dns.alpha.kubernetes.io/hostname` annotation\n\n#### Explicit Hostname Mapping for Services\n\nYou have a Service (e.g. of type LoadBalancer or ClusterIP) and want to expose it under a custom DNS name:\n\n```yml\napiVersion: v1\nkind: Service\nmetadata:\n  name: my-service\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: app.example.com\nspec:\n  type: LoadBalancer\n  ...\n```\n\n> ExternalDNS will create a A or CNAME record for app.example.com pointing to the external IP or hostname of the service.\n\n#### Multi-Hostname Records\n\nYou can assign multiple hostnames by separating them with commas:\n\n```yml\nannotations:\n  external-dns.alpha.kubernetes.io/hostname: api.example.com,api.internal.example.com\n```\n\n> ExternalDNS will create two DNS records for the same service.\n\n#### Static DNS Assignment Without Ingress Rules\n\nWhen using Ingress, you usually declare hostnames in the spec.rules[].host. But with this annotation, you can manage DNS independently:\n\n```yml\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  name: my-ingress\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: www.example.com\nspec:\n  rules:\n    - http:\n        paths:\n          - path: /\n            pathType: Prefix\n            backend:\n              service:\n                name: my-service\n                port:\n                  number: 80\n```\n\n> Useful when DNS management is decoupled from routing logic.\n\n## external-dns.alpha.kubernetes.io/ingress-hostname-source\n\nSpecifies where to get the domain for an `Ingress` resource.\n\nIf the value is `defined-hosts-only`, use only the domains from the `Ingress` spec.\n\nIf the value is `annotation-only`, use only the domains from the `Ingress` annotations.\n\nIf the annotation is not present, use the domains from both the spec and annotations.\n\n## external-dns.alpha.kubernetes.io/ingress\n\nThis annotation allows ExternalDNS to work with Istio & GlooEdge Gateways that don't have a public IP.\n\nIt can be used to address a specific architectural pattern, when a Kubernetes Ingress directs all public traffic to an Istio or GlooEdge Gateway:\n\n- **The Challenge**: By default, ExternalDNS sources the public IP address for a DNS record from a Service of type LoadBalancer.\nHowever, in some setups, the Gateway's Service is of type ClusterIP, with all public traffic routed to it via a separate Kubernetes Ingress object. This setup leaves the Gateway without a public IP that ExternalDNS can discover.\n\n- **The Solution**: The annotation on the Istio/GlooEdge Gateway tells ExternalDNS to ignore the Gateway's Service IP. Instead, it directs ExternalDNS to a specified Ingress resource to find the target LoadBalancer IP address.\n\n### Use Cases for `external-dns.alpha.kubernetes.io/ingress` annotation\n\n#### Getting target from Ingress backed Gloo Gateway\n\n```yml\napiVersion: gateway.solo.io/v1\nkind: Gateway\nmetadata:\n  annotations:\n    external-dns.alpha.kubernetes.io/ingress: gateway-proxy\n  labels:\n    app: gloo\n  name: gateway-proxy\n  namespace: gloo-system\nspec:\n  bindAddress: '::'\n  bindPort: 8080\n  options: {}\n  proxyNames:\n  - gateway-proxy\n  ssl: false\n  useProxyProto: false\n---\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  name: gateway-proxy\n  namespace: gloo-system\nspec:\n  ingressClassName: alb\n  rules:\n  - host: cool-service.example.com\n    http:\n      paths:\n      - backend:\n          service:\n            name: gateway-proxy\n            port:\n              name: http\n        path: /\n        pathType: Prefix\nstatus:\n  loadBalancer:\n    ingress:\n    - hostname: k8s-alb-c4aa37c880-740590208.us-east-1.elb.amazonaws.com\n---\n# This object is generated by GlooEdge Control Plane from Gateway and VirtualService.\n# We have no direct control on this resource\napiVersion: gloo.solo.io/v1\nkind: Proxy\nmetadata:\n  labels:\n    created_by: gloo-gateway\n  name: gateway-proxy\n  namespace: gloo-system\nspec:\n  listeners:\n  - bindAddress: '::'\n    bindPort: 8080\n    httpListener:\n      virtualHosts:\n      - domains:\n        - cool-service.example.com\n        metadataStatic:\n          sources:\n          - observedGeneration: \"6652\"\n            resourceKind: '*v1.VirtualService'\n            resourceRef:\n              name: cool-service\n              namespace: gloo-system\n        name: cool-service\n        routes:\n        - matchers:\n          - prefix: /\n          metadataStatic:\n            sources:\n            - observedGeneration: \"6652\"\n              resourceKind: '*v1.VirtualService'\n              resourceRef:\n                name: cool-service\n                namespace: gloo-system\n            upgrades:\n            - websocket: {}\n    metadataStatic:\n      sources:\n      - observedGeneration: \"6111\"\n        resourceKind: '*v1.Gateway'\n        resourceRef:\n          name: gateway-proxy\n          namespace: gloo-system\n    name: listener-::-8080\n    useProxyProto: false\n```\n\n## external-dns.alpha.kubernetes.io/internal-hostname\n\nSpecifies the domain for the resource's DNS records that are for use from internal networks.\n\nFor `Services` of type `LoadBalancer`, uses the `Service`'s `ClusterIP`.\n\nFor `Pods`, uses the `Pod`'s `Status.PodIP`.\n\n### Use Cases for `external-dns.alpha.kubernetes.io/internal-hostname` annotation\n\n#### Internal DNS Name for a LoadBalancer Service\n\nUse this annotation when you want an internal DNS name that resolves to the Service `ClusterIP`, for\nin-cluster workloads or private network clients.\n\n```yml\napiVersion: v1\nkind: Service\nmetadata:\n  name: my-service\n  annotations:\n    external-dns.alpha.kubernetes.io/internal-hostname: my-service.internal.example.com\nspec:\n  type: LoadBalancer\n  ...\n```\n\n> ExternalDNS will create an internal DNS record for `my-service.internal.example.com` targeting the Service `ClusterIP`.\n\n#### Internal DNS Name for a Pod\n\nUse this annotation on a Pod when you want an internal DNS name that resolves to that Pod's `Status.PodIP`.\n\n```yml\napiVersion: v1\nkind: Pod\nmetadata:\n  name: my-pod\n  annotations:\n    external-dns.alpha.kubernetes.io/internal-hostname: my-pod.internal.example.com\nspec:\n  ...\n```\n\n> ExternalDNS will create an internal DNS record for `my-pod.internal.example.com` targeting the Pod `Status.PodIP`.\n\n## external-dns.alpha.kubernetes.io/target\n\nSpecifies a comma-separated list of values to override the resource's DNS record targets (RDATA).\n\nTargets that parse as IPv4 addresses are published as A records and\ntargets that parse as IPv6 addresses are published as AAAA records. All other targets\nare published as CNAME records.\n\n## external-dns.alpha.kubernetes.io/ttl\n\nSpecifies the TTL (time to live) for the resource's DNS records.\n\nThe value may be specified as either a duration or an integer number of seconds.\nIt must be between `1` and `2,147,483,647` seconds.\n\n> Note; setting the value to `0` means, that TTL is not configured and thus use default.\n\n## external-dns.alpha.kubernetes.io/gateway-hostname-source\n\nSpecifies where to get the domain for a `Route` resource. This annotation should be present on the actual `Route` resource, not the `Gateway` resource itself.\n\nIf the value is `defined-hosts-only`, use only the domains from the `Route` spec.\n\nIf the value is `annotation-only`, use only the domains from the `Route` annotations.\n\nIf the annotation is not present, use the domains from both the spec and annotations.\n\n## Provider-specific annotations\n\nSome providers define their own annotations. Cloud-specific annotations have keys prefixed as follows:\n\n| Cloud      | Annotation prefix                              |\n|------------|------------------------------------------------|\n| AWS        | `external-dns.alpha.kubernetes.io/aws-`        |\n| CloudFlare | `external-dns.alpha.kubernetes.io/cloudflare-` |\n| Scaleway   | `external-dns.alpha.kubernetes.io/scw-`        |\n\nAdditional annotations implemented by specific providers:\n\n### external-dns.alpha.kubernetes.io/alias\n\nIf the value of this annotation is `true`, specifies that CNAME records generated by the\nresource should instead be alias records.\n\nThis annotation is only supported on A, AAAA, and CNAME record types. Endpoints with other\nrecord types (e.g. MX, SRV, TXT) that have this annotation set will be rejected.\n\n**Supported providers:**\n\n- **AWS**: This annotation is only relevant if the `--aws-prefer-cname` flag is specified.\n- **PowerDNS**: When this annotation is set to `true`, CNAME records will be created as ALIAS records.\n  This is useful when using PowerDNS with `expand-alias=yes` to resolve CNAME targets to IP addresses\n  on the authoritative server side. Alternatively, use the `--prefer-alias` flag to convert all\n  CNAME records to ALIAS globally.\n\n### external-dns.alpha.kubernetes.io/set-identifier\n\nSpecifies the set identifier for DNS records generated by the resource.\n\nA set identifier differentiates among multiple DNS record sets that have the same combination of domain and type.\nWhich record set or sets are returned to queries is then determined by the configured routing policy.\n\nRequired for AWS Route53 routing policies (weighted, latency, failover, geolocation, geoproximity, multi-value).\nSee the [AWS tutorial — Routing policies](../tutorials/aws.md#routing-policies) for the full list of annotations\nand examples.\n\nNotes:\n\n- The annotation is provider-agnostic in design but is primarily used with AWS Route53 routing policies.\n- The value is arbitrary but must be **unique per record set** for the same domain and type combination.\n- For Gateway API sources, this annotation must be placed on **Route resources** (e.g., `HTTPRoute`), not on\n  the `Gateway` resource itself. See [Gateway API Annotation Placement](#gateway-api-annotation-placement).\n\n### Gateway API with HTTPRoute\n\nWhen using Gateway API, place `set-identifier` on the Route resource, not the Gateway:\n\n```yaml\napiVersion: gateway.networking.k8s.io/v1\nkind: Gateway\nmetadata:\n  name: my-gateway\n  annotations:\n    # target goes on the Gateway\n    external-dns.alpha.kubernetes.io/target: \"alb-123.us-east-1.elb.amazonaws.com\"\n---\napiVersion: gateway.networking.k8s.io/v1\nkind: HTTPRoute\nmetadata:\n  name: my-route\n  annotations:\n    # set-identifier and routing policy go on the Route\n    external-dns.alpha.kubernetes.io/set-identifier: backend-v1\n    external-dns.alpha.kubernetes.io/aws-weight: \"100\"\nspec:\n  parentRefs:\n    - name: my-gateway\n  hostnames:\n    - app.example.com\n```\n\n> Placing `set-identifier` on the Gateway instead of the Route is a common mistake — the Gateway source only reads the `target` annotation.\n\n## Gateway API Annotation Placement\n\nWhen using Gateway API sources (`gateway-httproute`, `gateway-grpcroute`, `gateway-tlsroute`, etc.), annotations\nare read from different resources: **Gateway resource** reads only `target` annotation, while **Route resources**\n(HTTPRoute, GRPCRoute, TLSRoute, etc.) read all other annotations (`hostname`, `ttl`, `controller`, and\nprovider-specific annotations like `cloudflare-*`, `aws-*`, `scw-*`).\n\nFor more details and comprehensive examples, see the\n[Gateway API documentation](../sources/gateway-api.md#annotations).\n"
  },
  {
    "path": "docs/contributing/bug-report.md",
    "content": "# Bug Report Guide\n\n> **Before filing a bug:** validate the behavior against the [latest release](https://github.com/kubernetes-sigs/external-dns/releases).\n> We do not support past versions.\n>\n> [!WARNING]\n>\n> The outputs requested in this guide may contain sensitive information such as\n> domain names, IP addresses, cloud account IDs, annotation values, or\n> credentials. Redact any sensitive values before posting them publicly in a\n> GitHub issue.\n\nBug reports regularly arrive without the information needed to reproduce or debug\nthem — no process flags, no normalized Kubernetes resources, no logs — forcing\nmaintainers to ask multiple follow-up rounds before any investigation can start.\n\nA bug that cannot be reproduced will not be fixed. This page explains exactly what\ninformation to collect and how to collect it so that maintainers can reason about\nyour environment without making assumptions.\n\n---\n\n## Why we need normalized resources\n\nexternal-dns only reads Kubernetes API objects at runtime. It does not read Helm\nvalues, Terraform state, Flux kustomizations, or AWS Load Balancer Controller\nannotations directly — it sees only what those tools produce in the API server.\n\n**Please provide the live Kubernetes objects**, not the templates that generated them.\n\n---\n\n## Reproduce on a local cluster\n\nIf your environment is not reproducible or involves proprietary infrastructure,\nthe fastest path to a fix is reproducing the issue on a local cluster:\n[minikube](https://minikube.sigs.k8s.io) or [kind](https://kind.sigs.k8s.io).\n\n---\n\n## Step-by-step: collect the required information\n\nWork through each section below and paste the output into your issue.\n\n### 1 — external-dns info\n\n**Version**\n\n```sh\nkubectl get pod -n <namespace> -l app.kubernetes.io/name=external-dns \\\n  -o jsonpath='{.items[0].spec.containers[0].image}'\n```\n\nOr, if you have direct access to the binary:\n\n```sh\nexternal-dns --version\n```\n\n**Startup flags**\n\nHelm values and Terraform variables are *not* useful here because they are\ntransformed before reaching the process. We need the flags that the external-dns\n**process** was actually started with.\n\n```sh\nkubectl get pod -n <namespace> <pod-name> \\\n  -o jsonpath='{range .spec.containers[*]}{.args}{end}'\n```\n\nExample of the kind of output we need:\n\n```text\n--provider=aws\n--registry=txt\n--txt-owner-id=my-cluster\n--source=ingress\n--domain-filter=example.com\n--log-level=debug\n```\n\n**Debug logs**\n\nEnable debug logging before reproducing the issue. If external-dns is already\ndeployed, patch it:\n\n```sh\nkubectl set env deployment/external-dns \\\n  -n <namespace> \\\n  EXTERNAL_DNS_LOG_LEVEL=debug\n```\n\nOr add `--log-level=debug` to the process args and redeploy.\n\nOnce the pod restarts, collect logs **covering the full reconciliation loop**\nthat should have created or updated the record:\n\n```sh\nkubectl logs -n <namespace> \\\n  -l app.kubernetes.io/name=external-dns \\\n  --since=10m \\\n  --prefix=true \\\n  > extdns-debug.log\n```\n\nPaste the full content of `extdns-debug.log` into the issue (or attach the file).\n\nSpecifically we look for lines like:\n\n```text\nlevel=debug msg=\"Desired change: CREATE example.com A [1.2.3.4]\"\nlevel=debug msg=\"No endpoints could be generated from ingress ...\"\nlevel=info  msg=\"All records are already up to date\"\n```\n\n### 2 — Kubernetes resources\n\nCollect the full YAML — including `status` — for every resource relevant to\nyour source type. If reporting a regression, include the output **before and\nafter** the change. The `status.loadBalancer` field is critical for ingress and\nservice sources.\n\n```sh\nkubectl get <resource> -A -o yaml\n```\n\nCommon examples by source:\n\n```sh\nkubectl get ingress,service -A -o yaml          # source=ingress\nkubectl get service -A -o yaml                  # source=service\nkubectl get gateway,httproute -A -o yaml        # source=gateway-httproute\nkubectl get dnsendpoint -A -o yaml              # source=crd\nkubectl get nodes -o yaml                       # source=node\n```\n\n### 3 — DNS provider: existing vs expected records\n\nTell us what records **actually exist** in your DNS provider and what you\n**expected** to exist.\n\nFor Route 53:\n\n```sh\naws route53 list-resource-record-sets \\\n  --hosted-zone-id <ZONE_ID> \\\n  --query 'ResourceRecordSets[?Name==`example.com.`]'\n```\n\nFor other providers, use their CLI or API equivalent, or paste a screenshot from\nthe console.\n\nFormat the answer as:\n\n| Record            | Type  | Value                         | TTL | Expected? |\n|-------------------|-------|-------------------------------|-----|-----------|\n| `foo.example.com` | `A`   | `1.2.3.4`                     | 300 | yes       |\n| `foo.example.com` | `TXT` | `\"heritage=external-dns,...\"` | 300 | yes       |\n\n### 4 — TXT ownership records\n\nexternal-dns uses TXT records to track ownership. If records are not being\ncreated or are being deleted unexpectedly, include the TXT records:\n\n```sh\n# Route 53 example — look for TXT records with \"heritage=external-dns\"\naws route53 list-resource-record-sets \\\n  --hosted-zone-id <ZONE_ID> \\\n  --query 'ResourceRecordSets[?Type==`TXT`]'\n```\n\n---\n\n## Collection scripts\n\n**external-dns info** — version, startup args, and logs:\n\n```sh\n[[% include 'snippets/contributing/collect-extdns-info.sh' %]]\n```\n\n**Kubernetes resources** — set `RESOURCE` to the resource(s) relevant to your\nsource (e.g. `ingress`, `\"ingress,service\"`, `\"gateway,httproute\"`,\n`dnsendpoint`):\n\n```sh\n[[% include 'snippets/contributing/collect-resources.sh' %]]\n```\n\n---\n\n## Checklist before submitting\n\n- [ ] I have searched existing issues and tried to find a fix myself\n- [ ] I am using the [latest release](https://github.com/kubernetes-sigs/external-dns/releases),\n  or have checked the [staging image](../release.md#staging-release-cycle) to confirm the bug is still reproducible\n- [ ] I have provided the actual process flags (not Helm values)\n- [ ] I have provided `kubectl get <resource> -o yaml` output (with `status`)\n- [ ] I have provided external-dns debug logs\n- [ ] I have described what DNS records exist and what I expected\n\n---\n\n## Notes on third-party controllers\n\nIf you are using **AWS Load Balancer Controller**, **Flux**, **Terraform**, or\nsimilar tools alongside external-dns, note that multiple controllers may be\nreading and modifying the same Kubernetes objects at runtime. external-dns\nmaintainers can only reason about what external-dns *sees* in the API server —\nplease provide normalized Kubernetes objects as described above, rather than the\nconfiguration of the surrounding tooling.\n\nContributors and maintainers are very unlikely to be running the same stack.\nBug reporters should assume zero shared context — no cluster access, no cloud\naccount, no Helm values, and no knowledge of any third-party controllers in use. A well-detailed report — see the\n[checklist above](#checklist-before-submitting) — minimizes guesswork and\nsignificantly increases the chance of resolution.\n"
  },
  {
    "path": "docs/contributing/chart.md",
    "content": "# Helm Chart\n\n## Chart Changes\n\nWhen contributing chart changes please follow the same process as when contributing other content but also please **DON'T** modify _Chart.yaml_ in the PR as this would result in a chart release when merged and will mean that your PR will need modifying before it can be accepted.\n\nThe chart version will be updated as part of the PR to release the chart.\n\nPlease **DO** add your changes to the _CHANGELOG.md_ file in the chart directory under the `## [UNRELEASED]` section, if there isn't an uncommented `## [UNRELEASED]` section please copy the commented out template and use that.\n"
  },
  {
    "path": "docs/contributing/design.md",
    "content": "# Design\n\nExternalDNS's sources of DNS records live in package [source](https://github.com/kubernetes-sigs/external-dns/tree/master/source).\nThey implement the `Source` interface that has a single method `Endpoints` which returns the represented source's objects converted to `Endpoints`. Endpoints are just a tuple of DNS name and target where target can be an IP or another hostname.\n\nFor example, the `ServiceSource` returns all Services converted to `Endpoints` where the hostname is the value of the `external-dns.alpha.kubernetes.io/hostname` annotation and the target is the IP of the load balancer or the target is the IP of the service ClusterIP.\n\nThis list of endpoints is passed to the [Plan](https://github.com/kubernetes-sigs/external-dns/tree/master/plan) which determines the difference between the current DNS records and the desired list of `Endpoints`.\n\nOnce the difference has been figured out the list of intended changes is passed to a `Registry` which live in the [registry](https://github.com/kubernetes-sigs/external-dns/tree/master/registry) package.\nThe registry is a wrapper and access point to DNS provider. Registry implements the ownership concept by marking owned records and filtering out records not owned by ExternalDNS before passing them to DNS provider.\n\nThe [provider](https://github.com/kubernetes-sigs/external-dns/tree/master/provider) is the adapter to the DNS provider, e.g. Google Cloud DNS.\nIt implements two methods: `ApplyChanges` to apply a set of changes filtered by `Registry` and `Records` to retrieve the current list of records from the DNS provider.\n\nThe orchestration between the different components is controlled by the [controller](https://github.com/kubernetes-sigs/external-dns/tree/master/controller).\n\nYou can pick which `Source` and `Provider` to use at runtime via the `--source` and `--provider` flags, respectively.\n\n## Adding a DNS Provider\n\nA typical way to start on, e.g. a CoreDNS provider, would be to add a `coredns.go` to the providers package and implement the interface methods. Then you would have to register your provider under a name in `main.go`, e.g. `coredns`, and would be able to trigger its functions via setting `--provider=coredns`.\n\nNote, how your provider doesn't need to know anything about where the DNS records come from, nor does it have to figure out the difference between the current and the desired state, it merely executes the actions calculated by the plan.\n\n## Running GitHub Actions locally\n\nYou can also extend the CI workflow which is currently implemented as GitHub Action within the [workflow](https://github.com/kubernetes-sigs/external-dns/tree/HEAD/.github/workflows) folder.\nIn order to test your changes before committing you can leverage [act](https://github.com/nektos/act) to run the GitHub Action locally.\n\nFollow the installation instructions in the nektos/act [README.md](https://github.com/nektos/act/blob/master/README.md).\nAfterwards just run `act` within the root folder of the project.\n\nFor further usage of `act` refer to its documentation.\n"
  },
  {
    "path": "docs/contributing/dev-guide.md",
    "content": "---\ntags:\n  - contributing\n  - build\n  - testing\n  - integration-tests\n  - helm\n---\n\n# Developer Reference\n\nThe `external-dns` is the work of thousands of contributors, and is maintained by a small team within [kubernetes-sigs](https://github.com/kubernetes-sigs). This document covers basic needs to work with `external-dns` codebase. It contains instructions to build, run, and test `external-dns`.\n\n## Tools\n\nBuilding and/or testing `external-dns` requires additional tooling.\n\n- [Git](https://git-scm.com/downloads)\n- [Go 1.25+](https://golang.org/dl/)\n- [Go modules](https://github.com/golang/go/wiki/Modules)\n- [golangci-lint](https://github.com/golangci/golangci-lint)\n- [ko](https://ko.build/)\n- [kubectl](https://kubernetes.io/docs/tasks/tools/install-kubectl)\n- [helm](https://helm.sh/docs/helm/helm_install/)\n- [spectral](https://github.com/stoplightio/spectral)\n- [python](https://www.python.org/downloads/)\n\n### Go Tools\n\nAdditional Go-based tools are managed in [`go.tool.mod`](../../go.tool.mod) and used for code generation:\n\n| Tool                                                                  | Purpose                                            |\n|-----------------------------------------------------------------------|----------------------------------------------------|\n| [controller-gen](https://github.com/kubernetes-sigs/controller-tools) | Generates CRD manifests and deepcopy methods       |\n| [yq](https://github.com/mikefarah/yq)                                 | YAML processing (splitting, filtering CRD outputs) |\n| [yamlfmt](https://github.com/google/yamlfmt)                          | YAML formatting                                    |\n\nList all installed Go tools:\n\n```sh\nmake go-tools\n```\n\nUpdate Go tools to their latest versions:\n\n```sh\nmake update-tools-deps\n```\n\n> **Note:** Updates are done manually because Dependabot does not yet support `go.tool.mod`\n> ([dependabot-core#12050](https://github.com/dependabot/dependabot-core/issues/12050)).\n\n## First Steps\n\n***Configure Development Environment***\n\nYou must have a working [Go environment](https://go.dev/doc/install), compile the build, and set up testing.\n\n```shell\ngit clone https://github.com/kubernetes-sigs/external-dns.git && cd external-dns\n```\n\n## Building & Testing\n\nThe project uses the make build system. It'll run code generators, tests and static code analysis.\n\nBuild, run tests and lint the code:\n\n```shell\nmake go-lint\nmake test\nmake cover-html\n```\n\nIf added any flags or metrics, re-generate documentation\n\n```shell\nmake generate-flags-documentation\nmake generate-metrics-documentation\n```\n\nWe require all changes to be covered by acceptance tests and/or unit tests, depending on the situation.\nIn the context of the `external-dns`, acceptance tests are tests of interactions with providers, such as creating, reading information about, and destroying DNS resources. In contrast, unit tests test functionality wholly within the codebase itself, such as function tests.\n\n### Log Unit Testing\n\nTesting log messages within codebase provides significant advantages, especially when it comes to debugging, monitoring, and gaining a deeper understanding of system behavior. Log library [build-in testing functionality](https://github.com/sirupsen/logrus?tab=readme-ov-file#testing)\n\nThis practice enables:\n\n- Early detection of logging issues\n- Verification of Important Information\n- Ensuring Correct Severity Levels\n- Improving Observability and Monitoring\n- Driving Better Logging Practices\n\nTo illustrate how to unit test log output within functions, consider the following example:\n\n```go\nimport (\n\t\"testing\"\n\n\t\"sigs.k8s.io/external-dns/internal/testutils\"\n)\n\nfunc TestMe(t *testing.T) {\n  hook := testutils.LogsUnderTestWithLogLevel(log.WarnLevel, t)\n  ... function under tests ...\n  testutils.TestHelperLogContains(\"example warning message\", hook, t)\n  // provide negative assertion\n  testutils.TestHelperLogNotContains(\"this message should not be shown\", hook, t)\n}\n```\n\n## CRD Generation\n\nThe `DNSEndpoint` CRD manifest is generated from Go types using `controller-gen` and must be regenerated whenever the types in `endpoint/` or `apis/` change.\n\n```sh\nmake crd\n```\n\nThis runs [`scripts/generate-crd.sh`](../../scripts/generate-crd.sh) which:\n\n1. Generates `DeepCopy` methods for types in `endpoint/` and `apis/`\n2. Generates the CRD manifest into `config/crd/standard/`\n3. Copies the CRD (with filtered annotations) into `charts/external-dns/crds/`\n\nThe `controller-gen.kubebuilder.io/version` annotation in the generated YAML reflects the version of `controller-gen` from `go.tool.mod` at generation time and is updated automatically.\n\n### Integration Tests\n\nIntegration tests live in `tests/integration/` and verify behavior that spans multiple sources or wrappers together, using a fake Kubernetes client — no real cluster is required.\n\n#### Where integration tests sit\n\n```mermaid\nflowchart TD\n    E2E[\"E2E Tests<br>Real cluster + real DNS provider<br>Slow · requires cloud credentials\"]\n    IT[\"Integration Tests  ←  tests/integration/<br>Fake Kubernetes API · no cluster needed<br>Tests source + wrapper combinations · fast<br>Declarative YAML scenarios\"]\n    UT[\"Unit Tests<br>One source or wrapper in isolation<br>Mocked or minimal Kubernetes client\"]\n\n    E2E --> IT --> UT\n\n    style IT fill:#bbf7d0,stroke:#15803d,stroke-width:2px\n```\n\n#### What runs during a test\n\n```mermaid\nflowchart LR\n    subgraph yaml[\"tests/integration/scenarios/tests.yaml\"]\n        RES[\"resources<br>Service · Ingress · Pod\"]\n        CFG[\"config<br>sources · filters · wrappers\"]\n        EXP[\"expected<br>endpoints\"]\n    end\n\n    subgraph toolkit[\"toolkit  —  fake Kubernetes\"]\n        PARSE[\"ParseResources()\"]\n        FAKE[\"fake.Clientset\"]\n        WRAP[\"CreateWrappedSource()\"]\n    end\n\n    subgraph pipeline[\"ExternalDNS pipeline under test\"]\n        SRC[\"Source(s)<br>service · ingress · ...\"]\n        WRP[\"Wrapper(s)<br>dedup · targetFilter · NAT64\"]\n        OUT[\"Endpoints\"]\n    end\n\n    ASSERT[\"ValidateEndpoints()<br>DNSName · Targets<br>RecordType · TTL\"]\n\n    RES --> PARSE --> FAKE --> WRAP\n    CFG --> WRAP\n    WRAP --> SRC --> WRP --> OUT --> ASSERT\n    EXP --> ASSERT\n```\n\n**When to add an integration test:**\n\n- You are adding or changing a **source** (e.g. `service`, `ingress`) and want to verify it produces the correct endpoints end-to-end.\n- You are changing a **wrapper** (e.g. deduplication, target filtering, default targets, NAT64) and want to verify it behaves correctly when real Kubernetes resources are involved.\n- You are changing a **post-processor** and want to confirm it applies correctly to endpoints produced by one or more sources.\n- You are verifying **multiple sources** together (e.g. `service` and `ingress` both pointing to the same hostname) and their combined output.\n- You are fixing a **cross-cutting bug** that only manifests when sources, wrappers, and post-processors interact.\n- A unit test would require mocking too many internals — an integration test can express the scenario more clearly as a real Kubernetes resource.\n\n**How to add a scenario:**\n\nAdd an entry to `tests/integration/scenarios/tests.yaml`. Each scenario declares Kubernetes resources (Service, Ingress, etc.), the ExternalDNS source configuration, and the expected endpoints:\n\n```yaml\n- name: my-new-scenario\n  description: >\n    Brief explanation of what behavior this scenario validates.\n  config:\n    sources: [\"service\"]\n  resources:\n    - resource:\n        apiVersion: v1\n        kind: Service\n        metadata:\n          name: my-svc\n          namespace: default\n          annotations:\n            external-dns.alpha.kubernetes.io/hostname: my.example.com\n        spec:\n          type: LoadBalancer\n        status:\n          loadBalancer:\n            ingress:\n              - ip: 1.2.3.4\n  expected:\n    - dnsName: my.example.com\n      targets: [\"1.2.3.4\"]\n      recordType: A\n```\n\n**How to run:**\n\n```shell\ngo test ./tests/integration/...\n```\n\n## Complete test on local env\n\nIt's possible to run ExternalDNS locally. CoreDNS can be used for easier testing.\nSee the [related tutorials](../tutorials/coredns-etc.md) for full instructions.\n\n### Continuous Integration\n\nWhen submitting a pull request, you'll notice that we run several automated processes on your proposed change. Some of these processes are tests to ensure your contribution aligns with our standards. While we strive for accuracy, some users may find these tests confusing.\n\n## Execute code without building binary\n\nThe `external-dns` does not require `make build`. You could compile and run Go program with the command\n\n```sh\ngo run main.go \\\n    --provider=aws \\\n    --registry=txt \\\n    --source=fake \\\n    --log-level=info\n```\n\nFor this command to run successfully, it will require [AWS credentials](https://docs.aws.amazon.com/cli/v1/userguide/cli-configure-files.html) and access to local or remote access.\n\nTo run local cluster please refer to [running local cluster](#create-a-local-cluster)\n\n## Deploying a local build\n\nAfter building local images, it is often useful to deploy those images in a local cluster\n\nWe use [Minikube](https://minikube.sigs.k8s.io/docs/start/?arch=%2Fmacos%2Fx86-64%2Fstable%2Fbinary+download) but it could be [Kind](https://kind.sigs.k8s.io/) or any other solution.\n\n- [Create local cluster](#create-a-local-cluster)\n- [Build and load local images](#building-local-images)\n- Deploy with Helm\n- Deploy with kubernetes manifests\n\n## Create a local cluster\n\nFor simplicity, [minikube](https://minikube.sigs.k8s.io) can be used to create a single\nnode cluster.\n\nYou can set a specific Kubernetes version by setting the node's container image.\nSee [basic controls](https://minikube.sigs.k8s.io/docs/handbook/controls/) within the documentation about configuration for more details on this.\n\nOnce you have a configuration in place, create the cluster with\nthat configuration:\n\n```sh\nminikube start \\\n  --profile=external-dns \\\n  --memory=2000 \\\n  --cpus=2 \\\n  --disk-size=5g \\\n  --kubernetes-version=v1.31 \\\n  --driver=docker\n\nminikube profile external-dns\n```\n\nAfter the new Kubernetes cluster is ready, identify the cluster is running as the single node cluster:\n\n```sh\n❯❯ kubectl get nodes\nNAME           STATUS   ROLES           AGE   VERSION\nexternal-dns   Ready    control-plane   16s   v1.31.4\n```\n\n---\n\n## Building local images\n\nWhen building local images with ko you can't specify the registry used to create the image names. It will always be ko.local.\n\n- [minikube handbooks](https://minikube.sigs.k8s.io/docs/handbook/pushing/)\n\n> Note: You could skip this step if you build and push image to your private registry or using an official external-dns image\n\n```sh\n❯❯ export KO_DOCKER_REPO=ko.local\n❯❯ export VERSION=v1\n❯❯ docker context use rancher-desktop ## (optional) this command is only required when using rancher-desktop\n❯❯ ls -al /var/run/docker.sock ## (optional) validate that docker runtime is configured correctly and symlink exists\n\n❯❯ ko build --tags ${VERSION}\n❯❯ docker images\n$$ ko.local/external-dns-9036f6870f30cbdefa42a10f30bada63   local-v1\n```\n\n***Push image to minikube***\n\nRefer to [load image](https://minikube.sigs.k8s.io/docs/handbook/pushing/#7-loading-directly-to-in-cluster-container-runtime)\n\n```sh\n❯❯ minikube image load ko.local/external-dns-9036f6870f30cbdefa42a10f30bada63:local-v1\n❯❯ minikube image ls\n$$ registry.k8s.io/pause:3.10\n$$ ...\n$$ ko.local/external-dns-9036f6870f30cbdefa42a10f30bada63:local-v1\n$$ ...\n❯❯ kubectl run external-dns --image=ko.local/external-dns-9036f6870f30cbdefa42a10f30bada63:local-v1 --image-pull-policy=Never\n```\n\n***Build and push directly in minikube***\n\nAny `docker` command you run in this current terminal will run against the docker inside minikube cluster.\n\nRefer to [push directly](https://minikube.sigs.k8s.io/docs/handbook/pushing/#1-pushing-directly-to-the-in-cluster-docker-daemon-docker-env)\n\n```sh\n❯❯ eval $(minikube -p external-dns docker-env)\n❯❯ echo $MINIKUBE_ACTIVE_DOCKERD\n$$ external-dns\n❯❯ export VERSION=v1\n❯❯ ko build --local --tags ${VERSION}\n❯❯ docker images\n$$ REPOSITORY                                               TAG\n$$ registry.k8s.io/kube-apiserver                           v1.31.4\n$$ ....\n$$ ko.local/external-dns-9036f6870f30cbdefa42a10f30bada63   minikube-v1\n$$ ...\n❯❯ eval $(minikube docker-env -u) ## unset minikube\n```\n\n***Pushing to an in-cluster using Registry addon***\n\nRefer to [pushing images](https://minikube.sigs.k8s.io/docs/handbook/pushing/#4-pushing-to-an-in-cluster-using-registry-addon) for a full configuration\n\n```sh\n❯❯ export KO_DOCKER_REPO=$(minikube ip):5000\n❯❯ export VERSION=registry-v1\n❯❯ minikube addons enable registry\n❯❯ ko build --tags ${VERSION}\n```\n\n## Building image and push to a registry\n\nBuild container image and push to a specific registry\n\n```shell\nmake build.push IMAGE=your-registry/external-dns\n```\n\n---\n\n## Deploy with Helm\n\nBuild local images if required, load them on a local cluster, and deploy helm charts, run:\n\nRender chart templates locally and display the output\n\n```sh\n❯❯ helm lint --debug charts/external-dns\n❯❯ helm template external-dns charts/external-dns --output-dir _scratch\n```\n\nDeploy manifests to a cluster with required values\n\n```sh\n❯❯ kubectl apply -f _scratch --recursive=true\n```\n\nModify chart or values and validate the diff\n\n```sh\n❯❯ helm template external-dns charts/external-dns --output-dir _scratch\n❯❯ kubectl diff -f _scratch/external-dns --recursive=true --show-managed-fields=false\n```\n\n### Helm Values\n\nThis helm chart comes with a JSON schema generated from values with [helm schema](https://github.com/losisin/helm-values-schema-json.git) plugin.\n\n1. Install required plugin(s)\n\n```sh\n❯❯ scripts/helm-tools.sh --install\n```\n\n2. Ensure that the schema is always up-to-date\n\n```sh\n❯❯ scripts/helm-tools.sh --diff\n```\n\n3. When not up-to-date, update JSON schema\n\n```sh\n❯❯ scripts/helm-tools.sh --schema\n```\n\n4. Runs a series of tests to verify that the chart is well-formed, linted and JSON schema is valid\n\n```sh\n❯❯ scripts/helm-tools.sh --lint\n```\n\n5. Auto-generate documentation for helm charts into markdown files.\n\n```sh\n❯❯ scripts/helm-tools.sh --docs\n```\n\n6. Run helm unittests.\n\n```sh\n❯❯ make helm-test\n```\n\n7. Add an entry to the chart [CHANGELOG.md](../../charts/external-dns/CHANGELOG.md) under `## UNRELEASED` section and `open` pull request\n\n## Deploy with kubernetes manifests\n\n> Note; kubernetes manifest are not up to date. Consider to create an `examples` folder\n\n```sh\nkubectl apply -f kustomize --recursive=true --dry-run=client\n```\n\n## Contribute to documentation\n\nAll documentation is in `docs` folder. If new page is added or removed, make sure `mkdocs.yml` is also updated.\n\nInstall required dependencies. In order to not to break system packages, we are going to use virtual environments with [pipenv](https://pipenv.pypa.io/en/latest/installation.html).\n\n```sh\n❯❯ pipenv shell\n❯❯ pip install -r docs/scripts/requirements.txt\n❯❯ mkdocs serve\n$$ ...\n$$ Serving on http://127.0.0.1:8000/\n```\n\n### How to add an example snippet\n\nLet's say we are improving tutorial location in `docs/tutorials/aws.md`.\n\n1. Add a snippet to `docs/snippets/aws/<snippet-name>.<snippet-extension>`\n2. Add snippet to a markdown file `docs/tutorials/aws.md`\n\n[[% raw %]]\n\n````md\n  ```extension\n    [[% include 'snippets/aws/<snippet-name>.<snippet-extension>' %]]\n  ```\n````\n\n[[% endraw %]]\n"
  },
  {
    "path": "docs/contributing/index.md",
    "content": "# Developer Documentations (Advanced Topics)\n\nThis folder contains developer documentation.\n\nWhen you are ready to contribute, you can select issue at [Good First Issues](https://github.com/kubernetes-sigs/external-dns/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22help%20wanted%22).\n\nTo get started see: [dev-guide.md](dev-guide.md).\n\n> Note; when new feature/fix is ready, consider also to provide a way to test this manually with manifests and kubectl commands\n\n## Submit an Issue\n\nIn addition to contributions, we welcome [bug reports](https://github.com/kubernetes-sigs/external-dns/issues/new?template=---bug-report.md) and [feature requests](https://github.com/kubernetes-sigs/external-dns/issues/new?template=--enhancement-request.md).\n\nWhen filing a bug report, follow the **[Bug Report Guide](bug-report.md)** to collect\nthe normalized Kubernetes resources, process flags, and logs that maintainers need\nto reproduce and fix the issue.\n"
  },
  {
    "path": "docs/contributing/source-wrappers.md",
    "content": "# 🧩 Source Wrappers/Middleware\n\n## Overview\n\nIn ExternalDNS, a **Source** is a component responsible for discovering DNS records from Kubernetes resources (e.g., `Ingress`, `Service`, `Gateway`, etc.).\n\n**Source Wrappers** are middleware-like components that sit between the source and the plan generation. They extend or modify the behavior of the original sources by transforming, filtering, or enriching the DNS records before they're processed by the planner and provider.\n\n---\n\n## Why Wrappers?\n\nWrappers solve these key challenges:\n\n- ✂️ **Filtering**: Remove unwanted targets or records from sources based on labels, annotations, targets and etc.\n- 🔗 **Aggregation**: Combine Endpoints from multiple underlying sources. For example, from both Kubernetes Services and Ingresses.\n- 🧹 **Deduplication**: Prevent duplicate DNS records across sources.\n- 🌐 **Target transformation**: Rewrite targets for IPv6 networks or alter endpoint attributes like FQDNS or targets.\n- 🧪 **Testing and simulation**: Use the `FakeSource` or wrappers for dry-runs or simulations.\n- 🔁 **Composability**: Chain multiple behaviors without modifying core sources.\n- 🔐 **Access Control**: Limits endpoint exposure based on policies or user access.\n- 📊 **Observability**: Adds logging, debugging, or metrics around source behavior.\n\n---\n\n## Built In Wrappers\n\n|       Wrapper        | Purpose                                 | Use Case                                            |\n|:--------------------:|:----------------------------------------|:----------------------------------------------------|\n|    `MultiSource`     | Combine multiple sources.               | Aggregate `Ingress`, `Service`, etc.                |\n|    `DedupSource`     | Remove duplicate DNS records.           | Avoid duplicate records from sources.               |\n| `TargetFilterSource` | Include/exclude targets based on CIDRs. | Exclude internal IPs.                               |\n|    `NAT64Source`     | Add NAT64-prefixed AAAA records.        | Support IPv6 with NAT64.                            |\n|   `PostProcessor`    | Add records post-processing.            | Configure TTL, filter provider-specific properties. |\n\n### Use Cases\n\n### 1.1 `TargetFilterSource`\n\nFilters targets (e.g. IPs or hostnames) based on inclusion or exclusion rules.\n\n📌 **Use case**: Only publish public IPs, exclude test environments.\n\n```yaml\n--target-net-filter=192.168.0.0/16\n--exclude-target-nets=10.0.0.0/8\n```\n\n### 2.1 `NAT64Source`\n\nConverts IPv4 targets to IPv6 using NAT64 prefixes.\n\n📌 **Use case**: Publish AAAA records for IPv6-only clients in NAT64 environments.\n\n```yaml\n--nat64-prefix=64:ff9b::/96\n```\n\n### 3.1 `PostProcessor`\n\nApplies post-processing to all endpoints after they are collected from sources.\n\n📌 **Use case**\n\n- Sets a minimum TTL on endpoints that have no TTL or a TTL below the configured minimum.\n- Filters `ProviderSpecific` properties to retain only those belonging to the configured provider (e.g. `aws/evaluate-target-health` when provider is `aws`). Properties with no provider prefix (e.g. `alias`) are considered provider-agnostic and are always retained.\n- Sets the `alias=true` provider-specific property on `CNAME` endpoints when `--prefer-alias` is enabled, signalling providers that support ALIAS records (e.g. PowerDNS, AWS) to use them instead of CNAMEs. Per-resource annotations already present are not overwritten.\n\n```yaml\n--min-ttl=60s\n--provider=aws\n--prefer-alias\n```\n\n---\n\n## How Wrappers Work\n\nWrappers wrap a `Source` and implement the same `Source` interface (e.g., `Endpoints(ctx)`).\n\nThey typically follow this pattern:\n\n```go\npackage wrappers\n\ntype myWrapper struct {\n\tnext source.Source\n}\n\nfunc (m *myWrapper) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) {\n\teps, err := m.next.Endpoints(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Modify, filter, or enrich endpoints as needed\n\treturn eps, nil\n}\n\n// AddEventHandler must be implemented to satisfy the source.Source interface.\nfunc (m *myWrapper) AddEventHandler(ctx context.Context, handler func()) {\n\tlog.Debugf(\"myWrapper: adding event handler\")\n\tm.next.AddEventHandler(ctx, handler)\n}\n```\n\nThis allows wrappers to be stacked or composed together.\n\n---\n\n### Composition of Wrappers\n\nWrappers are often composed like this:\n\n```go\nsource := NewMultiSource(actualSources, defaultTargets)\nsource = NewDedupSource(source)\nsource = NewNAT64Source(source, cfg.NAT64Networks)\nsource = NewTargetFilterSource(source, targetFilter)\n```\n\nEach wrapper processes the output of the previous one.\n\n---\n\n## High Level Design\n\n- Source: Implements the base logic for extracting DNS endpoints (e.g. IngressSource, ServiceSource, etc.)\n- Wrappers: Decorate the source (e.g. DedupSource, TargetFilterSource) to enhance or filter endpoint data\n- Plan: Compares the endpoints from Source with DNS state from Provider and produces create/update/delete changes\n- Provider: Applies changes to actual DNS services (e.g. Route53, Cloudflare, Azure DNS)\n\n```mermaid\nsequenceDiagram\n    participant ExternalDNS\n    participant Source\n    participant Wrapper\n    participant DedupWrapper as DedupSource\n    participant Provider\n    participant Plan\n\n    ExternalDNS->>Source: Initialize source (e.g. Ingress, Service)\n    Source-->>ExternalDNS: Implements Source interface\n\n    ExternalDNS->>Wrapper: Wrap with decorators (e.g. dedup, filters)\n    Wrapper->>DedupWrapper: Compose with DedupSource\n    DedupWrapper-->>Wrapper: Return enriched Source\n\n    Wrapper-->>ExternalDNS: Return final wrapped Source\n\n    ExternalDNS->>Plan: Generate plan from Source\n    Plan->>Wrapper: Call Endpoints(ctx)\n    Wrapper->>DedupWrapper: Call Endpoints(ctx)\n    DedupWrapper->>Source: Call Endpoints(ctx)\n    Source-->>DedupWrapper: Return []*Endpoint\n    DedupWrapper-->>Wrapper: Return de-duplicated []*Endpoint\n    Wrapper-->>Plan: Return transformed []*Endpoint\n\n    ExternalDNS->>Provider: ApplyChanges(plan)\n    Provider-->>ExternalDNS: Sync DNS records\n```\n\n## Learn More\n\n- [Source Interface](https://github.com/kubernetes-sigs/external-dns/blob/master/source/source.go)\n- [Wrappers Source Code](https://github.com/kubernetes-sigs/external-dns/tree/master/source/wrappers)\n"
  },
  {
    "path": "docs/contributing/sources-and-providers.md",
    "content": "---\ntags:\n  - sources\n  - providers\n  - contributing\n---\n\n# Sources and Providers\n\nExternalDNS supports swapping out endpoint **sources** and DNS **providers** and both sides are pluggable. There currently exist multiple sources for different provider implementations.\n\n**Usage**\n\nYou can choose any combination of sources and providers on the command line.\nGiven a cluster on AWS you would most likely want to use the Service and Ingress Source in combination with the AWS provider.\n`Service` + `InMemory` is useful for testing your service collecting functionality, whereas `Fake` + `Google` is useful for testing that the Google provider behaves correctly, etc.\n\n## Sources\n\nSources are an abstraction over any kind of source of desired Endpoints, e.g.:\n\n* a list of Service objects from Kubernetes\n* a random list for testing purposes\n* an aggregated list of multiple nested sources\n\nThe `Source` interface has a single method called `Endpoints` that should return all desired Endpoint objects as a flat list.\n\n```go\ntype Source interface {\n  Endpoints() ([]*endpoint.Endpoint, error)\n}\n```\n\nAll sources live in package `source`.\n\n* `ServiceSource`: collects all Services that have an external IP and returns them as Endpoint objects. The desired DNS name corresponds to an annotation set on the Service or is compiled from the Service attributes via the FQDN Go template string.\n* `IngressSource`: collects all Ingresses that have an external IP and returns them as Endpoint objects. The desired DNS name corresponds to the host rules defined in the Ingress object.\n* `IstioGatewaySource`: collects all Istio Gateways and returns them as Endpoint objects. The desired DNS name corresponds to the hosts listed within the servers spec of each Gateway object.\n* `ContourIngressRouteSource`: collects all Contour IngressRoutes and returns them as Endpoint objects. The desired DNS name corresponds to the `virtualhost.fqdn` listed within the spec of each IngressRoute object.\n* `FakeSource`: returns a random list of Endpoints for the purpose of testing providers without having access to a Kubernetes cluster.\n* `ConnectorSource`: returns a list of Endpoint objects which are served by a tcp server configured through `connector-source-server` flag.\n* `CRDSource`: returns a list of Endpoint objects sourced from the spec of CRD objects. For more details refer to [CRD source](../sources/crd.md) documentation.\n* `EmptySource`: returns an empty list of Endpoint objects for the purpose of testing and cleaning out entries.\n\n### Adding New Sources\n\nWhen creating a new source, add the following annotations above the source struct definition:\n\n```go\n// myNewSource is an implementation of Source for MyResource objects.\n//\n// +externaldns:source:name=my-new-source\n// +externaldns:source:category=Kubernetes Core\n// +externaldns:source:description=Creates DNS entries from MyResource objects\n// +externaldns:source:resources=MyResource<Kind.apigroup.subdomain.domain>\n// +externaldns:source:filters=\n// +externaldns:source:namespace=all,single\n// +externaldns:source:fqdn-template=false\n// +externaldns:source:events=false|true\ntype myNewSource struct {\n    // ... fields\n}\n```\n\n**Annotation Reference:**\n\n* `+externaldns:source:name` - The CLI name used with `--source` flag (required)\n* `+externaldns:source:category` - Category for documentation grouping (required)\n* `+externaldns:source:description` - Short description of what the source does (required)\n* `+externaldns:source:resources` - Kubernetes resources watched (comma-separated). Convention `Kind.apigroup.subdomain.domain`\n* `+externaldns:source:filters` - Supported filter types (annotation, label)\n* `+externaldns:source:namespace` - Namespace support: comma-separated values (all, single, multiple)\n* `+externaldns:source:fqdn-template` - FQDN template support (true, false)\n* `+externaldns:source:events` - Kubernetes [`events`](https://kubernetes.io/docs/reference/kubectl/generated/kubectl_events/) support  (true, false)\n\nAfter adding annotations, run `make generate-sources-documentation` to update sources file.\n\n## Providers\n\nProviders are an abstraction over any kind of sink for desired Endpoints, e.g.:\n\n* storing them in Google Cloud DNS\n* printing them to stdout for testing purposes\n* fanning out to multiple nested providers\n\nThe `Provider` interface has two methods: `Records` and `ApplyChanges`.\n`Records` should return all currently existing DNS records converted to Endpoint objects as a flat list.\nUpon receiving a change set (via an object of `plan.Changes`), `ApplyChanges` should translate these to the provider specific actions in order to persist them in the provider's storage.\n\n```go\ntype Provider interface {\n  Records() ([]*endpoint.Endpoint, error)\n  ApplyChanges(changes *plan.Changes) error\n}\n```\n\nThe interface tries to be generic and assumes a flat list of records for both functions. However, many providers scope records into zones.\nTherefore, the provider implementation has to do some extra work to return that flat list. For instance, the AWS provider fetches the list of all hosted zones before it can return or apply the list of records.\nIf the provider has no concept of zones or if it makes sense to cache the list of hosted zones it is happily allowed to do so.\nFurthermore, the provider should respect the `--domain-filter` flag to limit the affected records by a domain suffix. For instance, the AWS provider filters out all hosted zones that doesn't match that domain filter.\n\nAll providers live in package `provider`.\n\n* `GoogleProvider`: returns and creates DNS records in Google Cloud DNS\n* `AWSProvider`: returns and creates DNS records in AWS Route 53\n* `AzureProvider`: returns and creates DNS records in Azure DNS\n* `InMemoryProvider`: Keeps a list of records in local memory\n\n### Implementing GetDomainFilter\n\n`GetDomainFilter()` is a method on the `Provider` interface. The default implementation in\n`BaseProvider` returns an empty filter with no effect. Providers can override it to\ncontribute an additional domain constraint to the reconcile plan, on top of whatever the\nuser configured via `--domain-filter`.\n\n#### How the controller uses it\n\nEach reconcile cycle, the controller builds a plan combining two filters:\n\n```go\nDomainFilter: endpoint.MatchAllDomainFilters{c.DomainFilter, registryFilter}\n```\n\n* `c.DomainFilter` — from the `--domain-filter` CLI flag (user-supplied)\n* `registryFilter` — the value returned by `provider.GetDomainFilter()`\n\n`MatchAllDomainFilters` is a logical AND: a record must satisfy both to be included in the\nplan. The provider filter acts as an additional, provider-side constraint on top of whatever\nthe user configured.\n\n#### When to leave the default\n\nIf your provider has no concept of zones, domains, or hosted zones — for example, a\nprovider backed by flat storage like etcd — the `BaseProvider` default is fine. Do not\noverride it just to echo `config.DomainFilter` back. For example, if the user runs with\n`--domain-filter=example.com` and the provider returns the same value, the plan sees:\n\n```go\nMatchAllDomainFilters{example.com, example.com}  // same filter twice, no added value\n```\n\nThis is functionally identical to the default and adds no protection.\n\n#### When and how to override — the dynamic pattern\n\nOverride `GetDomainFilter()` when your provider has an authoritative list of zones,\ndomains, or hosted zones it manages — regardless of what the DNS provider calls them —\nand can narrow the scope independently of what the user configured. Two concrete\nbenefits make this worthwhile:\n\n**Protection without user configuration** — when no `--domain-filter` is set,\n`BaseProvider` returns an empty filter and the controller has no domain constraint at all.\nA dynamic override builds the constraint from zones the provider actually manages, so the\ncontroller is scoped correctly even if the operator never sets a flag.\n\n**The filter reflects reality, not intent** — `--domain-filter` expresses what the\noperator wants to manage. `GetDomainFilter()` expresses what the provider actually manages\nat runtime — zones that exist and are accessible with the current credentials. The\nintersection of the two is tighter and safer than either alone.\n\nFor example, if `--domain-filter=example.com` is set but the provider only has access to\n`api.example.com` and `prod.example.com`, a dynamic implementation scopes the plan to\nexactly those two zones rather than anything under `example.com`.\n\nThe correct approach is to query your zone API at runtime and build the filter from the\nzones your provider actually controls. `AWSProvider.GetDomainFilter()` is the canonical\nexample:\n\n```go\nfunc (p *MyProvider) GetDomainFilter() endpoint.DomainFilterInterface {\n    zones, err := p.zones()\n    if err != nil {\n        return &endpoint.DomainFilter{}\n    }\n    // Apply your own configured filter to keep only zones this provider manages.\n    filteredZones := applyDomainFilter(zones)\n\n    names := make([]string, 0, len(zones))\n    for _, z := range filteredZones {\n        names = append(names, z.Name, \".\"+z.Name)\n    }\n    return endpoint.NewDomainFilter(names)\n}\n```\n\nEach zone name is added twice — as a bare domain (`example.com`) and with a leading dot\n(`.example.com`) — so the filter matches both exact records and subdomains.\n\nFor example, suppose the provider manages four zones:\n\n```sh\napi.example.com\nprod.myapp.io\nstaging.myapp.io\nlegacy.internal.net\n```\n\n**Without `--domain-filter`** — the provider filter alone constrains the plan:\n\n```go\nMatchAllDomainFilters{\n    <empty>,                                        // no CLI flag, matches everything\n    [api.example.com, .api.example.com,\n     prod.myapp.io,   .prod.myapp.io,\n     staging.myapp.io, .staging.myapp.io,\n     legacy.internal.net, .legacy.internal.net],   // only provider-managed zones\n}\n```\n\nThe controller will only touch records in those four zones. Any other zone in the cluster\nis left untouched, even if records pointing to it appear in sources.\n\n**With `--domain-filter=myapp.io`** — the two filters intersect:\n\n```go\nMatchAllDomainFilters{\n    myapp.io,                                       // CLI flag\n    [api.example.com, .api.example.com,\n     prod.myapp.io,   .prod.myapp.io,\n     staging.myapp.io, .staging.myapp.io,\n     legacy.internal.net, .legacy.internal.net],\n}\n```\n\nOnly `prod.myapp.io` and `staging.myapp.io` satisfy both filters and are in scope.\n`api.example.com` and `legacy.internal.net` are excluded by the CLI filter.\n\nOn error, return an empty `&endpoint.DomainFilter{}`. This has the same effect as the\n`BaseProvider` default — the CLI filter becomes the sole authority. If the user specifies\na domain the provider does not manage, reconciliation will proceed against it. This is a\ndeliberate tradeoff: a temporary API failure should not block all reconciliation.\n\nFor example, if the provider manages `a.com` and `b.com` but the user sets\n`--domain-filter=c.com`, a dynamic implementation produces an empty intersection —\nthe controller does nothing:\n\n```go\nMatchAllDomainFilters{\n    c.com,               // CLI flag\n    [a.com, .a.com,      // provider zones — no overlap with c.com\n     b.com, .b.com],\n}\n```\n\nWith an empty `GetDomainFilter()` (default or error), only the CLI filter applies and\nthe controller attempts to reconcile `c.com` against a provider that does not manage it.\n\n#### Zone name formatting\n\nCheck the format your provider's API returns for zone names before passing them to\n`endpoint.NewDomainFilter`. Some APIs include a trailing dot (`\"example.com.\"`), which\nmust be stripped first:\n\n```go\n// API returns:  \"foo.example.com.\"\n// Filter needs: \"foo.example.com\"\nname := strings.TrimSuffix(z.Name, \".\")\nnames = append(names, name, \".\"+name)\n```\n\n#### Summary\n\n| Implementation                        | `--domain-filter` unset                    | `--domain-filter` set                        |\n|---------------------------------------|--------------------------------------------|----------------------------------------------|\n| `BaseProvider` default                | No additional constraint                   | User filter applied                          |\n| Static (echoes `config.DomainFilter`) | No additional constraint (same as default) | Same filter applied twice — redundant        |\n| Dynamic (`ListZones` + filter)        | Provider-managed zones constrain the plan  | Intersection of user filter + provider zones |\n\nThe dynamic approach is what gives `GetDomainFilter()` its value: when no `--domain-filter`\nis set, it prevents the controller from touching records in zones the provider does not\nmanage.\n\n#### Testing\n\n`GetDomainFilter()` must have a unit test. See `TestAWSProvider_GetDomainFilter` for a\nreference. At minimum, test that:\n\n* Zone names are correctly mapped to filter entries (including the leading-dot variant)\n* An error from `ListZones` returns an empty `DomainFilter` gracefully\n\n## Provider Blueprints\n\nThe `provider/blueprint` package contains reusable building blocks for provider\nimplementations. Using them keeps providers consistent and avoids reimplementing\nsolved problems.\n\n### ZoneCache\n\n`ZoneCache[T]` is a generic, thread-safe TTL cache for zone, domain, or hosted zone data.\nSee `provider/blueprint/zone_cache.go` for the full API and godoc.\n\n**Reduced API pressure** — listing zones, domains, or hosted zones is called on every\nreconcile cycle, but they are rarely created or deleted. Caching the result for a\nconfigurable TTL means the provider only hits the API when the cache has expired, rather\nthan on every loop.\n\n**Consistent behaviour across providers** — thread safety, TTL logic, and the\ndisable-via-zero behaviour are implemented and tested once in `blueprint`. Providers that\nuse `ZoneCache` behave the same way, reducing drift between implementations over time.\n\nThe typical usage pattern — taken from `AWSProvider.zones()` — is:\n\n```go\n// On the provider struct:\nzonesCache *blueprint.ZoneCache[map[string]*MyZone]\n\n// In the constructor:\nzonesCache: blueprint.NewZoneCache[map[string]*MyZone](config.ZoneCacheDuration),\n\n// In the zone/domain-listing method:\nfunc (p *MyProvider) zones() (map[string]*MyZone, error) {\n    if !p.zonesCache.Expired() {\n        return p.zonesCache.Get(), nil\n    }\n\n    zones, err := p.client.ListZones()\n    if err != nil {\n        return nil, err\n    }\n\n    p.zonesCache.Reset(zones)\n    return zones, nil\n}\n```\n\nFull behaviour is documented in the `ZoneCache` godoc. The key contract to keep in mind\nwhen implementing the pattern: `Get()` returns stale data after expiry rather than a zero\nvalue — callers must check `Expired()` first and decide whether to refresh.\n\n### Configuration flag\n\n`ZoneCache` is controlled by a single shared flag:\n\n| Flag                     | Default | Description                                  |\n|--------------------------|---------|----------------------------------------------|\n| `--zones-cache-duration` | `0s`    | Zone list cache TTL. Set to `0s` to disable. |\n\nAdd a `ZoneCacheDuration time.Duration` field to your provider config struct, wire it to\nthis flag in `pkg/apis/externaldns/types.go`, and pass it to `NewZoneCache` in the\nconstructor.\n"
  },
  {
    "path": "docs/deprecation.md",
    "content": "# External DNS Deprecation Policy\n\nThis document defines the Deprecation Policy for External DNS.\n\nKubernetes is a dynamic system driven by APIs, which evolve with each new release. A crucial aspect of any API-driven system is having a well-defined deprecation policy.\nThis policy informs users about APIs that are slated for removal or modification. Kubernetes follows this principle and periodically refines or upgrades its APIs or capabilities.\nConsequently, older features are marked as deprecated and eventually phased out. To avoid breaking existing users, we should follow a simple deprecation policy for behaviors that a slated to be removed.\n\nThe features and capabilities either to evolve or need to be removed.\n\n## Deprecation Policy\n\nWe follow the [Kubernetes Deprecation Policy](https://kubernetes.io/docs/reference/using-api/deprecation-policy/) and [API Versioning Scheme](https://kubernetes.io/docs/reference/using-api/#api-versioning): alpha, beta, GA.\nIt is therefore important to be aware of deprecation announcements and know when API versions will be removed, to help minimize the effect.\n\n### Scope\n\n* CRDs and API Objects and fields: `.Spec`, `.Status` and `.Status.Conditions[]`\n* Annotations objects or it's values\n* Controller Configuration: CLI flags & environment variables\n* Metrics as defined in the [Kubernetes docs](https://kubernetes.io/docs/reference/using-api/deprecation-policy/#deprecating-a-metric)\n* Revert a specific behavior without an alternative (flag,crd or annotation)\n\n### Non-Scope\n\nEverything not listed in scope is not subject to this deprecation policy and it is subject to breaking changes, updates at any point in time, and deprecation - as long as it follows the Deprecation Process listed below.\n\nThis includes, but isn't limited to:\n\n* Any feature/specific behavior not in Scope.\n* Source code imports\n* Source code refactorings\n* Helm Charts\n* Release process\n* Docker Images (including multi-arch builds)\n* Image Signature (including provenance, providers, keys)\n\n## Including features and behaviors to the Deprecation Policy\n\nAny `maintainer` or `contributor` may propose including a feature, component, or behavior out of scope to be in scope of the deprecation policy.\n\nThe proposal must clearly outline the rationale for inclusion, the impact on users, stability, long term maintenance plan, and day-to-day activities, if such.\n\nThe proposal must be formalized by submitting a `docs/proposal/EDP-XXX.md` document in a Pull Request. Pull request must be labeled with `kind/proposal`.\n\nThe proposal [template location is here](./proposal/design-template.md). The template is quite complete, one can remove any unnecessary or irrelevant section on a specific proposal.\n\n## Deprecation Process\n\n### Nomination of Deprecation\n\nAny maintainer may propose deprecating a feature, component, or behavior (both in and out of scope). In Scope changes must abide to the Deprecation Policy above.\n\nThe proposal must clearly outline the rationale for deprecation, the impact on users, and any alternatives, if such.\n\nThe proposal must be formalized by submiting a `design` document as a Pull Request.\n\n### Showcase to Maintainers\n\nThe proposing maintainer must present the proposed deprecation to the maintainer group. This can be done synchronously during a community meeting or asynchronously, through a GitHub Pull Request.\n\n### Voting\n\nA majority vote of maintainers is required to approve the deprecation.\nVotes may be conducted asynchronously, with a reasonable deadline for responses (e.g., one week). Lazy Consensus applies if the reasonable deadline is extended, with a minimal of at least one other maintainer approving the changes.\n\n### Implementation\n\nUpon approval, the proposing maintainer is responsible for implementing the changes required to mark the feature as deprecated. This includes:\n\n* Updating the codebase with deprecation warnings where applicable.\n  * log.Warn(\"The XXX is on the path of ***DEPRECATION***. We recommend that you use YYY (link to docs)\")\n* Documenting the deprecation in release notes and relevant documentation.\n* Updating APIs, metrics, or behaviors per the Kubernetes Deprecation Policy if in scope.\n* If the feature is entirely deprecated, archival of any associated repositories (external provider as example).\n\n### Deprecation Notice in Release\n\nDeprecation must be introduced in the next release. The release must follow semantic versioning:\n\n* If the project is in the 0.x stage, a `minor` version `bump` is required.\n* For projects 1.x and beyond, a major version bump is required. For the features completely removed.\n  * If it's a flag change/flip, the `minor` version `bump` is acceptable\n\n### Full Deprecation and Removal\n\nThe removal must follow standard Kubernetes deprecation timelines if the feature is in scope.\n"
  },
  {
    "path": "docs/faq.md",
    "content": "# Frequently asked questions\n\n## How is ExternalDNS useful to me?\n\nYou've probably created many deployments. Typically, you expose your deployment to the Internet by creating a Service with `type=LoadBalancer`.\nDepending on your environment, this usually assigns a random publicly available endpoint to your service that you can access from anywhere in the world. On Google Kubernetes Engine, this is a public IP address:\n\n```console\n$ kubectl get svc\nNAME      CLUSTER-IP     EXTERNAL-IP     PORT(S)        AGE\nnginx     10.3.249.226   35.187.104.85   80:32281/TCP   1m\n```\n\nBut dealing with IPs for service discovery isn't nice, so you register this IP with your DNS provider under a better name—most likely, one that corresponds to your service name. If the IP changes, you update the DNS record accordingly.\n\nThose times are over! ExternalDNS takes care of that last step for you by keeping your DNS records synchronized with your external entry points.\n\nExternalDNS' usefulness also becomes clear when you use Ingresses to allow external traffic into your cluster. Via Ingress, you can tell Kubernetes to route traffic to different services based on certain HTTP request attributes, e.g. the Host header:\n\n```console\n$ kubectl get ing\nNAME         HOSTS                                      ADDRESS         PORTS     AGE\nentrypoint   frontend.example.org,backend.example.org   35.186.250.78   80        1m\n```\n\nBut there's nothing that actually makes clients resolve those hostnames to the Ingress' IP address. Again, you normally have to register each entry with your DNS provider. Only if you're lucky can you use a wildcard, like in the example above.\n\nExternalDNS can solve this for you as well.\n\n## Which DNS providers are supported?\n\nPlease check the [provider status table](https://github.com/kubernetes-sigs/external-dns#status-of-in-tree-providers) for the list of supported providers and their status.\n\nAs stated in the README, we are currently looking for stable maintainers for those providers, to ensure that bugfixes and new features will be available for all of those.\n\n## Which Kubernetes objects are supported?\n\nServices exposed via `type=LoadBalancer`, `type=ExternalName`, `type=NodePort`, and for the hostnames defined in Ingress objects as well as [headless hostPort](tutorials/hostport.md) services.\n\n## How do I specify a DNS name for my Kubernetes objects?\n\nThere are three sources of information for ExternalDNS to decide on DNS name. ExternalDNS will pick one in order as listed below:\n\n1. For ingress objects ExternalDNS will create a DNS record based on the hosts specified for the ingress object, as well as the `external-dns.alpha.kubernetes.io/hostname` annotation.\n   - For services ExternalDNS will look for the annotation `external-dns.alpha.kubernetes.io/hostname` on the service and use the loadbalancer IP, it also will look for the annotation `external-dns.alpha.kubernetes.io/internal-hostname` on the service and use the service IP.\n   - For ingresses, you can optionally force ExternalDNS to create records based on _either_ the hosts specified or the `external-dns.alpha.kubernetes.io/hostname` annotation. This behavior is controlled by\n      setting the `external-dns.alpha.kubernetes.io/ingress-hostname-source` annotation on that ingress to either `defined-hosts-only` or `annotation-only`.\n\n2. If compatibility mode is enabled (e.g. `--compatibility={mate,molecule}` flag), External DNS will parse annotations used by Zalando/Mate, wearemolecule/route53-kubernetes. Compatibility mode with Kops DNS Controller is planned to be added in the future.\n\n3. If `--fqdn-template` flag is specified, e.g. `--fqdn-template={{.Name}}.my-org.com`, ExternalDNS will use service/ingress specifications for the provided template to generate DNS name.\n\n## Which Service and Ingress controllers are supported?\n\nRegarding Services, we'll support the OSI Layer 4 load balancers that Kubernetes creates on AWS and Google Kubernetes Engine, and possibly other clusters running on Google Compute Engine.\n\nRegarding Ingress, we'll support:\n\n- Google's Ingress Controller on GKE that integrates with their Layer 7 load balancers (GLBC)\n- nginx-ingress-controller v0.9.x with a fronting Service\n- Zalando's [AWS Ingress controller](https://github.com/zalando-incubator/kube-ingress-aws-controller), based on AWS ALBs and [Skipper](https://github.com/zalando/skipper)\n- [Traefik](https://github.com/containous/traefik)\n  - version 1.7, when [`kubernetes.ingressEndpoint`](https://docs.traefik.io/v1.7/configuration/backends/kubernetes/#ingressendpoint) is configured (`kubernetes.ingressEndpoint.useDefaultPublishedService` in the [Helm chart](https://github.com/helm/charts/tree/HEAD/stable/traefik#configuration))\n  - versions \\>=2.0, when [`providers.kubernetesIngress.ingressEndpoint`](https://doc.traefik.io/traefik/providers/kubernetes-ingress/#ingressendpoint) is configured (`providers.kubernetesIngress.publishedService.enabled` is set to `true` in the [new Helm chart](https://github.com/traefik/traefik-helm-chart))\n\n## Are other Ingress Controllers supported?\n\nFor Ingress objects, ExternalDNS will attempt to discover the target hostname of the relevant Ingress Controller automatically.\nIf you are using an Ingress Controller that is not listed above you may have issues with ExternalDNS not discovering Endpoints and consequently not creating any DNS records.\nAs a workaround, it is possible to force create an Endpoint by manually specifying a target host/IP for the records to be created by setting the annotation `external-dns.alpha.kubernetes.io/target` in the Ingress object.\n\nAnother reason you may want to override the ingress hostname or IP address is if you have an external mechanism for handling failover across ingress endpoints.\nPossible scenarios for this would include using [keepalived-vip](https://github.com/kubernetes/contrib/tree/HEAD/keepalived-vip) to manage failover faster than DNS TTLs might expire.\n\nNote that if you set the target to a hostname, then a CNAME record will be created.\nIn this case, the hostname specified in the Ingress object's annotation must already exist.\n(i.e. you have a Service resource for your Ingress Controller with the `external-dns.alpha.kubernetes.io/hostname` annotation set to the same value)\n\n## What about other projects similar to ExternalDNS?\n\nExternalDNS is a joint effort to unify different projects accomplishing the same goals, namely:\n\n- Kops' [DNS Controller](https://github.com/kubernetes/kops/tree/HEAD/dns-controller)\n- Zalando's [Mate](https://github.com/linki/mate)\n- Molecule Software's [route53-kubernetes](https://github.com/wearemolecule/route53-kubernetes)\n\nWe strive to make the migration from these implementations a smooth experience. This means that, for some time, we'll support their annotation semantics in ExternalDNS and allow both implementations to run side-by-side. This enables you to migrate incrementally and slowly phase out the other implementation.\n\n## How does it work with other implementations and legacy records?\n\nExternalDNS will allow you to opt into any Services and Ingresses that you want it to consider, by an annotation.\nThis way, it can co-exist with other implementations running in the same cluster if they also support this pattern.\nHowever, we'll most likely declare ExternalDNS to be the default implementation.\nThis means that ExternalDNS will consider Services and Ingresses that don't specifically declare which controller they want to be processed by; this is similar to the `ingress.class` annotation on GKE.\n\n## I'm afraid you will mess up my DNS records\n\nSince v0.3, ExternalDNS can be configured to use an ownership registry.\nWhen this option is enabled, ExternalDNS will keep track of which records it has control over, and will never modify any records over which it doesn't have control.\nThis is a fundamental requirement to operate ExternalDNS safely when there might be other actors creating DNS records in the same target space.\n\nFor now ExternalDNS uses TXT records to label owned records, and there might be other alternatives coming in the future releases.\n\n## Does anyone use ExternalDNS in production?\n\nYes, multiple companies are using ExternalDNS in production. Zalando, as an example, has been using it in production since its v0.3 release, mostly using the AWS provider.\n\n## How can we start using ExternalDNS?\n\nCheck out the following descriptive tutorials on how to run ExternalDNS in [GKE](tutorials/gke.md) and [AWS](tutorials/aws.md) or any other supported provider.\n\n## Why is ExternalDNS only adding a single IP address in Route 53 on AWS when using the `nginx-ingress-controller` ?\n\nBy default the `nginx-ingress-controller` assigns a single IP address to an Ingress resource when it's created. ExternalDNS uses what's assigned to the Ingress resource, so it too will use this single IP address when adding the record in Route 53.\n\n### How do I get it to use the FQDN of the ELB assigned to my `nginx-ingress-controller` Service instead?\n\nIn most AWS deployments, you'll instead want the Route 53 entry to be the FQDN of the ELB that is assigned to the `nginx-ingress-controller` Service.\nTo accomplish this, when you create the `nginx-ingress-controller` Deployment, you need to provide the `--publish-service` option to the `/nginx-ingress-controller` executable under `args`.\nOnce this is deployed new Ingress resources will get the ELB's FQDN and ExternalDNS will use the same when creating records in Route 53.\n\nAccording to the `nginx-ingress-controller` [docs](https://kubernetes.github.io/ingress-nginx/) the value you need to provide `--publish-service` is:\n\n> Service fronting the ingress controllers. Takes the form namespace/name. The controller will set the endpoint records on the ingress objects to reflect those on the service.\n\nFor example if your `nginx-ingress-controller` Service's name is `nginx-ingress-controller-svc` and it's in the `default` namespace the start of your resource YAML might look like the following. Note the second to last line.\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: nginx-ingress-controller\nspec:\n  replicas: 1\n  selector:\n    matchLabels:\n      app: nginx-ingress\n  template:\n    metadata:\n      labels:\n        app: nginx-ingress\n    spec:\n      hostNetwork: false\n      containers:\n        - name: nginx-ingress-controller\n          image: \"gcr.io/google_containers/nginx-ingress-controller:0.9.0-beta.11\"\n          imagePullPolicy: \"IfNotPresent\"\n          args:\n            - /nginx-ingress-controller\n            - --default-backend-service={your-backend-service}\n            - --publish-service=default/nginx-ingress-controller-svc\n            - --configmap={your-configmap}\n```\n\n## I have a Service/Ingress but it's ignored by ExternalDNS. Why?\n\nExternalDNS can be configured to only use Services or Ingresses as source. In case Services or Ingresses seem to be ignored in your setup, consider checking how the flag `--source` was configured when deployed. For reference, see the issue https://github.com/kubernetes-sigs/external-dns/issues/267.\n\n## I'm using an ELB with TXT registry but the CNAME record clashes with the TXT record. How to avoid this?\n\nCNAMEs cannot co-exist with other records, therefore you can use the `--txt-prefix` flag which makes sure to create a TXT record with a name following the pattern `prefix.<CNAME record>`. For reference, see the issue https://github.com/kubernetes-sigs/external-dns/issues/262.\n\n## Can I force ExternalDNS to create CNAME records for ELB/ALB?\n\nThe default logic is: when a target looks like an ELB/ALB, ExternalDNS will create ALIAS records for it.\nUnder certain circumstances you want to force ExternalDNS to create CNAME records instead. If you want to do that, start ExternalDNS with the `--aws-prefer-cname` flag.\n\nWhy should I want to force ExternalDNS to create CNAME records for ELB/ALB? Some motivations of users were:\n\n> \"Our hosted zones records are synchronized with our enterprise DNS. The record type ALIAS is an AWS proprietary record type and AWS allows you to set a DNS record directly on AWS resources.\n> Since this is not a DNS RFC standard and therefore can not be transferred and created in our enterprise DNS. So we need to force CNAME creation instead.\"\n\nor\n\n> \"In case of ALIAS if we do nslookup with domain name, it will return only IPs of ELB. So it is always difficult for us to locate ELB in AWS console to which domain is pointing. If we configure it with CNAME it will return exact ELB CNAME, which is more helpful.!\"\n\n## Which permissions do I need when running ExternalDNS on a GCE or GKE node\n\nYou need to add either https://www.googleapis.com/auth/ndev.clouddns.readwrite or https://www.googleapis.com/auth/cloud-platform on your instance group's scope.\n\n## How can I run ExternalDNS under a specific GCP Service Account, e.g. to access DNS records in other projects?\n\nHave a look at https://github.com/linki/mate/blob/v0.6.2/examples/google/README.md#permissions\n\n## How do I configure multiple Sources via environment variables? (also applies to domain filters)\n\nSeparate the individual values via a line break. The equivalent of `--source=service --source=ingress` would be `service\\ningress`. However, it can be tricky do define that depending on your environment. The following examples work (zsh):\n\nVia docker:\n\n```console\n$ docker run \\\n  -e EXTERNAL_DNS_SOURCE=$'service\\ningress' \\\n  -e EXTERNAL_DNS_PROVIDER=google \\\n  -e EXTERNAL_DNS_DOMAIN_FILTER=$'foo.com\\nbar.com' \\\n  registry.k8s.io/external-dns/external-dns:v0.20.0\ntime=\"2017-08-08T14:10:26Z\" level=info msg=\"config: &{APIServerURL: KubeConfig: Sources:[service ingress] Namespace: ...\n```\n\nLocally:\n\n```console\n$ export EXTERNAL_DNS_SOURCE=$'service\\ningress'\n$ external-dns --provider=google\nINFO[0000] config: &{APIServerURL: KubeConfig: Sources:[service ingress] Namespace: ...\n```\n\n```sh\n$ EXTERNAL_DNS_SOURCE=$'service\\ningress' external-dns --provider=google\nINFO[0000] config: &{APIServerURL: KubeConfig: Sources:[service ingress] Namespace: ...\n```\n\nIn a Kubernetes manifest:\n\n```yaml\nspec:\n  containers:\n  - name: external-dns\n    args:\n    - --provider=google\n    env:\n    - name: EXTERNAL_DNS_SOURCE\n      value: \"service\\ningress\"\n```\n\nOr preferably:\n\n```yaml\nspec:\n  containers:\n  - name: external-dns\n    args:\n    - --provider=google\n    env:\n    - name: EXTERNAL_DNS_SOURCE\n      value: |-\n        service\n        ingress\n```\n\n## Running an internal and external dns service\n\nSometimes you need to run an internal and an external dns service.\nThe internal one should provision hostnames used on the internal network (perhaps inside a VPC), and the external one to expose DNS to the internet.\n\nTo do this with ExternalDNS you can use the `--ingress-class` flag to specifically tie an instance of ExternalDNS to an instance of a ingress controller.\nLet's assume you have two ingress controllers, `internal` and `external`.\nYou can then start two ExternalDNS providers, one with `--ingress-class=internal` and one with `--ingress-class=external`.\n\nIf you need to search for multiple ingress classes, you can specify the flag multiple times, like so:\n`--ingress-class=internal --ingress-class=external`.\n\nThe `--ingress-class` flag will check both the `spec.ingressClassName` field and the deprecated `kubernetes.io/ingress.class` annotation.\nThe `spec.ingressClassName` takes precedence over the annotation if both are supplied.\n\n**Backward compatibility**\n\nThe previous `--annotation-filter` flag can still be used to restrict which objects ExternalDNS considers; for example, `--annotation-filter=kubernetes.io/ingress.class in (public,dmz)`.\n\nHowever, beware when using annotation filters with multiple sources, e.g. `--source=service --source=ingress`, since `--annotation-filter` will filter every given source object.\nIf you need to use annotation filters against a specific source you have to run a separated external dns service containing only the wanted `--source` and `--annotation-filter`.\n\nNote: the `--ingress-class` flag cannot be used at the same time as the `--annotation-filter=kubernetes.io/ingress.class in (...)` flag; if you do this an error will be raised.\n\n**Performance considerations**\n\nFiltering based on ingress class name or annotations means that the external-dns controller will receive all resources of that kind and then filter on the client-side.\nIn larger clusters with many resources which change frequently this can cause performance issues.\nIf only some resources need to be managed by an instance of external-dns then label filtering can be used instead of ingress class filtering (or legacy annotation filtering).\nThis means that only those resources which match the selector specified in `--label-filter` will be passed to the controller.\n\n**Split horizon DNS with custom annotation prefixes**\n\nFor more advanced split horizon scenarios, you can use the `--annotation-prefix` flag to configure different instances to read different sets of annotations from the same resources. This is useful when you want a single Service or Ingress to create records in multiple DNS zones (e.g., internal and external).\n\nFor example:\n\n```bash\n# Internal DNS instance\n--annotation-prefix=internal.company.io/ --provider=aws --aws-zone-type=private\n\n# External DNS instance\n--annotation-prefix=external-dns.alpha.kubernetes.io/ --provider=aws --aws-zone-type=public\n```\n\nThen annotate your resources with both prefixes:\n\n```yaml\nmetadata:\n  annotations:\n    internal.company.io/hostname: app.internal.company.com\n    external-dns.alpha.kubernetes.io/hostname: app.company.com\n```\n\nSee the [Split Horizon DNS guide](advanced/split-horizon.md) for detailed examples and configuration.\n\n## How do I specify that I want the DNS record to point to either the Node's public or private IP when it has both?\n\nIf your Nodes have both public and private IP addresses, you might want to write DNS records with one or the other.\nFor example, you may want to write a DNS record in a private zone that resolves to your Nodes' private IPs so that traffic never leaves your private network.\n\nTo accomplish this, set this annotation on your service: `external-dns.alpha.kubernetes.io/access=private`\nConversely, to force the public IP: `external-dns.alpha.kubernetes.io/access=public`\n\nIf this annotation is not set, and the node has both public and private IP addresses, then the public IP will be used by default.\n\nSome loadbalancer implementations assign multiple IP addresses as external addresses. You can filter the generated targets by their networks\nusing `--target-net-filter=10.0.0.0/8` or `--exclude-target-net=10.0.0.0/8`.\n\n## Can external-dns manage(add/remove) records in a hosted zone which is setup in different AWS account?\n\nYes, give it the correct cross-account/assume-role permissions and use the `--aws-assume-role` flag https://github.com/kubernetes-sigs/external-dns/pull/524#issue-181256561\n\n## How do I provide multiple values to the annotation `external-dns.alpha.kubernetes.io/hostname`?\n\nSeparate them by `,`.\n\n## Are there official Docker images provided?\n\nWhen we tag a new release, we push a container image to the Kubernetes projects official container registry with the following name:\n\n```sh\nregistry.k8s.io/external-dns/external-dns\n```\n\nAs tags, you use the external-dns release of choice(i.e. `v0.7.6`). A `latest` tag is not provided in the container registry.\n\nIf you wish to build your own image, you can use the provided [.ko.yaml](https://github.com/kubernetes-sigs/external-dns/blob/master/.ko.yaml) as a starting point.\n\n## Which architectures are supported?\n\nFrom `v0.7.5` on we support `amd64`, `arm32v7` and `arm64v8`. This means that you can run ExternalDNS on a Kubernetes cluster backed by Rasperry Pis or on ARM instances in the cloud as well as more traditional machines backed by `amd64` compatible CPUs.\n\n## Which operating systems are supported?\n\nAt the time of writing we only support GNU/linux and we have no plans of supporting Windows or other operating systems.\n\n## Why am I seeing time out errors even though I have connectivity to my cluster?\n\nIf you're seeing an error such as this:\n\n```sh\nFATA[0060] failed to sync cache: timed out waiting for the condition\n```\n\nYou may not have the correct permissions required to query all the necessary resources in your kubernetes cluster. Specifically, you may be running in a `namespace` that you don't have these permissions in.\nBy default, commands are run against the `default` namespace. Try changing this to your particular namespace to see if that fixes the issue.\n\n## When we plan to release a v1.0, our first `major` release?\n\n> We should really get away from 0.x only if we have APIs that we can declare stable.\n\nThe jump to `1.0` isn’t just symbolic—it’s a promise. If the `External-DNS` maintainers can confidently say that config structures, CRDs, and flags won’t break unexpectedly, that’s the moment to move to `1.0`\n\nBefore moving to `1.0`, review and lock down:\n\n- CRD schemas (especially DNSEndpoint if applicable)\n- Annotations support\n- Command-line flags and configuration behavior\n- Environment variables and metrics\n- Provider interface stability\n- Once these are considered stable and documented, then a `1.0` tag makes sense.\n"
  },
  {
    "path": "docs/flags.md",
    "content": "---\ntags:\n  - flags\n  - autogenerated\n---\n\n# Flags\n\n<!-- THIS FILE MUST NOT BE EDITED BY HAND -->\n<!-- ON NEW FLAG ADDED PLEASE RUN 'make generate-flags-documentation' -->\n<!-- markdownlint-disable MD013 -->\n\n| Flag                                                               | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |\n|:-------------------------------------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|\n| `--[no-]version`                                                   | Show application version.                                                                                                                                                                                                                                                                                                                                                                                                                                                              |\n| `--server=\"\"`                                                      | The Kubernetes API server to connect to (default: auto-detect)                                                                                                                                                                                                                                                                                                                                                                                                                         |\n| `--kubeconfig=\"\"`                                                  | Retrieve target cluster configuration from a Kubernetes configuration file (default: auto-detect)                                                                                                                                                                                                                                                                                                                                                                                      |\n| `--request-timeout=30s`                                            | Request timeout when calling Kubernetes APIs. 0s means no timeout                                                                                                                                                                                                                                                                                                                                                                                                                      |\n| `--[no-]resolve-service-load-balancer-hostname`                    | Resolve the hostname of LoadBalancer-type Service object to IP addresses in order to create DNS A/AAAA records instead of CNAMEs                                                                                                                                                                                                                                                                                                                                                       |\n| `--[no-]listen-endpoint-events`                                    | Trigger a reconcile on changes to EndpointSlices, for Service source (default: false)                                                                                                                                                                                                                                                                                                                                                                                                  |\n| `--gloo-namespace=gloo-system`                                     | The Gloo Proxy namespace; specify multiple times for multiple namespaces. (default: gloo-system)                                                                                                                                                                                                                                                                                                                                                                                       |\n| `--skipper-routegroup-groupversion=\"zalando.org/v1\"`               | The resource version for skipper routegroup                                                                                                                                                                                                                                                                                                                                                                                                                                            |\n| `--[no-]always-publish-not-ready-addresses`                        | Always publish also not ready addresses for headless services (optional)                                                                                                                                                                                                                                                                                                                                                                                                               |\n| `--annotation-filter=\"\"`                                           | Filter resources queried for endpoints by annotation, using label selector semantics                                                                                                                                                                                                                                                                                                                                                                                                   |\n| `--annotation-prefix=\"external-dns.alpha.kubernetes.io/\"`          | Annotation prefix for external-dns annotations (default: external-dns.alpha.kubernetes.io/)                                                                                                                                                                                                                                                                                                                                                                                            |\n| `--compatibility=`                                                 | Process annotation semantics from legacy implementations (optional, options: mate, molecule, kops-dns-controller)                                                                                                                                                                                                                                                                                                                                                                      |\n| `--connector-source-server=\"localhost:8080\"`                       | The server to connect for connector source, valid only when using connector source                                                                                                                                                                                                                                                                                                                                                                                                     |\n| `--crd-source-apiversion=\"externaldns.k8s.io/v1alpha1\"`            | API version of the CRD for crd source, e.g. `externaldns.k8s.io/v1alpha1`, valid only when using crd source                                                                                                                                                                                                                                                                                                                                                                            |\n| `--crd-source-kind=\"DNSEndpoint\"`                                  | Kind of the CRD for the crd source in API group and version specified by crd-source-apiversion                                                                                                                                                                                                                                                                                                                                                                                         |\n| `--default-targets=DEFAULT-TARGETS`                                | Set globally default host/IP that will apply as a target instead of source addresses. Specify multiple times for multiple targets (optional)                                                                                                                                                                                                                                                                                                                                           |\n| `--[no-]force-default-targets`                                     | Force the application of --default-targets, overriding any targets provided by the source (DEPRECATED: This reverts to (improved) legacy behavior which allows empty CRD targets for migration to new state)                                                                                                                                                                                                                                                                           |\n| `--[no-]prefer-alias`                                              | When enabled, CNAME records will have the alias annotation set, signaling providers that support ALIAS records to use them instead of CNAMEs. Supported by: PowerDNS, AWS (with --aws-prefer-cname disabled)                                                                                                                                                                                                                                                                           |\n| `--exclude-record-types=EXCLUDE-RECORD-TYPES`                      | Record types to exclude from management; specify multiple times to exclude many; (optional)                                                                                                                                                                                                                                                                                                                                                                                            |\n| `--exclude-target-net=EXCLUDE-TARGET-NET`                          | Exclude target nets (optional)                                                                                                                                                                                                                                                                                                                                                                                                                                                         |\n| `--[no-]exclude-unschedulable`                                     | Exclude nodes that are considered unschedulable (default: true)                                                                                                                                                                                                                                                                                                                                                                                                                        |\n| `--[no-]expose-internal-ipv6`                                      | When using the node source, expose internal IPv6 addresses (optional, default: false)                                                                                                                                                                                                                                                                                                                                                                                                  |\n| `--gateway-label-filter=\"\"`                                        | Filter Gateways of Route endpoints via label selector (default: all gateways)                                                                                                                                                                                                                                                                                                                                                                                                          |\n| `--gateway-name=\"\"`                                                | Limit Gateways of Route endpoints to a specific name (default: all names)                                                                                                                                                                                                                                                                                                                                                                                                              |\n| `--gateway-namespace=\"\"`                                           | Limit Gateways of Route endpoints to a specific namespace (default: all namespaces)                                                                                                                                                                                                                                                                                                                                                                                                    |\n| `--[no-]ignore-hostname-annotation`                                | Ignore hostname annotation when generating DNS names, valid only when --fqdn-template is set (default: false)                                                                                                                                                                                                                                                                                                                                                                          |\n| `--[no-]ignore-ingress-rules-spec`                                 | Ignore the spec.rules section in Ingress resources (default: false)                                                                                                                                                                                                                                                                                                                                                                                                                    |\n| `--[no-]ignore-ingress-tls-spec`                                   | Ignore the spec.tls section in Ingress resources (default: false)                                                                                                                                                                                                                                                                                                                                                                                                                      |\n| `--[no-]ignore-non-host-network-pods`                              | Ignore pods not running on host network when using pod source (default: false)                                                                                                                                                                                                                                                                                                                                                                                                         |\n| `--ingress-class=INGRESS-CLASS`                                    | Require an Ingress to have this class name; specify multiple times to allow more than one class (optional; defaults to any class)                                                                                                                                                                                                                                                                                                                                                      |\n| `--label-filter=\"\"`                                                | Filter resources queried for endpoints by label selector; currently supported by source types crd, gateway-httproute, gateway-grpcroute, gateway-tlsroute, gateway-tcproute, gateway-udproute, ingress, node, openshift-route, service and ambassador-host                                                                                                                                                                                                                             |\n| `--managed-record-types=A...`                                      | Record types to manage; specify multiple times to include many; (default: A,AAAA,CNAME) (supported records: A, AAAA, CNAME, NS, SRV, TXT)                                                                                                                                                                                                                                                                                                                                              |\n| `--namespace=\"\"`                                                   | Limit resources queried for endpoints to a specific namespace (default: all namespaces)                                                                                                                                                                                                                                                                                                                                                                                                |\n| `--nat64-networks=NAT64-NETWORKS`                                  | Adding an A record for each AAAA record in NAT64-enabled networks; specify multiple times for multiple possible nets (optional)                                                                                                                                                                                                                                                                                                                                                        |\n| `--openshift-router-name=\"\"`                                       | if source is openshift-route then you can pass the ingress controller name. Based on this name external-dns will select the respective router from the route status and map that routerCanonicalHostname to the route host while creating a CNAME record.                                                                                                                                                                                                                              |\n| `--pod-source-domain=\"\"`                                           | Domain to use for pods records (optional)                                                                                                                                                                                                                                                                                                                                                                                                                                              |\n| `--[no-]publish-host-ip`                                           | Allow external-dns to publish host-ip for headless services (optional)                                                                                                                                                                                                                                                                                                                                                                                                                 |\n| `--[no-]publish-internal-services`                                 | Allow external-dns to publish DNS records for ClusterIP services (optional)                                                                                                                                                                                                                                                                                                                                                                                                            |\n| `--service-type-filter=SERVICE-TYPE-FILTER`                        | The service types to filter by. Specify multiple times for multiple filters to be applied. (optional, default: all, expected: ClusterIP, NodePort, LoadBalancer or ExternalName)                                                                                                                                                                                                                                                                                                       |\n| `--target-net-filter=TARGET-NET-FILTER`                            | Limit possible targets by a net filter; specify multiple times for multiple possible nets (optional)                                                                                                                                                                                                                                                                                                                                                                                   |\n| `--[no-]traefik-enable-legacy`                                     | Enable legacy listeners on Resources under the traefik.containo.us API Group                                                                                                                                                                                                                                                                                                                                                                                                           |\n| `--[no-]traefik-disable-new`                                       | Disable listeners on Resources under the traefik.io API Group                                                                                                                                                                                                                                                                                                                                                                                                                          |\n| `--unstructured-resource=UNSTRUCTURED-RESOURCE`                    | When using the unstructured source, specify resources in resource.version.group format (e.g., virtualmachineinstances.v1.kubevirt.io, configmap.v1); specify multiple times for multiple resources                                                                                                                                                                                                                                                                                     |\n| `--events-emit=EVENTS-EMIT`                                        | Events that should be emitted. Specify multiple times for multiple events support (optional, default: none, expected: RecordReady, RecordDeleted, RecordError)                                                                                                                                                                                                                                                                                                                         |\n| `--provider-cache-time=0s`                                         | The time to cache the DNS provider record list requests.                                                                                                                                                                                                                                                                                                                                                                                                                               |\n| `--domain-filter=`                                                 | Limit possible target zones by a domain suffix; specify multiple times for multiple domains (optional)                                                                                                                                                                                                                                                                                                                                                                                 |\n| `--exclude-domains=`                                               | Exclude subdomains (optional)                                                                                                                                                                                                                                                                                                                                                                                                                                                          |\n| `--regex-domain-filter=`                                           | Limit possible domains and target zones by a Regex filter; Overrides domain-filter (optional)                                                                                                                                                                                                                                                                                                                                                                                          |\n| `--regex-domain-exclusion=`                                        | Regex filter that excludes domains and target zones matched by regex-domain-filter (optional)                                                                                                                                                                                                                                                                                                                                                                                          |\n| `--zone-name-filter=`                                              | Filter target zones by zone domain (For now, only AzureDNS provider is using this flag); specify multiple times for multiple zones (optional)                                                                                                                                                                                                                                                                                                                                          |\n| `--zone-id-filter=`                                                | Filter target zones by hosted zone id; specify multiple times for multiple zones (optional)                                                                                                                                                                                                                                                                                                                                                                                            |\n| `--google-project=\"\"`                                              | When using the Google provider, current project is auto-detected, when running on GCP. Specify other project with this. Must be specified when running outside GCP.                                                                                                                                                                                                                                                                                                                    |\n| `--google-batch-change-size=1000`                                  | When using the Google provider, set the maximum number of changes that will be applied in each batch.                                                                                                                                                                                                                                                                                                                                                                                  |\n| `--google-batch-change-interval=1s`                                | When using the Google provider, set the interval between batch changes.                                                                                                                                                                                                                                                                                                                                                                                                                |\n| `--google-zone-visibility=`                                        | When using the Google provider, filter for zones with this visibility (optional, options: public, private)                                                                                                                                                                                                                                                                                                                                                                             |\n| `--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)                                                                                                                                                                                                                                                                                                                                                            |\n| `--alibaba-cloud-zone-type=`                                       | When using the Alibaba Cloud provider, filter for zones of this type (optional, options: public, private)                                                                                                                                                                                                                                                                                                                                                                              |\n| `--aws-zone-type=`                                                 | When using the AWS provider, filter for zones of this type (optional, default: any, options: public, private)                                                                                                                                                                                                                                                                                                                                                                          |\n| `--aws-zone-tags=`                                                 | When using the AWS provider, filter for zones with these tags                                                                                                                                                                                                                                                                                                                                                                                                                          |\n| `--aws-profile=`                                                   | When using the AWS provider, name of the profile to use                                                                                                                                                                                                                                                                                                                                                                                                                                |\n| `--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)                                                                                                                                                                                                                                                                                                         |\n| `--aws-assume-role-external-id=\"\"`                                 | When using the AWS API and assuming a role then specify this external ID` (optional)                                                                                                                                                                                                                                                                                                                                                                                                   |\n| `--aws-batch-change-size=1000`                                     | When using the AWS provider, set the maximum number of changes that will be applied in each batch.                                                                                                                                                                                                                                                                                                                                                                                     |\n| `--aws-batch-change-size-bytes=32000`                              | When using the AWS provider, set the maximum byte size that will be applied in each batch.                                                                                                                                                                                                                                                                                                                                                                                             |\n| `--aws-batch-change-size-values=1000`                              | When using the AWS provider, set the maximum total record values that will be applied in each batch.                                                                                                                                                                                                                                                                                                                                                                                   |\n| `--aws-batch-change-interval=1s`                                   | When using the AWS provider, set the interval between batch changes.                                                                                                                                                                                                                                                                                                                                                                                                                   |\n| `--[no-]aws-evaluate-target-health`                                | When using the AWS provider, set whether to evaluate the health of a DNS target (default: enabled, disable with --no-aws-evaluate-target-health)                                                                                                                                                                                                                                                                                                                                       |\n| `--aws-api-retries=3`                                              | When using the AWS API, set the maximum number of retries before giving up.                                                                                                                                                                                                                                                                                                                                                                                                            |\n| `--[no-]aws-prefer-cname`                                          | When using the AWS provider, prefer using CNAME instead of ALIAS (default: disabled)                                                                                                                                                                                                                                                                                                                                                                                                   |\n| `--aws-zones-cache-duration=0s`                                    | When using the AWS provider, set the zones list cache TTL (0s to disable).                                                                                                                                                                                                                                                                                                                                                                                                             |\n| `--[no-]aws-zone-match-parent`                                     | Expand limit possible target by sub-domains (default: disabled)                                                                                                                                                                                                                                                                                                                                                                                                                        |\n| `--[no-]aws-sd-service-cleanup`                                    | When using the AWS CloudMap provider, delete empty Services without endpoints (default: disabled)                                                                                                                                                                                                                                                                                                                                                                                      |\n| `--aws-sd-create-tag=AWS-SD-CREATE-TAG`                            | When using the AWS CloudMap provider, add tag to created services. The flag can be used multiple times                                                                                                                                                                                                                                                                                                                                                                                 |\n| `--azure-config-file=\"/etc/kubernetes/azure.json\"`                 | When using the Azure provider, specify the Azure configuration file (required when --provider=azure)                                                                                                                                                                                                                                                                                                                                                                                   |\n| `--azure-resource-group=\"\"`                                        | When using the Azure provider, override the Azure resource group to use (optional)                                                                                                                                                                                                                                                                                                                                                                                                     |\n| `--azure-subscription-id=\"\"`                                       | When using the Azure provider, override the Azure subscription to use (optional)                                                                                                                                                                                                                                                                                                                                                                                                       |\n| `--azure-user-assigned-identity-client-id=\"\"`                      | When using the Azure provider, override the client id of user assigned identity in config file (optional)                                                                                                                                                                                                                                                                                                                                                                              |\n| `--azure-zones-cache-duration=0s`                                  | When using the Azure provider, set the zones list cache TTL (0s to disable).                                                                                                                                                                                                                                                                                                                                                                                                           |\n| `--azure-maxretries-count=3`                                       | When using the Azure provider, set the number of retries for API calls (When less than 0, it disables retries). (optional)                                                                                                                                                                                                                                                                                                                                                             |\n| `--batch-change-size=200`                                          | Set the maximum number of DNS record changes that will be submitted to the provider in each batch (optional)                                                                                                                                                                                                                                                                                                                                                                           |\n| `--batch-change-interval=1s`                                       | Set the interval between batch changes (optional, default: 1s)                                                                                                                                                                                                                                                                                                                                                                                                                         |\n| `--[no-]cloudflare-proxied`                                        | When using the Cloudflare provider, specify if the proxy mode must be enabled (default: disabled)                                                                                                                                                                                                                                                                                                                                                                                      |\n| `--[no-]cloudflare-custom-hostnames`                               | When using the Cloudflare provider, specify if the Custom Hostnames feature will be used. Requires \"Cloudflare for SaaS\" enabled. (default: disabled)                                                                                                                                                                                                                                                                                                                                  |\n| `--cloudflare-custom-hostnames-min-tls-version=1.0`                | When using the Cloudflare provider with the Custom Hostnames, specify which Minimum TLS Version will be used by default. (default: 1.0, options: 1.0, 1.1, 1.2, 1.3)                                                                                                                                                                                                                                                                                                                   |\n| `--cloudflare-custom-hostnames-certificate-authority=none`         | When using the Cloudflare provider with the Custom Hostnames, specify which Certificate Authority will be used. A value of none indicates no Certificate Authority will be sent to the Cloudflare API (default: none, options: google, ssl_com, lets_encrypt, none)                                                                                                                                                                                                                    |\n| `--cloudflare-dns-records-per-page=100`                            | When using the Cloudflare provider, specify how many DNS records listed per page, max possible 5,000 (default: 100)                                                                                                                                                                                                                                                                                                                                                                    |\n| `--[no-]cloudflare-regional-services`                              | When using the Cloudflare provider, specify if Regional Services feature will be used (default: disabled)                                                                                                                                                                                                                                                                                                                                                                              |\n| `--cloudflare-region-key=\"\"`                                       | When using the Cloudflare provider, specify the default region for Regional Services. Any value other than an empty string will enable the Regional Services feature (optional)                                                                                                                                                                                                                                                                                                        |\n| `--cloudflare-record-comment=\"\"`                                   | When using the Cloudflare provider, specify the comment for the DNS records (default: '')                                                                                                                                                                                                                                                                                                                                                                                              |\n| `--coredns-prefix=\"/skydns/\"`                                      | When using the CoreDNS provider, specify the prefix name                                                                                                                                                                                                                                                                                                                                                                                                                               |\n| `--[no-]coredns-strictly-owned`                                    | When using the CoreDNS provider, store and filter strictly by txt-owner-id using an extra field inside of the etcd service (default: false)                                                                                                                                                                                                                                                                                                                                            |\n| `--akamai-serviceconsumerdomain=\"\"`                                | When using the Akamai provider, specify the base URL (required when --provider=akamai and edgerc-path not specified)                                                                                                                                                                                                                                                                                                                                                                   |\n| `--akamai-client-token=\"\"`                                         | When using the Akamai provider, specify the client token (required when --provider=akamai and edgerc-path not specified)                                                                                                                                                                                                                                                                                                                                                               |\n| `--akamai-client-secret=\"\"`                                        | When using the Akamai provider, specify the client secret (required when --provider=akamai and edgerc-path not specified)                                                                                                                                                                                                                                                                                                                                                              |\n| `--akamai-access-token=\"\"`                                         | When using the Akamai provider, specify the access token (required when --provider=akamai and edgerc-path not specified)                                                                                                                                                                                                                                                                                                                                                               |\n| `--akamai-edgerc-path=\"\"`                                          | When using the Akamai provider, specify the .edgerc file path. Path must be reachable form invocation environment. (required when --provider=akamai and *-token, secret serviceconsumerdomain not specified)                                                                                                                                                                                                                                                                           |\n| `--akamai-edgerc-section=\"\"`                                       | When using the Akamai provider, specify the .edgerc file path (Optional when edgerc-path is specified)                                                                                                                                                                                                                                                                                                                                                                                 |\n| `--oci-config-file=\"/etc/kubernetes/oci.yaml\"`                     | When using the OCI provider, specify the OCI configuration file (required when --provider=oci                                                                                                                                                                                                                                                                                                                                                                                          |\n| `--oci-compartment-ocid=\"\"`                                        | When using the OCI provider, specify the OCID of the OCI compartment containing all managed zones and records.  Required when using OCI IAM instance principal authentication.                                                                                                                                                                                                                                                                                                         |\n| `--oci-zone-scope=GLOBAL`                                          | When using OCI provider, filter for zones with this scope (optional, options: GLOBAL, PRIVATE). Defaults to GLOBAL, setting to empty value will target both.                                                                                                                                                                                                                                                                                                                           |\n| `--[no-]oci-auth-instance-principal`                               | When using the OCI provider, specify whether OCI IAM instance principal authentication should be used (instead of key-based auth via the OCI config file).                                                                                                                                                                                                                                                                                                                             |\n| `--oci-zones-cache-duration=0s`                                    | When using the OCI provider, set the zones list cache TTL (0s to disable).                                                                                                                                                                                                                                                                                                                                                                                                             |\n| `--inmemory-zone=`                                                 | Provide a list of pre-configured zones for the inmemory provider; specify multiple times for multiple zones (optional)                                                                                                                                                                                                                                                                                                                                                                 |\n| `--ovh-endpoint=\"ovh-eu\"`                                          | When using the OVH provider, specify the endpoint (default: ovh-eu)                                                                                                                                                                                                                                                                                                                                                                                                                    |\n| `--ovh-api-rate-limit=20`                                          | When using the OVH provider, specify the API request rate limit, X operations by seconds (default: 20)                                                                                                                                                                                                                                                                                                                                                                                 |\n| `--[no-]ovh-enable-cname-relative`                                 | When using the OVH provider, specify if CNAME should be treated as relative on target without final dot (default: false)                                                                                                                                                                                                                                                                                                                                                               |\n| `--pdns-server=\"http://localhost:8081\"`                            | When using the PowerDNS/PDNS provider, specify the URL to the pdns server (required when --provider=pdns)                                                                                                                                                                                                                                                                                                                                                                              |\n| `--pdns-server-id=\"localhost\"`                                     | When using the PowerDNS/PDNS provider, specify the id of the server to retrieve. Should be `localhost` except when the server is behind a proxy (optional when --provider=pdns) (default: localhost)                                                                                                                                                                                                                                                                                   |\n| `--pdns-api-key=\"\"`                                                | When using the PowerDNS/PDNS provider, specify the API key to use to authorize requests (required when --provider=pdns)                                                                                                                                                                                                                                                                                                                                                                |\n| `--[no-]pdns-skip-tls-verify`                                      | When using the PowerDNS/PDNS provider, disable verification of any TLS certificates (optional when --provider=pdns) (default: false)                                                                                                                                                                                                                                                                                                                                                   |\n| `--ns1-endpoint=\"\"`                                                | When using the NS1 provider, specify the URL of the API endpoint to target (default: https://api.nsone.net/v1/)                                                                                                                                                                                                                                                                                                                                                                        |\n| `--[no-]ns1-ignoressl`                                             | When using the NS1 provider, specify whether to verify the SSL certificate (default: false)                                                                                                                                                                                                                                                                                                                                                                                            |\n| `--ns1-min-ttl=0`                                                  | Minimal TTL (in seconds) for records. This value will be used if the provided TTL for a service/ingress is lower than this.                                                                                                                                                                                                                                                                                                                                                            |\n| `--godaddy-api-key=\"\"`                                             | When using the GoDaddy provider, specify the API Key (required when --provider=godaddy)                                                                                                                                                                                                                                                                                                                                                                                                |\n| `--godaddy-api-secret=\"\"`                                          | When using the GoDaddy provider, specify the API secret (required when --provider=godaddy)                                                                                                                                                                                                                                                                                                                                                                                             |\n| `--godaddy-api-ttl=0`                                              | TTL (in seconds) for records. This value will be used if the provided TTL for a service/ingress is not provided.                                                                                                                                                                                                                                                                                                                                                                       |\n| `--[no-]godaddy-api-ote`                                           | When using the GoDaddy provider, use OTE api (optional, default: false, when --provider=godaddy)                                                                                                                                                                                                                                                                                                                                                                                       |\n| `--tls-ca=\"\"`                                                      | When using TLS communication, the path to the certificate authority to verify server communications (optionally specify --tls-client-cert for two-way TLS)                                                                                                                                                                                                                                                                                                                             |\n| `--tls-client-cert=\"\"`                                             | When using TLS communication, the path to the certificate to present as a client (not required for TLS)                                                                                                                                                                                                                                                                                                                                                                                |\n| `--tls-client-cert-key=\"\"`                                         | When using TLS communication, the path to the certificate key to use with the client certificate (not required for TLS)                                                                                                                                                                                                                                                                                                                                                                |\n| `--exoscale-apienv=\"api\"`                                          | When using Exoscale provider, specify the API environment (optional)                                                                                                                                                                                                                                                                                                                                                                                                                   |\n| `--exoscale-apizone=\"ch-gva-2\"`                                    | When using Exoscale provider, specify the API Zone (optional)                                                                                                                                                                                                                                                                                                                                                                                                                          |\n| `--exoscale-apikey=\"\"`                                             | Provide your API Key for the Exoscale provider                                                                                                                                                                                                                                                                                                                                                                                                                                         |\n| `--exoscale-apisecret=\"\"`                                          | Provide your API Secret for the Exoscale provider                                                                                                                                                                                                                                                                                                                                                                                                                                      |\n| `--rfc2136-host=`                                                  | When using the RFC2136 provider, specify the host of the DNS server (optionally specify multiple times when using --rfc2136-load-balancing-strategy)                                                                                                                                                                                                                                                                                                                                   |\n| `--rfc2136-port=0`                                                 | When using the RFC2136 provider, specify the port of the DNS server                                                                                                                                                                                                                                                                                                                                                                                                                    |\n| `--rfc2136-zone=RFC2136-ZONE`                                      | When using the RFC2136 provider, specify zone entry of the DNS server to use (can be specified multiple times)                                                                                                                                                                                                                                                                                                                                                                         |\n| `--[no-]rfc2136-create-ptr`                                        | When using the RFC2136 provider, enable PTR management                                                                                                                                                                                                                                                                                                                                                                                                                                 |\n| `--[no-]rfc2136-insecure`                                          | When using the RFC2136 provider, specify whether to attach TSIG or not (default: false, requires --rfc2136-tsig-keyname and rfc2136-tsig-secret)                                                                                                                                                                                                                                                                                                                                       |\n| `--rfc2136-tsig-keyname=\"\"`                                        | When using the RFC2136 provider, specify the TSIG key to attached to DNS messages (required when --rfc2136-insecure=false)                                                                                                                                                                                                                                                                                                                                                             |\n| `--rfc2136-tsig-secret=\"\"`                                         | When using the RFC2136 provider, specify the TSIG (base64) value to attached to DNS messages (required when --rfc2136-insecure=false)                                                                                                                                                                                                                                                                                                                                                  |\n| `--rfc2136-tsig-secret-alg=\"\"`                                     | When using the RFC2136 provider, specify the TSIG (base64) value to attached to DNS messages (required when --rfc2136-insecure=false)                                                                                                                                                                                                                                                                                                                                                  |\n| `--[no-]rfc2136-tsig-axfr`                                         | When using the RFC2136 provider, specify the TSIG (base64) value to attached to DNS messages (required when --rfc2136-insecure=false)                                                                                                                                                                                                                                                                                                                                                  |\n| `--rfc2136-min-ttl=0s`                                             | When using the RFC2136 provider, specify minimal TTL (in duration format) for records. This value will be used if the provided TTL for a service/ingress is lower than this                                                                                                                                                                                                                                                                                                            |\n| `--[no-]rfc2136-gss-tsig`                                          | When using the RFC2136 provider, specify whether to use secure updates with GSS-TSIG using Kerberos (default: false, requires --rfc2136-kerberos-realm, --rfc2136-kerberos-username, and rfc2136-kerberos-password)                                                                                                                                                                                                                                                                    |\n| `--rfc2136-kerberos-username=\"\"`                                   | When using the RFC2136 provider with GSS-TSIG, specify the username of the user with permissions to update DNS records (required when --rfc2136-gss-tsig=true)                                                                                                                                                                                                                                                                                                                         |\n| `--rfc2136-kerberos-password=\"\"`                                   | When using the RFC2136 provider with GSS-TSIG, specify the password of the user with permissions to update DNS records (required when --rfc2136-gss-tsig=true)                                                                                                                                                                                                                                                                                                                         |\n| `--rfc2136-kerberos-realm=\"\"`                                      | When using the RFC2136 provider with GSS-TSIG, specify the realm of the user with permissions to update DNS records (required when --rfc2136-gss-tsig=true)                                                                                                                                                                                                                                                                                                                            |\n| `--rfc2136-batch-change-size=50`                                   | When using the RFC2136 provider, set the maximum number of changes that will be applied in each batch.                                                                                                                                                                                                                                                                                                                                                                                 |\n| `--[no-]rfc2136-use-tls`                                           | When using the RFC2136 provider, communicate with name server over tls                                                                                                                                                                                                                                                                                                                                                                                                                 |\n| `--[no-]rfc2136-skip-tls-verify`                                   | When using TLS with the RFC2136 provider, disable verification of any TLS certificates                                                                                                                                                                                                                                                                                                                                                                                                 |\n| `--rfc2136-load-balancing-strategy=disabled`                       | When using the RFC2136 provider, specify the load balancing strategy (default: disabled, options: random, round-robin, disabled)                                                                                                                                                                                                                                                                                                                                                       |\n| `--transip-account=\"\"`                                             | When using the TransIP provider, specify the account name (required when --provider=transip)                                                                                                                                                                                                                                                                                                                                                                                           |\n| `--transip-keyfile=\"\"`                                             | When using the TransIP provider, specify the path to the private key file (required when --provider=transip)                                                                                                                                                                                                                                                                                                                                                                           |\n| `--pihole-server=\"\"`                                               | When using the Pihole provider, the base URL of the Pihole web server (required when --provider=pihole)                                                                                                                                                                                                                                                                                                                                                                                |\n| `--pihole-password=\"\"`                                             | When using the Pihole provider, the password to the server if it is protected                                                                                                                                                                                                                                                                                                                                                                                                          |\n| `--[no-]pihole-tls-skip-verify`                                    | When using the Pihole provider, disable verification of any TLS certificates                                                                                                                                                                                                                                                                                                                                                                                                           |\n| `--pihole-api-version=\"5\"`                                         | When using the Pihole provider, specify the pihole API version (default: 5, options: 5, 6)                                                                                                                                                                                                                                                                                                                                                                                             |\n| `--plural-cluster=\"\"`                                              | When using the plural provider, specify the cluster name you're running with                                                                                                                                                                                                                                                                                                                                                                                                           |\n| `--plural-provider=\"\"`                                             | When using the plural provider, specify the provider name you're running with                                                                                                                                                                                                                                                                                                                                                                                                          |\n| `--policy=sync`                                                    | Modify how DNS records are synchronized between sources and providers (default: sync, options: sync, upsert-only, create-only)                                                                                                                                                                                                                                                                                                                                                         |\n| `--registry=txt`                                                   | The registry implementation to use to keep track of DNS record ownership (default: txt, options: txt, noop, dynamodb, aws-sd)                                                                                                                                                                                                                                                                                                                                                          |\n| `--txt-owner-id=\"default\"`                                         | When using the TXT or DynamoDB registry, a name that identifies this instance of ExternalDNS (default: default)                                                                                                                                                                                                                                                                                                                                                                        |\n| `--txt-prefix=\"\"`                                                  | When using the TXT registry, a custom string that's prefixed to each ownership DNS record (optional). Could contain record type template like '%{record_type}-prefix-'. Mutual exclusive with txt-suffix!                                                                                                                                                                                                                                                                              |\n| `--txt-suffix=\"\"`                                                  | When using the TXT registry, a custom string that's suffixed to the host portion of each ownership DNS record (optional). Could contain record type template like '-%{record_type}-suffix'. Mutual exclusive with txt-prefix!                                                                                                                                                                                                                                                          |\n| `--txt-wildcard-replacement=\"\"`                                    | When using the TXT registry, a custom string that's used instead of an asterisk for TXT records corresponding to wildcard DNS records (optional)                                                                                                                                                                                                                                                                                                                                       |\n| `--[no-]txt-encrypt-enabled`                                       | When using the TXT registry, set if TXT records should be encrypted before stored (default: disabled)                                                                                                                                                                                                                                                                                                                                                                                  |\n| `--txt-encrypt-aes-key=\"\"`                                         | When using the TXT registry, set TXT record decryption and encryption 32 byte aes key (required when --txt-encrypt=true)                                                                                                                                                                                                                                                                                                                                                               |\n| `--migrate-from-txt-owner=\"\"`                                      | Old txt-owner-id that needs to be overwritten (default: default)                                                                                                                                                                                                                                                                                                                                                                                                                       |\n| `--dynamodb-region=\"\"`                                             | When using the DynamoDB registry, the AWS region of the DynamoDB table (optional)                                                                                                                                                                                                                                                                                                                                                                                                      |\n| `--dynamodb-table=\"external-dns\"`                                  | When using the DynamoDB registry, the name of the DynamoDB table (default: \"external-dns\")                                                                                                                                                                                                                                                                                                                                                                                             |\n| `--txt-cache-interval=0s`                                          | The interval between cache synchronizations in duration format (default: disabled)                                                                                                                                                                                                                                                                                                                                                                                                     |\n| `--interval=1m0s`                                                  | The interval between two consecutive synchronizations in duration format (default: 1m)                                                                                                                                                                                                                                                                                                                                                                                                 |\n| `--min-event-sync-interval=5s`                                     | The minimum interval between two consecutive synchronizations triggered from kubernetes events in duration format (default: 5s)                                                                                                                                                                                                                                                                                                                                                        |\n| `--[no-]once`                                                      | When enabled, exits the synchronization loop after the first iteration (default: disabled)                                                                                                                                                                                                                                                                                                                                                                                             |\n| `--[no-]dry-run`                                                   | When enabled, prints DNS record changes rather than actually performing them (default: disabled)                                                                                                                                                                                                                                                                                                                                                                                       |\n| `--[no-]events`                                                    | When enabled, in addition to running every interval, the reconciliation loop will get triggered when supported sources change (default: disabled)                                                                                                                                                                                                                                                                                                                                      |\n| `--min-ttl=0s`                                                     | Configure global TTL for records in duration format. This value is used when the TTL for a source is not set or set to 0. (optional; examples: 1m12s, 72s, 72)                                                                                                                                                                                                                                                                                                                         |\n| `--log-format=text`                                                | The format in which log messages are printed (default: text, options: text, json)                                                                                                                                                                                                                                                                                                                                                                                                      |\n| `--metrics-address=\":7979\"`                                        | Specify where to serve the metrics and health check endpoint (default: :7979)                                                                                                                                                                                                                                                                                                                                                                                                          |\n| `--log-level=info`                                                 | Set the level of logging. (default: info, options: panic, debug, info, warning, error, fatal)                                                                                                                                                                                                                                                                                                                                                                                          |\n| `--webhook-provider-url=\"http://localhost:8888\"`                   | The URL of the remote endpoint to call for the webhook provider (default: http://localhost:8888)                                                                                                                                                                                                                                                                                                                                                                                       |\n| `--webhook-provider-read-timeout=5s`                               | The read timeout for the webhook provider in duration format (default: 5s)                                                                                                                                                                                                                                                                                                                                                                                                             |\n| `--webhook-provider-write-timeout=10s`                             | The write timeout for the webhook provider in duration format (default: 10s)                                                                                                                                                                                                                                                                                                                                                                                                           |\n| `--[no-]webhook-server`                                            | When enabled, runs as a webhook server instead of a controller. (default: false).                                                                                                                                                                                                                                                                                                                                                                                                      |\n| `--[no-]combine-fqdn-annotation`                                   | Combine FQDN template and Annotations instead of overwriting (default: false)                                                                                                                                                                                                                                                                                                                                                                                                          |\n| `--fqdn-template=\"\"`                                               | A templated string that's used to generate DNS names from sources that don't define a hostname themselves, or to add a hostname suffix when paired with the fake source (optional). Accepts comma separated list for multiple global FQDN.                                                                                                                                                                                                                                             |\n| `--target-template=\"\"`                                             | A templated string used to generate DNS targets (IP or hostname) from sources that support it (optional). Accepts comma separated list for multiple targets.                                                                                                                                                                                                                                                                                                                           |\n| `--fqdn-target-template=\"\"`                                        | A template that returns host:target pairs (e.g., '{{range .Object.endpoints}}{{.targetRef.name}}.svc.example.com:{{index .addresses 0}},{{end}}'). Accepts comma separated list for multiple pairs.                                                                                                                                                                                                                                                                                    |\n| `--provider=provider`                                              | The DNS provider where the DNS records will be created (required, options: akamai, alibabacloud, aws, aws-sd, azure, azure-dns, azure-private-dns, civo, cloudflare, coredns, dnsimple, exoscale, gandi, godaddy, google, inmemory, linode, ns1, oci, ovh, pdns, pihole, plural, rfc2136, scaleway, skydns, transip, webhook)                                                                                                                                                          |\n| `--source=source`                                                  | The resource types that are queried for endpoints; specify multiple times for multiple sources (required, options: service, ingress, node, pod, gateway-httproute, gateway-grpcroute, gateway-tlsroute, gateway-tcproute, gateway-udproute, istio-gateway, istio-virtualservice, contour-httpproxy, gloo-proxy, fake, connector, crd, empty, skipper-routegroup, openshift-route, ambassador-host, kong-tcpingress, f5-virtualserver, f5-transportserver, traefik-proxy, unstructured) |\n"
  },
  {
    "path": "docs/initial-design.md",
    "content": "# Proposal: Design of External DNS\n\n## Background\n\n[Project proposal](https://groups.google.com/forum/#!searching/kubernetes-dev/external$20dns%7Csort:relevance/kubernetes-dev/2wGQUB0fUuE/9OXz01i2BgAJ)\n\n[Initial discussion](https://docs.google.com/document/d/1ML_q3OppUtQKXan6Q42xIq2jelSoIivuXI8zExbc6ec/edit#heading=h.1pgkuagjhm4p)\n\nThis document describes the initial design proposal.\n\nExternal DNS is purposed to fill the existing gap of creating DNS records for Kubernetes resources. While there exist alternative solutions, this project is meant to be a standard way of managing DNS records for Kubernetes.\nThe current project is a fusion of the following projects and driven by its maintainers:\n\n1. [Kops DNS Controller](https://github.com/kubernetes/kops/tree/HEAD/dns-controller)\n2. [Mate](https://github.com/linki/mate)\n3. [wearemolecule/route53-kubernetes](https://github.com/wearemolecule/route53-kubernetes)\n\n## Example use case\n\nUser runs `kubectl create -f ingress.yaml`, this will create an ingress as normal.\nTypically the user would then have to manually create a DNS record pointing the ingress endpoint\nIf the external-dns controller is running on the cluster, it could automatically configure the DNS records instead, by observing the host attribute in the ingress object.\n\n## Goals\n\n1. Support AWS Route53 and Google Cloud DNS providers\n2. DNS for Kubernetes services(type=Loadbalancer) and Ingress\n3. Create/update/remove records as according to Kubernetes resources state\n4. It should address main requirements and support main features of the projects mentioned above\n\n## Design\n\n### Extensibility\n\nNew cloud providers should be easily pluggable. Initially only AWS/Google platforms are supported. However, in the future we are planning to incorporate CoreDNS and Azure DNS as possible DNS providers\n\n### Configuration\n\nDNS records will be automatically created in multiple situations:\n\n1. Setting `spec.rules.host` on an ingress object.\n2. Setting `spec.tls.hosts` on an ingress object.\n3. Adding the annotation `external-dns.alpha.kubernetes.io/hostname` on an ingress object.\n4. Adding the annotation `external-dns.alpha.kubernetes.io/hostname` on a `type=LoadBalancer` service object.\n\n### Annotations\n\nRecord configuration should occur via resource annotations. Supported annotations:\n\n| Annotations |                                                                                                                                                                        |\n|-------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------|\n| Tag         | external-dns.alpha.kubernetes.io/controller                                                                                                                            |\n| Description | Tells a DNS controller to process this service. This is useful when running different DNS controllers at the same time (or different versions of the same controller). |\n| Details     | The v1 implementation of dns-controller would look for service annotations `dns-controller` and `dns-controller/v1` but not for `mate/v1` or `dns-controller/v2`       |\n| Default     | dns-controller                                                                                                                                                         |\n| Example     | dns-controller/v1                                                                                                                                                      |\n| Required    | false                                                                                                                                                                  |\n| ---         | ---                                                                                                                                                                    |\n| Tag         | external-dns.alpha.kubernetes.io/hostname                                                                                                                              |\n| Description | Fully qualified name of the desired record                                                                                                                             |\n| Default     | none                                                                                                                                                                   |\n| Example     | foo.example.org                                                                                                                                                        |\n| Required    | Only for services. Ingress hostname is retrieved from `spec.rules.host` meta data on ingress                                                                           |\n\n### Compatibility\n\nExternal DNS should be compatible with annotations used by three above mentioned projects. The idea is that resources created and tagged with annotations for other projects should continue to be valid and now managed by External DNS.\n\n**Mate**\n\nMate does not require services/ingress to be tagged. Therefore, it is not safe to run both Mate and External-DNS simultaneously. The idea is that initial release (?) of External DNS will support Mate annotations, which indicates the hostname to be created. Therefore the switch should be simple.\n\n| Annotations |                                              |\n|-------------|----------------------------------------------|\n| Tag         | zalando.org/dnsname                          |\n| Description | Hostname to be registered                    |\n| Default     | Empty(falls back to template based approach) |\n| Example     | foo.example.org                              |\n| Required    | false                                        |\n\n**route53-kubernetes**\n\nIt should be safe to run both `route53-kubernetes` and `external-dns` simultaneously.\nSince `route53-kubernetes` only looks at services with the label `dns=route53` and does not support ingress there should be no collisions between annotations.\nIf users desire to switch to `external-dns` they can run both controllers and migrate services over as they are able.\n\n### Ownership\n\nExternal DNS should be *responsible* for the created records. Which means that the records should be tagged and only tagged records are viable for future deletion/update. It should not mess with pre-existing records created via other means.\n\n#### Ownership via TXT records\n\nEach record managed by External DNS is accompanied with a TXT record with a specific value to indicate that corresponding DNS record is managed by External DNS and it can be updated/deleted respectively.\nTXT records are limited to lifetimes of service/ingress objects and are created/deleted once k8s resources are created/deleted.\n"
  },
  {
    "path": "docs/monitoring/index.md",
    "content": "# Monitoring & Observability\n\nMonitoring is a crucial aspect of maintaining the health and performance of your applications.\nIt involves collecting, analyzing, and using information to ensure that your system is running smoothly and efficiently. Effective monitoring helps in identifying issues early, understanding system behavior, and making informed decisions to improve performance and reliability.\n\nFor `external-dns`, all metrics available for scraping are exposed on the `/metrics` endpoint. The metrics are in the Prometheus exposition format, which is widely used for monitoring and alerting.\n\nTo access the metrics:\n\n```sh\ncurl https://localhost:7979/metrics\n```\n\nIn the metrics output, you'll see the help text, type information, and current value of the `external_dns_registry_endpoints_total` counter:\n\n```yml\n# HELP external_dns_registry_endpoints_total Number of Endpoints in the registry\n# TYPE external_dns_registry_endpoints_total gauge\nexternal_dns_registry_endpoints_total 11\n```\n\nYou can configure a locally running [Prometheus instance](https://prometheus.io/docs/prometheus/latest/configuration/configuration/#scrape_config) to scrape metrics from the application. Here's an example prometheus.yml configuration:\n\n```yml\nscrape_configs:\n- job_name: external-dns\n  scrape_interval: 10s\n  static_configs:\n  - targets:\n    - localhost:7979\n```\n\nFor more detailed information on how to instrument application with Prometheus, you can refer to the [Prometheus Go client library documentation](https://prometheus.io/docs/guides/go-application/).\n\n## What metrics can I get from ExternalDNS and what do they mean?\n\n- The project maintain a [metrics page](./metrics.md) with a list of supported custom metrics.\n- [Go runtime](https://pkg.go.dev/runtime/metrics#hdr-Supported_metrics) metrics also available for scraping.\n\nExternalDNS exposes 3 types of metrics: Sources, Registry errors and Cache hits.\n\n`Source`s are mostly Kubernetes API objects. Examples of `source` errors may be connection errors to the Kubernetes API server itself or missing RBAC permissions.\nIt can also stem from incompatible configuration in the objects itself like invalid characters, processing a broken fqdnTemplate, etc.\n\n`Registry` errors are mostly Provider errors, unless there's some coding flaw in the registry package. Provider errors often arise due to accessing their APIs due to network or missing cloud-provider permissions when reading records.\nWhen applying a changeset, errors will arise if the changeset applied is incompatible with the current state.\n\nIn case of an increased error count, you could correlate them with the `http_request_duration_seconds{handler=\"instrumented_http\"}` metric which should show increased numbers for status codes 4xx (permissions, configuration, invalid changeset) or 5xx (apiserver down).\n\nYou can use the host label in the metric to figure out if the request was against the Kubernetes API server (Source errors) or the DNS provider API (Registry/Provider errors).\n\n## Owner Mismatch Metrics\n\nThe `external_dns_registry_skipped_records_owner_mismatch_per_sync` metric tracks DNS records that were skipped during synchronization because they are owned by a different ExternalDNS instance. This is useful for detecting ownership conflicts in multi-tenant or multi-instance deployments.\n\nThe metric includes the following labels:\n\n| Label           | Description                                      |\n|:----------------|:-------------------------------------------------|\n| `record_type`   | DNS record type (A, AAAA, CNAME, etc.)           |\n| `owner`         | The owner ID of the current ExternalDNS instance |\n| `foreign_owner` | The owner ID found on the existing record        |\n| `domain`        | The naked/apex domain (e.g., \"example.com\")      |\n\n**Note:** The `domain` label uses the naked/apex domain rather than the full FQDN to prevent metric cardinality explosion. With thousands of subdomains under one apex domain, using full FQDNs would create excessive metric series.\n\n## Metrics Best Practices\n\nWhen scraping ExternalDNS metrics, consider the following best practices:\n\n### Cardinality Management\n\n- **Vector metrics** (those with labels like `record_type`, `domain`) can generate multiple time series. Monitor your Prometheus storage and memory usage accordingly.\n- The `domain` label on owner mismatch metrics is intentionally limited to apex domains to bound cardinality.\n- Use recording rules to pre-aggregate high-cardinality metrics if you only need totals.\n\n### Recommended Scrape Interval\n\n- A scrape interval of 10-30 seconds is typically sufficient for ExternalDNS metrics.\n- Align your scrape interval with ExternalDNS's sync interval (`--interval` flag) for meaningful data.\n\n### Alerting Recommendations\n\nConsider alerting on:\n\n- `external_dns_source_errors_total` or `external_dns_registry_errors_total` increasing - indicates connectivity or permission issues.\n- `external_dns_controller_last_sync_timestamp_seconds` not updating - indicates the sync loop may be stuck.\n- `external_dns_registry_skipped_records_owner_mismatch_per_sync` non-zero - indicates ownership conflicts that may need investigation.\n\n## Resources\n\n- [Prometheus Instrumentation](https://prometheus.io/docs/practices/instrumentation/)\n- [Prometheus Alerting Best Practices](https://prometheus.io/docs/practices/alerting/)\n- [Prometheus Recording Rules](https://prometheus.io/docs/practices/rules/)\n- [Grafana: How to Manage High Cardinality Metrics](https://grafana.com/blog/2022/02/15/what-are-cardinality-spikes-and-why-do-they-matter/)\n"
  },
  {
    "path": "docs/monitoring/metrics.md",
    "content": "---\ntags:\n  - metrics\n  - autogenerated\n---\n\n# Available Metrics\n\n<!-- THIS FILE MUST NOT BE EDITED BY HAND -->\n<!-- ON NEW METRIC ADDED PLEASE RUN 'make generate-metrics-documentation' -->\n<!-- markdownlint-disable MD013 -->\n\nAll metrics available for scraping are exposed on the `/metrics` endpoint.\nThe metrics are in the Prometheus exposition format.\n\nTo access the metrics:\n\n```sh\ncurl https://localhost:7979/metrics\n```\n\n## Supported Metrics\n\n> Full metric name is constructed as follows:\n> `external_dns_<subsystem>_<name>`\n\n| Name                                    | Metric Type | Subsystem        | Help                                                                                                                                               |\n|:----------------------------------------|:------------|:-----------------|:---------------------------------------------------------------------------------------------------------------------------------------------------|\n| build_info                              | Gauge       |                  | A metric with a constant '1' value labeled with 'version' and 'revision' of external_dns and the 'go_version', 'os' and the 'arch' used the build. |\n| consecutive_soft_errors                 | Gauge       | controller       | Number of consecutive soft errors in reconciliation loop.                                                                                          |\n| last_reconcile_timestamp_seconds        | Gauge       | controller       | Timestamp of last attempted sync with the DNS provider                                                                                             |\n| last_sync_timestamp_seconds             | Gauge       | controller       | Timestamp of last successful sync with the DNS provider                                                                                            |\n| no_op_runs_total                        | Counter     | controller       | Number of reconcile loops ending up with no changes on the DNS provider side.                                                                      |\n| verified_records                        | Gauge       | controller       | Number of DNS records that exists both in source and registry (vector).                                                                            |\n| request_duration_seconds                | Summaryvec  | http             | The HTTP request latencies in seconds.                                                                                                             |\n| cache_apply_changes_calls               | Counter     | provider         | Number of calls to the provider cache ApplyChanges.                                                                                                |\n| cache_records_calls                     | Counter     | provider         | Number of calls to the provider cache Records list.                                                                                                |\n| endpoints_total                         | Gauge       | registry         | Number of Endpoints in the registry                                                                                                                |\n| errors_total                            | Counter     | registry         | Number of Registry errors.                                                                                                                         |\n| records                                 | Gauge       | registry         | Number of registry records partitioned by label name (vector).                                                                                     |\n| skipped_records_owner_mismatch_per_sync | Gauge       | registry         | Number of records skipped with owner mismatch for each record type, owner mismatch ID and domain (vector).                                         |\n| endpoints_total                         | Gauge       | source           | Number of Endpoints in all sources                                                                                                                 |\n| errors_total                            | Counter     | source           | Number of Source errors.                                                                                                                           |\n| records                                 | Gauge       | source           | Number of source records partitioned by label name (vector).                                                                                       |\n| adjustendpoints_errors_total            | Gauge       | webhook_provider | Errors with AdjustEndpoints method                                                                                                                 |\n| adjustendpoints_requests_total          | Gauge       | webhook_provider | Requests with AdjustEndpoints method                                                                                                               |\n| applychanges_errors_total               | Gauge       | webhook_provider | Errors with ApplyChanges method                                                                                                                    |\n| applychanges_requests_total             | Gauge       | webhook_provider | Requests with ApplyChanges method                                                                                                                  |\n| records_errors_total                    | Gauge       | webhook_provider | Errors with Records method                                                                                                                         |\n| records_requests_total                  | Gauge       | webhook_provider | Requests with Records method                                                                                                                       |\n\n## Available Go Runtime Metrics\n\n> The following Go runtime metrics are available for scraping. Please note that they may change over time and they are OS dependent.\n\n| Name                                 |\n|:-------------------------------------|\n| go_gc_duration_seconds               |\n| go_gc_gogc_percent                   |\n| go_gc_gomemlimit_bytes               |\n| go_goroutines                        |\n| go_info                              |\n| go_memstats_alloc_bytes              |\n| go_memstats_alloc_bytes_total        |\n| go_memstats_buck_hash_sys_bytes      |\n| go_memstats_frees_total              |\n| go_memstats_gc_sys_bytes             |\n| go_memstats_heap_alloc_bytes         |\n| go_memstats_heap_idle_bytes          |\n| go_memstats_heap_inuse_bytes         |\n| go_memstats_heap_objects             |\n| go_memstats_heap_released_bytes      |\n| go_memstats_heap_sys_bytes           |\n| go_memstats_last_gc_time_seconds     |\n| go_memstats_mallocs_total            |\n| go_memstats_mcache_inuse_bytes       |\n| go_memstats_mcache_sys_bytes         |\n| go_memstats_mspan_inuse_bytes        |\n| go_memstats_mspan_sys_bytes          |\n| go_memstats_next_gc_bytes            |\n| go_memstats_other_sys_bytes          |\n| go_memstats_stack_inuse_bytes        |\n| go_memstats_stack_sys_bytes          |\n| go_memstats_sys_bytes                |\n| go_sched_gomaxprocs_threads          |\n| go_threads                           |\n| process_cpu_seconds_total            |\n| process_max_fds                      |\n| process_network_receive_bytes_total  |\n| process_network_transmit_bytes_total |\n| process_open_fds                     |\n| process_resident_memory_bytes        |\n| process_start_time_seconds           |\n| process_virtual_memory_bytes         |\n| process_virtual_memory_max_bytes     |\n"
  },
  {
    "path": "docs/overrides/partials/copyright.html",
    "content": "<!--\n  Copyright (c) 2016-2024 Martin Donath <martin.donath@squidfunk.com>\n\n  Permission is hereby granted, free of charge, to any person obtaining a copy\n  of this software and associated documentation files (the \"Software\"), to\n  deal in the Software without restriction, including without limitation the\n  rights to use, copy, modify, merge, publish, distribute, sublicense, and/or\n  sell copies of the Software, and to permit persons to whom the Software is\n  furnished to do so, subject to the following conditions:\n\n  The above copyright notice and this permission notice shall be included in\n  all copies or substantial portions of the Software.\n\n  THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n  FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE\n  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\n  FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS\n  IN THE SOFTWARE.\n-->\n\n<!-- Copyright and theme information -->\n<div class=\"md-copyright\">\n  {% if config.trademark %}\n  <p>\n    The Linux Foundation has registered trademarks and uses trademarks. For a list of trademarks of The Linux Foundation,\n    please see our <a href=\"{{ config.trademark }}\">Trademark Usage page</a>.\n  </p>\n  {% endif %}\n  {% if not config.extra.generator == false %}\n    Made with\n    <a\n      href=\"https://squidfunk.github.io/mkdocs-material/\"\n      target=\"_blank\" rel=\"noopener\"\n    >\n      Material for MkDocs\n    </a>\n  {% endif %}\n</div>\n"
  },
  {
    "path": "docs/proposal/001-leader-election.md",
    "content": "```yaml\n---\ntitle: leader election proposal\nversion: 0.15.1\nauthors: @ivankatliarchuk\ncreation-date: 2025-01-30\nstatus: not-planned\n---\n```\n\n# Leader Election\n\nIn Kubernetes, **leader election** is a mechanism used by applications, controllers, or distributed systems to designate one instance or node as the \"leader\" that is responsible for managing specific tasks, while others operate as followers or standbys.\nThis ensures coordinated and fault-tolerant operations in highly available systems.\n\n- [Kubernetes Coordinated Leader Election](https://kubernetes.io/docs/concepts/cluster-administration/coordinated-leader-election/)\n- [Kubernetes Concepts: Leases](https://kubernetes.io/docs/concepts/architecture/leases/)\n\n## **Leader Election in Kubernetes**\n\nThe leader election mechanism implemented in Go code relies on Kubernetes coordination features, specifically Lease object in the `coordination.k8s.io` API Group. Lease locks provide a way to acquire a lease on a shared resource, which can be used to determine the leader among a group of nodes.\n\n***Leader Election Sequence Diagram***\n\n```mermaid\nsequenceDiagram\n    participant R1 as Replica 1 (Leader)\n    participant LR as Lock Resource\n    participant R2 as Replica 2 (Standby)\n    participant R3 as Replica 3 (Standby)\n\n    R1->>LR: Update Lock Resource\n    Note over LR: currentLeader: R1<br>timeStamp: 12:21<br>leaseDuration: 10s\n\n    loop Every polling period\n        R2->>LR: Poll leader status\n        LR-->>R2: Return lock info\n        R3->>LR: Poll leader status\n        LR-->>R3: Return lock info\n    end\n\n    Note over R2,R3: Replicas remain on standby<br>as long as leader is active\n```\n\n***Leader Election Flow***\n\n```mermaid\ngraph TD\nsubgraph Active Replica\nA[Replica 1]\nend\nsubgraph Kubernetes Resource Lock\nA[\"fa:fa-server  Replica 1\"] --> |Hold The Lock| C@{ label: \"Lock\" }\nend\nsubgraph Standby Replicas\n  D[\"fa:fa-server  Replica 2\"] -->|Poll| C\n  E[\"fa:fa-server  Replica 3\"] -->|Poll| C[\"fa:fa-lock Lock\"]\nend\n\tstyle C color:#8C52FF,fill:#A6A6A6\n\tstyle A color:#8C52FF,fill:#00BF63\n\tstyle D color:#000000,fill:#FFDE59\n\tstyle E color:#000000,fill:#FFDE59\n```\n\n***How Leader Is Elected***\n\n```mermaid\nflowchart TD\n    A[Start Leader Election] -->|Replica 1 Becomes Leader| B(Update Lock Resource)\n    B --> C{Is Leader Active?}\n    C -->|Yes| D[Replicas 2 & 3 Poll Leader Status]\n    C -->|No| E[Trigger New Election]\n    E -->|New Leader Found| F[Replica X Becomes Leader]\n    E -->|No Leader| G[Retry Election]\n    F --> B\n    G --> C\n    D --> C\n```\n\n### Enable Leader Election\n\nMinimum supported Kubernetes version is `v1.26`.\n\n> Currently, this feature is \"opt-in\". The `--enable-leader-election` flag must be explicitly provided to activate it in the service.\n\n| **Flag**                   | **Description**                                       |\n|:---------------------------|:------------------------------------------------------|\n| `--enable-leader-election` | This flag is required to enable leader election logic |\n\n```yml\nargs:\n   --registry=txt \\\n   --source=fake \\\n   --enable-leader-election\n```\n\n## **How Leader Election Works in Kubernetes**\n\n1. **Lease API**:\n   - Kubernetes provides a built-in `Lease` object in the `coordination.k8s.io/v1` API group, specifically designed for leader election.\n   - The leader writes a lease object with metadata (such as its identity and timestamp) to signal that it is the current leader.\n\n2. **Election Process**:\n   - All participating pods (or nodes) periodically check for the lease.\n   - The lease contains details of the current leader's identity (e.g., a pod name).\n   - If the lease expires or is not renewed, other contenders can try to acquire leadership by writing their identity into the lease object.\n\n3. **Heartbeat (Lease Renewal)**:\n   - The current leader must periodically update the lease to retain leadership.\n   - If the leader fails to renew the lease within the configured timeout, leadership is relinquished, and another instance can take over.\n\n---\n\n### **Key Concepts**\n\n- **Lease Duration**: Defines how long the leader is considered valid after the last lease renewal. Short lease durations result in faster failovers but higher contention and potential performance impact.\n- **Leader Identity**: Usually the name or ID of the pod that holds the leadership role.\n- **Backoff and Contention**: Followers typically wait and retry with a backoff period to avoid overwhelming the system when a leader is lost.\n\n---\n\n### **Why Leader Election is Important**\n\nLeader election ensures that:\n\n- **High Availability**: Fail-over to a new leader ensures availability even if the current leader goes down.\n- **Data Consistency**: Only one leader acts on critical tasks, preventing duplicate work or conflicting updates.\n- **Workload Distribution**: Secondary replicas can be on standby, reducing resource contention.\n\n---\n\n### **Use Cases**\n\nLeader election functionality is critical for building reliable, fault-tolerant, and scalable applications on Kubernetes.\n\n- **Cluster Upgrades**: Leader election ensures smooth cluster upgrades by designating one instance as responsible for orchestrating upgrades or managing specific components during the process.\nBy preventing multiple instances from making changes concurrently, it avoids conflicts and reduces downtime, ensuring consistency across the cluster.\n- **Workload Running on Spot Instances**: For workloads running on cost-effective but ephemeral spot instances, leader election is crucial for resiliency.\nWhen a spot instance running the leader is preempted, the failover process enables a standby instance to seamlessly take over leadership, ensuring continued execution of critical tasks.\n- **Requirement for Disaster Recovery**: In disaster recovery scenarios, leader election provides fault tolerance by allowing another instance to take over when the primary leader becomes unavailable.\nThis guarantees operational continuity even in the face of unexpected failures, supporting robust disaster recovery strategies.\n- **High Availability (HA) Scenarios**: In highly available systems, leader election ensures that a single active leader manages essential processes or state, while backups remain ready to step in instantly in case of failure.\nThis minimizes recovery time objectives (RTO) and eliminates single points of failure.\n- **Enhanced Reliability in Distributed Systems**: Incorporating leader election into your distributed system enhances its overall reliability.\nIt avoids the pitfalls of uncoordinated task execution, providing deterministic behavior and ensuring only one instance manages critical tasks at any given time.\n- **Conflict Prevention**: Leader election serves as a guard against conflicts arising from multiple instances attempting to execute the same tasks.\nBy ensuring that only the elected leader acts on shared resources or processes, it prevents data corruption, inconsistencies, and wasted computational effort.\n"
  },
  {
    "path": "docs/proposal/002-internal-ipv6-handling-rollback.md",
    "content": "<!-- clone me -->\n\n```yaml\n---\ntitle: \"Proposal: Rollback IPv6 internal Node IP exposure\"\nversion: if applicable\nauthors: @ivankatliarchuk, @szuecs, @mloiseleur\ncreation-date: 2025-01-01\nstatus: implemented\n---\n```\n\n# Introduce Feature Flag for IPv6 Internal Node IP Handling in ''external-dns'' and Change the behavior\n\n## Summary\n\nThis proposal aims to introduce a feature flag in 'external-dns' to control the handling of IPv6 internal node IPs.\nIn the current version, the feature flag will default to the existing behavior. In the next `minor` or `minor+N` version, the default behavior will be reversed, encouraging users to adopt the new behavior while providing a transition period.\n\n## Motivation\n\nThe discussion in [issue#4566](https://github.com/kubernetes-sigs/external-dns/issues/4566) and the\nsubsequent [pr#4574](https://github.com/kubernetes-sigs/external-dns/pull/4574) and [pr#4808](https://github.com/kubernetes-sigs/external-dns/pull/4808) highlighted concerns regarding the treatment of IPv6 internal node IPs.\nTo address these concerns without causing immediate disruption, a feature flag will allow users to opt-out the current behavior, providing flexibility during the transition.\n\n## Goals\n\n- Introduce feature to toggle the handling of IPv6 internal node IPs\n\n## Non-Goals\n\n- ***Propose/Add an annotation for this specific use case***\n  - Provide support for `external-dns.alpha.kubernetes.io/expose-internal-ipv6` in follow-up releases.\n  - Managing dual annotation and flag may introduce complexity.\n\n## Proposal\n\n- ***Introduce Feature Flag***\n  - Add a feature flag, e.g., `--expose-internal-ipv6=true`, to control the handling of IPv6 internal node IPs.\n  - In the current version, this flag will default to `true`, maintaining the existing behavior.\n\n- ***Flip Default Behavior in Next Minor Version***\n  - In the subsequent minor release, change the default value of `--expose-internal-ipv6` to `false`, adopting the new behavior by default.\n  - Users can still override this behavior by explicitly setting the flag as needed.\n\nProposed Changes in `source/node.go` file.\n\n```go\n// IPv6 addresses are labeled as NodeInternalIP despite being usable externally as well.\nif addr.Type == v1.NodeInternalIP && ns.exposeInternalIP && ... {\n    ipv6Addresses = append(ipv6Addresses, addr.Address)\n}\n```\n\n## User Stories\n\n- **As a cluster Operator or Administrator**, I want to control the handling of IPv6 internal node IPs to align with defined network topology and configuration.\n\n- **As a SecDevOps**, I want to ensure that `external-dns` does not expose internal IPv6 node addresses via public DNS records, so that I can prevent unintended data leaks and reduce the attack surface of my Kubernetes cluster.\n\n- **As a SecDevOps**, I want to use a feature flag to selectively enable or disable the new IPv6 behavior in `external-dns`, so that I can evaluate its security impact before it becomes the default setting in future releases.\n\n- **As a SecDevOps**, I want to use a feature flag to selectively enable or disable the new IPv6 behavior in `external-dns`, so that I can detect misconfigurations, act on potential security incidents, and ensure compliance with security policies.\n\n## Implementation Steps\n\n- Code Changes:\n  - Implement the feature flag in the 'external-dns' codebase to toggle the handling of IPv6 internal node IPs.\n\n- Documentation:\n  - Update the 'external-dns' documentation to include information about the feature flag, its purpose, and usage examples.\n\n## Drawbacks\n\n- Introducing a feature flag adds complexity to the configuration and codebase.\n- Changing default behavior in a future release may still cause issues for users who are unaware of the change.\n\n## Alternatives\n\n- ***Immediate Behavior Change***\n  - Directly change the behavior without a feature flag, which could lead to unexpected issues for users.\n- ***No Change***\n  - Maintain the current behavior, potentially leaving the concerns unaddressed.\n  - Users may not be able to update an `external-dns` version due to security, compliance or any other concerns.\n"
  },
  {
    "path": "docs/proposal/003-dnsendpoint-graduation-to-beta.md",
    "content": "```yaml\n---\ntitle: \"Proposal: Defining a path to Beta for DNSEndpoint API\"\nversion: v1alpha1\nauthors: @ivankatliarchuk, @raffo, @szuecs\ncreation-date: 2025-02-09\nstatus: approved\n---\n```\n\n# Proposal: Defining a path to Beta for DNSEndpoint API\n\n## Summary\n\nThe `DNSEndpoint` API in Kubernetes SIGs `external-dns` is currently in alpha. To ensure its stability and readiness for production environments, we propose defining and agreeing upon the necessary requirements for its graduation to beta.\nBy defining clear criteria, we aim to ensure stability, usability, and compatibility with the broader Kubernetes ecosystem. On completions of all this items, we should be in the position to graduate `DNSEndpoint` to `v1beta`.\n\n## Motivation\n\nThe DNSEndpoint API is a crucial component of the ExternalDNS project, allowing users to manage DNS records dynamically.\nCurrently, it remains in the alpha stage, limiting its adoption due to potential instability and lack of guaranteed backward compatibility. By advancing to beta, we can provide users with a more reliable API and encourage wider adoption with confidence in its long-term viability and support.\n\n### Goals\n\n- Define the necessary requirements for `DNSEndpoint` API to reach beta status.\n- Improve API stability, usability, and documentation.\n- Improve test coverage, automate documentation creation, and validation mechanisms.\n- Ensure backward compatibility and migration strategies from alpha to beta.\n- Collect and incorporate feedback from existing users to refine the API.\n- Address any identified issues or limitations in the current API design.\n\n### Non-Goals\n\n- This proposal does not cover the graduation of ExternalDNS itself to a stable release.\n- Making `DNSEndpoint` a stable (GA) API at this stage.\n- It does not include implementation details for specific DNS providers.\n- It does not introduce new functionality beyond stabilizing the DNSEndpoint API.\n- Redesigning the API from scratch.\n- Introducing breaking changes that would require significant refactoring for existing users.\n\n## Proposal\n\nThe proposal aims to formalize the promotion process by addressing API design, user needs, and implementation details.\n\nTo graduate the `DNSEndpoint` API to beta, we propose the following actions:\n\n1. Capture feedback from the community on missing functionality for DNSEndpoint CRD\n   - In a form of Github issue, pin the issue to the project\n   - Link all CRD related issues to it\n2. Refactor `endpoint` folder, move away `api/crd` related stuff to `apis/<apiVersion> folder`\n3. Documentation for API to be generated automatically with test coverage, similar to `docs/flags.md`\n4. APIs and CRDs discoverable. [doc.crds.dev](https://doc.crds.dev/github.com/kubernetes-sigs/external-dns). Example [crossplane](https://doc.crds.dev/github.com/crossplane/crossplane@v0.10.0)\n5. Review and change .status object such that people can debug and monitor DNSEndpoint object behavior.\n6. Introduce metrics related to DNSEndpoint CRD\n   - Number of CRDs discovered\n   - Number of CRDs by status success|fail\n\nProposed folder structure for `apis`. Examples - [gateway-api](https://github.com/kubernetes-sigs/gateway-api/tree/main/apis)\n\n***Multiple APIs under same version***\n\n```yml\n├── apis\n│   ├── v1alpha\n│   │   ├── util/validation\n│   │   ├── doc.go\n│   │   └── zz_generated.***.go\n│   ├── v1beta  # outside of scope currently, just an example\n│   │   ├── util/validation\n│   │   ├── doc.go\n│   │   └── zz_generated.***.go\n│   ├── v1       # outside of scope currently, just an example\n│   │   ├── util/validation\n│   │   ├── doc.go\n│   │   └── zz_generated.***.go\n```\n\nOr similar folder structure for `apis`. Examples - [cert-manager](https://github.com/cert-manager/cert-manager/tree/master/pkg/apis)\n\n***APIs versioned independently***\n\n```yml\n├── apis\n│   ├── dnsendpoint\n│   │   ├── v1alpha\n│   │   │   ├── util/validation\n│   │   │   ├── doc.go\n│   │   │   └── zz_generated.***.go\n│   │   ├── v1beta  # outside of scope currently, just an example\n│   │   │   ├── util/validation\n│   │   │   ├── doc.go\n│   │   │   └── zz_generated.***.go\n│   │   ├── v1       # outside of scope currently, just an example\n│   │   │   ├── util/validation\n│   │   │   ├── doc.go\n│   │   │   └── zz_generated.***.go\n│   ├── dnsentry\n│   │   ├── v1alpha\n```\n\n### User Stories\n\n#### Story 1: Cluster Operator/Admin Managing External DNS\n\n*As a cluster operator or administrator*, I want a stable `DNSEndpoint` API to reliably manage DNS records within Kubernetes so that I can ensure consistent and automated DNS resolution for my services.\n\n#### Story 2: Developers Integrating External DNS\n\n*As a developer*, I want a well-documented `DNSEndpoint` API that allows me to programmatically define and manage DNS records without worrying about breaking changes.\n\n#### Story 3: Cloud-Native Deployments\n\n*As a SRE*, I need a tested and validated `DNSEndpoint` API that integrates seamlessly with cloud-native networking services, ensuring high availability and scalability.\n\n#### Story 4: Platform Engineer\n\n*As a platform engineer*, I want stronger validation and defaulting so that I can reduce misconfigurations and operational overhead.\n\n### API\n\nThe DNSEndpoint API should provide a robust Custom Resource Definition (CRD) with well-defined fields and validation.\n\n#### DNSEndpoint\n\n- [ ] DNSEndpoint do not have any changes from v1alpha1.\n- [ ] DNSEndpoint to have changes from v1alpha1. `TBD`\n\n```yml\napiVersion: externaldns.k8s.io/v1beta1\nkind: DNSEndpoint\nmetadata:\n  name: example-endpoint\nspec:\n  endpoints:\n    - dnsName: \"example.com\"\n      recordType: \"A\"\n      targets:\n        - \"192.168.1.1\"\n      ttl: 300\n    - dnsName: \"www.example.com\"\n      recordType: \"CNAME\"\n      targets:\n        - \"example.com\"\n```\n\n### Behavior\n\nHow should the new CRD or feature behave? Are there edge cases?\n\n### Drawbacks\n\n- Transitioning to beta may require deprecating certain alpha features that are deemed unstable.\n- Increased maintenance effort to ensure stability and backward compatibility.\n- Users of the alpha API may need to adjust their configurations if breaking changes are introduced.\n- Additional maintenance and support burden for the `external-dns` maintainers.\n\n## Alternatives\n\n1. **Remain in Alpha**: The DNSEndpoint API could remain in alpha indefinitely, but this would discourage adoption and limit its reliability.\n\n- Pros: No immediate changes or migration concerns.\n- Cons: Lack of progress discourages adoption, and users may seek alternative solutions.\n\n2. **Graduate Directly to GA**: Skipping the beta phase could accelerate stability, but it would limit the opportunity for community feedback and refinement.\n\n3. **Introduce a New API Version**: Instead of modifying the existing API, a new version (e.g., `v2alpha1`) could be introduced, allowing gradual migration.\n\n    - Pros: Allowing gradual migration like `v1alpha1` -> `v2alpha1` -> `v1beta`\n    - Cons: This approach would require maintaining multiple versions simultaneously.\n\n4. **Redesign the API Before Graduation**\n\n    - Pros: Provides an opportunity to fix any fundamental design flaws before moving to beta.\n    - Cons: Increases complexity, delays the beta release, and may introduce unnecessary work for existing users.\n\n5. **Deprecate DNSEndpoint and Rely on External Solutions or Annotations**\n\n    - Pros: Potentially reduces the maintenance burden on the Kubernetes SIG.\n    - Cons: Forces users to migrate to third-party solutions or away from CRDs, reducing the cohesion of external-dns within Kubernetes.\n"
  },
  {
    "path": "docs/proposal/004-gateway-api-annotation-placement.md",
    "content": "```yaml\n---\ntitle: \"Gateway API Annotation Placement Clarity\"\nversion: v1alpha1\nauthors: \"@lexfrei\"\ncreation-date: 2025-10-23\nstatus: provisional\n---\n```\n\n# Gateway API Annotation Placement Clarity\n\n## Table of Contents\n\n<!-- toc -->\n- [Summary](#summary)\n- [Motivation](#motivation)\n  - [Goals](#goals)\n  - [Non-Goals](#non-goals)\n- [Proposal](#proposal)\n  - [User Stories](#user-stories)\n  - [Current Behavior](#current-behavior)\n  - [Proposed Solutions](#proposed-solutions)\n  - [Drawbacks](#drawbacks)\n- [Alternatives](#alternatives)\n<!-- /toc -->\n\n## Summary\n\nThe [annotations documentation](https://kubernetes-sigs.github.io/external-dns/latest/docs/annotations/annotations/)\nindicates that Gateway API sources support various annotations, but it does not clearly specify which Kubernetes\nresource (Gateway vs HTTPRoute/GRPCRoute/TLSRoute/etc.) these annotations should be placed on. This ambiguity leads\nto user confusion and misconfigurations.\n\nThis proposal aims to:\n\n1. **Short-term**: Improve documentation to explicitly clarify annotation placement\n2. **Long-term**: Consider implementing annotation inheritance from Gateway to Routes\n\n## Motivation\n\nUsers frequently misconfigure annotations when using Gateway API sources because the current documentation uses \"Gateway\" as the source name in the annotation support table, which is ambiguous\u0014it refers to gateway-api sources generically, not the Gateway resource specifically.\n\n### Current Implementation Behavior\n\nBased on the source code\n([source/gateway.go](https://github.com/kubernetes-sigs/external-dns/blob/master/source/gateway.go)):\n\n**Gateway resource annotations:**\n\n- `external-dns.alpha.kubernetes.io/target` - read from Gateway\n  ([line ~380](https://github.com/kubernetes-sigs/external-dns/blob/master/source/gateway.go#L380))\n\n**Route resource annotations (HTTPRoute, GRPCRoute, TLSRoute, TCPRoute, UDPRoute):**\n\n- `external-dns.alpha.kubernetes.io/hostname` - read from Route\n- `external-dns.alpha.kubernetes.io/ttl` - read from Route\n- `external-dns.alpha.kubernetes.io/controller` - read from Route\n- **Provider-specific annotations** (e.g., `cloudflare-proxied`, `aws/*`, `scw/*`, etc.) - read from Route\n  ([line ~242](https://github.com/kubernetes-sigs/external-dns/blob/master/source/gateway.go#L242))\n\nThis separation aligns with Gateway API architecture:\n\n- **Gateway** = infrastructure layer (IP addresses, listeners, load balancers)\n- **Routes** = application layer (DNS records, routing rules, hostnames)\n\nHowever, users expect provider-specific annotations to work on Gateway (similar to how `target` works), leading to silent failures.\n\n### Goals\n\n- Clarify annotation placement in documentation to prevent user confusion\n- Provide practical examples for common providers (Cloudflare, AWS, Scaleway)\n- Define a clear, documented contract for where each annotation type should be placed\n- Reduce support burden from repeated misconfigurations\n\n### Non-Goals\n\n- This proposal does not address the broader annotation standardization effort discussed in [PR #5080](https://github.com/kubernetes-sigs/external-dns/pull/5080)\n- Redesigning the Gateway API source implementation\n- Changing behavior for non-Gateway sources (Ingress, Service, etc.)\n- Making breaking changes to existing Gateway API functionality\n\n## Proposal\n\n### User Stories\n\n#### Story 1: Platform Engineer with Cloudflare (#5901)\n\n*As a platform engineer*, I set up a Gateway with the `external-dns.alpha.kubernetes.io/cloudflare-proxied: \"true\"`\nannotation, expecting all DNS records for Routes using this Gateway to be proxied through Cloudflare. However, the\nannotation is silently ignored, and records are created without proxy, leading to unexpected traffic routing and\nsecurity issues.\n\n**Root cause**: Had to dive into source code to discover that provider-specific annotations are only read from\nRoute resources, not Gateway resources.\n\n**Current workaround**: Must copy the `cloudflare-proxied` annotation to every HTTPRoute manually.\n\n#### Story 2: User Attempting Route-Specific Targets (#4056)\n\n*As a user*, I want to specify different target DNS records for specific hosts while sharing a common Gateway. I\nadded `external-dns.alpha.kubernetes.io/target` annotation on HTTPRoute to override the Gateway's target for one\nspecific host, but it doesn't work - the annotation is ignored on HTTPRoute.\n\n**Root cause**: The `target` annotation must be on the Gateway resource, not on Route resources. There's no way to\noverride targets on a per-Route basis.\n\n**Outcome**: User had to find alternative workarounds to exclude specific hosts or create separate Gateway resources.\n\n### Current Behavior\n\n#### Annotation Placement Matrix\n\n| Annotation Type                                            | Gateway Resource        | Route Resources (HTTPRoute, GRPCRoute, etc.) |\n|------------------------------------------------------------|-------------------------|----------------------------------------------|\n| `target`                                                   |  **Read from Gateway**  | L Ignored                                    |\n| `hostname`                                                 | L Not used              |  **Read from Route**                         |\n| `ttl`                                                      | L Not used              |  **Read from Route**                         |\n| `controller`                                               | L Not used              |  **Read from Route**                         |\n| Provider-specific (`cloudflare-proxied`, `aws/*`, `scw/*`) | L Not used              |  **Read from Route**                         |\n\n#### Code References\n\n```go\n// source/gateway.go line ~380\n// Target annotation is read from Gateway\noverride := annotations.TargetsFromTargetAnnotation(gw.gateway.Annotations)\n\n// source/gateway.go line ~242\n// Provider-specific annotations are read from Route\nproviderSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(annots)\n```\n\nWhere `annots` is derived from the Route's metadata (`meta.Annotations`), not the Gateway.\n\n### Proposed Solutions\n\n#### Solution 1: Documentation Improvements (Short-term - Quick Win)\n\n**Implementation Status**: Documentation improvements proposed in [PR #5918](https://github.com/kubernetes-sigs/external-dns/pull/5918).\n\n**Note**: If Solution 2 (Annotation Merging) is implemented, the documentation from PR #5918 will require updates\nto reflect the new inheritance behavior.\n\n**Changes to `docs/annotations/annotations.md`:**\n\nExpand footnote [^4] or add a new section \"Gateway API Annotation Placement\" with a detailed table:\n\n```markdown\n### Gateway API Annotation Placement\n\nWhen using Gateway API sources (gateway-httproute, gateway-grpcroute, etc.), annotations must be placed on specific resources:\n\n| Annotation | Placement | Example Resource |\n|------------|-----------|------------------|\n| `target` | Gateway | `kind: Gateway` |\n| `hostname` | Route | `kind: HTTPRoute`, `kind: GRPCRoute`, etc. |\n| `ttl` | Route | `kind: HTTPRoute`, `kind: GRPCRoute`, etc. |\n| `controller` | Route | `kind: HTTPRoute`, `kind: GRPCRoute`, etc. |\n| `cloudflare-proxied` | Route | `kind: HTTPRoute`, `kind: GRPCRoute`, etc. |\n| `aws-*` (all AWS annotations) | Route | `kind: HTTPRoute`, `kind: GRPCRoute`, etc. |\n| `scw-*` (all Scaleway annotations) | Route | `kind: HTTPRoute`, `kind: GRPCRoute`, etc. |\n\n**Rationale**: The Gateway resource defines infrastructure (IP addresses, listeners), while Routes define application-level DNS records. Therefore, DNS record properties (TTL, provider settings) are configured on Routes.\n```\n\n**Changes to `docs/sources/gateway-api.md`:**\n\nAdd a new section after \"Hostnames\":\n\n```markdown\n## Annotations\n\n### Annotation Placement\n\nExternalDNS reads different annotations from different Gateway API resources:\n\n- **Gateway annotations**: Only `external-dns.alpha.kubernetes.io/target` is read from Gateway resources\n- **Route annotations**: All other annotations (hostname, ttl, provider-specific) are read from Route resources\n\n#### Example: Cloudflare Proxied Records\n\n```yaml\napiVersion: gateway.networking.k8s.io/v1\nkind: Gateway\nmetadata:\n  name: my-gateway\n  namespace: default\n  annotations:\n    # \u0005 Correct: target annotation on Gateway\n    external-dns.alpha.kubernetes.io/target: \"203.0.113.1\"\nspec:\n  gatewayClassName: cilium\n  listeners:\n    - name: https\n      hostname: \"*.example.com\"\n      protocol: HTTPS\n      port: 443\n---\napiVersion: gateway.networking.k8s.io/v1\nkind: HTTPRoute\nmetadata:\n  name: my-route\n  annotations:\n    # \u0005 Correct: provider-specific annotations on HTTPRoute\n    external-dns.alpha.kubernetes.io/cloudflare-proxied: \"true\"\n    external-dns.alpha.kubernetes.io/ttl: \"300\"\nspec:\n  parentRefs:\n    - name: my-gateway\n      namespace: default\n  hostnames:\n    - api.example.com\n  rules:\n    - backendRefs:\n        - name: api-service\n          port: 8080\n```\n\n#### Example: AWS Route53 with Routing Policies\n\n```yaml\napiVersion: gateway.networking.k8s.io/v1\nkind: Gateway\nmetadata:\n  name: aws-gateway\n  annotations:\n    # \u0005 Correct: target annotation on Gateway\n    external-dns.alpha.kubernetes.io/target: \"alb-123.us-east-1.elb.amazonaws.com\"\n---\napiVersion: gateway.networking.k8s.io/v1\nkind: HTTPRoute\nmetadata:\n  name: weighted-route\n  annotations:\n    # \u0005 Correct: AWS-specific annotations on HTTPRoute\n    external-dns.alpha.kubernetes.io/aws-weight: \"100\"\n    external-dns.alpha.kubernetes.io/set-identifier: \"backend-v1\"\nspec:\n  parentRefs:\n    - name: aws-gateway\n  hostnames:\n    - app.example.com\n```\n\n### Common Mistakes\n\n❌ **Incorrect**: Placing provider-specific annotations on Gateway\n\n```yaml\nkind: Gateway\nmetadata:\n  annotations:\n    external-dns.alpha.kubernetes.io/cloudflare-proxied: \"true\"  # ❌ Ignored\n```\n\n❌ **Incorrect**: Placing target annotation on HTTPRoute\n\n```yaml\nkind: HTTPRoute\nmetadata:\n  annotations:\n    external-dns.alpha.kubernetes.io/target: \"203.0.113.1\"  # ❌ Ignored\n```\n\n**Implementation effort**: Low\n**Maintenance burden**: Minimal (documentation only)\n**User benefit**: Immediate clarity, reduced misconfiguration\n\n#### Solution 2: Annotation Inheritance and Merging (Long-term - Feature Enhancement)\n\n**Reference Implementation**: [PR #5998](https://github.com/kubernetes-sigs/external-dns/pull/5998)\n\nImplement annotation merging logic where:\n\n1. Gateway annotations serve as **defaults** for all Routes attached to that Gateway\n2. Route annotations **override** Gateway annotations for specific Routes\n3. **All annotations are inheritable**, including `target` — enabling per-Route target overrides\n\n**Proposed implementation** (pseudocode):\n\n```go\n// source/gateway.go - proposed changes\nfunc (src *gatewayRouteSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) {\n    // ... existing code ...\n\n    for _, route := range routes {\n        // Merge Gateway and Route annotations\n        // Route annotations take precedence over Gateway annotations\n        gwAnnots := gw.gateway.Annotations\n        rtAnnots := route.meta.Annotations\n        mergedAnnots := mergeAnnotations(gwAnnots, rtAnnots)\n\n        // Use merged annotations for all annotation processing\n        providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(mergedAnnots)\n        ttl := annotations.TTLFromAnnotations(mergedAnnots, resource)\n\n        // ... rest of endpoint creation ...\n    }\n}\n\n// Helper function\nfunc mergeAnnotations(gateway, route map[string]string) map[string]string {\n    merged := make(map[string]string, len(gateway)+len(route))\n\n    // Copy Gateway annotations (defaults)\n    for k, v := range gateway {\n        merged[k] = v\n    }\n\n    // Route annotations override Gateway defaults\n    for k, v := range route {\n        merged[k] = v\n    }\n\n    return merged\n}\n```\n\n**Example use case enabled by this approach:**\n\n```yaml\napiVersion: gateway.networking.k8s.io/v1\nkind: Gateway\nmetadata:\n  name: intranet-gateway\n  annotations:\n    # Default target for internal services\n    external-dns.alpha.kubernetes.io/target: \"172.16.6.6\"\n    # Set default for all Routes using this Gateway\n    external-dns.alpha.kubernetes.io/cloudflare-proxied: \"true\"\n    external-dns.alpha.kubernetes.io/ttl: \"300\"\n---\napiVersion: gateway.networking.k8s.io/v1\nkind: HTTPRoute\nmetadata:\n  name: internal-api\n  # Inherits: target=172.16.6.6, cloudflare-proxied=true, ttl=300 from Gateway\nspec:\n  parentRefs:\n    - name: intranet-gateway\n  hostnames:\n    - api.internal.example.com\n---\napiVersion: gateway.networking.k8s.io/v1\nkind: HTTPRoute\nmetadata:\n  name: public-api\n  annotations:\n    # Override: expose this route to the public internet\n    external-dns.alpha.kubernetes.io/target: \"203.0.113.1\"\n    # Inherits: cloudflare-proxied=true, ttl=300 from Gateway\nspec:\n  parentRefs:\n    - name: intranet-gateway\n  hostnames:\n    - api.example.com\n---\napiVersion: gateway.networking.k8s.io/v1\nkind: HTTPRoute\nmetadata:\n  name: static-assets\n  annotations:\n    # Override: disable proxying for static content\n    external-dns.alpha.kubernetes.io/cloudflare-proxied: \"false\"\n    # Inherits: target=172.16.6.6, ttl=300 from Gateway\nspec:\n  parentRefs:\n    - name: intranet-gateway\n  hostnames:\n    - static.internal.example.com\n```\n\nThis example demonstrates a common use case: an intranet Gateway where most services are internal\n(`172.16.6.6`), but specific Routes can be exposed publicly (`203.0.113.1`) by overriding the\n`target` annotation.\n\n**Benefits:**\n\n- Reduces configuration duplication\n- Enables centralized defaults at Gateway level\n- Maintains flexibility with Route-level overrides\n- Better matches user mental model (infrastructure defaults + application overrides)\n- **Solves User Story 2**: Enables per-Route target overrides without creating separate Gateways\n\n**Risks:**\n\n- Backward compatibility concerns (may change behavior for existing users)\n- Increased code complexity\n- Potential for confusion about precedence rules\n- Need for comprehensive testing across all Gateway API Route types\n\n**Mitigation strategies:**\n\n- Feature flag to opt-in to new behavior initially\n- Clear documentation of precedence rules\n- Extensive test coverage\n- Migration guide for users\n\n**Implementation effort**: Medium\n**Maintenance burden**: Medium (code + tests + docs)\n**User benefit**: Significant reduction in configuration overhead\n\n### Drawbacks\n\n#### Documentation-Only Solution\n\n- Does not address the underlying UX issue (annotation duplication)\n- Requires users to manually propagate settings across Routes\n- Still allows silent failures if users misplace annotations\n\n#### Annotation Merging Solution\n\n- Adds complexity to the codebase\n- Requires careful consideration of precedence rules\n- May introduce unexpected behavior changes for existing users\n- Needs comprehensive testing for edge cases (multiple Gateways, cross-namespace, etc.)\n- Potential performance impact from annotation merging on every reconciliation\n\n## Alternatives\n\n### Alternative 1: Do Nothing (Status Quo)\n\n**Description**: Keep current behavior and documentation as-is.\n\n**Pros**:\n\n- No implementation effort required\n- No risk of introducing new bugs\n- No breaking changes\n\n**Cons**:\n\n- Users continue to experience confusion and misconfigurations\n- Increased support burden on maintainers and community\n- Poor user experience compared to other sources (Ingress supports annotations more intuitively)\n\n**Recommendation**: L Not recommended - problem is well-documented and affects user productivity\n\n### Alternative 2: Move All Annotations to Gateway Only\n\n**Description**: Refactor source code to read all annotations from Gateway, not Routes.\n\n**Pros**:\n\n- Simplified mental model (one place for all annotations)\n- Centralized configuration\n\n**Cons**:\n\n- **Breaks Gateway API architecture** - Routes define application-layer DNS records, so DNS properties belong on Routes\n- Cannot have different settings per Route (e.g., different TTLs for api.example.com vs static.example.com)\n- Loses flexibility that Route-level annotations provide\n- Requires breaking change to existing implementations\n\n**Recommendation**: L Not recommended - violates Gateway API design principles\n\n### Alternative 3: Support Annotations on Both with Strict Validation\n\n**Description**: Allow annotations on both Gateway and Route, but error/warn if duplicates exist without clear precedence.\n\n**Pros**:\n\n- Provides flexibility\n- Catches configuration errors explicitly\n\n**Cons**:\n\n- Confusing for users (two valid places to configure)\n- Requires complex validation logic\n- Still doesn't solve the \"defaults + overrides\" use case\n- More complex to document and support\n\n**Recommendation**: �\u000f Possible but adds complexity without solving core UX issue\n\n### Alternative 4: Create Dedicated GatewayDNSConfig CRD\n\n**Description**: Introduce a new CRD that defines DNS configuration separately from Gateway and Route resources.\n\n**Example**:\n\n```yaml\napiVersion: externaldns.k8s.io/v1alpha1\nkind: GatewayDNSConfig\nmetadata:\n  name: cloudflare-defaults\nspec:\n  gatewayRef:\n    name: my-gateway\n  defaults:\n    ttl: 300\n    providerSpecific:\n      - name: cloudflare-proxied\n        value: \"true\"\n---\napiVersion: externaldns.k8s.io/v1alpha1\nkind: RouteDNSConfig\nmetadata:\n  name: api-route-dns\nspec:\n  routeRef:\n    kind: HTTPRoute\n    name: api-route\n  overrides:\n    ttl: 60  # Override Gateway default\n```\n\n**Pros**:\n\n- Clean separation of concerns\n- Clear precedence model\n- No annotations needed (type-safe CRDs)\n- Aligns with Kubernetes resource composition patterns\n\n**Cons**:\n\n- **Significant implementation effort** (new CRDs, controllers, validation, etc.)\n- Adds complexity with additional resources to manage\n- Requires migration from annotation-based approach\n- Diverges from how other sources work (Ingress, Service use annotations)\n- May conflict with future annotation standardization efforts\n\n**Recommendation**: �\u000f Potentially valuable long-term, but scope is too large for this specific issue\n\n### Alternative 5: Wait for Annotation Standardization (PR #5080)\n\n**Description**: Defer this work until the broader annotation standardization effort is resolved.\n\n**Pros**:\n\n- Avoids potentially redundant work\n- May be addressed as part of larger effort\n\n**Cons**:\n\n- PR #5080 is not yet ready for review and timeline is uncertain\n- Users continue to experience issues in the meantime\n- Documentation improvements are still valuable regardless of standardization outcome\n\n**Recommendation**: �\u000f Partial - implement documentation improvements now (Solution 1), reconsider\nannotation merging after standardization is resolved\n\n## Recommendation\n\n**Phased approach**:\n\n1. **Immediate (v0.15.0 or next minor)**: Implement Solution 1 (Documentation Improvements)\n   - Low risk, high user value\n   - Can be merged quickly\n   - Addresses immediate pain points\n\n2. **Near-term**: Review and merge Solution 2 (Annotation Merging)\n   - Reference implementation available: [PR #5998](https://github.com/kubernetes-sigs/external-dns/pull/5998)\n   - Includes comprehensive test coverage\n   - Backward compatible (no breaking changes for existing configurations)\n   - Solves User Story 2 (per-Route target overrides)\n\n3. **Future (post-PR #5080 resolution)**: Re-evaluate if additional changes are needed\n   - Assess compatibility with annotation standardization outcomes\n   - Gather user feedback on the annotation inheritance behavior\n\nThis approach provides immediate relief while keeping options open for more comprehensive solutions in the future.\n"
  },
  {
    "path": "docs/proposal/design-template.md",
    "content": "<!-- clone me -->\n```yaml\n---\ntitle: New Feature or Deprecation/Removal Proposal\nversion: if applicable\nauthors: you, me\ncreation-date: 2025-01-25 # format ISO 8601: YYYY-MM-DD\nstatus: draft|approved|rejected|not-planned|partially-implemented|implemented\n---\n```\n\n# New Feature or Deprecation/Removal Proposal\n\n## Table of Contents\n\n<!-- toc -->\n// add it here\n<!-- /toc -->\n\n## Summary\n\nPlease provide a summary of this proposal.\n\n## Motivation\n\nWhat is the motivation of this proposal? Why is it useful and relevant?\n\n### Goals\n\nWhat are the goals of this proposal, what's the problem we want to solve?\n\n### Non-Goals\n\nWhat are explicit non-goals of this proposal?\n\n## Proposal\n\nHow does the proposal look like?\n\n### User Stories\n\nHow would users use this feature, what are their needs?\n\n### API\n\nPlease describe the API (CRD or other) and show some examples.\n\n### Behavior\n\nHow should the new CRD or feature behave? Are there edge cases?\n\n### Drawbacks\n\nIf we implement this feature, what are drawbacks and disadvantages of this approach?\n\n## Alternatives\n\nWhat alternatives do we have and what are their pros and cons?\n"
  },
  {
    "path": "docs/proposal/multi-target.md",
    "content": "# Multiple Targets per hostname\n\n*(November 2017)*\n\n## Purpose\n\nOne should be able to define multiple targets (IPs/Hostnames) in the **same** Kubernetes resource object and expect\nExternalDNS create DNS record(s) with a specified hostname and all targets. So far the connection between k8s resources (ingress/services) and DNS records\nwere not streamlined. This proposal aims to make the connection explicit, making k8s resources acquire or release certain DNS names. As long as the resource\ningress/service owns the record it can have multiple targets enable iff they are specified in the same resource.\n\n## Use cases\n\nSee https://github.com/kubernetes-sigs/external-dns/issues/239\n\n## Current behaviour\n\n*(as of the moment of writing)*\n\nCentral piece of enabling multi-target is having consistent and correct behaviour in `plan` component in regards to how endpoints generated\nfrom kubernetes resources are mapped to dns records. Current implementation of the `plan` has inconsistent behaviour in the following scenarios, all\nof which must be resolved before multi-target support can be enabled in the provider implementations:\n\n1. No records registered so far. Two **different** ingresses request same hostname but different targets, e.g. Ingress A: example.com -> 1.1.1.1 and Ingress B: example.com -> 2.2.2.2\n    * *Current Behaviour*: both are added to the \"Create\" (records to be created) list and passed to Provider\n    * *Expected Behaviour*: only one (random/ or according to predefined strategy) should be chosen and passed to Provider\n\n    **NOTE**: while this seems to go against multi-target support, this is done so no other resource can \"hijack\" already created DNS record. Multi targets are supported only\non per single resource basis\n\n2. Now let's say Ingress A was chosen and successfully created, but both ingress A and B are still there. So on next iteration ExternalDNS would see both again in the Desired list.\n    * *Current Behaviour*: DNS record target will change to that of Ingress B.\n    * *Expected Behaviour*: Ingress A should stay unchanged. Ingress B record is not created\n\n3. DNS record for Ingress A was created but its target has changed. Ingress B is still there\n    * *Current Behaviour*: Undetermined behaviour based on which ingress will be parsed last.\n    * *Expected Behaviour*: DNS record should point to the new target specified in A. Ingress B should still be ignored.\n\n    **NOTE**: both 2. and 3. can be resolved if External DNS is aware which resource has already acquired DNS record\n\n4. Ingress C has multiple targets: 1.1.1.1 and 2.2.2.2\n    * *Current Behaviour*: Both targets are split into different endpoints and we end up in one of the cases above\n    * *Expected Behaviour*: Endpoint should contain list of targets and treated as one ingress object.\n\n## Requirements and assumptions\n\nFor this feature to work we have to make sure that:\n\n1. DNS records are now owned by certain ingress/service resources. For External DNS it would mean that TXT records now\nshould store back-reference for the resource this record was created for, i.e. `\"heritage=external-dns,external-dns/resource=ingress/default/my-ingress-object-name\"`\n2. DNS records are updated only:\n\n    * If owning resource target list has changed\n\n    * If owning resource record is not found in the desired list (meaning it was deleted), therefore it will now be owned by another record. So its target list will be updated\n\n    * Changes related to other record properties (e.g. TTL)\n\n4. All of the issues described in `Current Behaviour` sections are resolved\n\nOnce Create/Update/Delete lists are calculated correctly (this is where conflicts based on requested DNS names are resolved) they are passed to `provider`, where\n`provider` specific implementation will decide how to convert the structures into required formats. If DNS provider does not (or partially) support multi targets\nthen it is up to the provider to make sure that the change list of records passed to the DNS provider API is valid. **TODO**: explain best strategy.\n\nAdditionally see https://github.com/kubernetes-sigs/external-dns/issues/258\n\n## Implementation plan\n\nBrief summary of open PRs and what they are trying to address:\n\n### PRs\n\n1. https://github.com/kubernetes-sigs/external-dns/pull/243 - first attempt to add support for multiple targets. It is lagging far behind from tip\n\n    *what it does*: unfinished attempt to extend `Endpoint` struct, for it to allow multiple targets (essentially `target string -> targets []string`)\n\n    *action*: evaluate if rebasing makes sense, or we can just close it.\n\n2. https://github.com/kubernetes-sigs/external-dns/pull/261 - attempt to rework `plan` to make it work correctly with multiple targets.\n\n    *what it does* : attempts to fix issues with `plan` described in `Current Behaviour` section above. Included tests reveal the current problem with `plan`\n\n    *action*: rebase on default branch and make necessary changes to satisfy requirements listed in this document including back-reference to owning record\n\n3. https://github.com/kubernetes-sigs/external-dns/pull/326 - attempt to add multiple target support.\n\n    *what it does*: for each pair `DNS Name` + `Record Type` it aggregates **all** targets from the cluster and passes them to Provider. It adds basic support\n\n    *action*: the `plan` logic will probably needs to be reworked, however the rest concerning support in Providers and extending `Endpoint` struct can be reused.\n    Rebase on default branch and add missing pieces. Depends on `2`.\n\n Related PRs: https://github.com/kubernetes-sigs/external-dns/pull/331/files,  https://github.com/kubernetes-sigs/external-dns/pull/347/files - aiming at AWS Route53 weighted records.\nThese PRs should be considered after common agreement about the way to address multi-target support is achieved. Related discussion:  https://github.com/kubernetes-sigs/external-dns/issues/196\n\n### How to proceed from here\n\nThe following steps are needed:\n\n1. Make sure consensus regarding the approach is achieved via collaboration on the current document\n2. Notify all PR (see above) authors about the agreed approach\n3. Implementation:\n\n    a. `Plan` is working as expected - either based on #261 above or from scratch. `Plan` should be working correctly regardless of multi-target support\n\n    b. Extensive testing making sure new `plan` does not introduce any breaking changes\n\n    c. Change Endpoint struct to support multiple targets - based on #326 - integrate it with new `plan` @sethpollack\n\n    d. Make sure new endpoint format can still be used in providers which have only partial support for multi targets ~~**TODO**: how ?~~ . This is to be done by simply using first target in the targets list.\n\n    e. Add support for multi target which are already addressed in #326. It goes alongside c. and can be based on the same PR @sethpollack. New providers\n    added since then should maintain same functionality.\n\n5. Extensive testing on **all** providers before making new release\n6. Update all related documentation and explain how multi targets are supported on per provider basis\n7. Think of introducing weighted records (see PRs section above) and making them configurable.\n\n## Open questions\n\n* Handling cases when ingress/service targets include both hostnames and IPs - postpone this until use cases occurs\n* \"Weighted records scope\": https://github.com/kubernetes-sigs/external-dns/issues/196 - this should be considered once multi-target support is implemented\n"
  },
  {
    "path": "docs/providers.md",
    "content": "# Providers\n\nProvider supported configurations\n\n| Provider Name | Zone Cache | Dry Run | Default TTL (seconds) |\n|:--------------|:-----------|:--------|:----------------------|\n| Akamai        | n/a        | yes     | 600                   |\n| AlibabaCloud  | n/a        | yes     | 600                   |\n| AWS           | yes        | yes     | 300                   |\n| AWSSD         | n/a        | yes     | 300                   |\n| Azure         | yes        | yes     | 300                   |\n| Civo          | n/a        | yes     | n/a                   |\n| Cloudflare    | n/a        | yes     | 1                     |\n| CoreDNS       | n/a        | yes     | n/a                   |\n| DNSSimple     | n/a        | yes     | 3600                  |\n| Exoscale      | n/a        | yes     | n/a                   |\n| Gandi         | n/a        | no      | 600                   |\n| GoDaddy       | n/a        | yes     | 600                   |\n| Google GCP    | n/a        | yes     | 300                   |\n| InMemory      | n/a        | n/a     | n/a                   |\n| Linode        | n/a        | n/a     | n/a                   |\n| Myra Security | n/a        | yes     | 300                   |\n| NS1           | n/a        | yes     | 10                    |\n| OCI           | yes        | yes     | 300                   |\n| OVH           | n/a        | yes     | 0                     |\n| PDNS          | n/a        | yes     | 300                   |\n| PiHole        | n/a        | yes     | n/a                   |\n| Plural        | n/a        | n/a     | n/a                   |\n| RFC2136       | n/a        | yes     | n/a                   |\n| Scaleway      | n/a        | n/a     | 300                   |\n| Transip       | n/a        | yes     | 60                    |\n| Webhook       | n/a        | n/a     | n/a                   |\n"
  },
  {
    "path": "docs/registry/dynamodb.md",
    "content": "# The DynamoDB registry\n\nAs opposed to the default TXT registry, the DynamoDB registry stores DNS record metadata in an AWS DynamoDB table instead of in TXT records in a hosted zone.\nThis following tutorial extends [Setting up ExternalDNS for Services on AWS](../tutorials/aws.md) to use the DynamoDB registry instead.\n\n## IAM permissions\n\nThe ExternalDNS [IAM Policy](../tutorials/aws.md#iam-policy) must additionally be granted the following permissions:\n\n```json\n    {\n      \"Effect\": \"Allow\",\n      \"Action\": [\n        \"DynamoDB:DescribeTable\",\n        \"DynamoDB:PartiQLDelete\",\n        \"DynamoDB:PartiQLInsert\",\n        \"DynamoDB:PartiQLUpdate\",\n        \"DynamoDB:Scan\"\n      ],\n      \"Resource\": [\n        \"arn:aws:dynamodb:*:*:table/external-dns\"\n      ]\n    }\n```\n\nThe region and account ID may be specified explicitly specified instead of using wildcards.\n\n## Create a DynamoDB Table\n\nBy default, the DynamoDB registry stores data in the table named `external-dns` and it needs to exist before configuring ExternalDNS to use the DynamoDB registry.\nIf the DynamoDB table has a different name, it may be specified using the `--dynamodb-table` flag.\nIf the DynamoDB table is in a different region, it may be specified using the `--dynamodb-region` flag.\n\nThe following command creates a DynamoDB table with the name: `external-dns`:\n\n> The table must have a partition (HASH) key named `k` of type string (`S`) and the table must NOT have a sort (RANGE) key.\n\n```bash\naws dynamodb create-table \\\n  --table-name external-dns \\\n  --attribute-definitions \\\n    AttributeName=k,AttributeType=S \\\n  --key-schema \\\n    AttributeName=k,KeyType=HASH \\\n  --provisioned-throughput \\\n    ReadCapacityUnits=5,WriteCapacityUnits=5 \\\n  --table-class STANDARD\n```\n\n## Set up a hosted zone\n\nFollow [Set up a hosted zone](../tutorials/aws.md#set-up-a-hosted-zone)\n\n## Modify ExternalDNS deployment\n\nThe ExternalDNS deployment from [Deploy ExternalDNS](../tutorials/aws.md#deploy-externaldns) needs the following modifications:\n\n* `--registry=txt` should be changed to `--registry=dynamodb`\n* Add `--dynamodb-table=external-dns` to specify the name of the DynamoDB table, its value defaults to `external-dns`\n* Add `--dynamodb-region=us-east-1` to specify the region of the DynamoDB table\n\nFor example:\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\n  labels:\n    app.kubernetes.io/name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app.kubernetes.io/name: external-dns\n  template:\n    metadata:\n      labels:\n        app.kubernetes.io/name: external-dns\n    spec:\n      containers:\n        - name: external-dns\n          image: registry.k8s.io/external-dns/external-dns:v0.20.0\n          args:\n            - --source=service\n            - --source=ingress\n            - --domain-filter=example.com # will make ExternalDNS see only the hosted zones matching provided domain, omit to process all available hosted zones\n            - --provider=aws\n            - --policy=upsert-only # would prevent ExternalDNS from deleting any records, omit to enable full synchronization\n            - --aws-zone-type=public # only look at public hosted zones (valid values are public, private or no value for both)\n            - --registry=dynamodb # previously, --registry=txt\n            - --dynamodb-table=external-dns # defaults to external-dns\n            - --dynamodb-region=us-east-1 # set to the region the DynamoDB table in\n            - --txt-owner-id=my-hostedzone-identifier\n          env:\n            - name: AWS_DEFAULT_REGION\n              value: us-east-1 # change to region where EKS is installed\n      # # Uncomment below if using static credentials\n      #       - name: AWS_SHARED_CREDENTIALS_FILE\n      #        value: /.aws/credentials\n      #     volumeMounts:\n      #       - name: aws-credentials\n      #         mountPath: /.aws\n      #         readOnly: true\n      # volumes:\n      #   - name: aws-credentials\n      #     secret:\n      #       secretName: external-dns\n```\n\n## Validate ExternalDNS works\n\nCreate either a [Service](../tutorials/aws.md#verify-externaldns-works-service-example) or an [Ingress](../tutorials/aws.md#verify-externaldns-works-ingress-example) and\n\nAfter roughly two minutes, check that the corresponding entry was created in the DynamoDB table:\n\n```bash\naws dynamodb scan --table-name external-dns\n```\n\nThis will show something like:\n\n```json\n{\n    \"Items\": [\n        {\n            \"k\": {\n                \"S\": \"nginx.example.com#A#\"\n            },\n            \"o\": {\n                \"S\": \"my-identifier\"\n            },\n            \"l\": {\n                \"M\": {\n                    \"resource\": {\n                        \"S\": \"service/default/nginx\"\n                    }\n                }\n            }\n        }\n    ],\n    \"Count\": 1,\n    \"ScannedCount\": 1,\n    \"ConsumedCapacity\": null\n}\n```\n\n## Clean up\n\nIn addition to the clean up steps in [Setting up ExternalDNS for Services on AWS](../tutorials/aws.md#clean-up), delete the DynamoDB table that was used as a registry.\n\n```bash\naws dynamodb delete-table \\\n  --table-name external-dns\n```\n\n## Caching\n\nThe DynamoDB registry can optionally cache DNS records read from the provider. This can mitigate rate limits imposed by the provider.\n\nCaching is enabled by specifying a cache duration with the `--txt-cache-interval` flag.\n\n## Migration from TXT registry\n\nIf any ownership TXT records exist for the configured owner, the DynamoDB registry will migrate\nthe metadata therein to the DynamoDB table. If any such TXT records exist, any previous values for\n`--txt-prefix`, `--txt-suffix`, `--txt-wildcard-replacement`, and `--txt-encrypt-aes-key`\nmust be supplied.\n\nIf TXT records are in the set of managed record types specified by `--managed-record-types`,\nit will then delete the ownership TXT records on a subsequent reconciliation.\n"
  },
  {
    "path": "docs/registry/registry.md",
    "content": "# Registries\n\nA registry persists metadata pertaining to DNS records.\n\nThe most important metadata is the owning external-dns deployment.\nThis is specified using the `--txt-owner-id` flag, specifying a value unique to the\ndeployment of external-dns and which doesn't change for the lifetime of the deployment.\nDeployments in different clusters but sharing a DNS zone need to use different owner IDs.\n\nThe registry implementation is specified using the `--registry` flag.\n\n## Supported registries\n\n* [txt](txt.md) (default) - Stores metadata in TXT records in the same provider.\n* [dynamodb](dynamodb.md) - Stores metadata in an AWS DynamoDB table.\n* noop - Passes metadata directly to the provider. For most providers, this means the metadata is not persisted.\n* aws-sd - Stores metadata in AWS Service Discovery. Only usable with the `aws-sd` provider.\n"
  },
  {
    "path": "docs/registry/txt.md",
    "content": "# The TXT registry\n\nThe TXT registry is the default registry.\nIt stores DNS record metadata in TXT records, using the same provider.\n\n> Note:\n>\n> - If you plan to manage apex domains with external-dns whilst using a txt registry, you should ensure when using `--txt-prefix` that you specify the record type substitution and that it ends in a period (**.**).\n>   The record should be created under the same domain as the apex record being managed, i.e. `--txt-prefix=someprefix-%{record_type}.`\n> - `--txt-prefix` and `--txt-suffix` contribute to the 63-byte maximum record length. To avoid errors, use them only if absolutely required and keep them as short as possible.\n\n## Record Format Options\n\n### For version `v0.18+`\n\nThe TXT registry supports single format for storing DNS record metadata:\n\n- Creates a TXT record with record type information (e.g., 'a-' prefix for A records)\n\nThe TXT registry would try to guarantee a consistency in between providers and sources, if provider supports the behaviour.\n\nIf configured `--txt-prefix=\"%{record_type}-abc-.\"` for apex domain `ex.com` the expected result is\n\n|         Name         |  TYPE   |\n| :------------------: | :-----: |\n| `cname-abc-.ex.com.` |  `TXT`  |\n|      `ex.com.`       | `CNAME` |\n\nFor the domain `www.ex.com` the expected result is\n\n|           Name           |  TYPE   |\n| :----------------------: | :-----: |\n| `cname-abc-.www.ex.com.` |  `TXT`  |\n|      `www.ex.com.`       | `CNAME` |\n\nIf configured `--txt-suffix=\"-.%{record_type}\"` for apex domain `ex.com`, the expected result would be `ex-.a.com`, which fails to create a TXT record because it does not exist within the managed zone.\n\nFor the domain `www.ex.com` the expected result is\n\n|         Name         |  TYPE   |\n| :------------------: | :-----: |\n| `www-.cname.ex.com.` |  `TXT`  |\n|    `www.ex.com.`     | `CNAME` |\n\n### Manually Cleanup Legacy TXT Records\n\n> While deleting registry TXT records won't cause downtime, a well-thought-out migration and cleanup plan is crucial.\n\nOccasionally, it may be necessary to remove outdated TXT records from your registry.\n\nAn example script for AWS can be found in [scripts/aws-cleanup-legacy-txt-records.py](../../scripts/aws-cleanup-legacy-txt-records.py) with instructions on how to run it.\nThe script performs targeted deletion of TXT records that include `ResourceRecords` matching the `heritage=external-dns,external-dns/owner=default` or similar pattern.\nIn the event of unintended deletion of all TXT records managed by `external-dns`, `external-dns` will initiate a full DNS record regeneration, along with`TXT` and `non-TXT` records. Just be aware, this operation's duration is directly proportional to the DNS estate size.\"\n\n### For version `v0.16.0 & v0.16.1`\n\nThe TXT registry supports two formats for storing DNS record metadata:\n\n- Legacy format: Creates a TXT record without record type information\n- New format: Creates a TXT record with record type information (e.g., 'a-' prefix for A records)\n\nBy default, the TXT registry creates records in both formats for backwards compatibility. You can configure it to use only the new format by using the `--txt-new-format-only` flag. This reduces the number of TXT records created, which can be helpful when working with provider-specific record limits.\n\nNote: The following record types always use only the new format regardless of this setting:\n\n- AAAA records\n- Encrypted TXT records (when using `--txt-encrypt-enabled`)\n\nExample:\n\n```sh\n# Default behavior - creates both formats\nexternal-dns --provider=aws --source=ingress --managed-record-types=A --managed-record-types=TXT\n\n# Only create new format records (alongside other required flags)\nexternal-dns --provider=aws --source=ingress --managed-record-types=A --managed-record-types=TXT --txt-new-format-only\n```\n\nThe `--txt-new-format-only` flag should be used in addition to your existing external-dns configuration flags. It does not implicitly configure TXT record handling - you still need to specify `--managed-record-types=TXT` if you want external-dns to manage TXT records.\n\n### Migration to New Format Only\n\n> Note: `external-dns` will not automatically remove legacy format records when switching to new-format-only mode. You'll need to clean up the old records manually if desired.\n\nWhen transitioning from dual-format to new-format-only records:\n\n- Ensure all your `external-dns` instances support the new format\n- Enable the `--txt-new-format-only` flag on your external-dns instances\n  Manually clean up any existing legacy format TXT records from your DNS provider\n\n## Prefixes and Suffixes\n\nIn order to avoid having the registry TXT records collide with\nTXT or CNAME records created from sources, you can configure a fixed prefix or suffix\nto be added to the first component of the domain of all registry TXT records.\n\nThe prefix or suffix may not be changed after initial deployment,\nlest the registry records be orphaned and the metadata be lost.\n\nThe prefix or suffix may contain the substring `%{record_type}`, which is replaced with\nthe record type of the DNS record for which it is storing metadata.\n\nThe prefix is specified using the `--txt-prefix` flag and the suffix is specified using\nthe `--txt-suffix` flag. The two flags are mutually exclusive.\n\n## Wildcard Replacement\n\nThe `--txt-wildcard-replacement` flag specifies a string to use to replace the \"\\*\" in\nregistry TXT records for wildcard domains. Without using this, registry TXT records for\nwildcard domains will have invalid domain syntax and be rejected by most providers.\n\n## Encryption\n\nRegistry TXT records may contain information, such as the internal ingress name or namespace, considered sensitive, , which attackers could exploit to gather information about your infrastructure.\nBy encrypting TXT records, you can protect this information from unauthorized access.\n\nEncryption is enabled by setting the `--txt-encrypt-enabled`. The 32-byte AES-256-GCM encryption\nkey must be specified in URL-safe base64 form (recommended) or be a plain text, using the `--txt-encrypt-aes-key=<key>` flag.\n\nNote that the key used for encryption should be a secure key and properly managed to ensure the security of your TXT records.\n\n### Generating the TXT Encryption Key\n\nPython\n\n```python\npython -c 'import os,base64; print(base64.standard_b64encode(os.urandom(32)).decode())'\n```\n\nBash\n\n```shell\ndd if=/dev/urandom bs=32 count=1 2>/dev/null | base64; echo\n```\n\nOpenSSL\n\n```shell\nopenssl rand -base64 32\n```\n\nPowerShell\n\n```powershell\n# Add System.Web assembly to session, just in case\nAdd-Type -AssemblyName System.Web\n[Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes([System.Web.Security.Membership]::GeneratePassword(32,4)))\n```\n\nTerraform\n\n```hcl\nresource \"random_password\" \"txt_key\" {\n  length           = 32\n}\n```\n\n### Manually Encrypting/Decrypting TXT Records\n\nIn some cases you might need to edit registry TXT records. The following example Go code encrypts and decrypts such records.\n\n```go\npackage main\n\nimport (\n\tb64 \"encoding/base64\"\n\t\"fmt\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\nfunc main() {\n\tkeys := []string{\n\t\t\"ZPitL0NGVQBZbTD6DwXJzD8RiStSazzYXQsdUowLURY=\", // safe base64 url encoded 44 bytes and 32 when decoded\n\t\t\"01234567890123456789012345678901\",             // plain txt 32 bytes\n\t\t\"passphrasewhichneedstobe32bytes!\",             // plain txt 32 bytes\n\t}\n\n\tfor _, k := range keys {\n\t\tkey := []byte(k)\n\t\tif len(key) != 32 {\n\t\t\t// if key is not a plain txt let's decode\n\t\t\tvar err error\n\t\t\tif key, err = b64.StdEncoding.DecodeString(string(key)); err != nil || len(key) != 32 {\n\t\t\t\tfmt.Errorf(\"the AES Encryption key must have a length of 32 byte\")\n\t\t\t}\n\t\t}\n\t\tencrypted, _ := endpoint.EncryptText(\n\t\t\t\"heritage=external-dns,external-dns/owner=example,external-dns/resource=ingress/default/example\",\n\t\t\tkey,\n\t\t\tnil,\n\t\t)\n\t\tdecrypted, _, err := endpoint.DecryptText(encrypted, key)\n\t\tif err != nil {\n\t\t\tfmt.Println(\"Error decrypting:\", err, \"for key:\", k)\n\t\t}\n\t\tfmt.Println(decrypted)\n\t}\n}\n```\n\n## Caching\n\nThe TXT registry can optionally cache DNS records read from the provider. This can mitigate\nrate limits imposed by the provider.\n\nCaching is enabled by specifying a cache duration with the `--txt-cache-interval` flag.\n\n## OwnerID migration\n\n> Automating DNS migrations with third-party tools can be risky. DNS is often business-critical, and without deep understanding of the environment, 3rd party automation tools can do more harm than good.\n\nThe owner ID of the TXT records managed by external-dns instance can be updated.\n\nWhen `--migrate-from-txt-owner` is set, it will enable the migration checks\nin the run loop using `--txt-owner-id=new-owner-id` and the value you defined for this flag.\n\nIf you want to test the outputs of a migration beforehand, you can use the `--dry-run` flag\nalong with `--migrate-from-txt-owner`.\n\nExample, if you had a standard deployment like so:\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  replicas: 1\n  selector:\n    matchLabels:\n      app: external-dns\n  strategy:\n    type: Recreate\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      serviceAccountName: external-dns\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        imagePullPolicy: Always\n        args:\n        - \"--txt-prefix=%{record_type}-\"\n        - \"--txt-cache-interval=2m\"\n        - \"--log-level=debug\"\n        - \"--log-format=text\"\n        - \"--txt-owner-id=old-owner\"\n        - \"--policy=sync\"\n        - \"--provider=some-provider\"\n        - \"--registry=txt\"\n        - \"--interval=1m\"\n        - \"--source=ingress\"\n```\n\nYou can update your deployment to migrate like so :\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  replicas: 1\n  selector:\n    matchLabels:\n      app: external-dns\n  strategy:\n    type: Recreate\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      serviceAccountName: external-dns\n      containers:\n      - name: external-dns\n        imagePullPolicy: Always\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - \"--txt-prefix=%{record_type}-\"\n        - \"--txt-cache-interval=2m\"\n        - \"--log-level=debug\"\n        - \"--log-format=text\"\n        - \"--txt-owner-id=new-owner\"\n        - \"--migrate-from-txt-owner=old-owner\"\n        - \"--policy=sync\"\n        - \"--provider=some-provider\"\n        - \"--registry=txt\"\n        - \"--interval=1m\"\n        - \"--source=ingress\"\n```\n\nIf you didn't set the owner ID, the value set by external-dns is `default`. You can set the\n`--migrate-from-txt-owner` flag to `default` to migrate the associated records.\n\n### OwnerID migration: multi-cluster considerations\n\n> Warning: The `--migrate-from-txt-owner` flag combined with `policy=sync` can be unsafe in shared hosted zones when multiple clusters previously used the same TXT owner value (for example `default`).\n\nIn a shared hosted zone, if one cluster runs ExternalDNS with `policy=sync` and `--migrate-from-txt-owner=default`, it may attempt to delete DNS records that belong to other clusters which still use `owner=default`.\nTo avoid this, do not share the same TXT owner value across clusters in any zone where `policy=sync` or migration flags will be used.\n\n#### Per-cluster owner IDs\n\nFor multi-cluster setups sharing a hosted zone:\n\n- Assign a **unique** `--txt-owner-id` to each cluster (for example `cluster1`, `cluster2`) and document this convention clearly in your platform configuration.\n- Avoid using a common owner such as `default` across clusters in a shared zone if any cluster will run with `policy=sync` or use `--migrate-from-txt-owner`.\n\n#### Example migration sequence for shared zones\n\nWhen migrating from a shared owner (such as `default`) in a shared hosted zone:\n\n1. While still using `policy=upsert-only` (or equivalent), roll out cluster-specific `--txt-owner-id` values and ensure *new* records are created with the cluster’s own owner ID.\n2. Avoid `--migrate-from-txt-owner=<old-owner>` unless you can guarantee that only a single cluster has records with `<old-owner>` in that hosted zone, or perform the migration in an isolated zone where only that cluster writes records.\n\n### When to avoid owner migration\n\nThe following pattern is **not recommended** and may cause record deletion for other clusters:\n\n- Multiple clusters share a Route53 hosted zone and all existing records use `owner=default`.\n- Only one cluster is upgraded to use `policy=sync`, `--txt-owner-id=<cluster-name>`, and `--migrate-from-txt-owner=default`, while other clusters still use `owner=default`.\n\nIn this situation, the upgraded cluster can treat other clusters’ records as orphans and schedule them for deletion during synchronization. Prefer per-cluster zones, manual TXT record adjustment, or fully coordinated migration of all clusters if the migration flag must be used.\n"
  },
  {
    "path": "docs/release.md",
    "content": "# Release\n\n## Release cycle\n\nCurrently we don't release regularly. Whenever we think it makes sense to release a new version we do it.\nYou might want to ask in our Slack channel [external-dns](https://kubernetes.slack.com/archives/C771MKDKQ) when the next release will come out.\n\n## Staging Release cycle\n\nA new staging image is released weekly and can be found at [gcr.io/k8s-staging-external-dns/external-dns](https://console.cloud.google.com/gcr/images/k8s-staging-external-dns/GLOBAL/external-dns?pli=1&inv=1&invt=AboL6Q).\n\n> There is a time lag between merging changes into the master branch and the subsequent creation of the staging image.\n\nExample command to fetch `10` most recent staging images:\n\n```sh\nexport EXT_DNS_VERSION=\"v0.20.0\"\ncurl -sLk https://gcr.io/v2/k8s-staging-external-dns/external-dns/tags/list | jq | grep \"$EXT_DNS_VERSION\" | tail -n 10\n```\n\n## Versioning convention\n\nThese are the conventions that we will be using for releases following `0.7.6`:\n\n- **Patch** version should be updated if we need to merge bugfixes, e.g. provider a does need a fix in order make updates working again. I would see updating or improving documentation here.\n\n- **Minor** version should be updated if new features are implemented in existing providers or new provider get introduced.\n\n- **Major** version should be upgraded if we introduce breaking changes.\n\n### Semantic Versioning Discipline\n\nExternal-DNS follows semantic versioning principles:\n\n- `0.x` → pre-stable, APIs subject to change.\n- `1.x` → not yet considered.\n\n> **Versioning & Releases**\n> External-DNS opts to stay within `0.x` versioning scheme.\n> We strive for stability, but reserve the right to introduce breaking changes in minor version bumps when necessary.\n\n## How to release a new image\n\n### Prerequisite\n\nWe use https://github.com/cli/cli to automate the release process. Please install it according to the [official documentation](https://github.com/cli/cli#installation).\n\nYou must be an official maintainer of the project to be able to do a release.\n\n### Steps\n\n- Run `scripts/releaser.sh` to create a new GitHub release. Alternatively you can create a release in the GitHub UI making sure to click on the autogenerate release node feature.\n- The step above will trigger the Kubernetes based CI/CD system [Prow](https://prow.k8s.io/?repo=kubernetes-sigs%2Fexternal-dns). Verify that a new image was built and uploaded to `gcr.io/k8s-staging-external-dns/external-dns`.\n- Create a PR in the [k8s.io repo](https://github.com/kubernetes/k8s.io) by taking the current staging image using the sha256 digest. They can be obtained with `scripts/get-sha256.sh`. Once the PR is merged, the image will be live with the corresponding tag specified in the PR.\n  - See https://github.com/kubernetes/k8s.io/pull/8466 for reference\n- Verify that the image is pullable with the given tag\n  - `docker run registry.k8s.io/external-dns/external-dns:v0.x.0 --version`\n- Branch out from the default branch and run `scripts/version-updater.sh` to update the image tag used in the kustomization.yaml and in documentation.\n- Create the PR with this version change.\n- Create an issue to release the corresponding Helm chart via the chart release process (below) assigned to a chart maintainer\n- Once the PR is merged, all is done :-)\n\n## How to release a new chart version\n\nThe chart needs to be released in response to an ExternalDNS image release or on an as-needed basis; this should be triggered by an issue to release the chart.\n\n### Steps\n\n- Create a PR to update _Chart.yaml_ with the ExternalDNS version in `appVersion`, agreed on chart release version in `version` and `annotations` showing the changes\n- Validate that the chart linting is successful\n- Merge the PR to trigger a GitHub action to release the chart\n"
  },
  {
    "path": "docs/scripts/index.html.gotmpl",
    "content": "<head>\n  <meta http-equiv=\"Refresh\" content=\"0; url='/external-dns/{{.}}/'\" />\n</head>\n"
  },
  {
    "path": "docs/scripts/requirements.txt",
    "content": "mkdocs-git-revision-date-localized-plugin == 1.5.1\nmkdocs == 1.6.1\nmkdocs-macros-plugin == 1.5.0\nmkdocs-material == 9.7.5\nmkdocs-literate-nav == 0.6.2\nmkdocs-same-dir == 0.1.3\nmike == 2.1.4\n"
  },
  {
    "path": "docs/snippets/contributing/collect-extdns-info.sh",
    "content": "#!/usr/bin/env bash\n# Collect external-dns version, startup args, and logs.\n#\n# Usage:\n#   [NAMESPACE=external-dns] [SINCE=5m] ./collect-extdns-info.sh\n#\n# Examples:\n#   ./collect-extdns-info.sh\n#   NAMESPACE=my-ns ./collect-extdns-info.sh\n#   SINCE=30m ./collect-extdns-info.sh\n#   NAMESPACE=my-ns SINCE=1h ./collect-extdns-info.sh\nset -euo pipefail\n\nNS=\"${NAMESPACE:-external-dns}\"\nSINCE=\"${SINCE:-5m}\"\nOUT=\"extdns-info-$(date +%Y%m%d-%H%M%S).txt\"\n\n{\n  echo \"=== external-dns version ===\"\n  out=$(kubectl get pod -n \"${NS}\" \\\n    -l app.kubernetes.io/name=external-dns \\\n    -o jsonpath='{range .items[*]}{.metadata.name}{\"\\t\"}{range .spec.containers[*]}{.image}{\"\\n\"}{end}{end}' \\\n    2>/dev/null)\n  echo \"${out:-(not found)}\"\n\n  echo \"\"\n  echo \"=== external-dns startup args ===\"\n  out=$(kubectl get pod -n \"${NS}\" \\\n    -l app.kubernetes.io/name=external-dns \\\n    -o jsonpath='{range .items[*]}{.metadata.name}{\"\\n\"}{range .spec.containers[*]}{range .args[*]}{@}{\"\\n\"}{end}{end}{end}' \\\n    2>/dev/null)\n  echo \"${out:-(not found)}\"\n\n  echo \"\"\n  echo \"=== external-dns logs (last ${SINCE}) ===\"\n  out=$(kubectl logs -n \"${NS}\" \\\n    -l app.kubernetes.io/name=external-dns \\\n    --since=\"${SINCE}\" --prefix=true 2>/dev/null)\n  echo \"${out:-(not found)}\"\n\n} | tee \"${OUT}\"\n\necho \"\"\necho \"Saved to: ${OUT}\"\necho \"Review for sensitive data before sharing.\"\n"
  },
  {
    "path": "docs/snippets/contributing/collect-resources.sh",
    "content": "#!/usr/bin/env bash\n# Collect Kubernetes resources relevant to your external-dns source type.\n#\n# Usage:\n#   RESOURCE=<kubectl-resource> ./collect-resources.sh\n#\n# Examples:\n#   RESOURCE=ingress ./collect-resources.sh\n#   RESOURCE=\"ingress,service\" ./collect-resources.sh\n#   RESOURCE=\"gateway,httproute\" ./collect-resources.sh\n#   RESOURCE=dnsendpoint ./collect-resources.sh\nset -euo pipefail\n\nRESOURCE=\"${RESOURCE:?Usage: RESOURCE=<kubectl-resource> $0}\"\nOUT=\"extdns-resources-$(date +%Y%m%d-%H%M%S).txt\"\n\n{\n  echo \"=== ${RESOURCE} (all namespaces) ===\"\n  kubectl get \"${RESOURCE}\" -A -o yaml 2>/dev/null || echo \"(not found)\"\n\n} | tee \"${OUT}\"\n\necho \"\"\necho \"Saved to: ${OUT}\"\necho \"Review for sensitive data before sharing.\"\n"
  },
  {
    "path": "docs/snippets/exoscale/extdns.yaml",
    "content": "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      # Only use if you're also using RBAC\n      # serviceAccountName: external-dns\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.17.0\n        args:\n        - --source=ingress # or service or both\n        - --provider=exoscale\n        - --domain-filter={{ my-domain }}\n        - --policy=sync # if you want DNS entries to get deleted as well\n        - --txt-owner-id={{ owner-id-for-this-external-dns }}\n        - --exoscale-apikey={{ api-key}}\n        - --exoscale-apisecret={{ api-secret }}\n        # - --exoscale-apizone={{ api-zone }}\n        # - --exoscale-apienv={{ api-env }}\n"
  },
  {
    "path": "docs/snippets/exoscale/how-to-test.yaml",
    "content": "---\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  name: nginx\n  annotations:\n    external-dns.alpha.kubernetes.io/target: {{ Elastic-IP-address }}\nspec:\n  ingressClassName: nginx\n  rules:\n  - host: via-ingress.example.com\n    http:\n      paths:\n      - backend:\n          service:\n            name: \"nginx\"\n            port:\n              number: 80\n        path: /\n        pathType: Prefix\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: nginx\nspec:\n  ports:\n  - port: 80\n    targetPort: 80\n  selector:\n    app: nginx\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: nginx\nspec:\n  selector:\n    matchLabels:\n      app: nginx\n  template:\n    metadata:\n      labels:\n        app: nginx\n    spec:\n      containers:\n      - image: nginx\n        name: nginx\n        ports:\n        - containerPort: 80\n"
  },
  {
    "path": "docs/snippets/exoscale/rbac.yaml",
    "content": "---\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: external-dns\n  namespace: default\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: external-dns\nrules:\n- apiGroups: [\"\"]\n  resources: [\"services\",\"pods\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"discovery.k8s.io\"]\n  resources: [\"endpointslices\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"extensions\",\"networking.k8s.io\"]\n  resources: [\"ingresses\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"\"]\n  resources: [\"nodes\"]\n  verbs: [\"list\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: external-dns-viewer\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: external-dns\nsubjects:\n- kind: ServiceAccount\n  name: external-dns\n  namespace: default\n"
  },
  {
    "path": "docs/snippets/security-context/extdns-limited-privilege.yaml",
    "content": "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.17.0\n        args:\n        - ... # your arguments here\n        securityContext:\n          runAsNonRoot: true\n          runAsUser: 65534\n          readOnlyRootFilesystem: true\n          capabilities:\n            drop: [\"ALL\"]\n"
  },
  {
    "path": "docs/snippets/traefik-proxy/ingress-route-default.yaml",
    "content": "apiVersion: traefik.io/v1alpha1\nkind: IngressRoute\nmetadata:\n  name: traefik-ingress\n  annotations:\n    external-dns.alpha.kubernetes.io/target: traefik.example.com\n    kubernetes.io/ingress.class: traefik\nspec:\n  entryPoints:\n    - web\n    - websecure\n  routes:\n    - match: Host(`application.example.com`)\n      kind: Rule\n      services:\n        - name: service\n          namespace: namespace\n          port: port\n"
  },
  {
    "path": "docs/snippets/traefik-proxy/ingress-route-public-private.yaml",
    "content": "---\napiVersion: traefik.io/v1\nkind: IngressRoute\nmetadata:\n  name: traefik-public-abc\n  annotations:\n    kubernetes.io/ingress.class: traefik-public\nspec:\n  entryPoints:\n    - web\n    - websecure\n  routes:\n    - match: Host(`application.public.example.com`)\n      kind: Rule\n      services:\n        - name: service\n          namespace: namespace\n          port: port\n  tls:\n    secretName: traefik-tls-cert-public\n---\napiVersion: traefik.io/v1\nkind: IngressRoute\nmetadata:\n  name: traefik-private-abc\n  annotations:\n    kubernetes.io/ingress.class: traefik-private\nspec:\n  entryPoints:\n    - web\n    - websecure\n  routes:\n    - match: Host(`application.private.tlc`)\n      kind: Rule\n      services:\n        - name: service\n          namespace: namespace\n          port: port\n  tls:\n    secretName: traefik-tls-cert-private\n"
  },
  {
    "path": "docs/snippets/traefik-proxy/traefik-public-private-config.yaml",
    "content": "---\ntype: public\nproviders:\n  kubernetesCRD:\n    ingressClass: traefik-public\n\n  kubernetesIngress:\n    ingressClass: traefik-public\n---\ntype: private\nproviders:\n  kubernetesCRD:\n    ingressClass: traefik-private\n\n  kubernetesIngress:\n    ingressClass: traefik-private\n"
  },
  {
    "path": "docs/snippets/traefik-proxy/with-cluster-rbac.yaml",
    "content": "---\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: external-dns\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: external-dns\nrules:\n- apiGroups: [\"\"]\n  resources: [\"services\",\"pods\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"discovery.k8s.io\"]\n  resources: [\"endpointslices\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"\"]\n  resources: [\"nodes\"]\n  verbs: [\"list\",\"watch\"]\n- apiGroups: [\"traefik.containo.us\",\"traefik.io\"]\n  resources: [\"ingressroutes\", \"ingressroutetcps\", \"ingressrouteudps\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: external-dns-viewer\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: external-dns\nsubjects:\n- kind: ServiceAccount\n  name: external-dns\n  namespace: default\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      serviceAccountName: external-dns\n      containers:\n      - name: external-dns\n        # update this to the desired external-dns version\n        image: registry.k8s.io/external-dns/external-dns:v0.17.0\n        args:\n        - --source=traefik-proxy\n        - --provider=aws\n        - --registry=txt\n        - --txt-owner-id=my-identifier\n"
  },
  {
    "path": "docs/snippets/traefik-proxy/without-rbac.yaml",
    "content": "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      containers:\n      - name: external-dns\n        # update this to the desired external-dns version\n        image: registry.k8s.io/external-dns/external-dns:v0.17.0\n        args:\n        - --source=traefik-proxy\n        - --provider=aws\n        - --registry=txt\n        - --txt-owner-id=my-identifier\n"
  },
  {
    "path": "docs/snippets/tutorials/coredns/coredns-groups.yaml",
    "content": "---\napiVersion: v1\nkind: Service\nmetadata:\n  name: a\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: a.domain.local\n    external-dns.alpha.kubernetes.io/coredns-group: \"g1\"\nspec:\n  type: LoadBalancer\nstatus:\n  loadBalancer:\n    ingress:\n      - ip: 127.0.0.1\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: b\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: b.domain.local\n    external-dns.alpha.kubernetes.io/coredns-group: \"g1\"\nspec:\n  type: LoadBalancer\nstatus:\n  loadBalancer:\n    ingress:\n      - ip: 127.0.0.2\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: c\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: c.subdom.domain.local\n    external-dns.alpha.kubernetes.io/coredns-group: \"g2\"\nspec:\n  type: LoadBalancer\nstatus:\n  loadBalancer:\n    ingress:\n      - ip: 127.0.0.3\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: d\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: d.subdom.domain.local\n    external-dns.alpha.kubernetes.io/coredns-group: \"g2\"\nspec:\n  type: LoadBalancer\nstatus:\n  loadBalancer:\n    ingress:\n      - ip: 127.0.0.4\n"
  },
  {
    "path": "docs/snippets/tutorials/coredns/etcd.yaml",
    "content": "# kubectl apply -f docs/snippets/tutorials/coredns/etcd.yaml\n# kubectl delete -f docs/snippets/tutorials/coredns/etcd.yaml\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: etcd\n  namespace: default\nspec:\n  type: ClusterIP\n  clusterIP: None\n  ports:\n  - name: etcd-client\n    port: 2379\n  - name: etcd-server\n    port: 2380\n  - name: etcd-metrics\n    port: 8080\n  selector:\n    app: etcd\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: etcd-nodeport-external\n  namespace: default\nspec:\n  type: NodePort\n  ports:\n    - port: 2379\n      targetPort: 2379\n      nodePort: 32379 # must match kind config port mapping\n  selector:\n    app: etcd\n---\napiVersion: apps/v1\nkind: StatefulSet\nmetadata:\n  name: etcd\n  namespace: default\nspec:\n  serviceName: etcd\n  replicas: 1\n  selector:\n    matchLabels:\n      app: etcd\n  template:\n    metadata:\n      labels:\n        app: etcd\n      annotations:\n        serviceName: etcd\n    spec:\n      containers:\n      - name: etcd\n        image: quay.io/coreos/etcd:v3.5.15\n        command:\n        - /usr/local/bin/etcd\n        - --name=$(HOSTNAME)\n        - --listen-peer-urls=$(URI_SCHEME)://0.0.0.0:2380\n        - --listen-client-urls=$(URI_SCHEME)://0.0.0.0:2379\n        - --advertise-client-urls=$(URI_SCHEME)://$(HOSTNAME).$(SERVICE_NAME):2379\n        - --data-dir=/var/lib/etcd\n        ports:\n        - containerPort: 2379\n        volumeMounts:\n        - name: data\n          mountPath: /var/lib/etcd\n        env:\n        - name: K8S_NAMESPACE\n          valueFrom:\n            fieldRef:\n              fieldPath: metadata.namespace\n        - name: HOSTNAME\n          valueFrom:\n            fieldRef:\n              fieldPath: metadata.name\n        - name: SERVICE_NAME\n          valueFrom:\n            fieldRef:\n              fieldPath: metadata.annotations['serviceName']\n        - name: ETCDCTL_ENDPOINTS\n          value: $(HOSTNAME).$(SERVICE_NAME):2379\n        - name: URI_SCHEME\n          value: \"http\"\n  volumeClaimTemplates:\n  - metadata:\n      name: data\n    spec:\n      accessModes: [\"ReadWriteOnce\"]\n      resources:\n        requests:\n          storage: 50Mi\n"
  },
  {
    "path": "docs/snippets/tutorials/coredns/fixtures.yaml",
    "content": "# kubectl apply -f docs/snippets/tutorials/coredns/fixtures.yaml\n# kubectl delete -f docs/snippets/tutorials/coredns/fixtures.yaml\n# kubectl get svc -l svc=test-svc\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: a-g1-record\n  labels:\n    svc: test-svc\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: a.example.org\n    external-dns.alpha.kubernetes.io/coredns-group: \"g1\"\n    cluster-name: \"cluster1\"\n  namespace: default\nspec:\n  type: LoadBalancer\n  ports:\n  - port: 80\n    name: http\n    targetPort: 80\n  selector:\n    app: test-app\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: aa-g1-record\n  labels:\n    svc: test-svc\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: aa.example.org\n    external-dns.alpha.kubernetes.io/coredns-group: \"g1\"\n    cluster-name: \"cluster1\"\n  namespace: default\nspec:\n  type: LoadBalancer\n  ports:\n  - port: 80\n    name: http\n    targetPort: 80\n  selector:\n    app: test-app\n"
  },
  {
    "path": "docs/snippets/tutorials/coredns/kind.yaml",
    "content": "# ref: https://kind.sigs.k8s.io/docs/user/quick-start/\n# https://kind.sigs.k8s.io/docs/user/configuration/#extra-port-mappings\n\n# kind create cluster --config=docs/snippets/tutorials/coredns/kind.yaml\n# kind delete cluster --name coredns-etcd\n# kubectl cluster-info --context kind-coredns-etcd\n# kubectl get nodes -o wide\n---\nkind: Cluster\napiVersion: kind.x-k8s.io/v1alpha4\nname: coredns-etcd\nnetworking:\n  apiServerAddress: 127.0.0.1\n  apiServerPort: 6443\nnodes:\n- role: control-plane\n  image: kindest/node:v1.33.0\n  kubeadmConfigPatches:\n    - |\n      kind: InitConfiguration\n      nodeRegistration:\n        kubeletExtraArgs:\n          node-labels: \"ingress-ready=true\"\n  extraPortMappings:\n    - containerPort: 80\n      hostPort: 8080\n      listenAddress: \"0.0.0.0\"\n      protocol: TCP\n    - containerPort: 43\n      hostPort: 4443\n      listenAddress: \"0.0.0.0\"\n      protocol: TCP\n    - containerPort: 32379   # inside kind node\n      hostPort: 32379        # exposed on host\n      listenAddress: \"0.0.0.0\"\n      protocol: TCP\n- role: worker\n  image: kindest/node:v1.33.0\n"
  },
  {
    "path": "docs/snippets/tutorials/coredns/values-coredns.yaml",
    "content": "# kubectl logs deploy/coredns -n default -c coredns\n# ref: https://github.com/coredns/helm/blob/master/charts/coredns/values.yaml\nisClusterService: false\n\nservice:\n  name: coredns\n  port: 53\n  annotations: {}\n  clusterIP: \"\"\n\n# Main customization\nservers:\n  - zones:\n      - zone: .\n    port: 53\n    plugins:\n      - name: errors\n      - name: debug # <── enables debug mode\n      - name: health\n        configBlock: |-\n          lameduck 5s\n      - name: ready\n      # to query kubernetes API for data\n      - name: kubernetes\n        parameters: cluster.local 10.0.0.0/24\n        configBlock: |-\n          pods insecure\n          fallthrough in-addr.arpa ip6.arpa\n          ttl 30\n      - name: etcd\n        parameters: \"example.org\"\n        configBlock: |\n          stubzones\n          path /skydns\n          endpoint http://etcd.default.svc.cluster.local:2379\n          fallthrough\n      - name: log # <── log each DNS query\n      - name: forward\n        parameters: \". /etc/resolv.conf\"\n      - name: cache\n        parameters: 30\n      - name: reload\n      - name: loop\n      - name: loadbalance\n\nreplicaCount: 1\n\n# required to debug DNS resolution from within CoreDNS pods\n# kubectl logs deploy/coredns -n default -c resolv-check --tail=50\ninitContainers:\n  - name: resolv-check\n    image: busybox:1.37\n    command: [\"sh\", \"-c\", \"echo '--- /etc/resolv.conf ---'; cat /etc/resolv.conf; echo '---------------------------'; nslookup kubernetes.default.svc.cluster.local || true; sleep 5\"]\n"
  },
  {
    "path": "docs/snippets/tutorials/coredns/values-extdns-coredns.yaml",
    "content": "\n# ref: https://github.com/kubernetes-sigs/external-dns/blob/master/charts/external-dns/values.yaml\nprovider:\n  name: coredns\n\nenv:\n  - name: ETCD_URLS\n    value: \"http://etcd.default.svc.cluster.local:2379\"\n\n\ntxtOwnerId: cluster1\n# Filter resources queried for endpoints by annotation, using label selector semantics\nannotationFilter: cluster-name=cluster1\n\ndomainFilters:\n  - example.org\n\n# Sources define what ExternalDNS will use to discover endpoints\nsources:\n  - service\n\n# Policy options\npolicy: sync\n\nlogLevel: debug\ninterval: 1m\n\n# RBAC configuration\nrbac:\n  create: true\n\n# Optional: tune resource requests\nresources:\n  requests:\n    cpu: 100m\n    memory: 64Mi\n  limits:\n    cpu: 200m\n    memory: 128Mi\n"
  },
  {
    "path": "docs/sources/about.md",
    "content": "# About\n\nA source in ExternalDNS defines where DNS records are discovered from within your infrastructure. Each source corresponds to a specific Kubernetes resource or external system that declares DNS names.\n\nExternalDNS watches the specified sources for hostname information and uses it to create, update, or delete DNS records accordingly. Multiple sources can be configured simultaneously to support diverse environments.\n\n| Source                                  | Resources                                                                     | annotation-filter | label-filter |\n|-----------------------------------------|-------------------------------------------------------------------------------|:-----------------:|:------------:|\n| ambassador-host                         | Host.getambassador.io                                                         |        Yes        |     Yes      |\n| connector                               |                                                                               |                   |              |\n| contour-httpproxy                       | HttpProxy.projectcontour.io                                                   |        Yes        |              |\n| [crd](crd.md)                           | DNSEndpoint.externaldns.k8s.io                                                |        Yes        |     Yes      |\n| [f5-virtualserver](f5-virtualserver.md) | VirtualServer.cis.f5.com                                                      |        Yes        |              |\n| [gateway-grpcroute](gateway.md)         | GRPCRoute.gateway.networking.k8s.io                                           |        Yes        |     Yes      |\n| [gateway-httproute](gateway.md)         | HTTPRoute.gateway.networking.k8s.io                                           |        Yes        |     Yes      |\n| [gateway-tcproute](gateway.md)          | TCPRoute.gateway.networking.k8s.io                                            |        Yes        |     Yes      |\n| [gateway-tlsroute](gateway.md)          | TLSRoute.gateway.networking.k8s.io                                            |        Yes        |     Yes      |\n| [gateway-udproute](gateway.md)          | UDPRoute.gateway.networking.k8s.io                                            |        Yes        |     Yes      |\n| [gloo-proxy](gloo-proxy.md)             | Proxy.gloo.solo.io                                                            |                   |              |\n| [ingress](ingress.md)                   | Ingress.networking.k8s.io                                                     |        Yes        |     Yes      |\n| [istio-gateway](istio.md)               | Gateway.networking.istio.io                                                   |        Yes        |              |\n| [istio-virtualservice](istio.md)        | VirtualService.networking.istio.io                                            |        Yes        |              |\n| [kong-tcpingress](kong.md)              | TCPIngress.configuration.konghq.com                                           |        Yes        |              |\n| [node](nodes.md)                        | Node                                                                          |        Yes        |     Yes      |\n| [openshift-route](openshift.md)         | Route.route.openshift.io                                                      |        Yes        |     Yes      |\n| [pod](pod.md)                           | Pod                                                                           |        Yes        |     Yes      |\n| [service](service.md)                   | Service                                                                       |        Yes        |     Yes      |\n| skipper-routegroup                      | RouteGroup.zalando.org                                                        |        Yes        |              |\n| [traefik-proxy](traefik-proxy.md)       | IngressRoute.traefik.io IngressRouteTCP.traefik.io IngressRouteUDP.traefik.io |        Yes        |              |\n"
  },
  {
    "path": "docs/sources/crd/dnsendpoint-aws-example.yaml",
    "content": "apiVersion: externaldns.k8s.io/v1alpha1\nkind: DNSEndpoint\nmetadata:\n  name: examplednsrecord\nspec:\n  endpoints:\n  - dnsName: subdomain.foo.bar.com\n    providerSpecific:\n      - name: \"aws/failover\"\n        value: \"PRIMARY\"\n      - name: \"aws/health-check-id\"\n        value: \"asdf1234-as12-as12-as12-asdf12345678\"\n      - name: \"aws/evaluate-target-health\"\n        value: \"true\"\n    recordType: CNAME\n    setIdentifier: some-unique-id\n    targets:\n    - other-subdomain.foo.bar.com\n"
  },
  {
    "path": "docs/sources/crd/dnsendpoint-example.yaml",
    "content": "apiVersion: externaldns.k8s.io/v1alpha1\nkind: DNSEndpoint\nmetadata:\n  name: examplednsrecord\nspec:\n  endpoints:\n  - dnsName: foo.bar.com\n    recordTTL: 180\n    recordType: A\n    targets:\n    - 192.168.99.216\n    # Provider specific configurations are set like an annotation would on other sources\n    providerSpecific:\n      - name: external-dns.alpha.kubernetes.io/cloudflare-proxied\n        value: \"true\"\n"
  },
  {
    "path": "docs/sources/crd.md",
    "content": "# CRD Source\n\nCRD source provides a generic mechanism to manage DNS records in your favorite DNS provider supported by external-dns.\n\n## Details\n\nCRD source watches for a user specified CRD to extract [Endpoints](https://github.com/kubernetes-sigs/external-dns/blob/HEAD/endpoint/endpoint.go) from its `Spec`.\nSo users need to create such a CRD and register it to the kubernetes cluster and then create new object(s) of the CRD specifying the Endpoints.\n\n## Registering CRD\n\nHere is typical example of [CRD API type](https://github.com/kubernetes-sigs/external-dns/blob/HEAD/endpoint/endpoint.go) which provides Endpoints to `CRD source`:\n\n```go\ntype TTL int64\ntype Targets []string\ntype ProviderSpecificProperty struct {\n\tName  string `json:\"name,omitempty\"`\n\tValue string `json:\"value,omitempty\"`\n}\ntype ProviderSpecific []ProviderSpecificProperty\ntype Labels map[string]string\n\ntype Endpoint struct {\n\t// The hostname of the DNS record\n\tDNSName string `json:\"dnsName,omitempty\"`\n\t// The targets the DNS record points to\n\tTargets Targets `json:\"targets,omitempty\"`\n\t// RecordType type of record, e.g. CNAME, A, SRV, TXT etc\n\tRecordType string `json:\"recordType,omitempty\"`\n\t// TTL for the record\n\tRecordTTL TTL `json:\"recordTTL,omitempty\"`\n\t// Labels stores labels defined for the Endpoint\n\t// +optional\n\tLabels Labels `json:\"labels,omitempty\"`\n\t// ProviderSpecific stores provider specific config\n\t// +optional\n\tProviderSpecific ProviderSpecific `json:\"providerSpecific,omitempty\"`\n}\n\ntype DNSEndpointSpec struct {\n\tEndpoints []*Endpoint `json:\"endpoints,omitempty\"`\n}\n\ntype DNSEndpointStatus struct {\n\t// The generation observed by the external-dns controller.\n\t// +optional\n\tObservedGeneration int64 `json:\"observedGeneration,omitempty\"`\n}\n\n// +genclient\n// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object\n\n// DNSEndpoint is the CRD wrapper for Endpoint\n// +k8s:openapi-gen=true\n// +kubebuilder:resource:path=dnsendpoints\n// +kubebuilder:subresource:status\ntype DNSEndpoint struct {\n\tmetav1.TypeMeta   `json:\",inline\"`\n\tmetav1.ObjectMeta `json:\"metadata,omitempty\"`\n\n\tSpec   DNSEndpointSpec   `json:\"spec,omitempty\"`\n\tStatus DNSEndpointStatus `json:\"status,omitempty\"`\n}\n```\n\nRefer to [kubebuilder](https://github.com/kubernetes-sigs/kubebuilder) to create and register the CRD.\n\n## Usage\n\nOne can use CRD source by specifying `--source` flag with `crd` and specifying the ApiVersion and Kind of the CRD with `--crd-source-apiversion` and `crd-source-kind` respectively.\nfor e.g:\n\n```sh\nbuild/external-dns --source crd --crd-source-apiversion externaldns.k8s.io/v1alpha1  --crd-source-kind DNSEndpoint --provider inmemory --once --dry-run\n```\n\n## Creating DNS Records\n\nCreate the objects of CRD type by filling in the fields of CRD and DNS record would be created accordingly.\n\n### Example\n\nHere is an example [CRD manifest](https://github.com/kubernetes-sigs/external-dns/blob/HEAD/charts/external-dns/crds/dnsendpoints.externaldns.k8s.io.yaml) generated by kubebuilder.\nApply this to register the CRD\n\n```sh\n$ kubectl apply --server-side=true -f \"https://raw.githubusercontent.com/kubernetes-sigs/external-dns/master/config/crd/standard/dnsendpoints.externaldns.k8s.io.yaml\"\ncustomresourcedefinition.apiextensions.k8s.io \"dnsendpoints.externaldns.k8s.io\" created\n```\n\nThen you can create the dns-endpoint yaml similar to [dnsendpoint-example](crd/dnsendpoint-example.yaml)\n\n```sh\n$ kubectl apply -f docs/sources/crd/dnsendpoint-example.yaml\ndnsendpoint.externaldns.k8s.io \"examplednsrecord\" created\n```\n\nRun external-dns in dry-mode to see whether external-dns picks up the DNS record from CRD.\n\n```sh\n$ build/external-dns --source crd --crd-source-apiversion externaldns.k8s.io/v1alpha1  --crd-source-kind DNSEndpoint --provider inmemory --once --dry-run\nINFO[0000] running in dry-run mode. No changes to DNS records will be made.\nINFO[0000] Connected to cluster at https://192.168.99.100:8443\nINFO[0000] CREATE: foo.bar.com 180 IN A 192.168.99.216\nINFO[0000] CREATE: foo.bar.com 0 IN TXT \"heritage=external-dns,external-dns/owner=default\"\n```\n\n### Using CRD source to manage DNS records in different DNS providers\n\n[CRD source](https://github.com/kubernetes-sigs/external-dns/blob/master/docs/sources/crd.md) provides a generic mechanism and declarative way to manage DNS records in different DNS providers using external-dns.\n\n**Not all the record types are enabled by default so the required record types must be enabled by using `--managed-record-types`.**\n\n```bash\nexternal-dns --source=crd \\\n  --domain-filter=example.com \\\n  --managed-record-types=A \\\n  --managed-record-types=CNAME \\\n  --managed-record-types=NS\n```\n\n* Example for record type `A`\n\n```yaml\napiVersion: externaldns.k8s.io/v1alpha1\nkind: DNSEndpoint\nmetadata:\n  name: examplearecord\nspec:\n  endpoints:\n  - dnsName: example.com\n    recordTTL: 60\n    recordType: A\n    targets:\n    - 10.0.0.1\n```\n\n* Example for record type `CNAME`\n\n```yaml\napiVersion: externaldns.k8s.io/v1alpha1\nkind: DNSEndpoint\nmetadata:\n  name: examplecnamerecord\nspec:\n  endpoints:\n  - dnsName: test-a.example.com\n    recordTTL: 300\n    recordType: CNAME\n    targets:\n    - example.com\n```\n\n> **Note:** CNAME targets accept both bare hostnames (`example.com`) and absolute FQDNs with a trailing dot (`example.com.`), as defined by [RFC 1035 §5.1](https://www.rfc-editor.org/rfc/rfc1035#section-5.1). Other record types (A, AAAA, NS, etc.) do not accept a trailing dot.\n\n* Example for record type `NS`\n\n```yaml\napiVersion: externaldns.k8s.io/v1alpha1\nkind: DNSEndpoint\nmetadata:\n  name: ns-record\nspec:\n  endpoints:\n  - dnsName: zone.example.com\n    recordTTL: 300\n    recordType: NS\n    targets:\n    - ns1.example.com\n    - ns2.example.com\n```\n\n## RBAC configuration\n\nIf you use RBAC, extend the `external-dns` ClusterRole with:\n\n```yaml\n- apiGroups: [\"externaldns.k8s.io\"]\n  resources: [\"dnsendpoints\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"externaldns.k8s.io\"]\n  resources: [\"dnsendpoints/status\"]\n  verbs: [\"*\"]\n```\n"
  },
  {
    "path": "docs/sources/f5-transportserver.md",
    "content": "# F5 Networks TransportServer Source\n\nThis tutorial describes how to configure ExternalDNS to use the F5 Networks TransportServer Source. It is meant to supplement the other provider-specific setup tutorials.\n\nThe F5 Networks TransportServer CRD is part of [this](https://github.com/F5Networks/k8s-bigip-ctlr) project. See more in-depth info regarding the [TransportServer CRD here](https://github.com/F5Networks/k8s-bigip-ctlr/tree/master/docs/cis-20.x/config_examples/customResource/TransportServer).\n\n## Start with ExternalDNS with the F5 Networks TransportServer source\n\n1. Make sure that you have the `k8s-bigip-ctlr` installed in your cluster. The needed CRDs are bundled within the controller.\n\n2. In your Helm `values.yaml` add:\n\n```yaml\nsources:\n  - ...\n  - f5-transportserver\n  - ...\n```\n\nor add it in your `Deployment` if you aren't installing `external-dns` via Helm:\n\n```yaml\nargs:\n- --source=f5-transportserver\n```\n\nNote that, in case you're not installing via Helm, you'll need the following in the `ClusterRole` bound to the service account of `external-dns`:\n\n```yaml\n- apiGroups:\n  - cis.f5.com\n  resources:\n  - transportservers\n  verbs:\n  - get\n  - list\n  - watch\n```\n\n### Example TransportServer CR w/ host in spec\n\n```yaml\napiVersion: cis.f5.com/v1\nkind: TransportServer\nmetadata:\n  labels:\n    f5cr: 'true'\n  name: test-ts\n  namespace: test-ns\nspec:\n  bigipRouteDomain: 0\n  host: test.example.com\n  ipamLabel: vips\n  mode: standard\n  pool:\n    service: test-service\n    servicePort: 4222\n  virtualServerPort: 4222\n```\n\n### Example TransportServer CR w/ target annotation set\n\nIf the `external-dns.alpha.kubernetes.io/target` annotation is set, the record created will reflect that and everything else will be ignored.\n\n```yaml\napiVersion: cis.f5.com/v1\nkind: TransportServer\nmetadata:\n  annotations:\n    external-dns.alpha.kubernetes.io/target: 10.172.1.12\n  labels:\n    f5cr: 'true'\n  name: test-ts\n  namespace: test-ns\nspec:\n  bigipRouteDomain: 0\n  host: test.example.com\n  ipamLabel: vips\n  mode: standard\n  pool:\n    service: test-service\n    servicePort: 4222\n  virtualServerPort: 4222\n```\n\n### Example TransportServer CR w/ VirtualServerAddress set\n\nIf `virtualServerAddress` is set, the record created will reflect that. `external-dns.alpha.kubernetes.io/target` will take precedence though.\n\n```yaml\napiVersion: cis.f5.com/v1\nkind: TransportServer\nmetadata:\n  labels:\n    f5cr: 'true'\n  name: test-ts\n  namespace: test-ns\nspec:\n  bigipRouteDomain: 0\n  host: test.example.com\n  ipamLabel: vips\n  mode: standard\n  pool:\n    service: test-service\n    servicePort: 4222\n  virtualServerPort: 4222\n  virtualServerAddress: 10.172.1.123\n```\n\nIf there is no target annotation or `virtualServerAddress` field set, then it'll use the `VSAddress` field from the created TransportServer status to create the record.\n"
  },
  {
    "path": "docs/sources/f5-virtualserver.md",
    "content": "# F5 Networks VirtualServer Source\n\nThis tutorial describes how to configure ExternalDNS to use the F5 Networks VirtualServer Source. It is meant to supplement the other provider-specific setup tutorials.\n\nThe F5 Networks VirtualServer CRD is part of [this](https://github.com/F5Networks/k8s-bigip-ctlr) project.\nSee more in-depth info regarding the VirtualServer CRD [in the official documentation](https://github.com/F5Networks/k8s-bigip-ctlr/blob/master/docs/config_examples/customResource/CustomResource.md#virtualserver).\n\n## Start with ExternalDNS with the F5 Networks VirtualServer source\n\n1. Make sure that you have the `k8s-bigip-ctlr` installed in your cluster. The needed CRDs are bundled within the controller.\n\n2. In your Helm `values.yaml` add:\n\n```yaml\nsources:\n  - ...\n  - f5-virtualserver\n  - ...\n```\n\nor add it in your `Deployment` if you aren't installing `external-dns` via Helm:\n\n```yaml\nargs:\n- --source=f5-virtualserver\n```\n\nNote that, in case you're not installing via Helm, you'll need the following in the `ClusterRole` bound to the service account of `external-dns`:\n\n```yaml\n- apiGroups:\n  - cis.f5.com\n  resources:\n  - virtualservers\n  verbs:\n  - get\n  - list\n  - watch\n```\n\n## How it works\n\nThe F5 VirtualServer source creates DNS records based on the following fields:\n\n- **`spec.host`**: The primary hostname for the virtual server\n- **`spec.hostAliases`**: Additional hostnames that should also resolve to the same targets\n- **`spec.virtualServerAddress`**: The IP address to use as the target (if no target annotation is set)\n- **`status.vsAddress`**: The IP address from the status field (if no spec address or target annotation is set)\n\n### Example VirtualServer with hostAliases\n\n```yaml\napiVersion: cis.f5.com/v1\nkind: VirtualServer\nmetadata:\n  name: example-vs\n  namespace: default\nspec:\n  host: www.example.com\n  hostAliases:\n    - alias1.example.com\n    - alias2.example.com\n  virtualServerAddress: 192.168.1.100\n```\n\nThis configuration will create DNS A records for:\n\n- `www.example.com` → `192.168.1.100`\n- `alias1.example.com` → `192.168.1.100`\n- `alias2.example.com` → `192.168.1.100`\n\n### Target Priority\n\nThe source follows this priority order for determining targets:\n\n1. **Target annotation**: `external-dns.alpha.kubernetes.io/target` (highest priority)\n2. **Spec address**: `spec.virtualServerAddress`\n3. **Status address**: `status.vsAddress`\n\nIf none of these are available, the VirtualServer will be skipped.\n\n### TTL Support\n\nYou can set a custom TTL using the annotation:\n\n```yaml\nannotations:\n  external-dns.alpha.kubernetes.io/ttl: \"300\"\n```\n\n### Annotation Filtering\n\nYou can filter VirtualServers using the `--annotation-filter` flag to only process those with specific annotations.\n"
  },
  {
    "path": "docs/sources/gateway-api.md",
    "content": "# Gateway API Route Sources\n\nThis describes how to configure ExternalDNS to use Gateway API Route sources.\nIt is meant to supplement the other provider-specific setup tutorials.\n\n## Supported API Versions\n\nExternalDNS currently supports a mixture of v1alpha2, v1beta1, v1 APIs.\n\nGateway API has two release channels: Standard and Experimental.\nThe Experimental channel includes v1alpha2, v1beta2, and v1 APIs.\nThe Standard channel only includes v1beta2 and v1 APIs, not v1alpha2.\n\nTCPRoutes, TLSRoutes, and UDPRoutes only exist in v1alpha2 and continued support for\nthese versions is NOT guaranteed. At some time in the future, Gateway API will graduate\nthese Routes to v1. ExternalDNS will likely follow that upgrade and move to the v1 API,\nwhere they will be available in the Standard release channel. This will be a breaking\nchange if your Experimental CRDs are not updated to include the new v1 API.\n\nGateways and HTTPRoutes are available in v1alpha2, v1beta1, and v1 APIs.\nHowever, some notable environments are behind in upgrading their CRDs to include the v1 API.\nFor compatibility reasons Gateways and HTTPRoutes use the v1beta1 API.\n\nGRPCRoutes are available in v1alpha2 and v1 APIs, not v1beta2.\nTherefore, GRPCRoutes use the v1 API which is available in both release channels.\nUnfortunately, this means they will not be available in environments with old CRDs.\n\n## Hostnames\n\nHTTPRoute and TLSRoute specs, along with their associated Gateway Listeners, contain hostnames that\nwill be used by ExternalDNS. However, no such hostnames may be specified in TCPRoute or UDPRoute\nspecs. For TCPRoutes and UDPRoutes, the `external-dns.alpha.kubernetes.io/hostname` annotation\nis the recommended way to provide their hostnames to ExternalDNS. This annotation is also supported\nfor HTTPRoutes and TLSRoutes by ExternalDNS, but it's _strongly_ recommended that they use their\nspecs to provide all intended hostnames, since the Gateway that ultimately routes their\nrequests/connections won't recognize additional hostnames from the annotation.\n\n## Annotations\n\n### Annotation Placement\n\nExternalDNS reads different annotations from different Gateway API resources:\n\n- **Gateway annotations**: Only `external-dns.alpha.kubernetes.io/target` is read from Gateway resources\n- **Route annotations**: All other annotations (hostname, ttl, controller, provider-specific) are read from Route\n  resources (HTTPRoute, GRPCRoute, TLSRoute, TCPRoute, UDPRoute)\n\nThis separation aligns with Gateway API architecture where Gateway defines infrastructure (IP addresses, listeners)\nand Routes define application-level DNS records.\n\n### Examples\n\n#### Example: Cloudflare Proxied Records\n\n```yaml\napiVersion: gateway.networking.k8s.io/v1\nkind: Gateway\nmetadata:\n  name: my-gateway\n  namespace: default\n  annotations:\n    # ✅ Correct: target annotation on Gateway\n    external-dns.alpha.kubernetes.io/target: \"203.0.113.1\"\nspec:\n  gatewayClassName: cilium\n  listeners:\n    - name: https\n      hostname: \"*.example.com\"\n      protocol: HTTPS\n      port: 443\n---\napiVersion: gateway.networking.k8s.io/v1\nkind: HTTPRoute\nmetadata:\n  name: my-route\n  annotations:\n    # ✅ Correct: provider-specific annotations on HTTPRoute\n    external-dns.alpha.kubernetes.io/cloudflare-proxied: \"true\"\n    external-dns.alpha.kubernetes.io/ttl: \"300\"\nspec:\n  parentRefs:\n    - name: my-gateway\n      namespace: default\n  hostnames:\n    - api.example.com\n  rules:\n    - backendRefs:\n        - name: api-service\n          port: 8080\n```\n\n#### Example: AWS Route53 with Routing Policies\n\n```yaml\napiVersion: gateway.networking.k8s.io/v1\nkind: Gateway\nmetadata:\n  name: aws-gateway\n  annotations:\n    # ✅ Correct: target annotation on Gateway\n    external-dns.alpha.kubernetes.io/target: \"alb-123.us-east-1.elb.amazonaws.com\"\n---\napiVersion: gateway.networking.k8s.io/v1\nkind: HTTPRoute\nmetadata:\n  name: weighted-route\n  annotations:\n    # ✅ Correct: AWS-specific annotations on HTTPRoute\n    external-dns.alpha.kubernetes.io/aws-weight: \"100\"\n    external-dns.alpha.kubernetes.io/set-identifier: \"backend-v1\"\nspec:\n  parentRefs:\n    - name: aws-gateway\n  hostnames:\n    - app.example.com\n```\n\n### Common Mistakes\n\n❌ **Incorrect**: Placing provider-specific annotations on Gateway\n\n```yaml\nkind: Gateway\nmetadata:\n  annotations:\n    # ❌ These annotations are ignored on Gateway\n    external-dns.alpha.kubernetes.io/cloudflare-proxied: \"true\"\n    external-dns.alpha.kubernetes.io/ttl: \"300\"\n```\n\n❌ **Incorrect**: Placing target annotation on HTTPRoute\n\n```yaml\nkind: HTTPRoute\nmetadata:\n  annotations:\n    # ❌ This annotation is ignored on Routes\n    external-dns.alpha.kubernetes.io/target: \"203.0.113.1\"\n```\n\n### external-dns.alpha.kubernetes.io/gateway-hostname-source\n\n**Why is this needed:**\nIn certain scenarios, conflicting DNS records can arise when External DNS processes both the hostname annotations and the hostnames defined in the `*Route` spec. For example:\n\n- A CNAME record (`company.public.example.com -> company.private.example.com`) is used to direct traffic to private endpoints (e.g., AWS PrivateLink).\n- Some third-party services require traffic to resolve publicly to the Gateway API load balancer, but the hostname (`company.public.example.com`) must remain unchanged to avoid breaking the CNAME setup.\n- Without this annotation, External DNS may override the CNAME record with an A record due to conflicting hostname definitions.\n\n**Usage:**\nBy setting the annotation `external-dns.alpha.kubernetes.io/gateway-hostname-source: annotation-only`, users can instruct External DNS\nto ignore hostnames defined in the `HTTPRoute` spec and use only the hostnames specified in annotations. This ensures\ncompatibility with complex DNS configurations and avoids record conflicts.\n\n**Example:**\n\n```yaml\napiVersion: gateway.networking.k8s.io/v1beta1\nkind: HTTPRoute\nmetadata:\n  annotations:\n    external-dns.alpha.kubernetes.io/gateway-hostname-source: annotation-only\n    external-dns.alpha.kubernetes.io/hostname: company.private.example.com\nspec:\n  hostnames:\n    - company.public.example.com\n```\n\nIn this example, External DNS will create DNS records only for `company.private.example.com` based on the annotation, ignoring the `hostnames` field in the `HTTPRoute` spec. This prevents conflicts with existing CNAME records while enabling public resolution for specific endpoints.\n\nFor a complete list of supported annotations, see the\n[annotations documentation](../annotations/annotations.md#gateway-api-annotation-placement).\n\n## Manifest with RBAC\n\n```yaml\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: external-dns\n  namespace: default\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: external-dns\nrules:\n- apiGroups: [\"\"]\n  resources: [\"namespaces\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"gateway.networking.k8s.io\"]\n  resources: [\"gateways\",\"httproutes\",\"grpcroutes\",\"tlsroutes\",\"tcproutes\",\"udproutes\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: external-dns\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: external-dns\nsubjects:\n- kind: ServiceAccount\n  name: external-dns\n  namespace: default\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\n  namespace: default\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      serviceAccountName: external-dns\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        # Add desired Gateway API Route sources.\n        - --source=gateway-httproute\n        - --source=gateway-grpcroute\n        - --source=gateway-tlsroute\n        - --source=gateway-tcproute\n        - --source=gateway-udproute\n        # Optionally, limit Routes to those in the given namespace.\n        - --namespace=my-route-namespace\n        # Optionally, limit Routes to those matching the given label selector.\n        - --label-filter=my-route-label==my-route-value\n        # Optionally, limit Route endpoints to those Gateways with the given name.\n        - --gateway-name=my-gateway-name\n        # Optionally, limit Route endpoints to those Gateways in the given namespace.\n        - --gateway-namespace=my-gateway-namespace\n        # Optionally, limit Route endpoints to those Gateways matching the given label selector.\n        - --gateway-label-filter=my-gateway-label==my-gateway-value\n        # Add provider-specific flags...\n        - --domain-filter=external-dns-test.my-org.com\n        - --provider=google\n        - --registry=txt\n        - --txt-owner-id=my-identifier\n```\n"
  },
  {
    "path": "docs/sources/gateway.md",
    "content": "# Gateway sources\n\nThe gateway-grpcroute, gateway-httproute, gateway-tcproute, gateway-tlsroute, and gateway-udproute\nsources create DNS entries based on their respective `gateway.networking.k8s.io` resources.\n\n## Filtering the Routes considered\n\nThese sources support the `--label-filter` flag, which filters \\*Route resources\nby a set of labels.\n\n## Domain names\n\nTo calculate the Domain names created from a *Route, this source first collects a set\nof [domain names from the *Route](#domain-names-from-route).\n\nIt then iterates over each of the `status.parents` with\na [matching Gateway](#matching-gateways) and at least one [matching listener](#matching-listeners).\nFor each matching listener, if the\nlistener has a `hostname`, it narrows the set of domain names from the \\*Route to the portion\nthat overlaps the `hostname`. If a matching listener does not have a `hostname`, it uses\nthe un-narrowed set of domain names.\n\n### Domain names from Route\n\nThe set of domain names from a \\*Route is sourced from the following places:\n\n- If the \\*Route is a GRPCRoute, HTTPRoute, or TLSRoute, adds each of the`spec.hostnames`.\n\n- Adds the hostnames from any `external-dns.alpha.kubernetes.io/hostname` annotation on the \\*Route.\n  This behavior is suppressed if the `--ignore-hostname-annotation` flag was specified.\n\n- If no endpoints were produced by the previous steps\n  or the `--combine-fqdn-annotation` flag was specified, then adds hostnames\n  generated from any`--fqdn-template` flag.\n\n- If no endpoints were produced by the previous steps, each\n  attached Gateway listener will use its `hostname`, if present.\n\n### Matching Gateways\n\nMatching Gateways are discovered by iterating over the \\*Route's `status.parents`:\n\n- Ignores parents with a `parentRef.group` other than\n  `gateway.networking.k8s.io` or a `parentRef.kind` other than `Gateway`.\n\n- If the `--gateway-name` flag was specified, ignores parents with a `parentRef.name` other than the\n  specified value.\n\n  For example, given the following HTTPRoute:\n\n    ```yaml\n    apiVersion: gateway.networking.k8s.io/v1\n    kind: HTTPRoute\n    metadata:\n      name: echo\n    spec:\n      hostnames:\n        - echoserver.example.org\n      parentRefs:\n        - group: networking.k8s.io\n          kind: Gateway\n          name: internal\n    ---\n    apiVersion: gateway.networking.k8s.io/v1\n    kind: HTTPRoute\n    metadata:\n      name: echo2\n    spec:\n      hostnames:\n        - echoserver2.example.org\n      parentRefs:\n        - group: networking.k8s.io\n          kind: Gateway\n          name: external\n    ```\n\n  And using the `--gateway-name=external` flag, only the `echo2` HTTPRoute will be considered for DNS entries.\n\n- If the `--gateway-namespace` flag was specified, ignores parents with a `parentRef.namespace` other\n  than the specified value.\n\n- If the `--gateway-label-filter` flag was specified, ignores parents whose Gateway does not match the\n  specified label filter.\n\n- Ignores parents whose Gateway either does not exist or has not accepted the route.\n\n### Matching listeners\n\nIterates over all listeners for the parent's `parentRef.sectionName`:\n\n- Ignores listeners whose `protocol` field does not match the kind of the \\*Route per the following table:\n\n| kind      | protocols   |\n| --------- | ----------- |\n| GRPCRoute | HTTP, HTTPS |\n| HTTPRoute | HTTP, HTTPS |\n| TCPRoute  | TCP         |\n| TLSRoute  | TLS         |\n| UDPRoute  | UDP         |\n\n- If the parent's `parentRef.port` port is specified, ignores listeners without a matching `port`.\n\n- Ignores listeners which specify an `allowedRoutes` which does not allow the route.\n\n## Targets\n\nThe targets of the DNS entries created from a \\*Route are sourced from the following places:\n\n1. If a matching parent Gateway has an `external-dns.alpha.kubernetes.io/target` annotation, uses\n   the values from that.\n\n2. Otherwise, iterates over that parent Gateway's `status.addresses`,\n   adding each address's `value`.\n\nThe targets from each parent Gateway matching the \\*Route are then combined and de-duplicated.\n\n## Dualstack Routes\n\nGateway resources may be served from an external-loadbalancer which may support\nboth IPv4 and \"dualstack\" (both IPv4 and IPv6) interfaces. When using the AWS\nRoute53 provider, External DNS Controller will always create both A and AAAA\nalias DNS records by default, regardless of whether the load balancer is dual\nstack or not.\n\n## Example\n\n```yaml\napiVersion: gateway.networking.k8s.io/v1\nkind: HTTPRoute\nmetadata:\n  name: echo\nspec:\n  hostnames:\n    - echoserver.example.org\n  rules:\n    - backendRefs:\n        - group: \"\"\n          kind: Service\n          name: echo\n          port: 1027\n          weight: 1\n      matches:\n        - path:\n            type: PathPrefix\n            value: /echo\n```\n"
  },
  {
    "path": "docs/sources/gloo-proxy.md",
    "content": "# Gloo Proxy Source\n\nThis tutorial describes how to configure ExternalDNS to use the Gloo Proxy source.\nIt is meant to supplement the other provider-specific setup tutorials.\n\n## Manifest (for clusters without RBAC enabled)\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      containers:\n      - name: external-dns\n        # update this to the desired external-dns version\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=gloo-proxy\n        - --gloo-namespace=custom-gloo-system # gloo system namespace. Specify multiple times for multiple namespaces. Omit to use the default (gloo-system)\n        - --provider=aws\n        - --registry=txt\n        - --txt-owner-id=my-identifier\n```\n\n## Manifest (for clusters with RBAC enabled)\n\nCould be change if you have mulitple sources\n\n```yaml\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: external-dns\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: external-dns\nrules:\n- apiGroups: [\"\"]\n  resources: [\"services\",\"pods\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"discovery.k8s.io\"]\n  resources: [\"endpointslices\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"\"]\n  resources: [\"nodes\"]\n  verbs: [\"list\",\"watch\"]\n- apiGroups: [\"gloo.solo.io\"]\n  resources: [\"proxies\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"gateway.solo.io\"]\n  resources: [\"virtualservices\"]\n  verbs: [\"get\", \"list\", \"watch\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: external-dns-viewer\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: external-dns\nsubjects:\n- kind: ServiceAccount\n  name: external-dns\n  namespace: default\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      serviceAccountName: external-dns\n      containers:\n      - name: external-dns\n        # update this to the desired external-dns version\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=gloo-proxy\n        - --gloo-namespace=custom-gloo-system # gloo system namespace. Specify multiple times for multiple namespaces. Omit to use the default (gloo-system)\n        - --provider=aws\n        - --registry=txt\n        - --txt-owner-id=my-identifier\n```\n\n## Gateway Annotation\n\nTo support setups where an Ingress resource is used to provision an external LB you can add the following annotation to your Gateway\n\n**Note:** The Ingress namespace can be omitted if its in the same namespace as the gateway\n\n```bash\n$ cat <<EOF | kubectl apply -f -\napiVersion: gloo.solo.io/v1\nkind: Proxy\nmetadata:\n  labels:\n    created_by: gloo-gateway\n  name: gateway-proxy\n  namespace: gloo-system\nspec:\n  listeners:\n  - bindAddress: '::'\n    metadataStatic:\n      sources:\n      - resourceKind: '*v1.Gateway'\n        resourceRef:\n          name: gateway-proxy\n          namespace: gloo-system\n---\napiVersion: gateway.solo.io/v1\nkind: Gateway\nmetadata:\n  annotations:\n    external-dns.alpha.kubernetes.io/ingress: \"$ingressNamespace/$ingressName\"\n  labels:\n    app: gloo\n  name: gateway-proxy\n  namespace: gloo-system\nspec: {}\n---\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  labels:\n    gateway-proxy-id: gateway-proxy\n    gloo: gateway-proxy\n  name: gateway-proxy\n  namespace: gloo-system\nspec:\n  ingressClassName: alb\nEOF\n```\n"
  },
  {
    "path": "docs/sources/index.md",
    "content": "---\ntags:\n  - sources\n  - autogenerated\n---\n\n# Supported Sources\n\n<!-- THIS FILE MUST NOT BE EDITED BY HAND -->\n<!-- ON NEW SOURCE ADDED PLEASE RUN 'make generate-sources-documentation' -->\n<!-- markdownlint-disable MD013 -->\n\nExternalDNS supports multiple sources for discovering DNS records. Each source watches specific Kubernetes or cloud platform resources and generates DNS records based on their configuration.\n\n## Overview\n\nSources are responsible for:\n\n- Watching Kubernetes resources or external APIs\n- Extracting DNS information from annotations and resource specifications\n- Generating DNS endpoint records for providers to consume\n\n## Available Sources\n\n| **Source Name**          | Filters          | Namespace  | FQDN Template | Events | Provider Specific | Category            | Resources                                                                             |\n|:-------------------------|:-----------------|:-----------|:--------------|:-------|:------------------|:--------------------|:--------------------------------------------------------------------------------------|\n| **ambassador-host**      | annotation,label | all,single | false         | false  | true              | ingress controllers | Host.getambassador.io                                                                 |\n| **connector**            |                  |            | false         | false  | false             | special             | Remote TCP Server                                                                     |\n| **contour-httpproxy**    | annotation       | all,single | true          | false  | true              | ingress controllers | HTTPProxy.projectcontour.io                                                           |\n| **crd**                  | annotation,label | all,single | false         | true   | true              | externaldns         | DNSEndpoint.externaldns.k8s.io                                                        |\n| **empty**                |                  |            | false         | false  | false             | testing             | None                                                                                  |\n| **f5-transportserver**   | annotation       | all,single | false         | false  | false             | load balancers      | TransportServer.cis.f5.com                                                            |\n| **f5-virtualserver**     | annotation       | all,single | false         | false  | false             | load balancers      | VirtualServer.cis.f5.com                                                              |\n| **fake**                 |                  |            | true          | true   | false             | testing             | Fake Endpoints                                                                        |\n| **gateway-grpcroute**    | annotation,label | all,single | true          | false  | true              | gateway api         | GRPCRoute.gateway.networking.k8s.io                                                   |\n| **gateway-httproute**    | annotation,label | all,single | true          | false  | true              | gateway api         | HTTPRoute.gateway.networking.k8s.io                                                   |\n| **gateway-tcproute**     | annotation,label | all,single | true          | false  | true              | gateway api         | TCPRoute.gateway.networking.k8s.io                                                    |\n| **gateway-tlsroute**     | annotation,label | all,single | true          | false  | true              | gateway api         | TLSRoute.gateway.networking.k8s.io                                                    |\n| **gateway-udproute**     | annotation,label | all,single | true          | false  | true              | gateway api         | UDPRoute.gateway.networking.k8s.io                                                    |\n| **gloo-proxy**           |                  | all,single | false         | false  | true              | service mesh        | Proxy.gloo.solo.io                                                                    |\n| **ingress**              | annotation,label | all,single | true          | true   | true              | kubernetes core     | Ingress                                                                               |\n| **istio-gateway**        | annotation       | all,single | true          | false  | true              | service mesh        | Gateway.networking.istio.io                                                           |\n| **istio-virtualservice** | annotation       | all,single | true          | false  | true              | service mesh        | VirtualService.networking.istio.io                                                    |\n| **kong-tcpingress**      | annotation       | all,single | false         | false  | true              | ingress controllers | TCPIngress.configuration.konghq.com                                                   |\n| **node**                 | annotation,label | all        | true          | true   | false             | kubernetes core     | Node                                                                                  |\n| **openshift-route**      | annotation,label | all,single | true          | false  | true              | openshift           | Route.route.openshift.io                                                              |\n| **pod**                  | annotation,label | all,single | true          | true   | false             | kubernetes core     | Pod                                                                                   |\n| **service**              | annotation,label | all,single | true          | true   | true              | kubernetes core     | Service                                                                               |\n| **skipper-routegroup**   | annotation       | all,single | true          | false  | true              | ingress controllers | RouteGroup.zalando.org                                                                |\n| **traefik-proxy**        | annotation       | all,single | false         | false  | true              | ingress controllers | IngressRoute.traefik.io<br/>IngressRouteTCP.traefik.io<br/>IngressRouteUDP.traefik.io |\n| **unstructured**         | annotation,label | all,single | true          | false  | false             | custom resources    | Unstructured                                                                          |\n\n## Usage\n\nTo use a specific source, configure ExternalDNS with the `--source` flag:\n\n```bash\nexternal-dns --source=service --source=ingress\n```\n\nMultiple sources can be combined to watch different resource types simultaneously.\n\n## Source Categories\n\n- **Kubernetes Core**: Native Kubernetes resources (Service, Ingress, Pod, Node)\n- **ExternalDNS**: Native ExternalDNS resources\n- **Gateway API**: Kubernetes Gateway API resources (Gateway, HTTPRoute, etc.)\n- **Service Mesh**: Service mesh implementations (Istio, Gloo)\n- **Ingress Controllers**: Third-party ingress controller resources (Contour, Traefik, Ambassador, etc.)\n- **Load Balancers**: Load balancer specific resources (F5)\n- **OpenShift**: OpenShift specific resources (Route)\n- **Cloud Platforms**: Cloud platform integrations (Cloud Foundry)\n- **Wrappers**: Source wrappers that modify or combine other sources\n- **Special**: Special purpose sources (connector, empty)\n- **Testing**: Sources used for testing purposes\n"
  },
  {
    "path": "docs/sources/ingress.md",
    "content": "# Ingress source\n\nThe ingress source creates DNS entries based on `Ingress.networking.k8s.io` resources.\n\n## Filtering the Ingresses considered\n\nThe `--ingress-class` flag filters Ingress resources by a set of ingress classes.\nThe flag may be specified multiple times in order to\nallow multiple ingress classes.\n\nThis source supports the `--label-filter` flag, which filters Ingress resources\nby a set of labels.\n\n## Domain names\n\nThe domain names of the DNS entries created from an Ingress are sourced from the following places:\n\n1. Iterates over the Ingress's `spec.rules`, adding any non-empty `host`.\n\n  This behavior is suppressed if the `--ignore-ingress-rules-spec` flag was specified\nor the Ingress had an\n`external-dns.alpha.kubernetes.io/ingress-hostname-source: annotation-only` annotation.\n\n2. Iterates over the Ingress's `spec.tls`, adding each member of `hosts`.\n\n  This behavior is suppressed if the `--ignore-ingress-tls-spec` flag was specified\nor the Ingress had an\n`external-dns.alpha.kubernetes.io/ingress-hostname-source: annotation-only` annotation,\n\n3. Adds the hostnames from any `external-dns.alpha.kubernetes.io/hostname` annotation.\n\n  This behavior is suppressed if the `--ignore-hostname-annotation` flag was specified\nor the Ingress had an\n`external-dns.alpha.kubernetes.io/ingress-hostname-source: defined-hosts-only` annotation.\n\n4. If no DNS entries were produced for an Ingress by the previous steps\nor the `--combine-fqdn-annotation` flag was specified, then adds hostnames\ngenerated from any`--fqdn-template` flag.\n\n## Targets\n\nThe targets of the DNS entries created from an Ingress are sourced from the following places:\n\n1. If the Ingress has an `external-dns.alpha.kubernetes.io/target` annotation, uses\nthe values from that.\n\n2. Otherwise, iterates over the Ingress's `status.loadBalancer.ingress`,\nadding each non-empty `ip` and `hostname`.\n"
  },
  {
    "path": "docs/sources/istio.md",
    "content": "# Istio Gateway / Virtual Service Source\n\nThis tutorial describes how to configure ExternalDNS to use the Istio Gateway source.\nIt is meant to supplement the other provider-specific setup tutorials.\n\n**Note:** Using the Istio Gateway source requires Istio >=1.0.0.\n\n**Note:** Currently supported versions are `1.25` and `1.26` with `v1beta1` stored version.\n\n- [Support status of Istio releases](https://istio.io/latest/docs/releases/supported-releases/)\n\n- Manifest (for clusters without RBAC enabled)\n- Manifest (for clusters with RBAC enabled)\n- Update existing ExternalDNS Deployment\n\n## Manifest (for clusters without RBAC enabled)\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=service\n        - --source=ingress\n        - --source=istio-gateway        # choose one\n        - --source=istio-virtualservice # or both\n        - --domain-filter=external-dns-test.my-org.com # will make ExternalDNS see only the hosted zones matching provided domain, omit to process all available hosted zones\n        - --provider=aws\n        - --policy=upsert-only # would prevent ExternalDNS from deleting any records, omit to enable full synchronization\n        - --aws-zone-type=public # only look at public hosted zones (valid values are public, private or no value for both)\n        - --registry=txt\n        - --txt-owner-id=my-identifier\n```\n\n## Manifest (for clusters with RBAC enabled)\n\n```yaml\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: external-dns\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: external-dns\nrules:\n- apiGroups: [\"\"]\n  resources: [\"services\",\"pods\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"discovery.k8s.io\"]\n  resources: [\"endpointslices\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"extensions\",\"networking.k8s.io\"]\n  resources: [\"ingresses\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"\"]\n  resources: [\"nodes\"]\n  verbs: [\"list\"]\n- apiGroups: [\"networking.istio.io\"]\n  resources: [\"gateways\", \"virtualservices\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: external-dns-viewer\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: external-dns\nsubjects:\n- kind: ServiceAccount\n  name: external-dns\n  namespace: default\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      serviceAccountName: external-dns\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=service\n        - --source=ingress\n        - --source=istio-gateway\n        - --source=istio-virtualservice\n        - --domain-filter=external-dns-test.my-org.com # will make ExternalDNS see only the hosted zones matching provided domain, omit to process all available hosted zones\n        - --provider=aws\n        - --policy=upsert-only # would prevent ExternalDNS from deleting any records, omit to enable full synchronization\n        - --aws-zone-type=public # only look at public hosted zones (valid values are public, private or no value for both)\n        - --registry=txt\n        - --txt-owner-id=my-identifier\n```\n\n## Update existing ExternalDNS Deployment\n\n- For clusters with running `external-dns`, you can just update the deployment.\n- With access to the `kube-system` namespace, update the existing `external-dns` deployment.\n  - Add a parameter to the arguments of the container to create dns entries with `--source=istio-gateway`.\n\nExecute the following command or update the argument.\n\n```console\nkubectl patch deployment external-dns --type='json' \\\n  -p='[{\"op\": \"add\", \"path\": \"/spec/template/spec/containers/0/args/2\", \"value\": \"--source=istio-gateway\" }]'\n```\n\nIn case the setup uses a `clusterrole`, just append a new value to the enable the istio group.\n\n```console\nkubectl patch clusterrole external-dns --type='json' \\\n  -p='[{\"op\": \"add\", \"path\": \"/rules/4\", \"value\": { \"apiGroups\": [ \"networking.istio.io\"], \"resources\": [\"gateways\"],\"verbs\": [\"get\", \"watch\", \"list\" ]} }]'\n```\n\n## Verify that Istio Gateway/VirtualService Source works\n\nFollow the [Istio ingress traffic tutorial](https://istio.io/docs/tasks/traffic-management/ingress/)\nto deploy a sample service that will be exposed outside of the service mesh.\nThe following are relevant snippets from that tutorial.\n\n### Install a sample service\n\nWith automatic sidecar injection:\n\n```bash\nkubectl apply -f https://raw.githubusercontent.com/istio/istio/release-1.25/samples/httpbin/httpbin.yaml\n```\n\nOtherwise:\n\n```bash\nkubectl apply -f <(istioctl kube-inject -f https://raw.githubusercontent.com/istio/istio/release-1.25/samples/httpbin/httpbin.yaml)\n```\n\n### Using a Gateway as a source\n\n#### Create an Istio Gateway\n\n```bash\n$ cat <<EOF | kubectl apply -f -\napiVersion: networking.istio.io/v1alpha3\nkind: Gateway\nmetadata:\n  name: httpbin-gateway\n  namespace: istio-system\nspec:\n  selector:\n    istio: ingressgateway # use Istio default gateway implementation\n  servers:\n  - port:\n      number: 80\n      name: http\n      protocol: HTTP\n    hosts:\n    - \"httpbin.example.com\" # this is used by external-dns to extract DNS names\nEOF\n```\n\n#### Configure routes for traffic entering via the Gateway\n\n```bash\n$ cat <<EOF | kubectl apply -f -\napiVersion: networking.istio.io/v1alpha3\nkind: VirtualService\nmetadata:\n  name: httpbin\nspec:\n  hosts:\n  - \"httpbin.example.com\"\n  gateways:\n  - istio-system/httpbin-gateway\n  http:\n  - match:\n    - uri:\n        prefix: /status\n    - uri:\n        prefix: /delay\n    route:\n    - destination:\n        port:\n          number: 8000\n        host: httpbin\nEOF\n```\n\n### Using a VirtualService as a source\n\n#### Create an Istio Gateway\n\n```bash\n$ cat <<EOF | kubectl apply -f -\napiVersion: networking.istio.io/v1alpha3\nkind: Gateway\nmetadata:\n  name: httpbin-gateway\n  namespace: istio-system\nspec:\n  selector:\n    istio: ingressgateway # use Istio default gateway implementation\n  servers:\n  - port:\n      number: 80\n      name: http\n      protocol: HTTP\n    hosts:\n    - \"*\"\nEOF\n```\n\n#### Configure routes for traffic entering via the Gateway\n\n```bash\n$ cat <<EOF | kubectl apply -f -\napiVersion: networking.istio.io/v1alpha3\nkind: VirtualService\nmetadata:\n  name: httpbin\nspec:\n  hosts:\n  - \"httpbin.example.com\" # this is used by external-dns to extract DNS names\n  gateways:\n  - istio-system/httpbin-gateway\n  http:\n  - match:\n    - uri:\n        prefix: /status\n    - uri:\n        prefix: /delay\n    route:\n    - destination:\n        port:\n          number: 8000\n        host: httpbin\nEOF\n```\n\nTo get the targets to the extracted DNS names, external-dns is able to gather information from the kubernetes service of the Istio Ingress Gateway.\nPlease take a look at the [source service documentation](../sources/service.md) for more information on this.\n\nIt is also possible to set the targets manually by using the `external-dns.alpha.kubernetes.io/target` annotation on the Istio Ingress Gateway resource or the Istio VirtualService.\n\n### Access the sample service using `curl`\n\n```bash\n$ curl -I http://httpbin.example.com/status/200\nHTTP/1.1 200 OK\nserver: envoy\ndate: Tue, 28 Aug 2018 15:26:47 GMT\ncontent-type: text/html; charset=utf-8\naccess-control-allow-origin: *\naccess-control-allow-credentials: true\ncontent-length: 0\nx-envoy-upstream-service-time: 5\n```\n\nAccessing any other URL that has not been explicitly exposed should return an HTTP 404 error:\n\n```bash\n$ curl -I http://httpbin.example.com/headers\nHTTP/1.1 404 Not Found\ndate: Tue, 28 Aug 2018 15:27:48 GMT\nserver: envoy\ntransfer-encoding: chunked\n```\n\n**Note:** The `-H` flag in the original Istio tutorial is no longer necessary in the `curl` commands.\n\n## Optional Gateway Annotation\n\nTo support setups where an Ingress resource is used provision an external LB you can add the following annotation to your Gateway\n\n**Note:** The Ingress namespace can be omitted if its in the same namespace as the gateway\n\n```bash\n$ cat <<EOF | kubectl apply -f -\napiVersion: networking.istio.io/v1alpha3\nkind: Gateway\nmetadata:\n  name: httpbin-gateway\n  namespace: istio-system\n  annotations:\n    \"external-dns.alpha.kubernetes.io/ingress\": \"$ingressNamespace/$ingressName\"\nspec:\n  selector:\n    istio: ingressgateway # use Istio default gateway implementation\n  servers:\n  - port:\n      number: 80\n      name: http\n      protocol: HTTP\n    hosts:\n    - \"*\"\nEOF\n```\n\n## Debug ExternalDNS\n\n- Look for the deployment pod to see the status\n\n```console$ kubectl get pods | grep external-dns\nexternal-dns-6b84999479-4knv9     1/1     Running   0   3h29m\n```\n\n- Watch for the logs as follows\n\n```console\nkubectl logs -f external-dns-6b84999479-4knv9\n```\n\nAt this point, you can `create` or `update` any `Istio Gateway` object with `hosts` entries array.\n\n> **ATTENTION**: Make sure to specify those whose account is related to the DNS record.\n\n- Successful executions will print the following\n\n```console\ntime=\"2020-01-17T06:08:08Z\" level=info msg=\"Desired change: CREATE httpbin.example.com A\"\ntime=\"2020-01-17T06:08:08Z\" level=info msg=\"Desired change: CREATE httpbin.example.com TXT\"\ntime=\"2020-01-17T06:08:08Z\" level=info msg=\"2 record(s) in zone example.com. were successfully updated\"\ntime=\"2020-01-17T06:09:08Z\" level=info msg=\"All records are already up to date, there are no changes for the matching hosted zones\"\n```\n\n- If there's any problem around `clusterrole`, you would see the errors showing wrong permissions:\n\n```console\nsource \\\"gateways\\\" in API group \\\"networking.istio.io\\\" at the cluster scope\"\ntime=\"2020-01-17T06:07:08Z\" level=error msg=\"gateways.networking.istio.io is forbidden: User \\\"system:serviceaccount:kube-system:external-dns\\\" cannot list resource \\\"gateways\\\" in API group \\\"networking.istio.io\\\" at the cluster scope\"\n```\n"
  },
  {
    "path": "docs/sources/kong.md",
    "content": "# Kong TCPIngress Source\n\nThis tutorial describes how to configure ExternalDNS to use the Kong TCPIngress source.\nIt is meant to supplement the other provider-specific setup tutorials.\n\n## Manifest (for clusters without RBAC enabled)\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      containers:\n      - name: external-dns\n        # update this to the desired external-dns version\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=kong-tcpingress\n        - --provider=aws\n        - --registry=txt\n        - --txt-owner-id=my-identifier\n```\n\n## Manifest (for clusters with RBAC enabled)\n\nCould be changed if you have mulitple sources\n\n```yaml\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: external-dns\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: external-dns\nrules:\n- apiGroups: [\"\"]\n  resources: [\"services\",\"pods\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"discovery.k8s.io\"]\n  resources: [\"endpointslices\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"\"]\n  resources: [\"nodes\"]\n  verbs: [\"list\",\"watch\"]\n- apiGroups: [\"configuration.konghq.com\"]\n  resources: [\"tcpingresses\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: external-dns-viewer\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: external-dns\nsubjects:\n- kind: ServiceAccount\n  name: external-dns\n  namespace: default\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      serviceAccountName: external-dns\n      containers:\n      - name: external-dns\n        # update this to the desired external-dns version\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=kong-tcpingress\n        - --provider=aws\n        - --registry=txt\n        - --txt-owner-id=my-identifier\n```\n"
  },
  {
    "path": "docs/sources/mx-record.md",
    "content": "# MX record with CRD source\n\nYou can create and manage MX records with the help of [CRD source](../sources/crd.md)\nand `DNSEndpoint` CRD. Currently, this feature is only supported by `aws`, `azure`, `cloudflare`, `google` and `webhook` providers.\n\nIn order to start managing MX records you need to set the `--managed-record-types=MX` flag.\n\n```console\nexternal-dns --source crd --provider {aws|azure|google} --managed-record-types=A --managed-record-types=CNAME --managed-record-types=MX\n```\n\nTargets within the CRD need to be specified according to the RFC 1034 (section 3.6.1). Below is an example of\n`example.com` DNS MX record which specifies two separate targets with distinct priorities.\n\n```yaml\napiVersion: externaldns.k8s.io/v1alpha1\nkind: DNSEndpoint\nmetadata:\n  name: examplemxrecord\nspec:\n  endpoints:\n    - dnsName: example.com\n      recordTTL: 180\n      recordType: MX\n      targets:\n        - 10 mailhost1.example.com\n        - 20 mailhost2.example.com\n```\n"
  },
  {
    "path": "docs/sources/nodes.md",
    "content": "# Cluster Nodes as Source\n\nThis tutorial describes how to configure ExternalDNS to use the cluster nodes as source.\nUsing nodes (`--source=node`) as source is possible to synchronize a DNS zone with the nodes of a cluster.\n\nThe node source adds an `A` record per each node `externalIP` (if not found, any IPv4 `internalIP` is used instead).\nIt also adds an `AAAA` record per each node IPv6 `internalIP`. Refer to the [IPv6 Behavior](#ipv6-behavior) section for more details.\nThe TTL of the records can be set with the `external-dns.alpha.kubernetes.io/ttl` node annotation.\n\nNodes marked as **Unschedulable** as per [core/v1/NodeSpec](https://pkg.go.dev/k8s.io/api@v0.31.1/core/v1#NodeSpec) are excluded by default.\nAs such, no DNS records are created for Unhealthy, NotReady or SchedulingDisabled (cordon) nodes (and existing ones are removed).\nIn case you want to override the default, for example if you manage per-host DNS records via ExternalDNS, you can specify `--no-exclude-unschedulable` to always expose nodes no matter their status.\n\n## IPv6 Behavior\n\nBy default, ExternalDNS exposes the IPv6 `ExternalIP` of the nodes.\nIf needed, one can still explicitly expose the internal ipv6 addresses by using the `--expose-internal-ipv6` flag.\n\n### Example spec\n\n```yaml\nspec:\n  serviceAccountName: external-dns\n  containers:\n  - name: external-dns\n    image: registry.k8s.io/external-dns/external-dns:v0.20.0 # update this to the desired external-dns version\n    args:\n    - --source=node # will use nodes as source\n    - --provider=aws\n    - --zone-name-filter=external-dns-test.my-org.com # will make ExternalDNS see only the hosted zones matching provided domain, omit to process all available hosted zones\n    - --domain-filter=external-dns-test.my-org.com\n    - --aws-zone-type=public\n    - --registry=txt\n    - --fqdn-template={{.Name}}.external-dns-test.my-org.com\n    - --txt-owner-id=my-identifier\n    - --policy=sync\n    - --log-level=debug\n```\n\n## Manifest (for cluster without RBAC enabled)\n\n```yaml\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      serviceAccountName: external-dns\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=node # will use nodes as source\n        - --provider=aws\n        - --zone-name-filter=external-dns-test.my-org.com # will make ExternalDNS see only the hosted zones matching provided domain, omit to process all available hosted zones\n        - --domain-filter=external-dns-test.my-org.com\n        - --aws-zone-type=public\n        - --registry=txt\n        - --fqdn-template={{.Name}}.external-dns-test.my-org.com\n        - --txt-owner-id=my-identifier\n        - --policy=sync\n        - --log-level=debug\n```\n\n## Manifest (for cluster with RBAC enabled)\n\n```yaml\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: external-dns\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: external-dns\nrules:\n- apiGroups: [\"route.openshift.io\"]\n  resources: [\"routes\"]\n  verbs: [\"get\", \"watch\", \"list\"]\n- apiGroups: [\"\"]\n  resources: [\"services\",\"pods\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"discovery.k8s.io\"]\n  resources: [\"endpointslices\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"extensions\",\"networking.k8s.io\"]\n  resources: [\"ingresses\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"\"]\n  resources: [\"nodes\"]\n  verbs: [\"get\", \"watch\", \"list\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: external-dns-viewer\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: external-dns\nsubjects:\n- kind: ServiceAccount\n  name: external-dns\n  namespace: external-dns\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      serviceAccountName: external-dns\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=node # will use nodes as source\n        - --provider=aws\n        - --zone-name-filter=external-dns-test.my-org.com # will make ExternalDNS see only the hosted zones matching provided domain, omit to process all available hosted zones\n        - --domain-filter=external-dns-test.my-org.com\n        - --aws-zone-type=public\n        - --registry=txt\n        - --fqdn-template={{.Name}}.external-dns-test.my-org.com\n        - --txt-owner-id=my-identifier\n        - --policy=sync\n        - --log-level=debug\n```\n"
  },
  {
    "path": "docs/sources/ns-record.md",
    "content": "# NS record with CRD source\n\nYou can create NS records with the help of [CRD source](../sources/crd.md)\nand `DNSEndpoint` CRD.\n\nIn order to start managing NS records you need to set the `--managed-record-types=NS` flag.\n\n```console\nexternal-dns --source crd --managed-record-types=A --managed-record-types=CNAME --managed-record-types=NS\n```\n\nConsider the following example\n\n```yaml\napiVersion: externaldns.k8s.io/v1alpha1\nkind: DNSEndpoint\nmetadata:\n  name: ns-record\nspec:\n  endpoints:\n  - dnsName: zone.example.com\n    recordTTL: 300\n    recordType: NS\n    targets:\n    - ns1.example.com\n    - ns2.example.com\n```\n\nAfter instantiation of this Custom Resource external-dns will create NS record with the help of configured provider, e.g. `aws`\n"
  },
  {
    "path": "docs/sources/openshift.md",
    "content": "# OpenShift Route Source\n\nThis tutorial describes how to configure ExternalDNS to use the OpenShift Route source.\nIt is meant to supplement the other provider-specific setup tutorials.\n\n## For OCP 4.x\n\nIn OCP 4.x, if you have multiple [OpenShift ingress controllers](https://docs.openshift.com/container-platform/4.9/networking/ingress-operator.html) then you must specify an ingress controller name (also called router name), you can get it from the route's `status.ingress[*].routerName` field.\nIf you don't specify a router name when you have multiple ingress controllers in your cluster then the first router from the route's `status.ingress` will be used. Note that the router must have admitted the route in order to be selected.\nOnce the router is known, ExternalDNS will use this router's canonical hostname as the target for the CNAME record.\n\nStarting from OCP 4.10 you can use [ExternalDNS Operator](https://github.com/openshift/external-dns-operator) to manage ExternalDNS instances. Example of its custom resource for AWS provider:\n\n```yaml\n  apiVersion: externaldns.olm.openshift.io/v1alpha1\n  kind: ExternalDNS\n  metadata:\n    name: sample\n  spec:\n    provider:\n      type: AWS\n    source:\n      openshiftRouteOptions:\n        routerName: default\n      type: OpenShiftRoute\n    zones:\n      - Z05387772BD5723IZFRX3\n```\n\nThis will create an ExternalDNS POD with the following container args in `external-dns` namespace:\n\n```yaml\nspec:\n  containers:\n  - args:\n    - --metrics-address=127.0.0.1:7979\n    - --txt-owner-id=external-dns-sample\n    - --provider=aws\n    - --source=openshift-route\n    - --policy=sync\n    - --registry=txt\n    - --log-level=debug\n    - --zone-id-filter=Z05387772BD5723IZFRX3\n    - --openshift-router-name=default\n    - --txt-prefix=external-dns-\n```\n\n## For OCP 3.11 environment\n\n### Prepare ROUTER_CANONICAL_HOSTNAME in default/router deployment\n\nRead and go through [Finding the Host Name of the Router](https://docs.openshift.com/container-platform/3.11/install_config/router/default_haproxy_router.html#finding-router-hostname).\nIf no ROUTER_CANONICAL_HOSTNAME is set, you must annotate each route with external-dns.alpha.kubernetes.io/target!\n\n### Manifest (for clusters without RBAC enabled)\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=openshift-route\n        - --domain-filter=external-dns-test.my-org.com # will make ExternalDNS see only the hosted zones matching provided domain, omit to process all available hosted zones\n        - --provider=aws\n        - --policy=upsert-only # would prevent ExternalDNS from deleting any records, omit to enable full synchronization\n        - --aws-zone-type=public # only look at public hosted zones (valid values are public, private or no value for both)\n        - --registry=txt\n        - --txt-owner-id=my-identifier\n```\n\n### Manifest (for clusters with RBAC enabled)\n\n```yaml\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: external-dns\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: external-dns\nrules:\n- apiGroups: [\"\"]\n  resources: [\"services\",\"pods\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"discovery.k8s.io\"]\n  resources: [\"endpointslices\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"extensions\",\"networking.k8s.io\"]\n  resources: [\"ingresses\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"\"]\n  resources: [\"nodes\"]\n  verbs: [\"list\"]\n- apiGroups: [\"route.openshift.io\"]\n  resources: [\"routes\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: external-dns-viewer\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: external-dns\nsubjects:\n- kind: ServiceAccount\n  name: external-dns\n  namespace: default\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      serviceAccountName: external-dns\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=openshift-route\n        - --domain-filter=external-dns-test.my-org.com # will make ExternalDNS see only the hosted zones matching provided domain, omit to process all available hosted zones\n        - --provider=aws\n        - --policy=upsert-only # would prevent ExternalDNS from deleting any records, omit to enable full synchronization\n        - --aws-zone-type=public # only look at public hosted zones (valid values are public, private or no value for both)\n        - --registry=txt\n        - --txt-owner-id=my-identifier\n```\n\n### Verify External DNS works (OpenShift Route example)\n\nThe following instructions are based on the\n[Hello Openshift](https://github.com/openshift/origin/tree/HEAD/examples/hello-openshift).\n\n#### Install a sample service and expose it\n\n```bash\n$ oc apply -f - <<EOF\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  labels:\n    app: hello-openshift\n  name: hello-openshift\nspec:\n  replicas: 1\n  selector:\n    matchLabels:\n      app: hello-openshift\n  template:\n    metadata:\n      labels:\n        app: hello-openshift\n    spec:\n      containers:\n      - image: openshift/hello-openshift\n        name: hello-openshift\n---\napiVersion: v1\nkind: Service\nmetadata:\n  labels:\n    app: hello-openshift\n  name: hello-openshift\nspec:\n  ports:\n  - port: 8080\n    protocol: TCP\n    targetPort: 8080\n  selector:\n    app: hello-openshift\n  sessionAffinity: None\n  type: ClusterIP\n---\napiVersion: route.openshift.io/v1\nkind: Route\nmetadata:\n  name: hello-openshift\nspec:\n  host: hello-openshift.example.com\n  to:\n    kind: Service\n    name: hello-openshift\n    weight: 100\n  wildcardPolicy: None\nEOF\n```\n\n#### Access the sample route using `curl`\n\n```bash\n$ curl -i http://hello-openshift.example.com\nHTTP/1.1 200 OK\nDate: Fri, 10 Apr 2020 09:36:41 GMT\nContent-Length: 17\nContent-Type: text/plain; charset=utf-8\n\nHello OpenShift!\n```\n"
  },
  {
    "path": "docs/sources/pod.md",
    "content": "# Pod Source\n\nThe pod source creates DNS entries based on `Pod` resources.\n\n## Pods not running with host networking\n\nBy default, the pod source will consider the pods that aren't running with host networking enabled. You can override this behavior by using the `--ignore-non-host-network-pods` option to ignore non host networking pods.\n\n## Using a default domain for pods\n\nBy default, the pod source will look into the pod annotations to find the FQDN associated with a pod. You can also use the option `--pod-source-domain=example.org` to build the FQDN of the pods. The pod named \"test-pod\" will then be registered as \"test-pod.example.org\".\n\n## Configuration for registering all pods with their associated PTR record\n\nA use case where combining these options can be pertinent is when you are running on-premise Kubernetes clusters without SNAT enabled for the pod network.\nYou might want to register all the pods in the DNS with their associated PTR record so that the source of some traffic outside of the cluster can be rapidly associated with a workload using the \"nslookup\" or \"dig\" command on the pod IP.\nThis can be particularly useful if you are running a large number of Kubernetes clusters.\n\nYou will then use the following mix of options:\n\n- `--domain-filter=example.org`\n- `--domain-filter=10.0.0.in-addr.arpa`\n- `--source=pod`\n- `--pod-source-domain=example.org`\n- `--rfc2136-create-ptr`\n- `--rfc2136-zone=example.org`\n- `--rfc2136-zone=10.0.0.in-addr.arpa`\n"
  },
  {
    "path": "docs/sources/service.md",
    "content": "# Service source\n\nThe service source creates DNS entries based on `Service` resources.\n\n## Filtering the Services considered\n\nThe `--service-type-filter` flag filters Service resources by their `spec.type`.\nThe flag may be specified multiple times to allow multiple service types.\n\nThis source supports the `--label-filter` flag, which filters Service resources\nby a set of labels.\n\n## Domain names\n\nThe domain names of the DNS entries created from a Service are sourced from the following places:\n\n1. Adds the domain names from any `external-dns.alpha.kubernetes.io/hostname` and/or\n`external-dns.alpha.kubernetes.io/internal-hostname` annotation.\nThis behavior is suppressed if the `--ignore-hostname-annotation` flag was specified.\n\n2. If no DNS entries were produced for a Service by the previous steps\nand the `--compatibility` flag was specified, then adds DNS entries per the\nselected compatibility mode.\n\n3. If no DNS entries were produced for a Service by the previous steps\nor the `--combine-fqdn-annotation` flag was specified, then adds domain names\ngenerated from any`--fqdn-template` flag.\n\n### Domain names for headless service pods\n\nIf a headless Service (without an `external-dns.alpha.kubernetes.io/target` annotation) creates DNS entries with targets from\na Pod that has a non-empty `spec.hostname` field, additional DNS entries are created for that Pod, containing the targets from that Pod.\nFor each domain name created for the Service, the additional DNS entry for the Pod has that domain name prefixed with\nthe value of the Pod's `spec.hostname` field and a `.`.\n\n## Targets\n\nIf the Service has an `external-dns.alpha.kubernetes.io/target` annotation, uses\nthe values from that. Otherwise, the targets of the DNS entries created from a service are sourced depending\non the Service's `spec.type`:\n\n### LoadBalancer\n\n1. If the hostname came from an `external-dns.alpha.kubernetes.io/internal-hostname` annotation, uses\nthe Service's `spec.clusterIP` field. If that field has the value `None`, does not generate\nany targets for the hostname.\n\n2. Otherwise, if the Service has one or more `spec.externalIPs`, uses the values in that field.\n\n3. Otherwise, iterates over each `status.loadBalancer.ingress`, adding any non-empty `ip` and/or `hostname`.\n\nIf the `--resolve-service-load-balancer-hostname` flag was specified, any non-empty `hostname`\nis queried through DNS and any resulting IP addresses are added instead.\nA DNS query failure results in zero targets being added for that load balancer's ingress hostname.\n\n### ClusterIP (headless)\n\nIterates over all of the Service's Endpoints's `subsets.addresses`.\nIf the Service's `spec.publishNotReadyAddresses` is `true` or the `--always-publish-not-ready-addresses` flag is specified,\nalso iterates over the Endpoints's `subsets.notReadyAddresses`.\n\n1. If an address does not target a `Pod` that matches the Service's `spec.selector`, it is ignored.\n\n2. If the target pod has an `external-dns.alpha.kubernetes.io/target` annotation, uses\nthe values from that.\n\n3. Otherwise, if the Service has an `external-dns.alpha.kubernetes.io/endpoints-type: NodeExternalIP`\nannotation, uses the addresses from the Pod's Node's `status.addresses` that are either of type\n`ExternalIP` or IPv6 addresses of type `InternalIP`.\n\n4. Otherwise, if the Service has an `external-dns.alpha.kubernetes.io/endpoints-type: HostIP` annotation\nor the `--publish-host-ip` flag was specified, uses the Pod's `status.hostIP` field.\n\n5. Otherwise uses the `ip` field of the address from the Endpoints.\n\n### ClusterIP (not headless)\n\n1. If the hostname came from an `external-dns.alpha.kubernetes.io/internal-hostname` annotation\nor the `--publish-internal-services` flag was specified, uses the `spec.ClusterIP`.\n\n2. Otherwise, does not create any targets.\n\n### NodePort\n\nIf `spec.ExternalTrafficPolicy` is `Local`, selects Nodes that have at least one pod matching the Service's\n`spec.selector` with a pod `status.phase` of `Running`. Nodes are selected from the highest-priority tier available:\n\n1. Nodes with at least one ready, non-terminating pod (preferred).\n2. Nodes with at least one ready pod that is terminating (fallback during rolling updates).\n3. Nodes with at least one running but not-ready pod (last resort).\n\nOtherwise iterates over all Nodes, of any phase.\n\nIterates over each relevant Node's `status.addresses`:\n\n1. If there is an `external-dns.alpha.kubernetes.io/access: public` annotation on the Service, uses both addresses with\na `type` of `ExternalIP` and IPv6 addresses with a `type` of `InternalIP`.\n\n2. Otherwise, if there is an `external-dns.alpha.kubernetes.io/access: private` annotation on the Service, uses addresses with\na `type` of `InternalIP`.\n\n3. Otherwise, if there is at least one address with a `type` of `ExternalIP`, uses both addresses with\na `type` of `ExternalIP` and IPv6 addresses with a `type` of `InternalIP`.\n\n4. Otherwise, uses addresses with a `type` of `InternalIP`.\n\nAlso iterates over the Service's `spec.ports`, creating a SRV record for each port which has a `nodePort`.\nThe SRV record has a service of the Service's `name`, a protocol taken from the port's `protocol` field,\na priority of `0` and a weight of `50`.\nIn order for SRV records to be created, the `--managed-record-types` must have been specified, including `SRV`\nas one of the values.\n\n```console\nexternal-dns ... --managed-record-types=A --managed-record-types=CNAME --managed-record-types=SRV\n```\n\n### ExternalName\n\n1. If the Service has one or more `spec.externalIPs`, uses the values in that field.\n2. Otherwise, creates a target with the value of the Service's `externalName` field.\n\n## Endpoints Reconciliation\n\nBy default, ExternalDNS does not watch for endpoint changes and does not automatically reconcile DNS records as the endpoints, as matched by the Service's selector.\nTo enable reconcile on endpoints changes, you must specify the `--listen-endpoint-events` flag. However, be aware that this may increase the number of reconciliations performed by the controller, and the number of requests to the DNS provider.\n"
  },
  {
    "path": "docs/sources/traefik-proxy.md",
    "content": "# Traefik Proxy Source\n\n- [Traefik Documentation](https://doc.traefik.io/traefik/)\n- [Traefik Helm Chart](https://github.com/traefik/traefik-helm-chart)\n\nThis tutorial describes how to configure ExternalDNS to use the Traefik Proxy source.\nIt is meant to supplement the other provider-specific setup tutorials.\n\n## Manifest (for clusters without RBAC enabled)\n\n```yaml\n[[% include 'traefik-proxy/without-rbac.yaml' %]]\n```\n\n## Manifest (for clusters with RBAC enabled)\n\n```yaml\n[[% include 'traefik-proxy/with-cluster-rbac.yaml' %]]\n```\n\n## Deploying a Traefik IngressRoute\n\nCreate an IngressRoute file called 'ingress-route-default' with the following contents:\n\n```yaml\n[[% include 'traefik-proxy/ingress-route-default.yaml' %]]\n```\n\nNote the annotation on the IngressRoute (`external-dns.alpha.kubernetes.io/target`); use the same hostname as the traefik DNS.\n\nExternalDNS uses this annotation to determine what services should be registered with DNS.\n\nCreate the IngressRoute:\n\n```sh\nkubectl create -f docs/snippets/traefik-proxy/ingress-route-default.yaml\n```\n\nDepending where you run your IngressRoute it can take a little while for ExternalDNS synchronize the DNS record.\n\n## Support private and public routing\n\nTo create a more robust and manageable Kubernetes environment, leverage separate Ingress classes to finely control public and private routing's security, performance, and operational policies. Similar approach could work in multi-tenant environments.\n\nFor this we are going to need two instances of `traefik` (public and private) as well as two instances of `external-dns`.\n\nThe `traefik` configuration should contain (for more detailed configured validate with the vendor)\n\n```yaml\n[[% include 'traefik-proxy/traefik-public-private-config.yaml' %]]\n```\n\nCreate a IngressRoutes files with the following contents:\n\n```yaml\n[[% include 'traefik-proxy/ingress-route-public-private.yaml' %]]\n```\n\nAnd the arguments for `external-dns` instances should looks like\n\n```yaml\n---\nargs:\n  - --source=traefik-proxy\n  - --annotation-filter=\"kubernetes.io/ingress.class=traefik-public\"\n---\nargs:\n  - --source=traefik-proxy\n  - --annotation-filter=\"kubernetes.io/ingress.class=traefik-private\"\n```\n\n## Cleanup\n\nNow that we have verified that ExternalDNS will automatically manage Traefik DNS records, we can delete the tutorial's example:\n\n```sh\nkubectl delete -f docs/snippets/traefik-proxy/ingress-route-default.yaml\nkubectl delete -f externaldns.yaml\n```\n\n## Additional Flags\n\n| Flag                    | Description                                             |\n|-------------------------|---------------------------------------------------------|\n| --traefik-enable-legacy | Enable listeners on Resources under traefik.containo.us |\n| --traefik-disable-new   | Disable listeners on Resources under traefik.io         |\n\n### Resource Listeners\n\nTraefik has deprecated the legacy API group, _traefik.containo.us_, in favor of _traefik.io_. By default the `traefik-proxy` source listen for resources under traefik.io API groups.\n\nIf needed, you can enable legacy listener with `--traefik-enable-legacy` and also disable new listener with `--traefik-disable-new`.\n"
  },
  {
    "path": "docs/sources/txt-record.md",
    "content": "# Creating TXT record with CRD source\n\nYou can create and manage TXT records with the help of [CRD source](../sources/crd.md)\nand `DNSEndpoint` CRD. Currently, this feature is supported by multiple providers including `webhook`.\n\nIn order to start managing TXT records you need to set the `--managed-record-types=TXT` flag.\n\n```console\nexternal-dns --source crd --provider webhook --managed-record-types=A --managed-record-types=CNAME --managed-record-types=TXT\n```\n\nTargets within the CRD need to be specified according to the RFC 1035 (section 3.3.14). Below is an example of\n`example.com` DNS TXT two records creation.\n\n**NOTE** Current implementation do not support RFC 6763 (section 6).\n\n```yaml\napiVersion: externaldns.k8s.io/v1alpha1\nkind: DNSEndpoint\nmetadata:\n  name: examplemxrecord\nspec:\n  endpoints:\n    - dnsName: example.com\n      recordTTL: 180\n      recordType: TXT\n      targets:\n        - SOMETXT\n        - ANOTHERTXT\n```\n"
  },
  {
    "path": "docs/sources/unstructured.md",
    "content": "---\ntags: [\"source\", \"area/fqdn\", \"area/source\", \"templating\", \"unstructured\"]\n---\n\n# Unstructured Source\n\nThe `unstructured` source creates DNS records from any Kubernetes resource using Go templates.\nIt works with custom resources (CRDs) without requiring typed Go clients.\n\n## Use Cases\n\nUse this source when:\n\n- Your CRD is not supported by a built-in external-dns source\n- The resource exposes DNS-relevant data (hostnames, IPs, endpoints) in `.spec` or `.status`\n- A built-in source exists but only supports an older API version than you're using\n- You want to experiment with custom controllers or meshes but keep external-dns\n- Create DNS entries based on any attribute of any Kubernetes object\n  - When controller users Labels or Annotations with Json or flat key-value pairs\n  - Crossplane managed resources (RDS, ElastiCache, S3, etc.)\n  - Support Endpoints or any other native resources\n  - Use ConfigMaps as a lightweight DNS registry without needing custom CRDs\n- Allows the community to support new CRDs via configuration rather than code changes\n\n> **Note**: Prefer built-in sources when available (e.g., `istio-virtualservice`, `gateway-httproute`) as they provide optimized handling for those resource types.\n\n### Advanced Use Cases\n\nThe unstructured source can also be used with:\n\n**Knative Service** - Serverless workloads expose auto-generated URLs in `.status.url`\n\n```yaml\nstatus:\n  url: https://hello.default.example.com\n```\n\n**Argo Rollouts** - Canary/blue-green deployments with preview services in `.status.canary.stableRS`\n\n```yaml\nstatus:\n  canary:\n    stableRS: my-app-stable-abc123\n```\n\n**Linkerd ServiceProfile** - Service mesh with destination overrides in `.spec.dstOverrides`\n\n```yaml\nspec:\n  dstOverrides:\n  - authority: webapp.default.svc.cluster.local\n```\n\n**Crossplane Composition outputs** - Any Crossplane-managed cloud resource (ElastiCache, S3 websites, CloudFront, etc.)\n\n```yaml\nstatus:\n  atProvider:\n    configurationEndpoint:\n      address: my-cache.abc123.cache.amazonaws.com\n```\n\n**Cilium BGP PeeringPolicy** - BGP-advertised IPs for LoadBalancer services\n\n```yaml\nstatus:\n  conditions:\n  - type: Established\n    status: \"True\"\n```\n\n**ACK FieldExport** - AWS Controllers for Kubernetes can export resource status (RDS endpoints, S3 bucket URLs) to ConfigMaps via FieldExport, enabling dynamic DNS records\n\n```yaml\n# FieldExport copies S3 bucket URL to ConfigMap\napiVersion: services.k8s.aws/v1alpha1\nkind: FieldExport\nspec:\n  from:\n    path: \".status.location\"\n    resource:\n      group: s3.services.k8s.aws\n      kind: Bucket\n      name: my-bucket\n  to:\n    kind: configmap\n    name: bucket-dns\n```\n\n## Configuration\n\n| Flag                        | Description                                                        |\n|-----------------------------|--------------------------------------------------------------------|\n| `--unstructured-resource`   | Resources to watch in `resource.version.group` format (repeatable) |\n| `--fqdn-template`           | Go template for DNS names                                          |\n| `--target-template`         | Go template for DNS targets                                        |\n| `--fqdn-target-template`    | Go template returning `host:target` pairs                          |\n| `--label-filter`            | Filter resources by labels                                         |\n| `--annotation-filter`       | Filter resources by annotations                                    |\n| `--combine-fqdn-annotation` | Combine FQDN template and Annotations instead of overwriting       |\n\n## Template Syntax\n\nTemplates have access to typed-style fields and raw object data:\n\n| Field          | Description          |\n|----------------|----------------------|\n| `.Name`        | Object name          |\n| `.Namespace`   | Object namespace     |\n| `.Kind`        | Object kind          |\n| `.APIVersion`  | API version          |\n| `.Labels`      | Object labels        |\n| `.Annotations` | Object annotations   |\n| `.Metadata`    | Raw metadata section |\n| `.Spec`        | Raw spec section     |\n| `.Status`      | Raw status section   |\n| `.Object`      | Raw full object      |\n\n## Examples\n\n### ConfigMap DNS Registry\n\nUse ConfigMaps as a lightweight DNS registry without needing custom CRDs. Useful for GitOps workflows where teams manage DNS entries via ConfigMaps in their namespaces.\n\n```yaml\napiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: api-dns\n  namespace: production\n  labels:\n    external-dns.alpha.kubernetes.io/dns-controller: \"dns-controller\"\ndata:\n  hostname: api.example.com\n  target: 10.0.0.100\n```\n\n```bash\nexternal-dns \\\n  --source=unstructured \\\n  --unstructured-resource=configmaps.v1 \\\n  --fqdn-template='{{index .Object.data \"hostname\"}}' \\\n  --target-template='{{index .Object.data \"target\"}}' \\\n  --label-filter='external-dns.alpha.kubernetes.io/controller=dns-controller'\n\n# Result:\n# api.example.com -> 10.0.0.100 (A)\n```\n\n### Crossplane RDS Instance\n\n```bash\nexternal-dns \\\n  --source=unstructured \\\n  --unstructured-resource=rdsinstances.v1alpha1.rds.aws.crossplane.io \\\n  --fqdn-template='{{.Name}}.db.example.com' \\\n  --target-template='{{.Status.atProvider.endpoint.address}}'\n```\n\n### Multiple Resources\n\n```bash\nexternal-dns \\\n  --source=unstructured \\\n  --unstructured-resource=virtualmachineinstances.v1.kubevirt.io \\\n  --unstructured-resource=rdsinstances.v1alpha1.rds.aws.crossplane.io \\\n  --fqdn-template='{{.Name}}.{{.Kind}}.example.com' \\\n  --target-template='{{.Status.endpoint}}'\n```\n\n### MetalLB IPAddressPool\n\n```yaml\napiVersion: metallb.io/v1beta1\nkind: IPAddressPool\nmetadata:\n  name: production-pool\n  namespace: metallb-system\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: \"lb.example.com\"\nspec:\n  addresses:\n  - 192.168.10.11/32\n```\n\n```bash\nexternal-dns \\\n  --source=unstructured \\\n  --unstructured-resource=ipaddresspools.v1beta1.metallb.io \\\n  --fqdn-template='{{index .Annotations \"external-dns.alpha.kubernetes.io/hostname\"}}' \\\n  --target-template='{{$addr := index .Spec.addresses 0}}{{if contains $addr \"/32\"}}{{trimSuffix $addr \"/32\"}}{{else}}{{$addr}}{{end}}'\n\n# Result:\n# lb.example.com -> 192.168.10.11 (A)\n```\n\n> **Tip**: Use `contains` with `trimSuffix` to extract the IP from `/32` CIDR notation.\n\n### Apache APISIX Route\n\n```yaml\napiVersion: apisix.apache.org/v2\nkind: ApisixRoute\nmetadata:\n  name: httpbin\n  namespace: ingress-apisix\nspec:\n  http:\n  - name: httpbin\n    match:\n      hosts:\n      - httpbin.example.com\n      paths:\n      - /ip\n    backends:\n    - serviceName: httpbin\n      servicePort: 80\nstatus:\n  apisix:\n    gateway: apisix-gateway.ingress-apisix.svc.cluster.local\n```\n\n```bash\nexternal-dns \\\n  --source=unstructured \\\n  --unstructured-resource=apisixroutes.v2.apisix.apache.org \\\n  --fqdn-template='{{.Name}}.route.example.com' \\\n  --target-template='{{.Status.apisix.gateway}}'\n\n# Result:\n# httpbin.route.example.com -> apisix-gateway.ingress-apisix.svc.cluster.local (CNAME)\n```\n\n### cert-manager Certificate\n\n```yaml\napiVersion: cert-manager.io/v1\nkind: Certificate\nmetadata:\n  name: my-app-tls\n  namespace: production\n  annotations:\n    external-dns.alpha.kubernetes.io/target: \"10.0.0.50\"\nspec:\n  secretName: my-app-tls-secret\n  dnsNames:\n  - my-app.example.com\n  - www.my-app.example.com\n  issuerRef:\n    name: letsencrypt-prod\n    kind: ClusterIssuer\n```\n\n```bash\nexternal-dns \\\n  --source=unstructured \\\n  --unstructured-resource=certificates.v1.cert-manager.io \\\n  --fqdn-template='{{index .Spec.dnsNames 0}}' \\\n  --target-template='{{index .Annotations \"external-dns.alpha.kubernetes.io/target\"}}'\n\n# Result:\n# my-app.example.com -> 10.0.0.50 (A)\n```\n\n### Rancher Node\n\n```yaml\napiVersion: management.cattle.io/v3\nkind: Node\nmetadata:\n  name: my-node-1\n  namespace: cattle-system\n  labels:\n    cattle.io/creator: norman\n    node-role.kubernetes.io/controlplane: \"true\"\nspec:\n  clusterName: c-abcde\n  hostname: my-node-1\nstatus:\n  nodeName: worker-01\n  internalNodeStatus:\n    addresses:\n    - type: ExternalIP\n      address: 203.0.113.10\n```\n\n```bash\nexternal-dns \\\n  --source=unstructured \\\n  --unstructured-resource=nodes.v3.management.cattle.io \\\n  --fqdn-template='{{.Spec.hostname}}.nodes.example.com' \\\n  --target-template='{{(index .Status.internalNodeStatus.addresses 0).address}}' \\\n  --label-filter='node-role.kubernetes.io/controlplane=true'\n\n# Result:\n# my-node-1.nodes.example.com -> 203.0.113.10 (A)\n```\n\n### ACK FieldExport with ConfigMap\n\nUse AWS Controllers for Kubernetes (ACK) to dynamically populate ConfigMaps with resource endpoints. FieldExport copies values from ACK-managed resources (RDS, S3, ElastiCache) to ConfigMaps, which external-dns can then use for DNS records.\n\n```yaml\n# 1. ACK creates an S3 bucket\napiVersion: s3.services.k8s.aws/v1alpha1\nkind: Bucket\nmetadata:\n  name: app-assets\n  namespace: default\nspec:\n  name: my-app-assets-bucket\n---\n# 2. FieldExport copies the bucket URL to a ConfigMap\napiVersion: services.k8s.aws/v1alpha1\nkind: FieldExport\nmetadata:\n  name: export-bucket-url\n  namespace: default\nspec:\n  from:\n    path: \".status.location\"\n    resource:\n      group: s3.services.k8s.aws\n      kind: Bucket\n      name: app-assets\n  to:\n    kind: configmap\n    name: app-assets-dns\n    namespace: default\n---\n# 3. ConfigMap is populated by FieldExport\napiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: app-assets-dns\n  namespace: default\n  labels:\n    app.kubernetes.io/managed-by: ack-fieldexport\ndata:\n  default.export-bucket-url: \"https://my-app-assets-bucket.s3.amazonaws.com/\"\n```\n\n```bash\nexternal-dns \\\n  --source=unstructured \\\n  --unstructured-resource=configmaps.v1 \\\n  --fqdn-template='{{if eq .Kind \"ConfigMap\"}}{{.Name}}.cdn.example.com{{end}}' \\\n  --target-template='{{if eq .Kind \"ConfigMap\"}}{{$url := index .Object.data \"default.export-bucket-url\"}}{{trimSuffix (trimPrefix $url \"https://\") \"/\"}}{{end}}' \\\n  --label-filter='app.kubernetes.io/managed-by=ack-fieldexport'\n\n# Result:\n# app-assets-dns.cdn.example.com -> my-app-assets-bucket.s3.amazonaws.com (CNAME)\n```\n\n### EndpointSlice for Headless Services\n\nCreate per-pod DNS records from EndpointSlice resources for headless services. Each pod gets its own DNS entry pointing to its IP address.\n\n```yaml\napiVersion: discovery.k8s.io/v1\nkind: EndpointSlice\nmetadata:\n  name: test-abc12\n  namespace: default\n  labels:\n    endpointslice.kubernetes.io/managed-by: endpointslice-controller.k8s.io\n    kubernetes.io/service-name: test-headless\n    service.kubernetes.io/headless: \"\"\naddressType: IPv4\nendpoints:\n- addresses:\n  - 10.244.1.2\n  conditions:\n    ready: true\n  nodeName: worker1\n  targetRef:\n    kind: Pod\n    name: app-abc12\n    namespace: default\n- addresses:\n  - 10.244.2.3\n  - 10.244.2.4\n  conditions:\n    ready: true\n  nodeName: worker2\n  targetRef:\n    kind: Pod\n    name: app-def34\n    namespace: default\nports:\n- name: http\n  port: 80\n  protocol: TCP\n```\n\n```bash\nexternal-dns \\\n  --source=unstructured \\\n  --unstructured-resource=endpointslices.v1.discovery.k8s.io \\\n  --fqdn-target-template='{{if and (eq .Kind \"EndpointSlice\") (hasKey .Labels \"service.kubernetes.io/headless\")}}{{range $ep := .Object.endpoints}}{{if $ep.conditions.ready}}{{range $ep.addresses}}{{$ep.targetRef.name}}.pod.com:{{.}},{{end}}{{end}}{{end}}{{end}}' \\\n  --fqdn-target-template='{{if and (eq .Kind \"EndpointSlice\") (hasKey .Labels \"service.kubernetes.io/headless\")}}{{$svcName := index .Labels \"kubernetes.io/service-name\"}}{{range $ep :=.Object.endpoints}}{{if $ep.conditions.ready}}{{range $ep.addresses}}{{$svcName}}.example.com:{{.}},{{end}}{{end}}{{end}}{{end}}'\n\n# Result:\n# app-abc12.pod.com -> 10.244.1.2 (A)\n# app-def34.pod.com -> 10.244.2.3, 10.244.2.4 (A)\n# test-abc12.example.com -> 10.244.1.2, 10.244.2.3, 10.244.2.4 (A)\n```\n\nThe `--fqdn-target-template` flag returns `host:target` pairs, enabling 1:1 mapping between hostnames and targets. Useful when a Kubernetes resource contains arrays where each element should produce its own DNS record (e.g., EndpointSlice endpoints, multi-host configurations).\n\n## RBAC\n\nGrant external-dns access to your custom resources:\n\n```yaml\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: external-dns\nrules:\n  # Add for each resource type\n  - apiGroups: [\"rds.aws.crossplane.io\"]\n    resources: [\"rdsinstances\"]\n    verbs: [\"get\", \"watch\", \"list\"]\n  - apiGroups: [\"<your-api-group>\"]\n    resources: [\"<your-resources>\"]\n    verbs: [\"get\", \"watch\", \"list\"]\n```\n"
  },
  {
    "path": "docs/tutorials/akamai-edgedns.md",
    "content": "# Akamai Edge DNS\n\n## Prerequisites\n\nExternal-DNS v0.8.0 or greater.\n\n### Zones\n\nExternal-DNS manages service endpoints in existing DNS zones. The Akamai provider does not add, remove or configure new zones.\nThe [Akamai Control Center](https://control.akamai.com) or [Akamai DevOps Tools](https://developer.akamai.com/devops), [Akamai CLI](https://developer.akamai.com/cli) and [Akamai Terraform Provider](https://developer.akamai.com/tools/integrations/terraform) can create and manage Edge DNS zones.\n\n### Akamai Edge DNS Authentication\n\nThe Akamai Edge DNS provider requires valid Akamai Edgegrid API authentication credentials to access zones and manage  DNS records.\n\nEither directly by key or indirectly via a file can set credentials for the provider. The Akamai credential keys and mappings to the Akamai provider utilizing different presentation methods are:\n\n| Edgegrid Auth Key | External-DNS Cmd Line Key    | Environment/ConfigMap Key                 | Description                       |\n|-------------------|------------------------------|-------------------------------------------|-----------------------------------|\n| host              | akamai-serviceconsumerdomain | EXTERNAL_DNS_AKAMAI_SERVICECONSUMERDOMAIN | Akamai Edgegrid API server        |\n| access_token      | akamai-access-token          | EXTERNAL_DNS_AKAMAI_ACCESS_TOKEN          | Akamai Edgegrid API access token  |\n| client_token      | akamai-client-token          | EXTERNAL_DNS_AKAMAI_CLIENT_TOKEN          | Akamai Edgegrid API client token  |\n| client-secret     | akamai-client-secret         | EXTERNAL_DNS_AKAMAI_CLIENT_SECRET         | Akamai Edgegrid API client secret |\n\nIn addition to specifying auth credentials individually, an Akamai Edgegrid .edgerc file convention can set credentials.\n\n| External-DNS Cmd Line | Environment/ConfigMap              | Description                                                          |\n|-----------------------|------------------------------------|----------------------------------------------------------------------|\n| akamai-edgerc-path    | EXTERNAL_DNS_AKAMAI_EDGERC_PATH    | Accessible path to Edgegrid credentials file, e.g /home/test/.edgerc |\n| akamai-edgerc-section | EXTERNAL_DNS_AKAMAI_EDGERC_SECTION | Section in Edgegrid credentials file containing credentials          |\n\n[Akamai API Authentication](https://developer.akamai.com/getting-started/edgegrid) provides an overview and further information about authorization credentials for API base applications and tools.\n\n## Deploy External-DNS\n\nAn operational External-DNS deployment consists of an External-DNS container and service. The following sections demonstrate the ConfigMap objects that would make up an example functional external DNS kubernetes configuration utilizing NGINX as the service.\n\nConnect your `kubectl` client to the External-DNS cluster.\n\nBegin by creating a Kubernetes secret to securely store your  Akamai Edge DNS Access Tokens. This key will enable ExternalDNS to authenticate with Akamai Edge DNS:\n\n```shell\nkubectl create secret generic AKAMAI-DNS --from-literal=EXTERNAL_DNS_AKAMAI_SERVICECONSUMERDOMAIN=YOUR_SERVICECONSUMERDOMAIN --from-literal=EXTERNAL_DNS_AKAMAI_CLIENT_TOKEN=YOUR_CLIENT_TOKEN --from-literal=EXTERNAL_DNS_AKAMAI_CLIENT_SECRET=YOUR_CLIENT_SECRET --from-literal=EXTERNAL_DNS_AKAMAI_ACCESS_TOKEN=YOUR_ACCESS_TOKEN\n```\n\nEnsure to replace YOUR_SERVICECONSUMERDOMAIN, EXTERNAL_DNS_AKAMAI_CLIENT_TOKEN, YOUR_CLIENT_SECRET and YOUR_ACCESS_TOKEN with your actual Akamai Edge DNS API keys.\n\nThen apply one of the following manifests file to deploy ExternalDNS.\n\n### Using Helm\n\nCreate a values.yaml file to configure ExternalDNS to use Akamai Edge DNS as the DNS provider. This file should include the necessary environment variables:\n\n```shell\nprovider:\n  name: akamai\nenv:\n  - name: EXTERNAL_DNS_AKAMAI_SERVICECONSUMERDOMAIN\n    valueFrom:\n      secretKeyRef:\n        name: AKAMAI-DNS\n        key: EXTERNAL_DNS_AKAMAI_SERVICECONSUMERDOMAIN\n  - name: EXTERNAL_DNS_AKAMAI_CLIENT_TOKEN\n    valueFrom:\n      secretKeyRef:\n        name: AKAMAI-DNS\n        key: EXTERNAL_DNS_AKAMAI_CLIENT_TOKEN\n  - name: EXTERNAL_DNS_AKAMAI_CLIENT_SECRET\n    valueFrom:\n      secretKeyRef:\n        name: AKAMAI-DNS\n        key: EXTERNAL_DNS_AKAMAI_CLIENT_SECRET\n  - name: EXTERNAL_DNS_AKAMAI_ACCESS_TOKEN\n    valueFrom:\n      secretKeyRef:\n        name: AKAMAI-DNS\n        key: EXTERNAL_DNS_AKAMAI_ACCESS_TOKEN\n```\n\nFinally, install the ExternalDNS chart with Helm using the configuration specified in your values.yaml file:\n\n```shell\nhelm upgrade --install external-dns external-dns/external-dns --values values.yaml\n```\n\n### Manifest (for clusters without RBAC enabled)\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      serviceAccountName: external-dns\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=service  # or ingress or both\n        - --provider=akamai\n        - --domain-filter=example.com\n        # zone-id-filter may be specified as well to filter on contract ID\n        - --registry=txt\n        - --txt-owner-id={{ owner-id-for-this-external-dns }}\n        - --txt-prefix={{ prefix label for TXT record }}.\n        env:\n        - name: EXTERNAL_DNS_AKAMAI_SERVICECONSUMERDOMAIN\n          valueFrom:\n            secretKeyRef:\n              name: AKAMAI-DNS\n              key: EXTERNAL_DNS_AKAMAI_SERVICECONSUMERDOMAIN\n        - name: EXTERNAL_DNS_AKAMAI_CLIENT_TOKEN\n          valueFrom:\n            secretKeyRef:\n              name: AKAMAI-DNS\n              key: EXTERNAL_DNS_AKAMAI_CLIENT_TOKEN\n        - name: EXTERNAL_DNS_AKAMAI_CLIENT_SECRET\n          valueFrom:\n            secretKeyRef:\n              name: AKAMAI-DNS\n              key: EXTERNAL_DNS_AKAMAI_CLIENT_SECRET\n        - name: EXTERNAL_DNS_AKAMAI_ACCESS_TOKEN\n          valueFrom:\n            secretKeyRef:\n              name: AKAMAI-DNS\n              key: EXTERNAL_DNS_AKAMAI_ACCESS_TOKEN\n```\n\n### Manifest (for clusters with RBAC enabled)\n\n```yaml\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: external-dns\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: external-dns\nrules:\n- apiGroups: [\"\"]\n  resources: [\"services\",\"pods\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"discovery.k8s.io\"]\n  resources: [\"endpointslices\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"extensions\",\"networking.k8s.io\"]\n  resources: [\"ingresses\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"\"]\n  resources: [\"nodes\"]\n  verbs: [\"watch\", \"list\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: external-dns-viewer\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: external-dns\nsubjects:\n- kind: ServiceAccount\n  name: external-dns\n  namespace: default\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      serviceAccountName: external-dns\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=service  # or ingress or both\n        - --provider=akamai\n        - --domain-filter=example.com\n        # zone-id-filter may be specified as well to filter on contract ID\n        - --registry=txt\n        - --txt-owner-id={{ owner-id-for-this-external-dns }}\n        - --txt-prefix={{ prefix label for TXT record }}.\n        env:\n        - name: EXTERNAL_DNS_AKAMAI_SERVICECONSUMERDOMAIN\n          valueFrom:\n            secretKeyRef:\n              name: AKAMAI-DNS\n              key: EXTERNAL_DNS_AKAMAI_SERVICECONSUMERDOMAIN\n        - name: EXTERNAL_DNS_AKAMAI_CLIENT_TOKEN\n          valueFrom:\n            secretKeyRef:\n              name: AKAMAI-DNS\n              key: EXTERNAL_DNS_AKAMAI_CLIENT_TOKEN\n        - name: EXTERNAL_DNS_AKAMAI_CLIENT_SECRET\n          valueFrom:\n            secretKeyRef:\n              name: AKAMAI-DNS\n              key: EXTERNAL_DNS_AKAMAI_CLIENT_SECRET\n        - name: EXTERNAL_DNS_AKAMAI_ACCESS_TOKEN\n          valueFrom:\n            secretKeyRef:\n              name: AKAMAI-DNS\n              key: EXTERNAL_DNS_AKAMAI_ACCESS_TOKEN\n```\n\nCreate the deployment for External-DNS:\n\n```sh\nkubectl apply -f externaldns.yaml\n```\n\n## Deploying an Nginx Service\n\nCreate a service file called 'nginx.yaml' with the following contents:\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: nginx\nspec:\n  selector:\n    matchLabels:\n      app: nginx\n  template:\n    metadata:\n      labels:\n        app: nginx\n    spec:\n      containers:\n      - image: nginx\n        name: nginx\n        ports:\n        - containerPort: 80\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: nginx\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: nginx.example.com\n    external-dns.alpha.kubernetes.io/ttl: \"600\" #optional\nspec:\n  selector:\n    app: nginx\n  type: LoadBalancer\n  ports:\n    - protocol: TCP\n      port: 80\n      targetPort: 80\n```\n\nCreate the deployment and service object:\n\n```sh\nkubectl apply -f nginx.yaml\n```\n\n## Verify Akamai Edge DNS Records\n\nWait 3-5 minutes before validating the records to allow the record changes to propagate to all the Akamai name servers.\n\nValidate records using the [Akamai Control Center](http://control.akamai.com) or by executing a dig, nslookup or similar DNS command.\n\n## Cleanup\n\nOnce you successfully configure and verify record management via External-DNS, you can delete the tutorial's examples:\n\n```sh\nkubectl delete -f nginx.yaml\nkubectl delete -f externaldns.yaml\n```\n\n## Additional Information\n\n* The Akamai provider allows the administrative user to filter zones by both name (`domain-filter`) and contract Id (`zone-id-filter`). The Edge DNS API will return a '500 Internal Error' for invalid contract Ids.\n* The provider will substitute quotes in TXT records with a `` ` `` (back tick) when writing records with the API.\n"
  },
  {
    "path": "docs/tutorials/alibabacloud.md",
    "content": "# Alibaba Cloud\n\nThis tutorial describes how to setup ExternalDNS for usage within a Kubernetes cluster on Alibaba Cloud. Make sure to use **>=0.5.6** version of ExternalDNS for this tutorial\n\n## RAM Permissions\n\n```json\n{\n  \"Version\": \"1\",\n  \"Statement\": [\n    {\n      \"Action\": \"alidns:AddDomainRecord\",\n      \"Resource\": \"*\",\n      \"Effect\": \"Allow\"\n    },\n    {\n      \"Action\": \"alidns:DeleteDomainRecord\",\n      \"Resource\": \"*\",\n      \"Effect\": \"Allow\"\n    },\n    {\n      \"Action\": \"alidns:UpdateDomainRecord\",\n      \"Resource\": \"*\",\n      \"Effect\": \"Allow\"\n    },\n    {\n      \"Action\": \"alidns:DescribeDomainRecords\",\n      \"Resource\": \"*\",\n      \"Effect\": \"Allow\"\n    },\n    {\n      \"Action\": \"alidns:DescribeDomains\",\n      \"Resource\": \"*\",\n      \"Effect\": \"Allow\"\n    },\n    {\n      \"Action\": \"pvtz:AddZoneRecord\",\n      \"Resource\": \"*\",\n      \"Effect\": \"Allow\"\n    },\n    {\n      \"Action\": \"pvtz:DeleteZoneRecord\",\n      \"Resource\": \"*\",\n      \"Effect\": \"Allow\"\n    },\n    {\n      \"Action\": \"pvtz:UpdateZoneRecord\",\n      \"Resource\": \"*\",\n      \"Effect\": \"Allow\"\n    },\n    {\n      \"Action\": \"pvtz:DescribeZoneRecords\",\n      \"Resource\": \"*\",\n      \"Effect\": \"Allow\"\n    },\n    {\n      \"Action\": \"pvtz:DescribeZones\",\n      \"Resource\": \"*\",\n      \"Effect\": \"Allow\"\n    },\n    {\n      \"Action\": \"pvtz:DescribeZoneInfo\",\n      \"Resource\": \"*\",\n      \"Effect\": \"Allow\"\n    }\n  ]\n}\n```\n\nWhen running on Alibaba Cloud, you need to make sure that your nodes (on which External DNS runs) have the RAM instance profile with the above RAM role assigned.\n\n## Set up a Alibaba Cloud DNS service or Private Zone service\n\nAlibaba Cloud DNS Service is the domain name resolution and management service for public access. It routes access from end-users to the designated web app.\nAlibaba Cloud Private Zone is the domain name resolution and management service for VPC internal access.\n\n*If you prefer to try-out ExternalDNS in one of the existing domain or zone you can skip this step*\n\nCreate a DNS domain which will contain the managed DNS records. For public DNS service, the domain name should be valid and owned by yourself.\n\n```console\naliyun alidns AddDomain --DomainName \"external-dns-test.com\"\n```\n\nMake a note of the ID of the hosted zone you just created.\n\n```console\naliyun alidns DescribeDomains --KeyWord=\"external-dns-test.com\" | jq -r '.Domains.Domain[0].DomainId'\n```\n\n## Deploy ExternalDNS\n\nConnect your `kubectl` client to the cluster you want to test ExternalDNS with.\nThen apply one of the following manifests file to deploy ExternalDNS.\n\n### Manifest (for clusters without RBAC enabled)\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=service\n        - --source=ingress\n        - --domain-filter=external-dns-test.com # will make ExternalDNS see only the hosted zones matching provided domain, omit to process all available hosted zones\n        - --provider=alibabacloud\n        - --policy=upsert-only # would prevent ExternalDNS from deleting any records, omit to enable full synchronization\n        - --alibaba-cloud-zone-type=public # only look at public hosted zones (valid values are public, private or no value for both)\n        - --registry=txt\n        - --txt-owner-id=my-identifier\n        volumeMounts:\n        - mountPath: /usr/share/zoneinfo\n          name: hostpath\n      volumes:\n      - name: hostpath\n        hostPath:\n          path: /usr/share/zoneinfo\n          type: Directory\n```\n\n### Manifest (for clusters with RBAC enabled)\n\n```yaml\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: external-dns\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: external-dns\nrules:\n- apiGroups: [\"\"]\n  resources: [\"services\",\"pods\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"discovery.k8s.io\"]\n  resources: [\"endpointslices\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"extensions\",\"networking.k8s.io\"]\n  resources: [\"ingresses\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"\"]\n  resources: [\"nodes\"]\n  verbs: [\"list\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: external-dns-viewer\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: external-dns\nsubjects:\n- kind: ServiceAccount\n  name: external-dns\n  namespace: default\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      serviceAccountName: external-dns\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=service\n        - --source=ingress\n        - --domain-filter=external-dns-test.com # will make ExternalDNS see only the hosted zones matching provided domain, omit to process all available hosted zones\n        - --provider=alibabacloud\n        - --policy=upsert-only # would prevent ExternalDNS from deleting any records, omit to enable full synchronization\n        - --alibaba-cloud-zone-type=public # only look at public hosted zones (valid values are public, private or no value for both)\n        - --registry=txt\n        - --txt-owner-id=my-identifier\n        - --alibaba-cloud-config-file= # enable sts token\n        volumeMounts:\n        - mountPath: /usr/share/zoneinfo\n          name: hostpath\n      volumes:\n      - name: hostpath\n        hostPath:\n          path: /usr/share/zoneinfo\n          type: Directory\n```\n\n## Arguments\n\nThis list is not the full list, but a few arguments that where chosen.\n\n### alibaba-cloud-zone-type\n\n`alibaba-cloud-zone-type` allows filtering for private and public zones\n\n* If value is `public`, it will sync with records in Alibaba Cloud DNS Service\n* If value is `private`, it will sync with records in Alibaba Cloud Private Zone Service\n\n## Verify ExternalDNS works (Ingress example)\n\nCreate an ingress resource manifest file.\n\n> For ingress objects ExternalDNS will create a DNS record based on the host specified for the ingress object.\n\n```yaml\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  name: foo\nspec:\n  ingressClassName: nginx # use the one that corresponds to your ingress controller.\n  rules:\n  - host: foo.external-dns-test.com\n    http:\n      paths:\n      - backend:\n          service:\n            name: foo\n            port:\n              number: 80\n        pathType: Prefix\n```\n\n## Verify ExternalDNS works (Service example)\n\nCreate the following sample application to test that ExternalDNS works.\n\n> For services ExternalDNS will look for the annotation `external-dns.alpha.kubernetes.io/hostname` on the service and use the corresponding value.\n\n```yaml\napiVersion: v1\nkind: Service\nmetadata:\n  name: nginx\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: nginx.external-dns-test.com.\nspec:\n  type: LoadBalancer\n  ports:\n  - port: 80\n    name: http\n    targetPort: 80\n  selector:\n    app: nginx\n\n---\n\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: nginx\nspec:\n  selector:\n    matchLabels:\n      app: nginx\n  template:\n    metadata:\n      labels:\n        app: nginx\n    spec:\n      containers:\n      - image: nginx\n        name: nginx\n        ports:\n        - containerPort: 80\n          name: http\n```\n\nAfter roughly two minutes check that a corresponding DNS record for your service was created.\n\n```console\n$ aliyun alidns DescribeDomainRecords --DomainName=external-dns-test.com\n{\n  \"PageNumber\": 1,\n  \"TotalCount\": 1,\n  \"PageSize\": 20,\n  \"RequestId\": \"1DBEF426-F771-46C7-9802-4989E9C94EE8\",\n  \"DomainRecords\": {\n    \"Record\": [\n      {\n        \"RR\": \"nginx\",\n        \"Status\": \"ENABLE\",\n        \"Value\": \"1.2.3.4\",\n        \"Weight\": 1,\n        \"RecordId\": \"3994015629411328\",\n        \"Type\": \"A\",\n        \"DomainName\": \"external-dns-test.com\",\n        \"Locked\": false,\n        \"Line\": \"default\",\n        \"TTL\": 600\n      }，\n      {\n        \"RR\": \"nginx\",\n        \"Status\": \"ENABLE\",\n        \"Value\": \"heritage=external-dns;external-dns/owner=my-identifier\",\n        \"Weight\": 1,\n        \"RecordId\": \"3994015629411329\",\n        \"Type\": \"TTL\",\n        \"DomainName\": \"external-dns-test.com\",\n        \"Locked\": false,\n        \"Line\": \"default\",\n        \"TTL\": 600\n      }\n    ]\n  }\n}\n```\n\nNote created TXT record alongside ALIAS record. TXT record signifies that the corresponding ALIAS record is managed by ExternalDNS. This makes ExternalDNS safe for running in environments where there are other records managed via other means.\n\nLet's check that we can resolve this DNS name. We'll ask the nameservers assigned to your zone first.\n\n```console\ndig nginx.external-dns-test.com.\n```\n\nIf you hooked up your DNS zone with its parent zone correctly you can use `curl` to access your site.\n\n```console\n$ curl nginx.external-dns-test.com.\n<!DOCTYPE html>\n<html>\n<head>\n<title>Welcome to nginx!</title>\n...\n</head>\n<body>\n...\n</body>\n</html>\n```\n\n## Custom TTL\n\nThe default DNS record TTL (Time-To-Live) is 300 seconds. You can customize this value by setting the annotation `external-dns.alpha.kubernetes.io/ttl`.\ne.g., modify the service manifest YAML file above:\n\n```yaml\napiVersion: v1\nkind: Service\nmetadata:\n  name: nginx\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: nginx.external-dns-test.com\n    external-dns.alpha.kubernetes.io/ttl: 60\nspec:\n    ...\n```\n\nThis will set the DNS record's TTL to 60 seconds.\n\n## Clean up\n\nMake sure to delete all Service objects before terminating the cluster so all load balancers get cleaned up correctly.\n\n```console\nkubectl delete service nginx\n```\n\nGive ExternalDNS some time to clean up the DNS records for you. Then delete the hosted zone if you created one for the testing purpose.\n\n```console\naliyun alidns DeleteDomain --DomainName external-dns-test.com\n```\n\nFor more info about Alibaba Cloud external dns, please refer this [docs](https://yq.aliyun.com/articles/633412)\n"
  },
  {
    "path": "docs/tutorials/anexia-engine.md",
    "content": "# Anexia\n\nOfficial documentation of how to use `external-dns` in combination with Anexia Engine can be viewed on the official documentation pages.\nThese guides include an overview of Anexia CloudDNS and integration with ExternalDNS.\n\n* [User Guide - Kubernetes and CloudDNS](https://engine.anexia-it.com/docs/en/module/kubernetes/user-guide/kubernetes-and-clouddns)\n* [Getting Started - Setting up an ExternalDNS Webhook](https://engine.anexia-it.com/docs/en/module/kubernetes/getting-started/setting-up-an-externaldns-webhook)\n"
  },
  {
    "path": "docs/tutorials/aws-filters.md",
    "content": "# AWS Filters\n\nThis document provides guidance on filtering AWS zones using various strategies and flags.\n\n## Strategies for Scoping Zones\n\n> Without specifying these flags, management applies to all zones.\n\nIn order to manage specific zones,  there is a possibility to combine multiple options\n\n| Argument                   | Description                                                | Flow Control |\n|:---------------------------|:-----------------------------------------------------------|:------------:|\n| `--zone-id-filter`         | Specify multiple times if needed                           |      OR      |\n| `--domain-filter`          | By domain suffix - specify multiple times if needed        |      OR      |\n| `--regex-domain-filter`    | By domain suffix but as a regex - overrides domain-filter  |     AND      |\n| `--exclude-domains`        | To exclude a domain or subdomain                           |      OR      |\n| `--regex-domain-exclusion` | Subtracts its matches from `regex-domain-filter`'s matches |     AND      |\n| `--aws-zone-type`          | Only sync zones of this type `[public\\|private]`           |      OR      |\n| `--aws-zone-tags`          | Only sync zones with this tag                              |     AND      |\n\nMinimum required configuration\n\n```sh\nargs:\n    --provider=aws\n    --registry=txt\n    --source=service\n```\n\n### Filter by Zone Type\n\n> If this flag is not specified, management applies to both public and private zones.\n\n```sh\nargs:\n    --aws-zone-type=private|public # choose between public or private\n    ...\n```\n\n### Filter by Domain\n\n> Specify multiple times if needed.\n\n```sh\nargs:\n    --domain-filter=example.com\n    --domain-filter=.paradox.example.com\n    ...\n```\n\nExample `--domain-filter=example.com` will allow for zone `example.com` and any zones that end in `.example.com`, including `an.example.com`, i.e., the subdomains of example.com.\n\nWhen there are multiple domains, filter `--domain-filter=example.com` will match domains `example.com`, `ex.par.example.com`, `par.example.com`, `x.par.eu-west-1.example.com`.\n\nAnd if the filter is prepended with `.` e.g., `--domain-filter=.example.com` it will allow *only* zones that end in `.example.com`, i.e., the subdomains of example.com but not the `example.com` zone itself. Example result: `ex.par.eu-west-1.example.com`, `ex.par.example.com`, `par.example.com`.\n\n> Note: if you prepend the filter with \".\", it will not attempt to match parent zones.\n\n### Filter by Zone ID\n\n> Specify multiple times if needed, the flow logic is OR\n\n```sh\nargs:\n    --zone-id-filter=ABCDEF12345678\n    --zone-id-filter=XYZDEF12345888\n    ...\n```\n\n### Filter by Tag\n\n> Specify multiple times if needed, the flow logic is AND\n\nKeys only\n\n```sh\nargs:\n    --aws-zone-tags=owner\n    --aws-zone-tags=vertical\n```\n\nOr specify keys with values\n\n```sh\nargs:\n    --aws-zone-tags=owner=k8s\n    --aws-zone-tags=vertical=k8s\n```\n\nCan't specify multiple or separate values with commas: `key1=val1,key2=val2` at the moment.\nFilter only by value `--aws-zone-tags==tag-value` is not supported.\n\n```sh\nargs:\n    --aws-zone-tags=team=k8s,vertical=platform # this is not supported\n    --aws-zone-tags==tag-value # this is not supported\n```\n\n## Filtering Workflows\n\n***Filtering Sequence***\n\nThe diagram describes the sequence for filtering AWS zones.\n\n```mermaid\nflowchart TD\n    A[\"zones\"] --> B{\"Is zonesCache valid?\"}\n    B -- Yes --> C[\"Return cached zones\"]\n    B -- No --> D[\"Initialize zones map\"]\n    D --> E[\"For each profile and client\"]\n    E --> F[\"Create paginator\"]\n    F --> G{\"Has more pages?\"}\n    G -- Yes --> H[\"Get next page\"]\n    H --> I[\"For each zone in page\"]\n    I --> J{\"Match zoneIDFilter?\"}\n    J -- No --> G\n    J -- Yes --> K{\"Match zoneTypeFilter?\"}\n    K -- No --> G\n    K -- Yes --> L{\"Match domainFilter?\"}\n    L -- No --> M{\"zoneMatchParent?\"}\n    M -- No --> G\n    M -- Yes --> N{\"Match domainFilter parent?\"}\n    N -- No --> G\n    N -- Yes --> O{\"zoneTagFilter specified?\"}\n    O -- Yes --> P[\"Add zone to zonesToValidate\"]\n    O -- No --> Q[\"Add zone to zones map\"]\n    P --> Q\n    Q --> G\n    G -- No --> R{\"zonesToValidate not empty?\"}\n    R -- Yes --> S[\"Get tags for zones\"]\n    S --> T[\"For each zone and tags\"]\n    T --> U{\"Match zoneTagFilter?\"}\n    U -- No --> V[\"Delete zone from zones map\"]\n    U -- Yes --> W[\"Keep zone in zones map\"]\n    V --> W\n    W --> R\n    R -- No --> X[\"Update zonesCache\"]\n    X --> Y[\"Return zones\"]\n```\n\n***Filtering Flow***\n\nThe is a sequence diagram that describes the interaction between `external-dns`, `AWSProvider`, and `Route53Client`\nduring the filtering process. Here is a high-level description:\n\n```mermaid\nsequenceDiagram\n    participant external-dns\n    participant AWSProvider\n    participant Route53Client\n\n    external-dns->>AWSProvider: zones\n    alt Cache is valid\n        AWSProvider-->>external-dns: return cached zones\n    else\n\n        AWSProvider->>Route53Client: ListHostedZonesPaginator\n        loop While paginator.HasMorePages\n            Route53Client->>AWSProvider: paginator.NextPage\n            alt ThrottlingException\n                AWSProvider->>external-dns: error\n            else\n                AWSProvider-->>external-dns: return error\n            end\n            AWSProvider->>AWSProvider: Filter zones\n            alt Tags need validation\n                AWSProvider->>Route53Client: ListTagsForResources\n                Route53Client->>AWSProvider: return tags\n                AWSProvider->>AWSProvider: Validate tags\n            end\n        end\n        alt Cache duration > 0\n            AWSProvider->>AWSProvider: Update cache\n        end\n        AWSProvider-->>external-dns: return zones\n    end\n```\n"
  },
  {
    "path": "docs/tutorials/aws-load-balancer-controller.md",
    "content": "# AWS Load Balancer Controller\n\nThis tutorial describes how to use ExternalDNS with the [aws-load-balancer-controller][1].\n\n[1]: https://kubernetes-sigs.github.io/aws-load-balancer-controller\n\n## Setting up ExternalDNS and aws-load-balancer-controller\n\nFollow the [AWS tutorial](aws.md) to setup ExternalDNS for use in Kubernetes clusters\nrunning in AWS. Specify the `source=ingress` argument so that ExternalDNS will look\nfor hostnames in Ingress objects. In addition, you may wish to limit which Ingress\nobjects are used as an ExternalDNS source via the `ingress-class` argument, but\nthis is not required.\n\nFor help setting up the AWS Load Balancer Controller, follow the [Setup Guide][2].\n\n[2]: https://kubernetes-sigs.github.io/aws-load-balancer-controller/latest/deploy/installation/\n\nNote that the AWS Load Balancer Controller uses the same tags for [subnet auto-discovery][3]\nas Kubernetes does with the AWS cloud provider.\n\n[3]: https://kubernetes-sigs.github.io/aws-load-balancer-controller/latest/deploy/subnet_discovery/\n\nIn the examples that follow, it is assumed that you configured the ALB Ingress\nController with the `ingress-class=alb` argument (not to be confused with the\nsame argument to ExternalDNS) so that the controller will only respect Ingress\nobjects with the `ingressClassName` field set to \"alb\".\n\n## Deploy an example application\n\nCreate the following sample \"echoserver\" application to demonstrate how\nExternalDNS works with ALB ingress objects.\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: echoserver\nspec:\n  replicas: 1\n  selector:\n    matchLabels:\n      app: echoserver\n  template:\n    metadata:\n      labels:\n        app: echoserver\n    spec:\n      containers:\n      - image: gcr.io/google_containers/echoserver:1.4\n        imagePullPolicy: Always\n        name: echoserver\n        ports:\n        - containerPort: 8080\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: echoserver\nspec:\n  ports:\n    - port: 80\n      targetPort: 8080\n      protocol: TCP\n  type: NodePort\n  selector:\n    app: echoserver\n```\n\nNote that the Service object is of type `NodePort`. We don't need a Service of\ntype `LoadBalancer` here, since we will be using an Ingress to create an ALB.\n\n## Ingress examples\n\nCreate the following Ingress to expose the echoserver application to the Internet.\n\n```yaml\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  annotations:\n    alb.ingress.kubernetes.io/scheme: internet-facing\n  name: echoserver\nspec:\n  ingressClassName: alb\n  rules:\n  - host: echoserver.mycluster.example.org\n    http: &echoserver_root\n      paths:\n      - path: /\n        backend:\n          service:\n            name: echoserver\n            port:\n              number: 80\n        pathType: Prefix\n  - host: echoserver.example.org\n    http: *echoserver_root\n```\n\nThe above should result in the creation of an (ipv4) ALB in AWS which will forward\ntraffic to the echoserver application.\n\nIf the `--source=ingress` argument is specified, then ExternalDNS will create\nDNS records based on the hosts specified in ingress objects. The above example\nwould result in two alias records (A and AAAA) being created for each of the\ndomains: `echoserver.mycluster.example.org` and `echoserver.example.org`. All\nfour records alias the ALB that is associated with the Ingress object. As the\nALB is IPv4 only, the AAAA alias records have no effect.\n\nIf you would like ExternalDNS to not create AAAA records at all, you can add the\nfollowing command line parameter: `--exclude-record-types=AAAA`. Please be\naware, this will disable AAAA record creation even for dualstack enabled load\nbalancers.\n\nNote that the above example makes use of the YAML anchor feature to avoid having\nto repeat the http section for multiple hosts that use the exact same paths. If\nthis Ingress object will only be fronting one backend Service, we might instead\ncreate the following:\n\n```yaml\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  annotations:\n    alb.ingress.kubernetes.io/scheme: internet-facing\n    external-dns.alpha.kubernetes.io/hostname: echoserver.mycluster.example.org, echoserver.example.org\n  name: echoserver\nspec:\n  ingressClassName: alb\n  rules:\n  - http:\n      paths:\n      - path: /\n        backend:\n          service:\n            name: echoserver\n            port:\n              number: 80\n        pathType: Prefix\n```\n\nIn the above example we create a default path that works for any hostname, and\nmake use of the `external-dns.alpha.kubernetes.io/hostname` annotation to create\nmultiple aliases for the resulting ALB.\n\n## Dualstack Load Balancers\n\nAWS [supports both IPv4 and \"dualstack\" (both IPv4 and IPv6) interfaces for ALBs][4]\nand [NLBs][5]. The AWS Load Balancer Controller uses the `alb.ingress.kubernetes.io/ip-address-type`\nannotation (which defaults to `ipv4`) to determine this. ExternalDNS creates\nboth A and AAAA alias DNS records by default, regardless of this annotation.\nIt's possible to create only A records with the following command line\nparameter: `--exclude-record-types=AAAA`\n\n[4]: https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html#ip-address-type\n[5]: https://docs.aws.amazon.com/elasticloadbalancing/latest/network/network-load-balancers.html#ip-address-type\n\nExample:\n\n```yaml\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  annotations:\n    alb.ingress.kubernetes.io/scheme: internet-facing\n    alb.ingress.kubernetes.io/ip-address-type: dualstack\n  name: echoserver\nspec:\n  ingressClassName: alb\n  rules:\n  - host: echoserver.example.org\n    http:\n      paths:\n      - path: /\n        backend:\n          service:\n            name: echoserver\n            port:\n              number: 80\n        pathType: Prefix\n```\n\nThe above Ingress object will result in the creation of an ALB with a dualstack\ninterface.\n\n## Frontend Network Load Balancer (NLB)\n\nThe AWS Load Balancer Controller supports [fronting ALBs with an NLB][6] for improved performance\nand static IP addresses. When this feature is enabled, the controller creates both an ALB and an\nNLB, resulting in two hostnames in the Ingress status.\n\n[6]: https://kubernetes-sigs.github.io/aws-load-balancer-controller/latest/guide/ingress/annotations/#enable-frontend-nlb\n\n### Known Issue with Internal ALBs\n\nWhen using an internal ALB (`alb.ingress.kubernetes.io/scheme: internal`) with frontend NLB,\nExternalDNS may create DNS records pointing to the ALB instead of the NLB due to alphabetical\nordering:\n\n- Internal ALB hostname: `internal-k8s-myapp-alb.us-east-1.elb.amazonaws.com`\n- NLB hostname: `k8s-myapp-nlb-123456789.elb.us-east-1.amazonaws.com`\n\nWhen multiple targets exist, Route53 selects the first one alphabetically, which incorrectly\nselects the internal ALB. See [issue #5661][7] for details.\n\n[7]: https://github.com/kubernetes-sigs/external-dns/issues/5661\n\n### Workarounds\n\nThere are several approaches to ensure DNS records point to the correct (NLB) target:\n\n#### Option 1: Combine load balancer naming with target annotation (Recommended)\n\nUse [`alb.ingress.kubernetes.io/load-balancer-name`][8] to create predictable hostnames, then\nexplicitly reference the NLB using [`external-dns.alpha.kubernetes.io/target`][9]:\n\n[8]: https://kubernetes-sigs.github.io/aws-load-balancer-controller/latest/guide/ingress/annotations/#load-balancer-name\n[9]: https://kubernetes-sigs.github.io/external-dns/latest/docs/annotations/annotations/#external-dnsalphakubernetesiotarget\n\n```yaml\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  annotations:\n    alb.ingress.kubernetes.io/scheme: internal\n    alb.ingress.kubernetes.io/enable-frontend-nlb: \"true\"\n    alb.ingress.kubernetes.io/frontend-nlb-scheme: internal\n    alb.ingress.kubernetes.io/load-balancer-name: myapp-alb\n    external-dns.alpha.kubernetes.io/target: k8s-myapp-nlb.elb.us-east-1.amazonaws.com\n  name: echoserver\nspec:\n  ingressClassName: alb\n  rules:\n  - host: echoserver.example.org\n    http:\n      paths:\n      - path: /\n        backend:\n          service:\n            name: echoserver\n            port:\n              number: 80\n        pathType: Prefix\n```\n\n**Benefits**:\n\n- Predictable, consistent load balancer naming across environments\n- Explicit control over which target ExternalDNS uses\n- Works reliably with internal ALBs\n- No need to lookup auto-generated NLB names\n\n**NLB hostname pattern**: When you set `load-balancer-name: myapp-alb`, the NLB hostname\nbecomes `k8s-myapp-nlb.elb.<region>.amazonaws.com` (note the `-nlb` suffix).\n\n**ALB internal hostname pattern**: When you set `load-balancer-name: myapp-alb`, the ALB hostname\nbecomes `internal-myapp-nlb.<region>.elb.amazonaws.com` (note the `internal-` suffix).\n\n#### Option 2: Use the target annotation only\n\nIf you cannot control the load balancer name, explicitly specify the NLB hostname:\n\n```yaml\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  annotations:\n    alb.ingress.kubernetes.io/scheme: internal\n    alb.ingress.kubernetes.io/enable-frontend-nlb: \"true\"\n    alb.ingress.kubernetes.io/frontend-nlb-scheme: internal\n    external-dns.alpha.kubernetes.io/target: k8s-myapp-nlb-123456789.elb.us-east-1.amazonaws.com\n  name: echoserver\nspec:\n  ingressClassName: alb\n  rules:\n  - host: echoserver.example.org\n    http:\n      paths:\n      - path: /\n        backend:\n          service:\n            name: echoserver\n            port:\n              number: 80\n        pathType: Prefix\n```\n\n**Note**: You'll need to lookup the auto-generated NLB hostname after the controller creates it.\n\n#### Option 3: Use a DNSEndpoint resource\n\nCreate a [`DNSEndpoint`][10] custom resource to explicitly define the DNS record:\n\n```yaml\napiVersion: externaldns.k8s.io/v1alpha1\nkind: DNSEndpoint\nmetadata:\n  name: echoserver-dns\nspec:\n  endpoints:\n  - dnsName: echoserver.example.org\n    recordType: CNAME\n    targets:\n    - k8s-myapp-nlb-123456789.elb.us-east-1.amazonaws.com\n```\n\nThis approach is useful when you want to manage DNS records independently of the Ingress resource.\n\n[10]:https://kubernetes-sigs.github.io/external-dns/latest/docs/tutorials/crd/\n"
  },
  {
    "path": "docs/tutorials/aws-public-private-route53.md",
    "content": "# AWS Route53 with same domain for public and private zones\n\nThis tutorial describes how to setup ExternalDNS using the same domain for public and private Route53 zones and [nginx-ingress-controller](https://github.com/kubernetes/ingress-nginx).\nIt also outlines how to use [cert-manager](https://github.com/jetstack/cert-manager) to automatically issue SSL certificates from [Let's Encrypt](https://letsencrypt.org/) for both public and private records.\n\n## Deploy public nginx-ingress-controller\n\nYou may be interested with [GKE with nginx ingress](gke-nginx.md) for installation guidelines.\n\nSpecify `ingress-class` in nginx-ingress-controller container args:\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  labels:\n    app: external-ingress\n  name: external-ingress-controller\nspec:\n  replicas: 1\n  selector:\n    matchLabels:\n      app: external-ingress\n  template:\n    metadata:\n      labels:\n        app: external-ingress\n    spec:\n      containers:\n      - args:\n        - /nginx-ingress-controller\n        - --default-backend-service=$(POD_NAMESPACE)/default-http-backend\n        - --configmap=$(POD_NAMESPACE)/external-ingress-configuration\n        - --tcp-services-configmap=$(POD_NAMESPACE)/external-tcp-services\n        - --udp-services-configmap=$(POD_NAMESPACE)/external-udp-services\n        - --annotations-prefix=nginx.ingress.kubernetes.io\n        - --ingress-class=external-ingress\n        - --publish-service=$(POD_NAMESPACE)/external-ingress\n        env:\n        - name: POD_NAME\n          valueFrom:\n            fieldRef:\n              apiVersion: v1\n              fieldPath: metadata.name\n        - name: POD_NAMESPACE\n          valueFrom:\n            fieldRef:\n              apiVersion: v1\n              fieldPath: metadata.namespace\n        image: quay.io/kubernetes-ingress-controller/nginx-ingress-controller:0.11.0\n        livenessProbe:\n          failureThreshold: 3\n          httpGet:\n            path: /healthz\n            port: 10254\n            scheme: HTTP\n          initialDelaySeconds: 10\n          periodSeconds: 10\n          successThreshold: 1\n          timeoutSeconds: 1\n        name: external-ingress-controller\n        ports:\n        - containerPort: 80\n          name: http\n          protocol: TCP\n        - containerPort: 443\n          name: https\n          protocol: TCP\n        readinessProbe:\n          failureThreshold: 3\n          httpGet:\n            path: /healthz\n            port: 10254\n            scheme: HTTP\n          periodSeconds: 10\n          successThreshold: 1\n          timeoutSeconds: 1\n```\n\nSet `type: LoadBalancer` in your public nginx-ingress-controller Service definition.\n\n```yaml\napiVersion: v1\nkind: Service\nmetadata:\n  annotations:\n    service.beta.kubernetes.io/aws-load-balancer-connection-idle-timeout: \"3600\"\n    service.beta.kubernetes.io/aws-load-balancer-proxy-protocol: '*'\n  labels:\n    app: external-ingress\n  name: external-ingress\nspec:\n  externalTrafficPolicy: Cluster\n  ports:\n  - name: http\n    port: 80\n    protocol: TCP\n    targetPort: http\n  - name: https\n    port: 443\n    protocol: TCP\n    targetPort: https\n  selector:\n    app: external-ingress\n  sessionAffinity: None\n  type: LoadBalancer\n```\n\n## Deploy private nginx-ingress-controller\n\nMake sure to specify `ingress-class` in nginx-ingress-controller container args:\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  labels:\n    app: internal-ingress\n  name: internal-ingress-controller\nspec:\n  replicas: 1\n  selector:\n    matchLabels:\n      app: internal-ingress\n  template:\n    metadata:\n      labels:\n        app: internal-ingress\n    spec:\n      containers:\n      - args:\n        - /nginx-ingress-controller\n        - --default-backend-service=$(POD_NAMESPACE)/default-http-backend\n        - --configmap=$(POD_NAMESPACE)/internal-ingress-configuration\n        - --tcp-services-configmap=$(POD_NAMESPACE)/internal-tcp-services\n        - --udp-services-configmap=$(POD_NAMESPACE)/internal-udp-services\n        - --annotations-prefix=nginx.ingress.kubernetes.io\n        - --ingress-class=internal-ingress\n        - --publish-service=$(POD_NAMESPACE)/internal-ingress\n        env:\n        - name: POD_NAME\n          valueFrom:\n            fieldRef:\n              apiVersion: v1\n              fieldPath: metadata.name\n        - name: POD_NAMESPACE\n          valueFrom:\n            fieldRef:\n              apiVersion: v1\n              fieldPath: metadata.namespace\n        image: quay.io/kubernetes-ingress-controller/nginx-ingress-controller:0.11.0\n        livenessProbe:\n          failureThreshold: 3\n          httpGet:\n            path: /healthz\n            port: 10254\n            scheme: HTTP\n          initialDelaySeconds: 10\n          periodSeconds: 10\n          successThreshold: 1\n          timeoutSeconds: 1\n        name: internal-ingress-controller\n        ports:\n        - containerPort: 80\n          name: http\n          protocol: TCP\n        - containerPort: 443\n          name: https\n          protocol: TCP\n        readinessProbe:\n          failureThreshold: 3\n          httpGet:\n            path: /healthz\n            port: 10254\n            scheme: HTTP\n          periodSeconds: 10\n          successThreshold: 1\n          timeoutSeconds: 1\n```\n\nSet additional annotations in your private nginx-ingress-controller Service definition to create an internal load balancer.\n\n```yaml\napiVersion: v1\nkind: Service\nmetadata:\n  annotations:\n    service.beta.kubernetes.io/aws-load-balancer-connection-idle-timeout: \"3600\"\n    service.beta.kubernetes.io/aws-load-balancer-internal: 0.0.0.0/0\n    service.beta.kubernetes.io/aws-load-balancer-proxy-protocol: '*'\n  labels:\n    app: internal-ingress\n  name: internal-ingress\nspec:\n  externalTrafficPolicy: Cluster\n  ports:\n  - name: http\n    port: 80\n    protocol: TCP\n    targetPort: http\n  - name: https\n    port: 443\n    protocol: TCP\n    targetPort: https\n  selector:\n    app: internal-ingress\n  sessionAffinity: None\n  type: LoadBalancer\n```\n\n## Deploy the public zone ExternalDNS\n\nConsult [AWS ExternalDNS setup docs](aws.md) for installation guidelines.\n\nIn ExternalDNS containers args, make sure to specify `aws-zone-type` and `ingress-class`:\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  labels:\n    app: external-dns-public\n  name: external-dns-public\n  namespace: kube-system\nspec:\n  replicas: 1\n  selector:\n    matchLabels:\n      app: external-dns-public\n  strategy:\n    type: Recreate\n  template:\n    metadata:\n      labels:\n        app: external-dns-public\n    spec:\n      containers:\n      - args:\n        - --source=ingress\n        - --provider=aws\n        - --registry=txt\n        - --txt-owner-id=external-dns\n        - --ingress-class=external-ingress\n        - --aws-zone-type=public\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        name: external-dns-public\n```\n\n## Deploy the private zone ExternalDNS\n\nConsult [AWS ExternalDNS setup docs](aws.md) for installation guidelines.\n\nIn ExternalDNS containers args, make sure to specify `aws-zone-type` and `ingress-class`:\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  labels:\n    app: external-dns-private\n  name: external-dns-private\n  namespace: kube-system\nspec:\n  replicas: 1\n  selector:\n    matchLabels:\n      app: external-dns-private\n  strategy:\n    type: Recreate\n  template:\n    metadata:\n      labels:\n        app: external-dns-private\n    spec:\n      containers:\n      - args:\n        - --source=ingress\n        - --provider=aws\n        - --registry=txt\n        - --txt-owner-id=dev.k8s.nexus\n        - --ingress-class=internal-ingress\n        - --aws-zone-type=private\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        name: external-dns-private\n```\n\n## Create application Service definitions\n\nFor this setup to work, you need to create two Ingress definitions for your application.\n\nAt first, create a public Ingress definition:\n\n```yaml\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  labels:\n    app: app\n  name: app-public\nspec:\n  ingressClassName: external-ingress\n  rules:\n  - host: app.domain.com\n    http:\n      paths:\n      - backend:\n          service:\n            name: app\n            port:\n              number: 80\n        pathType: Prefix\n```\n\nThen create a private Ingress definition:\n\n```yaml\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  labels:\n    app: app\n  name: app-private\nspec:\n  ingressClassName: internal-ingress\n  rules:\n  - host: app.domain.com\n    http:\n      paths:\n      - backend:\n          service:\n            name: app\n            port:\n              number: 80\n        pathType: Prefix\n```\n\nAdditionally, you may leverage [cert-manager](https://github.com/jetstack/cert-manager) to automatically issue SSL certificates from [Let's Encrypt](https://letsencrypt.org/). To do that, request a certificate in public service definition:\n\n```yaml\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  annotations:\n    certmanager.k8s.io/acme-challenge-type: \"dns01\"\n    certmanager.k8s.io/acme-dns01-provider: \"route53\"\n    certmanager.k8s.io/cluster-issuer: \"letsencrypt-production\"\n    kubernetes.io/tls-acme: \"true\"\n  labels:\n    app: app\n  name: app-public\nspec:\n  ingressClassName: \"external-ingress\"\n  rules:\n  - host: app.domain.com\n    http:\n      paths:\n      - backend:\n          service:\n            name: app\n            port:\n              number: 80\n        pathType: Prefix\n  tls:\n  - hosts:\n    - app.domain.com\n    secretName: app-tls\n```\n\nAnd reuse the requested certificate in private Service definition:\n\n```yaml\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  labels:\n    app: app\n  name: app-private\nspec:\n  ingressClassName: \"internal-ingress\"\n  rules:\n  - host: app.domain.com\n    http:\n      paths:\n      - backend:\n          service:\n            name: app\n            port:\n              number: 80\n        pathType: Prefix\n  tls:\n  - hosts:\n    - app.domain.com\n    secretName: app-tls\n```\n"
  },
  {
    "path": "docs/tutorials/aws-sd.md",
    "content": "# AWS Cloud Map API\n\nThis tutorial describes how to set up ExternalDNS for usage within a Kubernetes cluster with [AWS Cloud Map API](https://docs.aws.amazon.com/cloud-map/).\n\n**AWS Cloud Map** API is an alternative approach to managing DNS records directly using the Route53 API. It is more suitable for a dynamic environment where service endpoints change frequently.\nIt abstracts away technical details of the DNS protocol and offers a simplified model. AWS Cloud Map consists of three main API calls:\n\n* CreatePublicDnsNamespace – automatically creates a DNS hosted zone\n* CreateService – creates a new named service inside the specified namespace\n* RegisterInstance/DeregisterInstance – can be called multiple times to create a DNS record for the specified *Service*\n\nLearn more about the API in the [AWS Cloud Map API Reference](https://docs.aws.amazon.com/cloud-map/latest/api/API_Operations.html).\n\n## IAM Permissions\n\nTo use the AWS Cloud Map API, a user must have permissions to create the DNS namespace. You need to make sure that your nodes (on which External DNS runs) have an IAM instance profile with the `AWSCloudMapFullAccess` managed policy attached, that provides following permissions:\n\n> Please be aware that this IAM role grants broad permissions across Route 53, and Service Discovery. For enhanced security, it's strongly recommended to review and restrict the actions and resources to the absolute minimum required for its intended purpose, following the principle of least privilege\n\n```json\n{\n  \"Version\": \"2012-10-17\",\n  \"Statement\": [\n    {\n      \"Effect\": \"Allow\",\n      \"Action\": [\n        \"route53:GetHostedZone\",\n        \"route53:ListHostedZonesByName\",\n        \"route53:CreateHostedZone\",\n        \"route53:DeleteHostedZone\",\n        \"route53:ChangeResourceRecordSets\",\n        \"route53:CreateHealthCheck\",\n        \"route53:GetHealthCheck\",\n        \"route53:DeleteHealthCheck\",\n        \"route53:UpdateHealthCheck\",\n        \"ec2:DescribeVpcs\",\n        \"ec2:DescribeRegions\",\n        \"servicediscovery:*\"\n      ],\n      \"Resource\": [\n        \"*\"\n      ]\n    }\n  ]\n}\n```\n\n### IAM Permissions with ABAC\n\nYou can use Attribute-based access control(ABAC) for advanced deployments.\n\nYou can define AWS tags that are applied to services created by the controller. By doing so, you can have precise control over your IAM policy to limit the scope of the permissions to services managed by the controller, rather than having to grant full permissions on your entire AWS account.\nTo pass tags to service creation, use either CLI flags or environment variables:\n\n*cli:* `--aws-sd-create-tag=key1=value1 --aws-sd-create-tag=key2=value2`\n\n*environment:* `EXTERNAL_DNS_AWS_SD_CREATE_TAG=key1=value1\\nkey2=value2`\n\nUsing tags, your `servicediscovery` policy can become:\n\n```json\n{\n  \"Version\": \"2012-10-17\",\n  \"Statement\": [\n    {\n      \"Effect\": \"Allow\",\n      \"Action\": [\n        \"route53:ChangeResourceRecordSets\"\n      ],\n      \"Resource\": [\n        \"arn:aws:route53:::hostedzone/*\"\n      ],\n      \"Condition\": {\n        \"ForAllValues:StringLike\": {\n          \"route53:ChangeResourceRecordSetsNormalizedRecordNames\": [\"*example.com\", \"marketing.example.com\", \"*-beta.example.com\"],\n          \"route53:ChangeResourceRecordSetsActions\": [\"CREATE\", \"UPSERT\", \"DELETE\"],\n          \"route53:ChangeResourceRecordSetsRecordTypes\": [\"A\", \"AAAA\", \"CNAME\", \"MX\", \"TXT\"]\n        }\n      }\n    },\n    {\n      \"Effect\": \"Allow\",\n      \"Action\": [\n        \"servicediscovery:ListNamespaces\",\n        \"servicediscovery:ListServices\"\n      ],\n      \"Resource\": [\n        \"*\"\n      ]\n    },\n    {\n      \"Effect\": \"Allow\",\n      \"Action\": [\n        \"servicediscovery:CreateService\",\n        \"servicediscovery:TagResource\"\n      ],\n      \"Resource\": [\n        \"*\"\n      ],\n      \"Condition\": {\n        \"StringEquals\": {\n          \"aws:RequestTag/YOUR_TAG_KEY\": \"YOUR_TAG_VALUE\"\n        }\n      }\n    },\n    {\n      \"Effect\": \"Allow\",\n      \"Action\": [\n        \"servicediscovery:DiscoverInstances\"\n      ],\n      \"Resource\": [\n        \"*\"\n      ],\n      \"Condition\": {\n        \"StringEquals\": {\n          \"servicediscovery:NamespaceName\": \"YOUR_NAMESPACE_NAME\"\n        }\n      }\n    },\n    {\n      \"Effect\": \"Allow\",\n      \"Action\": [\n        \"servicediscovery:RegisterInstance\",\n        \"servicediscovery:DeregisterInstance\",\n        \"servicediscovery:DeleteService\",\n        \"servicediscovery:UpdateService\"\n      ],\n      \"Resource\": [\n        \"*\"\n      ],\n      \"Condition\": {\n        \"StringEquals\": {\n          \"aws:ResourceTag/YOUR_TAG_KEY\": \"YOUR_TAG_VALUE\"\n        }\n      }\n    }\n  ]\n}\n```\n\nAdditional resources:\n\n* AWS IAM actions [documentation](https://www.awsiamactions.io/?o=servicediscovery%3A)\n\n## Set up a namespace\n\nCreate a DNS namespace using the AWS Cloud Map API:\n\n```console\naws servicediscovery create-public-dns-namespace --name \"external-dns-test.my-org.com\"\n```\n\nVerify that the namespace was truly created\n\n```console\naws servicediscovery list-namespaces\n```\n\n## Deploy ExternalDNS\n\nConnect your `kubectl` client to the cluster that you want to test ExternalDNS with.\nThen apply the following manifest file to deploy ExternalDNS.\n\n### Manifest (for clusters without RBAC enabled)\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        env:\n          - name: AWS_REGION\n            value: us-east-1 # put your CloudMap NameSpace region\n        args:\n        - --source=service\n        - --source=ingress\n        - --domain-filter=external-dns-test.my-org.com # Makes ExternalDNS see only the namespaces that match the specified domain. Omit the filter if you want to process all available namespaces.\n        - --provider=aws-sd\n        - --aws-zone-type=public # Only look at public namespaces. Valid values are public, private, or no value for both)\n        - --txt-owner-id=my-identifier\n```\n\n### Manifest (for clusters with RBAC enabled)\n\n```yaml\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: external-dns\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: external-dns\nrules:\n- apiGroups: [\"\"]\n  resources: [\"services\",\"pods\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"discovery.k8s.io\"]\n  resources: [\"endpointslices\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"extensions\",\"networking.k8s.io\"]\n  resources: [\"ingresses\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"\"]\n  resources: [\"nodes\"]\n  verbs: [\"list\",\"watch\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: external-dns-viewer\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: external-dns\nsubjects:\n- kind: ServiceAccount\n  name: external-dns\n  namespace: default\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      serviceAccountName: external-dns\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        env:\n          - name: AWS_REGION\n            value: us-east-1 # put your CloudMap NameSpace region\n        args:\n        - --source=service\n        - --source=ingress\n        - --domain-filter=external-dns-test.my-org.com # Makes ExternalDNS see only the namespaces that match the specified domain. Omit the filter if you want to process all available namespaces.\n        - --provider=aws-sd\n        - --aws-zone-type=public # Only look at public namespaces. Valid values are public, private, or no value for both)\n        - --txt-owner-id=my-identifier\n```\n\n## Verify that ExternalDNS works (Service example)\n\nCreate the following sample application to test that ExternalDNS works.\n\n> For services ExternalDNS will look for the annotation `external-dns.alpha.kubernetes.io/hostname` on the service and use the corresponding value.\n\n```yaml\napiVersion: v1\nkind: Service\nmetadata:\n  name: nginx\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: nginx.external-dns-test.my-org.com\nspec:\n  type: LoadBalancer\n  ports:\n  - port: 80\n    name: http\n    targetPort: 80\n  selector:\n    app: nginx\n\n---\n\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: nginx\nspec:\n  selector:\n    matchLabels:\n      app: nginx\n  template:\n    metadata:\n      labels:\n        app: nginx\n    spec:\n      containers:\n      - image: nginx\n        name: nginx\n        ports:\n        - containerPort: 80\n          name: http\n```\n\nAfter one minute check that a corresponding DNS record for your service was created in your hosted zone. We recommended that you use the [Amazon Route53 console](https://console.aws.amazon.com/route53) for that purpose.\n\n## Custom TTL\n\nThe default DNS record TTL (time to live) is 300 seconds. You can customize this value by setting the annotation `external-dns.alpha.kubernetes.io/ttl`.\nFor example, modify the service manifest YAML file above:\n\n```yaml\napiVersion: v1\nkind: Service\nmetadata:\n  name: nginx\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: nginx.external-dns-test.my-org.com\n    external-dns.alpha.kubernetes.io/ttl: \"60\"\nspec:\n    ...\n```\n\nThis will set the TTL for the DNS record to 60 seconds.\n\n## IPv6 Support\n\nIf your Kubernetes cluster is configured with IPv6 support, such as an [EKS cluster with IPv6 support](https://docs.aws.amazon.com/eks/latest/userguide/deploy-ipv6-cluster.html), ExternalDNS can\nalso create AAAA DNS records.\n\n```yaml\napiVersion: v1\nkind: Service\nmetadata:\n  name: nginx\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: nginx.external-dns-test.my-org.com\n    external-dns.alpha.kubernetes.io/ttl: \"60\"\nspec:\n  ipFamilies:\n    - \"IPv6\"\n  type: NodePort\n  ports:\n    - port: 80\n      name: http\n      targetPort: 80\n  selector:\n    app: nginx\n```\n\n:information_source: The AWS-SD provider does not currently support dualstack load balancers and will only create A records for these at this time. See the AWS provider and the [AWS Load Balancer Controller Tutorial](./aws-load-balancer-controller.md) for dualstack load balancer support.\n\n## Clean up\n\nDelete all service objects before terminating the cluster so all load balancers get cleaned up correctly.\n\n```console\nkubectl delete service nginx\n```\n\nGive ExternalDNS some time to clean up the DNS records for you. Then delete the remaining service and namespace.\n\n```console\n$ aws servicediscovery list-services\n\n{\n    \"Services\": [\n        {\n            \"Id\": \"srv-6dygt5ywvyzvi3an\",\n            \"Arn\": \"arn:aws:servicediscovery:us-west-2:861574988794:service/srv-6dygt5ywvyzvi3an\",\n            \"Name\": \"nginx\"\n        }\n    ]\n}\n```\n\n```console\naws servicediscovery delete-service --id srv-6dygt5ywvyzvi3an\n```\n\n```console\n$ aws servicediscovery list-namespaces\n{\n    \"Namespaces\": [\n        {\n            \"Type\": \"DNS_PUBLIC\",\n            \"Id\": \"ns-durf2oxu4gxcgo6z\",\n            \"Arn\": \"arn:aws:servicediscovery:us-west-2:861574988794:namespace/ns-durf2oxu4gxcgo6z\",\n            \"Name\": \"external-dns-test.my-org.com\"\n        }\n    ]\n}\n```\n\n```console\naws servicediscovery delete-namespace --id ns-durf2oxu4gxcgo6z\n```\n"
  },
  {
    "path": "docs/tutorials/aws.md",
    "content": "# AWS\n\nThis tutorial describes how to setup ExternalDNS for usage within a Kubernetes cluster on AWS. Make sure to use **>=0.15.0** version of ExternalDNS for this tutorial\n\n## IAM Policy\n\nThe following IAM Policy document allows ExternalDNS to update Route53 Resource\nRecord Sets and Hosted Zones. You'll want to create this Policy in IAM first. In\nour example, we'll call the policy `AllowExternalDNSUpdates` (but you can call\nit whatever you prefer).\n\n```json\n{\n  \"Version\": \"2012-10-17\",\n  \"Statement\": [\n    {\n      \"Effect\": \"Allow\",\n      \"Action\": [\n        \"route53:ChangeResourceRecordSets\",\n        \"route53:ListResourceRecordSets\",\n        \"route53:ListTagsForResources\"\n      ],\n      \"Resource\": [\n        \"arn:aws:route53:::hostedzone/*\"\n      ]\n    },\n    {\n      \"Effect\": \"Allow\",\n      \"Action\": [\n        \"route53:ListHostedZones\"\n      ],\n      \"Resource\": [\n        \"*\"\n      ]\n    }\n  ]\n}\n```\n\n### IAM Permissions with ABAC\n\nYou can use Attribute-based access control(ABAC) for advanced deployments.\n\n```json\n{\n  \"Version\": \"2012-10-17\",\n  \"Statement\": [\n    {\n      \"Effect\": \"Allow\",\n      \"Action\": [\n        \"route53:ChangeResourceRecordSets\",\n        \"route53:ListResourceRecordSets\",\n        \"route53:ListTagsForResources\"\n      ],\n      \"Resource\": [\n        \"arn:aws:route53:::hostedzone/*\"\n      ],\n      \"Condition\": {\n        \"ForAllValues:StringLike\": {\n          \"route53:ChangeResourceRecordSetsNormalizedRecordNames\": [\"*example.com\", \"marketing.example.com\", \"*-beta.example.com\"],\n          \"route53:ChangeResourceRecordSetsActions\": [\"CREATE\", \"UPSERT\", \"DELETE\"],\n          \"route53:ChangeResourceRecordSetsRecordTypes\": [\"A\", \"AAAA\", \"CNAME\", \"MX\", \"TXT\"]\n        }\n      }\n    },\n    {\n      \"Effect\": \"Allow\",\n      \"Action\": [\n        \"route53:ListHostedZones\"\n      ],\n      \"Resource\": [\n        \"*\"\n      ]\n    }\n  ]\n}\n```\n\n### Further improvements\n\nBoth policies can be further enhanced by tightening them down following the [principle of least privilege](https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#grant-least-privilege).\nExplicitly providing a list of selected zones instead of `*` you can scope the deployment down allowing changes only to zones from the list hence reducing the blast radius and improving auditability.\n\nAdditional resources:\n\n- AWS IAM actions [documentation](https://www.awsiamactions.io/?o=route53%3A)\n- AWS IAM [fine grained controll](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/specifying-conditions-route53.html#route53_rrsetConditionKeys)\n- [Actions and condition keys for Amazon Route 53](https://docs.aws.amazon.com/service-authorization/latest/reference/list_amazonroute53.html)\n\n## Create Role with AWS CLI\n\nIf you are using the AWS CLI, you can run the following to install the above policy (saved as `policy.json`).  This can be use in subsequent steps to allow ExternalDNS to access Route53 zones.\n\n```bash\naws iam create-policy --policy-name \"AllowExternalDNSUpdates\" --policy-document file://policy.json\n\n# example: arn:aws:iam::XXXXXXXXXXXX:policy/AllowExternalDNSUpdates\nexport POLICY_ARN=$(aws iam list-policies \\\n --query 'Policies[?PolicyName==`AllowExternalDNSUpdates`].Arn' --output text)\n```\n\n## Provisioning a Kubernetes cluster\n\nYou can use [eksctl](https://eksctl.io) to easily provision an [Amazon Elastic Kubernetes Service](https://aws.amazon.com/eks) ([EKS](https://aws.amazon.com/eks)) cluster that is suitable for this tutorial.  See [Getting started with Amazon EKS – eksctl](https://docs.aws.amazon.com/eks/latest/userguide/getting-started-eksctl.html).\n\n```bash\nexport EKS_CLUSTER_NAME=\"my-externaldns-cluster\"\nexport EKS_CLUSTER_REGION=\"us-east-2\"\nexport KUBECONFIG=\"$HOME/.kube/${EKS_CLUSTER_NAME}-${EKS_CLUSTER_REGION}.yaml\"\n\neksctl create cluster --name $EKS_CLUSTER_NAME --region $EKS_CLUSTER_REGION\n```\n\nFeel free to use other provisioning tools or an existing cluster.\nIf [Terraform](https://www.terraform.io/) is used, [vpc](https://registry.terraform.io/modules/terraform-aws-modules/vpc/aws/) and [eks](https://registry.terraform.io/modules/terraform-aws-modules/eks/aws/) modules are recommended for standing up an EKS cluster.\nAmazon has a workshop called [Amazon EKS Terraform Workshop](https://catalog.us-east-1.prod.workshops.aws/workshops/afee4679-89af-408b-8108-44f5b1065cc7/) that may be useful for this process.\n\n## Permissions to modify DNS zone\n\nYou will need to use the above policy (represented by the `POLICY_ARN` environment variable) to allow ExternalDNS to update records in Route53 DNS zones. Here are three common ways this can be accomplished:\n\n- [Node IAM Role](#node-iam-role)\n- [Static credentials](#static-credentials)\n- [IAM Roles for Service Accounts](#iam-roles-for-service-accounts)\n\nFor this tutorial, ExternalDNS will use the environment variable `EXTERNALDNS_NS` to represent the namespace, defaulted to `default`.\nFeel free to change this to something else, such `externaldns` or `kube-addons`.\nMake sure to edit the `subjects[0].namespace` for the `ClusterRoleBinding` resource when deploying ExternalDNS with RBAC enabled.\nSee [When using clusters with RBAC enabled](#when-using-clusters-with-rbac-enabled) for more information.\n\nAdditionally, throughout this tutorial, the example domain of `example.com` is used.  Change this to appropriate domain under your control.  See [Set up a hosted zone](#set-up-a-hosted-zone) section.\n\n### Node IAM Role\n\nIn this method, you can attach a policy to the Node IAM Role. This will allow nodes in the Kubernetes cluster to access Route53 zones, which allows ExternalDNS to update DNS records.\nGiven that this allows all containers to access Route53, not just ExternalDNS, running on the node with these privileges, this method is not recommended, and is only suitable for limited test environments.\n\nIf you are using eksctl to provision a new cluster, you add the policy at creation time with:\n\n```bash\neksctl create cluster --external-dns-access \\\n  --name $EKS_CLUSTER_NAME --region $EKS_CLUSTER_REGION \\\n```\n\n:warning: **WARNING**: This will assign allow read-write access to all nodes in the cluster, not just ExternalDNS.  For this reason, this method is only suitable for limited test environments.\n\nIf you already provisioned a cluster or use other provisioning tools like Terraform, you can use AWS CLI to attach the policy to the Node IAM Role.\n\n#### Get the Node IAM role name\n\nThe role name of the role associated with the node(s) where ExternalDNS will run is needed.  An easy way to get the role name is to use the AWS web console (https://console.aws.amazon.com/eks/), and find any instance in the target node group and copy the role name associated with that instance.\n\n##### Get role name with a single managed nodegroup\n\nFrom the command line, if you have a single managed node group, the default with `eksctl create cluster`, you can find the role name with the following:\n\n```bash\n# get managed node group name (assuming there's only one node group)\nGROUP_NAME=$(aws eks list-nodegroups --cluster-name $EKS_CLUSTER_NAME \\\n  --query nodegroups --out text)\n# fetch role arn given node group name\nROLE_ARN=$(aws eks describe-nodegroup --cluster-name $EKS_CLUSTER_NAME \\\n  --nodegroup-name $GROUP_NAME --query nodegroup.nodeRole --out text)\n# extract just the name part of role arn\nROLE_NAME=${NODE_ROLE_ARN##*/}\n```\n\n##### Get role name with other configurations\n\nIf you have multiple node groups or any unmanaged node groups, the process gets more complex.  The first step is to get the instance host name of the desired node to where ExternalDNS will be deployed or is already deployed:\n\n```bash\n# node instance name of one of the external dns pods currently running\nINSTANCE_NAME=$(kubectl get pods --all-namespaces \\\n  --selector app.kubernetes.io/instance=external-dns \\\n  --output jsonpath='{.items[0].spec.nodeName}')\n\n# instance name of one of the nodes (change if node group is different)\nINSTANCE_NAME=$(kubectl get nodes --output name | cut -d'/' -f2 | tail -1)\n```\n\nWith the instance host name, you can then get the instance id:\n\n```bash\nget_instance_id() {\n  INSTANCE_NAME=$1 # example: ip-192-168-74-34.us-east-2.compute.internal\n\n  # get list of nodes\n  # ip-192-168-74-34.us-east-2.compute.internal aws:///us-east-2a/i-xxxxxxxxxxxxxxxxx\n  # ip-192-168-86-105.us-east-2.compute.internal aws:///us-east-2a/i-xxxxxxxxxxxxxxxxx\n  NODES=$(kubectl get nodes \\\n   --output jsonpath='{range .items[*]}{.metadata.name}{\"\\t\"}{.spec.providerID}{\"\\n\"}{end}')\n\n  # print instance id from matching node\n  grep $INSTANCE_NAME <<< \"$NODES\" | cut -d'/' -f5\n}\n\nINSTANCE_ID=$(get_instance_id $INSTANCE_NAME)\n```\n\nWith the instance id, you can get the associated role name:\n\n```bash\nfindRoleName() {\n  INSTANCE_ID=$1\n\n  # get all of the roles\n  ROLES=($(aws iam list-roles --query Roles[*].RoleName --out text))\n  for ROLE in ${ROLES[*]}; do\n    # get instance profile arn\n    PROFILE_ARN=$(aws iam list-instance-profiles-for-role \\\n      --role-name $ROLE --query InstanceProfiles[0].Arn --output text)\n    # if there is an instance profile\n    if [[ \"$PROFILE_ARN\" != \"None\" ]]; then\n      # get all the instances with this associated instance profile\n      INSTANCES=$(aws ec2 describe-instances \\\n        --filters Name=iam-instance-profile.arn,Values=$PROFILE_ARN \\\n        --query Reservations[*].Instances[0].InstanceId --out text)\n      # find instances that match the instant profile\n      for INSTANCE in ${INSTANCES[*]}; do\n        # set role name value if there is a match\n        if [[ \"$INSTANCE_ID\" == \"$INSTANCE\" ]]; then ROLE_NAME=$ROLE; fi\n      done\n    fi\n  done\n\n  echo $ROLE_NAME\n}\n\nNODE_ROLE_NAME=$(findRoleName $INSTANCE_ID)\n```\n\nUsing the role name, you can associate the policy that was created earlier:\n\n```bash\n# attach policy arn created earlier to node IAM role\naws iam attach-role-policy --role-name $NODE_ROLE_NAME --policy-arn $POLICY_ARN\n```\n\n:warning: **WARNING**: This will assign allow read-write access to all pods running on the same node pool, not just the ExternalDNS pod(s).\n\n#### Deploy ExternalDNS with attached policy to Node IAM Role\n\nIf ExternalDNS is not yet deployed, follow the steps under [Deploy ExternalDNS](#deploy-externaldns) using either RBAC or non-RBAC.\n\n**NOTE**: Before deleting the cluster during, be sure to run `aws iam detach-role-policy`.  Otherwise, there can be errors as the provisioning system, such as `eksctl` or `terraform`, will not be able to delete the roles with the attached policy.\n\n### Static credentials\n\nIn this method, the policy is attached to an IAM user, and the credentials secrets for the IAM user are then made available using a Kubernetes secret.\n\nThis method is not the preferred method as the secrets in the credential file could be copied and used by an unauthorized threat actor.\nHowever, if the Kubernetes cluster is not hosted on AWS, it may be the only method available.\nGiven this situation, it is important to limit the associated privileges to just minimal required privileges, i.e. read-write access to Route53, and not used a credentials file that has extra privileges beyond what is required.\n\n#### Create IAM user and attach the policy\n\n```bash\n# create IAM user\naws iam create-user --user-name \"externaldns\"\n\n# attach policy arn created earlier to IAM user\naws iam attach-user-policy --user-name \"externaldns\" --policy-arn $POLICY_ARN\n```\n\n#### Create the static credentials\n\n```bash\nSECRET_ACCESS_KEY=$(aws iam create-access-key --user-name \"externaldns\")\nACCESS_KEY_ID=$(echo $SECRET_ACCESS_KEY | jq -r '.AccessKey.AccessKeyId')\n\ncat <<-EOF > credentials\n\n[default]\naws_access_key_id = $(echo $ACCESS_KEY_ID)\naws_secret_access_key = $(echo $SECRET_ACCESS_KEY | jq -r '.AccessKey.SecretAccessKey')\nEOF\n```\n\n#### Create Kubernetes secret from credentials\n\n```bash\nkubectl create secret generic external-dns \\\n  --namespace ${EXTERNALDNS_NS:-\"default\"} --from-file /local/path/to/credentials\n```\n\n#### Deploy ExternalDNS using static credentials\n\nFollow the steps under [Deploy ExternalDNS](#deploy-externaldns) using either RBAC or non-RBAC.  Make sure to uncomment the section that mounts volumes, so that the credentials can be mounted.\n\n> [!TIP]\n> By default ExternalDNS takes the profile named `default` from the credentials file. If you want to use a different\n> profile, you can set the environment variable `EXTERNAL_DNS_AWS_PROFILE` to the desired profile name or use the\n> `--aws-profile` command line argument. It is even possible to use more than one profile at ones, separated by space in\n> the environment variable `EXTERNAL_DNS_AWS_PROFILE` or by using `--aws-profile` multiple times. In this case\n> ExternalDNS looks for the hosted zones in all profiles and keeps maintaining a mapping table between zone and profile\n> in order to be able to modify the zones in the correct profile.\n\n### IAM Roles for Service Accounts\n\n[IRSA](https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html) ([IAM roles for Service Accounts](https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html)) allows cluster operators to map AWS IAM Roles to Kubernetes Service Accounts.\nThis essentially allows only ExternalDNS pods to access Route53 without exposing any static credentials.\n\nThis is the preferred method as it implements [PoLP](https://csrc.nist.gov/glossary/term/principle_of_least_privilege) ([Principle of Least Privilege](https://csrc.nist.gov/glossary/term/principle_of_least_privilege)).\n\n> [!IMPORTANT]\n> This method requires using KSA (Kubernetes service account) and RBAC.\n\nThis method requires deploying with RBAC.  See [When using clusters with RBAC enabled](#when-using-clusters-with-rbac-enabled) when ready to deploy ExternalDNS.\n\n> [!NOTE]\n> Similar methods to IRSA on AWS are [kiam](https://github.com/uswitch/kiam), which is in maintenence mode, and has [instructions](https://github.com/uswitch/kiam/blob/HEAD/docs/IAM.md) for creating an IAM role, and also [kube2iam](https://github.com/jtblin/kube2iam).\n> IRSA is the officially supported method for EKS clusters, and so for non-EKS clusters on AWS, these other tools could be an option.\n\n#### Verify OIDC is supported\n\n```bash\naws eks describe-cluster --name $EKS_CLUSTER_NAME \\\n  --query \"cluster.identity.oidc.issuer\" --output text\n```\n\n#### Associate OIDC to cluster\n\nConfigure the cluster with an OIDC provider and add support for [IRSA](https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html) ([IAM roles for Service Accounts](https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html)).\n\nIf you used `eksctl` to provision the EKS cluster, you can update it with the following command:\n\n```bash\neksctl utils associate-iam-oidc-provider \\\n  --cluster $EKS_CLUSTER_NAME --approve\n```\n\nIf the cluster was provisioned with Terraform, you can use the `iam_openid_connect_provider` resource ([ref](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_openid_connect_provider)) to associate to the OIDC provider.\n\n#### Create an IAM role bound to a service account\n\nFor the next steps in this process, we will need to associate the `external-dns` service account and a role used to grant access to Route53.  This requires the following steps:\n\n1. Create a role with a trust relationship to the cluster's OIDC provider\n2. Attach the `AllowExternalDNSUpdates` policy to the role\n3. Create the `external-dns` service account\n4. Add annotation to the service account with the role arn\n\n##### Use eksctl with eksctl created EKS cluster\n\nIf `eksctl` was used to provision the EKS cluster, you can perform all of these steps with the following command:\n\n```bash\neksctl create iamserviceaccount \\\n  --cluster $EKS_CLUSTER_NAME \\\n  --name \"external-dns\" \\\n  --namespace ${EXTERNALDNS_NS:-\"default\"} \\\n  --attach-policy-arn $POLICY_ARN \\\n  --approve\n```\n\n##### Use aws cli with any EKS cluster\n\nOtherwise, we can do the following steps using `aws` commands (also see [Creating an IAM role and policy for your service account](https://docs.aws.amazon.com/eks/latest/userguide/create-service-account-iam-policy-and-role.html)):\n\n```bash\nACCOUNT_ID=$(aws sts get-caller-identity \\\n  --query \"Account\" --output text)\nOIDC_PROVIDER=$(aws eks describe-cluster --name $EKS_CLUSTER_NAME \\\n  --query \"cluster.identity.oidc.issuer\" --output text | sed -e 's|^https://||')\n\ncat <<-EOF > trust.json\n{\n    \"Version\": \"2012-10-17\",\n    \"Statement\": [\n        {\n            \"Effect\": \"Allow\",\n            \"Principal\": {\n                \"Federated\": \"arn:aws:iam::$ACCOUNT_ID:oidc-provider/$OIDC_PROVIDER\"\n            },\n            \"Action\": \"sts:AssumeRoleWithWebIdentity\",\n            \"Condition\": {\n                \"StringEquals\": {\n                    \"$OIDC_PROVIDER:sub\": \"system:serviceaccount:${EXTERNALDNS_NS:-\"default\"}:external-dns\",\n                    \"$OIDC_PROVIDER:aud\": \"sts.amazonaws.com\"\n                }\n            }\n        }\n    ]\n}\nEOF\n\nIRSA_ROLE=\"external-dns-irsa-role\"\naws iam create-role --role-name $IRSA_ROLE --assume-role-policy-document file://trust.json\naws iam attach-role-policy --role-name $IRSA_ROLE --policy-arn $POLICY_ARN\n\nROLE_ARN=$(aws iam get-role --role-name $IRSA_ROLE --query Role.Arn --output text)\n\n# Create service account (skip if already created)\nkubectl create serviceaccount \"external-dns\" --namespace ${EXTERNALDNS_NS:-\"default\"}\n\n# Add annotation referencing IRSA role\nkubectl patch serviceaccount \"external-dns\" --namespace ${EXTERNALDNS_NS:-\"default\"} --patch \\\n \"{\\\"metadata\\\": { \\\"annotations\\\": { \\\"eks.amazonaws.com/role-arn\\\": \\\"$ROLE_ARN\\\" }}}\"\n```\n\nIf any part of this step is misconfigured, such as the role with incorrect namespace configured in the trust relationship, annotation pointing the the wrong role, etc., you will see errors like `WebIdentityErr: failed to retrieve credentials`. Check the configuration and make corrections.\n\nWhen the service account annotations are updated, then the current running pods will have to be terminated, so that new pod(s) with proper configuration (environment variables) will be created automatically.\n\nWhen annotation is added to service account, the ExternalDNS pod(s) scheduled will have `AWS_ROLE_ARN`, `AWS_STS_REGIONAL_ENDPOINTS`, and `AWS_WEB_IDENTITY_TOKEN_FILE` environment variables injected automatically.\n\n#### Deploy ExternalDNS using IRSA\n\nFollow the steps under [When using clusters with RBAC enabled](#when-using-clusters-with-rbac-enabled).  Make sure to comment out the service account section if this has been created already.\n\nIf you deployed ExternalDNS before adding the service account annotation and the corresponding role, you will likely see error with `failed to list hosted zones: AccessDenied: User`.\nYou can delete the current running ExternalDNS pod(s) after updating the annotation, so that new pods scheduled will have appropriate configuration to access Route53.\n\n### EKS Pod Identity Associations\n\nAlternatively to [IRSA](#iam-roles-for-service-accounts) on AWS EKS it is possible to use the new native method `EKS Pod Identity`, which associates IAM roles with Kubernetes service accounts, simplifying the process of granting AWS permissions to any Pod.\n\n> [!IMPORTANT]\n> Differently from `IRSA`, this method is only available on AWS EKS clusters.\n> This feature also eliminates the need for third-party solutions such as [kiam](https://github.com/uswitch/kiam) or [kube2iam](https://github.com/jtblin/kube2iam).\n\n#### Check Pod Identity Agent is enabled\n\nThis method requires the `Pod Identity Agent` installed on the cluster, hence the AWS EKS add-on `eks-pod-identity-agent`.\nPod identity associations is running an agent as a daemonset on the worker nodes.\n\nIt is also possible to create the add-on using `eksctl`\n\n```bash\neksctl create addon --cluster $EKS_CLUSTER_NAME --name eks-pod-identity-agent\n```\n\n#### Create an IAM role bound to a service account\n\n##### Use eksctl with eksctl created EKS cluster\n\nIf `eksctl` was used to provision the EKS cluster, you can perform all of these steps with the following command:\n\n```bash\neksctl create podidentityassociation \\\n  --cluster $EKS_CLUSTER_NAME \\\n  --namespace ${EXTERNALDNS_NS:-\"default\"} \\\n  --service-account-name external-dns \\\n  --role-name external-dns-pod-identity-role \\\n  --permission-policy-arns $POLICY_ARN \\\n  --approve\n```\n\n##### Use aws cli with any EKS cluster\n\n```bash\ncat <<-EOF > assume_role.json\n{\n  \"Version\": \"2012-10-17\",\n  \"Statement\": [\n    {\n      \"Effect\": \"Allow\",\n      \"Principal\": {\n        \"Service\": \"pods.eks.amazonaws.com\"\n      },\n      \"Action\": [\n        \"sts:AssumeRole\",\n        \"sts:TagSession\"\n      ]\n    }\n  ]\n}\nEOF\n\nPOD_IDENTITY_ROLE=\"external-dns-pod-identity-role\"\n\naws iam create-role --role-name $POD_IDENTITY_ROLE --assume-role-policy-document file://assume_role.json\naws iam attach-role-policy --role-name $POD_IDENTITY_ROLE --policy-arn $POLICY_ARN\n\nROLE_ARN=$(aws iam get-role --role-name $POD_IDENTITY_ROLE --query Role.Arn --output text)\n\n# Create service account (skip if already created)\nkubectl create serviceaccount \"external-dns\" --namespace ${EXTERNALDNS_NS:-\"default\"}\n\n# Create Pod Identity association\naws eks create-pod-identity-association \\\n  --cluster-name $EKS_CLUSTER_NAME \\\n  --namespace ${EXTERNALDNS_NS:-\"default\"} \\\n  --service-account external-dns \\\n  --role-arn $ROLE_ARN\n```\n\n##### Use Terraform\n\nThe same behaviour above can be achieved using Terraform. Here is a minimal placeholder snippet:\n\n```hcl\ndata \"aws_iam_policy_document\" \"eks_assume_role\" {\n  statement {\n    effect = \"Allow\"\n    principals {\n      type        = \"Service\"\n      identifiers = [\"pods.eks.amazonaws.com\"]\n    }\n    actions = [\n      \"sts:AssumeRole\",\n      \"sts:TagSession\"\n    ]\n  }\n}\n\nresource \"aws_iam_role\" \"external_dns_pod_identity\" {\n  name               = \"external-dns-pod-identity\"\n  assume_role_policy = data.aws_iam_policy_document.eks_assume_role.json\n}\n\nresource \"aws_iam_role_policy_attachment\" \"external_dns_route53\" {\n  role       = aws_iam_role.external_dns_pod_identity.name\n  policy_arn = aws_iam_policy.external_dns_access.arn\n}\n\n# EKS Pod Identity for External DNS operator\n# connects Service Account and EDO Namespace (even if not created yet)\nresource \"aws_eks_pod_identity_association\" \"external_dns_pod_identity\" {\n  cluster_name    = aws_eks_cluster.eks.name\n  namespace       = \"external-dns\"\n  service_account = \"external-dns\"\n  role_arn        = aws_iam_role.external_dns_pod_identity.arn\n}\n```\n\n#### Deploy ExternalDNS using Pod Identity\n\nUnlike the IRSA method, Pod Identity requires no further steps, nor service account annotations, since the pod identity association will bind the service account to the given IAM role, hence to a policy holding the requested set of permissions.\nThe EKS Pod Identity Agent handles credential injection at runtime.\n\n## Set up a hosted zone\n\n*If you prefer to try-out ExternalDNS in one of the existing hosted-zones you can skip this step*\n\nCreate a DNS zone which will contain the managed DNS records.  This tutorial will use the fictional domain of `example.com`.\n\n```bash\naws route53 create-hosted-zone --name \"example.com.\" \\\n  --caller-reference \"external-dns-test-$(date +%s)\"\n```\n\nMake a note of the nameservers that were assigned to your new zone.\n\n```bash\nZONE_ID=$(aws route53 list-hosted-zones-by-name --output json \\\n  --dns-name \"example.com.\" --query HostedZones[0].Id --out text)\n\naws route53 list-resource-record-sets --output text \\\n --hosted-zone-id $ZONE_ID --query \\\n \"ResourceRecordSets[?Type == 'NS'].ResourceRecords[*].Value | []\" | tr '\\t' '\\n'\n```\n\nThis should yield something similar this:\n\n```sh\nns-695.awsdns-22.net.\nns-1313.awsdns-36.org.\nns-350.awsdns-43.com.\nns-1805.awsdns-33.co.uk.\n```\n\nIf using your own domain that was registered with a third-party domain registrar, you should point your domain's name servers to the values in the from the list above.  Please consult your registrar's documentation on how to do that.\n\n## Deploy ExternalDNS\n\nConnect your `kubectl` client to the cluster you want to test ExternalDNS with.\nThen apply one of the following manifests file to deploy ExternalDNS. You can check if your cluster has RBAC by `kubectl api-versions | grep rbac.authorization.k8s.io`.\n\nFor clusters with RBAC enabled, be sure to choose the correct `namespace`.  For this tutorial, the enviornment variable `EXTERNALDNS_NS` will refer to the namespace.  You can set this to a value of your choice:\n\n```bash\nexport EXTERNALDNS_NS=\"default\" # externaldns, kube-addons, etc\n\n# create namespace if it does not yet exist\nkubectl get namespaces | grep -q $EXTERNALDNS_NS || \\\n  kubectl create namespace $EXTERNALDNS_NS\n```\n\n## Using Helm (with OIDC)\n\nCreate a values.yaml file to configure ExternalDNS:\n\n```shell\nprovider:\n  name: aws\nenv:\n  - name: AWS_DEFAULT_REGION\n    value: us-east-1 # change to region where EKS is installed\n```\n\nFinally, install the ExternalDNS chart with Helm using the configuration specified in your values.yaml file:\n\n```shell\nhelm repo add --force-update external-dns https://kubernetes-sigs.github.io/external-dns/\n\nhelm upgrade --install external-dns external-dns/external-dns --values values.yaml\n```\n\n### When using clusters without RBAC enabled\n\nSave the following below as `externaldns-no-rbac.yaml`.\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\n  labels:\n    app.kubernetes.io/name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app.kubernetes.io/name: external-dns\n  template:\n    metadata:\n      labels:\n        app.kubernetes.io/name: external-dns\n    spec:\n      containers:\n        - name: external-dns\n          image: registry.k8s.io/external-dns/external-dns:v0.20.0\n          args:\n            - --source=service\n            - --source=ingress\n            - --domain-filter=example.com # will make ExternalDNS see only the hosted zones matching provided domain, omit to process all available hosted zones\n            - --provider=aws\n            - --policy=upsert-only # would prevent ExternalDNS from deleting any records, omit to enable full synchronization\n            - --aws-zone-type=public # only look at public hosted zones (valid values are public, private or no value for both)\n            - --registry=txt\n            - --txt-owner-id=my-hostedzone-identifier\n          env:\n            - name: AWS_DEFAULT_REGION\n              value: us-east-1 # change to region where EKS is installed\n      # # Uncomment below if using static credentials\n      #       - name: AWS_SHARED_CREDENTIALS_FILE\n      #        value: /.aws/credentials\n      #     volumeMounts:\n      #       - name: aws-credentials\n      #         mountPath: /.aws\n      #         readOnly: true\n      # volumes:\n      #   - name: aws-credentials\n      #     secret:\n      #       secretName: external-dns\n```\n\nWhen ready you can deploy:\n\n```bash\nkubectl create --filename externaldns-no-rbac.yaml \\\n  --namespace ${EXTERNALDNS_NS:-\"default\"}\n```\n\n### When using clusters with RBAC enabled\n\nIf you're using EKS, you can update the `values.yaml` file you created earlier to include the annotations to link the Role ARN you created before.\n\n```yaml\nprovider:\n  name: aws\nserviceAccount:\n  annotations:\n    eks.amazonaws.com/role-arn: arn:aws:iam::${ACCOUNT_ID}:role/${EXTERNALDNS_ROLE_NAME:-\"external-dns\"}\n```\n\nIf you need to provide credentials directly using a secret (ie. You're not using EKS), you can change the `values.yaml` file to include volume and volume mounts.\n\n```yaml\nprovider:\n  name: aws\nenv:\n  - name: AWS_SHARED_CREDENTIALS_FILE\n    value: /etc/aws/credentials/my_credentials\nextraVolumes:\n  - name: aws-credentials\n    secret:\n      secretName: external-dns # In this example, the secret will have the data stored in a key named `my_credentials`\nextraVolumeMounts:\n  - name: aws-credentials\n    mountPath: /etc/aws/credentials\n    readOnly: true\n```\n\nWhen ready, update your Helm installation:\n\n```shell\nhelm upgrade --install external-dns external-dns/external-dns --values values.yaml\n```\n\n## Arguments\n\nThis list is not the full list, but a few arguments that where chosen.\n\n### aws-zone-type\n\n`aws-zone-type` allows filtering for private and public zones\n\n## Annotations\n\nAnnotations which are specific to AWS.\n\n### alias\n\n`external-dns.alpha.kubernetes.io/alias` if set to `true` on an ingress, it will create two ALIAS records (one 'A' for IPv4 and one 'AAAA' for IPv6) when the target is an ALIAS as well.\nTo make the target an alias, the ingress needs to be configured correctly as described in [the docs](./gke-nginx.md#with-a-separate-tcp-load-balancer).\nIn particular, the argument `--publish-service=default/nginx-ingress-controller` has to be set on the `nginx-ingress-controller` container.\nIf one uses the `nginx-ingress` Helm chart, this flag can be set with the `controller.publishService.enabled` configuration option.\n\n### target-hosted-zone\n\n`external-dns.alpha.kubernetes.io/aws-target-hosted-zone` can optionally be set to the ID of a Route53 hosted zone. This will force external-dns to use the specified hosted zone when creating an ALIAS target.\n\n### aws-zone-match-parent\n\n`aws-zone-match-parent` allows support subdomains within the same zone by using their parent domain, i.e --domain-filter=x.example.com would create a DNS entry for x.example.com (and subdomains thereof).\n\n```yaml\n## hosted zone domain: example.com\n--domain-filter=x.example.com,example.com\n--aws-zone-match-parent\n```\n\n## Verify ExternalDNS works (Service example)\n\nCreate the following sample application to test that ExternalDNS works.\n\n> For services ExternalDNS will look for the annotation `external-dns.alpha.kubernetes.io/hostname` on the service and use the corresponding value.\n> If you want to give multiple names to service, you can set it to external-dns.alpha.kubernetes.io/hostname with a comma `,` separator.\n\nFor this verification phase, you can use default or another namespace for the nginx demo, for example:\n\n```bash\nNGINXDEMO_NS=\"nginx\"\nkubectl get namespaces | grep -q $NGINXDEMO_NS || kubectl create namespace $NGINXDEMO_NS\n```\n\nSave the following manifest below as `nginx.yaml`:\n\n```yaml\napiVersion: v1\nkind: Service\nmetadata:\n  name: nginx\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: nginx.example.com\nspec:\n  type: LoadBalancer\n  ports:\n  - port: 80\n    name: http\n    targetPort: 80\n  selector:\n    app: nginx\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: nginx\nspec:\n  selector:\n    matchLabels:\n      app: nginx\n  template:\n    metadata:\n      labels:\n        app: nginx\n    spec:\n      containers:\n      - image: nginx\n        name: nginx\n        ports:\n        - containerPort: 80\n          name: http\n```\n\nDeploy the nginx deployment and service with:\n\n```bash\nkubectl create --filename nginx.yaml --namespace ${NGINXDEMO_NS:-\"default\"}\n```\n\nVerify that the load balancer was allocated with:\n\n```bash\nkubectl get service nginx --namespace ${NGINXDEMO_NS:-\"default\"}\n```\n\nThis should show something like:\n\n```bash\nNAME    TYPE           CLUSTER-IP     EXTERNAL-IP                                                                   PORT(S)        AGE\nnginx   LoadBalancer   10.100.47.41   ae11c2360188411e7951602725593fd1-1224345803.eu-central-1.elb.amazonaws.com.   80:32749/TCP   12m\n```\n\nAfter roughly two minutes check that a corresponding DNS record for your service that was created.\n\n```bash\naws route53 list-resource-record-sets --output json --hosted-zone-id $ZONE_ID \\\n  --query \"ResourceRecordSets[?Name == 'nginx.example.com.']|[?Type == 'A']\"\n```\n\nThis should show something like:\n\n```json\n[\n    {\n        \"Name\": \"nginx.example.com.\",\n        \"Type\": \"A\",\n        \"AliasTarget\": {\n            \"HostedZoneId\": \"ZEWFWZ4R16P7IB\",\n            \"DNSName\": \"ae11c2360188411e7951602725593fd1-1224345803.eu-central-1.elb.amazonaws.com.\",\n            \"EvaluateTargetHealth\": true\n        }\n    }\n]\n```\n\nOr for IPv6 (AAAA) records:\n\n```bash\naws route53 list-resource-record-sets --output json --hosted-zone-id $ZONE_ID \\\n  --query \"ResourceRecordSets[?Name == 'nginx.example.com.']|[?Type == 'AAAA']\"\n```\n\nThis should show something like:\n\n```json\n[\n    {\n        \"Name\": \"nginx.example.com.\",\n        \"Type\": \"AAAA\",\n        \"AliasTarget\": {\n            \"HostedZoneId\": \"ZEWFWZ4R16P7IB\",\n            \"DNSName\": \"ae11c2360188411e7951602725593fd1-1224345803.eu-central-1.elb.amazonaws.com.\",\n            \"EvaluateTargetHealth\": true\n        }\n    }\n]\n```\n\nIPv6 (AAAA) records are created when ALIAS is enabled even for load balancers that do not have dualstack enabled.\nHowever, Route53 returns empty sets when querying such records, meaning they are harmless and IPv4 will work as normal.\n\nYou can also fetch the corresponding text records:\n\n```bash\naws route53 list-resource-record-sets --output json --hosted-zone-id $ZONE_ID \\\n  --query \"ResourceRecordSets[?Name == 'nginx.example.com.']|[?Type == 'TXT']\"\n```\n\nThis will show something like:\n\n```json\n[\n    {\n        \"Name\": \"nginx.example.com.\",\n        \"Type\": \"TXT\",\n        \"TTL\": 300,\n        \"ResourceRecords\": [\n            {\n                \"Value\": \"\\\"heritage=external-dns,external-dns/owner=external-dns,external-dns/resource=service/default/nginx\\\"\"\n            }\n        ]\n    }\n]\n```\n\nNote created TXT record alongside ALIAS records. TXT record signifies that the corresponding ALIAS records are managed by ExternalDNS. This makes ExternalDNS safe for running in environments where there are other records managed via other means.\n\nFor more information about ALIAS records, see [Choosing between alias and non-alias records](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resource-record-sets-choosing-alias-non-alias.html).\n\nLet's check that we can resolve this DNS name. We'll ask the nameservers assigned to your zone first.\n\n```bash\ndig +short @ns-5514.awsdns-53.org. nginx.example.com.\n```\n\nThis should return 1+ IP addresses that correspond to the ELB FQDN, i.e. `ae11c2360188411e7951602725593fd1-1224345803.eu-central-1.elb.amazonaws.com.`.\n\nNext try the public nameservers configured by DNS client on your system:\n\n```bash\ndig +short nginx.example.com.\n```\n\nIf you hooked up your DNS zone with its parent zone correctly you can use `curl` to access your site.\n\n```bash\ncurl nginx.example.com.\n```\n\nThis should show something like:\n\n```html\n<!DOCTYPE html>\n<html>\n<head>\n<title>Welcome to nginx!</title>\n...\n</head>\n<body>\n<h1>Welcome to nginx!</h1>\n...\n</body>\n</html>\n```\n\n## Verify ExternalDNS works (Ingress example)\n\nWith the previous `deployment` and `service` objects deployed, we can add an `ingress` object and configure a FQDN value for the `host` key.  The ingress controller will match incoming HTTP traffic, and route it to the appropriate backend service based on the `host` key.\n\n> For ingress objects ExternalDNS will create a DNS record based on the host specified for the ingress object.\n\nFor this tutorial, we have two endpoints, the service with `LoadBalancer` type and an ingress.  For practical purposes, if an ingress is used, the service type can be changed to `ClusterIP` as two endpoints are unecessary in this scenario.\n\n> [!IMPORTANT]\n> This requires that an ingress controller has been installed in your Kubernetes cluster.\n> EKS does not come with an ingress controller by default. A popular ingress controller is [ingress-nginx](https://github.com/kubernetes/ingress-nginx/), which can be installed by a [helm chart](https://artifacthub.io/packages/helm/ingress-nginx/ingress-nginx) or by [manifests](https://kubernetes.github.io/ingress-nginx/deploy/#aws).\n\nCreate an ingress resource manifest file named `ingress.yaml` with the contents below:\n\n```yaml\n---\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  name: nginx\nspec:\n  ingressClassName: nginx\n  rules:\n    - host: server.example.com\n      http:\n        paths:\n          - backend:\n              service:\n                name: nginx\n                port:\n                  number: 80\n            path: /\n            pathType: Prefix\n```\n\nWhen ready, you can deploy this with:\n\n```bash\nkubectl create --filename ingress.yaml --namespace ${NGINXDEMO_NS:-\"default\"}\n```\n\nWatch the status of the ingress until the ADDRESS field is populated.\n\n```bash\nkubectl get ingress --watch --namespace ${NGINXDEMO_NS:-\"default\"}\n```\n\nYou should see something like this:\n\n```sh\nNAME    CLASS    HOSTS                ADDRESS   PORTS   AGE\nnginx   <none>   server.example.com             80      47s\nnginx   <none>   server.example.com   ae11c2360188411e7951602725593fd1-1224345803.eu-central-1.elb.amazonaws.com.   80      54s\n```\n\nFor the ingress test, run through similar checks, but using domain name used for the ingress:\n\n```bash\n# check records on route53\naws route53 list-resource-record-sets --output json --hosted-zone-id $ZONE_ID \\\n  --query \"ResourceRecordSets[?Name == 'server.example.com.']\"\n\n# query using a route53 name server\ndig +short @ns-5514.awsdns-53.org. server.example.com.\n# query using the default name server\ndig +short server.example.com.\n\n# connect to the nginx web server through the ingress\ncurl server.example.com.\n```\n\n## More service annotation options\n\n### Custom TTL\n\nThe default DNS record TTL (Time-To-Live) is 300 seconds. You can customize this value by setting the annotation `external-dns.alpha.kubernetes.io/ttl`.\ne.g., modify the service manifest YAML file above:\n\n```yaml\napiVersion: v1\nkind: Service\nmetadata:\n  name: nginx\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: nginx.example.com\n    external-dns.alpha.kubernetes.io/ttl: \"60\"\nspec:\n    ...\n```\n\nThis will set the DNS record's TTL to 60 seconds.\n\n### Routing policies\n\nRoute53 offers [different routing policies](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/routing-policy.html). The routing policy for a record can be controlled with the following annotations:\n\n- `external-dns.alpha.kubernetes.io/set-identifier`: this **needs** to be set to use any of the following routing policies\n\nFor any given DNS name, only **one** of the following routing policies can be used:\n\n- Weighted records: `external-dns.alpha.kubernetes.io/aws-weight`\n- Latency-based routing: `external-dns.alpha.kubernetes.io/aws-region`\n- Failover:`external-dns.alpha.kubernetes.io/aws-failover`\n- Geolocation-based routing:\n  - `external-dns.alpha.kubernetes.io/aws-geolocation-continent-code`\n  - `external-dns.alpha.kubernetes.io/aws-geolocation-country-code`\n  - `external-dns.alpha.kubernetes.io/aws-geolocation-subdivision-code`\n- Geoproximity routing:\n  - `external-dns.alpha.kubernetes.io/aws-geoproximity-region`\n  - `external-dns.alpha.kubernetes.io/aws-geoproximity-local-zone-group`\n  - `external-dns.alpha.kubernetes.io/aws-geoproximity-coordinates`\n  - `external-dns.alpha.kubernetes.io/aws-geoproximity-bias`\n- Multi-value answer:`external-dns.alpha.kubernetes.io/aws-multi-value-answer`\n\n#### Weighted Routing\n\nRoute traffic across two Services by weight. Both share the same hostname but carry different identifiers and weights:\n\n```yaml\napiVersion: v1\nkind: Service\nmetadata:\n  name: my-service-v1\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: app.example.com\n    external-dns.alpha.kubernetes.io/set-identifier: app-v1\n    external-dns.alpha.kubernetes.io/aws-weight: \"80\"\nspec:\n  type: LoadBalancer\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: my-service-v2\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: app.example.com\n    external-dns.alpha.kubernetes.io/set-identifier: app-v2\n    external-dns.alpha.kubernetes.io/aws-weight: \"20\"\nspec:\n  type: LoadBalancer\n```\n\n> ExternalDNS will create two Route53 weighted record sets for `app.example.com`, sending 80% of traffic to `my-service-v1` and 20% to `my-service-v2`.\n\n#### Failover Routing\n\nDesignate a primary and secondary record for active/passive failover:\n\n```yaml\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  name: my-ingress-primary\n  annotations:\n    external-dns.alpha.kubernetes.io/set-identifier: my-app-primary\n    external-dns.alpha.kubernetes.io/aws-failover: PRIMARY\n---\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  name: my-ingress-secondary\n  annotations:\n    external-dns.alpha.kubernetes.io/set-identifier: my-app-secondary\n    external-dns.alpha.kubernetes.io/aws-failover: SECONDARY\n```\n\n> Route53 will serve the `PRIMARY` record when healthy, and automatically fall back to `SECONDARY` when the health check fails.\n\n#### Latency-Based Routing\n\nRoute users to the nearest region by latency:\n\n```yaml\napiVersion: v1\nkind: Service\nmetadata:\n  name: my-service-us\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: api.example.com\n    external-dns.alpha.kubernetes.io/set-identifier: api-us-east-1\n    external-dns.alpha.kubernetes.io/aws-region: us-east-1\nspec:\n  type: LoadBalancer\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: my-service-eu\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: api.example.com\n    external-dns.alpha.kubernetes.io/set-identifier: api-eu-west-1\n    external-dns.alpha.kubernetes.io/aws-region: eu-west-1\nspec:\n  type: LoadBalancer\n```\n\n> Route53 will direct each user to the region with the lowest latency.\n\n### Associating DNS records with healthchecks\n\nYou can configure Route53 to associate DNS records with healthchecks for automated DNS failover using\n`external-dns.alpha.kubernetes.io/aws-health-check-id: <health-check-id>` annotation.\n\nNote: ExternalDNS does not support creating healthchecks, and assumes that `<health-check-id>` already exists.\n\n## Canonical Hosted Zones\n\nWhen creating ALIAS type records in Route53 it is required that external-dns be aware of the canonical hosted zone in which\nthe specified hostname is created. External-dns is able to automatically identify the canonical hosted zone for many\nhostnames based upon known hostname suffixes which are defined in [aws.go](https://github.com/kubernetes-sigs/external-dns/blob/master/provider/aws/aws.go#L65). If a hostname\ndoes not have a known suffix then the suffix can be added into `aws.go` or the [target-hosted-zone annotation](#target-hosted-zone)\ncan be used to manually define the ID of the canonical hosted zone.\n\n## Govcloud caveats\n\nDue to the special nature with how Route53 runs in Govcloud, there are a few tweaks in the deployment settings.\n\n- An Environment variable with name of `AWS_REGION` set to either `us-gov-west-1` or `us-gov-east-1` is required. Otherwise it tries to lookup a region that does not exist in Govcloud and it errors out.\n\n```yaml\nenv:\n- name: AWS_REGION\n  value: us-gov-west-1\n```\n\n- Route53 in Govcloud does not allow aliases. Therefore, container args must be set so that it uses CNAMES and a txt-prefix must be set to something. Otherwise, it will try to create a TXT record with the same value than the CNAME itself, which is not allowed.\n\n```yaml\nargs:\n- --aws-prefer-cname\n- --txt-prefix={{ YOUR_PREFIX }}\n```\n\n- The first two changes are needed if you use Route53 in Govcloud, which only supports private zones. There are also no cross account IAM whatsoever between Govcloud and commercial AWS accounts.\n  - If services and ingresses need to make Route 53 entries to an public zone in a commercial account, you will have set env variables of `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` with a key and secret to the commercial account that has the sufficient rights.\n\n```yaml\nenv:\n- name: AWS_ACCESS_KEY_ID\n  value: XXXXXXXXX\n- name: AWS_SECRET_ACCESS_KEY\n  valueFrom:\n    secretKeyRef:\n      name: {{ YOUR_SECRET_NAME }}\n      key: {{ YOUR_SECRET_KEY }}\n```\n\n## DynamoDB Registry\n\nThe DynamoDB Registry can be used to store dns records metadata. See the [DynamoDB Registry Tutorial](../registry/dynamodb.md) for more information.\n\n## Disable AAAA Record Creation\n\nIf you would like ExternalDNS to not create AAAA records at all, you can add the following command line parameter: `--exclude-record-types=AAAA`.\nPlease be aware, this will disable AAAA record creation even for dualstack enabled load balancers.\n\n## Clean up\n\nMake sure to delete all Service objects before terminating the cluster so all load balancers get cleaned up correctly.\n\n```bash\nkubectl delete service nginx\n```\n\n**IMPORTANT** If you attached a policy to the Node IAM Role, then you will want to detach this before deleting the EKS cluster.  Otherwise, the role resource will be locked, and the cluster cannot be deleted, especially if it was provisioned by automation like `terraform` or `eksctl`.\n\n```bash\naws iam detach-role-policy --role-name $NODE_ROLE_NAME --policy-arn $POLICY_ARN\n```\n\nIf the cluster was provisioned using `eksctl`, you can delete the cluster with:\n\n```bash\neksctl delete cluster --name $EKS_CLUSTER_NAME --region $EKS_CLUSTER_REGION\n```\n\nGive ExternalDNS some time to clean up the DNS records for you. Then delete the hosted zone if you created one for the testing purpose.\n\n```bash\naws route53 delete-hosted-zone --id $ZONE_ID # e.g /hostedzone/ZEWFWZ4R16P7IB\n```\n\nIf IAM user credentials were used, you can remove the user with:\n\n```bash\naws iam detach-user-policy --user-name \"externaldns\" --policy-arn $POLICY_ARN\n\n# If static credentials were used\naws iam delete-access-key --user-name \"externaldns\" --access-key-id $ACCESS_KEY_ID\n\naws iam delete-user --user-name \"externaldns\"\n```\n\nIf IRSA was used, you can remove the IRSA role with:\n\n```bash\naws iam detach-role-policy --role-name $IRSA_ROLE --policy-arn $POLICY_ARN\naws iam delete-role --role-name $IRSA_ROLE\n```\n\nDelete any unneeded policies:\n\n```bash\naws iam delete-policy --policy-arn $POLICY_ARN\n```\n\n## Throttling\n\nRoute53 has a [5 API requests per second per account hard quota](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DNSLimitations.html#limits-api-requests-route-53).\nRunning several fast polling ExternalDNS instances in a given account can easily hit that limit. Some ways to reduce the request rate include:\n\n- Reduce the polling loop's synchronization interval at the possible cost of slower change propagation (but see `--events` below to reduce the impact).\n  - `--interval=5m` (default `1m`)\n- Enable a Cache to store the zone records list. It comes with a cost: slower propagation when the zone gets modified from other sources such as the AWS console, terraform, cloudformation or anything similar.\n  - `--provider-cache-time=15m` (default `0m`)\n- Trigger the polling loop on changes to K8s objects, rather than only at `interval` and ensure a minimum of time between events, to have responsive updates with long poll intervals\n  - `--events`\n  - `--min-event-sync-interval=5m` (default `5s`)\n- Limit the [sources watched](https://github.com/kubernetes-sigs/external-dns/blob/master/pkg/apis/externaldns/types.go#L364) when the `--events` flag is specified to specific types, namespaces, labels, or annotations\n  - `--source=ingress --source=service` - specify multiple times for multiple sources\n  - `--namespace=my-app`\n  - `--label-filter=app in (my-app)`\n  - `--ingress-class=nginx-external`\n- Limit services watched by type (not applicable to ingress or other types)\n  - `--service-type-filter=LoadBalancer` default `all`\n- Limit the hosted zones considered\n  - `--zone-id-filter=ABCDEF12345678` - specify multiple times if needed\n  - `--domain-filter=example.com` by domain suffix - specify multiple times if needed\n  - `--regex-domain-filter=example*` by domain suffix but as a regex - overrides domain-filter\n  - `--exclude-domains=ignore.this.example.com` to exclude a domain or subdomain\n  - `--regex-domain-exclusion=ignore*` subtracts it's matches from `regex-domain-filter`'s matches\n  - `--aws-zone-type=public` only sync zones of this type `[public|private]`\n  - `--aws-zone-tags=owner=k8s` only sync zones with this tag\n- If the list of zones managed by ExternalDNS doesn't change frequently, cache it by setting a TTL.\n  - `--aws-zones-cache-duration=3h` (default `0` - disabled)\n- Increase the number of changes applied to Route53 in each batch\n  - `--aws-batch-change-size=4000` (default `1000`)\n- Increase the interval between changes\n  - `--aws-batch-change-interval=10s` (default `1s`)\n- Introducing some jitter to the pod initialization, so that when multiple instances of ExternalDNS are updated at the same time they do not make their requests on the same second.\n\nA simple way to implement randomised startup is with an init container:\n\n```yaml\n...\n    spec:\n      initContainers:\n      - name: init-jitter\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        command:\n        - /bin/sh\n        - -c\n        - 'FOR=$((RANDOM % 10))s;echo \"Sleeping for $FOR\";sleep $FOR'\n      containers:\n...\n```\n\n### EKS\n\nAn effective starting point for EKS with an ingress controller might look like:\n\n```bash\n--interval=5m\n--events\n--source=ingress\n--domain-filter=example.com\n--aws-zones-cache-duration=1h\n```\n\n### Batch size options\n\nAfter external-dns generates all changes, it will perform a task to group those changes into batches. Each change will be validated against batch-change-size limits.\nIf at least one of those parameters out of range - the change will be moved to a separate batch.\nIf the change can't fit into any batch - *it will be skipped.*\n\nThere are 3 options to control batch size for AWS provider:\n\n- Maximum amount of changes added to one batch\n  - `--aws-batch-change-size` (default `1000`)\n- Maximum size of changes in bytes added to one batch\n  - `--aws-batch-change-size-bytes` (default `32000`)\n- Maximum value count of changes added to one batch\n  - `aws-batch-change-size-values` (default `1000`)\n\n`aws-batch-change-size` can be very useful for throttling purposes and can be set to any value.\n\nDefault values for flags `aws-batch-change-size-bytes` and `aws-batch-change-size-values` are taken from [AWS documentation](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DNSLimitations.html#limits-api-requests) for Route53 API.\n> [!WARNING]\n> **You should not change those values until you really have to.**\nBecause those limits are in place, `aws-batch-change-size` can be set to any value: Even if your batch size is `4000` records, your change will be split to separate batches due to bytes/values size limits and apply request will be finished without issues.\n\n## Using CRD source to manage DNS records in AWS\n\nPlease refer to the [CRD source documentation](../sources/crd.md#example) for more information.\n"
  },
  {
    "path": "docs/tutorials/azure-private-dns.md",
    "content": "# Azure Private DNS\n\nThis tutorial describes how to set up ExternalDNS for managing records in Azure Private DNS.\n\nIt comprises of the following steps:\n\n1) Provision Azure Private DNS\n2) Configure service principal for managing the zone\n3) Deploy ExternalDNS\n4) Expose an NGINX service with a LoadBalancer and annotate it with the desired DNS name\n5) Install NGINX Ingress Controller (Optional)\n6) Expose an nginx service with an ingress (Optional)\n7) Verify the DNS records\n\nEverything will be deployed on Kubernetes.\nTherefore, please see the subsequent prerequisites.\n\n## Prerequisites\n\n- Azure Kubernetes Service is deployed and ready\n- [Azure CLI 2.0](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli) and `kubectl` installed on the box to execute the subsequent steps\n\n## Provision Azure Private DNS\n\nThe provider will find suitable zones for domains it manages. It will\nnot automatically create zones.\n\nFor this tutorial, we will create a Azure resource group named 'externaldns' that can easily be deleted later.\n\n```sh\naz group create -n externaldns -l westeurope\n```\n\nSubstitute a more suitable location for the resource group if desired.\n\nAs a prerequisite for Azure Private DNS to resolve records is to define links with VNETs.\nThus, first create a VNET.\n\n```sh\n$ az network vnet create \\\n  --name myvnet \\\n  --resource-group externaldns \\\n  --location westeurope \\\n  --address-prefix 10.2.0.0/16 \\\n  --subnet-name mysubnet \\\n  --subnet-prefixes 10.2.0.0/24\n```\n\nNext, create a Azure Private DNS zone for \"example.com\":\n\n```sh\naz network private-dns zone create -g externaldns -n example.com\n```\n\nSubstitute a domain you own for \"example.com\" if desired.\n\nFinally, create the mentioned link with the VNET.\n\n```sh\n$ az network private-dns link vnet create -g externaldns -n mylink \\\n   -z example.com -v myvnet --registration-enabled false\n```\n\n## Configure service principal for managing the zone\n\nExternalDNS needs permissions to make changes in Azure Private DNS.\nThese permissions are roles assigned to the service principal used by ExternalDNS.\n\nA service principal with a minimum access level of `Private DNS Zone Contributor` to the Private DNS zone(s) and `Reader` to the resource group containing the Azure Private DNS zone(s) is necessary.\nMore powerful role-assignments like `Owner` or assignments on subscription-level work too.\n\nStart off by **creating the service principal** without role-assignments.\n\n```sh\n$ az ad sp create-for-rbac --skip-assignment -n http://externaldns-sp\n{\n  \"appId\": \"appId GUID\",  <-- aadClientId value\n  ...\n  \"password\": \"password\",  <-- aadClientSecret value\n  \"tenant\": \"AzureAD Tenant Id\"  <-- tenantId value\n}\n```\n\n> Note: Alternatively, you can issue `az account show --query \"tenantId\"` to retrieve the id of your AAD Tenant too.\n\nNext, assign the roles to the service principal.\nBut first **retrieve the ID's** of the objects to assign roles on.\n\n```sh\n# find out the resource ids of the resource group where the dns zone is deployed, and the dns zone itself\n$ az group show --name externaldns --query id -o tsv\n/subscriptions/id/resourceGroups/externaldns\n\n$ az network private-dns zone show --name example.com -g externaldns --query id -o tsv\n/subscriptions/.../resourceGroups/externaldns/providers/Microsoft.Network/privateDnsZones/example.com\n```\n\nNow, **create role assignments**.\n\n```sh\n# 1. as a reader to the resource group\n$ az role assignment create --role \"Reader\" --assignee <appId GUID> --scope <resource group resource id>\n\n# 2. as a contributor to DNS Zone itself\n$ az role assignment create --role \"Private DNS Zone Contributor\" --assignee <appId GUID> --scope <dns zone resource id>\n```\n\n## Throttling\n\nWhen the ExternalDNS managed zones list doesn't change frequently, one can set `--azure-zones-cache-duration` (zones list cache time-to-live). The zones list cache is disabled by default, with a value of 0s.\nAlso, one can leverage the built-in retry policies of the Azure SDK. The flag --azure-maxretries-count can be specified in the manifest yaml to configure behavior. The default value of Azure SDK retry is 3.\n\n## Deploy ExternalDNS\n\nConfigure `kubectl` to be able to communicate and authenticate with your cluster.\nThis is per default done through the file `~/.kube/config`.\n\nFor general background information on this see [kubernetes-docs](https://kubernetes.io/docs/tasks/access-application-cluster/access-cluster/).\nAzure-CLI features functionality for automatically maintaining this file for AKS-Clusters. See [Azure-Docs](https://docs.microsoft.com/de-de/cli/azure/aks?view=azure-cli-latest#az-aks-get-credentials).\n\nFollow the steps for [azure-dns provider](./azure.md#creating-configuration-file) to create a configuration file.\n\nThen apply one of the following manifests depending on whether you use RBAC or not.\n\nThe credentials of the service principal are provided to ExternalDNS as environment-variables.\n\n### Manifest (for clusters without RBAC enabled)\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: externaldns\nspec:\n  selector:\n    matchLabels:\n      app: externaldns\n  strategy:\n    type: Recreate\n  template:\n    metadata:\n      labels:\n        app: externaldns\n    spec:\n      containers:\n      - name: externaldns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=service\n        - --source=ingress\n        - --domain-filter=example.com\n        - --provider=azure-private-dns\n        - --azure-resource-group=externaldns\n        - --azure-subscription-id=<use the id of your subscription>\n        - --azure-maxretries-count=1  # (optional) specifies the maxRetires value to be used by the Azure SDK. Default is 3.\n        volumeMounts:\n        - name: azure-config-file\n          mountPath: /etc/kubernetes\n          readOnly: true\n      volumes:\n      - name: azure-config-file\n        secret:\n          secretName: azure-config-file\n```\n\n### Manifest (for clusters with RBAC enabled, cluster access)\n\n```yaml\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: externaldns\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: externaldns\nrules:\n- apiGroups: [\"\"]\n  resources: [\"services\",\"pods\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"discovery.k8s.io\"]\n  resources: [\"endpointslices\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"extensions\",\"networking.k8s.io\"]\n  resources: [\"ingresses\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"\"]\n  resources: [\"nodes\"]\n  verbs: [\"get\", \"watch\", \"list\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: externaldns-viewer\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: externaldns\nsubjects:\n- kind: ServiceAccount\n  name: externaldns\n  namespace: default\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: externaldns\nspec:\n  selector:\n    matchLabels:\n      app: externaldns\n  strategy:\n    type: Recreate\n  template:\n    metadata:\n      labels:\n        app: externaldns\n    spec:\n      serviceAccountName: externaldns\n      containers:\n      - name: externaldns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=service\n        - --source=ingress\n        - --domain-filter=example.com\n        - --provider=azure-private-dns\n        - --azure-resource-group=externaldns\n        - --azure-subscription-id=<use the id of your subscription>\n        - --azure-maxretries-count=1  # (optional) specifies the maxRetires value to be used by the Azure SDK. Default is 3.\n        volumeMounts:\n        - name: azure-config-file\n          mountPath: /etc/kubernetes\n          readOnly: true\n      volumes:\n      - name: azure-config-file\n        secret:\n          secretName: azure-config-file\n```\n\n### Manifest (for clusters with RBAC enabled, namespace access)\n\nThis configuration is the same as above, except it only requires privileges for the current namespace, not for the whole cluster.\nHowever, access to [nodes](https://kubernetes.io/docs/concepts/architecture/nodes/) requires cluster access, so when using this manifest,\nservices with type `NodePort` will be skipped!\n\n```yaml\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: externaldns\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: Role\nmetadata:\n  name: externaldns\nrules:\n- apiGroups: [\"\"]\n  resources: [\"services\",\"pods\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"discovery.k8s.io\"]\n  resources: [\"endpointslices\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"extensions\",\"networking.k8s.io\"]\n  resources: [\"ingresses\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: RoleBinding\nmetadata:\n  name: externaldns\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: Role\n  name: externaldns\nsubjects:\n- kind: ServiceAccount\n  name: externaldns\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: externaldns\nspec:\n  selector:\n    matchLabels:\n      app: externaldns\n  strategy:\n    type: Recreate\n  template:\n    metadata:\n      labels:\n        app: externaldns\n    spec:\n      serviceAccountName: externaldns\n      containers:\n      - name: externaldns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=service\n        - --source=ingress\n        - --domain-filter=example.com\n        - --provider=azure-private-dns\n        - --azure-resource-group=externaldns\n        - --azure-subscription-id=<use the id of your subscription>\n        - --azure-maxretries-count=1  # (optional) specifies the maxRetires value to be used by the Azure SDK. Default is 3.\n        volumeMounts:\n        - name: azure-config-file\n          mountPath: /etc/kubernetes\n          readOnly: true\n      volumes:\n      - name: azure-config-file\n        secret:\n          secretName: azure-config-file\n```\n\nCreate the deployment for ExternalDNS:\n\n```sh\nkubectl create -f externaldns.yaml\n```\n\n## Create an nginx deployment\n\nThis step creates a demo workload in your cluster. Apply the following manifest to create a deployment that we are going to expose later in this tutorial in multiple ways:\n\n```yaml\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: nginx\nspec:\n  selector:\n    matchLabels:\n      app: nginx\n  template:\n    metadata:\n      labels:\n        app: nginx\n    spec:\n      containers:\n        - image: nginx\n          name: nginx\n          ports:\n          - containerPort: 80\n```\n\n## Expose the nginx deployment with a load balancer\n\nApply the following manifest to create a service of type `LoadBalancer`. This will create a public load balancer in Azure that will forward traffic to the nginx pods.\n\n```yaml\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: nginx-svc\n  annotations:\n    service.beta.kubernetes.io/azure-load-balancer-internal: \"true\"\n    external-dns.alpha.kubernetes.io/hostname: server.example.com\n    external-dns.alpha.kubernetes.io/internal-hostname: server-clusterip.example.com\nspec:\n  ports:\n    - port: 80\n      protocol: TCP\n      targetPort: 80\n  selector:\n    app: nginx\n  type: LoadBalancer\n```\n\nIn the service we used multiple annotations.\nThe annotation `service.beta.kubernetes.io/azure-load-balancer-internal` is used to create an internal load balancer.\nThe annotation `external-dns.alpha.kubernetes.io/hostname` is used to create a DNS record for the load balancer that will point to the internal IP address in the VNET allocated by the internal load balancer.\nThe annotation `external-dns.alpha.kubernetes.io/internal-hostname` is used to create a private DNS record for the load balancer that will point to the cluster IP.\n\n## Install NGINX Ingress Controller (Optional)\n\nHelm is used to deploy the ingress controller.\n\nWe employ the popular chart [ingress-nginx](https://github.com/kubernetes/ingress-nginx/tree/main/charts/ingress-nginx).\n\n```sh\n$ helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx\n$ helm repo update\n$ helm install [RELEASE_NAME] ingress-nginx/ingress-nginx\n     --set controller.publishService.enabled=true\n```\n\nThe parameter `controller.publishService.enabled` needs to be set to `true.`\n\nIt will make the ingress controller update the endpoint records of ingress-resources to contain the external-ip of the loadbalancer serving the ingress-controller.\nThis is crucial as ExternalDNS reads those endpoints records when creating DNS-Records from ingress-resources.\nIn the subsequent parameter we will make use of this. If you don't want to work with ingress-resources in your later use, you can leave the parameter out.\n\nVerify the correct propagation of the loadbalancer's ip by listing the ingresses.\n\n```sh\nkubectl get ingress\n```\n\nThe address column should contain the ip for each ingress. ExternalDNS will pick up exactly this piece of information.\n\n```sh\nNAME     HOSTS             ADDRESS          PORTS   AGE\nnginx1   sample1.aks.com   52.167.195.110   80      6d22h\nnginx2   sample2.aks.com   52.167.195.110   80      6d21h\n```\n\nIf you do not want to deploy the ingress controller with Helm, ensure to pass the following cmdline-flags to it through the mechanism of your choice:\n\n```sh\nflags:\n--publish-service=<namespace of ingress-controller >/<svcname of ingress-controller>\n--update-status=true (default-value)\n\nexample:\n./nginx-ingress-controller --publish-service=default/nginx-ingress-controller\n```\n\n## Expose the nginx deployment with the ingress (Optional)\n\nApply the following manifest to create an ingress resource that will expose the nginx deployment. The ingress resource backend points to a `ClusterIP` service that is needed to select the pods that will receive the traffic.\n\n```yaml\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: nginx-svc-clusterip\nspec:\n  ports:\n  - port: 80\n    protocol: TCP\n    targetPort: 80\n  selector:\n    app: nginx\n  type: ClusterIP\n\n---\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  name: nginx\nspec:\n  ingressClassName: nginx\n  rules:\n  - host: server.example.com\n    http:\n      paths:\n      - backend:\n          service:\n            name: nginx-svc-clusterip\n            port:\n              number: 80\n        pathType: Prefix\n```\n\nWhen you use ExternalDNS with Ingress resources, it automatically creates DNS records based on the hostnames listed in those Ingress objects.\nThose hostnames must match the filters that you defined (if any):\n\n- By default, `--domain-filter` filters Azure Private DNS zone.\n- If you use `--domain-filter` together with `--zone-name-filter`, the behavior changes: `--domain-filter` then filters Ingress domains, not the Azure Private DNS zone name.\n\nWhen those hostnames are removed or renamed the corresponding DNS records are also altered.\n\nCreate the deployment, service and ingress object:\n\n```sh\nkubectl create -f nginx.yaml\n```\n\nSince your external IP would have already been assigned to the nginx-ingress service, the DNS records pointing to the IP of the nginx-ingress service should be created within a minute.\n\n## Verify created records\n\nRun the following command to view the A records for your Azure Private DNS zone:\n\n```sh\naz network private-dns record-set a list -g externaldns -z example.com\n```\n\nSubstitute the zone for the one created above if a different domain was used.\n\nThis should show the external IP address of the service as the A record for your domain ('@' indicates the record is for the zone itself).\n"
  },
  {
    "path": "docs/tutorials/azure.md",
    "content": "# Azure DNS\n\nThis tutorial describes how to setup ExternalDNS for [Azure DNS](https://azure.microsoft.com/services/dns/) with [Azure Kubernetes Service](https://docs.microsoft.com/azure/aks/).\n\nMake sure to use **>=0.11.0** version of ExternalDNS for this tutorial.\n\nThis tutorial uses [Azure CLI 2.0](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli) for all\nAzure commands and assumes that the Kubernetes cluster was created via Azure Container Services and `kubectl` commands\nare being run on an orchestration node.\n\n## Creating an Azure DNS zone\n\nThe Azure provider for ExternalDNS will find suitable zones for domains it manages; it will not automatically create zones.\n\nFor this tutorial, we will create a Azure resource group named `MyDnsResourceGroup` that can easily be deleted later:\n\n```bash\naz group create --name \"MyDnsResourceGroup\" --location \"eastus\"\n```\n\nSubstitute a more suitable location for the resource group if desired.\n\nNext, create a Azure DNS zone for `example.com`:\n\n```bash\naz network dns zone create --resource-group \"MyDnsResourceGroup\" --name \"example.com\"\n```\n\nSubstitute a domain you own for `example.com` if desired.\n\nIf using your own domain that was registered with a third-party domain registrar, you should point your domain's name servers to the values in the `nameServers` field from the JSON data returned by the `az network dns zone create` command. Please consult your registrar's documentation on how to do that.\n\n### Internal Load Balancer\n\nTo create internal load balancers, one can set the annotation `service.beta.kubernetes.io/azure-load-balancer-internal` to `true` on the resource.\n**Note**: AKS cluster's control plane managed identity needs to be granted `Network Contributor` role to update the subnet. For more details refer to [Use an internal load balancer with Azure Kubernetes Service (AKS)](https://learn.microsoft.com/en-us/azure/aks/internal-lb)\n\n## Configuration file\n\nThe azure provider will reference a configuration file called `azure.json`.  The preferred way to inject the configuration file is by using a Kubernetes secret. The secret should contain an object named `azure.json` with content similar to this:\n\n```json\n{\n  \"tenantId\": \"01234abc-de56-ff78-abc1-234567890def\",\n  \"subscriptionId\": \"01234abc-de56-ff78-abc1-234567890def\",\n  \"resourceGroup\": \"MyDnsResourceGroup\",\n  \"aadClientId\": \"01234abc-de56-ff78-abc1-234567890def\",\n  \"aadClientSecret\": \"uKiuXeiwui4jo9quae9o\"\n}\n```\n\nThe following fields are used:\n\n* `tenantId` (**required**) - run `az account show --query \"tenantId\"` or by selecting Azure Active Directory in the Azure Portal and checking the _Directory ID_ under Properties.\n* `subscriptionId` (**required**) - run `az account show --query \"id\"` or by selecting Subscriptions in the Azure Portal.\n* `resourceGroup` (**required**) is the Resource Group created in a previous step that contains the Azure DNS Zone.\n* `aadClientID` is associated with the Service Principal. This is used with Service Principal or Workload Identity methods documented in the next section.\n* `aadClientSecret` is associated with the Service Principal. This is only used with Service Principal method documented in the next section.\n* `useManagedIdentityExtension` - this is set to `true` if you use either AKS Kubelet Identity or AAD Pod Identities methods documented in the next section.\n* `userAssignedIdentityID` - this contains the client id from the Managed identity when using the AAD Pod Identities method documented in the next setion.\n* `activeDirectoryAuthorityHost` - this contains the URI to override the default Azure Active Directory authority endpoint.\n  This is useful for Azure Stack Cloud deployments or custom environments.\n* `useWorkloadIdentityExtension` - this is set to `true` if you use Workload Identity method documented in the next section.\n* `ResourceManagerAudience` - this specifies the audience for the Azure Resource Manager service when using Azure Stack Cloud. This is required for Azure Stack Cloud deployments to authenticate with the correct Resource Manager endpoint.\n* `ResourceManagerEndpoint` - this specifies the endpoint URL for the Azure Resource Manager service when using Azure Stack Cloud. This is required for Azure Stack Cloud deployments to point to the correct Resource Manager instance.\n\nThe Azure DNS provider expects, by default, that the configuration file is at `/etc/kubernetes/azure.json`.  This can be overridden with the `--azure-config-file` option when starting ExternalDNS.\n\n## Permissions to modify DNS zone\n\nExternalDNS needs permissions to make changes to the Azure DNS zone. There are four ways configure the access needed:\n\n* [Service Principal](#service-principal)\n* [Managed Identity Using AKS Kubelet Identity](#managed-identity-using-aks-kubelet-identity)\n* [Managed Identity Using AAD Pod Identities](#managed-identity-using-aad-pod-identities)\n* [Managed Identity Using Workload Identity](#managed-identity-using-workload-identity)\n\n### Service Principal\n\nThese permissions are defined in a Service Principal that should be made available to ExternalDNS as a configuration file `azure.json`.\n\n#### Creating a service principal\n\nA Service Principal with a minimum access level of `DNS Zone Contributor` or `Contributor` to the DNS zone(s) and `Reader` to the resource group containing the Azure DNS zone(s) is necessary for ExternalDNS to be able to edit DNS records.\nHowever, other more permissive access levels will work too (e.g. `Contributor` to the resource group or the whole subscription).\n\nThis is an Azure CLI example on how to query the Azure API for the information required for the Resource Group and DNS zone you would have already created in previous steps (requires `azure-cli` and `jq`)\n\n```bash\n$ EXTERNALDNS_NEW_SP_NAME=\"ExternalDnsServicePrincipal\" # name of the service principal\n$ AZURE_DNS_ZONE_RESOURCE_GROUP=\"MyDnsResourceGroup\" # name of resource group where dns zone is hosted\n$ AZURE_DNS_ZONE=\"example.com\" # DNS zone name like example.com or sub.example.com\n\n# Create the service principal\n$ DNS_SP=$(az ad sp create-for-rbac --name $EXTERNALDNS_NEW_SP_NAME)\n$ EXTERNALDNS_SP_APP_ID=$(echo $DNS_SP | jq -r '.appId')\n$ EXTERNALDNS_SP_PASSWORD=$(echo $DNS_SP | jq -r '.password')\n```\n\n#### Assign the rights for the service principal\n\nGrant access to Azure DNS zone for the service principal.\n\n```bash\n# fetch DNS id used to grant access to the service principal\nDNS_ID=$(az network dns zone show --name $AZURE_DNS_ZONE \\\n --resource-group $AZURE_DNS_ZONE_RESOURCE_GROUP --query \"id\" --output tsv)\n\n# 1. as a reader to the resource group\n$ az role assignment create --role \"Reader\" --assignee $EXTERNALDNS_SP_APP_ID --scope $DNS_ID\n\n# 2. as a contributor to DNS Zone itself\n$ az role assignment create --role \"Contributor\" --assignee $EXTERNALDNS_SP_APP_ID --scope $DNS_ID\n```\n\n#### Creating a configuration file for the service principal\n\nCreate the file `azure.json` with values gather from previous steps.\n\n```bash\ncat <<-EOF > /local/path/to/azure.json\n{\n  \"tenantId\": \"$(az account show --query tenantId -o tsv)\",\n  \"subscriptionId\": \"$(az account show --query id -o tsv)\",\n  \"resourceGroup\": \"$AZURE_DNS_ZONE_RESOURCE_GROUP\",\n  \"aadClientId\": \"$EXTERNALDNS_SP_APP_ID\",\n  \"aadClientSecret\": \"$EXTERNALDNS_SP_PASSWORD\"\n}\nEOF\n```\n\nUse this file to create a Kubernetes secret:\n\n```bash\nkubectl create secret generic azure-config-file --namespace \"default\" --from-file /local/path/to/azure.json\n```\n\n### Managed identity using AKS Kubelet identity\n\nThe [managed identity](https://docs.microsoft.com/azure/active-directory/managed-identities-azure-resources/overview) that is assigned to the underlying node pool in the AKS cluster can be given permissions to access Azure DNS.\nManaged identities are essentially a service principal whose lifecycle is managed, such as deleting the AKS cluster will also delete the service principals associated with the AKS cluster.\nThe managed identity assigned Kubernetes node pool, or specifically the [VMSS](https://docs.microsoft.com/azure/virtual-machine-scale-sets/overview), is called the Kubelet identity.\n\nThe managed identites were previously called MSI (Managed Service Identity) and are enabled by default when creating an AKS cluster.\n\nNote that permissions granted to this identity will be accessible to all containers running inside the Kubernetes cluster, not just the ExternalDNS container(s).\n\nFor the managed identity, the contents of `azure.json` should be similar to this:\n\n```json\n{\n  \"tenantId\": \"01234abc-de56-ff78-abc1-234567890def\",\n  \"subscriptionId\": \"01234abc-de56-ff78-abc1-234567890def\",\n  \"resourceGroup\": \"MyDnsResourceGroup\",\n  \"useManagedIdentityExtension\": true,\n  \"userAssignedIdentityID\": \"01234abc-de56-ff78-abc1-234567890def\"\n}\n```\n\n#### Fetching the Kubelet identity\n\nFor this process, you will need to get the kubelet identity:\n\n```bash\n$ PRINCIPAL_ID=$(az aks show --resource-group $CLUSTER_GROUP --name $CLUSTERNAME \\\n  --query \"identityProfile.kubeletidentity.objectId\" --output tsv)\n$ IDENTITY_CLIENT_ID=$(az aks show --resource-group $CLUSTER_GROUP --name $CLUSTERNAME \\\n  --query \"identityProfile.kubeletidentity.clientId\" --output tsv)\n```\n\n#### Assign rights for the Kubelet identity\n\nGrant access to Azure DNS zone for the kubelet identity.\n\n```bash\n$ AZURE_DNS_ZONE=\"example.com\" # DNS zone name like example.com or sub.example.com\n$ AZURE_DNS_ZONE_RESOURCE_GROUP=\"MyDnsResourceGroup\" # resource group where DNS zone is hosted\n\n# fetch DNS id used to grant access to the kubelet identity\n$ DNS_ID=$(az network dns zone show --name $AZURE_DNS_ZONE \\\n  --resource-group $AZURE_DNS_ZONE_RESOURCE_GROUP --query \"id\" --output tsv)\n\n$ az role assignment create --role \"DNS Zone Contributor\" --assignee $PRINCIPAL_ID --scope $DNS_ID\n```\n\n#### Creating a configuration file for the kubelet identity\n\nCreate the file `azure.json` with values gather from previous steps.\n\n```bash\ncat <<-EOF > /local/path/to/azure.json\n{\n  \"tenantId\": \"$(az account show --query tenantId -o tsv)\",\n  \"subscriptionId\": \"$(az account show --query id -o tsv)\",\n  \"resourceGroup\": \"$AZURE_DNS_ZONE_RESOURCE_GROUP\",\n  \"useManagedIdentityExtension\": true,\n  \"userAssignedIdentityID\": \"$IDENTITY_CLIENT_ID\"\n}\nEOF\n```\n\nUse the `azure.json` file to create a Kubernetes secret:\n\n```bash\nkubectl create secret generic azure-config-file --namespace \"default\" --from-file /local/path/to/azure.json\n```\n\n### Managed identity using AAD Pod Identities\n\nFor this process, we will create a [managed identity](https://docs.microsoft.com//azure/active-directory/managed-identities-azure-resources/overview) that will be explicitly used by the ExternalDNS container.\nThis process is similar to Kubelet identity except that this managed identity is not associated with the Kubernetes node pool, but rather associated with explicit ExternalDNS containers.\n\n#### Enable the AAD Pod Identities feature\n\nFor this solution, [AAD Pod Identities](https://docs.microsoft.com/azure/aks/use-azure-ad-pod-identity) preview feature can be enabled.  The commands below should do the trick to enable this feature:\n\n```bash\naz feature register --name EnablePodIdentityPreview --namespace Microsoft.ContainerService\naz feature register --name AutoUpgradePreview --namespace Microsoft.ContainerService\naz extension add --name aks-preview\naz extension update --name aks-preview\naz provider register --namespace Microsoft.ContainerService\n```\n\n#### Deploy the AAD Pod Identities service\n\nOnce enabled, you can update your cluster and install needed services for the [AAD Pod Identities](https://docs.microsoft.com/azure/aks/use-azure-ad-pod-identity) feature.\n\n```bash\nAZURE_AKS_RESOURCE_GROUP=\"my-aks-cluster-group\" # name of resource group where aks cluster was created\nAZURE_AKS_CLUSTER_NAME=\"my-aks-cluster\" # name of aks cluster previously created\n\naz aks update --resource-group ${AZURE_AKS_RESOURCE_GROUP} --name ${AZURE_AKS_CLUSTER_NAME} --enable-pod-identity\n```\n\nNote that, if you use the default network plugin `kubenet`, then you need to add the command line option `--enable-pod-identity-with-kubenet` to the above command.\n\n#### Creating the managed identity\n\nAfter this process is finished, create a managed identity.\n\n```bash\n$ IDENTITY_RESOURCE_GROUP=$AZURE_AKS_RESOURCE_GROUP # custom group or reuse AKS group\n$ IDENTITY_NAME=\"example-com-identity\"\n\n# create a managed identity\n$ az identity create --resource-group \"${IDENTITY_RESOURCE_GROUP}\" --name \"${IDENTITY_NAME}\"\n```\n\n#### Assign rights for the managed identity\n\nGrant access to Azure DNS zone for the managed identity.\n\n```bash\n$ AZURE_DNS_ZONE_RESOURCE_GROUP=\"MyDnsResourceGroup\" # name of resource group where dns zone is hosted\n$ AZURE_DNS_ZONE=\"example.com\" # DNS zone name like example.com or sub.example.com\n\n# fetch identity client id from managed identity created earlier\n$ IDENTITY_CLIENT_ID=$(az identity show --resource-group \"${IDENTITY_RESOURCE_GROUP}\" \\\n  --name \"${IDENTITY_NAME}\" --query \"clientId\" --output tsv)\n# fetch DNS id used to grant access to the managed identity\n$ DNS_ID=$(az network dns zone show --name \"${AZURE_DNS_ZONE}\" \\\n  --resource-group \"${AZURE_DNS_ZONE_RESOURCE_GROUP}\" --query \"id\" --output tsv)\n\n$ az role assignment create --role \"DNS Zone Contributor\" \\\n  --assignee \"${IDENTITY_CLIENT_ID}\" --scope \"${DNS_ID}\"\n```\n\n#### Creating a configuration file for the managed identity\n\nCreate the file `azure.json` with the values from previous steps:\n\n```bash\ncat <<-EOF > /local/path/to/azure.json\n{\n  \"tenantId\": \"$(az account show --query tenantId -o tsv)\",\n  \"subscriptionId\": \"$(az account show --query id -o tsv)\",\n  \"resourceGroup\": \"$AZURE_DNS_ZONE_RESOURCE_GROUP\",\n  \"useManagedIdentityExtension\": true,\n  \"userAssignedIdentityID\": \"$IDENTITY_CLIENT_ID\"\n}\nEOF\n```\n\nUse the `azure.json` file to create a Kubernetes secret:\n\n```bash\nkubectl create secret generic azure-config-file --namespace \"default\" --from-file /local/path/to/azure.json\n```\n\n#### Creating an Azure identity binding\n\nA binding between the managed identity and the ExternalDNS pods needs to be setup by creating `AzureIdentity` and `AzureIdentityBinding` resources.\nThis will allow appropriately labeled ExternalDNS pods to authenticate using the managed identity.  When AAD Pod Identity feature is enabled from previous steps above, the `az aks pod-identity add` can be used to create these resources:\n\n```bash\n$ IDENTITY_RESOURCE_ID=$(az identity show --resource-group ${IDENTITY_RESOURCE_GROUP} \\\n  --name ${IDENTITY_NAME} --query id --output tsv)\n\n$ az aks pod-identity add --resource-group ${AZURE_AKS_RESOURCE_GROUP}  \\\n  --cluster-name ${AZURE_AKS_CLUSTER_NAME} --namespace \"default\" \\\n  --name \"external-dns\" --identity-resource-id ${IDENTITY_RESOURCE_ID}\n```\n\nThis will add something similar to the following resources:\n\n```yaml\napiVersion: aadpodidentity.k8s.io/v1\nkind: AzureIdentity\nmetadata:\n  labels:\n    addonmanager.kubernetes.io/mode: Reconcile\n    kubernetes.azure.com/managedby: aks\n  name: external-dns\nspec:\n  clientID: $IDENTITY_CLIENT_ID\n  resourceID: $IDENTITY_RESOURCE_ID\n  type: 0\n---\napiVersion: aadpodidentity.k8s.io/v1\nkind: AzureIdentityBinding\nmetadata:\n  annotations:\n  labels:\n    addonmanager.kubernetes.io/mode: Reconcile\n    kubernetes.azure.com/managedby: aks\n  name: external-dns-binding\nspec:\n  azureIdentity: external-dns\n  selector: external-dns\n```\n\n#### Update ExternalDNS labels\n\nWhen deploying ExternalDNS, you want to make sure that deployed pod(s) will have the label `aadpodidbinding: external-dns` to enable AAD Pod Identities. You can patch an existing deployment of ExternalDNS with this command:\n\n```bash\nkubectl patch deployment external-dns --namespace \"default\" --patch \\\n '{\"spec\": {\"template\": {\"metadata\": {\"labels\": {\"aadpodidbinding\": \"external-dns\"}}}}}'\n```\n\n### Managed identity using Workload Identity\n\nFor this process, we will create a [managed identity](https://docs.microsoft.com//azure/active-directory/managed-identities-azure-resources/overview) that will be explicitly used by the ExternalDNS container.\nThis process is somewhat similar to Pod Identity except that this managed identity is associated with a kubernetes service account.\n\n#### Deploy OIDC issuer and Workload Identity services\n\nUpdate your cluster to install [OIDC Issuer](https://learn.microsoft.com/en-us/azure/aks/use-oidc-issuer) and [Workload Identity](https://learn.microsoft.com/en-us/azure/aks/workload-identity-deploy-cluster):\n\n```bash\nAZURE_AKS_RESOURCE_GROUP=\"my-aks-cluster-group\" # name of resource group where aks cluster was created\nAZURE_AKS_CLUSTER_NAME=\"my-aks-cluster\" # name of aks cluster previously created\n\naz aks update --resource-group ${AZURE_AKS_RESOURCE_GROUP} --name ${AZURE_AKS_CLUSTER_NAME} --enable-oidc-issuer --enable-workload-identity\n```\n\n#### Create a managed identity\n\nCreate a managed identity:\n\n```bash\n$ IDENTITY_RESOURCE_GROUP=$AZURE_AKS_RESOURCE_GROUP # custom group or reuse AKS group\n$ IDENTITY_NAME=\"example-com-identity\"\n\n# create a managed identity\n$ az identity create --resource-group \"${IDENTITY_RESOURCE_GROUP}\" --name \"${IDENTITY_NAME}\"\n```\n\n#### Assign a role to the managed identity\n\nGrant access to Azure DNS zone for the managed identity:\n\n```bash\n$ AZURE_DNS_ZONE_RESOURCE_GROUP=\"MyDnsResourceGroup\" # name of resource group where dns zone is hosted\n$ AZURE_DNS_ZONE=\"example.com\" # DNS zone name like example.com or sub.example.com\n\n# fetch identity client id from managed identity created earlier\n$ IDENTITY_CLIENT_ID=$(az identity show --resource-group \"${IDENTITY_RESOURCE_GROUP}\" \\\n  --name \"${IDENTITY_NAME}\" --query \"clientId\" --output tsv)\n# fetch DNS id used to grant access to the managed identity\n$ DNS_ID=$(az network dns zone show --name \"${AZURE_DNS_ZONE}\" \\\n  --resource-group \"${AZURE_DNS_ZONE_RESOURCE_GROUP}\" --query \"id\" --output tsv)\n$ RESOURCE_GROUP_ID=$(az group show --name \"${AZURE_DNS_ZONE_RESOURCE_GROUP}\" --query \"id\" --output tsv)\n\n$ az role assignment create --role \"DNS Zone Contributor\" \\\n  --assignee \"${IDENTITY_CLIENT_ID}\" --scope \"${DNS_ID}\"\n$ az role assignment create --role \"Reader\" \\\n  --assignee \"${IDENTITY_CLIENT_ID}\" --scope \"${RESOURCE_GROUP_ID}\"\n```\n\n#### Create a federated identity credential\n\nA binding between the managed identity and the ExternalDNS service account needs to be setup by creating a federated identity resource:\n\n```bash\nOIDC_ISSUER_URL=\"$(az aks show -n myAKSCluster -g myResourceGroup --query \"oidcIssuerProfile.issuerUrl\" -otsv)\"\n\naz identity federated-credential create --name ${IDENTITY_NAME} --identity-name ${IDENTITY_NAME} --resource-group $AZURE_AKS_RESOURCE_GROUP} --issuer \"$OIDC_ISSUER_URL\" --subject \"system:serviceaccount:default:external-dns\"\n```\n\nNOTE: make sure federated credential refers to correct namespace and service account (`system:serviceaccount:<NAMESPACE>:<SERVICE_ACCOUNT>`)\n\n#### Helm\n\nWhen deploying external-dns with Helm you need to create a secret to store the Azure config (see below) and create a workload identity (out of scope here) before you can install the chart.\n\n```yaml\napiVersion: v1\nkind: Secret\nmetadata:\n  name: external-dns-azure\ntype: Opaque\ndata:\n  azure.json: |\n    {\n      \"tenantId\": \"<TENANT_ID>\",\n      \"subscriptionId\": \"<SUBSCRIPTION_ID>\",\n      \"resourceGroup\": \"<AZURE_DNS_ZONE_RESOURCE_GROUP>\",\n      \"useWorkloadIdentityExtension\": true\n    }\n```\n\nOnce you have created the secret and have a workload identity you can install the chart with the following values.\n\n```yaml\nfullnameOverride: external-dns\n\nserviceAccount:\n  labels:\n    azure.workload.identity/use: \"true\"\n  annotations:\n    azure.workload.identity/client-id: <IDENTITY_CLIENT_ID>\n\npodLabels:\n  azure.workload.identity/use: \"true\"\n\nextraVolumes:\n  - name: azure-config-file\n    secret:\n      secretName: external-dns-azure\n\nextraVolumeMounts:\n  - name: azure-config-file\n    mountPath: /etc/kubernetes\n    readOnly: true\n\nprovider:\n  name: azure\n```\n\nNOTE: make sure the pod is restarted whenever you make a configuration change.\n\n#### kubectl (alternative)\n\n##### Create a configuration file for the managed identity\n\nCreate the file `azure.json` with the values from previous steps:\n\n```bash\ncat <<-EOF > /local/path/to/azure.json\n{\n  \"subscriptionId\": \"$(az account show --query id -o tsv)\",\n  \"resourceGroup\": \"$AZURE_DNS_ZONE_RESOURCE_GROUP\",\n  \"useWorkloadIdentityExtension\": true\n}\nEOF\n```\n\nNOTE: it's also possible to specify (or override) ClientID specified in the next section through `aadClientId` field in this `azure.json` file.\n\nUse the `azure.json` file to create a Kubernetes secret:\n\n```bash\nkubectl create secret generic azure-config-file --namespace \"default\" --from-file /local/path/to/azure.json\n```\n\n##### Update labels and annotations on ExternalDNS service account\n\nTo instruct Workload Identity webhook to inject a projected token into the ExternalDNS pod, the pod needs to have a label `azure.workload.identity/use: \"true\"` (before Workload Identity 1.0.0, this label was supposed to be set on the service account instead).\nAlso, the service account needs to have an annotation `azure.workload.identity/client-id: <IDENTITY_CLIENT_ID>`:\n\nTo patch the existing serviceaccount and deployment, use the following command:\n\n```bash\n$ kubectl patch serviceaccount external-dns --namespace \"default\" --patch \\\n \"{\\\"metadata\\\": {\\\"annotations\\\": {\\\"azure.workload.identity/client-id\\\": \\\"${IDENTITY_CLIENT_ID}\\\"}}}\"\n$ kubectl patch deployment external-dns --namespace \"default\" --patch \\\n '{\"spec\": {\"template\": {\"metadata\": {\"labels\": {\\\"azure.workload.identity/use\\\": \\\"true\\\"}}}}}'\n```\n\nNOTE: it's also possible to specify (or override) ClientID through `aadClientId` field in `azure.json`.\n\nNOTE: make sure the pod is restarted whenever you make a configuration change.\n\n## Throttling\n\nWhen the ExternalDNS managed zones list doesn't change frequently, one can set `--azure-zones-cache-duration` (zones list cache time-to-live). The zones list cache is disabled by default, with a value of 0s.\nAlso, one can leverage the built-in retry policies of the Azure SDK with a tunable maxRetries value. Environment variable AZURE_SDK_MAX_RETRIES can be specified in the manifest yaml to configure behavior. The defualt value of Azure SDK retry is 3.\n\n## Ingress used with ExternalDNS\n\nThis deployment assumes that you will be using nginx-ingress. When using nginx-ingress do not deploy it as a Daemon Set.\nThis causes nginx-ingress to write the Cluster IP of the backend pods in the ingress status.loadbalancer.ip property which then has external-dns write the Cluster IP(s) in DNS vs. the nginx-ingress service external IP.\n\nEnsure that your nginx-ingress deployment has the following arg: added to it:\n\n```sh\n- --publish-service=namespace/nginx-ingress-controller-svcname\n```\n\nFor more details see here: [nginx-ingress external-dns](https://github.com/kubernetes-sigs/external-dns/blob/HEAD/docs/faq.md#why-is-externaldns-only-adding-a-single-ip-address-in-route-53-on-aws-when-using-the-nginx-ingress-controller-how-do-i-get-it-to-use-the-fqdn-of-the-elb-assigned-to-my-nginx-ingress-controller-service-instead)\n\n## Deploy ExternalDNS\n\nConnect your `kubectl` client to the cluster you want to test ExternalDNS with. Then apply one of the following manifests file to deploy ExternalDNS.\n\nThe deployment assumes that ExternalDNS will be installed into the `default` namespace.  If this namespace is different, the `ClusterRoleBinding` will need to be updated to reflect the desired alternative namespace, such as `external-dns`, `kube-addons`, etc.\n\n### Manifest (for clusters without RBAC enabled)\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=service\n        - --source=ingress\n        - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above.\n        - --provider=azure\n        - --azure-resource-group=MyDnsResourceGroup # (optional) use the DNS zones from the tutorial's resource group\n        - --azure-maxretries-count=1  # (optional) specifies the maxRetires value to be used by the Azure SDK. Default is 3.\n        volumeMounts:\n        - name: azure-config-file\n          mountPath: /etc/kubernetes\n          readOnly: true\n      volumes:\n      - name: azure-config-file\n        secret:\n          secretName: azure-config-file\n```\n\n### Manifest (for clusters with RBAC enabled, cluster access)\n\n```yaml\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: external-dns\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: external-dns\nrules:\n  - apiGroups: [\"\"]\n    resources: [\"services\",\"pods\", \"nodes\"]\n    verbs: [\"get\",\"watch\",\"list\"]\n  - apiGroups: [\"discovery.k8s.io\"]\n    resources: [\"endpointslices\"]\n    verbs: [\"get\",\"watch\",\"list\"]\n  - apiGroups: [\"extensions\",\"networking.k8s.io\"]\n    resources: [\"ingresses\"]\n    verbs: [\"get\",\"watch\",\"list\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: external-dns-viewer\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: external-dns\nsubjects:\n  - kind: ServiceAccount\n    name: external-dns\n    namespace: default\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      serviceAccountName: external-dns\n      containers:\n        - name: external-dns\n          image: registry.k8s.io/external-dns/external-dns:v0.20.0\n          args:\n            - --source=service\n            - --source=ingress\n            - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above.\n            - --provider=azure\n            - --azure-resource-group=MyDnsResourceGroup # (optional) use the DNS zones from the tutorial's resource group\n            - --txt-prefix=externaldns-\n            - --azure-maxretries-count=1  # (optional) specifies the maxRetires value to be used by the Azure SDK. Default is 3.\n          volumeMounts:\n            - name: azure-config-file\n              mountPath: /etc/kubernetes\n              readOnly: true\n      volumes:\n        - name: azure-config-file\n          secret:\n            secretName: azure-config-file\n```\n\n### Manifest (for clusters with RBAC enabled, namespace access)\n\nThis configuration is the same as above, except it only requires privileges for the current namespace, not for the whole cluster.\nHowever, access to [nodes](https://kubernetes.io/docs/concepts/architecture/nodes/) requires cluster access, so when using this manifest,\nservices with type `NodePort` will be skipped!\n\n```yaml\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: external-dns\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: Role\nmetadata:\n  name: external-dns\nrules:\n  - apiGroups: [\"\"]\n    resources: [\"services\",\"pods\"]\n    verbs: [\"get\",\"watch\",\"list\"]\n  - apiGroups: [\"discovery.k8s.io\"]\n    resources: [\"endpointslices\"]\n    verbs: [\"get\",\"watch\",\"list\"]\n  - apiGroups: [\"extensions\",\"networking.k8s.io\"]\n    resources: [\"ingresses\"]\n    verbs: [\"get\",\"watch\",\"list\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: RoleBinding\nmetadata:\n  name: external-dns\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: Role\n  name: external-dns\nsubjects:\n  - kind: ServiceAccount\n    name: external-dns\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      serviceAccountName: external-dns\n      containers:\n        - name: external-dns\n          image: registry.k8s.io/external-dns/external-dns:v0.20.0\n          args:\n            - --source=service\n            - --source=ingress\n            - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above.\n            - --provider=azure\n            - --azure-resource-group=MyDnsResourceGroup # (optional) use the DNS zones from the tutorial's resource group\n            - --azure-maxretries-count=1  # (optional) specifies the maxRetires value to be used by the Azure SDK. Default is 3.\n          volumeMounts:\n            - name: azure-config-file\n              mountPath: /etc/kubernetes\n              readOnly: true\n      volumes:\n        - name: azure-config-file\n          secret:\n            secretName: azure-config-file\n```\n\nCreate the deployment for ExternalDNS:\n\n```bash\nkubectl create --namespace \"default\" --filename externaldns.yaml\n```\n\n## Ingress Option: Expose an nginx service with an ingress\n\nCreate a file called `nginx.yaml` with the following contents:\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: nginx\nspec:\n  selector:\n    matchLabels:\n      app: nginx\n  template:\n    metadata:\n      labels:\n        app: nginx\n    spec:\n      containers:\n        - image: nginx\n          name: nginx\n          ports:\n          - containerPort: 80\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: nginx-svc\nspec:\n  ports:\n    - port: 80\n      protocol: TCP\n      targetPort: 80\n  selector:\n    app: nginx\n  type: ClusterIP\n\n---\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  name: nginx\nspec:\n  ingressClassName: nginx\n  rules:\n    - host: server.example.com\n      http:\n        paths:\n          - path: /\n            pathType: Prefix\n            backend:\n              service:\n                name: nginx-svc\n                port:\n                  number: 80\n```\n\nWhen you use ExternalDNS with Ingress resources, it automatically creates DNS records based on the hostnames listed in those Ingress objects.\nThose hostnames must match the filters that you defined (if any):\n\n* By default, `--domain-filter` filters Azure DNS zone.\n* If you use `--domain-filter` together with `--zone-name-filter`, the behavior changes: `--domain-filter` then filters Ingress domains, not the Azure DNS zone name.\n\nWhen those hostnames are removed or renamed the corresponding DNS records are also altered.\n\nCreate the deployment, service and ingress object:\n\n```bash\nkubectl create --namespace \"default\" --filename nginx.yaml\n```\n\nSince your external IP would have already been assigned to the nginx-ingress service, the DNS records pointing to the IP of the nginx-ingress service should be created within a minute.\n\n## Azure Load Balancer option: Expose an nginx service with a load balancer\n\nCreate a file called `nginx.yaml` with the following contents:\n\n```yaml\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: nginx\nspec:\n  selector:\n    matchLabels:\n      app: nginx\n  template:\n    metadata:\n      labels:\n        app: nginx\n    spec:\n      containers:\n        - image: nginx\n          name: nginx\n          ports:\n          - containerPort: 80\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: nginx-svc\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: server.example.com\nspec:\n  ports:\n    - port: 80\n      protocol: TCP\n      targetPort: 80\n  selector:\n    app: nginx\n  type: LoadBalancer\n```\n\nThe annotation `external-dns.alpha.kubernetes.io/hostname` is used to specify the DNS name that should be created for the service. The annotation value is a comma separated list of host names.\n\n## Verifying Azure DNS records\n\nRun the following command to view the A records for your Azure DNS zone:\n\n```bash\naz network dns record-set a list --resource-group \"MyDnsResourceGroup\" --zone-name example.com\n```\n\nSubstitute the zone for the one created above if a different domain was used.\n\nThis should show the external IP address of the service as the A record for your domain ('@' indicates the record is for the zone itself).\n\n## Delete Azure Resource Group\n\nNow that we have verified that ExternalDNS will automatically manage Azure DNS records, we can delete the tutorial's\nresource group:\n\n```bash\naz group delete --name \"MyDnsResourceGroup\"\n```\n\n## More tutorials\n\nA video explanation is available here: https://www.youtube.com/watch?v=VSn6DPKIhM8&list=PLpbcUe4chE79sB7Jg7B4z3HytqUUEwcNE\n\n![image](https://user-images.githubusercontent.com/6548359/235437721-87611869-75f2-4f32-bb35-9da585e46299.png)\n"
  },
  {
    "path": "docs/tutorials/civo.md",
    "content": "# Civo DNS\n\nThis tutorial describes how to setup ExternalDNS for usage within a Kubernetes cluster using Civo DNS Manager.\n\nMake sure to use **>0.13.5** version of ExternalDNS for this tutorial.\n\n## Managing DNS with Civo\n\nIf you want to learn about how to use Civo DNS Manager read the following tutorials:\n\n[An Introduction to Managing DNS](https://www.civo.com/learn/configure-dns)\n\n## Get Civo Token\n\nCopy the token in the settings for your account\nThe environment variable `CIVO_TOKEN` will be needed to run ExternalDNS with Civo.\n\n## Deploy ExternalDNS\n\nConnect your `kubectl` client to the cluster you want to test ExternalDNS with.\nThen apply one of the following manifests file to deploy ExternalDNS.\n\n### Manifest (for clusters without RBAC enabled)\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=service # ingress is also possible\n        - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above.\n        - --provider=civo\n        env:\n        - name: CIVO_TOKEN\n          value: \"YOUR_CIVO_API_TOKEN\"\n```\n\n### Manifest (for clusters with RBAC enabled)\n\n```yaml\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: external-dns\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: external-dns\nrules:\n- apiGroups: [\"\"]\n  resources: [\"services\",\"pods\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"discovery.k8s.io\"]\n  resources: [\"endpointslices\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"extensions\",\"networking.k8s.io\"]\n  resources: [\"ingresses\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"\"]\n  resources: [\"nodes\"]\n  verbs: [\"list\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: external-dns-viewer\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: external-dns\nsubjects:\n- kind: ServiceAccount\n  name: external-dns\n  namespace: default\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      serviceAccountName: external-dns\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=service # ingress is also possible\n        - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above.\n        - --provider=civo\n        env:\n        - name: CIVO_TOKEN\n          value: \"YOUR_CIVO_API_TOKEN\"\n```\n\n## Deploying an Nginx Service\n\nCreate a service file called 'nginx.yaml' with the following contents:\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: nginx\nspec:\n  selector:\n    matchLabels:\n      app: nginx\n  template:\n    metadata:\n      labels:\n        app: nginx\n    spec:\n      containers:\n      - image: nginx\n        name: nginx\n        ports:\n        - containerPort: 80\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: nginx\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: my-app.example.com\nspec:\n  selector:\n    app: nginx\n  type: LoadBalancer\n  ports:\n    - protocol: TCP\n      port: 80\n      targetPort: 80\n```\n\nNote the annotation on the service; use the same hostname as the Civo DNS zone created above.\n\nExternalDNS uses this annotation to determine what services should be registered with DNS. Removing the annotation will cause ExternalDNS to remove the corresponding DNS records.\n\nCreate the deployment and service:\n\n```console\nkubectl create -f nginx.yaml\n```\n\nDepending where you run your service it can take a little while for your cloud provider to create an external IP for the service.\n\nOnce the service has an external IP assigned, ExternalDNS will notice the new service IP address and synchronize the Civo DNS records.\n\n## Verifying Civo DNS records\n\nCheck your [Civo UI](https://www.civo.com/account/dns) to view the records for your Civo DNS zone.\n\nClick on the zone for the one created above if a different domain was used.\n\nThis should show the external IP address of the service as the A record for your domain.\n\n## Cleanup\n\nNow that we have verified that ExternalDNS will automatically manage Civo DNS records, we can delete the tutorial's example:\n\n```sh\nkubectl delete service -f nginx.yaml\nkubectl delete service -f externaldns.yaml\n```\n"
  },
  {
    "path": "docs/tutorials/cloudflare.md",
    "content": "# Cloudflare DNS\n\nThis tutorial describes how to setup ExternalDNS for usage within a Kubernetes cluster using Cloudflare DNS.\n\nMake sure to use **>=0.4.2** version of ExternalDNS for this tutorial.\n\n## Creating a Cloudflare DNS zone\n\nWe highly recommend to read this tutorial if you haven't used Cloudflare before:\n\n[Create a Cloudflare account and add a website](https://support.cloudflare.com/hc/en-us/articles/201720164-Step-2-Create-a-Cloudflare-account-and-add-a-website)\n\n## Creating Cloudflare Credentials\n\nSnippet from [Cloudflare - Getting Started](https://api.cloudflare.com/#getting-started-endpoints):\n\n> Cloudflare's API exposes the entire Cloudflare infrastructure via a standardized programmatic interface. Using Cloudflare's API, you can do just about anything you can do on cloudflare.com via the customer dashboard.\n> The Cloudflare API is a RESTful API based on HTTPS requests and JSON responses. If you are registered with Cloudflare, you can obtain your API key from the bottom of the \"My Account\" page, found here: [Go to My account](https://dash.cloudflare.com/profile).\n\nAPI Token will be preferred for authentication if `CF_API_TOKEN` environment variable is set.\nOtherwise `CF_API_KEY` and `CF_API_EMAIL` should be set to run ExternalDNS with Cloudflare.\nYou may provide the Cloudflare API token through a file by setting the\n`CF_API_TOKEN=\"file:/path/to/token\"`.\n\nNote. The `CF_API_KEY` and `CF_API_EMAIL` should not be present, if you are using a `CF_API_TOKEN`.\n\nWhen using API Token authentication, the token should be granted Zone `Read`, DNS `Edit` privileges, and access to `All zones`.\n\nIf you would like to further restrict the API permissions to a specific zone (or zones), you also need to use the `--zone-id-filter` so that the underlying API requests only access the zones that you explicitly specify, as opposed to accessing all zones.\n\n## Throttling\n\nCloudflare API has a [global rate limit of 1,200 requests per five minutes](https://developers.cloudflare.com/fundamentals/api/reference/limits/). Running several fast polling ExternalDNS instances in a given account can easily hit that limit.\nThe AWS Provider [docs](./aws.md#throttling) has some recommendations that can be followed here too, but in particular, consider passing `--cloudflare-dns-records-per-page` with a high value (maximum is 5,000).\n\n## Batch API\n\nThe Cloudflare provider submits DNS record changes using Cloudflare's [Batch DNS Records API](https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/batch/).\nAll creates, updates, and deletes for a zone are grouped into transactional chunks and sent in a single API call per chunk,\nsignificantly reducing the total number of requests made.\n\nThe batch API is transactional — if a chunk fails, the entire chunk is rolled back by Cloudflare.\nIn that case, ExternalDNS automatically retries each record change in the chunk individually.\nRecord types that are not supported by the batch PUT operation (e.g. SRV, CAA) are always submitted individually rather than through the batch API.\n\n| Flag | Default | Description |\n| :--- | :------ | :---------- |\n| `--batch-change-size` | `200` | Maximum number of DNS operations (creates + updates + deletes) per batch chunk. |\n| `--batch-change-interval` | `1s` | Pause between consecutive batch chunks. |\n\n## Deploy ExternalDNS\n\nConnect your `kubectl` client to the cluster you want to test ExternalDNS with.\n\nBegin by creating a Kubernetes secret to securely store your CloudFlare API key. This key will enable ExternalDNS to authenticate with CloudFlare:\n\n```shell\nkubectl create secret generic cloudflare-api-key --from-literal=apiKey=YOUR_API_KEY --from-literal=email=YOUR_CLOUDFLARE_EMAIL\n```\n\nAnd for API Token it should look like :\n\n```shell\nkubectl create secret generic cloudflare-api-key --from-literal=apiKey=YOUR_API_TOKEN\n```\n\nEnsure to replace YOUR_API_KEY with your actual CloudFlare API key and YOUR_CLOUDFLARE_EMAIL with the email associated with your CloudFlare account.\n\nThen apply one of the following manifests file to deploy ExternalDNS.\n\n### Using Helm\n\nCreate a values.yaml file to configure ExternalDNS to use CloudFlare as the DNS provider. This file should include the necessary environment variables:\n\n```yaml\nprovider:\n  name: cloudflare\nenv:\n  - name: CF_API_KEY\n    valueFrom:\n      secretKeyRef:\n        name: cloudflare-api-key\n        key: apiKey\n  - name: CF_API_EMAIL\n    valueFrom:\n      secretKeyRef:\n        name: cloudflare-api-key\n        key: email\n```\n\nUse this in your values.yaml, if you are using API Token:\n\n```yaml\nprovider:\n  name: cloudflare\nenv:\n  - name: CF_API_TOKEN\n    valueFrom:\n      secretKeyRef:\n        name: cloudflare-api-key\n        key: apiKey\n```\n\nFinally, install the ExternalDNS chart with Helm using the configuration specified in your values.yaml file:\n\n```shell\nhelm repo add external-dns https://kubernetes-sigs.github.io/external-dns/\n```\n\n```shell\nhelm repo update\n```\n\n```shell\nhelm upgrade --install external-dns external-dns/external-dns --values values.yaml\n```\n\n### Manifest (for clusters without RBAC enabled)\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      containers:\n        - name: external-dns\n          image: registry.k8s.io/external-dns/external-dns:v0.20.0\n          args:\n            - --source=service # ingress is also possible\n            - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above.\n            - --zone-id-filter=023e105f4ecef8ad9ca31a8372d0c353 # (optional) limit to a specific zone.\n            - --provider=cloudflare\n            - --cloudflare-proxied # (optional) enable the proxy feature of Cloudflare (DDOS protection, CDN...)\n            - --cloudflare-dns-records-per-page=5000 # (optional) configure how many DNS records to fetch per request\n            - --cloudflare-regional-services # (optional) enable the regional hostname feature that configure which region can decrypt HTTPS requests\n            - --cloudflare-region-key=\"eu\" # (optional) configure which region can decrypt HTTPS requests\n            - --cloudflare-record-comment=\"provisioned by external-dns\" # (optional) configure comments for provisioned records; <=100 chars for free zones; <=500 chars for paid zones\n         env:\n            - name: CF_API_KEY\n              valueFrom:\n                secretKeyRef:\n                  name: cloudflare-api-key\n                  key: apiKey\n            - name: CF_API_EMAIL\n              valueFrom:\n                secretKeyRef:\n                  name: cloudflare-api-key\n                  key: email\n```\n\n### Manifest (for clusters with RBAC enabled)\n\n```yaml\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: external-dns\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: external-dns\nrules:\n  - apiGroups: [\"\"]\n    resources: [\"services\",\"pods\"]\n    verbs: [\"get\",\"watch\",\"list\"]\n  - apiGroups: [\"discovery.k8s.io\"]\n    resources: [\"endpointslices\"]\n    verbs: [\"get\",\"watch\",\"list\"]\n  - apiGroups: [\"extensions\",\"networking.k8s.io\"]\n    resources: [\"ingresses\"]\n    verbs: [\"get\",\"watch\",\"list\"]\n  - apiGroups: [\"\"]\n    resources: [\"nodes\"]\n    verbs: [\"list\", \"watch\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: external-dns-viewer\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: external-dns\nsubjects:\n- kind: ServiceAccount\n  name: external-dns\n  namespace: default\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      serviceAccountName: external-dns\n      containers:\n        - name: external-dns\n          image: registry.k8s.io/external-dns/external-dns:v0.20.0\n          args:\n            - --source=service # ingress is also possible\n            - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above.\n            - --zone-id-filter=023e105f4ecef8ad9ca31a8372d0c353 # (optional) limit to a specific zone.\n            - --provider=cloudflare\n            - --cloudflare-proxied # (optional) enable the proxy feature of Cloudflare (DDOS protection, CDN...)\n            - --cloudflare-dns-records-per-page=5000 # (optional) configure how many DNS records to fetch per request\n            - --cloudflare-regional-services # (optional) enable the regional hostname feature that configure which region can decrypt HTTPS requests\n            - --cloudflare-region-key=\"eu\" # (optional) configure which region can decrypt HTTPS requests\n            - --cloudflare-record-comment=\"provisioned by external-dns\" # (optional) configure comments for provisioned records; <=100 chars for free zones; <=500 chars for paid zones\n          env:\n            - name: CF_API_KEY\n              valueFrom:\n                secretKeyRef:\n                  name: cloudflare-api-key\n                  key: apiKey\n            - name: CF_API_EMAIL\n              valueFrom:\n                secretKeyRef:\n                  name: cloudflare-api-key\n                  key: email\n```\n\n## Deploying an Nginx Service\n\nCreate a service file called 'nginx.yaml' with the following contents:\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: nginx\nspec:\n  selector:\n    matchLabels:\n      app: nginx\n  template:\n    metadata:\n      labels:\n        app: nginx\n    spec:\n      containers:\n      - image: nginx\n        name: nginx\n        ports:\n        - containerPort: 80\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: nginx\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: example.com\n    external-dns.alpha.kubernetes.io/ttl: \"120\" #optional\nspec:\n  selector:\n    app: nginx\n  type: LoadBalancer\n  ports:\n    - protocol: TCP\n      port: 80\n      targetPort: 80\n```\n\nNote the annotation on the service; use the same hostname as the Cloudflare DNS zone created above. The annotation may also be a subdomain\nof the DNS zone (e.g. 'www.example.com').\n\nBy setting the TTL annotation on the service, you have to pass a valid TTL, which must be 120 or above.\nThis annotation is optional, if you won't set it, it will be 1 (automatic) which is 300.\nFor Cloudflare proxied entries, set the TTL annotation to 1 (automatic), or do not set it.\n\nExternalDNS uses this annotation to determine what services should be registered with DNS. Removing the annotation\nwill cause ExternalDNS to remove the corresponding DNS records.\n\nCreate the deployment and service:\n\n```shell\nkubectl create -f nginx.yaml\n```\n\nDepending where you run your service it can take a little while for your cloud provider to create an external IP for the service.\n\nOnce the service has an external IP assigned, ExternalDNS will notice the new service IP address and synchronize\nthe Cloudflare DNS records.\n\n## Verifying Cloudflare DNS records\n\nCheck your [Cloudflare dashboard](https://www.cloudflare.com/a/dns/example.com) to view the records for your Cloudflare DNS zone.\n\nSubstitute the zone for the one created above if a different domain was used.\n\nThis should show the external IP address of the service as the A record for your domain.\n\n## Cleanup\n\nNow that we have verified that ExternalDNS will automatically manage Cloudflare DNS records, we can delete the tutorial's example:\n\n```shell\nkubectl delete -f nginx.yaml\nkubectl delete -f externaldns.yaml\n```\n\n## Setting cloudflare-proxied on a per-ingress basis\n\nUsing the `external-dns.alpha.kubernetes.io/cloudflare-proxied: \"true\"` annotation on your ingress, you can specify if the proxy feature of Cloudflare should be enabled for that record. This setting will override the global `--cloudflare-proxied` setting.\n\n## Setting cloudflare regional services\n\nWith Cloudflare regional services you can restrict which data centers can decrypt and serve HTTPS traffic.\n\nConfiguration of Cloudflare Regional Services is enabled by the `--cloudflare-regional-services` flag.\nA default region can be defined using the `--cloudflare-region-key` flag.\n\nUsing the `external-dns.alpha.kubernetes.io/cloudflare-region-key` annotation on your ingress, you can specify the region for that record.\n\nAn empty string will result in no regional hostname configured.\n\n**Accepted values for region key include:**\n\n- `eu`: European Union data centers only\n- `us`: United States data centers only\n- `ap`: Asia-Pacific data centers only\n- `fedramp`: US public sector (FedRAMP) data centers\n- `in`: India data centers only\n- `ca`: Canada data centers only\n- `jp`: Japan data centers only\n- `kr`: South Korea data centers only\n- `br`: Brazil data centers only\n- `za`: South Africa data centers only\n- `ae`: United Arab Emirates data centers only\n\nFor the most up-to-date list and details, see the [Cloudflare Regional Services documentation](https://developers.cloudflare.com/data-localization/regional-services/get-started/).\n\nCurrently, requires SuperAdmin or Admin role.\n\n## Setting cloudflare-custom-hostname\n\nAutomatic configuration of Cloudflare custom hostnames (using A/CNAME DNS records as custom origin servers) is enabled by the `--cloudflare-custom-hostnames` flag and the `external-dns.alpha.kubernetes.io/cloudflare-custom-hostname: <custom hostname>` annotation.\n\nMultiple hostnames are supported via a comma-separated list: `external-dns.alpha.kubernetes.io/cloudflare-custom-hostname: <custom hostname 1>,<custom hostname 2>`.\n\nSee [Cloudflare for Platforms](https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/domain-support/) for more information on custom hostnames.\n\nThis feature is disabled by default and supports the `--cloudflare-custom-hostnames-min-tls-version` and `--cloudflare-custom-hostnames-certificate-authority` flags.\n\n`--cloudflare-custom-hostnames-certificate-authority` defaults to `none`, which explicitly means no Certificate Authority (CA) is set when using the Cloudflare API. Specifying a custom CA is only possible for enterprise accounts.\n\nThe custom hostname DNS must resolve to the Cloudflare DNS record (`external-dns.alpha.kubernetes.io/hostname`) for automatic certificate validation via the HTTP method. It's important to note that the TXT method does not allow automatic validation and is not supported.\n\nRequires [Cloudflare for SaaS](https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/) product and \"SSL and Certificates\" API permission.\n\n## Setting Cloudflare DNS Record Tags\n\nCloudflare allows you to add descriptive tags to DNS records. This can be useful for organizing your records.\nFor example one can apply tags by environment (`production`, `staging`) or by the team that owns them (`frontend-team`, `backend-team`). ExternalDNS can manage these tags for you.\n\nTo assign tags to a DNS record, add the `external-dns.alpha.kubernetes.io/cloudflare-tags` annotation to your Kubernetes resource (like a Service or Ingress). The value should be a comma-separated list of your desired tags.\n\n```yaml\nmetadata:\n  annotations:\n    # Assigns three tags to the DNS record created from this resource\n    external-dns.alpha.kubernetes.io/cloudflare-tags: \"owner:frontend-team, env:dev, component:api\"\n```\n\n## Using CRD source to manage DNS records in Cloudflare\n\nPlease refer to the [CRD source documentation](../sources/crd.md#example) for more information.\n"
  },
  {
    "path": "docs/tutorials/contour.md",
    "content": "# Contour HTTPProxy\n\nThis tutorial describes how to configure External DNS to use the Contour `HTTPProxy` source.\nUsing the `HTTPProxy` resource with External DNS requires Contour version 1.5 or greater.\n\n## Example manifests for External DNS\n\n### Without RBAC\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=service\n        - --source=ingress\n        - --source=contour-httpproxy\n        - --domain-filter=external-dns-test.my-org.com # will make ExternalDNS see only the hosted zones matching provided domain, omit to process all available hosted zones\n        - --provider=aws\n        - --policy=upsert-only # would prevent ExternalDNS from deleting any records, omit to enable full synchronization\n        - --aws-zone-type=public # only look at public hosted zones (valid values are public, private or no value for both)\n        - --registry=txt\n        - --txt-owner-id=my-identifier\n```\n\n### With RBAC\n\n```yaml\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: external-dns\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: external-dns\nrules:\n- apiGroups: [\"\"]\n  resources: [\"services\",\"pods\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"discovery.k8s.io\"]\n  resources: [\"endpointslices\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"extensions\",\"networking.k8s.io\"]\n  resources: [\"ingresses\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"\"]\n  resources: [\"nodes\"]\n  verbs: [\"list\"]\n- apiGroups: [\"projectcontour.io\"]\n  resources: [\"httpproxies\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: external-dns-viewer\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: external-dns\nsubjects:\n- kind: ServiceAccount\n  name: external-dns\n  namespace: default\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      serviceAccountName: external-dns\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=service\n        - --source=ingress\n        - --source=contour-httpproxy\n        - --domain-filter=external-dns-test.my-org.com # will make ExternalDNS see only the hosted zones matching provided domain, omit to process all available hosted zones\n        - --provider=aws\n        - --policy=upsert-only # would prevent ExternalDNS from deleting any records, omit to enable full synchronization\n        - --aws-zone-type=public # only look at public hosted zones (valid values are public, private or no value for both)\n        - --registry=txt\n        - --txt-owner-id=my-identifier\n```\n\n### Verify External DNS works\n\nThe following instructions are based on the\n[Contour example workload](https://github.com/projectcontour/contour/tree/master/examples/example-workload/httpproxy).\n\n### Install a sample service\n\n```bash\n$ kubectl apply -f - <<EOF\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  labels:\n    app: kuard\n  name: kuard\nspec:\n  replicas: 3\n  selector:\n    matchLabels:\n      app: kuard\n  template:\n    metadata:\n      labels:\n        app: kuard\n    spec:\n      containers:\n      - image: gcr.io/kuar-demo/kuard-amd64:1\n        name: kuard\n---\napiVersion: v1\nkind: Service\nmetadata:\n  labels:\n    app: kuard\n  name: kuard\nspec:\n  ports:\n  - port: 80\n    protocol: TCP\n    targetPort: 8080\n  selector:\n    app: kuard\n  sessionAffinity: None\n  type: ClusterIP\nEOF\n```\n\nThen create an `HTTPProxy`:\n\n```sh\n$ kubectl apply -f - <<EOF\napiVersion: projectcontour.io/v1\nkind: HTTPProxy\nmetadata:\n  labels:\n    app: kuard\n  name: kuard\n  namespace: default\nspec:\n  virtualhost:\n    fqdn: kuard.example.com\n  routes:\n    - conditions:\n      - prefix: /\n      services:\n        - name: kuard\n          port: 80\nEOF\n```\n\n### Access the sample service using `curl`\n\n```bash\n$ curl -i http://kuard.example.com/healthy\nHTTP/1.1 200 OK\nContent-Type: text/plain\nDate: Thu, 27 Jun 2019 19:42:26 GMT\nContent-Length: 2\n\nok\n```\n"
  },
  {
    "path": "docs/tutorials/coredns-etcd.md",
    "content": "# CoreDNS with etcd backend\n\n## Overview\n\nThis tutorial describes how to deploy CoreDNS backed by etcd as a dynamic DNS provider for external-dns.\nIt shows how to configure external-dns to write DNS records into etcd, which CoreDNS will then serve.\n\n### TL;DR\n\nAfter completing this lab, you will have a Kubernetes environment running as containers in your local development machine with etcd, coredns and external-dns.\n\n### Notes\n\n- `CoreDNS` and etcd here run inside the cluster for demonstration purposes.\n- For real deployments, you can use external etcd or secure etcd with TLS.\n- The zone example.org is arbitrary — use your domain.\n- `external-dns` automatically maintains records in etcd under `/skydns/<reversed-domain>`.\n\n## Prerequisite\n\nBefore you start, ensure you have:\n\n- A running kubernetes cluster.\n  - In this tutorial we are going to use [kind](https://kind.sigs.k8s.io/)\n- [`kubectl`](https://kubernetes.io/docs/tasks/tools/) and [`helm`](https://helm.sh/)\n- `external-dns` source code or [helm chart](https://github.com/kubernetes-sigs/external-dns/tree/master/charts/external-dns)\n- `CoreDNS` [helm chart](https://github.com/coredns/helm)\n- Optional\n  - `dnstools` container for testing\n  - `etcdctl` to interat with [etcd](https://etcd.io/docs/v3.4/dev-guide/interacting_v3/)\n\n## Bootstrap Environment\n\n### 1. Create cluster\n\n```sh\nkind create cluster --config=docs/snippets/tutorials/coredns/kind.yaml\n\nCreating cluster \"coredns-etcd\" ...\n ✓ Ensuring node image (kindest/node:v1.33.0) 🖼\n ✓ Preparing nodes 📦 📦\n ✓ Writing configuration 📜\n ✓ Starting control-plane 🕹️\n ✓ Installing CNI 🔌\n ✓ Installing StorageClass 💾\n ✓ Joining worker nodes 🚜\nSet kubectl context to \"kind-coredns-etcd\"\nYou can now use your cluster with:\n\nkubectl cluster-info --context kind-coredns-etcd\n```\n\n### 2. Deploy etcd as stateful set\n\nThere are multiple options to configure etcd\n\n1. With custom manifest.\n2. ETCD [manifest](https://etcd.io/docs/v3.6/op-guide/kubernetes/)\n3. ETCD [operator](https://github.com/etcd-io/etcd-operator)\n\nIn this tutorial, we'll use the first option.\n\n```sh\n# apply custom manifest from external-dns repository\nkubectl apply -f docs/snippets/tutorials/coredns/etcd.yaml\n# wait until it's ready\nkubectl rollout status statefulset etcd\n\n❯❯ partitioned roll out complete: 1 new pods have been updated...\n```\n\nTest etcd connectivity:\n\n```sh\nkubectl exec -it etcd-0 -- etcdctl member list -wtable\n\n+------------------+---------+--------+------------------------+-------------------------+------------+\n|        ID        | STATUS  |  NAME  |       PEER ADDRS       |      CLIENT ADDRS       | IS LEARNER |\n+------------------+---------+--------+------------------------+-------------------------+------------+\n| 3b3ae05f90cfc535 | started | etcd-0 | http://10.244.1.3:2380 | http://etcd-0.etcd:2379 |      false |\n+------------------+---------+--------+------------------------+-------------------------+------------+\n```\n\nTest etcd record management:\n\n```sh\nkubectl -n default exec -it etcd-0 -- etcdctl put /skydns/org/example/myservice '{\"host\":\"10.0.0.10\"}'\n❯❯ OK\n\nkubectl -n default exec -it etcd-0 -- etcdctl get /skydns --prefix\n❯❯ /skydns/org/example/myservice\n❯❯ {\"host\":\"10.0.0.10\"}\n\nkubectl -n default exec -it etcd-0 -- etcdctl del /skydns/org/example/myservice\n❯❯ 1\n```\n\nTo access etcd from host:\n\n```sh\netcdctl --endpoints=http://127.0.0.1:32379 member list\n❯❯ 3b3ae05f90cfc535, started, etcd-0, http://10.244.1.3:2380, http://etcd-0.etcd:2379, false\n```\n\n### 3. Deploy CoreDNS using Helm\n\n- [CoreDNS](https://github.com/coredns/coredns)\n- [CoreDNS helm](https://github.com/coredns/helm)\n\n```sh\nhelm repo add coredns https://coredns.github.io/helm\nhelm repo update\n\nhelm upgrade --install coredns coredns/coredns \\\n  -f docs/snippets/tutorials/coredns/values-coredns.yaml \\\n  -n default\n\n❯❯ Release \"coredns\" does not exist. Installing it now.\n```\n\nValidate it's running\n\n```sh\nkubectl get pods -l app.kubernetes.io/name=coredns\n```\n\nCheck the logs for errors\n\n```sh\nkubectl logs deploy/coredns -n default -c coredns --tail=50\nkubectl logs deploy/coredns -n default -c resolv-check --tail=50\n```\n\nTest DNS Resolution\n\n```sh\nkubectl run -it --rm dnsutils --image=infoblox/dnstools\n\n❯❯ curl -v http://etcd.default.svc.cluster.local:2379/version\n❯❯ dig @coredns.default.svc.cluster.local kubernetes.default.svc.cluster.local\n❯❯ dig @coredns.default.svc.cluster.local etcd.default.svc.cluster.local\n```\n\n### 3. Configure ExternalDNS\n\nDeploy with helm and minimal configuration.\n\nAdd the `external-dns` helm repository and check available versions\n\n```sh\nhelm repo add external-dns https://kubernetes-sigs.github.io/external-dns/\nhelm repo update\nhelm search repo external-dns --versions\n```\n\nInstall with required configuration\n\n```sh\nhelm upgrade --install external-dns external-dns/external-dns \\\n  -f docs/snippets/tutorials/coredns/values-extdns-coredns.yaml \\\n  -n default\n\n❯❯ Release \"external-dns\" does not exist. Installing it now.\n```\n\nValidate pod status and view logs\n\n```sh\nkubectl get pods -l app.kubernetes.io/name=external-dns\n\nkubectl logs deploy/external-dns\n```\n\nOr run it on the host from sources\n\n```sh\nexport ETCD_URLS=\"http://127.0.0.1:32379\" # port mapping configured on kind cluster\n\ngo run main.go \\\n    --provider=coredns \\\n    --source=service \\\n    --log-level=debug\n```\n\n### 3. Configure Test Services\n\nApply manifest\n\n```sh\nkubectl apply -f docs/snippets/tutorials/coredns/fixtures.yaml\n\nkubectl get svc -l svc=test-svc\n\n❯❯ NAME           TYPE           CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE\n❯❯ a-g1-record    LoadBalancer   10.96.233.133   <pending>     80:31188/TCP   3m38s\n❯❯ aa-g1-record   LoadBalancer   10.96.93.4      <pending>     80:31710/TCP   3m38s\n```\n\nPatch services, to manually assign an Ingress IPs. It just makes the Service appear like a real LoadBalancer for tools/tests.\n\n```sh\nkubectl patch svc a-g1-record --type=merge \\\n -p '{\"status\":{\"loadBalancer\":{\"ingress\":[{\"ip\":\"172.18.0.2\"}]}}}' \\\n  --subresource=status\n❯❯ service/a-g1-record patched\n\nkubectl patch svc aa-g1-record --type=merge \\\n -p '{\"status\":{\"loadBalancer\":{\"ingress\":[{\"ip\":\"2001:db8::1\"}]}}}' \\\n  --subresource=status\n❯❯ service/aa-g1-record patched\n\nkubectl get svc -l svc=test-svc\n\n❯❯ NAME           TYPE           CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE\n❯❯ a-g1-record    LoadBalancer   10.96.233.133   172.18.0.2    80:31188/TCP   7m13s\n❯❯ aa-g1-record   LoadBalancer   10.96.93.4      2001:db8::1   80:31710/TCP   7m13s\n```\n\n### 4. Verify that records are written to etcd\n\nCheck `etcd` content. Where you should see keys similar to:\n\n```sh\nkubectl exec -it etcd-0 -- etcdctl get /skydns/org/example --prefix --keys-only\n\n❯❯ /skydns/org/example/a-a/1acbad7e\n❯❯ /skydns/org/example/a/048b0377\n❯❯ /skydns/org/example/aa/2b981607\n❯❯ /skydns/org/example/aaaa-aa/1228708f\n```\n\n### 5. Test DNS resolution via CoreDNS\n\nLaunch a debug pod:\n\n```sh\nkubectl run --rm -it dnsutils --image=infoblox/dnstools --restart=Never\n```\n\nRun with expected output\n\n```sh\ndig +short @coredns.default.svc.cluster.local a.example.org\n❯❯ 172.18.0.2\n\ndig +short @coredns.default.svc.cluster.local aa.example.org AAAA\n❯❯ 2001:db8::1\n```\n\n### 6. Cleanup\n\n```sh\nkind delete cluster --name coredns-etcd\n```\n"
  },
  {
    "path": "docs/tutorials/coredns.md",
    "content": "# CoreDNS\n\n- [Documentation](https://coredns.io/)\n\n## Multi cluster support options\n\nThe CoreDNS provider allows records from different CoreDNS providers to be separated in a single etcd\nby activating the setting `--coredns-strictly-owned` flag and set `txt-owner-id`. It will prevent any\noverride (update/create/delete) of records by a different owner and prevent loading of records by a\ndifferent owner.\n\nFlow:\n\n```mermaid\ngraph TD\n  subgraph ETCD\n    store--> E(services from Cluster A)\n    store--> F(services from Cluster B)\n    store--> G(services from someone else)\n  end\n  subgraph Cluster A\n  A(external-dns with stictly-owned)\n  end\n  A --> E\n  subgraph Cluster B\n  B(external-dns with stictly-owned)\n  end\n  B --> F\n  store --> CoreDNS\n```\n\nThis features works directly without any change to CoreDNS. CoreDNS will ignore this field inside the etcd record.\n\n### Other entries inside etcd\n\nService entries in etcd without an `owner` field will be filtered out by the provider if `strictly-owned` is activated.\nWarning: If you activate `strictly-owned` afterwards, these entries will be ignored as the `owner` field is empty.\n\n### Ways to migrate to a multi cluster setup\n\nWays:\n\n1. Add the correct owner to all services inside etcd by adding the field `owner` to the JSON.\n2. Remove all services and allow them to be required again after restarting the provider. (Possible downtime.)\n\n## Specific service annotation options\n\n### Groups\n\nGroups can be used to group set of services together. The main use of this is to limit recursion,\ni.e. don't return all records, but only a subset. Let's say we have a configuration like this:\n\n```yaml\n[[% include 'tutorials/coredns/coredns-groups.yaml' %]]\n```\n\nAnd we want domain.local to return (127.0.0.1 and 127.0.0.2) and subdom.domain.local to return (127.0.0.3 and 127.0.0.4).\nFor this the two domains, need to be in different groups. What those groups are does not matter,\nas long as a and b belong to the same group which is different from the group c and d belong to.\nIf a service is found without a group it is always included.\n"
  },
  {
    "path": "docs/tutorials/crd.md",
    "content": "# Using CRD Source for DNS Records\n\nThis tutorial describes how to use the CRD source with ExternalDNS to manage DNS records. The CRD source allows you to define your desired DNS records declaratively using `DNSEndpoint` custom resources.\n\n## Default Targets and CRD Targets\n\nExternalDNS has a `--default-targets` flag that can be used to specify a default set of targets for all created DNS records. The behavior of how these default targets interact with targets specified in a `DNSEndpoint` CRD has been refined.\n\n### New Behavior (default)\n\nBy default, ExternalDNS now has the following behavior:\n\n- If a `DNSEndpoint` resource has targets specified in its `spec.endpoints[].targets` field, these targets will be used for the DNS record, **overriding** any targets specified via the `--default-targets` flag.\n- If a `DNSEndpoint` resource has an **empty** `targets` field, the targets from the `--default-targets` flag will be used. This allows for creating records that point to default load balancers or IPs without explicitly listing them in every `DNSEndpoint` resource.\n\n### Legacy Behavior (`--force-default-targets`)\n\nTo maintain backward compatibility and support certain migration scenarios, the `--force-default-targets` flag is available.\n\n- When `--force-default-targets` is used, ExternalDNS will **always** use the targets from `--default-targets`, regardless of whether the `DNSEndpoint` resource has targets specified or not.\nThis flag allows for a smooth migration path to the new behavior. It allow keeping old CRD resources, allows to start removing targets from one by one resource and then remove the flag.\n\n## Examples\n\nLet's look at how this works in practice. Assume ExternalDNS is running with `--default-targets=1.2.3.4`.\n\n### DNSEndpoint with Targets\n\nHere is a `DNSEndpoint` with a target specified.\n\n```yaml\n---\napiVersion: externaldns.k8s.io/v1alpha1\nkind: DNSEndpoint\nmetadata:\n  name: targets\n  namespace: default\nspec:\n  endpoints:\n  - dnsName: smoke-t.example.com\n    recordTTL: 300\n    recordType: CNAME\n    targets:\n      - placeholder\n```\n\n- **Without `--force-default-targets` (New Behavior):** A CNAME record for `smoke-t.example.com` will be created pointing to `placeholder`.\n- **With `--force-default-targets` (Legacy Behavior):** A CNAME record for `smoke-t.example.com` will be created pointing to `1.2.3.4`. The `placeholder` target will be ignored.\n\n### DNSEndpoint with Empty/No Targets\n\nHere is a `DNSEndpoint` without any targets specified.\n\n```yaml\n---\napiVersion: externaldns.k8s.io/v1alpha1\nkind: DNSEndpoint\nmetadata:\n  name: no-targets\n  namespace: default\nspec:\n  endpoints:\n  - dnsName: smoke-nt.example.com\n    recordTTL: 300\n    recordType: CNAME\n```\n\n- **Without `--force-default-targets` (New Behavior):** A CNAME record for `smoke-nt.example.com` will be created pointing to `1.2.3.4`.\n- **With `--force-default-targets` (Legacy Behavior):** A CNAME record for `smoke-nt.example.com` will be created pointing to `1.2.3.4`.\n\n`--force-default-targets` allows migration path to clean CRD resources.\n\n### DNSEndpoint with an SRV record\n\nHere's an example of a `DNSEndpoint` with an SRV record:\n\n```yaml\n---\napiVersion: externaldns.k8s.io/v1alpha1\nkind: DNSEndpoint\nmetadata:\n  name: test-srv\n  namespace: default\nspec:\n  endpoints:\n  - dnsName: _sip._udp.test.example.com\n    recordTTL: 180\n    recordType: SRV\n    targets:\n    - 1 50 5060 sip1-n1.test.example.com\n    - 1 50 5060 sip1-n2.test.example.com\n```\n\n### DNSEndpoint with an NAPTR record\n\nHere's an example of a `DNSEndpoint` with an NAPTR record:\n\n```yaml\n---\napiVersion: externaldns.k8s.io/v1alpha1\nkind: DNSEndpoint\nmetadata:\n  name: test-naptr\n  namespace: default\nspec:\n  endpoints:\n  - dnsName: test.example.com\n    recordTTL: 180\n    recordType: NAPTR\n    targets:\n    - 50 50 \"S\" \"SIPS+D2T\" \"\" _sips._tcp.test.example.com.\n    - 100 50 \"S\" \"SIP+D2U\" \"\" _sip._udp.test.example.com.\n```\n"
  },
  {
    "path": "docs/tutorials/dnsimple.md",
    "content": "# DNSimple\n\nThis tutorial describes how to setup ExternalDNS for usage with DNSimple.\n\nMake sure to use **>=0.4.6** version of ExternalDNS for this tutorial.\n\n## Create a DNSimple API Access Token\n\nA DNSimple API access token can be acquired by following the [provided documentation from DNSimple](https://support.dnsimple.com/articles/api-access-token/)\n\nThe environment variable `DNSIMPLE_OAUTH` must be set to the generated API token to run ExternalDNS with DNSimple.\n\nWhen the generated DNSimple API access token is a _User token_, as opposed to an _Account token_, the following environment variables must also be set:\n\n- `DNSIMPLE_ACCOUNT_ID`: Set this to the account ID which the domains to be managed by ExternalDNS belong to (eg. `1001234`).\n- `DNSIMPLE_ZONES`: Set this to a comma separated list of DNS zones to be managed by ExternalDNS (eg. `mydomain.com,example.com`).\n\n## Deploy ExternalDNS\n\nConnect your `kubectl` client to the cluster you want to test ExternalDNS with.\nThen apply one of the following manifests file to deploy ExternalDNS.\n\n### Manifest (for clusters without RBAC enabled)\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=service\n        - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone you create in DNSimple.\n        - --provider=dnsimple\n        - --registry=txt\n        env:\n        - name: DNSIMPLE_OAUTH\n          value: \"YOUR_DNSIMPLE_API_KEY\"\n        - name: DNSIMPLE_ACCOUNT_ID\n          value: \"SET THIS IF USING A DNSIMPLE USER ACCESS TOKEN\"\n        - name: DNSIMPLE_ZONES\n          value: \"SET THIS IF USING A DNSIMPLE USER ACCESS TOKEN\"\n```\n\n### Manifest (for clusters with RBAC enabled)\n\n```yaml\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: external-dns\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: external-dns\nrules:\n- apiGroups: [\"\"]\n  resources: [\"services\",\"pods\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"discovery.k8s.io\"]\n  resources: [\"endpointslices\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"extensions\",\"networking.k8s.io\"]\n  resources: [\"ingresses\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"\"]\n  resources: [\"nodes\"]\n  verbs: [\"list\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: external-dns-viewer\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: external-dns\nsubjects:\n- kind: ServiceAccount\n  name: external-dns\n  namespace: default\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      serviceAccountName: external-dns\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=service\n        - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone you create in DNSimple.\n        - --provider=dnsimple\n        - --registry=txt\n        env:\n        - name: DNSIMPLE_OAUTH\n          value: \"YOUR_DNSIMPLE_API_KEY\"\n        - name: DNSIMPLE_ACCOUNT_ID\n          value: \"SET THIS IF USING A DNSIMPLE USER ACCESS TOKEN\"\n        - name: DNSIMPLE_ZONES\n          value: \"SET THIS IF USING A DNSIMPLE USER ACCESS TOKEN\"\n```\n\n## Deploying an Nginx Service\n\nCreate a service file called 'nginx.yaml' with the following contents:\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: nginx\nspec:\n  selector:\n    matchLabels:\n      app: nginx\n  template:\n    metadata:\n      labels:\n        app: nginx\n    spec:\n      containers:\n      - image: nginx\n        name: nginx\n        ports:\n        - containerPort: 80\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: nginx\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: validate-external-dns.example.com\nspec:\n  selector:\n    app: nginx\n  type: LoadBalancer\n  ports:\n    - protocol: TCP\n      port: 80\n      targetPort: 80\n```\n\nNote the annotation on the service; use the same hostname as the DNSimple DNS zone created above. The annotation may also be a subdomain\nof the DNS zone (e.g. 'www.example.com').\n\nExternalDNS uses this annotation to determine what services should be registered with DNS.  Removing the annotation will cause ExternalDNS to remove the corresponding DNS records.\n\nCreate the deployment and service:\n\n```sh\nkubectl create -f nginx.yaml\n```\n\nDepending where you run your service it can take a little while for your cloud provider to create an external IP for the service. Check the status by running\n`kubectl get services nginx`.  If the `EXTERNAL-IP` field shows an address, the service is ready to be accessed externally.\n\nOnce the service has an external IP assigned, ExternalDNS will notice the new service IP address and synchronize\nthe DNSimple DNS records.\n\n## Verifying DNSimple DNS records\n\n### Getting your DNSimple Account ID\n\nIf you do not know your DNSimple account ID it can be acquired using the [whoami](https://developer.dnsimple.com/v2/identity/#whoami) endpoint from the DNSimple Identity API\n\n```sh\ncurl -H \"Authorization: Bearer $DNSIMPLE_ACCOUNT_TOKEN\" \\\n    -H 'Accept: application/json' \\\n    https://api.dnsimple.com/v2/whoami\n{\n  \"data\": {\n    \"user\": null,\n    \"account\": {\n      \"id\": 1,\n      \"email\": \"example-account@example.com\",\n      \"plan_identifier\": \"dnsimple-professional\",\n      \"created_at\": \"2015-09-18T23:04:37Z\",\n      \"updated_at\": \"2016-06-09T20:03:39Z\"\n    }\n  }\n}\n```\n\n### Looking at the DNSimple Dashboard\n\nYou can view your DNSimple Record Editor at https://dnsimple.com/a/YOUR_ACCOUNT_ID/domains/example.com/records. Ensure you substitute the value `YOUR_ACCOUNT_ID` with the ID of your DNSimple account and `example.com` with the correct domain that you used during validation.\n\n### Using the DNSimple Zone Records API\n\nThis approach allows for you to use the DNSimple [List records for a zone](https://developer.dnsimple.com/v2/zones/records/#listZoneRecords) endpoint to verify the creation of the A and TXT record.\nEnsure you substitute the value `YOUR_ACCOUNT_ID` with the ID of your DNSimple account and `example.com` with the correct domain that you used during validation.\n\n```sh\ncurl -H \"Authorization: Bearer $DNSIMPLE_ACCOUNT_TOKEN\" \\\n    -H 'Accept: application/json' \\\n    'https://api.dnsimple.com/v2/YOUR_ACCOUNT_ID/zones/example.com/records&name=validate-external-dns'\n```\n\n## Clean up\n\nNow that we have verified that ExternalDNS will automatically manage DNSimple DNS records, we can delete the tutorial's example:\n\n```sh\nkubectl delete -f nginx.yaml\nkubectl delete -f externaldns.yaml\n```\n\n### Deleting Created Records\n\nThe created records can be deleted using the record IDs from the verification step and the [Delete a zone record](https://developer.dnsimple.com/v2/zones/records/#deleteZoneRecord) endpoint.\n"
  },
  {
    "path": "docs/tutorials/exoscale.md",
    "content": "# Exoscale\n\n## Prerequisites\n\nExoscale provider support was added via [this PR](https://github.com/kubernetes-sigs/external-dns/pull/625), thus you need to use external-dns v0.5.5.\n\nThe Exoscale provider expects that your Exoscale zones, you wish to add records to, already exists\nand are configured correctly. It does not add, remove or configure new zones in anyway.\n\nTo do this please refer to the [Exoscale DNS documentation](https://community.exoscale.com/documentation/dns/).\n\nAdditionally you will have to provide the Exoscale...:\n\n* API Key\n* API Secret\n* Elastic IP address, to access the workers\n\n## Deployment\n\nDeploying external DNS for Exoscale is actually nearly identical to deploying\nit for other providers. This is what a sample `deployment.yaml` looks like:\n\n```yaml\n[[% include 'exoscale/extdns.yaml' %]]\n```\n\nOptional arguments `--exoscale-apizone` and `--exoscale-apienv` define [Exoscale API Zone](https://community.exoscale.com/documentation/platform/exoscale-datacenter-zones/)\n(default `ch-gva-2`) and Exoscale API environment (default `api`, can be used to target non-production API server) respectively.\n\n## RBAC\n\nIf your cluster is RBAC enabled, you also need to setup the following, before you can run external-dns:\n\n```yaml\n[[% include 'exoscale/rbac.yaml' %]]\n```\n\n## Testing and Verification\n\n**Important!**: Remember to change `example.com` with your own domain throughout the following text.\n\nSpin up a simple nginx HTTP server with the following spec (`kubectl apply -f`):\n\n```yaml\n[[% include 'exoscale/how-to-test.yaml' %]]\n```\n\n**Important!**: Don't run dig, nslookup or similar immediately (until you've\nconfirmed the record exists). You'll get hit by [negative DNS caching](https://tools.ietf.org/html/rfc2308), which is hard to flush.\n\nWait about 30s-1m (interval for external-dns to kick in), then check Exoscales [portal](https://portal.exoscale.com/dns/example.com)... via-ingress.example.com should appear as a A and TXT record with your Elastic-IP-address.\n"
  },
  {
    "path": "docs/tutorials/externalname.md",
    "content": "# ExternalName Services\n\nThis tutorial describes how to setup ExternalDNS for usage in conjunction with an ExternalName service.\n\n## Use cases\n\nThe main use cases that inspired this feature is the necessity for having a subdomain pointing to an external domain. In this scenario, it makes sense for the subdomain to have a CNAME record pointing to the external domain.\n\n## Setup\n\n### External DNS\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --log-level=debug\n        - --source=service\n        - --source=ingress\n        - --namespace=dev\n        - --domain-filter=example.org.\n        - --provider=aws\n        - --registry=txt\n        - --txt-owner-id=dev.example.org\n```\n\n### ExternalName Service\n\n```yaml\nkind: Service\napiVersion: v1\nmetadata:\n  name: aws-service\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: tenant1.example.org,tenant2.example.org\nspec:\n  type: ExternalName\n  externalName: aws.example.org\n```\n\nThis will create 2 CNAME records pointing to `aws.example.org`:\n\n```sh\ntenant1.example.org\ntenant2.example.org\n```\n\n### ExternalName Service with an IP address\n\nIf `externalName` is an IP address, External DNS will create A records instead of CNAME.\n\n```yaml\nkind: Service\napiVersion: v1\nmetadata:\n  name: aws-service\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: tenant1.example.org,tenant2.example.org\nspec:\n  type: ExternalName\n  externalName: 111.111.111.111\n```\n\nThis will create 2 A records pointing to `111.111.111.111`:\n\n```sh\ntenant1.example.org\ntenant2.example.org\n```\n"
  },
  {
    "path": "docs/tutorials/gandi.md",
    "content": "# Gandi\n\nThis tutorial describes how to setup ExternalDNS for usage within a Kubernetes cluster using Gandi.\n\nMake sure to use **>=0.7.7** version of ExternalDNS for this tutorial.\n\n## Creating a Gandi DNS zone (domain)\n\nCreate a new DNS zone where you want to create your records in. Let's use `example.com` as an example here. Make sure the zone uses\n\n## Creating Gandi Personal Access Token (PAT)\n\nGenerate a Personal Access Token on [your account](https://admin.gandi.net) (click on \"User Settings\") with `Manage domain name technical configurations` permission.\n\nThe environment variable `GANDI_PAT` will be needed to run ExternalDNS with Gandi.\n\nYou can also set `GANDI_KEY` if you have an old API key.\n\n## Deploy ExternalDNS\n\nConnect your `kubectl` client to the cluster you want to test ExternalDNS with.\nThen apply one of the following manifests file to deploy ExternalDNS.\n\n### Manifest (for clusters without RBAC enabled)\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  replicas: 1\n  selector:\n    matchLabels:\n      app: external-dns\n  strategy:\n    type: Recreate\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=service # ingress is also possible\n        - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above.\n        - --provider=gandi\n        env:\n        - name: GANDI_PAT\n          value: \"YOUR_GANDI_PAT\"\n```\n\n### Manifest (for clusters with RBAC enabled)\n\n```yaml\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: external-dns\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: external-dns\nrules:\n- apiGroups: [\"\"]\n  resources: [\"services\",\"pods\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"discovery.k8s.io\"]\n  resources: [\"endpointslices\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"extensions\",\"networking.k8s.io\"]\n  resources: [\"ingresses\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"\"]\n  resources: [\"nodes\"]\n  verbs: [\"list\",\"watch\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: external-dns-viewer\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: external-dns\nsubjects:\n- kind: ServiceAccount\n  name: external-dns\n  namespace: default\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  replicas: 1\n  selector:\n    matchLabels:\n      app: external-dns\n  strategy:\n    type: Recreate\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      serviceAccountName: external-dns\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=service # ingress is also possible\n        - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above.\n        - --provider=gandi\n        env:\n        - name: GANDI_PAT\n          value: \"YOUR_GANDI_PAT\"\n```\n\n## Deploying an Nginx Service\n\nCreate a service file called 'nginx.yaml' with the following contents:\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: nginx\nspec:\n  replicas: 1\n  selector:\n    matchLabels:\n      app: nginx\n  template:\n    metadata:\n      labels:\n        app: nginx\n    spec:\n      containers:\n      - image: nginx\n        name: nginx\n        ports:\n        - containerPort: 80\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: nginx\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: my-app.example.com\nspec:\n  selector:\n    app: nginx\n  type: LoadBalancer\n  ports:\n    - protocol: TCP\n      port: 80\n      targetPort: 80\n```\n\nNote the annotation on the service; use the same hostname as the Gandi Domain. Make sure that your Domain is configured to use Live-DNS.\n\nExternalDNS uses this annotation to determine what services should be registered with DNS. Removing the annotation will cause ExternalDNS to remove the corresponding DNS records.\n\nCreate the deployment and service:\n\n```console\nkubectl create -f nginx.yaml\n```\n\nDepending where you run your service it can take a little while for your cloud provider to create an external IP for the service.\n\nOnce the service has an external IP assigned, ExternalDNS will notice the new service IP address and synchronize the Gandi DNS records.\n\n## Verifying Gandi DNS records\n\nCheck your [Gandi Dashboard](https://admin.gandi.net/domain) to view the records for your Gandi DNS zone.\n\nClick on the zone for the one created above if a different domain was used.\n\nThis should show the external IP address of the service as the A record for your domain.\n\n## Cleanup\n\nNow that we have verified that ExternalDNS will automatically manage Gandi DNS records, we can delete the tutorial's example:\n\n```sh\nkubectl delete service -f nginx.yaml\nkubectl delete service -f externaldns.yaml\n```\n\n## Additional options\n\nIf you're using organizations to separate your domains, you can pass the organization's ID in an environment variable called `GANDI_SHARING_ID` to get access to it.\n"
  },
  {
    "path": "docs/tutorials/gke-nginx.md",
    "content": "# GKE with nginx-ingress-controller\n\nThis tutorial describes how to setup ExternalDNS for usage within a GKE cluster that doesn't make use of Google's [default ingress controller](https://github.com/kubernetes/ingress-gce) but rather uses [nginx-ingress-controller](https://github.com/kubernetes/ingress-nginx) for that task.\n\n## Set up your environment\n\nSetup your environment to work with Google Cloud Platform. Fill in your values as needed, e.g. target project.\n\n```console\ngcloud config set project \"zalando-external-dns-test\"\ngcloud config set compute/region \"europe-west1\"\ngcloud config set compute/zone \"europe-west1-d\"\n```\n\n## GKE Node Scopes\n\nThe following instructions use instance scopes to provide ExternalDNS with the\npermissions it needs to manage DNS records. Note that since these permissions\nare associated with the instance, all pods in the cluster will also have these\npermissions. As such, this approach is not suitable for anything but testing\nenvironments.\n\nCreate a GKE cluster without using the default ingress controller.\n\n```console\n$ gcloud container clusters create \"external-dns\" \\\n    --num-nodes 1 \\\n    --scopes \"https://www.googleapis.com/auth/ndev.clouddns.readwrite\"\n```\n\nCreate a DNS zone which will contain the managed DNS records.\n\n```console\n$ gcloud dns managed-zones create \"external-dns-test-gcp-zalan-do\" \\\n    --dns-name \"external-dns-test.gcp.zalan.do.\" \\\n    --description \"Automatically managed zone by ExternalDNS\"\n```\n\nMake a note of the nameservers that were assigned to your new zone.\n\n```console\n$ gcloud dns record-sets list \\\n    --zone \"external-dns-test-gcp-zalan-do\" \\\n    --name \"external-dns-test.gcp.zalan.do.\" \\\n    --type NS\nNAME                             TYPE  TTL    DATA\nexternal-dns-test.gcp.zalan.do.  NS    21600  ns-cloud-e1.googledomains.com.,ns-cloud-e2.googledomains.com.,ns-cloud-e3.googledomains.com.,ns-cloud-e4.googledomains.com.\n```\n\nIn this case it's `ns-cloud-{e1-e4}.googledomains.com.` but your's could slightly differ, e.g. `{a1-a4}`, `{b1-b4}` etc.\n\nTell the parent zone where to find the DNS records for this zone by adding the corresponding NS records there. Assuming the parent zone is \"gcp-zalan-do\" and the domain is \"gcp.zalan.do\" and that it's also hosted at Google we would do the following.\n\n```console\n$ gcloud dns record-sets transaction start --zone \"gcp-zalan-do\"\n$ gcloud dns record-sets transaction add ns-cloud-e{1..4}.googledomains.com. \\\n    --name \"external-dns-test.gcp.zalan.do.\" --ttl 300 --type NS --zone \"gcp-zalan-do\"\n$ gcloud dns record-sets transaction execute --zone \"gcp-zalan-do\"\n```\n\nConnect your `kubectl` client to the cluster you just created and bind your GCP\nuser to the cluster admin role in Kubernetes.\n\n```console\n$ gcloud container clusters get-credentials \"external-dns\"\n$ kubectl create clusterrolebinding cluster-admin-me \\\n    --clusterrole=cluster-admin --user=\"$(gcloud config get-value account)\"\n```\n\n### Deploy the nginx ingress controller\n\nFirst, you need to deploy the nginx-based ingress controller. It can be deployed in at least two modes: Leveraging a Layer 4 load balancer in front of the nginx proxies or directly targeting pods with hostPorts on your worker nodes. ExternalDNS doesn't really care and supports both modes.\n\n#### Default Backend\n\nThe nginx controller uses a default backend that it serves when no Ingress rule matches. This is a separate Service that can be picked by you. We'll use the default backend that's used by other ingress controllers for that matter. Apply the following manifests to your cluster to deploy the default backend.\n\n```yaml\napiVersion: v1\nkind: Service\nmetadata:\n  name: default-http-backend\nspec:\n  ports:\n  - port: 80\n    targetPort: 8080\n  selector:\n    app: default-http-backend\n\n---\n\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: default-http-backend\nspec:\n  selector:\n    matchLabels:\n      app: default-http-backend\n  template:\n    metadata:\n      labels:\n        app: default-http-backend\n    spec:\n      containers:\n      - name: default-http-backend\n        image: gcr.io/google_containers/defaultbackend:1.3\n```\n\n#### Without a separate TCP load balancer\n\nBy default, the controller will update your Ingress objects with the public IPs of the nodes running your nginx controller instances.\nYou should run multiple instances in case of pod or node failure. The controller will do leader election and will put multiple IPs as targets in your Ingress objects in that case.\nIt could also make sense to run it as a DaemonSet. However, we'll just run a single replica. You have to open the respective ports on all of your worker nodes to allow nginx to receive traffic.\n\n```console\ngcloud compute firewall-rules create \"allow-http\" --allow tcp:80 --source-ranges \"0.0.0.0/0\" --target-tags \"gke-external-dns-9488ba14-node\"\ngcloud compute firewall-rules create \"allow-https\" --allow tcp:443 --source-ranges \"0.0.0.0/0\" --target-tags \"gke-external-dns-9488ba14-node\"\n```\n\nChange `--target-tags` to the corresponding tags of your nodes. You can find them by describing your instances or by looking at the default firewall rules created by GKE for your cluster.\n\nApply the following manifests to your cluster to deploy the nginx-based ingress controller. Note, how it receives a reference to the default backend's Service and that it listens on hostPorts. (You may have to use `hostNetwork: true` as well.)\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: nginx-ingress-controller\nspec:\n  selector:\n    matchLabels:\n      app: nginx-ingress-controller\n  template:\n    metadata:\n      labels:\n        app: nginx-ingress-controller\n    spec:\n      containers:\n      - name: nginx-ingress-controller\n        image: gcr.io/google_containers/nginx-ingress-controller:0.9.0-beta.3\n        args:\n        - /nginx-ingress-controller\n        - --default-backend-service=default/default-http-backend\n        env:\n          - name: POD_NAME\n            valueFrom:\n              fieldRef:\n                fieldPath: metadata.name\n          - name: POD_NAMESPACE\n            valueFrom:\n              fieldRef:\n                fieldPath: metadata.namespace\n        ports:\n        - containerPort: 80\n          hostPort: 80\n        - containerPort: 443\n          hostPort: 443\n```\n\n#### With a separate TCP load balancer\n\nHowever, you can also have the ingress controller proxied by a Kubernetes Service.\nThis will instruct the controller to populate this Service's external IP as the external IP of the Ingress.\nThis exposes the nginx proxies via a Layer 4 load balancer (`type=LoadBalancer`) which is more reliable than the other method. With that approach, you can run as many nginx proxy instances on your cluster as you like or have them autoscaled.\nThis is the preferred way of running the nginx controller.\n\nApply the following manifests to your cluster. Note, how the controller is receiving an additional flag telling it which Service it should treat as its public endpoint and how it doesn't need hostPorts anymore.\n\nApply the following manifests to run the controller in this mode.\n\n```yaml\napiVersion: v1\nkind: Service\nmetadata:\n  name: nginx-ingress-controller\nspec:\n  type: LoadBalancer\n  ports:\n  - name: http\n    port: 80\n    targetPort: 80\n  - name: https\n    port: 443\n    targetPort: 443\n  selector:\n    app: nginx-ingress-controller\n\n---\n\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: nginx-ingress-controller\nspec:\n  selector:\n    matchLabels:\n      app: nginx-ingress-controller\n  template:\n    metadata:\n      labels:\n        app: nginx-ingress-controller\n    spec:\n      containers:\n      - name: nginx-ingress-controller\n        image: gcr.io/google_containers/nginx-ingress-controller:0.9.0-beta.3\n        args:\n        - /nginx-ingress-controller\n        - --default-backend-service=default/default-http-backend\n        - --publish-service=default/nginx-ingress-controller\n        env:\n          - name: POD_NAME\n            valueFrom:\n              fieldRef:\n                fieldPath: metadata.name\n          - name: POD_NAMESPACE\n            valueFrom:\n              fieldRef:\n                fieldPath: metadata.namespace\n        ports:\n        - containerPort: 80\n        - containerPort: 443\n```\n\n### Deploy ExternalDNS\n\nApply the following manifest file to deploy ExternalDNS.\n\n```yaml\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: external-dns\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: external-dns\nrules:\n- apiGroups: [\"\"]\n  resources: [\"services\",\"pods\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"discovery.k8s.io\"]\n  resources: [\"endpointslices\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"extensions\",\"networking.k8s.io\"]\n  resources: [\"ingresses\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"\"]\n  resources: [\"nodes\"]\n  verbs: [\"list\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: external-dns-viewer\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: external-dns\nsubjects:\n- kind: ServiceAccount\n  name: external-dns\n  namespace: default\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      serviceAccountName: external-dns\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=ingress\n        - --domain-filter=external-dns-test.gcp.zalan.do\n        - --provider=google\n        - --google-project=zalando-external-dns-test\n        - --registry=txt\n        - --txt-owner-id=my-identifier\n```\n\nUse `--dry-run` if you want to be extra careful on the first run. Note, that you will not see any records created when you are running in dry-run mode. You can, however, inspect the logs and watch what would have been done.\n\n### Deploy a sample application\n\nCreate the following sample application to test that ExternalDNS works.\n\n```yaml\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  name: nginx\nspec:\n  ingressClassName: nginx\n  rules:\n  - host: via-ingress.external-dns-test.gcp.zalan.do\n    http:\n      paths:\n      - path: /\n        backend:\n          service:\n            name: nginx\n            port:\n              number: 80\n        pathType: Prefix\n\n---\n\napiVersion: v1\nkind: Service\nmetadata:\n  name: nginx\nspec:\n  ports:\n  - port: 80\n    targetPort: 80\n  selector:\n    app: nginx\n\n---\n\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: nginx\nspec:\n  selector:\n    matchLabels:\n      app: nginx\n  template:\n    metadata:\n      labels:\n        app: nginx\n    spec:\n      containers:\n      - image: nginx\n        name: nginx\n        ports:\n        - containerPort: 80\n```\n\nAfter roughly two minutes check that a corresponding DNS record for your Ingress was created.\n\n```console\n$ gcloud dns record-sets list \\\n    --zone \"external-dns-test-gcp-zalan-do\" \\\n    --name \"via-ingress.external-dns-test.gcp.zalan.do.\" \\\n    --type A\nNAME                                         TYPE  TTL  DATA\nvia-ingress.external-dns-test.gcp.zalan.do.  A     300  35.187.1.246\n```\n\nLet's check that we can resolve this DNS name as well.\n\n```console\ndig +short @ns-cloud-e1.googledomains.com. via-ingress.external-dns-test.gcp.zalan.do.\n35.187.1.246\n```\n\nTry with `curl` as well.\n\n```console\n$ curl via-ingress.external-dns-test.gcp.zalan.do\n<!DOCTYPE html>\n<html>\n<head>\n<title>Welcome to nginx!</title>\n...\n</head>\n<body>\n...\n</body>\n</html>\n```\n\n### Clean up\n\nMake sure to delete all Service and Ingress objects before terminating the cluster so all load balancers and DNS entries get cleaned up correctly.\n\n```console\nkubectl delete service nginx-ingress-controller\nkubectl delete ingress nginx\n```\n\nGive ExternalDNS some time to clean up the DNS records for you. Then delete the managed zone and cluster.\n\n```console\ngcloud dns managed-zones delete \"external-dns-test-gcp-zalan-do\"\ngcloud container clusters delete \"external-dns\"\n```\n\nAlso delete the NS records for your removed zone from the parent zone.\n\n```console\n$ gcloud dns record-sets transaction start --zone \"gcp-zalan-do\"\n$ gcloud dns record-sets transaction remove ns-cloud-e{1..4}.googledomains.com. \\\n    --name \"external-dns-test.gcp.zalan.do.\" --ttl 300 --type NS --zone \"gcp-zalan-do\"\n$ gcloud dns record-sets transaction execute --zone \"gcp-zalan-do\"\n```\n\n## GKE with Workload Identity\n\nThe following instructions use [GKE workload\nidentity](https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity)\nto provide ExternalDNS with the permissions it needs to manage DNS records.\nWorkload identity is the Google-recommended way to provide GKE workloads access\nto GCP APIs.\n\nCreate a GKE cluster with workload identity enabled and without the\nHttpLoadBalancing add-on.\n\n```console\n$ gcloud container clusters create external-dns \\\n    --workload-metadata-from-node=GKE_METADATA_SERVER \\\n    --identity-namespace=zalando-external-dns-test.svc.id.goog \\\n    --addons=HorizontalPodAutoscaling\n```\n\nCreate a GCP service account (GSA) for ExternalDNS and save its email address.\n\n```console\n$ sa_name=\"Kubernetes external-dns\"\n$ gcloud iam service-accounts create sa-edns --display-name=\"$sa_name\"\n$ sa_email=$(gcloud iam service-accounts list --format='value(email)' \\\n    --filter=\"displayName:$sa_name\")\n```\n\nBind the ExternalDNS GSA to the DNS admin role.\n\n```console\n$ gcloud projects add-iam-policy-binding zalando-external-dns-test \\\n    --member=\"serviceAccount:$sa_email\" --role=roles/dns.admin\n```\n\nLink the ExternalDNS GSA to the Kubernetes service account (KSA) that\nexternal-dns will run under, i.e., the external-dns KSA in the external-dns\nnamespaces.\n\n```console\n$ gcloud iam service-accounts add-iam-policy-binding \"$sa_email\" \\\n    --member=\"serviceAccount:zalando-external-dns-test.svc.id.goog[external-dns/external-dns]\" \\\n    --role=roles/iam.workloadIdentityUser\n```\n\nCreate a DNS zone which will contain the managed DNS records.\n\n```console\n$ gcloud dns managed-zones create external-dns-test-gcp-zalan-do \\\n    --dns-name=external-dns-test.gcp.zalan.do. \\\n    --description=\"Automatically managed zone by ExternalDNS\"\n```\n\nMake a note of the nameservers that were assigned to your new zone.\n\n```console\n$ gcloud dns record-sets list \\\n    --zone=external-dns-test-gcp-zalan-do \\\n    --name=external-dns-test.gcp.zalan.do. \\\n    --type NS\nNAME                             TYPE  TTL    DATA\nexternal-dns-test.gcp.zalan.do.  NS    21600  ns-cloud-e1.googledomains.com.,ns-cloud-e2.googledomains.com.,ns-cloud-e3.googledomains.com.,ns-cloud-e4.googledomains.com.\n```\n\nIn this case it's `ns-cloud-{e1-e4}.googledomains.com.` but your's could\nslightly differ, e.g. `{a1-a4}`, `{b1-b4}` etc.\n\nTell the parent zone where to find the DNS records for this zone by adding the\ncorresponding NS records there. Assuming the parent zone is \"gcp-zalan-do\" and\nthe domain is \"gcp.zalan.do\" and that it's also hosted at Google we would do the\nfollowing.\n\n```console\n$ gcloud dns record-sets transaction start --zone=gcp-zalan-do\n$ gcloud dns record-sets transaction add ns-cloud-e{1..4}.googledomains.com. \\\n    --name=external-dns-test.gcp.zalan.do. --ttl 300 --type NS --zone=gcp-zalan-do\n$ gcloud dns record-sets transaction execute --zone=gcp-zalan-do\n```\n\nConnect your `kubectl` client to the cluster you just created and bind your GCP\nuser to the cluster admin role in Kubernetes.\n\n```console\n$ gcloud container clusters get-credentials external-dns\n$ kubectl create clusterrolebinding cluster-admin-me \\\n    --clusterrole=cluster-admin --user=\"$(gcloud config get-value account)\"\n```\n\n### Deploy ingress-nginx\n\nFollow the [ingress-nginx GKE installation\ninstructions](https://kubernetes.github.io/ingress-nginx/deploy/#gce-gke) to\ndeploy it to the cluster.\n\n```console\n$ kubectl apply -f \\\n    https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v0.35.0/deploy/static/provider/cloud/deploy.yaml\n```\n\n### Deploy ExternalDNS\n\nApply the following manifest file to deploy external-dns.\n\n```yaml\napiVersion: v1\nkind: Namespace\nmetadata:\n  name: external-dns\n---\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: external-dns\n  namespace: external-dns\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: external-dns\nrules:\n  - apiGroups: [\"\"]\n    resources: [\"services\",  \"pods\"]\n    verbs: [\"get\", \"watch\", \"list\"]\n  - apiGroups: [\"discovery.k8s.io\"]\n    resources: [\"endpointslices\"]\n    verbs: [\"get\",\"watch\",\"list\"]\n  - apiGroups: [\"extensions\", \"networking.k8s.io\"]\n    resources: [\"ingresses\"]\n    verbs: [\"get\", \"watch\", \"list\"]\n  - apiGroups: [\"\"]\n    resources: [\"nodes\"]\n    verbs: [\"list\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: external-dns-viewer\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: external-dns\nsubjects:\n  - kind: ServiceAccount\n    name: external-dns\n    namespace: external-dns\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\n  namespace: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      containers:\n        - args:\n            - --source=ingress\n            - --domain-filter=external-dns-test.gcp.zalan.do\n            - --provider=google\n            - --google-project=zalando-external-dns-test\n            - --registry=txt\n            - --txt-owner-id=my-identifier\n          image: registry.k8s.io/external-dns/external-dns:v0.20.0\n          name: external-dns\n      securityContext:\n        fsGroup: 65534\n        runAsUser: 65534\n      serviceAccountName: external-dns\n```\n\nThen add the proper workload identity annotation to the cert-manager service\naccount.\n\n```bash\n$ kubectl annotate serviceaccount --namespace=external-dns external-dns \\\n    \"iam.gke.io/gcp-service-account=$sa_email\"\n```\n\n### Deploy a sample application\n\nCreate the following sample application to test that ExternalDNS works.\n\n```yaml\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  name: nginx\nspec:\n  ingressClassName: nginx\n  rules:\n  - host: via-ingress.external-dns-test.gcp.zalan.do\n    http:\n      paths:\n      - path: /\n        backend:\n          service:\n            name: nginx\n            port:\n              number: 80\n        pathType: Prefix\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: nginx\nspec:\n  ports:\n  - port: 80\n    targetPort: 80\n  selector:\n    app: nginx\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: nginx\nspec:\n  selector:\n    matchLabels:\n      app: nginx\n  template:\n    metadata:\n      labels:\n        app: nginx\n    spec:\n      containers:\n      - image: nginx\n        name: nginx\n        ports:\n        - containerPort: 80\n```\n\nAfter roughly two minutes check that a corresponding DNS record for your ingress\nwas created.\n\n```console\n$ gcloud dns record-sets list \\\n    --zone \"external-dns-test-gcp-zalan-do\" \\\n    --name \"via-ingress.external-dns-test.gcp.zalan.do.\" \\\n    --type A\nNAME                                         TYPE  TTL  DATA\nvia-ingress.external-dns-test.gcp.zalan.do.  A     300  35.187.1.246\n```\n\nLet's check that we can resolve this DNS name as well.\n\n```console\n$ dig +short @ns-cloud-e1.googledomains.com. via-ingress.external-dns-test.gcp.zalan.do.\n35.187.1.246\n```\n\nTry with `curl` as well.\n\n```console\n$ curl via-ingress.external-dns-test.gcp.zalan.do\n<!DOCTYPE html>\n<html>\n<head>\n<title>Welcome to nginx!</title>\n...\n</head>\n<body>\n...\n</body>\n</html>\n```\n\n### Clean up\n\nMake sure to delete all service and ingress objects before terminating the\ncluster so all load balancers and DNS entries get cleaned up correctly.\n\n```console\nkubectl delete service --namespace=ingress-nginx ingress-nginx-controller\nkubectl delete ingress nginx\n```\n\nGive ExternalDNS some time to clean up the DNS records for you. Then delete the\nmanaged zone and cluster.\n\n```console\ngcloud dns managed-zones delete external-dns-test-gcp-zalan-do\ngcloud container clusters delete external-dns\n```\n\nAlso delete the NS records for your removed zone from the parent zone.\n\n```console\n$ gcloud dns record-sets transaction start --zone gcp-zalan-do\n$ gcloud dns record-sets transaction remove ns-cloud-e{1..4}.googledomains.com. \\\n    --name=external-dns-test.gcp.zalan.do. --ttl 300 --type NS --zone=gcp-zalan-do\n$ gcloud dns record-sets transaction execute --zone=gcp-zalan-do\n```\n\n## User Demo How-To Blogs and Examples\n\n* Run external-dns on GKE with workload identity. See [Kubernetes, ingress-nginx, cert-manager & external-dns](https://blog.atomist.com/kubernetes-ingress-nginx-cert-manager-external-dns/)\n"
  },
  {
    "path": "docs/tutorials/gke.md",
    "content": "# GKE with default controller\n\nThis tutorial describes how to setup ExternalDNS for usage within a [GKE](https://cloud.google.com/kubernetes-engine) ([Google Kuberentes Engine](https://cloud.google.com/kubernetes-engine)) cluster. Make sure to use **>=0.11.0** version of ExternalDNS for this tutorial\n\n## Single project test scenario using access scopes\n\n*If you prefer to try-out ExternalDNS in one of the existing environments you can skip this step*\n\nThe following instructions use [access scopes](https://cloud.google.com/compute/docs/access/service-accounts#accesscopesiam) to provide ExternalDNS\nwith the permissions it needs to manage DNS records within a single [project](https://cloud.google.com/docs/overview#projects), the organizing entity to allocate resources.\n\nNote that since these permissions are associated with the instance, all pods in the cluster will also have these permissions. As such, this approach is not suitable for anything but testing environments.\n\nThis solution will only work when both CloudDNS and GKE are provisioned in the same project.  If the CloudDNS zone is in a different project, this solution will not work.\n\n### Configure Project Environment\n\nSet up your environment to work with Google Cloud Platform. Fill in your variables as needed, e.g. target project.\n\n```bash\n# set variables to the appropriate desired values\nPROJECT_ID=\"my-external-dns-test\"\nREGION=\"europe-west1\"\nZONE=\"europe-west1-d\"\nClOUD_BILLING_ACCOUNT=\"<my-cloud-billing-account>\"\n# set default settings for project\ngcloud config set project $PROJECT_ID\ngcloud config set compute/region $REGION\ngcloud config set compute/zone $ZONE\n# enable billing and APIs if not done already\ngcloud beta billing projects link $PROJECT_ID \\\n  --billing-account $BILLING_ACCOUNT\ngcloud services enable \"dns.googleapis.com\"\ngcloud services enable \"container.googleapis.com\"\n```\n\n### Create GKE Cluster\n\n```bash\ngcloud container clusters create $GKE_CLUSTER_NAME \\\n  --num-nodes 1 \\\n  --scopes \"https://www.googleapis.com/auth/ndev.clouddns.readwrite\"\n```\n\n> [!WARNING]\n> Note that this cluster will use the default [compute engine GSA](https://cloud.google.com/compute/docs/access/service-accounts#default_service_account) that contians the overly permissive project editor (`roles/editor`) role.\n> So essentially, anything on the cluster could potentially grant escalated privileges.\n> Also, as mentioned earlier, the access scope `ndev.clouddns.readwrite` will allow anything running on the cluster to have read/write permissions on all Cloud DNS zones within the same project.\n\n### Cloud DNS Zone\n\nCreate a DNS zone which will contain the managed DNS records.\nIf using your own domain that was registered with a third-party domain registrar, you should point your domain's name servers to the values under the `nameServers` key.\nPlease consult your registrar's documentation on how to do that. This tutorial will use example domain of `example.com`.\n\n```bash\ngcloud dns managed-zones create \"example-com\" --dns-name \"example.com.\" \\\n  --description \"Automatically managed zone by kubernetes.io/external-dns\"\n```\n\nMake a note of the nameservers that were assigned to your new zone.\n\n```bash\ngcloud dns record-sets list \\\n    --zone \"example-com\" --name \"example.com.\" --type NS\n```\n\nOutputs:\n\n```sh\nNAME          TYPE  TTL    DATA\nexample.com.  NS    21600  ns-cloud-e1.googledomains.com.,ns-cloud-e2.googledomains.com.,ns-cloud-e3.googledomains.com.,ns-cloud-e4.googledomains.com.\n```\n\nIn this case it's `ns-cloud-{e1-e4}.googledomains.com.` but your's could slightly differ, e.g. `{a1-a4}`, `{b1-b4}` etc.\n\n## Cross project access scenario using Google Service Account\n\nMore often, following best practices in regards to security and operations, Cloud DNS zones will be managed in a separate project from the Kubernetes cluster.\nThis section shows how setup ExternalDNS to access Cloud DNS from a different project. These steps will also work for single project scenarios as well.\n\nExternalDNS will need permissions to make changes to the Cloud DNS zone. There are three ways to configure the access needed:\n\n* [Worker Node Service Account](#worker-node-service-account-method)\n* [Static Credentials](#static-credentials)\n* [Workload Identity](#workload-identity)\n\n### Setup Cloud DNS and GKE\n\nBelow are examples on how you can configure Cloud DNS and GKE in separate projects, and then use one of the three methods to grant access to ExternalDNS.  Replace the environment variables to values that make sense in your environment.\n\n#### Configure Projects\n\nFor this process, create projects with the appropriate APIs enabled.\n\n```bash\n# set variables to appropriate desired values\nGKE_PROJECT_ID=\"my-workload-project\"\nDNS_PROJECT_ID=\"my-cloud-dns-project\"\nClOUD_BILLING_ACCOUNT=\"<my-cloud-billing-account>\"\n# enable billing and APIs for DNS project if not done already\ngcloud config set project $DNS_PROJECT_ID\ngcloud beta billing projects link $CLOUD_DNS_PROJECT \\\n  --billing-account $ClOUD_BILLING_ACCOUNT\ngcloud services enable \"dns.googleapis.com\"\n# enable billing and APIs for GKE project if not done already\ngcloud config set project $GKE_PROJECT_ID\ngcloud beta billing projects link $CLOUD_DNS_PROJECT \\\n  --billing-account $ClOUD_BILLING_ACCOUNT\ngcloud services enable \"container.googleapis.com\"\n```\n\n#### Provisioning Cloud DNS\n\nCreate a Cloud DNS zone in the designated DNS project.\n\n```bash\ngcloud dns managed-zones create \"example-com\" --project $DNS_PROJECT_ID \\\n  --description \"example.com\" --dns-name=\"example.com.\" --visibility=public\n```\n\nIf using your own domain that was registered with a third-party domain registrar, you should point your domain's name servers to the values under the `nameServers` key.  Please consult your registrar's documentation on how to do that. The example domain of `example.com` will be used for this tutorial.\n\n#### Provisioning a GKE cluster for cross project access\n\nCreate a GSA (Google Service Account) and grant it the [minimal set of privileges required](https://cloud.google.com/kubernetes-engine/docs/how-to/hardening-your-cluster#use_least_privilege_sa) for GKE nodes:\n\n```bash\nGKE_CLUSTER_NAME=\"my-external-dns-cluster\"\nGKE_REGION=\"us-central1\"\nGKE_SA_NAME=\"worker-nodes-sa\"\nGKE_SA_EMAIL=\"$GKE_SA_NAME@${GKE_PROJECT_ID}.iam.gserviceaccount.com\"\n\nROLES=(\n  roles/logging.logWriter\n  roles/monitoring.metricWriter\n  roles/monitoring.viewer\n  roles/stackdriver.resourceMetadata.writer\n)\n\ngcloud iam service-accounts create $GKE_SA_NAME \\\n  --display-name $GKE_SA_NAME --project $GKE_PROJECT_ID\n\n# assign google service account to roles in GKE project\nfor ROLE in ${ROLES[*]}; do\n  gcloud projects add-iam-policy-binding $GKE_PROJECT_ID \\\n    --member \"serviceAccount:$GKE_SA_EMAIL\" \\\n    --role $ROLE\ndone\n```\n\nCreate a cluster using this service account and enable [workload identity](https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity):\n\n```bash\ngcloud container clusters create $GKE_CLUSTER_NAME \\\n  --project $GKE_PROJECT_ID --region $GKE_REGION --num-nodes 1 \\\n  --service-account \"$GKE_SA_EMAIL\" \\\n  --workload-pool \"$GKE_PROJECT_ID.svc.id.goog\"\n```\n\n### Workload Identity\n\n[Workload Identity](https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity) allows workloads in your GKE cluster to [authenticate directly to GCP](https://cloud.google.com/kubernetes-engine/docs/concepts/workload-identity#credential-flow) using Kubernetes Service Accounts\n\nYou have an option to chose from using the gcloud CLI or using Terraform.\n\n=== \"gcloud CLI\"\n\n    The below instructions assume you are using the default Kubernetes Service account name of `external-dns` in the namespace `external-dns`\n\n    Grant the Kubernetes service account DNS `roles/dns.admin` at project level\n\n    ```shell\n    gcloud projects add-iam-policy-binding projects/DNS_PROJECT_ID \\\n        --role=roles/dns.admin \\\n        --member=principal://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/PROJECT_ID.svc.id.goog/subject/ns/external-dns/sa/external-dns \\\n        --condition=None\n    ```\n\n    Replace the following:\n\n    * `DNS_PROJECT_ID` : Project ID of your DNS project. If DNS is in the same project as your GKE cluster, use your GKE project.\n    * `PROJECT_ID`: your Google Cloud project ID of your GKE Cluster\n    * `PROJECT_NUMBER`: your numerical Google Cloud project number of your GKE cluster\n\n    If you wish to change the namespace, replace\n\n    * `ns/external-dns` with `ns/<your namespace`\n    * `sa/external-dns` with `sa/<your ksa>`\n\n=== \"Terraform\"\n\n    The below instructions assume you are using the default Kubernetes Service account name of `external-dns` in the namespace `external-dns`\n\n    Create a file called `main.tf` and place in it the below. _Note: If you're an experienced terraform user feel free to split these out in to different files_\n\n    ```hcl\n    variable \"gke-project\" {\n      type        = string\n      description = \"Name of the project that the GKE cluster exists in\"\n      default     = \"GKE-PROJECT\"\n    }\n\n    variable \"ksa_name\" {\n      type        = string\n      description = \"Name of the Kubernetes service account that will be accessing the DNS Zones\"\n      default     = \"external-dns\"\n    }\n\n    variable \"kns_name\" {\n      type        = string\n      description = \"Name of the Kubernetes Namespace\"\n      default     = \"external-dns\"\n    }\n\n    data \"google_project\" \"project\" {\n      project_id = var.gke-project\n    }\n\n    locals {\n      member = \"principal://iam.googleapis.com/projects/${data.google_project.project.number}/locations/global/workloadIdentityPools/${var.gke-project}.svc.id.goog/subject/ns/${var.kns_name}/sa/${var.ksa_name}\"\n    }\n\n    resource \"google_project_iam_member\" \"external_dns\" {\n      member  = local.member\n      project = \"DNS-PROJECT\"\n      role    = \"roles/dns.reader\"\n    }\n\n    resource \"google_dns_managed_zone_iam_member\" \"member\" {\n      project      = \"DNS-PROJECT\"\n      managed_zone = \"ZONE-NAME\"\n      role         = \"roles/dns.admin\"\n      member       = local.member\n    }\n    ```\n\n    Replace the following\n\n    * `GKE-PROJECT` : Project that contains your GKE cluster\n    * `DNS-PROJECT` : Project that holds your DNS zones\n\n    You can also change the below if you plan to use a different service account name and namespace\n\n    * `variable \"ksa_name\"` : Name of the Kubernetes service account external-dns will use\n    * `variable \"kns_name\"` : Name of the Kubernetes Name Space that will have external-dns installed to\n\n### Worker Node Service Account method\n\nIn this method, the GSA (Google Service Account) that is associated with GKE worker nodes will be configured to have access to Cloud DNS.\n\n**WARNING**: This will grant access to modify the Cloud DNS zone records for all containers running on cluster, not just ExternalDNS, so use this option with caution.  This is not recommended for production environments.\n\n```bash\nGKE_SA_EMAIL=\"$GKE_SA_NAME@${GKE_PROJECT_ID}.iam.gserviceaccount.com\"\n\n# assign google service account to dns.admin role in the cloud dns project\ngcloud projects add-iam-policy-binding $DNS_PROJECT_ID \\\n  --member serviceAccount:$GKE_SA_EMAIL \\\n  --role roles/dns.admin\n```\n\nAfter this, follow the steps in [Deploy ExternalDNS](#deploy-externaldns).  Make sure to set the `--google-project` flag to match the Cloud DNS project name.\n\n### Static Credentials\n\nIn this scenario, a new GSA (Google Service Account) is created that has access to the CloudDNS zone.  The credentials for this GSA are saved and installed as a Kubernetes secret that will be used by ExternalDNS.\n\nThis allows only containers that have access to the secret, such as ExternalDNS to update records on the Cloud DNS Zone.\n\n#### Create GSA for use with static credentials\n\n```bash\nDNS_SA_NAME=\"external-dns-sa\"\nDNS_SA_EMAIL=\"$DNS_SA_NAME@${GKE_PROJECT_ID}.iam.gserviceaccount.com\"\n\n# create GSA used to access the Cloud DNS zone\ngcloud iam service-accounts create $DNS_SA_NAME --display-name $DNS_SA_NAME\n\n# assign google service account to dns.admin role in cloud-dns project\ngcloud projects add-iam-policy-binding $DNS_PROJECT_ID \\\n  --member serviceAccount:$DNS_SA_EMAIL --role \"roles/dns.admin\"\n```\n\n#### Create Kubernetes secret using static credentials\n\nGenerate static credentials from the ExternalDNS GSA.\n\n```bash\n# download static credentials\ngcloud iam service-accounts keys create /local/path/to/credentials.json \\\n  --iam-account $DNS_SA_EMAIL\n```\n\nCreate a Kubernetes secret with the credentials in the same namespace of ExternalDNS.\n\n```bash\nkubectl create secret generic \"external-dns\" --namespace ${EXTERNALDNS_NS:-\"default\"} \\\n  --from-file /local/path/to/credentials.json\n```\n\nAfter this, follow the steps in [Deploy ExternalDNS](#deploy-externaldns).  Make sure to set the `--google-project` flag to match Cloud DNS project name. Make sure to uncomment out the section that mounts the secret to the ExternalDNS pods.\n\n#### Deploy External DNS\n\nDeploy ExternalDNS with the following steps below, documented under [Deploy ExternalDNS](#deploy-externaldns).  Set the `--google-project` flag to the Cloud DNS project name.\n\n#### Update ExternalDNS pods\n\n!!! note \"Only required if not enabled on all nodes\"\n    If you have GKE Workload Identity enabled on all nodes in your cluster, the below step is not necessary\n\nUpdate the Pod spec to schedule the workloads on nodes that use Workload Identity and to use the annotated Kubernetes service account.\n\n```bash\nkubectl patch deployment \"external-dns\" \\\n  --namespace ${EXTERNALDNS_NS:-\"default\"} \\\n  --patch \\\n '{\"spec\": {\"template\": {\"spec\": {\"nodeSelector\": {\"iam.gke.io/gke-metadata-server-enabled\": \"true\"}}}}}'\n```\n\nAfter all of these steps you may see several messages with `googleapi: Error 403: Forbidden, forbidden`.  After several minutes when the token is refreshed, these error messages will go away, and you should see info messages, such as: `All records are already up to date`.\n\n## Deploy ExternalDNS\n\nThen apply the following manifests file to deploy ExternalDNS.\n\n```yaml\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: external-dns\n  labels:\n    app.kubernetes.io/name: external-dns\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: external-dns\n  labels:\n    app.kubernetes.io/name: external-dns\nrules:\n  - apiGroups: [\"\"]\n    resources: [\"services\",\"pods\",\"nodes\"]\n    verbs: [\"get\",\"watch\",\"list\"]\n  - apiGroups: [\"discovery.k8s.io\"]\n    resources: [\"endpointslices\"]\n    verbs: [\"get\",\"watch\",\"list\"]\n  - apiGroups: [\"extensions\",\"networking.k8s.io\"]\n    resources: [\"ingresses\"]\n    verbs: [\"get\",\"watch\",\"list\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: external-dns-viewer\n  labels:\n    app.kubernetes.io/name: external-dns\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: external-dns\nsubjects:\n  - kind: ServiceAccount\n    name: external-dns\n    namespace: default # change if namespace is not 'default'\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\n  labels:\n    app.kubernetes.io/name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app.kubernetes.io/name: external-dns\n  template:\n    metadata:\n      labels:\n        app.kubernetes.io/name: external-dns\n    spec:\n      serviceAccountName: external-dns\n      containers:\n        - name: external-dns\n          image: registry.k8s.io/external-dns/external-dns:v0.20.0\n          args:\n            - --source=service\n            - --source=ingress\n            - --domain-filter=example.com # will make ExternalDNS see only the hosted zones matching provided domain, omit to process all available hosted zones\n            - --provider=google\n            - --log-format=json # google cloud logs parses severity of the \"text\" log format incorrectly\n    #        - --google-project=my-cloud-dns-project # Use this to specify a project different from the one external-dns is running inside\n            - --google-zone-visibility=public # Use this to filter to only zones with this visibility. Set to either 'public' or 'private'. Omitting will match public and private zones\n            - --policy=upsert-only # would prevent ExternalDNS from deleting any records, omit to enable full synchronization\n            - --registry=txt\n            - --txt-owner-id=my-identifier\n      #     # uncomment below if static credentials are used\n      #     env:\n      #       - name: GOOGLE_APPLICATION_CREDENTIALS\n      #         value: /etc/secrets/service-account/credentials.json\n      #     volumeMounts:\n      #       - name: google-service-account\n      #         mountPath: /etc/secrets/service-account/\n      # volumes:\n      #   - name: google-service-account\n      #     secret:\n      #       secretName: external-dns\n```\n\nCreate the deployment for ExternalDNS:\n\n```bash\nkubectl create --namespace \"default\" --filename externaldns.yaml\n```\n\n## Verify ExternalDNS works\n\nThe following will deploy a small nginx server that will be used to demonstrate that ExternalDNS is working.\n\n### Verify using an external load balancer\n\nCreate the following sample application to test that ExternalDNS works.  This example will provision a L4 load balancer.\n\n```yaml\napiVersion: v1\nkind: Service\nmetadata:\n  name: nginx\n  annotations:\n    # change nginx.example.com to match an appropriate value\n    external-dns.alpha.kubernetes.io/hostname: nginx.example.com\nspec:\n  type: LoadBalancer\n  ports:\n    - port: 80\n      targetPort: 80\n  selector:\n    app: nginx\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: nginx\nspec:\n  selector:\n    matchLabels:\n      app: nginx\n  template:\n    metadata:\n      labels:\n        app: nginx\n    spec:\n      containers:\n        - image: nginx\n          name: nginx\n          ports:\n            - containerPort: 80\n```\n\nCreate the deployment and service objects:\n\n```bash\nkubectl create --namespace \"default\" --filename nginx.yaml\n```\n\nAfter roughly two minutes check that a corresponding DNS record for your service was created.\n\n```bash\ngcloud dns record-sets list --zone \"example-com\" --name \"nginx.example.com.\"\n```\n\nExample output:\n\n```sh\nNAME                TYPE  TTL  DATA\nnginx.example.com.  A     300  104.155.60.49\nnginx.example.com.  TXT   300  \"heritage=external-dns,external-dns/owner=my-identifier\"\n```\n\nNote created `TXT` record alongside `A` record. `TXT` record signifies that the corresponding `A` record is managed by ExternalDNS. This makes ExternalDNS safe for running in environments where there are other records managed via other means.\n\nLet's check that we can resolve this DNS name. We'll ask the nameservers assigned to your zone first.\n\n```bash\ndig +short @ns-cloud-e1.googledomains.com. nginx.example.com.\n104.155.60.49\n```\n\nGiven you hooked up your DNS zone with its parent zone you can use `curl` to access your site.\n\n```bash\ncurl nginx.example.com\n```\n\n### Verify using an ingress\n\nLet's check that Ingress works as well. Create the following Ingress.\n\n```yaml\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  name: nginx\nspec:\n  rules:\n    - host: server.example.com\n      http:\n        paths:\n          - path: /\n            pathType: Prefix\n            backend:\n              service:\n                name: nginx\n                port:\n                  number: 80\n```\n\nCreate the ingress objects with:\n\n```bash\nkubectl create --namespace \"default\" --filename ingress.yaml\n```\n\nNote that this will ingress object will use the default ingress controller that comes with GKE to create a L7 load balancer in addition to the L4 load balancer previously with the service object.\nTo use only the L7 load balancer, update the service manafest to change the Service type to `NodePort` and remove the ExternalDNS annotation.\n\nAfter roughly two minutes check that a corresponding DNS record for your Ingress was created.\n\n```bash\ngcloud dns record-sets list \\\n    --zone \"example-com\" \\\n    --name \"server.example.com.\" \\\n```\n\nOutput:\n\n```sh\nNAME                 TYPE  TTL  DATA\nserver.example.com.  A     300  130.211.46.224\nserver.example.com.  TXT   300  \"heritage=external-dns,external-dns/owner=my-identifier\"\n```\n\nLet's check that we can resolve this DNS name as well.\n\n```bash\ndig +short @ns-cloud-e1.googledomains.com. server.example.com.\n130.211.46.224\n```\n\nTry with `curl` as well.\n\n```bash\ncurl server.example.com\n```\n\n### Clean up\n\nMake sure to delete all Service and Ingress objects before terminating the cluster so all load balancers get cleaned up correctly.\n\n```bash\nkubectl delete service nginx\nkubectl delete ingress nginx\n```\n\nGive ExternalDNS some time to clean up the DNS records for you. Then delete the managed zone and cluster.\n\n```bash\ngcloud dns managed-zones delete \"example-com\"\ngcloud container clusters delete \"external-dns\"\n```\n"
  },
  {
    "path": "docs/tutorials/godaddy.md",
    "content": "# GoDaddy\n\nThis tutorial describes how to set up ExternalDNS for use within a\nKubernetes cluster using GoDaddy DNS.\n\nMake sure to use **>=0.6** version of ExternalDNS for this tutorial.\n\n## Creating a zone with GoDaddy DNS\n\nIf you are new to GoDaddy, we recommend you first read the following\ninstructions for creating a zone.\n\n[Creating a zone using the GoDaddy web console](https://www.godaddy.com/)\n\n[Creating a zone using the GoDaddy API](https://developer.godaddy.com/)\n\n## Creating GoDaddy API key\n\nYou first need to create an API Key.\n\nUsing the [GoDaddy documentation](https://developer.godaddy.com/getstarted) you will have your `API key` and `API secret`\n\n## Deploy ExternalDNS\n\nConnect your `kubectl` client to the cluster with which you want to test ExternalDNS, and then apply one of the following manifest files for deployment:\n\n## Using Helm\n\nCreate a values.yaml file to configure ExternalDNS to use GoDaddy as the DNS provider. This file should include the necessary environment variables:\n\n```shell\nprovider:\n  name: godaddy\nextraArgs:\n  - --godaddy-api-key=YOUR_API_KEY\n  - --godaddy-api-secret=YOUR_API_SECRET\n```\n\nBe sure to replace YOUR_API_KEY and YOUR_API_SECRET with your actual GoDaddy API key and GoDaddy API secret.\n\nFinally, install the ExternalDNS chart with Helm using the configuration specified in your values.yaml file:\n\n```shell\nhelm upgrade --install external-dns external-dns/external-dns --values values.yaml\n```\n\n### Manifest (for clusters without RBAC enabled)\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=service # ingress is also possible\n        - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above.\n        - --provider=godaddy\n        - --txt-prefix=external-dns. # In case of multiple k8s cluster\n        - --txt-owner-id=owner-id # In case of multiple k8s cluster\n        - --godaddy-api-key=<Your API Key>\n        - --godaddy-api-secret=<Your API secret>\n```\n\n### Manifest (for clusters with RBAC enabled)\n\n```yaml\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: external-dns\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: external-dns\nrules:\n- apiGroups: [\"\"]\n  resources: [\"services\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"\"]\n  resources: [\"pods\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"discovery.k8s.io\"]\n  resources: [\"endpointslices\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"extensions\",\"networking.k8s.io\"]\n  resources: [\"ingresses\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"\"]\n  resources: [\"nodes\"]\n  verbs: [\"list\",\"watch\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: external-dns-viewer\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: external-dns\nsubjects:\n- kind: ServiceAccount\n  name: external-dns\n  namespace: default\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      serviceAccountName: external-dns\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=service # ingress is also possible\n        - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above.\n        - --provider=godaddy\n        - --txt-prefix=external-dns. # In case of multiple k8s cluster\n        - --txt-owner-id=owner-id # In case of multiple k8s cluster\n        - --godaddy-api-key=<Your API Key>\n        - --godaddy-api-secret=<Your API secret>\n```\n\n## Deploying an Nginx Service\n\nCreate a service file called 'nginx.yaml' with the following contents:\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: nginx\nspec:\n  selector:\n    matchLabels:\n      app: nginx\n  template:\n    metadata:\n      labels:\n        app: nginx\n    spec:\n      containers:\n      - image: nginx\n        name: nginx\n        ports:\n        - containerPort: 80\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: nginx\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: example.com\n    external-dns.alpha.kubernetes.io/ttl: \"120\" #optional\nspec:\n  selector:\n    app: nginx\n  type: LoadBalancer\n  ports:\n    - protocol: TCP\n      port: 80\n      targetPort: 80\n```\n\n**A note about annotations**\n\nVerify that the annotation on the service uses the same hostname as the GoDaddy DNS zone created above. The annotation may also be a subdomain of the DNS zone (e.g. 'www.example.com').\n\nThe TTL annotation can be used to configure the TTL on DNS records managed by ExternalDNS and is optional. If this annotation is not set, the TTL on records managed by ExternalDNS will default to 10.\n\nExternalDNS uses the hostname annotation to determine which services should be registered with DNS. Removing the hostname annotation will cause ExternalDNS to remove the corresponding DNS records.\n\n### Create the deployment and service\n\n```sh\nkubectl create -f nginx.yaml\n```\n\nDepending on where you run your service, it may take some time for your cloud provider to create an external IP for the service. Once an external IP is assigned, ExternalDNS detects the new service IP address and synchronizes the GoDaddy DNS records.\n\n## Verifying GoDaddy DNS records\n\nUse the GoDaddy web console or API to verify that the A record for your domain shows the external IP address of the services.\n\n## Cleanup\n\nOnce you successfully configure and verify record management via ExternalDNS, you can delete the tutorial's example:\n\n```sh\nkubectl delete -f nginx.yaml\nkubectl delete -f externaldns.yaml\n```\n"
  },
  {
    "path": "docs/tutorials/hostport.md",
    "content": "# Headless Services\n\nThis tutorial describes how to setup ExternalDNS for usage in conjunction with a Headless service.\n\n## Use cases\n\nThe main use cases that inspired this feature is the necessity for fixed addressable hostnames with services, such as Kafka when trying to access them from outside the cluster.\nIn this scenario, quite often, only the Node IP addresses are actually routable and as in systems like Kafka more direct connections are preferable.\n\n## Setup\n\nWe will go through a small example of deploying a simple Kafka with use of a headless service.\n\n### External DNS\n\nA simple deploy could look like this:\n\n### Manifest (for clusters without RBAC enabled)\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --log-level=debug\n        - --source=service\n        - --source=ingress\n        - --namespace=dev\n        - --domain-filter=example.org.\n        - --provider=aws\n        - --registry=txt\n        - --txt-owner-id=dev.example.org\n```\n\n### Manifest (for clusters with RBAC enabled)\n\n```yaml\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: external-dns\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: external-dns\nrules:\n- apiGroups: [\"\"]\n  resources: [\"services\",\"pods\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"discovery.k8s.io\"]\n  resources: [\"endpointslices\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"extensions\",\"networking.k8s.io\"]\n  resources: [\"ingresses\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"\"]\n  resources: [\"nodes\"]\n  verbs: [\"list\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: external-dns-viewer\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: external-dns\nsubjects:\n- kind: ServiceAccount\n  name: external-dns\n  namespace: default\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      serviceAccountName: external-dns\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --log-level=debug\n        - --source=service\n        - --source=ingress\n        - --namespace=dev\n        - --domain-filter=example.org.\n        - --provider=aws\n        - --registry=txt\n        - --txt-owner-id=dev.example.org\n```\n\n### Kafka Stateful Set\n\nFirst lets deploy a Kafka Stateful set, a simple example(a lot of stuff is missing) with a headless service called `ksvc`\n\n```yaml\napiVersion: apps/v1\nkind: StatefulSet\nmetadata:\n  name: kafka\nspec:\n  serviceName: ksvc\n  replicas: 3\n  template:\n    metadata:\n      labels:\n        component: kafka\n    spec:\n      containers:\n      - name:  kafka\n        image: confluent/kafka\n        ports:\n        - containerPort: 9092\n          hostPort: 9092\n          name: external\n        command:\n        - bash\n        - -c\n        - \" export DOMAIN=$(hostname -d) && \\\n            export KAFKA_BROKER_ID=$(echo $HOSTNAME|rev|cut -d '-' -f 1|rev) && \\\n            export KAFKA_ZOOKEEPER_CONNECT=$ZK_CSVC_SERVICE_HOST:$ZK_CSVC_SERVICE_PORT && \\\n            export KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://$HOSTNAME.example.org:9092 && \\\n            /etc/confluent/docker/run\"\n        volumeMounts:\n        - name: datadir\n          mountPath: /var/lib/kafka\n  volumeClaimTemplates:\n  - metadata:\n      name: datadir\n      annotations:\n          volume.beta.kubernetes.io/storage-class: st1\n    spec:\n      accessModes: [ \"ReadWriteOnce\" ]\n      resources:\n        requests:\n          storage:  500Gi\n```\n\nVery important here, is to set the `hostPort`(only works if the PodSecurityPolicy allows it)! and in case your app requires an actual hostname inside the container, unlike Kafka, which can advertise on another address, you have to set the hostname yourself.\n\n### Headless Service\n\nNow we need to define a headless service to use to expose the Kafka pods. There are generally two approaches to use expose the nodeport of a Headless service:\n\n1. Add `--fqdn-template={{ .Name }}.example.org`\n2. Use a full annotation\n\nIf you go with #1, you just need to define the headless service, here is an example of the case #2:\n\n```yaml\napiVersion: v1\nkind: Service\nmetadata:\n  name: ksvc\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname:  example.org\nspec:\n  ports:\n  - port: 9092\n    name: external\n  clusterIP: None\n  selector:\n    component: kafka\n```\n\nThis will create 4 dns records:\n\n```sh\nkafka-0.example.org IP-0\nkafka-1.example.org IP-1\nkafka-2.example.org IP-2\nexample.org IP-0,IP-1,IP-2\n```\n\n> !Notice rood domain with records `example.org`\n\nIf you set `--fqdn-template={{ .Name }}.example.org` you can omit the annotation.\n\n```sh\nkafka-0.ksvc.example.org IP-0\nkafka-1.ksvc.example.org IP-1\nkafka-2.ksvc.example.org IP-2\nksvc.example.org IP-0,IP-1,IP-2\n```\n\n#### Using pods' HostIPs as targets\n\nAdd the following annotation to your `Service`:\n\n```yaml\nexternal-dns.alpha.kubernetes.io/endpoints-type: HostIP\n```\n\nexternal-dns will now publish the value of the `.status.hostIP` field of the pods backing your `Service`.\n\n#### Using node external IPs as targets\n\nAdd the following annotation to your `Service`:\n\n```yaml\nexternal-dns.alpha.kubernetes.io/endpoints-type: NodeExternalIP\n```\n\nexternal-dns will now publish the node external IP (`.status.addresses` entries of with `type: NodeExternalIP`) of the nodes on which the pods backing your `Service` are running.\n\n#### Using pod annotations to specify target IPs\n\nAdd the following annotation to the **pods** backing your `Service`:\n\n```yaml\nexternal-dns.alpha.kubernetes.io/target: \"1.2.3.4\"\n```\n\nexternal-dns will publish the IP specified in the annotation of each pod instead of using the podIP advertised by Kubernetes.\n\nThis can be useful e.g. if you are NATing public IPs onto your pod IPs and want to publish these in DNS.\n"
  },
  {
    "path": "docs/tutorials/ionoscloud.md",
    "content": "# IONOS Cloud\n\nThis tutorial describes how to set up ExternalDNS for use within a Kubernetes cluster using IONOS Cloud DNS.\nFor more details, visit the [IONOS external-dns webhook repository](https://github.com/ionos-cloud/external-dns-ionos-webhook).\nYou can also find the [external-dns-ionos-webhook container image](https://github.com/ionos-cloud/external-dns-ionos-webhook/pkgs/container/external-dns-ionos-webhook) required for this setup.\n\n## Creating a DNS Zone with IONOS Cloud DNS\n\nIf you are new to IONOS Cloud DNS, we recommend you first read the following instructions for creating a DNS zone:\n\n- [Manage DNS Zones in Data Centre Designer](https://docs.ionos.com/cloud/network-services/cloud-dns/dcd-how-tos/manage-dns-zone)\n- [Creating a DNS Zone using the IONOS Cloud DNS API](https://docs.ionos.com/cloud/network-services/cloud-dns/api-how-tos/create-dns-zone)\n\n### Steps to Create a DNS Zone\n\n1. Log in to the [IONOS Cloud Data Center Designer](https://dcd.ionos.com/).\n2. Navigate to the **Network Services** section and select **Cloud DNS**.\n3. Click on **Create Zone** and provide the following details:\n   - **Zone Name**: Enter the domain name (e.g., `example.com`).\n   - **Description**: It is optional to provide a description of your zone.\n4. Save the zone configuration.\n\nFor more advanced configurations, such as adding records or managing subdomains, refer to the [IONOS Cloud DNS Documentation](https://docs.ionos.com/cloud/network-services/cloud-dns/).\n\n## Creating an IONOS API Token\n\nTo use ExternalDNS with IONOS Cloud DNS, you need an API token with sufficient privileges to manage DNS zones and records. Follow these steps to create an API token:\n\n1. Log in to the [IONOS Cloud Data Center Designer](https://dcd.ionos.com/).\n2. Navigate to the **Management** section in the top right corner and select **Token Manager**.\n3. Select the Time To Live(TTL) of the token and click on **Create Token**.\n4. Copy the generated token and store it securely. You will use this token to authenticate ExternalDNS.\n\n## Deploy ExternalDNS\n\n### Step 1: Create a Kubernetes Secret for the IONOS API Token\n\nStore your IONOS API token securely in a Kubernetes secret:\n\n```bash\nkubectl create secret generic ionos-credentials --from-literal=api-key='<IONOS_API_TOKEN>'\n```\n\nReplace `<IONOS_API_TOKEN>` with your actual IONOS API token.\n\n### Step 2: Configure ExternalDNS\n\nCreate a Helm values file for the ExternalDNS Helm chart that includes the webhook configuration. In this example, the values file is called `external-dns-ionos-values.yaml` .\n\n```yaml\nlogLevel: debug # ExternalDNS Log level, reduce in production\n\nnamespaced: false # if true, ExternalDNS will run in a namespaced scope (Role and Rolebinding will be namespaced too).\ntriggerLoopOnEvent: true # if true, ExternalDNS will trigger a loop on every event (create/update/delete) on the resources it watches.\n\nlogLevel: debug\nsources:\n  - ingress\n  - service\nprovider:\n  name: webhook\n  webhook:\n    image:\n      repository: ghcr.io/ionos-cloud/external-dns-ionos-webhook\n      tag: latest\n      pullPolicy: IfNotPresent\n    env:\n      - name: IONOS_API_KEY\n        valueFrom:\n          secretKeyRef:\n            name: ionos-credentials\n            key: api-key\n      - name: SERVER_PORT\n        value: \"8888\"\n      - name: METRICS_PORT\n        value: \"8080\"\n      - name: DRY_RUN\n        value: \"false\"\n```\n\n### Step 3: Install ExternalDNS Using Helm\n\nInstall ExternalDNS with the IONOS webhook provider:\n\n```bash\nhelm repo add external-dns https://kubernetes-sigs.github.io/external-dns/\nhelm upgrade --install external-dns external-dns/external-dns -f external-dns-ionos-values.yaml\n```\n\n## Deploying an Example Application\n\n### Step 1: Create a Deployment\n\nIn this step we will create `echoserver` application manifest with the following content:\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: echoserver\n  namespace: default\nspec:\n  replicas: 1\n  selector:\n    matchLabels:\n      app: echoserver\n  template:\n    metadata:\n      labels:\n        app: echoserver\n    spec:\n      containers:\n      - name: echoserver\n        image: ealen/echo-server:latest\n        ports:\n        - containerPort: 80\n```\n\nDeployment manifest can be saved in `echoserver-deployment.yaml` file.\n\nNext, we will apply the deployment:\n\n```bash\nkubectl apply -f echoserver-deployment.yaml\n```\n\n### Step 2: Create a Service\n\nIn this step, we will create a `Service` manifest to expose the `echoserver` application within the cluster. The service will also include an annotation for ExternalDNS to create a DNS record for the specified hostname.\n\nSave the following content in a file named `echoserver-service.yaml`:\n\n```yaml\napiVersion: v1\nkind: Service\nmetadata:\n  name: echoserver\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: app.example.com\nspec:\n  ports:\n    - port: 80\n      targetPort: 80\n  selector:\n    app: echoserver\n```\n\n **Note:** Replace `app.example.com` with a subdomain of your DNS zone configured in IONOS Cloud DNS. For example, if your DNS zone is `example.com`, you can use a subdomain like `app.example.com`.\n\nNext, apply the service:\n\n```bash\nkubectl apply -f echoserver-service.yaml\n```\n\nThis service will expose the echoserver application on port 80 and instruct ExternalDNS to create a DNS record for `app.example.com`.\n\n### Step 3: Create an Ingress\n\nIn this step, we will create an `Ingress` resource to expose the `echoserver` application externally. The ingress will route HTTP traffic to the `echoserver` service and include a hostname that ExternalDNS will use to create the corresponding DNS record.\n\nSave the following content in a file named `echoserver-ingress.yaml` :\n\n```yaml\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  name: echoserver\nspec:\n  rules:\n  - host: app.example.com\n    http:\n      paths:\n      - path: /\n        pathType: Prefix\n        backend:\n          service:\n            name: echoserver\n            port:\n              number: 80\n```\n\n **Note:** Replace `app.example.com` with a subdomain of your DNS zone configured in IONOS Cloud DNS. For example, if your DNS zone is `example.com`, you can use a subdomain like `app.example.com`.\n\nNext, apply the ingress manifest:\n\n```bash\nkubectl apply -f echoserver-ingress.yaml\n```\n\nThis ingress will expose the `echoserver` application at `http://app.example.com` and instruct ExternalDNS to create a DNS record for the specified hostname.\n\n## Accessing the Application\n\nOnce the `Ingress` resource has been applied and the DNS records have been created, you can access the application using the hostname specified in the ingress (`app.example.com`).\n\n### Verify Application Access\n\nUse the following `curl` command to verify that the application is accessible:\n\n```bash\ncurl -I http://app.example.com\n```\n\nReplace app.example.com with the subdomain you configured in your DNS zone.\n\n **Note:** Ensure that your DNS changes have propagated and that the hostname resolves to the correct IP address before running the command.\n\n### Expected result\n\nYou should see an HTTP response header indicating that the application is running, such as:\n\n```bash\nHTTP/1.1 200 OK\n```\n\n> **Troubleshooting:**\n>\n>If you encounter any issues, verify the following:\n>\n> - The DNS record for `app.example.com` (replace with your own subdomain configured in IONOS Cloud DNS) has been created in IONOS Cloud DNS.\n> - The ingress controller is running and properly configured in your Kubernetes cluster.\n> - The `echoserver` application is running and accessible within the cluster.\n\n## Verifying IONOS Cloud DNS Records\n\nUse the IONOS Cloud Console or API to verify that the A and TXT records for your domain have been created. For example, you can use the following API call:\n\n```bash\ncurl --location --request GET 'https://dns.de-fra.ionos.com/records?filter.name=app' \\\n--header 'Authorization: Bearer <IONOS_API_TOKEN>'\n```\n\nReplace `<IONOS_API_TOKEN>` with your actual API token.\n\nThe API response should include the `A` and `TXT` records for the subdomain you configured.\n\n> **Note:** DNS changes may take a few minutes to propagate. If the records are not visible immediately, wait and try again.\n\n## Cleanup\n\n> **Optional:** Perform the cleanup step only if you no longer need the deployed resources.\n\nOnce you have verified the setup, you can clean up the resources created during this tutorial:\n\n```bash\nkubectl delete -f echoserver-deployment.yaml\nkubectl delete -f echoserver-service.yaml\nkubectl delete -f echoserver-ingress.yaml\n```\n\n## Summary\n\nIn this tutorial, you successfully deployed ExternalDNS webhook with IONOS Cloud DNS as the provider.\nYou created a Kubernetes deployment, service, and ingress, and verified that DNS records were created and the application was accessible.\nYou also learned how to clean up the resources when they are no longer needed.\n"
  },
  {
    "path": "docs/tutorials/kops-dns-controller.md",
    "content": "# kOps dns-controller\n\nkOps includes a dns-controller that is primarily used to bootstrap the cluster, but can also be used for provisioning DNS entries for Services and Ingress.\n\nExternalDNS can be used as a drop-in replacement for dns-controller if you are running a non-gossip cluster. The flag `--compatibility kops-dns-controller` enables the dns-controller behaviour.\n\n## Annotations\n\nIn kops-dns-controller compatibility mode, ExternalDNS supports two additional annotations:\n\n* `dns.alpha.kubernetes.io/external` which is used to define a DNS record for accessing the resource publicly (i.e. public IPs)\n\n* `dns.alpha.kubernetes.io/internal` which is used to define a DNS record for accessing the resource from outside the cluster but inside the cloud,\ni.e. it will typically use internal IPs for instances.\n\nThese annotations may both be comma-separated lists of names.\n\n## DNS record mappings\n\nThe DNS record mappings try to \"do the right thing\", but what this means is different for each resource type.\n\n### Pods\n\nFor the external annotation, ExternalDNS will map a Pod to the external IPs of the Node.\n\nFor the internal annotation, ExternalDNS will map a Pod to the internal IPs of the Node.\n\nAnnotations added to Pods will always result in an A record being created.\n\n### Services\n\n* For a Service of Type=LoadBalancer, ExternalDNS looks at Status.LoadBalancer.Ingress. It will create CNAMEs to hostnames,\n  and A records for IP addresses. It will do this for both internal and external names\n\n* For a Service of Type=NodePort, ExternalDNS will create A records for the Node's internal/external IP addresses, as appropriate.\n"
  },
  {
    "path": "docs/tutorials/kube-ingress-aws.md",
    "content": "# kube-ingress-aws-controller\n\nThis tutorial describes how to use ExternalDNS with the [kube-ingress-aws-controller][1].\n\n[1]: https://github.com/zalando-incubator/kube-ingress-aws-controller\n\n## Setting up ExternalDNS and kube-ingress-aws-controller\n\nFollow the [AWS tutorial](aws.md) to setup ExternalDNS for use in Kubernetes clusters\nrunning in AWS. Specify the `source=ingress` argument so that ExternalDNS will look\nfor hostnames in Ingress objects. In addition, you may wish to limit which Ingress\nobjects are used as an ExternalDNS source via the `ingress-class` argument, but\nthis is not required.\n\nFor help setting up the Kubernetes Ingress AWS Controller, that can\ncreate ALBs and NLBs, follow the [Setup Guide][2].\n\n[2]: https://github.com/zalando-incubator/kube-ingress-aws-controller/tree/HEAD/deploy\n\n### Optional RouteGroup\n\n[RouteGroup][3] is a CRD, that enables you to do complex routing with\n[Skipper][4].\n\nFirst, you have to apply the RouteGroup CRD to your cluster:\n\n```sh\nkubectl apply -f https://github.com/zalando/skipper/blob/HEAD/dataclients/kubernetes/deploy/apply/routegroups_crd.yaml\n```\n\nYou have to grant all controllers: [Skipper][4],\n[kube-ingress-aws-controller][1] and external-dns to read the routegroup resource and\nkube-ingress-aws-controller to update the status field of a routegroup.\nThis depends on your RBAC policies, in case you use RBAC, you can use\nthis for all 3 controllers:\n\n```yaml\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: kube-ingress-aws-controller\nrules:\n- apiGroups:\n  - extensions\n  - networking.k8s.io\n  resources:\n  - ingresses\n  verbs:\n  - get\n  - list\n  - watch\n- apiGroups:\n  - extensions\n  - networking.k8s.io\n  resources:\n  - ingresses/status\n  verbs:\n  - patch\n  - update\n- apiGroups:\n  - zalando.org\n  resources:\n  - routegroups\n  verbs:\n  - get\n  - list\n  - watch\n- apiGroups:\n  - zalando.org\n  resources:\n  - routegroups/status\n  verbs:\n  - patch\n  - update\n```\n\nSee also current RBAC yaml files:\n\n- [kube-ingress-aws-controller](https://github.com/zalando-incubator/kubernetes-on-aws/blob/dev/cluster/manifests/ingress-controller/01-rbac.yaml)\n- [skipper](https://github.com/zalando-incubator/kubernetes-on-aws/blob/dev/cluster/manifests/skipper/rbac.yaml)\n- [external-dns](https://github.com/zalando-incubator/kubernetes-on-aws/blob/dev/cluster/manifests/external-dns/01-rbac.yaml)\n\n[3]: https://opensource.zalando.com/skipper/kubernetes/routegroups/#routegroups\n[4]: https://opensource.zalando.com/skipper\n\n## Deploy an example application\n\nCreate the following sample \"echoserver\" application to demonstrate how\nExternalDNS works with ingress objects, that were created by [kube-ingress-aws-controller][1].\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: echoserver\nspec:\n  replicas: 1\n  selector:\n    matchLabels:\n      app: echoserver\n  template:\n    metadata:\n      labels:\n        app: echoserver\n    spec:\n      containers:\n      - image: gcr.io/google_containers/echoserver:1.4\n        imagePullPolicy: Always\n        name: echoserver\n        ports:\n        - containerPort: 8080\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: echoserver\nspec:\n  ports:\n    - port: 80\n      targetPort: 8080\n      protocol: TCP\n  type: ClusterIP\n  selector:\n    app: echoserver\n```\n\nNote that the Service object is of type `ClusterIP`, because we will\ntarget [Skipper][4] and do the HTTP routing in Skipper. We don't need\na Service of type `LoadBalancer` here, since we will be using a shared\nskipper-ingress for all Ingress. Skipper use `hostNetwork` to be able\nto get traffic from AWS LoadBalancers EC2 network. ALBs or NLBs, will\nbe created based on need and will be shared across all ingress as\ndefault.\n\n## Ingress examples\n\nCreate the following Ingress to expose the echoserver application to the Internet.\n\n```yaml\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  name: echoserver\nspec:\n  ingressClassName: skipper\n  rules:\n  - host: echoserver.mycluster.example.org\n    http: &echoserver_root\n      paths:\n      - path: /\n        backend:\n          service:\n            name: echoserver\n            port:\n              number: 80\n        pathType: Prefix\n  - host: echoserver.example.org\n    http: *echoserver_root\n```\n\nThe above should result in the creation of an (ipv4) ALB in AWS which will forward\ntraffic to skipper which will forward to the echoserver application.\n\nIf the `--source=ingress` argument is specified, then ExternalDNS will create\nDNS records based on the hosts specified in ingress objects. The above example\nwould result in two alias records (A and AAAA) being created for each of the\ndomains: `echoserver.mycluster.example.org` and `echoserver.example.org`. All\nfour records alias the ALB that is associated with the Ingress object. As the\nALB is IPv4 only, the AAAA alias records have no effect.\n\nIf you would like ExternalDNS to not create AAAA records at all, you can add the\nfollowing command line parameter: `--exclude-record-types=AAAA`. Please be\naware, this will disable AAAA record creation even for dualstack enabled load\nbalancers.\n\nNote that the above example makes use of the YAML anchor feature to avoid having\nto repeat the http section for multiple hosts that use the exact same paths. If\nthis Ingress object will only be fronting one backend Service, we might instead\ncreate the following:\n\n```yaml\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: echoserver.mycluster.example.org, echoserver.example.org\n  name: echoserver\nspec:\n  ingressClassName: skipper\n  rules:\n  - http:\n      paths:\n      - path: /\n        backend:\n          service:\n            name: echoserver\n            port:\n              number: 80\n        pathType: Prefix\n```\n\nIn the above example we create a default path that works for any hostname, and\nmake use of the `external-dns.alpha.kubernetes.io/hostname` annotation to create\nmultiple aliases for the resulting ALB.\n\n## NLBs\n\nAWS has\n[NLBs](https://docs.aws.amazon.com/elasticloadbalancing/latest/network/introduction.html)\nand [kube-ingress-aws-controller][1] is able to create NLBs instead of ALBs.\nThe Kubernetes Ingress AWS controller supports the `zalando.org/aws-load-balancer-type`\nannotation (which defaults to `alb`) to determine this. If this annotation is\nset to `nlb` then ExternalDNS will create an NLB instead of an ALB.\n\nExample:\n\n```yaml\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  annotations:\n    zalando.org/aws-load-balancer-type: nlb\n  name: echoserver\nspec:\n  ingressClassName: skipper\n  rules:\n  - host: echoserver.example.org\n    http:\n      paths:\n      - path: /\n        backend:\n          service:\n            name: echoserver\n            port:\n              number: 80\n        pathType: Prefix\n```\n\nThe above Ingress object will result in the creation of an NLB. A\nsuccessful create, you can observe in the ingress `status` field, that is\nwritten by [kube-ingress-aws-controller][1]:\n\n```yaml\nstatus:\n  loadBalancer:\n    ingress:\n    - hostname: kube-ing-lb-atedkrlml7iu-1681027139.$region.elb.amazonaws.com\n```\n\nExternalDNS will create A and AAAA alias records for: `echoserver.example.org`.\nExternalDNS will use these alias records to automatically maintain IP addresses\nof the NLB.\n\n## Dualstack Load Balancers\n\nAWS [supports both IPv4 and \"dualstack\" (both IPv4 and IPv6) interfaces for ALBs][5]\nand [NLBs][6]. The Kubernetes Ingress AWS controller supports the `alb.ingress.kubernetes.io/ip-address-type`\nannotation (which defaults to `ipv4`) to determine this. ExternalDNS creates\nboth A and AAAA alias DNS records by default, regardless of this annotation.\nIt's possible to create only A records with the following command line\nparameter: `--exclude-record-types=AAAA`\n\n[5]: https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html#ip-address-type\n[6]: https://docs.aws.amazon.com/elasticloadbalancing/latest/network/network-load-balancers.html#ip-address-type\n\nExample:\n\n```yaml\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  annotations:\n    alb.ingress.kubernetes.io/ip-address-type: dualstack\n  name: echoserver\nspec:\n  ingressClassName: skipper\n  rules:\n  - host: echoserver.example.org\n    http:\n      paths:\n      - path: /\n        backend:\n          service:\n            name: echoserver\n            port:\n              number: 80\n        pathType: Prefix\n```\n\nThe above Ingress object will result in the creation of an ALB with a dualstack\ninterface.\n\n## RouteGroup (optional)\n\n[Kube-ingress-aws-controller][1], [Skipper][4] and external-dns\nsupport [RouteGroups][3]. External-dns needs to be started with\n`--source=skipper-routegroup` parameter in order to work on RouteGroup objects.\n\nHere we can not show [all RouteGroup\ncapabilities](https://opensource.zalando.com/skipper/kubernetes/routegroups/),\nbut we show one simple example with an application and a custom https\nredirect.\n\n```yaml\napiVersion: zalando.org/v1\nkind: RouteGroup\nmetadata:\n  name: my-route-group\nspec:\n  backends:\n  - name: my-backend\n    type: service\n    serviceName: my-service\n    servicePort: 80\n  - name: redirectShunt\n    type: shunt\n  defaultBackends:\n  - backendName: my-service\n  routes:\n  - pathSubtree: /\n  - pathSubtree: /\n    predicates:\n    - Header(\"X-Forwarded-Proto\", \"http\")\n    filters:\n    - redirectTo(302, \"https:\")\n    backends:\n    - redirectShunt\n```\n"
  },
  {
    "path": "docs/tutorials/linode.md",
    "content": "# Linode\n\nThis tutorial describes how to setup ExternalDNS for usage within a Kubernetes cluster using Linode DNS Manager.\n\nMake sure to use **>=0.5.5** version of ExternalDNS for this tutorial.\n\n## Managing DNS with Linode\n\nIf you want to learn about how to use Linode DNS Manager read the following tutorials:\n\n[An Introduction to Managing DNS](https://www.linode.com/docs/platform/manager/dns-manager/), and [general documentation](https://www.linode.com/docs/networking/dns/)\n\n## Creating Linode Credentials\n\nGenerate a new oauth token by following the instructions at [Access-and-Authentication](https://developers.linode.com/api/v4#section/Access-and-Authentication)\n\nThe environment variable `LINODE_TOKEN` will be needed to run ExternalDNS with Linode.\n\n## Deploy ExternalDNS\n\nConnect your `kubectl` client to the cluster you want to test ExternalDNS with.\nThen apply one of the following manifests file to deploy ExternalDNS.\n\n### Manifest (for clusters without RBAC enabled)\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=service # ingress is also possible\n        - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above.\n        - --provider=linode\n        env:\n        - name: LINODE_TOKEN\n          value: \"YOUR_LINODE_API_KEY\"\n```\n\n### Manifest (for clusters with RBAC enabled)\n\n```yaml\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: external-dns\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: external-dns\nrules:\n- apiGroups: [\"\"]\n  resources: [\"services\",\"pods\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"discovery.k8s.io\"]\n  resources: [\"endpointslices\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"extensions\",\"networking.k8s.io\"]\n  resources: [\"ingresses\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"\"]\n  resources: [\"nodes\"]\n  verbs: [\"list\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: external-dns-viewer\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: external-dns\nsubjects:\n- kind: ServiceAccount\n  name: external-dns\n  namespace: default\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      serviceAccountName: external-dns\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=service # ingress is also possible\n        - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above.\n        - --provider=linode\n        env:\n        - name: LINODE_TOKEN\n          value: \"YOUR_LINODE_API_KEY\"\n```\n\n## Deploying an Nginx Service\n\nCreate a service file called 'nginx.yaml' with the following contents:\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: nginx\nspec:\n  selector:\n    matchLabels:\n      app: nginx\n  template:\n    metadata:\n      labels:\n        app: nginx\n    spec:\n      containers:\n      - image: nginx\n        name: nginx\n        ports:\n        - containerPort: 80\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: nginx\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: my-app.example.com\nspec:\n  selector:\n    app: nginx\n  type: LoadBalancer\n  ports:\n    - protocol: TCP\n      port: 80\n      targetPort: 80\n```\n\nNote the annotation on the service; use the same hostname as the Linode DNS zone created above.\n\nExternalDNS uses this annotation to determine what services should be registered with DNS. Removing the annotation will cause ExternalDNS to remove the corresponding DNS records.\n\nCreate the deployment and service:\n\n```console\nkubectl create -f nginx.yaml\n```\n\nDepending where you run your service it can take a little while for your cloud provider to create an external IP for the service.\n\nOnce the service has an external IP assigned, ExternalDNS will notice the new service IP address and synchronize the Linode DNS records.\n\n## Verifying Linode DNS records\n\nCheck your [Linode UI](https://cloud.linode.com/domains) to view the records for your Linode DNS zone.\n\nClick on the zone for the one created above if a different domain was used.\n\nThis should show the external IP address of the service as the A record for your domain.\n\n## Cleanup\n\nNow that we have verified that ExternalDNS will automatically manage Linode DNS records, we can delete the tutorial's example:\n\n```sh\nkubectl delete service -f nginx.yaml\nkubectl delete service -f externaldns.yaml\n```\n"
  },
  {
    "path": "docs/tutorials/myra.md",
    "content": "# Myra ExternalDNS Webhook\n\nThis 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.\n\n## Prerequisites\n\n- Kubernetes cluster (v1.19+)\n- `kubectl` configured to access your cluster\n- Docker for building the container image\n- MyraSec API credentials (API key and secret)\n- Domain registered with MyraSec\n\n## Quick Installation\n\n### 1. Get the Docker Image\n\n#### Pull from container registry\n\nThe image is published with each version to Github Container Registry under [external-dns-myrasec-webhook](https://github.com/Myra-Security-GmbH/external-dns-myrasec-webhook/pkgs/container/external-dns-myrasec-webhook).\n\n```bash\n# Pull the image\ndocker pull ghcr.io/myra-security-gmbh/external-dns-myrasec-webhook:<VERSION>\n\n# For the sake of this tutorial, tag the image with \"myra-webhook:latest\"\ndocker image tag ghcr.io/myra-security-gmbh/external-dns-myrasec-webhook:<VERSION> myra-webhook:latest\n\n```\n\n#### Build and Push the Docker Image\n\n```bash\n# From the project root\ndocker build -t myra-webhook:latest .\n\n# Tag the image for your container registry\ndocker tag myra-webhook:latest <YOUR_REGISTRY>/myra-webhook:latest\n\n# Push to your container registry\ndocker push <YOUR_REGISTRY>/myra-webhook:latest\n```\n\n> **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.\n\n### 2. Configure API Credentials\n\nCreate a secret with your MyraSec API credentials:\n\n```bash\nkubectl create secret generic myra-webhook-secrets \\\n  --from-literal=myrasec-api-key=YOUR_API_KEY \\\n  --from-literal=myrasec-api-secret=YOUR_API_SECRET \\\n  --from-literal=domain-filter=YOUR_DOMAIN.com\n```\n\nAlternatively, apply the provided secret template after editing:\n\n```bash\n# Edit the secret file first\nvi deploy/myra-webhook-secrets.yaml\n\n# Then apply\nkubectl apply -f deploy/myra-webhook-secrets.yaml\n```\n\n### 3. Deploy the Webhook and ExternalDNS\n\n```bash\n# Apply the combined deployment\nkubectl apply -f deploy/combined-deployment.yaml\n```\n\nThis deploys:\n\n- ConfigMap with webhook configuration\n- ServiceAccount, ClusterRole, and ClusterRoleBinding for RBAC\n- Deployment with two containers:\n  - myra-webhook: The webhook provider implementation\n  - external-dns: The ExternalDNS controller using the webhook provider\n\n### 4. Verify Deployment\n\n```bash\n# Check if pods are running\nkubectl get pods -l app=myra-externaldns\n\n# Check logs for the webhook container\nkubectl logs -l app=myra-externaldns -c myra-webhook\n\n# Check logs for the external-dns container\nkubectl logs -l app=myra-externaldns -c external-dns\n```\n\n## Manual Testing with NGINX Demo\n\n### 1. Deploy the NGINX Demo Application\n\n```bash\n# Edit the domain in the nginx-demo.yaml file to match your domain\nvi deploy/nginx-demo.yaml\n\n# Most important part is to set the correct domain in the external-dns.alpha.kubernetes.io/hostname annotation\n# Example:\n# annotations:\n#   external-dns.alpha.kubernetes.io/enabled: \"true\"\n#   external-dns.alpha.kubernetes.io/hostname: \"nginx-demo.dummydomainforkubes.de\"\n#   external-dns.alpha.kubernetes.io/target: \"9.2.3.4\"\n\n# Apply the demo resources\nkubectl apply -f deploy/nginx-demo.yaml\n```\n\nThis creates:\n\n- NGINX Deployment\n- Service for the deployment\n- Ingress resource with ExternalDNS annotations\n\n### 2. Verify DNS Record Creation\n\nAfter deploying the demo application, ExternalDNS should automatically create DNS records in MyraSec:\n\n```bash\n# Check external-dns logs to see record creation\nkubectl logs -l app=myra-externaldns -c external-dns | grep \"nginx-demo\"\n\n# Verify the webhook logs\nkubectl logs -l app=myra-externaldns -c myra-webhook | grep \"Created DNS record\"\n```\n\nYou can also verify through the MyraSec dashboard that the records were created.\n\n### 3. Testing Record Deletion\n\nTo test record deletion:\n\n```bash\n# Delete the nginx-demo resources or remove annotation from ingress\nkubectl delete -f deploy/nginx-demo.yaml\n\n# Delete the ingress resource or remove annotation from ingress\n# If resource is still active, external dns might still see the record and manage it\nkubectl delete ingress nginx-demo -n default\n\n# Check external-dns logs to see record deletion\nkubectl logs -l app=myra-externaldns -c external-dns | grep \"nginx-demo\" | grep \"delete\"\n\n# Verify the webhook logs\nkubectl logs -l app=myra-externaldns -c myra-webhook | grep \"Deleted DNS record\"\n```\n\n## Configuration Options\n\nThe webhook can be configured through the ConfigMap:\n\n| Parameter                | Description                                       | Default   |\n| ------------------------ | ------------------------------------------------- | --------- |\n| `disable-protection`     | Disabled Myra protection for DNS records          | `\"false\"` |\n| `dry-run`                | Run in dry-run mode without making actual changes | `\"false\"` |\n| `environment`            | Environment name (affects private IP handling)    | `\"prod\"`  |\n| `log-level`              | Logging level (debug, info, warn, error)          | `\"debug\"` |\n| `ttl`                    | Default TTL for DNS records                       | `\"300\"`   |\n| `webhook-listen-address` | Address and port for the webhook server           | `\":8080\"` |\n\n## Troubleshooting\n\n### Common Issues\n\n1. **Webhook not receiving requests**\n\n   - Ensure the `webhook-provider-url` in the external-dns args is correct\n   - Check network connectivity between containers\n\n2. **DNS records not being created**\n\n   - Verify MyraSec API credentials are correct\n   - Check if the domain filter is properly configured\n   - Look for error messages in the webhook and external-dns logs\n\n3. **Permissions issues**\n   - Ensure the ServiceAccount has the correct RBAC permissions\n\n### Getting Help\n\nFor more detailed logs:\n\n```bash\n# Set log level to debug in the ConfigMap\nkubectl edit configmap myra-externaldns-config\n# Change log-level to \"debug\"\n\n# Restart the pods\nkubectl rollout restart deployment myra-externaldns\n```\n\n## Environment Configuration\n\nThe webhook supports different environment configurations through the `environment` setting in the ConfigMap:\n\n```yaml\napiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: myra-externaldns-config\ndata:\n  environment: \"prod\" # Can be \"prod\", \"staging\", \"dev\", etc.\n```\n\nThe environment setting affects how the webhook handles certain operations:\n\n| Environment                        | Behavior                                                                |\n| ---------------------------------- | ----------------------------------------------------------------------- |\n| `prod`, `production`, `staging`    | Strict mode: Skips private IP records, enforces stricter validation     |\n| `dev`, `development`, `test`, etc. | Development mode: Allows private IP records, more permissive validation |\n\nTo modify the environment:\n\n```bash\n# Edit the ConfigMap directly\nkubectl edit configmap myra-externaldns-config\n\n# Or apply an updated YAML file\nkubectl apply -f updated-config.yaml\n```\n\n## Advanced Configuration\n\nFor production deployments, consider:\n\n1. Using a proper image registry instead of `latest` tag\n2. Setting resource limits appropriate for your environment\n3. Configuring horizontal pod autoscaling\n4. Using Helm for deployment management\n"
  },
  {
    "path": "docs/tutorials/ns1.md",
    "content": "# NS1\n\nThis tutorial describes how to setup ExternalDNS for use within a\nKubernetes cluster using NS1 DNS.\n\nMake sure to use **>=0.5** version of ExternalDNS for this tutorial.\n\n## Creating a zone with NS1 DNS\n\nIf you are new to NS1, we recommend you first read the following\ninstructions for creating a zone.\n\n[Creating a zone using the NS1\nportal](https://ns1.com/knowledgebase/creating-a-zone)\n\n[Creating a zone using the NS1\nAPI](https://ns1.com/api#put-create-a-new-dns-zone)\n\n## Creating NS1 Credentials\n\nAll NS1 products are API-first, meaning everything that can be done on\nthe portal---including managing zones and records, data sources and\nfeeds, and account settings and users---can be done via API.\n\nThe NS1 API is a standard REST API with JSON responses. The environment\nvar `NS1_APIKEY` will be needed to run ExternalDNS with NS1.\n\n### To add or delete an API key\n\n1. Log into the NS1 portal at [my.nsone.net](http://my.nsone.net).\n\n2. Click your username in the upper-right corner, and navigate to **Account Settings** \\> **Users & Teams**.\n\n3. Navigate to the _API Keys_ tab, and click **Add Key**.\n\n4. Enter the name of the application and modify permissions and settings as desired. Once complete, click **Create Key**. The new API key appears in the list.\n\n> [!NOTE]\n> Set the permissions for your API keys just as you would for a user or team associated with your organization's NS1 account.\n> For more information, refer to the article [Creating and Managing API Keys](https://help.ns1.com/hc/en-us/articles/360026140094-Creating-managing-users) in the NS1 Knowledge Base.\n\n## Deploy ExternalDNS\n\nConnect your `kubectl` client to the cluster with which you want to test ExternalDNS, and then apply one of the following manifest files for deployment:\n\nBegin by creating a Kubernetes secret to securely store your NS1 API key. This key will enable ExternalDNS to authenticate with NS1:\n\n```shell\nkubectl create secret generic NS1_APIKEY --from-literal=NS1_API_KEY=YOUR_NS1_API_KEY\n```\n\nEnsure to replace YOUR_NS1_API_KEY with your actual NS1 API key.\n\nThen apply one of the following manifests file to deploy ExternalDNS.\n\n## Using Helm\n\nCreate a values.yaml file to configure ExternalDNS to use NS1 as the DNS provider. This file should include the necessary environment variables:\n\n```shell\nprovider:\n  name: ns1\nenv:\n  - name: NS1_APIKEY\n    valueFrom:\n      secretKeyRef:\n        name: NS1_APIKEY\n        key: NS1_API_KEY\n```\n\nFinally, install the ExternalDNS chart with Helm using the configuration specified in your values.yaml file:\n\n```shell\nhelm upgrade --install external-dns external-dns/external-dns --values values.yaml\n```\n\n### Manifest (for clusters without RBAC enabled)\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=service # ingress is also possible\n        - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above.\n        - --provider=ns1\n        env:\n       - name: NS1_APIKEY\n          valueFrom:\n            secretKeyRef:\n              name: NS1_APIKEY\n              key: NS1_API_KEY\n```\n\n### Manifest (for clusters with RBAC enabled)\n\n```yaml\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: external-dns\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: external-dns\nrules:\n- apiGroups: [\"\"]\n  resources: [\"services\",\"pods\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"discovery.k8s.io\"]\n  resources: [\"endpointslices\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"extensions\",\"networking.k8s.io\"]\n  resources: [\"ingresses\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"\"]\n  resources: [\"nodes\"]\n  verbs: [\"list\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: external-dns-viewer\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: external-dns\nsubjects:\n- kind: ServiceAccount\n  name: external-dns\n  namespace: default\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      serviceAccountName: external-dns\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=service # ingress is also possible\n        - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above.\n        - --provider=ns1\n        env:\n       - name: NS1_APIKEY\n          valueFrom:\n            secretKeyRef:\n              name: NS1_APIKEY\n              key: NS1_API_KEY\n```\n\n## Deploying an Nginx Service\n\nCreate a service file called 'nginx.yaml' with the following contents:\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: nginx\nspec:\n  selector:\n    matchLabels:\n      app: nginx\n  template:\n    metadata:\n      labels:\n        app: nginx\n    spec:\n      containers:\n      - image: nginx\n        name: nginx\n        ports:\n        - containerPort: 80\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: nginx\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: example.com\n    external-dns.alpha.kubernetes.io/ttl: \"120\" #optional\nspec:\n  selector:\n    app: nginx\n  type: LoadBalancer\n  ports:\n    - protocol: TCP\n      port: 80\n      targetPort: 80\n```\n\n**A note about annotations**\n\nVerify that the annotation on the service uses the same hostname as the NS1 DNS zone created above. The annotation may also be a subdomain of the DNS zone (e.g. 'www.example.com').\n\nThe TTL annotation can be used to configure the TTL on DNS records managed by ExternalDNS and is optional. If this annotation is not set, the TTL on records managed by ExternalDNS will default to 10.\n\nExternalDNS uses the hostname annotation to determine which services should be registered with DNS. Removing the hostname annotation will cause ExternalDNS to remove the corresponding DNS records.\n\n### Create the deployment and service\n\n```sh\nkubectl create -f nginx.yaml\n```\n\nDepending on where you run your service, it may take some time for your cloud provider to create an external IP for the service. Once an external IP is assigned, ExternalDNS detects the new service IP address and synchronizes the NS1 DNS records.\n\n## Verifying NS1 DNS records\n\nUse the NS1 portal or API to verify that the A record for your domain shows the external IP address of the services.\n\n## Cleanup\n\nOnce you successfully configure and verify record management via ExternalDNS, you can delete the tutorial's example:\n\n```sh\nkubectl delete -f nginx.yaml\nkubectl delete -f externaldns.yaml\n```\n"
  },
  {
    "path": "docs/tutorials/oracle.md",
    "content": "# Oracle Cloud Infrastructure\n\nThis tutorial describes how to setup ExternalDNS for usage within a Kubernetes cluster using OCI DNS.\n\nMake sure to use the latest version of ExternalDNS for this tutorial.\n\n## Creating an OCI DNS Zone\n\nCreate a DNS zone which will contain the managed DNS records. Let's use\n`example.com` as a reference here.  Make note of the OCID of the compartment\nin which you created the zone; you'll need to provide that later.\n\nFor more information about [OCI DNS see the documentation here][1].\n\n## Using Private OCI DNS Zones\n\nBy default, the ExternalDNS OCI provider is configured to use Global OCI\nDNS Zones. If you want to use Private OCI DNS Zones, add the following\nargument to the ExternalDNS controller:\n\n```sh\n--oci-zone-scope=PRIVATE\n```\n\nTo use both Global and Private OCI DNS Zones, set the OCI Zone Scope to be\nempty:\n\n```sh\n--oci-zone-scope=\n```\n\n## Deploy ExternalDNS\n\nConnect your `kubectl` client to the cluster you want to test ExternalDNS with.\nThe OCI provider supports two authentication options: key-based and instance\nprincipals.\n\n### Key-based\n\nWe first need to create a config file containing the information needed to connect with the OCI API.\n\nCreate a new file (oci.yaml) and modify the contents to match the example\nbelow. Be sure to adjust the values to match your own credentials, and the OCID\nof the compartment containing the zone:\n\n```yaml\nauth:\n  region: us-phoenix-1\n  tenancy: ocid1.tenancy.oc1...\n  user: ocid1.user.oc1...\n  key: |\n    -----BEGIN RSA PRIVATE KEY-----\n    -----END RSA PRIVATE KEY-----\n  fingerprint: af:81:71:8e...\n  # Omit if there is not a password for the key\n  passphrase: Tx1jRk...\ncompartment: ocid1.compartment.oc1...\n```\n\nCreate a secret using the config file above:\n\n```shell\nkubectl create secret generic external-dns-config --from-file=oci.yaml\n```\n\n### OCI IAM Instance Principal\n\nIf you're running ExternalDNS within OCI, you can use OCI IAM instance\nprincipals to authenticate with OCI.  This obviates the need to create the\nsecret with your credentials.  You'll need to ensure an OCI IAM policy exists\nwith a statement granting the `manage dns` permission on zones and records in\nthe target compartment to the dynamic group covering your instance running\nExternalDNS.\nE.g.:\n\n```sql\nAllow dynamic-group <dynamic-group-name> to manage dns in compartment id <target-compartment-OCID>\n```\n\nYou'll also need to add the `--oci-auth-instance-principal` flag to enable\nthis type of authentication. Finally, you'll need to add the\n`--oci-compartment-ocid=ocid1.compartment.oc1...` flag to provide the OCID of\nthe compartment containing the zone to be managed.\n\nFor more information about OCI IAM instance principals, see [the documentation here][2].\nFor more information about OCI IAM policy details for the DNS service, see [the documentation here][3].\n\n### OCI IAM Workload Identity\n\nIf you're running ExternalDNS within an OCI Container Engine for Kubernetes (OKE) cluster,\nyou can use OCI IAM Workload Identity to authenticate with OCI. You'll need to ensure an\nOCI IAM policy exists with a statement granting the `manage dns` permission on zones and\nrecords in the target compartment covering your OKE cluster running ExternalDNS.\nE.g.:\n\n```sql\nAllow any-user to manage dns in compartment <compartment-name> where all {request.principal.type='workload',request.principal.cluster_id='<cluster-ocid>',request.principal.service_account='external-dns'}\n```\n\nYou'll also need to create a new file (oci.yaml) and modify the contents to match the example\nbelow. Be sure to adjust the values to match your region and the OCID\nof the compartment containing the zone:\n\n```yaml\nauth:\n  region: us-phoenix-1\n  useWorkloadIdentity: true\ncompartment: ocid1.compartment.oc1...\n```\n\nCreate a secret using the config file above:\n\n```shell\nkubectl create secret generic external-dns-config --from-file=oci.yaml\n```\n\n## Manifest (for clusters with RBAC enabled)\n\nApply the following manifest to deploy ExternalDNS.\n\n```yaml\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: external-dns\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: external-dns\nrules:\n- apiGroups: [\"\"]\n  resources: [\"services\",\"pods\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"discovery.k8s.io\"]\n  resources: [\"endpointslices\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"extensions\",\"networking.k8s.io\"]\n  resources: [\"ingresses\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"\"]\n  resources: [\"nodes\"]\n  verbs: [\"list\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: external-dns-viewer\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: external-dns\nsubjects:\n- kind: ServiceAccount\n  name: external-dns\n  namespace: default\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      serviceAccountName: external-dns\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=service\n        - --source=ingress\n        - --provider=oci\n        - --policy=upsert-only # prevent ExternalDNS from deleting any records, omit to enable full synchronization\n        - --txt-owner-id=my-identifier\n        # Specifies the OCI DNS Zone scope, defaults to GLOBAL.\n        # May be GLOBAL, PRIVATE, or an empty value to specify both GLOBAL and PRIVATE OCI DNS Zones\n        # - --oci-zone-scope=GLOBAL\n        # Specifies the zone cache duration, defaults to 0s. If set to 0s, the zone cache is disabled.\n        # Use of zone caching is recommended to reduce the amount of requests sent to OCI DNS.\n        # - --oci-zones-cache-duration=0s\n        volumeMounts:\n          - name: config\n            mountPath: /etc/kubernetes/\n      volumes:\n      - name: config\n        secret:\n          secretName: external-dns-config\n```\n\n## Verify ExternalDNS works (Service example)\n\nCreate the following sample application to test that ExternalDNS works.\n\n> For services ExternalDNS will look for the annotation `external-dns.alpha.kubernetes.io/hostname` on the service and use the corresponding value.\n\n```yaml\napiVersion: v1\nkind: Service\nmetadata:\n  name: nginx\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: example.com\nspec:\n  type: LoadBalancer\n  ports:\n  - port: 80\n    name: http\n    targetPort: 80\n  selector:\n    app: nginx\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: nginx\nspec:\n  selector:\n    matchLabels:\n      app: nginx\n  template:\n    metadata:\n      labels:\n        app: nginx\n    spec:\n      containers:\n      - image: nginx\n        name: nginx\n        ports:\n        - containerPort: 80\n          name: http\n```\n\nApply the manifest above and wait roughly two minutes and check that a corresponding DNS record for your service was created.\n\n```sh\nkubectl apply -f nginx.yaml\n```\n\n[1]: https://docs.cloud.oracle.com/iaas/Content/DNS/Concepts/dnszonemanagement.htm\n[2]: https://docs.cloud.oracle.com/iaas/Content/Identity/Reference/dnspolicyreference.htm\n[3]: https://docs.cloud.oracle.com/iaas/Content/Identity/Tasks/callingservicesfrominstances.htm\n"
  },
  {
    "path": "docs/tutorials/ovh.md",
    "content": "# OVHcloud\n\nThis tutorial describes how to setup ExternalDNS for use within a\nKubernetes cluster using OVHcloud DNS.\n\nMake sure to use **>=0.6** version of ExternalDNS for this tutorial.\n\n## Creating a zone with OVHcloud DNS\n\nIf you are new to OVHcloud, we recommend you first read the following\ninstructions for creating a zone.\n\n[Creating a zone using the OVHcloud Manager](https://help.ovhcloud.com/csm/en-gb-dns-create-dns-zone?id=kb_article_view&sysparm_article=KB0051667/)\n\n[Creating a zone using the OVHcloud API](https://api.ovh.com/console/)\n\n## Creating OVHcloud Credentials\n\nYou first need to create an OVHcloud application: follow the\n[OVHcloud documentation](https://help.ovhcloud.com/csm/en-gb-api-getting-started-ovhcloud-api?id=kb_article_view&sysparm_article=KB0042784#advanced-usage-pair-ovhcloud-apis-with-an-application)\n you will have your `Application key` and `Application secret`\n\nAnd you will need to generate your consumer key, here the permissions needed :\n\n- GET on `/domain/zone`\n- GET on `/domain/zone/*/record`\n- GET on `/domain/zone/*/record/*`\n- PUT on `/domain/zone/*/record/*`\n- POST on `/domain/zone/*/record`\n- DELETE on `/domain/zone/*/record/*`\n- GET on `/domain/zone/*/soa`\n- POST on `/domain/zone/*/refresh`\n\nYou can use the following `curl` request to generate & validated your `Consumer key`\n\n```bash\ncurl -XPOST -H \"X-Ovh-Application: <ApplicationKey>\" -H \"Content-type: application/json\" https://eu.api.ovh.com/1.0/auth/credential -d '{\n  \"accessRules\": [\n    {\n      \"method\": \"GET\",\n      \"path\": \"/domain/zone\"\n    },\n    {\n      \"method\": \"GET\",\n      \"path\": \"/domain/zone/*/soa\"\n    },\n    {\n      \"method\": \"GET\",\n      \"path\": \"/domain/zone/*/record\"\n    },\n    {\n      \"method\": \"GET\",\n      \"path\": \"/domain/zone/*/record/*\"\n    },\n    {\n      \"method\": \"PUT\",\n      \"path\": \"/domain/zone/*/record/*\"\n    },\n    {\n      \"method\": \"POST\",\n      \"path\": \"/domain/zone/*/record\"\n    },\n    {\n      \"method\": \"DELETE\",\n      \"path\": \"/domain/zone/*/record/*\"\n    },\n    {\n      \"method\": \"POST\",\n      \"path\": \"/domain/zone/*/refresh\"\n    }\n  ],\n  \"redirection\":\"https://github.com/kubernetes-sigs/external-dns/blob/HEAD/docs/tutorials/ovh.md#creating-ovh-credentials\"\n}'\n```\n\n## Deploy ExternalDNS\n\nConnect your `kubectl` client to the cluster with which you want to test ExternalDNS, and then apply one of the following manifest files for deployment:\n\n### Manifest (for clusters without RBAC enabled)\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=service # ingress is also possible\n        - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above.\n        - --provider=ovh\n        env:\n        - name: OVH_APPLICATION_KEY\n          value: \"YOUR_OVH_APPLICATION_KEY\"\n        - name: OVH_APPLICATION_SECRET\n          value: \"YOUR_OVH_APPLICATION_SECRET\"\n        - name: OVH_CONSUMER_KEY\n          value: \"YOUR_OVH_CONSUMER_KEY_AFTER_VALIDATED_LINK\"\n```\n\n### Manifest (for clusters with RBAC enabled)\n\n```yaml\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: external-dns\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: external-dns\nrules:\n- apiGroups: [\"\"]\n  resources: [\"services\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"\"]\n  resources: [\"pods\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"discovery.k8s.io\"]\n  resources: [\"endpointslices\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"extensions\",\"networking.k8s.io\"]\n  resources: [\"ingresses\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"\"]\n  resources: [\"nodes\"]\n  verbs: [\"list\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: external-dns-viewer\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: external-dns\nsubjects:\n- kind: ServiceAccount\n  name: external-dns\n  namespace: default\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      serviceAccountName: external-dns\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=service # ingress is also possible\n        - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above.\n        - --provider=ovh\n        env:\n        - name: OVH_APPLICATION_KEY\n          value: \"YOUR_OVH_APPLICATION_KEY\"\n        - name: OVH_APPLICATION_SECRET\n          value: \"YOUR_OVH_APPLICATION_SECRET\"\n        - name: OVH_CONSUMER_KEY\n          value: \"YOUR_OVH_CONSUMER_KEY_AFTER_VALIDATED_LINK\"\n```\n\n## Deploying an Nginx Service\n\nCreate a service file called 'nginx.yaml' with the following contents:\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: nginx\nspec:\n  selector:\n    matchLabels:\n      app: nginx\n  template:\n    metadata:\n      labels:\n        app: nginx\n    spec:\n      containers:\n      - image: nginx\n        name: nginx\n        ports:\n        - containerPort: 80\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: nginx\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: example.com\n    external-dns.alpha.kubernetes.io/ttl: \"120\" #optional\nspec:\n  selector:\n    app: nginx\n  type: LoadBalancer\n  ports:\n    - protocol: TCP\n      port: 80\n      targetPort: 80\n```\n\n**A note about annotations**\n\nVerify that the annotation on the service uses the same hostname as the OVHcloud DNS zone created above. The annotation may also be a subdomain of the DNS zone (e.g. 'www.example.com').\n\nThe TTL annotation can be used to configure the TTL on DNS records managed by ExternalDNS and is optional. If this annotation is not set, the TTL on records managed by ExternalDNS will default to 10.\n\nExternalDNS uses the hostname annotation to determine which services should be registered with DNS. Removing the hostname annotation will cause ExternalDNS to remove the corresponding DNS records.\n\n### Create the deployment and service\n\n```sh\nkubectl create -f nginx.yaml\n```\n\nDepending on where you run your service, it may take some time for your cloud provider to create an external IP for the service. Once an external IP is assigned, ExternalDNS detects the new service IP address and synchronizes the OVHcloud DNS records.\n\n## Verifying OVHcloud DNS records\n\nUse the OVHcloud manager or API to verify that the A record for your domain shows the external IP address of the services.\n\n## Cleanup\n\nOnce you successfully configure and verify record management via ExternalDNS, you can delete the tutorial's example:\n\n```sh\nkubectl delete -f nginx.yaml\nkubectl delete -f externaldns.yaml\n```\n"
  },
  {
    "path": "docs/tutorials/pdns.md",
    "content": "# PowerDNS\n\n## Prerequisites\n\nThe provider has been written for and tested against [PowerDNS](https://github.com/PowerDNS/pdns) v4.1.x and thus requires **PowerDNS Auth Server >= 4.1.x**\n\nPowerDNS provider support was added via [this PR](https://github.com/kubernetes-sigs/external-dns/pull/373), thus you need to use external-dns version >= v0.5\n\nThe PDNS provider expects that your PowerDNS instance is already setup and\nfunctional. It expects that zones, you wish to add records to, already exist\nand are configured correctly. It does not add, remove or configure new zones in\nanyway.\n\n## Feature Support\n\nThe PDNS provider currently does not support:\n\n* Dry running a configuration is not supported\n\n## Deployment\n\nDeploying external DNS for PowerDNS is actually nearly identical to deploying\nit for other providers. This is what a sample `deployment.yaml` looks like:\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      # Only use if you're also using RBAC\n      # serviceAccountName: external-dns\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=service # or ingress or both\n        - --provider=pdns\n        - --pdns-server={{ pdns-api-url }}\n        - --pdns-server-id={{ pdns-server-id }}\n        - --pdns-api-key={{ pdns-http-api-key }}\n        - --txt-owner-id={{ owner-id-for-this-external-dns }}\n        - --domain-filter=external-dns-test.my-org.com # will make ExternalDNS see only the zones matching provided domain; omit to process all available zones in PowerDNS\n        - --log-level=debug\n        - --interval=30s\n```\n\n### Domain Filter (`--domain-filter`)\n\nWhen the `--domain-filter` argument is specified, external-dns will only create DNS records for host names (specified in ingress objects and services with the external-dns annotation) related to zones that match the `--domain-filter` argument in the external-dns deployment manifest.\n\neg. ```--domain-filter=example.org``` will allow for zone `example.org` and any zones in PowerDNS that ends in `.example.org`, including `an.example.org`, ie. the subdomains of example.org.\n\neg. ```--domain-filter=.example.org``` will allow *only* zones that end in `.example.org`, ie. the subdomains of example.org but not the `example.org` zone itself.\n\nThe filter can also match parent zones. For example `--domain-filter=a.example.com` will allow for zone `example.com`. If you want to match parent zones, you cannot pre-pend your filter with a \".\", eg. `--domain-filter=.example.com` will not attempt to match parent zones.\n\n### Regex Domain Filter (`--regex-domain-filter`)\n\n`--regex-domain-filter` limits possible domains and target zone with a regex. It overrides domain filters and can be specified only once.\n\n## RBAC\n\nIf your cluster is RBAC enabled, you also need to setup the following, before you can run external-dns:\n\n```yaml\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: external-dns\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: external-dns\nrules:\n- apiGroups: [\"\"]\n  resources: [\"services\",\"pods\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"discovery.k8s.io\"]\n  resources: [\"endpointslices\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"extensions\",\"networking.k8s.io\"]\n  resources: [\"ingresses\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"\"]\n  resources: [\"pods\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"\"]\n  resources: [\"nodes\"]\n  verbs: [\"list\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: external-dns-viewer\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: external-dns\nsubjects:\n- kind: ServiceAccount\n  name: external-dns\n  namespace: default\n```\n\n## Testing and Verification\n\n**Important!**: Remember to change `example.com` with your own domain throughout the following text.\n\nSpin up a simple \"Hello World\" HTTP server with the following spec (`kubectl apply -f`):\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: echo\nspec:\n  selector:\n    matchLabels:\n      app: echo\n  template:\n    metadata:\n      labels:\n        app: echo\n    spec:\n      containers:\n      - image: hashicorp/http-echo\n        name: echo\n        ports:\n        - containerPort: 5678\n        args:\n          - -text=\"Hello World\"\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: echo\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: echo.example.com\nspec:\n  selector:\n    app: echo\n  type: LoadBalancer\n  ports:\n    - protocol: TCP\n      port: 80\n      targetPort: 5678\n```\n\n**Important!**: Don't run dig, nslookup or similar immediately (until you've\nconfirmed the record exists). You'll get hit by [negative DNS caching](https://tools.ietf.org/html/rfc2308), which is hard to flush.\n\nRun the following to make sure everything is in order:\n\n```bash\nkubectl get services echo\nkubectl get endpoints echo\n```\n\nMake sure everything looks correct, i.e the service is defined and receives a\npublic IP, and that the endpoint also has a pod IP.\n\nOnce that's done, wait about 30s-1m (interval for external-dns to kick in), then do:\n\n```bash\ncurl -H \"X-API-Key: ${PDNS_API_KEY}\" ${PDNS_API_URL}/api/v1/servers/localhost/zones/example.com. | jq '.rrsets[] | select(.name | contains(\"echo\"))'\n```\n\nOnce the API shows the record correctly, you can double check your record using:\n\n```bash\ndig @${PDNS_FQDN} echo.example.com.\n```\n\n## Using CRD source to manage DNS records in PowerDNS\n\nPlease refer to the [CRD source documentation](../sources/crd.md#example) for more information.\n"
  },
  {
    "path": "docs/tutorials/pihole.md",
    "content": "# Pi-hole\n\nThis tutorial describes how to setup ExternalDNS to sync records with Pi-hole's Custom DNS.\nPi-hole has an internal list it checks last when resolving requests. This list can contain any number of arbitrary A, AAAA or CNAME records.\nThere is a pseudo-API exposed that ExternalDNS is able to use to manage these records.\n\n__NOTE:__ Your Pi-hole must be running [version 5.9 or newer](https://pi-hole.net/blog/2022/02/12/pi-hole-ftl-v5-14-web-v5-11-and-core-v5-9-released).\n\n__NOTE:__ Provider for Pi-hole version prior to 6.0 is now deprecated and will be removed in future release.\n\n__NOTE:__ Since Pi-hole version 6, you should use the flag *--pihole-api-version=6*\n\n## Deploy ExternalDNS\n\nYou can skip to the [manifest](#externaldns-manifest) if authentication is disabled on your Pi-hole instance or you don't want to use secrets.\n\nIf your Pi-hole server's admin dashboard is protected by a password, you'll likely want to create a secret first containing its value.\nThis is optional since you *do* retain the option to pass it as a flag with `--pihole-password`.\n\nYou can create the secret with:\n\n```bash\nkubectl create secret generic pihole-password \\\n    --from-literal EXTERNAL_DNS_PIHOLE_PASSWORD=supersecret\n```\n\nReplacing __\"supersecret\"__ with the actual password to your Pi-hole server.\n\n### ExternalDNS Manifest\n\nApply the following manifest to deploy ExternalDNS, editing values for your environment accordingly.\nBe sure to change the namespace in the `ClusterRoleBinding` if you are using a namespace other than __default__.\n\n```yaml\n---\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: external-dns\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: external-dns\nrules:\n- apiGroups: [\"\"]\n  resources: [\"services\",\"pods\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"discovery.k8s.io\"]\n  resources: [\"endpointslices\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"extensions\",\"networking.k8s.io\"]\n  resources: [\"ingresses\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"\"]\n  resources: [\"nodes\"]\n  verbs: [\"list\",\"watch\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: external-dns-viewer\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: external-dns\nsubjects:\n- kind: ServiceAccount\n  name: external-dns\n  namespace: default\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      serviceAccountName: external-dns\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        # If authentication is disabled and/or you didn't create\n        # a secret, you can remove this block.\n        envFrom:\n        - secretRef:\n            # Change this if you gave the secret a different name\n            name: pihole-password\n        args:\n        - --source=service\n        - --source=ingress\n        # Pihole only supports A/AAAA/CNAME records so there is no mechanism to track ownership.\n        # You don't need to set this flag, but if you leave it unset, you will receive warning\n        # logs when ExternalDNS attempts to create TXT records.\n        - --registry=noop\n        # IMPORTANT: If you have records that you manage manually in Pi-hole, set\n        # the policy to upsert-only so they do not get deleted.\n        - --policy=upsert-only\n        - --provider=pihole\n        # Switch to pihole V6 API\n        - --pihole-api-version=6\n        # Change this to the actual address of your Pi-hole web server\n        - --pihole-server=http://pihole-web.pihole.svc.cluster.local\n      securityContext:\n        fsGroup: 65534 # For ExternalDNS to be able to read Kubernetes token files\n```\n\n### Arguments\n\n- `--pihole-server (env: EXTERNAL_DNS_PIHOLE_SERVER)` - The address of the Pi-hole web server\n- `--pihole-password (env: EXTERNAL_DNS_PIHOLE_PASSWORD)` - The password to the Pi-hole web server (if enabled)\n- `--pihole-tls-skip-verify (env: EXTERNAL_DNS_PIHOLE_TLS_SKIP_VERIFY)` - Skip verification of any TLS certificates served by the Pi-hole web server.\n- `--pihole-api-version (env: EXTERNAL_DNS_PIHOLE_API_VERSION)` - Specify the pihole API version (default is 5. Eligible values are 5 or 6).\n\n## Verify ExternalDNS Works\n\n### Ingress Example\n\nCreate an Ingress resource. ExternalDNS will use the hostname specified in the Ingress object.\n\n```yaml\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  name: foo\nspec:\n  ingressClassName: nginx\n  rules:\n  - host: foo.bar.com\n    http:\n      paths:\n      - path: /\n        pathType: Prefix\n        backend:\n          service:\n            name: foo\n            port:\n              number: 80\n```\n\n### Service Example\n\nThe below sample application can be used to verify Services work.\nFor services ExternalDNS will look for the annotation `external-dns.alpha.kubernetes.io/hostname` on the service and use the corresponding value.\n\n```yaml\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: nginx\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: nginx.external-dns-test.homelab.com\nspec:\n  type: LoadBalancer\n  ports:\n  - port: 80\n    name: http\n    targetPort: 80\n  selector:\n    app: nginx\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: nginx\nspec:\n  selector:\n    matchLabels:\n      app: nginx\n  template:\n    metadata:\n      labels:\n        app: nginx\n    spec:\n      containers:\n      - image: nginx\n        name: nginx\n        ports:\n        - containerPort: 80\n          name: http\n```\n\nYou can then query your Pi-hole to see if the record was created.\n\nChange *@192.168.100.2* to the actual address of your DNS server\n\n```bash\n$ dig +short @192.168.100.2  nginx.external-dns-test.homelab.com\n\n192.168.100.129\n```\n"
  },
  {
    "path": "docs/tutorials/plural.md",
    "content": "# Plural\n\nThis tutorial describes how to setup ExternalDNS for usage within a Kubernetes cluster using Plural DNS.\n\nMake sure to use **>=0.12.3** version of ExternalDNS for this tutorial.\n\n## Creating Plural Credentials\n\nA secret containing the a Plural access token is needed for this provider. You can get a token for your [user here](https://app.plural.sh/profile/tokens).\n\nTo create the secret you can run `kubectl create secret generic plural-env --from-literal=PLURAL_ACCESS_TOKEN=<replace-with-your-access-token>`.\n\n## Deploy ExternalDNS\n\nConnect your `kubectl` client to the cluster you want to test ExternalDNS with.\nThen apply one of the following manifests file to deploy ExternalDNS.\n\n## Using Helm\n\nCreate a values.yaml file to configure ExternalDNS to use plural DNS as the DNS provider. This file should include the necessary environment variables:\n\n```shell\nprovider:\n  name: plural\nextraArgs:\n  - --plural-cluster=example-plural-cluster\n  - --plural-provider=aws # gcp, azure, equinix and kind are also possible\nenv:\n  - name: PLURAL_ACCESS_TOKEN\n    valueFrom:\n      secretKeyRef:\n        name: PLURAL_ACCESS_TOKEN\n        key: plural-env\n  - name: PLURAL_ENDPOINT\n    value: https://app.plural.sh\n```\n\nFinally, install the ExternalDNS chart with Helm using the configuration specified in your values.yaml file:\n\n```shell\nhelm upgrade --install external-dns external-dns/external-dns --values values.yaml\n```\n\n### Manifest (for clusters without RBAC enabled)\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=service # ingress is also possible\n        - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above.\n        - --provider=plural\n        - --plural-cluster=example-plural-cluster\n        - --plural-provider=aws # gcp, azure, equinix and kind are also possible\n        env:\n        - name: PLURAL_ACCESS_TOKEN\n          valueFrom:\n            secretKeyRef:\n              key: PLURAL_ACCESS_TOKEN\n              name: plural-env\n        - name: PLURAL_ENDPOINT # (optional) use an alternative endpoint for Plural; defaults to https://app.plural.sh\n          value: https://app.plural.sh\n```\n\n### Manifest (for clusters with RBAC enabled)\n\n```yaml\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: external-dns\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: external-dns\nrules:\n- apiGroups: [\"\"]\n  resources: [\"services\",\"pods\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"discovery.k8s.io\"]\n  resources: [\"endpointslices\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"extensions\",\"networking.k8s.io\"]\n  resources: [\"ingresses\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"\"]\n  resources: [\"nodes\"]\n  verbs: [\"list\", \"watch\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: external-dns-viewer\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: external-dns\nsubjects:\n- kind: ServiceAccount\n  name: external-dns\n  namespace: default\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=service # ingress is also possible\n        - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above.\n        - --provider=plural\n        - --plural-cluster=example-plural-cluster\n        - --plural-provider=aws # gcp, azure, equinix and kind are also possible\n        env:\n        - name: PLURAL_ACCESS_TOKEN\n          valueFrom:\n            secretKeyRef:\n              key: PLURAL_ACCESS_TOKEN\n              name: plural-env\n        - name: PLURAL_ENDPOINT # (optional) use an alternative endpoint for Plural; defaults to https://app.plural.sh\n          value: https://app.plural.sh\n```\n\n## Deploying an Nginx Service\n\nCreate a service file called 'nginx.yaml' with the following contents:\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: nginx\nspec:\n  selector:\n    matchLabels:\n      app: nginx\n  template:\n    metadata:\n      labels:\n        app: nginx\n    spec:\n      containers:\n      - image: nginx\n        name: nginx\n        ports:\n        - containerPort: 80\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: nginx\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: example.com\nspec:\n  selector:\n    app: nginx\n  type: LoadBalancer\n  ports:\n    - protocol: TCP\n      port: 80\n      targetPort: 80\n```\n\nNote the annotation on the service; use the same hostname as the Plural DNS zone created above. The annotation may also be a subdomain\nof the DNS zone (e.g. 'www.example.com').\n\nBy setting the TTL annotation on the service, you have to pass a valid TTL, which must be 120 or above.\nThis annotation is optional, if you won't set it, it will be 1 (automatic) which is 300.\n\nExternalDNS uses this annotation to determine what services should be registered with DNS.  Removing the annotation\nwill cause ExternalDNS to remove the corresponding DNS records.\n\nCreate the deployment and service:\n\n```sh\nkubectl create -f nginx.yaml\n```\n\nDepending where you run your service it can take a little while for your cloud provider to create an external IP for the service.\n\nOnce the service has an external IP assigned, ExternalDNS will notice the new service IP address and synchronize\nthe Plural DNS records.\n\n## Verifying Plural DNS records\n\nCheck your [Plural domain overview](https://app.plural.sh/account/domains) to view the domains associated with your Plural account. There you can view the records for each domain.\n\nThe records should show the external IP address of the service as the A record for your domain.\n\n## Cleanup\n\nNow that we have verified that ExternalDNS will automatically manage Plural DNS records, we can delete the tutorial's example:\n\n```sh\nkubectl delete -f nginx.yaml\nkubectl delete -f externaldns.yaml\n"
  },
  {
    "path": "docs/tutorials/rfc2136.md",
    "content": "# RFC2136 provider\n\nThis tutorial describes how to use the RFC2136 with either BIND or Windows DNS.\n\n## Using with BIND\n\nTo use external-dns with BIND: generate/procure a key, configure DNS and add a\ndeployment of external-dns.\n\n### Server credentials\n\n- RFC2136 was developed for and tested with [BIND](https://www.isc.org/downloads/bind/) DNS server.\nThis documentation assumes that you already have a configured and working server. If you don't,\nplease check BIND documents or tutorials.\n- If your DNS is provided for you, ask for a TSIG key authorized to update and\ntransfer the zone you wish to update. The key will look something like below.\nSkip the next steps wrt BIND setup.\n\n```text\nkey \"externaldns-key\" {\n algorithm hmac-sha256;\n secret \"96Ah/a2g0/nLeFGK+d/0tzQcccf9hCEIy34PoXX2Qg8=\";\n};\n```\n\n- If you are your own DNS administrator create a TSIG key. Use\n`tsig-keygen -a hmac-sha256 externaldns` or on older distributions\n`dnssec-keygen -a HMAC-SHA256 -b 256 -n HOST externaldns`. You will end up with\na key printed to standard out like above (or in the case of dnssec-keygen in a\nfile called `Kexternaldns......key`).\n\n### BIND Configuration\n\nIf you do not administer your own DNS, skip to RFC provider configuration\n\n- Edit your named.conf file (or appropriate included file) and add/change the\nfollowing.\n  - Make sure You are listening on the right interfaces. At least whatever\n  interface external-dns will be communicating over and the interface that\n  faces the internet.\n  - Add the key that you generated/was given to you above. Copy paste the four\n  lines that you got (not the same as the example key) into your file.\n  - Make sure zone transfer is enabled for the key, this enables listing all\n  records\n  - Create a zone for kubernetes. If you already have a zone, skip to the next\n  step. (I put the zone in it's own subdirectory because named,\n  which shouldn't be running as root, needs to create a journal file and the\n  default zone directory isn't writeable by named).\n\n  ```text\n  zone \"k8s.example.org\" {\n      type master;\n      file \"/etc/bind/pri/k8s/k8s.zone\";\n  };\n  ```\n\n  - Add your key to both transfer and update. For instance with our previous\n  zone.\n\n  ```text\n  zone \"k8s.example.org\" {\n      type master;\n      file \"/etc/bind/pri/k8s/k8s.zone\";\n      allow-transfer {\n          key \"externaldns-key\";\n      };\n      update-policy {\n          grant externaldns-key zonesub ANY;\n      };\n  };\n  ```\n\n  - Create a zone file (k8s.zone):\n\n  ```text\n  $TTL 60 ; 1 minute\n  k8s.example.org         IN SOA  k8s.example.org. root.k8s.example.org. (\n                                  16         ; serial\n                                  60         ; refresh (1 minute)\n                                  60         ; retry (1 minute)\n                                  60         ; expire (1 minute)\n                                  60         ; minimum (1 minute)\n                                  )\n                          NS      ns.k8s.example.org.\n  ns                      A       123.456.789.012\n  ```\n\n  - Reload (or restart) named\n\n### AXFR and the sync policy\n\nWhen using the `sync` policy, ExternalDNS requires AXFR (zone transfer) to be\nexplicitly enabled via the `--rfc2136-tsig-axfr` flag. This is necessary for\nExternalDNS to list all existing DNS records and determine which ones should be\nlifecycled.\n\nWithout `--rfc2136-tsig-axfr`, ExternalDNS cannot list records and will act as\nif the policy was set to `upsert-only`. No warning will be provided.\n\n### Using external-dns\n\nTo use external-dns add an ingress or a LoadBalancer service with a host that\nis part of the domain-filter. For example both of the following would produce\nA records.\n\n```text\napiVersion: v1\nkind: Service\nmetadata:\n  name: nginx\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: svc.example.org\nspec:\n  type: LoadBalancer\n  ports:\n  - port: 80\n    targetPort: 80\n  selector:\n    app: nginx\n---\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n    name: my-ingress\nspec:\n    rules:\n    - host: ingress.example.org\n      http:\n          paths:\n          - path: /\n            backend:\n                serviceName: my-service\n                servicePort: 8000\n```\n\n### Custom TTL\n\nThe default DNS record TTL (Time-To-Live) is 0 seconds. You can customize this value by setting the annotation `external-dns.alpha.kubernetes.io/ttl`. e.g., modify the service manifest YAML file above:\n\n```yaml\napiVersion: v1\nkind: Service\nmetadata:\n  name: nginx\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: nginx.external-dns-test.my-org.com\n    external-dns.alpha.kubernetes.io/ttl: 60\nspec:\n    ...\n```\n\nThis will set the DNS record's TTL to 60 seconds.\n\nA default TTL for all records can be set using the the flag with a time in seconds, minutes or hours, such as `--rfc2136-min-ttl=60s`\n\nThere are other annotation that can affect the generation of DNS records, but these are beyond the scope of this\ntutorial and are covered in the main documentation.\n\n### Generate reverse DNS records\n\nIf you want to generate reverse DNS records for your services, you have to enable the functionality using the `--rfc2136-create-ptr`\nflag. You have also to add the zone to the list of zones managed by ExternalDNS via the `--rfc2136-zone` and `--domain-filter` flags.\nAn example of a valid configuration is the following:\n\n```sh\n--domain-filter=157.168.192.in-addr.arpa --rfc2136-zone=157.168.192.in-addr.arpa\n```\n\nPTR record tracking is managed by the A/AAAA record so you can't create PTR records for already generated A/AAAA records.\n\n### Test with external-dns installed on local machine (optional)\n\nYou may install external-dns and test on a local machine by running:\n\n```sh\nexternal-dns --txt-owner-id k8s --provider rfc2136 \\\n  --rfc2136-host=192.168.0.1 --rfc2136-port=53 \\\n  --rfc2136-zone=k8s.example.org \\\n  --rfc2136-tsig-secret=96Ah/a2g0/nLeFGK+d/0tzQcccf9hCEIy34PoXX2Qg8= \\\n  --rfc2136-tsig-secret-alg=hmac-sha256 \\\n  --rfc2136-tsig-keyname=externaldns-key \\\n  --rfc2136-tsig-axfr \\\n  --source ingress --once \\\n  --domain-filter=k8s.example.org --dry-run\n```\n\n- host should be the IP of your master DNS server.\n- tsig-secret should be changed to match your secret.\n- tsig-keyname needs to match the keyname you used (if you changed it).\n- domain-filter can be used as shown to filter the domains you wish to update.\n\n### RFC2136 provider configuration\n\nIn order to use external-dns with your cluster you need to add a deployment\nwith access to your ingress and service resources. The following are two\nexample manifests with and without RBAC respectively.\n\n- With RBAC:\n\n```text\napiVersion: v1\nkind: Namespace\nmetadata:\n  name: external-dns\n  labels:\n    name: external-dns\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: external-dns\n  namespace: external-dns\nrules:\n- apiGroups:\n  - \"\"\n  resources:\n  - services\n  - pods\n  - nodes\n  verbs:\n  - get\n  - watch\n  - list\n- apiGroups:\n  - discovery.k8s.io\n  resources:\n  - endpointslices\n  verbs:\n  - get\n  - watch\n  - list\n- apiGroups:\n  - extensions\n  - networking.k8s.io\n  resources:\n  - ingresses\n  verbs:\n  - get\n  - list\n  - watch\n---\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: external-dns\n  namespace: external-dns\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: external-dns-viewer\n  namespace: external-dns\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: external-dns\nsubjects:\n- kind: ServiceAccount\n  name: external-dns\n  namespace: external-dns\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\n  namespace: external-dns\nspec:\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      serviceAccountName: external-dns\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --registry=txt\n        - --txt-prefix=external-dns-\n        - --txt-owner-id=k8s\n        - --provider=rfc2136\n        - --rfc2136-host=192.168.0.1\n        - --rfc2136-port=53\n        - --rfc2136-zone=k8s.example.org\n        - --rfc2136-zone=k8s.your-zone.org\n        - --rfc2136-tsig-secret=96Ah/a2g0/nLeFGK+d/0tzQcccf9hCEIy34PoXX2Qg8=\n        - --rfc2136-tsig-secret-alg=hmac-sha256\n        - --rfc2136-tsig-keyname=externaldns-key\n        - --rfc2136-tsig-axfr\n        - --source=ingress\n        - --domain-filter=k8s.example.org\n```\n\n- Without RBAC:\n\n```text\napiVersion: v1\nkind: Namespace\nmetadata:\n  name: external-dns\n  labels:\n    name: external-dns\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\n  namespace: external-dns\nspec:\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --registry=txt\n        - --txt-prefix=external-dns-\n        - --txt-owner-id=k8s\n        - --provider=rfc2136\n        - --rfc2136-host=192.168.0.1\n        - --rfc2136-port=53\n        - --rfc2136-zone=k8s.example.org\n        - --rfc2136-zone=k8s.your-zone.org\n        - --rfc2136-tsig-secret=96Ah/a2g0/nLeFGK+d/0tzQcccf9hCEIy34PoXX2Qg8=\n        - --rfc2136-tsig-secret-alg=hmac-sha256\n        - --rfc2136-tsig-keyname=externaldns-key\n        - --rfc2136-tsig-axfr\n        - --source=ingress\n        - --domain-filter=k8s.example.org\n```\n\n## Microsoft DNS\n\nWhile `external-dns` was not developed or tested against Microsoft DNS, it can be configured to work against it. YMMV.\n\n### Secure Updates Using RFC3645 (GSS-TSIG)\n\n#### DNS-side configuration\n\n1. Create a DNS zone\n2. Enable **secure** dynamic updates for the zone\n3. Enable Zone Transfers to all servers and/or other domains\n4. Create a user with permissions to create/update/delete records in that zone\n\nIf you see any error messages which indicate that `external-dns` was somehow not able to fetch\nexisting DNS records from your DNS server, this could mean that you forgot about step 3.\n\n##### Kerberos Configuration\n\nDNS with secure updates relies upon a valid Kerberos configuration running within the `external-dns` container.\nAt this time, you will need to create a ConfigMap for the `external-dns` container to use and mount it in your deployment.\nBelow is an example of a working Kerberos configuration inside a ConfigMap definition.  This may be different depending on many factors in your environment:\n\n```yaml\napiVersion: v1\nkind: ConfigMap\nmetadata:\n  creationTimestamp: null\n  name: krb5.conf\ndata:\n  krb5.conf: |\n    [logging]\n    default = FILE:/var/log/krb5libs.log\n    kdc = FILE:/var/log/krb5kdc.log\n    admin_server = FILE:/var/log/kadmind.log\n\n    [libdefaults]\n    dns_lookup_realm = false\n    ticket_lifetime = 24h\n    renew_lifetime = 7d\n    forwardable = true\n    rdns = false\n    pkinit_anchors = /etc/pki/tls/certs/ca-bundle.crt\n    default_ccache_name = KEYRING:persistent:%{uid}\n\n    default_realm = YOUR-REALM.COM\n\n    [realms]\n    YOUR-REALM.COM = {\n      kdc = dc1.yourdomain.com\n      admin_server = dc1.yourdomain.com\n    }\n\n    [domain_realm]\n    yourdomain.com = YOUR-REALM.COM\n    .yourdomain.com = YOUR-REALM.COM\n```\n\nIn most cases, the realm name will probably be the same as the domain name, so you can simply replace `YOUR-REALM.COM` with something like `YOURDOMAIN.COM`.\n\nOnce the ConfigMap is created, the container `external-dns` container needs to be told to mount that ConfigMap as a volume at the default Kerberos configuration location.  The pod spec should include a similar configuration to the following:\n\n```yaml\n...\n    volumeMounts:\n    - mountPath: /etc/krb5.conf\n      name: kerberos-config-volume\n      subPath: krb5.conf\n...\n  volumes:\n  - configMap:\n      defaultMode: 420\n      name: krb5.conf\n    name: kerberos-config-volume\n...\n```\n\n##### `external-dns` configuration\n\nYou'll want to configure `external-dns` similarly to the following:\n\n```text\n...\n        - --provider=rfc2136\n        - --rfc2136-gss-tsig\n        - --rfc2136-host=dns-host.yourdomain.com\n        - --rfc2136-port=53\n        - --rfc2136-zone=your-zone.com\n        - --rfc2136-zone=your-secondary-zone.com\n        - --rfc2136-kerberos-username=your-domain-account\n        - --rfc2136-kerberos-password=your-domain-password\n        - --rfc2136-kerberos-realm=your-domain.com\n        - --rfc2136-tsig-axfr # needed to enable zone transfers, which is required for deletion of records.\n...\n```\n\nAs noted above, the `--rfc2136-kerberos-realm` flag is completely optional and won't be necessary in many cases.\nMost likely, you will only need it if you see errors similar to this: `KRB Error: (68) KDC_ERR_WRONG_REALM Reserved for future use`.\n\nThe flag `--rfc2136-host` can be set to the host's domain name or IP address.\nHowever, it also determines the name of the Kerberos principal which is used during authentication.\nThis means that Active Directory might only work if this is set to a specific domain name, possibly leading to errors like this:\n`KDC_ERR_S_PRINCIPAL_UNKNOWN Server not found in Kerberos database`.\nTo fix this, try setting `--rfc2136-host` to the \"actual\" hostname of your DNS server.\n\n### Insecure Updates\n\n#### DNS-side configuration\n\n1. Create a DNS zone\n2. Enable insecure dynamic updates for the zone\n3. Enable Zone Transfers to all servers and/or other domains\n\n#### `external-dns` configuration\n\nYou'll want to configure `external-dns` similarly to the following:\n\n```text\n...\n        - --provider=rfc2136\n        - --rfc2136-host=192.168.0.1\n        - --rfc2136-port=53\n        - --rfc2136-zone=k8s.example.org\n        - --rfc2136-zone=k8s.your-zone.org\n        - --rfc2136-insecure\n        - --rfc2136-tsig-axfr # needed to enable zone transfers, which is required for deletion of records.\n...\n```\n\n## DNS Over TLS (RFCs 7858 and 9103)\n\nIf your DNS server does zone transfers over TLS, you can instruct `external-dns` to connect over TLS with the following flags:\n\n- `--rfc2136-use-tls` Will enable TLS for both zone transfers and for updates.\n- `--tls-ca=<cert-file>` Is the path to a file containing certificate(s) that can be used to verify the DNS server\n- `--tls-client-cert=<client-cert-file>` and\n- `--tls-client-cert-key=<client-key-file>` Set the client certificate and key for mutual verification\n- `--rfc2136-skip-tls-verify` Disables verification of the certificate supplied by the DNS server.\n\nIt is currently not supported to do only zone transfers over TLS, but not the updates. They are enabled and disabled together.\n\n## Configuring RFC2136 Provider with Multiple Hosts and Load Balancing\n\nThis section describes how to configure the RFC2136 provider in ExternalDNS to support multiple DNS servers and load balancing options.\n\n### Enhancements Overview\n\nThe RFC2136 provider now supports multiple DNS hosts and introduces load balancing options to distribute DNS update requests evenly across available DNS servers. This helps prevent a single server from becoming a bottleneck in environments with multiple DNS servers.\n\n### Configuration Steps\n\n1. **Allow Multiple Hosts for `--rfc2136-host`**\n    - Modify the `--rfc2136-host` command-line option to accept multiple hosts.\n    - Example: `--rfc2136-host=\"dns-host-1.yourdomain.com\" --rfc2136-host=\"dns-host-2.yourdomain.com\"`\n\n2. **Introduce Load Balancing Options**\n    - Add a new command-line option `--rfc2136-load-balancing-strategy` to specify the load balancing strategy.\n    - Supported options:\n        - `round-robin`: Distributes DNS updates evenly across all specified hosts in a round-robin manner.\n        - `random`: Randomly selects a host for each DNS update.\n        - `disabled` (default): Uses the first host in the list as the primary, only moving to the next host if a failure occurs.\n\n### Example Configuration\n\n```shell\nexternal-dns \\\n  --provider=rfc2136 \\\n  --rfc2136-host=\"dns-host-1.yourdomain.com\" \\\n  --rfc2136-host=\"dns-host-2.yourdomain.com\" \\\n  --rfc2136-host=\"dns-host-3.yourdomain.com\" \\\n  --rfc2136-load-balancing-strategy=\"round-robin\" \\\n  --rfc2136-port=53 \\\n  --rfc2136-zone=example.com \\\n  --rfc2136-tsig-secret-alg=hmac-sha256 \\\n  --rfc2136-tsig-keyname=example-key \\\n  --rfc2136-tsig-secret=example-secret \\\n  --rfc2136-insecure\n```\n\n### Helm\n\n```yaml\nextraArgs:\n  - --rfc2136-host=\"dns-host-1.yourdomain.com\"\n  - --rfc2136-port=53\n  - --rfc2136-zone=example.com\n  - --rfc2136-tsig-secret-alg=hmac-sha256\n  - --rfc2136-tsig-axfr\n\nenv:\n  - name: \"EXTERNAL_DNS_RFC2136_TSIG_SECRET\"\n    valueFrom:\n      secretKeyRef:\n        name: rfc2136-keys\n        key: rfc2136-tsig-secret\n  - name: \"EXTERNAL_DNS_RFC2136_TSIG_KEYNAME\"\n    valueFrom:\n      secretKeyRef:\n        name: rfc2136-keys\n        key: rfc2136-tsig-keyname\n```\n\n#### Secret creation\n\n```shell\nkubectl create secret generic rfc2136-keys --from-literal=rfc2136-tsig-secret='xxx' --from-literal=rfc2136-tsig-keyname='k8s-external-dns-key' -n external-dns\n```\n\n### Benefits\n\n- Distributes the load of DNS updates across multiple data centers, preventing any single DC from becoming a bottleneck.\n- Provides flexibility to choose different load balancing strategies based on the environment and requirements.\n- Improves the resilience and reliability of DNS updates by introducing a retry mechanism with a list of hosts.\n"
  },
  {
    "path": "docs/tutorials/scaleway.md",
    "content": "# Scaleway\n\nThis tutorial describes how to setup ExternalDNS for usage within a Kubernetes cluster using Scaleway DNS.\n\nMake sure to use **>=0.7.4** version of ExternalDNS for this tutorial.\n\n**Warning**: Scaleway DNS is currently in Public Beta and may not be suited for production usage.\n\n## Importing a Domain into Scaleway DNS\n\nIn order to use your domain, you need to import it into Scaleway DNS. If it's not already done, you can follow [this documentation](https://www.scaleway.com/en/docs/scaleway-dns/)\n\nOnce the domain is imported you can either use the root zone, or create a subzone to use.\n\nIn this example we will use `example.com` as an example.\n\n## Creating Scaleway Credentials\n\nTo use ExternalDNS with Scaleway DNS, you need to create an API token (composed of the Access Key and the Secret Key).\nYou can either use existing ones or you can create a new token, as explained in [How to generate an API token](https://www.scaleway.com/en/docs/generate-an-api-token/) or directly by going to the [credentials page](https://console.scaleway.com/account/organization/credentials).\n\nScaleway provider supports configuring credentials using profiles or supplying it directly with environment variables.\n\n### Configuration using a config file\n\nYou can supply the credentials through a config file:\n\n1. Create the config file. Check out [Scaleway docs](https://github.com/scaleway/scaleway-sdk-go/blob/master/scw/README.md#scaleway-config) for instructions\n2. Mount it as a Secret into the Pod\n3. Configure environment variable `SCW_PROFILE` to match the profile name in the config file\n4. Configure environment variable `SCW_CONFIG_PATH` to match the location of the mounted config file\n\n### Configuration using environment variables\n\nTwo environment variables are needed to run ExternalDNS with Scaleway DNS:\n\n- `SCW_ACCESS_KEY` which is the Access Key.\n- `SCW_SECRET_KEY` which is the Secret Key.\n\n## Deploy ExternalDNS\n\nConnect your `kubectl` client to the cluster you want to test ExternalDNS with.\nThen apply one of the following manifests file to deploy ExternalDNS.\n\nThe following example are suited for development. For a production usage, prefer secrets over environment, and use a [tagged release](https://github.com/kubernetes-sigs/external-dns/releases).\n\n### Manifest (for clusters without RBAC enabled)\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  replicas: 1\n  selector:\n    matchLabels:\n      app: external-dns\n  strategy:\n    type: Recreate\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=service # ingress is also possible\n        - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above.\n        - --provider=scaleway\n        env:\n        - name: SCW_ACCESS_KEY\n          value: \"<your access key>\"\n        - name: SCW_SECRET_KEY\n          value: \"<your secret key>\"\n        ### Set if configuring using a config file. Make sure to create the Secret first.\n        # - name: SCW_PROFILE\n        #   value: \"<profile name>\"\n        # - name: SCW_CONFIG_PATH\n        #   value: /etc/scw/config.yaml\n    #     volumeMounts:\n    #     - name: scw-config\n    #       mountPath: /etc/scw/config.yaml\n    #       readOnly: true\n    # volumes:\n    # - name: scw-config\n    #   secret:\n    #     secretName: scw-config\n    ###\n```\n\n### Manifest (for clusters with RBAC enabled)\n\n```yaml\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: external-dns\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: external-dns\nrules:\n- apiGroups: [\"\"]\n  resources: [\"services\",\"pods\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"discovery.k8s.io\"]\n  resources: [\"endpointslices\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"extensions\"]\n  resources: [\"ingresses\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"\"]\n  resources: [\"nodes\"]\n  verbs: [\"list\",\"watch\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: external-dns-viewer\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: external-dns\nsubjects:\n- kind: ServiceAccount\n  name: external-dns\n  namespace: default\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  replicas: 1\n  selector:\n    matchLabels:\n      app: external-dns\n  strategy:\n    type: Recreate\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      serviceAccountName: external-dns\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=service # ingress is also possible\n        - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above.\n        - --provider=scaleway\n        env:\n        - name: SCW_ACCESS_KEY\n          value: \"<your access key>\"\n        - name: SCW_SECRET_KEY\n          value: \"<your secret key>\"\n        ### Set if configuring using a config file. Make sure to create the Secret first.\n        # - name: SCW_PROFILE\n        #   value: \"<profile name>\"\n        # - name: SCW_CONFIG_PATH\n        #   value: /etc/scw/config.yaml\n    #     volumeMounts:\n    #     - name: scw-config\n    #       mountPath: /etc/scw/config.yaml\n    #       readOnly: true\n    # volumes:\n    # - name: scw-config\n    #   secret:\n    #     secretName: scw-config\n    ###\n```\n\n## Deploying an Nginx Service\n\nCreate a service file called 'nginx.yaml' with the following contents:\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: nginx\nspec:\n  replicas: 1\n  selector:\n    matchLabels:\n      app: nginx\n  template:\n    metadata:\n      labels:\n        app: nginx\n    spec:\n      containers:\n      - image: nginx\n        name: nginx\n        ports:\n        - containerPort: 80\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: nginx\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: my-app.example.com\nspec:\n  selector:\n    app: nginx\n  type: LoadBalancer\n  ports:\n    - protocol: TCP\n      port: 80\n      targetPort: 80\n```\n\nNote the annotation on the service; use the same hostname as the Scaleway DNS zone created above.\n\nExternalDNS uses this annotation to determine what services should be registered with DNS. Removing the annotation will cause ExternalDNS to remove the corresponding DNS records.\n\nCreate the deployment and service:\n\n```console\nkubectl create -f nginx.yaml\n```\n\nDepending where you run your service it can take a little while for your cloud provider to create an external IP for the service.\n\nOnce the service has an external IP assigned, ExternalDNS will notice the new service IP address and synchronize the Scaleway DNS records.\n\n## Verifying Scaleway DNS records\n\nCheck your [Scaleway DNS UI](https://console.scaleway.com/domains/external) to view the records for your Scaleway DNS zone.\n\nClick on the zone for the one created above if a different domain was used.\n\nThis should show the external IP address of the service as the A record for your domain.\n\n## Cleanup\n\nNow that we have verified that ExternalDNS will automatically manage Scaleway DNS records, we can delete the tutorial's example:\n\n```sh\nkubectl delete service -f nginx.yaml\nkubectl delete service -f externaldns.yaml\n```\n"
  },
  {
    "path": "docs/tutorials/security-context.md",
    "content": "# Running ExternalDNS with limited privileges\n\nYou can run ExternalDNS with reduced privileges since `v0.5.6` using the following `SecurityContext`.\n\n```yaml\n[[% include 'security-context/extdns-limited-privilege.yaml' %]]\n```\n"
  },
  {
    "path": "docs/tutorials/transip.md",
    "content": "# TransIP\n\nThis tutorial describes how to setup ExternalDNS for usage within a Kubernetes cluster using TransIP.\n\nMake sure to use **>=0.5.14** version of ExternalDNS for this tutorial, have at least 1 domain registered at TransIP and enabled the API.\n\n## Enable TransIP API and prepare your API key\n\nTo use the TransIP API you need an account at TransIP and enable API usage as described in the [knowledge base](https://www.transip.eu/knowledgebase/entry/77-want-use-the-transip-api/). With the private key generated by the API, we create a kubernetes secret:\n\n```console\nkubectl create secret generic transip-api-key --from-file=transip-api-key=/path/to/private.key\n```\n\n## Deploy ExternalDNS\n\nBelow are example manifests, for both cluster without or with RBAC enabled. Don't forget to replace `YOUR_TRANSIP_ACCOUNT_NAME` with your TransIP account name.\nIn these examples, an example domain-filter is defined. Such a filter can be used to prevent ExternalDNS from touching any domain not listed in the filter. Refer to the docs for any other command-line parameters you might want to use.\n\n### Manifest (for clusters without RBAC enabled)\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=service # ingress is also possible\n        - --domain-filter=example.com # (optional) limit to only example.com domains\n        - --provider=transip\n        - --transip-account=YOUR_TRANSIP_ACCOUNT_NAME\n        - --transip-keyfile=/transip/transip-api-key\n        volumeMounts:\n        - mountPath: /transip\n          name: transip-api-key\n          readOnly: true\n      volumes:\n      - name: transip-api-key\n        secret:\n          secretName: transip-api-key\n```\n\n### Manifest (for clusters with RBAC enabled)\n\n```yaml\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: external-dns\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: external-dns\nrules:\n- apiGroups: [\"\"]\n  resources: [\"services\",\"pods\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"discovery.k8s.io\"]\n  resources: [\"endpointslices\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"extensions\",\"networking.k8s.io\"]\n  resources: [\"ingresses\"]\n  verbs: [\"get\",\"watch\",\"list\"]\n- apiGroups: [\"\"]\n  resources: [\"nodes\"]\n  verbs: [\"watch\", \"list\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: external-dns-viewer\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: external-dns\nsubjects:\n- kind: ServiceAccount\n  name: external-dns\n  namespace: default\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      serviceAccountName: external-dns\n      containers:\n      - name: external-dns\n        image: registry.k8s.io/external-dns/external-dns:v0.20.0\n        args:\n        - --source=service # ingress is also possible\n        - --domain-filter=example.com # (optional) limit to only example.com domains\n        - --provider=transip\n        - --transip-account=YOUR_TRANSIP_ACCOUNT_NAME\n        - --transip-keyfile=/transip/transip-api-key\n        volumeMounts:\n        - mountPath: /transip\n          name: transip-api-key\n          readOnly: true\n      volumes:\n      - name: transip-api-key\n        secret:\n          secretName: transip-api-key\n```\n\n## Deploying an Nginx Service\n\nCreate a service file called 'nginx.yaml' with the following contents:\n\n```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: nginx\nspec:\n  selector:\n    matchLabels:\n      app: nginx\n  template:\n    metadata:\n      labels:\n        app: nginx\n    spec:\n      containers:\n      - image: nginx\n        name: nginx\n        ports:\n        - containerPort: 80\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: nginx\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: my-app.example.com\nspec:\n  selector:\n    app: nginx\n  type: LoadBalancer\n  ports:\n    - protocol: TCP\n      port: 80\n      targetPort: 80\n```\n\nNote the annotation on the service; this is the name ExternalDNS will create and manage DNS records for.\n\nExternalDNS uses this annotation to determine what services should be registered with DNS. Removing the annotation will cause ExternalDNS to remove the corresponding DNS records.\n\nCreate the deployment and service:\n\n```console\nkubectl create -f nginx.yaml\n```\n\nDepending where you run your service it can take a little while for your cloud provider to create an external IP for the service.\n\nOnce the service has an external IP assigned, ExternalDNS will notice the new service IP address and synchronize the TransIP DNS records.\n\n## Verifying TransIP DNS records\n\nCheck your [TransIP Control Panel](https://transip.eu/cp) to view the records for your TransIP DNS zone.\n\nClick on the zone for the one created above if a different domain was used.\n\nThis should show the external IP address of the service as the A record for your domain.\n"
  },
  {
    "path": "docs/tutorials/webhook-provider.md",
    "content": "# Webhook provider\n\nThe \"Webhook\" provider allows integrating ExternalDNS with DNS providers through an HTTP interface.\nThe Webhook provider implements the `Provider` interface. Instead of implementing code specific to a provider, it implements an HTTP client that sends requests to an HTTP API.\nThe idea behind it is that providers can be implemented in separate programs: these programs expose an HTTP API that the Webhook provider interacts with.\nThe ideal setup for providers is to run as a sidecar in the same pod of the ExternalDNS container, listening only on localhost. This is not strictly a requirement, but we do not recommend other setups.\n\n## Architectural diagram\n\n![Webhook provider](../img/webhook-provider.png)\n\n## API guarantees\n\nProviders implementing the HTTP API have to keep in sync with changes to the JSON serialization of Go types `plan.Changes`, `endpoint.Endpoint`, and `endpoint.DomainFilter`.\nGiven the maturity of the project, we do not expect to make significant changes to those types, but can't exclude the possibility that changes will need to happen.\nWe commit to publishing changes to those in the release notes, to ensure that providers implementing the API can keep providers up to date quickly.\n\n## Implementation requirements\n\nThe following table represents the methods to implement mapped to their HTTP method and route.\n\n### Provider endpoints\n\n| Provider method | HTTP Method | Route            | Description                              |\n|-----------------|-------------|------------------|------------------------------------------|\n| Negotiate       | GET         | /                | Negotiate `DomainFilter`                 |\n| Records         | GET         | /records         | Get records                              |\n| AdjustEndpoints | POST        | /adjustendpoints | Provider specific adjustments of records |\n| ApplyChanges    | POST        | /records         | Apply record                             |\n\nOpenAPI [spec is here](../../api/webhook.yaml).\n\nExternalDNS will also make requests to the `/` endpoint for negotiation and for deserialization of the `DomainFilter`.\n\nThe server needs to respond to those requests by reading the `Accept` header and responding with a corresponding `Content-Type` header specifying the supported media type format and version.\n\nThe default recommended port for the provider endpoints is `8888`, and should listen only on `localhost` (ie: only accessible for external-dns).\n\n**NOTE**: only `5xx` responses will be retried and only `20x` will be considered as successful. All status codes different from those will be considered a failure on ExternalDNS's side.\n\n**NOTE**: the `--webhook-provider-read-timeout` and `--webhook-provider-write-timeout` flags control the outbound HTTP client timeout.\nThe total client timeout is the sum of both values and covers the full round-trip: writing the request body, waiting for the response,\nand reading the response body. Requests that exceed this deadline are cancelled and treated as a failure.\n\n### Exposed endpoints\n\n| Provider method | HTTP Method | Route    | Description                                                                                  |\n| --------------- | ----------- | -------- | -------------------------------------------------------------------------------------------- |\n| K8s probe       | GET         | /healthz | Used by `livenessProbe` and `readinessProbe`                                                 |\n| Open Metrics    | GET         | /metrics | Optional endpoint to expose [Open Metrics](https://github.com/OpenObservability/OpenMetrics) |\n\nThe default recommended port for the exposed endpoints is `8080`, and it should be bound to all interfaces (`0.0.0.0`)\n\n## Custom Annotations\n\nThe Webhook provider supports custom annotations for DNS records. This feature allows users to define additional configuration options for DNS records managed by the Webhook provider. Custom annotations are defined using the annotation format `external-dns.alpha.kubernetes.io/webhook-<custom-annotation>`.\n\nCustom annotations can be used to influence DNS record creation and updates. Providers implementing the Webhook API should document the custom annotations they support and how they affect DNS record management.\n\n## Best practices for webhook provider authors\n\n### Status codes\n\nUse the correct HTTP status codes — they directly control ExternalDNS retry behaviour:\n\n| Situation | Status code | Effect |\n| --------- | ----------- | ------ |\n| Success (`Records`, `AdjustEndpoints`) | `200 OK` | Accepted |\n| Success (`ApplyChanges`) | `204 No Content` | Accepted |\n| Transient error (rate limit, upstream timeout, etc.) | `5xx` | Retried by ExternalDNS |\n| Permanent error (bad request, auth failure, etc.) | `4xx` | Not retried; logged as failure |\n| Redirects | `3xx` | Treated as a permanent failure; do not use |\n\n### Response bodies and connection reuse\n\nExternalDNS drains response bodies before closing them so that TCP connections can be returned to the pool and reused. To keep this effective:\n\n- **Always write a complete response body**, even for errors. An empty JSON object `{}` or a plain-text message is fine.\n- **Keep error response bodies small** (well under 1 MiB). ExternalDNS caps the drain at 1 MiB; bodies larger than that cause the connection to be discarded rather than pooled, increasing latency and resource usage on both sides.\n- **Do not stream indefinitely.** Finish writing the response and close it promptly.\n\n### Timeouts and cancellation\n\nExternalDNS propagates request context to all outbound calls. When the controller shuts down or a request times out, the in-flight HTTP connection is cancelled. Providers should:\n\n- Handle abrupt connection drops gracefully — do not treat a cancelled request as a reason to roll back partially applied changes without verifying state first.\n- Respond within the deadline configured by `--webhook-provider-read-timeout` and `--webhook-provider-write-timeout` (default values apply when unset). Long-running DNS API calls should have their own internal timeout shorter than the ExternalDNS deadline.\n\n### Memory and goroutine hygiene\n\n- Close request bodies after reading them to avoid goroutine leaks on the webhook provider side.\n- Avoid holding references to decoded request payloads longer than needed; `plan.Changes` and endpoint slices can be large for zones with many records.\n\n## Provider registry\n\nTo simplify the discovery of providers, we will accept pull requests that will add links to providers in this documentation.\nThis list will only serve the purpose of simplifying finding providers and will not constitute an official endorsement of any of the externally implemented providers unless otherwise stated.\n\n## Run an ExternalDNS in-tree provider as a webhook\n\nTo test the Webhook provider and provide a reference implementation, we added the functionality to run ExternalDNS as a webhook. To run the AWS provider as a webhook, you need the following flags:\n\n```yaml\n- --webhook-server\n- --provider=aws\n- --source=ingress\n```\n\nThe value of the `--source` flag is ignored in this mode.\n\nThis will start the AWS provider as an HTTP server exposed only on localhost.\nIn a separate process/container, run ExternalDNS with `--provider=webhook`.\nThis is the same setup that we recommend for other providers and a good way to test the Webhook provider.\n"
  },
  {
    "path": "docs/version-update-playbook.md",
    "content": "# 🧭 External-DNS Version Upgrade Playbook\n\n## Overview\n\nThis playbook describes the best practices and steps to safely upgrade **External-DNS** in Kubernetes clusters.\n\nUpgrading External-DNS involves validating configuration compatibility, testing changes, and ensuring no unintended DNS record modifications occur.\n\n> Note; We strongly encourage the community to help the maintainers validate changes before they are merged or released.\n> Early validation and feedback are key to ensuring stable upgrades for everyone.\n\n---\n\n## 1. Review Release Notes\n\n- Visit the official [External-DNS Releases](https://github.com/kubernetes-sigs/external-dns/releases).\n- Review all versions between your current and target release.\n- Pay attention to:\n  - **Breaking changes** (flags, CRD fields, provider behaviors). Not all changes could be captured as breaking changes.\n  - **Deprecations**\n  - **Provider-specific updates**\n  - **Bug fixes**\n\n> ⚠️ Breaking CLI flag or annotation changes are common in `0.x` releases.\n\n---\n\n## 2. Review Helm Chart and Configuration\n\nIf using Helm:\n\n- Compare your Helm chart version to the version supporting the new app release.\n- Check for:\n  - `values.yaml` structural changes\n  - Default arguments under `extraArgs`\n  - Updates to RBAC, ServiceAccounts, or Deployment templates\n\n---\n\n## 3. Check Compatibility\n\nBefore upgrading, confirm:\n\n- The new version supports your **Kubernetes version** (e.g., 1.25+).\n- The **DNS provider** integration you use is still supported.\n\n> 💡 Watch out for deprecated Kubernetes API versions (e.g., `v1/endpoints` → `discovery.k8s.io/v1/endpointslices`).\n\n---\n\n## 4. Test in Non-Production or with Dry Run flag\n\nRun the new External-DNS version in a **staging cluster**.\n\n- Use `--dry-run` mode to preview intended changes:\n  - Validate logs for any unexpected record changes.\n  - Ensure `external-dns` correctly identifies and plans updates without actually applying them.\n  - **submit a feature request** if `dry-run` is not supported for a specific case\n\n```yaml\nargs:\n  - --dry-run\n```\n\n---\n\n5. Backup DNS State\n\nBefore applying the upgrade, take a snapshot of your DNS zone(s).\n\n**Example (AWS Route53):**\n\n```sh\naws route53 list-resource-record-sets --hosted-zone-id ZONE_ID > backup.json\n```\n\nUse equivalent tooling for your DNS provider (Cloudflare, Google Cloud DNS, etc.).\n\n> Having a backup ensures you can restore records if External-DNS misconfigures entries and you have a solid DR solution.\n\n6. Perform a Controlled Rollout\n\nInstead of upgrading in-place, use a phased rollout across multiple environments or clusters.\n\nRecommended Approaches\n\na. Multi-Cluster Rollout and Progression\n\n  1. Deploy the new `external-dns` version first in sandbox, then staging, and finally production.\n  2. Monitor each environment for correct record syncing and absence of unexpected deletions.\n  3. Promote the configuration only after validation in the lower environment.\n\nb. Read-Only Parallel Deployment\n\n  1. Run a second External-DNS instance (e.g., external-dns-readonly) with:\n\n```yaml\nargs:\n  - --dry-run\n  - ...other flags\n```\n\n  1. Observe logs and planned record updates to confirm behavior.\n  2. Observe logs and planned record updates to confirm behavior.\n\n  7. Monitor and Validate\n\nAfter deploying the new version, continuously observe both application logs and DNS synchronization metrics to ensure External-DNS behaves as expected.\n\n**Logging**\n\nCheck logs for anomalies or unexpected record changes:\n\n```yaml\nkubectl logs -n external-dns deploy/external-dns --tail=100 -f\n```\n\nLook for:\n\n- Creating record or Deleting record entries — validate these match expected changes.\n- `WARN` or `ERROR` messages, particularly related to provider authentication or permissions.\n- `TXT` registry conflicts (ownership issues between multiple instances).\n\nIf using a centralized logging stack (e.g., Loki, Elasticsearch, or CloudWatch Logs):\n\n- Create a temporary dashboard or saved query filtering for \"Creating record\" OR \"Deleting record\".\n- Correlate `external-dns` logs with DNS provider API logs to detect mismatches.\n\n**Metrics and Observability**\n\nCheck metrics exposed by External-DNS (if Prometheus scraping is enabled):\n\nFocus on:\n\n- Error rate (*_errors_total)\n- Number of syncs per interval (*_sync_duration_seconds)\n- Provider API call spikes\n\nExample PromQL checks:\n\n```promql\nrate(external_dns_registry_errors_total[5m]) > 0\nrate(external_dns_provider_requests_total{operation=\"DELETE\"}[5m])\n```\n\n## External Verification\n\nIdeally, you should have a set of automated tests\n\nQuery key DNS records directly:\n\n```sh\ndig +short myapp.example.com\nnslookup api.staging.example.com\n```\n\nEnsure that A, CNAME, and TXT records remain correct and point to expected endpoints.\n\nAdditional Tips\n\n- Automate upgrade testing with CI/CD pipelines.\n- Maintain clear CHANGELOGs and migration notes for internal users.\n- Tag known good versions in Git or Helm values for rollback.\n- Avoid skipping multiple minor versions when possible.\n"
  },
  {
    "path": "e2e/deployment.yaml",
    "content": "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n  labels:\n    app: demo-app\n  name: demo-app\nspec:\n  replicas: 1\n  selector:\n    matchLabels:\n      app: demo-app\n  template:\n    metadata:\n      labels:\n        app: demo-app\n    spec:\n      containers:\n      - image: traefik/whoami:latest # minimal demo app\n        name: demo-app\n"
  },
  {
    "path": "e2e/provider/coredns.yaml",
    "content": "---\napiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: coredns\n  namespace: default\ndata:\n  Corefile: |\n    external.dns:5353 {\n        errors\n        log\n        etcd {\n            stubzones\n            path /skydns\n            endpoint http://etcd-0.etcd:2379\n        }\n        cache 30\n        forward . /etc/resolv.conf\n    }\n---\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: coredns\n  namespace: default\n  labels:\n    app: coredns\nspec:\n  replicas: 1\n  selector:\n    matchLabels:\n      app: coredns\n  template:\n    metadata:\n      labels:\n        app: coredns\n    spec:\n      hostNetwork: true\n      dnsPolicy: ClusterFirstWithHostNet\n      containers:\n      - name: coredns\n        image: coredns/coredns:1.13.1\n        args: [ \"-conf\", \"/etc/coredns/Corefile\" ]\n        volumeMounts:\n        - name: config-volume\n          mountPath: /etc/coredns\n        ports:\n        - containerPort: 5353\n          name: dns\n          protocol: UDP\n        - containerPort: 5353\n          name: dns-tcp\n          protocol: TCP\n        livenessProbe:\n          tcpSocket:\n            port: 5353\n          initialDelaySeconds: 10\n          periodSeconds: 10\n        readinessProbe:\n          tcpSocket:\n            port: 5353\n          initialDelaySeconds: 5\n          periodSeconds: 5\n      volumes:\n      - name: config-volume\n        configMap:\n          name: coredns\n          items:\n          - key: Corefile\n            path: Corefile\n---\napiVersion: v1\nkind: Service\nmetadata:\n  name: coredns\n  namespace: default\n  labels:\n    app: coredns\nspec:\n  selector:\n    app: coredns\n  ports:\n  - name: dns\n    port: 5353\n    targetPort: 5353\n    protocol: UDP\n  - name: dns-tcp\n    port: 5353\n    targetPort: 5353\n    protocol: TCP\n"
  },
  {
    "path": "e2e/provider/etcd.yaml",
    "content": "---\napiVersion: v1\nkind: Service\nmetadata:\n  name: etcd\n  namespace: default\nspec:\n  type: ClusterIP\n  clusterIP: None\n  selector:\n    app: etcd\n  publishNotReadyAddresses: true\n  ports:\n  - name: etcd-client\n    port: 2379\n  - name: etcd-server\n    port: 2380\n  - name: etcd-metrics\n    port: 8080\n---\napiVersion: apps/v1\nkind: StatefulSet\nmetadata:\n  namespace: default\n  name: etcd\nspec:\n  serviceName: etcd\n  replicas: 1\n  podManagementPolicy: Parallel\n  updateStrategy:\n    type: RollingUpdate\n  selector:\n    matchLabels:\n      app: etcd\n  template:\n    metadata:\n      labels:\n        app: etcd\n      annotations:\n        serviceName: etcd\n    spec:\n      affinity:\n        podAntiAffinity:\n          requiredDuringSchedulingIgnoredDuringExecution:\n          - labelSelector:\n              matchExpressions:\n              - key: app\n                operator: In\n                values:\n                - etcd\n            topologyKey: \"kubernetes.io/hostname\"\n      containers:\n      - name: etcd\n        image: quay.io/coreos/etcd:v3.6.0\n        imagePullPolicy: IfNotPresent\n        ports:\n        - name: etcd-client\n          containerPort: 2379\n        - name: etcd-server\n          containerPort: 2380\n        - name: etcd-metrics\n          containerPort: 8080\n        readinessProbe:\n          httpGet:\n            path: /readyz\n            port: 8080\n          initialDelaySeconds: 10\n          periodSeconds: 5\n          timeoutSeconds: 5\n          successThreshold: 1\n          failureThreshold: 30\n        livenessProbe:\n          httpGet:\n            path: /livez\n            port: 8080\n          initialDelaySeconds: 15\n          periodSeconds: 10\n          timeoutSeconds: 5\n          failureThreshold: 3\n        env:\n        - name: K8S_NAMESPACE\n          valueFrom:\n            fieldRef:\n             fieldPath: metadata.namespace\n        - name: HOSTNAME\n          valueFrom:\n            fieldRef:\n             fieldPath: metadata.name\n        - name: SERVICE_NAME\n          valueFrom:\n            fieldRef:\n              fieldPath: metadata.annotations['serviceName']\n        - name: ETCDCTL_ENDPOINTS\n          value: $(HOSTNAME).$(SERVICE_NAME):2379\n        - name: URI_SCHEME\n          value: \"http\"\n        command:\n        - /usr/local/bin/etcd\n        args:\n        - --name=$(HOSTNAME)\n        - --data-dir=/data\n        - --wal-dir=/data/wal\n        - --listen-peer-urls=$(URI_SCHEME)://0.0.0.0:2380\n        - --listen-client-urls=$(URI_SCHEME)://0.0.0.0:2379\n        - --advertise-client-urls=$(URI_SCHEME)://$(HOSTNAME).$(SERVICE_NAME):2379\n        - --initial-cluster-state=new\n        - --initial-cluster-token=etcd-$(K8S_NAMESPACE)\n        - --initial-cluster=etcd-0=$(URI_SCHEME)://etcd-0.$(SERVICE_NAME):2380\n        - --initial-advertise-peer-urls=$(URI_SCHEME)://$(HOSTNAME).$(SERVICE_NAME):2380\n        - --listen-metrics-urls=http://0.0.0.0:8080\n        volumeMounts:\n        - name: etcd-data\n          mountPath: /data\n  volumeClaimTemplates:\n  - metadata:\n      name: etcd-data\n    spec:\n      accessModes: [\"ReadWriteOnce\"]\n      resources:\n        requests:\n          storage: 1Gi\n"
  },
  {
    "path": "e2e/service.yaml",
    "content": "apiVersion: v1\nkind: Service\nmetadata:\n  labels:\n    app: demo-app\n  name: demo-app\n  annotations:\n    external-dns.alpha.kubernetes.io/hostname: externaldns-e2e.external.dns\nspec:\n  ports:\n  - port: 80\n    protocol: TCP\n    targetPort: 8080\n  selector:\n    app: demo-app\n  clusterIP: None\n"
  },
  {
    "path": "endpoint/OWNERS",
    "content": "# See the OWNERS docs at https://go.k8s.io/owners\n\nlabels:\n- endpoint\n"
  },
  {
    "path": "endpoint/crypto.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage endpoint\n\nimport (\n\t\"bytes\"\n\t\"compress/gzip\"\n\t\"crypto/aes\"\n\t\"crypto/cipher\"\n\t\"crypto/rand\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"io\"\n)\n\nconst standardGcmNonceSize = 12\n\n// GenerateNonce creates a random base64-encoded nonce of a fixed size.\nfunc GenerateNonce() (string, error) {\n\tnonce := make([]byte, standardGcmNonceSize)\n\tif _, err := io.ReadFull(rand.Reader, nonce); err != nil {\n\t\treturn \"\", err\n\t}\n\treturn base64.StdEncoding.EncodeToString(nonce), nil\n}\n\n// EncryptText gzips input data and encrypts it using the supplied AES key.\n// nonceEncoded must be a base64-encoded nonce of standardGcmNonceSize bytes.\nfunc EncryptText(text string, aesKey []byte, nonceEncoded string) (string, error) {\n\tif len(nonceEncoded) == 0 {\n\t\treturn \"\", fmt.Errorf(\"nonce must be provided\")\n\t}\n\n\tblock, err := aes.NewCipher(aesKey)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tgcm, err := cipher.NewGCMWithNonceSize(block, standardGcmNonceSize)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tnonce := make([]byte, standardGcmNonceSize)\n\tif _, err = base64.StdEncoding.Decode(nonce, []byte(nonceEncoded)); err != nil {\n\t\treturn \"\", err\n\t}\n\n\tdata, err := compressData([]byte(text))\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tcipherData := gcm.Seal(nonce, nonce, data, nil)\n\treturn base64.StdEncoding.EncodeToString(cipherData), nil\n}\n\n// DecryptText decrypts data using the supplied AES encryption key and decompresses it.\n// Returns the plaintext, the base64-encoded nonce, and any error.\nfunc DecryptText(text string, aesKey []byte) (string, string, error) {\n\tblock, err := aes.NewCipher(aesKey)\n\tif err != nil {\n\t\treturn \"\", \"\", err\n\t}\n\tgcm, err := cipher.NewGCMWithNonceSize(block, standardGcmNonceSize)\n\tif err != nil {\n\t\treturn \"\", \"\", err\n\t}\n\tdata, err := base64.StdEncoding.DecodeString(text)\n\tif err != nil {\n\t\treturn \"\", \"\", err\n\t}\n\tif len(data) <= standardGcmNonceSize {\n\t\treturn \"\", \"\", fmt.Errorf(\"encrypted data too short: got %d bytes, need more than %d\", len(data), standardGcmNonceSize)\n\t}\n\tnonce, ciphertext := data[:standardGcmNonceSize], data[standardGcmNonceSize:]\n\tplaindata, err := gcm.Open(nil, nonce, ciphertext, nil)\n\tif err != nil {\n\t\treturn \"\", \"\", err\n\t}\n\tplaindata, err = decompressData(plaindata)\n\tif err != nil {\n\t\treturn \"\", \"\", err\n\t}\n\n\treturn string(plaindata), base64.StdEncoding.EncodeToString(nonce), nil\n}\n\n// decompressData decompresses gzip-compressed data.\nfunc decompressData(data []byte) ([]byte, error) {\n\tgz, err := gzip.NewReader(bytes.NewBuffer(data))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer gz.Close()\n\tvar b bytes.Buffer\n\tif _, err = b.ReadFrom(gz); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn b.Bytes(), nil\n}\n\n// compressData compresses data using gzip to minimize storage in the registry.\nfunc compressData(data []byte) ([]byte, error) {\n\tvar b bytes.Buffer\n\tgz, err := gzip.NewWriterLevel(&b, gzip.BestCompression)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif _, err = gz.Write(data); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err = gz.Flush(); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif err = gz.Close(); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn b.Bytes(), nil\n}\n"
  },
  {
    "path": "endpoint/crypto_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage endpoint\n\nimport (\n\t\"encoding/base64\"\n\t\"io\"\n\t\"testing\"\n\n\t\"crypto/rand\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestEncrypt(t *testing.T) {\n\t// Verify that nil nonce is rejected\n\taesKey := []byte(\"s%zF`.*'5`9.AhI2!B,.~hmbs^.*TL?;\")\n\tplaintext := \"Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.\"\n\t_, err := EncryptText(plaintext, aesKey, \"\")\n\trequire.EqualError(t, err, \"nonce must be provided\")\n\n\t// Verify that text encryption and decryption works with a generated nonce\n\tnonce, err := GenerateNonce()\n\trequire.NoError(t, err)\n\tencryptedtext, err := EncryptText(plaintext, aesKey, nonce)\n\trequire.NoError(t, err)\n\tdecryptedtext, _, err := DecryptText(encryptedtext, aesKey)\n\trequire.NoError(t, err)\n\tif plaintext != decryptedtext {\n\t\tt.Errorf(\"Original plain text %#v differs from the resulting decrypted text %#v\", plaintext, decryptedtext)\n\t}\n\n\t// Verify that decrypt returns an error and empty data if wrong AES encryption key is used\n\tdecryptedtext, _, err = DecryptText(encryptedtext, []byte(\"s'J!jD`].LC?g&Oa11AgTub,j48ts/96\"))\n\trequire.Error(t, err)\n\tif decryptedtext != \"\" {\n\t\tt.Error(\"Data decryption failed, empty string should be as result\")\n\t}\n\n\t// Verify that decrypt returns an error and empty data if unencrypted input is supplied\n\tdecryptedtext, _, err = DecryptText(plaintext, aesKey)\n\trequire.Error(t, err)\n\tif decryptedtext != \"\" {\n\t\tt.Errorf(\"Data decryption failed, empty string should be as result\")\n\t}\n\n\t// Verify that a known encrypted text is decrypted to what is expected\n\tencryptedtext = \"0Mfzf6wsN8llrfX0ucDZ6nlc2+QiQfKKedjPPLu5atb2I35L9nUZeJcCnuLVW7CVW3K0h94vSuBLdXnMrj8Vcm0M09shxaoF48IcCpD03XtQbKXqk2hPbsW6+JybvplHIQGr16/PcjUSObGmR9yjf38+qEltApkKvrPjsyw43BX4eE10rL0Bln33UJD7/w+zazRDPFlAcbGtkt0ETKHnvyB3/aCddLipvrhjCXj2ZY/ktRF6h716kJRgXU10dCIQHFYU45MIdxI+k10HK3yZqhI2V0Gp2xjrFV/LRQ7/OS9SFee4asPWUYxbCEsnOzp8qc0dCPFSo1dtADzWnUZnsAcbnjtudT4milfLJc5CxDk1v3ykqQ/ajejwHjWQ7b8U6AsTErbezfdcqrb5IzkLgHb5TosnfrdDmNc9GcKfpsrCHbVY8KgNwMVdtwavLv7d9WM6sooUlZ3t0sABGkzagXQmPRvwLnkSOlie5XrnzWo8/8/4UByLga29CaXO\"\n\tdecryptedtext, _, err = DecryptText(encryptedtext, aesKey)\n\trequire.NoError(t, err)\n\tif decryptedtext != plaintext {\n\t\tt.Error(\"Decryption of text didn't result in expected plaintext result.\")\n\t}\n}\n\nfunc TestGenerateNonceSuccess(t *testing.T) {\n\tnonce, err := GenerateNonce()\n\trequire.NoError(t, err)\n\trequire.NotEmpty(t, nonce)\n\n\t// Test nonce length\n\tdecodedNonce, err := base64.StdEncoding.DecodeString(nonce)\n\trequire.NoError(t, err)\n\trequire.Len(t, decodedNonce, standardGcmNonceSize)\n}\n\nfunc TestGenerateNonceError(t *testing.T) {\n\t// Save the original rand.Reader\n\toriginalRandReader := rand.Reader\n\tdefer func() { rand.Reader = originalRandReader }()\n\n\t// Replace rand.Reader with a faulty reader\n\trand.Reader = &faultyReader{}\n\n\tnonce, err := GenerateNonce()\n\trequire.Error(t, err)\n\trequire.Empty(t, nonce)\n}\n\ntype faultyReader struct{}\n\nfunc (f *faultyReader) Read(_ []byte) (int, error) {\n\treturn 0, io.ErrUnexpectedEOF\n}\n"
  },
  {
    "path": "endpoint/domain_filter.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage endpoint\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"regexp\"\n\t\"sort\"\n\t\"strings\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"sigs.k8s.io/external-dns/internal/idna\"\n)\n\ntype MatchAllDomainFilters []DomainFilterInterface\n\nfunc (f MatchAllDomainFilters) Match(domain string) bool {\n\tfor _, filter := range f {\n\t\tif filter == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif !filter.Match(domain) {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\ntype DomainFilterInterface interface {\n\tMatch(domain string) bool\n}\n\n// DomainFilter holds a lists of valid domain names\ntype DomainFilter struct {\n\t// Filters define what domains to match\n\tFilters []string\n\t// exclude define what domains not to match\n\texclude []string\n\t// regex defines a regular expression to match the domains\n\tregex *regexp.Regexp\n\t// regexExclusion defines a regular expression to exclude the domains matched\n\tregexExclusion *regexp.Regexp\n}\n\nvar _ DomainFilterInterface = &DomainFilter{}\n\n// domainFilterSerde is a helper type for serializing and deserializing DomainFilter.\ntype domainFilterSerde struct {\n\tInclude      []string `json:\"include,omitempty\"`\n\tExclude      []string `json:\"exclude,omitempty\"`\n\tRegexInclude string   `json:\"regexInclude,omitempty\"`\n\tRegexExclude string   `json:\"regexExclude,omitempty\"`\n}\n\n// prepareFilters provides consistent trimming for filters/exclude params\nfunc prepareFilters(filters []string) []string {\n\tvar fs []string\n\tfor _, filter := range filters {\n\t\tif domain := normalizeDomain(strings.TrimSpace(filter)); domain != \"\" {\n\t\t\tfs = append(fs, domain)\n\t\t}\n\t}\n\treturn fs\n}\n\n// NewDomainFilterWithExclusions returns a new DomainFilter, given a list of matches and exclusions\nfunc NewDomainFilterWithExclusions(domainFilters []string, excludeDomains []string) *DomainFilter {\n\treturn &DomainFilter{Filters: prepareFilters(domainFilters), exclude: prepareFilters(excludeDomains)}\n}\n\n// NewDomainFilter returns a new DomainFilter given a comma separated list of domains\nfunc NewDomainFilter(domainFilters []string) *DomainFilter {\n\treturn &DomainFilter{Filters: prepareFilters(domainFilters)}\n}\n\n// NewRegexDomainFilter returns a new DomainFilter given a regular expression\nfunc NewRegexDomainFilter(regexDomainFilter *regexp.Regexp, regexDomainExclusion *regexp.Regexp) *DomainFilter {\n\treturn &DomainFilter{regex: regexDomainFilter, regexExclusion: regexDomainExclusion}\n}\n\n// NewDomainFilterWithOptions creates a DomainFilter based on the provided parameters.\n//\n// Example usage:\n// df := NewDomainFilterWithOptions(\n//\n//\tWithDomainFilter([]string{\"example.com\"}),\n//\tWithDomainExclude([]string{\"test.com\"}),\n//\n// )\nfunc NewDomainFilterWithOptions(opts ...DomainFilterOption) *DomainFilter {\n\tcfg := &domainFilterConfig{}\n\tfor _, opt := range opts {\n\t\topt(cfg)\n\t}\n\tif cfg.isRegexFilter {\n\t\treturn NewRegexDomainFilter(cfg.regexInclude, cfg.regexExclude)\n\t}\n\treturn NewDomainFilterWithExclusions(cfg.include, cfg.exclude)\n}\n\n// Match checks whether a domain can be found in the DomainFilter.\n// RegexFilter takes precedence over Filters\nfunc (df *DomainFilter) Match(domain string) bool {\n\tif df == nil {\n\t\treturn true // nil filter matches everything\n\t}\n\tif df.regex != nil && df.regex.String() != \"\" || df.regexExclusion != nil && df.regexExclusion.String() != \"\" {\n\t\treturn matchRegex(df.regex, df.regexExclusion, domain)\n\t}\n\n\treturn matchFilter(df.Filters, domain, true) && !matchFilter(df.exclude, domain, false)\n}\n\n// matchFilter determines if any `filters` match `domain`.\n// If no `filters` are provided, behavior depends on `emptyval`\n// (empty `df.filters` matches everything, while empty `df.exclude` excludes nothing)\nfunc matchFilter(filters []string, domain string, emptyval bool) bool {\n\tif len(filters) == 0 {\n\t\treturn emptyval\n\t}\n\n\tstrippedDomain := normalizeDomain(domain)\n\tfor _, filter := range filters {\n\t\tif filter == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tswitch {\n\t\tcase strings.HasPrefix(filter, \".\") && strings.HasSuffix(strippedDomain, filter):\n\t\t\treturn true\n\t\tcase strings.Count(strippedDomain, \".\") == strings.Count(filter, \".\") && strippedDomain == filter:\n\t\t\treturn true\n\t\tcase strings.HasSuffix(strippedDomain, \".\"+filter):\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// matchRegex determines if a domain matches the configured regular expressions in DomainFilter.\n// The function checks exclusion first, then inclusion:\n// 1. If negativeRegex is set and matches the domain, return false (excluded)\n// 2. If regex is set and matches the domain, return true (included)\n// 3. If regex is not set but negativeRegex is set, return true (not excluded, no inclusion filter)\n// 4. If regex is set but doesn't match, return false (not included)\nfunc matchRegex(regex *regexp.Regexp, negativeRegex *regexp.Regexp, domain string) bool {\n\tstrippedDomain := normalizeDomain(domain)\n\n\t// First check exclusion - if domain matches exclusion, reject it\n\tif negativeRegex != nil && negativeRegex.String() != \"\" {\n\t\tif negativeRegex.MatchString(strippedDomain) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Then check inclusion filter if set\n\tif regex != nil && regex.String() != \"\" {\n\t\treturn regex.MatchString(strippedDomain)\n\t}\n\n\t// If only exclusion is set (no inclusion filter), accept the domain\n\t// since it didn't match the exclusion\n\treturn true\n}\n\n// IsConfigured returns true if any inclusion or exclusion rules have been specified.\nfunc (df *DomainFilter) IsConfigured() bool {\n\tif df == nil {\n\t\treturn false // nil filter is not configured\n\t}\n\tif df.regex != nil && df.regex.String() != \"\" {\n\t\treturn true\n\t} else if df.regexExclusion != nil && df.regexExclusion.String() != \"\" {\n\t\treturn true\n\t}\n\treturn len(df.Filters) > 0 || len(df.exclude) > 0\n}\n\nfunc (df *DomainFilter) MarshalJSON() ([]byte, error) {\n\tif df == nil {\n\t\t// compatibility with nil DomainFilter\n\t\treturn json.Marshal(domainFilterSerde{\n\t\t\tInclude: nil,\n\t\t\tExclude: nil,\n\t\t})\n\t}\n\tif df.regex != nil || df.regexExclusion != nil {\n\t\tvar include, exclude string\n\t\tif df.regex != nil {\n\t\t\tinclude = df.regex.String()\n\t\t}\n\t\tif df.regexExclusion != nil {\n\t\t\texclude = df.regexExclusion.String()\n\t\t}\n\t\treturn json.Marshal(domainFilterSerde{\n\t\t\tRegexInclude: include,\n\t\t\tRegexExclude: exclude,\n\t\t})\n\t}\n\tsort.Strings(df.Filters)\n\tsort.Strings(df.exclude)\n\treturn json.Marshal(domainFilterSerde{\n\t\tInclude: df.Filters,\n\t\tExclude: df.exclude,\n\t})\n}\n\nfunc (df *DomainFilter) UnmarshalJSON(b []byte) error {\n\tvar deserialized domainFilterSerde\n\terr := json.Unmarshal(b, &deserialized)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif deserialized.RegexInclude == \"\" && deserialized.RegexExclude == \"\" {\n\t\t*df = *NewDomainFilterWithExclusions(deserialized.Include, deserialized.Exclude)\n\t\treturn nil\n\t}\n\n\tif len(deserialized.Include) > 0 || len(deserialized.Exclude) > 0 {\n\t\treturn errors.New(\"cannot have both domain list and regex\")\n\t}\n\n\tvar include, exclude *regexp.Regexp\n\tif deserialized.RegexInclude != \"\" {\n\t\tinclude, err = regexp.Compile(deserialized.RegexInclude)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"invalid regexInclude: %w\", err)\n\t\t}\n\t}\n\tif deserialized.RegexExclude != \"\" {\n\t\texclude, err = regexp.Compile(deserialized.RegexExclude)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"invalid regexExclude: %w\", err)\n\t\t}\n\t}\n\t*df = *NewRegexDomainFilter(include, exclude)\n\treturn nil\n}\n\nfunc (df *DomainFilter) MatchParent(domain string) bool {\n\tif df == nil {\n\t\treturn true // nil filter matches everything\n\t}\n\tif matchFilter(df.exclude, domain, false) {\n\t\treturn false\n\t}\n\tif len(df.Filters) == 0 {\n\t\treturn true\n\t}\n\n\tstrippedDomain := normalizeDomain(domain)\n\tfor _, filter := range df.Filters {\n\t\tif filter == \"\" || strings.HasPrefix(filter, \".\") {\n\t\t\t// We don't check parents if the filter is prefixed with \".\"\n\t\t\tcontinue\n\t\t}\n\t\tif strings.HasSuffix(filter, \".\"+strippedDomain) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// normalizeDomain converts a domain to a canonical form, so that we can filter on it\n// it: trim \".\" suffix, get Unicode version of domain compliant with Section 5 of RFC 5891\nfunc normalizeDomain(domain string) string {\n\ts, err := idna.Profile.ToUnicode(strings.TrimSuffix(domain, \".\"))\n\tif err != nil {\n\t\tlog.Warnf(`Got error while parsing domain %s: %v`, domain, err)\n\t}\n\treturn s\n}\n\ntype DomainFilterOption func(*domainFilterConfig)\ntype domainFilterConfig struct {\n\tinclude       []string\n\texclude       []string\n\tregexInclude  *regexp.Regexp\n\tregexExclude  *regexp.Regexp\n\tisRegexFilter bool\n}\n\nfunc WithDomainFilter(filters []string) DomainFilterOption {\n\treturn func(cfg *domainFilterConfig) {\n\t\tcfg.include = prepareFilters(filters)\n\t}\n}\n\nfunc WithDomainExclude(exclude []string) DomainFilterOption {\n\treturn func(cfg *domainFilterConfig) {\n\t\tcfg.exclude = prepareFilters(exclude)\n\t}\n}\n\nfunc WithRegexDomainFilter(regex *regexp.Regexp) DomainFilterOption {\n\treturn func(cfg *domainFilterConfig) {\n\t\tcfg.regexInclude = regex\n\t\tif regex != nil && regex.String() != \"\" {\n\t\t\tcfg.isRegexFilter = true\n\t\t}\n\t}\n}\n\nfunc WithRegexDomainExclude(regex *regexp.Regexp) DomainFilterOption {\n\treturn func(cfg *domainFilterConfig) {\n\t\tcfg.regexExclude = regex\n\t\tif regex != nil && regex.String() != \"\" {\n\t\t\tcfg.isRegexFilter = true\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "endpoint/domain_filter_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage endpoint\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"regexp\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\ntype domainFilterTest struct {\n\tdomainFilter          []string\n\texclusions            []string\n\tdomains               []string\n\texpected              bool\n\texpectedSerialization map[string][]string\n}\n\ntype regexDomainFilterTest struct {\n\tregex                 *regexp.Regexp\n\tregexExclusion        *regexp.Regexp\n\tdomains               []string\n\texpected              bool\n\texpectedSerialization map[string]string\n}\n\nvar domainFilterTests = []domainFilterTest{\n\t{\n\t\t[]string{\"google.com.\", \"exaring.de\", \"inovex.de\"},\n\t\t[]string{},\n\t\t[]string{\"google.com\", \"exaring.de\", \"inovex.de\"},\n\t\ttrue,\n\t\tmap[string][]string{\n\t\t\t\"include\": {\"exaring.de\", \"google.com\", \"inovex.de\"},\n\t\t},\n\t},\n\t{\n\t\t[]string{\"google.com.\", \"exaring.de\", \"inovex.de\"},\n\t\t[]string{},\n\t\t[]string{\"google.com\", \"exaring.de\", \"inovex.de\"},\n\t\ttrue,\n\t\tmap[string][]string{\n\t\t\t\"include\": {\"exaring.de\", \"google.com\", \"inovex.de\"},\n\t\t},\n\t},\n\t{\n\t\t[]string{\"google.com.\", \"exaring.de.\", \"inovex.de\"},\n\t\t[]string{},\n\t\t[]string{\"google.com\", \"exaring.de\", \"inovex.de\"},\n\t\ttrue,\n\t\tmap[string][]string{\n\t\t\t\"include\": {\"exaring.de\", \"google.com\", \"inovex.de\"},\n\t\t},\n\t},\n\t{\n\t\t[]string{\"foo.org.      \"},\n\t\t[]string{},\n\t\t[]string{\"foo.org\"},\n\t\ttrue,\n\t\tmap[string][]string{\n\t\t\t\"include\": {\"foo.org\"},\n\t\t},\n\t},\n\t{\n\t\t[]string{\"   foo.org\"},\n\t\t[]string{},\n\t\t[]string{\"foo.org\"},\n\t\ttrue,\n\t\tmap[string][]string{\n\t\t\t\"include\": {\"foo.org\"},\n\t\t},\n\t},\n\t{\n\t\t[]string{\"foo.org.\"},\n\t\t[]string{},\n\t\t[]string{\"foo.org\"},\n\t\ttrue,\n\t\tmap[string][]string{\n\t\t\t\"include\": {\"foo.org\"},\n\t\t},\n\t},\n\t{\n\t\t[]string{\"foo.org.\"},\n\t\t[]string{},\n\t\t[]string{\"baz.org\"},\n\t\tfalse,\n\t\tmap[string][]string{\n\t\t\t\"include\": {\"foo.org\"},\n\t\t},\n\t},\n\t{\n\t\t[]string{\"baz.foo.org.\"},\n\t\t[]string{},\n\t\t[]string{\"foo.org\"},\n\t\tfalse,\n\t\tmap[string][]string{\n\t\t\t\"include\": {\"baz.foo.org\"},\n\t\t},\n\t},\n\t{\n\t\t[]string{\"\", \"foo.org.\"},\n\t\t[]string{},\n\t\t[]string{\"foo.org\"},\n\t\ttrue,\n\t\tmap[string][]string{\n\t\t\t\"include\": {\"foo.org\"},\n\t\t},\n\t},\n\t{\n\t\t[]string{\"\", \"foo.org.\"},\n\t\t[]string{},\n\t\t[]string{},\n\t\ttrue,\n\t\tmap[string][]string{\n\t\t\t\"include\": {\"foo.org\"},\n\t\t},\n\t},\n\t{\n\t\t[]string{\"\"},\n\t\t[]string{},\n\t\t[]string{\"foo.org\"},\n\t\ttrue,\n\t\tmap[string][]string{},\n\t},\n\t{\n\t\t[]string{\"\"},\n\t\t[]string{},\n\t\t[]string{},\n\t\ttrue,\n\t\tmap[string][]string{},\n\t},\n\t{\n\t\t[]string{\" \"},\n\t\t[]string{},\n\t\t[]string{},\n\t\ttrue,\n\t\tmap[string][]string{},\n\t},\n\t{\n\t\t[]string{\"bar.sub.example.org\"},\n\t\t[]string{},\n\t\t[]string{\"foo.bar.sub.example.org\"},\n\t\ttrue,\n\t\tmap[string][]string{\n\t\t\t\"include\": {\"bar.sub.example.org\"},\n\t\t},\n\t},\n\t{\n\t\t[]string{\"example.org\"},\n\t\t[]string{},\n\t\t[]string{\"anexample.org\", \"test.anexample.org\"},\n\t\tfalse,\n\t\tmap[string][]string{\n\t\t\t\"include\": {\"example.org\"},\n\t\t},\n\t},\n\t{\n\t\t[]string{\".example.org\"},\n\t\t[]string{},\n\t\t[]string{\"anexample.org\", \"test.anexample.org\"},\n\t\tfalse,\n\t\tmap[string][]string{\n\t\t\t\"include\": {\".example.org\"},\n\t\t},\n\t},\n\t{\n\t\t[]string{\".example.org\"},\n\t\t[]string{},\n\t\t[]string{\"example.org\"},\n\t\tfalse,\n\t\tmap[string][]string{\n\t\t\t\"include\": {\".example.org\"},\n\t\t},\n\t},\n\t{\n\t\t[]string{\".example.org\"},\n\t\t[]string{},\n\t\t[]string{\"test.example.org\"},\n\t\ttrue,\n\t\tmap[string][]string{\n\t\t\t\"include\": {\".example.org\"},\n\t\t},\n\t},\n\t{\n\t\t[]string{\"anexample.org\"},\n\t\t[]string{},\n\t\t[]string{\"example.org\", \"test.example.org\"},\n\t\tfalse,\n\t\tmap[string][]string{\n\t\t\t\"include\": {\"anexample.org\"},\n\t\t},\n\t},\n\t{\n\t\t[]string{\".org\"},\n\t\t[]string{},\n\t\t[]string{\"example.org\", \"test.example.org\", \"foo.test.example.org\"},\n\t\ttrue,\n\t\tmap[string][]string{\n\t\t\t\"include\": {\".org\"},\n\t\t},\n\t},\n\t{\n\t\t[]string{\"example.org\"},\n\t\t[]string{\"api.example.org\"},\n\t\t[]string{\"example.org\", \"test.example.org\", \"foo.test.example.org\"},\n\t\ttrue,\n\t\tmap[string][]string{\n\t\t\t\"include\": {\"example.org\"},\n\t\t\t\"exclude\": {\"api.example.org\"},\n\t\t},\n\t},\n\t{\n\t\t[]string{\"example.org\"},\n\t\t[]string{\"api.example.org\"},\n\t\t[]string{\"foo.api.example.org\", \"api.example.org\"},\n\t\tfalse,\n\t\tmap[string][]string{\n\t\t\t\"include\": {\"example.org\"},\n\t\t\t\"exclude\": {\"api.example.org\"},\n\t\t},\n\t},\n\t{\n\t\t[]string{\"   example.org. \"},\n\t\t[]string{\"   .api.example.org    \"},\n\t\t[]string{\"foo.api.example.org\", \"bar.baz.api.example.org.\"},\n\t\tfalse,\n\t\tmap[string][]string{\n\t\t\t\"include\": {\"example.org\"},\n\t\t\t\"exclude\": {\".api.example.org\"},\n\t\t},\n\t},\n\t{\n\t\t[]string{\"æøå.org\"},\n\t\t[]string{\"api.æøå.org\"},\n\t\t[]string{\"foo.api.æøå.org\", \"api.æøå.org\"},\n\t\tfalse,\n\t\tmap[string][]string{\n\t\t\t\"include\": {\"æøå.org\"},\n\t\t\t\"exclude\": {\"api.æøå.org\"},\n\t\t},\n\t},\n\t{\n\t\t[]string{\"   æøå.org. \"},\n\t\t[]string{\"   .api.æøå.org    \"},\n\t\t[]string{\"foo.api.æøå.org\", \"bar.baz.api.æøå.org.\"},\n\t\tfalse,\n\t\tmap[string][]string{\n\t\t\t\"include\": {\"æøå.org\"},\n\t\t\t\"exclude\": {\".api.æøå.org\"},\n\t\t},\n\t},\n\t{\n\t\t[]string{\"example.org.\"},\n\t\t[]string{\"api.example.org\"},\n\t\t[]string{\"dev-api.example.org\", \"qa-api.example.org\"},\n\t\ttrue,\n\t\tmap[string][]string{\n\t\t\t\"include\": {\"example.org\"},\n\t\t\t\"exclude\": {\"api.example.org\"},\n\t\t},\n\t},\n\t{\n\t\t[]string{\"example.org.\"},\n\t\t[]string{\"api.example.org\"},\n\t\t[]string{\"dev.api.example.org\", \"qa.api.example.org\"},\n\t\tfalse,\n\t\tmap[string][]string{\n\t\t\t\"include\": {\"example.org\"},\n\t\t\t\"exclude\": {\"api.example.org\"},\n\t\t},\n\t},\n\t{\n\t\t[]string{\"example.org\", \"api.example.org\"},\n\t\t[]string{\"internal.api.example.org\"},\n\t\t[]string{\"foo.api.example.org\"},\n\t\ttrue,\n\t\tmap[string][]string{\n\t\t\t\"include\": {\"api.example.org\", \"example.org\"},\n\t\t\t\"exclude\": {\"internal.api.example.org\"},\n\t\t},\n\t},\n\t{\n\t\t[]string{\"example.org\", \"api.example.org\"},\n\t\t[]string{\"internal.api.example.org\"},\n\t\t[]string{\"foo.internal.api.example.org\"},\n\t\tfalse,\n\t\tmap[string][]string{\n\t\t\t\"include\": {\"api.example.org\", \"example.org\"},\n\t\t\t\"exclude\": {\"internal.api.example.org\"},\n\t\t},\n\t},\n\t{\n\t\t[]string{\"eXaMPle.ORG\", \"API.example.ORG\"},\n\t\t[]string{\"Foo-Bar.Example.Org\"},\n\t\t[]string{\"FoOoo.Api.Example.Org\"},\n\t\ttrue,\n\t\tmap[string][]string{\n\t\t\t\"include\": {\"api.example.org\", \"example.org\"},\n\t\t\t\"exclude\": {\"foo-bar.example.org\"},\n\t\t},\n\t},\n\t{\n\t\t[]string{\"sTOnks📈.ORG\", \"API.xn--StonkS-u354e.ORG\"},\n\t\t[]string{\"Foo-Bar.stoNks📈.Org\"},\n\t\t[]string{\"FoOoo.Api.Stonks📈.Org\"},\n\t\ttrue,\n\t\tmap[string][]string{\n\t\t\t\"include\": {\"api.stonks📈.org\", \"stonks📈.org\"},\n\t\t\t\"exclude\": {\"foo-bar.stonks📈.org\"},\n\t\t},\n\t},\n\t{\n\t\t[]string{\"eXaMPle.ORG\", \"API.example.ORG\"},\n\t\t[]string{\"api.example.org\"},\n\t\t[]string{\"foobar.Example.Org\"},\n\t\ttrue,\n\t\tmap[string][]string{\n\t\t\t\"include\": {\"api.example.org\", \"example.org\"},\n\t\t\t\"exclude\": {\"api.example.org\"},\n\t\t},\n\t},\n\t{\n\t\t[]string{\"eXaMPle.ORG\", \"API.example.ORG\"},\n\t\t[]string{\"api.example.org\"},\n\t\t[]string{\"foobar.API.Example.Org\"},\n\t\tfalse,\n\t\tmap[string][]string{\n\t\t\t\"include\": {\"api.example.org\", \"example.org\"},\n\t\t\t\"exclude\": {\"api.example.org\"},\n\t\t},\n\t},\n}\n\nvar regexDomainFilterTests = []regexDomainFilterTest{\n\t{\n\t\tregexp.MustCompile(`\\.org$`),\n\t\tregexp.MustCompile(\"\"),\n\t\t[]string{\"foo.org\", \"bar.org\", \"foo.bar.org\"},\n\t\ttrue,\n\t\tmap[string]string{\n\t\t\t\"regexInclude\": \"\\\\.org$\",\n\t\t},\n\t},\n\t{\n\t\tregexp.MustCompile(`\\.bar\\.org$`),\n\t\tregexp.MustCompile(\"\"),\n\t\t[]string{\"foo.org\", \"bar.org\", \"example.com\"},\n\t\tfalse,\n\t\tmap[string]string{\n\t\t\t\"regexInclude\": \"\\\\.bar\\\\.org$\",\n\t\t},\n\t},\n\t{\n\t\tregexp.MustCompile(`(?:foo|bar)\\.org$`),\n\t\tregexp.MustCompile(\"\"),\n\t\t[]string{\"foo.org\", \"bar.org\", \"example.foo.org\", \"example.bar.org\", \"a.example.foo.org\", \"a.example.bar.org\"},\n\t\ttrue,\n\t\tmap[string]string{\n\t\t\t\"regexInclude\": \"(?:foo|bar)\\\\.org$\",\n\t\t},\n\t},\n\t{\n\t\tregexp.MustCompile(\"(?:😍|🤩)\\\\.org$\"),\n\t\tregexp.MustCompile(\"\"),\n\t\t[]string{\"😍.org\", \"xn--r28h.org\", \"🤩.org\", \"example.😍.org\", \"example.🤩.org\", \"a.example.xn--r28h.org\", \"a.example.🤩.org\"},\n\t\ttrue,\n\t\tmap[string]string{\n\t\t\t\"regexInclude\": \"(?:😍|🤩)\\\\.org$\",\n\t\t},\n\t},\n\t{\n\t\tregexp.MustCompile(\"(?:😍|🤩)\\\\.org$\"),\n\t\tregexp.MustCompile(\"^example\\\\.(?:😍|🤩)\\\\.org$\"),\n\t\t[]string{\"example.😍.org\", \"example.🤩.org\"},\n\t\tfalse,\n\t\tmap[string]string{\n\t\t\t\"regexInclude\": \"(?:😍|🤩)\\\\.org$\",\n\t\t\t\"regexExclude\": \"^example\\\\.(?:😍|🤩)\\\\.org$\",\n\t\t},\n\t},\n\t{\n\t\tregexp.MustCompile(\"(?:foo|bar)\\\\.org$\"),\n\t\tregexp.MustCompile(\"^example\\\\.(?:foo|bar)\\\\.org$\"),\n\t\t[]string{\"foo.org\", \"bar.org\", \"a.example.foo.org\", \"a.example.bar.org\"},\n\t\ttrue,\n\t\tmap[string]string{\n\t\t\t\"regexInclude\": `(?:foo|bar)\\.org$`,\n\t\t\t\"regexExclude\": `^example\\.(?:foo|bar)\\.org$`,\n\t\t},\n\t},\n\t{\n\t\tregexp.MustCompile(`(?:foo|bar)\\.org$`),\n\t\tregexp.MustCompile(`^example\\.(?:foo|bar)\\.org$`),\n\t\t[]string{\"example.foo.org\", \"example.bar.org\"},\n\t\tfalse,\n\t\tmap[string]string{\n\t\t\t\"regexInclude\": \"(?:foo|bar)\\\\.org$\",\n\t\t\t\"regexExclude\": \"^example\\\\.(?:foo|bar)\\\\.org$\",\n\t\t},\n\t},\n\t{\n\t\tregexp.MustCompile(`(?:foo|bar)\\.org$`),\n\t\tregexp.MustCompile(`^example\\.(?:foo|bar)\\.org$`),\n\t\t[]string{\"foo.org\", \"bar.org\", \"a.example.foo.org\", \"a.example.bar.org\"},\n\t\ttrue,\n\t\tmap[string]string{\n\t\t\t\"regexInclude\": \"(?:foo|bar)\\\\.org$\",\n\t\t\t\"regexExclude\": \"^example\\\\.(?:foo|bar)\\\\.org$\",\n\t\t},\n\t},\n\t{\n\t\t// Test case: domain doesn't match include filter, also doesn't match exclusion\n\t\t// Should be REJECTED because it doesn't match the include filter\n\t\tregexp.MustCompile(`foo\\.org$`),\n\t\tregexp.MustCompile(`^temp\\.`),\n\t\t[]string{\"bar.org\", \"example.com\", \"test.net\"},\n\t\tfalse,\n\t\tmap[string]string{\n\t\t\t\"regexInclude\": `foo\\.org$`,\n\t\t\t\"regexExclude\": `^temp\\.`,\n\t\t},\n\t},\n\t{\n\t\t// Test case: domain matches include filter, doesn't match exclusion\n\t\t// Should be ACCEPTED\n\t\tregexp.MustCompile(`\\.prod\\.example\\.com$`),\n\t\tregexp.MustCompile(`^temp-`),\n\t\t[]string{\"api.prod.example.com\", \"web.prod.example.com\"},\n\t\ttrue,\n\t\tmap[string]string{\n\t\t\t\"regexInclude\": `\\.prod\\.example\\.com$`,\n\t\t\t\"regexExclude\": `^temp-`,\n\t\t},\n\t},\n\t{\n\t\t// Test case: domain matches both include and exclusion\n\t\t// Exclusion should take precedence - REJECTED\n\t\tregexp.MustCompile(`\\.prod\\.example\\.com$`),\n\t\tregexp.MustCompile(`^temp-`),\n\t\t[]string{\"temp-api.prod.example.com\", \"temp-web.prod.example.com\"},\n\t\tfalse,\n\t\tmap[string]string{\n\t\t\t\"regexInclude\": `\\.prod\\.example\\.com$`,\n\t\t\t\"regexExclude\": `^temp-`,\n\t\t},\n\t},\n\t{\n\t\t// Test case: domain doesn't match include filter\n\t\t// Should be REJECTED even if exclusion doesn't match\n\t\tregexp.MustCompile(`\\.staging\\.example\\.com$`),\n\t\tregexp.MustCompile(`^internal-`),\n\t\t[]string{\"api.prod.example.com\", \"web.dev.example.com\", \"service.test.org\"},\n\t\tfalse,\n\t\tmap[string]string{\n\t\t\t\"regexInclude\": `\\.staging\\.example\\.com$`,\n\t\t\t\"regexExclude\": `^internal-`,\n\t\t},\n\t},\n}\n\nfunc TestDomainFilterMatch(t *testing.T) {\n\tfor i, tt := range domainFilterTests {\n\t\tif len(tt.exclusions) > 0 {\n\t\t\tt.Logf(\"NewDomainFilter() doesn't support exclusions - skipping test %+v\", tt)\n\t\t\tcontinue\n\t\t}\n\t\tt.Run(fmt.Sprintf(\"%d\", i), func(t *testing.T) {\n\t\t\tdomainFilter := NewDomainFilter(tt.domainFilter)\n\n\t\t\tassertSerializes(t, domainFilter, tt.expectedSerialization)\n\t\t\tdeserialized := deserialize(t, map[string][]string{\n\t\t\t\t\"include\": tt.domainFilter,\n\t\t\t})\n\n\t\t\tfor _, domain := range tt.domains {\n\t\t\t\tassert.Equal(t, tt.expected, domainFilter.Match(domain), \"%v\", domain)\n\t\t\t\tassert.Equal(t, tt.expected, domainFilter.Match(domain+\".\"), \"%v\", domain+\".\")\n\n\t\t\t\tassert.Equal(t, tt.expected, deserialized.Match(domain), \"deserialized %v\", domain)\n\t\t\t\tassert.Equal(t, tt.expected, deserialized.Match(domain+\".\"), \"deserialized %v\", domain+\".\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestDomainFilterWithExclusions(t *testing.T) {\n\tfor i, tt := range domainFilterTests {\n\t\tt.Run(fmt.Sprintf(\"%d\", i), func(t *testing.T) {\n\t\t\tif len(tt.exclusions) == 0 {\n\t\t\t\ttt.exclusions = append(tt.exclusions, \"\")\n\t\t\t}\n\t\t\tdomainFilter := NewDomainFilterWithOptions(\n\t\t\t\tWithDomainFilter(tt.domainFilter),\n\t\t\t\tWithDomainExclude(tt.exclusions))\n\n\t\t\tassertSerializes(t, domainFilter, tt.expectedSerialization)\n\t\t\tdeserialized := deserialize(t, map[string][]string{\n\t\t\t\t\"include\": tt.domainFilter,\n\t\t\t\t\"exclude\": tt.exclusions,\n\t\t\t})\n\n\t\t\tfor _, domain := range tt.domains {\n\t\t\t\tassert.Equal(t, tt.expected, domainFilter.Match(domain), \"%v\", domain)\n\t\t\t\tassert.Equal(t, tt.expected, domainFilter.Match(domain+\".\"), \"%v\", domain+\".\")\n\n\t\t\t\tassert.Equal(t, tt.expected, deserialized.Match(domain), \"deserialized %v\", domain)\n\t\t\t\tassert.Equal(t, tt.expected, deserialized.Match(domain+\".\"), \"deserialized %v\", domain+\".\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestDomainFilterMatchWithEmptyFilter(t *testing.T) {\n\tfor _, tt := range domainFilterTests {\n\t\tdomainFilter := DomainFilter{}\n\t\tfor i, domain := range tt.domains {\n\t\t\tassert.True(t, domainFilter.Match(domain), \"should not fail: %v in test-case #%v\", domain, i)\n\t\t\tassert.True(t, domainFilter.Match(domain+\".\"), \"should not fail: %v in test-case #%v\", domain+\".\", i)\n\t\t}\n\t}\n}\n\nfunc TestNewDomainFilterWithExclusionsHandlesEmptyInputs(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tfilters []string\n\t\texclude []string\n\t}{\n\t\t{\n\t\t\tname:    \"NilSlices\",\n\t\t\tfilters: nil,\n\t\t\texclude: nil,\n\t\t},\n\t\t{\n\t\t\tname:    \"EmptySlices\",\n\t\t\tfilters: []string{},\n\t\t\texclude: []string{},\n\t\t},\n\t\t{\n\t\t\tname:    \"WhitespaceOnly\",\n\t\t\tfilters: []string{\" \", \"\"},\n\t\t\texclude: []string{\"\", \" \"},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tdomainFilter := NewDomainFilterWithOptions(WithDomainFilter(tt.filters), WithDomainExclude(tt.exclude))\n\n\t\t\tassert.False(t, domainFilter.IsConfigured())\n\t\t\tassert.Empty(t, domainFilter.Filters)\n\t\t\tassert.Empty(t, domainFilter.exclude)\n\t\t\tassert.True(t, domainFilter.Match(\"example.com\"))\n\t\t})\n\t}\n}\n\nfunc TestRegexDomainFilter(t *testing.T) {\n\tfor i, tt := range regexDomainFilterTests {\n\t\tt.Run(fmt.Sprintf(\"%d\", i), func(t *testing.T) {\n\t\t\tdomainFilter := NewDomainFilterWithOptions(\n\t\t\t\tWithRegexDomainFilter(tt.regex), WithRegexDomainExclude(tt.regexExclusion))\n\n\t\t\tassertSerializes(t, domainFilter, tt.expectedSerialization)\n\t\t\tdeserialized := deserialize(t, map[string]string{\n\t\t\t\t\"regexInclude\": tt.regex.String(),\n\t\t\t\t\"regexExclude\": tt.regexExclusion.String(),\n\t\t\t})\n\n\t\t\tfor _, domain := range tt.domains {\n\t\t\t\tassert.Equal(t, tt.expected, domainFilter.Match(domain), \"%v\", domain)\n\t\t\t\tassert.Equal(t, tt.expected, domainFilter.Match(domain+\".\"), \"%v\", domain+\".\")\n\n\t\t\t\tassert.Equal(t, tt.expected, deserialized.Match(domain), \"deserialized %v\", domain)\n\t\t\t\tassert.Equal(t, tt.expected, deserialized.Match(domain+\".\"), \"deserialized %v\", domain+\".\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPrepareFiltersStripsWhitespaceAndDotSuffix(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\tinput  []string\n\t\toutput []string\n\t}{\n\t\t{\n\t\t\t[]string{},\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t[]string{\"\"},\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t[]string{\" \", \"   \", \"\"},\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t[]string{\"  foo   \", \"  bar. \", \"baz.\", \"xn--bar-zna\"},\n\t\t\t[]string{\"foo\", \"bar\", \"baz\", \"øbar\"},\n\t\t},\n\t\t{\n\t\t\t[]string{\"foo.bar\", \"  foo.bar.  \", \" foo.bar.baz \", \" foo.bar.baz.  \"},\n\t\t\t[]string{\"foo.bar\", \"foo.bar\", \"foo.bar.baz\", \"foo.bar.baz\"},\n\t\t},\n\t} {\n\t\tt.Run(\"test string\", func(t *testing.T) {\n\t\t\tassert.Equal(t, tt.output, prepareFilters(tt.input))\n\t\t})\n\t}\n}\n\nfunc TestMatchFilterReturnsProperEmptyVal(t *testing.T) {\n\temptyFilters := []string{}\n\tassert.True(t, matchFilter(emptyFilters, \"somedomain.com\", true))\n\tassert.False(t, matchFilter(emptyFilters, \"somedomain.com\", false))\n}\n\nfunc TestDomainFilterIsConfigured(t *testing.T) {\n\tfor i, tt := range []struct {\n\t\tfilters  []string\n\t\texclude  []string\n\t\texpected bool\n\t}{\n\t\t{\n\t\t\t[]string{\"\"},\n\t\t\t[]string{\"\"},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t[]string{\"    \"},\n\t\t\t[]string{\"    \"},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t[]string{\"\", \"\"},\n\t\t\t[]string{\"\"},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t[]string{\" . \"},\n\t\t\t[]string{\" . \"},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t[]string{\" notempty.com \"},\n\t\t\t[]string{\"  \"},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t[]string{\" notempty.com \"},\n\t\t\t[]string{\"  thisdoesntmatter.com \"},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t[]string{\"\"},\n\t\t\t[]string{\"  thisdoesntmatter.com \"},\n\t\t\ttrue,\n\t\t},\n\t} {\n\t\tt.Run(fmt.Sprintf(\"%d\", i), func(t *testing.T) {\n\t\t\tdf := NewDomainFilterWithExclusions(tt.filters, tt.exclude)\n\t\t\tassert.Equal(t, tt.expected, df.IsConfigured())\n\t\t})\n\t}\n}\n\nfunc TestRegexDomainFilterIsConfigured(t *testing.T) {\n\tfor i, tt := range []struct {\n\t\tregex        string\n\t\tregexExclude string\n\t\texpected     bool\n\t}{\n\t\t{\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"(?:foo|bar)\\\\.org$\",\n\t\t\t\"\",\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"\",\n\t\t\t\"\\\\.org$\",\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"(?:foo|bar)\\\\.org$\",\n\t\t\t\"\\\\.org$\",\n\t\t\ttrue,\n\t\t},\n\t} {\n\t\tt.Run(fmt.Sprintf(\"%d\", i), func(t *testing.T) {\n\t\t\tdf := NewDomainFilterWithOptions(\n\t\t\t\tWithRegexDomainFilter(regexp.MustCompile(tt.regex)),\n\t\t\t\tWithRegexDomainExclude(regexp.MustCompile(tt.regexExclude)))\n\t\t\tassert.Equal(t, tt.expected, df.IsConfigured())\n\t\t})\n\t}\n}\n\nfunc TestDomainFilterDeserializeError(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\tname          string\n\t\tserialized    map[string]any\n\t\texpectedError string\n\t}{\n\t\t{\n\t\t\tname: \"invalid json\",\n\t\t\tserialized: map[string]any{\n\t\t\t\t\"include\": 3,\n\t\t\t},\n\t\t\texpectedError: \"json: cannot unmarshal number into Go struct field domainFilterSerde.include of type []string\",\n\t\t},\n\t\t{\n\t\t\tname: \"include and regexInclude\",\n\t\t\tserialized: map[string]any{\n\t\t\t\t\"include\":      []string{\"example.com\"},\n\t\t\t\t\"regexInclude\": \"example.com\",\n\t\t\t},\n\t\t\texpectedError: \"cannot have both domain list and regex\",\n\t\t},\n\t\t{\n\t\t\tname: \"exclude and regexInclude\",\n\t\t\tserialized: map[string]any{\n\t\t\t\t\"exclude\":      []string{\"example.com\"},\n\t\t\t\t\"regexInclude\": \"example.com\",\n\t\t\t},\n\t\t\texpectedError: \"cannot have both domain list and regex\",\n\t\t},\n\t\t{\n\t\t\tname: \"include and regexExclude\",\n\t\t\tserialized: map[string]any{\n\t\t\t\t\"include\":      []string{\"example.com\"},\n\t\t\t\t\"regexExclude\": \"example.com\",\n\t\t\t},\n\t\t\texpectedError: \"cannot have both domain list and regex\",\n\t\t},\n\t\t{\n\t\t\tname: \"exclude and regexExclude\",\n\t\t\tserialized: map[string]any{\n\t\t\t\t\"exclude\":      []string{\"example.com\"},\n\t\t\t\t\"regexExclude\": \"example.com\",\n\t\t\t},\n\t\t\texpectedError: \"cannot have both domain list and regex\",\n\t\t},\n\t\t{\n\t\t\tname: \"invalid regexInclude\",\n\t\t\tserialized: map[string]any{\n\t\t\t\t\"regexInclude\": \"*\",\n\t\t\t},\n\t\t\texpectedError: \"invalid regexInclude: error parsing regexp: missing argument to repetition operator: `*`\",\n\t\t},\n\t\t{\n\t\t\tname: \"invalid regexExclude\",\n\t\t\tserialized: map[string]any{\n\t\t\t\t\"regexExclude\": \"*\",\n\t\t\t},\n\t\t\texpectedError: \"invalid regexExclude: error parsing regexp: missing argument to repetition operator: `*`\",\n\t\t},\n\t} {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar deserialized DomainFilter\n\t\t\ttoJson, _ := json.Marshal(tt.serialized)\n\t\t\terr := json.Unmarshal(toJson, &deserialized)\n\t\t\tassert.EqualError(t, err, tt.expectedError)\n\t\t})\n\t}\n}\n\nfunc assertSerializes[T any](t *testing.T, domainFilter *DomainFilter, expectedSerialization map[string]T) {\n\tserialized, err := json.Marshal(domainFilter)\n\tassert.NoError(t, err, \"serializing\")\n\texpected, err := json.Marshal(expectedSerialization)\n\trequire.NoError(t, err)\n\tassert.JSONEq(t, string(expected), string(serialized), \"json serialization\")\n}\n\nfunc deserialize[T any](t *testing.T, serialized map[string]T) *DomainFilter {\n\tinJson, err := json.Marshal(serialized)\n\trequire.NoError(t, err)\n\tvar deserialized DomainFilter\n\terr = json.Unmarshal(inJson, &deserialized)\n\tassert.NoError(t, err, \"deserializing\")\n\n\treturn &deserialized\n}\n\nfunc TestDomainFilterMatchParent(t *testing.T) {\n\tparentMatchTests := []domainFilterTest{\n\t\t{\n\t\t\t[]string{\"a.example.com.\"},\n\t\t\t[]string{},\n\t\t\t[]string{\"example.com\"},\n\t\t\ttrue,\n\t\t\tmap[string][]string{\n\t\t\t\t\"include\": {\"a.example.com\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t[]string{\" a.example.com \"},\n\t\t\t[]string{},\n\t\t\t[]string{\"example.com\"},\n\t\t\ttrue,\n\t\t\tmap[string][]string{\n\t\t\t\t\"include\": {\"a.example.com\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t[]string{\"\"},\n\t\t\t[]string{},\n\t\t\t[]string{\"example.com\"},\n\t\t\ttrue,\n\t\t\tmap[string][]string{},\n\t\t},\n\t\t{\n\t\t\t[]string{\".a.example.com.\"},\n\t\t\t[]string{},\n\t\t\t[]string{\"example.com\"},\n\t\t\tfalse,\n\t\t\tmap[string][]string{\n\t\t\t\t\"include\": {\".a.example.com\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t[]string{\"a.example.com.\", \"b.example.com\"},\n\t\t\t[]string{},\n\t\t\t[]string{\"example.com\"},\n\t\t\ttrue,\n\t\t\tmap[string][]string{\n\t\t\t\t\"include\": {\"a.example.com\", \"b.example.com\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t[]string{\"a.xn--c1yn36f.æøå.\", \"b.點看.xn--5cab8c\", \"c.點看.æøå\"},\n\t\t\t[]string{},\n\t\t\t[]string{\"xn--c1yn36f.xn--5cab8c\"},\n\t\t\ttrue,\n\t\t\tmap[string][]string{\n\t\t\t\t\"include\": {\"a.點看.æøå\", \"b.點看.æøå\", \"c.點看.æøå\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t[]string{\"punycode.xn--c1yn36f.local\", \"å.點看.local.\", \"ø.點看.local\"},\n\t\t\t[]string{},\n\t\t\t[]string{\"點看.local\"},\n\t\t\ttrue,\n\t\t\tmap[string][]string{\n\t\t\t\t\"include\": {\"punycode.點看.local\", \"å.點看.local\", \"ø.點看.local\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t[]string{\"a.example.com\"},\n\t\t\t[]string{},\n\t\t\t[]string{\"b.example.com\"},\n\t\t\tfalse,\n\t\t\tmap[string][]string{\n\t\t\t\t\"include\": {\"a.example.com\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t[]string{\"example.com\"},\n\t\t\t[]string{},\n\t\t\t[]string{\"example.com\"},\n\t\t\tfalse,\n\t\t\tmap[string][]string{\n\t\t\t\t\"include\": {\"example.com\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t[]string{\"example.com\"},\n\t\t\t[]string{},\n\t\t\t[]string{\"anexample.com\"},\n\t\t\tfalse,\n\t\t\tmap[string][]string{\n\t\t\t\t\"include\": {\"example.com\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t[]string{\"\"},\n\t\t\t[]string{},\n\t\t\t[]string{\"\"},\n\t\t\ttrue,\n\t\t\tmap[string][]string{},\n\t\t},\n\t}\n\tfor i, tt := range parentMatchTests {\n\t\tt.Run(fmt.Sprintf(\"%d\", i), func(t *testing.T) {\n\t\t\tdomainFilter := NewDomainFilterWithOptions(\n\t\t\t\tWithDomainFilter(tt.domainFilter), WithDomainExclude(tt.exclusions))\n\n\t\t\tassertSerializes(t, domainFilter, tt.expectedSerialization)\n\t\t\tdeserialized := deserialize(t, map[string][]string{\n\t\t\t\t\"include\": tt.domainFilter,\n\t\t\t\t\"exclude\": tt.exclusions,\n\t\t\t})\n\n\t\t\tfor _, domain := range tt.domains {\n\t\t\t\tassert.Equal(t, tt.expected, domainFilter.MatchParent(domain), \"%v\", domain)\n\t\t\t\tassert.Equal(t, tt.expected, domainFilter.MatchParent(domain+\".\"), \"%v\", domain+\".\")\n\n\t\t\t\tassert.Equal(t, tt.expected, deserialized.MatchParent(domain), \"deserialized %v\", domain)\n\t\t\t\tassert.Equal(t, tt.expected, deserialized.MatchParent(domain+\".\"), \"deserialized %v\", domain+\".\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSimpleDomainFilterWithExclusion(t *testing.T) {\n\ttest := []struct {\n\t\tdomainFilter    []string\n\t\texclusionFilter []string\n\t\tdomains         []string\n\t\twant            []string\n\t}{\n\t\t{\n\t\t\tdomainFilter:    []string{\"ex.com\"},\n\t\t\texclusionFilter: []string{\"subdomain.ex.com\"},\n\t\t\tdomains:         []string{\"subdomain.ex.com\", \"ex.com\", \"subdomain.ex.com.\", \".subdomain.ex.com\", \"one.subdomain.ex.com\", \"ex.com.\"},\n\t\t\twant:            []string{\"ex.com\", \"ex.com.\"},\n\t\t},\n\t\t{\n\t\t\tdomainFilter:    []string{\"ex.com\"},\n\t\t\texclusionFilter: []string{},\n\t\t\tdomains:         []string{\"subdomain.ex.com\", \"ex.com\", \"subdomain.ex.com.\", \".subdomain.ex.com\", \"one.subdomain.ex.com\", \"ex.com.\"},\n\t\t\twant:            []string{\"subdomain.ex.com\", \"ex.com\", \"subdomain.ex.com.\", \".subdomain.ex.com\", \"one.subdomain.ex.com\", \"ex.com.\"},\n\t\t},\n\t\t{\n\t\t\tdomainFilter:    []string{\"ex.com\"},\n\t\t\texclusionFilter: []string{\"one.subdomain.ex.com\"},\n\t\t\tdomains:         []string{\"subdomain.ex.com\", \"ex.com\", \"subdomain.ex.com.\", \".subdomain.ex.com\", \"one.subdomain.ex.com\", \"ex.com.\"},\n\t\t\twant:            []string{\"subdomain.ex.com\", \"ex.com\", \"subdomain.ex.com.\", \".subdomain.ex.com\", \"ex.com.\"},\n\t\t},\n\t\t{\n\t\t\tdomainFilter:    []string{\"ex.com\"},\n\t\t\texclusionFilter: []string{\".ex.com\"},\n\t\t\tdomains:         []string{\"subdomain.ex.com\", \"ex.com\", \"subdomain.ex.com.\", \".subdomain.ex.com\", \"one.subdomain.ex.com\", \"ex.com.\"},\n\t\t\twant:            []string{\"ex.com\", \"ex.com.\"},\n\t\t},\n\t}\n\n\tfor _, tt := range test {\n\t\tt.Run(fmt.Sprintf(\"include:%s-exclude:%s\", strings.Join(tt.domainFilter, \"_\"), strings.Join(tt.exclusionFilter, \"_\")), func(t *testing.T) {\n\t\t\tdomainFilter := NewDomainFilterWithOptions(\n\t\t\t\tWithDomainFilter(tt.domainFilter), WithDomainExclude(tt.exclusionFilter))\n\t\t\tvar got []string\n\t\t\tfor _, domain := range tt.domains {\n\t\t\t\tif domainFilter.Match(domain) {\n\t\t\t\t\tgot = append(got, domain)\n\t\t\t\t}\n\t\t\t}\n\t\t\tassert.Len(t, tt.want, len(got))\n\t\t\tassert.Equal(t, tt.want, got)\n\t\t})\n\t}\n}\n\nfunc TestDomainFilterNormalizeDomain(t *testing.T) {\n\trecords := []struct {\n\t\tdnsName string\n\t\texpect  string\n\t}{\n\t\t{\n\t\t\t\"3AAAA.FOO.BAR.COM\",\n\t\t\t\"3aaaa.foo.bar.com\",\n\t\t},\n\t\t{\n\t\t\t\"example.foo.com.\",\n\t\t\t\"example.foo.com\",\n\t\t},\n\t\t{\n\t\t\t\"example123.foo.com\",\n\t\t\t\"example123.foo.com\",\n\t\t},\n\t\t{\n\t\t\t\"foo.com.\",\n\t\t\t\"foo.com\",\n\t\t},\n\t\t{\n\t\t\t\"foo123.COM\",\n\t\t\t\"foo123.com\",\n\t\t},\n\t\t{\n\t\t\t\"my-exaMple3.FOO.BAR.COM\",\n\t\t\t\"my-example3.foo.bar.com\",\n\t\t},\n\t\t{\n\t\t\t\"my-example1214.FOO-1235.BAR-foo.COM\",\n\t\t\t\"my-example1214.foo-1235.bar-foo.com\",\n\t\t},\n\t\t{\n\t\t\t\"my-example-my-example-1214.FOO-1235.BAR-foo.COM\",\n\t\t\t\"my-example-my-example-1214.foo-1235.bar-foo.com\",\n\t\t},\n\t\t{\n\t\t\t\"xn--c1yn36f.org.\",\n\t\t\t\"點看.org\",\n\t\t},\n\t\t{\n\t\t\t\"xn--nordic--w1a.xn--xn--kItty-pd34d-hn01b3542b.com\",\n\t\t\t\"nordic-ø.xn--kitty-點看pd34d.com\",\n\t\t},\n\t\t{\n\t\t\t\"xn--nordic--w1a.xn--kItty-pd34d.com\",\n\t\t\t\"nordic-ø.kitty😸.com\",\n\t\t},\n\t\t{\n\t\t\t\"nordic-ø.kitty😸.COM\",\n\t\t\t\"nordic-ø.kitty😸.com\",\n\t\t},\n\t\t{\n\t\t\t\"xn--nordic--w1a.kiTTy😸.com.\",\n\t\t\t\"nordic-ø.kitty😸.com\",\n\t\t},\n\t}\n\tfor _, r := range records {\n\t\tgotName := normalizeDomain(r.dnsName)\n\t\tassert.Equal(t, r.expect, gotName)\n\t}\n}\n\nfunc TestMatchTargetFilterReturnsProperEmptyVal(t *testing.T) {\n\tvar emptyFilters []string\n\tassert.True(t, matchFilter(emptyFilters, \"sometarget.com\", true))\n\tassert.False(t, matchFilter(emptyFilters, \"sometarget.com\", false))\n}\n\nfunc TestNewDomainFilterFromConfig(t *testing.T) {\n\ttests := []struct {\n\t\tname                 string\n\t\tdomainFilter         []string\n\t\tdomainExclude        []string\n\t\tregexDomainFilter    *regexp.Regexp\n\t\tregexDomainExclusion *regexp.Regexp\n\t\texpectedDomainFilter *DomainFilter\n\t\tisConfigured         bool\n\t\tmatchDomain          string\n\t\texpectMatch          bool\n\t}{\n\t\t{\n\t\t\tname:                 \"RegexDomainFilter with non regex filters ignored\",\n\t\t\tregexDomainFilter:    regexp.MustCompile(`example\\.com`),\n\t\t\tregexDomainExclusion: regexp.MustCompile(`excluded\\.example\\.com`),\n\t\t\tdomainFilter:         []string{\"example.com\"},\n\t\t\tdomainExclude:        []string{\"excluded.example.com\"},\n\t\t\texpectedDomainFilter: NewRegexDomainFilter(regexp.MustCompile(`example\\.com`), regexp.MustCompile(`excluded\\.example\\.com`)),\n\t\t\tisConfigured:         true,\n\t\t},\n\t\t{\n\t\t\tname:                 \"RegexDomainWithoutExclusionFilter and domainExclude is ignored\",\n\t\t\tregexDomainFilter:    regexp.MustCompile(`example\\.com`),\n\t\t\tdomainExclude:        []string{\"excluded.example.com\"},\n\t\t\texpectedDomainFilter: NewRegexDomainFilter(regexp.MustCompile(`example\\.com`), nil),\n\t\t\tisConfigured:         true,\n\t\t},\n\t\t{\n\t\t\tname:                 \"DomainFilterWithExclusions\",\n\t\t\tregexDomainFilter:    regexp.MustCompile(``),\n\t\t\tdomainFilter:         []string{\"example.com\"},\n\t\t\tdomainExclude:        []string{\"excluded.example.com\"},\n\t\t\texpectedDomainFilter: NewDomainFilterWithExclusions([]string{\"example.com\"}, []string{\"excluded.example.com\"}),\n\t\t\tisConfigured:         true,\n\t\t},\n\t\t{\n\t\t\tname:                 \"DomainFilterWithExclusionsOnly\",\n\t\t\tdomainExclude:        []string{\"excluded.example.com\"},\n\t\t\texpectedDomainFilter: NewDomainFilterWithExclusions([]string{}, []string{\"excluded.example.com\"}),\n\t\t\tisConfigured:         true,\n\t\t},\n\t\t{\n\t\t\tname:                 \"EmptyDomainFilter\",\n\t\t\tdomainFilter:         []string{},\n\t\t\tdomainExclude:        []string{},\n\t\t\texpectedDomainFilter: NewDomainFilterWithExclusions([]string{}, []string{}),\n\t\t\tisConfigured:         false,\n\t\t},\n\t\t{\n\t\t\tname:                 \"RegexDomainExclusionWithoutRegexFilter\",\n\t\t\tregexDomainExclusion: regexp.MustCompile(`test-v1\\.3\\.example-test\\.in`),\n\t\t\texpectedDomainFilter: NewRegexDomainFilter(nil, regexp.MustCompile(`test-v1\\.3\\.example-test\\.in`)),\n\t\t\tisConfigured:         true,\n\t\t\tmatchDomain:          \"test-v1.3.example-test.in\",\n\t\t\texpectMatch:          false,\n\t\t},\n\t\t{\n\t\t\tname:                 \"RegexDomainFilterWithMultipleDomains\",\n\t\t\tregexDomainFilter:    regexp.MustCompile(`(example\\.com|test\\.org)`),\n\t\t\texpectedDomainFilter: NewRegexDomainFilter(regexp.MustCompile(`(example\\.com|test\\.org)`), nil),\n\t\t\tisConfigured:         true,\n\t\t\tmatchDomain:          \"api.example.com\",\n\t\t\texpectMatch:          true,\n\t\t},\n\t\t{\n\t\t\tname:                 \"RegexDomainFilterWithWildcardPattern\",\n\t\t\tregexDomainFilter:    regexp.MustCompile(`.*\\.staging\\..*`),\n\t\t\texpectedDomainFilter: NewRegexDomainFilter(regexp.MustCompile(`.*\\.staging\\..*`), nil),\n\t\t\tisConfigured:         true,\n\t\t\tmatchDomain:          \"app.staging.example.com\",\n\t\t\texpectMatch:          true,\n\t\t},\n\t\t{\n\t\t\tname:                 \"RegexDomainExclusionWithComplexPattern\",\n\t\t\tregexDomainExclusion: regexp.MustCompile(`^(internal|private)-.*\\.example\\.com$`),\n\t\t\texpectedDomainFilter: NewRegexDomainFilter(nil, regexp.MustCompile(`^(internal|private)-.*\\.example\\.com$`)),\n\t\t\tisConfigured:         true,\n\t\t\tmatchDomain:          \"internal-service.example.com\",\n\t\t\texpectMatch:          false,\n\t\t},\n\t\t{\n\t\t\tname:                 \"RegexFilterAndExclusionBothPresent\",\n\t\t\tregexDomainFilter:    regexp.MustCompile(`.*\\.prod\\..*`),\n\t\t\tregexDomainExclusion: regexp.MustCompile(`temp-.*\\.prod\\..*`),\n\t\t\texpectedDomainFilter: NewRegexDomainFilter(regexp.MustCompile(`.*\\.prod\\..*`), regexp.MustCompile(`temp-.*\\.prod\\..*`)),\n\t\t\tisConfigured:         true,\n\t\t\tmatchDomain:          \"temp-api.prod.example.com\",\n\t\t\texpectMatch:          false,\n\t\t},\n\t\t{\n\t\t\tname:                 \"RegexWithEscapedSpecialChars\",\n\t\t\tregexDomainFilter:    regexp.MustCompile(`test\\-api\\.v\\d+\\.example\\.com`),\n\t\t\texpectedDomainFilter: NewRegexDomainFilter(regexp.MustCompile(`test\\-api\\.v\\d+\\.example\\.com`), nil),\n\t\t\tisConfigured:         true,\n\t\t\tmatchDomain:          \"test-api.v2.example.com\",\n\t\t\texpectMatch:          true,\n\t\t},\n\t\t{\n\t\t\tname:                 \"RegexExclusionWithNumericPattern\",\n\t\t\tregexDomainExclusion: regexp.MustCompile(`\\d{3,}-temp\\..*`),\n\t\t\texpectedDomainFilter: NewRegexDomainFilter(nil, regexp.MustCompile(`\\d{3,}-temp\\..*`)),\n\t\t\tisConfigured:         true,\n\t\t\tmatchDomain:          \"123-temp.example.com\",\n\t\t\texpectMatch:          false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tfilter := NewDomainFilterWithOptions(\n\t\t\t\tWithDomainFilter(tt.domainFilter),\n\t\t\t\tWithDomainExclude(tt.domainExclude),\n\t\t\t\tWithRegexDomainFilter(tt.regexDomainFilter),\n\t\t\t\tWithRegexDomainExclude(tt.regexDomainExclusion))\n\n\t\t\tassert.Equal(t, tt.isConfigured, filter.IsConfigured())\n\t\t\tassert.Equal(t, tt.expectedDomainFilter, filter)\n\t\t\tif tt.matchDomain != \"\" {\n\t\t\t\tassert.Equal(t, tt.expectMatch, filter.Match(tt.matchDomain))\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestRegexDomainFilterZoneNames(t *testing.T) {\n\tconst (\n\t\trootZone       = \"test.com\"\n\t\tusEast1Zone    = \"us-east-1.test.com\"\n\t\teuCentral1Zone = \"eu-central-1.test.com\"\n\t\tglobalZone     = \"global.test.com\"\n\t\twwwUsEast1     = \"www.us-east-1.test.com\"\n\t\twwwEuCentral1  = \"www.eu-central-1.test.com\"\n\t\twwwRoot        = \"www.test.com\"\n\t)\n\n\ttests := []struct {\n\t\tname       string\n\t\tregex      string\n\t\tdomains    []string\n\t\tassertions func(t *testing.T, domain string, matched bool)\n\t}{\n\t\t{\n\t\t\tname:    `^[\\w-]+\\.us-east-1\\.test\\.com$: + requires label prefix, matches records under us-east-1.test.com`,\n\t\t\tregex:   `^[\\w-]+\\.us-east-1\\.test\\.com$`,\n\t\t\tdomains: []string{wwwUsEast1},\n\t\t\tassertions: func(t *testing.T, domain string, matched bool) {\n\t\t\t\tassert.True(t, matched, domain)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:    `^[\\w-]+\\.us-east-1\\.test\\.com$: + excludes zone apex and all non-us-east-1 domains`,\n\t\t\tregex:   `^[\\w-]+\\.us-east-1\\.test\\.com$`,\n\t\t\tdomains: []string{usEast1Zone, rootZone, euCentral1Zone, globalZone, wwwRoot, wwwEuCentral1},\n\t\t\tassertions: func(t *testing.T, domain string, matched bool) {\n\t\t\t\tassert.False(t, matched, domain)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:    `^([\\w-]+\\.)*us-east-1\\.test\\.com$: * makes label prefix optional, matches zone apex and records`,\n\t\t\tregex:   `^([\\w-]+\\.)*us-east-1\\.test\\.com$`,\n\t\t\tdomains: []string{usEast1Zone, wwwUsEast1},\n\t\t\tassertions: func(t *testing.T, domain string, matched bool) {\n\t\t\t\tassert.True(t, matched, domain)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:    `^([\\w-]+\\.)*us-east-1\\.test\\.com$: does not match root zone, other regions, or global`,\n\t\t\tregex:   `^([\\w-]+\\.)*us-east-1\\.test\\.com$`,\n\t\t\tdomains: []string{rootZone, euCentral1Zone, globalZone, wwwRoot, wwwEuCentral1},\n\t\t\tassertions: func(t *testing.T, domain string, matched bool) {\n\t\t\t\tassert.False(t, matched, domain)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:    `^[\\w-]+\\.(us-east-1|eu-central-1)\\.test\\.com$: + matches records under both regions`,\n\t\t\tregex:   `^[\\w-]+\\.(us-east-1|eu-central-1)\\.test\\.com$`,\n\t\t\tdomains: []string{wwwUsEast1, wwwEuCentral1},\n\t\t\tassertions: func(t *testing.T, domain string, matched bool) {\n\t\t\t\tassert.True(t, matched, domain)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:    `^[\\w-]+\\.(us-east-1|eu-central-1)\\.test\\.com$: + excludes both regional zone apexes`,\n\t\t\tregex:   `^[\\w-]+\\.(us-east-1|eu-central-1)\\.test\\.com$`,\n\t\t\tdomains: []string{usEast1Zone, euCentral1Zone, rootZone, globalZone, wwwRoot},\n\t\t\tassertions: func(t *testing.T, domain string, matched bool) {\n\t\t\t\tassert.False(t, matched, domain)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:    `^([\\w-]+\\.)*(?:us-east-1|eu-central-1)\\.test\\.com$: * matches zone apexes and records for both regions`,\n\t\t\tregex:   `^([\\w-]+\\.)*(?:us-east-1|eu-central-1)\\.test\\.com$`,\n\t\t\tdomains: []string{usEast1Zone, euCentral1Zone, wwwUsEast1, wwwEuCentral1},\n\t\t\tassertions: func(t *testing.T, domain string, matched bool) {\n\t\t\t\tassert.True(t, matched, domain)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:    `^([\\w-]+\\.)*(?:us-east-1|eu-central-1)\\.test\\.com$: does not match root zone, global, or root record`,\n\t\t\tregex:   `^([\\w-]+\\.)*(?:us-east-1|eu-central-1)\\.test\\.com$`,\n\t\t\tdomains: []string{rootZone, globalZone, wwwRoot},\n\t\t\tassertions: func(t *testing.T, domain string, matched bool) {\n\t\t\t\tassert.False(t, matched, domain)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:    `^www\\.us-east-1\\.test\\.com$: exact record match, matches www.us-east-1.test.com only`,\n\t\t\tregex:   `^www\\.us-east-1\\.test\\.com$`,\n\t\t\tdomains: []string{wwwUsEast1},\n\t\t\tassertions: func(t *testing.T, domain string, matched bool) {\n\t\t\t\tassert.True(t, matched, domain)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:    `^www\\.us-east-1\\.test\\.com$: does not match zone apexes or other records`,\n\t\t\tregex:   `^www\\.us-east-1\\.test\\.com$`,\n\t\t\tdomains: []string{rootZone, usEast1Zone, euCentral1Zone, wwwRoot, wwwEuCentral1},\n\t\t\tassertions: func(t *testing.T, domain string, matched bool) {\n\t\t\t\tassert.False(t, matched, domain)\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tdf := NewRegexDomainFilter(regexp.MustCompile(tt.regex), nil)\n\t\t\tfor _, domain := range tt.domains {\n\t\t\t\ttt.assertions(t, domain, df.Match(domain))\n\t\t\t\ttt.assertions(t, domain+\".\", df.Match(domain+\".\"))\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "endpoint/endpoint.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage endpoint\n\nimport (\n\t\"cmp\"\n\t\"fmt\"\n\t\"net/netip\"\n\t\"slices\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/miekg/dns\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"k8s.io/utils/set\"\n\n\t\"sigs.k8s.io/external-dns/pkg/events\"\n)\n\nconst (\n\t// RecordTypeA is a RecordType enum value\n\tRecordTypeA = \"A\"\n\t// RecordTypeAAAA is a RecordType enum value\n\tRecordTypeAAAA = \"AAAA\"\n\t// RecordTypeCNAME is a RecordType enum value\n\tRecordTypeCNAME = \"CNAME\"\n\t// RecordTypeTXT is a RecordType enum value\n\tRecordTypeTXT = \"TXT\"\n\t// RecordTypeSRV is a RecordType enum value\n\tRecordTypeSRV = \"SRV\"\n\t// RecordTypeNS is a RecordType enum value\n\tRecordTypeNS = \"NS\"\n\t// RecordTypePTR is a RecordType enum value\n\tRecordTypePTR = \"PTR\"\n\t// RecordTypeMX is a RecordType enum value\n\tRecordTypeMX = \"MX\"\n\t// RecordTypeNAPTR is a RecordType enum value\n\tRecordTypeNAPTR = \"NAPTR\"\n\n\t// TODO: review source/annotations package to consolidate alias key definitions;\n\t// currently duplicated here to avoid circular dependency.\n\tproviderSpecificAlias = \"alias\"\n\n\t// ProviderSpecificRecordType is the provider-specific property name used to\n\t// request a particular DNS record type (e.g. \"ptr\") on an endpoint.\n\tProviderSpecificRecordType = \"record-type\"\n)\n\nvar (\n\tKnownRecordTypes = []string{\n\t\tRecordTypeA,\n\t\tRecordTypeAAAA,\n\t\tRecordTypeCNAME,\n\t\tRecordTypeTXT,\n\t\tRecordTypeSRV,\n\t\tRecordTypeNS,\n\t\tRecordTypePTR,\n\t\tRecordTypeMX,\n\t\tRecordTypeNAPTR,\n\t}\n)\n\n// TTL is a structure defining the TTL of a DNS record\ntype TTL int64\n\n// IsConfigured returns true if TTL is configured, false otherwise\nfunc (ttl TTL) IsConfigured() bool {\n\treturn ttl > 0\n}\n\n// Targets is a representation of a list of targets for an endpoint.\ntype Targets []string\n\n// MXTarget represents a single MX (Mail Exchange) record target, including its priority and host.\ntype MXTarget struct {\n\tpriority uint16\n\thost     string\n}\n\n// NewTargets is a convenience method to create a new Targets object from a vararg of strings.\n// Returns a new Targets slice with duplicates removed and elements sorted in order.\nfunc NewTargets(target ...string) Targets {\n\treturn set.New(target...).SortedList()\n}\n\nfunc (t Targets) String() string {\n\treturn strings.Join(t, \";\")\n}\n\nfunc (t Targets) Len() int {\n\treturn len(t)\n}\n\nfunc (t Targets) Less(i, j int) bool {\n\tipi, err := netip.ParseAddr(t[i])\n\tif err != nil {\n\t\treturn t[i] < t[j]\n\t}\n\n\tipj, err := netip.ParseAddr(t[j])\n\tif err != nil {\n\t\treturn t[i] < t[j]\n\t}\n\n\treturn ipi.String() < ipj.String()\n}\n\nfunc (t Targets) Swap(i, j int) {\n\tt[i], t[j] = t[j], t[i]\n}\n\n// Same compares two Targets and returns true if they are identical (case-insensitive)\nfunc (t Targets) Same(o Targets) bool {\n\tif len(t) != len(o) {\n\t\treturn false\n\t}\n\tsort.Stable(t)\n\tsort.Stable(o)\n\n\tfor i, e := range t {\n\t\tif !strings.EqualFold(e, o[i]) {\n\t\t\t// IPv6 can be shortened, so it should be parsed for equality checking\n\t\t\tipA, err := netip.ParseAddr(e)\n\t\t\tif err != nil {\n\t\t\t\tlog.WithFields(log.Fields{\n\t\t\t\t\t\"targets\":           t,\n\t\t\t\t\t\"comparisonTargets\": o,\n\t\t\t\t}).Debugf(\"Couldn't parse %s as an IP address: %v\", e, err)\n\t\t\t}\n\n\t\t\tipB, err := netip.ParseAddr(o[i])\n\t\t\tif err != nil {\n\t\t\t\tlog.WithFields(log.Fields{\n\t\t\t\t\t\"targets\":           t,\n\t\t\t\t\t\"comparisonTargets\": o,\n\t\t\t\t}).Debugf(\"Couldn't parse %s as an IP address: %v\", e, err)\n\t\t\t}\n\n\t\t\t// IPv6 Address Shortener == IPv6 Address Expander\n\t\t\tif ipA.IsValid() && ipB.IsValid() {\n\t\t\t\treturn ipA.String() == ipB.String()\n\t\t\t}\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// IsLess should fulfill the requirement to compare two targets and choose the 'lesser' one.\n// In the past target was a simple string so simple string comparison could be used. Now we define 'less'\n// as either being the shorter list of targets or where the first entry is less.\n// FIXME We really need to define under which circumstances a list Targets is considered 'less'\n// than another.\nfunc (t Targets) IsLess(o Targets) bool {\n\tif len(t) < len(o) {\n\t\treturn true\n\t}\n\tif len(t) > len(o) {\n\t\treturn false\n\t}\n\n\tsort.Sort(t)\n\tsort.Sort(o)\n\n\tfor i, e := range t {\n\t\tif e != o[i] {\n\t\t\t// Explicitly prefers IP addresses (e.g. A records) over FQDNs (e.g. CNAMEs).\n\t\t\t// This prevents behavior like `1-2-3-4.example.com` being \"less\" than `1.2.3.4` when doing lexicographical string comparison.\n\t\t\tipA, err := netip.ParseAddr(e)\n\t\t\tif err != nil {\n\t\t\t\t// Ignoring parsing errors is fine due to the empty netip.Addr{} type being an invalid IP,\n\t\t\t\t// which is checked by IsValid() below. However, still log them in case a provider is experiencing\n\t\t\t\t// non-obvious issues with the records being created.\n\t\t\t\tlog.WithFields(log.Fields{\n\t\t\t\t\t\"targets\":           t,\n\t\t\t\t\t\"comparisonTargets\": o,\n\t\t\t\t}).Debugf(\"Couldn't parse %s as an IP address: %v\", e, err)\n\t\t\t}\n\n\t\t\tipB, err := netip.ParseAddr(o[i])\n\t\t\tif err != nil {\n\t\t\t\tlog.WithFields(log.Fields{\n\t\t\t\t\t\"targets\":           t,\n\t\t\t\t\t\"comparisonTargets\": o,\n\t\t\t\t}).Debugf(\"Couldn't parse %s as an IP address: %v\", e, err)\n\t\t\t}\n\n\t\t\t// If both targets are valid IP addresses, use the built-in Less() function to do the comparison.\n\t\t\t// If one is a valid IP and the other is not, prefer the IP address (consider it \"less\").\n\t\t\t// If neither is a valid IP, use lexicographical string comparison to determine which string sorts first alphabetically.\n\t\t\tswitch {\n\t\t\tcase ipA.IsValid() && ipB.IsValid():\n\t\t\t\treturn ipA.Less(ipB)\n\t\t\tcase ipA.IsValid() && !ipB.IsValid():\n\t\t\t\treturn true\n\t\t\tcase !ipA.IsValid() && ipB.IsValid():\n\t\t\t\treturn false\n\t\t\tdefault:\n\t\t\t\treturn e < o[i]\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\n// ProviderSpecificProperty holds the name and value of a configuration which is specific to individual DNS providers\ntype ProviderSpecificProperty struct {\n\tName  string `json:\"name,omitempty\"`\n\tValue string `json:\"value,omitempty\"`\n}\n\n// ProviderSpecific holds configuration which is specific to individual DNS providers\ntype ProviderSpecific []ProviderSpecificProperty\n\n// EndpointKey is the type of a map key for separating endpoints or targets.\ntype EndpointKey struct {\n\tDNSName       string\n\tRecordType    string\n\tSetIdentifier string\n\tRecordTTL     TTL\n\tTarget        string\n}\n\ntype ObjectRef = events.ObjectReference\n\n// Endpoint is a high-level way of a connection between a service and an IP\n// +kubebuilder:object:generate=true\ntype Endpoint struct {\n\t// The hostname of the DNS record\n\tDNSName string `json:\"dnsName,omitempty\"`\n\t// The targets the DNS record points to\n\tTargets Targets `json:\"targets,omitempty\"`\n\t// RecordType type of record, e.g. CNAME, A, AAAA, SRV, TXT etc\n\tRecordType string `json:\"recordType,omitempty\"`\n\t// Identifier to distinguish multiple records with the same name and type (e.g. Route53 records with routing policies other than 'simple')\n\tSetIdentifier string `json:\"setIdentifier,omitempty\"`\n\t// TTL for the record\n\tRecordTTL TTL `json:\"recordTTL,omitempty\"`\n\t// Labels stores labels defined for the Endpoint\n\t// +optional\n\tLabels Labels `json:\"labels,omitempty\"`\n\t// ProviderSpecific stores provider specific config\n\t// +optional\n\tProviderSpecific ProviderSpecific `json:\"providerSpecific,omitempty\"`\n\t// refObject stores reference object\n\t// TODO: should be an array, as endpoints merged from multiple sources may have multiple ref objects\n\t// +optional\n\trefObject *ObjectRef `json:\"-\"`\n}\n\n// NewEndpoint initialization method to be used to create an endpoint\nfunc NewEndpoint(dnsName, recordType string, targets ...string) *Endpoint {\n\treturn NewEndpointWithTTL(dnsName, recordType, TTL(0), targets...)\n}\n\n// NewEndpointWithTTL initialization method to be used to create an endpoint with a TTL struct\nfunc NewEndpointWithTTL(dnsName, recordType string, ttl TTL, targets ...string) *Endpoint {\n\tcleanTargets := make([]string, len(targets))\n\tfor idx, target := range targets {\n\t\t// Only trim trailing dots for domain name record types, not for TXT or NAPTR records\n\t\t// TXT records can contain arbitrary text including multiple dots\n\t\t// SRV can contain dots in their target part (RFC2782)\n\t\tswitch recordType {\n\t\tcase RecordTypeTXT, RecordTypeNAPTR, RecordTypeSRV:\n\t\t\tcleanTargets[idx] = target\n\t\tdefault:\n\t\t\tcleanTargets[idx] = strings.TrimSuffix(target, \".\")\n\t\t}\n\t}\n\n\tfor label := range strings.SplitSeq(dnsName, \".\") {\n\t\tif len(label) > 63 {\n\t\t\tlog.Errorf(\"label %s in %s is longer than 63 characters. Cannot create endpoint\", label, dnsName)\n\t\t\treturn nil\n\t\t}\n\t}\n\n\treturn &Endpoint{\n\t\tDNSName:    strings.TrimSuffix(dnsName, \".\"),\n\t\tTargets:    cleanTargets,\n\t\tRecordType: recordType,\n\t\tLabels:     NewLabels(),\n\t\tRecordTTL:  ttl,\n\t}\n}\n\n// WithSetIdentifier applies the given set identifier to the endpoint.\nfunc (e *Endpoint) WithSetIdentifier(setIdentifier string) *Endpoint {\n\te.SetIdentifier = setIdentifier\n\treturn e\n}\n\n// WithProviderSpecific attaches a key/value pair to the Endpoint and returns the Endpoint.\n// This can be used to pass additional data through the stages of ExternalDNS's Endpoint processing.\n// The assumption is that most of the time this will be provider specific metadata that doesn't\n// warrant its own field on the Endpoint object itself. It differs from Labels in the fact that it's\n// not persisted in the Registry but only kept in memory during a single record synchronization.\nfunc (e *Endpoint) WithProviderSpecific(key, value string) *Endpoint {\n\te.SetProviderSpecificProperty(key, value)\n\treturn e\n}\n\n// GetProviderSpecificProperty returns the value of a ProviderSpecificProperty if the property exists.\nfunc (e *Endpoint) GetProviderSpecificProperty(key string) (string, bool) {\n\tif len(e.ProviderSpecific) == 0 {\n\t\treturn \"\", false\n\t}\n\tfor _, providerSpecific := range e.ProviderSpecific {\n\t\tif providerSpecific.Name == key {\n\t\t\treturn providerSpecific.Value, true\n\t\t}\n\t}\n\treturn \"\", false\n}\n\n// GetBoolProviderSpecificProperty returns a boolean provider-specific property value.\nfunc (e *Endpoint) GetBoolProviderSpecificProperty(key string) (bool, bool) {\n\tprop, ok := e.GetProviderSpecificProperty(key)\n\tif !ok {\n\t\treturn false, false\n\t}\n\tswitch prop {\n\tcase \"true\":\n\t\treturn true, true\n\tcase \"false\":\n\t\treturn false, true\n\tdefault:\n\t\treturn false, true\n\t}\n}\n\n// SetProviderSpecificProperty sets the value of a ProviderSpecificProperty.\nfunc (e *Endpoint) SetProviderSpecificProperty(key string, value string) {\n\tif len(e.ProviderSpecific) == 0 {\n\t\te.ProviderSpecific = append(e.ProviderSpecific, ProviderSpecificProperty{\n\t\t\tName:  key,\n\t\t\tValue: value,\n\t\t})\n\t\treturn\n\t}\n\tfor i, providerSpecific := range e.ProviderSpecific {\n\t\tif providerSpecific.Name == key {\n\t\t\te.ProviderSpecific[i] = ProviderSpecificProperty{\n\t\t\t\tName:  key,\n\t\t\t\tValue: value,\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\n\te.ProviderSpecific = append(e.ProviderSpecific, ProviderSpecificProperty{Name: key, Value: value})\n}\n\n// DeleteProviderSpecificProperty deletes any ProviderSpecificProperty of the specified name.\nfunc (e *Endpoint) DeleteProviderSpecificProperty(key string) {\n\tif len(e.ProviderSpecific) == 0 {\n\t\treturn\n\t}\n\tfor i, providerSpecific := range e.ProviderSpecific {\n\t\tif providerSpecific.Name == key {\n\t\t\te.ProviderSpecific = append(e.ProviderSpecific[:i], e.ProviderSpecific[i+1:]...)\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// RetainProviderProperties retains only properties whose name is prefixed with\n// \"provider/\" (e.g. \"aws/evaluate-target-health\" for provider \"aws\").\n// Properties belonging to other providers are dropped.\n// Properties with no provider prefix (e.g. \"alias\") are provider-agnostic and always retained.\n// TODO: cloudflare does not follow the \"provider/\" prefix convention — its properties use the\n// annotation form \"external-dns.alpha.kubernetes.io/cloudflare-*\", so filtering is skipped for\n// cloudflare and all properties are retained (only sorted). This should be removed once cloudflare\n// adopts the standard prefix convention.\nfunc (e *Endpoint) RetainProviderProperties(provider string) {\n\tif len(e.ProviderSpecific) == 0 {\n\t\treturn\n\t}\n\tif provider != \"\" && provider != \"cloudflare\" {\n\t\tprefix := provider + \"/\"\n\t\te.ProviderSpecific = slices.DeleteFunc(e.ProviderSpecific, func(prop ProviderSpecificProperty) bool {\n\t\t\treturn strings.Contains(prop.Name, \"/\") && !strings.HasPrefix(prop.Name, prefix)\n\t\t})\n\t}\n\tslices.SortFunc(e.ProviderSpecific, func(a, b ProviderSpecificProperty) int {\n\t\treturn cmp.Compare(a.Name, b.Name)\n\t})\n}\n\n// WithLabel adds or updates a label for the Endpoint.\n//\n// Example usage:\n//\n//\tep.WithLabel(\"owner\", \"user123\")\nfunc (e *Endpoint) WithLabel(key, value string) *Endpoint {\n\tif e.Labels == nil {\n\t\te.Labels = NewLabels()\n\t}\n\te.Labels[key] = value\n\treturn e\n}\n\n// WithRefObject sets the reference object for the Endpoint and returns the Endpoint.\n// This can be used to associate the Endpoint with a specific Kubernetes object.\nfunc (e *Endpoint) WithRefObject(obj *events.ObjectReference) *Endpoint {\n\te.refObject = obj\n\treturn e\n}\n\nfunc (e *Endpoint) RefObject() *events.ObjectReference {\n\treturn e.refObject\n}\n\n// Key returns the EndpointKey of the Endpoint.\nfunc (e *Endpoint) Key() EndpointKey {\n\treturn EndpointKey{\n\t\tDNSName:       e.DNSName,\n\t\tRecordType:    e.RecordType,\n\t\tSetIdentifier: e.SetIdentifier,\n\t}\n}\n\n// IsOwnedBy returns true if the endpoint owner label matches the given ownerID, false otherwise\nfunc (e *Endpoint) IsOwnedBy(ownerID string) bool {\n\tendpointOwner, ok := e.Labels[OwnerLabelKey]\n\treturn ok && endpointOwner == ownerID\n}\n\n// GetNakedDomain returns the parent domain of the DNS name (without the first label).\n// For example, \"www.example.com\" returns \"example.com\".\n// For apex/two-label names like \"example.com\", the full name is returned unchanged.\nfunc (e *Endpoint) GetNakedDomain() string {\n\tif e.DNSName == \"\" {\n\t\treturn \"\"\n\t}\n\tparts := strings.SplitN(e.DNSName, \".\", 2)\n\tif len(parts) < 2 || !strings.Contains(parts[1], \".\") {\n\t\treturn e.DNSName\n\t}\n\treturn parts[1]\n}\n\n// NewPTREndpoint creates a PTR endpoint from a forward IP target and one or more hostnames.\n// It computes the reverse DNS name (in-addr.arpa / ip6.arpa) from the target IP.\nfunc NewPTREndpoint(target string, ttl TTL, hostnames ...string) (*Endpoint, error) {\n\trevAddr, err := dns.ReverseAddr(target)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to compute reverse address for %s: %w\", target, err)\n\t}\n\tptrName := strings.TrimSuffix(revAddr, \".\")\n\treturn NewEndpointWithTTL(ptrName, RecordTypePTR, ttl, hostnames...), nil\n}\n\nfunc (e *Endpoint) String() string {\n\treturn fmt.Sprintf(\"%s %d IN %s %s %s %s\", e.DNSName, e.RecordTTL, e.RecordType, e.SetIdentifier, e.Targets, e.ProviderSpecific)\n}\n\nfunc (e *Endpoint) Describe() string {\n\treturn fmt.Sprintf(\"record:%s, owner:%s, type:%s, targets:%s\", e.DNSName, e.SetIdentifier, e.RecordType, strings.Join(e.Targets, \", \"))\n}\n\n// FilterEndpointsByOwnerID Apply filter to slice of endpoints and return new filtered slice that includes\n// only endpoints that match.\nfunc FilterEndpointsByOwnerID(ownerID string, eps []*Endpoint) []*Endpoint {\n\tfiltered := []*Endpoint{}\n\tfor _, ep := range eps {\n\t\tendpointOwner, ok := ep.Labels[OwnerLabelKey]\n\t\tswitch {\n\t\tcase !ok:\n\t\t\tlog.Debugf(`Skipping endpoint %v because of missing owner label (required: \"%s\")`, ep, ownerID)\n\t\tcase endpointOwner != ownerID:\n\t\t\tlog.Debugf(`Skipping endpoint %v because owner id does not match (found: \"%s\", required: \"%s\")`, ep, endpointOwner, ownerID)\n\t\tdefault:\n\t\t\tfiltered = append(filtered, ep)\n\t\t}\n\t}\n\n\treturn filtered\n}\n\n// RemoveDuplicates returns a slice holding the unique endpoints.\n// This function doesn't contemplate the Targets of an Endpoint\n// as part of the primary Key\nfunc RemoveDuplicates(endpoints []*Endpoint) []*Endpoint {\n\tvisited := make(map[EndpointKey]struct{})\n\tresult := []*Endpoint{}\n\n\tfor _, ep := range endpoints {\n\t\tkey := ep.Key()\n\n\t\tif _, found := visited[key]; !found {\n\t\t\tresult = append(result, ep)\n\t\t\tvisited[key] = struct{}{}\n\t\t} else {\n\t\t\tlog.Debugf(`Skipping duplicated endpoint: %v`, ep)\n\t\t}\n\t}\n\n\treturn result\n}\n\n// RequestedRecordType returns the value of the \"record-type\" provider-specific\n// property, following the same pattern as the alias accessor.\nfunc (e *Endpoint) RequestedRecordType() (string, bool) {\n\treturn e.GetProviderSpecificProperty(ProviderSpecificRecordType)\n}\n\n// TODO: rename to Validate\n// CheckEndpoint Check if endpoint is properly formatted according to RFC standards\nfunc (e *Endpoint) CheckEndpoint() bool {\n\tif !e.supportsAlias() {\n\t\tif _, ok := e.GetBoolProviderSpecificProperty(providerSpecificAlias); ok {\n\t\t\tlog.Warnf(\"Endpoint %s of type %s does not support alias records\", e.DNSName, e.RecordType)\n\t\t\treturn false\n\t\t}\n\t}\n\n\tswitch recordType := e.RecordType; recordType {\n\tcase RecordTypeA, RecordTypeAAAA:\n\t\tif !e.isAlias() {\n\t\t\treturn e.Targets.ValidateIPRecord(recordType)\n\t\t}\n\tcase RecordTypeMX:\n\t\treturn e.Targets.ValidateMXRecord()\n\tcase RecordTypeSRV:\n\t\treturn e.Targets.ValidateSRVRecord()\n\tcase RecordTypePTR:\n\t\treturn e.ValidatePTRRecord()\n\t}\n\treturn true\n}\n\n// isAlias returns true if the endpoint has the alias provider-specific property set to true.\nfunc (e *Endpoint) isAlias() bool {\n\tval, ok := e.GetBoolProviderSpecificProperty(providerSpecificAlias)\n\treturn ok && val\n}\n\nfunc (e *Endpoint) supportsAlias() bool {\n\tswitch e.RecordType {\n\tcase RecordTypeA, RecordTypeAAAA, RecordTypeCNAME:\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\n// WithMinTTL sets the endpoint's TTL to the given value if the current TTL is not configured.\nfunc (e *Endpoint) WithMinTTL(ttl int64) {\n\tif !e.RecordTTL.IsConfigured() && ttl > 0 {\n\t\tlog.Debugf(\"Overriding existing TTL %d with new value %d for endpoint %s\", e.RecordTTL, ttl, e.DNSName)\n\t\te.RecordTTL = TTL(ttl)\n\t}\n}\n\n// NewMXRecord parses a string representation of an MX record target (e.g., \"10 mail.example.com\")\n// and returns an MXTarget struct. Returns an error if the input is invalid.\nfunc NewMXRecord(target string) (*MXTarget, error) {\n\tparts := strings.Fields(strings.TrimSpace(target))\n\tif len(parts) != 2 {\n\t\treturn nil, fmt.Errorf(\"invalid MX record target: %s. MX records must have a preference value and a host, e.g. '10 example.com'\", target)\n\t}\n\n\tpriority, err := strconv.ParseUint(parts[0], 10, 16)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid integer value in target: %s\", target)\n\t}\n\n\treturn &MXTarget{\n\t\tpriority: uint16(priority),\n\t\thost:     parts[1],\n\t}, nil\n}\n\n// GetPriority returns the priority of the MX record target.\nfunc (m *MXTarget) GetPriority() *uint16 {\n\treturn &m.priority\n}\n\n// GetHost returns the host of the MX record target.\nfunc (m *MXTarget) GetHost() *string {\n\treturn &m.host\n}\n\nfunc (t Targets) ValidateIPRecord(recordType string) bool {\n\tfor _, target := range t {\n\t\taddr, err := netip.ParseAddr(target)\n\t\tif err != nil {\n\t\t\tlog.Debugf(\"Invalid %s record target: %s is not a valid IP address\", recordType, target)\n\t\t\treturn false\n\t\t}\n\t\tif recordType == RecordTypeA && addr.Is6() {\n\t\t\tlog.Debugf(\"Invalid A record target: %s is an IPv6 address\", target)\n\t\t\treturn false\n\t\t}\n\t\tif recordType == RecordTypeAAAA && addr.Is4() {\n\t\t\tlog.Debugf(\"Invalid AAAA record target: %s is an IPv4 address\", target)\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc (t Targets) ValidateMXRecord() bool {\n\tfor _, target := range t {\n\t\t_, err := NewMXRecord(target)\n\t\tif err != nil {\n\t\t\tlog.Debugf(\"Invalid MX record target: %s. %v\", target, err)\n\t\t\treturn false\n\t\t}\n\t}\n\n\treturn true\n}\n\nfunc (t Targets) ValidateSRVRecord() bool {\n\tfor _, target := range t {\n\t\t// SRV records must have a priority, weight, a port value and a target e.g. \"10 5 5060 example.com.\"\n\t\t// as per https://www.rfc-editor.org/rfc/rfc2782.txt the target host has to end with a dot.\n\t\ttargetParts := strings.Fields(strings.TrimSpace(target))\n\t\tif len(targetParts) != 4 {\n\t\t\tlog.Debugf(\"Invalid SRV record target: %s. SRV records must have a priority, weight, a port value and a target host, e.g. '10 5 5060 example.com.'\", target)\n\t\t\treturn false\n\t\t}\n\t\tif !strings.HasSuffix(targetParts[3], \".\") {\n\t\t\tlog.Debugf(\"Invalid SRV record target: %s. Target host does not end with a dot.'\", target)\n\t\t\treturn false\n\t\t}\n\n\t\tfor _, part := range targetParts[:3] {\n\t\t\t_, err := strconv.ParseUint(part, 10, 16)\n\t\t\tif err != nil {\n\t\t\t\tlog.Debugf(\"Invalid SRV record target: %s. Invalid integer value in target.\", target)\n\t\t\t\treturn false\n\t\t\t}\n\t\t}\n\t}\n\treturn true\n}\n\n// ValidatePTRRecord checks that a PTR endpoint has a valid reverse DNS name\n// (ending in .in-addr.arpa or .ip6.arpa) and that targets are non-empty hostnames.\nfunc (e *Endpoint) ValidatePTRRecord() bool {\n\tname := strings.ToLower(e.DNSName)\n\tif !isReverseDNSName(name) {\n\t\tlog.Debugf(\"Invalid PTR record: DNSName %q must be a valid reverse DNS name under .in-addr.arpa or .ip6.arpa\", e.DNSName)\n\t\treturn false\n\t}\n\tif len(e.Targets) == 0 {\n\t\tlog.Debugf(\"Invalid PTR record: at least one target is required for %s\", e.DNSName)\n\t\treturn false\n\t}\n\tfor _, target := range e.Targets {\n\t\tif strings.TrimSpace(target) == \"\" {\n\t\t\tlog.Debugf(\"Invalid PTR record: target must not be empty for %s\", e.DNSName)\n\t\t\treturn false\n\t\t}\n\t\tif _, err := netip.ParseAddr(target); err == nil {\n\t\t\tlog.Debugf(\"Invalid PTR record: target %q for %s must be a hostname, not an IP address\", target, e.DNSName)\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// isReverseDNSName checks that name ends with .in-addr.arpa or .ip6.arpa\n// and has at least one label before the suffix.\nfunc isReverseDNSName(name string) bool {\n\tfor _, suffix := range []string{\".in-addr.arpa\", \".ip6.arpa\"} {\n\t\tif prefix, ok := strings.CutSuffix(name, suffix); ok {\n\t\t\treturn len(prefix) > 0 && prefix[0] != '.'\n\t\t}\n\t}\n\treturn false\n}\n\n// GetDNSName returns the DNS name of the endpoint.\nfunc (e *Endpoint) GetDNSName() string {\n\treturn e.DNSName\n}\n\n// GetRecordType returns the record type of the endpoint.\nfunc (e *Endpoint) GetRecordType() string {\n\treturn e.RecordType\n}\n\n// GetRecordTTL returns the TTL of the endpoint as int64.\nfunc (e *Endpoint) GetRecordTTL() int64 {\n\treturn int64(e.RecordTTL)\n}\n\n// GetTargets returns the targets of the endpoint.\nfunc (e *Endpoint) GetTargets() []string {\n\treturn e.Targets\n}\n\n// GetOwner returns the owner of the endpoint from labels or set identifier.\nfunc (e *Endpoint) GetOwner() string {\n\tif val, ok := e.Labels[OwnerLabelKey]; ok {\n\t\treturn val\n\t}\n\treturn e.SetIdentifier\n}\n"
  },
  {
    "path": "endpoint/endpoint_benchmark_test.go",
    "content": "/*\nCopyright 2026 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage endpoint\n\nimport (\n\t\"fmt\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nvar (\n\tproviderSpecificKeys = []string{\n\t\t\"alias\",\n\t\t\"provider/target-hosted-zone\",\n\t\t\"provider/evaluate-target-health\",\n\t\t\"provider/weight\",\n\t\t\"provider/region\",\n\t\t\"provider/failover\",\n\t\t\"provider/geolocation-continent-code\",\n\t\t\"provider/geolocation-country-code\",\n\t\t\"provider/geolocation-subdivision-code\",\n\t\t\"provider/geoproximity-region\",\n\t\t\"provider/geoproximity-bias\",\n\t\t\"provider/geoproximity-coordinates\",\n\t\t\"provider/geoproximity-local-zone-group\",\n\t\t\"provider/multi-value-answer\",\n\t\t\"provider/health-check-id\",\n\t\t\"same-zone\",\n\t}\n)\n\n// TestEndpointGeneration validates that generateBenchmarkEndpoints\n// creates correct data for both slice implementations.\nfunc TestEndpointGeneration(t *testing.T) {\n\tfor _, setProps := range []int{0, 1, 3, 5, 16} {\n\t\tt.Run(fmt.Sprintf(\"set=%d\", setProps), func(t *testing.T) {\n\t\t\tendpoints := generateBenchmarkEndpoints(10, setProps)\n\t\t\tassert.Len(t, endpoints, 10)\n\t\t\tfor _, ep := range endpoints {\n\t\t\t\tassert.Len(t, ep.ProviderSpecific, setProps)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// BenchmarkProviderSpecificRealisticAccess simulates realistic provider behavior:\n// The provider checks ALL its supported properties on each endpoint,\n// even though only a few (setProps) are actually configured.\nfunc BenchmarkProviderSpecificRandomAccess(b *testing.B) {\n\t// setProps: how many properties are actually set on the endpoint\n\t// The provider will still check all 16 keys\n\tsetPropsOptions := []int{0, 1, 5, 9, 16}\n\tendpointCounts := []int{100, 1000, 10000, 50000, 100000, 200000}\n\n\tkeys := []string{\n\t\t\"provider/weight\",\n\t\t\"nonexistent\",\n\t\t\"provider/geoproximity-region\",\n\t\t\"same-zone\",\n\t}\n\n\tfor _, setProps := range setPropsOptions {\n\t\tfor _, epCount := range endpointCounts {\n\t\t\tendpoints := generateBenchmarkEndpoints(epCount, setProps)\n\t\t\tb.Run(fmt.Sprintf(\"slice/set=%d/endpoints=%d\", setProps, epCount), func(b *testing.B) {\n\t\t\t\tfor b.Loop() {\n\t\t\t\t\tfor _, ep := range endpoints {\n\t\t\t\t\t\t// Provider checks random supported properties\n\t\t\t\t\t\tfor _, key := range keys {\n\t\t\t\t\t\t\tep.GetProviderSpecificProperty(key)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\t}\n}\n\nfunc BenchmarkProviderSpecificDelete(b *testing.B) {\n\tpropertyCounts := []int{0, 5, 10}\n\tendpointCounts := []int{100, 300, 1000, 10000, 50000}\n\n\tkeys := []string{\n\t\t\"provider/weight\",\n\t\t\"nonexistent\",\n\t}\n\n\tfor _, propCount := range propertyCounts {\n\t\tfor _, epCount := range endpointCounts {\n\t\t\tb.Run(fmt.Sprintf(\"slice/props=%d/endpoints=%d\", propCount, epCount), func(b *testing.B) {\n\t\t\t\ttemplate := generateBenchmarkEndpoints(epCount, propCount)\n\t\t\t\tb.ResetTimer()\n\t\t\t\tfor b.Loop() {\n\t\t\t\t\t// Shallow copy is enough if we only care about the slice structure\n\t\t\t\t\tendpoints := make([]*Endpoint, len(template))\n\t\t\t\t\tcopy(endpoints, template)\n\t\t\t\t\tfor _, ep := range endpoints {\n\t\t\t\t\t\tfor _, key := range keys {\n\t\t\t\t\t\t\tep.DeleteProviderSpecificProperty(key)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\t}\n}\n\n// generateBenchmarkEndpoints creates endpoints with realistic AWS provider-specific properties.\n// setPropsCount determines how many of the providerSpecificKeys are actually set on each endpoint.\nfunc generateBenchmarkEndpoints(count, setPropsCount int) []*Endpoint {\n\tendpoints := make([]*Endpoint, count)\n\tfor i := range count {\n\t\tep := &Endpoint{\n\t\t\tDNSName:    fmt.Sprintf(\"endpoint-%d.example.com\", i),\n\t\t\tRecordType: RecordTypeA,\n\t\t\tTargets:    Targets{fmt.Sprintf(\"192.0.2.%d\", i%256)},\n\t\t\tRecordTTL:  TTL(300),\n\t\t\tLabels:     NewLabels(),\n\t\t}\n\n\t\t// Set only the first setPropsCount properties\n\t\tif setPropsCount > 0 {\n\t\t\tep.ProviderSpecific = make(ProviderSpecific, setPropsCount)\n\t\t\tfor j := range setPropsCount {\n\t\t\t\tkey := providerSpecificKeys[j%len(providerSpecificKeys)]\n\t\t\t\tep.ProviderSpecific[j] = ProviderSpecificProperty{\n\t\t\t\t\tName:  key,\n\t\t\t\t\tValue: fmt.Sprintf(\"value-%d\", j),\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tendpoints[i] = ep\n\t}\n\treturn endpoints\n}\n"
  },
  {
    "path": "endpoint/endpoint_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage endpoint\n\nimport (\n\t\"fmt\"\n\t\"reflect\"\n\t\"slices\"\n\t\"testing\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\tlogtest \"sigs.k8s.io/external-dns/internal/testutils/log\"\n\t\"sigs.k8s.io/external-dns/pkg/events\"\n)\n\nfunc TestNewEndpoint(t *testing.T) {\n\te := NewEndpoint(\"example.org\", \"CNAME\", \"foo.com\")\n\tif e.DNSName != \"example.org\" || e.Targets[0] != \"foo.com\" || e.RecordType != \"CNAME\" {\n\t\tt.Error(\"endpoint is not initialized correctly\")\n\t}\n\tif e.Labels == nil {\n\t\tt.Error(\"Labels is not initialized\")\n\t}\n\n\tw := NewEndpoint(\"example.org.\", \"\", \"load-balancer.com.\")\n\tif w.DNSName != \"example.org\" || w.Targets[0] != \"load-balancer.com\" || w.RecordType != \"\" {\n\t\tt.Error(\"endpoint is not initialized correctly\")\n\t}\n}\n\nfunc TestNewTargets(t *testing.T) {\n\tcases := []struct {\n\t\tname     string\n\t\tinput    []string\n\t\texpected Targets\n\t}{\n\t\t{\n\t\t\tname:     \"no targets\",\n\t\t\tinput:    []string{},\n\t\t\texpected: Targets{},\n\t\t},\n\t\t{\n\t\t\tname:     \"single target\",\n\t\t\tinput:    []string{\"1.2.3.4\"},\n\t\t\texpected: Targets{\"1.2.3.4\"},\n\t\t},\n\t\t{\n\t\t\tname:     \"multiple targets\",\n\t\t\tinput:    []string{\"example.com\", \"8.8.8.8\", \"::0001\"},\n\t\t\texpected: Targets{\"8.8.8.8\", \"::0001\", \"example.com\"},\n\t\t},\n\t}\n\n\tfor _, c := range cases {\n\t\tt.Run(c.name, func(t *testing.T) {\n\t\t\tTargets := NewTargets(c.input...)\n\t\t\tchangedTarget := Targets.String()\n\t\t\tassert.Equal(t, c.expected.String(), changedTarget)\n\t\t})\n\t}\n}\n\nfunc TestTargetsSame(t *testing.T) {\n\ttests := []Targets{\n\t\t{\"\"},\n\t\t{\"1.2.3.4\"},\n\t\t{\"8.8.8.8\", \"8.8.4.4\"},\n\t\t{\"dd:dd::01\", \"::1\", \"::0001\"},\n\t\t{\"example.org\", \"EXAMPLE.ORG\"},\n\t}\n\n\tfor _, d := range tests {\n\t\tif d.Same(d) != true {\n\t\t\tt.Errorf(\"%#v should equal %#v\", d, d)\n\t\t}\n\t}\n}\n\nfunc TestSameSuccess(t *testing.T) {\n\ttests := []struct {\n\t\ta Targets\n\t\tb Targets\n\t}{\n\t\t{\n\t\t\t[]string{\"::1\"},\n\t\t\t[]string{\"::0001\"},\n\t\t},\n\t\t{\n\t\t\t[]string{\"::1\", \"dd:dd::01\"},\n\t\t\t[]string{\"dd:00dd::0001\", \"::0001\"},\n\t\t},\n\n\t\t{\n\t\t\t[]string{\"::1\", \"dd:dd::01\"},\n\t\t\t[]string{\"00dd:dd::0001\", \"::0001\"},\n\t\t},\n\t\t{\n\t\t\t[]string{\"::1\", \"1.1.1.1\", \"2600.com\", \"3.3.3.3\"},\n\t\t\t[]string{\"2600.com\", \"::0001\", \"3.3.3.3\", \"1.1.1.1\"},\n\t\t},\n\t}\n\n\tfor _, d := range tests {\n\t\tif d.a.Same(d.b) == false {\n\t\t\tt.Errorf(\"%#v should equal %#v\", d.a, d.b)\n\t\t}\n\t}\n}\n\nfunc TestSameFailures(t *testing.T) {\n\ttests := []struct {\n\t\ta Targets\n\t\tb Targets\n\t}{\n\t\t{\n\t\t\t[]string{\"1.2.3.4\"},\n\t\t\t[]string{\"4.3.2.1\"},\n\t\t}, {\n\t\t\t[]string{\"1.2.3.4\"},\n\t\t\t[]string{\"1.2.3.4\", \"4.3.2.1\"},\n\t\t}, {\n\t\t\t[]string{\"1.2.3.4\", \"4.3.2.1\"},\n\t\t\t[]string{\"1.2.3.4\"},\n\t\t}, {\n\t\t\t[]string{\"1.2.3.4\", \"4.3.2.1\"},\n\t\t\t[]string{\"8.8.8.8\", \"8.8.4.4\"},\n\t\t},\n\t\t{\n\t\t\t[]string{\"::1\", \"2600.com\", \"3.3.3.3\"},\n\t\t\t[]string{\"2600.com\", \"3.3.3.3\", \"1.1.1.1\"},\n\t\t},\n\t}\n\n\tfor _, d := range tests {\n\t\tif d.a.Same(d.b) == true {\n\t\t\tt.Errorf(\"%#v should not equal %#v\", d.a, d.b)\n\t\t}\n\t}\n}\n\nfunc TestIsLess(t *testing.T) {\n\ttestsA := []Targets{\n\t\t{\"\"},\n\t\t{\"1.2.3.4\"},\n\t\t{\"1.2.3.4\"},\n\t\t{\"example.org\", \"example.com\"},\n\t\t{\"8.8.8.8\", \"8.8.4.4\"},\n\t\t{\"1-2-3-4.example.org\", \"EXAMPLE.ORG\"},\n\t\t{\"1-2-3-4.example.org\", \"EXAMPLE.ORG\", \"1.2.3.4\"},\n\t\t{\"example.com\", \"example.org\"},\n\t}\n\ttestsB := []Targets{\n\t\t{\"\", \"\"},\n\t\t{\"1-2-3-4.example.org\"},\n\t\t{\"1.2.3.5\"},\n\t\t{\"example.com\", \"examplea.org\"},\n\t\t{\"8.8.8.8\"},\n\t\t{\"1.2.3.4\", \"EXAMPLE.ORG\"},\n\t\t{\"1-2-3-4.example.org\", \"EXAMPLE.ORG\"},\n\t\t{\"example.com\", \"example.org\"},\n\t}\n\texpected := []bool{\n\t\ttrue,\n\t\ttrue,\n\t\ttrue,\n\t\ttrue,\n\t\tfalse,\n\t\tfalse,\n\t\tfalse,\n\t\tfalse,\n\t}\n\n\tfor i, d := range testsA {\n\t\tif d.IsLess(testsB[i]) != expected[i] {\n\t\t\tt.Errorf(\"%v < %v is expected to be %v\", d, testsB[i], expected[i])\n\t\t}\n\t}\n}\n\nfunc TestGetProviderSpecificProperty(t *testing.T) {\n\tt.Run(\"empty provider specific\", func(t *testing.T) {\n\t\te := &Endpoint{}\n\t\tval, ok := e.GetProviderSpecificProperty(\"any\")\n\t\tassert.False(t, ok)\n\t\tassert.Empty(t, val)\n\t})\n\n\tt.Run(\"key is not present in provider specific\", func(t *testing.T) {\n\t\te := &Endpoint{\n\t\t\tProviderSpecific: []ProviderSpecificProperty{\n\t\t\t\t{Name: \"name\", Value: \"value\"},\n\t\t\t},\n\t\t}\n\t\tval, ok := e.GetProviderSpecificProperty(\"hello\")\n\t\tassert.False(t, ok)\n\t\tassert.Empty(t, val)\n\t})\n\n\tt.Run(\"key is present in provider specific\", func(t *testing.T) {\n\t\te := &Endpoint{\n\t\t\tProviderSpecific: []ProviderSpecificProperty{\n\t\t\t\t{Name: \"name\", Value: \"value\"},\n\t\t\t},\n\t\t}\n\t\tval, ok := e.GetProviderSpecificProperty(\"name\")\n\t\tassert.True(t, ok)\n\t\tassert.Equal(t, \"value\", val)\n\t})\n}\n\nfunc TestSetProviderSpecficProperty(t *testing.T) {\n\tcases := []struct {\n\t\tname               string\n\t\tendpoint           Endpoint\n\t\tkey                string\n\t\tvalue              string\n\t\texpectedIdentifier string\n\t\texpected           []ProviderSpecificProperty\n\t}{\n\t\t{\n\t\t\tname:     \"endpoint is empty\",\n\t\t\tendpoint: Endpoint{},\n\t\t\tkey:      \"key1\",\n\t\t\tvalue:    \"value1\",\n\t\t\texpected: []ProviderSpecificProperty{\n\t\t\t\t{\n\t\t\t\t\tName:  \"key1\",\n\t\t\t\t\tValue: \"value1\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"name and key are not matching\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:       \"example.org\",\n\t\t\t\tRecordTTL:     TTL(0),\n\t\t\t\tRecordType:    RecordTypeA,\n\t\t\t\tSetIdentifier: \"newIdentifier\",\n\t\t\t\tTargets: Targets{\n\t\t\t\t\t\"example.org\", \"example.com\", \"1.2.4.5\",\n\t\t\t\t},\n\t\t\t\tProviderSpecific: []ProviderSpecificProperty{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  \"name1\",\n\t\t\t\t\t\tValue: \"value1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedIdentifier: \"newIdentifier\",\n\t\t\tkey:                \"name2\",\n\t\t\tvalue:              \"value2\",\n\n\t\t\texpected: []ProviderSpecificProperty{\n\t\t\t\t{\n\t\t\t\t\tName:  \"name1\",\n\t\t\t\t\tValue: \"value1\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tName:  \"name2\",\n\t\t\t\t\tValue: \"value2\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"some keys are matching and some are not matching \",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:       \"example.org\",\n\t\t\t\tRecordTTL:     TTL(0),\n\t\t\t\tRecordType:    RecordTypeA,\n\t\t\t\tSetIdentifier: \"newIdentifier\",\n\t\t\t\tTargets: Targets{\n\t\t\t\t\t\"example.org\", \"example.com\", \"1.2.4.5\",\n\t\t\t\t},\n\t\t\t\tProviderSpecific: []ProviderSpecificProperty{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  \"name1\",\n\t\t\t\t\t\tValue: \"value1\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  \"name2\",\n\t\t\t\t\t\tValue: \"value2\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  \"name3\",\n\t\t\t\t\t\tValue: \"value3\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tkey:                \"name2\",\n\t\t\tvalue:              \"value2\",\n\t\t\texpectedIdentifier: \"newIdentifier\",\n\t\t\texpected: []ProviderSpecificProperty{\n\t\t\t\t{\n\t\t\t\t\tName:  \"name1\",\n\t\t\t\t\tValue: \"value1\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tName:  \"name2\",\n\t\t\t\t\tValue: \"value2\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tName:  \"name3\",\n\t\t\t\t\tValue: \"value3\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"name and key are not matching\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:       \"example.org\",\n\t\t\t\tRecordTTL:     TTL(0),\n\t\t\t\tRecordType:    RecordTypeA,\n\t\t\t\tSetIdentifier: \"identifier\",\n\t\t\t\tTargets: Targets{\n\t\t\t\t\t\"example.org\", \"example.com\", \"1.2.4.5\",\n\t\t\t\t},\n\t\t\t\tProviderSpecific: []ProviderSpecificProperty{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  \"name1\",\n\t\t\t\t\t\tValue: \"value1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tkey:                \"name1\",\n\t\t\tvalue:              \"value2\",\n\t\t\texpectedIdentifier: \"identifier\",\n\t\t\texpected: []ProviderSpecificProperty{\n\t\t\t\t{\n\t\t\t\t\tName:  \"name1\",\n\t\t\t\t\tValue: \"value2\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, c := range cases {\n\t\tt.Run(c.name, func(t *testing.T) {\n\t\t\tc.endpoint.WithProviderSpecific(c.key, c.value)\n\t\t\texpectedString := fmt.Sprintf(\"%s %d IN %s %s %s %s\", c.endpoint.DNSName, c.endpoint.RecordTTL, c.endpoint.RecordType, c.endpoint.SetIdentifier, c.endpoint.Targets, c.endpoint.ProviderSpecific)\n\t\t\tidentifier := c.endpoint.WithSetIdentifier(c.endpoint.SetIdentifier)\n\t\t\tassert.Equal(t, c.expectedIdentifier, identifier.SetIdentifier)\n\t\t\tassert.Equal(t, expectedString, c.endpoint.String())\n\t\t\tif !reflect.DeepEqual([]ProviderSpecificProperty(c.endpoint.ProviderSpecific), c.expected) {\n\t\t\t\tt.Errorf(\"unexpected ProviderSpecific:\\nGot:      %#v\\nExpected: %#v\", c.endpoint.ProviderSpecific, c.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestDeleteProviderSpecificProperty(t *testing.T) {\n\tcases := []struct {\n\t\tname     string\n\t\tendpoint Endpoint\n\t\tkey      string\n\t\texpected []ProviderSpecificProperty\n\t}{\n\t\t{\n\t\t\tname:     \"empty provider specific\",\n\t\t\tendpoint: Endpoint{},\n\t\t\tkey:      \"any\",\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"name and key are not matching\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tProviderSpecific: []ProviderSpecificProperty{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  \"name1\",\n\t\t\t\t\t\tValue: \"value1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tkey: \"name2\",\n\t\t\texpected: []ProviderSpecificProperty{\n\t\t\t\t{\n\t\t\t\t\tName:  \"name1\",\n\t\t\t\t\tValue: \"value1\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"some keys are matching and some keys are not matching\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tProviderSpecific: []ProviderSpecificProperty{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  \"name1\",\n\t\t\t\t\t\tValue: \"value1\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  \"name2\",\n\t\t\t\t\t\tValue: \"value2\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  \"name3\",\n\t\t\t\t\t\tValue: \"value3\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tkey: \"name2\",\n\t\t\texpected: []ProviderSpecificProperty{\n\t\t\t\t{\n\t\t\t\t\tName:  \"name1\",\n\t\t\t\t\tValue: \"value1\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tName:  \"name3\",\n\t\t\t\t\tValue: \"value3\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"name and key are matching\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tProviderSpecific: []ProviderSpecificProperty{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  \"name1\",\n\t\t\t\t\t\tValue: \"value1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tkey:      \"name1\",\n\t\t\texpected: []ProviderSpecificProperty{},\n\t\t},\n\t}\n\n\tfor _, c := range cases {\n\t\tt.Run(c.name, func(t *testing.T) {\n\t\t\tc.endpoint.DeleteProviderSpecificProperty(c.key)\n\t\t\tif !reflect.DeepEqual([]ProviderSpecificProperty(c.endpoint.ProviderSpecific), c.expected) {\n\t\t\t\tt.Errorf(\"unexpected ProviderSpecific:\\nGot:      %#v\\nExpected: %#v\", c.endpoint.ProviderSpecific, c.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestRetainProviderProperties(t *testing.T) {\n\tcases := []struct {\n\t\tname     string\n\t\tendpoint Endpoint\n\t\tprovider string\n\t\texpected []ProviderSpecificProperty\n\t}{\n\t\t{\n\t\t\tname:     \"empty provider specific\",\n\t\t\tendpoint: Endpoint{},\n\t\t\tprovider: \"aws\",\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"empty provider, properties untouched\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tProviderSpecific: []ProviderSpecificProperty{\n\t\t\t\t\t{Name: \"aws/evaluate-target-health\", Value: \"true\"},\n\t\t\t\t\t{Name: \"coredns/group\", Value: \"my-group\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tprovider: \"\",\n\t\t\texpected: []ProviderSpecificProperty{\n\t\t\t\t{Name: \"aws/evaluate-target-health\", Value: \"true\"},\n\t\t\t\t{Name: \"coredns/group\", Value: \"my-group\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"all properties match provider\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tProviderSpecific: []ProviderSpecificProperty{\n\t\t\t\t\t{Name: \"aws/evaluate-target-health\", Value: \"true\"},\n\t\t\t\t\t{Name: \"aws/weight\", Value: \"10\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tprovider: \"aws\",\n\t\t\texpected: []ProviderSpecificProperty{\n\t\t\t\t{Name: \"aws/evaluate-target-health\", Value: \"true\"},\n\t\t\t\t{Name: \"aws/weight\", Value: \"10\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"no properties match provider\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tProviderSpecific: []ProviderSpecificProperty{\n\t\t\t\t\t{Name: \"coredns/group\", Value: \"my-group\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tprovider: \"aws\",\n\t\t\texpected: []ProviderSpecificProperty{},\n\t\t},\n\t\t{\n\t\t\tname: \"mixed providers, only configured provider retained\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tProviderSpecific: []ProviderSpecificProperty{\n\t\t\t\t\t{Name: \"aws/evaluate-target-health\", Value: \"true\"},\n\t\t\t\t\t{Name: \"coredns/group\", Value: \"my-group\"},\n\t\t\t\t\t{Name: \"aws/weight\", Value: \"10\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tprovider: \"aws\",\n\t\t\texpected: []ProviderSpecificProperty{\n\t\t\t\t{Name: \"aws/evaluate-target-health\", Value: \"true\"},\n\t\t\t\t{Name: \"aws/weight\", Value: \"10\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"provider agnostic properties without prefix are retained\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tProviderSpecific: []ProviderSpecificProperty{\n\t\t\t\t\t{Name: \"alias\", Value: \"true\"},\n\t\t\t\t\t{Name: \"aws/evaluate-target-health\", Value: \"true\"},\n\t\t\t\t\t{Name: \"coredns/group\", Value: \"my-group\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tprovider: \"aws\",\n\t\t\texpected: []ProviderSpecificProperty{\n\t\t\t\t{Name: \"alias\", Value: \"true\"},\n\t\t\t\t{Name: \"aws/evaluate-target-health\", Value: \"true\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"provider prefix must match exactly, not as substring\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tProviderSpecific: []ProviderSpecificProperty{\n\t\t\t\t\t{Name: \"aws-extended/some-prop\", Value: \"val\"},\n\t\t\t\t\t{Name: \"aws/weight\", Value: \"10\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tprovider: \"aws\",\n\t\t\texpected: []ProviderSpecificProperty{\n\t\t\t\t{Name: \"aws/weight\", Value: \"10\"},\n\t\t\t},\n\t\t},\n\t\t// cloudflare uses annotation-style names (e.g. \"external-dns.alpha.kubernetes.io/cloudflare-*\")\n\t\t// rather than the standard \"provider/\" prefix, so all properties are retained and only sorted.\n\t\t{\n\t\t\tname: \"cloudflare retains all properties\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tProviderSpecific: []ProviderSpecificProperty{\n\t\t\t\t\t{Name: \"external-dns.alpha.kubernetes.io/cloudflare-tags\", Value: \"tag1\"},\n\t\t\t\t\t{Name: \"aws/evaluate-target-health\", Value: \"true\"},\n\t\t\t\t\t{Name: \"alias\", Value: \"false\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tprovider: \"cloudflare\",\n\t\t\texpected: []ProviderSpecificProperty{\n\t\t\t\t{Name: \"alias\", Value: \"false\"},\n\t\t\t\t{Name: \"aws/evaluate-target-health\", Value: \"true\"},\n\t\t\t\t{Name: \"external-dns.alpha.kubernetes.io/cloudflare-tags\", Value: \"tag1\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"cloudflare properties are sorted\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tProviderSpecific: []ProviderSpecificProperty{\n\t\t\t\t\t{Name: \"external-dns.alpha.kubernetes.io/cloudflare-proxied\", Value: \"true\"},\n\t\t\t\t\t{Name: \"external-dns.alpha.kubernetes.io/cloudflare-tags\", Value: \"tag1\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tprovider: \"cloudflare\",\n\t\t\texpected: []ProviderSpecificProperty{\n\t\t\t\t{Name: \"external-dns.alpha.kubernetes.io/cloudflare-proxied\", Value: \"true\"},\n\t\t\t\t{Name: \"external-dns.alpha.kubernetes.io/cloudflare-tags\", Value: \"tag1\"},\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, c := range cases {\n\t\tt.Run(c.name, func(t *testing.T) {\n\t\t\tc.endpoint.RetainProviderProperties(c.provider)\n\t\t\trequire.Equal(t, c.expected, []ProviderSpecificProperty(c.endpoint.ProviderSpecific))\n\t\t})\n\t}\n}\n\nfunc TestFilterEndpointsByOwnerIDWithRecordTypeA(t *testing.T) {\n\tfoo1 := &Endpoint{\n\t\tDNSName:    \"foo.com\",\n\t\tRecordType: RecordTypeA,\n\t\tLabels: Labels{\n\t\t\tOwnerLabelKey: \"foo\",\n\t\t},\n\t}\n\tfoo2 := &Endpoint{\n\t\tDNSName:    \"foo2.com\",\n\t\tRecordType: RecordTypeA,\n\t\tLabels: Labels{\n\t\t\tOwnerLabelKey: \"foo\",\n\t\t},\n\t}\n\tbar := &Endpoint{\n\t\tDNSName:    \"foo.com\",\n\t\tRecordType: RecordTypeA,\n\t\tLabels: Labels{\n\t\t\tOwnerLabelKey: \"bar\",\n\t\t},\n\t}\n\ttype args struct {\n\t\townerID string\n\t\teps     []*Endpoint\n\t}\n\ttests := []struct {\n\t\tname string\n\t\targs args\n\t\twant []*Endpoint\n\t}{\n\t\t{\n\t\t\tname: \"filter values\",\n\t\t\targs: args{\n\t\t\t\townerID: \"foo\",\n\t\t\t\teps: []*Endpoint{\n\t\t\t\t\tfoo1,\n\t\t\t\t\tfoo2,\n\t\t\t\t\tbar,\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []*Endpoint{\n\t\t\t\tfoo1,\n\t\t\t\tfoo2,\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := FilterEndpointsByOwnerID(tt.args.ownerID, tt.args.eps); !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Errorf(\"ApplyEndpointFilter() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestFilterEndpointsByOwnerIDWithRecordTypeCNAME(t *testing.T) {\n\tfoo1 := &Endpoint{\n\t\tDNSName:    \"foo.com\",\n\t\tRecordType: RecordTypeCNAME,\n\t\tLabels: Labels{\n\t\t\tOwnerLabelKey: \"foo\",\n\t\t},\n\t}\n\tfoo2 := &Endpoint{\n\t\tDNSName:    \"foo2.com\",\n\t\tRecordType: RecordTypeCNAME,\n\t\tLabels: Labels{\n\t\t\tOwnerLabelKey: \"foo\",\n\t\t},\n\t}\n\tbar := &Endpoint{\n\t\tDNSName:    \"foo.com\",\n\t\tRecordType: RecordTypeCNAME,\n\t\tLabels: Labels{\n\t\t\tOwnerLabelKey: \"bar\",\n\t\t},\n\t}\n\ttype args struct {\n\t\townerID string\n\t\teps     []*Endpoint\n\t}\n\ttests := []struct {\n\t\tname string\n\t\targs args\n\t\twant []*Endpoint\n\t}{\n\t\t{\n\t\t\tname: \"filter values\",\n\t\t\targs: args{\n\t\t\t\townerID: \"foo\",\n\t\t\t\teps: []*Endpoint{\n\t\t\t\t\tfoo1,\n\t\t\t\t\tfoo2,\n\t\t\t\t\tbar,\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []*Endpoint{\n\t\t\t\tfoo1,\n\t\t\t\tfoo2,\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := FilterEndpointsByOwnerID(tt.args.ownerID, tt.args.eps); !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Errorf(\"ApplyEndpointFilter() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestFilterEndpointsByOwnerID_Logs(t *testing.T) {\n\tconst (\n\t\tmsgMismatch = \"owner id does not match\"\n\t\tmsgMissing  = \"missing owner label\"\n\t)\n\n\tmatching := &Endpoint{DNSName: \"foo.com\", RecordType: RecordTypeA, Labels: Labels{OwnerLabelKey: \"foo\"}}\n\tmismatch := &Endpoint{DNSName: \"bar.com\", RecordType: RecordTypeA, Labels: Labels{OwnerLabelKey: \"bar\"}}\n\tnoLabel := &Endpoint{DNSName: \"baz.com\", RecordType: RecordTypeA}\n\n\ttests := []struct {\n\t\tname     string\n\t\teps      []*Endpoint\n\t\twantLogs []string\n\t}{\n\t\t{\n\t\t\tname: \"no log: all endpoints match owner\",\n\t\t\teps:  []*Endpoint{matching},\n\t\t},\n\t\t{\n\t\t\tname:     \"logs owner mismatch\",\n\t\t\teps:      []*Endpoint{matching, mismatch},\n\t\t\twantLogs: []string{msgMismatch},\n\t\t},\n\t\t{\n\t\t\tname:     \"logs missing owner label\",\n\t\t\teps:      []*Endpoint{matching, noLabel},\n\t\t\twantLogs: []string{msgMissing},\n\t\t},\n\t\t{\n\t\t\tname:     \"logs both mismatch and missing label\",\n\t\t\teps:      []*Endpoint{matching, mismatch, noLabel},\n\t\t\twantLogs: []string{msgMismatch, msgMissing},\n\t\t},\n\t}\n\n\tallMsgs := []string{msgMismatch, msgMissing}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\thook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t)\n\t\t\tFilterEndpointsByOwnerID(\"foo\", tt.eps)\n\t\t\tfor _, msg := range allMsgs {\n\t\t\t\tif slices.Contains(tt.wantLogs, msg) {\n\t\t\t\t\tlogtest.TestHelperLogContainsWithLogLevel(msg, log.DebugLevel, hook, t)\n\t\t\t\t} else {\n\t\t\t\t\tlogtest.TestHelperLogNotContains(msg, hook, t)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestIsOwnedBy(t *testing.T) {\n\ttype fields struct {\n\t\tLabels Labels\n\t}\n\ttype args struct {\n\t\townerID string\n\t}\n\ttests := []struct {\n\t\tname   string\n\t\tfields fields\n\t\targs   args\n\t\twant   bool\n\t}{\n\t\t{\n\t\t\tname:   \"empty labels\",\n\t\t\tfields: fields{Labels: Labels{}},\n\t\t\targs:   args{ownerID: \"foo\"},\n\t\t\twant:   false,\n\t\t},\n\t\t{\n\t\t\tname:   \"owner label not match\",\n\t\t\tfields: fields{Labels: Labels{OwnerLabelKey: \"bar\"}},\n\t\t\targs:   args{ownerID: \"foo\"},\n\t\t\twant:   false,\n\t\t},\n\t\t{\n\t\t\tname:   \"owner label match\",\n\t\t\tfields: fields{Labels: Labels{OwnerLabelKey: \"foo\"}},\n\t\t\targs:   args{ownerID: \"foo\"},\n\t\t\twant:   true,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\te := &Endpoint{\n\t\t\t\tLabels: tt.fields.Labels,\n\t\t\t}\n\t\t\tif got := e.IsOwnedBy(tt.args.ownerID); got != tt.want {\n\t\t\t\tt.Errorf(\"Endpoint.isOwnedBy() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestDuplicatedEndpointsWithSimpleZone(t *testing.T) {\n\tfoo1 := &Endpoint{\n\t\tDNSName:    \"foo.com\",\n\t\tRecordType: RecordTypeA,\n\t\tLabels: Labels{\n\t\t\tOwnerLabelKey: \"foo\",\n\t\t},\n\t}\n\tfoo2 := &Endpoint{\n\t\tDNSName:    \"foo.com\",\n\t\tRecordType: RecordTypeA,\n\t\tLabels: Labels{\n\t\t\tOwnerLabelKey: \"foo\",\n\t\t},\n\t}\n\tbar := &Endpoint{\n\t\tDNSName:    \"foo.com\",\n\t\tRecordType: RecordTypeA,\n\t\tLabels: Labels{\n\t\t\tOwnerLabelKey: \"bar\",\n\t\t},\n\t}\n\n\ttype args struct {\n\t\teps []*Endpoint\n\t}\n\ttests := []struct {\n\t\tname string\n\t\targs args\n\t\twant []*Endpoint\n\t}{\n\t\t{\n\t\t\tname: \"filter values\",\n\t\t\targs: args{\n\t\t\t\teps: []*Endpoint{\n\t\t\t\t\tfoo1,\n\t\t\t\t\tfoo2,\n\t\t\t\t\tbar,\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []*Endpoint{\n\t\t\t\tfoo1,\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := RemoveDuplicates(tt.args.eps); !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Errorf(\"RemoveDuplicates() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestDuplicatedEndpointsWithOverlappingZones(t *testing.T) {\n\tfoo1 := &Endpoint{\n\t\tDNSName:    \"internal.foo.com\",\n\t\tRecordType: RecordTypeA,\n\t\tLabels: Labels{\n\t\t\tOwnerLabelKey: \"foo\",\n\t\t},\n\t}\n\tfoo2 := &Endpoint{\n\t\tDNSName:    \"internal.foo.com\",\n\t\tRecordType: RecordTypeA,\n\t\tLabels: Labels{\n\t\t\tOwnerLabelKey: \"foo\",\n\t\t},\n\t}\n\tfoo3 := &Endpoint{\n\t\tDNSName:    \"foo.com\",\n\t\tRecordType: RecordTypeA,\n\t\tLabels: Labels{\n\t\t\tOwnerLabelKey: \"foo\",\n\t\t},\n\t}\n\tfoo4 := &Endpoint{\n\t\tDNSName:    \"foo.com\",\n\t\tRecordType: RecordTypeA,\n\t\tLabels: Labels{\n\t\t\tOwnerLabelKey: \"foo\",\n\t\t},\n\t}\n\tbar := &Endpoint{\n\t\tDNSName:    \"internal.foo.com\",\n\t\tRecordType: RecordTypeA,\n\t\tLabels: Labels{\n\t\t\tOwnerLabelKey: \"bar\",\n\t\t},\n\t}\n\tbar2 := &Endpoint{\n\t\tDNSName:    \"foo.com\",\n\t\tRecordType: RecordTypeA,\n\t\tLabels: Labels{\n\t\t\tOwnerLabelKey: \"bar\",\n\t\t},\n\t}\n\n\ttype args struct {\n\t\teps []*Endpoint\n\t}\n\ttests := []struct {\n\t\tname string\n\t\targs args\n\t\twant []*Endpoint\n\t}{\n\t\t{\n\t\t\tname: \"filter values\",\n\t\t\targs: args{\n\t\t\t\teps: []*Endpoint{\n\t\t\t\t\tfoo1,\n\t\t\t\t\tfoo2,\n\t\t\t\t\tfoo3,\n\t\t\t\t\tfoo4,\n\t\t\t\t\tbar,\n\t\t\t\t\tbar2,\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []*Endpoint{\n\t\t\t\tfoo1,\n\t\t\t\tfoo3,\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := RemoveDuplicates(tt.args.eps); !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Errorf(\"RemoveDuplicates() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPDNScheckEndpoint(t *testing.T) {\n\ttests := []struct {\n\t\tdescription string\n\t\tendpoint    Endpoint\n\t\texpected    bool\n\t}{\n\t\t{\n\t\t\tdescription: \"Valid MX record target\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:    \"example.com\",\n\t\t\t\tRecordType: RecordTypeMX,\n\t\t\t\tTargets:    Targets{\"10 example.com\"},\n\t\t\t},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tdescription: \"Valid MX record with multiple targets\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:    \"example.com\",\n\t\t\t\tRecordType: RecordTypeMX,\n\t\t\t\tTargets:    Targets{\"10 example.com\", \"20 backup.example.com\"},\n\t\t\t},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tdescription: \"MX record with valid and invalid targets\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:    \"example.com\",\n\t\t\t\tRecordType: RecordTypeMX,\n\t\t\t\tTargets:    Targets{\"example.com\", \"backup.example.com\"},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tdescription: \"Invalid MX record with missing priority value\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:    \"example.com\",\n\t\t\t\tRecordType: RecordTypeMX,\n\t\t\t\tTargets:    Targets{\"example.com\"},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tdescription: \"Invalid MX record with too many arguments\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:    \"example.com\",\n\t\t\t\tRecordType: RecordTypeMX,\n\t\t\t\tTargets:    Targets{\"10 example.com abc\"},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tdescription: \"Invalid MX record with non-integer priority\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:    \"example.com\",\n\t\t\t\tRecordType: RecordTypeMX,\n\t\t\t\tTargets:    Targets{\"abc example.com\"},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tdescription: \"Valid SRV record target\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:    \"_service._tls.example.com\",\n\t\t\t\tRecordType: RecordTypeSRV,\n\t\t\t\tTargets:    Targets{\"10 20 5060 service.example.com.\"},\n\t\t\t},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tdescription: \"Invalid SRV record with missing part\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:    \"_service._tls.example.com\",\n\t\t\t\tRecordType: RecordTypeSRV,\n\t\t\t\tTargets:    Targets{\"10 20 5060\"},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tdescription: \"Invalid SRV record with non-integer part\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:    \"_service._tls.example.com\",\n\t\t\t\tRecordType: RecordTypeSRV,\n\t\t\t\tTargets:    Targets{\"10 20 abc service.example.com.\"},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tdescription: \"Invalid SRV record with missing dot for target host\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:    \"_service._tls.example.com\",\n\t\t\t\tRecordType: RecordTypeSRV,\n\t\t\t\tTargets:    Targets{\"10 20 5060 service.example.com\"},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tactual := tt.endpoint.CheckEndpoint()\n\t\tassert.Equal(t, tt.expected, actual)\n\t}\n}\n\nfunc TestNewMXTarget(t *testing.T) {\n\ttests := []struct {\n\t\tdescription string\n\t\ttarget      string\n\t\texpected    *MXTarget\n\t\texpectError bool\n\t}{\n\t\t{\n\t\t\tdescription: \"Valid MX record\",\n\t\t\ttarget:      \"10 example.com\",\n\t\t\texpected:    &MXTarget{priority: 10, host: \"example.com\"},\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tdescription: \"Invalid MX record with missing priority\",\n\t\t\ttarget:      \"example.com\",\n\t\t\texpectError: true,\n\t\t},\n\t\t{\n\t\t\tdescription: \"Invalid MX record with non-integer priority\",\n\t\t\ttarget:      \"abc example.com\",\n\t\t\texpectError: true,\n\t\t},\n\t\t{\n\t\t\tdescription: \"Invalid MX record with too many parts\",\n\t\t\ttarget:      \"10 example.com extra\",\n\t\t\texpectError: true,\n\t\t},\n\t\t{\n\t\t\tdescription: \"Missing host\",\n\t\t\ttarget:      \"10 \",\n\t\t\texpected:    nil,\n\t\t\texpectError: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.description, func(t *testing.T) {\n\t\t\tactual, err := NewMXRecord(tt.target)\n\t\t\tif tt.expectError {\n\t\t\t\tassert.Error(t, err)\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t\tassert.Equal(t, tt.expected, actual)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCheckEndpoint(t *testing.T) {\n\ttests := []struct {\n\t\tdescription string\n\t\tendpoint    Endpoint\n\t\texpected    bool\n\t}{\n\t\t{\n\t\t\tdescription: \"Valid MX record target\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:    \"example.com\",\n\t\t\t\tRecordType: RecordTypeMX,\n\t\t\t\tTargets:    Targets{\"10 example.com\"},\n\t\t\t},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tdescription: \"Invalid MX record target\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:    \"example.com\",\n\t\t\t\tRecordType: RecordTypeMX,\n\t\t\t\tTargets:    Targets{\"example.com\"},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tdescription: \"Valid SRV record target\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:    \"_service._tcp.example.com\",\n\t\t\t\tRecordType: RecordTypeSRV,\n\t\t\t\tTargets:    Targets{\"10 5 5060 example.com.\"},\n\t\t\t},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tdescription: \"Invalid SRV record target\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:    \"_service._tcp.example.com\",\n\t\t\t\tRecordType: RecordTypeSRV,\n\t\t\t\tTargets:    Targets{\"10 5 example.com.\"},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tdescription: \"Non-MX/SRV record type\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:    \"example.com\",\n\t\t\t\tRecordType: RecordTypeA,\n\t\t\t\tTargets:    Targets{\"192.168.1.1\"},\n\t\t\t},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tdescription: \"Valid AAAA record\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:    \"example.com\",\n\t\t\t\tRecordType: RecordTypeAAAA,\n\t\t\t\tTargets:    Targets{\"2001:db8::1\"},\n\t\t\t},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tdescription: \"Invalid A record - not an IP\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:    \"example.com\",\n\t\t\t\tRecordType: RecordTypeA,\n\t\t\t\tTargets:    Targets{\"not-an-ip\"},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tdescription: \"Invalid A record - IPv6 address\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:    \"example.com\",\n\t\t\t\tRecordType: RecordTypeA,\n\t\t\t\tTargets:    Targets{\"2001:db8::1\"},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tdescription: \"Invalid AAAA record - IPv4 address\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:    \"example.com\",\n\t\t\t\tRecordType: RecordTypeAAAA,\n\t\t\t\tTargets:    Targets{\"192.168.1.1\"},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tdescription: \"Invalid AAAA record - not an IP\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:    \"example.com\",\n\t\t\t\tRecordType: RecordTypeAAAA,\n\t\t\t\tTargets:    Targets{\"not-an-ip\"},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tdescription: \"A record with alias=true is valid\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:          \"example.com\",\n\t\t\t\tRecordType:       RecordTypeA,\n\t\t\t\tTargets:          Targets{\"my-elb-123.us-east-1.elb.amazonaws.com\"},\n\t\t\t\tProviderSpecific: ProviderSpecific{{Name: providerSpecificAlias, Value: \"true\"}},\n\t\t\t},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tdescription: \"AAAA record with alias=true is valid\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:          \"example.com\",\n\t\t\t\tRecordType:       RecordTypeAAAA,\n\t\t\t\tTargets:          Targets{\"dualstack.my-elb-123.us-east-1.elb.amazonaws.com\"},\n\t\t\t\tProviderSpecific: ProviderSpecific{{Name: providerSpecificAlias, Value: \"true\"}},\n\t\t\t},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tdescription: \"CNAME record with alias=true is valid\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:          \"example.com\",\n\t\t\t\tRecordType:       RecordTypeCNAME,\n\t\t\t\tTargets:          Targets{\"d111111abcdef8.cloudfront.net\"},\n\t\t\t\tProviderSpecific: ProviderSpecific{{Name: providerSpecificAlias, Value: \"true\"}},\n\t\t\t},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tdescription: \"MX record with alias=true is invalid\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:          \"example.com\",\n\t\t\t\tRecordType:       RecordTypeMX,\n\t\t\t\tTargets:          Targets{\"10 mail.example.com\"},\n\t\t\t\tProviderSpecific: ProviderSpecific{{Name: providerSpecificAlias, Value: \"true\"}},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tdescription: \"TXT record with alias=true is invalid\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:          \"example.com\",\n\t\t\t\tRecordType:       RecordTypeTXT,\n\t\t\t\tTargets:          Targets{\"v=spf1 ~all\"},\n\t\t\t\tProviderSpecific: ProviderSpecific{{Name: providerSpecificAlias, Value: \"true\"}},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tdescription: \"NS record with alias=true is invalid\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:          \"example.com\",\n\t\t\t\tRecordType:       RecordTypeNS,\n\t\t\t\tTargets:          Targets{\"ns1.example.com\"},\n\t\t\t\tProviderSpecific: ProviderSpecific{{Name: providerSpecificAlias, Value: \"true\"}},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tdescription: \"SRV record with alias=true is invalid\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:          \"_sip._tcp.example.com\",\n\t\t\t\tRecordType:       RecordTypeSRV,\n\t\t\t\tTargets:          Targets{\"10 5 5060 sip.example.com.\"},\n\t\t\t\tProviderSpecific: ProviderSpecific{{Name: providerSpecificAlias, Value: \"true\"}},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tdescription: \"MX record with alias=false is also invalid\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:          \"example.com\",\n\t\t\t\tRecordType:       RecordTypeMX,\n\t\t\t\tTargets:          Targets{\"10 mail.example.com\"},\n\t\t\t\tProviderSpecific: ProviderSpecific{{Name: providerSpecificAlias, Value: \"false\"}},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tdescription: \"MX record without alias property is valid\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:    \"example.com\",\n\t\t\t\tRecordType: RecordTypeMX,\n\t\t\t\tTargets:    Targets{\"10 mail.example.com\"},\n\t\t\t},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tdescription: \"Valid PTR record with in-addr.arpa\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:    \"2.49.168.192.in-addr.arpa\",\n\t\t\t\tRecordType: RecordTypePTR,\n\t\t\t\tTargets:    Targets{\"web.example.com\"},\n\t\t\t},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tdescription: \"Valid PTR record with ip6.arpa\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:    \"1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa\",\n\t\t\t\tRecordType: RecordTypePTR,\n\t\t\t\tTargets:    Targets{\"v6.example.com\"},\n\t\t\t},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tdescription: \"Valid PTR record with multiple hostname targets\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:    \"1.0.0.10.in-addr.arpa\",\n\t\t\t\tRecordType: RecordTypePTR,\n\t\t\t\tTargets:    Targets{\"a.example.com\", \"b.example.com\"},\n\t\t\t},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tdescription: \"Invalid PTR record - DNS name not reverse DNS\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:    \"web.example.com\",\n\t\t\t\tRecordType: RecordTypePTR,\n\t\t\t\tTargets:    Targets{\"10.0.0.1\"},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tdescription: \"Invalid PTR record - target is an IP address\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:    \"1.0.0.10.in-addr.arpa\",\n\t\t\t\tRecordType: RecordTypePTR,\n\t\t\t\tTargets:    Targets{\"10.0.0.1\"},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tdescription: \"Invalid PTR record - target is an IPv6 address\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:    \"1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa\",\n\t\t\t\tRecordType: RecordTypePTR,\n\t\t\t\tTargets:    Targets{\"2001:db8::1\"},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tdescription: \"Invalid PTR record - empty target\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:    \"1.0.0.10.in-addr.arpa\",\n\t\t\t\tRecordType: RecordTypePTR,\n\t\t\t\tTargets:    Targets{\"\"},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tdescription: \"Invalid PTR record - no targets\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:    \"1.0.0.10.in-addr.arpa\",\n\t\t\t\tRecordType: RecordTypePTR,\n\t\t\t\tTargets:    Targets{},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tdescription: \"Invalid PTR record - bare in-addr.arpa\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:    \"in-addr.arpa\",\n\t\t\t\tRecordType: RecordTypePTR,\n\t\t\t\tTargets:    Targets{\"web.example.com\"},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tdescription: \"Invalid PTR record - dot-prefixed in-addr.arpa\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:    \".in-addr.arpa\",\n\t\t\t\tRecordType: RecordTypePTR,\n\t\t\t\tTargets:    Targets{\"web.example.com\"},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tdescription: \"Invalid PTR record - bare ip6.arpa\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:    \"ip6.arpa\",\n\t\t\t\tRecordType: RecordTypePTR,\n\t\t\t\tTargets:    Targets{\"web.example.com\"},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tdescription: \"Invalid PTR record - dot-prefixed ip6.arpa\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tDNSName:    \".ip6.arpa\",\n\t\t\t\tRecordType: RecordTypePTR,\n\t\t\t\tTargets:    Targets{\"web.example.com\"},\n\t\t\t},\n\t\t\texpected: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.description, func(t *testing.T) {\n\t\t\tactual := tt.endpoint.CheckEndpoint()\n\t\t\tassert.Equal(t, tt.expected, actual)\n\t\t})\n\t}\n}\n\nfunc TestCheckEndpoint_AliasWarningLog(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tep      Endpoint\n\t\twantLog bool\n\t}{\n\t\t{\n\t\t\tname: \"unsupported type with alias logs warning\",\n\t\t\tep: Endpoint{\n\t\t\t\tDNSName:          \"example.com\",\n\t\t\t\tRecordType:       RecordTypeMX,\n\t\t\t\tTargets:          Targets{\"10 mail.example.com\"},\n\t\t\t\tProviderSpecific: ProviderSpecific{{Name: providerSpecificAlias, Value: \"true\"}},\n\t\t\t},\n\t\t\twantLog: true,\n\t\t},\n\t\t{\n\t\t\tname: \"supported type with alias does not log\",\n\t\t\tep: Endpoint{\n\t\t\t\tDNSName:          \"example.com\",\n\t\t\t\tRecordType:       RecordTypeA,\n\t\t\t\tTargets:          Targets{\"my-elb-123.us-east-1.elb.amazonaws.com\"},\n\t\t\t\tProviderSpecific: ProviderSpecific{{Name: providerSpecificAlias, Value: \"true\"}},\n\t\t\t},\n\t\t\twantLog: false,\n\t\t},\n\t\t{\n\t\t\tname: \"unsupported type without alias does not log\",\n\t\t\tep: Endpoint{\n\t\t\t\tDNSName:    \"example.com\",\n\t\t\t\tRecordType: RecordTypeMX,\n\t\t\t\tTargets:    Targets{\"10 mail.example.com\"},\n\t\t\t},\n\t\t\twantLog: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\thook := logtest.LogsUnderTestWithLogLevel(log.WarnLevel, t)\n\n\t\t\ttt.ep.CheckEndpoint()\n\n\t\t\twarnMsg := \"does not support alias records\"\n\t\t\tif tt.wantLog {\n\t\t\t\tlogtest.TestHelperLogContains(warnMsg, hook, t)\n\t\t\t} else {\n\t\t\t\tlogtest.TestHelperLogNotContains(warnMsg, hook, t)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCheckEndpoint_PTRValidationLog(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tep      Endpoint\n\t\twantLog string\n\t}{\n\t\t{\n\t\t\tname: \"non-reverse DNS name logs invalid\",\n\t\t\tep: Endpoint{\n\t\t\t\tDNSName:    \"web.example.com\",\n\t\t\t\tRecordType: RecordTypePTR,\n\t\t\t\tTargets:    Targets{\"other.example.com\"},\n\t\t\t},\n\t\t\twantLog: \"must be a valid reverse DNS name\",\n\t\t},\n\t\t{\n\t\t\tname: \"IP address target logs invalid\",\n\t\t\tep: Endpoint{\n\t\t\t\tDNSName:    \"1.0.0.10.in-addr.arpa\",\n\t\t\t\tRecordType: RecordTypePTR,\n\t\t\t\tTargets:    Targets{\"10.0.0.1\"},\n\t\t\t},\n\t\t\twantLog: \"must be a hostname, not an IP address\",\n\t\t},\n\t\t{\n\t\t\tname: \"empty target logs invalid\",\n\t\t\tep: Endpoint{\n\t\t\t\tDNSName:    \"1.0.0.10.in-addr.arpa\",\n\t\t\t\tRecordType: RecordTypePTR,\n\t\t\t\tTargets:    Targets{\"\"},\n\t\t\t},\n\t\t\twantLog: \"target must not be empty\",\n\t\t},\n\t\t{\n\t\t\tname: \"no targets logs invalid\",\n\t\t\tep: Endpoint{\n\t\t\t\tDNSName:    \"1.0.0.10.in-addr.arpa\",\n\t\t\t\tRecordType: RecordTypePTR,\n\t\t\t\tTargets:    Targets{},\n\t\t\t},\n\t\t\twantLog: \"at least one target is required\",\n\t\t},\n\t\t{\n\t\t\tname: \"valid PTR does not log\",\n\t\t\tep: Endpoint{\n\t\t\t\tDNSName:    \"2.49.168.192.in-addr.arpa\",\n\t\t\t\tRecordType: RecordTypePTR,\n\t\t\t\tTargets:    Targets{\"web.example.com\"},\n\t\t\t},\n\t\t\twantLog: \"\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\thook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t)\n\n\t\t\ttt.ep.CheckEndpoint()\n\n\t\t\tif tt.wantLog != \"\" {\n\t\t\t\tlogtest.TestHelperLogContains(tt.wantLog, hook, t)\n\t\t\t} else {\n\t\t\t\tlogtest.TestHelperLogNotContains(\"Invalid PTR record\", hook, t)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEndpoint_WithRefObject(t *testing.T) {\n\tep := &Endpoint{}\n\tref := &events.ObjectReference{\n\t\tKind:      \"Service\",\n\t\tNamespace: \"default\",\n\t\tName:      \"my-service\",\n\t}\n\tresult := ep.WithRefObject(ref)\n\n\tassert.Equal(t, ref, ep.RefObject(), \"refObject should be set\")\n\tassert.Equal(t, ep, result, \"should return the same Endpoint pointer\")\n}\n\nfunc TestTargets_UniqueOrdered(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tinput    Targets\n\t\texpected Targets\n\t}{\n\t\t{\n\t\t\tname:     \"no duplicates\",\n\t\t\tinput:    Targets{\"a.example.com\", \"b.example.com\"},\n\t\t\texpected: Targets{\"a.example.com\", \"b.example.com\"},\n\t\t},\n\t\t{\n\t\t\tname:     \"with duplicates\",\n\t\t\tinput:    Targets{\"a.example.com\", \"b.example.com\", \"a.example.com\"},\n\t\t\texpected: Targets{\"a.example.com\", \"b.example.com\"},\n\t\t},\n\t\t{\n\t\t\tname:     \"all duplicates\",\n\t\t\tinput:    []string{\"a.example.com\", \"a.example.com\", \"a.example.com\"},\n\t\t\texpected: Targets{\"a.example.com\"},\n\t\t},\n\t\t{\n\t\t\tname:     \"already sorted\",\n\t\t\tinput:    Targets{\"a.example.com\", \"c.example.com\", \"d.example.com\"},\n\t\t\texpected: Targets{\"a.example.com\", \"c.example.com\", \"d.example.com\"},\n\t\t},\n\t\t{\n\t\t\tname:     \"unsorted input\",\n\t\t\tinput:    Targets{\"z.example.com\", \"a.example.com\", \"m.example.com\"},\n\t\t\texpected: Targets{\"a.example.com\", \"m.example.com\", \"z.example.com\"},\n\t\t},\n\t\t{\n\t\t\tname:     \"empty input\",\n\t\t\tinput:    Targets{},\n\t\t\texpected: Targets{},\n\t\t},\n\t\t{\n\t\t\tname:     \"single element\",\n\t\t\tinput:    Targets{\"only.example.com\"},\n\t\t\texpected: Targets{\"only.example.com\"},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := NewTargets(tt.input...)\n\t\t\tassert.Equal(t, tt.expected, result)\n\t\t})\n\t}\n}\n\nfunc TestEndpoint_WithMinTTL(t *testing.T) {\n\ttests := []struct {\n\t\tname         string\n\t\tinitialTTL   TTL\n\t\tinputTTL     int64\n\t\texpectedTTL  TTL\n\t\tisConfigured bool\n\t}{\n\t\t{\n\t\t\tname:         \"sets TTL when not configured and input > 0\",\n\t\t\tinitialTTL:   0,\n\t\t\tinputTTL:     300,\n\t\t\texpectedTTL:  300,\n\t\t\tisConfigured: true,\n\t\t},\n\t\t{\n\t\t\tname:         \"does not override when already configured\",\n\t\t\tinitialTTL:   120,\n\t\t\tinputTTL:     300,\n\t\t\texpectedTTL:  120,\n\t\t\tisConfigured: true,\n\t\t},\n\t\t{\n\t\t\tname:         \"does not set when input is zero\",\n\t\t\tinitialTTL:   30,\n\t\t\tinputTTL:     0,\n\t\t\texpectedTTL:  30,\n\t\t\tisConfigured: true,\n\t\t},\n\t\t{\n\t\t\tname:        \"does not set when input is negative\",\n\t\t\tinitialTTL:  0,\n\t\t\tinputTTL:    -10,\n\t\t\texpectedTTL: 0,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tep := &Endpoint{RecordTTL: tt.initialTTL}\n\t\t\tep.WithMinTTL(tt.inputTTL)\n\t\t\tassert.Equal(t, tt.expectedTTL, ep.RecordTTL)\n\t\t\tassert.Equal(t, tt.isConfigured, ep.RecordTTL.IsConfigured())\n\t\t})\n\t}\n}\n\n// TestNewEndpointWithTTLPreservesDotsInTXTRecords tests that trailing dots are preserved in TXT records\nfunc TestNewEndpointWithTTLPreservesDotsInTXTRecords(t *testing.T) {\n\t// TXT records should preserve trailing dots (and any arbitrary text)\n\ttxtEndpoint := NewEndpointWithTTL(\"example.com\", RecordTypeTXT, TTL(300),\n\t\t\"v=1;some_signature=aBx3d5..\",\n\t\t\"text.with.dots...\",\n\t\t\"simple-text\")\n\n\trequire.NotNil(t, txtEndpoint, \"TXT endpoint should be created\")\n\trequire.Len(t, txtEndpoint.Targets, 3, \"should have 3 targets\")\n\n\t// All dots should be preserved in TXT targets\n\tassert.Equal(t, \"v=1;some_signature=aBx3d5..\", txtEndpoint.Targets[0])\n\tassert.Equal(t, \"text.with.dots...\", txtEndpoint.Targets[1])\n\tassert.Equal(t, \"simple-text\", txtEndpoint.Targets[2])\n\n\t// Domain name record types should still have trailing dots trimmed\n\taEndpoint := NewEndpointWithTTL(\"example.com\", RecordTypeA, TTL(300), \"1.2.3.4.\")\n\trequire.NotNil(t, aEndpoint, \"A endpoint should be created\")\n\tassert.Equal(t, \"1.2.3.4\", aEndpoint.Targets[0], \"A record should have trailing dot trimmed\")\n\n\tcnameEndpoint := NewEndpointWithTTL(\"example.com\", RecordTypeCNAME, TTL(300), \"target.example.com.\")\n\trequire.NotNil(t, cnameEndpoint, \"CNAME endpoint should be created\")\n\tassert.Equal(t, \"target.example.com\", cnameEndpoint.Targets[0], \"CNAME record should have trailing dot trimmed\")\n}\n\nfunc TestGetBoolProviderSpecificProperty(t *testing.T) {\n\ttests := []struct {\n\t\tname           string\n\t\tendpoint       Endpoint\n\t\tkey            string\n\t\texpectedValue  bool\n\t\texpectedExists bool\n\t}{\n\t\t{\n\t\t\tname:           \"key does not exist\",\n\t\t\tendpoint:       Endpoint{},\n\t\t\tkey:            \"nonexistent\",\n\t\t\texpectedValue:  false,\n\t\t\texpectedExists: false,\n\t\t},\n\t\t{\n\t\t\tname: \"key exists with true value\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tProviderSpecific: []ProviderSpecificProperty{\n\t\t\t\t\t{Name: \"enabled\", Value: \"true\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tkey:            \"enabled\",\n\t\t\texpectedValue:  true,\n\t\t\texpectedExists: true,\n\t\t},\n\t\t{\n\t\t\tname: \"key exists with false value\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tProviderSpecific: []ProviderSpecificProperty{\n\t\t\t\t\t{Name: \"disabled\", Value: \"false\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tkey:            \"disabled\",\n\t\t\texpectedValue:  false,\n\t\t\texpectedExists: true,\n\t\t},\n\t\t{\n\t\t\tname: \"key exists with invalid boolean value\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tProviderSpecific: []ProviderSpecificProperty{\n\t\t\t\t\t{Name: \"invalid\", Value: \"maybe\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tkey:            \"invalid\",\n\t\t\texpectedValue:  false,\n\t\t\texpectedExists: true,\n\t\t},\n\t\t{\n\t\t\tname: \"key exists with empty value\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tProviderSpecific: []ProviderSpecificProperty{\n\t\t\t\t\t{Name: \"empty\", Value: \"\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tkey:            \"empty\",\n\t\t\texpectedValue:  false,\n\t\t\texpectedExists: true,\n\t\t},\n\t\t{\n\t\t\tname: \"key exists with numeric value\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tProviderSpecific: []ProviderSpecificProperty{\n\t\t\t\t\t{Name: \"numeric\", Value: \"1\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tkey:            \"numeric\",\n\t\t\texpectedValue:  false,\n\t\t\texpectedExists: true,\n\t\t},\n\t\t{\n\t\t\tname: \"multiple properties, find correct one\",\n\t\t\tendpoint: Endpoint{\n\t\t\t\tProviderSpecific: []ProviderSpecificProperty{\n\t\t\t\t\t{Name: \"first\", Value: \"invalid\"},\n\t\t\t\t\t{Name: \"second\", Value: \"true\"},\n\t\t\t\t\t{Name: \"third\", Value: \"false\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tkey:            \"second\",\n\t\t\texpectedValue:  true,\n\t\t\texpectedExists: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvalue, exists := tt.endpoint.GetBoolProviderSpecificProperty(tt.key)\n\t\t\tassert.Equal(t, tt.expectedValue, value)\n\t\t\tassert.Equal(t, tt.expectedExists, exists)\n\t\t})\n\t}\n}\n\nfunc TestGetOwnerId(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tendpoint *Endpoint\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname: \"owner label is set\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tLabels: Labels{\n\t\t\t\t\tOwnerLabelKey: \"my-owner\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: \"my-owner\",\n\t\t},\n\t\t{\n\t\t\tname: \"owner label is empty string\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tLabels: Labels{\n\t\t\t\t\tOwnerLabelKey: \"\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"owner label is not set\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tLabels: Labels{\n\t\t\t\t\t\"other-label\": \"value\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"labels map is empty\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tLabels: Labels{},\n\t\t\t},\n\t\t\texpected: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"labels map is nil\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tLabels: nil,\n\t\t\t},\n\t\t\texpected: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"multiple labels with owner\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tLabels: Labels{\n\t\t\t\t\tOwnerLabelKey: \"owner-123\",\n\t\t\t\t\t\"other-key\":   \"other-value\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: \"owner-123\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := tt.endpoint.GetOwner()\n\t\t\tassert.Equal(t, tt.expected, result)\n\t\t})\n\t}\n}\n\nfunc TestGetNakedDomain(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tendpoint *Endpoint\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname: \"standard subdomain\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tDNSName: \"www.example.com\",\n\t\t\t},\n\t\t\texpected: \"example.com\",\n\t\t},\n\t\t{\n\t\t\tname: \"nested subdomain\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tDNSName: \"api.v1.example.com\",\n\t\t\t},\n\t\t\texpected: \"v1.example.com\",\n\t\t},\n\t\t{\n\t\t\tname: \"root domain only\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tDNSName: \"example.com\",\n\t\t\t},\n\t\t\texpected: \"example.com\",\n\t\t},\n\t\t{\n\t\t\tname: \"single label (no dots)\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tDNSName: \"localhost\",\n\t\t\t},\n\t\t\texpected: \"localhost\",\n\t\t},\n\t\t{\n\t\t\tname: \"empty DNS name\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tDNSName: \"\",\n\t\t\t},\n\t\t\texpected: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"deeply nested subdomain\",\n\t\t\tendpoint: &Endpoint{\n\t\t\t\tDNSName: \"a.b.c.d.example.com\",\n\t\t\t},\n\t\t\texpected: \"b.c.d.example.com\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := tt.endpoint.GetNakedDomain()\n\t\t\tassert.Equal(t, tt.expected, result)\n\n\t\t})\n\t}\n}\n\nfunc TestRequestedRecordType(t *testing.T) {\n\tep := NewEndpoint(\"example.com\", RecordTypeA, \"1.2.3.4\").\n\t\tWithProviderSpecific(ProviderSpecificRecordType, \"ptr\")\n\tval, ok := ep.RequestedRecordType()\n\tassert.True(t, ok)\n\tassert.Equal(t, \"ptr\", val)\n\n\tep2 := NewEndpoint(\"example.com\", RecordTypeA, \"1.2.3.4\")\n\t_, ok = ep2.RequestedRecordType()\n\tassert.False(t, ok)\n}\n\nfunc TestNewPTREndpoint(t *testing.T) {\n\ttests := []struct {\n\t\tname      string\n\t\ttarget    string\n\t\tttl       TTL\n\t\thostnames []string\n\t\twantName  string\n\t\twantErr   bool\n\t}{\n\t\t{\n\t\t\tname:      \"IPv4\",\n\t\t\ttarget:    \"192.168.49.2\",\n\t\t\tttl:       300,\n\t\t\thostnames: []string{\"web.example.com\"},\n\t\t\twantName:  \"2.49.168.192.in-addr.arpa\",\n\t\t},\n\t\t{\n\t\t\tname:      \"IPv6\",\n\t\t\ttarget:    \"2001:db8::1\",\n\t\t\tttl:       600,\n\t\t\thostnames: []string{\"v6.example.com\"},\n\t\t\twantName:  \"1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa\",\n\t\t},\n\t\t{\n\t\t\tname:      \"multiple hostnames\",\n\t\t\ttarget:    \"10.0.0.1\",\n\t\t\tttl:       60,\n\t\t\thostnames: []string{\"a.example.com\", \"b.example.com\"},\n\t\t\twantName:  \"1.0.0.10.in-addr.arpa\",\n\t\t},\n\t\t{\n\t\t\tname:    \"invalid target\",\n\t\t\ttarget:  \"not-an-ip\",\n\t\t\twantErr: true,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tep, err := NewPTREndpoint(tt.target, tt.ttl, tt.hostnames...)\n\t\t\tif tt.wantErr {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\trequire.NoError(t, err)\n\t\t\tassert.Equal(t, tt.wantName, ep.DNSName)\n\t\t\tassert.Equal(t, RecordTypePTR, ep.RecordType)\n\t\t\tassert.Equal(t, tt.ttl, ep.RecordTTL)\n\t\t\tassert.Equal(t, Targets(tt.hostnames), ep.Targets)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "endpoint/labels.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage endpoint\n\nimport (\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"errors\"\n\t\"fmt\"\n\t\"sort\"\n\t\"strings\"\n)\n\n// ErrInvalidHeritage is returned when heritage was not found, or different heritage is found\nvar ErrInvalidHeritage = errors.New(\"heritage is unknown or not found\")\n\nconst (\n\theritage = \"external-dns\"\n\t// OwnerLabelKey is the name of the label that defines the owner of an Endpoint.\n\tOwnerLabelKey = \"owner\"\n\t// ResourceLabelKey is the name of the label that identifies k8s resource which wants to acquire the DNS name\n\tResourceLabelKey = \"resource\"\n\t// OwnedRecordLabelKey is the name of the label that identifies the record that is owned by the labeled TXT registry record\n\tOwnedRecordLabelKey = \"ownedRecord\"\n\n\t// AWSSDDescriptionLabel label responsible for storing raw owner/resource combination information in the Labels\n\t// supposed to be inserted by AWS SD Provider, and parsed into OwnerLabelKey and ResourceLabelKey key by AWS SD Registry\n\tAWSSDDescriptionLabel = \"aws-sd-description\"\n\n\t// txtEncryptionNonce label for keep same nonce for same txt records, for prevent different result of encryption for same txt record, it can cause issues for some providers\n\ttxtEncryptionNonce = \"txt-encryption-nonce\"\n)\n\n// Labels store metadata related to the endpoint\n// it is then stored in a persistent storage via serialization\ntype Labels map[string]string\n\n// NewLabels returns empty Labels\nfunc NewLabels() Labels {\n\treturn map[string]string{}\n}\n\n// NewLabelsFromString constructs endpoints labels from a provided format string\n// if heritage set to another value is found then error is returned\n// no heritage automatically assumes is not owned by external-dns and returns invalidHeritage error\nfunc NewLabelsFromStringPlain(labelText string) (Labels, error) {\n\tendpointLabels := map[string]string{}\n\tlabelText = strings.Trim(labelText, \"\\\"\") // drop quotes\n\ttokens := strings.Split(labelText, \",\")\n\tfoundExternalDNSHeritage := false\n\tfor _, token := range tokens {\n\t\tif len(strings.Split(token, \"=\")) != 2 {\n\t\t\tcontinue\n\t\t}\n\t\tkey := strings.Split(token, \"=\")[0]\n\t\tval := strings.Split(token, \"=\")[1]\n\t\tif key == \"heritage\" && val != heritage {\n\t\t\treturn nil, ErrInvalidHeritage\n\t\t}\n\t\tif key == \"heritage\" {\n\t\t\tfoundExternalDNSHeritage = true\n\t\t\tcontinue\n\t\t}\n\t\tif strings.HasPrefix(key, heritage) {\n\t\t\tendpointLabels[strings.TrimPrefix(key, heritage+\"/\")] = val\n\t\t}\n\t}\n\n\tif !foundExternalDNSHeritage {\n\t\treturn nil, ErrInvalidHeritage\n\t}\n\n\treturn endpointLabels, nil\n}\n\nfunc NewLabelsFromString(labelText string, aesKey []byte) (Labels, error) {\n\tif len(aesKey) != 0 {\n\t\tdecryptedText, encryptionNonce, err := DecryptText(strings.Trim(labelText, \"\\\"\"), aesKey)\n\t\t// in case if we have a decryption error, try process original text\n\t\t// decryption errors should be ignored here, because we can already have plain-text labels in the registry\n\t\tif err == nil {\n\t\t\tlabels, err := NewLabelsFromStringPlain(decryptedText)\n\t\t\tif err == nil {\n\t\t\t\tlabels[txtEncryptionNonce] = encryptionNonce\n\t\t\t}\n\n\t\t\treturn labels, err\n\t\t}\n\t}\n\treturn NewLabelsFromStringPlain(labelText)\n}\n\n// SerializePlain transforms endpoints labels into a external-dns recognizable format string\n// withQuotes adds additional quotes\nfunc (l Labels) SerializePlain(withQuotes bool) string {\n\tvar tokens []string\n\ttokens = append(tokens, fmt.Sprintf(\"heritage=%s\", heritage))\n\tvar keys []string\n\tfor key := range l {\n\t\tkeys = append(keys, key)\n\t}\n\tsort.Strings(keys) // sort for consistency\n\n\tfor _, key := range keys {\n\t\tif key == txtEncryptionNonce {\n\t\t\tcontinue\n\t\t}\n\t\ttokens = append(tokens, fmt.Sprintf(\"%s/%s=%s\", heritage, key, l[key]))\n\t}\n\tif withQuotes {\n\t\treturn fmt.Sprintf(\"\\\"%s\\\"\", strings.Join(tokens, \",\"))\n\t}\n\treturn strings.Join(tokens, \",\")\n}\n\n// Serialize same to SerializePlain, but encrypt data, if encryption enabled\nfunc (l Labels) Serialize(withQuotes bool, txtEncryptEnabled bool, aesKey []byte) string {\n\tif !txtEncryptEnabled {\n\t\treturn l.SerializePlain(withQuotes)\n\t}\n\n\tencryptionNonce, ok := l[txtEncryptionNonce]\n\tif !ok {\n\t\tvar err error\n\t\tencryptionNonce, err = GenerateNonce()\n\t\tif err != nil {\n\t\t\tlog.Fatalf(\"Failed to generate cryptographic nonce: %v\", err)\n\t\t}\n\t\tl[txtEncryptionNonce] = encryptionNonce\n\t}\n\n\ttext := l.SerializePlain(false)\n\tlog.Debugf(\"Encrypt the serialized text %#v before returning it.\", text)\n\tvar err error\n\ttext, err = EncryptText(text, aesKey, encryptionNonce)\n\tif err != nil {\n\t\t// TODO: review if we could return error instead of crashing the external-dns\n\t\t// if encryption failed, the external-dns will crash\n\t\tlog.Fatalf(\"Failed to encrypt the text: %v\", err)\n\t}\n\n\tif withQuotes {\n\t\ttext = fmt.Sprintf(\"\\\"%s\\\"\", text)\n\t}\n\tlog.Debugf(\"Serialized text after encryption is %#v.\", text)\n\treturn text\n}\n"
  },
  {
    "path": "endpoint/labels_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage endpoint\n\nimport (\n\t\"bytes\"\n\t\"crypto/rand\"\n\t\"fmt\"\n\t\"testing\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/suite\"\n)\n\ntype LabelsSuite struct {\n\tsuite.Suite\n\taesKey                       []byte\n\tfoo                          Labels\n\tfooAsText                    string\n\tfooAsTextWithQuotes          string\n\tfooAsTextEncrypted           string\n\tfooAsTextWithQuotesEncrypted string\n\tbarText                      string\n\tbarTextEncrypted             string\n\tbarTextAsMap                 Labels\n\tnoHeritageText               string\n\twrongHeritageText            string\n\tmultipleHeritageText         string // considered invalid\n}\n\nfunc (suite *LabelsSuite) SetupTest() {\n\tsuite.foo = map[string]string{\n\t\t\"owner\":    \"foo-owner\",\n\t\t\"resource\": \"foo-resource\",\n\t}\n\tsuite.aesKey = []byte(\")K_Fy|?Z.64#UuHm`}[d!GC%WJM_fs{_\")\n\tsuite.fooAsText = \"heritage=external-dns,external-dns/owner=foo-owner,external-dns/resource=foo-resource\"\n\tsuite.fooAsTextWithQuotes = fmt.Sprintf(`\"%s\"`, suite.fooAsText)\n\tsuite.fooAsTextEncrypted = `+lvP8q9KHJ6BS6O81i2Q6DLNdf2JSKy8j/gbZKviTZlGYj7q+yDoYMgkQ1hPn6urtGllM5bfFMcaaHto52otQtiOYrX8990J3kQqg4s47m3bH3Ejl8RSxSSuWJM3HJtPghQzYg0/LSOsdQ0=`\n\tsuite.fooAsTextWithQuotesEncrypted = fmt.Sprintf(`\"%s\"`, suite.fooAsTextEncrypted)\n\tsuite.barTextAsMap = map[string]string{\n\t\t\"owner\":    \"bar-owner\",\n\t\t\"resource\": \"bar-resource\",\n\t\t\"new-key\":  \"bar-new-key\",\n\t}\n\tsuite.barText = \"heritage=external-dns,,external-dns/owner=bar-owner,external-dns/resource=bar-resource,external-dns/new-key=bar-new-key,random=stuff,no-equal-sign,,\" // also has some random gibberish\n\tsuite.barTextEncrypted = \"yi6vVATlgYN0enXBIupVK2atNUKtajofWMroWtvZjUanFZXlWvqjJPpjmMd91kv86bZj+syQEP0uR3TK6eFVV7oKFh/NxYyh238FjZ+25zlXW9TgbLoMalUNOkhKFdfXkLeeaqJjePB59t+kQBYX+ZEryK652asPs6M+xTIvtg07N7WWZ6SjJujm0RRISg==\"\n\tsuite.noHeritageText = \"external-dns/owner=random-owner\"\n\tsuite.wrongHeritageText = \"heritage=mate,external-dns/owner=random-owner\"\n\tsuite.multipleHeritageText = \"heritage=mate,heritage=external-dns,external-dns/owner=random-owner\"\n}\n\nfunc (suite *LabelsSuite) TestSerialize() {\n\tsuite.Equal(suite.fooAsText, suite.foo.SerializePlain(false), \"should serializeLabel\")\n\tsuite.Equal(suite.fooAsTextWithQuotes, suite.foo.SerializePlain(true), \"should serializeLabel\")\n\tsuite.Equal(suite.fooAsText, suite.foo.Serialize(false, false, nil), \"should serializeLabel\")\n\tsuite.Equal(suite.fooAsTextWithQuotes, suite.foo.Serialize(true, false, nil), \"should serializeLabel\")\n\tsuite.Equal(suite.fooAsText, suite.foo.Serialize(false, false, suite.aesKey), \"should serializeLabel\")\n\tsuite.Equal(suite.fooAsTextWithQuotes, suite.foo.Serialize(true, false, suite.aesKey), \"should serializeLabel\")\n\tsuite.NotEqual(suite.fooAsText, suite.foo.Serialize(false, true, suite.aesKey), \"should serializeLabel and encrypt\")\n\tsuite.NotEqual(suite.fooAsTextWithQuotes, suite.foo.Serialize(true, true, suite.aesKey), \"should serializeLabel and encrypt\")\n}\n\nfunc (suite *LabelsSuite) TestEncryptionNonceReUsage() {\n\tfoo, err := NewLabelsFromString(suite.fooAsTextEncrypted, suite.aesKey)\n\tsuite.NoError(err, \"should succeed for valid label text\")\n\tserialized := foo.Serialize(false, true, suite.aesKey)\n\tsuite.Equal(serialized, suite.fooAsTextEncrypted, \"serialized result should be equal\")\n}\n\nfunc (suite *LabelsSuite) TestEncryptionKeyChanged() {\n\tfoo, err := NewLabelsFromString(suite.fooAsTextEncrypted, suite.aesKey)\n\tsuite.NoError(err, \"should succeed for valid label text\")\n\n\tserialised := foo.Serialize(false, true, []byte(\"passphrasewhichneedstobe32bytes!\"))\n\tsuite.NotEqual(serialised, suite.fooAsTextEncrypted, \"serialized result should be equal\")\n}\n\nfunc (suite *LabelsSuite) TestEncryptionFailed() {\n\tfoo, err := NewLabelsFromString(suite.fooAsTextEncrypted, suite.aesKey)\n\tsuite.NoError(err, \"should succeed for valid label text\")\n\n\tdefer func() { log.StandardLogger().ExitFunc = nil }()\n\n\tb := new(bytes.Buffer)\n\n\tvar fatalCrash bool\n\tlog.StandardLogger().ExitFunc = func(int) { fatalCrash = true }\n\tlog.StandardLogger().SetOutput(b)\n\n\t_ = foo.Serialize(false, true, []byte(\"wrong-key\"))\n\n\tsuite.True(fatalCrash, \"should fail if encryption key is wrong\")\n\tsuite.Contains(b.String(), \"Failed to encrypt the text:\")\n}\n\nfunc (suite *LabelsSuite) TestEncryptionFailedFaultyReader() {\n\tfoo, err := NewLabelsFromString(suite.fooAsTextEncrypted, suite.aesKey)\n\tsuite.NoError(err, \"should succeed for valid label text\")\n\n\t// remove encryption nonce just for simplicity, so that we could regenerate nonce\n\tdelete(foo, txtEncryptionNonce)\n\n\toriginalRandReader := rand.Reader\n\tdefer func() {\n\t\tlog.StandardLogger().ExitFunc = nil\n\t\trand.Reader = originalRandReader\n\t}()\n\n\t// Replace rand.Reader with a faulty reader\n\trand.Reader = &faultyReader{}\n\n\tb := new(bytes.Buffer)\n\n\tvar fatalCrash bool\n\tlog.StandardLogger().ExitFunc = func(int) { fatalCrash = true }\n\tlog.StandardLogger().SetOutput(b)\n\n\t_ = foo.Serialize(false, true, suite.aesKey)\n\n\tsuite.True(fatalCrash)\n\tsuite.Contains(b.String(), \"Failed to generate cryptographic nonce\")\n}\n\nfunc (suite *LabelsSuite) TestDeserialize() {\n\tfoo, err := NewLabelsFromStringPlain(suite.fooAsText)\n\tsuite.NoError(err, \"should succeed for valid label text\")\n\tsuite.Equal(suite.foo, foo, \"should reconstruct original label map\")\n\n\tfoo, err = NewLabelsFromStringPlain(suite.fooAsTextWithQuotes)\n\tsuite.NoError(err, \"should succeed for valid label text\")\n\tsuite.Equal(suite.foo, foo, \"should reconstruct original label map\")\n\n\tfoo, err = NewLabelsFromString(suite.fooAsTextEncrypted, suite.aesKey)\n\tsuite.NoError(err, \"should succeed for valid encrypted label text\")\n\tfor key, val := range suite.foo {\n\t\tsuite.Equal(val, foo[key], \"should contains all keys from original label map\")\n\t}\n\n\tfoo, err = NewLabelsFromString(suite.fooAsTextWithQuotesEncrypted, suite.aesKey)\n\tsuite.NoError(err, \"should succeed for valid encrypted label text\")\n\tfor key, val := range suite.foo {\n\t\tsuite.Equal(val, foo[key], \"should contains all keys from original label map\")\n\t}\n\n\tbar, err := NewLabelsFromStringPlain(suite.barText)\n\tsuite.NoError(err, \"should succeed for valid label text\")\n\tsuite.Equal(suite.barTextAsMap, bar, \"should reconstruct original label map\")\n\n\tbar, err = NewLabelsFromString(suite.barText, suite.aesKey)\n\tsuite.NoError(err, \"should succeed for valid encrypted label text\")\n\tsuite.Equal(suite.barTextAsMap, bar, \"should reconstruct original label map\")\n\n\tnoHeritage, err := NewLabelsFromStringPlain(suite.noHeritageText)\n\tsuite.Equal(ErrInvalidHeritage, err, \"should fail if no heritage is found\")\n\tsuite.Nil(noHeritage, \"should return nil\")\n\n\twrongHeritage, err := NewLabelsFromStringPlain(suite.wrongHeritageText)\n\tsuite.Equal(ErrInvalidHeritage, err, \"should fail if wrong heritage is found\")\n\tsuite.Nil(wrongHeritage, \"if error should return nil\")\n\n\tmultipleHeritage, err := NewLabelsFromStringPlain(suite.multipleHeritageText)\n\tsuite.Equal(ErrInvalidHeritage, err, \"should fail if multiple heritage is found\")\n\tsuite.Nil(multipleHeritage, \"if error should return nil\")\n}\n\nfunc TestLabels(t *testing.T) {\n\tsuite.Run(t, new(LabelsSuite))\n}\n"
  },
  {
    "path": "endpoint/target_filter.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage endpoint\n\nimport (\n\t\"net\"\n\t\"strings\"\n\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// TargetFilterInterface defines the interface to select matching targets for a specific provider or runtime\ntype TargetFilterInterface interface {\n\tMatch(target string) bool\n\tIsEnabled() bool\n}\n\n// TargetNetFilter holds a lists of valid target names\ntype TargetNetFilter struct {\n\t// filterNets define what targets to match\n\tfilterNets []*net.IPNet\n\t// excludeNets define what targets not to match\n\texcludeNets []*net.IPNet\n}\n\n// prepareTargetFilters provides consistent trimming for filters/exclude params\nfunc prepareTargetFilters(filters []string) []*net.IPNet {\n\tfs := make([]*net.IPNet, 0)\n\n\tfor _, filter := range filters {\n\t\tfilter = strings.TrimSpace(filter)\n\t\t_, filterNet, err := net.ParseCIDR(filter)\n\t\tif err != nil {\n\t\t\tlog.Errorf(\"Invalid target net filter: %s\", filter)\n\t\t\tcontinue\n\t\t}\n\n\t\tfs = append(fs, filterNet)\n\t}\n\treturn fs\n}\n\n// NewTargetNetFilterWithExclusions returns a new TargetNetFilter, given a list of matches and exclusions\nfunc NewTargetNetFilterWithExclusions(targetFilterNets []string, excludeNets []string) TargetNetFilter {\n\treturn TargetNetFilter{filterNets: prepareTargetFilters(targetFilterNets), excludeNets: prepareTargetFilters(excludeNets)}\n}\n\n// Match checks whether a target can be found in the TargetNetFilter.\nfunc (tf TargetNetFilter) Match(target string) bool {\n\treturn matchTargetNetFilter(tf.filterNets, target, true) && !matchTargetNetFilter(tf.excludeNets, target, false)\n}\n\n// IsEnabled returns true if any filters or exclusions are set.\nfunc (tf TargetNetFilter) IsEnabled() bool {\n\treturn len(tf.filterNets) > 0 || len(tf.excludeNets) > 0\n}\n\n// matchTargetNetFilter determines if any `filters` match `target`.\n// If no `filters` are provided, behavior depends on `emptyval`\n// (empty `tf.filters` matches everything, while empty `tf.exclude` excludes nothing)\nfunc matchTargetNetFilter(filters []*net.IPNet, target string, emptyval bool) bool {\n\tif len(filters) == 0 {\n\t\treturn emptyval\n\t}\n\n\tip := net.ParseIP(target)\n\n\tfor _, filter := range filters {\n\t\tif filter.Contains(ip) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n"
  },
  {
    "path": "endpoint/target_filter_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage endpoint\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\ntype targetFilterTest struct {\n\ttargetFilter []string\n\texclusions   []string\n\ttargets      []string\n\texpected     bool\n}\n\nvar targetFilterTests = []targetFilterTest{\n\t{\n\t\t[]string{\"10.0.0.0/8\"},\n\t\t[]string{},\n\t\t[]string{\"10.1.2.3\"},\n\t\ttrue,\n\t},\n\t{\n\t\t[]string{\" 10.0.0.0/8 \"},\n\t\t[]string{},\n\t\t[]string{\"10.1.2.3\"},\n\t\ttrue,\n\t},\n\t{\n\t\t[]string{\"0\"},\n\t\t[]string{},\n\t\t[]string{\"10.1.2.3\"},\n\t\ttrue,\n\t},\n\t{\n\t\t[]string{\"10.0.0.0/8\"},\n\t\t[]string{},\n\t\t[]string{\"1.1.1.1\"},\n\t\tfalse,\n\t},\n\t{\n\t\t[]string{},\n\t\t[]string{\"10.0.0.0/8\"},\n\t\t[]string{\"1.1.1.1\"},\n\t\ttrue,\n\t},\n\t{\n\t\t[]string{},\n\t\t[]string{\"10.0.0.0/8\"},\n\t\t[]string{\"10.1.2.3\"},\n\t\tfalse,\n\t},\n\t{\n\t\t[]string{},\n\t\t[]string{\"10.0.0.0/8\"},\n\t\t[]string{\"49.13.41.161\"},\n\t\ttrue,\n\t},\n\t{\n\t\t[]string{},\n\t\t[]string{\"10.0.0.0/8\"},\n\t\t[]string{\"10.0.1.101\"},\n\t\tfalse,\n\t},\n}\n\nfunc TestTargetFilterWithExclusions(t *testing.T) {\n\tfor i, tt := range targetFilterTests {\n\t\tif len(tt.exclusions) == 0 {\n\t\t\ttt.exclusions = append(tt.exclusions, \"\")\n\t\t}\n\t\ttargetFilter := NewTargetNetFilterWithExclusions(tt.targetFilter, tt.exclusions)\n\t\tfor _, target := range tt.targets {\n\t\t\tassert.Equal(t, tt.expected, targetFilter.Match(target), \"should not fail: %v in test-case #%v\", target, i)\n\t\t}\n\t}\n}\n\nfunc TestTargetFilterMatchWithEmptyFilter(t *testing.T) {\n\tfor _, tt := range targetFilterTests {\n\t\ttargetFilter := TargetNetFilter{}\n\t\tfor i, target := range tt.targets {\n\t\t\tassert.True(t, targetFilter.Match(target), \"should not fail: %v in test-case #%v\", target, i)\n\t\t}\n\t}\n}\n\nfunc TestTargetNetFilter_IsEnabled(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tfilterNets  []string\n\t\texcludeNets []string\n\t\twant        bool\n\t}{\n\t\t{\"both empty\", []string{}, []string{}, false},\n\t\t{\"filterNets non-empty\", []string{\"10.0.0.0/8\"}, []string{}, true},\n\t\t{\"excludeNets non-empty\", []string{}, []string{\"10.0.0.0/8\"}, true},\n\t\t{\"both non-empty\", []string{\"10.0.0.0/8\"}, []string{\"192.168.0.0/16\"}, true},\n\t}\n\n\tfor _, tt := range tests {\n\t\ttf := NewTargetNetFilterWithExclusions(tt.filterNets, tt.excludeNets)\n\t\tassert.Equal(t, tt.want, tf.IsEnabled())\n\t}\n}\n"
  },
  {
    "path": "endpoint/utils.go",
    "content": "/*\nCopyright 2026 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage endpoint\n\nimport (\n\t\"net/netip\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\n\t\"sigs.k8s.io/external-dns/pkg/events\"\n)\n\nconst (\n\tmsg = \"No endpoints could be generated from '%s/%s/%s'\"\n)\n\n// SuitableType returns the DNS record type for the given target:\n// A for IPv4, AAAA for IPv6, CNAME for everything else.\nfunc SuitableType(target string) string {\n\tip, err := netip.ParseAddr(target)\n\tif err != nil {\n\t\treturn RecordTypeCNAME\n\t}\n\tswitch {\n\tcase ip.Is4():\n\t\treturn RecordTypeA\n\tcase ip.Is6():\n\t\treturn RecordTypeAAAA\n\tdefault:\n\t\treturn RecordTypeCNAME\n\t}\n}\n\n// HasNoEmptyEndpoints checks if the endpoint list is empty and logs\n// a debug message if so. Returns true if empty, false otherwise.\nfunc HasNoEmptyEndpoints(\n\tendpoints []*Endpoint,\n\trType string, entity metav1.ObjectMetaAccessor,\n) bool {\n\tif len(endpoints) == 0 {\n\t\tlog.Debugf(msg, rType, entity.GetObjectMeta().GetNamespace(), entity.GetObjectMeta().GetName())\n\t\treturn true\n\t}\n\treturn false\n}\n\n// EndpointsForHostname returns endpoint objects for each host-target combination,\n// grouping targets by their suitable DNS record type (A, AAAA, or CNAME).\nfunc EndpointsForHostname(hostname string, targets Targets, ttl TTL, providerSpecific ProviderSpecific, setIdentifier string, resource string) []*Endpoint {\n\tbyType := map[string]Targets{}\n\tfor _, t := range targets {\n\t\trt := SuitableType(t)\n\t\tbyType[rt] = append(byType[rt], t)\n\t}\n\n\tvar endpoints []*Endpoint\n\tfor _, rt := range []string{RecordTypeA, RecordTypeAAAA, RecordTypeCNAME} {\n\t\tif len(byType[rt]) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tep := NewEndpointWithTTL(hostname, rt, ttl, byType[rt]...)\n\t\tif ep == nil {\n\t\t\tcontinue\n\t\t}\n\t\tep.ProviderSpecific = providerSpecific\n\t\tep.SetIdentifier = setIdentifier\n\t\tif resource != \"\" {\n\t\t\tep.Labels[ResourceLabelKey] = resource\n\t\t}\n\t\tendpoints = append(endpoints, ep)\n\t}\n\treturn endpoints\n}\n\n// AttachRefObject sets the same ObjectReference on every endpoint in eps.\n// The reference is shared across all endpoints, so callers should create it once\n// per source object rather than once per endpoint.\nfunc AttachRefObject(eps []*Endpoint, ref *events.ObjectReference) {\n\tfor _, ep := range eps {\n\t\tep.WithRefObject(ref)\n\t}\n}\n"
  },
  {
    "path": "endpoint/utils_test.go",
    "content": "/*\nCopyright 2026 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage endpoint\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n)\n\ntype mockObjectMetaAccessor struct {\n\tnamespace string\n\tname      string\n}\n\nfunc (m *mockObjectMetaAccessor) GetObjectMeta() metav1.Object {\n\treturn &metav1.ObjectMeta{\n\t\tNamespace: m.namespace,\n\t\tName:      m.name,\n\t}\n}\n\nfunc TestSuitableType(t *testing.T) {\n\ttests := []struct {\n\t\ttarget   string\n\t\texpected string\n\t}{\n\t\t// IPv4\n\t\t{\"192.168.1.1\", RecordTypeA},\n\t\t{\"255.255.255.255\", RecordTypeA},\n\t\t{\"0.0.0.0\", RecordTypeA},\n\t\t// IPv6\n\t\t{\"2001:0db8:85a3:0000:0000:8a2e:0370:7334\", RecordTypeAAAA},\n\t\t{\"2001:db8:85a3::8a2e:370:7334\", RecordTypeAAAA},\n\t\t{\"::ffff:192.168.20.3\", RecordTypeAAAA}, // IPv4-mapped IPv6\n\t\t{\"::1\", RecordTypeAAAA},\n\t\t{\"::\", RecordTypeAAAA},\n\t\t// CNAME (hostname or invalid)\n\t\t{\"example.com\", RecordTypeCNAME},\n\t\t{\"\", RecordTypeCNAME},\n\t\t{\"256.256.256.256\", RecordTypeCNAME},\n\t\t{\"192.168.0.1/22\", RecordTypeCNAME},\n\t\t{\"192.168.1\", RecordTypeCNAME},\n\t\t{\"abc.def.ghi.jkl\", RecordTypeCNAME},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.target, func(t *testing.T) {\n\t\t\tassert.Equal(t, tt.expected, SuitableType(tt.target))\n\t\t})\n\t}\n}\n\nfunc TestHasEmptyEndpoints(t *testing.T) {\n\ttests := []struct {\n\t\tname      string\n\t\tendpoints []*Endpoint\n\t\trType     string\n\t\tentity    metav1.ObjectMetaAccessor\n\t\texpected  bool\n\t}{\n\t\t{\n\t\t\tname:      \"nil endpoints returns true\",\n\t\t\tendpoints: nil,\n\t\t\trType:     \"Service\",\n\t\t\tentity:    &mockObjectMetaAccessor{namespace: \"default\", name: \"my-service\"},\n\t\t\texpected:  true,\n\t\t},\n\t\t{\n\t\t\tname:      \"empty slice returns true\",\n\t\t\tendpoints: []*Endpoint{},\n\t\t\trType:     \"Ingress\",\n\t\t\tentity:    &mockObjectMetaAccessor{namespace: \"kube-system\", name: \"my-ingress\"},\n\t\t\texpected:  true,\n\t\t},\n\t\t{\n\t\t\tname: \"single endpoint returns false\",\n\t\t\tendpoints: []*Endpoint{\n\t\t\t\tNewEndpoint(\"example.org\", \"A\", \"1.2.3.4\"),\n\t\t\t},\n\t\t\trType:    \"Service\",\n\t\t\tentity:   &mockObjectMetaAccessor{namespace: \"default\", name: \"my-service\"},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname: \"multiple endpoints returns false\",\n\t\t\tendpoints: []*Endpoint{\n\t\t\t\tNewEndpoint(\"example.org\", \"A\", \"1.2.3.4\"),\n\t\t\t\tNewEndpoint(\"test.example.org\", \"CNAME\", \"example.org\"),\n\t\t\t},\n\t\t\trType:    \"Ingress\",\n\t\t\tentity:   &mockObjectMetaAccessor{namespace: \"production\", name: \"frontend\"},\n\t\t\texpected: false,\n\t\t},\n\t}\n\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tresult := HasNoEmptyEndpoints(tc.endpoints, tc.rType, tc.entity)\n\t\t\tassert.Equal(t, tc.expected, result)\n\t\t\t// TODO: Add log capture and verification\n\t\t})\n\t}\n}\n\nfunc TestEndpointsForHostname(t *testing.T) {\n\ttests := []struct {\n\t\tname             string\n\t\thostname         string\n\t\ttargets          Targets\n\t\tttl              TTL\n\t\tproviderSpecific ProviderSpecific\n\t\tsetIdentifier    string\n\t\tresource         string\n\t\texpected         []*Endpoint\n\t}{\n\t\t{\n\t\t\tname:     \"A record targets\",\n\t\t\thostname: \"example.com\",\n\t\t\ttargets:  Targets{\"192.0.2.1\", \"192.0.2.2\"},\n\t\t\tttl:      TTL(300),\n\t\t\tproviderSpecific: ProviderSpecific{\n\t\t\t\t{Name: \"provider\", Value: \"value\"},\n\t\t\t},\n\t\t\tsetIdentifier: \"identifier\",\n\t\t\tresource:      \"resource\",\n\t\t\texpected: []*Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:          \"example.com\",\n\t\t\t\t\tTargets:          Targets{\"192.0.2.1\", \"192.0.2.2\"},\n\t\t\t\t\tRecordType:       RecordTypeA,\n\t\t\t\t\tRecordTTL:        TTL(300),\n\t\t\t\t\tProviderSpecific: ProviderSpecific{{Name: \"provider\", Value: \"value\"}},\n\t\t\t\t\tSetIdentifier:    \"identifier\",\n\t\t\t\t\tLabels:           map[string]string{ResourceLabelKey: \"resource\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:     \"AAAA record targets\",\n\t\t\thostname: \"example.com\",\n\t\t\ttargets:  Targets{\"2001:db8::1\", \"2001:db8::2\"},\n\t\t\tttl:      TTL(300),\n\t\t\tproviderSpecific: ProviderSpecific{\n\t\t\t\t{Name: \"provider\", Value: \"value\"},\n\t\t\t},\n\t\t\tsetIdentifier: \"identifier\",\n\t\t\tresource:      \"resource\",\n\t\t\texpected: []*Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:          \"example.com\",\n\t\t\t\t\tTargets:          Targets{\"2001:db8::1\", \"2001:db8::2\"},\n\t\t\t\t\tRecordType:       RecordTypeAAAA,\n\t\t\t\t\tRecordTTL:        TTL(300),\n\t\t\t\t\tProviderSpecific: ProviderSpecific{{Name: \"provider\", Value: \"value\"}},\n\t\t\t\t\tSetIdentifier:    \"identifier\",\n\t\t\t\t\tLabels:           map[string]string{ResourceLabelKey: \"resource\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:     \"CNAME record targets\",\n\t\t\thostname: \"example.com\",\n\t\t\ttargets:  Targets{\"cname.example.com\"},\n\t\t\tttl:      TTL(300),\n\t\t\tproviderSpecific: ProviderSpecific{\n\t\t\t\t{Name: \"provider\", Value: \"value\"},\n\t\t\t},\n\t\t\tsetIdentifier: \"identifier\",\n\t\t\tresource:      \"resource\",\n\t\t\texpected: []*Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:          \"example.com\",\n\t\t\t\t\tTargets:          Targets{\"cname.example.com\"},\n\t\t\t\t\tRecordType:       RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:        TTL(300),\n\t\t\t\t\tProviderSpecific: ProviderSpecific{{Name: \"provider\", Value: \"value\"}},\n\t\t\t\t\tSetIdentifier:    \"identifier\",\n\t\t\t\t\tLabels:           map[string]string{ResourceLabelKey: \"resource\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:             \"No targets\",\n\t\t\thostname:         \"example.com\",\n\t\t\ttargets:          Targets{},\n\t\t\tttl:              TTL(300),\n\t\t\tproviderSpecific: ProviderSpecific{},\n\t\t\tsetIdentifier:    \"\",\n\t\t\tresource:         \"\",\n\t\t\texpected:         []*Endpoint(nil),\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := EndpointsForHostname(tt.hostname, tt.targets, tt.ttl, tt.providerSpecific, tt.setIdentifier, tt.resource)\n\t\t\tassert.Equal(t, tt.expected, result)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "endpoint/zz_generated.deepcopy.go",
    "content": "//go:build !ignore_autogenerated\n\n// Code generated by controller-gen. DO NOT EDIT.\n\npackage endpoint\n\n// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.\nfunc (in *Endpoint) DeepCopyInto(out *Endpoint) {\n\t*out = *in\n\tif in.Targets != nil {\n\t\tin, out := &in.Targets, &out.Targets\n\t\t*out = make(Targets, len(*in))\n\t\tcopy(*out, *in)\n\t}\n\tif in.Labels != nil {\n\t\tin, out := &in.Labels, &out.Labels\n\t\t*out = make(Labels, len(*in))\n\t\tfor key, val := range *in {\n\t\t\t(*out)[key] = val\n\t\t}\n\t}\n\tif in.ProviderSpecific != nil {\n\t\tin, out := &in.ProviderSpecific, &out.ProviderSpecific\n\t\t*out = make(ProviderSpecific, len(*in))\n\t\tcopy(*out, *in)\n\t}\n\tif in.refObject != nil {\n\t\tin, out := &in.refObject, &out.refObject\n\t\t*out = new(ObjectRef)\n\t\t**out = **in\n\t}\n}\n\n// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Endpoint.\nfunc (in *Endpoint) DeepCopy() *Endpoint {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(Endpoint)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n"
  },
  {
    "path": "external-dns.code-workspace",
    "content": "{\n\t\"folders\": [\n\t\t{\n\t\t\t\"path\": \".\"\n\t\t}\n\t]\n}\n"
  },
  {
    "path": "go.mod",
    "content": "module sigs.k8s.io/external-dns\n\ngo 1.25.7\n\nrequire (\n\tcloud.google.com/go/compute/metadata v0.9.0\n\tgithub.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0\n\tgithub.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1\n\tgithub.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0\n\tgithub.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0\n\tgithub.com/F5Networks/k8s-bigip-ctlr/v2 v2.20.2\n\tgithub.com/Yamashou/gqlgenc v0.33.0\n\tgithub.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2\n\tgithub.com/alecthomas/kingpin/v2 v2.4.0\n\tgithub.com/aliyun/alibaba-cloud-sdk-go v1.63.107\n\tgithub.com/aws/aws-sdk-go-v2 v1.41.4\n\tgithub.com/aws/aws-sdk-go-v2/config v1.32.12\n\tgithub.com/aws/aws-sdk-go-v2/credentials v1.19.12\n\tgithub.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.20.35\n\tgithub.com/aws/aws-sdk-go-v2/service/dynamodb v1.56.2\n\tgithub.com/aws/aws-sdk-go-v2/service/route53 v1.62.4\n\tgithub.com/aws/aws-sdk-go-v2/service/servicediscovery v1.39.25\n\tgithub.com/aws/aws-sdk-go-v2/service/sts v1.41.9\n\tgithub.com/aws/smithy-go v1.24.2\n\tgithub.com/bodgit/tsig v1.2.2\n\tgithub.com/cenkalti/backoff/v5 v5.0.3\n\tgithub.com/civo/civogo v0.7.0\n\tgithub.com/cloudflare/cloudflare-go/v5 v5.1.0\n\tgithub.com/datawire/ambassador v1.12.4\n\tgithub.com/denverdino/aliyungo v0.0.0-20230411124812-ab98a9173ace\n\tgithub.com/dnsimple/dnsimple-go v1.7.0\n\tgithub.com/exoscale/egoscale v0.102.3\n\tgithub.com/ffledgling/pdns-go v0.0.0-20180219074714-524e7daccd99\n\tgithub.com/go-gandi/go-gandi v0.7.0\n\tgithub.com/go-logr/logr v1.4.3\n\tgithub.com/goccy/go-yaml v1.19.2\n\tgithub.com/google/go-cmp v0.7.0\n\tgithub.com/google/uuid v1.6.0\n\tgithub.com/linode/linodego v1.66.0\n\tgithub.com/maxatome/go-testdeep v1.15.0\n\tgithub.com/miekg/dns v1.1.72\n\tgithub.com/openshift/api v0.0.0-20251015095338-264e80a2b6e7\n\tgithub.com/openshift/client-go v0.0.0-20251015124057-db0dee36e235\n\tgithub.com/oracle/oci-go-sdk/v65 v65.109.2\n\tgithub.com/ovh/go-ovh v1.9.0\n\tgithub.com/patrickmn/go-cache v2.1.0+incompatible\n\tgithub.com/pluralsh/gqlclient v1.12.2\n\tgithub.com/projectcontour/contour v1.33.2\n\tgithub.com/prometheus/client_golang v1.23.2\n\tgithub.com/prometheus/client_model v0.6.2\n\tgithub.com/prometheus/common v0.67.5\n\tgithub.com/scaleway/scaleway-sdk-go v1.0.0-beta.36\n\tgithub.com/sirupsen/logrus v1.9.4\n\tgithub.com/stretchr/testify v1.11.1\n\tgithub.com/transip/gotransip/v6 v6.26.1\n\tgo.etcd.io/etcd/api/v3 v3.6.8\n\tgo.etcd.io/etcd/client/v3 v3.6.8\n\tgo.uber.org/ratelimit v0.3.1\n\tgolang.org/x/net v0.52.0\n\tgolang.org/x/oauth2 v0.36.0\n\tgolang.org/x/sync v0.20.0\n\tgolang.org/x/text v0.35.0\n\tgolang.org/x/time v0.15.0\n\tgoogle.golang.org/api v0.272.0\n\tgopkg.in/ns1/ns1-go.v2 v2.17.2\n\tistio.io/api v1.29.1\n\tistio.io/client-go v1.29.1\n\tk8s.io/api v0.35.3\n\tk8s.io/apimachinery v0.35.3\n\tk8s.io/client-go v0.35.3\n\tk8s.io/klog/v2 v2.140.0\n\tk8s.io/utils v0.0.0-20260108192941-914a6e750570\n\tsigs.k8s.io/controller-runtime v0.23.3\n\tsigs.k8s.io/gateway-api v1.5.1\n\tsigs.k8s.io/yaml v1.6.0\n)\n\nrequire (\n\tcloud.google.com/go/auth v0.18.2 // indirect\n\tcloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect\n\tgithub.com/99designs/gqlgen v0.17.73 // indirect\n\tgithub.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect\n\tgithub.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect\n\tgithub.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect\n\tgithub.com/alexbrainman/sspi v0.0.0-20180613141037-e580b900e9f5 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.32.13 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.20 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect\n\tgithub.com/benbjohnson/clock v1.3.0 // indirect\n\tgithub.com/beorn7/perks v1.0.1 // indirect\n\tgithub.com/cespare/xxhash/v2 v2.3.0 // indirect\n\tgithub.com/coreos/go-semver v0.3.1 // indirect\n\tgithub.com/coreos/go-systemd/v22 v22.5.0 // indirect\n\tgithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect\n\tgithub.com/deepmap/oapi-codegen v1.9.1 // indirect\n\tgithub.com/emicklei/go-restful/v3 v3.13.0 // indirect\n\tgithub.com/evanphx/json-patch/v5 v5.9.11 // indirect\n\tgithub.com/felixge/httpsnoop v1.0.4 // indirect\n\tgithub.com/fxamacker/cbor/v2 v2.9.0 // indirect\n\tgithub.com/go-logr/stdr v1.2.2 // indirect\n\tgithub.com/go-openapi/jsonpointer v0.22.4 // indirect\n\tgithub.com/go-openapi/jsonreference v0.21.4 // indirect\n\tgithub.com/go-openapi/swag v0.25.4 // indirect\n\tgithub.com/go-openapi/swag/cmdutils v0.25.4 // indirect\n\tgithub.com/go-openapi/swag/conv v0.25.4 // indirect\n\tgithub.com/go-openapi/swag/fileutils v0.25.4 // indirect\n\tgithub.com/go-openapi/swag/jsonname v0.25.4 // indirect\n\tgithub.com/go-openapi/swag/jsonutils v0.25.4 // indirect\n\tgithub.com/go-openapi/swag/loading v0.25.4 // indirect\n\tgithub.com/go-openapi/swag/mangling v0.25.4 // indirect\n\tgithub.com/go-openapi/swag/netutils v0.25.4 // indirect\n\tgithub.com/go-openapi/swag/stringutils v0.25.4 // indirect\n\tgithub.com/go-openapi/swag/typeutils v0.25.4 // indirect\n\tgithub.com/go-openapi/swag/yamlutils v0.25.4 // indirect\n\tgithub.com/go-resty/resty/v2 v2.17.2 // indirect\n\tgithub.com/gofrs/flock v0.10.0 // indirect\n\tgithub.com/gofrs/uuid v4.4.0+incompatible // indirect\n\tgithub.com/gogo/protobuf v1.3.2 // indirect\n\tgithub.com/golang-jwt/jwt/v5 v5.3.0 // indirect\n\tgithub.com/golang/protobuf v1.5.4 // indirect\n\tgithub.com/google/gnostic-models v0.7.1 // indirect\n\tgithub.com/google/go-querystring v1.2.0 // indirect\n\tgithub.com/google/pprof v0.0.0-20250501235452-c0086092b71a // indirect\n\tgithub.com/google/s2a-go v0.1.9 // indirect\n\tgithub.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect\n\tgithub.com/googleapis/gax-go/v2 v2.18.0 // indirect\n\tgithub.com/gopherjs/gopherjs v1.17.2 // indirect\n\tgithub.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect\n\tgithub.com/hashicorp/errwrap v1.1.0 // indirect\n\tgithub.com/hashicorp/go-cleanhttp v0.5.2 // indirect\n\tgithub.com/hashicorp/go-multierror v1.1.1 // indirect\n\tgithub.com/hashicorp/go-retryablehttp v0.7.7 // indirect\n\tgithub.com/hashicorp/go-uuid v1.0.3 // indirect\n\tgithub.com/jcmturner/aescts/v2 v2.0.0 // indirect\n\tgithub.com/jcmturner/dnsutils/v2 v2.0.0 // indirect\n\tgithub.com/jcmturner/gofork v1.7.6 // indirect\n\tgithub.com/jcmturner/goidentity/v6 v6.0.1 // indirect\n\tgithub.com/jcmturner/gokrb5/v8 v8.4.3 // indirect\n\tgithub.com/jcmturner/rpc/v2 v2.0.3 // indirect\n\tgithub.com/jinzhu/copier v0.4.0 // indirect\n\tgithub.com/jmespath/go-jmespath v0.4.0 // indirect\n\tgithub.com/json-iterator/go v1.1.12 // indirect\n\tgithub.com/kylelemons/godebug v1.1.0 // indirect\n\tgithub.com/mattn/go-runewidth v0.0.16 // indirect\n\tgithub.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect\n\tgithub.com/mitchellh/go-homedir v1.1.0 // indirect\n\tgithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect\n\tgithub.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect\n\tgithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect\n\tgithub.com/openshift/gssapi v0.0.0-20161010215902-5fb4217df13b // indirect\n\tgithub.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect\n\tgithub.com/peterhellberg/link v1.1.0 // indirect\n\tgithub.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect\n\tgithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect\n\tgithub.com/prometheus/procfs v0.17.0 // indirect\n\tgithub.com/rivo/uniseg v0.4.7 // indirect\n\tgithub.com/schollz/progressbar/v3 v3.8.6 // indirect\n\tgithub.com/shopspring/decimal v1.3.1 // indirect\n\tgithub.com/sony/gobreaker v0.5.0 // indirect\n\tgithub.com/sosodev/duration v1.3.1 // indirect\n\tgithub.com/spf13/pflag v1.0.10 // indirect\n\tgithub.com/stretchr/objx v0.5.2 // indirect\n\tgithub.com/tidwall/gjson v1.14.4 // indirect\n\tgithub.com/tidwall/match v1.1.1 // indirect\n\tgithub.com/tidwall/pretty v1.2.1 // indirect\n\tgithub.com/tidwall/sjson v1.2.5 // indirect\n\tgithub.com/vektah/gqlparser/v2 v2.5.26 // indirect\n\tgithub.com/x448/float16 v0.8.4 // indirect\n\tgithub.com/xhit/go-str2duration/v2 v2.1.0 // indirect\n\tgithub.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect\n\tgo.etcd.io/etcd/client/pkg/v3 v3.6.8 // indirect\n\tgo.opentelemetry.io/auto/sdk v1.2.1 // indirect\n\tgo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect\n\tgo.opentelemetry.io/otel v1.39.0 // indirect\n\tgo.opentelemetry.io/otel/metric v1.39.0 // indirect\n\tgo.opentelemetry.io/otel/trace v1.39.0 // indirect\n\tgo.uber.org/atomic v1.10.0 // indirect\n\tgo.uber.org/multierr v1.11.0 // indirect\n\tgo.uber.org/zap v1.27.0 // indirect\n\tgo.yaml.in/yaml/v2 v2.4.3 // indirect\n\tgo.yaml.in/yaml/v3 v3.0.4 // indirect\n\tgolang.org/x/crypto v0.49.0 // indirect\n\tgolang.org/x/mod v0.33.0 // indirect\n\tgolang.org/x/sys v0.42.0 // indirect\n\tgolang.org/x/term v0.41.0 // indirect\n\tgolang.org/x/tools v0.42.0 // indirect\n\tgoogle.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d // indirect\n\tgoogle.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect\n\tgoogle.golang.org/grpc v1.79.3 // indirect\n\tgoogle.golang.org/protobuf v1.36.11 // indirect\n\tgopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect\n\tgopkg.in/inf.v0 v0.9.1 // indirect\n\tgopkg.in/ini.v1 v1.67.1 // indirect\n\tgopkg.in/yaml.v2 v2.4.0 // indirect\n\tgopkg.in/yaml.v3 v3.0.1 // indirect\n\tk8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e // indirect\n\tmoul.io/http2curl v1.0.0 // indirect\n\tsigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect\n\tsigs.k8s.io/randfill v1.0.0 // indirect\n\tsigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect\n)\n"
  },
  {
    "path": "go.sum",
    "content": "bazil.org/fuse v0.0.0-20160811212531-371fbbdaa898/go.mod h1:Xbm+BRKSBEpa4q4hTSxohYNQpsxXPbPry4JJWOB3LB8=\ncloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=\ncloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=\ncloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=\ncloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=\ncloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=\ncloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=\ncloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=\ncloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=\ncloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=\ndmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=\ngit.lukeshu.com/go/libsystemd v0.5.3/go.mod h1:FfDoP0i92r4p5Vn4NCLxvjkd7rCOe6otPa4L6hZg9WM=\ngithub.com/99designs/gqlgen v0.17.73 h1:A3Ki+rHWqKbAOlg5fxiZBnz6OjW3nwupDHEG15gEsrg=\ngithub.com/99designs/gqlgen v0.17.73/go.mod h1:2RyGWjy2k7W9jxrs8MOQthXGkD3L3oGr0jXW3Pu8lGg=\ngithub.com/Azure/azure-sdk-for-go v16.2.1+incompatible h1:KnPIugL51v3N3WwvaSmZbxukD1WuWXOiE9fRdu32f2I=\ngithub.com/Azure/azure-sdk-for-go v16.2.1+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=\ngithub.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=\ngithub.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=\ngithub.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=\ngithub.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=\ngithub.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=\ngithub.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8=\ngithub.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=\ngithub.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=\ngithub.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 h1:lpOxwrQ919lCZoNCd69rVt8u1eLZuMORrGXqy8sNf3c=\ngithub.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0/go.mod h1:fSvRkb8d26z9dbL40Uf/OO6Vo9iExtZK3D0ulRV+8M0=\ngithub.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsIIvxVT+uE6yrNldntJKlLRgxGbZ85kgtz5SNBhMw=\ngithub.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0/go.mod h1:AW8VEadnhw9xox+VaVd9sP7NjzOAnaZBLRH6Tq3cJ38=\ngithub.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 h1:yzrctSl9GMIQ5lHu7jc8olOsGjWDCsBpJhWqfGa/YIM=\ngithub.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0/go.mod h1:GE4m0rnnfwLGX0Y9A9A25Zx5N/90jneT5ABevqzhuFQ=\ngithub.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM=\ngithub.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE=\ngithub.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8=\ngithub.com/Azure/go-autorest v10.8.1+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=\ngithub.com/Azure/go-autorest/autorest v0.9.0/go.mod h1:xyHB1BMZT0cuDHU7I0+g046+BFDTQ8rEZB0s4Yfa6bI=\ngithub.com/Azure/go-autorest/autorest/adal v0.5.0/go.mod h1:8Z9fGy2MpX0PvDjB1pEgQTmVqjGhiHBW7RJJEciWzS0=\ngithub.com/Azure/go-autorest/autorest/date v0.1.0/go.mod h1:plvfp3oPSKwf2DNjlBjWF/7vwR+cUD/ELuzDCXwHUVA=\ngithub.com/Azure/go-autorest/autorest/mocks v0.1.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0=\ngithub.com/Azure/go-autorest/autorest/mocks v0.2.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0=\ngithub.com/Azure/go-autorest/logger v0.1.0/go.mod h1:oExouG+K6PryycPJfVSxi/koC6LSNgds39diKLz7Vrc=\ngithub.com/Azure/go-autorest/tracing v0.5.0/go.mod h1:r/s2XiOKccPW3HrqB+W0TQzfbtp2fGCgRFtBroKn4Dk=\ngithub.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=\ngithub.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=\ngithub.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=\ngithub.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=\ngithub.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=\ngithub.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=\ngithub.com/DATA-DOG/go-sqlmock v1.4.1/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=\ngithub.com/F5Networks/k8s-bigip-ctlr/v2 v2.20.2 h1:QyEs7el2INrys40wwF5jIW5+q5uBa7rNsNEsUtroRvw=\ngithub.com/F5Networks/k8s-bigip-ctlr/v2 v2.20.2/go.mod h1:tV7L3tfaN0R6z9PmuqacxBsEsFsIzptza00AuJ0fPck=\ngithub.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo=\ngithub.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=\ngithub.com/MakeNowJust/heredoc v0.0.0-20170808103936-bb23615498cd/go.mod h1:64YHyfSL2R96J44Nlwm39UHepQbyR5q10x7iYa1ks2E=\ngithub.com/Masterminds/goutils v1.1.0/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=\ngithub.com/Masterminds/semver v1.4.2 h1:WBLTQ37jOCzSLtXNdoo8bNM8876KhNqOKvrlGITgsTc=\ngithub.com/Masterminds/semver v1.4.2/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=\ngithub.com/Masterminds/semver/v3 v3.1.0/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=\ngithub.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=\ngithub.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=\ngithub.com/Masterminds/sprig v2.17.1+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o=\ngithub.com/Masterminds/sprig/v3 v3.1.0/go.mod h1:ONGMf7UfYGAbMXCZmQLy8x3lCDIPrEZE/rU8pmrbihA=\ngithub.com/Masterminds/squirrel v1.2.0/go.mod h1:yaPeOnPG5ZRwL9oKdTsO/prlkPbXWZlRVMQ/gGlzIuA=\ngithub.com/Masterminds/squirrel v1.4.0/go.mod h1:yaPeOnPG5ZRwL9oKdTsO/prlkPbXWZlRVMQ/gGlzIuA=\ngithub.com/Masterminds/vcs v1.13.1/go.mod h1:N09YCmOQr6RLxC6UNHzuVwAdodYbbnycGHSmwVJjcKA=\ngithub.com/Microsoft/go-winio v0.4.15-0.20190919025122-fc70bd9a86b5/go.mod h1:tTuCMEN+UleMWgg9dVx4Hu52b1bJo+59jBh3ajtinzw=\ngithub.com/Microsoft/hcsshim v0.8.7/go.mod h1:OHd7sQqRFrYd3RmSgbgji+ctCwkbq2wbEYNSzOYtcBQ=\ngithub.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ=\ngithub.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=\ngithub.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=\ngithub.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=\ngithub.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=\ngithub.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=\ngithub.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=\ngithub.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ=\ngithub.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=\ngithub.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=\ngithub.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g=\ngithub.com/Yamashou/gqlgenc v0.33.0 h1:0fxTnNE8/JVmFpfo7reA5pEgOcr7VjNc+/nEpVhNjfc=\ngithub.com/Yamashou/gqlgenc v0.33.0/go.mod h1:MZGXx/nALyxcehcFeLGmYiNsJ+hQTOGJzNYCGNX4rL0=\ngithub.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=\ngithub.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM=\ngithub.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=\ngithub.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2 h1:F1j7z+/DKEsYqZNoxC6wvfmaiDneLsQOFQmuq9NADSY=\ngithub.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2/go.mod h1:QlXr/TrICfQ/ANa76sLeQyhAJyNR9sEcfNuZBkY9jgY=\ngithub.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY=\ngithub.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE=\ngithub.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=\ngithub.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=\ngithub.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=\ngithub.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=\ngithub.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0=\ngithub.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs=\ngithub.com/alexbrainman/sspi v0.0.0-20180613141037-e580b900e9f5 h1:P5U+E4x5OkVEKQDklVPmzs71WM56RTTRqV4OrDC//Y4=\ngithub.com/alexbrainman/sspi v0.0.0-20180613141037-e580b900e9f5/go.mod h1:976q2ETgjT2snVCf2ZaBnyBbVoPERGjUz+0sofzEfro=\ngithub.com/aliyun/alibaba-cloud-sdk-go v1.63.107 h1:qagvUyrgOnBIlVRQWOyCZGVKUIYbMBdGdJ104vBpRFU=\ngithub.com/aliyun/alibaba-cloud-sdk-go v1.63.107/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=\ngithub.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=\ngithub.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=\ngithub.com/andybalholm/brotli v0.0.0-20190621154722-5f990b63d2d6/go.mod h1:+lx6/Aqd1kLJ1GQfkvOnaZ1WGmLpMpbprPuIOOZX30U=\ngithub.com/aokoli/goutils v1.1.0/go.mod h1:SijmP0QR8LtwsmDs8Yii5Z/S4trXFGFC2oO5g9DP+DQ=\ngithub.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=\ngithub.com/apache/thrift v0.13.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=\ngithub.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=\ngithub.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=\ngithub.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=\ngithub.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=\ngithub.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A=\ngithub.com/asaskevich/govalidator v0.0.0-20180720115003-f9ffefc3facf/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY=\ngithub.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY=\ngithub.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=\ngithub.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=\ngithub.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU=\ngithub.com/aws/aws-sdk-go v1.15.11/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0=\ngithub.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=\ngithub.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=\ngithub.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k=\ngithub.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=\ngithub.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0=\ngithub.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g=\ngithub.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8=\ngithub.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE=\ngithub.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.20.35 h1:CQ2kB9Q4xQ2PDBmn+KCr/pw1DvK7pH6NkR2nl2KV7ng=\ngithub.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.20.35/go.mod h1:ypTMB9nZhpqfMeRVesGj4dEknIg0YS+aXGtLMidw/Ek=\ngithub.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo=\ngithub.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE=\ngithub.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc=\ngithub.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o=\ngithub.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw=\ngithub.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc=\ngithub.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=\ngithub.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=\ngithub.com/aws/aws-sdk-go-v2/service/dynamodb v1.56.2 h1:xi/ECwajy2mixviBD7bKAlGGSwzEaFKX2wIhrZt9NGw=\ngithub.com/aws/aws-sdk-go-v2/service/dynamodb v1.56.2/go.mod h1:dLREOeW66eVaaGIOi2ZlLHDgkR3nuJ02rd00j0YSlBE=\ngithub.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.32.13 h1:xQ9dX2jxVm14uNVe0WomcCSza832ytYWt1ZBu2LrBLM=\ngithub.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.32.13/go.mod h1:D5up2/CMSP4sF8ESBWla6gJvIMySJi8dYYAaED4oTCc=\ngithub.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=\ngithub.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=\ngithub.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.20 h1:ru+seMuylHiNZlvgZei83eD8h37hRjm1XIMOEmcV0BU=\ngithub.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.20/go.mod h1:ihZMtPTKoX/ugQRHbui6zNdSgVYN1KY2Dgwb2d3hXlc=\ngithub.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y=\ngithub.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk=\ngithub.com/aws/aws-sdk-go-v2/service/route53 v1.62.4 h1:64aYPyHg3RjLvnMMSYQSg7aP+r1WRCPIS9SP9KfHjWg=\ngithub.com/aws/aws-sdk-go-v2/service/route53 v1.62.4/go.mod h1:bPSPzWTn9LSX6e0KPp4LlPoaspouZdKAlIdSMdhBBrs=\ngithub.com/aws/aws-sdk-go-v2/service/servicediscovery v1.39.25 h1:sOfFkPdRwWVyI4vU3V69Y+F1nR1VXjisXT7ukomUo3Q=\ngithub.com/aws/aws-sdk-go-v2/service/servicediscovery v1.39.25/go.mod h1:/MExzmYxZtSpYWqMGwLnAhlyoKXcvRkdLE2ji/Us0kA=\ngithub.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow=\ngithub.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE=\ngithub.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o=\ngithub.com/aws/aws-sdk-go-v2/service/sso v1.30.13/go.mod h1:2h/xGEowcW/g38g06g3KpRWDlT+OTfxxI0o1KqayAB8=\ngithub.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB8KfgAEuG0dc08Bkda7NU=\ngithub.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17/go.mod h1:Al9fFsXjv4KfbzQHGe6V4NZSZQXecFcvaIF4e70FoRA=\ngithub.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8MSU6Ch5i9PgBkcU=\ngithub.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk=\ngithub.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=\ngithub.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=\ngithub.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=\ngithub.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=\ngithub.com/beorn7/perks v0.0.0-20160804104726-4c0e84591b9a/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=\ngithub.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=\ngithub.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=\ngithub.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=\ngithub.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=\ngithub.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=\ngithub.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=\ngithub.com/blang/semver v3.1.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=\ngithub.com/blang/semver v3.5.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=\ngithub.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=\ngithub.com/bodgit/tsig v1.2.2 h1:RgxTCr8UFUHyU4D8Ygb2UtXtS4niw4B6XYYBpgCjl0k=\ngithub.com/bodgit/tsig v1.2.2/go.mod h1:rIGNOLZOV/UA03fmCUtEFbpWOrIoaOuETkpaeTvnLF4=\ngithub.com/bshuster-repo/logrus-logstash-hook v0.4.1/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk=\ngithub.com/bugsnag/bugsnag-go v0.0.0-20141110184014-b1d153021fcd/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8=\ngithub.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b/go.mod h1:obH5gd0BsqsP2LwDJ9aOkm/6J86V6lyAXCoQWGw3K50=\ngithub.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=\ngithub.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ=\ngithub.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=\ngithub.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=\ngithub.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=\ngithub.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=\ngithub.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=\ngithub.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=\ngithub.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=\ngithub.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=\ngithub.com/chai2010/gettext-go v0.0.0-20160711120539-c6fed771bfd5/go.mod h1:/iP1qXHoty45bqomnu2LM+VVyAEdWN+vtSHGlQgyxbw=\ngithub.com/civo/civogo v0.7.0 h1:XY75Ru7MgjEaE9cC14x0/7v/8WQZBW8WkvA5kUHjn0Q=\ngithub.com/civo/civogo v0.7.0/go.mod h1:0RNiA3NDI1imXDADWSCtzcHjUCV02E+SnRLoZKKo1wY=\ngithub.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE=\ngithub.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=\ngithub.com/cloudflare/cloudflare-go/v5 v5.1.0 h1:vvWUtrt5ZPEBFidL2ik64QipXLZmhMBgtRTw4bYvPwE=\ngithub.com/cloudflare/cloudflare-go/v5 v5.1.0/go.mod h1:C6OjOlDHOk/g7lXehothXJRFZrSIJMLzOZB2SXQhcjk=\ngithub.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=\ngithub.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=\ngithub.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8=\ngithub.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI=\ngithub.com/containerd/cgroups v0.0.0-20190919134610-bf292b21730f/go.mod h1:OApqhQ4XNSNC13gXIwDjhOQxjWa/NxkwZXJ1EvqT0ko=\ngithub.com/containerd/console v0.0.0-20180822173158-c12b1e7919c1/go.mod h1:Tj/on1eG8kiEhd0+fhSDzsPAFESxzBBvdyEgyryXffw=\ngithub.com/containerd/containerd v1.3.0-beta.2.0.20190828155532-0293cbd26c69/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=\ngithub.com/containerd/containerd v1.3.2/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=\ngithub.com/containerd/containerd v1.3.4/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=\ngithub.com/containerd/continuity v0.0.0-20190426062206-aaeac12a7ffc/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y=\ngithub.com/containerd/continuity v0.0.0-20200107194136-26c1120b8d41/go.mod h1:Dq467ZllaHgAtVp4p1xUQWBrFXR9s/wyoTpG8zOJGkY=\ngithub.com/containerd/fifo v0.0.0-20190226154929-a9fb20d87448/go.mod h1:ODA38xgv3Kuk8dQz2ZQXpnv/UZZUHUCL7pnLehbXgQI=\ngithub.com/containerd/go-runc v0.0.0-20180907222934-5a6d9f37cfa3/go.mod h1:IV7qH3hrUgRmyYrtgEeGWJfWbgcHL9CSRruz2Vqcph0=\ngithub.com/containerd/ttrpc v0.0.0-20190828154514-0e0f228740de/go.mod h1:PvCDdDGpgqzQIzDW1TphrGLssLDZp2GuS+X5DkEJB8o=\ngithub.com/containerd/typeurl v0.0.0-20180627222232-a93fcdb778cd/go.mod h1:Cm3kwCdlkCfMSHURc+r6fwoGH6/F1hH3S4sg0rLFWPc=\ngithub.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=\ngithub.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=\ngithub.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=\ngithub.com/coreos/go-oidc v2.1.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc=\ngithub.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=\ngithub.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=\ngithub.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4=\ngithub.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec=\ngithub.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=\ngithub.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=\ngithub.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=\ngithub.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=\ngithub.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=\ngithub.com/coreos/pkg v0.0.0-20180108230652-97fdf19511ea/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=\ngithub.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=\ngithub.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=\ngithub.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=\ngithub.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=\ngithub.com/cyberdelia/templates v0.0.0-20141128023046-ca7fffd4298c/go.mod h1:GyV+0YP4qX0UQ7r2MoYZ+AvYDp12OF5yg4q8rGnyNh4=\ngithub.com/cyphar/filepath-securejoin v0.2.2/go.mod h1:FpkQEhXnPnOthhzymB7CGsFk2G9VLXONKD9G7QGMM+4=\ngithub.com/datawire/ambassador v1.12.4 h1:g+agFHayLqETkCgFgEQi9qk4zakE0UAhgK8xVUEcDDI=\ngithub.com/datawire/ambassador v1.12.4/go.mod h1:2grBLdYgILzrgTpenDMB5OeyhObIUaT+KwkLkZI1KDE=\ngithub.com/datawire/dlib v1.2.0/go.mod h1:t0upKFHApJskdVFH/gyksG5+vMCl0GCKeEZIEJBBv4g=\ngithub.com/datawire/pf v0.0.0-20180510150411-31a823f9495a/go.mod h1:H8uUmE8qqo7z9u30MYB9riLyRckPHOPBk9ZdCuH+dQQ=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/daviddengcn/go-colortext v0.0.0-20160507010035-511bcaf42ccd/go.mod h1:dv4zxwHi5C/8AeI+4gX4dCWOIvNi7I6JCSX0HvlKPgE=\ngithub.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=\ngithub.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0-20210816181553-5444fa50b93d/go.mod h1:tmAIfUFEirG/Y8jhZ9M+h36obRZAk/1fcSpXwAVlfqE=\ngithub.com/deepmap/oapi-codegen v1.9.1 h1:yHmEnA7jSTUMQgV+uN02WpZtwHnz2CBW3mZRIxr1vtI=\ngithub.com/deepmap/oapi-codegen v1.9.1/go.mod h1:PLqNAhdedP8ttRpBBkzLKU3bp+Fpy+tTgeAMlztR2cw=\ngithub.com/deislabs/oras v0.8.1/go.mod h1:Mx0rMSbBNaNfY9hjpccEnxkOqJL6KGjtxNHPLC4G4As=\ngithub.com/denisenkom/go-mssqldb v0.0.0-20191001013358-cfbb681360f0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=\ngithub.com/denverdino/aliyungo v0.0.0-20190125010748-a747050bb1ba/go.mod h1:dV8lFg6daOBZbT6/BDGIz6Y3WFGn8juu6G+CQ6LHtl0=\ngithub.com/denverdino/aliyungo v0.0.0-20230411124812-ab98a9173ace h1:1SnCTPFh2AADpm7ti864EYaugexyiDFt55BW188+d6k=\ngithub.com/denverdino/aliyungo v0.0.0-20230411124812-ab98a9173ace/go.mod h1:TK05uvk4XXfK2kdvRwfcZ1NaxjDxmm7H3aQLko0mJxA=\ngithub.com/dgrijalva/jwt-go v0.0.0-20170104182250-a601269ab70c/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=\ngithub.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=\ngithub.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=\ngithub.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E=\ngithub.com/dnsimple/dnsimple-go v1.7.0 h1:JKu9xJtZ3SqOC+BuYgAWeab7+EEx0sz422vu8j611ZY=\ngithub.com/dnsimple/dnsimple-go v1.7.0/go.mod h1:EKpuihlWizqYafSnQHGCd/gyvy3HkEQJ7ODB4KdV8T8=\ngithub.com/docker/cli v0.0.0-20200130152716-5d0cf8839492/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=\ngithub.com/docker/distribution v0.0.0-20191216044856-a8371794149d/go.mod h1:0+TTO4EOBfRPhZXAeF1Vu+W3hHZ8eLp8PgKVZlcvtFY=\ngithub.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=\ngithub.com/docker/docker v0.7.3-0.20190327010347-be7ac8be2ae0/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=\ngithub.com/docker/docker v1.4.2-0.20200203170920-46ec8731fbce/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=\ngithub.com/docker/docker-credential-helpers v0.6.3/go.mod h1:WRaJzqw3CTB9bk10avuGsjVBZsD05qeibJ1/TYlvc0Y=\ngithub.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=\ngithub.com/docker/go-metrics v0.0.0-20180209012529-399ea8c73916/go.mod h1:/u0gXw0Gay3ceNrsHubL3BtdOL2fHf93USgMTe0W5dI=\ngithub.com/docker/go-units v0.3.3/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=\ngithub.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=\ngithub.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE=\ngithub.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM=\ngithub.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=\ngithub.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5JflhBbQEHo=\ngithub.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=\ngithub.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=\ngithub.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=\ngithub.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=\ngithub.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=\ngithub.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=\ngithub.com/ecodia/golang-awaitility v0.0.0-20180710094957-fb55e59708c7/go.mod h1:etn7NbLy5UviLk20XMZbSn/0AigF3Zfx7wwaEZ3fyIk=\ngithub.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=\ngithub.com/elazarl/goproxy v0.0.0-20180725130230-947c36da3153/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc=\ngithub.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs=\ngithub.com/emicklei/go-restful v2.9.5+incompatible/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs=\ngithub.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=\ngithub.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=\ngithub.com/enceve/crypto v0.0.0-20160707101852-34d48bb93815/go.mod h1:wYFFK4LYXbX7j+76mOq7aiC/EAw2S22CrzPHqgsisPw=\ngithub.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g=\ngithub.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=\ngithub.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=\ngithub.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=\ngithub.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=\ngithub.com/envoyproxy/protoc-gen-validate v0.3.0-java.0.20200609174644-bd816e4522c1/go.mod h1:bjmEhrMDubXDd0uKxnWwRmgSsiEv2CkJliIHnj6ETm8=\ngithub.com/evanphx/json-patch v4.2.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=\ngithub.com/evanphx/json-patch v4.5.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=\ngithub.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=\ngithub.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=\ngithub.com/exoscale/egoscale v0.102.3 h1:DYqN2ipoLKpiFoprRGQkp2av/Ze7sUYYlGhi1N62tfY=\ngithub.com/exoscale/egoscale v0.102.3/go.mod h1:RPf2Gah6up+6kAEayHTQwqapzXlm93f0VQas/UEGU5c=\ngithub.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d/go.mod h1:ZZMPRZwes7CROmyNKgQzC3XPs6L/G2EJLHddWejkmf4=\ngithub.com/fatih/camelcase v1.0.0/go.mod h1:yN2Sb0lFhZJUdVvtELVWefmrXpuZESvPmqwoZc+/fpc=\ngithub.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=\ngithub.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=\ngithub.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=\ngithub.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=\ngithub.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=\ngithub.com/ffledgling/pdns-go v0.0.0-20180219074714-524e7daccd99 h1:jmwW6QWvUO2OPe22YfgFvBaaZlSr8Rlrac5lZvG6IdM=\ngithub.com/ffledgling/pdns-go v0.0.0-20180219074714-524e7daccd99/go.mod h1:4mP9w9+vYGw2jUx2+2v03IA+phyQQjNRR4AL3uxlNrs=\ngithub.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=\ngithub.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4=\ngithub.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20=\ngithub.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=\ngithub.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=\ngithub.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=\ngithub.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=\ngithub.com/garyburd/redigo v0.0.0-20150301180006-535138d7bcd7/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY=\ngithub.com/getkin/kin-openapi v0.87.0/go.mod h1:660oXbgy5JFMKreazJaQTw7o+X00qeSyhcnluiMv+Xg=\ngithub.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=\ngithub.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=\ngithub.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=\ngithub.com/gin-gonic/gin v1.7.4/go.mod h1:jD2toBW3GZUr5UMcdrwQA10I7RuaFOl/SGeDjXkfUtY=\ngithub.com/globalsign/mgo v0.0.0-20180905125535-1ca0a4f7cbcb/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q=\ngithub.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q=\ngithub.com/go-chi/chi/v5 v5.0.0/go.mod h1:BBug9lr0cqtdAhsu6R4AAdvufI0/XBzAQSsUqJpoZOs=\ngithub.com/go-gandi/go-gandi v0.7.0 h1:gsP33dUspsN1M+ZW9HEgHchK9HiaSkYnltO73RHhSZA=\ngithub.com/go-gandi/go-gandi v0.7.0/go.mod h1:9NoYyfWCjFosClPiWjkbbRK5UViaZ4ctpT8/pKSSFlw=\ngithub.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=\ngithub.com/go-ini/ini v1.25.4/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=\ngithub.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=\ngithub.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=\ngithub.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o=\ngithub.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=\ngithub.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=\ngithub.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=\ngithub.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas=\ngithub.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=\ngithub.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=\ngithub.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=\ngithub.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=\ngithub.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=\ngithub.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=\ngithub.com/go-logr/zapr v0.1.0/go.mod h1:tabnROwaDl0UNxkVeFRbY8bwB37GwRv0P8lg6aAiEnk=\ngithub.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=\ngithub.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg=\ngithub.com/go-openapi/analysis v0.0.0-20180825180245-b006789cd277/go.mod h1:k70tL6pCuVxPJOHXQ+wIac1FUrvNkHolPie/cLEU6hI=\ngithub.com/go-openapi/analysis v0.17.0/go.mod h1:IowGgpVeD0vNm45So8nr+IcQ3pxVtpRoBWb8PVZO0ik=\ngithub.com/go-openapi/analysis v0.18.0/go.mod h1:IowGgpVeD0vNm45So8nr+IcQ3pxVtpRoBWb8PVZO0ik=\ngithub.com/go-openapi/analysis v0.19.2/go.mod h1:3P1osvZa9jKjb8ed2TPng3f0i/UY9snX6gxi44djMjk=\ngithub.com/go-openapi/analysis v0.19.5/go.mod h1:hkEAkxagaIvIP7VTn8ygJNkd4kAYON2rCu0v0ObL0AU=\ngithub.com/go-openapi/errors v0.17.0/go.mod h1:LcZQpmvG4wyF5j4IhA73wkLFQg+QJXOQHVjmcZxhka0=\ngithub.com/go-openapi/errors v0.18.0/go.mod h1:LcZQpmvG4wyF5j4IhA73wkLFQg+QJXOQHVjmcZxhka0=\ngithub.com/go-openapi/errors v0.19.2/go.mod h1:qX0BLWsyaKfvhluLejVpVNwNRdXZhEbTA4kxxpKBC94=\ngithub.com/go-openapi/jsonpointer v0.0.0-20160704185906-46af16f9f7b1/go.mod h1:+35s3my2LFTysnkMfxsJBAMHj/DoqoB9knIWoYG/Vk0=\ngithub.com/go-openapi/jsonpointer v0.17.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M=\ngithub.com/go-openapi/jsonpointer v0.18.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M=\ngithub.com/go-openapi/jsonpointer v0.19.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDBPKZGEoHC/NkiQRg=\ngithub.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=\ngithub.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=\ngithub.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=\ngithub.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=\ngithub.com/go-openapi/jsonreference v0.0.0-20160704190145-13c6e3589ad9/go.mod h1:W3Z9FmVs9qj+KR4zFKmDPGiLdk1D9Rlm7cyMvf57TTg=\ngithub.com/go-openapi/jsonreference v0.17.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I=\ngithub.com/go-openapi/jsonreference v0.18.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I=\ngithub.com/go-openapi/jsonreference v0.19.2/go.mod h1:jMjeRr2HHw6nAVajTXJ4eiUwohSTlpa0o73RUL1owJc=\ngithub.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8=\ngithub.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=\ngithub.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=\ngithub.com/go-openapi/loads v0.17.0/go.mod h1:72tmFy5wsWx89uEVddd0RjRWPZm92WRLhf7AC+0+OOU=\ngithub.com/go-openapi/loads v0.18.0/go.mod h1:72tmFy5wsWx89uEVddd0RjRWPZm92WRLhf7AC+0+OOU=\ngithub.com/go-openapi/loads v0.19.0/go.mod h1:72tmFy5wsWx89uEVddd0RjRWPZm92WRLhf7AC+0+OOU=\ngithub.com/go-openapi/loads v0.19.2/go.mod h1:QAskZPMX5V0C2gvfkGZzJlINuP7Hx/4+ix5jWFxsNPs=\ngithub.com/go-openapi/loads v0.19.4/go.mod h1:zZVHonKd8DXyxyw4yfnVjPzBjIQcLt0CCsn0N0ZrQsk=\ngithub.com/go-openapi/runtime v0.0.0-20180920151709-4f900dc2ade9/go.mod h1:6v9a6LTXWQCdL8k1AO3cvqx5OtZY/Y9wKTgaoP6YRfA=\ngithub.com/go-openapi/runtime v0.19.0/go.mod h1:OwNfisksmmaZse4+gpV3Ne9AyMOlP1lt4sK4FXt0O64=\ngithub.com/go-openapi/runtime v0.19.4/go.mod h1:X277bwSUBxVlCYR3r7xgZZGKVvBd/29gLDlFGtJ8NL4=\ngithub.com/go-openapi/spec v0.0.0-20160808142527-6aced65f8501/go.mod h1:J8+jY1nAiCcj+friV/PDoE1/3eeccG9LYBs0tYvLOWc=\ngithub.com/go-openapi/spec v0.17.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI=\ngithub.com/go-openapi/spec v0.18.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI=\ngithub.com/go-openapi/spec v0.19.2/go.mod h1:sCxk3jxKgioEJikev4fgkNmwS+3kuYdJtcsZsD5zxMY=\ngithub.com/go-openapi/spec v0.19.3/go.mod h1:FpwSN1ksY1eteniUU7X0N/BgJ7a4WvBFVA8Lj9mJglo=\ngithub.com/go-openapi/strfmt v0.17.0/go.mod h1:P82hnJI0CXkErkXi8IKjPbNBM6lV6+5pLP5l494TcyU=\ngithub.com/go-openapi/strfmt v0.18.0/go.mod h1:P82hnJI0CXkErkXi8IKjPbNBM6lV6+5pLP5l494TcyU=\ngithub.com/go-openapi/strfmt v0.19.0/go.mod h1:+uW+93UVvGGq2qGaZxdDeJqSAqBqBdl+ZPMF/cC8nDY=\ngithub.com/go-openapi/strfmt v0.19.3/go.mod h1:0yX7dbo8mKIvc3XSKp7MNfxw4JytCfCD6+bY1AVL9LU=\ngithub.com/go-openapi/swag v0.0.0-20160704191624-1d0bd113de87/go.mod h1:DXUve3Dpr1UfpPtxFw+EFuQ41HhCWZfha5jSVRG7C7I=\ngithub.com/go-openapi/swag v0.17.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg=\ngithub.com/go-openapi/swag v0.18.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg=\ngithub.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=\ngithub.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=\ngithub.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU=\ngithub.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ=\ngithub.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4=\ngithub.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=\ngithub.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=\ngithub.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=\ngithub.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y=\ngithub.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk=\ngithub.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=\ngithub.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=\ngithub.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=\ngithub.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=\ngithub.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=\ngithub.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=\ngithub.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=\ngithub.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=\ngithub.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48=\ngithub.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg=\ngithub.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0=\ngithub.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg=\ngithub.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=\ngithub.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=\ngithub.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=\ngithub.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=\ngithub.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=\ngithub.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=\ngithub.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=\ngithub.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=\ngithub.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=\ngithub.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=\ngithub.com/go-openapi/validate v0.18.0/go.mod h1:Uh4HdOzKt19xGIGm1qHf/ofbX1YQ4Y+MYsct2VUrAJ4=\ngithub.com/go-openapi/validate v0.19.2/go.mod h1:1tRCw7m3jtI8eNWEEliiAqUIcBztB2KDnRCRMUi7GTA=\ngithub.com/go-openapi/validate v0.19.5/go.mod h1:8DJv2CVJQ6kGNpFW6eV9N3JviE1C85nY1c2z52x1Gk4=\ngithub.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=\ngithub.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=\ngithub.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=\ngithub.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=\ngithub.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=\ngithub.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=\ngithub.com/go-playground/validator/v10 v10.9.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=\ngithub.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk=\ngithub.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=\ngithub.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=\ngithub.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=\ngithub.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=\ngithub.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=\ngithub.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=\ngithub.com/gobuffalo/envy v1.7.0/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI=\ngithub.com/gobuffalo/envy v1.7.1/go.mod h1:FurDp9+EDPE4aIUS3ZLyD+7/9fpx7YRt/ukY6jIHf0w=\ngithub.com/gobuffalo/flect v0.2.0/go.mod h1:W3K3X9ksuZfir8f/LrfVtWmCDQFfayuylOJ7sz/Fj80=\ngithub.com/gobuffalo/logger v1.0.1/go.mod h1:2zbswyIUa45I+c+FLXuWl9zSWEiVuthsk8ze5s8JvPs=\ngithub.com/gobuffalo/packd v0.3.0/go.mod h1:zC7QkmNkYVGKPw4tHpBQ+ml7W/3tIebgeo1b36chA3Q=\ngithub.com/gobuffalo/packr/v2 v2.7.1/go.mod h1:qYEvAazPaVxy7Y7KR0W8qYEE+RymX74kETFqjFoFlOc=\ngithub.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=\ngithub.com/goccy/go-json v0.7.8/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=\ngithub.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=\ngithub.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=\ngithub.com/godbus/dbus v0.0.0-20190422162347-ade71ed3457e/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4=\ngithub.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=\ngithub.com/godror/godror v0.13.3/go.mod h1:2ouUT4kdhUBk7TAkHWD4SN0CdI0pgEQbo8FVHhbSKWg=\ngithub.com/gofrs/flock v0.7.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=\ngithub.com/gofrs/flock v0.10.0 h1:SHMXenfaB03KbroETaCMtbBg3Yn29v4w1r+tgy4ff4k=\ngithub.com/gofrs/flock v0.10.0/go.mod h1:FirDy1Ing0mI2+kB6wk+vyyAH+e6xiE+EYA0jnzV9jc=\ngithub.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=\ngithub.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=\ngithub.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s=\ngithub.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=\ngithub.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=\ngithub.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=\ngithub.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=\ngithub.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=\ngithub.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=\ngithub.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A=\ngithub.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=\ngithub.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=\ngithub.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=\ngithub.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=\ngithub.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=\ngithub.com/golang/gddo v0.0.0-20190419222130-af0f2af80721/go.mod h1:xEhNfoBDX1hzLm2Nf80qUvZ2sVwoMZ8d6IE2SrsQfh4=\ngithub.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=\ngithub.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=\ngithub.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=\ngithub.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=\ngithub.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=\ngithub.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=\ngithub.com/golang/protobuf v0.0.0-20161109072736-4bd1920723d7/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=\ngithub.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=\ngithub.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=\ngithub.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=\ngithub.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=\ngithub.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=\ngithub.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=\ngithub.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=\ngithub.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=\ngithub.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=\ngithub.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=\ngithub.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=\ngithub.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=\ngithub.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=\ngithub.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=\ngithub.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=\ngithub.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=\ngithub.com/golangci/lint-1 v0.0.0-20181222135242-d2cdd8c08219/go.mod h1:/X8TswGSh1pIozq4ZwCfxS0WA5JGXguxk94ar/4c87Y=\ngithub.com/golangplus/bytes v0.0.0-20160111154220-45c989fe5450/go.mod h1:Bk6SMAONeMXrxql8uvOKuAZSu8aM5RUGv+1C6IJaEho=\ngithub.com/golangplus/fmt v0.0.0-20150411045040-2a5d6d7d2995/go.mod h1:lJgMEyOkYFkPcDKwRXegd+iM6E7matEszMG5HhwytU8=\ngithub.com/golangplus/testing v0.0.0-20180327235837-af21d9c3145e/go.mod h1:0AA//k/eakGydO4jKRoRL2j92ZKSzTgj9tclaCrvXHk=\ngithub.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=\ngithub.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=\ngithub.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=\ngithub.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=\ngithub.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=\ngithub.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=\ngithub.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=\ngithub.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=\ngithub.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=\ngithub.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=\ngithub.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=\ngithub.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=\ngithub.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=\ngithub.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=\ngithub.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=\ngithub.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=\ngithub.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=\ngithub.com/google/pprof v0.0.0-20250501235452-c0086092b71a h1:rDA3FfmxwXR+BVKKdz55WwMJ1pD2hJQNW31d+l3mPk4=\ngithub.com/google/pprof v0.0.0-20250501235452-c0086092b71a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA=\ngithub.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=\ngithub.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=\ngithub.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=\ngithub.com/google/shlex v0.0.0-20181106134648-c34317bd91bf/go.mod h1:RpwtwJQFrIEPstU94h88MWPXP2ektJZ8cZ0YntAmXiE=\ngithub.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=\ngithub.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=\ngithub.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=\ngithub.com/googleapis/gax-go/v2 v2.18.0 h1:jxP5Uuo3bxm3M6gGtV94P4lliVetoCB4Wk2x8QA86LI=\ngithub.com/googleapis/gax-go/v2 v2.18.0/go.mod h1:uSzZN4a356eRG985CzJ3WfbFSpqkLTjsnhWGJR6EwrE=\ngithub.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY=\ngithub.com/googleapis/gnostic v0.1.0/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY=\ngithub.com/googleapis/gnostic v0.3.1/go.mod h1:on+2t9HRStVgn95RSsFWFz+6Q0Snyqv1awfrALZdbtU=\ngithub.com/gookit/color v1.2.3/go.mod h1:AhIE+pS6D4Ql0SQWbBeXPHw7gY0/sjHoA4s/n1KB7xg=\ngithub.com/gophercloud/gophercloud v0.1.0/go.mod h1:vxM41WHh5uqHVBMZHzuwNOHh8XEoIEcSTewFxm1c5g8=\ngithub.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=\ngithub.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=\ngithub.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=\ngithub.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=\ngithub.com/gorilla/handlers v0.0.0-20150720190736-60c7bfde3e33/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=\ngithub.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=\ngithub.com/gorilla/mux v1.7.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=\ngithub.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=\ngithub.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=\ngithub.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=\ngithub.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=\ngithub.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=\ngithub.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=\ngithub.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=\ngithub.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=\ngithub.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=\ngithub.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo=\ngithub.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=\ngithub.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=\ngithub.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=\ngithub.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=\ngithub.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=\ngithub.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=\ngithub.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=\ngithub.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=\ngithub.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=\ngithub.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=\ngithub.com/hashicorp/consul/api v1.3.0/go.mod h1:MmDNSzIMUjNpY/mQ398R4bk2FnqQLoPndWW5VkKPlCE=\ngithub.com/hashicorp/consul/sdk v0.3.0/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=\ngithub.com/hashicorp/errwrap v0.0.0-20141028054710-7554cd9344ce/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=\ngithub.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=\ngithub.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=\ngithub.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=\ngithub.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=\ngithub.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=\ngithub.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=\ngithub.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=\ngithub.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=\ngithub.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=\ngithub.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=\ngithub.com/hashicorp/go-multierror v0.0.0-20161216184304-ed905158d874/go.mod h1:JMRHfdO9jKNzS/+BTlxCjKNQHg/jZAft8U7LloJvN7I=\ngithub.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=\ngithub.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=\ngithub.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=\ngithub.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=\ngithub.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=\ngithub.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=\ngithub.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=\ngithub.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=\ngithub.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=\ngithub.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=\ngithub.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=\ngithub.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=\ngithub.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=\ngithub.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=\ngithub.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=\ngithub.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=\ngithub.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=\ngithub.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=\ngithub.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=\ngithub.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=\ngithub.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=\ngithub.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=\ngithub.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=\ngithub.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=\ngithub.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=\ngithub.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg=\ngithub.com/iancoleman/strcase v0.0.0-20180726023541-3605ed457bf7/go.mod h1:SK73tn/9oHe+/Y0h39VT4UCxmurVJkR5NA7kMEAOgSE=\ngithub.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=\ngithub.com/imdario/mergo v0.3.8/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=\ngithub.com/imdario/mergo v0.3.9/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=\ngithub.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=\ngithub.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo=\ngithub.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=\ngithub.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=\ngithub.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=\ngithub.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=\ngithub.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=\ngithub.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=\ngithub.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=\ngithub.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=\ngithub.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=\ngithub.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=\ngithub.com/jcmturner/gokrb5/v8 v8.4.3 h1:iTonLeSJOn7MVUtyMT+arAn5AKAPrkilzhGw8wE/Tq8=\ngithub.com/jcmturner/gokrb5/v8 v8.4.3/go.mod h1:dqRwJGXznQrzw6cWmyo6kH+E7jksEQG/CyVWsJEsJO0=\ngithub.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=\ngithub.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=\ngithub.com/jinzhu/copier v0.3.5/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=\ngithub.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=\ngithub.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=\ngithub.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=\ngithub.com/jmespath/go-jmespath v0.0.0-20160803190731-bd40a432e4c7/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=\ngithub.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=\ngithub.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=\ngithub.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=\ngithub.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=\ngithub.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=\ngithub.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks=\ngithub.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=\ngithub.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=\ngithub.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=\ngithub.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=\ngithub.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=\ngithub.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=\ngithub.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=\ngithub.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=\ngithub.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=\ngithub.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=\ngithub.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=\ngithub.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=\ngithub.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=\ngithub.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=\ngithub.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw=\ngithub.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=\ngithub.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=\ngithub.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=\ngithub.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=\ngithub.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=\ngithub.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=\ngithub.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=\ngithub.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=\ngithub.com/klauspost/compress v1.9.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=\ngithub.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=\ngithub.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=\ngithub.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=\ngithub.com/klauspost/pgzip v1.2.1/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=\ngithub.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=\ngithub.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=\ngithub.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=\ngithub.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=\ngithub.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=\ngithub.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=\ngithub.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=\ngithub.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=\ngithub.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=\ngithub.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=\ngithub.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA=\ngithub.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=\ngithub.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=\ngithub.com/labstack/echo/v4 v4.6.3/go.mod h1:Hk5OiHj0kDqmFq7aHe7eDqI7CUhuCrfpupQtLGGLm7A=\ngithub.com/labstack/gommon v0.3.1/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=\ngithub.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=\ngithub.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=\ngithub.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=\ngithub.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=\ngithub.com/lestrrat-go/backoff/v2 v2.0.8/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBBOiM/uP402WwN8Y=\ngithub.com/lestrrat-go/blackmagic v1.0.0/go.mod h1:TNgH//0vYSs8VXDCfkZLgIrVTTXQELZffUV0tz3MtdQ=\ngithub.com/lestrrat-go/codegen v1.0.2/go.mod h1:JhJw6OQAuPEfVKUCLItpaVLumDGWQznd1VaXrBk9TdM=\ngithub.com/lestrrat-go/httpcc v1.0.0/go.mod h1:tGS/u00Vh5N6FHNkExqGGNId8e0Big+++0Gf8MBnAvE=\ngithub.com/lestrrat-go/iter v1.0.1/go.mod h1:zIdgO1mRKhn8l9vrZJZz9TUMMFbQbLeTsbqPDrJ/OJc=\ngithub.com/lestrrat-go/jwx v1.2.7/go.mod h1:bw24IXWbavc0R2RsOtpXL7RtMyP589yZ1+L7kd09ZGA=\ngithub.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=\ngithub.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=\ngithub.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=\ngithub.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=\ngithub.com/lib/pq v1.7.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=\ngithub.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE=\ngithub.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM=\ngithub.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4=\ngithub.com/linode/linodego v1.66.0 h1:rK8QJFaV53LWOEJvb/evhTg/dP5ElvtuZmx4iv4RJds=\ngithub.com/linode/linodego v1.66.0/go.mod h1:12ykGs9qsvxE+OU3SXuW2w+DTruWF35FPlXC7gGk2tU=\ngithub.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc=\ngithub.com/lyft/protoc-gen-star v0.4.10/go.mod h1:mE8fbna26u7aEA2QCVvvfBU/ZrPgocG1206xAFPcs94=\ngithub.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ=\ngithub.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=\ngithub.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=\ngithub.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=\ngithub.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=\ngithub.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=\ngithub.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=\ngithub.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=\ngithub.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs=\ngithub.com/marstr/guid v1.1.0/go.mod h1:74gB1z2wpxxInTG6yaqA7KrtM0NZ+RbrcqDvYHefzho=\ngithub.com/matryer/moq v0.0.0-20190312154309-6cfb0558e1bd/go.mod h1:9ELz6aaclSIGnZBoaSLZ3NAl1VTufbOrXBPvtcy6WiQ=\ngithub.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=\ngithub.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=\ngithub.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=\ngithub.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=\ngithub.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=\ngithub.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=\ngithub.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=\ngithub.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=\ngithub.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=\ngithub.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=\ngithub.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=\ngithub.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/mattn/go-oci8 v0.0.7/go.mod h1:wjDx6Xm9q7dFtHJvIlrI99JytznLw5wQ4R+9mNXJwGI=\ngithub.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=\ngithub.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=\ngithub.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=\ngithub.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=\ngithub.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=\ngithub.com/mattn/go-shellwords v1.0.10/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=\ngithub.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=\ngithub.com/mattn/go-sqlite3 v1.12.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=\ngithub.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=\ngithub.com/maxatome/go-testdeep v1.15.0 h1:o3ghyKh6Hmy5EnyfQh+8lbH++4gpknmgc8jo7h/AVp8=\ngithub.com/maxatome/go-testdeep v1.15.0/go.mod h1:BEC221DXFjTrG2VLzAYYi3xz8aK1QYa391djuaR2jkA=\ngithub.com/mholt/archiver/v3 v3.3.0/go.mod h1:YnQtqsp+94Rwd0D/rk5cnLrxusUBUXg+08Ebtr1Mqao=\ngithub.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=\ngithub.com/miekg/dns v1.1.6/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=\ngithub.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME=\ngithub.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=\ngithub.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=\ngithub.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=\ngithub.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=\ngithub.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=\ngithub.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=\ngithub.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=\ngithub.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=\ngithub.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=\ngithub.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=\ngithub.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=\ngithub.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=\ngithub.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=\ngithub.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=\ngithub.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=\ngithub.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f/go.mod h1:OkQIRizQZAeMln+1tSwduZz7+Af5oFlKirV/MSYes2A=\ngithub.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=\ngithub.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=\ngithub.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=\ngithub.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=\ngithub.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=\ngithub.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=\ngithub.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=\ngithub.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=\ngithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=\ngithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=\ngithub.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=\ngithub.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=\ngithub.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg=\ngithub.com/nats-io/jwt v0.3.2/go.mod h1:/euKqTS1ZD+zzjYrY7pseZrTtWQSjujC7xjPc8wL6eU=\ngithub.com/nats-io/nats-server/v2 v2.1.2/go.mod h1:Afk+wRZqkMQs/p45uXdrVLuab3gwv3Z8C4HTBu8GD/k=\ngithub.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzEE/Zbp4w=\ngithub.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=\ngithub.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=\ngithub.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=\ngithub.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms=\ngithub.com/ncw/swift v1.0.47/go.mod h1:23YIA4yWVnGwv2dQlN4bB7egfYX6YLn0Yo/S6zZO/ZM=\ngithub.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=\ngithub.com/nwaples/rardecode v1.0.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=\ngithub.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=\ngithub.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs=\ngithub.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=\ngithub.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=\ngithub.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=\ngithub.com/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=\ngithub.com/olekukonko/tablewriter v0.0.2/go.mod h1:rSAaSIOAGT9odnlyGlUfAJaoc5w2fSBUmeGDbRWPxyQ=\ngithub.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=\ngithub.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=\ngithub.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=\ngithub.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=\ngithub.com/onsi/ginkgo v1.11.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=\ngithub.com/onsi/ginkgo v1.12.1 h1:mFwc4LvZ0xpSvDZ3E+k8Yte0hLOMxXUlP+yXtJqkYfQ=\ngithub.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=\ngithub.com/onsi/ginkgo/v2 v2.28.0 h1:Rrf+lVLmtlBIKv6KrIGJCjyY8N36vDVcutbGJkyqjJc=\ngithub.com/onsi/ginkgo/v2 v2.28.0/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=\ngithub.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=\ngithub.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=\ngithub.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=\ngithub.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=\ngithub.com/onsi/gomega v1.8.1/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA=\ngithub.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=\ngithub.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=\ngithub.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=\ngithub.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=\ngithub.com/opencontainers/go-digest v0.0.0-20170106003457-a6d0ee40d420/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=\ngithub.com/opencontainers/go-digest v0.0.0-20180430190053-c9281466c8b2/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=\ngithub.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=\ngithub.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=\ngithub.com/opencontainers/image-spec v1.0.0/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=\ngithub.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=\ngithub.com/opencontainers/runc v0.0.0-20190115041553-12f6a991201f/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U=\ngithub.com/opencontainers/runc v0.1.1/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U=\ngithub.com/opencontainers/runtime-spec v0.1.2-0.20190507144316-5b71a03e2700/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=\ngithub.com/opencontainers/runtime-tools v0.0.0-20181011054405-1d69bd0f9c39/go.mod h1:r3f7wjNzSs2extwzU3Y+6pKfobzPh+kKFJ3ofN+3nfs=\ngithub.com/openshift/api v0.0.0-20251015095338-264e80a2b6e7 h1:Ot2fbEEPmF3WlPQkyEW/bUCV38GMugH/UmZvxpWceNc=\ngithub.com/openshift/api v0.0.0-20251015095338-264e80a2b6e7/go.mod h1:d5uzF0YN2nQQFA0jIEWzzOZ+edmo6wzlGLvx5Fhz4uY=\ngithub.com/openshift/client-go v0.0.0-20251015124057-db0dee36e235 h1:9JBeIXmnHlpXTQPi7LPmu1jdxznBhAE7bb1K+3D8gxY=\ngithub.com/openshift/client-go v0.0.0-20251015124057-db0dee36e235/go.mod h1:L49W6pfrZkfOE5iC1PqEkuLkXG4W0BX4w8b+L2Bv7fM=\ngithub.com/openshift/gssapi v0.0.0-20161010215902-5fb4217df13b h1:it0YPE/evO6/m8t8wxis9KFI2F/aleOKsI6d9uz0cEk=\ngithub.com/openshift/gssapi v0.0.0-20161010215902-5fb4217df13b/go.mod h1:tNrEB5k8SI+g5kOlsCmL2ELASfpqEofI0+FLBgBdN08=\ngithub.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492/go.mod h1:Ngi6UdF0k5OKD5t5wlmGhe/EDKPoUM3BXZSSfIuJbis=\ngithub.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74=\ngithub.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=\ngithub.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=\ngithub.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=\ngithub.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A=\ngithub.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU=\ngithub.com/openzipkin-contrib/zipkin-go-opentracing v0.4.5/go.mod h1:/wsWhb9smxSfWAKL3wpBW7V8scJMt8N8gnaMCS9E/cA=\ngithub.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw=\ngithub.com/openzipkin/zipkin-go v0.2.1/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4=\ngithub.com/openzipkin/zipkin-go v0.2.2/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4=\ngithub.com/oracle/oci-go-sdk/v65 v65.109.2 h1:epzga51qucVjF+8ci2oYYq+mi3cE0DACGmC139WecMM=\ngithub.com/oracle/oci-go-sdk/v65 v65.109.2/go.mod h1:8ZzvzuEG/cFLFZhxg/Mg1w19KqyXBKO3c17QIc5PkGs=\ngithub.com/ovh/go-ovh v1.9.0 h1:6K8VoL3BYjVV3In9tPJUdT7qMx9h0GExN9EXx1r2kKE=\ngithub.com/ovh/go-ovh v1.9.0/go.mod h1:cTVDnl94z4tl8pP1uZ/8jlVxntjSIf09bNcQ5TJSC7c=\ngithub.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIwwtUjcrb0b5/5kLM=\ngithub.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=\ngithub.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=\ngithub.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=\ngithub.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=\ngithub.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=\ngithub.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac=\ngithub.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=\ngithub.com/peterhellberg/link v1.1.0 h1:s2+RH8EGuI/mI4QwrWGSYQCRz7uNgip9BaM04HKu5kc=\ngithub.com/peterhellberg/link v1.1.0/go.mod h1:gtSlOT4jmkY8P47hbTc8PTgiDDWpdPbFYl75keYyBB8=\ngithub.com/phayes/freeport v0.0.0-20180830031419-95f893ade6f2/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE=\ngithub.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc=\ngithub.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=\ngithub.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA=\ngithub.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=\ngithub.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=\ngithub.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=\ngithub.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pkg/errors v0.8.1-0.20171018195549-f15c970de5b7/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=\ngithub.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA=\ngithub.com/pluralsh/gqlclient v1.12.2 h1:BrEFAASktf4quFw57CIaLAd+NZUTLhG08fe6tnhBQN4=\ngithub.com/pluralsh/gqlclient v1.12.2/go.mod h1:OEjN9L63x8m3A3eQBv5kVkFgiY9fp2aZ0cgOF0uII58=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=\ngithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=\ngithub.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA=\ngithub.com/projectcontour/contour v1.33.2 h1:BFjTltoZPWQkdPejnMrMiCPhSbdQrZDF0GeEFHcr0E4=\ngithub.com/projectcontour/contour v1.33.2/go.mod h1:EMmGYpisEQVVA1Edx1IteEwJ0dJqL7yfriBzDKY0zHI=\ngithub.com/prometheus/client_golang v0.0.0-20180209125602-c332b6f63c06/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=\ngithub.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=\ngithub.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=\ngithub.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso=\ngithub.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=\ngithub.com/prometheus/client_golang v1.3.0/go.mod h1:hJaj2vgQTGQmVCsAACORcieXFeDPbaTKGT+JTgUa3og=\ngithub.com/prometheus/client_golang v1.6.0/go.mod h1:ZLOG9ck3JLRdB5MgO8f+lLTe83AXG6ro35rLTxvnIl4=\ngithub.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=\ngithub.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=\ngithub.com/prometheus/client_model v0.0.0-20171117100541-99fa1f4be8e5/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=\ngithub.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=\ngithub.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=\ngithub.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=\ngithub.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=\ngithub.com/prometheus/client_model v0.1.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=\ngithub.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=\ngithub.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=\ngithub.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=\ngithub.com/prometheus/common v0.0.0-20180110214958-89604d197083/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=\ngithub.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=\ngithub.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=\ngithub.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=\ngithub.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=\ngithub.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA=\ngithub.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=\ngithub.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=\ngithub.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=\ngithub.com/prometheus/procfs v0.0.0-20180125133057-cb4147076ac7/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=\ngithub.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=\ngithub.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=\ngithub.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=\ngithub.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=\ngithub.com/prometheus/procfs v0.0.5/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ=\ngithub.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=\ngithub.com/prometheus/procfs v0.0.11/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=\ngithub.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=\ngithub.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=\ngithub.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=\ngithub.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=\ngithub.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=\ngithub.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=\ngithub.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=\ngithub.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=\ngithub.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=\ngithub.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=\ngithub.com/rogpeppe/go-internal v1.3.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=\ngithub.com/rogpeppe/go-internal v1.4.0/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=\ngithub.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=\ngithub.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=\ngithub.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=\ngithub.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=\ngithub.com/rubenv/sql-migrate v0.0.0-20200212082348-64f95ea68aa3/go.mod h1:rtQlpHw+eR6UrqaS3kX1VYeaCxzCVdimDS7g5Ln4pPc=\ngithub.com/rubenv/sql-migrate v0.0.0-20200616145509-8d140a17f351/go.mod h1:DCgfY80j8GYL7MLEfvcpSFvjD0L5yZq/aZUJmhZklyg=\ngithub.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=\ngithub.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=\ngithub.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=\ngithub.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E=\ngithub.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=\ngithub.com/scaleway/scaleway-sdk-go v1.0.0-beta.36 h1:ObX9hZmK+VmijreZO/8x9pQ8/P/ToHD/bdSb4Eg4tUo=\ngithub.com/scaleway/scaleway-sdk-go v1.0.0-beta.36/go.mod h1:LEsDu4BubxK7/cWhtlQWfuxwL4rf/2UEpxXz1o1EMtM=\ngithub.com/schollz/progressbar/v3 v3.8.6 h1:QruMUdzZ1TbEP++S1m73OqRJk20ON11m6Wqv4EoGg8c=\ngithub.com/schollz/progressbar/v3 v3.8.6/go.mod h1:W5IEwbJecncFGBvuEh4A7HT1nZZ6WNIL2i3qbnI0WKY=\ngithub.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=\ngithub.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=\ngithub.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=\ngithub.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=\ngithub.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=\ngithub.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=\ngithub.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=\ngithub.com/sirupsen/logrus v1.0.4-0.20170822132746-89742aefa4b2/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc=\ngithub.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=\ngithub.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=\ngithub.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=\ngithub.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=\ngithub.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=\ngithub.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=\ngithub.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=\ngithub.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=\ngithub.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=\ngithub.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=\ngithub.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg=\ngithub.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM=\ngithub.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=\ngithub.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=\ngithub.com/sony/gobreaker v0.5.0 h1:dRCvqm0P490vZPmy7ppEk2qCnCieBooFJ+YoXGYB+yg=\ngithub.com/sony/gobreaker v0.5.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=\ngithub.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4=\ngithub.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=\ngithub.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=\ngithub.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=\ngithub.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=\ngithub.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=\ngithub.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=\ngithub.com/spf13/cobra v0.0.2-0.20171109065643-2da4a54c5cee/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=\ngithub.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=\ngithub.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=\ngithub.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=\ngithub.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=\ngithub.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=\ngithub.com/spf13/pflag v1.0.1-0.20171106142849-4c012f6dcd95/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=\ngithub.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=\ngithub.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=\ngithub.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=\ngithub.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=\ngithub.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=\ngithub.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=\ngithub.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=\ngithub.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=\ngithub.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=\ngithub.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=\ngithub.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=\ngithub.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=\ngithub.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=\ngithub.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=\ngithub.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=\ngithub.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=\ngithub.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=\ngithub.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=\ngithub.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=\ngithub.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=\ngithub.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=\ngithub.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=\ngithub.com/syndtr/gocapability v0.0.0-20170704070218-db04d3cc01c8/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=\ngithub.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=\ngithub.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=\ngithub.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=\ngithub.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=\ngithub.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=\ngithub.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=\ngithub.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=\ngithub.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=\ngithub.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=\ngithub.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=\ngithub.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=\ngithub.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=\ngithub.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=\ngithub.com/transip/gotransip/v6 v6.26.1 h1:MeqIjkTBBsZwWAK6giZyMkqLmKMclVHEuTNmoBdx4MA=\ngithub.com/transip/gotransip/v6 v6.26.1/go.mod h1:x0/RWGRK/zob817O3tfO2xhFoP1vu8YOHORx6Jpk80s=\ngithub.com/uber/jaeger-client-go v2.30.0+incompatible h1:D6wyKGCecFaSRUpo8lCVbaOOb6ThwMmTEbhRwtKR97o=\ngithub.com/uber/jaeger-client-go v2.30.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk=\ngithub.com/uber/jaeger-lib v2.4.1+incompatible h1:td4jdvLcExb4cBISKIpHuGoVXh+dVKhn2Um6rjCsSsg=\ngithub.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U=\ngithub.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=\ngithub.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=\ngithub.com/ugorji/go v1.2.6/go.mod h1:anCg0y61KIhDlPZmnH+so+RQbysYVyDko0IMgJv0Nn0=\ngithub.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=\ngithub.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=\ngithub.com/ugorji/go/codec v1.2.6/go.mod h1:V6TCNZ4PHqoHGFZuSG1W8nrCzzdgA2DozYxWFFpvxTw=\ngithub.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8=\ngithub.com/urfave/cli v0.0.0-20171014202726-7bc6a0acffa5/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=\ngithub.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=\ngithub.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=\ngithub.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=\ngithub.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=\ngithub.com/vektah/gqlparser v1.1.2/go.mod h1:1ycwN7Ij5njmMkPPAOaRFY4rET2Enx7IkVv3vaXspKw=\ngithub.com/vektah/gqlparser/v2 v2.5.26 h1:REqqFkO8+SOEgZHR/eHScjjVjGS8Nk3RMO/juiTobN4=\ngithub.com/vektah/gqlparser/v2 v2.5.26/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=\ngithub.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=\ngithub.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=\ngithub.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=\ngithub.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=\ngithub.com/xeipuuv/gojsonschema v0.0.0-20180618132009-1d523034197f/go.mod h1:5yf86TLmAcydyeJq5YvxkGPE2fm/u4myDekKRoLuqhs=\ngithub.com/xeipuuv/gojsonschema v1.1.0/go.mod h1:5yf86TLmAcydyeJq5YvxkGPE2fm/u4myDekKRoLuqhs=\ngithub.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=\ngithub.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc=\ngithub.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU=\ngithub.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=\ngithub.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=\ngithub.com/xlab/handysort v0.0.0-20150421192137-fb3537ed64a1/go.mod h1:QcJo0QPSfTONNIgpN5RA8prR7fF8nkF6cTWTcNerRO8=\ngithub.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=\ngithub.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=\ngithub.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=\ngithub.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=\ngithub.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=\ngithub.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=\ngithub.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs=\ngithub.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50/go.mod h1:NUSPSUX/bi6SeDMUh6brw0nXpxHnc96TguQh0+r/ssA=\ngithub.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f/go.mod h1:GlGEuHIJweS1mbCqG+7vt2nvWLzLLnRHbXz5JKd/Qbg=\ngithub.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=\ngo.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=\ngo.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=\ngo.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg=\ngo.etcd.io/etcd/api/v3 v3.6.8 h1:gqb1VN92TAI6G2FiBvWcqKtHiIjr4SU2GdXxTwyexbM=\ngo.etcd.io/etcd/api/v3 v3.6.8/go.mod h1:qyQj1HZPUV3B5cbAL8scG62+fyz5dSxxu0w8pn28N6Q=\ngo.etcd.io/etcd/client/pkg/v3 v3.6.8 h1:Qs/5C0LNFiqXxYf2GU8MVjYUEXJ6sZaYOz0zEqQgy50=\ngo.etcd.io/etcd/client/pkg/v3 v3.6.8/go.mod h1:GsiTRUZE2318PggZkAo6sWb6l8JLVrnckTNfbG8PWtw=\ngo.etcd.io/etcd/client/v3 v3.6.8 h1:B3G76t1UykqAOrbio7s/EPatixQDkQBevN8/mwiplrY=\ngo.etcd.io/etcd/client/v3 v3.6.8/go.mod h1:MVG4BpSIuumPi+ELF7wYtySETmoTWBHVcDoHdVupwt8=\ngo.mongodb.org/mongo-driver v1.0.3/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM=\ngo.mongodb.org/mongo-driver v1.1.1/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM=\ngo.mongodb.org/mongo-driver v1.1.2/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM=\ngo.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=\ngo.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=\ngo.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=\ngo.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=\ngo.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=\ngo.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=\ngo.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=\ngo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=\ngo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=\ngo.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=\ngo.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=\ngo.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=\ngo.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=\ngo.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=\ngo.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=\ngo.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=\ngo.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=\ngo.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=\ngo.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=\ngo.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=\ngo.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=\ngo.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=\ngo.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=\ngo.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=\ngo.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=\ngo.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=\ngo.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=\ngo.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=\ngo.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=\ngo.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=\ngo.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=\ngo.uber.org/ratelimit v0.3.1 h1:K4qVE+byfv/B3tC+4nYWP7v/6SimcO7HzHekoMNBma0=\ngo.uber.org/ratelimit v0.3.1/go.mod h1:6euWsTB6U/Nb3X++xEUXA8ciPJvr19Q/0h1+oDcJhRk=\ngo.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=\ngo.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=\ngo.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=\ngo.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=\ngo.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=\ngo.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=\ngo.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=\ngo.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=\ngo.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=\ngolang.org/x/crypto v0.0.0-20171113213409-9f005a07e0d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=\ngolang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=\ngolang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=\ngolang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=\ngolang.org/x/crypto v0.0.0-20190211182817-74369b46fc67/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20190320223903-b7391e95e576/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20190617133340-57b3e21c3d56/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20190621222207-cc06ce4a13d4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20200128174031-69ecbb4d6d5d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=\ngolang.org/x/crypto v0.0.0-20200220183623-bac4c82f6975/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=\ngolang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=\ngolang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=\ngolang.org/x/crypto v0.0.0-20201217014255-9d1352758620/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=\ngolang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=\ngolang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=\ngolang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=\ngolang.org/x/crypto v0.0.0-20220131195533-30dcbda58838/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=\ngolang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=\ngolang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=\ngolang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=\ngolang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=\ngolang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=\ngolang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=\ngolang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=\ngolang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=\ngolang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=\ngolang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs=\ngolang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=\ngolang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=\ngolang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=\ngolang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=\ngolang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=\ngolang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=\ngolang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=\ngolang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=\ngolang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=\ngolang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=\ngolang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=\ngolang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=\ngolang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=\ngolang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=\ngolang.org/x/net v0.0.0-20170114055629-f2499483f923/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190320064053-1272bf9dcd53/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=\ngolang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=\ngolang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20190619014844-b5b0513f8c1b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20191004110552-13f9640d40b9/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=\ngolang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=\ngolang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=\ngolang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=\ngolang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=\ngolang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=\ngolang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=\ngolang.org/x/net v0.0.0-20210913180222-943fd674d43e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=\ngolang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=\ngolang.org/x/net v0.0.0-20220725212005-46097bf591d3/go.mod h1:AaygXjzTFtRAg2ttMY5RMuhpJ3cNnI0XpyFJD1iQRSM=\ngolang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=\ngolang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=\ngolang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=\ngolang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=\ngolang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=\ngolang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=\ngolang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=\ngolang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=\ngolang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=\ngolang.org/x/sys v0.0.0-20170830134202-bb24a47a89ea/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20180425194835-bb9c189858d9/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190209173611-3b5209105503/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190321052220-f7bb7a8bee54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190514135907-3a4b5fb9f71f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190515120540-06a5c4944438/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190602015325-4c4f7f33c9ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20191022100944-742c48ecaeb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=\ngolang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=\ngolang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=\ngolang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=\ngolang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=\ngolang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=\ngolang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=\ngolang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=\ngolang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=\ngolang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=\ngolang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=\ngolang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=\ngolang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=\ngolang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=\ngolang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=\ngolang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=\ngolang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=\ngolang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=\ngolang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=\ngolang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20181011042414-1f849cf54d09/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20190125232054-d66bd3c5d5a6/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20190206041539-40960b6deb8e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=\ngolang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=\ngolang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=\ngolang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=\ngolang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=\ngolang.org/x/tools v0.0.0-20190614205625-5aca471b1d59/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=\ngolang.org/x/tools v0.0.0-20190617190820-da514acc4774/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=\ngolang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=\ngolang.org/x/tools v0.0.0-20190920225731-5eefd052ad72/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191004055002-72853e10c5a3/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20200522201501-cb1345f3a375/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=\ngolang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=\ngolang.org/x/tools v0.0.0-20200918232735-d647fc253266/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU=\ngolang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=\ngolang.org/x/tools v0.0.0-20210114065538-d78b04bdf963/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=\ngolang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=\ngolang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=\ngolang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=\ngolang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngomodules.xyz/jsonpatch/v2 v2.0.1/go.mod h1:IhYNNY4jnS53ZnfE4PAmpKtDpTCj1JFXc+3mwe7XcUU=\ngonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo=\ngonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0=\ngonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=\ngonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=\ngonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw=\ngonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc=\ngoogle.golang.org/api v0.0.0-20160322025152-9bf6e6e569ff/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0=\ngoogle.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=\ngoogle.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=\ngoogle.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA=\ngoogle.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA=\ngoogle.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=\ngoogle.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=\ngoogle.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=\ngoogle.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=\ngoogle.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=\ngoogle.golang.org/cloud v0.0.0-20151119220103-975617b05ea8/go.mod h1:0H1ncTHf11KCFhTc/+EFRbzSCOZx+VUbRMk55Yv5MYk=\ngoogle.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=\ngoogle.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=\ngoogle.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=\ngoogle.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=\ngoogle.golang.org/genproto v0.0.0-20190530194941-fb225487d101/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s=\ngoogle.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=\ngoogle.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=\ngoogle.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d h1:vsOm753cOAMkt76efriTCDKjpCbK18XGHMJHo0JUKhc=\ngoogle.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:0oz9d7g9QLSdv9/lgbIjowW1JoxMbxmBVNe8i6tORJI=\ngoogle.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d h1:EocjzKLywydp5uZ5tJ79iP6Q0UjDnyiHkGRWxuPBP8s=\ngoogle.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:48U2I+QQUYhsFrg2SY6r+nJzeOtjey7j//WBESw+qyQ=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=\ngoogle.golang.org/grpc v0.0.0-20160317175043-d3ddb4469d5a/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=\ngoogle.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=\ngoogle.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=\ngoogle.golang.org/grpc v1.20.0/go.mod h1:chYK+tFQF0nDUGJgXMSgLCQk3phJEuONr2DCgLDdAQM=\ngoogle.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=\ngoogle.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=\ngoogle.golang.org/grpc v1.22.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=\ngoogle.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=\ngoogle.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=\ngoogle.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=\ngoogle.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=\ngoogle.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=\ngoogle.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=\ngoogle.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=\ngoogle.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=\ngoogle.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=\ngoogle.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=\ngoogle.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=\ngoogle.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=\ngoogle.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=\ngoogle.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=\ngoogle.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=\ngoogle.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=\ngoogle.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=\ngoogle.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=\ngoogle.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=\ngopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U=\ngopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20141024133853-64131543e789/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=\ngopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw=\ngopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=\ngopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=\ngopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=\ngopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=\ngopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o=\ngopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo=\ngopkg.in/gorp.v1 v1.7.2/go.mod h1:Wo3h+DBQZIxATwftsglhdD/62zRFPhGhTiu5jUJmCaw=\ngopkg.in/h2non/gock.v1 v1.0.15/go.mod h1:sX4zAkdYX1TRGJ2JY156cFspQn4yRWn6p9EMdODlynE=\ngopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY=\ngopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0=\ngopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=\ngopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=\ngopkg.in/ini.v1 v1.51.1/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=\ngopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=\ngopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k=\ngopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=\ngopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=\ngopkg.in/ns1/ns1-go.v2 v2.17.2 h1:x8YKHqCJWkC/hddfUhw7FRqTG0x3fr/0ZnWYN+i4THs=\ngopkg.in/ns1/ns1-go.v2 v2.17.2/go.mod h1:pfaU0vECVP7DIOr453z03HXS6dFJpXdNRwOyRzwmPSc=\ngopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=\ngopkg.in/square/go-jose.v2 v2.2.2/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=\ngopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=\ngopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=\ngopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=\ngopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=\ngopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=\ngopkg.in/yaml.v3 v3.0.0-20190905181640-827449938966/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=\nhelm.sh/helm/v3 v3.2.4/go.mod h1:ZaXz/vzktgwjyGGFbUWtIQkscfE7WYoRGP2szqAFHR0=\nhonnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=\nhonnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=\nhonnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=\nhonnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=\nhonnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=\nistio.io/api v1.29.1 h1:dSure3CSur+mRZYvTYRUNgR/P+TYO5ItlPk1lUu4rU8=\nistio.io/api v1.29.1/go.mod h1:+brQWcBHoROuyA6fv8rbgg8Kfn0RCGuqoY0duCMuSLA=\nistio.io/client-go v1.29.1 h1:GU7/5310KXpNS5vDhDSCYttABpHJddz5tQ+7aBYeJGU=\nistio.io/client-go v1.29.1/go.mod h1:iCVud7UDjvGbxjqUH+cRk9OruaKdrK69CFDmnV+NOFw=\nk8s.io/api v0.18.0/go.mod h1:q2HRQkfDzHMBZL9l/y9rH63PkQl4vae0xRT+8prbrK8=\nk8s.io/api v0.18.2/go.mod h1:SJCWI7OLzhZSvbY7U8zwNl9UA4o1fizoug34OV/2r78=\nk8s.io/api v0.18.4/go.mod h1:lOIQAKYgai1+vz9J7YcDZwC26Z0zQewYOGWdyIPUUQ4=\nk8s.io/api v0.35.3 h1:pA2fiBc6+N9PDf7SAiluKGEBuScsTzd2uYBkA5RzNWQ=\nk8s.io/api v0.35.3/go.mod h1:9Y9tkBcFwKNq2sxwZTQh1Njh9qHl81D0As56tu42GA4=\nk8s.io/apiextensions-apiserver v0.18.0/go.mod h1:18Cwn1Xws4xnWQNC00FLq1E350b9lUF+aOdIWDOZxgo=\nk8s.io/apiextensions-apiserver v0.18.2/go.mod h1:q3faSnRGmYimiocj6cHQ1I3WpLqmDgJFlKL37fC4ZvY=\nk8s.io/apiextensions-apiserver v0.18.4/go.mod h1:NYeyeYq4SIpFlPxSAB6jHPIdvu3hL0pc36wuRChybio=\nk8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4=\nk8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU=\nk8s.io/apimachinery v0.18.0/go.mod h1:9SnR/e11v5IbyPCGbvJViimtJ0SwHG4nfZFjU77ftcA=\nk8s.io/apimachinery v0.18.2/go.mod h1:9SnR/e11v5IbyPCGbvJViimtJ0SwHG4nfZFjU77ftcA=\nk8s.io/apimachinery v0.18.4/go.mod h1:OaXp26zu/5J7p0f92ASynJa1pZo06YlV9fG7BoWbCko=\nk8s.io/apimachinery v0.35.3 h1:MeaUwQCV3tjKP4bcwWGgZ/cp/vpsRnQzqO6J6tJyoF8=\nk8s.io/apimachinery v0.35.3/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=\nk8s.io/apiserver v0.18.0/go.mod h1:3S2O6FeBBd6XTo0njUrLxiqk8GNy6wWOftjhJcXYnjw=\nk8s.io/apiserver v0.18.2/go.mod h1:Xbh066NqrZO8cbsoenCwyDJ1OSi8Ag8I2lezeHxzwzw=\nk8s.io/apiserver v0.18.4/go.mod h1:q+zoFct5ABNnYkGIaGQ3bcbUNdmPyOCoEBcg51LChY8=\nk8s.io/cli-runtime v0.18.0/go.mod h1:1eXfmBsIJosjn9LjEBUd2WVPoPAY9XGTqTFcPMIBsUQ=\nk8s.io/cli-runtime v0.18.4/go.mod h1:9/hS/Cuf7NVzWR5F/5tyS6xsnclxoPLVtwhnkJG1Y4g=\nk8s.io/client-go v0.18.0/go.mod h1:uQSYDYs4WhVZ9i6AIoEZuwUggLVEF64HOD37boKAtF8=\nk8s.io/client-go v0.18.2/go.mod h1:Xcm5wVGXX9HAA2JJ2sSBUn3tCJ+4SVlCbl2MNNv+CIU=\nk8s.io/client-go v0.18.4/go.mod h1:f5sXwL4yAZRkAtzOxRWUhA/N8XzGCb+nPZI8PfobZ9g=\nk8s.io/client-go v0.35.3 h1:s1lZbpN4uI6IxeTM2cpdtrwHcSOBML1ODNTCCfsP1pg=\nk8s.io/client-go v0.35.3/go.mod h1:RzoXkc0mzpWIDvBrRnD+VlfXP+lRzqQjCmKtiwZ8Q9c=\nk8s.io/code-generator v0.18.0/go.mod h1:+UHX5rSbxmR8kzS+FAv7um6dtYrZokQvjHpDSYRVkTc=\nk8s.io/code-generator v0.18.2/go.mod h1:+UHX5rSbxmR8kzS+FAv7um6dtYrZokQvjHpDSYRVkTc=\nk8s.io/code-generator v0.18.4/go.mod h1:TgNEVx9hCyPGpdtCWA34olQYLkh3ok9ar7XfSsr8b6c=\nk8s.io/component-base v0.18.0/go.mod h1:u3BCg0z1uskkzrnAKFzulmYaEpZF7XC9Pf/uFyb1v2c=\nk8s.io/component-base v0.18.2/go.mod h1:kqLlMuhJNHQ9lz8Z7V5bxUUtjFZnrypArGl58gmDfUM=\nk8s.io/component-base v0.18.4/go.mod h1:7jr/Ef5PGmKwQhyAz/pjByxJbC58mhKAhiaDu0vXfPk=\nk8s.io/gengo v0.0.0-20190128074634-0689ccc1d7d6/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0=\nk8s.io/gengo v0.0.0-20200114144118-36b2048a9120/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0=\nk8s.io/helm v2.16.9+incompatible/go.mod h1:LZzlS4LQBHfciFOurYBFkCMTaZ0D1l+p0teMg7TSULI=\nk8s.io/klog v0.0.0-20181102134211-b9b56d5dfc92/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk=\nk8s.io/klog v0.3.0/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk=\nk8s.io/klog v1.0.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I=\nk8s.io/klog/v2 v2.0.0/go.mod h1:PBfzABfn139FHAV07az/IF9Wp1bkk3vpT2XSJ76fSDE=\nk8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=\nk8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=\nk8s.io/kube-openapi v0.0.0-20200121204235-bf4fb3bd569c/go.mod h1:GRQhZsXIAJ1xR0C9bd8UpWHZ5plfAS9fzPjJuQ6JL3E=\nk8s.io/kube-openapi v0.0.0-20200410145947-61e04a5be9a6/go.mod h1:GRQhZsXIAJ1xR0C9bd8UpWHZ5plfAS9fzPjJuQ6JL3E=\nk8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e h1:iW9ChlU0cU16w8MpVYjXk12dqQ4BPFBEgif+ap7/hqQ=\nk8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=\nk8s.io/kubectl v0.18.0/go.mod h1:LOkWx9Z5DXMEg5KtOjHhRiC1fqJPLyCr3KtQgEolCkU=\nk8s.io/kubectl v0.18.4/go.mod h1:EzB+nfeUWk6fm6giXQ8P4Fayw3dsN+M7Wjy23mTRtB0=\nk8s.io/kubernetes v1.13.0/go.mod h1:ocZa8+6APFNC2tX1DZASIbocyYT5jHzqFVsY5aoB7Jk=\nk8s.io/metrics v0.18.0/go.mod h1:8aYTW18koXqjLVKL7Ds05RPMX9ipJZI3mywYvBOxXd4=\nk8s.io/metrics v0.18.4/go.mod h1:luze4fyI9JG4eLDZy0kFdYEebqNfi0QrG4xNEbPkHOs=\nk8s.io/utils v0.0.0-20200324210504-a9aa75ae1b89/go.mod h1:sZAwmy6armz5eXlNoLmJcl4F1QuKu7sr+mFQ0byX7Ew=\nk8s.io/utils v0.0.0-20200603063816-c1c6865ac451/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA=\nk8s.io/utils v0.0.0-20260108192941-914a6e750570 h1:JT4W8lsdrGENg9W+YwwdLJxklIuKWdRm+BC+xt33FOY=\nk8s.io/utils v0.0.0-20260108192941-914a6e750570/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=\nmoul.io/http2curl v1.0.0 h1:6XwpyZOYsgZJrU8exnG87ncVkU1FVCcTRpwzOkTDUi8=\nmoul.io/http2curl v1.0.0/go.mod h1:f6cULg+e4Md/oW1cYmwW4IWQOVl2lGbmCNGOHvzX2kE=\nrsc.io/letsencrypt v0.0.3/go.mod h1:buyQKZ6IXrRnB7TdkHP0RyEybLx18HHyOSoTyoOLqNY=\nrsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=\nsigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.7/go.mod h1:PHgbrJT7lCHcxMU+mDHEm+nx46H4zuuHZkDP6icnhu0=\nsigs.k8s.io/controller-runtime v0.6.1/go.mod h1:XRYBPdbf5XJu9kpS84VJiZ7h/u1hF3gEORz0efEja7A=\nsigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80=\nsigs.k8s.io/controller-runtime v0.23.3/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0=\nsigs.k8s.io/controller-tools v0.3.1-0.20200517180335-820a4a27ea84/go.mod h1:enhtKGfxZD1GFEoMgP8Fdbu+uKQ/cq1/WGJhdVChfvI=\nsigs.k8s.io/gateway-api v1.5.1 h1:RqVRIlkhLhUO8wOHKTLnTJA6o/1un4po4/6M1nRzdd0=\nsigs.k8s.io/gateway-api v1.5.1/go.mod h1:GvCETiaMAlLym5CovLxGjS0NysqFk3+Yuq3/rh6QL2o=\nsigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=\nsigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=\nsigs.k8s.io/kustomize v2.0.3+incompatible/go.mod h1:MkjgH3RdOWrievjo6c9T245dYlB5QeXV4WCbnt/PEpU=\nsigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=\nsigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=\nsigs.k8s.io/structured-merge-diff/v3 v3.0.0-20200116222232-67a7b8c61874/go.mod h1:PlARxl6Hbt/+BC80dRLi1qAmnMqwqDg62YvvVkZjemw=\nsigs.k8s.io/structured-merge-diff/v3 v3.0.0/go.mod h1:PlARxl6Hbt/+BC80dRLi1qAmnMqwqDg62YvvVkZjemw=\nsigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8=\nsigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=\nsigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=\nsigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc=\nsigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=\nsigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=\nsourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU=\nvbom.ml/util v0.0.0-20160121211510-db5cfe13f5cc/go.mod h1:so/NYdZXCz+E3ZpW0uAoCj6uzU2+8OWDFv/HxUSs7kI=\n"
  },
  {
    "path": "go.tool.mod",
    "content": "module sigs.k8s.io/external-dns/tools\n\ngo 1.25.7\n\ntool (\n\tgithub.com/google/yamlfmt/cmd/yamlfmt\n\tgithub.com/mikefarah/yq/v4\n\tsigs.k8s.io/controller-tools/cmd/controller-gen\n)\n\nrequire (\n\tgithub.com/a8m/envsubst v1.4.3 // indirect\n\tgithub.com/agext/levenshtein v1.2.1 // indirect\n\tgithub.com/alecthomas/chroma/v2 v2.20.0 // indirect\n\tgithub.com/alecthomas/participle/v2 v2.1.4 // indirect\n\tgithub.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect\n\tgithub.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect\n\tgithub.com/aymerick/douceur v0.2.0 // indirect\n\tgithub.com/bahlo/generic-list-go v0.2.0 // indirect\n\tgithub.com/bmatcuk/doublestar/v4 v4.7.1 // indirect\n\tgithub.com/buger/jsonparser v1.1.1 // indirect\n\tgithub.com/cespare/xxhash/v2 v2.3.0 // indirect\n\tgithub.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 // indirect\n\tgithub.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4 // indirect\n\tgithub.com/charmbracelet/colorprofile v0.3.2 // indirect\n\tgithub.com/charmbracelet/glamour v0.10.0 // indirect\n\tgithub.com/charmbracelet/harmonica v0.2.0 // indirect\n\tgithub.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect\n\tgithub.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3 // indirect\n\tgithub.com/charmbracelet/x/ansi v0.10.1 // indirect\n\tgithub.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1 // indirect\n\tgithub.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect\n\tgithub.com/charmbracelet/x/input v0.3.7 // indirect\n\tgithub.com/charmbracelet/x/term v0.2.1 // indirect\n\tgithub.com/charmbracelet/x/windows v0.2.1 // indirect\n\tgithub.com/dimchansky/utfbom v1.1.1 // indirect\n\tgithub.com/dlclark/regexp2 v1.11.5 // indirect\n\tgithub.com/dop251/goja v0.0.0-20250630131328-58d95d85e994 // indirect\n\tgithub.com/dop251/goja_nodejs v0.0.0-20250409162600-f7acab6894b0 // indirect\n\tgithub.com/dustin/go-humanize v1.0.1 // indirect\n\tgithub.com/elliotchance/orderedmap v1.8.0 // indirect\n\tgithub.com/fatih/color v1.18.0 // indirect\n\tgithub.com/fsnotify/fsnotify v1.9.0 // indirect\n\tgithub.com/fxamacker/cbor/v2 v2.9.0 // indirect\n\tgithub.com/go-ini/ini v1.67.0 // indirect\n\tgithub.com/go-logr/logr v1.4.3 // indirect\n\tgithub.com/go-openapi/jsonpointer v0.21.0 // indirect\n\tgithub.com/go-openapi/jsonreference v0.20.2 // indirect\n\tgithub.com/go-openapi/swag v0.23.0 // indirect\n\tgithub.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect\n\tgithub.com/go-viper/mapstructure/v2 v2.4.0 // indirect\n\tgithub.com/gobuffalo/flect v1.0.3 // indirect\n\tgithub.com/goccy/go-json v0.10.5 // indirect\n\tgithub.com/goccy/go-yaml v1.19.2 // indirect\n\tgithub.com/gogo/protobuf v1.3.2 // indirect\n\tgithub.com/google/gnostic-models v0.7.0 // indirect\n\tgithub.com/google/go-cmp v0.7.0 // indirect\n\tgithub.com/google/pprof v0.0.0-20250501235452-c0086092b71a // indirect\n\tgithub.com/google/uuid v1.6.0 // indirect\n\tgithub.com/google/yamlfmt v0.21.0 // indirect\n\tgithub.com/gorilla/css v1.0.1 // indirect\n\tgithub.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect\n\tgithub.com/hashicorp/hcl/v2 v2.24.0 // indirect\n\tgithub.com/iancoleman/strcase v0.3.0 // indirect\n\tgithub.com/inconshreveable/mousetrap v1.1.0 // indirect\n\tgithub.com/jinzhu/copier v0.4.0 // indirect\n\tgithub.com/josharian/intern v1.0.0 // indirect\n\tgithub.com/json-iterator/go v1.1.12 // indirect\n\tgithub.com/lucasb-eyer/go-colorful v1.2.0 // indirect\n\tgithub.com/magiconair/properties v1.8.10 // indirect\n\tgithub.com/mailru/easyjson v0.7.7 // indirect\n\tgithub.com/mattn/go-colorable v0.1.14 // indirect\n\tgithub.com/mattn/go-isatty v0.0.20 // indirect\n\tgithub.com/mattn/go-runewidth v0.0.16 // indirect\n\tgithub.com/microcosm-cc/bluemonday v1.0.27 // indirect\n\tgithub.com/mikefarah/yq/v4 v4.52.4 // indirect\n\tgithub.com/mitchellh/go-wordwrap v1.0.1 // indirect\n\tgithub.com/mitchellh/mapstructure v1.5.0 // indirect\n\tgithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect\n\tgithub.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect\n\tgithub.com/muesli/cancelreader v0.2.2 // indirect\n\tgithub.com/muesli/reflow v0.3.0 // indirect\n\tgithub.com/muesli/termenv v0.16.0 // indirect\n\tgithub.com/pelletier/go-toml/v2 v2.2.4 // indirect\n\tgithub.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb // indirect\n\tgithub.com/pkg/errors v0.9.1 // indirect\n\tgithub.com/rivo/uniseg v0.4.7 // indirect\n\tgithub.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect\n\tgithub.com/sagikazarmark/locafero v0.11.0 // indirect\n\tgithub.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect\n\tgithub.com/sasha-s/go-deadlock v0.3.5 // indirect\n\tgithub.com/segmentio/ksuid v1.0.4 // indirect\n\tgithub.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect\n\tgithub.com/sourcegraph/jsonrpc2 v0.2.0 // indirect\n\tgithub.com/spf13/afero v1.15.0 // indirect\n\tgithub.com/spf13/cast v1.10.0 // indirect\n\tgithub.com/spf13/cobra v1.10.2 // indirect\n\tgithub.com/spf13/pflag v1.0.10 // indirect\n\tgithub.com/spf13/viper v1.21.0 // indirect\n\tgithub.com/subosito/gotenv v1.6.0 // indirect\n\tgithub.com/tliron/commonlog v0.2.19 // indirect\n\tgithub.com/tliron/glsp v0.2.2 // indirect\n\tgithub.com/tliron/kutil v0.3.26 // indirect\n\tgithub.com/x448/float16 v0.8.4 // indirect\n\tgithub.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect\n\tgithub.com/yuin/goldmark v1.7.8 // indirect\n\tgithub.com/yuin/goldmark-emoji v1.0.5 // indirect\n\tgithub.com/yuin/gopher-lua v1.1.1 // indirect\n\tgithub.com/zclconf/go-cty v1.17.0 // indirect\n\tgo.yaml.in/yaml/v2 v2.4.3 // indirect\n\tgo.yaml.in/yaml/v3 v3.0.4 // indirect\n\tgo.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect\n\tgolang.org/x/crypto v0.48.0 // indirect\n\tgolang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect\n\tgolang.org/x/mod v0.33.0 // indirect\n\tgolang.org/x/net v0.50.0 // indirect\n\tgolang.org/x/sync v0.19.0 // indirect\n\tgolang.org/x/sys v0.41.0 // indirect\n\tgolang.org/x/term v0.40.0 // indirect\n\tgolang.org/x/text v0.34.0 // indirect\n\tgolang.org/x/tools v0.41.0 // indirect\n\tgoogle.golang.org/protobuf v1.36.8 // indirect\n\tgopkg.in/inf.v0 v0.9.1 // indirect\n\tgopkg.in/op/go-logging.v1 v1.0.0-20160211212156-b2cb9fa56473 // indirect\n\tgopkg.in/yaml.v2 v2.4.0 // indirect\n\tgopkg.in/yaml.v3 v3.0.1 // indirect\n\tk8s.io/api v0.35.0 // indirect\n\tk8s.io/apiextensions-apiserver v0.35.0 // indirect\n\tk8s.io/apimachinery v0.35.0 // indirect\n\tk8s.io/code-generator v0.35.0 // indirect\n\tk8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b // indirect\n\tk8s.io/klog/v2 v2.130.1 // indirect\n\tk8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect\n\tk8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect\n\tsigs.k8s.io/controller-tools v0.20.1 // indirect\n\tsigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect\n\tsigs.k8s.io/randfill v1.0.0 // indirect\n\tsigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect\n\tsigs.k8s.io/yaml v1.6.0 // indirect\n)\n"
  },
  {
    "path": "go.tool.sum",
    "content": "github.com/a8m/envsubst v1.4.3 h1:kDF7paGK8QACWYaQo6KtyYBozY2jhQrTuNNuUxQkhJY=\ngithub.com/a8m/envsubst v1.4.3/go.mod h1:4jjHWQlZoaXPoLQUb7H2qT4iLkZDdmEQiOUogdUmqVU=\ngithub.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8=\ngithub.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=\ngithub.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=\ngithub.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=\ngithub.com/alecthomas/participle/v2 v2.1.4 h1:W/H79S8Sat/krZ3el6sQMvMaahJ+XcM9WSI2naI7w2U=\ngithub.com/alecthomas/participle/v2 v2.1.4/go.mod h1:8tqVbpTX20Ru4NfYQgZf4mP18eXPTBViyMWiArNEgGI=\ngithub.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=\ngithub.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=\ngithub.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=\ngithub.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=\ngithub.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=\ngithub.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=\ngithub.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=\ngithub.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=\ngithub.com/bmatcuk/doublestar/v4 v4.7.1 h1:fdDeAqgT47acgwd9bd9HxJRDmc9UAmPpc+2m0CXv75Q=\ngithub.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=\ngithub.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=\ngithub.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=\ngithub.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=\ngithub.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=\ngithub.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 h1:swACzss0FjnyPz1enfX56GKkLiuKg5FlyVmOLIlU2kE=\ngithub.com/charmbracelet/bubbles/v2 v2.0.0-beta.1/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=\ngithub.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4 h1:UgUuKKvBwgqm2ZEL+sKv/OLeavrUb4gfHgdxe6oIOno=\ngithub.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4/go.mod h1:0wWFRpsgF7vHsCukVZ5LAhZkiR4j875H6KEM2/tFQmA=\ngithub.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI=\ngithub.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI=\ngithub.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=\ngithub.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=\ngithub.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=\ngithub.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=\ngithub.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=\ngithub.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=\ngithub.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3 h1:W6DpZX6zSkZr0iFq6JVh1vItLoxfYtNlaxOJtWp8Kis=\ngithub.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3/go.mod h1:65HTtKURcv/ict9ZQhr6zT84JqIjMcJbyrZYHHKNfKA=\ngithub.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=\ngithub.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=\ngithub.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1 h1:MTSs/nsZNfZPbYk/r9hluK2BtwoqvEYruAujNVwgDv0=\ngithub.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk=\ngithub.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=\ngithub.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=\ngithub.com/charmbracelet/x/input v0.3.7 h1:UzVbkt1vgM9dBQ+K+uRolBlN6IF2oLchmPKKo/aucXo=\ngithub.com/charmbracelet/x/input v0.3.7/go.mod h1:ZSS9Cia6Cycf2T6ToKIOxeTBTDwl25AGwArJuGaOBH8=\ngithub.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=\ngithub.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=\ngithub.com/charmbracelet/x/windows v0.2.1 h1:3x7vnbpQrjpuq/4L+I4gNsG5htYoCiA5oe9hLjAij5I=\ngithub.com/charmbracelet/x/windows v0.2.1/go.mod h1:ptZp16h40gDYqs5TSawSVW+yiLB13j4kSMA0lSCHL0M=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=\ngithub.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U=\ngithub.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE=\ngithub.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=\ngithub.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=\ngithub.com/dop251/goja v0.0.0-20250630131328-58d95d85e994 h1:aQYWswi+hRL2zJqGacdCZx32XjKYV8ApXFGntw79XAM=\ngithub.com/dop251/goja v0.0.0-20250630131328-58d95d85e994/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=\ngithub.com/dop251/goja_nodejs v0.0.0-20250409162600-f7acab6894b0 h1:fuHXpEVTTk7TilRdfGRLHpiTD6tnT0ihEowCfWjlFvw=\ngithub.com/dop251/goja_nodejs v0.0.0-20250409162600-f7acab6894b0/go.mod h1:Tb7Xxye4LX7cT3i8YLvmPMGCV92IOi4CDZvm/V8ylc0=\ngithub.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=\ngithub.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=\ngithub.com/elliotchance/orderedmap v1.8.0 h1:TrOREecvh3JbS+NCgwposXG5ZTFHtEsQiCGOhPElnMw=\ngithub.com/elliotchance/orderedmap v1.8.0/go.mod h1:wsDwEaX5jEoyhbs7x93zk2H/qv0zwuhg4inXhDkYqys=\ngithub.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=\ngithub.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=\ngithub.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=\ngithub.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=\ngithub.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=\ngithub.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=\ngithub.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=\ngithub.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=\ngithub.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=\ngithub.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=\ngithub.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=\ngithub.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=\ngithub.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=\ngithub.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=\ngithub.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=\ngithub.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=\ngithub.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=\ngithub.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=\ngithub.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=\ngithub.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=\ngithub.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=\ngithub.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=\ngithub.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4=\ngithub.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs=\ngithub.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=\ngithub.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=\ngithub.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=\ngithub.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=\ngithub.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE=\ngithub.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=\ngithub.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=\ngithub.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=\ngithub.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=\ngithub.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=\ngithub.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=\ngithub.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=\ngithub.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=\ngithub.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=\ngithub.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=\ngithub.com/google/pprof v0.0.0-20250501235452-c0086092b71a h1:rDA3FfmxwXR+BVKKdz55WwMJ1pD2hJQNW31d+l3mPk4=\ngithub.com/google/pprof v0.0.0-20250501235452-c0086092b71a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/google/yamlfmt v0.17.2 h1:TkXxhmj7dnpmOnlWGOXog92Gs6MWcTZqnf3kuyp8yFQ=\ngithub.com/google/yamlfmt v0.17.2/go.mod h1:gs0UEklJOYkUJ+OOCG0hg9n+DzucKDPlJElTUasVNK8=\ngithub.com/google/yamlfmt v0.21.0 h1:9FKApQkDpMKgBjwLFytBHUCgqnQgxaQnci0uiESfbzs=\ngithub.com/google/yamlfmt v0.21.0/go.mod h1:q6FYExB+Ueu7jZDjKECJk+EaeDXJzJ6Ne0dxx69GWfI=\ngithub.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=\ngithub.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=\ngithub.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=\ngithub.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=\ngithub.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=\ngithub.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=\ngithub.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE=\ngithub.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM=\ngithub.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI=\ngithub.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=\ngithub.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=\ngithub.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=\ngithub.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=\ngithub.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=\ngithub.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=\ngithub.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=\ngithub.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=\ngithub.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=\ngithub.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=\ngithub.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=\ngithub.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=\ngithub.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=\ngithub.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=\ngithub.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=\ngithub.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=\ngithub.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=\ngithub.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=\ngithub.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=\ngithub.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=\ngithub.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=\ngithub.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=\ngithub.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=\ngithub.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=\ngithub.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=\ngithub.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=\ngithub.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=\ngithub.com/mikefarah/yq/v4 v4.47.2 h1:Jb5fHlvgK5eeaPbreG9UJs1E5w6l5hUzXjeaY6LTTWY=\ngithub.com/mikefarah/yq/v4 v4.47.2/go.mod h1:ulYbZUzGJsBDDwO5ohvk/KOW4vW5Iddd/DBeAY1Q09g=\ngithub.com/mikefarah/yq/v4 v4.50.1 h1:u7pnei4FIv4HGL5ZBuNVDhDBe9et1YRFnoTmKZw6zOY=\ngithub.com/mikefarah/yq/v4 v4.50.1/go.mod h1:L4Z8NywrquZ+PVMz6IFFeGIp64eBC2mGC0nMryygCnI=\ngithub.com/mikefarah/yq/v4 v4.52.4 h1:wZlxBMjyKCzzQjL0u6a3zToKuyE7OdJr4OtLBtwph4Q=\ngithub.com/mikefarah/yq/v4 v4.52.4/go.mod h1:8QwgSgDsmt4LCbfwvGUAh5oWSukRRuVJ8Gj98zJ/45o=\ngithub.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=\ngithub.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=\ngithub.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=\ngithub.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=\ngithub.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=\ngithub.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=\ngithub.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=\ngithub.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=\ngithub.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=\ngithub.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=\ngithub.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=\ngithub.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=\ngithub.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=\ngithub.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=\ngithub.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=\ngithub.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=\ngithub.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=\ngithub.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb h1:3PrKuO92dUTMrQ9dx0YNejC6U/Si6jqKmyQ9vWjwqR4=\ngithub.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=\ngithub.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=\ngithub.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=\ngithub.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=\ngithub.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=\ngithub.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=\ngithub.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=\ngithub.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=\ngithub.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=\ngithub.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=\ngithub.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=\ngithub.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=\ngithub.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=\ngithub.com/sasha-s/go-deadlock v0.3.5 h1:tNCOEEDG6tBqrNDOX35j/7hL5FcFViG6awUGROb2NsU=\ngithub.com/sasha-s/go-deadlock v0.3.5/go.mod h1:bugP6EGbdGYObIlx7pUZtWqlvo8k9H6vCBBsiChJQ5U=\ngithub.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c=\ngithub.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE=\ngithub.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=\ngithub.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=\ngithub.com/sourcegraph/jsonrpc2 v0.2.0 h1:KjN/dC4fP6aN9030MZCJs9WQbTOjWHhrtKVpzzSrr/U=\ngithub.com/sourcegraph/jsonrpc2 v0.2.0/go.mod h1:ZafdZgk/axhT1cvZAPOhw+95nz2I/Ra5qMlU4gTRwIo=\ngithub.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=\ngithub.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=\ngithub.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=\ngithub.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=\ngithub.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=\ngithub.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=\ngithub.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=\ngithub.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=\ngithub.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=\ngithub.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=\ngithub.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=\ngithub.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=\ngithub.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=\ngithub.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=\ngithub.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=\ngithub.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=\ngithub.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=\ngithub.com/tliron/commonlog v0.2.19 h1:v1mOH1TyzFLqkshR03khw7ENAZPjAyZTQBQrqN+vX9c=\ngithub.com/tliron/commonlog v0.2.19/go.mod h1:AcdhfcUqlAWukDrzTGyaPhUgYiNdZhS4dKzD/e0tjcY=\ngithub.com/tliron/glsp v0.2.2 h1:IKPfwpE8Lu8yB6Dayta+IyRMAbTVunudeauEgjXBt+c=\ngithub.com/tliron/glsp v0.2.2/go.mod h1:GMVWDNeODxHzmDPvYbYTCs7yHVaEATfYtXiYJ9w1nBg=\ngithub.com/tliron/kutil v0.3.26 h1:G+dicQLvzm3zdOMrrQFLBfHJXtk57fEu2kf1IFNyJxw=\ngithub.com/tliron/kutil v0.3.26/go.mod h1:1/HRVAb+fnRIRnzmhu0FPP+ZJKobrpwHStDVMuaXDzY=\ngithub.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=\ngithub.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=\ngithub.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=\ngithub.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=\ngithub.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=\ngithub.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=\ngithub.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=\ngithub.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=\ngithub.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=\ngithub.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=\ngithub.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=\ngithub.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=\ngithub.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=\ngithub.com/zclconf/go-cty v1.17.0 h1:seZvECve6XX4tmnvRzWtJNHdscMtYEx5R7bnnVyd/d0=\ngithub.com/zclconf/go-cty v1.17.0/go.mod h1:wqFzcImaLTI6A5HfsRwB0nj5n0MRZFwmey8YoFPPs3U=\ngo.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=\ngo.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=\ngo.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=\ngo.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=\ngo.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=\ngo.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=\ngo.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s=\ngo.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=\ngo.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=\ngo.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=\ngolang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=\ngolang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=\ngolang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=\ngolang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=\ngolang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU=\ngolang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk=\ngolang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=\ngolang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=\ngolang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=\ngolang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=\ngolang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=\ngolang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=\ngolang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=\ngolang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=\ngolang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=\ngolang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=\ngolang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=\ngolang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=\ngolang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=\ngolang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=\ngolang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=\ngolang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=\ngolang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=\ngolang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=\ngolang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=\ngolang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=\ngolang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=\ngolang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=\ngolang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=\ngolang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=\ngolang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=\ngolang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=\ngolang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=\ngolang.org/x/telemetry v0.0.0-20240522233618-39ace7a40ae7 h1:FemxDzfMUcK2f3YY4H+05K9CDzbSVr2+q/JKN45pey0=\ngolang.org/x/telemetry v0.0.0-20240522233618-39ace7a40ae7/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0=\ngolang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=\ngolang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=\ngolang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=\ngolang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=\ngolang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=\ngolang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=\ngolang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=\ngolang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=\ngolang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=\ngolang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=\ngolang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=\ngolang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=\ngolang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=\ngolang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=\ngolang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=\ngolang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=\ngolang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=\ngolang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=\ngolang.org/x/vuln v1.1.4 h1:Ju8QsuyhX3Hk8ma3CesTbO8vfJD9EvUBgHvkxHBzj0I=\ngolang.org/x/vuln v1.1.4/go.mod h1:F+45wmU18ym/ca5PLTPLsSzr2KppzswxPP603ldA67s=\ngolang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngoogle.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=\ngoogle.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=\ngoogle.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=\ngoogle.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=\ngopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=\ngopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=\ngopkg.in/op/go-logging.v1 v1.0.0-20160211212156-b2cb9fa56473 h1:6D+BvnJ/j6e222UW8s2qTSe3wGBtvo0MbVQG/c5k8RE=\ngopkg.in/op/go-logging.v1 v1.0.0-20160211212156-b2cb9fa56473/go.mod h1:N1eN2tsCx0Ydtgjl4cqmbRCsY4/+z4cYDeqwZTk6zog=\ngopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=\ngopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\nk8s.io/api v0.34.0 h1:L+JtP2wDbEYPUeNGbeSa/5GwFtIA662EmT2YSLOkAVE=\nk8s.io/api v0.34.0/go.mod h1:YzgkIzOOlhl9uwWCZNqpw6RJy9L2FK4dlJeayUoydug=\nk8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY=\nk8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA=\nk8s.io/apiextensions-apiserver v0.34.0 h1:B3hiB32jV7BcyKcMU5fDaDxk882YrJ1KU+ZSkA9Qxoc=\nk8s.io/apiextensions-apiserver v0.34.0/go.mod h1:hLI4GxE1BDBy9adJKxUxCEHBGZtGfIg98Q+JmTD7+g0=\nk8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4=\nk8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU=\nk8s.io/apimachinery v0.34.0 h1:eR1WO5fo0HyoQZt1wdISpFDffnWOvFLOOeJ7MgIv4z0=\nk8s.io/apimachinery v0.34.0/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=\nk8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8=\nk8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=\nk8s.io/code-generator v0.34.0 h1:Ze2i1QsvUprIlX3oHiGv09BFQRLCz+StA8qKwwFzees=\nk8s.io/code-generator v0.34.0/go.mod h1:Py2+4w2HXItL8CGhks8uI/wS3Y93wPKO/9mBQUYNua0=\nk8s.io/code-generator v0.35.0 h1:TvrtfKYZTm9oDF2z+veFKSCcgZE3Igv0svY+ehCmjHQ=\nk8s.io/code-generator v0.35.0/go.mod h1:iS1gvVf3c/T71N5DOGYO+Gt3PdJ6B9LYSvIyQ4FHzgc=\nk8s.io/gengo/v2 v2.0.0-20250604051438-85fd79dbfd9f h1:SLb+kxmzfA87x4E4brQzB33VBbT2+x7Zq9ROIHmGn9Q=\nk8s.io/gengo/v2 v2.0.0-20250604051438-85fd79dbfd9f/go.mod h1:EJykeLsmFC60UQbYJezXkEsG2FLrt0GPNkU5iK5GWxU=\nk8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b h1:gMplByicHV/TJBizHd9aVEsTYoJBnnUAT5MHlTkbjhQ=\nk8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b/go.mod h1:CgujABENc3KuTrcsdpGmrrASjtQsWCT7R99mEV4U/fM=\nk8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=\nk8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=\nk8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA=\nk8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts=\nk8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE=\nk8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=\nk8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y=\nk8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=\nk8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=\nk8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=\nsigs.k8s.io/controller-tools v0.17.2 h1:jNFOKps8WnaRKZU2R+4vRCHnXyJanVmXBWqkuUPFyFg=\nsigs.k8s.io/controller-tools v0.17.2/go.mod h1:4q5tZG2JniS5M5bkiXY2/potOiXyhoZVw/U48vLkXk0=\nsigs.k8s.io/controller-tools v0.19.0 h1:OU7jrPPiZusryu6YK0jYSjPqg8Vhf8cAzluP9XGI5uk=\nsigs.k8s.io/controller-tools v0.19.0/go.mod h1:y5HY/iNDFkmFla2CfQoVb2AQXMsBk4ad84iR1PLANB0=\nsigs.k8s.io/controller-tools v0.20.0 h1:VWZF71pwSQ2lZZCt7hFGJsOfDc5dVG28/IysjjMWXL8=\nsigs.k8s.io/controller-tools v0.20.0/go.mod h1:b4qPmjGU3iZwqn34alUU5tILhNa9+VXK+J3QV0fT/uU=\nsigs.k8s.io/controller-tools v0.20.1 h1:gkfMt9YodI0K85oT8rVi80NTXO/kDmabKR5Ajn5GYxs=\nsigs.k8s.io/controller-tools v0.20.1/go.mod h1:b4qPmjGU3iZwqn34alUU5tILhNa9+VXK+J3QV0fT/uU=\nsigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE=\nsigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=\nsigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=\nsigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=\nsigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=\nsigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=\nsigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=\nsigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=\nsigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=\nsigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=\n"
  },
  {
    "path": "internal/OWNERS",
    "content": "# See the OWNERS docs at https://go.k8s.io/owners\n\nlabels:\n- internal\n"
  },
  {
    "path": "internal/config/config.go",
    "content": "/*\nCopyright 2020 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage config\n\n// FastPoll used for fast testing\nvar FastPoll = false\n"
  },
  {
    "path": "internal/flags/binders.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage flags\n\nimport (\n\t\"fmt\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/alecthomas/kingpin/v2\"\n)\n\n// FlagBinder abstracts flag registration for different CLI backends.\ntype FlagBinder interface {\n\tStringVar(name, help, def string, target *string)\n\tBoolVar(name, help string, def bool, target *bool)\n\tDurationVar(name, help string, def time.Duration, target *time.Duration)\n\tIntVar(name, help string, def int, target *int)\n\tInt64Var(name, help string, def int64, target *int64)\n\tStringsVar(name, help string, def []string, target *[]string)\n\tEnumVar(name, help, def string, target *string, allowed ...string)\n\t// StringsEnumVar binds a repeatable string flag with an allowed set.\n\t// Implementations may not enforce allowed values.\n\tStringsEnumVar(name, help string, def []string, target *[]string, allowed ...string)\n\t// StringMapVar binds key=value repeatable flags into a map.\n\tStringMapVar(name, help string, target *map[string]string)\n\t// RegexpVar binds a regular expression value.\n\tRegexpVar(name, help string, def *regexp.Regexp, target **regexp.Regexp)\n}\n\n// KingpinBinder implements FlagBinder using github.com/alecthomas/kingpin/v2.\ntype KingpinBinder struct {\n\tApp *kingpin.Application\n}\n\n// NewKingpinBinder creates a FlagBinder backed by a kingpin Application.\nfunc NewKingpinBinder(app *kingpin.Application) *KingpinBinder {\n\treturn &KingpinBinder{App: app}\n}\n\nfunc (b *KingpinBinder) StringVar(name, help, def string, target *string) {\n\tb.App.Flag(name, help).Default(def).StringVar(target)\n}\n\nfunc (b *KingpinBinder) BoolVar(name, help string, def bool, target *bool) {\n\tif def {\n\t\tb.App.Flag(name, help).Default(\"true\").BoolVar(target)\n\t} else {\n\t\tb.App.Flag(name, help).Default(\"false\").BoolVar(target)\n\t}\n}\n\nfunc (b *KingpinBinder) DurationVar(name, help string, def time.Duration, target *time.Duration) {\n\tb.App.Flag(name, help).Default(def.String()).DurationVar(target)\n}\n\nfunc (b *KingpinBinder) IntVar(name, help string, def int, target *int) {\n\tb.App.Flag(name, help).Default(strconv.Itoa(def)).IntVar(target)\n}\n\nfunc (b *KingpinBinder) Int64Var(name, help string, def int64, target *int64) {\n\tb.App.Flag(name, help).Default(strconv.FormatInt(def, 10)).Int64Var(target)\n}\n\nfunc (b *KingpinBinder) StringsVar(name, help string, def []string, target *[]string) {\n\tif len(def) > 0 {\n\t\tb.App.Flag(name, help).Default(def...).StringsVar(target)\n\t\treturn\n\t}\n\tb.App.Flag(name, help).StringsVar(target)\n}\n\nfunc (b *KingpinBinder) EnumVar(name, help, def string, target *string, allowed ...string) {\n\tb.App.Flag(name, help).Default(def).EnumVar(target, allowed...)\n}\n\nfunc (b *KingpinBinder) StringsEnumVar(name, help string, def []string, target *[]string, allowed ...string) {\n\tif len(def) > 0 {\n\t\tb.App.Flag(name, help).Default(def...).EnumsVar(target, allowed...)\n\t\treturn\n\t}\n\tb.App.Flag(name, help).EnumsVar(target, allowed...)\n}\n\nfunc (b *KingpinBinder) StringMapVar(name, help string, target *map[string]string) {\n\tb.App.Flag(name, help).StringMapVar(target)\n}\n\nfunc (b *KingpinBinder) RegexpVar(name, help string, def *regexp.Regexp, target **regexp.Regexp) {\n\tdefStr := \"\"\n\tif def != nil {\n\t\tdefStr = def.String()\n\t}\n\tb.App.Flag(name, help).Default(defStr).RegexpVar(target)\n}\n\ntype regexpValue struct {\n\ttarget **regexp.Regexp\n}\n\nfunc (rv *regexpValue) String() string {\n\tif rv == nil || rv.target == nil || *rv.target == nil {\n\t\treturn \"\"\n\t}\n\treturn (*rv.target).String()\n}\n\nfunc (rv *regexpValue) Set(s string) error {\n\tre, err := regexp.Compile(s)\n\tif err != nil {\n\t\treturn err\n\t}\n\t*rv.target = re\n\treturn nil\n}\n\nfunc (rv *regexpValue) Type() string { return \"regexp\" }\n\ntype regexpSetter interface {\n\tSet(string) error\n}\n\nfunc setRegexpDefault(rs regexpSetter, def *regexp.Regexp, name string) {\n\tif def != nil {\n\t\tif err := rs.Set(def.String()); err != nil {\n\t\t\tpanic(fmt.Errorf(\"invalid default regexp for flag %s: %w\", name, err))\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/flags/binders_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage flags\n\nimport (\n\t\"errors\"\n\t\"regexp\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/alecthomas/kingpin/v2\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\ntype badSetter struct{}\n\nfunc (b *badSetter) Set(_ string) error { return errors.New(\"bad default\") }\n\nfunc TestKingpinBinderParsesAllTypes(t *testing.T) {\n\tapp := kingpin.New(\"test\", \"\")\n\tb := NewKingpinBinder(app)\n\n\tvar (\n\t\ts    string\n\t\tbval bool\n\t\td    time.Duration\n\t\ti    int\n\t\ti64  int64\n\t\tss   []string\n\t\te    string\n\t)\n\n\tb.StringVar(\"s\", \"string flag\", \"def\", &s)\n\tb.BoolVar(\"b\", \"bool flag\", true, &bval)\n\tb.DurationVar(\"d\", \"duration flag\", 5*time.Second, &d)\n\tb.IntVar(\"i\", \"int flag\", 7, &i)\n\tb.Int64Var(\"i64\", \"int64 flag\", 9, &i64)\n\tb.StringsVar(\"ss\", \"strings flag\", []string{\"x\"}, &ss)\n\tb.EnumVar(\"e\", \"enum flag\", \"a\", &e, \"a\", \"b\")\n\n\t_, err := app.Parse([]string{\"--s=abc\", \"--no-b\", \"--d=2s\", \"--i=42\", \"--i64=64\", \"--ss=one\", \"--ss=two\", \"--e=b\"})\n\trequire.NoError(t, err)\n\n\tassert.Equal(t, \"abc\", s)\n\tassert.False(t, bval)\n\tassert.Equal(t, 2*time.Second, d)\n\tassert.Equal(t, 42, i)\n\tassert.Equal(t, int64(64), i64)\n\tassert.ElementsMatch(t, []string{\"one\", \"two\"}, ss)\n\tassert.Equal(t, \"b\", e)\n}\n\nfunc TestKingpinBinderEnumValidation(t *testing.T) {\n\tapp := kingpin.New(\"test\", \"\")\n\tb := NewKingpinBinder(app)\n\n\tvar e string\n\tb.EnumVar(\"e\", \"enum flag\", \"a\", &e, \"a\", \"b\")\n\n\t_, err := app.Parse([]string{\"--e=c\"})\n\trequire.Error(t, err)\n}\n\nfunc TestKingpinBinderStringsVarNoDefaultAndBoolDefaultFalse(t *testing.T) {\n\tapp := kingpin.New(\"test\", \"\")\n\tb := NewKingpinBinder(app)\n\n\tvar (\n\t\tss []string\n\t\tb2 bool\n\t)\n\n\tb.StringsVar(\"ss\", \"strings flag\", nil, &ss)\n\tb.BoolVar(\"b2\", \"bool2 flag\", false, &b2)\n\n\t_, err := app.Parse([]string{})\n\trequire.NoError(t, err)\n\n\tassert.Empty(t, ss)\n\tassert.False(t, b2)\n}\n\nfunc TestCobraRegexValueSetStringType(t *testing.T) {\n\tvar r *regexp.Regexp\n\trv := &regexpValue{target: &r}\n\n\trequire.Equal(t, \"regexp\", rv.Type())\n\t// empty when target nil\n\tassert.Empty(t, rv.String())\n\n\t// invalid pattern returns error\n\terr := rv.Set(\"(\")\n\trequire.Error(t, err)\n\n\t// valid pattern sets target\n\terr = rv.Set(\"^foo$\")\n\trequire.NoError(t, err)\n\trequire.NotNil(t, r)\n\tassert.Equal(t, \"^foo$\", r.String())\n\tassert.Equal(t, \"^foo$\", rv.String())\n}\n\nfunc TestKingpinRegexpVarDefaultAndParse(t *testing.T) {\n\tapp := kingpin.New(\"test\", \"\")\n\tb := NewKingpinBinder(app)\n\n\tvar r *regexp.Regexp\n\tb.RegexpVar(\"re\", \"help\", regexp.MustCompile(\"^a+$\"), &r)\n\n\t_, err := app.Parse([]string{})\n\trequire.NoError(t, err)\n\trequire.NotNil(t, r)\n\tassert.Equal(t, \"^a+$\", r.String())\n\n\t// user-provided value should override default\n\tvar r2 *regexp.Regexp\n\tapp2 := kingpin.New(\"test2\", \"\")\n\tb2 := NewKingpinBinder(app2)\n\tb2.RegexpVar(\"re\", \"help\", nil, &r2)\n\t_, err = app2.Parse([]string{\"--re=^b+$\"})\n\trequire.NoError(t, err)\n\trequire.NotNil(t, r2)\n\tassert.Equal(t, \"^b+$\", r2.String())\n}\n\nfunc TestKingpinStringsEnumVarWithAndWithoutDefault(t *testing.T) {\n\tapp := kingpin.New(\"test\", \"\")\n\tb := NewKingpinBinder(app)\n\n\tvar vals []string\n\tb.StringsEnumVar(\"se\", \"help\", []string{\"a\", \"b\"}, &vals, \"a\", \"b\", \"c\")\n\t_, err := app.Parse([]string{})\n\trequire.NoError(t, err)\n\tassert.ElementsMatch(t, []string{\"a\", \"b\"}, vals)\n\n\t// without default\n\tapp2 := kingpin.New(\"test2\", \"\")\n\tb2 := NewKingpinBinder(app2)\n\tvar vals2 []string\n\tb2.StringsEnumVar(\"se\", \"help\", nil, &vals2, \"a\", \"b\", \"c\")\n\t_, err = app2.Parse([]string{\"--se=a\", \"--se=c\"})\n\trequire.NoError(t, err)\n\tassert.ElementsMatch(t, []string{\"a\", \"c\"}, vals2)\n}\n\nfunc TestSetRegexDefaultPanicsOnInvalidDefault(t *testing.T) {\n\tbs := &badSetter{}\n\tdef := regexp.MustCompile(\"^\")\n\trequire.Panics(t, func() { setRegexpDefault(bs, def, \"flag\") })\n}\n"
  },
  {
    "path": "internal/gen/docs/flags/main.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage main\n\nimport (\n\t\"embed\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\n\t\"sigs.k8s.io/external-dns/internal/gen/docs/render\"\n\tcfg \"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n)\n\nvar (\n\t//go:embed \"templates/*\"\n\ttemplates embed.FS\n)\n\ntype Flag struct {\n\tName        string\n\tDescription string\n}\ntype Flags []Flag\n\n// addFlag adds a new flag to the Flags slice.\nfunc (f *Flags) addFlag(name, description string) {\n\t*f = append(*f, Flag{Name: name, Description: description})\n}\n\n// main generates a markdown file with the supported flags\n// and writes it to the 'docs/flags.md' file.\n// To re-generate, execute 'go run internal/gen/docs/flags/main.go'.\nfunc main() {\n\ttestPath, _ := os.Getwd()\n\tpath := fmt.Sprintf(\"%s/docs/flags.md\", testPath)\n\tfmt.Printf(\"generate file '%s' with supported flags\\n\", path)\n\n\tflags := computeFlags()\n\tcontent, err := flags.generateMarkdownTable()\n\tif err != nil {\n\t\t_ = fmt.Errorf(\"failed to generate markdown file '%s': %v\", path, err.Error())\n\t}\n\tcontent += \"\\n\"\n\t_ = render.WriteToFile(path, content)\n}\n\nfunc computeFlags() Flags {\n\tapp := cfg.App(&cfg.Config{})\n\tmodelFlags := app.Model().Flags\n\n\tflags := Flags{}\n\n\tfor _, flag := range modelFlags {\n\t\t// do not include helpers and completion flags\n\t\tif strings.Contains(flag.Name, \"help\") || strings.Contains(flag.Name, \"completion-\") {\n\t\t\tcontinue\n\t\t}\n\t\tflagString := \"\"\n\t\tflagName := flag.Name\n\t\tif flag.IsBoolFlag() {\n\t\t\tflagName = \"[no-]\" + flagName\n\t\t}\n\t\tflagString += fmt.Sprintf(\"--%s\", flagName)\n\n\t\tif !flag.IsBoolFlag() {\n\t\t\tflagString += fmt.Sprintf(\"=%s\", flag.FormatPlaceHolder())\n\t\t}\n\t\tflags.addFlag(fmt.Sprintf(\"`%s`\", flagString), flag.HelpWithEnvar())\n\t}\n\treturn flags\n}\n\ntype columnWidths struct {\n\tFlag        int\n\tDescription int\n}\n\nfunc computeFlagColumnWidths(flags Flags) columnWidths {\n\treturn columnWidths{\n\t\tFlag:        render.MapColumn(\"Flag\", flags, func(f Flag) string { return f.Name }),\n\t\tDescription: render.MapColumn(\"Description\", flags, func(f Flag) string { return f.Description }),\n\t}\n}\n\ntype templateData struct {\n\tFlags     Flags\n\tColWidths columnWidths\n}\n\nfunc (f *Flags) generateMarkdownTable() (string, error) {\n\treturn render.RenderTemplate(templates, \"flags.gotpl\", templateData{\n\t\tFlags:     *f,\n\t\tColWidths: computeFlagColumnWidths(*f),\n\t})\n}\n"
  },
  {
    "path": "internal/gen/docs/flags/main_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage main\n\nimport (\n\t\"fmt\"\n\t\"io/fs\"\n\t\"os\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nconst pathToDocs = \"%s/../../../../docs\"\n\nfunc TestComputeFlags(t *testing.T) {\n\tflags := computeFlags()\n\n\tif len(flags) == 0 {\n\t\tt.Errorf(\"Expected non-zero flags, got %d\", len(flags))\n\t}\n\n\tfor _, flag := range flags {\n\t\tif strings.Contains(flag.Name, \"help\") || strings.Contains(flag.Name, \"completion-\") {\n\t\t\tt.Errorf(\"Unexpected flag: %s\", flag.Name)\n\t\t}\n\t}\n}\n\nfunc TestGenerateMarkdownTableRenderer(t *testing.T) {\n\tflags := Flags{\n\t\t{Name: \"flag1\", Description: \"description1\"},\n\t}\n\n\tgot, err := flags.generateMarkdownTable()\n\tassert.NoError(t, err)\n\n\tassert.Contains(t, got, \"<!-- THIS FILE MUST NOT BE EDITED BY HAND -->\")\n\tassert.Contains(t, got, \"| flag1 | description1 |\")\n}\n\nfunc TestFlagsMdExists(t *testing.T) {\n\ttestPath, _ := os.Getwd()\n\tfsys := os.DirFS(fmt.Sprintf(pathToDocs, testPath))\n\tfileName := \"flags.md\"\n\tst, err := fs.Stat(fsys, fileName)\n\tassert.NoError(t, err, \"expected file %s to exist\", fileName)\n\tassert.Equal(t, fileName, st.Name())\n}\n\nfunc TestFlagsMdUpToDate(t *testing.T) {\n\ttestPath, _ := os.Getwd()\n\tfsys := os.DirFS(fmt.Sprintf(pathToDocs, testPath))\n\tfileName := \"flags.md\"\n\texpected, err := fs.ReadFile(fsys, fileName)\n\tassert.NoError(t, err, \"expected file %s to exist\", fileName)\n\n\tflags := computeFlags()\n\tactual, err := flags.generateMarkdownTable()\n\tassert.NoError(t, err)\n\tactual += \"\\n\"\n\tassert.Equal(t, string(expected), actual, \"expected file '%s' to be up to date. execute 'make generate-flags-documentation'\", fileName)\n}\n\nfunc TestFlagsMdExtraFlagAdded(t *testing.T) {\n\ttestPath, _ := os.Getwd()\n\tfsys := os.DirFS(fmt.Sprintf(pathToDocs, testPath))\n\tfilePath := \"flags.md\"\n\texpected, err := fs.ReadFile(fsys, filePath)\n\tassert.NoError(t, err, \"expected file %s to exist\", filePath)\n\n\tflags := computeFlags()\n\tflags.addFlag(\"new-flag\", \"description2\")\n\tactual, err := flags.generateMarkdownTable()\n\n\tassert.NoError(t, err)\n\tassert.NotEqual(t, string(expected), actual)\n}\n"
  },
  {
    "path": "internal/gen/docs/flags/templates/flags.gotpl",
    "content": "---\ntags:\n  - flags\n  - autogenerated\n---\n\n# Flags\n\n<!-- THIS FILE MUST NOT BE EDITED BY HAND -->\n<!-- ON NEW FLAG ADDED PLEASE RUN 'make generate-flags-documentation' -->\n<!-- markdownlint-disable MD013 -->\n\n| {{ padRight .ColWidths.Flag \"Flag\" }} | {{ padRight .ColWidths.Description \"Description\" }} |\n|:{{ leftSep .ColWidths.Flag }}|:{{ leftSep .ColWidths.Description }}|\n{{- range .Flags }}\n| {{ padRight $.ColWidths.Flag .Name }} | {{ padRight $.ColWidths.Description .Description }} |\n{{- end -}}\n"
  },
  {
    "path": "internal/gen/docs/metrics/main.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage main\n\nimport (\n\t\"embed\"\n\t\"fmt\"\n\t\"os\"\n\t\"slices\"\n\t\"sort\"\n\t\"strings\"\n\n\t\"github.com/prometheus/client_golang/prometheus\"\n\n\t\"sigs.k8s.io/external-dns/internal/gen/docs/render\"\n\t\"sigs.k8s.io/external-dns/pkg/metrics\"\n\n\t// these imports are necessary for the code generation process.\n\t_ \"sigs.k8s.io/external-dns/controller\"\n\t_ \"sigs.k8s.io/external-dns/provider\"\n\t_ \"sigs.k8s.io/external-dns/provider/webhook\"\n)\n\nvar (\n\t//go:embed \"templates/*\"\n\ttemplates embed.FS\n)\n\nfunc main() {\n\ttestPath, _ := os.Getwd()\n\tpath := fmt.Sprintf(\"%s/docs/monitoring/metrics.md\", testPath)\n\tfmt.Printf(\"generate file '%s' with configured metrics\\n\", path)\n\n\tcontent, err := generateMarkdownTable(metrics.RegisterMetric, true)\n\tif err != nil {\n\t\t_, _ = fmt.Fprintf(os.Stderr, \"failed to generate markdown file '%s': %v\\n\", path, err)\n\t\tos.Exit(1)\n\t}\n\tcontent += \"\\n\"\n\t_ = render.WriteToFile(path, content)\n}\n\nfunc generateMarkdownTable(m *metrics.MetricRegistry, withRuntime bool) (string, error) {\n\tsortMetrics(m.Metrics)\n\tvar runtimeMetrics []string\n\tif withRuntime {\n\t\truntimeMetrics = getRuntimeMetrics(prometheus.DefaultGatherer)\n\t\t// available when promhttp.Handler() is activated\n\t\truntimeMetrics = append(runtimeMetrics, []string{\n\t\t\t\"process_network_receive_bytes_total\",\n\t\t\t\"process_network_transmit_bytes_total\",\n\t\t}...)\n\t\tsort.Strings(runtimeMetrics)\n\t\truntimeMetrics = slices.Compact(runtimeMetrics)\n\t} else {\n\t\truntimeMetrics = []string{}\n\t}\n\n\treturn render.RenderTemplate(templates, \"metrics.gotpl\", templateData{\n\t\tMetrics:        m.Metrics,\n\t\tRuntimeMetrics: runtimeMetrics,\n\t\tColWidths:      computeColumnWidths(m.Metrics),\n\t\tRuntimeWidth:   render.ComputeColumnWidth(\"Name\", runtimeMetrics),\n\t})\n}\n\n// sortMetrics sorts the given slice of metrics by their subsystem and name.\n// Metrics are first sorted by their subsystem, and then by their name within each subsystem.\nfunc sortMetrics(metrics []*metrics.Metric) {\n\tsort.Slice(metrics, func(i, j int) bool {\n\t\tif metrics[i].Subsystem == metrics[j].Subsystem {\n\t\t\treturn metrics[i].Name < metrics[j].Name\n\t\t}\n\t\treturn metrics[i].Subsystem < metrics[j].Subsystem\n\t})\n}\n\n// getRuntimeMetrics retrieves the list of runtime metrics from the Prometheus registry.\nfunc getRuntimeMetrics(gatherer prometheus.Gatherer) []string {\n\tmfs, err := gatherer.Gather()\n\tif err != nil {\n\t\treturn nil\n\t}\n\n\truntimeMetrics := make([]string, 0, len(mfs))\n\tfor _, mf := range mfs {\n\t\tname := mf.GetName()\n\t\tif !strings.HasPrefix(name, \"external_dns\") {\n\t\t\truntimeMetrics = append(runtimeMetrics, name)\n\t\t}\n\t}\n\treturn runtimeMetrics\n}\n\ntype templateData struct {\n\tMetrics        []*metrics.Metric\n\tRuntimeMetrics []string\n\tColWidths      columnWidths\n\tRuntimeWidth   int\n}\n\ntype columnWidths struct {\n\tName      int\n\tType      int\n\tSubsystem int\n\tHelp      int\n}\n\nfunc computeColumnWidths(ms []*metrics.Metric) columnWidths {\n\treturn columnWidths{\n\t\tName:      render.MapColumn(\"Name\", ms, func(m *metrics.Metric) string { return m.Name }),\n\t\tType:      render.MapColumn(\"Metric Type\", ms, func(m *metrics.Metric) string { return m.Type }),\n\t\tSubsystem: render.MapColumn(\"Subsystem\", ms, func(m *metrics.Metric) string { return m.Subsystem }),\n\t\tHelp:      render.MapColumn(\"Help\", ms, func(m *metrics.Metric) string { return m.Help }),\n\t}\n}\n"
  },
  {
    "path": "internal/gen/docs/metrics/main_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage main\n\nimport (\n\t\"fmt\"\n\t\"io/fs\"\n\t\"os\"\n\t\"testing\"\n\n\t\"github.com/prometheus/client_golang/prometheus\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"sigs.k8s.io/external-dns/pkg/metrics\"\n)\n\nconst (\n\tpathToDocs        = \"%s/../../../../docs/monitoring\"\n\tknownMetricsCount = 22\n)\n\nfunc TestComputeMetrics(t *testing.T) {\n\treg := metrics.RegisterMetric\n\n\tif len(reg.Metrics) == 0 {\n\t\tt.Errorf(\"Expected not empty metrics registry, got %d\", len(reg.Metrics))\n\t}\n\n\tassert.Len(t, reg.Metrics, knownMetricsCount)\n}\n\nfunc TestGenerateMarkdownTableRenderer(t *testing.T) {\n\treg := metrics.NewMetricsRegister()\n\n\tgot, err := generateMarkdownTable(reg, false)\n\tassert.NoError(t, err)\n\n\tassert.Contains(t, got, \"# Available Metrics\\n\\n<!-- THIS FILE MUST NOT BE EDITED BY HAND -->\\n\")\n\tassert.Contains(t, got, \"| Name | Metric Type | Subsystem | Help |\")\n}\n\nfunc TestGenerateMarkdownTableWithSingleMetric(t *testing.T) {\n\treg := metrics.NewMetricsRegister()\n\n\treg.MustRegister(metrics.NewGaugeWithOpts(\n\t\tprometheus.GaugeOpts{\n\t\t\tNamespace: \"external_dns\",\n\t\t\tSubsystem: \"controller_0\",\n\t\t\tName:      \"verified_aaaa_records\",\n\t\t\tHelp:      \"This is just a test.\",\n\t\t},\n\t))\n\n\tgot, err := generateMarkdownTable(reg, false)\n\trequire.NoError(t, err)\n\n\tassert.Contains(t, got, \"verified_aaaa_records\")\n\tassert.Contains(t, got, \"This is just a test.\")\n}\n\nfunc TestMetricsMdUpToDate(t *testing.T) {\n\ttestPath, _ := os.Getwd()\n\tfsys := os.DirFS(fmt.Sprintf(pathToDocs, testPath))\n\tfileName := \"metrics.md\"\n\texpected, err := fs.ReadFile(fsys, fileName)\n\tassert.NoError(t, err, \"expected file %s to exist\", fileName)\n\n\treg := metrics.RegisterMetric\n\tactual, err := generateMarkdownTable(reg, false)\n\tassert.NoError(t, err)\n\tassert.Contains(t, string(expected), actual, \"expected file 'docs/monitoring/metrics.md' to be up to date. execute 'make generate-metrics-documentation\")\n}\n\nfunc TestMetricsMdExtraMetricAdded(t *testing.T) {\n\ttestPath, _ := os.Getwd()\n\tfsys := os.DirFS(fmt.Sprintf(pathToDocs, testPath))\n\tfileName := \"metrics.md\"\n\texpected, err := fs.ReadFile(fsys, fileName)\n\tassert.NoError(t, err, \"expected file %s to exist\", fileName)\n\n\t// Use a fresh registry to avoid mutating the global RegisterMetric.\n\treg := metrics.NewMetricsRegister()\n\tfor _, m := range metrics.RegisterMetric.Metrics {\n\t\treg.Metrics = append(reg.Metrics, m)\n\t}\n\treg.MustRegister(metrics.NewGaugeWithOpts(\n\t\tprometheus.GaugeOpts{\n\t\t\tNamespace: \"external_dns\",\n\t\t\tSubsystem: \"controller_1\",\n\t\t\tName:      \"verified_aaaa_records\",\n\t\t\tHelp:      \"This is just a test.\",\n\t\t},\n\t))\n\n\tactual, err := generateMarkdownTable(reg, false)\n\tassert.NoError(t, err)\n\tassert.NotEqual(t, string(expected), actual)\n}\n\nfunc TestGetRuntimeMetricsForNewRegistry(t *testing.T) {\n\treg := prometheus.NewRegistry()\n\t// Register some runtime metrics\n\treg.MustRegister(prometheus.NewGauge(prometheus.GaugeOpts{\n\t\tName: \"go_goroutines\",\n\t\tHelp: \"Number of goroutines that currently exist.\",\n\t}))\n\treg.MustRegister(prometheus.NewGauge(prometheus.GaugeOpts{\n\t\tName: \"go_memstats_alloc_bytes\",\n\t\tHelp: \"Number of bytes allocated and still in use.\",\n\t}))\n\truntimeMetrics := getRuntimeMetrics(reg)\n\n\t// Check that the runtime metrics are correctly retrieved\n\texpectedMetrics := []string{\"go_goroutines\", \"go_memstats_alloc_bytes\"}\n\tassert.ElementsMatch(t, expectedMetrics, runtimeMetrics)\n\tassert.Len(t, runtimeMetrics, 2)\n}\n\nfunc TestGetRuntimeMetricsForDefaultRegistry(t *testing.T) {\n\truntimeMetrics := getRuntimeMetrics(prometheus.DefaultGatherer)\n\tif len(runtimeMetrics) == 0 {\n\t\tt.Errorf(\"Expected not empty runtime metrics, got %d\", len(runtimeMetrics))\n\t}\n}\n"
  },
  {
    "path": "internal/gen/docs/metrics/templates/metrics.gotpl",
    "content": "---\ntags:\n  - metrics\n  - autogenerated\n---\n\n# Available Metrics\n\n<!-- THIS FILE MUST NOT BE EDITED BY HAND -->\n<!-- ON NEW METRIC ADDED PLEASE RUN 'make generate-metrics-documentation' -->\n<!-- markdownlint-disable MD013 -->\n\nAll metrics available for scraping are exposed on the {{backtick 1}}/metrics{{backtick 1}} endpoint.\nThe metrics are in the Prometheus exposition format.\n\nTo access the metrics:\n\n{{backtick 3}}sh\ncurl https://localhost:7979/metrics\n{{backtick 3}}\n\n## Supported Metrics\n\n> Full metric name is constructed as follows:\n> {{backtick 1}}external_dns_<subsystem>_<name>{{backtick 1}}\n\n| {{ padRight .ColWidths.Name \"Name\" }} | {{ padRight .ColWidths.Type \"Metric Type\" }} | {{ padRight .ColWidths.Subsystem \"Subsystem\" }} | {{ padRight .ColWidths.Help \"Help\" }} |\n|:{{ leftSep .ColWidths.Name }}|:{{ leftSep .ColWidths.Type }}|:{{ leftSep .ColWidths.Subsystem }}|:{{ leftSep .ColWidths.Help }}|\n{{- range .Metrics }}\n| {{ padRight $.ColWidths.Name .Name }} | {{ padRight $.ColWidths.Type (.Type | capitalize) }} | {{ padRight $.ColWidths.Subsystem .Subsystem }} | {{ padRight $.ColWidths.Help .Help }} |\n{{- end }}\n\n## Available Go Runtime Metrics\n\n> The following Go runtime metrics are available for scraping. Please note that they may change over time and they are OS dependent.\n\n{{ if .RuntimeMetrics -}}\n| {{ padRight .RuntimeWidth \"Name\" }} |\n|:{{ leftSep .RuntimeWidth }}|\n{{- range .RuntimeMetrics }}\n| {{ padRight $.RuntimeWidth . }} |\n{{- end -}}\n{{- end -}}\n"
  },
  {
    "path": "internal/gen/docs/render/render.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage render\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"os\"\n\t\"strings\"\n\t\"text/template\"\n\n\t\"golang.org/x/text/cases\"\n\t\"golang.org/x/text/language\"\n)\n\n// WriteToFile writes the given content to a file, creating or truncating it.\nfunc WriteToFile(filename string, content string) error {\n\treturn os.WriteFile(filename, []byte(content), 0644)\n}\n\n// RenderTemplate parses and executes a named Go template from the given filesystem.\nfunc RenderTemplate(fsys fs.FS, name string, data any) (string, error) {\n\ttmpl := template.New(\"\").Funcs(FuncMap())\n\ttemplate.Must(tmpl.ParseFS(fsys, \"templates/*.gotpl\"))\n\n\tvar b bytes.Buffer\n\tif err := tmpl.ExecuteTemplate(&b, name, data); err != nil {\n\t\treturn \"\", err\n\t}\n\treturn b.String(), nil\n}\n\n// FuncMap returns a mapping of all of the functions that Engine has.\nfunc FuncMap() template.FuncMap {\n\treturn template.FuncMap{\n\t\t\"backtick\": func(times int) string {\n\t\t\treturn strings.Repeat(\"`\", times)\n\t\t},\n\t\t\"capitalize\": cases.Title(language.English, cases.Compact).String,\n\t\t\"replace\":    strings.ReplaceAll,\n\t\t\"lower\":      strings.ToLower,\n\t\t\"bold\": func(s string) string {\n\t\t\treturn \"**\" + s + \"**\"\n\t\t},\n\t\t// padRight pads s with spaces on the right to the given width.\n\t\t\"padRight\": func(width int, s string) string {\n\t\t\treturn fmt.Sprintf(\"%-*s\", width, s)\n\t\t},\n\t\t// leftSep generates a left-aligned markdown table separator of the given column width.\n\t\t\"leftSep\": func(width int) string {\n\t\t\treturn strings.Repeat(\"-\", width+1)\n\t\t},\n\t}\n}\n\n// ComputeColumnWidth returns the maximum string length among the header and all values.\nfunc ComputeColumnWidth(header string, values []string) int {\n\treturn MapColumn(header, values, func(s string) string { return s })\n}\n\n// MapColumn returns the max width among the header and fn applied to each item.\nfunc MapColumn[T any](header string, items []T, fn func(T) string) int {\n\tw := len(header)\n\tfor _, item := range items {\n\t\tw = max(w, len(fn(item)))\n\t}\n\treturn w\n}\n"
  },
  {
    "path": "internal/gen/docs/render/render_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage render\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\t\"testing\"\n\t\"testing/fstest\"\n\t\"text/template\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestWriteToFile(t *testing.T) {\n\tfilename := fmt.Sprintf(\"%s/testfile\", t.TempDir())\n\tcontent := \"Hello, World!\"\n\n\tdefer os.Remove(filename)\n\n\terr := WriteToFile(filename, content)\n\tif err != nil {\n\t\tt.Fatalf(\"expected no error, got %v\", err)\n\t}\n\n\tdata, err := os.ReadFile(filename)\n\tif err != nil {\n\t\tt.Fatalf(\"expected no error reading file, got %v\", err)\n\t}\n\n\tif string(data) != content {\n\t\tt.Errorf(\"expected content %q, got %q\", content, string(data))\n\t}\n}\n\nfunc TestComputeColumnWidth(t *testing.T) {\n\ttests := []struct {\n\t\tname   string\n\t\theader string\n\t\tvalues []string\n\t\twant   int\n\t}{\n\t\t{\n\t\t\tname:   \"header wins when all values are shorter\",\n\t\t\theader: \"Metric Type\",\n\t\t\tvalues: []string{\"gauge\", \"counter\"},\n\t\t\twant:   len(\"Metric Type\"),\n\t\t},\n\t\t{\n\t\t\tname:   \"value wins when longer than header\",\n\t\t\theader: \"Name\",\n\t\t\tvalues: []string{\"last_reconcile_timestamp_seconds\"},\n\t\t\twant:   len(\"last_reconcile_timestamp_seconds\"),\n\t\t},\n\t\t{\n\t\t\tname:   \"empty values returns header length\",\n\t\t\theader: \"Subsystem\",\n\t\t\tvalues: []string{},\n\t\t\twant:   len(\"Subsystem\"),\n\t\t},\n\t\t{\n\t\t\tname:   \"empty header and empty values returns zero\",\n\t\t\theader: \"\",\n\t\t\tvalues: []string{},\n\t\t\twant:   0,\n\t\t},\n\t\t{\n\t\t\tname:   \"empty header defers to longest value\",\n\t\t\theader: \"\",\n\t\t\tvalues: []string{\"short\", \"much longer value\"},\n\t\t\twant:   len(\"much longer value\"),\n\t\t},\n\t\t{\n\t\t\tname:   \"empty string values do not shrink below header\",\n\t\t\theader: \"Help\",\n\t\t\tvalues: []string{\"\", \"\"},\n\t\t\twant:   len(\"Help\"),\n\t\t},\n\t\t{\n\t\t\tname:   \"tie between header and value returns that length\",\n\t\t\theader: \"exact\",\n\t\t\tvalues: []string{\"exact\"},\n\t\t\twant:   len(\"exact\"),\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := ComputeColumnWidth(tt.header, tt.values)\n\t\t\tassert.Equal(t, tt.want, got)\n\t\t})\n\t}\n}\n\nfunc TestMapColumn(t *testing.T) {\n\ttype item struct{ val string }\n\tfn := func(i item) string { return i.val }\n\n\ttests := []struct {\n\t\tname   string\n\t\theader string\n\t\titems  []item\n\t\twant   int\n\t}{\n\t\t{\n\t\t\tname:   \"header wins when all values are shorter\",\n\t\t\theader: \"Metric Type\",\n\t\t\titems:  []item{{\"gauge\"}, {\"counter\"}},\n\t\t\twant:   len(\"Metric Type\"),\n\t\t},\n\t\t{\n\t\t\tname:   \"value wins when longer than header\",\n\t\t\theader: \"Name\",\n\t\t\titems:  []item{{\"last_reconcile_timestamp_seconds\"}},\n\t\t\twant:   len(\"last_reconcile_timestamp_seconds\"),\n\t\t},\n\t\t{\n\t\t\tname:   \"empty items returns header length\",\n\t\t\theader: \"Subsystem\",\n\t\t\titems:  []item{},\n\t\t\twant:   len(\"Subsystem\"),\n\t\t},\n\t\t{\n\t\t\tname:   \"empty header and empty items returns zero\",\n\t\t\theader: \"\",\n\t\t\titems:  []item{},\n\t\t\twant:   0,\n\t\t},\n\t\t{\n\t\t\tname:   \"empty header defers to longest value\",\n\t\t\theader: \"\",\n\t\t\titems:  []item{{\"short\"}, {\"much longer value\"}},\n\t\t\twant:   len(\"much longer value\"),\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := MapColumn(tt.header, tt.items, fn)\n\t\t\tassert.Equal(t, tt.want, got)\n\t\t})\n\t}\n}\n\nfunc TestFuncs(t *testing.T) {\n\ttests := []struct {\n\t\ttpl, expect string\n\t\tvars        any\n\t}{\n\t\t{\n\t\t\ttpl:    `{{ backtick 3 }}`,\n\t\t\texpect: \"```\",\n\t\t\tvars:   map[string]any{},\n\t\t},\n\t\t{\n\t\t\ttpl:    `{{ capitalize .name }}`,\n\t\t\texpect: \"Capital\",\n\t\t\tvars:   map[string]any{\"name\": \"capital\"},\n\t\t},\n\t\t{\n\t\t\ttpl:    `{{ replace .resources \",\" \"<br/>\" }}`,\n\t\t\texpect: \"one<br/>two<br/>tree\",\n\t\t\tvars:   map[string]any{\"resources\": \"one,two,tree\"},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tvar b strings.Builder\n\t\terr := template.Must(template.New(\"test\").Funcs(FuncMap()).Parse(tt.tpl)).Execute(&b, tt.vars)\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, tt.expect, b.String(), tt.tpl)\n\t}\n}\n\nfunc TestRenderTemplate(t *testing.T) {\n\tfsys := fstest.MapFS{\n\t\t\"templates/test.gotpl\": &fstest.MapFile{\n\t\t\tData: []byte(\"Hello {{ .Name }}!\"),\n\t\t},\n\t}\n\n\tresult, err := RenderTemplate(fsys, \"test.gotpl\", struct{ Name string }{Name: \"World\"})\n\trequire.NoError(t, err)\n\tassert.Equal(t, \"Hello World!\", result)\n}\n\nfunc TestRenderTemplateWithFuncMap(t *testing.T) {\n\tfsys := fstest.MapFS{\n\t\t\"templates/test.gotpl\": &fstest.MapFile{\n\t\t\tData: []byte(\"{{ backtick 3 }}go\\nfmt.Println({{ capitalize .Lang }})\\n{{ backtick 3 }}\"),\n\t\t},\n\t}\n\n\tresult, err := RenderTemplate(fsys, \"test.gotpl\", struct{ Lang string }{Lang: \"go\"})\n\trequire.NoError(t, err)\n\tassert.Contains(t, result, \"```go\")\n\tassert.Contains(t, result, \"Go\")\n}\n"
  },
  {
    "path": "internal/gen/docs/sources/main.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage main\n\nimport (\n\t\"embed\"\n\t\"fmt\"\n\t\"go/ast\"\n\t\"go/parser\"\n\t\"go/token\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"slices\"\n\t\"strings\"\n\n\t\"sigs.k8s.io/external-dns/internal/gen/docs/render\"\n)\n\nconst (\n\tannotationPrefix           = \"+externaldns:source:\"\n\tannotationName             = annotationPrefix + \"name=\"\n\tannotationCategory         = annotationPrefix + \"category=\"\n\tannotationDesc             = annotationPrefix + \"description=\"\n\tannotationResources        = annotationPrefix + \"resources=\"\n\tannotationFilters          = annotationPrefix + \"filters=\"\n\tannotationNamespace        = annotationPrefix + \"namespace=\"\n\tannotationFQDNTemplate     = annotationPrefix + \"fqdn-template=\"\n\tannotationEvents           = annotationPrefix + \"events=\"\n\tannotationProviderSpecific = annotationPrefix + \"provider-specific=\"\n)\n\nvar (\n\t//go:embed \"templates/*\"\n\ttemplates embed.FS\n\t// Regex to match source type names (must end with \"Source\")\n\tsourceTypeRegex = regexp.MustCompile(`^(\\w+)Source$`)\n)\n\n// Source represents metadata about a source implementation\ntype Source struct {\n\tName             string // e.g., \"service\", \"ingress\", \"crd\"\n\tType             string // e.g., \"serviceSource\"\n\tFile             string // e.g., \"source/service.go\"\n\tDescription      string // Description of what this source does\n\tCategory         string // e.g., \"Kubernetes\", \"Gateway\", \"Service Mesh\", \"Wrapper\"\n\tResources        string // Kubernetes resources watched, e.g., \"Service\", \"Ingress\"\n\tFilters          string // Supported filters, e.g., \"annotation,label\"\n\tNamespace        string // Namespace support: \"all\", \"single\", \"multiple\"\n\tFQDNTemplate     string // FQDN template support: \"true\", \"false\"\n\tEvents           string // Events support: \"true\", \"false\"\n\tProviderSpecific string // Provider-specific properties support: \"true\", \"false\"\n}\n\ntype Sources []Source\n\n// main generates a markdown file with the supported sources\n// and writes it to the 'docs/sources/index.md' file.\n// To re-generate, execute 'go run internal/gen/docs/sources/main.go'.\nfunc main() {\n\tcPath, _ := os.Getwd()\n\tpath := fmt.Sprintf(\"%s/docs/sources/index.md\", cPath)\n\tfmt.Printf(\"generate file '%s' with supported sources\\n\", path)\n\n\tsources, err := discoverSources(fmt.Sprintf(\"%s/source\", cPath))\n\tif err != nil {\n\t\t_, _ = fmt.Fprintf(os.Stderr, \"failed to discover sources: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\tcontent, err := sources.generateMarkdown()\n\tif err != nil {\n\t\t_, _ = fmt.Fprintf(os.Stderr, \"failed to generate markdown file '%s': %v\\n\", path, err)\n\t\tos.Exit(1)\n\t}\n\t_ = render.WriteToFile(path, content)\n}\n\n// discoverSources scans the source directory and discovers all source implementations\n// by parsing Go files and extracting +externaldns:source annotations\nfunc discoverSources(dir string) (Sources, error) {\n\t// Parse all source files for annotations\n\tsources, err := parseSourceAnnotations(dir)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Sort sources by name\n\tslices.SortFunc(sources, func(a, b Source) int {\n\t\treturn strings.Compare(a.Name, b.Name)\n\t})\n\n\treturn sources, nil\n}\n\ntype columnWidths struct {\n\tName             int\n\tResources        int\n\tFilters          int\n\tNamespace        int\n\tFQDNTemplate     int\n\tEvents           int\n\tProviderSpecific int\n\tCategory         int\n}\n\nfunc computeColumnWidths(sources Sources) columnWidths {\n\treturn columnWidths{\n\t\tName:             render.MapColumn(\"**Source Name**\", sources, func(s Source) string { return \"**\" + s.Name + \"**\" }),\n\t\tResources:        render.MapColumn(\"Resources\", sources, func(s Source) string { return strings.ReplaceAll(s.Resources, \",\", \"<br/>\") }),\n\t\tFilters:          render.MapColumn(\"Filters\", sources, func(s Source) string { return s.Filters }),\n\t\tNamespace:        render.MapColumn(\"Namespace\", sources, func(s Source) string { return s.Namespace }),\n\t\tFQDNTemplate:     render.MapColumn(\"FQDN Template\", sources, func(s Source) string { return s.FQDNTemplate }),\n\t\tEvents:           render.MapColumn(\"Events\", sources, func(s Source) string { return s.Events }),\n\t\tProviderSpecific: render.MapColumn(\"Provider Specific\", sources, func(s Source) string { return s.ProviderSpecific }),\n\t\tCategory:         render.MapColumn(\"Category\", sources, func(s Source) string { return strings.ToLower(s.Category) }),\n\t}\n}\n\ntype templateData struct {\n\tSources   Sources\n\tColWidths columnWidths\n}\n\nfunc (s *Sources) generateMarkdown() (string, error) {\n\treturn render.RenderTemplate(templates, \"sources.gotpl\", templateData{\n\t\tSources:   *s,\n\t\tColWidths: computeColumnWidths(*s),\n\t})\n}\n\n// parseSourceAnnotations parses all Go files in the source directory\n// and extracts source metadata from +externaldns:source annotations\nfunc parseSourceAnnotations(sourceDir string) (Sources, error) {\n\tvar sources Sources\n\n\t// Walk through the source directory\n\terr := filepath.WalkDir(sourceDir, func(path string, d os.DirEntry, err error) error {\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Skip directories and non-Go files\n\t\tif d.IsDir() || !strings.HasSuffix(path, \".go\") {\n\t\t\treturn nil\n\t\t}\n\n\t\t// Skip test files\n\t\tif strings.HasSuffix(path, \"_test.go\") {\n\t\t\treturn nil\n\t\t}\n\n\t\t// Parse the Go file\n\t\tfileSources, err := parseFile(path, sourceDir)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to parse %s: %w\", path, err)\n\t\t}\n\n\t\tsources = append(sources, fileSources...)\n\t\treturn nil\n\t})\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn sources, nil\n}\n\n// parseFile parses a single Go file and extracts source annotations\nfunc parseFile(filePath, baseDir string) (Sources, error) {\n\tvar sources Sources\n\n\tfset := token.NewFileSet()\n\tnode, err := parser.ParseFile(fset, filePath, nil, parser.ParseComments)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Get relative path for the File field\n\trelPath, err := filepath.Rel(baseDir, filePath)\n\tif err != nil {\n\t\trelPath = filePath\n\t}\n\t// Normalize to use forward slashes\n\trelPath = filepath.ToSlash(relPath)\n\n\t// Create a map of all comments by their starting position\n\tcmap := ast.NewCommentMap(fset, node, node.Comments)\n\n\tvar errFound error\n\t// Inspect the AST for type declarations\n\tast.Inspect(node, func(n ast.Node) bool {\n\t\t// Look for type declarations that are GenDecl (general declarations)\n\t\tgenDecl, ok := n.(*ast.GenDecl)\n\t\tif !ok {\n\t\t\treturn true\n\t\t}\n\n\t\t// Get comments associated with this declaration\n\t\tcomments := cmap[genDecl]\n\t\tif len(comments) == 0 {\n\t\t\treturn true\n\t\t}\n\n\t\t// Check each spec in the declaration\n\t\tfor _, spec := range genDecl.Specs {\n\t\t\ttypeSpec, ok := spec.(*ast.TypeSpec)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// Check if it's a struct type\n\t\t\t_, ok = typeSpec.Type.(*ast.StructType)\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// Check if the type name matches *Source pattern\n\t\t\ttypeName := typeSpec.Name.Name\n\t\t\tif !sourceTypeRegex.MatchString(typeName) {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// Combine all comment text\n\t\t\tvar commentText strings.Builder\n\t\t\tfor _, cg := range comments {\n\t\t\t\tcommentText.WriteString(cg.Text())\n\t\t\t}\n\n\t\t\tif commentText.Len() == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\textractedSources, err := extractSourcesFromComments(commentText.String(), typeName, relPath)\n\t\t\tif err != nil {\n\t\t\t\terrFound = err\n\t\t\t\treturn false\n\t\t\t}\n\t\t\tsources = append(sources, extractedSources...)\n\t\t}\n\n\t\treturn true\n\t})\n\n\treturn sources, errFound\n}\n\n// extractSourcesFromComments extracts source metadata from comment text.\n// It can extract multiple sources from the same comment block (e.g., for gateway routes).\nfunc extractSourcesFromComments(comments, typeName, filePath string) (Sources, error) {\n\tvar sources Sources\n\tvar currentSource *Source\n\n\tfor line := range strings.SplitSeq(comments, \"\\n\") {\n\t\tline = strings.TrimSpace(line)\n\t\tif !strings.HasPrefix(line, annotationPrefix) {\n\t\t\tcontinue\n\t\t}\n\t\t// When we see a name annotation, start a new source\n\t\tswitch {\n\t\tcase strings.HasPrefix(line, annotationName):\n\t\t\t// Save previous source if it exists\n\t\t\tif currentSource != nil && currentSource.Name != \"\" {\n\t\t\t\tsources = append(sources, *currentSource)\n\t\t\t}\n\n\t\t\t// Start new source\n\t\t\tcurrentSource = &Source{\n\t\t\t\tType:             typeName,\n\t\t\t\tFile:             filePath,\n\t\t\t\tName:             strings.TrimPrefix(line, annotationName),\n\t\t\t\tEvents:           \"false\",\n\t\t\t\tProviderSpecific: \"false\",\n\t\t\t}\n\t\tcase currentSource == nil:\n\t\t\treturn nil, fmt.Errorf(\"found annotation line without preceding source name in type %s: %s\", typeName, line)\n\t\tcase strings.HasPrefix(line, annotationCategory):\n\t\t\tcurrentSource.Category = strings.TrimPrefix(line, annotationCategory)\n\t\tcase strings.HasPrefix(line, annotationDesc):\n\t\t\tcurrentSource.Description = strings.TrimPrefix(line, annotationDesc)\n\t\tcase strings.HasPrefix(line, annotationResources):\n\t\t\tcurrentSource.Resources = strings.TrimPrefix(line, annotationResources)\n\t\tcase strings.HasPrefix(line, annotationFilters):\n\t\t\tcurrentSource.Filters = strings.TrimPrefix(line, annotationFilters)\n\t\tcase strings.HasPrefix(line, annotationNamespace):\n\t\t\tcurrentSource.Namespace = strings.TrimPrefix(line, annotationNamespace)\n\t\tcase strings.HasPrefix(line, annotationFQDNTemplate):\n\t\t\tcurrentSource.FQDNTemplate = strings.TrimPrefix(line, annotationFQDNTemplate)\n\t\tcase strings.HasPrefix(line, annotationEvents):\n\t\t\tcurrentSource.Events = strings.TrimPrefix(line, annotationEvents)\n\t\tcase strings.HasPrefix(line, annotationProviderSpecific):\n\t\t\tcurrentSource.ProviderSpecific = strings.TrimPrefix(line, annotationProviderSpecific)\n\t\t}\n\t}\n\n\t// Don't forget the last source\n\tif currentSource != nil && currentSource.Name != \"\" {\n\t\tsources = append(sources, *currentSource)\n\t}\n\n\treturn sources, nil\n}\n"
  },
  {
    "path": "internal/gen/docs/sources/main_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage main\n\nimport (\n\t\"fmt\"\n\t\"io/fs\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nconst (\n\tpathToDocs = \"%s/../../../../docs/sources\"\n\tfileName   = \"index.md\"\n)\n\nfunc TestIndexMdExists(t *testing.T) {\n\ttestPath, _ := os.Getwd()\n\tfsys := os.DirFS(fmt.Sprintf(pathToDocs, testPath))\n\tst, err := fs.Stat(fsys, fileName)\n\tassert.NoError(t, err, \"expected file %s to exist\", fileName)\n\tassert.Equal(t, fileName, st.Name())\n}\n\nfunc TestIndexMdUpToDate(t *testing.T) {\n\ttestPath, _ := os.Getwd()\n\tfsys := os.DirFS(fmt.Sprintf(pathToDocs, testPath))\n\texpected, err := fs.ReadFile(fsys, fileName)\n\tassert.NoError(t, err, \"expected file %s to exist\", fileName)\n\n\tsourceDir := fmt.Sprintf(\"%s/../../../../source\", testPath)\n\tsources, err := discoverSources(sourceDir)\n\trequire.NoError(t, err, \"expected to find sources\")\n\tactual, err := sources.generateMarkdown()\n\tassert.NoError(t, err)\n\tassert.Contains(t, string(expected), actual, \"expected file 'docs/sources/index.md' to be up to date. execute 'make generate-sources-documentation'\")\n}\n\nfunc TestDiscoverSources(t *testing.T) {\n\ttestPath, _ := os.Getwd()\n\tsourceDir := fmt.Sprintf(\"%s/../../../../source\", testPath)\n\n\tsources, err := discoverSources(sourceDir)\n\trequire.NoError(t, err)\n\n\tassert.GreaterOrEqual(t, len(sources), 5, \"Expected at least 5 sources with annotations\")\n\n\t// Verify sources are sorted by name\n\tfor i := range len(sources) - 1 {\n\t\tprev, curr := sources[i], sources[i+1]\n\t\tif prev.Name > curr.Name {\n\t\t\tt.Errorf(\"Sources not sorted correctly: %s should come before %s\", curr.Name, prev.Name)\n\t\t}\n\t}\n}\n\nfunc TestGenerateMarkdown(t *testing.T) {\n\tsources := Sources{\n\t\t{\n\t\t\tName:         \"test\",\n\t\t\tType:         \"testSource\",\n\t\t\tFile:         \"source/test.go\",\n\t\t\tCategory:     \"Test\",\n\t\t\tDescription:  \"Test source\",\n\t\t\tResources:    \"TestResource\",\n\t\t\tFilters:      \"annotation,label\",\n\t\t\tNamespace:    \"all,single\",\n\t\t\tFQDNTemplate: \"true\",\n\t\t},\n\t}\n\n\tcontent, err := sources.generateMarkdown()\n\trequire.NoError(t, err)\n\tassert.NotEmpty(t, content)\n\n\tassert.Contains(t, content, \"# Supported Sources\")\n\tassert.Contains(t, content, \"## Available Sources\")\n}\n\nfunc TestParseSourceAnnotations(t *testing.T) {\n\ttmpDir := t.TempDir()\n\ttestFile := filepath.Join(tmpDir, \"test_source.go\")\n\tcontent := `package main\n\n// testSource is a test source implementation.\n//\n// +externaldns:source:name=test-source\n// +externaldns:source:category=Testing\n// +externaldns:source:description=A test source for unit testing\n// +externaldns:source:resources=TestResource\n// +externaldns:source:filters=annotation,label\n// +externaldns:source:namespace=all,single\n// +externaldns:source:fqdn-template=true\n// +externaldns:source:provider-specific=true\ntype testSource struct {\n\tclient string\n}\n`\n\terr := os.WriteFile(testFile, []byte(content), 0644)\n\trequire.NoError(t, err)\n\n\tsources, err := parseSourceAnnotations(tmpDir)\n\trequire.NoError(t, err)\n\tassert.Len(t, sources, 1)\n\n\tsource := sources[0]\n\tassert.Equal(t, \"test-source\", source.Name)\n\tassert.Equal(t, \"Testing\", source.Category)\n\tassert.Equal(t, \"TestResource\", source.Resources)\n\tassert.Equal(t, \"annotation,label\", source.Filters)\n\tassert.Equal(t, \"all,single\", source.Namespace)\n\tassert.Equal(t, \"true\", source.FQDNTemplate)\n\tassert.Equal(t, \"false\", source.Events)\n\tassert.Equal(t, \"true\", source.ProviderSpecific)\n}\n\nfunc TestParseSourceAnnotations_SkipsTestFiles(t *testing.T) {\n\ttmpDir := t.TempDir()\n\n\t// Create a test file that should be skipped\n\ttestFile := filepath.Join(tmpDir, \"test_source_test.go\")\n\tcontent := `package main\n\n// +externaldns:source:name=should-be-skipped\n// +externaldns:source:category=Test\n// +externaldns:source:description=Should be skipped\ntype testSource struct {}\n`\n\terr := os.WriteFile(testFile, []byte(content), 0644)\n\trequire.NoError(t, err)\n\n\tsources, err := parseSourceAnnotations(tmpDir)\n\trequire.NoError(t, err)\n\tassert.Empty(t, sources)\n}\n\nfunc TestParseFile_MultipleSourcesInOneFile(t *testing.T) {\n\ttmpDir := t.TempDir()\n\n\ttestFile := filepath.Join(tmpDir, \"multi.go\")\n\tcontent := `package main\n\n// firstSource is the first source.\n//\n// +externaldns:source:name=first\n// +externaldns:source:category=Testing\n// +externaldns:source:description=First source\ntype firstSource struct {}\n\n// secondSource is the second source.\n//\n// +externaldns:source:name=second\n// +externaldns:source:category=Testing\n// +externaldns:source:description=Second source\n// +externaldns:source:events=true\ntype secondSource struct {}\n`\n\terr := os.WriteFile(testFile, []byte(content), 0644)\n\trequire.NoError(t, err)\n\n\tsources, err := parseFile(testFile, tmpDir)\n\trequire.NoError(t, err)\n\tassert.Len(t, sources, 2)\n\tassert.Equal(t, \"first\", sources[0].Name)\n\tassert.Equal(t, \"false\", sources[0].Events)\n\tassert.Equal(t, \"false\", sources[0].ProviderSpecific)\n\tassert.Equal(t, \"second\", sources[1].Name)\n\tassert.Equal(t, \"true\", sources[1].Events)\n\tassert.Equal(t, \"false\", sources[1].ProviderSpecific)\n}\n\nfunc TestParseFile_IgnoresNonSourceTypes(t *testing.T) {\n\ttmpDir := t.TempDir()\n\n\ttestFile := filepath.Join(tmpDir, \"nonsource.go\")\n\tcontent := `package main\n\n// regularStruct is not a source (doesn't end with \"Source\").\n//\n// +externaldns:source:name=should-not-parse\n// +externaldns:source:category=Test\n// +externaldns:source:description=Should not be parsed\ntype regularStruct struct {}\n`\n\terr := os.WriteFile(testFile, []byte(content), 0644)\n\trequire.NoError(t, err)\n\n\tsources, err := parseFile(testFile, tmpDir)\n\trequire.NoError(t, err)\n\tassert.Empty(t, sources)\n}\n\nfunc TestParseSourceAnnotations_ErrorOnInvalidFile(t *testing.T) {\n\ttmpDir := t.TempDir()\n\n\t// Create a file with invalid Go syntax\n\ttestFile := filepath.Join(tmpDir, \"invalid.go\")\n\tcontent := `package main\n\nthis is not valid go syntax\n`\n\terr := os.WriteFile(testFile, []byte(content), 0644)\n\trequire.NoError(t, err)\n\n\t_, err = parseSourceAnnotations(tmpDir)\n\trequire.Error(t, err)\n}\n\nfunc TestParseFile_InvalidGoFile(t *testing.T) {\n\ttmpDir := t.TempDir()\n\n\ttestFile := filepath.Join(tmpDir, \"invalid.go\")\n\tcontent := `this is not valid go code`\n\n\terr := os.WriteFile(testFile, []byte(content), 0644)\n\trequire.NoError(t, err)\n\n\t_, err = parseFile(testFile, tmpDir)\n\trequire.Error(t, err)\n}\n\nfunc TestParseSourceAnnotations_WithSubdirectories(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tsubDir := filepath.Join(tmpDir, \"subdir\")\n\tif err := os.Mkdir(subDir, 0755); err != nil {\n\t\tt.Fatalf(\"Failed to create subdirectory: %v\", err)\n\t}\n\n\t// Create a test file in subdirectory\n\ttestFile := filepath.Join(subDir, \"nested_source.go\")\n\tcontent := `package main\n\n// nestedSource is in a subdirectory.\n//\n// +externaldns:source:name=nested\n// +externaldns:source:category=Testing\n// +externaldns:source:description=Nested source\ntype nestedSource struct {}\n`\n\terr := os.WriteFile(testFile, []byte(content), 0644)\n\trequire.NoError(t, err)\n\n\tsources, err := parseSourceAnnotations(tmpDir)\n\trequire.NoError(t, err)\n\tassert.Len(t, sources, 1)\n\tassert.Equal(t, \"nested\", sources[0].Name)\n\tassert.Contains(t, sources[0].File, \"subdir/nested_source.go\")\n}\n\nfunc TestGenerateMarkdown_WithMultipleCategories(t *testing.T) {\n\tsources := Sources{\n\t\t{\n\t\t\tName:         \"service\",\n\t\t\tCategory:     \"Kubernetes Core\",\n\t\t\tDescription:  \"Service source\",\n\t\t\tResources:    \"Service\",\n\t\t\tFilters:      \"annotation,label\",\n\t\t\tNamespace:    \"all,single\",\n\t\t\tFQDNTemplate: \"true\",\n\t\t},\n\t\t{\n\t\t\tName:         \"ingress\",\n\t\t\tCategory:     \"Kubernetes Core\",\n\t\t\tDescription:  \"Ingress source\",\n\t\t\tResources:    \"Ingress\",\n\t\t\tFilters:      \"annotation,label\",\n\t\t\tNamespace:    \"all,single\",\n\t\t\tFQDNTemplate: \"true\",\n\t\t},\n\t\t{\n\t\t\tName:         \"gateway-httproute\",\n\t\t\tCategory:     \"Gateway API\",\n\t\t\tDescription:  \"HTTP route source\",\n\t\t\tResources:    \"HTTPRoute.gateway.networking.k8s.io\",\n\t\t\tFilters:      \"annotation,label\",\n\t\t\tNamespace:    \"all,single\",\n\t\t\tFQDNTemplate: \"false\",\n\t\t},\n\t}\n\n\tcontent, err := sources.generateMarkdown()\n\trequire.NoError(t, err)\n\tassert.Contains(t, content, \"service\")\n\tassert.Contains(t, content, \"ingress\")\n\tassert.Contains(t, content, \"gateway-httproute\")\n}\n\nfunc TestExtractSourcesFromComments(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tcomments    string\n\t\ttypeName    string\n\t\tfilePath    string\n\t\twantSources int\n\t\twantErr     bool\n\t\tvalidate    func(*testing.T, Source)\n\t}{\n\t\t{\n\t\t\tname: \"valid single source\",\n\t\t\tcomments: `testSource is a test implementation.\n\n+externaldns:source:name=test\n+externaldns:source:category=Testing\n+externaldns:source:description=A test source\n+externaldns:source:resources=TestResource\n+externaldns:source:filters=annotation\n+externaldns:source:namespace=all\n+externaldns:source:fqdn-template=false\n`,\n\t\t\ttypeName:    \"testSource\",\n\t\t\tfilePath:    \"test.go\",\n\t\t\twantSources: 1,\n\t\t\tvalidate: func(t *testing.T, s Source) {\n\t\t\t\tassert.Equal(t, \"test\", s.Name)\n\t\t\t\tassert.Equal(t, \"Testing\", s.Category)\n\t\t\t\tassert.Equal(t, \"A test source\", s.Description)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"multiple sources in same comment block\",\n\t\t\tcomments: `gatewaySource handles multiple gateway types.\n\n+externaldns:source:name=http-route\n+externaldns:source:category=Gateway\n+externaldns:source:description=Handles HTTP routes\n+externaldns:source:resources=HTTPRoute\n\n+externaldns:source:name=tcp-route\n+externaldns:source:category=Gateway\n+externaldns:source:description=Handles TCP routes\n+externaldns:source:resources=TCPRoute\n`,\n\t\t\ttypeName:    \"gatewaySource\",\n\t\t\tfilePath:    \"gateway.go\",\n\t\t\twantSources: 2,\n\t\t\tvalidate: func(t *testing.T, s Source) {\n\t\t\t\tassert.Contains(t, []string{\"http-route\", \"tcp-route\"}, s.Name)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"missing required name annotation\",\n\t\t\tcomments: `testSource without name.\n\n+externaldns:source:category=Testing\n+externaldns:source:description=Missing name\n`,\n\t\t\ttypeName: \"testSource\",\n\t\t\tfilePath: \"test.go\",\n\t\t\twantErr:  true,\n\t\t},\n\t\t{\n\t\t\tname: \"optional annotations can be missing\",\n\t\t\tcomments: `testSource with minimal annotations.\n\n+externaldns:source:name=minimal\n+externaldns:source:category=Testing\n+externaldns:source:description=Minimal source\n`,\n\t\t\ttypeName:    \"testSource\",\n\t\t\tfilePath:    \"test.go\",\n\t\t\twantSources: 1,\n\t\t\tvalidate: func(t *testing.T, s Source) {\n\t\t\t\tassert.Equal(t, \"minimal\", s.Name)\n\t\t\t\tassert.Empty(t, s.Resources)\n\t\t\t\tassert.Empty(t, s.Filters)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:        \"empty name annotation\",\n\t\t\twantSources: 0,\n\t\t\tcomments: `testSource with minimal annotations.\n\n+externaldns:source:name=\n+externaldns:source:category=Testing\n+externaldns:source:description=Minimal source\n`,\n\t\t\ttypeName: \"testSource\",\n\t\t\tfilePath: \"test.go\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tsources, err := extractSourcesFromComments(tt.comments, tt.typeName, tt.filePath)\n\n\t\t\tif tt.wantErr {\n\t\t\t\trequire.Error(t, err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\trequire.NoError(t, err)\n\t\t\tassert.Len(t, sources, tt.wantSources)\n\n\t\t\tif tt.validate != nil {\n\t\t\t\tfor _, source := range sources {\n\t\t\t\t\ttt.validate(t, source)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Verify all sources have required fields\n\t\t\tfor _, source := range sources {\n\t\t\t\tassert.Equal(t, tt.typeName, source.Type)\n\t\t\t\tassert.Equal(t, tt.filePath, source.File)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/gen/docs/sources/templates/sources.gotpl",
    "content": "---\ntags:\n  - sources\n  - autogenerated\n---\n\n# Supported Sources\n\n<!-- THIS FILE MUST NOT BE EDITED BY HAND -->\n<!-- ON NEW SOURCE ADDED PLEASE RUN 'make generate-sources-documentation' -->\n<!-- markdownlint-disable MD013 -->\n\nExternalDNS supports multiple sources for discovering DNS records. Each source watches specific Kubernetes or cloud platform resources and generates DNS records based on their configuration.\n\n## Overview\n\nSources are responsible for:\n\n- Watching Kubernetes resources or external APIs\n- Extracting DNS information from annotations and resource specifications\n- Generating DNS endpoint records for providers to consume\n\n## Available Sources\n\n| {{ padRight .ColWidths.Name \"**Source Name**\" }} | {{ padRight .ColWidths.Filters \"Filters\" }} | {{ padRight .ColWidths.Namespace \"Namespace\" }} | {{ padRight .ColWidths.FQDNTemplate \"FQDN Template\" }} | {{ padRight .ColWidths.Events \"Events\" }} | {{ padRight .ColWidths.ProviderSpecific \"Provider Specific\" }} | {{ padRight .ColWidths.Category \"Category\" }} | {{ padRight .ColWidths.Resources \"Resources\" }} |\n|:{{ leftSep .ColWidths.Name }}|:{{ leftSep .ColWidths.Filters }}|:{{ leftSep .ColWidths.Namespace }}|:{{ leftSep .ColWidths.FQDNTemplate }}|:{{ leftSep .ColWidths.Events }}|:{{ leftSep .ColWidths.ProviderSpecific }}|:{{ leftSep .ColWidths.Category }}|:{{ leftSep .ColWidths.Resources }}|\n{{- range .Sources }}\n| {{ padRight $.ColWidths.Name (.Name | bold) }} | {{ padRight $.ColWidths.Filters .Filters }} | {{ padRight $.ColWidths.Namespace .Namespace }} | {{ padRight $.ColWidths.FQDNTemplate .FQDNTemplate }} | {{ padRight $.ColWidths.Events .Events }} | {{ padRight $.ColWidths.ProviderSpecific .ProviderSpecific }} | {{ padRight $.ColWidths.Category (lower .Category) }} | {{ padRight $.ColWidths.Resources (replace .Resources \",\" \"<br/>\") }} |\n{{- end }}\n\n## Usage\n\nTo use a specific source, configure ExternalDNS with the {{backtick 1}}--source{{backtick 1}} flag:\n\n{{backtick 3}}bash\nexternal-dns --source=service --source=ingress\n{{backtick 3}}\n\nMultiple sources can be combined to watch different resource types simultaneously.\n\n## Source Categories\n\n- **Kubernetes Core**: Native Kubernetes resources (Service, Ingress, Pod, Node)\n- **ExternalDNS**: Native ExternalDNS resources\n- **Gateway API**: Kubernetes Gateway API resources (Gateway, HTTPRoute, etc.)\n- **Service Mesh**: Service mesh implementations (Istio, Gloo)\n- **Ingress Controllers**: Third-party ingress controller resources (Contour, Traefik, Ambassador, etc.)\n- **Load Balancers**: Load balancer specific resources (F5)\n- **OpenShift**: OpenShift specific resources (Route)\n- **Cloud Platforms**: Cloud platform integrations (Cloud Foundry)\n- **Wrappers**: Source wrappers that modify or combine other sources\n- **Special**: Special purpose sources (connector, empty)\n- **Testing**: Sources used for testing purposes\n"
  },
  {
    "path": "internal/idna/idna.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage idna\n\nimport (\n\t\"strings\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"golang.org/x/net/idna\"\n)\n\nvar (\n\tProfile = idna.New(\n\t\tidna.MapForLookup(),\n\t\tidna.Transitional(true),\n\t\tidna.StrictDomainName(false),\n\t)\n)\n\n// NormalizeDNSName converts a DNS name to a canonical form, so that we can use string equality\n// it: removes space, get ASCII version of dnsName complient with Section 5 of RFC 5891, ensures there is a trailing dot\nfunc NormalizeDNSName(dnsName string) string {\n\ts, err := Profile.ToASCII(strings.TrimSpace(dnsName))\n\tif err != nil {\n\t\tlog.Warnf(`Got error while parsing DNSName %s: %v`, dnsName, err)\n\t}\n\tif !strings.HasSuffix(s, \".\") {\n\t\ts += \".\"\n\t}\n\treturn s\n}\n"
  },
  {
    "path": "internal/idna/idna_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage idna\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestProfileWithDefault(t *testing.T) {\n\ttets := []struct {\n\t\tinput    string\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tinput:    \"*.GÖPHER.com\",\n\t\t\texpected: \"*.göpher.com\",\n\t\t},\n\t\t{\n\t\t\tinput:    \"*._abrakadabra.com\",\n\t\t\texpected: \"*._abrakadabra.com\",\n\t\t},\n\t\t{\n\t\t\tinput:    \"_abrakadabra.com\",\n\t\t\texpected: \"_abrakadabra.com\",\n\t\t},\n\t\t{\n\t\t\tinput:    \"*.foo.kube.example.com\",\n\t\t\texpected: \"*.foo.kube.example.com\",\n\t\t},\n\t\t{\n\t\t\tinput:    \"xn--bcher-kva.example.com\",\n\t\t\texpected: \"bücher.example.com\",\n\t\t},\n\t}\n\tfor _, tt := range tets {\n\t\tt.Run(strings.ToLower(tt.input), func(t *testing.T) {\n\t\t\tresult, err := Profile.ToUnicode(tt.input)\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.Equal(t, tt.expected, result)\n\t\t})\n\t}\n}\n\nfunc TestNormalizeDNSName(tt *testing.T) {\n\trecords := []struct {\n\t\tdnsName string\n\t\texpect  string\n\t}{\n\t\t{\n\t\t\t\"3AAAA.FOO.BAR.COM    \",\n\t\t\t\"3aaaa.foo.bar.com.\",\n\t\t},\n\t\t{\n\t\t\t\"   example.foo.com.\",\n\t\t\t\"example.foo.com.\",\n\t\t},\n\t\t{\n\t\t\t\"example123.foo.com \",\n\t\t\t\"example123.foo.com.\",\n\t\t},\n\t\t{\n\t\t\t\"foo\",\n\t\t\t\"foo.\",\n\t\t},\n\t\t{\n\t\t\t\"123foo.bar\",\n\t\t\t\"123foo.bar.\",\n\t\t},\n\t\t{\n\t\t\t\"foo.com\",\n\t\t\t\"foo.com.\",\n\t\t},\n\t\t{\n\t\t\t\"foo.com.\",\n\t\t\t\"foo.com.\",\n\t\t},\n\t\t{\n\t\t\t\"_foo.com.\",\n\t\t\t\"_foo.com.\",\n\t\t},\n\t\t{\n\t\t\t\"\\u005Ffoo.com.\",\n\t\t\t\"_foo.com.\",\n\t\t},\n\t\t{\n\t\t\t\".foo.com.\",\n\t\t\t\".foo.com.\",\n\t\t},\n\t\t{\n\t\t\t\"foo123.COM\",\n\t\t\t\"foo123.com.\",\n\t\t},\n\t\t{\n\t\t\t\"my-exaMple3.FOO.BAR.COM\",\n\t\t\t\"my-example3.foo.bar.com.\",\n\t\t},\n\t\t{\n\t\t\t\"   my-example1214.FOO-1235.BAR-foo.COM   \",\n\t\t\t\"my-example1214.foo-1235.bar-foo.com.\",\n\t\t},\n\t\t{\n\t\t\t\"my-example-my-example-1214.FOO-1235.BAR-foo.COM\",\n\t\t\t\"my-example-my-example-1214.foo-1235.bar-foo.com.\",\n\t\t},\n\t\t{\n\t\t\t\"點看.org.\",\n\t\t\t\"xn--c1yn36f.org.\",\n\t\t},\n\t\t{\n\t\t\t\"nordic-ø.xn--kitty-點看pd34d.com\",\n\t\t\t\"xn--nordic--w1a.xn--xn--kitty-pd34d-hn01b3542b.com.\",\n\t\t},\n\t\t{\n\t\t\t\"nordic-ø.kitty😸.com.\",\n\t\t\t\"xn--nordic--w1a.xn--kitty-pd34d.com.\",\n\t\t},\n\t\t{\n\t\t\t\"  nordic-ø.kitty😸.COM\",\n\t\t\t\"xn--nordic--w1a.xn--kitty-pd34d.com.\",\n\t\t},\n\t\t{\n\t\t\t\"xn--nordic--w1a.kitty😸.com.\",\n\t\t\t\"xn--nordic--w1a.xn--kitty-pd34d.com.\",\n\t\t},\n\t\t{\n\t\t\t\"*.example.com.\",\n\t\t\t\"*.example.com.\",\n\t\t},\n\t\t{\n\t\t\t\"*.example.com\",\n\t\t\t\"*.example.com.\",\n\t\t},\n\t}\n\tfor _, r := range records {\n\t\ttt.Run(r.dnsName, func(t *testing.T) {\n\t\t\tgotName := NormalizeDNSName(r.dnsName)\n\t\t\tassert.Equal(t, r.expect, gotName)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/testresources/ca.pem",
    "content": "-----BEGIN CERTIFICATE-----\nMIIDDzCCAfegAwIBAgIUbIVuK9I6w/4U7WQCF4XYTn5qHUYwDQYJKoZIhvcNAQEL\nBQAwFzEVMBMGA1UEAwwMVGVzdCBSb290IENBMB4XDTI1MTAxMDE5MDIwNVoXDTM1\nMTAwODE5MDIwNVowFzEVMBMGA1UEAwwMVGVzdCBSb290IENBMIIBIjANBgkqhkiG\n9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyYQae0ZtDTb1I8+eof1SmMSZ0AQzxC3ZrJqa\nmNXitNQl345Oqo/Rw2ypqNPJ3kqs66KWFm43QzAkUg8sk31AK7Ddf9qxzoqqME0L\npckTDDZoPsZQSVKYfCDeimd7Rc6g/kg7QUHbuNJpxHU+3/WOHPFQAPGDIFzy7hPT\nTk1m00Hg+qQThIHkvEzlgYxRL9qVu4+Xxa+PqRck3If4ladMigEJSQBhNVbYpoP1\nqfHcS6ppFnCemNXPTvnE+Qkl4d/GFjOCWWJTU6lIpMJQB23rcp5P7y/QahapWonR\nTBzUGD3RenVydqCj7g36XQygfDKSuXo2EjsafPYSRyKWIDWsNQIDAQABo1MwUTAd\nBgNVHQ4EFgQUvi2RqnpYDVcyOAe0qPOThztmcjMwHwYDVR0jBBgwFoAUvi2RqnpY\nDVcyOAe0qPOThztmcjMwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC\nAQEAn2jeh6I9ywo8vsvjtippWfclQbC4/p9Au5PTMnfHTkL6f7OYs6swg0Kltam8\npeiIQUI2cKHuVNGyUFqeoaJmydJ+tgyQOmeaC1m1zx++ck8hpLdz0sP4/Psf2u8B\nafQ5SRfFJfwICeOzrlgSeoMSsPg7IycR6PCiZAGFzdN3qB1lhCQZYpGQH0/Fax1b\nz0p7kDneaNBQXSsD1igF56R5yzck+QZ4UmvRELEgk0t2JmlLNCrTw9aauGsjhgy/\n/amKBlmnywfSogJS+k1GgY5KCtSS9AA6CvqA0qnGeR1S09RiACfCls4Tlo3e/ikL\nKNJIlBlunVpyIG6OMjLJNZ3FVA==\n-----END CERTIFICATE-----\n"
  },
  {
    "path": "internal/testresources/client-cert-key.pem",
    "content": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDSqF1QwmVuTf27\nUDYzIWw7Fe2k5hC6LuIrusr5zdUToP6b1xrh3O0RF8GOJJoKvJbieptnRxWxWCbH\nBhZnEznf2NX5Cdn0LbxpD4IE549TNJyBqNtfgRSmTrDlY81xJVOLByFx+b1448fC\nUFS34qNhtCodGQq8kYfp2pZ4Z9GbwJZJVpNlAL8Ofwnv3mS1b/LShnhhscFuRzrh\nVV1UVs+9fEWcwSDp+pXn4rV/cMQZTnIck9KYvlq+rkU76+SVpWKEwNFfnw7vvtwm\n5nlriKtjk/6kPirAnWBl8JzdhcZcfJ75Wq7prZR4zXB6S57h2UnlJBfNPhMNs5HD\nGrw2NSUXAgMBAAECggEAB9y8ztzSiFFk3O7bdwESVwo0emkTyr8hNdyc4sHp5/ek\nSRC4MiHavz6RvMpk7W2oe/9zeWFPz/SoTdlOUL6I9G/VXJwfhFuIoqsvgRtbYBGg\nnb49oczhhmt9crJM4qIwAgpcFzLo/XAS7o+s+cf9rRHaWIesvOj5l6LO5uOJETUH\njUleAR54RpyNTJdYySguabb3LyDMWg+ri8BUcfUegseqBdeyuwcehBEiKyC6xUcE\nzM1XdrkEX9ZfcGO7x5l0egPhn+eSuj4p6N7gFtnC5jlDUWnZku1wO+lu8B/5olf5\ntqfYmd6Hcyqz6lkMiWuhX8aSmGuS/QS2Fe2h87rEEQKBgQDyPGfhYj0fcel7XnER\nbWZSZ5XNwS8vCbBHP269aehPQQo32sGH+WGkxbXotlKK5hEUSxMR42xsDL0IAcGu\nCuDA1Czu+fj7FSHuerd73xH4yEoSxgy4G2vvnUCvqPwlQtPyZDKYRepeqy2V1h+y\n1YrpeukO3enSPmH8+mgGYlGl3QKBgQDeoJ34teTCdIRm4v6o11JOX+k/BqkkMzAB\nt67stOQXatgeWIeYp49cZCXeIca83M/iINImpBr5sx681VvN84JEznkhAa1Wb/mk\nTUdhbHQAr13a15T7QDG81ejF5ifmxVfewPNq0REmIs1fc0x7zX4Ey1x8y4R1fy4X\nH84k1bKJgwKBgAo3Wfo7dnB5EWvOk940Svh2ve6rkx3cvr6Cgl0itlWBXLj2VOsz\nLVcRr5Zc+iY5hcbhU7CRcuUrtF0+FbkNZGU9jZeWm1Wbko7IRizHP67KY7Ve/PJW\n1bqJW00NR3Ua2G2EpE2fxT6w4X9MRJH6R52JPYMPAOmJEADnXrPGOcNRAoGAKG5H\nAioWd3ItsXm8AfHI0s78TyPoh9h7+XPgYsCfQ9l1kl1FkuWrVX4immrL6vS3FDwd\nrkLTW1G6XVTqLUbx+4j72pCxaCdB0SLvubO2hYFTrDDGr7KC1eaLNZWM3Y4tXRjx\nnA6H7MMZRSJtW3aAUmKUU12qmqQUPMLb7ziYCf0CgYAw0n5WJHQl5rpFOwty72ZQ\nnqoFu2azM8Jm/IvftK/XXZIxRT50Bw6Dc4KYSR+VkLE9RibHlXuP2Lp5EaRO3SpH\n3GsojUPifphft3gcxgBtPGtQwdCFOkIV/wwD6Q0Z7wQ+BVM6VifKTm1lSjDMS8aX\nF6hGTBUc7Nmgz4+kh9cOWA==\n-----END PRIVATE KEY-----\n"
  },
  {
    "path": "internal/testresources/client-cert.pem",
    "content": "-----BEGIN CERTIFICATE-----\nMIIDETCCAfmgAwIBAgIUU8qhWScbHKzCU94Gy+xdN9jltYEwDQYJKoZIhvcNAQEL\nBQAwFzEVMBMGA1UEAwwMVGVzdCBSb290IENBMB4XDTI1MTAxMDE5MDIzNFoXDTM1\nMTAwODE5MDIzNFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0B\nAQEFAAOCAQ8AMIIBCgKCAQEA0qhdUMJlbk39u1A2MyFsOxXtpOYQui7iK7rK+c3V\nE6D+m9ca4dztERfBjiSaCryW4nqbZ0cVsVgmxwYWZxM539jV+QnZ9C28aQ+CBOeP\nUzScgajbX4EUpk6w5WPNcSVTiwchcfm9eOPHwlBUt+KjYbQqHRkKvJGH6dqWeGfR\nm8CWSVaTZQC/Dn8J795ktW/y0oZ4YbHBbkc64VVdVFbPvXxFnMEg6fqV5+K1f3DE\nGU5yHJPSmL5avq5FO+vklaVihMDRX58O777cJuZ5a4irY5P+pD4qwJ1gZfCc3YXG\nXHye+Vqu6a2UeM1wekue4dlJ5SQXzT4TDbORwxq8NjUlFwIDAQABo1gwVjAUBgNV\nHREEDTALgglsb2NhbGhvc3QwHQYDVR0OBBYEFLmx5zCvEt71ffphWJRZ2kPlzJiA\nMB8GA1UdIwQYMBaAFL4tkap6WA1XMjgHtKjzk4c7ZnIzMA0GCSqGSIb3DQEBCwUA\nA4IBAQAwtr0bTiaASWguBxu2xUvuXpm8ONmi3Ekts9+kJLVHPHdntfEbW2p/45Lx\nj0nCVduOkyb7AbPez02vW3orW4NTAJL8SPmvAqk0+ClTlm7RTvp/iOZcRaxOrZES\n2S66rELSd386264RX644sJF1mwFmFuO4UJ/w9qmlDxCO9n8pm7SXDTYKsXsDO7BY\ncuBYFOhMD5ECeCP7/dyJNLzt63S5k+SmxaE17FWfRi6I4OE1vcxRAGpsCE7CYhW8\nxYXnM0+AMf8nxblcTVls1moV3hk0P7XstO2gi9nZubav/cZigWwzFpAyoimTFDFe\nbKlHpNaD1H3ossbQzS222GTvrZQm\n-----END CERTIFICATE-----\n"
  },
  {
    "path": "internal/testutils/endpoint.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage testutils\n\nimport (\n\t\"fmt\"\n\t\"maps\"\n\t\"math/rand\"\n\t\"net/netip\"\n\t\"reflect\"\n\t\"slices\"\n\t\"sort\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\tctrlclient \"sigs.k8s.io/controller-runtime/pkg/client\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/pkg/events\"\n)\n\n/** test utility functions for endpoints verifications */\n\ntype byNames endpoint.ProviderSpecific\n\nfunc (p byNames) Len() int           { return len(p) }\nfunc (p byNames) Swap(i, j int)      { p[i], p[j] = p[j], p[i] }\nfunc (p byNames) Less(i, j int) bool { return p[i].Name < p[j].Name }\n\ntype byAllFields []*endpoint.Endpoint\n\nfunc (b byAllFields) Len() int      { return len(b) }\nfunc (b byAllFields) Swap(i, j int) { b[i], b[j] = b[j], b[i] }\nfunc (b byAllFields) Less(i, j int) bool {\n\tif b[i].DNSName < b[j].DNSName {\n\t\treturn true\n\t}\n\tif b[i].DNSName == b[j].DNSName {\n\t\t// This rather bad, we need a more complex comparison for Targets, which considers all elements\n\t\tif b[i].Targets.Same(b[j].Targets) {\n\t\t\tif b[i].RecordType == (b[j].RecordType) {\n\t\t\t\tsa := b[i].ProviderSpecific\n\t\t\t\tsb := b[j].ProviderSpecific\n\t\t\t\tsort.Sort(byNames(sa))\n\t\t\t\tsort.Sort(byNames(sb))\n\t\t\t\treturn reflect.DeepEqual(sa, sb)\n\t\t\t}\n\t\t\treturn b[i].RecordType <= b[j].RecordType\n\t\t}\n\t\treturn b[i].Targets.String() <= b[j].Targets.String()\n\t}\n\treturn false\n}\n\n// SameEndpoint returns true if two endpoints are same\n// considers example.org. and example.org DNSName/Target as different endpoints\nfunc SameEndpoint(a, b *endpoint.Endpoint) bool {\n\tif a == nil || b == nil {\n\t\treturn a == b\n\t}\n\treturn a.DNSName == b.DNSName && a.Targets.Same(b.Targets) && a.RecordType == b.RecordType && a.SetIdentifier == b.SetIdentifier &&\n\t\ta.Labels[endpoint.OwnerLabelKey] == b.Labels[endpoint.OwnerLabelKey] && a.RecordTTL == b.RecordTTL &&\n\t\ta.Labels[endpoint.ResourceLabelKey] == b.Labels[endpoint.ResourceLabelKey] &&\n\t\ta.Labels[endpoint.OwnedRecordLabelKey] == b.Labels[endpoint.OwnedRecordLabelKey] &&\n\t\tSameProviderSpecific(a.ProviderSpecific, b.ProviderSpecific)\n}\n\n// SameEndpoints compares two slices of endpoints regardless of order\n// [x,y,z] == [z,x,y]\n// [x,x,z] == [x,z,x]\n// [x,y,y] != [x,x,y]\n// [x,x,x] != [x,x,z]\nfunc SameEndpoints(a, b []*endpoint.Endpoint) bool {\n\tif len(a) != len(b) {\n\t\treturn false\n\t}\n\n\tsa := a\n\tsb := b\n\tsort.Sort(byAllFields(sa))\n\tsort.Sort(byAllFields(sb))\n\n\tfor i := range sa {\n\t\tif !SameEndpoint(sa[i], sb[i]) {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// SameEndpointLabels verifies that labels of the two slices of endpoints are the same\nfunc SameEndpointLabels(a, b []*endpoint.Endpoint) bool {\n\tif len(a) != len(b) {\n\t\treturn false\n\t}\n\n\tsa := a\n\tsb := b\n\tsort.Sort(byAllFields(sa))\n\tsort.Sort(byAllFields(sb))\n\n\tfor i := range sa {\n\t\tif !reflect.DeepEqual(sa[i].Labels, sb[i].Labels) {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// SamePlanChanges verifies that two set of changes are the same\nfunc SamePlanChanges(a, b map[string][]*endpoint.Endpoint) bool {\n\treturn SameEndpoints(a[\"Create\"], b[\"Create\"]) && SameEndpoints(a[\"Delete\"], b[\"Delete\"]) &&\n\t\tSameEndpoints(a[\"UpdateOld\"], b[\"UpdateOld\"]) && SameEndpoints(a[\"UpdateNew\"], b[\"UpdateNew\"])\n}\n\n// SameProviderSpecific verifies that two maps contain the same string/string key/value pairs\nfunc SameProviderSpecific(a, b endpoint.ProviderSpecific) bool {\n\tsa := a\n\tsb := b\n\tsort.Sort(byNames(sa))\n\tsort.Sort(byNames(sb))\n\treturn reflect.DeepEqual(sa, sb)\n}\n\n// NewTargetsFromAddr convert an array of netip.Addr to Targets (array of string)\nfunc NewTargetsFromAddr(targets []netip.Addr) endpoint.Targets {\n\tt := make(endpoint.Targets, len(targets))\n\tfor i, target := range targets {\n\t\tt[i] = target.String()\n\t}\n\treturn t\n}\n\n// GenerateTestEndpointsByType generates a shuffled slice of test Endpoints for each record type and count specified in typeCounts.\n// Usage example:\n//\n//\tendpoints := GenerateTestEndpointsByType(map[string]int{\"A\": 2, \"CNAME\": 1})\n//\tendpoints will contain 2 A records and 1 CNAME record with unique DNS names and targets.\nfunc GenerateTestEndpointsByType(typeCounts map[string]int) []*endpoint.Endpoint {\n\treturn GenerateTestEndpointsWithDistribution(typeCounts, map[string]int{\"example.com\": 1}, nil)\n}\n\n// GenerateTestEndpointsWithDistribution generates test endpoints with specified distributions\n// of record types, domains, and owners.\n// - typeCounts: maps record type (e.g., \"A\", \"CNAME\") to how many endpoints of that type to create\n// - domainWeights: maps domain suffix to weight; domains are distributed proportionally\n// - ownerWeights: maps owner ID to weight; owners are distributed proportionally\n//\n// The total number of endpoints equals the sum of typeCounts values.\n// Weights represent ratios: {\"example.com\": 2, \"test.org\": 1} means ~66% example.com, ~33% test.org\n//\n// Example:\n//\n//\tendpoints := GenerateTestEndpointsWithDistribution(\n//\t    map[string]int{\"A\": 6, \"CNAME\": 4},       // 10 endpoints total\n//\t    map[string]int{\"example.com\": 2, \"test.org\": 1},  // ~66% example.com, ~33% test.org\n//\t    map[string]int{\"owner1\": 3, \"owner2\": 1},         // ~75% owner1, ~25% owner2\n//\t)\nfunc GenerateTestEndpointsWithDistribution(\n\ttypeCounts map[string]int,\n\tdomainWeights map[string]int,\n\townerWeights map[string]int,\n) []*endpoint.Endpoint {\n\t// Calculate total endpoints\n\ttotalEndpoints := 0\n\tfor _, count := range typeCounts {\n\t\ttotalEndpoints += count\n\t}\n\n\t// Build domain distribution (sorted keys for determinism)\n\tdomainKeys := slices.Sorted(maps.Keys(domainWeights))\n\tdomains := distributeByWeight(domainKeys, domainWeights, totalEndpoints)\n\n\t// Build owner distribution (sorted keys for determinism)\n\townerKeys := slices.Sorted(maps.Keys(ownerWeights))\n\towners := distributeByWeight(ownerKeys, ownerWeights, totalEndpoints)\n\n\t// Sort record types for deterministic iteration\n\ttypeKeys := slices.Sorted(maps.Keys(typeCounts))\n\n\tvar result []*endpoint.Endpoint\n\tidx := 0\n\tfor _, rt := range typeKeys {\n\t\tcount := typeCounts[rt]\n\t\tfor range count {\n\t\t\t// Determine domain from distribution or use default\n\t\t\tdomain := \"example.com\"\n\t\t\tif idx < len(domains) {\n\t\t\t\tdomain = domains[idx]\n\t\t\t}\n\n\t\t\t// Create endpoint with labels\n\t\t\tep := &endpoint.Endpoint{\n\t\t\t\tDNSName:    fmt.Sprintf(\"%s-%d.%s\", strings.ToLower(rt), idx, domain),\n\t\t\t\tTargets:    endpoint.Targets{fmt.Sprintf(\"192.0.2.%d\", idx)},\n\t\t\t\tRecordType: rt,\n\t\t\t\tRecordTTL:  300,\n\t\t\t\tLabels:     endpoint.Labels{},\n\t\t\t}\n\n\t\t\t// Assign owner from distribution\n\t\t\tif idx < len(owners) {\n\t\t\t\tep.Labels[endpoint.OwnerLabelKey] = owners[idx]\n\t\t\t}\n\n\t\t\tresult = append(result, ep)\n\t\t\tidx++\n\t\t}\n\t}\n\n\trand.Shuffle(len(result), func(i, j int) {\n\t\tresult[i], result[j] = result[j], result[i]\n\t})\n\treturn result\n}\n\n// NewEndpointWithRef builds an endpoint attached to a Kubernetes object reference.\n// The record type is inferred from target: A for IPv4, AAAA for IPv6, CNAME otherwise.\n// Kind and APIVersion are resolved from the client-go scheme, so TypeMeta need not be set on obj.\nfunc NewEndpointWithRef(dns, target string, obj ctrlclient.Object, source string) *endpoint.Endpoint {\n\treturn endpoint.NewEndpoint(dns, endpoint.SuitableType(target), target).\n\t\tWithRefObject(events.NewObjectReference(obj, source))\n}\n\n// AssertEndpointsHaveRefObject asserts that endpoints have the expected count\n// and each endpoint has a non-nil RefObject with the expected source type.\nfunc AssertEndpointsHaveRefObject(\n\tt *testing.T,\n\tendpoints []*endpoint.Endpoint,\n\texpectedSource string,\n\texpectedCount int) {\n\tt.Helper()\n\tassert.Len(t, endpoints, expectedCount)\n\tfor _, ep := range endpoints {\n\t\tassert.NotNil(t, ep.RefObject())\n\t\tassert.NotEmpty(t, ep.RefObject().UID)\n\t\tassert.Equal(t, expectedSource, ep.RefObject().Source)\n\t}\n}\n\n// distributeByWeight distributes n items according to weights.\n// Returns a slice of length n with items distributed proportionally.\nfunc distributeByWeight(keys []string, weights map[string]int, n int) []string {\n\tif len(keys) == 0 || n == 0 {\n\t\treturn nil\n\t}\n\n\ttotalWeight := 0\n\tfor _, key := range keys {\n\t\ttotalWeight += weights[key]\n\t}\n\tif totalWeight == 0 {\n\t\treturn nil\n\t}\n\n\tresult := make([]string, 0, n)\n\tfor _, key := range keys {\n\t\tcount := (weights[key] * n) / totalWeight\n\t\tfor range count {\n\t\t\tresult = append(result, key)\n\t\t}\n\t}\n\n\t// Fill any remaining slots due to rounding with the last key\n\tfor len(result) < n {\n\t\tresult = append(result, keys[len(keys)-1])\n\t}\n\n\treturn result\n}\n"
  },
  {
    "path": "internal/testutils/endpoint_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage testutils\n\nimport (\n\t\"net/netip\"\n\t\"reflect\"\n\t\"sort\"\n\t\"strings\"\n\t\"testing\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\tlogtest \"sigs.k8s.io/external-dns/internal/testutils/log\"\n)\n\nfunc TestExampleSameEndpoints(t *testing.T) {\n\teps := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName: \"example.org\",\n\t\t\tTargets: endpoint.Targets{\"load-balancer.org\"},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"example.org\",\n\t\t\tTargets:    endpoint.Targets{\"load-balancer.org\"},\n\t\t\tRecordType: endpoint.RecordTypeTXT,\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"abc.com\",\n\t\t\tTargets:    endpoint.Targets{\"something\"},\n\t\t\tRecordType: endpoint.RecordTypeTXT,\n\t\t},\n\t\t{\n\t\t\tDNSName:       \"abc.com\",\n\t\t\tTargets:       endpoint.Targets{\"1.2.3.4\"},\n\t\t\tRecordType:    endpoint.RecordTypeA,\n\t\t\tSetIdentifier: \"test-set-1\",\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"bbc.com\",\n\t\t\tTargets:    endpoint.Targets{\"foo.com\"},\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"cbc.com\",\n\t\t\tTargets:    endpoint.Targets{\"foo.com\"},\n\t\t\tRecordType: \"CNAME\",\n\t\t\tRecordTTL:  endpoint.TTL(60),\n\t\t},\n\t\t{\n\t\t\tDNSName: \"example.org\",\n\t\t\tTargets: endpoint.Targets{\"load-balancer.org\"},\n\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\tendpoint.ProviderSpecificProperty{Name: \"foo\", Value: \"bar\"},\n\t\t\t},\n\t\t},\n\t}\n\n\tsort.Sort(byAllFields(eps))\n\n\texpectedOrder := []string{\n\t\t\"abc.com\",\n\t\t\"abc.com\",\n\t\t\"bbc.com\",\n\t\t\"cbc.com\",\n\t\t\"example.org\",\n\t\t\"example.org\",\n\t\t\"example.org\",\n\t}\n\n\tassert.Len(t, eps, len(expectedOrder))\n\tfor i, ep := range eps {\n\t\tassert.Equal(t, expectedOrder[i], ep.DNSName, \"endpoint %d should be %s\", i, expectedOrder[i])\n\t}\n}\n\nfunc makeEndpoint(DNSName string) *endpoint.Endpoint { // nolint: gocritic // captLocal\n\treturn &endpoint.Endpoint{\n\t\tDNSName:       DNSName,\n\t\tTargets:       endpoint.Targets{\"target.com\"},\n\t\tRecordType:    \"A\",\n\t\tSetIdentifier: \"set1\",\n\t\tRecordTTL:     300,\n\t\tLabels: map[string]string{\n\t\t\tendpoint.OwnerLabelKey:       \"owner\",\n\t\t\tendpoint.ResourceLabelKey:    \"resource\",\n\t\t\tendpoint.OwnedRecordLabelKey: \"owned\",\n\t\t},\n\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t{Name: \"key\", Value: \"val\"},\n\t\t},\n\t}\n}\n\nfunc TestSameEndpoint(t *testing.T) {\n\ttests := []struct {\n\t\tname           string\n\t\ta              *endpoint.Endpoint\n\t\tb              *endpoint.Endpoint\n\t\tisSameEndpoint bool\n\t}{\n\t\t{\n\t\t\tname:           \"DNSName is not equal\",\n\t\t\ta:              &endpoint.Endpoint{DNSName: \"example.org\"},\n\t\t\tb:              &endpoint.Endpoint{DNSName: \"example.com\"},\n\t\t\tisSameEndpoint: false,\n\t\t},\n\t\t{\n\t\t\tname: \"All fields are equal\",\n\t\t\ta: &endpoint.Endpoint{\n\t\t\t\tDNSName:       \"example.org\",\n\t\t\t\tTargets:       endpoint.Targets{\"lb.example.com\"},\n\t\t\t\tRecordType:    \"A\",\n\t\t\t\tSetIdentifier: \"set-1\",\n\t\t\t\tRecordTTL:     300,\n\t\t\t\tLabels: map[string]string{\n\t\t\t\t\tendpoint.OwnerLabelKey:       \"owner-1\",\n\t\t\t\t\tendpoint.ResourceLabelKey:    \"resource-1\",\n\t\t\t\t\tendpoint.OwnedRecordLabelKey: \"owned-true\",\n\t\t\t\t},\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t{Name: \"key1\", Value: \"val1\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tb: &endpoint.Endpoint{\n\t\t\t\tDNSName:       \"example.org\",\n\t\t\t\tTargets:       endpoint.Targets{\"lb.example.com\"},\n\t\t\t\tRecordType:    \"A\",\n\t\t\t\tSetIdentifier: \"set-1\",\n\t\t\t\tRecordTTL:     300,\n\t\t\t\tLabels: map[string]string{\n\t\t\t\t\tendpoint.OwnerLabelKey:       \"owner-1\",\n\t\t\t\t\tendpoint.ResourceLabelKey:    \"resource-1\",\n\t\t\t\t\tendpoint.OwnedRecordLabelKey: \"owned-true\",\n\t\t\t\t},\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t{Name: \"key1\", Value: \"val1\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tisSameEndpoint: true,\n\t\t},\n\t\t{\n\t\t\tname:           \"Different Targets\",\n\t\t\ta:              &endpoint.Endpoint{DNSName: \"example.org\", Targets: endpoint.Targets{\"a.com\"}},\n\t\t\tb:              &endpoint.Endpoint{DNSName: \"example.org\", Targets: endpoint.Targets{\"b.com\"}},\n\t\t\tisSameEndpoint: false,\n\t\t},\n\t\t{\n\t\t\tname:           \"Different RecordType\",\n\t\t\ta:              &endpoint.Endpoint{DNSName: \"example.org\", RecordType: \"A\"},\n\t\t\tb:              &endpoint.Endpoint{DNSName: \"example.org\", RecordType: \"CNAME\"},\n\t\t\tisSameEndpoint: false,\n\t\t},\n\t\t{\n\t\t\tname:           \"Different SetIdentifier\",\n\t\t\ta:              &endpoint.Endpoint{DNSName: \"example.org\", SetIdentifier: \"id1\"},\n\t\t\tb:              &endpoint.Endpoint{DNSName: \"example.org\", SetIdentifier: \"id2\"},\n\t\t\tisSameEndpoint: false,\n\t\t},\n\t\t{\n\t\t\tname: \"Different OwnerLabelKey\",\n\t\t\ta: &endpoint.Endpoint{\n\t\t\t\tDNSName: \"example.org\",\n\t\t\t\tLabels: map[string]string{\n\t\t\t\t\tendpoint.OwnerLabelKey: \"owner1\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tb: &endpoint.Endpoint{\n\t\t\t\tDNSName: \"example.org\",\n\t\t\t\tLabels: map[string]string{\n\t\t\t\t\tendpoint.OwnerLabelKey: \"owner2\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tisSameEndpoint: false,\n\t\t},\n\t\t{\n\t\t\tname:           \"Different RecordTTL\",\n\t\t\ta:              &endpoint.Endpoint{DNSName: \"example.org\", RecordTTL: 300},\n\t\t\tb:              &endpoint.Endpoint{DNSName: \"example.org\", RecordTTL: 400},\n\t\t\tisSameEndpoint: false,\n\t\t},\n\t\t{\n\t\t\tname: \"Different ProviderSpecific\",\n\t\t\ta: &endpoint.Endpoint{\n\t\t\t\tDNSName: \"example.org\",\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t{Name: \"key1\", Value: \"val1\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tb: &endpoint.Endpoint{\n\t\t\t\tDNSName: \"example.org\",\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t{Name: \"key1\", Value: \"val2\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tisSameEndpoint: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tisSameEndpoint := SameEndpoint(tt.a, tt.b)\n\t\t\tassert.Equal(t, tt.isSameEndpoint, isSameEndpoint)\n\t\t})\n\t}\n}\nfunc TestSameEndpoints(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\ta, b []*endpoint.Endpoint\n\t\twant bool\n\t}{\n\t\t{\n\t\t\tname: \"Both slices nil\",\n\t\t\ta:    nil,\n\t\t\tb:    nil,\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tname: \"One nil, one empty\",\n\t\t\ta:    []*endpoint.Endpoint{},\n\t\t\tb:    nil,\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tname: \"Different lengths\",\n\t\t\ta:    []*endpoint.Endpoint{makeEndpoint(\"a.com\")},\n\t\t\tb:    []*endpoint.Endpoint{},\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tname: \"Same endpoints in same order\",\n\t\t\ta:    []*endpoint.Endpoint{makeEndpoint(\"a.com\"), makeEndpoint(\"b.com\")},\n\t\t\tb:    []*endpoint.Endpoint{makeEndpoint(\"a.com\"), makeEndpoint(\"b.com\")},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tname: \"Same endpoints in different order\",\n\t\t\ta:    []*endpoint.Endpoint{makeEndpoint(\"b.com\"), makeEndpoint(\"a.com\")},\n\t\t\tb:    []*endpoint.Endpoint{makeEndpoint(\"a.com\"), makeEndpoint(\"b.com\")},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tname: \"One endpoint differs\",\n\t\t\ta:    []*endpoint.Endpoint{makeEndpoint(\"a.com\"), makeEndpoint(\"b.com\")},\n\t\t\tb:    []*endpoint.Endpoint{makeEndpoint(\"a.com\"), makeEndpoint(\"c.com\")},\n\t\t\twant: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tisSameEndpoints := SameEndpoints(tt.a, tt.b)\n\t\t\tassert.Equal(t, tt.want, isSameEndpoints)\n\t\t})\n\t}\n}\n\nfunc TestSameEndpointLabel(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\ta    []*endpoint.Endpoint\n\t\tb    []*endpoint.Endpoint\n\t\twant bool\n\t}{\n\t\t{\n\t\t\tname: \"length of a and b are not same\",\n\t\t\ta:    []*endpoint.Endpoint{makeEndpoint(\"a.com\")},\n\t\t\tb:    []*endpoint.Endpoint{makeEndpoint(\"b.com\"), makeEndpoint(\"c.com\")},\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tname: \"endpoint's labels are same in a and b\",\n\t\t\ta:    []*endpoint.Endpoint{makeEndpoint(\"a.com\"), makeEndpoint(\"c.com\")},\n\t\t\tb:    []*endpoint.Endpoint{makeEndpoint(\"b.com\"), makeEndpoint(\"c.com\")},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tname: \"endpoint's labels are not same in a and b\",\n\t\t\ta: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName: \"a.com\",\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"owner1\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"resource1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName: \"b.com\",\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"owner2\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"resource2\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tb: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName: \"a.com\",\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"resource\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName: \"b.com\",\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"owner1\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"resource1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tisSameEndpointLabels := SameEndpointLabels(tt.a, tt.b)\n\t\t\tassert.Equal(t, tt.want, isSameEndpointLabels)\n\t\t})\n\t}\n}\n\nfunc TestSamePlanChanges(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\ta    map[string][]*endpoint.Endpoint\n\t\tb    map[string][]*endpoint.Endpoint\n\t\twant bool\n\t}{\n\t\t{\n\t\t\tname: \"endpoints with all operations in a and b are same\",\n\t\t\ta: map[string][]*endpoint.Endpoint{\n\t\t\t\t\"Create\":    {makeEndpoint(\"a.com\")},\n\t\t\t\t\"Delete\":    {makeEndpoint(\"b.com\")},\n\t\t\t\t\"UpdateOld\": {makeEndpoint(\"a.com\")},\n\t\t\t\t\"UpdateNew\": {makeEndpoint(\"c.com\")},\n\t\t\t},\n\t\t\tb: map[string][]*endpoint.Endpoint{\n\t\t\t\t\"Create\":    {makeEndpoint(\"a.com\")},\n\t\t\t\t\"Delete\":    {makeEndpoint(\"b.com\")},\n\t\t\t\t\"UpdateOld\": {makeEndpoint(\"a.com\")},\n\t\t\t\t\"UpdateNew\": {makeEndpoint(\"c.com\")},\n\t\t\t},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tname: \"endpoints for create operations in a and b are not same\",\n\t\t\ta: map[string][]*endpoint.Endpoint{\n\t\t\t\t\"Create\":    {makeEndpoint(\"a.com\")},\n\t\t\t\t\"Delete\":    {makeEndpoint(\"b.com\")},\n\t\t\t\t\"UpdateOld\": {makeEndpoint(\"a.com\")},\n\t\t\t\t\"UpdateNew\": {makeEndpoint(\"c.com\")},\n\t\t\t},\n\t\t\tb: map[string][]*endpoint.Endpoint{\n\t\t\t\t\"Create\":    {makeEndpoint(\"x.com\")},\n\t\t\t\t\"Delete\":    {makeEndpoint(\"b.com\")},\n\t\t\t\t\"UpdateOld\": {makeEndpoint(\"a.com\")},\n\t\t\t\t\"UpdateNew\": {makeEndpoint(\"c.com\")},\n\t\t\t},\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tname: \"endpoints for delete operations in a and b are not same\",\n\t\t\ta: map[string][]*endpoint.Endpoint{\n\t\t\t\t\"Create\":    {makeEndpoint(\"a.com\")},\n\t\t\t\t\"Delete\":    {makeEndpoint(\"b.com\")},\n\t\t\t\t\"UpdateOld\": {makeEndpoint(\"a.com\")},\n\t\t\t\t\"UpdateNew\": {makeEndpoint(\"c.com\")},\n\t\t\t},\n\t\t\tb: map[string][]*endpoint.Endpoint{\n\t\t\t\t\"Create\":    {makeEndpoint(\"a.com\")},\n\t\t\t\t\"Delete\":    {makeEndpoint(\"g.com\")},\n\t\t\t\t\"UpdateOld\": {makeEndpoint(\"a.com\")},\n\t\t\t\t\"UpdateNew\": {makeEndpoint(\"c.com\")},\n\t\t\t},\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tname: \"endpoints for updateOld operations in a and b are not same\",\n\t\t\ta: map[string][]*endpoint.Endpoint{\n\t\t\t\t\"Create\":    {makeEndpoint(\"a.com\")},\n\t\t\t\t\"Delete\":    {makeEndpoint(\"b.com\")},\n\t\t\t\t\"UpdateOld\": {makeEndpoint(\"b.com\")},\n\t\t\t\t\"UpdateNew\": {makeEndpoint(\"c.com\")},\n\t\t\t},\n\t\t\tb: map[string][]*endpoint.Endpoint{\n\t\t\t\t\"Create\":    {makeEndpoint(\"a.com\")},\n\t\t\t\t\"Delete\":    {makeEndpoint(\"b.com\")},\n\t\t\t\t\"UpdateOld\": {makeEndpoint(\"c.com\")},\n\t\t\t\t\"UpdateNew\": {makeEndpoint(\"c.com\")},\n\t\t\t},\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tname: \"endpoints for updateNew operations in a and b are same\",\n\t\t\ta: map[string][]*endpoint.Endpoint{\n\t\t\t\t\"Create\":    {makeEndpoint(\"a.com\")},\n\t\t\t\t\"Delete\":    {makeEndpoint(\"b.com\")},\n\t\t\t\t\"UpdateOld\": {makeEndpoint(\"a.com\")},\n\t\t\t\t\"UpdateNew\": {makeEndpoint(\"d.com\")},\n\t\t\t},\n\t\t\tb: map[string][]*endpoint.Endpoint{\n\t\t\t\t\"Create\":    {makeEndpoint(\"a.com\")},\n\t\t\t\t\"Delete\":    {makeEndpoint(\"b.com\")},\n\t\t\t\t\"UpdateOld\": {makeEndpoint(\"a.com\")},\n\t\t\t\t\"UpdateNew\": {makeEndpoint(\"c.com\")},\n\t\t\t},\n\t\t\twant: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tcheckPlanChanges := SamePlanChanges(tt.a, tt.b)\n\t\t\tassert.Equal(t, tt.want, checkPlanChanges)\n\t\t})\n\t}\n}\nfunc TestNewTargetsFromAddr(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tinput    []netip.Addr\n\t\texpected endpoint.Targets\n\t}{\n\t\t{\n\t\t\tname:     \"empty slice\",\n\t\t\tinput:    []netip.Addr{},\n\t\t\texpected: endpoint.Targets{},\n\t\t},\n\t\t{\n\t\t\tname: \"single IPv4 address\",\n\t\t\tinput: []netip.Addr{\n\t\t\t\tnetip.MustParseAddr(\"192.0.2.1\"),\n\t\t\t},\n\t\t\texpected: endpoint.Targets{\"192.0.2.1\"},\n\t\t},\n\t\t{\n\t\t\tname: \"multiple IP addresses\",\n\t\t\tinput: []netip.Addr{\n\t\t\t\tnetip.MustParseAddr(\"192.0.2.1\"),\n\t\t\t\tnetip.MustParseAddr(\"2001:db8::1\"),\n\t\t\t},\n\t\t\texpected: endpoint.Targets{\"192.0.2.1\", \"2001:db8::1\"},\n\t\t},\n\t\t{\n\t\t\tname: \"IPv6 address only\",\n\t\t\tinput: []netip.Addr{\n\t\t\t\tnetip.MustParseAddr(\"::1\"),\n\t\t\t},\n\t\t\texpected: endpoint.Targets{\"::1\"},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := NewTargetsFromAddr(tt.input)\n\t\t\tif !reflect.DeepEqual(got, tt.expected) {\n\t\t\t\tt.Errorf(\"NewTargetsFromAddr() = %v, want %v\", got, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestWithLabel(t *testing.T) {\n\te := &endpoint.Endpoint{}\n\t// should initialize Labels and set the key\n\treturned := e.WithLabel(\"foo\", \"bar\")\n\tassert.Equal(t, e, returned)\n\tassert.NotNil(t, e.Labels)\n\tassert.Equal(t, \"bar\", e.Labels[\"foo\"])\n\n\t// overriding an existing key\n\te2 := e.WithLabel(\"foo\", \"baz\")\n\tassert.Equal(t, e, e2)\n\tassert.Equal(t, \"baz\", e.Labels[\"foo\"])\n\n\t// adding a new key without wiping others\n\te.Labels[\"existing\"] = \"orig\"\n\te.WithLabel(\"new\", \"val\")\n\tassert.Equal(t, \"orig\", e.Labels[\"existing\"])\n\tassert.Equal(t, \"val\", e.Labels[\"new\"])\n}\n\nfunc TestGenerateTestEndpointsWithDistribution(t *testing.T) {\n\ttests := []struct {\n\t\tname          string\n\t\ttypeCounts    map[string]int\n\t\tdomainWeights map[string]int\n\t\townerWeights  map[string]int\n\t\twantTotal     int\n\t\twantTypes     map[string]int\n\t\twantDomains   map[string]int\n\t\twantOwners    map[string]int\n\t}{\n\t\t{\n\t\t\tname:          \"basic distribution\",\n\t\t\ttypeCounts:    map[string]int{\"A\": 6, \"CNAME\": 4},\n\t\t\tdomainWeights: map[string]int{\"example.com\": 1, \"test.org\": 1},\n\t\t\townerWeights:  map[string]int{\"owner1\": 1, \"owner2\": 1},\n\t\t\twantTotal:     10,\n\t\t\twantTypes:     map[string]int{\"A\": 6, \"CNAME\": 4},\n\t\t\twantDomains:   map[string]int{\"example.com\": 5, \"test.org\": 5},\n\t\t\twantOwners:    map[string]int{\"owner1\": 5, \"owner2\": 5},\n\t\t},\n\t\t{\n\t\t\tname:          \"weighted distribution 2:1\",\n\t\t\ttypeCounts:    map[string]int{\"A\": 9},\n\t\t\tdomainWeights: map[string]int{\"example.com\": 2, \"test.org\": 1},\n\t\t\townerWeights:  map[string]int{\"owner1\": 2, \"owner2\": 1},\n\t\t\twantTotal:     9,\n\t\t\twantTypes:     map[string]int{\"A\": 9},\n\t\t\twantDomains:   map[string]int{\"example.com\": 6, \"test.org\": 3},\n\t\t\twantOwners:    map[string]int{\"owner1\": 6, \"owner2\": 3},\n\t\t},\n\t\t{\n\t\t\tname:          \"empty weights use defaults\",\n\t\t\ttypeCounts:    map[string]int{\"A\": 3},\n\t\t\tdomainWeights: map[string]int{},\n\t\t\townerWeights:  map[string]int{},\n\t\t\twantTotal:     3,\n\t\t\twantTypes:     map[string]int{\"A\": 3},\n\t\t\twantDomains:   map[string]int{\"example.com\": 3},\n\t\t\twantOwners:    map[string]int{},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\teps := GenerateTestEndpointsWithDistribution(tt.typeCounts, tt.domainWeights, tt.ownerWeights)\n\n\t\t\tassert.Len(t, eps, tt.wantTotal, \"total endpoint count\")\n\n\t\t\t// Count actual distributions\n\t\t\tgotTypes := make(map[string]int)\n\t\t\tgotDomains := make(map[string]int)\n\t\t\tgotOwners := make(map[string]int)\n\n\t\t\tfor _, ep := range eps {\n\t\t\t\tgotTypes[ep.RecordType]++\n\t\t\t\tfor domain := range tt.wantDomains {\n\t\t\t\t\tif strings.HasSuffix(ep.DNSName, domain) {\n\t\t\t\t\t\tgotDomains[domain]++\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif owner, ok := ep.Labels[endpoint.OwnerLabelKey]; ok {\n\t\t\t\t\tgotOwners[owner]++\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tassert.Equal(t, tt.wantTypes, gotTypes, \"record type distribution\")\n\t\t\tassert.Equal(t, tt.wantDomains, gotDomains, \"domain distribution\")\n\t\t\tassert.Equal(t, tt.wantOwners, gotOwners, \"owner distribution\")\n\t\t})\n\t}\n}\n\nfunc TestFilterEndpointsByOwnerIDLogging(t *testing.T) {\n\tnoOwner := &endpoint.Endpoint{}\n\townedByFoo := &endpoint.Endpoint{\n\t\tLabels: endpoint.Labels{\n\t\t\tendpoint.OwnerLabelKey: \"foo\",\n\t\t},\n\t}\n\townedByBar := &endpoint.Endpoint{\n\t\tLabels: endpoint.Labels{\n\t\t\tendpoint.OwnerLabelKey: \"bar\",\n\t\t},\n\t}\n\ttests := []struct {\n\t\tname         string\n\t\townerID      string\n\t\tendpoints    []*endpoint.Endpoint\n\t\tmessages     []string\n\t\tmessages_not []string\n\t\tresult       []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\tname:         \"one_matches\",\n\t\t\townerID:      \"foo\",\n\t\t\tendpoints:    []*endpoint.Endpoint{ownedByFoo},\n\t\t\tmessages:     []string{},\n\t\t\tmessages_not: []string{\"\"},\n\t\t\tresult:       []*endpoint.Endpoint{ownedByFoo},\n\t\t},\n\t\t{\n\t\t\tname:         \"wrong_owner\",\n\t\t\townerID:      \"foo\",\n\t\t\tendpoints:    []*endpoint.Endpoint{ownedByFoo, ownedByBar},\n\t\t\tmessages:     []string{\"because owner id does not match\"},\n\t\t\tmessages_not: []string{},\n\t\t\tresult:       []*endpoint.Endpoint{ownedByFoo},\n\t\t},\n\t\t{\n\t\t\tname:         \"no_owner\",\n\t\t\townerID:      \"bar\",\n\t\t\tendpoints:    []*endpoint.Endpoint{noOwner, ownedByBar},\n\t\t\tmessages:     []string{\"because of missing owner label\"},\n\t\t\tmessages_not: []string{\"because owner id does not match\"},\n\t\t\tresult:       []*endpoint.Endpoint{ownedByBar},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\thook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t)\n\t\t\tendpoint.FilterEndpointsByOwnerID(tt.ownerID, tt.endpoints)\n\t\t\tfor _, m := range tt.messages {\n\t\t\t\tlogtest.TestHelperLogContains(m, hook, t)\n\t\t\t}\n\t\t\tfor _, m := range tt.messages_not {\n\t\t\t\tlogtest.TestHelperLogNotContains(m, hook, t)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/testutils/env.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage testutils\n\nimport (\n\t\"testing\"\n)\n\nfunc TestHelperEnvSetter(t *testing.T, envs map[string]string) {\n\tt.Helper()\n\n\tfor name, value := range envs {\n\t\tt.Setenv(name, value)\n\t}\n}\n"
  },
  {
    "path": "internal/testutils/helpers.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage testutils\n\n// ToPtr returns a pointer to the given value of any type.\n// Example usage:\n//\n//\tfoo := 42\n//\tfooPtr := ToPtr(foo)\n//\tfmt.Println(*fooPtr) // Output: 42\nfunc ToPtr[T any](v T) *T {\n\treturn &v\n}\n"
  },
  {
    "path": "internal/testutils/helpers_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage testutils\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestStringPtr(t *testing.T) {\n\toriginal := \"hello\"\n\tptr := ToPtr(original)\n\tif ptr == nil {\n\t\tt.Fatal(\"StringPtr returned nil\")\n\t}\n\tif *ptr != original {\n\t\tt.Fatalf(\"expected %q, got %q\", original, *ptr)\n\t}\n\n\t// Ensure the pointer value is independent of the original variable\n\toriginal = \"world\"\n\tif *ptr == original {\n\t\tt.Error(\"pointer value changed with the original variable\")\n\t}\n}\n\nfunc TestIsPointer(t *testing.T) {\n\tvalue := \"test\"\n\tptr := ToPtr(value)\n\n\tassert.IsType(t, *ptr, value)\n}\n"
  },
  {
    "path": "internal/testutils/init.go",
    "content": "/*\nCopyright 2020 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage testutils\n\nimport (\n\t\"io\"\n\t\"log\"\n\t\"os\"\n\n\t\"github.com/sirupsen/logrus\"\n\n\t\"sigs.k8s.io/external-dns/internal/config\"\n)\n\nfunc init() {\n\tconfig.FastPoll = true\n\tif os.Getenv(\"DEBUG\") == \"\" {\n\t\tlogrus.SetOutput(io.Discard)\n\t\tlog.SetOutput(io.Discard)\n\t} else {\n\t\tif level, err := logrus.ParseLevel(os.Getenv(\"DEBUG\")); err == nil {\n\t\t\tlogrus.SetLevel(level)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/testutils/log/log.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage logtest\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"github.com/sirupsen/logrus/hooks/test\"\n)\n\n// LogsUnderTestWithLogLevel redirects log(s) output to a buffer for testing purposes\n//\n// Usage: LogsUnderTestWithLogLevel(t)\n// Example:\n//\n//\thook := testutils.LogsUnderTestWithLogLevel(log.DebugLevel, t)\n//\t... do something that logs ...\n//\n// testutils.TestHelperLogContains(\"expected debug log message\", hook, t)\nfunc LogsUnderTestWithLogLevel(level log.Level, t *testing.T) *test.Hook {\n\tt.Helper()\n\tlogger, hook := test.NewNullLogger()\n\n\tlog.AddHook(hook)\n\tlog.SetOutput(logger.Out)\n\tlog.SetLevel(level)\n\treturn hook\n}\n\n// TestHelperLogContains verifies that a specific log message is present\n// in the captured log entries. It asserts that the provided message `msg`\n// appears in at least one of the log entries recorded by the `hook`.\n//\n// Parameters:\n// - msg: The log message that should be found.\n// - hook: The test hook capturing log entries.\n// - t: The testing object used for assertions.\n//\n// Usage:\n// This helper is used in tests to ensure that certain log messages are\n// logged during the execution of the code under test.\nfunc TestHelperLogContains(msg string, hook *test.Hook, t *testing.T) {\n\tt.Helper()\n\tisContains := false\n\tfor _, entry := range hook.AllEntries() {\n\t\tif strings.Contains(entry.Message, msg) {\n\t\t\tisContains = true\n\t\t}\n\t}\n\tassert.True(t, isContains, \"Expected log message not found: %s\", msg)\n}\n\n// TestHelperLogNotContains verifies that a specific log message is not present\n// in the captured log entries. It asserts that the provided message `msg`\n// does not appear in any of the log entries recorded by the `hook`.\n//\n// Parameters:\n// - msg: The log message that should not be found.\n// - hook: The test hook capturing log entries.\n// - t: The testing object used for assertions.\n//\n// Usage:\n// This helper is used in tests to ensure that certain log messages are not\n// logged during the execution of the code under test.\nfunc TestHelperLogNotContains(msg string, hook *test.Hook, t *testing.T) {\n\tt.Helper()\n\tisContains := false\n\tfor _, entry := range hook.AllEntries() {\n\t\tif strings.Contains(entry.Message, msg) {\n\t\t\tisContains = true\n\t\t}\n\t}\n\tassert.False(t, isContains, \"Expected log message found when should not: %s\", msg)\n}\n\n// TestHelperLogContainsWithLogLevel verifies that a specific log message with a given log level\n// is present in the captured log entries. It asserts that the provided message `msg`\n// appears in at least one of the log entries recorded by the `hook` with the specified log level.\n//\n// Parameters:\n// - msg: The log message that should be found.\n// - level: The log level that the message should have.\n// - hook: The test hook capturing log entries.\n// - t: The testing object used for assertions.\n//\n// Usage:\n// This helper is used in tests to ensure that certain log messages with a specific log level\n// are logged during the execution of the code under test.\nfunc TestHelperLogContainsWithLogLevel(msg string, level log.Level, hook *test.Hook, t *testing.T) {\n\tt.Helper()\n\tisContains := false\n\tfor _, entry := range hook.AllEntries() {\n\t\tif strings.Contains(entry.Message, msg) && entry.Level == level {\n\t\t\tisContains = true\n\t\t}\n\t}\n\tassert.True(t, isContains, \"Expected log message not found: %s with level %s\", msg, level)\n}\n\n// TestHelperWithLogExitFunc overrides the logrus ExitFunc for the duration of a test.\n// It returns a restore function that resets the ExitFunc back to the previous value.\nfunc TestHelperWithLogExitFunc(exitFunc func(int)) func() {\n\tlogger := log.StandardLogger()\n\tpreviousExitFunc := logger.ExitFunc\n\tlogger.ExitFunc = exitFunc\n\treturn func() {\n\t\tlogger.ExitFunc = previousExitFunc\n\t}\n}\n"
  },
  {
    "path": "internal/testutils/metrics.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage testutils\n\nimport (\n\t\"fmt\"\n\t\"sort\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/prometheus/client_golang/prometheus\"\n\tdto \"github.com/prometheus/client_model/go\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\n// TestHelperVerifyMetricsGaugeVectorWithLabels verifies that a prometheus.GaugeVec metric with specific labels has the expected value.\n// Supports partial label matching - if only some labels are provided, it sums all metrics matching those labels.\n//\n// Example usage:\n//\n//\tExact match (all labels)\n//\tlabels := map[string]string{\"method\": \"GET\", \"status\": \"200\"}\n//\tTestHelperVerifyMetricsGaugeVectorWithLabels(t, 42.0, myGaugeVec, labels)\n//\n//\tPartial match (sum all metrics with method=GET)\n//\tlabels := map[string]string{\"method\": \"GET\"}\n//\tTestHelperVerifyMetricsGaugeVectorWithLabels(t, 100.0, myGaugeVec, labels)\nfunc TestHelperVerifyMetricsGaugeVectorWithLabels(t *testing.T, expected float64, metric prometheus.GaugeVec, labels map[string]string) {\n\tTestHelperVerifyMetricsGaugeVectorWithLabelsFunc(t, expected, assert.Equal, metric, labels)\n}\n\n// TestHelperVerifyMetricsGaugeVectorWithLabelsFunc is a helper function that verifies a prometheus.GaugeVec metric with specific labels using a custom assertion function.\n// Supports partial label matching - if only some labels are provided, it sums all metrics matching those labels.\nfunc TestHelperVerifyMetricsGaugeVectorWithLabelsFunc(t *testing.T, expected float64, aFunc assert.ComparisonAssertionFunc, metric prometheus.GaugeVec, labels map[string]string) {\n\tt.Helper()\n\n\t// Collect all metrics and find matching ones\n\tactual := sumMetricsWithLabels(&metric, labels)\n\n\tif !aFunc(t, expected, actual, \"Expected gauge value does not match the actual value\", labels) {\n\t\tt.Logf(\"Available metrics:\\n%s\", collectGaugeVecMetrics(&metric))\n\t}\n}\n\n// collectAll drains all current observations from a GaugeVec into a slice.\nfunc collectAll(metric *prometheus.GaugeVec) []*dto.Metric {\n\tch := make(chan prometheus.Metric, 1024)\n\tgo func() {\n\t\tmetric.Collect(ch)\n\t\tclose(ch)\n\t}()\n\tvar result []*dto.Metric\n\tfor m := range ch {\n\t\tvar dm dto.Metric\n\t\tif err := m.Write(&dm); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tresult = append(result, &dm)\n\t}\n\treturn result\n}\n\n// sumMetricsWithLabels sums all metric values that match the provided labels (partial match supported).\n// Label matching is case-insensitive since metrics are stored in lowercase.\nfunc sumMetricsWithLabels(metric *prometheus.GaugeVec, matchLabels map[string]string) float64 {\n\tvar sum float64\n\tfor _, dm := range collectAll(metric) {\n\t\t// Check if all matchLabels are present with correct values (case-insensitive)\n\t\tmetricLabels := make(map[string]string)\n\t\tfor _, lp := range dm.Label {\n\t\t\tmetricLabels[lp.GetName()] = lp.GetValue()\n\t\t}\n\n\t\tmatches := true\n\t\tfor k, v := range matchLabels {\n\t\t\tif !strings.EqualFold(metricLabels[k], v) {\n\t\t\t\tmatches = false\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif matches && dm.Gauge != nil {\n\t\t\tsum += dm.Gauge.GetValue()\n\t\t}\n\t}\n\n\treturn sum\n}\n\n// collectGaugeVecMetrics collects all metrics from a GaugeVec and returns a formatted string.\n// Shows both per-label aggregates and detailed metrics.\nfunc collectGaugeVecMetrics(metric *prometheus.GaugeVec) string {\n\t// Collect all metrics and aggregate by label\n\ttype metricEntry struct {\n\t\tlabels map[string]string\n\t\tvalue  float64\n\t}\n\tvar entries []metricEntry\n\taggregates := make(map[string]map[string]float64) // labelName -> labelValue -> sum\n\n\tfor _, dm := range collectAll(metric) {\n\t\tentry := metricEntry{labels: make(map[string]string)}\n\t\tfor _, lp := range dm.Label {\n\t\t\tname, value := lp.GetName(), lp.GetValue()\n\t\t\tentry.labels[name] = value\n\n\t\t\tif aggregates[name] == nil {\n\t\t\t\taggregates[name] = make(map[string]float64)\n\t\t\t}\n\t\t\tif dm.Gauge != nil {\n\t\t\t\taggregates[name][value] += dm.Gauge.GetValue()\n\t\t\t}\n\t\t}\n\t\tif dm.Gauge != nil {\n\t\t\tentry.value = dm.Gauge.GetValue()\n\t\t}\n\t\tentries = append(entries, entry)\n\t}\n\n\tif len(entries) == 0 {\n\t\treturn \"  (no metrics collected)\"\n\t}\n\n\tvar sb strings.Builder\n\n\t// Output aggregates by label (sorted)\n\tsb.WriteString(\"Totals by label:\\n\")\n\tvar labelNames []string\n\tfor name := range aggregates {\n\t\tlabelNames = append(labelNames, name)\n\t}\n\tsort.Strings(labelNames)\n\n\tfor _, name := range labelNames {\n\t\tvalues := aggregates[name]\n\t\tvar pairs []string\n\t\tfor v, sum := range values {\n\t\t\tpairs = append(pairs, fmt.Sprintf(\"%s=%.0f\", v, sum))\n\t\t}\n\t\tsort.Strings(pairs)\n\t\tsb.WriteString(fmt.Sprintf(\"  %s: %s\\n\", name, strings.Join(pairs, \", \")))\n\t}\n\n\t// Output detailed metrics\n\tsb.WriteString(\"\\nAll metrics:\\n\")\n\tfor _, e := range entries {\n\t\tvar labels []string\n\t\tfor k, v := range e.labels {\n\t\t\tlabels = append(labels, fmt.Sprintf(\"%s=%q\", k, v))\n\t\t}\n\t\tsort.Strings(labels)\n\t\tsb.WriteString(fmt.Sprintf(\"  {%s} = %.2f\\n\", strings.Join(labels, \", \"), e.value))\n\t}\n\n\treturn sb.String()\n}\n"
  },
  {
    "path": "internal/testutils/mock_source.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage testutils\n\nimport (\n\t\"context\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/mock\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\n// MockSource returns mock endpoints.\ntype MockSource struct {\n\tmock.Mock\n\tendpoints []*endpoint.Endpoint\n}\n\nfunc NewMockSource(endpoints ...*endpoint.Endpoint) *MockSource {\n\tm := &MockSource{\n\t\tendpoints: endpoints,\n\t}\n\tm.On(\"Endpoints\").Return(endpoints, nil)\n\tm.On(\"AddEventHandler\", mock.AnythingOfType(\"*context.cancelCtx\")).Return()\n\treturn m\n}\n\n// Endpoints returns the desired mock endpoints.\nfunc (m *MockSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) {\n\targs := m.Called()\n\n\tendpoints := args.Get(0)\n\tif endpoints == nil {\n\t\treturn nil, args.Error(1)\n\t}\n\n\treturn endpoints.([]*endpoint.Endpoint), args.Error(1)\n}\n\n// AddEventHandler adds an event handler that should be triggered if something in source changes\nfunc (m *MockSource) AddEventHandler(ctx context.Context, handler func()) {\n\tm.Called(ctx)\n\tif handler == nil {\n\t\treturn\n\t}\n\tgo func() {\n\t\tticker := time.NewTicker(5 * time.Second)\n\t\tdefer ticker.Stop()\n\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn\n\t\t\tcase <-ticker.C:\n\t\t\t\thandler()\n\t\t\t}\n\t\t}\n\t}()\n}\n"
  },
  {
    "path": "kustomize/OWNERS",
    "content": "# See the OWNERS docs at https://go.k8s.io/owners\n\nlabels:\n- kustomize\n"
  },
  {
    "path": "kustomize/external-dns-clusterrole.yaml",
    "content": "apiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: external-dns\nrules:\n  - apiGroups: [\"\"]\n    resources: [\"pods\", \"services\"]\n    verbs: [\"get\", \"watch\", \"list\"]\n  - apiGroups: [\"discovery.k8s.io\"]\n    resources: [\"endpointslices\"]\n    verbs: [\"get\", \"watch\", \"list\"]\n  - apiGroups: [\"extensions\"]\n    resources: [\"ingresses\"]\n    verbs: [\"get\", \"watch\", \"list\"]\n  - apiGroups: [\"networking.k8s.io\"]\n    resources: [\"ingresses\"]\n    verbs: [\"get\", \"watch\", \"list\"]\n  - apiGroups: [\"\"]\n    resources: [\"nodes\"]\n    verbs: [\"watch\", \"list\"]\n"
  },
  {
    "path": "kustomize/external-dns-clusterrolebinding.yaml",
    "content": "apiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: external-dns-viewer\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: external-dns\nsubjects:\n  - kind: ServiceAccount\n    name: external-dns\n    namespace: default\n"
  },
  {
    "path": "kustomize/external-dns-deployment.yaml",
    "content": "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  strategy:\n    type: Recreate\n  selector:\n    matchLabels:\n      app: external-dns\n  template:\n    metadata:\n      labels:\n        app: external-dns\n    spec:\n      serviceAccountName: external-dns\n      containers:\n        - name: external-dns\n          image: registry.k8s.io/external-dns/external-dns\n          args:\n            - --source=service\n            - --source=ingress\n            - --registry=txt\n"
  },
  {
    "path": "kustomize/external-dns-serviceaccount.yaml",
    "content": "apiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: external-dns\n"
  },
  {
    "path": "kustomize/kustomization.yaml",
    "content": "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\n\nimages:\n  - name: registry.k8s.io/external-dns/external-dns\n    newTag: v0.20.0\n\nresources:\n  - ./external-dns-deployment.yaml\n  - ./external-dns-serviceaccount.yaml\n  - ./external-dns-clusterrole.yaml\n  - ./external-dns-clusterrolebinding.yaml\n"
  },
  {
    "path": "main.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage main\n\nimport (\n\t_ \"k8s.io/client-go/plugin/pkg/client/auth\"\n\n\t\"sigs.k8s.io/external-dns/controller\"\n)\n\nfunc main() {\n\tcontroller.Execute()\n}\n"
  },
  {
    "path": "mkdocs.yml",
    "content": "site_name: external-dns\nsite_author: external-dns maintainers\nrepo_name: kubernetes-sigs/external-dns\nrepo_url: https://github.com/kubernetes-sigs/external-dns/\n\ndocs_dir: .\n\nnav:\n  - README.md\n  - Chart:\n      - About: charts/external-dns/README.md\n      - Changelog: charts/external-dns/CHANGELOG.md\n  - About:\n      - FAQ: docs/faq.md\n      - Flags: docs/flags.md\n      - Out of Incubator: docs/20190708-external-dns-incubator.md\n      - Code of Conduct: code-of-conduct.md\n      - License: LICENSE.md\n      - Providers: docs/providers.md\n      - Version Update: docs/version-update-playbook.md\n  - Tutorials: docs/tutorials/*\n  - Annotations:\n      - About: docs/annotations/annotations.md\n  - Sources: docs/sources/*\n  - Registries:\n      - About: docs/registry/registry.md\n      - TXT: docs/registry/txt.md\n      - DynamoDB: docs/registry/dynamodb.md\n  - Advanced Topics:\n      - FQDN Templating: docs/advanced/fqdn-templating.md\n      - Import Records: docs/advanced/import-records.md\n      - Initial Design: docs/initial-design.md\n      - Kubernetes Events: docs/advanced/events.md\n      - Leader Election: docs/proposal/001-leader-election.md\n      - Monitoring: docs/monitoring/*\n      - MultiTarget: docs/proposal/multi-target.md\n      - NAT64: docs/advanced/nat64.md\n      - Rate Limits: docs/advanced/rate-limits.md\n      - TTL: docs/advanced/ttl.md\n      - Decisions: docs/proposal/0*.md\n      - Domain Filter: docs/advanced/domain-filter.md\n      - Configuration Precedence: docs/advanced/configuration-precedence.md\n      - Split Horizon DNS: docs/advanced/split-horizon.md\n  - Contributing:\n      - Kubernetes Contributions: CONTRIBUTING.md\n      - Release: docs/release.md\n      - Deprecation Policy: docs/deprecation.md\n      - docs/contributing/*\n\ntheme:\n  name: material\n  custom_dir: docs/overrides\n  features:\n    - content.code.annotate\n    - navigation.top\n    - navigation.tracking\n    - navigation.indexes\n    - navigation.instant\n    - navigation.tabs\n    - navigation.tabs.sticky\n\nextra:\n  version:\n    provider: mike\n\nmarkdown_extensions:\n  - meta\n  - tables\n  - toc:\n      permalink: true\n  - abbr\n  - extra\n  - admonition\n  - smarty\n  - nl2br\n  - mdx_truly_sane_lists:\n      nested_indent: 2\n  - attr_list\n  - def_list\n  - footnotes\n  - md_in_html\n  - pymdownx.arithmatex:\n      generic: true\n  - pymdownx.betterem:\n      smart_enable: all\n  - pymdownx.caret\n  - pymdownx.details\n  - pymdownx.emoji:\n      emoji_index: !!python/name:material.extensions.emoji.twemoji\n      emoji_generator: !!python/name:material.extensions.emoji.to_svg\n  - pymdownx.highlight:\n      use_pygments: true\n      anchor_linenums: true\n  - pymdownx.inlinehilite\n  - pymdownx.keys\n  - pymdownx.mark\n  - pymdownx.smartsymbols\n  - pymdownx.snippets\n  - pymdownx.superfences:\n      custom_fences:\n        - name: mermaid\n          class: mermaid\n          format: !!python/name:pymdownx.superfences.fence_code_format\n  - pymdownx.tabbed:\n      alternate_style: true\n  - pymdownx.tilde\n  - pymdownx.tasklist:\n      custom_checkbox: true\n\nplugins:\n  - same-dir\n  - search\n  - literate-nav\n  - git-revision-date-localized:\n      type: date\n      fallback_to_build_date: true\n  # https://mkdocs-macros-plugin.readthedocs.io/en/latest/\n  - macros:\n      include_dir: docs/snippets\n      # required, as default jinja markers are {{ and }}\n      # ref: https://mkdocs-macros-plugin.readthedocs.io/en/latest/rendering/#solution-5-altering-the-syntax-of-jinja2-for-mkdocs-macros\n      j2_block_start_string: '[[%'\n      j2_block_end_string: '%]]'\n      j2_variable_start_string: '[['\n      j2_variable_end_string: ']]'\n      j2_comment_start_string: '[[#'\n      j2_comment_end_string: '#]]'\n"
  },
  {
    "path": "pkg/OWNERS",
    "content": "# See the OWNERS docs at https://go.k8s.io/owners\n\nlabels:\n- pkg\n"
  },
  {
    "path": "pkg/apis/OWNERS",
    "content": "# See the OWNERS docs at https://go.k8s.io/owners\n\nlabels:\n- apis\n"
  },
  {
    "path": "pkg/apis/externaldns/constants.go",
    "content": "/*\nCopyright 2026 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage externaldns\n\nconst (\n\tRegistryTXT      = \"txt\"\n\tRegistryNoop     = \"noop\"\n\tRegistryDynamoDB = \"dynamodb\"\n\tRegistryAWSSD    = \"aws-sd\"\n\n\tProviderAkamai       = \"akamai\"\n\tProviderAlibabaCloud = \"alibabacloud\"\n\tProviderAWS          = \"aws\"\n\tProviderAWSSD        = \"aws-sd\"\n\tProviderAzure        = \"azure\"\n\tProviderAzureDNS     = \"azure-dns\"\n\tProviderAzurePrivate = \"azure-private-dns\"\n\tProviderCivo         = \"civo\"\n\tProviderCloudflare   = \"cloudflare\"\n\tProviderCoreDNS      = \"coredns\"\n\tProviderSkyDNS       = \"skydns\"\n\tProviderDNSimple     = \"dnsimple\"\n\tProviderExoscale     = \"exoscale\"\n\tProviderGandi        = \"gandi\"\n\tProviderGoDaddy      = \"godaddy\"\n\tProviderGoogle       = \"google\"\n\tProviderInMemory     = \"inmemory\"\n\tProviderLinode       = \"linode\"\n\tProviderNS1          = \"ns1\"\n\tProviderOCI          = \"oci\"\n\tProviderOVH          = \"ovh\"\n\tProviderPDNS         = \"pdns\"\n\tProviderPihole       = \"pihole\"\n\tProviderPlural       = \"plural\"\n\tProviderRFC2136      = \"rfc2136\"\n\tProviderScaleway     = \"scaleway\"\n\tProviderTransip      = \"transip\"\n\tProviderWebhook      = \"webhook\"\n)\n"
  },
  {
    "path": "pkg/apis/externaldns/types.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage externaldns\n\nimport (\n\t\"fmt\"\n\t\"reflect\"\n\t\"regexp\"\n\t\"strings\"\n\t\"time\"\n\n\t\"k8s.io/apimachinery/pkg/labels\"\n\n\t\"sigs.k8s.io/external-dns/internal/flags\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n\n\t\"github.com/alecthomas/kingpin/v2\"\n\t\"github.com/sirupsen/logrus\"\n)\n\nconst (\n\tpasswordMask = \"******\"\n)\n\n// Config is a project-wide configuration\ntype Config struct {\n\tAPIServerURL                                  string\n\tKubeConfig                                    string\n\tRequestTimeout                                time.Duration\n\tDefaultTargets                                []string\n\tGlooNamespaces                                []string\n\tSkipperRouteGroupVersion                      string\n\tSources                                       []string\n\tNamespace                                     string\n\tAnnotationFilter                              string\n\tAnnotationPrefix                              string\n\tLabelFilter                                   string\n\tIngressClassNames                             []string\n\tFQDNTemplate                                  string\n\tTargetTemplate                                string\n\tFQDNTargetTemplate                            string\n\tCombineFQDNAndAnnotation                      bool\n\tIgnoreHostnameAnnotation                      bool\n\tIgnoreNonHostNetworkPods                      bool\n\tIgnoreIngressTLSSpec                          bool\n\tIgnoreIngressRulesSpec                        bool\n\tListenEndpointEvents                          bool\n\tExposeInternalIPV6                            bool\n\tGatewayName                                   string\n\tGatewayNamespace                              string\n\tGatewayLabelFilter                            string\n\tCompatibility                                 string\n\tPodSourceDomain                               string\n\tPublishInternal                               bool\n\tPublishHostIP                                 bool\n\tAlwaysPublishNotReadyAddresses                bool\n\tConnectorSourceServer                         string\n\tProvider                                      string\n\tProviderCacheTime                             time.Duration\n\tGoogleProject                                 string\n\tGoogleBatchChangeSize                         int\n\tGoogleBatchChangeInterval                     time.Duration\n\tGoogleZoneVisibility                          string\n\tDomainFilter                                  []string\n\tDomainExclude                                 []string\n\tRegexDomainFilter                             *regexp.Regexp\n\tRegexDomainExclude                            *regexp.Regexp\n\tZoneNameFilter                                []string\n\tZoneIDFilter                                  []string\n\tTargetNetFilter                               []string\n\tExcludeTargetNets                             []string\n\tAlibabaCloudConfigFile                        string\n\tAlibabaCloudZoneType                          string\n\tAWSZoneType                                   string\n\tAWSZoneTagFilter                              []string\n\tAWSAssumeRole                                 string\n\tAWSProfiles                                   []string\n\tAWSAssumeRoleExternalID                       string `secure:\"yes\"`\n\tAWSBatchChangeSize                            int\n\tAWSBatchChangeSizeBytes                       int\n\tAWSBatchChangeSizeValues                      int\n\tAWSBatchChangeInterval                        time.Duration\n\tAWSEvaluateTargetHealth                       bool\n\tAWSAPIRetries                                 int\n\tAWSPreferCNAME                                bool\n\tAWSZoneCacheDuration                          time.Duration\n\tAWSSDServiceCleanup                           bool\n\tAWSSDCreateTag                                map[string]string\n\tAWSZoneMatchParent                            bool\n\tAWSDynamoDBRegion                             string\n\tAWSDynamoDBTable                              string\n\tAzureConfigFile                               string\n\tAzureResourceGroup                            string\n\tAzureSubscriptionID                           string\n\tAzureUserAssignedIdentityClientID             string\n\tAzureActiveDirectoryAuthorityHost             string\n\tAzureZonesCacheDuration                       time.Duration\n\tAzureMaxRetriesCount                          int\n\tBatchChangeSize                               int\n\tBatchChangeInterval                           time.Duration\n\tCloudflareProxied                             bool\n\tCloudflareCustomHostnames                     bool\n\tCloudflareDNSRecordsPerPage                   int\n\tCloudflareDNSRecordsComment                   string\n\tCloudflareCustomHostnamesMinTLSVersion        string\n\tCloudflareCustomHostnamesCertificateAuthority string\n\tCloudflareRegionalServices                    bool\n\tCloudflareRegionKey                           string\n\tCoreDNSPrefix                                 string\n\tCoreDNSStrictlyOwned                          bool\n\tAkamaiServiceConsumerDomain                   string\n\tAkamaiClientToken                             string\n\tAkamaiClientSecret                            string\n\tAkamaiAccessToken                             string\n\tAkamaiEdgercPath                              string\n\tAkamaiEdgercSection                           string\n\tOCIConfigFile                                 string\n\tOCICompartmentOCID                            string\n\tOCIAuthInstancePrincipal                      bool\n\tOCIZoneScope                                  string\n\tOCIZoneCacheDuration                          time.Duration\n\tInMemoryZones                                 []string\n\tOVHEndpoint                                   string\n\tOVHApiRateLimit                               int\n\tOVHEnableCNAMERelative                        bool\n\tPDNSServer                                    string\n\tPDNSServerID                                  string\n\tPDNSAPIKey                                    string `secure:\"yes\"`\n\tPDNSSkipTLSVerify                             bool\n\tTLSCA                                         string\n\tTLSClientCert                                 string\n\tTLSClientCertKey                              string\n\tPolicy                                        string\n\tRegistry                                      string\n\tTXTOwnerID                                    string\n\tTXTOwnerOld                                   string\n\tTXTPrefix                                     string\n\tTXTSuffix                                     string\n\tTXTEncryptEnabled                             bool\n\tTXTEncryptAESKey                              string `secure:\"yes\"`\n\tInterval                                      time.Duration\n\tMinEventSyncInterval                          time.Duration\n\tMinTTL                                        time.Duration\n\tOnce                                          bool\n\tDryRun                                        bool\n\tUpdateEvents                                  bool\n\tLogFormat                                     string\n\tMetricsAddress                                string\n\tLogLevel                                      string\n\tTXTCacheInterval                              time.Duration\n\tTXTWildcardReplacement                        string\n\tExoscaleEndpoint                              string\n\tExoscaleAPIKey                                string `secure:\"yes\"`\n\tExoscaleAPISecret                             string `secure:\"yes\"`\n\tExoscaleAPIEnvironment                        string\n\tExoscaleAPIZone                               string\n\tCRDSourceAPIVersion                           string\n\tCRDSourceKind                                 string\n\tServiceTypeFilter                             []string\n\tResolveServiceLoadBalancerHostname            bool\n\tRFC2136Host                                   []string\n\tRFC2136Port                                   int\n\tRFC2136Zone                                   []string\n\tRFC2136Insecure                               bool\n\tRFC2136GSSTSIG                                bool\n\tRFC2136CreatePTR                              bool\n\tRFC2136KerberosRealm                          string\n\tRFC2136KerberosUsername                       string\n\tRFC2136KerberosPassword                       string `secure:\"yes\"`\n\tRFC2136TSIGKeyName                            string\n\tRFC2136TSIGSecret                             string `secure:\"yes\"`\n\tRFC2136TSIGSecretAlg                          string\n\tRFC2136TAXFR                                  bool\n\tRFC2136MinTTL                                 time.Duration\n\tRFC2136LoadBalancingStrategy                  string\n\tRFC2136BatchChangeSize                        int\n\tRFC2136UseTLS                                 bool\n\tRFC2136SkipTLSVerify                          bool\n\tNS1Endpoint                                   string\n\tNS1IgnoreSSL                                  bool\n\tNS1MinTTLSeconds                              int\n\tTransIPAccountName                            string\n\tTransIPPrivateKeyFile                         string\n\tManagedDNSRecordTypes                         []string\n\tExcludeDNSRecordTypes                         []string\n\tGoDaddyAPIKey                                 string `secure:\"yes\"`\n\tGoDaddySecretKey                              string `secure:\"yes\"`\n\tGoDaddyTTL                                    int64\n\tGoDaddyOTE                                    bool\n\tOCPRouterName                                 string\n\tPiholeServer                                  string\n\tPiholePassword                                string `secure:\"yes\"`\n\tPiholeTLSInsecureSkipVerify                   bool\n\tPiholeApiVersion                              string\n\tPluralCluster                                 string\n\tPluralProvider                                string\n\tWebhookProviderURL                            string\n\tWebhookProviderReadTimeout                    time.Duration\n\tWebhookProviderWriteTimeout                   time.Duration\n\tWebhookServer                                 bool\n\tTraefikEnableLegacy                           bool\n\tTraefikDisableNew                             bool\n\tNAT64Networks                                 []string\n\tExcludeUnschedulable                          bool\n\tEmitEvents                                    []string\n\tForceDefaultTargets                           bool\n\tUnstructuredResources                         []string\n\tPreferAlias                                   bool\n}\n\nvar defaultConfig = &Config{\n\tAkamaiAccessToken:           \"\",\n\tAkamaiClientSecret:          \"\",\n\tAkamaiClientToken:           \"\",\n\tAkamaiEdgercPath:            \"\",\n\tAkamaiEdgercSection:         \"\",\n\tAkamaiServiceConsumerDomain: \"\",\n\tAlibabaCloudConfigFile:      \"/etc/kubernetes/alibaba-cloud.json\",\n\tAnnotationFilter:            \"\",\n\tAnnotationPrefix:            annotations.DefaultAnnotationPrefix,\n\tAPIServerURL:                \"\",\n\tAWSAPIRetries:               3,\n\tAWSAssumeRole:               \"\",\n\tAWSAssumeRoleExternalID:     \"\",\n\tAWSBatchChangeInterval:      time.Second,\n\tAWSBatchChangeSize:          1000,\n\tAWSBatchChangeSizeBytes:     32000,\n\tAWSBatchChangeSizeValues:    1000,\n\tAWSDynamoDBRegion:           \"\",\n\tAWSDynamoDBTable:            \"external-dns\",\n\tAWSEvaluateTargetHealth:     true,\n\tAWSPreferCNAME:              false,\n\tAWSSDCreateTag:              map[string]string{},\n\tAWSSDServiceCleanup:         false,\n\tAWSZoneCacheDuration:        0 * time.Second,\n\tAWSZoneMatchParent:          false,\n\tAWSZoneTagFilter:            []string{},\n\tAWSZoneType:                 \"\",\n\tAzureConfigFile:             \"/etc/kubernetes/azure.json\",\n\tAzureResourceGroup:          \"\",\n\tAzureSubscriptionID:         \"\",\n\tAzureZonesCacheDuration:     0 * time.Second,\n\tAzureMaxRetriesCount:        3,\n\tBatchChangeSize:             200,\n\tBatchChangeInterval:         time.Second,\n\tCloudflareCustomHostnamesCertificateAuthority: \"none\",\n\tCloudflareCustomHostnames:                     false,\n\tCloudflareCustomHostnamesMinTLSVersion:        \"1.0\",\n\tCloudflareDNSRecordsPerPage:                   100,\n\tCloudflareProxied:                             false,\n\tCloudflareRegionalServices:                    false,\n\tCloudflareRegionKey:                           \"earth\",\n\n\tCombineFQDNAndAnnotation:     false,\n\tCompatibility:                \"\",\n\tConnectorSourceServer:        \"localhost:8080\",\n\tCoreDNSPrefix:                \"/skydns/\",\n\tCoreDNSStrictlyOwned:         false,\n\tCRDSourceAPIVersion:          \"externaldns.k8s.io/v1alpha1\",\n\tCRDSourceKind:                \"DNSEndpoint\",\n\tDefaultTargets:               []string{},\n\tDomainFilter:                 []string{},\n\tDryRun:                       false,\n\tExcludeDNSRecordTypes:        []string{},\n\tDomainExclude:                []string{},\n\tExcludeTargetNets:            []string{},\n\tEmitEvents:                   []string{},\n\tExcludeUnschedulable:         true,\n\tExoscaleAPIEnvironment:       \"api\",\n\tExoscaleAPIKey:               \"\",\n\tExoscaleAPISecret:            \"\",\n\tExoscaleAPIZone:              \"ch-gva-2\",\n\tExposeInternalIPV6:           false,\n\tFQDNTemplate:                 \"\",\n\tTargetTemplate:               \"\",\n\tFQDNTargetTemplate:           \"\",\n\tGatewayLabelFilter:           \"\",\n\tGatewayName:                  \"\",\n\tGatewayNamespace:             \"\",\n\tGlooNamespaces:               []string{\"gloo-system\"},\n\tGoDaddyAPIKey:                \"\",\n\tGoDaddyOTE:                   false,\n\tGoDaddySecretKey:             \"\",\n\tGoDaddyTTL:                   600,\n\tGoogleBatchChangeInterval:    time.Second,\n\tGoogleBatchChangeSize:        1000,\n\tGoogleProject:                \"\",\n\tGoogleZoneVisibility:         \"\",\n\tIgnoreHostnameAnnotation:     false,\n\tIgnoreIngressRulesSpec:       false,\n\tIgnoreIngressTLSSpec:         false,\n\tIngressClassNames:            nil,\n\tInMemoryZones:                []string{},\n\tInterval:                     time.Minute,\n\tKubeConfig:                   \"\",\n\tLabelFilter:                  labels.Everything().String(),\n\tLogFormat:                    \"text\",\n\tLogLevel:                     logrus.InfoLevel.String(),\n\tManagedDNSRecordTypes:        []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME},\n\tMetricsAddress:               \":7979\",\n\tMinEventSyncInterval:         5 * time.Second,\n\tMinTTL:                       0,\n\tNamespace:                    \"\",\n\tNAT64Networks:                []string{},\n\tNS1Endpoint:                  \"\",\n\tNS1IgnoreSSL:                 false,\n\tOCIConfigFile:                \"/etc/kubernetes/oci.yaml\",\n\tOCIZoneCacheDuration:         0 * time.Second,\n\tOCIZoneScope:                 \"GLOBAL\",\n\tOnce:                         false,\n\tOVHApiRateLimit:              20,\n\tOVHEnableCNAMERelative:       false,\n\tOVHEndpoint:                  \"ovh-eu\",\n\tPDNSAPIKey:                   \"\",\n\tPDNSServer:                   \"http://localhost:8081\",\n\tPDNSServerID:                 \"localhost\",\n\tPDNSSkipTLSVerify:            false,\n\tPiholeApiVersion:             \"5\",\n\tPiholePassword:               \"\",\n\tPiholeServer:                 \"\",\n\tPiholeTLSInsecureSkipVerify:  false,\n\tPluralCluster:                \"\",\n\tPluralProvider:               \"\",\n\tPodSourceDomain:              \"\",\n\tPolicy:                       \"sync\",\n\tProvider:                     \"\",\n\tProviderCacheTime:            0,\n\tPublishHostIP:                false,\n\tPublishInternal:              false,\n\tRegexDomainExclude:           regexp.MustCompile(\"\"),\n\tRegexDomainFilter:            regexp.MustCompile(\"\"),\n\tRegistry:                     RegistryTXT,\n\tRequestTimeout:               time.Second * 30,\n\tRFC2136BatchChangeSize:       50,\n\tRFC2136GSSTSIG:               false,\n\tRFC2136Host:                  []string{\"\"},\n\tRFC2136Insecure:              false,\n\tRFC2136KerberosPassword:      \"\",\n\tRFC2136KerberosRealm:         \"\",\n\tRFC2136KerberosUsername:      \"\",\n\tRFC2136LoadBalancingStrategy: \"disabled\",\n\tRFC2136MinTTL:                0,\n\tRFC2136Port:                  0,\n\tRFC2136SkipTLSVerify:         false,\n\tRFC2136TAXFR:                 true,\n\tRFC2136TSIGKeyName:           \"\",\n\tRFC2136TSIGSecret:            \"\",\n\tRFC2136TSIGSecretAlg:         \"\",\n\tRFC2136UseTLS:                false,\n\tRFC2136Zone:                  []string{},\n\tServiceTypeFilter:            []string{},\n\tSkipperRouteGroupVersion:     \"zalando.org/v1\",\n\tSources:                      nil,\n\tTargetNetFilter:              []string{},\n\tTLSCA:                        \"\",\n\tTLSClientCert:                \"\",\n\tTLSClientCertKey:             \"\",\n\tTraefikEnableLegacy:          false,\n\tTraefikDisableNew:            false,\n\tTransIPAccountName:           \"\",\n\tTransIPPrivateKeyFile:        \"\",\n\tTXTCacheInterval:             0,\n\tTXTEncryptAESKey:             \"\",\n\tTXTEncryptEnabled:            false,\n\tTXTOwnerID:                   \"default\",\n\tTXTOwnerOld:                  \"\",\n\tTXTPrefix:                    \"\",\n\tTXTSuffix:                    \"\",\n\tTXTWildcardReplacement:       \"\",\n\tUpdateEvents:                 false,\n\tWebhookProviderReadTimeout:   5 * time.Second,\n\tWebhookProviderURL:           \"http://localhost:8888\",\n\tWebhookProviderWriteTimeout:  10 * time.Second,\n\tWebhookServer:                false,\n\tZoneIDFilter:                 []string{},\n\tForceDefaultTargets:          false,\n\tUnstructuredResources:        []string{},\n\tPreferAlias:                  false,\n}\n\nvar ProviderNames = []string{\n\tProviderAkamai,\n\tProviderAlibabaCloud,\n\tProviderAWS,\n\tProviderAWSSD,\n\tProviderAzure,\n\tProviderAzureDNS,\n\tProviderAzurePrivate,\n\tProviderCivo,\n\tProviderCloudflare,\n\tProviderCoreDNS,\n\tProviderDNSimple,\n\tProviderExoscale,\n\tProviderGandi,\n\tProviderGoDaddy,\n\tProviderGoogle,\n\tProviderInMemory,\n\tProviderLinode,\n\tProviderNS1,\n\tProviderOCI,\n\tProviderOVH,\n\tProviderPDNS,\n\tProviderPihole,\n\tProviderPlural,\n\tProviderRFC2136,\n\tProviderScaleway,\n\tProviderSkyDNS,\n\tProviderTransip,\n\tProviderWebhook,\n}\n\nvar allowedSources = []string{\n\t\"service\",\n\t\"ingress\",\n\t\"node\",\n\t\"pod\",\n\t\"gateway-httproute\",\n\t\"gateway-grpcroute\",\n\t\"gateway-tlsroute\",\n\t\"gateway-tcproute\",\n\t\"gateway-udproute\",\n\t\"istio-gateway\",\n\t\"istio-virtualservice\",\n\t\"contour-httpproxy\",\n\t\"gloo-proxy\",\n\t\"fake\",\n\t\"connector\",\n\t\"crd\",\n\t\"empty\",\n\t\"skipper-routegroup\",\n\t\"openshift-route\",\n\t\"ambassador-host\",\n\t\"kong-tcpingress\",\n\t\"f5-virtualserver\",\n\t\"f5-transportserver\",\n\t\"traefik-proxy\",\n\t\"unstructured\",\n}\n\n// NewConfig returns new Config object\nfunc NewConfig() *Config {\n\treturn &Config{\n\t\tAnnotationPrefix: annotations.DefaultAnnotationPrefix,\n\t\tAWSSDCreateTag:   map[string]string{},\n\t}\n}\n\nfunc (cfg *Config) String() string {\n\t// prevent logging of sensitive information\n\ttemp := *cfg\n\n\tt := reflect.TypeFor[Config]()\n\tfor i := 0; i < t.NumField(); i++ {\n\t\tf := t.Field(i)\n\t\tif val, ok := f.Tag.Lookup(\"secure\"); ok && val == \"yes\" {\n\t\t\tif f.Type.Kind() != reflect.String {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tv := reflect.ValueOf(&temp).Elem().Field(i)\n\t\t\tif v.String() != \"\" {\n\t\t\t\tv.SetString(passwordMask)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn fmt.Sprintf(\"%+v\", temp)\n}\n\n// allLogLevelsAsStrings returns all logrus levels as a list of strings\nfunc allLogLevelsAsStrings() []string {\n\tvar levels []string\n\tfor _, level := range logrus.AllLevels {\n\t\tlevels = append(levels, level.String())\n\t}\n\treturn levels\n}\n\n// ParseFlags adds and parses flags from command line\nfunc (cfg *Config) ParseFlags(args []string) error {\n\tif _, err := App(cfg).Parse(args); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc bindFlags(b flags.FlagBinder, cfg *Config) {\n\t// Flags related to Kubernetes\n\tb.StringVar(\"server\", \"The Kubernetes API server to connect to (default: auto-detect)\", defaultConfig.APIServerURL, &cfg.APIServerURL)\n\tb.StringVar(\"kubeconfig\", \"Retrieve target cluster configuration from a Kubernetes configuration file (default: auto-detect)\", defaultConfig.KubeConfig, &cfg.KubeConfig)\n\tb.DurationVar(\"request-timeout\", \"Request timeout when calling Kubernetes APIs. 0s means no timeout\", defaultConfig.RequestTimeout, &cfg.RequestTimeout)\n\tb.BoolVar(\"resolve-service-load-balancer-hostname\", \"Resolve the hostname of LoadBalancer-type Service object to IP addresses in order to create DNS A/AAAA records instead of CNAMEs\", false, &cfg.ResolveServiceLoadBalancerHostname)\n\tb.BoolVar(\"listen-endpoint-events\", \"Trigger a reconcile on changes to EndpointSlices, for Service source (default: false)\", false, &cfg.ListenEndpointEvents)\n\n\t// Flags related to Gloo\n\tb.StringsVar(\"gloo-namespace\", \"The Gloo Proxy namespace; specify multiple times for multiple namespaces. (default: gloo-system)\", []string{\"gloo-system\"}, &cfg.GlooNamespaces)\n\n\t// Flags related to Skipper RouteGroup\n\tb.StringVar(\"skipper-routegroup-groupversion\", \"The resource version for skipper routegroup\", defaultConfig.SkipperRouteGroupVersion, &cfg.SkipperRouteGroupVersion)\n\n\t// Flags related to processing source\n\tb.BoolVar(\"always-publish-not-ready-addresses\", \"Always publish also not ready addresses for headless services (optional)\", false, &cfg.AlwaysPublishNotReadyAddresses)\n\tb.StringVar(\"annotation-filter\", \"Filter resources queried for endpoints by annotation, using label selector semantics\", defaultConfig.AnnotationFilter, &cfg.AnnotationFilter)\n\tb.StringVar(\"annotation-prefix\", \"Annotation prefix for external-dns annotations (default: external-dns.alpha.kubernetes.io/)\", defaultConfig.AnnotationPrefix, &cfg.AnnotationPrefix)\n\tb.EnumVar(\"compatibility\", \"Process annotation semantics from legacy implementations (optional, options: mate, molecule, kops-dns-controller)\", defaultConfig.Compatibility, &cfg.Compatibility, \"\", \"mate\", \"molecule\", \"kops-dns-controller\")\n\tb.StringVar(\"connector-source-server\", \"The server to connect for connector source, valid only when using connector source\", defaultConfig.ConnectorSourceServer, &cfg.ConnectorSourceServer)\n\tb.StringVar(\"crd-source-apiversion\", \"API version of the CRD for crd source, e.g. `externaldns.k8s.io/v1alpha1`, valid only when using crd source\", defaultConfig.CRDSourceAPIVersion, &cfg.CRDSourceAPIVersion)\n\tb.StringVar(\"crd-source-kind\", \"Kind of the CRD for the crd source in API group and version specified by crd-source-apiversion\", defaultConfig.CRDSourceKind, &cfg.CRDSourceKind)\n\tb.StringsVar(\"default-targets\", \"Set globally default host/IP that will apply as a target instead of source addresses. Specify multiple times for multiple targets (optional)\", nil, &cfg.DefaultTargets)\n\tb.BoolVar(\"force-default-targets\", \"Force the application of --default-targets, overriding any targets provided by the source (DEPRECATED: This reverts to (improved) legacy behavior which allows empty CRD targets for migration to new state)\", defaultConfig.ForceDefaultTargets, &cfg.ForceDefaultTargets)\n\tb.BoolVar(\"prefer-alias\", \"When enabled, CNAME records will have the alias annotation set, signaling providers that support ALIAS records to use them instead of CNAMEs. Supported by: PowerDNS, AWS (with --aws-prefer-cname disabled)\", defaultConfig.PreferAlias, &cfg.PreferAlias)\n\tb.StringsVar(\"exclude-record-types\", \"Record types to exclude from management; specify multiple times to exclude many; (optional)\", nil, &cfg.ExcludeDNSRecordTypes)\n\tb.StringsVar(\"exclude-target-net\", \"Exclude target nets (optional)\", nil, &cfg.ExcludeTargetNets)\n\tb.BoolVar(\"exclude-unschedulable\", \"Exclude nodes that are considered unschedulable (default: true)\", defaultConfig.ExcludeUnschedulable, &cfg.ExcludeUnschedulable)\n\tb.BoolVar(\"expose-internal-ipv6\", \"When using the node source, expose internal IPv6 addresses (optional, default: false)\", false, &cfg.ExposeInternalIPV6)\n\tb.StringVar(\"gateway-label-filter\", \"Filter Gateways of Route endpoints via label selector (default: all gateways)\", defaultConfig.GatewayLabelFilter, &cfg.GatewayLabelFilter)\n\tb.StringVar(\"gateway-name\", \"Limit Gateways of Route endpoints to a specific name (default: all names)\", defaultConfig.GatewayName, &cfg.GatewayName)\n\tb.StringVar(\"gateway-namespace\", \"Limit Gateways of Route endpoints to a specific namespace (default: all namespaces)\", defaultConfig.GatewayNamespace, &cfg.GatewayNamespace)\n\tb.BoolVar(\"ignore-hostname-annotation\", \"Ignore hostname annotation when generating DNS names, valid only when --fqdn-template is set (default: false)\", false, &cfg.IgnoreHostnameAnnotation)\n\tb.BoolVar(\"ignore-ingress-rules-spec\", \"Ignore the spec.rules section in Ingress resources (default: false)\", false, &cfg.IgnoreIngressRulesSpec)\n\tb.BoolVar(\"ignore-ingress-tls-spec\", \"Ignore the spec.tls section in Ingress resources (default: false)\", false, &cfg.IgnoreIngressTLSSpec)\n\tb.BoolVar(\"ignore-non-host-network-pods\", \"Ignore pods not running on host network when using pod source (default: false)\", false, &cfg.IgnoreNonHostNetworkPods)\n\tb.StringsVar(\"ingress-class\", \"Require an Ingress to have this class name; specify multiple times to allow more than one class (optional; defaults to any class)\", nil, &cfg.IngressClassNames)\n\tb.StringVar(\"label-filter\", \"Filter resources queried for endpoints by label selector; currently supported by source types crd, gateway-httproute, gateway-grpcroute, gateway-tlsroute, gateway-tcproute, gateway-udproute, ingress, node, openshift-route, service and ambassador-host\", defaultConfig.LabelFilter, &cfg.LabelFilter)\n\tmanagedRecordTypesHelp := fmt.Sprintf(\"Record types to manage; specify multiple times to include many; (default: %s) (supported records: A, AAAA, CNAME, NS, SRV, TXT)\", strings.Join(defaultConfig.ManagedDNSRecordTypes, \",\"))\n\tb.StringsVar(\"managed-record-types\", managedRecordTypesHelp, defaultConfig.ManagedDNSRecordTypes, &cfg.ManagedDNSRecordTypes)\n\tb.StringVar(\"namespace\", \"Limit resources queried for endpoints to a specific namespace (default: all namespaces)\", defaultConfig.Namespace, &cfg.Namespace)\n\tb.StringsVar(\"nat64-networks\", \"Adding an A record for each AAAA record in NAT64-enabled networks; specify multiple times for multiple possible nets (optional)\", nil, &cfg.NAT64Networks)\n\tb.StringVar(\"openshift-router-name\", \"if source is openshift-route then you can pass the ingress controller name. Based on this name external-dns will select the respective router from the route status and map that routerCanonicalHostname to the route host while creating a CNAME record.\", defaultConfig.OCPRouterName, &cfg.OCPRouterName)\n\tb.StringVar(\"pod-source-domain\", \"Domain to use for pods records (optional)\", defaultConfig.PodSourceDomain, &cfg.PodSourceDomain)\n\tb.BoolVar(\"publish-host-ip\", \"Allow external-dns to publish host-ip for headless services (optional)\", false, &cfg.PublishHostIP)\n\tb.BoolVar(\"publish-internal-services\", \"Allow external-dns to publish DNS records for ClusterIP services (optional)\", false, &cfg.PublishInternal)\n\tb.StringsVar(\"service-type-filter\", \"The service types to filter by. Specify multiple times for multiple filters to be applied. (optional, default: all, expected: ClusterIP, NodePort, LoadBalancer or ExternalName)\", defaultConfig.ServiceTypeFilter, &cfg.ServiceTypeFilter)\n\tb.StringsVar(\"target-net-filter\", \"Limit possible targets by a net filter; specify multiple times for multiple possible nets (optional)\", nil, &cfg.TargetNetFilter)\n\tb.BoolVar(\"traefik-enable-legacy\", \"Enable legacy listeners on Resources under the traefik.containo.us API Group\", defaultConfig.TraefikEnableLegacy, &cfg.TraefikEnableLegacy)\n\tb.BoolVar(\"traefik-disable-new\", \"Disable listeners on Resources under the traefik.io API Group\", defaultConfig.TraefikDisableNew, &cfg.TraefikDisableNew)\n\n\tb.StringsVar(\"unstructured-resource\", \"When using the unstructured source, specify resources in resource.version.group format (e.g., virtualmachineinstances.v1.kubevirt.io, configmap.v1); specify multiple times for multiple resources\", nil, &cfg.UnstructuredResources)\n\tb.StringsVar(\"events-emit\", \"Events that should be emitted. Specify multiple times for multiple events support (optional, default: none, expected: RecordReady, RecordDeleted, RecordError)\", defaultConfig.EmitEvents, &cfg.EmitEvents)\n\tb.DurationVar(\"provider-cache-time\", \"The time to cache the DNS provider record list requests.\", defaultConfig.ProviderCacheTime, &cfg.ProviderCacheTime)\n\tb.StringsVar(\"domain-filter\", \"Limit possible target zones by a domain suffix; specify multiple times for multiple domains (optional)\", []string{\"\"}, &cfg.DomainFilter)\n\tb.StringsVar(\"exclude-domains\", \"Exclude subdomains (optional)\", []string{\"\"}, &cfg.DomainExclude)\n\tb.RegexpVar(\"regex-domain-filter\", \"Limit possible domains and target zones by a Regex filter; Overrides domain-filter (optional)\", defaultConfig.RegexDomainFilter, &cfg.RegexDomainFilter)\n\tb.RegexpVar(\"regex-domain-exclusion\", \"Regex filter that excludes domains and target zones matched by regex-domain-filter (optional)\", defaultConfig.RegexDomainExclude, &cfg.RegexDomainExclude)\n\tb.StringsVar(\"zone-name-filter\", \"Filter target zones by zone domain (For now, only AzureDNS provider is using this flag); specify multiple times for multiple zones (optional)\", []string{\"\"}, &cfg.ZoneNameFilter)\n\tb.StringsVar(\"zone-id-filter\", \"Filter target zones by hosted zone id; specify multiple times for multiple zones (optional)\", []string{\"\"}, &cfg.ZoneIDFilter)\n\tb.StringVar(\"google-project\", \"When using the Google provider, current project is auto-detected, when running on GCP. Specify other project with this. Must be specified when running outside GCP.\", defaultConfig.GoogleProject, &cfg.GoogleProject)\n\tb.IntVar(\"google-batch-change-size\", \"When using the Google provider, set the maximum number of changes that will be applied in each batch.\", defaultConfig.GoogleBatchChangeSize, &cfg.GoogleBatchChangeSize)\n\tb.DurationVar(\"google-batch-change-interval\", \"When using the Google provider, set the interval between batch changes.\", defaultConfig.GoogleBatchChangeInterval, &cfg.GoogleBatchChangeInterval)\n\tb.EnumVar(\"google-zone-visibility\", \"When using the Google provider, filter for zones with this visibility (optional, options: public, private)\", defaultConfig.GoogleZoneVisibility, &cfg.GoogleZoneVisibility, \"\", \"public\", \"private\")\n\tb.StringVar(\"alibaba-cloud-config-file\", \"When using the Alibaba Cloud provider, specify the Alibaba Cloud configuration file (required when --provider=alibabacloud)\", defaultConfig.AlibabaCloudConfigFile, &cfg.AlibabaCloudConfigFile)\n\tb.EnumVar(\"alibaba-cloud-zone-type\", \"When using the Alibaba Cloud provider, filter for zones of this type (optional, options: public, private)\", defaultConfig.AlibabaCloudZoneType, &cfg.AlibabaCloudZoneType, \"\", \"public\", \"private\")\n\tb.EnumVar(\"aws-zone-type\", \"When using the AWS provider, filter for zones of this type (optional, default: any, options: public, private)\", defaultConfig.AWSZoneType, &cfg.AWSZoneType, \"\", \"public\", \"private\")\n\tb.StringsVar(\"aws-zone-tags\", \"When using the AWS provider, filter for zones with these tags\", []string{\"\"}, &cfg.AWSZoneTagFilter)\n\tb.StringsVar(\"aws-profile\", \"When using the AWS provider, name of the profile to use\", []string{\"\"}, &cfg.AWSProfiles)\n\tb.StringVar(\"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)\", defaultConfig.AWSAssumeRole, &cfg.AWSAssumeRole)\n\tb.StringVar(\"aws-assume-role-external-id\", \"When using the AWS API and assuming a role then specify this external ID` (optional)\", defaultConfig.AWSAssumeRoleExternalID, &cfg.AWSAssumeRoleExternalID)\n\tb.IntVar(\"aws-batch-change-size\", \"When using the AWS provider, set the maximum number of changes that will be applied in each batch.\", defaultConfig.AWSBatchChangeSize, &cfg.AWSBatchChangeSize)\n\tb.IntVar(\"aws-batch-change-size-bytes\", \"When using the AWS provider, set the maximum byte size that will be applied in each batch.\", defaultConfig.AWSBatchChangeSizeBytes, &cfg.AWSBatchChangeSizeBytes)\n\tb.IntVar(\"aws-batch-change-size-values\", \"When using the AWS provider, set the maximum total record values that will be applied in each batch.\", defaultConfig.AWSBatchChangeSizeValues, &cfg.AWSBatchChangeSizeValues)\n\tb.DurationVar(\"aws-batch-change-interval\", \"When using the AWS provider, set the interval between batch changes.\", defaultConfig.AWSBatchChangeInterval, &cfg.AWSBatchChangeInterval)\n\tb.BoolVar(\"aws-evaluate-target-health\", \"When using the AWS provider, set whether to evaluate the health of a DNS target (default: enabled, disable with --no-aws-evaluate-target-health)\", defaultConfig.AWSEvaluateTargetHealth, &cfg.AWSEvaluateTargetHealth)\n\tb.IntVar(\"aws-api-retries\", \"When using the AWS API, set the maximum number of retries before giving up.\", defaultConfig.AWSAPIRetries, &cfg.AWSAPIRetries)\n\tb.BoolVar(\"aws-prefer-cname\", \"When using the AWS provider, prefer using CNAME instead of ALIAS (default: disabled)\", defaultConfig.AWSPreferCNAME, &cfg.AWSPreferCNAME)\n\tb.DurationVar(\"aws-zones-cache-duration\", \"When using the AWS provider, set the zones list cache TTL (0s to disable).\", defaultConfig.AWSZoneCacheDuration, &cfg.AWSZoneCacheDuration)\n\tb.BoolVar(\"aws-zone-match-parent\", \"Expand limit possible target by sub-domains (default: disabled)\", defaultConfig.AWSZoneMatchParent, &cfg.AWSZoneMatchParent)\n\tb.BoolVar(\"aws-sd-service-cleanup\", \"When using the AWS CloudMap provider, delete empty Services without endpoints (default: disabled)\", defaultConfig.AWSSDServiceCleanup, &cfg.AWSSDServiceCleanup)\n\tb.StringMapVar(\"aws-sd-create-tag\", \"When using the AWS CloudMap provider, add tag to created services. The flag can be used multiple times\", &cfg.AWSSDCreateTag)\n\tb.StringVar(\"azure-config-file\", \"When using the Azure provider, specify the Azure configuration file (required when --provider=azure)\", defaultConfig.AzureConfigFile, &cfg.AzureConfigFile)\n\tb.StringVar(\"azure-resource-group\", \"When using the Azure provider, override the Azure resource group to use (optional)\", defaultConfig.AzureResourceGroup, &cfg.AzureResourceGroup)\n\tb.StringVar(\"azure-subscription-id\", \"When using the Azure provider, override the Azure subscription to use (optional)\", defaultConfig.AzureSubscriptionID, &cfg.AzureSubscriptionID)\n\tb.StringVar(\"azure-user-assigned-identity-client-id\", \"When using the Azure provider, override the client id of user assigned identity in config file (optional)\", \"\", &cfg.AzureUserAssignedIdentityClientID)\n\tb.DurationVar(\"azure-zones-cache-duration\", \"When using the Azure provider, set the zones list cache TTL (0s to disable).\", defaultConfig.AzureZonesCacheDuration, &cfg.AzureZonesCacheDuration)\n\tb.IntVar(\"azure-maxretries-count\", \"When using the Azure provider, set the number of retries for API calls (When less than 0, it disables retries). (optional)\", defaultConfig.AzureMaxRetriesCount, &cfg.AzureMaxRetriesCount)\n\n\tb.IntVar(\"batch-change-size\", \"Set the maximum number of DNS record changes that will be submitted to the provider in each batch (optional)\", defaultConfig.BatchChangeSize, &cfg.BatchChangeSize)\n\tb.DurationVar(\"batch-change-interval\", \"Set the interval between batch changes (optional, default: 1s)\", defaultConfig.BatchChangeInterval, &cfg.BatchChangeInterval)\n\tb.BoolVar(\"cloudflare-proxied\", \"When using the Cloudflare provider, specify if the proxy mode must be enabled (default: disabled)\", false, &cfg.CloudflareProxied)\n\tb.BoolVar(\"cloudflare-custom-hostnames\", \"When using the Cloudflare provider, specify if the Custom Hostnames feature will be used. Requires \\\"Cloudflare for SaaS\\\" enabled. (default: disabled)\", false, &cfg.CloudflareCustomHostnames)\n\tb.EnumVar(\"cloudflare-custom-hostnames-min-tls-version\", \"When using the Cloudflare provider with the Custom Hostnames, specify which Minimum TLS Version will be used by default. (default: 1.0, options: 1.0, 1.1, 1.2, 1.3)\", \"1.0\", &cfg.CloudflareCustomHostnamesMinTLSVersion, \"1.0\", \"1.1\", \"1.2\", \"1.3\")\n\tb.EnumVar(\"cloudflare-custom-hostnames-certificate-authority\", \"When using the Cloudflare provider with the Custom Hostnames, specify which Certificate Authority will be used. A value of none indicates no Certificate Authority will be sent to the Cloudflare API (default: none, options: google, ssl_com, lets_encrypt, none)\", \"none\", &cfg.CloudflareCustomHostnamesCertificateAuthority, \"google\", \"ssl_com\", \"lets_encrypt\", \"none\")\n\tb.IntVar(\"cloudflare-dns-records-per-page\", \"When using the Cloudflare provider, specify how many DNS records listed per page, max possible 5,000 (default: 100)\", defaultConfig.CloudflareDNSRecordsPerPage, &cfg.CloudflareDNSRecordsPerPage)\n\tb.BoolVar(\"cloudflare-regional-services\", \"When using the Cloudflare provider, specify if Regional Services feature will be used (default: disabled)\", defaultConfig.CloudflareRegionalServices, &cfg.CloudflareRegionalServices)\n\tb.StringVar(\"cloudflare-region-key\", \"When using the Cloudflare provider, specify the default region for Regional Services. Any value other than an empty string will enable the Regional Services feature (optional)\", \"\", &cfg.CloudflareRegionKey)\n\tb.StringVar(\"cloudflare-record-comment\", \"When using the Cloudflare provider, specify the comment for the DNS records (default: '')\", \"\", &cfg.CloudflareDNSRecordsComment)\n\n\tb.StringVar(\"coredns-prefix\", \"When using the CoreDNS provider, specify the prefix name\", defaultConfig.CoreDNSPrefix, &cfg.CoreDNSPrefix)\n\tb.BoolVar(\"coredns-strictly-owned\", \"When using the CoreDNS provider, store and filter strictly by txt-owner-id using an extra field inside of the etcd service (default: false)\", defaultConfig.CoreDNSStrictlyOwned, &cfg.CoreDNSStrictlyOwned)\n\tb.StringVar(\"akamai-serviceconsumerdomain\", \"When using the Akamai provider, specify the base URL (required when --provider=akamai and edgerc-path not specified)\", defaultConfig.AkamaiServiceConsumerDomain, &cfg.AkamaiServiceConsumerDomain)\n\tb.StringVar(\"akamai-client-token\", \"When using the Akamai provider, specify the client token (required when --provider=akamai and edgerc-path not specified)\", defaultConfig.AkamaiClientToken, &cfg.AkamaiClientToken)\n\tb.StringVar(\"akamai-client-secret\", \"When using the Akamai provider, specify the client secret (required when --provider=akamai and edgerc-path not specified)\", defaultConfig.AkamaiClientSecret, &cfg.AkamaiClientSecret)\n\tb.StringVar(\"akamai-access-token\", \"When using the Akamai provider, specify the access token (required when --provider=akamai and edgerc-path not specified)\", defaultConfig.AkamaiAccessToken, &cfg.AkamaiAccessToken)\n\tb.StringVar(\"akamai-edgerc-path\", \"When using the Akamai provider, specify the .edgerc file path. Path must be reachable form invocation environment. (required when --provider=akamai and *-token, secret serviceconsumerdomain not specified)\", defaultConfig.AkamaiEdgercPath, &cfg.AkamaiEdgercPath)\n\tb.StringVar(\"akamai-edgerc-section\", \"When using the Akamai provider, specify the .edgerc file path (Optional when edgerc-path is specified)\", defaultConfig.AkamaiEdgercSection, &cfg.AkamaiEdgercSection)\n\tb.StringVar(\"oci-config-file\", \"When using the OCI provider, specify the OCI configuration file (required when --provider=oci\", defaultConfig.OCIConfigFile, &cfg.OCIConfigFile)\n\tb.StringVar(\"oci-compartment-ocid\", \"When using the OCI provider, specify the OCID of the OCI compartment containing all managed zones and records.  Required when using OCI IAM instance principal authentication.\", defaultConfig.OCICompartmentOCID, &cfg.OCICompartmentOCID)\n\tb.EnumVar(\"oci-zone-scope\", \"When using OCI provider, filter for zones with this scope (optional, options: GLOBAL, PRIVATE). Defaults to GLOBAL, setting to empty value will target both.\", defaultConfig.OCIZoneScope, &cfg.OCIZoneScope, \"\", \"GLOBAL\", \"PRIVATE\")\n\tb.BoolVar(\"oci-auth-instance-principal\", \"When using the OCI provider, specify whether OCI IAM instance principal authentication should be used (instead of key-based auth via the OCI config file).\", defaultConfig.OCIAuthInstancePrincipal, &cfg.OCIAuthInstancePrincipal)\n\tb.DurationVar(\"oci-zones-cache-duration\", \"When using the OCI provider, set the zones list cache TTL (0s to disable).\", defaultConfig.OCIZoneCacheDuration, &cfg.OCIZoneCacheDuration)\n\tb.StringsVar(\"inmemory-zone\", \"Provide a list of pre-configured zones for the inmemory provider; specify multiple times for multiple zones (optional)\", []string{\"\"}, &cfg.InMemoryZones)\n\tb.StringVar(\"ovh-endpoint\", \"When using the OVH provider, specify the endpoint (default: ovh-eu)\", defaultConfig.OVHEndpoint, &cfg.OVHEndpoint)\n\tb.IntVar(\"ovh-api-rate-limit\", \"When using the OVH provider, specify the API request rate limit, X operations by seconds (default: 20)\", defaultConfig.OVHApiRateLimit, &cfg.OVHApiRateLimit)\n\tb.BoolVar(\"ovh-enable-cname-relative\", \"When using the OVH provider, specify if CNAME should be treated as relative on target without final dot (default: false)\", defaultConfig.OVHEnableCNAMERelative, &cfg.OVHEnableCNAMERelative)\n\tb.StringVar(\"pdns-server\", \"When using the PowerDNS/PDNS provider, specify the URL to the pdns server (required when --provider=pdns)\", defaultConfig.PDNSServer, &cfg.PDNSServer)\n\tb.StringVar(\"pdns-server-id\", \"When using the PowerDNS/PDNS provider, specify the id of the server to retrieve. Should be `localhost` except when the server is behind a proxy (optional when --provider=pdns) (default: localhost)\", defaultConfig.PDNSServerID, &cfg.PDNSServerID)\n\tb.StringVar(\"pdns-api-key\", \"When using the PowerDNS/PDNS provider, specify the API key to use to authorize requests (required when --provider=pdns)\", defaultConfig.PDNSAPIKey, &cfg.PDNSAPIKey)\n\tb.BoolVar(\"pdns-skip-tls-verify\", \"When using the PowerDNS/PDNS provider, disable verification of any TLS certificates (optional when --provider=pdns) (default: false)\", defaultConfig.PDNSSkipTLSVerify, &cfg.PDNSSkipTLSVerify)\n\tb.StringVar(\"ns1-endpoint\", \"When using the NS1 provider, specify the URL of the API endpoint to target (default: https://api.nsone.net/v1/)\", defaultConfig.NS1Endpoint, &cfg.NS1Endpoint)\n\tb.BoolVar(\"ns1-ignoressl\", \"When using the NS1 provider, specify whether to verify the SSL certificate (default: false)\", defaultConfig.NS1IgnoreSSL, &cfg.NS1IgnoreSSL)\n\tb.IntVar(\"ns1-min-ttl\", \"Minimal TTL (in seconds) for records. This value will be used if the provided TTL for a service/ingress is lower than this.\", cfg.NS1MinTTLSeconds, &cfg.NS1MinTTLSeconds)\n\t// GoDaddy flags\n\tb.StringVar(\"godaddy-api-key\", \"When using the GoDaddy provider, specify the API Key (required when --provider=godaddy)\", defaultConfig.GoDaddyAPIKey, &cfg.GoDaddyAPIKey)\n\tb.StringVar(\"godaddy-api-secret\", \"When using the GoDaddy provider, specify the API secret (required when --provider=godaddy)\", defaultConfig.GoDaddySecretKey, &cfg.GoDaddySecretKey)\n\tb.Int64Var(\"godaddy-api-ttl\", \"TTL (in seconds) for records. This value will be used if the provided TTL for a service/ingress is not provided.\", cfg.GoDaddyTTL, &cfg.GoDaddyTTL)\n\tb.BoolVar(\"godaddy-api-ote\", \"When using the GoDaddy provider, use OTE api (optional, default: false, when --provider=godaddy)\", defaultConfig.GoDaddyOTE, &cfg.GoDaddyOTE)\n\n\t// Flags related to TLS communication\n\tb.StringVar(\"tls-ca\", \"When using TLS communication, the path to the certificate authority to verify server communications (optionally specify --tls-client-cert for two-way TLS)\", defaultConfig.TLSCA, &cfg.TLSCA)\n\tb.StringVar(\"tls-client-cert\", \"When using TLS communication, the path to the certificate to present as a client (not required for TLS)\", defaultConfig.TLSClientCert, &cfg.TLSClientCert)\n\tb.StringVar(\"tls-client-cert-key\", \"When using TLS communication, the path to the certificate key to use with the client certificate (not required for TLS)\", defaultConfig.TLSClientCertKey, &cfg.TLSClientCertKey)\n\n\t// Flags related to Exoscale provider\n\tb.StringVar(\"exoscale-apienv\", \"When using Exoscale provider, specify the API environment (optional)\", defaultConfig.ExoscaleAPIEnvironment, &cfg.ExoscaleAPIEnvironment)\n\tb.StringVar(\"exoscale-apizone\", \"When using Exoscale provider, specify the API Zone (optional)\", defaultConfig.ExoscaleAPIZone, &cfg.ExoscaleAPIZone)\n\tb.StringVar(\"exoscale-apikey\", \"Provide your API Key for the Exoscale provider\", defaultConfig.ExoscaleAPIKey, &cfg.ExoscaleAPIKey)\n\tb.StringVar(\"exoscale-apisecret\", \"Provide your API Secret for the Exoscale provider\", defaultConfig.ExoscaleAPISecret, &cfg.ExoscaleAPISecret)\n\n\t// Flags related to RFC2136 provider\n\tb.StringsVar(\"rfc2136-host\", \"When using the RFC2136 provider, specify the host of the DNS server (optionally specify multiple times when using --rfc2136-load-balancing-strategy)\", []string{defaultConfig.RFC2136Host[0]}, &cfg.RFC2136Host)\n\tb.IntVar(\"rfc2136-port\", \"When using the RFC2136 provider, specify the port of the DNS server\", defaultConfig.RFC2136Port, &cfg.RFC2136Port)\n\tb.StringsVar(\"rfc2136-zone\", \"When using the RFC2136 provider, specify zone entry of the DNS server to use (can be specified multiple times)\", nil, &cfg.RFC2136Zone)\n\tb.BoolVar(\"rfc2136-create-ptr\", \"When using the RFC2136 provider, enable PTR management\", defaultConfig.RFC2136CreatePTR, &cfg.RFC2136CreatePTR)\n\tb.BoolVar(\"rfc2136-insecure\", \"When using the RFC2136 provider, specify whether to attach TSIG or not (default: false, requires --rfc2136-tsig-keyname and rfc2136-tsig-secret)\", defaultConfig.RFC2136Insecure, &cfg.RFC2136Insecure)\n\tb.StringVar(\"rfc2136-tsig-keyname\", \"When using the RFC2136 provider, specify the TSIG key to attached to DNS messages (required when --rfc2136-insecure=false)\", defaultConfig.RFC2136TSIGKeyName, &cfg.RFC2136TSIGKeyName)\n\tb.StringVar(\"rfc2136-tsig-secret\", \"When using the RFC2136 provider, specify the TSIG (base64) value to attached to DNS messages (required when --rfc2136-insecure=false)\", defaultConfig.RFC2136TSIGSecret, &cfg.RFC2136TSIGSecret)\n\tb.StringVar(\"rfc2136-tsig-secret-alg\", \"When using the RFC2136 provider, specify the TSIG (base64) value to attached to DNS messages (required when --rfc2136-insecure=false)\", defaultConfig.RFC2136TSIGSecretAlg, &cfg.RFC2136TSIGSecretAlg)\n\tb.BoolVar(\"rfc2136-tsig-axfr\", \"When using the RFC2136 provider, specify the TSIG (base64) value to attached to DNS messages (required when --rfc2136-insecure=false)\", false, &cfg.RFC2136TAXFR)\n\tb.DurationVar(\"rfc2136-min-ttl\", \"When using the RFC2136 provider, specify minimal TTL (in duration format) for records. This value will be used if the provided TTL for a service/ingress is lower than this\", defaultConfig.RFC2136MinTTL, &cfg.RFC2136MinTTL)\n\tb.BoolVar(\"rfc2136-gss-tsig\", \"When using the RFC2136 provider, specify whether to use secure updates with GSS-TSIG using Kerberos (default: false, requires --rfc2136-kerberos-realm, --rfc2136-kerberos-username, and rfc2136-kerberos-password)\", defaultConfig.RFC2136GSSTSIG, &cfg.RFC2136GSSTSIG)\n\tb.StringVar(\"rfc2136-kerberos-username\", \"When using the RFC2136 provider with GSS-TSIG, specify the username of the user with permissions to update DNS records (required when --rfc2136-gss-tsig=true)\", defaultConfig.RFC2136KerberosUsername, &cfg.RFC2136KerberosUsername)\n\tb.StringVar(\"rfc2136-kerberos-password\", \"When using the RFC2136 provider with GSS-TSIG, specify the password of the user with permissions to update DNS records (required when --rfc2136-gss-tsig=true)\", defaultConfig.RFC2136KerberosPassword, &cfg.RFC2136KerberosPassword)\n\tb.StringVar(\"rfc2136-kerberos-realm\", \"When using the RFC2136 provider with GSS-TSIG, specify the realm of the user with permissions to update DNS records (required when --rfc2136-gss-tsig=true)\", defaultConfig.RFC2136KerberosRealm, &cfg.RFC2136KerberosRealm)\n\tb.IntVar(\"rfc2136-batch-change-size\", \"When using the RFC2136 provider, set the maximum number of changes that will be applied in each batch.\", defaultConfig.RFC2136BatchChangeSize, &cfg.RFC2136BatchChangeSize)\n\tb.BoolVar(\"rfc2136-use-tls\", \"When using the RFC2136 provider, communicate with name server over tls\", defaultConfig.RFC2136UseTLS, &cfg.RFC2136UseTLS)\n\tb.BoolVar(\"rfc2136-skip-tls-verify\", \"When using TLS with the RFC2136 provider, disable verification of any TLS certificates\", defaultConfig.RFC2136SkipTLSVerify, &cfg.RFC2136SkipTLSVerify)\n\tb.EnumVar(\"rfc2136-load-balancing-strategy\", \"When using the RFC2136 provider, specify the load balancing strategy (default: disabled, options: random, round-robin, disabled)\", defaultConfig.RFC2136LoadBalancingStrategy, &cfg.RFC2136LoadBalancingStrategy, \"random\", \"round-robin\", \"disabled\")\n\n\t// Flags related to TransIP provider\n\tb.StringVar(\"transip-account\", \"When using the TransIP provider, specify the account name (required when --provider=transip)\", defaultConfig.TransIPAccountName, &cfg.TransIPAccountName)\n\tb.StringVar(\"transip-keyfile\", \"When using the TransIP provider, specify the path to the private key file (required when --provider=transip)\", defaultConfig.TransIPPrivateKeyFile, &cfg.TransIPPrivateKeyFile)\n\n\t// Flags related to Pihole provider\n\tb.StringVar(\"pihole-server\", \"When using the Pihole provider, the base URL of the Pihole web server (required when --provider=pihole)\", defaultConfig.PiholeServer, &cfg.PiholeServer)\n\tb.StringVar(\"pihole-password\", \"When using the Pihole provider, the password to the server if it is protected\", defaultConfig.PiholePassword, &cfg.PiholePassword)\n\tb.BoolVar(\"pihole-tls-skip-verify\", \"When using the Pihole provider, disable verification of any TLS certificates\", defaultConfig.PiholeTLSInsecureSkipVerify, &cfg.PiholeTLSInsecureSkipVerify)\n\tb.StringVar(\"pihole-api-version\", \"When using the Pihole provider, specify the pihole API version (default: 5, options: 5, 6)\", defaultConfig.PiholeApiVersion, &cfg.PiholeApiVersion)\n\n\t// Flags related to the Plural provider\n\tb.StringVar(\"plural-cluster\", \"When using the plural provider, specify the cluster name you're running with\", defaultConfig.PluralCluster, &cfg.PluralCluster)\n\tb.StringVar(\"plural-provider\", \"When using the plural provider, specify the provider name you're running with\", defaultConfig.PluralProvider, &cfg.PluralProvider)\n\n\t// Flags related to policies\n\tb.EnumVar(\"policy\", \"Modify how DNS records are synchronized between sources and providers (default: sync, options: sync, upsert-only, create-only)\", defaultConfig.Policy, &cfg.Policy, \"sync\", \"upsert-only\", \"create-only\")\n\n\t// Flags related to the registry\n\tb.EnumVar(\"registry\", \"The registry implementation to use to keep track of DNS record ownership (default: txt, options: txt, noop, dynamodb, aws-sd)\", defaultConfig.Registry, &cfg.Registry, RegistryTXT, RegistryNoop, RegistryDynamoDB, RegistryAWSSD)\n\tb.StringVar(\"txt-owner-id\", \"When using the TXT or DynamoDB registry, a name that identifies this instance of ExternalDNS (default: default)\", defaultConfig.TXTOwnerID, &cfg.TXTOwnerID)\n\tb.StringVar(\"txt-prefix\", \"When using the TXT registry, a custom string that's prefixed to each ownership DNS record (optional). Could contain record type template like '%{record_type}-prefix-'. Mutual exclusive with txt-suffix!\", defaultConfig.TXTPrefix, &cfg.TXTPrefix)\n\tb.StringVar(\"txt-suffix\", \"When using the TXT registry, a custom string that's suffixed to the host portion of each ownership DNS record (optional). Could contain record type template like '-%{record_type}-suffix'. Mutual exclusive with txt-prefix!\", defaultConfig.TXTSuffix, &cfg.TXTSuffix)\n\tb.StringVar(\"txt-wildcard-replacement\", \"When using the TXT registry, a custom string that's used instead of an asterisk for TXT records corresponding to wildcard DNS records (optional)\", defaultConfig.TXTWildcardReplacement, &cfg.TXTWildcardReplacement)\n\tb.BoolVar(\"txt-encrypt-enabled\", \"When using the TXT registry, set if TXT records should be encrypted before stored (default: disabled)\", defaultConfig.TXTEncryptEnabled, &cfg.TXTEncryptEnabled)\n\tb.StringVar(\"txt-encrypt-aes-key\", \"When using the TXT registry, set TXT record decryption and encryption 32 byte aes key (required when --txt-encrypt=true)\", defaultConfig.TXTEncryptAESKey, &cfg.TXTEncryptAESKey)\n\tb.StringVar(\"migrate-from-txt-owner\", \"Old txt-owner-id that needs to be overwritten (default: default)\", defaultConfig.TXTOwnerOld, &cfg.TXTOwnerOld)\n\tb.StringVar(\"dynamodb-region\", \"When using the DynamoDB registry, the AWS region of the DynamoDB table (optional)\", cfg.AWSDynamoDBRegion, &cfg.AWSDynamoDBRegion)\n\tb.StringVar(\"dynamodb-table\", \"When using the DynamoDB registry, the name of the DynamoDB table (default: \\\"external-dns\\\")\", defaultConfig.AWSDynamoDBTable, &cfg.AWSDynamoDBTable)\n\n\t// Flags related to the main control loop\n\tb.DurationVar(\"txt-cache-interval\", \"The interval between cache synchronizations in duration format (default: disabled)\", defaultConfig.TXTCacheInterval, &cfg.TXTCacheInterval)\n\tb.DurationVar(\"interval\", \"The interval between two consecutive synchronizations in duration format (default: 1m)\", defaultConfig.Interval, &cfg.Interval)\n\tb.DurationVar(\"min-event-sync-interval\", \"The minimum interval between two consecutive synchronizations triggered from kubernetes events in duration format (default: 5s)\", defaultConfig.MinEventSyncInterval, &cfg.MinEventSyncInterval)\n\tb.BoolVar(\"once\", \"When enabled, exits the synchronization loop after the first iteration (default: disabled)\", defaultConfig.Once, &cfg.Once)\n\tb.BoolVar(\"dry-run\", \"When enabled, prints DNS record changes rather than actually performing them (default: disabled)\", defaultConfig.DryRun, &cfg.DryRun)\n\tb.BoolVar(\"events\", \"When enabled, in addition to running every interval, the reconciliation loop will get triggered when supported sources change (default: disabled)\", defaultConfig.UpdateEvents, &cfg.UpdateEvents)\n\tb.DurationVar(\"min-ttl\", \"Configure global TTL for records in duration format. This value is used when the TTL for a source is not set or set to 0. (optional; examples: 1m12s, 72s, 72)\", defaultConfig.MinTTL, &cfg.MinTTL)\n\n\t// Miscellaneous flags\n\tb.EnumVar(\"log-format\", \"The format in which log messages are printed (default: text, options: text, json)\", defaultConfig.LogFormat, &cfg.LogFormat, \"text\", \"json\")\n\tb.StringVar(\"metrics-address\", \"Specify where to serve the metrics and health check endpoint (default: :7979)\", defaultConfig.MetricsAddress, &cfg.MetricsAddress)\n\tb.EnumVar(\"log-level\", \"Set the level of logging. (default: info, options: panic, debug, info, warning, error, fatal)\", defaultConfig.LogLevel, &cfg.LogLevel, allLogLevelsAsStrings()...)\n\n\t// Webhook provider\n\tb.StringVar(\"webhook-provider-url\", \"The URL of the remote endpoint to call for the webhook provider (default: http://localhost:8888)\", defaultConfig.WebhookProviderURL, &cfg.WebhookProviderURL)\n\tb.DurationVar(\"webhook-provider-read-timeout\", \"The read timeout for the webhook provider in duration format (default: 5s)\", defaultConfig.WebhookProviderReadTimeout, &cfg.WebhookProviderReadTimeout)\n\tb.DurationVar(\"webhook-provider-write-timeout\", \"The write timeout for the webhook provider in duration format (default: 10s)\", defaultConfig.WebhookProviderWriteTimeout, &cfg.WebhookProviderWriteTimeout)\n\tb.BoolVar(\"webhook-server\", \"When enabled, runs as a webhook server instead of a controller. (default: false).\", defaultConfig.WebhookServer, &cfg.WebhookServer)\n\n\t// FQDN Templating\n\tb.BoolVar(\"combine-fqdn-annotation\", \"Combine FQDN template and Annotations instead of overwriting (default: false)\", false, &cfg.CombineFQDNAndAnnotation)\n\tb.StringVar(\"fqdn-template\", \"A templated string that's used to generate DNS names from sources that don't define a hostname themselves, or to add a hostname suffix when paired with the fake source (optional). Accepts comma separated list for multiple global FQDN.\", defaultConfig.FQDNTemplate, &cfg.FQDNTemplate)\n\tb.StringVar(\"target-template\", \"A templated string used to generate DNS targets (IP or hostname) from sources that support it (optional). Accepts comma separated list for multiple targets.\", defaultConfig.TargetTemplate, &cfg.TargetTemplate)\n\tb.StringVar(\"fqdn-target-template\", \"A template that returns host:target pairs (e.g., '{{range .Object.endpoints}}{{.targetRef.name}}.svc.example.com:{{index .addresses 0}},{{end}}'). Accepts comma separated list for multiple pairs.\", defaultConfig.FQDNTargetTemplate, &cfg.FQDNTargetTemplate)\n}\n\nfunc App(cfg *Config) *kingpin.Application {\n\tapp := kingpin.New(\"external-dns\", \"ExternalDNS synchronizes exposed Kubernetes Services and Ingresses with DNS providers.\\n\\nNote that all flags may be replaced with env vars - `--flag` -> `EXTERNAL_DNS_FLAG=1` or `--flag value` -> `EXTERNAL_DNS_FLAG=value`\")\n\tapp.Version(Version)\n\tapp.DefaultEnvars()\n\n\tbindFlags(flags.NewKingpinBinder(app), cfg)\n\n\t// Kingpin-only semantics: preserve Required/PlaceHolder and enum validation\n\t// that Kingpin provided before the flags were migrated into the binder.\n\tproviderHelp := \"The DNS provider where the DNS records will be created (required, options: \" + strings.Join(ProviderNames, \", \") + \")\"\n\tapp.Flag(\"provider\", providerHelp).Required().PlaceHolder(\"provider\").EnumVar(&cfg.Provider, ProviderNames...)\n\n\t// Reintroduce source enum/required validation in Kingpin to match previous behavior.\n\tsourceHelp := \"The resource types that are queried for endpoints; specify multiple times for multiple sources (required, options: \" + strings.Join(allowedSources, \", \") + \")\"\n\tapp.Flag(\"source\", sourceHelp).Required().PlaceHolder(\"source\").EnumsVar(&cfg.Sources, allowedSources...)\n\n\treturn app\n}\n"
  },
  {
    "path": "pkg/apis/externaldns/types_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage externaldns\n\nimport (\n\t\"regexp\"\n\t\"testing\"\n\t\"time\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/internal/flags\"\n\t\"sigs.k8s.io/external-dns/internal/testutils\"\n\n\t\"github.com/alecthomas/kingpin/v2\"\n\t\"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nvar (\n\tminimalConfig = &Config{\n\t\tAPIServerURL:                           \"\",\n\t\tKubeConfig:                             \"\",\n\t\tRequestTimeout:                         time.Second * 30,\n\t\tGlooNamespaces:                         []string{\"gloo-system\"},\n\t\tSkipperRouteGroupVersion:               \"zalando.org/v1\",\n\t\tSources:                                []string{\"service\"},\n\t\tNamespace:                              \"\",\n\t\tAnnotationPrefix:                       \"external-dns.alpha.kubernetes.io/\",\n\t\tFQDNTemplate:                           \"\",\n\t\tCompatibility:                          \"\",\n\t\tProvider:                               ProviderGoogle,\n\t\tGoogleProject:                          \"\",\n\t\tGoogleBatchChangeSize:                  1000,\n\t\tGoogleBatchChangeInterval:              time.Second,\n\t\tGoogleZoneVisibility:                   \"\",\n\t\tDomainFilter:                           []string{\"\"},\n\t\tDomainExclude:                          []string{\"\"},\n\t\tRegexDomainFilter:                      regexp.MustCompile(\"\"),\n\t\tRegexDomainExclude:                     regexp.MustCompile(\"\"),\n\t\tZoneNameFilter:                         []string{\"\"},\n\t\tZoneIDFilter:                           []string{\"\"},\n\t\tAlibabaCloudConfigFile:                 \"/etc/kubernetes/alibaba-cloud.json\",\n\t\tAWSZoneType:                            \"\",\n\t\tAWSZoneTagFilter:                       []string{\"\"},\n\t\tAWSZoneMatchParent:                     false,\n\t\tAWSAssumeRole:                          \"\",\n\t\tAWSAssumeRoleExternalID:                \"\",\n\t\tAWSBatchChangeSize:                     1000,\n\t\tAWSBatchChangeSizeBytes:                32000,\n\t\tAWSBatchChangeSizeValues:               1000,\n\t\tAWSBatchChangeInterval:                 time.Second,\n\t\tAWSEvaluateTargetHealth:                true,\n\t\tAWSAPIRetries:                          3,\n\t\tAWSPreferCNAME:                         false,\n\t\tAWSProfiles:                            []string{\"\"},\n\t\tAWSZoneCacheDuration:                   0 * time.Second,\n\t\tAWSSDServiceCleanup:                    false,\n\t\tAWSSDCreateTag:                         map[string]string{},\n\t\tAWSDynamoDBTable:                       \"external-dns\",\n\t\tAzureConfigFile:                        \"/etc/kubernetes/azure.json\",\n\t\tAzureResourceGroup:                     \"\",\n\t\tAzureSubscriptionID:                    \"\",\n\t\tAzureMaxRetriesCount:                   3,\n\t\tBatchChangeSize:                        200,\n\t\tBatchChangeInterval:                    time.Second,\n\t\tCloudflareProxied:                      false,\n\t\tCloudflareCustomHostnames:              false,\n\t\tCloudflareCustomHostnamesMinTLSVersion: \"1.0\",\n\t\tCloudflareCustomHostnamesCertificateAuthority: \"none\",\n\t\tCloudflareDNSRecordsPerPage:                   100,\n\t\tCloudflareDNSRecordsComment:                   \"\",\n\t\tCloudflareRegionKey:                           \"\",\n\t\tCoreDNSPrefix:                                 \"/skydns/\",\n\t\tAkamaiServiceConsumerDomain:                   \"\",\n\t\tAkamaiClientToken:                             \"\",\n\t\tAkamaiClientSecret:                            \"\",\n\t\tAkamaiAccessToken:                             \"\",\n\t\tAkamaiEdgercPath:                              \"\",\n\t\tAkamaiEdgercSection:                           \"\",\n\t\tOCIConfigFile:                                 \"/etc/kubernetes/oci.yaml\",\n\t\tOCIZoneScope:                                  \"GLOBAL\",\n\t\tOCIZoneCacheDuration:                          0 * time.Second,\n\t\tInMemoryZones:                                 []string{\"\"},\n\t\tOVHEndpoint:                                   \"ovh-eu\",\n\t\tOVHApiRateLimit:                               20,\n\t\tPDNSServer:                                    \"http://localhost:8081\",\n\t\tPDNSServerID:                                  \"localhost\",\n\t\tPDNSAPIKey:                                    \"\",\n\t\tPolicy:                                        \"sync\",\n\t\tRegistry:                                      \"txt\",\n\t\tTXTOwnerID:                                    \"default\",\n\t\tTXTOwnerOld:                                   \"\",\n\t\tTXTPrefix:                                     \"\",\n\t\tTXTCacheInterval:                              0,\n\t\tInterval:                                      time.Minute,\n\t\tMinEventSyncInterval:                          5 * time.Second,\n\t\tOnce:                                          false,\n\t\tDryRun:                                        false,\n\t\tUpdateEvents:                                  false,\n\t\tLogFormat:                                     \"text\",\n\t\tMetricsAddress:                                \":7979\",\n\t\tLogLevel:                                      logrus.InfoLevel.String(),\n\t\tConnectorSourceServer:                         \"localhost:8080\",\n\t\tExoscaleAPIEnvironment:                        \"api\",\n\t\tExoscaleAPIZone:                               \"ch-gva-2\",\n\t\tExoscaleAPIKey:                                \"\",\n\t\tExoscaleAPISecret:                             \"\",\n\t\tCRDSourceAPIVersion:                           \"externaldns.k8s.io/v1alpha1\",\n\t\tCRDSourceKind:                                 \"DNSEndpoint\",\n\t\tTransIPAccountName:                            \"\",\n\t\tTransIPPrivateKeyFile:                         \"\",\n\t\tManagedDNSRecordTypes:                         []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME},\n\t\tRFC2136BatchChangeSize:                        50,\n\t\tRFC2136Host:                                   []string{\"\"},\n\t\tRFC2136LoadBalancingStrategy:                  \"disabled\",\n\t\tOCPRouterName:                                 \"default\",\n\t\tPiholeApiVersion:                              \"5\",\n\t\tWebhookProviderURL:                            \"http://localhost:8888\",\n\t\tWebhookProviderReadTimeout:                    5 * time.Second,\n\t\tWebhookProviderWriteTimeout:                   10 * time.Second,\n\t\tExcludeUnschedulable:                          true,\n\t}\n\n\toverriddenConfig = &Config{\n\t\tAPIServerURL:                           \"http://127.0.0.1:8080\",\n\t\tKubeConfig:                             \"/some/path\",\n\t\tRequestTimeout:                         time.Second * 77,\n\t\tGlooNamespaces:                         []string{\"gloo-not-system\", \"gloo-second-system\"},\n\t\tSkipperRouteGroupVersion:               \"zalando.org/v2\",\n\t\tSources:                                []string{\"service\", \"ingress\", \"connector\"},\n\t\tNamespace:                              \"namespace\",\n\t\tAnnotationPrefix:                       \"external-dns.alpha.kubernetes.io/\",\n\t\tIgnoreHostnameAnnotation:               true,\n\t\tIgnoreNonHostNetworkPods:               true,\n\t\tIgnoreIngressTLSSpec:                   true,\n\t\tIgnoreIngressRulesSpec:                 true,\n\t\tFQDNTemplate:                           \"{{.Name}}.service.example.com\",\n\t\tCompatibility:                          \"mate\",\n\t\tProvider:                               ProviderGoogle,\n\t\tGoogleProject:                          \"project\",\n\t\tGoogleBatchChangeSize:                  100,\n\t\tGoogleBatchChangeInterval:              time.Second * 2,\n\t\tGoogleZoneVisibility:                   \"private\",\n\t\tDomainFilter:                           []string{\"example.org\", \"company.com\"},\n\t\tDomainExclude:                          []string{\"xapi.example.org\", \"xapi.company.com\"},\n\t\tRegexDomainFilter:                      regexp.MustCompile(\"(example\\\\.org|company\\\\.com)$\"),\n\t\tRegexDomainExclude:                     regexp.MustCompile(\"xapi\\\\.(example\\\\.org|company\\\\.com)$\"),\n\t\tZoneNameFilter:                         []string{\"yapi.example.org\", \"yapi.company.com\"},\n\t\tZoneIDFilter:                           []string{\"/hostedzone/ZTST1\", \"/hostedzone/ZTST2\"},\n\t\tTargetNetFilter:                        []string{\"10.0.0.0/9\", \"10.1.0.0/9\"},\n\t\tExcludeTargetNets:                      []string{\"1.0.0.0/9\", \"1.1.0.0/9\"},\n\t\tAlibabaCloudConfigFile:                 \"/etc/kubernetes/alibaba-cloud.json\",\n\t\tAWSZoneType:                            \"private\",\n\t\tAWSZoneTagFilter:                       []string{\"tag=foo\"},\n\t\tAWSZoneMatchParent:                     true,\n\t\tAWSAssumeRole:                          \"some-other-role\",\n\t\tAWSAssumeRoleExternalID:                \"pg2000\",\n\t\tAWSBatchChangeSize:                     100,\n\t\tAWSBatchChangeSizeBytes:                16000,\n\t\tAWSBatchChangeSizeValues:               100,\n\t\tAWSBatchChangeInterval:                 time.Second * 2,\n\t\tAWSEvaluateTargetHealth:                false,\n\t\tAWSAPIRetries:                          13,\n\t\tAWSPreferCNAME:                         true,\n\t\tAWSProfiles:                            []string{\"profile1\", \"profile2\"},\n\t\tAWSZoneCacheDuration:                   10 * time.Second,\n\t\tAWSSDServiceCleanup:                    true,\n\t\tAWSSDCreateTag:                         map[string]string{\"key1\": \"value1\", \"key2\": \"value2\"},\n\t\tAWSDynamoDBTable:                       \"custom-table\",\n\t\tAzureConfigFile:                        \"azure.json\",\n\t\tAzureResourceGroup:                     \"arg\",\n\t\tAzureSubscriptionID:                    \"arg\",\n\t\tAzureMaxRetriesCount:                   4,\n\t\tBatchChangeSize:                        200,\n\t\tBatchChangeInterval:                    time.Second,\n\t\tCloudflareProxied:                      true,\n\t\tCloudflareCustomHostnames:              true,\n\t\tCloudflareCustomHostnamesMinTLSVersion: \"1.3\",\n\t\tCloudflareCustomHostnamesCertificateAuthority: \"google\",\n\t\tCloudflareDNSRecordsPerPage:                   5000,\n\t\tCloudflareRegionalServices:                    true,\n\t\tCloudflareRegionKey:                           \"us\",\n\t\tCoreDNSPrefix:                                 \"/coredns/\",\n\t\tAkamaiServiceConsumerDomain:                   \"oooo-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx.luna.akamaiapis.net\",\n\t\tAkamaiClientToken:                             \"o184671d5307a388180fbf7f11dbdf46\",\n\t\tAkamaiClientSecret:                            \"o184671d5307a388180fbf7f11dbdf46\",\n\t\tAkamaiAccessToken:                             \"o184671d5307a388180fbf7f11dbdf46\",\n\t\tAkamaiEdgercPath:                              \"/home/test/.edgerc\",\n\t\tAkamaiEdgercSection:                           \"default\",\n\t\tOCIConfigFile:                                 \"oci.yaml\",\n\t\tOCIZoneScope:                                  \"PRIVATE\",\n\t\tOCIZoneCacheDuration:                          30 * time.Second,\n\t\tInMemoryZones:                                 []string{\"example.org\", \"company.com\"},\n\t\tOVHEndpoint:                                   \"ovh-ca\",\n\t\tOVHApiRateLimit:                               42,\n\t\tPDNSServer:                                    \"http://ns.example.com:8081\",\n\t\tPDNSServerID:                                  \"localhost\",\n\t\tPDNSAPIKey:                                    \"some-secret-key\",\n\t\tPDNSSkipTLSVerify:                             true,\n\t\tTLSCA:                                         \"/path/to/ca.crt\",\n\t\tTLSClientCert:                                 \"/path/to/cert.pem\",\n\t\tTLSClientCertKey:                              \"/path/to/key.pem\",\n\t\tPodSourceDomain:                               \"example.org\",\n\t\tPolicy:                                        \"upsert-only\",\n\t\tRegistry:                                      \"noop\",\n\t\tTXTOwnerID:                                    \"owner-1\",\n\t\tTXTPrefix:                                     \"associated-txt-record\",\n\t\tTXTOwnerOld:                                   \"old-owner\",\n\t\tTXTCacheInterval:                              12 * time.Hour,\n\t\tInterval:                                      10 * time.Minute,\n\t\tMinEventSyncInterval:                          50 * time.Second,\n\t\tMinTTL:                                        40 * time.Second,\n\t\tOnce:                                          true,\n\t\tDryRun:                                        true,\n\t\tUpdateEvents:                                  true,\n\t\tLogFormat:                                     \"json\",\n\t\tMetricsAddress:                                \"127.0.0.1:9099\",\n\t\tLogLevel:                                      logrus.DebugLevel.String(),\n\t\tConnectorSourceServer:                         \"localhost:8081\",\n\t\tExoscaleAPIEnvironment:                        \"api1\",\n\t\tExoscaleAPIZone:                               \"zone1\",\n\t\tExoscaleAPIKey:                                \"1\",\n\t\tExoscaleAPISecret:                             \"2\",\n\t\tCRDSourceAPIVersion:                           \"test.k8s.io/v1alpha1\",\n\t\tCRDSourceKind:                                 \"Endpoint\",\n\t\tNS1Endpoint:                                   \"https://api.example.com/v1\",\n\t\tNS1IgnoreSSL:                                  true,\n\t\tTransIPAccountName:                            \"transip\",\n\t\tTransIPPrivateKeyFile:                         \"/path/to/transip.key\",\n\t\tManagedDNSRecordTypes:                         []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME, endpoint.RecordTypeNS},\n\t\tRFC2136BatchChangeSize:                        100,\n\t\tRFC2136Host:                                   []string{\"rfc2136-host1\", \"rfc2136-host2\"},\n\t\tRFC2136LoadBalancingStrategy:                  \"round-robin\",\n\t\tPiholeApiVersion:                              \"6\",\n\t\tWebhookProviderURL:                            \"http://localhost:8888\",\n\t\tWebhookProviderReadTimeout:                    5 * time.Second,\n\t\tWebhookProviderWriteTimeout:                   10 * time.Second,\n\t\tExcludeUnschedulable:                          false,\n\t}\n)\n\nfunc TestParseFlags(t *testing.T) {\n\tfor _, ti := range []struct {\n\t\ttitle    string\n\t\targs     []string\n\t\tenvVars  map[string]string\n\t\texpected func(*Config)\n\t}{\n\t\t{\n\t\t\ttitle: \"default config with minimal flags defined\",\n\t\t\targs: []string{\n\t\t\t\t\"--source=service\",\n\t\t\t\t\"--provider=google\",\n\t\t\t\t\"--openshift-router-name=default\",\n\t\t\t},\n\t\t\tenvVars: map[string]string{},\n\t\t\texpected: func(cfg *Config) {\n\t\t\t\tassert.Equal(t, minimalConfig, cfg)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"validate bool flags work as expected\",\n\t\t\targs: []string{\n\t\t\t\t\"--source=service\",\n\t\t\t\t\"--provider=google\",\n\t\t\t\t\"--aws-evaluate-target-health\",\n\t\t\t\t\"--exclude-unschedulable\",\n\t\t\t},\n\t\t\tenvVars: map[string]string{},\n\t\t\texpected: func(cfg *Config) {\n\t\t\t\tassert.True(t, cfg.AWSEvaluateTargetHealth)\n\t\t\t\tassert.True(t, cfg.ExcludeUnschedulable)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"validate negation flags work as expected\",\n\t\t\targs: []string{\n\t\t\t\t\"--source=service\",\n\t\t\t\t\"--provider=aws\",\n\t\t\t\t\"--no-aws-evaluate-target-health\",\n\t\t\t\t\"--no-exclude-unschedulable\",\n\t\t\t},\n\t\t\tenvVars: map[string]string{},\n\t\t\texpected: func(cfg *Config) {\n\t\t\t\tassert.False(t, cfg.AWSEvaluateTargetHealth)\n\t\t\t\tassert.False(t, cfg.ExcludeUnschedulable)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"override everything via flags\",\n\t\t\targs: []string{\n\t\t\t\t\"--server=http://127.0.0.1:8080\",\n\t\t\t\t\"--kubeconfig=/some/path\",\n\t\t\t\t\"--request-timeout=77s\",\n\t\t\t\t\"--gloo-namespace=gloo-not-system\",\n\t\t\t\t\"--gloo-namespace=gloo-second-system\",\n\t\t\t\t\"--skipper-routegroup-groupversion=zalando.org/v2\",\n\t\t\t\t\"--source=service\",\n\t\t\t\t\"--source=ingress\",\n\t\t\t\t\"--source=connector\",\n\t\t\t\t\"--namespace=namespace\",\n\t\t\t\t\"--fqdn-template={{.Name}}.service.example.com\",\n\t\t\t\t\"--ignore-non-host-network-pods\",\n\t\t\t\t\"--ignore-hostname-annotation\",\n\t\t\t\t\"--ignore-ingress-tls-spec\",\n\t\t\t\t\"--ignore-ingress-rules-spec\",\n\t\t\t\t\"--compatibility=mate\",\n\t\t\t\t\"--provider=google\",\n\t\t\t\t\"--google-project=project\",\n\t\t\t\t\"--google-batch-change-size=100\",\n\t\t\t\t\"--google-batch-change-interval=2s\",\n\t\t\t\t\"--google-zone-visibility=private\",\n\t\t\t\t\"--azure-config-file=azure.json\",\n\t\t\t\t\"--azure-resource-group=arg\",\n\t\t\t\t\"--azure-subscription-id=arg\",\n\t\t\t\t\"--azure-maxretries-count=4\",\n\t\t\t\t\"--cloudflare-proxied\",\n\t\t\t\t\"--cloudflare-custom-hostnames\",\n\t\t\t\t\"--cloudflare-custom-hostnames-min-tls-version=1.3\",\n\t\t\t\t\"--cloudflare-custom-hostnames-certificate-authority=google\",\n\t\t\t\t\"--cloudflare-dns-records-per-page=5000\",\n\t\t\t\t\"--cloudflare-regional-services\",\n\t\t\t\t\"--cloudflare-region-key=us\",\n\t\t\t\t\"--coredns-prefix=/coredns/\",\n\t\t\t\t\"--akamai-serviceconsumerdomain=oooo-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx.luna.akamaiapis.net\",\n\t\t\t\t\"--akamai-client-token=o184671d5307a388180fbf7f11dbdf46\",\n\t\t\t\t\"--akamai-client-secret=o184671d5307a388180fbf7f11dbdf46\",\n\t\t\t\t\"--akamai-access-token=o184671d5307a388180fbf7f11dbdf46\",\n\t\t\t\t\"--akamai-edgerc-path=/home/test/.edgerc\",\n\t\t\t\t\"--akamai-edgerc-section=default\",\n\t\t\t\t\"--inmemory-zone=example.org\",\n\t\t\t\t\"--inmemory-zone=company.com\",\n\t\t\t\t\"--ovh-endpoint=ovh-ca\",\n\t\t\t\t\"--ovh-api-rate-limit=42\",\n\t\t\t\t\"--pdns-server=http://ns.example.com:8081\",\n\t\t\t\t\"--pdns-server-id=localhost\",\n\t\t\t\t\"--pdns-api-key=some-secret-key\",\n\t\t\t\t\"--pdns-skip-tls-verify\",\n\t\t\t\t\"--oci-config-file=oci.yaml\",\n\t\t\t\t\"--oci-zone-scope=PRIVATE\",\n\t\t\t\t\"--oci-zones-cache-duration=30s\",\n\t\t\t\t\"--tls-ca=/path/to/ca.crt\",\n\t\t\t\t\"--tls-client-cert=/path/to/cert.pem\",\n\t\t\t\t\"--tls-client-cert-key=/path/to/key.pem\",\n\t\t\t\t\"--pod-source-domain=example.org\",\n\t\t\t\t\"--domain-filter=example.org\",\n\t\t\t\t\"--domain-filter=company.com\",\n\t\t\t\t\"--exclude-domains=xapi.example.org\",\n\t\t\t\t\"--exclude-domains=xapi.company.com\",\n\t\t\t\t\"--regex-domain-filter=(example\\\\.org|company\\\\.com)$\",\n\t\t\t\t\"--regex-domain-exclusion=xapi\\\\.(example\\\\.org|company\\\\.com)$\",\n\t\t\t\t\"--zone-name-filter=yapi.example.org\",\n\t\t\t\t\"--zone-name-filter=yapi.company.com\",\n\t\t\t\t\"--zone-id-filter=/hostedzone/ZTST1\",\n\t\t\t\t\"--zone-id-filter=/hostedzone/ZTST2\",\n\t\t\t\t\"--target-net-filter=10.0.0.0/9\",\n\t\t\t\t\"--target-net-filter=10.1.0.0/9\",\n\t\t\t\t\"--exclude-target-net=1.0.0.0/9\",\n\t\t\t\t\"--exclude-target-net=1.1.0.0/9\",\n\t\t\t\t\"--aws-zone-type=private\",\n\t\t\t\t\"--aws-zone-tags=tag=foo\",\n\t\t\t\t\"--aws-zone-match-parent\",\n\t\t\t\t\"--aws-assume-role=some-other-role\",\n\t\t\t\t\"--aws-assume-role-external-id=pg2000\",\n\t\t\t\t\"--aws-batch-change-size=100\",\n\t\t\t\t\"--aws-batch-change-size-bytes=16000\",\n\t\t\t\t\"--aws-batch-change-size-values=100\",\n\t\t\t\t\"--aws-batch-change-interval=2s\",\n\t\t\t\t\"--aws-api-retries=13\",\n\t\t\t\t\"--aws-prefer-cname\",\n\t\t\t\t\"--aws-profile=profile1\",\n\t\t\t\t\"--aws-profile=profile2\",\n\t\t\t\t\"--aws-zones-cache-duration=10s\",\n\t\t\t\t\"--aws-sd-service-cleanup\",\n\t\t\t\t\"--aws-sd-create-tag=key1=value1\",\n\t\t\t\t\"--aws-sd-create-tag=key2=value2\",\n\t\t\t\t\"--no-aws-evaluate-target-health\",\n\t\t\t\t\"--pihole-api-version=6\",\n\t\t\t\t\"--policy=upsert-only\",\n\t\t\t\t\"--registry=noop\",\n\t\t\t\t\"--txt-owner-id=owner-1\",\n\t\t\t\t\"--migrate-from-txt-owner=old-owner\",\n\t\t\t\t\"--txt-prefix=associated-txt-record\",\n\t\t\t\t\"--txt-cache-interval=12h\",\n\t\t\t\t\"--dynamodb-table=custom-table\",\n\t\t\t\t\"--interval=10m\",\n\t\t\t\t\"--min-event-sync-interval=50s\",\n\t\t\t\t\"--min-ttl=40s\",\n\t\t\t\t\"--once\",\n\t\t\t\t\"--dry-run\",\n\t\t\t\t\"--events\",\n\t\t\t\t\"--log-format=json\",\n\t\t\t\t\"--metrics-address=127.0.0.1:9099\",\n\t\t\t\t\"--log-level=debug\",\n\t\t\t\t\"--connector-source-server=localhost:8081\",\n\t\t\t\t\"--exoscale-apienv=api1\",\n\t\t\t\t\"--exoscale-apizone=zone1\",\n\t\t\t\t\"--exoscale-apikey=1\",\n\t\t\t\t\"--exoscale-apisecret=2\",\n\t\t\t\t\"--crd-source-apiversion=test.k8s.io/v1alpha1\",\n\t\t\t\t\"--crd-source-kind=Endpoint\",\n\t\t\t\t\"--ns1-endpoint=https://api.example.com/v1\",\n\t\t\t\t\"--ns1-ignoressl\",\n\t\t\t\t\"--transip-account=transip\",\n\t\t\t\t\"--transip-keyfile=/path/to/transip.key\",\n\t\t\t\t\"--managed-record-types=A\",\n\t\t\t\t\"--managed-record-types=AAAA\",\n\t\t\t\t\"--managed-record-types=CNAME\",\n\t\t\t\t\"--managed-record-types=NS\",\n\t\t\t\t\"--no-exclude-unschedulable\",\n\t\t\t\t\"--rfc2136-batch-change-size=100\",\n\t\t\t\t\"--rfc2136-load-balancing-strategy=round-robin\",\n\t\t\t\t\"--rfc2136-host=rfc2136-host1\",\n\t\t\t\t\"--rfc2136-host=rfc2136-host2\",\n\t\t\t\t\"--batch-change-size=200\",\n\t\t\t},\n\t\t\tenvVars: map[string]string{},\n\t\t\texpected: func(cfg *Config) {\n\t\t\t\tassert.Equal(t, overriddenConfig, cfg)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"override everything via environment variables\",\n\t\t\targs:  []string{},\n\t\t\tenvVars: map[string]string{\n\t\t\t\t\"EXTERNAL_DNS_SERVER\":                                            \"http://127.0.0.1:8080\",\n\t\t\t\t\"EXTERNAL_DNS_KUBECONFIG\":                                        \"/some/path\",\n\t\t\t\t\"EXTERNAL_DNS_REQUEST_TIMEOUT\":                                   \"77s\",\n\t\t\t\t\"EXTERNAL_DNS_CONTOUR_LOAD_BALANCER\":                             \"heptio-contour-other/contour-other\",\n\t\t\t\t\"EXTERNAL_DNS_GLOO_NAMESPACE\":                                    \"gloo-not-system\\ngloo-second-system\",\n\t\t\t\t\"EXTERNAL_DNS_SKIPPER_ROUTEGROUP_GROUPVERSION\":                   \"zalando.org/v2\",\n\t\t\t\t\"EXTERNAL_DNS_SOURCE\":                                            \"service\\ningress\\nconnector\",\n\t\t\t\t\"EXTERNAL_DNS_NAMESPACE\":                                         \"namespace\",\n\t\t\t\t\"EXTERNAL_DNS_FQDN_TEMPLATE\":                                     \"{{.Name}}.service.example.com\",\n\t\t\t\t\"EXTERNAL_DNS_IGNORE_NON_HOST_NETWORK_PODS\":                      \"1\",\n\t\t\t\t\"EXTERNAL_DNS_IGNORE_HOSTNAME_ANNOTATION\":                        \"1\",\n\t\t\t\t\"EXTERNAL_DNS_IGNORE_INGRESS_TLS_SPEC\":                           \"1\",\n\t\t\t\t\"EXTERNAL_DNS_IGNORE_INGRESS_RULES_SPEC\":                         \"1\",\n\t\t\t\t\"EXTERNAL_DNS_COMPATIBILITY\":                                     \"mate\",\n\t\t\t\t\"EXTERNAL_DNS_PROVIDER\":                                          \"google\",\n\t\t\t\t\"EXTERNAL_DNS_GOOGLE_PROJECT\":                                    \"project\",\n\t\t\t\t\"EXTERNAL_DNS_GOOGLE_BATCH_CHANGE_SIZE\":                          \"100\",\n\t\t\t\t\"EXTERNAL_DNS_GOOGLE_BATCH_CHANGE_INTERVAL\":                      \"2s\",\n\t\t\t\t\"EXTERNAL_DNS_GOOGLE_ZONE_VISIBILITY\":                            \"private\",\n\t\t\t\t\"EXTERNAL_DNS_AZURE_CONFIG_FILE\":                                 \"azure.json\",\n\t\t\t\t\"EXTERNAL_DNS_AZURE_RESOURCE_GROUP\":                              \"arg\",\n\t\t\t\t\"EXTERNAL_DNS_AZURE_SUBSCRIPTION_ID\":                             \"arg\",\n\t\t\t\t\"EXTERNAL_DNS_AZURE_MAXRETRIES_COUNT\":                            \"4\",\n\t\t\t\t\"EXTERNAL_DNS_CLOUDFLARE_PROXIED\":                                \"1\",\n\t\t\t\t\"EXTERNAL_DNS_CLOUDFLARE_CUSTOM_HOSTNAMES\":                       \"1\",\n\t\t\t\t\"EXTERNAL_DNS_CLOUDFLARE_CUSTOM_HOSTNAMES_MIN_TLS_VERSION\":       \"1.3\",\n\t\t\t\t\"EXTERNAL_DNS_CLOUDFLARE_CUSTOM_HOSTNAMES_CERTIFICATE_AUTHORITY\": \"google\",\n\t\t\t\t\"EXTERNAL_DNS_CLOUDFLARE_DNS_RECORDS_PER_PAGE\":                   \"5000\",\n\t\t\t\t\"EXTERNAL_DNS_CLOUDFLARE_REGIONAL_SERVICES\":                      \"1\",\n\t\t\t\t\"EXTERNAL_DNS_CLOUDFLARE_REGION_KEY\":                             \"us\",\n\t\t\t\t\"EXTERNAL_DNS_COREDNS_PREFIX\":                                    \"/coredns/\",\n\t\t\t\t\"EXTERNAL_DNS_AKAMAI_SERVICECONSUMERDOMAIN\":                      \"oooo-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx.luna.akamaiapis.net\",\n\t\t\t\t\"EXTERNAL_DNS_AKAMAI_CLIENT_TOKEN\":                               \"o184671d5307a388180fbf7f11dbdf46\",\n\t\t\t\t\"EXTERNAL_DNS_AKAMAI_CLIENT_SECRET\":                              \"o184671d5307a388180fbf7f11dbdf46\",\n\t\t\t\t\"EXTERNAL_DNS_AKAMAI_ACCESS_TOKEN\":                               \"o184671d5307a388180fbf7f11dbdf46\",\n\t\t\t\t\"EXTERNAL_DNS_AKAMAI_EDGERC_PATH\":                                \"/home/test/.edgerc\",\n\t\t\t\t\"EXTERNAL_DNS_AKAMAI_EDGERC_SECTION\":                             \"default\",\n\t\t\t\t\"EXTERNAL_DNS_OCI_CONFIG_FILE\":                                   \"oci.yaml\",\n\t\t\t\t\"EXTERNAL_DNS_OCI_ZONE_SCOPE\":                                    \"PRIVATE\",\n\t\t\t\t\"EXTERNAL_DNS_OCI_ZONES_CACHE_DURATION\":                          \"30s\",\n\t\t\t\t\"EXTERNAL_DNS_INMEMORY_ZONE\":                                     \"example.org\\ncompany.com\",\n\t\t\t\t\"EXTERNAL_DNS_OVH_ENDPOINT\":                                      \"ovh-ca\",\n\t\t\t\t\"EXTERNAL_DNS_OVH_API_RATE_LIMIT\":                                \"42\",\n\t\t\t\t\"EXTERNAL_DNS_POD_SOURCE_DOMAIN\":                                 \"example.org\",\n\t\t\t\t\"EXTERNAL_DNS_DOMAIN_FILTER\":                                     \"example.org\\ncompany.com\",\n\t\t\t\t\"EXTERNAL_DNS_EXCLUDE_DOMAINS\":                                   \"xapi.example.org\\nxapi.company.com\",\n\t\t\t\t\"EXTERNAL_DNS_REGEX_DOMAIN_FILTER\":                               \"(example\\\\.org|company\\\\.com)$\",\n\t\t\t\t\"EXTERNAL_DNS_REGEX_DOMAIN_EXCLUSION\":                            \"xapi\\\\.(example\\\\.org|company\\\\.com)$\",\n\t\t\t\t\"EXTERNAL_DNS_TARGET_NET_FILTER\":                                 \"10.0.0.0/9\\n10.1.0.0/9\",\n\t\t\t\t\"EXTERNAL_DNS_EXCLUDE_TARGET_NET\":                                \"1.0.0.0/9\\n1.1.0.0/9\",\n\t\t\t\t\"EXTERNAL_DNS_PDNS_SERVER\":                                       \"http://ns.example.com:8081\",\n\t\t\t\t\"EXTERNAL_DNS_PDNS_ID\":                                           \"localhost\",\n\t\t\t\t\"EXTERNAL_DNS_PDNS_API_KEY\":                                      \"some-secret-key\",\n\t\t\t\t\"EXTERNAL_DNS_PDNS_SKIP_TLS_VERIFY\":                              \"1\",\n\t\t\t\t\"EXTERNAL_DNS_RDNS_ROOT_DOMAIN\":                                  \"lb.rancher.cloud\",\n\t\t\t\t\"EXTERNAL_DNS_TLS_CA\":                                            \"/path/to/ca.crt\",\n\t\t\t\t\"EXTERNAL_DNS_TLS_CLIENT_CERT\":                                   \"/path/to/cert.pem\",\n\t\t\t\t\"EXTERNAL_DNS_TLS_CLIENT_CERT_KEY\":                               \"/path/to/key.pem\",\n\t\t\t\t\"EXTERNAL_DNS_ZONE_NAME_FILTER\":                                  \"yapi.example.org\\nyapi.company.com\",\n\t\t\t\t\"EXTERNAL_DNS_ZONE_ID_FILTER\":                                    \"/hostedzone/ZTST1\\n/hostedzone/ZTST2\",\n\t\t\t\t\"EXTERNAL_DNS_AWS_ZONE_TYPE\":                                     \"private\",\n\t\t\t\t\"EXTERNAL_DNS_AWS_ZONE_TAGS\":                                     \"tag=foo\",\n\t\t\t\t\"EXTERNAL_DNS_AWS_ZONE_MATCH_PARENT\":                             \"true\",\n\t\t\t\t\"EXTERNAL_DNS_AWS_ASSUME_ROLE\":                                   \"some-other-role\",\n\t\t\t\t\"EXTERNAL_DNS_AWS_ASSUME_ROLE_EXTERNAL_ID\":                       \"pg2000\",\n\t\t\t\t\"EXTERNAL_DNS_AWS_BATCH_CHANGE_SIZE\":                             \"100\",\n\t\t\t\t\"EXTERNAL_DNS_AWS_BATCH_CHANGE_SIZE_BYTES\":                       \"16000\",\n\t\t\t\t\"EXTERNAL_DNS_AWS_BATCH_CHANGE_SIZE_VALUES\":                      \"100\",\n\t\t\t\t\"EXTERNAL_DNS_AWS_BATCH_CHANGE_INTERVAL\":                         \"2s\",\n\t\t\t\t\"EXTERNAL_DNS_AWS_EVALUATE_TARGET_HEALTH\":                        \"0\",\n\t\t\t\t\"EXTERNAL_DNS_AWS_API_RETRIES\":                                   \"13\",\n\t\t\t\t\"EXTERNAL_DNS_AWS_PREFER_CNAME\":                                  \"true\",\n\t\t\t\t\"EXTERNAL_DNS_AWS_PROFILE\":                                       \"profile1\\nprofile2\",\n\t\t\t\t\"EXTERNAL_DNS_AWS_ZONES_CACHE_DURATION\":                          \"10s\",\n\t\t\t\t\"EXTERNAL_DNS_AWS_SD_SERVICE_CLEANUP\":                            \"true\",\n\t\t\t\t\"EXTERNAL_DNS_AWS_SD_CREATE_TAG\":                                 \"key1=value1\\nkey2=value2\",\n\t\t\t\t\"EXTERNAL_DNS_DYNAMODB_TABLE\":                                    \"custom-table\",\n\t\t\t\t\"EXTERNAL_DNS_PIHOLE_API_VERSION\":                                \"6\",\n\t\t\t\t\"EXTERNAL_DNS_POLICY\":                                            \"upsert-only\",\n\t\t\t\t\"EXTERNAL_DNS_REGISTRY\":                                          \"noop\",\n\t\t\t\t\"EXTERNAL_DNS_TXT_OWNER_ID\":                                      \"owner-1\",\n\t\t\t\t\"EXTERNAL_DNS_TXT_PREFIX\":                                        \"associated-txt-record\",\n\t\t\t\t\"EXTERNAL_DNS_MIGRATE_FROM_TXT_OWNER\":                            \"old-owner\",\n\t\t\t\t\"EXTERNAL_DNS_TXT_CACHE_INTERVAL\":                                \"12h\",\n\t\t\t\t\"EXTERNAL_DNS_TXT_NEW_FORMAT_ONLY\":                               \"1\",\n\t\t\t\t\"EXTERNAL_DNS_INTERVAL\":                                          \"10m\",\n\t\t\t\t\"EXTERNAL_DNS_MIN_EVENT_SYNC_INTERVAL\":                           \"50s\",\n\t\t\t\t\"EXTERNAL_DNS_MIN_TTL\":                                           \"40s\",\n\t\t\t\t\"EXTERNAL_DNS_ONCE\":                                              \"1\",\n\t\t\t\t\"EXTERNAL_DNS_DRY_RUN\":                                           \"1\",\n\t\t\t\t\"EXTERNAL_DNS_EVENTS\":                                            \"1\",\n\t\t\t\t\"EXTERNAL_DNS_LOG_FORMAT\":                                        \"json\",\n\t\t\t\t\"EXTERNAL_DNS_METRICS_ADDRESS\":                                   \"127.0.0.1:9099\",\n\t\t\t\t\"EXTERNAL_DNS_LOG_LEVEL\":                                         \"debug\",\n\t\t\t\t\"EXTERNAL_DNS_CONNECTOR_SOURCE_SERVER\":                           \"localhost:8081\",\n\t\t\t\t\"EXTERNAL_DNS_EXOSCALE_APIENV\":                                   \"api1\",\n\t\t\t\t\"EXTERNAL_DNS_EXOSCALE_APIZONE\":                                  \"zone1\",\n\t\t\t\t\"EXTERNAL_DNS_EXOSCALE_APIKEY\":                                   \"1\",\n\t\t\t\t\"EXTERNAL_DNS_EXOSCALE_APISECRET\":                                \"2\",\n\t\t\t\t\"EXTERNAL_DNS_CRD_SOURCE_APIVERSION\":                             \"test.k8s.io/v1alpha1\",\n\t\t\t\t\"EXTERNAL_DNS_CRD_SOURCE_KIND\":                                   \"Endpoint\",\n\t\t\t\t\"EXTERNAL_DNS_NS1_ENDPOINT\":                                      \"https://api.example.com/v1\",\n\t\t\t\t\"EXTERNAL_DNS_NS1_IGNORESSL\":                                     \"1\",\n\t\t\t\t\"EXTERNAL_DNS_TRANSIP_ACCOUNT\":                                   \"transip\",\n\t\t\t\t\"EXTERNAL_DNS_TRANSIP_KEYFILE\":                                   \"/path/to/transip.key\",\n\t\t\t\t\"EXTERNAL_DNS_MANAGED_RECORD_TYPES\":                              \"A\\nAAAA\\nCNAME\\nNS\",\n\t\t\t\t\"EXTERNAL_DNS_EXCLUDE_UNSCHEDULABLE\":                             \"false\",\n\t\t\t\t\"EXTERNAL_DNS_RFC2136_BATCH_CHANGE_SIZE\":                         \"100\",\n\t\t\t\t\"EXTERNAL_DNS_RFC2136_LOAD_BALANCING_STRATEGY\":                   \"round-robin\",\n\t\t\t\t\"EXTERNAL_DNS_RFC2136_HOST\":                                      \"rfc2136-host1\\nrfc2136-host2\",\n\t\t\t\t\"EXTERNAL_DNS_BATCH_CHANGE_SIZE\":                                 \"200\",\n\t\t\t},\n\t\t\texpected: func(cfg *Config) {\n\t\t\t\tassert.Equal(t, overriddenConfig, cfg)\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(ti.title, func(t *testing.T) {\n\t\t\ttestutils.TestHelperEnvSetter(t, ti.envVars)\n\n\t\t\tcfg := NewConfig()\n\t\t\trequire.NoError(t, cfg.ParseFlags(ti.args))\n\t\t\tti.expected(cfg)\n\t\t})\n\t}\n}\n\nfunc TestParseFlagsCobraExecuteError(t *testing.T) {\n\tcfg := NewConfig()\n\terr := cfg.ParseFlags([]string{\"--cli-backend=cobra\", \"--unknown-flag\"})\n\trequire.Error(t, err)\n}\n\nfunc TestParseFlagsKingpinParseError(t *testing.T) {\n\tcfg := NewConfig()\n\terr := cfg.ParseFlags([]string{\"--unknown-flag\"})\n\trequire.Error(t, err)\n}\n\nfunc TestConfigStringMasksSecureFields(t *testing.T) {\n\tcfg := NewConfig()\n\tcfg.AWSAssumeRoleExternalID = \"sensitive-value\"\n\tcfg.GoDaddyAPIKey = \"another-secret\"\n\n\ts := cfg.String()\n\trequire.NotContains(t, s, \"sensitive-value\")\n\trequire.NotContains(t, s, \"another-secret\")\n\trequire.Contains(t, s, passwordMask)\n}\n\n// Default path should use kingpin and parse flags correctly\nfunc TestParseFlagsDefaultKingpin(t *testing.T) {\n\tt.Setenv(\"EXTERNAL_DNS_CLI\", \"\")\n\n\targs := []string{\n\t\t\"--provider=aws\",\n\t\t\"--source=service\",\n\t\t\"--source=ingress\",\n\t\t\"--server=http://127.0.0.1:8080\",\n\t\t\"--kubeconfig=/some/path\",\n\t\t\"--request-timeout=2s\",\n\t\t\"--namespace=ns\",\n\t\t\"--domain-filter=example.org\",\n\t\t\"--domain-filter=company.com\",\n\t\t\"--openshift-router-name=default\",\n\t}\n\n\tcfg := NewConfig()\n\trequire.NoError(t, cfg.ParseFlags(args))\n\n\tassert.Equal(t, ProviderAWS, cfg.Provider)\n\tassert.ElementsMatch(t, []string{\"service\", \"ingress\"}, cfg.Sources)\n\tassert.Equal(t, \"http://127.0.0.1:8080\", cfg.APIServerURL)\n\tassert.Equal(t, \"/some/path\", cfg.KubeConfig)\n\tassert.Equal(t, 2*time.Second, cfg.RequestTimeout)\n\tassert.Equal(t, \"ns\", cfg.Namespace)\n\tassert.ElementsMatch(t, []string{\"example.org\", \"company.com\"}, cfg.DomainFilter)\n\tassert.Equal(t, \"default\", cfg.OCPRouterName)\n}\n\n// When EXTERNAL_DNS_CLI=cobra is set, cobra path should parse the subset of\n// flags it currently binds, yielding parity with kingpin for those fields.\nfunc TestParseFlagsCobraSwitchParitySubset(t *testing.T) {\n\targs := []string{\n\t\t\"--provider=aws\",\n\t\t\"--source=service\",\n\t\t\"--source=ingress\",\n\t\t\"--server=http://127.0.0.1:8080\",\n\t\t\"--kubeconfig=/some/path\",\n\t\t\"--request-timeout=2s\",\n\t\t\"--namespace=ns\",\n\t\t\"--domain-filter=example.org\",\n\t\t\"--domain-filter=company.com\",\n\t\t\"--openshift-router-name=default\",\n\t}\n\n\t// Kingpin baseline\n\tcfgK := NewConfig()\n\trequire.NoError(t, cfgK.ParseFlags(args))\n\n\t// Cobra path via env switch\n\tt.Setenv(\"EXTERNAL_DNS_CLI\", \"cobra\")\n\tcfgC := NewConfig()\n\trequire.NoError(t, cfgC.ParseFlags(args))\n\n\t// Compare selected fields bound in cobra\n\tassert.Equal(t, cfgK.Provider, cfgC.Provider)\n\tassert.ElementsMatch(t, cfgK.Sources, cfgC.Sources)\n\tassert.Equal(t, cfgK.APIServerURL, cfgC.APIServerURL)\n\tassert.Equal(t, cfgK.KubeConfig, cfgC.KubeConfig)\n\tassert.Equal(t, cfgK.RequestTimeout, cfgC.RequestTimeout)\n\tassert.Equal(t, cfgK.Namespace, cfgC.Namespace)\n\tassert.ElementsMatch(t, cfgK.DomainFilter, cfgC.DomainFilter)\n\tassert.Equal(t, cfgK.OCPRouterName, cfgC.OCPRouterName)\n}\n\nfunc TestParseFlagsCliFlagOverridesEnv(t *testing.T) {\n\t// Env requests cobra; CLI flag forces kingpin.\n\tt.Setenv(\"EXTERNAL_DNS_CLI\", \"cobra\")\n\targs := []string{\n\t\t\"--provider=aws\",\n\t\t\"--source=service\",\n\t\t// Flag not bound in Cobra newCobraCommand path; will error if cobra is used.\n\t\t\"--log-format=json\",\n\t}\n\n\tcfg := NewConfig()\n\trequire.NoError(t, cfg.ParseFlags(args))\n\tassert.Equal(t, ProviderAWS, cfg.Provider)\n\tassert.ElementsMatch(t, []string{\"service\"}, cfg.Sources)\n\tassert.Equal(t, \"json\", cfg.LogFormat)\n}\n\nfunc TestParseFlagsCliFlagSeparatedValue(t *testing.T) {\n\t// Support \"--cli-backend\", \"cobra\" form as well.\n\targs := []string{\n\t\t\"--provider=aws\",\n\t\t\"--source=service\",\n\t}\n\tcfg := NewConfig()\n\trequire.NoError(t, cfg.ParseFlags(args))\n\tassert.Equal(t, ProviderAWS, cfg.Provider)\n\tassert.ElementsMatch(t, []string{\"service\"}, cfg.Sources)\n}\n\nfunc TestPasswordsNotLogged(t *testing.T) {\n\tcfg := Config{\n\t\tPDNSAPIKey:        \"pdns-api-key\",\n\t\tRFC2136TSIGSecret: \"tsig-secret\",\n\t}\n\n\ts := cfg.String()\n\n\tassert.NotContains(t, s, \"pdns-api-key\")\n\tassert.NotContains(t, s, \"tsig-secret\")\n}\n\n// Additional assertions to cover previously unasserted flags. These focus on\n// exercising Kingpin flag bindings for a wide set of providers/features.\n// parseCfg builds a Config by parsing base flags plus any extras.\nfunc parseCfg(t *testing.T, extra ...string) *Config {\n\tt.Helper()\n\tcfg := NewConfig()\n\targs := append([]string{\"--provider=google\", \"--source=service\"}, extra...)\n\trequire.NoError(t, cfg.ParseFlags(args))\n\treturn cfg\n}\n\nfunc TestParseFlagsAlibabaCloud(t *testing.T) {\n\tt.Parallel()\n\tcfg := parseCfg(t,\n\t\t\"--alibaba-cloud-config-file=/etc/kubernetes/alibaba-override.json\",\n\t\t\"--alibaba-cloud-zone-type=private\",\n\t)\n\tassert.Equal(t, \"/etc/kubernetes/alibaba-override.json\", cfg.AlibabaCloudConfigFile)\n\tassert.Equal(t, \"private\", cfg.AlibabaCloudZoneType)\n}\n\nfunc TestParseFlagsPublishingAndFilters(t *testing.T) {\n\tt.Parallel()\n\tcfg := parseCfg(t,\n\t\t\"--always-publish-not-ready-addresses\",\n\t\t\"--annotation-filter=key=value\",\n\t\t\"--combine-fqdn-annotation\",\n\t\t\"--default-targets=1.2.3.4\",\n\t\t\"--default-targets=5.6.7.8\",\n\t\t\"--exclude-record-types=TXT\",\n\t\t\"--exclude-record-types=CNAME\",\n\t\t\"--expose-internal-ipv6\",\n\t\t\"--force-default-targets\",\n\t\t\"--ingress-class=nginx\",\n\t\t\"--ingress-class=internal\",\n\t\t\"--label-filter=environment=prod\",\n\t\t\"--nat64-networks=64:ff9b::/96\",\n\t\t\"--nat64-networks=64:ff9b:1::/48\",\n\t\t\"--publish-host-ip\",\n\t\t\"--publish-internal-services\",\n\t\t\"--resolve-service-load-balancer-hostname\",\n\t\t\"--service-type-filter=ClusterIP\",\n\t\t\"--service-type-filter=NodePort\",\n\t\t\"--events-emit=RecordReady\",\n\t\t\"--events-emit=RecordDeleted\",\n\t)\n\tassert.True(t, cfg.AlwaysPublishNotReadyAddresses)\n\tassert.Equal(t, \"key=value\", cfg.AnnotationFilter)\n\tassert.True(t, cfg.CombineFQDNAndAnnotation)\n\tassert.ElementsMatch(t, []string{\"1.2.3.4\", \"5.6.7.8\"}, cfg.DefaultTargets)\n\tassert.ElementsMatch(t, []string{\"TXT\", \"CNAME\"}, cfg.ExcludeDNSRecordTypes)\n\tassert.True(t, cfg.ExposeInternalIPV6)\n\tassert.True(t, cfg.ForceDefaultTargets)\n\tassert.ElementsMatch(t, []string{\"nginx\", \"internal\"}, cfg.IngressClassNames)\n\tassert.Equal(t, \"environment=prod\", cfg.LabelFilter)\n\tassert.ElementsMatch(t, []string{\"64:ff9b::/96\", \"64:ff9b:1::/48\"}, cfg.NAT64Networks)\n\tassert.True(t, cfg.PublishHostIP)\n\tassert.True(t, cfg.PublishInternal)\n\tassert.True(t, cfg.ResolveServiceLoadBalancerHostname)\n\tassert.ElementsMatch(t, []string{\"ClusterIP\", \"NodePort\"}, cfg.ServiceTypeFilter)\n\tassert.ElementsMatch(t, []string{\"RecordReady\", \"RecordDeleted\"}, cfg.EmitEvents)\n}\n\nfunc TestParseFlagsGateway(t *testing.T) {\n\tt.Parallel()\n\tcfg := parseCfg(t,\n\t\t\"--gateway-label-filter=app=gateway\",\n\t\t\"--gateway-name=gw-1\",\n\t\t\"--gateway-namespace=gw-ns\",\n\t)\n\tassert.Equal(t, \"app=gateway\", cfg.GatewayLabelFilter)\n\tassert.Equal(t, \"gw-1\", cfg.GatewayName)\n\tassert.Equal(t, \"gw-ns\", cfg.GatewayNamespace)\n}\n\nfunc TestParseFlagsAzure(t *testing.T) {\n\tt.Parallel()\n\tcfg := parseCfg(t,\n\t\t\"--azure-user-assigned-identity-client-id=00000000-0000-0000-0000-000000000000\",\n\t\t\"--azure-zones-cache-duration=30s\",\n\t)\n\tassert.Equal(t, \"00000000-0000-0000-0000-000000000000\", cfg.AzureUserAssignedIdentityClientID)\n\tassert.Equal(t, 30*time.Second, cfg.AzureZonesCacheDuration)\n}\n\nfunc TestParseFlagsCloudflare(t *testing.T) {\n\tt.Parallel()\n\tcfg := parseCfg(t, \"--cloudflare-record-comment=managed-by-external-dns\")\n\tassert.Equal(t, \"managed-by-external-dns\", cfg.CloudflareDNSRecordsComment)\n}\n\nfunc TestParseFlagsNS1(t *testing.T) {\n\tt.Parallel()\n\tcfg := parseCfg(t, \"--ns1-min-ttl=60\")\n\tassert.Equal(t, 60, cfg.NS1MinTTLSeconds)\n}\n\nfunc TestParseFlagsOVH(t *testing.T) {\n\tt.Parallel()\n\tcfg := parseCfg(t, \"--ovh-enable-cname-relative\")\n\tassert.True(t, cfg.OVHEnableCNAMERelative)\n}\n\nfunc TestParseFlagsPihole(t *testing.T) {\n\tt.Parallel()\n\tcfg := parseCfg(t,\n\t\t\"--pihole-server=https://pi.example\",\n\t\t\"--pihole-password=pw\",\n\t\t\"--pihole-tls-skip-verify\",\n\t)\n\tassert.Equal(t, \"https://pi.example\", cfg.PiholeServer)\n\tassert.Equal(t, \"pw\", cfg.PiholePassword)\n\tassert.True(t, cfg.PiholeTLSInsecureSkipVerify)\n}\n\nfunc TestParseFlagsOCI(t *testing.T) {\n\tt.Parallel()\n\tcfg := parseCfg(t,\n\t\t\"--oci-auth-instance-principal\",\n\t\t\"--oci-compartment-ocid=ocid1.compartment.oc1..aaaa\",\n\t)\n\tassert.True(t, cfg.OCIAuthInstancePrincipal)\n\tassert.Equal(t, \"ocid1.compartment.oc1..aaaa\", cfg.OCICompartmentOCID)\n}\n\nfunc TestParseFlagsPlural(t *testing.T) {\n\tt.Parallel()\n\tcfg := parseCfg(t,\n\t\t\"--plural-cluster=mycluster\",\n\t\t\"--plural-provider=aws\",\n\t)\n\tassert.Equal(t, \"mycluster\", cfg.PluralCluster)\n\tassert.Equal(t, \"aws\", cfg.PluralProvider)\n}\n\nfunc TestParseFlagsProviderCacheAndDynamoDB(t *testing.T) {\n\tt.Parallel()\n\tcfg := parseCfg(t,\n\t\t\"--provider-cache-time=20s\",\n\t\t\"--dynamodb-region=us-east-2\",\n\t)\n\tassert.Equal(t, 20*time.Second, cfg.ProviderCacheTime)\n\tassert.Equal(t, \"us-east-2\", cfg.AWSDynamoDBRegion)\n}\n\nfunc TestParseFlagsGoDaddy(t *testing.T) {\n\tt.Parallel()\n\tcfg := parseCfg(t,\n\t\t\"--godaddy-api-key=key\",\n\t\t\"--godaddy-api-secret=secret\",\n\t\t\"--godaddy-api-ttl=1234\",\n\t\t\"--godaddy-api-ote\",\n\t)\n\tassert.Equal(t, \"key\", cfg.GoDaddyAPIKey)\n\tassert.Equal(t, \"secret\", cfg.GoDaddySecretKey)\n\tassert.Equal(t, int64(1234), cfg.GoDaddyTTL)\n\tassert.True(t, cfg.GoDaddyOTE)\n}\n\nfunc TestParseFlagsRFC2136(t *testing.T) {\n\tt.Parallel()\n\tcfg := parseCfg(t,\n\t\t\"--rfc2136-port=5353\",\n\t\t\"--rfc2136-zone=example.org.\",\n\t\t\"--rfc2136-zone=example.com.\",\n\t\t\"--rfc2136-create-ptr\",\n\t\t\"--rfc2136-insecure\",\n\t\t\"--rfc2136-kerberos-realm=EXAMPLE.COM\",\n\t\t\"--rfc2136-kerberos-username=svc-externaldns\",\n\t\t\"--rfc2136-kerberos-password=secret\",\n\t\t\"--rfc2136-tsig-keyname=keyname.\",\n\t\t\"--rfc2136-tsig-secret=base64secret\",\n\t\t\"--rfc2136-tsig-secret-alg=hmac-sha256\",\n\t\t\"--rfc2136-tsig-axfr\",\n\t\t\"--rfc2136-min-ttl=30s\",\n\t\t\"--rfc2136-gss-tsig\",\n\t\t\"--rfc2136-use-tls\",\n\t\t\"--rfc2136-skip-tls-verify\",\n\t)\n\tassert.Equal(t, 5353, cfg.RFC2136Port)\n\tassert.ElementsMatch(t, []string{\"example.org.\", \"example.com.\"}, cfg.RFC2136Zone)\n\tassert.True(t, cfg.RFC2136CreatePTR)\n\tassert.True(t, cfg.RFC2136Insecure)\n\tassert.Equal(t, \"EXAMPLE.COM\", cfg.RFC2136KerberosRealm)\n\tassert.Equal(t, \"svc-externaldns\", cfg.RFC2136KerberosUsername)\n\tassert.Equal(t, \"secret\", cfg.RFC2136KerberosPassword)\n\tassert.Equal(t, \"keyname.\", cfg.RFC2136TSIGKeyName)\n\tassert.Equal(t, \"base64secret\", cfg.RFC2136TSIGSecret)\n\tassert.Equal(t, \"hmac-sha256\", cfg.RFC2136TSIGSecretAlg)\n\tassert.True(t, cfg.RFC2136TAXFR)\n\tassert.Equal(t, 30*time.Second, cfg.RFC2136MinTTL)\n\tassert.True(t, cfg.RFC2136GSSTSIG)\n\tassert.True(t, cfg.RFC2136UseTLS)\n\tassert.True(t, cfg.RFC2136SkipTLSVerify)\n}\n\nfunc TestParseFlagsTraefik(t *testing.T) {\n\tt.Parallel()\n\tcfg := parseCfg(t,\n\t\t\"--traefik-enable-legacy\",\n\t\t\"--traefik-disable-new\",\n\t)\n\tassert.True(t, cfg.TraefikEnableLegacy)\n\tassert.True(t, cfg.TraefikDisableNew)\n}\n\nfunc TestParseFlagsTXTRegistry(t *testing.T) {\n\tt.Parallel()\n\tcfg := parseCfg(t,\n\t\t\"--txt-encrypt-enabled\",\n\t\t\"--txt-encrypt-aes-key=0123456789abcdef0123456789abcdef\",\n\t\t\"--txt-suffix=-suffix\",\n\t\t\"--txt-wildcard-replacement=X\",\n\t)\n\tassert.True(t, cfg.TXTEncryptEnabled)\n\tassert.Equal(t, \"0123456789abcdef0123456789abcdef\", cfg.TXTEncryptAESKey)\n\tassert.Equal(t, \"-suffix\", cfg.TXTSuffix)\n\tassert.Equal(t, \"X\", cfg.TXTWildcardReplacement)\n}\n\nfunc TestParseFlagsWebhookProvider(t *testing.T) {\n\tt.Parallel()\n\tcfg := parseCfg(t,\n\t\t\"--webhook-provider-url=http://127.0.0.1:9999\",\n\t\t\"--webhook-provider-read-timeout=7s\",\n\t\t\"--webhook-provider-write-timeout=8s\",\n\t\t\"--webhook-server\",\n\t)\n\tassert.Equal(t, \"http://127.0.0.1:9999\", cfg.WebhookProviderURL)\n\tassert.Equal(t, 7*time.Second, cfg.WebhookProviderReadTimeout)\n\tassert.Equal(t, 8*time.Second, cfg.WebhookProviderWriteTimeout)\n\tassert.True(t, cfg.WebhookServer)\n}\n\nfunc TestParseFlagsMiscListeners(t *testing.T) {\n\tt.Parallel()\n\tcfg := parseCfg(t, \"--listen-endpoint-events\")\n\tassert.True(t, cfg.ListenEndpointEvents)\n}\n\n// Helpers to run bindFlags + parse for each binder.\nfunc runWithKingpin(t *testing.T, args []string) *Config {\n\tt.Helper()\n\tcfg := &Config{}\n\tcfg.AWSSDCreateTag = map[string]string{}\n\tcfg.RegexDomainFilter = defaultConfig.RegexDomainFilter\n\tapp := kingpin.New(\"test\", \"\")\n\tbindFlags(flags.NewKingpinBinder(app), cfg)\n\t_, err := app.Parse(args)\n\trequire.NoError(t, err)\n\treturn cfg\n}\n\nfunc TestBinderParityRepeatable(t *testing.T) {\n\targs := []string{\"--managed-record-types=A\", \"--managed-record-types=TXT\"}\n\tcfgK := runWithKingpin(t, args)\n\tassert.ElementsMatch(t, []string{\"A\", \"TXT\"}, cfgK.ManagedDNSRecordTypes)\n}\n\nfunc TestBinderParityMapAndRegexp(t *testing.T) {\n\targs := []string{\"--regex-domain-filter=^ex.*$\", \"--aws-sd-create-tag=foo=bar\"}\n\tcfgK := runWithKingpin(t, args)\n\n\trequire.NotNil(t, cfgK.RegexDomainFilter)\n\trequire.NotNil(t, cfgK.AWSSDCreateTag)\n\tassert.Equal(t, map[string]string{\"foo\": \"bar\"}, cfgK.AWSSDCreateTag)\n}\n\n// Kingpin validates enum values at parse time\nfunc TestBinderEnumValidationDifference(t *testing.T) {\n\t// Kingpin should reject unknown enum values\n\tappArgs := []string{\"--google-zone-visibility=bogus\"}\n\tapp := kingpin.New(\"test\", \"\")\n\tcfgK := &Config{}\n\tbindFlags(flags.NewKingpinBinder(app), cfgK)\n\t_, err := app.Parse(appArgs)\n\trequire.Error(t, err)\n}\n"
  },
  {
    "path": "pkg/apis/externaldns/validation/validation.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage validation\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"k8s.io/apimachinery/pkg/labels\"\n\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n)\n\n// ValidateConfig performs validation on the Config object\nfunc ValidateConfig(cfg *externaldns.Config) error {\n\t// TODO: Should probably return field.ErrorList\n\n\tif err := preValidateConfig(cfg); err != nil {\n\t\treturn err\n\t}\n\n\tif err := validateConfigForProvider(cfg); err != nil {\n\t\treturn err\n\t}\n\n\tif cfg.IgnoreHostnameAnnotation && cfg.FQDNTemplate == \"\" {\n\t\treturn errors.New(\"FQDN Template must be set if ignoring annotations\")\n\t}\n\n\tif len(cfg.TXTPrefix) > 0 && len(cfg.TXTSuffix) > 0 {\n\t\treturn errors.New(\"txt-prefix and txt-suffix are mutual exclusive\")\n\t}\n\n\t_, err := labels.Parse(cfg.LabelFilter)\n\tif err != nil {\n\t\treturn errors.New(\"--label-filter does not specify a valid label selector\")\n\t}\n\n\tif cfg.AnnotationPrefix == \"\" {\n\t\treturn errors.New(\"--annotation-prefix cannot be empty\")\n\t}\n\tif !strings.HasSuffix(cfg.AnnotationPrefix, \"/\") {\n\t\treturn errors.New(\"--annotation-prefix must end with '/'\")\n\t}\n\n\treturn nil\n}\n\nfunc preValidateConfig(cfg *externaldns.Config) error {\n\tif cfg.LogFormat != \"text\" && cfg.LogFormat != \"json\" {\n\t\treturn fmt.Errorf(\"unsupported log format: %s\", cfg.LogFormat)\n\t}\n\tif len(cfg.Sources) == 0 {\n\t\treturn errors.New(\"no sources specified\")\n\t}\n\tif cfg.Provider == \"\" {\n\t\treturn errors.New(\"no provider specified\")\n\t}\n\treturn nil\n}\n\nfunc validateConfigForProvider(cfg *externaldns.Config) error {\n\tswitch cfg.Provider {\n\tcase externaldns.ProviderAzure:\n\t\treturn validateConfigForAzure(cfg)\n\tcase externaldns.ProviderAkamai:\n\t\treturn validateConfigForAkamai(cfg)\n\tcase externaldns.ProviderRFC2136:\n\t\treturn validateConfigForRfc2136(cfg)\n\tdefault:\n\t\treturn nil\n\t}\n}\n\nfunc validateConfigForAzure(cfg *externaldns.Config) error {\n\tif cfg.AzureConfigFile == \"\" {\n\t\treturn errors.New(\"no Azure config file specified\")\n\t}\n\treturn nil\n}\n\nfunc validateConfigForAkamai(cfg *externaldns.Config) error {\n\tif cfg.AkamaiServiceConsumerDomain == \"\" && cfg.AkamaiEdgercPath != \"\" {\n\t\treturn errors.New(\"no Akamai ServiceConsumerDomain specified\")\n\t}\n\tif cfg.AkamaiClientToken == \"\" && cfg.AkamaiEdgercPath != \"\" {\n\t\treturn errors.New(\"no Akamai client token specified\")\n\t}\n\tif cfg.AkamaiClientSecret == \"\" && cfg.AkamaiEdgercPath != \"\" {\n\t\treturn errors.New(\"no Akamai client secret specified\")\n\t}\n\tif cfg.AkamaiAccessToken == \"\" && cfg.AkamaiEdgercPath != \"\" {\n\t\treturn errors.New(\"no Akamai access token specified\")\n\t}\n\treturn nil\n}\n\nfunc validateConfigForRfc2136(cfg *externaldns.Config) error {\n\tif cfg.RFC2136MinTTL < 0 {\n\t\treturn errors.New(\"TTL specified for rfc2136 is negative\")\n\t}\n\tif cfg.RFC2136Insecure && cfg.RFC2136GSSTSIG {\n\t\treturn errors.New(\"--rfc2136-insecure and --rfc2136-gss-tsig are mutually exclusive arguments\")\n\t}\n\tif cfg.RFC2136GSSTSIG {\n\t\tif cfg.RFC2136KerberosPassword == \"\" || cfg.RFC2136KerberosUsername == \"\" || cfg.RFC2136KerberosRealm == \"\" {\n\t\t\treturn errors.New(\"--rfc2136-kerberos-realm, --rfc2136-kerberos-username, and --rfc2136-kerberos-password are required when specifying --rfc2136-gss-tsig option\")\n\t\t}\n\t}\n\tif cfg.RFC2136BatchChangeSize < 1 {\n\t\treturn errors.New(\"batch size specified for rfc2136 cannot be less than 1\")\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/apis/externaldns/validation/validation_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage validation\n\nimport (\n\t\"testing\"\n\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestValidateFlags(t *testing.T) {\n\tcfg := newValidConfig(t)\n\trequire.NoError(t, ValidateConfig(cfg))\n\n\tcfg = newValidConfig(t)\n\tcfg.LogFormat = \"test\"\n\trequire.Error(t, ValidateConfig(cfg))\n\n\tcfg = newValidConfig(t)\n\tcfg.LogFormat = \"\"\n\trequire.Error(t, ValidateConfig(cfg))\n\n\tfor _, format := range []string{\"text\", \"json\"} {\n\t\tcfg = newValidConfig(t)\n\t\tcfg.LogFormat = format\n\t\trequire.NoError(t, ValidateConfig(cfg))\n\t}\n\n\tcfg = newValidConfig(t)\n\tcfg.Sources = []string{}\n\trequire.Error(t, ValidateConfig(cfg))\n\n\tcfg = newValidConfig(t)\n\tcfg.Provider = \"\"\n\trequire.Error(t, ValidateConfig(cfg))\n\n\tcfg = newValidConfig(t)\n\tcfg.IgnoreHostnameAnnotation = true\n\tcfg.FQDNTemplate = \"\"\n\trequire.Error(t, ValidateConfig(cfg))\n\n\tcfg = newValidConfig(t)\n\tcfg.TXTPrefix = \"foo\"\n\tcfg.TXTSuffix = \"bar\"\n\trequire.Error(t, ValidateConfig(cfg))\n\n\tcfg = newValidConfig(t)\n\tcfg.LabelFilter = \"foo\"\n\trequire.NoError(t, ValidateConfig(cfg))\n\n\tcfg = newValidConfig(t)\n\tcfg.LabelFilter = \"foo=bar\"\n\trequire.NoError(t, ValidateConfig(cfg))\n\n\tcfg = newValidConfig(t)\n\tcfg.LabelFilter = \"#invalid-selector\"\n\trequire.Error(t, ValidateConfig(cfg))\n\n\tcfg = newValidConfig(t)\n\tcfg.AnnotationPrefix = \"\"\n\trequire.Error(t, ValidateConfig(cfg))\n\n\tcfg = newValidConfig(t)\n\tcfg.AnnotationPrefix = \"custom.io\"\n\trequire.Error(t, ValidateConfig(cfg))\n\n\tcfg = newValidConfig(t)\n\tcfg.AnnotationPrefix = \"custom.io/\"\n\trequire.NoError(t, ValidateConfig(cfg))\n\n\tcfg = newValidConfig(t)\n\tcfg.AnnotationPrefix = \"external-dns.alpha.kubernetes.io/\"\n\trequire.NoError(t, ValidateConfig(cfg))\n}\n\nfunc newValidConfig(t *testing.T) *externaldns.Config {\n\tcfg := externaldns.NewConfig()\n\n\tcfg.LogFormat = \"json\"\n\tcfg.Sources = []string{\"test-source\"}\n\tcfg.Provider = \"test-provider\"\n\n\trequire.NoError(t, ValidateConfig(cfg))\n\n\treturn cfg\n}\n\nfunc TestValidateBadIgnoreHostnameAnnotationsConfig(t *testing.T) {\n\tcfg := externaldns.NewConfig()\n\tcfg.IgnoreHostnameAnnotation = true\n\tcfg.FQDNTemplate = \"\"\n\n\tassert.Error(t, ValidateConfig(cfg))\n}\n\nfunc TestValidateBadRfc2136Config(t *testing.T) {\n\tcfg := externaldns.NewConfig()\n\n\tcfg.LogFormat = \"json\"\n\tcfg.Sources = []string{\"test-source\"}\n\tcfg.Provider = \"rfc2136\"\n\tcfg.RFC2136MinTTL = -1\n\tcfg.RFC2136CreatePTR = false\n\tcfg.RFC2136BatchChangeSize = 50\n\n\terr := ValidateConfig(cfg)\n\n\tassert.Error(t, err)\n}\n\nfunc TestValidateBadRfc2136Batch(t *testing.T) {\n\tcfg := externaldns.NewConfig()\n\n\tcfg.LogFormat = \"json\"\n\tcfg.Sources = []string{\"test-source\"}\n\tcfg.Provider = \"rfc2136\"\n\tcfg.RFC2136MinTTL = 3600\n\tcfg.RFC2136BatchChangeSize = 0\n\n\terr := ValidateConfig(cfg)\n\n\tassert.Error(t, err)\n}\n\nfunc TestValidateGoodRfc2136Config(t *testing.T) {\n\tcfg := externaldns.NewConfig()\n\n\tcfg.LogFormat = \"json\"\n\tcfg.Sources = []string{\"test-source\"}\n\tcfg.Provider = \"rfc2136\"\n\tcfg.RFC2136MinTTL = 3600\n\tcfg.RFC2136BatchChangeSize = 50\n\n\terr := ValidateConfig(cfg)\n\n\tassert.NoError(t, err)\n}\n\nfunc TestValidateBadRfc2136GssTsigConfig(t *testing.T) {\n\tinvalidRfc2136GssTsigConfigs := []*externaldns.Config{\n\t\t{\n\t\t\tLogFormat:               \"json\",\n\t\t\tSources:                 []string{\"test-source\"},\n\t\t\tProvider:                \"rfc2136\",\n\t\t\tAnnotationPrefix:        \"external-dns.alpha.kubernetes.io/\",\n\t\t\tRFC2136GSSTSIG:          true,\n\t\t\tRFC2136KerberosRealm:    \"test-realm\",\n\t\t\tRFC2136KerberosUsername: \"test-user\",\n\t\t\tRFC2136KerberosPassword: \"\",\n\t\t\tRFC2136MinTTL:           3600,\n\t\t\tRFC2136BatchChangeSize:  50,\n\t\t},\n\t\t{\n\t\t\tLogFormat:               \"json\",\n\t\t\tSources:                 []string{\"test-source\"},\n\t\t\tProvider:                \"rfc2136\",\n\t\t\tAnnotationPrefix:        \"external-dns.alpha.kubernetes.io/\",\n\t\t\tRFC2136GSSTSIG:          true,\n\t\t\tRFC2136KerberosRealm:    \"test-realm\",\n\t\t\tRFC2136KerberosUsername: \"\",\n\t\t\tRFC2136KerberosPassword: \"test-pass\",\n\t\t\tRFC2136MinTTL:           3600,\n\t\t\tRFC2136BatchChangeSize:  50,\n\t\t},\n\t\t{\n\t\t\tLogFormat:               \"json\",\n\t\t\tSources:                 []string{\"test-source\"},\n\t\t\tProvider:                \"rfc2136\",\n\t\t\tAnnotationPrefix:        \"external-dns.alpha.kubernetes.io/\",\n\t\t\tRFC2136GSSTSIG:          true,\n\t\t\tRFC2136Insecure:         true,\n\t\t\tRFC2136KerberosRealm:    \"test-realm\",\n\t\t\tRFC2136KerberosUsername: \"test-user\",\n\t\t\tRFC2136KerberosPassword: \"test-pass\",\n\t\t\tRFC2136MinTTL:           3600,\n\t\t\tRFC2136BatchChangeSize:  50,\n\t\t},\n\t\t{\n\t\t\tLogFormat:               \"json\",\n\t\t\tSources:                 []string{\"test-source\"},\n\t\t\tProvider:                \"rfc2136\",\n\t\t\tAnnotationPrefix:        \"external-dns.alpha.kubernetes.io/\",\n\t\t\tRFC2136GSSTSIG:          true,\n\t\t\tRFC2136KerberosRealm:    \"\",\n\t\t\tRFC2136KerberosUsername: \"test-user\",\n\t\t\tRFC2136KerberosPassword: \"\",\n\t\t\tRFC2136MinTTL:           3600,\n\t\t\tRFC2136BatchChangeSize:  50,\n\t\t},\n\t\t{\n\t\t\tLogFormat:               \"json\",\n\t\t\tSources:                 []string{\"test-source\"},\n\t\t\tProvider:                \"rfc2136\",\n\t\t\tAnnotationPrefix:        \"external-dns.alpha.kubernetes.io/\",\n\t\t\tRFC2136GSSTSIG:          true,\n\t\t\tRFC2136KerberosRealm:    \"\",\n\t\t\tRFC2136KerberosUsername: \"\",\n\t\t\tRFC2136KerberosPassword: \"test-pass\",\n\t\t\tRFC2136MinTTL:           3600,\n\t\t\tRFC2136BatchChangeSize:  50,\n\t\t},\n\t\t{\n\t\t\tLogFormat:               \"json\",\n\t\t\tSources:                 []string{\"test-source\"},\n\t\t\tProvider:                \"rfc2136\",\n\t\t\tAnnotationPrefix:        \"external-dns.alpha.kubernetes.io/\",\n\t\t\tRFC2136GSSTSIG:          true,\n\t\t\tRFC2136Insecure:         true,\n\t\t\tRFC2136KerberosRealm:    \"\",\n\t\t\tRFC2136KerberosUsername: \"test-user\",\n\t\t\tRFC2136KerberosPassword: \"test-pass\",\n\t\t\tRFC2136MinTTL:           3600,\n\t\t\tRFC2136BatchChangeSize:  50,\n\t\t},\n\t\t{\n\t\t\tLogFormat:               \"json\",\n\t\t\tSources:                 []string{\"test-source\"},\n\t\t\tProvider:                \"rfc2136\",\n\t\t\tAnnotationPrefix:        \"external-dns.alpha.kubernetes.io/\",\n\t\t\tRFC2136GSSTSIG:          true,\n\t\t\tRFC2136KerberosRealm:    \"\",\n\t\t\tRFC2136KerberosUsername: \"test-user\",\n\t\t\tRFC2136KerberosPassword: \"test-pass\",\n\t\t\tRFC2136MinTTL:           3600,\n\t\t\tRFC2136BatchChangeSize:  50,\n\t\t},\n\t}\n\n\tfor _, cfg := range invalidRfc2136GssTsigConfigs {\n\t\terr := ValidateConfig(cfg)\n\n\t\tassert.Error(t, err)\n\t}\n}\n\nfunc TestValidateGoodRfc2136GssTsigConfig(t *testing.T) {\n\tvalidRfc2136GssTsigConfigs := []*externaldns.Config{\n\t\t{\n\t\t\tLogFormat:               \"json\",\n\t\t\tSources:                 []string{\"test-source\"},\n\t\t\tProvider:                \"rfc2136\",\n\t\t\tAnnotationPrefix:        \"external-dns.alpha.kubernetes.io/\",\n\t\t\tRFC2136GSSTSIG:          true,\n\t\t\tRFC2136Insecure:         false,\n\t\t\tRFC2136KerberosRealm:    \"test-realm\",\n\t\t\tRFC2136KerberosUsername: \"test-user\",\n\t\t\tRFC2136KerberosPassword: \"test-pass\",\n\t\t\tRFC2136MinTTL:           3600,\n\t\t\tRFC2136BatchChangeSize:  50,\n\t\t},\n\t}\n\n\tfor _, cfg := range validRfc2136GssTsigConfigs {\n\t\terr := ValidateConfig(cfg)\n\n\t\tassert.NoError(t, err)\n\t}\n}\n\nfunc TestValidateBadAkamaiConfig(t *testing.T) {\n\tinvalidAkamaiConfigs := []*externaldns.Config{\n\t\t{\n\t\t\tLogFormat:          \"json\",\n\t\t\tSources:            []string{\"test-source\"},\n\t\t\tProvider:           \"akamai\",\n\t\t\tAnnotationPrefix:   \"external-dns.alpha.kubernetes.io/\",\n\t\t\tAkamaiClientToken:  \"test-token\",\n\t\t\tAkamaiClientSecret: \"test-secret\",\n\t\t\tAkamaiAccessToken:  \"test-access-token\",\n\t\t\tAkamaiEdgercPath:   \"/path/to/edgerc\",\n\t\t\t// Missing AkamaiServiceConsumerDomain\n\t\t},\n\t\t{\n\t\t\tLogFormat:                   \"json\",\n\t\t\tSources:                     []string{\"test-source\"},\n\t\t\tProvider:                    \"akamai\",\n\t\t\tAnnotationPrefix:            \"external-dns.alpha.kubernetes.io/\",\n\t\t\tAkamaiServiceConsumerDomain: \"test-domain\",\n\t\t\tAkamaiClientSecret:          \"test-secret\",\n\t\t\tAkamaiAccessToken:           \"test-access-token\",\n\t\t\tAkamaiEdgercPath:            \"/path/to/edgerc\",\n\t\t\t// Missing AkamaiClientToken\n\t\t},\n\t\t{\n\t\t\tLogFormat:                   \"json\",\n\t\t\tSources:                     []string{\"test-source\"},\n\t\t\tProvider:                    \"akamai\",\n\t\t\tAnnotationPrefix:            \"external-dns.alpha.kubernetes.io/\",\n\t\t\tAkamaiServiceConsumerDomain: \"test-domain\",\n\t\t\tAkamaiClientToken:           \"test-token\",\n\t\t\tAkamaiAccessToken:           \"test-access-token\",\n\t\t\tAkamaiEdgercPath:            \"/path/to/edgerc\",\n\t\t\t// Missing AkamaiClientSecret\n\t\t},\n\t\t{\n\t\t\tLogFormat:                   \"json\",\n\t\t\tSources:                     []string{\"test-source\"},\n\t\t\tProvider:                    \"akamai\",\n\t\t\tAnnotationPrefix:            \"external-dns.alpha.kubernetes.io/\",\n\t\t\tAkamaiServiceConsumerDomain: \"test-domain\",\n\t\t\tAkamaiClientToken:           \"test-token\",\n\t\t\tAkamaiClientSecret:          \"test-secret\",\n\t\t\tAkamaiEdgercPath:            \"/path/to/edgerc\",\n\t\t\t// Missing AkamaiAccessToken\n\t\t},\n\t}\n\n\tfor _, cfg := range invalidAkamaiConfigs {\n\t\terr := ValidateConfig(cfg)\n\t\tassert.Error(t, err)\n\t}\n}\n\nfunc TestValidateGoodAkamaiConfig(t *testing.T) {\n\tvalidAkamaiConfigs := []*externaldns.Config{\n\t\t{\n\t\t\tLogFormat:                   \"json\",\n\t\t\tSources:                     []string{\"test-source\"},\n\t\t\tProvider:                    \"akamai\",\n\t\t\tAnnotationPrefix:            \"external-dns.alpha.kubernetes.io/\",\n\t\t\tAkamaiServiceConsumerDomain: \"test-domain\",\n\t\t\tAkamaiClientToken:           \"test-token\",\n\t\t\tAkamaiClientSecret:          \"test-secret\",\n\t\t\tAkamaiAccessToken:           \"test-access-token\",\n\t\t\tAkamaiEdgercPath:            \"/path/to/edgerc\",\n\t\t},\n\t\t{\n\t\t\tLogFormat:        \"json\",\n\t\t\tSources:          []string{\"test-source\"},\n\t\t\tProvider:         \"akamai\",\n\t\t\tAnnotationPrefix: \"external-dns.alpha.kubernetes.io/\",\n\t\t\t// All Akamai fields can be empty if AkamaiEdgercPath is not specified\n\t\t},\n\t}\n\n\tfor _, cfg := range validAkamaiConfigs {\n\t\terr := ValidateConfig(cfg)\n\t\tassert.NoError(t, err)\n\t}\n}\n\nfunc TestValidateBadAzureConfig(t *testing.T) {\n\tcfg := externaldns.NewConfig()\n\n\tcfg.LogFormat = \"json\"\n\tcfg.Sources = []string{\"test-source\"}\n\tcfg.Provider = \"azure\"\n\tcfg.AnnotationPrefix = \"external-dns.alpha.kubernetes.io/\"\n\t// AzureConfigFile is empty\n\n\terr := ValidateConfig(cfg)\n\n\tassert.Error(t, err)\n}\n\nfunc TestValidateGoodAzureConfig(t *testing.T) {\n\tcfg := externaldns.NewConfig()\n\n\tcfg.LogFormat = \"json\"\n\tcfg.Sources = []string{\"test-source\"}\n\tcfg.Provider = \"azure\"\n\tcfg.AnnotationPrefix = \"external-dns.alpha.kubernetes.io/\"\n\tcfg.AzureConfigFile = \"/path/to/azure.json\"\n\n\terr := ValidateConfig(cfg)\n\n\tassert.NoError(t, err)\n}\n"
  },
  {
    "path": "pkg/apis/externaldns/version.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage externaldns\n\nimport (\n\t\"fmt\"\n\t\"runtime\"\n)\n\nconst (\n\tbannerTemplate = `GitCommitShort=%s, GoVersion=%s, Platform=%s, UserAgent=%s`\n)\n\nvar (\n\tVersion          = \"unknown\" // Set at the build time via `-ldflags \"-X main.Version=<value>\"`\n\tGitCommit        = \"unknown\" // Set at the build time via `-ldflags \"-X main.GitCommitSHA=<value>\"`\n\tUserAgentProduct = \"ExternalDNS\"\n\tgoVersion        = runtime.Version()\n)\n\nfunc UserAgent() string {\n\treturn fmt.Sprintf(\"%s/%s\", UserAgentProduct, Version)\n}\n\nfunc Banner() string {\n\tplatform := fmt.Sprintf(\"%s/%s\", runtime.GOOS, runtime.GOARCH)\n\treturn fmt.Sprintf(\n\t\tbannerTemplate,\n\t\tGitCommit,\n\t\tgoVersion,\n\t\tplatform,\n\t\tUserAgent(),\n\t)\n}\n"
  },
  {
    "path": "pkg/apis/externaldns/version_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage externaldns\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestBanner(t *testing.T) {\n\t// Set variables to known values\n\tVersion = \"1.0.0\"\n\tgoVersion = \"go1.17\"\n\tGitCommit = \"49a0c57c7\"\n\n\twant := Banner()\n\tassert.Contains(t, want, \"GoVersion=go1.17\")\n\tassert.Contains(t, want, \"GitCommitShort=49a0c57c7\")\n\tassert.Contains(t, want, \"UserAgent=ExternalDNS/1.0.0\")\n}\n"
  },
  {
    "path": "pkg/client/OWNERS",
    "content": "# See the OWNERS docs at https://go.k8s.io/owners\n\nlabels:\n- client\n"
  },
  {
    "path": "pkg/client/config.go",
    "content": "/*\nCopyright 2026 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\n// Package kubeclient provides shared utilities for creating Kubernetes REST configurations\n// and clients with standardized metrics instrumentation.\npackage kubeclient\n\nimport (\n\t\"os\"\n\t\"time\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"k8s.io/client-go/kubernetes\"\n\t\"k8s.io/client-go/rest\"\n\t\"k8s.io/client-go/tools/clientcmd\"\n\n\textdnshttp \"sigs.k8s.io/external-dns/pkg/http\"\n)\n\n// GetRestConfig returns the REST client configuration for Kubernetes API access.\n// Supports both in-cluster and external cluster configurations.\n//\n// Configuration Priority:\n// 1. KubeConfig file if specified\n// 2. Recommended home file (~/.kube/config)\n// 3. In-cluster config\n// TODO: consider clientcmd.NewDefaultClientConfigLoadingRules() with clientcmd.NewNonInteractiveDeferredLoadingClientConfig\nfunc GetRestConfig(kubeConfig, apiServerURL string) (*rest.Config, error) {\n\tif kubeConfig == \"\" {\n\t\tif _, err := os.Stat(clientcmd.RecommendedHomeFile); err == nil {\n\t\t\tkubeConfig = clientcmd.RecommendedHomeFile\n\t\t}\n\t}\n\tlog.Debugf(\"apiServerURL: %s\", apiServerURL)\n\tlog.Debugf(\"kubeConfig: %s\", kubeConfig)\n\n\t// evaluate whether to use kubeConfig-file or serviceaccount-token\n\tvar (\n\t\tconfig *rest.Config\n\t\terr    error\n\t)\n\tif kubeConfig == \"\" {\n\t\tlog.Debug(\"Using inCluster-config based on serviceaccount-token\")\n\t\tconfig, err = rest.InClusterConfig()\n\t} else {\n\t\tlog.Debug(\"Using kubeConfig\")\n\t\tconfig, err = clientcmd.BuildConfigFromFlags(apiServerURL, kubeConfig)\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn config, nil\n}\n\n// InstrumentedRESTConfig creates a REST config with request instrumentation for monitoring.\n// Adds HTTP transport wrapper for Prometheus metrics collection and request timeout configuration.\n//\n// Metrics: Wraps the transport with pkg/http.NewInstrumentedTransport to collect\n// HTTP request duration metrics for all Kubernetes API calls.\n//\n// Timeout: Applies the specified request timeout to prevent hanging requests.\nfunc InstrumentedRESTConfig(kubeConfig, apiServerURL string, requestTimeout time.Duration) (*rest.Config, error) {\n\tconfig, err := GetRestConfig(kubeConfig, apiServerURL)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tconfig.WrapTransport = extdnshttp.NewInstrumentedTransport\n\tconfig.Timeout = requestTimeout\n\n\treturn config, nil\n}\n\n// NewKubeClient returns a new Kubernetes client object. It takes a Config and\n// uses APIServerURL and KubeConfig attributes to connect to the cluster. If\n// KubeConfig isn't provided it defaults to using the recommended default.\nfunc NewKubeClient(kubeConfig, apiServerURL string, requestTimeout time.Duration) (*kubernetes.Clientset, error) {\n\tlog.Infof(\"Instantiating new Kubernetes client\")\n\tconfig, err := InstrumentedRESTConfig(kubeConfig, apiServerURL, requestTimeout)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tclient, err := kubernetes.NewForConfig(config)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tlog.Infof(\"Created Kubernetes client %s\", config.Host)\n\treturn client, nil\n}\n"
  },
  {
    "path": "pkg/client/config_test.go",
    "content": "/*\nCopyright 2026 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage kubeclient\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\t\"k8s.io/client-go/tools/clientcmd\"\n)\n\nfunc TestGetRestConfig_WithKubeConfig(t *testing.T) {\n\tsvr := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}))\n\tdefer svr.Close()\n\n\tmockKubeCfgDir := filepath.Join(t.TempDir(), \".kube\")\n\tmockKubeCfgPath := filepath.Join(mockKubeCfgDir, \"config\")\n\terr := os.MkdirAll(mockKubeCfgDir, 0755)\n\trequire.NoError(t, err)\n\n\tkubeCfgTemplate := `apiVersion: v1\nkind: Config\nclusters:\n- cluster:\n    server: %s\n  name: test-cluster\ncontexts:\n- context:\n    cluster: test-cluster\n    user: test-user\n  name: test-context\ncurrent-context: test-context\nusers:\n- name: test-user\n  user:\n    token: fake-token\n`\n\terr = os.WriteFile(mockKubeCfgPath, fmt.Appendf(nil, kubeCfgTemplate, svr.URL), 0644)\n\trequire.NoError(t, err)\n\n\tconfig, err := GetRestConfig(mockKubeCfgPath, \"\")\n\trequire.NoError(t, err)\n\trequire.NotNil(t, config)\n\tassert.Equal(t, svr.URL, config.Host)\n}\n\nfunc TestInstrumentedRESTConfig_AddsMetrics(t *testing.T) {\n\tsvr := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}))\n\tdefer svr.Close()\n\n\tmockKubeCfgDir := filepath.Join(t.TempDir(), \".kube\")\n\tmockKubeCfgPath := filepath.Join(mockKubeCfgDir, \"config\")\n\terr := os.MkdirAll(mockKubeCfgDir, 0755)\n\trequire.NoError(t, err)\n\n\tkubeCfgTemplate := `apiVersion: v1\nkind: Config\nclusters:\n- cluster:\n    server: %s\n  name: test-cluster\ncontexts:\n- context:\n    cluster: test-cluster\n    user: test-user\n  name: test-context\ncurrent-context: test-context\nusers:\n- name: test-user\n  user:\n    token: fake-token\n`\n\terr = os.WriteFile(mockKubeCfgPath, fmt.Appendf(nil, kubeCfgTemplate, svr.URL), 0644)\n\trequire.NoError(t, err)\n\n\ttimeout := 30 * time.Second\n\tconfig, err := InstrumentedRESTConfig(mockKubeCfgPath, \"\", timeout)\n\trequire.NoError(t, err)\n\trequire.NotNil(t, config)\n\n\tassert.Equal(t, timeout, config.Timeout)\n\tassert.NotNil(t, config.WrapTransport, \"WrapTransport should be set for metrics\")\n}\n\nfunc TestGetRestConfig_RecommendedHomeFile(t *testing.T) {\n\tsvr := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}))\n\tdefer svr.Close()\n\n\tmockKubeCfgDir := filepath.Join(t.TempDir(), \".kube\")\n\tmockKubeCfgPath := filepath.Join(mockKubeCfgDir, \"config\")\n\terr := os.MkdirAll(mockKubeCfgDir, 0755)\n\trequire.NoError(t, err)\n\n\tkubeCfgTemplate := `apiVersion: v1\nkind: Config\nclusters:\n- cluster:\n    server: %s\n  name: test-cluster\ncontexts:\n- context:\n    cluster: test-cluster\n    user: test-user\n  name: test-context\ncurrent-context: test-context\n`\n\terr = os.WriteFile(mockKubeCfgPath, fmt.Appendf(nil, kubeCfgTemplate, svr.URL), 0644)\n\trequire.NoError(t, err)\n\n\tprevRecommendedHomeFile := clientcmd.RecommendedHomeFile\n\tt.Cleanup(func() {\n\t\tclientcmd.RecommendedHomeFile = prevRecommendedHomeFile\n\t})\n\tclientcmd.RecommendedHomeFile = mockKubeCfgPath\n\n\tconfig, err := GetRestConfig(\"\", \"\")\n\trequire.NoError(t, err)\n\trequire.NotNil(t, config)\n\tassert.Equal(t, svr.URL, config.Host)\n}\n"
  },
  {
    "path": "pkg/events/OWNERS",
    "content": "# See the OWNERS docs at https://go.k8s.io/owners\n\nlabels:\n- events\n"
  },
  {
    "path": "pkg/events/controller.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage events\n\nimport (\n\t\"context\"\n\t\"os\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\teventsv1 \"k8s.io/api/events/v1\"\n\tapierrors \"k8s.io/apimachinery/pkg/api/errors\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\tutilruntime \"k8s.io/apimachinery/pkg/util/runtime\"\n\t\"k8s.io/apimachinery/pkg/util/sets\"\n\t\"k8s.io/apimachinery/pkg/util/wait\"\n\tv1 \"k8s.io/client-go/kubernetes/typed/events/v1\"\n\t\"k8s.io/client-go/util/workqueue\"\n)\n\nconst (\n\tworkers          = 1\n\tcontrollerName   = \"external-dns\"\n\tmaxTriesPerEvent = 3\n\tmaxQueuedEvents  = 100\n)\n\ntype EventEmitter interface {\n\tAdd(...Event)\n}\n\ntype Controller struct {\n\tclient          v1.EventsV1Interface\n\tqueue           workqueue.TypedRateLimitingInterface[any]\n\temitEvents      sets.Set[Reason]\n\tmaxQueuedEvents int\n\tdryRun          bool\n\thostname        string\n}\n\nfunc NewEventController(client v1.EventsV1Interface, cfg *Config) (*Controller, error) {\n\tqueue := workqueue.NewTypedRateLimitingQueueWithConfig[any](\n\t\tworkqueue.DefaultTypedControllerRateLimiter[any](),\n\t\tworkqueue.TypedRateLimitingQueueConfig[any]{Name: controllerName},\n\t)\n\thostname, _ := os.Hostname()\n\treturn &Controller{\n\t\tclient:          client,\n\t\tqueue:           queue,\n\t\temitEvents:      cfg.emitEvents,\n\t\tmaxQueuedEvents: maxQueuedEvents,\n\t\tdryRun:          cfg.dryRun,\n\t\thostname:        hostname,\n\t}, nil\n}\n\nfunc (ec *Controller) Run(ctx context.Context) {\n\tif len(ec.emitEvents) == 0 {\n\t\treturn\n\t}\n\tgo ec.run(ctx)\n}\n\nfunc (ec *Controller) run(ctx context.Context) {\n\tlog.Info(\"event Controller started\")\n\tdefer log.Info(\"event Controller terminated\")\n\tdefer utilruntime.HandleCrash()\n\tvar waitGroup wait.Group\n\tfor range workers {\n\t\twaitGroup.StartWithContext(ctx, func(ctx context.Context) {\n\t\t\tfor ec.processNextWorkItem(ctx) {\n\t\t\t}\n\t\t})\n\t}\n\t<-ctx.Done()\n\tec.queue.ShutDownWithDrain()\n\twaitGroup.Wait()\n}\n\nfunc (ec *Controller) processNextWorkItem(ctx context.Context) bool {\n\tkey, quit := ec.queue.Get()\n\tif quit {\n\t\treturn false\n\t}\n\tdefer ec.queue.Done(key)\n\tevent, ok := key.(*eventsv1.Event)\n\tif !ok {\n\t\tlog.Errorf(\"failed to convert key to Event: %q\", key)\n\t\treturn true\n\t}\n\tvar dryRun []string\n\tif ec.dryRun {\n\t\tdryRun = []string{metav1.DryRunAll}\n\t}\n\t_, err := ec.client.Events(event.Namespace).Create(ctx, event, metav1.CreateOptions{\n\t\tDryRun: dryRun,\n\t})\n\tif err != nil && !apierrors.IsNotFound(err) {\n\t\tif ec.queue.NumRequeues(key) < maxTriesPerEvent {\n\t\t\tlog.Errorf(\"not able to create event, retrying for key/%s. %v\", key, err)\n\t\t\tec.queue.AddRateLimited(key)\n\t\t\treturn true\n\t\t}\n\t\tlog.Errorf(\"dropping event %s/%s with key/%q after %d retries. %v\", event.Namespace, event.Name, key, ec.queue.NumRequeues(key), err)\n\t}\n\tec.queue.Forget(key)\n\treturn true\n}\n\nfunc (ec *Controller) Add(events ...Event) {\n\tif ec.queue.Len() >= ec.maxQueuedEvents {\n\t\tlog.Warnf(\"event queue is full, dropping %d events\", len(events))\n\t\treturn\n\t}\n\tfor _, e := range events {\n\t\tevent := e.event()\n\t\tif event == nil {\n\t\t\tcontinue\n\t\t}\n\t\tec.emit(event)\n\t}\n}\n\nfunc (ec *Controller) emit(event *eventsv1.Event) {\n\tif !ec.emitEvents.Has(Reason(event.Reason)) {\n\t\tlog.Debugf(\"skipping event %s/%s/%s with reason %s as not configured to emit\", event.Kind, event.Namespace, event.Name, event.Reason)\n\t\treturn\n\t}\n\tevent.ReportingController = controllerName\n\tec.queue.Add(event)\n}\n"
  },
  {
    "path": "pkg/events/controller_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage events\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\tv1 \"k8s.io/api/core/v1\"\n\teventsv1 \"k8s.io/api/events/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/runtime\"\n\t\"k8s.io/apimachinery/pkg/util/sets\"\n\t\"k8s.io/apimachinery/pkg/util/wait\"\n\t\"k8s.io/client-go/kubernetes/fake\"\n\teventsclient \"k8s.io/client-go/kubernetes/typed/events/v1\"\n\t\"k8s.io/client-go/tools/clientcmd\"\n\t\"k8s.io/client-go/util/workqueue\"\n\n\tclienttesting \"k8s.io/client-go/testing\"\n)\n\nfunc TestNewEventController_Success(t *testing.T) {\n\tsvr := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}))\n\tdefer svr.Close()\n\n\tmockKubeCfgDir := filepath.Join(t.TempDir(), \".kube\")\n\tmockKubeCfgPath := filepath.Join(mockKubeCfgDir, \"config\")\n\terr := os.MkdirAll(mockKubeCfgDir, 0755)\n\trequire.NoError(t, err)\n\n\tkubeCfgTemplate := `\napiVersion: v1\nkind: Config\nclusters:\n- cluster:\n    server: %s\n  name: test-cluster\ncontexts:\n- context:\n    cluster: test-cluster\n    user: test-user\n  name: test-context\ncurrent-context: test-context\nusers:\n- name: test-user\n  user:\n    token: fake-token\n`\n\terr = os.WriteFile(mockKubeCfgPath, fmt.Appendf(nil, kubeCfgTemplate, svr.URL), os.FileMode(0755))\n\trequire.NoError(t, err)\n\n\trestConfig, err := clientcmd.BuildConfigFromFlags(svr.URL, mockKubeCfgPath)\n\trequire.NoError(t, err)\n\tclient, err := eventsclient.NewForConfig(restConfig)\n\trequire.NoError(t, err)\n\n\tcfg := NewConfig(\n\t\tWithEmitEvents([]string{string(RecordReady)}),\n\t)\n\tctrl, err := NewEventController(client, cfg)\n\trequire.NoError(t, err)\n\trequire.NotNil(t, ctrl)\n\trequire.False(t, ctrl.dryRun)\n}\n\nfunc TestController_Run_NoEmitEvents(t *testing.T) {\n\tkClient := fake.NewClientset()\n\tctrl := &Controller{\n\t\tclient:     kClient.EventsV1(),\n\t\temitEvents: sets.New[Reason](),\n\t}\n\n\trequire.NotPanics(t, func() {\n\t\tctrl.Run(t.Context())\n\t})\n}\n\nfunc TestController_Run_EmitEvents(t *testing.T) {\n\tlog.SetLevel(log.ErrorLevel)\n\tctx := t.Context()\n\n\teventCreated := make(chan struct{})\n\tkubeClient := fake.NewClientset()\n\tkubeClient.PrependReactor(\"create\", \"events\", func(_ clienttesting.Action) (bool, runtime.Object, error) {\n\t\teventCreated <- struct{}{}\n\t\treturn true, nil, nil\n\t})\n\n\teventsClient := kubeClient.EventsV1()\n\tctrl := &Controller{\n\t\tclient:     eventsClient,\n\t\temitEvents: sets.New[Reason](RecordReady),\n\t\tqueue: workqueue.NewTypedRateLimitingQueueWithConfig[any](\n\t\t\tworkqueue.DefaultTypedControllerRateLimiter[any](),\n\t\t\tworkqueue.TypedRateLimitingQueueConfig[any]{Name: controllerName},\n\t\t),\n\t\thostname:        controllerName,\n\t\tmaxQueuedEvents: 1,\n\t}\n\n\tctrl.Run(ctx)\n\n\tevent := NewEvent(NewObjectReference(&v1.Pod{\n\t\tTypeMeta: metav1.TypeMeta{\n\t\t\tKind:       \"Pod\",\n\t\t\tAPIVersion: \"v1\",\n\t\t},\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:      \"fake-object\",\n\t\t\tNamespace: v1.NamespaceDefault,\n\t\t\tUID:       \"9de3fc19-8aeb-4e76-865d-ada955403103\",\n\t\t},\n\t}, \"fake-source\"), \"record created\", ActionCreate, RecordReady)\n\n\tctrl.Add(event)\n\n\tselect {\n\tcase <-eventCreated:\n\tcase <-time.After(wait.ForeverTestTimeout):\n\t\tt.Fatal(\"event not created\")\n\t}\n}\n\nfunc TestController_Queue_EmitEvents(t *testing.T) {\n\tlog.SetLevel(log.ErrorLevel)\n\n\teventsClient := fake.NewClientset().EventsV1()\n\tctrl := &Controller{\n\t\tclient:     eventsClient,\n\t\temitEvents: sets.New[Reason](RecordReady),\n\t\tqueue: workqueue.NewTypedRateLimitingQueueWithConfig[any](\n\t\t\tworkqueue.DefaultTypedControllerRateLimiter[any](),\n\t\t\tworkqueue.TypedRateLimitingQueueConfig[any]{Name: controllerName},\n\t\t),\n\t\thostname:        controllerName,\n\t\tmaxQueuedEvents: 1,\n\t}\n\n\tevent := NewEvent(NewObjectReference(&v1.Pod{\n\t\tTypeMeta: metav1.TypeMeta{\n\t\t\tKind:       \"Pod\",\n\t\t\tAPIVersion: \"v1\",\n\t\t},\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:      \"fake-object\",\n\t\t\tNamespace: v1.NamespaceDefault,\n\t\t\tUID:       \"9de3fc19-8aeb-4e76-865d-ada955403103\",\n\t\t},\n\t}, \"fake-source\"), \"record created\", ActionCreate, RecordReady)\n\n\tctrl.Add(event)\n\n\tqueueItem, shutdown := ctrl.queue.Get()\n\trequire.False(t, shutdown)\n\tvalue, ok := queueItem.(*eventsv1.Event)\n\tassert.True(t, ok)\n\tassert.NotNil(t, value)\n\n\tassert.Contains(t, value.Name, \"fake-object.\")\n\tassert.Contains(t, value.Reason, RecordReady)\n}\n\nfunc TestController_ProcessNextWorkItem_RequeuesOnError(t *testing.T) {\n\tlog.SetLevel(log.ErrorLevel)\n\tctx := t.Context()\n\n\tcreateAttempts := 0\n\tkubeClient := fake.NewClientset()\n\tkubeClient.PrependReactor(\"create\", \"events\", func(_ clienttesting.Action) (bool, runtime.Object, error) {\n\t\tcreateAttempts++\n\t\tif createAttempts <= maxTriesPerEvent {\n\t\t\treturn true, nil, fmt.Errorf(\"transient API error\")\n\t\t}\n\t\treturn true, nil, nil\n\t})\n\n\teventCreated := make(chan struct{}, 1)\n\tkubeClient.PrependReactor(\"create\", \"events\", func(_ clienttesting.Action) (bool, runtime.Object, error) {\n\t\tcreateAttempts++\n\t\tif createAttempts <= maxTriesPerEvent {\n\t\t\treturn true, nil, fmt.Errorf(\"transient API error\")\n\t\t}\n\t\teventCreated <- struct{}{}\n\t\treturn true, nil, nil\n\t})\n\n\tctrl := &Controller{\n\t\tclient:     kubeClient.EventsV1(),\n\t\temitEvents: sets.New[Reason](RecordReady),\n\t\tqueue: workqueue.NewTypedRateLimitingQueueWithConfig[any](\n\t\t\tworkqueue.NewTypedMaxOfRateLimiter[any](\n\t\t\t\tworkqueue.NewTypedItemFastSlowRateLimiter[any](1*time.Millisecond, 1*time.Millisecond, 5),\n\t\t\t),\n\t\t\tworkqueue.TypedRateLimitingQueueConfig[any]{Name: controllerName},\n\t\t),\n\t\thostname:        controllerName,\n\t\tmaxQueuedEvents: maxQueuedEvents,\n\t}\n\n\tctrl.Run(ctx)\n\n\tevent := NewEvent(NewObjectReference(&v1.Pod{\n\t\tTypeMeta: metav1.TypeMeta{\n\t\t\tKind:       \"Pod\",\n\t\t\tAPIVersion: \"v1\",\n\t\t},\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:      \"fake-object\",\n\t\t\tNamespace: v1.NamespaceDefault,\n\t\t\tUID:       \"9de3fc19-8aeb-4e76-865d-ada955403103\",\n\t\t},\n\t}, \"fake-source\"), \"record created\", ActionCreate, RecordReady)\n\n\tctrl.Add(event)\n\n\tselect {\n\tcase <-eventCreated:\n\t\tassert.Greater(t, createAttempts, 1, \"event should have been retried at least once\")\n\tcase <-time.After(5 * time.Second):\n\t\tt.Fatalf(\"event was not retried and delivered; only %d attempt(s) made\", createAttempts)\n\t}\n}\n"
  },
  {
    "path": "pkg/events/fake/fake.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage fake\n\nimport (\n\t\"github.com/stretchr/testify/mock\"\n\n\t\"sigs.k8s.io/external-dns/pkg/events\"\n)\n\ntype EventEmitter struct {\n\tmock.Mock\n}\n\nfunc (m *EventEmitter) Add(events ...events.Event) {\n\tm.Called(events[0])\n}\n\nfunc NewFakeEventEmitter() *EventEmitter {\n\tm := &EventEmitter{}\n\tm.On(\"Add\", mock.AnythingOfType(\"events.Event\"))\n\treturn m\n}\n"
  },
  {
    "path": "pkg/events/fake/fake_test.go",
    "content": "/*\nCopyright 2026 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage fake\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"sigs.k8s.io/external-dns/pkg/events\"\n)\n\nfunc TestNewFakeEventEmitter(t *testing.T) {\n\temitter := NewFakeEventEmitter()\n\n\trequire.NotNil(t, emitter)\n\tassert.IsType(t, &EventEmitter{}, emitter)\n}\n\nfunc TestEventEmitter_Add_SingleEvent(t *testing.T) {\n\temitter := NewFakeEventEmitter()\n\n\tevent := events.NewEvent(nil, \"test message\", events.ActionCreate, events.RecordReady)\n\n\temitter.Add(event)\n\n\temitter.AssertExpectations(t)\n}\n\nfunc TestEventEmitter_Add_MultipleEvents(t *testing.T) {\n\temitter := NewFakeEventEmitter()\n\n\tevent1 := events.NewEvent(nil, \"test message 1\", events.ActionCreate, events.RecordReady)\n\tevent2 := events.NewEvent(nil, \"test message 2\", events.ActionUpdate, events.RecordReady)\n\n\t// Note: The implementation only processes events[0], so we test that behavior\n\temitter.Add(event1, event2)\n\n\temitter.AssertExpectations(t)\n}\n\nfunc TestEventEmitter_Add_WithDifferentEventTypes(t *testing.T) {\n\ttests := []struct {\n\t\tname   string\n\t\taction events.Action\n\t\treason events.Reason\n\t}{\n\t\t{\n\t\t\tname:   \"create action with RecordReady\",\n\t\t\taction: events.ActionCreate,\n\t\t\treason: events.RecordReady,\n\t\t},\n\t\t{\n\t\t\tname:   \"update action with RecordReady\",\n\t\t\taction: events.ActionUpdate,\n\t\t\treason: events.RecordReady,\n\t\t},\n\t\t{\n\t\t\tname:   \"delete action with RecordDeleted\",\n\t\t\taction: events.ActionDelete,\n\t\t\treason: events.RecordDeleted,\n\t\t},\n\t\t{\n\t\t\tname:   \"failed action with RecordError\",\n\t\t\taction: events.ActionFailed,\n\t\t\treason: events.RecordError,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\temitter := NewFakeEventEmitter()\n\t\t\tevent := events.NewEvent(nil, \"test message\", tt.action, tt.reason)\n\n\t\t\temitter.Add(event)\n\n\t\t\temitter.AssertExpectations(t)\n\t\t})\n\t}\n}\n\nfunc TestEventEmitter_Add_VerifyMockCalled(t *testing.T) {\n\temitter := &EventEmitter{}\n\n\tevent := events.NewEvent(nil, \"test message\", events.ActionCreate, events.RecordReady)\n\n\temitter.On(\"Add\", event).Return()\n\n\temitter.Add(event)\n\n\temitter.AssertExpectations(t)\n}\n\nfunc TestEventEmitter_Add_VerifyMockCalledWithAnyEvent(t *testing.T) {\n\temitter := NewFakeEventEmitter()\n\n\tevent := events.NewEvent(nil, \"test message\", events.ActionCreate, events.RecordReady)\n\n\temitter.Add(event)\n\n\t// NewFakeEventEmitter sets up mock.AnythingOfType, so this should pass\n\temitter.AssertExpectations(t)\n}\n\nfunc TestEventEmitter_Add_EmptyEventsPanics(t *testing.T) {\n\temitter := NewFakeEventEmitter()\n\n\t// The Add method accesses events[0] without checking if events is empty\n\t// This will panic if called with no arguments\n\tassert.Panics(t, func() {\n\t\temitter.Add()\n\t}, \"Add() should panic when called with no events\")\n}\n"
  },
  {
    "path": "pkg/events/types.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage events\n\nimport (\n\t\"fmt\"\n\t\"reflect\"\n\t\"regexp\"\n\t\"slices\"\n\t\"strings\"\n\t\"time\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\tapiv1 \"k8s.io/api/core/v1\"\n\teventsv1 \"k8s.io/api/events/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/runtime/schema\"\n\t\"k8s.io/apimachinery/pkg/types\"\n\t\"k8s.io/apimachinery/pkg/util/sets\"\n\t\"k8s.io/client-go/kubernetes/scheme\"\n\truntime \"sigs.k8s.io/controller-runtime/pkg/client\"\n)\n\nconst (\n\tActionCreate  Action = \"Created\"\n\tActionUpdate  Action = \"Updated\"\n\tActionDelete  Action = \"Deleted\"\n\tActionFailed  Action = \"FailedSync\"\n\tRecordReady   Reason = \"RecordReady\"\n\tRecordDeleted Reason = \"RecordDeleted\"\n\tRecordError   Reason = \"RecordError\"\n\n\tEventTypeNormal  EventType = EventType(apiv1.EventTypeNormal)\n\tEventTypeWarning EventType = EventType(apiv1.EventTypeWarning)\n)\n\nvar (\n\tinvalidChars           = regexp.MustCompile(`[^a-z0-9.\\-]`)\n\tstartsWithAlphaNumeric = regexp.MustCompile(`^[a-z0-9]`)\n\tendsWithAlphaNumeric   = regexp.MustCompile(`[a-z0-9]$`)\n)\n\ntype (\n\t// Action values for actions\n\tAction string\n\t// Reason types of Event Reasons\n\tReason string\n\t// EventType values for event types\n\tEventType    string\n\tConfigOption func(*Config)\n\n\tEvent struct {\n\t\tref     ObjectReference\n\t\tmessage string\n\t\tsource  string\n\t\taction  Action\n\t\teType   EventType\n\t\treason  Reason\n\t}\n\n\t// ObjectReference holds metadata about a Kubernetes object for event correlation.\n\t// TODO: consider make fields private. Ensuring data integrity, encapsulation and immutability.\n\tObjectReference struct {\n\t\tKind       string\n\t\tApiVersion string\n\t\tNamespace  string\n\t\tName       string\n\t\tUID        types.UID\n\t\tSource     string\n\t}\n\n\tConfig struct {\n\t\temitEvents sets.Set[Reason]\n\t\tdryRun     bool\n\t}\n\n\t// EndpointInfo defines the interface for endpoint data needed to create events.\n\tEndpointInfo interface {\n\t\tGetDNSName() string\n\t\tGetRecordType() string\n\t\tGetRecordTTL() int64\n\t\tGetTargets() []string\n\t\tGetOwner() string\n\t\tRefObject() *ObjectReference\n\t}\n)\n\nfunc NewObjectReference(obj runtime.Object, source string) *ObjectReference {\n\t// Kubernetes API doesn't populate TypeMeta (Kind/APIVersion) when retrieving\n\t// objects via informers. Look up the Kind from the scheme without mutating the object.\n\tgvk := obj.GetObjectKind().GroupVersionKind()\n\tif gvk.Kind == \"\" {\n\t\tgvks, _, err := scheme.Scheme.ObjectKinds(obj)\n\t\tif err == nil && len(gvks) > 0 {\n\t\t\tgvk = gvks[0]\n\t\t} else {\n\t\t\t// Fallback to reflection for types not in scheme\n\t\t\tgvk = schema.GroupVersionKind{Kind: reflect.TypeOf(obj).Elem().Name()}\n\t\t}\n\t}\n\treturn &ObjectReference{\n\t\tKind:       gvk.Kind,\n\t\tApiVersion: gvk.GroupVersion().String(),\n\t\tNamespace:  obj.GetNamespace(),\n\t\tName:       obj.GetName(),\n\t\tUID:        obj.GetUID(),\n\t\tSource:     source,\n\t}\n}\n\nfunc NewEvent(obj *ObjectReference, msg string, a Action, r Reason) Event {\n\tif obj == nil {\n\t\treturn Event{}\n\t}\n\treturn Event{\n\t\tref:     *obj,\n\t\tmessage: msg,\n\t\teType:   EventTypeNormal,\n\t\taction:  a,\n\t\treason:  r,\n\t\tsource:  obj.Source,\n\t}\n}\n\n// NewEventFromEndpoint creates an Event from an EndpointInfo with formatted message.\nfunc NewEventFromEndpoint(ep EndpointInfo, a Action, r Reason) Event {\n\tif ep == nil || ep.RefObject() == nil {\n\t\treturn Event{}\n\t}\n\tmsg := fmt.Sprintf(\"(external-dns) record:%s,owner:%s,type:%s,ttl:%d,targets:%s\",\n\t\tep.GetDNSName(), ep.GetOwner(), ep.GetRecordType(), ep.GetRecordTTL(),\n\t\tstrings.Join(ep.GetTargets(), \",\"))\n\treturn NewEvent(ep.RefObject(), msg, a, r)\n}\n\nfunc (e *Event) description() string {\n\treturn fmt.Sprintf(\"%s/%s/%s\", e.ref.Kind, e.ref.Namespace, e.ref.Name)\n}\n\nfunc (e *Event) Action() Action {\n\treturn e.action\n}\n\nfunc (e *Event) Reason() Reason {\n\treturn e.reason\n}\n\nfunc (e *Event) EventType() EventType {\n\treturn e.eType\n}\n\nfunc (e *Event) event() *eventsv1.Event {\n\tif e.ref.Name == \"\" {\n\t\tlog.Debug(\"skipping event for resources as the name is not generated yet\")\n\t\treturn nil\n\t}\n\tmessage := e.message\n\t// https://github.com/kubernetes/api/blob/7da28ad7db85e33ab8be3b89e63cad4c07b37fb2/events/v1/types.go#L77\n\tif len(message) > 1024 {\n\t\tmessage = message[0:1021] + \"...\"\n\t}\n\n\ttimestamp := metav1.MicroTime{Time: time.Now()}\n\n\t// Events are namespaced resources. For cluster-scoped objects like Nodes,\n\t// the namespace is empty, so we default to \"default\" namespace.\n\tnamespace := e.ref.Namespace\n\tif namespace == \"\" {\n\t\tnamespace = \"default\"\n\t}\n\n\tevent := &eventsv1.Event{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:      sanitize(e.ref.Name),\n\t\t\tNamespace: namespace,\n\t\t},\n\t\tEventTime:           timestamp,\n\t\tReportingInstance:   controllerName + \"/source/\" + e.ref.Source,\n\t\tReportingController: controllerName,\n\t\tAction:              string(e.action),\n\t\tReason:              string(e.reason),\n\t\tNote:                message,\n\t\tType:                string(e.eType),\n\t}\n\tif e.ref.UID != \"\" {\n\t\tref := e.ref.objectRef()\n\t\tevent.Related = ref\n\t\tevent.Regarding = *ref\n\t}\n\treturn event\n}\n\n// Sanitize input to comply with RFC 1123 subdomain naming requirements\nfunc sanitize(input string) string {\n\tt := metav1.Time{Time: time.Now()}\n\tif input == \"\" {\n\t\treturn fmt.Sprintf(\"a.%x\", t.UnixNano())\n\t}\n\tsanitized := invalidChars.ReplaceAllString(strings.ToLower(input), \"-\")\n\n\t// the name should start with an alphanumeric character\n\tif len(sanitized) > 0 && !startsWithAlphaNumeric.MatchString(sanitized) {\n\t\tsanitized = \"a\" + sanitized\n\t}\n\n\t// the name should end with an alphanumeric character\n\tif len(sanitized) > 0 && !endsWithAlphaNumeric.MatchString(sanitized) {\n\t\tsanitized += \"z\"\n\t}\n\n\tsanitized = invalidChars.ReplaceAllString(sanitized, \"-\")\n\n\treturn fmt.Sprintf(\"%v.%x\", sanitized, t.UnixNano())\n}\n\nfunc WithDryRun(dryRun bool) ConfigOption {\n\treturn func(c *Config) {\n\t\tc.dryRun = dryRun\n\t}\n}\n\nfunc WithEmitEvents(events []string) ConfigOption {\n\treturn func(c *Config) {\n\t\tif len(events) > 0 {\n\t\t\tc.emitEvents = sets.New[Reason]()\n\t\t\tfor _, event := range events {\n\t\t\t\tif slices.Contains([]string{string(RecordReady), string(RecordError)}, event) {\n\t\t\t\t\tc.emitEvents.Insert(Reason(event))\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc NewConfig(opts ...ConfigOption) *Config {\n\tc := &Config{}\n\tfor _, opt := range opts {\n\t\topt(c)\n\t}\n\treturn c\n}\n\nfunc (c *Config) IsEnabled() bool {\n\treturn len(c.emitEvents) > 0\n}\n\nfunc (r *ObjectReference) objectRef() *apiv1.ObjectReference {\n\treturn &apiv1.ObjectReference{\n\t\tKind:       r.Kind,\n\t\tNamespace:  r.Namespace,\n\t\tName:       r.Name,\n\t\tUID:        r.UID,\n\t\tAPIVersion: r.ApiVersion,\n\t}\n}\n"
  },
  {
    "path": "pkg/events/types_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage events\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\tapiv1 \"k8s.io/api/core/v1\"\n\teventsv1 \"k8s.io/api/events/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/runtime\"\n\t\"k8s.io/apimachinery/pkg/util/sets\"\n\tctrlruntime \"sigs.k8s.io/controller-runtime/pkg/client\"\n)\n\nfunc TestNewObjectReference_DoesNotMutateObject(t *testing.T) {\n\t// Verify that NewObjectReference does NOT mutate the original object\n\tpod := &apiv1.Pod{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:      \"test-pod\",\n\t\t\tNamespace: \"default\",\n\t\t},\n\t}\n\tpodCopy := pod.DeepCopy()\n\n\t_ = NewObjectReference(pod, \"test\")\n\n\tassert.Equal(t, podCopy, pod)\n}\n\nfunc TestSanitize(t *testing.T) {\n\ttests := []struct {\n\t\tinput    string\n\t\texpected string // expected prefix of sanitized output\n\t}{\n\t\t{\"My.Resource_1\", \"my.resource-1.\"},\n\t\t{\"!@#bad*chars\", \"a---bad-chars.\"},\n\t\t{\"-start\", \"a-start.\"},\n\t\t{\"end-\", \"end-z.\"},\n\t\t{\"-both-\", \"a-both-z.\"},\n\t\t{\"\", \"a.\"},\n\t\t{\"ALLCAPS\", \"allcaps.\"},\n\t\t{\"foo.bar\", \"foo.bar.\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.input, func(t *testing.T) {\n\t\t\tresult := sanitize(tt.input)\n\t\t\trequire.True(t, strings.HasPrefix(result, tt.expected), \"expected prefix %q, got %q\", tt.expected, result)\n\t\t\trequire.Greater(t, len(result), len(tt.expected))\n\t\t})\n\t}\n}\n\nfunc TestEvent_Reference(t *testing.T) {\n\ttests := []struct {\n\t\tkind      string\n\t\tnamespace string\n\t\tname      string\n\t\texpected  string\n\t}{\n\t\t{\"Pod\", \"default\", \"nginx\", \"Pod/default/nginx\"},\n\t\t{\"Service\", \"prod\", \"api\", \"Service/prod/api\"},\n\t\t{\"\", \"\", \"\", \"//\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tev := Event{\n\t\t\tref: ObjectReference{\n\t\t\t\tKind:      tt.kind,\n\t\t\t\tNamespace: tt.namespace,\n\t\t\t\tName:      tt.name,\n\t\t\t\tSource:    \"fake-source\",\n\t\t\t},\n\t\t}\n\t\trequire.Equal(t, tt.expected, ev.description())\n\t}\n}\n\nfunc TestEvent_NewEvents(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tevent   Event\n\t\tasserts func(e *eventsv1.Event)\n\t}{\n\t\t{\n\t\t\tname:  \"empty event\",\n\t\t\tevent: NewEvent(nil, \"\", ActionCreate, RecordReady),\n\t\t\tasserts: func(e *eventsv1.Event) {\n\t\t\t\trequire.Nil(t, e)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"event without uuid\",\n\t\t\tevent: NewEvent(NewObjectReference(&apiv1.Pod{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tKind:       \"Pod\",\n\t\t\t\t\tAPIVersion: \"apiv1\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"fake-pod\",\n\t\t\t\t\tNamespace: apiv1.NamespaceDefault,\n\t\t\t\t},\n\t\t\t}, \"fake\"), \"\", ActionCreate, RecordReady),\n\t\t\tasserts: func(e *eventsv1.Event) {\n\t\t\t\trequire.NotNil(t, e)\n\t\t\t\trequire.Contains(t, e.Name, \"fake-pod.\")\n\t\t\t\trequire.Equal(t, apiv1.NamespaceDefault, e.Namespace)\n\t\t\t\trequire.Nil(t, e.Related)\n\t\t\t\trequire.Equal(t, apiv1.ObjectReference{}, e.Regarding)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"event with uuid\",\n\t\t\tevent: NewEvent(NewObjectReference(&apiv1.Pod{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tKind:       \"Pod\",\n\t\t\t\t\tAPIVersion: \"apiv1\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"fake-pod\",\n\t\t\t\t\tNamespace: apiv1.NamespaceDefault,\n\t\t\t\t\tUID:       \"9de3fc19-8aeb-4e76-865d-ada955403103\",\n\t\t\t\t},\n\t\t\t}, \"fake\"), \"\", ActionCreate, RecordReady),\n\t\t\tasserts: func(e *eventsv1.Event) {\n\t\t\t\trequire.NotNil(t, e)\n\t\t\t\trequire.Contains(t, e.Name, \"fake-pod.\")\n\t\t\t\trequire.NotNil(t, e.Related)\n\t\t\t\trequire.NotNil(t, e.Regarding)\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(_ *testing.T) {\n\t\t\ttt.asserts(tt.event.event())\n\t\t})\n\t}\n}\n\nfunc TestEvent_Transpose(t *testing.T) {\n\tev := NewEvent(&ObjectReference{\n\t\tKind:      \"Pod\",\n\t\tNamespace: \"default\",\n\t\tName:      \"nginx\",\n\t}, \"test message\", ActionCreate, RecordReady)\n\n\tevent := ev.event()\n\trequire.NotNil(t, event)\n\trequire.Contains(t, event.ObjectMeta.Name, ev.ref.Name)\n\trequire.Equal(t, \"default\", event.ObjectMeta.Namespace)\n\trequire.Equal(t, string(ActionCreate), event.Action)\n\trequire.Equal(t, string(RecordReady), event.Reason)\n\trequire.Equal(t, \"test message\", event.Note)\n\trequire.Equal(t, apiv1.EventTypeNormal, event.Type)\n\trequire.Equal(t, controllerName, event.ReportingController)\n\trequire.Contains(t, event.ReportingInstance, controllerName+\"/source/\")\n\n\tlongMsg := strings.Repeat(\"a\", 2000)\n\tev.message = longMsg\n\tevent = ev.event()\n\trequire.Equal(t, longMsg[:1021]+\"...\", event.Note)\n\n\tev.ref.Name = \"\"\n\trequire.Nil(t, ev.event())\n}\n\nfunc TestWithEmitEvents(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tinput    []string\n\t\texpected sets.Set[Reason]\n\t\tassert   func(c *Config)\n\t}{\n\t\t{\n\t\t\tname:     \"valid events\",\n\t\t\tinput:    []string{string(RecordReady), string(RecordError)},\n\t\t\texpected: sets.New[Reason](RecordReady, RecordError),\n\t\t\tassert: func(c *Config) {\n\t\t\t\trequire.Equal(t, sets.New[Reason](RecordReady, RecordError), c.emitEvents)\n\t\t\t\trequire.True(t, c.IsEnabled())\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid event\",\n\t\t\tinput:    []string{\"InvalidEvent\"},\n\t\t\texpected: sets.New[Reason](),\n\t\t\tassert: func(c *Config) {\n\t\t\t\trequire.Equal(t, sets.New[Reason](), c.emitEvents)\n\t\t\t\trequire.False(t, c.IsEnabled())\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:     \"mixed valid and invalid\",\n\t\t\tinput:    []string{string(RecordReady), \"InvalidEvent\"},\n\t\t\texpected: sets.New[Reason](RecordReady),\n\t\t\tassert: func(c *Config) {\n\t\t\t\trequire.Equal(t, sets.New[Reason](RecordReady), c.emitEvents)\n\t\t\t\trequire.True(t, c.IsEnabled())\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:     \"empty input\",\n\t\t\tinput:    []string{},\n\t\t\texpected: nil,\n\t\t\tassert: func(c *Config) {\n\t\t\t\trequire.NotNil(t, c)\n\t\t\t\trequire.False(t, c.IsEnabled())\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(_ *testing.T) {\n\t\t\tcfg := &Config{}\n\t\t\topt := WithEmitEvents(tt.input)\n\t\t\topt(cfg)\n\t\t\ttt.assert(cfg)\n\t\t})\n\t}\n}\n\n// mockEndpointInfo implements EndpointInfo for testing\ntype mockEndpointInfo struct {\n\tdnsName    string\n\trecordType string\n\trecordTTL  int64\n\ttargets    []string\n\towner      string\n\trefObject  *ObjectReference\n}\n\nfunc (m *mockEndpointInfo) GetDNSName() string          { return m.dnsName }\nfunc (m *mockEndpointInfo) GetRecordType() string       { return m.recordType }\nfunc (m *mockEndpointInfo) GetRecordTTL() int64         { return m.recordTTL }\nfunc (m *mockEndpointInfo) GetTargets() []string        { return m.targets }\nfunc (m *mockEndpointInfo) GetOwner() string            { return m.owner }\nfunc (m *mockEndpointInfo) RefObject() *ObjectReference { return m.refObject }\n\nfunc TestNewEventFromEndpoint(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tep      EndpointInfo\n\t\taction  Action\n\t\treason  Reason\n\t\tasserts func(t *testing.T, ev Event)\n\t}{\n\t\t{\n\t\t\tname:   \"nil endpoint returns empty event\",\n\t\t\tep:     nil,\n\t\t\taction: ActionCreate,\n\t\t\treason: RecordReady,\n\t\t\tasserts: func(t *testing.T, ev Event) {\n\t\t\t\trequire.Equal(t, Event{}, ev)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"endpoint with nil RefObject returns empty event\",\n\t\t\tep: &mockEndpointInfo{\n\t\t\t\tdnsName:    \"example.com\",\n\t\t\t\trecordType: \"A\",\n\t\t\t\trecordTTL:  300,\n\t\t\t\ttargets:    []string{\"10.0.0.1\"},\n\t\t\t\towner:      \"default\",\n\t\t\t\trefObject:  nil,\n\t\t\t},\n\t\t\taction: ActionCreate,\n\t\t\treason: RecordReady,\n\t\t\tasserts: func(t *testing.T, ev Event) {\n\t\t\t\trequire.Equal(t, Event{}, ev)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"valid endpoint with create action\",\n\t\t\tep: &mockEndpointInfo{\n\t\t\t\tdnsName:    \"test.example.com\",\n\t\t\t\trecordType: \"A\",\n\t\t\t\trecordTTL:  300,\n\t\t\t\ttargets:    []string{\"10.0.0.1\", \"10.0.0.2\"},\n\t\t\t\towner:      \"my-owner\",\n\t\t\t\trefObject: &ObjectReference{\n\t\t\t\t\tKind:      \"Service\",\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\tName:      \"my-service\",\n\t\t\t\t\tSource:    \"service\",\n\t\t\t\t},\n\t\t\t},\n\t\t\taction: ActionCreate,\n\t\t\treason: RecordReady,\n\t\t\tasserts: func(t *testing.T, ev Event) {\n\t\t\t\trequire.Equal(t, ActionCreate, ev.action)\n\t\t\t\trequire.Equal(t, RecordReady, ev.reason)\n\t\t\t\trequire.Equal(t, EventTypeNormal, ev.eType)\n\t\t\t\trequire.Equal(t, \"Service\", ev.ref.Kind)\n\t\t\t\trequire.Equal(t, \"default\", ev.ref.Namespace)\n\t\t\t\trequire.Equal(t, \"my-service\", ev.ref.Name)\n\t\t\t\trequire.Contains(t, ev.message, \"record:test.example.com\")\n\t\t\t\trequire.Contains(t, ev.message, \"owner:my-owner\")\n\t\t\t\trequire.Contains(t, ev.message, \"type:A\")\n\t\t\t\trequire.Contains(t, ev.message, \"ttl:300\")\n\t\t\t\trequire.Contains(t, ev.message, \"targets:10.0.0.1,10.0.0.2\")\n\t\t\t\trequire.Contains(t, ev.message, \"(external-dns)\")\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"valid endpoint with delete action\",\n\t\t\tep: &mockEndpointInfo{\n\t\t\t\tdnsName:    \"deleted.example.com\",\n\t\t\t\trecordType: \"CNAME\",\n\t\t\t\trecordTTL:  0,\n\t\t\t\ttargets:    []string{\"target.example.com\"},\n\t\t\t\towner:      \"\",\n\t\t\t\trefObject: &ObjectReference{\n\t\t\t\t\tKind:      \"Ingress\",\n\t\t\t\t\tNamespace: \"prod\",\n\t\t\t\t\tName:      \"my-ingress\",\n\t\t\t\t\tSource:    \"ingress\",\n\t\t\t\t},\n\t\t\t},\n\t\t\taction: ActionDelete,\n\t\t\treason: RecordDeleted,\n\t\t\tasserts: func(t *testing.T, ev Event) {\n\t\t\t\trequire.Equal(t, ActionDelete, ev.action)\n\t\t\t\trequire.Equal(t, RecordDeleted, ev.reason)\n\t\t\t\trequire.Contains(t, ev.message, \"record:deleted.example.com\")\n\t\t\t\trequire.Contains(t, ev.message, \"type:CNAME\")\n\t\t\t\trequire.Contains(t, ev.message, \"ttl:0\")\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"endpoint for cluster-scoped resource (Node)\",\n\t\t\tep: &mockEndpointInfo{\n\t\t\t\tdnsName:    \"node1.example.com\",\n\t\t\t\trecordType: \"A\",\n\t\t\t\trecordTTL:  60,\n\t\t\t\ttargets:    []string{\"192.168.1.1\"},\n\t\t\t\towner:      \"default\",\n\t\t\t\trefObject: &ObjectReference{\n\t\t\t\t\tKind:      \"Node\",\n\t\t\t\t\tNamespace: \"\", // cluster-scoped\n\t\t\t\t\tName:      \"node1\",\n\t\t\t\t\tSource:    \"node\",\n\t\t\t\t},\n\t\t\t},\n\t\t\taction: ActionCreate,\n\t\t\treason: RecordReady,\n\t\t\tasserts: func(t *testing.T, ev Event) {\n\t\t\t\trequire.Equal(t, ActionCreate, ev.action)\n\t\t\t\trequire.Empty(t, ev.ref.Namespace)\n\n\t\t\t\tk8sEvent := ev.event()\n\t\t\t\trequire.NotNil(t, k8sEvent)\n\t\t\t\trequire.Equal(t, \"default\", k8sEvent.Namespace)\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tev := NewEventFromEndpoint(tt.ep, tt.action, tt.reason)\n\t\t\ttt.asserts(t, ev)\n\t\t})\n\t}\n}\n\nfunc TestNewObjectReference(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tobj      ctrlruntime.Object\n\t\tsource   string\n\t\texpected *ObjectReference\n\t}{\n\t\t{\n\t\t\tname: \"Pod with TypeMeta already set\",\n\t\t\tobj: &apiv1.Pod{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tKind:       \"Pod\",\n\t\t\t\t\tAPIVersion: \"v1\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"my-pod\",\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\tUID:       \"pod-uid-123\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tsource: \"pod\",\n\t\t\texpected: &ObjectReference{\n\t\t\t\tKind:       \"Pod\",\n\t\t\t\tApiVersion: \"v1\",\n\t\t\t\tNamespace:  \"default\",\n\t\t\t\tName:       \"my-pod\",\n\t\t\t\tUID:        \"pod-uid-123\",\n\t\t\t\tSource:     \"pod\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"Pod without TypeMeta (simulating informer behavior)\",\n\t\t\tobj: &apiv1.Pod{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"informer-pod\",\n\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\tUID:       \"informer-uid-456\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tsource: \"pod\",\n\t\t\texpected: &ObjectReference{\n\t\t\t\tKind:       \"Pod\",\n\t\t\t\tApiVersion: \"v1\",\n\t\t\t\tNamespace:  \"kube-system\",\n\t\t\t\tName:       \"informer-pod\",\n\t\t\t\tUID:        \"informer-uid-456\",\n\t\t\t\tSource:     \"pod\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"Service without TypeMeta\",\n\t\t\tobj: &apiv1.Service{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"my-service\",\n\t\t\t\t\tNamespace: \"prod\",\n\t\t\t\t\tUID:       \"svc-uid-789\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tsource: \"service\",\n\t\t\texpected: &ObjectReference{\n\t\t\t\tKind:       \"Service\",\n\t\t\t\tApiVersion: \"v1\",\n\t\t\t\tNamespace:  \"prod\",\n\t\t\t\tName:       \"my-service\",\n\t\t\t\tUID:        \"svc-uid-789\",\n\t\t\t\tSource:     \"service\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"Node (cluster-scoped, no namespace)\",\n\t\t\tobj: &apiv1.Node{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"worker-node-1\",\n\t\t\t\t\tUID:  \"node-uid-abc\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tsource: \"node\",\n\t\t\texpected: &ObjectReference{\n\t\t\t\tKind:       \"Node\",\n\t\t\t\tApiVersion: \"v1\",\n\t\t\t\tNamespace:  \"\",\n\t\t\t\tName:       \"worker-node-1\",\n\t\t\t\tUID:        \"node-uid-abc\",\n\t\t\t\tSource:     \"node\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"Endpoints without TypeMeta\",\n\t\t\tobj: &apiv1.Endpoints{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"my-endpoints\",\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\tUID:       \"ep-uid-def\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tsource: \"endpoints\",\n\t\t\texpected: &ObjectReference{\n\t\t\t\tKind:       \"Endpoints\",\n\t\t\t\tApiVersion: \"v1\",\n\t\t\t\tNamespace:  \"default\",\n\t\t\t\tName:       \"my-endpoints\",\n\t\t\t\tUID:        \"ep-uid-def\",\n\t\t\t\tSource:     \"endpoints\",\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := NewObjectReference(tt.obj, tt.source)\n\t\t\trequire.Equal(t, tt.expected.Kind, result.Kind)\n\t\t\trequire.Equal(t, tt.expected.ApiVersion, result.ApiVersion)\n\t\t\trequire.Equal(t, tt.expected.Namespace, result.Namespace)\n\t\t\trequire.Equal(t, tt.expected.Name, result.Name)\n\t\t\trequire.Equal(t, tt.expected.UID, result.UID)\n\t\t\trequire.Equal(t, tt.expected.Source, result.Source)\n\t\t})\n\t}\n}\n\n// customObject is a type not registered in the scheme, used to test reflection fallback\ntype customObject struct {\n\tmetav1.TypeMeta\n\tmetav1.ObjectMeta\n}\n\nfunc (c *customObject) DeepCopyObject() runtime.Object {\n\treturn &customObject{\n\t\tTypeMeta:   c.TypeMeta,\n\t\tObjectMeta: *c.ObjectMeta.DeepCopy(),\n\t}\n}\n\nfunc TestNewObjectReference_ReflectionFallback(t *testing.T) {\n\t// Test that when object type is not in scheme, reflection is used to get Kind\n\tobj := &customObject{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:      \"custom-resource\",\n\t\t\tNamespace: \"custom-ns\",\n\t\t\tUID:       \"custom-uid-123\",\n\t\t},\n\t}\n\n\tref := NewObjectReference(obj, \"custom\")\n\n\t// Kind should be derived from reflection (struct name)\n\trequire.Equal(t, \"customObject\", ref.Kind)\n\t// APIVersion will be empty since it's not in scheme\n\trequire.Empty(t, ref.ApiVersion)\n\trequire.Equal(t, \"custom-ns\", ref.Namespace)\n\trequire.Equal(t, \"custom-resource\", ref.Name)\n\trequire.Equal(t, \"custom-uid-123\", string(ref.UID))\n\trequire.Equal(t, \"custom\", ref.Source)\n}\n"
  },
  {
    "path": "pkg/http/drain.go",
    "content": "/*\nCopyright 2026 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage http\n\nimport (\n\t\"io\"\n)\n\nconst (\n\t// drainMaxBytes caps how much of a response body we drain to return the\n\t// connection to the pool. A buggy or adversarial server could stream an\n\t// unbounded body; reading it all would block indefinitely and waste memory.\n\t// On success paths the JSON decoder has already consumed the payload before\n\t// the deferred DrainAndClose runs, so only trailing bytes remain. On error paths\n\t// the body is typically a short error message. 1 MiB is generous for either case.\n\tdrainMaxBytes = 1 << 20 // 1 MiB\n)\n\n// DrainAndClose drains up to drainMaxBytes of the response body before\n// closing it so the underlying TCP connection can be reused by the HTTP\n// client's connection pool. Bytes beyond the cap are left unread; the\n// connection will be discarded rather than pooled in that case, which is\n// acceptable for oversized or malformed responses.\nfunc DrainAndClose(body io.ReadCloser) {\n\t_, _ = io.Copy(io.Discard, io.LimitReader(body, drainMaxBytes))\n\t_ = body.Close()\n}\n"
  },
  {
    "path": "pkg/http/drain_test.go",
    "content": "/*\nCopyright 2026 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage http\n\nimport (\n\t\"bytes\"\n\t\"io\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\ntype trackingReadCloser struct {\n\tio.Reader\n\tclosed bool\n}\n\nfunc (t *trackingReadCloser) Close() error {\n\tt.closed = true\n\treturn nil\n}\n\nfunc TestDrainAndClose_DrainsThenCloses(t *testing.T) {\n\trc := &trackingReadCloser{Reader: strings.NewReader(\"remaining body data\")}\n\tDrainAndClose(rc)\n\tassert.True(t, rc.closed)\n\n\t// Confirm body is fully drained: reader should be at EOF.\n\tn, err := rc.Read(make([]byte, 1))\n\tassert.Equal(t, 0, n)\n\tassert.ErrorIs(t, err, io.EOF)\n}\n\nfunc TestDrainAndClose_EmptyBody(t *testing.T) {\n\trc := &trackingReadCloser{Reader: strings.NewReader(\"\")}\n\tDrainAndClose(rc)\n\tassert.True(t, rc.closed)\n}\n\nfunc TestDrainAndClose_OversizedBody(t *testing.T) {\n\t// Body is 1 byte larger than the cap; the excess byte must remain unread so\n\t// the connection is discarded rather than pooled — but Close must still be called.\n\toversized := bytes.Repeat([]byte(\"x\"), drainMaxBytes+1)\n\trc := &trackingReadCloser{Reader: bytes.NewReader(oversized)}\n\tDrainAndClose(rc)\n\tassert.True(t, rc.closed)\n\n\t// Exactly one byte should remain after the capped drain.\n\tremaining, err := io.ReadAll(rc.Reader)\n\tassert.NoError(t, err)\n\tassert.Len(t, remaining, 1, \"expected exactly 1 byte past the drain cap to remain unread\")\n}\n"
  },
  {
    "path": "pkg/http/http.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\n// ref: https://github.com/linki/instrumented_http/blob/master/client.go\n\npackage http\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/prometheus/client_golang/prometheus\"\n\n\t\"sigs.k8s.io/external-dns/pkg/metrics\"\n)\n\nvar (\n\tRequestDurationMetric = metrics.NewSummaryVecWithOpts(\n\t\tprometheus.SummaryOpts{\n\t\t\tName:        \"request_duration_seconds\",\n\t\t\tHelp:        \"The HTTP request latencies in seconds.\",\n\t\t\tSubsystem:   \"http\",\n\t\t\tConstLabels: prometheus.Labels{\"handler\": \"instrumented_http\"},\n\t\t\tObjectives:  map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001},\n\t\t},\n\t\t[]string{metrics.LabelScheme, metrics.LabelHost, metrics.LabelPath, metrics.LabelMethod, metrics.LabelStatus},\n\t)\n)\n\nfunc init() {\n\tmetrics.RegisterMetric.MustRegister(RequestDurationMetric)\n}\n\ntype CustomRoundTripper struct {\n\tnext http.RoundTripper\n}\n\n// CancelRequest is a no-op to satisfy interfaces that require it.\n// https://github.com/kubernetes/client-go/blob/34f52c14eaed7e50c845cc14e85e1c4c91e77470/transport/transport.go#L346\nfunc (r *CustomRoundTripper) CancelRequest(_ *http.Request) {\n}\n\nfunc (r *CustomRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {\n\tstart := time.Now()\n\tresp, err := r.next.RoundTrip(req)\n\n\tstatus := \"\"\n\tif resp != nil {\n\t\tstatus = fmt.Sprintf(\"%d\", resp.StatusCode)\n\t}\n\n\tRequestDurationMetric.SetWithLabels(time.Since(start).Seconds(), metrics.Labels{\n\t\tmetrics.LabelScheme: req.URL.Scheme,\n\t\tmetrics.LabelHost:   req.URL.Host,\n\t\tmetrics.LabelPath:   metrics.PathProcessor(req.URL.Path),\n\t\tmetrics.LabelMethod: req.Method,\n\t\tmetrics.LabelStatus: status,\n\t})\n\n\treturn resp, err\n}\n\nfunc NewInstrumentedClient(next *http.Client) *http.Client {\n\tif next == nil {\n\t\tnext = http.DefaultClient\n\t}\n\n\tnext.Transport = NewInstrumentedTransport(next.Transport)\n\n\treturn next\n}\n\nfunc NewInstrumentedTransport(next http.RoundTripper) http.RoundTripper {\n\tif next == nil {\n\t\tnext = http.DefaultTransport\n\t}\n\n\treturn &CustomRoundTripper{next: next}\n}\n"
  },
  {
    "path": "pkg/http/http_benchmark_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage http\n\nimport (\n\t\"bytes\"\n\t\"io\"\n\t\"net/http\"\n\t\"sync\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\ntype roundTripFunc func(req *http.Request) *http.Response\n\nfunc (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {\n\treturn f(req), nil\n}\n\n// newTestClient returns *http.client with Transport replaced to avoid making real calls\nfunc newTestClient(fn roundTripFunc) *http.Client {\n\treturn &http.Client{\n\t\tTransport: NewInstrumentedTransport(fn),\n\t}\n}\n\ntype apiUnderTest struct {\n\tclient  *http.Client\n\tbaseURL string\n}\n\nfunc (api *apiUnderTest) doStuff() ([]byte, error) {\n\tresp, err := api.client.Get(api.baseURL + \"/some/path\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\treturn io.ReadAll(resp.Body)\n}\n\nfunc BenchmarkRoundTripper(b *testing.B) {\n\tclient := newTestClient(func(_ *http.Request) *http.Response {\n\t\treturn &http.Response{\n\t\t\tStatusCode: http.StatusOK,\n\t\t\tBody:       io.NopCloser(bytes.NewBufferString(`OK`)),\n\t\t\tHeader:     make(http.Header),\n\t\t}\n\t})\n\n\tfor b.Loop() {\n\t\tapi := apiUnderTest{client, \"http://example.com\"}\n\t\tbody, err := api.doStuff()\n\t\trequire.NoError(b, err)\n\t\tassert.Equal(b, []byte(\"OK\"), body)\n\t}\n}\n\nfunc TestRoundTripper_Concurrent(t *testing.T) {\n\tclient := newTestClient(func(_ *http.Request) *http.Response {\n\t\treturn &http.Response{\n\t\t\tStatusCode: http.StatusOK,\n\t\t\tBody:       io.NopCloser(bytes.NewBufferString(`OK`)),\n\t\t\tHeader:     make(http.Header),\n\t\t}\n\t})\n\tapi := &apiUnderTest{client: client, baseURL: \"http://example.com\"}\n\n\tconst numGoroutines = 100\n\tvar wg sync.WaitGroup\n\twg.Add(numGoroutines)\n\n\tfor range numGoroutines {\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tbody, err := api.doStuff()\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.Equal(t, []byte(\"OK\"), body)\n\t\t}()\n\t}\n\twg.Wait()\n}\n"
  },
  {
    "path": "pkg/http/http_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage http\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\ntype dummyTransport struct{}\n\nfunc (d *dummyTransport) RoundTrip(_ *http.Request) (*http.Response, error) {\n\treturn nil, fmt.Errorf(\"dummy error\")\n}\n\nfunc TestNewInstrumentedTransport(t *testing.T) {\n\tdt := &dummyTransport{}\n\trt := NewInstrumentedTransport(dt)\n\tcrt, ok := rt.(*CustomRoundTripper)\n\trequire.True(t, ok)\n\trequire.Equal(t, dt, crt.next)\n\n\t// Should default to http.DefaultTransport if nil\n\trt2 := NewInstrumentedTransport(nil)\n\tcrt2, ok := rt2.(*CustomRoundTripper)\n\trequire.True(t, ok)\n\trequire.Equal(t, http.DefaultTransport, crt2.next)\n}\n\nfunc TestNewInstrumentedClient(t *testing.T) {\n\tclient := &http.Client{Transport: &dummyTransport{}}\n\tresult := NewInstrumentedClient(client)\n\trequire.Equal(t, client, result)\n\t_, ok := result.Transport.(*CustomRoundTripper)\n\trequire.True(t, ok)\n\n\t// Should default to http.DefaultClient if nil\n\tresult2 := NewInstrumentedClient(nil)\n\trequire.Equal(t, http.DefaultClient, result2)\n\t_, ok = result2.Transport.(*CustomRoundTripper)\n\trequire.True(t, ok)\n}\n\nfunc TestCancelRequest(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\ttitle              string\n\t\tcustomRoundTripper CustomRoundTripper\n\t\trequest            *http.Request\n\t}{\n\t\t{\n\t\t\ttitle:              \"CancelRequest does nothing\",\n\t\t\tcustomRoundTripper: CustomRoundTripper{},\n\t\t\trequest:            &http.Request{},\n\t\t},\n\t} {\n\t\tt.Run(tt.title, func(_ *testing.T) {\n\t\t\ttt.customRoundTripper.CancelRequest(tt.request)\n\t\t})\n\t}\n}\n\ntype mockRoundTripper struct {\n\tresponse *http.Response\n\terror    error\n}\n\nfunc (mrt mockRoundTripper) RoundTrip(*http.Request) (*http.Response, error) {\n\treturn mrt.response, mrt.error\n}\n\nfunc TestRoundTrip(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\ttitle            string\n\t\tnextRoundTripper mockRoundTripper\n\t\trequest          *http.Request\n\t\tmethod           string\n\t\turl              string\n\t\tbody             io.Reader\n\n\t\texpectError      bool\n\t\texpectedResponse *http.Response\n\t}{\n\t\t{\n\t\t\ttitle:            \"RoundTrip returns no error\",\n\t\t\tnextRoundTripper: mockRoundTripper{},\n\t\t\trequest: &http.Request{\n\t\t\t\tMethod: http.MethodGet,\n\t\t\t\tURL: &url.URL{\n\t\t\t\t\tScheme: \"HTTPS\",\n\t\t\t\t\tHost:   \"test.local\",\n\t\t\t\t\tPath:   \"/path\",\n\t\t\t\t},\n\t\t\t\tBody: nil,\n\t\t\t},\n\t\t\texpectError:      false,\n\t\t\texpectedResponse: nil,\n\t\t},\n\t\t{\n\t\t\ttitle: \"RoundTrip extracts status from request\",\n\t\t\tnextRoundTripper: mockRoundTripper{\n\t\t\t\tresponse: &http.Response{\n\t\t\t\t\tStatusCode: http.StatusOK,\n\t\t\t\t},\n\t\t\t},\n\t\t\trequest: &http.Request{\n\t\t\t\tMethod: http.MethodGet,\n\t\t\t\tURL: &url.URL{\n\t\t\t\t\tScheme: \"HTTPS\",\n\t\t\t\t\tHost:   \"test.local\",\n\t\t\t\t\tPath:   \"/path\",\n\t\t\t\t},\n\t\t\t\tBody: nil,\n\t\t\t},\n\t\t\texpectError: false,\n\t\t\texpectedResponse: &http.Response{\n\t\t\t\tStatusCode: http.StatusOK,\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(tt.title, func(t *testing.T) {\n\t\t\treq, err := http.NewRequest(tt.method, tt.url, tt.body)\n\t\t\tcustomRoundTripper := CustomRoundTripper{\n\t\t\t\tnext: tt.nextRoundTripper,\n\t\t\t}\n\n\t\t\tresp, err := customRoundTripper.RoundTrip(req)\n\n\t\t\tif tt.expectError {\n\t\t\t\tassert.Error(t, err)\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t}\n\n\t\t\tassert.Equal(t, tt.expectedResponse, resp)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/metrics/OWNERS",
    "content": "# See the OWNERS docs at https://go.k8s.io/owners\n\nlabels:\n- metrics\n"
  },
  {
    "path": "pkg/metrics/labels.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage metrics\n\nimport (\n\t\"github.com/prometheus/client_golang/prometheus\"\n)\n\nconst (\n\tLabelScheme = \"scheme\"\n\tLabelHost   = \"host\"\n\tLabelPath   = \"path\"\n\tLabelMethod = \"method\"\n\tLabelStatus = \"status\"\n)\n\ntype Labels = prometheus.Labels\n"
  },
  {
    "path": "pkg/metrics/metrics.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage metrics\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/prometheus/client_golang/prometheus\"\n\t\"github.com/prometheus/common/version\"\n\tlog \"github.com/sirupsen/logrus\"\n\n\tcfg \"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n)\n\nconst (\n\tNamespace = \"external_dns\"\n)\n\nvar (\n\tRegisterMetric = NewMetricsRegister()\n)\n\nfunc init() {\n\tRegisterMetric.MustRegister(NewGaugeFuncMetric(prometheus.GaugeOpts{\n\t\tNamespace: Namespace,\n\t\tName:      \"build_info\",\n\t\tHelp: fmt.Sprintf(\n\t\t\t\"A metric with a constant '1' value labeled with 'version' and 'revision' of %s and the 'go_version', 'os' and the 'arch' used the build.\",\n\t\t\tNamespace,\n\t\t),\n\t\tConstLabels: prometheus.Labels{\n\t\t\t\"version\":    cfg.Version,\n\t\t\t\"revision\":   version.GetRevision(),\n\t\t\t\"go_version\": version.GoVersion,\n\t\t\t\"os\":         version.GoOS,\n\t\t\t\"arch\":       version.GoArch,\n\t\t},\n\t}))\n}\n\nfunc NewMetricsRegister() *MetricRegistry {\n\treg := prometheus.WrapRegistererWith(\n\t\tprometheus.Labels{},\n\t\tprometheus.DefaultRegisterer)\n\treturn &MetricRegistry{\n\t\tRegisterer: reg,\n\t\tMetrics:    []*Metric{},\n\t\tmName:      make(map[string]bool),\n\t}\n}\n\n// MustRegister registers a metric if it hasn't been registered yet.\n//\n// Usage: MustRegister(...)\n// Example:\n//\n//\tfunc init() {\n//\t     metrics.RegisterMetric.MustRegister(errorsTotal)\n//\t}\nfunc (m *MetricRegistry) MustRegister(cs IMetric) {\n\tswitch v := cs.(type) {\n\tcase CounterMetric, GaugeMetric, SummaryVecMetric, CounterVecMetric, GaugeVecMetric, GaugeFuncMetric:\n\t\tif _, exists := m.mName[cs.Get().FQDN]; exists {\n\t\t\treturn\n\t\t} else {\n\t\t\tm.mName[cs.Get().FQDN] = true\n\t\t}\n\t\tm.Metrics = append(m.Metrics, cs.Get())\n\t\tswitch metric := v.(type) {\n\t\tcase CounterMetric:\n\t\t\tm.Registerer.MustRegister(metric.Counter)\n\t\tcase GaugeMetric:\n\t\t\tm.Registerer.MustRegister(metric.Gauge)\n\t\tcase SummaryVecMetric:\n\t\t\tm.Registerer.MustRegister(metric.SummaryVec)\n\t\tcase GaugeVecMetric:\n\t\t\tm.Registerer.MustRegister(metric.Gauge)\n\t\tcase CounterVecMetric:\n\t\t\tm.Registerer.MustRegister(metric.CounterVec)\n\t\tcase GaugeFuncMetric:\n\t\t\tm.Registerer.MustRegister(metric.GaugeFunc)\n\t\t}\n\t\tlog.Debugf(\"Register metric: %s\", cs.Get().FQDN)\n\tdefault:\n\t\tlog.Warnf(\"Unsupported metric type: %T\", v)\n\t\treturn\n\t}\n}\n"
  },
  {
    "path": "pkg/metrics/metrics_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage metrics\n\nimport (\n\t\"testing\"\n\n\t\"github.com/prometheus/client_golang/prometheus\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/assert\"\n\n\tlogtest \"sigs.k8s.io/external-dns/internal/testutils/log\"\n)\n\ntype MockMetric struct {\n\tFQDN string\n}\n\nfunc (m *MockMetric) Get() *Metric {\n\treturn &Metric{FQDN: m.FQDN}\n}\n\nfunc TestMustRegister(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tmetrics  []IMetric\n\t\texpected int\n\t}{\n\t\t{\n\t\t\tname: \"single metric\",\n\t\t\tmetrics: []IMetric{\n\t\t\t\tNewCounterWithOpts(prometheus.CounterOpts{Name: \"test_counter_1\"}),\n\t\t\t},\n\t\t\texpected: 1,\n\t\t},\n\t\t{\n\t\t\tname: \"two metrics\",\n\t\t\tmetrics: []IMetric{\n\t\t\t\tNewGaugeWithOpts(prometheus.GaugeOpts{Name: \"test_gauge_2\", Subsystem: \"test\"}),\n\t\t\t\tNewCounterWithOpts(prometheus.CounterOpts{Name: \"test_counter_2\", Subsystem: \"app\"}),\n\t\t\t},\n\t\t\texpected: 2,\n\t\t},\n\t\t{\n\t\t\tname: \"mix of metrics\",\n\t\t\tmetrics: []IMetric{\n\t\t\t\tNewGaugeWithOpts(prometheus.GaugeOpts{Name: \"test_gauge_3\"}),\n\t\t\t\tNewCounterWithOpts(prometheus.CounterOpts{Name: \"test_counter_3\"}),\n\t\t\t\tNewCounterVecWithOpts(prometheus.CounterOpts{Name: \"test_counter_vec_3\"}, []string{\"label\"}),\n\t\t\t\tNewGaugedVectorOpts(prometheus.GaugeOpts{Name: \"test_gauge_v_3\"}, []string{\"label\"}),\n\t\t\t\tNewSummaryVecWithOpts(prometheus.SummaryOpts{Name: \"test_summary_v_3\"}, []string{\"label\"}),\n\t\t\t},\n\t\t\texpected: 5,\n\t\t},\n\t\t{\n\t\t\tname: \"unsupported metric\",\n\t\t\tmetrics: []IMetric{\n\t\t\t\t&MockMetric{FQDN: \"unsupported_metric\"},\n\t\t\t},\n\t\t\texpected: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"skip if metric exists\",\n\t\t\tmetrics: []IMetric{\n\t\t\t\tNewGaugeWithOpts(prometheus.GaugeOpts{Name: \"existing_metric\"}),\n\t\t\t\tNewGaugeWithOpts(prometheus.GaugeOpts{Name: \"existing_metric\"}),\n\t\t\t},\n\t\t\texpected: 1,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tregistry := NewMetricsRegister()\n\t\t\tfor _, m := range tt.metrics {\n\t\t\t\tregistry.MustRegister(m)\n\t\t\t}\n\t\t\tassert.Len(t, registry.Metrics, tt.expected)\n\t\t})\n\t}\n}\n\nfunc TestUnsupportedMetricWarning(t *testing.T) {\n\thook := logtest.LogsUnderTestWithLogLevel(log.WarnLevel, t)\n\tregistry := NewMetricsRegister()\n\tmockUnsupported := &MockMetric{FQDN: \"unsupported_metric\"}\n\tregistry.MustRegister(mockUnsupported)\n\tassert.NotContains(t, registry.mName, \"unsupported_metric\")\n\n\tlogtest.TestHelperLogContains(\"Unsupported metric type: *metrics.MockMetric\", hook, t)\n}\n\nfunc TestNewMetricsRegister(t *testing.T) {\n\tregistry := NewMetricsRegister()\n\n\tassert.NotNil(t, registry)\n\tassert.NotNil(t, registry.Registerer)\n\tassert.Empty(t, registry.Metrics)\n\tassert.Empty(t, registry.mName)\n}\n"
  },
  {
    "path": "pkg/metrics/models.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage metrics\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/prometheus/client_golang/prometheus\"\n)\n\ntype MetricRegistry struct {\n\tRegisterer prometheus.Registerer\n\tMetrics    []*Metric\n\tmName      map[string]bool\n}\n\ntype Metric struct {\n\tType      string\n\tNamespace string\n\tSubsystem string\n\tName      string\n\tHelp      string\n\tFQDN      string\n}\n\ntype IMetric interface {\n\tGet() *Metric\n}\n\ntype GaugeMetric struct {\n\tMetric\n\tGauge prometheus.Gauge\n}\n\nfunc (g GaugeMetric) Get() *Metric {\n\treturn &g.Metric\n}\n\ntype CounterMetric struct {\n\tMetric\n\tCounter prometheus.Counter\n}\n\nfunc (g CounterMetric) Get() *Metric {\n\treturn &g.Metric\n}\n\ntype CounterVecMetric struct {\n\tMetric\n\tCounterVec *prometheus.CounterVec\n}\n\nfunc (g CounterVecMetric) Get() *Metric {\n\treturn &g.Metric\n}\n\ntype GaugeVecMetric struct {\n\tMetric\n\tGauge prometheus.GaugeVec\n}\n\nfunc (g GaugeVecMetric) Get() *Metric {\n\treturn &g.Metric\n}\n\n// SetWithLabels sets the value of the Gauge metric for the specified label values.\n// All label values are converted to lowercase before being applied.\nfunc (g GaugeVecMetric) SetWithLabels(value float64, lvs ...string) {\n\tg.Gauge.WithLabelValues(toLower(lvs)...).Set(value)\n}\n\n// AddWithLabels adds the value to the Gauge metric for the specified label values.\n// All label values are converted to lowercase before being applied.\n//\n// Without Reset(), values accumulate and reset only on process restart.\n// Use Reset() + AddWithLabels() pattern for per-cycle counts.\nfunc (g GaugeVecMetric) AddWithLabels(value float64, lvs ...string) {\n\tg.Gauge.WithLabelValues(toLower(lvs)...).Add(value)\n}\n\nfunc NewGaugeWithOpts(opts prometheus.GaugeOpts) GaugeMetric {\n\topts.Namespace = Namespace\n\treturn GaugeMetric{\n\t\tMetric: Metric{\n\t\t\tType:      \"gauge\",\n\t\t\tName:      opts.Name,\n\t\t\tFQDN:      fmt.Sprintf(\"%s_%s\", opts.Subsystem, opts.Name),\n\t\t\tNamespace: opts.Namespace,\n\t\t\tSubsystem: opts.Subsystem,\n\t\t\tHelp:      opts.Help,\n\t\t},\n\t\tGauge: prometheus.NewGauge(opts),\n\t}\n}\n\n// NewGaugedVectorOpts creates a new GaugeVec based on the provided GaugeOpts and\n// partitioned by the given label names.\nfunc NewGaugedVectorOpts(opts prometheus.GaugeOpts, labelNames []string) GaugeVecMetric {\n\topts.Namespace = Namespace\n\treturn GaugeVecMetric{\n\t\tMetric: Metric{\n\t\t\tType:      \"gauge\",\n\t\t\tName:      opts.Name,\n\t\t\tFQDN:      fmt.Sprintf(\"%s_%s\", opts.Subsystem, opts.Name),\n\t\t\tNamespace: opts.Namespace,\n\t\t\tSubsystem: opts.Subsystem,\n\t\t\tHelp:      opts.Help,\n\t\t},\n\t\tGauge: *prometheus.NewGaugeVec(opts, labelNames),\n\t}\n}\n\nfunc NewCounterWithOpts(opts prometheus.CounterOpts) CounterMetric {\n\topts.Namespace = Namespace\n\treturn CounterMetric{\n\t\tMetric: Metric{\n\t\t\tType:      \"counter\",\n\t\t\tName:      opts.Name,\n\t\t\tFQDN:      fmt.Sprintf(\"%s_%s\", opts.Subsystem, opts.Name),\n\t\t\tNamespace: opts.Namespace,\n\t\t\tSubsystem: opts.Subsystem,\n\t\t\tHelp:      opts.Help,\n\t\t},\n\t\tCounter: prometheus.NewCounter(opts),\n\t}\n}\n\nfunc NewCounterVecWithOpts(opts prometheus.CounterOpts, labelNames []string) CounterVecMetric {\n\topts.Namespace = Namespace\n\treturn CounterVecMetric{\n\t\tMetric: Metric{\n\t\t\tType:      \"counter\",\n\t\t\tName:      opts.Name,\n\t\t\tFQDN:      fmt.Sprintf(\"%s_%s\", opts.Subsystem, opts.Name),\n\t\t\tNamespace: opts.Namespace,\n\t\t\tSubsystem: opts.Subsystem,\n\t\t\tHelp:      opts.Help,\n\t\t},\n\t\tCounterVec: prometheus.NewCounterVec(opts, labelNames),\n\t}\n}\n\ntype GaugeFuncMetric struct {\n\tMetric\n\tGaugeFunc prometheus.GaugeFunc\n}\n\nfunc (g GaugeFuncMetric) Get() *Metric {\n\treturn &g.Metric\n}\n\nfunc NewGaugeFuncMetric(opts prometheus.GaugeOpts) GaugeFuncMetric {\n\treturn GaugeFuncMetric{\n\t\tMetric: Metric{\n\t\t\tType: \"gauge\",\n\t\t\tName: opts.Name,\n\t\t\tFQDN: func() string {\n\t\t\t\tif opts.Subsystem != \"\" {\n\t\t\t\t\treturn fmt.Sprintf(\"%s_%s\", opts.Subsystem, opts.Name)\n\t\t\t\t}\n\t\t\t\treturn opts.Name\n\t\t\t}(),\n\t\t\tNamespace: opts.Namespace,\n\t\t\tSubsystem: opts.Subsystem,\n\t\t\tHelp:      opts.Help,\n\t\t},\n\t\tGaugeFunc: prometheus.NewGaugeFunc(opts, func() float64 { return 1 }),\n\t}\n}\n\ntype SummaryVecMetric struct {\n\tMetric\n\tSummaryVec prometheus.SummaryVec\n}\n\nfunc (s SummaryVecMetric) Get() *Metric {\n\treturn &s.Metric\n}\n\nfunc (s SummaryVecMetric) SetWithLabels(value float64, labels prometheus.Labels) {\n\ts.SummaryVec.With(labels).Observe(value)\n}\n\nfunc NewSummaryVecWithOpts(opts prometheus.SummaryOpts, labels []string) SummaryVecMetric {\n\topts.Namespace = Namespace\n\treturn SummaryVecMetric{\n\t\tMetric: Metric{\n\t\t\tType:      \"summaryVec\",\n\t\t\tName:      opts.Name,\n\t\t\tFQDN:      fmt.Sprintf(\"%s_%s\", opts.Subsystem, opts.Name),\n\t\t\tNamespace: opts.Namespace,\n\t\t\tSubsystem: opts.Subsystem,\n\t\t\tHelp:      opts.Help,\n\t\t},\n\t\tSummaryVec: *prometheus.NewSummaryVec(opts, labels),\n\t}\n}\n\nfunc PathProcessor(path string) string {\n\tparts := strings.Split(path, \"/\")\n\treturn parts[len(parts)-1]\n}\n\n// toLower converts all label values to lowercase.\n// The Prometheus maintainers have intentionally avoided magic transformations to keep label handling explicit and predictable.\n// We expect consistent casing, normalizing at ingestion is the standard practice.\nfunc toLower(lvs []string) []string {\n\tfor i := range lvs {\n\t\tlvs[i] = strings.ToLower(lvs[i])\n\t}\n\treturn lvs\n}\n"
  },
  {
    "path": "pkg/metrics/models_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage metrics\n\nimport (\n\t\"reflect\"\n\t\"runtime\"\n\t\"testing\"\n\n\t\"github.com/prometheus/client_golang/prometheus\"\n\tdto \"github.com/prometheus/client_model/go\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestNewGaugeWithOpts(t *testing.T) {\n\topts := prometheus.GaugeOpts{\n\t\tName:      \"test_gauge\",\n\t\tSubsystem: \"test_subsystem\",\n\t\tHelp:      \"This is a test gauge\",\n\t}\n\n\tgaugeMetric := NewGaugeWithOpts(opts)\n\n\tassert.Equal(t, \"gauge\", gaugeMetric.Type)\n\tassert.Equal(t, \"test_gauge\", gaugeMetric.Name)\n\tassert.Equal(t, Namespace, gaugeMetric.Namespace)\n\tassert.Equal(t, \"test_subsystem\", gaugeMetric.Subsystem)\n\tassert.Equal(t, \"This is a test gauge\", gaugeMetric.Help)\n\tassert.Equal(t, \"test_subsystem_test_gauge\", gaugeMetric.FQDN)\n\tassert.NotNil(t, gaugeMetric.Gauge)\n}\n\nfunc TestNewCounterWithOpts(t *testing.T) {\n\topts := prometheus.CounterOpts{\n\t\tName:      \"test_counter\",\n\t\tSubsystem: \"test_subsystem\",\n\t\tHelp:      \"This is a test counter\",\n\t}\n\n\tcounterMetric := NewCounterWithOpts(opts)\n\n\tassert.Equal(t, \"counter\", counterMetric.Type)\n\tassert.Equal(t, \"test_counter\", counterMetric.Name)\n\tassert.Equal(t, Namespace, counterMetric.Namespace)\n\tassert.Equal(t, \"test_subsystem\", counterMetric.Subsystem)\n\tassert.Equal(t, \"This is a test counter\", counterMetric.Help)\n\tassert.Equal(t, \"test_subsystem_test_counter\", counterMetric.FQDN)\n\tassert.NotNil(t, counterMetric.Counter)\n}\n\nfunc TestNewCounterVecWithOpts(t *testing.T) {\n\topts := prometheus.CounterOpts{\n\t\tName:      \"test_counter_vec\",\n\t\tNamespace: \"test_namespace\",\n\t\tSubsystem: \"test_subsystem\",\n\t\tHelp:      \"This is a test counter vector\",\n\t}\n\n\tlabelNames := []string{\"label1\", \"label2\"}\n\n\tcounterVecMetric := NewCounterVecWithOpts(opts, labelNames)\n\n\tassert.Equal(t, \"counter\", counterVecMetric.Type)\n\tassert.Equal(t, \"test_counter_vec\", counterVecMetric.Name)\n\tassert.Equal(t, Namespace, counterVecMetric.Namespace)\n\tassert.Equal(t, \"test_subsystem\", counterVecMetric.Subsystem)\n\tassert.Equal(t, \"This is a test counter vector\", counterVecMetric.Help)\n\tassert.Equal(t, \"test_subsystem_test_counter_vec\", counterVecMetric.FQDN)\n\tassert.NotNil(t, counterVecMetric.CounterVec)\n}\n\nfunc TestGaugeV_SetWithLabels(t *testing.T) {\n\topts := prometheus.GaugeOpts{\n\t\tName:      \"test_gauge\",\n\t\tNamespace: \"test_ns\",\n\t\tSubsystem: \"test_sub\",\n\t\tHelp:      \"help text\",\n\t}\n\tgv := NewGaugedVectorOpts(opts, []string{\"label1\", \"label2\"})\n\n\tgv.SetWithLabels(1.23, \"Alpha\", \"BETA\")\n\n\tg, err := gv.Gauge.GetMetricWithLabelValues(\"alpha\", \"beta\")\n\tassert.NoError(t, err)\n\n\tvar m dto.Metric\n\terr = g.Write(&m)\n\tassert.NoError(t, err)\n\tassert.NotNil(t, m.Gauge)\n\tassert.InDelta(t, 1.23, *m.Gauge.Value, 0.01)\n\n\t// Override the value\n\tgv.SetWithLabels(4.56, \"ALPHA\", \"beta\")\n\t// reuse g (same label combination)\n\terr = g.Write(&m)\n\tassert.NoError(t, err)\n\tassert.InDelta(t, 4.56, *m.Gauge.Value, 0.01)\n\n\tassert.Len(t, m.Label, 2)\n}\n\nfunc TestNewGaugeFuncMetric(t *testing.T) {\n\ttests := []struct {\n\t\tname                    string\n\t\tmetricName              string\n\t\tsubSystem               string\n\t\tconstLabels             prometheus.Labels\n\t\texpectedFqName          string\n\t\texpectedDescString      string\n\t\texpectedGaugeFuncReturn float64\n\t}{\n\t\t{\n\t\t\tname:       \"NewGaugeFuncMetric returns build_info\",\n\t\t\tmetricName: \"build_info\",\n\t\t\tsubSystem:  \"\",\n\t\t\tconstLabels: prometheus.Labels{\n\t\t\t\t\"version\":   \"0.0.1\",\n\t\t\t\t\"goversion\": runtime.Version(),\n\t\t\t\t\"arch\":      \"arm64\",\n\t\t\t},\n\t\t\texpectedFqName:          \"external_dns_build_info\",\n\t\t\texpectedDescString:      \"version=\\\"0.0.1\\\"\",\n\t\t\texpectedGaugeFuncReturn: 1,\n\t\t},\n\t\t{\n\t\t\tname:                    \"NewGaugeFuncMetric subsystem alters name\",\n\t\t\tmetricName:              \"metricName\",\n\t\t\tsubSystem:               \"subSystem\",\n\t\t\tconstLabels:             prometheus.Labels{},\n\t\t\texpectedFqName:          \"external_dns_subSystem_metricName\",\n\t\t\texpectedDescString:      \"\",\n\t\t\texpectedGaugeFuncReturn: 1,\n\t\t},\n\t\t{\n\t\t\tname:                    \"NewGaugeFuncMetric GaugeFunc returns 1\",\n\t\t\tmetricName:              \"metricName\",\n\t\t\tsubSystem:               \"\",\n\t\t\tconstLabels:             prometheus.Labels{},\n\t\t\texpectedFqName:          \"external_dns_metricName\",\n\t\t\texpectedDescString:      \"\",\n\t\t\texpectedGaugeFuncReturn: 1,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tmetric := NewGaugeFuncMetric(prometheus.GaugeOpts{\n\t\t\t\tNamespace:   Namespace,\n\t\t\t\tName:        tt.metricName,\n\t\t\t\tSubsystem:   tt.subSystem,\n\t\t\t\tConstLabels: tt.constLabels,\n\t\t\t})\n\n\t\t\tdesc := metric.GaugeFunc.Desc()\n\n\t\t\tassert.Equal(t, tt.expectedFqName, reflect.ValueOf(desc).Elem().FieldByName(\"fqName\").String())\n\t\t\tassert.Contains(t, desc.String(), tt.expectedDescString)\n\n\t\t\ttestRegistry := prometheus.NewRegistry()\n\t\t\terr := testRegistry.Register(metric.GaugeFunc)\n\t\t\trequire.NoError(t, err)\n\n\t\t\tmetricFamily, err := testRegistry.Gather()\n\t\t\trequire.NoError(t, err)\n\t\t\trequire.Len(t, metricFamily, 1)\n\n\t\t\trequire.NotNil(t, metricFamily[0].Metric[0].Gauge)\n\t\t\tassert.InDelta(t, tt.expectedGaugeFuncReturn, metricFamily[0].Metric[0].GetGauge().GetValue(), 0.0001)\n\t\t})\n\t}\n}\n\nfunc TestSummaryV_SetWithLabels(t *testing.T) {\n\topts := prometheus.SummaryOpts{\n\t\tName:      \"test_summaryVec\",\n\t\tNamespace: \"test_ns\",\n\t\tSubsystem: \"test_sub\",\n\t\tHelp:      \"help text\",\n\t}\n\n\tlabels := Labels{}\n\tsv := NewSummaryVecWithOpts(opts, []string{\"label1\", \"label2\"})\n\n\tlabels[\"label1\"] = \"alpha\"\n\tlabels[\"label2\"] = \"beta\"\n\n\tsv.SetWithLabels(5.01, labels)\n\n\treg := prometheus.NewRegistry()\n\treg.MustRegister(sv.SummaryVec)\n\n\tmetricsFamilies, err := reg.Gather()\n\tassert.NoError(t, err)\n\tassert.Len(t, metricsFamilies, 1)\n\n\ts, err := sv.SummaryVec.GetMetricWithLabelValues(\"alpha\", \"beta\")\n\tassert.NoError(t, err)\n\tmetricsFamilies, err = reg.Gather()\n\n\ts.Observe(5.21)\n\tmetricsFamilies, err = reg.Gather()\n\tassert.NoError(t, err)\n\n\tassert.InDelta(t, 10.22, *metricsFamilies[0].Metric[0].Summary.SampleSum, 0.01)\n\tassert.Len(t, metricsFamilies[0].Metric[0].Label, 2)\n}\n\nfunc TestPathProcessor(t *testing.T) {\n\ttests := []struct {\n\t\tinput    string\n\t\texpected string\n\t}{\n\t\t{\"/foo/bar\", \"bar\"},\n\t\t{\"/foo/\", \"\"},\n\t\t{\"/\", \"\"},\n\t\t{\"\", \"\"},\n\t\t{\"/foo/bar/baz\", \"baz\"},\n\t\t{\"foo/bar\", \"bar\"},\n\t\t{\"foo\", \"foo\"},\n\t\t{\"foo/\", \"\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.input, func(t *testing.T) {\n\t\t\trequire.Equal(t, tt.expected, PathProcessor(tt.input))\n\t\t})\n\t}\n}\n\nfunc TestGaugeV_AddWithLabels(t *testing.T) {\n\topts := prometheus.GaugeOpts{\n\t\tName:      \"test_gauge_add\",\n\t\tNamespace: \"test_ns\",\n\t\tSubsystem: \"test_sub\",\n\t\tHelp:      \"help text\",\n\t}\n\tgv := NewGaugedVectorOpts(opts, []string{\"label1\", \"label2\"})\n\n\t// Add with mixed case labels - should be lowercased\n\tgv.AddWithLabels(1.0, \"Alpha\", \"BETA\")\n\n\tg, err := gv.Gauge.GetMetricWithLabelValues(\"alpha\", \"beta\")\n\tassert.NoError(t, err)\n\n\tvar m dto.Metric\n\terr = g.Write(&m)\n\tassert.NoError(t, err)\n\tassert.NotNil(t, m.Gauge)\n\tassert.InDelta(t, 1.0, *m.Gauge.Value, 0.01)\n\n\t// Add again - should increment, not override\n\tgv.AddWithLabels(2.0, \"ALPHA\", \"beta\")\n\terr = g.Write(&m)\n\tassert.NoError(t, err)\n\tassert.InDelta(t, 3.0, *m.Gauge.Value, 0.01) // 1.0 + 2.0 = 3.0\n\n\t// Add one more time\n\tgv.AddWithLabels(0.5, \"alpha\", \"Beta\")\n\terr = g.Write(&m)\n\tassert.NoError(t, err)\n\tassert.InDelta(t, 3.5, *m.Gauge.Value, 0.01) // 3.0 + 0.5 = 3.5\n\n\tassert.Len(t, m.Label, 2)\n}\n"
  },
  {
    "path": "pkg/rfc2317/OWNERS",
    "content": "# See the OWNERS docs at https://go.k8s.io/owners\n\nlabels:\n- rfc2317\n"
  },
  {
    "path": "pkg/rfc2317/arpa.go",
    "content": "/*\nCopyright 2023 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage rfc2317\n\nimport (\n\t\"fmt\"\n\t\"net\"\n\t\"strconv\"\n\t\"strings\"\n)\n\n// CidrToInAddr converts a CIDR block into its reverse lookup (in-addr) name.\n// Given \"2001::/16\" returns \"1.0.0.2.ip6.arpa\"\n// Given \"10.20.30.0/24\" returns \"30.20.10.in-addr.arpa\"\n// Given \"10.20.30.0/25\" returns \"0/25.30.20.10.in-addr.arpa\" (RFC2317)\nfunc CidrToInAddr(cidr string) (string, error) {\n\t// If the user sent an IP instead of a CIDR (i.e. no \"/\"), turn it\n\t// into a CIDR by adding /32 or /128 as appropriate.\n\tip := net.ParseIP(cidr)\n\tif ip != nil {\n\t\tif ip.To4() != nil {\n\t\t\tcidr = ip.String() + \"/32\"\n\t\t\t// Older code used `cidr + \"/32\"` but that didn't work with\n\t\t\t// \"IPv4 mapped IPv6 address\". ip.String() returns the IPv4\n\t\t\t// address for all IPv4 addresses no matter how they are\n\t\t\t// expressed internally.\n\t\t} else {\n\t\t\tcidr += \"/128\"\n\t\t}\n\t}\n\n\ta, c, err := net.ParseCIDR(cidr)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tbase, err := reverseaddr(a.String())\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\tbase = strings.TrimRight(base, \".\")\n\tif !a.Equal(c.IP) {\n\t\treturn \"\", fmt.Errorf(\"CIDR %v has 1 bits beyond the mask\", cidr)\n\t}\n\n\tbits, total := c.Mask.Size()\n\tvar toTrim int\n\tif bits == 0 {\n\t\treturn \"\", fmt.Errorf(\"cannot use /0 in reverse CIDR\")\n\t}\n\n\t// Handle IPv4 \"Classless in-addr.arpa delegation\" RFC2317:\n\tif total == 32 && bits >= 25 && bits < 32 {\n\t\t// first address / netmask . Class-b-arpa.\n\t\tfparts := strings.Split(c.IP.String(), \".\")\n\t\tfirst := fparts[3]\n\t\tbparts := strings.SplitN(base, \".\", 2)\n\t\treturn fmt.Sprintf(\"%s/%d.%s\", first, bits, bparts[1]), nil\n\t}\n\n\t// Handle IPv4 Class-full and IPv6:\n\tswitch total {\n\tcase 32:\n\t\tif bits%8 != 0 {\n\t\t\treturn \"\", fmt.Errorf(\"IPv4 mask must be multiple of 8 bits\")\n\t\t}\n\t\ttoTrim = (total - bits) / 8\n\tcase 128:\n\t\tif bits%4 != 0 {\n\t\t\treturn \"\", fmt.Errorf(\"IPv6 mask must be multiple of 4 bits\")\n\t\t}\n\t\ttoTrim = (total - bits) / 4\n\tdefault:\n\t\treturn \"\", fmt.Errorf(\"invalid address (not IPv4 or IPv6): %v\", cidr)\n\t}\n\n\tparts := strings.SplitN(base, \".\", toTrim+1)\n\treturn parts[len(parts)-1], nil\n}\n\n// copied from go source.\n// https://github.com/golang/go/blob/38b2c06e144c6ea7087c575c76c66e41265ae0b7/src/net/dnsclient.go#L26C1-L51C1\n// The go source does not export this function so we copy it here.\n\n// reverseaddr returns the in-addr.arpa. or ip6.arpa. hostname of the IP\n// address addr suitable for rDNS (PTR) record lookup or an error if it fails\n// to parse the IP address.\nfunc reverseaddr(addr string) (string, error) {\n\tip := net.ParseIP(addr)\n\tif ip == nil {\n\t\treturn \"\", &net.DNSError{Err: \"unrecognized address\", Name: addr}\n\t}\n\tif ip.To4() != nil {\n\t\treturn Uitoa(uint(ip[15])) + \".\" + Uitoa(uint(ip[14])) + \".\" + Uitoa(uint(ip[13])) + \".\" + Uitoa(uint(ip[12])) + \".in-addr.arpa.\", nil\n\t}\n\t// Must be IPv6\n\tbuf := make([]byte, 0, len(ip)*4+len(\"ip6.arpa.\"))\n\t// Add it, in reverse, to the buffer\n\tfor i := len(ip) - 1; i >= 0; i-- {\n\t\tv := ip[i]\n\t\tbuf = append(buf, hexDigit[v&0xF],\n\t\t\t'.',\n\t\t\thexDigit[v>>4],\n\t\t\t'.')\n\t}\n\t// Append \"ip6.arpa.\" and return (buf already has the final .)\n\tbuf = append(buf, \"ip6.arpa.\"...)\n\treturn string(buf), nil\n}\n\nconst hexDigit = \"0123456789abcdef\"\n\nfunc Uitoa(val uint) string {\n\treturn strconv.FormatInt(int64(val), 10)\n}\n"
  },
  {
    "path": "pkg/rfc2317/arpa_test.go",
    "content": "/*\nCopyright 2023 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage rfc2317\n\nimport (\n\t\"fmt\"\n\t\"testing\"\n)\n\nfunc TestCidrToInAddr(t *testing.T) {\n\tvar tests = []struct {\n\t\tin     string\n\t\tout    string\n\t\terrmsg string\n\t}{\n\n\t\t{\"174.136.107.0/24\", \"107.136.174.in-addr.arpa\", \"\"},\n\t\t{\"174.136.107.1/24\", \"107.136.174.in-addr.arpa\", \"CIDR 174.136.107.1/24 has 1 bits beyond the mask\"},\n\n\t\t{\"174.136.0.0/16\", \"136.174.in-addr.arpa\", \"\"},\n\t\t{\"174.136.43.0/16\", \"136.174.in-addr.arpa\", \"CIDR 174.136.43.0/16 has 1 bits beyond the mask\"},\n\n\t\t{\"174.0.0.0/8\", \"174.in-addr.arpa\", \"\"},\n\t\t{\"174.136.43.0/8\", \"174.in-addr.arpa\", \"CIDR 174.136.43.0/8 has 1 bits beyond the mask\"},\n\t\t{\"174.136.0.44/8\", \"174.in-addr.arpa\", \"CIDR 174.136.0.44/8 has 1 bits beyond the mask\"},\n\t\t{\"174.136.45.45/8\", \"174.in-addr.arpa\", \"CIDR 174.136.45.45/8 has 1 bits beyond the mask\"},\n\n\t\t{\"2001::/16\", \"1.0.0.2.ip6.arpa\", \"\"},\n\t\t{\"2001:0db8:0123:4567:89ab:cdef:1234:5670/124\", \"7.6.5.4.3.2.1.f.e.d.c.b.a.9.8.7.6.5.4.3.2.1.0.8.b.d.0.1.0.0.2.ip6.arpa\", \"\"},\n\n\t\t{\"174.136.107.14/32\", \"14.107.136.174.in-addr.arpa\", \"\"},\n\t\t{\"2001:0db8:0123:4567:89ab:cdef:1234:5678/128\", \"8.7.6.5.4.3.2.1.f.e.d.c.b.a.9.8.7.6.5.4.3.2.1.0.8.b.d.0.1.0.0.2.ip6.arpa\", \"\"},\n\n\t\t// IPv4 \"Classless in-addr.arpa delegation\" RFC2317.\n\t\t// From examples in the RFC:\n\t\t{\"192.0.2.0/25\", \"0/25.2.0.192.in-addr.arpa\", \"\"},\n\t\t{\"192.0.2.128/26\", \"128/26.2.0.192.in-addr.arpa\", \"\"},\n\t\t{\"192.0.2.192/26\", \"192/26.2.0.192.in-addr.arpa\", \"\"},\n\t\t// All the base cases:\n\t\t{\"174.1.0.0/25\", \"0/25.0.1.174.in-addr.arpa\", \"\"},\n\t\t{\"174.1.0.0/26\", \"0/26.0.1.174.in-addr.arpa\", \"\"},\n\t\t{\"174.1.0.0/27\", \"0/27.0.1.174.in-addr.arpa\", \"\"},\n\t\t{\"174.1.0.0/28\", \"0/28.0.1.174.in-addr.arpa\", \"\"},\n\t\t{\"174.1.0.0/29\", \"0/29.0.1.174.in-addr.arpa\", \"\"},\n\t\t{\"174.1.0.0/30\", \"0/30.0.1.174.in-addr.arpa\", \"\"},\n\t\t{\"174.1.0.0/31\", \"0/31.0.1.174.in-addr.arpa\", \"\"},\n\t\t// /25 (all cases)\n\t\t{\"174.1.0.0/25\", \"0/25.0.1.174.in-addr.arpa\", \"\"},\n\t\t{\"174.1.0.128/25\", \"128/25.0.1.174.in-addr.arpa\", \"\"},\n\t\t// /26 (all cases)\n\t\t{\"174.1.0.0/26\", \"0/26.0.1.174.in-addr.arpa\", \"\"},\n\t\t{\"174.1.0.64/26\", \"64/26.0.1.174.in-addr.arpa\", \"\"},\n\t\t{\"174.1.0.128/26\", \"128/26.0.1.174.in-addr.arpa\", \"\"},\n\t\t{\"174.1.0.192/26\", \"192/26.0.1.174.in-addr.arpa\", \"\"},\n\t\t// /27 (all cases)\n\t\t{\"174.1.0.0/27\", \"0/27.0.1.174.in-addr.arpa\", \"\"},\n\t\t{\"174.1.0.32/27\", \"32/27.0.1.174.in-addr.arpa\", \"\"},\n\t\t{\"174.1.0.64/27\", \"64/27.0.1.174.in-addr.arpa\", \"\"},\n\t\t{\"174.1.0.96/27\", \"96/27.0.1.174.in-addr.arpa\", \"\"},\n\t\t{\"174.1.0.128/27\", \"128/27.0.1.174.in-addr.arpa\", \"\"},\n\t\t{\"174.1.0.160/27\", \"160/27.0.1.174.in-addr.arpa\", \"\"},\n\t\t{\"174.1.0.192/27\", \"192/27.0.1.174.in-addr.arpa\", \"\"},\n\t\t{\"174.1.0.224/27\", \"224/27.0.1.174.in-addr.arpa\", \"\"},\n\t\t// /28 (first 2, last 2)\n\t\t{\"174.1.0.0/28\", \"0/28.0.1.174.in-addr.arpa\", \"\"},\n\t\t{\"174.1.0.16/28\", \"16/28.0.1.174.in-addr.arpa\", \"\"},\n\t\t{\"174.1.0.224/28\", \"224/28.0.1.174.in-addr.arpa\", \"\"},\n\t\t{\"174.1.0.240/28\", \"240/28.0.1.174.in-addr.arpa\", \"\"},\n\t\t// /29 (first 2 cases)\n\t\t{\"174.1.0.0/29\", \"0/29.0.1.174.in-addr.arpa\", \"\"},\n\t\t{\"174.1.0.8/29\", \"8/29.0.1.174.in-addr.arpa\", \"\"},\n\t\t// /30 (first 2 cases)\n\t\t{\"174.1.0.0/30\", \"0/30.0.1.174.in-addr.arpa\", \"\"},\n\t\t{\"174.1.0.4/30\", \"4/30.0.1.174.in-addr.arpa\", \"\"},\n\t\t// /31 (first 2 cases)\n\t\t{\"174.1.0.0/31\", \"0/31.0.1.174.in-addr.arpa\", \"\"},\n\t\t{\"174.1.0.2/31\", \"2/31.0.1.174.in-addr.arpa\", \"\"},\n\n\t\t// IPv4-mapped IPv6 addresses:\n\t\t{\"::ffff:174.136.107.15\", \"15.107.136.174.in-addr.arpa\", \"\"},\n\n\t\t// Error Cases:\n\t\t{\"0.0.0.0/0\", \"\", \"cannot use /0 in reverse CIDR\"},\n\t\t{\"2001::/0\", \"\", \"CIDR 2001::/0 has 1 bits beyond the mask\"},\n\t\t{\"4.5/16\", \"\", \"invalid CIDR address: 4.5/16\"},\n\t\t{\"foo.com\", \"\", \"invalid CIDR address: foo.com\"},\n\t}\n\tfor i, tst := range tests {\n\t\tt.Run(fmt.Sprintf(\"%d--%s\", i, tst.in), func(t *testing.T) {\n\t\t\td, err := CidrToInAddr(tst.in)\n\n\t\t\tif tst.errmsg == \"\" {\n\t\t\t\t// We DO NOT expect an error.\n\t\t\t\tif err != nil {\n\t\t\t\t\t// ...but we got one.\n\t\t\t\t\tt.Errorf(\"Expected '%s' but got ERROR('%s')\", tst.out, err)\n\t\t\t\t} else if (tst.errmsg == \"\") && d != tst.out {\n\t\t\t\t\t// but the expected output was wrong\n\t\t\t\t\tt.Errorf(\"Expected '%s' but got '%s'\", tst.out, d)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// We DO expect an error.\n\t\t\t\tif err == nil {\n\t\t\t\t\t// ...but we didn't get one.\n\t\t\t\t\tt.Errorf(\"Expected ERROR('%s') but got result '%s'\", tst.errmsg, d)\n\t\t\t\t} else if err.Error() != tst.errmsg {\n\t\t\t\t\t// ...but not the right error.\n\t\t\t\t\tt.Errorf(\"Expected ERROR('%s') but got ERROR('%s')\", tst.errmsg, err)\n\t\t\t\t}\n\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/tlsutils/OWNERS",
    "content": "# See the OWNERS docs at https://go.k8s.io/owners\n\nlabels:\n- tls\n"
  },
  {
    "path": "pkg/tlsutils/tlsconfig.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage tlsutils\n\nimport (\n\t\"crypto/tls\"\n\t\"crypto/x509\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n)\n\nconst (\n\t// The TLS 1.2 default was introduced in Go 1.18 (released March 2022).\n\tdefaultMinVersion = tls.VersionTLS12\n)\n\n// CreateTLSConfig creates tls.Config instance from TLS parameters passed in environment variables with the given prefix\nfunc CreateTLSConfig(prefix string) (*tls.Config, error) {\n\tcaFile := os.Getenv(fmt.Sprintf(\"%s_CA_FILE\", prefix))\n\tcertFile := os.Getenv(fmt.Sprintf(\"%s_CERT_FILE\", prefix))\n\tkeyFile := os.Getenv(fmt.Sprintf(\"%s_KEY_FILE\", prefix))\n\tserverName := os.Getenv(fmt.Sprintf(\"%s_TLS_SERVER_NAME\", prefix))\n\tisInsecureStr := strings.ToLower(os.Getenv(fmt.Sprintf(\"%s_TLS_INSECURE\", prefix)))\n\tisInsecure := isInsecureStr == \"true\" || isInsecureStr == \"yes\" || isInsecureStr == \"1\"\n\treturn NewTLSConfig(certFile, keyFile, caFile, serverName, isInsecure, defaultMinVersion)\n}\n\n// NewTLSConfig creates a tls.Config instance from directly passed parameters, loading the ca, cert, and key from disk\nfunc NewTLSConfig(certPath, keyPath, caPath, serverName string, insecure bool, minVersion uint16) (*tls.Config, error) {\n\tif (certPath != \"\" && keyPath == \"\") || (certPath == \"\" && keyPath != \"\") {\n\t\treturn nil, errors.New(\"either both cert and key or none must be provided\")\n\t}\n\tvar certificates []tls.Certificate\n\tif certPath != \"\" {\n\t\tcert, err := tls.LoadX509KeyPair(certPath, keyPath)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"could not load TLS cert: %w\", err)\n\t\t}\n\t\tcertificates = append(certificates, cert)\n\t}\n\t// If rootCAs is nil, TLS uses the host's root CA set.\n\tvar rootCAs *x509.CertPool\n\tvar err error\n\n\tif caPath != \"\" {\n\t\trootCAs, err = loadRoots(caPath)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn &tls.Config{\n\t\tMinVersion:         minVersion,\n\t\tCertificates:       certificates,\n\t\tRootCAs:            rootCAs,\n\t\tInsecureSkipVerify: insecure,\n\t\tServerName:         serverName,\n\t}, nil\n}\n\n// loads CA cert\nfunc loadRoots(caPath string) (*x509.CertPool, error) {\n\troots := x509.NewCertPool()\n\tpem, err := os.ReadFile(caPath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error reading %s: %w\", caPath, err)\n\t}\n\tif !roots.AppendCertsFromPEM(pem) {\n\t\treturn nil, fmt.Errorf(\"could not parse PEM certificates from %s\", caPath)\n\t}\n\treturn roots, nil\n}\n"
  },
  {
    "path": "pkg/tlsutils/tlsconfig_test.go",
    "content": "/*\nCopyright 2023 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage tlsutils\n\nimport (\n\t\"crypto/tls\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nvar (\n\trsaCertPEM = `-----BEGIN CERTIFICATE-----\nMIIB0zCCAX2gAwIBAgIJAI/M7BYjwB+uMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV\nBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX\naWRnaXRzIFB0eSBMdGQwHhcNMTIwOTEyMjE1MjAyWhcNMTUwOTEyMjE1MjAyWjBF\nMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50\nZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBANLJ\nhPHhITqQbPklG3ibCVxwGMRfp/v4XqhfdQHdcVfHap6NQ5Wok/4xIA+ui35/MmNa\nrtNuC+BdZ1tMuVCPFZcCAwEAAaNQME4wHQYDVR0OBBYEFJvKs8RfJaXTH08W+SGv\nzQyKn0H8MB8GA1UdIwQYMBaAFJvKs8RfJaXTH08W+SGvzQyKn0H8MAwGA1UdEwQF\nMAMBAf8wDQYJKoZIhvcNAQEFBQADQQBJlffJHybjDGxRMqaRmDhX0+6v02TUKZsW\nr5QuVbpQhH6u+0UgcW0jp9QwpxoPTLTWGXEWBBBurxFwiCBhkQ+V\n-----END CERTIFICATE-----\n`\n\trsaKeyPEM = testingKey(`-----BEGIN RSA TESTING KEY-----\nMIIBOwIBAAJBANLJhPHhITqQbPklG3ibCVxwGMRfp/v4XqhfdQHdcVfHap6NQ5Wo\nk/4xIA+ui35/MmNartNuC+BdZ1tMuVCPFZcCAwEAAQJAEJ2N+zsR0Xn8/Q6twa4G\n6OB1M1WO+k+ztnX/1SvNeWu8D6GImtupLTYgjZcHufykj09jiHmjHx8u8ZZB/o1N\nMQIhAPW+eyZo7ay3lMz1V01WVjNKK9QSn1MJlb06h/LuYv9FAiEA25WPedKgVyCW\nSmUwbPw8fnTcpqDWE3yTO3vKcebqMSsCIBF3UmVue8YU3jybC3NxuXq3wNm34R8T\nxVLHwDXh/6NJAiEAl2oHGGLz64BuAfjKrqwz7qMYr9HCLIe/YsoWq/olzScCIQDi\nD2lWusoe2/nEqfDVVWGWlyJ7yOmqaVm/iNUN9B2N2g==\n-----END RSA TESTING KEY-----\n`)\n)\n\nfunc testingKey(s string) string { return strings.ReplaceAll(s, \"TESTING KEY\", \"PRIVATE KEY\") }\n\nfunc writeTempFile(t *testing.T, dir, name, content, envKey string) {\n\tt.Helper()\n\tpath := filepath.Join(dir, name)\n\trequire.NoError(t, os.WriteFile(path, []byte(content), 0644))\n\tt.Setenv(envKey, path)\n}\n\nfunc TestCreateTLSConfig(t *testing.T) {\n\n\ttests := []struct {\n\t\ttitle         string\n\t\tprefix        string\n\t\tcaFile        string\n\t\tcertFile      string\n\t\tkeyFile       string\n\t\tisInsecureStr string\n\t\tserverName    string\n\t\tassertions    func(actual *tls.Config, err error)\n\t}{\n\t\t{\n\t\t\t\"Provide only CA returns error\",\n\t\t\t\"prefix\",\n\t\t\t\"\",\n\t\t\trsaCertPEM,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfunc(_ *tls.Config, err error) {\n\t\t\t\tassert.Contains(t, err.Error(), \"either both cert and key or none must be provided\")\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"Invalid cert and key returns error\",\n\t\t\t\"prefix\",\n\t\t\t\"\",\n\t\t\t\"invalid-cert\",\n\t\t\t\"invalid-key\",\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfunc(_ *tls.Config, err error) {\n\t\t\t\tassert.Contains(t, err.Error(), \"could not load TLS cert\")\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"Valid cert and key return a valid tls.Config with a certificate\",\n\t\t\t\"prefix\",\n\t\t\t\"\",\n\t\t\trsaCertPEM,\n\t\t\trsaKeyPEM,\n\t\t\t\"\",\n\t\t\t\"server-name\",\n\t\t\tfunc(actual *tls.Config, err error) {\n\t\t\t\trequire.NoError(t, err)\n\t\t\t\tassert.Equal(t, \"server-name\", actual.ServerName)\n\t\t\t\tassert.NotNil(t, actual.Certificates[0])\n\t\t\t\tassert.False(t, actual.InsecureSkipVerify)\n\t\t\t\tassert.Equal(t, actual.MinVersion, uint16(defaultMinVersion))\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"Invalid CA file returns error\",\n\t\t\t\"prefix\",\n\t\t\t\"invalid-ca-content\",\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfunc(_ *tls.Config, err error) {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\tassert.Contains(t, err.Error(), \"could not parse PEM certificates from\")\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"Invalid CA file path returns error\",\n\t\t\t\"prefix\",\n\t\t\t\"ca-path-does-not-exist\",\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\t\"server-name\",\n\t\t\tfunc(_ *tls.Config, err error) {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\tassert.Contains(t, err.Error(), \"error reading /path/does/not/exist\")\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"Complete config with CA, cert, and key returns valid tls.Config\",\n\t\t\t\"prefix\",\n\t\t\trsaCertPEM,\n\t\t\trsaCertPEM,\n\t\t\trsaKeyPEM,\n\t\t\t\"\",\n\t\t\t\"server-name\",\n\t\t\tfunc(actual *tls.Config, err error) {\n\t\t\t\trequire.NoError(t, err)\n\t\t\t\tassert.Equal(t, \"server-name\", actual.ServerName)\n\t\t\t\tassert.NotNil(t, actual.Certificates[0])\n\t\t\t\tassert.NotNil(t, actual.RootCAs)\n\t\t\t\tassert.False(t, actual.InsecureSkipVerify)\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tc := range tests {\n\t\tt.Run(tc.title, func(t *testing.T) {\n\t\t\t// setup\n\t\t\tdir := t.TempDir()\n\n\t\t\tif tc.caFile == \"ca-path-does-not-exist\" {\n\t\t\t\tt.Setenv(fmt.Sprintf(\"%s_CA_FILE\", tc.prefix), \"/path/does/not/exist\")\n\t\t\t} else if tc.caFile != \"\" {\n\t\t\t\twriteTempFile(t, dir, \"caFile\", tc.caFile, fmt.Sprintf(\"%s_CA_FILE\", tc.prefix))\n\t\t\t}\n\n\t\t\tif tc.certFile != \"\" {\n\t\t\t\twriteTempFile(t, dir, \"certFile\", tc.certFile, fmt.Sprintf(\"%s_CERT_FILE\", tc.prefix))\n\t\t\t}\n\n\t\t\tif tc.keyFile != \"\" {\n\t\t\t\twriteTempFile(t, dir, \"keyFile\", tc.keyFile, fmt.Sprintf(\"%s_KEY_FILE\", tc.prefix))\n\t\t\t}\n\n\t\t\tif tc.serverName != \"\" {\n\t\t\t\tt.Setenv(fmt.Sprintf(\"%s_TLS_SERVER_NAME\", tc.prefix), tc.serverName)\n\t\t\t}\n\n\t\t\tif tc.isInsecureStr != \"\" {\n\t\t\t\tt.Setenv(fmt.Sprintf(\"%s_TLS_INSECURE\", tc.prefix), tc.isInsecureStr)\n\t\t\t}\n\n\t\t\t// test\n\t\t\tactual, err := CreateTLSConfig(tc.prefix)\n\t\t\ttc.assertions(actual, err)\n\t\t})\n\t}\n\n}\n"
  },
  {
    "path": "plan/OWNERS",
    "content": "# See the OWNERS docs at https://go.k8s.io/owners\n\nlabels:\n- plan\n"
  },
  {
    "path": "plan/conflict.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage plan\n\nimport (\n\t\"slices\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\n// ConflictResolver is used to make a decision in case of two or more different kubernetes resources\n// are trying to acquire the same DNS name\ntype ConflictResolver interface {\n\tResolveCreate(candidates []*endpoint.Endpoint) *endpoint.Endpoint\n\tResolveUpdate(current *endpoint.Endpoint, candidates []*endpoint.Endpoint) *endpoint.Endpoint\n\tResolveRecordTypes(key planKey, row *planTableRow) map[string]*domainEndpoints\n}\n\n// PerResource allows only one resource to own a given dns name\ntype PerResource struct{}\n\n// ResolveCreate is invoked when dns name is not owned by any resource\n// ResolveCreate takes \"minimal\" (string comparison of Target) endpoint to acquire the DNS record\nfunc (s PerResource) ResolveCreate(candidates []*endpoint.Endpoint) *endpoint.Endpoint {\n\treturn slices.MinFunc(candidates, compareEndpoints)\n}\n\n// ResolveUpdate is invoked when dns name is already owned by \"current\" endpoint\n// ResolveUpdate uses \"current\" record as base and updates it accordingly with new version of same resource\n// if it doesn't exist then pick min\nfunc (s PerResource) ResolveUpdate(current *endpoint.Endpoint, candidates []*endpoint.Endpoint) *endpoint.Endpoint {\n\tcurrentResource := current.Labels[endpoint.ResourceLabelKey] // resource which has already acquired the DNS\n\tslices.SortStableFunc(candidates, compareEndpoints)\n\tfor _, ep := range candidates {\n\t\tif ep.Labels[endpoint.ResourceLabelKey] == currentResource {\n\t\t\treturn ep\n\t\t}\n\t}\n\treturn s.ResolveCreate(candidates)\n}\n\n// ResolveRecordTypes attempts to detect and resolve record type conflicts in desired\n// endpoints for a domain. For example if there is more than 1 candidate and at least one\n// of them is a CNAME. Per [RFC 1034 3.6.2] domains that contain a CNAME can not contain any\n// other record types. The default policy will prefer A and AAAA record types when a conflict is\n// detected (consistent with [endpoint.Targets.Less]).\n//\n// [RFC 1034 3.6.2]: https://datatracker.ietf.org/doc/html/rfc1034#autoid-15\nfunc (s PerResource) ResolveRecordTypes(key planKey, row *planTableRow) map[string]*domainEndpoints {\n\t// no conflicts if only a single desired record type for the domain\n\tif len(row.candidates) <= 1 {\n\t\treturn row.records\n\t}\n\n\tcname, other := false, false\n\tfor _, c := range row.candidates {\n\t\tif c.RecordType == endpoint.RecordTypeCNAME {\n\t\t\tcname = true\n\t\t} else {\n\t\t\tother = true\n\t\t}\n\t\tif cname && other {\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif !cname || !other {\n\t\treturn row.records\n\t}\n\n\t// conflict was found: prefer non-CNAME record types, discard CNAME candidates\n\t// but keep current CNAME so it can be deleted\n\t// TODO: emit metric\n\tlog.Warnf(\"Domain %s contains conflicting record type candidates; discarding CNAME record\", key.dnsName)\n\trecords := make(map[string]*domainEndpoints, len(row.records))\n\tfor recordType, recs := range row.records {\n\t\tif recordType == endpoint.RecordTypeCNAME {\n\t\t\trecords[recordType] = &domainEndpoints{current: recs.current, candidates: []*endpoint.Endpoint{}}\n\t\t\tcontinue\n\t\t}\n\t\trecords[recordType] = recs\n\t}\n\treturn records\n}\n\n// compareEndpoints compares two endpoints by their targets for use in sort/min operations.\nfunc compareEndpoints(a, b *endpoint.Endpoint) int {\n\tif a.Targets.IsLess(b.Targets) {\n\t\treturn -1\n\t}\n\tif b.Targets.IsLess(a.Targets) {\n\t\treturn 1\n\t}\n\treturn 0\n}\n\n// TODO: with cross-resource/cross-cluster setup alternative variations of ConflictResolver can be used\n"
  },
  {
    "path": "plan/conflict_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage plan\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/suite\"\n\n\tlogtest \"sigs.k8s.io/external-dns/internal/testutils/log\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\nvar _ ConflictResolver = PerResource{}\n\ntype ResolverSuite struct {\n\t// resolvers\n\tperResource PerResource\n\t// endpoints\n\tfooV1Cname          *endpoint.Endpoint\n\tfooV2Cname          *endpoint.Endpoint\n\tfooV2CnameDuplicate *endpoint.Endpoint\n\tfooA5               *endpoint.Endpoint\n\tfooAAAA5            *endpoint.Endpoint\n\tbar127A             *endpoint.Endpoint\n\tbar192A             *endpoint.Endpoint\n\tbar127AAnother      *endpoint.Endpoint\n\tlegacyBar192A       *endpoint.Endpoint // record created in AWS now without resource label\n\tsuite.Suite\n}\n\nfunc (suite *ResolverSuite) SetupTest() {\n\tsuite.perResource = PerResource{}\n\t// initialize endpoints used in tests\n\tsuite.fooV1Cname = &endpoint.Endpoint{\n\t\tDNSName:    \"foo\",\n\t\tTargets:    endpoint.Targets{\"v1\"},\n\t\tRecordType: \"CNAME\",\n\t\tLabels: map[string]string{\n\t\t\tendpoint.ResourceLabelKey: \"ingress/default/foo-v1\",\n\t\t},\n\t}\n\tsuite.fooV2Cname = &endpoint.Endpoint{\n\t\tDNSName:    \"foo\",\n\t\tTargets:    endpoint.Targets{\"v2\"},\n\t\tRecordType: \"CNAME\",\n\t\tLabels: map[string]string{\n\t\t\tendpoint.ResourceLabelKey: \"ingress/default/foo-v2\",\n\t\t},\n\t}\n\tsuite.fooV2CnameDuplicate = &endpoint.Endpoint{\n\t\tDNSName:    \"foo\",\n\t\tTargets:    endpoint.Targets{\"v2\"},\n\t\tRecordType: \"CNAME\",\n\t\tLabels: map[string]string{\n\t\t\tendpoint.ResourceLabelKey: \"ingress/default/foo-v2-duplicate\",\n\t\t},\n\t}\n\tsuite.fooA5 = &endpoint.Endpoint{\n\t\tDNSName:    \"foo\",\n\t\tTargets:    endpoint.Targets{\"5.5.5.5\"},\n\t\tRecordType: \"A\",\n\t\tLabels: map[string]string{\n\t\t\tendpoint.ResourceLabelKey: \"ingress/default/foo-5\",\n\t\t},\n\t}\n\tsuite.fooAAAA5 = &endpoint.Endpoint{\n\t\tDNSName:    \"foo\",\n\t\tTargets:    endpoint.Targets{\"2001:DB8::1\"},\n\t\tRecordType: \"AAAA\",\n\t\tLabels: map[string]string{\n\t\t\tendpoint.ResourceLabelKey: \"ingress/default/foo-5\",\n\t\t},\n\t}\n\tsuite.bar127A = &endpoint.Endpoint{\n\t\tDNSName:    \"bar\",\n\t\tTargets:    endpoint.Targets{\"127.0.0.1\"},\n\t\tRecordType: \"A\",\n\t\tLabels: map[string]string{\n\t\t\tendpoint.ResourceLabelKey: \"ingress/default/bar-127\",\n\t\t},\n\t}\n\tsuite.bar127AAnother = &endpoint.Endpoint{ // TODO: remove this once we move to multiple targets under same endpoint\n\t\tDNSName:    \"bar\",\n\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\tRecordType: \"A\",\n\t\tLabels: map[string]string{\n\t\t\tendpoint.ResourceLabelKey: \"ingress/default/bar-127\",\n\t\t},\n\t}\n\tsuite.bar192A = &endpoint.Endpoint{\n\t\tDNSName:    \"bar\",\n\t\tTargets:    endpoint.Targets{\"192.168.0.1\"},\n\t\tRecordType: \"A\",\n\t\tLabels: map[string]string{\n\t\t\tendpoint.ResourceLabelKey: \"ingress/default/bar-192\",\n\t\t},\n\t}\n\tsuite.legacyBar192A = &endpoint.Endpoint{\n\t\tDNSName:    \"bar\",\n\t\tTargets:    endpoint.Targets{\"192.168.0.1\"},\n\t\tRecordType: \"A\",\n\t}\n}\n\nfunc (suite *ResolverSuite) TestStrictResolver() {\n\t// test that perResource resolver picks min for create list\n\tsuite.Equal(suite.bar127A, suite.perResource.ResolveCreate([]*endpoint.Endpoint{suite.bar127A, suite.bar192A}), \"should pick min one\")\n\tsuite.Equal(suite.fooA5, suite.perResource.ResolveCreate([]*endpoint.Endpoint{suite.fooA5, suite.fooV1Cname}), \"should pick min one\")\n\tsuite.Equal(suite.fooV1Cname, suite.perResource.ResolveCreate([]*endpoint.Endpoint{suite.fooV2Cname, suite.fooV1Cname}), \"should pick min one\")\n\n\t// test that perResource resolver preserves resource if it still exists\n\tsuite.Equal(suite.bar127AAnother, suite.perResource.ResolveUpdate(suite.bar127A, []*endpoint.Endpoint{suite.bar127AAnother, suite.bar127A}), \"should pick min for update when same resource endpoint occurs multiple times (remove after multiple-target support\") // TODO:remove this test\n\tsuite.Equal(suite.bar127A, suite.perResource.ResolveUpdate(suite.bar127A, []*endpoint.Endpoint{suite.bar192A, suite.bar127A}), \"should pick existing resource\")\n\tsuite.Equal(suite.fooV2Cname, suite.perResource.ResolveUpdate(suite.fooV2Cname, []*endpoint.Endpoint{suite.fooV2Cname, suite.fooV2CnameDuplicate}), \"should pick existing resource even if targets are same\")\n\tsuite.Equal(suite.fooA5, suite.perResource.ResolveUpdate(suite.fooV1Cname, []*endpoint.Endpoint{suite.fooA5, suite.fooV2Cname}), \"should pick new if resource was deleted\")\n\t// should actually get the updated record (note ttl is different)\n\tnewFooV1Cname := &endpoint.Endpoint{\n\t\tDNSName:    suite.fooV1Cname.DNSName,\n\t\tTargets:    suite.fooV1Cname.Targets,\n\t\tLabels:     suite.fooV1Cname.Labels,\n\t\tRecordType: suite.fooV1Cname.RecordType,\n\t\tRecordTTL:  suite.fooV1Cname.RecordTTL + 1, // ttl is different\n\t}\n\tsuite.Equal(newFooV1Cname, suite.perResource.ResolveUpdate(suite.fooV1Cname, []*endpoint.Endpoint{suite.fooA5, suite.fooV2Cname, newFooV1Cname}), \"should actually pick same resource with updates\")\n\n\t// legacy record's resource value will not match any candidates resource label\n\t// therefore pick minimum again\n\tsuite.Equal(suite.bar127A, suite.perResource.ResolveUpdate(suite.legacyBar192A, []*endpoint.Endpoint{suite.bar127A, suite.bar192A}), \" legacy record's resource value will not match, should pick minimum\")\n}\n\nfunc (suite *ResolverSuite) TestPerResource_ResolveRecordTypes() {\n\ttype args struct {\n\t\tkey planKey\n\t\trow *planTableRow\n\t}\n\ttests := []struct {\n\t\tname string\n\t\targs args\n\t\twant map[string]*domainEndpoints\n\t}{\n\t\t{\n\t\t\tname: \"no conflict: cname record\",\n\t\t\targs: args{\n\t\t\t\tkey: planKey{dnsName: \"foo\"},\n\t\t\t\trow: &planTableRow{\n\t\t\t\t\tcandidates: []*endpoint.Endpoint{suite.fooV1Cname},\n\t\t\t\t\trecords: map[string]*domainEndpoints{\n\t\t\t\t\t\tendpoint.RecordTypeCNAME: {\n\t\t\t\t\t\t\tcandidates: []*endpoint.Endpoint{suite.fooV1Cname},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: map[string]*domainEndpoints{\n\t\t\t\tendpoint.RecordTypeCNAME: {\n\t\t\t\t\tcandidates: []*endpoint.Endpoint{suite.fooV1Cname},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"no conflict: a record\",\n\t\t\targs: args{\n\t\t\t\tkey: planKey{dnsName: \"foo\"},\n\t\t\t\trow: &planTableRow{\n\t\t\t\t\tcurrent:    []*endpoint.Endpoint{suite.fooA5},\n\t\t\t\t\tcandidates: []*endpoint.Endpoint{suite.fooA5},\n\t\t\t\t\trecords: map[string]*domainEndpoints{\n\t\t\t\t\t\tendpoint.RecordTypeA: {\n\t\t\t\t\t\t\tcurrent:    suite.fooA5,\n\t\t\t\t\t\t\tcandidates: []*endpoint.Endpoint{suite.fooA5},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: map[string]*domainEndpoints{\n\t\t\t\tendpoint.RecordTypeA: {\n\t\t\t\t\tcurrent:    suite.fooA5,\n\t\t\t\t\tcandidates: []*endpoint.Endpoint{suite.fooA5},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"no conflict: a and aaaa records\",\n\t\t\targs: args{\n\t\t\t\tkey: planKey{dnsName: \"foo\"},\n\t\t\t\trow: &planTableRow{\n\t\t\t\t\tcandidates: []*endpoint.Endpoint{suite.fooA5, suite.fooAAAA5},\n\t\t\t\t\trecords: map[string]*domainEndpoints{\n\t\t\t\t\t\tendpoint.RecordTypeA: {\n\t\t\t\t\t\t\tcandidates: []*endpoint.Endpoint{suite.fooA5},\n\t\t\t\t\t\t},\n\t\t\t\t\t\tendpoint.RecordTypeAAAA: {\n\t\t\t\t\t\t\tcandidates: []*endpoint.Endpoint{suite.fooAAAA5},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: map[string]*domainEndpoints{\n\t\t\t\tendpoint.RecordTypeA: {\n\t\t\t\t\tcandidates: []*endpoint.Endpoint{suite.fooA5},\n\t\t\t\t},\n\t\t\t\tendpoint.RecordTypeAAAA: {\n\t\t\t\t\tcandidates: []*endpoint.Endpoint{suite.fooAAAA5},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"conflict: cname and a records\",\n\t\t\targs: args{\n\t\t\t\tkey: planKey{dnsName: \"foo\"},\n\t\t\t\trow: &planTableRow{\n\t\t\t\t\tcurrent:    []*endpoint.Endpoint{suite.fooV1Cname},\n\t\t\t\t\tcandidates: []*endpoint.Endpoint{suite.fooV1Cname, suite.fooA5},\n\t\t\t\t\trecords: map[string]*domainEndpoints{\n\t\t\t\t\t\tendpoint.RecordTypeCNAME: {\n\t\t\t\t\t\t\tcurrent:    suite.fooV1Cname,\n\t\t\t\t\t\t\tcandidates: []*endpoint.Endpoint{suite.fooV1Cname},\n\t\t\t\t\t\t},\n\t\t\t\t\t\tendpoint.RecordTypeA: {\n\t\t\t\t\t\t\tcandidates: []*endpoint.Endpoint{suite.fooA5},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: map[string]*domainEndpoints{\n\t\t\t\tendpoint.RecordTypeCNAME: {\n\t\t\t\t\tcurrent:    suite.fooV1Cname,\n\t\t\t\t\tcandidates: []*endpoint.Endpoint{},\n\t\t\t\t},\n\t\t\t\tendpoint.RecordTypeA: {\n\t\t\t\t\tcandidates: []*endpoint.Endpoint{suite.fooA5},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"conflict: cname, a, and aaaa records\",\n\t\t\targs: args{\n\t\t\t\tkey: planKey{dnsName: \"foo\"},\n\t\t\t\trow: &planTableRow{\n\t\t\t\t\tcurrent:    []*endpoint.Endpoint{suite.fooA5, suite.fooAAAA5},\n\t\t\t\t\tcandidates: []*endpoint.Endpoint{suite.fooV1Cname, suite.fooA5, suite.fooAAAA5},\n\t\t\t\t\trecords: map[string]*domainEndpoints{\n\t\t\t\t\t\tendpoint.RecordTypeCNAME: {\n\t\t\t\t\t\t\tcandidates: []*endpoint.Endpoint{suite.fooV1Cname},\n\t\t\t\t\t\t},\n\t\t\t\t\t\tendpoint.RecordTypeA: {\n\t\t\t\t\t\t\tcurrent:    suite.fooA5,\n\t\t\t\t\t\t\tcandidates: []*endpoint.Endpoint{suite.fooA5},\n\t\t\t\t\t\t},\n\t\t\t\t\t\tendpoint.RecordTypeAAAA: {\n\t\t\t\t\t\t\tcurrent:    suite.fooAAAA5,\n\t\t\t\t\t\t\tcandidates: []*endpoint.Endpoint{suite.fooAAAA5},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: map[string]*domainEndpoints{\n\t\t\t\tendpoint.RecordTypeCNAME: {\n\t\t\t\t\tcandidates: []*endpoint.Endpoint{},\n\t\t\t\t},\n\t\t\t\tendpoint.RecordTypeA: {\n\t\t\t\t\tcurrent:    suite.fooA5,\n\t\t\t\t\tcandidates: []*endpoint.Endpoint{suite.fooA5},\n\t\t\t\t},\n\t\t\t\tendpoint.RecordTypeAAAA: {\n\t\t\t\t\tcurrent:    suite.fooAAAA5,\n\t\t\t\t\tcandidates: []*endpoint.Endpoint{suite.fooAAAA5},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tsuite.Run(tt.name, func() {\n\t\t\tif got := suite.perResource.ResolveRecordTypes(tt.args.key, tt.args.row); !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tsuite.T().Errorf(\"PerResource.ResolveRecordTypes() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPerResource_ResolveRecordTypes_LogsWarning(t *testing.T) {\n\tconst warnMsg = \"contains conflicting record type candidates; discarding CNAME record\"\n\n\tcname := &endpoint.Endpoint{DNSName: \"foo\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"v1\"}}\n\ta := &endpoint.Endpoint{DNSName: \"foo\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"5.5.5.5\"}}\n\taaaa := &endpoint.Endpoint{DNSName: \"foo\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:DB8::1\"}}\n\n\ttests := []struct {\n\t\tname        string\n\t\trow         *planTableRow\n\t\texpectsWarn bool\n\t}{\n\t\t{\n\t\t\tname: \"no warning: cname only\",\n\t\t\trow: &planTableRow{\n\t\t\t\tcandidates: []*endpoint.Endpoint{cname},\n\t\t\t\trecords: map[string]*domainEndpoints{\n\t\t\t\t\tendpoint.RecordTypeCNAME: {candidates: []*endpoint.Endpoint{cname}},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"no warning: a and aaaa only\",\n\t\t\trow: &planTableRow{\n\t\t\t\tcandidates: []*endpoint.Endpoint{a, aaaa},\n\t\t\t\trecords: map[string]*domainEndpoints{\n\t\t\t\t\tendpoint.RecordTypeA:    {candidates: []*endpoint.Endpoint{a}},\n\t\t\t\t\tendpoint.RecordTypeAAAA: {candidates: []*endpoint.Endpoint{aaaa}},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"warning: cname conflicts with a record\",\n\t\t\trow: &planTableRow{\n\t\t\t\tcandidates: []*endpoint.Endpoint{cname, a},\n\t\t\t\trecords: map[string]*domainEndpoints{\n\t\t\t\t\tendpoint.RecordTypeCNAME: {candidates: []*endpoint.Endpoint{cname}},\n\t\t\t\t\tendpoint.RecordTypeA:     {candidates: []*endpoint.Endpoint{a}},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectsWarn: true,\n\t\t},\n\t\t{\n\t\t\tname: \"warning: cname conflicts with a and aaaa records\",\n\t\t\trow: &planTableRow{\n\t\t\t\tcandidates: []*endpoint.Endpoint{cname, a, aaaa},\n\t\t\t\trecords: map[string]*domainEndpoints{\n\t\t\t\t\tendpoint.RecordTypeCNAME: {candidates: []*endpoint.Endpoint{cname}},\n\t\t\t\t\tendpoint.RecordTypeA:     {candidates: []*endpoint.Endpoint{a}},\n\t\t\t\t\tendpoint.RecordTypeAAAA:  {candidates: []*endpoint.Endpoint{aaaa}},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectsWarn: true,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\thook := logtest.LogsUnderTestWithLogLevel(log.WarnLevel, t)\n\t\t\tPerResource{}.ResolveRecordTypes(planKey{dnsName: \"foo\"}, tt.row)\n\t\t\tif tt.expectsWarn {\n\t\t\t\tlogtest.TestHelperLogContainsWithLogLevel(warnMsg, log.WarnLevel, hook, t)\n\t\t\t} else {\n\t\t\t\tlogtest.TestHelperLogNotContains(warnMsg, hook, t)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestConflictResolver(t *testing.T) {\n\tsuite.Run(t, new(ResolverSuite))\n}\n"
  },
  {
    "path": "plan/metrics.go",
    "content": "/*\nCopyright 2026 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage plan\n\nimport (\n\t\"github.com/prometheus/client_golang/prometheus\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/pkg/metrics\"\n)\n\nvar (\n\t// registryOwnerMismatchPerSync tracks records skipped due to owner mismatch.\n\t// The \"domain\" label uses the naked/apex domain (e.g., \"example.com\") rather than\n\t// full FQDNs to prevent cardinality explosion. With thousands of subdomains under\n\t// one apex domain, using full FQDNs would create excessive metric series.\n\tregistryOwnerMismatchPerSync = metrics.NewGaugedVectorOpts(\n\t\tprometheus.GaugeOpts{\n\t\t\tSubsystem: \"registry\",\n\t\t\tName:      \"skipped_records_owner_mismatch_per_sync\",\n\t\t\tHelp:      \"Number of records skipped with owner mismatch for each record type, owner mismatch ID and domain (vector).\",\n\t\t},\n\t\t[]string{\"record_type\", \"owner\", \"foreign_owner\", \"domain\"},\n\t)\n)\n\nfunc init() {\n\tmetrics.RegisterMetric.MustRegister(registryOwnerMismatchPerSync)\n}\n\n// recordOwnerMismatch increments the per-sync gauge for a single skipped record due to an\n// owner mismatch. Labels capture the record type, expected owner, foreign owner, and the\n// record's parent domain (apex). Using the parent domain instead of the full FQDN prevents\n// metric cardinality explosion.\nfunc recordOwnerMismatch(owner string, current *endpoint.Endpoint) {\n\tregistryOwnerMismatchPerSync.AddWithLabels(\n\t\t1.0,\n\t\tcurrent.RecordType,\n\t\towner,\n\t\tcurrent.GetOwner(),\n\t\tcurrent.GetNakedDomain(),\n\t)\n}\n"
  },
  {
    "path": "plan/metrics_test.go",
    "content": "/*\nCopyright 2026 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage plan\n\nimport (\n\t\"testing\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"sigs.k8s.io/external-dns/internal/testutils\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\tlogtest \"sigs.k8s.io/external-dns/internal/testutils/log\"\n)\n\nfunc TestOwnerMismatchMetric(t *testing.T) {\n\tcurrentA := &endpoint.Endpoint{\n\t\tDNSName:    \"example.domain.com\",\n\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\tRecordType: endpoint.RecordTypeA,\n\t\tLabels: map[string]string{\n\t\t\tendpoint.OwnerLabelKey: \"other-owner\",\n\t\t},\n\t}\n\tdesiredCname := &endpoint.Endpoint{\n\t\tDNSName:    \"example.domain.com\",\n\t\tTargets:    endpoint.Targets{\"target.example.com\"},\n\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\tLabels: map[string]string{\n\t\t\tendpoint.OwnerLabelKey: \"my-owner\",\n\t\t},\n\t}\n\n\tp := &Plan{\n\t\tPolicies:       []Policy{&SyncPolicy{}},\n\t\tCurrent:        []*endpoint.Endpoint{currentA},\n\t\tDesired:        []*endpoint.Endpoint{desiredCname},\n\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME},\n\t\tOwnerID:        \"my-owner\",\n\t}\n\n\tchanges := p.Calculate().Changes\n\tassert.Empty(t, changes.Create, \"expected no creates due to owner mismatch\")\n\n\ttestutils.TestHelperVerifyMetricsGaugeVectorWithLabels(\n\t\tt,\n\t\t1.0,\n\t\tregistryOwnerMismatchPerSync.Gauge,\n\t\tmap[string]string{\n\t\t\t\"record_type\":   endpoint.RecordTypeA,\n\t\t\t\"foreign_owner\": \"other-owner\",\n\t\t\t\"domain\":        \"domain.com\",\n\t\t},\n\t)\n}\n\n// TestCalculateOwnerMismatchDetection verifies that owner mismatch is detected\n// when desired endpoints want to create new record types on DNS names\n// that have current records owned by a different owner.\nfunc TestCalculateOwnerMismatchDetection(t *testing.T) {\n\tcurrent := testutils.GenerateTestEndpointsWithDistribution(\n\t\tmap[string]int{endpoint.RecordTypeA: 10},\n\t\tmap[string]int{\"example.com\": 1},\n\t\tmap[string]int{\"other-owner\": 1},\n\t)\n\n\t// Create desired endpoints: same DNS names but with different type records (new type triggers Create)\n\tvar desired []*endpoint.Endpoint\n\tfor _, ep := range current {\n\t\tdesired = append(desired, &endpoint.Endpoint{\n\t\t\tDNSName:    ep.DNSName,\n\t\t\tTargets:    endpoint.Targets{\"abrakadabra\"},\n\t\t\tRecordType: endpoint.RecordTypeTXT,\n\t\t\tRecordTTL:  300,\n\t\t})\n\t}\n\n\tp := &Plan{\n\t\tPolicies:       []Policy{&SyncPolicy{}},\n\t\tCurrent:        current,\n\t\tDesired:        desired,\n\t\tManagedRecords: endpoint.KnownRecordTypes,\n\t\tOwnerID:        \"my-owner\",\n\t}\n\thook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t)\n\tchanges := p.Calculate().Changes\n\n\tassert.Empty(t, changes.Create, \"expected no creates due to owner mismatch\")\n\tlogtest.TestHelperLogContains(\"owner id does not match for one or more items to create\", hook, t)\n}\n\nfunc TestOwnerMismatchMetricDistribution(t *testing.T) {\n\tp := newOwnerMismatchFixture()\n\n\tp.Calculate()\n\ttestutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 44, registryOwnerMismatchPerSync.Gauge,\n\t\tmap[string]string{\"record_type\": endpoint.RecordTypeSRV})\n\ttestutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 41, registryOwnerMismatchPerSync.Gauge,\n\t\tmap[string]string{\"foreign_owner\": \"owner1\"})\n\ttestutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 125, registryOwnerMismatchPerSync.Gauge,\n\t\tmap[string]string{\"owner\": \"my-owner\"})\n\ttestutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 21, registryOwnerMismatchPerSync.Gauge,\n\t\tmap[string]string{\"foreign_owner\": \"owner1\", \"domain\": \"open.net\"})\n\ttestutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 2, registryOwnerMismatchPerSync.Gauge,\n\t\tmap[string]string{\"record_type\": endpoint.RecordTypeCNAME, \"foreign_owner\": \"owner1\", \"domain\": \"open.net\"})\n}\n\nfunc BenchmarkOwnerMismatchMetricDistribution(b *testing.B) {\n\tp := newOwnerMismatchFixture(1000)\n\n\tfor b.Loop() {\n\t\tp.Calculate()\n\t}\n}\n\nfunc newOwnerMismatchFixture(scale ...int) *Plan {\n\tfactor := 1\n\tif len(scale) > 0 && scale[0] > 1 {\n\t\tfactor = scale[0]\n\t}\n\tcurrent := testutils.GenerateTestEndpointsWithDistribution(\n\t\tmap[string]int{\n\t\t\tendpoint.RecordTypeA:     12 * factor,\n\t\t\tendpoint.RecordTypeAAAA:  27 * factor,\n\t\t\tendpoint.RecordTypeCNAME: 42 * factor,\n\t\t\tendpoint.RecordTypeSRV:   44 * factor,\n\t\t},\n\t\tmap[string]int{\n\t\t\t\"example.com\": 1,\n\t\t\t\"tld.org\":     2,\n\t\t\t\"open.net\":    3,\n\t\t},\n\t\tmap[string]int{\"owner1\": 1, \"owner2\": 1, \"owner3\": 1},\n\t)\n\n\tvar desired []*endpoint.Endpoint\n\tfor _, ep := range current {\n\t\tdesired = append(desired, &endpoint.Endpoint{\n\t\t\tDNSName:    ep.DNSName,\n\t\t\tTargets:    endpoint.Targets{\"txt-value\"},\n\t\t\tRecordType: endpoint.RecordTypeTXT,\n\t\t\tRecordTTL:  300,\n\t\t})\n\t}\n\n\treturn &Plan{\n\t\tPolicies:       []Policy{&SyncPolicy{}},\n\t\tCurrent:        current,\n\t\tDesired:        desired,\n\t\tManagedRecords: endpoint.KnownRecordTypes,\n\t\tOwnerID:        \"my-owner\",\n\t}\n}\n\nfunc TestFlushOwnerMismatch(t *testing.T) {\n\ttests := []struct {\n\t\tname         string\n\t\towner        string\n\t\tcurrent      *endpoint.Endpoint\n\t\tcalls        int\n\t\texpected     float64\n\t\texpectedTags map[string]string\n\t}{\n\t\t{\n\t\t\tname:  \"handles_missing_foreign_owner_label\",\n\t\t\towner: \"me\",\n\t\t\tcurrent: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"sub.domain.net\",\n\t\t\t\tRecordType: endpoint.RecordTypeTXT,\n\t\t\t\tLabels:     map[string]string{},\n\t\t\t},\n\t\t\tcalls:    1,\n\t\t\texpected: 1.0,\n\t\t\texpectedTags: map[string]string{\n\t\t\t\t\"record_type\":   endpoint.RecordTypeTXT,\n\t\t\t\t\"owner\":         \"me\",\n\t\t\t\t\"foreign_owner\": \"\",\n\t\t\t\t\"domain\":        \"domain.net\",\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tregistryOwnerMismatchPerSync.Gauge.Reset()\n\n\t\t\tfor range tt.calls {\n\t\t\t\trecordOwnerMismatch(tt.owner, tt.current)\n\t\t\t}\n\n\t\t\ttestutils.TestHelperVerifyMetricsGaugeVectorWithLabels(\n\t\t\t\tt,\n\t\t\t\ttt.expected,\n\t\t\t\tregistryOwnerMismatchPerSync.Gauge,\n\t\t\t\ttt.expectedTags,\n\t\t\t)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "plan/plan.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage plan\n\nimport (\n\t\"slices\"\n\n\t\"github.com/google/go-cmp/cmp\"\n\t\"github.com/google/go-cmp/cmp/cmpopts\"\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/internal/idna\"\n)\n\n// Plan can convert a list of desired and current records to a series of create,\n// update and delete actions.\ntype Plan struct {\n\t// List of current records\n\t// Records that already exist in the DNS provider (e.g., Route53, Cloudflare, etc.). These are fetched from the provider's registry.\n\tCurrent []*endpoint.Endpoint\n\t// List of desired records\n\t// Records that should exist based on Kubernetes resources (Ingress, Service, etc.). These are computed from the source.\n\tDesired []*endpoint.Endpoint\n\t// Policies under which the desired changes are calculated\n\tPolicies []Policy\n\t// List of changes necessary to move towards desired state\n\t// Populated after calling Calculate()\n\tChanges *Changes\n\t// DomainFilter matches DNS names\n\tDomainFilter endpoint.MatchAllDomainFilters\n\t// ManagedRecords are DNS record types that will be considered for management.\n\tManagedRecords []string\n\t// ExcludeRecords are DNS record types that will be excluded from management.\n\tExcludeRecords []string\n\t// OwnerID of records to manage\n\tOwnerID string\n\t// Old owner ID we migrate from\n\tOldOwnerID string\n}\n\n// Changes holds lists of actions to be executed by dns providers\ntype Changes struct {\n\t// Records that need to be created\n\tCreate []*endpoint.Endpoint `json:\"create,omitempty\"`\n\t// Records that need to be updated (current data)\n\tUpdateOld []*endpoint.Endpoint `json:\"updateOld,omitempty\"`\n\t// Records that need to be updated (desired data)\n\tUpdateNew []*endpoint.Endpoint `json:\"updateNew,omitempty\"`\n\t// Records that need to be deleted\n\tDelete []*endpoint.Endpoint `json:\"delete,omitempty\"`\n}\n\n// planKey is a key for a row in `planTable`.\ntype planKey struct {\n\tdnsName       string\n\tsetIdentifier string\n}\n\n// planTable is a supplementary struct for Plan\n// each row correspond to a planKey -> (current records + all desired records)\n//\n//\tplanTable (-> = target)\n//\t--------------------------------------------------------------\n//\tDNSName | Current record       | Desired Records             |\n//\t--------------------------------------------------------------\n//\tfoo.com | [->1.1.1.1 ]         | [->1.1.1.1]                 |  = no action\n//\t--------------------------------------------------------------\n//\tbar.com |                      | [->191.1.1.1, ->190.1.1.1]  |  = create (bar.com [-> 190.1.1.1])\n//\t--------------------------------------------------------------\n//\tdog.com | [->1.1.1.2]          |                             |  = delete (dog.com [-> 1.1.1.2])\n//\t--------------------------------------------------------------\n//\tcat.com | [->::1, ->1.1.1.3]   | [->1.1.1.3]                 |  = update old (cat.com [-> ::1, -> 1.1.1.3]) new (cat.com [-> 1.1.1.3])\n//\t--------------------------------------------------------------\n//\tbig.com | [->1.1.1.4]          | [->ing.elb.com]             |  = update old (big.com [-> 1.1.1.4]) new (big.com [-> ing.elb.com])\n//\t--------------------------------------------------------------\n//\t\"=\", i.e. result of calculation relies on supplied ConflictResolver\ntype planTable struct {\n\trows     map[planKey]*planTableRow\n\tresolver ConflictResolver\n}\n\nfunc newPlanTable() planTable { // TODO: make resolver configurable\n\treturn planTable{map[planKey]*planTableRow{}, PerResource{}}\n}\n\n// planTableRow represents a set of current and desired domain resource records.\ntype planTableRow struct {\n\t// current corresponds to the records currently occupying dns name on the dns provider. More than one record may\n\t// be represented here: for example A and AAAA. If the current domain record is a CNAME, no other record types\n\t// are allowed per [RFC 1034 3.6.2]\n\t//\n\t// [RFC 1034 3.6.2]: https://datatracker.ietf.org/doc/html/rfc1034#autoid-15\n\tcurrent []*endpoint.Endpoint\n\t// candidates corresponds to the list of records which would like to have this dnsName.\n\tcandidates []*endpoint.Endpoint\n\t// records is a grouping of current and candidates by record type, for example A, AAAA, CNAME.\n\trecords map[string]*domainEndpoints\n}\n\n// domainEndpoints is a grouping of current, which are existing records from the registry, and candidates,\n// which are desired records from the source. All records in this grouping have the same record type.\ntype domainEndpoints struct {\n\t// current corresponds to existing record from the registry. Maybe nil if no current record of the type exists.\n\tcurrent *endpoint.Endpoint\n\t// candidates corresponds to the list of records which would like to have this dnsName.\n\tcandidates []*endpoint.Endpoint\n}\n\nfunc (t *planTable) addCurrent(e *endpoint.Endpoint) {\n\tkey := t.newPlanKey(e)\n\tt.rows[key].current = append(t.rows[key].current, e)\n\tt.rows[key].records[e.RecordType].current = e\n}\n\nfunc (t *planTable) addCandidate(e *endpoint.Endpoint) {\n\tkey := t.newPlanKey(e)\n\trow := t.rows[key]\n\trow.candidates = append(row.candidates, e)\n\trow.records[e.RecordType].candidates = append(row.records[e.RecordType].candidates, e)\n}\n\nfunc (t *planTable) newPlanKey(e *endpoint.Endpoint) planKey {\n\tkey := planKey{\n\t\tdnsName:       idna.NormalizeDNSName(e.DNSName),\n\t\tsetIdentifier: e.SetIdentifier,\n\t}\n\n\tif _, ok := t.rows[key]; !ok {\n\t\tt.rows[key] = &planTableRow{\n\t\t\trecords: make(map[string]*domainEndpoints),\n\t\t}\n\t}\n\n\tif _, ok := t.rows[key].records[e.RecordType]; !ok {\n\t\tt.rows[key].records[e.RecordType] = &domainEndpoints{}\n\t}\n\n\treturn key\n}\n\nfunc (c *Changes) HasChanges() bool {\n\tif len(c.Create) > 0 || len(c.Delete) > 0 {\n\t\treturn true\n\t}\n\treturn !cmp.Equal(c.UpdateNew, c.UpdateOld, cmpopts.IgnoreUnexported(endpoint.Endpoint{}))\n}\n\n// Calculate computes the actions needed to move current state towards desired\n// state. It then passes those changes to the current policy for further\n// processing. It returns a copy of Plan with the changes populated.\nfunc (p *Plan) Calculate() *Plan {\n\tt := newPlanTable()\n\n\tif p.DomainFilter == nil {\n\t\tp.DomainFilter = endpoint.MatchAllDomainFilters(nil)\n\t}\n\n\tfor _, current := range filterRecordsForPlan(p.Current, p.DomainFilter, p.ManagedRecords, p.ExcludeRecords) {\n\t\tt.addCurrent(current)\n\t}\n\tfor _, desired := range filterRecordsForPlan(p.Desired, p.DomainFilter, p.ManagedRecords, p.ExcludeRecords) {\n\t\tt.addCandidate(desired)\n\t}\n\n\tif p.OwnerID != \"\" {\n\t\tregistryOwnerMismatchPerSync.Gauge.Reset()\n\t}\n\tchanges := p.calculateChanges(t)\n\n\t// Return a minimal plan with only the fields relevant to callers.\n\t// ManagedRecords is reset to the canonical defaults (A/AAAA/CNAME) —\n\t// this is intentional: it restores the default managed set regardless\n\t// of what was passed in, preventing callers that chain off Calculate()\n\t// from accidentally inheriting a non-default managed record configuration.\n\t// See: https://github.com/kubernetes-sigs/external-dns/pull/1915\n\tplan := &Plan{\n\t\tCurrent:        p.Current,\n\t\tDesired:        p.Desired,\n\t\tChanges:        changes,\n\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME},\n\t}\n\n\treturn plan\n}\n\nfunc (p *Plan) calculateChanges(t planTable) *Changes {\n\tchanges := &Changes{}\n\n\tfor key, row := range t.rows {\n\t\tswitch {\n\t\t// dns name not taken\n\t\tcase len(row.current) == 0:\n\t\t\trecordsByType := t.resolver.ResolveRecordTypes(key, row)\n\t\t\tfor _, records := range recordsByType {\n\t\t\t\tif len(records.candidates) > 0 {\n\t\t\t\t\tchanges.Create = append(changes.Create, t.resolver.ResolveCreate(records.candidates))\n\t\t\t\t}\n\t\t\t}\n\n\t\t// dns name released or possibly owned by a different external dns\n\t\tcase len(row.candidates) == 0:\n\t\t\tchanges.Delete = append(changes.Delete, row.current...)\n\n\t\t// dns name is taken\n\t\tcase len(row.candidates) > 0:\n\t\t\tp.appendTakenDNSNameChanges(t, changes, key, row)\n\t\t}\n\t}\n\n\tfor _, pol := range p.Policies {\n\t\tchanges = pol.Apply(changes)\n\t}\n\n\t// filter out updates this external dns does not have ownership claim over\n\tif p.OwnerID != \"\" {\n\t\tchanges.Delete = endpoint.FilterEndpointsByOwnerID(p.OwnerID, changes.Delete)\n\t\tchanges.Delete = endpoint.RemoveDuplicates(changes.Delete)\n\t\tchanges.UpdateOld = endpoint.FilterEndpointsByOwnerID(p.OwnerID, changes.UpdateOld)\n\t\tchanges.UpdateNew = endpoint.FilterEndpointsByOwnerID(p.OwnerID, changes.UpdateNew)\n\t}\n\n\treturn changes\n}\n\nfunc (p *Plan) appendTakenDNSNameChanges(\n\tt planTable,\n\tchanges *Changes,\n\tkey planKey,\n\trow *planTableRow) {\n\t// apply changes for each record type\n\trowChanges := p.calculatePlanTableRowChanges(t, key, row)\n\tchanges.Delete = append(changes.Delete, rowChanges.Delete...)\n\tchanges.UpdateNew = append(changes.UpdateNew, rowChanges.UpdateNew...)\n\tchanges.UpdateOld = append(changes.UpdateOld, rowChanges.UpdateOld...)\n\tif len(rowChanges.Create) == 0 {\n\t\treturn\n\t}\n\n\t// only add creates if the external dns has ownership claim on the domain\n\townersMatch := true\n\tif p.OwnerID != \"\" {\n\t\tfor _, current := range row.current {\n\t\t\tif !current.IsOwnedBy(p.OwnerID) {\n\t\t\t\townersMatch = false\n\t\t\t\trecordOwnerMismatch(p.OwnerID, current)\n\t\t\t\tif log.IsLevelEnabled(log.DebugLevel) {\n\t\t\t\t\tlog.Debugf(`Skipping endpoint %v because owner id does not match for one or more items to create, found: \"%s\", required: \"%s\"`, current, current.Labels[endpoint.OwnerLabelKey], p.OwnerID)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tif ownersMatch {\n\t\tchanges.Create = append(changes.Create, rowChanges.Create...)\n\t}\n}\n\nfunc (p *Plan) calculatePlanTableRowChanges(t planTable, key planKey, row *planTableRow) *Changes {\n\tchanges := &Changes{}\n\n\trecordsByType := t.resolver.ResolveRecordTypes(key, row)\n\tfor _, records := range recordsByType {\n\t\tswitch {\n\t\t// record type not desired\n\t\tcase records.current != nil && len(records.candidates) == 0:\n\t\t\tchanges.Delete = append(changes.Delete, records.current)\n\n\t\t// new record type desired\n\t\tcase records.current == nil && len(records.candidates) > 0:\n\t\t\tupdate := t.resolver.ResolveCreate(records.candidates)\n\t\t\t// creates are evaluated after all domain records have been processed to\n\t\t\t// validate that this external dns has ownership claim on the domain before\n\t\t\t// adding the records to planned changes.\n\t\t\tchanges.Create = append(changes.Create, update)\n\n\t\t// update existing record\n\t\tcase records.current != nil && len(records.candidates) > 0:\n\t\t\tp.appendEndpointUpdates(t, changes, records.current, records.candidates)\n\t\t}\n\t}\n\n\treturn changes\n}\n\nfunc (p *Plan) appendEndpointUpdates(t planTable, changes *Changes, current *endpoint.Endpoint, candidates []*endpoint.Endpoint) {\n\tupdate := t.resolver.ResolveUpdate(current, candidates)\n\n\tif shouldUpdateTTL(update, current) || targetChanged(update, current) ||\n\t\tp.providerSpecificChanged(update, current) || p.isOldOwnerIDSetAndDifferent(current) {\n\t\tinheritOwner(current, update)\n\t\tchanges.UpdateNew = append(changes.UpdateNew, update)\n\t\tchanges.UpdateOld = append(changes.UpdateOld, current)\n\t}\n}\n\nfunc (p *Plan) isOldOwnerIDSetAndDifferent(current *endpoint.Endpoint) bool {\n\treturn p.OldOwnerID != \"\" && current.Labels[endpoint.OwnerLabelKey] != p.OldOwnerID\n}\n\nfunc inheritOwner(from, to *endpoint.Endpoint) {\n\tif to.Labels == nil {\n\t\tto.Labels = map[string]string{}\n\t}\n\tif from.Labels == nil {\n\t\tfrom.Labels = map[string]string{}\n\t}\n\tto.Labels[endpoint.OwnerLabelKey] = from.Labels[endpoint.OwnerLabelKey]\n}\n\nfunc targetChanged(desired, current *endpoint.Endpoint) bool {\n\treturn !desired.Targets.Same(current.Targets)\n}\n\nfunc shouldUpdateTTL(desired, current *endpoint.Endpoint) bool {\n\tif !desired.RecordTTL.IsConfigured() {\n\t\treturn false\n\t}\n\treturn desired.RecordTTL != current.RecordTTL\n}\n\nfunc (p *Plan) providerSpecificChanged(desired, current *endpoint.Endpoint) bool {\n\tdesiredProperties := make(map[string]endpoint.ProviderSpecificProperty, len(desired.ProviderSpecific))\n\n\tfor _, d := range desired.ProviderSpecific {\n\t\tdesiredProperties[d.Name] = d\n\t}\n\tfor _, c := range current.ProviderSpecific {\n\t\tif d, ok := desiredProperties[c.Name]; ok {\n\t\t\tif c.Value != d.Value {\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tdelete(desiredProperties, c.Name)\n\t\t} else {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn len(desiredProperties) > 0\n}\n\n// filterRecordsForPlan removes records that are not relevant to the planner.\n// Currently, this just removes TXT records to prevent them from being\n// deleted erroneously by the planner (only the TXT registry should do this.)\n//\n// Per RFC 1034, CNAME records conflict with all other records - it is the\n// only record with this property. The behavior of the planner may need to be\n// made more sophisticated to codify this.\nfunc filterRecordsForPlan(records []*endpoint.Endpoint, domainFilter endpoint.MatchAllDomainFilters, managedRecords, excludeRecords []string) []*endpoint.Endpoint {\n\tfiltered := make([]*endpoint.Endpoint, 0, len(records))\n\n\tfor _, record := range records {\n\t\t// Ignore records that do not match the domain filter provided\n\t\tif !domainFilter.Match(record.DNSName) {\n\t\t\tlog.Debugf(\"ignoring record %s that does not match domain filter\", record.DNSName)\n\t\t\tcontinue\n\t\t}\n\t\tif IsManagedRecord(record.RecordType, managedRecords, excludeRecords) {\n\t\t\tfiltered = append(filtered, record)\n\t\t}\n\t}\n\n\treturn filtered\n}\n\nfunc IsManagedRecord(record string, managedRecords, excludeRecords []string) bool {\n\tif slices.Contains(excludeRecords, record) {\n\t\treturn false\n\t}\n\treturn slices.Contains(managedRecords, record)\n}\n"
  },
  {
    "path": "plan/plan_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage plan\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"strings\"\n\t\"testing\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\t\"github.com/stretchr/testify/suite\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/internal/testutils\"\n\tlogtest \"sigs.k8s.io/external-dns/internal/testutils/log\"\n)\n\ntype PlanTestSuite struct {\n\tsuite.Suite\n\tfooV1Cname                       *endpoint.Endpoint\n\tfooV2Cname                       *endpoint.Endpoint\n\tfooV2CnameUppercase              *endpoint.Endpoint\n\tfooV2TXT                         *endpoint.Endpoint\n\tfooV2CnameNoLabel                *endpoint.Endpoint\n\tfooV3CnameSameResource           *endpoint.Endpoint\n\tfooA5                            *endpoint.Endpoint\n\tfooAAAA                          *endpoint.Endpoint\n\tdsA                              *endpoint.Endpoint\n\tdsAAAA                           *endpoint.Endpoint\n\tbar127A                          *endpoint.Endpoint\n\tbar127AWithTTL                   *endpoint.Endpoint\n\tbar127AWithProviderSpecificTrue  *endpoint.Endpoint\n\tbar127AWithProviderSpecificFalse *endpoint.Endpoint\n\tbar127AWithProviderSpecificUnset *endpoint.Endpoint\n\tbar192A                          *endpoint.Endpoint\n\tmultiple1                        *endpoint.Endpoint\n\tmultiple2                        *endpoint.Endpoint\n\tmultiple3                        *endpoint.Endpoint\n\tdomainFilterFiltered1            *endpoint.Endpoint\n\tdomainFilterFiltered2            *endpoint.Endpoint\n\tdomainFilterFiltered3            *endpoint.Endpoint\n\tdomainFilterExcluded             *endpoint.Endpoint\n}\n\nfunc (suite *PlanTestSuite) SetupTest() {\n\tsuite.fooV1Cname = &endpoint.Endpoint{\n\t\tDNSName:    \"foo\",\n\t\tTargets:    endpoint.Targets{\"v1\"},\n\t\tRecordType: \"CNAME\",\n\t\tLabels: map[string]string{\n\t\t\tendpoint.ResourceLabelKey: \"ingress/default/foo-v1\",\n\t\t\tendpoint.OwnerLabelKey:    \"pwner\",\n\t\t},\n\t}\n\t// same resource as fooV1Cname, but target is different. It will never be picked because its target lexicographically bigger than \"v1\"\n\tsuite.fooV3CnameSameResource = &endpoint.Endpoint{ // TODO: remove this once endpoint can support multiple targets\n\t\tDNSName:    \"foo\",\n\t\tTargets:    endpoint.Targets{\"v3\"},\n\t\tRecordType: \"CNAME\",\n\t\tLabels: map[string]string{\n\t\t\tendpoint.ResourceLabelKey: \"ingress/default/foo-v1\",\n\t\t\tendpoint.OwnerLabelKey:    \"pwner\",\n\t\t},\n\t}\n\tsuite.fooV2Cname = &endpoint.Endpoint{\n\t\tDNSName:    \"foo\",\n\t\tTargets:    endpoint.Targets{\"v2\"},\n\t\tRecordType: \"CNAME\",\n\t\tLabels: map[string]string{\n\t\t\tendpoint.ResourceLabelKey: \"ingress/default/foo-v2\",\n\t\t},\n\t}\n\tsuite.fooV2CnameUppercase = &endpoint.Endpoint{\n\t\tDNSName:    \"foo\",\n\t\tTargets:    endpoint.Targets{\"V2\"},\n\t\tRecordType: \"CNAME\",\n\t\tLabels: map[string]string{\n\t\t\tendpoint.ResourceLabelKey: \"ingress/default/foo-v2\",\n\t\t},\n\t}\n\tsuite.fooV2TXT = &endpoint.Endpoint{\n\t\tDNSName:    \"foo\",\n\t\tRecordType: \"TXT\",\n\t}\n\tsuite.fooV2CnameNoLabel = &endpoint.Endpoint{\n\t\tDNSName:    \"foo\",\n\t\tTargets:    endpoint.Targets{\"v2\"},\n\t\tRecordType: \"CNAME\",\n\t}\n\tsuite.fooA5 = &endpoint.Endpoint{\n\t\tDNSName:    \"foo\",\n\t\tTargets:    endpoint.Targets{\"5.5.5.5\"},\n\t\tRecordType: \"A\",\n\t\tLabels: map[string]string{\n\t\t\tendpoint.ResourceLabelKey: \"ingress/default/foo-5\",\n\t\t},\n\t}\n\tsuite.fooAAAA = &endpoint.Endpoint{\n\t\tDNSName:    \"foo\",\n\t\tTargets:    endpoint.Targets{\"2001:DB8::1\"},\n\t\tRecordType: \"AAAA\",\n\t\tLabels: map[string]string{\n\t\t\tendpoint.ResourceLabelKey: \"ingress/default/foo-AAAA\",\n\t\t},\n\t}\n\tsuite.dsA = &endpoint.Endpoint{\n\t\tDNSName:    \"ds\",\n\t\tTargets:    endpoint.Targets{\"1.1.1.1\"},\n\t\tRecordType: \"A\",\n\t\tLabels: map[string]string{\n\t\t\tendpoint.ResourceLabelKey: \"ingress/default/ds\",\n\t\t},\n\t}\n\tsuite.dsAAAA = &endpoint.Endpoint{\n\t\tDNSName:    \"ds\",\n\t\tTargets:    endpoint.Targets{\"2001:DB8::1\"},\n\t\tRecordType: \"AAAA\",\n\t\tLabels: map[string]string{\n\t\t\tendpoint.ResourceLabelKey: \"ingress/default/ds-AAAAA\",\n\t\t},\n\t}\n\tsuite.bar127A = &endpoint.Endpoint{\n\t\tDNSName:    \"bar\",\n\t\tTargets:    endpoint.Targets{\"127.0.0.1\"},\n\t\tRecordType: \"A\",\n\t\tLabels: map[string]string{\n\t\t\tendpoint.ResourceLabelKey: \"ingress/default/bar-127\",\n\t\t},\n\t}\n\tsuite.bar127AWithTTL = &endpoint.Endpoint{\n\t\tDNSName:    \"bar\",\n\t\tTargets:    endpoint.Targets{\"127.0.0.1\"},\n\t\tRecordType: \"A\",\n\t\tRecordTTL:  300,\n\t\tLabels: map[string]string{\n\t\t\tendpoint.ResourceLabelKey: \"ingress/default/bar-127\",\n\t\t},\n\t}\n\tsuite.bar127AWithProviderSpecificTrue = &endpoint.Endpoint{\n\t\tDNSName:    \"bar\",\n\t\tTargets:    endpoint.Targets{\"127.0.0.1\"},\n\t\tRecordType: \"A\",\n\t\tLabels: map[string]string{\n\t\t\tendpoint.ResourceLabelKey: \"ingress/default/bar-127\",\n\t\t},\n\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\tendpoint.ProviderSpecificProperty{\n\t\t\t\tName:  \"alias\",\n\t\t\t\tValue: \"false\",\n\t\t\t},\n\t\t\tendpoint.ProviderSpecificProperty{\n\t\t\t\tName:  \"external-dns.alpha.kubernetes.io/cloudflare-proxied\",\n\t\t\t\tValue: \"true\",\n\t\t\t},\n\t\t},\n\t}\n\tsuite.bar127AWithProviderSpecificFalse = &endpoint.Endpoint{\n\t\tDNSName:    \"bar\",\n\t\tTargets:    endpoint.Targets{\"127.0.0.1\"},\n\t\tRecordType: \"A\",\n\t\tLabels: map[string]string{\n\t\t\tendpoint.ResourceLabelKey: \"ingress/default/bar-127\",\n\t\t},\n\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\tendpoint.ProviderSpecificProperty{\n\t\t\t\tName:  \"external-dns.alpha.kubernetes.io/cloudflare-proxied\",\n\t\t\t\tValue: \"false\",\n\t\t\t},\n\t\t\tendpoint.ProviderSpecificProperty{\n\t\t\t\tName:  \"alias\",\n\t\t\t\tValue: \"false\",\n\t\t\t},\n\t\t},\n\t}\n\tsuite.bar127AWithProviderSpecificUnset = &endpoint.Endpoint{\n\t\tDNSName:    \"bar\",\n\t\tTargets:    endpoint.Targets{\"127.0.0.1\"},\n\t\tRecordType: \"A\",\n\t\tLabels: map[string]string{\n\t\t\tendpoint.ResourceLabelKey: \"ingress/default/bar-127\",\n\t\t},\n\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\tendpoint.ProviderSpecificProperty{\n\t\t\t\tName:  \"alias\",\n\t\t\t\tValue: \"false\",\n\t\t\t},\n\t\t},\n\t}\n\tsuite.bar192A = &endpoint.Endpoint{\n\t\tDNSName:    \"bar\",\n\t\tTargets:    endpoint.Targets{\"192.168.0.1\"},\n\t\tRecordType: \"A\",\n\t\tLabels: map[string]string{\n\t\t\tendpoint.ResourceLabelKey: \"ingress/default/bar-192\",\n\t\t},\n\t}\n\tsuite.multiple1 = &endpoint.Endpoint{\n\t\tDNSName:       \"multiple\",\n\t\tTargets:       endpoint.Targets{\"192.168.0.1\"},\n\t\tRecordType:    \"A\",\n\t\tSetIdentifier: \"test-set-1\",\n\t}\n\tsuite.multiple2 = &endpoint.Endpoint{\n\t\tDNSName:       \"multiple\",\n\t\tTargets:       endpoint.Targets{\"192.168.0.2\"},\n\t\tRecordType:    \"A\",\n\t\tSetIdentifier: \"test-set-1\",\n\t}\n\tsuite.multiple3 = &endpoint.Endpoint{\n\t\tDNSName:       \"multiple\",\n\t\tTargets:       endpoint.Targets{\"192.168.0.2\"},\n\t\tRecordType:    \"A\",\n\t\tSetIdentifier: \"test-set-2\",\n\t}\n\tsuite.domainFilterFiltered1 = &endpoint.Endpoint{\n\t\tDNSName:    \"foo.domain.tld\",\n\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\tRecordType: \"A\",\n\t}\n\tsuite.domainFilterFiltered2 = &endpoint.Endpoint{\n\t\tDNSName:    \"bar.domain.tld\",\n\t\tTargets:    endpoint.Targets{\"1.2.3.5\"},\n\t\tRecordType: \"A\",\n\t}\n\tsuite.domainFilterFiltered3 = &endpoint.Endpoint{\n\t\tDNSName:    \"baz.domain.tld\",\n\t\tTargets:    endpoint.Targets{\"1.2.3.6\"},\n\t\tRecordType: \"A\",\n\t}\n\tsuite.domainFilterExcluded = &endpoint.Endpoint{\n\t\tDNSName:    \"foo.ex.domain.tld\",\n\t\tTargets:    endpoint.Targets{\"1.1.1.1\"},\n\t\tRecordType: \"A\",\n\t}\n}\n\nfunc TestPlan_ChangesJson_DecodeEncode(t *testing.T) {\n\tch := &Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName: \"foo\",\n\t\t\t},\n\t\t},\n\t\tUpdateOld: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName: \"bar\",\n\t\t\t},\n\t\t},\n\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName: \"baz\",\n\t\t\t},\n\t\t},\n\t\tDelete: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName: \"qux\",\n\t\t\t},\n\t\t},\n\t}\n\tjsonBytes, err := json.Marshal(ch)\n\trequire.NoError(t, err)\n\tassert.JSONEq(t,\n\t\t`{\"create\":[{\"dnsName\":\"foo\"}],\"updateOld\":[{\"dnsName\":\"bar\"}],\"updateNew\":[{\"dnsName\":\"baz\"}],\"delete\":[{\"dnsName\":\"qux\"}]}`,\n\t\tstring(jsonBytes))\n\tvar changes Changes\n\terr = json.NewDecoder(bytes.NewBuffer(jsonBytes)).Decode(&changes)\n\trequire.NoError(t, err)\n\tassert.Equal(t, ch, &changes)\n}\n\nfunc TestPlan_ChangesJson_DecodeMixedCase(t *testing.T) {\n\tinput := `{\"Create\":[{\"dnsName\":\"foo\"}],\"UpdateOld\":[{\"dnsName\":\"bar\"}],\"updateNew\":[{\"dnsName\":\"baz\"}],\"Delete\":[{\"dnsName\":\"qux\"}]}`\n\tvar changes Changes\n\terr := json.NewDecoder(strings.NewReader(input)).Decode(&changes)\n\trequire.NoError(t, err)\n\tassert.Len(t, changes.Create, 1)\n}\n\nfunc (suite *PlanTestSuite) TestSyncFirstRound() {\n\tcurrent := []*endpoint.Endpoint{}\n\tdesired := []*endpoint.Endpoint{suite.fooV1Cname, suite.fooV2Cname, suite.bar127A}\n\texpectedCreate := []*endpoint.Endpoint{suite.fooV1Cname, suite.bar127A} // v1 is chosen because of resolver taking \"min\"\n\texpectedUpdateOld := []*endpoint.Endpoint{}\n\texpectedUpdateNew := []*endpoint.Endpoint{}\n\texpectedDelete := []*endpoint.Endpoint{}\n\n\tp := &Plan{\n\t\tPolicies:       []Policy{&SyncPolicy{}},\n\t\tCurrent:        current,\n\t\tDesired:        desired,\n\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},\n\t}\n\n\tchanges := p.Calculate().Changes\n\tvalidateEntries(suite.T(), changes.Create, expectedCreate)\n\tvalidateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew)\n\tvalidateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld)\n\tvalidateEntries(suite.T(), changes.Delete, expectedDelete)\n}\n\nfunc (suite *PlanTestSuite) TestSyncSecondRound() {\n\tcurrent := []*endpoint.Endpoint{suite.fooV1Cname}\n\tdesired := []*endpoint.Endpoint{suite.fooV2Cname, suite.fooV1Cname, suite.bar127A}\n\texpectedCreate := []*endpoint.Endpoint{suite.bar127A}\n\texpectedUpdateOld := []*endpoint.Endpoint{}\n\texpectedUpdateNew := []*endpoint.Endpoint{}\n\texpectedDelete := []*endpoint.Endpoint{}\n\n\tp := &Plan{\n\t\tPolicies:       []Policy{&SyncPolicy{}},\n\t\tCurrent:        current,\n\t\tDesired:        desired,\n\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},\n\t}\n\n\tchanges := p.Calculate().Changes\n\tvalidateEntries(suite.T(), changes.Create, expectedCreate)\n\tvalidateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew)\n\tvalidateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld)\n\tvalidateEntries(suite.T(), changes.Delete, expectedDelete)\n}\n\nfunc (suite *PlanTestSuite) TestSyncSecondRoundMigration() {\n\tcurrent := []*endpoint.Endpoint{suite.fooV2CnameNoLabel}\n\tdesired := []*endpoint.Endpoint{suite.fooV2Cname, suite.fooV1Cname, suite.bar127A}\n\texpectedCreate := []*endpoint.Endpoint{suite.bar127A}\n\texpectedUpdateOld := []*endpoint.Endpoint{suite.fooV2CnameNoLabel}\n\texpectedUpdateNew := []*endpoint.Endpoint{suite.fooV1Cname}\n\texpectedDelete := []*endpoint.Endpoint{}\n\n\tp := &Plan{\n\t\tPolicies:       []Policy{&SyncPolicy{}},\n\t\tCurrent:        current,\n\t\tDesired:        desired,\n\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},\n\t}\n\n\tchanges := p.Calculate().Changes\n\tvalidateEntries(suite.T(), changes.Create, expectedCreate)\n\tvalidateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew)\n\tvalidateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld)\n\tvalidateEntries(suite.T(), changes.Delete, expectedDelete)\n}\n\nfunc (suite *PlanTestSuite) TestSyncSecondRoundWithTTLChange() {\n\tcurrent := []*endpoint.Endpoint{suite.bar127A}\n\tdesired := []*endpoint.Endpoint{suite.bar127AWithTTL}\n\texpectedCreate := []*endpoint.Endpoint{}\n\texpectedUpdateOld := []*endpoint.Endpoint{suite.bar127A}\n\texpectedUpdateNew := []*endpoint.Endpoint{suite.bar127AWithTTL}\n\texpectedDelete := []*endpoint.Endpoint{}\n\n\tp := &Plan{\n\t\tPolicies:       []Policy{&SyncPolicy{}},\n\t\tCurrent:        current,\n\t\tDesired:        desired,\n\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},\n\t}\n\n\tchanges := p.Calculate().Changes\n\tvalidateEntries(suite.T(), changes.Create, expectedCreate)\n\tvalidateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew)\n\tvalidateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld)\n\tvalidateEntries(suite.T(), changes.Delete, expectedDelete)\n}\n\nfunc (suite *PlanTestSuite) TestSyncSecondRoundWithProviderSpecificChange() {\n\tcurrent := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificTrue}\n\tdesired := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificFalse}\n\texpectedCreate := []*endpoint.Endpoint{}\n\texpectedUpdateOld := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificTrue}\n\texpectedUpdateNew := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificFalse}\n\texpectedDelete := []*endpoint.Endpoint{}\n\n\tp := &Plan{\n\t\tPolicies:       []Policy{&SyncPolicy{}},\n\t\tCurrent:        current,\n\t\tDesired:        desired,\n\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},\n\t}\n\n\tchanges := p.Calculate().Changes\n\tvalidateEntries(suite.T(), changes.Create, expectedCreate)\n\tvalidateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew)\n\tvalidateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld)\n\tvalidateEntries(suite.T(), changes.Delete, expectedDelete)\n}\n\nfunc (suite *PlanTestSuite) TestSyncSecondRoundWithProviderSpecificNoChange() {\n\tcurrent := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificTrue}\n\tdesired := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificTrue}\n\n\tp := &Plan{\n\t\tPolicies:       []Policy{&SyncPolicy{}},\n\t\tCurrent:        current,\n\t\tDesired:        desired,\n\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},\n\t}\n\n\tchanges := p.Calculate().Changes\n\tsuite.False(changes.HasChanges())\n}\n\nfunc (suite *PlanTestSuite) TestHasChangesCreate() {\n\tchanges := &Changes{\n\t\tCreate: []*endpoint.Endpoint{suite.fooV1Cname},\n\t}\n\tsuite.True(changes.HasChanges())\n}\n\nfunc (suite *PlanTestSuite) TestHasChangesDelete() {\n\tchanges := &Changes{\n\t\tDelete: []*endpoint.Endpoint{suite.fooV1Cname},\n\t}\n\tsuite.True(changes.HasChanges())\n}\n\nfunc (suite *PlanTestSuite) TestHasChanges() {\n\tcurrent := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificTrue}\n\tdesired := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificFalse}\n\n\tp := &Plan{\n\t\tPolicies:       []Policy{&SyncPolicy{}},\n\t\tCurrent:        current,\n\t\tDesired:        desired,\n\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},\n\t}\n\n\tchanges := p.Calculate().Changes\n\tsuite.True(changes.HasChanges())\n}\n\nfunc (suite *PlanTestSuite) TestSyncSecondRoundWithProviderSpecificRemoval() {\n\tcurrent := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificFalse}\n\tdesired := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificUnset}\n\texpectedCreate := []*endpoint.Endpoint{}\n\texpectedUpdateOld := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificFalse}\n\texpectedUpdateNew := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificUnset}\n\texpectedDelete := []*endpoint.Endpoint{}\n\n\tp := &Plan{\n\t\tPolicies:       []Policy{&SyncPolicy{}},\n\t\tCurrent:        current,\n\t\tDesired:        desired,\n\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},\n\t}\n\n\tchanges := p.Calculate().Changes\n\tvalidateEntries(suite.T(), changes.Create, expectedCreate)\n\tvalidateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew)\n\tvalidateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld)\n\tvalidateEntries(suite.T(), changes.Delete, expectedDelete)\n}\n\nfunc (suite *PlanTestSuite) TestSyncSecondRoundWithProviderSpecificAddition() {\n\tcurrent := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificUnset}\n\tdesired := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificTrue}\n\texpectedCreate := []*endpoint.Endpoint{}\n\texpectedUpdateOld := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificUnset}\n\texpectedUpdateNew := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificTrue}\n\texpectedDelete := []*endpoint.Endpoint{}\n\n\tp := &Plan{\n\t\tPolicies:       []Policy{&SyncPolicy{}},\n\t\tCurrent:        current,\n\t\tDesired:        desired,\n\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},\n\t}\n\n\tchanges := p.Calculate().Changes\n\tvalidateEntries(suite.T(), changes.Create, expectedCreate)\n\tvalidateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew)\n\tvalidateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld)\n\tvalidateEntries(suite.T(), changes.Delete, expectedDelete)\n}\n\nfunc (suite *PlanTestSuite) TestSyncSecondRoundWithOwnerInherited() {\n\tcurrent := []*endpoint.Endpoint{suite.fooV1Cname}\n\tdesired := []*endpoint.Endpoint{suite.fooV2Cname}\n\n\texpectedCreate := []*endpoint.Endpoint{}\n\texpectedUpdateOld := []*endpoint.Endpoint{suite.fooV1Cname}\n\texpectedUpdateNew := []*endpoint.Endpoint{{\n\t\tDNSName:    suite.fooV2Cname.DNSName,\n\t\tTargets:    suite.fooV2Cname.Targets,\n\t\tRecordType: suite.fooV2Cname.RecordType,\n\t\tRecordTTL:  suite.fooV2Cname.RecordTTL,\n\t\tLabels: map[string]string{\n\t\t\tendpoint.ResourceLabelKey: suite.fooV2Cname.Labels[endpoint.ResourceLabelKey],\n\t\t\tendpoint.OwnerLabelKey:    suite.fooV1Cname.Labels[endpoint.OwnerLabelKey],\n\t\t},\n\t}}\n\texpectedDelete := []*endpoint.Endpoint{}\n\n\tp := &Plan{\n\t\tPolicies:       []Policy{&SyncPolicy{}},\n\t\tCurrent:        current,\n\t\tDesired:        desired,\n\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},\n\t}\n\n\tchanges := p.Calculate().Changes\n\tvalidateEntries(suite.T(), changes.Create, expectedCreate)\n\tvalidateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew)\n\tvalidateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld)\n\tvalidateEntries(suite.T(), changes.Delete, expectedDelete)\n}\n\nfunc (suite *PlanTestSuite) TestIdempotency() {\n\tcurrent := []*endpoint.Endpoint{suite.fooV1Cname, suite.fooV2Cname}\n\tdesired := []*endpoint.Endpoint{suite.fooV1Cname, suite.fooV2Cname}\n\texpectedCreate := []*endpoint.Endpoint{}\n\texpectedUpdateOld := []*endpoint.Endpoint{}\n\texpectedUpdateNew := []*endpoint.Endpoint{}\n\texpectedDelete := []*endpoint.Endpoint{}\n\n\tp := &Plan{\n\t\tPolicies: []Policy{&SyncPolicy{}},\n\t\tCurrent:  current,\n\t\tDesired:  desired,\n\t}\n\n\tchanges := p.Calculate().Changes\n\tvalidateEntries(suite.T(), changes.Create, expectedCreate)\n\tvalidateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew)\n\tvalidateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld)\n\tvalidateEntries(suite.T(), changes.Delete, expectedDelete)\n}\n\nfunc (suite *PlanTestSuite) TestRecordTypeChange() {\n\tcurrent := []*endpoint.Endpoint{suite.fooV1Cname}\n\tdesired := []*endpoint.Endpoint{suite.fooA5}\n\texpectedCreate := []*endpoint.Endpoint{suite.fooA5}\n\texpectedUpdateOld := []*endpoint.Endpoint{}\n\texpectedUpdateNew := []*endpoint.Endpoint{}\n\texpectedDelete := []*endpoint.Endpoint{suite.fooV1Cname}\n\n\tp := &Plan{\n\t\tPolicies:       []Policy{&SyncPolicy{}},\n\t\tCurrent:        current,\n\t\tDesired:        desired,\n\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME},\n\t\tOwnerID:        suite.fooV1Cname.Labels[endpoint.OwnerLabelKey],\n\t}\n\n\tchanges := p.Calculate().Changes\n\tvalidateEntries(suite.T(), changes.Create, expectedCreate)\n\tvalidateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew)\n\tvalidateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld)\n\tvalidateEntries(suite.T(), changes.Delete, expectedDelete)\n}\n\nfunc (suite *PlanTestSuite) TestExistingCNameWithDualStackDesired() {\n\tcurrent := []*endpoint.Endpoint{suite.fooV1Cname}\n\tdesired := []*endpoint.Endpoint{suite.fooA5, suite.fooAAAA}\n\texpectedCreate := []*endpoint.Endpoint{suite.fooA5, suite.fooAAAA}\n\texpectedUpdateOld := []*endpoint.Endpoint{}\n\texpectedUpdateNew := []*endpoint.Endpoint{}\n\texpectedDelete := []*endpoint.Endpoint{suite.fooV1Cname}\n\n\tp := &Plan{\n\t\tPolicies:       []Policy{&SyncPolicy{}},\n\t\tCurrent:        current,\n\t\tDesired:        desired,\n\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME},\n\t\tOwnerID:        suite.fooV1Cname.Labels[endpoint.OwnerLabelKey],\n\t}\n\n\tchanges := p.Calculate().Changes\n\tvalidateEntries(suite.T(), changes.Create, expectedCreate)\n\tvalidateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew)\n\tvalidateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld)\n\tvalidateEntries(suite.T(), changes.Delete, expectedDelete)\n}\n\nfunc (suite *PlanTestSuite) TestExistingDualStackWithCNameDesired() {\n\tsuite.fooA5.Labels[endpoint.OwnerLabelKey] = \"nerf\"\n\tsuite.fooAAAA.Labels[endpoint.OwnerLabelKey] = \"nerf\"\n\tcurrent := []*endpoint.Endpoint{suite.fooA5, suite.fooAAAA}\n\tdesired := []*endpoint.Endpoint{suite.fooV2Cname}\n\texpectedCreate := []*endpoint.Endpoint{suite.fooV2Cname}\n\texpectedUpdateOld := []*endpoint.Endpoint{}\n\texpectedUpdateNew := []*endpoint.Endpoint{}\n\texpectedDelete := []*endpoint.Endpoint{suite.fooA5, suite.fooAAAA}\n\n\tp := &Plan{\n\t\tPolicies:       []Policy{&SyncPolicy{}},\n\t\tCurrent:        current,\n\t\tDesired:        desired,\n\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME},\n\t\tOwnerID:        suite.fooA5.Labels[endpoint.OwnerLabelKey],\n\t}\n\n\tchanges := p.Calculate().Changes\n\tvalidateEntries(suite.T(), changes.Create, expectedCreate)\n\tvalidateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew)\n\tvalidateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld)\n\tvalidateEntries(suite.T(), changes.Delete, expectedDelete)\n}\n\n// TestExistingOwnerNotMatchingDualStackDesired validates that if there is an existing\n// record for a domain but there is no ownership claim over it and there are desired\n// records no changes are planed. Only domains that have explicit ownership claims should\n// be updated.\nfunc (suite *PlanTestSuite) TestExistingOwnerNotMatchingDualStackDesired() {\n\tsuite.fooA5.Labels = nil\n\tcurrent := []*endpoint.Endpoint{suite.fooA5}\n\tdesired := []*endpoint.Endpoint{suite.fooV2Cname}\n\texpectedCreate := []*endpoint.Endpoint{}\n\texpectedUpdateOld := []*endpoint.Endpoint{}\n\texpectedUpdateNew := []*endpoint.Endpoint{}\n\texpectedDelete := []*endpoint.Endpoint{}\n\n\tp := &Plan{\n\t\tPolicies:       []Policy{&SyncPolicy{}},\n\t\tCurrent:        current,\n\t\tDesired:        desired,\n\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME},\n\t\tOwnerID:        \"pwner\",\n\t}\n\n\tchanges := p.Calculate().Changes\n\tvalidateEntries(suite.T(), changes.Create, expectedCreate)\n\tvalidateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew)\n\tvalidateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld)\n\tvalidateEntries(suite.T(), changes.Delete, expectedDelete)\n}\n\n// TestConflictingCurrentNonConflictingDesired is a bit of a corner case as it would indicate\n// that the provider is not following valid DNS rules or there may be some\n// caching issues. In this case since the desired records are not conflicting\n// the updates will end up with the conflict resolved.\nfunc (suite *PlanTestSuite) TestConflictingCurrentNonConflictingDesired() {\n\tsuite.fooA5.Labels[endpoint.OwnerLabelKey] = suite.fooV1Cname.Labels[endpoint.OwnerLabelKey]\n\tcurrent := []*endpoint.Endpoint{suite.fooV1Cname, suite.fooA5}\n\tdesired := []*endpoint.Endpoint{suite.fooA5}\n\texpectedCreate := []*endpoint.Endpoint{}\n\texpectedUpdateOld := []*endpoint.Endpoint{}\n\texpectedUpdateNew := []*endpoint.Endpoint{}\n\texpectedDelete := []*endpoint.Endpoint{suite.fooV1Cname}\n\n\tp := &Plan{\n\t\tPolicies:       []Policy{&SyncPolicy{}},\n\t\tCurrent:        current,\n\t\tDesired:        desired,\n\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME},\n\t\tOwnerID:        suite.fooV1Cname.Labels[endpoint.OwnerLabelKey],\n\t}\n\n\tchanges := p.Calculate().Changes\n\tvalidateEntries(suite.T(), changes.Create, expectedCreate)\n\tvalidateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew)\n\tvalidateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld)\n\tvalidateEntries(suite.T(), changes.Delete, expectedDelete)\n}\n\n// TestConflictingCurrentNoDesired is a bit of a corner case as it would indicate\n// that the provider is not following valid DNS rules or there may be some\n// caching issues. In this case there are no desired enpoint candidates so plan\n// on deleting the records.\nfunc (suite *PlanTestSuite) TestConflictingCurrentNoDesired() {\n\tsuite.fooA5.Labels[endpoint.OwnerLabelKey] = suite.fooV1Cname.Labels[endpoint.OwnerLabelKey]\n\tcurrent := []*endpoint.Endpoint{suite.fooV1Cname, suite.fooA5}\n\tdesired := []*endpoint.Endpoint{}\n\texpectedCreate := []*endpoint.Endpoint{}\n\texpectedUpdateOld := []*endpoint.Endpoint{}\n\texpectedUpdateNew := []*endpoint.Endpoint{}\n\texpectedDelete := []*endpoint.Endpoint{suite.fooV1Cname, suite.fooA5}\n\n\tp := &Plan{\n\t\tPolicies:       []Policy{&SyncPolicy{}},\n\t\tCurrent:        current,\n\t\tDesired:        desired,\n\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME},\n\t\tOwnerID:        suite.fooV1Cname.Labels[endpoint.OwnerLabelKey],\n\t}\n\n\tchanges := p.Calculate().Changes\n\tvalidateEntries(suite.T(), changes.Create, expectedCreate)\n\tvalidateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew)\n\tvalidateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld)\n\tvalidateEntries(suite.T(), changes.Delete, expectedDelete)\n}\n\n// TestCurrentWithConflictingDesired simulates where the desired records result in conflicting records types.\n// This could be the result of multiple sources generating conflicting records types. In this case the conflict\n// resolver should prefer the A and AAAA record candidate and delete the other records.\nfunc (suite *PlanTestSuite) TestCurrentWithConflictingDesired() {\n\tsuite.fooV1Cname.Labels[endpoint.OwnerLabelKey] = \"nerf\"\n\tcurrent := []*endpoint.Endpoint{suite.fooV1Cname}\n\tdesired := []*endpoint.Endpoint{suite.fooV1Cname, suite.fooA5, suite.fooAAAA}\n\texpectedCreate := []*endpoint.Endpoint{suite.fooA5, suite.fooAAAA}\n\texpectedUpdateOld := []*endpoint.Endpoint{}\n\texpectedUpdateNew := []*endpoint.Endpoint{}\n\texpectedDelete := []*endpoint.Endpoint{suite.fooV1Cname}\n\n\tp := &Plan{\n\t\tPolicies:       []Policy{&SyncPolicy{}},\n\t\tCurrent:        current,\n\t\tDesired:        desired,\n\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME},\n\t\tOwnerID:        suite.fooV1Cname.Labels[endpoint.OwnerLabelKey],\n\t}\n\n\tchanges := p.Calculate().Changes\n\tvalidateEntries(suite.T(), changes.Create, expectedCreate)\n\tvalidateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew)\n\tvalidateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld)\n\tvalidateEntries(suite.T(), changes.Delete, expectedDelete)\n}\n\n// TestNoCurrentWithConflictingDesired simulates where the desired records result in conflicting records types.\n// This could be the result of multiple sources generating conflicting records types. In this case, the\n// conflict resolver should prefer the A and AAAA record and drop the other candidate record types.\nfunc (suite *PlanTestSuite) TestNoCurrentWithConflictingDesired() {\n\tcurrent := []*endpoint.Endpoint{}\n\tdesired := []*endpoint.Endpoint{suite.fooV1Cname, suite.fooA5, suite.fooAAAA}\n\texpectedCreate := []*endpoint.Endpoint{suite.fooA5, suite.fooAAAA}\n\texpectedUpdateOld := []*endpoint.Endpoint{}\n\texpectedUpdateNew := []*endpoint.Endpoint{}\n\texpectedDelete := []*endpoint.Endpoint{}\n\n\tp := &Plan{\n\t\tPolicies:       []Policy{&SyncPolicy{}},\n\t\tCurrent:        current,\n\t\tDesired:        desired,\n\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME},\n\t}\n\n\tchanges := p.Calculate().Changes\n\tvalidateEntries(suite.T(), changes.Create, expectedCreate)\n\tvalidateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew)\n\tvalidateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld)\n\tvalidateEntries(suite.T(), changes.Delete, expectedDelete)\n}\n\nfunc (suite *PlanTestSuite) TestIgnoreTXT() {\n\tcurrent := []*endpoint.Endpoint{suite.fooV2TXT}\n\tdesired := []*endpoint.Endpoint{suite.fooV2Cname}\n\texpectedCreate := []*endpoint.Endpoint{suite.fooV2Cname}\n\texpectedUpdateOld := []*endpoint.Endpoint{}\n\texpectedUpdateNew := []*endpoint.Endpoint{}\n\texpectedDelete := []*endpoint.Endpoint{}\n\n\tp := &Plan{\n\t\tPolicies:       []Policy{&SyncPolicy{}},\n\t\tCurrent:        current,\n\t\tDesired:        desired,\n\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},\n\t}\n\n\tchanges := p.Calculate().Changes\n\tvalidateEntries(suite.T(), changes.Create, expectedCreate)\n\tvalidateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew)\n\tvalidateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld)\n\tvalidateEntries(suite.T(), changes.Delete, expectedDelete)\n}\n\nfunc (suite *PlanTestSuite) TestExcludeTXT() {\n\tcurrent := []*endpoint.Endpoint{suite.fooV2TXT}\n\tdesired := []*endpoint.Endpoint{suite.fooV2Cname}\n\texpectedCreate := []*endpoint.Endpoint{suite.fooV2Cname}\n\texpectedUpdateOld := []*endpoint.Endpoint{}\n\texpectedUpdateNew := []*endpoint.Endpoint{}\n\texpectedDelete := []*endpoint.Endpoint{}\n\n\tp := &Plan{\n\t\tPolicies:       []Policy{&SyncPolicy{}},\n\t\tCurrent:        current,\n\t\tDesired:        desired,\n\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME, endpoint.RecordTypeTXT},\n\t\tExcludeRecords: []string{endpoint.RecordTypeTXT},\n\t}\n\n\tchanges := p.Calculate().Changes\n\tvalidateEntries(suite.T(), changes.Create, expectedCreate)\n\tvalidateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew)\n\tvalidateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld)\n\tvalidateEntries(suite.T(), changes.Delete, expectedDelete)\n}\n\nfunc (suite *PlanTestSuite) TestIgnoreTargetCase() {\n\tcurrent := []*endpoint.Endpoint{suite.fooV2Cname}\n\tdesired := []*endpoint.Endpoint{suite.fooV2CnameUppercase}\n\texpectedCreate := []*endpoint.Endpoint{}\n\texpectedUpdateOld := []*endpoint.Endpoint{}\n\texpectedUpdateNew := []*endpoint.Endpoint{}\n\texpectedDelete := []*endpoint.Endpoint{}\n\n\tp := &Plan{\n\t\tPolicies: []Policy{&SyncPolicy{}},\n\t\tCurrent:  current,\n\t\tDesired:  desired,\n\t}\n\n\tchanges := p.Calculate().Changes\n\tvalidateEntries(suite.T(), changes.Create, expectedCreate)\n\tvalidateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew)\n\tvalidateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld)\n\tvalidateEntries(suite.T(), changes.Delete, expectedDelete)\n}\n\nfunc (suite *PlanTestSuite) TestRemoveEndpoint() {\n\tcurrent := []*endpoint.Endpoint{suite.fooV1Cname, suite.bar192A}\n\tdesired := []*endpoint.Endpoint{suite.fooV1Cname}\n\texpectedCreate := []*endpoint.Endpoint{}\n\texpectedUpdateOld := []*endpoint.Endpoint{}\n\texpectedUpdateNew := []*endpoint.Endpoint{}\n\texpectedDelete := []*endpoint.Endpoint{suite.bar192A}\n\n\tp := &Plan{\n\t\tPolicies:       []Policy{&SyncPolicy{}},\n\t\tCurrent:        current,\n\t\tDesired:        desired,\n\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},\n\t}\n\n\tchanges := p.Calculate().Changes\n\tvalidateEntries(suite.T(), changes.Create, expectedCreate)\n\tvalidateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew)\n\tvalidateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld)\n\tvalidateEntries(suite.T(), changes.Delete, expectedDelete)\n}\n\nfunc (suite *PlanTestSuite) TestRemoveEndpointWithUpsert() {\n\tcurrent := []*endpoint.Endpoint{suite.fooV1Cname, suite.bar192A}\n\tdesired := []*endpoint.Endpoint{suite.fooV1Cname}\n\texpectedCreate := []*endpoint.Endpoint{}\n\texpectedUpdateOld := []*endpoint.Endpoint{}\n\texpectedUpdateNew := []*endpoint.Endpoint{}\n\texpectedDelete := []*endpoint.Endpoint{}\n\n\tp := &Plan{\n\t\tPolicies:       []Policy{&UpsertOnlyPolicy{}},\n\t\tCurrent:        current,\n\t\tDesired:        desired,\n\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},\n\t}\n\n\tchanges := p.Calculate().Changes\n\tvalidateEntries(suite.T(), changes.Create, expectedCreate)\n\tvalidateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew)\n\tvalidateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld)\n\tvalidateEntries(suite.T(), changes.Delete, expectedDelete)\n}\n\nfunc (suite *PlanTestSuite) TestMultipleRecordsSameNameDifferentSetIdentifier() {\n\tcurrent := []*endpoint.Endpoint{suite.multiple1}\n\tdesired := []*endpoint.Endpoint{suite.multiple2, suite.multiple3}\n\texpectedCreate := []*endpoint.Endpoint{suite.multiple3}\n\texpectedUpdateOld := []*endpoint.Endpoint{suite.multiple1}\n\texpectedUpdateNew := []*endpoint.Endpoint{suite.multiple2}\n\texpectedDelete := []*endpoint.Endpoint{}\n\n\tp := &Plan{\n\t\tPolicies:       []Policy{&SyncPolicy{}},\n\t\tCurrent:        current,\n\t\tDesired:        desired,\n\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},\n\t}\n\n\tchanges := p.Calculate().Changes\n\tvalidateEntries(suite.T(), changes.Create, expectedCreate)\n\tvalidateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew)\n\tvalidateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld)\n\tvalidateEntries(suite.T(), changes.Delete, expectedDelete)\n}\n\nfunc (suite *PlanTestSuite) TestSetIdentifierUpdateCreatesAndDeletes() {\n\tcurrent := []*endpoint.Endpoint{suite.multiple2}\n\tdesired := []*endpoint.Endpoint{suite.multiple3}\n\texpectedCreate := []*endpoint.Endpoint{suite.multiple3}\n\texpectedUpdateOld := []*endpoint.Endpoint{}\n\texpectedUpdateNew := []*endpoint.Endpoint{}\n\texpectedDelete := []*endpoint.Endpoint{suite.multiple2}\n\n\tp := &Plan{\n\t\tPolicies:       []Policy{&SyncPolicy{}},\n\t\tCurrent:        current,\n\t\tDesired:        desired,\n\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},\n\t}\n\n\tchanges := p.Calculate().Changes\n\tvalidateEntries(suite.T(), changes.Create, expectedCreate)\n\tvalidateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew)\n\tvalidateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld)\n\tvalidateEntries(suite.T(), changes.Delete, expectedDelete)\n}\n\nfunc (suite *PlanTestSuite) TestDomainFiltersInitial() {\n\tcurrent := []*endpoint.Endpoint{suite.domainFilterExcluded}\n\tdesired := []*endpoint.Endpoint{suite.domainFilterExcluded, suite.domainFilterFiltered1, suite.domainFilterFiltered2, suite.domainFilterFiltered3}\n\texpectedCreate := []*endpoint.Endpoint{suite.domainFilterFiltered1, suite.domainFilterFiltered2, suite.domainFilterFiltered3}\n\texpectedUpdateOld := []*endpoint.Endpoint{}\n\texpectedUpdateNew := []*endpoint.Endpoint{}\n\texpectedDelete := []*endpoint.Endpoint{}\n\n\tdomainFilter := endpoint.NewDomainFilterWithExclusions([]string{\"domain.tld\"}, []string{\"ex.domain.tld\"})\n\tp := &Plan{\n\t\tPolicies:       []Policy{&SyncPolicy{}},\n\t\tCurrent:        current,\n\t\tDesired:        desired,\n\t\tDomainFilter:   endpoint.MatchAllDomainFilters{domainFilter},\n\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},\n\t}\n\n\tchanges := p.Calculate().Changes\n\tvalidateEntries(suite.T(), changes.Create, expectedCreate)\n\tvalidateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew)\n\tvalidateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld)\n\tvalidateEntries(suite.T(), changes.Delete, expectedDelete)\n}\n\nfunc (suite *PlanTestSuite) TestDomainFiltersUpdate() {\n\tcurrent := []*endpoint.Endpoint{suite.domainFilterExcluded, suite.domainFilterFiltered1, suite.domainFilterFiltered2}\n\tdesired := []*endpoint.Endpoint{suite.domainFilterExcluded, suite.domainFilterFiltered1, suite.domainFilterFiltered2, suite.domainFilterFiltered3}\n\texpectedCreate := []*endpoint.Endpoint{suite.domainFilterFiltered3}\n\texpectedUpdateOld := []*endpoint.Endpoint{}\n\texpectedUpdateNew := []*endpoint.Endpoint{}\n\texpectedDelete := []*endpoint.Endpoint{}\n\n\tdomainFilter := endpoint.NewDomainFilterWithExclusions([]string{\"domain.tld\"}, []string{\"ex.domain.tld\"})\n\tp := &Plan{\n\t\tPolicies:       []Policy{&SyncPolicy{}},\n\t\tCurrent:        current,\n\t\tDesired:        desired,\n\t\tDomainFilter:   endpoint.MatchAllDomainFilters{domainFilter},\n\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},\n\t}\n\n\tchanges := p.Calculate().Changes\n\tvalidateEntries(suite.T(), changes.Create, expectedCreate)\n\tvalidateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew)\n\tvalidateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld)\n\tvalidateEntries(suite.T(), changes.Delete, expectedDelete)\n}\n\nfunc (suite *PlanTestSuite) TestAAAARecords() {\n\tcurrent := []*endpoint.Endpoint{}\n\tdesired := []*endpoint.Endpoint{suite.fooAAAA}\n\texpectedCreate := []*endpoint.Endpoint{suite.fooAAAA}\n\texpectNoChanges := []*endpoint.Endpoint{}\n\n\tp := &Plan{\n\t\tPolicies:       []Policy{&SyncPolicy{}},\n\t\tCurrent:        current,\n\t\tDesired:        desired,\n\t\tManagedRecords: []string{endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME},\n\t}\n\n\tchanges := p.Calculate().Changes\n\tvalidateEntries(suite.T(), changes.Create, expectedCreate)\n\tvalidateEntries(suite.T(), changes.Delete, expectNoChanges)\n\tvalidateEntries(suite.T(), changes.UpdateOld, expectNoChanges)\n\tvalidateEntries(suite.T(), changes.UpdateNew, expectNoChanges)\n}\n\nfunc (suite *PlanTestSuite) TestDualStackRecords() {\n\tcurrent := []*endpoint.Endpoint{}\n\tdesired := []*endpoint.Endpoint{suite.dsA, suite.dsAAAA}\n\texpectedCreate := []*endpoint.Endpoint{suite.dsA, suite.dsAAAA}\n\texpectNoChanges := []*endpoint.Endpoint{}\n\n\tp := &Plan{\n\t\tPolicies:       []Policy{&SyncPolicy{}},\n\t\tCurrent:        current,\n\t\tDesired:        desired,\n\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME},\n\t}\n\n\tchanges := p.Calculate().Changes\n\tvalidateEntries(suite.T(), changes.Create, expectedCreate)\n\tvalidateEntries(suite.T(), changes.Delete, expectNoChanges)\n\tvalidateEntries(suite.T(), changes.UpdateOld, expectNoChanges)\n\tvalidateEntries(suite.T(), changes.UpdateNew, expectNoChanges)\n}\n\nfunc (suite *PlanTestSuite) TestDualStackRecordsDelete() {\n\tcurrent := []*endpoint.Endpoint{suite.dsA, suite.dsAAAA}\n\tdesired := []*endpoint.Endpoint{}\n\texpectedDelete := []*endpoint.Endpoint{suite.dsA, suite.dsAAAA}\n\texpectNoChanges := []*endpoint.Endpoint{}\n\n\tp := &Plan{\n\t\tPolicies:       []Policy{&SyncPolicy{}},\n\t\tCurrent:        current,\n\t\tDesired:        desired,\n\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME},\n\t}\n\n\tchanges := p.Calculate().Changes\n\tvalidateEntries(suite.T(), changes.Delete, expectedDelete)\n\tvalidateEntries(suite.T(), changes.Create, expectNoChanges)\n\tvalidateEntries(suite.T(), changes.UpdateOld, expectNoChanges)\n\tvalidateEntries(suite.T(), changes.UpdateNew, expectNoChanges)\n}\n\nfunc (suite *PlanTestSuite) TestDualStackToSingleStack() {\n\tcurrent := []*endpoint.Endpoint{suite.dsA, suite.dsAAAA}\n\tdesired := []*endpoint.Endpoint{suite.dsA}\n\texpectedDelete := []*endpoint.Endpoint{suite.dsAAAA}\n\texpectNoChanges := []*endpoint.Endpoint{}\n\n\tp := &Plan{\n\t\tPolicies:       []Policy{&SyncPolicy{}},\n\t\tCurrent:        current,\n\t\tDesired:        desired,\n\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME},\n\t}\n\n\tchanges := p.Calculate().Changes\n\tvalidateEntries(suite.T(), changes.Delete, expectedDelete)\n\tvalidateEntries(suite.T(), changes.Create, expectNoChanges)\n\tvalidateEntries(suite.T(), changes.UpdateOld, expectNoChanges)\n\tvalidateEntries(suite.T(), changes.UpdateNew, expectNoChanges)\n}\n\nfunc (suite *PlanTestSuite) TestRecordOwnerIdMigration() {\n\tsuite.fooA5.Labels[endpoint.OwnerLabelKey] = \"bar\"\n\tcurrent := []*endpoint.Endpoint{suite.fooA5}\n\tdesired := []*endpoint.Endpoint{suite.fooA5}\n\texpectedCreate := []*endpoint.Endpoint{}\n\texpectedUpdateOld := []*endpoint.Endpoint{suite.fooA5}\n\texpectedUpdateNew := []*endpoint.Endpoint{suite.fooA5}\n\texpectedDelete := []*endpoint.Endpoint{}\n\n\tp := &Plan{\n\t\tPolicies:       []Policy{&SyncPolicy{}},\n\t\tCurrent:        current,\n\t\tDesired:        desired,\n\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME},\n\t\tOwnerID:        suite.fooA5.Labels[endpoint.OwnerLabelKey],\n\t\tOldOwnerID:     \"foo\",\n\t}\n\n\tchanges := p.Calculate().Changes\n\n\tvalidateEntries(suite.T(), changes.Create, expectedCreate)\n\tvalidateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew)\n\tvalidateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld)\n\tvalidateEntries(suite.T(), changes.Delete, expectedDelete)\n}\n\nfunc TestPlan(t *testing.T) {\n\tsuite.Run(t, new(PlanTestSuite))\n}\n\n// validateEntries validates that the list of entries matches expected.\nfunc validateEntries(t *testing.T, entries, expected []*endpoint.Endpoint) {\n\tif !testutils.SameEndpoints(entries, expected) {\n\t\tt.Fatalf(\"expected %q to match %q\", entries, expected)\n\t}\n}\n\nfunc TestShouldUpdateProviderSpecific(tt *testing.T) {\n\tfor _, test := range []struct {\n\t\tname         string\n\t\tcurrent      *endpoint.Endpoint\n\t\tdesired      *endpoint.Endpoint\n\t\tshouldUpdate bool\n\t}{\n\t\t{\n\t\t\tname: \"skip AWS target health\",\n\t\t\tcurrent: &endpoint.Endpoint{\n\t\t\t\tDNSName: \"foo.com\",\n\t\t\t\tProviderSpecific: []endpoint.ProviderSpecificProperty{\n\t\t\t\t\t{Name: \"aws/evaluate-target-health\", Value: \"true\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tdesired: &endpoint.Endpoint{\n\t\t\t\tDNSName: \"bar.com\",\n\t\t\t\tProviderSpecific: []endpoint.ProviderSpecificProperty{\n\t\t\t\t\t{Name: \"aws/evaluate-target-health\", Value: \"true\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tshouldUpdate: false,\n\t\t},\n\t\t{\n\t\t\tname: \"custom property unchanged\",\n\t\t\tcurrent: &endpoint.Endpoint{\n\t\t\t\tProviderSpecific: []endpoint.ProviderSpecificProperty{\n\t\t\t\t\t{Name: \"custom/property\", Value: \"true\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tdesired: &endpoint.Endpoint{\n\t\t\t\tProviderSpecific: []endpoint.ProviderSpecificProperty{\n\t\t\t\t\t{Name: \"custom/property\", Value: \"true\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tshouldUpdate: false,\n\t\t},\n\t\t{\n\t\t\tname: \"custom property value changed\",\n\t\t\tcurrent: &endpoint.Endpoint{\n\t\t\t\tProviderSpecific: []endpoint.ProviderSpecificProperty{\n\t\t\t\t\t{Name: \"custom/property\", Value: \"true\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tdesired: &endpoint.Endpoint{\n\t\t\t\tProviderSpecific: []endpoint.ProviderSpecificProperty{\n\t\t\t\t\t{Name: \"custom/property\", Value: \"false\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tshouldUpdate: true,\n\t\t},\n\t\t{\n\t\t\tname: \"custom property key changed\",\n\t\t\tcurrent: &endpoint.Endpoint{\n\t\t\t\tProviderSpecific: []endpoint.ProviderSpecificProperty{\n\t\t\t\t\t{Name: \"custom/property\", Value: \"true\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tdesired: &endpoint.Endpoint{\n\t\t\t\tProviderSpecific: []endpoint.ProviderSpecificProperty{\n\t\t\t\t\t{Name: \"new/property\", Value: \"true\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tshouldUpdate: true,\n\t\t},\n\t} {\n\t\ttt.Run(test.name, func(t *testing.T) {\n\t\t\tplan := &Plan{\n\t\t\t\tCurrent:        []*endpoint.Endpoint{test.current},\n\t\t\t\tDesired:        []*endpoint.Endpoint{test.desired},\n\t\t\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},\n\t\t\t}\n\t\t\tb := plan.providerSpecificChanged(test.desired, test.current)\n\t\t\tassert.Equal(t, test.shouldUpdate, b)\n\t\t})\n\t}\n}\n\nfunc TestOwnerMismatchLogsDebug(t *testing.T) {\n\tconst wantMsg = \"owner id does not match\"\n\n\t// current A record owned by someone else; desired CNAME owned by us.\n\t// The CNAME has no current record → triggers a create, which activates\n\t// the owner-check block and the debug log.\n\tcurrent := &endpoint.Endpoint{\n\t\tDNSName:    \"foo\",\n\t\tRecordType: endpoint.RecordTypeA,\n\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\tLabels:     map[string]string{endpoint.OwnerLabelKey: \"other\"},\n\t}\n\tdesired := &endpoint.Endpoint{\n\t\tDNSName:    \"foo\",\n\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\tTargets:    endpoint.Targets{\"bar.example.com\"},\n\t\tLabels:     map[string]string{endpoint.OwnerLabelKey: \"pwner\"},\n\t}\n\n\tp := &Plan{\n\t\tPolicies:       []Policy{&SyncPolicy{}},\n\t\tCurrent:        []*endpoint.Endpoint{current},\n\t\tDesired:        []*endpoint.Endpoint{desired},\n\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},\n\t\tOwnerID:        \"pwner\",\n\t}\n\n\thook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t)\n\tp.Calculate()\n\tlogtest.TestHelperLogContainsWithLogLevel(wantMsg, log.DebugLevel, hook, t)\n}\n"
  },
  {
    "path": "plan/policy.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage plan\n\n// Policy allows to apply different rules to a set of changes.\ntype Policy interface {\n\tApply(changes *Changes) *Changes\n}\n\n// Policies is a registry of available policies, keyed by name.\nvar Policies = map[string]Policy{\n\t\"sync\":        &SyncPolicy{},\n\t\"upsert-only\": &UpsertOnlyPolicy{},\n\t\"create-only\": &CreateOnlyPolicy{},\n}\n\n// SyncPolicy allows for full synchronization of DNS records.\ntype SyncPolicy struct{}\n\n// Apply is a pass-through: sync allows all changes without restriction.\nfunc (p *SyncPolicy) Apply(changes *Changes) *Changes {\n\treturn changes\n}\n\n// UpsertOnlyPolicy allows everything but deleting DNS records.\ntype UpsertOnlyPolicy struct{}\n\n// Apply applies the upsert-only policy which strips out any deletions.\nfunc (p *UpsertOnlyPolicy) Apply(changes *Changes) *Changes {\n\treturn &Changes{\n\t\tCreate:    changes.Create,\n\t\tUpdateOld: changes.UpdateOld,\n\t\tUpdateNew: changes.UpdateNew,\n\t}\n}\n\n// CreateOnlyPolicy allows only creating DNS records.\ntype CreateOnlyPolicy struct{}\n\n// Apply applies the create-only policy which strips out updates and deletions.\nfunc (p *CreateOnlyPolicy) Apply(changes *Changes) *Changes {\n\treturn &Changes{\n\t\tCreate: changes.Create,\n\t}\n}\n"
  },
  {
    "path": "plan/policy_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage plan\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\n// TestApply tests that applying a policy results in the correct set of changes.\nfunc TestApply(t *testing.T) {\n\t// empty list of records\n\tempty := []*endpoint.Endpoint{}\n\t// a simple entry\n\tfooV1 := []*endpoint.Endpoint{{DNSName: \"foo\", Targets: endpoint.Targets{\"v1\"}}}\n\t// the same entry but with different target\n\tfooV2 := []*endpoint.Endpoint{{DNSName: \"foo\", Targets: endpoint.Targets{\"v2\"}}}\n\t// another two simple entries\n\tbar := []*endpoint.Endpoint{{DNSName: \"bar\", Targets: endpoint.Targets{\"v1\"}}}\n\tbaz := []*endpoint.Endpoint{{DNSName: \"baz\", Targets: endpoint.Targets{\"v1\"}}}\n\n\tfor _, tc := range []struct {\n\t\tpolicy   Policy\n\t\tchanges  *Changes\n\t\texpected *Changes\n\t}{\n\t\t{\n\t\t\t// SyncPolicy doesn't modify the set of changes.\n\t\t\t&SyncPolicy{},\n\t\t\t&Changes{Create: baz, UpdateOld: fooV1, UpdateNew: fooV2, Delete: bar},\n\t\t\t&Changes{Create: baz, UpdateOld: fooV1, UpdateNew: fooV2, Delete: bar},\n\t\t},\n\t\t{\n\t\t\t// UpsertOnlyPolicy clears the list of deletions.\n\t\t\t&UpsertOnlyPolicy{},\n\t\t\t&Changes{Create: baz, UpdateOld: fooV1, UpdateNew: fooV2, Delete: bar},\n\t\t\t&Changes{Create: baz, UpdateOld: fooV1, UpdateNew: fooV2, Delete: empty},\n\t\t},\n\t\t{\n\t\t\t// CreateOnlyPolicy clears the list of updates and deletions.\n\t\t\t&CreateOnlyPolicy{},\n\t\t\t&Changes{Create: baz, UpdateOld: fooV1, UpdateNew: fooV2, Delete: bar},\n\t\t\t&Changes{Create: baz, UpdateOld: empty, UpdateNew: empty, Delete: empty},\n\t\t},\n\t} {\n\t\t// apply policy\n\t\tchanges := tc.policy.Apply(tc.changes)\n\n\t\t// validate changes after applying policy\n\t\tvalidateEntries(t, changes.Create, tc.expected.Create)\n\t\tvalidateEntries(t, changes.UpdateOld, tc.expected.UpdateOld)\n\t\tvalidateEntries(t, changes.UpdateNew, tc.expected.UpdateNew)\n\t\tvalidateEntries(t, changes.Delete, tc.expected.Delete)\n\t}\n}\n\n// TestPolicies tests that policies are correctly registered.\nfunc TestPolicies(t *testing.T) {\n\tvalidatePolicy(t, Policies[\"sync\"], &SyncPolicy{})\n\tvalidatePolicy(t, Policies[\"upsert-only\"], &UpsertOnlyPolicy{})\n\tvalidatePolicy(t, Policies[\"create-only\"], &CreateOnlyPolicy{})\n}\n\n// validatePolicy validates that a given policy is of the given type.\nfunc validatePolicy(t *testing.T, policy, expected Policy) {\n\tpolicyType := reflect.TypeOf(policy).String()\n\texpectedType := reflect.TypeOf(expected).String()\n\n\tif policyType != expectedType {\n\t\tt.Errorf(\"expected %q to match %q\", policyType, expectedType)\n\t}\n}\n"
  },
  {
    "path": "provider/OWNERS",
    "content": "# See the OWNERS docs at https://go.k8s.io/owners\n\nlabels:\n- provider\n"
  },
  {
    "path": "provider/akamai/akamai.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage akamai\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"strconv\"\n\t\"strings\"\n\n\tdns \"github.com/akamai/AkamaiOPEN-edgegrid-golang/configdns-v2\"\n\t\"github.com/akamai/AkamaiOPEN-edgegrid-golang/edgegrid\"\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\nconst (\n\t// Default Record TTL\n\tdefaultTTL = 600\n\tmaxUint    = ^uint(0)\n\tmaxInt     = int(maxUint >> 1)\n)\n\n// AkamaiDNSService is a proxy interface of the Akamai edgegrid configdns-v2 package that can be stubbed for testing.\ntype AkamaiDNSService interface {\n\tListZones(queryArgs dns.ZoneListQueryArgs) (*dns.ZoneListResponse, error)\n\tGetRecordsets(zone string, queryArgs dns.RecordsetQueryArgs) (*dns.RecordSetResponse, error)\n\tGetRecord(zone string, name string, recordtype string) (*dns.RecordBody, error)\n\tDeleteRecord(record *dns.RecordBody, zone string, recLock bool) error\n\tUpdateRecord(record *dns.RecordBody, zone string, recLock bool) error\n\tCreateRecordsets(recordsets *dns.Recordsets, zone string, recLock bool) error\n}\n\ntype AkamaiConfig struct {\n\tDomainFilter          *endpoint.DomainFilter\n\tZoneIDFilter          provider.ZoneIDFilter\n\tServiceConsumerDomain string\n\tClientToken           string\n\tClientSecret          string\n\tAccessToken           string\n\tEdgercPath            string\n\tEdgercSection         string\n\tMaxBody               int\n\tAccountKey            string\n\tDryRun                bool\n}\n\n// AkamaiProvider implements the DNS provider for Akamai.\ntype AkamaiProvider struct {\n\tprovider.BaseProvider\n\t// Edgedns zones to filter on\n\tdomainFilter *endpoint.DomainFilter\n\t// Contract Ids to filter on\n\tzoneIDFilter provider.ZoneIDFilter\n\t// Edgegrid library configuration\n\tconfig *edgegrid.Config\n\tdryRun bool\n\t// Defines client. Allows for mocking.\n\tclient AkamaiDNSService\n}\n\ntype akamaiZones struct {\n\tZones []akamaiZone `json:\"zones\"`\n}\n\ntype akamaiZone struct {\n\tContractID string `json:\"contractId\"`\n\tZone       string `json:\"zone\"`\n}\n\n// New creates an Akamai provider from the given configuration.\nfunc New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) {\n\treturn newProvider(\n\t\tAkamaiConfig{\n\t\t\tDomainFilter:          domainFilter,\n\t\t\tZoneIDFilter:          provider.NewZoneIDFilter(cfg.ZoneIDFilter),\n\t\t\tServiceConsumerDomain: cfg.AkamaiServiceConsumerDomain,\n\t\t\tClientToken:           cfg.AkamaiClientToken,\n\t\t\tClientSecret:          cfg.AkamaiClientSecret,\n\t\t\tAccessToken:           cfg.AkamaiAccessToken,\n\t\t\tEdgercPath:            cfg.AkamaiEdgercPath,\n\t\t\tEdgercSection:         cfg.AkamaiEdgercSection,\n\t\t\tDryRun:                cfg.DryRun,\n\t\t}, nil)\n}\n\n// newAkamaiProvider initializes a new Akamai DNS based Provider.\nfunc newProvider(akamaiConfig AkamaiConfig, akaService AkamaiDNSService) (provider.Provider, error) {\n\tvar edgeGridConfig edgegrid.Config\n\n\t// environment overrides edgerc file but config needs to be complete\n\tif akamaiConfig.ServiceConsumerDomain == \"\" || akamaiConfig.ClientToken == \"\" || akamaiConfig.ClientSecret == \"\" || akamaiConfig.AccessToken == \"\" {\n\t\t// Kubernetes config incomplete or non existent. Can't mix and match.\n\t\t// Look for Akamai environment or .edgerd creds\n\t\tvar err error\n\t\tedgeGridConfig, err = edgegrid.Init(akamaiConfig.EdgercPath, akamaiConfig.EdgercSection) // use default .edgerc location and section\n\t\tif err != nil {\n\t\t\tlog.Errorf(\"Edgegrid Init Failed\")\n\t\t\treturn &AkamaiProvider{}, err // return an empty provider for backward compatibility\n\t\t}\n\t\tedgeGridConfig.HeaderToSign = append(edgeGridConfig.HeaderToSign, \"X-External-DNS\")\n\t} else {\n\t\t// Use external-dns config\n\t\tedgeGridConfig = edgegrid.Config{\n\t\t\tHost:         akamaiConfig.ServiceConsumerDomain,\n\t\t\tClientToken:  akamaiConfig.ClientToken,\n\t\t\tClientSecret: akamaiConfig.ClientSecret,\n\t\t\tAccessToken:  akamaiConfig.AccessToken,\n\t\t\tMaxBody:      131072, // same default val as used by Edgegrid\n\t\t\tHeaderToSign: []string{\n\t\t\t\t\"X-External-DNS\",\n\t\t\t},\n\t\t\tDebug: false,\n\t\t}\n\t\t// Check for edgegrid overrides\n\t\tif envval, ok := os.LookupEnv(\"AKAMAI_MAX_BODY\"); ok {\n\t\t\tif i, err := strconv.Atoi(envval); err == nil {\n\t\t\t\tedgeGridConfig.MaxBody = i\n\t\t\t\tlog.Debugf(\"Edgegrid maxbody set to %s\", envval)\n\t\t\t}\n\t\t}\n\t\tif envval, ok := os.LookupEnv(\"AKAMAI_ACCOUNT_KEY\"); ok {\n\t\t\tedgeGridConfig.AccountKey = envval\n\t\t\tlog.Debugf(\"Edgegrid applying account key %s\", envval)\n\t\t}\n\t\tif envval, ok := os.LookupEnv(\"AKAMAI_DEBUG\"); ok {\n\t\t\tif dbgval, err := strconv.ParseBool(envval); err == nil {\n\t\t\t\tedgeGridConfig.Debug = dbgval\n\t\t\t\tlog.Debugf(\"Edgegrid debug set to %s\", envval)\n\t\t\t}\n\t\t}\n\t}\n\n\tprovider := &AkamaiProvider{\n\t\tdomainFilter: akamaiConfig.DomainFilter,\n\t\tzoneIDFilter: akamaiConfig.ZoneIDFilter,\n\t\tconfig:       &edgeGridConfig,\n\t\tdryRun:       akamaiConfig.DryRun,\n\t}\n\tif akaService != nil {\n\t\tlog.Debugf(\"Using STUB\")\n\t\tprovider.client = akaService\n\t} else {\n\t\tprovider.client = provider\n\t}\n\n\t// Init library for direct endpoint calls\n\tdns.Init(edgeGridConfig)\n\n\treturn provider, nil\n}\n\nfunc (p AkamaiProvider) ListZones(queryArgs dns.ZoneListQueryArgs) (*dns.ZoneListResponse, error) {\n\treturn dns.ListZones(queryArgs)\n}\n\nfunc (p AkamaiProvider) GetRecordsets(zone string, queryArgs dns.RecordsetQueryArgs) (*dns.RecordSetResponse, error) {\n\treturn dns.GetRecordsets(zone, queryArgs)\n}\n\nfunc (p AkamaiProvider) CreateRecordsets(recordsets *dns.Recordsets, zone string, reclock bool) error {\n\treturn recordsets.Save(zone, reclock)\n}\n\nfunc (p AkamaiProvider) GetRecord(zone string, name string, recordtype string) (*dns.RecordBody, error) {\n\treturn dns.GetRecord(zone, name, recordtype)\n}\n\nfunc (p AkamaiProvider) DeleteRecord(record *dns.RecordBody, zone string, recLock bool) error {\n\treturn record.Delete(zone, recLock)\n}\n\nfunc (p AkamaiProvider) UpdateRecord(record *dns.RecordBody, zone string, recLock bool) error {\n\treturn record.Update(zone, recLock)\n}\n\n// Fetch zones using Edgegrid DNS v2 API\nfunc (p AkamaiProvider) fetchZones() (akamaiZones, error) {\n\tfilteredZones := akamaiZones{Zones: make([]akamaiZone, 0)}\n\tqueryArgs := dns.ZoneListQueryArgs{Types: \"primary\", ShowAll: true}\n\t// filter based on contractIds\n\tif len(p.zoneIDFilter.ZoneIDs) > 0 {\n\t\tqueryArgs.ContractIds = strings.Join(p.zoneIDFilter.ZoneIDs, \",\")\n\t}\n\tresp, err := p.client.ListZones(queryArgs) // retrieve all primary zones filtered by contract ids\n\tif err != nil {\n\t\tlog.Errorf(\"Failed to fetch zones from Akamai\")\n\t\treturn filteredZones, err\n\t}\n\n\tfor _, zone := range resp.Zones {\n\t\tif p.domainFilter.Match(zone.Zone) {\n\t\t\tfilteredZones.Zones = append(filteredZones.Zones, akamaiZone{ContractID: zone.ContractId, Zone: zone.Zone})\n\t\t\tlog.Debugf(\"Fetched zone: '%s' (ZoneID: %s)\", zone.Zone, zone.ContractId)\n\t\t}\n\t}\n\tlenFilteredZones := len(filteredZones.Zones)\n\tif lenFilteredZones == 0 {\n\t\tlog.Warnf(\"No zones could be fetched\")\n\t} else {\n\t\tlog.Debugf(\"Fetched '%d' zones from Akamai\", lenFilteredZones)\n\t}\n\n\treturn filteredZones, nil\n}\n\n// Records returns the list of records in a given zone.\nfunc (p AkamaiProvider) Records(context.Context) ([]*endpoint.Endpoint, error) {\n\tvar endpoints []*endpoint.Endpoint\n\tzones, err := p.fetchZones() // returns a filtered set of zones\n\tif err != nil {\n\t\tlog.Warnf(\"Failed to identify target zones! Error: %s\", err.Error())\n\t\treturn endpoints, err\n\t}\n\tfor _, zone := range zones.Zones {\n\t\trecordsets, err := p.client.GetRecordsets(zone.Zone, dns.RecordsetQueryArgs{ShowAll: true})\n\t\tif err != nil {\n\t\t\tlog.Errorf(\"Recordsets retrieval for zone: '%s' failed! %s\", zone.Zone, err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tif len(recordsets.Recordsets) == 0 {\n\t\t\tlog.Warnf(\"Zone %s contains no recordsets\", zone.Zone)\n\t\t}\n\n\t\tfor _, recordset := range recordsets.Recordsets {\n\t\t\tif !provider.SupportedRecordType(recordset.Type) {\n\t\t\t\tlog.Debugf(\"Skipping endpoint DNSName: '%s' RecordType: '%s'. Record type not supported.\", recordset.Name, recordset.Type)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif !p.domainFilter.Match(recordset.Name) {\n\t\t\t\tlog.Debugf(\"Skipping endpoint. Record name %s doesn't match containing zone %s.\", recordset.Name, zone)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvar temp any = int64(recordset.TTL)\n\t\t\tttl := endpoint.TTL(temp.(int64))\n\t\t\tendpoints = append(endpoints, endpoint.NewEndpointWithTTL(recordset.Name,\n\t\t\t\trecordset.Type,\n\t\t\t\tttl,\n\t\t\t\ttrimTxtRdata(recordset.Rdata, recordset.Type)...))\n\t\t\tlog.Debugf(\"Fetched endpoint DNSName: '%s' RecordType: '%s' Rdata: '%s')\", recordset.Name, recordset.Type, recordset.Rdata)\n\t\t}\n\t}\n\tlenEndpoints := len(endpoints)\n\tif lenEndpoints == 0 {\n\t\tlog.Warnf(\"No endpoints could be fetched\")\n\t} else {\n\t\tlog.Debugf(\"Fetched '%d' endpoints from Akamai\", lenEndpoints)\n\t\tlog.Debugf(\"Endpoints [%v]\", endpoints)\n\t}\n\n\treturn endpoints, nil\n}\n\n// ApplyChanges applies a given set of changes in a given zone.\nfunc (p AkamaiProvider) ApplyChanges(_ context.Context, changes *plan.Changes) error {\n\tzoneNameIDMapper := provider.ZoneIDName{}\n\tzones, err := p.fetchZones()\n\tif err != nil {\n\t\tlog.Errorf(\"Failed to fetch zones from Akamai\")\n\t\treturn err\n\t}\n\n\tfor _, z := range zones.Zones {\n\t\tzoneNameIDMapper[z.Zone] = z.Zone\n\t}\n\tlog.Debugf(\"Processing zones: [%v]\", zoneNameIDMapper)\n\n\t// Create recordsets\n\tlog.Debugf(\"Create Changes requested [%v]\", changes.Create)\n\tif err := p.createRecordsets(zoneNameIDMapper, changes.Create); err != nil {\n\t\treturn err\n\t}\n\t// Delete recordsets\n\tlog.Debugf(\"Delete Changes requested [%v]\", changes.Delete)\n\tif err := p.deleteRecordsets(zoneNameIDMapper, changes.Delete); err != nil {\n\t\treturn err\n\t}\n\t// Update recordsets\n\tlog.Debugf(\"Update Changes requested [%v]\", changes.UpdateNew)\n\tif err := p.updateNewRecordsets(zoneNameIDMapper, changes.UpdateNew); err != nil {\n\t\treturn err\n\t}\n\t// Check that all old endpoints were accounted for\n\trevRecs := changes.Delete\n\trevRecs = append(revRecs, changes.UpdateNew...)\n\tfor _, rec := range changes.UpdateOld {\n\t\tfound := false\n\t\tfor _, r := range revRecs {\n\t\t\tif rec.DNSName == r.DNSName {\n\t\t\t\tfound = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif !found {\n\t\t\tlog.Warnf(\"UpdateOld endpoint '%s' is not accounted for in UpdateNew|Delete endpoint list\", rec.DNSName)\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// Create DNS Recordset\nfunc newAkamaiRecordset(dnsName, recordType string, ttl int, targets []string) dns.Recordset {\n\treturn dns.Recordset{\n\t\tName:  strings.TrimSuffix(dnsName, \".\"),\n\t\tRdata: targets,\n\t\tType:  recordType,\n\t\tTTL:   ttl,\n\t}\n}\n\n// cleanTargets preps recordset rdata if necessary for EdgeDNS\nfunc cleanTargets(rtype string, targets ...string) []string {\n\tlog.Debugf(\"Targets to clean: [%v]\", targets)\n\tswitch rtype {\n\tcase \"CNAME\", \"SRV\":\n\t\tfor idx, target := range targets {\n\t\t\ttargets[idx] = strings.TrimSuffix(target, \".\")\n\t\t}\n\tcase \"TXT\":\n\t\tfor idx, target := range targets {\n\t\t\tlog.Debugf(\"TXT data to clean: [%s]\", target)\n\t\t\t// need to embed text data in quotes. Make sure not piling on\n\t\t\ttarget = strings.Trim(target, \"\\\"\")\n\t\t\t// bug in DNS API with embedded quotes.\n\t\t\tif strings.Contains(target, \"owner\") && strings.Contains(target, \"\\\"\") {\n\t\t\t\ttarget = strings.ReplaceAll(target, \"\\\"\", \"`\")\n\t\t\t}\n\t\t\ttargets[idx] = \"\\\"\" + target + \"\\\"\"\n\t\t}\n\t}\n\tlog.Debugf(\"Clean targets: [%v]\", targets)\n\n\treturn targets\n}\n\n// trimTxtRdata removes surrounding quotes for received TXT rdata\nfunc trimTxtRdata(rdata []string, rtype string) []string {\n\tif rtype == \"TXT\" {\n\t\tfor idx, d := range rdata {\n\t\t\tif strings.Contains(d, \"`\") {\n\t\t\t\trdata[idx] = strings.ReplaceAll(d, \"`\", \"\\\"\")\n\t\t\t}\n\t\t}\n\t}\n\tlog.Debugf(\"Trimmed data: [%v]\", rdata)\n\n\treturn rdata\n}\n\nfunc ttlAsInt(src endpoint.TTL) int {\n\tvar temp any = int64(src)\n\ttemp64 := temp.(int64)\n\tvar ttl = defaultTTL\n\tif temp64 > 0 && temp64 <= int64(maxInt) {\n\t\tttl = int(temp64)\n\t}\n\n\treturn ttl\n}\n\n// Create Endpoint Recordsets\nfunc (p AkamaiProvider) createRecordsets(zoneNameIDMapper provider.ZoneIDName, endpoints []*endpoint.Endpoint) error {\n\tif len(endpoints) == 0 {\n\t\tlog.Info(\"No endpoints to create\")\n\t\treturn nil\n\t}\n\n\tendpointsByZone := edgeChangesByZone(zoneNameIDMapper, endpoints)\n\n\t// create all recordsets by zone\n\tfor zone, endpoints := range endpointsByZone {\n\t\trecordsets := &dns.Recordsets{Recordsets: make([]dns.Recordset, 0)}\n\t\tfor _, endpoint := range endpoints {\n\t\t\tnewrec := newAkamaiRecordset(endpoint.DNSName,\n\t\t\t\tendpoint.RecordType,\n\t\t\t\tttlAsInt(endpoint.RecordTTL),\n\t\t\t\tcleanTargets(endpoint.RecordType, endpoint.Targets...))\n\t\t\tlogfields := log.Fields{\n\t\t\t\t\"record\": newrec.Name,\n\t\t\t\t\"type\":   newrec.Type,\n\t\t\t\t\"ttl\":    newrec.TTL,\n\t\t\t\t\"target\": fmt.Sprintf(\"%v\", newrec.Rdata),\n\t\t\t\t\"zone\":   zone,\n\t\t\t}\n\t\t\tlog.WithFields(logfields).Info(\"Creating recordsets\")\n\t\t\trecordsets.Recordsets = append(recordsets.Recordsets, newrec)\n\t\t}\n\n\t\tif p.dryRun {\n\t\t\tcontinue\n\t\t}\n\t\t// Create recordsets all at once\n\t\terr := p.client.CreateRecordsets(recordsets, zone, true)\n\t\tif err != nil {\n\t\t\tlog.Errorf(\"Failed to create endpoints for DNS zone %s. Error: %s\", zone, err.Error())\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (p AkamaiProvider) deleteRecordsets(zoneNameIDMapper provider.ZoneIDName, endpoints []*endpoint.Endpoint) error {\n\tfor _, endpoint := range endpoints {\n\t\tzoneName, _ := zoneNameIDMapper.FindZone(endpoint.DNSName)\n\t\tif zoneName == \"\" {\n\t\t\tlog.Debugf(\"Skipping Akamai Edge DNS endpoint deletion: '%s' type: '%s', it does not match against Domain filters\", endpoint.DNSName, endpoint.RecordType)\n\t\t\tcontinue\n\t\t}\n\t\tlog.Infof(\"Akamai Edge DNS recordset deletion- Zone: '%s', DNSName: '%s', RecordType: '%s', Targets: '%+v'\", zoneName, endpoint.DNSName, endpoint.RecordType, endpoint.Targets)\n\n\t\tif p.dryRun {\n\t\t\tcontinue\n\t\t}\n\n\t\trecName := strings.TrimSuffix(endpoint.DNSName, \".\")\n\t\trec, err := p.client.GetRecord(zoneName, recName, endpoint.RecordType)\n\t\tif err != nil {\n\t\t\trecordError := &dns.RecordError{}\n\t\t\tif errors.As(err, &recordError) {\n\t\t\t\treturn fmt.Errorf(\"endpoint deletion. record validation failed. error: %w\", err)\n\t\t\t}\n\t\t\tlog.Infof(\"Endpoint deletion. Record doesn't exist. Name: %s, Type: %s\", recName, endpoint.RecordType)\n\t\t\tcontinue\n\t\t}\n\t\tif err := p.client.DeleteRecord(rec, zoneName, true); err != nil {\n\t\t\tlog.Errorf(\"edge dns recordset deletion failed. error: %s\", err.Error())\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// Update endpoint recordsets\nfunc (p AkamaiProvider) updateNewRecordsets(zoneNameIDMapper provider.ZoneIDName, endpoints []*endpoint.Endpoint) error {\n\tfor _, endpoint := range endpoints {\n\t\tzoneName, _ := zoneNameIDMapper.FindZone(endpoint.DNSName)\n\t\tif zoneName == \"\" {\n\t\t\tlog.Debugf(\"Skipping Akamai Edge DNS endpoint update: '%s' type: '%s', it does not match against Domain filters\", endpoint.DNSName, endpoint.RecordType)\n\t\t\tcontinue\n\t\t}\n\t\tlog.Infof(\"Akamai Edge DNS recordset update - Zone: '%s', DNSName: '%s', RecordType: '%s', Targets: '%+v'\", zoneName, endpoint.DNSName, endpoint.RecordType, endpoint.Targets)\n\n\t\tif p.dryRun {\n\t\t\tcontinue\n\t\t}\n\n\t\trecName := strings.TrimSuffix(endpoint.DNSName, \".\")\n\t\trec, err := p.client.GetRecord(zoneName, recName, endpoint.RecordType)\n\t\tif err != nil {\n\t\t\tlog.Errorf(\"Endpoint update. Record validation failed. Error: %s\", err.Error())\n\t\t\treturn err\n\t\t}\n\t\trec.TTL = ttlAsInt(endpoint.RecordTTL)\n\t\trec.Target = cleanTargets(endpoint.RecordType, endpoint.Targets...)\n\t\tif err := p.client.UpdateRecord(rec, zoneName, true); err != nil {\n\t\t\tlog.Errorf(\"Akamai Edge DNS recordset update failed. Error: %s\", err.Error())\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// edgeChangesByZone separates a multi-zone change into a single change per zone.\nfunc edgeChangesByZone(zoneMap provider.ZoneIDName, endpoints []*endpoint.Endpoint) map[string][]*endpoint.Endpoint {\n\tcreatesByZone := make(map[string][]*endpoint.Endpoint, len(zoneMap))\n\tfor _, z := range zoneMap {\n\t\tcreatesByZone[z] = make([]*endpoint.Endpoint, 0)\n\t}\n\tfor _, ep := range endpoints {\n\t\tzone, _ := zoneMap.FindZone(ep.DNSName)\n\t\tif zone != \"\" {\n\t\t\tcreatesByZone[zone] = append(createsByZone[zone], ep)\n\t\t\tcontinue\n\t\t}\n\t\tlog.Debugf(\"Skipping Akamai Edge DNS creation of endpoint: '%s' type: '%s', it does not match against Domain filters\", ep.DNSName, ep.RecordType)\n\t}\n\n\treturn createsByZone\n}\n"
  },
  {
    "path": "provider/akamai/akamai_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage akamai\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/require\"\n\n\tdns \"github.com/akamai/AkamaiOPEN-edgegrid-golang/configdns-v2\"\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\ntype edgednsStubData struct {\n\tobjType string // zone, record, recordsets\n\toutput  []any\n}\n\ntype edgednsStub struct {\n\tstubData map[string]edgednsStubData\n}\n\nfunc newStub() *edgednsStub {\n\treturn &edgednsStub{\n\t\tstubData: make(map[string]edgednsStubData),\n\t}\n}\n\nfunc createAkamaiStubProvider(stub *edgednsStub, domfilter *endpoint.DomainFilter, idfilter provider.ZoneIDFilter) (*AkamaiProvider, error) {\n\takamaiConfig := AkamaiConfig{\n\t\tDomainFilter:          domfilter,\n\t\tZoneIDFilter:          idfilter,\n\t\tServiceConsumerDomain: \"testzone.com\",\n\t\tClientToken:           \"test_token\",\n\t\tClientSecret:          \"test_client_secret\",\n\t\tAccessToken:           \"test_access_token\",\n\t}\n\n\tprov, err := newProvider(akamaiConfig, stub)\n\taprov := prov.(*AkamaiProvider)\n\treturn aprov, err\n}\n\nfunc (r *edgednsStub) createStubDataEntry(objtype string) {\n\tlog.Debugf(\"Creating stub data entry\")\n\tif _, exists := r.stubData[objtype]; !exists {\n\t\tr.stubData[objtype] = edgednsStubData{objType: objtype}\n\t}\n\n\treturn\n}\n\nfunc (r *edgednsStub) setOutput(objtype string, output []any) {\n\tlog.Debugf(\"Setting output to %v\", output)\n\tr.createStubDataEntry(objtype)\n\tstubdata := r.stubData[objtype]\n\tstubdata.output = output\n\tr.stubData[objtype] = stubdata\n\n\treturn\n}\n\nfunc (r *edgednsStub) ListZones(_ dns.ZoneListQueryArgs) (*dns.ZoneListResponse, error) {\n\tlog.Debugf(\"Entering ListZones\")\n\t// Ignore Metadata`\n\tresp := &dns.ZoneListResponse{}\n\tzones := make([]*dns.ZoneResponse, 0)\n\tfor _, zname := range r.stubData[\"zone\"].output {\n\t\tlog.Debugf(\"Processing output: %v\", zname)\n\t\tzn := &dns.ZoneResponse{Zone: zname.(string), ContractId: \"contract\"}\n\t\tlog.Debugf(\"Created Zone Object: %v\", zn)\n\t\tzones = append(zones, zn)\n\t}\n\tresp.Zones = zones\n\treturn resp, nil\n}\n\nfunc (r *edgednsStub) GetRecordsets(_ string, _ dns.RecordsetQueryArgs) (*dns.RecordSetResponse, error) {\n\tlog.Debugf(\"Entering GetRecordsets\")\n\t// Ignore Metadata`\n\tresp := &dns.RecordSetResponse{}\n\tsets := make([]dns.Recordset, 0)\n\tfor _, rec := range r.stubData[\"recordset\"].output {\n\t\trset := rec.(dns.Recordset)\n\t\tsets = append(sets, rset)\n\t}\n\tresp.Recordsets = sets\n\n\treturn resp, nil\n}\n\nfunc (r *edgednsStub) CreateRecordsets(_ *dns.Recordsets, _ string, _ bool) error {\n\treturn nil\n}\n\nfunc (r *edgednsStub) GetRecord(_ string, _ string, _ string) (*dns.RecordBody, error) {\n\tresp := &dns.RecordBody{}\n\n\treturn resp, nil\n}\n\nfunc (r *edgednsStub) DeleteRecord(_ *dns.RecordBody, _ string, _ bool) error {\n\treturn nil\n}\n\nfunc (r *edgednsStub) UpdateRecord(_ *dns.RecordBody, _ string, _ bool) error {\n\treturn nil\n}\n\n// Test FetchZones\nfunc TestFetchZonesZoneIDFilter(t *testing.T) {\n\tstub := newStub()\n\tdomfilter := &endpoint.DomainFilter{}\n\tidfilter := provider.NewZoneIDFilter([]string{\"Test\"})\n\tc, err := createAkamaiStubProvider(stub, domfilter, idfilter)\n\tassert.NoError(t, err)\n\tstub.setOutput(\"zone\", []any{\"test1.testzone.com\", \"test2.testzone.com\"})\n\n\tx, _ := c.fetchZones()\n\ty, err := json.Marshal(x)\n\trequire.NoError(t, err)\n\tif assert.NotNil(t, y) {\n\t\tassert.JSONEq(t, \"{\\\"zones\\\":[{\\\"contractId\\\":\\\"contract\\\",\\\"zone\\\":\\\"test1.testzone.com\\\"},{\\\"contractId\\\":\\\"contract\\\",\\\"zone\\\":\\\"test2.testzone.com\\\"}]}\", string(y))\n\t}\n}\n\nfunc TestFetchZonesEmpty(t *testing.T) {\n\tstub := newStub()\n\tdomfilter := endpoint.NewDomainFilter([]string{\"Nonexistent\"})\n\tidfilter := provider.NewZoneIDFilter([]string{\"Nonexistent\"})\n\tc, err := createAkamaiStubProvider(stub, domfilter, idfilter)\n\trequire.NoError(t, err)\n\tstub.setOutput(\"zone\", []any{})\n\n\tx, _ := c.fetchZones()\n\ty, err := json.Marshal(x)\n\trequire.NoError(t, err)\n\tif assert.NotNil(t, y) {\n\t\tassert.JSONEq(t, \"{\\\"zones\\\":[]}\", string(y))\n\t}\n}\n\n// TestAkamaiRecords tests record endpoint\nfunc TestAkamaiRecords(t *testing.T) {\n\tstub := newStub()\n\tdomfilter := &endpoint.DomainFilter{}\n\tidfilter := provider.ZoneIDFilter{}\n\tc, err := createAkamaiStubProvider(stub, domfilter, idfilter)\n\trequire.NoError(t, err)\n\tstub.setOutput(\"zone\", []any{\"test1.testzone.com\"})\n\trecordsets := make([]any, 0)\n\trecordsets = append(recordsets, dns.Recordset{\n\t\tName:  \"www.example.com\",\n\t\tType:  endpoint.RecordTypeA,\n\t\tRdata: []string{\"10.0.0.2\", \"10.0.0.3\"},\n\t})\n\trecordsets = append(recordsets, dns.Recordset{\n\t\tName:  \"www.example.com\",\n\t\tType:  endpoint.RecordTypeTXT,\n\t\tRdata: []string{\"heritage=external-dns,external-dns/owner=default\"},\n\t})\n\trecordsets = append(recordsets, dns.Recordset{\n\t\tName:  \"www.exclude.me\",\n\t\tType:  endpoint.RecordTypeA,\n\t\tRdata: []string{\"192.168.0.1\", \"192.168.0.2\"},\n\t})\n\tstub.setOutput(\"recordset\", recordsets)\n\tendpoints := make([]*endpoint.Endpoint, 0)\n\tendpoints = append(endpoints, endpoint.NewEndpoint(\"www.example.com\", endpoint.RecordTypeA, \"10.0.0.2\", \"10.0.0.3\"))\n\tendpoints = append(endpoints, endpoint.NewEndpoint(\"www.example.com\", endpoint.RecordTypeTXT, \"heritage=external-dns,external-dns/owner=default\"))\n\tendpoints = append(endpoints, endpoint.NewEndpoint(\"www.exclude.me\", endpoint.RecordTypeA, \"192.168.0.1\", \"192.168.0.2\"))\n\n\tx, _ := c.Records(t.Context())\n\tif assert.NotNil(t, x) {\n\t\tassert.Equal(t, endpoints, x)\n\t}\n}\n\nfunc TestAkamaiRecordsEmpty(t *testing.T) {\n\tstub := newStub()\n\tdomfilter := &endpoint.DomainFilter{}\n\tidfilter := provider.NewZoneIDFilter([]string{\"Nonexistent\"})\n\tc, err := createAkamaiStubProvider(stub, domfilter, idfilter)\n\trequire.NoError(t, err)\n\tstub.setOutput(\"zone\", []any{\"test1.testzone.com\"})\n\trecordsets := make([]any, 0)\n\tstub.setOutput(\"recordset\", recordsets)\n\n\tx, _ := c.Records(t.Context())\n\tassert.Nil(t, x)\n}\n\nfunc TestAkamaiRecordsFilters(t *testing.T) {\n\tstub := newStub()\n\tdomfilter := endpoint.NewDomainFilter([]string{\"www.exclude.me\"})\n\tidfilter := provider.ZoneIDFilter{}\n\tc, err := createAkamaiStubProvider(stub, domfilter, idfilter)\n\tassert.NoError(t, err)\n\tstub.setOutput(\"zone\", []any{\"www.exclude.me\"})\n\trecordsets := make([]any, 0)\n\trecordsets = append(recordsets, dns.Recordset{\n\t\tName:  \"www.example.com\",\n\t\tType:  endpoint.RecordTypeA,\n\t\tRdata: []string{\"10.0.0.2\", \"10.0.0.3\"},\n\t})\n\trecordsets = append(recordsets, dns.Recordset{\n\t\tName:  \"www.exclude.me\",\n\t\tType:  endpoint.RecordTypeA,\n\t\tRdata: []string{\"192.168.0.1\", \"192.168.0.2\"},\n\t})\n\tstub.setOutput(\"recordset\", recordsets)\n\tendpoints := make([]*endpoint.Endpoint, 0)\n\tendpoints = append(endpoints, endpoint.NewEndpoint(\"www.exclude.me\", endpoint.RecordTypeA, \"192.168.0.1\", \"192.168.0.2\"))\n\n\tx, _ := c.Records(t.Context())\n\tif assert.NotNil(t, x) {\n\t\tassert.Equal(t, endpoints, x)\n\t}\n}\n\n// TestCreateRecords tests create function\n// (p AkamaiProvider) createRecordsets(zoneNameIDMapper provider.ZoneIDName, endpoints []*endpoint.Endpoint) error\nfunc TestCreateRecords(t *testing.T) {\n\tstub := newStub()\n\tdomfilter := &endpoint.DomainFilter{}\n\tidfilter := provider.ZoneIDFilter{}\n\tc, err := createAkamaiStubProvider(stub, domfilter, idfilter)\n\tassert.NoError(t, err)\n\n\tzoneNameIDMapper := provider.ZoneIDName{\"example.com\": \"example.com\"}\n\tendpoints := make([]*endpoint.Endpoint, 0)\n\tendpoints = append(endpoints, endpoint.NewEndpoint(\"www.example.com\", endpoint.RecordTypeA, \"10.0.0.2\", \"10.0.0.3\"))\n\tendpoints = append(endpoints, endpoint.NewEndpoint(\"www.example.com\", endpoint.RecordTypeTXT, \"heritage=external-dns,external-dns/owner=default\"))\n\n\terr = c.createRecordsets(zoneNameIDMapper, endpoints)\n\tassert.NoError(t, err)\n}\n\nfunc TestCreateRecordsDomainFilter(t *testing.T) {\n\tstub := newStub()\n\tdomfilter := &endpoint.DomainFilter{}\n\tidfilter := provider.ZoneIDFilter{}\n\tc, err := createAkamaiStubProvider(stub, domfilter, idfilter)\n\tassert.NoError(t, err)\n\n\tzoneNameIDMapper := provider.ZoneIDName{\"example.com\": \"example.com\"}\n\texclude := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"www.example.com\", endpoint.RecordTypeA, \"10.0.0.2\", \"10.0.0.3\"),\n\t\tendpoint.NewEndpoint(\"www.example.com\", endpoint.RecordTypeTXT, \"heritage=external-dns,external-dns/owner=default\"),\n\t\tendpoint.NewEndpoint(\"www.exclude.me\", endpoint.RecordTypeA, \"10.0.0.2\", \"10.0.0.3\"),\n\t}\n\n\terr = c.createRecordsets(zoneNameIDMapper, exclude)\n\tassert.NoError(t, err)\n}\n\n// TestDeleteRecords validate delete\nfunc TestDeleteRecords(t *testing.T) {\n\tstub := newStub()\n\tdomfilter := &endpoint.DomainFilter{}\n\tidfilter := provider.ZoneIDFilter{}\n\tc, err := createAkamaiStubProvider(stub, domfilter, idfilter)\n\tassert.NoError(t, err)\n\n\tzoneNameIDMapper := provider.ZoneIDName{\"example.com\": \"example.com\"}\n\tendpoints := make([]*endpoint.Endpoint, 0)\n\tendpoints = append(endpoints, endpoint.NewEndpoint(\"www.example.com\", endpoint.RecordTypeA, \"10.0.0.2\", \"10.0.0.3\"))\n\tendpoints = append(endpoints, endpoint.NewEndpoint(\"www.example.com\", endpoint.RecordTypeTXT, \"heritage=external-dns,external-dns/owner=default\"))\n\n\terr = c.deleteRecordsets(zoneNameIDMapper, endpoints)\n\tassert.NoError(t, err)\n}\n\nfunc TestDeleteRecordsDomainFilter(t *testing.T) {\n\tstub := newStub()\n\tdomfilter := endpoint.NewDomainFilter([]string{\"example.com\"})\n\tidfilter := provider.ZoneIDFilter{}\n\tc, err := createAkamaiStubProvider(stub, domfilter, idfilter)\n\trequire.NoError(t, err)\n\n\tzoneNameIDMapper := provider.ZoneIDName{\"example.com\": \"example.com\"}\n\texclude := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"www.example.com\", endpoint.RecordTypeA, \"10.0.0.2\", \"10.0.0.3\"),\n\t\tendpoint.NewEndpoint(\"www.example.com\", endpoint.RecordTypeTXT, \"heritage=external-dns,external-dns/owner=default\"),\n\t\tendpoint.NewEndpoint(\"www.exclude.me\", endpoint.RecordTypeA, \"10.0.0.2\", \"10.0.0.3\"),\n\t}\n\n\terr = c.deleteRecordsets(zoneNameIDMapper, exclude)\n\tassert.NoError(t, err)\n}\n\n// Test record update func\nfunc TestUpdateRecords(t *testing.T) {\n\tstub := newStub()\n\tdomfilter := &endpoint.DomainFilter{}\n\tidfilter := provider.ZoneIDFilter{}\n\tc, err := createAkamaiStubProvider(stub, domfilter, idfilter)\n\trequire.NoError(t, err)\n\n\tzoneNameIDMapper := provider.ZoneIDName{\"example.com\": \"example.com\"}\n\tendpoints := make([]*endpoint.Endpoint, 0)\n\tendpoints = append(endpoints, endpoint.NewEndpoint(\"www.example.com\", endpoint.RecordTypeA, \"10.0.0.2\", \"10.0.0.3\"))\n\tendpoints = append(endpoints, endpoint.NewEndpoint(\"www.example.com\", endpoint.RecordTypeTXT, \"heritage=external-dns,external-dns/owner=default\"))\n\n\terr = c.updateNewRecordsets(zoneNameIDMapper, endpoints)\n\trequire.NoError(t, err)\n}\n\nfunc TestUpdateRecordsDomainFilter(t *testing.T) {\n\tstub := newStub()\n\tdomfilter := endpoint.NewDomainFilter([]string{\"example.com\"})\n\tidfilter := provider.ZoneIDFilter{}\n\tc, err := createAkamaiStubProvider(stub, domfilter, idfilter)\n\trequire.NoError(t, err)\n\n\tzoneNameIDMapper := provider.ZoneIDName{\"example.com\": \"example.com\"}\n\texclude := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"www.example.com\", endpoint.RecordTypeA, \"10.0.0.2\", \"10.0.0.3\"),\n\t\tendpoint.NewEndpoint(\"www.example.com\", endpoint.RecordTypeTXT, \"heritage=external-dns,external-dns/owner=default\"),\n\t\tendpoint.NewEndpoint(\"www.exclude.me\", endpoint.RecordTypeA, \"10.0.0.2\", \"10.0.0.3\"),\n\t}\n\n\terr = c.updateNewRecordsets(zoneNameIDMapper, exclude)\n\trequire.NoError(t, err)\n}\n\nfunc TestAkamaiApplyChanges(t *testing.T) {\n\tstub := newStub()\n\tdomfilter := endpoint.NewDomainFilter([]string{\"example.com\"})\n\tidfilter := provider.ZoneIDFilter{}\n\tc, err := createAkamaiStubProvider(stub, domfilter, idfilter)\n\tassert.NoError(t, err)\n\n\tstub.setOutput(\"zone\", []any{\"example.com\"})\n\tchanges := &plan.Changes{}\n\tchanges.Create = []*endpoint.Endpoint{\n\t\t{DNSName: \"www.example.com\", RecordType: \"A\", Targets: endpoint.Targets{\"target\"}, RecordTTL: 300},\n\t\t{DNSName: \"test.example.com\", RecordType: \"A\", Targets: endpoint.Targets{\"target\"}, RecordTTL: 300},\n\t\t{DNSName: \"test.this.example.com\", RecordType: \"A\", Targets: endpoint.Targets{\"127.0.0.1\"}, RecordTTL: 300},\n\t\t{DNSName: \"www.example.com\", RecordType: \"TXT\", Targets: endpoint.Targets{\"heritage=external-dns,external-dns/owner=default\"}, RecordTTL: 300},\n\t\t{DNSName: \"test.example.com\", RecordType: \"TXT\", Targets: endpoint.Targets{\"heritage=external-dns,external-dns/owner=default\"}, RecordTTL: 300},\n\t\t{DNSName: \"test.this.example.com\", RecordType: \"TXT\", Targets: endpoint.Targets{\"heritage=external-dns,external-dns/owner=default\"}, RecordTTL: 300},\n\t\t{DNSName: \"another.example.com\", RecordType: \"A\", Targets: endpoint.Targets{\"target\"}},\n\t}\n\tchanges.Delete = []*endpoint.Endpoint{{DNSName: \"delete.example.com\", RecordType: \"A\", Targets: endpoint.Targets{\"target\"}, RecordTTL: 300}}\n\tchanges.UpdateOld = []*endpoint.Endpoint{{DNSName: \"old.example.com\", RecordType: \"A\", Targets: endpoint.Targets{\"target-old\"}, RecordTTL: 300}}\n\tchanges.UpdateNew = []*endpoint.Endpoint{{DNSName: \"update.example.com\", Targets: endpoint.Targets{\"target-new\"}, RecordType: \"CNAME\", RecordTTL: 300}}\n\tapply := c.ApplyChanges(t.Context(), changes)\n\tassert.NoError(t, apply)\n}\n"
  },
  {
    "path": "provider/alibabacloud/alibaba_cloud.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage alibabacloud\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"slices\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/aliyun/alibaba-cloud-sdk-go/sdk/requests\"\n\t\"github.com/aliyun/alibaba-cloud-sdk-go/services/alidns\"\n\t\"github.com/aliyun/alibaba-cloud-sdk-go/services/pvtz\"\n\t\"github.com/denverdino/aliyungo/metadata\"\n\t\"github.com/goccy/go-yaml\"\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\nconst (\n\tdefaultTTL                              = 600\n\tdefaultAlibabaCloudPrivateZoneRecordTTL = 60\n\tdefaultAlibabaCloudPageSize             = 50\n\tnullHostAlibabaCloud                    = \"@\"\n\tpVTZDoamin                              = \"pvtz.aliyuncs.com\"\n\tdefaultAlibabaCloudRequestScheme        = \"https\"\n)\n\n// AlibabaCloudDNSAPI is a minimal implementation of DNS API that we actually use, used primarily for unit testing.\n// See https://help.aliyun.com/document_detail/29739.html for descriptions of all of its methods.\ntype AlibabaCloudDNSAPI interface {\n\tAddDomainRecord(request *alidns.AddDomainRecordRequest) (*alidns.AddDomainRecordResponse, error)\n\tDeleteDomainRecord(request *alidns.DeleteDomainRecordRequest) (*alidns.DeleteDomainRecordResponse, error)\n\tUpdateDomainRecord(request *alidns.UpdateDomainRecordRequest) (*alidns.UpdateDomainRecordResponse, error)\n\tDescribeDomainRecords(request *alidns.DescribeDomainRecordsRequest) (*alidns.DescribeDomainRecordsResponse, error)\n\tDescribeDomains(request *alidns.DescribeDomainsRequest) (*alidns.DescribeDomainsResponse, error)\n}\n\n// AlibabaCloudPrivateZoneAPI is a minimal implementation of Private Zone API that we actually use, used primarily for unit testing.\n// See https://help.aliyun.com/document_detail/66234.html for descriptions of all of its methods.\ntype AlibabaCloudPrivateZoneAPI interface {\n\tAddZoneRecord(request *pvtz.AddZoneRecordRequest) (*pvtz.AddZoneRecordResponse, error)\n\tDeleteZoneRecord(request *pvtz.DeleteZoneRecordRequest) (*pvtz.DeleteZoneRecordResponse, error)\n\tUpdateZoneRecord(request *pvtz.UpdateZoneRecordRequest) (*pvtz.UpdateZoneRecordResponse, error)\n\tDescribeZoneRecords(request *pvtz.DescribeZoneRecordsRequest) (*pvtz.DescribeZoneRecordsResponse, error)\n\tDescribeZones(request *pvtz.DescribeZonesRequest) (*pvtz.DescribeZonesResponse, error)\n\tDescribeZoneInfo(request *pvtz.DescribeZoneInfoRequest) (*pvtz.DescribeZoneInfoResponse, error)\n}\n\n// AlibabaCloudProvider implements the DNS provider for Alibaba Cloud.\ntype AlibabaCloudProvider struct {\n\tprovider.BaseProvider\n\tdomainFilter         *endpoint.DomainFilter\n\tzoneIDFilter         provider.ZoneIDFilter // Private Zone only\n\tMaxChangeCount       int\n\tEvaluateTargetHealth bool\n\tAssumeRole           string\n\tvpcID                string // Private Zone only\n\tdryRun               bool\n\tdnsClient            AlibabaCloudDNSAPI\n\tpvtzClient           AlibabaCloudPrivateZoneAPI\n\tprivateZone          bool\n\tclientLock           sync.RWMutex\n\tnextExpire           time.Time\n}\n\ntype alibabaCloudConfig struct {\n\tRegionID        string    `json:\"regionId\"        yaml:\"regionId\"`\n\tAccessKeyID     string    `json:\"accessKeyId\"     yaml:\"accessKeyId\"`\n\tAccessKeySecret string    `json:\"accessKeySecret\" yaml:\"accessKeySecret\"`\n\tVPCID           string    `json:\"vpcId\"           yaml:\"vpcId\"`\n\tRoleName        string    `json:\"-\"               yaml:\"-\"` // For ECS RAM role only\n\tStsToken        string    `json:\"-\"               yaml:\"-\"`\n\tExpireTime      time.Time `json:\"-\"               yaml:\"-\"`\n}\n\n// New creates an Alibaba Cloud provider from the given configuration.\nfunc New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) {\n\treturn newProvider(cfg.AlibabaCloudConfigFile, domainFilter, provider.NewZoneIDFilter(cfg.ZoneIDFilter), cfg.AlibabaCloudZoneType, cfg.DryRun)\n}\n\n// newAlibabaCloudProvider creates a new Alibaba Cloud provider.\n//\n// Returns the provider or an error if a provider could not be created.\nfunc newProvider(configFile string, domainFilter *endpoint.DomainFilter, zoneIDFileter provider.ZoneIDFilter, zoneType string, dryRun bool) (*AlibabaCloudProvider, error) {\n\tcfg := alibabaCloudConfig{}\n\tif configFile != \"\" {\n\t\tcontents, err := os.ReadFile(configFile)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to read Alibaba Cloud config file '%s': %w\", configFile, err)\n\t\t}\n\t\terr = yaml.Unmarshal(contents, &cfg)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to parse Alibaba Cloud config file '%s': %w\", configFile, err)\n\t\t}\n\t} else {\n\t\tvar tmpError error\n\t\tcfg, tmpError = getCloudConfigFromStsToken()\n\t\tif tmpError != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to getCloudConfigFromStsToken: %w\", tmpError)\n\t\t}\n\t}\n\n\t// Public DNS service\n\tvar dnsClient AlibabaCloudDNSAPI\n\tvar err error\n\n\tif cfg.RoleName == \"\" {\n\t\tdnsClient, err = alidns.NewClientWithAccessKey(\n\t\t\tcfg.RegionID,\n\t\t\tcfg.AccessKeyID,\n\t\t\tcfg.AccessKeySecret,\n\t\t)\n\t} else {\n\t\tdnsClient, err = alidns.NewClientWithStsToken(\n\t\t\tcfg.RegionID,\n\t\t\tcfg.AccessKeyID,\n\t\t\tcfg.AccessKeySecret,\n\t\t\tcfg.StsToken,\n\t\t)\n\t}\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create Alibaba Cloud DNS client: %w\", err)\n\t}\n\n\t// Private DNS service\n\tvar pvtzClient AlibabaCloudPrivateZoneAPI\n\tif cfg.RoleName == \"\" {\n\t\tpvtzClient, err = pvtz.NewClientWithAccessKey(\n\t\t\t\"cn-hangzhou\", // The Private Zone location is fixed\n\t\t\tcfg.AccessKeyID,\n\t\t\tcfg.AccessKeySecret,\n\t\t)\n\t} else {\n\t\tpvtzClient, err = pvtz.NewClientWithStsToken(\n\t\t\tcfg.RegionID,\n\t\t\tcfg.AccessKeyID,\n\t\t\tcfg.AccessKeySecret,\n\t\t\tcfg.StsToken,\n\t\t)\n\t}\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tprovider := &AlibabaCloudProvider{\n\t\tdomainFilter: domainFilter,\n\t\tzoneIDFilter: zoneIDFileter,\n\t\tvpcID:        cfg.VPCID,\n\t\tdryRun:       dryRun,\n\t\tdnsClient:    dnsClient,\n\t\tpvtzClient:   pvtzClient,\n\t\tprivateZone:  zoneType == \"private\",\n\t}\n\n\tif cfg.RoleName != \"\" {\n\t\tprovider.setNextExpire(cfg.ExpireTime)\n\t\tgo provider.refreshStsToken(1 * time.Second)\n\t}\n\treturn provider, nil\n}\n\nfunc getCloudConfigFromStsToken() (alibabaCloudConfig, error) {\n\tcfg := alibabaCloudConfig{}\n\t// Load config from Metadata Service\n\tm := metadata.NewMetaData(nil)\n\troleName := \"\"\n\tvar err error\n\tif roleName, err = m.RoleName(); err != nil {\n\t\treturn cfg, fmt.Errorf(\"failed to get role name from Metadata Service: %w\", err)\n\t}\n\tvpcID, err := m.VpcID()\n\tif err != nil {\n\t\treturn cfg, fmt.Errorf(\"failed to get VPC ID from Metadata Service: %w\", err)\n\t}\n\tregionID, err := m.Region()\n\tif err != nil {\n\t\treturn cfg, fmt.Errorf(\"failed to get Region ID from Metadata Service: %w\", err)\n\t}\n\trole, err := m.RamRoleToken(roleName)\n\tif err != nil {\n\t\treturn cfg, fmt.Errorf(\"failed to get STS Token from Metadata Service: %w\", err)\n\t}\n\tcfg.RegionID = regionID\n\tcfg.RoleName = roleName\n\tcfg.VPCID = vpcID\n\tcfg.AccessKeyID = role.AccessKeyId\n\tcfg.AccessKeySecret = role.AccessKeySecret\n\tcfg.StsToken = role.SecurityToken\n\tcfg.ExpireTime = role.Expiration\n\treturn cfg, nil\n}\n\nfunc (p *AlibabaCloudProvider) getDNSClient() AlibabaCloudDNSAPI {\n\tp.clientLock.RLock()\n\tdefer p.clientLock.RUnlock()\n\treturn p.dnsClient\n}\n\nfunc (p *AlibabaCloudProvider) getPvtzClient() AlibabaCloudPrivateZoneAPI {\n\tp.clientLock.RLock()\n\tdefer p.clientLock.RUnlock()\n\treturn p.pvtzClient\n}\n\nfunc (p *AlibabaCloudProvider) setNextExpire(expireTime time.Time) {\n\tp.clientLock.Lock()\n\tdefer p.clientLock.Unlock()\n\tp.nextExpire = expireTime\n}\n\nfunc (p *AlibabaCloudProvider) refreshStsToken(sleepTime time.Duration) {\n\tfor {\n\t\ttime.Sleep(sleepTime)\n\t\tnow := time.Now()\n\t\tutcLocation, err := time.LoadLocation(\"\")\n\t\tif err != nil {\n\t\t\tlog.Errorf(\"Get utc time error %v\", err)\n\t\t\tcontinue\n\t\t}\n\t\tnowTime := now.In(utcLocation)\n\t\tp.clientLock.RLock()\n\t\tsleepTime = p.nextExpire.Sub(nowTime)\n\t\tp.clientLock.RUnlock()\n\t\tlog.Infof(\"Distance expiration time %v\", sleepTime)\n\t\tif sleepTime < 10*time.Minute {\n\t\t\tsleepTime = time.Second * 1\n\t\t} else {\n\t\t\tsleepTime = 9 * time.Minute\n\t\t\tlog.Info(\"Next fetch sts sleep interval : \", sleepTime.String())\n\t\t\tcontinue\n\t\t}\n\t\tcfg, err := getCloudConfigFromStsToken()\n\t\tif err != nil {\n\t\t\tlog.Errorf(\"Failed to getCloudConfigFromStsToken: %v\", err)\n\t\t\tcontinue\n\t\t}\n\t\tdnsClient, err := alidns.NewClientWithStsToken(\n\t\t\tcfg.RegionID,\n\t\t\tcfg.AccessKeyID,\n\t\t\tcfg.AccessKeySecret,\n\t\t\tcfg.StsToken,\n\t\t)\n\t\tif err != nil {\n\t\t\tlog.Errorf(\"Failed to new client with sts token %v\", err)\n\t\t\tcontinue\n\t\t}\n\t\tpvtzClient, err := pvtz.NewClientWithStsToken(\n\t\t\tcfg.RegionID,\n\t\t\tcfg.AccessKeyID,\n\t\t\tcfg.AccessKeySecret,\n\t\t\tcfg.StsToken,\n\t\t)\n\t\tif err != nil {\n\t\t\tlog.Errorf(\"Failed to new client with sts token %v\", err)\n\t\t\tcontinue\n\t\t}\n\t\tlog.Infof(\"Refresh client from sts token, next expire time %v\", cfg.ExpireTime)\n\t\tp.clientLock.Lock()\n\t\tp.dnsClient = dnsClient\n\t\tp.pvtzClient = pvtzClient\n\t\tp.nextExpire = cfg.ExpireTime\n\t\tp.clientLock.Unlock()\n\t}\n}\n\n// Records gets the current records.\n//\n// Returns the current records or an error if the operation failed.\nfunc (p *AlibabaCloudProvider) Records(_ context.Context) ([]*endpoint.Endpoint, error) {\n\tif p.privateZone {\n\t\treturn p.privateZoneRecords()\n\t} else {\n\t\treturn p.recordsForDNS()\n\t}\n}\n\n// ApplyChanges applies the given changes.\n//\n// Returns nil if the operation was successful or an error if the operation failed.\nfunc (p *AlibabaCloudProvider) ApplyChanges(_ context.Context, changes *plan.Changes) error {\n\tif changes == nil || len(changes.Create)+len(changes.Delete)+len(changes.UpdateNew) == 0 {\n\t\t// No op\n\t\treturn nil\n\t}\n\n\tif p.privateZone {\n\t\treturn p.applyChangesForPrivateZone(changes)\n\t}\n\treturn p.applyChangesForDNS(changes)\n}\n\nfunc (p *AlibabaCloudProvider) getDNSName(rr, domain string) string {\n\tif rr == nullHostAlibabaCloud {\n\t\treturn domain\n\t}\n\treturn rr + \".\" + domain\n}\n\n// recordsForDNS gets the current records.\n//\n// Returns the current records or an error if the operation failed.\nfunc (p *AlibabaCloudProvider) recordsForDNS() ([]*endpoint.Endpoint, error) {\n\trecords, err := p.records()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tendpoints := make([]*endpoint.Endpoint, 0, len(records))\n\tfor _, recordList := range p.groupRecords(records) {\n\t\tname := p.getDNSName(recordList[0].RR, recordList[0].DomainName)\n\t\trecordType := recordList[0].Type\n\t\tttl := recordList[0].TTL\n\n\t\tvar targets []string\n\t\tfor _, record := range recordList {\n\t\t\ttarget := record.Value\n\t\t\tif recordType == \"TXT\" {\n\t\t\t\ttarget = p.unescapeTXTRecordValue(target)\n\t\t\t}\n\t\t\ttargets = append(targets, target)\n\t\t}\n\t\tep := endpoint.NewEndpointWithTTL(name, recordType, endpoint.TTL(ttl), targets...)\n\t\tendpoints = append(endpoints, ep)\n\t}\n\treturn endpoints, nil\n}\n\nfunc getNextPageNumber(pageNumber, totalCount int64) int64 {\n\tif pageNumber*defaultAlibabaCloudPageSize >= totalCount {\n\t\treturn 0\n\t}\n\treturn pageNumber + 1\n}\n\nfunc (p *AlibabaCloudProvider) getRecordKey(record alidns.Record) string {\n\tif record.RR == nullHostAlibabaCloud {\n\t\treturn record.Type + \":\" + record.DomainName\n\t}\n\treturn record.Type + \":\" + record.RR + \".\" + record.DomainName\n}\n\nfunc (p *AlibabaCloudProvider) getRecordKeyByEndpoint(endpoint *endpoint.Endpoint) string {\n\treturn endpoint.RecordType + \":\" + endpoint.DNSName\n}\n\nfunc (p *AlibabaCloudProvider) groupRecords(records []alidns.Record) map[string][]alidns.Record {\n\tendpointMap := make(map[string][]alidns.Record)\n\tfor _, record := range records {\n\t\tkey := p.getRecordKey(record)\n\n\t\trecordList := endpointMap[key]\n\t\tendpointMap[key] = append(recordList, record)\n\t}\n\treturn endpointMap\n}\n\nfunc (p *AlibabaCloudProvider) records() ([]alidns.Record, error) {\n\tlog.Infof(\"Retrieving Alibaba Cloud DNS Domain Records\")\n\tvar results []alidns.Record\n\thostedZoneDomains, err := p.getDomainList()\n\tif err != nil {\n\t\treturn results, fmt.Errorf(\"getting domain list: %w\", err)\n\t}\n\tif !p.domainFilter.IsConfigured() {\n\t\tfor _, zoneDomain := range hostedZoneDomains {\n\t\t\tdomainRecords, err := p.getDomainRecords(zoneDomain)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"getDomainRecords %q: %w\", zoneDomain, err)\n\t\t\t}\n\t\t\tresults = append(results, domainRecords...)\n\t\t}\n\t} else {\n\t\tfor _, domainName := range p.domainFilter.Filters {\n\t\t\t_, domainName = p.splitDNSName(domainName, hostedZoneDomains)\n\t\t\ttmpResults, err := p.getDomainRecords(domainName)\n\t\t\tif err != nil {\n\t\t\t\tlog.Errorf(\"getDomainRecords %s error %v\", domainName, err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tresults = append(results, tmpResults...)\n\t\t}\n\t}\n\tlog.Infof(\"Found %d Alibaba Cloud DNS record(s).\", len(results))\n\treturn results, nil\n}\n\nfunc (p *AlibabaCloudProvider) getDomainList() ([]string, error) {\n\tvar domainNames []string\n\trequest := alidns.CreateDescribeDomainsRequest()\n\trequest.PageSize = requests.NewInteger(defaultAlibabaCloudPageSize)\n\trequest.PageNumber = \"1\"\n\trequest.Scheme = defaultAlibabaCloudRequestScheme\n\tfor {\n\t\tresp, err := p.dnsClient.DescribeDomains(request)\n\t\tif err != nil {\n\t\t\tlog.Errorf(\"Failed to describe domains for Alibaba Cloud DNS: %v\", err)\n\t\t\treturn nil, err\n\t\t}\n\t\tfor _, tmpDomain := range resp.Domains.Domain {\n\t\t\tdomainNames = append(domainNames, tmpDomain.DomainName)\n\t\t}\n\t\tnextPage := getNextPageNumber(resp.PageNumber, resp.TotalCount)\n\t\tif nextPage == 0 {\n\t\t\tbreak\n\t\t} else {\n\t\t\trequest.PageNumber = requests.NewInteger64(nextPage)\n\t\t}\n\t}\n\treturn domainNames, nil\n}\n\nfunc (p *AlibabaCloudProvider) getDomainRecords(domainName string) ([]alidns.Record, error) {\n\tvar results []alidns.Record\n\trequest := alidns.CreateDescribeDomainRecordsRequest()\n\trequest.DomainName = domainName\n\trequest.PageSize = requests.NewInteger(defaultAlibabaCloudPageSize)\n\trequest.PageNumber = \"1\"\n\trequest.Scheme = defaultAlibabaCloudRequestScheme\n\tfor {\n\t\tresponse, err := p.getDNSClient().DescribeDomainRecords(request)\n\t\tif err != nil {\n\t\t\tlog.Errorf(\"Failed to describe domain records for Alibaba Cloud DNS: %v\", err)\n\t\t\treturn nil, err\n\t\t}\n\n\t\tfor _, record := range response.DomainRecords.Record {\n\t\t\tdomainName := record.RR + \".\" + record.DomainName\n\t\t\trecordType := record.Type\n\n\t\t\tif !p.domainFilter.Match(domainName) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif !provider.SupportedRecordType(recordType) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t// TODO filter Locked record\n\t\t\tresults = append(results, record)\n\t\t}\n\t\tnextPage := getNextPageNumber(response.PageNumber, response.TotalCount)\n\t\tif nextPage == 0 {\n\t\t\tbreak\n\t\t} else {\n\t\t\trequest.PageNumber = requests.NewInteger64(nextPage)\n\t\t}\n\t}\n\n\treturn results, nil\n}\n\nfunc (p *AlibabaCloudProvider) applyChangesForDNS(changes *plan.Changes) error {\n\tlog.Infof(\"ApplyChanges to Alibaba Cloud DNS: %++v\", *changes)\n\n\trecords, err := p.records()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\trecordMap := p.groupRecords(records)\n\n\thostedZoneDomains, err := p.getDomainList()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"getting domain list: %w\", err)\n\t}\n\n\tp.createRecords(changes.Create, hostedZoneDomains)\n\tp.deleteRecords(recordMap, changes.Delete)\n\tp.updateRecords(recordMap, changes.UpdateNew, hostedZoneDomains)\n\treturn nil\n}\n\nfunc (p *AlibabaCloudProvider) escapeTXTRecordValue(value string) string {\n\t// For unsupported chars\n\treturn value\n}\n\nfunc (p *AlibabaCloudProvider) unescapeTXTRecordValue(value string) string {\n\tif strings.HasPrefix(value, \"heritage=\") {\n\t\treturn fmt.Sprintf(\"\\\"%s\\\"\", strings.ReplaceAll(value, \";\", \",\"))\n\t}\n\treturn value\n}\n\nfunc (p *AlibabaCloudProvider) createRecord(endpoint *endpoint.Endpoint, target string, hostedZoneDomains []string) error {\n\tif len(hostedZoneDomains) == 0 {\n\t\tlog.Errorf(\"Failed to create %s record named '%s' to '%s' for Alibaba Cloud DNS: zone not found\",\n\t\t\tendpoint.RecordType, endpoint.DNSName, target)\n\t\treturn fmt.Errorf(\"zone not found\")\n\t}\n\n\trr, domain := p.splitDNSName(endpoint.DNSName, hostedZoneDomains)\n\n\tif domain == \"\" {\n\t\tlog.Errorf(\"Failed to create %s record named '%s' to '%s' for Alibaba Cloud DNS: no corresponding DNS zone found for this domain '%s'\",\n\t\t\tendpoint.RecordType, endpoint.DNSName, target, endpoint.DNSName)\n\t\treturn fmt.Errorf(\"no corresponding DNS zone found for this domain\")\n\t}\n\n\trequest := alidns.CreateAddDomainRecordRequest()\n\trequest.DomainName = domain\n\trequest.Type = endpoint.RecordType\n\trequest.RR = rr\n\trequest.Scheme = defaultAlibabaCloudRequestScheme\n\n\tttl := int(endpoint.RecordTTL)\n\tif ttl != 0 {\n\t\trequest.TTL = requests.NewInteger(ttl)\n\t}\n\n\tif endpoint.RecordType == \"TXT\" {\n\t\ttarget = p.escapeTXTRecordValue(target)\n\t}\n\n\trequest.Value = target\n\n\tif p.dryRun {\n\t\tlog.Infof(\"Dry run: Create %s record named '%s' to '%s' with ttl %d for Alibaba Cloud DNS\", endpoint.RecordType, endpoint.DNSName, target, ttl)\n\t\treturn nil\n\t}\n\n\tresponse, err := p.getDNSClient().AddDomainRecord(request)\n\tif err == nil {\n\t\tlog.Infof(\"Create %s record named '%s' to '%s' with ttl %d for Alibaba Cloud DNS: Record ID=%s\", endpoint.RecordType, endpoint.DNSName, target, ttl, response.RecordId)\n\t} else {\n\t\tlog.Errorf(\"Failed to create %s record named '%s' to '%s' with ttl %d for Alibaba Cloud DNS: %v\", endpoint.RecordType, endpoint.DNSName, target, ttl, err)\n\t}\n\treturn err\n}\n\nfunc (p *AlibabaCloudProvider) createRecords(endpoints []*endpoint.Endpoint, hostedZoneDomains []string) {\n\tfor _, endpoint := range endpoints {\n\t\tfor _, target := range endpoint.Targets {\n\t\t\tp.createRecord(endpoint, target, hostedZoneDomains)\n\t\t}\n\t}\n}\n\nfunc (p *AlibabaCloudProvider) deleteRecord(recordID string) error {\n\tif p.dryRun {\n\t\tlog.Infof(\"Dry run: Delete record id '%s' in Alibaba Cloud DNS\", recordID)\n\t\treturn nil\n\t}\n\n\trequest := alidns.CreateDeleteDomainRecordRequest()\n\trequest.RecordId = recordID\n\trequest.Scheme = defaultAlibabaCloudRequestScheme\n\tresponse, err := p.getDNSClient().DeleteDomainRecord(request)\n\tif err == nil {\n\t\tlog.Infof(\"Delete record id %s in Alibaba Cloud DNS\", response.RecordId)\n\t} else {\n\t\tlog.Errorf(\"Failed to delete record '%s' in Alibaba Cloud DNS: %v\", response.RecordId, err)\n\t}\n\treturn err\n}\n\nfunc (p *AlibabaCloudProvider) updateRecord(record alidns.Record, endpoint *endpoint.Endpoint) error {\n\trequest := alidns.CreateUpdateDomainRecordRequest()\n\trequest.RecordId = record.RecordId\n\trequest.RR = record.RR\n\trequest.Type = record.Type\n\trequest.Value = record.Value\n\trequest.Scheme = defaultAlibabaCloudRequestScheme\n\tttl := int(endpoint.RecordTTL)\n\tif ttl != 0 {\n\t\trequest.TTL = requests.NewInteger(ttl)\n\t}\n\tresponse, err := p.getDNSClient().UpdateDomainRecord(request)\n\tif err == nil {\n\t\tlog.Infof(\"Update record id '%s' in Alibaba Cloud DNS\", response.RecordId)\n\t} else {\n\t\tlog.Errorf(\"Failed to update record '%s' in Alibaba Cloud DNS: %v\", response.RecordId, err)\n\t}\n\treturn err\n}\n\nfunc (p *AlibabaCloudProvider) deleteRecords(recordMap map[string][]alidns.Record, endpoints []*endpoint.Endpoint) {\n\tfor _, endpoint := range endpoints {\n\t\tkey := p.getRecordKeyByEndpoint(endpoint)\n\t\trecords := recordMap[key]\n\t\tfound := false\n\t\tfor _, record := range records {\n\t\t\tvalue := record.Value\n\t\t\tif record.Type == \"TXT\" {\n\t\t\t\tvalue = p.unescapeTXTRecordValue(value)\n\t\t\t}\n\n\t\t\tif slices.Contains(endpoint.Targets, value) {\n\t\t\t\tp.deleteRecord(record.RecordId)\n\t\t\t\tfound = true\n\t\t\t}\n\t\t}\n\t\tif !found {\n\t\t\tlog.Errorf(\"Failed to find %s record named '%s' to delete for Alibaba Cloud DNS\", endpoint.RecordType, endpoint.DNSName)\n\t\t}\n\t}\n}\n\nfunc (p *AlibabaCloudProvider) equals(record alidns.Record, endpoint *endpoint.Endpoint) bool {\n\tttl1 := record.TTL\n\tif ttl1 == defaultTTL {\n\t\tttl1 = 0\n\t}\n\n\tttl2 := int64(endpoint.RecordTTL)\n\tif ttl2 == defaultTTL {\n\t\tttl2 = 0\n\t}\n\n\treturn ttl1 == ttl2\n}\n\nfunc (p *AlibabaCloudProvider) updateRecords(recordMap map[string][]alidns.Record, endpoints []*endpoint.Endpoint, hostedZoneDomains []string) {\n\tfor _, endpoint := range endpoints {\n\t\tkey := p.getRecordKeyByEndpoint(endpoint)\n\t\trecords := recordMap[key]\n\t\tfor _, record := range records {\n\t\t\tvalue := record.Value\n\t\t\tif record.Type == \"TXT\" {\n\t\t\t\tvalue = p.unescapeTXTRecordValue(value)\n\t\t\t}\n\t\t\tfound := false\n\t\t\tfor _, target := range endpoint.Targets {\n\t\t\t\t// Find matched record to delete\n\t\t\t\tif value == target {\n\t\t\t\t\tfound = true\n\t\t\t\t}\n\t\t\t}\n\t\t\tif found {\n\t\t\t\tif !p.equals(record, endpoint) {\n\t\t\t\t\t// Update record\n\t\t\t\t\tp.updateRecord(record, endpoint)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tp.deleteRecord(record.RecordId)\n\t\t\t}\n\t\t}\n\t\tfor _, target := range endpoint.Targets {\n\t\t\tif endpoint.RecordType == \"TXT\" {\n\t\t\t\ttarget = p.escapeTXTRecordValue(target)\n\t\t\t}\n\t\t\tfound := false\n\t\t\tfor _, record := range records {\n\t\t\t\t// Find matched record to delete\n\t\t\t\tif record.Value == target {\n\t\t\t\t\tfound = true\n\t\t\t\t}\n\t\t\t}\n\t\t\tif !found {\n\t\t\t\tp.createRecord(endpoint, target, hostedZoneDomains)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (p *AlibabaCloudProvider) splitDNSName(dnsName string, hostedZoneDomains []string) (string, string) {\n\tname := strings.TrimSuffix(dnsName, \".\")\n\n\t// sort zones by dot count; make sure subdomains sort earlier\n\tsort.Slice(hostedZoneDomains, func(i, j int) bool {\n\t\treturn strings.Count(hostedZoneDomains[i], \".\") > strings.Count(hostedZoneDomains[j], \".\")\n\t})\n\n\tvar rr, domain string\n\n\tfor _, filter := range hostedZoneDomains {\n\t\tif strings.HasSuffix(name, \".\"+filter) {\n\t\t\trr = name[0 : len(name)-len(filter)-1]\n\t\t\tdomain = filter\n\t\t\tbreak\n\t\t} else if name == filter {\n\t\t\tdomain = filter\n\t\t\trr = \"\"\n\t\t}\n\t}\n\n\tif rr == \"\" {\n\t\trr = nullHostAlibabaCloud\n\t}\n\treturn rr, domain\n}\n\nfunc (p *AlibabaCloudProvider) matchVPC(zoneID string) bool {\n\trequest := pvtz.CreateDescribeZoneInfoRequest()\n\trequest.ZoneId = zoneID\n\trequest.Domain = pVTZDoamin\n\trequest.Scheme = defaultAlibabaCloudRequestScheme\n\tresponse, err := p.getPvtzClient().DescribeZoneInfo(request)\n\tif err != nil {\n\t\tlog.Errorf(\"Failed to describe zone info %s in Alibaba Cloud DNS: %v\", zoneID, err)\n\t\treturn false\n\t}\n\tfoundVPC := false\n\tfor _, vpc := range response.BindVpcs.Vpc {\n\t\tif vpc.VpcId == p.vpcID {\n\t\t\tfoundVPC = true\n\t\t\tbreak\n\t\t}\n\t}\n\treturn foundVPC\n}\n\nfunc (p *AlibabaCloudProvider) privateZones() ([]pvtz.Zone, error) {\n\tvar zones []pvtz.Zone\n\n\trequest := pvtz.CreateDescribeZonesRequest()\n\trequest.PageSize = requests.NewInteger(defaultAlibabaCloudPageSize)\n\trequest.PageNumber = \"1\"\n\trequest.Domain = pVTZDoamin\n\trequest.Scheme = defaultAlibabaCloudRequestScheme\n\tfor {\n\t\tresponse, err := p.getPvtzClient().DescribeZones(request)\n\t\tif err != nil {\n\t\t\tlog.Errorf(\"Failed to describe zones in Alibaba Cloud DNS: %v\", err)\n\t\t\treturn nil, err\n\t\t}\n\t\tfor _, zone := range response.Zones.Zone {\n\t\t\tlog.Infof(\"PrivateZones zone: %++v\", zone)\n\n\t\t\tif !p.zoneIDFilter.Match(zone.ZoneId) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif !p.domainFilter.Match(zone.ZoneName) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif !p.matchVPC(zone.ZoneId) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tzones = append(zones, zone)\n\t\t}\n\t\tnextPage := getNextPageNumber(int64(response.PageNumber), int64(response.TotalItems))\n\t\tif nextPage == 0 {\n\t\t\tbreak\n\t\t} else {\n\t\t\trequest.PageNumber = requests.NewInteger64(nextPage)\n\t\t}\n\t}\n\treturn zones, nil\n}\n\ntype alibabaPrivateZone struct {\n\tpvtz.Zone\n\trecords []pvtz.Record\n}\n\nfunc (p *AlibabaCloudProvider) getPrivateZones() (map[string]*alibabaPrivateZone, error) {\n\tlog.Infof(\"Retrieving Alibaba Cloud Private Zone records\")\n\n\tresult := make(map[string]*alibabaPrivateZone)\n\trecordsCount := 0\n\n\tzones, err := p.privateZones()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor _, zone := range zones {\n\t\trequest := pvtz.CreateDescribeZoneRecordsRequest()\n\t\trequest.ZoneId = zone.ZoneId\n\t\trequest.PageSize = requests.NewInteger(defaultAlibabaCloudPageSize)\n\t\trequest.PageNumber = \"1\"\n\t\trequest.Domain = pVTZDoamin\n\t\trequest.Scheme = defaultAlibabaCloudRequestScheme\n\t\tvar records []pvtz.Record\n\n\t\tfor {\n\t\t\tresponse, err := p.getPvtzClient().DescribeZoneRecords(request)\n\t\t\tif err != nil {\n\t\t\t\tlog.Errorf(\"Failed to describe zone record '%s' in Alibaba Cloud DNS: %v\", zone.ZoneId, err)\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\tfor _, record := range response.Records.Record {\n\t\t\t\trecordType := record.Type\n\n\t\t\t\tif !provider.SupportedRecordType(recordType) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\t// TODO filter Locked\n\t\t\t\trecords = append(records, record)\n\t\t\t}\n\t\t\tnextPage := getNextPageNumber(int64(response.PageNumber), int64(response.TotalItems))\n\t\t\tif nextPage == 0 {\n\t\t\t\tbreak\n\t\t\t} else {\n\t\t\t\trequest.PageNumber = requests.NewInteger64(nextPage)\n\t\t\t}\n\t\t}\n\n\t\tprivateZone := alibabaPrivateZone{\n\t\t\tZone:    zone,\n\t\t\trecords: records,\n\t\t}\n\t\trecordsCount += len(records)\n\t\tresult[zone.ZoneName] = &privateZone\n\t}\n\tlog.Infof(\"Found %d Alibaba Cloud Private Zone record(s).\", recordsCount)\n\treturn result, nil\n}\n\nfunc (p *AlibabaCloudProvider) groupPrivateZoneRecords(zone *alibabaPrivateZone) map[string][]pvtz.Record {\n\tendpointMap := make(map[string][]pvtz.Record)\n\n\tfor _, record := range zone.records {\n\t\tkey := record.Type + \":\" + record.Rr\n\t\trecordList := endpointMap[key]\n\t\tendpointMap[key] = append(recordList, record)\n\t}\n\n\treturn endpointMap\n}\n\n// recordsForPrivateZone gets the current records.\n//\n// Returns the current records or an error if the operation failed.\nfunc (p *AlibabaCloudProvider) privateZoneRecords() ([]*endpoint.Endpoint, error) {\n\tzones, err := p.getPrivateZones()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tendpoints := make([]*endpoint.Endpoint, 0)\n\n\tfor _, zone := range zones {\n\t\trecordMap := p.groupPrivateZoneRecords(zone)\n\t\tfor _, recordList := range recordMap {\n\t\t\tname := p.getDNSName(recordList[0].Rr, zone.ZoneName)\n\t\t\trecordType := recordList[0].Type\n\t\t\tttl := recordList[0].Ttl\n\t\t\tif ttl == defaultAlibabaCloudPrivateZoneRecordTTL {\n\t\t\t\tttl = 0\n\t\t\t}\n\t\t\tvar targets []string\n\t\t\tfor _, record := range recordList {\n\t\t\t\ttarget := record.Value\n\t\t\t\tif recordType == \"TXT\" {\n\t\t\t\t\ttarget = p.unescapeTXTRecordValue(target)\n\t\t\t\t}\n\t\t\t\ttargets = append(targets, target)\n\t\t\t}\n\t\t\tep := endpoint.NewEndpointWithTTL(name, recordType, endpoint.TTL(ttl), targets...)\n\t\t\tendpoints = append(endpoints, ep)\n\t\t}\n\t}\n\treturn endpoints, nil\n}\n\nfunc (p *AlibabaCloudProvider) createPrivateZoneRecord(zones map[string]*alibabaPrivateZone, endpoint *endpoint.Endpoint, target string) error {\n\trr, domain := p.splitDNSName(endpoint.DNSName, keys(zones))\n\tzone := zones[domain]\n\tif zone == nil {\n\t\terr := fmt.Errorf(\"failed to find private zone '%s'\", domain)\n\t\tlog.Errorf(\"Failed to create %s record named '%s' to '%s' for Alibaba Cloud Private Zone: %v\", endpoint.RecordType, endpoint.DNSName, target, err)\n\t\treturn err\n\t}\n\n\trequest := pvtz.CreateAddZoneRecordRequest()\n\trequest.ZoneId = zone.ZoneId\n\trequest.Type = endpoint.RecordType\n\trequest.Rr = rr\n\trequest.Domain = pVTZDoamin\n\trequest.Scheme = defaultAlibabaCloudRequestScheme\n\n\tttl := int(endpoint.RecordTTL)\n\tif ttl != 0 {\n\t\trequest.Ttl = requests.NewInteger(ttl)\n\t}\n\n\tif endpoint.RecordType == \"TXT\" {\n\t\ttarget = p.escapeTXTRecordValue(target)\n\t}\n\n\trequest.Value = target\n\n\tif p.dryRun {\n\t\tlog.Infof(\"Dry run: Create %s record named '%s' to '%s' with ttl %d for Alibaba Cloud Private Zone\", endpoint.RecordType, endpoint.DNSName, target, ttl)\n\t\treturn nil\n\t}\n\n\tresponse, err := p.getPvtzClient().AddZoneRecord(request)\n\tif err == nil {\n\t\tlog.Infof(\"Create %s record named '%s' to '%s' with ttl %d for Alibaba Cloud Private Zone: Record ID=%d\", endpoint.RecordType, endpoint.DNSName, target, ttl, response.RecordId)\n\t} else {\n\t\tlog.Errorf(\"Failed to create %s record named '%s' to '%s' with ttl %d for Alibaba Cloud Private Zone: %v\", endpoint.RecordType, endpoint.DNSName, target, ttl, err)\n\t}\n\treturn err\n}\n\nfunc (p *AlibabaCloudProvider) createPrivateZoneRecords(zones map[string]*alibabaPrivateZone, endpoints []*endpoint.Endpoint) {\n\tfor _, endpoint := range endpoints {\n\t\tfor _, target := range endpoint.Targets {\n\t\t\t_ = p.createPrivateZoneRecord(zones, endpoint, target)\n\t\t}\n\t}\n}\n\nfunc (p *AlibabaCloudProvider) deletePrivateZoneRecord(recordID int64) error {\n\tif p.dryRun {\n\t\tlog.Infof(\"Dry run: Delete record id '%d' in Alibaba Cloud Private Zone\", recordID)\n\t}\n\n\trequest := pvtz.CreateDeleteZoneRecordRequest()\n\trequest.RecordId = requests.NewInteger64(recordID)\n\trequest.Domain = pVTZDoamin\n\trequest.Scheme = defaultAlibabaCloudRequestScheme\n\n\tresponse, err := p.getPvtzClient().DeleteZoneRecord(request)\n\tif err == nil {\n\t\tlog.Infof(\"Delete record id '%d' in Alibaba Cloud Private Zone\", response.RecordId)\n\t} else {\n\t\tlog.Errorf(\"Failed to delete record %d in Alibaba Cloud Private Zone: %v\", response.RecordId, err)\n\t}\n\treturn err\n}\n\nfunc (p *AlibabaCloudProvider) deletePrivateZoneRecords(zones map[string]*alibabaPrivateZone, endpoints []*endpoint.Endpoint) {\n\tzoneNames := keys(zones)\n\tfor _, endpoint := range endpoints {\n\t\trr, domain := p.splitDNSName(endpoint.DNSName, zoneNames)\n\n\t\tzone := zones[domain]\n\t\tif zone == nil {\n\t\t\tlog.Errorf(\"Failed to delete %s record named '%s' for Alibaba Cloud Private Zone: failed to find private zone '%s'\", endpoint.RecordType, endpoint.DNSName, domain)\n\t\t\tcontinue\n\t\t}\n\t\tfound := false\n\t\tfor _, record := range zone.records {\n\t\t\tif rr == record.Rr && endpoint.RecordType == record.Type {\n\t\t\t\tvalue := record.Value\n\t\t\t\tif record.Type == \"TXT\" {\n\t\t\t\t\tvalue = p.unescapeTXTRecordValue(value)\n\t\t\t\t}\n\t\t\t\tif slices.Contains(endpoint.Targets, value) {\n\t\t\t\t\tp.deletePrivateZoneRecord(record.RecordId)\n\t\t\t\t\tfound = true\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif !found {\n\t\t\tlog.Errorf(\"Failed to find %s record named '%s' to delete for Alibaba Cloud Private Zone\", endpoint.RecordType, endpoint.DNSName)\n\t\t}\n\t}\n}\n\n// ApplyChanges applies the given changes.\n//\n// Returns nil if the operation was successful or an error if the operation failed.\nfunc (p *AlibabaCloudProvider) applyChangesForPrivateZone(changes *plan.Changes) error {\n\tlog.Infof(\"ApplyChanges to Alibaba Cloud Private Zone: %++v\", *changes)\n\n\tzones, err := p.getPrivateZones()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor zoneName, zone := range zones {\n\t\tlog.Debugf(\"%s: %++v\", zoneName, zone)\n\t}\n\n\tp.createPrivateZoneRecords(zones, changes.Create)\n\tp.deletePrivateZoneRecords(zones, changes.Delete)\n\tp.updatePrivateZoneRecords(zones, changes.UpdateNew)\n\treturn nil\n}\n\nfunc (p *AlibabaCloudProvider) updatePrivateZoneRecord(record pvtz.Record, endpoint *endpoint.Endpoint) error {\n\trequest := pvtz.CreateUpdateZoneRecordRequest()\n\trequest.RecordId = requests.NewInteger64(record.RecordId)\n\trequest.Rr = record.Rr\n\trequest.Type = record.Type\n\trequest.Value = record.Value\n\trequest.Domain = pVTZDoamin\n\trequest.Scheme = defaultAlibabaCloudRequestScheme\n\tttl := int(endpoint.RecordTTL)\n\tif ttl != 0 {\n\t\trequest.Ttl = requests.NewInteger(ttl)\n\t}\n\tresponse, err := p.getPvtzClient().UpdateZoneRecord(request)\n\tif err == nil {\n\t\tlog.Infof(\"Update record id '%d' in Alibaba Cloud Private Zone\", response.RecordId)\n\t} else {\n\t\tlog.Errorf(\"Failed to update record '%d' in Alibaba Cloud Private Zone: %v\", response.RecordId, err)\n\t}\n\treturn err\n}\n\nfunc (p *AlibabaCloudProvider) equalsPrivateZone(record pvtz.Record, endpoint *endpoint.Endpoint) bool {\n\tttl1 := record.Ttl\n\tif ttl1 == defaultAlibabaCloudPrivateZoneRecordTTL {\n\t\tttl1 = 0\n\t}\n\n\tttl2 := int(endpoint.RecordTTL)\n\tif ttl2 == defaultAlibabaCloudPrivateZoneRecordTTL {\n\t\tttl2 = 0\n\t}\n\n\treturn ttl1 == ttl2\n}\n\nfunc (p *AlibabaCloudProvider) updatePrivateZoneRecords(zones map[string]*alibabaPrivateZone, endpoints []*endpoint.Endpoint) {\n\tzoneNames := keys(zones)\n\tfor _, endpoint := range endpoints {\n\t\trr, domain := p.splitDNSName(endpoint.DNSName, zoneNames)\n\t\tzone := zones[domain]\n\t\tif zone == nil {\n\t\t\tlog.Errorf(\"Failed to update %s record named '%s' for Alibaba Cloud Private Zone: failed to find private zone '%s'\", endpoint.RecordType, endpoint.DNSName, domain)\n\t\t\tcontinue\n\t\t}\n\n\t\tfor _, record := range zone.records {\n\t\t\tif record.Rr != rr || record.Type != endpoint.RecordType {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalue := record.Value\n\t\t\tif record.Type == \"TXT\" {\n\t\t\t\tvalue = p.unescapeTXTRecordValue(value)\n\t\t\t}\n\t\t\tfound := slices.Contains(endpoint.Targets, value)\n\t\t\tif found {\n\t\t\t\tif !p.equalsPrivateZone(record, endpoint) {\n\t\t\t\t\t// Update record\n\t\t\t\t\tp.updatePrivateZoneRecord(record, endpoint)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tp.deletePrivateZoneRecord(record.RecordId)\n\t\t\t}\n\t\t}\n\t\tfor _, target := range endpoint.Targets {\n\t\t\tif endpoint.RecordType == \"TXT\" {\n\t\t\t\ttarget = p.escapeTXTRecordValue(target)\n\t\t\t}\n\t\t\tfound := false\n\t\t\tfor _, record := range zone.records {\n\t\t\t\tif record.Rr != rr || record.Type != endpoint.RecordType {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\t// Find matched record to delete\n\t\t\t\tif record.Value == target {\n\t\t\t\t\tfound = true\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif !found {\n\t\t\t\tp.createPrivateZoneRecord(zones, endpoint, target)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc keys[T any](value map[string]T) []string {\n\tvar results []string\n\tfor k := range value {\n\t\tresults = append(results, k)\n\t}\n\treturn results\n}\n"
  },
  {
    "path": "provider/alibabacloud/alibaba_cloud_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage alibabacloud\n\nimport (\n\t\"testing\"\n\n\t\"github.com/aliyun/alibaba-cloud-sdk-go/services/alidns\"\n\t\"github.com/aliyun/alibaba-cloud-sdk-go/services/pvtz\"\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/plan\"\n)\n\ntype MockAlibabaCloudDNSAPI struct {\n\trecords []alidns.Record\n}\n\nfunc NewMockAlibabaCloudDNSAPI() *MockAlibabaCloudDNSAPI {\n\tapi := MockAlibabaCloudDNSAPI{}\n\tapi.records = []alidns.Record{\n\t\t{\n\t\t\tRecordId:   \"1\",\n\t\t\tDomainName: \"container-service.top\",\n\t\t\tType:       \"A\",\n\t\t\tTTL:        300,\n\t\t\tRR:         \"abc\",\n\t\t\tValue:      \"1.2.3.4\",\n\t\t},\n\t\t{\n\t\t\tRecordId:   \"2\",\n\t\t\tDomainName: \"container-service.top\",\n\t\t\tType:       \"TXT\",\n\t\t\tTTL:        300,\n\t\t\tRR:         \"abc\",\n\t\t\tValue:      \"heritage=external-dns;external-dns/owner=default\",\n\t\t},\n\t}\n\treturn &api\n}\n\nfunc (m *MockAlibabaCloudDNSAPI) AddDomainRecord(request *alidns.AddDomainRecordRequest) (*alidns.AddDomainRecordResponse, error) {\n\tttl, _ := request.TTL.GetValue()\n\tm.records = append(m.records, alidns.Record{\n\t\tRecordId:   \"3\",\n\t\tDomainName: request.DomainName,\n\t\tType:       request.Type,\n\t\tTTL:        int64(ttl),\n\t\tRR:         request.RR,\n\t\tValue:      request.Value,\n\t})\n\treturn alidns.CreateAddDomainRecordResponse(), nil\n}\n\nfunc (m *MockAlibabaCloudDNSAPI) DeleteDomainRecord(request *alidns.DeleteDomainRecordRequest) (*alidns.DeleteDomainRecordResponse, error) {\n\tvar result []alidns.Record\n\tfor _, record := range m.records {\n\t\tif record.RecordId != request.RecordId {\n\t\t\tresult = append(result, record)\n\t\t}\n\t}\n\tm.records = result\n\tresponse := alidns.CreateDeleteDomainRecordResponse()\n\tresponse.RecordId = request.RecordId\n\treturn response, nil\n}\n\nfunc (m *MockAlibabaCloudDNSAPI) UpdateDomainRecord(request *alidns.UpdateDomainRecordRequest) (*alidns.UpdateDomainRecordResponse, error) {\n\tttl, _ := request.TTL.GetValue64()\n\tfor i := range m.records {\n\t\tif m.records[i].RecordId == request.RecordId {\n\t\t\tm.records[i].TTL = ttl\n\t\t}\n\t}\n\tresponse := alidns.CreateUpdateDomainRecordResponse()\n\tresponse.RecordId = request.RecordId\n\treturn response, nil\n}\n\nfunc (m *MockAlibabaCloudDNSAPI) DescribeDomains(_ *alidns.DescribeDomainsRequest) (*alidns.DescribeDomainsResponse, error) {\n\tvar result alidns.DomainsInDescribeDomains\n\tfor _, record := range m.records {\n\t\tdomain := alidns.Domain{}\n\t\tdomain.DomainName = record.DomainName\n\t\tresult.Domain = append(result.Domain, alidns.DomainInDescribeDomains{\n\t\t\tDomainName: domain.DomainName,\n\t\t})\n\t}\n\tresponse := alidns.CreateDescribeDomainsResponse()\n\tresponse.Domains = result\n\treturn response, nil\n}\n\nfunc (m *MockAlibabaCloudDNSAPI) DescribeDomainRecords(request *alidns.DescribeDomainRecordsRequest) (*alidns.DescribeDomainRecordsResponse, error) {\n\tvar result []alidns.Record\n\tfor _, record := range m.records {\n\t\tif record.DomainName == request.DomainName {\n\t\t\tresult = append(result, record)\n\t\t}\n\t}\n\tresponse := alidns.CreateDescribeDomainRecordsResponse()\n\tresponse.DomainRecords.Record = result\n\treturn response, nil\n}\n\ntype MockAlibabaCloudPrivateZoneAPI struct {\n\tzone    pvtz.Zone\n\trecords []pvtz.Record\n}\n\nfunc NewMockAlibabaCloudPrivateZoneAPI() *MockAlibabaCloudPrivateZoneAPI {\n\tvpc := pvtz.Vpc{\n\t\tRegionId: \"cn-beijing\",\n\t\tVpcId:    \"vpc-xxxxxx\",\n\t}\n\n\tapi := MockAlibabaCloudPrivateZoneAPI{zone: pvtz.Zone{\n\t\tZoneId:   \"test-zone\",\n\t\tZoneName: \"container-service.top\",\n\t\tVpcs: pvtz.Vpcs{\n\t\t\tVpc: []pvtz.Vpc{vpc},\n\t\t},\n\t}}\n\n\tapi.records = []pvtz.Record{\n\t\t{\n\t\t\tRecordId: 1,\n\t\t\tType:     \"A\",\n\t\t\tTtl:      300,\n\t\t\tRr:       \"abc\",\n\t\t\tValue:    \"1.2.3.4\",\n\t\t},\n\t\t{\n\t\t\tRecordId: 2,\n\t\t\tType:     \"TXT\",\n\t\t\tTtl:      300,\n\t\t\tRr:       \"abc\",\n\t\t\tValue:    \"heritage=external-dns;external-dns/owner=default\",\n\t\t},\n\t}\n\treturn &api\n}\n\nfunc (m *MockAlibabaCloudPrivateZoneAPI) AddZoneRecord(request *pvtz.AddZoneRecordRequest) (*pvtz.AddZoneRecordResponse, error) {\n\tttl, _ := request.Ttl.GetValue()\n\tm.records = append(m.records, pvtz.Record{\n\t\tRecordId: 3,\n\t\tType:     request.Type,\n\t\tTtl:      ttl,\n\t\tRr:       request.Rr,\n\t\tValue:    request.Value,\n\t})\n\treturn pvtz.CreateAddZoneRecordResponse(), nil\n}\n\nfunc (m *MockAlibabaCloudPrivateZoneAPI) DeleteZoneRecord(request *pvtz.DeleteZoneRecordRequest) (*pvtz.DeleteZoneRecordResponse, error) {\n\trecordID, _ := request.RecordId.GetValue64()\n\n\tvar result []pvtz.Record\n\tfor _, record := range m.records {\n\t\tif record.RecordId != recordID {\n\t\t\tresult = append(result, record)\n\t\t}\n\t}\n\tm.records = result\n\treturn pvtz.CreateDeleteZoneRecordResponse(), nil\n}\n\nfunc (m *MockAlibabaCloudPrivateZoneAPI) UpdateZoneRecord(request *pvtz.UpdateZoneRecordRequest) (*pvtz.UpdateZoneRecordResponse, error) {\n\trecordID, _ := request.RecordId.GetValue64()\n\tttl, _ := request.Ttl.GetValue()\n\tfor i := range m.records {\n\t\tif m.records[i].RecordId == recordID {\n\t\t\tm.records[i].Ttl = ttl\n\t\t}\n\t}\n\treturn pvtz.CreateUpdateZoneRecordResponse(), nil\n}\n\nfunc (m *MockAlibabaCloudPrivateZoneAPI) DescribeZoneRecords(_ *pvtz.DescribeZoneRecordsRequest) (*pvtz.DescribeZoneRecordsResponse, error) {\n\tresponse := pvtz.CreateDescribeZoneRecordsResponse()\n\tresponse.Records.Record = append(response.Records.Record, m.records...)\n\treturn response, nil\n}\n\nfunc (m *MockAlibabaCloudPrivateZoneAPI) DescribeZones(_ *pvtz.DescribeZonesRequest) (*pvtz.DescribeZonesResponse, error) {\n\tresponse := pvtz.CreateDescribeZonesResponse()\n\tresponse.Zones.Zone = append(response.Zones.Zone, m.zone)\n\treturn response, nil\n}\n\nfunc (m *MockAlibabaCloudPrivateZoneAPI) DescribeZoneInfo(_ *pvtz.DescribeZoneInfoRequest) (*pvtz.DescribeZoneInfoResponse, error) {\n\tresponse := pvtz.CreateDescribeZoneInfoResponse()\n\tresponse.ZoneId = m.zone.ZoneId\n\tresponse.ZoneName = m.zone.ZoneName\n\tresponse.BindVpcs = pvtz.BindVpcsInDescribeZoneInfo{Vpc: make([]pvtz.VpcInDescribeZoneInfo, len(m.zone.Vpcs.Vpc))}\n\tfor idx, vpc := range m.zone.Vpcs.Vpc {\n\t\tresponse.BindVpcs.Vpc[idx] = pvtz.VpcInDescribeZoneInfo{VpcName: vpc.VpcName, VpcId: vpc.VpcId, VpcType: vpc.VpcType, RegionName: vpc.RegionName, RegionId: vpc.RegionId}\n\t}\n\treturn response, nil\n}\n\nfunc newTestAlibabaCloudProvider(private bool) *AlibabaCloudProvider {\n\tcfg := alibabaCloudConfig{\n\t\tVPCID: \"vpc-xxxxxx\",\n\t}\n\n\tdomainFilterTest := endpoint.NewDomainFilter([]string{\"container-service.top.\", \"example.org\"})\n\n\treturn &AlibabaCloudProvider{\n\t\tdomainFilter: domainFilterTest,\n\t\tvpcID:        cfg.VPCID,\n\t\tdryRun:       false,\n\t\tdnsClient:    NewMockAlibabaCloudDNSAPI(),\n\t\tpvtzClient:   NewMockAlibabaCloudPrivateZoneAPI(),\n\t\tprivateZone:  private,\n\t}\n}\n\nfunc TestAlibabaCloudPrivateProvider_Records(t *testing.T) {\n\tp := newTestAlibabaCloudProvider(true)\n\tendpoints, err := p.Records(t.Context())\n\tif err != nil {\n\t\tt.Errorf(\"Failed to get records: %v\", err)\n\t} else {\n\t\tif len(endpoints) != 2 {\n\t\t\tt.Errorf(\"Incorrect number of records: %d\", len(endpoints))\n\t\t}\n\t\tfor _, ep := range endpoints {\n\t\t\tt.Logf(\"Endpoint for %++v\", *ep)\n\t\t}\n\t}\n}\n\nfunc TestAlibabaCloudProvider_Records(t *testing.T) {\n\tp := newTestAlibabaCloudProvider(false)\n\tendpoints, err := p.Records(t.Context())\n\tif err != nil {\n\t\tt.Errorf(\"Failed to get records: %v\", err)\n\t} else {\n\t\tif len(endpoints) != 2 {\n\t\t\tt.Errorf(\"Incorrect number of records: %d\", len(endpoints))\n\t\t}\n\t\tfor _, ep := range endpoints {\n\t\t\tt.Logf(\"Endpoint for %++v\", *ep)\n\t\t}\n\t}\n}\n\nfunc TestAlibabaCloudProvider_ApplyChanges(t *testing.T) {\n\tp := newTestAlibabaCloudProvider(false)\n\tdefaultTtlPlan := &endpoint.Endpoint{\n\t\tDNSName:    \"ttl.container-service.top\",\n\t\tRecordType: \"A\",\n\t\tRecordTTL:  defaultTTL,\n\t\tTargets:    endpoint.NewTargets(\"4.3.2.1\"),\n\t}\n\tchanges := plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"xyz.container-service.top\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tRecordTTL:  300,\n\t\t\t\tTargets:    endpoint.NewTargets(\"4.3.2.1\"),\n\t\t\t},\n\t\t\tdefaultTtlPlan,\n\t\t},\n\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"abc.container-service.top\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tRecordTTL:  500,\n\t\t\t\tTargets:    endpoint.NewTargets(\"1.2.3.4\", \"5.6.7.8\"),\n\t\t\t},\n\t\t},\n\t\tDelete: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"abc.container-service.top\",\n\t\t\t\tRecordType: \"TXT\",\n\t\t\t\tRecordTTL:  300,\n\t\t\t\tTargets:    endpoint.NewTargets(\"\\\"heritage=external-dns,external-dns/owner=default\\\"\"),\n\t\t\t},\n\t\t},\n\t}\n\tctx := t.Context()\n\terr := p.ApplyChanges(ctx, &changes)\n\tassert.NoError(t, err)\n\tendpoints, err := p.Records(ctx)\n\tif err != nil {\n\t\tt.Errorf(\"Failed to get records: %v\", err)\n\t} else {\n\t\tif len(endpoints) != 3 {\n\t\t\tt.Errorf(\"Incorrect number of records: %d\", len(endpoints))\n\t\t}\n\t\tfor _, ep := range endpoints {\n\t\t\tt.Logf(\"Endpoint for %++v\", *ep)\n\t\t}\n\t}\n\tfor _, ep := range endpoints {\n\t\tif ep.DNSName == defaultTtlPlan.DNSName {\n\t\t\tif ep.RecordTTL != defaultTtlPlan.RecordTTL {\n\t\t\t\tt.Error(\"default ttl execute error\")\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc TestAlibabaCloudProvider_ApplyChanges_HaveNoDefinedZoneDomain(t *testing.T) {\n\tp := newTestAlibabaCloudProvider(false)\n\tdefaultTtlPlan := &endpoint.Endpoint{\n\t\tDNSName:    \"ttl.container-service.top\",\n\t\tRecordType: \"A\",\n\t\tRecordTTL:  defaultTTL,\n\t\tTargets:    endpoint.NewTargets(\"4.3.2.1\"),\n\t}\n\tchanges := plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"www.example.com\", // no found this zone by API: DescribeDomains\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tRecordTTL:  300,\n\t\t\t\tTargets:    endpoint.NewTargets(\"9.9.9.9\"),\n\t\t\t},\n\t\t\tdefaultTtlPlan,\n\t\t},\n\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"abc.container-service.top\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tRecordTTL:  500,\n\t\t\t\tTargets:    endpoint.NewTargets(\"1.2.3.4\", \"5.6.7.8\"),\n\t\t\t},\n\t\t},\n\t\tDelete: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"abc.container-service.top\",\n\t\t\t\tRecordType: \"TXT\",\n\t\t\t\tRecordTTL:  300,\n\t\t\t\tTargets:    endpoint.NewTargets(\"\\\"heritage=external-dns,external-dns/owner=default\\\"\"),\n\t\t\t},\n\t\t},\n\t}\n\tctx := t.Context()\n\terr := p.ApplyChanges(ctx, &changes)\n\tassert.NoError(t, err)\n\tendpoints, err := p.Records(ctx)\n\tif err != nil {\n\t\tt.Errorf(\"Failed to get records: %v\", err)\n\t} else {\n\t\tif len(endpoints) != 2 {\n\t\t\tt.Errorf(\"Incorrect number of records: %d\", len(endpoints))\n\t\t}\n\t\tfor _, ep := range endpoints {\n\t\t\tt.Logf(\"Endpoint for %++v\", *ep)\n\t\t}\n\t}\n\tfor _, ep := range endpoints {\n\t\tif ep.DNSName == defaultTtlPlan.DNSName {\n\t\t\tif ep.RecordTTL != defaultTtlPlan.RecordTTL {\n\t\t\t\tt.Error(\"default ttl execute error\")\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc TestAlibabaCloudProvider_Records_PrivateZone(t *testing.T) {\n\tp := newTestAlibabaCloudProvider(true)\n\tendpoints, err := p.Records(t.Context())\n\tif err != nil {\n\t\tt.Errorf(\"Failed to get records: %v\", err)\n\t} else {\n\t\tif len(endpoints) != 2 {\n\t\t\tt.Errorf(\"Incorrect number of records: %d\", len(endpoints))\n\t\t}\n\t\tfor _, ep := range endpoints {\n\t\t\tt.Logf(\"Endpoint for %++v\", *ep)\n\t\t}\n\t}\n}\n\nfunc TestAlibabaCloudProvider_ApplyChanges_PrivateZone(t *testing.T) {\n\tp := newTestAlibabaCloudProvider(true)\n\tchanges := plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"xyz.container-service.top\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tRecordTTL:  300,\n\t\t\t\tTargets:    endpoint.NewTargets(\"4.3.2.1\"),\n\t\t\t},\n\t\t},\n\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"abc.container-service.top\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tRecordTTL:  500,\n\t\t\t\tTargets:    endpoint.NewTargets(\"1.2.3.4\", \"5.6.7.8\"),\n\t\t\t},\n\t\t},\n\t\tDelete: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"abc.container-service.top\",\n\t\t\t\tRecordType: \"TXT\",\n\t\t\t\tRecordTTL:  300,\n\t\t\t\tTargets:    endpoint.NewTargets(\"\\\"heritage=external-dns,external-dns/owner=default\\\"\"),\n\t\t\t},\n\t\t},\n\t}\n\tctx := t.Context()\n\terr := p.ApplyChanges(ctx, &changes)\n\tassert.NoError(t, err)\n\tendpoints, err := p.Records(ctx)\n\tif err != nil {\n\t\tt.Errorf(\"Failed to get records: %v\", err)\n\t} else {\n\t\tif len(endpoints) != 2 {\n\t\t\tt.Errorf(\"Incorrect number of records: %d\", len(endpoints))\n\t\t}\n\t\tfor _, ep := range endpoints {\n\t\t\tt.Logf(\"Endpoint for %++v\", *ep)\n\t\t}\n\t}\n}\n\nfunc TestAlibabaCloudProvider_splitDNSName(t *testing.T) {\n\tp := newTestAlibabaCloudProvider(false)\n\tendpoint := &endpoint.Endpoint{}\n\thostedZoneDomains := []string{\"container-service.top\", \"example.org\"}\n\n\tvar emptyZoneDomains []string\n\n\tendpoint.DNSName = \"www.example.org\"\n\trr, domain := p.splitDNSName(endpoint.DNSName, hostedZoneDomains)\n\tif rr != \"www\" || domain != \"example.org\" {\n\t\tt.Errorf(\"Failed to splitDNSName for %s: rr=%s, domain=%s\", endpoint.DNSName, rr, domain)\n\t}\n\tendpoint.DNSName = \".example.org\"\n\trr, domain = p.splitDNSName(endpoint.DNSName, hostedZoneDomains)\n\tif rr != \"@\" || domain != \"example.org\" {\n\t\tt.Errorf(\"Failed to splitDNSName for %s: rr=%s, domain=%s\", endpoint.DNSName, rr, domain)\n\t}\n\tendpoint.DNSName = \"www\"\n\trr, domain = p.splitDNSName(endpoint.DNSName, hostedZoneDomains)\n\tif rr != \"@\" || domain != \"\" {\n\t\tt.Errorf(\"Failed to splitDNSName for %s: rr=%s, domain=%s\", endpoint.DNSName, rr, domain)\n\t}\n\tendpoint.DNSName = \"\"\n\trr, domain = p.splitDNSName(endpoint.DNSName, hostedZoneDomains)\n\tif rr != \"@\" || domain != \"\" {\n\t\tt.Errorf(\"Failed to splitDNSName for %s: rr=%s, domain=%s\", endpoint.DNSName, rr, domain)\n\t}\n\tendpoint.DNSName = \"_30000._tcp.container-service.top\"\n\trr, domain = p.splitDNSName(endpoint.DNSName, hostedZoneDomains)\n\tif rr != \"_30000._tcp\" || domain != \"container-service.top\" {\n\t\tt.Errorf(\"Failed to splitDNSName for %s: rr=%s, domain=%s\", endpoint.DNSName, rr, domain)\n\t}\n\tendpoint.DNSName = \"container-service.top\"\n\trr, domain = p.splitDNSName(endpoint.DNSName, hostedZoneDomains)\n\tif rr != \"@\" || domain != \"container-service.top\" {\n\t\tt.Errorf(\"Failed to splitDNSName for %s: rr=%s, domain=%s\", endpoint.DNSName, rr, domain)\n\t}\n\tendpoint.DNSName = \"a.b.container-service.top\"\n\trr, domain = p.splitDNSName(endpoint.DNSName, hostedZoneDomains)\n\tif rr != \"a.b\" || domain != \"container-service.top\" {\n\t\tt.Errorf(\"Failed to splitDNSName for %s: rr=%s, domain=%s\", endpoint.DNSName, rr, domain)\n\t}\n\tendpoint.DNSName = \"a.b.c.container-service.top\"\n\trr, domain = p.splitDNSName(endpoint.DNSName, hostedZoneDomains)\n\tif rr != \"a.b.c\" || domain != \"container-service.top\" {\n\t\tt.Errorf(\"Failed to splitDNSName for %s: rr=%s, domain=%s\", endpoint.DNSName, rr, domain)\n\t}\n\tendpoint.DNSName = \"a.b.c.container-service.top\"\n\trr, domain = p.splitDNSName(endpoint.DNSName, []string{\"c.container-service.top\"})\n\tif rr != \"a.b\" || domain != \"c.container-service.top\" {\n\t\tt.Errorf(\"Failed to splitDNSName for %s: rr=%s, domain=%s\", endpoint.DNSName, rr, domain)\n\t}\n\n\tendpoint.DNSName = \"a.b.c.container-service.top\"\n\trr, domain = p.splitDNSName(endpoint.DNSName, []string{\"container-service.top\", \"c.container-service.top\"})\n\tif rr != \"a.b\" || domain != \"c.container-service.top\" {\n\t\tt.Errorf(\"Failed to splitDNSName for %s: rr=%s, domain=%s\", endpoint.DNSName, rr, domain)\n\t}\n\trr, domain = p.splitDNSName(endpoint.DNSName, emptyZoneDomains)\n\tif rr != \"@\" || domain != \"\" {\n\t\tt.Errorf(\"Failed to splitDNSName with emptyZoneDomains for %s: rr=%s, domain=%s\", endpoint.DNSName, rr, domain)\n\t}\n\trr, domain = p.splitDNSName(endpoint.DNSName, []string{\"example.com\"})\n\tif rr != \"@\" || domain != \"\" {\n\t\tt.Errorf(\"Failed to splitDNSName for %s: rr=%s, domain=%s\", endpoint.DNSName, rr, domain)\n\t}\n}\n\nfunc TestAlibabaCloudProvider_TXTEndpoint(t *testing.T) {\n\tp := newTestAlibabaCloudProvider(false)\n\tconst recordValue = \"heritage=external-dns,external-dns/owner=default\"\n\tconst endpointTarget = \"\\\"heritage=external-dns,external-dns/owner=default\\\"\"\n\n\tif p.escapeTXTRecordValue(endpointTarget) != endpointTarget {\n\t\tt.Errorf(\"Failed to escapeTXTRecordValue: %s\", p.escapeTXTRecordValue(endpointTarget))\n\t}\n\tif p.unescapeTXTRecordValue(recordValue) != endpointTarget {\n\t\tt.Errorf(\"Failed to unescapeTXTRecordValue: %s\", p.unescapeTXTRecordValue(recordValue))\n\t}\n}\n\n// TestAlibabaCloudProvider_TXTEndpoint_PrivateZone\nfunc TestAlibabaCloudProvider_TXTEndpoint_PrivateZone(t *testing.T) {\n\tp := newTestAlibabaCloudProvider(true)\n\tconst recordValue = \"heritage=external-dns,external-dns/owner=default\"\n\tconst endpointTarget = \"\\\"heritage=external-dns,external-dns/owner=default\\\"\"\n\n\tif p.escapeTXTRecordValue(endpointTarget) != endpointTarget {\n\t\tt.Errorf(\"Failed to escapeTXTRecordValue: %s\", p.escapeTXTRecordValue(endpointTarget))\n\t}\n\tif p.unescapeTXTRecordValue(recordValue) != endpointTarget {\n\t\tt.Errorf(\"Failed to unescapeTXTRecordValue: %s\", p.unescapeTXTRecordValue(recordValue))\n\t}\n}\n"
  },
  {
    "path": "provider/aws/aws.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage aws\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"regexp\"\n\t\"slices\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/aws/aws-sdk-go-v2/aws\"\n\t\"github.com/aws/aws-sdk-go-v2/service/route53\"\n\troute53types \"github.com/aws/aws-sdk-go-v2/service/route53/types\"\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n\t\"sigs.k8s.io/external-dns/provider/blueprint\"\n)\n\nconst (\n\tdefaultAWSProfile = \"default\"\n\tdefaultTTL        = 300\n\t// From the experiments, it seems that the default MaxItems applied is 100,\n\t// and that, on the server side, there is a hard limit of 300 elements per page.\n\t// After a discussion with AWS representatives, clients should accept\n\t// when fewer items are returned, and still paginate accordingly.\n\t// As we are using the standard AWS client, this should already be compliant.\n\t// Hence, if AWS ever decides to raise this limit, we will automatically reduce the pressure on rate limits\n\troute53PageSize int32 = 300\n\t// providerSpecificAlias specifies whether a CNAME endpoint maps to an AWS ALIAS record.\n\tproviderSpecificAlias            = \"alias\"\n\tproviderSpecificTargetHostedZone = \"aws/target-hosted-zone\"\n\t// providerSpecificEvaluateTargetHealth specifies whether an AWS ALIAS record\n\t// has the EvaluateTargetHealth field set to true. Present iff the endpoint\n\t// has a `providerSpecificAlias` value of `true`.\n\tproviderSpecificEvaluateTargetHealth               = \"aws/evaluate-target-health\"\n\tproviderSpecificWeight                             = \"aws/weight\"\n\tproviderSpecificRegion                             = \"aws/region\"\n\tproviderSpecificFailover                           = \"aws/failover\"\n\tproviderSpecificGeolocationContinentCode           = \"aws/geolocation-continent-code\"\n\tproviderSpecificGeolocationCountryCode             = \"aws/geolocation-country-code\"\n\tproviderSpecificGeolocationSubdivisionCode         = \"aws/geolocation-subdivision-code\"\n\tproviderSpecificGeoProximityLocationAWSRegion      = \"aws/geoproximity-region\"\n\tproviderSpecificGeoProximityLocationBias           = \"aws/geoproximity-bias\"\n\tproviderSpecificGeoProximityLocationCoordinates    = \"aws/geoproximity-coordinates\"\n\tproviderSpecificGeoProximityLocationLocalZoneGroup = \"aws/geoproximity-local-zone-group\"\n\tproviderSpecificMultiValueAnswer                   = \"aws/multi-value-answer\"\n\tproviderSpecificHealthCheckID                      = \"aws/health-check-id\"\n\tsameZoneAlias                                      = \"same-zone\"\n\t// Currently supported up to 10 health checks or hosted zones.\n\t// https://docs.aws.amazon.com/Route53/latest/APIReference/API_ListTagsForResources.html#API_ListTagsForResources_RequestSyntax\n\tbatchSize    = 10\n\tminLatitude  = -90.0\n\tmaxLatitude  = 90.0\n\tminLongitude = -180.0\n\tmaxLongitude = 180.0\n)\n\n// see elb: https://docs.aws.amazon.com/general/latest/gr/elb.html\nvar canonicalHostedZones = map[string]string{\n\t// Application Load Balancers and Classic Load Balancers\n\t\"us-east-2.elb.amazonaws.com\":         \"Z3AADJGX6KTTL2\",\n\t\"us-east-1.elb.amazonaws.com\":         \"Z35SXDOTRQ7X7K\",\n\t\"us-west-1.elb.amazonaws.com\":         \"Z368ELLRRE2KJ0\",\n\t\"us-west-2.elb.amazonaws.com\":         \"Z1H1FL5HABSF5\",\n\t\"ca-central-1.elb.amazonaws.com\":      \"ZQSVJUPU6J1EY\",\n\t\"ca-west-1.elb.amazonaws.com\":         \"Z06473681N0SF6OS049SD\",\n\t\"ap-east-1.elb.amazonaws.com\":         \"Z3DQVH9N71FHZ0\",\n\t\"ap-east-2.elb.amazonaws.com\":         \"Z02789141MW7T1WBU19PO\",\n\t\"ap-south-1.elb.amazonaws.com\":        \"ZP97RAFLXTNZK\",\n\t\"ap-south-2.elb.amazonaws.com\":        \"Z0173938T07WNTVAEPZN\",\n\t\"ap-northeast-2.elb.amazonaws.com\":    \"ZWKZPGTI48KDX\",\n\t\"ap-northeast-3.elb.amazonaws.com\":    \"Z5LXEXXYW11ES\",\n\t\"ap-southeast-1.elb.amazonaws.com\":    \"Z1LMS91P8CMLE5\",\n\t\"ap-southeast-2.elb.amazonaws.com\":    \"Z1GM3OXH4ZPM65\",\n\t\"ap-southeast-3.elb.amazonaws.com\":    \"Z08888821HLRG5A9ZRTER\",\n\t\"ap-southeast-4.elb.amazonaws.com\":    \"Z09517862IB2WZLPXG76F\",\n\t\"ap-southeast-5.elb.amazonaws.com\":    \"Z06010284QMVVW7WO5J\",\n\t\"ap-southeast-6.elb.amazonaws.com\":    \"Z023301818UFJ50CIO0MV\",\n\t\"ap-southeast-7.elb.amazonaws.com\":    \"Z0390008CMBRTHFGWBCB\",\n\t\"ap-northeast-1.elb.amazonaws.com\":    \"Z14GRHDCWA56QT\",\n\t\"eu-central-1.elb.amazonaws.com\":      \"Z215JYRZR1TBD5\",\n\t\"eu-central-2.elb.amazonaws.com\":      \"Z06391101F2ZOEP8P5EB3\",\n\t\"eu-west-1.elb.amazonaws.com\":         \"Z32O12XQLNTSW2\",\n\t\"eu-west-2.elb.amazonaws.com\":         \"ZHURV8PSTC4K8\",\n\t\"eu-west-3.elb.amazonaws.com\":         \"Z3Q77PNBQS71R4\",\n\t\"eu-north-1.elb.amazonaws.com\":        \"Z23TAZ6LKFMNIO\",\n\t\"eu-south-1.elb.amazonaws.com\":        \"Z3ULH7SSC9OV64\",\n\t\"eu-south-2.elb.amazonaws.com\":        \"Z0956581394HF5D5LXGAP\",\n\t\"sa-east-1.elb.amazonaws.com\":         \"Z2P70J7HTTTPLU\",\n\t\"cn-north-1.elb.amazonaws.com.cn\":     \"Z1GDH35T77C1KE\",\n\t\"cn-northwest-1.elb.amazonaws.com.cn\": \"ZM7IZAIOVVDZF\",\n\t\"us-gov-west-1.elb.amazonaws.com\":     \"Z33AYJ8TM3BH4J\",\n\t\"us-gov-east-1.elb.amazonaws.com\":     \"Z166TLBEWOO7G0\",\n\t\"mx-central-1.elb.amazonaws.com\":      \"Z023552324OKD1BB28BH5\",\n\t\"me-central-1.elb.amazonaws.com\":      \"Z08230872XQRWHG2XF6I\",\n\t\"me-south-1.elb.amazonaws.com\":        \"ZS929ML54UICD\",\n\t\"af-south-1.elb.amazonaws.com\":        \"Z268VQBMOI5EKX\",\n\t\"il-central-1.elb.amazonaws.com\":      \"Z09170902867EHPV2DABU\",\n\t// Network Load Balancers https://docs.aws.amazon.com/general/latest/gr/elb.html#elb_region\n\t\"elb.us-east-2.amazonaws.com\":         \"ZLMOA37VPKANP\",\n\t\"elb.us-east-1.amazonaws.com\":         \"Z26RNL4JYFTOTI\",\n\t\"elb.us-west-1.amazonaws.com\":         \"Z24FKFUX50B4VW\",\n\t\"elb.us-west-2.amazonaws.com\":         \"Z18D5FSROUN65G\",\n\t\"elb.ca-central-1.amazonaws.com\":      \"Z2EPGBW3API2WT\",\n\t\"elb.ca-west-1.amazonaws.com\":         \"Z02754302KBB00W2LKWZ9\",\n\t\"elb.ap-east-1.amazonaws.com\":         \"Z12Y7K3UBGUAD1\",\n\t\"elb.ap-east-2.amazonaws.com\":         \"Z09176273OC2HWIAUNYW\",\n\t\"elb.ap-south-1.amazonaws.com\":        \"ZVDDRBQ08TROA\",\n\t\"elb.ap-south-2.amazonaws.com\":        \"Z0711778386UTO08407HT\",\n\t\"elb.ap-northeast-3.amazonaws.com\":    \"Z1GWIQ4HH19I5X\",\n\t\"elb.ap-northeast-2.amazonaws.com\":    \"ZIBE1TIR4HY56\",\n\t\"elb.ap-southeast-1.amazonaws.com\":    \"ZKVM4W9LS7TM\",\n\t\"elb.ap-southeast-2.amazonaws.com\":    \"ZCT6FZBF4DROD\",\n\t\"elb.ap-southeast-3.amazonaws.com\":    \"Z01971771FYVNCOVWJU1G\",\n\t\"elb.ap-southeast-4.amazonaws.com\":    \"Z01156963G8MIIL7X90IV\",\n\t\"elb.ap-southeast-5.amazonaws.com\":    \"Z026317210H9ACVTRO6FB\",\n\t\"elb.ap-southeast-6.amazonaws.com\":    \"Z01392953RKV2Q3RBP0KU\",\n\t\"elb.ap-southeast-7.amazonaws.com\":    \"Z054363131YWATEMWRG5L\",\n\t\"elb.ap-northeast-1.amazonaws.com\":    \"Z31USIVHYNEOWT\",\n\t\"elb.eu-central-1.amazonaws.com\":      \"Z3F0SRJ5LGBH90\",\n\t\"elb.eu-central-2.amazonaws.com\":      \"Z02239872DOALSIDCX66S\",\n\t\"elb.eu-west-1.amazonaws.com\":         \"Z2IFOLAFXWLO4F\",\n\t\"elb.eu-west-2.amazonaws.com\":         \"ZD4D7Y8KGAS4G\",\n\t\"elb.eu-west-3.amazonaws.com\":         \"Z1CMS0P5QUZ6D5\",\n\t\"elb.eu-north-1.amazonaws.com\":        \"Z1UDT6IFJ4EJM\",\n\t\"elb.eu-south-1.amazonaws.com\":        \"Z23146JA1KNAFP\",\n\t\"elb.eu-south-2.amazonaws.com\":        \"Z1011216NVTVYADP1SSV\",\n\t\"elb.sa-east-1.amazonaws.com\":         \"ZTK26PT1VY4CU\",\n\t\"elb.cn-north-1.amazonaws.com.cn\":     \"Z3QFB96KMJ7ED6\",\n\t\"elb.cn-northwest-1.amazonaws.com.cn\": \"ZQEIKTCZ8352D\",\n\t\"elb.us-gov-west-1.amazonaws.com\":     \"ZMG1MZ2THAWF1\",\n\t\"elb.us-gov-east-1.amazonaws.com\":     \"Z1ZSMQQ6Q24QQ8\",\n\t\"elb.mx-central-1.amazonaws.com\":      \"Z02031231H3ID6HYJ9A7U\",\n\t\"elb.me-central-1.amazonaws.com\":      \"Z00282643NTTLPANJJG2P\",\n\t\"elb.me-south-1.amazonaws.com\":        \"Z3QSRYVP46NYYV\",\n\t\"elb.af-south-1.amazonaws.com\":        \"Z203XCE67M25HM\",\n\t\"elb.il-central-1.amazonaws.com\":      \"Z0313266YDI6ZRHTGQY4\",\n\t// Global Accelerator\n\t\"awsglobalaccelerator.com\": \"Z2BJ6XQ5FK7U4H\",\n\t// Cloudfront and AWS API Gateway edge-optimized endpoints\n\t\"cloudfront.net\": \"Z2FDTNDATAQYW2\",\n\t// VPC Endpoint (PrivateLink) https://github.com/kubernetes-sigs/external-dns/issues/3429#issuecomment-1440415806\n\t\"eu-west-2.vpce.amazonaws.com\":      \"Z7K1066E3PUKB\",\n\t\"us-east-2.vpce.amazonaws.com\":      \"ZC8PG0KIFKBRI\",\n\t\"af-south-1.vpce.amazonaws.com\":     \"Z09302161J80N9A7UTP7U\",\n\t\"ap-east-1.vpce.amazonaws.com\":      \"Z2LIHJ7PKBEMWN\",\n\t\"ap-east-2.vpce.amazonaws.com\":      \"Z09379811HWP0POAUWVN3\",\n\t\"ap-northeast-1.vpce.amazonaws.com\": \"Z2E726K9Y6RL4W\",\n\t\"ap-northeast-2.vpce.amazonaws.com\": \"Z27UANNT0PRK1T\",\n\t\"ap-northeast-3.vpce.amazonaws.com\": \"Z376B5OMM2JZL2\",\n\t\"ap-south-1.vpce.amazonaws.com\":     \"Z2KVTB3ZLFM7JR\",\n\t\"ap-south-2.vpce.amazonaws.com\":     \"Z0952991RWSF5AHIQDIY\",\n\t\"ap-southeast-1.vpce.amazonaws.com\": \"Z18LLCSTV4NVNL\",\n\t\"ap-southeast-2.vpce.amazonaws.com\": \"ZDK2GCRPAFKGO\",\n\t\"ap-southeast-3.vpce.amazonaws.com\": \"Z03881013RZ9BYYZO8N5W\",\n\t\"ap-southeast-4.vpce.amazonaws.com\": \"Z07508191CO1RNBX3X3AU\",\n\t\"ca-central-1.vpce.amazonaws.com\":   \"ZRCXCF510Y6P9\",\n\t\"eu-central-1.vpce.amazonaws.com\":   \"Z273ZU8SZ5RJPC\",\n\t\"eu-central-2.vpce.amazonaws.com\":   \"Z045369019J4FUQ4S272E\",\n\t\"eu-north-1.vpce.amazonaws.com\":     \"Z3OWWK6JFDEDGC\",\n\t\"eu-south-1.vpce.amazonaws.com\":     \"Z2A5FDNRLY7KZG\",\n\t\"eu-south-2.vpce.amazonaws.com\":     \"Z014396544HENR57XQCJ\",\n\t\"eu-west-1.vpce.amazonaws.com\":      \"Z38GZ743OKFT7T\",\n\t\"eu-west-3.vpce.amazonaws.com\":      \"Z1DWHTMFP0WECP\",\n\t\"me-central-1.vpce.amazonaws.com\":   \"Z07122992YCEUCB9A9570\",\n\t\"me-south-1.vpce.amazonaws.com\":     \"Z3B95P3VBGEQGY\",\n\t\"sa-east-1.vpce.amazonaws.com\":      \"Z2LXUWEVLCVZIB\",\n\t\"us-east-1.vpce.amazonaws.com\":      \"Z7HUB22UULQXV\",\n\t\"us-gov-east-1.vpce.amazonaws.com\":  \"Z2MU5TEIGO9WXB\",\n\t\"us-gov-west-1.vpce.amazonaws.com\":  \"Z12529ZODG2B6H\",\n\t\"us-west-1.vpce.amazonaws.com\":      \"Z12I86A8N7VCZO\",\n\t\"us-west-2.vpce.amazonaws.com\":      \"Z1YSA3EXCYUU9Z\",\n\t// AWS API Gateway (Regional endpoints)\n\t// See: https://docs.aws.amazon.com/general/latest/gr/apigateway.html\n\t\"execute-api.us-east-2.amazonaws.com\":      \"ZOJJZC49E0EPZ\",\n\t\"execute-api.us-east-1.amazonaws.com\":      \"Z1UJRXOUMOOFQ8\",\n\t\"execute-api.us-west-1.amazonaws.com\":      \"Z2MUQ32089INYE\",\n\t\"execute-api.us-west-2.amazonaws.com\":      \"Z2OJLYMUO9EFXC\",\n\t\"execute-api.af-south-1.amazonaws.com\":     \"Z2DHW2332DAMTN\",\n\t\"execute-api.ap-east-1.amazonaws.com\":      \"Z3FD1VL90ND7K5\",\n\t\"execute-api.ap-east-2.amazonaws.com\":      \"Z02909591O7FG9Q56HWB1\",\n\t\"execute-api.ap-south-1.amazonaws.com\":     \"Z3VO1THU9YC4UR\",\n\t\"execute-api.ap-northeast-2.amazonaws.com\": \"Z20JF4UZKIW1U8\",\n\t\"execute-api.ap-southeast-1.amazonaws.com\": \"ZL327KTPIQFUL\",\n\t\"execute-api.ap-southeast-2.amazonaws.com\": \"Z2RPCDW04V8134\",\n\t\"execute-api.ap-northeast-1.amazonaws.com\": \"Z1YSHQZHG15GKL\",\n\t\"execute-api.ca-central-1.amazonaws.com\":   \"Z19DQILCV0OWEC\",\n\t\"execute-api.eu-central-1.amazonaws.com\":   \"Z1U9ULNL0V5AJ3\",\n\t\"execute-api.eu-west-1.amazonaws.com\":      \"ZLY8HYME6SFDD\",\n\t\"execute-api.eu-west-2.amazonaws.com\":      \"ZJ5UAJN8Y3Z2Q\",\n\t\"execute-api.eu-south-1.amazonaws.com\":     \"Z3BT4WSQ9TDYZV\",\n\t\"execute-api.eu-west-3.amazonaws.com\":      \"Z3KY65QIEKYHQQ\",\n\t\"execute-api.eu-south-2.amazonaws.com\":     \"Z02499852UI5HEQ5JVWX3\",\n\t\"execute-api.eu-north-1.amazonaws.com\":     \"Z3UWIKFBOOGXPP\",\n\t\"execute-api.me-south-1.amazonaws.com\":     \"Z20ZBPC0SS8806\",\n\t\"execute-api.me-central-1.amazonaws.com\":   \"Z08780021BKYYY8U0YHTV\",\n\t\"execute-api.sa-east-1.amazonaws.com\":      \"ZCMLWB8V5SYIT\",\n\t\"execute-api.us-gov-east-1.amazonaws.com\":  \"Z3SE9ATJYCRCZJ\",\n\t\"execute-api.us-gov-west-1.amazonaws.com\":  \"Z1K6XKP9SAGWDV\",\n}\n\n// Route53API is the subset of the AWS Route53 API that we actually use.  Add methods as required. Signatures must match exactly.\n// https://github.com/aws/aws-sdk-go-v2/tree/main/service/route53\ntype Route53API interface {\n\tListResourceRecordSets(ctx context.Context, input *route53.ListResourceRecordSetsInput, optFns ...func(options *route53.Options)) (*route53.ListResourceRecordSetsOutput, error)\n\tChangeResourceRecordSets(ctx context.Context, input *route53.ChangeResourceRecordSetsInput, optFns ...func(options *route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error)\n\tCreateHostedZone(ctx context.Context, input *route53.CreateHostedZoneInput, optFns ...func(*route53.Options)) (*route53.CreateHostedZoneOutput, error)\n\tListHostedZones(ctx context.Context, input *route53.ListHostedZonesInput, optFns ...func(options *route53.Options)) (*route53.ListHostedZonesOutput, error)\n\tListTagsForResources(ctx context.Context, input *route53.ListTagsForResourcesInput, optFns ...func(options *route53.Options)) (*route53.ListTagsForResourcesOutput, error)\n}\n\n// Route53Change wrapper to handle ownership relation throughout the provider implementation\ntype Route53Change struct {\n\troute53types.Change\n\tOwnedRecord string\n\tsizeBytes   int\n\tsizeValues  int\n}\n\ntype Route53Changes []*Route53Change\n\ntype profiledZone struct {\n\tprofile string\n\tzone    *route53types.HostedZone\n}\n\ntype geoProximity struct {\n\tlocation *route53types.GeoProximityLocation\n\tendpoint *endpoint.Endpoint\n\tisSet    bool\n}\n\nfunc (cs Route53Changes) Route53Changes() []route53types.Change {\n\tvar ret []route53types.Change\n\tfor _, c := range cs {\n\t\tret = append(ret, c.Change)\n\t}\n\treturn ret\n}\n\ntype zoneTags map[string]map[string]string\n\n// filterZonesByTags filters the provided zones map by matching the tags against the provider's zoneTagFilter.\n// It removes any zones from the map that do not match the filter criteria.\nfunc (z zoneTags) filterZonesByTags(p *AWSProvider, zones map[string]*profiledZone) {\n\tfor zone, tags := range z {\n\t\tif !p.zoneTagFilter.Match(tags) {\n\t\t\tdelete(zones, zone)\n\t\t}\n\t}\n}\n\n// append adds tags to the ZoneTags for a given zoneID.\nfunc (z zoneTags) append(id string, tags []route53types.Tag) {\n\tzoneId := fmt.Sprintf(\"/hostedzone/%s\", id)\n\tif _, ok := z[zoneId]; !ok {\n\t\tz[zoneId] = make(map[string]string)\n\t}\n\tfor _, tag := range tags {\n\t\tz[zoneId][*tag.Key] = *tag.Value\n\t}\n}\n\n// AWSProvider is an implementation of Provider for AWS Route53.\ntype AWSProvider struct {\n\tprovider.BaseProvider\n\tclients               map[string]Route53API\n\tdryRun                bool\n\tbatchChangeSize       int\n\tbatchChangeSizeBytes  int\n\tbatchChangeSizeValues int\n\tbatchChangeInterval   time.Duration\n\tevaluateTargetHealth  bool\n\t// only consider hosted zones managing domains ending in this suffix\n\tdomainFilter *endpoint.DomainFilter\n\t// filter hosted zones by id\n\tzoneIDFilter provider.ZoneIDFilter\n\t// filter hosted zones by type (e.g. private or public)\n\tzoneTypeFilter provider.ZoneTypeFilter\n\t// filter hosted zones by tags\n\tzoneTagFilter provider.ZoneTagFilter\n\t// extend filter for subdomains in the zone (e.g. first.us-east-1.example.com)\n\tzoneMatchParent bool\n\tpreferCNAME     bool\n\tzonesCache      *blueprint.ZoneCache[map[string]*profiledZone]\n\t// queue for collecting changes to submit them in the next iteration, but after all other changes\n\tfailedChangesQueue map[string]Route53Changes\n}\n\n// AWSConfig contains configuration to create a new AWS provider.\ntype AWSConfig struct {\n\tDomainFilter          *endpoint.DomainFilter\n\tZoneIDFilter          provider.ZoneIDFilter\n\tZoneTypeFilter        provider.ZoneTypeFilter\n\tZoneTagFilter         provider.ZoneTagFilter\n\tZoneMatchParent       bool\n\tBatchChangeSize       int\n\tBatchChangeSizeBytes  int\n\tBatchChangeSizeValues int\n\tBatchChangeInterval   time.Duration\n\tEvaluateTargetHealth  bool\n\tPreferCNAME           bool\n\tDryRun                bool\n\tZoneCacheDuration     time.Duration\n}\n\n// New creates an AWS Route53 provider from the given configuration.\nfunc New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) {\n\tconfigs := CreateV2Configs(cfg)\n\tclients := make(map[string]Route53API, len(configs))\n\tfor profile, config := range configs {\n\t\tclients[profile] = route53.NewFromConfig(config)\n\t}\n\treturn newProvider(\n\t\tAWSConfig{\n\t\t\tDomainFilter:          domainFilter,\n\t\t\tZoneIDFilter:          provider.NewZoneIDFilter(cfg.ZoneIDFilter),\n\t\t\tZoneTypeFilter:        provider.NewZoneTypeFilter(cfg.AWSZoneType),\n\t\t\tZoneTagFilter:         provider.NewZoneTagFilter(cfg.AWSZoneTagFilter),\n\t\t\tZoneMatchParent:       cfg.AWSZoneMatchParent,\n\t\t\tBatchChangeSize:       cfg.AWSBatchChangeSize,\n\t\t\tBatchChangeSizeBytes:  cfg.AWSBatchChangeSizeBytes,\n\t\t\tBatchChangeSizeValues: cfg.AWSBatchChangeSizeValues,\n\t\t\tBatchChangeInterval:   cfg.AWSBatchChangeInterval,\n\t\t\tEvaluateTargetHealth:  cfg.AWSEvaluateTargetHealth,\n\t\t\tPreferCNAME:           cfg.AWSPreferCNAME,\n\t\t\tDryRun:                cfg.DryRun,\n\t\t\tZoneCacheDuration:     cfg.AWSZoneCacheDuration,\n\t\t},\n\t\tclients,\n\t), nil\n}\n\n// newProvider initializes a new AWS Route53 based Provider.\nfunc newProvider(cfg AWSConfig, clients map[string]Route53API) *AWSProvider {\n\tpr := &AWSProvider{\n\t\tclients:               clients,\n\t\tdomainFilter:          cfg.DomainFilter,\n\t\tzoneIDFilter:          cfg.ZoneIDFilter,\n\t\tzoneTypeFilter:        cfg.ZoneTypeFilter,\n\t\tzoneTagFilter:         cfg.ZoneTagFilter,\n\t\tzoneMatchParent:       cfg.ZoneMatchParent,\n\t\tbatchChangeSize:       cfg.BatchChangeSize,\n\t\tbatchChangeSizeBytes:  cfg.BatchChangeSizeBytes,\n\t\tbatchChangeSizeValues: cfg.BatchChangeSizeValues,\n\t\tbatchChangeInterval:   cfg.BatchChangeInterval,\n\t\tevaluateTargetHealth:  cfg.EvaluateTargetHealth,\n\t\tpreferCNAME:           cfg.PreferCNAME,\n\t\tdryRun:                cfg.DryRun,\n\t\tzonesCache:            blueprint.NewZoneCache[map[string]*profiledZone](cfg.ZoneCacheDuration),\n\t\tfailedChangesQueue:    make(map[string]Route53Changes),\n\t}\n\treturn pr\n}\n\n// Zones returns the list of hosted zones.\nfunc (p *AWSProvider) Zones(ctx context.Context) (map[string]*route53types.HostedZone, error) {\n\tzones, err := p.zones(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tresult := make(map[string]*route53types.HostedZone, len(zones))\n\tfor id, zone := range zones {\n\t\tresult[id] = zone.zone\n\t}\n\treturn result, nil\n}\n\n// zones returns the list of zones per AWS profile\nfunc (p *AWSProvider) zones(ctx context.Context) (map[string]*profiledZone, error) {\n\tif !p.zonesCache.Expired() {\n\t\tcachedZones := p.zonesCache.Get()\n\t\tlog.Debugf(\"Using cached AWS zones, zone count: %d.\", len(cachedZones))\n\t\treturn cachedZones, nil\n\t}\n\tlog.Debug(\"Retrieving AWS zones.\")\n\n\tzones := make(map[string]*profiledZone)\n\n\tfor profile, client := range p.clients {\n\t\tpaginator := route53.NewListHostedZonesPaginator(client, &route53.ListHostedZonesInput{})\n\n\t\tfor paginator.HasMorePages() {\n\t\t\tresp, err := paginator.NextPage(ctx)\n\t\t\tif err != nil {\n\t\t\t\tvar te *route53types.ThrottlingException\n\t\t\t\tif errors.As(err, &te) {\n\t\t\t\t\tlog.Infof(\"Skipping AWS profile %q due to provider side throttling: %v\", profile, te.ErrorMessage())\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\t// nothing to do here. Falling through to general error handling\n\t\t\t\treturn nil, provider.NewSoftErrorf(\"failed to list hosted zones: %w\", err)\n\t\t\t}\n\t\t\tvar zonesToTagFilter []string\n\t\t\tfor _, zone := range resp.HostedZones {\n\t\t\t\tif !p.zoneIDFilter.Match(*zone.Id) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tif !p.zoneTypeFilter.Match(zone) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tif !p.domainFilter.Match(*zone.Name) {\n\t\t\t\t\tif !p.zoneMatchParent {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tif !p.domainFilter.MatchParent(*zone.Name) {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif !p.zoneTagFilter.IsEmpty() {\n\t\t\t\t\tzonesToTagFilter = append(zonesToTagFilter, cleanZoneID(*zone.Id))\n\t\t\t\t}\n\n\t\t\t\tzones[*zone.Id] = &profiledZone{\n\t\t\t\t\tprofile: profile,\n\t\t\t\t\tzone:    &zone,\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif len(zonesToTagFilter) > 0 {\n\t\t\t\tif zTags, err := p.tagsForZone(ctx, zonesToTagFilter, profile); err != nil {\n\t\t\t\t\treturn nil, provider.NewSoftErrorf(\"failed to list tags for zones %w\", err)\n\t\t\t\t} else {\n\t\t\t\t\tzTags.filterZonesByTags(p, zones)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tif log.IsLevelEnabled(log.DebugLevel) {\n\t\tfor _, zone := range zones {\n\t\t\tlog.Debugf(\"Considering zone: %s (domain: %s)\", *zone.zone.Id, *zone.zone.Name)\n\t\t}\n\t}\n\n\tp.zonesCache.Reset(zones)\n\treturn zones, nil\n}\n\n// wildcardUnescape converts \\\\052.abc back to *.abc\n// Route53 stores wildcards escaped: http://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DomainNameFormat.html?shortFooter=true#domain-name-format-asterisk\nfunc wildcardUnescape(s string) string {\n\treturn strings.Replace(s, \"\\\\052\", \"*\", 1)\n}\n\n// See https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DomainNameFormat.html\n// convertOctalToAscii decodes inputs that contain octal escape sequences into their original ASCII characters.\n// The function returns converted string where any octal escape sequences have been replaced with their corresponding ASCII characters.\nfunc convertOctalToAscii(input string) string {\n\tif !containsOctalSequence(input) {\n\t\treturn input\n\t}\n\tresult, err := strconv.Unquote(\"\\\"\" + input + \"\\\"\")\n\tif err != nil {\n\t\treturn input\n\t}\n\treturn result\n}\n\n// validateDomainName checks if the domain name contains valid octal escape sequences.\nfunc containsOctalSequence(domain string) bool {\n\t// Pattern to match valid octal escape sequences\n\toctalEscapePattern := `\\\\[0-3][0-7]{2}`\n\toctalEscapeRegex := regexp.MustCompile(octalEscapePattern)\n\treturn octalEscapeRegex.MatchString(domain)\n}\n\n// Records returns the list of records in a given hosted zone.\nfunc (p *AWSProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {\n\tzones, err := p.zones(ctx)\n\tif err != nil {\n\t\treturn nil, provider.NewSoftErrorf(\"records retrieval failed: %v\", err)\n\t}\n\n\treturn p.records(ctx, zones)\n}\n\nfunc (p *AWSProvider) records(ctx context.Context, zones map[string]*profiledZone) ([]*endpoint.Endpoint, error) {\n\tendpoints := make([]*endpoint.Endpoint, 0)\n\n\tfor _, z := range zones {\n\t\tclient := p.clients[z.profile]\n\n\t\tpaginator := route53.NewListResourceRecordSetsPaginator(client, &route53.ListResourceRecordSetsInput{\n\t\t\tHostedZoneId: z.zone.Id,\n\t\t\tMaxItems:     aws.Int32(route53PageSize),\n\t\t})\n\n\t\tfor paginator.HasMorePages() {\n\t\t\tresp, err := paginator.NextPage(ctx)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, provider.NewSoftErrorf(\"failed to list resource records sets for zone %s using aws profile %q: %w\", *z.zone.Id, z.profile, err)\n\t\t\t}\n\n\t\t\tfor _, r := range resp.ResourceRecordSets {\n\t\t\t\tnewEndpoints := make([]*endpoint.Endpoint, 0)\n\n\t\t\t\tif !p.SupportedRecordType(r.Type) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tname := convertOctalToAscii(wildcardUnescape(*r.Name))\n\n\t\t\t\tvar ttl endpoint.TTL\n\t\t\t\tif r.TTL != nil {\n\t\t\t\t\tttl = endpoint.TTL(*r.TTL)\n\t\t\t\t}\n\n\t\t\t\tif len(r.ResourceRecords) > 0 {\n\t\t\t\t\ttargets := make([]string, len(r.ResourceRecords))\n\t\t\t\t\tfor idx, rr := range r.ResourceRecords {\n\t\t\t\t\t\ttargets[idx] = *rr.Value\n\t\t\t\t\t}\n\n\t\t\t\t\tep := endpoint.NewEndpointWithTTL(name, string(r.Type), ttl, targets...)\n\t\t\t\t\tif r.Type == endpoint.RecordTypeCNAME {\n\t\t\t\t\t\tep = ep.WithProviderSpecific(providerSpecificAlias, \"false\")\n\t\t\t\t\t}\n\t\t\t\t\tnewEndpoints = append(newEndpoints, ep)\n\t\t\t\t}\n\n\t\t\t\tif r.AliasTarget != nil {\n\t\t\t\t\t// Alias records don't have TTLs so provide the default to match the TXT generation\n\t\t\t\t\tif ttl == 0 {\n\t\t\t\t\t\tttl = defaultTTL\n\t\t\t\t\t}\n\t\t\t\t\tep := endpoint.\n\t\t\t\t\t\tNewEndpointWithTTL(name, string(r.Type), ttl, *r.AliasTarget.DNSName).\n\t\t\t\t\t\tWithProviderSpecific(providerSpecificEvaluateTargetHealth, fmt.Sprintf(\"%t\", r.AliasTarget.EvaluateTargetHealth)).\n\t\t\t\t\t\tWithProviderSpecific(providerSpecificAlias, \"true\")\n\t\t\t\t\tnewEndpoints = append(newEndpoints, ep)\n\t\t\t\t}\n\n\t\t\t\tfor _, ep := range newEndpoints {\n\t\t\t\t\tif r.SetIdentifier != nil {\n\t\t\t\t\t\tep.SetIdentifier = *r.SetIdentifier\n\t\t\t\t\t\tswitch {\n\t\t\t\t\t\tcase r.Weight != nil:\n\t\t\t\t\t\t\tep.WithProviderSpecific(providerSpecificWeight, fmt.Sprintf(\"%d\", *r.Weight))\n\t\t\t\t\t\tcase r.Region != \"\":\n\t\t\t\t\t\t\tep.WithProviderSpecific(providerSpecificRegion, string(r.Region))\n\t\t\t\t\t\tcase r.Failover != \"\":\n\t\t\t\t\t\t\tep.WithProviderSpecific(providerSpecificFailover, string(r.Failover))\n\t\t\t\t\t\tcase r.MultiValueAnswer != nil && *r.MultiValueAnswer:\n\t\t\t\t\t\t\tep.WithProviderSpecific(providerSpecificMultiValueAnswer, \"\")\n\t\t\t\t\t\tcase r.GeoLocation != nil:\n\t\t\t\t\t\t\tif r.GeoLocation.ContinentCode != nil {\n\t\t\t\t\t\t\t\tep.WithProviderSpecific(providerSpecificGeolocationContinentCode, *r.GeoLocation.ContinentCode)\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\tif r.GeoLocation.CountryCode != nil {\n\t\t\t\t\t\t\t\t\tep.WithProviderSpecific(providerSpecificGeolocationCountryCode, *r.GeoLocation.CountryCode)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tif r.GeoLocation.SubdivisionCode != nil {\n\t\t\t\t\t\t\t\t\tep.WithProviderSpecific(providerSpecificGeolocationSubdivisionCode, *r.GeoLocation.SubdivisionCode)\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\tcase r.GeoProximityLocation != nil:\n\t\t\t\t\t\t\thandleGeoProximityLocationRecord(&r, ep)\n\t\t\t\t\t\tdefault:\n\t\t\t\t\t\t\t// one of the above needs to be set, otherwise SetIdentifier doesn't make sense\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tif r.HealthCheckId != nil {\n\t\t\t\t\t\tep.WithProviderSpecific(providerSpecificHealthCheckID, *r.HealthCheckId)\n\t\t\t\t\t}\n\n\t\t\t\t\tendpoints = append(endpoints, ep)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn endpoints, nil\n}\n\nfunc handleGeoProximityLocationRecord(r *route53types.ResourceRecordSet, ep *endpoint.Endpoint) {\n\tif region := aws.ToString(r.GeoProximityLocation.AWSRegion); region != \"\" {\n\t\tep.WithProviderSpecific(providerSpecificGeoProximityLocationAWSRegion, region)\n\t}\n\n\tif bias := r.GeoProximityLocation.Bias; bias != nil {\n\t\tep.WithProviderSpecific(providerSpecificGeoProximityLocationBias, fmt.Sprintf(\"%d\", aws.ToInt32(bias)))\n\t}\n\n\tif coords := r.GeoProximityLocation.Coordinates; coords != nil {\n\t\tcoordinates := fmt.Sprintf(\"%s,%s\", aws.ToString(coords.Latitude), aws.ToString(coords.Longitude))\n\t\tep.WithProviderSpecific(providerSpecificGeoProximityLocationCoordinates, coordinates)\n\t}\n\n\tif localZoneGroup := aws.ToString(r.GeoProximityLocation.LocalZoneGroup); localZoneGroup != \"\" {\n\t\tep.WithProviderSpecific(providerSpecificGeoProximityLocationLocalZoneGroup, localZoneGroup)\n\t}\n}\n\n// Identify if old and new endpoints require DELETE/CREATE instead of UPDATE.\nfunc (p *AWSProvider) requiresDeleteCreate(old *endpoint.Endpoint, newE *endpoint.Endpoint) bool {\n\t// a change of a record type\n\tif old.RecordType != newE.RecordType {\n\t\treturn true\n\t}\n\n\t// an ALIAS record change to/from an A\n\tif old.RecordType == endpoint.RecordTypeA {\n\t\toldAlias, _ := old.GetProviderSpecificProperty(providerSpecificAlias)\n\t\tnewAlias, _ := newE.GetProviderSpecificProperty(providerSpecificAlias)\n\t\tif oldAlias != newAlias {\n\t\t\treturn true\n\t\t}\n\t}\n\n\t// a set identifier change\n\tif old.SetIdentifier != newE.SetIdentifier {\n\t\treturn true\n\t}\n\n\t// a change of routing policy\n\t// defaults to true for geolocation properties if any geolocation property exists in old/new but not the other\n\tfor _, propType := range [7]string{providerSpecificWeight, providerSpecificRegion, providerSpecificFailover,\n\t\tproviderSpecificFailover, providerSpecificGeolocationContinentCode, providerSpecificGeolocationCountryCode,\n\t\tproviderSpecificGeolocationSubdivisionCode} {\n\t\t_, oldPolicy := old.GetProviderSpecificProperty(propType)\n\t\t_, newPolicy := newE.GetProviderSpecificProperty(propType)\n\t\tif oldPolicy != newPolicy {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\nfunc (p *AWSProvider) createUpdateChanges(newEndpoints, oldEndpoints []*endpoint.Endpoint) Route53Changes {\n\tvar deletes []*endpoint.Endpoint\n\tvar creates []*endpoint.Endpoint\n\tvar updates []*endpoint.Endpoint\n\n\tfor i, newE := range newEndpoints {\n\t\tif i >= len(oldEndpoints) || oldEndpoints[i] == nil {\n\t\t\tlog.Debugf(\"skip %s as endpoint not found in current endpoints\", newE.DNSName)\n\t\t\tcontinue\n\t\t}\n\t\toldE := oldEndpoints[i]\n\t\tif p.requiresDeleteCreate(oldE, newE) {\n\t\t\tdeletes = append(deletes, oldE)\n\t\t\tcreates = append(creates, newE)\n\t\t} else {\n\t\t\t// Safe to perform an UPSERT.\n\t\t\tupdates = append(updates, newE)\n\t\t}\n\t}\n\n\tcombined := make(Route53Changes, 0, len(deletes)+len(creates)+len(updates))\n\tcombined = append(combined, p.newChanges(route53types.ChangeActionCreate, creates)...)\n\tcombined = append(combined, p.newChanges(route53types.ChangeActionUpsert, updates)...)\n\tcombined = append(combined, p.newChanges(route53types.ChangeActionDelete, deletes)...)\n\treturn combined\n}\n\n// GetDomainFilter generates a filter to exclude any domain that is not controlled by the provider\nfunc (p *AWSProvider) GetDomainFilter() endpoint.DomainFilterInterface {\n\tzones, err := p.Zones(context.Background())\n\tif err != nil {\n\t\tlog.Errorf(\"failed to list zones: %v\", err)\n\t\treturn &endpoint.DomainFilter{}\n\t}\n\tzoneNames := []string(nil)\n\tfor _, z := range zones {\n\t\tzoneNames = append(zoneNames, *z.Name, \".\"+*z.Name)\n\t}\n\tlog.Infof(\"Applying provider record filter for domains: %v\", zoneNames)\n\treturn endpoint.NewDomainFilter(zoneNames)\n}\n\n// ApplyChanges applies a given set of changes in a given zone.\nfunc (p *AWSProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {\n\tzones, err := p.zones(ctx)\n\tif err != nil {\n\t\treturn provider.NewSoftErrorf(\"failed to list zones, not applying changes: %w\", err)\n\t}\n\n\tupdateChanges := p.createUpdateChanges(changes.UpdateNew, changes.UpdateOld)\n\n\tcombinedChanges := make(Route53Changes, 0, len(changes.Delete)+len(changes.Create)+len(updateChanges))\n\tcombinedChanges = append(combinedChanges, p.newChanges(route53types.ChangeActionCreate, changes.Create)...)\n\tcombinedChanges = append(combinedChanges, p.newChanges(route53types.ChangeActionDelete, changes.Delete)...)\n\tcombinedChanges = append(combinedChanges, updateChanges...)\n\n\treturn p.submitChanges(ctx, combinedChanges, zones)\n}\n\n// submitChanges takes a zone and a collection of Changes and sends them as a single transaction.\nfunc (p *AWSProvider) submitChanges(ctx context.Context, changes Route53Changes, zones map[string]*profiledZone) error {\n\t// return early if there is nothing to change\n\tif len(changes) == 0 {\n\t\tlog.Info(\"All records are already up to date\")\n\t\treturn nil\n\t}\n\n\t// separate into per-zone change sets to be passed to the API.\n\tchangesByZone := changesByZone(zones, changes)\n\tif len(changesByZone) == 0 {\n\t\tlog.Info(\"All records are already up to date, there are no changes for the matching hosted zones\")\n\t}\n\n\tvar failedZones []string\n\tdebugLevel := log.DebugLevel\n\tfor z, cs := range changesByZone {\n\t\tlog := log.WithFields(log.Fields{\n\t\t\t\"zoneName\": *zones[z].zone.Name,\n\t\t\t\"zoneID\":   z,\n\t\t\t\"profile\":  zones[z].profile,\n\t\t})\n\n\t\tvar failedUpdate bool\n\n\t\t// group changes into new changes and into changes that failed in a previous iteration and are retried\n\t\tretriedChanges, newChanges := findChangesInQueue(cs, p.failedChangesQueue[z])\n\t\tp.failedChangesQueue[z] = nil\n\n\t\tbatchCs := append(batchChangeSet(newChanges, p.batchChangeSize, p.batchChangeSizeBytes, p.batchChangeSizeValues),\n\t\t\tbatchChangeSet(retriedChanges, p.batchChangeSize, p.batchChangeSizeBytes, p.batchChangeSizeValues)...)\n\t\tfor i, b := range batchCs {\n\t\t\tif len(b) == 0 {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tfor _, c := range b {\n\t\t\t\tlog.Infof(\"Desired change: %s %s %s\", c.Action, *c.ResourceRecordSet.Name, c.ResourceRecordSet.Type)\n\t\t\t}\n\n\t\t\tif p.dryRun {\n\t\t\t\tlog.Debug(\"Dry run mode, skipping change submission\")\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tparams := &route53.ChangeResourceRecordSetsInput{\n\t\t\t\tHostedZoneId: aws.String(z),\n\t\t\t\tChangeBatch: &route53types.ChangeBatch{\n\t\t\t\t\tChanges: b.Route53Changes(),\n\t\t\t\t},\n\t\t\t}\n\n\t\t\tsuccessfulChanges := 0\n\n\t\t\tclient := p.clients[zones[z].profile]\n\t\t\tif _, err := client.ChangeResourceRecordSets(ctx, params); err != nil {\n\t\t\t\tlog.Errorf(\"Failure in zone %s when submitting change batch: %v\", *zones[z].zone.Name, err)\n\n\t\t\t\tchangesByOwnership := groupChangesByNameAndOwnershipRelation(b)\n\n\t\t\t\tif len(changesByOwnership) > 1 {\n\t\t\t\t\tlog.Debug(\"Trying to submit change sets one-by-one instead\")\n\t\t\t\t\tfor _, changes := range changesByOwnership {\n\t\t\t\t\t\tif log.Logger.IsLevelEnabled(debugLevel) {\n\t\t\t\t\t\t\tfor _, c := range changes {\n\t\t\t\t\t\t\t\tlog.Debugf(\"Desired change: %s %s %s\", c.Action, *c.ResourceRecordSet.Name, c.ResourceRecordSet.Type)\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\tparams.ChangeBatch = &route53types.ChangeBatch{\n\t\t\t\t\t\t\tChanges: changes.Route53Changes(),\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif _, err := client.ChangeResourceRecordSets(ctx, params); err != nil {\n\t\t\t\t\t\t\tfailedUpdate = true\n\t\t\t\t\t\t\tlog.Errorf(\"Failed submitting change (error: %v), it will be retried in a separate change batch in the next iteration\", err)\n\t\t\t\t\t\t\tp.failedChangesQueue[z] = append(p.failedChangesQueue[z], changes...)\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tsuccessfulChanges += len(changes)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tfailedUpdate = true\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tsuccessfulChanges = len(b)\n\t\t\t}\n\n\t\t\tif successfulChanges > 0 {\n\t\t\t\t// z is the R53 Hosted Zone ID already as aws.StringValue\n\t\t\t\tlog.Infof(\"%d record(s) were successfully updated\", successfulChanges)\n\t\t\t}\n\n\t\t\tif i != len(batchCs)-1 {\n\t\t\t\ttime.Sleep(p.batchChangeInterval)\n\t\t\t}\n\t\t}\n\n\t\tif failedUpdate {\n\t\t\tfailedZones = append(failedZones, z)\n\t\t}\n\t}\n\n\tif len(failedZones) > 0 {\n\t\treturn provider.NewSoftErrorf(\"failed to submit all changes for the following zones: %v\", failedZones)\n\t}\n\n\treturn nil\n}\n\n// newChanges returns a collection of Changes based on the given records and action.\nfunc (p *AWSProvider) newChanges(action route53types.ChangeAction, endpoints []*endpoint.Endpoint) Route53Changes {\n\tchanges := make(Route53Changes, 0, len(endpoints))\n\n\tfor _, ep := range endpoints {\n\t\tchange := p.newChange(action, ep)\n\t\tchanges = append(changes, change)\n\t}\n\n\treturn changes\n}\n\n// AdjustEndpoints modifies the provided endpoints (coming from various sources) to match\n// the endpoints that the provider returns in `Records` so that the change plan will not have\n// unneeded (potentially failing) changes.\n// Example: CNAME endpoints pointing to ELBs will have a `alias` provider-specific property\n// added to match the endpoints generated from existing alias records in Route53.\nfunc (p *AWSProvider) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) {\n\t// Holds CNAME targets that we will treat as Alias records. Such records are\n\t// hard coded to 'A' type aliases but we also need their 'AAAA' counterparts.\n\tvar aliasCnameAaaaEndpoints []*endpoint.Endpoint\n\n\tfor _, ep := range endpoints {\n\t\tif aaaa := p.adjustEndpointAndNewAaaaIfNeeded(ep); aaaa != nil {\n\t\t\taliasCnameAaaaEndpoints = append(aliasCnameAaaaEndpoints, aaaa)\n\t\t}\n\t}\n\treturn append(endpoints, aliasCnameAaaaEndpoints...), nil\n}\n\nfunc (p *AWSProvider) adjustEndpointAndNewAaaaIfNeeded(ep *endpoint.Endpoint) *endpoint.Endpoint {\n\tvar aaaa *endpoint.Endpoint\n\tswitch ep.RecordType {\n\tcase endpoint.RecordTypeA, endpoint.RecordTypeAAAA:\n\t\tp.adjustAandAAAARecord(ep)\n\tcase endpoint.RecordTypeCNAME:\n\t\tp.adjustCNAMERecord(ep)\n\t\tadjustGeoProximityLocationEndpoint(ep)\n\t\tif isAlias, _ := ep.GetBoolProviderSpecificProperty(providerSpecificAlias); isAlias {\n\t\t\taaaa = ep.DeepCopy()\n\t\t\taaaa.RecordType = endpoint.RecordTypeAAAA\n\t\t}\n\t\treturn aaaa\n\tdefault:\n\t\tp.adjustOtherRecord(ep)\n\t}\n\tadjustGeoProximityLocationEndpoint(ep)\n\treturn aaaa\n}\n\nfunc (p *AWSProvider) adjustAliasRecord(ep *endpoint.Endpoint) {\n\tif ep.RecordTTL.IsConfigured() {\n\t\tlog.Debugf(\"Modifying endpoint: %v, setting ttl=%v\", ep, defaultTTL)\n\t\tep.RecordTTL = defaultTTL\n\t}\n\n\tif enable, exists := ep.GetBoolProviderSpecificProperty(providerSpecificEvaluateTargetHealth); exists {\n\t\t// normalize to string \"true\"/\"false\"\n\t\tep.SetProviderSpecificProperty(providerSpecificEvaluateTargetHealth, strconv.FormatBool(enable))\n\t} else {\n\t\t// if not set, use provider default\n\t\tep.SetProviderSpecificProperty(providerSpecificEvaluateTargetHealth, strconv.FormatBool(p.evaluateTargetHealth))\n\t}\n}\n\nfunc (p *AWSProvider) adjustAandAAAARecord(ep *endpoint.Endpoint) {\n\tisAlias, _ := ep.GetBoolProviderSpecificProperty(providerSpecificAlias)\n\tif isAlias {\n\t\tp.adjustAliasRecord(ep)\n\t} else {\n\t\tep.DeleteProviderSpecificProperty(providerSpecificAlias)\n\t\tep.DeleteProviderSpecificProperty(providerSpecificEvaluateTargetHealth)\n\t}\n}\n\nfunc (p *AWSProvider) adjustCNAMERecord(ep *endpoint.Endpoint) {\n\tisAlias, exists := ep.GetBoolProviderSpecificProperty(providerSpecificAlias)\n\n\t// fallback to determining alias based on preferCNAME if not explicitly set\n\tif !exists {\n\t\tisAlias = useAlias(ep, p.preferCNAME)\n\t\tlog.Debugf(\"Modifying endpoint: %v, setting %s=%v\", ep, providerSpecificAlias, isAlias)\n\t\tep.SetProviderSpecificProperty(providerSpecificAlias, strconv.FormatBool(isAlias))\n\t}\n\n\t// if not an alias, ensure alias properties are adjusted accordingly\n\tif !isAlias {\n\t\tif exists {\n\t\t\t// normalize to string \"false\" when provider specific alias is set to false or other non-true value\n\t\t\tep.SetProviderSpecificProperty(providerSpecificAlias, \"false\")\n\t\t}\n\t\tep.DeleteProviderSpecificProperty(providerSpecificEvaluateTargetHealth)\n\t}\n\n\t// if an alias, convert to A record and adjust alias properties\n\tif isAlias {\n\t\tep.RecordType = endpoint.RecordTypeA\n\t\tp.adjustAliasRecord(ep)\n\t}\n}\n\nfunc (p *AWSProvider) adjustOtherRecord(ep *endpoint.Endpoint) {\n\t// TODO: fix For records other than A, AAAA, and CNAME, if an alias record is set, the alias record processing is not performed.\n\t// This will be fixed in another PR.\n\tif isAlias, _ := ep.GetBoolProviderSpecificProperty(providerSpecificAlias); isAlias {\n\t\tp.adjustAliasRecord(ep)\n\t\tep.DeleteProviderSpecificProperty(providerSpecificAlias)\n\t} else {\n\t\tep.DeleteProviderSpecificProperty(providerSpecificAlias)\n\t\tep.DeleteProviderSpecificProperty(providerSpecificEvaluateTargetHealth)\n\t}\n}\n\n// if the endpoint is using geoproximity, set the bias to 0 if not set\n// this is needed to avoid unnecessary Upserts if the desired endpoint doesn't specify a bias\nfunc adjustGeoProximityLocationEndpoint(ep *endpoint.Endpoint) {\n\tif ep.SetIdentifier == \"\" {\n\t\treturn\n\t}\n\t_, ok1 := ep.GetProviderSpecificProperty(providerSpecificGeoProximityLocationAWSRegion)\n\t_, ok2 := ep.GetProviderSpecificProperty(providerSpecificGeoProximityLocationLocalZoneGroup)\n\t_, ok3 := ep.GetProviderSpecificProperty(providerSpecificGeoProximityLocationCoordinates)\n\n\tif ok1 || ok2 || ok3 {\n\t\t// check if ep has bias property and if not, set it to 0\n\t\tif _, ok := ep.GetProviderSpecificProperty(providerSpecificGeoProximityLocationBias); !ok {\n\t\t\tep.SetProviderSpecificProperty(providerSpecificGeoProximityLocationBias, \"0\")\n\t\t}\n\t}\n}\n\n// newChange returns a route53 Change\n// returned Change is based on the given record by the given action, e.g.\n// action=ChangeActionCreate returns a change for creation of the record and\n// action=ChangeActionDelete returns a change for deletion of the record.\nfunc (p *AWSProvider) newChange(action route53types.ChangeAction, ep *endpoint.Endpoint) *Route53Change {\n\tchange := &Route53Change{\n\t\tChange: route53types.Change{\n\t\t\tAction: action,\n\t\t\tResourceRecordSet: &route53types.ResourceRecordSet{\n\t\t\t\tName: aws.String(ep.DNSName),\n\t\t\t},\n\t\t},\n\t}\n\tchange.ResourceRecordSet.Type = route53types.RRType(ep.RecordType)\n\tif targetHostedZone := isAWSAlias(ep); targetHostedZone != \"\" {\n\t\tevalTargetHealth := p.evaluateTargetHealth\n\t\tif prop, exists := ep.GetBoolProviderSpecificProperty(providerSpecificEvaluateTargetHealth); exists {\n\t\t\tevalTargetHealth = prop\n\t\t}\n\t\tchange.ResourceRecordSet.AliasTarget = &route53types.AliasTarget{\n\t\t\tDNSName:              aws.String(ep.Targets[0]),\n\t\t\tHostedZoneId:         aws.String(cleanZoneID(targetHostedZone)),\n\t\t\tEvaluateTargetHealth: evalTargetHealth,\n\t\t}\n\t\tchange.sizeBytes += len([]byte(ep.Targets[0]))\n\t\tchange.sizeValues += 1\n\t} else {\n\t\tif !ep.RecordTTL.IsConfigured() {\n\t\t\tchange.ResourceRecordSet.TTL = aws.Int64(defaultTTL)\n\t\t} else {\n\t\t\tchange.ResourceRecordSet.TTL = aws.Int64(int64(ep.RecordTTL))\n\t\t}\n\t\tchange.ResourceRecordSet.ResourceRecords = make([]route53types.ResourceRecord, len(ep.Targets))\n\t\tfor idx, val := range ep.Targets {\n\t\t\tchange.ResourceRecordSet.ResourceRecords[idx] = route53types.ResourceRecord{\n\t\t\t\tValue: aws.String(val),\n\t\t\t}\n\t\t\tchange.sizeBytes += len([]byte(val))\n\t\t\tchange.sizeValues += 1\n\t\t}\n\t}\n\n\tif action == route53types.ChangeActionUpsert {\n\t\t// If the value of the Action element is UPSERT, each ResourceRecord element and each character in a Value\n\t\t// element is counted twice\n\t\tchange.sizeBytes *= 2\n\t\tchange.sizeValues *= 2\n\t}\n\n\tif ep.SetIdentifier != \"\" {\n\t\tchange.ResourceRecordSet.SetIdentifier = aws.String(ep.SetIdentifier)\n\t}\n\tif prop, ok := ep.GetProviderSpecificProperty(providerSpecificWeight); ok {\n\t\tweight, err := strconv.ParseInt(prop, 10, 64)\n\t\tif err != nil {\n\t\t\tlog.Errorf(\"Failed parsing value of %s: %s: %v; using weight of 0\", providerSpecificWeight, prop, err)\n\t\t\tweight = 0\n\t\t}\n\t\tchange.ResourceRecordSet.Weight = aws.Int64(weight)\n\t}\n\tif prop, ok := ep.GetProviderSpecificProperty(providerSpecificRegion); ok {\n\t\tchange.ResourceRecordSet.Region = route53types.ResourceRecordSetRegion(prop)\n\t}\n\tif prop, ok := ep.GetProviderSpecificProperty(providerSpecificFailover); ok {\n\t\tchange.ResourceRecordSet.Failover = route53types.ResourceRecordSetFailover(prop)\n\t}\n\tif _, ok := ep.GetProviderSpecificProperty(providerSpecificMultiValueAnswer); ok {\n\t\tchange.ResourceRecordSet.MultiValueAnswer = aws.Bool(true)\n\t}\n\n\tgeolocation := &route53types.GeoLocation{}\n\tuseGeolocation := false\n\tif prop, ok := ep.GetProviderSpecificProperty(providerSpecificGeolocationContinentCode); ok {\n\t\tgeolocation.ContinentCode = aws.String(prop)\n\t\tuseGeolocation = true\n\t} else {\n\t\tif prop, ok := ep.GetProviderSpecificProperty(providerSpecificGeolocationCountryCode); ok {\n\t\t\tgeolocation.CountryCode = aws.String(prop)\n\t\t\tuseGeolocation = true\n\t\t}\n\t\tif prop, ok := ep.GetProviderSpecificProperty(providerSpecificGeolocationSubdivisionCode); ok {\n\t\t\tgeolocation.SubdivisionCode = aws.String(prop)\n\t\t\tuseGeolocation = true\n\t\t}\n\t}\n\tif useGeolocation {\n\t\tchange.ResourceRecordSet.GeoLocation = geolocation\n\t}\n\n\twithChangeForGeoProximityEndpoint(change, ep)\n\n\tif prop, ok := ep.GetProviderSpecificProperty(providerSpecificHealthCheckID); ok {\n\t\tchange.ResourceRecordSet.HealthCheckId = aws.String(prop)\n\t}\n\n\tif ownedRecord, ok := ep.Labels[endpoint.OwnedRecordLabelKey]; ok {\n\t\tchange.OwnedRecord = ownedRecord\n\t}\n\n\treturn change\n}\n\nfunc newGeoProximity(ep *endpoint.Endpoint) *geoProximity {\n\treturn &geoProximity{\n\t\tlocation: &route53types.GeoProximityLocation{},\n\t\tendpoint: ep,\n\t\tisSet:    false,\n\t}\n}\n\nfunc (gp *geoProximity) withAWSRegion() *geoProximity {\n\tif prop, ok := gp.endpoint.GetProviderSpecificProperty(providerSpecificGeoProximityLocationAWSRegion); ok {\n\t\tgp.location.AWSRegion = aws.String(prop)\n\t\tgp.isSet = true\n\t}\n\treturn gp\n}\n\n// add a method to set the local zone group for the geoproximity location\nfunc (gp *geoProximity) withLocalZoneGroup() *geoProximity {\n\tif prop, ok := gp.endpoint.GetProviderSpecificProperty(providerSpecificGeoProximityLocationLocalZoneGroup); ok {\n\t\tgp.location.LocalZoneGroup = aws.String(prop)\n\t\tgp.isSet = true\n\t}\n\treturn gp\n}\n\n// add a method to set the bias for the geoproximity location\nfunc (gp *geoProximity) withBias() *geoProximity {\n\tif prop, ok := gp.endpoint.GetProviderSpecificProperty(providerSpecificGeoProximityLocationBias); ok {\n\t\tbias, err := strconv.ParseInt(prop, 10, 32)\n\t\tif err != nil {\n\t\t\tlog.Warnf(\"Failed parsing value of %s: %s: %v; using bias of 0\", providerSpecificGeoProximityLocationBias, prop, err)\n\t\t\tbias = 0\n\t\t}\n\t\tgp.location.Bias = aws.Int32(int32(bias))\n\t\tgp.isSet = true\n\t}\n\treturn gp\n}\n\n// validateCoordinates checks if the given latitude and longitude are valid.\nfunc validateCoordinates(lat, long string) error {\n\tlatitude, err := strconv.ParseFloat(lat, 64)\n\tif err != nil || latitude < minLatitude || latitude > maxLatitude {\n\t\treturn fmt.Errorf(\"invalid latitude: must be a number between %f and %f\", minLatitude, maxLatitude)\n\t}\n\n\tlongitude, err := strconv.ParseFloat(long, 64)\n\tif err != nil || longitude < minLongitude || longitude > maxLongitude {\n\t\treturn fmt.Errorf(\"invalid longitude: must be a number between %f and %f\", minLongitude, maxLongitude)\n\t}\n\n\treturn nil\n}\n\nfunc (gp *geoProximity) withCoordinates() *geoProximity {\n\tif prop, ok := gp.endpoint.GetProviderSpecificProperty(providerSpecificGeoProximityLocationCoordinates); ok {\n\t\tcoordinates := strings.Split(prop, \",\")\n\t\tif len(coordinates) == 2 {\n\t\t\tlatitude := coordinates[0]\n\t\t\tlongitude := coordinates[1]\n\t\t\tif err := validateCoordinates(latitude, longitude); err != nil {\n\t\t\t\tlog.Warnf(\"Invalid coordinates %s for name=%s setIdentifier=%s; %v\", prop, gp.endpoint.DNSName, gp.endpoint.SetIdentifier, err)\n\t\t\t} else {\n\t\t\t\tgp.location.Coordinates = &route53types.Coordinates{\n\t\t\t\t\tLatitude:  aws.String(latitude),\n\t\t\t\t\tLongitude: aws.String(longitude),\n\t\t\t\t}\n\t\t\t\tgp.isSet = true\n\t\t\t}\n\t\t} else {\n\t\t\tlog.Warnf(\"Invalid coordinates format for %s: %s; expected format 'latitude,longitude'\", providerSpecificGeoProximityLocationCoordinates, prop)\n\t\t}\n\t}\n\treturn gp\n}\n\nfunc (gp *geoProximity) build() *route53types.GeoProximityLocation {\n\tif gp.isSet {\n\t\treturn gp.location\n\t}\n\treturn nil\n}\n\nfunc withChangeForGeoProximityEndpoint(change *Route53Change, ep *endpoint.Endpoint) {\n\tgeoProx := newGeoProximity(ep).\n\t\twithAWSRegion().\n\t\twithCoordinates().\n\t\twithLocalZoneGroup().\n\t\twithBias()\n\n\tchange.ResourceRecordSet.GeoProximityLocation = geoProx.build()\n}\n\n// searches for `changes` that are contained in `queue` and returns the `changes` separated by whether they were found in the queue (`foundChanges`) or not (`notFoundChanges`)\nfunc findChangesInQueue(changes Route53Changes, queue Route53Changes) (Route53Changes, Route53Changes) {\n\tif queue == nil {\n\t\treturn Route53Changes{}, changes\n\t}\n\n\tvar foundChanges, notFoundChanges Route53Changes\n\n\tfor _, c := range changes {\n\t\tfound := false\n\t\tif slices.Contains(queue, c) {\n\t\t\tfoundChanges = append(foundChanges, c)\n\t\t\tfound = true\n\t\t}\n\t\tif !found {\n\t\t\tnotFoundChanges = append(notFoundChanges, c)\n\t\t}\n\t}\n\n\treturn foundChanges, notFoundChanges\n}\n\n// group the given changes by name and ownership relation to ensure these are always submitted in the same transaction to Route53;\n// grouping by name is done to always submit changes with the same name but different set identifier together,\n// grouping by ownership relation is done to always submit changes of records and e.g. their corresponding TXT registry records together\nfunc groupChangesByNameAndOwnershipRelation(cs Route53Changes) map[string]Route53Changes {\n\tchangesByOwnership := make(map[string]Route53Changes)\n\tfor _, v := range cs {\n\t\tkey := v.OwnedRecord\n\t\tif key == \"\" {\n\t\t\tkey = *v.ResourceRecordSet.Name\n\t\t}\n\t\tchangesByOwnership[key] = append(changesByOwnership[key], v)\n\t}\n\treturn changesByOwnership\n}\n\nfunc (p *AWSProvider) tagsForZone(ctx context.Context, zoneIDs []string, profile string) (zoneTags, error) {\n\tclient := p.clients[profile]\n\n\tresult := zoneTags{}\n\n\tfor i := 0; i < len(zoneIDs); i += batchSize {\n\t\tbatch := zoneIDs[i:min(i+batchSize, len(zoneIDs))]\n\t\tif len(batch) == 0 {\n\t\t\tbreak\n\t\t}\n\t\tresponse, err := client.ListTagsForResources(ctx, &route53.ListTagsForResourcesInput{\n\t\t\tResourceType: route53types.TagResourceTypeHostedzone,\n\t\t\tResourceIds:  batch,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn nil, provider.NewSoftErrorf(\"failed to list tags for zones. %v\", err)\n\t\t}\n\n\t\tfor _, res := range response.ResourceTagSets {\n\t\t\tresult.append(*res.ResourceId, res.Tags)\n\t\t}\n\t}\n\treturn result, nil\n}\n\n// count bytes for all changes values\nfunc countChangeBytes(cs Route53Changes) int {\n\tcount := 0\n\tfor _, c := range cs {\n\t\tcount += c.sizeBytes\n\t}\n\treturn count\n}\n\n// count total value count for all changes\nfunc countChangeValues(cs Route53Changes) int {\n\tcount := 0\n\tfor _, c := range cs {\n\t\tcount += c.sizeValues\n\t}\n\treturn count\n}\n\nfunc batchChangeSet(cs Route53Changes, batchSize int, batchSizeBytes int, batchSizeValues int) []Route53Changes {\n\tif len(cs) <= batchSize && countChangeBytes(cs) <= batchSizeBytes && countChangeValues(cs) <= batchSizeValues {\n\t\tres := sortChangesByActionNameType(cs)\n\t\treturn []Route53Changes{res}\n\t}\n\n\tbatchChanges := make([]Route53Changes, 0)\n\n\tchangesByOwnership := groupChangesByNameAndOwnershipRelation(cs)\n\n\tnames := make([]string, 0)\n\tfor v := range changesByOwnership {\n\t\tnames = append(names, v)\n\t}\n\tsort.Strings(names)\n\n\tcurrentBatch := Route53Changes{}\n\tfor k, name := range names {\n\t\tv := changesByOwnership[name]\n\t\tvBytes := countChangeBytes(v)\n\t\tvValues := countChangeValues(v)\n\t\tif len(v) > batchSize {\n\t\t\tlog.Warnf(\"Total changes for %v exceeds max batch size of %d, total changes: %d; changes will not be performed\", k, batchSize, len(v))\n\t\t\tcontinue\n\t\t}\n\t\tif vBytes > batchSizeBytes {\n\t\t\tlog.Warnf(\"Total changes for %v exceeds max batch size bytes of %d, total changes bytes: %d; changes will not be performed\", k, batchSizeBytes, vBytes)\n\t\t\tcontinue\n\t\t}\n\t\tif vValues > batchSizeValues {\n\t\t\tlog.Warnf(\"Total changes for %v exceeds max batch size values of %d, total changes values: %d; changes will not be performed\", k, batchSizeValues, vValues)\n\t\t\tcontinue\n\t\t}\n\n\t\tbytes := countChangeBytes(currentBatch) + vBytes\n\t\tvalues := countChangeValues(currentBatch) + vValues\n\n\t\tif len(currentBatch)+len(v) > batchSize || bytes > batchSizeBytes || values > batchSizeValues {\n\t\t\t// currentBatch would be too large if we add this changeset;\n\t\t\t// add currentBatch to batchChanges and start a new currentBatch\n\t\t\tbatchChanges = append(batchChanges, sortChangesByActionNameType(currentBatch))\n\t\t\tcurrentBatch = append(Route53Changes{}, v...)\n\t\t} else {\n\t\t\tcurrentBatch = append(currentBatch, v...)\n\t\t}\n\t}\n\tif len(currentBatch) > 0 {\n\t\t// add final currentBatch\n\t\tbatchChanges = append(batchChanges, sortChangesByActionNameType(currentBatch))\n\t}\n\n\treturn batchChanges\n}\n\nfunc sortChangesByActionNameType(cs Route53Changes) Route53Changes {\n\tsort.SliceStable(cs, func(i, j int) bool {\n\t\tif cs[i].Action > cs[j].Action {\n\t\t\treturn true\n\t\t}\n\t\tif cs[i].Action < cs[j].Action {\n\t\t\treturn false\n\t\t}\n\t\tif *cs[i].ResourceRecordSet.Name < *cs[j].ResourceRecordSet.Name {\n\t\t\treturn true\n\t\t}\n\t\tif *cs[i].ResourceRecordSet.Name > *cs[j].ResourceRecordSet.Name {\n\t\t\treturn false\n\t\t}\n\t\treturn cs[i].ResourceRecordSet.Type < cs[j].ResourceRecordSet.Type\n\t})\n\n\treturn cs\n}\n\n// changesByZone separates a multi-zone change into a single change per zone.\nfunc changesByZone(zones map[string]*profiledZone, changeSet Route53Changes) map[string]Route53Changes {\n\tchanges := make(map[string]Route53Changes)\n\n\tfor _, z := range zones {\n\t\tchanges[*z.zone.Id] = Route53Changes{}\n\t}\n\n\tfor _, c := range changeSet {\n\t\thostname := provider.EnsureTrailingDot(*c.ResourceRecordSet.Name)\n\n\t\tzones := suitableZones(hostname, zones)\n\t\tif len(zones) == 0 {\n\t\t\tlog.Debugf(\"Skipping record %s because no hosted zone matching record DNS Name was detected\", *c.ResourceRecordSet.Name)\n\t\t\tcontinue\n\t\t}\n\t\tfor _, z := range zones {\n\t\t\tif c.ResourceRecordSet.AliasTarget != nil && *c.ResourceRecordSet.AliasTarget.HostedZoneId == sameZoneAlias {\n\t\t\t\t// alias record is to be created; target needs to be in the same zone as endpoint\n\t\t\t\t// if it's not, this will fail\n\t\t\t\trrset := *c.ResourceRecordSet\n\t\t\t\taliasTarget := *rrset.AliasTarget\n\t\t\t\taliasTarget.HostedZoneId = aws.String(cleanZoneID(*z.zone.Id))\n\t\t\t\trrset.AliasTarget = &aliasTarget\n\t\t\t\tc = &Route53Change{\n\t\t\t\t\tChange: route53types.Change{\n\t\t\t\t\t\tAction:            c.Action,\n\t\t\t\t\t\tResourceRecordSet: &rrset,\n\t\t\t\t\t},\n\t\t\t\t}\n\t\t\t}\n\t\t\tchanges[*z.zone.Id] = append(changes[*z.zone.Id], c)\n\t\t\tlog.Debugf(\"Adding %s to zone %s [Id: %s]\", hostname, *z.zone.Name, *z.zone.Id)\n\t\t}\n\t}\n\n\t// separating a change could lead to empty sub changes, remove them here.\n\tfor zone, change := range changes {\n\t\tif len(change) == 0 {\n\t\t\tdelete(changes, zone)\n\t\t}\n\t}\n\n\treturn changes\n}\n\n// suitableZones returns all suitable private zones and the most suitable public zone\n//\n//\tfor a given hostname and a set of zones.\nfunc suitableZones(hostname string, zones map[string]*profiledZone) []*profiledZone {\n\tvar matchingZones []*profiledZone\n\tvar publicZone *profiledZone\n\n\tfor _, z := range zones {\n\t\tif *z.zone.Name == hostname || strings.HasSuffix(hostname, \".\"+*z.zone.Name) {\n\t\t\tif z.zone.Config == nil || !z.zone.Config.PrivateZone {\n\t\t\t\t// Only select the best matching public zone\n\t\t\t\tif publicZone == nil || len(*z.zone.Name) > len(*publicZone.zone.Name) {\n\t\t\t\t\tpublicZone = z\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Include all private zones\n\t\t\t\tmatchingZones = append(matchingZones, z)\n\t\t\t}\n\t\t}\n\t}\n\n\tif publicZone != nil {\n\t\tmatchingZones = append(matchingZones, publicZone)\n\t}\n\n\treturn matchingZones\n}\n\n// useAlias determines if AWS ALIAS should be used.\nfunc useAlias(ep *endpoint.Endpoint, preferCNAME bool) bool {\n\tif preferCNAME {\n\t\treturn false\n\t}\n\n\tif ep.RecordType == endpoint.RecordTypeCNAME && len(ep.Targets) > 0 {\n\t\treturn canonicalHostedZone(ep.Targets[0]) != \"\"\n\t}\n\n\treturn false\n}\n\n// isAWSAlias determines if a given endpoint is supposed to create an AWS Alias record\n// and (if so) returns the target hosted zone ID\nfunc isAWSAlias(ep *endpoint.Endpoint) string {\n\tisAlias, _ := ep.GetBoolProviderSpecificProperty(providerSpecificAlias)\n\tif isAlias && slices.Contains([]string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA}, ep.RecordType) && len(ep.Targets) > 0 {\n\t\t// alias records can only point to canonical hosted zones (e.g. to ELBs) or other records in the same zone\n\n\t\tif hostedZoneID, ok := ep.GetProviderSpecificProperty(providerSpecificTargetHostedZone); ok {\n\t\t\t// existing Endpoint where we got the target hosted zone from the Route53 data\n\t\t\treturn hostedZoneID\n\t\t}\n\n\t\t// check if the target is in a canonical hosted zone\n\t\tif canonicalHostedZone := canonicalHostedZone(ep.Targets[0]); canonicalHostedZone != \"\" {\n\t\t\treturn canonicalHostedZone\n\t\t}\n\n\t\t// if not, target needs to be in the same zone\n\t\treturn sameZoneAlias\n\t}\n\treturn \"\"\n}\n\n// canonicalHostedZone returns the matching canonical zone for a given hostname.\nfunc canonicalHostedZone(hostname string) string {\n\t// strings.HasSuffix is optimized for this specific task and avoids the overhead associated with compiling and executing a regular expression.\n\tif strings.HasSuffix(hostname, \"aws.com\") || strings.HasSuffix(hostname, \"aws.com.cn\") || strings.HasSuffix(hostname, \"tor.com\") || strings.HasSuffix(hostname, \"ont.com\") || strings.HasSuffix(hostname, \"ont.net\") {\n\t\tparts := strings.Split(hostname, \".\")\n\t\t// iterate from the second-last part (zone) towards the beginning\n\t\tfor i := len(parts) - 2; i >= 0; i-- {\n\t\t\tsuffix := strings.Join(parts[i:], \".\")\n\t\t\tif zone, exists := canonicalHostedZones[suffix]; exists {\n\t\t\t\treturn zone\n\t\t\t}\n\t\t}\n\t}\n\n\tif strings.HasSuffix(hostname, \".amazonaws.com\") {\n\t\t// hostname is an AWS hostname, but could not find canonical hosted zone.\n\t\t// This could mean that a new region has been added but is not supported yet.\n\t\tlog.Warnf(\"Could not find canonical hosted zone for domain %s. This may be because your region is not supported yet.\", hostname)\n\t}\n\n\treturn \"\"\n}\n\n// cleanZoneID removes the \"/hostedzone/\" prefix\nfunc cleanZoneID(id string) string {\n\treturn strings.TrimPrefix(id, \"/hostedzone/\")\n}\n\nfunc (p *AWSProvider) SupportedRecordType(recordType route53types.RRType) bool {\n\tswitch recordType {\n\tcase route53types.RRTypeMx, route53types.RRTypeNaptr:\n\t\treturn true\n\tdefault:\n\t\treturn provider.SupportedRecordType(string(recordType))\n\t}\n}\n"
  },
  {
    "path": "provider/aws/aws_fixtures_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage aws\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"testing\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/assert\"\n\n\tlogtest \"sigs.k8s.io/external-dns/internal/testutils/log\"\n)\n\nfunc TestAWSRecordsV1(t *testing.T) {\n\tvar zones HostedZones\n\tunmarshalZonesFixture(&zones, t)\n\n\tstub := NewRoute53APIFixtureStub(&zones)\n\tprovider := providerFilters(stub,\n\t\tWithZoneIDFilters(\n\t\t\t\"Z10242883PKPS38KA4S6C\", \"Z10295763LSQ170JCTR78\",\n\t\t\t\"Z102957NOTEXISTS\", \"Z09418121E8V6WT4FASZE\",\n\t\t),\n\t\tWithDomainFilters(\"w2.w1.ex.com\", \"ex.com\"),\n\t)\n\n\tctx := t.Context()\n\tz, err := provider.Zones(ctx)\n\tassert.NoError(t, err)\n\tassert.Len(t, z, 3)\n}\n\nfunc TestAWSZonesFilterWithTags(t *testing.T) {\n\tvar zones HostedZones\n\tunmarshalZonesFixture(&zones, t)\n\n\tstub := NewRoute53APIFixtureStub(&zones)\n\tprovider := providerFilters(stub,\n\t\tWithZoneTagFilters([]string{\"level=5\", \"owner=ext-dns\"}),\n\t)\n\n\tctx := t.Context()\n\tz, err := provider.Zones(ctx)\n\tassert.NoError(t, err)\n\tassert.Len(t, z, 24)\n\tassert.Equal(t, 17, stub.calls[\"listtagsforresource\"])\n}\n\nfunc TestAWSZonesFiltersWithTags(t *testing.T) {\n\ttests := []struct {\n\t\tfilters     []string\n\t\twant, calls int\n\t}{\n\t\t{[]string{\"owner=ext-dns\"}, 169, 17},\n\t\t{[]string{\"domain=n3.n2.n1.ex.com\"}, 1, 17},\n\t\t{[]string{\"parentdomain=n3.n2.n1.ex.com\"}, 1, 17},\n\t\t{[]string{\"vpcid=vpc-not-exists\"}, 0, 17},\n\t}\n\n\tfor _, tt := range tests {\n\t\ttName := fmt.Sprintf(\"filters=%s and zones=%d\", strings.Join(tt.filters, \",\"), tt.want)\n\t\tt.Run(tName, func(t *testing.T) {\n\t\t\tvar zones HostedZones\n\t\t\tunmarshalZonesFixture(&zones, t)\n\n\t\t\tstub := NewRoute53APIFixtureStub(&zones)\n\t\t\tprovider := providerFilters(stub,\n\t\t\t\tWithZoneTagFilters(tt.filters),\n\t\t\t)\n\t\t\tz, err := provider.Zones(t.Context())\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.Len(t, z, tt.want)\n\t\t\tassert.Equal(t, tt.calls, stub.calls[\"listtagsforresource\"])\n\t\t})\n\t}\n}\n\nfunc TestAWSZonesSecondRequestHitsTheCache(t *testing.T) {\n\tvar zones HostedZones\n\tunmarshalZonesFixture(&zones, t)\n\n\tstub := NewRoute53APIFixtureStub(&zones)\n\tprovider := providerFilters(stub)\n\n\tctx := t.Context()\n\t_, err := provider.Zones(ctx)\n\tassert.NoError(t, err)\n\thook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t)\n\t_, _ = provider.Zones(ctx)\n\n\tlogtest.TestHelperLogContainsWithLogLevel(\"Using cached AWS zones\", log.DebugLevel, hook, t)\n}\n"
  },
  {
    "path": "provider/aws/aws_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage aws\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"maps\"\n\t\"math\"\n\t\"net\"\n\t\"sort\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/aws/aws-sdk-go-v2/aws\"\n\t\"github.com/aws/aws-sdk-go-v2/service/route53\"\n\troute53types \"github.com/aws/aws-sdk-go-v2/service/route53/types\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"sigs.k8s.io/external-dns/provider/blueprint\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/internal/testutils\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\nconst (\n\tdefaultBatchChangeSize       = 4000\n\tdefaultBatchChangeSizeBytes  = 32000\n\tdefaultBatchChangeSizeValues = 1000\n\tdefaultBatchChangeInterval   = time.Second\n\tdefaultEvaluateTargetHealth  = true\n)\n\n// Compile time check for interface conformance\nvar _ Route53API = &Route53APIStub{}\n\n// Route53APIStub is a minimal implementation of Route53API, used primarily for unit testing.\n// See http://http://docs.aws.amazon.com/sdk-for-go/api/service/route53.html for descriptions\n// of all of its methods.\n// mostly taken from: https://github.com/kubernetes/kubernetes/blob/853167624edb6bc0cfdcdfb88e746e178f5db36c/federation/pkg/dnsprovider/providers/aws/route53/stubs/route53api.go\ntype Route53APIStub struct {\n\tzones      map[string]*route53types.HostedZone\n\trecordSets map[string]map[string][]route53types.ResourceRecordSet\n\tzoneTags   map[string][]route53types.Tag\n\tm          dynamicMock\n\tt          *testing.T\n}\n\n// MockMethod starts a description of an expectation of the specified method\n// being called.\n//\n//\tRoute53APIStub.MockMethod(\"MyMethod\", arg1, arg2)\nfunc (r *Route53APIStub) MockMethod(method string, args ...any) *mock.Call {\n\treturn r.m.On(method, args...)\n}\n\n// NewRoute53APIStub returns an initialized Route53APIStub\nfunc NewRoute53APIStub(t *testing.T) *Route53APIStub {\n\treturn &Route53APIStub{\n\t\tzones:      make(map[string]*route53types.HostedZone),\n\t\trecordSets: make(map[string]map[string][]route53types.ResourceRecordSet),\n\t\tzoneTags:   make(map[string][]route53types.Tag),\n\t\tt:          t,\n\t}\n}\n\nfunc (r *Route53APIStub) ListResourceRecordSets(ctx context.Context, input *route53.ListResourceRecordSetsInput, optFns ...func(options *route53.Options)) (*route53.ListResourceRecordSetsOutput, error) {\n\tif r.m.isMocked(\"ListResourceRecordSets\", input) {\n\t\treturn r.m.ListResourceRecordSets(ctx, input, optFns...)\n\t}\n\n\toutput := &route53.ListResourceRecordSetsOutput{} // TODO: Support optional input args.\n\trequire.NotNil(r.t, input.MaxItems)\n\tassert.Equal(r.t, route53PageSize, *input.MaxItems)\n\tif len(r.recordSets) == 0 {\n\t\toutput.ResourceRecordSets = []route53types.ResourceRecordSet{}\n\t} else if _, ok := r.recordSets[*input.HostedZoneId]; !ok {\n\t\toutput.ResourceRecordSets = []route53types.ResourceRecordSet{}\n\t} else {\n\t\tfor _, rrsets := range r.recordSets[*input.HostedZoneId] {\n\t\t\toutput.ResourceRecordSets = append(output.ResourceRecordSets, rrsets...)\n\t\t}\n\t}\n\treturn output, nil\n}\n\ntype Route53APICounter struct {\n\twrapped Route53API\n\tcalls   map[string]int\n}\n\nfunc NewRoute53APICounter(w Route53API) *Route53APICounter {\n\treturn &Route53APICounter{\n\t\twrapped: w,\n\t\tcalls:   map[string]int{},\n\t}\n}\n\nfunc (c *Route53APICounter) ListResourceRecordSets(ctx context.Context, input *route53.ListResourceRecordSetsInput, optFns ...func(options *route53.Options)) (*route53.ListResourceRecordSetsOutput, error) {\n\tc.calls[\"ListResourceRecordSetsPages\"]++\n\treturn c.wrapped.ListResourceRecordSets(ctx, input, optFns...)\n}\n\nfunc (c *Route53APICounter) ChangeResourceRecordSets(ctx context.Context, input *route53.ChangeResourceRecordSetsInput, optFns ...func(*route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error) {\n\tc.calls[\"ChangeResourceRecordSets\"]++\n\treturn c.wrapped.ChangeResourceRecordSets(ctx, input, optFns...)\n}\n\nfunc (c *Route53APICounter) CreateHostedZone(ctx context.Context, input *route53.CreateHostedZoneInput, optFns ...func(*route53.Options)) (*route53.CreateHostedZoneOutput, error) {\n\tc.calls[\"CreateHostedZone\"]++\n\treturn c.wrapped.CreateHostedZone(ctx, input, optFns...)\n}\n\nfunc (c *Route53APICounter) ListHostedZones(ctx context.Context, input *route53.ListHostedZonesInput, optFns ...func(options *route53.Options)) (*route53.ListHostedZonesOutput, error) {\n\tc.calls[\"ListHostedZonesPages\"]++\n\treturn c.wrapped.ListHostedZones(ctx, input, optFns...)\n}\n\nfunc (c *Route53APICounter) ListTagsForResources(ctx context.Context, input *route53.ListTagsForResourcesInput, optFns ...func(options *route53.Options)) (*route53.ListTagsForResourcesOutput, error) {\n\tc.calls[\"ListTagsForResource\"]++\n\treturn c.wrapped.ListTagsForResources(ctx, input, optFns...)\n}\n\n// Route53 stores wildcards escaped: http://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DomainNameFormat.html?shortFooter=true#domain-name-format-asterisk\nfunc wildcardEscape(s string) string {\n\tif strings.Contains(s, \"*\") {\n\t\ts = strings.Replace(s, \"*\", \"\\\\052\", 1)\n\t}\n\treturn s\n}\n\n// Route53 octal escapes https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DomainNameFormat.html\nfunc specialCharactersEscape(s string) string {\n\tvar result strings.Builder\n\tfor _, char := range s {\n\t\tif (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '-' || char == '.' {\n\t\t\tresult.WriteRune(char)\n\t\t} else {\n\t\t\toctalCode := fmt.Sprintf(\"\\\\%03o\", char)\n\t\t\tresult.WriteString(octalCode)\n\t\t}\n\t}\n\treturn result.String()\n}\n\nfunc (r *Route53APIStub) ListTagsForResources(_ context.Context, input *route53.ListTagsForResourcesInput, _ ...func(options *route53.Options)) (*route53.ListTagsForResourcesOutput, error) {\n\tif input.ResourceType == route53types.TagResourceTypeHostedzone {\n\t\tvar sets []route53types.ResourceTagSet\n\t\tfor _, el := range input.ResourceIds {\n\t\t\tzoneId := fmt.Sprintf(\"/%s/%s\", input.ResourceType, el)\n\t\t\tif strings.Contains(zoneId, \"ext-dns-test-error-on-list-tags\") {\n\t\t\t\treturn nil, fmt.Errorf(\"operation error Route53APIStub: ListTagsForResource\")\n\t\t\t}\n\n\t\t\tif r.zoneTags[zoneId] != nil {\n\t\t\t\tsets = append(sets, route53types.ResourceTagSet{\n\t\t\t\t\tResourceId:   &el,\n\t\t\t\t\tResourceType: route53types.TagResourceTypeHostedzone,\n\t\t\t\t\tTags:         r.zoneTags[zoneId],\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t\treturn &route53.ListTagsForResourcesOutput{ResourceTagSets: sets}, nil\n\t}\n\treturn &route53.ListTagsForResourcesOutput{}, nil\n}\n\nfunc (r *Route53APIStub) ChangeResourceRecordSets(_ context.Context, input *route53.ChangeResourceRecordSetsInput, _ ...func(options *route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error) {\n\tif r.m.isMocked(\"ChangeResourceRecordSets\", input) {\n\t\treturn r.m.ChangeResourceRecordSets(input)\n\t}\n\n\t_, ok := r.zones[*input.HostedZoneId]\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"hosted zone doesn't exist: %s\", *input.HostedZoneId)\n\t}\n\n\tif len(input.ChangeBatch.Changes) == 0 {\n\t\treturn nil, fmt.Errorf(\"ChangeBatch doesn't contain any changes\")\n\t}\n\n\toutput := &route53.ChangeResourceRecordSetsOutput{}\n\trecordSets, ok := r.recordSets[*input.HostedZoneId]\n\tif !ok {\n\t\trecordSets = make(map[string][]route53types.ResourceRecordSet)\n\t}\n\n\tfor _, change := range input.ChangeBatch.Changes {\n\t\tif change.ResourceRecordSet.Type == route53types.RRTypeA {\n\t\t\tfor _, rrs := range change.ResourceRecordSet.ResourceRecords {\n\t\t\t\tif net.ParseIP(*rrs.Value) == nil {\n\t\t\t\t\treturn nil, fmt.Errorf(\"A records must point to IPs\")\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tchange.ResourceRecordSet.Name = aws.String(wildcardEscape(provider.EnsureTrailingDot(*change.ResourceRecordSet.Name)))\n\n\t\tif change.ResourceRecordSet.AliasTarget != nil {\n\t\t\tchange.ResourceRecordSet.AliasTarget.DNSName = aws.String(wildcardEscape(provider.EnsureTrailingDot(*change.ResourceRecordSet.AliasTarget.DNSName)))\n\t\t}\n\n\t\tsetID := \"\"\n\t\tif change.ResourceRecordSet.SetIdentifier != nil {\n\t\t\tsetID = *change.ResourceRecordSet.SetIdentifier\n\t\t}\n\t\tkey := *change.ResourceRecordSet.Name + \"::\" + string(change.ResourceRecordSet.Type) + \"::\" + setID\n\t\tswitch change.Action {\n\t\tcase route53types.ChangeActionCreate:\n\t\t\tif _, found := recordSets[key]; found {\n\t\t\t\treturn nil, fmt.Errorf(\"attempt to create duplicate rrset %s\", key) // TODO: Return AWS errors with codes etc\n\t\t\t}\n\t\t\trecordSets[key] = append(recordSets[key], *change.ResourceRecordSet)\n\t\tcase route53types.ChangeActionDelete:\n\t\t\tif _, found := recordSets[key]; !found {\n\t\t\t\treturn nil, fmt.Errorf(\"attempt to delete non-existent rrset %s\", key) // TODO: Check other fields too\n\t\t\t}\n\t\t\tdelete(recordSets, key)\n\t\tcase route53types.ChangeActionUpsert:\n\t\t\trecordSets[key] = []route53types.ResourceRecordSet{*change.ResourceRecordSet}\n\t\t}\n\t}\n\tr.recordSets[*input.HostedZoneId] = recordSets\n\treturn output, nil // TODO: We should ideally return status etc, but we don't' use that yet.\n}\n\nfunc (r *Route53APIStub) ListHostedZones(_ context.Context, _ *route53.ListHostedZonesInput, _ ...func(options *route53.Options)) (*route53.ListHostedZonesOutput, error) {\n\toutput := &route53.ListHostedZonesOutput{}\n\tfor _, zone := range r.zones {\n\t\toutput.HostedZones = append(output.HostedZones, *zone)\n\t}\n\treturn output, nil\n}\n\nfunc (r *Route53APIStub) CreateHostedZone(_ context.Context, input *route53.CreateHostedZoneInput, _ ...func(options *route53.Options)) (*route53.CreateHostedZoneOutput, error) {\n\tname := *input.Name\n\tid := \"/hostedzone/\" + name\n\tif _, ok := r.zones[id]; ok {\n\t\treturn nil, fmt.Errorf(\"Error creating hosted DNS zone: %s already exists\", id)\n\t}\n\tr.zones[id] = &route53types.HostedZone{\n\t\tId:     aws.String(id),\n\t\tName:   aws.String(name),\n\t\tConfig: input.HostedZoneConfig,\n\t}\n\treturn &route53.CreateHostedZoneOutput{HostedZone: r.zones[id]}, nil\n}\n\ntype dynamicMock struct {\n\tmock.Mock\n}\n\nfunc (m *dynamicMock) ListResourceRecordSets(_ context.Context, input *route53.ListResourceRecordSetsInput, _ ...func(options *route53.Options)) (*route53.ListResourceRecordSetsOutput, error) {\n\targs := m.Called(input)\n\tif args.Get(0) != nil {\n\t\treturn args.Get(0).(*route53.ListResourceRecordSetsOutput), args.Error(1)\n\t}\n\treturn nil, args.Error(1)\n}\n\nfunc (m *dynamicMock) ChangeResourceRecordSets(input *route53.ChangeResourceRecordSetsInput) (*route53.ChangeResourceRecordSetsOutput, error) {\n\targs := m.Called(input)\n\tif args.Get(0) != nil {\n\t\treturn args.Get(0).(*route53.ChangeResourceRecordSetsOutput), args.Error(1)\n\t}\n\treturn nil, args.Error(1)\n}\n\nfunc (m *dynamicMock) isMocked(method string, arguments ...any) bool {\n\tfor _, call := range m.ExpectedCalls {\n\t\tif call.Method == method && call.Repeatability > -1 {\n\t\t\t_, diffCount := call.Arguments.Diff(arguments)\n\t\t\tif diffCount == 0 {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestAWSZones(t *testing.T) {\n\tpublicZones := map[string]*route53types.HostedZone{\n\t\t\"/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do.\": {\n\t\t\tId:   aws.String(\"/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tName: aws.String(\"zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t},\n\t\t\"/hostedzone/zone-2.ext-dns-test-2.teapot.zalan.do.\": {\n\t\t\tId:   aws.String(\"/hostedzone/zone-2.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tName: aws.String(\"zone-2.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t},\n\t}\n\n\tprivateZones := map[string]*route53types.HostedZone{\n\t\t\"/hostedzone/zone-3.ext-dns-test-2.teapot.zalan.do.\": {\n\t\t\tId:   aws.String(\"/hostedzone/zone-3.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tName: aws.String(\"zone-3.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t},\n\t}\n\n\tallZones := map[string]*route53types.HostedZone{}\n\tmaps.Copy(allZones, publicZones)\n\tmaps.Copy(allZones, privateZones)\n\n\tnoZones := map[string]*route53types.HostedZone{}\n\n\tfor _, ti := range []struct {\n\t\tmsg            string\n\t\tzoneIDFilter   provider.ZoneIDFilter\n\t\tzoneTypeFilter provider.ZoneTypeFilter\n\t\tzoneTagFilter  provider.ZoneTagFilter\n\t\texpectedZones  map[string]*route53types.HostedZone\n\t}{\n\t\t{\"no filter\", provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(\"\"), provider.NewZoneTagFilter([]string{}), allZones},\n\t\t{\"public filter\", provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(\"public\"), provider.NewZoneTagFilter([]string{}), publicZones},\n\t\t{\"private filter\", provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(\"private\"), provider.NewZoneTagFilter([]string{}), privateZones},\n\t\t{\"unknown filter\", provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(\"unknown\"), provider.NewZoneTagFilter([]string{}), noZones},\n\t\t{\"zone id filter\", provider.NewZoneIDFilter([]string{\"/hostedzone/zone-3.ext-dns-test-2.teapot.zalan.do.\"}), provider.NewZoneTypeFilter(\"\"), provider.NewZoneTagFilter([]string{}), privateZones},\n\t\t{\"tag filter zero zone match\", provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(\"\"), provider.NewZoneTagFilter([]string{\"zone=not-exists\"}), noZones},\n\t\t{\"tag filter single zone match\", provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(\"\"), provider.NewZoneTagFilter([]string{\"zone=3\"}), privateZones},\n\t} {\n\t\tt.Run(ti.msg, func(t *testing.T) {\n\t\t\tprovider, _ := newAWSProviderWithTagFilter(t, endpoint.NewDomainFilter([]string{\"ext-dns-test-2.teapot.zalan.do.\"}), ti.zoneIDFilter, ti.zoneTypeFilter, ti.zoneTagFilter, defaultEvaluateTargetHealth, false, false, nil)\n\t\t\tzones, err := provider.Zones(t.Context())\n\t\t\trequire.NoError(t, err)\n\t\t\tvalidateAWSZones(t, zones, ti.expectedZones)\n\t\t})\n\t}\n}\n\nfunc TestAWSZonesWithTagFilterError(t *testing.T) {\n\tclient := NewRoute53APIStub(t)\n\tprovider := &AWSProvider{\n\t\tclients:       map[string]Route53API{defaultAWSProfile: client},\n\t\tzoneTagFilter: provider.NewZoneTagFilter([]string{\"zone=2\"}),\n\t\tdryRun:        false,\n\t\tzonesCache:    blueprint.NewZoneCache[map[string]*profiledZone](1 * time.Minute),\n\t}\n\tcreateAWSZone(t, provider, &route53types.HostedZone{\n\t\tId:     aws.String(\"/hostedzone/zone-1.ext-dns-test-ok.example.com.\"),\n\t\tName:   aws.String(\"zone-1.ext-dns-test-ok.example.com.\"),\n\t\tConfig: &route53types.HostedZoneConfig{PrivateZone: false},\n\t})\n\tcreateAWSZone(t, provider, &route53types.HostedZone{\n\t\tId:     aws.String(\"/hostedzone/zone-2.ext-dns-test-error-on-list-tags.example.com.\"),\n\t\tName:   aws.String(\"zone-2.ext-dns-test-error-on-list-tags.example.com.\"),\n\t\tConfig: &route53types.HostedZoneConfig{PrivateZone: false},\n\t})\n\t_, err := provider.Zones(t.Context())\n\trequire.Error(t, err)\n\trequire.ErrorContains(t, err, \"failed to list tags for zones\")\n}\n\nfunc TestAWSRecordsFilter(t *testing.T) {\n\tprovider, _ := newAWSProvider(t, &endpoint.DomainFilter{}, provider.ZoneIDFilter{}, provider.ZoneTypeFilter{}, false, false, false, nil)\n\tdomainFilter := provider.GetDomainFilter()\n\trequire.NotNil(t, domainFilter)\n\trequire.IsType(t, &endpoint.DomainFilter{}, domainFilter)\n\tcount := 0\n\tfilters := domainFilter.(*endpoint.DomainFilter).Filters\n\tfor _, tld := range []string{\n\t\t\"zone-4.ext-dns-test-3.teapot.zalan.do\",\n\t\t\".zone-4.ext-dns-test-3.teapot.zalan.do\",\n\t\t\"zone-2.ext-dns-test-2.teapot.zalan.do\",\n\t\t\".zone-2.ext-dns-test-2.teapot.zalan.do\",\n\t\t\"zone-3.ext-dns-test-2.teapot.zalan.do\",\n\t\t\".zone-3.ext-dns-test-2.teapot.zalan.do\",\n\t\t\"zone-4.ext-dns-test-3.teapot.zalan.do\",\n\t\t\".zone-4.ext-dns-test-3.teapot.zalan.do\",\n\t} {\n\t\tassert.Contains(t, filters, tld)\n\t\tcount++\n\t}\n\tassert.Len(t, filters, count)\n}\n\nfunc TestAWSRecords(t *testing.T) {\n\tprovider, _ := newAWSProvider(t, endpoint.NewDomainFilter([]string{\"ext-dns-test-2.teapot.zalan.do.\"}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(\"\"), false, false, false, []route53types.ResourceRecordSet{\n\t\t{\n\t\t\tName:            aws.String(\"list-test.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType:            route53types.RRTypeA,\n\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"1.2.3.4\")}},\n\t\t},\n\t\t{\n\t\t\tName:            aws.String(\"list-test.zone-2.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType:            route53types.RRTypeA,\n\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"8.8.8.8\")}},\n\t\t},\n\t\t{\n\t\t\tName:            aws.String(wildcardEscape(\"*.wildcard-test.zone-2.ext-dns-test-2.teapot.zalan.do.\")),\n\t\t\tType:            route53types.RRTypeA,\n\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"8.8.8.8\")}},\n\t\t},\n\t\t{\n\t\t\tName:            aws.String(specialCharactersEscape(\"escape-%!s(<nil>)-codes.zone-2.ext-dns-test-2.teapot.zalan.do.\")),\n\t\t\tType:            route53types.RRTypeCname,\n\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"example\")}},\n\t\t},\n\t\t{\n\t\t\tName:            aws.String(specialCharactersEscape(\"escape-%!s(<nil>)-codes-a.zone-2.ext-dns-test-2.teapot.zalan.do.\")),\n\t\t\tType:            route53types.RRTypeA,\n\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"1.2.3.4\")}},\n\t\t},\n\t\t{\n\t\t\tName: aws.String(specialCharactersEscape(\"escape-%!s(<nil>)-codes-alias.zone-2.ext-dns-test-2.teapot.zalan.do.\")),\n\t\t\tType: route53types.RRTypeA,\n\t\t\tTTL:  aws.Int64(defaultTTL),\n\t\t\tAliasTarget: &route53types.AliasTarget{\n\t\t\t\tDNSName:              aws.String(\"escape-codes.eu-central-1.elb.amazonaws.com.\"),\n\t\t\t\tEvaluateTargetHealth: false,\n\t\t\t\tHostedZoneId:         aws.String(\"Z215JYRZR1TBD5\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName: aws.String(specialCharactersEscape(\"escape-%!s(<nil>)-codes-alias.zone-2.ext-dns-test-2.teapot.zalan.do.\")),\n\t\t\tType: route53types.RRTypeAaaa,\n\t\t\tTTL:  aws.Int64(defaultTTL),\n\t\t\tAliasTarget: &route53types.AliasTarget{\n\t\t\t\tDNSName:              aws.String(\"escape-codes.eu-central-1.elb.amazonaws.com.\"),\n\t\t\t\tEvaluateTargetHealth: false,\n\t\t\t\tHostedZoneId:         aws.String(\"Z215JYRZR1TBD5\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName: aws.String(\"list-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType: route53types.RRTypeA,\n\t\t\tAliasTarget: &route53types.AliasTarget{\n\t\t\t\tDNSName:              aws.String(\"foo.eu-central-1.elb.amazonaws.com.\"),\n\t\t\t\tEvaluateTargetHealth: false,\n\t\t\t\tHostedZoneId:         aws.String(\"Z215JYRZR1TBD5\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName: aws.String(\"list-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType: route53types.RRTypeAaaa,\n\t\t\tAliasTarget: &route53types.AliasTarget{\n\t\t\t\tDNSName:              aws.String(\"foo.eu-central-1.elb.amazonaws.com.\"),\n\t\t\t\tEvaluateTargetHealth: false,\n\t\t\t\tHostedZoneId:         aws.String(\"Z215JYRZR1TBD5\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName: aws.String(\"*.wildcard-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType: route53types.RRTypeA,\n\t\t\tAliasTarget: &route53types.AliasTarget{\n\t\t\t\tDNSName:              aws.String(\"foo.eu-central-1.elb.amazonaws.com.\"),\n\t\t\t\tEvaluateTargetHealth: false,\n\t\t\t\tHostedZoneId:         aws.String(\"Z215JYRZR1TBD5\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName: aws.String(\"*.wildcard-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType: route53types.RRTypeAaaa,\n\t\t\tAliasTarget: &route53types.AliasTarget{\n\t\t\t\tDNSName:              aws.String(\"foo.eu-central-1.elb.amazonaws.com.\"),\n\t\t\t\tEvaluateTargetHealth: false,\n\t\t\t\tHostedZoneId:         aws.String(\"Z215JYRZR1TBD5\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName: aws.String(\"list-test-alias-evaluate.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType: route53types.RRTypeA,\n\t\t\tAliasTarget: &route53types.AliasTarget{\n\t\t\t\tDNSName:              aws.String(\"foo.eu-central-1.elb.amazonaws.com.\"),\n\t\t\t\tEvaluateTargetHealth: true,\n\t\t\t\tHostedZoneId:         aws.String(\"Z215JYRZR1TBD5\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName: aws.String(\"list-test-alias-evaluate.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType: route53types.RRTypeAaaa,\n\t\t\tAliasTarget: &route53types.AliasTarget{\n\t\t\t\tDNSName:              aws.String(\"foo.eu-central-1.elb.amazonaws.com.\"),\n\t\t\t\tEvaluateTargetHealth: true,\n\t\t\t\tHostedZoneId:         aws.String(\"Z215JYRZR1TBD5\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:            aws.String(\"list-test-multiple.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType:            route53types.RRTypeA,\n\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"8.8.8.8\")}, {Value: aws.String(\"8.8.4.4\")}},\n\t\t},\n\t\t{\n\t\t\tName:            aws.String(\"prefix-*.wildcard.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType:            route53types.RRTypeTxt,\n\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"random\")}},\n\t\t},\n\t\t{\n\t\t\tName:            aws.String(\"weight-test.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType:            route53types.RRTypeA,\n\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"1.2.3.4\")}},\n\t\t\tSetIdentifier:   aws.String(\"test-set-1\"),\n\t\t\tWeight:          aws.Int64(10),\n\t\t},\n\t\t{\n\t\t\tName:            aws.String(\"weight-test.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType:            route53types.RRTypeA,\n\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"4.3.2.1\")}},\n\t\t\tSetIdentifier:   aws.String(\"test-set-2\"),\n\t\t\tWeight:          aws.Int64(20),\n\t\t},\n\t\t{\n\t\t\tName:            aws.String(\"latency-test.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType:            route53types.RRTypeA,\n\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"1.2.3.4\")}},\n\t\t\tSetIdentifier:   aws.String(\"test-set\"),\n\t\t\tRegion:          route53types.ResourceRecordSetRegionUsEast1,\n\t\t},\n\t\t{\n\t\t\tName:            aws.String(\"failover-test.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType:            route53types.RRTypeA,\n\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"1.2.3.4\")}},\n\t\t\tSetIdentifier:   aws.String(\"test-set\"),\n\t\t\tFailover:        route53types.ResourceRecordSetFailoverPrimary,\n\t\t},\n\t\t{\n\t\t\tName:             aws.String(\"multi-value-answer-test.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType:             route53types.RRTypeA,\n\t\t\tTTL:              aws.Int64(defaultTTL),\n\t\t\tResourceRecords:  []route53types.ResourceRecord{{Value: aws.String(\"1.2.3.4\")}},\n\t\t\tSetIdentifier:    aws.String(\"test-set\"),\n\t\t\tMultiValueAnswer: aws.Bool(true),\n\t\t},\n\t\t{\n\t\t\tName:            aws.String(\"geolocation-test.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType:            route53types.RRTypeA,\n\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"1.2.3.4\")}},\n\t\t\tSetIdentifier:   aws.String(\"test-set-1\"),\n\t\t\tGeoLocation: &route53types.GeoLocation{\n\t\t\t\tContinentCode: aws.String(\"EU\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:            aws.String(\"geolocation-test.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType:            route53types.RRTypeA,\n\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"4.3.2.1\")}},\n\t\t\tSetIdentifier:   aws.String(\"test-set-2\"),\n\t\t\tGeoLocation: &route53types.GeoLocation{\n\t\t\t\tCountryCode: aws.String(\"DE\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:            aws.String(\"geolocation-subdivision-test.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType:            route53types.RRTypeA,\n\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"1.2.3.4\")}},\n\t\t\tSetIdentifier:   aws.String(\"test-set-1\"),\n\t\t\tGeoLocation: &route53types.GeoLocation{\n\t\t\t\tSubdivisionCode: aws.String(\"NY\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:            aws.String(\"geoproximitylocation-region.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType:            route53types.RRTypeA,\n\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"1.2.3.4\")}},\n\t\t\tSetIdentifier:   aws.String(\"test-set-1\"),\n\t\t\tGeoProximityLocation: &route53types.GeoProximityLocation{\n\t\t\t\tAWSRegion: aws.String(\"us-west-2\"),\n\t\t\t\tBias:      aws.Int32(10),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:            aws.String(\"geoproximitylocation-localzone.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType:            route53types.RRTypeA,\n\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"1.2.3.4\")}},\n\t\t\tSetIdentifier:   aws.String(\"test-set-1\"),\n\t\t\tGeoProximityLocation: &route53types.GeoProximityLocation{\n\t\t\t\tLocalZoneGroup: aws.String(\"usw2-pdx1-az1\"),\n\t\t\t\tBias:           aws.Int32(10),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:            aws.String(\"geoproximitylocation-coordinates.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType:            route53types.RRTypeA,\n\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"1.2.3.4\")}},\n\t\t\tSetIdentifier:   aws.String(\"test-set-1\"),\n\t\t\tGeoProximityLocation: &route53types.GeoProximityLocation{\n\t\t\t\tCoordinates: &route53types.Coordinates{\n\t\t\t\t\tLatitude:  aws.String(\"90\"),\n\t\t\t\t\tLongitude: aws.String(\"90\"),\n\t\t\t\t},\n\t\t\t\tBias: aws.Int32(0),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:            aws.String(\"healthcheck-test.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType:            route53types.RRTypeCname,\n\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"foo.example.com\")}},\n\t\t\tSetIdentifier:   aws.String(\"test-set-1\"),\n\t\t\tHealthCheckId:   aws.String(\"foo-bar-healthcheck-id\"),\n\t\t\tWeight:          aws.Int64(10),\n\t\t},\n\t\t{\n\t\t\tName:            aws.String(\"healthcheck-test.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType:            route53types.RRTypeA,\n\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"4.3.2.1\")}},\n\t\t\tSetIdentifier:   aws.String(\"test-set-2\"),\n\t\t\tHealthCheckId:   aws.String(\"abc-def-healthcheck-id\"),\n\t\t\tWeight:          aws.Int64(20),\n\t\t},\n\t\t{\n\t\t\tName:            aws.String(\"mail.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType:            route53types.RRTypeMx,\n\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"10 mailhost1.example.com\")}, {Value: aws.String(\"20 mailhost2.example.com\")}},\n\t\t},\n\t\t{\n\t\t\tName:            aws.String(\"naptr.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType:            route53types.RRTypeNaptr,\n\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(`10 \"U\" \"SIP+DTU\" \"\" _sip._udp.sip1.example.com`)}, {Value: aws.String(`10 \"U\" \"SIPS+D2T\" \"\" _sips._tcp.sip1.example.com`)}},\n\t\t},\n\t})\n\n\trecords, err := provider.Records(t.Context())\n\trequire.NoError(t, err)\n\n\tvalidateEndpoints(t, provider, records, []*endpoint.Endpoint{\n\t\tendpoint.NewEndpointWithTTL(\"list-test.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), \"1.2.3.4\"),\n\t\tendpoint.NewEndpointWithTTL(\"list-test.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), \"8.8.8.8\"),\n\t\tendpoint.NewEndpointWithTTL(\"*.wildcard-test.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), \"8.8.8.8\"),\n\t\tendpoint.NewEndpointWithTTL(\"escape-%!s(<nil>)-codes.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeCNAME, endpoint.TTL(defaultTTL), \"example\").WithProviderSpecific(providerSpecificAlias, \"false\"),\n\t\tendpoint.NewEndpointWithTTL(\"escape-%!s(<nil>)-codes-a.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), \"1.2.3.4\"),\n\t\tendpoint.NewEndpointWithTTL(\"escape-%!s(<nil>)-codes-alias.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), \"escape-codes.eu-central-1.elb.amazonaws.com\").WithProviderSpecific(providerSpecificEvaluateTargetHealth, \"false\").WithProviderSpecific(providerSpecificAlias, \"true\"),\n\t\tendpoint.NewEndpointWithTTL(\"escape-%!s(<nil>)-codes-alias.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeAAAA, endpoint.TTL(defaultTTL), \"escape-codes.eu-central-1.elb.amazonaws.com\").WithProviderSpecific(providerSpecificEvaluateTargetHealth, \"false\").WithProviderSpecific(providerSpecificAlias, \"true\"),\n\t\tendpoint.NewEndpointWithTTL(\"list-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), \"foo.eu-central-1.elb.amazonaws.com\").WithProviderSpecific(providerSpecificEvaluateTargetHealth, \"false\").WithProviderSpecific(providerSpecificAlias, \"true\"),\n\t\tendpoint.NewEndpointWithTTL(\"list-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeAAAA, endpoint.TTL(defaultTTL), \"foo.eu-central-1.elb.amazonaws.com\").WithProviderSpecific(providerSpecificEvaluateTargetHealth, \"false\").WithProviderSpecific(providerSpecificAlias, \"true\"),\n\t\tendpoint.NewEndpointWithTTL(\"*.wildcard-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), \"foo.eu-central-1.elb.amazonaws.com\").WithProviderSpecific(providerSpecificEvaluateTargetHealth, \"false\").WithProviderSpecific(providerSpecificAlias, \"true\"),\n\t\tendpoint.NewEndpointWithTTL(\"*.wildcard-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeAAAA, endpoint.TTL(defaultTTL), \"foo.eu-central-1.elb.amazonaws.com\").WithProviderSpecific(providerSpecificEvaluateTargetHealth, \"false\").WithProviderSpecific(providerSpecificAlias, \"true\"),\n\t\tendpoint.NewEndpointWithTTL(\"list-test-alias-evaluate.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), \"foo.eu-central-1.elb.amazonaws.com\").WithProviderSpecific(providerSpecificEvaluateTargetHealth, \"true\").WithProviderSpecific(providerSpecificAlias, \"true\"),\n\t\tendpoint.NewEndpointWithTTL(\"list-test-alias-evaluate.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeAAAA, endpoint.TTL(defaultTTL), \"foo.eu-central-1.elb.amazonaws.com\").WithProviderSpecific(providerSpecificEvaluateTargetHealth, \"true\").WithProviderSpecific(providerSpecificAlias, \"true\"),\n\t\tendpoint.NewEndpointWithTTL(\"list-test-multiple.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), \"8.8.8.8\", \"8.8.4.4\"),\n\t\tendpoint.NewEndpointWithTTL(\"prefix-*.wildcard.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeTXT, endpoint.TTL(defaultTTL), \"random\"),\n\t\tendpoint.NewEndpointWithTTL(\"weight-test.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), \"1.2.3.4\").WithSetIdentifier(\"test-set-1\").WithProviderSpecific(providerSpecificWeight, \"10\"),\n\t\tendpoint.NewEndpointWithTTL(\"weight-test.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), \"4.3.2.1\").WithSetIdentifier(\"test-set-2\").WithProviderSpecific(providerSpecificWeight, \"20\"),\n\t\tendpoint.NewEndpointWithTTL(\"latency-test.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), \"1.2.3.4\").WithSetIdentifier(\"test-set\").WithProviderSpecific(providerSpecificRegion, \"us-east-1\"),\n\t\tendpoint.NewEndpointWithTTL(\"failover-test.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), \"1.2.3.4\").WithSetIdentifier(\"test-set\").WithProviderSpecific(providerSpecificFailover, \"PRIMARY\"),\n\t\tendpoint.NewEndpointWithTTL(\"multi-value-answer-test.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), \"1.2.3.4\").WithSetIdentifier(\"test-set\").WithProviderSpecific(providerSpecificMultiValueAnswer, \"\"),\n\t\tendpoint.NewEndpointWithTTL(\"geolocation-test.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), \"1.2.3.4\").WithSetIdentifier(\"test-set-1\").WithProviderSpecific(providerSpecificGeolocationContinentCode, \"EU\"),\n\t\tendpoint.NewEndpointWithTTL(\"geolocation-test.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), \"4.3.2.1\").WithSetIdentifier(\"test-set-2\").WithProviderSpecific(providerSpecificGeolocationCountryCode, \"DE\"),\n\t\tendpoint.NewEndpointWithTTL(\"geolocation-subdivision-test.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), \"1.2.3.4\").WithSetIdentifier(\"test-set-1\").WithProviderSpecific(providerSpecificGeolocationSubdivisionCode, \"NY\"),\n\t\tendpoint.NewEndpointWithTTL(\"geoproximitylocation-region.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), \"1.2.3.4\").WithSetIdentifier(\"test-set-1\").WithProviderSpecific(providerSpecificGeoProximityLocationAWSRegion, \"us-west-2\").WithProviderSpecific(providerSpecificGeoProximityLocationBias, \"10\"),\n\t\tendpoint.NewEndpointWithTTL(\"geoproximitylocation-localzone.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), \"1.2.3.4\").WithSetIdentifier(\"test-set-1\").WithProviderSpecific(providerSpecificGeoProximityLocationLocalZoneGroup, \"usw2-pdx1-az1\").WithProviderSpecific(providerSpecificGeoProximityLocationBias, \"10\"),\n\t\tendpoint.NewEndpointWithTTL(\"geoproximitylocation-coordinates.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), \"1.2.3.4\").WithSetIdentifier(\"test-set-1\").WithProviderSpecific(providerSpecificGeoProximityLocationCoordinates, \"90,90\").WithProviderSpecific(providerSpecificGeoProximityLocationBias, \"0\"),\n\t\tendpoint.NewEndpointWithTTL(\"healthcheck-test.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeCNAME, endpoint.TTL(defaultTTL), \"foo.example.com\").WithSetIdentifier(\"test-set-1\").WithProviderSpecific(providerSpecificWeight, \"10\").WithProviderSpecific(providerSpecificHealthCheckID, \"foo-bar-healthcheck-id\").WithProviderSpecific(providerSpecificAlias, \"false\"),\n\t\tendpoint.NewEndpointWithTTL(\"healthcheck-test.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), \"4.3.2.1\").WithSetIdentifier(\"test-set-2\").WithProviderSpecific(providerSpecificWeight, \"20\").WithProviderSpecific(providerSpecificHealthCheckID, \"abc-def-healthcheck-id\"),\n\t\tendpoint.NewEndpointWithTTL(\"mail.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeMX, endpoint.TTL(defaultTTL), \"10 mailhost1.example.com\", \"20 mailhost2.example.com\"),\n\t\tendpoint.NewEndpointWithTTL(\"naptr.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeNAPTR, endpoint.TTL(defaultTTL), `10 \"U\" \"SIP+DTU\" \"\" _sip._udp.sip1.example.com`, `10 \"U\" \"SIPS+D2T\" \"\" _sips._tcp.sip1.example.com`),\n\t})\n}\n\nfunc TestAWSRecordsSoftError(t *testing.T) {\n\tpvd, subClient := newAWSProvider(t, endpoint.NewDomainFilter([]string{\"ext-dns-test-2.teapot.zalan.do.\"}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(\"\"), false, false, false, []route53types.ResourceRecordSet{\n\t\t{\n\t\t\tName:            aws.String(\"list-test.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType:            route53types.RRTypeA,\n\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"1.2.3.4\")}},\n\t\t},\n\t})\n\n\tsubClient.MockMethod(\"ListResourceRecordSets\", mock.Anything).Return(nil, fmt.Errorf(\"Mock route53 failure\"))\n\t_, err := pvd.Records(t.Context())\n\trequire.Error(t, err)\n\trequire.ErrorIs(t, err, provider.SoftError)\n}\n\nfunc TestAWSAdjustEndpoints(t *testing.T) {\n\tprovider, _ := newAWSProvider(t, endpoint.NewDomainFilter([]string{\"ext-dns-test-2.teapot.zalan.do.\"}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(\"\"), defaultEvaluateTargetHealth, false, false, nil)\n\n\trecords := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"a-test.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"8.8.8.8\"),\n\t\tendpoint.NewEndpoint(\"cname-test.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeCNAME, \"foo.example.com\"),\n\t\tendpoint.NewEndpointWithTTL(\"cname-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, 60, \"alias-target.zone-2.ext-dns-test-2.teapot.zalan.do\").WithProviderSpecific(providerSpecificAlias, \"true\"),\n\t\tendpoint.NewEndpointWithTTL(\"cname-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeAAAA, 60, \"alias-target.zone-2.ext-dns-test-2.teapot.zalan.do\").WithProviderSpecific(providerSpecificAlias, \"true\"),\n\t\tendpoint.NewEndpoint(\"cname-test-elb.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeCNAME, \"foo.eu-central-1.elb.amazonaws.com\"),\n\t\tendpoint.NewEndpoint(\"cname-test-elb-no-alias.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeCNAME, \"foo.eu-central-1.elb.amazonaws.com\").WithProviderSpecific(providerSpecificAlias, \"false\"),\n\t\tendpoint.NewEndpoint(\"cname-test-elb-no-eth.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeCNAME, \"foo.eu-central-1.elb.amazonaws.com\").WithProviderSpecific(providerSpecificEvaluateTargetHealth, \"false\"), // eth = evaluate target health\n\t\tendpoint.NewEndpoint(\"cname-test-elb-alias.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeCNAME, \"foo.eu-central-1.elb.amazonaws.com\").WithProviderSpecific(providerSpecificAlias, \"true\").WithProviderSpecific(providerSpecificEvaluateTargetHealth, \"true\"),\n\t\tendpoint.NewEndpoint(\"a-test-geoproximity-no-bias.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"8.8.8.8\").WithSetIdentifier(\"test-set-1\").WithProviderSpecific(providerSpecificGeoProximityLocationAWSRegion, \"us-west-2\"),\n\t}\n\n\trecords, err := provider.AdjustEndpoints(records)\n\trequire.NoError(t, err)\n\n\tvalidateEndpoints(t, provider, records, []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"a-test.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"8.8.8.8\"),\n\t\tendpoint.NewEndpoint(\"cname-test.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeCNAME, \"foo.example.com\").WithProviderSpecific(providerSpecificAlias, \"false\"),\n\t\tendpoint.NewEndpointWithTTL(\"cname-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, 300, \"alias-target.zone-2.ext-dns-test-2.teapot.zalan.do\").WithProviderSpecific(providerSpecificAlias, \"true\").WithProviderSpecific(providerSpecificEvaluateTargetHealth, \"true\"),\n\t\tendpoint.NewEndpointWithTTL(\"cname-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeAAAA, 300, \"alias-target.zone-2.ext-dns-test-2.teapot.zalan.do\").WithProviderSpecific(providerSpecificAlias, \"true\").WithProviderSpecific(providerSpecificEvaluateTargetHealth, \"true\"),\n\t\tendpoint.NewEndpoint(\"cname-test-elb.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"foo.eu-central-1.elb.amazonaws.com\").WithProviderSpecific(providerSpecificAlias, \"true\").WithProviderSpecific(providerSpecificEvaluateTargetHealth, \"true\"),\n\t\tendpoint.NewEndpoint(\"cname-test-elb.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeAAAA, \"foo.eu-central-1.elb.amazonaws.com\").WithProviderSpecific(providerSpecificAlias, \"true\").WithProviderSpecific(providerSpecificEvaluateTargetHealth, \"true\"),\n\t\tendpoint.NewEndpoint(\"cname-test-elb-no-alias.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeCNAME, \"foo.eu-central-1.elb.amazonaws.com\").WithProviderSpecific(providerSpecificAlias, \"false\"),\n\t\tendpoint.NewEndpoint(\"cname-test-elb-no-eth.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"foo.eu-central-1.elb.amazonaws.com\").WithProviderSpecific(providerSpecificAlias, \"true\").WithProviderSpecific(providerSpecificEvaluateTargetHealth, \"false\"),    // eth = evaluate target health\n\t\tendpoint.NewEndpoint(\"cname-test-elb-no-eth.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeAAAA, \"foo.eu-central-1.elb.amazonaws.com\").WithProviderSpecific(providerSpecificAlias, \"true\").WithProviderSpecific(providerSpecificEvaluateTargetHealth, \"false\"), // eth = evaluate target health\n\t\tendpoint.NewEndpoint(\"cname-test-elb-alias.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"foo.eu-central-1.elb.amazonaws.com\").WithProviderSpecific(providerSpecificAlias, \"true\").WithProviderSpecific(providerSpecificEvaluateTargetHealth, \"true\"),\n\t\tendpoint.NewEndpoint(\"cname-test-elb-alias.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeAAAA, \"foo.eu-central-1.elb.amazonaws.com\").WithProviderSpecific(providerSpecificAlias, \"true\").WithProviderSpecific(providerSpecificEvaluateTargetHealth, \"true\"),\n\t\tendpoint.NewEndpoint(\"a-test-geoproximity-no-bias.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"8.8.8.8\").WithSetIdentifier(\"test-set-1\").WithProviderSpecific(providerSpecificGeoProximityLocationAWSRegion, \"us-west-2\").WithProviderSpecific(providerSpecificGeoProximityLocationBias, \"0\"),\n\t})\n}\n\nfunc TestAWSApplyChanges(t *testing.T) {\n\ttests := []struct {\n\t\tname       string\n\t\tsetup      func(p *AWSProvider) context.Context\n\t\tlistRRSets int\n\t}{\n\t\t{\"no cache\", func(_ *AWSProvider) context.Context { return t.Context() }, 0},\n\t\t{\"cached\", func(p *AWSProvider) context.Context {\n\t\t\tctx := t.Context()\n\t\t\trecords, err := p.Records(ctx)\n\t\t\trequire.NoError(t, err)\n\t\t\treturn context.WithValue(ctx, provider.RecordsContextKey, records)\n\t\t}, 0},\n\t}\n\n\tfor _, tt := range tests {\n\t\tprovider, _ := newAWSProvider(t, endpoint.NewDomainFilter([]string{\"ext-dns-test-2.teapot.zalan.do.\"}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(\"\"), defaultEvaluateTargetHealth, false, false, []route53types.ResourceRecordSet{\n\t\t\t{\n\t\t\t\tName:            aws.String(\"update-test.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeA,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"8.8.8.8\")}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"update-test-aaaa.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeAaaa,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"2606:4700:4700::1111\")}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"delete-test.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeA,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"8.8.8.8\")}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"delete-test-aaaa.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeAaaa,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"2606:4700:4700::1111\")}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"update-test.zone-2.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeA,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"8.8.4.4\")}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"update-test-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeAaaa,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"2606:4700:4700::1001\")}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"delete-test.zone-2.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeA,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"8.8.4.4\")}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"delete-test-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeAaaa,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"2606:4700:4700::1001\")}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"update-test-a-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeA,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"1.1.1.1\")}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName: aws.String(\"update-test-alias-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType: route53types.RRTypeA,\n\t\t\t\tAliasTarget: &route53types.AliasTarget{\n\t\t\t\t\tDNSName:              aws.String(\"foo.eu-central-1.elb.amazonaws.com.\"),\n\t\t\t\t\tEvaluateTargetHealth: true,\n\t\t\t\t\tHostedZoneId:         aws.String(\"Z215JYRZR1TBD5\"),\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName: aws.String(\"update-test-alias-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType: route53types.RRTypeAaaa,\n\t\t\t\tAliasTarget: &route53types.AliasTarget{\n\t\t\t\t\tDNSName:              aws.String(\"foo.eu-central-1.elb.amazonaws.com.\"),\n\t\t\t\t\tEvaluateTargetHealth: true,\n\t\t\t\t\tHostedZoneId:         aws.String(\"Z215JYRZR1TBD5\"),\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"update-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeCname,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"bar.elb.amazonaws.com\")}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName: aws.String(\"update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType: route53types.RRTypeA,\n\t\t\t\tAliasTarget: &route53types.AliasTarget{\n\t\t\t\t\tDNSName:              aws.String(\"bar.elb.amazonaws.com.\"),\n\t\t\t\t\tEvaluateTargetHealth: true,\n\t\t\t\t\tHostedZoneId:         aws.String(\"Z215JYRZR1TBD5\"),\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName: aws.String(\"update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType: route53types.RRTypeAaaa,\n\t\t\t\tAliasTarget: &route53types.AliasTarget{\n\t\t\t\t\tDNSName:              aws.String(\"bar.elb.amazonaws.com.\"),\n\t\t\t\t\tEvaluateTargetHealth: true,\n\t\t\t\t\tHostedZoneId:         aws.String(\"Z215JYRZR1TBD5\"),\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"delete-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeCname,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"qux.elb.amazonaws.com\")}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName: aws.String(\"delete-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType: route53types.RRTypeA,\n\t\t\t\tAliasTarget: &route53types.AliasTarget{\n\t\t\t\t\tDNSName:              aws.String(\"qux.elb.amazonaws.com.\"),\n\t\t\t\t\tEvaluateTargetHealth: true,\n\t\t\t\t\tHostedZoneId:         aws.String(\"Z215JYRZR1TBD5\"),\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName: aws.String(\"delete-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType: route53types.RRTypeAaaa,\n\t\t\t\tAliasTarget: &route53types.AliasTarget{\n\t\t\t\t\tDNSName:              aws.String(\"qux.elb.amazonaws.com.\"),\n\t\t\t\t\tEvaluateTargetHealth: true,\n\t\t\t\t\tHostedZoneId:         aws.String(\"Z215JYRZR1TBD5\"),\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"update-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeA,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"8.8.8.8\")}, {Value: aws.String(\"8.8.4.4\")}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"delete-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeA,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"1.2.3.4\")}, {Value: aws.String(\"4.3.2.1\")}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"delete-test-multiple-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeAaaa,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"2606:4700:4700::1111\")}, {Value: aws.String(\"2606:4700:4700::1001\")}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"delete-test-geoproximity.zone-2.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeA,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"1.2.3.4\")}},\n\t\t\t\tSetIdentifier:   aws.String(\"geoproximity-delete\"),\n\t\t\t\tGeoProximityLocation: &route53types.GeoProximityLocation{\n\t\t\t\t\tAWSRegion: aws.String(\"us-west-2\"),\n\t\t\t\t\tBias:      aws.Int32(10),\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"update-test-geoproximity.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeA,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"1.2.3.4\")}},\n\t\t\t\tSetIdentifier:   aws.String(\"geoproximity-update\"),\n\t\t\t\tGeoProximityLocation: &route53types.GeoProximityLocation{\n\t\t\t\t\tLocalZoneGroup: aws.String(\"usw2-lax1-az2\"),\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"weighted-to-simple.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeA,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"1.2.3.4\")}},\n\t\t\t\tSetIdentifier:   aws.String(\"weighted-to-simple\"),\n\t\t\t\tWeight:          aws.Int64(10),\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"simple-to-weighted.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeA,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"1.2.3.4\")}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"policy-change.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeA,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"1.2.3.4\")}},\n\t\t\t\tSetIdentifier:   aws.String(\"policy-change\"),\n\t\t\t\tWeight:          aws.Int64(10),\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"set-identifier-change.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeA,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"1.2.3.4\")}},\n\t\t\t\tSetIdentifier:   aws.String(\"before\"),\n\t\t\t\tWeight:          aws.Int64(10),\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"set-identifier-no-change.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeA,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"1.2.3.4\")}},\n\t\t\t\tSetIdentifier:   aws.String(\"no-change\"),\n\t\t\t\tWeight:          aws.Int64(10),\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"update-test-mx.zone-2.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeMx,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"10 mailhost2.bar.elb.amazonaws.com\")}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"delete-test-mx.zone-2.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeMx,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"30 mailhost1.foo.elb.amazonaws.com\")}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(specialCharactersEscape(\"escape-%!s(<nil>)-codes.zone-2.ext-dns-test-2.teapot.zalan.do.\")),\n\t\t\t\tType:            route53types.RRTypeA,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"1.2.3.4\")}},\n\t\t\t\tSetIdentifier:   aws.String(\"no-change\"),\n\t\t\t\tWeight:          aws.Int64(10),\n\t\t\t},\n\t\t\t{\n\t\t\t\tName: aws.String(\"delete-test-naptr.zone-2.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType: route53types.RRTypeNaptr,\n\t\t\t\tTTL:  aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{\n\t\t\t\t\t{Value: aws.String(`10 \"U\" \"SIP+DTU\" \"\" _sip._udp.sip1.example.com.`)},\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"update-test-naptr.zone-2.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeNaptr,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(`10 \"U\" \"SIP+DTU\" \"\" _sip._udp.sip1.example.com.`)}},\n\t\t\t},\n\t\t})\n\n\t\tcreateRecords := []*endpoint.Endpoint{\n\t\t\tendpoint.NewEndpoint(\"create-test.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"8.8.8.8\"),\n\t\t\tendpoint.NewEndpoint(\"create-test.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"8.8.4.4\"),\n\t\t\tendpoint.NewEndpoint(\"create-test-aaaa.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeAAAA, \"2606:4700:4700::1111\"),\n\t\t\tendpoint.NewEndpoint(\"create-test-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeAAAA, \"2606:4700:4700::1001\"),\n\t\t\tendpoint.NewEndpoint(\"create-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeCNAME, \"foo.elb.amazonaws.com\"),\n\t\t\tendpoint.NewEndpoint(\"create-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeCNAME, \"foo.elb.amazonaws.com\"),\n\t\t\tendpoint.NewEndpoint(\"create-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"8.8.8.8\", \"8.8.4.4\"),\n\t\t\tendpoint.NewEndpoint(\"create-test-multiple-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeAAAA, \"2606:4700:4700::1111\", \"2606:4700:4700::1001\"),\n\t\t\tendpoint.NewEndpoint(\"create-test-mx.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeMX, \"10 mailhost1.foo.elb.amazonaws.com\"),\n\t\t\tendpoint.NewEndpoint(\"create-test-naptr.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeNAPTR, `10 \"U\" \"SIP+DTU\" \"\" _sip._udp.sip1.example.com.`),\n\t\t\tendpoint.NewEndpoint(\"create-test-geoproximity-region.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"8.8.8.8\").\n\t\t\t\tWithSetIdentifier(\"geoproximity-region\").\n\t\t\t\tWithProviderSpecific(providerSpecificGeoProximityLocationAWSRegion, \"us-west-2\").\n\t\t\t\tWithProviderSpecific(providerSpecificGeoProximityLocationBias, \"10\"),\n\t\t\tendpoint.NewEndpoint(\"create-test-geoproximity-coordinates.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"8.8.8.8\").\n\t\t\t\tWithSetIdentifier(\"geoproximity-coordinates\").\n\t\t\t\tWithProviderSpecific(providerSpecificGeoProximityLocationCoordinates, \"60,60\"),\n\t\t}\n\n\t\tcurrentRecords := []*endpoint.Endpoint{\n\t\t\tendpoint.NewEndpoint(\"update-test.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"8.8.8.8\"),\n\t\t\tendpoint.NewEndpoint(\"update-test.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"8.8.4.4\"),\n\t\t\tendpoint.NewEndpoint(\"update-test-aaaa.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeAAAA, \"2606:4700:4700::1111\"),\n\t\t\tendpoint.NewEndpoint(\"update-test-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeAAAA, \"2606:4700:4700::1001\"),\n\t\t\tendpoint.NewEndpoint(\"update-test-a-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"1.1.1.1\"),\n\t\t\tendpoint.NewEndpoint(\"update-test-alias-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"foo.eu-central-1.elb.amazonaws.com\").WithProviderSpecific(providerSpecificAlias, \"true\"),\n\t\t\tendpoint.NewEndpoint(\"update-test-alias-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeAAAA, \"foo.eu-central-1.elb.amazonaws.com\").WithProviderSpecific(providerSpecificAlias, \"true\"),\n\t\t\tendpoint.NewEndpoint(\"update-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeCNAME, \"bar.elb.amazonaws.com\"),\n\t\t\tendpoint.NewEndpoint(\"update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"bar.elb.amazonaws.com\").WithProviderSpecific(providerSpecificAlias, \"true\"),\n\t\t\tendpoint.NewEndpoint(\"update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeAAAA, \"bar.elb.amazonaws.com\").WithProviderSpecific(providerSpecificAlias, \"true\"),\n\t\t\tendpoint.NewEndpoint(\"update-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"8.8.8.8\", \"8.8.4.4\"),\n\t\t\tendpoint.NewEndpoint(\"update-test-multiple-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeAAAA, \"2606:4700:4700::1111\", \"2606:4700:4700::1001\"),\n\t\t\tendpoint.NewEndpoint(\"update-test-geoproximity.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"1.2.3.4\").\n\t\t\t\tWithSetIdentifier(\"geoproximity-update\").\n\t\t\t\tWithProviderSpecific(providerSpecificGeoProximityLocationLocalZoneGroup, \"usw2-lax1-az2\"),\n\t\t\tendpoint.NewEndpoint(\"weighted-to-simple.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"1.2.3.4\").WithSetIdentifier(\"weighted-to-simple\").WithProviderSpecific(providerSpecificWeight, \"10\"),\n\t\t\tendpoint.NewEndpoint(\"simple-to-weighted.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"1.2.3.4\"),\n\t\t\tendpoint.NewEndpoint(\"policy-change.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"1.2.3.4\").WithSetIdentifier(\"policy-change\").WithProviderSpecific(providerSpecificWeight, \"10\"),\n\t\t\tendpoint.NewEndpoint(\"set-identifier-change.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"1.2.3.4\").WithSetIdentifier(\"before\").WithProviderSpecific(providerSpecificWeight, \"10\"),\n\t\t\tendpoint.NewEndpoint(\"set-identifier-no-change.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"1.2.3.4\").WithSetIdentifier(\"no-change\").WithProviderSpecific(providerSpecificWeight, \"10\"),\n\t\t\tendpoint.NewEndpoint(\"update-test-mx.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeMX, \"10 mailhost2.bar.elb.amazonaws.com\"),\n\t\t\tendpoint.NewEndpoint(\"update-test-naptr.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeNAPTR, `10 \"U\" \"SIP+DTU\" \"\" _sip._udp.sip1.example.com.`),\n\t\t\tendpoint.NewEndpoint(\"escape-%!s(<nil>)-codes.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"1.2.3.4\").WithSetIdentifier(\"policy-change\").WithSetIdentifier(\"no-change\").WithProviderSpecific(providerSpecificWeight, \"10\"),\n\t\t}\n\t\tupdatedRecords := []*endpoint.Endpoint{\n\t\t\tendpoint.NewEndpoint(\"update-test.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"1.2.3.4\"),\n\t\t\tendpoint.NewEndpoint(\"update-test.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"4.3.2.1\"),\n\t\t\tendpoint.NewEndpoint(\"update-test-aaaa.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeAAAA, \"2606:4700:4700::1001\"),\n\t\t\tendpoint.NewEndpoint(\"update-test-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeAAAA, \"2606:4700:4700::1111\"),\n\t\t\tendpoint.NewEndpoint(\"update-test-a-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"foo.elb.amazonaws.com\").WithProviderSpecific(providerSpecificAlias, \"true\"),\n\t\t\tendpoint.NewEndpoint(\"update-test-a-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeAAAA, \"foo.elb.amazonaws.com\").WithProviderSpecific(providerSpecificAlias, \"true\"),\n\t\t\tendpoint.NewEndpoint(\"update-test-alias-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeCNAME, \"my-internal-host.example.com\"),\n\t\t\tendpoint.NewEndpoint(\"update-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeCNAME, \"baz.elb.amazonaws.com\"),\n\t\t\tendpoint.NewEndpoint(\"update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"baz.elb.amazonaws.com\").WithProviderSpecific(providerSpecificAlias, \"true\"),\n\t\t\tendpoint.NewEndpoint(\"update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeAAAA, \"baz.elb.amazonaws.com\").WithProviderSpecific(providerSpecificAlias, \"true\"),\n\t\t\tendpoint.NewEndpoint(\"update-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"1.2.3.4\", \"4.3.2.1\"),\n\t\t\tendpoint.NewEndpoint(\"update-test-multiple-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeAAAA, \"2606:4700:4700::1001\", \"2606:4700:4700::1111\"),\n\t\t\tendpoint.NewEndpoint(\"update-test-geoproximity.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"1.2.3.4\").\n\t\t\t\tWithSetIdentifier(\"geoproximity-update\").\n\t\t\t\tWithProviderSpecific(providerSpecificGeoProximityLocationLocalZoneGroup, \"usw2-phx2-az1\"),\n\t\t\tendpoint.NewEndpoint(\"weighted-to-simple.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"1.2.3.4\"),\n\t\t\tendpoint.NewEndpoint(\"simple-to-weighted.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"1.2.3.4\").WithSetIdentifier(\"simple-to-weighted\").WithProviderSpecific(providerSpecificWeight, \"10\"),\n\t\t\tendpoint.NewEndpoint(\"policy-change.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"1.2.3.4\").WithSetIdentifier(\"policy-change\").WithProviderSpecific(providerSpecificRegion, \"us-east-1\"),\n\t\t\tendpoint.NewEndpoint(\"set-identifier-change.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"1.2.3.4\").WithSetIdentifier(\"after\").WithProviderSpecific(providerSpecificWeight, \"10\"),\n\t\t\tendpoint.NewEndpoint(\"set-identifier-no-change.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"1.2.3.4\").WithSetIdentifier(\"no-change\").WithProviderSpecific(providerSpecificWeight, \"20\"),\n\t\t\tendpoint.NewEndpoint(\"update-test-mx.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeMX, \"20 mailhost3.foo.elb.amazonaws.com\"),\n\t\t\tendpoint.NewEndpoint(\"update-test-naptr.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeNAPTR, `20 \"U\" \"SIP+DTU\" \"\" _sip._udp.sip2.example.com.`),\n\t\t}\n\n\t\tdeleteRecords := []*endpoint.Endpoint{\n\t\t\tendpoint.NewEndpoint(\"delete-test.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"8.8.8.8\"),\n\t\t\tendpoint.NewEndpoint(\"delete-test.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"8.8.4.4\"),\n\t\t\tendpoint.NewEndpoint(\"delete-test-aaaa.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeAAAA, \"2606:4700:4700::1111\"),\n\t\t\tendpoint.NewEndpoint(\"delete-test-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeAAAA, \"2606:4700:4700::1001\"),\n\t\t\tendpoint.NewEndpoint(\"delete-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeCNAME, \"qux.elb.amazonaws.com\"),\n\t\t\tendpoint.NewEndpoint(\"delete-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"qux.elb.amazonaws.com\").WithProviderSpecific(providerSpecificAlias, \"true\"),\n\t\t\tendpoint.NewEndpoint(\"delete-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeAAAA, \"qux.elb.amazonaws.com\").WithProviderSpecific(providerSpecificAlias, \"true\"),\n\t\t\tendpoint.NewEndpoint(\"delete-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"1.2.3.4\", \"4.3.2.1\"),\n\t\t\tendpoint.NewEndpoint(\"delete-test-multiple-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeAAAA, \"2606:4700:4700::1111\", \"2606:4700:4700::1001\"),\n\t\t\tendpoint.NewEndpoint(\"delete-test-geoproximity.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"1.2.3.4\").WithSetIdentifier(\"geoproximity-delete\").WithProviderSpecific(providerSpecificGeoProximityLocationAWSRegion, \"us-west-2\").WithProviderSpecific(providerSpecificGeoProximityLocationBias, \"10\"),\n\t\t\tendpoint.NewEndpoint(\"delete-test-mx.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeMX, \"30 mailhost1.foo.elb.amazonaws.com\"),\n\t\t\tendpoint.NewEndpoint(\"delete-test-naptr.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeNAPTR, `10 \"U\" \"SIP+DTU\" \"\" _sip._udp.sip1.example.com.`),\n\t\t}\n\n\t\tchanges := &plan.Changes{\n\t\t\tCreate:    createRecords,\n\t\t\tUpdateNew: updatedRecords,\n\t\t\tUpdateOld: currentRecords,\n\t\t\tDelete:    deleteRecords,\n\t\t}\n\n\t\tctx := tt.setup(provider)\n\n\t\tprovider.zonesCache = blueprint.NewZoneCache[map[string]*profiledZone](0 * time.Minute)\n\t\tcounter := NewRoute53APICounter(provider.clients[defaultAWSProfile])\n\t\tprovider.clients[defaultAWSProfile] = counter\n\t\trequire.NoError(t, provider.ApplyChanges(ctx, changes))\n\n\t\tassert.Equal(t, 1, counter.calls[\"ListHostedZonesPages\"], tt.name)\n\t\tassert.Equal(t, tt.listRRSets, counter.calls[\"ListResourceRecordSetsPages\"], tt.name)\n\n\t\tvalidateRecords(t, listAWSRecords(t, provider.clients[defaultAWSProfile], \"/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do.\"), []route53types.ResourceRecordSet{\n\t\t\t{\n\t\t\t\tName:            aws.String(\"create-test.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeA,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"8.8.8.8\")}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"create-test-aaaa.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeAaaa,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"2606:4700:4700::1111\")}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"update-test.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeA,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"1.2.3.4\")}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"update-test-aaaa.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeAaaa,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"2606:4700:4700::1001\")}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName: aws.String(\"update-test-a-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType: route53types.RRTypeA,\n\t\t\t\tAliasTarget: &route53types.AliasTarget{\n\t\t\t\t\tDNSName:              aws.String(\"foo.elb.amazonaws.com.\"),\n\t\t\t\t\tEvaluateTargetHealth: true,\n\t\t\t\t\tHostedZoneId:         aws.String(\"zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName: aws.String(\"update-test-a-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType: route53types.RRTypeAaaa,\n\t\t\t\tAliasTarget: &route53types.AliasTarget{\n\t\t\t\t\tDNSName:              aws.String(\"foo.elb.amazonaws.com.\"),\n\t\t\t\t\tEvaluateTargetHealth: true,\n\t\t\t\t\tHostedZoneId:         aws.String(\"zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"update-test-alias-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeCname,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"my-internal-host.example.com\")}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"create-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeCname,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"foo.elb.amazonaws.com\")}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"update-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeCname,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"baz.elb.amazonaws.com\")}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"create-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeCname,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"foo.elb.amazonaws.com\")}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName: aws.String(\"update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType: route53types.RRTypeA,\n\t\t\t\tAliasTarget: &route53types.AliasTarget{\n\t\t\t\t\tDNSName:              aws.String(\"baz.elb.amazonaws.com.\"),\n\t\t\t\t\tEvaluateTargetHealth: true,\n\t\t\t\t\tHostedZoneId:         aws.String(\"zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName: aws.String(\"update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType: route53types.RRTypeAaaa,\n\t\t\t\tAliasTarget: &route53types.AliasTarget{\n\t\t\t\t\tDNSName:              aws.String(\"baz.elb.amazonaws.com.\"),\n\t\t\t\t\tEvaluateTargetHealth: true,\n\t\t\t\t\tHostedZoneId:         aws.String(\"zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"weighted-to-simple.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeA,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"1.2.3.4\")}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"simple-to-weighted.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeA,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"1.2.3.4\")}},\n\t\t\t\tSetIdentifier:   aws.String(\"simple-to-weighted\"),\n\t\t\t\tWeight:          aws.Int64(10),\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"policy-change.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeA,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"1.2.3.4\")}},\n\t\t\t\tSetIdentifier:   aws.String(\"policy-change\"),\n\t\t\t\tRegion:          route53types.ResourceRecordSetRegionUsEast1,\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"set-identifier-change.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeA,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"1.2.3.4\")}},\n\t\t\t\tSetIdentifier:   aws.String(\"after\"),\n\t\t\t\tWeight:          aws.Int64(10),\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"set-identifier-no-change.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeA,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"1.2.3.4\")}},\n\t\t\t\tSetIdentifier:   aws.String(\"no-change\"),\n\t\t\t\tWeight:          aws.Int64(20),\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"create-test-mx.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeMx,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"10 mailhost1.foo.elb.amazonaws.com\")}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"create-test-naptr.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeNaptr,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(`10 \"U\" \"SIP+DTU\" \"\" _sip._udp.sip1.example.com.`)}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"create-test-geoproximity-region.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeA,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"8.8.8.8\")}},\n\t\t\t\tSetIdentifier:   aws.String(\"geoproximity-region\"),\n\t\t\t\tGeoProximityLocation: &route53types.GeoProximityLocation{\n\t\t\t\t\tAWSRegion: aws.String(\"us-west-2\"),\n\t\t\t\t\tBias:      aws.Int32(10),\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"update-test-geoproximity.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeA,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"1.2.3.4\")}},\n\t\t\t\tSetIdentifier:   aws.String(\"geoproximity-update\"),\n\t\t\t\tGeoProximityLocation: &route53types.GeoProximityLocation{\n\t\t\t\t\tLocalZoneGroup: aws.String(\"usw2-phx2-az1\"),\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"create-test-geoproximity-coordinates.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeA,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"8.8.8.8\")}},\n\t\t\t\tSetIdentifier:   aws.String(\"geoproximity-coordinates\"),\n\t\t\t\tGeoProximityLocation: &route53types.GeoProximityLocation{\n\t\t\t\t\tCoordinates: &route53types.Coordinates{\n\t\t\t\t\t\tLatitude:  aws.String(\"60\"),\n\t\t\t\t\t\tLongitude: aws.String(\"60\"),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t\tvalidateRecords(t, listAWSRecords(t, provider.clients[defaultAWSProfile], \"/hostedzone/zone-2.ext-dns-test-2.teapot.zalan.do.\"), []route53types.ResourceRecordSet{\n\t\t\t{\n\t\t\t\tName:            aws.String(\"escape-\\\\045\\\\041s\\\\050\\\\074nil\\\\076\\\\051-codes.zone-2.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeA,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"1.2.3.4\")}},\n\t\t\t\tSetIdentifier:   aws.String(\"no-change\"),\n\t\t\t\tWeight:          aws.Int64(10),\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"create-test.zone-2.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeA,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"8.8.4.4\")}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"create-test-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeAaaa,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"2606:4700:4700::1001\")}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"update-test.zone-2.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeA,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"4.3.2.1\")}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"update-test-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeAaaa,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"2606:4700:4700::1111\")}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"create-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeA,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"8.8.8.8\")}, {Value: aws.String(\"8.8.4.4\")}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"create-test-multiple-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeAaaa,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"2606:4700:4700::1111\")}, {Value: aws.String(\"2606:4700:4700::1001\")}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"update-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeA,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"1.2.3.4\")}, {Value: aws.String(\"4.3.2.1\")}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"update-test-multiple-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeAaaa,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"2606:4700:4700::1001\")}, {Value: aws.String(\"2606:4700:4700::1111\")}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"update-test-mx.zone-2.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeMx,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"20 mailhost3.foo.elb.amazonaws.com\")}},\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:            aws.String(\"update-test-naptr.zone-2.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType:            route53types.RRTypeNaptr,\n\t\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(`20 \"U\" \"SIP+DTU\" \"\" _sip._udp.sip2.example.com.`)}},\n\t\t\t},\n\t\t})\n\t}\n}\n\nfunc TestAWSApplyChangesDryRun(t *testing.T) {\n\toriginalRecords := []route53types.ResourceRecordSet{\n\t\t{\n\t\t\tName:            aws.String(\"update-test.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType:            route53types.RRTypeA,\n\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"8.8.8.8\")}},\n\t\t},\n\t\t{\n\t\t\tName:            aws.String(\"delete-test.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType:            route53types.RRTypeA,\n\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"8.8.8.8\")}},\n\t\t},\n\t\t{\n\t\t\tName:            aws.String(\"update-test.zone-2.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType:            route53types.RRTypeA,\n\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"8.8.4.4\")}},\n\t\t},\n\t\t{\n\t\t\tName:            aws.String(\"delete-test.zone-2.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType:            route53types.RRTypeA,\n\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"8.8.4.4\")}},\n\t\t},\n\t\t{\n\t\t\tName:            aws.String(\"update-test-a-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType:            route53types.RRTypeA,\n\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"1.1.1.1\")}},\n\t\t},\n\t\t{\n\t\t\tName:            aws.String(\"update-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType:            route53types.RRTypeCname,\n\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"bar.elb.amazonaws.com\")}},\n\t\t},\n\t\t{\n\t\t\tName:            aws.String(\"delete-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType:            route53types.RRTypeCname,\n\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"qux.elb.amazonaws.com\")}},\n\t\t},\n\t\t{\n\t\t\tName:            aws.String(\"update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType:            route53types.RRTypeCname,\n\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"bar.elb.amazonaws.com\")}},\n\t\t},\n\t\t{\n\t\t\tName:            aws.String(\"delete-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType:            route53types.RRTypeCname,\n\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"qux.elb.amazonaws.com\")}},\n\t\t},\n\t\t{\n\t\t\tName:            aws.String(\"update-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType:            route53types.RRTypeA,\n\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"8.8.8.8\")}, {Value: aws.String(\"8.8.4.4\")}},\n\t\t},\n\t\t{\n\t\t\tName:            aws.String(\"delete-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType:            route53types.RRTypeA,\n\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"1.2.3.4\")}, {Value: aws.String(\"4.3.2.1\")}},\n\t\t},\n\t\t{\n\t\t\tName:            aws.String(\"update-test-mx.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType:            route53types.RRTypeMx,\n\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"20 mail.foo.elb.amazonaws.com\")}},\n\t\t},\n\t\t{\n\t\t\tName:            aws.String(\"delete-test-mx.zone-2.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType:            route53types.RRTypeMx,\n\t\t\tTTL:             aws.Int64(defaultTTL),\n\t\t\tResourceRecords: []route53types.ResourceRecord{{Value: aws.String(\"10 mail.bar.elb.amazonaws.com\")}},\n\t\t},\n\t}\n\n\tprovider, _ := newAWSProvider(t, endpoint.NewDomainFilter([]string{\"ext-dns-test-2.teapot.zalan.do.\"}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(\"\"), defaultEvaluateTargetHealth, false, true, originalRecords)\n\n\tcreateRecords := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"create-test.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"8.8.8.8\"),\n\t\tendpoint.NewEndpoint(\"create-test.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"8.8.4.4\"),\n\t\tendpoint.NewEndpoint(\"create-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeCNAME, \"foo.elb.amazonaws.com\"),\n\t\tendpoint.NewEndpoint(\"create-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeCNAME, \"foo.elb.amazonaws.com\"),\n\t\tendpoint.NewEndpoint(\"create-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"8.8.8.8\", \"8.8.4.4\"),\n\t\tendpoint.NewEndpoint(\"create-test-mx.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeMX, \"30 mail.foo.elb.amazonaws.com\"),\n\t}\n\n\tcurrentRecords := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"update-test.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"8.8.8.8\"),\n\t\tendpoint.NewEndpoint(\"update-test.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"8.8.4.4\"),\n\t\tendpoint.NewEndpoint(\"update-test-a-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"1.1.1.1\"),\n\t\tendpoint.NewEndpoint(\"update-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeCNAME, \"bar.elb.amazonaws.com\"),\n\t\tendpoint.NewEndpoint(\"update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeCNAME, \"bar.elb.amazonaws.com\"),\n\t\tendpoint.NewEndpoint(\"update-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"8.8.8.8\", \"8.8.4.4\"),\n\t\tendpoint.NewEndpoint(\"update-test-mx.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeMX, \"20 mail.foo.elb.amazonaws.com\"),\n\t}\n\tupdatedRecords := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"update-test.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"1.2.3.4\"),\n\t\tendpoint.NewEndpoint(\"update-test.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"4.3.2.1\"),\n\t\tendpoint.NewEndpoint(\"update-test-a-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeCNAME, \"foo.elb.amazonaws.com\"),\n\t\tendpoint.NewEndpoint(\"update-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeCNAME, \"baz.elb.amazonaws.com\"),\n\t\tendpoint.NewEndpoint(\"update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeCNAME, \"baz.elb.amazonaws.com\"),\n\t\tendpoint.NewEndpoint(\"update-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"1.2.3.4\", \"4.3.2.1\"),\n\t\tendpoint.NewEndpoint(\"update-test-mx.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeMX, \"10 mail.bar.elb.amazonaws.com\"),\n\t}\n\n\tdeleteRecords := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"delete-test.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"8.8.8.8\"),\n\t\tendpoint.NewEndpoint(\"delete-test.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"8.8.4.4\"),\n\t\tendpoint.NewEndpoint(\"delete-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeCNAME, \"qux.elb.amazonaws.com\"),\n\t\tendpoint.NewEndpoint(\"delete-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeCNAME, \"qux.elb.amazonaws.com\"),\n\t\tendpoint.NewEndpoint(\"delete-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, \"1.2.3.4\", \"4.3.2.1\"),\n\t\tendpoint.NewEndpoint(\"delete-test-mx.zone-2.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeMX, \"10 mail.bar.elb.amazonaws.com\"),\n\t}\n\n\tchanges := &plan.Changes{\n\t\tCreate:    createRecords,\n\t\tUpdateNew: updatedRecords,\n\t\tUpdateOld: currentRecords,\n\t\tDelete:    deleteRecords,\n\t}\n\n\tctx := t.Context()\n\n\trequire.NoError(t, provider.ApplyChanges(ctx, changes))\n\n\tvalidateRecords(t,\n\t\tappend(\n\t\t\tlistAWSRecords(t, provider.clients[defaultAWSProfile], \"/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tlistAWSRecords(t, provider.clients[defaultAWSProfile], \"/hostedzone/zone-2.ext-dns-test-2.teapot.zalan.do.\")...),\n\t\toriginalRecords)\n}\n\nfunc TestAWSChangesByZones(t *testing.T) {\n\tchanges := Route53Changes{\n\t\t{\n\t\t\tChange: route53types.Change{\n\t\t\t\tAction: route53types.ChangeActionCreate,\n\t\t\t\tResourceRecordSet: &route53types.ResourceRecordSet{\n\t\t\t\t\tName: aws.String(\"qux.foo.example.org\"), TTL: aws.Int64(1),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tChange: route53types.Change{\n\t\t\t\tAction: route53types.ChangeActionCreate,\n\t\t\t\tResourceRecordSet: &route53types.ResourceRecordSet{\n\t\t\t\t\tName: aws.String(\"qux.bar.example.org\"), TTL: aws.Int64(2),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tChange: route53types.Change{\n\t\t\t\tAction: route53types.ChangeActionDelete,\n\t\t\t\tResourceRecordSet: &route53types.ResourceRecordSet{\n\t\t\t\t\tName: aws.String(\"wambo.foo.example.org\"), TTL: aws.Int64(10),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tChange: route53types.Change{\n\t\t\t\tAction: route53types.ChangeActionDelete,\n\t\t\t\tResourceRecordSet: &route53types.ResourceRecordSet{\n\t\t\t\t\tName: aws.String(\"wambo.bar.example.org\"), TTL: aws.Int64(20),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tzones := map[string]*profiledZone{\n\t\t\"foo-example-org\": {\n\t\t\tprofile: defaultAWSProfile,\n\t\t\tzone: &route53types.HostedZone{\n\t\t\t\tId:   aws.String(\"foo-example-org\"),\n\t\t\t\tName: aws.String(\"foo.example.org.\"),\n\t\t\t},\n\t\t},\n\t\t\"bar-example-org\": {\n\t\t\tprofile: defaultAWSProfile,\n\t\t\tzone: &route53types.HostedZone{\n\t\t\t\tId:   aws.String(\"bar-example-org\"),\n\t\t\t\tName: aws.String(\"bar.example.org.\"),\n\t\t\t},\n\t\t},\n\t\t\"bar-example-org-private\": {\n\t\t\tprofile: defaultAWSProfile,\n\t\t\tzone: &route53types.HostedZone{\n\t\t\t\tId:     aws.String(\"bar-example-org-private\"),\n\t\t\t\tName:   aws.String(\"bar.example.org.\"),\n\t\t\t\tConfig: &route53types.HostedZoneConfig{PrivateZone: true},\n\t\t\t},\n\t\t},\n\t\t\"baz-example-org\": {\n\t\t\tprofile: defaultAWSProfile,\n\t\t\tzone: &route53types.HostedZone{\n\t\t\t\tId:   aws.String(\"baz-example-org\"),\n\t\t\t\tName: aws.String(\"baz.example.org.\"),\n\t\t\t},\n\t\t},\n\t}\n\n\tchangesByZone := changesByZone(zones, changes)\n\trequire.Len(t, changesByZone, 3)\n\n\tvalidateAWSChangeRecords(t, changesByZone[\"foo-example-org\"], Route53Changes{\n\t\t{\n\t\t\tChange: route53types.Change{\n\t\t\t\tAction: route53types.ChangeActionCreate,\n\t\t\t\tResourceRecordSet: &route53types.ResourceRecordSet{\n\t\t\t\t\tName: aws.String(\"qux.foo.example.org\"), TTL: aws.Int64(1),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tChange: route53types.Change{\n\t\t\t\tAction: route53types.ChangeActionDelete,\n\t\t\t\tResourceRecordSet: &route53types.ResourceRecordSet{\n\t\t\t\t\tName: aws.String(\"wambo.foo.example.org\"), TTL: aws.Int64(10),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t})\n\n\tvalidateAWSChangeRecords(t, changesByZone[\"bar-example-org\"], Route53Changes{\n\t\t{\n\t\t\tChange: route53types.Change{\n\t\t\t\tAction: route53types.ChangeActionCreate,\n\t\t\t\tResourceRecordSet: &route53types.ResourceRecordSet{\n\t\t\t\t\tName: aws.String(\"qux.bar.example.org\"), TTL: aws.Int64(2),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tChange: route53types.Change{\n\t\t\t\tAction: route53types.ChangeActionDelete,\n\t\t\t\tResourceRecordSet: &route53types.ResourceRecordSet{\n\t\t\t\t\tName: aws.String(\"wambo.bar.example.org\"), TTL: aws.Int64(20),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t})\n\n\tvalidateAWSChangeRecords(t, changesByZone[\"bar-example-org-private\"], Route53Changes{\n\t\t{\n\t\t\tChange: route53types.Change{\n\t\t\t\tAction: route53types.ChangeActionCreate,\n\t\t\t\tResourceRecordSet: &route53types.ResourceRecordSet{\n\t\t\t\t\tName: aws.String(\"qux.bar.example.org\"), TTL: aws.Int64(2),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tChange: route53types.Change{\n\t\t\t\tAction: route53types.ChangeActionDelete,\n\t\t\t\tResourceRecordSet: &route53types.ResourceRecordSet{\n\t\t\t\t\tName: aws.String(\"wambo.bar.example.org\"), TTL: aws.Int64(20),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t})\n}\n\nfunc TestAWSsubmitChanges(t *testing.T) {\n\tprovider, _ := newAWSProvider(t, endpoint.NewDomainFilter([]string{\"ext-dns-test-2.teapot.zalan.do.\"}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(\"\"), defaultEvaluateTargetHealth, false, false, nil)\n\tconst subnets = 16\n\tconst hosts = defaultBatchChangeSize / subnets\n\n\tendpoints := make([]*endpoint.Endpoint, 0)\n\tfor i := range subnets {\n\t\tfor j := 1; j < (hosts + 1); j++ {\n\t\t\thostname := fmt.Sprintf(\"subnet%dhost%d.zone-1.ext-dns-test-2.teapot.zalan.do\", i, j)\n\t\t\tip := fmt.Sprintf(\"1.1.%d.%d\", i, j)\n\t\t\tep := endpoint.NewEndpointWithTTL(hostname, endpoint.RecordTypeA, endpoint.TTL(defaultTTL), ip)\n\t\t\tendpoints = append(endpoints, ep)\n\t\t}\n\t}\n\n\tctx := t.Context()\n\tzones, _ := provider.zones(ctx)\n\trecords, _ := provider.Records(ctx)\n\tcs := make(Route53Changes, 0, len(endpoints))\n\tcs = append(cs, provider.newChanges(route53types.ChangeActionCreate, endpoints)...)\n\n\trequire.NoError(t, provider.submitChanges(ctx, cs, zones))\n\n\trecords, err := provider.Records(ctx)\n\trequire.NoError(t, err)\n\n\tvalidateEndpoints(t, provider, records, endpoints)\n}\n\nfunc TestAWSsubmitChangesError(t *testing.T) {\n\tprovider, clientStub := newAWSProvider(t, endpoint.NewDomainFilter([]string{\"ext-dns-test-2.teapot.zalan.do.\"}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(\"\"), defaultEvaluateTargetHealth, false, false, nil)\n\tclientStub.MockMethod(\"ChangeResourceRecordSets\", mock.Anything).Return(nil, fmt.Errorf(\"Mock route53 failure\"))\n\n\tctx := t.Context()\n\tzones, err := provider.zones(ctx)\n\trequire.NoError(t, err)\n\n\tep := endpoint.NewEndpointWithTTL(\"fail.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), \"1.0.0.1\")\n\tcs := provider.newChanges(route53types.ChangeActionCreate, []*endpoint.Endpoint{ep})\n\n\trequire.Error(t, provider.submitChanges(ctx, cs, zones))\n}\n\nfunc TestAWSsubmitChangesRetryOnError(t *testing.T) {\n\tprovider, clientStub := newAWSProvider(t, endpoint.NewDomainFilter([]string{\"ext-dns-test-2.teapot.zalan.do.\"}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(\"\"), defaultEvaluateTargetHealth, false, false, nil)\n\n\tctx := t.Context()\n\tzones, err := provider.zones(ctx)\n\trequire.NoError(t, err)\n\n\tep1 := endpoint.NewEndpointWithTTL(\"success.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), \"1.0.0.1\")\n\tep2 := endpoint.NewEndpointWithTTL(\"fail.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), \"1.0.0.2\")\n\tep3 := endpoint.NewEndpointWithTTL(\"success2.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), \"1.0.0.3\")\n\n\tep2txt := endpoint.NewEndpointWithTTL(\"fail__edns_housekeeping.zone-1.ext-dns-test-2.teapot.zalan.do\", endpoint.RecordTypeTXT, endpoint.TTL(defaultTTL), \"something\") // \"__edns_housekeeping\" is the TXT suffix\n\tep2txt.Labels = map[string]string{\n\t\tendpoint.OwnedRecordLabelKey: \"fail.zone-1.ext-dns-test-2.teapot.zalan.do\",\n\t}\n\n\t// \"success\" and \"fail\" are created in the first step, both are submitted in the same batch; this should fail\n\tcs1 := provider.newChanges(route53types.ChangeActionCreate, []*endpoint.Endpoint{ep2, ep2txt, ep1})\n\tinput1 := &route53.ChangeResourceRecordSetsInput{\n\t\tHostedZoneId: aws.String(\"/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\tChangeBatch: &route53types.ChangeBatch{\n\t\t\tChanges: cs1.Route53Changes(),\n\t\t},\n\t}\n\tclientStub.MockMethod(\"ChangeResourceRecordSets\", input1).Return(nil, fmt.Errorf(\"Mock route53 failure\"))\n\n\t// because of the failure, changes will be retried one by one; make \"fail\" submitted in its own batch fail as well\n\tcs2 := provider.newChanges(route53types.ChangeActionCreate, []*endpoint.Endpoint{ep2, ep2txt})\n\tinput2 := &route53.ChangeResourceRecordSetsInput{\n\t\tHostedZoneId: aws.String(\"/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\tChangeBatch: &route53types.ChangeBatch{\n\t\t\tChanges: cs2.Route53Changes(),\n\t\t},\n\t}\n\tclientStub.MockMethod(\"ChangeResourceRecordSets\", input2).Return(nil, fmt.Errorf(\"Mock route53 failure\"))\n\n\t// \"success\" should have been created, verify that we still get an error because \"fail\" failed\n\trequire.Error(t, provider.submitChanges(ctx, cs1, zones))\n\n\t// assert that \"success\" was successfully created and \"fail\" and its TXT record were not\n\trecords, err := provider.Records(ctx)\n\trequire.NoError(t, err)\n\trequire.True(t, containsRecordWithDNSName(records, \"success.zone-1.ext-dns-test-2.teapot.zalan.do\"))\n\trequire.False(t, containsRecordWithDNSName(records, \"fail.zone-1.ext-dns-test-2.teapot.zalan.do\"))\n\trequire.False(t, containsRecordWithDNSName(records, \"fail__edns_housekeeping.zone-1.ext-dns-test-2.teapot.zalan.do\"))\n\n\t// next batch should contain \"fail\" and \"success2\", should succeed this time\n\tcs3 := provider.newChanges(route53types.ChangeActionCreate, []*endpoint.Endpoint{ep2, ep2txt, ep3})\n\trequire.NoError(t, provider.submitChanges(ctx, cs3, zones))\n\n\t// verify all records are there\n\trecords, err = provider.Records(ctx)\n\trequire.NoError(t, err)\n\trequire.True(t, containsRecordWithDNSName(records, \"success.zone-1.ext-dns-test-2.teapot.zalan.do\"))\n\trequire.True(t, containsRecordWithDNSName(records, \"fail.zone-1.ext-dns-test-2.teapot.zalan.do\"))\n\trequire.True(t, containsRecordWithDNSName(records, \"success2.zone-1.ext-dns-test-2.teapot.zalan.do\"))\n\trequire.True(t, containsRecordWithDNSName(records, \"fail__edns_housekeeping.zone-1.ext-dns-test-2.teapot.zalan.do\"))\n}\n\nfunc TestAWSBatchChangeSet(t *testing.T) {\n\tvar cs Route53Changes\n\n\tfor i := 1; i <= defaultBatchChangeSize; i += 2 {\n\t\tcs = append(cs, &Route53Change{\n\t\t\tChange: route53types.Change{\n\t\t\t\tAction: route53types.ChangeActionCreate,\n\t\t\t\tResourceRecordSet: &route53types.ResourceRecordSet{\n\t\t\t\t\tName: aws.String(fmt.Sprintf(\"host-%d\", i)),\n\t\t\t\t\tType: route53types.RRTypeA,\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t\tcs = append(cs, &Route53Change{\n\t\t\tChange: route53types.Change{\n\t\t\t\tAction: route53types.ChangeActionCreate,\n\t\t\t\tResourceRecordSet: &route53types.ResourceRecordSet{\n\t\t\t\t\tName: aws.String(fmt.Sprintf(\"host-%d\", i)),\n\t\t\t\t\tType: route53types.RRTypeTxt,\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t}\n\n\tbatchCs := batchChangeSet(cs, defaultBatchChangeSize, defaultBatchChangeSizeBytes, defaultBatchChangeSizeValues)\n\n\trequire.Len(t, batchCs, 1)\n\n\t// sorting cs not needed as it should be returned as is\n\tvalidateAWSChangeRecords(t, batchCs[0], cs)\n}\n\nfunc TestAWSBatchChangeSetExceeding(t *testing.T) {\n\tvar cs Route53Changes\n\tconst testCount = 50\n\tconst testLimit = 11\n\tconst expectedBatchCount = 5\n\tconst expectedChangesCount = 10\n\n\tfor i := 1; i <= testCount; i += 2 {\n\t\tcs = append(cs,\n\t\t\t&Route53Change{\n\t\t\t\tChange: route53types.Change{\n\t\t\t\t\tAction: route53types.ChangeActionCreate,\n\t\t\t\t\tResourceRecordSet: &route53types.ResourceRecordSet{\n\t\t\t\t\t\tName: aws.String(fmt.Sprintf(\"host-%d\", i)),\n\t\t\t\t\t\tType: route53types.RRTypeA,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t&Route53Change{\n\t\t\t\tChange: route53types.Change{\n\t\t\t\t\tAction: route53types.ChangeActionCreate,\n\t\t\t\t\tResourceRecordSet: &route53types.ResourceRecordSet{\n\t\t\t\t\t\tName: aws.String(fmt.Sprintf(\"host-%d\", i)),\n\t\t\t\t\t\tType: route53types.RRTypeTxt,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t)\n\t}\n\n\tbatchCs := batchChangeSet(cs, testLimit, defaultBatchChangeSizeBytes, defaultBatchChangeSizeValues)\n\n\trequire.Len(t, batchCs, expectedBatchCount)\n\n\t// sorting cs needed to match batchCs\n\tfor i, batch := range batchCs {\n\t\tvalidateAWSChangeRecords(t, batch, sortChangesByActionNameType(cs)[i*expectedChangesCount:expectedChangesCount*(i+1)])\n\t}\n}\n\nfunc TestAWSBatchChangeSetExceedingNameChange(t *testing.T) {\n\tvar cs Route53Changes\n\tconst testCount = 10\n\tconst testLimit = 1\n\n\tfor i := 1; i <= testCount; i += 2 {\n\t\tcs = append(cs,\n\t\t\t&Route53Change{\n\t\t\t\tChange: route53types.Change{\n\t\t\t\t\tAction: route53types.ChangeActionCreate,\n\t\t\t\t\tResourceRecordSet: &route53types.ResourceRecordSet{\n\t\t\t\t\t\tName: aws.String(fmt.Sprintf(\"host-%d\", i)),\n\t\t\t\t\t\tType: route53types.RRTypeA,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t&Route53Change{\n\t\t\t\tChange: route53types.Change{\n\t\t\t\t\tAction: route53types.ChangeActionCreate,\n\t\t\t\t\tResourceRecordSet: &route53types.ResourceRecordSet{\n\t\t\t\t\t\tName: aws.String(fmt.Sprintf(\"host-%d\", i)),\n\t\t\t\t\t\tType: route53types.RRTypeTxt,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t)\n\t}\n\n\tbatchCs := batchChangeSet(cs, testLimit, defaultBatchChangeSizeBytes, defaultBatchChangeSizeValues)\n\n\trequire.Empty(t, batchCs)\n}\n\nfunc TestAWSBatchChangeSetExceedingBytesLimit(t *testing.T) {\n\tconst (\n\t\ttestCount = 50\n\t\ttestLimit = 100\n\t\tgroupSize = 2\n\t)\n\n\tvar (\n\t\tcs Route53Changes\n\t\t// Bytes for each name\n\t\ttestBytes = len([]byte(\"1.2.3.4\")) + len([]byte(\"test-record\"))\n\t\t// testCount / groupSize / (testLimit // bytes)\n\t\texpectedBatchCountFloat = float64(testCount) / float64(groupSize) / float64(testLimit/testBytes)\n\t\t// Round up\n\t\texpectedBatchCount = int(math.Ceil(expectedBatchCountFloat))\n\t)\n\n\tfor i := 1; i <= testCount; i += groupSize {\n\t\tcs = append(cs,\n\t\t\t&Route53Change{\n\t\t\t\tChange: route53types.Change{\n\t\t\t\t\tAction: route53types.ChangeActionCreate,\n\t\t\t\t\tResourceRecordSet: &route53types.ResourceRecordSet{\n\t\t\t\t\t\tName: aws.String(fmt.Sprintf(\"host-%d\", i)),\n\t\t\t\t\t\tType: route53types.RRTypeA,\n\t\t\t\t\t\tResourceRecords: []route53types.ResourceRecord{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tValue: aws.String(\"1.2.3.4\"),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tsizeBytes:  len([]byte(\"1.2.3.4\")),\n\t\t\t\tsizeValues: 1,\n\t\t\t},\n\t\t\t&Route53Change{\n\t\t\t\tChange: route53types.Change{\n\t\t\t\t\tAction: route53types.ChangeActionCreate,\n\t\t\t\t\tResourceRecordSet: &route53types.ResourceRecordSet{\n\t\t\t\t\t\tName: aws.String(fmt.Sprintf(\"host-%d\", i)),\n\t\t\t\t\t\tType: route53types.RRTypeTxt,\n\t\t\t\t\t\tResourceRecords: []route53types.ResourceRecord{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tValue: aws.String(\"txt-record\"),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tsizeBytes:  len([]byte(\"txt-record\")),\n\t\t\t\tsizeValues: 1,\n\t\t\t},\n\t\t)\n\t}\n\n\tbatchCs := batchChangeSet(cs, defaultBatchChangeSize, testLimit, defaultBatchChangeSizeValues)\n\n\trequire.Len(t, batchCs, expectedBatchCount)\n}\n\nfunc TestAWSBatchChangeSetExceedingBytesLimitUpsert(t *testing.T) {\n\tconst (\n\t\ttestCount = 50\n\t\ttestLimit = 100\n\t\tgroupSize = 2\n\t)\n\n\tvar (\n\t\tcs Route53Changes\n\t\t// Bytes for each name multiplied by 2 for Upsert records\n\t\ttestBytes = (len([]byte(\"1.2.3.4\")) + len([]byte(\"test-record\"))) * 2\n\t\t// testCount / groupSize / (testLimit // bytes)\n\t\texpectedBatchCountFloat = float64(testCount) / float64(groupSize) / float64(testLimit/testBytes)\n\t\t// Round up\n\t\texpectedBatchCount = int(math.Ceil(expectedBatchCountFloat))\n\t)\n\n\tfor i := 1; i <= testCount; i += groupSize {\n\t\tcs = append(cs,\n\t\t\t&Route53Change{\n\t\t\t\tChange: route53types.Change{\n\t\t\t\t\tAction: route53types.ChangeActionUpsert,\n\t\t\t\t\tResourceRecordSet: &route53types.ResourceRecordSet{\n\t\t\t\t\t\tName: aws.String(fmt.Sprintf(\"host-%d\", i)),\n\t\t\t\t\t\tType: route53types.RRTypeA,\n\t\t\t\t\t\tResourceRecords: []route53types.ResourceRecord{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tValue: aws.String(\"1.2.3.4\"),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tsizeBytes:  len([]byte(\"1.2.3.4\")) * 2,\n\t\t\t\tsizeValues: 1,\n\t\t\t},\n\t\t\t&Route53Change{\n\t\t\t\tChange: route53types.Change{\n\t\t\t\t\tAction: route53types.ChangeActionUpsert,\n\t\t\t\t\tResourceRecordSet: &route53types.ResourceRecordSet{\n\t\t\t\t\t\tName: aws.String(fmt.Sprintf(\"host-%d\", i)),\n\t\t\t\t\t\tType: route53types.RRTypeTxt,\n\t\t\t\t\t\tResourceRecords: []route53types.ResourceRecord{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tValue: aws.String(\"txt-record\"),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tsizeBytes:  len([]byte(\"txt-record\")) * 2,\n\t\t\t\tsizeValues: 1,\n\t\t\t},\n\t\t)\n\t}\n\n\tbatchCs := batchChangeSet(cs, defaultBatchChangeSize, testLimit, defaultBatchChangeSizeValues)\n\n\trequire.Len(t, batchCs, expectedBatchCount)\n}\n\nfunc TestAWSBatchChangeSetExceedingValuesLimit(t *testing.T) {\n\tconst (\n\t\ttestCount = 50\n\t\ttestLimit = 100\n\t\tgroupSize = 2\n\t\t// Values for each group\n\t\ttestValues = 2\n\t)\n\n\tvar (\n\t\tcs Route53Changes\n\t\t// testCount / groupSize / (testLimit // bytes)\n\t\texpectedBatchCountFloat = float64(testCount) / float64(groupSize) / float64(testLimit/testValues)\n\t\t// Round up\n\t\texpectedBatchCount = int(math.Ceil(expectedBatchCountFloat))\n\t)\n\n\tfor i := 1; i <= testCount; i += groupSize {\n\t\tcs = append(cs,\n\t\t\t&Route53Change{\n\t\t\t\tChange: route53types.Change{\n\t\t\t\t\tAction: route53types.ChangeActionCreate,\n\t\t\t\t\tResourceRecordSet: &route53types.ResourceRecordSet{\n\t\t\t\t\t\tName: aws.String(fmt.Sprintf(\"host-%d\", i)),\n\t\t\t\t\t\tType: route53types.RRTypeA,\n\t\t\t\t\t\tResourceRecords: []route53types.ResourceRecord{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tValue: aws.String(\"1.2.3.4\"),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tsizeBytes:  len([]byte(\"1.2.3.4\")),\n\t\t\t\tsizeValues: 1,\n\t\t\t},\n\t\t\t&Route53Change{\n\t\t\t\tChange: route53types.Change{\n\t\t\t\t\tAction: route53types.ChangeActionCreate,\n\t\t\t\t\tResourceRecordSet: &route53types.ResourceRecordSet{\n\t\t\t\t\t\tName: aws.String(fmt.Sprintf(\"host-%d\", i)),\n\t\t\t\t\t\tType: route53types.RRTypeTxt,\n\t\t\t\t\t\tResourceRecords: []route53types.ResourceRecord{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tValue: aws.String(\"txt-record\"),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tsizeBytes:  len([]byte(\"txt-record\")),\n\t\t\t\tsizeValues: 1,\n\t\t\t},\n\t\t)\n\t}\n\n\tbatchCs := batchChangeSet(cs, defaultBatchChangeSize, defaultBatchChangeSizeBytes, testLimit)\n\n\trequire.Len(t, batchCs, expectedBatchCount)\n}\n\nfunc TestAWSBatchChangeSetExceedingValuesLimitUpsert(t *testing.T) {\n\tconst (\n\t\ttestCount = 50\n\t\ttestLimit = 100\n\t\tgroupSize = 2\n\t\t// Values for each group multiplied by 2 for Upsert records\n\t\ttestValues = 2 * 2\n\t)\n\n\tvar (\n\t\tcs Route53Changes\n\t\t// testCount / groupSize / (testLimit // bytes)\n\t\texpectedBatchCountFloat = float64(testCount) / float64(groupSize) / float64(testLimit/testValues)\n\t\t// Round up\n\t\texpectedBatchCount = int(math.Ceil(expectedBatchCountFloat))\n\t)\n\n\tfor i := 1; i <= testCount; i += groupSize {\n\t\tcs = append(cs,\n\t\t\t&Route53Change{\n\t\t\t\tChange: route53types.Change{\n\t\t\t\t\tAction: route53types.ChangeActionUpsert,\n\t\t\t\t\tResourceRecordSet: &route53types.ResourceRecordSet{\n\t\t\t\t\t\tName: aws.String(fmt.Sprintf(\"host-%d\", i)),\n\t\t\t\t\t\tType: route53types.RRTypeA,\n\t\t\t\t\t\tResourceRecords: []route53types.ResourceRecord{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tValue: aws.String(\"1.2.3.4\"),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tsizeBytes:  len([]byte(\"1.2.3.4\")) * 2,\n\t\t\t\tsizeValues: 1,\n\t\t\t},\n\t\t\t&Route53Change{\n\t\t\t\tChange: route53types.Change{\n\t\t\t\t\tAction: route53types.ChangeActionUpsert,\n\t\t\t\t\tResourceRecordSet: &route53types.ResourceRecordSet{\n\t\t\t\t\t\tName: aws.String(fmt.Sprintf(\"host-%d\", i)),\n\t\t\t\t\t\tType: route53types.RRTypeTxt,\n\t\t\t\t\t\tResourceRecords: []route53types.ResourceRecord{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tValue: aws.String(\"txt-record\"),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tsizeBytes:  len([]byte(\"txt-record\")) * 2,\n\t\t\t\tsizeValues: 1,\n\t\t\t},\n\t\t)\n\t}\n\n\tbatchCs := batchChangeSet(cs, defaultBatchChangeSize, defaultBatchChangeSizeBytes, testLimit)\n\n\trequire.Len(t, batchCs, expectedBatchCount)\n}\n\nfunc validateEndpoints(t *testing.T, provider *AWSProvider, endpoints []*endpoint.Endpoint, expected []*endpoint.Endpoint) {\n\tassert.True(t, testutils.SameEndpoints(endpoints, expected), \"actual and expected endpoints don't match. %+v:%+v\", endpoints, expected)\n\n\tnormalized, err := provider.AdjustEndpoints(endpoints)\n\tassert.NoError(t, err)\n\tassert.True(t, testutils.SameEndpoints(normalized, expected), \"normalized and expected endpoints don't match. %+v:%+v\", normalized, expected)\n}\n\nfunc validateAWSZones(t *testing.T, zones map[string]*route53types.HostedZone, expected map[string]*route53types.HostedZone) {\n\trequire.Len(t, zones, len(expected))\n\n\tfor i, zone := range zones {\n\t\tvalidateAWSZone(t, zone, expected[i])\n\t}\n}\n\nfunc validateAWSZone(t *testing.T, zone *route53types.HostedZone, expected *route53types.HostedZone) {\n\tassert.Equal(t, *expected.Id, *zone.Id)\n\tassert.Equal(t, *expected.Name, *zone.Name)\n}\n\nfunc validateAWSChangeRecords(t *testing.T, records Route53Changes, expected Route53Changes) {\n\trequire.Len(t, records, len(expected))\n\n\tfor i := range records {\n\t\tvalidateAWSChangeRecord(t, records[i], expected[i])\n\t}\n}\n\nfunc validateAWSChangeRecord(t *testing.T, record *Route53Change, expected *Route53Change) {\n\tassert.Equal(t, expected.Action, record.Action)\n\tassert.Equal(t, *expected.ResourceRecordSet.Name, *record.ResourceRecordSet.Name)\n\tassert.Equal(t, expected.ResourceRecordSet.Type, record.ResourceRecordSet.Type)\n}\n\nfunc TestAWSCreateRecordsWithCNAME(t *testing.T) {\n\tprovider, _ := newAWSProvider(t, endpoint.NewDomainFilter([]string{\"ext-dns-test-2.teapot.zalan.do.\"}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(\"\"), defaultEvaluateTargetHealth, false, false, nil)\n\n\trecords := []*endpoint.Endpoint{\n\t\t{DNSName: \"create-test.zone-1.ext-dns-test-2.teapot.zalan.do\", Targets: endpoint.Targets{\"foo.example.org\"}, RecordType: endpoint.RecordTypeCNAME},\n\t}\n\n\tadjusted, err := provider.AdjustEndpoints(records)\n\trequire.NoError(t, err)\n\trequire.NoError(t, provider.ApplyChanges(t.Context(), &plan.Changes{\n\t\tCreate: adjusted,\n\t}))\n\n\trecordSets := listAWSRecords(t, provider.clients[defaultAWSProfile], \"/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do.\")\n\n\tvalidateRecords(t, recordSets, []route53types.ResourceRecordSet{\n\t\t{\n\t\t\tName: aws.String(\"create-test.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\tType: route53types.RRTypeCname,\n\t\t\tTTL:  aws.Int64(300),\n\t\t\tResourceRecords: []route53types.ResourceRecord{\n\t\t\t\t{\n\t\t\t\t\tValue: aws.String(\"foo.example.org\"),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t})\n}\n\nfunc TestAWSCreateRecordsWithALIAS(t *testing.T) {\n\tfor key, evaluateTargetHealth := range map[string]bool{\n\t\t\"true\":  true,\n\t\t\"false\": false,\n\t\t\"\":      false,\n\t} {\n\t\tprovider, _ := newAWSProvider(t, endpoint.NewDomainFilter([]string{\"ext-dns-test-2.teapot.zalan.do.\"}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(\"\"), defaultEvaluateTargetHealth, false, false, nil)\n\t\trecords := []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"create-test.zone-1.ext-dns-test-2.teapot.zalan.do\",\n\t\t\t\tTargets:    endpoint.Targets{\"foo.eu-central-1.elb.amazonaws.com\"},\n\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\tendpoint.ProviderSpecificProperty{\n\t\t\t\t\t\tName:  providerSpecificAlias,\n\t\t\t\t\t\tValue: \"true\",\n\t\t\t\t\t},\n\t\t\t\t\tendpoint.ProviderSpecificProperty{\n\t\t\t\t\t\tName:  providerSpecificEvaluateTargetHealth,\n\t\t\t\t\t\tValue: key,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"create-test.zone-1.ext-dns-test-2.teapot.zalan.do\",\n\t\t\t\tTargets:    endpoint.Targets{\"foo.eu-central-1.elb.amazonaws.com\"},\n\t\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\tendpoint.ProviderSpecificProperty{\n\t\t\t\t\t\tName:  providerSpecificAlias,\n\t\t\t\t\t\tValue: \"true\",\n\t\t\t\t\t},\n\t\t\t\t\tendpoint.ProviderSpecificProperty{\n\t\t\t\t\t\tName:  providerSpecificEvaluateTargetHealth,\n\t\t\t\t\t\tValue: key,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\tadjusted, err := provider.AdjustEndpoints(records)\n\t\trequire.NoError(t, err)\n\t\trequire.NoError(t, provider.ApplyChanges(t.Context(), &plan.Changes{\n\t\t\tCreate: adjusted,\n\t\t}))\n\n\t\trecordSets := listAWSRecords(t, provider.clients[defaultAWSProfile], \"/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do.\")\n\n\t\tvalidateRecords(t, recordSets, []route53types.ResourceRecordSet{\n\t\t\t{\n\t\t\t\tAliasTarget: &route53types.AliasTarget{\n\t\t\t\t\tDNSName:              aws.String(\"foo.eu-central-1.elb.amazonaws.com.\"),\n\t\t\t\t\tEvaluateTargetHealth: evaluateTargetHealth,\n\t\t\t\t\tHostedZoneId:         aws.String(\"Z215JYRZR1TBD5\"),\n\t\t\t\t},\n\t\t\t\tName: aws.String(\"create-test.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType: route53types.RRTypeA,\n\t\t\t},\n\t\t\t{\n\t\t\t\tAliasTarget: &route53types.AliasTarget{\n\t\t\t\t\tDNSName:              aws.String(\"foo.eu-central-1.elb.amazonaws.com.\"),\n\t\t\t\t\tEvaluateTargetHealth: evaluateTargetHealth,\n\t\t\t\t\tHostedZoneId:         aws.String(\"Z215JYRZR1TBD5\"),\n\t\t\t\t},\n\t\t\t\tName: aws.String(\"create-test.zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\t\t\tType: route53types.RRTypeAaaa,\n\t\t\t},\n\t\t})\n\t}\n}\n\nfunc TestAWSisLoadBalancer(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\ttarget      string\n\t\trecordType  string\n\t\tpreferCNAME bool\n\t\texpected    bool\n\t}{\n\t\t{\"bar.eu-central-1.elb.amazonaws.com\", endpoint.RecordTypeCNAME, false, true},\n\t\t{\"bar.eu-central-1.elb.amazonaws.com\", endpoint.RecordTypeCNAME, true, false},\n\t\t{\"foo.example.org\", endpoint.RecordTypeCNAME, false, false},\n\t\t{\"foo.example.org\", endpoint.RecordTypeCNAME, true, false},\n\t} {\n\t\tep := &endpoint.Endpoint{\n\t\t\tTargets:    endpoint.Targets{tc.target},\n\t\t\tRecordType: tc.recordType,\n\t\t}\n\t\tassert.Equal(t, tc.expected, useAlias(ep, tc.preferCNAME))\n\t}\n}\n\nfunc TestAWSisAWSAlias(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\ttarget     string\n\t\trecordType string\n\t\talias      bool\n\t\thz         string\n\t}{\n\t\t{\"foo.example.org\", endpoint.RecordTypeA, false, \"\"},                                    // normal A record\n\t\t{\"foo.example.org\", endpoint.RecordTypeAAAA, false, \"\"},                                 // normal AAAA record\n\t\t{\"bar.eu-central-1.elb.amazonaws.com\", endpoint.RecordTypeA, true, \"Z215JYRZR1TBD5\"},    // pointing to ELB DNS name (alias A)\n\t\t{\"bar.eu-central-1.elb.amazonaws.com\", endpoint.RecordTypeAAAA, true, \"Z215JYRZR1TBD5\"}, // pointing to ELB DNS name (alias AAAA)\n\t\t{\"foobar.example.org\", endpoint.RecordTypeA, true, \"Z1234567890ABC\"},                    // HZID retrieved by Route53 (alias A)\n\t\t{\"foobar.example.org\", endpoint.RecordTypeAAAA, true, \"Z1234567890ABC\"},                 // HZID retrieved by Route53 (alias AAAA)\n\t\t{\"baz.example.org\", endpoint.RecordTypeA, true, sameZoneAlias},                          // record to be created (alias A)\n\t\t{\"baz.example.org\", endpoint.RecordTypeAAAA, true, sameZoneAlias},                       // record to be created (alias AAAA)\n\t} {\n\t\tep := &endpoint.Endpoint{\n\t\t\tTargets:    endpoint.Targets{tc.target},\n\t\t\tRecordType: tc.recordType,\n\t\t}\n\t\tif tc.alias {\n\t\t\tep = ep.WithProviderSpecific(providerSpecificAlias, \"true\")\n\t\t\tep = ep.WithProviderSpecific(providerSpecificTargetHostedZone, tc.hz)\n\t\t}\n\t\tassert.Equal(t, tc.hz, isAWSAlias(ep), \"%v\", tc)\n\t}\n}\n\nfunc TestAWSCanonicalHostedZone(t *testing.T) {\n\tfor suffix, id := range canonicalHostedZones {\n\t\tzone := canonicalHostedZone(fmt.Sprintf(\"foo.%s\", suffix))\n\t\tassert.Equal(t, id, zone, \"zone suffix: %s\", suffix)\n\t}\n\n\tzone := canonicalHostedZone(\"foo.example.org\")\n\tassert.Empty(t, zone, \"no canonical zone should be returned for a non-aws hostname\")\n}\n\nfunc TestAWSCanonicalHostedZoneNotExist(t *testing.T) {\n\tvar buf bytes.Buffer\n\tlog.SetOutput(&buf)\n\thost := \"foo.elb.eastwest-1.amazonaws.com\"\n\t_ = canonicalHostedZone(host)\n\tassert.Containsf(t, buf.String(), \"Could not find canonical hosted zone for domain\", host)\n}\n\nfunc BenchmarkTestAWSCanonicalHostedZone(b *testing.B) {\n\tfor b.Loop() {\n\t\tfor suffix := range canonicalHostedZones {\n\t\t\t_ = canonicalHostedZone(fmt.Sprintf(\"foo.%s\", suffix))\n\t\t}\n\t}\n}\n\nfunc BenchmarkTestAWSNonCanonicalHostedZone(b *testing.B) {\n\tfor b.Loop() {\n\t\tfor range canonicalHostedZones {\n\t\t\t_ = canonicalHostedZone(\"extremely.long.zone-2.ext.dns.test.zone.non.canonical.example.com\")\n\t\t}\n\t}\n}\n\nfunc TestAWSSuitableZones(t *testing.T) {\n\tzones := map[string]*profiledZone{\n\t\t// Public domain\n\t\t\"example-org\": {profile: defaultAWSProfile, zone: &route53types.HostedZone{Id: aws.String(\"example-org\"), Name: aws.String(\"example.org.\")}},\n\t\t// Public subdomain\n\t\t\"bar-example-org\": {profile: defaultAWSProfile, zone: &route53types.HostedZone{Id: aws.String(\"bar-example-org\"), Name: aws.String(\"bar.example.org.\"), Config: &route53types.HostedZoneConfig{PrivateZone: false}}},\n\t\t// Public subdomain\n\t\t\"longfoo-bar-example-org\": {profile: defaultAWSProfile, zone: &route53types.HostedZone{Id: aws.String(\"longfoo-bar-example-org\"), Name: aws.String(\"longfoo.bar.example.org.\")}},\n\t\t// Private domain\n\t\t\"example-org-private\": {profile: defaultAWSProfile, zone: &route53types.HostedZone{Id: aws.String(\"example-org-private\"), Name: aws.String(\"example.org.\"), Config: &route53types.HostedZoneConfig{PrivateZone: true}}},\n\t\t// Private subdomain\n\t\t\"bar-example-org-private\": {profile: defaultAWSProfile, zone: &route53types.HostedZone{Id: aws.String(\"bar-example-org-private\"), Name: aws.String(\"bar.example.org.\"), Config: &route53types.HostedZoneConfig{PrivateZone: true}}},\n\t}\n\n\tfor _, tc := range []struct {\n\t\thostname string\n\t\texpected []*profiledZone\n\t}{\n\t\t// bar.example.org is NOT suitable\n\t\t{\"foobar.example.org.\", []*profiledZone{zones[\"example-org-private\"], zones[\"example-org\"]}},\n\n\t\t// all matching private zones are suitable\n\t\t// https://github.com/kubernetes-sigs/external-dns/pull/356\n\t\t{\"bar.example.org.\", []*profiledZone{zones[\"example-org-private\"], zones[\"bar-example-org-private\"], zones[\"bar-example-org\"]}},\n\n\t\t{\"foo.bar.example.org.\", []*profiledZone{zones[\"example-org-private\"], zones[\"bar-example-org-private\"], zones[\"bar-example-org\"]}},\n\t\t{\"foo.example.org.\", []*profiledZone{zones[\"example-org-private\"], zones[\"example-org\"]}},\n\t\t{\"foo.kubernetes.io.\", nil},\n\t} {\n\t\tsuitableZones := suitableZones(tc.hostname, zones)\n\t\tsort.Slice(suitableZones, func(i, j int) bool {\n\t\t\treturn *suitableZones[i].zone.Id < *suitableZones[j].zone.Id\n\t\t})\n\t\tsort.Slice(tc.expected, func(i, j int) bool {\n\t\t\treturn *tc.expected[i].zone.Id < *tc.expected[j].zone.Id\n\t\t})\n\t\tassert.Equal(t, tc.expected, suitableZones)\n\t}\n}\n\nfunc createAWSZone(t *testing.T, provider *AWSProvider, zone *route53types.HostedZone) {\n\tparams := &route53.CreateHostedZoneInput{\n\t\tCallerReference:  aws.String(\"external-dns.alpha.kubernetes.io/test-zone\"),\n\t\tName:             zone.Name,\n\t\tHostedZoneConfig: zone.Config,\n\t}\n\n\tif _, err := provider.clients[defaultAWSProfile].CreateHostedZone(t.Context(), params); err != nil {\n\t\tvar hzExists *route53types.HostedZoneAlreadyExists\n\t\trequire.ErrorAs(t, err, &hzExists)\n\t}\n}\n\nfunc setAWSRecords(t *testing.T, provider *AWSProvider, records []route53types.ResourceRecordSet) {\n\tdryRun := provider.dryRun\n\tprovider.dryRun = false\n\tdefer func() {\n\t\tprovider.dryRun = dryRun\n\t}()\n\n\tctx := t.Context()\n\tendpoints, err := provider.Records(ctx)\n\trequire.NoError(t, err)\n\n\tvalidateEndpoints(t, provider, endpoints, []*endpoint.Endpoint{})\n\n\tvar changes Route53Changes\n\tfor _, record := range records {\n\t\tchanges = append(changes, &Route53Change{\n\t\t\tChange: route53types.Change{\n\t\t\t\tAction:            route53types.ChangeActionCreate,\n\t\t\t\tResourceRecordSet: &record,\n\t\t\t},\n\t\t})\n\t}\n\n\tzones, err := provider.zones(ctx)\n\trequire.NoError(t, err)\n\terr = provider.submitChanges(ctx, changes, zones)\n\trequire.NoError(t, err)\n\n\t_, err = provider.Records(ctx)\n\trequire.NoError(t, err)\n}\n\nfunc listAWSRecords(t *testing.T, client Route53API, zone string) []route53types.ResourceRecordSet {\n\tresp, err := client.ListResourceRecordSets(t.Context(), &route53.ListResourceRecordSetsInput{\n\t\tHostedZoneId: aws.String(zone),\n\t\tMaxItems:     aws.Int32(route53PageSize),\n\t})\n\trequire.NoError(t, err)\n\n\treturn resp.ResourceRecordSets\n}\n\nfunc newAWSProvider(t *testing.T, domainFilter *endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, zoneTypeFilter provider.ZoneTypeFilter, evaluateTargetHealth, preferCNAME, dryRun bool, records []route53types.ResourceRecordSet) (*AWSProvider, *Route53APIStub) {\n\treturn newAWSProviderWithTagFilter(t, domainFilter, zoneIDFilter, zoneTypeFilter, provider.NewZoneTagFilter([]string{}), evaluateTargetHealth, preferCNAME, dryRun, records)\n}\n\nfunc newAWSProviderWithTagFilter(t *testing.T, domainFilter *endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, zoneTypeFilter provider.ZoneTypeFilter, zoneTagFilter provider.ZoneTagFilter, evaluateTargetHealth, preferCNAME, dryRun bool, records []route53types.ResourceRecordSet) (*AWSProvider, *Route53APIStub) {\n\tclient := NewRoute53APIStub(t)\n\n\tprovider := &AWSProvider{\n\t\tclients:               map[string]Route53API{defaultAWSProfile: client},\n\t\tbatchChangeSize:       defaultBatchChangeSize,\n\t\tbatchChangeSizeBytes:  defaultBatchChangeSizeBytes,\n\t\tbatchChangeSizeValues: defaultBatchChangeSizeValues,\n\t\tbatchChangeInterval:   defaultBatchChangeInterval,\n\t\tevaluateTargetHealth:  evaluateTargetHealth,\n\t\tdomainFilter:          domainFilter,\n\t\tzoneIDFilter:          zoneIDFilter,\n\t\tzoneTypeFilter:        zoneTypeFilter,\n\t\tzoneTagFilter:         zoneTagFilter,\n\t\tpreferCNAME:           preferCNAME,\n\t\tdryRun:                false,\n\t\tzonesCache:            blueprint.NewZoneCache[map[string]*profiledZone](1 * time.Minute),\n\t\tfailedChangesQueue:    make(map[string]Route53Changes),\n\t}\n\n\tcreateAWSZone(t, provider, &route53types.HostedZone{\n\t\tId:     aws.String(\"/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\tName:   aws.String(\"zone-1.ext-dns-test-2.teapot.zalan.do.\"),\n\t\tConfig: &route53types.HostedZoneConfig{PrivateZone: false},\n\t})\n\n\tcreateAWSZone(t, provider, &route53types.HostedZone{\n\t\tId:     aws.String(\"/hostedzone/zone-2.ext-dns-test-2.teapot.zalan.do.\"),\n\t\tName:   aws.String(\"zone-2.ext-dns-test-2.teapot.zalan.do.\"),\n\t\tConfig: &route53types.HostedZoneConfig{PrivateZone: false},\n\t})\n\n\tcreateAWSZone(t, provider, &route53types.HostedZone{\n\t\tId:     aws.String(\"/hostedzone/zone-3.ext-dns-test-2.teapot.zalan.do.\"),\n\t\tName:   aws.String(\"zone-3.ext-dns-test-2.teapot.zalan.do.\"),\n\t\tConfig: &route53types.HostedZoneConfig{PrivateZone: true},\n\t})\n\n\t// filtered out by domain filter\n\tcreateAWSZone(t, provider, &route53types.HostedZone{\n\t\tId:     aws.String(\"/hostedzone/zone-4.ext-dns-test-3.teapot.zalan.do.\"),\n\t\tName:   aws.String(\"zone-4.ext-dns-test-3.teapot.zalan.do.\"),\n\t\tConfig: &route53types.HostedZoneConfig{PrivateZone: false},\n\t})\n\n\tsetupZoneTags(provider.clients[defaultAWSProfile].(*Route53APIStub))\n\n\tsetAWSRecords(t, provider, records)\n\n\tprovider.dryRun = dryRun\n\n\treturn provider, client\n}\n\nfunc setupZoneTags(client *Route53APIStub) {\n\taddZoneTags(client.zoneTags, \"/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do.\", map[string]string{\n\t\t\"zone-1-tag-1\": \"tag-1-value\",\n\t\t\"domain\":       \"test-2\",\n\t\t\"zone\":         \"1\",\n\t})\n\taddZoneTags(client.zoneTags, \"/hostedzone/zone-2.ext-dns-test-2.teapot.zalan.do.\", map[string]string{\n\t\t\"zone-2-tag-1\": \"tag-1-value\",\n\t\t\"domain\":       \"test-2\",\n\t\t\"zone\":         \"2\",\n\t})\n\taddZoneTags(client.zoneTags, \"/hostedzone/zone-3.ext-dns-test-2.teapot.zalan.do.\", map[string]string{\n\t\t\"zone-3-tag-1\": \"tag-1-value\",\n\t\t\"domain\":       \"test-2\",\n\t\t\"zone\":         \"3\",\n\t})\n\taddZoneTags(client.zoneTags, \"/hostedzone/zone-4.ext-dns-test-2.teapot.zalan.do.\", map[string]string{\n\t\t\"zone-4-tag-1\": \"tag-1-value\",\n\t\t\"domain\":       \"test-3\",\n\t\t\"zone\":         \"4\",\n\t})\n}\n\nfunc addZoneTags(tagMap map[string][]route53types.Tag, zoneID string, tags map[string]string) {\n\ttagList := make([]route53types.Tag, 0, len(tags))\n\tfor k, v := range tags {\n\t\ttagList = append(tagList, route53types.Tag{\n\t\t\tKey:   aws.String(k),\n\t\t\tValue: aws.String(v),\n\t\t})\n\t}\n\ttagMap[zoneID] = tagList\n}\n\nfunc validateRecords(t *testing.T, records []route53types.ResourceRecordSet, expected []route53types.ResourceRecordSet) {\n\tassert.ElementsMatch(t, expected, records)\n}\n\nfunc containsRecordWithDNSName(records []*endpoint.Endpoint, dnsName string) bool {\n\tfor _, record := range records {\n\t\tif record.DNSName == dnsName {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestRequiresDeleteCreate(t *testing.T) {\n\tprovider, _ := newAWSProvider(t, endpoint.NewDomainFilter([]string{\"foo.bar.\"}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(\"\"), defaultEvaluateTargetHealth, false, false, nil)\n\n\toldRecordType := endpoint.NewEndpointWithTTL(\"recordType\", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), \"8.8.8.8\")\n\tnewRecordType := endpoint.NewEndpointWithTTL(\"recordType\", endpoint.RecordTypeCNAME, endpoint.TTL(defaultTTL), \"bar\").WithProviderSpecific(providerSpecificAlias, \"false\")\n\n\tassert.False(t, provider.requiresDeleteCreate(oldRecordType, oldRecordType), \"actual and expected endpoints don't match. %+v:%+v\", oldRecordType, oldRecordType)\n\tassert.True(t, provider.requiresDeleteCreate(oldRecordType, newRecordType), \"actual and expected endpoints don't match. %+v:%+v\", oldRecordType, newRecordType)\n\n\toldAtoAlias := endpoint.NewEndpointWithTTL(\"AtoAlias\", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), \"1.1.1.1\")\n\tnewAtoAlias := endpoint.NewEndpointWithTTL(\"AtoAlias\", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), \"bar.us-east-1.elb.amazonaws.com\").WithProviderSpecific(providerSpecificAlias, \"true\")\n\n\tassert.False(t, provider.requiresDeleteCreate(oldAtoAlias, oldAtoAlias), \"actual and expected endpoints don't match. %+v:%+v\", oldAtoAlias, oldAtoAlias.DNSName)\n\tassert.True(t, provider.requiresDeleteCreate(oldAtoAlias, newAtoAlias), \"actual and expected endpoints don't match. %+v:%+v\", oldAtoAlias, newAtoAlias)\n\n\toldPolicy := endpoint.NewEndpointWithTTL(\"policy\", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), \"8.8.8.8\").WithSetIdentifier(\"nochange\").WithProviderSpecific(providerSpecificRegion, \"us-east-1\")\n\tnewPolicy := endpoint.NewEndpointWithTTL(\"policy\", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), \"8.8.8.8\").WithSetIdentifier(\"nochange\").WithProviderSpecific(providerSpecificWeight, \"10\")\n\n\tassert.False(t, provider.requiresDeleteCreate(oldPolicy, oldPolicy), \"actual and expected endpoints don't match. %+v:%+v\", oldPolicy, oldPolicy)\n\tassert.True(t, provider.requiresDeleteCreate(oldPolicy, newPolicy), \"actual and expected endpoints don't match. %+v:%+v\", oldPolicy, newPolicy)\n\n\toldSetIdentifier := endpoint.NewEndpointWithTTL(\"setIdentifier\", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), \"8.8.8.8\").WithSetIdentifier(\"old\")\n\tnewSetIdentifier := endpoint.NewEndpointWithTTL(\"setIdentifier\", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), \"8.8.8.8\").WithSetIdentifier(\"new\")\n\n\tassert.False(t, provider.requiresDeleteCreate(oldSetIdentifier, oldSetIdentifier), \"actual and expected endpoints don't match. %+v:%+v\", oldSetIdentifier, oldSetIdentifier)\n\tassert.True(t, provider.requiresDeleteCreate(oldSetIdentifier, newSetIdentifier), \"actual and expected endpoints don't match. %+v:%+v\", oldSetIdentifier, newSetIdentifier)\n}\n\nfunc TestConvertOctalToAscii(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tinput    string\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname:     \"Characters escaped !\\\"#$%&'()*+,-/:;\",\n\t\t\tinput:    \"txt-\\\\041\\\\042\\\\043\\\\044\\\\045\\\\046\\\\047\\\\050\\\\051\\\\052\\\\053\\\\054-\\\\057\\\\072\\\\073-test.example.com\",\n\t\t\texpected: \"txt-!\\\"#$%&'()*+,-/:;-test.example.com\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Characters escaped <=>?@[\\\\]^_`{|}~\",\n\t\t\tinput:    \"txt-\\\\074\\\\075\\\\076\\\\077\\\\100\\\\133\\\\134\\\\135\\\\136_\\\\140\\\\173\\\\174\\\\175\\\\176-test2.example.com\",\n\t\t\texpected: \"txt-<=>?@[\\\\]^_`{|}~-test2.example.com\",\n\t\t},\n\t\t{\n\t\t\tname:     \"No escaped characters in domain\",\n\t\t\tinput:    \"txt-awesome-test3.example.com\",\n\t\t\texpected: \"txt-awesome-test3.example.com\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tactual := convertOctalToAscii(tt.input)\n\t\t\tassert.Equal(t, tt.expected, actual)\n\t\t})\n\t}\n}\n\nfunc TestGeoProximityWithAWSRegion(t *testing.T) {\n\ttests := []struct {\n\t\tname           string\n\t\tregion         string\n\t\thasRegion      bool\n\t\texpectedSet    bool\n\t\texpectedRegion string\n\t}{\n\t\t{\n\t\t\tname:           \"valid AWS region\",\n\t\t\tregion:         \"us-west-2\",\n\t\t\thasRegion:      true,\n\t\t\texpectedSet:    true,\n\t\t\texpectedRegion: \"us-west-2\",\n\t\t},\n\t\t{\n\t\t\tname:           \"another valid AWS region\",\n\t\t\tregion:         \"eu-central-1\",\n\t\t\thasRegion:      true,\n\t\t\texpectedSet:    true,\n\t\t\texpectedRegion: \"eu-central-1\",\n\t\t},\n\t\t{\n\t\t\tname:           \"empty region string\",\n\t\t\tregion:         \"\",\n\t\t\thasRegion:      true,\n\t\t\texpectedSet:    true,\n\t\t\texpectedRegion: \"\",\n\t\t},\n\t\t{\n\t\t\tname:           \"no region property set\",\n\t\t\tregion:         \"\",\n\t\t\thasRegion:      false,\n\t\t\texpectedSet:    false,\n\t\t\texpectedRegion: \"\",\n\t\t},\n\t\t{\n\t\t\tname:           \"region with special characters\",\n\t\t\tregion:         \"us-gov-west-1\",\n\t\t\thasRegion:      true,\n\t\t\texpectedSet:    true,\n\t\t\texpectedRegion: \"us-gov-west-1\",\n\t\t},\n\t\t{\n\t\t\tname:           \"region with numbers\",\n\t\t\tregion:         \"ap-southeast-3\",\n\t\t\thasRegion:      true,\n\t\t\texpectedSet:    true,\n\t\t\texpectedRegion: \"ap-southeast-3\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tep := &endpoint.Endpoint{\n\t\t\t\tDNSName:       \"test.example.com\",\n\t\t\t\tSetIdentifier: \"test-set\",\n\t\t\t}\n\n\t\t\tif tt.hasRegion {\n\t\t\t\tep.SetProviderSpecificProperty(providerSpecificGeoProximityLocationAWSRegion, tt.region)\n\t\t\t}\n\n\t\t\tgp := newGeoProximity(ep)\n\t\t\tresult := gp.withAWSRegion()\n\n\t\t\tassert.Equal(t, tt.expectedSet, result.isSet)\n\n\t\t\tif tt.expectedSet {\n\t\t\t\tassert.NotNil(t, result.location.AWSRegion)\n\t\t\t\tassert.Equal(t, tt.expectedRegion, *result.location.AWSRegion)\n\t\t\t} else {\n\t\t\t\tassert.Nil(t, result.location.AWSRegion)\n\t\t\t}\n\n\t\t\t// Verify the method returns the same instance for chaining\n\t\t\tassert.Equal(t, gp, result)\n\t\t})\n\t}\n}\n\nfunc TestGeoProximityWithLocalZoneGroup(t *testing.T) {\n\ttests := []struct {\n\t\tname                   string\n\t\tlocalZoneGroup         string\n\t\thasLocalZoneGroup      bool\n\t\texpectedSet            bool\n\t\texpectedLocalZoneGroup string\n\t}{\n\t\t{\n\t\t\tname:                   \"valid local zone group\",\n\t\t\tlocalZoneGroup:         \"usw2-lax1-az1\",\n\t\t\thasLocalZoneGroup:      true,\n\t\t\texpectedSet:            true,\n\t\t\texpectedLocalZoneGroup: \"usw2-lax1-az1\",\n\t\t},\n\t\t{\n\t\t\tname:                   \"empty local zone group\",\n\t\t\tlocalZoneGroup:         \"\",\n\t\t\thasLocalZoneGroup:      true,\n\t\t\texpectedSet:            true,\n\t\t\texpectedLocalZoneGroup: \"\",\n\t\t},\n\t\t{\n\t\t\tname:                   \"no local zone group property\",\n\t\t\tlocalZoneGroup:         \"\",\n\t\t\thasLocalZoneGroup:      false,\n\t\t\texpectedSet:            false,\n\t\t\texpectedLocalZoneGroup: \"\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tep := &endpoint.Endpoint{\n\t\t\t\tDNSName:       \"test.example.com\",\n\t\t\t\tSetIdentifier: \"test-set\",\n\t\t\t}\n\n\t\t\tif tt.hasLocalZoneGroup {\n\t\t\t\tep.SetProviderSpecificProperty(providerSpecificGeoProximityLocationLocalZoneGroup, tt.localZoneGroup)\n\t\t\t}\n\n\t\t\tgp := newGeoProximity(ep)\n\t\t\tresult := gp.withLocalZoneGroup()\n\n\t\t\tassert.Equal(t, tt.expectedSet, result.isSet)\n\n\t\t\tif tt.expectedSet {\n\t\t\t\tassert.NotNil(t, result.location.LocalZoneGroup)\n\t\t\t\tassert.Equal(t, tt.expectedLocalZoneGroup, *result.location.LocalZoneGroup)\n\t\t\t} else {\n\t\t\t\tassert.Nil(t, result.location.LocalZoneGroup)\n\t\t\t}\n\n\t\t\t// Verify method returns same instance for chaining\n\t\t\tassert.Equal(t, gp, result)\n\t\t})\n\t}\n}\n\nfunc TestGeoProximityWithCoordinates(t *testing.T) {\n\ttests := []struct {\n\t\tname             string\n\t\tcoordinates      string\n\t\texpectedSet      bool\n\t\texpectedLat      string\n\t\texpectedLong     string\n\t\tshouldHaveCoords bool\n\t}{\n\t\t{\n\t\t\tname:             \"valid coordinates\",\n\t\t\tcoordinates:      \"45.0,90.0\",\n\t\t\texpectedSet:      true,\n\t\t\texpectedLat:      \"45.0\",\n\t\t\texpectedLong:     \"90.0\",\n\t\t\tshouldHaveCoords: true,\n\t\t},\n\t\t{\n\t\t\tname:             \"edge case min coordinates\",\n\t\t\tcoordinates:      \"-90.0,-180.0\",\n\t\t\texpectedSet:      true,\n\t\t\texpectedLat:      \"-90.0\",\n\t\t\texpectedLong:     \"-180.0\",\n\t\t\tshouldHaveCoords: true,\n\t\t},\n\t\t{\n\t\t\tname:             \"edge case max coordinates\",\n\t\t\tcoordinates:      \"90.0,180.0\",\n\t\t\texpectedSet:      true,\n\t\t\texpectedLat:      \"90.0\",\n\t\t\texpectedLong:     \"180.0\",\n\t\t\tshouldHaveCoords: true,\n\t\t},\n\t\t{\n\t\t\tname:             \"invalid latitude too high\",\n\t\t\tcoordinates:      \"91.0,90.0\",\n\t\t\texpectedSet:      false,\n\t\t\tshouldHaveCoords: false,\n\t\t},\n\t\t{\n\t\t\tname:             \"invalid longitude too low\",\n\t\t\tcoordinates:      \"45.0,-181.0\",\n\t\t\texpectedSet:      false,\n\t\t\tshouldHaveCoords: false,\n\t\t},\n\t\t{\n\t\t\tname:             \"invalid format - single value\",\n\t\t\tcoordinates:      \"45.0\",\n\t\t\texpectedSet:      false,\n\t\t\tshouldHaveCoords: false,\n\t\t},\n\t\t{\n\t\t\tname:             \"invalid format - three values\",\n\t\t\tcoordinates:      \"45.0,90.0,10.0\",\n\t\t\texpectedSet:      false,\n\t\t\tshouldHaveCoords: false,\n\t\t},\n\t\t{\n\t\t\tname:             \"invalid format - non-numeric\",\n\t\t\tcoordinates:      \"abc,def\",\n\t\t\texpectedSet:      false,\n\t\t\tshouldHaveCoords: false,\n\t\t},\n\t\t{\n\t\t\tname:             \"no coordinates property\",\n\t\t\tcoordinates:      \"\",\n\t\t\texpectedSet:      false,\n\t\t\tshouldHaveCoords: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tep := &endpoint.Endpoint{}\n\t\t\tif tt.coordinates != \"\" {\n\t\t\t\tep.SetProviderSpecificProperty(providerSpecificGeoProximityLocationCoordinates, tt.coordinates)\n\t\t\t}\n\n\t\t\tgp := newGeoProximity(ep)\n\t\t\tresult := gp.withCoordinates()\n\n\t\t\tassert.Equal(t, tt.expectedSet, result.isSet)\n\n\t\t\tif tt.shouldHaveCoords {\n\t\t\t\tassert.NotNil(t, result.location.Coordinates)\n\t\t\t\tassert.Equal(t, tt.expectedLat, *result.location.Coordinates.Latitude)\n\t\t\t\tassert.Equal(t, tt.expectedLong, *result.location.Coordinates.Longitude)\n\t\t\t} else {\n\t\t\t\tassert.Nil(t, result.location.Coordinates)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGeoProximityWithBias(t *testing.T) {\n\ttests := []struct {\n\t\tname         string\n\t\tbias         string\n\t\thasBias      bool\n\t\texpectedSet  bool\n\t\texpectedBias int32\n\t}{\n\t\t{\n\t\t\tname:         \"valid positive bias\",\n\t\t\tbias:         \"10\",\n\t\t\thasBias:      true,\n\t\t\texpectedSet:  true,\n\t\t\texpectedBias: 10,\n\t\t},\n\t\t{\n\t\t\tname:         \"valid negative bias\",\n\t\t\tbias:         \"-5\",\n\t\t\thasBias:      true,\n\t\t\texpectedSet:  true,\n\t\t\texpectedBias: -5,\n\t\t},\n\t\t{\n\t\t\tname:         \"zero bias\",\n\t\t\tbias:         \"0\",\n\t\t\thasBias:      true,\n\t\t\texpectedSet:  true,\n\t\t\texpectedBias: 0,\n\t\t},\n\t\t{\n\t\t\tname:         \"large positive bias\",\n\t\t\tbias:         \"99\",\n\t\t\thasBias:      true,\n\t\t\texpectedSet:  true,\n\t\t\texpectedBias: 99,\n\t\t},\n\t\t{\n\t\t\tname:         \"large negative bias\",\n\t\t\tbias:         \"-99\",\n\t\t\thasBias:      true,\n\t\t\texpectedSet:  true,\n\t\t\texpectedBias: -99,\n\t\t},\n\t\t{\n\t\t\tname:         \"invalid bias - non-numeric\",\n\t\t\tbias:         \"abc\",\n\t\t\thasBias:      true,\n\t\t\texpectedSet:  true,\n\t\t\texpectedBias: 0, // defaults to 0 on error\n\t\t},\n\t\t{\n\t\t\tname:         \"invalid bias - float\",\n\t\t\tbias:         \"10.5\",\n\t\t\thasBias:      true,\n\t\t\texpectedSet:  true,\n\t\t\texpectedBias: 0, // defaults to 0 on error\n\t\t},\n\t\t{\n\t\t\tname:         \"empty bias string\",\n\t\t\tbias:         \"\",\n\t\t\thasBias:      true,\n\t\t\texpectedSet:  true,\n\t\t\texpectedBias: 0, // defaults to 0 on error\n\t\t},\n\t\t{\n\t\t\tname:         \"no bias property\",\n\t\t\tbias:         \"\",\n\t\t\thasBias:      false,\n\t\t\texpectedSet:  false,\n\t\t\texpectedBias: 0,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tep := &endpoint.Endpoint{\n\t\t\t\tDNSName:       \"test.example.com\",\n\t\t\t\tSetIdentifier: \"test-set\",\n\t\t\t}\n\n\t\t\tif tt.hasBias {\n\t\t\t\tep.SetProviderSpecificProperty(providerSpecificGeoProximityLocationBias, tt.bias)\n\t\t\t}\n\n\t\t\tgp := newGeoProximity(ep)\n\t\t\tresult := gp.withBias()\n\n\t\t\tassert.Equal(t, tt.expectedSet, result.isSet)\n\n\t\t\tif tt.expectedSet {\n\t\t\t\tassert.NotNil(t, result.location.Bias)\n\t\t\t\tassert.Equal(t, tt.expectedBias, *result.location.Bias)\n\t\t\t} else {\n\t\t\t\tassert.Nil(t, result.location.Bias)\n\t\t\t}\n\n\t\t\t// Verify method returns same instance for chaining\n\t\t\tassert.Equal(t, gp, result)\n\t\t})\n\t}\n}\n\nfunc TestAWSProvider_createUpdateChanges_NewMoreThanOld(t *testing.T) {\n\tprovider, _ := newAWSProvider(t, endpoint.NewDomainFilter([]string{\"foo.bar.\"}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(\"\"), true, false, false, nil)\n\n\toldEndpoints := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpointWithTTL(\"record1.foo.bar.\", endpoint.RecordTypeA, endpoint.TTL(300), \"1.1.1.1\"),\n\t\tnil,\n\t}\n\tnewEndpoints := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpointWithTTL(\"record1.foo.bar.\", endpoint.RecordTypeA, endpoint.TTL(300), \"1.1.1.1\"),\n\t\tendpoint.NewEndpointWithTTL(\"record2.foo.bar.\", endpoint.RecordTypeA, endpoint.TTL(300), \"2.2.2.2\"),\n\t\tendpoint.NewEndpointWithTTL(\"record3.foo.bar.\", endpoint.RecordTypeA, endpoint.TTL(300), \"3.3.3.3\"),\n\t}\n\n\tchanges := provider.createUpdateChanges(newEndpoints, oldEndpoints)\n\n\t// record2 should be created, record1 should be upserted\n\tvar creates, upserts, deletes int\n\tfor _, c := range changes {\n\t\tswitch c.Action {\n\t\tcase route53types.ChangeActionCreate:\n\t\t\tcreates++\n\t\tcase route53types.ChangeActionUpsert:\n\t\t\tupserts++\n\t\tcase route53types.ChangeActionDelete:\n\t\t\tdeletes++\n\t\t}\n\t}\n\n\trequire.Equal(t, 0, creates, \"should create the extra new endpoint\")\n\trequire.Equal(t, 1, upserts, \"should upsert the matching endpoint\")\n\trequire.Equal(t, 0, deletes, \"should not delete anything\")\n}\n\nfunc TestAWSProvider_adjustEndpointAndNewAaaaIfNeeded(t *testing.T) {\n\ttests := []struct {\n\t\tname         string\n\t\tpreferCNAME  bool\n\t\tep           *endpoint.Endpoint\n\t\texpected     *endpoint.Endpoint\n\t\texpectedAaaa *endpoint.Endpoint\n\t}{\n\t\t// --- A / AAAA ---\n\t\t{\n\t\t\tname: \"A record without provider specific should not change and not create AAAA\",\n\t\t\tep: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"test.foo.bar.\",\n\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\tTargets:    endpoint.Targets{\"1.1.1.1\"},\n\t\t\t},\n\t\t\texpected: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"test.foo.bar.\",\n\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\tTargets:    endpoint.Targets{\"1.1.1.1\"},\n\t\t\t},\n\t\t\texpectedAaaa: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"A record with alias=true should set default ttl, add evaluateTargetHealth and not create AAAA\",\n\t\t\tep: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"test.foo.bar.\",\n\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\tTargets:    endpoint.Targets{\"1.1.1.1\"},\n\t\t\t\tRecordTTL:  600,\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificAlias,\n\t\t\t\t\t\tValue: \"true\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"test.foo.bar.\",\n\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\tTargets:    endpoint.Targets{\"1.1.1.1\"},\n\t\t\t\tRecordTTL:  defaultTTL,\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificAlias,\n\t\t\t\t\t\tValue: \"true\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificEvaluateTargetHealth,\n\t\t\t\t\t\tValue: \"false\", // p.evaluateTargetHealth=false in this test\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedAaaa: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"A record with alias!=true value should remove alias and evaluateTargetHealth and not create AAAA\",\n\t\t\tep: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"test.foo.bar.\",\n\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\tTargets:    endpoint.Targets{\"1.1.1.1\"},\n\t\t\t\tRecordTTL:  600,\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificAlias,\n\t\t\t\t\t\tValue: \"false\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificEvaluateTargetHealth,\n\t\t\t\t\t\tValue: \"true\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: &endpoint.Endpoint{\n\t\t\t\tDNSName:          \"test.foo.bar.\",\n\t\t\t\tRecordType:       endpoint.RecordTypeA,\n\t\t\t\tTargets:          endpoint.Targets{\"1.1.1.1\"},\n\t\t\t\tRecordTTL:        600,\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t},\n\t\t\texpectedAaaa: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"A record with alias=true and invalid evaluateTargetHealth should normalize it to false and set default ttl\",\n\t\t\tep: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"test.foo.bar.\",\n\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\tTargets:    endpoint.Targets{\"1.1.1.1\"},\n\t\t\t\tRecordTTL:  600,\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificAlias,\n\t\t\t\t\t\tValue: \"true\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificEvaluateTargetHealth,\n\t\t\t\t\t\tValue: \"invalid\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"test.foo.bar.\",\n\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\tTargets:    endpoint.Targets{\"1.1.1.1\"},\n\t\t\t\tRecordTTL:  defaultTTL,\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificAlias,\n\t\t\t\t\t\tValue: \"true\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificEvaluateTargetHealth,\n\t\t\t\t\t\tValue: \"false\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedAaaa: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"AAAA record with alias=true should behave like A record\",\n\t\t\tep: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"test.foo.bar.\",\n\t\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t\t\tTargets:    endpoint.Targets{\"2001:db8::1\"},\n\t\t\t\tRecordTTL:  600,\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificAlias,\n\t\t\t\t\t\tValue: \"true\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"test.foo.bar.\",\n\t\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t\t\tTargets:    endpoint.Targets{\"2001:db8::1\"},\n\t\t\t\tRecordTTL:  defaultTTL,\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificAlias,\n\t\t\t\t\t\tValue: \"true\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificEvaluateTargetHealth,\n\t\t\t\t\t\tValue: \"false\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedAaaa: nil,\n\t\t},\n\n\t\t// --- CNAME ---\n\t\t{\n\t\t\tname: \"CNAME record with alias=false should keep alias=false, remove evaluateTargetHealth and not create AAAA\",\n\t\t\tep: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"test.foo.bar.\",\n\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\tRecordTTL:  600,\n\t\t\t\tTargets:    endpoint.Targets{\"target.foo.bar.\"},\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificAlias,\n\t\t\t\t\t\tValue: \"false\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificEvaluateTargetHealth,\n\t\t\t\t\t\tValue: \"true\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"test.foo.bar.\",\n\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\tRecordTTL:  600,\n\t\t\t\tTargets:    endpoint.Targets{\"target.foo.bar.\"},\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificAlias,\n\t\t\t\t\t\tValue: \"false\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedAaaa: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"CNAME record with invalid alias value should normalize to alias=false and not create AAAA\",\n\t\t\tep: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"test.foo.bar.\",\n\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\tRecordTTL:  600,\n\t\t\t\tTargets:    endpoint.Targets{\"target.foo.bar.\"},\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificAlias,\n\t\t\t\t\t\tValue: \"invalid\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"test.foo.bar.\",\n\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\tRecordTTL:  600,\n\t\t\t\tTargets:    endpoint.Targets{\"target.foo.bar.\"},\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificAlias,\n\t\t\t\t\t\tValue: \"false\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedAaaa: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"CNAME record with alias=true should set default ttl, add evaluateTargetHealth and create AAAA\",\n\t\t\tep: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"test.foo.bar.\",\n\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\tRecordTTL:  600,\n\t\t\t\tTargets:    endpoint.Targets{\"target.foo.bar.\"},\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificAlias,\n\t\t\t\t\t\tValue: \"true\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"test.foo.bar.\",\n\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\tRecordTTL:  defaultTTL,\n\t\t\t\tTargets:    endpoint.Targets{\"target.foo.bar.\"},\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificAlias,\n\t\t\t\t\t\tValue: \"true\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificEvaluateTargetHealth,\n\t\t\t\t\t\tValue: \"false\", // p.evaluateTargetHealth=false in this test\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedAaaa: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"test.foo.bar.\",\n\t\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t\t\tRecordTTL:  defaultTTL,\n\t\t\t\tTargets:    endpoint.Targets{\"target.foo.bar.\"},\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificAlias,\n\t\t\t\t\t\tValue: \"true\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificEvaluateTargetHealth,\n\t\t\t\t\t\tValue: \"false\", // p.evaluateTargetHealth=false in this test\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"CNAME record with alias=true and evaluateTargetHealth=true should keep evaluateTargetHealth and create AAAA\",\n\t\t\tep: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"test.foo.bar.\",\n\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\tRecordTTL:  600,\n\t\t\t\tTargets:    endpoint.Targets{\"target.foo.bar.\"},\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificAlias,\n\t\t\t\t\t\tValue: \"true\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificEvaluateTargetHealth,\n\t\t\t\t\t\tValue: \"true\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"test.foo.bar.\",\n\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\tRecordTTL:  defaultTTL,\n\t\t\t\tTargets:    endpoint.Targets{\"target.foo.bar.\"},\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificAlias,\n\t\t\t\t\t\tValue: \"true\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificEvaluateTargetHealth,\n\t\t\t\t\t\tValue: \"true\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedAaaa: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"test.foo.bar.\",\n\t\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t\t\tRecordTTL:  defaultTTL,\n\t\t\t\tTargets:    endpoint.Targets{\"target.foo.bar.\"},\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificAlias,\n\t\t\t\t\t\tValue: \"true\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificEvaluateTargetHealth,\n\t\t\t\t\t\tValue: \"true\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"CNAME record with alias=true and invalid evaluateTargetHealth should normalize it to false and create AAAA\",\n\t\t\tep: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"test.foo.bar.\",\n\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\tRecordTTL:  600,\n\t\t\t\tTargets:    endpoint.Targets{\"target.foo.bar.\"},\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificAlias,\n\t\t\t\t\t\tValue: \"true\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificEvaluateTargetHealth,\n\t\t\t\t\t\tValue: \"invalid\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"test.foo.bar.\",\n\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\tRecordTTL:  defaultTTL,\n\t\t\t\tTargets:    endpoint.Targets{\"target.foo.bar.\"},\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificAlias,\n\t\t\t\t\t\tValue: \"true\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificEvaluateTargetHealth,\n\t\t\t\t\t\tValue: \"false\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedAaaa: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"test.foo.bar.\",\n\t\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t\t\tRecordTTL:  defaultTTL,\n\t\t\t\tTargets:    endpoint.Targets{\"target.foo.bar.\"},\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificAlias,\n\t\t\t\t\t\tValue: \"true\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificEvaluateTargetHealth,\n\t\t\t\t\t\tValue: \"false\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"CNAME without alias to ELB target should enable alias and create AAAA\",\n\t\t\tep: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"test.foo.bar.\",\n\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\tTargets:    endpoint.Targets{\"test-123.us-east-1.elb.amazonaws.com\"},\n\t\t\t},\n\t\t\texpected: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"test.foo.bar.\",\n\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\tTargets:    endpoint.Targets{\"test-123.us-east-1.elb.amazonaws.com\"},\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificAlias,\n\t\t\t\t\t\tValue: \"true\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificEvaluateTargetHealth,\n\t\t\t\t\t\tValue: \"false\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedAaaa: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"test.foo.bar.\",\n\t\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t\t\tTargets:    endpoint.Targets{\"test-123.us-east-1.elb.amazonaws.com\"},\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificAlias,\n\t\t\t\t\t\tValue: \"true\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificEvaluateTargetHealth,\n\t\t\t\t\t\tValue: \"false\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:        \"CNAME with preferCNAME=true should set alias=false and not create AAAA even for ELB target\",\n\t\t\tpreferCNAME: true,\n\t\t\tep: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"test.foo.bar.\",\n\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\tTargets:    endpoint.Targets{\"test-123.us-east-1.elb.amazonaws.com.\"},\n\t\t\t},\n\t\t\texpected: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"test.foo.bar.\",\n\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\tTargets:    endpoint.Targets{\"test-123.us-east-1.elb.amazonaws.com.\"},\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificAlias,\n\t\t\t\t\t\tValue: \"false\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedAaaa: nil,\n\t\t},\n\n\t\t// --- MX / other records ---\n\t\t{\n\t\t\tname: \"MX record without provider specific should not change and not create AAAA\",\n\t\t\tep: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"test.foo.bar.\",\n\t\t\t\tRecordType: endpoint.RecordTypeMX,\n\t\t\t\tTargets:    endpoint.Targets{\"10 mail.example.com.\"},\n\t\t\t},\n\t\t\texpected: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"test.foo.bar.\",\n\t\t\t\tRecordType: endpoint.RecordTypeMX,\n\t\t\t\tTargets:    endpoint.Targets{\"10 mail.example.com.\"},\n\t\t\t},\n\t\t\texpectedAaaa: nil,\n\t\t},\n\t\t// TODO: fix For records other than A, AAAA, and CNAME, if an alias record is set, the alias record processing is not performed. This will be fixed in another PR.\n\t\t{\n\t\t\tname: \"MX record with alias=true should remove alias and set default ttl, add evaluateTargetHealth and not create AAAA\",\n\t\t\tep: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"test.foo.bar.\",\n\t\t\t\tRecordType: endpoint.RecordTypeMX,\n\t\t\t\tTargets:    endpoint.Targets{\"10 mail.example.com.\"},\n\t\t\t\tRecordTTL:  600,\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificAlias,\n\t\t\t\t\t\tValue: \"true\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"test.foo.bar.\",\n\t\t\t\tRecordType: endpoint.RecordTypeMX,\n\t\t\t\tTargets:    endpoint.Targets{\"10 mail.example.com.\"},\n\t\t\t\tRecordTTL:  defaultTTL,\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  providerSpecificEvaluateTargetHealth,\n\t\t\t\t\t\tValue: \"false\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedAaaa: nil,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\tp, _ := newAWSProvider(\n\t\t\t\tt,\n\t\t\t\tendpoint.NewDomainFilter([]string{\"foo.bar.\"}),\n\t\t\t\tprovider.NewZoneIDFilter([]string{}),\n\t\t\t\tprovider.NewZoneTypeFilter(\"\"),\n\t\t\t\tfalse,\n\t\t\t\ttt.preferCNAME,\n\t\t\t\tfalse,\n\t\t\t\tnil,\n\t\t\t)\n\n\t\t\taaaa := p.adjustEndpointAndNewAaaaIfNeeded(tt.ep)\n\n\t\t\tassert.True(t, testutils.SameEndpoint(tt.ep, tt.expected),\n\t\t\t\t\"actual and expected endpoints don't match. %+v:%+v\", tt.ep, tt.expected)\n\n\t\t\tassert.True(t, testutils.SameEndpoint(aaaa, tt.expectedAaaa),\n\t\t\t\t\"actual and expected AAAA endpoints don't match. %+v:%+v\", aaaa, tt.expectedAaaa)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "provider/aws/aws_utils_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage aws\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/aws/aws-sdk-go-v2/service/route53\"\n\troute53types \"github.com/aws/aws-sdk-go-v2/service/route53/types\"\n\t\"github.com/goccy/go-yaml\"\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/provider\"\n\t\"sigs.k8s.io/external-dns/provider/blueprint\"\n)\n\ntype HostedZones struct {\n\tZones []*HostedZone `yaml:\"zones\"`\n}\n\ntype HostedZone struct {\n\tName string\n\tID   string\n\tTags []route53types.Tag `yaml:\"tags\"`\n}\n\nvar _ Route53API = &Route53APIFixtureStub{}\n\ntype Route53APIFixtureStub struct {\n\tzones    map[string]*route53types.HostedZone\n\tzoneTags map[string][]route53types.Tag\n\tcalls    map[string]int\n}\n\nfunc providerFilters(client *Route53APIFixtureStub, options ...func(awsProvider *AWSProvider)) *AWSProvider {\n\tp := &AWSProvider{\n\t\tclients:              map[string]Route53API{defaultAWSProfile: client},\n\t\tevaluateTargetHealth: false,\n\t\tdryRun:               false,\n\t\tdomainFilter:         &endpoint.DomainFilter{},\n\t\tzoneIDFilter:         provider.NewZoneIDFilter([]string{}),\n\t\tzoneTypeFilter:       provider.NewZoneTypeFilter(\"\"),\n\t\tzoneTagFilter:        provider.NewZoneTagFilter([]string{}),\n\t\tzonesCache:           blueprint.NewZoneCache[map[string]*profiledZone](1 * time.Second),\n\t}\n\tfor _, o := range options {\n\t\to(p)\n\t}\n\treturn p\n}\n\nfunc WithDomainFilters(filters ...string) func(awsProvider *AWSProvider) {\n\treturn func(awsProvider *AWSProvider) {\n\t\tawsProvider.domainFilter = endpoint.NewDomainFilter(filters)\n\t}\n}\n\nfunc WithZoneIDFilters(filters ...string) func(awsProvider *AWSProvider) {\n\treturn func(awsProvider *AWSProvider) {\n\t\tawsProvider.zoneIDFilter = provider.NewZoneIDFilter(filters)\n\t}\n}\n\nfunc WithZoneTagFilters(filters []string) func(awsProvider *AWSProvider) {\n\treturn func(awsProvider *AWSProvider) {\n\t\tawsProvider.zoneTagFilter = provider.NewZoneTagFilter(filters)\n\t}\n}\n\nfunc NewRoute53APIFixtureStub(zones *HostedZones) *Route53APIFixtureStub {\n\troute53Zones := make(map[string]*route53types.HostedZone)\n\tzoneTags := make(map[string][]route53types.Tag)\n\tfor _, zone := range zones.Zones {\n\t\troute53Zones[zone.ID] = &route53types.HostedZone{\n\t\t\tId:   &zone.ID,\n\t\t\tName: &zone.Name,\n\t\t}\n\t\tzoneTags[cleanZoneID(zone.ID)] = zone.Tags\n\t}\n\treturn &Route53APIFixtureStub{\n\t\tzones:    route53Zones,\n\t\tzoneTags: zoneTags,\n\t\tcalls:    make(map[string]int),\n\t}\n}\n\nfunc (r Route53APIFixtureStub) ListResourceRecordSets(_ context.Context, _ *route53.ListResourceRecordSetsInput, _ ...func(options *route53.Options)) (*route53.ListResourceRecordSetsOutput, error) {\n\t// TODO implement me\n\tpanic(\"implement me\")\n}\n\nfunc (r Route53APIFixtureStub) ChangeResourceRecordSets(_ context.Context, _ *route53.ChangeResourceRecordSetsInput, _ ...func(options *route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error) {\n\t// TODO implement me\n\tpanic(\"implement me\")\n}\n\nfunc (r Route53APIFixtureStub) CreateHostedZone(_ context.Context, _ *route53.CreateHostedZoneInput, _ ...func(*route53.Options)) (*route53.CreateHostedZoneOutput, error) {\n\t// TODO implement me\n\tpanic(\"implement me\")\n}\n\nfunc (r Route53APIFixtureStub) ListHostedZones(_ context.Context, _ *route53.ListHostedZonesInput, _ ...func(options *route53.Options)) (*route53.ListHostedZonesOutput, error) {\n\tr.calls[\"listhostedzones\"]++\n\toutput := &route53.ListHostedZonesOutput{}\n\tfor _, zone := range r.zones {\n\t\toutput.HostedZones = append(output.HostedZones, *zone)\n\t}\n\treturn output, nil\n}\n\nfunc (r Route53APIFixtureStub) ListTagsForResources(_ context.Context, input *route53.ListTagsForResourcesInput, _ ...func(options *route53.Options)) (*route53.ListTagsForResourcesOutput, error) {\n\tr.calls[\"listtagsforresource\"]++\n\n\tvar sets []route53types.ResourceTagSet\n\n\tfor _, el := range input.ResourceIds {\n\t\tif r.zoneTags[el] != nil {\n\t\t\tsets = append(sets, route53types.ResourceTagSet{\n\t\t\t\tResourceId:   &el,\n\t\t\t\tResourceType: route53types.TagResourceTypeHostedzone,\n\t\t\t\tTags:         r.zoneTags[el],\n\t\t\t})\n\t\t}\n\t}\n\treturn &route53.ListTagsForResourcesOutput{ResourceTagSets: sets}, nil\n}\n\nfunc unmarshalZonesFixture(obj any, t *testing.T) {\n\tt.Helper()\n\tpath, _ := os.Getwd()\n\tfile, err := os.Open(path + \"/fixtures/160-plus-zones.yaml\")\n\tassert.NoError(t, err)\n\tdefer file.Close()\n\tdec := yaml.NewDecoder(file)\n\terr = dec.Decode(obj)\n\tassert.NoError(t, err)\n}\n"
  },
  {
    "path": "provider/aws/config.go",
    "content": "/*\nCopyright 2023 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage aws\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\tawsv2 \"github.com/aws/aws-sdk-go-v2/aws\"\n\n\t\"github.com/aws/aws-sdk-go-v2/aws/retry\"\n\t\"github.com/aws/aws-sdk-go-v2/config\"\n\tstscredsv2 \"github.com/aws/aws-sdk-go-v2/credentials/stscreds\"\n\t\"github.com/aws/aws-sdk-go-v2/service/sts\"\n\t\"github.com/sirupsen/logrus\"\n\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n)\n\n// AWSSessionConfig contains configuration to create a new AWS provider.\ntype AWSSessionConfig struct {\n\tAssumeRole           string\n\tAssumeRoleExternalID string\n\tAPIRetries           int\n\tProfile              string\n}\n\nfunc CreateDefaultV2Config(cfg *externaldns.Config) awsv2.Config {\n\tresult, err := newV2Config(\n\t\tAWSSessionConfig{\n\t\t\tAssumeRole:           cfg.AWSAssumeRole,\n\t\t\tAssumeRoleExternalID: cfg.AWSAssumeRoleExternalID,\n\t\t\tAPIRetries:           cfg.AWSAPIRetries,\n\t\t},\n\t)\n\tif err != nil {\n\t\tlogrus.Fatal(err)\n\t}\n\treturn result\n}\n\nfunc CreateV2Configs(cfg *externaldns.Config) map[string]awsv2.Config {\n\tresult := make(map[string]awsv2.Config)\n\tif len(cfg.AWSProfiles) == 0 || (len(cfg.AWSProfiles) == 1 && cfg.AWSProfiles[0] == \"\") {\n\t\tcfg := CreateDefaultV2Config(cfg)\n\t\tresult[defaultAWSProfile] = cfg\n\t} else {\n\t\tfor _, profile := range cfg.AWSProfiles {\n\t\t\tcfg, err := newV2Config(\n\t\t\t\tAWSSessionConfig{\n\t\t\t\t\tAssumeRole:           cfg.AWSAssumeRole,\n\t\t\t\t\tAssumeRoleExternalID: cfg.AWSAssumeRoleExternalID,\n\t\t\t\t\tAPIRetries:           cfg.AWSAPIRetries,\n\t\t\t\t\tProfile:              profile,\n\t\t\t\t},\n\t\t\t)\n\t\t\tif err != nil {\n\t\t\t\tlogrus.Fatal(err)\n\t\t\t}\n\t\t\tresult[profile] = cfg\n\t\t}\n\t}\n\treturn result\n}\n\nfunc newV2Config(awsConfig AWSSessionConfig) (awsv2.Config, error) {\n\tdefaultOpts := []func(*config.LoadOptions) error{\n\t\tconfig.WithRetryer(func() awsv2.Retryer {\n\t\t\treturn retry.AddWithMaxAttempts(retry.NewStandard(), awsConfig.APIRetries)\n\t\t}),\n\t\tconfig.WithSharedConfigProfile(awsConfig.Profile),\n\t\tconfig.WithAPIOptions(GetInstrumentationMiddlewares()),\n\t}\n\n\tcfg, err := config.LoadDefaultConfig(context.Background(), defaultOpts...)\n\tif err != nil {\n\t\treturn awsv2.Config{}, fmt.Errorf(\"instantiating AWS config: %w\", err)\n\t}\n\n\tif awsConfig.AssumeRole != \"\" {\n\t\tstsSvc := sts.NewFromConfig(cfg)\n\t\tvar assumeRoleOpts []func(*stscredsv2.AssumeRoleOptions)\n\t\tif awsConfig.AssumeRoleExternalID != \"\" {\n\t\t\tlogrus.Infof(\"Assuming role %s with external id\", awsConfig.AssumeRole)\n\t\t\tlogrus.Debugf(\"External id: %s\", awsConfig.AssumeRoleExternalID)\n\t\t\tassumeRoleOpts = []func(*stscredsv2.AssumeRoleOptions){\n\t\t\t\tfunc(opts *stscredsv2.AssumeRoleOptions) {\n\t\t\t\t\topts.ExternalID = &awsConfig.AssumeRoleExternalID\n\t\t\t\t},\n\t\t\t}\n\t\t} else {\n\t\t\tlogrus.Infof(\"Assuming role: %s\", awsConfig.AssumeRole)\n\t\t}\n\t\tcreds := stscredsv2.NewAssumeRoleProvider(stsSvc, awsConfig.AssumeRole, assumeRoleOpts...)\n\t\tcfg.Credentials = awsv2.NewCredentialsCache(creds)\n\t}\n\n\treturn cfg, nil\n}\n"
  },
  {
    "path": "provider/aws/config_test.go",
    "content": "/*\nCopyright 2023 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage aws\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"testing\"\n\n\t\"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"sigs.k8s.io/external-dns/internal/testutils\"\n\tlogtest \"sigs.k8s.io/external-dns/internal/testutils/log\"\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n)\n\nfunc Test_newV2Config(t *testing.T) {\n\ttestutils.TestHelperEnvSetter(t, map[string]string{\n\t\t\"AWS_REGION\":                \"us-east-1\",\n\t\t\"AWS_EC2_METADATA_DISABLED\": \"true\",\n\t})\n\n\tt.Run(\"should use profile from credentials file\", func(t *testing.T) {\n\t\t// setup\n\t\tcredsFile, err := prepareCredentialsFile(t)\n\t\tdefer os.Remove(credsFile.Name())\n\t\trequire.NoError(t, err)\n\t\ttestutils.TestHelperEnvSetter(t, map[string]string{\n\t\t\t\"AWS_SHARED_CREDENTIALS_FILE\": credsFile.Name(),\n\t\t})\n\n\t\t// when\n\t\tcfg, err := newV2Config(AWSSessionConfig{Profile: \"profile2\"})\n\t\trequire.NoError(t, err)\n\t\tcreds, err := cfg.Credentials.Retrieve(t.Context())\n\n\t\t// then\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, \"AKID2345\", creds.AccessKeyID)\n\t\tassert.Equal(t, \"SECRET2\", creds.SecretAccessKey)\n\t})\n\n\tt.Run(\"should respect env variables without profile\", func(t *testing.T) {\n\t\t// setup\n\t\ttestutils.TestHelperEnvSetter(t, map[string]string{\n\t\t\t\"AWS_ACCESS_KEY_ID\":     \"AKIAIOSFODNN7EXAMPLE\",\n\t\t\t\"AWS_SECRET_ACCESS_KEY\": \"topsecret\",\n\t\t})\n\n\t\t// when\n\t\tcfg, err := newV2Config(AWSSessionConfig{})\n\t\trequire.NoError(t, err)\n\t\tcreds, err := cfg.Credentials.Retrieve(t.Context())\n\n\t\t// then\n\t\tassert.NoError(t, err)\n\t\tassert.Equal(t, \"AKIAIOSFODNN7EXAMPLE\", creds.AccessKeyID)\n\t\tassert.Equal(t, \"topsecret\", creds.SecretAccessKey)\n\t})\n\n\tt.Run(\"should not error when AWS_CA_BUNDLE set\", func(t *testing.T) {\n\t\t// setup\n\t\ttestutils.TestHelperEnvSetter(t, map[string]string{\n\t\t\t\"AWS_CA_BUNDLE\": \"../../internal/testresources/ca.pem\",\n\t\t})\n\n\t\t// when\n\t\t_, err := newV2Config(AWSSessionConfig{})\n\t\trequire.NoError(t, err)\n\n\t\t// then\n\t\tassert.NoError(t, err)\n\t})\n\n\tt.Run(\"should configure assume role credentials\", func(t *testing.T) {\n\t\t// setup\n\t\ttestutils.TestHelperEnvSetter(t, map[string]string{\n\t\t\t\"AWS_ACCESS_KEY_ID\":     \"AKIAIOSFODNN7EXAMPLE\",\n\t\t\t\"AWS_SECRET_ACCESS_KEY\": \"topsecret\",\n\t\t})\n\n\t\t// when\n\t\tcfg, err := newV2Config(AWSSessionConfig{\n\t\t\tAssumeRole:           \"arn:aws:iam::123456789012:role/example\",\n\t\t\tAssumeRoleExternalID: \"external-id\",\n\t\t})\n\n\t\t// then\n\t\trequire.NoError(t, err)\n\t\trequire.NotNil(t, cfg.Credentials)\n\t\tassert.Contains(t, fmt.Sprintf(\"%T\", cfg.Credentials), \"CredentialsCache\")\n\t})\n\n\tt.Run(\"should log assume role without external ID\", func(t *testing.T) {\n\t\t// setup\n\t\ttestutils.TestHelperEnvSetter(t, map[string]string{\n\t\t\t\"AWS_ACCESS_KEY_ID\":     \"AKIAIOSFODNN7EXAMPLE\",\n\t\t\t\"AWS_SECRET_ACCESS_KEY\": \"topsecret\",\n\t\t})\n\n\t\thook := logtest.LogsUnderTestWithLogLevel(logrus.InfoLevel, t)\n\t\tdefer hook.Reset()\n\n\t\t// when\n\t\t_, err := newV2Config(AWSSessionConfig{\n\t\t\tAssumeRole: \"arn:aws:iam::123456789012:role/example\",\n\t\t})\n\n\t\t// then\n\t\trequire.NoError(t, err)\n\t\tlogtest.TestHelperLogContainsWithLogLevel(\n\t\t\t\"Assuming role: arn:aws:iam::123456789012:role/example\",\n\t\t\tlogrus.InfoLevel,\n\t\t\thook,\n\t\t\tt,\n\t\t)\n\t})\n\n\tt.Run(\"returns error when config cannot be loaded\", func(t *testing.T) {\n\t\t// setup\n\t\ttestutils.TestHelperEnvSetter(t, map[string]string{\n\t\t\t\"AWS_SHARED_CREDENTIALS_FILE\": \"missing-ca.pem\",\n\t\t})\n\n\t\t// when\n\t\t_, err := newV2Config(AWSSessionConfig{Profile: \"profile1\"})\n\n\t\t// then\n\t\trequire.Error(t, err)\n\t\tassert.Contains(t, err.Error(), \"instantiating AWS config\")\n\t})\n}\n\nfunc prepareCredentialsFile(t *testing.T) (*os.File, error) {\n\tcredsFile, err := os.CreateTemp(t.TempDir(), \"aws-*.creds\")\n\trequire.NoError(t, err)\n\t_, err = credsFile.WriteString(\"[profile1]\\naws_access_key_id=AKID1234\\naws_secret_access_key=SECRET1\\n\\n[profile2]\\naws_access_key_id=AKID2345\\naws_secret_access_key=SECRET2\\n\")\n\trequire.NoError(t, err)\n\terr = credsFile.Close()\n\trequire.NoError(t, err)\n\treturn credsFile, err\n}\n\nfunc TestCreateV2Configs(t *testing.T) {\n\ttestutils.TestHelperEnvSetter(t, map[string]string{\n\t\t\"AWS_REGION\":                \"us-east-1\",\n\t\t\"AWS_EC2_METADATA_DISABLED\": \"true\",\n\t})\n\n\tt.Run(\"returns default profile when none configured\", func(t *testing.T) {\n\t\t// setup\n\t\ttestutils.TestHelperEnvSetter(t, map[string]string{\n\t\t\t\"AWS_ACCESS_KEY_ID\":     \"AKIAIOSFODNN7EXAMPLE\",\n\t\t\t\"AWS_SECRET_ACCESS_KEY\": \"topsecret\",\n\t\t})\n\n\t\tcfg := &externaldns.Config{\n\t\t\tAWSAPIRetries: 3,\n\t\t}\n\n\t\t// when\n\t\tconfigs := CreateV2Configs(cfg)\n\n\t\t// then\n\t\trequire.Len(t, configs, 1)\n\t\t_, ok := configs[defaultAWSProfile]\n\t\tassert.True(t, ok)\n\t})\n\n\tt.Run(\"returns profile configs when configured\", func(t *testing.T) {\n\t\t// setup\n\t\tcredsFile, err := prepareCredentialsFile(t)\n\t\tdefer os.Remove(credsFile.Name())\n\t\trequire.NoError(t, err)\n\t\ttestutils.TestHelperEnvSetter(t, map[string]string{\n\t\t\t\"AWS_SHARED_CREDENTIALS_FILE\": credsFile.Name(),\n\t\t})\n\n\t\tcfg := &externaldns.Config{\n\t\t\tAWSProfiles:   []string{\"profile1\", \"profile2\"},\n\t\t\tAWSAPIRetries: 2,\n\t\t}\n\n\t\t// when\n\t\tconfigs := CreateV2Configs(cfg)\n\n\t\t// then\n\t\trequire.Len(t, configs, 2)\n\t\tcreds, err := configs[\"profile1\"].Credentials.Retrieve(t.Context())\n\t\trequire.NoError(t, err)\n\t\tassert.Equal(t, \"AKID1234\", creds.AccessKeyID)\n\t\tassert.Equal(t, \"SECRET1\", creds.SecretAccessKey)\n\n\t\tcreds, err = configs[\"profile2\"].Credentials.Retrieve(t.Context())\n\t\trequire.NoError(t, err)\n\t\tassert.Equal(t, \"AKID2345\", creds.AccessKeyID)\n\t\tassert.Equal(t, \"SECRET2\", creds.SecretAccessKey)\n\t})\n}\n\nfunc TestCreateConfigFatalOnError(t *testing.T) {\n\ttestutils.TestHelperEnvSetter(t, map[string]string{\n\t\t\"AWS_REGION\":                \"us-east-1\",\n\t\t\"AWS_EC2_METADATA_DISABLED\": \"true\",\n\t})\n\n\tt.Run(\"CreateDefaultV2Config exits on load error\", func(t *testing.T) {\n\t\ttestutils.TestHelperEnvSetter(t, map[string]string{\n\t\t\t\"AWS_PROFILE\":                 \"profile1\",\n\t\t\t\"AWS_SHARED_CREDENTIALS_FILE\": \"missing-ca.pem\",\n\t\t})\n\n\t\texitCode := 0\n\t\t_ = logtest.TestHelperWithLogExitFunc(func(code int) {\n\t\t\texitCode = code\n\t\t\tpanic(\"exit\")\n\t\t})\n\n\t\tassert.Panics(t, func() {\n\t\t\tCreateDefaultV2Config(&externaldns.Config{})\n\t\t})\n\t\tassert.Equal(t, 1, exitCode)\n\t})\n\n\tt.Run(\"CreateV2Configs exits on load error\", func(t *testing.T) {\n\t\ttestutils.TestHelperEnvSetter(t, map[string]string{\n\t\t\t\"AWS_SHARED_CREDENTIALS_FILE\": \"missing-ca.pem\",\n\t\t})\n\n\t\texitCode := 0\n\t\t_ = logtest.TestHelperWithLogExitFunc(func(code int) {\n\t\t\texitCode = code\n\t\t\tpanic(\"exit\")\n\t\t})\n\n\t\tassert.Panics(t, func() {\n\t\t\tCreateV2Configs(&externaldns.Config{AWSProfiles: []string{\"profile1\"}})\n\t\t})\n\t\tassert.Equal(t, 1, exitCode)\n\t})\n}\n"
  },
  {
    "path": "provider/aws/fixtures/160-plus-zones.yaml",
    "content": "# AWS zones fixtures with tags\n# number of zones 160+ (root domain + subdomains)\n# root domain ex.com\n# subdomains examples in the form of\n# - x1.ex.com\n# - ........\n# - x7.x6.x5.x4.x3.x2.x1.ex.com\nzones:\n- name: ex.com.\n  id: /hostedzone/Z10242883PKPS38KA4S6C\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: level\n    value: root\n  - key: managed\n    value: terraform\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: x7.x6.x5.x4.x3.x2.x1.ex.com.\n  id: /hostedzone/Z1032002B96QH7HFX83T\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: x6.x5.x4.x3.x2.x1.ex.com\n  - key: level\n    value: \"7\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: x7.x6.x5.x4.x3.x2.x1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: u7.u6.u5.u4.u3.u2.u1.ex.com.\n  id: /hostedzone/Z10320232590ZA96YS1UU\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: u6.u5.u4.u3.u2.u1.ex.com\n  - key: level\n    value: \"7\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: u7.u6.u5.u4.u3.u2.u1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: a7.a6.a5.a4.a3.a2.a1.ex.com.\n  id: /hostedzone/Z10315369KW0URNJU6RK\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: a6.a5.a4.a3.a2.a1.ex.com\n  - key: level\n    value: \"7\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: a7.a6.a5.a4.a3.a2.a1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: j7.j6.j5.j4.j3.j2.j1.ex.com.\n  id: /hostedzone/Z10295763LSQ170JCTR78\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: j6.j5.j4.j3.j2.j1.ex.com\n  - key: level\n    value: \"7\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: j7.j6.j5.j4.j3.j2.j1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: c7.c6.c5.c4.c3.c2.c1.ex.com.\n  id: /hostedzone/Z10288493DKATL8232JNZ\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: c6.c5.c4.c3.c2.c1.ex.com\n  - key: level\n    value: \"7\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: c7.c6.c5.c4.c3.c2.c1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: f7.f6.f5.f4.f3.f2.f1.ex.com.\n  id: /hostedzone/Z102884820EE3LQYU9WOM\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: f6.f5.f4.f3.f2.f1.ex.com\n  - key: level\n    value: \"7\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: f7.f6.f5.f4.f3.f2.f1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: w7.w6.w5.w4.w3.w2.w1.ex.com.\n  id: /hostedzone/Z10283413MVB9WV61S7OA\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: w6.w5.w4.w3.w2.w1.ex.com\n  - key: level\n    value: \"7\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: w7.w6.w5.w4.w3.w2.w1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: r1.ex.com.\n  id: /hostedzone/Z1016808BHW7INWWYYIF\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: ex.com\n  - key: level\n    value: \"1\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: r1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: h1.ex.com.\n  id: /hostedzone/Z1016090E4ZMPH0OUE8Z\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: ex.com\n  - key: level\n    value: \"1\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: h1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: t1.ex.com.\n  id: /hostedzone/Z10161971RT6SUM2O9Q5E\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: ex.com\n  - key: level\n    value: \"1\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: t1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: o1.ex.com.\n  id: /hostedzone/Z10159761394AY0UJXGVW\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: ex.com\n  - key: level\n    value: \"1\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: o1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: g1.ex.com.\n  id: /hostedzone/Z10160182ROTV7D06VGRH\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: ex.com\n  - key: level\n    value: \"1\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: g1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: l2.l1.ex.com.\n  id: /hostedzone/Z10238911BEICMQG6K7N4\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: l1.ex.com\n  - key: level\n    value: \"2\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: l2.l1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: d1.ex.com.\n  id: /hostedzone/Z10238042ATQ2R880EA30\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: ex.com\n  - key: level\n    value: \"1\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: d1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: g2.g1.ex.com.\n  id: /hostedzone/Z102355127WZ6HZSCS1UQ\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: g1.ex.com\n  - key: level\n    value: \"2\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: g2.g1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: k1.ex.com.\n  id: /hostedzone/Z10228862S8D8AWIBRPX5\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: ex.com\n  - key: level\n    value: \"1\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: k1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: z2.z1.ex.com.\n  id: /hostedzone/Z1022753YQV9L66OQKKG\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: z1.ex.com\n  - key: level\n    value: \"2\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: z2.z1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: n1.ex.com.\n  id: /hostedzone/Z10222872GS2T2IW5YE39\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: ex.com\n  - key: level\n    value: \"1\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: n1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: p3.p2.p1.ex.com.\n  id: /hostedzone/Z10392163D8GY90CD0H8\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: p2.p1.ex.com\n  - key: level\n    value: \"3\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: p3.p2.p1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: z3.z2.z1.ex.com.\n  id: /hostedzone/Z10385543080IK4J0F0TN\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: z2.z1.ex.com\n  - key: level\n    value: \"3\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: z3.z2.z1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: m3.m2.m1.ex.com.\n  id: /hostedzone/Z10382283MVOVGXANQEYL\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: m2.m1.ex.com\n  - key: level\n    value: \"3\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: m3.m2.m1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: r4.r3.r2.r1.ex.com.\n  id: /hostedzone/Z1038243J4I23XS7OSKF\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: r3.r2.r1.ex.com\n  - key: level\n    value: \"4\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: r4.r3.r2.r1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: k3.k2.k1.ex.com.\n  id: /hostedzone/Z103808619D40WT1DR49J\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: k2.k1.ex.com\n  - key: level\n    value: \"3\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: k3.k2.k1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: p4.p3.p2.p1.ex.com.\n  id: /hostedzone/Z1038033327YHLUQQI3X2\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: p3.p2.p1.ex.com\n  - key: level\n    value: \"4\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: p4.p3.p2.p1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: m5.m4.m3.m2.m1.ex.com.\n  id: /hostedzone/Z0533089I4KYU2CI4QI1\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: m4.m3.m2.m1.ex.com\n  - key: level\n    value: \"5\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: m5.m4.m3.m2.m1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: c5.c4.c3.c2.c1.ex.com.\n  id: /hostedzone/Z05330532TU88KT3RR8DV\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: c4.c3.c2.c1.ex.com\n  - key: level\n    value: \"5\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: c5.c4.c3.c2.c1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: r5.r4.r3.r2.r1.ex.com.\n  id: /hostedzone/Z05330482Y4BD73GXQL9R\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: r4.r3.r2.r1.ex.com\n  - key: level\n    value: \"5\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: r5.r4.r3.r2.r1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: g5.g4.g3.g2.g1.ex.com.\n  id: /hostedzone/Z0532558273KTRBR8UW6J\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: g4.g3.g2.g1.ex.com\n  - key: level\n    value: \"5\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: g5.g4.g3.g2.g1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: r6.r5.r4.r3.r2.r1.ex.com.\n  id: /hostedzone/Z054065934ZVPHZZKEPH4\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: r5.r4.r3.r2.r1.ex.com\n  - key: level\n    value: \"6\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: r6.r5.r4.r3.r2.r1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: f5.f4.f3.f2.f1.ex.com.\n  id: /hostedzone/Z053991036YAFX9ZO6WWP\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: f4.f3.f2.f1.ex.com\n  - key: level\n    value: \"5\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: f5.f4.f3.f2.f1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: a5.a4.a3.a2.a1.ex.com.\n  id: /hostedzone/Z05399152X3HLIXV18OFH\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: a4.a3.a2.a1.ex.com\n  - key: level\n    value: \"5\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: a5.a4.a3.a2.a1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: g6.g5.g4.g3.g2.g1.ex.com.\n  id: /hostedzone/Z0539521253N0CZENGVYT\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: g5.g4.g3.g2.g1.ex.com\n  - key: level\n    value: \"6\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: g6.g5.g4.g3.g2.g1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: h5.h4.h3.h2.h1.ex.com.\n  id: /hostedzone/Z0539437KPBCRSU5OQS2\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: h4.h3.h2.h1.ex.com\n  - key: level\n    value: \"5\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: h5.h4.h3.h2.h1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: z6.z5.z4.z3.z2.z1.ex.com.\n  id: /hostedzone/Z05393465GOC3P191Z3M\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: z5.z4.z3.z2.z1.ex.com\n  - key: level\n    value: \"6\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: z6.z5.z4.z3.z2.z1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: x6.x5.x4.x3.x2.x1.ex.com.\n  id: /hostedzone/Z05392373JKKXLCN3FNI4\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: x5.x4.x3.x2.x1.ex.com\n  - key: level\n    value: \"6\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: x6.x5.x4.x3.x2.x1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: j5.j4.j3.j2.j1.ex.com.\n  id: /hostedzone/Z0539046IFXW5PM8YN1H\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: j4.j3.j2.j1.ex.com\n  - key: level\n    value: \"5\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: j5.j4.j3.j2.j1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: d6.d5.d4.d3.d2.d1.ex.com.\n  id: /hostedzone/Z053882914TO782DFG8CG\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: d5.d4.d3.d2.d1.ex.com\n  - key: level\n    value: \"6\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: d6.d5.d4.d3.d2.d1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: z5.z4.z3.z2.z1.ex.com.\n  id: /hostedzone/Z0538661Y3HB6MCJOE82\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: z4.z3.z2.z1.ex.com\n  - key: level\n    value: \"5\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: z5.z4.z3.z2.z1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: k5.k4.k3.k2.k1.ex.com.\n  id: /hostedzone/Z05385574XIO6WFJ8LQ1\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: k4.k3.k2.k1.ex.com\n  - key: level\n    value: \"5\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: k5.k4.k3.k2.k1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: i5.i4.i3.i2.i1.ex.com.\n  id: /hostedzone/Z053855215ZX8GW2J5KBG\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: i4.i3.i2.i1.ex.com\n  - key: level\n    value: \"5\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: i5.i4.i3.i2.i1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: o5.o4.o3.o2.o1.ex.com.\n  id: /hostedzone/Z0538559IWTO974D6T3D\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: o4.o3.o2.o1.ex.com\n  - key: level\n    value: \"5\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: o5.o4.o3.o2.o1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: o6.o5.o4.o3.o2.o1.ex.com.\n  id: /hostedzone/Z05384951BX94HIDSNC29\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: o5.o4.o3.o2.o1.ex.com\n  - key: level\n    value: \"6\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: o6.o5.o4.o3.o2.o1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: w6.w5.w4.w3.w2.w1.ex.com.\n  id: /hostedzone/Z053846613UOJETPXKV9G\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: w5.w4.w3.w2.w1.ex.com\n  - key: level\n    value: \"6\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: w6.w5.w4.w3.w2.w1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: b6.b5.b4.b3.b2.b1.ex.com.\n  id: /hostedzone/Z05380942D45I29YOVUJ1\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: b5.b4.b3.b2.b1.ex.com\n  - key: level\n    value: \"6\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: b6.b5.b4.b3.b2.b1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: p6.p5.p4.p3.p2.p1.ex.com.\n  id: /hostedzone/Z05376071AXQ7LGK8Y7FO\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: p5.p4.p3.p2.p1.ex.com\n  - key: level\n    value: \"6\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: p6.p5.p4.p3.p2.p1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: i6.i5.i4.i3.i2.i1.ex.com.\n  id: /hostedzone/Z05375472NGNYPN630LTO\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: i5.i4.i3.i2.i1.ex.com\n  - key: level\n    value: \"6\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: i6.i5.i4.i3.i2.i1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: k6.k5.k4.k3.k2.k1.ex.com.\n  id: /hostedzone/Z05374771LUBA1Z2GUIT6\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: k5.k4.k3.k2.k1.ex.com\n  - key: level\n    value: \"6\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: k6.k5.k4.k3.k2.k1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: h6.h5.h4.h3.h2.h1.ex.com.\n  id: /hostedzone/Z0537068TAWOCOL8HJ61\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: h5.h4.h3.h2.h1.ex.com\n  - key: level\n    value: \"6\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: h6.h5.h4.h3.h2.h1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: s7.s6.s5.s4.s3.s2.s1.ex.com.\n  id: /hostedzone/Z072010925XG7D9WXB3H\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: s6.s5.s4.s3.s2.s1.ex.com\n  - key: level\n    value: \"7\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: s7.s6.s5.s4.s3.s2.s1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: y7.y6.y5.y4.y3.y2.y1.ex.com.\n  id: /hostedzone/Z071952623W901PGCEAA1\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: y6.y5.y4.y3.y2.y1.ex.com\n  - key: level\n    value: \"7\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: y7.y6.y5.y4.y3.y2.y1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: k7.k6.k5.k4.k3.k2.k1.ex.com.\n  id: /hostedzone/Z0719164E82YWUXVQD4F\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: k6.k5.k4.k3.k2.k1.ex.com\n  - key: level\n    value: \"7\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: k7.k6.k5.k4.k3.k2.k1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: c1.ex.com.\n  id: /hostedzone/Z07076132LTJ3XGH5CJWY\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: ex.com\n  - key: level\n    value: \"1\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: c1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: z1.ex.com.\n  id: /hostedzone/Z070741827T4NNW17A6N9\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: ex.com\n  - key: level\n    value: \"1\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: z1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: i2.i1.ex.com.\n  id: /hostedzone/Z0706863ZTD2HUTU10N0\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: i1.ex.com\n  - key: level\n    value: \"2\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: i2.i1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: x2.x1.ex.com.\n  id: /hostedzone/Z0705382P6ROONRM2CKR\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: x1.ex.com\n  - key: level\n    value: \"2\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: x2.x1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: p1.ex.com.\n  id: /hostedzone/Z0705253DHCTMDZ48Q4V\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: ex.com\n  - key: level\n    value: \"1\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: p1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: s1.ex.com.\n  id: /hostedzone/Z070526324O7V32HB2GP4\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: ex.com\n  - key: level\n    value: \"1\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: s1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: n3.n2.n1.ex.com.\n  id: /hostedzone/Z072628929FO3LXWQVOY7\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: n2.n1.ex.com\n  - key: level\n    value: \"3\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: n3.n2.n1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: r3.r2.r1.ex.com.\n  id: /hostedzone/Z07259612MWHI9NFRS53E\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: r2.r1.ex.com\n  - key: level\n    value: \"3\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: r3.r2.r1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: j3.j2.j1.ex.com.\n  id: /hostedzone/Z072595530FWB47CDV2WC\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: j2.j1.ex.com\n  - key: level\n    value: \"3\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: j3.j2.j1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: d3.d2.d1.ex.com.\n  id: /hostedzone/Z0725572346MYVZ3KGNUH\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: d2.d1.ex.com\n  - key: level\n    value: \"3\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: d3.d2.d1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: o3.o2.o1.ex.com.\n  id: /hostedzone/Z072558318OW0Y4DNPN66\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: o2.o1.ex.com\n  - key: level\n    value: \"3\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: o3.o2.o1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: x3.x2.x1.ex.com.\n  id: /hostedzone/Z0725404124TWY26MTQXT\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: x2.x1.ex.com\n  - key: level\n    value: \"3\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: x3.x2.x1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: l3.l2.l1.ex.com.\n  id: /hostedzone/Z07255931Q3MVC1T9FIX1\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: l2.l1.ex.com\n  - key: level\n    value: \"3\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: l3.l2.l1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: t4.t3.t2.t1.ex.com.\n  id: /hostedzone/Z0725300TUYR9MM1NP9T\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: t3.t2.t1.ex.com\n  - key: level\n    value: \"4\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: t4.t3.t2.t1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: i4.i3.i2.i1.ex.com.\n  id: /hostedzone/Z07252442H3FRQ6T9REYR\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: i3.i2.i1.ex.com\n  - key: level\n    value: \"4\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: i4.i3.i2.i1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: g3.g2.g1.ex.com.\n  id: /hostedzone/Z072522110PO1T9F6EOIH\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: g2.g1.ex.com\n  - key: level\n    value: \"3\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: g3.g2.g1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: y3.y2.y1.ex.com.\n  id: /hostedzone/Z074908589R64YFZTTRF\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: y2.y1.ex.com\n  - key: level\n    value: \"3\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: y3.y2.y1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: w4.w3.w2.w1.ex.com.\n  id: /hostedzone/Z07491213MWEKC6MH5BEX\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: w3.w2.w1.ex.com\n  - key: level\n    value: \"4\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: w4.w3.w2.w1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: e4.e3.e2.e1.ex.com.\n  id: /hostedzone/Z0748318XZ5SAQVUUDHE\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: e3.e2.e1.ex.com\n  - key: level\n    value: \"4\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: e4.e3.e2.e1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: a4.a3.a2.a1.ex.com.\n  id: /hostedzone/Z07482493MHOG25MXMYQW\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: a3.a2.a1.ex.com\n  - key: level\n    value: \"4\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: a4.a3.a2.a1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: g4.g3.g2.g1.ex.com.\n  id: /hostedzone/Z07473562MT9TYWVVE38G\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: g3.g2.g1.ex.com\n  - key: level\n    value: \"4\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: g4.g3.g2.g1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: d4.d3.d2.d1.ex.com.\n  id: /hostedzone/Z0747355363XLS6BWS4JP\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: d3.d2.d1.ex.com\n  - key: level\n    value: \"4\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: d4.d3.d2.d1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: u4.u3.u2.u1.ex.com.\n  id: /hostedzone/Z07473491EXNK832Z1KY2\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: u3.u2.u1.ex.com\n  - key: level\n    value: \"4\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: u4.u3.u2.u1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: e5.e4.e3.e2.e1.ex.com.\n  id: /hostedzone/Z07516762P24S9BSAPW20\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: e4.e3.e2.e1.ex.com\n  - key: level\n    value: \"5\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: e5.e4.e3.e2.e1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: y5.y4.y3.y2.y1.ex.com.\n  id: /hostedzone/Z07510071JNCBZN4PU9PU\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: y4.y3.y2.y1.ex.com\n  - key: level\n    value: \"5\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: y5.y4.y3.y2.y1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: x5.x4.x3.x2.x1.ex.com.\n  id: /hostedzone/Z0750455O2P676BQCQ36\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: x4.x3.x2.x1.ex.com\n  - key: level\n    value: \"5\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: x5.x4.x3.x2.x1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: s5.s4.s3.s2.s1.ex.com.\n  id: /hostedzone/Z07503442IXQWYXD3NA2P\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: s4.s3.s2.s1.ex.com\n  - key: level\n    value: \"5\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: s5.s4.s3.s2.s1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: y6.y5.y4.y3.y2.y1.ex.com.\n  id: /hostedzone/Z07499781IH1IWL681HZA\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: y5.y4.y3.y2.y1.ex.com\n  - key: level\n    value: \"6\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: y6.y5.y4.y3.y2.y1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: s6.s5.s4.s3.s2.s1.ex.com.\n  id: /hostedzone/Z074127711E3PB3VWCVR8\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: s5.s4.s3.s2.s1.ex.com\n  - key: level\n    value: \"6\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: s6.s5.s4.s3.s2.s1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: l6.l5.l4.l3.l2.l1.ex.com.\n  id: /hostedzone/Z07407861HUM7TWLBEVHW\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: l5.l4.l3.l2.l1.ex.com\n  - key: level\n    value: \"6\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: l6.l5.l4.l3.l2.l1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: j6.j5.j4.j3.j2.j1.ex.com.\n  id: /hostedzone/Z07407492KRCV3E1Q3TTH\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: j5.j4.j3.j2.j1.ex.com\n  - key: level\n    value: \"6\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: j6.j5.j4.j3.j2.j1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: z7.z6.z5.z4.z3.z2.z1.ex.com.\n  id: /hostedzone/Z05977813GQTSJK0XA6CR\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: z6.z5.z4.z3.z2.z1.ex.com\n  - key: level\n    value: \"7\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: z7.z6.z5.z4.z3.z2.z1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: p7.p6.p5.p4.p3.p2.p1.ex.com.\n  id: /hostedzone/Z05976812XQ4FYDX0EA1M\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: p6.p5.p4.p3.p2.p1.ex.com\n  - key: level\n    value: \"7\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: p7.p6.p5.p4.p3.p2.p1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: r7.r6.r5.r4.r3.r2.r1.ex.com.\n  id: /hostedzone/Z059725536VFD0SUK6COX\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: r6.r5.r4.r3.r2.r1.ex.com\n  - key: level\n    value: \"7\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: r7.r6.r5.r4.r3.r2.r1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: n7.n6.n5.n4.n3.n2.n1.ex.com.\n  id: /hostedzone/Z05968613E7Z7DOMT8XQY\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: n6.n5.n4.n3.n2.n1.ex.com\n  - key: level\n    value: \"7\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: n7.n6.n5.n4.n3.n2.n1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: j1.ex.com.\n  id: /hostedzone/Z061728615D0VKL6DXRPH\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: ex.com\n  - key: level\n    value: \"1\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: j1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: y1.ex.com.\n  id: /hostedzone/Z06172299A0X24MKXTOD\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: ex.com\n  - key: level\n    value: \"1\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: y1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: p2.p1.ex.com.\n  id: /hostedzone/Z06167006NZ1D5IRNRYA\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: p1.ex.com\n  - key: level\n    value: \"2\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: p2.p1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: f1.ex.com.\n  id: /hostedzone/Z06160862E2ELNT29RQL3\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: ex.com\n  - key: level\n    value: \"1\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: f1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: l1.ex.com.\n  id: /hostedzone/Z06158931J4KH25JG225F\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: ex.com\n  - key: level\n    value: \"1\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: l1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: a1.ex.com.\n  id: /hostedzone/Z06155043AVN8RVC88TYY\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: ex.com\n  - key: level\n    value: \"1\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: a1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: u1.ex.com.\n  id: /hostedzone/Z061540412CK4L8XI4BB6\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: ex.com\n  - key: level\n    value: \"1\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: u1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: c2.c1.ex.com.\n  id: /hostedzone/Z06150423UYFKAE29W1SW\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: c1.ex.com\n  - key: level\n    value: \"2\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: c2.c1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: m2.m1.ex.com.\n  id: /hostedzone/Z06152622MZNX328M11VY\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: m1.ex.com\n  - key: level\n    value: \"2\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: m2.m1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: o2.o1.ex.com.\n  id: /hostedzone/Z06151611YG2J9D7Y9URS\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: o1.ex.com\n  - key: level\n    value: \"2\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: o2.o1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: w1.ex.com.\n  id: /hostedzone/Z06144631U9WHJGPNE0F6\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: ex.com\n  - key: level\n    value: \"1\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: w1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: b2.b1.ex.com.\n  id: /hostedzone/Z0621921Q39DAS25KW2F\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: b1.ex.com\n  - key: level\n    value: \"2\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: b2.b1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: k2.k1.ex.com.\n  id: /hostedzone/Z06214591V9ROEHRT4AJR\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: k1.ex.com\n  - key: level\n    value: \"2\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: k2.k1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: r2.r1.ex.com.\n  id: /hostedzone/Z062145823F9HOXQ8AQTT\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: r1.ex.com\n  - key: level\n    value: \"2\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: r2.r1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: a2.a1.ex.com.\n  id: /hostedzone/Z062114713UUBAQBGL50S\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: a1.ex.com\n  - key: level\n    value: \"2\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: a2.a1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: u2.u1.ex.com.\n  id: /hostedzone/Z062114626Y9F53JWLIF3\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: u1.ex.com\n  - key: level\n    value: \"2\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: u2.u1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: j2.j1.ex.com.\n  id: /hostedzone/Z06208221KB9XPMJOH1YY\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: j1.ex.com\n  - key: level\n    value: \"2\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: j2.j1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: f2.f1.ex.com.\n  id: /hostedzone/Z0620221RICGJ8UMXA3J\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: f1.ex.com\n  - key: level\n    value: \"2\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: f2.f1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: s3.s2.s1.ex.com.\n  id: /hostedzone/Z0633015M1WISEQYPT1O\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: s2.s1.ex.com\n  - key: level\n    value: \"3\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: s3.s2.s1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: a3.a2.a1.ex.com.\n  id: /hostedzone/Z06329532JLRXZTWQP7A3\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: a2.a1.ex.com\n  - key: level\n    value: \"3\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: a3.a2.a1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: m4.m3.m2.m1.ex.com.\n  id: /hostedzone/Z0632386384GR41PHHKYP\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: m3.m2.m1.ex.com\n  - key: level\n    value: \"4\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: m4.m3.m2.m1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: y4.y3.y2.y1.ex.com.\n  id: /hostedzone/Z06317473A6DQOQB2TRU5\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: y3.y2.y1.ex.com\n  - key: level\n    value: \"4\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: y4.y3.y2.y1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: k4.k3.k2.k1.ex.com.\n  id: /hostedzone/Z0631662C8V9KGIE114K\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: k3.k2.k1.ex.com\n  - key: level\n    value: \"4\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: k4.k3.k2.k1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: l4.l3.l2.l1.ex.com.\n  id: /hostedzone/Z0631619300T0491JETF6\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: l3.l2.l1.ex.com\n  - key: level\n    value: \"4\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: l4.l3.l2.l1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: c4.c3.c2.c1.ex.com.\n  id: /hostedzone/Z06312335K3AAQ106WOS\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: c3.c2.c1.ex.com\n  - key: level\n    value: \"4\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: c4.c3.c2.c1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: d5.d4.d3.d2.d1.ex.com.\n  id: /hostedzone/Z06264302YRR5RW36ZQTN\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: d4.d3.d2.d1.ex.com\n  - key: level\n    value: \"5\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: d5.d4.d3.d2.d1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: n5.n4.n3.n2.n1.ex.com.\n  id: /hostedzone/Z06255473B4E9CCCYXD0C\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: n4.n3.n2.n1.ex.com\n  - key: level\n    value: \"5\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: n5.n4.n3.n2.n1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: t6.t5.t4.t3.t2.t1.ex.com.\n  id: /hostedzone/Z06247991Z9UOIVBEHBIZ\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: t5.t4.t3.t2.t1.ex.com\n  - key: level\n    value: \"6\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: t6.t5.t4.t3.t2.t1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: m6.m5.m4.m3.m2.m1.ex.com.\n  id: /hostedzone/Z06248401WRYVSUJIMXGM\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: m5.m4.m3.m2.m1.ex.com\n  - key: level\n    value: \"6\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: m6.m5.m4.m3.m2.m1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: u5.u4.u3.u2.u1.ex.com.\n  id: /hostedzone/Z06248072H6SA6EBROQVA\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: u4.u3.u2.u1.ex.com\n  - key: level\n    value: \"5\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: u5.u4.u3.u2.u1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: t5.t4.t3.t2.t1.ex.com.\n  id: /hostedzone/Z062430113Y30HAIE6V8\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: t4.t3.t2.t1.ex.com\n  - key: level\n    value: \"5\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: t5.t4.t3.t2.t1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: n6.n5.n4.n3.n2.n1.ex.com.\n  id: /hostedzone/Z06239002LL6XI9BMP5LP\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: n5.n4.n3.n2.n1.ex.com\n  - key: level\n    value: \"6\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: n6.n5.n4.n3.n2.n1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: u6.u5.u4.u3.u2.u1.ex.com.\n  id: /hostedzone/Z062358930OBBSG0DNV0Y\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: u5.u4.u3.u2.u1.ex.com\n  - key: level\n    value: \"6\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: u6.u5.u4.u3.u2.u1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: l5.l4.l3.l2.l1.ex.com.\n  id: /hostedzone/Z06227713K2LX8JW32GM0\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: l4.l3.l2.l1.ex.com\n  - key: level\n    value: \"5\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: l5.l4.l3.l2.l1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: w5.w4.w3.w2.w1.ex.com.\n  id: /hostedzone/Z06227293A8RSW44CUSQI\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: w4.w3.w2.w1.ex.com\n  - key: level\n    value: \"5\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: w5.w4.w3.w2.w1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: e6.e5.e4.e3.e2.e1.ex.com.\n  id: /hostedzone/Z06304583NEE5VFLGKEQU\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: e5.e4.e3.e2.e1.ex.com\n  - key: level\n    value: \"6\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: e6.e5.e4.e3.e2.e1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: b7.b6.b5.b4.b3.b2.b1.ex.com.\n  id: /hostedzone/Z0942661HXLGP9L2YZH3\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: b6.b5.b4.b3.b2.b1.ex.com\n  - key: level\n    value: \"7\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: b7.b6.b5.b4.b3.b2.b1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: t7.t6.t5.t4.t3.t2.t1.ex.com.\n  id: /hostedzone/Z0949431OHPKQPZEXGDX\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: t6.t5.t4.t3.t2.t1.ex.com\n  - key: level\n    value: \"7\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: t7.t6.t5.t4.t3.t2.t1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: d7.d6.d5.d4.d3.d2.d1.ex.com.\n  id: /hostedzone/Z09491241UC9DWHHT0SEN\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: d6.d5.d4.d3.d2.d1.ex.com\n  - key: level\n    value: \"7\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: d7.d6.d5.d4.d3.d2.d1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: h7.h6.h5.h4.h3.h2.h1.ex.com.\n  id: /hostedzone/Z09480891AX3V95RKI43I\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: h6.h5.h4.h3.h2.h1.ex.com\n  - key: level\n    value: \"7\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: h7.h6.h5.h4.h3.h2.h1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: g7.g6.g5.g4.g3.g2.g1.ex.com.\n  id: /hostedzone/Z094802628HB653GKGL2W\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: g6.g5.g4.g3.g2.g1.ex.com\n  - key: level\n    value: \"7\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: g7.g6.g5.g4.g3.g2.g1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: m7.m6.m5.m4.m3.m2.m1.ex.com.\n  id: /hostedzone/Z09480162FW8C3KROXT7G\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: m6.m5.m4.m3.m2.m1.ex.com\n  - key: level\n    value: \"7\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: m7.m6.m5.m4.m3.m2.m1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: l7.l6.l5.l4.l3.l2.l1.ex.com.\n  id: /hostedzone/Z09480273OHY83D5NHYVH\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: l6.l5.l4.l3.l2.l1.ex.com\n  - key: level\n    value: \"7\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: l7.l6.l5.l4.l3.l2.l1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: o7.o6.o5.o4.o3.o2.o1.ex.com.\n  id: /hostedzone/Z09480223A3R9GDRXB62G\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: o6.o5.o4.o3.o2.o1.ex.com\n  - key: level\n    value: \"7\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: o7.o6.o5.o4.o3.o2.o1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: i7.i6.i5.i4.i3.i2.i1.ex.com.\n  id: /hostedzone/Z09475203CZDWQ5UOPB5H\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: i6.i5.i4.i3.i2.i1.ex.com\n  - key: level\n    value: \"7\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: i7.i6.i5.i4.i3.i2.i1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: e7.e6.e5.e4.e3.e2.e1.ex.com.\n  id: /hostedzone/Z09465491NJY6YM6BV9CR\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: e6.e5.e4.e3.e2.e1.ex.com\n  - key: level\n    value: \"7\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: e7.e6.e5.e4.e3.e2.e1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: w2.w1.ex.com.\n  id: /hostedzone/Z09418121E8V6WT4FASZE\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: w1.ex.com\n  - key: level\n    value: \"2\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: w2.w1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: t2.t1.ex.com.\n  id: /hostedzone/Z0941411140EAM8LI6XSX\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: t1.ex.com\n  - key: level\n    value: \"2\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: t2.t1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: m1.ex.com.\n  id: /hostedzone/Z0941151MH351YJEC250\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: ex.com\n  - key: level\n    value: \"1\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: m1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: e2.e1.ex.com.\n  id: /hostedzone/Z09408592RKGIVCOGBFJW\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: e1.ex.com\n  - key: level\n    value: \"2\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: e2.e1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: n2.n1.ex.com.\n  id: /hostedzone/Z09408791OZQTXJTECCDX\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: n1.ex.com\n  - key: level\n    value: \"2\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: n2.n1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: i1.ex.com.\n  id: /hostedzone/Z0940748ZXY7SLGNELA0\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: ex.com\n  - key: level\n    value: \"1\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: i1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: e1.ex.com.\n  id: /hostedzone/Z094076011ZHUH8WUULP\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: ex.com\n  - key: level\n    value: \"1\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: e1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: b1.ex.com.\n  id: /hostedzone/Z09407613O62TF4KJ7UHH\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: ex.com\n  - key: level\n    value: \"1\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: b1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: x1.ex.com.\n  id: /hostedzone/Z09407532B9TMSQHCL5QA\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: ex.com\n  - key: level\n    value: \"1\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: x1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: y2.y1.ex.com.\n  id: /hostedzone/Z0940306YB0C0775W4GP\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: y1.ex.com\n  - key: level\n    value: \"2\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: y2.y1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: h2.h1.ex.com.\n  id: /hostedzone/Z09405092OGSZIJY9BEYN\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: h1.ex.com\n  - key: level\n    value: \"2\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: h2.h1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: d2.d1.ex.com.\n  id: /hostedzone/Z09396301VGLGYVE5RGK5\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: d1.ex.com\n  - key: level\n    value: \"2\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: d2.d1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: s2.s1.ex.com.\n  id: /hostedzone/Z09394103L1W2N920D71K\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: s1.ex.com\n  - key: level\n    value: \"2\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: s2.s1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: f3.f2.f1.ex.com.\n  id: /hostedzone/Z09514023G8ZGR4DJKR3A\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: f2.f1.ex.com\n  - key: level\n    value: \"3\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: f3.f2.f1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: b3.b2.b1.ex.com.\n  id: /hostedzone/Z09583652IMG5STP7731F\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: b2.b1.ex.com\n  - key: level\n    value: \"3\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: b3.b2.b1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: w3.w2.w1.ex.com.\n  id: /hostedzone/Z09580483LFSMB2OBV4FM\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: w2.w1.ex.com\n  - key: level\n    value: \"3\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: w3.w2.w1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: x4.x3.x2.x1.ex.com.\n  id: /hostedzone/Z095764620VD6TFV6Q8FH\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: x3.x2.x1.ex.com\n  - key: level\n    value: \"4\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: x4.x3.x2.x1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: f4.f3.f2.f1.ex.com.\n  id: /hostedzone/Z09564251P7OWGV8YAVPD\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: f3.f2.f1.ex.com\n  - key: level\n    value: \"4\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: f4.f3.f2.f1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: z4.z3.z2.z1.ex.com.\n  id: /hostedzone/Z095609014Z6XE70HI4T4\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: z3.z2.z1.ex.com\n  - key: level\n    value: \"4\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: z4.z3.z2.z1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: h3.h2.h1.ex.com.\n  id: /hostedzone/Z09557511F8219IOYA0PO\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: h2.h1.ex.com\n  - key: level\n    value: \"3\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: h3.h2.h1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: b4.b3.b2.b1.ex.com.\n  id: /hostedzone/Z095570265KY2U6HO9TO\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: b3.b2.b1.ex.com\n  - key: level\n    value: \"4\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: b4.b3.b2.b1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: s4.s3.s2.s1.ex.com.\n  id: /hostedzone/Z0955182J8RQ0EOVX7X5\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: s3.s2.s1.ex.com\n  - key: level\n    value: \"4\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: s4.s3.s2.s1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: j4.j3.j2.j1.ex.com.\n  id: /hostedzone/Z09551293SYT29TZGCK1Q\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: j3.j2.j1.ex.com\n  - key: level\n    value: \"4\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: j4.j3.j2.j1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: i3.i2.i1.ex.com.\n  id: /hostedzone/Z09549753KGGEAJYSIKQU\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: i2.i1.ex.com\n  - key: level\n    value: \"3\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: i3.i2.i1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: h4.h3.h2.h1.ex.com.\n  id: /hostedzone/Z095473017P9TIG47W418\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: h3.h2.h1.ex.com\n  - key: level\n    value: \"4\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: h4.h3.h2.h1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: c3.c2.c1.ex.com.\n  id: /hostedzone/Z09788602NGY8C9CCTV10\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: c2.c1.ex.com\n  - key: level\n    value: \"3\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: c3.c2.c1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: t3.t2.t1.ex.com.\n  id: /hostedzone/Z09788491STHS3LT21IWW\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: t2.t1.ex.com\n  - key: level\n    value: \"3\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: t3.t2.t1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: e3.e2.e1.ex.com.\n  id: /hostedzone/Z09788593ERXU8DSD4LCO\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: e2.e1.ex.com\n  - key: level\n    value: \"3\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: e3.e2.e1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: u3.u2.u1.ex.com.\n  id: /hostedzone/Z09783123AIJ322RP9AVS\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: u2.u1.ex.com\n  - key: level\n    value: \"3\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: u3.u2.u1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: n4.n3.n2.n1.ex.com.\n  id: /hostedzone/Z0978397265BSNRBCT08T\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: n3.n2.n1.ex.com\n  - key: level\n    value: \"4\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: n4.n3.n2.n1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: o4.o3.o2.o1.ex.com.\n  id: /hostedzone/Z09753782VB47JVMP3QQH\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: o3.o2.o1.ex.com\n  - key: level\n    value: \"4\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: o4.o3.o2.o1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: b5.b4.b3.b2.b1.ex.com.\n  id: /hostedzone/Z09808623VONT9B5AGIBQ\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: b4.b3.b2.b1.ex.com\n  - key: level\n    value: \"5\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: b5.b4.b3.b2.b1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: c6.c5.c4.c3.c2.c1.ex.com.\n  id: /hostedzone/Z09705082YEX3UKM47BT3\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: c5.c4.c3.c2.c1.ex.com\n  - key: level\n    value: \"6\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: c6.c5.c4.c3.c2.c1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: p5.p4.p3.p2.p1.ex.com.\n  id: /hostedzone/Z09704821Y3VVAC37HSED\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: p4.p3.p2.p1.ex.com\n  - key: level\n    value: \"5\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: p5.p4.p3.p2.p1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: a6.a5.a4.a3.a2.a1.ex.com.\n  id: /hostedzone/Z0969737LNGOLZ7RPT2E\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: a5.a4.a3.a2.a1.ex.com\n  - key: level\n    value: \"6\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: a6.a5.a4.a3.a2.a1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n- name: f6.f5.f4.f3.f2.f1.ex.com.\n  id: /hostedzone/Z09695981XV9GI7P7WRMX\n  tags:\n  - key: owner\n    value: ext-dns\n  - key: parentdomain\n    value: f5.f4.f3.f2.f1.ex.com\n  - key: level\n    value: \"6\"\n  - key: managed\n    value: terraform\n  - key: domain\n    value: f6.f5.f4.f3.f2.f1.ex.com\n  - key: vpcid\n    value: vpc-123456\n  - key: env\n    value: sandbox\n  - key: rootdomain\n    value: ex.com\n"
  },
  {
    "path": "provider/aws/instrumented_config.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage aws\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/aws/smithy-go/middleware\"\n\tsmithyhttp \"github.com/aws/smithy-go/transport/http\"\n\n\textdnshttp \"sigs.k8s.io/external-dns/pkg/http\"\n\t\"sigs.k8s.io/external-dns/pkg/metrics\"\n)\n\ntype requestMetrics struct {\n\tStartTime time.Time\n}\n\ntype requestMetricsKey struct{}\n\nfunc getRequestMetric(ctx context.Context) requestMetrics {\n\trequestMetrics, _ := middleware.GetStackValue(ctx, requestMetricsKey{}).(requestMetrics)\n\treturn requestMetrics\n}\n\nfunc setRequestMetric(ctx context.Context, requestMetrics requestMetrics) context.Context {\n\treturn middleware.WithStackValue(ctx, requestMetricsKey{}, requestMetrics)\n}\n\nvar initializeTimedOperationMiddleware = middleware.InitializeMiddlewareFunc(\"timedOperation\", func(\n\tctx context.Context, in middleware.InitializeInput, next middleware.InitializeHandler,\n) (middleware.InitializeOutput, middleware.Metadata, error) {\n\trequestMetrics := requestMetrics{}\n\trequestMetrics.StartTime = time.Now()\n\tctx = setRequestMetric(ctx, requestMetrics)\n\n\treturn next.HandleInitialize(ctx, in)\n})\n\nvar extractAWSRequestParameters = middleware.DeserializeMiddlewareFunc(\"extractAWSRequestParameters\", func(\n\tctx context.Context, in middleware.DeserializeInput, next middleware.DeserializeHandler,\n) (middleware.DeserializeOutput, middleware.Metadata, error) {\n\t// Call the next middleware first to get the response\n\tout, metadata, err := next.HandleDeserialize(ctx, in)\n\n\trequestMetrics := getRequestMetric(ctx)\n\n\tlabels := metrics.Labels{}\n\n\tif req, ok := in.Request.(*smithyhttp.Request); ok && req != nil {\n\t\tlabels[metrics.LabelScheme] = req.URL.Scheme\n\t\tlabels[metrics.LabelHost] = req.URL.Host\n\t\tlabels[metrics.LabelPath] = metrics.PathProcessor(req.URL.Path)\n\t\tlabels[metrics.LabelMethod] = req.Method\n\t\tlabels[metrics.LabelStatus] = \"unknown\"\n\t}\n\n\t// Try to access HTTP response and status code\n\tif resp, ok := out.RawResponse.(*smithyhttp.Response); ok && resp != nil {\n\t\tlabels[metrics.LabelStatus] = fmt.Sprintf(\"%d\", resp.StatusCode)\n\t}\n\n\textdnshttp.RequestDurationMetric.SetWithLabels(time.Since(requestMetrics.StartTime).Seconds(), labels)\n\n\treturn out, metadata, err\n})\n\nfunc GetInstrumentationMiddlewares() []func(*middleware.Stack) error {\n\treturn []func(s *middleware.Stack) error{\n\t\tfunc(s *middleware.Stack) error {\n\t\t\tif err := s.Initialize.Add(initializeTimedOperationMiddleware, middleware.Before); err != nil {\n\t\t\t\treturn fmt.Errorf(\"error adding timedOperationMiddleware: %w\", err)\n\t\t\t}\n\n\t\t\tif err := s.Deserialize.Add(extractAWSRequestParameters, middleware.After); err != nil {\n\t\t\t\treturn fmt.Errorf(\"error adding extractAWSRequestParameters: %w\", err)\n\t\t\t}\n\n\t\t\treturn nil\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "provider/aws/instrumented_config_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage aws\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/aws/smithy-go/middleware\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\tsmithyhttp \"github.com/aws/smithy-go/transport/http\"\n)\n\nfunc Test_GetInstrumentationMiddlewares(t *testing.T) {\n\tt.Run(\"adds expected middlewares\", func(t *testing.T) {\n\t\tstack := middleware.NewStack(\"test-stack\", nil)\n\n\t\tfor _, mw := range GetInstrumentationMiddlewares() {\n\t\t\terr := mw(stack)\n\t\t\trequire.NoError(t, err)\n\t\t}\n\n\t\t// Check Initialize stage\n\t\ttimedOperationMiddleware, found := stack.Initialize.Get(\"timedOperation\")\n\t\tassert.True(t, found, \"timedOperation middleware should be present in Initialize stage\")\n\t\tassert.NotNil(t, timedOperationMiddleware)\n\n\t\t// Check Deserialize stage\n\t\textractAWSRequestParametersMiddleware, found := stack.Deserialize.Get(\"extractAWSRequestParameters\")\n\t\tassert.True(t, found, \"extractAWSRequestParameters middleware should be present in Deserialize stage\")\n\t\tassert.NotNil(t, extractAWSRequestParametersMiddleware)\n\t})\n}\n\ntype MockInitializeHandler struct {\n\tCapturedContext context.Context\n}\n\nfunc (mock *MockInitializeHandler) HandleInitialize(ctx context.Context, _ middleware.InitializeInput) (middleware.InitializeOutput, middleware.Metadata, error) {\n\tmock.CapturedContext = ctx\n\n\treturn middleware.InitializeOutput{}, middleware.Metadata{}, nil\n}\n\nfunc Test_InitializedTimedOperationMiddleware(t *testing.T) {\n\ttestContext := t.Context()\n\tmockInitializeHandler := &MockInitializeHandler{}\n\n\t_, _, err := initializeTimedOperationMiddleware.HandleInitialize(testContext, middleware.InitializeInput{}, mockInitializeHandler)\n\trequire.NoError(t, err)\n\n\trequestMetrics := middleware.GetStackValue(mockInitializeHandler.CapturedContext, requestMetricsKey{}).(requestMetrics)\n\tassert.NotNil(t, requestMetrics.StartTime)\n}\n\ntype MockDeserializeHandler struct {\n}\n\nfunc (mock *MockDeserializeHandler) HandleDeserialize(_ context.Context, _ middleware.DeserializeInput) (middleware.DeserializeOutput, middleware.Metadata, error) {\n\treturn middleware.DeserializeOutput{}, middleware.Metadata{}, nil\n}\n\nfunc Test_ExtractAWSRequestParameters(t *testing.T) {\n\ttestContext := t.Context()\n\tmiddleware.WithStackValue(testContext, requestMetricsKey{}, requestMetrics{StartTime: time.Now()})\n\n\tmockDeserializeHandler := &MockDeserializeHandler{}\n\n\tdeserializeInput := middleware.DeserializeInput{\n\t\tRequest: &smithyhttp.Request{\n\t\t\tRequest: &http.Request{\n\t\t\t\tMethod: http.MethodGet,\n\t\t\t\tURL: &url.URL{\n\t\t\t\t\tHost:   \"example.com\",\n\t\t\t\t\tScheme: \"HTTPS\",\n\t\t\t\t\tPath:   \"/testPath\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\t_, _, err := extractAWSRequestParameters.HandleDeserialize(testContext, deserializeInput, mockDeserializeHandler)\n\trequire.NoError(t, err)\n}\n"
  },
  {
    "path": "provider/awssd/aws_sd.go",
    "content": "/*\nCopyright 2018 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage awssd\n\nimport (\n\t\"context\"\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/aws/aws-sdk-go-v2/aws\"\n\tsd \"github.com/aws/aws-sdk-go-v2/service/servicediscovery\"\n\tsdtypes \"github.com/aws/aws-sdk-go-v2/service/servicediscovery/types\"\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\textdnsaws \"sigs.k8s.io/external-dns/provider/aws\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\nconst (\n\tdefaultTTL = 300\n\n\t// https://github.com/aws/aws-sdk-go-v2/blob/cf8509382340d6afdc93612550d56d685181bbb3/service/servicediscovery/api_op_ListServices.go#L42\n\tmaxResults = 100\n\n\tsdNamespaceTypePublic  = \"public\"\n\tsdNamespaceTypePrivate = \"private\"\n\n\tsdInstanceAttrIPV4  = \"AWS_INSTANCE_IPV4\"\n\tsdInstanceAttrIPV6  = \"AWS_INSTANCE_IPV6\"\n\tsdInstanceAttrCname = \"AWS_INSTANCE_CNAME\"\n\tsdInstanceAttrAlias = \"AWS_ALIAS_DNS_NAME\"\n)\n\nvar (\n\t// matches ELB with hostname format load-balancer.us-east-1.elb.amazonaws.com\n\tsdElbHostnameRegex = regexp.MustCompile(`.+\\.[^.]+\\.elb\\.amazonaws\\.com$`)\n\n\t// matches NLB with hostname format load-balancer.elb.us-east-1.amazonaws.com\n\tsdNlbHostnameRegex = regexp.MustCompile(`.+\\.elb\\.[^.]+\\.amazonaws\\.com$`)\n)\n\n// AWSSDClient is the subset of the AWS Cloud Map API that we actually use. Add methods as required.\n// Signatures must match exactly. Taken from https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/servicediscovery\ntype AWSSDClient interface {\n\tCreateService(ctx context.Context, params *sd.CreateServiceInput, optFns ...func(*sd.Options)) (*sd.CreateServiceOutput, error)\n\tDeregisterInstance(ctx context.Context, params *sd.DeregisterInstanceInput, optFns ...func(*sd.Options)) (*sd.DeregisterInstanceOutput, error)\n\tDiscoverInstances(ctx context.Context, params *sd.DiscoverInstancesInput, optFns ...func(*sd.Options)) (*sd.DiscoverInstancesOutput, error)\n\tListNamespaces(ctx context.Context, params *sd.ListNamespacesInput, optFns ...func(*sd.Options)) (*sd.ListNamespacesOutput, error)\n\tListServices(ctx context.Context, params *sd.ListServicesInput, optFns ...func(*sd.Options)) (*sd.ListServicesOutput, error)\n\tRegisterInstance(ctx context.Context, params *sd.RegisterInstanceInput, optFns ...func(*sd.Options)) (*sd.RegisterInstanceOutput, error)\n\tUpdateService(ctx context.Context, params *sd.UpdateServiceInput, optFns ...func(*sd.Options)) (*sd.UpdateServiceOutput, error)\n\tDeleteService(ctx context.Context, params *sd.DeleteServiceInput, optFns ...func(*sd.Options)) (*sd.DeleteServiceOutput, error)\n}\n\n// AWSSDProvider is an implementation of Provider for AWS Cloud Map.\ntype AWSSDProvider struct {\n\tprovider.BaseProvider\n\tclient AWSSDClient\n\tdryRun bool\n\t// only consider namespaces ending in this suffix\n\tnamespaceFilter *endpoint.DomainFilter\n\t// filter namespace by type (private or public)\n\tnamespaceTypeFilter []sdtypes.NamespaceFilter\n\t// enables service without instances cleanup\n\tcleanEmptyService bool\n\t// filter services for removal\n\townerID string\n\t// tags to be added to the service\n\ttags []sdtypes.Tag\n}\n\n// New creates an AWS Service Discovery provider from the given configuration.\nfunc New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) {\n\t// Check that only compatible Registry is used with AWS-SD\n\tif cfg.Registry != \"noop\" && cfg.Registry != \"aws-sd\" {\n\t\tlog.Infof(\"Registry \\\"%s\\\" cannot be used with AWS Cloud Map. Switching to \\\"aws-sd\\\".\", cfg.Registry)\n\t\tcfg.Registry = \"aws-sd\"\n\t}\n\treturn newProvider(domainFilter, cfg.AWSZoneType, cfg.DryRun, cfg.AWSSDServiceCleanup, cfg.TXTOwnerID, cfg.AWSSDCreateTag, sd.NewFromConfig(extdnsaws.CreateDefaultV2Config(cfg))), nil\n}\n\n// newProvider initializes a new AWS Cloud Map based Provider.\nfunc newProvider(domainFilter *endpoint.DomainFilter, namespaceType string, dryRun, cleanEmptyService bool, ownerID string, tags map[string]string, client AWSSDClient) *AWSSDProvider {\n\tp := &AWSSDProvider{\n\t\tclient:              client,\n\t\tdryRun:              dryRun,\n\t\tnamespaceFilter:     domainFilter,\n\t\tnamespaceTypeFilter: newSdNamespaceFilter(namespaceType),\n\t\tcleanEmptyService:   cleanEmptyService,\n\t\townerID:             ownerID,\n\t\ttags:                awsTags(tags),\n\t}\n\n\treturn p\n}\n\n// newSdNamespaceFilter returns NamespaceFilter based on the given namespace type configuration.\n// If the config is \"public\", it filters for public namespaces; if \"private\", for private namespaces.\n// For any other value (including empty), it returns filters for both public and private namespaces.\n// ref: https://docs.aws.amazon.com/cloud-map/latest/api/API_ListNamespaces.html\nfunc newSdNamespaceFilter(namespaceTypeConfig string) []sdtypes.NamespaceFilter {\n\tswitch namespaceTypeConfig {\n\tcase sdNamespaceTypePublic:\n\t\treturn []sdtypes.NamespaceFilter{\n\t\t\t{\n\t\t\t\tName:   sdtypes.NamespaceFilterNameType,\n\t\t\t\tValues: []string{string(sdtypes.NamespaceTypeDnsPublic)},\n\t\t\t},\n\t\t}\n\tcase sdNamespaceTypePrivate:\n\t\treturn []sdtypes.NamespaceFilter{\n\t\t\t{\n\t\t\t\tName:   sdtypes.NamespaceFilterNameType,\n\t\t\t\tValues: []string{string(sdtypes.NamespaceTypeDnsPrivate)},\n\t\t\t},\n\t\t}\n\tdefault:\n\t\treturn []sdtypes.NamespaceFilter{}\n\t}\n}\n\n// awsTags converts user-supplied tags to AWS format\nfunc awsTags(tags map[string]string) []sdtypes.Tag {\n\tawsTags := make([]sdtypes.Tag, 0, len(tags))\n\tfor k, v := range tags {\n\t\tawsTags = append(awsTags, sdtypes.Tag{Key: aws.String(k), Value: aws.String(v)})\n\t}\n\treturn awsTags\n}\n\n// Records returns list of all endpoints.\nfunc (p *AWSSDProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {\n\tnamespaces, err := p.ListNamespaces(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tendpoints := make([]*endpoint.Endpoint, 0)\n\n\tfor _, ns := range namespaces {\n\t\tservices, err := p.ListServicesByNamespaceID(ctx, ns.Id)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tfor _, srv := range services {\n\t\t\tresp, err := p.client.DiscoverInstances(ctx, &sd.DiscoverInstancesInput{\n\t\t\t\tNamespaceName: ns.Name,\n\t\t\t\tServiceName:   srv.Name,\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\tif len(resp.Instances) == 0 {\n\t\t\t\tif err := p.DeleteService(ctx, srv); err != nil {\n\t\t\t\t\tlog.Errorf(\"Failed to delete service %q, error: %s\", *srv.Name, err)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif srv.Description == nil {\n\t\t\t\tlog.Warnf(\"Skipping service %q as owner id not configured\", *srv.Name)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tendpoints = append(endpoints, p.instancesToEndpoint(ns, srv, resp.Instances))\n\t\t}\n\t}\n\n\treturn endpoints, nil\n}\n\nfunc (p *AWSSDProvider) instancesToEndpoint(ns *sdtypes.NamespaceSummary, srv *sdtypes.Service, instances []sdtypes.HttpInstanceSummary) *endpoint.Endpoint {\n\t// DNS name of the record is a concatenation of service and namespace\n\trecordName := *srv.Name + \".\" + *ns.Name\n\n\tlabels := endpoint.NewLabels()\n\n\tlabels[endpoint.AWSSDDescriptionLabel] = *srv.Description\n\n\tnewEndpoint := &endpoint.Endpoint{\n\t\tDNSName:   recordName,\n\t\tRecordTTL: endpoint.TTL(*srv.DnsConfig.DnsRecords[0].TTL),\n\t\tTargets:   make(endpoint.Targets, 0, len(instances)),\n\t\tLabels:    labels,\n\t}\n\n\tfor _, inst := range instances {\n\t\tswitch {\n\t\t// CNAME\n\t\tcase inst.Attributes[sdInstanceAttrCname] != \"\" && srv.DnsConfig.DnsRecords[0].Type == sdtypes.RecordTypeCname:\n\t\t\tnewEndpoint.RecordType = endpoint.RecordTypeCNAME\n\t\t\tnewEndpoint.Targets = append(newEndpoint.Targets, inst.Attributes[sdInstanceAttrCname])\n\t\t// ALIAS\n\t\tcase inst.Attributes[sdInstanceAttrAlias] != \"\":\n\t\t\tnewEndpoint.RecordType = endpoint.RecordTypeCNAME\n\t\t\tnewEndpoint.Targets = append(newEndpoint.Targets, inst.Attributes[sdInstanceAttrAlias])\n\t\t// IPv4-based target\n\t\tcase inst.Attributes[sdInstanceAttrIPV4] != \"\":\n\t\t\tnewEndpoint.RecordType = endpoint.RecordTypeA\n\t\t\tnewEndpoint.Targets = append(newEndpoint.Targets, inst.Attributes[sdInstanceAttrIPV4])\n\t\t// IPv6-based target\n\t\tcase inst.Attributes[sdInstanceAttrIPV6] != \"\":\n\t\t\tnewEndpoint.RecordType = endpoint.RecordTypeAAAA\n\t\t\tnewEndpoint.Targets = append(newEndpoint.Targets, inst.Attributes[sdInstanceAttrIPV6])\n\t\tdefault:\n\t\t\tlog.Warnf(\"Invalid instance \\\"%v\\\" found in service \\\"%v\\\"\", inst, srv.Name)\n\t\t}\n\t}\n\n\treturn newEndpoint\n}\n\n// ApplyChanges applies Kubernetes changes in endpoints to AWS API\nfunc (p *AWSSDProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {\n\t// return early if there is nothing to change\n\tif len(changes.Create) == 0 && len(changes.Delete) == 0 && len(changes.UpdateNew) == 0 {\n\t\tlog.Info(\"All records are already up to date\")\n\t\treturn nil\n\t}\n\n\t// convert updates to delete and create operation if applicable (updates not supported)\n\tcreates, deletes := p.updatesToCreates(changes)\n\tchanges.Delete = append(changes.Delete, deletes...)\n\tchanges.Create = append(changes.Create, creates...)\n\n\tnamespaces, err := p.ListNamespaces(ctx)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = p.submitDeletes(ctx, namespaces, changes.Delete)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = p.submitCreates(ctx, namespaces, changes.Create)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (p *AWSSDProvider) updatesToCreates(changes *plan.Changes) ([]*endpoint.Endpoint, []*endpoint.Endpoint) {\n\tupdateNewMap := map[string]*endpoint.Endpoint{}\n\tfor _, e := range changes.UpdateNew {\n\t\tupdateNewMap[e.DNSName] = e\n\t}\n\n\tvar creates, deletes []*endpoint.Endpoint\n\n\tfor _, old := range changes.UpdateOld {\n\t\tcurrent := updateNewMap[old.DNSName]\n\n\t\tif !old.Targets.Same(current.Targets) {\n\t\t\tcurrentTargetsMap := make(map[string]struct{}, len(current.Targets))\n\t\t\tfor _, newTarget := range current.Targets {\n\t\t\t\tcurrentTargetsMap[newTarget] = struct{}{}\n\t\t\t}\n\n\t\t\t// If targets changed, only deregister removed targets (i.e. in `UpdateOld` but not in `UpdateNew`)\n\t\t\ttargetsToRemove := make(endpoint.Targets, 0)\n\t\t\tfor _, oldTarget := range old.Targets {\n\t\t\t\tif _, found := currentTargetsMap[oldTarget]; !found {\n\t\t\t\t\ttargetsToRemove = append(targetsToRemove, oldTarget)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\told.Targets = targetsToRemove\n\t\t\tdeletes = append(deletes, old)\n\t\t}\n\n\t\t// always register (or re-register) instance with the current data\n\t\tcreates = append(creates, current)\n\t}\n\n\treturn creates, deletes\n}\n\nfunc (p *AWSSDProvider) submitCreates(ctx context.Context, namespaces []*sdtypes.NamespaceSummary, changes []*endpoint.Endpoint) error {\n\tchangesByNamespaceID := p.changesByNamespaceID(namespaces, changes)\n\n\tfor nsID, changeList := range changesByNamespaceID {\n\t\tservices, err := p.ListServicesByNamespaceID(ctx, aws.String(nsID))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfor _, ch := range changeList {\n\t\t\t_, srvName := p.parseHostname(ch.DNSName)\n\n\t\t\tsrv := services[srvName]\n\t\t\tif srv == nil {\n\t\t\t\t// when service is missing create a new one\n\t\t\t\tsrv, err = p.CreateService(ctx, &nsID, &srvName, ch)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\t// update a local list of services\n\t\t\t\tservices[*srv.Name] = srv\n\t\t\t} else if ch.RecordTTL.IsConfigured() && *srv.DnsConfig.DnsRecords[0].TTL != int64(ch.RecordTTL) {\n\t\t\t\t// update service when TTL differ\n\t\t\t\terr = p.UpdateService(ctx, srv, ch)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\terr = p.RegisterInstance(ctx, srv, ch)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (p *AWSSDProvider) submitDeletes(ctx context.Context, namespaces []*sdtypes.NamespaceSummary, changes []*endpoint.Endpoint) error {\n\tchangesByNamespaceID := p.changesByNamespaceID(namespaces, changes)\n\n\tfor nsID, changeList := range changesByNamespaceID {\n\t\tservices, err := p.ListServicesByNamespaceID(ctx, aws.String(nsID))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfor _, ch := range changeList {\n\t\t\thostname := ch.DNSName\n\t\t\t_, srvName := p.parseHostname(hostname)\n\n\t\t\tsrv := services[srvName]\n\t\t\tif srv == nil {\n\t\t\t\treturn fmt.Errorf(\"service \\\"%s\\\" is missing when trying to delete \\\"%v\\\"\", srvName, hostname)\n\t\t\t}\n\n\t\t\terr := p.DeregisterInstance(ctx, srv, ch)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// ListNamespaces returns all namespaces matching defined namespace filter\nfunc (p *AWSSDProvider) ListNamespaces(ctx context.Context) ([]*sdtypes.NamespaceSummary, error) {\n\tnamespaces := make([]*sdtypes.NamespaceSummary, 0)\n\n\tpaginator := sd.NewListNamespacesPaginator(p.client, &sd.ListNamespacesInput{\n\t\tFilters: p.namespaceTypeFilter,\n\t})\n\tfor paginator.HasMorePages() {\n\t\tresp, err := paginator.NextPage(ctx)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tfor _, ns := range resp.Namespaces {\n\t\t\tif !p.namespaceFilter.Match(*ns.Name) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tnamespaces = append(namespaces, &ns)\n\t\t}\n\t}\n\n\treturn namespaces, nil\n}\n\n// ListServicesByNamespaceID returns a list of services in a given namespace.\nfunc (p *AWSSDProvider) ListServicesByNamespaceID(ctx context.Context, namespaceID *string) (map[string]*sdtypes.Service, error) {\n\tservices := make([]sdtypes.ServiceSummary, 0)\n\n\tpaginator := sd.NewListServicesPaginator(p.client, &sd.ListServicesInput{\n\t\tFilters: []sdtypes.ServiceFilter{{\n\t\t\tName:   sdtypes.ServiceFilterNameNamespaceId,\n\t\t\tValues: []string{*namespaceID},\n\t\t}},\n\t\tMaxResults: aws.Int32(maxResults),\n\t})\n\tfor paginator.HasMorePages() {\n\t\tresp, err := paginator.NextPage(ctx)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tservices = append(services, resp.Services...)\n\t}\n\n\tservicesMap := make(map[string]*sdtypes.Service)\n\tfor _, serviceSummary := range services {\n\t\tservice := &sdtypes.Service{\n\t\t\tArn:                     serviceSummary.Arn,\n\t\t\tCreateDate:              serviceSummary.CreateDate,\n\t\t\tDescription:             serviceSummary.Description,\n\t\t\tDnsConfig:               serviceSummary.DnsConfig,\n\t\t\tHealthCheckConfig:       serviceSummary.HealthCheckConfig,\n\t\t\tHealthCheckCustomConfig: serviceSummary.HealthCheckCustomConfig,\n\t\t\tId:                      serviceSummary.Id,\n\t\t\tInstanceCount:           serviceSummary.InstanceCount,\n\t\t\tName:                    serviceSummary.Name,\n\t\t\tNamespaceId:             namespaceID,\n\t\t\tType:                    serviceSummary.Type,\n\t\t}\n\n\t\tservicesMap[*service.Name] = service\n\t}\n\treturn servicesMap, nil\n}\n\n// CreateService creates a new service in AWS API. Returns the created service.\nfunc (p *AWSSDProvider) CreateService(ctx context.Context, namespaceID *string, srvName *string, ep *endpoint.Endpoint) (*sdtypes.Service, error) {\n\tlog.Infof(\"Creating a new service \\\"%s\\\" in \\\"%s\\\" namespace\", *srvName, *namespaceID)\n\n\tsrvType := p.serviceTypeFromEndpoint(ep)\n\troutingPolicy := p.routingPolicyFromEndpoint(ep)\n\n\tttl := int64(defaultTTL)\n\tif ep.RecordTTL.IsConfigured() {\n\t\tttl = int64(ep.RecordTTL)\n\t}\n\n\tif p.dryRun {\n\t\t// return a mock service summary in case of a dry run\n\t\treturn &sdtypes.Service{Id: aws.String(\"dry-run-service\"), Name: aws.String(\"dry-run-service\")}, nil\n\t}\n\n\tout, err := p.client.CreateService(ctx, &sd.CreateServiceInput{\n\t\tName:        srvName,\n\t\tDescription: aws.String(ep.Labels[endpoint.AWSSDDescriptionLabel]),\n\t\tDnsConfig: &sdtypes.DnsConfig{\n\t\t\tRoutingPolicy: routingPolicy,\n\t\t\tDnsRecords: []sdtypes.DnsRecord{{\n\t\t\t\tType: srvType,\n\t\t\t\tTTL:  aws.Int64(ttl),\n\t\t\t}},\n\t\t},\n\t\tNamespaceId: namespaceID,\n\t\tTags:        p.tags,\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn out.Service, nil\n}\n\n// UpdateService updates the specified service with information from the provided endpoint.\nfunc (p *AWSSDProvider) UpdateService(ctx context.Context, service *sdtypes.Service, ep *endpoint.Endpoint) error {\n\tlog.Infof(\"Updating service \\\"%s\\\"\", *service.Name)\n\n\tsrvType := p.serviceTypeFromEndpoint(ep)\n\n\tttl := int64(defaultTTL)\n\tif ep.RecordTTL.IsConfigured() {\n\t\tttl = int64(ep.RecordTTL)\n\t}\n\n\tif p.dryRun {\n\t\treturn nil\n\t}\n\n\t_, err := p.client.UpdateService(ctx, &sd.UpdateServiceInput{\n\t\tId: service.Id,\n\t\tService: &sdtypes.ServiceChange{\n\t\t\tDescription: aws.String(ep.Labels[endpoint.AWSSDDescriptionLabel]),\n\t\t\tDnsConfig: &sdtypes.DnsConfigChange{\n\t\t\t\tDnsRecords: []sdtypes.DnsRecord{{\n\t\t\t\t\tType: srvType,\n\t\t\t\t\tTTL:  aws.Int64(ttl),\n\t\t\t\t}},\n\t\t\t},\n\t\t},\n\t})\n\treturn err\n}\n\n// DeleteService deletes empty Service from AWS API if its owner id match\nfunc (p *AWSSDProvider) DeleteService(ctx context.Context, service *sdtypes.Service) error {\n\tlog.Debugf(\"Check if service \\\"%s\\\" owner id match and it can be deleted\", *service.Name)\n\n\tif p.dryRun || !p.cleanEmptyService {\n\t\treturn nil\n\t}\n\n\t// convert ownerID string to the service description format\n\tlabel := endpoint.NewLabels()\n\tlabel[endpoint.OwnerLabelKey] = p.ownerID\n\tlabel[endpoint.AWSSDDescriptionLabel] = label.SerializePlain(false)\n\n\tif service.Description == nil {\n\t\tlog.Debugf(\"Skipping service removal %q because owner id (service.Description) not set, when should be %q\", *service.Name, label[endpoint.AWSSDDescriptionLabel])\n\t\treturn nil\n\t}\n\n\tif strings.HasPrefix(*service.Description, label[endpoint.AWSSDDescriptionLabel]) {\n\t\tlog.Infof(\"Deleting service \\\"%s\\\"\", *service.Name)\n\t\t_, err := p.client.DeleteService(ctx, &sd.DeleteServiceInput{\n\t\t\tId: aws.String(*service.Id),\n\t\t})\n\t\treturn err\n\t}\n\tlog.Debugf(\"Skipping service removal %q because owner id does not match, found: %q, required: %q\", *service.Name, *service.Description, label[endpoint.AWSSDDescriptionLabel])\n\n\treturn nil\n}\n\n// RegisterInstance creates a new instance in given service.\nfunc (p *AWSSDProvider) RegisterInstance(ctx context.Context, service *sdtypes.Service, ep *endpoint.Endpoint) error {\n\tfor _, target := range ep.Targets {\n\t\tlog.Infof(\"Registering a new instance \\\"%s\\\" for service \\\"%s\\\" (%s)\", target, *service.Name, *service.Id)\n\n\t\tattr := make(map[string]string)\n\n\t\tswitch ep.RecordType {\n\t\tcase endpoint.RecordTypeCNAME:\n\t\t\tif p.isAWSLoadBalancer(target) {\n\t\t\t\tattr[sdInstanceAttrAlias] = target\n\t\t\t} else {\n\t\t\t\tattr[sdInstanceAttrCname] = target\n\t\t\t}\n\t\tcase endpoint.RecordTypeA:\n\t\t\tattr[sdInstanceAttrIPV4] = target\n\t\tcase endpoint.RecordTypeAAAA:\n\t\t\tattr[sdInstanceAttrIPV6] = target\n\t\tdefault:\n\t\t\treturn fmt.Errorf(\"invalid endpoint type (%v)\", ep)\n\t\t}\n\n\t\tif !p.dryRun {\n\t\t\t_, err := p.client.RegisterInstance(ctx, &sd.RegisterInstanceInput{\n\t\t\t\tServiceId:  service.Id,\n\t\t\t\tAttributes: attr,\n\t\t\t\tInstanceId: aws.String(p.targetToInstanceID(target)),\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// DeregisterInstance removes an instance from given service.\nfunc (p *AWSSDProvider) DeregisterInstance(ctx context.Context, service *sdtypes.Service, ep *endpoint.Endpoint) error {\n\tfor _, target := range ep.Targets {\n\t\tlog.Infof(\"De-registering an instance \\\"%s\\\" for service \\\"%s\\\" (%s)\", target, *service.Name, *service.Id)\n\n\t\tif !p.dryRun {\n\t\t\t_, err := p.client.DeregisterInstance(ctx, &sd.DeregisterInstanceInput{\n\t\t\t\tInstanceId: aws.String(p.targetToInstanceID(target)),\n\t\t\t\tServiceId:  service.Id,\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// Instance ID length is limited by AWS API to 64 characters. For longer strings SHA-256 hash will be used instead of\n// the verbatim target to limit the length.\nfunc (p *AWSSDProvider) targetToInstanceID(target string) string {\n\tif len(target) > 64 {\n\t\thash := sha256.Sum256([]byte(strings.ToLower(target)))\n\t\treturn hex.EncodeToString(hash[:])\n\t}\n\n\treturn strings.ToLower(target)\n}\n\nfunc (p *AWSSDProvider) changesByNamespaceID(namespaces []*sdtypes.NamespaceSummary, changes []*endpoint.Endpoint) map[string][]*endpoint.Endpoint {\n\tchangesByNsID := make(map[string][]*endpoint.Endpoint)\n\n\tfor _, ns := range namespaces {\n\t\tchangesByNsID[*ns.Id] = []*endpoint.Endpoint{}\n\t}\n\n\tfor _, c := range changes {\n\t\t// trim the trailing dot from hostname if any\n\t\thostname := strings.TrimSuffix(c.DNSName, \".\")\n\t\tnsName, _ := p.parseHostname(hostname)\n\n\t\tmatchingNamespaces := matchingNamespaces(nsName, namespaces)\n\t\tif len(matchingNamespaces) == 0 {\n\t\t\tlog.Warnf(\"Skipping record %s because no namespace matching record DNS Name was detected \", c.String())\n\t\t\tcontinue\n\t\t}\n\t\tfor _, ns := range matchingNamespaces {\n\t\t\tchangesByNsID[*ns.Id] = append(changesByNsID[*ns.Id], c)\n\t\t}\n\t}\n\n\t// separating a change could lead to empty sub changes, remove them here.\n\tfor zone, change := range changesByNsID {\n\t\tif len(change) == 0 {\n\t\t\tdelete(changesByNsID, zone)\n\t\t}\n\t}\n\n\treturn changesByNsID\n}\n\n// returns list of all namespaces matching given hostname\nfunc matchingNamespaces(hostname string, namespaces []*sdtypes.NamespaceSummary) []*sdtypes.NamespaceSummary {\n\tmatchingNamespaces := make([]*sdtypes.NamespaceSummary, 0)\n\n\tfor _, ns := range namespaces {\n\t\tif *ns.Name == hostname {\n\t\t\tmatchingNamespaces = append(matchingNamespaces, ns)\n\t\t}\n\t}\n\n\treturn matchingNamespaces\n}\n\n// parseHostname parse hostname to namespace (domain) and service\nfunc (p *AWSSDProvider) parseHostname(hostname string) (string, string) {\n\tparts := strings.Split(hostname, \".\")\n\treturn strings.Join(parts[1:], \".\"), parts[0]\n}\n\n// determine service routing policy based on endpoint type\nfunc (p *AWSSDProvider) routingPolicyFromEndpoint(ep *endpoint.Endpoint) sdtypes.RoutingPolicy {\n\tif ep.RecordType == endpoint.RecordTypeA || ep.RecordType == endpoint.RecordTypeAAAA {\n\t\treturn sdtypes.RoutingPolicyMultivalue\n\t}\n\n\treturn sdtypes.RoutingPolicyWeighted\n}\n\n// determine the service type (A, AAAA, CNAME) from a given endpoint\nfunc (p *AWSSDProvider) serviceTypeFromEndpoint(ep *endpoint.Endpoint) sdtypes.RecordType {\n\tswitch ep.RecordType {\n\tcase endpoint.RecordTypeCNAME:\n\t\t// FIXME service type is derived from the first target only. Theoretically this may be problem.\n\t\t// But I don't see a scenario where one endpoint contains targets of different types.\n\t\tif p.isAWSLoadBalancer(ep.Targets[0]) {\n\t\t\t// ALIAS target uses DNS record of type A\n\t\t\treturn sdtypes.RecordTypeA\n\t\t}\n\t\treturn sdtypes.RecordTypeCname\n\tcase endpoint.RecordTypeAAAA:\n\t\treturn sdtypes.RecordTypeAaaa\n\tdefault:\n\t\treturn sdtypes.RecordTypeA\n\t}\n}\n\n// determine if a given hostname belongs to an AWS load balancer\nfunc (p *AWSSDProvider) isAWSLoadBalancer(hostname string) bool {\n\tmatchElb := sdElbHostnameRegex.MatchString(hostname)\n\tmatchNlb := sdNlbHostnameRegex.MatchString(hostname)\n\n\treturn matchElb || matchNlb\n}\n"
  },
  {
    "path": "provider/awssd/aws_sd_test.go",
    "content": "/*\nCopyright 2018 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage awssd\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n\n\t\"github.com/aws/aws-sdk-go-v2/aws\"\n\tsdtypes \"github.com/aws/aws-sdk-go-v2/service/servicediscovery/types\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/internal/testutils\"\n\tlogtest \"sigs.k8s.io/external-dns/internal/testutils/log\"\n\t\"sigs.k8s.io/external-dns/plan\"\n)\n\nfunc TestAWSSDProvider_Records(t *testing.T) {\n\tnamespaces := map[string]*sdtypes.Namespace{\n\t\t\"private\": {\n\t\t\tId:   aws.String(\"private\"),\n\t\t\tName: aws.String(\"private.com\"),\n\t\t\tType: sdtypes.NamespaceTypeDnsPrivate,\n\t\t},\n\t}\n\n\tservices := map[string]map[string]*sdtypes.Service{\n\t\t\"private\": {\n\t\t\t\"a-srv\": {\n\t\t\t\tId:          aws.String(\"a-srv\"),\n\t\t\t\tName:        aws.String(\"service1\"),\n\t\t\t\tNamespaceId: aws.String(\"private\"),\n\t\t\t\tDescription: aws.String(\"owner-id\"),\n\t\t\t\tDnsConfig: &sdtypes.DnsConfig{\n\t\t\t\t\tRoutingPolicy: sdtypes.RoutingPolicyWeighted,\n\t\t\t\t\tDnsRecords: []sdtypes.DnsRecord{{\n\t\t\t\t\t\tType: sdtypes.RecordTypeA,\n\t\t\t\t\t\tTTL:  aws.Int64(100),\n\t\t\t\t\t}},\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"alias-srv\": {\n\t\t\t\tId:          aws.String(\"alias-srv\"),\n\t\t\t\tName:        aws.String(\"service2\"),\n\t\t\t\tNamespaceId: aws.String(\"private\"),\n\t\t\t\tDescription: aws.String(\"owner-id\"),\n\t\t\t\tDnsConfig: &sdtypes.DnsConfig{\n\t\t\t\t\tRoutingPolicy: sdtypes.RoutingPolicyWeighted,\n\t\t\t\t\tDnsRecords: []sdtypes.DnsRecord{{\n\t\t\t\t\t\tType: sdtypes.RecordTypeA,\n\t\t\t\t\t\tTTL:  aws.Int64(100),\n\t\t\t\t\t}},\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"cname-srv\": {\n\t\t\t\tId:          aws.String(\"cname-srv\"),\n\t\t\t\tName:        aws.String(\"service3\"),\n\t\t\t\tNamespaceId: aws.String(\"private\"),\n\t\t\t\tDescription: aws.String(\"owner-id\"),\n\t\t\t\tDnsConfig: &sdtypes.DnsConfig{\n\t\t\t\t\tRoutingPolicy: sdtypes.RoutingPolicyWeighted,\n\t\t\t\t\tDnsRecords: []sdtypes.DnsRecord{{\n\t\t\t\t\t\tType: sdtypes.RecordTypeCname,\n\t\t\t\t\t\tTTL:  aws.Int64(80),\n\t\t\t\t\t}},\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"aaaa-srv\": {\n\t\t\t\tId:          aws.String(\"aaaa-srv\"),\n\t\t\t\tName:        aws.String(\"service4\"),\n\t\t\t\tDescription: aws.String(\"owner-id\"),\n\t\t\t\tDnsConfig: &sdtypes.DnsConfig{\n\t\t\t\t\tNamespaceId:   aws.String(\"private\"),\n\t\t\t\t\tRoutingPolicy: sdtypes.RoutingPolicyWeighted,\n\t\t\t\t\tDnsRecords: []sdtypes.DnsRecord{{\n\t\t\t\t\t\tType: sdtypes.RecordTypeAaaa,\n\t\t\t\t\t\tTTL:  aws.Int64(100),\n\t\t\t\t\t}},\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"aaaa-srv-not-managed-without-owner-id\": {\n\t\t\t\tId:          aws.String(\"aaaa-srv\"),\n\t\t\t\tName:        aws.String(\"service5\"),\n\t\t\t\tDescription: nil,\n\t\t\t\tDnsConfig: &sdtypes.DnsConfig{\n\t\t\t\t\tNamespaceId:   aws.String(\"private\"),\n\t\t\t\t\tRoutingPolicy: sdtypes.RoutingPolicyWeighted,\n\t\t\t\t\tDnsRecords: []sdtypes.DnsRecord{{\n\t\t\t\t\t\tType: sdtypes.RecordTypeAaaa,\n\t\t\t\t\t\tTTL:  aws.Int64(100),\n\t\t\t\t\t}},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tinstances := map[string]map[string]*sdtypes.Instance{\n\t\t\"a-srv\": {\n\t\t\t\"1.2.3.4\": {\n\t\t\t\tId: aws.String(\"1.2.3.4\"),\n\t\t\t\tAttributes: map[string]string{\n\t\t\t\t\tsdInstanceAttrIPV4: \"1.2.3.4\",\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"1.2.3.5\": {\n\t\t\t\tId: aws.String(\"1.2.3.5\"),\n\t\t\t\tAttributes: map[string]string{\n\t\t\t\t\tsdInstanceAttrIPV4: \"1.2.3.5\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"alias-srv\": {\n\t\t\t\"load-balancer.us-east-1.elb.amazonaws.com\": {\n\t\t\t\tId: aws.String(\"load-balancer.us-east-1.elb.amazonaws.com\"),\n\t\t\t\tAttributes: map[string]string{\n\t\t\t\t\tsdInstanceAttrAlias: \"load-balancer.us-east-1.elb.amazonaws.com\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"cname-srv\": {\n\t\t\t\"cname.target.com\": {\n\t\t\t\tId: aws.String(\"cname.target.com\"),\n\t\t\t\tAttributes: map[string]string{\n\t\t\t\t\tsdInstanceAttrCname: \"cname.target.com\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"aaaa-srv\": {\n\t\t\t\"0000:0000:0000:0000:abcd:abcd:abcd:abcd\": {\n\t\t\t\tId: aws.String(\"0000:0000:0000:0000:abcd:abcd:abcd:abcd\"),\n\t\t\t\tAttributes: map[string]string{\n\t\t\t\t\tsdInstanceAttrIPV6: \"0000:0000:0000:0000:abcd:abcd:abcd:abcd\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\texpectedEndpoints := []*endpoint.Endpoint{\n\t\t{DNSName: \"service1.private.com\", Targets: endpoint.Targets{\"1.2.3.4\", \"1.2.3.5\"}, RecordType: endpoint.RecordTypeA, RecordTTL: 100, Labels: map[string]string{endpoint.AWSSDDescriptionLabel: \"owner-id\"}},\n\t\t{DNSName: \"service2.private.com\", Targets: endpoint.Targets{\"load-balancer.us-east-1.elb.amazonaws.com\"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 100, Labels: map[string]string{endpoint.AWSSDDescriptionLabel: \"owner-id\"}},\n\t\t{DNSName: \"service3.private.com\", Targets: endpoint.Targets{\"cname.target.com\"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 80, Labels: map[string]string{endpoint.AWSSDDescriptionLabel: \"owner-id\"}},\n\t\t{DNSName: \"service4.private.com\", Targets: endpoint.Targets{\"0000:0000:0000:0000:abcd:abcd:abcd:abcd\"}, RecordType: endpoint.RecordTypeAAAA, RecordTTL: 100, Labels: map[string]string{endpoint.AWSSDDescriptionLabel: \"owner-id\"}},\n\t}\n\n\tapi := &AWSSDClientStub{\n\t\tnamespaces: namespaces,\n\t\tservices:   services,\n\t\tinstances:  instances,\n\t}\n\n\tprovider := newTestAWSSDProvider(api, endpoint.NewDomainFilter([]string{}), \"\", \"\")\n\n\tendpoints, _ := provider.Records(t.Context())\n\n\tassert.True(t, testutils.SameEndpoints(expectedEndpoints, endpoints), \"expected and actual endpoints don't match, expected=%v, actual=%v\", expectedEndpoints, endpoints)\n}\n\nfunc TestAWSSDProvider_ApplyChanges(t *testing.T) {\n\tnamespaces := map[string]*sdtypes.Namespace{\n\t\t\"private\": {\n\t\t\tId:   aws.String(\"private\"),\n\t\t\tName: aws.String(\"private.com\"),\n\t\t\tType: sdtypes.NamespaceTypeDnsPrivate,\n\t\t},\n\t}\n\n\tapi := &AWSSDClientStub{\n\t\tnamespaces: namespaces,\n\t\tservices:   make(map[string]map[string]*sdtypes.Service),\n\t\tinstances:  make(map[string]map[string]*sdtypes.Instance),\n\t}\n\n\texpectedEndpoints := []*endpoint.Endpoint{\n\t\t{DNSName: \"service1.private.com\", Targets: endpoint.Targets{\"1.2.3.4\", \"1.2.3.5\"}, RecordType: endpoint.RecordTypeA, RecordTTL: 60},\n\t\t{DNSName: \"service2.private.com\", Targets: endpoint.Targets{\"load-balancer.us-east-1.elb.amazonaws.com\"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 80},\n\t\t{DNSName: \"service3.private.com\", Targets: endpoint.Targets{\"cname.target.com\"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 100},\n\t}\n\n\tprovider := newTestAWSSDProvider(api, endpoint.NewDomainFilter([]string{}), \"\", \"\")\n\n\tctx := t.Context()\n\n\t// apply creates\n\terr := provider.ApplyChanges(ctx, &plan.Changes{\n\t\tCreate: expectedEndpoints,\n\t})\n\tassert.NoError(t, err)\n\n\t// make sure services were created\n\tassert.Len(t, api.services[\"private\"], 3)\n\texistingServices, _ := provider.ListServicesByNamespaceID(t.Context(), namespaces[\"private\"].Id)\n\tassert.NotNil(t, existingServices[\"service1\"])\n\tassert.NotNil(t, existingServices[\"service2\"])\n\tassert.NotNil(t, existingServices[\"service3\"])\n\n\t// make sure instances were registered\n\tendpoints, _ := provider.Records(ctx)\n\tassert.True(t, testutils.SameEndpoints(expectedEndpoints, endpoints), \"expected and actual endpoints don't match, expected=%v, actual=%v\", expectedEndpoints, endpoints)\n\n\tctx = t.Context()\n\t// apply deletes\n\terr = provider.ApplyChanges(ctx, &plan.Changes{\n\t\tDelete: expectedEndpoints,\n\t})\n\tassert.NoError(t, err)\n\n\t// make sure all instances are gone\n\tendpoints, _ = provider.Records(ctx)\n\tassert.Empty(t, endpoints)\n}\n\nfunc TestAWSSDProvider_ApplyChanges_Update(t *testing.T) {\n\tnamespaces := map[string]*sdtypes.Namespace{\n\t\t\"private\": {\n\t\t\tId:   aws.String(\"private\"),\n\t\t\tName: aws.String(\"private.com\"),\n\t\t\tType: sdtypes.NamespaceTypeDnsPrivate,\n\t\t},\n\t}\n\n\tapi := &AWSSDClientStub{\n\t\tnamespaces: namespaces,\n\t\tservices:   make(map[string]map[string]*sdtypes.Service),\n\t\tinstances:  make(map[string]map[string]*sdtypes.Instance),\n\t}\n\n\toldEndpoints := []*endpoint.Endpoint{\n\t\t{DNSName: \"service1.private.com\", Targets: endpoint.Targets{\"1.2.3.4\", \"1.2.3.5\"}, RecordType: endpoint.RecordTypeA, RecordTTL: 60},\n\t}\n\n\tnewEndpoints := []*endpoint.Endpoint{\n\t\t{DNSName: \"service1.private.com\", Targets: endpoint.Targets{\"1.2.3.4\", \"1.2.3.6\"}, RecordType: endpoint.RecordTypeA, RecordTTL: 60},\n\t}\n\n\tprovider := newTestAWSSDProvider(api, endpoint.NewDomainFilter([]string{}), \"\", \"\")\n\n\tctx := t.Context()\n\n\t// apply creates\n\t_ = provider.ApplyChanges(ctx, &plan.Changes{\n\t\tCreate: oldEndpoints,\n\t})\n\n\tctx = t.Context()\n\n\t// apply update\n\t_ = provider.ApplyChanges(ctx, &plan.Changes{\n\t\tUpdateOld: oldEndpoints,\n\t\tUpdateNew: newEndpoints,\n\t})\n\n\t// make sure services were created\n\tassert.Len(t, api.services[\"private\"], 1)\n\texistingServices, _ := provider.ListServicesByNamespaceID(ctx, namespaces[\"private\"].Id)\n\tassert.NotNil(t, existingServices[\"service1\"])\n\n\t// make sure instances were registered\n\tendpoints, _ := provider.Records(ctx)\n\tassert.True(t, testutils.SameEndpoints(newEndpoints, endpoints), \"expected and actual endpoints don't match, expected=%v, actual=%v\", newEndpoints, endpoints)\n\n\t// make sure only one instance is de-registered\n\tassert.Len(t, api.deregistered, 1)\n\tassert.Equal(t, \"1.2.3.5\", api.deregistered[0], \"wrong target de-registered\")\n}\n\nfunc TestAWSSDProvider_ListNamespaces(t *testing.T) {\n\tnamespaces := map[string]*sdtypes.Namespace{\n\t\t\"private\": {\n\t\t\tId:   aws.String(\"private\"),\n\t\t\tName: aws.String(\"private.com\"),\n\t\t\tType: sdtypes.NamespaceTypeDnsPrivate,\n\t\t},\n\t\t\"public\": {\n\t\t\tId:   aws.String(\"public\"),\n\t\t\tName: aws.String(\"public.com\"),\n\t\t\tType: sdtypes.NamespaceTypeDnsPublic,\n\t\t},\n\t}\n\n\tapi := &AWSSDClientStub{\n\t\tnamespaces: namespaces,\n\t}\n\n\tfor _, tc := range []struct {\n\t\tmsg                 string\n\t\tdomainFilter        *endpoint.DomainFilter\n\t\tnamespaceTypeFilter string\n\t\texpectedNamespaces  []*sdtypes.NamespaceSummary\n\t}{\n\t\t{\"public filter\", endpoint.NewDomainFilter([]string{}), \"public\", []*sdtypes.NamespaceSummary{namespaceToNamespaceSummary(namespaces[\"public\"])}},\n\t\t{\"private filter\", endpoint.NewDomainFilter([]string{}), \"private\", []*sdtypes.NamespaceSummary{namespaceToNamespaceSummary(namespaces[\"private\"])}},\n\t\t{\"optional filter\", endpoint.NewDomainFilter([]string{}), \"\", []*sdtypes.NamespaceSummary{namespaceToNamespaceSummary(namespaces[\"public\"]), namespaceToNamespaceSummary(namespaces[\"private\"])}},\n\t\t{\"domain filter\", endpoint.NewDomainFilter([]string{\"public.com\"}), \"\", []*sdtypes.NamespaceSummary{namespaceToNamespaceSummary(namespaces[\"public\"])}},\n\t\t{\"non-existing domain\", endpoint.NewDomainFilter([]string{\"xxx.com\"}), \"\", []*sdtypes.NamespaceSummary{}},\n\t} {\n\t\tprovider := newTestAWSSDProvider(api, tc.domainFilter, tc.namespaceTypeFilter, \"\")\n\n\t\tresult, err := provider.ListNamespaces(t.Context())\n\t\trequire.NoError(t, err)\n\n\t\texpectedMap := make(map[string]*sdtypes.NamespaceSummary)\n\t\tresultMap := make(map[string]*sdtypes.NamespaceSummary)\n\t\tfor _, ns := range tc.expectedNamespaces {\n\t\t\texpectedMap[*ns.Id] = ns\n\t\t}\n\t\tfor _, ns := range result {\n\t\t\tresultMap[*ns.Id] = ns\n\t\t}\n\n\t\tif !reflect.DeepEqual(resultMap, expectedMap) {\n\t\t\tt.Errorf(\"AWSSDProvider.ListNamespaces() error = %v, wantErr %v\", result, tc.expectedNamespaces)\n\t\t}\n\t}\n}\n\nfunc TestAWSSDProvider_ListServicesByNamespace(t *testing.T) {\n\tnamespaces := map[string]*sdtypes.Namespace{\n\t\t\"private\": {\n\t\t\tId:   aws.String(\"private\"),\n\t\t\tName: aws.String(\"private.com\"),\n\t\t\tType: sdtypes.NamespaceTypeDnsPrivate,\n\t\t},\n\t\t\"public\": {\n\t\t\tId:   aws.String(\"public\"),\n\t\t\tName: aws.String(\"public.com\"),\n\t\t\tType: sdtypes.NamespaceTypeDnsPublic,\n\t\t},\n\t}\n\n\tservices := map[string]map[string]*sdtypes.Service{\n\t\t\"private\": {\n\t\t\t\"srv1\": {\n\t\t\t\tId:          aws.String(\"srv1\"),\n\t\t\t\tName:        aws.String(\"service1\"),\n\t\t\t\tNamespaceId: aws.String(\"private\"),\n\t\t\t},\n\t\t\t\"srv2\": {\n\t\t\t\tId:          aws.String(\"srv2\"),\n\t\t\t\tName:        aws.String(\"service2\"),\n\t\t\t\tNamespaceId: aws.String(\"private\"),\n\t\t\t},\n\t\t},\n\t\t\"public\": {\n\t\t\t\"srv3\": {\n\t\t\t\tId:          aws.String(\"srv3\"),\n\t\t\t\tName:        aws.String(\"service3\"),\n\t\t\t\tNamespaceId: aws.String(\"public\"),\n\t\t\t},\n\t\t},\n\t}\n\n\tapi := &AWSSDClientStub{\n\t\tnamespaces: namespaces,\n\t\tservices:   services,\n\t}\n\n\tfor _, tc := range []struct {\n\t\texpectedServices map[string]*sdtypes.Service\n\t}{\n\t\t{map[string]*sdtypes.Service{\"service1\": services[\"private\"][\"srv1\"], \"service2\": services[\"private\"][\"srv2\"]}},\n\t} {\n\t\tprovider := newTestAWSSDProvider(api, endpoint.NewDomainFilter([]string{}), \"\", \"\")\n\n\t\tresult, err := provider.ListServicesByNamespaceID(t.Context(), namespaces[\"private\"].Id)\n\t\trequire.NoError(t, err)\n\t\tassert.Equal(t, tc.expectedServices, result)\n\t}\n}\n\nfunc TestAWSSDProvider_CreateService(t *testing.T) {\n\tnamespaces := map[string]*sdtypes.Namespace{\n\t\t\"private\": {\n\t\t\tId:   aws.String(\"private\"),\n\t\t\tName: aws.String(\"private.com\"),\n\t\t\tType: sdtypes.NamespaceTypeDnsPrivate,\n\t\t},\n\t}\n\n\tapi := &AWSSDClientStub{\n\t\tnamespaces: namespaces,\n\t\tservices:   make(map[string]map[string]*sdtypes.Service),\n\t}\n\n\texpectedServices := make(map[string]*sdtypes.Service)\n\n\tprovider := newTestAWSSDProvider(api, endpoint.NewDomainFilter([]string{}), \"\", \"\")\n\n\t// A type\n\t_, err := provider.CreateService(t.Context(), aws.String(\"private\"), aws.String(\"A-srv\"), &endpoint.Endpoint{\n\t\tLabels: map[string]string{\n\t\t\tendpoint.AWSSDDescriptionLabel: \"A-srv\",\n\t\t},\n\t\tRecordType: endpoint.RecordTypeA,\n\t\tRecordTTL:  60,\n\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t})\n\tassert.NoError(t, err)\n\n\texpectedServices[\"A-srv\"] = &sdtypes.Service{\n\t\tName:        aws.String(\"A-srv\"),\n\t\tDescription: aws.String(\"A-srv\"),\n\t\tDnsConfig: &sdtypes.DnsConfig{\n\t\t\tRoutingPolicy: sdtypes.RoutingPolicyMultivalue,\n\t\t\tDnsRecords: []sdtypes.DnsRecord{{\n\t\t\t\tType: sdtypes.RecordTypeA,\n\t\t\t\tTTL:  aws.Int64(60),\n\t\t\t}},\n\t\t},\n\t\tNamespaceId: aws.String(\"private\"),\n\t}\n\n\t// AAAA type\n\t_, err = provider.CreateService(t.Context(), aws.String(\"private\"), aws.String(\"AAAA-srv\"), &endpoint.Endpoint{\n\t\tLabels: map[string]string{\n\t\t\tendpoint.AWSSDDescriptionLabel: \"AAAA-srv\",\n\t\t},\n\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\tRecordTTL:  60,\n\t\tTargets:    endpoint.Targets{\"::1234:5678:\"},\n\t})\n\tassert.NoError(t, err)\n\texpectedServices[\"AAAA-srv\"] = &sdtypes.Service{\n\t\tName:        aws.String(\"AAAA-srv\"),\n\t\tDescription: aws.String(\"AAAA-srv\"),\n\t\tDnsConfig: &sdtypes.DnsConfig{\n\t\t\tRoutingPolicy: sdtypes.RoutingPolicyMultivalue,\n\t\t\tDnsRecords: []sdtypes.DnsRecord{{\n\t\t\t\tType: sdtypes.RecordTypeAaaa,\n\t\t\t\tTTL:  aws.Int64(60),\n\t\t\t}},\n\t\t},\n\t\tNamespaceId: aws.String(\"private\"),\n\t}\n\n\t// CNAME type\n\t_, err = provider.CreateService(t.Context(), aws.String(\"private\"), aws.String(\"CNAME-srv\"), &endpoint.Endpoint{\n\t\tLabels: map[string]string{\n\t\t\tendpoint.AWSSDDescriptionLabel: \"CNAME-srv\",\n\t\t},\n\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\tRecordTTL:  80,\n\t\tTargets:    endpoint.Targets{\"cname.target.com\"},\n\t})\n\tassert.NoError(t, err)\n\texpectedServices[\"CNAME-srv\"] = &sdtypes.Service{\n\t\tName:        aws.String(\"CNAME-srv\"),\n\t\tDescription: aws.String(\"CNAME-srv\"),\n\t\tDnsConfig: &sdtypes.DnsConfig{\n\t\t\tRoutingPolicy: sdtypes.RoutingPolicyWeighted,\n\t\t\tDnsRecords: []sdtypes.DnsRecord{{\n\t\t\t\tType: sdtypes.RecordTypeCname,\n\t\t\t\tTTL:  aws.Int64(80),\n\t\t\t}},\n\t\t},\n\t\tNamespaceId: aws.String(\"private\"),\n\t}\n\n\t// ALIAS type\n\t_, err = provider.CreateService(t.Context(), aws.String(\"private\"), aws.String(\"ALIAS-srv\"), &endpoint.Endpoint{\n\t\tLabels: map[string]string{\n\t\t\tendpoint.AWSSDDescriptionLabel: \"ALIAS-srv\",\n\t\t},\n\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\tRecordTTL:  100,\n\t\tTargets:    endpoint.Targets{\"load-balancer.us-east-1.elb.amazonaws.com\"},\n\t})\n\tassert.NoError(t, err)\n\texpectedServices[\"ALIAS-srv\"] = &sdtypes.Service{\n\t\tName:        aws.String(\"ALIAS-srv\"),\n\t\tDescription: aws.String(\"ALIAS-srv\"),\n\t\tDnsConfig: &sdtypes.DnsConfig{\n\t\t\tRoutingPolicy: sdtypes.RoutingPolicyWeighted,\n\t\t\tDnsRecords: []sdtypes.DnsRecord{{\n\t\t\t\tType: sdtypes.RecordTypeA,\n\t\t\t\tTTL:  aws.Int64(100),\n\t\t\t}},\n\t\t},\n\t\tNamespaceId: aws.String(\"private\"),\n\t}\n\n\ttestHelperAWSSDServicesMapsEqual(t, expectedServices, api.services[\"private\"])\n}\n\nfunc TestAWSSDProvider_CreateServiceDryRun(t *testing.T) {\n\tnamespaces := map[string]*sdtypes.Namespace{\n\t\t\"private\": {\n\t\t\tId:   aws.String(\"private\"),\n\t\t\tName: aws.String(\"private.com\"),\n\t\t\tType: sdtypes.NamespaceTypeDnsPrivate,\n\t\t},\n\t}\n\n\tapi := &AWSSDClientStub{\n\t\tnamespaces: namespaces,\n\t\tservices:   make(map[string]map[string]*sdtypes.Service),\n\t}\n\n\tprovider := newTestAWSSDProvider(api, endpoint.NewDomainFilter([]string{}), \"\", \"\")\n\tprovider.dryRun = true\n\n\tservice, err := provider.CreateService(t.Context(), aws.String(\"private\"), aws.String(\"A-srv\"), &endpoint.Endpoint{\n\t\tLabels: map[string]string{\n\t\t\tendpoint.AWSSDDescriptionLabel: \"A-srv\",\n\t\t},\n\t\tRecordType: endpoint.RecordTypeA,\n\t\tRecordTTL:  60,\n\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t})\n\tassert.NoError(t, err)\n\n\tassert.NotNil(t, service)\n\tassert.Equal(t, \"dry-run-service\", *service.Name)\n}\n\nfunc TestAWSSDProvider_CreateService_LabelNotSet(t *testing.T) {\n\tnamespaces := map[string]*sdtypes.Namespace{\n\t\t\"private\": {\n\t\t\tId:   aws.String(\"private\"),\n\t\t\tName: aws.String(\"private.com\"),\n\t\t\tType: sdtypes.NamespaceTypeDnsPrivate,\n\t\t},\n\t}\n\n\tapi := &AWSSDClientStub{\n\t\tnamespaces: namespaces,\n\t\tservices:   make(map[string]map[string]*sdtypes.Service),\n\t}\n\n\tprovider := newTestAWSSDProvider(api, endpoint.NewDomainFilter([]string{}), \"\", \"owner-123\")\n\n\tservice, err := provider.CreateService(t.Context(), aws.String(\"private\"), aws.String(\"A-srv\"), &endpoint.Endpoint{\n\t\tLabels: map[string]string{\n\t\t\t\"wrong-unsupported-label\": \"A-srv\",\n\t\t},\n\t\tRecordType: endpoint.RecordTypeA,\n\t\tRecordTTL:  60,\n\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t})\n\n\tassert.NoError(t, err)\n\tassert.NotNil(t, service)\n\tassert.Empty(t, *service.Description)\n}\n\nfunc TestAWSSDProvider_UpdateService(t *testing.T) {\n\tnamespaces := map[string]*sdtypes.Namespace{\n\t\t\"private\": {\n\t\t\tId:   aws.String(\"private\"),\n\t\t\tName: aws.String(\"private.com\"),\n\t\t\tType: sdtypes.NamespaceTypeDnsPrivate,\n\t\t},\n\t}\n\n\tservices := map[string]map[string]*sdtypes.Service{\n\t\t\"private\": {\n\t\t\t\"srv1\": {\n\t\t\t\tId:          aws.String(\"srv1\"),\n\t\t\t\tName:        aws.String(\"service1\"),\n\t\t\t\tNamespaceId: aws.String(\"private\"),\n\t\t\t\tDnsConfig: &sdtypes.DnsConfig{\n\t\t\t\t\tRoutingPolicy: sdtypes.RoutingPolicyMultivalue,\n\t\t\t\t\tDnsRecords: []sdtypes.DnsRecord{{\n\t\t\t\t\t\tType: sdtypes.RecordTypeA,\n\t\t\t\t\t\tTTL:  aws.Int64(60),\n\t\t\t\t\t}},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tapi := &AWSSDClientStub{\n\t\tnamespaces: namespaces,\n\t\tservices:   services,\n\t}\n\n\tprovider := newTestAWSSDProvider(api, endpoint.NewDomainFilter([]string{}), \"\", \"\")\n\n\t// update service with different TTL\n\terr := provider.UpdateService(t.Context(), services[\"private\"][\"srv1\"], &endpoint.Endpoint{\n\t\tRecordType: endpoint.RecordTypeA,\n\t\tRecordTTL:  100,\n\t})\n\n\tassert.NoError(t, err)\n\tassert.Len(t, api.services[\"private\"], 1)\n\tassert.Equal(t, int64(100), *api.services[\"private\"][\"srv1\"].DnsConfig.DnsRecords[0].TTL)\n}\n\nfunc TestAWSSDProvider_UpdateService_DryRun(t *testing.T) {\n\tnamespaces := map[string]*sdtypes.Namespace{\n\t\t\"private\": {\n\t\t\tId:   aws.String(\"private\"),\n\t\t\tName: aws.String(\"private.com\"),\n\t\t\tType: sdtypes.NamespaceTypeDnsPrivate,\n\t\t},\n\t}\n\n\tservices := map[string]map[string]*sdtypes.Service{\n\t\t\"private\": {\n\t\t\t\"srv1\": {\n\t\t\t\tId:          aws.String(\"srv1\"),\n\t\t\t\tName:        aws.String(\"service1\"),\n\t\t\t\tNamespaceId: aws.String(\"private\"),\n\t\t\t\tDnsConfig: &sdtypes.DnsConfig{\n\t\t\t\t\tRoutingPolicy: sdtypes.RoutingPolicyMultivalue,\n\t\t\t\t\tDnsRecords: []sdtypes.DnsRecord{{\n\t\t\t\t\t\tType: sdtypes.RecordTypeA,\n\t\t\t\t\t\tTTL:  aws.Int64(60),\n\t\t\t\t\t}},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tapi := &AWSSDClientStub{\n\t\tnamespaces: namespaces,\n\t\tservices:   services,\n\t}\n\n\tprovider := newTestAWSSDProvider(api, endpoint.NewDomainFilter([]string{}), \"\", \"\")\n\tprovider.dryRun = true\n\n\t// update service with different TTL\n\terr := provider.UpdateService(t.Context(), services[\"private\"][\"srv1\"], &endpoint.Endpoint{\n\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\tRecordTTL:  100,\n\t})\n\n\tassert.NoError(t, err)\n\tassert.Len(t, api.services[\"private\"], 1)\n\t// records should not be updated\n\tassert.NotEqual(t, 100, api.services[\"private\"][\"srv1\"].DnsConfig.DnsRecords[0].TTL)\n\tassert.NotEqual(t, endpoint.RecordTypeAAAA, api.services[\"private\"][\"srv1\"].DnsConfig.DnsRecords[0].Type)\n}\n\nfunc TestAWSSDProvider_DeleteService(t *testing.T) {\n\tnamespaces := map[string]*sdtypes.Namespace{\n\t\t\"private\": {\n\t\t\tId:   aws.String(\"private\"),\n\t\t\tName: aws.String(\"private.com\"),\n\t\t\tType: sdtypes.NamespaceTypeDnsPrivate,\n\t\t},\n\t}\n\n\tservices := map[string]map[string]*sdtypes.Service{\n\t\t\"private\": {\n\t\t\t\"srv1\": {\n\t\t\t\tId:          aws.String(\"srv1\"),\n\t\t\t\tDescription: aws.String(\"heritage=external-dns,external-dns/owner=owner-id\"),\n\t\t\t\tName:        aws.String(\"service1\"),\n\t\t\t\tNamespaceId: aws.String(\"private\"),\n\t\t\t},\n\t\t\t\"srv2\": {\n\t\t\t\tId:          aws.String(\"srv2\"),\n\t\t\t\tDescription: aws.String(\"heritage=external-dns,external-dns/owner=owner-id\"),\n\t\t\t\tName:        aws.String(\"service2\"),\n\t\t\t\tNamespaceId: aws.String(\"private\"),\n\t\t\t},\n\t\t\t\"srv3\": {\n\t\t\t\tId:          aws.String(\"srv3\"),\n\t\t\t\tDescription: aws.String(\"heritage=external-dns,external-dns/owner=owner-id,external-dns/resource=virtualservice/grpc-server/validate-grpc-server\"),\n\t\t\t\tName:        aws.String(\"service3\"),\n\t\t\t\tNamespaceId: aws.String(\"private\"),\n\t\t\t},\n\t\t\t\"srv4\": {\n\t\t\t\tId:          aws.String(\"srv4\"),\n\t\t\t\tDescription: nil,\n\t\t\t\tName:        aws.String(\"service4\"),\n\t\t\t\tNamespaceId: aws.String(\"private\"),\n\t\t\t},\n\t\t},\n\t}\n\n\tapi := &AWSSDClientStub{\n\t\tnamespaces: namespaces,\n\t\tservices:   services,\n\t}\n\n\tprovider := newTestAWSSDProvider(api, endpoint.NewDomainFilter([]string{}), \"\", \"owner-id\")\n\n\t// delete first service\n\terr := provider.DeleteService(t.Context(), services[\"private\"][\"srv1\"])\n\tassert.NoError(t, err)\n\tassert.Len(t, api.services[\"private\"], 3)\n\n\t// delete third service\n\terr = provider.DeleteService(t.Context(), services[\"private\"][\"srv3\"])\n\tassert.NoError(t, err)\n\tassert.Len(t, api.services[\"private\"], 2)\n\n\t// delete service with no description\n\terr = provider.DeleteService(t.Context(), services[\"private\"][\"srv4\"])\n\tassert.NoError(t, err)\n\n\texpected := map[string]*sdtypes.Service{\n\t\t\"srv2\": {\n\t\t\tId:          aws.String(\"srv2\"),\n\t\t\tDescription: aws.String(\"heritage=external-dns,external-dns/owner=owner-id\"),\n\t\t\tName:        aws.String(\"service2\"),\n\t\t\tNamespaceId: aws.String(\"private\"),\n\t\t},\n\t\t\"srv4\": {\n\t\t\tId:          aws.String(\"srv4\"),\n\t\t\tDescription: nil,\n\t\t\tName:        aws.String(\"service4\"),\n\t\t\tNamespaceId: aws.String(\"private\"),\n\t\t},\n\t}\n\n\tassert.Equal(t, expected, api.services[\"private\"])\n}\n\nfunc TestAWSSDProvider_DeleteServiceEmptyDescription_Logging(t *testing.T) {\n\tnamespaces := map[string]*sdtypes.Namespace{\n\t\t\"private\": {\n\t\t\tId:   aws.String(\"private\"),\n\t\t\tName: aws.String(\"private.com\"),\n\t\t\tType: sdtypes.NamespaceTypeDnsPrivate,\n\t\t},\n\t}\n\n\tservices := map[string]map[string]*sdtypes.Service{\n\t\t\"private\": {\n\t\t\t\"srv1\": {\n\t\t\t\tId:          aws.String(\"srv1\"),\n\t\t\t\tDescription: nil,\n\t\t\t\tName:        aws.String(\"service1\"),\n\t\t\t\tNamespaceId: aws.String(\"private\"),\n\t\t\t},\n\t\t},\n\t}\n\n\tlogs := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t)\n\n\tapi := &AWSSDClientStub{\n\t\tnamespaces: namespaces,\n\t\tservices:   services,\n\t}\n\n\tprovider := newTestAWSSDProvider(api, endpoint.NewDomainFilter([]string{}), \"\", \"owner-id\")\n\n\t// delete service\n\terr := provider.DeleteService(t.Context(), services[\"private\"][\"srv1\"])\n\tassert.NoError(t, err)\n\tassert.Len(t, api.services[\"private\"], 1)\n\n\tlogtest.TestHelperLogContainsWithLogLevel(\"Skipping service removal \\\"service1\\\" because owner id (service.Description) not set, when should be\", log.DebugLevel, logs, t)\n}\n\nfunc TestAWSSDProvider_DeleteServiceDryRun(t *testing.T) {\n\tnamespaces := map[string]*sdtypes.Namespace{\n\t\t\"private\": {\n\t\t\tId:   aws.String(\"private\"),\n\t\t\tName: aws.String(\"private.com\"),\n\t\t\tType: sdtypes.NamespaceTypeDnsPrivate,\n\t\t},\n\t}\n\n\tservices := map[string]map[string]*sdtypes.Service{\n\t\t\"private\": {\n\t\t\t\"srv1\": {\n\t\t\t\tId:          aws.String(\"srv1\"),\n\t\t\t\tDescription: aws.String(\"heritage=external-dns,external-dns/owner=owner-id\"),\n\t\t\t\tName:        aws.String(\"service1\"),\n\t\t\t\tNamespaceId: aws.String(\"private\"),\n\t\t\t},\n\t\t},\n\t}\n\n\tapi := &AWSSDClientStub{\n\t\tnamespaces: namespaces,\n\t\tservices:   services,\n\t}\n\n\tprovider := newTestAWSSDProvider(api, endpoint.NewDomainFilter([]string{}), \"\", \"owner-id\")\n\tprovider.dryRun = true\n\n\t// delete first service\n\terr := provider.DeleteService(t.Context(), services[\"private\"][\"srv1\"])\n\tassert.NoError(t, err)\n\tassert.Len(t, api.services[\"private\"], 1)\n}\n\nfunc TestAWSSDProvider_RegisterInstance(t *testing.T) {\n\tnamespaces := map[string]*sdtypes.Namespace{\n\t\t\"private\": {\n\t\t\tId:   aws.String(\"private\"),\n\t\t\tName: aws.String(\"private.com\"),\n\t\t\tType: sdtypes.NamespaceTypeDnsPrivate,\n\t\t},\n\t}\n\n\tservices := map[string]map[string]*sdtypes.Service{\n\t\t\"private\": {\n\t\t\t\"a-srv\": {\n\t\t\t\tId:          aws.String(\"a-srv\"),\n\t\t\t\tName:        aws.String(\"service1\"),\n\t\t\t\tNamespaceId: aws.String(\"private\"),\n\t\t\t\tDnsConfig: &sdtypes.DnsConfig{\n\t\t\t\t\tRoutingPolicy: sdtypes.RoutingPolicyWeighted,\n\t\t\t\t\tDnsRecords: []sdtypes.DnsRecord{{\n\t\t\t\t\t\tType: sdtypes.RecordTypeA,\n\t\t\t\t\t\tTTL:  aws.Int64(60),\n\t\t\t\t\t}},\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"cname-srv\": {\n\t\t\t\tId:          aws.String(\"cname-srv\"),\n\t\t\t\tName:        aws.String(\"service2\"),\n\t\t\t\tNamespaceId: aws.String(\"private\"),\n\t\t\t\tDnsConfig: &sdtypes.DnsConfig{\n\t\t\t\t\tRoutingPolicy: sdtypes.RoutingPolicyWeighted,\n\t\t\t\t\tDnsRecords: []sdtypes.DnsRecord{{\n\t\t\t\t\t\tType: sdtypes.RecordTypeCname,\n\t\t\t\t\t\tTTL:  aws.Int64(60),\n\t\t\t\t\t}},\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"alias-srv\": {\n\t\t\t\tId:          aws.String(\"alias-srv\"),\n\t\t\t\tName:        aws.String(\"service3\"),\n\t\t\t\tNamespaceId: aws.String(\"private\"),\n\t\t\t\tDnsConfig: &sdtypes.DnsConfig{\n\t\t\t\t\tRoutingPolicy: sdtypes.RoutingPolicyWeighted,\n\t\t\t\t\tDnsRecords: []sdtypes.DnsRecord{{\n\t\t\t\t\t\tType: sdtypes.RecordTypeA,\n\t\t\t\t\t\tTTL:  aws.Int64(60),\n\t\t\t\t\t}},\n\t\t\t\t},\n\t\t\t},\n\t\t\t\"aaaa-srv\": {\n\t\t\t\tId:          aws.String(\"aaaa-srv\"),\n\t\t\t\tName:        aws.String(\"service4\"),\n\t\t\t\tDescription: aws.String(\"owner-id\"),\n\t\t\t\tDnsConfig: &sdtypes.DnsConfig{\n\t\t\t\t\tNamespaceId:   aws.String(\"private\"),\n\t\t\t\t\tRoutingPolicy: sdtypes.RoutingPolicyWeighted,\n\t\t\t\t\tDnsRecords: []sdtypes.DnsRecord{{\n\t\t\t\t\t\tType: sdtypes.RecordTypeAaaa,\n\t\t\t\t\t\tTTL:  aws.Int64(100),\n\t\t\t\t\t}},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tapi := &AWSSDClientStub{\n\t\tnamespaces: namespaces,\n\t\tservices:   services,\n\t\tinstances:  make(map[string]map[string]*sdtypes.Instance),\n\t}\n\n\tprovider := newTestAWSSDProvider(api, endpoint.NewDomainFilter([]string{}), \"\", \"\")\n\n\texpectedInstances := make(map[string]*sdtypes.Instance)\n\n\t// IPv4-based instance\n\terr := provider.RegisterInstance(t.Context(), services[\"private\"][\"a-srv\"], &endpoint.Endpoint{\n\t\tRecordType: endpoint.RecordTypeA,\n\t\tDNSName:    \"service1.private.com.\",\n\t\tRecordTTL:  300,\n\t\tTargets:    endpoint.Targets{\"1.2.3.4\", \"1.2.3.5\"},\n\t})\n\tassert.NoError(t, err)\n\texpectedInstances[\"1.2.3.4\"] = &sdtypes.Instance{\n\t\tId: aws.String(\"1.2.3.4\"),\n\t\tAttributes: map[string]string{\n\t\t\tsdInstanceAttrIPV4: \"1.2.3.4\",\n\t\t},\n\t}\n\texpectedInstances[\"1.2.3.5\"] = &sdtypes.Instance{\n\t\tId: aws.String(\"1.2.3.5\"),\n\t\tAttributes: map[string]string{\n\t\t\tsdInstanceAttrIPV4: \"1.2.3.5\",\n\t\t},\n\t}\n\n\t// AWS ELB instance (ALIAS)\n\terr = provider.RegisterInstance(t.Context(), services[\"private\"][\"alias-srv\"], &endpoint.Endpoint{\n\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\tDNSName:    \"service1.private.com.\",\n\t\tRecordTTL:  300,\n\t\tTargets:    endpoint.Targets{\"load-balancer.us-east-1.elb.amazonaws.com\", \"load-balancer.us-west-2.elb.amazonaws.com\"},\n\t})\n\tassert.NoError(t, err)\n\texpectedInstances[\"load-balancer.us-east-1.elb.amazonaws.com\"] = &sdtypes.Instance{\n\t\tId: aws.String(\"load-balancer.us-east-1.elb.amazonaws.com\"),\n\t\tAttributes: map[string]string{\n\t\t\tsdInstanceAttrAlias: \"load-balancer.us-east-1.elb.amazonaws.com\",\n\t\t},\n\t}\n\texpectedInstances[\"load-balancer.us-west-2.elb.amazonaws.com\"] = &sdtypes.Instance{\n\t\tId: aws.String(\"load-balancer.us-west-2.elb.amazonaws.com\"),\n\t\tAttributes: map[string]string{\n\t\t\tsdInstanceAttrAlias: \"load-balancer.us-west-2.elb.amazonaws.com\",\n\t\t},\n\t}\n\n\t// AWS NLB instance (ALIAS)\n\t_ = provider.RegisterInstance(t.Context(), services[\"private\"][\"alias-srv\"], &endpoint.Endpoint{\n\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\tDNSName:    \"service1.private.com.\",\n\t\tRecordTTL:  300,\n\t\tTargets:    endpoint.Targets{\"load-balancer.elb.us-west-2.amazonaws.com\"},\n\t})\n\texpectedInstances[\"load-balancer.elb.us-west-2.amazonaws.com\"] = &sdtypes.Instance{\n\t\tId: aws.String(\"load-balancer.elb.us-west-2.amazonaws.com\"),\n\t\tAttributes: map[string]string{\n\t\t\tsdInstanceAttrAlias: \"load-balancer.elb.us-west-2.amazonaws.com\",\n\t\t},\n\t}\n\n\t// CNAME instance\n\t_ = provider.RegisterInstance(t.Context(), services[\"private\"][\"cname-srv\"], &endpoint.Endpoint{\n\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\tDNSName:    \"service2.private.com.\",\n\t\tRecordTTL:  300,\n\t\tTargets:    endpoint.Targets{\"cname.target.com\"},\n\t})\n\texpectedInstances[\"cname.target.com\"] = &sdtypes.Instance{\n\t\tId: aws.String(\"cname.target.com\"),\n\t\tAttributes: map[string]string{\n\t\t\tsdInstanceAttrCname: \"cname.target.com\",\n\t\t},\n\t}\n\n\t// IPv6-based instance\n\tprovider.RegisterInstance(t.Context(), services[\"private\"][\"aaaa-srv\"], &endpoint.Endpoint{\n\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\tDNSName:    \"service4.private.com.\",\n\t\tRecordTTL:  300,\n\t\tTargets:    endpoint.Targets{\"0000:0000:0000:0000:abcd:abcd:abcd:abcd\"},\n\t})\n\texpectedInstances[\"0000:0000:0000:0000:abcd:abcd:abcd:abcd\"] = &sdtypes.Instance{\n\t\tId: aws.String(\"0000:0000:0000:0000:abcd:abcd:abcd:abcd\"),\n\t\tAttributes: map[string]string{\n\t\t\tsdInstanceAttrIPV6: \"0000:0000:0000:0000:abcd:abcd:abcd:abcd\",\n\t\t},\n\t}\n\n\t// validate instances\n\tfor _, srvInst := range api.instances {\n\t\tfor id, inst := range srvInst {\n\t\t\tif !reflect.DeepEqual(*expectedInstances[id], *inst) {\n\t\t\t\tt.Errorf(\"Instances don't match, expected = %v, actual %v\", *expectedInstances[id], *inst)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc TestAWSSDProvider_DeregisterInstance(t *testing.T) {\n\tnamespaces := map[string]*sdtypes.Namespace{\n\t\t\"private\": {\n\t\t\tId:   aws.String(\"private\"),\n\t\t\tName: aws.String(\"private.com\"),\n\t\t\tType: sdtypes.NamespaceTypeDnsPrivate,\n\t\t},\n\t}\n\n\tservices := map[string]map[string]*sdtypes.Service{\n\t\t\"private\": {\n\t\t\t\"srv1\": {\n\t\t\t\tId:   aws.String(\"srv1\"),\n\t\t\t\tName: aws.String(\"service1\"),\n\t\t\t},\n\t\t},\n\t}\n\n\tinstances := map[string]map[string]*sdtypes.Instance{\n\t\t\"srv1\": {\n\t\t\t\"1.2.3.4\": {\n\t\t\t\tId: aws.String(\"1.2.3.4\"),\n\t\t\t\tAttributes: map[string]string{\n\t\t\t\t\tsdInstanceAttrIPV4: \"1.2.3.4\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tapi := &AWSSDClientStub{\n\t\tnamespaces: namespaces,\n\t\tservices:   services,\n\t\tinstances:  instances,\n\t}\n\n\tprovider := newTestAWSSDProvider(api, endpoint.NewDomainFilter([]string{}), \"\", \"\")\n\n\t_ = provider.DeregisterInstance(t.Context(), services[\"private\"][\"srv1\"], endpoint.NewEndpoint(\"srv1.private.com.\", endpoint.RecordTypeA, \"1.2.3.4\"))\n\n\tassert.Empty(t, instances[\"srv1\"])\n}\n\nfunc TestAWSSDProvider_awsTags(t *testing.T) {\n\ttests := []struct {\n\t\tExpectation []sdtypes.Tag\n\t\tInput       map[string]string\n\t}{\n\t\t{\n\t\t\tExpectation: []sdtypes.Tag{\n\t\t\t\t{\n\t\t\t\t\tKey:   aws.String(\"key1\"),\n\t\t\t\t\tValue: aws.String(\"value1\"),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tKey:   aws.String(\"key2\"),\n\t\t\t\t\tValue: aws.String(\"value2\"),\n\t\t\t\t},\n\t\t\t},\n\t\t\tInput: map[string]string{\n\t\t\t\t\"key1\": \"value1\",\n\t\t\t\t\"key2\": \"value2\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tExpectation: []sdtypes.Tag{},\n\t\t\tInput:       map[string]string{},\n\t\t},\n\t\t{\n\t\t\tExpectation: []sdtypes.Tag{},\n\t\t\tInput:       nil,\n\t\t},\n\t}\n\n\tfor _, test := range tests {\n\t\trequire.ElementsMatch(t, test.Expectation, awsTags(test.Input))\n\t}\n}\n"
  },
  {
    "path": "provider/awssd/fixtures_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage awssd\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"reflect\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/aws/aws-sdk-go-v2/aws\"\n\t\"github.com/aws/aws-sdk-go-v2/service/servicediscovery\"\n\t\"github.com/aws/aws-sdk-go-v2/service/servicediscovery/types\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\n\tsd \"github.com/aws/aws-sdk-go-v2/service/servicediscovery\"\n\tsdtypes \"github.com/aws/aws-sdk-go-v2/service/servicediscovery/types\"\n)\n\nvar (\n\t// Compile time checks for interface conformance\n\t_                    AWSSDClient = &AWSSDClientStub{}\n\tErrNamespaceNotFound             = errors.New(\"namespace not found\")\n)\n\ntype AWSSDClientStub struct {\n\t// map[namespace_id]namespace\n\tnamespaces map[string]*types.Namespace\n\n\t// map[namespace_id] => map[service_id]instance\n\tservices map[string]map[string]*types.Service\n\n\t// map[service_id] => map[inst_id]instance\n\tinstances map[string]map[string]*types.Instance\n\n\t// []inst_id\n\tderegistered []string\n}\n\nfunc (s *AWSSDClientStub) CreateService(_ context.Context, input *servicediscovery.CreateServiceInput, _ ...func(*servicediscovery.Options)) (*servicediscovery.CreateServiceOutput, error) {\n\tsrv := &types.Service{\n\t\tId:               input.Name,\n\t\tDnsConfig:        input.DnsConfig,\n\t\tName:             input.Name,\n\t\tDescription:      input.Description,\n\t\tCreateDate:       aws.Time(time.Now()),\n\t\tCreatorRequestId: input.CreatorRequestId,\n\t}\n\n\tnsServices, ok := s.services[*input.NamespaceId]\n\tif !ok {\n\t\tnsServices = make(map[string]*types.Service)\n\t\ts.services[*input.NamespaceId] = nsServices\n\t}\n\tnsServices[*srv.Id] = srv\n\n\treturn &servicediscovery.CreateServiceOutput{\n\t\tService: srv,\n\t}, nil\n}\n\nfunc (s *AWSSDClientStub) DeregisterInstance(_ context.Context, input *servicediscovery.DeregisterInstanceInput, _ ...func(options *servicediscovery.Options)) (*servicediscovery.DeregisterInstanceOutput, error) {\n\tserviceInstances := s.instances[*input.ServiceId]\n\tdelete(serviceInstances, *input.InstanceId)\n\ts.deregistered = append(s.deregistered, *input.InstanceId)\n\n\treturn &servicediscovery.DeregisterInstanceOutput{}, nil\n}\n\nfunc (s *AWSSDClientStub) GetService(_ context.Context, input *servicediscovery.GetServiceInput, _ ...func(options *servicediscovery.Options)) (*servicediscovery.GetServiceOutput, error) {\n\tfor _, entry := range s.services {\n\t\tsrv, ok := entry[*input.Id]\n\t\tif ok {\n\t\t\treturn &servicediscovery.GetServiceOutput{\n\t\t\t\tService: srv,\n\t\t\t}, nil\n\t\t}\n\t}\n\n\treturn nil, errors.New(\"service not found\")\n}\n\nfunc (s *AWSSDClientStub) DiscoverInstances(_ context.Context, input *sd.DiscoverInstancesInput, _ ...func(options *sd.Options)) (*sd.DiscoverInstancesOutput, error) {\n\tinstances := make([]sdtypes.HttpInstanceSummary, 0)\n\n\tvar foundNs bool\n\tfor _, ns := range s.namespaces {\n\t\tif *ns.Name == *input.NamespaceName {\n\t\t\tfoundNs = true\n\n\t\t\tfor _, srv := range s.services[*ns.Id] {\n\t\t\t\tif *srv.Name == *input.ServiceName {\n\t\t\t\t\tfor _, inst := range s.instances[*srv.Id] {\n\t\t\t\t\t\tinstances = append(instances, *instanceToHTTPInstanceSummary(inst))\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tif !foundNs {\n\t\treturn nil, ErrNamespaceNotFound\n\t}\n\n\treturn &sd.DiscoverInstancesOutput{\n\t\tInstances: instances,\n\t}, nil\n}\n\nfunc (s *AWSSDClientStub) ListNamespaces(_ context.Context, input *sd.ListNamespacesInput, _ ...func(options *sd.Options)) (*sd.ListNamespacesOutput, error) {\n\tnamespaces := make([]sdtypes.NamespaceSummary, 0)\n\n\tfor _, ns := range s.namespaces {\n\t\tif len(input.Filters) > 0 && input.Filters[0].Name == sdtypes.NamespaceFilterNameType {\n\t\t\tif ns.Type != sdtypes.NamespaceType(input.Filters[0].Values[0]) {\n\t\t\t\t// skip namespaces not matching filter\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\tnamespaces = append(namespaces, *namespaceToNamespaceSummary(ns))\n\t}\n\n\treturn &sd.ListNamespacesOutput{\n\t\tNamespaces: namespaces,\n\t}, nil\n}\n\nfunc (s *AWSSDClientStub) ListServices(_ context.Context, input *sd.ListServicesInput, _ ...func(options *sd.Options)) (*sd.ListServicesOutput, error) {\n\tservices := make([]sdtypes.ServiceSummary, 0)\n\n\t// get namespace filter\n\tif len(input.Filters) == 0 || input.Filters[0].Name != sdtypes.ServiceFilterNameNamespaceId {\n\t\treturn nil, errors.New(\"missing namespace filter\")\n\t}\n\tnsID := input.Filters[0].Values[0]\n\n\tfor _, srv := range s.services[nsID] {\n\t\tservices = append(services, *serviceToServiceSummary(srv))\n\t}\n\n\treturn &sd.ListServicesOutput{\n\t\tServices: services,\n\t}, nil\n}\n\nfunc (s *AWSSDClientStub) RegisterInstance(_ context.Context, input *sd.RegisterInstanceInput, _ ...func(options *sd.Options)) (*sd.RegisterInstanceOutput, error) {\n\tsrvInstances, ok := s.instances[*input.ServiceId]\n\tif !ok {\n\t\tsrvInstances = make(map[string]*sdtypes.Instance)\n\t\ts.instances[*input.ServiceId] = srvInstances\n\t}\n\n\tsrvInstances[*input.InstanceId] = &sdtypes.Instance{\n\t\tId:               input.InstanceId,\n\t\tAttributes:       input.Attributes,\n\t\tCreatorRequestId: input.CreatorRequestId,\n\t}\n\n\treturn &sd.RegisterInstanceOutput{}, nil\n}\n\nfunc (s *AWSSDClientStub) UpdateService(ctx context.Context, input *sd.UpdateServiceInput, _ ...func(options *sd.Options)) (*sd.UpdateServiceOutput, error) {\n\tout, err := s.GetService(ctx, &sd.GetServiceInput{Id: input.Id})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\torigSrv := out.Service\n\tupdateSrv := input.Service\n\n\torigSrv.Description = updateSrv.Description\n\torigSrv.DnsConfig.DnsRecords = updateSrv.DnsConfig.DnsRecords\n\n\treturn &sd.UpdateServiceOutput{}, nil\n}\n\nfunc (s *AWSSDClientStub) DeleteService(ctx context.Context, input *sd.DeleteServiceInput, _ ...func(options *sd.Options)) (*sd.DeleteServiceOutput, error) {\n\tout, err := s.GetService(ctx, &sd.GetServiceInput{Id: input.Id})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tservice := out.Service\n\tnamespace := s.services[*service.NamespaceId]\n\tdelete(namespace, *input.Id)\n\n\treturn &sd.DeleteServiceOutput{}, nil\n}\n\nfunc newTestAWSSDProvider(api AWSSDClient, domainFilter *endpoint.DomainFilter, namespaceTypeFilter, ownerID string) *AWSSDProvider {\n\treturn &AWSSDProvider{\n\t\tclient:              api,\n\t\tdryRun:              false,\n\t\tnamespaceFilter:     domainFilter,\n\t\tnamespaceTypeFilter: newSdNamespaceFilter(namespaceTypeFilter),\n\t\tcleanEmptyService:   true,\n\t\townerID:             ownerID,\n\t}\n}\n\nfunc instanceToHTTPInstanceSummary(instance *sdtypes.Instance) *sdtypes.HttpInstanceSummary {\n\tif instance == nil {\n\t\treturn nil\n\t}\n\n\treturn &sdtypes.HttpInstanceSummary{\n\t\tInstanceId: instance.Id,\n\t\tAttributes: instance.Attributes,\n\t}\n}\n\nfunc namespaceToNamespaceSummary(namespace *sdtypes.Namespace) *sdtypes.NamespaceSummary {\n\tif namespace == nil {\n\t\treturn nil\n\t}\n\n\treturn &sdtypes.NamespaceSummary{\n\t\tId:   namespace.Id,\n\t\tType: namespace.Type,\n\t\tName: namespace.Name,\n\t\tArn:  namespace.Arn,\n\t}\n}\n\nfunc serviceToServiceSummary(service *sdtypes.Service) *sdtypes.ServiceSummary {\n\tif service == nil {\n\t\treturn nil\n\t}\n\n\treturn &sdtypes.ServiceSummary{\n\t\tArn:                     service.Arn,\n\t\tCreateDate:              service.CreateDate,\n\t\tDescription:             service.Description,\n\t\tDnsConfig:               service.DnsConfig,\n\t\tHealthCheckConfig:       service.HealthCheckConfig,\n\t\tHealthCheckCustomConfig: service.HealthCheckCustomConfig,\n\t\tId:                      service.Id,\n\t\tInstanceCount:           service.InstanceCount,\n\t\tName:                    service.Name,\n\t\tType:                    service.Type,\n\t}\n}\n\nfunc testHelperAWSSDServicesMapsEqual(t *testing.T, expected map[string]*sdtypes.Service, services map[string]*sdtypes.Service) {\n\trequire.Len(t, services, len(expected))\n\n\tfor _, srv := range services {\n\t\ttestHelperAWSSDServicesEqual(t, expected[*srv.Name], srv)\n\t}\n}\n\nfunc testHelperAWSSDServicesEqual(t *testing.T, expected *sdtypes.Service, srv *sdtypes.Service) {\n\tassert.Equal(t, *expected.Description, *srv.Description)\n\tassert.Equal(t, *expected.Name, *srv.Name)\n\tassert.True(t, reflect.DeepEqual(*expected.DnsConfig, *srv.DnsConfig))\n}\n"
  },
  {
    "path": "provider/azure/azure.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\n//nolint:staticcheck // Required due to the current dependency on a deprecated version of azure-sdk-for-go\npackage azure\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\n\t\"sigs.k8s.io/external-dns/provider/blueprint\"\n\n\tazcoreruntime \"github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime\"\n\t\"github.com/Azure/azure-sdk-for-go/sdk/azcore/to\"\n\tdns \"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\nconst (\n\tdefaultTTL = 300\n)\n\n// ZonesClient is an interface of dns.ZoneClient that can be stubbed for testing.\ntype ZonesClient interface {\n\tNewListByResourceGroupPager(resourceGroupName string, options *dns.ZonesClientListByResourceGroupOptions) *azcoreruntime.Pager[dns.ZonesClientListByResourceGroupResponse]\n}\n\n// RecordSetsClient is an interface of dns.RecordSetsClient that can be stubbed for testing.\ntype RecordSetsClient interface {\n\tNewListAllByDNSZonePager(resourceGroupName string, zoneName string, options *dns.RecordSetsClientListAllByDNSZoneOptions) *azcoreruntime.Pager[dns.RecordSetsClientListAllByDNSZoneResponse]\n\tDelete(ctx context.Context, resourceGroupName string, zoneName string, relativeRecordSetName string, recordType dns.RecordType, options *dns.RecordSetsClientDeleteOptions) (dns.RecordSetsClientDeleteResponse, error)\n\tCreateOrUpdate(ctx context.Context, resourceGroupName string, zoneName string, relativeRecordSetName string, recordType dns.RecordType, parameters dns.RecordSet, options *dns.RecordSetsClientCreateOrUpdateOptions) (dns.RecordSetsClientCreateOrUpdateResponse, error)\n}\n\n// AzureProvider implements the DNS provider for Microsoft's Azure cloud platform.\ntype AzureProvider struct {\n\tprovider.BaseProvider\n\tdomainFilter                 *endpoint.DomainFilter\n\tzoneNameFilter               *endpoint.DomainFilter\n\tzoneIDFilter                 provider.ZoneIDFilter\n\tdryRun                       bool\n\tresourceGroup                string\n\tuserAssignedIdentityClientID string\n\tactiveDirectoryAuthorityHost string\n\tzonesClient                  ZonesClient\n\tzonesCache                   *blueprint.ZoneCache[[]dns.Zone]\n\trecordSetsClient             RecordSetsClient\n\tmaxRetriesCount              int\n}\n\n// New creates an Azure DNS provider from the given configuration.\nfunc New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) {\n\treturn newProvider(\n\t\tcfg.AzureConfigFile,\n\t\tdomainFilter,\n\t\tendpoint.NewDomainFilter(cfg.ZoneNameFilter),\n\t\tprovider.NewZoneIDFilter(cfg.ZoneIDFilter),\n\t\tcfg.AzureSubscriptionID,\n\t\tcfg.AzureResourceGroup,\n\t\tcfg.AzureUserAssignedIdentityClientID,\n\t\tcfg.AzureActiveDirectoryAuthorityHost,\n\t\tcfg.AzureZonesCacheDuration,\n\t\tcfg.AzureMaxRetriesCount,\n\t\tcfg.DryRun,\n\t)\n}\n\n// newProvider creates a new Azure provider.\n//\n// Returns the provider or an error if a provider could not be created.\nfunc newProvider(configFile string, domainFilter, zoneNameFilter *endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, subscriptionID, resourceGroup, userAssignedIdentityClientID, activeDirectoryAuthorityHost string, zonesCacheDuration time.Duration, maxRetriesCount int, dryRun bool) (*AzureProvider, error) {\n\tcfg, err := getConfig(configFile, subscriptionID, resourceGroup, userAssignedIdentityClientID, activeDirectoryAuthorityHost)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read Azure config file '%s': %w\", configFile, err)\n\t}\n\n\tcred, clientOpts, err := getCredentials(*cfg, maxRetriesCount)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get credentials: %w\", err)\n\t}\n\n\tzonesClient, err := dns.NewZonesClient(cfg.SubscriptionID, cred, clientOpts)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\trecordSetsClient, err := dns.NewRecordSetsClient(cfg.SubscriptionID, cred, clientOpts)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &AzureProvider{\n\t\tdomainFilter:                 domainFilter,\n\t\tzoneNameFilter:               zoneNameFilter,\n\t\tzoneIDFilter:                 zoneIDFilter,\n\t\tdryRun:                       dryRun,\n\t\tresourceGroup:                cfg.ResourceGroup,\n\t\tuserAssignedIdentityClientID: cfg.UserAssignedIdentityID,\n\t\tactiveDirectoryAuthorityHost: cfg.ActiveDirectoryAuthorityHost,\n\t\tzonesClient:                  zonesClient,\n\t\tzonesCache:                   blueprint.NewZoneCache[[]dns.Zone](zonesCacheDuration),\n\t\trecordSetsClient:             recordSetsClient,\n\t\tmaxRetriesCount:              maxRetriesCount,\n\t}, nil\n}\n\n// Records gets the current records.\n//\n// Returns the current records or an error if the operation failed.\nfunc (p *AzureProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {\n\tzones, err := p.zones(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tendpoints := make([]*endpoint.Endpoint, 0)\n\n\tfor _, zone := range zones {\n\t\tpager := p.recordSetsClient.NewListAllByDNSZonePager(p.resourceGroup, *zone.Name, &dns.RecordSetsClientListAllByDNSZoneOptions{Top: nil})\n\t\tfor pager.More() {\n\t\t\tnextResult, err := pager.NextPage(ctx)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, provider.NewSoftErrorf(\"failed to fetch dns records: %w\", err)\n\t\t\t}\n\t\t\tfor _, recordSet := range nextResult.Value {\n\t\t\t\tif recordSet.Name == nil || recordSet.Type == nil {\n\t\t\t\t\tlog.Error(\"Skipping invalid record set with nil name or type.\")\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\trecordType := strings.TrimPrefix(*recordSet.Type, \"Microsoft.Network/dnszones/\")\n\t\t\t\tif !p.SupportedRecordType(recordType) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tname := formatAzureDNSName(*recordSet.Name, *zone.Name)\n\t\t\t\tif len(p.zoneNameFilter.Filters) > 0 && !p.domainFilter.Match(name) {\n\t\t\t\t\tlog.Debugf(\"Skipping return of record %s because it was filtered out by the specified --domain-filter\", name)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttargets := extractAzureTargets(recordSet)\n\t\t\t\tif len(targets) == 0 {\n\t\t\t\t\tlog.Debugf(\"Failed to extract targets for '%s' with type '%s'.\", name, recordType)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tvar ttl endpoint.TTL\n\t\t\t\tif recordSet.Properties.TTL != nil {\n\t\t\t\t\tttl = endpoint.TTL(*recordSet.Properties.TTL)\n\t\t\t\t}\n\t\t\t\tep := endpoint.NewEndpointWithTTL(name, recordType, ttl, targets...)\n\t\t\t\tlog.Debugf(\n\t\t\t\t\t\"Found %s record for '%s' with target '%s'.\",\n\t\t\t\t\tep.RecordType,\n\t\t\t\t\tep.DNSName,\n\t\t\t\t\tep.Targets,\n\t\t\t\t)\n\t\t\t\tendpoints = append(endpoints, ep)\n\t\t\t}\n\t\t}\n\t}\n\treturn endpoints, nil\n}\n\n// ApplyChanges applies the given changes.\n//\n// Returns nil if the operation was successful or an error if the operation failed.\nfunc (p *AzureProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {\n\tzones, err := p.zones(ctx)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdeleted, updated := p.mapChanges(zones, changes)\n\tp.deleteRecords(ctx, deleted)\n\tp.updateRecords(ctx, updated)\n\treturn nil\n}\n\nfunc (p *AzureProvider) zones(ctx context.Context) ([]dns.Zone, error) {\n\tif !p.zonesCache.Expired() {\n\t\tcachedZones := p.zonesCache.Get()\n\t\tlog.Debugf(\"Using cached Azure DNS zones for resource group: %s zone count: %d.\", p.resourceGroup, len(cachedZones))\n\t\treturn cachedZones, nil\n\t}\n\tlog.Debugf(\"Retrieving Azure DNS zones for resource group: %s.\", p.resourceGroup)\n\tvar zones []dns.Zone\n\tpager := p.zonesClient.NewListByResourceGroupPager(p.resourceGroup, &dns.ZonesClientListByResourceGroupOptions{Top: nil})\n\tfor pager.More() {\n\t\tnextResult, err := pager.NextPage(ctx)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tfor _, zone := range nextResult.Value {\n\t\t\tif zone.Name != nil && p.domainFilter.Match(*zone.Name) && p.zoneIDFilter.Match(*zone.ID) {\n\t\t\t\tzones = append(zones, *zone)\n\t\t\t} else if zone.Name != nil && len(p.zoneNameFilter.Filters) > 0 && p.zoneNameFilter.Match(*zone.Name) {\n\t\t\t\t// Handle zoneNameFilter\n\t\t\t\tzones = append(zones, *zone)\n\t\t\t}\n\t\t}\n\t}\n\tp.zonesCache.Reset(zones)\n\treturn zones, nil\n}\n\nfunc (p *AzureProvider) SupportedRecordType(recordType string) bool {\n\tswitch recordType {\n\tcase \"MX\":\n\t\treturn true\n\tdefault:\n\t\treturn provider.SupportedRecordType(recordType)\n\t}\n}\n\ntype azureChangeMap map[string][]*endpoint.Endpoint\n\nfunc (p *AzureProvider) mapChanges(zones []dns.Zone, changes *plan.Changes) (azureChangeMap, azureChangeMap) {\n\tignored := map[string]bool{}\n\tdeleted := azureChangeMap{}\n\tupdated := azureChangeMap{}\n\tzoneNameIDMapper := provider.ZoneIDName{}\n\tfor _, z := range zones {\n\t\tif z.Name != nil {\n\t\t\tzoneNameIDMapper.Add(*z.Name, *z.Name)\n\t\t}\n\t}\n\tmapChange := func(changeMap azureChangeMap, change *endpoint.Endpoint) {\n\t\tzone, _ := zoneNameIDMapper.FindZone(change.DNSName)\n\t\tif zone == \"\" {\n\t\t\tif _, ok := ignored[change.DNSName]; !ok {\n\t\t\t\tignored[change.DNSName] = true\n\t\t\t\tlog.Infof(\"Ignoring changes to '%s' because a suitable Azure DNS zone was not found.\", change.DNSName)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t\t// Ensure the record type is suitable\n\t\tchangeMap[zone] = append(changeMap[zone], change)\n\t}\n\n\tfor _, change := range changes.Delete {\n\t\tmapChange(deleted, change)\n\t}\n\n\tfor _, change := range changes.Create {\n\t\tmapChange(updated, change)\n\t}\n\n\tfor _, change := range changes.UpdateNew {\n\t\tmapChange(updated, change)\n\t}\n\treturn deleted, updated\n}\n\nfunc (p *AzureProvider) deleteRecords(ctx context.Context, deleted azureChangeMap) {\n\t// Delete records first\n\tfor zone, endpoints := range deleted {\n\t\tfor _, ep := range endpoints {\n\t\t\tname := p.recordSetNameForZone(zone, ep)\n\t\t\tif !p.domainFilter.Match(ep.DNSName) {\n\t\t\t\tlog.Debugf(\"Skipping deletion of record %s because it was filtered out by the specified --domain-filter\", ep.DNSName)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif p.dryRun {\n\t\t\t\tlog.Infof(\"Would delete %s record named '%s' for Azure DNS zone '%s'.\", ep.RecordType, name, zone)\n\t\t\t} else {\n\t\t\t\tlog.Infof(\"Deleting %s record named '%s' for Azure DNS zone '%s'.\", ep.RecordType, name, zone)\n\t\t\t\tif _, err := p.recordSetsClient.Delete(ctx, p.resourceGroup, zone, name, dns.RecordType(ep.RecordType), nil); err != nil {\n\t\t\t\t\tlog.Errorf(\n\t\t\t\t\t\t\"Failed to delete %s record named '%s' for Azure DNS zone '%s': %v\",\n\t\t\t\t\t\tep.RecordType,\n\t\t\t\t\t\tname,\n\t\t\t\t\t\tzone,\n\t\t\t\t\t\terr,\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (p *AzureProvider) updateRecords(ctx context.Context, updated azureChangeMap) {\n\tfor zone, endpoints := range updated {\n\t\tfor _, ep := range endpoints {\n\t\t\tname := p.recordSetNameForZone(zone, ep)\n\t\t\tif !p.domainFilter.Match(ep.DNSName) {\n\t\t\t\tlog.Debugf(\"Skipping update of record %s because it was filtered out by the specified --domain-filter\", ep.DNSName)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif p.dryRun {\n\t\t\t\tlog.Infof(\n\t\t\t\t\t\"Would update %s record named '%s' to '%s' for Azure DNS zone '%s'.\",\n\t\t\t\t\tep.RecordType,\n\t\t\t\t\tname,\n\t\t\t\t\tep.Targets,\n\t\t\t\t\tzone,\n\t\t\t\t)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tlog.Infof(\n\t\t\t\t\"Updating %s record named '%s' to '%s' for Azure DNS zone '%s'.\",\n\t\t\t\tep.RecordType,\n\t\t\t\tname,\n\t\t\t\tep.Targets,\n\t\t\t\tzone,\n\t\t\t)\n\n\t\t\trecordSet, err := p.newRecordSet(ep)\n\t\t\tif err == nil {\n\t\t\t\t_, err = p.recordSetsClient.CreateOrUpdate(\n\t\t\t\t\tctx,\n\t\t\t\t\tp.resourceGroup,\n\t\t\t\t\tzone,\n\t\t\t\t\tname,\n\t\t\t\t\tdns.RecordType(ep.RecordType),\n\t\t\t\t\trecordSet,\n\t\t\t\t\tnil,\n\t\t\t\t)\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\tlog.Errorf(\n\t\t\t\t\t\"Failed to update %s record named '%s' to '%s' for DNS zone '%s': %v\",\n\t\t\t\t\tep.RecordType,\n\t\t\t\t\tname,\n\t\t\t\t\tep.Targets,\n\t\t\t\t\tzone,\n\t\t\t\t\terr,\n\t\t\t\t)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (p *AzureProvider) recordSetNameForZone(zone string, endpoint *endpoint.Endpoint) string {\n\t// Remove the zone from the record set\n\tname := endpoint.DNSName\n\tname = name[:len(name)-len(zone)]\n\tname = strings.TrimSuffix(name, \".\")\n\n\t// For root, use @\n\tif name == \"\" {\n\t\treturn \"@\"\n\t}\n\treturn name\n}\n\nfunc (p *AzureProvider) newRecordSet(endpoint *endpoint.Endpoint) (dns.RecordSet, error) {\n\tvar ttl int64 = defaultTTL\n\tif endpoint.RecordTTL.IsConfigured() {\n\t\tttl = int64(endpoint.RecordTTL)\n\t}\n\tswitch dns.RecordType(endpoint.RecordType) {\n\tcase dns.RecordTypeA:\n\t\taRecords := make([]*dns.ARecord, len(endpoint.Targets))\n\t\tfor i, target := range endpoint.Targets {\n\t\t\taRecords[i] = &dns.ARecord{\n\t\t\t\tIPv4Address: to.Ptr(target),\n\t\t\t}\n\t\t}\n\t\treturn dns.RecordSet{\n\t\t\tProperties: &dns.RecordSetProperties{\n\t\t\t\tTTL:      to.Ptr(ttl),\n\t\t\t\tARecords: aRecords,\n\t\t\t},\n\t\t}, nil\n\tcase dns.RecordTypeAAAA:\n\t\taaaaRecords := make([]*dns.AaaaRecord, len(endpoint.Targets))\n\t\tfor i, target := range endpoint.Targets {\n\t\t\taaaaRecords[i] = &dns.AaaaRecord{\n\t\t\t\tIPv6Address: to.Ptr(target),\n\t\t\t}\n\t\t}\n\t\treturn dns.RecordSet{\n\t\t\tProperties: &dns.RecordSetProperties{\n\t\t\t\tTTL:         to.Ptr(ttl),\n\t\t\t\tAaaaRecords: aaaaRecords,\n\t\t\t},\n\t\t}, nil\n\tcase dns.RecordTypeCNAME:\n\t\treturn dns.RecordSet{\n\t\t\tProperties: &dns.RecordSetProperties{\n\t\t\t\tTTL: to.Ptr(ttl),\n\t\t\t\tCnameRecord: &dns.CnameRecord{\n\t\t\t\t\tCname: to.Ptr(endpoint.Targets[0]),\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil\n\tcase dns.RecordTypeMX:\n\t\tmxRecords := make([]*dns.MxRecord, len(endpoint.Targets))\n\t\tfor i, target := range endpoint.Targets {\n\t\t\tmxRecord, err := parseMxTarget[dns.MxRecord](target)\n\t\t\tif err != nil {\n\t\t\t\treturn dns.RecordSet{}, err\n\t\t\t}\n\t\t\tmxRecords[i] = &mxRecord\n\t\t}\n\t\treturn dns.RecordSet{\n\t\t\tProperties: &dns.RecordSetProperties{\n\t\t\t\tTTL:       to.Ptr(ttl),\n\t\t\t\tMxRecords: mxRecords,\n\t\t\t},\n\t\t}, nil\n\tcase dns.RecordTypeNS:\n\t\tnsRecords := make([]*dns.NsRecord, len(endpoint.Targets))\n\t\tfor i, target := range endpoint.Targets {\n\t\t\tnsRecords[i] = &dns.NsRecord{\n\t\t\t\tNsdname: to.Ptr(target),\n\t\t\t}\n\t\t}\n\t\treturn dns.RecordSet{\n\t\t\tProperties: &dns.RecordSetProperties{\n\t\t\t\tTTL:       to.Ptr(ttl),\n\t\t\t\tNsRecords: nsRecords,\n\t\t\t},\n\t\t}, nil\n\tcase dns.RecordTypeTXT:\n\t\treturn dns.RecordSet{\n\t\t\tProperties: &dns.RecordSetProperties{\n\t\t\t\tTTL: to.Ptr(ttl),\n\t\t\t\tTxtRecords: []*dns.TxtRecord{\n\t\t\t\t\t{\n\t\t\t\t\t\tValue: []*string{\n\t\t\t\t\t\t\t&endpoint.Targets[0],\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil\n\t}\n\treturn dns.RecordSet{}, fmt.Errorf(\"unsupported record type '%s'\", endpoint.RecordType)\n}\n\n// Helper function (shared with test code)\nfunc formatAzureDNSName(recordName, zoneName string) string {\n\tif recordName == \"@\" {\n\t\treturn zoneName\n\t}\n\treturn fmt.Sprintf(\"%s.%s\", recordName, zoneName)\n}\n\n// Helper function (shared with text code)\nfunc extractAzureTargets(recordSet *dns.RecordSet) []string {\n\tproperties := recordSet.Properties\n\tif properties == nil {\n\t\treturn []string{}\n\t}\n\n\t// Check for A records\n\taRecords := properties.ARecords\n\tif len(aRecords) > 0 && (aRecords)[0].IPv4Address != nil {\n\t\ttargets := make([]string, len(aRecords))\n\t\tfor i, aRecord := range aRecords {\n\t\t\ttargets[i] = *aRecord.IPv4Address\n\t\t}\n\t\treturn targets\n\t}\n\n\t// Check for AAAA records\n\taaaaRecords := properties.AaaaRecords\n\tif len(aaaaRecords) > 0 && (aaaaRecords)[0].IPv6Address != nil {\n\t\ttargets := make([]string, len(aaaaRecords))\n\t\tfor i, aaaaRecord := range aaaaRecords {\n\t\t\ttargets[i] = *aaaaRecord.IPv6Address\n\t\t}\n\t\treturn targets\n\t}\n\n\t// Check for CNAME records\n\tcnameRecord := properties.CnameRecord\n\tif cnameRecord != nil && cnameRecord.Cname != nil {\n\t\treturn []string{*cnameRecord.Cname}\n\t}\n\n\t// Check for MX records\n\tmxRecords := properties.MxRecords\n\tif len(mxRecords) > 0 && (mxRecords)[0].Exchange != nil {\n\t\ttargets := make([]string, len(mxRecords))\n\t\tfor i, mxRecord := range mxRecords {\n\t\t\ttargets[i] = fmt.Sprintf(\"%d %s\", *mxRecord.Preference, *mxRecord.Exchange)\n\t\t}\n\t\treturn targets\n\t}\n\n\t// Check for NS records\n\tnsRecords := properties.NsRecords\n\tif len(nsRecords) > 0 && (nsRecords)[0].Nsdname != nil {\n\t\ttargets := make([]string, len(nsRecords))\n\t\tfor i, nsRecord := range nsRecords {\n\t\t\ttargets[i] = *nsRecord.Nsdname\n\t\t}\n\t\treturn targets\n\t}\n\n\t// Check for TXT records\n\ttxtRecords := properties.TxtRecords\n\tif len(txtRecords) > 0 && (txtRecords)[0].Value != nil {\n\t\tvalues := (txtRecords)[0].Value\n\t\tif len(values) > 0 {\n\t\t\treturn []string{*(values)[0]}\n\t\t}\n\t}\n\treturn []string{}\n}\n"
  },
  {
    "path": "provider/azure/azure_private_dns.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\n//nolint:staticcheck // Required due to the current dependency on a deprecated version of azure-sdk-for-go\npackage azure\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\t\"time\"\n\n\tazcoreruntime \"github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime\"\n\t\"github.com/Azure/azure-sdk-for-go/sdk/azcore/to\"\n\tprivatedns \"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns\"\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"sigs.k8s.io/external-dns/provider/blueprint\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\n// PrivateZonesClient is an interface of privatedns.PrivateZoneClient that can be stubbed for testing.\ntype PrivateZonesClient interface {\n\tNewListByResourceGroupPager(resourceGroupName string, options *privatedns.PrivateZonesClientListByResourceGroupOptions) *azcoreruntime.Pager[privatedns.PrivateZonesClientListByResourceGroupResponse]\n}\n\n// PrivateRecordSetsClient is an interface of privatedns.RecordSetsClient that can be stubbed for testing.\ntype PrivateRecordSetsClient interface {\n\tNewListPager(resourceGroupName string, privateZoneName string, options *privatedns.RecordSetsClientListOptions) *azcoreruntime.Pager[privatedns.RecordSetsClientListResponse]\n\tDelete(ctx context.Context, resourceGroupName string, privateZoneName string, recordType privatedns.RecordType, relativeRecordSetName string, options *privatedns.RecordSetsClientDeleteOptions) (privatedns.RecordSetsClientDeleteResponse, error)\n\tCreateOrUpdate(ctx context.Context, resourceGroupName string, privateZoneName string, recordType privatedns.RecordType, relativeRecordSetName string, parameters privatedns.RecordSet, options *privatedns.RecordSetsClientCreateOrUpdateOptions) (privatedns.RecordSetsClientCreateOrUpdateResponse, error)\n}\n\n// AzurePrivateDNSProvider implements the DNS provider for Microsoft's Azure Private DNS service\ntype AzurePrivateDNSProvider struct {\n\tprovider.BaseProvider\n\tdomainFilter                 *endpoint.DomainFilter\n\tzoneNameFilter               *endpoint.DomainFilter\n\tzoneIDFilter                 provider.ZoneIDFilter\n\tdryRun                       bool\n\tresourceGroup                string\n\tuserAssignedIdentityClientID string\n\tactiveDirectoryAuthorityHost string\n\tzonesClient                  PrivateZonesClient\n\tzonesCache                   *blueprint.ZoneCache[[]privatedns.PrivateZone]\n\trecordSetsClient             PrivateRecordSetsClient\n\tmaxRetriesCount              int\n}\n\n// newPrivateDNSProvider creates a new Azure Private DNS provider.\n//\n// Returns the provider or an error if a provider could not be created.\nfunc newPrivateDNSProvider(configFile string, domainFilter, zoneNameFilter *endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, subscriptionID, resourceGroup, userAssignedIdentityClientID, activeDirectoryAuthorityHost string, zonesCacheDuration time.Duration, maxRetriesCount int, dryRun bool) (*AzurePrivateDNSProvider, error) {\n\tcfg, err := getConfig(configFile, subscriptionID, resourceGroup, userAssignedIdentityClientID, activeDirectoryAuthorityHost)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read Azure config file '%s': %w\", configFile, err)\n\t}\n\n\tcred, clientOpts, err := getCredentials(*cfg, maxRetriesCount)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get credentials: %w\", err)\n\t}\n\n\tzonesClient, err := privatedns.NewPrivateZonesClient(cfg.SubscriptionID, cred, clientOpts)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\trecordSetsClient, err := privatedns.NewRecordSetsClient(cfg.SubscriptionID, cred, clientOpts)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &AzurePrivateDNSProvider{\n\t\tdomainFilter:                 domainFilter,\n\t\tzoneNameFilter:               zoneNameFilter,\n\t\tzoneIDFilter:                 zoneIDFilter,\n\t\tdryRun:                       dryRun,\n\t\tresourceGroup:                cfg.ResourceGroup,\n\t\tuserAssignedIdentityClientID: cfg.UserAssignedIdentityID,\n\t\tactiveDirectoryAuthorityHost: cfg.ActiveDirectoryAuthorityHost,\n\t\tzonesClient:                  zonesClient,\n\t\tzonesCache:                   blueprint.NewZoneCache[[]privatedns.PrivateZone](zonesCacheDuration),\n\t\trecordSetsClient:             recordSetsClient,\n\t\tmaxRetriesCount:              maxRetriesCount,\n\t}, nil\n}\n\n// NewPrivate creates an Azure Private DNS provider from the given configuration.\nfunc NewPrivate(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) {\n\treturn newPrivateDNSProvider(\n\t\tcfg.AzureConfigFile,\n\t\tdomainFilter,\n\t\tendpoint.NewDomainFilter(cfg.ZoneNameFilter),\n\t\tprovider.NewZoneIDFilter(cfg.ZoneIDFilter),\n\t\tcfg.AzureSubscriptionID,\n\t\tcfg.AzureResourceGroup,\n\t\tcfg.AzureUserAssignedIdentityClientID,\n\t\tcfg.AzureActiveDirectoryAuthorityHost,\n\t\tcfg.AzureZonesCacheDuration,\n\t\tcfg.AzureMaxRetriesCount,\n\t\tcfg.DryRun,\n\t)\n}\n\n// Records gets the current records.\n//\n// Returns the current records or an error if the operation failed.\nfunc (p *AzurePrivateDNSProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {\n\tzones, err := p.zones(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tlog.Debugf(\"Retrieving Azure Private DNS Records for resource group '%s'\", p.resourceGroup)\n\n\tendpoints := make([]*endpoint.Endpoint, 0)\n\tfor _, zone := range zones {\n\t\tpager := p.recordSetsClient.NewListPager(p.resourceGroup, *zone.Name, &privatedns.RecordSetsClientListOptions{Top: nil})\n\t\tfor pager.More() {\n\t\t\tnextResult, err := pager.NextPage(ctx)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, provider.NewSoftErrorf(\"failed to fetch dns records: %v\", err)\n\t\t\t}\n\n\t\t\tfor _, recordSet := range nextResult.Value {\n\t\t\t\tvar recordType string\n\t\t\t\tif recordSet.Type == nil {\n\t\t\t\t\tlog.Debugf(\"Skipping invalid record set with missing type.\")\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\trecordType = strings.TrimPrefix(*recordSet.Type, \"Microsoft.Network/privateDnsZones/\")\n\n\t\t\t\tvar name string\n\t\t\t\tif recordSet.Name == nil {\n\t\t\t\t\tlog.Debugf(\"Skipping invalid record set with missing name.\")\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tname = formatAzureDNSName(*recordSet.Name, *zone.Name)\n\n\t\t\t\tif len(p.zoneNameFilter.Filters) > 0 && !p.domainFilter.Match(name) {\n\t\t\t\t\tlog.Debugf(\"Skipping return of record %s because it was filtered out by the specified --domain-filter\", name)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttargets := extractAzurePrivateDNSTargets(recordSet)\n\t\t\t\tif len(targets) == 0 {\n\t\t\t\t\tlog.Debugf(\"Failed to extract targets for '%s' with type '%s'.\", name, recordType)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tvar ttl endpoint.TTL\n\t\t\t\tif recordSet.Properties.TTL != nil {\n\t\t\t\t\tttl = endpoint.TTL(*recordSet.Properties.TTL)\n\t\t\t\t}\n\n\t\t\t\tep := endpoint.NewEndpointWithTTL(name, recordType, ttl, targets...)\n\t\t\t\tlog.Debugf(\n\t\t\t\t\t\"Found %s record for '%s' with target '%s'.\",\n\t\t\t\t\tep.RecordType,\n\t\t\t\t\tep.DNSName,\n\t\t\t\t\tep.Targets,\n\t\t\t\t)\n\t\t\t\tendpoints = append(endpoints, ep)\n\t\t\t}\n\t\t}\n\t}\n\n\tlog.Debugf(\"Returning %d Azure Private DNS Records for resource group '%s'\", len(endpoints), p.resourceGroup)\n\n\treturn endpoints, nil\n}\n\n// ApplyChanges applies the given changes.\n//\n// Returns nil if the operation was successful or an error if the operation failed.\nfunc (p *AzurePrivateDNSProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {\n\tlog.Debugf(\"Received %d changes to process\", len(changes.Create)+len(changes.Delete)+len(changes.UpdateNew)+len(changes.UpdateOld))\n\n\tzones, err := p.zones(ctx)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdeleted, updated := p.mapChanges(zones, changes)\n\tp.deleteRecords(ctx, deleted)\n\tp.updateRecords(ctx, updated)\n\treturn nil\n}\n\nfunc (p *AzurePrivateDNSProvider) zones(ctx context.Context) ([]privatedns.PrivateZone, error) {\n\tif !p.zonesCache.Expired() {\n\t\tcachedZones := p.zonesCache.Get()\n\t\tlog.Debugf(\"Using cached Azure Private DNS zones for resource group: %s zone count: %d.\", p.resourceGroup, len(cachedZones))\n\t\treturn cachedZones, nil\n\t}\n\tlog.Debugf(\"Retrieving Azure Private DNS zones for resource group: %s.\", p.resourceGroup)\n\tvar zones []privatedns.PrivateZone\n\n\tpager := p.zonesClient.NewListByResourceGroupPager(p.resourceGroup, &privatedns.PrivateZonesClientListByResourceGroupOptions{Top: nil})\n\tfor pager.More() {\n\t\tnextResult, err := pager.NextPage(ctx)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tfor _, zone := range nextResult.Value {\n\t\t\tif zone.Name != nil && p.domainFilter.Match(*zone.Name) && p.zoneIDFilter.Match(*zone.ID) {\n\t\t\t\tzones = append(zones, *zone)\n\t\t\t} else if zone.Name != nil && len(p.zoneNameFilter.Filters) > 0 && p.zoneNameFilter.Match(*zone.Name) {\n\t\t\t\t// Handle zoneNameFilter\n\t\t\t\tzones = append(zones, *zone)\n\t\t\t}\n\t\t}\n\t}\n\n\tp.zonesCache.Reset(zones)\n\treturn zones, nil\n}\n\ntype azurePrivateDNSChangeMap map[string][]*endpoint.Endpoint\n\nfunc (p *AzurePrivateDNSProvider) mapChanges(zones []privatedns.PrivateZone, changes *plan.Changes) (azurePrivateDNSChangeMap, azurePrivateDNSChangeMap) {\n\tignored := map[string]bool{}\n\tdeleted := azurePrivateDNSChangeMap{}\n\tupdated := azurePrivateDNSChangeMap{}\n\tzoneNameIDMapper := provider.ZoneIDName{}\n\tfor _, z := range zones {\n\t\tif z.Name != nil {\n\t\t\tzoneNameIDMapper.Add(*z.Name, *z.Name)\n\t\t}\n\t}\n\tmapChange := func(changeMap azurePrivateDNSChangeMap, change *endpoint.Endpoint) {\n\t\tzone, _ := zoneNameIDMapper.FindZone(change.DNSName)\n\t\tif zone == \"\" {\n\t\t\tif _, ok := ignored[change.DNSName]; !ok {\n\t\t\t\tignored[change.DNSName] = true\n\t\t\t\tlog.Infof(\"Ignoring changes to '%s' because a suitable Azure Private DNS zone was not found.\", change.DNSName)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t\t// Ensure the record type is suitable\n\t\tchangeMap[zone] = append(changeMap[zone], change)\n\t}\n\n\tfor _, change := range changes.Delete {\n\t\tmapChange(deleted, change)\n\t}\n\n\tfor _, change := range changes.Create {\n\t\tmapChange(updated, change)\n\t}\n\n\tfor _, change := range changes.UpdateNew {\n\t\tmapChange(updated, change)\n\t}\n\treturn deleted, updated\n}\n\nfunc (p *AzurePrivateDNSProvider) deleteRecords(ctx context.Context, deleted azurePrivateDNSChangeMap) {\n\tlog.Debugf(\"Records to be deleted: %d\", len(deleted))\n\t// Delete records first\n\tfor zone, endpoints := range deleted {\n\t\tfor _, ep := range endpoints {\n\t\t\tname := p.recordSetNameForZone(zone, ep)\n\t\t\tif !p.domainFilter.Match(ep.DNSName) {\n\t\t\t\tlog.Debugf(\"Skipping deletion of record %s because it was filtered out by the specified --domain-filter\", ep.DNSName)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif p.dryRun {\n\t\t\t\tlog.Infof(\"Would delete %s record named '%s' for Azure Private DNS zone '%s'.\", ep.RecordType, name, zone)\n\t\t\t} else {\n\t\t\t\tlog.Infof(\"Deleting %s record named '%s' for Azure Private DNS zone '%s'.\", ep.RecordType, name, zone)\n\t\t\t\tif _, err := p.recordSetsClient.Delete(ctx, p.resourceGroup, zone, privatedns.RecordType(ep.RecordType), name, nil); err != nil {\n\t\t\t\t\tlog.Errorf(\n\t\t\t\t\t\t\"Failed to delete %s record named '%s' for Azure Private DNS zone '%s': %v\",\n\t\t\t\t\t\tep.RecordType,\n\t\t\t\t\t\tname,\n\t\t\t\t\t\tzone,\n\t\t\t\t\t\terr,\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (p *AzurePrivateDNSProvider) updateRecords(ctx context.Context, updated azurePrivateDNSChangeMap) {\n\tlog.Debugf(\"Records to be updated: %d\", len(updated))\n\tfor zone, endpoints := range updated {\n\t\tfor _, ep := range endpoints {\n\t\t\tname := p.recordSetNameForZone(zone, ep)\n\t\t\tif !p.domainFilter.Match(ep.DNSName) {\n\t\t\t\tlog.Debugf(\"Skipping update of record %s because it was filtered out by the specified --domain-filter\", ep.DNSName)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif p.dryRun {\n\t\t\t\tlog.Infof(\n\t\t\t\t\t\"Would update %s record named '%s' to '%s' for Azure Private DNS zone '%s'.\",\n\t\t\t\t\tep.RecordType,\n\t\t\t\t\tname,\n\t\t\t\t\tep.Targets,\n\t\t\t\t\tzone,\n\t\t\t\t)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tlog.Infof(\n\t\t\t\t\"Updating %s record named '%s' to '%s' for Azure Private DNS zone '%s'.\",\n\t\t\t\tep.RecordType,\n\t\t\t\tname,\n\t\t\t\tep.Targets,\n\t\t\t\tzone,\n\t\t\t)\n\n\t\t\trecordSet, err := p.newRecordSet(ep)\n\t\t\tif err == nil {\n\t\t\t\t_, err = p.recordSetsClient.CreateOrUpdate(\n\t\t\t\t\tctx,\n\t\t\t\t\tp.resourceGroup,\n\t\t\t\t\tzone,\n\t\t\t\t\tprivatedns.RecordType(ep.RecordType),\n\t\t\t\t\tname,\n\t\t\t\t\trecordSet,\n\t\t\t\t\tnil,\n\t\t\t\t)\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\tlog.Errorf(\n\t\t\t\t\t\"Failed to update %s record named '%s' to '%s' for Azure Private DNS zone '%s': %v\",\n\t\t\t\t\tep.RecordType,\n\t\t\t\t\tname,\n\t\t\t\t\tep.Targets,\n\t\t\t\t\tzone,\n\t\t\t\t\terr,\n\t\t\t\t)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (p *AzurePrivateDNSProvider) recordSetNameForZone(zone string, endpoint *endpoint.Endpoint) string {\n\t// Remove the zone from the record set\n\tname := endpoint.DNSName\n\tname = name[:len(name)-len(zone)]\n\tname = strings.TrimSuffix(name, \".\")\n\n\t// For root, use @\n\tif name == \"\" {\n\t\treturn \"@\"\n\t}\n\treturn name\n}\n\nfunc (p *AzurePrivateDNSProvider) newRecordSet(endpoint *endpoint.Endpoint) (privatedns.RecordSet, error) {\n\tvar ttl int64 = defaultTTL\n\tif endpoint.RecordTTL.IsConfigured() {\n\t\tttl = int64(endpoint.RecordTTL)\n\t}\n\tswitch privatedns.RecordType(endpoint.RecordType) {\n\tcase privatedns.RecordTypeA:\n\t\taRecords := make([]*privatedns.ARecord, len(endpoint.Targets))\n\t\tfor i, target := range endpoint.Targets {\n\t\t\taRecords[i] = &privatedns.ARecord{\n\t\t\t\tIPv4Address: to.Ptr(target),\n\t\t\t}\n\t\t}\n\t\treturn privatedns.RecordSet{\n\t\t\tProperties: &privatedns.RecordSetProperties{\n\t\t\t\tTTL:      to.Ptr(ttl),\n\t\t\t\tARecords: aRecords,\n\t\t\t},\n\t\t}, nil\n\tcase privatedns.RecordTypeAAAA:\n\t\taaaaRecords := make([]*privatedns.AaaaRecord, len(endpoint.Targets))\n\t\tfor i, target := range endpoint.Targets {\n\t\t\taaaaRecords[i] = &privatedns.AaaaRecord{\n\t\t\t\tIPv6Address: to.Ptr(target),\n\t\t\t}\n\t\t}\n\t\treturn privatedns.RecordSet{\n\t\t\tProperties: &privatedns.RecordSetProperties{\n\t\t\t\tTTL:         to.Ptr(ttl),\n\t\t\t\tAaaaRecords: aaaaRecords,\n\t\t\t},\n\t\t}, nil\n\tcase privatedns.RecordTypeCNAME:\n\t\treturn privatedns.RecordSet{\n\t\t\tProperties: &privatedns.RecordSetProperties{\n\t\t\t\tTTL: to.Ptr(ttl),\n\t\t\t\tCnameRecord: &privatedns.CnameRecord{\n\t\t\t\t\tCname: to.Ptr(endpoint.Targets[0]),\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil\n\tcase privatedns.RecordTypeMX:\n\t\tmxRecords := make([]*privatedns.MxRecord, len(endpoint.Targets))\n\t\tfor i, target := range endpoint.Targets {\n\t\t\tmxRecord, err := parseMxTarget[privatedns.MxRecord](target)\n\t\t\tif err != nil {\n\t\t\t\treturn privatedns.RecordSet{}, err\n\t\t\t}\n\t\t\tmxRecords[i] = &mxRecord\n\t\t}\n\t\treturn privatedns.RecordSet{\n\t\t\tProperties: &privatedns.RecordSetProperties{\n\t\t\t\tTTL:       to.Ptr(ttl),\n\t\t\t\tMxRecords: mxRecords,\n\t\t\t},\n\t\t}, nil\n\tcase privatedns.RecordTypeTXT:\n\t\treturn privatedns.RecordSet{\n\t\t\tProperties: &privatedns.RecordSetProperties{\n\t\t\t\tTTL: to.Ptr(ttl),\n\t\t\t\tTxtRecords: []*privatedns.TxtRecord{\n\t\t\t\t\t{\n\t\t\t\t\t\tValue: []*string{\n\t\t\t\t\t\t\t&endpoint.Targets[0],\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil\n\t}\n\treturn privatedns.RecordSet{}, fmt.Errorf(\"unsupported record type '%s'\", endpoint.RecordType)\n}\n\n// Helper function (shared with test code)\nfunc extractAzurePrivateDNSTargets(recordSet *privatedns.RecordSet) []string {\n\tproperties := recordSet.Properties\n\tif properties == nil {\n\t\treturn []string{}\n\t}\n\n\t// Check for A records\n\taRecords := properties.ARecords\n\tif len(aRecords) > 0 && (aRecords)[0].IPv4Address != nil {\n\t\ttargets := make([]string, len(aRecords))\n\t\tfor i, aRecord := range aRecords {\n\t\t\ttargets[i] = *aRecord.IPv4Address\n\t\t}\n\t\treturn targets\n\t}\n\n\t// Check for AAAA records\n\taaaaRecords := properties.AaaaRecords\n\tif len(aaaaRecords) > 0 && (aaaaRecords)[0].IPv6Address != nil {\n\t\ttargets := make([]string, len(aaaaRecords))\n\t\tfor i, aaaaRecord := range aaaaRecords {\n\t\t\ttargets[i] = *aaaaRecord.IPv6Address\n\t\t}\n\t\treturn targets\n\t}\n\n\t// Check for CNAME records\n\tcnameRecord := properties.CnameRecord\n\tif cnameRecord != nil && cnameRecord.Cname != nil {\n\t\treturn []string{*cnameRecord.Cname}\n\t}\n\n\t// Check for MX records\n\tmxRecords := properties.MxRecords\n\tif len(mxRecords) > 0 && (mxRecords)[0].Exchange != nil {\n\t\ttargets := make([]string, len(mxRecords))\n\t\tfor i, mxRecord := range mxRecords {\n\t\t\ttargets[i] = fmt.Sprintf(\"%d %s\", *mxRecord.Preference, *mxRecord.Exchange)\n\t\t}\n\t\treturn targets\n\t}\n\n\t// Check for TXT records\n\ttxtRecords := properties.TxtRecords\n\tif len(txtRecords) > 0 && (txtRecords)[0].Value != nil {\n\t\tvalues := (txtRecords)[0].Value\n\t\tif len(values) > 0 {\n\t\t\treturn []string{*(values)[0]}\n\t\t}\n\t}\n\treturn []string{}\n}\n"
  },
  {
    "path": "provider/azure/azure_privatedns_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage azure\n\nimport (\n\t\"context\"\n\t\"testing\"\n\n\tazcoreruntime \"github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime\"\n\t\"github.com/Azure/azure-sdk-for-go/sdk/azcore/to\"\n\tprivatedns \"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns\"\n\n\t\"sigs.k8s.io/external-dns/provider/blueprint\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\nconst (\n\trecordTTL = 300\n)\n\n// mockPrivateZonesClient implements the methods of the Azure Private DNS Zones Client which are used in the Azure Private DNS Provider\n// and returns static results which are defined per test\ntype mockPrivateZonesClient struct {\n\tpagingHandler azcoreruntime.PagingHandler[privatedns.PrivateZonesClientListByResourceGroupResponse]\n}\n\nfunc newMockPrivateZonesClient(zones []*privatedns.PrivateZone) mockPrivateZonesClient {\n\tpagingHandler := azcoreruntime.PagingHandler[privatedns.PrivateZonesClientListByResourceGroupResponse]{\n\t\tMore: func(_ privatedns.PrivateZonesClientListByResourceGroupResponse) bool {\n\t\t\treturn false\n\t\t},\n\t\tFetcher: func(context.Context, *privatedns.PrivateZonesClientListByResourceGroupResponse) (privatedns.PrivateZonesClientListByResourceGroupResponse, error) {\n\t\t\treturn privatedns.PrivateZonesClientListByResourceGroupResponse{\n\t\t\t\tPrivateZoneListResult: privatedns.PrivateZoneListResult{\n\t\t\t\t\tValue: zones,\n\t\t\t\t},\n\t\t\t}, nil\n\t\t},\n\t}\n\treturn mockPrivateZonesClient{\n\t\tpagingHandler: pagingHandler,\n\t}\n}\n\nfunc (client *mockPrivateZonesClient) NewListByResourceGroupPager(_ string, _ *privatedns.PrivateZonesClientListByResourceGroupOptions) *azcoreruntime.Pager[privatedns.PrivateZonesClientListByResourceGroupResponse] {\n\treturn azcoreruntime.NewPager(client.pagingHandler)\n}\n\n// mockPrivateRecordSetsClient implements the methods of the Azure Private DNS RecordSet Client which are used in the Azure Private DNS Provider\n// and returns static results which are defined per test\ntype mockPrivateRecordSetsClient struct {\n\tpagingHandler    azcoreruntime.PagingHandler[privatedns.RecordSetsClientListResponse]\n\tdeletedEndpoints []*endpoint.Endpoint\n\tupdatedEndpoints []*endpoint.Endpoint\n}\n\nfunc newMockPrivateRecordSectsClient(recordSets []*privatedns.RecordSet) mockPrivateRecordSetsClient {\n\tpagingHandler := azcoreruntime.PagingHandler[privatedns.RecordSetsClientListResponse]{\n\t\tMore: func(_ privatedns.RecordSetsClientListResponse) bool {\n\t\t\treturn false\n\t\t},\n\t\tFetcher: func(context.Context, *privatedns.RecordSetsClientListResponse) (privatedns.RecordSetsClientListResponse, error) {\n\t\t\treturn privatedns.RecordSetsClientListResponse{\n\t\t\t\tRecordSetListResult: privatedns.RecordSetListResult{\n\t\t\t\t\tValue: recordSets,\n\t\t\t\t},\n\t\t\t}, nil\n\t\t},\n\t}\n\treturn mockPrivateRecordSetsClient{\n\t\tpagingHandler: pagingHandler,\n\t}\n}\n\nfunc (client *mockPrivateRecordSetsClient) NewListPager(_ string, _ string, _ *privatedns.RecordSetsClientListOptions) *azcoreruntime.Pager[privatedns.RecordSetsClientListResponse] {\n\treturn azcoreruntime.NewPager(client.pagingHandler)\n}\n\nfunc (client *mockPrivateRecordSetsClient) Delete(_ context.Context, _ string, privateZoneName string, recordType privatedns.RecordType, relativeRecordSetName string, _ *privatedns.RecordSetsClientDeleteOptions) (privatedns.RecordSetsClientDeleteResponse, error) {\n\tclient.deletedEndpoints = append(\n\t\tclient.deletedEndpoints,\n\t\tendpoint.NewEndpoint(\n\t\t\tformatAzureDNSName(relativeRecordSetName, privateZoneName),\n\t\t\tstring(recordType),\n\t\t\t\"\",\n\t\t),\n\t)\n\treturn privatedns.RecordSetsClientDeleteResponse{}, nil\n}\n\nfunc (client *mockPrivateRecordSetsClient) CreateOrUpdate(_ context.Context, _ string, privateZoneName string, recordType privatedns.RecordType, relativeRecordSetName string, parameters privatedns.RecordSet, _ *privatedns.RecordSetsClientCreateOrUpdateOptions) (privatedns.RecordSetsClientCreateOrUpdateResponse, error) {\n\tvar ttl endpoint.TTL\n\tif parameters.Properties.TTL != nil {\n\t\tttl = endpoint.TTL(*parameters.Properties.TTL)\n\t}\n\tclient.updatedEndpoints = append(\n\t\tclient.updatedEndpoints,\n\t\tendpoint.NewEndpointWithTTL(\n\t\t\tformatAzureDNSName(relativeRecordSetName, privateZoneName),\n\t\t\tstring(recordType),\n\t\t\tttl,\n\t\t\textractAzurePrivateDNSTargets(&parameters)...,\n\t\t),\n\t)\n\treturn privatedns.RecordSetsClientCreateOrUpdateResponse{}, nil\n}\n\nfunc createMockPrivateZone(zone string, id string) *privatedns.PrivateZone {\n\treturn &privatedns.PrivateZone{\n\t\tID:   to.Ptr(id),\n\t\tName: to.Ptr(zone),\n\t}\n}\n\nfunc privateARecordSetPropertiesGetter(values []string, ttl int64) *privatedns.RecordSetProperties {\n\taRecords := make([]*privatedns.ARecord, len(values))\n\tfor i, value := range values {\n\t\taRecords[i] = &privatedns.ARecord{\n\t\t\tIPv4Address: to.Ptr(value),\n\t\t}\n\t}\n\treturn &privatedns.RecordSetProperties{\n\t\tTTL:      to.Ptr(ttl),\n\t\tARecords: aRecords,\n\t}\n}\n\nfunc privateAAAARecordSetPropertiesGetter(values []string, ttl int64) *privatedns.RecordSetProperties {\n\taaaaRecords := make([]*privatedns.AaaaRecord, len(values))\n\tfor i, value := range values {\n\t\taaaaRecords[i] = &privatedns.AaaaRecord{\n\t\t\tIPv6Address: to.Ptr(value),\n\t\t}\n\t}\n\treturn &privatedns.RecordSetProperties{\n\t\tTTL:         to.Ptr(ttl),\n\t\tAaaaRecords: aaaaRecords,\n\t}\n}\n\nfunc privateCNameRecordSetPropertiesGetter(values []string, ttl int64) *privatedns.RecordSetProperties {\n\treturn &privatedns.RecordSetProperties{\n\t\tTTL: to.Ptr(ttl),\n\t\tCnameRecord: &privatedns.CnameRecord{\n\t\t\tCname: to.Ptr(values[0]),\n\t\t},\n\t}\n}\n\nfunc privateMXRecordSetPropertiesGetter(values []string, ttl int64) *privatedns.RecordSetProperties {\n\tmxRecords := make([]*privatedns.MxRecord, len(values))\n\tfor i, target := range values {\n\t\tmxRecord, _ := parseMxTarget[privatedns.MxRecord](target)\n\t\tmxRecords[i] = &mxRecord\n\t}\n\treturn &privatedns.RecordSetProperties{\n\t\tTTL:       to.Ptr(ttl),\n\t\tMxRecords: mxRecords,\n\t}\n}\n\nfunc privateTxtRecordSetPropertiesGetter(values []string, ttl int64) *privatedns.RecordSetProperties {\n\treturn &privatedns.RecordSetProperties{\n\t\tTTL: to.Ptr(ttl),\n\t\tTxtRecords: []*privatedns.TxtRecord{\n\t\t\t{\n\t\t\t\tValue: []*string{&values[0]},\n\t\t\t},\n\t\t},\n\t}\n}\n\nfunc privateOthersRecordSetPropertiesGetter(_ []string, ttl int64) *privatedns.RecordSetProperties {\n\treturn &privatedns.RecordSetProperties{\n\t\tTTL: to.Ptr(ttl),\n\t}\n}\n\nfunc createPrivateMockRecordSet(recordType string, values ...string) *privatedns.RecordSet {\n\treturn createPrivateMockRecordSetMultiWithTTL(\"@\", recordType, 0, values...)\n}\n\nfunc createPrivateMockRecordSetWithNameAndTTL(name, recordType, value string, ttl int64) *privatedns.RecordSet {\n\treturn createPrivateMockRecordSetMultiWithTTL(name, recordType, ttl, value)\n}\n\nfunc createPrivateMockRecordSetMultiWithTTL(name, recordType string, ttl int64, values ...string) *privatedns.RecordSet {\n\tvar getterFunc func(values []string, ttl int64) *privatedns.RecordSetProperties\n\n\tswitch recordType {\n\tcase endpoint.RecordTypeA:\n\t\tgetterFunc = privateARecordSetPropertiesGetter\n\tcase endpoint.RecordTypeAAAA:\n\t\tgetterFunc = privateAAAARecordSetPropertiesGetter\n\tcase endpoint.RecordTypeCNAME:\n\t\tgetterFunc = privateCNameRecordSetPropertiesGetter\n\tcase endpoint.RecordTypeMX:\n\t\tgetterFunc = privateMXRecordSetPropertiesGetter\n\tcase endpoint.RecordTypeTXT:\n\t\tgetterFunc = privateTxtRecordSetPropertiesGetter\n\tdefault:\n\t\tgetterFunc = privateOthersRecordSetPropertiesGetter\n\t}\n\treturn &privatedns.RecordSet{\n\t\tName:       to.Ptr(name),\n\t\tType:       to.Ptr(\"Microsoft.Network/privateDnsZones/\" + recordType),\n\t\tProperties: getterFunc(values, ttl),\n\t}\n}\n\n// newMockedAzurePrivateDNSProvider creates an AzureProvider comprising the mocked clients for zones and recordsets\nfunc newMockedAzurePrivateDNSProvider(domainFilter *endpoint.DomainFilter, zoneNameFilter *endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, dryRun bool, resourceGroup string, zones []*privatedns.PrivateZone, recordSets []*privatedns.RecordSet, maxRetriesCount int) *AzurePrivateDNSProvider {\n\tzonesClient := newMockPrivateZonesClient(zones)\n\trecordSetsClient := newMockPrivateRecordSectsClient(recordSets)\n\treturn newAzurePrivateDNSProvider(domainFilter, zoneNameFilter, zoneIDFilter, dryRun, resourceGroup, &zonesClient, &recordSetsClient, maxRetriesCount)\n}\n\nfunc newAzurePrivateDNSProvider(domainFilter *endpoint.DomainFilter, zoneNameFilter *endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, dryRun bool, resourceGroup string, privateZonesClient PrivateZonesClient, privateRecordsClient PrivateRecordSetsClient, maxRetriesCount int) *AzurePrivateDNSProvider {\n\treturn &AzurePrivateDNSProvider{\n\t\tdomainFilter:     domainFilter,\n\t\tzoneNameFilter:   zoneNameFilter,\n\t\tzoneIDFilter:     zoneIDFilter,\n\t\tdryRun:           dryRun,\n\t\tresourceGroup:    resourceGroup,\n\t\tzonesClient:      privateZonesClient,\n\t\tzonesCache:       blueprint.NewZoneCache[[]privatedns.PrivateZone](0),\n\t\trecordSetsClient: privateRecordsClient,\n\t\tmaxRetriesCount:  maxRetriesCount,\n\t}\n}\n\nfunc TestAzurePrivateDNSRecord(t *testing.T) {\n\tprovider := newMockedAzurePrivateDNSProvider(endpoint.NewDomainFilter([]string{\"example.com\"}), endpoint.NewDomainFilter([]string{}), provider.NewZoneIDFilter([]string{\"\"}), true, \"k8s\",\n\t\t[]*privatedns.PrivateZone{\n\t\t\tcreateMockPrivateZone(\"example.com\", \"/privateDnsZones/example.com\"),\n\t\t},\n\t\t[]*privatedns.RecordSet{\n\t\t\tcreatePrivateMockRecordSet(\"NS\", \"ns1-03.azure-dns.com.\"),\n\t\t\tcreatePrivateMockRecordSet(\"SOA\", \"Email: azuredns-hostmaster.microsoft.com\"),\n\t\t\tcreatePrivateMockRecordSet(endpoint.RecordTypeA, \"123.123.123.122\"),\n\t\t\tcreatePrivateMockRecordSet(endpoint.RecordTypeAAAA, \"2001::123:123:123:122\"),\n\t\t\tcreatePrivateMockRecordSet(endpoint.RecordTypeTXT, \"heritage=external-dns,external-dns/owner=default\"),\n\t\t\tcreatePrivateMockRecordSetWithNameAndTTL(\"nginx\", endpoint.RecordTypeA, \"123.123.123.123\", 3600),\n\t\t\tcreatePrivateMockRecordSetWithNameAndTTL(\"nginx\", endpoint.RecordTypeAAAA, \"2001::123:123:123:123\", 3600),\n\t\t\tcreatePrivateMockRecordSetWithNameAndTTL(\"nginx\", endpoint.RecordTypeTXT, \"heritage=external-dns,external-dns/owner=default\", recordTTL),\n\t\t\tcreatePrivateMockRecordSetWithNameAndTTL(\"hack\", endpoint.RecordTypeCNAME, \"hack.azurewebsites.net\", 10),\n\t\t\tcreatePrivateMockRecordSetWithNameAndTTL(\"mail\", endpoint.RecordTypeMX, \"10 example.com\", 4000),\n\t\t}, 3)\n\n\tactual, err := provider.Records(t.Context())\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\texpected := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeA, \"123.123.123.122\"),\n\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeAAAA, \"2001::123:123:123:122\"),\n\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeTXT, \"heritage=external-dns,external-dns/owner=default\"),\n\t\tendpoint.NewEndpointWithTTL(\"nginx.example.com\", endpoint.RecordTypeA, 3600, \"123.123.123.123\"),\n\t\tendpoint.NewEndpointWithTTL(\"nginx.example.com\", endpoint.RecordTypeAAAA, 3600, \"2001::123:123:123:123\"),\n\t\tendpoint.NewEndpointWithTTL(\"nginx.example.com\", endpoint.RecordTypeTXT, recordTTL, \"heritage=external-dns,external-dns/owner=default\"),\n\t\tendpoint.NewEndpointWithTTL(\"hack.example.com\", endpoint.RecordTypeCNAME, 10, \"hack.azurewebsites.net\"),\n\t\tendpoint.NewEndpointWithTTL(\"mail.example.com\", endpoint.RecordTypeMX, 4000, \"10 example.com\"),\n\t}\n\n\tvalidateAzureEndpoints(t, actual, expected)\n}\n\nfunc TestAzurePrivateDNSMultiRecord(t *testing.T) {\n\tprovider := newMockedAzurePrivateDNSProvider(endpoint.NewDomainFilter([]string{\"example.com\"}), endpoint.NewDomainFilter([]string{}), provider.NewZoneIDFilter([]string{\"\"}), true, \"k8s\",\n\t\t[]*privatedns.PrivateZone{\n\t\t\tcreateMockPrivateZone(\"example.com\", \"/privateDnsZones/example.com\"),\n\t\t},\n\t\t[]*privatedns.RecordSet{\n\t\t\tcreatePrivateMockRecordSet(\"NS\", \"ns1-03.azure-dns.com.\"),\n\t\t\tcreatePrivateMockRecordSet(\"SOA\", \"Email: azuredns-hostmaster.microsoft.com\"),\n\t\t\tcreatePrivateMockRecordSet(endpoint.RecordTypeA, \"123.123.123.122\", \"234.234.234.233\"),\n\t\t\tcreatePrivateMockRecordSet(endpoint.RecordTypeAAAA, \"2001::123:123:123:122\", \"2001::234:234:234:233\"),\n\t\t\tcreatePrivateMockRecordSet(endpoint.RecordTypeTXT, \"heritage=external-dns,external-dns/owner=default\"),\n\t\t\tcreatePrivateMockRecordSetMultiWithTTL(\"nginx\", endpoint.RecordTypeA, 3600, \"123.123.123.123\", \"234.234.234.234\"),\n\t\t\tcreatePrivateMockRecordSetMultiWithTTL(\"nginx\", endpoint.RecordTypeAAAA, 3600, \"2001::123:123:123:123\", \"2001::234:234:234:234\"),\n\t\t\tcreatePrivateMockRecordSetWithNameAndTTL(\"nginx\", endpoint.RecordTypeTXT, \"heritage=external-dns,external-dns/owner=default\", recordTTL),\n\t\t\tcreatePrivateMockRecordSetWithNameAndTTL(\"hack\", endpoint.RecordTypeCNAME, \"hack.azurewebsites.net\", 10),\n\t\t\tcreatePrivateMockRecordSetMultiWithTTL(\"mail\", endpoint.RecordTypeMX, 4000, \"10 example.com\", \"20 backup.example.com\"),\n\t\t}, 3)\n\n\tactual, err := provider.Records(t.Context())\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\texpected := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeA, \"123.123.123.122\", \"234.234.234.233\"),\n\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeAAAA, \"2001::123:123:123:122\", \"2001::234:234:234:233\"),\n\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeTXT, \"heritage=external-dns,external-dns/owner=default\"),\n\t\tendpoint.NewEndpointWithTTL(\"nginx.example.com\", endpoint.RecordTypeA, 3600, \"123.123.123.123\", \"234.234.234.234\"),\n\t\tendpoint.NewEndpointWithTTL(\"nginx.example.com\", endpoint.RecordTypeAAAA, 3600, \"2001::123:123:123:123\", \"2001::234:234:234:234\"),\n\t\tendpoint.NewEndpointWithTTL(\"nginx.example.com\", endpoint.RecordTypeTXT, recordTTL, \"heritage=external-dns,external-dns/owner=default\"),\n\t\tendpoint.NewEndpointWithTTL(\"hack.example.com\", endpoint.RecordTypeCNAME, 10, \"hack.azurewebsites.net\"),\n\t\tendpoint.NewEndpointWithTTL(\"mail.example.com\", endpoint.RecordTypeMX, 4000, \"10 example.com\", \"20 backup.example.com\"),\n\t}\n\n\tvalidateAzureEndpoints(t, actual, expected)\n}\n\nfunc TestAzurePrivateDNSApplyChanges(t *testing.T) {\n\trecordsClient := mockPrivateRecordSetsClient{}\n\n\ttestAzurePrivateDNSApplyChangesInternal(t, false, &recordsClient)\n\n\tvalidateAzureEndpoints(t, recordsClient.deletedEndpoints, []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"deleted.example.com\", endpoint.RecordTypeA, \"\"),\n\t\tendpoint.NewEndpoint(\"deletedaaaa.example.com\", endpoint.RecordTypeAAAA, \"\"),\n\t\tendpoint.NewEndpoint(\"deletedcname.example.com\", endpoint.RecordTypeCNAME, \"\"),\n\t})\n\n\tvalidateAzureEndpoints(t, recordsClient.updatedEndpoints, []*endpoint.Endpoint{\n\t\tendpoint.NewEndpointWithTTL(\"example.com\", endpoint.RecordTypeA, endpoint.TTL(recordTTL), \"1.2.3.4\"),\n\t\tendpoint.NewEndpointWithTTL(\"example.com\", endpoint.RecordTypeAAAA, endpoint.TTL(recordTTL), \"2001::1:2:3:4\"),\n\t\tendpoint.NewEndpointWithTTL(\"example.com\", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), \"tag\"),\n\t\tendpoint.NewEndpointWithTTL(\"foo.example.com\", endpoint.RecordTypeA, endpoint.TTL(recordTTL), \"1.2.3.4\", \"1.2.3.5\"),\n\t\tendpoint.NewEndpointWithTTL(\"foo.example.com\", endpoint.RecordTypeAAAA, endpoint.TTL(recordTTL), \"2001::1:2:3:4\", \"2001::1:2:3:5\"),\n\t\tendpoint.NewEndpointWithTTL(\"foo.example.com\", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), \"tag\"),\n\t\tendpoint.NewEndpointWithTTL(\"bar.example.com\", endpoint.RecordTypeCNAME, endpoint.TTL(recordTTL), \"other.com\"),\n\t\tendpoint.NewEndpointWithTTL(\"bar.example.com\", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), \"tag\"),\n\t\tendpoint.NewEndpointWithTTL(\"other.com\", endpoint.RecordTypeA, endpoint.TTL(recordTTL), \"5.6.7.8\"),\n\t\tendpoint.NewEndpointWithTTL(\"other.com\", endpoint.RecordTypeAAAA, endpoint.TTL(recordTTL), \"2001::5:6:7:8\"),\n\t\tendpoint.NewEndpointWithTTL(\"other.com\", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), \"tag\"),\n\t\tendpoint.NewEndpointWithTTL(\"new.example.com\", endpoint.RecordTypeA, 3600, \"111.222.111.222\"),\n\t\tendpoint.NewEndpointWithTTL(\"new.example.com\", endpoint.RecordTypeAAAA, 3600, \"2001::111:222:111:222\"),\n\t\tendpoint.NewEndpointWithTTL(\"newcname.example.com\", endpoint.RecordTypeCNAME, 10, \"other.com\"),\n\t\tendpoint.NewEndpointWithTTL(\"newmail.example.com\", endpoint.RecordTypeMX, 7200, \"40 bar.other.com\"),\n\t\tendpoint.NewEndpointWithTTL(\"mail.example.com\", endpoint.RecordTypeMX, endpoint.TTL(recordTTL), \"10 other.com\"),\n\t\tendpoint.NewEndpointWithTTL(\"mail.example.com\", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), \"tag\"),\n\t})\n}\n\nfunc TestAzurePrivateDNSApplyChangesDryRun(t *testing.T) {\n\trecordsClient := mockPrivateRecordSetsClient{}\n\n\ttestAzurePrivateDNSApplyChangesInternal(t, true, &recordsClient)\n\n\tvalidateAzureEndpoints(t, recordsClient.deletedEndpoints, []*endpoint.Endpoint{})\n\n\tvalidateAzureEndpoints(t, recordsClient.updatedEndpoints, []*endpoint.Endpoint{})\n}\n\nfunc testAzurePrivateDNSApplyChangesInternal(t *testing.T, dryRun bool, client PrivateRecordSetsClient) {\n\tzones := []*privatedns.PrivateZone{\n\t\tcreateMockPrivateZone(\"example.com\", \"/privateDnsZones/example.com\"),\n\t\tcreateMockPrivateZone(\"other.com\", \"/privateDnsZones/other.com\"),\n\t}\n\tzonesClient := newMockPrivateZonesClient(zones)\n\n\tprovider := newAzurePrivateDNSProvider(\n\t\tendpoint.NewDomainFilter([]string{\"\"}),\n\t\tendpoint.NewDomainFilter([]string{\"\"}),\n\t\tprovider.NewZoneIDFilter([]string{\"\"}),\n\t\tdryRun,\n\t\t\"group\",\n\t\t&zonesClient,\n\t\tclient,\n\t\t3,\n\t)\n\n\tcreateRecords := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeA, \"1.2.3.4\"),\n\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeAAAA, \"2001::1:2:3:4\"),\n\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeTXT, \"tag\"),\n\t\tendpoint.NewEndpoint(\"foo.example.com\", endpoint.RecordTypeA, \"1.2.3.5\", \"1.2.3.4\"),\n\t\tendpoint.NewEndpoint(\"foo.example.com\", endpoint.RecordTypeAAAA, \"2001::1:2:3:5\", \"2001::1:2:3:4\"),\n\t\tendpoint.NewEndpoint(\"foo.example.com\", endpoint.RecordTypeTXT, \"tag\"),\n\t\tendpoint.NewEndpoint(\"bar.example.com\", endpoint.RecordTypeCNAME, \"other.com\"),\n\t\tendpoint.NewEndpoint(\"bar.example.com\", endpoint.RecordTypeTXT, \"tag\"),\n\t\tendpoint.NewEndpoint(\"other.com\", endpoint.RecordTypeA, \"5.6.7.8\"),\n\t\tendpoint.NewEndpoint(\"other.com\", endpoint.RecordTypeAAAA, \"2001::5:6:7:8\"),\n\t\tendpoint.NewEndpoint(\"other.com\", endpoint.RecordTypeTXT, \"tag\"),\n\t\tendpoint.NewEndpoint(\"nope.com\", endpoint.RecordTypeA, \"4.4.4.4\"),\n\t\tendpoint.NewEndpoint(\"nope.com\", endpoint.RecordTypeAAAA, \"2001::4:4:4:4\"),\n\t\tendpoint.NewEndpoint(\"nope.com\", endpoint.RecordTypeTXT, \"tag\"),\n\t\tendpoint.NewEndpoint(\"mail.example.com\", endpoint.RecordTypeMX, \"10 other.com\"),\n\t\tendpoint.NewEndpoint(\"mail.example.com\", endpoint.RecordTypeTXT, \"tag\"),\n\t}\n\n\tcurrentRecords := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"old.example.com\", endpoint.RecordTypeA, \"121.212.121.212\"),\n\t\tendpoint.NewEndpoint(\"oldcname.example.com\", endpoint.RecordTypeCNAME, \"other.com\"),\n\t\tendpoint.NewEndpoint(\"old.nope.com\", endpoint.RecordTypeA, \"121.212.121.212\"),\n\t\tendpoint.NewEndpoint(\"oldmail.example.com\", endpoint.RecordTypeMX, \"20 foo.other.com\"),\n\t}\n\tupdatedRecords := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpointWithTTL(\"new.example.com\", endpoint.RecordTypeA, 3600, \"111.222.111.222\"),\n\t\tendpoint.NewEndpointWithTTL(\"new.example.com\", endpoint.RecordTypeAAAA, 3600, \"2001::111:222:111:222\"),\n\t\tendpoint.NewEndpointWithTTL(\"newcname.example.com\", endpoint.RecordTypeCNAME, 10, \"other.com\"),\n\t\tendpoint.NewEndpoint(\"new.nope.com\", endpoint.RecordTypeA, \"222.111.222.111\"),\n\t\tendpoint.NewEndpoint(\"new.nope.com\", endpoint.RecordTypeAAAA, \"2001::222:111:222:111\"),\n\t\tendpoint.NewEndpointWithTTL(\"newmail.example.com\", endpoint.RecordTypeMX, 7200, \"40 bar.other.com\"),\n\t}\n\n\tdeleteRecords := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"deleted.example.com\", endpoint.RecordTypeA, \"111.222.111.222\"),\n\t\tendpoint.NewEndpoint(\"deletedaaaa.example.com\", endpoint.RecordTypeAAAA, \"2001::111:222:111:222\"),\n\t\tendpoint.NewEndpoint(\"deletedcname.example.com\", endpoint.RecordTypeCNAME, \"other.com\"),\n\t\tendpoint.NewEndpoint(\"deleted.nope.com\", endpoint.RecordTypeA, \"222.111.222.111\"),\n\t\tendpoint.NewEndpoint(\"deleted.nope.com\", endpoint.RecordTypeAAAA, \"2001::222:111:222:111\"),\n\t}\n\n\tchanges := &plan.Changes{\n\t\tCreate:    createRecords,\n\t\tUpdateNew: updatedRecords,\n\t\tUpdateOld: currentRecords,\n\t\tDelete:    deleteRecords,\n\t}\n\n\tif err := provider.ApplyChanges(t.Context(), changes); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestAzurePrivateDNSNameFilter(t *testing.T) {\n\tprovider := newMockedAzurePrivateDNSProvider(endpoint.NewDomainFilter([]string{\"nginx.example.com\"}), endpoint.NewDomainFilter([]string{\"example.com\"}), provider.NewZoneIDFilter([]string{\"\"}), true, \"k8s\",\n\t\t[]*privatedns.PrivateZone{\n\t\t\tcreateMockPrivateZone(\"example.com\", \"/privateDnsZones/example.com\"),\n\t\t},\n\n\t\t[]*privatedns.RecordSet{\n\t\t\tcreatePrivateMockRecordSet(\"NS\", \"ns1-03.azure-dns.com.\"),\n\t\t\tcreatePrivateMockRecordSet(\"SOA\", \"Email: azuredns-hostmaster.microsoft.com\"),\n\t\t\tcreatePrivateMockRecordSet(endpoint.RecordTypeA, \"123.123.123.122\"),\n\t\t\tcreatePrivateMockRecordSet(endpoint.RecordTypeTXT, \"heritage=external-dns,external-dns/owner=default\"),\n\t\t\tcreatePrivateMockRecordSetWithNameAndTTL(\"test.nginx\", endpoint.RecordTypeA, \"123.123.123.123\", 3600),\n\t\t\tcreatePrivateMockRecordSetWithNameAndTTL(\"nginx\", endpoint.RecordTypeA, \"123.123.123.123\", 3600),\n\t\t\tcreatePrivateMockRecordSetWithNameAndTTL(\"nginx\", endpoint.RecordTypeTXT, \"heritage=external-dns,external-dns/owner=default\", recordTTL),\n\t\t\tcreatePrivateMockRecordSetWithNameAndTTL(\"mail.nginx\", endpoint.RecordTypeMX, \"20 example.com\", recordTTL),\n\t\t\tcreatePrivateMockRecordSetWithNameAndTTL(\"hack\", endpoint.RecordTypeCNAME, \"hack.azurewebsites.net\", 10),\n\t\t}, 3)\n\n\tctx := t.Context()\n\tactual, err := provider.Records(ctx)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\texpected := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpointWithTTL(\"test.nginx.example.com\", endpoint.RecordTypeA, 3600, \"123.123.123.123\"),\n\t\tendpoint.NewEndpointWithTTL(\"nginx.example.com\", endpoint.RecordTypeA, 3600, \"123.123.123.123\"),\n\t\tendpoint.NewEndpointWithTTL(\"nginx.example.com\", endpoint.RecordTypeTXT, recordTTL, \"heritage=external-dns,external-dns/owner=default\"),\n\t\tendpoint.NewEndpointWithTTL(\"mail.nginx.example.com\", endpoint.RecordTypeMX, recordTTL, \"20 example.com\"),\n\t}\n\n\tvalidateAzureEndpoints(t, actual, expected)\n}\n\nfunc TestAzurePrivateDNSApplyChangesZoneName(t *testing.T) {\n\trecordsClient := mockPrivateRecordSetsClient{}\n\n\ttestAzurePrivateDNSApplyChangesInternalZoneName(t, false, &recordsClient)\n\n\tvalidateAzureEndpoints(t, recordsClient.deletedEndpoints, []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"deleted.foo.example.com\", endpoint.RecordTypeA, \"\"),\n\t\tendpoint.NewEndpoint(\"deletedaaaa.foo.example.com\", endpoint.RecordTypeAAAA, \"\"),\n\t\tendpoint.NewEndpoint(\"deletedcname.foo.example.com\", endpoint.RecordTypeCNAME, \"\"),\n\t})\n\n\tvalidateAzureEndpoints(t, recordsClient.updatedEndpoints, []*endpoint.Endpoint{\n\t\tendpoint.NewEndpointWithTTL(\"foo.example.com\", endpoint.RecordTypeA, endpoint.TTL(recordTTL), \"1.2.3.4\", \"1.2.3.5\"),\n\t\tendpoint.NewEndpointWithTTL(\"foo.example.com\", endpoint.RecordTypeAAAA, endpoint.TTL(recordTTL), \"2001::1:2:3:4\", \"2001::1:2:3:5\"),\n\t\tendpoint.NewEndpointWithTTL(\"foo.example.com\", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), \"tag\"),\n\t\tendpoint.NewEndpointWithTTL(\"new.foo.example.com\", endpoint.RecordTypeA, 3600, \"111.222.111.222\"),\n\t\tendpoint.NewEndpointWithTTL(\"new.foo.example.com\", endpoint.RecordTypeAAAA, 3600, \"2001::111:222:111:222\"),\n\t\tendpoint.NewEndpointWithTTL(\"newcname.foo.example.com\", endpoint.RecordTypeCNAME, 10, \"other.com\"),\n\t})\n}\n\nfunc testAzurePrivateDNSApplyChangesInternalZoneName(t *testing.T, dryRun bool, client PrivateRecordSetsClient) {\n\tzones := []*privatedns.PrivateZone{\n\t\tcreateMockPrivateZone(\"example.com\", \"/privateDnsZones/example.com\"),\n\t}\n\tzonesClient := newMockPrivateZonesClient(zones)\n\n\tprovider := newAzurePrivateDNSProvider(\n\t\tendpoint.NewDomainFilter([]string{\"foo.example.com\"}),\n\t\tendpoint.NewDomainFilter([]string{\"example.com\"}),\n\t\tprovider.NewZoneIDFilter([]string{\"\"}),\n\t\tdryRun,\n\t\t\"group\",\n\t\t&zonesClient,\n\t\tclient,\n\t\t3,\n\t)\n\n\tcreateRecords := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeA, \"1.2.3.4\"),\n\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeAAAA, \"2001::1:2:3:4\"),\n\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeTXT, \"tag\"),\n\t\tendpoint.NewEndpoint(\"foo.example.com\", endpoint.RecordTypeA, \"1.2.3.5\", \"1.2.3.4\"),\n\t\tendpoint.NewEndpoint(\"foo.example.com\", endpoint.RecordTypeAAAA, \"2001::1:2:3:5\", \"2001::1:2:3:4\"),\n\t\tendpoint.NewEndpoint(\"foo.example.com\", endpoint.RecordTypeTXT, \"tag\"),\n\t\tendpoint.NewEndpoint(\"bar.example.com\", endpoint.RecordTypeCNAME, \"other.com\"),\n\t\tendpoint.NewEndpoint(\"bar.example.com\", endpoint.RecordTypeTXT, \"tag\"),\n\t\tendpoint.NewEndpoint(\"other.com\", endpoint.RecordTypeA, \"5.6.7.8\"),\n\t\tendpoint.NewEndpoint(\"other.com\", endpoint.RecordTypeTXT, \"tag\"),\n\t\tendpoint.NewEndpoint(\"nope.com\", endpoint.RecordTypeA, \"4.4.4.4\"),\n\t\tendpoint.NewEndpoint(\"nope.com\", endpoint.RecordTypeTXT, \"tag\"),\n\t}\n\n\tcurrentRecords := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"old.foo.example.com\", endpoint.RecordTypeA, \"121.212.121.212\"),\n\t\tendpoint.NewEndpoint(\"oldcname.foo.example.com\", endpoint.RecordTypeCNAME, \"other.com\"),\n\t\tendpoint.NewEndpoint(\"old.nope.example.com\", endpoint.RecordTypeA, \"121.212.121.212\"),\n\t}\n\tupdatedRecords := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpointWithTTL(\"new.foo.example.com\", endpoint.RecordTypeA, 3600, \"111.222.111.222\"),\n\t\tendpoint.NewEndpointWithTTL(\"new.foo.example.com\", endpoint.RecordTypeAAAA, 3600, \"2001::111:222:111:222\"),\n\t\tendpoint.NewEndpointWithTTL(\"newcname.foo.example.com\", endpoint.RecordTypeCNAME, 10, \"other.com\"),\n\t\tendpoint.NewEndpoint(\"new.nope.example.com\", endpoint.RecordTypeA, \"222.111.222.111\"),\n\t\tendpoint.NewEndpoint(\"new.nope.example.com\", endpoint.RecordTypeAAAA, \"2001::222:111:222:111\"),\n\t}\n\n\tdeleteRecords := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"deleted.foo.example.com\", endpoint.RecordTypeA, \"111.222.111.222\"),\n\t\tendpoint.NewEndpoint(\"deletedaaaa.foo.example.com\", endpoint.RecordTypeAAAA, \"2001::111:222:111:222\"),\n\t\tendpoint.NewEndpoint(\"deletedcname.foo.example.com\", endpoint.RecordTypeCNAME, \"other.com\"),\n\t\tendpoint.NewEndpoint(\"deleted.nope.example.com\", endpoint.RecordTypeA, \"222.111.222.111\"),\n\t}\n\n\tchanges := &plan.Changes{\n\t\tCreate:    createRecords,\n\t\tUpdateNew: updatedRecords,\n\t\tUpdateOld: currentRecords,\n\t\tDelete:    deleteRecords,\n\t}\n\n\tif err := provider.ApplyChanges(t.Context(), changes); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n"
  },
  {
    "path": "provider/azure/azure_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage azure\n\nimport (\n\t\"context\"\n\t\"testing\"\n\n\tazcoreruntime \"github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime\"\n\t\"github.com/Azure/azure-sdk-for-go/sdk/azcore/to\"\n\tdns \"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns\"\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"sigs.k8s.io/external-dns/provider/blueprint\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/internal/testutils\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\n// mockZonesClient implements the methods of the Azure DNS Zones Client which are used in the Azure Provider\n// and returns static results which are defined per test\ntype mockZonesClient struct {\n\tpagingHandler azcoreruntime.PagingHandler[dns.ZonesClientListByResourceGroupResponse]\n}\n\nfunc newMockZonesClient(zones []*dns.Zone) mockZonesClient {\n\tpagingHandler := azcoreruntime.PagingHandler[dns.ZonesClientListByResourceGroupResponse]{\n\t\tMore: func(_ dns.ZonesClientListByResourceGroupResponse) bool {\n\t\t\treturn false\n\t\t},\n\t\tFetcher: func(context.Context, *dns.ZonesClientListByResourceGroupResponse) (dns.ZonesClientListByResourceGroupResponse, error) {\n\t\t\treturn dns.ZonesClientListByResourceGroupResponse{\n\t\t\t\tZoneListResult: dns.ZoneListResult{\n\t\t\t\t\tValue: zones,\n\t\t\t\t},\n\t\t\t}, nil\n\t\t},\n\t}\n\treturn mockZonesClient{\n\t\tpagingHandler: pagingHandler,\n\t}\n}\n\nfunc (client *mockZonesClient) NewListByResourceGroupPager(_ string, _ *dns.ZonesClientListByResourceGroupOptions) *azcoreruntime.Pager[dns.ZonesClientListByResourceGroupResponse] {\n\treturn azcoreruntime.NewPager(client.pagingHandler)\n}\n\n// mockZonesClient implements the methods of the Azure DNS RecordSet Client which are used in the Azure Provider\n// and returns static results which are defined per test\ntype mockRecordSetsClient struct {\n\tpagingHandler    azcoreruntime.PagingHandler[dns.RecordSetsClientListAllByDNSZoneResponse]\n\tdeletedEndpoints []*endpoint.Endpoint\n\tupdatedEndpoints []*endpoint.Endpoint\n}\n\nfunc newMockRecordSetsClient(recordSets []*dns.RecordSet) mockRecordSetsClient {\n\tpagingHandler := azcoreruntime.PagingHandler[dns.RecordSetsClientListAllByDNSZoneResponse]{\n\t\tMore: func(_ dns.RecordSetsClientListAllByDNSZoneResponse) bool {\n\t\t\treturn false\n\t\t},\n\t\tFetcher: func(context.Context, *dns.RecordSetsClientListAllByDNSZoneResponse) (dns.RecordSetsClientListAllByDNSZoneResponse, error) {\n\t\t\treturn dns.RecordSetsClientListAllByDNSZoneResponse{\n\t\t\t\tRecordSetListResult: dns.RecordSetListResult{\n\t\t\t\t\tValue: recordSets,\n\t\t\t\t},\n\t\t\t}, nil\n\t\t},\n\t}\n\treturn mockRecordSetsClient{\n\t\tpagingHandler: pagingHandler,\n\t}\n}\n\nfunc (client *mockRecordSetsClient) NewListAllByDNSZonePager(_ string, _ string, _ *dns.RecordSetsClientListAllByDNSZoneOptions) *azcoreruntime.Pager[dns.RecordSetsClientListAllByDNSZoneResponse] {\n\treturn azcoreruntime.NewPager(client.pagingHandler)\n}\n\nfunc (client *mockRecordSetsClient) Delete(_ context.Context, _ string, zoneName string, relativeRecordSetName string, recordType dns.RecordType, _ *dns.RecordSetsClientDeleteOptions) (dns.RecordSetsClientDeleteResponse, error) {\n\tclient.deletedEndpoints = append(\n\t\tclient.deletedEndpoints,\n\t\tendpoint.NewEndpoint(\n\t\t\tformatAzureDNSName(relativeRecordSetName, zoneName),\n\t\t\tstring(recordType),\n\t\t\t\"\",\n\t\t),\n\t)\n\treturn dns.RecordSetsClientDeleteResponse{}, nil\n}\n\nfunc (client *mockRecordSetsClient) CreateOrUpdate(_ context.Context, _ string, zoneName string, relativeRecordSetName string, recordType dns.RecordType, parameters dns.RecordSet, _ *dns.RecordSetsClientCreateOrUpdateOptions) (dns.RecordSetsClientCreateOrUpdateResponse, error) {\n\tvar ttl endpoint.TTL\n\tif parameters.Properties.TTL != nil {\n\t\tttl = endpoint.TTL(*parameters.Properties.TTL)\n\t}\n\tclient.updatedEndpoints = append(\n\t\tclient.updatedEndpoints,\n\t\tendpoint.NewEndpointWithTTL(\n\t\t\tformatAzureDNSName(relativeRecordSetName, zoneName),\n\t\t\tstring(recordType),\n\t\t\tttl,\n\t\t\textractAzureTargets(&parameters)...,\n\t\t),\n\t)\n\treturn dns.RecordSetsClientCreateOrUpdateResponse{}, nil\n}\n\nfunc createMockZone(zone string, id string) *dns.Zone {\n\treturn &dns.Zone{\n\t\tID:   to.Ptr(id),\n\t\tName: to.Ptr(zone),\n\t}\n}\n\nfunc aRecordSetPropertiesGetter(values []string, ttl int64) *dns.RecordSetProperties {\n\taRecords := make([]*dns.ARecord, len(values))\n\tfor i, value := range values {\n\t\taRecords[i] = &dns.ARecord{\n\t\t\tIPv4Address: to.Ptr(value),\n\t\t}\n\t}\n\treturn &dns.RecordSetProperties{\n\t\tTTL:      to.Ptr(ttl),\n\t\tARecords: aRecords,\n\t}\n}\n\nfunc aaaaRecordSetPropertiesGetter(values []string, ttl int64) *dns.RecordSetProperties {\n\taaaaRecords := make([]*dns.AaaaRecord, len(values))\n\tfor i, value := range values {\n\t\taaaaRecords[i] = &dns.AaaaRecord{\n\t\t\tIPv6Address: to.Ptr(value),\n\t\t}\n\t}\n\treturn &dns.RecordSetProperties{\n\t\tTTL:         to.Ptr(ttl),\n\t\tAaaaRecords: aaaaRecords,\n\t}\n}\n\nfunc cNameRecordSetPropertiesGetter(values []string, ttl int64) *dns.RecordSetProperties {\n\treturn &dns.RecordSetProperties{\n\t\tTTL: to.Ptr(ttl),\n\t\tCnameRecord: &dns.CnameRecord{\n\t\t\tCname: to.Ptr(values[0]),\n\t\t},\n\t}\n}\n\nfunc mxRecordSetPropertiesGetter(values []string, ttl int64) *dns.RecordSetProperties {\n\tmxRecords := make([]*dns.MxRecord, len(values))\n\tfor i, target := range values {\n\t\tmxRecord, _ := parseMxTarget[dns.MxRecord](target)\n\t\tmxRecords[i] = &mxRecord\n\t}\n\treturn &dns.RecordSetProperties{\n\t\tTTL:       to.Ptr(ttl),\n\t\tMxRecords: mxRecords,\n\t}\n}\n\nfunc nsRecordSetPropertiesGetter(values []string, ttl int64) *dns.RecordSetProperties {\n\tnsRecords := make([]*dns.NsRecord, len(values))\n\tfor i, value := range values {\n\t\tnsRecords[i] = &dns.NsRecord{\n\t\t\tNsdname: to.Ptr(value),\n\t\t}\n\t}\n\treturn &dns.RecordSetProperties{\n\t\tTTL:       to.Ptr(ttl),\n\t\tNsRecords: nsRecords,\n\t}\n}\n\nfunc txtRecordSetPropertiesGetter(values []string, ttl int64) *dns.RecordSetProperties {\n\treturn &dns.RecordSetProperties{\n\t\tTTL: to.Ptr(ttl),\n\t\tTxtRecords: []*dns.TxtRecord{\n\t\t\t{\n\t\t\t\tValue: []*string{to.Ptr(values[0])},\n\t\t\t},\n\t\t},\n\t}\n}\n\nfunc othersRecordSetPropertiesGetter(_ []string, ttl int64) *dns.RecordSetProperties {\n\treturn &dns.RecordSetProperties{\n\t\tTTL: to.Ptr(ttl),\n\t}\n}\n\nfunc createMockRecordSet(name, recordType string, values ...string) *dns.RecordSet {\n\treturn createMockRecordSetMultiWithTTL(name, recordType, 0, values...)\n}\n\nfunc createMockRecordSetWithTTL(name, recordType, value string, ttl int64) *dns.RecordSet {\n\treturn createMockRecordSetMultiWithTTL(name, recordType, ttl, value)\n}\n\nfunc createMockRecordSetMultiWithTTL(name, recordType string, ttl int64, values ...string) *dns.RecordSet {\n\tvar getterFunc func(values []string, ttl int64) *dns.RecordSetProperties\n\n\tswitch recordType {\n\tcase endpoint.RecordTypeA:\n\t\tgetterFunc = aRecordSetPropertiesGetter\n\tcase endpoint.RecordTypeAAAA:\n\t\tgetterFunc = aaaaRecordSetPropertiesGetter\n\tcase endpoint.RecordTypeCNAME:\n\t\tgetterFunc = cNameRecordSetPropertiesGetter\n\tcase endpoint.RecordTypeMX:\n\t\tgetterFunc = mxRecordSetPropertiesGetter\n\tcase endpoint.RecordTypeNS:\n\t\tgetterFunc = nsRecordSetPropertiesGetter\n\tcase endpoint.RecordTypeTXT:\n\t\tgetterFunc = txtRecordSetPropertiesGetter\n\tdefault:\n\t\tgetterFunc = othersRecordSetPropertiesGetter\n\t}\n\treturn &dns.RecordSet{\n\t\tName:       to.Ptr(name),\n\t\tType:       to.Ptr(\"Microsoft.Network/dnszones/\" + recordType),\n\t\tProperties: getterFunc(values, ttl),\n\t}\n}\n\n// newMockedAzureProvider creates an AzureProvider comprising the mocked clients for zones and recordsets\nfunc newMockedAzureProvider(domainFilter *endpoint.DomainFilter, zoneNameFilter *endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, dryRun bool, resourceGroup string, userAssignedIdentityClientID string, activeDirectoryAuthorityHost string, zones []*dns.Zone, recordSets []*dns.RecordSet, maxRetriesCount int) *AzureProvider {\n\tzonesClient := newMockZonesClient(zones)\n\trecordSetsClient := newMockRecordSetsClient(recordSets)\n\treturn newAzureProvider(domainFilter, zoneNameFilter, zoneIDFilter, dryRun, resourceGroup, userAssignedIdentityClientID, activeDirectoryAuthorityHost, &zonesClient, &recordSetsClient, maxRetriesCount)\n}\n\nfunc newAzureProvider(domainFilter *endpoint.DomainFilter, zoneNameFilter *endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, dryRun bool, resourceGroup string, userAssignedIdentityClientID string, activeDirectoryAuthorityHost string, zonesClient ZonesClient, recordsClient RecordSetsClient, maxRetriesCount int) *AzureProvider {\n\treturn &AzureProvider{\n\t\tdomainFilter:                 domainFilter,\n\t\tzoneNameFilter:               zoneNameFilter,\n\t\tzoneIDFilter:                 zoneIDFilter,\n\t\tdryRun:                       dryRun,\n\t\tresourceGroup:                resourceGroup,\n\t\tuserAssignedIdentityClientID: userAssignedIdentityClientID,\n\t\tactiveDirectoryAuthorityHost: activeDirectoryAuthorityHost,\n\t\tzonesClient:                  zonesClient,\n\t\tzonesCache:                   blueprint.NewZoneCache[[]dns.Zone](0),\n\t\trecordSetsClient:             recordsClient,\n\t\tmaxRetriesCount:              maxRetriesCount,\n\t}\n}\n\nfunc validateAzureEndpoints(t *testing.T, endpoints []*endpoint.Endpoint, expected []*endpoint.Endpoint) {\n\tassert.True(t, testutils.SameEndpoints(endpoints, expected), \"actual and expected endpoints don't match. %s:%s\", endpoints, expected)\n}\n\nfunc TestAzureRecord(t *testing.T) {\n\tprovider := newMockedAzureProvider(endpoint.NewDomainFilter([]string{\"example.com\"}), endpoint.NewDomainFilter([]string{}), provider.NewZoneIDFilter([]string{\"\"}), true, \"k8s\", \"\", \"\",\n\t\t[]*dns.Zone{\n\t\t\tcreateMockZone(\"example.com\", \"/dnszones/example.com\"),\n\t\t},\n\t\t[]*dns.RecordSet{\n\t\t\tcreateMockRecordSet(\"@\", endpoint.RecordTypeNS, \"ns1-03.azure-dns.com.\"),\n\t\t\tcreateMockRecordSet(\"@\", \"SOA\", \"Email: azuredns-hostmaster.microsoft.com\"),\n\t\t\tcreateMockRecordSet(\"@\", endpoint.RecordTypeA, \"123.123.123.122\"),\n\t\t\tcreateMockRecordSet(\"@\", endpoint.RecordTypeAAAA, \"2001::123:123:123:122\"),\n\t\t\tcreateMockRecordSet(\"cloud\", endpoint.RecordTypeNS, \"ns1.example.com.\"),\n\t\t\tcreateMockRecordSet(\"@\", endpoint.RecordTypeTXT, \"heritage=external-dns,external-dns/owner=default\"),\n\t\t\tcreateMockRecordSetWithTTL(\"nginx\", endpoint.RecordTypeA, \"123.123.123.123\", 3600),\n\t\t\tcreateMockRecordSetWithTTL(\"nginx\", endpoint.RecordTypeAAAA, \"2001::123:123:123:123\", 3600),\n\t\t\tcreateMockRecordSetWithTTL(\"cloud-ttl\", endpoint.RecordTypeNS, \"ns1-ttl.example.com.\", 10),\n\t\t\tcreateMockRecordSetWithTTL(\"nginx\", endpoint.RecordTypeTXT, \"heritage=external-dns,external-dns/owner=default\", recordTTL),\n\t\t\tcreateMockRecordSetWithTTL(\"hack\", endpoint.RecordTypeCNAME, \"hack.azurewebsites.net\", 10),\n\t\t\tcreateMockRecordSetMultiWithTTL(\"mail\", endpoint.RecordTypeMX, 4000, \"10 example.com\"),\n\t\t}, 3)\n\n\tctx := t.Context()\n\tactual, err := provider.Records(ctx)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\texpected := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeNS, \"ns1-03.azure-dns.com.\"),\n\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeA, \"123.123.123.122\"),\n\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeAAAA, \"2001::123:123:123:122\"),\n\t\tendpoint.NewEndpoint(\"cloud.example.com\", endpoint.RecordTypeNS, \"ns1.example.com.\"),\n\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeTXT, \"heritage=external-dns,external-dns/owner=default\"),\n\t\tendpoint.NewEndpointWithTTL(\"nginx.example.com\", endpoint.RecordTypeA, 3600, \"123.123.123.123\"),\n\t\tendpoint.NewEndpointWithTTL(\"nginx.example.com\", endpoint.RecordTypeAAAA, 3600, \"2001::123:123:123:123\"),\n\t\tendpoint.NewEndpointWithTTL(\"cloud-ttl.example.com\", endpoint.RecordTypeNS, 10, \"ns1-ttl.example.com.\"),\n\t\tendpoint.NewEndpointWithTTL(\"nginx.example.com\", endpoint.RecordTypeTXT, recordTTL, \"heritage=external-dns,external-dns/owner=default\"),\n\t\tendpoint.NewEndpointWithTTL(\"hack.example.com\", endpoint.RecordTypeCNAME, 10, \"hack.azurewebsites.net\"),\n\t\tendpoint.NewEndpointWithTTL(\"mail.example.com\", endpoint.RecordTypeMX, 4000, \"10 example.com\"),\n\t}\n\n\tvalidateAzureEndpoints(t, actual, expected)\n}\n\nfunc TestAzureMultiRecord(t *testing.T) {\n\tprovider := newMockedAzureProvider(endpoint.NewDomainFilter([]string{\"example.com\"}), endpoint.NewDomainFilter([]string{}), provider.NewZoneIDFilter([]string{\"\"}), true, \"k8s\", \"\", \"\",\n\t\t[]*dns.Zone{\n\t\t\tcreateMockZone(\"example.com\", \"/dnszones/example.com\"),\n\t\t},\n\t\t[]*dns.RecordSet{\n\t\t\tcreateMockRecordSet(\"@\", endpoint.RecordTypeNS, \"ns1-03.azure-dns.com.\"),\n\t\t\tcreateMockRecordSet(\"@\", \"SOA\", \"Email: azuredns-hostmaster.microsoft.com\"),\n\t\t\tcreateMockRecordSet(\"@\", endpoint.RecordTypeA, \"123.123.123.122\", \"234.234.234.233\"),\n\t\t\tcreateMockRecordSet(\"@\", endpoint.RecordTypeAAAA, \"2001::123:123:123:122\", \"2001::234:234:234:233\"),\n\t\t\tcreateMockRecordSet(\"cloud\", endpoint.RecordTypeNS, \"ns1.example.com.\", \"ns2.example.com.\"),\n\t\t\tcreateMockRecordSet(\"@\", endpoint.RecordTypeTXT, \"heritage=external-dns,external-dns/owner=default\"),\n\t\t\tcreateMockRecordSetMultiWithTTL(\"nginx\", endpoint.RecordTypeA, 3600, \"123.123.123.123\", \"234.234.234.234\"),\n\t\t\tcreateMockRecordSetMultiWithTTL(\"nginx\", endpoint.RecordTypeAAAA, 3600, \"2001::123:123:123:123\", \"2001::234:234:234:234\"),\n\t\t\tcreateMockRecordSetMultiWithTTL(\"cloud-ttl\", endpoint.RecordTypeNS, 10, \"ns1-ttl.example.com.\", \"ns2-ttl.example.com.\"),\n\t\t\tcreateMockRecordSetWithTTL(\"nginx\", endpoint.RecordTypeTXT, \"heritage=external-dns,external-dns/owner=default\", recordTTL),\n\t\t\tcreateMockRecordSetWithTTL(\"hack\", endpoint.RecordTypeCNAME, \"hack.azurewebsites.net\", 10),\n\t\t\tcreateMockRecordSetMultiWithTTL(\"mail\", endpoint.RecordTypeMX, 4000, \"10 example.com\", \"20 backup.example.com\"),\n\t\t}, 3)\n\n\tctx := t.Context()\n\tactual, err := provider.Records(ctx)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\texpected := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeNS, \"ns1-03.azure-dns.com.\"),\n\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeA, \"123.123.123.122\", \"234.234.234.233\"),\n\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeAAAA, \"2001::123:123:123:122\", \"2001::234:234:234:233\"),\n\t\tendpoint.NewEndpoint(\"cloud.example.com\", endpoint.RecordTypeNS, \"ns1.example.com.\", \"ns2.example.com.\"),\n\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeTXT, \"heritage=external-dns,external-dns/owner=default\"),\n\t\tendpoint.NewEndpointWithTTL(\"nginx.example.com\", endpoint.RecordTypeA, 3600, \"123.123.123.123\", \"234.234.234.234\"),\n\t\tendpoint.NewEndpointWithTTL(\"nginx.example.com\", endpoint.RecordTypeAAAA, 3600, \"2001::123:123:123:123\", \"2001::234:234:234:234\"),\n\t\tendpoint.NewEndpointWithTTL(\"cloud-ttl.example.com\", endpoint.RecordTypeNS, 10, \"ns1-ttl.example.com.\", \"ns2-ttl.example.com.\"),\n\t\tendpoint.NewEndpointWithTTL(\"nginx.example.com\", endpoint.RecordTypeTXT, recordTTL, \"heritage=external-dns,external-dns/owner=default\"),\n\t\tendpoint.NewEndpointWithTTL(\"hack.example.com\", endpoint.RecordTypeCNAME, 10, \"hack.azurewebsites.net\"),\n\t\tendpoint.NewEndpointWithTTL(\"mail.example.com\", endpoint.RecordTypeMX, 4000, \"10 example.com\", \"20 backup.example.com\"),\n\t}\n\n\tvalidateAzureEndpoints(t, actual, expected)\n}\n\nfunc TestAzureApplyChanges(t *testing.T) {\n\trecordsClient := mockRecordSetsClient{}\n\n\ttestAzureApplyChangesInternal(t, false, &recordsClient)\n\n\tvalidateAzureEndpoints(t, recordsClient.deletedEndpoints, []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"deleted.example.com\", endpoint.RecordTypeA, \"\"),\n\t\tendpoint.NewEndpoint(\"deletedaaaa.example.com\", endpoint.RecordTypeAAAA, \"\"),\n\t\tendpoint.NewEndpoint(\"deletedcname.example.com\", endpoint.RecordTypeCNAME, \"\"),\n\t\tendpoint.NewEndpoint(\"deletedns.example.com\", endpoint.RecordTypeNS, \"\"),\n\t})\n\n\tvalidateAzureEndpoints(t, recordsClient.updatedEndpoints, []*endpoint.Endpoint{\n\t\tendpoint.NewEndpointWithTTL(\"example.com\", endpoint.RecordTypeA, endpoint.TTL(recordTTL), \"1.2.3.4\"),\n\t\tendpoint.NewEndpointWithTTL(\"example.com\", endpoint.RecordTypeAAAA, endpoint.TTL(recordTTL), \"2001::1:2:3:4\"),\n\t\tendpoint.NewEndpointWithTTL(\"example.com\", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), \"tag\"),\n\t\tendpoint.NewEndpointWithTTL(\"foo.example.com\", endpoint.RecordTypeA, endpoint.TTL(recordTTL), \"1.2.3.4\", \"1.2.3.5\"),\n\t\tendpoint.NewEndpointWithTTL(\"foo.example.com\", endpoint.RecordTypeAAAA, endpoint.TTL(recordTTL), \"2001::1:2:3:4\", \"2001::1:2:3:5\"),\n\t\tendpoint.NewEndpointWithTTL(\"cloud.example.com\", endpoint.RecordTypeNS, endpoint.TTL(recordTTL), \"ns1.example.com.\"),\n\t\tendpoint.NewEndpointWithTTL(\"foo.example.com\", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), \"tag\"),\n\t\tendpoint.NewEndpointWithTTL(\"bar.example.com\", endpoint.RecordTypeCNAME, endpoint.TTL(recordTTL), \"other.com\"),\n\t\tendpoint.NewEndpointWithTTL(\"bar.example.com\", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), \"tag\"),\n\t\tendpoint.NewEndpointWithTTL(\"other.com\", endpoint.RecordTypeA, endpoint.TTL(recordTTL), \"5.6.7.8\"),\n\t\tendpoint.NewEndpointWithTTL(\"other.com\", endpoint.RecordTypeAAAA, endpoint.TTL(recordTTL), \"2001::5:6:7:8\"),\n\t\tendpoint.NewEndpointWithTTL(\"cloud.other.com\", endpoint.RecordTypeNS, endpoint.TTL(recordTTL), \"ns2.other.com.\"),\n\t\tendpoint.NewEndpointWithTTL(\"other.com\", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), \"tag\"),\n\t\tendpoint.NewEndpointWithTTL(\"new.example.com\", endpoint.RecordTypeA, 3600, \"111.222.111.222\"),\n\t\tendpoint.NewEndpointWithTTL(\"new.example.com\", endpoint.RecordTypeAAAA, 3600, \"2001::111:222:111:222\"),\n\t\tendpoint.NewEndpointWithTTL(\"newcname.example.com\", endpoint.RecordTypeCNAME, 10, \"other.com\"),\n\t\tendpoint.NewEndpointWithTTL(\"newns.example.com\", endpoint.RecordTypeNS, 10, \"ns1.example.com.\"),\n\t\tendpoint.NewEndpointWithTTL(\"newmail.example.com\", endpoint.RecordTypeMX, 7200, \"40 bar.other.com\"),\n\t\tendpoint.NewEndpointWithTTL(\"mail.example.com\", endpoint.RecordTypeMX, endpoint.TTL(recordTTL), \"10 other.com\"),\n\t\tendpoint.NewEndpointWithTTL(\"mail.example.com\", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), \"tag\"),\n\t})\n}\n\nfunc TestAzureApplyChangesDryRun(t *testing.T) {\n\trecordsClient := mockRecordSetsClient{}\n\n\ttestAzureApplyChangesInternal(t, true, &recordsClient)\n\n\tvalidateAzureEndpoints(t, recordsClient.deletedEndpoints, []*endpoint.Endpoint{})\n\n\tvalidateAzureEndpoints(t, recordsClient.updatedEndpoints, []*endpoint.Endpoint{})\n}\n\nfunc testAzureApplyChangesInternal(t *testing.T, dryRun bool, client RecordSetsClient) {\n\tzones := []*dns.Zone{\n\t\tcreateMockZone(\"example.com\", \"/dnszones/example.com\"),\n\t\tcreateMockZone(\"other.com\", \"/dnszones/other.com\"),\n\t}\n\tzonesClient := newMockZonesClient(zones)\n\n\tprovider := newAzureProvider(\n\t\tendpoint.NewDomainFilter([]string{\"\"}),\n\t\tendpoint.NewDomainFilter([]string{\"\"}),\n\t\tprovider.NewZoneIDFilter([]string{\"\"}),\n\t\tdryRun,\n\t\t\"group\",\n\t\t\"\",\n\t\t\"\",\n\t\t&zonesClient,\n\t\tclient,\n\t\t3,\n\t)\n\n\tcreateRecords := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeA, \"1.2.3.4\"),\n\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeAAAA, \"2001::1:2:3:4\"),\n\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeTXT, \"tag\"),\n\t\tendpoint.NewEndpoint(\"foo.example.com\", endpoint.RecordTypeA, \"1.2.3.5\", \"1.2.3.4\"),\n\t\tendpoint.NewEndpoint(\"foo.example.com\", endpoint.RecordTypeAAAA, \"2001::1:2:3:5\", \"2001::1:2:3:4\"),\n\t\tendpoint.NewEndpoint(\"cloud.example.com\", endpoint.RecordTypeNS, \"ns1.example.com.\"),\n\t\tendpoint.NewEndpoint(\"foo.example.com\", endpoint.RecordTypeTXT, \"tag\"),\n\t\tendpoint.NewEndpoint(\"bar.example.com\", endpoint.RecordTypeCNAME, \"other.com\"),\n\t\tendpoint.NewEndpoint(\"bar.example.com\", endpoint.RecordTypeTXT, \"tag\"),\n\t\tendpoint.NewEndpoint(\"other.com\", endpoint.RecordTypeA, \"5.6.7.8\"),\n\t\tendpoint.NewEndpoint(\"other.com\", endpoint.RecordTypeAAAA, \"2001::5:6:7:8\"),\n\t\tendpoint.NewEndpoint(\"other.com\", endpoint.RecordTypeTXT, \"tag\"),\n\t\tendpoint.NewEndpoint(\"cloud.other.com\", endpoint.RecordTypeNS, \"ns2.other.com.\"),\n\t\tendpoint.NewEndpoint(\"nope.com\", endpoint.RecordTypeA, \"4.4.4.4\"),\n\t\tendpoint.NewEndpoint(\"nope.com\", endpoint.RecordTypeAAAA, \"2001::4:4:4:4\"),\n\t\tendpoint.NewEndpoint(\"cloud.nope.com\", endpoint.RecordTypeNS, \"ns1.nope.com.\"),\n\t\tendpoint.NewEndpoint(\"nope.com\", endpoint.RecordTypeTXT, \"tag\"),\n\t\tendpoint.NewEndpoint(\"mail.example.com\", endpoint.RecordTypeMX, \"10 other.com\"),\n\t\tendpoint.NewEndpoint(\"mail.example.com\", endpoint.RecordTypeTXT, \"tag\"),\n\t}\n\n\tcurrentRecords := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"old.example.com\", endpoint.RecordTypeA, \"121.212.121.212\"),\n\t\tendpoint.NewEndpoint(\"oldcname.example.com\", endpoint.RecordTypeCNAME, \"other.com\"),\n\t\tendpoint.NewEndpoint(\"oldcloud.example.com\", endpoint.RecordTypeNS, \"ns1.example.com.\"),\n\t\tendpoint.NewEndpoint(\"old.nope.com\", endpoint.RecordTypeA, \"121.212.121.212\"),\n\t\tendpoint.NewEndpoint(\"oldmail.example.com\", endpoint.RecordTypeMX, \"20 foo.other.com\"),\n\t}\n\tupdatedRecords := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpointWithTTL(\"new.example.com\", endpoint.RecordTypeA, 3600, \"111.222.111.222\"),\n\t\tendpoint.NewEndpointWithTTL(\"new.example.com\", endpoint.RecordTypeAAAA, 3600, \"2001::111:222:111:222\"),\n\t\tendpoint.NewEndpointWithTTL(\"newcname.example.com\", endpoint.RecordTypeCNAME, 10, \"other.com\"),\n\t\tendpoint.NewEndpointWithTTL(\"newns.example.com\", endpoint.RecordTypeNS, 10, \"ns1.example.com.\"),\n\t\tendpoint.NewEndpoint(\"new.nope.com\", endpoint.RecordTypeA, \"222.111.222.111\"),\n\t\tendpoint.NewEndpoint(\"new.nope.com\", endpoint.RecordTypeAAAA, \"2001::222:111:222:111\"),\n\t\tendpoint.NewEndpoint(\"newns.nope.com\", endpoint.RecordTypeNS, \"ns1.example.com\"),\n\t\tendpoint.NewEndpointWithTTL(\"newmail.example.com\", endpoint.RecordTypeMX, 7200, \"40 bar.other.com\"),\n\t}\n\n\tdeleteRecords := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"deleted.example.com\", endpoint.RecordTypeA, \"111.222.111.222\"),\n\t\tendpoint.NewEndpoint(\"deletedaaaa.example.com\", endpoint.RecordTypeAAAA, \"2001::111:222:111:222\"),\n\t\tendpoint.NewEndpoint(\"deletedcname.example.com\", endpoint.RecordTypeCNAME, \"other.com\"),\n\t\tendpoint.NewEndpoint(\"deletedns.example.com\", endpoint.RecordTypeNS, \"ns1.example.com.\"),\n\t\tendpoint.NewEndpoint(\"deleted.nope.com\", endpoint.RecordTypeA, \"222.111.222.111\"),\n\t\tendpoint.NewEndpoint(\"deleted.nope.com\", endpoint.RecordTypeAAAA, \"2001::222:111:222:111\"),\n\t\tendpoint.NewEndpoint(\"deletedns.nope.com\", endpoint.RecordTypeNS, \"ns1.example.com.\"),\n\t}\n\n\tchanges := &plan.Changes{\n\t\tCreate:    createRecords,\n\t\tUpdateNew: updatedRecords,\n\t\tUpdateOld: currentRecords,\n\t\tDelete:    deleteRecords,\n\t}\n\n\tif err := provider.ApplyChanges(t.Context(), changes); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestAzureNameFilter(t *testing.T) {\n\tprovider := newMockedAzureProvider(endpoint.NewDomainFilter([]string{\"nginx.example.com\"}), endpoint.NewDomainFilter([]string{\"example.com\"}), provider.NewZoneIDFilter([]string{\"\"}), true, \"k8s\", \"\", \"\",\n\t\t[]*dns.Zone{\n\t\t\tcreateMockZone(\"example.com\", \"/dnszones/example.com\"),\n\t\t},\n\n\t\t[]*dns.RecordSet{\n\t\t\tcreateMockRecordSet(\"@\", \"NS\", \"ns1-03.azure-dns.com.\"),\n\t\t\tcreateMockRecordSet(\"@\", \"SOA\", \"Email: azuredns-hostmaster.microsoft.com\"),\n\t\t\tcreateMockRecordSet(\"@\", endpoint.RecordTypeA, \"123.123.123.122\"),\n\t\t\tcreateMockRecordSet(\"@\", endpoint.RecordTypeTXT, \"heritage=external-dns,external-dns/owner=default\"),\n\t\t\tcreateMockRecordSetWithTTL(\"test.nginx\", endpoint.RecordTypeA, \"123.123.123.123\", 3600),\n\t\t\tcreateMockRecordSetWithTTL(\"nginx\", endpoint.RecordTypeA, \"123.123.123.123\", 3600),\n\t\t\tcreateMockRecordSetWithTTL(\"nginx\", endpoint.RecordTypeNS, \"ns1.example.com.\", 3600),\n\t\t\tcreateMockRecordSetWithTTL(\"nginx\", endpoint.RecordTypeTXT, \"heritage=external-dns,external-dns/owner=default\", recordTTL),\n\t\t\tcreateMockRecordSetWithTTL(\"mail.nginx\", endpoint.RecordTypeMX, \"20 example.com\", recordTTL),\n\t\t\tcreateMockRecordSetWithTTL(\"hack\", endpoint.RecordTypeCNAME, \"hack.azurewebsites.net\", 10),\n\t\t\tcreateMockRecordSetWithTTL(\"hack\", endpoint.RecordTypeNS, \"ns1.example.com.\", 3600),\n\t\t}, 3)\n\n\tctx := t.Context()\n\tactual, err := provider.Records(ctx)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\texpected := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpointWithTTL(\"test.nginx.example.com\", endpoint.RecordTypeA, 3600, \"123.123.123.123\"),\n\t\tendpoint.NewEndpointWithTTL(\"nginx.example.com\", endpoint.RecordTypeA, 3600, \"123.123.123.123\"),\n\t\tendpoint.NewEndpointWithTTL(\"nginx.example.com\", endpoint.RecordTypeNS, 3600, \"ns1.example.com.\"),\n\t\tendpoint.NewEndpointWithTTL(\"nginx.example.com\", endpoint.RecordTypeTXT, recordTTL, \"heritage=external-dns,external-dns/owner=default\"),\n\t\tendpoint.NewEndpointWithTTL(\"mail.nginx.example.com\", endpoint.RecordTypeMX, recordTTL, \"20 example.com\"),\n\t}\n\n\tvalidateAzureEndpoints(t, actual, expected)\n}\n\nfunc TestAzureApplyChangesZoneName(t *testing.T) {\n\trecordsClient := mockRecordSetsClient{}\n\n\ttestAzureApplyChangesInternalZoneName(t, false, &recordsClient)\n\n\tvalidateAzureEndpoints(t, recordsClient.deletedEndpoints, []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"deleted.foo.example.com\", endpoint.RecordTypeA, \"\"),\n\t\tendpoint.NewEndpoint(\"deletedaaaa.foo.example.com\", endpoint.RecordTypeAAAA, \"\"),\n\t\tendpoint.NewEndpoint(\"deletedcname.foo.example.com\", endpoint.RecordTypeCNAME, \"\"),\n\t\tendpoint.NewEndpoint(\"deletedns.foo.example.com\", endpoint.RecordTypeNS, \"\"),\n\t})\n\n\tvalidateAzureEndpoints(t, recordsClient.updatedEndpoints, []*endpoint.Endpoint{\n\t\tendpoint.NewEndpointWithTTL(\"foo.example.com\", endpoint.RecordTypeA, endpoint.TTL(recordTTL), \"1.2.3.4\", \"1.2.3.5\"),\n\t\tendpoint.NewEndpointWithTTL(\"foo.example.com\", endpoint.RecordTypeAAAA, endpoint.TTL(recordTTL), \"2001::1:2:3:4\", \"2001::1:2:3:5\"),\n\t\tendpoint.NewEndpointWithTTL(\"foo.example.com\", endpoint.RecordTypeNS, endpoint.TTL(recordTTL), \"ns1.example.com.\"),\n\t\tendpoint.NewEndpointWithTTL(\"foo.example.com\", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), \"tag\"),\n\t\tendpoint.NewEndpointWithTTL(\"new.foo.example.com\", endpoint.RecordTypeA, 3600, \"111.222.111.222\"),\n\t\tendpoint.NewEndpointWithTTL(\"new.foo.example.com\", endpoint.RecordTypeAAAA, 3600, \"2001::111:222:111:222\"),\n\t\tendpoint.NewEndpointWithTTL(\"newns.foo.example.com\", endpoint.RecordTypeNS, 10, \"ns1.foo.example.com.\"),\n\t\tendpoint.NewEndpointWithTTL(\"newcname.foo.example.com\", endpoint.RecordTypeCNAME, 10, \"other.com\"),\n\t})\n}\n\nfunc testAzureApplyChangesInternalZoneName(t *testing.T, dryRun bool, client RecordSetsClient) {\n\tzonesClient := newMockZonesClient([]*dns.Zone{createMockZone(\"example.com\", \"/dnszones/example.com\")})\n\n\tprovider := newAzureProvider(\n\t\tendpoint.NewDomainFilter([]string{\"foo.example.com\"}),\n\t\tendpoint.NewDomainFilter([]string{\"example.com\"}),\n\t\tprovider.NewZoneIDFilter([]string{\"\"}),\n\t\tdryRun,\n\t\t\"group\",\n\t\t\"\",\n\t\t\"\",\n\t\t&zonesClient,\n\t\tclient,\n\t\t3,\n\t)\n\n\tcreateRecords := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeA, \"1.2.3.4\"),\n\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeAAAA, \"2001::1:2:3:4\"),\n\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeTXT, \"tag\"),\n\t\tendpoint.NewEndpoint(\"foo.example.com\", endpoint.RecordTypeA, \"1.2.3.5\", \"1.2.3.4\"),\n\t\tendpoint.NewEndpoint(\"foo.example.com\", endpoint.RecordTypeAAAA, \"2001::1:2:3:5\", \"2001::1:2:3:4\"),\n\t\tendpoint.NewEndpoint(\"foo.example.com\", endpoint.RecordTypeNS, \"ns1.example.com.\"),\n\t\tendpoint.NewEndpoint(\"foo.example.com\", endpoint.RecordTypeTXT, \"tag\"),\n\t\tendpoint.NewEndpoint(\"bar.example.com\", endpoint.RecordTypeCNAME, \"other.com\"),\n\t\tendpoint.NewEndpoint(\"barns.example.com\", endpoint.RecordTypeNS, \"ns1.example.com.\"),\n\t\tendpoint.NewEndpoint(\"bar.example.com\", endpoint.RecordTypeTXT, \"tag\"),\n\t\tendpoint.NewEndpoint(\"other.com\", endpoint.RecordTypeA, \"5.6.7.8\"),\n\t\tendpoint.NewEndpoint(\"foons.other.com\", endpoint.RecordTypeNS, \"ns1.other.com\"),\n\t\tendpoint.NewEndpoint(\"other.com\", endpoint.RecordTypeTXT, \"tag\"),\n\t\tendpoint.NewEndpoint(\"nope.com\", endpoint.RecordTypeA, \"4.4.4.4\"),\n\t\tendpoint.NewEndpoint(\"nope.com\", endpoint.RecordTypeTXT, \"tag\"),\n\t}\n\n\tcurrentRecords := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"old.foo.example.com\", endpoint.RecordTypeA, \"121.212.121.212\"),\n\t\tendpoint.NewEndpoint(\"oldcname.foo.example.com\", endpoint.RecordTypeCNAME, \"other.com\"),\n\t\tendpoint.NewEndpoint(\"old.nope.example.com\", endpoint.RecordTypeA, \"121.212.121.212\"),\n\t}\n\tupdatedRecords := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpointWithTTL(\"new.foo.example.com\", endpoint.RecordTypeA, 3600, \"111.222.111.222\"),\n\t\tendpoint.NewEndpointWithTTL(\"new.foo.example.com\", endpoint.RecordTypeAAAA, 3600, \"2001::111:222:111:222\"),\n\t\tendpoint.NewEndpointWithTTL(\"newcname.foo.example.com\", endpoint.RecordTypeCNAME, 10, \"other.com\"),\n\t\tendpoint.NewEndpointWithTTL(\"newns.foo.example.com\", endpoint.RecordTypeNS, 10, \"ns1.foo.example.com.\"),\n\t\tendpoint.NewEndpoint(\"new.nope.example.com\", endpoint.RecordTypeA, \"222.111.222.111\"),\n\t\tendpoint.NewEndpoint(\"new.nope.example.com\", endpoint.RecordTypeAAAA, \"2001::222:111:222:111\"),\n\t\tendpoint.NewEndpointWithTTL(\"newns.nope.example.com\", endpoint.RecordTypeNS, 10, \"ns1.nope.example.com.\"),\n\t}\n\n\tdeleteRecords := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"deleted.foo.example.com\", endpoint.RecordTypeA, \"111.222.111.222\"),\n\t\tendpoint.NewEndpoint(\"deletedaaaa.foo.example.com\", endpoint.RecordTypeAAAA, \"2001::111:222:111:222\"),\n\t\tendpoint.NewEndpoint(\"deletedcname.foo.example.com\", endpoint.RecordTypeCNAME, \"other.com\"),\n\t\tendpoint.NewEndpoint(\"deletedns.foo.example.com\", endpoint.RecordTypeNS, \"ns1.foo.example.com.\"),\n\t\tendpoint.NewEndpoint(\"deleted.nope.example.com\", endpoint.RecordTypeA, \"222.111.222.111\"),\n\t}\n\n\tchanges := &plan.Changes{\n\t\tCreate:    createRecords,\n\t\tUpdateNew: updatedRecords,\n\t\tUpdateOld: currentRecords,\n\t\tDelete:    deleteRecords,\n\t}\n\n\tif err := provider.ApplyChanges(t.Context(), changes); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n"
  },
  {
    "path": "provider/azure/common.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\n//nolint:staticcheck // Required due to the current dependency on a deprecated version of azure-sdk-for-go\npackage azure\n\nimport (\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/Azure/azure-sdk-for-go/sdk/azcore/to\"\n\tdns \"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns\"\n\tprivatedns \"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns\"\n)\n\n// Helper function (shared with test code)\nfunc parseMxTarget[T dns.MxRecord | privatedns.MxRecord](mxTarget string) (T, error) {\n\ttargetParts := strings.SplitN(mxTarget, \" \", 2)\n\tif len(targetParts) != 2 {\n\t\treturn T{}, fmt.Errorf(\"mx target needs to be of form '10 example.com'\")\n\t}\n\n\tpreferenceRaw, exchange := targetParts[0], targetParts[1]\n\tpreference, err := strconv.ParseInt(preferenceRaw, 10, 32)\n\tif err != nil {\n\t\treturn T{}, fmt.Errorf(\"invalid preference specified\")\n\t}\n\n\treturn T{\n\t\tPreference: to.Ptr(int32(preference)),\n\t\tExchange:   to.Ptr(exchange),\n\t}, nil\n}\n"
  },
  {
    "path": "provider/azure/common_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage azure\n\nimport (\n\t\"fmt\"\n\t\"testing\"\n\n\t\"github.com/Azure/azure-sdk-for-go/sdk/azcore/to\"\n\tdns \"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns\"\n\tprivatedns \"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc Test_parseMxTarget(t *testing.T) {\n\ttype testCase[T interface {\n\t\tdns.MxRecord | privatedns.MxRecord\n\t}] struct {\n\t\tname    string\n\t\targs    string\n\t\twant    T\n\t\twantErr assert.ErrorAssertionFunc\n\t}\n\n\ttests := []testCase[dns.MxRecord]{\n\t\t{\n\t\t\tname: \"valid mx target\",\n\t\t\targs: \"10 example.com\",\n\t\t\twant: dns.MxRecord{\n\t\t\t\tPreference: to.Ptr(int32(10)),\n\t\t\t\tExchange:   to.Ptr(\"example.com\"),\n\t\t\t},\n\t\t\twantErr: assert.NoError,\n\t\t},\n\t\t{\n\t\t\tname: \"valid mx target with a subdomain\",\n\t\t\targs: \"99 foo-bar.example.com\",\n\t\t\twant: dns.MxRecord{\n\t\t\t\tPreference: to.Ptr(int32(99)),\n\t\t\t\tExchange:   to.Ptr(\"foo-bar.example.com\"),\n\t\t\t},\n\t\t\twantErr: assert.NoError,\n\t\t},\n\t\t{\n\t\t\tname:    \"invalid mx target with misplaced preference and exchange\",\n\t\t\targs:    \"example.com 10\",\n\t\t\twant:    dns.MxRecord{},\n\t\t\twantErr: assert.Error,\n\t\t},\n\t\t{\n\t\t\tname:    \"invalid mx target without preference\",\n\t\t\targs:    \"example.com\",\n\t\t\twant:    dns.MxRecord{},\n\t\t\twantErr: assert.Error,\n\t\t},\n\t\t{\n\t\t\tname:    \"invalid mx target with non numeric preference\",\n\t\t\targs:    \"aa example.com\",\n\t\t\twant:    dns.MxRecord{},\n\t\t\twantErr: assert.Error,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, err := parseMxTarget[dns.MxRecord](tt.args)\n\t\t\tif !tt.wantErr(t, err, fmt.Sprintf(\"parseMxTarget(%v)\", tt.args)) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tassert.Equalf(t, tt.want, got, \"parseMxTarget(%v)\", tt.args)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "provider/azure/config.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage azure\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/Azure/azure-sdk-for-go/sdk/azcore\"\n\t\"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm\"\n\t\"github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud\"\n\t\"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy\"\n\t\"github.com/Azure/azure-sdk-for-go/sdk/azidentity\"\n\t\"github.com/google/uuid\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// config represents common config items for Azure DNS and Azure Private DNS\ntype config struct {\n\tCloud                        string `json:\"cloud\"                        yaml:\"cloud\"`\n\tTenantID                     string `json:\"tenantId\"                     yaml:\"tenantId\"`\n\tSubscriptionID               string `json:\"subscriptionId\"               yaml:\"subscriptionId\"`\n\tResourceGroup                string `json:\"resourceGroup\"                yaml:\"resourceGroup\"`\n\tLocation                     string `json:\"location\"                     yaml:\"location\"`\n\tClientID                     string `json:\"aadClientId\"                  yaml:\"aadClientId\"`\n\tClientSecret                 string `json:\"aadClientSecret\"              yaml:\"aadClientSecret\"`\n\tUseManagedIdentityExtension  bool   `json:\"useManagedIdentityExtension\"  yaml:\"useManagedIdentityExtension\"`\n\tUseWorkloadIdentityExtension bool   `json:\"useWorkloadIdentityExtension\" yaml:\"useWorkloadIdentityExtension\"`\n\tUserAssignedIdentityID       string `json:\"userAssignedIdentityID\"       yaml:\"userAssignedIdentityID\"`\n\tActiveDirectoryAuthorityHost string `json:\"activeDirectoryAuthorityHost\" yaml:\"activeDirectoryAuthorityHost\"`\n\tResourceManagerAudience      string `json:\"resourceManagerAudience\"      yaml:\"resourceManagerAudience\"`\n\tResourceManagerEndpoint      string `json:\"resourceManagerEndpoint\"      yaml:\"resourceManagerEndpoint\"`\n}\n\nfunc getConfig(configFile, subscriptionID, resourceGroup, userAssignedIdentityClientID, activeDirectoryAuthorityHost string) (*config, error) {\n\tcontents, err := os.ReadFile(configFile)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to read Azure config file '%s': %w\", configFile, err)\n\t}\n\tcfg := &config{}\n\tif err := json.Unmarshal(contents, &cfg); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse Azure config file '%s': %w\", configFile, err)\n\t}\n\t// If a subscription ID was given, override what was present in the config file\n\tif subscriptionID != \"\" {\n\t\tcfg.SubscriptionID = subscriptionID\n\t}\n\t// If a resource group was given, override what was present in the config file\n\tif resourceGroup != \"\" {\n\t\tcfg.ResourceGroup = resourceGroup\n\t}\n\t// If userAssignedIdentityClientID is provided explicitly, override existing one in config file\n\tif userAssignedIdentityClientID != \"\" {\n\t\tcfg.UserAssignedIdentityID = userAssignedIdentityClientID\n\t}\n\t// If activeDirectoryAuthorityHost is provided explicitly, override existing one in config file\n\tif activeDirectoryAuthorityHost != \"\" {\n\t\tcfg.ActiveDirectoryAuthorityHost = activeDirectoryAuthorityHost\n\t}\n\treturn cfg, nil\n}\n\n// ctxKey is a type for context keys\n// This is used to avoid collisions with other packages that may use the same key in the context.\ntype ctxKey string\n\nconst (\n\t// Context key for request ID\n\tclientRequestIDKey ctxKey = \"client-request-id\"\n\t// Azure API Headers\n\tmsRequestIDHeader          = \"x-ms-request-id\"\n\tmsCorrelationRequestHeader = \"x-ms-correlation-request-id\"\n\tmsClientRequestIDHeader    = \"x-ms-client-request-id\"\n)\n\n// customHeaderPolicy adds UUID to request headers\ntype customHeaderPolicy struct{}\n\nfunc (p *customHeaderPolicy) Do(req *policy.Request) (*http.Response, error) {\n\tid := req.Raw().Header.Get(msClientRequestIDHeader)\n\tif id == \"\" {\n\t\tid = uuid.New().String()\n\t\treq.Raw().Header.Set(msClientRequestIDHeader, id)\n\t\tnewCtx := context.WithValue(req.Raw().Context(), clientRequestIDKey, id)\n\t\t*req.Raw() = *req.Raw().WithContext(newCtx)\n\t}\n\treturn req.Next()\n}\nfunc CustomHeaderPolicynew() policy.Policy { return &customHeaderPolicy{} }\n\n// getCredentials retrieves Azure API credentials.\nfunc getCredentials(cfg config, maxRetries int) (azcore.TokenCredential, *arm.ClientOptions, error) {\n\tcloudCfg, err := getCloudConfiguration(cfg)\n\tif err != nil {\n\t\treturn nil, nil, fmt.Errorf(\"failed to get cloud configuration: %w\", err)\n\t}\n\tclientOpts := azcore.ClientOptions{\n\t\tCloud: cloudCfg,\n\t\tRetry: policy.RetryOptions{\n\t\t\tMaxRetries: int32(maxRetries),\n\t\t},\n\t\tLogging: policy.LogOptions{\n\t\t\tAllowedHeaders: []string{\n\t\t\t\tmsRequestIDHeader,\n\t\t\t\tmsCorrelationRequestHeader,\n\t\t\t\tmsClientRequestIDHeader,\n\t\t\t},\n\t\t},\n\t\tPerCallPolicies: []policy.Policy{\n\t\t\tCustomHeaderPolicynew(),\n\t\t},\n\t}\n\tlog.Debugf(\"Configured Azure client with maxRetries: %d\", clientOpts.Retry.MaxRetries)\n\tarmClientOpts := &arm.ClientOptions{\n\t\tClientOptions: clientOpts,\n\t}\n\n\t// Try to retrieve token with service principal credentials.\n\t// Try to use service principal first, some AKS clusters are in an intermediate state that `UseManagedIdentityExtension` is `true`\n\t// and service principal exists. In this case, we still want to use service principal to authenticate.\n\tif len(cfg.ClientID) > 0 &&\n\t\tlen(cfg.ClientSecret) > 0 &&\n\t\t// due to some historical reason, for pure MSI cluster,\n\t\t// they will use \"msi\" as placeholder in azure.json.\n\t\t// In this case, we shouldn't try to use SPN to authenticate.\n\t\t!strings.EqualFold(cfg.ClientID, \"msi\") &&\n\t\t!strings.EqualFold(cfg.ClientSecret, \"msi\") {\n\t\tlog.Info(\"Using client_id+client_secret to retrieve access token for Azure API.\")\n\t\topts := &azidentity.ClientSecretCredentialOptions{\n\t\t\tClientOptions: clientOpts,\n\t\t}\n\t\tcred, err := azidentity.NewClientSecretCredential(cfg.TenantID, cfg.ClientID, cfg.ClientSecret, opts)\n\t\tif err != nil {\n\t\t\treturn nil, nil, fmt.Errorf(\"failed to create service principal token: %w\", err)\n\t\t}\n\t\treturn cred, armClientOpts, nil\n\t}\n\n\t// Try to retrieve token with Workload Identity.\n\tif cfg.UseWorkloadIdentityExtension {\n\t\tlog.Info(\"Using workload identity extension to retrieve access token for Azure API.\")\n\n\t\twiOpt := azidentity.WorkloadIdentityCredentialOptions{\n\t\t\tClientOptions: clientOpts,\n\t\t\t// In a standard scenario, Client ID and Tenant ID are expected to be read from environment variables.\n\t\t\t// Though, in certain cases, it might be important to have an option to override those (e.g. when AZURE_TENANT_ID is not set\n\t\t\t// through a webhook or azure.workload.identity/client-id service account annotation is absent). When any of those values are\n\t\t\t// empty in our config, they will automatically be read from environment variables by azidentity\n\t\t\tTenantID: cfg.TenantID,\n\t\t\tClientID: cfg.ClientID,\n\t\t}\n\n\t\tcred, err := azidentity.NewWorkloadIdentityCredential(&wiOpt)\n\t\tif err != nil {\n\t\t\treturn nil, nil, fmt.Errorf(\"failed to create a workload identity token: %w\", err)\n\t\t}\n\n\t\treturn cred, armClientOpts, nil\n\t}\n\n\t// Try to retrieve token with MSI.\n\tif cfg.UseManagedIdentityExtension {\n\t\tlog.Info(\"Using managed identity extension to retrieve access token for Azure API.\")\n\t\tmsiOpt := azidentity.ManagedIdentityCredentialOptions{\n\t\t\tClientOptions: clientOpts,\n\t\t}\n\t\tif cfg.UserAssignedIdentityID != \"\" {\n\t\t\tmsiOpt.ID = azidentity.ClientID(cfg.UserAssignedIdentityID)\n\t\t}\n\t\tcred, err := azidentity.NewManagedIdentityCredential(&msiOpt)\n\t\tif err != nil {\n\t\t\treturn nil, nil, fmt.Errorf(\"failed to create the managed service identity token: %w\", err)\n\t\t}\n\t\treturn cred, armClientOpts, nil\n\t}\n\n\treturn nil, nil, fmt.Errorf(\"no credentials provided for Azure API\")\n}\n\nfunc getCloudConfiguration(cfg config) (cloud.Configuration, error) {\n\tname := strings.ToUpper(cfg.Cloud)\n\tswitch name {\n\tcase \"AZURECLOUD\", \"AZUREPUBLICCLOUD\", \"\":\n\t\treturn cloud.AzurePublic, nil\n\tcase \"AZUREUSGOVERNMENT\", \"AZUREUSGOVERNMENTCLOUD\":\n\t\treturn cloud.AzureGovernment, nil\n\tcase \"AZURECHINACLOUD\":\n\t\treturn cloud.AzureChina, nil\n\tcase \"AZURESTACKCLOUD\":\n\t\treturn cloud.Configuration{\n\t\t\tActiveDirectoryAuthorityHost: cfg.ActiveDirectoryAuthorityHost,\n\t\t\tServices: map[cloud.ServiceName]cloud.ServiceConfiguration{\n\t\t\t\tcloud.ResourceManager: {\n\t\t\t\t\tAudience: cfg.ResourceManagerAudience,\n\t\t\t\t\tEndpoint: cfg.ResourceManagerEndpoint,\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil\n\t}\n\treturn cloud.Configuration{}, fmt.Errorf(\"unknown cloud name: %s\", name)\n}\n"
  },
  {
    "path": "provider/azure/config_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage azure\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"path\"\n\t\"runtime\"\n\t\"strconv\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud\"\n\t\"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy\"\n\tazruntime \"github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestGetCloudConfiguration(t *testing.T) {\n\ttests := map[string]struct {\n\t\tcloudName string\n\t\texpected  cloud.Configuration\n\t}{\n\t\t\"AzureChinaCloud\":   {\"AzureChinaCloud\", cloud.AzureChina},\n\t\t\"AzurePublicCloud\":  {\"\", cloud.AzurePublic},\n\t\t\"AzureUSGovernment\": {\"AzureUSGovernmentCloud\", cloud.AzureGovernment},\n\t}\n\n\tfor name, test := range tests {\n\t\tt.Run(name, func(t *testing.T) {\n\t\t\tcfg := config{Cloud: test.cloudName}\n\t\t\tcloudCfg, err := getCloudConfiguration(cfg)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"got unexpected err %v\", err)\n\t\t\t}\n\t\t\tif cloudCfg.ActiveDirectoryAuthorityHost != test.expected.ActiveDirectoryAuthorityHost {\n\t\t\t\tt.Errorf(\"got %v, want %v\", cloudCfg, test.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestOverrideConfiguration(t *testing.T) {\n\t_, filename, _, _ := runtime.Caller(0)\n\tconfigFile := path.Join(path.Dir(filename), \"fixtures/config_test.json\")\n\tcfg, err := getConfig(configFile, \"subscription-override\", \"rg-override\", \"\", \"aad-endpoint-override\")\n\tif err != nil {\n\t\tt.Errorf(\"got unexpected err %v\", err)\n\t}\n\tassert.Equal(t, \"subscription-override\", cfg.SubscriptionID)\n\tassert.Equal(t, \"rg-override\", cfg.ResourceGroup)\n\tassert.Equal(t, \"aad-endpoint-override\", cfg.ActiveDirectoryAuthorityHost)\n}\n\n// Test for custom header policy\ntype transportFunc func(*http.Request) (*http.Response, error)\n\nfunc (f transportFunc) Do(req *http.Request) (*http.Response, error) {\n\treturn f(req)\n}\n\nfunc TestCustomHeaderPolicyWithRetries(t *testing.T) {\n\t// Set up test environment\n\n\tflagValue := \"-6\"\n\n\tretries, err := parseMaxRetries(flagValue)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to parse retries: %v\", err)\n\t}\n\tmaxRetries := int32(retries)\n\t// Flag was provided with non-zero value\n\tmaxRetries = int32(retries)\n\tt.Logf(\"Using provided flag value: %d\", retries)\n\n\tvar attempt int32\n\tvar firstRequestID string\n\n\t// Create mock transport that simulates 429 responses\n\tmockTransport := transportFunc(func(req *http.Request) (*http.Response, error) {\n\t\tattempt++\n\n\t\t// Get the request ID from header\n\t\trequestID := req.Header.Get(\"x-ms-client-request-id\")\n\t\tif requestID == \"\" {\n\t\t\tt.Fatalf(\"Request ID missing on attempt %d\", attempt)\n\t\t}\n\n\t\t// On first attempt, store the request ID\n\t\tif attempt == 1 {\n\t\t\tfirstRequestID = requestID\n\t\t\tt.Logf(\"Initial request ID: %s\", firstRequestID)\n\t\t} else {\n\t\t\t// On subsequent attempts, verify it matches the first request ID\n\t\t\tif requestID != firstRequestID {\n\t\t\t\tt.Fatalf(\"Request ID changed on retry %d: got %s, want %s\",\n\t\t\t\t\tattempt, requestID, firstRequestID)\n\t\t\t} else {\n\t\t\t\tt.Logf(\"Request ID preserved on attempt %d: %s\", attempt, requestID)\n\t\t\t}\n\t\t}\n\n\t\t// Verify the ID is also in the context\n\t\tif ctxID, ok := req.Context().Value(clientRequestIDKey).(string); !ok || ctxID != requestID {\n\t\t\tt.Errorf(\"Context ID mismatch on attempt %d: got %v, want %s\",\n\t\t\t\tattempt, ctxID, requestID)\n\t\t}\n\n\t\t// Return 429 for all but the last attempt\n\t\tif maxRetries < 0 || attempt <= maxRetries {\n\t\t\tt.Logf(\"Attempt %d: THROTTLED (429) - Request ID: %s\", attempt, requestID)\n\t\t\treturn &http.Response{\n\t\t\t\tStatusCode: http.StatusTooManyRequests,\n\t\t\t\tBody:       io.NopCloser(strings.NewReader(\"Too many requests\")),\n\t\t\t\tRequest:    req,\n\t\t\t\tHeader: http.Header{\n\t\t\t\t\t\"x-ms-client-request-id\": []string{requestID},\n\t\t\t\t\t\"Retry-After\":            []string{\"1\"},\n\t\t\t\t},\n\t\t\t}, nil\n\t\t}\n\n\t\t// Return 200 on final attempt\n\t\tt.Logf(\"Attempt %d: SUCCESS (200) - Request ID: %s\", attempt, requestID)\n\t\treturn &http.Response{\n\t\t\tStatusCode: http.StatusOK,\n\t\t\tBody:       io.NopCloser(strings.NewReader(\"Success\")),\n\t\t\tRequest:    req,\n\t\t\tHeader: http.Header{\n\t\t\t\t\"x-ms-client-request-id\": []string{requestID},\n\t\t\t},\n\t\t}, nil\n\t})\n\n\t// Create pipeline with retry policy and custom header policy\n\tmockPipeline := azruntime.NewPipeline(\n\t\t\"testmodule\",\n\t\t\"1.0\",\n\t\tazruntime.PipelineOptions{\n\t\t\tPerCall: []policy.Policy{\n\t\t\t\tCustomHeaderPolicynew(),\n\t\t\t},\n\t\t},\n\t\t&policy.ClientOptions{\n\t\t\tRetry: policy.RetryOptions{\n\t\t\t\tMaxRetries: maxRetries,\n\t\t\t},\n\t\t\tTransport: mockTransport,\n\t\t},\n\t)\n\t// Create request and execute\n\treq, err := azruntime.NewRequest(t.Context(), http.MethodGet, \"https://example.com\")\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create request: %v\", err)\n\t}\n\n\tresp, err := mockPipeline.Do(req)\n\tif err != nil {\n\t\tt.Fatalf(\"Request failed: %v\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\t// Verify we got the expected number of attempts\n\tvar expectedAttempts int32\n\tif maxRetries < 0 {\n\t\texpectedAttempts = 1 // For negative retries, only one attempt should be made\n\t} else {\n\t\texpectedAttempts = maxRetries + 1 // For zero or positive retries, attempts = retries + 1\n\t}\n\n\tif attempt != expectedAttempts {\n\t\tt.Errorf(\"Wrong number of attempts: got %d, want %d\", attempt, expectedAttempts)\n\t}\n\n\tt.Logf(\"Test completed with %d attempts, all with request ID: %s\", attempt, firstRequestID)\n}\n\nfunc TestMaxRetriesCount(t *testing.T) {\n\tdefaultRetries := 3\n\n\ttests := []struct {\n\t\tname        string\n\t\tinput       string\n\t\tisSet       bool // indicates if flag was provided\n\t\texpected    int\n\t\tshouldError bool\n\t\tdescription string\n\t}{\n\t\t{\n\t\t\tname:        \"FlagNotProvided\",\n\t\t\tinput:       \"\",\n\t\t\tisSet:       false,\n\t\t\texpected:    defaultRetries,\n\t\t\tshouldError: false,\n\t\t\tdescription: \"When flag is not provided, should use default value\",\n\t\t},\n\t\t{\n\t\t\tname:        \"FlagProvidedEmpty\",\n\t\t\tinput:       \"\",\n\t\t\tisSet:       true,\n\t\t\texpected:    0,\n\t\t\tshouldError: true,\n\t\t\tdescription: \"When flag is provided but empty, should error\",\n\t\t},\n\t\t{\n\t\t\tname:        \"ValidPositive\",\n\t\t\tinput:       \"5\",\n\t\t\tisSet:       true,\n\t\t\texpected:    5,\n\t\t\tshouldError: false,\n\t\t\tdescription: \"Valid positive number should be accepted\",\n\t\t},\n\t\t{\n\t\t\tname:        \"ZeroRetries\",\n\t\t\tinput:       \"0\",\n\t\t\tisSet:       true,\n\t\t\texpected:    0,\n\t\t\tshouldError: false,\n\t\t\tdescription: \"Zero should be accepted and handled by SDK\",\n\t\t},\n\t\t{\n\t\t\tname:        \"NegativeRetries\",\n\t\t\tinput:       \"-2\",\n\t\t\tisSet:       true,\n\t\t\texpected:    -2,\n\t\t\tshouldError: false,\n\t\t\tdescription: \"Negative values should be accepted and  handled by SDK\",\n\t\t},\n\t\t{\n\t\t\tname:        \"InvalidString\",\n\t\t\tinput:       \"abc\",\n\t\t\tisSet:       true,\n\t\t\texpected:    0,\n\t\t\tshouldError: true,\n\t\t\tdescription: \"Non-numeric string should error\",\n\t\t},\n\t\t{\n\t\t\tname:        \"Whitespace\",\n\t\t\tinput:       \"   \",\n\t\t\tisSet:       true,\n\t\t\texpected:    0,\n\t\t\tshouldError: true,\n\t\t\tdescription: \"Whitespace should error\",\n\t\t},\n\t\t{\n\t\t\tname:        \"SpecialChars\",\n\t\t\tinput:       \"@#$%\",\n\t\t\tisSet:       true,\n\t\t\texpected:    0,\n\t\t\tshouldError: true,\n\t\t\tdescription: \"Special characters should error\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tt.Logf(\"=== Test Case: %s ===\", tt.name)\n\t\t\tt.Logf(\"Description: %s\", tt.description)\n\t\t\tt.Logf(\"Input: %q (flag provided: %v)\", tt.input, tt.isSet)\n\n\t\t\t// Handle flag not provided case\n\t\t\tif !tt.isSet {\n\t\t\t\tt.Logf(\"Using default value: %d\", defaultRetries)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tretries, err := parseMaxRetries(tt.input)\n\n\t\t\t// Check error condition\n\t\t\tif tt.shouldError {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Errorf(\"Expected error for input %q but got none\", tt.input)\n\t\t\t\t} else {\n\t\t\t\t\tt.Logf(\"Got expected error: %v\", err)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Errorf(\"Unexpected error: %v\", err)\n\t\t\t\t}\n\t\t\t\tif retries != tt.expected {\n\t\t\t\t\tt.Errorf(\"Got %d retries, want %d\", retries, tt.expected)\n\t\t\t\t} else {\n\t\t\t\t\tt.Logf(\"Got expected value: %d\", retries)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\n// Helper function to parse max retries value\nfunc parseMaxRetries(value string) (int, error) {\n\t// Trim whitespace\n\tvalue = strings.TrimSpace(value)\n\n\t// Empty string or whitespace should error\n\tif value == \"\" {\n\t\treturn 0, fmt.Errorf(\"retry count must be provided when flag is set\")\n\t}\n\n\tretries, err := strconv.Atoi(value)\n\tif err != nil {\n\t\treturn 0, fmt.Errorf(\"invalid retry count %q: %w\", value, err)\n\t}\n\n\treturn retries, nil\n}\n"
  },
  {
    "path": "provider/azure/fixtures/config_test.json",
    "content": "{\n  \"tenantId\": \"tenant\",\n  \"subscriptionId\": \"subscription\",\n  \"resourceGroup\": \"rg\",\n  \"aadClientId\": \"clientId\",\n  \"aadClientSecret\": \"clientSecret\"\n}\n"
  },
  {
    "path": "provider/blueprint/zone_cache.go",
    "content": "/*\nCopyright 2026 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage blueprint\n\nimport (\n\t\"sync\"\n\t\"time\"\n\n\tlog \"github.com/sirupsen/logrus\"\n)\n\n// ZoneCache is a generic cache for DNS zones with TTL-based expiration.\n// It can store any type of zone data and provides thread-safe access.\ntype ZoneCache[T any] struct {\n\tmu       sync.RWMutex\n\tage      time.Time\n\tduration time.Duration\n\tdata     T\n}\n\n// NewZoneCache creates a new ZoneCache with the specified TTL duration.\n// A duration of 0 or less disables caching: Reset becomes a no-op and Expired always returns true.\nfunc NewZoneCache[T any](duration time.Duration) *ZoneCache[T] {\n\treturn &ZoneCache[T]{duration: duration}\n}\n\n// Get returns the cached data. Returns the zero value if the cache has never been populated.\n// Data is not cleared on expiration; Get returns the last known value until Reset is called again.\nfunc (c *ZoneCache[T]) Get() T {\n\tc.mu.RLock()\n\tdefer c.mu.RUnlock()\n\treturn c.data\n}\n\n// Reset updates the cached data and refreshes the age timestamp.\n// Only updates if caching is enabled (duration > 0).\nfunc (c *ZoneCache[T]) Reset(data T) {\n\tif c.duration <= 0 {\n\t\treturn\n\t}\n\tc.mu.Lock()\n\tdefer c.mu.Unlock()\n\tc.data = data\n\tc.age = time.Now()\n\tlog.WithField(\"duration\", c.duration).Debug(\"zone cache reset\")\n}\n\n// Expired returns true if the cache is empty (never populated) or the TTL has elapsed since\n// the last Reset. When caching is disabled (duration <= 0), always returns true.\nfunc (c *ZoneCache[T]) Expired() bool {\n\tc.mu.RLock()\n\tdefer c.mu.RUnlock()\n\treturn c.age.IsZero() || time.Since(c.age) > c.duration\n}\n"
  },
  {
    "path": "provider/blueprint/zone_cache_test.go",
    "content": "/*\nCopyright 2026 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage blueprint\n\nimport (\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"testing/synctest\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestZoneCache_SliceCache(t *testing.T) {\n\tcache := NewZoneCache[[]string](time.Hour)\n\n\t// Initially expired (empty)\n\tassert.True(t, cache.Expired())\n\n\t// After reset, not expired\n\tcache.Reset([]string{\"zone1\", \"zone2\"})\n\tassert.False(t, cache.Expired())\n\tassert.Equal(t, []string{\"zone1\", \"zone2\"}, cache.Get())\n}\n\nfunc TestZoneCache_MapCache(t *testing.T) {\n\tcache := NewZoneCache[map[string]int](time.Hour)\n\n\t// Initially expired (empty)\n\tassert.True(t, cache.Expired())\n\n\t// After reset, not expired\n\tcache.Reset(map[string]int{\"a\": 1, \"b\": 2})\n\tassert.False(t, cache.Expired())\n\tassert.Equal(t, map[string]int{\"a\": 1, \"b\": 2}, cache.Get())\n}\n\nfunc TestZoneCache_Expiration(t *testing.T) {\n\t// Very short duration for testing\n\tcache := NewZoneCache[[]string](10 * time.Millisecond)\n\n\tcache.Reset([]string{\"zone1\"})\n\tassert.False(t, cache.Expired())\n\n\t// Wait for expiration\n\ttime.Sleep(20 * time.Millisecond)\n\tassert.True(t, cache.Expired())\n}\n\nfunc TestZoneCache_CachingDisabled(t *testing.T) {\n\tcache := NewZoneCache[[]string](0)\n\n\tcache.Reset([]string{\"zone1\"})\n\t// Should still be expired because caching is disabled\n\tassert.True(t, cache.Expired())\n\t// Data should not be stored\n\tassert.Nil(t, cache.Get())\n}\n\nfunc TestZoneCache_Expiration_Synctest(t *testing.T) {\n\tsynctest.Test(t, func(t *testing.T) {\n\t\tcache := NewZoneCache[[]string](5 * time.Minute)\n\n\t\tcache.Reset([]string{\"zone1\", \"zone2\"})\n\t\tassert.False(t, cache.Expired(), \"should not be expired immediately after reset\")\n\t\tassert.Equal(t, []string{\"zone1\", \"zone2\"}, cache.Get())\n\n\t\t// Advance time but not past expiration\n\t\ttime.Sleep(3 * time.Minute)\n\t\tassert.False(t, cache.Expired(), \"should not be expired before duration\")\n\n\t\t// Advance time past expiration\n\t\ttime.Sleep(3 * time.Minute) // Total: 6 minutes > 5 minute duration\n\t\tassert.True(t, cache.Expired(), \"should be expired after duration\")\n\n\t\t// Data is still accessible even when expired\n\t\tassert.Equal(t, []string{\"zone1\", \"zone2\"}, cache.Get())\n\n\t\t// Reset refreshes the cache\n\t\tcache.Reset([]string{\"zone3\"})\n\t\tassert.False(t, cache.Expired(), \"should not be expired after fresh reset\")\n\t\tassert.Equal(t, []string{\"zone3\"}, cache.Get())\n\t})\n}\n\nfunc TestZoneCache_ThreadSafety(t *testing.T) {\n\tcache := NewZoneCache[[]int](time.Hour)\n\n\tvar wg sync.WaitGroup\n\tconst numWriters = 3\n\tconst numReaders = 5\n\tconst iterations = 100\n\n\tvar validReads atomic.Int64\n\n\t// Writer goroutines\n\tfor w := range numWriters {\n\t\twg.Add(1)\n\t\tgo func(writerID int) {\n\t\t\tdefer wg.Done()\n\t\t\tfor i := range iterations {\n\t\t\t\tcache.Reset([]int{writerID, i})\n\t\t\t}\n\t\t}(w)\n\t}\n\n\t// Reader goroutines\n\tfor range numReaders {\n\t\twg.Go(func() {\n\t\t\tfor range iterations {\n\t\t\t\tdata := cache.Get()\n\t\t\t\texpired := cache.Expired()\n\n\t\t\t\t// Verify data consistency: if we got data, it should be valid\n\t\t\t\tif data != nil {\n\t\t\t\t\tassert.Len(t, data, 2, \"cached slice should always have exactly 2 elements\")\n\t\t\t\t\tassert.GreaterOrEqual(t, data[0], 0)\n\t\t\t\t\tassert.Less(t, data[0], numWriters)\n\t\t\t\t\tassert.GreaterOrEqual(t, data[1], 0)\n\t\t\t\t\tassert.Less(t, data[1], iterations)\n\t\t\t\t\tvalidReads.Add(1)\n\t\t\t\t}\n\n\t\t\t\t// Expired is a valid boolean - just verify it doesn't panic\n\t\t\t\t_ = expired\n\t\t\t}\n\t\t})\n\t}\n\n\twg.Wait()\n\n\t// After all writes complete, cache should have valid final state\n\tfinalData := cache.Get()\n\tassert.NotNil(t, finalData, \"cache should have data after writes\")\n\tassert.Len(t, finalData, 2, \"final data should have 2 elements\")\n\tassert.False(t, cache.Expired(), \"cache should not be expired\")\n}\n"
  },
  {
    "path": "provider/cached_provider.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage provider\n\nimport (\n\t\"context\"\n\t\"time\"\n\n\t\"github.com/prometheus/client_golang/prometheus\"\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/pkg/metrics\"\n\t\"sigs.k8s.io/external-dns/plan\"\n)\n\nvar (\n\tcachedRecordsCallsTotal = metrics.NewCounterVecWithOpts(\n\t\tprometheus.CounterOpts{\n\t\t\tSubsystem: \"provider\",\n\t\t\tName:      \"cache_records_calls\",\n\t\t\tHelp:      \"Number of calls to the provider cache Records list.\",\n\t\t},\n\t\t[]string{\n\t\t\t\"from_cache\",\n\t\t},\n\t)\n\tcachedApplyChangesCallsTotal = metrics.NewCounterWithOpts(\n\t\tprometheus.CounterOpts{\n\t\t\tSubsystem: \"provider\",\n\t\t\tName:      \"cache_apply_changes_calls\",\n\t\t\tHelp:      \"Number of calls to the provider cache ApplyChanges.\",\n\t\t},\n\t)\n)\n\nfunc init() {\n\tmetrics.RegisterMetric.MustRegister(cachedRecordsCallsTotal)\n\tmetrics.RegisterMetric.MustRegister(cachedApplyChangesCallsTotal)\n}\n\ntype CachedProvider struct {\n\tProvider\n\tRefreshDelay time.Duration\n\tlastRead     time.Time\n\tcache        []*endpoint.Endpoint\n}\n\nfunc NewCachedProvider(provider Provider, refreshDelay time.Duration) *CachedProvider {\n\treturn &CachedProvider{\n\t\tProvider:     provider,\n\t\tRefreshDelay: refreshDelay,\n\t}\n}\n\nfunc (c *CachedProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {\n\tif c.needRefresh() {\n\t\tlog.Info(\"Records cache provider: refreshing records list cache\")\n\t\trecords, err := c.Provider.Records(ctx)\n\t\tif err != nil {\n\t\t\tc.cache = nil\n\t\t\treturn nil, err\n\t\t}\n\t\tc.cache = records\n\t\tc.lastRead = time.Now()\n\t\tcachedRecordsCallsTotal.CounterVec.WithLabelValues(\"false\").Inc()\n\t} else {\n\t\tlog.Debug(\"Records cache provider: using records list from cache\")\n\t\tcachedRecordsCallsTotal.CounterVec.WithLabelValues(\"true\").Inc()\n\t}\n\treturn c.cache, nil\n}\nfunc (c *CachedProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {\n\tif !changes.HasChanges() {\n\t\tlog.Info(\"Records cache provider: no changes to be applied\")\n\t\treturn nil\n\t}\n\tc.Reset()\n\tcachedApplyChangesCallsTotal.Counter.Inc()\n\treturn c.Provider.ApplyChanges(ctx, changes)\n}\n\nfunc (c *CachedProvider) Reset() {\n\tc.cache = nil\n\tc.lastRead = time.Time{}\n}\n\nfunc (c *CachedProvider) needRefresh() bool {\n\tif c.cache == nil {\n\t\tlog.Debug(\"Records cache provider is not initialized\")\n\t\treturn true\n\t}\n\tlog.Debug(\"Records cache last Read: \", c.lastRead, \"expiration: \", c.RefreshDelay, \" provider expiration:\", c.lastRead.Add(c.RefreshDelay), \"expired: \", time.Now().After(c.lastRead.Add(c.RefreshDelay)))\n\treturn time.Now().After(c.lastRead.Add(c.RefreshDelay))\n}\n"
  },
  {
    "path": "provider/cached_provider_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\npackage provider\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/plan\"\n)\n\ntype testProviderFunc struct {\n\trecords             func(ctx context.Context) ([]*endpoint.Endpoint, error)\n\tapplyChanges        func(ctx context.Context, changes *plan.Changes) error\n\tpropertyValuesEqual func(name string, previous string, current string) bool\n\tadjustEndpoints     func(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error)\n\tgetDomainFilter     func() endpoint.DomainFilterInterface\n}\n\nfunc (p *testProviderFunc) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {\n\treturn p.records(ctx)\n}\n\nfunc (p *testProviderFunc) ApplyChanges(ctx context.Context, changes *plan.Changes) error {\n\treturn p.applyChanges(ctx, changes)\n}\n\nfunc (p *testProviderFunc) PropertyValuesEqual(name string, previous string, current string) bool {\n\treturn p.propertyValuesEqual(name, previous, current)\n}\n\nfunc (p *testProviderFunc) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) {\n\treturn p.adjustEndpoints(endpoints)\n}\n\nfunc (p *testProviderFunc) GetDomainFilter() endpoint.DomainFilterInterface {\n\treturn p.getDomainFilter()\n}\n\nfunc recordsNotCalled(t *testing.T) func(ctx context.Context) ([]*endpoint.Endpoint, error) {\n\treturn func(_ context.Context) ([]*endpoint.Endpoint, error) {\n\t\tt.Errorf(\"unexpected call to Records\")\n\t\treturn nil, nil\n\t}\n}\n\nfunc applyChangesNotCalled(t *testing.T) func(_ context.Context, _ *plan.Changes) error {\n\treturn func(_ context.Context, _ *plan.Changes) error {\n\t\tt.Errorf(\"unexpected call to ApplyChanges\")\n\t\treturn nil\n\t}\n}\n\nfunc propertyValuesEqualNotCalled(t *testing.T) func(name string, previous string, current string) bool {\n\treturn func(_ string, _ string, _ string) bool {\n\t\tt.Errorf(\"unexpected call to PropertyValuesEqual\")\n\t\treturn false\n\t}\n}\n\nfunc adjustEndpointsNotCalled(t *testing.T) func(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) {\n\treturn func(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) {\n\t\tt.Errorf(\"unexpected call to AdjustEndpoints\")\n\t\treturn endpoints, errors.New(\"unexpected call to AdjustEndpoints\")\n\t}\n}\n\nfunc newTestProviderFunc(t *testing.T) *testProviderFunc {\n\treturn &testProviderFunc{\n\t\trecords:             recordsNotCalled(t),\n\t\tapplyChanges:        applyChangesNotCalled(t),\n\t\tpropertyValuesEqual: propertyValuesEqualNotCalled(t),\n\t\tadjustEndpoints:     adjustEndpointsNotCalled(t),\n\t}\n}\n\nfunc TestCachedProviderCallsProviderOnFirstCall(t *testing.T) {\n\ttestProvider := newTestProviderFunc(t)\n\ttestProvider.records = func(_ context.Context) ([]*endpoint.Endpoint, error) {\n\t\treturn []*endpoint.Endpoint{{DNSName: \"domain.fqdn\"}}, nil\n\t}\n\tprovider := CachedProvider{\n\t\tProvider: testProvider,\n\t}\n\tendpoints, err := provider.Records(t.Context())\n\tassert.NoError(t, err)\n\trequire.NotNil(t, endpoints)\n\trequire.Len(t, endpoints, 1)\n\trequire.NotNil(t, endpoints[0])\n\tassert.Equal(t, \"domain.fqdn\", endpoints[0].DNSName)\n}\n\nfunc TestCachedProviderUsesCacheWhileValid(t *testing.T) {\n\ttestProvider := newTestProviderFunc(t)\n\ttestProvider.records = func(_ context.Context) ([]*endpoint.Endpoint, error) {\n\t\treturn []*endpoint.Endpoint{{DNSName: \"domain.fqdn\"}}, nil\n\t}\n\tprovider := CachedProvider{\n\t\tRefreshDelay: 30 * time.Second,\n\t\tProvider:     testProvider,\n\t}\n\t_, err := provider.Records(t.Context())\n\trequire.NoError(t, err)\n\n\tt.Run(\"With consecutive calls within the caching time frame\", func(t *testing.T) {\n\t\ttestProvider.records = recordsNotCalled(t)\n\t\tendpoints, err := provider.Records(t.Context())\n\t\tassert.NoError(t, err)\n\t\trequire.NotNil(t, endpoints)\n\t\trequire.Len(t, endpoints, 1)\n\t\trequire.NotNil(t, endpoints[0])\n\t\tassert.Equal(t, \"domain.fqdn\", endpoints[0].DNSName)\n\t})\n\n\tt.Run(\"When the caching time frame is exceeded\", func(t *testing.T) {\n\t\ttestProvider.records = func(_ context.Context) ([]*endpoint.Endpoint, error) {\n\t\t\treturn []*endpoint.Endpoint{{DNSName: \"new.domain.fqdn\"}}, nil\n\t\t}\n\t\tprovider.lastRead = time.Now().Add(-20 * time.Minute)\n\t\tendpoints, err := provider.Records(t.Context())\n\t\tassert.NoError(t, err)\n\t\trequire.NotNil(t, endpoints)\n\t\trequire.Len(t, endpoints, 1)\n\t\trequire.NotNil(t, endpoints[0])\n\t\tassert.Equal(t, \"new.domain.fqdn\", endpoints[0].DNSName)\n\t})\n}\n\nfunc TestCachedProviderForcesCacheRefreshOnUpdate(t *testing.T) {\n\ttestProvider := newTestProviderFunc(t)\n\ttestProvider.records = func(_ context.Context) ([]*endpoint.Endpoint, error) {\n\t\treturn []*endpoint.Endpoint{{DNSName: \"domain.fqdn\"}}, nil\n\t}\n\tprovider := CachedProvider{\n\t\tRefreshDelay: 30 * time.Second,\n\t\tProvider:     testProvider,\n\t}\n\t_, err := provider.Records(t.Context())\n\trequire.NoError(t, err)\n\n\tt.Run(\"When empty changes are applied\", func(t *testing.T) {\n\t\ttestProvider.records = recordsNotCalled(t)\n\t\ttestProvider.applyChanges = func(_ context.Context, _ *plan.Changes) error {\n\t\t\treturn nil\n\t\t}\n\t\terr := provider.ApplyChanges(t.Context(), &plan.Changes{})\n\t\tassert.NoError(t, err)\n\t\tt.Run(\"Next call to Records is cached\", func(t *testing.T) {\n\t\t\ttestProvider.applyChanges = applyChangesNotCalled(t)\n\t\t\ttestProvider.records = func(_ context.Context) ([]*endpoint.Endpoint, error) {\n\t\t\t\treturn []*endpoint.Endpoint{{DNSName: \"new.domain.fqdn\"}}, nil\n\t\t\t}\n\t\t\tendpoints, err := provider.Records(t.Context())\n\n\t\t\tassert.NoError(t, err)\n\t\t\trequire.NotNil(t, endpoints)\n\t\t\trequire.Len(t, endpoints, 1)\n\t\t\trequire.NotNil(t, endpoints[0])\n\t\t\tassert.Equal(t, \"domain.fqdn\", endpoints[0].DNSName)\n\t\t})\n\t})\n\n\tt.Run(\"When changes are applied\", func(t *testing.T) {\n\t\ttestProvider.records = recordsNotCalled(t)\n\t\ttestProvider.applyChanges = func(_ context.Context, _ *plan.Changes) error {\n\t\t\treturn nil\n\t\t}\n\t\terr := provider.ApplyChanges(t.Context(), &plan.Changes{\n\t\t\tCreate: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"hello.world\"},\n\t\t\t},\n\t\t})\n\t\tassert.NoError(t, err)\n\t\tt.Run(\"Next call to Records is not cached\", func(t *testing.T) {\n\t\t\ttestProvider.applyChanges = applyChangesNotCalled(t)\n\t\t\ttestProvider.records = func(_ context.Context) ([]*endpoint.Endpoint, error) {\n\t\t\t\treturn []*endpoint.Endpoint{{DNSName: \"new.domain.fqdn\"}}, nil\n\t\t\t}\n\t\t\tendpoints, err := provider.Records(t.Context())\n\n\t\t\tassert.NoError(t, err)\n\t\t\trequire.NotNil(t, endpoints)\n\t\t\trequire.Len(t, endpoints, 1)\n\t\t\trequire.NotNil(t, endpoints[0])\n\t\t\tassert.Equal(t, \"new.domain.fqdn\", endpoints[0].DNSName)\n\t\t})\n\t})\n}\n"
  },
  {
    "path": "provider/civo/civo.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage civo\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/civo/civogo\"\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\n// CivoProvider is an implementation of Provider for Civo's DNS.\ntype CivoProvider struct {\n\tprovider.BaseProvider\n\tClient       civogo.Client\n\tdomainFilter *endpoint.DomainFilter\n\tDryRun       bool\n}\n\n// CivoChanges All API calls calculated from the plan\ntype CivoChanges struct {\n\tCreates []*CivoChangeCreate\n\tDeletes []*CivoChangeDelete\n\tUpdates []*CivoChangeUpdate\n}\n\n// Empty returns true if there are no changes\nfunc (c *CivoChanges) Empty() bool {\n\treturn len(c.Creates) == 0 && len(c.Updates) == 0 && len(c.Deletes) == 0\n}\n\n// CivoChangeCreate Civo Domain Record Creates\ntype CivoChangeCreate struct {\n\tDomain  civogo.DNSDomain\n\tOptions *civogo.DNSRecordConfig\n}\n\n// CivoChangeUpdate Civo Domain Record Updates\ntype CivoChangeUpdate struct {\n\tDomain       civogo.DNSDomain\n\tDomainRecord civogo.DNSRecord\n\tOptions      civogo.DNSRecordConfig\n}\n\n// CivoChangeDelete Civo Domain Record Deletes\ntype CivoChangeDelete struct {\n\tDomain       civogo.DNSDomain\n\tDomainRecord civogo.DNSRecord\n}\n\n// New creates a Civo provider from the given configuration.\nfunc New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) {\n\treturn newProvider(domainFilter, cfg.DryRun)\n}\n\n// newProvider initializes a new Civo DNS based Provider.\nfunc newProvider(domainFilter *endpoint.DomainFilter, dryRun bool) (*CivoProvider, error) {\n\ttoken, ok := os.LookupEnv(\"CIVO_TOKEN\")\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"no token found\")\n\t}\n\n\t// Declare a default region just for the client is not used for anything else\n\t// as the DNS API is global and not region based\n\tregion := \"LON1\"\n\n\tcivoClient, err := civogo.NewClient(token, region)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tuserAgent := &civogo.Component{\n\t\tName:    externaldns.UserAgentProduct,\n\t\tVersion: externaldns.Version,\n\t}\n\tcivoClient.SetUserAgent(userAgent)\n\n\tprovider := &CivoProvider{\n\t\tClient:       *civoClient,\n\t\tdomainFilter: domainFilter,\n\t\tDryRun:       dryRun,\n\t}\n\treturn provider, nil\n}\n\n// Zones returns the list of hosted zones.\nfunc (p *CivoProvider) Zones(_ context.Context) ([]civogo.DNSDomain, error) {\n\tzones, err := p.fetchZones()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn zones, nil\n}\n\n// Records returns the list of records in a given zone.\nfunc (p *CivoProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {\n\tzones, err := p.Zones(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar endpoints []*endpoint.Endpoint\n\n\tfor _, zone := range zones {\n\t\trecords, err := p.fetchRecords(zone.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tfor _, r := range records {\n\t\t\ttoUpper := strings.ToUpper(string(r.Type))\n\t\t\tif provider.SupportedRecordType(toUpper) {\n\t\t\t\tname := fmt.Sprintf(\"%s.%s\", r.Name, zone.Name)\n\n\t\t\t\t// root name is identified by the empty string and should be\n\t\t\t\t// translated to zone name for the endpoint entry.\n\t\t\t\tif r.Name == \"\" {\n\t\t\t\t\tname = zone.Name\n\t\t\t\t}\n\n\t\t\t\tendpoints = append(endpoints, endpoint.NewEndpointWithTTL(name, toUpper, endpoint.TTL(r.TTL), r.Value))\n\t\t\t}\n\t\t}\n\t}\n\n\treturn endpoints, nil\n}\n\nfunc (p *CivoProvider) fetchRecords(domainID string) ([]civogo.DNSRecord, error) {\n\trecords, err := p.Client.ListDNSRecords(domainID)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn records, nil\n}\n\nfunc (p *CivoProvider) fetchZones() ([]civogo.DNSDomain, error) {\n\tvar zones []civogo.DNSDomain\n\n\tallZones, err := p.Client.ListDNSDomains()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor _, zone := range allZones {\n\t\tif !p.domainFilter.Match(zone.Name) {\n\t\t\tcontinue\n\t\t}\n\n\t\tzones = append(zones, zone)\n\t}\n\n\treturn zones, nil\n}\n\n// submitChanges takes a zone and a collection of Changes and sends them as a single transaction.\nfunc (p *CivoProvider) submitChanges(changes CivoChanges) error {\n\tif changes.Empty() {\n\t\tlog.Info(\"All records are already up to date\")\n\t\treturn nil\n\t}\n\n\tfor _, change := range changes.Creates {\n\t\tlogFields := log.Fields{\n\t\t\t\"Type\":     change.Options.Type,\n\t\t\t\"Name\":     change.Options.Name,\n\t\t\t\"Value\":    change.Options.Value,\n\t\t\t\"Priority\": change.Options.Priority,\n\t\t\t\"TTL\":      change.Options.TTL,\n\t\t\t\"action\":   \"Create\",\n\t\t}\n\n\t\tlog.WithFields(logFields).Info(\"Creating record.\")\n\n\t\tif p.DryRun {\n\t\t\tlog.WithFields(logFields).Info(\"Would create record.\")\n\t\t} else if _, err := p.Client.CreateDNSRecord(change.Domain.ID, change.Options); err != nil {\n\t\t\tlog.WithFields(logFields).Errorf(\n\t\t\t\t\"Failed to Create record: %v\",\n\t\t\t\terr,\n\t\t\t)\n\t\t}\n\t}\n\n\tfor _, change := range changes.Deletes {\n\t\tlogFields := log.Fields{\n\t\t\t\"Type\":     change.DomainRecord.Type,\n\t\t\t\"Name\":     change.DomainRecord.Name,\n\t\t\t\"Value\":    change.DomainRecord.Value,\n\t\t\t\"Priority\": change.DomainRecord.Priority,\n\t\t\t\"TTL\":      change.DomainRecord.TTL,\n\t\t\t\"action\":   \"Delete\",\n\t\t}\n\n\t\tlog.WithFields(logFields).Info(\"Deleting record.\")\n\n\t\tif p.DryRun {\n\t\t\tlog.WithFields(logFields).Info(\"Would delete record.\")\n\t\t} else if _, err := p.Client.DeleteDNSRecord(&change.DomainRecord); err != nil {\n\t\t\tlog.WithFields(logFields).Errorf(\n\t\t\t\t\"Failed to Delete record: %v\",\n\t\t\t\terr,\n\t\t\t)\n\t\t}\n\t}\n\n\tfor _, change := range changes.Updates {\n\t\tlogFields := log.Fields{\n\t\t\t\"Type\":     change.DomainRecord.Type,\n\t\t\t\"Name\":     change.DomainRecord.Name,\n\t\t\t\"Value\":    change.DomainRecord.Value,\n\t\t\t\"Priority\": change.DomainRecord.Priority,\n\t\t\t\"TTL\":      change.DomainRecord.TTL,\n\t\t\t\"action\":   \"Update\",\n\t\t}\n\n\t\tlog.WithFields(logFields).Info(\"Updating record.\")\n\n\t\tif p.DryRun {\n\t\t\tlog.WithFields(logFields).Info(\"Would update record.\")\n\t\t} else if _, err := p.Client.UpdateDNSRecord(&change.DomainRecord, &change.Options); err != nil {\n\t\t\tlog.WithFields(logFields).Errorf(\n\t\t\t\t\"Failed to Update record: %v\",\n\t\t\t\terr,\n\t\t\t)\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// processCreateActions return a list of changes to create records.\nfunc processCreateActions(zonesByID map[string]civogo.DNSDomain, recordsByZoneID map[string][]civogo.DNSRecord, createsByZone map[string][]*endpoint.Endpoint, civoChange *CivoChanges) error {\n\tfor zoneID, creates := range createsByZone {\n\t\tzone := zonesByID[zoneID]\n\n\t\tif len(creates) == 0 {\n\t\t\tlog.WithFields(log.Fields{\n\t\t\t\t\"zoneID\":   zoneID,\n\t\t\t\t\"zoneName\": zone.Name,\n\t\t\t}).Info(\"Skipping Zone, no creates found.\")\n\t\t\tcontinue\n\t\t}\n\n\t\trecords := recordsByZoneID[zoneID]\n\n\t\t// Generate Create\n\t\tfor _, ep := range creates {\n\t\t\tmatchedRecords := getRecordID(records, zone, *ep)\n\n\t\t\tif len(matchedRecords) != 0 {\n\t\t\t\tlog.WithFields(log.Fields{\n\t\t\t\t\t\"zoneID\":     zoneID,\n\t\t\t\t\t\"zoneName\":   zone.Name,\n\t\t\t\t\t\"dnsName\":    ep.DNSName,\n\t\t\t\t\t\"recordType\": ep.RecordType,\n\t\t\t\t}).Warn(\"Records found which should not exist\")\n\t\t\t}\n\n\t\t\trecordType, err := convertRecordType(ep.RecordType)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tfor _, target := range ep.Targets {\n\t\t\t\tcivoChange.Creates = append(civoChange.Creates, &CivoChangeCreate{\n\t\t\t\t\tDomain: zone,\n\t\t\t\t\tOptions: &civogo.DNSRecordConfig{\n\t\t\t\t\t\tValue:    target,\n\t\t\t\t\t\tName:     getStrippedRecordName(zone, *ep),\n\t\t\t\t\t\tType:     recordType,\n\t\t\t\t\t\tPriority: 0,\n\t\t\t\t\t\tTTL:      int(ep.RecordTTL),\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// processUpdateActions return a list of changes to update records.\nfunc processUpdateActions(zonesByID map[string]civogo.DNSDomain, recordsByZoneID map[string][]civogo.DNSRecord, updatesByZone map[string][]*endpoint.Endpoint, civoChange *CivoChanges) error {\n\tfor zoneID, updates := range updatesByZone {\n\t\tzone := zonesByID[zoneID]\n\n\t\tif len(updates) == 0 {\n\t\t\tlog.WithFields(log.Fields{\n\t\t\t\t\"zoneID\":   zoneID,\n\t\t\t\t\"zoneName\": zone.Name,\n\t\t\t}).Debug(\"Skipping Zone, no updates found.\")\n\t\t\tcontinue\n\t\t}\n\n\t\trecords := recordsByZoneID[zoneID]\n\n\t\tfor _, ep := range updates {\n\t\t\tmatchedRecords := getRecordID(records, zone, *ep)\n\t\t\tif len(matchedRecords) == 0 {\n\t\t\t\tlog.WithFields(log.Fields{\n\t\t\t\t\t\"zoneID\":     zoneID,\n\t\t\t\t\t\"dnsName\":    ep.DNSName,\n\t\t\t\t\t\"zoneName\":   zone.Name,\n\t\t\t\t\t\"recordType\": ep.RecordType,\n\t\t\t\t}).Warn(\"Update Records not found.\")\n\t\t\t}\n\n\t\t\trecordType, err := convertRecordType(ep.RecordType)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tmatchedRecordsByTarget := make(map[string]civogo.DNSRecord)\n\t\t\tfor _, record := range matchedRecords {\n\t\t\t\tmatchedRecordsByTarget[record.Value] = record\n\t\t\t}\n\n\t\t\tfor _, target := range ep.Targets {\n\t\t\t\tif record, ok := matchedRecordsByTarget[target]; ok {\n\t\t\t\t\tlog.WithFields(log.Fields{\n\t\t\t\t\t\t\"zoneID\":     zoneID,\n\t\t\t\t\t\t\"dnsName\":    ep.DNSName,\n\t\t\t\t\t\t\"zoneName\":   zone.Name,\n\t\t\t\t\t\t\"recordType\": ep.RecordType,\n\t\t\t\t\t\t\"target\":     target,\n\t\t\t\t\t}).Warn(\"Updating Existing Target\")\n\n\t\t\t\t\tcivoChange.Updates = append(civoChange.Updates, &CivoChangeUpdate{\n\t\t\t\t\t\tDomain:       zone,\n\t\t\t\t\t\tDomainRecord: record,\n\t\t\t\t\t\tOptions: civogo.DNSRecordConfig{\n\t\t\t\t\t\t\tValue:    target,\n\t\t\t\t\t\t\tName:     getStrippedRecordName(zone, *ep),\n\t\t\t\t\t\t\tType:     recordType,\n\t\t\t\t\t\t\tPriority: 0,\n\t\t\t\t\t\t\tTTL:      int(ep.RecordTTL),\n\t\t\t\t\t\t},\n\t\t\t\t\t})\n\n\t\t\t\t\tdelete(matchedRecordsByTarget, target)\n\t\t\t\t} else {\n\t\t\t\t\t// Record did not previously exist, create new 'target'\n\t\t\t\t\tlog.WithFields(log.Fields{\n\t\t\t\t\t\t\"zoneID\":     zoneID,\n\t\t\t\t\t\t\"dnsName\":    ep.DNSName,\n\t\t\t\t\t\t\"zoneName\":   zone.Name,\n\t\t\t\t\t\t\"recordType\": ep.RecordType,\n\t\t\t\t\t\t\"target\":     target,\n\t\t\t\t\t}).Warn(\"Creating New Target\")\n\n\t\t\t\t\tcivoChange.Creates = append(civoChange.Creates, &CivoChangeCreate{\n\t\t\t\t\t\tDomain: zone,\n\t\t\t\t\t\tOptions: &civogo.DNSRecordConfig{\n\t\t\t\t\t\t\tValue:    target,\n\t\t\t\t\t\t\tName:     getStrippedRecordName(zone, *ep),\n\t\t\t\t\t\t\tType:     recordType,\n\t\t\t\t\t\t\tPriority: 0,\n\t\t\t\t\t\t\tTTL:      int(ep.RecordTTL),\n\t\t\t\t\t\t},\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Any remaining records have been removed, delete them\n\t\t\tfor _, record := range matchedRecordsByTarget {\n\t\t\t\tlog.WithFields(log.Fields{\n\t\t\t\t\t\"zoneID\":     zoneID,\n\t\t\t\t\t\"dnsName\":    ep.DNSName,\n\t\t\t\t\t\"recordType\": ep.RecordType,\n\t\t\t\t\t\"target\":     record.Value,\n\t\t\t\t}).Warn(\"Deleting target\")\n\n\t\t\t\tcivoChange.Deletes = append(civoChange.Deletes, &CivoChangeDelete{\n\t\t\t\t\tDomain:       zone,\n\t\t\t\t\tDomainRecord: record,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// processDeleteActions return a list of changes to delete records.\nfunc processDeleteActions(zonesByID map[string]civogo.DNSDomain, recordsByZoneID map[string][]civogo.DNSRecord, deletesByZone map[string][]*endpoint.Endpoint, civoChange *CivoChanges) error {\n\tfor zoneID, deletes := range deletesByZone {\n\t\tzone := zonesByID[zoneID]\n\n\t\tif len(deletes) == 0 {\n\t\t\tlog.WithFields(log.Fields{\n\t\t\t\t\"zoneID\":   zoneID,\n\t\t\t\t\"zoneName\": zone.Name,\n\t\t\t}).Debug(\"Skipping Zone, no deletes found.\")\n\t\t\tcontinue\n\t\t}\n\n\t\trecords := recordsByZoneID[zoneID]\n\n\t\tfor _, ep := range deletes {\n\t\t\tmatchedRecords := getRecordID(records, zone, *ep)\n\n\t\t\tif len(matchedRecords) == 0 {\n\t\t\t\tlog.WithFields(log.Fields{\n\t\t\t\t\t\"zoneID\":     zoneID,\n\t\t\t\t\t\"dnsName\":    ep.DNSName,\n\t\t\t\t\t\"zoneName\":   zone.Name,\n\t\t\t\t\t\"recordType\": ep.RecordType,\n\t\t\t\t}).Warn(\"Records to Delete not found.\")\n\t\t\t}\n\n\t\t\tfor _, record := range matchedRecords {\n\t\t\t\tcivoChange.Deletes = append(civoChange.Deletes, &CivoChangeDelete{\n\t\t\t\t\tDomain:       zone,\n\t\t\t\t\tDomainRecord: record,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n\n// ApplyChanges applies a given set of changes in a given zone.\nfunc (p *CivoProvider) ApplyChanges(_ context.Context, changes *plan.Changes) error {\n\tvar civoChange CivoChanges\n\trecordsByZoneID := make(map[string][]civogo.DNSRecord)\n\n\tzones, err := p.fetchZones()\n\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tzonesByID := make(map[string]civogo.DNSDomain)\n\n\tzoneNameIDMapper := provider.ZoneIDName{}\n\n\tfor _, z := range zones {\n\t\tzoneNameIDMapper.Add(z.ID, z.Name)\n\t\tzonesByID[z.ID] = z\n\t}\n\n\t// Fetch records for each zone\n\tfor _, zone := range zones {\n\t\trecords, err := p.fetchRecords(zone.ID)\n\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\trecordsByZoneID[zone.ID] = append(recordsByZoneID[zone.ID], records...)\n\t}\n\n\tcreatesByZone := endpointsByZone(zoneNameIDMapper, changes.Create)\n\tupdatesByZone := endpointsByZone(zoneNameIDMapper, changes.UpdateNew)\n\tdeletesByZone := endpointsByZone(zoneNameIDMapper, changes.Delete)\n\n\t// Generate Creates\n\terr = processCreateActions(zonesByID, recordsByZoneID, createsByZone, &civoChange)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Generate Updates\n\terr = processUpdateActions(zonesByID, recordsByZoneID, updatesByZone, &civoChange)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Generate Deletes\n\terr = processDeleteActions(zonesByID, recordsByZoneID, deletesByZone, &civoChange)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn p.submitChanges(civoChange)\n}\n\nfunc endpointsByZone(zoneNameIDMapper provider.ZoneIDName, endpoints []*endpoint.Endpoint) map[string][]*endpoint.Endpoint {\n\tendpointsByZone := make(map[string][]*endpoint.Endpoint)\n\n\tfor _, ep := range endpoints {\n\t\tzoneID, _ := zoneNameIDMapper.FindZone(ep.DNSName)\n\t\tif zoneID == \"\" {\n\t\t\tlog.Debugf(\"Skipping record %s because no hosted zone matching record DNS Name was detected\", ep.DNSName)\n\t\t\tcontinue\n\t\t}\n\t\tendpointsByZone[zoneID] = append(endpointsByZone[zoneID], ep)\n\t}\n\n\treturn endpointsByZone\n}\n\nfunc convertRecordType(recordType string) (civogo.DNSRecordType, error) {\n\tswitch recordType {\n\tcase \"A\":\n\t\treturn civogo.DNSRecordTypeA, nil\n\tcase \"CNAME\":\n\t\treturn civogo.DNSRecordTypeCName, nil\n\tcase \"TXT\":\n\t\treturn civogo.DNSRecordTypeTXT, nil\n\tcase \"SRV\":\n\t\treturn civogo.DNSRecordTypeSRV, nil\n\tdefault:\n\t\treturn \"\", fmt.Errorf(\"invalid Record Type: %s\", recordType)\n\t}\n}\n\nfunc getStrippedRecordName(zone civogo.DNSDomain, ep endpoint.Endpoint) string {\n\tif ep.DNSName == zone.Name {\n\t\treturn \"\"\n\t}\n\n\treturn strings.TrimSuffix(ep.DNSName, \".\"+zone.Name)\n}\n\nfunc getRecordID(records []civogo.DNSRecord, zone civogo.DNSDomain, ep endpoint.Endpoint) []civogo.DNSRecord {\n\tvar matchedRecords []civogo.DNSRecord\n\n\tfor _, record := range records {\n\t\tstripedName := getStrippedRecordName(zone, ep)\n\t\ttoUpper := strings.ToUpper(string(record.Type))\n\t\tif record.Name == stripedName && toUpper == ep.RecordType {\n\t\t\tmatchedRecords = append(matchedRecords, record)\n\t\t}\n\t}\n\n\treturn matchedRecords\n}\n"
  },
  {
    "path": "provider/civo/civo_test.go",
    "content": "/*\nCopyright 2020 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\npackage civo\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"reflect\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/civo/civogo\"\n\t\"github.com/google/go-cmp/cmp\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/plan\"\n)\n\nfunc TestNewProvider(t *testing.T) {\n\tt.Setenv(\"CIVO_TOKEN\", \"xxxxxxxxxxxxxxx\")\n\t_, err := newProvider(endpoint.NewDomainFilter([]string{\"test.civo.com\"}), true)\n\trequire.NoError(t, err)\n\n\t_ = os.Unsetenv(\"CIVO_TOKEN\")\n}\n\nfunc TestNewCivoProviderNoToken(t *testing.T) {\n\t_, err := newProvider(endpoint.NewDomainFilter([]string{\"test.civo.com\"}), true)\n\tassert.Error(t, err)\n\n\tassert.Equal(t, \"no token found\", err.Error())\n}\n\nfunc TestCivoProviderZones(t *testing.T) {\n\tclient, server, _ := civogo.NewClientForTesting(map[string]string{\n\t\t\"/v2/dns\": `[\n\t\t\t{\"id\": \"12345\", \"account_id\": \"1\", \"name\": \"example.com\"},\n\t\t\t{\"id\": \"12346\", \"account_id\": \"1\", \"name\": \"example.net\"}\n\t\t\t]`,\n\t})\n\tdefer server.Close()\n\tprovider := &CivoProvider{\n\t\tClient: *client,\n\t}\n\n\texpected, err := client.ListDNSDomains()\n\tassert.NoError(t, err)\n\n\tzones, err := provider.Zones(t.Context())\n\tassert.NoError(t, err)\n\n\t// Check if the return is a DNSDomain type\n\tassert.ElementsMatch(t, zones, expected)\n}\n\nfunc TestCivoProviderZonesWithError(t *testing.T) {\n\tclient, server, _ := civogo.NewClientForTesting(map[string]string{\n\t\t\"/v2/dns-error\": `[]`,\n\t})\n\tdefer server.Close()\n\tprovider := &CivoProvider{\n\t\tClient: *client,\n\t}\n\n\t_, err := provider.Zones(t.Context())\n\tassert.Error(t, err)\n}\n\nfunc TestCivoProviderRecords(t *testing.T) {\n\tclient, server, _ := civogo.NewAdvancedClientForTesting([]civogo.ConfigAdvanceClientForTesting{\n\t\t{\n\t\t\tMethod: \"GET\",\n\t\t\tValue: []civogo.ValueAdvanceClientForTesting{\n\t\t\t\t{\n\t\t\t\t\tRequestBody: ``,\n\t\t\t\t\tURL:         \"/v2/dns/12345/records\",\n\t\t\t\t\tResponseBody: `[\n\t\t\t\t\t\t{\"id\": \"1\", \"domain_id\":\"12345\", \"account_id\": \"1\", \"name\": \"www\", \"type\": \"A\", \"value\": \"10.0.0.0\", \"ttl\": 600},\n\t\t\t\t\t\t{\"id\": \"2\", \"account_id\": \"1\", \"domain_id\":\"12345\", \"name\": \"mail\", \"type\": \"A\", \"value\": \"10.0.0.1\", \"ttl\": 600}\n\t\t\t\t\t\t]`,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tRequestBody: ``,\n\t\t\t\t\tURL:         \"/v2/dns\",\n\t\t\t\t\tResponseBody: `[\n\t\t\t\t\t\t{\"id\": \"12345\", \"account_id\": \"1\", \"name\": \"example.com\"},\n\t\t\t\t\t\t{\"id\": \"12346\", \"account_id\": \"1\", \"name\": \"example.net\"}\n\t\t\t\t\t\t]`,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t})\n\n\tdefer server.Close()\n\tprovider := &CivoProvider{\n\t\tClient:       *client,\n\t\tdomainFilter: endpoint.NewDomainFilter([]string{\"example.com\"}),\n\t}\n\n\texpected, err := client.ListDNSRecords(\"12345\")\n\tassert.NoError(t, err)\n\n\trecords, err := provider.Records(t.Context())\n\tassert.NoError(t, err)\n\n\tassert.Equal(t, strings.TrimSuffix(records[0].DNSName, \".example.com\"), expected[0].Name)\n\tassert.Equal(t, records[0].RecordType, string(expected[0].Type))\n\tassert.Equal(t, int(records[0].RecordTTL), expected[0].TTL)\n\n\tassert.Equal(t, strings.TrimSuffix(records[1].DNSName, \".example.com\"), expected[1].Name)\n\tassert.Equal(t, records[1].RecordType, string(expected[1].Type))\n\tassert.Equal(t, int(records[1].RecordTTL), expected[1].TTL)\n}\n\nfunc TestCivoProviderRecordsWithError(t *testing.T) {\n\tclient, server, _ := civogo.NewAdvancedClientForTesting([]civogo.ConfigAdvanceClientForTesting{\n\t\t{\n\t\t\tMethod: \"GET\",\n\t\t\tValue: []civogo.ValueAdvanceClientForTesting{\n\t\t\t\t{\n\t\t\t\t\tRequestBody: ``,\n\t\t\t\t\tURL:         \"/v2/dns/12345/records\",\n\t\t\t\t\tResponseBody: `[\n\t\t\t\t\t\t{\"id\": \"1\", \"domain_id\":\"12345\", \"account_id\": \"1\", \"name\": \"\", \"type\": \"A\", \"value\": \"10.0.0.0\", \"ttl\": 600},\n\t\t\t\t\t\t{\"id\": \"2\", \"account_id\": \"1\", \"domain_id\":\"12345\", \"name\": \"\", \"type\": \"A\", \"value\": \"10.0.0.1\", \"ttl\": 600}\n\t\t\t\t\t\t]`,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tRequestBody:  ``,\n\t\t\t\t\tURL:          \"/v2/dns\",\n\t\t\t\t\tResponseBody: `invalid-json-data`,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t})\n\n\tdefer server.Close()\n\n\tprovider := &CivoProvider{\n\t\tClient:       *client,\n\t\tdomainFilter: endpoint.NewDomainFilter([]string{\"example.com\"}),\n\t}\n\n\t_, err := client.ListDNSRecords(\"12345\")\n\tassert.NoError(t, err)\n\n\tendpoint, err := provider.Records(t.Context())\n\tassert.Error(t, err)\n\tassert.Nil(t, endpoint)\n\n}\n\nfunc TestCivoProviderWithoutRecords(t *testing.T) {\n\tclient, server, _ := civogo.NewClientForTesting(map[string]string{\n\t\t\"/v2/dns/12345/records\": `[]`,\n\t\t\"/v2/dns\": `[\n\t\t\t{\"id\": \"12345\", \"account_id\": \"1\", \"name\": \"example.com\"},\n\t\t\t{\"id\": \"12346\", \"account_id\": \"1\", \"name\": \"example.net\"}\n\t\t\t]`,\n\t})\n\tdefer server.Close()\n\tprovider := &CivoProvider{\n\t\tClient:       *client,\n\t\tdomainFilter: endpoint.NewDomainFilter([]string{\"example.com\"}),\n\t}\n\n\trecords, err := provider.Records(t.Context())\n\tassert.NoError(t, err)\n\n\tassert.Empty(t, records)\n}\n\nfunc TestCivoProcessCreateActionsLogs(t *testing.T) {\n\tlog.SetOutput(io.Discard)\n\tt.Run(\"Logs Skipping Zone, no creates found\", func(t *testing.T) {\n\t\tzonesByID := map[string]civogo.DNSDomain{\n\t\t\t\"example.com\": {\n\t\t\t\tID:        \"1\",\n\t\t\t\tAccountID: \"1\",\n\t\t\t\tName:      \"example.com\",\n\t\t\t},\n\t\t}\n\n\t\trecordsByZoneID := map[string][]civogo.DNSRecord{\n\t\t\t\"example.com\": {\n\t\t\t\t{\n\t\t\t\t\tID:        \"1\",\n\t\t\t\t\tAccountID: \"1\",\n\t\t\t\t\tName:      \"abc\",\n\t\t\t\t\tValue:     \"12.12.12.1\",\n\t\t\t\t\tType:      \"A\",\n\t\t\t\t\tTTL:       600,\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\n\t\tupdateByZone := map[string][]*endpoint.Endpoint{\n\t\t\t\"example.com\": {\n\t\t\t\tendpoint.NewEndpoint(\"abc.example.com\", endpoint.RecordTypeA, \"1.2.3.4\"),\n\t\t\t},\n\t\t}\n\t\tvar civoChanges CivoChanges\n\n\t\terr := processCreateActions(zonesByID, recordsByZoneID, updateByZone, &civoChanges)\n\t\trequire.NoError(t, err)\n\t\tassert.Len(t, civoChanges.Creates, 1)\n\t\tassert.Empty(t, civoChanges.Deletes)\n\t\tassert.Empty(t, civoChanges.Updates)\n\t})\n\n\tt.Run(\"Records found which should not exist\", func(t *testing.T) {\n\t\tzonesByID := map[string]civogo.DNSDomain{\n\t\t\t\"example.com\": {\n\t\t\t\tID:        \"1\",\n\t\t\t\tAccountID: \"1\",\n\t\t\t\tName:      \"example.com\",\n\t\t\t},\n\t\t}\n\n\t\trecordsByZoneID := map[string][]civogo.DNSRecord{\n\t\t\t\"example.com\": {},\n\t\t}\n\n\t\tupdateByZone := map[string][]*endpoint.Endpoint{\n\t\t\t\"example.com\": {},\n\t\t}\n\t\tvar civoChanges CivoChanges\n\n\t\terr := processCreateActions(zonesByID, recordsByZoneID, updateByZone, &civoChanges)\n\t\trequire.NoError(t, err)\n\t\tassert.Empty(t, civoChanges.Creates)\n\t\tassert.Empty(t, civoChanges.Creates)\n\t\tassert.Empty(t, civoChanges.Updates)\n\t})\n}\nfunc TestCivoProcessCreateActions(t *testing.T) {\n\tzoneByID := map[string]civogo.DNSDomain{\n\t\t\"example.com\": {\n\t\t\tID:        \"1\",\n\t\t\tAccountID: \"1\",\n\t\t\tName:      \"example.com\",\n\t\t},\n\t}\n\n\trecordsByZoneID := map[string][]civogo.DNSRecord{\n\t\t\"example.com\": {\n\t\t\t{\n\t\t\t\tID:          \"1\",\n\t\t\t\tAccountID:   \"1\",\n\t\t\t\tDNSDomainID: \"1\",\n\t\t\t\tName:        \"txt\",\n\t\t\t\tValue:       \"12.12.12.1\",\n\t\t\t\tType:        \"A\",\n\t\t\t\tTTL:         600,\n\t\t\t},\n\t\t},\n\t}\n\n\tcreatesByZone := map[string][]*endpoint.Endpoint{\n\t\t\"example.com\": {\n\t\t\tendpoint.NewEndpoint(\"foo.example.com\", endpoint.RecordTypeA, \"1.2.3.4\"),\n\t\t\tendpoint.NewEndpoint(\"txt.example.com\", endpoint.RecordTypeCNAME, \"foo.example.com\"),\n\t\t},\n\t}\n\n\tvar changes CivoChanges\n\terr := processCreateActions(zoneByID, recordsByZoneID, createsByZone, &changes)\n\trequire.NoError(t, err)\n\n\tassert.Len(t, changes.Creates, 2)\n\tassert.Empty(t, changes.Updates)\n\tassert.Empty(t, changes.Deletes)\n\n\texpectedCreates := []*CivoChangeCreate{\n\t\t{\n\t\t\tDomain: civogo.DNSDomain{\n\t\t\t\tID:        \"1\",\n\t\t\t\tAccountID: \"1\",\n\t\t\t\tName:      \"example.com\",\n\t\t\t},\n\t\t\tOptions: &civogo.DNSRecordConfig{\n\t\t\t\tType:  \"A\",\n\t\t\t\tName:  \"foo\",\n\t\t\t\tValue: \"1.2.3.4\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDomain: civogo.DNSDomain{\n\t\t\t\tID:        \"1\",\n\t\t\t\tAccountID: \"1\",\n\t\t\t\tName:      \"example.com\",\n\t\t\t},\n\t\t\tOptions: &civogo.DNSRecordConfig{\n\t\t\t\tType:  \"CNAME\",\n\t\t\t\tName:  \"txt\",\n\t\t\t\tValue: \"foo.example.com\",\n\t\t\t},\n\t\t},\n\t}\n\n\tif !elementsMatch(t, expectedCreates, changes.Creates) {\n\t\tassert.Failf(t, \"diff: %s\", cmp.Diff(expectedCreates, changes.Creates))\n\t}\n}\n\nfunc TestCivoProcessCreateActionsWithError(t *testing.T) {\n\tzoneByID := map[string]civogo.DNSDomain{\n\t\t\"example.com\": {\n\t\t\tID:        \"1\",\n\t\t\tAccountID: \"1\",\n\t\t\tName:      \"example.com\",\n\t\t},\n\t}\n\n\trecordsByZoneID := map[string][]civogo.DNSRecord{\n\t\t\"example.com\": {\n\t\t\t{\n\t\t\t\tID:          \"1\",\n\t\t\t\tAccountID:   \"1\",\n\t\t\t\tDNSDomainID: \"1\",\n\t\t\t\tName:        \"txt\",\n\t\t\t\tValue:       \"12.12.12.1\",\n\t\t\t\tType:        \"A\",\n\t\t\t\tTTL:         600,\n\t\t\t},\n\t\t},\n\t}\n\n\tcreatesByZone := map[string][]*endpoint.Endpoint{\n\t\t\"example.com\": {\n\t\t\tendpoint.NewEndpoint(\"foo.example.com\", \"AAAA\", \"1.2.3.4\"),\n\t\t\tendpoint.NewEndpoint(\"txt.example.com\", endpoint.RecordTypeCNAME, \"foo.example.com\"),\n\t\t},\n\t}\n\n\tvar changes CivoChanges\n\terr := processCreateActions(zoneByID, recordsByZoneID, createsByZone, &changes)\n\trequire.Error(t, err)\n\tassert.Equal(t, \"invalid Record Type: AAAA\", err.Error())\n}\n\nfunc TestCivoProcessUpdateActionsWithError(t *testing.T) {\n\tlog.SetOutput(io.Discard)\n\tzoneByID := map[string]civogo.DNSDomain{\n\t\t\"example.com\": {\n\t\t\tID:        \"1\",\n\t\t\tAccountID: \"1\",\n\t\t\tName:      \"example.com\",\n\t\t},\n\t}\n\n\trecordsByZoneID := map[string][]civogo.DNSRecord{\n\t\t\"example.com\": {\n\t\t\t{\n\t\t\t\tID:          \"1\",\n\t\t\t\tAccountID:   \"1\",\n\t\t\t\tDNSDomainID: \"1\",\n\t\t\t\tName:        \"txt\",\n\t\t\t\tValue:       \"12.12.12.1\",\n\t\t\t\tType:        \"A\",\n\t\t\t\tTTL:         600,\n\t\t\t},\n\t\t},\n\t}\n\n\tupdatesByZone := map[string][]*endpoint.Endpoint{\n\t\t\"example.com\": {\n\t\t\tendpoint.NewEndpoint(\"foo.example.com\", \"AAAA\", \"1.2.3.4\"),\n\t\t\tendpoint.NewEndpoint(\"txt.example.com\", endpoint.RecordTypeCNAME, \"foo.example.com\"),\n\t\t},\n\t}\n\n\tvar changes CivoChanges\n\terr := processUpdateActions(zoneByID, recordsByZoneID, updatesByZone, &changes)\n\trequire.Error(t, err)\n}\n\nfunc TestCivoProcessUpdateActions(t *testing.T) {\n\tzoneByID := map[string]civogo.DNSDomain{\n\t\t\"example.com\": {\n\t\t\tID:        \"1\",\n\t\t\tAccountID: \"1\",\n\t\t\tName:      \"example.com\",\n\t\t},\n\t}\n\n\trecordsByZoneID := map[string][]civogo.DNSRecord{\n\t\t\"example.com\": {\n\t\t\t{\n\t\t\t\tID:          \"1\",\n\t\t\t\tAccountID:   \"1\",\n\t\t\t\tDNSDomainID: \"1\",\n\t\t\t\tName:        \"txt\",\n\t\t\t\tValue:       \"1.2.3.4\",\n\t\t\t\tType:        \"A\",\n\t\t\t\tTTL:         600,\n\t\t\t},\n\t\t\t{\n\t\t\t\tID:          \"2\",\n\t\t\t\tAccountID:   \"1\",\n\t\t\t\tDNSDomainID: \"1\",\n\t\t\t\tName:        \"foo\",\n\t\t\t\tValue:       \"foo.example.com\",\n\t\t\t\tType:        \"CNAME\",\n\t\t\t\tTTL:         600,\n\t\t\t},\n\t\t\t{\n\t\t\t\tID:          \"3\",\n\t\t\t\tAccountID:   \"1\",\n\t\t\t\tDNSDomainID: \"1\",\n\t\t\t\tName:        \"bar\",\n\t\t\t\tValue:       \"10.10.10.1\",\n\t\t\t\tType:        \"A\",\n\t\t\t\tTTL:         600,\n\t\t\t},\n\t\t},\n\t}\n\n\tupdatesByZone := map[string][]*endpoint.Endpoint{\n\t\t\"example.com\": {\n\t\t\tendpoint.NewEndpoint(\"txt.example.com\", endpoint.RecordTypeA, \"10.20.30.40\"),\n\t\t\tendpoint.NewEndpoint(\"foo.example.com\", endpoint.RecordTypeCNAME, \"bar.example.com\"),\n\t\t},\n\t}\n\n\tvar changes CivoChanges\n\terr := processUpdateActions(zoneByID, recordsByZoneID, updatesByZone, &changes)\n\trequire.NoError(t, err)\n\n\tassert.Len(t, changes.Creates, 2)\n\tassert.Empty(t, changes.Updates)\n\tassert.Len(t, changes.Deletes, 2)\n\n\texpectedUpdate := []*CivoChangeCreate{\n\t\t{\n\t\t\tDomain: civogo.DNSDomain{\n\t\t\t\tID:        \"1\",\n\t\t\t\tAccountID: \"1\",\n\t\t\t\tName:      \"example.com\",\n\t\t\t},\n\t\t\tOptions: &civogo.DNSRecordConfig{\n\t\t\t\tType:  \"A\",\n\t\t\t\tName:  \"txt\",\n\t\t\t\tValue: \"10.20.30.40\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDomain: civogo.DNSDomain{\n\t\t\t\tID:        \"1\",\n\t\t\t\tAccountID: \"1\",\n\t\t\t\tName:      \"example.com\",\n\t\t\t},\n\t\t\tOptions: &civogo.DNSRecordConfig{\n\t\t\t\tType:  \"CNAME\",\n\t\t\t\tName:  \"foo\",\n\t\t\t\tValue: \"bar.example.com\",\n\t\t\t},\n\t\t},\n\t}\n\n\tif !elementsMatch(t, expectedUpdate, changes.Creates) {\n\t\tassert.Failf(t, \"diff: %s\", cmp.Diff(expectedUpdate, changes.Creates))\n\t}\n\n\texpectedDelete := []*CivoChangeDelete{\n\t\t{\n\t\t\tDomain: civogo.DNSDomain{\n\t\t\t\tID:        \"1\",\n\t\t\t\tAccountID: \"1\",\n\t\t\t\tName:      \"example.com\",\n\t\t\t},\n\t\t\tDomainRecord: civogo.DNSRecord{\n\t\t\t\tID:          \"1\",\n\t\t\t\tAccountID:   \"1\",\n\t\t\t\tDNSDomainID: \"1\",\n\t\t\t\tName:        \"txt\",\n\t\t\t\tValue:       \"1.2.3.4\",\n\t\t\t\tType:        \"A\",\n\t\t\t\tPriority:    0,\n\t\t\t\tTTL:         600,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDomain: civogo.DNSDomain{\n\t\t\t\tID:        \"1\",\n\t\t\t\tAccountID: \"1\",\n\t\t\t\tName:      \"example.com\",\n\t\t\t},\n\t\t\tDomainRecord: civogo.DNSRecord{\n\t\t\t\tID:          \"2\",\n\t\t\t\tAccountID:   \"1\",\n\t\t\t\tDNSDomainID: \"1\",\n\t\t\t\tName:        \"foo\",\n\t\t\t\tValue:       \"foo.example.com\",\n\t\t\t\tType:        \"CNAME\",\n\t\t\t\tPriority:    0,\n\t\t\t\tTTL:         600,\n\t\t\t},\n\t\t},\n\t}\n\n\tif !elementsMatch(t, expectedDelete, changes.Deletes) {\n\t\tassert.Failf(t, \"diff: %s\", cmp.Diff(expectedDelete, changes.Deletes))\n\t}\n}\n\nfunc TestCivoProcessDeleteAction(t *testing.T) {\n\tzoneByID := map[string]civogo.DNSDomain{\n\t\t\"example.com\": {\n\t\t\tID:        \"1\",\n\t\t\tAccountID: \"1\",\n\t\t\tName:      \"example.com\",\n\t\t},\n\t}\n\n\trecordsByZoneID := map[string][]civogo.DNSRecord{\n\t\t\"example.com\": {\n\t\t\t{\n\t\t\t\tID:          \"1\",\n\t\t\t\tAccountID:   \"1\",\n\t\t\t\tDNSDomainID: \"1\",\n\t\t\t\tName:        \"txt\",\n\t\t\t\tValue:       \"1.2.3.4\",\n\t\t\t\tType:        \"A\",\n\t\t\t\tTTL:         600,\n\t\t\t},\n\t\t\t{\n\t\t\t\tID:          \"2\",\n\t\t\t\tAccountID:   \"1\",\n\t\t\t\tDNSDomainID: \"1\",\n\t\t\t\tName:        \"foo\",\n\t\t\t\tValue:       \"5.6.7.8\",\n\t\t\t\tType:        \"A\",\n\t\t\t\tTTL:         600,\n\t\t\t},\n\t\t\t{\n\t\t\t\tID:          \"3\",\n\t\t\t\tAccountID:   \"1\",\n\t\t\t\tDNSDomainID: \"1\",\n\t\t\t\tName:        \"bar\",\n\t\t\t\tValue:       \"10.10.10.1\",\n\t\t\t\tType:        \"A\",\n\t\t\t\tTTL:         600,\n\t\t\t},\n\t\t},\n\t}\n\n\tdeleteByDomain := map[string][]*endpoint.Endpoint{\n\t\t\"example.com\": {\n\t\t\tendpoint.NewEndpoint(\"txt.example.com\", endpoint.RecordTypeA, \"1.2.3.4\"),\n\t\t\tendpoint.NewEndpoint(\"foo.example.com\", endpoint.RecordTypeA, \"5.6.7.8\"),\n\t\t},\n\t}\n\n\tvar changes CivoChanges\n\terr := processDeleteActions(zoneByID, recordsByZoneID, deleteByDomain, &changes)\n\trequire.NoError(t, err)\n\n\tassert.Empty(t, changes.Creates)\n\tassert.Empty(t, changes.Updates)\n\tassert.Len(t, changes.Deletes, 2)\n\n\texpectedDelete := []*CivoChangeDelete{\n\t\t{\n\t\t\tDomain: civogo.DNSDomain{\n\t\t\t\tID:        \"1\",\n\t\t\t\tAccountID: \"1\",\n\t\t\t\tName:      \"example.com\",\n\t\t\t},\n\t\t\tDomainRecord: civogo.DNSRecord{\n\t\t\t\tID:          \"1\",\n\t\t\t\tAccountID:   \"1\",\n\t\t\t\tDNSDomainID: \"1\",\n\t\t\t\tName:        \"txt\",\n\t\t\t\tValue:       \"1.2.3.4\",\n\t\t\t\tType:        \"A\",\n\t\t\t\tTTL:         600,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDomain: civogo.DNSDomain{\n\t\t\t\tID:        \"1\",\n\t\t\t\tAccountID: \"1\",\n\t\t\t\tName:      \"example.com\",\n\t\t\t},\n\t\t\tDomainRecord: civogo.DNSRecord{\n\t\t\t\tID:          \"2\",\n\t\t\t\tAccountID:   \"1\",\n\t\t\t\tDNSDomainID: \"1\",\n\t\t\t\tType:        \"A\",\n\t\t\t\tName:        \"foo\",\n\t\t\t\tValue:       \"5.6.7.8\",\n\t\t\t\tTTL:         600,\n\t\t\t},\n\t\t},\n\t}\n\n\tif !elementsMatch(t, expectedDelete, changes.Deletes) {\n\t\tassert.Failf(t, \"diff: %s\", cmp.Diff(expectedDelete, changes.Deletes))\n\t}\n}\n\nfunc TestCivoApplyChanges(t *testing.T) {\n\tlog.SetOutput(io.Discard)\n\tclient, server, _ := civogo.NewAdvancedClientForTesting([]civogo.ConfigAdvanceClientForTesting{\n\t\t{\n\t\t\tMethod: \"GET\",\n\t\t\tValue: []civogo.ValueAdvanceClientForTesting{\n\t\t\t\t{\n\t\t\t\t\tRequestBody:  \"\",\n\t\t\t\t\tURL:          \"/v2/dns\",\n\t\t\t\t\tResponseBody: `[{\"id\": \"12345\", \"account_id\": \"1\", \"name\": \"example.com\"}]`,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tRequestBody:  \"\",\n\t\t\t\t\tURL:          \"/v2/dns/12345/records\",\n\t\t\t\t\tResponseBody: `[]`,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t})\n\tdefer server.Close()\n\n\tchanges := &plan.Changes{}\n\tprovider := &CivoProvider{\n\t\tClient: *client,\n\t}\n\tchanges.Create = []*endpoint.Endpoint{\n\t\t{DNSName: \"new.ext-dns-test.example.com\", Targets: endpoint.Targets{\"target\"}, RecordType: endpoint.RecordTypeA},\n\t\t{DNSName: \"new.ext-dns-test-with-ttl.example.com\", Targets: endpoint.Targets{\"target\"}, RecordType: endpoint.RecordTypeA, RecordTTL: 100},\n\t}\n\tchanges.Delete = []*endpoint.Endpoint{{DNSName: \"foobar.ext-dns-test.example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"target\"}}}\n\tchanges.UpdateOld = []*endpoint.Endpoint{{DNSName: \"foobar.ext-dns-test.example.de\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"target-old\"}}}\n\tchanges.UpdateNew = []*endpoint.Endpoint{{DNSName: \"foobar.ext-dns-test.foo.com\", Targets: endpoint.Targets{\"target-new\"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 100}}\n\terr := provider.ApplyChanges(t.Context(), changes)\n\tassert.NoError(t, err)\n}\n\nfunc TestCivoApplyChangesError(t *testing.T) {\n\tlog.SetOutput(io.Discard)\n\tclient, server, _ := civogo.NewAdvancedClientForTesting([]civogo.ConfigAdvanceClientForTesting{\n\t\t{\n\t\t\tMethod: \"GET\",\n\t\t\tValue: []civogo.ValueAdvanceClientForTesting{\n\t\t\t\t{\n\t\t\t\t\tRequestBody:  \"\",\n\t\t\t\t\tURL:          \"/v2/dns\",\n\t\t\t\t\tResponseBody: `[{\"id\": \"12345\", \"account_id\": \"1\", \"name\": \"example.com\"}]`,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tRequestBody:  \"\",\n\t\t\t\t\tURL:          \"/v2/dns/12345/records\",\n\t\t\t\t\tResponseBody: `[]`,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t})\n\n\tdefer server.Close()\n\n\tprovider := &CivoProvider{\n\t\tClient: *client,\n\t}\n\n\tcases := []struct {\n\t\tName    string\n\t\tchanges *plan.Changes\n\t}{\n\t\t{\n\t\t\tName: \"invalid record type from processCreateActions\",\n\t\t\tchanges: &plan.Changes{\n\t\t\t\tCreate: []*endpoint.Endpoint{\n\t\t\t\t\tendpoint.NewEndpoint(\"bad.example.com\", \"AAAA\", \"1.2.3.4\"),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName: \"invalid record type from processUpdateActions\",\n\t\t\tchanges: &plan.Changes{\n\t\t\t\tUpdateOld: []*endpoint.Endpoint{\n\t\t\t\t\tendpoint.NewEndpoint(\"bad.example.com\", \"AAAA\", \"1.2.3.4\"),\n\t\t\t\t},\n\t\t\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\t\t\tendpoint.NewEndpoint(\"bad.example.com\", \"AAAA\", \"5.6.7.8\"),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range cases {\n\t\tt.Run(tt.Name, func(t *testing.T) {\n\t\t\terr := provider.ApplyChanges(t.Context(), tt.changes)\n\t\t\tassert.Equal(t, \"invalid Record Type: AAAA\", string(err.Error()))\n\t\t})\n\t}\n}\n\nfunc TestCivoProviderFetchZones(t *testing.T) {\n\tclient, server, _ := civogo.NewClientForTesting(map[string]string{\n\t\t\"/v2/dns\": `[\n\t\t\t{\"id\": \"12345\", \"account_id\": \"1\", \"name\": \"example.com\"},\n\t\t\t{\"id\": \"12346\", \"account_id\": \"1\", \"name\": \"example.net\"}\n\t\t\t]`,\n\t})\n\tdefer server.Close()\n\tprovider := &CivoProvider{\n\t\tClient: *client,\n\t}\n\n\texpected, err := client.ListDNSDomains()\n\tif err != nil {\n\t\tt.Errorf(\"should not fail, %s\", err)\n\t}\n\tzones, err := provider.fetchZones()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tassert.ElementsMatch(t, zones, expected)\n}\nfunc TestCivoProviderFetchZonesWithFilter(t *testing.T) {\n\tclient, server, _ := civogo.NewClientForTesting(map[string]string{\n\t\t\"/v2/dns\": `[\n\t\t\t{\"id\": \"12345\", \"account_id\": \"1\", \"name\": \"example.com\"},\n\t\t\t{\"id\": \"12346\", \"account_id\": \"1\", \"name\": \"example.net\"}\n\t\t\t]`,\n\t})\n\tdefer server.Close()\n\tprovider := &CivoProvider{\n\t\tClient:       *client,\n\t\tdomainFilter: endpoint.NewDomainFilter([]string{\".com\"}),\n\t}\n\n\texpected := []civogo.DNSDomain{\n\t\t{ID: \"12345\", Name: \"example.com\", AccountID: \"1\"},\n\t}\n\n\tactual, err := provider.fetchZones()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tassert.ElementsMatch(t, expected, actual)\n}\n\nfunc TestCivoProviderFetchRecords(t *testing.T) {\n\tclient, server, _ := civogo.NewClientForTesting(map[string]string{\n\t\t\"/v2/dns/12345/records\": `[\n\t\t\t{\"id\": \"1\", \"domain_id\":\"12345\", \"account_id\": \"1\", \"name\": \"www\", \"type\": \"A\", \"value\": \"10.0.0.0\", \"ttl\": 600},\n\t\t\t{\"id\": \"2\", \"account_id\": \"1\", \"domain_id\":\"12345\", \"name\": \"mail\", \"type\": \"A\", \"value\": \"10.0.0.1\", \"ttl\": 600}\n\t\t\t]`,\n\t})\n\tdefer server.Close()\n\tprovider := &CivoProvider{\n\t\tClient: *client,\n\t}\n\n\texpected, err := client.ListDNSRecords(\"12345\")\n\tassert.NoError(t, err)\n\n\tactual, err := provider.fetchRecords(\"12345\")\n\tassert.NoError(t, err)\n\n\tassert.ElementsMatch(t, expected, actual)\n}\n\nfunc TestCivoProviderFetchRecordsWithError(t *testing.T) {\n\tlog.SetOutput(io.Discard)\n\tclient, server, _ := civogo.NewClientForTesting(map[string]string{\n\t\t\"/v2/dns/12345/records\": `[\n\t\t\t{\"id\": \"1\", \"domain_id\":\"12345\", \"account_id\": \"1\", \"name\": \"www\", \"type\": \"A\", \"value\": \"10.0.0.0\", \"ttl\": 600},\n\t\t\t{\"id\": \"2\", \"account_id\": \"1\", \"domain_id\":\"12345\", \"name\": \"mail\", \"type\": \"A\", \"value\": \"10.0.0.1\", \"ttl\": 600}\n\t\t\t]`,\n\t})\n\tdefer server.Close()\n\tprovider := &CivoProvider{\n\t\tClient: *client,\n\t}\n\n\t_, err := provider.fetchRecords(\"235698\")\n\tassert.Error(t, err)\n}\n\nfunc TestCivo_getStrippedRecordName(t *testing.T) {\n\tassert.Empty(t, getStrippedRecordName(civogo.DNSDomain{\n\t\tName: \"foo.com\",\n\t}, endpoint.Endpoint{\n\t\tDNSName: \"foo.com\",\n\t}))\n\n\tassert.Equal(t, \"api\", getStrippedRecordName(civogo.DNSDomain{\n\t\tName: \"foo.com\",\n\t}, endpoint.Endpoint{\n\t\tDNSName: \"api.foo.com\",\n\t}))\n}\n\nfunc TestCivo_convertRecordType(t *testing.T) {\n\trecord, err := convertRecordType(\"A\")\n\trecordA := civogo.DNSRecordType(civogo.DNSRecordTypeA)\n\trequire.NoError(t, err)\n\tassert.Equal(t, recordA, record)\n\n\trecord, err = convertRecordType(\"CNAME\")\n\trecordCName := civogo.DNSRecordType(civogo.DNSRecordTypeCName)\n\trequire.NoError(t, err)\n\tassert.Equal(t, recordCName, record)\n\n\trecord, err = convertRecordType(\"TXT\")\n\trecordTXT := civogo.DNSRecordType(civogo.DNSRecordTypeTXT)\n\trequire.NoError(t, err)\n\tassert.Equal(t, recordTXT, record)\n\n\trecord, err = convertRecordType(\"SRV\")\n\trecordSRV := civogo.DNSRecordType(civogo.DNSRecordTypeSRV)\n\trequire.NoError(t, err)\n\tassert.Equal(t, recordSRV, record)\n\n\t_, err = convertRecordType(\"INVALID\")\n\trequire.Error(t, err)\n\n\tassert.Equal(t, \"invalid Record Type: INVALID\", err.Error())\n}\n\nfunc TestCivoProviderGetRecordID(t *testing.T) {\n\tzone := civogo.DNSDomain{\n\t\tID:   \"12345\",\n\t\tName: \"test.com\",\n\t}\n\n\trecord := []civogo.DNSRecord{{\n\t\tID:          \"1\",\n\t\tType:        \"A\",\n\t\tName:        \"www\",\n\t\tValue:       \"10.0.0.0\",\n\t\tDNSDomainID: \"12345\",\n\t\tTTL:         600,\n\t}, {\n\t\tID:          \"2\",\n\t\tType:        \"A\",\n\t\tName:        \"api\",\n\t\tValue:       \"10.0.0.1\",\n\t\tDNSDomainID: \"12345\",\n\t\tTTL:         600,\n\t}}\n\n\tendPoint := endpoint.Endpoint{DNSName: \"www.test.com\", Targets: endpoint.Targets{\"10.0.0.0\"}, RecordType: \"A\"}\n\tid := getRecordID(record, zone, endPoint)\n\n\tassert.Equal(t, id[0].ID, record[0].ID)\n}\n\nfunc TestCivo_submitChangesCreate(t *testing.T) {\n\tlog.SetOutput(io.Discard)\n\tclient, server, _ := civogo.NewAdvancedClientForTesting([]civogo.ConfigAdvanceClientForTesting{\n\t\t{\n\t\t\tMethod: \"POST\",\n\t\t\tValue: []civogo.ValueAdvanceClientForTesting{\n\t\t\t\t{\n\t\t\t\t\tRequestBody: `{\"type\":\"MX\",\"name\":\"mail\",\"value\":\"10.0.0.1\",\"priority\":10,\"ttl\":600}`,\n\t\t\t\t\tURL:         \"/v2/dns/12345/records\",\n\t\t\t\t\tResponseBody: `{\n\t\t\t\t\t\t\"id\": \"76cc107f-fbef-4e2b-b97f-f5d34f4075d3\",\n\t\t\t\t\t\t\"account_id\": \"1\",\n\t\t\t\t\t\t\"domain_id\": \"12345\",\n\t\t\t\t\t\t\"name\": \"mail\",\n\t\t\t\t\t\t\"value\": \"10.0.0.1\",\n\t\t\t\t\t\t\"type\": \"MX\",\n\t\t\t\t\t\t\"priority\": 10,\n\t\t\t\t\t\t\"ttl\": 600\n\t\t\t\t\t}`,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tMethod: \"DELETE\",\n\t\t\tValue: []civogo.ValueAdvanceClientForTesting{\n\t\t\t\t{\n\t\t\t\t\tURL:          \"/v2/dns/12345/records/76cc107f-fbef-4e2b-b97f-f5d34f4075d3\",\n\t\t\t\t\tResponseBody: `{\"result\": \"success\"}`,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tURL:          \"/v2/dns/12345/records/error-record-id\",\n\t\t\t\t\tResponseBody: `{\"result\": \"error\", \"error\": \"failed to delete record\"}`,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tMethod: \"PUT\",\n\t\t\tValue: []civogo.ValueAdvanceClientForTesting{\n\t\t\t\t{\n\t\t\t\t\tRequestBody: `{\"type\":\"MX\",\"name\":\"mail\",\"value\":\"10.0.0.2\",\"priority\":10,\"ttl\":600}`,\n\t\t\t\t\tURL:         \"/v2/dns/12345/records/76cc107f-fbef-4e2b-b97f-f5d34f4075d3\",\n\t\t\t\t\tResponseBody: `{\n\t\t\t\t\t\t\"id\": \"76cc107f-fbef-4e2b-b97f-f5d34f4075d3\",\n\t\t\t\t\t\t\"account_id\": \"1\",\n\t\t\t\t\t\t\"domain_id\": \"12345\",\n\t\t\t\t\t\t\"name\": \"mail\",\n\t\t\t\t\t\t\"value\": \"10.0.0.2\",\n\t\t\t\t\t\t\"type\": \"MX\",\n\t\t\t\t\t\t\"priority\": 10,\n\t\t\t\t\t\t\"ttl\": 600\n\t\t\t\t\t}`,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tRequestBody:  `{\"type\":\"MX\",\"name\":\"mail\",\"value\":\"10.0.0.3\",\"priority\":10,\"ttl\":600}`,\n\t\t\t\t\tURL:          \"/v2/dns/12345/records/error-record-id\",\n\t\t\t\t\tResponseBody: `{\"result\": \"error\", \"error\": \"failed to update record\"}`,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t})\n\tdefer server.Close()\n\n\tprovider := &CivoProvider{\n\t\tClient: *client,\n\t\tDryRun: true,\n\t}\n\n\tcases := []struct {\n\t\tname           string\n\t\tchanges        *CivoChanges\n\t\texpectedResult error\n\t}{\n\t\t{\n\t\t\tname:           \"changes slice is empty\",\n\t\t\tchanges:        &CivoChanges{},\n\t\t\texpectedResult: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"changes slice has changes and update changes\",\n\t\t\tchanges: &CivoChanges{\n\t\t\t\tCreates: []*CivoChangeCreate{\n\t\t\t\t\t{\n\t\t\t\t\t\tDomain: civogo.DNSDomain{\n\t\t\t\t\t\t\tID:        \"12345\",\n\t\t\t\t\t\t\tAccountID: \"1\",\n\t\t\t\t\t\t\tName:      \"example.com\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\tOptions: &civogo.DNSRecordConfig{\n\t\t\t\t\t\t\tType:     \"MX\",\n\t\t\t\t\t\t\tName:     \"mail\",\n\t\t\t\t\t\t\tValue:    \"10.0.0.1\",\n\t\t\t\t\t\t\tPriority: 10,\n\t\t\t\t\t\t\tTTL:      600,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\n\t\t\t\tUpdates: []*CivoChangeUpdate{\n\t\t\t\t\t{\n\t\t\t\t\t\tDomain: civogo.DNSDomain{\n\t\t\t\t\t\t\tID:        \"12345\",\n\t\t\t\t\t\t\tAccountID: \"2\",\n\t\t\t\t\t\t\tName:      \"example.org\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tDomain: civogo.DNSDomain{\n\t\t\t\t\t\t\tID:        \"67890\",\n\t\t\t\t\t\t\tAccountID: \"3\",\n\t\t\t\t\t\t\tName:      \"example.COM\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedResult: nil,\n\t\t},\n\t}\n\n\tfor _, c := range cases {\n\t\tt.Run(c.name, func(t *testing.T) {\n\t\t\terr := provider.submitChanges(*c.changes)\n\t\t\tassert.NoError(t, err)\n\t\t})\n\t}\n}\n\nfunc TestCivo_submitChangesDelete(t *testing.T) {\n\tclient, server, _ := civogo.NewClientForTesting(map[string]string{\n\t\t\"/v2/dns/12345/records/76cc107f-fbef-4e2b-b97f-f5d34f4075d3\": `{\"result\": \"success\"}`,\n\t})\n\tdefer server.Close()\n\n\tprovider := &CivoProvider{\n\t\tClient: *client,\n\t\tDryRun: false,\n\t}\n\n\tchanges := CivoChanges{\n\t\tDeletes: []*CivoChangeDelete{\n\t\t\t{\n\t\t\t\tDomain: civogo.DNSDomain{ID: \"12345\", AccountID: \"1\", Name: \"example.com\"},\n\t\t\t\tDomainRecord: civogo.DNSRecord{\n\t\t\t\t\tID:          \"76cc107f-fbef-4e2b-b97f-f5d34f4075d3\",\n\t\t\t\t\tAccountID:   \"1\",\n\t\t\t\t\tDNSDomainID: \"12345\",\n\t\t\t\t\tName:        \"mail\",\n\t\t\t\t\tValue:       \"10.0.0.2\",\n\t\t\t\t\tType:        \"MX\",\n\t\t\t\t\tPriority:    10,\n\t\t\t\t\tTTL:         600,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\terr := provider.submitChanges(changes)\n\tassert.NoError(t, err)\n}\n\nfunc TestCivoChangesEmpty(t *testing.T) {\n\t// Test empty CivoChanges\n\tc := &CivoChanges{}\n\tassert.True(t, c.Empty())\n\n\t// Test CivoChanges with Creates\n\tc = &CivoChanges{\n\t\tCreates: []*CivoChangeCreate{\n\t\t\t{\n\t\t\t\tDomain: civogo.DNSDomain{\n\t\t\t\t\tID:        \"12345\",\n\t\t\t\t\tAccountID: \"1\",\n\t\t\t\t\tName:      \"example.com\",\n\t\t\t\t},\n\t\t\t\tOptions: &civogo.DNSRecordConfig{\n\t\t\t\t\tType:     civogo.DNSRecordTypeA,\n\t\t\t\t\tName:     \"www\",\n\t\t\t\t\tValue:    \"192.1.1.1\",\n\t\t\t\t\tPriority: 0,\n\t\t\t\t\tTTL:      600,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tassert.False(t, c.Empty())\n\n\t// Test CivoChanges with Updates\n\tc = &CivoChanges{\n\t\tUpdates: []*CivoChangeUpdate{\n\t\t\t{\n\t\t\t\tDomain: civogo.DNSDomain{\n\t\t\t\t\tID:        \"12345\",\n\t\t\t\t\tAccountID: \"1\",\n\t\t\t\t\tName:      \"example.com\",\n\t\t\t\t},\n\t\t\t\tDomainRecord: civogo.DNSRecord{\n\t\t\t\t\tID:          \"76cc107f-fbef-4e2b-b97f-f5d34f4075d3\",\n\t\t\t\t\tAccountID:   \"1\",\n\t\t\t\t\tDNSDomainID: \"12345\",\n\t\t\t\t\tName:        \"mail\",\n\t\t\t\t\tValue:       \"192.168.1.2\",\n\t\t\t\t\tType:        \"MX\",\n\t\t\t\t\tPriority:    10,\n\t\t\t\t\tTTL:         600,\n\t\t\t\t},\n\t\t\t\tOptions: civogo.DNSRecordConfig{\n\t\t\t\t\tType:     \"MX\",\n\t\t\t\t\tName:     \"mail\",\n\t\t\t\t\tValue:    \"192.168.1.3\",\n\t\t\t\t\tPriority: 10,\n\t\t\t\t\tTTL:      600,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tassert.False(t, c.Empty())\n\n\t// Test CivoChanges with Deletes\n\tc = &CivoChanges{\n\t\tDeletes: []*CivoChangeDelete{\n\t\t\t{\n\t\t\t\tDomain: civogo.DNSDomain{\n\t\t\t\t\tID:        \"12345\",\n\t\t\t\t\tAccountID: \"1\",\n\t\t\t\t\tName:      \"example.com\",\n\t\t\t\t},\n\t\t\t\tDomainRecord: civogo.DNSRecord{\n\t\t\t\t\tID:          \"76cc107f-fbef-4e2b-b97f-f5d34f4075d3\",\n\t\t\t\t\tAccountID:   \"1\",\n\t\t\t\t\tDNSDomainID: \"12345\",\n\t\t\t\t\tName:        \"mail\",\n\t\t\t\t\tValue:       \"192.168.1.3\",\n\t\t\t\t\tType:        \"MX\",\n\t\t\t\t\tPriority:    10,\n\t\t\t\t\tTTL:         600,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tassert.False(t, c.Empty())\n\n\t// Test CivoChanges with Creates, Updates, and Deletes\n\tc = &CivoChanges{\n\t\tCreates: []*CivoChangeCreate{\n\t\t\t{\n\t\t\t\tDomain: civogo.DNSDomain{\n\t\t\t\t\tID:        \"12345\",\n\t\t\t\t\tAccountID: \"1\",\n\t\t\t\t\tName:      \"example.com\",\n\t\t\t\t},\n\t\t\t\tOptions: &civogo.DNSRecordConfig{\n\t\t\t\t\tType:     civogo.DNSRecordTypeA,\n\t\t\t\t\tName:     \"www\",\n\t\t\t\t\tValue:    \"192.1.1.1\",\n\t\t\t\t\tPriority: 0,\n\t\t\t\t\tTTL:      600,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tUpdates: []*CivoChangeUpdate{\n\t\t\t{\n\t\t\t\tDomain: civogo.DNSDomain{\n\t\t\t\t\tID:        \"12345\",\n\t\t\t\t\tAccountID: \"1\",\n\t\t\t\t\tName:      \"example.com\",\n\t\t\t\t},\n\t\t\t\tDomainRecord: civogo.DNSRecord{\n\t\t\t\t\tID:          \"76cc107f-fbef-4e2b-b97f-f5d34f4075d3\",\n\t\t\t\t\tAccountID:   \"1\",\n\t\t\t\t\tDNSDomainID: \"12345\",\n\t\t\t\t\tName:        \"mail\",\n\t\t\t\t\tValue:       \"192.168.1.2\",\n\t\t\t\t\tType:        \"MX\",\n\t\t\t\t\tPriority:    10,\n\t\t\t\t\tTTL:         600,\n\t\t\t\t},\n\t\t\t\tOptions: civogo.DNSRecordConfig{\n\t\t\t\t\tType:     \"MX\",\n\t\t\t\t\tName:     \"mail\",\n\t\t\t\t\tValue:    \"192.168.1.3\",\n\t\t\t\t\tPriority: 10,\n\t\t\t\t\tTTL:      600,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tDeletes: []*CivoChangeDelete{\n\t\t\t{\n\t\t\t\tDomain: civogo.DNSDomain{\n\t\t\t\t\tID:        \"12345\",\n\t\t\t\t\tAccountID: \"1\",\n\t\t\t\t\tName:      \"example.com\",\n\t\t\t\t},\n\t\t\t\tDomainRecord: civogo.DNSRecord{\n\t\t\t\t\tID:          \"76cc107f-fbef-4e2b-b97f-f5d34f4075d3\",\n\t\t\t\t\tAccountID:   \"1\",\n\t\t\t\t\tDNSDomainID: \"12345\",\n\t\t\t\t\tName:        \"mail\",\n\t\t\t\t\tValue:       \"192.168.1.3\",\n\t\t\t\t\tType:        \"MX\",\n\t\t\t\t\tPriority:    10,\n\t\t\t\t\tTTL:         600,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tassert.False(t, c.Empty())\n}\n\n// This function is an adapted copy of the testify package's ElementsMatch function with the\n// call to ObjectsAreEqual replaced with cmp.Equal which better handles struct's with pointers to\n// other structs. It also ignores ordering when comparing unlike cmp.Equal.\nfunc elementsMatch(t *testing.T, listA, listB any) bool {\n\tswitch {\n\tcase listA == nil && listB == nil:\n\t\treturn true\n\tcase listA == nil:\n\t\treturn isEmpty(listB)\n\tcase listB == nil:\n\t\treturn isEmpty(listA)\n\t}\n\n\taKind := reflect.TypeOf(listA).Kind()\n\tbKind := reflect.TypeOf(listB).Kind()\n\n\tif aKind != reflect.Array && aKind != reflect.Slice {\n\t\treturn assert.Fail(t, fmt.Sprintf(\"%q has an unsupported type %s\", listA, aKind))\n\t}\n\n\tif bKind != reflect.Array && bKind != reflect.Slice {\n\t\treturn assert.Fail(t, fmt.Sprintf(\"%q has an unsupported type %s\", listB, bKind))\n\t}\n\n\taValue := reflect.ValueOf(listA)\n\tbValue := reflect.ValueOf(listB)\n\n\taLen := aValue.Len()\n\tbLen := bValue.Len()\n\n\tif aLen != bLen {\n\t\treturn assert.Fail(t, fmt.Sprintf(\"lengths don't match: %d != %d\", aLen, bLen))\n\t}\n\n\t// Mark indexes in bValue that we already used\n\tvisited := make([]bool, bLen)\n\tfor i := range aLen {\n\t\telement := aValue.Index(i).Interface()\n\t\tfound := false\n\t\tfor j := range bLen {\n\t\t\tif visited[j] {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif cmp.Equal(bValue.Index(j).Interface(), element) {\n\t\t\t\tvisited[j] = true\n\t\t\t\tfound = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif !found {\n\t\t\treturn assert.Fail(t, fmt.Sprintf(\"element %s appears more times in %s than in %s\", element, aValue, bValue))\n\t\t}\n\t}\n\n\treturn true\n}\n\nfunc isEmpty(xs any) bool {\n\tif xs != nil {\n\t\tobjValue := reflect.ValueOf(xs)\n\t\treturn objValue.Len() == 0\n\t}\n\treturn true\n}\n"
  },
  {
    "path": "provider/cloudflare/OWNERS",
    "content": "approvers:\n  - sheerun\n"
  },
  {
    "path": "provider/cloudflare/cloudflare.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage cloudflare\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/cloudflare/cloudflare-go/v5\"\n\t\"github.com/cloudflare/cloudflare-go/v5/addressing\"\n\t\"github.com/cloudflare/cloudflare-go/v5/custom_hostnames\"\n\t\"github.com/cloudflare/cloudflare-go/v5/dns\"\n\t\"github.com/cloudflare/cloudflare-go/v5/option\"\n\t\"github.com/cloudflare/cloudflare-go/v5/zones\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"golang.org/x/net/publicsuffix\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n)\n\ntype changeAction int\n\nconst (\n\t// Environment variable names for CloudFlare authentication\n\tcfAPIEmailEnvKey = \"CF_API_EMAIL\"\n\tcfAPIKeyEnvKey   = \"CF_API_KEY\"\n\tcfAPITokenEnvKey = \"CF_API_TOKEN\"\n\n\t// cloudFlareCreate is a ChangeAction enum value\n\tcloudFlareCreate changeAction = iota\n\t// cloudFlareDelete is a ChangeAction enum value\n\tcloudFlareDelete\n\t// cloudFlareUpdate is a ChangeAction enum value\n\tcloudFlareUpdate\n\t// defaultTTL 1 = automatic\n\tdefaultTTL = 1\n\n\t// Cloudflare tier limitations https://developers.cloudflare.com/dns/manage-dns-records/reference/record-attributes/#availability\n\tfreeZoneMaxCommentLength = 100\n\tpaidZoneMaxCommentLength = 500\n)\n\nvar changeActionNames = map[changeAction]string{\n\tcloudFlareCreate: \"CREATE\",\n\tcloudFlareDelete: \"DELETE\",\n\tcloudFlareUpdate: \"UPDATE\",\n}\n\nfunc (action changeAction) String() string {\n\treturn changeActionNames[action]\n}\n\ntype DNSRecordIndex struct {\n\tName    string\n\tType    string\n\tContent string\n}\n\ntype DNSRecordsMap map[DNSRecordIndex]dns.RecordResponse\n\nvar recordTypeProxyNotSupported = map[string]bool{\n\t\"LOC\": true,\n\t\"MX\":  true,\n\t\"NS\":  true,\n\t\"SPF\": true,\n\t\"TXT\": true,\n\t\"SRV\": true,\n}\n\n// cloudFlareDNS is the subset of the CloudFlare API that we actually use.  Add methods as required. Signatures must match exactly.\ntype cloudFlareDNS interface {\n\tZoneIDByName(zoneName string) (string, error)\n\tListZones(ctx context.Context, params zones.ZoneListParams) autoPager[zones.Zone]\n\tGetZone(ctx context.Context, zoneID string) (*zones.Zone, error)\n\tListDNSRecords(ctx context.Context, params dns.RecordListParams) autoPager[dns.RecordResponse]\n\tBatchDNSRecords(ctx context.Context, params dns.RecordBatchParams) (*dns.RecordBatchResponse, error)\n\tCreateDNSRecord(ctx context.Context, params dns.RecordNewParams) (*dns.RecordResponse, error)\n\tDeleteDNSRecord(ctx context.Context, recordID string, params dns.RecordDeleteParams) error\n\tUpdateDNSRecord(ctx context.Context, recordID string, params dns.RecordUpdateParams) (*dns.RecordResponse, error)\n\tListDataLocalizationRegionalHostnames(ctx context.Context, params addressing.RegionalHostnameListParams) autoPager[addressing.RegionalHostnameListResponse]\n\tCreateDataLocalizationRegionalHostname(ctx context.Context, params addressing.RegionalHostnameNewParams) error\n\tUpdateDataLocalizationRegionalHostname(ctx context.Context, hostname string, params addressing.RegionalHostnameEditParams) error\n\tDeleteDataLocalizationRegionalHostname(ctx context.Context, hostname string, params addressing.RegionalHostnameDeleteParams) error\n\tCustomHostnames(ctx context.Context, zoneID string) autoPager[custom_hostnames.CustomHostnameListResponse]\n\tDeleteCustomHostname(ctx context.Context, customHostnameID string, params custom_hostnames.CustomHostnameDeleteParams) error\n\tCreateCustomHostname(ctx context.Context, zoneID string, ch customHostname) error\n}\n\ntype zoneService struct {\n\tservice *cloudflare.Client\n}\n\nfunc (z zoneService) ZoneIDByName(zoneName string) (string, error) {\n\t// Use v4 API to find zone by name\n\tparams := zones.ZoneListParams{\n\t\tName: cloudflare.F(zoneName),\n\t}\n\n\titer := z.service.Zones.ListAutoPaging(context.Background(), params)\n\tfor zone := range autoPagerIterator(iter) {\n\t\tif zone.Name == zoneName {\n\t\t\treturn zone.ID, nil\n\t\t}\n\t}\n\n\tif err := iter.Err(); err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to list zones from CloudFlare API: %w\", err)\n\t}\n\n\treturn \"\", fmt.Errorf(\"zone %q not found in CloudFlare account - verify the zone exists and API credentials have access to it\", zoneName)\n}\n\nfunc (z zoneService) CreateDNSRecord(ctx context.Context, params dns.RecordNewParams) (*dns.RecordResponse, error) {\n\treturn z.service.DNS.Records.New(ctx, params)\n}\n\nfunc (z zoneService) ListDNSRecords(ctx context.Context, params dns.RecordListParams) autoPager[dns.RecordResponse] {\n\treturn z.service.DNS.Records.ListAutoPaging(ctx, params)\n}\n\nfunc (z zoneService) UpdateDNSRecord(ctx context.Context, recordID string, params dns.RecordUpdateParams) (*dns.RecordResponse, error) {\n\treturn z.service.DNS.Records.Update(ctx, recordID, params)\n}\n\nfunc (z zoneService) DeleteDNSRecord(ctx context.Context, recordID string, params dns.RecordDeleteParams) error {\n\t_, err := z.service.DNS.Records.Delete(ctx, recordID, params)\n\treturn err\n}\n\nfunc (z zoneService) ListZones(ctx context.Context, params zones.ZoneListParams) autoPager[zones.Zone] {\n\treturn z.service.Zones.ListAutoPaging(ctx, params)\n}\n\nfunc (z zoneService) GetZone(ctx context.Context, zoneID string) (*zones.Zone, error) {\n\treturn z.service.Zones.Get(ctx, zones.ZoneGetParams{ZoneID: cloudflare.F(zoneID)})\n}\n\n// listZonesV4Params returns the appropriate Zone List Params for v4 API\nfunc listZonesV4Params() zones.ZoneListParams {\n\treturn zones.ZoneListParams{}\n}\n\ntype DNSRecordsConfig struct {\n\tPerPage             int\n\tComment             string\n\tBatchChangeSize     int\n\tBatchChangeInterval time.Duration\n}\n\nfunc (c *DNSRecordsConfig) trimAndValidateComment(dnsName, comment string, paidZone func(string) bool) string {\n\tif len(comment) <= freeZoneMaxCommentLength {\n\t\treturn comment\n\t}\n\n\tmaxLength := freeZoneMaxCommentLength\n\tif paidZone(dnsName) {\n\t\tmaxLength = paidZoneMaxCommentLength\n\t}\n\n\tif len(comment) > maxLength {\n\t\tlog.Warnf(\"DNS record comment is invalid. Trimming comment of %s. To avoid endless syncs, please set it to less than %d chars.\", dnsName, maxLength)\n\t\treturn comment[:maxLength]\n\t}\n\n\treturn comment\n}\n\nfunc (p *CloudFlareProvider) ZoneHasPaidPlan(hostname string) bool {\n\tzone, err := publicsuffix.EffectiveTLDPlusOne(hostname)\n\tif err != nil {\n\t\tlog.Errorf(\"Failed to get effective TLD+1 for hostname %s %v\", hostname, err)\n\t\treturn false\n\t}\n\tzoneID, err := p.Client.ZoneIDByName(zone)\n\tif err != nil {\n\t\tlog.Errorf(\"Failed to get zone %s by name %v\", zone, err)\n\t\treturn false\n\t}\n\n\tzoneDetails, err := p.Client.GetZone(context.Background(), zoneID)\n\tif err != nil {\n\t\tlog.Errorf(\"Failed to get zone %s details %v\", zone, err)\n\t\treturn false\n\t}\n\n\treturn zoneDetails.Plan.IsSubscribed //nolint:staticcheck // SA1019: Plan.IsSubscribed is deprecated but no replacement available yet\n}\n\n// CloudFlareProvider is an implementation of Provider for CloudFlare DNS.\ntype CloudFlareProvider struct {\n\tprovider.BaseProvider\n\tClient cloudFlareDNS\n\t// only consider hosted zones managing domains ending in this suffix\n\tdomainFilter           *endpoint.DomainFilter\n\tzoneIDFilter           provider.ZoneIDFilter\n\tproxiedByDefault       bool\n\tDryRun                 bool\n\tCustomHostnamesConfig  CustomHostnamesConfig\n\tDNSRecordsConfig       DNSRecordsConfig\n\tRegionalServicesConfig RegionalServicesConfig\n}\n\n// cloudFlareChange differentiates between ChangeActions\ntype cloudFlareChange struct {\n\tAction              changeAction\n\tResourceRecord      dns.RecordResponse\n\tRegionalHostname    regionalHostname\n\tCustomHostnames     map[string]customHostname\n\tCustomHostnamesPrev []string\n}\n\nfunc convertCloudflareError(err error) error {\n\t// Handle CloudFlare v5 SDK errors according to the documentation:\n\t// https://github.com/cloudflare/cloudflare-go?tab=readme-ov-file#errors\n\tvar apierr *cloudflare.Error\n\tif errors.As(err, &apierr) {\n\t\t// Rate limit errors (429) and server errors (5xx) should be treated as soft errors\n\t\t// so that external-dns will retry them later\n\t\tif apierr.StatusCode == http.StatusTooManyRequests || apierr.StatusCode >= http.StatusInternalServerError {\n\t\t\treturn provider.NewSoftError(err)\n\t\t}\n\t\t// For other structured API errors (4xx), return the error unchanged\n\t\t// Note: We must NOT call err.Error() on v5 cloudflare.Error types with nil internal fields\n\t\treturn err\n\t}\n\n\t// Transport-level errors that the SDK does not wrap as *cloudflare.Error.\n\t// Both are transient and worth retrying at the external-dns level.\n\t//   ErrUnexpectedEOF – connection closed mid-response (during body read)\n\t//   EOF              – connection closed before any response bytes arrived\n\tif errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {\n\t\treturn provider.NewSoftError(err)\n\t}\n\n\t// The v5 SDK's retry logic and error wrapping can hide the structured error type,\n\t// so we need string matching to catch rate limits in wrapped errors like:\n\t// \"exceeded available rate limit retries\" from the SDK's auto-retry mechanism.\n\terrMsg := strings.ToLower(err.Error())\n\tif strings.Contains(errMsg, \"rate limit\") ||\n\t\tstrings.Contains(errMsg, \"429\") ||\n\t\tstrings.Contains(errMsg, \"exceeded available rate limit retries\") ||\n\t\tstrings.Contains(errMsg, \"too many requests\") {\n\t\treturn provider.NewSoftError(err)\n\t}\n\n\treturn err\n}\n\n// newProvider initializes a new CloudFlare DNS based Provider.\nfunc newProvider(\n\tdomainFilter *endpoint.DomainFilter,\n\tzoneIDFilter provider.ZoneIDFilter,\n\tproxiedByDefault bool,\n\tdryRun bool,\n\tregionalServicesConfig RegionalServicesConfig,\n\tcustomHostnamesConfig CustomHostnamesConfig,\n\tdnsRecordsConfig DNSRecordsConfig,\n) (*CloudFlareProvider, error) {\n\t// initialize via chosen auth method and returns new API object\n\n\tvar client *cloudflare.Client\n\n\ttoken := os.Getenv(cfAPITokenEnvKey)\n\tif token != \"\" {\n\t\tif trimed, ok := strings.CutPrefix(token, \"file:\"); ok {\n\t\t\ttokenBytes, err := os.ReadFile(trimed)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"failed to read %s from file: %w\", cfAPITokenEnvKey, err)\n\t\t\t}\n\t\t\ttoken = strings.TrimSpace(string(tokenBytes))\n\t\t}\n\t\tclient = cloudflare.NewClient(\n\t\t\toption.WithAPIToken(token),\n\t\t)\n\t} else {\n\t\tapiKey := os.Getenv(cfAPIKeyEnvKey)\n\t\tapiEmail := os.Getenv(cfAPIEmailEnvKey)\n\t\tif apiKey == \"\" || apiEmail == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"cloudflare credentials are not configured: set either %s or both %s and %s environment variables\", cfAPITokenEnvKey, cfAPIKeyEnvKey, cfAPIEmailEnvKey)\n\t\t}\n\t\tclient = cloudflare.NewClient(\n\t\t\toption.WithAPIKey(apiKey),\n\t\t\toption.WithAPIEmail(apiEmail),\n\t\t)\n\t}\n\n\tif regionalServicesConfig.RegionKey != \"\" {\n\t\tregionalServicesConfig.Enabled = true\n\t}\n\n\treturn &CloudFlareProvider{\n\t\tClient:                 zoneService{client},\n\t\tdomainFilter:           domainFilter,\n\t\tzoneIDFilter:           zoneIDFilter,\n\t\tproxiedByDefault:       proxiedByDefault,\n\t\tCustomHostnamesConfig:  customHostnamesConfig,\n\t\tDryRun:                 dryRun,\n\t\tRegionalServicesConfig: regionalServicesConfig,\n\t\tDNSRecordsConfig:       dnsRecordsConfig,\n\t}, nil\n}\n\n// New creates a Cloudflare provider from the given configuration.\nfunc New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) {\n\treturn newProvider(\n\t\tdomainFilter,\n\t\tprovider.NewZoneIDFilter(cfg.ZoneIDFilter),\n\t\tcfg.CloudflareProxied,\n\t\tcfg.DryRun,\n\t\tRegionalServicesConfig{\n\t\t\tEnabled:   cfg.CloudflareRegionalServices,\n\t\t\tRegionKey: cfg.CloudflareRegionKey,\n\t\t},\n\t\tCustomHostnamesConfig{\n\t\t\tEnabled:              cfg.CloudflareCustomHostnames,\n\t\t\tMinTLSVersion:        cfg.CloudflareCustomHostnamesMinTLSVersion,\n\t\t\tCertificateAuthority: cfg.CloudflareCustomHostnamesCertificateAuthority,\n\t\t},\n\t\tDNSRecordsConfig{\n\t\t\tPerPage:             cfg.CloudflareDNSRecordsPerPage,\n\t\t\tComment:             cfg.CloudflareDNSRecordsComment,\n\t\t\tBatchChangeSize:     cfg.BatchChangeSize,\n\t\t\tBatchChangeInterval: cfg.BatchChangeInterval,\n\t\t},\n\t)\n}\n\n// Zones returns the list of hosted zones.\nfunc (p *CloudFlareProvider) Zones(ctx context.Context) ([]zones.Zone, error) {\n\tvar result []zones.Zone\n\n\t// if there is a zoneIDfilter configured\n\t// && if the filter isn't just a blank string (used in tests)\n\tif len(p.zoneIDFilter.ZoneIDs) > 0 && p.zoneIDFilter.ZoneIDs[0] != \"\" {\n\t\tlog.Debugln(\"zoneIDFilter configured. only looking up zone IDs defined\")\n\t\tfor _, zoneID := range p.zoneIDFilter.ZoneIDs {\n\t\t\tlog.Debugf(\"looking up zone %q\", zoneID)\n\t\t\tdetailResponse, err := p.Client.GetZone(ctx, zoneID)\n\t\t\tif err != nil {\n\t\t\t\tlog.Errorf(\"zone %q lookup failed, %v\", zoneID, err)\n\t\t\t\treturn result, convertCloudflareError(err)\n\t\t\t}\n\t\t\tlog.WithFields(log.Fields{\n\t\t\t\t\"zoneName\": detailResponse.Name,\n\t\t\t\t\"zoneID\":   detailResponse.ID,\n\t\t\t}).Debugln(\"adding zone for consideration\")\n\t\t\tresult = append(result, *detailResponse)\n\t\t}\n\t\treturn result, nil\n\t}\n\n\tlog.Debugln(\"no zoneIDFilter configured, looking at all zones\")\n\n\tparams := listZonesV4Params()\n\titer := p.Client.ListZones(ctx, params)\n\tfor zone := range autoPagerIterator(iter) {\n\t\tif !p.domainFilter.Match(zone.Name) {\n\t\t\tlog.Debugf(\"zone %q not in domain filter\", zone.Name)\n\t\t\tcontinue\n\t\t}\n\t\tlog.WithFields(log.Fields{\n\t\t\t\"zoneName\": zone.Name,\n\t\t\t\"zoneID\":   zone.ID,\n\t\t}).Debugln(\"adding zone for consideration\")\n\t\tresult = append(result, zone)\n\t}\n\tif iter.Err() != nil {\n\t\treturn nil, convertCloudflareError(iter.Err())\n\t}\n\n\treturn result, nil\n}\n\n// Records returns the list of records.\nfunc (p *CloudFlareProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {\n\tzones, err := p.Zones(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar endpoints []*endpoint.Endpoint\n\tfor _, zone := range zones {\n\t\trecords, err := p.getDNSRecordsMap(ctx, zone.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\t// nil if custom hostnames are not enabled\n\t\tchs, chErr := p.listCustomHostnamesWithPagination(ctx, zone.ID)\n\t\tif chErr != nil {\n\t\t\treturn nil, chErr\n\t\t}\n\n\t\t// As CloudFlare does not support \"sets\" of targets, but instead returns\n\t\t// a single entry for each name/type/target, we have to group by name\n\t\t// and record to allow the planner to calculate the correct plan. See #992.\n\t\tzoneEndpoints := p.groupByNameAndTypeWithCustomHostnames(records, chs)\n\n\t\tif err := p.addEnpointsProviderSpecificRegionKeyProperty(ctx, zone.ID, zoneEndpoints); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tendpoints = append(endpoints, zoneEndpoints...)\n\t}\n\n\treturn endpoints, nil\n}\n\n// ApplyChanges applies a given set of changes in a given zone.\nfunc (p *CloudFlareProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {\n\tvar cloudflareChanges []*cloudFlareChange\n\n\t// if custom hostnames are enabled, deleting first allows to avoid conflicts with the new ones\n\tif p.CustomHostnamesConfig.Enabled {\n\t\tfor _, e := range changes.Delete {\n\t\t\tfor _, target := range e.Targets {\n\t\t\t\tchange, err := p.newCloudFlareChange(cloudFlareDelete, e, target, nil)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlog.Errorf(\"failed to create cloudflare change: %v\", err)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tcloudflareChanges = append(cloudflareChanges, change)\n\t\t\t}\n\t\t}\n\t}\n\n\tfor _, e := range changes.Create {\n\t\tfor _, target := range e.Targets {\n\t\t\tchange, err := p.newCloudFlareChange(cloudFlareCreate, e, target, nil)\n\t\t\tif err != nil {\n\t\t\t\tlog.Errorf(\"failed to create cloudflare change: %v\", err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcloudflareChanges = append(cloudflareChanges, change)\n\t\t}\n\t}\n\n\tfor i, desired := range changes.UpdateNew {\n\t\tcurrent := changes.UpdateOld[i]\n\n\t\tadd, remove, leave := provider.Difference(current.Targets, desired.Targets)\n\n\t\tfor _, a := range remove {\n\t\t\tchange, err := p.newCloudFlareChange(cloudFlareDelete, current, a, current)\n\t\t\tif err != nil {\n\t\t\t\tlog.Errorf(\"failed to create cloudflare change: %v\", err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcloudflareChanges = append(cloudflareChanges, change)\n\t\t}\n\n\t\tfor _, a := range add {\n\t\t\tchange, err := p.newCloudFlareChange(cloudFlareCreate, desired, a, current)\n\t\t\tif err != nil {\n\t\t\t\tlog.Errorf(\"failed to create cloudflare change: %v\", err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcloudflareChanges = append(cloudflareChanges, change)\n\t\t}\n\n\t\tfor _, a := range leave {\n\t\t\tchange, err := p.newCloudFlareChange(cloudFlareUpdate, desired, a, current)\n\t\t\tif err != nil {\n\t\t\t\tlog.Errorf(\"failed to create cloudflare change: %v\", err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tcloudflareChanges = append(cloudflareChanges, change)\n\t\t}\n\t}\n\n\t// TODO: consider deleting before creating even if custom hostnames are not in use\n\tif !p.CustomHostnamesConfig.Enabled {\n\t\tfor _, e := range changes.Delete {\n\t\t\tfor _, target := range e.Targets {\n\t\t\t\tchange, err := p.newCloudFlareChange(cloudFlareDelete, e, target, nil)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlog.Errorf(\"failed to create cloudflare change: %v\", err)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tcloudflareChanges = append(cloudflareChanges, change)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn p.submitChanges(ctx, cloudflareChanges)\n}\n\n// submitChanges takes a zone and a collection of Changes and sends them as a single transaction.\nfunc (p *CloudFlareProvider) submitChanges(ctx context.Context, changes []*cloudFlareChange) error {\n\t// return early if there is nothing to change\n\tif len(changes) == 0 {\n\t\tlog.Info(\"All records are already up to date\")\n\t\treturn nil\n\t}\n\n\tzones, err := p.Zones(ctx)\n\tif err != nil {\n\t\treturn err\n\t}\n\t// separate into per-zone change sets to be passed to the API.\n\tchangesByZone := p.changesByZone(zones, changes)\n\n\tvar failedZones []string\n\tfor zoneID, zoneChanges := range changesByZone {\n\t\tvar failedChange bool\n\n\t\tfor _, change := range zoneChanges {\n\t\t\tlogFields := log.Fields{\n\t\t\t\t\"record\": change.ResourceRecord.Name,\n\t\t\t\t\"type\":   change.ResourceRecord.Type,\n\t\t\t\t\"ttl\":    change.ResourceRecord.TTL,\n\t\t\t\t\"action\": change.Action.String(),\n\t\t\t\t\"zone\":   zoneID,\n\t\t\t}\n\t\t\tlog.WithFields(logFields).Info(\"Changing record.\")\n\t\t}\n\n\t\tif p.DryRun {\n\t\t\t// In dry-run mode, skip all DNS record mutations but still process\n\t\t\t// regional hostname changes (which have their own dry-run logging).\n\t\t\tif p.RegionalServicesConfig.Enabled {\n\t\t\t\tdesiredRegionalHostnames, err := desiredRegionalHostnames(zoneChanges)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"failed to build desired regional hostnames: %w\", err)\n\t\t\t\t}\n\t\t\t\tif len(desiredRegionalHostnames) > 0 {\n\t\t\t\t\tregionalHostnames, err := p.listDataLocalisationRegionalHostnames(ctx, zoneID)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn fmt.Errorf(\"could not fetch regional hostnames from zone, %w\", err)\n\t\t\t\t\t}\n\t\t\t\t\tregionalHostnamesChanges := regionalHostnamesChanges(desiredRegionalHostnames, regionalHostnames)\n\t\t\t\t\tif !p.submitRegionalHostnameChanges(ctx, zoneID, regionalHostnamesChanges) {\n\t\t\t\t\t\tfailedChange = true\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tif failedChange {\n\t\t\t\tfailedZones = append(failedZones, zoneID)\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\t// Fetch the zone's current DNS records and custom hostnames once, rather\n\t\t// than once per change, to avoid O(n) API calls for n changes.\n\t\trecords, err := p.getDNSRecordsMap(ctx, zoneID)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"could not fetch records from zone, %w\", err)\n\t\t}\n\t\tchs, chErr := p.listCustomHostnamesWithPagination(ctx, zoneID)\n\t\tif chErr != nil {\n\t\t\treturn fmt.Errorf(\"could not fetch custom hostnames from zone, %w\", chErr)\n\t\t}\n\n\t\t// Apply custom hostname side-effects (separate Cloudflare API), then\n\t\t// classify DNS record changes into batch collections.\n\t\tif p.processCustomHostnameChanges(ctx, zoneID, zoneChanges, chs) {\n\t\t\tfailedChange = true\n\t\t}\n\t\tbc := p.buildBatchCollections(zoneID, zoneChanges, records)\n\n\t\tif p.submitDNSRecordChanges(ctx, zoneID, bc, records) {\n\t\t\tfailedChange = true\n\t\t}\n\t\tif p.RegionalServicesConfig.Enabled {\n\t\t\tdesiredRegionalHostnames, err := desiredRegionalHostnames(zoneChanges)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to build desired regional hostnames: %w\", err)\n\t\t\t}\n\t\t\tif len(desiredRegionalHostnames) > 0 {\n\t\t\t\tregionalHostnames, err := p.listDataLocalisationRegionalHostnames(ctx, zoneID)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"could not fetch regional hostnames from zone, %w\", err)\n\t\t\t\t}\n\t\t\t\tregionalHostnamesChanges := regionalHostnamesChanges(desiredRegionalHostnames, regionalHostnames)\n\t\t\t\tif !p.submitRegionalHostnameChanges(ctx, zoneID, regionalHostnamesChanges) {\n\t\t\t\t\tfailedChange = true\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif failedChange {\n\t\t\tfailedZones = append(failedZones, zoneID)\n\t\t}\n\t}\n\n\tif len(failedZones) > 0 {\n\t\treturn provider.NewSoftErrorf(\"failed to submit all changes for the following zones: %q\", failedZones)\n\t}\n\n\treturn nil\n}\n\n// parseTagsAnnotation is the single helper method to handle tags from the annotation string.\n// It splits the string, cleans up whitespace, and sorts the tags to create a canonical representation.\nfunc parseTagsAnnotation(tagString string) []string {\n\ttags := strings.Split(tagString, \",\")\n\tcleanedTags := make([]string, 0, len(tags))\n\tfor _, tag := range tags {\n\t\ttrimmed := strings.TrimSpace(tag)\n\t\tif trimmed != \"\" {\n\t\t\tcleanedTags = append(cleanedTags, trimmed)\n\t\t}\n\t}\n\tsort.Strings(cleanedTags)\n\treturn cleanedTags\n}\n\n// AdjustEndpoints modifies the endpoints as needed by the specific provider\nfunc (p *CloudFlareProvider) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) {\n\tvar adjustedEndpoints []*endpoint.Endpoint\n\tfor _, e := range endpoints {\n\t\tproxied := shouldBeProxied(e, p.proxiedByDefault)\n\t\tif proxied {\n\t\t\te.RecordTTL = 0\n\t\t}\n\t\te.SetProviderSpecificProperty(annotations.CloudflareProxiedKey, strconv.FormatBool(proxied))\n\n\t\tif p.CustomHostnamesConfig.Enabled {\n\t\t\t// sort custom hostnames in annotation to properly detect changes\n\t\t\tif customHostnames := getEndpointCustomHostnames(e); len(customHostnames) > 1 {\n\t\t\t\tsort.Strings(customHostnames)\n\t\t\t\te.SetProviderSpecificProperty(annotations.CloudflareCustomHostnameKey, strings.Join(customHostnames, \",\"))\n\t\t\t}\n\t\t} else {\n\t\t\t// ignore custom hostnames annotations if not enabled\n\t\t\te.DeleteProviderSpecificProperty(annotations.CloudflareCustomHostnameKey)\n\t\t}\n\n\t\tif val, ok := e.GetProviderSpecificProperty(annotations.CloudflareTagsKey); ok {\n\t\t\tsortedTags := parseTagsAnnotation(val)\n\t\t\te.SetProviderSpecificProperty(annotations.CloudflareTagsKey, strings.Join(sortedTags, \",\"))\n\t\t}\n\n\t\tp.adjustEndpointProviderSpecificRegionKeyProperty(e)\n\n\t\tif p.DNSRecordsConfig.Comment != \"\" {\n\t\t\tif _, found := e.GetProviderSpecificProperty(annotations.CloudflareRecordCommentKey); !found {\n\t\t\t\te.SetProviderSpecificProperty(annotations.CloudflareRecordCommentKey, p.DNSRecordsConfig.Comment)\n\t\t\t}\n\t\t}\n\n\t\tadjustedEndpoints = append(adjustedEndpoints, e)\n\t}\n\treturn adjustedEndpoints, nil\n}\n\n// changesByZone separates a multi-zone change into a single change per zone.\nfunc (p *CloudFlareProvider) changesByZone(zones []zones.Zone, changeSet []*cloudFlareChange) map[string][]*cloudFlareChange {\n\tchanges := make(map[string][]*cloudFlareChange)\n\tzoneNameIDMapper := provider.ZoneIDName{}\n\n\tfor _, z := range zones {\n\t\tzoneNameIDMapper.Add(z.ID, z.Name)\n\t\tchanges[z.ID] = []*cloudFlareChange{}\n\t}\n\n\tfor _, c := range changeSet {\n\t\tzoneID, _ := zoneNameIDMapper.FindZone(c.ResourceRecord.Name)\n\t\tif zoneID == \"\" {\n\t\t\tlog.Debugf(\"Skipping record %q because no hosted zone matching record DNS Name was detected\", c.ResourceRecord.Name)\n\t\t\tcontinue\n\t\t}\n\t\tchanges[zoneID] = append(changes[zoneID], c)\n\t}\n\n\treturn changes\n}\n\nfunc (p *CloudFlareProvider) getRecordID(records DNSRecordsMap, record dns.RecordResponse) string {\n\tif zoneRecord, ok := records[DNSRecordIndex{Name: record.Name, Type: string(record.Type), Content: record.Content}]; ok {\n\t\treturn zoneRecord.ID\n\t}\n\treturn \"\"\n}\n\nfunc (p *CloudFlareProvider) newCloudFlareChange(action changeAction, ep *endpoint.Endpoint, target string, current *endpoint.Endpoint) (*cloudFlareChange, error) {\n\tttl := dns.TTL(defaultTTL)\n\tproxied := shouldBeProxied(ep, p.proxiedByDefault)\n\n\tif ep.RecordTTL.IsConfigured() {\n\t\tttl = dns.TTL(ep.RecordTTL)\n\t}\n\n\tprevCustomHostnames := []string{}\n\tnewCustomHostnames := map[string]customHostname{}\n\tif p.CustomHostnamesConfig.Enabled {\n\t\tif current != nil {\n\t\t\tprevCustomHostnames = getEndpointCustomHostnames(current)\n\t\t}\n\t\tfor _, v := range getEndpointCustomHostnames(ep) {\n\t\t\tnewCustomHostnames[v] = p.newCustomHostname(v, ep.DNSName)\n\t\t}\n\t}\n\n\t// Load comment from program flag\n\tcomment := p.DNSRecordsConfig.Comment\n\tif val, ok := ep.GetProviderSpecificProperty(annotations.CloudflareRecordCommentKey); ok {\n\t\t// Replace comment with Ingress annotation\n\t\tcomment = val\n\t}\n\n\tvar tags []string\n\tif val, ok := ep.GetProviderSpecificProperty(annotations.CloudflareTagsKey); ok {\n\t\ttags = parseTagsAnnotation(val)\n\t}\n\n\tif len(comment) > freeZoneMaxCommentLength {\n\t\tcomment = p.DNSRecordsConfig.trimAndValidateComment(ep.DNSName, comment, p.ZoneHasPaidPlan)\n\t}\n\n\tvar priority float64\n\tif ep.RecordType == \"MX\" {\n\t\tmxRecord, err := endpoint.NewMXRecord(target)\n\t\tif err != nil {\n\t\t\treturn &cloudFlareChange{}, fmt.Errorf(\"failed to parse MX record target %q: %w\", target, err)\n\t\t} else {\n\t\t\tpriority = float64(*mxRecord.GetPriority())\n\t\t\ttarget = *mxRecord.GetHost()\n\t\t}\n\t}\n\n\treturn &cloudFlareChange{\n\t\tAction: action,\n\t\tResourceRecord: dns.RecordResponse{\n\t\t\tName:     ep.DNSName,\n\t\t\tTTL:      ttl,\n\t\t\tProxied:  proxied,\n\t\t\tType:     dns.RecordResponseType(ep.RecordType),\n\t\t\tContent:  target,\n\t\t\tComment:  comment,\n\t\t\tTags:     tags,\n\t\t\tPriority: priority,\n\t\t},\n\t\tRegionalHostname:    p.regionalHostname(ep),\n\t\tCustomHostnamesPrev: prevCustomHostnames,\n\t\tCustomHostnames:     newCustomHostnames,\n\t}, nil\n}\n\nfunc newDNSRecordIndex(r dns.RecordResponse) DNSRecordIndex {\n\treturn DNSRecordIndex{Name: r.Name, Type: string(r.Type), Content: r.Content}\n}\n\n// getDNSRecordsMap retrieves all DNS records for a given zone and returns them as a DNSRecordsMap.\nfunc (p *CloudFlareProvider) getDNSRecordsMap(ctx context.Context, zoneID string) (DNSRecordsMap, error) {\n\t// for faster getRecordID lookup\n\trecordsMap := make(DNSRecordsMap)\n\tparams := dns.RecordListParams{ZoneID: cloudflare.F(zoneID)}\n\tif p.DNSRecordsConfig.PerPage > 0 {\n\t\tparams.PerPage = cloudflare.F(float64(p.DNSRecordsConfig.PerPage))\n\t}\n\titer := p.Client.ListDNSRecords(ctx, params)\n\tfor record := range autoPagerIterator(iter) {\n\t\trecordsMap[newDNSRecordIndex(record)] = record\n\t}\n\tif iter.Err() != nil {\n\t\treturn nil, convertCloudflareError(iter.Err())\n\t}\n\treturn recordsMap, nil\n}\n\nfunc shouldBeProxied(ep *endpoint.Endpoint, proxiedByDefault bool) bool {\n\tproxied := proxiedByDefault\n\n\tfor _, v := range ep.ProviderSpecific {\n\t\tif v.Name == annotations.CloudflareProxiedKey {\n\t\t\tb, err := strconv.ParseBool(v.Value)\n\t\t\tif err != nil {\n\t\t\t\tlog.Errorf(\"Failed to parse annotation [%q]: %v\", annotations.CloudflareProxiedKey, err)\n\t\t\t} else {\n\t\t\t\tproxied = b\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif recordTypeProxyNotSupported[ep.RecordType] {\n\t\tproxied = false\n\t}\n\treturn proxied\n}\n\nfunc getEndpointCustomHostnames(ep *endpoint.Endpoint) []string {\n\tfor _, v := range ep.ProviderSpecific {\n\t\tif v.Name == annotations.CloudflareCustomHostnameKey {\n\t\t\tcustomHostnames := strings.Split(v.Value, \",\")\n\t\t\treturn customHostnames\n\t\t}\n\t}\n\treturn []string{}\n}\n\nfunc (p *CloudFlareProvider) groupByNameAndTypeWithCustomHostnames(records DNSRecordsMap, chs customHostnamesMap) []*endpoint.Endpoint {\n\tvar endpoints []*endpoint.Endpoint\n\n\t// group supported records by name and type\n\tgroups := map[string][]dns.RecordResponse{}\n\n\tfor _, r := range records {\n\t\tif !p.SupportedAdditionalRecordTypes(string(r.Type)) {\n\t\t\tcontinue\n\t\t}\n\n\t\tgroupBy := r.Name + string(r.Type)\n\t\tif _, ok := groups[groupBy]; !ok {\n\t\t\tgroups[groupBy] = []dns.RecordResponse{}\n\t\t}\n\n\t\tgroups[groupBy] = append(groups[groupBy], r)\n\t}\n\n\t// map custom origin to custom hostname, custom origin should match to a dns record\n\tcustomHostnames := map[string][]string{}\n\n\tfor _, c := range chs {\n\t\tcustomHostnames[c.customOriginServer] = append(customHostnames[c.customOriginServer], c.hostname)\n\t}\n\n\t// create a single endpoint with all the targets for each name/type\n\tfor _, records := range groups {\n\t\tif len(records) == 0 {\n\t\t\treturn endpoints\n\t\t}\n\t\ttargets := make([]string, len(records))\n\t\tfor i, record := range records {\n\t\t\tif records[i].Type == \"MX\" {\n\t\t\t\ttargets[i] = fmt.Sprintf(\"%v %v\", record.Priority, record.Content)\n\t\t\t} else {\n\t\t\t\ttargets[i] = record.Content\n\t\t\t}\n\t\t}\n\t\te := endpoint.NewEndpointWithTTL(\n\t\t\trecords[0].Name,\n\t\t\tstring(records[0].Type),\n\t\t\tendpoint.TTL(records[0].TTL),\n\t\t\ttargets...)\n\t\tproxied := records[0].Proxied\n\t\tif e == nil {\n\t\t\tcontinue\n\t\t}\n\t\te = e.WithProviderSpecific(annotations.CloudflareProxiedKey, strconv.FormatBool(proxied))\n\t\t// noop (customHostnames is empty) if custom hostnames feature is not in use\n\t\tif customHostnames, ok := customHostnames[records[0].Name]; ok {\n\t\t\tsort.Strings(customHostnames)\n\t\t\te = e.WithProviderSpecific(annotations.CloudflareCustomHostnameKey, strings.Join(customHostnames, \",\"))\n\t\t}\n\n\t\tif records[0].Comment != \"\" {\n\t\t\te = e.WithProviderSpecific(annotations.CloudflareRecordCommentKey, records[0].Comment)\n\t\t}\n\n\t\tif records[0].Tags != nil {\n\t\t\tif tags, ok := records[0].Tags.([]string); ok && len(tags) > 0 {\n\t\t\t\tsort.Strings(tags)\n\t\t\t\te = e.WithProviderSpecific(annotations.CloudflareTagsKey, strings.Join(tags, \",\"))\n\t\t\t}\n\t\t}\n\n\t\tendpoints = append(endpoints, e)\n\t}\n\treturn endpoints\n}\n\n// SupportedRecordType returns true if the record type is supported by the provider\nfunc (p *CloudFlareProvider) SupportedAdditionalRecordTypes(recordType string) bool {\n\tswitch recordType {\n\tcase endpoint.RecordTypeMX:\n\t\treturn true\n\tdefault:\n\t\treturn provider.SupportedRecordType(recordType)\n\t}\n}\n"
  },
  {
    "path": "provider/cloudflare/cloudflare_batch.go",
    "content": "/*\nCopyright 2026 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage cloudflare\n\nimport (\n\t\"context\"\n\t\"time\"\n\n\t\"github.com/cloudflare/cloudflare-go/v5\"\n\t\"github.com/cloudflare/cloudflare-go/v5/dns\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\nconst (\n\t// defaultBatchChangeSize is the default maximum number of DNS record\n\t// operations included in each Cloudflare batch request.\n\tdefaultBatchChangeSize = 200\n)\n\n// batchCollections groups the parallel slices that are assembled while\n// classifying per-zone changes. It is passed as a unit to\n// submitDNSRecordChanges and chunkBatchChanges, replacing the previous\n// eight-parameter signatures and making it clear which slices travel\n// together.\ntype batchCollections struct {\n\t// Batch API parameters in server-execution order: deletes → puts → posts.\n\tbatchDeletes []dns.RecordBatchParamsDelete\n\tbatchPosts   []dns.RecordBatchParamsPostUnion\n\tbatchPuts    []dns.BatchPutUnionParam\n\n\t// Parallel change slices — one entry per batch param, in the same order,\n\t// so that a failed batch chunk can be replayed with per-record fallback.\n\tdeleteChanges []*cloudFlareChange\n\tcreateChanges []*cloudFlareChange\n\tupdateChanges []*cloudFlareChange\n\n\t// fallbackUpdates holds changes for record types whose batch-put param\n\t// requires structured Data fields (e.g. SRV, CAA). These are submitted\n\t// via individual UpdateDNSRecord calls instead of the batch API.\n\tfallbackUpdates []*cloudFlareChange\n}\n\n// batchChunk holds a DNS record batch request alongside the source changes\n// that produced it, enabling per-record fallback when a batch fails.\ntype batchChunk struct {\n\tparams        dns.RecordBatchParams\n\tdeleteChanges []*cloudFlareChange\n\tcreateChanges []*cloudFlareChange\n\tupdateChanges []*cloudFlareChange\n}\n\n// BatchDNSRecords submits a batch of DNS record changes to the Cloudflare API.\nfunc (z zoneService) BatchDNSRecords(ctx context.Context, params dns.RecordBatchParams) (*dns.RecordBatchResponse, error) {\n\treturn z.service.DNS.Records.Batch(ctx, params)\n}\n\n// getUpdateDNSRecordParam returns the RecordUpdateParams for an individual update.\nfunc getUpdateDNSRecordParam(zoneID string, cfc cloudFlareChange) dns.RecordUpdateParams {\n\treturn dns.RecordUpdateParams{\n\t\tZoneID: cloudflare.F(zoneID),\n\t\tBody: dns.RecordUpdateParamsBody{\n\t\t\tName:     cloudflare.F(cfc.ResourceRecord.Name),\n\t\t\tTTL:      cloudflare.F(cfc.ResourceRecord.TTL),\n\t\t\tProxied:  cloudflare.F(cfc.ResourceRecord.Proxied),\n\t\t\tType:     cloudflare.F(dns.RecordUpdateParamsBodyType(cfc.ResourceRecord.Type)),\n\t\t\tContent:  cloudflare.F(cfc.ResourceRecord.Content),\n\t\t\tPriority: cloudflare.F(cfc.ResourceRecord.Priority),\n\t\t\tComment:  cloudflare.F(cfc.ResourceRecord.Comment),\n\t\t\tTags:     cloudflare.F(cfc.ResourceRecord.Tags),\n\t\t},\n\t}\n}\n\n// getCreateDNSRecordParam returns the RecordNewParams for an individual create.\nfunc getCreateDNSRecordParam(zoneID string, cfc *cloudFlareChange) dns.RecordNewParams {\n\treturn dns.RecordNewParams{\n\t\tZoneID: cloudflare.F(zoneID),\n\t\tBody: dns.RecordNewParamsBody{\n\t\t\tName:     cloudflare.F(cfc.ResourceRecord.Name),\n\t\t\tTTL:      cloudflare.F(cfc.ResourceRecord.TTL),\n\t\t\tProxied:  cloudflare.F(cfc.ResourceRecord.Proxied),\n\t\t\tType:     cloudflare.F(dns.RecordNewParamsBodyType(cfc.ResourceRecord.Type)),\n\t\t\tContent:  cloudflare.F(cfc.ResourceRecord.Content),\n\t\t\tPriority: cloudflare.F(cfc.ResourceRecord.Priority),\n\t\t\tComment:  cloudflare.F(cfc.ResourceRecord.Comment),\n\t\t\tTags:     cloudflare.F(cfc.ResourceRecord.Tags),\n\t\t},\n\t}\n}\n\n// chunkBatchChanges splits DNS record batch operations into batchChunks,\n// each containing at most <limit> total operations. Operations are distributed\n// in server-execution order: deletes first, then puts, then posts.\n// The parallel change slices track which cloudFlareChange produced each batch\n// param so that individual fallback is possible when a chunk fails.\nfunc chunkBatchChanges(zoneID string, bc batchCollections, limit int) []batchChunk {\n\tdeletes, deleteChanges := bc.batchDeletes, bc.deleteChanges\n\tposts, createChanges := bc.batchPosts, bc.createChanges\n\tputs, updateChanges := bc.batchPuts, bc.updateChanges\n\n\tvar chunks []batchChunk\n\tdi, pi, ui := 0, 0, 0\n\tfor di < len(deletes) || pi < len(posts) || ui < len(puts) {\n\t\tremaining := limit\n\t\tchunk := batchChunk{\n\t\t\tparams: dns.RecordBatchParams{ZoneID: cloudflare.F(zoneID)},\n\t\t}\n\n\t\tif di < len(deletes) && remaining > 0 {\n\t\t\tend := min(di+remaining, len(deletes))\n\t\t\tchunk.params.Deletes = cloudflare.F(deletes[di:end])\n\t\t\tchunk.deleteChanges = deleteChanges[di:end]\n\t\t\tremaining -= end - di\n\t\t\tdi = end\n\t\t}\n\n\t\tif ui < len(puts) && remaining > 0 {\n\t\t\tend := min(ui+remaining, len(puts))\n\t\t\tchunk.params.Puts = cloudflare.F(puts[ui:end])\n\t\t\tchunk.updateChanges = updateChanges[ui:end]\n\t\t\tremaining -= end - ui\n\t\t\tui = end\n\t\t}\n\n\t\tif pi < len(posts) && remaining > 0 {\n\t\t\tend := min(pi+remaining, len(posts))\n\t\t\tchunk.params.Posts = cloudflare.F(posts[pi:end])\n\t\t\tchunk.createChanges = createChanges[pi:end]\n\t\t\tpi = end\n\t\t}\n\n\t\tchunks = append(chunks, chunk)\n\t}\n\treturn chunks\n}\n\n// tagsFromResponse converts a RecordResponse Tags field (any) to the typed tag slice.\nfunc tagsFromResponse(tags any) []dns.RecordTagsParam {\n\tif ts, ok := tags.([]string); ok {\n\t\treturn ts\n\t}\n\treturn nil\n}\n\n// buildBatchPostParam constructs a RecordBatchParamsPost for creating a DNS record in a batch.\nfunc buildBatchPostParam(r dns.RecordResponse) dns.RecordBatchParamsPost {\n\treturn dns.RecordBatchParamsPost{\n\t\tName:     cloudflare.F(r.Name),\n\t\tTTL:      cloudflare.F(r.TTL),\n\t\tType:     cloudflare.F(dns.RecordBatchParamsPostsType(r.Type)),\n\t\tContent:  cloudflare.F(r.Content),\n\t\tProxied:  cloudflare.F(r.Proxied),\n\t\tPriority: cloudflare.F(r.Priority),\n\t\tComment:  cloudflare.F(r.Comment),\n\t\tTags:     cloudflare.F[any](tagsFromResponse(r.Tags)),\n\t}\n}\n\n// buildBatchPutParam constructs a BatchPutUnionParam for updating a DNS record in a batch.\n// Returns (nil, false) for record types that use structured Data fields (e.g. SRV, CAA),\n// which fall back to individual UpdateDNSRecord calls.\nfunc buildBatchPutParam(id string, r dns.RecordResponse) (dns.BatchPutUnionParam, bool) {\n\ttags := tagsFromResponse(r.Tags)\n\tcomment := r.Comment\n\tswitch r.Type {\n\tcase dns.RecordResponseTypeA:\n\t\treturn dns.BatchPutARecordParam{\n\t\t\tID: cloudflare.F(id),\n\t\t\tARecordParam: dns.ARecordParam{\n\t\t\t\tName:    cloudflare.F(r.Name),\n\t\t\t\tTTL:     cloudflare.F(r.TTL),\n\t\t\t\tType:    cloudflare.F(dns.ARecordTypeA),\n\t\t\t\tContent: cloudflare.F(r.Content),\n\t\t\t\tProxied: cloudflare.F(r.Proxied),\n\t\t\t\tComment: cloudflare.F(comment),\n\t\t\t\tTags:    cloudflare.F(tags),\n\t\t\t},\n\t\t}, true\n\tcase dns.RecordResponseTypeAAAA:\n\t\treturn dns.BatchPutAAAARecordParam{\n\t\t\tID: cloudflare.F(id),\n\t\t\tAAAARecordParam: dns.AAAARecordParam{\n\t\t\t\tName:    cloudflare.F(r.Name),\n\t\t\t\tTTL:     cloudflare.F(r.TTL),\n\t\t\t\tType:    cloudflare.F(dns.AAAARecordTypeAAAA),\n\t\t\t\tContent: cloudflare.F(r.Content),\n\t\t\t\tProxied: cloudflare.F(r.Proxied),\n\t\t\t\tComment: cloudflare.F(comment),\n\t\t\t\tTags:    cloudflare.F(tags),\n\t\t\t},\n\t\t}, true\n\tcase dns.RecordResponseTypeCNAME:\n\t\treturn dns.BatchPutCNAMERecordParam{\n\t\t\tID: cloudflare.F(id),\n\t\t\tCNAMERecordParam: dns.CNAMERecordParam{\n\t\t\t\tName:    cloudflare.F(r.Name),\n\t\t\t\tTTL:     cloudflare.F(r.TTL),\n\t\t\t\tType:    cloudflare.F(dns.CNAMERecordTypeCNAME),\n\t\t\t\tContent: cloudflare.F(r.Content),\n\t\t\t\tProxied: cloudflare.F(r.Proxied),\n\t\t\t\tComment: cloudflare.F(comment),\n\t\t\t\tTags:    cloudflare.F(tags),\n\t\t\t},\n\t\t}, true\n\tcase dns.RecordResponseTypeTXT:\n\t\treturn dns.BatchPutTXTRecordParam{\n\t\t\tID: cloudflare.F(id),\n\t\t\tTXTRecordParam: dns.TXTRecordParam{\n\t\t\t\tName:    cloudflare.F(r.Name),\n\t\t\t\tTTL:     cloudflare.F(r.TTL),\n\t\t\t\tType:    cloudflare.F(dns.TXTRecordTypeTXT),\n\t\t\t\tContent: cloudflare.F(r.Content),\n\t\t\t\tProxied: cloudflare.F(r.Proxied),\n\t\t\t\tComment: cloudflare.F(comment),\n\t\t\t\tTags:    cloudflare.F(tags),\n\t\t\t},\n\t\t}, true\n\tcase dns.RecordResponseTypeMX:\n\t\treturn dns.BatchPutMXRecordParam{\n\t\t\tID: cloudflare.F(id),\n\t\t\tMXRecordParam: dns.MXRecordParam{\n\t\t\t\tName:     cloudflare.F(r.Name),\n\t\t\t\tTTL:      cloudflare.F(r.TTL),\n\t\t\t\tType:     cloudflare.F(dns.MXRecordTypeMX),\n\t\t\t\tContent:  cloudflare.F(r.Content),\n\t\t\t\tProxied:  cloudflare.F(r.Proxied),\n\t\t\t\tComment:  cloudflare.F(comment),\n\t\t\t\tTags:     cloudflare.F(tags),\n\t\t\t\tPriority: cloudflare.F(r.Priority),\n\t\t\t},\n\t\t}, true\n\tcase dns.RecordResponseTypeNS:\n\t\treturn dns.BatchPutNSRecordParam{\n\t\t\tID: cloudflare.F(id),\n\t\t\tNSRecordParam: dns.NSRecordParam{\n\t\t\t\tName:    cloudflare.F(r.Name),\n\t\t\t\tTTL:     cloudflare.F(r.TTL),\n\t\t\t\tType:    cloudflare.F(dns.NSRecordTypeNS),\n\t\t\t\tContent: cloudflare.F(r.Content),\n\t\t\t\tProxied: cloudflare.F(r.Proxied),\n\t\t\t\tComment: cloudflare.F(comment),\n\t\t\t\tTags:    cloudflare.F(tags),\n\t\t\t},\n\t\t}, true\n\tdefault:\n\t\t// Record types that use structured Data fields (SRV, CAA, etc.) are not\n\t\t// supported in the generic batch put and fall back to individual updates.\n\t\treturn nil, false\n\t}\n}\n\n// buildBatchCollections classifies per-zone changes into batch collections.\n// Custom hostname side-effects are handled separately by\n// processCustomHostnameChanges before this is called.\nfunc (p *CloudFlareProvider) buildBatchCollections(\n\tzoneID string,\n\tchanges []*cloudFlareChange,\n\trecords DNSRecordsMap,\n) batchCollections {\n\tvar bc batchCollections\n\n\tfor _, change := range changes {\n\t\tlogFields := log.Fields{\n\t\t\t\"record\": change.ResourceRecord.Name,\n\t\t\t\"type\":   change.ResourceRecord.Type,\n\t\t\t\"ttl\":    change.ResourceRecord.TTL,\n\t\t\t\"action\": change.Action.String(),\n\t\t\t\"zone\":   zoneID,\n\t\t}\n\n\t\tswitch change.Action {\n\t\tcase cloudFlareCreate:\n\t\t\tbc.batchPosts = append(bc.batchPosts, buildBatchPostParam(change.ResourceRecord))\n\t\t\tbc.createChanges = append(bc.createChanges, change)\n\n\t\tcase cloudFlareDelete:\n\t\t\trecordID := p.getRecordID(records, change.ResourceRecord)\n\t\t\tif recordID == \"\" {\n\t\t\t\tlog.WithFields(logFields).Errorf(\"failed to find previous record: %v\", change.ResourceRecord)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tbc.batchDeletes = append(bc.batchDeletes, dns.RecordBatchParamsDelete{ID: cloudflare.F(recordID)})\n\t\t\tbc.deleteChanges = append(bc.deleteChanges, change)\n\n\t\tcase cloudFlareUpdate:\n\t\t\trecordID := p.getRecordID(records, change.ResourceRecord)\n\t\t\tif recordID == \"\" {\n\t\t\t\tlog.WithFields(logFields).Errorf(\"failed to find previous record: %v\", change.ResourceRecord)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif putParam, ok := buildBatchPutParam(recordID, change.ResourceRecord); ok {\n\t\t\t\tbc.batchPuts = append(bc.batchPuts, putParam)\n\t\t\t\tbc.updateChanges = append(bc.updateChanges, change)\n\t\t\t} else {\n\t\t\t\tlog.WithFields(logFields).Debugf(\"batch PUT not supported for type %s, using individual update\", change.ResourceRecord.Type)\n\t\t\t\tbc.fallbackUpdates = append(bc.fallbackUpdates, change)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn bc\n}\n\n// submitDNSRecordChanges submits the pre-built batch collections and any\n// fallback individual updates for a single zone. When a batch chunk fails,\n// the provider falls back to individual API calls for that chunk's changes\n// (since the batch is transactional — failure means full rollback).\n// Returns true if any operation fails.\nfunc (p *CloudFlareProvider) submitDNSRecordChanges(\n\tctx context.Context,\n\tzoneID string,\n\tbc batchCollections,\n\trecords DNSRecordsMap,\n) bool {\n\tfailed := false\n\tif len(bc.batchDeletes) > 0 || len(bc.batchPosts) > 0 || len(bc.batchPuts) > 0 {\n\t\tlimit := max(p.DNSRecordsConfig.BatchChangeSize, defaultBatchChangeSize)\n\t\tchunks := chunkBatchChanges(zoneID, bc, limit)\n\t\tfor i, chunk := range chunks {\n\t\t\tlog.Debugf(\"Submitting batch DNS records for zone %s (chunk %d/%d): %d deletes, %d creates, %d updates\",\n\t\t\t\tzoneID, i+1, len(chunks),\n\t\t\t\tlen(chunk.params.Deletes.Value),\n\t\t\t\tlen(chunk.params.Posts.Value),\n\t\t\t\tlen(chunk.params.Puts.Value),\n\t\t\t)\n\t\t\tif _, err := p.Client.BatchDNSRecords(ctx, chunk.params); err != nil {\n\t\t\t\tlog.Warnf(\"Batch DNS operation failed for zone %s (chunk %d/%d): %v — falling back to individual operations\",\n\t\t\t\t\tzoneID, i+1, len(chunks), convertCloudflareError(err))\n\t\t\t\tif p.fallbackIndividualChanges(ctx, zoneID, chunk, records) {\n\t\t\t\t\tfailed = true\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tlog.Debugf(\"Successfully submitted batch DNS records for zone %s (chunk %d/%d)\", zoneID, i+1, len(chunks))\n\t\t\t}\n\t\t\tif i < len(chunks)-1 && p.DNSRecordsConfig.BatchChangeInterval > 0 {\n\t\t\t\ttime.Sleep(p.DNSRecordsConfig.BatchChangeInterval)\n\t\t\t}\n\t\t}\n\t}\n\tfor _, change := range bc.fallbackUpdates {\n\t\tlogFields := log.Fields{\n\t\t\t\"record\": change.ResourceRecord.Name,\n\t\t\t\"type\":   change.ResourceRecord.Type,\n\t\t\t\"ttl\":    change.ResourceRecord.TTL,\n\t\t\t\"action\": change.Action.String(),\n\t\t\t\"zone\":   zoneID,\n\t\t}\n\t\trecordID := p.getRecordID(records, change.ResourceRecord)\n\t\trecordParam := getUpdateDNSRecordParam(zoneID, *change)\n\t\tif _, err := p.Client.UpdateDNSRecord(ctx, recordID, recordParam); err != nil {\n\t\t\tfailed = true\n\t\t\tlog.WithFields(logFields).Errorf(\"failed to update record: %v\", err)\n\t\t} else {\n\t\t\tlog.WithFields(logFields).Debugf(\"individual update succeeded\")\n\t\t}\n\t}\n\treturn failed\n}\n\n// fallbackIndividualChanges replays a failed (rolled-back) batch chunk as\n// individual API calls. Because the batch API is transactional, a failure means\n// zero state was changed in that chunk, so these individual calls are the first\n// real mutations. Individual calls return Cloudflare's own per-record error\n// details.\n//\n// Execution order matches the batch contract: deletes → updates → creates.\n// Returns true if any operation failed.\nfunc (p *CloudFlareProvider) fallbackIndividualChanges(\n\tctx context.Context,\n\tzoneID string,\n\tchunk batchChunk,\n\trecords DNSRecordsMap,\n) bool {\n\tfailed := false\n\n\t// Process in batch execution order: deletes → updates → creates.\n\tgroups := []struct {\n\t\tchanges []*cloudFlareChange\n\t}{\n\t\t{chunk.deleteChanges},\n\t\t{chunk.updateChanges},\n\t\t{chunk.createChanges},\n\t}\n\n\tfor _, group := range groups {\n\t\tfor _, change := range group.changes {\n\t\t\tlogFields := log.Fields{\n\t\t\t\t\"record\":  change.ResourceRecord.Name,\n\t\t\t\t\"type\":    change.ResourceRecord.Type,\n\t\t\t\t\"content\": change.ResourceRecord.Content,\n\t\t\t\t\"action\":  change.Action.String(),\n\t\t\t\t\"zone\":    zoneID,\n\t\t\t}\n\n\t\t\tvar err error\n\t\t\tswitch change.Action {\n\t\t\tcase cloudFlareCreate:\n\t\t\t\tparams := getCreateDNSRecordParam(zoneID, change)\n\t\t\t\t_, err = p.Client.CreateDNSRecord(ctx, params)\n\n\t\t\tcase cloudFlareDelete:\n\t\t\t\trecordID := p.getRecordID(records, change.ResourceRecord)\n\t\t\t\tif recordID == \"\" {\n\t\t\t\t\t// Record is already absent — the desired state is achieved.\n\t\t\t\t\tlog.WithFields(logFields).Info(\"fallback: record already gone, treating delete as success\")\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\terr = p.Client.DeleteDNSRecord(ctx, recordID, dns.RecordDeleteParams{\n\t\t\t\t\tZoneID: cloudflare.F(zoneID),\n\t\t\t\t})\n\n\t\t\tcase cloudFlareUpdate:\n\t\t\t\trecordID := p.getRecordID(records, change.ResourceRecord)\n\t\t\t\tif recordID == \"\" {\n\t\t\t\t\t// Record is gone; let the next sync cycle issue a fresh CREATE.\n\t\t\t\t\tlog.WithFields(logFields).Info(\"fallback: record unexpectedly not found for update, will re-evaluate on next sync\")\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tparams := getUpdateDNSRecordParam(zoneID, *change)\n\t\t\t\t_, err = p.Client.UpdateDNSRecord(ctx, recordID, params)\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tfailed = true\n\t\t\t\tlog.WithFields(logFields).Errorf(\"fallback: individual %s failed: %v\", change.Action, convertCloudflareError(err))\n\t\t\t} else {\n\t\t\t\tlog.WithFields(logFields).Debugf(\"fallback: individual %s succeeded\", change.Action)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn failed\n}\n"
  },
  {
    "path": "provider/cloudflare/cloudflare_batch_test.go",
    "content": "/*\nCopyright 2026 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage cloudflare\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"maps\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/cloudflare/cloudflare-go/v5\"\n\t\"github.com/cloudflare/cloudflare-go/v5/dns\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/plan\"\n)\n\nfunc (m *mockCloudFlareClient) BatchDNSRecords(_ context.Context, params dns.RecordBatchParams) (*dns.RecordBatchResponse, error) {\n\tm.BatchDNSRecordsCalls++\n\tzoneID := params.ZoneID.Value\n\n\t// Snapshot zone state for transactional rollback on error.\n\t// The real Cloudflare batch API is fully transactional — if any\n\t// operation fails, the entire batch is rolled back.\n\tvar snapshot map[string]dns.RecordResponse\n\tif zone, ok := m.Records[zoneID]; ok {\n\t\tsnapshot = make(map[string]dns.RecordResponse, len(zone))\n\t\tmaps.Copy(snapshot, zone)\n\t}\n\tactionsStart := len(m.Actions)\n\n\tvar firstErr error\n\n\t// Process Deletes first to mirror the real API's ordering.\n\tfor _, del := range params.Deletes.Value {\n\t\trecordID := del.ID.Value\n\t\tm.Actions = append(m.Actions, MockAction{\n\t\t\tName:     \"Delete\",\n\t\t\tZoneId:   zoneID,\n\t\t\tRecordId: recordID,\n\t\t})\n\t\tif zone, ok := m.Records[zoneID]; ok {\n\t\t\tif rec, exists := zone[recordID]; exists {\n\t\t\t\tname := rec.Name\n\t\t\t\tdelete(zone, recordID)\n\t\t\t\tif strings.HasPrefix(name, \"newerror-delete-\") && firstErr == nil {\n\t\t\t\t\tfirstErr = errors.New(\"failed to delete erroring DNS record\")\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// Process Puts (updates) before Posts (creates) to mirror the real API's\n\t// server-side execution order: Deletes → Patches → Puts → Posts.\n\tfor _, putUnion := range params.Puts.Value {\n\t\tid, record := extractBatchPutData(putUnion)\n\t\tm.Actions = append(m.Actions, MockAction{\n\t\t\tName:       \"Update\",\n\t\t\tZoneId:     zoneID,\n\t\t\tRecordId:   id,\n\t\t\tRecordData: record,\n\t\t})\n\t\tif zone, ok := m.Records[zoneID]; ok {\n\t\t\tif _, exists := zone[id]; exists {\n\t\t\t\tif strings.HasPrefix(record.Name, \"newerror-update-\") {\n\t\t\t\t\tif firstErr == nil {\n\t\t\t\t\t\tfirstErr = errors.New(\"failed to update erroring DNS record\")\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tzone[id] = record\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// Process Posts (creates).\n\tfor _, postUnion := range params.Posts.Value {\n\t\tpost, ok := postUnion.(dns.RecordBatchParamsPost)\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\t\ttypeStr := string(post.Type.Value)\n\t\trecord := dns.RecordResponse{\n\t\t\tID:       generateDNSRecordID(typeStr, post.Name.Value, post.Content.Value),\n\t\t\tName:     post.Name.Value,\n\t\t\tTTL:      dns.TTL(post.TTL.Value),\n\t\t\tProxied:  post.Proxied.Value,\n\t\t\tType:     dns.RecordResponseType(typeStr),\n\t\t\tContent:  post.Content.Value,\n\t\t\tPriority: post.Priority.Value,\n\t\t}\n\t\tm.Actions = append(m.Actions, MockAction{\n\t\t\tName:       \"Create\",\n\t\t\tZoneId:     zoneID,\n\t\t\tRecordId:   record.ID,\n\t\t\tRecordData: record,\n\t\t})\n\t\tif zone, ok := m.Records[zoneID]; ok {\n\t\t\tzone[record.ID] = record\n\t\t}\n\t\tif record.Name == \"newerror.bar.com\" && firstErr == nil {\n\t\t\tfirstErr = fmt.Errorf(\"failed to create record\")\n\t\t}\n\t}\n\n\t// Transactional: on error, rollback all state and action changes.\n\tif firstErr != nil {\n\t\tif snapshot != nil {\n\t\t\tm.Records[zoneID] = snapshot\n\t\t}\n\t\tm.Actions = m.Actions[:actionsStart]\n\t\treturn nil, firstErr\n\t}\n\n\treturn &dns.RecordBatchResponse{}, nil\n}\n\n// extractBatchPutData unpacks a BatchPutUnionParam into a record ID and a RecordResponse\n// suitable for recording in the mock's Actions list.\nfunc extractBatchPutData(put dns.BatchPutUnionParam) (string, dns.RecordResponse) {\n\tswitch p := put.(type) {\n\tcase dns.BatchPutARecordParam:\n\t\treturn p.ID.Value, dns.RecordResponse{\n\t\t\tID:      p.ID.Value,\n\t\t\tName:    p.Name.Value,\n\t\t\tTTL:     p.TTL.Value,\n\t\t\tProxied: p.Proxied.Value,\n\t\t\tType:    dns.RecordResponseTypeA,\n\t\t\tContent: p.Content.Value,\n\t\t}\n\tcase dns.BatchPutAAAARecordParam:\n\t\treturn p.ID.Value, dns.RecordResponse{\n\t\t\tID:      p.ID.Value,\n\t\t\tName:    p.Name.Value,\n\t\t\tTTL:     p.TTL.Value,\n\t\t\tProxied: p.Proxied.Value,\n\t\t\tType:    dns.RecordResponseTypeAAAA,\n\t\t\tContent: p.Content.Value,\n\t\t}\n\tcase dns.BatchPutCNAMERecordParam:\n\t\treturn p.ID.Value, dns.RecordResponse{\n\t\t\tID:      p.ID.Value,\n\t\t\tName:    p.Name.Value,\n\t\t\tTTL:     p.TTL.Value,\n\t\t\tProxied: p.Proxied.Value,\n\t\t\tType:    dns.RecordResponseTypeCNAME,\n\t\t\tContent: p.Content.Value,\n\t\t}\n\tcase dns.BatchPutTXTRecordParam:\n\t\treturn p.ID.Value, dns.RecordResponse{\n\t\t\tID:      p.ID.Value,\n\t\t\tName:    p.Name.Value,\n\t\t\tTTL:     p.TTL.Value,\n\t\t\tProxied: p.Proxied.Value,\n\t\t\tType:    dns.RecordResponseTypeTXT,\n\t\t\tContent: p.Content.Value,\n\t\t}\n\tcase dns.BatchPutMXRecordParam:\n\t\treturn p.ID.Value, dns.RecordResponse{\n\t\t\tID:       p.ID.Value,\n\t\t\tName:     p.Name.Value,\n\t\t\tTTL:      p.TTL.Value,\n\t\t\tProxied:  p.Proxied.Value,\n\t\t\tType:     dns.RecordResponseTypeMX,\n\t\t\tContent:  p.Content.Value,\n\t\t\tPriority: p.Priority.Value,\n\t\t}\n\tcase dns.BatchPutNSRecordParam:\n\t\treturn p.ID.Value, dns.RecordResponse{\n\t\t\tID:      p.ID.Value,\n\t\t\tName:    p.Name.Value,\n\t\t\tTTL:     p.TTL.Value,\n\t\t\tProxied: p.Proxied.Value,\n\t\t\tType:    dns.RecordResponseTypeNS,\n\t\t\tContent: p.Content.Value,\n\t\t}\n\tdefault:\n\t\tpanic(fmt.Sprintf(\"extractBatchPutData: unexpected BatchPutUnionParam type %T\", put))\n\t}\n}\n\n// generateDNSRecordID builds the deterministic record ID used by the mock client.\nfunc generateDNSRecordID(rrtype string, name string, content string) string {\n\treturn fmt.Sprintf(\"%s-%s-%s\", name, rrtype, content)\n}\n\nfunc TestBatchFallbackIndividual(t *testing.T) {\n\tt.Run(\"batch failure falls back to individual operations\", func(t *testing.T) {\n\t\t// Create a provider with pre-existing records.\n\t\tclient := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{\n\t\t\t\"001\": {\n\t\t\t\t{ID: \"existing-1\", Name: \"ok.bar.com\", Type: \"A\", Content: \"1.2.3.4\", TTL: 120},\n\t\t\t},\n\t\t})\n\t\tp := &CloudFlareProvider{\n\t\t\tClient: client,\n\t\t}\n\n\t\t// Apply changes that include a good create and a bad create.\n\t\t// \"newerror.bar.com\" triggers a batch failure in the mock BatchDNSRecords,\n\t\t// then an individual fallback failure in CreateDNSRecord.\n\t\tchanges := &plan.Changes{\n\t\t\tCreate: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"good.bar.com\", Targets: endpoint.Targets{\"5.6.7.8\"}, RecordType: \"A\"},\n\t\t\t\t{DNSName: \"newerror.bar.com\", Targets: endpoint.Targets{\"9.10.11.12\"}, RecordType: \"A\"},\n\t\t\t},\n\t\t}\n\n\t\terr := p.ApplyChanges(t.Context(), changes)\n\t\trequire.Error(t, err, \"should return error when individual fallback has failures\")\n\t\tassert.Equal(t, 1, client.BatchDNSRecordsCalls, \"batch path should be attempted before fallback\")\n\n\t\t// The batch should have failed (because of newerror.bar.com), then\n\t\t// fallback should have applied \"good.bar.com\" individually (success)\n\t\t// and \"newerror.bar.com\" individually (failure).\n\n\t\t// Verify the good record was created via individual fallback.\n\t\tzone001 := client.Records[\"001\"]\n\t\tgoodID := generateDNSRecordID(\"A\", \"good.bar.com\", \"5.6.7.8\")\n\t\tassert.Contains(t, zone001, goodID, \"good record should exist after individual fallback\")\n\t})\n\n\tt.Run(\"failed individual delete is reported\", func(t *testing.T) {\n\t\t// When a batch containing two deletes fails, the fallback replays them\n\t\t// individually. The one that ultimately fails should be reported;\n\t\t// the one that succeeds should not block the overall zone from converging.\n\t\tclient := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{\n\t\t\t\"001\": {\n\t\t\t\t{ID: \"del-ok\", Name: \"deleteme.bar.com\", Type: \"A\", Content: \"1.2.3.4\", TTL: 120},\n\t\t\t\t{ID: \"del-err\", Name: \"newerror-delete-1.bar.com\", Type: \"A\", Content: \"5.6.7.8\", TTL: 120},\n\t\t\t},\n\t\t})\n\t\tp := &CloudFlareProvider{\n\t\t\tClient: client,\n\t\t}\n\n\t\tchanges := &plan.Changes{\n\t\t\tDelete: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"deleteme.bar.com\", Targets: endpoint.Targets{\"1.2.3.4\"}, RecordType: \"A\"},\n\t\t\t\t{DNSName: \"newerror-delete-1.bar.com\", Targets: endpoint.Targets{\"5.6.7.8\"}, RecordType: \"A\"},\n\t\t\t},\n\t\t}\n\t\terr := p.ApplyChanges(t.Context(), changes)\n\t\trequire.Error(t, err, \"should return error for the failing delete\")\n\n\t\t// The good delete should have succeeded via individual fallback.\n\t\tassert.NotContains(t, client.Records[\"001\"], \"del-ok\", \"successfully deleted record should be gone\")\n\t})\n\n\tt.Run(\"fallback update failure is reported\", func(t *testing.T) {\n\t\tclient := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{\n\t\t\t\"001\": {\n\t\t\t\t{ID: \"upd-err\", Name: \"newerror-update-1.bar.com\", Type: \"A\", Content: \"1.2.3.4\", TTL: 120},\n\t\t\t},\n\t\t})\n\t\tp := &CloudFlareProvider{\n\t\t\tClient: client,\n\t\t}\n\n\t\tchanges := &plan.Changes{\n\t\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"newerror-update-1.bar.com\", Targets: endpoint.Targets{\"1.2.3.4\"}, RecordType: \"A\", RecordTTL: 300},\n\t\t\t},\n\t\t\tUpdateOld: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"newerror-update-1.bar.com\", Targets: endpoint.Targets{\"1.2.3.4\"}, RecordType: \"A\", RecordTTL: 120},\n\t\t\t},\n\t\t}\n\t\terr := p.ApplyChanges(t.Context(), changes)\n\t\trequire.Error(t, err, \"should return error for the failing update\")\n\t})\n}\n\nfunc TestChunkBatchChanges(t *testing.T) {\n\t// Build sample changes and batch params.\n\tmkDelete := func(id string) dns.RecordBatchParamsDelete {\n\t\treturn dns.RecordBatchParamsDelete{ID: cloudflare.F(id)}\n\t}\n\tmkPost := func(name, content string) dns.RecordBatchParamsPostUnion {\n\t\treturn dns.RecordBatchParamsPost{\n\t\t\tName:    cloudflare.F(name),\n\t\t\tType:    cloudflare.F(dns.RecordBatchParamsPostsTypeA),\n\t\t\tContent: cloudflare.F(content),\n\t\t}\n\t}\n\tmkPut := func(id, name, content string) dns.BatchPutUnionParam {\n\t\treturn dns.BatchPutARecordParam{\n\t\t\tID: cloudflare.F(id),\n\t\t\tARecordParam: dns.ARecordParam{\n\t\t\t\tName:    cloudflare.F(name),\n\t\t\t\tType:    cloudflare.F(dns.ARecordTypeA),\n\t\t\t\tContent: cloudflare.F(content),\n\t\t\t},\n\t\t}\n\t}\n\tmkChange := func(action changeAction, name, content string) *cloudFlareChange {\n\t\treturn &cloudFlareChange{\n\t\t\tAction:         action,\n\t\t\tResourceRecord: dns.RecordResponse{Name: name, Type: \"A\", Content: content},\n\t\t}\n\t}\n\n\tdeletes := []dns.RecordBatchParamsDelete{mkDelete(\"d1\"), mkDelete(\"d2\")}\n\tdeleteChanges := []*cloudFlareChange{\n\t\tmkChange(cloudFlareDelete, \"del1.bar.com\", \"1.1.1.1\"),\n\t\tmkChange(cloudFlareDelete, \"del2.bar.com\", \"2.2.2.2\"),\n\t}\n\tposts := []dns.RecordBatchParamsPostUnion{mkPost(\"create1.bar.com\", \"3.3.3.3\")}\n\tcreateChanges := []*cloudFlareChange{\n\t\tmkChange(cloudFlareCreate, \"create1.bar.com\", \"3.3.3.3\"),\n\t}\n\tputs := []dns.BatchPutUnionParam{mkPut(\"u1\", \"update1.bar.com\", \"4.4.4.4\")}\n\tupdateChanges := []*cloudFlareChange{\n\t\tmkChange(cloudFlareUpdate, \"update1.bar.com\", \"4.4.4.4\"),\n\t}\n\n\tt.Run(\"single chunk when under limit\", func(t *testing.T) {\n\t\tbc := batchCollections{\n\t\t\tbatchDeletes:  deletes,\n\t\t\tdeleteChanges: deleteChanges,\n\t\t\tbatchPosts:    posts,\n\t\t\tcreateChanges: createChanges,\n\t\t\tbatchPuts:     puts,\n\t\t\tupdateChanges: updateChanges,\n\t\t}\n\t\tchunks := chunkBatchChanges(\"zone1\", bc, 10)\n\t\trequire.Len(t, chunks, 1)\n\t\tassert.Len(t, chunks[0].deleteChanges, 2)\n\t\tassert.Len(t, chunks[0].createChanges, 1)\n\t\tassert.Len(t, chunks[0].updateChanges, 1)\n\t})\n\n\tt.Run(\"splits into multiple chunks at limit\", func(t *testing.T) {\n\t\tbc := batchCollections{\n\t\t\tbatchDeletes:  deletes,\n\t\t\tdeleteChanges: deleteChanges,\n\t\t\tbatchPosts:    posts,\n\t\t\tcreateChanges: createChanges,\n\t\t\tbatchPuts:     puts,\n\t\t\tupdateChanges: updateChanges,\n\t\t}\n\t\tchunks := chunkBatchChanges(\"zone1\", bc, 2)\n\t\trequire.Len(t, chunks, 2)\n\t\t// First chunk: 2 deletes (fills limit)\n\t\tassert.Len(t, chunks[0].deleteChanges, 2)\n\t\tassert.Empty(t, chunks[0].updateChanges)\n\t\tassert.Empty(t, chunks[0].createChanges)\n\t\t// Second chunk: 1 put then 1 post\n\t\tassert.Empty(t, chunks[1].deleteChanges)\n\t\tassert.Len(t, chunks[1].updateChanges, 1)\n\t\tassert.Len(t, chunks[1].createChanges, 1)\n\t})\n\n\tt.Run(\"preserves operation order across chunk boundaries\", func(t *testing.T) {\n\t\tbc := batchCollections{\n\t\t\tbatchDeletes: []dns.RecordBatchParamsDelete{mkDelete(\"d1\")},\n\t\t\tdeleteChanges: []*cloudFlareChange{\n\t\t\t\tmkChange(cloudFlareDelete, \"del1.bar.com\", \"1.1.1.1\"),\n\t\t\t},\n\t\t\tbatchPuts: []dns.BatchPutUnionParam{\n\t\t\t\tmkPut(\"u1\", \"update1.bar.com\", \"2.2.2.2\"),\n\t\t\t\tmkPut(\"u2\", \"update2.bar.com\", \"3.3.3.3\"),\n\t\t\t},\n\t\t\tupdateChanges: []*cloudFlareChange{\n\t\t\t\tmkChange(cloudFlareUpdate, \"update1.bar.com\", \"2.2.2.2\"),\n\t\t\t\tmkChange(cloudFlareUpdate, \"update2.bar.com\", \"3.3.3.3\"),\n\t\t\t},\n\t\t\tbatchPosts: []dns.RecordBatchParamsPostUnion{\n\t\t\t\tmkPost(\"create1.bar.com\", \"4.4.4.4\"),\n\t\t\t\tmkPost(\"create2.bar.com\", \"5.5.5.5\"),\n\t\t\t},\n\t\t\tcreateChanges: []*cloudFlareChange{\n\t\t\t\tmkChange(cloudFlareCreate, \"create1.bar.com\", \"4.4.4.4\"),\n\t\t\t\tmkChange(cloudFlareCreate, \"create2.bar.com\", \"5.5.5.5\"),\n\t\t\t},\n\t\t}\n\n\t\tchunks := chunkBatchChanges(\"zone1\", bc, 2)\n\t\trequire.Len(t, chunks, 3)\n\n\t\tassert.Len(t, chunks[0].deleteChanges, 1)\n\t\tassert.Len(t, chunks[0].updateChanges, 1)\n\t\tassert.Empty(t, chunks[0].createChanges)\n\n\t\tassert.Empty(t, chunks[1].deleteChanges)\n\t\tassert.Len(t, chunks[1].updateChanges, 1)\n\t\tassert.Len(t, chunks[1].createChanges, 1)\n\n\t\tassert.Empty(t, chunks[2].deleteChanges)\n\t\tassert.Empty(t, chunks[2].updateChanges)\n\t\tassert.Len(t, chunks[2].createChanges, 1)\n\t})\n}\n\nfunc TestTagsFromResponse(t *testing.T) {\n\tt.Run(\"nil input returns nil\", func(t *testing.T) {\n\t\tassert.Nil(t, tagsFromResponse(nil))\n\t})\n\tt.Run(\"non-string-slice returns nil\", func(t *testing.T) {\n\t\tassert.Nil(t, tagsFromResponse(42))\n\t})\n\tt.Run(\"string slice is returned unchanged\", func(t *testing.T) {\n\t\ttags := []string{\"tag1\", \"tag2\"}\n\t\tassert.Equal(t, tags, tagsFromResponse(tags))\n\t})\n}\n\nfunc TestBuildBatchPutParam(t *testing.T) {\n\tbase := dns.RecordResponse{\n\t\tName:    \"example.bar.com\",\n\t\tTTL:     120,\n\t\tProxied: false,\n\t\tComment: \"test-comment\",\n\t}\n\n\tt.Run(\"AAAA record\", func(t *testing.T) {\n\t\tr := base\n\t\tr.Type = dns.RecordResponseTypeAAAA\n\t\tr.Content = \"2001:db8::1\"\n\t\tparam, ok := buildBatchPutParam(\"id-aaaa\", r)\n\t\trequire.True(t, ok)\n\t\tp, cast := param.(dns.BatchPutAAAARecordParam)\n\t\trequire.True(t, cast)\n\t\tassert.Equal(t, \"id-aaaa\", p.ID.Value)\n\t\tassert.Equal(t, \"2001:db8::1\", p.Content.Value)\n\t\tassert.Equal(t, dns.AAAARecordTypeAAAA, p.Type.Value)\n\t})\n\n\tt.Run(\"CNAME record\", func(t *testing.T) {\n\t\tr := base\n\t\tr.Type = dns.RecordResponseTypeCNAME\n\t\tr.Content = \"target.bar.com\"\n\t\tparam, ok := buildBatchPutParam(\"id-cname\", r)\n\t\trequire.True(t, ok)\n\t\tp, cast := param.(dns.BatchPutCNAMERecordParam)\n\t\trequire.True(t, cast)\n\t\tassert.Equal(t, \"id-cname\", p.ID.Value)\n\t\tassert.Equal(t, \"target.bar.com\", p.Content.Value)\n\t\tassert.Equal(t, dns.CNAMERecordTypeCNAME, p.Type.Value)\n\t})\n\n\tt.Run(\"TXT record\", func(t *testing.T) {\n\t\tr := base\n\t\tr.Type = dns.RecordResponseTypeTXT\n\t\tr.Content = \"v=spf1 include:example.com ~all\"\n\t\tparam, ok := buildBatchPutParam(\"id-txt\", r)\n\t\trequire.True(t, ok)\n\t\tp, cast := param.(dns.BatchPutTXTRecordParam)\n\t\trequire.True(t, cast)\n\t\tassert.Equal(t, \"id-txt\", p.ID.Value)\n\t\tassert.Equal(t, dns.TXTRecordTypeTXT, p.Type.Value)\n\t})\n\n\tt.Run(\"MX record with priority\", func(t *testing.T) {\n\t\tr := base\n\t\tr.Type = dns.RecordResponseTypeMX\n\t\tr.Content = \"mail.example.com\"\n\t\tr.Priority = 10\n\t\tparam, ok := buildBatchPutParam(\"id-mx\", r)\n\t\trequire.True(t, ok)\n\t\tp, cast := param.(dns.BatchPutMXRecordParam)\n\t\trequire.True(t, cast)\n\t\tassert.Equal(t, \"id-mx\", p.ID.Value)\n\t\tassert.InDelta(t, float64(10), float64(p.Priority.Value), 0)\n\t\tassert.Equal(t, dns.MXRecordTypeMX, p.Type.Value)\n\t})\n\n\tt.Run(\"NS record\", func(t *testing.T) {\n\t\tr := base\n\t\tr.Type = dns.RecordResponseTypeNS\n\t\tr.Content = \"ns1.example.com\"\n\t\tparam, ok := buildBatchPutParam(\"id-ns\", r)\n\t\trequire.True(t, ok)\n\t\tp, cast := param.(dns.BatchPutNSRecordParam)\n\t\trequire.True(t, cast)\n\t\tassert.Equal(t, \"id-ns\", p.ID.Value)\n\t\tassert.Equal(t, dns.NSRecordTypeNS, p.Type.Value)\n\t})\n\n\tt.Run(\"SRV record falls back (returns nil, false)\", func(t *testing.T) {\n\t\tr := base\n\t\tr.Type = dns.RecordResponseTypeSRV\n\t\tr.Content = \"10 20 443 target.bar.com\"\n\t\tparam, ok := buildBatchPutParam(\"id-srv\", r)\n\t\tassert.False(t, ok)\n\t\tassert.Nil(t, param)\n\t})\n\n\tt.Run(\"CAA record falls back (returns nil, false)\", func(t *testing.T) {\n\t\tr := base\n\t\tr.Type = dns.RecordResponseTypeCAA\n\t\tr.Content = \"0 issue letsencrypt.org\"\n\t\tparam, ok := buildBatchPutParam(\"id-caa\", r)\n\t\tassert.False(t, ok)\n\t\tassert.Nil(t, param)\n\t})\n}\n\nfunc TestBuildBatchCollections_EdgeCases(t *testing.T) {\n\tp := &CloudFlareProvider{}\n\n\tt.Run(\"update with missing record ID is skipped\", func(t *testing.T) {\n\t\tchanges := []*cloudFlareChange{\n\t\t\t{\n\t\t\t\tAction: cloudFlareUpdate,\n\t\t\t\tResourceRecord: dns.RecordResponse{\n\t\t\t\t\tName:    \"missing.bar.com\",\n\t\t\t\t\tType:    dns.RecordResponseTypeA,\n\t\t\t\t\tContent: \"1.2.3.4\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\t// Empty records map — getRecordID will return \"\"\n\t\tbc := p.buildBatchCollections(\"zone1\", changes, make(DNSRecordsMap))\n\t\tassert.Empty(t, bc.batchPuts, \"missing record should not be added to batch puts\")\n\t\tassert.Empty(t, bc.updateChanges)\n\t\tassert.Empty(t, bc.fallbackUpdates)\n\t})\n\n\tt.Run(\"SRV update goes to fallbackUpdates\", func(t *testing.T) {\n\t\tsrvRecord := dns.RecordResponse{\n\t\t\tID:      \"srv-1\",\n\t\t\tName:    \"srv.bar.com\",\n\t\t\tType:    dns.RecordResponseTypeSRV,\n\t\t\tContent: \"10 20 443 target.bar.com\",\n\t\t}\n\t\trecords := DNSRecordsMap{\n\t\t\tnewDNSRecordIndex(srvRecord): srvRecord,\n\t\t}\n\t\tchanges := []*cloudFlareChange{\n\t\t\t{\n\t\t\t\tAction:         cloudFlareUpdate,\n\t\t\t\tResourceRecord: srvRecord,\n\t\t\t},\n\t\t}\n\t\tbc := p.buildBatchCollections(\"zone1\", changes, records)\n\t\tassert.Empty(t, bc.batchPuts, \"SRV should not be in batch puts\")\n\t\tassert.Empty(t, bc.updateChanges)\n\t\trequire.Len(t, bc.fallbackUpdates, 1)\n\t\tassert.Equal(t, \"srv.bar.com\", bc.fallbackUpdates[0].ResourceRecord.Name)\n\t})\n\n\tt.Run(\"delete with missing record ID is skipped\", func(t *testing.T) {\n\t\tchanges := []*cloudFlareChange{\n\t\t\t{\n\t\t\t\tAction: cloudFlareDelete,\n\t\t\t\tResourceRecord: dns.RecordResponse{\n\t\t\t\t\tName:    \"gone.bar.com\",\n\t\t\t\t\tType:    dns.RecordResponseTypeA,\n\t\t\t\t\tContent: \"1.2.3.4\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\tbc := p.buildBatchCollections(\"zone1\", changes, make(DNSRecordsMap))\n\t\tassert.Empty(t, bc.batchDeletes, \"missing record should not be added to batch deletes\")\n\t\tassert.Empty(t, bc.deleteChanges)\n\t})\n}\n\nfunc TestSubmitDNSRecordChanges_BatchInterval(t *testing.T) {\n\t// Build 201 creates so they span 2 chunks (defaultBatchChangeSize=200),\n\t// triggering the time.Sleep(BatchChangeInterval) code path between chunks.\n\tclient := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{\n\t\t\"001\": {},\n\t})\n\tp := &CloudFlareProvider{\n\t\tClient: client,\n\t\tDNSRecordsConfig: DNSRecordsConfig{\n\t\t\tBatchChangeInterval: 1, // 1 nanosecond — non-zero triggers sleep\n\t\t},\n\t}\n\n\tconst nRecords = defaultBatchChangeSize + 1\n\tvar posts []dns.RecordBatchParamsPostUnion\n\tvar createChanges []*cloudFlareChange\n\tfor i := range nRecords {\n\t\tname := fmt.Sprintf(\"record%d.bar.com\", i)\n\t\tposts = append(posts, dns.RecordBatchParamsPost{\n\t\t\tName:    cloudflare.F(name),\n\t\t\tType:    cloudflare.F(dns.RecordBatchParamsPostsTypeA),\n\t\t\tContent: cloudflare.F(\"1.2.3.4\"),\n\t\t})\n\t\tcreateChanges = append(createChanges, &cloudFlareChange{\n\t\t\tAction:         cloudFlareCreate,\n\t\t\tResourceRecord: dns.RecordResponse{Name: name, Type: \"A\", Content: \"1.2.3.4\"},\n\t\t})\n\t}\n\n\tbc := batchCollections{\n\t\tbatchPosts:    posts,\n\t\tcreateChanges: createChanges,\n\t}\n\n\tfailed := p.submitDNSRecordChanges(t.Context(), \"001\", bc, make(DNSRecordsMap))\n\tassert.False(t, failed, \"should not fail\")\n\tassert.Equal(t, 2, client.BatchDNSRecordsCalls, \"two chunks should require two batch API calls\")\n}\n\nfunc TestSubmitDNSRecordChanges_FallbackUpdates(t *testing.T) {\n\tt.Run(\"successful SRV fallback update\", func(t *testing.T) {\n\t\tsrvRecord := dns.RecordResponse{\n\t\t\tID:      \"srv-1\",\n\t\t\tName:    \"srv.bar.com\",\n\t\t\tType:    dns.RecordResponseTypeSRV,\n\t\t\tContent: \"10 20 443 target.bar.com\",\n\t\t}\n\t\tclient := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{\n\t\t\t\"001\": {srvRecord},\n\t\t})\n\t\tp := &CloudFlareProvider{Client: client}\n\n\t\trecords := DNSRecordsMap{\n\t\t\tnewDNSRecordIndex(srvRecord): srvRecord,\n\t\t}\n\t\tbc := batchCollections{\n\t\t\tfallbackUpdates: []*cloudFlareChange{\n\t\t\t\t{Action: cloudFlareUpdate, ResourceRecord: srvRecord},\n\t\t\t},\n\t\t}\n\n\t\tfailed := p.submitDNSRecordChanges(t.Context(), \"001\", bc, records)\n\t\tassert.False(t, failed, \"successful SRV fallback update should not report failure\")\n\t\tassert.Equal(t, 0, client.BatchDNSRecordsCalls, \"batch API not called for fallback-only changes\")\n\t})\n\n\tt.Run(\"failed SRV fallback update is reported\", func(t *testing.T) {\n\t\tsrvRecord := dns.RecordResponse{\n\t\t\tID:      \"newerror-upd-srv\",\n\t\t\tName:    \"newerror-update-srv.bar.com\",\n\t\t\tType:    dns.RecordResponseTypeSRV,\n\t\t\tContent: \"10 20 443 target.bar.com\",\n\t\t}\n\t\tclient := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{\n\t\t\t\"001\": {srvRecord},\n\t\t})\n\t\tp := &CloudFlareProvider{Client: client}\n\n\t\trecords := DNSRecordsMap{\n\t\t\tnewDNSRecordIndex(srvRecord): srvRecord,\n\t\t}\n\t\tbc := batchCollections{\n\t\t\tfallbackUpdates: []*cloudFlareChange{\n\t\t\t\t{Action: cloudFlareUpdate, ResourceRecord: srvRecord},\n\t\t\t},\n\t\t}\n\n\t\tfailed := p.submitDNSRecordChanges(t.Context(), \"001\", bc, records)\n\t\tassert.True(t, failed, \"failed SRV fallback update should be reported\")\n\t})\n}\n\nfunc TestFallbackIndividualChanges_MissingRecord(t *testing.T) {\n\tclient := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{\n\t\t\"001\": {},\n\t})\n\tp := &CloudFlareProvider{Client: client}\n\temptyRecords := make(DNSRecordsMap)\n\n\tt.Run(\"delete where record is already gone succeeds silently\", func(t *testing.T) {\n\t\tchunk := batchChunk{\n\t\t\tdeleteChanges: []*cloudFlareChange{\n\t\t\t\t{\n\t\t\t\t\tAction: cloudFlareDelete,\n\t\t\t\t\tResourceRecord: dns.RecordResponse{\n\t\t\t\t\t\tName:    \"gone.bar.com\",\n\t\t\t\t\t\tType:    dns.RecordResponseTypeA,\n\t\t\t\t\t\tContent: \"1.2.3.4\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\tfailed := p.fallbackIndividualChanges(t.Context(), \"001\", chunk, emptyRecords)\n\t\tassert.False(t, failed, \"delete of already-absent record should not report failure\")\n\t})\n\n\tt.Run(\"update where record is not found skips gracefully\", func(t *testing.T) {\n\t\tchunk := batchChunk{\n\t\t\tupdateChanges: []*cloudFlareChange{\n\t\t\t\t{\n\t\t\t\t\tAction: cloudFlareUpdate,\n\t\t\t\t\tResourceRecord: dns.RecordResponse{\n\t\t\t\t\t\tName:    \"missing.bar.com\",\n\t\t\t\t\t\tType:    dns.RecordResponseTypeA,\n\t\t\t\t\t\tContent: \"1.2.3.4\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\tfailed := p.fallbackIndividualChanges(t.Context(), \"001\", chunk, emptyRecords)\n\t\tassert.False(t, failed, \"update of missing record should not report failure\")\n\t})\n}\n"
  },
  {
    "path": "provider/cloudflare/cloudflare_custom_hostnames.go",
    "content": "/*\nCopyright 2026 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage cloudflare\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"maps\"\n\t\"slices\"\n\n\t\"github.com/cloudflare/cloudflare-go/v5\"\n\t\"github.com/cloudflare/cloudflare-go/v5/custom_hostnames\"\n\t\"github.com/cloudflare/cloudflare-go/v5/option\"\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\n// customHostname represents a Cloudflare custom hostname (v5 API compatible wrapper)\ntype customHostname struct {\n\tid                 string\n\thostname           string\n\tcustomOriginServer string\n\tcustomOriginSNI    string\n\tssl                *customHostnameSSL\n}\n\n// customHostnameSSL represents SSL configuration for custom hostname\ntype customHostnameSSL struct {\n\tsslType              string\n\tmethod               string\n\tbundleMethod         string\n\tcertificateAuthority string\n\tsettings             customHostnameSSLSettings\n}\n\n// customHostnameSSLSettings represents SSL settings for custom hostname\ntype customHostnameSSLSettings struct {\n\tminTLSVersion string\n}\n\n// for faster getCustomHostname() lookup\ntype customHostnameIndex struct {\n\thostname string\n}\n\ntype customHostnamesMap map[customHostnameIndex]customHostname\n\ntype CustomHostnamesConfig struct {\n\tEnabled              bool\n\tMinTLSVersion        string\n\tCertificateAuthority string\n}\n\nvar recordTypeCustomHostnameSupported = map[string]bool{\n\t\"A\":     true,\n\t\"CNAME\": true,\n}\n\nfunc (z zoneService) CustomHostnames(ctx context.Context, zoneID string) autoPager[custom_hostnames.CustomHostnameListResponse] {\n\tparams := custom_hostnames.CustomHostnameListParams{\n\t\tZoneID: cloudflare.F(zoneID),\n\t}\n\treturn z.service.CustomHostnames.ListAutoPaging(ctx, params)\n}\n\nfunc (z zoneService) DeleteCustomHostname(ctx context.Context, customHostnameID string, params custom_hostnames.CustomHostnameDeleteParams) error {\n\t_, err := z.service.CustomHostnames.Delete(ctx, customHostnameID, params)\n\treturn err\n}\n\nfunc (z zoneService) CreateCustomHostname(ctx context.Context, zoneID string, ch customHostname) error {\n\tparams := buildCustomHostnameNewParams(zoneID, ch)\n\t_, err := z.service.CustomHostnames.New(ctx, params,\n\t\toption.WithJSONSet(\"custom_origin_server\", ch.customOriginServer))\n\treturn err\n}\n\n// buildCustomHostnameNewParams builds the params for creating a custom hostname\nfunc buildCustomHostnameNewParams(zoneID string, ch customHostname) custom_hostnames.CustomHostnameNewParams {\n\tparams := custom_hostnames.CustomHostnameNewParams{\n\t\tZoneID:   cloudflare.F(zoneID),\n\t\tHostname: cloudflare.F(ch.hostname),\n\t}\n\tif ch.ssl != nil {\n\t\tsslParams := custom_hostnames.CustomHostnameNewParamsSSL{}\n\t\tif ch.ssl.method != \"\" {\n\t\t\tsslParams.Method = cloudflare.F(custom_hostnames.DCVMethod(ch.ssl.method))\n\t\t}\n\t\tif ch.ssl.sslType != \"\" {\n\t\t\tsslParams.Type = cloudflare.F(custom_hostnames.DomainValidationType(ch.ssl.sslType))\n\t\t}\n\t\tif ch.ssl.bundleMethod != \"\" {\n\t\t\tsslParams.BundleMethod = cloudflare.F(custom_hostnames.BundleMethod(ch.ssl.bundleMethod))\n\t\t}\n\t\tif ch.ssl.certificateAuthority != \"\" && ch.ssl.certificateAuthority != \"none\" {\n\t\t\tsslParams.CertificateAuthority = cloudflare.F(cloudflare.CertificateCA(ch.ssl.certificateAuthority))\n\t\t}\n\t\tif ch.ssl.settings.minTLSVersion != \"\" {\n\t\t\tsslParams.Settings = cloudflare.F(custom_hostnames.CustomHostnameNewParamsSSLSettings{\n\t\t\t\tMinTLSVersion: cloudflare.F(custom_hostnames.CustomHostnameNewParamsSSLSettingsMinTLSVersion(ch.ssl.settings.minTLSVersion)),\n\t\t\t})\n\t\t}\n\t\tparams.SSL = cloudflare.F(sslParams)\n\t}\n\treturn params\n}\n\n// submitCustomHostnameChanges implements Custom Hostname functionality for the Change, returns false if it fails\nfunc (p *CloudFlareProvider) submitCustomHostnameChanges(ctx context.Context, zoneID string, change *cloudFlareChange, chs customHostnamesMap, logFields log.Fields) bool {\n\t// return early if disabled\n\tif !p.CustomHostnamesConfig.Enabled {\n\t\treturn true\n\t}\n\n\tswitch change.Action {\n\tcase cloudFlareUpdate:\n\t\treturn p.processCustomHostnameUpdate(ctx, zoneID, change, chs, logFields)\n\tcase cloudFlareDelete:\n\t\treturn p.processCustomHostnameDelete(ctx, zoneID, change, chs, logFields)\n\tcase cloudFlareCreate:\n\t\treturn p.processCustomHostnameCreate(ctx, zoneID, change, chs, logFields)\n\t}\n\n\treturn true\n}\n\nfunc (p *CloudFlareProvider) processCustomHostnameUpdate(ctx context.Context, zoneID string, change *cloudFlareChange, chs customHostnamesMap, logFields log.Fields) bool {\n\tif !recordTypeCustomHostnameSupported[string(change.ResourceRecord.Type)] {\n\t\treturn true\n\t}\n\tfailedChange := false\n\tadd, remove, _ := provider.Difference(change.CustomHostnamesPrev, slices.Collect(maps.Keys(change.CustomHostnames)))\n\n\tfor _, changeCH := range remove {\n\t\tif prevCh, err := getCustomHostname(chs, changeCH); err == nil {\n\t\t\tprevChID := prevCh.id\n\t\t\tif prevChID != \"\" {\n\t\t\t\tlog.WithFields(logFields).Infof(\"Removing previous custom hostname %q/%q\", prevChID, changeCH)\n\t\t\t\tparams := custom_hostnames.CustomHostnameDeleteParams{ZoneID: cloudflare.F(zoneID)}\n\t\t\t\tchErr := p.Client.DeleteCustomHostname(ctx, prevChID, params)\n\t\t\t\tif chErr != nil {\n\t\t\t\t\tfailedChange = true\n\t\t\t\t\tlog.WithFields(logFields).Errorf(\"failed to remove previous custom hostname %q/%q: %v\", prevChID, changeCH, chErr)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tfor _, changeCH := range add {\n\t\tlog.WithFields(logFields).Infof(\"Adding custom hostname %q\", changeCH)\n\t\tchErr := p.Client.CreateCustomHostname(ctx, zoneID, change.CustomHostnames[changeCH])\n\t\tif chErr != nil {\n\t\t\tfailedChange = true\n\t\t\tlog.WithFields(logFields).Errorf(\"failed to add custom hostname %q: %v\", changeCH, chErr)\n\t\t}\n\t}\n\treturn !failedChange\n}\n\nfunc (p *CloudFlareProvider) processCustomHostnameDelete(ctx context.Context, zoneID string, change *cloudFlareChange, chs customHostnamesMap, logFields log.Fields) bool {\n\tfailedChange := false\n\tfor _, changeCH := range change.CustomHostnames {\n\t\tif recordTypeCustomHostnameSupported[string(change.ResourceRecord.Type)] && changeCH.hostname != \"\" {\n\t\t\tlog.WithFields(logFields).Infof(\"Deleting custom hostname %q\", changeCH.hostname)\n\t\t\tif ch, err := getCustomHostname(chs, changeCH.hostname); err == nil {\n\t\t\t\tchID := ch.id\n\t\t\t\tparams := custom_hostnames.CustomHostnameDeleteParams{ZoneID: cloudflare.F(zoneID)}\n\t\t\t\tchErr := p.Client.DeleteCustomHostname(ctx, chID, params)\n\t\t\t\tif chErr != nil {\n\t\t\t\t\tfailedChange = true\n\t\t\t\t\tlog.WithFields(logFields).Errorf(\"failed to delete custom hostname %q/%q: %v\", chID, changeCH.hostname, chErr)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tlog.WithFields(logFields).Warnf(\"failed to delete custom hostname %q: %v\", changeCH.hostname, err)\n\t\t\t}\n\t\t}\n\t}\n\treturn !failedChange\n}\n\nfunc (p *CloudFlareProvider) processCustomHostnameCreate(ctx context.Context, zoneID string, change *cloudFlareChange, chs customHostnamesMap, logFields log.Fields) bool {\n\tfailedChange := false\n\tfor _, changeCH := range change.CustomHostnames {\n\t\tif recordTypeCustomHostnameSupported[string(change.ResourceRecord.Type)] && changeCH.hostname != \"\" {\n\t\t\tlog.WithFields(logFields).Infof(\"Creating custom hostname %q\", changeCH.hostname)\n\t\t\tif ch, err := getCustomHostname(chs, changeCH.hostname); err == nil {\n\t\t\t\tif changeCH.customOriginServer == ch.customOriginServer {\n\t\t\t\t\tlog.WithFields(logFields).Warnf(\"custom hostname %q already exists with the same origin %q, continue\", changeCH.hostname, ch.customOriginServer)\n\t\t\t\t} else {\n\t\t\t\t\tfailedChange = true\n\t\t\t\t\tlog.WithFields(logFields).Errorf(\"failed to create custom hostname, %q already exists with origin %q\", changeCH.hostname, ch.customOriginServer)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tchErr := p.Client.CreateCustomHostname(ctx, zoneID, changeCH)\n\t\t\t\tif chErr != nil {\n\t\t\t\t\tfailedChange = true\n\t\t\t\t\tlog.WithFields(logFields).Errorf(\"failed to create custom hostname %q: %v\", changeCH.hostname, chErr)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn !failedChange\n}\n\nfunc getCustomHostname(chs customHostnamesMap, chName string) (customHostname, error) {\n\tif chName == \"\" {\n\t\treturn customHostname{}, fmt.Errorf(\"failed to get custom hostname: %q is empty\", chName)\n\t}\n\tif ch, ok := chs[customHostnameIndex{hostname: chName}]; ok {\n\t\treturn ch, nil\n\t}\n\treturn customHostname{}, fmt.Errorf(\"failed to get custom hostname: %q not found\", chName)\n}\n\nfunc (p *CloudFlareProvider) newCustomHostname(hostname string, origin string) customHostname {\n\treturn customHostname{\n\t\thostname:           hostname,\n\t\tcustomOriginServer: origin,\n\t\tssl:                getCustomHostnamesSSLOptions(p.CustomHostnamesConfig),\n\t}\n}\n\nfunc getCustomHostnamesSSLOptions(customHostnamesConfig CustomHostnamesConfig) *customHostnameSSL {\n\tssl := &customHostnameSSL{\n\t\tsslType:      \"dv\",\n\t\tmethod:       \"http\",\n\t\tbundleMethod: \"ubiquitous\",\n\t\tsettings: customHostnameSSLSettings{\n\t\t\tminTLSVersion: customHostnamesConfig.MinTLSVersion,\n\t\t},\n\t}\n\t// Set CertificateAuthority if provided\n\t// We're not able to set it at all (even with a blank) if you're not on an enterprise plan\n\tif customHostnamesConfig.CertificateAuthority != \"none\" {\n\t\tssl.certificateAuthority = customHostnamesConfig.CertificateAuthority\n\t}\n\treturn ssl\n}\n\nfunc newCustomHostnameIndex(ch customHostname) customHostnameIndex {\n\treturn customHostnameIndex{hostname: ch.hostname}\n}\n\n// listCustomHostnamesWithPagination performs automatic pagination of results on requests to cloudflare.CustomHostnames\nfunc (p *CloudFlareProvider) listCustomHostnamesWithPagination(ctx context.Context, zoneID string) (customHostnamesMap, error) {\n\tif !p.CustomHostnamesConfig.Enabled {\n\t\treturn nil, nil\n\t}\n\tchs := make(customHostnamesMap)\n\titer := p.Client.CustomHostnames(ctx, zoneID)\n\tcustomHostnames, err := listAllCustomHostnames(iter)\n\tif err != nil {\n\t\tconvertedError := convertCloudflareError(err)\n\t\tif !errors.Is(convertedError, provider.SoftError) {\n\t\t\tlog.Errorf(\"zone %q failed to fetch custom hostnames. Please check if \\\"Cloudflare for SaaS\\\" is enabled and API key permissions, %v\", zoneID, err)\n\t\t}\n\t\treturn nil, convertedError\n\t}\n\tfor _, ch := range customHostnames {\n\t\tchs[newCustomHostnameIndex(ch)] = ch\n\t}\n\treturn chs, nil\n}\n\n// processCustomHostnameChanges applies custom hostname side-effects for each\n// change in the set and returns true if any operation failed.\nfunc (p *CloudFlareProvider) processCustomHostnameChanges(\n\tctx context.Context,\n\tzoneID string,\n\tchanges []*cloudFlareChange,\n\tchs customHostnamesMap,\n) bool {\n\tfailed := false\n\tfor _, change := range changes {\n\t\tlogFields := log.Fields{\n\t\t\t\"record\": change.ResourceRecord.Name,\n\t\t\t\"type\":   change.ResourceRecord.Type,\n\t\t\t\"ttl\":    change.ResourceRecord.TTL,\n\t\t\t\"action\": change.Action.String(),\n\t\t\t\"zone\":   zoneID,\n\t\t}\n\t\tif !p.submitCustomHostnameChanges(ctx, zoneID, change, chs, logFields) {\n\t\t\tfailed = true\n\t\t}\n\t}\n\treturn failed\n}\n\n// listAllCustomHostnames extracts all custom hostnames from the iterator\nfunc listAllCustomHostnames(iter autoPager[custom_hostnames.CustomHostnameListResponse]) ([]customHostname, error) {\n\tvar customHostnames []customHostname\n\tfor ch := range autoPagerIterator(iter) {\n\t\tcustomHostnames = append(customHostnames, customHostname{\n\t\t\tid:                 ch.ID,\n\t\t\thostname:           ch.Hostname,\n\t\t\tcustomOriginServer: ch.CustomOriginServer,\n\t\t\tcustomOriginSNI:    ch.CustomOriginSNI,\n\t\t})\n\t}\n\tif iter.Err() != nil {\n\t\treturn nil, iter.Err()\n\t}\n\treturn customHostnames, nil\n}\n"
  },
  {
    "path": "provider/cloudflare/cloudflare_custom_hostnames_test.go",
    "content": "/*\nCopyright 2026 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage cloudflare\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/cloudflare/cloudflare-go/v5\"\n\t\"github.com/cloudflare/cloudflare-go/v5/custom_hostnames\"\n\t\"github.com/cloudflare/cloudflare-go/v5/dns\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\tlogtest \"sigs.k8s.io/external-dns/internal/testutils/log\"\n\t\"sigs.k8s.io/external-dns/plan\"\n)\n\nfunc (m *mockCloudFlareClient) CustomHostnames(ctx context.Context, zoneID string) autoPager[custom_hostnames.CustomHostnameListResponse] {\n\tif strings.HasPrefix(zoneID, \"newerror-\") {\n\t\treturn &mockAutoPager[custom_hostnames.CustomHostnameListResponse]{\n\t\t\terr: errors.New(\"failed to list custom hostnames\"),\n\t\t}\n\t}\n\n\tresult := []custom_hostnames.CustomHostnameListResponse{}\n\tif chs, ok := m.customHostnames[zoneID]; ok {\n\t\tfor _, ch := range chs {\n\t\t\tif strings.HasPrefix(ch.hostname, \"newerror-list-\") {\n\t\t\t\tparams := custom_hostnames.CustomHostnameDeleteParams{ZoneID: cloudflare.F(zoneID)}\n\t\t\t\tm.DeleteCustomHostname(ctx, ch.id, params)\n\t\t\t\treturn &mockAutoPager[custom_hostnames.CustomHostnameListResponse]{\n\t\t\t\t\terr: errors.New(\"failed to list erroring custom hostname\"),\n\t\t\t\t}\n\t\t\t}\n\t\t\tresult = append(result, custom_hostnames.CustomHostnameListResponse{\n\t\t\t\tID:                 ch.id,\n\t\t\t\tHostname:           ch.hostname,\n\t\t\t\tCustomOriginServer: ch.customOriginServer,\n\t\t\t})\n\t\t}\n\t}\n\treturn &mockAutoPager[custom_hostnames.CustomHostnameListResponse]{\n\t\titems: result,\n\t}\n}\n\nfunc (m *mockCloudFlareClient) CreateCustomHostname(_ context.Context, zoneID string, ch customHostname) error {\n\tif ch.hostname == \"\" || ch.customOriginServer == \"\" || ch.hostname == \"newerror-create.foo.fancybar.com\" {\n\t\treturn fmt.Errorf(\"Invalid custom hostname or origin hostname\")\n\t}\n\tif _, ok := m.customHostnames[zoneID]; !ok {\n\t\tm.customHostnames[zoneID] = []customHostname{}\n\t}\n\tnewCustomHostname := ch\n\tnewCustomHostname.id = fmt.Sprintf(\"ID-%s\", ch.hostname)\n\tm.customHostnames[zoneID] = append(m.customHostnames[zoneID], newCustomHostname)\n\treturn nil\n}\n\nfunc (m *mockCloudFlareClient) DeleteCustomHostname(_ context.Context, customHostnameID string, params custom_hostnames.CustomHostnameDeleteParams) error {\n\tzoneID := params.ZoneID.String()\n\tidx := 0\n\tif idx = getCustomHostnameIdxByID(m.customHostnames[zoneID], customHostnameID); idx < 0 {\n\t\treturn fmt.Errorf(\"Invalid custom hostname ID to delete\")\n\t}\n\n\tm.customHostnames[zoneID] = append(m.customHostnames[zoneID][:idx], m.customHostnames[zoneID][idx+1:]...)\n\n\tif customHostnameID == \"ID-newerror-delete.foo.fancybar.com\" {\n\t\treturn fmt.Errorf(\"Invalid custom hostname to delete\")\n\t}\n\treturn nil\n}\n\nfunc getCustomHostnameIdxByID(chs []customHostname, customHostnameID string) int {\n\tfor idx, ch := range chs {\n\t\tif ch.id == customHostnameID {\n\t\t\treturn idx\n\t\t}\n\t}\n\treturn -1\n}\n\nfunc TestCloudflareCustomHostnameOperations(t *testing.T) {\n\tclient := NewMockCloudFlareClient()\n\tprovider := &CloudFlareProvider{\n\t\tClient:                client,\n\t\tCustomHostnamesConfig: CustomHostnamesConfig{Enabled: true},\n\t}\n\tctx := t.Context()\n\tdomainFilter := endpoint.NewDomainFilter([]string{\"bar.com\"})\n\n\ttestFailCases := []struct {\n\t\tName                    string\n\t\tEndpoints               []*endpoint.Endpoint\n\t\tExpectedCustomHostnames map[string]string\n\t}{}\n\n\tfor _, tc := range testFailCases {\n\t\tt.Run(tc.Name, func(t *testing.T) {\n\t\t\trecords, err := provider.Records(ctx)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"should not fail, %v\", err)\n\t\t\t}\n\n\t\t\tendpoints, err := provider.AdjustEndpoints(tc.Endpoints)\n\n\t\t\tassert.NoError(t, err)\n\t\t\tplan := &plan.Plan{\n\t\t\t\tCurrent:        records,\n\t\t\t\tDesired:        endpoints,\n\t\t\t\tDomainFilter:   endpoint.MatchAllDomainFilters{domainFilter},\n\t\t\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},\n\t\t\t}\n\n\t\t\tplanned := plan.Calculate()\n\n\t\t\terr = provider.ApplyChanges(t.Context(), planned.Changes)\n\t\t\tif e := checkFailed(tc.Name, err, false); !errors.Is(e, nil) {\n\t\t\t\tt.Error(e)\n\t\t\t}\n\n\t\t\tchs, chErr := provider.listCustomHostnamesWithPagination(ctx, \"001\")\n\t\t\tif e := checkFailed(tc.Name, chErr, false); !errors.Is(e, nil) {\n\t\t\t\tt.Error(e)\n\t\t\t}\n\n\t\t\tactualCustomHostnames := map[string]string{}\n\t\t\tfor _, ch := range chs {\n\t\t\t\tactualCustomHostnames[ch.hostname] = ch.customOriginServer\n\t\t\t}\n\t\t\tif len(actualCustomHostnames) == 0 {\n\t\t\t\tactualCustomHostnames = nil\n\t\t\t}\n\t\t\tassert.Equal(t, tc.ExpectedCustomHostnames, actualCustomHostnames, \"custom hostnames should be the same\")\n\t\t})\n\t}\n}\n\nfunc TestCloudflareDisabledCustomHostnameOperations(t *testing.T) {\n\tclient := NewMockCloudFlareClient()\n\tprovider := &CloudFlareProvider{\n\t\tClient:                client,\n\t\tCustomHostnamesConfig: CustomHostnamesConfig{Enabled: false},\n\t}\n\tctx := t.Context()\n\tdomainFilter := endpoint.NewDomainFilter([]string{\"bar.com\"})\n\n\ttestCases := []struct {\n\t\tName        string\n\t\tEndpoints   []*endpoint.Endpoint\n\t\ttestChanges bool\n\t}{\n\t\t{\n\t\t\tName: \"add custom hostname\",\n\t\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"a.foo.bar.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.11\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  endpoint.TTL(defaultTTL),\n\t\t\t\t\tLabels:     endpoint.Labels{},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:  \"external-dns.alpha.kubernetes.io/cloudflare-custom-hostname\",\n\t\t\t\t\t\t\tValue: \"a.foo.fancybar.com\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"b.foo.bar.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.12\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  endpoint.TTL(defaultTTL),\n\t\t\t\t\tLabels:     endpoint.Labels{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"c.foo.bar.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.13\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  endpoint.TTL(defaultTTL),\n\t\t\t\t\tLabels:     endpoint.Labels{},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:  \"external-dns.alpha.kubernetes.io/cloudflare-custom-hostname\",\n\t\t\t\t\t\t\tValue: \"c1.foo.fancybar.com\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\ttestChanges: false,\n\t\t},\n\t\t{\n\t\t\tName: \"add custom hostname\",\n\t\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"a.foo.bar.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.11\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  endpoint.TTL(defaultTTL),\n\t\t\t\t\tLabels:     endpoint.Labels{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"b.foo.bar.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.12\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  endpoint.TTL(defaultTTL),\n\t\t\t\t\tLabels:     endpoint.Labels{},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:  \"external-dns.alpha.kubernetes.io/cloudflare-custom-hostname\",\n\t\t\t\t\t\t\tValue: \"b.foo.fancybar.com\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"c.foo.bar.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.13\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  endpoint.TTL(defaultTTL),\n\t\t\t\t\tLabels:     endpoint.Labels{},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:  \"external-dns.alpha.kubernetes.io/cloudflare-custom-hostname\",\n\t\t\t\t\t\t\tValue: \"c2.foo.fancybar.com\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\ttestChanges: true,\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.Name, func(t *testing.T) {\n\t\t\trecords, err := provider.Records(ctx)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"should not fail, %v\", err)\n\t\t\t}\n\n\t\t\tendpoints, err := provider.AdjustEndpoints(tc.Endpoints)\n\n\t\t\tassert.NoError(t, err)\n\t\t\tplan := &plan.Plan{\n\t\t\t\tCurrent:        records,\n\t\t\t\tDesired:        endpoints,\n\t\t\t\tDomainFilter:   endpoint.MatchAllDomainFilters{domainFilter},\n\t\t\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},\n\t\t\t}\n\t\t\tplanned := plan.Calculate()\n\t\t\terr = provider.ApplyChanges(ctx, planned.Changes)\n\t\t\tif e := checkFailed(tc.Name, err, false); !errors.Is(e, nil) {\n\t\t\t\tt.Error(e)\n\t\t\t}\n\t\t\tif tc.testChanges {\n\t\t\t\tassert.False(t, planned.Changes.HasChanges(), \"no new changes should be here\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCloudflareCustomHostnameNotFoundOnRecordDeletion(t *testing.T) {\n\tclient := NewMockCloudFlareClient()\n\tprovider := &CloudFlareProvider{\n\t\tClient:                client,\n\t\tCustomHostnamesConfig: CustomHostnamesConfig{Enabled: true},\n\t}\n\tctx := t.Context()\n\tzoneID := \"001\"\n\tdomainFilter := endpoint.NewDomainFilter([]string{\"bar.com\"})\n\n\ttestCases := []struct {\n\t\tName                    string\n\t\tEndpoints               []*endpoint.Endpoint\n\t\tExpectedCustomHostnames map[string]string\n\t\tpreApplyHook            string\n\t\tlogOutput               string\n\t}{\n\t\t{\n\t\t\tName: \"create DNS record with custom hostname\",\n\t\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"create.foo.bar.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  endpoint.TTL(defaultTTL),\n\t\t\t\t\tLabels:     endpoint.Labels{},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:  \"external-dns.alpha.kubernetes.io/cloudflare-custom-hostname\",\n\t\t\t\t\t\t\tValue: \"newerror-getCustomHostnameOrigin.foo.fancybar.com\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tpreApplyHook: \"\",\n\t\t\tlogOutput:    \"\",\n\t\t},\n\t\t{\n\t\t\tName:         \"remove DNS record with unexpectedly missing custom hostname\",\n\t\t\tEndpoints:    []*endpoint.Endpoint{},\n\t\t\tpreApplyHook: \"corrupt\",\n\t\t\tlogOutput:    \"failed to delete custom hostname \\\"newerror-getCustomHostnameOrigin.foo.fancybar.com\\\": failed to get custom hostname: \\\"newerror-getCustomHostnameOrigin.foo.fancybar.com\\\" not found\",\n\t\t},\n\t\t{\n\t\t\tName:         \"duplicate custom hostname\",\n\t\t\tEndpoints:    []*endpoint.Endpoint{},\n\t\t\tpreApplyHook: \"duplicate\",\n\t\t\tlogOutput:    \"\",\n\t\t},\n\t\t{\n\t\t\tName: \"create DNS record with custom hostname\",\n\t\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"a.foo.bar.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  endpoint.TTL(defaultTTL),\n\t\t\t\t\tLabels:     endpoint.Labels{},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:  \"external-dns.alpha.kubernetes.io/cloudflare-custom-hostname\",\n\t\t\t\t\t\t\tValue: \"a.foo.fancybar.com\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tpreApplyHook: \"\",\n\t\t\tlogOutput:    \"custom hostname \\\"a.foo.fancybar.com\\\" already exists with the same origin \\\"a.foo.bar.com\\\", continue\",\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.Name, func(t *testing.T) {\n\t\t\thook := logtest.LogsUnderTestWithLogLevel(log.InfoLevel, t)\n\n\t\t\trecords, err := provider.Records(ctx)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"should not fail, %v\", err)\n\t\t\t}\n\n\t\t\tendpoints, err := provider.AdjustEndpoints(tc.Endpoints)\n\n\t\t\tassert.NoError(t, err)\n\t\t\tplan := &plan.Plan{\n\t\t\t\tCurrent:        records,\n\t\t\t\tDesired:        endpoints,\n\t\t\t\tDomainFilter:   endpoint.MatchAllDomainFilters{domainFilter},\n\t\t\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},\n\t\t\t}\n\n\t\t\tplanned := plan.Calculate()\n\n\t\t\t// manually corrupt custom hostname before the deletion step\n\t\t\t// the purpose is to cause getCustomHostnameOrigin() to fail on change.Action == cloudFlareDelete\n\t\t\tchs, chErr := provider.listCustomHostnamesWithPagination(ctx, zoneID)\n\t\t\tif e := checkFailed(tc.Name, chErr, false); !errors.Is(e, nil) {\n\t\t\t\tt.Error(e)\n\t\t\t}\n\t\t\tswitch tc.preApplyHook {\n\t\t\tcase \"corrupt\":\n\t\t\t\tif ch, err := getCustomHostname(chs, \"newerror-getCustomHostnameOrigin.foo.fancybar.com\"); errors.Is(err, nil) {\n\t\t\t\t\tchID := ch.id\n\t\t\t\t\tt.Logf(\"corrupting custom hostname %q\", chID)\n\t\t\t\t\toldIdx := getCustomHostnameIdxByID(client.customHostnames[zoneID], chID)\n\t\t\t\t\toldCh := client.customHostnames[zoneID][oldIdx]\n\t\t\t\t\tch := customHostname{\n\t\t\t\t\t\thostname:           \"corrupted-newerror-getCustomHostnameOrigin.foo.fancybar.com\",\n\t\t\t\t\t\tcustomOriginServer: oldCh.customOriginServer,\n\t\t\t\t\t\tssl:                oldCh.ssl,\n\t\t\t\t\t}\n\t\t\t\t\tclient.customHostnames[zoneID][oldIdx] = ch\n\t\t\t\t}\n\t\t\tcase \"duplicate\": // manually inject duplicating custom hostname with the same name and origin\n\t\t\t\tch := customHostname{\n\t\t\t\t\tid:                 \"ID-random-123\",\n\t\t\t\t\thostname:           \"a.foo.fancybar.com\",\n\t\t\t\t\tcustomOriginServer: \"a.foo.bar.com\",\n\t\t\t\t}\n\t\t\t\tclient.customHostnames[zoneID] = append(client.customHostnames[zoneID], ch)\n\t\t\t}\n\t\t\terr = provider.ApplyChanges(t.Context(), planned.Changes)\n\t\t\tif e := checkFailed(tc.Name, err, false); !errors.Is(e, nil) {\n\t\t\t\tt.Error(e)\n\t\t\t}\n\n\t\t\tlogtest.TestHelperLogContains(tc.logOutput, hook, t)\n\t\t})\n\t}\n}\n\nfunc TestCloudflareListCustomHostnamesWithPagionation(t *testing.T) {\n\tclient := NewMockCloudFlareClient()\n\tprovider := &CloudFlareProvider{\n\t\tClient:                client,\n\t\tCustomHostnamesConfig: CustomHostnamesConfig{Enabled: true},\n\t}\n\tctx := t.Context()\n\tdomainFilter := endpoint.NewDomainFilter([]string{\"bar.com\"})\n\n\tconst CustomHostnamesNumber = 342\n\tvar generatedEndpoints []*endpoint.Endpoint\n\tfor i := range CustomHostnamesNumber {\n\t\tep := []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    fmt.Sprintf(\"host-%d.foo.bar.com\", i),\n\t\t\t\tTargets:    endpoint.Targets{fmt.Sprintf(\"cname-%d.foo.bar.com\", i)},\n\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\tRecordTTL:  endpoint.TTL(defaultTTL),\n\t\t\t\tLabels:     endpoint.Labels{},\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  \"external-dns.alpha.kubernetes.io/cloudflare-custom-hostname\",\n\t\t\t\t\t\tValue: fmt.Sprintf(\"host-%d.foo.fancybar.com\", i),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\tgeneratedEndpoints = append(generatedEndpoints, ep...)\n\t}\n\n\trecords, err := provider.Records(ctx)\n\tif err != nil {\n\t\tt.Errorf(\"should not fail, %v\", err)\n\t}\n\n\tendpoints, err := provider.AdjustEndpoints(generatedEndpoints)\n\n\tassert.NoError(t, err)\n\tplan := &plan.Plan{\n\t\tCurrent:        records,\n\t\tDesired:        endpoints,\n\t\tDomainFilter:   endpoint.MatchAllDomainFilters{domainFilter},\n\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},\n\t}\n\n\tplanned := plan.Calculate()\n\n\terr = provider.ApplyChanges(t.Context(), planned.Changes)\n\tif err != nil {\n\t\tt.Errorf(\"should not fail - %v\", err)\n\t}\n\n\tchs, chErr := provider.listCustomHostnamesWithPagination(ctx, \"001\")\n\tif chErr != nil {\n\t\tt.Errorf(\"should not fail - %v\", chErr)\n\t}\n\tassert.Len(t, chs, CustomHostnamesNumber)\n}\n\nfunc TestBuildCustomHostnameNewParams(t *testing.T) {\n\tt.Run(\"Minimal custom hostname without SSL\", func(t *testing.T) {\n\t\tch := customHostname{\n\t\t\thostname:           \"test.example.com\",\n\t\t\tcustomOriginServer: \"origin.example.com\",\n\t\t}\n\n\t\tparams := buildCustomHostnameNewParams(\"zone-123\", ch)\n\n\t\tassert.Equal(t, \"zone-123\", params.ZoneID.Value)\n\t\tassert.Equal(t, \"test.example.com\", params.Hostname.Value)\n\t\tassert.False(t, params.SSL.Present)\n\t})\n\n\tt.Run(\"Custom hostname with full SSL configuration\", func(t *testing.T) {\n\t\tch := customHostname{\n\t\t\thostname:           \"test.example.com\",\n\t\t\tcustomOriginServer: \"origin.example.com\",\n\t\t\tssl: &customHostnameSSL{\n\t\t\t\tsslType:              \"dv\",\n\t\t\t\tmethod:               \"http\",\n\t\t\t\tbundleMethod:         \"ubiquitous\",\n\t\t\t\tcertificateAuthority: \"digicert\",\n\t\t\t\tsettings: customHostnameSSLSettings{\n\t\t\t\t\tminTLSVersion: \"1.2\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\n\t\tparams := buildCustomHostnameNewParams(\"zone-123\", ch)\n\n\t\tassert.Equal(t, \"zone-123\", params.ZoneID.Value)\n\t\tassert.Equal(t, \"test.example.com\", params.Hostname.Value)\n\t\tassert.True(t, params.SSL.Present)\n\n\t\tssl := params.SSL.Value\n\t\tassert.Equal(t, \"dv\", string(ssl.Type.Value))\n\t\tassert.Equal(t, \"http\", string(ssl.Method.Value))\n\t\tassert.Equal(t, \"ubiquitous\", string(ssl.BundleMethod.Value))\n\t\tassert.Equal(t, \"digicert\", string(ssl.CertificateAuthority.Value))\n\t\tassert.Equal(t, \"1.2\", string(ssl.Settings.Value.MinTLSVersion.Value))\n\t})\n\n\tt.Run(\"Custom hostname with partial SSL configuration\", func(t *testing.T) {\n\t\tch := customHostname{\n\t\t\thostname:           \"test.example.com\",\n\t\t\tcustomOriginServer: \"origin.example.com\",\n\t\t\tssl: &customHostnameSSL{\n\t\t\t\tsslType: \"dv\",\n\t\t\t\tmethod:  \"http\",\n\t\t\t},\n\t\t}\n\n\t\tparams := buildCustomHostnameNewParams(\"zone-123\", ch)\n\n\t\tassert.True(t, params.SSL.Present)\n\t\tssl := params.SSL.Value\n\t\tassert.Equal(t, \"dv\", string(ssl.Type.Value))\n\t\tassert.Equal(t, \"http\", string(ssl.Method.Value))\n\t\tassert.False(t, ssl.BundleMethod.Present)\n\t\tassert.False(t, ssl.CertificateAuthority.Present)\n\t\tassert.False(t, ssl.Settings.Present)\n\t})\n\n\tt.Run(\"Custom hostname with 'none' certificate authority\", func(t *testing.T) {\n\t\tch := customHostname{\n\t\t\thostname:           \"test.example.com\",\n\t\t\tcustomOriginServer: \"origin.example.com\",\n\t\t\tssl: &customHostnameSSL{\n\t\t\t\tsslType:              \"dv\",\n\t\t\t\tmethod:               \"http\",\n\t\t\t\tcertificateAuthority: \"none\",\n\t\t\t},\n\t\t}\n\n\t\tparams := buildCustomHostnameNewParams(\"zone-123\", ch)\n\n\t\tassert.True(t, params.SSL.Present)\n\t\tssl := params.SSL.Value\n\t\t// \"none\" should not be set as certificate authority\n\t\tassert.False(t, ssl.CertificateAuthority.Present)\n\t})\n\n\tt.Run(\"Custom hostname with empty certificate authority\", func(t *testing.T) {\n\t\tch := customHostname{\n\t\t\thostname:           \"test.example.com\",\n\t\t\tcustomOriginServer: \"origin.example.com\",\n\t\t\tssl: &customHostnameSSL{\n\t\t\t\tsslType:              \"dv\",\n\t\t\t\tmethod:               \"http\",\n\t\t\t\tcertificateAuthority: \"\",\n\t\t\t},\n\t\t}\n\n\t\tparams := buildCustomHostnameNewParams(\"zone-123\", ch)\n\n\t\tassert.True(t, params.SSL.Present)\n\t\tssl := params.SSL.Value\n\t\t// Empty string should not be set\n\t\tassert.False(t, ssl.CertificateAuthority.Present)\n\t})\n\n\tt.Run(\"Custom hostname with only MinTLSVersion\", func(t *testing.T) {\n\t\tch := customHostname{\n\t\t\thostname:           \"test.example.com\",\n\t\t\tcustomOriginServer: \"origin.example.com\",\n\t\t\tssl: &customHostnameSSL{\n\t\t\t\tsettings: customHostnameSSLSettings{\n\t\t\t\t\tminTLSVersion: \"1.3\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\n\t\tparams := buildCustomHostnameNewParams(\"zone-123\", ch)\n\n\t\tassert.True(t, params.SSL.Present)\n\t\tssl := params.SSL.Value\n\t\tassert.True(t, ssl.Settings.Present)\n\t\tassert.Equal(t, \"1.3\", string(ssl.Settings.Value.MinTLSVersion.Value))\n\t})\n}\n\nfunc TestSubmitCustomHostnameChanges(t *testing.T) {\n\tctx := t.Context()\n\n\tt.Run(\"CustomHostnames_Disabled\", func(t *testing.T) {\n\t\tclient := NewMockCloudFlareClient()\n\t\tprovider := &CloudFlareProvider{\n\t\t\tClient: client,\n\t\t\tCustomHostnamesConfig: CustomHostnamesConfig{\n\t\t\t\tEnabled: false,\n\t\t\t},\n\t\t}\n\n\t\tchange := &cloudFlareChange{\n\t\t\tAction: cloudFlareCreate,\n\t\t}\n\n\t\tresult := provider.submitCustomHostnameChanges(ctx, \"zone1\", change, nil, nil)\n\t\tassert.True(t, result, \"Should return true when custom hostnames are disabled\")\n\t})\n\n\tt.Run(\"CustomHostnames_Create\", func(t *testing.T) {\n\t\tclient := NewMockCloudFlareClient()\n\t\tprovider := &CloudFlareProvider{\n\t\t\tClient: client,\n\t\t\tCustomHostnamesConfig: CustomHostnamesConfig{\n\t\t\t\tEnabled: true,\n\t\t\t},\n\t\t}\n\n\t\tchange := &cloudFlareChange{\n\t\t\tAction: cloudFlareCreate,\n\t\t\tResourceRecord: dns.RecordResponse{\n\t\t\t\tType: \"A\",\n\t\t\t},\n\t\t\tCustomHostnames: map[string]customHostname{\n\t\t\t\t\"new.example.com\": {\n\t\t\t\t\thostname:           \"new.example.com\",\n\t\t\t\t\tcustomOriginServer: \"origin.example.com\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\n\t\tchs := make(customHostnamesMap)\n\t\tresult := provider.submitCustomHostnameChanges(ctx, \"zone1\", change, chs, nil)\n\t\tassert.True(t, result, \"Should successfully create custom hostname\")\n\t\tassert.Len(t, client.customHostnames[\"zone1\"], 1, \"One custom hostname should be created\")\n\t\tassert.Contains(t, client.customHostnames[\"zone1\"],\n\t\t\tcustomHostname{\n\t\t\t\tid:                 \"ID-new.example.com\",\n\t\t\t\thostname:           \"new.example.com\",\n\t\t\t\tcustomOriginServer: \"origin.example.com\",\n\t\t\t},\n\t\t\t\"Custom hostname should be created in mock client\",\n\t\t)\n\t})\n\n\tt.Run(\"CustomHostnames_Create_AlreadyExists\", func(t *testing.T) {\n\t\tclient := NewMockCloudFlareClient()\n\t\tprovider := &CloudFlareProvider{\n\t\t\tClient: client,\n\t\t\tCustomHostnamesConfig: CustomHostnamesConfig{\n\t\t\t\tEnabled: true,\n\t\t\t},\n\t\t}\n\n\t\tchange := &cloudFlareChange{\n\t\t\tAction: cloudFlareCreate,\n\t\t\tResourceRecord: dns.RecordResponse{\n\t\t\t\tType: \"A\",\n\t\t\t},\n\t\t\tCustomHostnames: map[string]customHostname{\n\t\t\t\t\"exists.example.com\": {\n\t\t\t\t\thostname:           \"exists.example.com\",\n\t\t\t\t\tcustomOriginServer: \"origin.example.com\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\n\t\tchs := customHostnamesMap{\n\t\t\tcustomHostnameIndex{hostname: \"exists.example.com\"}: {\n\t\t\t\tid:                 \"ch1\",\n\t\t\t\thostname:           \"exists.example.com\",\n\t\t\t\tcustomOriginServer: \"origin.example.com\",\n\t\t\t},\n\t\t}\n\n\t\tclient.customHostnames = map[string][]customHostname{\n\t\t\t\"zone1\": {\n\t\t\t\t{\n\t\t\t\t\tid:                 \"ch1\",\n\t\t\t\t\thostname:           \"exists.example.com\",\n\t\t\t\t\tcustomOriginServer: \"origin.example.com\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\n\t\tresult := provider.submitCustomHostnameChanges(ctx, \"zone1\", change, chs, nil)\n\t\tassert.True(t, result, \"Should succeed when custom hostname already exists with same origin\")\n\t\tassert.Len(t, client.customHostnames[\"zone1\"], 1, \"No new custom hostname should be created\")\n\t\tassert.Contains(t, client.customHostnames[\"zone1\"],\n\t\t\tcustomHostname{\n\t\t\t\tid:                 \"ch1\",\n\t\t\t\thostname:           \"exists.example.com\",\n\t\t\t\tcustomOriginServer: \"origin.example.com\",\n\t\t\t},\n\t\t\t\"Existing custom hostname should remain unchanged in mock client\",\n\t\t)\n\t})\n\n\tt.Run(\"CustomHostnames_Delete\", func(_ *testing.T) {\n\t\tclient := NewMockCloudFlareClient()\n\t\tclient.customHostnames = map[string][]customHostname{\n\t\t\t\"zone1\": {\n\t\t\t\t{\n\t\t\t\t\tid:                 \"ch1\",\n\t\t\t\t\thostname:           \"delete.example.com\",\n\t\t\t\t\tcustomOriginServer: \"origin.example.com\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\tprovider := &CloudFlareProvider{\n\t\t\tClient: client,\n\t\t\tCustomHostnamesConfig: CustomHostnamesConfig{\n\t\t\t\tEnabled: true,\n\t\t\t},\n\t\t}\n\n\t\tchange := &cloudFlareChange{\n\t\t\tAction: cloudFlareDelete,\n\t\t\tResourceRecord: dns.RecordResponse{\n\t\t\t\tType: \"A\",\n\t\t\t},\n\t\t\tCustomHostnames: map[string]customHostname{\n\t\t\t\t\"delete.example.com\": {\n\t\t\t\t\thostname: \"delete.example.com\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\n\t\tchs := customHostnamesMap{\n\t\t\tcustomHostnameIndex{hostname: \"delete.example.com\"}: {\n\t\t\t\tid:                 \"ch1\",\n\t\t\t\thostname:           \"delete.example.com\",\n\t\t\t\tcustomOriginServer: \"origin.example.com\",\n\t\t\t},\n\t\t}\n\n\t\t// Note: submitCustomHostnameChanges returns false on failure, true on success\n\t\t// The mock may not find the hostname to delete, which is fine for this test\n\t\tresult := provider.submitCustomHostnameChanges(ctx, \"zone1\", change, chs, nil)\n\t\t// We just verify it doesn't panic - result may be true or false depending on mock behavior\n\t\t_ = result\n\t})\n\n\tt.Run(\"CustomHostnames_Update\", func(t *testing.T) {\n\t\tclient := NewMockCloudFlareClient()\n\t\tclient.customHostnames = map[string][]customHostname{\n\t\t\t\"zone1\": {\n\t\t\t\t{\n\t\t\t\t\tid:                 \"ch1\",\n\t\t\t\t\thostname:           \"old.example.com\",\n\t\t\t\t\tcustomOriginServer: \"origin.example.com\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\tprovider := &CloudFlareProvider{\n\t\t\tClient: client,\n\t\t\tCustomHostnamesConfig: CustomHostnamesConfig{\n\t\t\t\tEnabled: true,\n\t\t\t},\n\t\t}\n\n\t\tchange := &cloudFlareChange{\n\t\t\tAction: cloudFlareUpdate,\n\t\t\tResourceRecord: dns.RecordResponse{\n\t\t\t\tType: \"A\",\n\t\t\t},\n\t\t\tCustomHostnames: map[string]customHostname{\n\t\t\t\t\"new.example.com\": {\n\t\t\t\t\thostname:           \"new.example.com\",\n\t\t\t\t\tcustomOriginServer: \"origin.example.com\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tCustomHostnamesPrev: []string{\"old.example.com\"},\n\t\t}\n\n\t\tchs := customHostnamesMap{\n\t\t\tcustomHostnameIndex{hostname: \"old.example.com\"}: {\n\t\t\t\tid:                 \"ch1\",\n\t\t\t\thostname:           \"old.example.com\",\n\t\t\t\tcustomOriginServer: \"origin.example.com\",\n\t\t\t},\n\t\t}\n\n\t\tclient.customHostnames = map[string][]customHostname{\n\t\t\t\"zone1\": {\n\t\t\t\t{\n\t\t\t\t\tid:                 \"ch1\",\n\t\t\t\t\thostname:           \"old.example.com\",\n\t\t\t\t\tcustomOriginServer: \"origin.example.com\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\n\t\tresult := provider.submitCustomHostnameChanges(ctx, \"zone1\", change, chs, nil)\n\t\tassert.True(t, result, \"Should successfully update custom hostname\")\n\t\tassert.Len(t, client.customHostnames[\"zone1\"], 1, \"One custom hostname should exist after update\")\n\t\tassert.Contains(t, client.customHostnames[\"zone1\"],\n\t\t\tcustomHostname{\n\t\t\t\tid:                 \"ID-new.example.com\",\n\t\t\t\thostname:           \"new.example.com\",\n\t\t\t\tcustomOriginServer: \"origin.example.com\",\n\t\t\t},\n\t\t\t\"Custom hostname should be updated in mock client\",\n\t\t)\n\t})\n}\n"
  },
  {
    "path": "provider/cloudflare/cloudflare_regional.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage cloudflare\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"maps\"\n\t\"slices\"\n\n\t\"github.com/cloudflare/cloudflare-go/v5\"\n\t\"github.com/cloudflare/cloudflare-go/v5/addressing\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n)\n\ntype RegionalServicesConfig struct {\n\tEnabled   bool\n\tRegionKey string\n}\n\nvar recordTypeRegionalHostnameSupported = map[string]bool{\n\t\"A\":     true,\n\t\"AAAA\":  true,\n\t\"CNAME\": true,\n}\n\ntype regionalHostname struct {\n\thostname  string\n\tregionKey string\n}\n\n// regionalHostnamesMap is a map of regional hostnames keyed by hostname.\ntype regionalHostnamesMap map[string]regionalHostname\n\ntype regionalHostnameChange struct {\n\taction changeAction\n\tregionalHostname\n}\n\nfunc (z zoneService) ListDataLocalizationRegionalHostnames(ctx context.Context, params addressing.RegionalHostnameListParams) autoPager[addressing.RegionalHostnameListResponse] {\n\treturn z.service.Addressing.RegionalHostnames.ListAutoPaging(ctx, params)\n}\n\nfunc (z zoneService) CreateDataLocalizationRegionalHostname(ctx context.Context, params addressing.RegionalHostnameNewParams) error {\n\t_, err := z.service.Addressing.RegionalHostnames.New(ctx, params)\n\treturn err\n}\n\nfunc (z zoneService) UpdateDataLocalizationRegionalHostname(ctx context.Context, hostname string, params addressing.RegionalHostnameEditParams) error {\n\t_, err := z.service.Addressing.RegionalHostnames.Edit(ctx, hostname, params)\n\treturn err\n}\n\nfunc (z zoneService) DeleteDataLocalizationRegionalHostname(ctx context.Context, hostname string, params addressing.RegionalHostnameDeleteParams) error {\n\t_, err := z.service.Addressing.RegionalHostnames.Delete(ctx, hostname, params)\n\treturn err\n}\n\n// listDataLocalizationRegionalHostnamesParams is a function that returns the appropriate RegionalHostname List Param based on the zoneID\nfunc listDataLocalizationRegionalHostnamesParams(zoneID string) addressing.RegionalHostnameListParams {\n\treturn addressing.RegionalHostnameListParams{\n\t\tZoneID: cloudflare.F(zoneID),\n\t}\n}\n\n// createDataLocalizationRegionalHostnameParams is a function that returns the appropriate RegionalHostname Param based on the cloudFlareChange passed in\nfunc createDataLocalizationRegionalHostnameParams(zoneID string, rhc regionalHostnameChange) addressing.RegionalHostnameNewParams {\n\treturn addressing.RegionalHostnameNewParams{\n\t\tZoneID:    cloudflare.F(zoneID),\n\t\tHostname:  cloudflare.F(rhc.hostname),\n\t\tRegionKey: cloudflare.F(rhc.regionKey),\n\t}\n}\n\n// updateDataLocalizationRegionalHostnameParams is a function that returns the appropriate RegionalHostname Param based on the cloudFlareChange passed in\nfunc updateDataLocalizationRegionalHostnameParams(zoneID string, rhc regionalHostnameChange) addressing.RegionalHostnameEditParams {\n\treturn addressing.RegionalHostnameEditParams{\n\t\tZoneID:    cloudflare.F(zoneID),\n\t\tRegionKey: cloudflare.F(rhc.regionKey),\n\t}\n}\n\n// deleteDataLocalizationRegionalHostnameParams is a function that returns the appropriate RegionalHostname Param based on the cloudFlareChange passed in\nfunc deleteDataLocalizationRegionalHostnameParams(zoneID string) addressing.RegionalHostnameDeleteParams {\n\treturn addressing.RegionalHostnameDeleteParams{\n\t\tZoneID: cloudflare.F(zoneID),\n\t}\n}\n\n// submitRegionalHostnameChanges applies a set of regional hostname changes, returns false if at least one fails\nfunc (p *CloudFlareProvider) submitRegionalHostnameChanges(ctx context.Context, zoneID string, rhChanges []regionalHostnameChange) bool {\n\tfailedChange := false\n\n\tfor _, rhChange := range rhChanges {\n\t\tif !p.submitRegionalHostnameChange(ctx, zoneID, rhChange) {\n\t\t\tfailedChange = true\n\t\t}\n\t}\n\n\treturn !failedChange\n}\n\n// submitRegionalHostnameChange applies a single regional hostname change, returns false if it fails\nfunc (p *CloudFlareProvider) submitRegionalHostnameChange(ctx context.Context, zoneID string, rhChange regionalHostnameChange) bool {\n\tchangeLog := log.WithFields(log.Fields{\n\t\t\"hostname\":   rhChange.hostname,\n\t\t\"region_key\": rhChange.regionKey,\n\t\t\"action\":     rhChange.action.String(),\n\t\t\"zone\":       zoneID,\n\t})\n\tif p.DryRun {\n\t\tchangeLog.Debug(\"Dry run: skipping regional hostname change\", rhChange.action)\n\t\treturn true\n\t}\n\tswitch rhChange.action {\n\tcase cloudFlareCreate:\n\t\tchangeLog.Debug(\"Creating regional hostname\")\n\t\tparams := createDataLocalizationRegionalHostnameParams(zoneID, rhChange)\n\t\tif err := p.Client.CreateDataLocalizationRegionalHostname(ctx, params); err != nil {\n\t\t\tchangeLog.Errorf(\"failed to create regional hostname: %v\", err)\n\t\t\treturn false\n\t\t}\n\tcase cloudFlareUpdate:\n\t\tchangeLog.Debug(\"Updating regional hostname\")\n\t\tparams := updateDataLocalizationRegionalHostnameParams(zoneID, rhChange)\n\t\tif err := p.Client.UpdateDataLocalizationRegionalHostname(ctx, rhChange.hostname, params); err != nil {\n\t\t\tchangeLog.Errorf(\"failed to update regional hostname: %v\", err)\n\t\t\treturn false\n\t\t}\n\tcase cloudFlareDelete:\n\t\tchangeLog.Debug(\"Deleting regional hostname\")\n\t\tparams := deleteDataLocalizationRegionalHostnameParams(zoneID)\n\t\tif err := p.Client.DeleteDataLocalizationRegionalHostname(ctx, rhChange.hostname, params); err != nil {\n\t\t\tchangeLog.Errorf(\"failed to delete regional hostname: %v\", err)\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// listDataLocalisationRegionalHostnames fetches the current regional hostnames for the given zone ID.\n//\n// It returns a map of hostnames to regional hostnames, or an error if the request fails.\nfunc (p *CloudFlareProvider) listDataLocalisationRegionalHostnames(ctx context.Context, zoneID string) (regionalHostnamesMap, error) {\n\tparams := listDataLocalizationRegionalHostnamesParams(zoneID)\n\titer := p.Client.ListDataLocalizationRegionalHostnames(ctx, params)\n\trhsMap := make(regionalHostnamesMap)\n\tfor rh := range autoPagerIterator(iter) {\n\t\trhsMap[rh.Hostname] = regionalHostname{\n\t\t\thostname:  rh.Hostname,\n\t\t\tregionKey: rh.RegionKey,\n\t\t}\n\t}\n\tif iter.Err() != nil {\n\t\treturn nil, convertCloudflareError(iter.Err())\n\t}\n\treturn rhsMap, nil\n}\n\n// regionalHostname returns a regionalHostname for the given endpoint.\n//\n// If the regional services feature is not enabled or the record type does not support regional hostnames,\n// it returns an empty regionalHostname.\n// If the endpoint has a specific region key set, it uses that; otherwise, it defaults to the region key configured in the provider.\nfunc (p *CloudFlareProvider) regionalHostname(ep *endpoint.Endpoint) regionalHostname {\n\tif !p.RegionalServicesConfig.Enabled || !recordTypeRegionalHostnameSupported[ep.RecordType] {\n\t\treturn regionalHostname{}\n\t}\n\tregionKey := p.RegionalServicesConfig.RegionKey\n\tif epRegionKey, exists := ep.GetProviderSpecificProperty(annotations.CloudflareRegionKey); exists {\n\t\tregionKey = epRegionKey\n\t}\n\treturn regionalHostname{\n\t\thostname:  ep.DNSName,\n\t\tregionKey: regionKey,\n\t}\n}\n\n// addEnpointsProviderSpecificRegionKeyProperty fetch the regional hostnames on cloudflare and\n// adds Cloudflare-specific region keys to the provided endpoints.\n//\n// Do nothing if the regional services feature is not enabled.\n// Defaults to the region key configured in the provider config if not found in the regional hostnames.\nfunc (p *CloudFlareProvider) addEnpointsProviderSpecificRegionKeyProperty(ctx context.Context, zoneID string, endpoints []*endpoint.Endpoint) error {\n\tif !p.RegionalServicesConfig.Enabled {\n\t\treturn nil\n\t}\n\n\t// Filter endpoints to only those that support regional hostnames\n\t// so we can skip regional hostname lookups if not needed.\n\tvar supportedEndpoints []*endpoint.Endpoint\n\tfor _, ep := range endpoints {\n\t\tif recordTypeRegionalHostnameSupported[ep.RecordType] {\n\t\t\tsupportedEndpoints = append(supportedEndpoints, ep)\n\t\t}\n\t}\n\tif len(supportedEndpoints) == 0 {\n\t\treturn nil\n\t}\n\n\tregionalHostnames, err := p.listDataLocalisationRegionalHostnames(ctx, zoneID)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, ep := range supportedEndpoints {\n\t\tvar regionKey string\n\t\tif rh, found := regionalHostnames[ep.DNSName]; found {\n\t\t\tregionKey = rh.regionKey\n\t\t}\n\t\tep.SetProviderSpecificProperty(annotations.CloudflareRegionKey, regionKey)\n\t}\n\treturn nil\n}\n\n// adjustEnpointProviderSpecificRegionKeyProperty updates the given endpoint's provider-specific\n// Cloudflare region key based on the provider's RegionalServicesConfig.\n//   - If regional services are disabled or the endpoint's record type does not\n//     support regional hostnames, the Cloudflare region key is removed.\n//   - If enabled and supported, and the key is not already set, it is initialized\n//     to the provider's default RegionKey.\n//\n// The endpoint is modified in place and any explicitly set region key is left unchanged.\nfunc (p *CloudFlareProvider) adjustEndpointProviderSpecificRegionKeyProperty(ep *endpoint.Endpoint) {\n\tif !p.RegionalServicesConfig.Enabled || !recordTypeRegionalHostnameSupported[ep.RecordType] {\n\t\tep.DeleteProviderSpecificProperty(annotations.CloudflareRegionKey)\n\t\treturn\n\t}\n\t// Add default region key if not set\n\tif _, ok := ep.GetProviderSpecificProperty(annotations.CloudflareRegionKey); !ok {\n\t\tep.SetProviderSpecificProperty(annotations.CloudflareRegionKey, p.RegionalServicesConfig.RegionKey)\n\t}\n}\n\n// desiredRegionalHostnames builds a list of desired regional hostnames from changes.\n//\n// If there is a delete and a create or update action for the same hostname,\n// The create or update takes precedence.\n// Returns an error for conflicting region keys.\nfunc desiredRegionalHostnames(changes []*cloudFlareChange) ([]regionalHostname, error) {\n\trhs := make(map[string]regionalHostname)\n\tfor _, change := range changes {\n\t\tif change.RegionalHostname.hostname == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\trh, found := rhs[change.RegionalHostname.hostname]\n\t\tif !found {\n\t\t\tif change.Action == cloudFlareDelete {\n\t\t\t\trhs[change.RegionalHostname.hostname] = regionalHostname{\n\t\t\t\t\thostname:  change.RegionalHostname.hostname,\n\t\t\t\t\tregionKey: \"\", // Indicate that this regional hostname should not exists\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\trhs[change.RegionalHostname.hostname] = change.RegionalHostname\n\t\t\tcontinue\n\t\t}\n\t\tif change.Action == cloudFlareDelete {\n\t\t\t// A previous regional hostname exists so we can skip this delete action\n\t\t\tcontinue\n\t\t}\n\t\tif rh.regionKey == \"\" {\n\t\t\t// If the existing regional hostname has no region key, we can overwrite it\n\t\t\trhs[change.RegionalHostname.hostname] = change.RegionalHostname\n\t\t\tcontinue\n\t\t}\n\t\tif rh.regionKey != change.RegionalHostname.regionKey {\n\t\t\treturn nil, fmt.Errorf(\"conflicting region keys for regional hostname %q: %q and %q\", change.RegionalHostname.hostname, rh.regionKey, change.RegionalHostname.regionKey)\n\t\t}\n\t}\n\treturn slices.Collect(maps.Values(rhs)), nil\n}\n\n// regionalHostnamesChanges build a list of changes needed to synchronize the current regional hostnames state with the desired state.\nfunc regionalHostnamesChanges(desired []regionalHostname, regionalHostnames regionalHostnamesMap) []regionalHostnameChange {\n\tchanges := make([]regionalHostnameChange, 0)\n\tfor _, rh := range desired {\n\t\tcurrent, found := regionalHostnames[rh.hostname]\n\t\tif rh.regionKey == \"\" {\n\t\t\t// If the region key is empty, we don't want a regional hostname\n\t\t\tif !found {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tchanges = append(changes, regionalHostnameChange{\n\t\t\t\taction:           cloudFlareDelete,\n\t\t\t\tregionalHostname: rh,\n\t\t\t})\n\t\t\tcontinue\n\t\t}\n\t\tif !found {\n\t\t\tchanges = append(changes, regionalHostnameChange{\n\t\t\t\taction:           cloudFlareCreate,\n\t\t\t\tregionalHostname: rh,\n\t\t\t})\n\t\t\tcontinue\n\t\t}\n\t\tif rh.regionKey != current.regionKey {\n\t\t\tchanges = append(changes, regionalHostnameChange{\n\t\t\t\taction:           cloudFlareUpdate,\n\t\t\t\tregionalHostname: rh,\n\t\t\t})\n\t\t}\n\t}\n\treturn changes\n}\n"
  },
  {
    "path": "provider/cloudflare/cloudflare_regional_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage cloudflare\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"sort\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/cloudflare/cloudflare-go/v5/addressing\"\n\t\"github.com/cloudflare/cloudflare-go/v5/dns\"\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/internal/testutils\"\n\tlogtest \"sigs.k8s.io/external-dns/internal/testutils/log\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n)\n\nfunc (m *mockCloudFlareClient) ListDataLocalizationRegionalHostnames(_ context.Context, params addressing.RegionalHostnameListParams) autoPager[addressing.RegionalHostnameListResponse] {\n\tzoneID := params.ZoneID.Value\n\tif strings.Contains(zoneID, \"rherror\") {\n\t\treturn &mockAutoPager[addressing.RegionalHostnameListResponse]{err: fmt.Errorf(\"failed to list regional hostnames\")}\n\t}\n\tresults := make([]addressing.RegionalHostnameListResponse, 0, len(m.regionalHostnames[zoneID]))\n\tfor _, rh := range m.regionalHostnames[zoneID] {\n\t\tresults = append(results, addressing.RegionalHostnameListResponse{\n\t\t\tHostname:  rh.hostname,\n\t\t\tRegionKey: rh.regionKey,\n\t\t})\n\t}\n\treturn &mockAutoPager[addressing.RegionalHostnameListResponse]{\n\t\titems: results,\n\t}\n}\n\nfunc (m *mockCloudFlareClient) CreateDataLocalizationRegionalHostname(_ context.Context, params addressing.RegionalHostnameNewParams) error {\n\tif strings.Contains(params.Hostname.Value, \"rherror\") {\n\t\treturn fmt.Errorf(\"failed to create regional hostname\")\n\t}\n\n\tm.Actions = append(m.Actions, MockAction{\n\t\tName:     \"CreateDataLocalizationRegionalHostname\",\n\t\tZoneId:   params.ZoneID.Value,\n\t\tRecordId: \"\",\n\t\tRegionalHostname: regionalHostname{\n\t\t\thostname:  params.Hostname.Value,\n\t\t\tregionKey: params.RegionKey.Value,\n\t\t},\n\t})\n\treturn nil\n}\n\nfunc (m *mockCloudFlareClient) UpdateDataLocalizationRegionalHostname(_ context.Context, hostname string, params addressing.RegionalHostnameEditParams) error {\n\tif strings.Contains(hostname, \"rherror\") {\n\t\treturn fmt.Errorf(\"failed to update regional hostname\")\n\t}\n\n\tm.Actions = append(m.Actions, MockAction{\n\t\tName:     \"UpdateDataLocalizationRegionalHostname\",\n\t\tZoneId:   params.ZoneID.Value,\n\t\tRecordId: \"\",\n\t\tRegionalHostname: regionalHostname{\n\t\t\thostname:  hostname,\n\t\t\tregionKey: params.RegionKey.Value,\n\t\t},\n\t})\n\treturn nil\n}\n\nfunc (m *mockCloudFlareClient) DeleteDataLocalizationRegionalHostname(_ context.Context, hostname string, params addressing.RegionalHostnameDeleteParams) error {\n\tif strings.Contains(hostname, \"rherror\") {\n\t\treturn fmt.Errorf(\"failed to delete regional hostname\")\n\t}\n\tm.Actions = append(m.Actions, MockAction{\n\t\tName:     \"DeleteDataLocalizationRegionalHostname\",\n\t\tZoneId:   params.ZoneID.Value,\n\t\tRecordId: \"\",\n\t\tRegionalHostname: regionalHostname{\n\t\t\thostname: hostname,\n\t\t},\n\t})\n\treturn nil\n}\n\nfunc TestCloudflareRegionalHostnameActions(t *testing.T) {\n\ttests := []struct {\n\t\tname              string\n\t\trecords           map[string]dns.RecordResponse\n\t\tregionalHostnames []regionalHostname\n\t\tendpoints         []*endpoint.Endpoint\n\t\twant              []MockAction\n\t}{\n\t\t{\n\t\t\tname:              \"create\",\n\t\t\trecords:           map[string]dns.RecordResponse{},\n\t\t\tregionalHostnames: []regionalHostname{},\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tRecordType: \"A\",\n\t\t\t\t\tDNSName:    \"create.bar.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"127.0.0.1\"},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:  \"external-dns.alpha.kubernetes.io/cloudflare-region-key\",\n\t\t\t\t\t\t\tValue: \"eu\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []MockAction{\n\t\t\t\t{\n\t\t\t\t\tName:     \"Create\",\n\t\t\t\t\tZoneId:   \"001\",\n\t\t\t\t\tRecordId: generateDNSRecordID(\"A\", \"create.bar.com\", \"127.0.0.1\"),\n\t\t\t\t\tRecordData: dns.RecordResponse{\n\t\t\t\t\t\tID:      generateDNSRecordID(\"A\", \"create.bar.com\", \"127.0.0.1\"),\n\t\t\t\t\t\tType:    \"A\",\n\t\t\t\t\t\tName:    \"create.bar.com\",\n\t\t\t\t\t\tContent: \"127.0.0.1\",\n\t\t\t\t\t\tTTL:     1,\n\t\t\t\t\t\tProxied: false,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tName:   \"CreateDataLocalizationRegionalHostname\",\n\t\t\t\t\tZoneId: \"001\",\n\t\t\t\t\tRegionalHostname: regionalHostname{\n\t\t\t\t\t\thostname:  \"create.bar.com\",\n\t\t\t\t\t\tregionKey: \"eu\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"Update\",\n\t\t\trecords: map[string]dns.RecordResponse{\n\t\t\t\t\"update.bar.com\": {\n\t\t\t\t\tID:      generateDNSRecordID(\"A\", \"update.bar.com\", \"127.0.0.1\"),\n\t\t\t\t\tType:    \"A\",\n\t\t\t\t\tName:    \"update.bar.com\",\n\t\t\t\t\tContent: \"127.0.0.1\",\n\t\t\t\t\tTTL:     1,\n\t\t\t\t\tProxied: false,\n\t\t\t\t},\n\t\t\t},\n\t\t\tregionalHostnames: []regionalHostname{\n\t\t\t\t{\n\t\t\t\t\thostname:  \"update.bar.com\",\n\t\t\t\t\tregionKey: \"us\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tRecordType: \"A\",\n\t\t\t\t\tDNSName:    \"update.bar.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"127.0.0.1\"},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:  \"external-dns.alpha.kubernetes.io/cloudflare-region-key\",\n\t\t\t\t\t\t\tValue: \"eu\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []MockAction{\n\t\t\t\t{\n\t\t\t\t\tName:     \"Update\",\n\t\t\t\t\tZoneId:   \"001\",\n\t\t\t\t\tRecordId: generateDNSRecordID(\"A\", \"update.bar.com\", \"127.0.0.1\"),\n\t\t\t\t\tRecordData: dns.RecordResponse{\n\t\t\t\t\t\tID:      generateDNSRecordID(\"A\", \"update.bar.com\", \"127.0.0.1\"),\n\t\t\t\t\t\tType:    \"A\",\n\t\t\t\t\t\tName:    \"update.bar.com\",\n\t\t\t\t\t\tContent: \"127.0.0.1\",\n\t\t\t\t\t\tTTL:     1,\n\t\t\t\t\t\tProxied: false,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tName:   \"UpdateDataLocalizationRegionalHostname\",\n\t\t\t\t\tZoneId: \"001\",\n\t\t\t\t\tRegionalHostname: regionalHostname{\n\t\t\t\t\t\thostname:  \"update.bar.com\",\n\t\t\t\t\t\tregionKey: \"eu\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"Delete\",\n\t\t\trecords: map[string]dns.RecordResponse{\n\t\t\t\t\"update.bar.com\": {\n\t\t\t\t\tID:      generateDNSRecordID(\"A\", \"delete.bar.com\", \"127.0.0.1\"),\n\t\t\t\t\tType:    \"A\",\n\t\t\t\t\tName:    \"delete.bar.com\",\n\t\t\t\t\tContent: \"127.0.0.1\",\n\t\t\t\t\tTTL:     1,\n\t\t\t\t\tProxied: false,\n\t\t\t\t},\n\t\t\t},\n\t\t\tregionalHostnames: []regionalHostname{\n\t\t\t\t{\n\t\t\t\t\thostname:  \"delete.bar.com\",\n\t\t\t\t\tregionKey: \"us\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tendpoints: []*endpoint.Endpoint{},\n\t\t\twant: []MockAction{\n\t\t\t\t{\n\t\t\t\t\tName:       \"Delete\",\n\t\t\t\t\tZoneId:     \"001\",\n\t\t\t\t\tRecordId:   generateDNSRecordID(\"A\", \"delete.bar.com\", \"127.0.0.1\"),\n\t\t\t\t\tRecordData: dns.RecordResponse{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tName:   \"DeleteDataLocalizationRegionalHostname\",\n\t\t\t\t\tZoneId: \"001\",\n\t\t\t\t\tRegionalHostname: regionalHostname{\n\t\t\t\t\t\thostname: \"delete.bar.com\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"No change\",\n\t\t\trecords: map[string]dns.RecordResponse{\n\t\t\t\t\"nochange.bar.com\": {\n\t\t\t\t\tID:      generateDNSRecordID(\"A\", \"nochange.bar.com\", \"127.0.0.1\"),\n\t\t\t\t\tType:    \"A\",\n\t\t\t\t\tName:    \"nochange.bar.com\",\n\t\t\t\t\tContent: \"127.0.0.1\",\n\t\t\t\t\tTTL:     1,\n\t\t\t\t\tProxied: false,\n\t\t\t\t},\n\t\t\t},\n\t\t\tregionalHostnames: []regionalHostname{\n\t\t\t\t{\n\t\t\t\t\thostname:  \"nochange.bar.com\",\n\t\t\t\t\tregionKey: \"eu\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tRecordType: \"A\",\n\t\t\t\t\tDNSName:    \"nochange.bar.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"127.0.0.1\"},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:  \"external-dns.alpha.kubernetes.io/cloudflare-region-key\",\n\t\t\t\t\t\t\tValue: \"eu\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: nil,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tprovider := &CloudFlareProvider{\n\t\t\t\tRegionalServicesConfig: RegionalServicesConfig{Enabled: true, RegionKey: \"us\"},\n\t\t\t\tClient: &mockCloudFlareClient{\n\t\t\t\t\tZones: map[string]string{\n\t\t\t\t\t\t\"001\": \"bar.com\",\n\t\t\t\t\t},\n\t\t\t\t\tRecords: map[string]map[string]dns.RecordResponse{\n\t\t\t\t\t\t\"001\": tt.records,\n\t\t\t\t\t},\n\t\t\t\t\tregionalHostnames: map[string][]regionalHostname{\n\t\t\t\t\t\t\"001\": tt.regionalHostnames,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}\n\n\t\t\tAssertActions(t, provider, tt.endpoints, tt.want, []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME})\n\t\t})\n\t}\n}\n\nfunc TestCloudflareRegionalHostnameDefaults(t *testing.T) {\n\tendpoints := []*endpoint.Endpoint{\n\t\t{\n\t\t\tRecordType: \"A\",\n\t\t\tDNSName:    \"bar.com\",\n\t\t\tTargets:    endpoint.Targets{\"127.0.0.1\", \"127.0.0.2\"},\n\t\t},\n\t}\n\n\tAssertActions(t, &CloudFlareProvider{RegionalServicesConfig: RegionalServicesConfig{Enabled: true, RegionKey: \"us\"}}, endpoints, []MockAction{\n\t\t{\n\t\t\tName:     \"Create\",\n\t\t\tZoneId:   \"001\",\n\t\t\tRecordId: generateDNSRecordID(\"A\", \"bar.com\", \"127.0.0.1\"),\n\t\t\tRecordData: dns.RecordResponse{\n\t\t\t\tID:      generateDNSRecordID(\"A\", \"bar.com\", \"127.0.0.1\"),\n\t\t\t\tType:    \"A\",\n\t\t\t\tName:    \"bar.com\",\n\t\t\t\tContent: \"127.0.0.1\",\n\t\t\t\tTTL:     1,\n\t\t\t\tProxied: false,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:     \"Create\",\n\t\t\tZoneId:   \"001\",\n\t\t\tRecordId: generateDNSRecordID(\"A\", \"bar.com\", \"127.0.0.2\"),\n\t\t\tRecordData: dns.RecordResponse{\n\t\t\t\tID:      generateDNSRecordID(\"A\", \"bar.com\", \"127.0.0.2\"),\n\t\t\t\tType:    \"A\",\n\t\t\t\tName:    \"bar.com\",\n\t\t\t\tContent: \"127.0.0.2\",\n\t\t\t\tTTL:     1,\n\t\t\t\tProxied: false,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:   \"CreateDataLocalizationRegionalHostname\",\n\t\t\tZoneId: \"001\",\n\t\t\tRegionalHostname: regionalHostname{\n\t\t\t\thostname:  \"bar.com\",\n\t\t\t\tregionKey: \"us\",\n\t\t\t},\n\t\t},\n\t},\n\t\t[]string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},\n\t)\n}\n\nfunc Test_regionalHostname(t *testing.T) {\n\ttype args struct {\n\t\tendpoint *endpoint.Endpoint\n\t\tconfig   RegionalServicesConfig\n\t}\n\ttests := []struct {\n\t\tname string\n\t\targs args\n\t\twant regionalHostname\n\t}{\n\t\t{\n\t\t\tname: \"no region key\",\n\t\t\targs: args{\n\t\t\t\tendpoint: &endpoint.Endpoint{\n\t\t\t\t\tRecordType: \"A\",\n\t\t\t\t\tDNSName:    \"example.com\",\n\t\t\t\t},\n\t\t\t\tconfig: RegionalServicesConfig{\n\t\t\t\t\tEnabled:   true,\n\t\t\t\t\tRegionKey: \"\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: regionalHostname{\n\t\t\t\thostname:  \"example.com\",\n\t\t\t\tregionKey: \"\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"default region key\",\n\t\t\targs: args{\n\t\t\t\tendpoint: &endpoint.Endpoint{\n\t\t\t\t\tRecordType: \"A\",\n\t\t\t\t\tDNSName:    \"example.com\",\n\t\t\t\t},\n\t\t\t\tconfig: RegionalServicesConfig{\n\t\t\t\t\tEnabled:   true,\n\t\t\t\t\tRegionKey: \"us\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: regionalHostname{\n\t\t\t\thostname:  \"example.com\",\n\t\t\t\tregionKey: \"us\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"endpoint with region key\",\n\t\t\targs: args{\n\t\t\t\tendpoint: &endpoint.Endpoint{\n\t\t\t\t\tRecordType: \"A\",\n\t\t\t\t\tDNSName:    \"example.com\",\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:  \"external-dns.alpha.kubernetes.io/cloudflare-region-key\",\n\t\t\t\t\t\t\tValue: \"eu\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tconfig: RegionalServicesConfig{\n\t\t\t\t\tEnabled:   true,\n\t\t\t\t\tRegionKey: \"us\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: regionalHostname{\n\t\t\t\thostname:  \"example.com\",\n\t\t\t\tregionKey: \"eu\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"endpoint with empty region key\",\n\t\t\targs: args{\n\t\t\t\tendpoint: &endpoint.Endpoint{\n\t\t\t\t\tRecordType: \"A\",\n\t\t\t\t\tDNSName:    \"example.com\",\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:  \"external-dns.alpha.kubernetes.io/cloudflare-region-key\",\n\t\t\t\t\t\t\tValue: \"\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tconfig: RegionalServicesConfig{\n\t\t\t\t\tEnabled:   true,\n\t\t\t\t\tRegionKey: \"us\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: regionalHostname{\n\t\t\t\thostname:  \"example.com\",\n\t\t\t\tregionKey: \"\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"unsupported record type\",\n\t\t\targs: args{\n\t\t\t\tendpoint: &endpoint.Endpoint{\n\t\t\t\t\tRecordType: \"TXT\",\n\t\t\t\t\tDNSName:    \"example.com\",\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:  \"external-dns.alpha.kubernetes.io/cloudflare-region-key\",\n\t\t\t\t\t\t\tValue: \"eu\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tconfig: RegionalServicesConfig{\n\t\t\t\t\tEnabled:   true,\n\t\t\t\t\tRegionKey: \"us\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: regionalHostname{},\n\t\t},\n\t\t{\n\t\t\tname: \"disabled\",\n\t\t\targs: args{\n\t\t\t\tendpoint: &endpoint.Endpoint{\n\t\t\t\t\tRecordType: \"A\",\n\t\t\t\t\tDNSName:    \"example.com\",\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:  \"external-dns.alpha.kubernetes.io/cloudflare-region-key\",\n\t\t\t\t\t\t\tValue: \"us\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tconfig: RegionalServicesConfig{\n\t\t\t\t\tEnabled: false,\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: regionalHostname{\n\t\t\t\thostname:  \"\",\n\t\t\t\tregionKey: \"\",\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tp := CloudFlareProvider{RegionalServicesConfig: tt.args.config}\n\t\t\tgot := p.regionalHostname(tt.args.endpoint)\n\t\t\tassert.Equal(t, tt.want, got)\n\t\t})\n\t}\n}\n\nfunc Test_desiredDataLocalizationRegionalHostnames(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tchanges []*cloudFlareChange\n\t\twant    []regionalHostname\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname:    \"empty input\",\n\t\t\tchanges: []*cloudFlareChange{},\n\t\t\twant:    nil,\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"change without regional hostname config\",\n\t\t\tchanges: []*cloudFlareChange{{\n\t\t\t\tAction: cloudFlareCreate,\n\t\t\t}},\n\t\t\twant:    nil,\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"changes with same hostname and region key\",\n\t\t\tchanges: []*cloudFlareChange{\n\t\t\t\t{\n\t\t\t\t\tAction: cloudFlareCreate,\n\t\t\t\t\tRegionalHostname: regionalHostname{\n\t\t\t\t\t\thostname:  \"example.com\",\n\t\t\t\t\t\tregionKey: \"eu\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tAction: cloudFlareUpdate,\n\t\t\t\t\tRegionalHostname: regionalHostname{\n\t\t\t\t\t\thostname:  \"example.com\",\n\t\t\t\t\t\tregionKey: \"eu\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []regionalHostname{\n\t\t\t\t{\n\t\t\t\t\thostname:  \"example.com\",\n\t\t\t\t\tregionKey: \"eu\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"changes with same hostname but different region keys\",\n\t\t\tchanges: []*cloudFlareChange{\n\t\t\t\t{\n\t\t\t\t\tAction: cloudFlareCreate,\n\t\t\t\t\tRegionalHostname: regionalHostname{\n\t\t\t\t\t\thostname:  \"example.com\",\n\t\t\t\t\t\tregionKey: \"eu\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tAction: cloudFlareUpdate,\n\t\t\t\t\tRegionalHostname: regionalHostname{\n\t\t\t\t\t\thostname:  \"example.com\",\n\t\t\t\t\t\tregionKey: \"us\", // Different region key\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant:    nil,\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"changes with different hostnames\",\n\t\t\tchanges: []*cloudFlareChange{\n\t\t\t\t{\n\t\t\t\t\tAction: cloudFlareCreate,\n\t\t\t\t\tRegionalHostname: regionalHostname{\n\t\t\t\t\t\thostname:  \"example1.com\",\n\t\t\t\t\t\tregionKey: \"eu\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tAction: cloudFlareUpdate,\n\t\t\t\t\tRegionalHostname: regionalHostname{\n\t\t\t\t\t\thostname:  \"example2.com\",\n\t\t\t\t\t\tregionKey: \"us\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tAction: cloudFlareDelete,\n\t\t\t\t\tRegionalHostname: regionalHostname{\n\t\t\t\t\t\thostname:  \"example3.com\",\n\t\t\t\t\t\tregionKey: \"us\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []regionalHostname{\n\t\t\t\t{\n\t\t\t\t\thostname:  \"example1.com\",\n\t\t\t\t\tregionKey: \"eu\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\thostname:  \"example2.com\",\n\t\t\t\t\tregionKey: \"us\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\thostname:  \"example3.com\",\n\t\t\t\t\tregionKey: \"\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"change with empty region key\",\n\t\t\tchanges: []*cloudFlareChange{\n\t\t\t\t{\n\t\t\t\t\tAction: cloudFlareCreate,\n\t\t\t\t\tRegionalHostname: regionalHostname{\n\t\t\t\t\t\thostname:  \"example.com\",\n\t\t\t\t\t\tregionKey: \"\", // Empty region key\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []regionalHostname{\n\t\t\t\t{\n\t\t\t\t\thostname:  \"example.com\",\n\t\t\t\t\tregionKey: \"\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"empty region key followed by region key\",\n\t\t\tchanges: []*cloudFlareChange{\n\t\t\t\t{\n\t\t\t\t\tAction: cloudFlareCreate,\n\t\t\t\t\tRegionalHostname: regionalHostname{\n\t\t\t\t\t\thostname:  \"example.com\",\n\t\t\t\t\t\tregionKey: \"\", // Empty region key\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tAction: cloudFlareUpdate,\n\t\t\t\t\tRegionalHostname: regionalHostname{\n\t\t\t\t\t\thostname:  \"example.com\",\n\t\t\t\t\t\tregionKey: \"eu\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []regionalHostname{\n\t\t\t\t{\n\t\t\t\t\thostname:  \"example.com\",\n\t\t\t\t\tregionKey: \"eu\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"region key followed by empty region key\",\n\t\t\tchanges: []*cloudFlareChange{\n\t\t\t\t{\n\t\t\t\t\tAction: cloudFlareCreate,\n\t\t\t\t\tRegionalHostname: regionalHostname{\n\t\t\t\t\t\thostname:  \"example.com\",\n\t\t\t\t\t\tregionKey: \"eu\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tAction: cloudFlareUpdate,\n\t\t\t\t\tRegionalHostname: regionalHostname{\n\t\t\t\t\t\thostname:  \"example.com\",\n\t\t\t\t\t\tregionKey: \"eu\", // Empty region key\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []regionalHostname{\n\t\t\t\t{\n\t\t\t\t\thostname:  \"example.com\",\n\t\t\t\t\tregionKey: \"eu\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"delete followed by create for the same hostname\",\n\t\t\tchanges: []*cloudFlareChange{\n\t\t\t\t{\n\t\t\t\t\tAction: cloudFlareDelete,\n\t\t\t\t\tRegionalHostname: regionalHostname{\n\t\t\t\t\t\thostname:  \"example.com\",\n\t\t\t\t\t\tregionKey: \"eu\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tAction: cloudFlareCreate,\n\t\t\t\t\tRegionalHostname: regionalHostname{\n\t\t\t\t\t\thostname:  \"example.com\",\n\t\t\t\t\t\tregionKey: \"eu\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []regionalHostname{\n\t\t\t\t{\n\t\t\t\t\thostname:  \"example.com\",\n\t\t\t\t\tregionKey: \"eu\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"create followed by delete for the same hostname\",\n\t\t\tchanges: []*cloudFlareChange{\n\t\t\t\t{\n\t\t\t\t\tAction: cloudFlareCreate,\n\t\t\t\t\tRegionalHostname: regionalHostname{\n\t\t\t\t\t\thostname:  \"example.com\",\n\t\t\t\t\t\tregionKey: \"eu\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tAction: cloudFlareDelete,\n\t\t\t\t\tRegionalHostname: regionalHostname{\n\t\t\t\t\t\thostname:  \"example.com\",\n\t\t\t\t\t\tregionKey: \"eu\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []regionalHostname{\n\t\t\t\t{\n\t\t\t\t\thostname:  \"example.com\",\n\t\t\t\t\tregionKey: \"eu\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, err := desiredRegionalHostnames(tt.changes)\n\t\t\tif tt.wantErr {\n\t\t\t\tassert.Error(t, err)\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t}\n\t\t\tsort.Slice(got, func(i, j int) bool {\n\t\t\t\treturn got[i].hostname < got[j].hostname\n\t\t\t})\n\t\t\tsort.Slice(tt.want, func(i, j int) bool {\n\t\t\t\treturn tt.want[i].hostname < tt.want[j].hostname\n\t\t\t})\n\t\t\tassert.Equal(t, tt.want, got)\n\t\t})\n\t}\n}\n\nfunc Test_dataLocalizationRegionalHostnamesChanges(t *testing.T) {\n\ttests := []struct {\n\t\tname              string\n\t\tdesired           []regionalHostname\n\t\tregionalHostnames regionalHostnamesMap\n\t\twant              []regionalHostnameChange\n\t}{\n\t\t{\n\t\t\tname:              \"empty desired and current lists\",\n\t\t\tdesired:           []regionalHostname{},\n\t\t\tregionalHostnames: regionalHostnamesMap{},\n\t\t\twant:              []regionalHostnameChange{},\n\t\t},\n\t\t{\n\t\t\tname: \"multiple changes\",\n\t\t\tdesired: []regionalHostname{\n\t\t\t\t{\n\t\t\t\t\thostname:  \"create.example.com\",\n\t\t\t\t\tregionKey: \"eu\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\thostname:  \"update.example.com\",\n\t\t\t\t\tregionKey: \"eu\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\thostname:  \"delete.example.com\",\n\t\t\t\t\tregionKey: \"\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\thostname:  \"nochange.example.com\",\n\t\t\t\t\tregionKey: \"us\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\thostname:  \"absent.example.com\",\n\t\t\t\t\tregionKey: \"\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tregionalHostnames: regionalHostnamesMap{\n\t\t\t\t\"update.example.com\": regionalHostname{\n\t\t\t\t\thostname:  \"update.example.com\",\n\t\t\t\t\tregionKey: \"us\",\n\t\t\t\t},\n\t\t\t\t\"delete.example.com\": regionalHostname{\n\t\t\t\t\thostname:  \"delete.example.com\",\n\t\t\t\t\tregionKey: \"ap\",\n\t\t\t\t},\n\t\t\t\t\"nochange.example.com\": regionalHostname{\n\t\t\t\t\thostname:  \"nochange.example.com\",\n\t\t\t\t\tregionKey: \"us\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []regionalHostnameChange{\n\t\t\t\t{\n\t\t\t\t\taction: cloudFlareCreate,\n\t\t\t\t\tregionalHostname: regionalHostname{\n\t\t\t\t\t\thostname:  \"create.example.com\",\n\t\t\t\t\t\tregionKey: \"eu\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\taction: cloudFlareUpdate,\n\t\t\t\t\tregionalHostname: regionalHostname{\n\t\t\t\t\t\thostname:  \"update.example.com\",\n\t\t\t\t\t\tregionKey: \"eu\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\taction: cloudFlareDelete,\n\t\t\t\t\tregionalHostname: regionalHostname{\n\t\t\t\t\t\thostname:  \"delete.example.com\",\n\t\t\t\t\t\tregionKey: \"\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := regionalHostnamesChanges(tt.desired, tt.regionalHostnames)\n\t\t\tassert.Equal(t, tt.want, got)\n\t\t})\n\t}\n}\n\nfunc TestRecordsWithListRegionalHostnameFaillure(t *testing.T) {\n\tclient := &mockCloudFlareClient{\n\t\tZones: map[string]string{\n\t\t\t\"rherror\": \"error.com\",\n\t\t},\n\t\tRecords: map[string]map[string]dns.RecordResponse{\n\t\t\t\"rherror\": {\"foo.error.com\": {Type: \"A\"}},\n\t\t},\n\t}\n\tfailingProvider := &CloudFlareProvider{\n\t\tClient:                 client,\n\t\tRegionalServicesConfig: RegionalServicesConfig{Enabled: true},\n\t}\n\t_, err := failingProvider.Records(t.Context())\n\tassert.Error(t, err, \"listing regional hostnames should fail\")\n}\n\nfunc TestApplyChangesWithRegionalHostnamesFaillures(t *testing.T) {\n\tt.Parallel()\n\ttype fields struct {\n\t\tRecords           map[string]dns.RecordResponse\n\t\tRegionalHostnames []regionalHostname\n\t\tRegionKey         string\n\t}\n\ttype args struct {\n\t\tchanges *plan.Changes\n\t}\n\ttests := []struct {\n\t\tname        string\n\t\tfields      fields\n\t\targs        args\n\t\terrMsg      string\n\t\texpectDebug string\n\t}{\n\t\t{\n\t\t\tname: \"list zone fails\",\n\t\t\targs: args{\n\t\t\t\tchanges: &plan.Changes{\n\t\t\t\t\tCreate: []*endpoint.Endpoint{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tRecordType: \"A\",\n\t\t\t\t\t\t\tDNSName:    \"foo.error.com\",\n\t\t\t\t\t\t\tTargets:    endpoint.Targets{\"127.0.0.1\"},\n\t\t\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t\t\t{Name: \"external-dns.alpha.kubernetes.io/cloudflare-region-key\", Value: \"eu\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\terrMsg: \"failed to list regional hostnames\",\n\t\t},\n\t\t{\n\t\t\tname: \"create fails\",\n\t\t\tfields: fields{\n\t\t\t\tRecords:           map[string]dns.RecordResponse{},\n\t\t\t\tRegionalHostnames: []regionalHostname{},\n\t\t\t\tRegionKey:         \"us\",\n\t\t\t},\n\t\t\targs: args{\n\t\t\t\tchanges: &plan.Changes{\n\t\t\t\t\tCreate: []*endpoint.Endpoint{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tRecordType: \"A\",\n\t\t\t\t\t\t\tDNSName:    \"rherror.bar.com\",\n\t\t\t\t\t\t\tTargets:    endpoint.Targets{\"127.0.0.1\"},\n\t\t\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t\t\t{Name: \"external-dns.alpha.kubernetes.io/cloudflare-region-key\", Value: \"eu\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectDebug: \"failed to create regional hostname\",\n\t\t},\n\t\t{\n\t\t\tname: \"update fails\",\n\t\t\tfields: fields{\n\t\t\t\tRecords: map[string]dns.RecordResponse{\n\t\t\t\t\t\"rherror.bar.com\": {\n\t\t\t\t\t\tID:      \"123\",\n\t\t\t\t\t\tType:    \"A\",\n\t\t\t\t\t\tName:    \"rherror.bar.com\",\n\t\t\t\t\t\tContent: \"127.0.0.1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tRegionalHostnames: []regionalHostname{\n\t\t\t\t\t{hostname: \"rherror.bar.com\", regionKey: \"us\"},\n\t\t\t\t},\n\t\t\t\tRegionKey: \"us\",\n\t\t\t},\n\t\t\targs: args{\n\t\t\t\tchanges: &plan.Changes{\n\t\t\t\t\tUpdateOld: []*endpoint.Endpoint{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tRecordType: \"A\",\n\t\t\t\t\t\t\tDNSName:    \"rherror.bar.com\",\n\t\t\t\t\t\t\tTargets:    endpoint.Targets{\"127.0.0.1\"},\n\t\t\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t\t\t{Name: \"external-dns.alpha.kubernetes.io/cloudflare-region-key\", Value: \"eu\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tRecordType: \"A\",\n\t\t\t\t\t\t\tDNSName:    \"rherror.bar.com\",\n\t\t\t\t\t\t\tTargets:    endpoint.Targets{\"127.0.0.2\"},\n\t\t\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t\t\t{Name: \"external-dns.alpha.kubernetes.io/cloudflare-region-key\", Value: \"eu\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectDebug: \"failed to update regional hostname\",\n\t\t},\n\t\t{\n\t\t\tname: \"delete fails\",\n\t\t\tfields: fields{\n\t\t\t\tRecords: map[string]dns.RecordResponse{\n\t\t\t\t\t\"rherror.bar.com\": {\n\t\t\t\t\t\tID:      \"123\",\n\t\t\t\t\t\tType:    \"A\",\n\t\t\t\t\t\tName:    \"newerror.bar.com\",\n\t\t\t\t\t\tContent: \"127.0.0.1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tRegionalHostnames: []regionalHostname{\n\t\t\t\t\t{hostname: \"rherror.bar.com\", regionKey: \"us\"},\n\t\t\t\t},\n\t\t\t\tRegionKey: \"us\",\n\t\t\t},\n\t\t\targs: args{\n\t\t\t\tchanges: &plan.Changes{\n\t\t\t\t\tDelete: []*endpoint.Endpoint{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tRecordType: \"A\",\n\t\t\t\t\t\t\tDNSName:    \"rherror.bar.com\",\n\t\t\t\t\t\t\tTargets:    endpoint.Targets{\"127.0.0.1\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectDebug: \"failed to delete regional hostname\",\n\t\t},\n\t\t{\n\t\t\t// This should not happen in practice, but we test it to ensure we return an error.\n\t\t\tname: \"conflicting regional keys\",\n\t\t\tfields: fields{\n\t\t\t\tRecords:           map[string]dns.RecordResponse{},\n\t\t\t\tRegionalHostnames: []regionalHostname{},\n\t\t\t\tRegionKey:         \"us\",\n\t\t\t},\n\t\t\targs: args{\n\t\t\t\tchanges: &plan.Changes{\n\t\t\t\t\tCreate: []*endpoint.Endpoint{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tRecordType: \"A\",\n\t\t\t\t\t\t\tDNSName:    \"foo.bar.com\",\n\t\t\t\t\t\t\tTargets:    endpoint.Targets{\"127.0.0.1\"},\n\t\t\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t\t\t{Name: \"external-dns.alpha.kubernetes.io/cloudflare-region-key\", Value: \"eu\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tRecordType: \"A\",\n\t\t\t\t\t\t\tDNSName:    \"foo.bar.com\",\n\t\t\t\t\t\t\tTargets:    endpoint.Targets{\"127.0.0.1\"},\n\t\t\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t\t\t{Name: \"external-dns.alpha.kubernetes.io/cloudflare-region-key\", Value: \"us\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\terrMsg: \"conflicting region keys\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tt.Parallel()\n\t\t\trecords := tt.fields.Records\n\t\t\tif records == nil {\n\t\t\t\trecords = map[string]dns.RecordResponse{}\n\t\t\t}\n\t\t\tp := &CloudFlareProvider{\n\t\t\t\tClient: &mockCloudFlareClient{\n\t\t\t\t\tZones: map[string]string{\n\t\t\t\t\t\t\"001\":     \"bar.com\",\n\t\t\t\t\t\t\"rherror\": \"error.com\",\n\t\t\t\t\t},\n\t\t\t\t\tRecords: map[string]map[string]dns.RecordResponse{\n\t\t\t\t\t\t\"001\": records,\n\t\t\t\t\t},\n\t\t\t\t\tregionalHostnames: map[string][]regionalHostname{\n\t\t\t\t\t\t\"001\": tt.fields.RegionalHostnames,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tRegionalServicesConfig: RegionalServicesConfig{\n\t\t\t\t\tEnabled:   true,\n\t\t\t\t\tRegionKey: tt.fields.RegionKey,\n\t\t\t\t},\n\t\t\t}\n\t\t\thook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t)\n\t\t\terr := p.ApplyChanges(t.Context(), tt.args.changes)\n\t\t\tassert.Error(t, err, \"ApplyChanges should return an error\")\n\t\t\tif tt.errMsg != \"\" && err != nil {\n\t\t\t\tassert.Contains(t, err.Error(), tt.errMsg, \"Expected error message to contain: %s\", tt.errMsg)\n\t\t\t}\n\t\t\tif tt.expectDebug != \"\" {\n\t\t\t\tlogtest.TestHelperLogContains(tt.expectDebug, hook, t)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestApplyChangesWithRegionalHostnamesDryRun(t *testing.T) {\n\tt.Parallel()\n\ttype fields struct {\n\t\tRecords           map[string]dns.RecordResponse\n\t\tRegionalHostnames []regionalHostname\n\t\tRegionKey         string\n\t}\n\ttype args struct {\n\t\tchanges *plan.Changes\n\t}\n\ttests := []struct {\n\t\tname        string\n\t\tfields      fields\n\t\targs        args\n\t\texpectDebug string\n\t}{\n\t\t{\n\t\t\tname: \"create dry run\",\n\t\t\tfields: fields{\n\t\t\t\tRecords:           map[string]dns.RecordResponse{},\n\t\t\t\tRegionalHostnames: []regionalHostname{},\n\t\t\t\tRegionKey:         \"us\",\n\t\t\t},\n\t\t\targs: args{\n\t\t\t\tchanges: &plan.Changes{\n\t\t\t\t\tCreate: []*endpoint.Endpoint{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tRecordType: \"A\",\n\t\t\t\t\t\t\tDNSName:    \"foo.bar.com\",\n\t\t\t\t\t\t\tTargets:    endpoint.Targets{\"127.0.0.1\"},\n\t\t\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t\t\t{Name: \"external-dns.alpha.kubernetes.io/cloudflare-region-key\", Value: \"eu\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectDebug: \"Dry run: skipping regional hostname change\",\n\t\t},\n\t\t{\n\t\t\tname: \"update fails\",\n\t\t\tfields: fields{\n\t\t\t\tRecords: map[string]dns.RecordResponse{\n\t\t\t\t\t\"foo.bar.com\": {\n\t\t\t\t\t\tID:      \"123\",\n\t\t\t\t\t\tType:    \"A\",\n\t\t\t\t\t\tName:    \"foo.bar.com\",\n\t\t\t\t\t\tContent: \"127.0.0.1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tRegionalHostnames: []regionalHostname{\n\t\t\t\t\t{hostname: \"foo.bar.com\", regionKey: \"us\"},\n\t\t\t\t},\n\t\t\t\tRegionKey: \"us\",\n\t\t\t},\n\t\t\targs: args{\n\t\t\t\tchanges: &plan.Changes{\n\t\t\t\t\tUpdateOld: []*endpoint.Endpoint{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tRecordType: \"A\",\n\t\t\t\t\t\t\tDNSName:    \"foo.bar.com\",\n\t\t\t\t\t\t\tTargets:    endpoint.Targets{\"127.0.0.1\"},\n\t\t\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t\t\t{Name: \"external-dns.alpha.kubernetes.io/cloudflare-region-key\", Value: \"eu\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tRecordType: \"A\",\n\t\t\t\t\t\t\tDNSName:    \"foo.bar.com\",\n\t\t\t\t\t\t\tTargets:    endpoint.Targets{\"127.0.0.2\"},\n\t\t\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t\t\t{Name: \"external-dns.alpha.kubernetes.io/cloudflare-region-key\", Value: \"eu\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectDebug: \"Dry run: skipping regional hostname change\",\n\t\t},\n\t\t{\n\t\t\tname: \"delete fails\",\n\t\t\tfields: fields{\n\t\t\t\tRecords: map[string]dns.RecordResponse{\n\t\t\t\t\t\"foo.bar.com\": {\n\t\t\t\t\t\tID:      \"123\",\n\t\t\t\t\t\tType:    \"A\",\n\t\t\t\t\t\tName:    \"foo.bar.com\",\n\t\t\t\t\t\tContent: \"127.0.0.1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tRegionalHostnames: []regionalHostname{\n\t\t\t\t\t{hostname: \"foo.bar.com\", regionKey: \"us\"},\n\t\t\t\t},\n\t\t\t\tRegionKey: \"us\",\n\t\t\t},\n\t\t\targs: args{\n\t\t\t\tchanges: &plan.Changes{\n\t\t\t\t\tDelete: []*endpoint.Endpoint{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tRecordType: \"A\",\n\t\t\t\t\t\t\tDNSName:    \"foo.bar.com\",\n\t\t\t\t\t\t\tTargets:    endpoint.Targets{\"127.0.0.1\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectDebug: \"Dry run: skipping regional hostname change\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tt.Parallel()\n\t\t\trecords := tt.fields.Records\n\t\t\tif records == nil {\n\t\t\t\trecords = map[string]dns.RecordResponse{}\n\t\t\t}\n\t\t\tp := &CloudFlareProvider{\n\t\t\t\tDryRun: true,\n\t\t\t\tClient: &mockCloudFlareClient{\n\t\t\t\t\tZones: map[string]string{\n\t\t\t\t\t\t\"001\": \"bar.com\",\n\t\t\t\t\t},\n\t\t\t\t\tRecords: map[string]map[string]dns.RecordResponse{\n\t\t\t\t\t\t\"001\": records,\n\t\t\t\t\t},\n\t\t\t\t\tregionalHostnames: map[string][]regionalHostname{\n\t\t\t\t\t\t\"001\": tt.fields.RegionalHostnames,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tRegionalServicesConfig: RegionalServicesConfig{\n\t\t\t\t\tEnabled:   true,\n\t\t\t\t\tRegionKey: tt.fields.RegionKey,\n\t\t\t\t},\n\t\t\t}\n\t\t\thook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t)\n\t\t\terr := p.ApplyChanges(t.Context(), tt.args.changes)\n\t\t\tassert.NoError(t, err, \"ApplyChanges should not fail\")\n\t\t\tif tt.expectDebug != \"\" {\n\t\t\t\tlogtest.TestHelperLogContains(tt.expectDebug, hook, t)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCloudflareAdjustEndpointsRegionalServices(t *testing.T) {\n\ttestCases := []struct {\n\t\tname                   string\n\t\trecordType             string\n\t\tregionalServicesConfig RegionalServicesConfig\n\t\tinitialRegionKey       string  // existing region key on endpoint\n\t\texpectedRegionKey      *string // expected region key after AdjustEndpoints (nil = should not be present)\n\t}{\n\t\t// Supported types should get region key when enabled\n\t\t{\n\t\t\tname:                   \"A record with regional services enabled\",\n\t\t\trecordType:             \"A\",\n\t\t\tregionalServicesConfig: RegionalServicesConfig{Enabled: true, RegionKey: \"us\"},\n\t\t\tinitialRegionKey:       \"\",\n\t\t\texpectedRegionKey:      testutils.ToPtr(\"us\"),\n\t\t},\n\t\t{\n\t\t\tname:                   \"AAAA record with regional services enabled\",\n\t\t\trecordType:             \"AAAA\",\n\t\t\tregionalServicesConfig: RegionalServicesConfig{Enabled: true, RegionKey: \"us\"},\n\t\t\tinitialRegionKey:       \"\",\n\t\t\texpectedRegionKey:      testutils.ToPtr(\"us\"),\n\t\t},\n\t\t{\n\t\t\tname:                   \"CNAME record with regional services enabled\",\n\t\t\trecordType:             \"CNAME\",\n\t\t\tregionalServicesConfig: RegionalServicesConfig{Enabled: true, RegionKey: \"us\"},\n\t\t\tinitialRegionKey:       \"\",\n\t\t\texpectedRegionKey:      testutils.ToPtr(\"us\"),\n\t\t},\n\n\t\t// Unsupported types should NOT get region key even when enabled\n\t\t{\n\t\t\tname:                   \"TXT record with regional services enabled\",\n\t\t\trecordType:             \"TXT\",\n\t\t\tregionalServicesConfig: RegionalServicesConfig{Enabled: true, RegionKey: \"us\"},\n\t\t\tinitialRegionKey:       \"\",\n\t\t\texpectedRegionKey:      nil,\n\t\t},\n\n\t\t// Disabled regional services should remove region key for all types\n\t\t{\n\t\t\tname:                   \"A record with regional services disabled\",\n\t\t\trecordType:             \"A\",\n\t\t\tregionalServicesConfig: RegionalServicesConfig{Enabled: false},\n\t\t\tinitialRegionKey:       \"existing-region\",\n\t\t\texpectedRegionKey:      nil,\n\t\t},\n\t\t{\n\t\t\tname:                   \"TXT record with regional services disabled\",\n\t\t\trecordType:             \"TXT\",\n\t\t\tregionalServicesConfig: RegionalServicesConfig{Enabled: false},\n\t\t\tinitialRegionKey:       \"existing-region\",\n\t\t\texpectedRegionKey:      nil,\n\t\t},\n\n\t\t// Existing region key should be preserved when already set\n\t\t{\n\t\t\tname:                   \"A record with existing custom region key\",\n\t\t\trecordType:             \"A\",\n\t\t\tregionalServicesConfig: RegionalServicesConfig{Enabled: true, RegionKey: \"us\"},\n\t\t\tinitialRegionKey:       \"eu\",\n\t\t\texpectedRegionKey:      testutils.ToPtr(\"eu\"),\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\t// Create endpoint with initial region key if specified\n\t\t\ttestEndpoint := &endpoint.Endpoint{\n\t\t\t\tRecordType: tc.recordType,\n\t\t\t\tDNSName:    \"test.bar.com\",\n\t\t\t\tTargets:    endpoint.Targets{\"127.0.0.1\"},\n\t\t\t}\n\n\t\t\tif tc.initialRegionKey != \"\" {\n\t\t\t\ttestEndpoint.ProviderSpecific = endpoint.ProviderSpecific{\n\t\t\t\t\tendpoint.ProviderSpecificProperty{\n\t\t\t\t\t\tName:  annotations.CloudflareRegionKey,\n\t\t\t\t\t\tValue: tc.initialRegionKey,\n\t\t\t\t\t},\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tprovider := &CloudFlareProvider{\n\t\t\t\tRegionalServicesConfig: tc.regionalServicesConfig,\n\t\t\t}\n\n\t\t\tadjustedEndpoints, err := provider.AdjustEndpoints([]*endpoint.Endpoint{testEndpoint})\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.Len(t, adjustedEndpoints, 1)\n\n\t\t\tregionKey, exists := adjustedEndpoints[0].GetProviderSpecificProperty(annotations.CloudflareRegionKey)\n\n\t\t\tif tc.expectedRegionKey != nil {\n\t\t\t\t// Region key should be present with expected value\n\t\t\t\tassert.True(t, exists, \"Region key should be present\")\n\t\t\t\tassert.Equal(t, *tc.expectedRegionKey, regionKey, \"Region key value should match expected\")\n\t\t\t} else {\n\t\t\t\t// Region key should not be present\n\t\t\t\tassert.False(t, exists, \"Region key should not be present\")\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestSubmitChanges_DryRun_RegionalErrors covers error paths in the dry-run branch of\n// submitChanges. Two statements in that branch are not covered by any test:\n//\n//   - the `failedChange = true` body inside `if !p.submitRegionalHostnameChanges(...)`\n//   - the subsequent `failedZones = append(...)` when failedChange is true\n//\n// Both are unreachable because submitRegionalHostnameChange unconditionally returns true\n// when DryRun=true (it logs and returns before making any API call), so the failure\n// branch can never be entered without changing the production code.\nfunc TestSubmitChanges_DryRun_RegionalErrors(t *testing.T) {\n\tt.Run(\"desiredRegionalHostnames conflict returns error\", func(t *testing.T) {\n\t\t// Two changes for the same hostname with different region keys →\n\t\t// desiredRegionalHostnames returns a conflict error.\n\t\tclient := NewMockCloudFlareClient()\n\t\tp := &CloudFlareProvider{\n\t\t\tClient: client,\n\t\t\tDryRun: true,\n\t\t\tRegionalServicesConfig: RegionalServicesConfig{\n\t\t\t\tEnabled: true,\n\t\t\t},\n\t\t}\n\n\t\t// Build conflicting cloudFlareChanges directly and call submitChanges,\n\t\t// which is in the same package.\n\t\tchanges := []*cloudFlareChange{\n\t\t\t{\n\t\t\t\tAction:         cloudFlareCreate,\n\t\t\t\tResourceRecord: dns.RecordResponse{Name: \"foo.bar.com\", Type: \"A\", Content: \"1.2.3.4\"},\n\t\t\t\tRegionalHostname: regionalHostname{\n\t\t\t\t\thostname:  \"foo.bar.com\",\n\t\t\t\t\tregionKey: \"us\",\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tAction:         cloudFlareUpdate,\n\t\t\t\tResourceRecord: dns.RecordResponse{Name: \"foo.bar.com\", Type: \"A\", Content: \"1.2.3.4\"},\n\t\t\t\tRegionalHostname: regionalHostname{\n\t\t\t\t\thostname:  \"foo.bar.com\",\n\t\t\t\t\tregionKey: \"eu\", // different from \"us\" → conflict\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\terr := p.submitChanges(t.Context(), changes)\n\t\trequire.Error(t, err)\n\t\tassert.Contains(t, err.Error(), \"failed to build desired regional hostnames\")\n\t})\n\n\tt.Run(\"listDataLocalisationRegionalHostnames error in dry-run returns error\", func(t *testing.T) {\n\t\t// Zone ID containing \"rherror\" causes the mock to return an error from\n\t\t// ListDataLocalizationRegionalHostnames.\n\t\tclient := &mockCloudFlareClient{\n\t\t\tZones: map[string]string{\n\t\t\t\t\"rherror-zone1\": \"rherror.bar.com\",\n\t\t\t},\n\t\t\tRecords: map[string]map[string]dns.RecordResponse{\n\t\t\t\t\"rherror-zone1\": {},\n\t\t\t},\n\t\t\tcustomHostnames:   map[string][]customHostname{},\n\t\t\tregionalHostnames: map[string][]regionalHostname{},\n\t\t}\n\t\tp := &CloudFlareProvider{\n\t\t\tClient: client,\n\t\t\tDryRun: true,\n\t\t\tRegionalServicesConfig: RegionalServicesConfig{\n\t\t\t\tEnabled:   true,\n\t\t\t\tRegionKey: \"us\",\n\t\t\t},\n\t\t\tdomainFilter: endpoint.NewDomainFilter([]string{\"rherror.bar.com\"}),\n\t\t}\n\n\t\tchanges := []*cloudFlareChange{\n\t\t\t{\n\t\t\t\tAction:         cloudFlareCreate,\n\t\t\t\tResourceRecord: dns.RecordResponse{Name: \"foo.rherror.bar.com\", Type: \"A\", Content: \"1.2.3.4\"},\n\t\t\t\tRegionalHostname: regionalHostname{\n\t\t\t\t\thostname:  \"foo.rherror.bar.com\",\n\t\t\t\t\tregionKey: \"us\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\terr := p.submitChanges(t.Context(), changes)\n\t\trequire.Error(t, err)\n\t\tassert.Contains(t, err.Error(), \"could not fetch regional hostnames from zone\")\n\t})\n}\n"
  },
  {
    "path": "provider/cloudflare/cloudflare_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage cloudflare\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"slices\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/cloudflare/cloudflare-go/v5\"\n\t\"github.com/cloudflare/cloudflare-go/v5/dns\"\n\t\"github.com/cloudflare/cloudflare-go/v5/option\"\n\t\"github.com/cloudflare/cloudflare-go/v5/zones\"\n\t\"github.com/maxatome/go-testdeep/td\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/internal/testutils\"\n\tlogtest \"sigs.k8s.io/external-dns/internal/testutils/log\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n)\n\n// newCloudflareError creates a cloudflare.Error suitable for testing.\n// The v5 SDK's Error type panics when .Error() is called with nil Request/Response fields,\n// so this helper initializes them properly.\nfunc newCloudflareError(statusCode int) *cloudflare.Error {\n\treq := httptest.NewRequest(http.MethodGet, \"https://api.cloudflare.com/client/v4/zones\", nil)\n\tresp := &http.Response{\n\t\tStatusCode: statusCode,\n\t\tStatus:     http.StatusText(statusCode),\n\t\tRequest:    req,\n\t}\n\treturn &cloudflare.Error{\n\t\tStatusCode: statusCode,\n\t\tRequest:    req,\n\t\tResponse:   resp,\n\t}\n}\n\nvar ExampleDomain = []dns.RecordResponse{\n\t{\n\t\tID:      \"1234567890\",\n\t\tName:    \"foobar.bar.com\",\n\t\tType:    endpoint.RecordTypeA,\n\t\tTTL:     120,\n\t\tContent: \"1.2.3.4\",\n\t\tProxied: false,\n\t\tComment: \"valid comment\",\n\t},\n\t{\n\t\tID:      \"2345678901\",\n\t\tName:    \"foobar.bar.com\",\n\t\tType:    endpoint.RecordTypeA,\n\t\tTTL:     120,\n\t\tContent: \"3.4.5.6\",\n\t\tProxied: false,\n\t},\n\t{\n\t\tID:      \"1231231233\",\n\t\tName:    \"bar.foo.com\",\n\t\tType:    endpoint.RecordTypeA,\n\t\tTTL:     1,\n\t\tContent: \"2.3.4.5\",\n\t\tProxied: false,\n\t},\n}\n\ntype MockAction struct {\n\tName             string\n\tZoneId           string\n\tRecordId         string\n\tRecordData       dns.RecordResponse\n\tRegionalHostname regionalHostname\n}\n\ntype mockCloudFlareClient struct {\n\tZones                map[string]string\n\tRecords              map[string]map[string]dns.RecordResponse\n\tActions              []MockAction\n\tBatchDNSRecordsCalls int\n\tlistZonesError       error // For v4 ListZones\n\tgetZoneError         error // For v4 GetZone\n\tdnsRecordsError      error\n\tcustomHostnames      map[string][]customHostname\n\tregionalHostnames    map[string][]regionalHostname\n\tdnsRecordsListParams dns.RecordListParams\n}\n\nfunc NewMockCloudFlareClient() *mockCloudFlareClient {\n\treturn &mockCloudFlareClient{\n\t\tZones: map[string]string{\n\t\t\t\"001\": \"bar.com\",\n\t\t\t\"002\": \"foo.com\",\n\t\t},\n\t\tRecords: map[string]map[string]dns.RecordResponse{\n\t\t\t\"001\": {},\n\t\t\t\"002\": {},\n\t\t},\n\t\tcustomHostnames:   map[string][]customHostname{},\n\t\tregionalHostnames: map[string][]regionalHostname{},\n\t}\n}\n\nfunc NewMockCloudFlareClientWithRecords(records map[string][]dns.RecordResponse) *mockCloudFlareClient {\n\tm := NewMockCloudFlareClient()\n\n\tfor zoneID, zoneRecords := range records {\n\t\tif zone, ok := m.Records[zoneID]; ok {\n\t\t\tfor _, record := range zoneRecords {\n\t\t\t\tzone[record.ID] = record\n\t\t\t}\n\t\t}\n\t}\n\n\treturn m\n}\n\nfunc (m *mockCloudFlareClient) CreateDNSRecord(_ context.Context, params dns.RecordNewParams) (*dns.RecordResponse, error) {\n\tbody := params.Body.(dns.RecordNewParamsBody)\n\n\trecord := dns.RecordResponse{\n\t\tID:       generateDNSRecordID(body.Type.String(), body.Name.Value, body.Content.Value),\n\t\tName:     body.Name.Value,\n\t\tTTL:      dns.TTL(body.TTL.Value),\n\t\tProxied:  body.Proxied.Value,\n\t\tType:     dns.RecordResponseType(body.Type.String()),\n\t\tContent:  body.Content.Value,\n\t\tPriority: body.Priority.Value,\n\t}\n\n\tm.Actions = append(m.Actions, MockAction{\n\t\tName:       \"Create\",\n\t\tZoneId:     params.ZoneID.Value,\n\t\tRecordId:   record.ID,\n\t\tRecordData: record,\n\t})\n\tif zone, ok := m.Records[params.ZoneID.Value]; ok {\n\t\tzone[record.ID] = record\n\t}\n\n\tif record.Name == \"newerror.bar.com\" {\n\t\treturn nil, fmt.Errorf(\"failed to create record\")\n\t}\n\treturn &record, nil\n}\n\nfunc (m *mockCloudFlareClient) ListDNSRecords(ctx context.Context, params dns.RecordListParams) autoPager[dns.RecordResponse] {\n\tm.dnsRecordsListParams = params\n\tif m.dnsRecordsError != nil {\n\t\treturn &mockAutoPager[dns.RecordResponse]{err: m.dnsRecordsError}\n\t}\n\titer := &mockAutoPager[dns.RecordResponse]{}\n\tif zone, ok := m.Records[params.ZoneID.Value]; ok {\n\t\tfor _, record := range zone {\n\t\t\tif strings.HasPrefix(record.Name, \"newerror-list-\") {\n\t\t\t\tm.DeleteDNSRecord(ctx, record.ID, dns.RecordDeleteParams{ZoneID: params.ZoneID})\n\t\t\t\titer.err = errors.New(\"failed to list erroring DNS record\")\n\t\t\t\treturn iter\n\t\t\t}\n\t\t\titer.items = append(iter.items, record)\n\t\t}\n\t}\n\treturn iter\n}\n\nfunc (m *mockCloudFlareClient) UpdateDNSRecord(_ context.Context, recordID string, params dns.RecordUpdateParams) (*dns.RecordResponse, error) {\n\tzoneID := params.ZoneID.String()\n\tbody := params.Body.(dns.RecordUpdateParamsBody)\n\n\trecord := dns.RecordResponse{\n\t\tID:       recordID,\n\t\tName:     body.Name.Value,\n\t\tTTL:      dns.TTL(body.TTL.Value),\n\t\tProxied:  body.Proxied.Value,\n\t\tType:     dns.RecordResponseType(body.Type.String()),\n\t\tContent:  body.Content.Value,\n\t\tPriority: body.Priority.Value,\n\t}\n\n\tm.Actions = append(m.Actions, MockAction{\n\t\tName:       \"Update\",\n\t\tZoneId:     zoneID,\n\t\tRecordId:   recordID,\n\t\tRecordData: record,\n\t})\n\tif zone, ok := m.Records[zoneID]; ok {\n\t\tif _, ok := zone[recordID]; ok {\n\t\t\tif strings.HasPrefix(record.Name, \"newerror-update-\") {\n\t\t\t\treturn nil, errors.New(\"failed to update erroring DNS record\")\n\t\t\t}\n\t\t\tzone[recordID] = record\n\t\t}\n\t}\n\treturn &record, nil\n}\n\nfunc (m *mockCloudFlareClient) DeleteDNSRecord(_ context.Context, recordID string, params dns.RecordDeleteParams) error {\n\tzoneID := params.ZoneID.String()\n\tm.Actions = append(m.Actions, MockAction{\n\t\tName:     \"Delete\",\n\t\tZoneId:   zoneID,\n\t\tRecordId: recordID,\n\t})\n\tif zone, ok := m.Records[zoneID]; ok {\n\t\tif _, ok := zone[recordID]; ok {\n\t\t\tname := zone[recordID].Name\n\t\t\tdelete(zone, recordID)\n\t\t\tif strings.HasPrefix(name, \"newerror-delete-\") {\n\t\t\t\treturn errors.New(\"failed to delete erroring DNS record\")\n\t\t\t}\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (m *mockCloudFlareClient) ZoneIDByName(zoneName string) (string, error) {\n\t// Simulate iterator error (line 144)\n\tif m.listZonesError != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to list zones from CloudFlare API: %w\", m.listZonesError)\n\t}\n\n\tfor id, name := range m.Zones {\n\t\tif name == zoneName {\n\t\t\treturn id, nil\n\t\t}\n\t}\n\n\t// Use the improved error message (line 147)\n\treturn \"\", fmt.Errorf(\"zone %q not found in CloudFlare account - verify the zone exists and API credentials have access to it\", zoneName)\n}\n\nfunc (m *mockCloudFlareClient) ListZones(_ context.Context, _ zones.ZoneListParams) autoPager[zones.Zone] {\n\tif m.listZonesError != nil {\n\t\treturn &mockAutoPager[zones.Zone]{\n\t\t\terr: m.listZonesError,\n\t\t}\n\t}\n\n\tvar results []zones.Zone\n\n\tfor id, zoneName := range m.Zones {\n\t\tresults = append(results, zones.Zone{\n\t\t\tID:   id,\n\t\t\tName: zoneName,\n\t\t\tPlan: zones.ZonePlan{IsSubscribed: strings.HasSuffix(zoneName, \"bar.com\")}, // nolint:SA1019 // Plan.IsSubscribed is deprecated but no replacement available yet\n\t\t})\n\t}\n\n\treturn &mockAutoPager[zones.Zone]{\n\t\titems: results,\n\t}\n}\n\nfunc (m *mockCloudFlareClient) GetZone(_ context.Context, zoneID string) (*zones.Zone, error) {\n\tif m.getZoneError != nil {\n\t\treturn nil, m.getZoneError\n\t}\n\n\tfor id, zoneName := range m.Zones {\n\t\tif zoneID == id {\n\t\t\treturn &zones.Zone{\n\t\t\t\tID:   zoneID,\n\t\t\t\tName: zoneName,\n\t\t\t\tPlan: zones.ZonePlan{IsSubscribed: strings.HasSuffix(zoneName, \"bar.com\")}, // nolint:SA1019 // Plan.IsSubscribed is deprecated but no replacement available yet\n\t\t\t}, nil\n\t\t}\n\t}\n\n\treturn nil, errors.New(\"Unknown zoneID: \" + zoneID)\n}\n\nfunc AssertActions(t *testing.T, provider *CloudFlareProvider, endpoints []*endpoint.Endpoint, actions []MockAction, managedRecords []string, args ...any) {\n\tt.Helper()\n\n\tvar client *mockCloudFlareClient\n\n\tif provider.Client == nil {\n\t\tclient = NewMockCloudFlareClient()\n\t\tprovider.Client = client\n\t} else {\n\t\tclient = provider.Client.(*mockCloudFlareClient)\n\t}\n\n\tctx := t.Context()\n\n\trecords, err := provider.Records(ctx)\n\tif err != nil {\n\t\tt.Fatalf(\"cannot fetch records, %s\", err)\n\t}\n\n\tendpoints, err = provider.AdjustEndpoints(endpoints)\n\tassert.NoError(t, err)\n\tdomainFilter := endpoint.NewDomainFilter([]string{\"bar.com\"})\n\tplan := &plan.Plan{\n\t\tCurrent:        records,\n\t\tDesired:        endpoints,\n\t\tDomainFilter:   endpoint.MatchAllDomainFilters{domainFilter},\n\t\tManagedRecords: managedRecords,\n\t}\n\n\tchanges := plan.Calculate().Changes\n\n\t// Records other than A, CNAME and NS are not supported by planner, just create them\n\tfor _, endpoint := range endpoints {\n\t\tif !slices.Contains(managedRecords, endpoint.RecordType) {\n\t\t\tchanges.Create = append(changes.Create, endpoint)\n\t\t}\n\t}\n\n\terr = provider.ApplyChanges(t.Context(), changes)\n\tif err != nil {\n\t\tt.Fatalf(\"cannot apply changes, %s\", err)\n\t}\n\n\ttd.Cmp(t, client.Actions, actions, args...)\n}\n\nfunc TestCloudflareA(t *testing.T) {\n\tendpoints := []*endpoint.Endpoint{\n\t\t{\n\t\t\tRecordType: \"A\",\n\t\t\tDNSName:    \"bar.com\",\n\t\t\tTargets:    endpoint.Targets{\"127.0.0.1\", \"127.0.0.2\"},\n\t\t},\n\t}\n\n\tAssertActions(t, &CloudFlareProvider{}, endpoints, []MockAction{\n\t\t{\n\t\t\tName:     \"Create\",\n\t\t\tZoneId:   \"001\",\n\t\t\tRecordId: generateDNSRecordID(\"A\", \"bar.com\", \"127.0.0.1\"),\n\t\t\tRecordData: dns.RecordResponse{\n\t\t\t\tID:      generateDNSRecordID(\"A\", \"bar.com\", \"127.0.0.1\"),\n\t\t\t\tType:    \"A\",\n\t\t\t\tName:    \"bar.com\",\n\t\t\t\tContent: \"127.0.0.1\",\n\t\t\t\tTTL:     1,\n\t\t\t\tProxied: false,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:     \"Create\",\n\t\t\tZoneId:   \"001\",\n\t\t\tRecordId: generateDNSRecordID(\"A\", \"bar.com\", \"127.0.0.2\"),\n\t\t\tRecordData: dns.RecordResponse{\n\t\t\t\tID:      generateDNSRecordID(\"A\", \"bar.com\", \"127.0.0.2\"),\n\t\t\t\tType:    \"A\",\n\t\t\t\tName:    \"bar.com\",\n\t\t\t\tContent: \"127.0.0.2\",\n\t\t\t\tTTL:     1,\n\t\t\t\tProxied: false,\n\t\t\t},\n\t\t},\n\t},\n\t\t[]string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},\n\t)\n}\n\nfunc TestCloudflareCname(t *testing.T) {\n\tendpoints := []*endpoint.Endpoint{\n\t\t{\n\t\t\tRecordType: \"CNAME\",\n\t\t\tDNSName:    \"cname.bar.com\",\n\t\t\tTargets:    endpoint.Targets{\"google.com\", \"facebook.com\"},\n\t\t},\n\t}\n\n\tAssertActions(t, &CloudFlareProvider{}, endpoints, []MockAction{\n\t\t{\n\t\t\tName:     \"Create\",\n\t\t\tZoneId:   \"001\",\n\t\t\tRecordId: generateDNSRecordID(\"CNAME\", \"cname.bar.com\", \"google.com\"),\n\t\t\tRecordData: dns.RecordResponse{\n\t\t\t\tID:      generateDNSRecordID(\"CNAME\", \"cname.bar.com\", \"google.com\"),\n\t\t\t\tType:    \"CNAME\",\n\t\t\t\tName:    \"cname.bar.com\",\n\t\t\t\tContent: \"google.com\",\n\t\t\t\tTTL:     1,\n\t\t\t\tProxied: false,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:     \"Create\",\n\t\t\tZoneId:   \"001\",\n\t\t\tRecordId: generateDNSRecordID(\"CNAME\", \"cname.bar.com\", \"facebook.com\"),\n\t\t\tRecordData: dns.RecordResponse{\n\t\t\t\tID:      generateDNSRecordID(\"CNAME\", \"cname.bar.com\", \"facebook.com\"),\n\t\t\t\tType:    \"CNAME\",\n\t\t\t\tName:    \"cname.bar.com\",\n\t\t\t\tContent: \"facebook.com\",\n\t\t\t\tTTL:     1,\n\t\t\t\tProxied: false,\n\t\t\t},\n\t\t},\n\t},\n\t\t[]string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},\n\t)\n}\n\nfunc TestCloudflareMx(t *testing.T) {\n\tendpoints := []*endpoint.Endpoint{\n\t\t{\n\t\t\tRecordType: \"MX\",\n\t\t\tDNSName:    \"mx.bar.com\",\n\t\t\tTargets:    endpoint.Targets{\"10 google.com\", \"20 facebook.com\"},\n\t\t},\n\t}\n\n\tAssertActions(t, &CloudFlareProvider{}, endpoints, []MockAction{\n\t\t{\n\t\t\tName:     \"Create\",\n\t\t\tZoneId:   \"001\",\n\t\t\tRecordId: generateDNSRecordID(\"MX\", \"mx.bar.com\", \"google.com\"),\n\t\t\tRecordData: dns.RecordResponse{\n\t\t\t\tID:       generateDNSRecordID(\"MX\", \"mx.bar.com\", \"google.com\"),\n\t\t\t\tType:     \"MX\",\n\t\t\t\tName:     \"mx.bar.com\",\n\t\t\t\tContent:  \"google.com\",\n\t\t\t\tPriority: 10,\n\t\t\t\tTTL:      1,\n\t\t\t\tProxied:  false,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:     \"Create\",\n\t\t\tZoneId:   \"001\",\n\t\t\tRecordId: generateDNSRecordID(\"MX\", \"mx.bar.com\", \"facebook.com\"),\n\t\t\tRecordData: dns.RecordResponse{\n\t\t\t\tID:       generateDNSRecordID(\"MX\", \"mx.bar.com\", \"facebook.com\"),\n\t\t\t\tType:     \"MX\",\n\t\t\t\tName:     \"mx.bar.com\",\n\t\t\t\tContent:  \"facebook.com\",\n\t\t\t\tPriority: 20,\n\t\t\t\tTTL:      1,\n\t\t\t\tProxied:  false,\n\t\t\t},\n\t\t},\n\t},\n\t\t[]string{endpoint.RecordTypeMX},\n\t)\n}\n\nfunc TestCloudflareTxt(t *testing.T) {\n\tendpoints := []*endpoint.Endpoint{\n\t\t{\n\t\t\tRecordType: \"TXT\",\n\t\t\tDNSName:    \"txt.bar.com\",\n\t\t\tTargets:    endpoint.Targets{\"v=spf1 include:_spf.google.com ~all\"},\n\t\t},\n\t}\n\n\tAssertActions(t, &CloudFlareProvider{}, endpoints, []MockAction{\n\t\t{\n\t\t\tName:     \"Create\",\n\t\t\tZoneId:   \"001\",\n\t\t\tRecordId: generateDNSRecordID(\"TXT\", \"txt.bar.com\", \"v=spf1 include:_spf.google.com ~all\"),\n\t\t\tRecordData: dns.RecordResponse{\n\t\t\t\tID:      generateDNSRecordID(\"TXT\", \"txt.bar.com\", \"v=spf1 include:_spf.google.com ~all\"),\n\t\t\t\tType:    \"TXT\",\n\t\t\t\tName:    \"txt.bar.com\",\n\t\t\t\tContent: \"v=spf1 include:_spf.google.com ~all\",\n\t\t\t\tTTL:     1,\n\t\t\t\tProxied: false,\n\t\t\t},\n\t\t},\n\t},\n\t\t[]string{endpoint.RecordTypeTXT},\n\t)\n}\n\nfunc TestCloudflareCustomTTL(t *testing.T) {\n\tendpoints := []*endpoint.Endpoint{\n\t\t{\n\t\t\tRecordType: \"A\",\n\t\t\tDNSName:    \"ttl.bar.com\",\n\t\t\tTargets:    endpoint.Targets{\"127.0.0.1\"},\n\t\t\tRecordTTL:  120,\n\t\t},\n\t}\n\n\tAssertActions(t, &CloudFlareProvider{}, endpoints, []MockAction{\n\t\t{\n\t\t\tName:     \"Create\",\n\t\t\tZoneId:   \"001\",\n\t\t\tRecordId: generateDNSRecordID(\"A\", \"ttl.bar.com\", \"127.0.0.1\"),\n\t\t\tRecordData: dns.RecordResponse{\n\t\t\t\tID:      generateDNSRecordID(\"A\", \"ttl.bar.com\", \"127.0.0.1\"),\n\t\t\t\tType:    \"A\",\n\t\t\t\tName:    \"ttl.bar.com\",\n\t\t\t\tContent: \"127.0.0.1\",\n\t\t\t\tTTL:     120,\n\t\t\t\tProxied: false,\n\t\t\t},\n\t\t},\n\t},\n\t\t[]string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},\n\t)\n}\n\nfunc TestCloudflareProxiedDefault(t *testing.T) {\n\tendpoints := []*endpoint.Endpoint{\n\t\t{\n\t\t\tRecordType: \"A\",\n\t\t\tDNSName:    \"bar.com\",\n\t\t\tTargets:    endpoint.Targets{\"127.0.0.1\"},\n\t\t},\n\t}\n\n\tAssertActions(t, &CloudFlareProvider{proxiedByDefault: true}, endpoints, []MockAction{\n\t\t{\n\t\t\tName:     \"Create\",\n\t\t\tZoneId:   \"001\",\n\t\t\tRecordId: generateDNSRecordID(\"A\", \"bar.com\", \"127.0.0.1\"),\n\t\t\tRecordData: dns.RecordResponse{\n\t\t\t\tID:      generateDNSRecordID(\"A\", \"bar.com\", \"127.0.0.1\"),\n\t\t\t\tType:    \"A\",\n\t\t\t\tName:    \"bar.com\",\n\t\t\t\tContent: \"127.0.0.1\",\n\t\t\t\tTTL:     1,\n\t\t\t\tProxied: true,\n\t\t\t},\n\t\t},\n\t},\n\t\t[]string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},\n\t)\n}\n\nfunc TestCloudflareProxiedOverrideTrue(t *testing.T) {\n\tendpoints := []*endpoint.Endpoint{\n\t\t{\n\t\t\tRecordType: \"A\",\n\t\t\tDNSName:    \"bar.com\",\n\t\t\tTargets:    endpoint.Targets{\"127.0.0.1\"},\n\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\tendpoint.ProviderSpecificProperty{\n\t\t\t\t\tName:  \"external-dns.alpha.kubernetes.io/cloudflare-proxied\",\n\t\t\t\t\tValue: \"true\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tAssertActions(t, &CloudFlareProvider{}, endpoints, []MockAction{\n\t\t{\n\t\t\tName:     \"Create\",\n\t\t\tZoneId:   \"001\",\n\t\t\tRecordId: generateDNSRecordID(\"A\", \"bar.com\", \"127.0.0.1\"),\n\t\t\tRecordData: dns.RecordResponse{\n\t\t\t\tID:      generateDNSRecordID(\"A\", \"bar.com\", \"127.0.0.1\"),\n\t\t\t\tType:    \"A\",\n\t\t\t\tName:    \"bar.com\",\n\t\t\t\tContent: \"127.0.0.1\",\n\t\t\t\tTTL:     1,\n\t\t\t\tProxied: true,\n\t\t\t},\n\t\t},\n\t},\n\t\t[]string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},\n\t)\n}\n\nfunc TestCloudflareProxiedOverrideFalse(t *testing.T) {\n\tendpoints := []*endpoint.Endpoint{\n\t\t{\n\t\t\tRecordType: \"A\",\n\t\t\tDNSName:    \"bar.com\",\n\t\t\tTargets:    endpoint.Targets{\"127.0.0.1\"},\n\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\tendpoint.ProviderSpecificProperty{\n\t\t\t\t\tName:  \"external-dns.alpha.kubernetes.io/cloudflare-proxied\",\n\t\t\t\t\tValue: \"false\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tAssertActions(t, &CloudFlareProvider{proxiedByDefault: true}, endpoints, []MockAction{\n\t\t{\n\t\t\tName:     \"Create\",\n\t\t\tZoneId:   \"001\",\n\t\t\tRecordId: generateDNSRecordID(\"A\", \"bar.com\", \"127.0.0.1\"),\n\t\t\tRecordData: dns.RecordResponse{\n\t\t\t\tID:      generateDNSRecordID(\"A\", \"bar.com\", \"127.0.0.1\"),\n\t\t\t\tType:    \"A\",\n\t\t\t\tName:    \"bar.com\",\n\t\t\t\tContent: \"127.0.0.1\",\n\t\t\t\tTTL:     1,\n\t\t\t\tProxied: false,\n\t\t\t},\n\t\t},\n\t},\n\t\t[]string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},\n\t)\n}\n\nfunc TestCloudflareProxiedOverrideIllegal(t *testing.T) {\n\tendpoints := []*endpoint.Endpoint{\n\t\t{\n\t\t\tRecordType: \"A\",\n\t\t\tDNSName:    \"bar.com\",\n\t\t\tTargets:    endpoint.Targets{\"127.0.0.1\"},\n\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\tendpoint.ProviderSpecificProperty{\n\t\t\t\t\tName:  \"external-dns.alpha.kubernetes.io/cloudflare-proxied\",\n\t\t\t\t\tValue: \"asfasdfa\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tAssertActions(t, &CloudFlareProvider{proxiedByDefault: true}, endpoints, []MockAction{\n\t\t{\n\t\t\tName:     \"Create\",\n\t\t\tZoneId:   \"001\",\n\t\t\tRecordId: generateDNSRecordID(\"A\", \"bar.com\", \"127.0.0.1\"),\n\t\t\tRecordData: dns.RecordResponse{\n\t\t\t\tID:      generateDNSRecordID(\"A\", \"bar.com\", \"127.0.0.1\"),\n\t\t\t\tType:    \"A\",\n\t\t\t\tName:    \"bar.com\",\n\t\t\t\tContent: \"127.0.0.1\",\n\t\t\t\tTTL:     1,\n\t\t\t\tProxied: true,\n\t\t\t},\n\t\t},\n\t},\n\t\t[]string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},\n\t)\n}\n\nfunc TestCloudflareSetProxied(t *testing.T) {\n\ttestCases := []struct {\n\t\trecordType string\n\t\tdomain     string\n\t\tproxiable  bool\n\t}{\n\t\t{\"A\", \"bar.com\", true},\n\t\t{\"CNAME\", \"bar.com\", true},\n\t\t{\"TXT\", \"bar.com\", false},\n\t\t{\"MX\", \"bar.com\", false},\n\t\t{\"NS\", \"bar.com\", false},\n\t\t{\"SPF\", \"bar.com\", false},\n\t\t{\"SRV\", \"bar.com\", false},\n\t\t{\"A\", \"*.bar.com\", true},\n\t\t{\"CNAME\", \"*.docs.bar.com\", true},\n\t}\n\n\tfor _, testCase := range testCases {\n\t\tt.Run(fmt.Sprint(testCase), func(t *testing.T) {\n\t\t\tvar targets endpoint.Targets\n\t\t\tvar content string\n\t\t\tvar priority float64\n\n\t\t\tif testCase.recordType == \"MX\" {\n\t\t\t\ttargets = endpoint.Targets{\"10 mx.example.com\"}\n\t\t\t\tcontent = \"mx.example.com\"\n\t\t\t\tpriority = 10\n\t\t\t} else {\n\t\t\t\ttargets = endpoint.Targets{\"127.0.0.1\"}\n\t\t\t\tcontent = \"127.0.0.1\"\n\t\t\t}\n\n\t\t\tendpoints := []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tRecordType: testCase.recordType,\n\t\t\t\t\tDNSName:    testCase.domain,\n\t\t\t\t\tTargets:    endpoint.Targets{targets[0]},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\tendpoint.ProviderSpecificProperty{\n\t\t\t\t\t\t\tName:  \"external-dns.alpha.kubernetes.io/cloudflare-proxied\",\n\t\t\t\t\t\t\tValue: \"true\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}\n\t\t\texpectedID := fmt.Sprintf(\"%s-%s-%s\", testCase.domain, testCase.recordType, content)\n\t\t\trecordData := dns.RecordResponse{\n\t\t\t\tID:      expectedID,\n\t\t\t\tType:    dns.RecordResponseType(testCase.recordType),\n\t\t\t\tName:    testCase.domain,\n\t\t\t\tContent: content,\n\t\t\t\tTTL:     1,\n\t\t\t\tProxied: testCase.proxiable,\n\t\t\t}\n\t\t\tif testCase.recordType == \"MX\" {\n\t\t\t\trecordData.Priority = priority\n\t\t\t}\n\t\t\tAssertActions(t, &CloudFlareProvider{}, endpoints, []MockAction{\n\t\t\t\t{\n\t\t\t\t\tName:       \"Create\",\n\t\t\t\t\tZoneId:     \"001\",\n\t\t\t\t\tRecordId:   expectedID,\n\t\t\t\t\tRecordData: recordData,\n\t\t\t\t},\n\t\t\t}, []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME, endpoint.RecordTypeNS, endpoint.RecordTypeMX}, testCase.recordType+\" record on \"+testCase.domain)\n\t\t})\n\t}\n}\n\nfunc TestCloudflareZones(t *testing.T) {\n\tprovider := &CloudFlareProvider{\n\t\tClient:       NewMockCloudFlareClient(),\n\t\tdomainFilter: endpoint.NewDomainFilter([]string{\"bar.com\"}),\n\t\tzoneIDFilter: provider.NewZoneIDFilter([]string{\"\"}),\n\t}\n\n\tzones, err := provider.Zones(t.Context())\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tassert.Len(t, zones, 1)\n\tassert.Equal(t, \"bar.com\", zones[0].Name)\n}\n\n// test failures on zone lookup\nfunc TestCloudflareZonesFailed(t *testing.T) {\n\n\tclient := NewMockCloudFlareClient()\n\tclient.getZoneError = errors.New(\"zone lookup failed\")\n\n\tprovider := &CloudFlareProvider{\n\t\tClient:       client,\n\t\tdomainFilter: endpoint.NewDomainFilter([]string{\"bar.com\"}),\n\t\tzoneIDFilter: provider.NewZoneIDFilter([]string{\"001\"}),\n\t}\n\n\t_, err := provider.Zones(t.Context())\n\tif err == nil {\n\t\tt.Errorf(\"should fail, %s\", err)\n\t}\n}\n\nfunc TestCloudFlareZonesWithIDFilter(t *testing.T) {\n\tclient := NewMockCloudFlareClient()\n\tclient.listZonesError = errors.New(\"shouldn't need to list zones when ZoneIDFilter in use\")\n\tprovider := &CloudFlareProvider{\n\t\tClient:       client,\n\t\tdomainFilter: endpoint.NewDomainFilter([]string{\"bar.com\", \"foo.com\"}),\n\t\tzoneIDFilter: provider.NewZoneIDFilter([]string{\"001\"}),\n\t}\n\n\tzones, err := provider.Zones(t.Context())\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// foo.com should *not* be returned as it doesn't match ZoneID filter\n\tassert.Len(t, zones, 1)\n\tassert.Equal(t, \"bar.com\", zones[0].Name)\n}\n\nfunc TestCloudflareListZonesRateLimited(t *testing.T) {\n\t// Create a mock client that returns a rate limit error\n\tclient := NewMockCloudFlareClient()\n\tclient.listZonesError = newCloudflareError(429)\n\tp := &CloudFlareProvider{Client: client}\n\n\t// Call the Zones function\n\t_, err := p.Zones(t.Context())\n\n\t// Assert that a soft error was returned\n\tif !errors.Is(err, provider.SoftError) {\n\t\tt.Error(\"expected a rate limit error\")\n\t}\n}\n\nfunc TestCloudflareListZonesRateLimitedStringError(t *testing.T) {\n\t// Create a mock client that returns a rate limit error\n\tclient := NewMockCloudFlareClient()\n\tclient.listZonesError = errors.New(\"exceeded available rate limit retries\")\n\tp := &CloudFlareProvider{Client: client}\n\n\t// Call the Zones function\n\t_, err := p.Zones(t.Context())\n\n\t// Assert that a soft error was returned\n\tassert.ErrorIs(t, err, provider.SoftError, \"expected a rate limit error\")\n}\n\nfunc TestCloudflareListZoneInternalErrors(t *testing.T) {\n\t// Create a mock client that returns a internal server error\n\tclient := NewMockCloudFlareClient()\n\tclient.listZonesError = newCloudflareError(500)\n\tp := &CloudFlareProvider{Client: client}\n\n\t// Call the Zones function\n\t_, err := p.Zones(t.Context())\n\n\t// Assert that a soft error was returned\n\tt.Log(err)\n\tif !errors.Is(err, provider.SoftError) {\n\t\tt.Errorf(\"expected a internal error\")\n\t}\n}\n\nfunc TestCloudflareRecords(t *testing.T) {\n\tclient := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{\n\t\t\"001\": ExampleDomain,\n\t})\n\n\t// Set DNSRecordsPerPage to 1 test the pagination behaviour\n\tp := &CloudFlareProvider{\n\t\tClient:           client,\n\t\tDNSRecordsConfig: DNSRecordsConfig{PerPage: 1},\n\t}\n\tctx := t.Context()\n\n\trecords, err := p.Records(ctx)\n\tif err != nil {\n\t\tt.Errorf(\"should not fail, %s\", err)\n\t}\n\tassert.Len(t, records, 2)\n\tclient.dnsRecordsError = errors.New(\"failed to list dns records\")\n\t_, err = p.Records(ctx)\n\tif err == nil {\n\t\tt.Errorf(\"expected to fail\")\n\t}\n\tclient.dnsRecordsError = nil\n\tclient.listZonesError = newCloudflareError(429)\n\t_, err = p.Records(ctx)\n\t// Assert that a soft error was returned\n\tif !errors.Is(err, provider.SoftError) {\n\t\tt.Error(\"expected a rate limit error\")\n\t}\n\n\tclient.listZonesError = newCloudflareError(500)\n\t_, err = p.Records(ctx)\n\t// Assert that a soft error was returned\n\tif !errors.Is(err, provider.SoftError) {\n\t\tt.Error(\"expected a internal server error\")\n\t}\n\n\tclient.listZonesError = errors.New(\"failed to list zones\")\n\t_, err = p.Records(ctx)\n\tif err == nil {\n\t\tt.Errorf(\"expected to fail\")\n\t}\n}\n\nfunc TestGetDNSRecordsMapWithPerPage(t *testing.T) {\n\tclient := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{\n\t\t\"001\": ExampleDomain,\n\t})\n\n\tctx := t.Context()\n\n\tt.Run(\"PerPage set to positive value\", func(t *testing.T) {\n\t\tprovider := &CloudFlareProvider{\n\t\t\tClient:           client,\n\t\t\tDNSRecordsConfig: DNSRecordsConfig{PerPage: 100},\n\t\t}\n\t\t_, err := provider.getDNSRecordsMap(ctx, \"001\")\n\t\tassert.NoError(t, err)\n\t\tassert.True(t, client.dnsRecordsListParams.PerPage.Present)\n\t\tassert.InEpsilon(t, float64(100), client.dnsRecordsListParams.PerPage.Value, 0.0001)\n\t})\n\n\tt.Run(\"PerPage not set\", func(t *testing.T) {\n\t\tprovider := &CloudFlareProvider{\n\t\t\tClient:           client,\n\t\t\tDNSRecordsConfig: DNSRecordsConfig{},\n\t\t}\n\t\t_, err := provider.getDNSRecordsMap(ctx, \"001\")\n\t\tassert.NoError(t, err)\n\t\tassert.False(t, client.dnsRecordsListParams.PerPage.Present)\n\t})\n}\n\nfunc TestCloudflareProvider(t *testing.T) {\n\tvar err error\n\n\ttype EnvVar struct {\n\t\tKey   string\n\t\tValue string\n\t}\n\n\t// unset environment variables to avoid interference with tests\n\ttestutils.TestHelperEnvSetter(t, map[string]string{\n\t\tcfAPIEmailEnvKey: \"\",\n\t\tcfAPIKeyEnvKey:   \"\",\n\t\tcfAPITokenEnvKey: \"\",\n\t})\n\n\ttokenFile := \"/tmp/cf_api_token\"\n\tif err := os.WriteFile(tokenFile, []byte(\"abc123def\"), 0o644); err != nil {\n\t\tt.Errorf(\"failed to write token file, %s\", err)\n\t}\n\n\ttestCases := []struct {\n\t\tName        string\n\t\tEnvironment []EnvVar\n\t\tShouldFail  bool\n\t}{\n\t\t{\n\t\t\tName: \"use_api_token\",\n\t\t\tEnvironment: []EnvVar{\n\t\t\t\t{Key: cfAPITokenEnvKey, Value: \"abc123def\"},\n\t\t\t},\n\t\t\tShouldFail: false,\n\t\t},\n\t\t{\n\t\t\tName: \"use_api_token_file_contents\",\n\t\t\tEnvironment: []EnvVar{\n\t\t\t\t{Key: cfAPITokenEnvKey, Value: tokenFile},\n\t\t\t},\n\t\t\tShouldFail: false,\n\t\t},\n\t\t{\n\t\t\tName: \"use_email_and_key\",\n\t\t\tEnvironment: []EnvVar{\n\t\t\t\t{Key: cfAPIKeyEnvKey, Value: \"xxxxxxxxxxxxxxxxx\"},\n\t\t\t\t{Key: cfAPIEmailEnvKey, Value: \"test@test.com\"},\n\t\t\t},\n\t\t\tShouldFail: false,\n\t\t},\n\t\t{\n\t\t\tName:        \"no_use_email_and_key\",\n\t\t\tEnvironment: []EnvVar{},\n\t\t\tShouldFail:  true,\n\t\t},\n\t\t{\n\t\t\tName: \"use_credentials_in_missing_file\",\n\t\t\tEnvironment: []EnvVar{\n\t\t\t\t{Key: cfAPITokenEnvKey, Value: \"file://abc\"},\n\t\t\t},\n\t\t\tShouldFail: true,\n\t\t},\n\t\t{\n\t\t\tName: \"use_credentials_in_missing_file\",\n\t\t\tEnvironment: []EnvVar{\n\t\t\t\t{Key: cfAPITokenEnvKey, Value: \"file:/tmp/cf_api_token\"},\n\t\t\t},\n\t\t\tShouldFail: false,\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.Name, func(t *testing.T) {\n\t\t\tfor _, env := range tc.Environment {\n\t\t\t\tt.Setenv(env.Key, env.Value)\n\t\t\t}\n\n\t\t\t_, err = newProvider(\n\t\t\t\tendpoint.NewDomainFilter([]string{\"bar.com\"}),\n\t\t\t\tprovider.NewZoneIDFilter([]string{\"\"}),\n\t\t\t\tfalse,\n\t\t\t\ttrue,\n\t\t\t\tRegionalServicesConfig{Enabled: false},\n\t\t\t\tCustomHostnamesConfig{Enabled: false},\n\t\t\t\tDNSRecordsConfig{PerPage: 5000, Comment: \"\"},\n\t\t\t)\n\t\t\tif err != nil && !tc.ShouldFail {\n\t\t\t\tt.Errorf(\"should not fail, %s\", err)\n\t\t\t}\n\t\t\tif err == nil && tc.ShouldFail {\n\t\t\t\tt.Errorf(\"should fail, %s\", err)\n\t\t\t}\n\t\t})\n\n\t}\n}\n\nfunc TestCloudflareApplyChanges(t *testing.T) {\n\tchanges := &plan.Changes{}\n\tclient := NewMockCloudFlareClient()\n\tprovider := &CloudFlareProvider{\n\t\tClient: client,\n\t}\n\tchanges.Create = []*endpoint.Endpoint{{\n\t\tDNSName: \"new.bar.com\",\n\t\tTargets: endpoint.Targets{\"target\"},\n\t}, {\n\t\tDNSName: \"new.ext-dns-test.unrelated.to\",\n\t\tTargets: endpoint.Targets{\"target\"},\n\t}}\n\tchanges.Delete = []*endpoint.Endpoint{{\n\t\tDNSName: \"foobar.bar.com\",\n\t\tTargets: endpoint.Targets{\"target\"},\n\t}}\n\tchanges.UpdateOld = []*endpoint.Endpoint{{\n\t\tDNSName: \"foobar.bar.com\",\n\t\tTargets: endpoint.Targets{\"target-old\"},\n\t}}\n\tchanges.UpdateNew = []*endpoint.Endpoint{{\n\t\tDNSName: \"foobar.bar.com\",\n\t\tTargets: endpoint.Targets{\"target-new\"},\n\t}}\n\terr := provider.ApplyChanges(t.Context(), changes)\n\tif err != nil {\n\t\tt.Errorf(\"should not fail, %s\", err)\n\t}\n\n\ttd.Cmp(t, client.Actions, []MockAction{\n\t\t{\n\t\t\tName:     \"Create\",\n\t\t\tZoneId:   \"001\",\n\t\t\tRecordId: generateDNSRecordID(\"\", \"new.bar.com\", \"target\"),\n\t\t\tRecordData: dns.RecordResponse{\n\t\t\t\tID:      generateDNSRecordID(\"\", \"new.bar.com\", \"target\"),\n\t\t\t\tName:    \"new.bar.com\",\n\t\t\t\tContent: \"target\",\n\t\t\t\tTTL:     1,\n\t\t\t\tProxied: false,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:     \"Create\",\n\t\t\tZoneId:   \"001\",\n\t\t\tRecordId: generateDNSRecordID(\"\", \"foobar.bar.com\", \"target-new\"),\n\t\t\tRecordData: dns.RecordResponse{\n\t\t\t\tID:      generateDNSRecordID(\"\", \"foobar.bar.com\", \"target-new\"),\n\t\t\t\tName:    \"foobar.bar.com\",\n\t\t\t\tContent: \"target-new\",\n\t\t\t\tTTL:     1,\n\t\t\t\tProxied: false,\n\t\t\t},\n\t\t},\n\t})\n\n\t// empty changes\n\tchanges.Create = []*endpoint.Endpoint{}\n\tchanges.Delete = []*endpoint.Endpoint{}\n\tchanges.UpdateOld = []*endpoint.Endpoint{}\n\tchanges.UpdateNew = []*endpoint.Endpoint{}\n\n\terr = provider.ApplyChanges(t.Context(), changes)\n\tif err != nil {\n\t\tt.Errorf(\"should not fail, %s\", err)\n\t}\n}\n\nfunc TestCloudflareDryRunApplyChanges(t *testing.T) {\n\tchanges := &plan.Changes{}\n\tclient := NewMockCloudFlareClient()\n\n\tprovider := &CloudFlareProvider{\n\t\tClient: client,\n\t\tDryRun: true,\n\t}\n\tchanges.Create = []*endpoint.Endpoint{{\n\t\tDNSName: \"new.bar.com\",\n\t\tTargets: endpoint.Targets{\"target\"},\n\t}}\n\terr := provider.ApplyChanges(t.Context(), changes)\n\tif err != nil {\n\t\tt.Errorf(\"should not fail, %s\", err)\n\t}\n\tctx := t.Context()\n\trecords, err := provider.Records(ctx)\n\tif err != nil {\n\t\tt.Errorf(\"should not fail, %s\", err)\n\t}\n\tassert.Empty(t, records, \"should not have any records\")\n}\n\nfunc TestCloudflareApplyChangesError(t *testing.T) {\n\tchanges := &plan.Changes{}\n\tclient := NewMockCloudFlareClient()\n\tprovider := &CloudFlareProvider{\n\t\tClient: client,\n\t}\n\tchanges.Create = []*endpoint.Endpoint{{\n\t\tDNSName: \"newerror.bar.com\",\n\t\tTargets: endpoint.Targets{\"target\"},\n\t}}\n\terr := provider.ApplyChanges(t.Context(), changes)\n\tif err == nil {\n\t\tt.Errorf(\"should fail, %s\", err)\n\t}\n}\n\nfunc TestCloudflareGetRecordID(t *testing.T) {\n\tp := &CloudFlareProvider{}\n\trecordsMap := DNSRecordsMap{\n\t\t{Name: \"foo.com\", Type: endpoint.RecordTypeCNAME, Content: \"foobar\"}: {\n\t\t\tName:    \"foo.com\",\n\t\t\tType:    endpoint.RecordTypeCNAME,\n\t\t\tContent: \"foobar\",\n\t\t\tID:      \"1\",\n\t\t},\n\t\t{Name: \"bar.de\", Type: endpoint.RecordTypeA}: {\n\t\t\tName: \"bar.de\",\n\t\t\tType: endpoint.RecordTypeA,\n\t\t\tID:   \"2\",\n\t\t},\n\t\t{Name: \"bar.de\", Type: endpoint.RecordTypeA, Content: \"1.2.3.4\"}: {\n\t\t\tName:    \"bar.de\",\n\t\t\tType:    endpoint.RecordTypeA,\n\t\t\tContent: \"1.2.3.4\",\n\t\t\tID:      \"2\",\n\t\t},\n\t}\n\n\tassert.Empty(t, p.getRecordID(recordsMap, dns.RecordResponse{\n\t\tName:    \"foo.com\",\n\t\tType:    endpoint.RecordTypeA,\n\t\tContent: \"foobar\",\n\t}))\n\n\tassert.Empty(t, p.getRecordID(recordsMap, dns.RecordResponse{\n\t\tName:    \"foo.com\",\n\t\tType:    endpoint.RecordTypeCNAME,\n\t\tContent: \"fizfuz\",\n\t}))\n\n\tassert.Equal(t, \"1\", p.getRecordID(recordsMap, dns.RecordResponse{\n\t\tName:    \"foo.com\",\n\t\tType:    endpoint.RecordTypeCNAME,\n\t\tContent: \"foobar\",\n\t}))\n\tassert.Empty(t, p.getRecordID(recordsMap, dns.RecordResponse{\n\t\tName:    \"bar.de\",\n\t\tType:    endpoint.RecordTypeA,\n\t\tContent: \"2.3.4.5\",\n\t}))\n\tassert.Equal(t, \"2\", p.getRecordID(recordsMap, dns.RecordResponse{\n\t\tName:    \"bar.de\",\n\t\tType:    endpoint.RecordTypeA,\n\t\tContent: \"1.2.3.4\",\n\t}))\n}\n\nfunc TestCloudflareGroupByNameAndTypeWithCustomHostnames(t *testing.T) {\n\tprovider := &CloudFlareProvider{\n\t\tClient:       NewMockCloudFlareClient(),\n\t\tdomainFilter: endpoint.NewDomainFilter([]string{\"bar.com\"}),\n\t\tzoneIDFilter: provider.NewZoneIDFilter([]string{\"\"}),\n\t}\n\ttestCases := []struct {\n\t\tName              string\n\t\tRecords           []dns.RecordResponse\n\t\tExpectedEndpoints []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\tName:              \"empty\",\n\t\t\tRecords:           []dns.RecordResponse{},\n\t\t\tExpectedEndpoints: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\tName: \"single record - single target\",\n\t\t\tRecords: []dns.RecordResponse{\n\t\t\t\t{\n\t\t\t\t\tName:    \"foo.com\",\n\t\t\t\t\tType:    endpoint.RecordTypeA,\n\t\t\t\t\tContent: \"10.10.10.1\",\n\t\t\t\t\tTTL:     defaultTTL,\n\t\t\t\t\tProxied: false,\n\t\t\t\t},\n\t\t\t},\n\t\t\tExpectedEndpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"10.10.10.1\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  endpoint.TTL(defaultTTL),\n\t\t\t\t\tLabels:     endpoint.Labels{},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:  \"external-dns.alpha.kubernetes.io/cloudflare-proxied\",\n\t\t\t\t\t\t\tValue: \"false\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName: \"single record - multiple targets\",\n\t\t\tRecords: []dns.RecordResponse{\n\t\t\t\t{\n\t\t\t\t\tName:    \"foo.com\",\n\t\t\t\t\tType:    endpoint.RecordTypeA,\n\t\t\t\t\tContent: \"10.10.10.1\",\n\t\t\t\t\tTTL:     defaultTTL,\n\t\t\t\t\tProxied: false,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tName:    \"foo.com\",\n\t\t\t\t\tType:    endpoint.RecordTypeA,\n\t\t\t\t\tContent: \"10.10.10.2\",\n\t\t\t\t\tTTL:     defaultTTL,\n\t\t\t\t\tProxied: false,\n\t\t\t\t},\n\t\t\t},\n\t\t\tExpectedEndpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"10.10.10.1\", \"10.10.10.2\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  endpoint.TTL(defaultTTL),\n\t\t\t\t\tLabels:     endpoint.Labels{},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:  \"external-dns.alpha.kubernetes.io/cloudflare-proxied\",\n\t\t\t\t\t\t\tValue: \"false\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName: \"multiple record - multiple targets\",\n\t\t\tRecords: []dns.RecordResponse{\n\t\t\t\t{\n\t\t\t\t\tName:    \"foo.com\",\n\t\t\t\t\tType:    endpoint.RecordTypeA,\n\t\t\t\t\tContent: \"10.10.10.1\",\n\t\t\t\t\tTTL:     defaultTTL,\n\t\t\t\t\tProxied: false,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tName:    \"foo.com\",\n\t\t\t\t\tType:    endpoint.RecordTypeA,\n\t\t\t\t\tContent: \"10.10.10.2\",\n\t\t\t\t\tTTL:     defaultTTL,\n\t\t\t\t\tProxied: false,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tName:    \"bar.de\",\n\t\t\t\t\tType:    endpoint.RecordTypeA,\n\t\t\t\t\tContent: \"10.10.10.1\",\n\t\t\t\t\tTTL:     defaultTTL,\n\t\t\t\t\tProxied: false,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tName:    \"bar.de\",\n\t\t\t\t\tType:    endpoint.RecordTypeA,\n\t\t\t\t\tContent: \"10.10.10.2\",\n\t\t\t\t\tTTL:     defaultTTL,\n\t\t\t\t\tProxied: false,\n\t\t\t\t},\n\t\t\t},\n\t\t\tExpectedEndpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"10.10.10.1\", \"10.10.10.2\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  endpoint.TTL(defaultTTL),\n\t\t\t\t\tLabels:     endpoint.Labels{},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:  \"external-dns.alpha.kubernetes.io/cloudflare-proxied\",\n\t\t\t\t\t\t\tValue: \"false\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"bar.de\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"10.10.10.1\", \"10.10.10.2\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  endpoint.TTL(defaultTTL),\n\t\t\t\t\tLabels:     endpoint.Labels{},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:  \"external-dns.alpha.kubernetes.io/cloudflare-proxied\",\n\t\t\t\t\t\t\tValue: \"false\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName: \"multiple record - mixed single/multiple targets\",\n\t\t\tRecords: []dns.RecordResponse{\n\t\t\t\t{\n\t\t\t\t\tName:    \"foo.com\",\n\t\t\t\t\tType:    endpoint.RecordTypeA,\n\t\t\t\t\tContent: \"10.10.10.1\",\n\t\t\t\t\tTTL:     defaultTTL,\n\t\t\t\t\tProxied: false,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tName:    \"foo.com\",\n\t\t\t\t\tType:    endpoint.RecordTypeA,\n\t\t\t\t\tContent: \"10.10.10.2\",\n\t\t\t\t\tTTL:     defaultTTL,\n\t\t\t\t\tProxied: false,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tName:    \"bar.de\",\n\t\t\t\t\tType:    endpoint.RecordTypeA,\n\t\t\t\t\tContent: \"10.10.10.1\",\n\t\t\t\t\tTTL:     defaultTTL,\n\t\t\t\t\tProxied: false,\n\t\t\t\t},\n\t\t\t},\n\t\t\tExpectedEndpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"10.10.10.1\", \"10.10.10.2\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  endpoint.TTL(defaultTTL),\n\t\t\t\t\tLabels:     endpoint.Labels{},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:  \"external-dns.alpha.kubernetes.io/cloudflare-proxied\",\n\t\t\t\t\t\t\tValue: \"false\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"bar.de\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"10.10.10.1\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  endpoint.TTL(defaultTTL),\n\t\t\t\t\tLabels:     endpoint.Labels{},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:  \"external-dns.alpha.kubernetes.io/cloudflare-proxied\",\n\t\t\t\t\t\t\tValue: \"false\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName: \"unsupported record type\",\n\t\t\tRecords: []dns.RecordResponse{\n\t\t\t\t{\n\t\t\t\t\tName:    \"foo.com\",\n\t\t\t\t\tType:    endpoint.RecordTypeA,\n\t\t\t\t\tContent: \"10.10.10.1\",\n\t\t\t\t\tTTL:     defaultTTL,\n\t\t\t\t\tProxied: false,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tName:    \"foo.com\",\n\t\t\t\t\tType:    endpoint.RecordTypeA,\n\t\t\t\t\tContent: \"10.10.10.2\",\n\t\t\t\t\tTTL:     defaultTTL,\n\t\t\t\t\tProxied: false,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tName:    \"bar.de\",\n\t\t\t\t\tType:    \"NOT SUPPORTED\",\n\t\t\t\t\tContent: \"10.10.10.1\",\n\t\t\t\t\tTTL:     defaultTTL,\n\t\t\t\t\tProxied: false,\n\t\t\t\t},\n\t\t\t},\n\t\t\tExpectedEndpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"10.10.10.1\", \"10.10.10.2\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  endpoint.TTL(defaultTTL),\n\t\t\t\t\tLabels:     endpoint.Labels{},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:  \"external-dns.alpha.kubernetes.io/cloudflare-proxied\",\n\t\t\t\t\t\t\tValue: \"false\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.Name, func(t *testing.T) {\n\t\t\trecords := make(DNSRecordsMap)\n\t\t\tfor _, r := range tc.Records {\n\t\t\t\trecords[newDNSRecordIndex(r)] = r\n\t\t\t}\n\t\t\tendpoints := provider.groupByNameAndTypeWithCustomHostnames(records, customHostnamesMap{})\n\t\t\t// Targets order could be random with underlying map\n\t\t\tfor _, ep := range endpoints {\n\t\t\t\tslices.Sort(ep.Targets)\n\t\t\t}\n\t\t\tfor _, ep := range tc.ExpectedEndpoints {\n\t\t\t\tslices.Sort(ep.Targets)\n\t\t\t}\n\t\t\tassert.ElementsMatch(t, endpoints, tc.ExpectedEndpoints)\n\t\t})\n\t}\n}\n\nfunc TestGroupByNameAndTypeWithCustomHostnames_MX(t *testing.T) {\n\tt.Parallel()\n\tclient := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{\n\t\t\"001\": {\n\t\t\t{\n\t\t\t\tID:       \"mx-1\",\n\t\t\t\tName:     \"mx.bar.com\",\n\t\t\t\tType:     endpoint.RecordTypeMX,\n\t\t\t\tTTL:      3600,\n\t\t\t\tContent:  \"mail.bar.com\",\n\t\t\t\tPriority: 10,\n\t\t\t},\n\t\t\t{\n\t\t\t\tID:       \"mx-2\",\n\t\t\t\tName:     \"mx.bar.com\",\n\t\t\t\tType:     endpoint.RecordTypeMX,\n\t\t\t\tTTL:      3600,\n\t\t\t\tContent:  \"mail2.bar.com\",\n\t\t\t\tPriority: 20,\n\t\t\t},\n\t\t},\n\t})\n\tprovider := &CloudFlareProvider{\n\t\tClient: client,\n\t}\n\tctx := t.Context()\n\tchs := customHostnamesMap{}\n\trecords, err := provider.getDNSRecordsMap(ctx, \"001\")\n\tassert.NoError(t, err)\n\n\tendpoints := provider.groupByNameAndTypeWithCustomHostnames(records, chs)\n\tassert.Len(t, endpoints, 1)\n\tmxEndpoint := endpoints[0]\n\tassert.Equal(t, \"mx.bar.com\", mxEndpoint.DNSName)\n\tassert.Equal(t, endpoint.RecordTypeMX, mxEndpoint.RecordType)\n\tassert.ElementsMatch(t, []string{\"10 mail.bar.com\", \"20 mail2.bar.com\"}, mxEndpoint.Targets)\n\tassert.Equal(t, endpoint.TTL(3600), mxEndpoint.RecordTTL)\n}\n\nfunc TestProviderPropertiesIdempotency(t *testing.T) {\n\tt.Parallel()\n\n\ttestCases := []struct {\n\t\tName                  string\n\t\tSetupProvider         func(*CloudFlareProvider)\n\t\tSetupRecord           func(*dns.RecordResponse)\n\t\tCustomHostnames       []customHostname\n\t\tRegionKey             string\n\t\tShouldBeUpdated       bool\n\t\tPropertyKey           string\n\t\tExpectPropertyPresent bool\n\t\tExpectPropertyValue   string\n\t}{\n\t\t{\n\t\t\tName:            \"No custom properties, ExpectUpdates: false\",\n\t\t\tSetupProvider:   func(_ *CloudFlareProvider) {},\n\t\t\tSetupRecord:     func(_ *dns.RecordResponse) {},\n\t\t\tShouldBeUpdated: false,\n\t\t},\n\t\t// Proxied tests\n\t\t{\n\t\t\tName:            \"ProxiedByDefault: true, ProxiedRecord: true, ExpectUpdates: false\",\n\t\t\tSetupProvider:   func(p *CloudFlareProvider) { p.proxiedByDefault = true },\n\t\t\tSetupRecord:     func(r *dns.RecordResponse) { r.Proxied = true },\n\t\t\tShouldBeUpdated: false,\n\t\t},\n\t\t{\n\t\t\tName:                \"ProxiedByDefault: true, ProxiedRecord: false, ExpectUpdates: true\",\n\t\t\tSetupProvider:       func(p *CloudFlareProvider) { p.proxiedByDefault = true },\n\t\t\tSetupRecord:         func(r *dns.RecordResponse) { r.Proxied = false },\n\t\t\tShouldBeUpdated:     true,\n\t\t\tPropertyKey:         annotations.CloudflareProxiedKey,\n\t\t\tExpectPropertyValue: \"true\",\n\t\t},\n\t\t{\n\t\t\tName:                \"ProxiedByDefault: false, ProxiedRecord: true, ExpectUpdates: true\",\n\t\t\tSetupProvider:       func(p *CloudFlareProvider) { p.proxiedByDefault = false },\n\t\t\tSetupRecord:         func(r *dns.RecordResponse) { r.Proxied = true },\n\t\t\tShouldBeUpdated:     true,\n\t\t\tPropertyKey:         annotations.CloudflareProxiedKey,\n\t\t\tExpectPropertyValue: \"false\",\n\t\t},\n\t\t// Comment tests\n\t\t{\n\t\t\tName:            \"DefaultComment: 'foo', RecordComment: 'foo', ExpectUpdates: false\",\n\t\t\tSetupProvider:   func(p *CloudFlareProvider) { p.DNSRecordsConfig.Comment = \"foo\" },\n\t\t\tSetupRecord:     func(r *dns.RecordResponse) { r.Comment = \"foo\" },\n\t\t\tShouldBeUpdated: false,\n\t\t},\n\t\t{\n\t\t\tName:                  \"DefaultComment: '', RecordComment: none, ExpectUpdates: true\",\n\t\t\tSetupProvider:         func(p *CloudFlareProvider) { p.DNSRecordsConfig.Comment = \"\" },\n\t\t\tSetupRecord:           func(r *dns.RecordResponse) { r.Comment = \"foo\" },\n\t\t\tShouldBeUpdated:       true,\n\t\t\tPropertyKey:           annotations.CloudflareRecordCommentKey,\n\t\t\tExpectPropertyPresent: false,\n\t\t},\n\t\t{\n\t\t\tName:                \"DefaultComment: 'foo', RecordComment: 'foo', ExpectUpdates: true\",\n\t\t\tSetupProvider:       func(p *CloudFlareProvider) { p.DNSRecordsConfig.Comment = \"foo\" },\n\t\t\tSetupRecord:         func(r *dns.RecordResponse) { r.Comment = \"\" },\n\t\t\tShouldBeUpdated:     true,\n\t\t\tPropertyKey:         annotations.CloudflareRecordCommentKey,\n\t\t\tExpectPropertyValue: \"foo\",\n\t\t},\n\t\t// Regional Hostname tests\n\t\t{\n\t\t\tName: \"DefaultRegionKey: 'us', RecordRegionKey: 'us', ExpectUpdates: false\",\n\t\t\tSetupProvider: func(p *CloudFlareProvider) {\n\t\t\t\tp.RegionalServicesConfig.Enabled = true\n\t\t\t\tp.RegionalServicesConfig.RegionKey = \"us\"\n\t\t\t},\n\t\t\tRegionKey:       \"us\",\n\t\t\tShouldBeUpdated: false,\n\t\t},\n\t\t{\n\t\t\tName: \"DefaultRegionKey: 'us', RecordRegionKey: 'us', ExpectUpdates: false\",\n\t\t\tSetupProvider: func(p *CloudFlareProvider) {\n\t\t\t\tp.RegionalServicesConfig.Enabled = true\n\t\t\t\tp.RegionalServicesConfig.RegionKey = \"us\"\n\t\t\t},\n\t\t\tRegionKey:           \"eu\",\n\t\t\tShouldBeUpdated:     true,\n\t\t\tPropertyKey:         annotations.CloudflareRegionKey,\n\t\t\tExpectPropertyValue: \"us\",\n\t\t},\n\t\t// Custom Hostname tests\n\t\t// TODO: add tests for custom hostnames when properly supported\n\t}\n\n\tfor _, test := range testCases {\n\t\tt.Run(test.Name, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\trecord := dns.RecordResponse{\n\t\t\t\tID:      \"1234567890\",\n\t\t\t\tName:    \"foobar.bar.com\",\n\t\t\t\tType:    endpoint.RecordTypeA,\n\t\t\t\tTTL:     120,\n\t\t\t\tContent: \"1.2.3.4\",\n\t\t\t}\n\t\t\tif test.SetupRecord != nil {\n\t\t\t\ttest.SetupRecord(&record)\n\t\t\t}\n\t\t\tclient := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{\n\t\t\t\t\"001\": {record},\n\t\t\t})\n\n\t\t\tif len(test.CustomHostnames) > 0 {\n\t\t\t\tcustomHostnames := make([]customHostname, 0, len(test.CustomHostnames))\n\t\t\t\tfor _, ch := range test.CustomHostnames {\n\t\t\t\t\tch.customOriginServer = record.Name\n\t\t\t\t\tcustomHostnames = append(customHostnames, ch)\n\t\t\t\t}\n\t\t\t\tclient.customHostnames = map[string][]customHostname{\n\t\t\t\t\t\"001\": customHostnames,\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif test.RegionKey != \"\" {\n\t\t\t\tclient.regionalHostnames = map[string][]regionalHostname{\n\t\t\t\t\t\"001\": {{hostname: record.Name, regionKey: test.RegionKey}},\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tprovider := &CloudFlareProvider{\n\t\t\t\tClient: client,\n\t\t\t}\n\t\t\tif test.SetupProvider != nil {\n\t\t\t\ttest.SetupProvider(provider)\n\t\t\t}\n\n\t\t\tcurrent, err := provider.Records(t.Context())\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"should not fail, %s\", err)\n\t\t\t}\n\t\t\tassert.Len(t, current, 1)\n\n\t\t\tdesired := []*endpoint.Endpoint{}\n\t\t\tfor _, c := range current {\n\t\t\t\t// Copy all except ProviderSpecific fields\n\t\t\t\tdesired = append(desired, &endpoint.Endpoint{\n\t\t\t\t\tDNSName:       c.DNSName,\n\t\t\t\t\tTargets:       c.Targets,\n\t\t\t\t\tRecordType:    c.RecordType,\n\t\t\t\t\tSetIdentifier: c.SetIdentifier,\n\t\t\t\t\tRecordTTL:     c.RecordTTL,\n\t\t\t\t\tLabels:        c.Labels,\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tdesired, err = provider.AdjustEndpoints(desired)\n\t\t\tassert.NoError(t, err)\n\n\t\t\tplan := plan.Plan{\n\t\t\t\tCurrent:        current,\n\t\t\t\tDesired:        desired,\n\t\t\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},\n\t\t\t}\n\n\t\t\tplan = *plan.Calculate()\n\t\t\trequire.NotNil(t, plan.Changes, \"should have plan\")\n\t\t\tassert.Empty(t, plan.Changes.Create, \"should not have creates\")\n\t\t\tassert.Empty(t, plan.Changes.Delete, \"should not have deletes\")\n\n\t\t\tif test.ShouldBeUpdated {\n\t\t\t\tassert.Len(t, plan.Changes.UpdateOld, 1, \"should have old updates\")\n\t\t\t\trequire.Len(t, plan.Changes.UpdateNew, 1, \"should have new updates\")\n\t\t\t\tif test.PropertyKey != \"\" {\n\t\t\t\t\tvalue, ok := plan.Changes.UpdateNew[0].GetProviderSpecificProperty(test.PropertyKey)\n\t\t\t\t\tif test.ExpectPropertyPresent || test.ExpectPropertyValue != \"\" {\n\t\t\t\t\t\tassert.Truef(t, ok, \"should have property %s\", test.PropertyKey)\n\t\t\t\t\t\tassert.Equal(t, test.ExpectPropertyValue, value)\n\t\t\t\t\t} else {\n\t\t\t\t\t\tassert.Falsef(t, ok, \"should not have property %s\", test.PropertyKey)\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tassert.Empty(t, test.ExpectPropertyValue, \"test misconfigured, should not expect property value if no property key set\")\n\t\t\t\t\tassert.False(t, test.ExpectPropertyPresent, \"test misconfigured, should not expect property presence if no property key set\")\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tassert.Empty(t, plan.Changes.UpdateNew, \"should not have new updates\")\n\t\t\t\tassert.Empty(t, plan.Changes.UpdateOld, \"should not have old updates\")\n\t\t\t\tassert.Empty(t, test.PropertyKey, \"test misconfigured, should not expect property if no update expected\")\n\t\t\t\tassert.Empty(t, test.ExpectPropertyValue, \"test misconfigured, should not expect property value if no update expected\")\n\t\t\t\tassert.False(t, test.ExpectPropertyPresent, \"test misconfigured, should not expect property presence if no update expected\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCloudflareComplexUpdate(t *testing.T) {\n\tclient := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{\n\t\t\"001\": ExampleDomain,\n\t})\n\n\tprovider := &CloudFlareProvider{\n\t\tClient: client,\n\t}\n\tctx := t.Context()\n\n\trecords, err := provider.Records(ctx)\n\tif err != nil {\n\t\tt.Errorf(\"should not fail, %s\", err)\n\t}\n\n\tdomainFilter := endpoint.NewDomainFilter([]string{\"bar.com\"})\n\tendpoints, err := provider.AdjustEndpoints([]*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"foobar.bar.com\",\n\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\", \"2.3.4.5\"},\n\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\tRecordTTL:  endpoint.TTL(defaultTTL),\n\t\t\tLabels:     endpoint.Labels{},\n\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t{\n\t\t\t\t\tName:  \"external-dns.alpha.kubernetes.io/cloudflare-proxied\",\n\t\t\t\t\tValue: \"true\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t})\n\tassert.NoError(t, err)\n\tplan := &plan.Plan{\n\t\tCurrent:        records,\n\t\tDesired:        endpoints,\n\t\tDomainFilter:   endpoint.MatchAllDomainFilters{domainFilter},\n\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},\n\t}\n\n\tplanned := plan.Calculate()\n\n\terr = provider.ApplyChanges(t.Context(), planned.Changes)\n\tif err != nil {\n\t\tt.Errorf(\"should not fail, %s\", err)\n\t}\n\n\ttd.CmpDeeply(t, client.Actions, []MockAction{\n\t\t{\n\t\t\tName:     \"Delete\",\n\t\t\tZoneId:   \"001\",\n\t\t\tRecordId: \"2345678901\",\n\t\t},\n\t\t{\n\t\t\tName:     \"Update\",\n\t\t\tZoneId:   \"001\",\n\t\t\tRecordId: \"1234567890\",\n\t\t\tRecordData: dns.RecordResponse{\n\t\t\t\tID:      \"1234567890\",\n\t\t\t\tName:    \"foobar.bar.com\",\n\t\t\t\tType:    \"A\",\n\t\t\t\tContent: \"1.2.3.4\",\n\t\t\t\tTTL:     1,\n\t\t\t\tProxied: true,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:     \"Create\",\n\t\t\tZoneId:   \"001\",\n\t\t\tRecordId: generateDNSRecordID(\"A\", \"foobar.bar.com\", \"2.3.4.5\"),\n\t\t\tRecordData: dns.RecordResponse{\n\t\t\t\tID:      generateDNSRecordID(\"A\", \"foobar.bar.com\", \"2.3.4.5\"),\n\t\t\t\tName:    \"foobar.bar.com\",\n\t\t\t\tType:    \"A\",\n\t\t\t\tContent: \"2.3.4.5\",\n\t\t\t\tTTL:     1,\n\t\t\t\tProxied: true,\n\t\t\t},\n\t\t},\n\t})\n}\n\nfunc TestCustomTTLWithEnabledProxyNotChanged(t *testing.T) {\n\tclient := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{\n\t\t\"001\": {\n\t\t\t{\n\t\t\t\tID:      \"1234567890\",\n\t\t\t\tName:    \"foobar.bar.com\",\n\t\t\t\tType:    endpoint.RecordTypeA,\n\t\t\t\tTTL:     1,\n\t\t\t\tContent: \"1.2.3.4\",\n\t\t\t\tProxied: true,\n\t\t\t},\n\t\t},\n\t})\n\n\tprovider := &CloudFlareProvider{\n\t\tClient: client,\n\t}\n\n\trecords, err := provider.Records(t.Context())\n\tif err != nil {\n\t\tt.Errorf(\"should not fail, %s\", err)\n\t}\n\n\tendpoints := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"foobar.bar.com\",\n\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\tRecordTTL:  300,\n\t\t\tLabels:     endpoint.Labels{},\n\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t{\n\t\t\t\t\tName:  \"external-dns.alpha.kubernetes.io/cloudflare-proxied\",\n\t\t\t\t\tValue: \"true\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tprovider.AdjustEndpoints(endpoints)\n\n\tdomainFilter := endpoint.NewDomainFilter([]string{\"bar.com\"})\n\tplan := &plan.Plan{\n\t\tCurrent:        records,\n\t\tDesired:        endpoints,\n\t\tDomainFilter:   endpoint.MatchAllDomainFilters{domainFilter},\n\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},\n\t}\n\n\tplanned := plan.Calculate()\n\n\tassert.Empty(t, planned.Changes.Create, \"no new changes should be here\")\n\tassert.Empty(t, planned.Changes.UpdateNew, \"no new changes should be here\")\n\tassert.Empty(t, planned.Changes.UpdateOld, \"no new changes should be here\")\n\tassert.Empty(t, planned.Changes.Delete, \"no new changes should be here\")\n}\n\nfunc TestCloudFlareProvider_Region(t *testing.T) {\n\ttestutils.TestHelperEnvSetter(t, map[string]string{\n\t\tcfAPITokenEnvKey: \"abc123def\",\n\t\tcfAPIEmailEnvKey: \"test@test.com\",\n\t})\n\tprovider, err := newProvider(\n\t\tendpoint.NewDomainFilter([]string{\"example.com\"}),\n\t\tprovider.ZoneIDFilter{},\n\t\ttrue,\n\t\tfalse,\n\t\tRegionalServicesConfig{Enabled: false, RegionKey: \"us\"},\n\t\tCustomHostnamesConfig{Enabled: false},\n\t\tDNSRecordsConfig{PerPage: 50, Comment: \"\"},\n\t)\n\tassert.NoError(t, err, \"should not fail to create provider\")\n\tassert.True(t, provider.RegionalServicesConfig.Enabled, \"expect regional services to be enabled\")\n\tassert.Equal(t, \"us\", provider.RegionalServicesConfig.RegionKey, \"expected region key to be 'us'\")\n}\nfunc TestCloudFlareProvider_newCloudFlareChange(t *testing.T) {\n\tt.Parallel()\n\n\tcomment := string(make([]byte, paidZoneMaxCommentLength+1))\n\tfreeValidComment := comment[:freeZoneMaxCommentLength]\n\tfreeInvalidComment := comment[:freeZoneMaxCommentLength+1]\n\tpaidValidComment := comment[:paidZoneMaxCommentLength]\n\tpaidInvalidComment := comment[:paidZoneMaxCommentLength+1]\n\n\tfreeProvider := &CloudFlareProvider{\n\t\tClient:                 NewMockCloudFlareClient(),\n\t\tdomainFilter:           endpoint.NewDomainFilter([]string{\"example.com\"}),\n\t\tRegionalServicesConfig: RegionalServicesConfig{Enabled: true, RegionKey: \"us\"},\n\t}\n\tpaidProvider := &CloudFlareProvider{\n\t\tClient:                 NewMockCloudFlareClient(),\n\t\tdomainFilter:           endpoint.NewDomainFilter([]string{\"bar.com\"}),\n\t\tRegionalServicesConfig: RegionalServicesConfig{Enabled: true, RegionKey: \"us\"},\n\t\tDNSRecordsConfig:       DNSRecordsConfig{Comment: paidValidComment},\n\t}\n\n\tep := &endpoint.Endpoint{\n\t\tDNSName:    \"example.com\",\n\t\tRecordType: \"A\",\n\t\tTargets:    []string{\"192.0.2.1\"},\n\t}\n\n\tchange, _ := freeProvider.newCloudFlareChange(cloudFlareCreate, ep, ep.Targets[0], nil)\n\tif change.RegionalHostname.regionKey != \"us\" {\n\t\tt.Errorf(\"expected region key to be 'us', but got '%s'\", change.RegionalHostname.regionKey)\n\t}\n\n\tcommentTestCases := []struct {\n\t\tname     string\n\t\tprovider *CloudFlareProvider\n\t\tendpoint *endpoint.Endpoint\n\t\texpected int\n\t}{\n\t\t{\n\t\t\tname:     \"For free Zone respecting comment length, expect no trimming\",\n\t\t\tprovider: freeProvider,\n\t\t\tendpoint: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"example.com\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tTargets:    []string{\"192.0.2.1\"},\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  annotations.CloudflareRecordCommentKey,\n\t\t\t\t\t\tValue: freeValidComment,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: len(freeValidComment),\n\t\t},\n\t\t{\n\t\t\tname:     \"For free Zones not respecting comment length, expect trimmed comments\",\n\t\t\tprovider: freeProvider,\n\t\t\tendpoint: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"example.com\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tTargets:    []string{\"192.0.2.1\"},\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  annotations.CloudflareRecordCommentKey,\n\t\t\t\t\t\tValue: freeInvalidComment,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: freeZoneMaxCommentLength,\n\t\t},\n\t\t{\n\t\t\tname:     \"For paid Zones respecting comment length, expect no trimming\",\n\t\t\tprovider: paidProvider,\n\t\t\tendpoint: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"bar.com\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tTargets:    []string{\"192.0.2.1\"},\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  annotations.CloudflareRecordCommentKey,\n\t\t\t\t\t\tValue: paidValidComment,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: len(paidValidComment),\n\t\t},\n\t\t{\n\t\t\tname:     \"For paid Zones not respecting comment length, expect trimmed comments\",\n\t\t\tprovider: paidProvider,\n\t\t\tendpoint: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"bar.com\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tTargets:    []string{\"192.0.2.1\"},\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  annotations.CloudflareRecordCommentKey,\n\t\t\t\t\t\tValue: paidInvalidComment,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: paidZoneMaxCommentLength,\n\t\t},\n\t}\n\n\tfor _, test := range commentTestCases {\n\t\tt.Run(test.name, func(t *testing.T) {\n\t\t\tt.Parallel()\n\t\t\tchange, err := test.provider.newCloudFlareChange(cloudFlareCreate, test.endpoint, test.endpoint.Targets[0], nil)\n\t\t\tassert.NoError(t, err)\n\t\t\tif len(change.ResourceRecord.Comment) != test.expected {\n\t\t\t\tt.Errorf(\"expected comment to be %d characters long, but got %d\", test.expected, len(change.ResourceRecord.Comment))\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCloudFlareProvider_submitChangesCNAME(t *testing.T) {\n\tclient := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{\n\t\t\"001\": {\n\t\t\t{\n\t\t\t\tID:      \"1234567890\",\n\t\t\t\tName:    \"my-domain-here.app\",\n\t\t\t\tType:    endpoint.RecordTypeCNAME,\n\t\t\t\tTTL:     1,\n\t\t\t\tContent: \"my-tunnel-guid-here.cfargotunnel.com\",\n\t\t\t\tProxied: true,\n\t\t\t},\n\t\t\t{\n\t\t\t\tID:      \"9876543210\",\n\t\t\t\tName:    \"my-domain-here.app\",\n\t\t\t\tType:    endpoint.RecordTypeTXT,\n\t\t\t\tTTL:     1,\n\t\t\t\tContent: \"heritage=external-dns,external-dns/owner=default,external-dns/resource=service/external-dns/my-domain-here-app\",\n\t\t\t},\n\t\t},\n\t})\n\t// zoneIdFilter := provider.NewZoneIDFilter([]string{\"001\"})\n\tprovider := &CloudFlareProvider{\n\t\tClient: client,\n\t}\n\n\tchanges := []*cloudFlareChange{\n\t\t{\n\t\t\tAction: cloudFlareUpdate,\n\t\t\tResourceRecord: dns.RecordResponse{\n\t\t\t\tName:    \"my-domain-here.app\",\n\t\t\t\tType:    endpoint.RecordTypeCNAME,\n\t\t\t\tID:      \"1234567890\",\n\t\t\t\tContent: \"my-tunnel-guid-here.cfargotunnel.com\",\n\t\t\t},\n\t\t\tRegionalHostname: regionalHostname{\n\t\t\t\thostname: \"my-domain-here.app\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tAction: cloudFlareUpdate,\n\t\t\tResourceRecord: dns.RecordResponse{\n\t\t\t\tName:    \"my-domain-here.app\",\n\t\t\t\tType:    endpoint.RecordTypeTXT,\n\t\t\t\tID:      \"9876543210\",\n\t\t\t\tContent: \"heritage=external-dns,external-dns/owner=default,external-dns/resource=service/external-dns/my-domain-here-app\",\n\t\t\t},\n\t\t\tRegionalHostname: regionalHostname{\n\t\t\t\thostname:  \"my-domain-here.app\",\n\t\t\t\tregionKey: \"\",\n\t\t\t},\n\t\t},\n\t}\n\n\t// Should not return an error\n\terr := provider.submitChanges(t.Context(), changes)\n\tif err != nil {\n\t\tt.Errorf(\"should not fail, %s\", err)\n\t}\n}\n\nfunc TestCloudFlareProvider_submitChangesApex(t *testing.T) {\n\t// Create a mock CloudFlare client with APEX records\n\tclient := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{\n\t\t\"001\": {\n\t\t\t{\n\t\t\t\tID:      \"1234567890\",\n\t\t\t\tName:    \"@\", // APEX record\n\t\t\t\tType:    endpoint.RecordTypeCNAME,\n\t\t\t\tTTL:     1,\n\t\t\t\tContent: \"my-tunnel-guid-here.cfargotunnel.com\",\n\t\t\t\tProxied: true,\n\t\t\t},\n\t\t\t{\n\t\t\t\tID:      \"9876543210\",\n\t\t\t\tName:    \"@\", // APEX record\n\t\t\t\tType:    endpoint.RecordTypeTXT,\n\t\t\t\tTTL:     1,\n\t\t\t\tContent: \"heritage=external-dns,external-dns/owner=default,external-dns/resource=service/external-dns/my-domain-here-app\",\n\t\t\t},\n\t\t},\n\t})\n\n\t// Create a CloudFlare provider instance\n\tprovider := &CloudFlareProvider{\n\t\tClient: client,\n\t}\n\n\t// Define changes to submit\n\tchanges := []*cloudFlareChange{\n\t\t{\n\t\t\tAction: cloudFlareUpdate,\n\t\t\tResourceRecord: dns.RecordResponse{\n\t\t\t\tName:    \"@\", // APEX record\n\t\t\t\tType:    endpoint.RecordTypeCNAME,\n\t\t\t\tID:      \"1234567890\",\n\t\t\t\tContent: \"my-tunnel-guid-here.cfargotunnel.com\",\n\t\t\t},\n\t\t\tRegionalHostname: regionalHostname{\n\t\t\t\thostname: \"@\", // APEX record\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tAction: cloudFlareUpdate,\n\t\t\tResourceRecord: dns.RecordResponse{\n\t\t\t\tName:    \"@\", // APEX record\n\t\t\t\tType:    endpoint.RecordTypeTXT,\n\t\t\t\tID:      \"9876543210\",\n\t\t\t\tContent: \"heritage=external-dns,external-dns/owner=default,external-dns/resource=service/external-dns/my-domain-here-app\",\n\t\t\t},\n\t\t\tRegionalHostname: regionalHostname{\n\t\t\t\thostname:  \"@\", // APEX record\n\t\t\t\tregionKey: \"\",\n\t\t\t},\n\t\t},\n\t}\n\n\t// Submit changes and verify no error is returned\n\terr := provider.submitChanges(t.Context(), changes)\n\tif err != nil {\n\t\tt.Errorf(\"should not fail, %s\", err)\n\t}\n}\n\nfunc TestCloudflareZoneRecordsFail(t *testing.T) {\n\tclient := &mockCloudFlareClient{\n\t\tZones: map[string]string{\n\t\t\t\"newerror-001\": \"bar.com\",\n\t\t},\n\t\tRecords:         map[string]map[string]dns.RecordResponse{},\n\t\tcustomHostnames: map[string][]customHostname{},\n\t}\n\tfailingProvider := &CloudFlareProvider{\n\t\tClient:                client,\n\t\tCustomHostnamesConfig: CustomHostnamesConfig{Enabled: true},\n\t}\n\tctx := t.Context()\n\n\t_, err := failingProvider.Records(ctx)\n\tif err == nil {\n\t\tt.Errorf(\"should fail - invalid zone id, %s\", err)\n\t}\n}\n\n// TestCloudflareLongRecordsErrorLog checks if the error is logged when a record name exceeds 63 characters\n// it's not likely to happen in practice, as the Cloudflare API should reject having it\nfunc TestCloudflareLongRecordsErrorLog(t *testing.T) {\n\tclient := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{\n\t\t\"001\": {\n\t\t\t{\n\t\t\t\tID:      \"1234567890\",\n\t\t\t\tName:    \"very-very-very-very-very-very-very-long-name-more-than-63-bytes-long.bar.com\",\n\t\t\t\tType:    endpoint.RecordTypeTXT,\n\t\t\t\tTTL:     120,\n\t\t\t\tContent: \"some-content\",\n\t\t\t},\n\t\t},\n\t})\n\thook := logtest.LogsUnderTestWithLogLevel(log.InfoLevel, t)\n\tp := &CloudFlareProvider{\n\t\tClient:                client,\n\t\tCustomHostnamesConfig: CustomHostnamesConfig{Enabled: true},\n\t}\n\tctx := t.Context()\n\t_, err := p.Records(ctx)\n\tif err != nil {\n\t\tt.Errorf(\"should not fail - too long record, %s\", err)\n\t}\n\tlogtest.TestHelperLogContains(\"s longer than 63 characters. Cannot create endpoint\", hook, t)\n}\n\n// check if the error is expected\nfunc checkFailed(name string, err error, shouldFail bool) error {\n\tif errors.Is(err, nil) && shouldFail {\n\t\treturn fmt.Errorf(\"should fail - %q\", name)\n\t}\n\tif !errors.Is(err, nil) && !shouldFail {\n\t\treturn fmt.Errorf(\"should not fail - %q, %w\", name, err)\n\t}\n\treturn nil\n}\n\nfunc TestCloudflareDNSRecordsOperationsFail(t *testing.T) {\n\tclient := NewMockCloudFlareClient()\n\tprovider := &CloudFlareProvider{\n\t\tClient:                client,\n\t\tCustomHostnamesConfig: CustomHostnamesConfig{Enabled: true},\n\t}\n\tctx := t.Context()\n\tdomainFilter := endpoint.NewDomainFilter([]string{\"bar.com\"})\n\n\ttestFailCases := []struct {\n\t\tName                    string\n\t\tEndpoints               []*endpoint.Endpoint\n\t\tExpectedCustomHostnames map[string]string\n\t\tshouldFail              bool\n\t}{\n\t\t{\n\t\t\tName: \"failing to create dns record\",\n\t\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"newerror.bar.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  endpoint.TTL(defaultTTL),\n\t\t\t\t\tLabels:     endpoint.Labels{},\n\t\t\t\t},\n\t\t\t},\n\t\t\tshouldFail: true,\n\t\t},\n\t\t{\n\t\t\tName: \"adding failing to list DNS record\",\n\t\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"newerror-list-1.foo.bar.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  endpoint.TTL(defaultTTL),\n\t\t\t\t\tLabels:     endpoint.Labels{},\n\t\t\t\t},\n\t\t\t},\n\t\t\tshouldFail: false,\n\t\t},\n\t\t{\n\t\t\tName:       \"causing to list failing to list DNS record\",\n\t\t\tEndpoints:  []*endpoint.Endpoint{},\n\t\t\tshouldFail: true,\n\t\t},\n\t\t{\n\t\t\tName: \"create failing to update DNS record\",\n\t\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"newerror-update-1.foo.bar.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  endpoint.TTL(defaultTTL),\n\t\t\t\t\tLabels:     endpoint.Labels{},\n\t\t\t\t},\n\t\t\t},\n\t\t\tshouldFail: false,\n\t\t},\n\t\t{\n\t\t\tName: \"failing to update DNS record\",\n\t\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"newerror-update-1.foo.bar.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  1234,\n\t\t\t\t\tLabels:     endpoint.Labels{},\n\t\t\t\t},\n\t\t\t},\n\t\t\tshouldFail: true,\n\t\t},\n\t\t{\n\t\t\tName: \"create failing to delete DNS record\",\n\t\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"newerror-delete-1.foo.bar.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  1234,\n\t\t\t\t\tLabels:     endpoint.Labels{},\n\t\t\t\t},\n\t\t\t},\n\t\t\tshouldFail: false,\n\t\t},\n\t\t{\n\t\t\tName:       \"failing to delete erroring DNS record\",\n\t\t\tEndpoints:  []*endpoint.Endpoint{},\n\t\t\tshouldFail: true,\n\t\t},\n\t}\n\n\tfor _, tc := range testFailCases {\n\t\tt.Run(tc.Name, func(t *testing.T) {\n\t\t\tvar err error\n\t\t\tvar records, endpoints []*endpoint.Endpoint\n\n\t\t\trecords, err = provider.Records(ctx)\n\t\t\tif errors.Is(err, nil) {\n\t\t\t\tendpoints, err = provider.AdjustEndpoints(tc.Endpoints)\n\t\t\t}\n\t\t\tif errors.Is(err, nil) {\n\t\t\t\tplan := &plan.Plan{\n\t\t\t\t\tCurrent:        records,\n\t\t\t\t\tDesired:        endpoints,\n\t\t\t\t\tDomainFilter:   endpoint.MatchAllDomainFilters{domainFilter},\n\t\t\t\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},\n\t\t\t\t}\n\t\t\t\tplanned := plan.Calculate()\n\t\t\t\terr = provider.ApplyChanges(t.Context(), planned.Changes)\n\t\t\t}\n\t\t\tif e := checkFailed(tc.Name, err, tc.shouldFail); !errors.Is(e, nil) {\n\t\t\t\tt.Error(e)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestZoneHasPaidPlan(t *testing.T) {\n\tclient := NewMockCloudFlareClient()\n\tcfprovider := &CloudFlareProvider{\n\t\tClient:       client,\n\t\tdomainFilter: endpoint.NewDomainFilter([]string{\"foo.com\", \"bar.com\"}),\n\t\tzoneIDFilter: provider.NewZoneIDFilter([]string{\"\"}),\n\t}\n\n\tassert.False(t, cfprovider.ZoneHasPaidPlan(\"subdomain.foo.com\"))\n\tassert.True(t, cfprovider.ZoneHasPaidPlan(\"subdomain.bar.com\"))\n\tassert.False(t, cfprovider.ZoneHasPaidPlan(\"invaliddomain\"))\n\n\tclient.getZoneError = errors.New(\"zone lookup failed\")\n\tcfproviderWithZoneError := &CloudFlareProvider{\n\t\tClient:       client,\n\t\tdomainFilter: endpoint.NewDomainFilter([]string{\"foo.com\", \"bar.com\"}),\n\t\tzoneIDFilter: provider.NewZoneIDFilter([]string{\"\"}),\n\t}\n\tassert.False(t, cfproviderWithZoneError.ZoneHasPaidPlan(\"subdomain.foo.com\"))\n}\n\nfunc TestCloudflareApplyChanges_AllErrorLogPaths(t *testing.T) {\n\thook := logtest.LogsUnderTestWithLogLevel(log.ErrorLevel, t)\n\n\tclient := NewMockCloudFlareClient()\n\tprovider := &CloudFlareProvider{\n\t\tClient: client,\n\t}\n\n\tcases := []struct {\n\t\tname                   string\n\t\tchanges                *plan.Changes\n\t\tcustomHostnamesEnabled bool\n\t\terrorLogCount          int\n\t}{\n\t\t{\n\t\t\tname: \"Create error (custom hostnames enabled)\",\n\t\t\tchanges: &plan.Changes{\n\t\t\t\tCreate: []*endpoint.Endpoint{{\n\t\t\t\t\tDNSName:    \"bad-create.bar.com\",\n\t\t\t\t\tRecordType: \"MX\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"not-a-valid-mx\"},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:  \"external-dns.alpha.kubernetes.io/cloudflare-custom-hostname\",\n\t\t\t\t\t\t\tValue: \"bad-create-custom.bar.com\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t}},\n\t\t\t},\n\t\t\tcustomHostnamesEnabled: true,\n\t\t\terrorLogCount:          1,\n\t\t},\n\t\t{\n\t\t\tname: \"Delete error (custom hostnames enabled)\",\n\t\t\tchanges: &plan.Changes{\n\t\t\t\tDelete: []*endpoint.Endpoint{{\n\t\t\t\t\tDNSName:    \"bad-delete.bar.com\",\n\t\t\t\t\tRecordType: \"MX\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"not-a-valid-mx\"},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:  \"external-dns.alpha.kubernetes.io/cloudflare-custom-hostname\",\n\t\t\t\t\t\t\tValue: \"bad-delete-custom.bar.com\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t}},\n\t\t\t},\n\t\t\tcustomHostnamesEnabled: true,\n\t\t\terrorLogCount:          1,\n\t\t},\n\t\t{\n\t\t\tname: \"Update add/remove error (custom hostnames enabled)\",\n\t\t\tchanges: &plan.Changes{\n\t\t\t\tUpdateNew: []*endpoint.Endpoint{{\n\t\t\t\t\tDNSName:    \"bad-update-add.bar.com\",\n\t\t\t\t\tRecordType: \"MX\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"not-a-valid-mx\"},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:  \"external-dns.alpha.kubernetes.io/cloudflare-custom-hostname\",\n\t\t\t\t\t\t\tValue: \"bad-update-add-custom.bar.com\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t}},\n\t\t\t\tUpdateOld: []*endpoint.Endpoint{{\n\t\t\t\t\tDNSName:    \"old-bad-update-add.bar.com\",\n\t\t\t\t\tRecordType: \"MX\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"not-a-valid-mx-but-still-updated\"},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:  \"external-dns.alpha.kubernetes.io/cloudflare-custom-hostname\",\n\t\t\t\t\t\t\tValue: \"bad-update-add-custom.bar.com\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t}},\n\t\t\t},\n\t\t\tcustomHostnamesEnabled: true,\n\t\t\terrorLogCount:          2,\n\t\t},\n\t\t{\n\t\t\tname: \"Update leave error (custom hostnames enabled)\",\n\t\t\tchanges: &plan.Changes{\n\t\t\t\tUpdateOld: []*endpoint.Endpoint{{\n\t\t\t\t\tDNSName:    \"bad-update-leave.bar.com\",\n\t\t\t\t\tRecordType: \"MX\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"not-a-valid-mx\"},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:  \"external-dns.alpha.kubernetes.io/cloudflare-custom-hostname\",\n\t\t\t\t\t\t\tValue: \"bad-update-leave-custom.bar.com\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t}},\n\t\t\t\tUpdateNew: []*endpoint.Endpoint{{\n\t\t\t\t\tDNSName:    \"bad-update-leave.bar.com\",\n\t\t\t\t\tRecordType: \"MX\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"not-a-valid-mx\"},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:  \"external-dns.alpha.kubernetes.io/cloudflare-custom-hostname\",\n\t\t\t\t\t\t\tValue: \"bad-update-leave-custom.bar.com\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t}},\n\t\t\t},\n\t\t\tcustomHostnamesEnabled: true,\n\t\t\terrorLogCount:          1,\n\t\t},\n\t\t{\n\t\t\tname: \"Delete error (custom hostnames disabled)\",\n\t\t\tchanges: &plan.Changes{\n\t\t\t\tDelete: []*endpoint.Endpoint{{\n\t\t\t\t\tDNSName:    \"bad-delete2.bar.com\",\n\t\t\t\t\tRecordType: \"MX\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"not-a-valid-mx\"},\n\t\t\t\t}},\n\t\t\t},\n\t\t\tcustomHostnamesEnabled: false,\n\t\t\terrorLogCount:          1,\n\t\t},\n\t}\n\n\t// Test with custom hostnames enabled and disabled\n\tfor _, tc := range cases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif tc.customHostnamesEnabled {\n\t\t\t\tprovider.CustomHostnamesConfig = CustomHostnamesConfig{Enabled: true}\n\t\t\t} else {\n\t\t\t\tprovider.CustomHostnamesConfig = CustomHostnamesConfig{Enabled: false}\n\t\t\t}\n\t\t\thook.Reset()\n\t\t\terr := provider.ApplyChanges(t.Context(), tc.changes)\n\t\t\tassert.NoError(t, err, \"ApplyChanges should not return error for newCloudFlareChange error (it should log and continue)\")\n\t\t\terrorLogCount := 0\n\t\t\tfor _, entry := range hook.Entries {\n\t\t\t\tif entry.Level == log.ErrorLevel &&\n\t\t\t\t\tstrings.Contains(entry.Message, \"failed to create cloudflare change\") {\n\t\t\t\t\terrorLogCount++\n\t\t\t\t}\n\t\t\t}\n\t\t\tassert.Equal(t, tc.errorLogCount, errorLogCount, \"expected error log count for %s\", tc.name)\n\t\t})\n\t}\n}\n\nfunc TestCloudFlareProvider_SupportedAdditionalRecordTypes(t *testing.T) {\n\tprovider := &CloudFlareProvider{}\n\n\ttests := []struct {\n\t\trecordType string\n\t\texpected   bool\n\t}{\n\t\t{endpoint.RecordTypeMX, true},\n\t\t{endpoint.RecordTypeA, true},\n\t\t{endpoint.RecordTypeCNAME, true},\n\t\t{endpoint.RecordTypeTXT, true},\n\t\t{endpoint.RecordTypeNS, true},\n\t\t{\"SRV\", true},\n\t\t{\"SPF\", false},\n\t\t{\"LOC\", false},\n\t\t{\"UNKNOWN\", false},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.recordType, func(t *testing.T) {\n\t\t\tresult := provider.SupportedAdditionalRecordTypes(tt.recordType)\n\t\t\tassert.Equal(t, tt.expected, result)\n\t\t})\n\t}\n}\n\nfunc TestCloudflareZoneChanges(t *testing.T) {\n\tclient := NewMockCloudFlareClient()\n\tcfProvider := &CloudFlareProvider{\n\t\tClient:       client,\n\t\tdomainFilter: endpoint.NewDomainFilter([]string{\"foo.com\", \"bar.com\"}),\n\t\tzoneIDFilter: provider.NewZoneIDFilter([]string{\"\"}),\n\t}\n\n\t// Test zone listing and filtering\n\tzones, err := cfProvider.Zones(t.Context())\n\tassert.NoError(t, err)\n\tassert.Len(t, zones, 2)\n\n\t// Verify zone names\n\tzoneNames := make([]string, len(zones))\n\tfor i, zone := range zones {\n\t\tzoneNames[i] = zone.Name\n\t}\n\tassert.Contains(t, zoneNames, \"foo.com\")\n\tassert.Contains(t, zoneNames, \"bar.com\")\n\n\t// Test zone filtering with specific zone ID\n\tproviderWithZoneFilter := &CloudFlareProvider{\n\t\tClient:       client,\n\t\tdomainFilter: endpoint.NewDomainFilter([]string{\"foo.com\", \"bar.com\"}),\n\t\tzoneIDFilter: provider.NewZoneIDFilter([]string{\"001\"}),\n\t}\n\n\tfilteredZones, err := providerWithZoneFilter.Zones(t.Context())\n\tassert.NoError(t, err)\n\tassert.Len(t, filteredZones, 1)\n\tassert.Equal(t, \"bar.com\", filteredZones[0].Name) // zone 001 is bar.com\n\tassert.Equal(t, \"001\", filteredZones[0].ID)\n\n\t// Test zone changes grouping\n\tchanges := []*cloudFlareChange{\n\t\t{\n\t\t\tAction:         cloudFlareCreate,\n\t\t\tResourceRecord: dns.RecordResponse{Name: \"test1.foo.com\", Type: \"A\", Content: \"1.2.3.4\"},\n\t\t},\n\t\t{\n\t\t\tAction:         cloudFlareCreate,\n\t\t\tResourceRecord: dns.RecordResponse{Name: \"test2.foo.com\", Type: \"A\", Content: \"1.2.3.5\"},\n\t\t},\n\t\t{\n\t\t\tAction:         cloudFlareCreate,\n\t\t\tResourceRecord: dns.RecordResponse{Name: \"test1.bar.com\", Type: \"A\", Content: \"1.2.3.6\"},\n\t\t},\n\t}\n\n\tchangesByZone := cfProvider.changesByZone(zones, changes)\n\tassert.Len(t, changesByZone, 2)\n\tassert.Len(t, changesByZone[\"001\"], 1) // bar.com zone (test1.bar.com)\n\tassert.Len(t, changesByZone[\"002\"], 2) // foo.com zone (test1.foo.com, test2.foo.com)\n\n\t// Test paid plan detection\n\tassert.False(t, cfProvider.ZoneHasPaidPlan(\"subdomain.foo.com\")) // free plan\n\tassert.True(t, cfProvider.ZoneHasPaidPlan(\"subdomain.bar.com\"))  // paid plan\n}\n\nfunc TestCloudflareZoneErrors(t *testing.T) {\n\tclient := NewMockCloudFlareClient()\n\n\t// Test list zones error\n\tclient.listZonesError = errors.New(\"failed to list zones\")\n\tcfProvider := &CloudFlareProvider{\n\t\tClient: client,\n\t}\n\n\tzones, err := cfProvider.Zones(t.Context())\n\tassert.Error(t, err)\n\tassert.Contains(t, err.Error(), \"failed to list zones\")\n\tassert.Nil(t, zones)\n\n\t// Test get zone error\n\tclient.listZonesError = nil\n\tclient.getZoneError = errors.New(\"failed to get zone\")\n\n\t// This should still work for listing but fail when getting individual zones\n\tzones, err = cfProvider.Zones(t.Context())\n\tassert.NoError(t, err) // List works, individual gets may fail internally\n\tassert.NotNil(t, zones)\n}\n\nfunc TestCloudflareZoneFiltering(t *testing.T) {\n\tclient := NewMockCloudFlareClient()\n\n\t// Test with domain filter only\n\tcfProvider := &CloudFlareProvider{\n\t\tClient:       client,\n\t\tdomainFilter: endpoint.NewDomainFilter([]string{\"foo.com\"}),\n\t\tzoneIDFilter: provider.NewZoneIDFilter([]string{\"\"}),\n\t}\n\n\tzones, err := cfProvider.Zones(t.Context())\n\tassert.NoError(t, err)\n\tassert.Len(t, zones, 1)\n\tassert.Equal(t, \"foo.com\", zones[0].Name)\n\n\t// Test with zone ID filter\n\tproviderWithIDFilter := &CloudFlareProvider{\n\t\tClient:       client,\n\t\tdomainFilter: endpoint.NewDomainFilter([]string{}),\n\t\tzoneIDFilter: provider.NewZoneIDFilter([]string{\"002\"}),\n\t}\n\n\tfilteredZones, err := providerWithIDFilter.Zones(t.Context())\n\tassert.NoError(t, err)\n\tassert.Len(t, filteredZones, 1)\n\tassert.Equal(t, \"foo.com\", filteredZones[0].Name) // zone 002 is foo.com\n\tassert.Equal(t, \"002\", filteredZones[0].ID)\n}\n\nfunc TestCloudflareZonePlanDetection(t *testing.T) {\n\tclient := NewMockCloudFlareClient()\n\tcfProvider := &CloudFlareProvider{\n\t\tClient:       client,\n\t\tdomainFilter: endpoint.NewDomainFilter([]string{\"foo.com\", \"bar.com\"}),\n\t\tzoneIDFilter: provider.NewZoneIDFilter([]string{\"\"}),\n\t}\n\n\t// Test free plan detection (foo.com)\n\tassert.False(t, cfProvider.ZoneHasPaidPlan(\"foo.com\"))\n\tassert.False(t, cfProvider.ZoneHasPaidPlan(\"subdomain.foo.com\"))\n\tassert.False(t, cfProvider.ZoneHasPaidPlan(\"deep.subdomain.foo.com\"))\n\n\t// Test paid plan detection (bar.com)\n\tassert.True(t, cfProvider.ZoneHasPaidPlan(\"bar.com\"))\n\tassert.True(t, cfProvider.ZoneHasPaidPlan(\"subdomain.bar.com\"))\n\tassert.True(t, cfProvider.ZoneHasPaidPlan(\"deep.subdomain.bar.com\"))\n\n\t// Test invalid domain\n\tassert.False(t, cfProvider.ZoneHasPaidPlan(\"invalid.domain.com\"))\n\n\t// Test with zone error\n\tclient.getZoneError = errors.New(\"zone lookup failed\")\n\tproviderWithError := &CloudFlareProvider{\n\t\tClient:       client,\n\t\tdomainFilter: endpoint.NewDomainFilter([]string{\"foo.com\", \"bar.com\"}),\n\t\tzoneIDFilter: provider.NewZoneIDFilter([]string{\"\"}),\n\t}\n\tassert.False(t, providerWithError.ZoneHasPaidPlan(\"subdomain.foo.com\"))\n}\n\nfunc TestCloudflareChangesByZone(t *testing.T) {\n\tclient := NewMockCloudFlareClient()\n\tcfProvider := &CloudFlareProvider{\n\t\tClient:       client,\n\t\tdomainFilter: endpoint.NewDomainFilter([]string{\"foo.com\", \"bar.com\"}),\n\t\tzoneIDFilter: provider.NewZoneIDFilter([]string{\"\"}),\n\t}\n\n\tzones, err := cfProvider.Zones(t.Context())\n\tassert.NoError(t, err)\n\tassert.Len(t, zones, 2)\n\n\t// Test empty changes\n\temptyChanges := []*cloudFlareChange{}\n\tchangesByZone := cfProvider.changesByZone(zones, emptyChanges)\n\tassert.Len(t, changesByZone, 2)       // Should return map with zones but empty slices\n\tassert.Empty(t, changesByZone[\"001\"]) // bar.com zone should have no changes\n\tassert.Empty(t, changesByZone[\"002\"]) // foo.com zone should have no changes\n\n\t// Test changes for different zones\n\tchanges := []*cloudFlareChange{\n\t\t{\n\t\t\tAction:         cloudFlareCreate,\n\t\t\tResourceRecord: dns.RecordResponse{Name: \"api.foo.com\", Type: \"A\", Content: \"1.2.3.4\"},\n\t\t},\n\t\t{\n\t\t\tAction:         cloudFlareUpdate,\n\t\t\tResourceRecord: dns.RecordResponse{Name: \"www.foo.com\", Type: \"CNAME\", Content: \"foo.com\"},\n\t\t},\n\t\t{\n\t\t\tAction:         cloudFlareCreate,\n\t\t\tResourceRecord: dns.RecordResponse{Name: \"mail.bar.com\", Type: \"MX\", Content: \"10 mail.bar.com\"},\n\t\t},\n\t\t{\n\t\t\tAction:         cloudFlareDelete,\n\t\t\tResourceRecord: dns.RecordResponse{Name: \"old.bar.com\", Type: \"A\", Content: \"5.6.7.8\"},\n\t\t},\n\t}\n\n\tchangesByZone = cfProvider.changesByZone(zones, changes)\n\tassert.Len(t, changesByZone, 2)\n\n\t// Verify bar.com zone changes (zone 001)\n\tbarChanges := changesByZone[\"001\"]\n\tassert.Len(t, barChanges, 2)\n\tassert.Equal(t, \"mail.bar.com\", barChanges[0].ResourceRecord.Name)\n\tassert.Equal(t, \"old.bar.com\", barChanges[1].ResourceRecord.Name)\n\n\t// Verify foo.com zone changes (zone 002)\n\tfooChanges := changesByZone[\"002\"]\n\tassert.Len(t, fooChanges, 2)\n\tassert.Equal(t, \"api.foo.com\", fooChanges[0].ResourceRecord.Name)\n\tassert.Equal(t, \"www.foo.com\", fooChanges[1].ResourceRecord.Name)\n}\n\nfunc TestConvertCloudflareError(t *testing.T) {\n\ttests := []struct {\n\t\tname            string\n\t\tinputError      error\n\t\texpectSoftError bool\n\t\tdescription     string\n\t}{\n\t\t{\n\t\t\tname:            \"Rate limit error via Error type\",\n\t\t\tinputError:      newCloudflareError(429),\n\t\t\texpectSoftError: true,\n\t\t\tdescription:     \"CloudFlare API rate limit error should be converted to soft error\",\n\t\t},\n\t\t{\n\t\t\tname:            \"Rate limit error via ClientRateLimited\",\n\t\t\tinputError:      newCloudflareError(429), // Complete rate limit error\n\t\t\texpectSoftError: true,\n\t\t\tdescription:     \"CloudFlare client rate limited error should be converted to soft error\",\n\t\t},\n\t\t{\n\t\t\tname:            \"Server error 500\",\n\t\t\tinputError:      newCloudflareError(500),\n\t\t\texpectSoftError: true,\n\t\t\tdescription:     \"Server error (500+) should be converted to soft error\",\n\t\t},\n\t\t{\n\t\t\tname:            \"Server error 502\",\n\t\t\tinputError:      newCloudflareError(502),\n\t\t\texpectSoftError: true,\n\t\t\tdescription:     \"Server error (502) should be converted to soft error\",\n\t\t},\n\t\t{\n\t\t\tname:            \"Server error 503\",\n\t\t\tinputError:      newCloudflareError(503),\n\t\t\texpectSoftError: true,\n\t\t\tdescription:     \"Server error (503) should be converted to soft error\",\n\t\t},\n\t\t{\n\t\t\tname:            \"io.ErrUnexpectedEOF is soft\",\n\t\t\tinputError:      io.ErrUnexpectedEOF,\n\t\t\texpectSoftError: true,\n\t\t\tdescription:     \"Unexpected EOF (connection closed mid-response) should be converted to soft error\",\n\t\t},\n\t\t{\n\t\t\tname:            \"io.EOF is soft\",\n\t\t\tinputError:      io.EOF,\n\t\t\texpectSoftError: true,\n\t\t\tdescription:     \"EOF (connection closed before response) should be converted to soft error\",\n\t\t},\n\t\t{\n\t\t\tname:            \"wrapped io.ErrUnexpectedEOF is soft\",\n\t\t\tinputError:      fmt.Errorf(\"transport error: %w\", io.ErrUnexpectedEOF),\n\t\t\texpectSoftError: true,\n\t\t\tdescription:     \"Wrapped unexpected EOF should be converted to soft error\",\n\t\t},\n\t\t{\n\t\t\tname:            \"Rate limit string error\",\n\t\t\tinputError:      errors.New(\"exceeded available rate limit retries\"),\n\t\t\texpectSoftError: true,\n\t\t\tdescription:     \"String error containing rate limit message should be converted to soft error\",\n\t\t},\n\t\t{\n\t\t\tname:            \"Rate limit string error mixed case\",\n\t\t\tinputError:      errors.New(\"request failed: exceeded available rate limit retries for this operation\"),\n\t\t\texpectSoftError: true,\n\t\t\tdescription:     \"String error containing rate limit message should be converted to soft error regardless of context\",\n\t\t},\n\t\t{\n\t\t\tname:            \"Client error 400\",\n\t\t\tinputError:      newCloudflareError(400),\n\t\t\texpectSoftError: false,\n\t\t\tdescription:     \"Client error (400) should not be converted to soft error\",\n\t\t},\n\t\t{\n\t\t\tname:            \"Client error 401\",\n\t\t\tinputError:      newCloudflareError(401),\n\t\t\texpectSoftError: false,\n\t\t\tdescription:     \"Client error (401) should not be converted to soft error\",\n\t\t},\n\t\t{\n\t\t\tname:            \"Client error 404\",\n\t\t\tinputError:      newCloudflareError(404),\n\t\t\texpectSoftError: false,\n\t\t\tdescription:     \"Client error (404) should not be converted to soft error\",\n\t\t},\n\t\t{\n\t\t\tname:            \"Generic error\",\n\t\t\tinputError:      errors.New(\"some generic error\"),\n\t\t\texpectSoftError: false,\n\t\t\tdescription:     \"Generic error should not be converted to soft error\",\n\t\t},\n\t\t{\n\t\t\tname:            \"Network error\",\n\t\t\tinputError:      errors.New(\"connection refused\"),\n\t\t\texpectSoftError: false,\n\t\t\tdescription:     \"Network error should not be converted to soft error\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := convertCloudflareError(tt.inputError)\n\n\t\t\tif tt.expectSoftError {\n\t\t\t\tassert.ErrorIs(t, result, provider.SoftError,\n\t\t\t\t\t\"Expected soft error for %s: %s\", tt.name, tt.description)\n\n\t\t\t\t// Verify error message preservation for all errors now that newCloudflareError\n\t\t\t\t// properly initializes the Request/Response fields\n\t\t\t\tassert.Contains(t, result.Error(), tt.inputError.Error(),\n\t\t\t\t\t\"Original error message should be preserved\")\n\t\t\t} else {\n\t\t\t\tassert.NotErrorIs(t, result, provider.SoftError,\n\t\t\t\t\t\"Expected non-soft error for %s: %s\", tt.name, tt.description)\n\t\t\t\tassert.Equal(t, tt.inputError, result,\n\t\t\t\t\t\"Non-soft errors should be returned unchanged\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestConvertCloudflareErrorInContext(t *testing.T) {\n\ttests := []struct {\n\t\tname            string\n\t\tsetupMock       func(*mockCloudFlareClient)\n\t\tfunction        func(*CloudFlareProvider) error\n\t\texpectSoftError bool\n\t\tdescription     string\n\t}{\n\t\t{\n\t\t\tname: \"Zones with GetZone rate limit error\",\n\t\t\tsetupMock: func(client *mockCloudFlareClient) {\n\t\t\t\tclient.Zones = map[string]string{\"zone1\": \"example.com\"}\n\t\t\t\tclient.getZoneError = newCloudflareError(429)\n\t\t\t},\n\t\t\tfunction: func(p *CloudFlareProvider) error {\n\t\t\t\tp.zoneIDFilter.ZoneIDs = []string{\"zone1\"}\n\t\t\t\t_, err := p.Zones(t.Context())\n\t\t\t\treturn err\n\t\t\t},\n\t\t\texpectSoftError: true,\n\t\t\tdescription:     \"Zones function should convert GetZone rate limit errors to soft errors\",\n\t\t},\n\t\t{\n\t\t\tname: \"Zones with GetZone server error\",\n\t\t\tsetupMock: func(client *mockCloudFlareClient) {\n\t\t\t\tclient.Zones = map[string]string{\"zone1\": \"example.com\"}\n\t\t\t\tclient.getZoneError = newCloudflareError(500)\n\t\t\t},\n\t\t\tfunction: func(p *CloudFlareProvider) error {\n\t\t\t\tp.zoneIDFilter.ZoneIDs = []string{\"zone1\"}\n\t\t\t\t_, err := p.Zones(t.Context())\n\t\t\t\treturn err\n\t\t\t},\n\t\t\texpectSoftError: true,\n\t\t\tdescription:     \"Zones function should convert GetZone server errors to soft errors\",\n\t\t},\n\t\t{\n\t\t\tname: \"Zones with GetZone client error\",\n\t\t\tsetupMock: func(client *mockCloudFlareClient) {\n\t\t\t\tclient.Zones = map[string]string{\"zone1\": \"example.com\"}\n\t\t\t\tclient.getZoneError = newCloudflareError(404)\n\t\t\t},\n\t\t\tfunction: func(p *CloudFlareProvider) error {\n\t\t\t\tp.zoneIDFilter.ZoneIDs = []string{\"zone1\"}\n\t\t\t\t_, err := p.Zones(t.Context())\n\t\t\t\treturn err\n\t\t\t},\n\t\t\texpectSoftError: false,\n\t\t\tdescription:     \"Zones function should not convert GetZone client errors to soft errors\",\n\t\t},\n\t\t{\n\t\t\tname: \"Zones with ListZones rate limit error\",\n\t\t\tsetupMock: func(client *mockCloudFlareClient) {\n\t\t\t\tclient.listZonesError = errors.New(\"exceeded available rate limit retries\")\n\t\t\t},\n\t\t\tfunction: func(p *CloudFlareProvider) error {\n\t\t\t\t_, err := p.Zones(t.Context())\n\t\t\t\treturn err\n\t\t\t},\n\t\t\texpectSoftError: true,\n\t\t\tdescription:     \"Zones function should convert ListZones rate limit string errors to soft errors\",\n\t\t},\n\t\t{\n\t\t\tname: \"Zones with ListZones server error\",\n\t\t\tsetupMock: func(client *mockCloudFlareClient) {\n\t\t\t\tclient.listZonesError = newCloudflareError(503)\n\t\t\t},\n\t\t\tfunction: func(p *CloudFlareProvider) error {\n\t\t\t\t_, err := p.Zones(t.Context())\n\t\t\t\treturn err\n\t\t\t},\n\t\t\texpectSoftError: true,\n\t\t\tdescription:     \"Zones function should convert ListZones server errors to soft errors\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tclient := NewMockCloudFlareClient()\n\t\t\ttt.setupMock(client)\n\n\t\t\tp := &CloudFlareProvider{\n\t\t\t\tClient:       client,\n\t\t\t\tzoneIDFilter: provider.ZoneIDFilter{},\n\t\t\t}\n\n\t\t\terr := tt.function(p)\n\t\t\tassert.Error(t, err, \"Expected an error from %s\", tt.name)\n\n\t\t\tif tt.expectSoftError {\n\t\t\t\tassert.ErrorIs(t, err, provider.SoftError,\n\t\t\t\t\t\"Expected soft error for %s: %s\", tt.name, tt.description)\n\t\t\t} else {\n\t\t\t\tassert.NotErrorIs(t, err, provider.SoftError,\n\t\t\t\t\t\"Expected non-soft error for %s: %s\", tt.name, tt.description)\n\t\t\t}\n\t\t})\n\t}\n}\nfunc TestCloudFlareZonesDomainFilter(t *testing.T) {\n\t// Create a domain filter that only matches \"bar.com\"\n\t// This should filter out \"foo.com\" and trigger the debug log\n\tdomainFilter := endpoint.NewDomainFilter([]string{\"bar.com\"})\n\n\tp := &CloudFlareProvider{\n\t\tClient:       NewMockCloudFlareClient(),\n\t\tdomainFilter: domainFilter,\n\t}\n\n\t// Capture debug logs to verify the filter log message\n\thook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t)\n\n\t// Call Zones() which should trigger the domain filter logic\n\tzones, err := p.Zones(t.Context())\n\trequire.NoError(t, err)\n\n\t// Should only return the \"bar.com\" zone since \"foo.com\" is filtered out\n\tassert.Len(t, zones, 1)\n\tassert.Equal(t, \"bar.com\", zones[0].Name)\n\tassert.Equal(t, \"001\", zones[0].ID)\n\n\t// Verify that the debug log was written for the filtered zone\n\tlogtest.TestHelperLogContains(\"zone \\\"foo.com\\\" not in domain filter\", hook, t)\n\tlogtest.TestHelperLogContains(\"no zoneIDFilter configured, looking at all zones\", hook, t)\n}\n\nfunc TestZoneIDByNameIteratorError(t *testing.T) {\n\tclient := NewMockCloudFlareClient()\n\n\t// Set up an error that will be returned by the ListZones iterator (line 144)\n\tclient.listZonesError = fmt.Errorf(\"CloudFlare API connection timeout\")\n\n\t// Call ZoneIDByName which should hit line 144 (iterator error handling)\n\tzoneID, err := client.ZoneIDByName(\"example.com\")\n\n\t// Should return empty zone ID and the wrapped iterator error\n\tassert.Empty(t, zoneID)\n\tassert.Error(t, err)\n\tassert.Contains(t, err.Error(), \"failed to list zones from CloudFlare API\")\n\tassert.Contains(t, err.Error(), \"CloudFlare API connection timeout\")\n}\n\nfunc TestZoneIDByNameZoneNotFound(t *testing.T) {\n\tclient := NewMockCloudFlareClient()\n\n\t// Set up mock to return different zones but not the one we're looking for\n\tclient.Zones = map[string]string{\n\t\t\"zone456\": \"different.com\",\n\t\t\"zone789\": \"another.com\",\n\t}\n\n\t// Call ZoneIDByName for a zone that doesn't exist, should hit line 147 (zone not found)\n\tzoneID, err := client.ZoneIDByName(\"nonexistent.com\")\n\n\t// Should return empty zone ID and the improved error message\n\tassert.Empty(t, zoneID)\n\tassert.Error(t, err)\n\tassert.Contains(t, err.Error(), `zone \"nonexistent.com\" not found in CloudFlare account`)\n\tassert.Contains(t, err.Error(), \"verify the zone exists and API credentials have access to it\")\n}\n\nfunc TestGetUpdateDNSRecordParam(t *testing.T) {\n\tcfc := cloudFlareChange{\n\t\tResourceRecord: dns.RecordResponse{\n\t\t\tID:       \"1234\",\n\t\t\tName:     \"example.com\",\n\t\t\tType:     endpoint.RecordTypeA,\n\t\t\tTTL:      120,\n\t\t\tProxied:  true,\n\t\t\tContent:  \"1.2.3.4\",\n\t\t\tPriority: 10,\n\t\t\tComment:  \"test-comment\",\n\t\t},\n\t}\n\n\tparams := getUpdateDNSRecordParam(\"zone-123\", cfc)\n\tbody := params.Body.(dns.RecordUpdateParamsBody)\n\n\tassert.Equal(t, \"zone-123\", params.ZoneID.Value)\n\tassert.Equal(t, \"example.com\", body.Name.Value)\n\tassert.InDelta(t, 120, float64(body.TTL.Value), 0)\n\tassert.True(t, body.Proxied.Value)\n\tassert.EqualValues(t, \"A\", body.Type.Value)\n\tassert.Equal(t, \"1.2.3.4\", body.Content.Value)\n\tassert.InDelta(t, 10, float64(body.Priority.Value), 0)\n\tassert.Equal(t, \"test-comment\", body.Comment.Value)\n}\n\nfunc TestZoneService(t *testing.T) {\n\tt.Parallel()\n\n\tctx, cancel := context.WithCancel(t.Context())\n\tcancel()\n\n\tclient := &zoneService{\n\t\tservice: cloudflare.NewClient(),\n\t}\n\n\tzoneID := \"foo\"\n\n\tt.Run(\"ListDNSRecord\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\titer := client.ListDNSRecords(ctx, dns.RecordListParams{ZoneID: cloudflare.F(\"foo\")})\n\t\tassert.False(t, iter.Next())\n\t\tassert.Empty(t, iter.Current())\n\t\tassert.ErrorIs(t, iter.Err(), context.Canceled)\n\t})\n\n\tt.Run(\"CreateDNSRecord\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tparams := getCreateDNSRecordParam(zoneID, &cloudFlareChange{})\n\t\trecord, err := client.CreateDNSRecord(ctx, params)\n\t\tassert.Empty(t, record)\n\t\tassert.ErrorIs(t, err, context.Canceled)\n\t})\n\n\tt.Run(\"UpdateDNSRecord\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\trecordParam := getUpdateDNSRecordParam(zoneID, cloudFlareChange{})\n\t\t_, err := client.UpdateDNSRecord(ctx, \"1234\", recordParam)\n\t\tassert.ErrorIs(t, err, context.Canceled)\n\t})\n\n\tt.Run(\"DeleteDNSRecord\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\terr := client.DeleteDNSRecord(ctx, \"1234\", dns.RecordDeleteParams{ZoneID: cloudflare.F(\"foo\")})\n\t\tassert.ErrorIs(t, err, context.Canceled)\n\t})\n\n\tt.Run(\"ListZones\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\titer := client.ListZones(ctx, listZonesV4Params())\n\t\tassert.False(t, iter.Next())\n\t\tassert.Empty(t, iter.Current())\n\t\tassert.ErrorIs(t, iter.Err(), context.Canceled)\n\t})\n\n\tt.Run(\"GetZone\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tzone, err := client.GetZone(ctx, zoneID)\n\t\tassert.Nil(t, zone)\n\t\tassert.ErrorIs(t, err, context.Canceled)\n\t})\n\n\tt.Run(\"ListDataLocalizationRegionalHostnames\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tparams := listDataLocalizationRegionalHostnamesParams(zoneID)\n\t\titer := client.ListDataLocalizationRegionalHostnames(ctx, params)\n\t\tassert.False(t, iter.Next())\n\t\tassert.Empty(t, iter.Current())\n\t\tassert.ErrorIs(t, iter.Err(), context.Canceled)\n\t})\n\n\tt.Run(\"CreateDataLocalizationRegionalHostname\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tparams := createDataLocalizationRegionalHostnameParams(zoneID, regionalHostnameChange{})\n\t\terr := client.CreateDataLocalizationRegionalHostname(ctx, params)\n\t\tassert.ErrorIs(t, err, context.Canceled)\n\t})\n\n\tt.Run(\"DeleteDataLocalizationRegionalHostname\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tparams := deleteDataLocalizationRegionalHostnameParams(zoneID)\n\t\terr := client.DeleteDataLocalizationRegionalHostname(ctx, \"foo\", params)\n\t\tassert.ErrorIs(t, err, context.Canceled)\n\t})\n\n\tt.Run(\"UpdateDataLocalizationRegionalHostname\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\tparams := updateDataLocalizationRegionalHostnameParams(zoneID, regionalHostnameChange{})\n\t\terr := client.UpdateDataLocalizationRegionalHostname(ctx, \"foo\", params)\n\t\tassert.ErrorIs(t, err, context.Canceled)\n\t})\n\n\tt.Run(\"CustomHostnames\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\titer := client.CustomHostnames(ctx, zoneID)\n\t\tassert.False(t, iter.Next())\n\t\tassert.Empty(t, iter.Current())\n\t\tassert.ErrorIs(t, iter.Err(), context.Canceled)\n\t})\n\n\tt.Run(\"CreateCustomHostname\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\terr := client.CreateCustomHostname(ctx, zoneID, customHostname{})\n\t\tassert.ErrorIs(t, err, context.Canceled)\n\t})\n\n\tt.Run(\"BatchDNSRecords\", func(t *testing.T) {\n\t\tt.Parallel()\n\t\t_, err := client.BatchDNSRecords(ctx, dns.RecordBatchParams{ZoneID: cloudflare.F(zoneID)})\n\t\tassert.ErrorIs(t, err, context.Canceled)\n\t})\n}\n\nfunc TestSubmitChanges_ErrorPaths(t *testing.T) {\n\tt.Run(\"getDNSRecordsMap error returns error from submitChanges\", func(t *testing.T) {\n\t\tclient := NewMockCloudFlareClient()\n\t\tclient.dnsRecordsError = errors.New(\"dns list failed\")\n\t\tp := &CloudFlareProvider{Client: client}\n\n\t\tchanges := &plan.Changes{\n\t\t\tCreate: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"test.bar.com\", Targets: endpoint.Targets{\"1.2.3.4\"}, RecordType: \"A\"},\n\t\t\t},\n\t\t}\n\t\terr := p.ApplyChanges(t.Context(), changes)\n\t\trequire.Error(t, err)\n\t\tassert.Contains(t, err.Error(), \"could not fetch records from zone\")\n\t})\n\n\tt.Run(\"listCustomHostnamesWithPagination error returns error from submitChanges\", func(t *testing.T) {\n\t\t// The mock returns an error for CustomHostnames() when zoneID starts with \"newerror-\".\n\t\t// CustomHostnamesConfig.Enabled must be true to reach that code path.\n\t\tclient := &mockCloudFlareClient{\n\t\t\tZones: map[string]string{\n\t\t\t\t\"newerror-zone1\": \"errorcf.com\",\n\t\t\t},\n\t\t\tRecords: map[string]map[string]dns.RecordResponse{\n\t\t\t\t\"newerror-zone1\": {},\n\t\t\t},\n\t\t\tcustomHostnames:   map[string][]customHostname{},\n\t\t\tregionalHostnames: map[string][]regionalHostname{},\n\t\t}\n\t\tp := &CloudFlareProvider{\n\t\t\tClient:                client,\n\t\t\tdomainFilter:          endpoint.NewDomainFilter([]string{\"errorcf.com\"}),\n\t\t\tCustomHostnamesConfig: CustomHostnamesConfig{Enabled: true},\n\t\t}\n\n\t\tchanges := &plan.Changes{\n\t\t\tCreate: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"sub.errorcf.com\", Targets: endpoint.Targets{\"1.2.3.4\"}, RecordType: \"A\"},\n\t\t\t},\n\t\t}\n\t\terr := p.ApplyChanges(t.Context(), changes)\n\t\trequire.Error(t, err)\n\t\tassert.Contains(t, err.Error(), \"could not fetch custom hostnames from zone\")\n\t})\n\n\tt.Run(\"processCustomHostnameChanges failure sets failedChange\", func(t *testing.T) {\n\t\t// The mock's CreateCustomHostname fails for \"newerror-create.foo.fancybar.com\".\n\t\t// With CustomHostnames enabled, the failing create causes processCustomHostnameChanges\n\t\t// to return true, which sets failedChange=true for the zone.\n\t\tclient := NewMockCloudFlareClient()\n\t\tp := &CloudFlareProvider{\n\t\t\tClient:                client,\n\t\t\tCustomHostnamesConfig: CustomHostnamesConfig{Enabled: true},\n\t\t}\n\n\t\tchanges := &plan.Changes{\n\t\t\tCreate: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"a.bar.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: \"A\",\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:  \"external-dns.alpha.kubernetes.io/cloudflare-custom-hostname\",\n\t\t\t\t\t\t\tValue: \"newerror-create.foo.fancybar.com\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\terr := p.ApplyChanges(t.Context(), changes)\n\t\trequire.Error(t, err, \"failing custom hostname create should cause an error\")\n\t})\n\n\tt.Run(\"Zones error propagates from submitChanges\", func(t *testing.T) {\n\t\t// Setting listZonesError causes p.Zones() to fail inside submitChanges,\n\t\t// exercising the `if err != nil { return err }` block at the top of the loop.\n\t\tclient := NewMockCloudFlareClient()\n\t\tclient.listZonesError = errors.New(\"zones fetch failed\")\n\t\tp := &CloudFlareProvider{Client: client}\n\n\t\tchanges := &plan.Changes{\n\t\t\tCreate: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"test.bar.com\", Targets: endpoint.Targets{\"1.2.3.4\"}, RecordType: \"A\"},\n\t\t\t},\n\t\t}\n\t\terr := p.ApplyChanges(t.Context(), changes)\n\t\trequire.Error(t, err)\n\t\tassert.Contains(t, err.Error(), \"zones fetch failed\")\n\t})\n}\n\nfunc TestParseTagsAnnotation(t *testing.T) {\n\tt.Run(\"parses comma-separated tags\", func(t *testing.T) {\n\t\ttags := parseTagsAnnotation(\"tag1,tag2,tag3\")\n\t\tassert.Equal(t, []string{\"tag1\", \"tag2\", \"tag3\"}, tags)\n\t})\n\tt.Run(\"trims whitespace from each tag\", func(t *testing.T) {\n\t\ttags := parseTagsAnnotation(\"  z-tag ,  a-tag  \")\n\t\tassert.Equal(t, []string{\"a-tag\", \"z-tag\"}, tags)\n\t})\n\tt.Run(\"sorts tags canonically\", func(t *testing.T) {\n\t\ttags := parseTagsAnnotation(\"c,a,b\")\n\t\tassert.Equal(t, []string{\"a\", \"b\", \"c\"}, tags)\n\t})\n\tt.Run(\"skips empty tokens\", func(t *testing.T) {\n\t\ttags := parseTagsAnnotation(\"tag1,,,, tag2\")\n\t\tassert.Equal(t, []string{\"tag1\", \"tag2\"}, tags)\n\t})\n}\n\nfunc TestAdjustEndpoints_TagsAnnotation(t *testing.T) {\n\t// parseTagsAnnotation is only invoked when the CloudflareTagsKey annotation\n\t// is present on the endpoint. This test exercises that branch via AdjustEndpoints.\n\tp := &CloudFlareProvider{}\n\tep := &endpoint.Endpoint{\n\t\tRecordType: \"A\",\n\t\tDNSName:    \"test.bar.com\",\n\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t{\n\t\t\t\tName:  annotations.CloudflareTagsKey,\n\t\t\t\tValue: \"beta, alpha, gamma\",\n\t\t\t},\n\t\t},\n\t}\n\tadjusted, err := p.AdjustEndpoints([]*endpoint.Endpoint{ep})\n\trequire.NoError(t, err)\n\trequire.Len(t, adjusted, 1)\n\n\tval, ok := adjusted[0].GetProviderSpecificProperty(annotations.CloudflareTagsKey)\n\trequire.True(t, ok, \"tags annotation should still be present after AdjustEndpoints\")\n\t// Tags should be sorted and whitespace-trimmed\n\tassert.Equal(t, \"alpha,beta,gamma\", val)\n}\n\nfunc TestZoneServiceZoneIDByName(t *testing.T) {\n\t// Build a minimal cloudflare API response page for /zones.\n\twriteZonesPage := func(w http.ResponseWriter, zones []map[string]any) {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tif err := json.NewEncoder(w).Encode(map[string]any{\n\t\t\t\"result\": zones,\n\t\t\t\"result_info\": map[string]any{\n\t\t\t\t\"count\":       len(zones),\n\t\t\t\t\"total_count\": len(zones),\n\t\t\t\t\"page\":        1,\n\t\t\t\t\"per_page\":    20,\n\t\t\t},\n\t\t\t\"success\":  true,\n\t\t\t\"errors\":   []any{},\n\t\t\t\"messages\": []any{},\n\t\t}); err != nil {\n\t\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t\t}\n\t}\n\n\tt.Run(\"zone found returns its ID\", func(t *testing.T) {\n\t\tts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\t\twriteZonesPage(w, []map[string]any{\n\t\t\t\t{\"id\": \"zone-abc\", \"name\": \"example.com\", \"plan\": map[string]any{\"is_subscribed\": false}},\n\t\t\t})\n\t\t}))\n\t\tdefer ts.Close()\n\n\t\tsvc := &zoneService{service: cloudflare.NewClient(\n\t\t\toption.WithBaseURL(ts.URL+\"/\"),\n\t\t\toption.WithAPIToken(\"test-token\"),\n\t\t\toption.WithMaxRetries(0),\n\t\t)}\n\t\tid, err := svc.ZoneIDByName(\"example.com\")\n\t\trequire.NoError(t, err)\n\t\tassert.Equal(t, \"zone-abc\", id)\n\t})\n\n\tt.Run(\"zone not found returns descriptive error\", func(t *testing.T) {\n\t\tts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\t\twriteZonesPage(w, []map[string]any{})\n\t\t}))\n\t\tdefer ts.Close()\n\n\t\tsvc := &zoneService{service: cloudflare.NewClient(\n\t\t\toption.WithBaseURL(ts.URL+\"/\"),\n\t\t\toption.WithAPIToken(\"test-token\"),\n\t\t\toption.WithMaxRetries(0),\n\t\t)}\n\t\tid, err := svc.ZoneIDByName(\"missing.com\")\n\t\tassert.Empty(t, id)\n\t\trequire.Error(t, err)\n\t\tassert.Contains(t, err.Error(), \"not found in CloudFlare account\")\n\t})\n\n\tt.Run(\"server error causes wrapped iterator error\", func(t *testing.T) {\n\t\tts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\t\t_ = json.NewEncoder(w).Encode(map[string]any{\n\t\t\t\t\"result\":   nil,\n\t\t\t\t\"success\":  false,\n\t\t\t\t\"errors\":   []map[string]any{{\"code\": 500, \"message\": \"internal server error\"}},\n\t\t\t\t\"messages\": []any{},\n\t\t\t})\n\t\t}))\n\t\tdefer ts.Close()\n\n\t\tsvc := &zoneService{service: cloudflare.NewClient(\n\t\t\toption.WithBaseURL(ts.URL+\"/\"),\n\t\t\toption.WithAPIToken(\"test-token\"),\n\t\t\toption.WithMaxRetries(0),\n\t\t)}\n\t\tid, err := svc.ZoneIDByName(\"any.com\")\n\t\tassert.Empty(t, id)\n\t\trequire.Error(t, err)\n\t\tassert.Contains(t, err.Error(), \"failed to list zones from CloudFlare API\")\n\t})\n}\n"
  },
  {
    "path": "provider/cloudflare/pagination.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage cloudflare\n\ntype autoPager[T any] interface {\n\tNext() bool\n\tCurrent() T\n\tErr() error\n}\n\n// autoPagerIterator returns an iterator over an autoPager.\nfunc autoPagerIterator[T any](iter autoPager[T]) func(yield func(T) bool) {\n\treturn func(yield func(T) bool) {\n\t\tfor iter.Next() {\n\t\t\tif !yield(iter.Current()) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "provider/cloudflare/pagination_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage cloudflare\n\nimport (\n\t\"errors\"\n\t\"slices\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\ntype mockAutoPager[T any] struct {\n\titems    []T\n\tindex    int\n\terr      error\n\terrIndex int\n}\n\nfunc (m *mockAutoPager[T]) Next() bool {\n\tm.index++\n\treturn !m.hasError() && m.hasNext()\n}\n\nfunc (m *mockAutoPager[T]) Current() T {\n\tif m.hasNext() && !m.hasError() {\n\t\treturn m.items[m.index-1]\n\t}\n\tvar zero T\n\treturn zero\n}\n\nfunc (m *mockAutoPager[T]) Err() error {\n\treturn m.err\n}\n\nfunc (m *mockAutoPager[T]) hasError() bool {\n\treturn m.err != nil && m.errIndex <= m.index\n}\n\nfunc (m *mockAutoPager[T]) hasNext() bool {\n\treturn m.index > 0 && m.index <= len(m.items)\n}\n\nfunc TestAutoPagerIterator(t *testing.T) {\n\tt.Run(\"iterate empty\", func(t *testing.T) {\n\t\tpager := &mockAutoPager[string]{}\n\t\titerator := autoPagerIterator(pager)\n\t\tcollected := slices.Collect(iterator)\n\t\tassert.Empty(t, collected)\n\t})\n\n\tt.Run(\"iterate all items\", func(t *testing.T) {\n\t\tpager := &mockAutoPager[int]{items: []int{1, 2, 3, 4, 5}}\n\t\titerator := autoPagerIterator(pager)\n\t\tcollected := slices.Collect(iterator)\n\t\tassert.Equal(t, []int{1, 2, 3, 4, 5}, collected)\n\t})\n\n\tt.Run(\"iterate with early termination\", func(t *testing.T) {\n\t\tpager := &mockAutoPager[int]{items: []int{1, 2, 3, 4, 5}}\n\t\titerator := autoPagerIterator(pager)\n\t\tvar collected []int\n\t\tfor item := range iterator {\n\t\t\tcollected = append(collected, item)\n\t\t\tif item == 3 {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tassert.Equal(t, []int{1, 2, 3}, collected)\n\t})\n\n\tt.Run(\"iterate with error at index\", func(t *testing.T) {\n\t\texpectedErr := errors.New(\"pager error\")\n\t\tpager := &mockAutoPager[int]{items: []int{1, 2, 3, 4, 5}, err: expectedErr, errIndex: 3}\n\t\titerator := autoPagerIterator(pager)\n\t\tcollected := slices.Collect(iterator)\n\t\tassert.Equal(t, []int{1, 2}, collected)\n\t})\n}\n"
  },
  {
    "path": "provider/coredns/OWNERS",
    "content": "approvers:\n  - ytsarev\n"
  },
  {
    "path": "provider/coredns/coredns.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage coredns\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"math/rand\"\n\t\"net\"\n\t\"os\"\n\t\"slices\"\n\t\"strings\"\n\t\"time\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"go.etcd.io/etcd/api/v3/mvccpb\"\n\tetcdcv3 \"go.etcd.io/etcd/client/v3\"\n\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\n\t\"sigs.k8s.io/external-dns/pkg/tlsutils\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\nconst (\n\tpriority    = 10 // default priority when nothing is set\n\tetcdTimeout = 5 * time.Second\n\n\trandomPrefixLabel     = \"prefix\"\n\tproviderSpecificGroup = \"coredns/group\"\n)\n\nvar (\n\t// avoids allocating a new slice on every call\n\tskipLabels = []string{\"originalText\", \"prefix\", \"resource\"}\n)\n\n// coreDNSClient is an interface to work with CoreDNS service records in etcd\ntype coreDNSClient interface {\n\tGetServices(ctx context.Context, prefix string) ([]*Service, error)\n\tSaveService(ctx context.Context, value *Service) error\n\tDeleteService(ctx context.Context, key string) error\n}\n\ntype coreDNSProvider struct {\n\tprovider.BaseProvider\n\tdryRun        bool\n\tstrictlyOwned bool\n\tcoreDNSPrefix string\n\tdomainFilter  *endpoint.DomainFilter\n\tclient        coreDNSClient\n}\n\n// Service represents CoreDNS etcd record\ntype Service struct {\n\tHost     string `json:\"host,omitempty\"`\n\tPort     int    `json:\"port,omitempty\"`\n\tPriority int    `json:\"priority,omitempty\"`\n\tWeight   int    `json:\"weight,omitempty\"`\n\tText     string `json:\"text,omitempty\"`\n\tMail     bool   `json:\"mail,omitempty\"` // Be an MX record. Priority becomes Preference.\n\tTTL      uint32 `json:\"ttl,omitempty\"`\n\n\t// When a SRV record with a \"Host: IP-address\" is added, we synthesize\n\t// a srv.Target domain name.  Normally we convert the full Key where\n\t// the record lives to a DNS name and use this as the srv.Target.  When\n\t// TargetStrip > 0 we strip the left most TargetStrip labels from the\n\t// DNS name.\n\tTargetStrip int `json:\"targetstrip,omitempty\"`\n\n\t// Group is used to group (or *not* to group) different services\n\t// together. Services with an identical Group are returned in the same\n\t// answer.\n\tGroup string `json:\"group,omitempty\"`\n\n\t// Etcd key where we found this service and ignored from json un-/marshaling\n\tKey string `json:\"-\"`\n\n\t// Owner is used to prevent service to be added by different external-dns (only used by external-dns)\n\tOwner string `json:\"owner,omitempty\"`\n}\n\ntype etcdClient struct {\n\tclient        *etcdcv3.Client\n\towner         string\n\tstrictlyOwned bool\n}\n\nvar _ coreDNSClient = etcdClient{}\n\n// GetServices GetService return all Service records stored in etcd stored anywhere under the given key (recursively)\nfunc (c etcdClient) GetServices(ctx context.Context, prefix string) ([]*Service, error) {\n\tctx, cancel := context.WithTimeout(ctx, etcdTimeout)\n\tdefer cancel()\n\n\tpath := prefix\n\tr, err := c.client.Get(ctx, path, etcdcv3.WithPrefix())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar svcs []*Service\n\tbx := make(map[Service]bool)\n\tfor _, n := range r.Kvs {\n\t\tsvc, err := c.unmarshalService(n)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif c.strictlyOwned && svc.Owner != c.owner {\n\t\t\tcontinue\n\t\t}\n\t\tb := Service{\n\t\t\tHost:     svc.Host,\n\t\t\tPort:     svc.Port,\n\t\t\tPriority: svc.Priority,\n\t\t\tWeight:   svc.Weight,\n\t\t\tText:     svc.Text,\n\t\t\tKey:      string(n.Key),\n\t\t}\n\t\tif _, ok := bx[b]; ok {\n\t\t\t// skip the service if already added to service list.\n\t\t\t// the same service might be found in multiple etcd nodes.\n\t\t\tcontinue\n\t\t}\n\t\tbx[b] = true\n\n\t\tsvc.Key = string(n.Key)\n\t\tif svc.Priority == 0 {\n\t\t\tsvc.Priority = priority\n\t\t}\n\t\tsvcs = append(svcs, svc)\n\t}\n\treturn svcs, nil\n}\n\n// SaveService persists service data into etcd\nfunc (c etcdClient) SaveService(ctx context.Context, service *Service) error {\n\tctx, cancel := context.WithTimeout(ctx, etcdTimeout)\n\tdefer cancel()\n\n\t// check only for empty OwnedBy\n\tif c.strictlyOwned && service.Owner != c.owner {\n\t\tr, err := c.client.Get(ctx, service.Key)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"etcd get %q: %w\", service.Key, err)\n\t\t}\n\t\t// Key missing -> treat as owned (safe to create)\n\t\tif r != nil && len(r.Kvs) != 0 {\n\t\t\tsvc, err := c.unmarshalService(r.Kvs[0])\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to unmarshal value for key %q: %w\", service.Key, err)\n\t\t\t}\n\t\t\tif svc.Owner != c.owner {\n\t\t\t\treturn fmt.Errorf(\"key %q is not owned by this provider\", service.Key)\n\t\t\t}\n\t\t}\n\t\tservice.Owner = c.owner\n\t}\n\n\tvalue, err := json.Marshal(&service)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = c.client.Put(ctx, service.Key, string(value))\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// DeleteService deletes service record from etcd\nfunc (c etcdClient) DeleteService(ctx context.Context, key string) error {\n\tctx, cancel := context.WithTimeout(ctx, etcdTimeout)\n\tdefer cancel()\n\n\tif c.strictlyOwned {\n\t\trs, err := c.client.Get(ctx, key, etcdcv3.WithPrefix())\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tfor _, r := range rs.Kvs {\n\t\t\tsvc, err := c.unmarshalService(r)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif svc.Owner != c.owner {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t_, err = c.client.Delete(ctx, string(r.Key))\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn err\n\t} else {\n\t\t_, err := c.client.Delete(ctx, key, etcdcv3.WithPrefix())\n\t\treturn err\n\t}\n}\n\nfunc (c etcdClient) unmarshalService(n *mvccpb.KeyValue) (*Service, error) {\n\tsvc := new(Service)\n\tif err := json.Unmarshal(n.Value, svc); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to unmarshal %q: %w\", n.Key, err)\n\t}\n\treturn svc, nil\n}\n\n// builds etcd client config depending on connection scheme and TLS parameters\nfunc getETCDConfig() (*etcdcv3.Config, error) {\n\tetcdURLsStr := os.Getenv(\"ETCD_URLS\")\n\tif etcdURLsStr == \"\" {\n\t\tetcdURLsStr = \"http://localhost:2379\"\n\t}\n\tetcdURLs := strings.Split(etcdURLsStr, \",\")\n\tfirstURL := strings.ToLower(etcdURLs[0])\n\tetcdUsername := os.Getenv(\"ETCD_USERNAME\")\n\tetcdPassword := os.Getenv(\"ETCD_PASSWORD\")\n\tswitch {\n\tcase strings.HasPrefix(firstURL, \"http://\"):\n\t\treturn &etcdcv3.Config{Endpoints: etcdURLs, Username: etcdUsername, Password: etcdPassword}, nil\n\tcase strings.HasPrefix(firstURL, \"https://\"):\n\t\ttlsConfig, err := tlsutils.CreateTLSConfig(\"ETCD\")\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tlog.Debug(\"using TLS for etcd\")\n\t\treturn &etcdcv3.Config{\n\t\t\tEndpoints: etcdURLs,\n\t\t\tTLS:       tlsConfig,\n\t\t\tUsername:  etcdUsername,\n\t\t\tPassword:  etcdPassword,\n\t\t}, nil\n\tdefault:\n\t\treturn nil, errors.New(\"etcd URLs must start with either http:// or https://\")\n\t}\n}\n\n// the newETCDClient is an etcd client constructor\nfunc newETCDClient(owner string, strictlyOwned bool) (coreDNSClient, error) {\n\tcfg, err := getETCDConfig()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tc, err := etcdcv3.New(*cfg)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn etcdClient{c, owner, strictlyOwned}, nil\n}\n\n// New creates a CoreDNS/SkyDNS provider from the given configuration.\nfunc New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) {\n\treturn newProvider(domainFilter, cfg.CoreDNSPrefix, cfg.TXTOwnerID, cfg.CoreDNSStrictlyOwned, cfg.DryRun)\n}\n\n// newProvider is a CoreDNS provider constructor\nfunc newProvider(domainFilter *endpoint.DomainFilter, prefix, owner string, strictlyOwned, dryRun bool) (provider.Provider, error) {\n\tclient, err := newETCDClient(owner, strictlyOwned)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn coreDNSProvider{\n\t\tclient:        client,\n\t\tdryRun:        dryRun,\n\t\tstrictlyOwned: strictlyOwned,\n\t\tcoreDNSPrefix: prefix,\n\t\tdomainFilter:  domainFilter,\n\t}, nil\n}\n\n// findEp takes an Endpoint slice and looks for an element in it. If found it will\n// return Endpoint, otherwise it will return nil and a bool of false.\nfunc findEp(slice []*endpoint.Endpoint, dnsName string) (*endpoint.Endpoint, bool) {\n\tfor _, item := range slice {\n\t\tif item.DNSName == dnsName {\n\t\t\treturn item, true\n\t\t}\n\t}\n\treturn nil, false\n}\n\n// Records returns all DNS records found in CoreDNS etcd backend. Depending on the record fields\n// it may be mapped to one or two records of type A, CNAME, TXT, A+TXT, CNAME+TXT\nfunc (p coreDNSProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {\n\tvar result []*endpoint.Endpoint\n\tservices, err := p.client.GetServices(ctx, p.coreDNSPrefix)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tfor _, service := range services {\n\t\tdomains := strings.Split(strings.TrimPrefix(service.Key, p.coreDNSPrefix), \"/\")\n\t\treverse(domains)\n\t\tdnsName := strings.Join(domains[service.TargetStrip:], \".\")\n\t\tif !p.domainFilter.Match(dnsName) {\n\t\t\tcontinue\n\t\t}\n\t\tlog.Debugf(\"Getting service (%v) with service host (%s)\", service, service.Host)\n\t\tprefix := strings.Join(domains[:service.TargetStrip], \".\")\n\t\tif service.Host != \"\" {\n\t\t\tep, found := findEp(result, dnsName)\n\t\t\tif found {\n\t\t\t\tep.Targets = append(ep.Targets, service.Host)\n\t\t\t\tlog.Debugf(\"Extending ep (%s) with new service host (%s)\", ep, service.Host)\n\t\t\t} else {\n\t\t\t\tep = endpoint.NewEndpointWithTTL(\n\t\t\t\t\tdnsName,\n\t\t\t\t\tguessRecordType(service.Host),\n\t\t\t\t\tendpoint.TTL(service.TTL),\n\t\t\t\t\tservice.Host,\n\t\t\t\t)\n\t\t\t\tif service.Group != \"\" {\n\t\t\t\t\tep.WithProviderSpecific(providerSpecificGroup, service.Group)\n\t\t\t\t}\n\t\t\t\tlog.Debugf(\"Creating new ep (%s) with new service host (%s)\", ep, service.Host)\n\t\t\t}\n\t\t\tif p.strictlyOwned {\n\t\t\t\tep.Labels[endpoint.OwnerLabelKey] = service.Owner\n\t\t\t}\n\t\t\tep.Labels[\"originalText\"] = service.Text\n\t\t\tep.Labels[randomPrefixLabel] = prefix\n\t\t\tep.Labels[service.Host] = prefix\n\t\t\tresult = append(result, ep)\n\t\t}\n\t\tif service.Text != \"\" {\n\t\t\tep := endpoint.NewEndpoint(\n\t\t\t\tdnsName,\n\t\t\t\tendpoint.RecordTypeTXT,\n\t\t\t\tservice.Text,\n\t\t\t)\n\t\t\tif p.strictlyOwned {\n\t\t\t\tep.Labels[endpoint.OwnerLabelKey] = service.Owner\n\t\t\t}\n\t\t\tep.Labels[randomPrefixLabel] = prefix\n\t\t\tresult = append(result, ep)\n\t\t}\n\t}\n\treturn result, nil\n}\n\nfunc (p coreDNSProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {\n\tgrouped := p.groupEndpoints(changes)\n\n\tfor dnsName, group := range grouped {\n\t\tif !p.domainFilter.Match(dnsName) {\n\t\t\tlog.Debugf(\"Skipping record %q due to domain filter\", dnsName)\n\t\t\tcontinue\n\t\t}\n\t\tif err := p.applyGroup(ctx, dnsName, group); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn p.deleteEndpoints(ctx, changes.Delete)\n}\n\nfunc (p coreDNSProvider) groupEndpoints(changes *plan.Changes) map[string][]*endpoint.Endpoint {\n\tgrouped := make(map[string][]*endpoint.Endpoint)\n\tfor _, ep := range changes.Create {\n\t\tgrouped[ep.DNSName] = append(grouped[ep.DNSName], ep)\n\t}\n\tfor i, ep := range changes.UpdateNew {\n\t\tlog.Debugf(\"Updating labels (%s) with old labels (%s)\", ep.Labels, changes.UpdateOld[i].Labels)\n\t\tep.Labels = changes.UpdateOld[i].Labels\n\t\tgrouped[ep.DNSName] = append(grouped[ep.DNSName], ep)\n\t}\n\treturn grouped\n}\n\nfunc (p coreDNSProvider) applyGroup(ctx context.Context, dnsName string, group []*endpoint.Endpoint) error {\n\tvar services []*Service\n\n\tfor _, ep := range group {\n\t\tif ep.RecordType != endpoint.RecordTypeTXT {\n\t\t\tsrvs, err := p.createServicesForEndpoint(ctx, dnsName, ep)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tservices = append(services, srvs...)\n\t\t}\n\t}\n\n\tservices = p.updateTXTRecords(dnsName, group, services)\n\n\tfor _, service := range services {\n\t\tlog.Infof(\"Add/set key %s to Host=%s, Text=%s, TTL=%d\", service.Key, service.Host, service.Text, service.TTL)\n\t\tif p.dryRun {\n\t\t\tcontinue\n\t\t}\n\t\tif err := p.client.SaveService(ctx, service); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (p coreDNSProvider) createServicesForEndpoint(ctx context.Context, dnsName string, ep *endpoint.Endpoint) ([]*Service, error) {\n\tvar services []*Service\n\n\tfor _, target := range ep.Targets {\n\t\tprefix := ep.Labels[target]\n\t\tif prefix == \"\" {\n\t\t\tprefix = fmt.Sprintf(\"%08x\", rand.Int31())\n\t\t\tlog.Infof(\"Generating new prefix: (%s)\", prefix)\n\t\t}\n\t\tgroup := \"\"\n\t\tif prop, ok := ep.GetProviderSpecificProperty(providerSpecificGroup); ok {\n\t\t\tgroup = prop\n\t\t}\n\t\tservice := Service{\n\t\t\tHost:        target,\n\t\t\tText:        ep.Labels[\"originalText\"],\n\t\t\tKey:         p.etcdKeyFor(prefix + \".\" + dnsName),\n\t\t\tTargetStrip: strings.Count(prefix, \".\") + 1,\n\t\t\tTTL:         uint32(ep.RecordTTL),\n\t\t\tGroup:       group,\n\t\t}\n\t\tservices = append(services, &service)\n\t\tep.Labels[target] = prefix\n\t}\n\n\t// Clean outdated labels\n\tfor label, labelPrefix := range ep.Labels {\n\t\tif slices.Contains(skipLabels, label) {\n\t\t\tcontinue\n\t\t}\n\t\tif !slices.Contains(ep.Targets, label) {\n\t\t\tkey := p.etcdKeyFor(labelPrefix + \".\" + dnsName)\n\t\t\tlog.Infof(\"Delete key %s\", key)\n\t\t\tif p.dryRun {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif err := p.client.DeleteService(ctx, key); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\t}\n\treturn services, nil\n}\n\n// updateTXTRecords updates the TXT records in the provided services slice based on the given group of endpoints.\nfunc (p coreDNSProvider) updateTXTRecords(dnsName string, group []*endpoint.Endpoint, services []*Service) []*Service {\n\tindex := 0\n\tfor _, ep := range group {\n\t\tif ep.RecordType != endpoint.RecordTypeTXT {\n\t\t\tcontinue\n\t\t}\n\t\tif index >= len(services) {\n\t\t\tprefix := ep.Labels[randomPrefixLabel]\n\t\t\tif prefix == \"\" {\n\t\t\t\tprefix = fmt.Sprintf(\"%08x\", rand.Int31())\n\t\t\t}\n\t\t\tservices = append(services, &Service{\n\t\t\t\tKey:         p.etcdKeyFor(prefix + \".\" + dnsName),\n\t\t\t\tTargetStrip: strings.Count(prefix, \".\") + 1,\n\t\t\t\tTTL:         uint32(ep.RecordTTL),\n\t\t\t})\n\t\t}\n\t\tservices[index].Text = ep.Targets[0]\n\t\tindex++\n\t}\n\n\tfor i := index; index > 0 && i < len(services); i++ {\n\t\tservices[i].Text = \"\"\n\t}\n\treturn services\n}\n\nfunc (p coreDNSProvider) deleteEndpoints(ctx context.Context, endpoints []*endpoint.Endpoint) error {\n\tfor _, ep := range endpoints {\n\t\tdnsName := ep.DNSName\n\t\tif ep.Labels[randomPrefixLabel] != \"\" {\n\t\t\tdnsName = ep.Labels[randomPrefixLabel] + \".\" + dnsName\n\t\t}\n\t\tkey := p.etcdKeyFor(dnsName)\n\t\tlog.Infof(\"Delete key %s\", key)\n\t\tif p.dryRun {\n\t\t\tcontinue\n\t\t}\n\t\tif err := p.client.DeleteService(ctx, key); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (p coreDNSProvider) etcdKeyFor(dnsName string) string {\n\tdomains := strings.Split(dnsName, \".\")\n\treverse(domains)\n\treturn p.coreDNSPrefix + strings.Join(domains, \"/\")\n}\n\nfunc guessRecordType(target string) string {\n\tif net.ParseIP(target) != nil {\n\t\treturn endpoint.RecordTypeA\n\t}\n\treturn endpoint.RecordTypeCNAME\n}\n\nfunc reverse(slice []string) {\n\tfor i := range len(slice) / 2 {\n\t\tj := len(slice) - i - 1\n\t\tslice[i], slice[j] = slice[j], slice[i]\n\t}\n}\n"
  },
  {
    "path": "provider/coredns/coredns_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage coredns\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"reflect\"\n\t\"strings\"\n\t\"testing\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\t\"go.etcd.io/etcd/api/v3/mvccpb\"\n\tetcdcv3 \"go.etcd.io/etcd/client/v3\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/internal/testutils\"\n\tlogtest \"sigs.k8s.io/external-dns/internal/testutils/log\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\nconst defaultCoreDNSPrefix = \"/skydns/\"\n\ntype fakeETCDClient struct {\n\tservices map[string]Service\n}\n\nfunc (c fakeETCDClient) GetServices(_ context.Context, prefix string) ([]*Service, error) {\n\tvar result []*Service\n\tfor key, value := range c.services {\n\t\tif strings.HasPrefix(key, prefix) {\n\t\t\tvalueCopy := value\n\t\t\tvalueCopy.Key = key\n\t\t\tresult = append(result, &valueCopy)\n\t\t}\n\t}\n\treturn result, nil\n}\n\nfunc (c fakeETCDClient) SaveService(_ context.Context, service *Service) error {\n\tc.services[service.Key] = *service\n\treturn nil\n}\n\nfunc (c fakeETCDClient) DeleteService(_ context.Context, key string) error {\n\tdelete(c.services, key)\n\treturn nil\n}\n\ntype MockEtcdKV struct {\n\tetcdcv3.KV\n\tmock.Mock\n}\n\nfunc (m *MockEtcdKV) Put(ctx context.Context, key, input string, _ ...etcdcv3.OpOption) (*etcdcv3.PutResponse, error) {\n\targs := m.Called(ctx, key, input)\n\treturn args.Get(0).(*etcdcv3.PutResponse), args.Error(1)\n}\n\nfunc (m *MockEtcdKV) Get(ctx context.Context, key string, opts ...etcdcv3.OpOption) (*etcdcv3.GetResponse, error) {\n\tif len(opts) == 0 {\n\t\targs := m.Called(ctx, key)\n\t\treturn args.Get(0).(*etcdcv3.GetResponse), args.Error(1)\n\t} else {\n\t\targs := m.Called(ctx, key, opts[0])\n\t\treturn args.Get(0).(*etcdcv3.GetResponse), args.Error(1)\n\t}\n}\n\nfunc (m *MockEtcdKV) Delete(ctx context.Context, key string, opts ...etcdcv3.OpOption) (*etcdcv3.DeleteResponse, error) {\n\tif len(opts) == 0 {\n\t\targs := m.Called(ctx, key)\n\t\treturn args.Get(0).(*etcdcv3.DeleteResponse), args.Error(1)\n\t} else {\n\t\targs := m.Called(ctx, key, opts[0])\n\t\treturn args.Get(0).(*etcdcv3.DeleteResponse), args.Error(1)\n\t}\n}\n\nfunc TestETCDConfig(t *testing.T) {\n\tvar tests = []struct {\n\t\tname  string\n\t\tinput map[string]string\n\t\twant  *etcdcv3.Config\n\t}{\n\t\t{\n\t\t\t\"default config\",\n\t\t\tmap[string]string{},\n\t\t\t&etcdcv3.Config{Endpoints: []string{\"http://localhost:2379\"}},\n\t\t},\n\t\t{\n\t\t\t\"config with ETCD_URLS\",\n\t\t\tmap[string]string{\"ETCD_URLS\": \"http://example.com:2379\"},\n\t\t\t&etcdcv3.Config{Endpoints: []string{\"http://example.com:2379\"}},\n\t\t},\n\t\t{\n\t\t\t\"config with ETCD_USERNAME and ETCD_PASSWORD\",\n\t\t\tmap[string]string{\"ETCD_USERNAME\": \"root\", \"ETCD_PASSWORD\": \"test\"},\n\t\t\t&etcdcv3.Config{\n\t\t\t\tEndpoints: []string{\"http://localhost:2379\"},\n\t\t\t\tUsername:  \"root\",\n\t\t\t\tPassword:  \"test\",\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\ttestutils.TestHelperEnvSetter(t, tt.input)\n\t\t\tcfg, _ := getETCDConfig()\n\t\t\tif !reflect.DeepEqual(cfg, tt.want) {\n\t\t\t\tt.Errorf(\"unexpected config. Got %v, want %v\", cfg, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEtcdHttpsProtocol(t *testing.T) {\n\tenvs := map[string]string{\n\t\t\"ETCD_URLS\": \"https://example.com:2379\",\n\t}\n\ttestutils.TestHelperEnvSetter(t, envs)\n\n\tcfg, err := getETCDConfig()\n\tassert.NoError(t, err)\n\tassert.NotNil(t, cfg)\n}\n\nfunc TestEtcdHttpsIncorrectConfigError(t *testing.T) {\n\tenvs := map[string]string{\n\t\t\"ETCD_URLS\":     \"https://example.com:2379\",\n\t\t\"ETCD_KEY_FILE\": \"incorrect-path-to-etcd-tls-key\",\n\t}\n\ttestutils.TestHelperEnvSetter(t, envs)\n\n\t_, err := getETCDConfig()\n\tassert.Errorf(t, err, \"Error creating TLS config: either both cert and key or none must be provided\")\n}\n\nfunc TestEtcdUnsupportedProtocolError(t *testing.T) {\n\tenvs := map[string]string{\n\t\t\"ETCD_URLS\": \"jdbc:ftp:RemoteHost=MyFTPServer\",\n\t}\n\ttestutils.TestHelperEnvSetter(t, envs)\n\n\t_, err := getETCDConfig()\n\tassert.Errorf(t, err, \"etcd URLs must start with either http:// or https://\")\n}\n\nfunc TestAServiceTranslation(t *testing.T) {\n\texpectedTarget := \"1.2.3.4\"\n\texpectedDNSName := \"example.com\"\n\texpectedRecordType := endpoint.RecordTypeA\n\n\tclient := fakeETCDClient{\n\t\tmap[string]Service{\n\t\t\t\"/skydns/com/example\": {Host: expectedTarget},\n\t\t},\n\t}\n\tprovider := coreDNSProvider{\n\t\tclient:        client,\n\t\tcoreDNSPrefix: defaultCoreDNSPrefix,\n\t}\n\tendpoints, err := provider.Records(t.Context())\n\trequire.NoError(t, err)\n\tif len(endpoints) != 1 {\n\t\tt.Fatalf(\"got unexpected number of endpoints: %d\", len(endpoints))\n\t}\n\tif endpoints[0].DNSName != expectedDNSName {\n\t\tt.Errorf(\"got unexpected DNS name: %s != %s\", endpoints[0].DNSName, expectedDNSName)\n\t}\n\tif endpoints[0].Targets[0] != expectedTarget {\n\t\tt.Errorf(\"got unexpected DNS target: %s != %s\", endpoints[0].Targets[0], expectedTarget)\n\t}\n\tif endpoints[0].RecordType != expectedRecordType {\n\t\tt.Errorf(\"got unexpected DNS record type: %s != %s\", endpoints[0].RecordType, expectedRecordType)\n\t}\n}\n\nfunc TestCNAMEServiceTranslation(t *testing.T) {\n\texpectedTarget := \"example.net\"\n\texpectedDNSName := \"example.com\"\n\texpectedRecordType := endpoint.RecordTypeCNAME\n\n\tclient := fakeETCDClient{\n\t\tmap[string]Service{\n\t\t\t\"/skydns/com/example\": {Host: expectedTarget},\n\t\t},\n\t}\n\tprovider := coreDNSProvider{\n\t\tclient:        client,\n\t\tcoreDNSPrefix: defaultCoreDNSPrefix,\n\t}\n\tendpoints, err := provider.Records(t.Context())\n\trequire.NoError(t, err)\n\tif len(endpoints) != 1 {\n\t\tt.Fatalf(\"got unexpected number of endpoints: %d\", len(endpoints))\n\t}\n\tif endpoints[0].DNSName != expectedDNSName {\n\t\tt.Errorf(\"got unexpected DNS name: %s != %s\", endpoints[0].DNSName, expectedDNSName)\n\t}\n\tif endpoints[0].Targets[0] != expectedTarget {\n\t\tt.Errorf(\"got unexpected DNS target: %s != %s\", endpoints[0].Targets[0], expectedTarget)\n\t}\n\tif endpoints[0].RecordType != expectedRecordType {\n\t\tt.Errorf(\"got unexpected DNS record type: %s != %s\", endpoints[0].RecordType, expectedRecordType)\n\t}\n}\n\nfunc TestTXTServiceTranslation(t *testing.T) {\n\texpectedTarget := \"string\"\n\texpectedDNSName := \"example.com\"\n\texpectedRecordType := endpoint.RecordTypeTXT\n\n\tclient := fakeETCDClient{\n\t\tmap[string]Service{\n\t\t\t\"/skydns/com/example\": {Text: expectedTarget},\n\t\t},\n\t}\n\tprovider := coreDNSProvider{\n\t\tclient:        client,\n\t\tcoreDNSPrefix: defaultCoreDNSPrefix,\n\t}\n\tendpoints, err := provider.Records(t.Context())\n\trequire.NoError(t, err)\n\tif len(endpoints) != 1 {\n\t\tt.Fatalf(\"got unexpected number of endpoints: %d\", len(endpoints))\n\t}\n\tif endpoints[0].DNSName != expectedDNSName {\n\t\tt.Errorf(\"got unexpected DNS name: %s != %s\", endpoints[0].DNSName, expectedDNSName)\n\t}\n\tif endpoints[0].Targets[0] != expectedTarget {\n\t\tt.Errorf(\"got unexpected DNS target: %s != %s\", endpoints[0].Targets[0], expectedTarget)\n\t}\n\tif endpoints[0].RecordType != expectedRecordType {\n\t\tt.Errorf(\"got unexpected DNS record type: %s != %s\", endpoints[0].RecordType, expectedRecordType)\n\t}\n}\n\nfunc TestAWithTXTServiceTranslation(t *testing.T) {\n\texpectedTargets := map[string]string{\n\t\tendpoint.RecordTypeA:   \"1.2.3.4\",\n\t\tendpoint.RecordTypeTXT: \"string\",\n\t}\n\texpectedDNSName := \"example.com\"\n\n\tclient := fakeETCDClient{\n\t\tmap[string]Service{\n\t\t\t\"/skydns/com/example\": {Host: \"1.2.3.4\", Text: \"string\"},\n\t\t},\n\t}\n\tprovider := coreDNSProvider{\n\t\tclient:        client,\n\t\tcoreDNSPrefix: defaultCoreDNSPrefix,\n\t}\n\tendpoints, err := provider.Records(t.Context())\n\trequire.NoError(t, err)\n\tif len(endpoints) != len(expectedTargets) {\n\t\tt.Fatalf(\"got unexpected number of endpoints: %d\", len(endpoints))\n\t}\n\n\tfor _, ep := range endpoints {\n\t\texpectedTarget := expectedTargets[ep.RecordType]\n\t\tif expectedTarget == \"\" {\n\t\t\tt.Errorf(\"got unexpected DNS record type: %s\", ep.RecordType)\n\t\t\tcontinue\n\t\t}\n\t\tdelete(expectedTargets, ep.RecordType)\n\n\t\tif ep.DNSName != expectedDNSName {\n\t\t\tt.Errorf(\"got unexpected DNS name: %s != %s\", ep.DNSName, expectedDNSName)\n\t\t}\n\n\t\tif ep.Targets[0] != expectedTarget {\n\t\t\tt.Errorf(\"got unexpected DNS target: %s != %s\", ep.Targets[0], expectedTarget)\n\t\t}\n\t}\n}\n\nfunc TestCNAMEWithTXTServiceTranslation(t *testing.T) {\n\texpectedTargets := map[string]string{\n\t\tendpoint.RecordTypeCNAME: \"example.net\",\n\t\tendpoint.RecordTypeTXT:   \"string\",\n\t}\n\texpectedDNSName := \"example.com\"\n\n\tclient := fakeETCDClient{\n\t\tmap[string]Service{\n\t\t\t\"/skydns/com/example\": {Host: \"example.net\", Text: \"string\"},\n\t\t},\n\t}\n\tprovider := coreDNSProvider{\n\t\tclient:        client,\n\t\tcoreDNSPrefix: defaultCoreDNSPrefix,\n\t}\n\tendpoints, err := provider.Records(t.Context())\n\trequire.NoError(t, err)\n\tif len(endpoints) != len(expectedTargets) {\n\t\tt.Fatalf(\"got unexpected number of endpoints: %d\", len(endpoints))\n\t}\n\n\tfor _, ep := range endpoints {\n\t\texpectedTarget := expectedTargets[ep.RecordType]\n\t\tif expectedTarget == \"\" {\n\t\t\tt.Errorf(\"got unexpected DNS record type: %s\", ep.RecordType)\n\t\t\tcontinue\n\t\t}\n\t\tdelete(expectedTargets, ep.RecordType)\n\n\t\tif ep.DNSName != expectedDNSName {\n\t\t\tt.Errorf(\"got unexpected DNS name: %s != %s\", ep.DNSName, expectedDNSName)\n\t\t}\n\n\t\tif ep.Targets[0] != expectedTarget {\n\t\t\tt.Errorf(\"got unexpected DNS target: %s != %s\", ep.Targets[0], expectedTarget)\n\t\t}\n\t}\n}\n\nfunc TestCoreDNSApplyChanges(t *testing.T) {\n\tclient := fakeETCDClient{\n\t\tmap[string]Service{},\n\t}\n\tcoredns := coreDNSProvider{\n\t\tclient:        client,\n\t\tcoreDNSPrefix: defaultCoreDNSPrefix,\n\t}\n\n\tchanges1 := &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\tendpoint.NewEndpoint(\"domain1.local\", endpoint.RecordTypeA, \"5.5.5.5\"),\n\t\t\tendpoint.NewEndpoint(\"domain1.local\", endpoint.RecordTypeTXT, \"string1\"),\n\t\t\tendpoint.NewEndpoint(\"domain2.local\", endpoint.RecordTypeCNAME, \"site.local\"),\n\t\t},\n\t}\n\terr := coredns.ApplyChanges(t.Context(), changes1)\n\trequire.NoError(t, err)\n\n\texpectedServices1 := map[string][]*Service{\n\t\t\"/skydns/local/domain1\": {{Host: \"5.5.5.5\", Text: \"string1\"}},\n\t\t\"/skydns/local/domain2\": {{Host: \"site.local\"}},\n\t}\n\tvalidateServices(client.services, expectedServices1, t, 1)\n\n\tchanges2 := &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\tendpoint.NewEndpoint(\"domain3.local\", endpoint.RecordTypeA, \"7.7.7.7\"),\n\t\t},\n\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\tendpoint.NewEndpoint(\"domain1.local\", \"A\", \"6.6.6.6\"),\n\t\t},\n\t}\n\trecords, _ := coredns.Records(t.Context())\n\tfor _, ep := range records {\n\t\tif ep.DNSName == \"domain1.local\" {\n\t\t\tchanges2.UpdateOld = append(changes2.UpdateOld, ep)\n\t\t}\n\t}\n\terr = applyServiceChanges(coredns, changes2)\n\trequire.NoError(t, err)\n\n\texpectedServices2 := map[string][]*Service{\n\t\t\"/skydns/local/domain1\": {{Host: \"6.6.6.6\", Text: \"string1\"}},\n\t\t\"/skydns/local/domain2\": {{Host: \"site.local\"}},\n\t\t\"/skydns/local/domain3\": {{Host: \"7.7.7.7\"}},\n\t}\n\tvalidateServices(client.services, expectedServices2, t, 2)\n\n\tchanges3 := &plan.Changes{\n\t\tDelete: []*endpoint.Endpoint{\n\t\t\tendpoint.NewEndpoint(\"domain1.local\", endpoint.RecordTypeA, \"6.6.6.6\"),\n\t\t\tendpoint.NewEndpoint(\"domain1.local\", endpoint.RecordTypeTXT, \"string\"),\n\t\t\tendpoint.NewEndpoint(\"domain3.local\", endpoint.RecordTypeA, \"7.7.7.7\"),\n\t\t},\n\t}\n\n\terr = applyServiceChanges(coredns, changes3)\n\trequire.NoError(t, err)\n\n\texpectedServices3 := map[string][]*Service{\n\t\t\"/skydns/local/domain2\": {{Host: \"site.local\"}},\n\t}\n\tvalidateServices(client.services, expectedServices3, t, 3)\n\n\t// Test for multiple A records for the same FQDN\n\tchanges4 := &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\tendpoint.NewEndpoint(\"domain1.local\", endpoint.RecordTypeA, \"5.5.5.5\"),\n\t\t\tendpoint.NewEndpoint(\"domain1.local\", endpoint.RecordTypeA, \"6.6.6.6\"),\n\t\t\tendpoint.NewEndpoint(\"domain1.local\", endpoint.RecordTypeA, \"7.7.7.7\"),\n\t\t},\n\t}\n\terr = coredns.ApplyChanges(t.Context(), changes4)\n\trequire.NoError(t, err)\n\n\texpectedServices4 := map[string][]*Service{\n\t\t\"/skydns/local/domain2\": {{Host: \"site.local\"}},\n\t\t\"/skydns/local/domain1\": {{Host: \"5.5.5.5\"}, {Host: \"6.6.6.6\"}, {Host: \"7.7.7.7\"}},\n\t}\n\tvalidateServices(client.services, expectedServices4, t, 4)\n}\n\nfunc TestCoreDNSApplyChanges_DomainDoNotMatch(t *testing.T) {\n\tclient := fakeETCDClient{\n\t\tmap[string]Service{},\n\t}\n\tcoredns := coreDNSProvider{\n\t\tclient:        client,\n\t\tcoreDNSPrefix: defaultCoreDNSPrefix,\n\t\tdomainFilter:  endpoint.NewDomainFilter([]string{\"example.local\"}),\n\t}\n\n\tchanges1 := &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\tendpoint.NewEndpoint(\"domain1.local\", endpoint.RecordTypeA, \"5.5.5.5\"),\n\t\t\tendpoint.NewEndpoint(\"example.local\", endpoint.RecordTypeTXT, \"string1\"),\n\t\t\tendpoint.NewEndpoint(\"domain2.local\", endpoint.RecordTypeCNAME, \"site.local\"),\n\t\t},\n\t}\n\thook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t)\n\terr := coredns.ApplyChanges(t.Context(), changes1)\n\trequire.NoError(t, err)\n\n\tlogtest.TestHelperLogContains(\"Skipping record \\\"domain1.local\\\" due to domain filter\", hook, t)\n\tlogtest.TestHelperLogContains(\"Skipping record \\\"domain2.local\\\" due to domain filter\", hook, t)\n}\n\nfunc applyServiceChanges(provider coreDNSProvider, changes *plan.Changes) error {\n\tctx := context.Background()\n\trecords, _ := provider.Records(ctx)\n\tfor _, col := range [][]*endpoint.Endpoint{changes.Create, changes.UpdateNew, changes.Delete} {\n\t\tfor _, record := range col {\n\t\t\tfor _, existingRecord := range records {\n\t\t\t\tif existingRecord.DNSName == record.DNSName && existingRecord.RecordType == record.RecordType {\n\t\t\t\t\tmergeLabels(record, existingRecord.Labels)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn provider.ApplyChanges(ctx, changes)\n}\n\nfunc validateServices(services map[string]Service, expectedServices map[string][]*Service, t *testing.T, step int) {\n\tt.Helper()\n\tfor key, value := range services {\n\t\tkeyParts := strings.Split(key, \"/\")\n\t\texpectedKey := strings.Join(keyParts[:len(keyParts)-value.TargetStrip], \"/\")\n\t\texpectedServiceEntries := expectedServices[expectedKey]\n\t\tif expectedServiceEntries == nil {\n\t\t\tt.Errorf(\"unexpected service %s\", key)\n\t\t\tcontinue\n\t\t}\n\t\tfound := false\n\t\tfor i, expectedServiceEntry := range expectedServiceEntries {\n\t\t\tif value.Host == expectedServiceEntry.Host && value.Text == expectedServiceEntry.Text && value.Group == expectedServiceEntry.Group {\n\t\t\t\texpectedServiceEntries = append(expectedServiceEntries[:i], expectedServiceEntries[i+1:]...)\n\t\t\t\tfound = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif !found {\n\t\t\tt.Errorf(\"unexpected service %s: %s on step %d\", key, value.Host, step)\n\t\t}\n\t\tif len(expectedServiceEntries) == 0 {\n\t\t\tdelete(expectedServices, expectedKey)\n\t\t} else {\n\t\t\texpectedServices[expectedKey] = expectedServiceEntries\n\t\t}\n\t}\n\tif len(expectedServices) != 0 {\n\t\tt.Errorf(\"unmatched expected services: %+v on step %d\", expectedServices, step)\n\t}\n}\n\n// mergeLabels adds keys to labels if not defined for the endpoint\nfunc mergeLabels(e *endpoint.Endpoint, labels map[string]string) {\n\tfor k, v := range labels {\n\t\tif e.Labels[k] == \"\" {\n\t\t\te.Labels[k] = v\n\t\t}\n\t}\n}\n\nfunc TestGetServices_Success(t *testing.T) {\n\tsvc := Service{Host: \"example.com\", Port: 80, Priority: 1, Weight: 10, Text: \"hello\"}\n\tvalue, err := json.Marshal(svc)\n\trequire.NoError(t, err)\n\tmockKV := new(MockEtcdKV)\n\tmockKV.On(\"Get\", mock.Anything, \"/prefix\", mock.AnythingOfType(\"clientv3.OpOption\")).\n\t\tReturn(&etcdcv3.GetResponse{\n\t\t\tKvs: []*mvccpb.KeyValue{\n\t\t\t\t{\n\t\t\t\t\tKey:   []byte(\"/prefix/1\"),\n\t\t\t\t\tValue: value,\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil)\n\n\tc := etcdClient{\n\t\tclient: &etcdcv3.Client{\n\t\t\tKV: mockKV,\n\t\t},\n\t}\n\n\tresult, err := c.GetServices(t.Context(), \"/prefix\")\n\tassert.NoError(t, err)\n\tassert.Len(t, result, 1)\n\tassert.Equal(t, \"example.com\", result[0].Host)\n}\n\nfunc TestGetServices_Duplicate(t *testing.T) {\n\tmockKV := new(MockEtcdKV)\n\tc := etcdClient{\n\t\tclient: &etcdcv3.Client{\n\t\t\tKV: mockKV,\n\t\t},\n\t}\n\n\tsvc := Service{Host: \"example.com\", Port: 80, Priority: 1, Weight: 10, Text: \"hello\"}\n\tvalue, err := json.Marshal(svc)\n\trequire.NoError(t, err)\n\n\tmockKV.On(\"Get\", mock.Anything, \"/prefix\", mock.AnythingOfType(\"clientv3.OpOption\")).\n\t\tReturn(&etcdcv3.GetResponse{\n\t\t\tKvs: []*mvccpb.KeyValue{\n\t\t\t\t{\n\t\t\t\t\tKey:   []byte(\"/prefix/1\"),\n\t\t\t\t\tValue: value,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tKey:   []byte(\"/prefix/1\"),\n\t\t\t\t\tValue: value,\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil)\n\n\tresult, err := c.GetServices(t.Context(), \"/prefix\")\n\tassert.NoError(t, err)\n\tassert.Len(t, result, 1)\n}\n\nfunc TestGetServices_Multiple(t *testing.T) {\n\tmockKV := new(MockEtcdKV)\n\tc := etcdClient{\n\t\tclient: &etcdcv3.Client{\n\t\t\tKV: mockKV,\n\t\t},\n\t}\n\n\tsvc := Service{Host: \"example.com\", Port: 80, Priority: 1, Weight: 10, Text: \"hello\"}\n\tvalue, err := json.Marshal(svc)\n\trequire.NoError(t, err)\n\tsvc2 := Service{Host: \"example.com\", Port: 80, Priority: 0, Weight: 10, Text: \"hello\"}\n\tvalue2, err := json.Marshal(svc2)\n\trequire.NoError(t, err)\n\n\tmockKV.On(\"Get\", mock.Anything, \"/prefix\", mock.AnythingOfType(\"clientv3.OpOption\")).\n\t\tReturn(&etcdcv3.GetResponse{\n\t\t\tKvs: []*mvccpb.KeyValue{\n\t\t\t\t{\n\t\t\t\t\tKey:   []byte(\"/prefix/1\"),\n\t\t\t\t\tValue: value,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tKey:   []byte(\"/prefix/2\"),\n\t\t\t\t\tValue: value2,\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil)\n\n\tresult, err := c.GetServices(t.Context(), \"/prefix\")\n\tassert.NoError(t, err)\n\tassert.Len(t, result, 2)\n\tassert.Equal(t, priority, result[1].Priority)\n}\n\nfunc TestGetServices_FilterOutOtherServicesOwnerSetButNothingChanged(t *testing.T) {\n\tmockKV := new(MockEtcdKV)\n\tc := etcdClient{\n\t\tclient: &etcdcv3.Client{\n\t\t\tKV: mockKV,\n\t\t},\n\t\towner:         \"owner\",\n\t\tstrictlyOwned: false,\n\t}\n\n\tsvc := Service{Host: \"example.com\", Port: 80, Priority: 1, Weight: 10, Text: \"hello\", Owner: \"owner\"}\n\tvalue, err := json.Marshal(svc)\n\trequire.NoError(t, err)\n\tsvc2 := Service{Host: \"example.com\", Port: 80, Priority: 0, Weight: 10, Text: \"hello\", Owner: \"\"}\n\tvalue2, err := json.Marshal(svc2)\n\trequire.NoError(t, err)\n\tsvc3 := Service{Host: \"example.com\", Port: 80, Priority: 0, Weight: 10, Text: \"hello\", Owner: \"different-owner\"}\n\tvalue3, err := json.Marshal(svc3)\n\trequire.NoError(t, err)\n\n\tmockKV.On(\"Get\", mock.Anything, \"/prefix\", mock.AnythingOfType(\"clientv3.OpOption\")).\n\t\tReturn(&etcdcv3.GetResponse{\n\t\t\tKvs: []*mvccpb.KeyValue{\n\t\t\t\t{\n\t\t\t\t\tKey:   []byte(\"/prefix/1\"),\n\t\t\t\t\tValue: value,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tKey:   []byte(\"/prefix/2\"),\n\t\t\t\t\tValue: value2,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tKey:   []byte(\"/prefix/3\"),\n\t\t\t\t\tValue: value3,\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil)\n\n\tresult, err := c.GetServices(t.Context(), \"/prefix\")\n\tassert.NoError(t, err)\n\tassert.Len(t, result, 3)\n}\n\nfunc TestGetServices_FilterOutOtherServicesWithStrictlyOwned(t *testing.T) {\n\tmockKV := new(MockEtcdKV)\n\tc := etcdClient{\n\t\tclient: &etcdcv3.Client{\n\t\t\tKV: mockKV,\n\t\t},\n\t\towner:         \"owner\",\n\t\tstrictlyOwned: true,\n\t}\n\n\tsvc := Service{Host: \"example.com\", Port: 80, Priority: 1, Weight: 10, Text: \"hello\", Owner: \"owner\"}\n\tvalue, err := json.Marshal(svc)\n\trequire.NoError(t, err)\n\tsvc2 := Service{Host: \"example.com\", Port: 80, Priority: 0, Weight: 10, Text: \"hello\", Owner: \"\"}\n\tvalue2, err := json.Marshal(svc2)\n\trequire.NoError(t, err)\n\tsvc3 := Service{Host: \"example.com\", Port: 80, Priority: 0, Weight: 10, Text: \"hello\", Owner: \"different-owner\"}\n\tvalue3, err := json.Marshal(svc3)\n\trequire.NoError(t, err)\n\n\tmockKV.On(\"Get\", mock.Anything, \"/prefix\", mock.AnythingOfType(\"clientv3.OpOption\")).\n\t\tReturn(&etcdcv3.GetResponse{\n\t\t\tKvs: []*mvccpb.KeyValue{\n\t\t\t\t{\n\t\t\t\t\tKey:   []byte(\"/prefix/1\"),\n\t\t\t\t\tValue: value,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tKey:   []byte(\"/prefix/2\"),\n\t\t\t\t\tValue: value2,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tKey:   []byte(\"/prefix/3\"),\n\t\t\t\t\tValue: value3,\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil)\n\n\tresult, err := c.GetServices(t.Context(), \"/prefix\")\n\tassert.NoError(t, err)\n\tassert.Len(t, result, 1)\n\tassert.Equal(t, \"owner\", result[0].Owner)\n}\n\nfunc TestGetServices_UnmarshalError(t *testing.T) {\n\tmockKV := new(MockEtcdKV)\n\tc := etcdClient{\n\t\tclient: &etcdcv3.Client{\n\t\t\tKV: mockKV,\n\t\t},\n\t}\n\n\tmockKV.On(\"Get\", mock.Anything, \"/prefix\", mock.AnythingOfType(\"clientv3.OpOption\")).\n\t\tReturn(&etcdcv3.GetResponse{\n\t\t\tKvs: []*mvccpb.KeyValue{\n\t\t\t\t{\n\t\t\t\t\tKey:   []byte(\"/prefix/1\"),\n\t\t\t\t\tValue: []byte(\"invalid-json\"),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tKey:   []byte(\"/prefix/1\"),\n\t\t\t\t\tValue: []byte(\"invalid-json\"),\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil)\n\n\t_, err := c.GetServices(t.Context(), \"/prefix\")\n\tassert.Error(t, err)\n\tassert.Contains(t, err.Error(), \"/prefix/1\")\n}\n\nfunc TestGetServices_GetError(t *testing.T) {\n\tmockKV := new(MockEtcdKV)\n\tc := etcdClient{\n\t\tclient: &etcdcv3.Client{\n\t\t\tKV: mockKV,\n\t\t},\n\t}\n\n\tmockKV.On(\"Get\", mock.Anything, \"/prefix\", mock.AnythingOfType(\"clientv3.OpOption\")).\n\t\tReturn(&etcdcv3.GetResponse{}, errors.New(\"etcd failure\"))\n\n\t_, err := c.GetServices(t.Context(), \"/prefix\")\n\tassert.Error(t, err)\n\tassert.EqualError(t, err, \"etcd failure\")\n}\n\nfunc TestDeleteService(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tkey     string\n\t\tmockErr error\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"successful deletion\",\n\t\t\tkey:  \"/skydns/local/test\",\n\t\t},\n\t\t{\n\t\t\tname:    \"etcd error\",\n\t\t\tkey:     \"/skydns/local/test\",\n\t\t\tmockErr: errors.New(\"etcd failure\"),\n\t\t\twantErr: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tmockKV := new(MockEtcdKV)\n\t\t\tmockKV.On(\"Delete\", mock.Anything, mock.Anything, mock.AnythingOfType(\"clientv3.OpOption\")).\n\t\t\t\tReturn(&etcdcv3.DeleteResponse{}, tt.mockErr)\n\n\t\t\tc := etcdClient{\n\t\t\t\tclient: &etcdcv3.Client{\n\t\t\t\t\tKV: mockKV,\n\t\t\t\t},\n\t\t\t}\n\n\t\t\terr := c.DeleteService(t.Context(), tt.key)\n\n\t\t\tif tt.wantErr {\n\t\t\t\trequire.Error(t, err)\n\t\t\t\tassert.Equal(t, tt.mockErr, err)\n\t\t\t} else {\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\t\t\tmockKV.AssertExpectations(t)\n\t\t})\n\t}\n}\n\nfunc TestDeleteServiceWithStrictlyOwned(t *testing.T) {\n\ttests := []struct {\n\t\tname             string\n\t\towner            string\n\t\tkey              string\n\t\texistingServices []Service\n\t\tdeletedKeys      []string\n\t}{\n\t\t{\n\t\t\tname:  \"successful deletion with the same owner with strictly owned\",\n\t\t\tkey:   \"/skydns/local/test\",\n\t\t\towner: \"owner\",\n\t\t\texistingServices: []Service{{\n\t\t\t\tHost:     \"example.com\",\n\t\t\t\tPort:     80,\n\t\t\t\tPriority: 1,\n\t\t\t\tWeight:   10,\n\t\t\t\tText:     \"hello\",\n\t\t\t\tKey:      \"/skydns/local/test\",\n\t\t\t\tOwner:    \"owner\",\n\t\t\t}},\n\t\t\tdeletedKeys: []string{\"/skydns/local/test\"},\n\t\t},\n\t\t{\n\t\t\tname:  \"prevent deletion of a service without an owner with strictly owned\",\n\t\t\tkey:   \"/skydns/local/test\",\n\t\t\towner: \"owner\",\n\t\t\texistingServices: []Service{{\n\t\t\t\tHost:     \"example.com\",\n\t\t\t\tPort:     80,\n\t\t\t\tPriority: 1,\n\t\t\t\tWeight:   10,\n\t\t\t\tText:     \"hello\",\n\t\t\t\tKey:      \"/skydns/local/test\",\n\t\t\t}},\n\t\t\tdeletedKeys: []string{},\n\t\t},\n\t\t{\n\t\t\tname:  \"prevent deletion with different owner with strictly owned\",\n\t\t\tkey:   \"/skydns/local/test\",\n\t\t\towner: \"owner\",\n\t\t\texistingServices: []Service{{\n\t\t\t\tHost:     \"example.com\",\n\t\t\t\tPort:     80,\n\t\t\t\tPriority: 1,\n\t\t\t\tWeight:   10,\n\t\t\t\tText:     \"hello\",\n\t\t\t\tKey:      \"/skydns/local/test\",\n\t\t\t\tOwner:    \"other-owner\",\n\t\t\t}},\n\t\t\tdeletedKeys: []string{},\n\t\t},\n\t\t{\n\t\t\tname:  \"successful partial deletion for same owners with strictly owned\",\n\t\t\tkey:   \"/skydns/local/test\",\n\t\t\towner: \"owner\",\n\t\t\texistingServices: []Service{\n\t\t\t\t{\n\t\t\t\t\tHost:     \"example.com\",\n\t\t\t\t\tPort:     80,\n\t\t\t\t\tPriority: 1,\n\t\t\t\t\tWeight:   10,\n\t\t\t\t\tText:     \"hello\",\n\t\t\t\t\tKey:      \"/skydns/local/test/1\",\n\t\t\t\t\tOwner:    \"owner\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tHost:     \"example.com\",\n\t\t\t\t\tPort:     80,\n\t\t\t\t\tPriority: 1,\n\t\t\t\t\tWeight:   10,\n\t\t\t\t\tText:     \"hello\",\n\t\t\t\t\tKey:      \"/skydns/local/test/2\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tHost:     \"example.com\",\n\t\t\t\t\tPort:     80,\n\t\t\t\t\tPriority: 1,\n\t\t\t\t\tWeight:   10,\n\t\t\t\t\tText:     \"hello\",\n\t\t\t\t\tKey:      \"/skydns/local/test/3\",\n\t\t\t\t\tOwner:    \"different-owner\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tHost:     \"example.com\",\n\t\t\t\t\tPort:     80,\n\t\t\t\t\tPriority: 1,\n\t\t\t\t\tWeight:   10,\n\t\t\t\t\tText:     \"hello\",\n\t\t\t\t\tKey:      \"/skydns/local/test/4\",\n\t\t\t\t\tOwner:    \"owner\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tdeletedKeys: []string{\"/skydns/local/test/1\", \"/skydns/local/test/4\"},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tmockKV := new(MockEtcdKV)\n\t\t\tfor _, key := range tt.deletedKeys {\n\t\t\t\tmockKV.On(\"Delete\", mock.Anything, key).\n\t\t\t\t\tReturn(&etcdcv3.DeleteResponse{}, nil)\n\t\t\t}\n\t\t\tkvs := []*mvccpb.KeyValue{}\n\t\t\tfor _, service := range tt.existingServices {\n\t\t\t\tactualValue, err := json.Marshal(&service)\n\t\t\t\trequire.NoError(t, err)\n\t\t\t\tkvs = append(kvs, &mvccpb.KeyValue{\n\t\t\t\t\tKey:   []byte(service.Key),\n\t\t\t\t\tValue: actualValue,\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tmockKV.On(\"Get\", mock.Anything, tt.key, mock.AnythingOfType(\"clientv3.OpOption\")).Return(&etcdcv3.GetResponse{\n\t\t\t\tKvs: kvs,\n\t\t\t}, nil)\n\n\t\t\tc := etcdClient{\n\t\t\t\tclient: &etcdcv3.Client{\n\t\t\t\t\tKV: mockKV,\n\t\t\t\t},\n\t\t\t\towner:         tt.owner,\n\t\t\t\tstrictlyOwned: true,\n\t\t\t}\n\n\t\t\terr := c.DeleteService(t.Context(), tt.key)\n\n\t\t\trequire.NoError(t, err)\n\t\t\tmockKV.AssertExpectations(t)\n\t\t})\n\t}\n}\n\nfunc TestSaveService(t *testing.T) {\n\ttype testCase struct {\n\t\tname            string\n\t\towner           string\n\t\tstrictlyOwned   bool\n\t\tservice         *Service\n\t\texpectedService *Service\n\t\texists          bool\n\t\tignoreGetCall   bool\n\t\tmockPutErr      error\n\t\twantErr         bool\n\t}\n\ttests := []testCase{\n\t\t{\n\t\t\tname: \"success\",\n\t\t\tservice: &Service{\n\t\t\t\tHost:     \"example.com\",\n\t\t\t\tPort:     80,\n\t\t\t\tPriority: 1,\n\t\t\t\tWeight:   10,\n\t\t\t\tText:     \"hello\",\n\t\t\t\tKey:      \"/prefix/1\",\n\t\t\t},\n\t\t\texpectedService: &Service{\n\t\t\t\tHost:     \"example.com\",\n\t\t\t\tPort:     80,\n\t\t\t\tPriority: 1,\n\t\t\t\tWeight:   10,\n\t\t\t\tText:     \"hello\",\n\t\t\t\tKey:      \"/prefix/1\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:   \"success with 'owner' without strictly owned\",\n\t\t\towner:  \"owner\",\n\t\t\texists: true,\n\t\t\tservice: &Service{\n\t\t\t\tHost:     \"example.com\",\n\t\t\t\tPort:     80,\n\t\t\t\tPriority: 1,\n\t\t\t\tWeight:   10,\n\t\t\t\tText:     \"hello\",\n\t\t\t\tKey:      \"/prefix/1\",\n\t\t\t},\n\t\t\texpectedService: &Service{\n\t\t\t\tHost:     \"example.com\",\n\t\t\t\tPort:     80,\n\t\t\t\tPriority: 1,\n\t\t\t\tWeight:   10,\n\t\t\t\tText:     \"hello\",\n\t\t\t\tKey:      \"/prefix/1\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:   \"success with 'owner' (creation) without strictly owned\",\n\t\t\towner:  \"owner\",\n\t\t\texists: false,\n\t\t\tservice: &Service{\n\t\t\t\tHost:     \"example.com\",\n\t\t\t\tPort:     80,\n\t\t\t\tPriority: 1,\n\t\t\t\tWeight:   10,\n\t\t\t\tText:     \"hello\",\n\t\t\t\tKey:      \"/prefix/1\",\n\t\t\t},\n\t\t\texpectedService: &Service{\n\t\t\t\tHost:     \"example.com\",\n\t\t\t\tPort:     80,\n\t\t\t\tPriority: 1,\n\t\t\t\tWeight:   10,\n\t\t\t\tText:     \"hello\",\n\t\t\t\tKey:      \"/prefix/1\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:   \"success with 'owner' (update) without strictly owned (owner not changed)\",\n\t\t\towner:  \"owner\",\n\t\t\texists: true,\n\t\t\tservice: &Service{\n\t\t\t\tHost:     \"example.com\",\n\t\t\t\tPort:     80,\n\t\t\t\tPriority: 1,\n\t\t\t\tWeight:   10,\n\t\t\t\tText:     \"hello\",\n\t\t\t\tKey:      \"/prefix/1\",\n\t\t\t\tOwner:    \"owner\",\n\t\t\t},\n\t\t\texpectedService: &Service{\n\t\t\t\tHost:     \"example.com\",\n\t\t\t\tPort:     80,\n\t\t\t\tPriority: 1,\n\t\t\t\tWeight:   10,\n\t\t\t\tText:     \"hello\",\n\t\t\t\tKey:      \"/prefix/1\",\n\t\t\t\tOwner:    \"owner\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:   \"success with different 'owner' without strictly owned\",\n\t\t\towner:  \"owner\",\n\t\t\texists: true,\n\t\t\tservice: &Service{\n\t\t\t\tHost:     \"example.com\",\n\t\t\t\tPort:     80,\n\t\t\t\tPriority: 1,\n\t\t\t\tWeight:   10,\n\t\t\t\tText:     \"hello\",\n\t\t\t\tKey:      \"/prefix/1\",\n\t\t\t\tOwner:    \"other-owner\",\n\t\t\t},\n\t\t\texpectedService: &Service{\n\t\t\t\tHost:     \"example.com\",\n\t\t\t\tPort:     80,\n\t\t\t\tPriority: 1,\n\t\t\t\tWeight:   10,\n\t\t\t\tText:     \"hello\",\n\t\t\t\tKey:      \"/prefix/1\",\n\t\t\t\tOwner:    \"other-owner\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:          \"failed with 'owner' is empty with strictly owned\",\n\t\t\towner:         \"owner\",\n\t\t\tstrictlyOwned: true,\n\t\t\texists:        true,\n\t\t\tservice: &Service{\n\t\t\t\tHost:     \"example.com\",\n\t\t\t\tPort:     80,\n\t\t\t\tPriority: 1,\n\t\t\t\tWeight:   10,\n\t\t\t\tText:     \"hello\",\n\t\t\t\tKey:      \"/prefix/1\",\n\t\t\t},\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname:          \"success with 'owner' (creation) with strictly owned\",\n\t\t\towner:         \"owner\",\n\t\t\tstrictlyOwned: true,\n\t\t\texists:        false,\n\t\t\tservice: &Service{\n\t\t\t\tHost:     \"example.com\",\n\t\t\t\tPort:     80,\n\t\t\t\tPriority: 1,\n\t\t\t\tWeight:   10,\n\t\t\t\tText:     \"hello\",\n\t\t\t\tKey:      \"/prefix/1\",\n\t\t\t},\n\t\t\texpectedService: &Service{\n\t\t\t\tHost:     \"example.com\",\n\t\t\t\tPort:     80,\n\t\t\t\tPriority: 1,\n\t\t\t\tWeight:   10,\n\t\t\t\tText:     \"hello\",\n\t\t\t\tKey:      \"/prefix/1\",\n\t\t\t\tOwner:    \"owner\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:          \"success with 'owner' (update) with strictly owned (owner not changed)\",\n\t\t\towner:         \"owner\",\n\t\t\tstrictlyOwned: true,\n\t\t\texists:        true,\n\t\t\tignoreGetCall: true,\n\t\t\tservice: &Service{\n\t\t\t\tHost:     \"example.com\",\n\t\t\t\tPort:     80,\n\t\t\t\tPriority: 1,\n\t\t\t\tWeight:   10,\n\t\t\t\tText:     \"hello\",\n\t\t\t\tKey:      \"/prefix/1\",\n\t\t\t\tOwner:    \"owner\",\n\t\t\t},\n\t\t\texpectedService: &Service{\n\t\t\t\tHost:     \"example.com\",\n\t\t\t\tPort:     80,\n\t\t\t\tPriority: 1,\n\t\t\t\tWeight:   10,\n\t\t\t\tText:     \"hello\",\n\t\t\t\tKey:      \"/prefix/1\",\n\t\t\t\tOwner:    \"owner\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:          \"failed with different 'owner' with strictly owned\",\n\t\t\towner:         \"owner\",\n\t\t\tstrictlyOwned: true,\n\t\t\texists:        true,\n\t\t\tservice: &Service{\n\t\t\t\tHost:     \"example.com\",\n\t\t\t\tPort:     80,\n\t\t\t\tPriority: 1,\n\t\t\t\tWeight:   10,\n\t\t\t\tText:     \"hello\",\n\t\t\t\tKey:      \"/prefix/1\",\n\t\t\t\tOwner:    \"other-owner\",\n\t\t\t},\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"etcd put error\",\n\t\t\tservice: &Service{\n\t\t\t\tHost: \"example.com\",\n\t\t\t\tKey:  \"/prefix/2\",\n\t\t\t},\n\t\t\texpectedService: &Service{\n\t\t\t\tHost: \"example.com\",\n\t\t\t\tKey:  \"/prefix/2\",\n\t\t\t},\n\t\t\tmockPutErr: errors.New(\"etcd failure\"),\n\t\t\twantErr:    true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tmockKV := new(MockEtcdKV)\n\t\t\tvalue, err := json.Marshal(&tt.expectedService)\n\t\t\trequire.NoError(t, err)\n\t\t\tif tt.expectedService != nil {\n\t\t\t\tmockKV.On(\"Put\", mock.Anything, tt.service.Key, string(value)).\n\t\t\t\t\tReturn(&etcdcv3.PutResponse{}, tt.mockPutErr)\n\t\t\t}\n\t\t\tactualValue, err := json.Marshal(&tt.service)\n\t\t\trequire.NoError(t, err)\n\t\t\tif tt.strictlyOwned && !tt.ignoreGetCall {\n\t\t\t\tif tt.exists {\n\t\t\t\t\tmockKV.On(\"Get\", mock.Anything, tt.service.Key).Return(&etcdcv3.GetResponse{\n\t\t\t\t\t\tKvs: []*mvccpb.KeyValue{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tKey:   []byte(tt.service.Key),\n\t\t\t\t\t\t\t\tValue: actualValue,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t}, nil)\n\t\t\t\t} else {\n\t\t\t\t\tmockKV.On(\"Get\", mock.Anything, tt.service.Key).Return(&etcdcv3.GetResponse{\n\t\t\t\t\t\tKvs: []*mvccpb.KeyValue{},\n\t\t\t\t\t}, nil)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tc := etcdClient{\n\t\t\t\tclient: &etcdcv3.Client{\n\t\t\t\t\tKV: mockKV,\n\t\t\t\t},\n\t\t\t\towner:         tt.owner,\n\t\t\t\tstrictlyOwned: tt.strictlyOwned,\n\t\t\t}\n\n\t\t\terr = c.SaveService(t.Context(), tt.service)\n\t\t\tif tt.wantErr {\n\t\t\t\tassert.Error(t, err)\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t}\n\t\t\tmockKV.AssertExpectations(t)\n\t\t})\n\t}\n}\n\nfunc TestNewProvider(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tenvs    map[string]string\n\t\twantErr bool\n\t\terrMsg  string\n\t}{\n\t\t{\n\t\t\tname: \"default config\",\n\t\t\tenvs: map[string]string{},\n\t\t},\n\t\t{\n\t\t\tname: \"config with ETCD_URLS\",\n\t\t\tenvs: map[string]string{\"ETCD_URLS\": \"http://example.com:2379\"},\n\t\t},\n\t\t{\n\t\t\tname:    \"config with unsupported protocol\",\n\t\t\tenvs:    map[string]string{\"ETCD_URLS\": \"ftp://example.com:20\"},\n\t\t\twantErr: true,\n\t\t\terrMsg:  \"etcd URLs must start with either http:// or https://\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\ttestutils.TestHelperEnvSetter(t, tt.envs)\n\n\t\t\tprovider, err := newProvider(&endpoint.DomainFilter{}, \"/prefix/\", \"\", false, false)\n\t\t\tif tt.wantErr {\n\t\t\t\trequire.Error(t, err)\n\t\t\t\tassert.EqualError(t, err, tt.errMsg)\n\t\t\t} else {\n\t\t\t\trequire.NoError(t, err)\n\t\t\t\trequire.NotNil(t, provider)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestFindEp(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tslice    []*endpoint.Endpoint\n\t\tdnsName  string\n\t\twant     *endpoint.Endpoint\n\t\twantBool bool\n\t}{\n\t\t{\n\t\t\tname: \"found\",\n\t\t\tslice: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.com\"},\n\t\t\t\t{DNSName: \"bar.example.com\"},\n\t\t\t},\n\t\t\tdnsName:  \"bar.example.com\",\n\t\t\twant:     &endpoint.Endpoint{DNSName: \"bar.example.com\"},\n\t\t\twantBool: true,\n\t\t},\n\t\t{\n\t\t\tname: \"not found\",\n\t\t\tslice: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.com\"},\n\t\t\t},\n\t\t\tdnsName:  \"baz.example.com\",\n\t\t\twant:     nil,\n\t\t\twantBool: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"empty slice\",\n\t\t\tslice:    []*endpoint.Endpoint{},\n\t\t\tdnsName:  \"foo.example.com\",\n\t\t\twant:     nil,\n\t\t\twantBool: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, ok := findEp(tt.slice, tt.dnsName)\n\t\t\tassert.Equal(t, tt.wantBool, ok)\n\t\t\tif ok {\n\t\t\t\tassert.Equal(t, tt.dnsName, got.DNSName)\n\t\t\t} else {\n\t\t\t\tassert.Nil(t, got)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCoreDNSProvider_updateTXTRecords_WithEdpoints(t *testing.T) {\n\tprovider := coreDNSProvider{coreDNSPrefix: \"/prefix/\"}\n\tdnsName := \"foo.example.com\"\n\n\tgroup := []*endpoint.Endpoint{\n\t\t{\n\t\t\tRecordType: endpoint.RecordTypeTXT,\n\t\t\tTargets:    endpoint.Targets{\"txt-value\"},\n\t\t\tLabels:     map[string]string{randomPrefixLabel: \"pfx\"},\n\t\t\tRecordTTL:  60,\n\t\t},\n\t\t{\n\t\t\tRecordType: endpoint.RecordTypeTXT,\n\t\t\tTargets:    endpoint.Targets{\"txt-value-2\"},\n\t\t\tLabels:     map[string]string{randomPrefixLabel: \"\"},\n\t\t\tRecordTTL:  60,\n\t\t},\n\t}\n\n\tservices := provider.updateTXTRecords(dnsName, group, []*Service{})\n\tassert.Len(t, services, 2)\n\tassert.Equal(t, \"txt-value\", services[0].Text)\n\tassert.Equal(t, \"txt-value-2\", services[1].Text)\n}\n\nfunc TestCoreDNSProvider_updateTXTRecords_ClearsExtraText(t *testing.T) {\n\tprovider := coreDNSProvider{coreDNSPrefix: \"/prefix/\"}\n\tdnsName := \"foo.example.com\"\n\n\tgroup := []*endpoint.Endpoint{\n\t\t{\n\t\t\tRecordType: endpoint.RecordTypeTXT,\n\t\t\tTargets:    endpoint.Targets{\"txt-value\"},\n\t\t\tLabels:     map[string]string{randomPrefixLabel: \"pfx\"},\n\t\t\tRecordTTL:  60,\n\t\t},\n\t}\n\n\tvar services []*Service\n\tservices = append(services, &Service{Key: \"/prefix/1\", Text: \"should-be-txt-value\"})\n\tservices = append(services, &Service{Key: \"/prefix/2\", Text: \"should-be-empty\"})\n\tservices = append(services, &Service{Key: \"/prefix/3\", Text: \"should-be-empty\"})\n\n\tservices = provider.updateTXTRecords(dnsName, group, services)\n\tassert.Len(t, services, 3)\n\n\tassert.Equal(t, \"txt-value\", services[0].Text)\n\tassert.Empty(t, services[1].Text)\n}\n\nfunc TestApplyChangesAWithGroupServiceTranslation(t *testing.T) {\n\tclient := fakeETCDClient{\n\t\tmap[string]Service{},\n\t}\n\tcoredns := coreDNSProvider{\n\t\tclient:        client,\n\t\tcoreDNSPrefix: defaultCoreDNSPrefix,\n\t}\n\n\tchanges1 := &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\tendpoint.NewEndpoint(\"domain1.local\", endpoint.RecordTypeA, \"5.5.5.5\").WithProviderSpecific(providerSpecificGroup, \"test1\"),\n\t\t\tendpoint.NewEndpoint(\"domain2.local\", endpoint.RecordTypeA, \"5.5.5.6\").WithProviderSpecific(providerSpecificGroup, \"test1\"),\n\t\t\tendpoint.NewEndpoint(\"domain3.local\", endpoint.RecordTypeA, \"5.5.5.7\").WithProviderSpecific(providerSpecificGroup, \"test2\"),\n\t\t},\n\t}\n\tcoredns.ApplyChanges(t.Context(), changes1)\n\n\texpectedServices1 := map[string][]*Service{\n\t\t\"/skydns/local/domain1\": {{Host: \"5.5.5.5\", Group: \"test1\"}},\n\t\t\"/skydns/local/domain2\": {{Host: \"5.5.5.6\", Group: \"test1\"}},\n\t\t\"/skydns/local/domain3\": {{Host: \"5.5.5.7\", Group: \"test2\"}},\n\t}\n\tvalidateServices(client.services, expectedServices1, t, 1)\n}\n\nfunc TestRecordsAWithGroupServiceTranslation(t *testing.T) {\n\tclient := fakeETCDClient{\n\t\tmap[string]Service{\n\t\t\t\"/skydns/local/domain1\": {Host: \"5.5.5.5\", Group: \"test1\"},\n\t\t},\n\t}\n\tcoredns := coreDNSProvider{\n\t\tclient:        client,\n\t\tcoreDNSPrefix: defaultCoreDNSPrefix,\n\t}\n\tendpoints, err := coredns.Records(t.Context())\n\trequire.NoError(t, err)\n\tif prop, ok := endpoints[0].GetProviderSpecificProperty(providerSpecificGroup); !ok {\n\t\tt.Error(\"go no Group name\")\n\t} else if prop != \"test1\" {\n\t\tt.Errorf(\"got unexpected Group name: %s != %s\", prop, \"test1\")\n\t}\n}\n\nfunc TestRecordsIncludeLabelOwnerWithStrictlyOwned(t *testing.T) {\n\tclient := fakeETCDClient{\n\t\tmap[string]Service{\n\t\t\t\"/skydns/local/domain1\": {Host: \"5.5.5.5\", Group: \"test1\", Owner: \"owner\"},\n\t\t\t\"/skydns/com/example\":   {Text: \"bla\", Owner: \"owner\"},\n\t\t},\n\t}\n\tcoredns := coreDNSProvider{\n\t\tclient:        client,\n\t\tcoreDNSPrefix: defaultCoreDNSPrefix,\n\t\tstrictlyOwned: true,\n\t}\n\tendpoints, err := coredns.Records(t.Context())\n\trequire.NoError(t, err)\n\tfor _, ep := range endpoints {\n\t\tassert.Equal(t, \"owner\", ep.Labels[endpoint.OwnerLabelKey])\n\t}\n}\n\nfunc TestRecordsIncludeOwnerASLabelWithoutStrictlyOwned(t *testing.T) {\n\tclient := fakeETCDClient{\n\t\tmap[string]Service{\n\t\t\t\"/skydns/local/domain1\": {Host: \"5.5.5.5\", Group: \"test1\", Owner: \"owner\"},\n\t\t\t\"/skydns/com/example\":   {Text: \"bla\", Owner: \"owner\"},\n\t\t},\n\t}\n\tcoredns := coreDNSProvider{\n\t\tclient:        client,\n\t\tcoreDNSPrefix: defaultCoreDNSPrefix,\n\t\tstrictlyOwned: false,\n\t}\n\tendpoints, err := coredns.Records(t.Context())\n\trequire.NoError(t, err)\n\tfor _, ep := range endpoints {\n\t\tassert.Empty(t, ep.Labels[endpoint.OwnerLabelKey])\n\t}\n}\n"
  },
  {
    "path": "provider/dnsimple/dnsimple.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage dnsimple\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/dnsimple/dnsimple-go/dnsimple\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"golang.org/x/oauth2\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\nconst (\n\tdnsimpleCreate = \"CREATE\"\n\tdnsimpleDelete = \"DELETE\"\n\tdnsimpleUpdate = \"UPDATE\"\n\n\tdefaultTTL = 3600 // Default TTL of 1 hour if not set (DNSimple's default)\n)\n\ntype dnsimpleIdentityService struct {\n\tservice *dnsimple.IdentityService\n}\n\nfunc (i dnsimpleIdentityService) Whoami(ctx context.Context) (*dnsimple.WhoamiResponse, error) {\n\treturn i.service.Whoami(ctx)\n}\n\n// dnsimpleZoneServiceInterface is an interface that contains all necessary zone services from DNSimple\ntype dnsimpleZoneServiceInterface interface {\n\tListZones(ctx context.Context, accountID string, options *dnsimple.ZoneListOptions) (*dnsimple.ZonesResponse, error)\n\tListRecords(ctx context.Context, accountID string, zoneID string, options *dnsimple.ZoneRecordListOptions) (*dnsimple.ZoneRecordsResponse, error)\n\tCreateRecord(ctx context.Context, accountID string, zoneID string, recordAttributes dnsimple.ZoneRecordAttributes) (*dnsimple.ZoneRecordResponse, error)\n\tDeleteRecord(ctx context.Context, accountID string, zoneID string, recordID int64) (*dnsimple.ZoneRecordResponse, error)\n\tUpdateRecord(ctx context.Context, accountID string, zoneID string, recordID int64, recordAttributes dnsimple.ZoneRecordAttributes) (*dnsimple.ZoneRecordResponse, error)\n}\n\ntype dnsimpleZoneService struct {\n\tservice *dnsimple.ZonesService\n}\n\nfunc (z dnsimpleZoneService) ListZones(ctx context.Context, accountID string, options *dnsimple.ZoneListOptions) (*dnsimple.ZonesResponse, error) {\n\treturn z.service.ListZones(ctx, accountID, options)\n}\n\nfunc (z dnsimpleZoneService) ListRecords(ctx context.Context, accountID string, zoneID string, options *dnsimple.ZoneRecordListOptions) (*dnsimple.ZoneRecordsResponse, error) {\n\treturn z.service.ListRecords(ctx, accountID, zoneID, options)\n}\n\nfunc (z dnsimpleZoneService) CreateRecord(ctx context.Context, accountID string, zoneID string, recordAttributes dnsimple.ZoneRecordAttributes) (*dnsimple.ZoneRecordResponse, error) {\n\treturn z.service.CreateRecord(ctx, accountID, zoneID, recordAttributes)\n}\n\nfunc (z dnsimpleZoneService) DeleteRecord(ctx context.Context, accountID string, zoneID string, recordID int64) (*dnsimple.ZoneRecordResponse, error) {\n\treturn z.service.DeleteRecord(ctx, accountID, zoneID, recordID)\n}\n\nfunc (z dnsimpleZoneService) UpdateRecord(ctx context.Context, accountID string, zoneID string, recordID int64, recordAttributes dnsimple.ZoneRecordAttributes) (*dnsimple.ZoneRecordResponse, error) {\n\treturn z.service.UpdateRecord(ctx, accountID, zoneID, recordID, recordAttributes)\n}\n\ntype dnsimpleProvider struct {\n\tprovider.BaseProvider\n\tclient       dnsimpleZoneServiceInterface\n\tidentity     dnsimpleIdentityService\n\taccountID    string\n\tdomainFilter *endpoint.DomainFilter\n\tzoneIDFilter provider.ZoneIDFilter\n\tdryRun       bool\n}\n\ntype dnsimpleChange struct {\n\tAction            string\n\tResourceRecordSet dnsimple.ZoneRecord\n}\n\n// New creates a DNSimple provider from the given configuration.\nfunc New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) {\n\treturn newProvider(domainFilter, provider.NewZoneIDFilter(cfg.ZoneIDFilter), cfg.DryRun)\n}\n\n// newProvider initializes a new Dnsimple based provider\nfunc newProvider(domainFilter *endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, dryRun bool) (provider.Provider, error) {\n\toauthToken := os.Getenv(\"DNSIMPLE_OAUTH\")\n\tif len(oauthToken) == 0 {\n\t\treturn nil, fmt.Errorf(\"no dnsimple oauth token provided\")\n\t}\n\n\tts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: oauthToken})\n\ttc := oauth2.NewClient(context.Background(), ts)\n\n\tclient := dnsimple.NewClient(tc)\n\tclient.SetUserAgent(externaldns.UserAgent())\n\n\tprovider := &dnsimpleProvider{\n\t\tclient:       dnsimpleZoneService{service: client.Zones},\n\t\tidentity:     dnsimpleIdentityService{service: client.Identity},\n\t\tdomainFilter: domainFilter,\n\t\tzoneIDFilter: zoneIDFilter,\n\t\tdryRun:       dryRun,\n\t}\n\n\tprovider.accountID = os.Getenv(\"DNSIMPLE_ACCOUNT_ID\")\n\tif provider.accountID == \"\" {\n\t\twhoamiResponse, err := provider.identity.Whoami(context.Background())\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tprovider.accountID = int64ToString(whoamiResponse.Data.Account.ID)\n\t}\n\treturn provider, nil\n}\n\n// GetAccountID returns the account ID given DNSimple credentials.\nfunc (p *dnsimpleProvider) GetAccountID(ctx context.Context) (string, error) {\n\t// get DNSimple client accountID\n\twhoamiResponse, err := p.identity.Whoami(ctx)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn int64ToString(whoamiResponse.Data.Account.ID), nil\n}\n\nfunc ZonesFromZoneString(zonestring string) map[string]dnsimple.Zone {\n\tzones := make(map[string]dnsimple.Zone)\n\tzoneNames := strings.Split(zonestring, \",\")\n\tfor indexId, zoneName := range zoneNames {\n\t\tzone := dnsimple.Zone{Name: zoneName, ID: int64(indexId)}\n\t\tzones[int64ToString(zone.ID)] = zone\n\t}\n\treturn zones\n}\n\n// Zones Return a list of filtered Zones\nfunc (p *dnsimpleProvider) Zones(ctx context.Context) (map[string]dnsimple.Zone, error) {\n\tzones := make(map[string]dnsimple.Zone)\n\n\t// If the DNSIMPLE_ZONES environment variable is specified, generate a list of Zones from it\n\t// This is useful for when the DNSIMPLE_OAUTH environment variable is a User API token and\n\t// not an Account API token as the User API token will not have permissions to list Zones\n\t// belong to another account which the User has access permissions for.\n\tenvZonesStr := os.Getenv(\"DNSIMPLE_ZONES\")\n\tif envZonesStr != \"\" {\n\t\treturn ZonesFromZoneString(envZonesStr), nil\n\t}\n\n\tpage := 1\n\tlistOptions := &dnsimple.ZoneListOptions{}\n\tfor {\n\t\tlistOptions.Page = &page\n\t\tzonesResponse, err := p.client.ListZones(ctx, p.accountID, listOptions)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tfor _, zone := range zonesResponse.Data {\n\t\t\tif !p.domainFilter.Match(zone.Name) {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif !p.zoneIDFilter.Match(int64ToString(zone.ID)) {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tzones[int64ToString(zone.ID)] = zone\n\t\t}\n\n\t\tpage++\n\t\tif page > zonesResponse.Pagination.TotalPages {\n\t\t\tbreak\n\t\t}\n\t}\n\treturn zones, nil\n}\n\n// Records returns a list of endpoints in a given zone\nfunc (p *dnsimpleProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {\n\tzones, err := p.Zones(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tendpoints := make([]*endpoint.Endpoint, 0)\n\tfor _, zone := range zones {\n\t\tpage := 1\n\t\tlistOptions := &dnsimple.ZoneRecordListOptions{}\n\t\tfor {\n\t\t\tlistOptions.Page = &page\n\t\t\trecords, err := p.client.ListRecords(ctx, p.accountID, zone.Name, listOptions)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tfor _, record := range records.Data {\n\t\t\t\tif record.Type != endpoint.RecordTypeA && record.Type != endpoint.RecordTypeCNAME && record.Type != endpoint.RecordTypeTXT {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\t// Apex records have an empty string for their name.\n\t\t\t\t// Consider this when creating the endpoint dnsName\n\t\t\t\tdnsName := fmt.Sprintf(\"%s.%s\", record.Name, record.ZoneID)\n\t\t\t\tif record.Name == \"\" {\n\t\t\t\t\tdnsName = record.ZoneID\n\t\t\t\t}\n\t\t\t\tendpoints = append(endpoints, endpoint.NewEndpointWithTTL(dnsName, record.Type, endpoint.TTL(record.TTL), record.Content))\n\t\t\t}\n\t\t\tpage++\n\t\t\tif page > records.Pagination.TotalPages {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\treturn endpoints, nil\n}\n\n// newDnsimpleChange initializes a new change to dns records\nfunc newDnsimpleChange(action string, e *endpoint.Endpoint) *dnsimpleChange {\n\tttl := defaultTTL\n\tif e.RecordTTL.IsConfigured() {\n\t\tttl = int(e.RecordTTL)\n\t}\n\n\tchange := &dnsimpleChange{\n\t\tAction: action,\n\t\tResourceRecordSet: dnsimple.ZoneRecord{\n\t\t\tName:    e.DNSName,\n\t\t\tType:    e.RecordType,\n\t\t\tContent: e.Targets[0],\n\t\t\tTTL:     ttl,\n\t\t},\n\t}\n\treturn change\n}\n\n// newDnsimpleChanges returns a slice of changes based on given action and record\nfunc newDnsimpleChanges(action string, endpoints []*endpoint.Endpoint) []*dnsimpleChange {\n\tchanges := make([]*dnsimpleChange, 0, len(endpoints))\n\tfor _, e := range endpoints {\n\t\tchanges = append(changes, newDnsimpleChange(action, e))\n\t}\n\treturn changes\n}\n\n// submitChanges takes a zone and a collection of changes and makes all changes from the collection\nfunc (p *dnsimpleProvider) submitChanges(ctx context.Context, changes []*dnsimpleChange) error {\n\tif len(changes) == 0 {\n\t\tlog.Infof(\"All records are already up to date\")\n\t\treturn nil\n\t}\n\tzones, err := p.Zones(ctx)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, change := range changes {\n\t\tzone := dnsimpleSuitableZone(change.ResourceRecordSet.Name, zones)\n\t\tif zone == nil {\n\t\t\tlog.Debugf(\"Skipping record %s because no hosted zone matching record DNS Name was detected\", change.ResourceRecordSet.Name)\n\t\t\tcontinue\n\t\t}\n\n\t\tlog.Infof(\"Changing records: %s %v in zone: %s\", change.Action, change.ResourceRecordSet, zone.Name)\n\n\t\tif change.ResourceRecordSet.Name == zone.Name {\n\t\t\tchange.ResourceRecordSet.Name = \"\" // Apex records have an empty name\n\t\t} else {\n\t\t\tchange.ResourceRecordSet.Name = strings.TrimSuffix(change.ResourceRecordSet.Name, fmt.Sprintf(\".%s\", zone.Name))\n\t\t}\n\n\t\trecordAttributes := dnsimple.ZoneRecordAttributes{\n\t\t\tName:    &change.ResourceRecordSet.Name,\n\t\t\tType:    change.ResourceRecordSet.Type,\n\t\t\tContent: change.ResourceRecordSet.Content,\n\t\t\tTTL:     change.ResourceRecordSet.TTL,\n\t\t}\n\n\t\tif !p.dryRun {\n\t\t\tswitch change.Action {\n\t\t\tcase dnsimpleCreate:\n\t\t\t\t_, err := p.client.CreateRecord(ctx, p.accountID, zone.Name, recordAttributes)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\tcase dnsimpleDelete:\n\t\t\t\trecordID, err := p.GetRecordID(ctx, zone.Name, *recordAttributes.Name)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\t_, err = p.client.DeleteRecord(ctx, p.accountID, zone.Name, recordID)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\tcase dnsimpleUpdate:\n\t\t\t\trecordID, err := p.GetRecordID(ctx, zone.Name, *recordAttributes.Name)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\t_, err = p.client.UpdateRecord(ctx, p.accountID, zone.Name, recordID, recordAttributes)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n\n// GetRecordID returns the record ID for a given record name and zone.\nfunc (p *dnsimpleProvider) GetRecordID(ctx context.Context, zone string, recordName string) (int64, error) {\n\tpage := 1\n\tlistOptions := &dnsimple.ZoneRecordListOptions{Name: &recordName}\n\tfor {\n\t\tlistOptions.Page = &page\n\t\trecords, err := p.client.ListRecords(ctx, p.accountID, zone, listOptions)\n\t\tif err != nil {\n\t\t\treturn 0, err\n\t\t}\n\n\t\tfor _, record := range records.Data {\n\t\t\tif record.Name == recordName {\n\t\t\t\treturn record.ID, nil\n\t\t\t}\n\t\t}\n\n\t\tpage++\n\t\tif page > records.Pagination.TotalPages {\n\t\t\tbreak\n\t\t}\n\t}\n\treturn 0, fmt.Errorf(\"no record id found\")\n}\n\n// dnsimpleSuitableZone returns the most suitable zone for a given hostname and a set of zones.\nfunc dnsimpleSuitableZone(hostname string, zones map[string]dnsimple.Zone) *dnsimple.Zone {\n\tvar zone *dnsimple.Zone\n\tfor _, z := range zones {\n\t\tif strings.HasSuffix(hostname, z.Name) {\n\t\t\tif zone == nil || len(z.Name) > len(zone.Name) {\n\t\t\t\tnewZ := z\n\t\t\t\tzone = &newZ\n\t\t\t}\n\t\t}\n\t}\n\treturn zone\n}\n\n// ApplyChanges applies a given set of changes\nfunc (p *dnsimpleProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {\n\tcombinedChanges := make([]*dnsimpleChange, 0, len(changes.Create)+len(changes.UpdateNew)+len(changes.Delete))\n\n\tcombinedChanges = append(combinedChanges, newDnsimpleChanges(dnsimpleCreate, changes.Create)...)\n\tcombinedChanges = append(combinedChanges, newDnsimpleChanges(dnsimpleUpdate, changes.UpdateNew)...)\n\tcombinedChanges = append(combinedChanges, newDnsimpleChanges(dnsimpleDelete, changes.Delete)...)\n\n\treturn p.submitChanges(ctx, combinedChanges)\n}\n\nfunc int64ToString(i int64) string {\n\treturn strconv.FormatInt(i, 10)\n}\n"
  },
  {
    "path": "provider/dnsimple/dnsimple_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage dnsimple\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"testing\"\n\n\t\"github.com/dnsimple/dnsimple-go/dnsimple\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\nvar (\n\tmockProvider                     dnsimpleProvider\n\tdnsimpleListRecordsResponse      dnsimple.ZoneRecordsResponse\n\tdnsimpleListZonesResponse        dnsimple.ZonesResponse\n\tdnsimpleListZonesFromEnvResponse dnsimple.ZonesResponse\n)\n\nfunc TestDnsimpleServices(t *testing.T) {\n\t// Setup example responses\n\tfirstZone := dnsimple.Zone{\n\t\tID:        1,\n\t\tAccountID: 12345,\n\t\tName:      \"example.com\",\n\t}\n\tsecondZone := dnsimple.Zone{\n\t\tID:        2,\n\t\tAccountID: 54321,\n\t\tName:      \"example-beta.com\",\n\t}\n\tzones := []dnsimple.Zone{firstZone, secondZone}\n\tdnsimpleListZonesResponse = dnsimple.ZonesResponse{\n\t\tResponse: dnsimple.Response{Pagination: &dnsimple.Pagination{}},\n\t\tData:     zones,\n\t}\n\tfirstEnvDefinedZone := dnsimple.Zone{\n\t\tID:        0,\n\t\tAccountID: 12345,\n\t\tName:      \"example-from-env.com\",\n\t}\n\tenvDefinedZones := []dnsimple.Zone{firstEnvDefinedZone}\n\tdnsimpleListZonesFromEnvResponse = dnsimple.ZonesResponse{\n\t\tResponse: dnsimple.Response{Pagination: &dnsimple.Pagination{}},\n\t\tData:     envDefinedZones,\n\t}\n\tfirstRecord := dnsimple.ZoneRecord{\n\t\tID:       2,\n\t\tZoneID:   \"example.com\",\n\t\tParentID: 0,\n\t\tName:     \"example\",\n\t\tContent:  \"target\",\n\t\tTTL:      3600,\n\t\tPriority: 0,\n\t\tType:     \"CNAME\",\n\t}\n\tsecondRecord := dnsimple.ZoneRecord{\n\t\tID:       1,\n\t\tZoneID:   \"example.com\",\n\t\tParentID: 0,\n\t\tName:     \"example-beta\",\n\t\tContent:  \"127.0.0.1\",\n\t\tTTL:      3600,\n\t\tPriority: 0,\n\t\tType:     \"A\",\n\t}\n\tthirdRecord := dnsimple.ZoneRecord{\n\t\tID:       3,\n\t\tZoneID:   \"example.com\",\n\t\tParentID: 0,\n\t\tName:     \"custom-ttl\",\n\t\tContent:  \"target\",\n\t\tTTL:      60,\n\t\tPriority: 0,\n\t\tType:     \"CNAME\",\n\t}\n\tfourthRecord := dnsimple.ZoneRecord{\n\t\tID:       4,\n\t\tZoneID:   \"example.com\",\n\t\tParentID: 0,\n\t\tName:     \"\", // Apex domain A record\n\t\tContent:  \"127.0.0.1\",\n\t\tTTL:      3600,\n\t\tPriority: 0,\n\t\tType:     \"A\",\n\t}\n\n\trecords := []dnsimple.ZoneRecord{firstRecord, secondRecord, thirdRecord, fourthRecord}\n\tdnsimpleListRecordsResponse = dnsimple.ZoneRecordsResponse{\n\t\tResponse: dnsimple.Response{Pagination: &dnsimple.Pagination{}},\n\t\tData:     records,\n\t}\n\n\t// Setup mock services\n\t// Note: AnythingOfType doesn't work with interfaces https://github.com/stretchr/testify/issues/519\n\tmockDNS := &mockDnsimpleZoneServiceInterface{}\n\tmockDNS.On(\"ListZones\", t.Context(), \"1\", &dnsimple.ZoneListOptions{ListOptions: dnsimple.ListOptions{Page: dnsimple.Int(1)}}).Return(&dnsimpleListZonesResponse, nil)\n\tmockDNS.On(\"ListZones\", t.Context(), \"2\", &dnsimple.ZoneListOptions{ListOptions: dnsimple.ListOptions{Page: dnsimple.Int(1)}}).Return(nil, fmt.Errorf(\"Account ID not found\"))\n\tmockDNS.On(\"ListRecords\", t.Context(), \"1\", \"example.com\", &dnsimple.ZoneRecordListOptions{ListOptions: dnsimple.ListOptions{Page: dnsimple.Int(1)}}).Return(&dnsimpleListRecordsResponse, nil)\n\tmockDNS.On(\"ListRecords\", t.Context(), \"1\", \"example-beta.com\", &dnsimple.ZoneRecordListOptions{ListOptions: dnsimple.ListOptions{Page: dnsimple.Int(1)}}).Return(&dnsimple.ZoneRecordsResponse{Response: dnsimple.Response{Pagination: &dnsimple.Pagination{}}}, nil)\n\n\tfor _, record := range records {\n\t\trecordName := record.Name\n\t\tsimpleRecord := dnsimple.ZoneRecordAttributes{\n\t\t\tName:    &recordName,\n\t\t\tType:    record.Type,\n\t\t\tContent: record.Content,\n\t\t\tTTL:     record.TTL,\n\t\t}\n\n\t\tdnsimpleRecordResponse := dnsimple.ZoneRecordsResponse{\n\t\t\tResponse: dnsimple.Response{Pagination: &dnsimple.Pagination{}},\n\t\t\tData:     []dnsimple.ZoneRecord{record},\n\t\t}\n\n\t\tmockDNS.On(\"ListRecords\", t.Context(), \"1\", record.ZoneID, &dnsimple.ZoneRecordListOptions{Name: &recordName, ListOptions: dnsimple.ListOptions{Page: dnsimple.Int(1)}}).Return(&dnsimpleRecordResponse, nil)\n\t\tmockDNS.On(\"CreateRecord\", t.Context(), \"1\", record.ZoneID, simpleRecord).Return(&dnsimple.ZoneRecordResponse{}, nil)\n\t\tmockDNS.On(\"DeleteRecord\", t.Context(), \"1\", record.ZoneID, record.ID).Return(&dnsimple.ZoneRecordResponse{}, nil)\n\t\tmockDNS.On(\"UpdateRecord\", t.Context(), \"1\", record.ZoneID, record.ID, simpleRecord).Return(&dnsimple.ZoneRecordResponse{}, nil)\n\t}\n\n\tmockProvider = dnsimpleProvider{client: mockDNS}\n\n\t// Run tests on mock services\n\tt.Run(\"Zones\", testDnsimpleProviderZones)\n\tt.Run(\"Records\", testDnsimpleProviderRecords)\n\tt.Run(\"ApplyChanges\", testDnsimpleProviderApplyChanges)\n\tt.Run(\"ApplyChanges/SkipUnknownZone\", testDnsimpleProviderApplyChangesSkipsUnknown)\n\tt.Run(\"SuitableZone\", testDnsimpleSuitableZone)\n\tt.Run(\"GetRecordID\", testDnsimpleGetRecordID)\n}\n\nfunc testDnsimpleProviderZones(t *testing.T) {\n\tctx := t.Context()\n\tmockProvider.accountID = \"1\"\n\tresult, err := mockProvider.Zones(ctx)\n\tassert.NoError(t, err)\n\tvalidateDnsimpleZones(t, result, dnsimpleListZonesResponse.Data)\n\n\tmockProvider.accountID = \"2\"\n\t_, err = mockProvider.Zones(ctx)\n\tassert.Error(t, err)\n\n\tmockProvider.accountID = \"3\"\n\tt.Setenv(\"DNSIMPLE_ZONES\", \"example-from-env.com\")\n\tresult, err = mockProvider.Zones(ctx)\n\tassert.NoError(t, err)\n\tvalidateDnsimpleZones(t, result, dnsimpleListZonesFromEnvResponse.Data)\n\n\tmockProvider.accountID = \"2\"\n\tos.Unsetenv(\"DNSIMPLE_ZONES\")\n}\n\nfunc testDnsimpleProviderRecords(t *testing.T) {\n\tctx := t.Context()\n\tmockProvider.accountID = \"1\"\n\tresult, err := mockProvider.Records(ctx)\n\tassert.NoError(t, err)\n\tassert.Len(t, result, len(dnsimpleListRecordsResponse.Data))\n\n\tmockProvider.accountID = \"2\"\n\t_, err = mockProvider.Records(ctx)\n\tassert.Error(t, err)\n}\n\nfunc testDnsimpleProviderApplyChanges(t *testing.T) {\n\tchanges := &plan.Changes{}\n\tchanges.Create = []*endpoint.Endpoint{\n\t\t{DNSName: \"example.example.com\", Targets: endpoint.Targets{\"target\"}, RecordType: endpoint.RecordTypeCNAME},\n\t\t{DNSName: \"custom-ttl.example.com\", RecordTTL: 60, Targets: endpoint.Targets{\"target\"}, RecordType: endpoint.RecordTypeCNAME},\n\t}\n\tchanges.Delete = []*endpoint.Endpoint{\n\t\t{DNSName: \"example-beta.example.com\", Targets: endpoint.Targets{\"127.0.0.1\"}, RecordType: endpoint.RecordTypeA},\n\t}\n\tchanges.UpdateNew = []*endpoint.Endpoint{\n\t\t{DNSName: \"example.example.com\", Targets: endpoint.Targets{\"target\"}, RecordType: endpoint.RecordTypeCNAME},\n\t\t{DNSName: \"example.com\", Targets: endpoint.Targets{\"127.0.0.1\"}, RecordType: endpoint.RecordTypeA},\n\t}\n\n\tmockProvider.accountID = \"1\"\n\terr := mockProvider.ApplyChanges(t.Context(), changes)\n\tif err != nil {\n\t\tt.Errorf(\"Failed to apply changes: %v\", err)\n\t}\n}\n\nfunc testDnsimpleProviderApplyChangesSkipsUnknown(t *testing.T) {\n\tchanges := &plan.Changes{}\n\tchanges.Create = []*endpoint.Endpoint{\n\t\t{DNSName: \"example.not-included.com\", Targets: endpoint.Targets{\"dasd\"}, RecordType: endpoint.RecordTypeCNAME},\n\t}\n\n\tmockProvider.accountID = \"1\"\n\terr := mockProvider.ApplyChanges(t.Context(), changes)\n\tif err != nil {\n\t\tt.Errorf(\"Failed to ignore unknown zones: %v\", err)\n\t}\n}\n\nfunc testDnsimpleSuitableZone(t *testing.T) {\n\tctx := t.Context()\n\tmockProvider.accountID = \"1\"\n\tzones, err := mockProvider.Zones(ctx)\n\trequire.NoError(t, err)\n\n\tzone := dnsimpleSuitableZone(\"example-beta.example.com\", zones)\n\tassert.Equal(t, \"example.com\", zone.Name)\n\n\tt.Setenv(\"DNSIMPLE_ZONES\", \"environment-example.com,example.environment-example.com\")\n\tmockProvider.accountID = \"3\"\n\tzones, err = mockProvider.Zones(ctx)\n\trequire.NoError(t, err)\n\n\tzone = dnsimpleSuitableZone(\"hello.example.environment-example.com\", zones)\n\tassert.Equal(t, \"example.environment-example.com\", zone.Name)\n\n\t_ = os.Unsetenv(\"DNSIMPLE_ZONES\")\n\tmockProvider.accountID = \"1\"\n}\n\nfunc TestNewProvider(t *testing.T) {\n\tt.Setenv(\"DNSIMPLE_OAUTH\", \"xxxxxxxxxxxxxxxxxxxxxxxxxx\")\n\t_, err := newProvider(endpoint.NewDomainFilter([]string{\"example.com\"}), provider.NewZoneIDFilter([]string{\"\"}), true)\n\tif err == nil {\n\t\tt.Errorf(\"Expected to fail new provider on bad token\")\n\t}\n\n\t_ = os.Unsetenv(\"DNSIMPLE_OAUTH\")\n\t_, err = newProvider(endpoint.NewDomainFilter([]string{\"example.com\"}), provider.NewZoneIDFilter([]string{\"\"}), true)\n\tif err == nil {\n\t\tt.Errorf(\"Expected to fail new provider on empty token\")\n\t}\n\n\tt.Setenv(\"DNSIMPLE_OAUTH\", \"xxxxxxxxxxxxxxxxxxxxxxxxxx\")\n\tt.Setenv(\"DNSIMPLE_ACCOUNT_ID\", \"12345678\")\n\tproviderTypedProvider, err := newProvider(endpoint.NewDomainFilter([]string{\"example.com\"}), provider.NewZoneIDFilter([]string{\"\"}), true)\n\tdnsimpleTypedProvider := providerTypedProvider.(*dnsimpleProvider)\n\tif err != nil {\n\t\tt.Errorf(\"Unexpected error thrown when testing NewDnsimpleProvider with the DNSIMPLE_ACCOUNT_ID environment variable set\")\n\t}\n\tassert.Equal(t, \"12345678\", dnsimpleTypedProvider.accountID)\n\tos.Unsetenv(\"DNSIMPLE_OAUTH\")\n\tos.Unsetenv(\"DNSIMPLE_ACCOUNT_ID\")\n}\n\nfunc testDnsimpleGetRecordID(t *testing.T) {\n\tvar result int64\n\tvar err error\n\n\tmockProvider.accountID = \"1\"\n\tresult, err = mockProvider.GetRecordID(t.Context(), \"example.com\", \"example\")\n\tassert.NoError(t, err)\n\tassert.Equal(t, int64(2), result)\n\n\tresult, err = mockProvider.GetRecordID(t.Context(), \"example.com\", \"example-beta\")\n\tassert.NoError(t, err)\n\tassert.Equal(t, int64(1), result)\n}\n\nfunc validateDnsimpleZones(t *testing.T, zones map[string]dnsimple.Zone, expected []dnsimple.Zone) {\n\trequire.Len(t, zones, len(expected))\n\n\tfor _, e := range expected {\n\t\tassert.Equal(t, zones[int64ToString(e.ID)].Name, e.Name)\n\t}\n}\n\ntype mockDnsimpleZoneServiceInterface struct {\n\tmock.Mock\n}\n\nfunc (_m *mockDnsimpleZoneServiceInterface) CreateRecord(ctx context.Context, accountID string, zoneID string, recordAttributes dnsimple.ZoneRecordAttributes) (*dnsimple.ZoneRecordResponse, error) {\n\targs := _m.Called(ctx, accountID, zoneID, recordAttributes)\n\tvar r0 *dnsimple.ZoneRecordResponse\n\n\tif args.Get(0) != nil {\n\t\tr0 = args.Get(0).(*dnsimple.ZoneRecordResponse)\n\t}\n\n\treturn r0, args.Error(1)\n}\n\nfunc (_m *mockDnsimpleZoneServiceInterface) DeleteRecord(ctx context.Context, accountID string, zoneID string, recordID int64) (*dnsimple.ZoneRecordResponse, error) {\n\targs := _m.Called(ctx, accountID, zoneID, recordID)\n\tvar r0 *dnsimple.ZoneRecordResponse\n\n\tif args.Get(0) != nil {\n\t\tr0 = args.Get(0).(*dnsimple.ZoneRecordResponse)\n\t}\n\n\treturn r0, args.Error(1)\n}\n\nfunc (_m *mockDnsimpleZoneServiceInterface) ListRecords(ctx context.Context, accountID string, zoneID string, options *dnsimple.ZoneRecordListOptions) (*dnsimple.ZoneRecordsResponse, error) {\n\targs := _m.Called(ctx, accountID, zoneID, options)\n\tvar r0 *dnsimple.ZoneRecordsResponse\n\n\tif args.Get(0) != nil {\n\t\tr0 = args.Get(0).(*dnsimple.ZoneRecordsResponse)\n\t}\n\n\treturn r0, args.Error(1)\n}\n\nfunc (_m *mockDnsimpleZoneServiceInterface) ListZones(ctx context.Context, accountID string, options *dnsimple.ZoneListOptions) (*dnsimple.ZonesResponse, error) {\n\targs := _m.Called(ctx, accountID, options)\n\tvar r0 *dnsimple.ZonesResponse\n\n\tif args.Get(0) != nil {\n\t\tr0 = args.Get(0).(*dnsimple.ZonesResponse)\n\t}\n\n\treturn r0, args.Error(1)\n}\n\nfunc (_m *mockDnsimpleZoneServiceInterface) UpdateRecord(ctx context.Context, accountID string, zoneID string, recordID int64, recordAttributes dnsimple.ZoneRecordAttributes) (*dnsimple.ZoneRecordResponse, error) {\n\targs := _m.Called(ctx, accountID, zoneID, recordID, recordAttributes)\n\tvar r0 *dnsimple.ZoneRecordResponse\n\n\tif args.Get(0) != nil {\n\t\tr0 = args.Get(0).(*dnsimple.ZoneRecordResponse)\n\t}\n\n\treturn r0, args.Error(1)\n}\n"
  },
  {
    "path": "provider/exoscale/exoscale.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage exoscale\n\nimport (\n\t\"context\"\n\t\"strings\"\n\n\tegoscale \"github.com/exoscale/egoscale/v2\"\n\texoapi \"github.com/exoscale/egoscale/v2/api\"\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\n// EgoscaleClientI for replaceable implementation\ntype EgoscaleClientI interface {\n\tListDNSDomainRecords(context.Context, string, string) ([]egoscale.DNSDomainRecord, error)\n\tListDNSDomains(context.Context, string) ([]egoscale.DNSDomain, error)\n\tCreateDNSDomainRecord(context.Context, string, string, *egoscale.DNSDomainRecord) (*egoscale.DNSDomainRecord, error)\n\tDeleteDNSDomainRecord(context.Context, string, string, *egoscale.DNSDomainRecord) error\n\tUpdateDNSDomainRecord(context.Context, string, string, *egoscale.DNSDomainRecord) error\n}\n\n// ExoscaleProvider initialized as dns provider with no records\ntype ExoscaleProvider struct {\n\tprovider.BaseProvider\n\tdomain         *endpoint.DomainFilter\n\tclient         EgoscaleClientI\n\tapiEnv         string\n\tapiZone        string\n\tfilter         *zoneFilter\n\tOnApplyChanges func(changes *plan.Changes)\n\tdryRun         bool\n}\n\n// ExoscaleOption for Provider options\ntype ExoscaleOption func(*ExoscaleProvider)\n\n// New creates an Exoscale provider from the given configuration.\nfunc New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) {\n\treturn newProvider(\n\t\tcfg.ExoscaleAPIEnvironment,\n\t\tcfg.ExoscaleAPIZone,\n\t\tcfg.ExoscaleAPIKey,\n\t\tcfg.ExoscaleAPISecret,\n\t\tcfg.DryRun,\n\t\tExoscaleWithDomain(domainFilter),\n\t\tExoscaleWithLogging(),\n\t)\n}\n\n// newProvider returns ExoscaleProvider DNS provider interface implementation\nfunc newProvider(env, zone, key, secret string, dryRun bool, opts ...ExoscaleOption) (*ExoscaleProvider, error) {\n\tclient, err := egoscale.NewClient(\n\t\tkey,\n\t\tsecret,\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn NewExoscaleProviderWithClient(client, env, zone, dryRun, opts...), nil\n}\n\n// NewExoscaleProviderWithClient returns ExoscaleProvider DNS provider interface implementation (Client provided)\nfunc NewExoscaleProviderWithClient(client EgoscaleClientI, env, zone string, dryRun bool, opts ...ExoscaleOption) *ExoscaleProvider {\n\tep := &ExoscaleProvider{\n\t\tfilter:         &zoneFilter{},\n\t\tOnApplyChanges: func(_ *plan.Changes) {},\n\t\tdomain:         endpoint.NewDomainFilter([]string{\"\"}),\n\t\tclient:         client,\n\t\tapiEnv:         env,\n\t\tapiZone:        zone,\n\t\tdryRun:         dryRun,\n\t}\n\tfor _, opt := range opts {\n\t\topt(ep)\n\t}\n\treturn ep\n}\n\nfunc (ep *ExoscaleProvider) getZones(ctx context.Context) (map[string]string, error) {\n\tctx = exoapi.WithEndpoint(ctx, exoapi.NewReqEndpoint(ep.apiEnv, ep.apiZone))\n\tdomains, err := ep.client.ListDNSDomains(ctx, ep.apiZone)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tzones := map[string]string{}\n\tfor _, domain := range domains {\n\t\tzones[*domain.ID] = *domain.UnicodeName\n\t}\n\n\treturn zones, nil\n}\n\n// ApplyChanges simply modifies DNS via exoscale API\nfunc (ep *ExoscaleProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {\n\tep.OnApplyChanges(changes)\n\n\tif ep.dryRun {\n\t\tlog.Infof(\"Will NOT delete these records: %+v\", changes.Delete)\n\t\tlog.Infof(\"Will NOT create these records: %+v\", changes.Create)\n\t\tlog.Infof(\"Will NOT update these records: %+v\", merge(changes.UpdateOld, changes.UpdateNew))\n\t\treturn nil\n\t}\n\n\tctx = exoapi.WithEndpoint(ctx, exoapi.NewReqEndpoint(ep.apiEnv, ep.apiZone))\n\n\tzones, err := ep.getZones(ctx)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, epoint := range changes.Create {\n\t\tif !ep.domain.Match(epoint.DNSName) {\n\t\t\tcontinue\n\t\t}\n\n\t\tzoneID, name := ep.filter.EndpointZoneID(epoint, zones)\n\t\tif zoneID == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\t// API does not accept 0 as default TTL but wants nil pointer instead\n\t\tvar ttl *int64\n\t\tif epoint.RecordTTL != 0 {\n\t\t\tt := int64(epoint.RecordTTL)\n\t\t\tttl = &t\n\t\t}\n\t\trecord := egoscale.DNSDomainRecord{\n\t\t\tName:    &name,\n\t\t\tType:    &epoint.RecordType,\n\t\t\tTTL:     ttl,\n\t\t\tContent: &epoint.Targets[0],\n\t\t}\n\t\t_, err := ep.client.CreateDNSDomainRecord(ctx, ep.apiZone, zoneID, &record)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tfor _, epoint := range changes.UpdateNew {\n\t\tif !ep.domain.Match(epoint.DNSName) {\n\t\t\tcontinue\n\t\t}\n\n\t\tzoneID, name := ep.filter.EndpointZoneID(epoint, zones)\n\t\tif zoneID == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\trecords, err := ep.client.ListDNSDomainRecords(ctx, ep.apiZone, zoneID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfor _, record := range records {\n\t\t\tif *record.Name != name {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\trecord.Type = &epoint.RecordType\n\t\t\trecord.Content = &epoint.Targets[0]\n\t\t\tif epoint.RecordTTL != 0 {\n\t\t\t\tttl := int64(epoint.RecordTTL)\n\t\t\t\trecord.TTL = &ttl\n\t\t\t}\n\n\t\t\terr = ep.client.UpdateDNSDomainRecord(ctx, ep.apiZone, zoneID, &record)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tbreak\n\t\t}\n\t}\n\n\tfor _, epoint := range changes.UpdateOld {\n\t\t// Since Exoscale \"Patches\", we've ignored UpdateOld\n\t\t// We leave this logging here for information\n\t\tlog.Debugf(\"UPDATE-OLD (ignored) for epoint: %+v\", epoint)\n\t}\n\n\tfor _, epoint := range changes.Delete {\n\t\tif !ep.domain.Match(epoint.DNSName) {\n\t\t\tcontinue\n\t\t}\n\n\t\tzoneID, name := ep.filter.EndpointZoneID(epoint, zones)\n\t\tif zoneID == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\trecords, err := ep.client.ListDNSDomainRecords(ctx, ep.apiZone, zoneID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfor _, record := range records {\n\t\t\tif *record.Name != name {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\terr = ep.client.DeleteDNSDomainRecord(ctx, ep.apiZone, zoneID, &egoscale.DNSDomainRecord{ID: record.ID})\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tbreak\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// Records returns the list of endpoints\nfunc (ep *ExoscaleProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {\n\tctx = exoapi.WithEndpoint(ctx, exoapi.NewReqEndpoint(ep.apiEnv, ep.apiZone))\n\tendpoints := make([]*endpoint.Endpoint, 0)\n\n\tdomains, err := ep.client.ListDNSDomains(ctx, ep.apiZone)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor _, domain := range domains {\n\t\trecords, err := ep.client.ListDNSDomainRecords(ctx, ep.apiZone, *domain.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tfor _, record := range records {\n\t\t\tif *record.Type != endpoint.RecordTypeA && *record.Type != endpoint.RecordTypeCNAME && *record.Type != endpoint.RecordTypeTXT {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\te := endpoint.NewEndpointWithTTL((*record.Name)+\".\"+(*domain.UnicodeName), *record.Type, endpoint.TTL(*record.TTL), *record.Content)\n\t\t\tendpoints = append(endpoints, e)\n\t\t}\n\t}\n\n\tlog.Infof(\"called Records() with %d items\", len(endpoints))\n\treturn endpoints, nil\n}\n\n// ExoscaleWithDomain modifies the domain on which dns zones are filtered\nfunc ExoscaleWithDomain(domainFilter *endpoint.DomainFilter) ExoscaleOption {\n\treturn func(p *ExoscaleProvider) {\n\t\tp.domain = domainFilter\n\t}\n}\n\n// ExoscaleWithLogging injects logging when ApplyChanges is called\nfunc ExoscaleWithLogging() ExoscaleOption {\n\treturn func(p *ExoscaleProvider) {\n\t\tp.OnApplyChanges = func(changes *plan.Changes) {\n\t\t\tfor _, v := range changes.Create {\n\t\t\t\tlog.Infof(\"CREATE: %v\", v)\n\t\t\t}\n\t\t\tfor _, v := range changes.UpdateOld {\n\t\t\t\tlog.Infof(\"UPDATE (old): %v\", v)\n\t\t\t}\n\t\t\tfor _, v := range changes.UpdateNew {\n\t\t\t\tlog.Infof(\"UPDATE (new): %v\", v)\n\t\t\t}\n\t\t\tfor _, v := range changes.Delete {\n\t\t\t\tlog.Infof(\"DELETE: %v\", v)\n\t\t\t}\n\t\t}\n\t}\n}\n\ntype zoneFilter struct {\n\tdomain string\n}\n\n// Zones filters map[zoneID]zoneName for names having f.domain as suffix\nfunc (f *zoneFilter) Zones(zones map[string]string) map[string]string {\n\tresult := map[string]string{}\n\tfor zoneID, zoneName := range zones {\n\t\tif strings.HasSuffix(zoneName, f.domain) {\n\t\t\tresult[zoneID] = zoneName\n\t\t}\n\t}\n\treturn result\n}\n\n// EndpointZoneID determines zoneID for endpoint from map[zoneID]zoneName by taking longest suffix zoneName match in endpoint DNSName\n// returns empty string if no matches are found\nfunc (f *zoneFilter) EndpointZoneID(endpoint *endpoint.Endpoint, zones map[string]string) (string, string) {\n\tvar matchZoneID, matchZoneName, name string\n\tfor zoneID, zoneName := range zones {\n\t\tif strings.HasSuffix(endpoint.DNSName, \".\"+zoneName) && len(zoneName) > len(matchZoneName) {\n\t\t\tmatchZoneName = zoneName\n\t\t\tmatchZoneID = zoneID\n\t\t\tname = strings.TrimSuffix(endpoint.DNSName, \".\"+zoneName)\n\t\t}\n\t}\n\treturn matchZoneID, name\n}\n\nfunc merge(updateOld, updateNew []*endpoint.Endpoint) []*endpoint.Endpoint {\n\tfindMatch := func(template *endpoint.Endpoint) *endpoint.Endpoint {\n\t\tfor _, record := range updateNew {\n\t\t\tif template.DNSName == record.DNSName &&\n\t\t\t\ttemplate.RecordType == record.RecordType {\n\t\t\t\treturn record\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t}\n\n\tvar result []*endpoint.Endpoint\n\tfor _, old := range updateOld {\n\t\tmatchingNew := findMatch(old)\n\t\tif matchingNew == nil {\n\t\t\t// no match shouldn't happen\n\t\t\tcontinue\n\t\t}\n\n\t\tif !matchingNew.Targets.Same(old.Targets) {\n\t\t\t// new target: always update, TTL will be overwritten too if necessary\n\t\t\tresult = append(result, matchingNew)\n\t\t\tcontinue\n\t\t}\n\n\t\tif matchingNew.RecordTTL != 0 && matchingNew.RecordTTL != old.RecordTTL {\n\t\t\t// same target, but new non-zero TTL set in k8s, must update\n\t\t\t// probably would happen only if there is a bug in the code calling the provider\n\t\t\tresult = append(result, matchingNew)\n\t\t}\n\t}\n\n\treturn result\n}\n"
  },
  {
    "path": "provider/exoscale/exoscale_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage exoscale\n\nimport (\n\t\"context\"\n\t\"testing\"\n\n\tegoscale \"github.com/exoscale/egoscale/v2\"\n\t\"github.com/sirupsen/logrus/hooks/test\"\n\t\"github.com/stretchr/testify/assert\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\n\t\"github.com/google/uuid\"\n)\n\ntype createRecordExoscale struct {\n\tdomainID string\n\trecord   *egoscale.DNSDomainRecord\n}\n\ntype deleteRecordExoscale struct {\n\tdomainID string\n\trecordID string\n}\n\ntype updateRecordExoscale struct {\n\tdomainID string\n\trecord   *egoscale.DNSDomainRecord\n}\n\nvar (\n\tcreateExoscale []createRecordExoscale\n\tdeleteExoscale []deleteRecordExoscale\n\tupdateExoscale []updateRecordExoscale\n)\n\nvar defaultTTL int64 = 3600\nvar domainIDs = []string{uuid.New().String(), uuid.New().String(), uuid.New().String(), uuid.New().String()}\nvar groups = map[string][]egoscale.DNSDomainRecord{\n\tdomainIDs[0]: {\n\t\t{ID: strPtr(uuid.New().String()), Name: strPtr(\"v1\"), Type: strPtr(\"TXT\"), Content: strPtr(\"test\"), TTL: &defaultTTL},\n\t\t{ID: strPtr(uuid.New().String()), Name: strPtr(\"v2\"), Type: strPtr(\"CNAME\"), Content: strPtr(\"test\"), TTL: &defaultTTL},\n\t},\n\tdomainIDs[1]: {\n\t\t{ID: strPtr(uuid.New().String()), Name: strPtr(\"v2\"), Type: strPtr(\"A\"), Content: strPtr(\"test\"), TTL: &defaultTTL},\n\t\t{ID: strPtr(uuid.New().String()), Name: strPtr(\"v3\"), Type: strPtr(\"ALIAS\"), Content: strPtr(\"test\"), TTL: &defaultTTL},\n\t},\n\tdomainIDs[2]: {\n\t\t{ID: strPtr(uuid.New().String()), Name: strPtr(\"v1\"), Type: strPtr(\"TXT\"), Content: strPtr(\"test\"), TTL: &defaultTTL},\n\t},\n\tdomainIDs[3]: {\n\t\t{ID: strPtr(uuid.New().String()), Name: strPtr(\"v4\"), Type: strPtr(\"ALIAS\"), Content: strPtr(\"test\"), TTL: &defaultTTL},\n\t},\n}\n\nfunc strPtr(s string) *string {\n\treturn &s\n}\n\ntype ExoscaleClientStub struct{}\n\nfunc NewExoscaleClientStub() EgoscaleClientI {\n\tep := &ExoscaleClientStub{}\n\treturn ep\n}\n\nfunc (ep *ExoscaleClientStub) ListDNSDomains(_ context.Context, _ string) ([]egoscale.DNSDomain, error) {\n\tdomains := []egoscale.DNSDomain{\n\t\t{ID: &domainIDs[0], UnicodeName: strPtr(\"foo.com\")},\n\t\t{ID: &domainIDs[1], UnicodeName: strPtr(\"bar.com\")},\n\t}\n\treturn domains, nil\n}\n\nfunc (ep *ExoscaleClientStub) ListDNSDomainRecords(_ context.Context, _, domainID string) ([]egoscale.DNSDomainRecord, error) {\n\treturn groups[domainID], nil\n}\n\nfunc (ep *ExoscaleClientStub) CreateDNSDomainRecord(_ context.Context, _, domainID string, record *egoscale.DNSDomainRecord) (*egoscale.DNSDomainRecord, error) {\n\tcreateExoscale = append(createExoscale, createRecordExoscale{domainID: domainID, record: record})\n\treturn record, nil\n}\n\nfunc (ep *ExoscaleClientStub) DeleteDNSDomainRecord(_ context.Context, _, domainID string, record *egoscale.DNSDomainRecord) error {\n\tdeleteExoscale = append(deleteExoscale, deleteRecordExoscale{domainID: domainID, recordID: *record.ID})\n\treturn nil\n}\n\nfunc (ep *ExoscaleClientStub) UpdateDNSDomainRecord(_ context.Context, _, domainID string, record *egoscale.DNSDomainRecord) error {\n\tupdateExoscale = append(updateExoscale, updateRecordExoscale{domainID: domainID, record: record})\n\treturn nil\n}\n\nfunc contains(arr []*endpoint.Endpoint, name string) bool {\n\tfor _, a := range arr {\n\t\tif a.DNSName == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc TestExoscaleGetRecords(t *testing.T) {\n\tprovider := NewExoscaleProviderWithClient(NewExoscaleClientStub(), \"\", \"\", false)\n\n\trecs, err := provider.Records(t.Context())\n\tif err == nil {\n\t\tassert.Len(t, recs, 3)\n\t\tassert.True(t, contains(recs, \"v1.foo.com\"))\n\t\tassert.True(t, contains(recs, \"v2.bar.com\"))\n\t\tassert.True(t, contains(recs, \"v2.foo.com\"))\n\t\tassert.False(t, contains(recs, \"v3.bar.com\"))\n\t\tassert.False(t, contains(recs, \"v1.foobar.com\"))\n\t} else {\n\t\tassert.Error(t, err)\n\t}\n}\n\nfunc TestExoscaleApplyChanges(t *testing.T) {\n\tprovider := NewExoscaleProviderWithClient(NewExoscaleClientStub(), \"\", \"\", false)\n\n\tplan := &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"v1.foo.com\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tTargets:    []string{\"\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"v1.foobar.com\",\n\t\t\t\tRecordType: \"TXT\",\n\t\t\t\tTargets:    []string{\"\"},\n\t\t\t},\n\t\t},\n\t\tDelete: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"v1.foo.com\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tTargets:    []string{\"\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"v1.foobar.com\",\n\t\t\t\tRecordType: \"TXT\",\n\t\t\t\tTargets:    []string{\"\"},\n\t\t\t},\n\t\t},\n\t\tUpdateOld: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"v1.foo.com\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tTargets:    []string{\"\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"v1.foobar.com\",\n\t\t\t\tRecordType: \"TXT\",\n\t\t\t\tTargets:    []string{\"\"},\n\t\t\t},\n\t\t},\n\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"v1.foo.com\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tTargets:    []string{\"\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"v1.foobar.com\",\n\t\t\t\tRecordType: \"TXT\",\n\t\t\t\tTargets:    []string{\"\"},\n\t\t\t},\n\t\t},\n\t}\n\tcreateExoscale = make([]createRecordExoscale, 0)\n\tdeleteExoscale = make([]deleteRecordExoscale, 0)\n\n\tprovider.ApplyChanges(t.Context(), plan)\n\n\tassert.Len(t, createExoscale, 1)\n\tassert.Equal(t, domainIDs[0], createExoscale[0].domainID)\n\tassert.Equal(t, \"v1\", *createExoscale[0].record.Name)\n\n\tassert.Len(t, deleteExoscale, 1)\n\tassert.Equal(t, domainIDs[0], deleteExoscale[0].domainID)\n\tassert.Equal(t, *groups[domainIDs[0]][0].ID, deleteExoscale[0].recordID)\n\n\tassert.Len(t, updateExoscale, 1)\n\tassert.Equal(t, domainIDs[0], updateExoscale[0].domainID)\n\tassert.Equal(t, *groups[domainIDs[0]][0].ID, *updateExoscale[0].record.ID)\n}\n\nfunc TestExoscaleMerge_NoUpdateOnTTL0Changes(t *testing.T) {\n\tupdateOld := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"name1\",\n\t\t\tTargets:    endpoint.Targets{\"target1\"},\n\t\t\tRecordTTL:  endpoint.TTL(1),\n\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"name2\",\n\t\t\tTargets:    endpoint.Targets{\"target2\"},\n\t\t\tRecordTTL:  endpoint.TTL(1),\n\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t},\n\t}\n\n\tupdateNew := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"name1\",\n\t\t\tTargets:    endpoint.Targets{\"target1\"},\n\t\t\tRecordTTL:  endpoint.TTL(0),\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"name2\",\n\t\t\tTargets:    endpoint.Targets{\"target2\"},\n\t\t\tRecordTTL:  endpoint.TTL(0),\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t},\n\t}\n\n\tassert.Empty(t, merge(updateOld, updateNew))\n}\n\nfunc TestExoscaleMerge_UpdateOnTTLChanges(t *testing.T) {\n\tupdateOld := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"name1\",\n\t\t\tTargets:    endpoint.Targets{\"target1\"},\n\t\t\tRecordTTL:  endpoint.TTL(1),\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"name2\",\n\t\t\tTargets:    endpoint.Targets{\"target2\"},\n\t\t\tRecordTTL:  endpoint.TTL(1),\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t},\n\t}\n\n\tupdateNew := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"name1\",\n\t\t\tTargets:    endpoint.Targets{\"target1\"},\n\t\t\tRecordTTL:  endpoint.TTL(77),\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"name2\",\n\t\t\tTargets:    endpoint.Targets{\"target2\"},\n\t\t\tRecordTTL:  endpoint.TTL(10),\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t},\n\t}\n\n\tmerged := merge(updateOld, updateNew)\n\tassert.Len(t, merged, 2)\n\tassert.Equal(t, \"name1\", merged[0].DNSName)\n}\n\nfunc TestExoscaleMerge_AlwaysUpdateTarget(t *testing.T) {\n\tupdateOld := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"name1\",\n\t\t\tTargets:    endpoint.Targets{\"target1\"},\n\t\t\tRecordTTL:  endpoint.TTL(1),\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"name2\",\n\t\t\tTargets:    endpoint.Targets{\"target2\"},\n\t\t\tRecordTTL:  endpoint.TTL(1),\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t},\n\t}\n\n\tupdateNew := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"name1\",\n\t\t\tTargets:    endpoint.Targets{\"target1-changed\"},\n\t\t\tRecordTTL:  endpoint.TTL(0),\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"name2\",\n\t\t\tTargets:    endpoint.Targets{\"target2\"},\n\t\t\tRecordTTL:  endpoint.TTL(0),\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t},\n\t}\n\n\tmerged := merge(updateOld, updateNew)\n\tassert.Len(t, merged, 1)\n\tassert.Equal(t, \"target1-changed\", merged[0].Targets[0])\n}\n\nfunc TestExoscaleMerge_NoUpdateIfTTLUnchanged(t *testing.T) {\n\tupdateOld := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"name1\",\n\t\t\tTargets:    endpoint.Targets{\"target1\"},\n\t\t\tRecordTTL:  endpoint.TTL(55),\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"name2\",\n\t\t\tTargets:    endpoint.Targets{\"target2\"},\n\t\t\tRecordTTL:  endpoint.TTL(55),\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t},\n\t}\n\n\tupdateNew := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"name1\",\n\t\t\tTargets:    endpoint.Targets{\"target1\"},\n\t\t\tRecordTTL:  endpoint.TTL(55),\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"name2\",\n\t\t\tTargets:    endpoint.Targets{\"target2\"},\n\t\t\tRecordTTL:  endpoint.TTL(55),\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t},\n\t}\n\n\tmerged := merge(updateOld, updateNew)\n\tassert.Empty(t, merged)\n}\n\nfunc TestZones(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tdomain   string\n\t\tinput    map[string]string\n\t\texpected map[string]string\n\t}{\n\t\t{\n\t\t\tname:   \"single matching zone\",\n\t\t\tdomain: \"example.com\",\n\t\t\tinput: map[string]string{\n\t\t\t\t\"1\": \"example.com\",\n\t\t\t},\n\t\t\texpected: map[string]string{\n\t\t\t\t\"1\": \"example.com\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:   \"non matching zone\",\n\t\t\tdomain: \"example.com\",\n\t\t\tinput: map[string]string{\n\t\t\t\t\"1\": \"other.com\",\n\t\t\t},\n\t\t\texpected: map[string]string{},\n\t\t},\n\t\t{\n\t\t\tname:   \"multiple zones mixed match\",\n\t\t\tdomain: \"example.com\",\n\t\t\tinput: map[string]string{\n\t\t\t\t\"1\": \"example.com\",\n\t\t\t\t\"2\": \"sub.example.com\",\n\t\t\t\t\"3\": \"other.com\",\n\t\t\t},\n\t\t\texpected: map[string]string{\n\t\t\t\t\"1\": \"example.com\",\n\t\t\t\t\"2\": \"sub.example.com\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:     \"empty input\",\n\t\t\tdomain:   \"example.com\",\n\t\t\tinput:    map[string]string{},\n\t\t\texpected: map[string]string{},\n\t\t},\n\t\t{\n\t\t\tname:   \"empty domain matches all\",\n\t\t\tdomain: \"\",\n\t\t\tinput: map[string]string{\n\t\t\t\t\"1\": \"example.com\",\n\t\t\t\t\"2\": \"other.com\",\n\t\t\t},\n\t\t\texpected: map[string]string{\n\t\t\t\t\"1\": \"example.com\",\n\t\t\t\t\"2\": \"other.com\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:   \"suffix must be exact\",\n\t\t\tdomain: \"ample.com\",\n\t\t\tinput: map[string]string{\n\t\t\t\t\"1\": \"example.com\",\n\t\t\t\t\"2\": \"sample.com\",\n\t\t\t},\n\t\t\texpected: map[string]string{\n\t\t\t\t\"1\": \"example.com\",\n\t\t\t\t\"2\": \"sample.com\",\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, test := range tests {\n\t\tt.Run(test.name, func(t *testing.T) {\n\t\t\tzoneFilter := zoneFilter{\n\t\t\t\tdomain: test.domain,\n\t\t\t}\n\t\t\tresult := zoneFilter.Zones(test.input)\n\t\t\tassert.Equal(t, test.expected, result)\n\t\t})\n\t}\n}\n\nfunc TestExoscaleWithDomain_SetsDomain(t *testing.T) {\n\ttests := []struct {\n\t\tname         string\n\t\tdomainFilter []string\n\t}{\n\t\t{\n\t\t\tname:         \"domain filter\",\n\t\t\tdomainFilter: []string{\"example.com\", \"apple.xyz\"},\n\t\t},\n\t}\n\n\tfor _, test := range tests {\n\t\tt.Run(test.name, func(_ *testing.T) {\n\t\t\tp := &ExoscaleProvider{}\n\n\t\t\tdf := endpoint.NewDomainFilter(test.domainFilter)\n\n\t\t\tExoscaleWithDomain(df)(p)\n\t\t})\n\t}\n}\n\nfunc TestInMemoryWithLogging_LogsChanges(t *testing.T) {\n\tt.Run(\"exoscaleWithlogging\", func(t *testing.T) {\n\t\tlogger, hook := test.NewNullLogger()\n\t\tlog.SetFormatter(logger.Formatter)\n\t\tlog.SetLevel(log.InfoLevel)\n\t\tlog.AddHook(hook)\n\n\t\tp := &ExoscaleProvider{}\n\t\tExoscaleWithLogging()(p)\n\n\t\tchanges := &plan.Changes{\n\t\t\tCreate: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"create.example.com\", RecordType: \"A\"},\n\t\t\t},\n\t\t\tUpdateOld: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"old.example.com\", RecordType: \"A\"},\n\t\t\t},\n\t\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"new.example.com\", RecordType: \"A\"},\n\t\t\t},\n\t\t\tDelete: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"delete.example.com\", RecordType: \"A\"},\n\t\t\t},\n\t\t}\n\n\t\tp.OnApplyChanges(changes)\n\n\t\tentries := hook.AllEntries()\n\n\t\tassert.Contains(t, entries[0].Message, \"CREATE\")\n\t\tassert.Contains(t, entries[1].Message, \"UPDATE (old)\")\n\t\tassert.Contains(t, entries[2].Message, \"UPDATE (new)\")\n\t\tassert.Contains(t, entries[3].Message, \"DELETE\")\n\n\t})\n}\n"
  },
  {
    "path": "provider/factory/provider.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage factory\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\t\"sigs.k8s.io/external-dns/provider\"\n\t\"sigs.k8s.io/external-dns/provider/akamai\"\n\t\"sigs.k8s.io/external-dns/provider/alibabacloud\"\n\t\"sigs.k8s.io/external-dns/provider/aws\"\n\t\"sigs.k8s.io/external-dns/provider/awssd\"\n\t\"sigs.k8s.io/external-dns/provider/azure\"\n\t\"sigs.k8s.io/external-dns/provider/civo\"\n\t\"sigs.k8s.io/external-dns/provider/cloudflare\"\n\t\"sigs.k8s.io/external-dns/provider/coredns\"\n\t\"sigs.k8s.io/external-dns/provider/dnsimple\"\n\t\"sigs.k8s.io/external-dns/provider/exoscale\"\n\t\"sigs.k8s.io/external-dns/provider/gandi\"\n\t\"sigs.k8s.io/external-dns/provider/godaddy\"\n\t\"sigs.k8s.io/external-dns/provider/google\"\n\t\"sigs.k8s.io/external-dns/provider/inmemory\"\n\t\"sigs.k8s.io/external-dns/provider/linode\"\n\t\"sigs.k8s.io/external-dns/provider/ns1\"\n\t\"sigs.k8s.io/external-dns/provider/oci\"\n\t\"sigs.k8s.io/external-dns/provider/ovh\"\n\t\"sigs.k8s.io/external-dns/provider/pdns\"\n\t\"sigs.k8s.io/external-dns/provider/pihole\"\n\t\"sigs.k8s.io/external-dns/provider/plural\"\n\t\"sigs.k8s.io/external-dns/provider/rfc2136\"\n\t\"sigs.k8s.io/external-dns/provider/scaleway\"\n\t\"sigs.k8s.io/external-dns/provider/transip\"\n\t\"sigs.k8s.io/external-dns/provider/webhook\"\n)\n\n// ProviderConstructor is a function that creates a provider from configuration.\ntype ProviderConstructor func(\n\tctx context.Context,\n\tcfg *externaldns.Config,\n\tdomainFilter *endpoint.DomainFilter,\n) (provider.Provider, error)\n\n// Select creates a provider based on the given configuration.\nfunc Select(\n\tctx context.Context,\n\tcfg *externaldns.Config,\n\tdomainFilter *endpoint.DomainFilter) (provider.Provider, error) {\n\tconstructor, ok := providers(cfg.Provider)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown dns provider: %s\", cfg.Provider)\n\t}\n\tp, err := constructor(ctx, cfg, domainFilter)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif p != nil && cfg.ProviderCacheTime > 0 {\n\t\treturn provider.NewCachedProvider(p, cfg.ProviderCacheTime), nil\n\t}\n\treturn p, nil\n}\n\n// providers looks up the constructor for the named provider.\nfunc providers(selector string) (ProviderConstructor, bool) {\n\tm := map[string]ProviderConstructor{\n\t\texternaldns.ProviderAkamai:       akamai.New,\n\t\texternaldns.ProviderAlibabaCloud: alibabacloud.New,\n\t\texternaldns.ProviderAWS:          aws.New,\n\t\texternaldns.ProviderAWSSD:        awssd.New,\n\t\texternaldns.ProviderAzure:        azure.New,\n\t\texternaldns.ProviderAzureDNS:     azure.New,\n\t\texternaldns.ProviderAzurePrivate: azure.NewPrivate,\n\t\texternaldns.ProviderCivo:         civo.New,\n\t\texternaldns.ProviderCloudflare:   cloudflare.New,\n\t\texternaldns.ProviderCoreDNS:      coredns.New,\n\t\texternaldns.ProviderSkyDNS:       coredns.New,\n\t\texternaldns.ProviderDNSimple:     dnsimple.New,\n\t\texternaldns.ProviderExoscale:     exoscale.New,\n\t\texternaldns.ProviderGandi:        gandi.New,\n\t\texternaldns.ProviderGoDaddy:      godaddy.New,\n\t\texternaldns.ProviderGoogle:       google.New,\n\t\texternaldns.ProviderInMemory:     inmemory.New,\n\t\texternaldns.ProviderLinode:       linode.New,\n\t\texternaldns.ProviderNS1:          ns1.New,\n\t\texternaldns.ProviderOCI:          oci.New,\n\t\texternaldns.ProviderOVH:          ovh.New,\n\t\texternaldns.ProviderPDNS:         pdns.New,\n\t\texternaldns.ProviderPihole:       pihole.New,\n\t\texternaldns.ProviderPlural:       plural.New,\n\t\texternaldns.ProviderRFC2136:      rfc2136.New,\n\t\texternaldns.ProviderScaleway:     scaleway.New,\n\t\texternaldns.ProviderTransip:      transip.New,\n\t\texternaldns.ProviderWebhook:      webhook.New,\n\t}\n\tc, ok := m[selector]\n\treturn c, ok\n}\n"
  },
  {
    "path": "provider/factory/provider_test.go",
    "content": "/*\nCopyright 2026 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage factory\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"reflect\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n)\n\nfunc TestSelectProvider(t *testing.T) {\n\ttests := []struct {\n\t\tname          string\n\t\tcfg           *externaldns.Config\n\t\texpectedType  string\n\t\texpectedError string\n\t}{\n\t\t{\n\t\t\tname: \"aws provider\",\n\t\t\tcfg: &externaldns.Config{\n\t\t\t\tProvider: externaldns.ProviderAWS,\n\t\t\t},\n\t\t\texpectedType: \"*aws.AWSProvider\",\n\t\t},\n\t\t{\n\t\t\tname: \"rfc2136 provider\",\n\t\t\tcfg: &externaldns.Config{\n\t\t\t\tProvider:             externaldns.ProviderRFC2136,\n\t\t\t\tRFC2136TSIGSecretAlg: \"hmac-sha256\",\n\t\t\t},\n\t\t\texpectedType: \"*rfc2136.rfc2136Provider\",\n\t\t},\n\t\t{\n\t\t\tname: \"gandi provider\",\n\t\t\tcfg: &externaldns.Config{\n\t\t\t\tProvider: externaldns.ProviderGandi,\n\t\t\t},\n\t\t\texpectedError: \"no environment variable GANDI_KEY or GANDI_PAT provided\",\n\t\t},\n\t\t{\n\t\t\tname: \"inmemory provider\",\n\t\t\tcfg: &externaldns.Config{\n\t\t\t\tProvider: externaldns.ProviderInMemory,\n\t\t\t},\n\t\t\texpectedType: \"*inmemory.InMemoryProvider\",\n\t\t},\n\t\t{\n\t\t\tname: \"oci provider instance principal without compartment OCID\",\n\t\t\tcfg: &externaldns.Config{\n\t\t\t\tProvider:                 externaldns.ProviderOCI,\n\t\t\t\tOCIAuthInstancePrincipal: true,\n\t\t\t\tOCICompartmentOCID:       \"\",\n\t\t\t},\n\t\t\texpectedError: \"instance principal authentication requested, but no compartment OCID provided\",\n\t\t},\n\t\t{\n\t\t\tname: \"oci provider without config file\",\n\t\t\tcfg: &externaldns.Config{\n\t\t\t\tProvider:      externaldns.ProviderOCI,\n\t\t\t\tOCIConfigFile: \"\",\n\t\t\t},\n\t\t\texpectedError: \"reading OCI config file\",\n\t\t},\n\t\t{\n\t\t\tname: \"coredns provider\",\n\t\t\tcfg: &externaldns.Config{\n\t\t\t\tProvider: externaldns.ProviderCoreDNS,\n\t\t\t},\n\t\t\texpectedType: \"coredns.coreDNSProvider\",\n\t\t},\n\t\t{\n\t\t\tname: \"pihole provider\",\n\t\t\tcfg: &externaldns.Config{\n\t\t\t\tProvider:         externaldns.ProviderPihole,\n\t\t\t\tPiholeApiVersion: \"6\",\n\t\t\t\tPiholeServer:     \"http://localhost:8080\",\n\t\t\t},\n\t\t\texpectedType: \"*pihole.PiholeProvider\",\n\t\t},\n\t\t{\n\t\t\tname: \"dnsimple provider\",\n\t\t\tcfg: &externaldns.Config{\n\t\t\t\tProvider: externaldns.ProviderDNSimple,\n\t\t\t},\n\t\t\texpectedError: \"no dnsimple oauth token provided\",\n\t\t},\n\t\t{\n\t\t\tname: \"unknown provider\",\n\t\t\tcfg: &externaldns.Config{\n\t\t\t\tProvider: \"unknown\",\n\t\t\t},\n\t\t\texpectedError: \"unknown dns provider: unknown\",\n\t\t},\n\t\t{\n\t\t\tname: \"inmemory cached provider\",\n\t\t\tcfg: &externaldns.Config{\n\t\t\t\tProvider:          externaldns.ProviderInMemory,\n\t\t\t\tProviderCacheTime: 10 * time.Millisecond,\n\t\t\t},\n\t\t\texpectedType: \"*provider.CachedProvider\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tdomainFilter := endpoint.NewDomainFilter([]string{\"example.com\"})\n\n\t\t\tp, err := Select(t.Context(), tt.cfg, domainFilter)\n\n\t\t\tif tt.expectedError != \"\" {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\tassert.ErrorContains(t, err, tt.expectedError)\n\t\t\t} else {\n\t\t\t\trequire.NoError(t, err)\n\t\t\t\trequire.NotNil(t, p)\n\t\t\t\tassert.Contains(t, reflect.TypeOf(p).String(), tt.expectedType)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestKnownProviders(t *testing.T) {\n\tnames := make([]string, 0, len(externaldns.ProviderNames))\n\tfor _, name := range externaldns.ProviderNames {\n\t\tt.Run(name, func(t *testing.T) {\n\t\t\tnames = append(names, name)\n\t\t\t_, ok := providers(name)\n\t\t\tassert.True(t, ok, \"expected provider %s to be registered\", name)\n\t\t})\n\t}\n\tassert.ElementsMatch(t, externaldns.ProviderNames, names)\n}\n\nfunc TestSelectProvider_Webhook(t *testing.T) {\n\t// Stand up a minimal HTTP server that returns a valid negotiation response.\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"application/external.dns.webhook+json;version=1\")\n\t\tw.WriteHeader(http.StatusOK)\n\t\t_, _ = w.Write([]byte(`{}`))\n\t}))\n\tdefer srv.Close()\n\n\tcfg := &externaldns.Config{\n\t\tProvider:           externaldns.ProviderWebhook,\n\t\tWebhookProviderURL: srv.URL,\n\t}\n\tp, err := Select(t.Context(), cfg, nil)\n\trequire.NoError(t, err)\n\trequire.NotNil(t, p)\n}\n"
  },
  {
    "path": "provider/fakes/provider.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage fakes\n\nimport (\n\t\"context\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/plan\"\n)\n\ntype MockProvider struct {\n\tRecordsErr      error\n\tApplyChangesErr error\n}\n\nfunc (m *MockProvider) Records(_ context.Context) ([]*endpoint.Endpoint, error) {\n\treturn nil, m.RecordsErr\n}\n\nfunc (m *MockProvider) ApplyChanges(_ context.Context, _ *plan.Changes) error {\n\treturn m.ApplyChangesErr\n}\n\nfunc (m *MockProvider) AdjustEndpoints(eps []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) {\n\treturn eps, nil\n}\n\nfunc (m *MockProvider) GetDomainFilter() endpoint.DomainFilterInterface {\n\treturn &endpoint.DomainFilter{}\n}\n"
  },
  {
    "path": "provider/gandi/client.go",
    "content": "/*\nCopyright 2021 The Kubernetes Authors.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n    http://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage gandi\n\nimport (\n\t\"github.com/go-gandi/go-gandi/domain\"\n\t\"github.com/go-gandi/go-gandi/livedns\"\n)\n\ntype DomainClientAdapter interface {\n\tListDomains() ([]domain.ListResponse, error)\n}\n\ntype domainClient struct {\n\tClient *domain.Domain\n}\n\nfunc (p *domainClient) ListDomains() ([]domain.ListResponse, error) {\n\treturn p.Client.ListDomains()\n}\n\nfunc NewDomainClient(client *domain.Domain) DomainClientAdapter {\n\treturn &domainClient{client}\n}\n\n// standardResponse copied from go-gandi/internal/gandi.go\ntype standardResponse struct {\n\tCode    int             `json:\"code,omitempty\"`\n\tMessage string          `json:\"message,omitempty\"`\n\tUUID    string          `json:\"uuid,omitempty\"`\n\tObject  string          `json:\"object,omitempty\"`\n\tCause   string          `json:\"cause,omitempty\"`\n\tStatus  string          `json:\"status,omitempty\"`\n\tErrors  []standardError `json:\"errors,omitempty\"`\n}\n\n// standardError copied from go-gandi/internal/gandi.go\ntype standardError struct {\n\tLocation    string `json:\"location\"`\n\tName        string `json:\"name\"`\n\tDescription string `json:\"description\"`\n}\n\ntype LiveDNSClientAdapter interface {\n\tGetDomainRecords(fqdn string) (records []livedns.DomainRecord, err error)\n\tCreateDomainRecord(fqdn, name, recordtype string, ttl int, values []string) (standardResponse, error)\n\tDeleteDomainRecord(fqdn, name, recordtype string) (err error)\n\tUpdateDomainRecordByNameAndType(fqdn, name, recordtype string, ttl int, values []string) (standardResponse, error)\n}\n\ntype LiveDNSClient struct {\n\tClient *livedns.LiveDNS\n}\n\nfunc NewLiveDNSClient(client *livedns.LiveDNS) LiveDNSClientAdapter {\n\treturn &LiveDNSClient{client}\n}\n\nfunc (p *LiveDNSClient) GetDomainRecords(fqdn string) ([]livedns.DomainRecord, error) {\n\treturn p.Client.GetDomainRecords(fqdn)\n}\n\nfunc (p *LiveDNSClient) CreateDomainRecord(fqdn, name, recordtype string, ttl int, values []string) (standardResponse, error) {\n\tres, err := p.Client.CreateDomainRecord(fqdn, name, recordtype, ttl, values)\n\tif err != nil {\n\t\treturn standardResponse{}, err\n\t}\n\n\t// response needs to be copied as the Standard* structs are internal\n\tvar errors []standardError\n\tfor _, e := range res.Errors {\n\t\terrors = append(errors, standardError(e))\n\t}\n\treturn standardResponse{\n\t\tCode:    res.Code,\n\t\tMessage: res.Message,\n\t\tUUID:    res.UUID,\n\t\tObject:  res.Object,\n\t\tCause:   res.Cause,\n\t\tStatus:  res.Status,\n\t\tErrors:  errors,\n\t}, err\n}\n\nfunc (p *LiveDNSClient) DeleteDomainRecord(fqdn, name, recordtype string) error {\n\treturn p.Client.DeleteDomainRecord(fqdn, name, recordtype)\n}\n\nfunc (p *LiveDNSClient) UpdateDomainRecordByNameAndType(fqdn, name, recordtype string, ttl int, values []string) (standardResponse, error) {\n\tres, err := p.Client.UpdateDomainRecordByNameAndType(fqdn, name, recordtype, ttl, values)\n\tif err != nil {\n\t\treturn standardResponse{}, err\n\t}\n\n\t// response needs to be copied as the Standard* structs are internal\n\tvar errors []standardError\n\tfor _, e := range res.Errors {\n\t\terrors = append(errors, standardError(e))\n\t}\n\treturn standardResponse{\n\t\tCode:    res.Code,\n\t\tMessage: res.Message,\n\t\tUUID:    res.UUID,\n\t\tObject:  res.Object,\n\t\tCause:   res.Cause,\n\t\tStatus:  res.Status,\n\t\tErrors:  errors,\n\t}, err\n}\n"
  },
  {
    "path": "provider/gandi/gandi.go",
    "content": "/*\nCopyright 2021 The Kubernetes Authors.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n    http://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage gandi\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/go-gandi/go-gandi\"\n\t\"github.com/go-gandi/go-gandi/config\"\n\t\"github.com/go-gandi/go-gandi/livedns\"\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\nconst (\n\tgandiCreate          = \"CREATE\"\n\tgandiDelete          = \"DELETE\"\n\tgandiUpdate          = \"UPDATE\"\n\tdefaultTTL           = 600\n\tgandiLiveDNSProvider = \"livedns\"\n)\n\ntype GandiChanges struct {\n\tAction   string\n\tZoneName string\n\tRecord   livedns.DomainRecord\n}\n\ntype GandiProvider struct {\n\tprovider.BaseProvider\n\tLiveDNSClient LiveDNSClientAdapter\n\tDomainClient  DomainClientAdapter\n\tdomainFilter  *endpoint.DomainFilter\n\tDryRun        bool\n}\n\nfunc newProvider(domainFilter *endpoint.DomainFilter, dryRun bool) (*GandiProvider, error) {\n\tkey, ok_key := os.LookupEnv(\"GANDI_KEY\")\n\tpat, ok_pat := os.LookupEnv(\"GANDI_PAT\")\n\tif !ok_key && !ok_pat {\n\t\treturn nil, errors.New(\"no environment variable GANDI_KEY or GANDI_PAT provided\")\n\t}\n\tif ok_key {\n\t\tlog.Warning(\"Usage of GANDI_KEY (API Key) is deprecated. Please consider creating a Personal Access Token (PAT) instead, see https://api.gandi.net/docs/authentication/\")\n\t}\n\tsharingID, _ := os.LookupEnv(\"GANDI_SHARING_ID\")\n\n\tg := config.Config{\n\t\tAPIKey:              key,\n\t\tPersonalAccessToken: pat,\n\t\tSharingID:           sharingID,\n\t\tDebug:               false,\n\t\t// dry-run doesn't work but it won't hurt passing the flag\n\t\tDryRun: dryRun,\n\t}\n\n\tliveDNSClient := gandi.NewLiveDNSClient(g)\n\tdomainClient := gandi.NewDomainClient(g)\n\n\tgandiProvider := &GandiProvider{\n\t\tLiveDNSClient: NewLiveDNSClient(liveDNSClient),\n\t\tDomainClient:  NewDomainClient(domainClient),\n\t\tdomainFilter:  domainFilter,\n\t\tDryRun:        dryRun,\n\t}\n\treturn gandiProvider, nil\n}\n\n// New creates a Gandi provider from the given configuration.\nfunc New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) {\n\treturn newProvider(domainFilter, cfg.DryRun)\n}\n\nfunc (p *GandiProvider) Zones() ([]string, error) {\n\tavailableDomains, err := p.DomainClient.ListDomains()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tzones := []string{}\n\tfor _, domain := range availableDomains {\n\t\tif !p.domainFilter.Match(domain.FQDN) {\n\t\t\tlog.Debugf(\"Excluding domain %s by domain-filter\", domain.FQDN)\n\t\t\tcontinue\n\t\t}\n\n\t\tif domain.NameServer.Current != gandiLiveDNSProvider {\n\t\t\tlog.Debugf(\"Excluding domain %s, not configured for livedns\", domain.FQDN)\n\t\t\tcontinue\n\t\t}\n\n\t\tzones = append(zones, domain.FQDN)\n\t}\n\treturn zones, nil\n}\n\nfunc (p *GandiProvider) Records(_ context.Context) ([]*endpoint.Endpoint, error) {\n\tliveDNSZones, err := p.Zones()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tendpoints := []*endpoint.Endpoint{}\n\tfor _, zone := range liveDNSZones {\n\t\trecords, err := p.LiveDNSClient.GetDomainRecords(zone)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tfor _, r := range records {\n\t\t\tif provider.SupportedRecordType(r.RrsetType) {\n\t\t\t\tname := r.RrsetName + \".\" + zone\n\n\t\t\t\tif r.RrsetName == \"@\" {\n\t\t\t\t\tname = zone\n\t\t\t\t}\n\n\t\t\t\tfor _, v := range r.RrsetValues {\n\t\t\t\t\tlog.WithFields(log.Fields{\n\t\t\t\t\t\t\"record\": r.RrsetName,\n\t\t\t\t\t\t\"type\":   r.RrsetType,\n\t\t\t\t\t\t\"value\":  v,\n\t\t\t\t\t\t\"ttl\":    r.RrsetTTL,\n\t\t\t\t\t\t\"zone\":   zone,\n\t\t\t\t\t}).Debug(\"Returning endpoint record\")\n\n\t\t\t\t\tendpoints = append(\n\t\t\t\t\t\tendpoints,\n\t\t\t\t\t\tendpoint.NewEndpointWithTTL(name, r.RrsetType, endpoint.TTL(r.RrsetTTL), v),\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn endpoints, nil\n}\n\nfunc (p *GandiProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {\n\tcombinedChanges := make([]*GandiChanges, 0, len(changes.Create)+len(changes.UpdateNew)+len(changes.Delete))\n\n\tcombinedChanges = append(combinedChanges, p.newGandiChanges(gandiCreate, changes.Create)...)\n\tcombinedChanges = append(combinedChanges, p.newGandiChanges(gandiUpdate, changes.UpdateNew)...)\n\tcombinedChanges = append(combinedChanges, p.newGandiChanges(gandiDelete, changes.Delete)...)\n\n\treturn p.submitChanges(ctx, combinedChanges)\n}\n\nfunc (p *GandiProvider) submitChanges(_ context.Context, changes []*GandiChanges) error {\n\tif len(changes) == 0 {\n\t\tlog.Infof(\"All records are already up to date\")\n\t\treturn nil\n\t}\n\n\tliveDNSDomains, err := p.Zones()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tzoneChanges := p.groupAndFilterByZone(liveDNSDomains, changes)\n\n\tfor _, changes := range zoneChanges {\n\t\tfor _, change := range changes {\n\t\t\tif change.Record.RrsetType == endpoint.RecordTypeCNAME && !strings.HasSuffix(change.Record.RrsetValues[0], \".\") {\n\t\t\t\tchange.Record.RrsetValues[0] += \".\"\n\t\t\t}\n\n\t\t\t// Prepare record name\n\t\t\tif change.Record.RrsetName == change.ZoneName {\n\t\t\t\tlog.WithFields(log.Fields{\n\t\t\t\t\t\"record\": change.Record.RrsetName,\n\t\t\t\t\t\"type\":   change.Record.RrsetType,\n\t\t\t\t\t\"value\":  change.Record.RrsetValues[0],\n\t\t\t\t\t\"ttl\":    change.Record.RrsetTTL,\n\t\t\t\t\t\"action\": change.Action,\n\t\t\t\t\t\"zone\":   change.ZoneName,\n\t\t\t\t}).Debugf(\"Converting record name: %s to apex domain (@)\", change.Record.RrsetName)\n\n\t\t\t\tchange.Record.RrsetName = \"@\"\n\t\t\t} else {\n\t\t\t\tchange.Record.RrsetName = strings.TrimSuffix(\n\t\t\t\t\tchange.Record.RrsetName,\n\t\t\t\t\t\".\"+change.ZoneName,\n\t\t\t\t)\n\t\t\t}\n\n\t\t\tlog.WithFields(log.Fields{\n\t\t\t\t\"record\": change.Record.RrsetName,\n\t\t\t\t\"type\":   change.Record.RrsetType,\n\t\t\t\t\"value\":  change.Record.RrsetValues[0],\n\t\t\t\t\"ttl\":    change.Record.RrsetTTL,\n\t\t\t\t\"action\": change.Action,\n\t\t\t\t\"zone\":   change.ZoneName,\n\t\t\t}).Info(\"Changing record\")\n\n\t\t\tif !p.DryRun {\n\t\t\t\tswitch change.Action {\n\t\t\t\tcase gandiCreate:\n\t\t\t\t\tanswer, err := p.LiveDNSClient.CreateDomainRecord(\n\t\t\t\t\t\tchange.ZoneName,\n\t\t\t\t\t\tchange.Record.RrsetName,\n\t\t\t\t\t\tchange.Record.RrsetType,\n\t\t\t\t\t\tchange.Record.RrsetTTL,\n\t\t\t\t\t\tchange.Record.RrsetValues,\n\t\t\t\t\t)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tlog.WithFields(log.Fields{\n\t\t\t\t\t\t\t\"Code\":    answer.Code,\n\t\t\t\t\t\t\t\"Message\": answer.Message,\n\t\t\t\t\t\t\t\"Cause\":   answer.Cause,\n\t\t\t\t\t\t\t\"Errors\":  answer.Errors,\n\t\t\t\t\t\t}).Warning(\"Create problem\")\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\tcase gandiDelete:\n\t\t\t\t\terr := p.LiveDNSClient.DeleteDomainRecord(change.ZoneName, change.Record.RrsetName, change.Record.RrsetType)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tlog.Warning(\"Delete problem\")\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\tcase gandiUpdate:\n\t\t\t\t\tanswer, err := p.LiveDNSClient.UpdateDomainRecordByNameAndType(\n\t\t\t\t\t\tchange.ZoneName,\n\t\t\t\t\t\tchange.Record.RrsetName,\n\t\t\t\t\t\tchange.Record.RrsetType,\n\t\t\t\t\t\tchange.Record.RrsetTTL,\n\t\t\t\t\t\tchange.Record.RrsetValues,\n\t\t\t\t\t)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tlog.WithFields(log.Fields{\n\t\t\t\t\t\t\t\"Code\":    answer.Code,\n\t\t\t\t\t\t\t\"Message\": answer.Message,\n\t\t\t\t\t\t\t\"Cause\":   answer.Cause,\n\t\t\t\t\t\t\t\"Errors\":  answer.Errors,\n\t\t\t\t\t\t}).Warning(\"Update problem\")\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (p *GandiProvider) newGandiChanges(action string, endpoints []*endpoint.Endpoint) []*GandiChanges {\n\tchanges := make([]*GandiChanges, 0, len(endpoints))\n\tttl := defaultTTL\n\tfor _, e := range endpoints {\n\t\tif e.RecordTTL.IsConfigured() {\n\t\t\tttl = int(e.RecordTTL)\n\t\t}\n\t\tchange := &GandiChanges{\n\t\t\tAction: action,\n\t\t\tRecord: livedns.DomainRecord{\n\t\t\t\tRrsetType:   e.RecordType,\n\t\t\t\tRrsetName:   e.DNSName,\n\t\t\t\tRrsetValues: e.Targets,\n\t\t\t\tRrsetTTL:    ttl,\n\t\t\t},\n\t\t}\n\t\tchanges = append(changes, change)\n\t}\n\treturn changes\n}\n\nfunc (p *GandiProvider) groupAndFilterByZone(zones []string, changes []*GandiChanges) map[string][]*GandiChanges {\n\tchange := make(map[string][]*GandiChanges)\n\tzoneNameID := provider.ZoneIDName{}\n\n\tfor _, z := range zones {\n\t\tzoneNameID.Add(z, z)\n\t\tchange[z] = []*GandiChanges{}\n\t}\n\n\tfor _, c := range changes {\n\t\tzoneID, zoneName := zoneNameID.FindZone(c.Record.RrsetName)\n\t\tif zoneName == \"\" {\n\t\t\tlog.Debugf(\"Skipping record %s because no hosted domain matching record DNS Name was detected\", c.Record.RrsetName)\n\t\t\tcontinue\n\t\t}\n\t\tc.ZoneName = zoneName\n\t\tchange[zoneID] = append(change[zoneID], c)\n\t}\n\treturn change\n}\n"
  },
  {
    "path": "provider/gandi/gandi_test.go",
    "content": "/*\nCopyright 2021 The Kubernetes Authors.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n    http://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage gandi\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"testing\"\n\n\t\"github.com/go-gandi/go-gandi/domain\"\n\t\"github.com/go-gandi/go-gandi/livedns\"\n\t\"github.com/maxatome/go-testdeep/td\"\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/internal/testutils\"\n\t\"sigs.k8s.io/external-dns/plan\"\n)\n\ntype MockAction struct {\n\tName   string\n\tFQDN   string\n\tRecord livedns.DomainRecord\n}\n\ntype mockGandiClient struct {\n\tActions         []MockAction\n\tFunctionToFail  string `default:\"\"`\n\tRecordsToReturn []livedns.DomainRecord\n}\n\nconst (\n\tdomainUriPrefix  = \"https://api.gandi.net/v5/domain/domains/\"\n\texampleDotComUri = domainUriPrefix + \"example.com\"\n\texampleDotNetUri = domainUriPrefix + \"example.net\"\n)\n\n// Mock all methods\n\nfunc (m *mockGandiClient) GetDomainRecords(fqdn string) ([]livedns.DomainRecord, error) {\n\tm.Actions = append(m.Actions, MockAction{\n\t\tName: \"GetDomainRecords\",\n\t\tFQDN: fqdn,\n\t})\n\n\tif m.FunctionToFail == \"GetDomainRecords\" {\n\t\treturn nil, fmt.Errorf(\"injected error\")\n\t}\n\n\treturn m.RecordsToReturn, nil\n}\n\nfunc (m *mockGandiClient) CreateDomainRecord(fqdn, name, recordtype string, ttl int, values []string) (standardResponse, error) {\n\tm.Actions = append(m.Actions, MockAction{\n\t\tName: \"CreateDomainRecord\",\n\t\tFQDN: fqdn,\n\t\tRecord: livedns.DomainRecord{\n\t\t\tRrsetType:   recordtype,\n\t\t\tRrsetTTL:    ttl,\n\t\t\tRrsetName:   name,\n\t\t\tRrsetValues: values,\n\t\t},\n\t})\n\n\tif m.FunctionToFail == \"CreateDomainRecord\" {\n\t\treturn standardResponse{}, fmt.Errorf(\"injected error\")\n\t}\n\n\treturn standardResponse{}, nil\n}\n\nfunc (m *mockGandiClient) DeleteDomainRecord(fqdn, name, recordtype string) error {\n\tm.Actions = append(m.Actions, MockAction{\n\t\tName: \"DeleteDomainRecord\",\n\t\tFQDN: fqdn,\n\t\tRecord: livedns.DomainRecord{\n\t\t\tRrsetType: recordtype,\n\t\t\tRrsetName: name,\n\t\t},\n\t})\n\n\tif m.FunctionToFail == \"DeleteDomainRecord\" {\n\t\treturn fmt.Errorf(\"injected error\")\n\t}\n\n\treturn nil\n}\n\nfunc (m *mockGandiClient) UpdateDomainRecordByNameAndType(fqdn, name, recordtype string, ttl int, values []string) (standardResponse, error) {\n\tm.Actions = append(m.Actions, MockAction{\n\t\tName: \"UpdateDomainRecordByNameAndType\",\n\t\tFQDN: fqdn,\n\t\tRecord: livedns.DomainRecord{\n\t\t\tRrsetType:   recordtype,\n\t\t\tRrsetTTL:    ttl,\n\t\t\tRrsetName:   name,\n\t\t\tRrsetValues: values,\n\t\t},\n\t})\n\n\tif m.FunctionToFail == \"UpdateDomainRecordByNameAndType\" {\n\t\treturn standardResponse{}, fmt.Errorf(\"injected error\")\n\t}\n\n\treturn standardResponse{}, nil\n}\n\nfunc (m *mockGandiClient) ListDomains() ([]domain.ListResponse, error) {\n\tm.Actions = append(m.Actions, MockAction{\n\t\tName: \"ListDomains\",\n\t})\n\n\tif m.FunctionToFail == \"ListDomains\" {\n\t\treturn []domain.ListResponse{}, fmt.Errorf(\"injected error\")\n\t}\n\n\treturn []domain.ListResponse{\n\t\t// Tests are using example.com\n\t\t{\n\t\t\tFQDN:        \"example.com\",\n\t\t\tFQDNUnicode: \"example.com\",\n\t\t\tHref:        exampleDotComUri,\n\t\t\tID:          \"b3e9c271-1c29-4441-97d9-bc021a7ac7c3\",\n\t\t\tNameServer: &domain.NameServerConfig{\n\t\t\t\tCurrent: gandiLiveDNSProvider,\n\t\t\t},\n\t\t\tTLD: \"com\",\n\t\t},\n\t\t// example.net returns \"other\" as NameServer, so it is ignored\n\t\t{\n\t\t\tFQDN:        \"example.net\",\n\t\t\tFQDNUnicode: \"example.net\",\n\t\t\tHref:        exampleDotNetUri,\n\t\t\tID:          \"dc78c1d8-6143-4edb-93bc-3a20d8bc3570\",\n\t\t\tNameServer: &domain.NameServerConfig{\n\t\t\t\tCurrent: \"other\",\n\t\t\t},\n\t\t\tTLD: \"net\",\n\t\t},\n\t}, nil\n}\n\n// Tests\n\nfunc TestNewProvider(t *testing.T) {\n\tt.Setenv(\"GANDI_KEY\", \"myGandiKey\")\n\tprovider, err := newProvider(endpoint.NewDomainFilter([]string{\"example.com\"}), true)\n\tif err != nil {\n\t\tt.Errorf(\"failed : %s\", err)\n\t}\n\tassert.True(t, provider.DryRun)\n\n\tt.Setenv(\"GANDI_PAT\", \"myGandiPAT\")\n\tprovider, err = newProvider(endpoint.NewDomainFilter([]string{\"example.com\"}), true)\n\tif err != nil {\n\t\tt.Errorf(\"failed : %s\", err)\n\t}\n\tassert.True(t, provider.DryRun)\n\n\t_ = os.Unsetenv(\"GANDI_KEY\")\n\tprovider, err = newProvider(endpoint.NewDomainFilter([]string{\"example.com\"}), true)\n\tif err != nil {\n\t\tt.Errorf(\"failed : %s\", err)\n\t}\n\tassert.True(t, provider.DryRun)\n\n\tt.Setenv(\"GANDI_SHARING_ID\", \"aSharingId\")\n\tprovider, err = newProvider(endpoint.NewDomainFilter([]string{\"example.com\"}), false)\n\tif err != nil {\n\t\tt.Errorf(\"failed : %s\", err)\n\t}\n\tassert.False(t, provider.DryRun)\n\n\t_ = os.Unsetenv(\"GANDI_PAT\")\n\t_, err = newProvider(endpoint.NewDomainFilter([]string{\"example.com\"}), true)\n\tif err == nil {\n\t\tt.Errorf(\"expected to fail\")\n\t}\n}\n\nfunc TestGandiProvider_RecordsReturnsCorrectEndpoints(t *testing.T) {\n\tmockedClient := &mockGandiClient{\n\t\tRecordsToReturn: []livedns.DomainRecord{\n\t\t\t{\n\t\t\t\tRrsetType:   endpoint.RecordTypeCNAME,\n\t\t\t\tRrsetTTL:    600,\n\t\t\t\tRrsetName:   \"@\",\n\t\t\t\tRrsetHref:   exampleDotComUri + \"/records/%40/A\",\n\t\t\t\tRrsetValues: []string{\"192.168.0.1\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tRrsetType:   endpoint.RecordTypeCNAME,\n\t\t\t\tRrsetTTL:    600,\n\t\t\t\tRrsetName:   \"www\",\n\t\t\t\tRrsetHref:   exampleDotComUri + \"/records/www/CNAME\",\n\t\t\t\tRrsetValues: []string{\"lb.example.com\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tRrsetType:   endpoint.RecordTypeA,\n\t\t\t\tRrsetTTL:    600,\n\t\t\t\tRrsetName:   \"test\",\n\t\t\t\tRrsetHref:   exampleDotComUri + \"/records/test/A\",\n\t\t\t\tRrsetValues: []string{\"192.168.0.2\"},\n\t\t\t},\n\t\t},\n\t}\n\n\tmockedProvider := &GandiProvider{\n\t\tDomainClient:  mockedClient,\n\t\tLiveDNSClient: mockedClient,\n\t}\n\n\tactualEndpoints, err := mockedProvider.Records(t.Context())\n\tif err != nil {\n\t\tt.Errorf(\"should not fail, %s\", err)\n\t}\n\n\texpectedEndpoints := []*endpoint.Endpoint{\n\t\t{\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\tDNSName:    \"example.com\",\n\t\t\tTargets:    endpoint.Targets{\"192.168.0.1\"},\n\t\t\tRecordTTL:  600,\n\t\t},\n\t\t{\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\tDNSName:    \"www.example.com\",\n\t\t\tTargets:    endpoint.Targets{\"lb.example.com\"},\n\t\t\tRecordTTL:  600,\n\t\t},\n\t\t{\n\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\tDNSName:    \"test.example.com\",\n\t\t\tTargets:    endpoint.Targets{\"192.168.0.2\"},\n\t\t\tRecordTTL:  600,\n\t\t},\n\t}\n\n\tassert.Len(t, actualEndpoints, len(expectedEndpoints))\n\t// we could use testutils.SameEndpoints (plural), but this makes it easier to identify which case is failing\n\tfor i := range actualEndpoints {\n\t\tif !testutils.SameEndpoint(expectedEndpoints[i], actualEndpoints[i]) {\n\t\t\tt.Errorf(\"should be equal, expected:%v <> actual:%v\", expectedEndpoints[i], actualEndpoints[i])\n\n\t\t}\n\t}\n}\n\nfunc TestGandiProvider_RecordsOnFilteredDomainsShouldYieldNoEndpoints(t *testing.T) {\n\tmockedClient := &mockGandiClient{\n\t\tRecordsToReturn: []livedns.DomainRecord{\n\t\t\t{\n\t\t\t\tRrsetType:   endpoint.RecordTypeCNAME,\n\t\t\t\tRrsetTTL:    600,\n\t\t\t\tRrsetName:   \"@\",\n\t\t\t\tRrsetHref:   exampleDotComUri + \"/records/test/MX\",\n\t\t\t\tRrsetValues: []string{\"192.168.0.1\"},\n\t\t\t},\n\t\t},\n\t}\n\n\tmockedProvider := &GandiProvider{\n\t\tDomainClient:  mockedClient,\n\t\tLiveDNSClient: mockedClient,\n\t\tdomainFilter:  endpoint.NewDomainFilterWithExclusions([]string{}, []string{\"example.com\"}),\n\t}\n\n\tendpoints, _ := mockedProvider.Records(t.Context())\n\tassert.Empty(t, endpoints)\n}\n\nfunc TestGandiProvider_RecordsWithUnsupportedTypesAreNotReturned(t *testing.T) {\n\tmockedClient := &mockGandiClient{\n\t\tRecordsToReturn: []livedns.DomainRecord{\n\t\t\t{\n\t\t\t\tRrsetType:   \"MX\",\n\t\t\t\tRrsetTTL:    360,\n\t\t\t\tRrsetName:   \"@\",\n\t\t\t\tRrsetHref:   exampleDotComUri + \"/records/%40/A\",\n\t\t\t\tRrsetValues: []string{\"smtp.example.com\"},\n\t\t\t},\n\t\t},\n\t}\n\n\tmockedProvider := &GandiProvider{\n\t\tDomainClient:  mockedClient,\n\t\tLiveDNSClient: mockedClient,\n\t}\n\n\tendpoints, _ := mockedProvider.Records(t.Context())\n\tassert.Empty(t, endpoints)\n}\n\nfunc TestGandiProvider_ApplyChangesMakesExpectedAPICalls(t *testing.T) {\n\tchanges := &plan.Changes{}\n\tmockedClient := &mockGandiClient{}\n\tmockedProvider := &GandiProvider{\n\t\tDomainClient:  mockedClient,\n\t\tLiveDNSClient: mockedClient,\n\t}\n\n\tchanges.Create = []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"test2.example.com\",\n\t\t\tTargets:    endpoint.Targets{\"192.168.0.1\"},\n\t\t\tRecordType: \"A\",\n\t\t\tRecordTTL:  666,\n\t\t},\n\t}\n\tchanges.UpdateNew = []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"test3.example.com\",\n\t\t\tTargets:    endpoint.Targets{\"192.168.0.2\"},\n\t\t\tRecordType: \"A\",\n\t\t\tRecordTTL:  777,\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"example.com.example.com\",\n\t\t\tTargets:    endpoint.Targets{\"lb-2.example.net\"},\n\t\t\tRecordType: \"CNAME\",\n\t\t\tRecordTTL:  777,\n\t\t},\n\t}\n\tchanges.Delete = []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"test4.example.com\",\n\t\t\tTargets:    endpoint.Targets{\"192.168.0.3\"},\n\t\t\tRecordType: \"A\",\n\t\t},\n\t}\n\n\terr := mockedProvider.ApplyChanges(t.Context(), changes)\n\tif err != nil {\n\t\tt.Errorf(\"should not fail, %s\", err)\n\t}\n\n\ttd.Cmp(t, mockedClient.Actions, []MockAction{\n\t\t{\n\t\t\tName: \"ListDomains\",\n\t\t},\n\t\t{\n\t\t\tName: \"CreateDomainRecord\",\n\t\t\tFQDN: \"example.com\",\n\t\t\tRecord: livedns.DomainRecord{\n\t\t\t\tRrsetType:   endpoint.RecordTypeA,\n\t\t\t\tRrsetName:   \"test2\",\n\t\t\t\tRrsetValues: []string{\"192.168.0.1\"},\n\t\t\t\tRrsetTTL:    666,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName: \"UpdateDomainRecordByNameAndType\",\n\t\t\tFQDN: \"example.com\",\n\t\t\tRecord: livedns.DomainRecord{\n\t\t\t\tRrsetType:   endpoint.RecordTypeA,\n\t\t\t\tRrsetName:   \"test3\",\n\t\t\t\tRrsetValues: []string{\"192.168.0.2\"},\n\t\t\t\tRrsetTTL:    777,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName: \"UpdateDomainRecordByNameAndType\",\n\t\t\tFQDN: \"example.com\",\n\t\t\tRecord: livedns.DomainRecord{\n\t\t\t\tRrsetType:   endpoint.RecordTypeCNAME,\n\t\t\t\tRrsetName:   \"example.com\",\n\t\t\t\tRrsetValues: []string{\"lb-2.example.net.\"},\n\t\t\t\tRrsetTTL:    777,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName: \"DeleteDomainRecord\",\n\t\t\tFQDN: \"example.com\",\n\t\t\tRecord: livedns.DomainRecord{\n\t\t\t\tRrsetType: endpoint.RecordTypeA,\n\t\t\t\tRrsetName: \"test4\",\n\t\t\t},\n\t\t},\n\t})\n}\n\nfunc TestGandiProvider_ApplyChangesRespectsDryRun(t *testing.T) {\n\tchanges := &plan.Changes{}\n\tmockedClient := &mockGandiClient{}\n\tmockedProvider := &GandiProvider{\n\t\tDryRun:        true,\n\t\tDomainClient:  mockedClient,\n\t\tLiveDNSClient: mockedClient,\n\t}\n\n\tchanges.Create = []*endpoint.Endpoint{{DNSName: \"test2.example.com\", Targets: endpoint.Targets{\"192.168.0.1\"}, RecordType: \"A\", RecordTTL: 666}}\n\tchanges.UpdateNew = []*endpoint.Endpoint{{DNSName: \"test3.example.com\", Targets: endpoint.Targets{\"192.168.0.2\"}, RecordType: \"A\", RecordTTL: 777}}\n\tchanges.Delete = []*endpoint.Endpoint{{DNSName: \"test4.example.com\", Targets: endpoint.Targets{\"192.168.0.3\"}, RecordType: \"A\"}}\n\n\tmockedProvider.ApplyChanges(t.Context(), changes)\n\n\ttd.Cmp(t, mockedClient.Actions, []MockAction{\n\t\t{\n\t\t\tName: \"ListDomains\",\n\t\t},\n\t})\n}\n\nfunc TestGandiProvider_ApplyChangesWithEmptyResultDoesNothing(t *testing.T) {\n\tchanges := &plan.Changes{}\n\tmockedClient := &mockGandiClient{}\n\tmockedProvider := &GandiProvider{\n\t\tDomainClient:  mockedClient,\n\t\tLiveDNSClient: mockedClient,\n\t}\n\n\tmockedProvider.ApplyChanges(t.Context(), changes)\n\n\tassert.Empty(t, mockedClient.Actions)\n}\n\nfunc TestGandiProvider_ApplyChangesWithUnknownDomainDoesNoUpdate(t *testing.T) {\n\tchanges := &plan.Changes{}\n\tmockedClient := &mockGandiClient{}\n\tmockedProvider := &GandiProvider{\n\t\tDomainClient:  mockedClient,\n\t\tLiveDNSClient: mockedClient,\n\t}\n\n\tchanges.Create = []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"test.example.net\",\n\t\t\tTargets:    endpoint.Targets{\"192.168.0.1\"},\n\t\t\tRecordType: \"A\",\n\t\t\tRecordTTL:  666,\n\t\t},\n\t}\n\n\tmockedProvider.ApplyChanges(t.Context(), changes)\n\n\ttd.Cmp(t, mockedClient.Actions, []MockAction{\n\t\t{\n\t\t\tName: \"ListDomains\",\n\t\t},\n\t})\n}\n\nfunc TestGandiProvider_ApplyChangesConvertsApexDomain(t *testing.T) {\n\tchanges := &plan.Changes{}\n\tmockedClient := &mockGandiClient{}\n\tmockedProvider := &GandiProvider{\n\t\tDomainClient:  mockedClient,\n\t\tLiveDNSClient: mockedClient,\n\t}\n\n\t// Add a change where DNSName equals the zone name (apex domain)\n\tchanges.Create = []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"example.com\", // Matches the zone name\n\t\t\tTargets:    endpoint.Targets{\"192.168.0.1\"},\n\t\t\tRecordType: \"A\",\n\t\t\tRecordTTL:  666,\n\t\t},\n\t}\n\n\terr := mockedProvider.ApplyChanges(t.Context(), changes)\n\tif err != nil {\n\t\tt.Errorf(\"should not fail, %s\", err)\n\t}\n\n\ttd.Cmp(t, mockedClient.Actions, []MockAction{\n\t\t{\n\t\t\tName: \"ListDomains\",\n\t\t},\n\t\t{\n\t\t\tName: \"CreateDomainRecord\",\n\t\t\tFQDN: \"example.com\",\n\t\t\tRecord: livedns.DomainRecord{\n\t\t\t\tRrsetType:   endpoint.RecordTypeA,\n\t\t\t\tRrsetName:   \"@\",\n\t\t\t\tRrsetValues: []string{\"192.168.0.1\"},\n\t\t\t\tRrsetTTL:    666,\n\t\t\t},\n\t\t},\n\t})\n}\n\nfunc TestGandiProvider_FailingCases(t *testing.T) {\n\tchanges := &plan.Changes{}\n\tchanges.Create = []*endpoint.Endpoint{{DNSName: \"test2.example.com\", Targets: endpoint.Targets{\"192.168.0.1\"}, RecordType: \"A\", RecordTTL: 666}}\n\tchanges.UpdateNew = []*endpoint.Endpoint{{DNSName: \"test3.example.com\", Targets: endpoint.Targets{\"192.168.0.2\"}, RecordType: \"A\", RecordTTL: 777}}\n\tchanges.Delete = []*endpoint.Endpoint{{DNSName: \"test4.example.com\", Targets: endpoint.Targets{\"192.168.0.3\"}, RecordType: \"A\"}}\n\n\t// Failing ListDomains API call creates an error when calling Records\n\tmockedClient := &mockGandiClient{\n\t\tFunctionToFail: \"ListDomains\",\n\t}\n\tmockedProvider := &GandiProvider{\n\t\tDomainClient:  mockedClient,\n\t\tLiveDNSClient: mockedClient,\n\t}\n\n\t_, err := mockedProvider.Records(t.Context())\n\tif err == nil {\n\t\tt.Error(\"should have failed\")\n\t}\n\n\t// Failing GetDomainRecords API call creates an error when calling Records\n\tmockedClient = &mockGandiClient{\n\t\tFunctionToFail: \"GetDomainRecords\",\n\t}\n\tmockedProvider = &GandiProvider{\n\t\tDomainClient:  mockedClient,\n\t\tLiveDNSClient: mockedClient,\n\t}\n\n\t_, err = mockedProvider.Records(t.Context())\n\tif err == nil {\n\t\tt.Error(\"should have failed\")\n\t}\n\n\t// Failing ListDomains API call creates an error when calling ApplyChanges\n\tmockedClient = &mockGandiClient{\n\t\tFunctionToFail: \"ListDomains\",\n\t}\n\tmockedProvider = &GandiProvider{\n\t\tDomainClient:  mockedClient,\n\t\tLiveDNSClient: mockedClient,\n\t}\n\n\terr = mockedProvider.ApplyChanges(t.Context(), changes)\n\tif err == nil {\n\t\tt.Error(\"should have failed\")\n\t}\n\n\t// Failing CreateDomainRecord API call creates an error when calling ApplyChanges\n\tmockedClient = &mockGandiClient{\n\t\tFunctionToFail: \"CreateDomainRecord\",\n\t}\n\tmockedProvider = &GandiProvider{\n\t\tDomainClient:  mockedClient,\n\t\tLiveDNSClient: mockedClient,\n\t}\n\n\terr = mockedProvider.ApplyChanges(t.Context(), changes)\n\tif err == nil {\n\t\tt.Error(\"should have failed\")\n\t}\n\n\t// Failing DeleteDomainRecord API call creates an error when calling ApplyChanges\n\tmockedClient = &mockGandiClient{\n\t\tFunctionToFail: \"DeleteDomainRecord\",\n\t}\n\tmockedProvider = &GandiProvider{\n\t\tDomainClient:  mockedClient,\n\t\tLiveDNSClient: mockedClient,\n\t}\n\n\terr = mockedProvider.ApplyChanges(t.Context(), changes)\n\tif err == nil {\n\t\tt.Error(\"should have failed\")\n\t}\n\n\t// Failing UpdateDomainRecordByNameAndType API call creates an error when calling ApplyChanges\n\tmockedClient = &mockGandiClient{\n\t\tFunctionToFail: \"UpdateDomainRecordByNameAndType\",\n\t}\n\tmockedProvider = &GandiProvider{\n\t\tDomainClient:  mockedClient,\n\t\tLiveDNSClient: mockedClient,\n\t}\n\n\terr = mockedProvider.ApplyChanges(t.Context(), changes)\n\tif err == nil {\n\t\tt.Error(\"should have failed\")\n\t}\n}\n"
  },
  {
    "path": "provider/godaddy/client.go",
    "content": "/*\nCopyright 2020 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage godaddy\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"math/rand\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"time\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"golang.org/x/time/rate\"\n\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n)\n\nconst (\n\tErrCodeQuotaExceeded = \"QUOTA_EXCEEDED\"\n\n\t// DefaultTimeout api requests after\n\tDefaultTimeout = 180 * time.Second\n)\n\n// Errors\nvar (\n\tErrAPIDown = errors.New(\"godaddy: the GoDaddy API is down\")\n)\n\n// APIError error\ntype APIError struct {\n\tCode    string\n\tMessage string\n}\n\nfunc (err *APIError) Error() string {\n\treturn fmt.Sprintf(\"Error %s: %q\", err.Code, err.Message)\n}\n\n// Logger is the interface that should be implemented for loggers that wish to\n// log HTTP requests and HTTP responses.\ntype Logger interface {\n\t// LogRequest logs an HTTP request.\n\tLogRequest(*http.Request)\n\n\t// LogResponse logs an HTTP response.\n\tLogResponse(*http.Response)\n}\n\n// Client represents a client to call the GoDaddy API\ntype Client struct {\n\t// APIKey holds the Application key\n\tAPIKey string\n\n\t// APISecret holds the Application secret key\n\tAPISecret string\n\n\t// API endpoint\n\tAPIEndPoint string\n\n\t// Client is the underlying HTTP client used to run the requests. It may be overloaded but a default one is instanciated in ``NewClient`` by default.\n\tClient *http.Client\n\n\t// GoDaddy limits to 60 requests per minute\n\tRatelimiter *rate.Limiter\n\n\t// Logger is used to log HTTP requests and responses.\n\tLogger Logger\n\n\tTimeout time.Duration\n}\n\n// GDErrorField describe the error reason\ntype GDErrorField struct {\n\tCode        string `json:\"code,omitempty\"`\n\tMessage     string `json:\"message,omitempty\"`\n\tPath        string `json:\"path,omitempty\"`\n\tPathRelated string `json:\"pathRelated,omitempty\"`\n}\n\n// GDErrorResponse is the body response when an API call fails\ntype GDErrorResponse struct {\n\tCode    string         `json:\"code\"`\n\tFields  []GDErrorField `json:\"fields,omitempty\"`\n\tMessage string         `json:\"message,omitempty\"`\n}\n\nfunc (r GDErrorResponse) String() string {\n\tif b, err := json.Marshal(r); err == nil {\n\t\treturn string(b)\n\t}\n\n\treturn \"<error>\"\n}\n\n// NewClient represents a new client to call the API\nfunc NewClient(useOTE bool, apiKey, apiSecret string) (*Client, error) {\n\tvar endpoint string\n\n\tif useOTE {\n\t\tendpoint = \"https://api.ote-godaddy.com\"\n\t} else {\n\t\tendpoint = \"https://api.godaddy.com\"\n\t}\n\n\tclient := Client{\n\t\tAPIKey:      apiKey,\n\t\tAPISecret:   apiSecret,\n\t\tAPIEndPoint: endpoint,\n\t\tClient:      &http.Client{},\n\t\t// Add one token every second\n\t\tRatelimiter: rate.NewLimiter(rate.Every(time.Second), 60),\n\t\tTimeout:     DefaultTimeout,\n\t}\n\n\t// Get and check the configuration\n\tif err := client.validate(); err != nil {\n\t\tvar apiErr *APIError\n\t\t// Quota Exceeded errors are limited to the endpoint being called. Other endpoints are not affected when we hit\n\t\t// the quota limit on the endpoint used for validation. We can safely ignore this error.\n\t\t// Quota limits on other endpoints will be logged by their respective calls.\n\t\tif ok := errors.As(err, &apiErr); ok && apiErr.Code != ErrCodeQuotaExceeded {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\treturn &client, nil\n}\n\n//\n// Common request wrappers\n//\n\n// Get is a wrapper for the GET method\nfunc (c *Client) Get(url string, resType any) error {\n\treturn c.CallAPI(\"GET\", url, nil, resType)\n}\n\n// Patch is a wrapper for the PATCH method\nfunc (c *Client) Patch(url string, reqBody, resType any) error {\n\treturn c.CallAPI(\"PATCH\", url, reqBody, resType)\n}\n\n// Post is a wrapper for the POST method\nfunc (c *Client) Post(url string, reqBody, resType any) error {\n\treturn c.CallAPI(\"POST\", url, reqBody, resType)\n}\n\n// Put is a wrapper for the PUT method\nfunc (c *Client) Put(url string, reqBody, resType any) error {\n\treturn c.CallAPI(\"PUT\", url, reqBody, resType)\n}\n\n// Delete is a wrapper for the DELETE method\nfunc (c *Client) Delete(url string, resType any) error {\n\treturn c.CallAPI(\"DELETE\", url, nil, resType)\n}\n\n// GetWithContext is a wrapper for the GET method\nfunc (c *Client) GetWithContext(ctx context.Context, url string, resType any) error {\n\treturn c.CallAPIWithContext(ctx, \"GET\", url, nil, resType)\n}\n\n// PatchWithContext is a wrapper for the PATCH method\nfunc (c *Client) PatchWithContext(ctx context.Context, url string, reqBody, resType any) error {\n\treturn c.CallAPIWithContext(ctx, \"PATCH\", url, reqBody, resType)\n}\n\n// PostWithContext is a wrapper for the POST method\nfunc (c *Client) PostWithContext(ctx context.Context, url string, reqBody, resType any) error {\n\treturn c.CallAPIWithContext(ctx, \"POST\", url, reqBody, resType)\n}\n\n// PutWithContext is a wrapper for the PUT method\nfunc (c *Client) PutWithContext(ctx context.Context, url string, reqBody, resType any) error {\n\treturn c.CallAPIWithContext(ctx, \"PUT\", url, reqBody, resType)\n}\n\n// DeleteWithContext is a wrapper for the DELETE method\nfunc (c *Client) DeleteWithContext(ctx context.Context, url string, resType any) error {\n\treturn c.CallAPIWithContext(ctx, \"DELETE\", url, nil, resType)\n}\n\n// NewRequest returns a new HTTP request\nfunc (c *Client) NewRequest(method, path string, reqBody any) (*http.Request, error) {\n\tvar body []byte\n\tvar err error\n\n\tif reqBody != nil {\n\t\tbody, err = json.Marshal(reqBody)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\ttarget := fmt.Sprintf(\"%s%s\", c.APIEndPoint, path)\n\treq, err := http.NewRequest(method, target, bytes.NewReader(body))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Inject headers\n\tif body != nil {\n\t\treq.Header.Set(\"Content-Type\", \"application/json;charset=utf-8\")\n\t}\n\treq.Header.Set(\"Authorization\", fmt.Sprintf(\"sso-key %s:%s\", c.APIKey, c.APISecret))\n\treq.Header.Set(\"Accept\", \"application/json\")\n\treq.Header.Set(\"User-Agent\", externaldns.UserAgent())\n\n\t// Send the request with requested timeout\n\tc.Client.Timeout = c.Timeout\n\n\treturn req, nil\n}\n\n// Do sends an HTTP request and returns an HTTP response\nfunc (c *Client) Do(req *http.Request) (*http.Response, error) {\n\tif c.Logger != nil {\n\t\tc.Logger.LogRequest(req)\n\t}\n\n\tc.Ratelimiter.Wait(req.Context())\n\tresp, err := c.Client.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\t// In case of several clients behind NAT we still can hit rate limit\n\tfor i := 1; i < 3 && resp != nil && resp.StatusCode == http.StatusTooManyRequests; i++ {\n\t\tretryAfter, err := strconv.ParseInt(resp.Header.Get(\"Retry-After\"), 10, 0)\n\t\tif err != nil {\n\t\t\tlog.Error(\"Rate-limited response did not contain a valid Retry-After header, quota likely exceeded\")\n\t\t\tbreak\n\t\t}\n\n\t\tjitter := rand.Int63n(retryAfter)\n\t\tretryAfterSec := retryAfter + jitter/2\n\n\t\tsleepTime := time.Duration(retryAfterSec) * time.Second\n\t\ttime.Sleep(sleepTime)\n\n\t\tc.Ratelimiter.Wait(req.Context())\n\t\tresp, err = c.Client.Do(req)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"doing request after waiting for retry after: %w\", err)\n\t\t}\n\t}\n\tif c.Logger != nil {\n\t\tc.Logger.LogResponse(resp)\n\t}\n\treturn resp, nil\n}\n\n// CallAPI is the lowest level call helper. If needAuth is true,\n// inject authentication headers and sign the request.\n//\n// Request signature is a sha1 hash on following fields, joined by '+':\n// - applicationSecret (from Client instance)\n// - consumerKey (from Client instance)\n// - capitalized method (from arguments)\n// - full request url, including any query string argument\n// - full serialized request body\n// - server current time (takes time delta into account)\n//\n// Call will automatically assemble the target url from the endpoint\n// configured in the client instance and the path argument. If the reqBody\n// argument is not nil, it will also serialize it as json and inject\n// the required Content-Type header.\n//\n// If everything went fine, unmarshall response into resType and return nil\n// otherwise, return the error\nfunc (c *Client) CallAPI(method, path string, reqBody, resType any) error {\n\treturn c.CallAPIWithContext(context.Background(), method, path, reqBody, resType)\n}\n\n// CallAPIWithContext is the lowest level call helper. If needAuth is true,\n// inject authentication headers and sign the request.\n//\n// Request signature is a sha1 hash on following fields, joined by '+':\n// - applicationSecret (from Client instance)\n// - consumerKey (from Client instance)\n// - capitalized method (from arguments)\n// - full request url, including any query string argument\n// - full serialized request body\n// - server current time (takes time delta into account)\n//\n// # Context is used by http.Client to handle context cancelation\n//\n// Call will automatically assemble the target url from the endpoint\n// configured in the client instance and the path argument. If the reqBody\n// argument is not nil, it will also serialize it as json and inject\n// the required Content-Type header.\n//\n// If everything went fine, unmarshall response into resType and return nil\n// otherwise, return the error\nfunc (c *Client) CallAPIWithContext(ctx context.Context, method, path string, reqBody, resType any) error {\n\treq, err := c.NewRequest(method, path, reqBody)\n\tif err != nil {\n\t\treturn err\n\t}\n\treq = req.WithContext(ctx)\n\tresponse, err := c.Do(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn c.UnmarshalResponse(response, resType)\n}\n\n// UnmarshalResponse checks the response and unmarshals it into the response\n// type if needed Helper function, called from CallAPI\nfunc (c *Client) UnmarshalResponse(response *http.Response, resType any) error {\n\t// Read all the response body\n\tdefer response.Body.Close()\n\tbody, err := io.ReadAll(response.Body)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// < 200 && >= 300 : API error\n\tif response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {\n\t\tapiError := &APIError{\n\t\t\tCode: fmt.Sprintf(\"HTTPStatus: %d\", response.StatusCode),\n\t\t}\n\n\t\tif err = json.Unmarshal(body, apiError); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn apiError\n\t}\n\n\t// Nothing to unmarshal\n\tif len(body) == 0 || resType == nil {\n\t\treturn nil\n\t}\n\n\treturn json.Unmarshal(body, &resType)\n}\n\nfunc (c *Client) validate() error {\n\tvar response any\n\n\tif err := c.Get(domainsURI, response); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "provider/godaddy/client_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage godaddy\n\nimport (\n\t\"errors\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"golang.org/x/time/rate\"\n)\n\n// Tests that\nfunc TestClient_DoWhenQuotaExceeded(t *testing.T) {\n\tassert := assert.New(t)\n\n\t// Mock server to return 429 with a JSON payload\n\tmockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tw.WriteHeader(http.StatusTooManyRequests)\n\t\t_, err := w.Write([]byte(`{\"code\": \"QUOTA_EXCEEDED\", \"message\": \"rate limit exceeded\"}`))\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Failed to write response: %v\", err)\n\t\t}\n\t}))\n\tdefer mockServer.Close()\n\n\tclient := Client{\n\t\tAPIKey:      \"\",\n\t\tAPISecret:   \"\",\n\t\tAPIEndPoint: mockServer.URL,\n\t\tClient:      &http.Client{},\n\t\t// Add one token every second\n\t\tRatelimiter: rate.NewLimiter(rate.Every(time.Second), 60),\n\t\tTimeout:     DefaultTimeout,\n\t}\n\n\treq, err := client.NewRequest(\"GET\", \"/v1/domains/example.net/records\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to create request: %v\", err)\n\t}\n\n\tresp, err := client.Do(req)\n\tassert.NoError(err, \"A CODE_EXCEEDED response should not return an error\")\n\tassert.Equal(http.StatusTooManyRequests, resp.StatusCode, \"Expected a 429 response\")\n\n\trespContents := GDErrorResponse{}\n\terr = client.UnmarshalResponse(resp, &respContents)\n\tif assert.Error(err) {\n\t\tvar apiErr *APIError\n\t\terrors.As(err, &apiErr)\n\t\tassert.Equal(\"QUOTA_EXCEEDED\", apiErr.Code)\n\t\tassert.Equal(\"rate limit exceeded\", apiErr.Message)\n\t}\n}\n"
  },
  {
    "path": "provider/godaddy/godaddy.go",
    "content": "/*\nCopyright 2020 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage godaddy\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"slices\"\n\t\"strings\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"golang.org/x/sync/errgroup\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\nconst (\n\tdefaultTTL = 600\n\tgdCreate   = 0\n\tgdReplace  = 1\n\tgdDelete   = 2\n\n\tdomainsURI = \"/v1/domains?statuses=ACTIVE,PENDING_DNS_ACTIVE\"\n)\n\nvar actionNames = []string{\n\t\"create\",\n\t\"replace\",\n\t\"delete\",\n}\n\ntype gdClient interface {\n\tPatch(string, any, any) error\n\tPost(string, any, any) error\n\tPut(string, any, any) error\n\tGet(string, any) error\n\tDelete(string, any) error\n}\n\n// GDProvider declare GoDaddy provider\ntype GDProvider struct {\n\tprovider.BaseProvider\n\n\tdomainFilter *endpoint.DomainFilter\n\tclient       gdClient\n\tttl          int64\n\tDryRun       bool\n}\n\ntype gdEndpoint struct {\n\tendpoint *endpoint.Endpoint\n\taction   int\n}\n\ntype gdRecordField struct {\n\tData     string  `json:\"data\"`\n\tName     string  `json:\"name\"`\n\tTTL      int64   `json:\"ttl\"`\n\tType     string  `json:\"type\"`\n\tPort     *int    `json:\"port,omitempty\"`\n\tPriority *int    `json:\"priority,omitempty\"`\n\tWeight   *int64  `json:\"weight,omitempty\"`\n\tProtocol *string `json:\"protocol,omitempty\"`\n\tService  *string `json:\"service,omitempty\"`\n}\n\ntype gdReplaceRecordField struct {\n\tData     string  `json:\"data\"`\n\tTTL      int64   `json:\"ttl\"`\n\tPort     *int    `json:\"port,omitempty\"`\n\tPriority *int    `json:\"priority,omitempty\"`\n\tWeight   *int64  `json:\"weight,omitempty\"`\n\tProtocol *string `json:\"protocol,omitempty\"`\n\tService  *string `json:\"service,omitempty\"`\n}\n\ntype gdRecords struct {\n\trecords []gdRecordField\n\tchanged bool\n\tzone    string\n}\n\ntype gdZone struct {\n\tCreatedAt           string\n\tDomain              string\n\tDomainID            int64\n\tExpirationProtected bool\n\tExpires             string\n\tExposeWhois         bool\n\tHoldRegistrar       bool\n\tLocked              bool\n\tNameServers         *[]string\n\tPrivacy             bool\n\tRenewAuto           bool\n\tRenewDeadline       string\n\tRenewable           bool\n\tStatus              string\n\tTransferProtected   bool\n}\n\ntype gdZoneIDName map[string]*gdRecords\n\nfunc (z gdZoneIDName) add(zoneID string, zoneRecord *gdRecords) {\n\tz[zoneID] = zoneRecord\n}\n\nfunc (z gdZoneIDName) findZoneRecord(hostname string) (string, *gdRecords) {\n\tvar suitableZoneID string\n\tvar suitableZoneRecord *gdRecords\n\tfor zoneID, zoneRecord := range z {\n\t\tif hostname == zoneRecord.zone || strings.HasSuffix(hostname, \".\"+zoneRecord.zone) {\n\t\t\tif suitableZoneRecord == nil || len(zoneRecord.zone) > len(suitableZoneRecord.zone) {\n\t\t\t\tsuitableZoneID = zoneID\n\t\t\t\tsuitableZoneRecord = zoneRecord\n\t\t\t}\n\t\t}\n\t}\n\n\treturn suitableZoneID, suitableZoneRecord\n}\n\n// newProvider initializes a new GoDaddy DNS based Provider.\nfunc newProvider(domainFilter *endpoint.DomainFilter, ttl int64, apiKey, apiSecret string, useOTE, dryRun bool) (*GDProvider, error) {\n\tclient, err := NewClient(useOTE, apiKey, apiSecret)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &GDProvider{\n\t\tclient:       client,\n\t\tdomainFilter: domainFilter,\n\t\tttl:          maxOf(defaultTTL, ttl),\n\t\tDryRun:       dryRun,\n\t}, nil\n}\n\n// New creates a GoDaddy provider from the given configuration.\nfunc New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) {\n\treturn newProvider(domainFilter, cfg.GoDaddyTTL, cfg.GoDaddyAPIKey, cfg.GoDaddySecretKey, cfg.GoDaddyOTE, cfg.DryRun)\n}\n\nfunc (p *GDProvider) zones() ([]string, error) {\n\tzones := []gdZone{}\n\tfilteredZones := []string{}\n\n\tif err := p.client.Get(domainsURI, &zones); err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor _, zone := range zones {\n\t\tif p.domainFilter.Match(zone.Domain) {\n\t\t\tfilteredZones = append(filteredZones, zone.Domain)\n\t\t\tlog.Debugf(\"GoDaddy: %s zone found\", zone.Domain)\n\t\t}\n\t}\n\n\tlog.Infof(\"GoDaddy: %d zones found\", len(filteredZones))\n\n\treturn filteredZones, nil\n}\n\nfunc (p *GDProvider) zonesRecords(ctx context.Context, all bool) ([]string, []gdRecords, error) {\n\tvar allRecords []gdRecords\n\tzones, err := p.zones()\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\tswitch len(zones) {\n\tcase 0:\n\t\tallRecords = []gdRecords{}\n\tcase 1:\n\t\trecord, err := p.records(&ctx, zones[0], all)\n\t\tif err != nil {\n\t\t\treturn nil, nil, err\n\t\t}\n\n\t\tallRecords = append(allRecords, *record)\n\tdefault:\n\t\tchRecords := make(chan gdRecords, len(zones))\n\n\t\teg, ctx := errgroup.WithContext(ctx)\n\n\t\tfor _, zoneName := range zones {\n\t\t\tzone := zoneName\n\t\t\teg.Go(func() error {\n\t\t\t\trecord, err := p.records(&ctx, zone, all)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\n\t\t\t\tchRecords <- *record\n\n\t\t\t\treturn nil\n\t\t\t})\n\t\t}\n\n\t\tif err := eg.Wait(); err != nil {\n\t\t\treturn nil, nil, err\n\t\t}\n\n\t\tclose(chRecords)\n\n\t\tfor records := range chRecords {\n\t\t\tallRecords = append(allRecords, records)\n\t\t}\n\t}\n\n\treturn zones, allRecords, nil\n}\n\nfunc (p *GDProvider) records(_ *context.Context, zone string, all bool) (*gdRecords, error) {\n\tvar recordsIds []gdRecordField\n\n\tlog.Debugf(\"GoDaddy: Getting records for %s\", zone)\n\n\tif err := p.client.Get(fmt.Sprintf(\"/v1/domains/%s/records\", zone), &recordsIds); err != nil {\n\t\treturn nil, err\n\t}\n\n\tif all {\n\t\treturn &gdRecords{\n\t\t\tzone:    zone,\n\t\t\trecords: recordsIds,\n\t\t}, nil\n\t}\n\n\tresults := &gdRecords{\n\t\tzone:    zone,\n\t\trecords: make([]gdRecordField, 0, len(recordsIds)),\n\t}\n\n\tfor _, rec := range recordsIds {\n\t\tif provider.SupportedRecordType(rec.Type) {\n\t\t\tlog.Debugf(\"GoDaddy: Record %s for %s is %+v\", rec.Name, zone, rec)\n\n\t\t\tresults.records = append(results.records, rec)\n\t\t} else {\n\t\t\tlog.Infof(\"GoDaddy: Ignore record %s for %s is %+v\", rec.Name, zone, rec)\n\t\t}\n\t}\n\n\treturn results, nil\n}\n\nfunc (p *GDProvider) groupByNameAndType(zoneRecords []gdRecords) []*endpoint.Endpoint {\n\tendpoints := []*endpoint.Endpoint{}\n\n\t// group supported records by name and type\n\tgroupsByZone := map[string]map[string][]gdRecordField{}\n\n\tfor _, zone := range zoneRecords {\n\t\tgroups := map[string][]gdRecordField{}\n\n\t\tgroupsByZone[zone.zone] = groups\n\n\t\tfor _, r := range zone.records {\n\t\t\tgroupBy := fmt.Sprintf(\"%s - %s\", r.Type, r.Name)\n\n\t\t\tif _, ok := groups[groupBy]; !ok {\n\t\t\t\tgroups[groupBy] = []gdRecordField{}\n\t\t\t}\n\n\t\t\tgroups[groupBy] = append(groups[groupBy], r)\n\t\t}\n\t}\n\n\t// create single endpoint with all the targets for each name/type\n\tfor zoneName, groups := range groupsByZone {\n\t\tfor _, records := range groups {\n\t\t\ttargets := []string{}\n\n\t\t\tfor _, record := range records {\n\t\t\t\ttargets = append(targets, record.Data)\n\t\t\t}\n\n\t\t\tvar recordName string\n\n\t\t\tif records[0].Name == \"@\" {\n\t\t\t\trecordName = strings.TrimPrefix(zoneName, \".\")\n\t\t\t} else {\n\t\t\t\trecordName = strings.TrimPrefix(fmt.Sprintf(\"%s.%s\", records[0].Name, zoneName), \".\")\n\t\t\t}\n\n\t\t\tep := endpoint.NewEndpointWithTTL(\n\t\t\t\trecordName,\n\t\t\t\trecords[0].Type,\n\t\t\t\tendpoint.TTL(records[0].TTL),\n\t\t\t\ttargets...,\n\t\t\t)\n\n\t\t\tendpoints = append(endpoints, ep)\n\t\t}\n\t}\n\n\treturn endpoints\n}\n\n// Records returns the list of records in all relevant zones.\nfunc (p *GDProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {\n\t_, records, err := p.zonesRecords(ctx, false)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tendpoints := p.groupByNameAndType(records)\n\n\tlog.Infof(\"GoDaddy: %d endpoints have been found\", len(endpoints))\n\n\treturn endpoints, nil\n}\n\nfunc (p *GDProvider) appendChange(action int, endpoints []*endpoint.Endpoint, allChanges []gdEndpoint) []gdEndpoint {\n\tfor _, e := range endpoints {\n\t\tallChanges = append(allChanges, gdEndpoint{\n\t\t\taction:   action,\n\t\t\tendpoint: e,\n\t\t})\n\t}\n\n\treturn allChanges\n}\n\nfunc (p *GDProvider) changeAllRecords(endpoints []gdEndpoint, zoneRecords []*gdRecords) error {\n\tzoneNameIDMapper := gdZoneIDName{}\n\n\tfor _, zoneRecord := range zoneRecords {\n\t\tzoneNameIDMapper.add(zoneRecord.zone, zoneRecord)\n\t}\n\n\tfor _, e := range endpoints {\n\t\tdnsName := e.endpoint.DNSName\n\t\tzone, zoneRecord := zoneNameIDMapper.findZoneRecord(dnsName)\n\n\t\tif zone == \"\" {\n\t\t\tlog.Debugf(\"Skipping record %s because no hosted zone matching record DNS Name was detected\", dnsName)\n\t\t} else {\n\t\t\tdnsName = strings.TrimSuffix(dnsName, \".\"+zone)\n\t\t\tif dnsName == zone {\n\t\t\t\tdnsName = \"\"\n\t\t\t}\n\n\t\t\tif e.endpoint.RecordType == endpoint.RecordTypeA && (len(dnsName) == 0) {\n\t\t\t\tdnsName = \"@\"\n\t\t\t}\n\n\t\t\te.endpoint.RecordTTL = endpoint.TTL(maxOf(defaultTTL, int64(e.endpoint.RecordTTL)))\n\n\t\t\tif err := zoneRecord.applyEndpoint(e.action, p.client, *e.endpoint, dnsName, p.DryRun); err != nil {\n\t\t\t\tlog.Errorf(\"Unable to apply change %s on record %s type %s, %v\", actionNames[e.action], dnsName, e.endpoint.RecordType, err)\n\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// ApplyChanges applies a given set of changes in a given zone.\nfunc (p *GDProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {\n\tif countTargets(changes) == 0 {\n\t\treturn nil\n\t}\n\n\t_, records, err := p.zonesRecords(ctx, true)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tchangedZoneRecords := make([]*gdRecords, len(records))\n\n\tfor i := range records {\n\t\tchangedZoneRecords[i] = &records[i]\n\t}\n\n\tvar allChanges []gdEndpoint\n\n\tallChanges = p.appendChange(gdDelete, changes.Delete, allChanges)\n\n\tiOldSkip := make(map[int]bool)\n\tiNewSkip := make(map[int]bool)\n\n\tfor iOld, recOld := range changes.UpdateOld {\n\t\tfor iNew, recNew := range changes.UpdateNew {\n\t\t\tif recOld.DNSName == recNew.DNSName && recOld.RecordType == recNew.RecordType {\n\t\t\t\tReplaceEndpoints := []*endpoint.Endpoint{recNew}\n\t\t\t\tallChanges = p.appendChange(gdReplace, ReplaceEndpoints, allChanges)\n\t\t\t\tiOldSkip[iOld] = true\n\t\t\t\tiNewSkip[iNew] = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\tfor iOld, recOld := range changes.UpdateOld {\n\t\t_, found := iOldSkip[iOld]\n\t\tif found {\n\t\t\tcontinue\n\t\t}\n\t\tfor iNew, recNew := range changes.UpdateNew {\n\t\t\t_, found := iNewSkip[iNew]\n\t\t\tif found {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif recOld.DNSName != recNew.DNSName {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tDeleteEndpoints := []*endpoint.Endpoint{recOld}\n\t\t\tCreateEndpoints := []*endpoint.Endpoint{recNew}\n\t\t\tallChanges = p.appendChange(gdDelete, DeleteEndpoints, allChanges)\n\t\t\tallChanges = p.appendChange(gdCreate, CreateEndpoints, allChanges)\n\n\t\t\tbreak\n\t\t}\n\t}\n\n\tallChanges = p.appendChange(gdCreate, changes.Create, allChanges)\n\n\tlog.Infof(\"GoDaddy: %d changes will be done\", len(allChanges))\n\n\tif err = p.changeAllRecords(allChanges, changedZoneRecords); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (p *gdRecords) addRecord(client gdClient, endpoint endpoint.Endpoint, dnsName string, dryRun bool) error {\n\tvar response GDErrorResponse\n\tfor _, target := range endpoint.Targets {\n\t\tchange := gdRecordField{\n\t\t\tType: endpoint.RecordType,\n\t\t\tName: dnsName,\n\t\t\tTTL:  int64(endpoint.RecordTTL),\n\t\t\tData: target,\n\t\t}\n\n\t\tp.records = append(p.records, change)\n\t\tp.changed = true\n\n\t\tlog.Debugf(\"GoDaddy: Add an entry %s to zone %s\", change.String(), p.zone)\n\t\tif dryRun {\n\t\t\tlog.Infof(\"[DryRun] - Add record %s.%s of type %s %s\", change.Name, p.zone, change.Type, toString(change))\n\t\t} else if err := client.Patch(fmt.Sprintf(\"/v1/domains/%s/records\", p.zone), []gdRecordField{change}, &response); err != nil {\n\t\t\tlog.Errorf(\"Add record %s.%s of type %s failed: %s\", change.Name, p.zone, change.Type, response)\n\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (p *gdRecords) replaceRecord(client gdClient, endpoint endpoint.Endpoint, dnsName string, dryRun bool) error {\n\tchanged := []gdReplaceRecordField{}\n\trecords := []string{}\n\n\tfor _, target := range endpoint.Targets {\n\t\tchange := gdRecordField{\n\t\t\tType: endpoint.RecordType,\n\t\t\tName: dnsName,\n\t\t\tTTL:  int64(endpoint.RecordTTL),\n\t\t\tData: target,\n\t\t}\n\n\t\tfor index, record := range p.records {\n\t\t\tif record.Type == change.Type && record.Name == change.Name {\n\t\t\t\tp.records[index] = change\n\t\t\t\tp.changed = true\n\t\t\t}\n\t\t}\n\t\trecords = append(records, target)\n\t\tchanged = append(changed, gdReplaceRecordField{\n\t\t\tData:     change.Data,\n\t\t\tTTL:      change.TTL,\n\t\t\tPort:     change.Port,\n\t\t\tPriority: change.Priority,\n\t\t\tWeight:   change.Weight,\n\t\t\tProtocol: change.Protocol,\n\t\t\tService:  change.Service,\n\t\t})\n\t}\n\n\tvar response GDErrorResponse\n\n\tif dryRun {\n\t\tlog.Infof(\"[DryRun] - Replace record %s.%s of type %s %s\", dnsName, p.zone, endpoint.RecordType, records)\n\n\t\treturn nil\n\t}\n\n\tlog.Debugf(\"Replace record %s.%s of type %s %s\", dnsName, p.zone, endpoint.RecordType, records)\n\tif err := client.Put(fmt.Sprintf(\"/v1/domains/%s/records/%s/%s\", p.zone, endpoint.RecordType, dnsName), changed, &response); err != nil {\n\t\tlog.Errorf(\"Replace record %s.%s of type %s failed: %v\", dnsName, p.zone, endpoint.RecordType, response)\n\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// Remove one record from the record list\nfunc (p *gdRecords) deleteRecord(client gdClient, endpoint endpoint.Endpoint, dnsName string, dryRun bool) error {\n\trecords := []string{}\n\n\tfor _, target := range endpoint.Targets {\n\t\tchange := gdRecordField{\n\t\t\tType: endpoint.RecordType,\n\t\t\tName: dnsName,\n\t\t\tTTL:  int64(endpoint.RecordTTL),\n\t\t\tData: target,\n\t\t}\n\t\trecords = append(records, target)\n\n\t\tlog.Debugf(\"GoDaddy: Delete an entry %s from zone %s\", change.String(), p.zone)\n\n\t\tdeleteIndex := -1\n\n\t\tfor index, record := range p.records {\n\t\t\tif record.Type == change.Type && record.Name == change.Name && record.Data == change.Data {\n\t\t\t\tdeleteIndex = index\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif deleteIndex >= 0 {\n\t\t\tp.records[deleteIndex] = p.records[len(p.records)-1]\n\n\t\t\tp.records = p.records[:len(p.records)-1]\n\t\t\tp.changed = true\n\t\t}\n\t}\n\n\tif dryRun {\n\t\tlog.Infof(\"[DryRun] - Delete record %s.%s of type %s %s\", dnsName, p.zone, endpoint.RecordType, records)\n\n\t\treturn nil\n\t}\n\n\tvar response GDErrorResponse\n\tif err := client.Delete(fmt.Sprintf(\"/v1/domains/%s/records/%s/%s\", p.zone, endpoint.RecordType, dnsName), &response); err != nil {\n\t\tlog.Errorf(\"Delete record %s.%s of type %s failed: %v\", dnsName, p.zone, endpoint.RecordType, response)\n\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (p *gdRecords) applyEndpoint(action int, client gdClient, endpoint endpoint.Endpoint, dnsName string, dryRun bool) error {\n\tswitch action {\n\tcase gdCreate:\n\t\treturn p.addRecord(client, endpoint, dnsName, dryRun)\n\tcase gdReplace:\n\t\treturn p.replaceRecord(client, endpoint, dnsName, dryRun)\n\tcase gdDelete:\n\t\treturn p.deleteRecord(client, endpoint, dnsName, dryRun)\n\t}\n\n\treturn nil\n}\n\nfunc (c gdRecordField) String() string {\n\treturn fmt.Sprintf(\"%s %d IN %s %s\", c.Name, c.TTL, c.Type, c.Data)\n}\n\nfunc countTargets(p *plan.Changes) int {\n\tchanges := [][]*endpoint.Endpoint{p.Create, p.UpdateNew, p.UpdateOld, p.Delete}\n\tcount := 0\n\n\tfor _, endpoints := range changes {\n\t\tfor _, ep := range endpoints {\n\t\t\tcount += len(ep.Targets)\n\t\t}\n\t}\n\n\treturn count\n}\n\nfunc maxOf(vars ...int64) int64 {\n\treturn slices.Max(vars)\n}\n\nfunc toString(obj any) string {\n\tb, err := json.MarshalIndent(obj, \"\", \"\t\")\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"<%v>\", err)\n\t}\n\n\treturn string(b)\n}\n"
  },
  {
    "path": "provider/godaddy/godaddy_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage godaddy\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"sort\"\n\t\"testing\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/plan\"\n)\n\ntype mockGoDaddyClient struct {\n\tmock.Mock\n\tcurrentTest *testing.T\n}\n\nfunc newMockGoDaddyClient(t *testing.T) *mockGoDaddyClient {\n\treturn &mockGoDaddyClient{\n\t\tcurrentTest: t,\n\t}\n}\n\nvar (\n\tzoneNameExampleOrg string = \"example.org\"\n\tzoneNameExampleNet string = \"example.net\"\n)\n\nfunc (c *mockGoDaddyClient) Post(endpoint string, input any, output any) error {\n\tlog.Infof(\"POST: %s - %v\", endpoint, input)\n\tstub := c.Called(endpoint, input)\n\tdata, err := json.Marshal(stub.Get(0))\n\trequire.NoError(c.currentTest, err)\n\terr = json.Unmarshal(data, output)\n\trequire.NoError(c.currentTest, err)\n\treturn stub.Error(1)\n}\n\nfunc (c *mockGoDaddyClient) Patch(endpoint string, input any, output any) error {\n\tlog.Infof(\"PATCH: %s - %v\", endpoint, input)\n\tstub := c.Called(endpoint, input)\n\tdata, err := json.Marshal(stub.Get(0))\n\trequire.NoError(c.currentTest, err)\n\terr = json.Unmarshal(data, output)\n\trequire.NoError(c.currentTest, err)\n\treturn stub.Error(1)\n}\n\nfunc (c *mockGoDaddyClient) Put(endpoint string, input any, output any) error {\n\tlog.Infof(\"PUT: %s - %v\", endpoint, input)\n\tstub := c.Called(endpoint, input)\n\tdata, err := json.Marshal(stub.Get(0))\n\trequire.NoError(c.currentTest, err)\n\terr = json.Unmarshal(data, output)\n\trequire.NoError(c.currentTest, err)\n\treturn stub.Error(1)\n}\n\nfunc (c *mockGoDaddyClient) Get(endpoint string, output any) error {\n\tlog.Infof(\"GET: %s\", endpoint)\n\tstub := c.Called(endpoint)\n\tdata, err := json.Marshal(stub.Get(0))\n\trequire.NoError(c.currentTest, err)\n\terr = json.Unmarshal(data, output)\n\trequire.NoError(c.currentTest, err)\n\treturn stub.Error(1)\n}\n\nfunc (c *mockGoDaddyClient) Delete(endpoint string, output any) error {\n\tlog.Infof(\"DELETE: %s\", endpoint)\n\tstub := c.Called(endpoint)\n\tdata, err := json.Marshal(stub.Get(0))\n\trequire.NoError(c.currentTest, err)\n\terr = json.Unmarshal(data, output)\n\trequire.NoError(c.currentTest, err)\n\treturn stub.Error(1)\n}\n\nfunc TestGoDaddyZones(t *testing.T) {\n\tassert := assert.New(t)\n\tclient := newMockGoDaddyClient(t)\n\tprovider := &GDProvider{\n\t\tclient:       client,\n\t\tdomainFilter: endpoint.NewDomainFilter([]string{\"com\"}),\n\t}\n\n\t// Basic zones\n\tclient.On(\"Get\", domainsURI).Return([]gdZone{\n\t\t{\n\t\t\tDomain: \"example.com\",\n\t\t},\n\t\t{\n\t\t\tDomain: \"example.net\",\n\t\t},\n\t}, nil).Once()\n\n\tdomains, err := provider.zones()\n\n\tassert.NoError(err)\n\tassert.Contains(domains, \"example.com\")\n\tassert.NotContains(domains, \"example.net\")\n\n\tclient.AssertExpectations(t)\n\n\t// Error on getting zones\n\tclient.On(\"Get\", domainsURI).Return(nil, ErrAPIDown).Once()\n\tdomains, err = provider.zones()\n\tassert.Error(err)\n\tassert.Nil(domains)\n\tclient.AssertExpectations(t)\n}\n\nfunc TestGoDaddyZoneRecords(t *testing.T) {\n\tassert := assert.New(t)\n\tclient := newMockGoDaddyClient(t)\n\tprovider := &GDProvider{\n\t\tclient: client,\n\t}\n\n\t// Basic zones records\n\tclient.On(\"Get\", domainsURI).Return([]gdZone{\n\t\t{\n\t\t\tDomain: zoneNameExampleNet,\n\t\t},\n\t}, nil).Once()\n\n\tclient.On(\"Get\", \"/v1/domains/example.net/records\").Return([]gdRecordField{\n\t\t{\n\t\t\tName: \"godaddy\",\n\t\t\tType: \"NS\",\n\t\t\tTTL:  defaultTTL,\n\t\t\tData: \"203.0.113.42\",\n\t\t},\n\t\t{\n\t\t\tName: \"godaddy\",\n\t\t\tType: \"A\",\n\t\t\tTTL:  defaultTTL,\n\t\t\tData: \"203.0.113.42\",\n\t\t},\n\t}, nil).Once()\n\n\tzones, records, err := provider.zonesRecords(t.Context(), true)\n\n\tassert.NoError(err)\n\n\tassert.ElementsMatch(zones, []string{\n\t\tzoneNameExampleNet,\n\t})\n\n\tassert.ElementsMatch(records, []gdRecords{\n\t\t{\n\t\t\tzone: zoneNameExampleNet,\n\t\t\trecords: []gdRecordField{\n\t\t\t\t{\n\t\t\t\t\tName: \"godaddy\",\n\t\t\t\t\tType: \"NS\",\n\t\t\t\t\tTTL:  defaultTTL,\n\t\t\t\t\tData: \"203.0.113.42\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tName: \"godaddy\",\n\t\t\t\t\tType: \"A\",\n\t\t\t\t\tTTL:  defaultTTL,\n\t\t\t\t\tData: \"203.0.113.42\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t})\n\n\tclient.AssertExpectations(t)\n\n\t// Error on getting zones list\n\tclient.On(\"Get\", domainsURI).Return(nil, ErrAPIDown).Once()\n\tzones, records, err = provider.zonesRecords(t.Context(), false)\n\tassert.Error(err)\n\tassert.Nil(zones)\n\tassert.Nil(records)\n\tclient.AssertExpectations(t)\n\n\t// Error on getting zone records\n\tclient.On(\"Get\", domainsURI).Return([]gdZone{\n\t\t{\n\t\t\tDomain: zoneNameExampleNet,\n\t\t},\n\t}, nil).Once()\n\n\tclient.On(\"Get\", \"/v1/domains/example.net/records\").Return(nil, ErrAPIDown).Once()\n\n\tzones, records, err = provider.zonesRecords(t.Context(), false)\n\n\tassert.Error(err)\n\tassert.Nil(zones)\n\tassert.Nil(records)\n\tclient.AssertExpectations(t)\n\n\t// Error on getting zone record detail\n\tclient.On(\"Get\", domainsURI).Return([]gdZone{\n\t\t{\n\t\t\tDomain: zoneNameExampleNet,\n\t\t},\n\t}, nil).Once()\n\n\tclient.On(\"Get\", \"/v1/domains/example.net/records\").Return(nil, ErrAPIDown).Once()\n\n\tzones, records, err = provider.zonesRecords(t.Context(), false)\n\tassert.Error(err)\n\tassert.Nil(zones)\n\tassert.Nil(records)\n\tclient.AssertExpectations(t)\n}\n\nfunc TestGoDaddyRecords(t *testing.T) {\n\tassert := assert.New(t)\n\tclient := newMockGoDaddyClient(t)\n\tprovider := &GDProvider{\n\t\tclient: client,\n\t}\n\n\t// Basic zones records\n\tclient.On(\"Get\", domainsURI).Return([]gdZone{\n\t\t{\n\t\t\tDomain: zoneNameExampleOrg,\n\t\t},\n\t\t{\n\t\t\tDomain: zoneNameExampleNet,\n\t\t},\n\t}, nil).Once()\n\n\tclient.On(\"Get\", \"/v1/domains/example.org/records\").Return([]gdRecordField{\n\t\t{\n\t\t\tName: \"@\",\n\t\t\tType: \"A\",\n\t\t\tTTL:  defaultTTL,\n\t\t\tData: \"203.0.113.42\",\n\t\t},\n\t\t{\n\t\t\tName: \"www\",\n\t\t\tType: \"CNAME\",\n\t\t\tTTL:  defaultTTL,\n\t\t\tData: \"example.org\",\n\t\t},\n\t}, nil).Once()\n\n\tclient.On(\"Get\", \"/v1/domains/example.net/records\").Return([]gdRecordField{\n\t\t{\n\t\t\tName: \"godaddy\",\n\t\t\tType: \"A\",\n\t\t\tTTL:  defaultTTL,\n\t\t\tData: \"203.0.113.42\",\n\t\t},\n\t\t{\n\t\t\tName: \"godaddy\",\n\t\t\tType: \"A\",\n\t\t\tTTL:  defaultTTL,\n\t\t\tData: \"203.0.113.43\",\n\t\t},\n\t}, nil).Once()\n\n\tendpoints, err := provider.Records(t.Context())\n\tassert.NoError(err)\n\n\t// Little fix for multi targets endpoint\n\tfor _, endpoint := range endpoints {\n\t\tsort.Strings(endpoint.Targets)\n\t}\n\n\tassert.ElementsMatch(endpoints, []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"godaddy.example.net\",\n\t\t\tRecordType: \"A\",\n\t\t\tRecordTTL:  defaultTTL,\n\t\t\tLabels:     endpoint.NewLabels(),\n\t\t\tTargets: []string{\n\t\t\t\t\"203.0.113.42\",\n\t\t\t\t\"203.0.113.43\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"example.org\",\n\t\t\tRecordType: \"A\",\n\t\t\tRecordTTL:  defaultTTL,\n\t\t\tLabels:     endpoint.NewLabels(),\n\t\t\tTargets: []string{\n\t\t\t\t\"203.0.113.42\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"www.example.org\",\n\t\t\tRecordType: \"CNAME\",\n\t\t\tRecordTTL:  defaultTTL,\n\t\t\tLabels:     endpoint.NewLabels(),\n\t\t\tTargets: []string{\n\t\t\t\t\"example.org\",\n\t\t\t},\n\t\t},\n\t})\n\n\tclient.AssertExpectations(t)\n\n\t// Error getting zone\n\tclient.On(\"Get\", domainsURI).Return(nil, ErrAPIDown).Once()\n\tendpoints, err = provider.Records(t.Context())\n\tassert.Error(err)\n\tassert.Nil(endpoints)\n\tclient.AssertExpectations(t)\n}\n\nfunc TestGoDaddyChange(t *testing.T) {\n\tassert := assert.New(t)\n\tclient := newMockGoDaddyClient(t)\n\tprovider := &GDProvider{\n\t\tclient: client,\n\t}\n\n\tchanges := plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \".example.net\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tRecordTTL:  defaultTTL,\n\t\t\t\tTargets: []string{\n\t\t\t\t\t\"203.0.113.42\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tDelete: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"godaddy.example.net\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tTargets: []string{\n\t\t\t\t\t\"203.0.113.43\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\t// Fetch domains\n\tclient.On(\"Get\", domainsURI).Return([]gdZone{\n\t\t{\n\t\t\tDomain: zoneNameExampleNet,\n\t\t},\n\t}, nil).Once()\n\n\t// Fetch record\n\tclient.On(\"Get\", \"/v1/domains/example.net/records\").Return([]gdRecordField{\n\t\t{\n\t\t\tName: \"godaddy\",\n\t\t\tType: \"A\",\n\t\t\tTTL:  defaultTTL,\n\t\t\tData: \"203.0.113.43\",\n\t\t},\n\t}, nil).Once()\n\n\t// Add entry\n\tclient.On(\"Patch\", \"/v1/domains/example.net/records\", []gdRecordField{\n\t\t{\n\t\t\tName: \"@\",\n\t\t\tType: \"A\",\n\t\t\tTTL:  defaultTTL,\n\t\t\tData: \"203.0.113.42\",\n\t\t},\n\t}).Return(nil, nil).Once()\n\n\t// Delete entry\n\tclient.On(\"Delete\", \"/v1/domains/example.net/records/A/godaddy\").Return(nil, nil).Once()\n\n\tassert.NoError(provider.ApplyChanges(t.Context(), &changes))\n\n\tclient.AssertExpectations(t)\n}\n\nconst (\n\toperationFailedTestErrCode = \"GD500\"\n\toperationFailedTestReason  = \"Could not apply request\"\n\trecordNotFoundErrCode      = \"GD404\"\n\trecordNotFoundReason       = \"The requested record is not found in DNS zone\"\n)\n\nfunc TestGoDaddyErrorResponse(t *testing.T) {\n\tassert := assert.New(t)\n\tclient := newMockGoDaddyClient(t)\n\tprovider := &GDProvider{\n\t\tclient: client,\n\t}\n\n\tchanges := plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \".example.net\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tRecordTTL:  defaultTTL,\n\t\t\t\tTargets: []string{\n\t\t\t\t\t\"203.0.113.42\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tDelete: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"godaddy.example.net\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tTargets: []string{\n\t\t\t\t\t\"203.0.113.43\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\t// Fetch domains\n\tclient.On(\"Get\", domainsURI).Return([]gdZone{\n\t\t{\n\t\t\tDomain: zoneNameExampleNet,\n\t\t},\n\t}, nil).Once()\n\n\t// Fetch record\n\tclient.On(\"Get\", \"/v1/domains/example.net/records\").Return([]gdRecordField{\n\t\t{\n\t\t\tName: \"godaddy\",\n\t\t\tType: \"A\",\n\t\t\tTTL:  defaultTTL,\n\t\t\tData: \"203.0.113.43\",\n\t\t},\n\t}, nil).Once()\n\n\t// Delete entry\n\tclient.On(\"Delete\", \"/v1/domains/example.net/records/A/godaddy\").Return(GDErrorResponse{\n\t\tCode:    operationFailedTestErrCode,\n\t\tMessage: operationFailedTestReason,\n\t\tFields: []GDErrorField{{\n\t\t\tCode:    recordNotFoundErrCode,\n\t\t\tMessage: recordNotFoundReason,\n\t\t}},\n\t}, errors.New(operationFailedTestReason)).Once()\n\n\tassert.Error(provider.ApplyChanges(t.Context(), &changes))\n\n\tclient.AssertExpectations(t)\n}\n"
  },
  {
    "path": "provider/google/google.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage google\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"sort\"\n\t\"time\"\n\n\t\"cloud.google.com/go/compute/metadata\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"golang.org/x/oauth2/google\"\n\tdns \"google.golang.org/api/dns/v1\"\n\tgoogleapi \"google.golang.org/api/googleapi\"\n\t\"google.golang.org/api/option\"\n\n\textdnshttp \"sigs.k8s.io/external-dns/pkg/http\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\nconst (\n\tdefaultTTL = 300\n)\n\ntype managedZonesCreateCallInterface interface {\n\tDo(opts ...googleapi.CallOption) (*dns.ManagedZone, error)\n}\n\ntype managedZonesListCallInterface interface {\n\tPages(ctx context.Context, f func(*dns.ManagedZonesListResponse) error) error\n}\n\ntype managedZonesServiceInterface interface {\n\tCreate(project string, managedzone *dns.ManagedZone) managedZonesCreateCallInterface\n\tList(project string) managedZonesListCallInterface\n}\n\ntype resourceRecordSetsListCallInterface interface {\n\tPages(ctx context.Context, f func(*dns.ResourceRecordSetsListResponse) error) error\n}\n\ntype resourceRecordSetsClientInterface interface {\n\tList(project string, managedZone string) resourceRecordSetsListCallInterface\n}\n\ntype changesCreateCallInterface interface {\n\tDo(opts ...googleapi.CallOption) (*dns.Change, error)\n}\n\ntype changesServiceInterface interface {\n\tCreate(project string, managedZone string, change *dns.Change) changesCreateCallInterface\n}\n\ntype resourceRecordSetsService struct {\n\tservice *dns.ResourceRecordSetsService\n}\n\nfunc (r resourceRecordSetsService) List(project string, managedZone string) resourceRecordSetsListCallInterface {\n\treturn r.service.List(project, managedZone)\n}\n\ntype managedZonesService struct {\n\tservice *dns.ManagedZonesService\n}\n\nfunc (m managedZonesService) Create(project string, managedzone *dns.ManagedZone) managedZonesCreateCallInterface {\n\treturn m.service.Create(project, managedzone)\n}\n\nfunc (m managedZonesService) List(project string) managedZonesListCallInterface {\n\treturn m.service.List(project)\n}\n\ntype changesService struct {\n\tservice *dns.ChangesService\n}\n\nfunc (c changesService) Create(project string, managedZone string, change *dns.Change) changesCreateCallInterface {\n\treturn c.service.Create(project, managedZone, change)\n}\n\n// GoogleProvider is an implementation of Provider for Google CloudDNS.\ntype GoogleProvider struct {\n\tprovider.BaseProvider\n\t// The Google project to work in\n\tproject string\n\t// Enabled dry-run will print any modifying actions rather than execute them.\n\tdryRun bool\n\t// Max batch size to submit to Google Cloud DNS per transaction.\n\tbatchChangeSize int\n\t// Interval between batch updates.\n\tbatchChangeInterval time.Duration\n\t// only consider hosted zones managing domains ending in this suffix\n\tdomainFilter *endpoint.DomainFilter\n\t// filter for zones based on visibility\n\tzoneTypeFilter provider.ZoneTypeFilter\n\t// only consider hosted zones ending with this zone id\n\tzoneIDFilter provider.ZoneIDFilter\n\t// A client for managing resource record sets\n\tresourceRecordSetsClient resourceRecordSetsClientInterface\n\t// A client for managing hosted zones\n\tmanagedZonesClient managedZonesServiceInterface\n\t// A client for managing change sets\n\tchangesClient changesServiceInterface\n\t// The context parameter to be passed for gcloud API calls.\n\tctx context.Context\n}\n\n// New creates a Google Cloud DNS provider from the given configuration.\nfunc New(ctx context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) {\n\treturn newProvider(ctx, cfg.GoogleProject, domainFilter, provider.NewZoneIDFilter(cfg.ZoneIDFilter), cfg.GoogleBatchChangeSize, cfg.GoogleBatchChangeInterval, cfg.GoogleZoneVisibility, cfg.DryRun)\n}\n\n// newProvider initializes a new Google CloudDNS based Provider.\nfunc newProvider(ctx context.Context, project string, domainFilter *endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, batchChangeSize int, batchChangeInterval time.Duration, zoneVisibility string, dryRun bool) (*GoogleProvider, error) {\n\tgcloud, err := google.DefaultClient(ctx, dns.NdevClouddnsReadwriteScope)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tgcloud = extdnshttp.NewInstrumentedClient(gcloud)\n\n\tdnsClient, err := dns.NewService(ctx, option.WithHTTPClient(gcloud))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif project == \"\" {\n\t\tmProject, mErr := metadata.ProjectIDWithContext(ctx)\n\t\tif mErr != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to auto-detect the project id: %w\", mErr)\n\t\t}\n\t\tlog.Infof(\"Google project auto-detected: %s\", mProject)\n\t\tproject = mProject\n\t}\n\n\tzoneTypeFilter := provider.NewZoneTypeFilter(zoneVisibility)\n\n\treturn &GoogleProvider{\n\t\tproject:                  project,\n\t\tdryRun:                   dryRun,\n\t\tbatchChangeSize:          batchChangeSize,\n\t\tbatchChangeInterval:      batchChangeInterval,\n\t\tdomainFilter:             domainFilter,\n\t\tzoneTypeFilter:           zoneTypeFilter,\n\t\tzoneIDFilter:             zoneIDFilter,\n\t\tresourceRecordSetsClient: resourceRecordSetsService{dnsClient.ResourceRecordSets},\n\t\tmanagedZonesClient:       managedZonesService{dnsClient.ManagedZones},\n\t\tchangesClient:            changesService{dnsClient.Changes},\n\t\tctx:                      ctx,\n\t}, nil\n}\n\n// Zones returns the list of hosted zones.\nfunc (p *GoogleProvider) Zones(ctx context.Context) (map[string]*dns.ManagedZone, error) {\n\tzones := make(map[string]*dns.ManagedZone)\n\n\tf := func(resp *dns.ManagedZonesListResponse) error {\n\t\tfor _, zone := range resp.ManagedZones {\n\t\t\tif zone.PeeringConfig == nil {\n\t\t\t\tif p.domainFilter.Match(zone.DnsName) && p.zoneTypeFilter.Match(zone.Visibility) && (p.zoneIDFilter.Match(fmt.Sprintf(\"%v\", zone.Id)) || p.zoneIDFilter.Match(fmt.Sprintf(\"%v\", zone.Name))) {\n\t\t\t\t\tzones[zone.Name] = zone\n\t\t\t\t\tlog.Debugf(\"Matched %s (zone: %s) (visibility: %s)\", zone.DnsName, zone.Name, zone.Visibility)\n\t\t\t\t} else {\n\t\t\t\t\tlog.Debugf(\"Filtered %s (zone: %s) (visibility: %s)\", zone.DnsName, zone.Name, zone.Visibility)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tlog.Debugf(\"Filtered peering zone %s (zone: %s) (visibility: %s)\", zone.DnsName, zone.Name, zone.Visibility)\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}\n\n\tlog.Debugf(\"Matching zones against domain filters: %v\", p.domainFilter)\n\tif err := p.managedZonesClient.List(p.project).Pages(ctx, f); err != nil {\n\t\treturn nil, provider.NewSoftErrorf(\"failed to list zones: %w\", err)\n\t}\n\n\tif len(zones) == 0 {\n\t\tlog.Warnf(\"No zones in the project, %s, match domain filters: %v\", p.project, p.domainFilter)\n\t}\n\n\tfor _, zone := range zones {\n\t\tlog.Debugf(\"Considering zone: %s (domain: %s)\", zone.Name, zone.DnsName)\n\t}\n\n\treturn zones, nil\n}\n\n// Records returns the list of records in all relevant zones.\nfunc (p *GoogleProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {\n\tzones, err := p.Zones(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tendpoints := make([]*endpoint.Endpoint, 0)\n\n\tf := func(resp *dns.ResourceRecordSetsListResponse) error {\n\t\tfor _, r := range resp.Rrsets {\n\t\t\tif !p.SupportedRecordType(r.Type) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tendpoints = append(endpoints, endpoint.NewEndpointWithTTL(r.Name, r.Type, endpoint.TTL(r.Ttl), r.Rrdatas...))\n\t\t}\n\n\t\treturn nil\n\t}\n\n\tfor _, z := range zones {\n\t\tif err := p.resourceRecordSetsClient.List(p.project, z.Name).Pages(ctx, f); err != nil {\n\t\t\treturn nil, provider.NewSoftErrorf(\"failed to list records in zone %s: %v\", z.Name, err)\n\t\t}\n\t}\n\n\treturn endpoints, nil\n}\n\n// ApplyChanges applies a given set of changes in a given zone.\nfunc (p *GoogleProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {\n\tchange := &dns.Change{}\n\n\tchange.Additions = append(change.Additions, p.newFilteredRecords(changes.Create)...)\n\n\tchange.Additions = append(change.Additions, p.newFilteredRecords(changes.UpdateNew)...)\n\tchange.Deletions = append(change.Deletions, p.newFilteredRecords(changes.UpdateOld)...)\n\n\tchange.Deletions = append(change.Deletions, p.newFilteredRecords(changes.Delete)...)\n\n\treturn p.submitChange(ctx, change)\n}\n\n// SupportedRecordType returns true if the record type is supported by the provider\nfunc (p *GoogleProvider) SupportedRecordType(recordType string) bool {\n\tswitch recordType {\n\tcase \"MX\":\n\t\treturn true\n\tdefault:\n\t\treturn provider.SupportedRecordType(recordType)\n\t}\n}\n\n// newFilteredRecords returns a collection of RecordSets based on the given endpoints and domainFilter.\nfunc (p *GoogleProvider) newFilteredRecords(endpoints []*endpoint.Endpoint) []*dns.ResourceRecordSet {\n\tvar records []*dns.ResourceRecordSet\n\n\tfor _, ep := range endpoints {\n\t\tif p.domainFilter.Match(ep.DNSName) {\n\t\t\trecords = append(records, newRecord(ep))\n\t\t}\n\t}\n\n\treturn records\n}\n\n// submitChange takes a zone and a Change and sends it to Google.\nfunc (p *GoogleProvider) submitChange(ctx context.Context, change *dns.Change) error {\n\tif len(change.Additions) == 0 && len(change.Deletions) == 0 {\n\t\tlog.Info(\"All records are already up to date\")\n\t\treturn nil\n\t}\n\n\tzones, err := p.Zones(ctx)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// separate into per-zone change sets to be passed to the API.\n\tchanges := separateChange(zones, change)\n\n\tfor zone, change := range changes {\n\t\tfor batch, c := range batchChange(change, p.batchChangeSize) {\n\t\t\tlog.Infof(\"Change zone: %v batch #%d\", zone, batch)\n\t\t\tfor _, del := range c.Deletions {\n\t\t\t\tlog.Infof(\"Del records: %s %s %s %d\", del.Name, del.Type, del.Rrdatas, del.Ttl)\n\t\t\t}\n\t\t\tfor _, add := range c.Additions {\n\t\t\t\tlog.Infof(\"Add records: %s %s %s %d\", add.Name, add.Type, add.Rrdatas, add.Ttl)\n\t\t\t}\n\n\t\t\tif p.dryRun {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif _, err := p.changesClient.Create(p.project, zone, c).Do(); err != nil {\n\t\t\t\treturn provider.NewSoftErrorf(\"failed to create changes: %w\", err)\n\t\t\t}\n\n\t\t\ttime.Sleep(p.batchChangeInterval)\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// batchChange separates a zone in multiple transaction.\nfunc batchChange(change *dns.Change, batchSize int) []*dns.Change {\n\tvar changes []*dns.Change\n\n\tif batchSize == 0 {\n\t\treturn append(changes, change)\n\t}\n\n\ttype dnsChange struct {\n\t\tadditions []*dns.ResourceRecordSet\n\t\tdeletions []*dns.ResourceRecordSet\n\t}\n\n\tchangesByName := map[string]*dnsChange{}\n\n\tfor _, a := range change.Additions {\n\t\tchange, ok := changesByName[a.Name]\n\t\tif !ok {\n\t\t\tchange = &dnsChange{}\n\t\t\tchangesByName[a.Name] = change\n\t\t}\n\n\t\tchange.additions = append(change.additions, a)\n\t}\n\n\tfor _, a := range change.Deletions {\n\t\tchange, ok := changesByName[a.Name]\n\t\tif !ok {\n\t\t\tchange = &dnsChange{}\n\t\t\tchangesByName[a.Name] = change\n\t\t}\n\n\t\tchange.deletions = append(change.deletions, a)\n\t}\n\n\tnames := make([]string, 0)\n\tfor v := range changesByName {\n\t\tnames = append(names, v)\n\t}\n\tsort.Strings(names)\n\n\tcurrentChange := &dns.Change{}\n\tvar totalChanges int\n\tfor _, name := range names {\n\t\tc := changesByName[name]\n\n\t\ttotalChangesByName := len(c.additions) + len(c.deletions)\n\n\t\tif totalChangesByName > batchSize {\n\t\t\tlog.Warnf(\"Total changes for %s exceeds max batch size of %d, total changes: %d\", name,\n\t\t\t\tbatchSize, totalChangesByName)\n\t\t\tcontinue\n\t\t}\n\n\t\tif totalChanges+totalChangesByName > batchSize {\n\t\t\ttotalChanges = 0\n\t\t\tchanges = append(changes, currentChange)\n\t\t\tcurrentChange = &dns.Change{}\n\t\t}\n\n\t\tcurrentChange.Additions = append(currentChange.Additions, c.additions...)\n\t\tcurrentChange.Deletions = append(currentChange.Deletions, c.deletions...)\n\n\t\ttotalChanges += totalChangesByName\n\t}\n\n\tif totalChanges > 0 {\n\t\tchanges = append(changes, currentChange)\n\t}\n\n\treturn changes\n}\n\n// separateChange separates a multi-zone change into a single change per zone.\nfunc separateChange(zones map[string]*dns.ManagedZone, change *dns.Change) map[string]*dns.Change {\n\tchanges := make(map[string]*dns.Change)\n\tzoneNameIDMapper := provider.ZoneIDName{}\n\tfor _, z := range zones {\n\t\tzoneNameIDMapper[z.Name] = z.DnsName\n\t\tchanges[z.Name] = &dns.Change{\n\t\t\tAdditions: []*dns.ResourceRecordSet{},\n\t\t\tDeletions: []*dns.ResourceRecordSet{},\n\t\t}\n\t}\n\tfor _, a := range change.Additions {\n\t\tif zoneName, _ := zoneNameIDMapper.FindZone(provider.EnsureTrailingDot(a.Name)); zoneName != \"\" {\n\t\t\tchanges[zoneName].Additions = append(changes[zoneName].Additions, a)\n\t\t} else {\n\t\t\tlog.Warnf(\"No matching zone for record addition: %s %s %s %d\", a.Name, a.Type, a.Rrdatas, a.Ttl)\n\t\t}\n\t}\n\n\tfor _, d := range change.Deletions {\n\t\tif zoneName, _ := zoneNameIDMapper.FindZone(provider.EnsureTrailingDot(d.Name)); zoneName != \"\" {\n\t\t\tchanges[zoneName].Deletions = append(changes[zoneName].Deletions, d)\n\t\t} else {\n\t\t\tlog.Warnf(\"No matching zone for record deletion: %s %s %s %d\", d.Name, d.Type, d.Rrdatas, d.Ttl)\n\t\t}\n\t}\n\n\t// separating a change could lead to empty sub changes, remove them here.\n\tfor zone, change := range changes {\n\t\tif len(change.Additions) == 0 && len(change.Deletions) == 0 {\n\t\t\tdelete(changes, zone)\n\t\t}\n\t}\n\n\treturn changes\n}\n\n// newRecord returns a RecordSet based on the given endpoint.\nfunc newRecord(ep *endpoint.Endpoint) *dns.ResourceRecordSet {\n\t// TODO(linki): works around appending a trailing dot to TXT records. I think\n\t// we should go back to storing DNS names with a trailing dot internally. This\n\t// way we can use it has is here and trim it off if it exists when necessary.\n\ttargets := make([]string, len(ep.Targets))\n\tcopy(targets, []string(ep.Targets))\n\tif ep.RecordType == endpoint.RecordTypeCNAME {\n\t\ttargets[0] = provider.EnsureTrailingDot(targets[0])\n\t}\n\n\tif ep.RecordType == endpoint.RecordTypeMX {\n\t\tfor i, mxRecord := range ep.Targets {\n\t\t\ttargets[i] = provider.EnsureTrailingDot(mxRecord)\n\t\t}\n\t}\n\n\tif ep.RecordType == endpoint.RecordTypeSRV {\n\t\tfor i, srvRecord := range ep.Targets {\n\t\t\ttargets[i] = provider.EnsureTrailingDot(srvRecord)\n\t\t}\n\t}\n\n\tif ep.RecordType == endpoint.RecordTypeNS {\n\t\tfor i, nsRecord := range ep.Targets {\n\t\t\ttargets[i] = provider.EnsureTrailingDot(nsRecord)\n\t\t}\n\t}\n\n\t// no annotation results in a Ttl of 0, default to 300 for backwards-compatibility\n\tvar ttl int64 = defaultTTL\n\tif ep.RecordTTL.IsConfigured() {\n\t\tttl = int64(ep.RecordTTL)\n\t}\n\n\treturn &dns.ResourceRecordSet{\n\t\tName:    provider.EnsureTrailingDot(ep.DNSName),\n\t\tRrdatas: targets,\n\t\tTtl:     ttl,\n\t\tType:    ep.RecordType,\n\t}\n}\n"
  },
  {
    "path": "provider/google/google_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage google\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"slices\"\n\t\"sort\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\t\"golang.org/x/net/context\"\n\tdns \"google.golang.org/api/dns/v1\"\n\t\"google.golang.org/api/googleapi\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/internal/testutils\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\nvar (\n\ttestZones                    = map[string]*dns.ManagedZone{}\n\ttestRecords                  = map[string]map[string]*dns.ResourceRecordSet{}\n\tgoogleDefaultBatchChangeSize = 4000\n)\n\ntype mockManagedZonesCreateCall struct {\n\tproject     string\n\tmanagedZone *dns.ManagedZone\n}\n\nfunc (m *mockManagedZonesCreateCall) Do(_ ...googleapi.CallOption) (*dns.ManagedZone, error) {\n\tzoneKey := zoneKey(m.project, m.managedZone.Name)\n\n\tif _, ok := testZones[zoneKey]; ok {\n\t\treturn nil, &googleapi.Error{Code: http.StatusConflict}\n\t}\n\n\ttestZones[zoneKey] = m.managedZone\n\n\treturn m.managedZone, nil\n}\n\ntype mockManagedZonesListCall struct {\n\tproject          string\n\tzonesListSoftErr error\n}\n\nfunc (m *mockManagedZonesListCall) Pages(_ context.Context, f func(*dns.ManagedZonesListResponse) error) error {\n\tzones := []*dns.ManagedZone{}\n\n\tfor k, v := range testZones {\n\t\tif strings.HasPrefix(k, m.project+\"/\") {\n\t\t\tzones = append(zones, v)\n\t\t}\n\t}\n\n\tif m.zonesListSoftErr != nil {\n\t\treturn m.zonesListSoftErr\n\t}\n\n\treturn f(&dns.ManagedZonesListResponse{ManagedZones: zones})\n}\n\ntype mockManagedZonesClient struct {\n\tzonesErr error\n}\n\nfunc (m *mockManagedZonesClient) Create(project string, managedZone *dns.ManagedZone) managedZonesCreateCallInterface {\n\treturn &mockManagedZonesCreateCall{project: project, managedZone: managedZone}\n}\n\nfunc (m *mockManagedZonesClient) List(project string) managedZonesListCallInterface {\n\treturn &mockManagedZonesListCall{project: project, zonesListSoftErr: m.zonesErr}\n}\n\ntype mockResourceRecordSetsListCall struct {\n\tproject            string\n\tmanagedZone        string\n\trecordsListSoftErr error\n}\n\nfunc (m *mockResourceRecordSetsListCall) Pages(_ context.Context, f func(*dns.ResourceRecordSetsListResponse) error) error {\n\tzoneKey := zoneKey(m.project, m.managedZone)\n\n\tif _, ok := testZones[zoneKey]; !ok {\n\t\treturn &googleapi.Error{Code: http.StatusNotFound}\n\t}\n\n\tresp := []*dns.ResourceRecordSet{}\n\n\tfor _, v := range testRecords[zoneKey] {\n\t\tresp = append(resp, v)\n\t}\n\n\tif m.recordsListSoftErr != nil {\n\t\treturn m.recordsListSoftErr\n\t}\n\n\treturn f(&dns.ResourceRecordSetsListResponse{Rrsets: resp})\n}\n\ntype mockResourceRecordSetsClient struct {\n\trecordsErr error\n}\n\nfunc (m *mockResourceRecordSetsClient) List(project string, managedZone string) resourceRecordSetsListCallInterface {\n\treturn &mockResourceRecordSetsListCall{project: project, managedZone: managedZone, recordsListSoftErr: m.recordsErr}\n}\n\ntype mockChangesCreateCall struct {\n\tproject     string\n\tmanagedZone string\n\tchange      *dns.Change\n}\n\nfunc (m *mockChangesCreateCall) Do(_ ...googleapi.CallOption) (*dns.Change, error) {\n\tzoneKey := zoneKey(m.project, m.managedZone)\n\n\tif _, ok := testZones[zoneKey]; !ok {\n\t\treturn nil, &googleapi.Error{Code: http.StatusNotFound}\n\t}\n\n\tif _, ok := testRecords[zoneKey]; !ok {\n\t\ttestRecords[zoneKey] = make(map[string]*dns.ResourceRecordSet)\n\t}\n\n\tfor _, c := range append(m.change.Additions, m.change.Deletions...) {\n\t\tif !isValidRecordSet(c) {\n\t\t\treturn nil, &googleapi.Error{\n\t\t\t\tCode:    http.StatusBadRequest,\n\t\t\t\tMessage: fmt.Sprintf(\"invalid record: %v\", c),\n\t\t\t}\n\t\t}\n\t}\n\n\tfor _, del := range m.change.Deletions {\n\t\trecordKey := recordKey(del.Type, del.Name)\n\t\tdelete(testRecords[zoneKey], recordKey)\n\t}\n\n\tfor _, add := range m.change.Additions {\n\t\trecordKey := recordKey(add.Type, add.Name)\n\t\ttestRecords[zoneKey][recordKey] = add\n\t}\n\n\treturn m.change, nil\n}\n\ntype mockChangesClient struct{}\n\nfunc (m *mockChangesClient) Create(project string, managedZone string, change *dns.Change) changesCreateCallInterface {\n\treturn &mockChangesCreateCall{project: project, managedZone: managedZone, change: change}\n}\n\nfunc zoneKey(project, zoneName string) string {\n\treturn project + \"/\" + zoneName\n}\n\nfunc recordKey(recordType, recordName string) string {\n\treturn recordType + \"/\" + recordName\n}\n\nfunc isValidRecordSet(recordSet *dns.ResourceRecordSet) bool {\n\tif !hasTrailingDot(recordSet.Name) {\n\t\treturn false\n\t}\n\n\tswitch recordSet.Type {\n\tcase endpoint.RecordTypeCNAME:\n\t\tfor _, rrd := range recordSet.Rrdatas {\n\t\t\tif !hasTrailingDot(rrd) {\n\t\t\t\treturn false\n\t\t\t}\n\t\t}\n\tcase endpoint.RecordTypeA, endpoint.RecordTypeTXT:\n\t\tif slices.ContainsFunc(recordSet.Rrdatas, hasTrailingDot) {\n\t\t\treturn false\n\t\t}\n\tdefault:\n\t\tpanic(\"unhandled record type\")\n\t}\n\n\treturn true\n}\n\nfunc hasTrailingDot(target string) bool {\n\treturn strings.HasSuffix(target, \".\")\n}\n\nfunc TestGoogleZonesIDFilter(t *testing.T) {\n\tprovider := newGoogleProviderZoneOverlap(t, endpoint.NewDomainFilter([]string{\"cluster.local.\"}), provider.NewZoneIDFilter([]string{\"10002\"}), provider.NewZoneTypeFilter(\"\"), []*endpoint.Endpoint{})\n\n\tzones, err := provider.Zones(t.Context())\n\trequire.NoError(t, err)\n\n\tvalidateZones(t, zones, map[string]*dns.ManagedZone{\n\t\t\"internal-2\": {Name: \"internal-2\", DnsName: \"cluster.local.\", Id: 10002, Visibility: \"private\"},\n\t})\n}\n\nfunc TestGoogleZonesNameFilter(t *testing.T) {\n\tprovider := newGoogleProviderZoneOverlap(t, endpoint.NewDomainFilter([]string{\"cluster.local.\"}), provider.NewZoneIDFilter([]string{\"internal-2\"}), provider.NewZoneTypeFilter(\"\"), []*endpoint.Endpoint{})\n\n\tzones, err := provider.Zones(t.Context())\n\trequire.NoError(t, err)\n\n\tvalidateZones(t, zones, map[string]*dns.ManagedZone{\n\t\t\"internal-2\": {Name: \"internal-2\", DnsName: \"cluster.local.\", Id: 10002, Visibility: \"private\"},\n\t})\n}\n\nfunc TestGoogleZonesVisibilityFilterPublic(t *testing.T) {\n\tprovider := newGoogleProviderZoneOverlap(t, endpoint.NewDomainFilter([]string{\"cluster.local.\"}), provider.NewZoneIDFilter([]string{\"split-horizon-1\"}), provider.NewZoneTypeFilter(\"public\"), []*endpoint.Endpoint{})\n\n\tzones, err := provider.Zones(t.Context())\n\trequire.NoError(t, err)\n\n\tvalidateZones(t, zones, map[string]*dns.ManagedZone{\n\t\t\"split-horizon-1\": {Name: \"split-horizon-1\", DnsName: \"cluster.local.\", Id: 10001, Visibility: \"public\"},\n\t})\n}\n\nfunc TestGoogleZonesVisibilityFilterPrivate(t *testing.T) {\n\tprovider := newGoogleProviderZoneOverlap(t, endpoint.NewDomainFilter([]string{\"cluster.local.\"}), provider.NewZoneIDFilter([]string{\"split-horizon-1\"}), provider.NewZoneTypeFilter(\"public\"), []*endpoint.Endpoint{})\n\n\tzones, err := provider.Zones(t.Context())\n\trequire.NoError(t, err)\n\n\tvalidateZones(t, zones, map[string]*dns.ManagedZone{\n\t\t\"split-horizon-1\": {Name: \"split-horizon-1\", DnsName: \"cluster.local.\", Id: 10001, Visibility: \"public\"},\n\t})\n}\n\nfunc TestGoogleZonesVisibilityFilterPrivatePeering(t *testing.T) {\n\tprovider := newGoogleProviderZoneOverlap(t, endpoint.NewDomainFilter([]string{\"svc.local.\"}), provider.NewZoneIDFilter([]string{\"\"}), provider.NewZoneTypeFilter(\"private\"), []*endpoint.Endpoint{})\n\n\tzones, err := provider.Zones(t.Context())\n\trequire.NoError(t, err)\n\n\tvalidateZones(t, zones, map[string]*dns.ManagedZone{\n\t\t\"svc-local\": {Name: \"svc-local\", DnsName: \"svc.local.\", Id: 1005, Visibility: \"private\"},\n\t})\n}\n\nfunc TestGoogleRecords(t *testing.T) {\n\toriginalEndpoints := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpointWithTTL(\"list-test.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, endpoint.TTL(1), \"1.2.3.4\"),\n\t\tendpoint.NewEndpointWithTTL(\"list-test.zone-2.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, endpoint.TTL(2), \"8.8.8.8\"),\n\t\tendpoint.NewEndpointWithTTL(\"list-test-alias.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeCNAME, endpoint.TTL(3), \"foo.elb.amazonaws.com\"),\n\t}\n\n\tprovider := newGoogleProvider(t, endpoint.NewDomainFilter([]string{\"ext-dns-test-2.gcp.zalan.do.\"}), provider.NewZoneIDFilter([]string{\"\"}), false, originalEndpoints, nil, nil)\n\n\trecords, err := provider.Records(t.Context())\n\trequire.NoError(t, err)\n\n\tvalidateEndpoints(t, records, originalEndpoints)\n}\n\nfunc TestGoogleRecordsFilter(t *testing.T) {\n\toriginalEndpoints := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpointWithTTL(\"update-test.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, defaultTTL, \"8.8.8.8\"),\n\t\tendpoint.NewEndpointWithTTL(\"delete-test.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, defaultTTL, \"8.8.8.8\"),\n\t\tendpoint.NewEndpointWithTTL(\"update-test.zone-2.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, defaultTTL, \"8.8.4.4\"),\n\t\tendpoint.NewEndpointWithTTL(\"delete-test.zone-2.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, defaultTTL, \"8.8.4.4\"),\n\t\tendpoint.NewEndpointWithTTL(\"update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeCNAME, defaultTTL, \"bar.elb.amazonaws.com\"),\n\t\tendpoint.NewEndpointWithTTL(\"delete-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeCNAME, defaultTTL, \"qux.elb.amazonaws.com\"),\n\t}\n\n\tprovider := newGoogleProvider(\n\t\tt,\n\t\tendpoint.NewDomainFilter([]string{\n\t\t\t// our two valid zones\n\t\t\t\"zone-1.ext-dns-test-2.gcp.zalan.do.\",\n\t\t\t\"zone-2.ext-dns-test-2.gcp.zalan.do.\",\n\t\t\t// we filter for a zone that doesn't exist, should have no effect.\n\t\t\t\"zone-0.ext-dns-test-2.gcp.zalan.do.\",\n\t\t\t// there exists a third zone \"zone-3\" that we want to exclude from being managed.\n\t\t}),\n\t\tprovider.NewZoneIDFilter([]string{\"\"}),\n\t\tfalse,\n\t\toriginalEndpoints,\n\t\tnil,\n\t\tnil,\n\t)\n\n\t// these records should be filtered out since they don't match a hosted zone or domain filter.\n\tignoredEndpoints := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"filter-create-test.zone-0.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, \"4.2.2.2\"),\n\t\tendpoint.NewEndpoint(\"filter-update-test.zone-0.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, \"4.2.2.2\"),\n\t\tendpoint.NewEndpoint(\"filter-delete-test.zone-0.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, \"4.2.2.2\"),\n\t\tendpoint.NewEndpoint(\"filter-create-test.zone-3.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, \"4.2.2.2\"),\n\t\tendpoint.NewEndpoint(\"filter-update-test.zone-3.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, \"4.2.2.2\"),\n\t\tendpoint.NewEndpoint(\"filter-delete-test.zone-3.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, \"4.2.2.2\"),\n\t}\n\n\trequire.NoError(t, provider.ApplyChanges(t.Context(), &plan.Changes{\n\t\tCreate: ignoredEndpoints,\n\t}))\n\n\trecords, err := provider.Records(t.Context())\n\trequire.NoError(t, err)\n\n\t// assert that due to filtering no changes were made.\n\tvalidateEndpoints(t, records, originalEndpoints)\n}\n\nfunc TestGoogleApplyChanges(t *testing.T) {\n\tprovider := newGoogleProvider(\n\t\tt,\n\t\tendpoint.NewDomainFilter([]string{\n\t\t\t// our two valid zones\n\t\t\t\"zone-1.ext-dns-test-2.gcp.zalan.do.\",\n\t\t\t\"zone-2.ext-dns-test-2.gcp.zalan.do.\",\n\t\t\t// we filter for a zone that doesn't exist, should have no effect.\n\t\t\t\"zone-0.ext-dns-test-2.gcp.zalan.do.\",\n\t\t\t// there exists a third zone \"zone-3\" that we want to exclude from being managed.\n\t\t}),\n\t\tprovider.NewZoneIDFilter([]string{\"\"}),\n\t\tfalse,\n\t\t[]*endpoint.Endpoint{\n\t\t\tendpoint.NewEndpointWithTTL(\"update-test.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, defaultTTL, \"8.8.8.8\"),\n\t\t\tendpoint.NewEndpointWithTTL(\"delete-test.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, defaultTTL, \"8.8.8.8\"),\n\t\t\tendpoint.NewEndpointWithTTL(\"update-test-ttl.zone-2.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, endpoint.TTL(10), \"8.8.4.4\"),\n\t\t\tendpoint.NewEndpointWithTTL(\"delete-test.zone-2.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, defaultTTL, \"8.8.4.4\"),\n\t\t\tendpoint.NewEndpointWithTTL(\"update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeCNAME, defaultTTL, \"bar.elb.amazonaws.com\"),\n\t\t\tendpoint.NewEndpointWithTTL(\"delete-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeCNAME, defaultTTL, \"qux.elb.amazonaws.com\"),\n\t\t},\n\t\tnil,\n\t\tnil,\n\t)\n\n\tcreateRecords := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"create-test.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, \"8.8.8.8\"),\n\t\tendpoint.NewEndpointWithTTL(\"create-test-ttl.zone-2.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, endpoint.TTL(15), \"8.8.4.4\"),\n\t\tendpoint.NewEndpoint(\"create-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeCNAME, \"foo.elb.amazonaws.com\"),\n\t\tendpoint.NewEndpoint(\"filter-create-test.zone-3.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, \"4.2.2.2\"),\n\t\tendpoint.NewEndpoint(\"nomatch-create-test.zone-0.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, \"4.2.2.1\"),\n\t}\n\n\tcurrentRecords := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"update-test.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, \"8.8.8.8\"),\n\t\tendpoint.NewEndpoint(\"update-test.zone-2.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, \"8.8.4.4\"),\n\t\tendpoint.NewEndpoint(\"update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeCNAME, \"bar.elb.amazonaws.com\"),\n\t\tendpoint.NewEndpoint(\"filter-update-test.zone-3.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, \"4.2.2.2\"),\n\t}\n\tupdatedRecords := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"update-test.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, \"1.2.3.4\"),\n\t\tendpoint.NewEndpointWithTTL(\"update-test-ttl.zone-2.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, endpoint.TTL(25), \"4.3.2.1\"),\n\t\tendpoint.NewEndpoint(\"update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeCNAME, \"baz.elb.amazonaws.com\"),\n\t\tendpoint.NewEndpoint(\"filter-update-test.zone-3.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, \"5.6.7.8\"),\n\t\tendpoint.NewEndpoint(\"nomatch-update-test.zone-0.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, \"8.7.6.5\"),\n\t}\n\n\tdeleteRecords := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"delete-test.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, \"8.8.8.8\"),\n\t\tendpoint.NewEndpoint(\"delete-test.zone-2.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, \"8.8.4.4\"),\n\t\tendpoint.NewEndpoint(\"delete-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeCNAME, \"qux.elb.amazonaws.com\"),\n\t\tendpoint.NewEndpoint(\"filter-delete-test.zone-3.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, \"4.2.2.2\"),\n\t\tendpoint.NewEndpoint(\"nomatch-delete-test.zone-0.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, \"4.2.2.1\"),\n\t}\n\n\tchanges := &plan.Changes{\n\t\tCreate:    createRecords,\n\t\tUpdateNew: updatedRecords,\n\t\tUpdateOld: currentRecords,\n\t\tDelete:    deleteRecords,\n\t}\n\n\trequire.NoError(t, provider.ApplyChanges(t.Context(), changes))\n\n\trecords, err := provider.Records(t.Context())\n\trequire.NoError(t, err)\n\n\tvalidateEndpoints(t, records, []*endpoint.Endpoint{\n\t\tendpoint.NewEndpointWithTTL(\"create-test.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, defaultTTL, \"8.8.8.8\"),\n\t\tendpoint.NewEndpointWithTTL(\"update-test.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, defaultTTL, \"1.2.3.4\"),\n\t\tendpoint.NewEndpointWithTTL(\"create-test-ttl.zone-2.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, endpoint.TTL(15), \"8.8.4.4\"),\n\t\tendpoint.NewEndpointWithTTL(\"update-test-ttl.zone-2.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, endpoint.TTL(25), \"4.3.2.1\"),\n\t\tendpoint.NewEndpointWithTTL(\"create-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeCNAME, defaultTTL, \"foo.elb.amazonaws.com\"),\n\t\tendpoint.NewEndpointWithTTL(\"update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeCNAME, defaultTTL, \"baz.elb.amazonaws.com\"),\n\t})\n}\n\nfunc TestGoogleApplyChangesDryRun(t *testing.T) {\n\toriginalEndpoints := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpointWithTTL(\"update-test.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, defaultTTL, \"8.8.8.8\"),\n\t\tendpoint.NewEndpointWithTTL(\"delete-test.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, defaultTTL, \"8.8.8.8\"),\n\t\tendpoint.NewEndpointWithTTL(\"update-test.zone-2.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, defaultTTL, \"8.8.4.4\"),\n\t\tendpoint.NewEndpointWithTTL(\"delete-test.zone-2.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, defaultTTL, \"8.8.4.4\"),\n\t\tendpoint.NewEndpointWithTTL(\"update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeCNAME, defaultTTL, \"bar.elb.amazonaws.com\"),\n\t\tendpoint.NewEndpointWithTTL(\"delete-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeCNAME, defaultTTL, \"qux.elb.amazonaws.com\"),\n\t}\n\n\tprovider := newGoogleProvider(t, endpoint.NewDomainFilter([]string{\"ext-dns-test-2.gcp.zalan.do.\"}), provider.NewZoneIDFilter([]string{\"\"}), true, originalEndpoints, nil, nil)\n\n\tcreateRecords := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"create-test.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, \"8.8.8.8\"),\n\t\tendpoint.NewEndpoint(\"create-test.zone-2.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, \"8.8.4.4\"),\n\t\tendpoint.NewEndpoint(\"create-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeCNAME, \"foo.elb.amazonaws.com\"),\n\t}\n\n\tcurrentRecords := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"update-test.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, \"8.8.8.8\"),\n\t\tendpoint.NewEndpoint(\"update-test.zone-2.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, \"8.8.4.4\"),\n\t\tendpoint.NewEndpoint(\"update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeCNAME, \"bar.elb.amazonaws.com\"),\n\t}\n\tupdatedRecords := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"update-test.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, \"1.2.3.4\"),\n\t\tendpoint.NewEndpoint(\"update-test.zone-2.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, \"4.3.2.1\"),\n\t\tendpoint.NewEndpoint(\"update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeCNAME, \"baz.elb.amazonaws.com\"),\n\t}\n\n\tdeleteRecords := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"delete-test.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, \"8.8.8.8\"),\n\t\tendpoint.NewEndpoint(\"delete-test.zone-2.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, \"8.8.4.4\"),\n\t\tendpoint.NewEndpoint(\"delete-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeCNAME, \"qux.elb.amazonaws.com\"),\n\t}\n\n\tchanges := &plan.Changes{\n\t\tCreate:    createRecords,\n\t\tUpdateNew: updatedRecords,\n\t\tUpdateOld: currentRecords,\n\t\tDelete:    deleteRecords,\n\t}\n\n\tctx := t.Context()\n\trequire.NoError(t, provider.ApplyChanges(ctx, changes))\n\n\trecords, err := provider.Records(ctx)\n\trequire.NoError(t, err)\n\n\tvalidateEndpoints(t, records, originalEndpoints)\n}\n\nfunc TestGoogleApplyChangesEmpty(t *testing.T) {\n\tprovider := newGoogleProvider(t, endpoint.NewDomainFilter([]string{\"ext-dns-test-2.gcp.zalan.do.\"}), provider.NewZoneIDFilter([]string{\"\"}), false, []*endpoint.Endpoint{}, nil, nil)\n\tassert.NoError(t, provider.ApplyChanges(t.Context(), &plan.Changes{}))\n}\n\nfunc TestNewFilteredRecords(t *testing.T) {\n\tprovider := newGoogleProvider(t, endpoint.NewDomainFilter([]string{\"ext-dns-test-2.gcp.zalan.do.\"}), provider.NewZoneIDFilter([]string{\"\"}), false, []*endpoint.Endpoint{}, nil, nil)\n\n\trecords := provider.newFilteredRecords([]*endpoint.Endpoint{\n\t\tendpoint.NewEndpointWithTTL(\"update-test.zone-2.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, 1, \"8.8.4.4\"),\n\t\tendpoint.NewEndpointWithTTL(\"delete-test.zone-2.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, 120, \"8.8.4.4\"),\n\t\tendpoint.NewEndpointWithTTL(\"update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeCNAME, 4000, \"bar.elb.amazonaws.com\"),\n\t\tendpoint.NewEndpointWithTTL(\"update-test-ns.zone-1.ext-dns-test-2.gcp.zalan.do.\", endpoint.RecordTypeNS, 120, \"foo.elb.amazonaws.com\"),\n\t\t// test fallback to Ttl:300 when Ttl==0 :\n\t\tendpoint.NewEndpointWithTTL(\"update-test.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, 0, \"8.8.8.8\"),\n\t\tendpoint.NewEndpointWithTTL(\"update-test-mx.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeMX, 6000, \"10 mail.elb.amazonaws.com\"),\n\t\tendpoint.NewEndpoint(\"delete-test.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeA, \"8.8.8.8\"),\n\t\tendpoint.NewEndpoint(\"delete-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeCNAME, \"qux.elb.amazonaws.com\"),\n\t\tendpoint.NewEndpoint(\"delete-test-ns.zone-1.ext-dns-test-2.gcp.zalan.do\", endpoint.RecordTypeNS, \"foo.elb.amazonaws.com\"),\n\t})\n\n\tvalidateChangeRecords(t, records, []*dns.ResourceRecordSet{\n\t\t{Name: \"update-test.zone-2.ext-dns-test-2.gcp.zalan.do.\", Rrdatas: []string{\"8.8.4.4\"}, Type: \"A\", Ttl: 1},\n\t\t{Name: \"delete-test.zone-2.ext-dns-test-2.gcp.zalan.do.\", Rrdatas: []string{\"8.8.4.4\"}, Type: \"A\", Ttl: 120},\n\t\t{Name: \"update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do.\", Rrdatas: []string{\"bar.elb.amazonaws.com.\"}, Type: \"CNAME\", Ttl: 4000},\n\t\t{Name: \"update-test-ns.zone-1.ext-dns-test-2.gcp.zalan.do.\", Rrdatas: []string{\"foo.elb.amazonaws.com.\"}, Type: \"NS\", Ttl: 120},\n\t\t{Name: \"update-test.zone-1.ext-dns-test-2.gcp.zalan.do.\", Rrdatas: []string{\"8.8.8.8\"}, Type: \"A\", Ttl: 300},\n\t\t{Name: \"update-test-mx.zone-1.ext-dns-test-2.gcp.zalan.do.\", Rrdatas: []string{\"10 mail.elb.amazonaws.com.\"}, Type: \"MX\", Ttl: 6000},\n\t\t{Name: \"delete-test.zone-1.ext-dns-test-2.gcp.zalan.do.\", Rrdatas: []string{\"8.8.8.8\"}, Type: \"A\", Ttl: 300},\n\t\t{Name: \"delete-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do.\", Rrdatas: []string{\"qux.elb.amazonaws.com.\"}, Type: \"CNAME\", Ttl: 300},\n\t\t{Name: \"delete-test-ns.zone-1.ext-dns-test-2.gcp.zalan.do.\", Rrdatas: []string{\"foo.elb.amazonaws.com.\"}, Type: \"NS\", Ttl: 300},\n\t})\n}\n\nfunc TestSeparateChanges(t *testing.T) {\n\tchange := &dns.Change{\n\t\tAdditions: []*dns.ResourceRecordSet{\n\t\t\t{Name: \"qux.foo.example.org.\", Ttl: 1},\n\t\t\t{Name: \"qux.bar.example.org.\", Ttl: 2},\n\t\t},\n\t\tDeletions: []*dns.ResourceRecordSet{\n\t\t\t{Name: \"wambo.foo.example.org.\", Ttl: 10},\n\t\t\t{Name: \"wambo.bar.example.org.\", Ttl: 20},\n\t\t},\n\t}\n\n\tzones := map[string]*dns.ManagedZone{\n\t\t\"foo-example-org\": {\n\t\t\tName:    \"foo-example-org\",\n\t\t\tDnsName: \"foo.example.org.\",\n\t\t},\n\t\t\"bar-example-org\": {\n\t\t\tName:    \"bar-example-org\",\n\t\t\tDnsName: \"bar.example.org.\",\n\t\t},\n\t\t\"baz-example-org\": {\n\t\t\tName:    \"baz-example-org\",\n\t\t\tDnsName: \"baz.example.org.\",\n\t\t},\n\t}\n\n\tchanges := separateChange(zones, change)\n\trequire.Len(t, changes, 2)\n\n\tvalidateChange(t, changes[\"foo-example-org\"], &dns.Change{\n\t\tAdditions: []*dns.ResourceRecordSet{\n\t\t\t{Name: \"qux.foo.example.org.\", Ttl: 1},\n\t\t},\n\t\tDeletions: []*dns.ResourceRecordSet{\n\t\t\t{Name: \"wambo.foo.example.org.\", Ttl: 10},\n\t\t},\n\t})\n\n\tvalidateChange(t, changes[\"bar-example-org\"], &dns.Change{\n\t\tAdditions: []*dns.ResourceRecordSet{\n\t\t\t{Name: \"qux.bar.example.org.\", Ttl: 2},\n\t\t},\n\t\tDeletions: []*dns.ResourceRecordSet{\n\t\t\t{Name: \"wambo.bar.example.org.\", Ttl: 20},\n\t\t},\n\t})\n}\n\nfunc TestGoogleBatchChangeSet(t *testing.T) {\n\tcs := &dns.Change{}\n\n\tfor i := 1; i <= googleDefaultBatchChangeSize; i += 2 {\n\t\tcs.Additions = append(cs.Additions, &dns.ResourceRecordSet{\n\t\t\tName: fmt.Sprintf(\"host-%d.example.org.\", i),\n\t\t\tTtl:  2,\n\t\t})\n\t\tcs.Deletions = append(cs.Deletions, &dns.ResourceRecordSet{\n\t\t\tName: fmt.Sprintf(\"host-%d.example.org.\", i),\n\t\t\tTtl:  20,\n\t\t})\n\t}\n\n\tbatchCs := batchChange(cs, googleDefaultBatchChangeSize)\n\n\trequire.Len(t, batchCs, 1)\n\n\tsortChangesByName(cs)\n\tvalidateChange(t, batchCs[0], cs)\n}\n\nfunc TestGoogleBatchChangeSetExceeding(t *testing.T) {\n\tcs := &dns.Change{}\n\tconst testCount = 50\n\tconst testLimit = 11\n\tconst expectedBatchCount = 5\n\n\tfor i := 1; i <= testCount; i += 2 {\n\t\tcs.Additions = append(cs.Additions, &dns.ResourceRecordSet{\n\t\t\tName: fmt.Sprintf(\"host-%d.example.org.\", i),\n\t\t\tTtl:  2,\n\t\t})\n\t\tcs.Deletions = append(cs.Deletions, &dns.ResourceRecordSet{\n\t\t\tName: fmt.Sprintf(\"host-%d.example.org.\", i),\n\t\t\tTtl:  20,\n\t\t})\n\t}\n\n\tbatchCs := batchChange(cs, testLimit)\n\n\trequire.Len(t, batchCs, expectedBatchCount)\n\n\tdnsChange := &dns.Change{}\n\tfor _, c := range batchCs {\n\t\tdnsChange.Additions = append(dnsChange.Additions, c.Additions...)\n\t\tdnsChange.Deletions = append(dnsChange.Deletions, c.Deletions...)\n\t}\n\n\trequire.Len(t, dnsChange.Additions, len(cs.Additions))\n\trequire.Len(t, dnsChange.Deletions, len(cs.Deletions))\n\n\tsortChangesByName(cs)\n\tsortChangesByName(dnsChange)\n\n\tvalidateChange(t, dnsChange, cs)\n}\n\nfunc TestGoogleBatchChangeSetExceedingNameChange(t *testing.T) {\n\tcs := &dns.Change{}\n\tconst testLimit = 1\n\n\tcs.Additions = append(cs.Additions, &dns.ResourceRecordSet{\n\t\tName: \"host-1.example.org.\",\n\t\tTtl:  2,\n\t})\n\tcs.Deletions = append(cs.Deletions, &dns.ResourceRecordSet{\n\t\tName: \"host-1.example.org.\",\n\t\tTtl:  20,\n\t})\n\n\tbatchCs := batchChange(cs, testLimit)\n\n\trequire.Empty(t, batchCs)\n}\n\nfunc TestSoftErrListZonesConflict(t *testing.T) {\n\tp := newGoogleProvider(t, endpoint.NewDomainFilter([]string{\"ext-dns-test-2.gcp.zalan.do.\"}), provider.NewZoneIDFilter([]string{}), false, []*endpoint.Endpoint{}, provider.NewSoftErrorf(\"failed to list zones\"), nil)\n\n\tzones, err := p.Zones(t.Context())\n\trequire.Error(t, err)\n\trequire.ErrorIs(t, err, provider.SoftError)\n\n\trequire.Empty(t, zones)\n}\n\nfunc TestSoftErrListRecordsConflict(t *testing.T) {\n\tp := newGoogleProvider(t, endpoint.NewDomainFilter([]string{\"ext-dns-test-2.gcp.zalan.do.\"}), provider.NewZoneIDFilter([]string{}), false, []*endpoint.Endpoint{}, nil, provider.NewSoftErrorf(\"failed to list records in zone\"))\n\n\trecords, err := p.Records(t.Context())\n\trequire.Error(t, err)\n\trequire.ErrorIs(t, err, provider.SoftError)\n\n\trequire.Empty(t, records)\n}\n\nfunc sortChangesByName(cs *dns.Change) {\n\tsort.SliceStable(cs.Additions, func(i, j int) bool {\n\t\treturn cs.Additions[i].Name < cs.Additions[j].Name\n\t})\n\n\tsort.SliceStable(cs.Deletions, func(i, j int) bool {\n\t\treturn cs.Deletions[i].Name < cs.Deletions[j].Name\n\t})\n}\n\nfunc validateZones(t *testing.T, zones map[string]*dns.ManagedZone, expected map[string]*dns.ManagedZone) {\n\trequire.Len(t, zones, len(expected))\n\n\tfor i, zone := range zones {\n\t\tvalidateZone(t, zone, expected[i])\n\t}\n}\n\nfunc validateZone(t *testing.T, zone *dns.ManagedZone, expected *dns.ManagedZone) {\n\tassert.Equal(t, expected.Name, zone.Name)\n\tassert.Equal(t, expected.DnsName, zone.DnsName)\n\tassert.Equal(t, expected.Visibility, zone.Visibility)\n}\n\nfunc validateChange(t *testing.T, change *dns.Change, expected *dns.Change) {\n\tvalidateChangeRecords(t, change.Additions, expected.Additions)\n\tvalidateChangeRecords(t, change.Deletions, expected.Deletions)\n}\n\nfunc validateChangeRecords(t *testing.T, records []*dns.ResourceRecordSet, expected []*dns.ResourceRecordSet) {\n\trequire.Len(t, records, len(expected))\n\n\tfor i := range records {\n\t\tvalidateChangeRecord(t, records[i], expected[i])\n\t}\n}\n\nfunc validateChangeRecord(t *testing.T, record *dns.ResourceRecordSet, expected *dns.ResourceRecordSet) {\n\tassert.Equal(t, expected.Name, record.Name)\n\tassert.Equal(t, expected.Rrdatas, record.Rrdatas)\n\tassert.Equal(t, expected.Ttl, record.Ttl)\n\tassert.Equal(t, expected.Type, record.Type)\n}\n\nfunc newGoogleProviderZoneOverlap(t *testing.T, domainFilter *endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, zoneTypeFilter provider.ZoneTypeFilter, _ []*endpoint.Endpoint) *GoogleProvider {\n\tprovider := &GoogleProvider{\n\t\tproject:                  \"zalando-external-dns-test\",\n\t\tdryRun:                   false,\n\t\tdomainFilter:             domainFilter,\n\t\tzoneIDFilter:             zoneIDFilter,\n\t\tzoneTypeFilter:           zoneTypeFilter,\n\t\tresourceRecordSetsClient: &mockResourceRecordSetsClient{},\n\t\tmanagedZonesClient:       &mockManagedZonesClient{},\n\t\tchangesClient:            &mockChangesClient{},\n\t}\n\n\tcreateZone(t, provider, &dns.ManagedZone{\n\t\tName:       \"internal-1\",\n\t\tDnsName:    \"cluster.local.\",\n\t\tId:         10001,\n\t\tVisibility: \"private\",\n\t})\n\n\tcreateZone(t, provider, &dns.ManagedZone{\n\t\tName:       \"internal-2\",\n\t\tDnsName:    \"cluster.local.\",\n\t\tId:         10002,\n\t\tVisibility: \"private\",\n\t})\n\n\tcreateZone(t, provider, &dns.ManagedZone{\n\t\tName:       \"internal-3\",\n\t\tDnsName:    \"cluster.local.\",\n\t\tId:         10003,\n\t\tVisibility: \"private\",\n\t})\n\n\tcreateZone(t, provider, &dns.ManagedZone{\n\t\tName:       \"split-horizon-1\",\n\t\tDnsName:    \"cluster.local.\",\n\t\tId:         10004,\n\t\tVisibility: \"public\",\n\t})\n\n\tcreateZone(t, provider, &dns.ManagedZone{\n\t\tName:       \"split-horizon-1\",\n\t\tDnsName:    \"cluster.local.\",\n\t\tId:         10004,\n\t\tVisibility: \"private\",\n\t})\n\n\tcreateZone(t, provider, &dns.ManagedZone{\n\t\tName:       \"svc-local\",\n\t\tDnsName:    \"svc.local.\",\n\t\tId:         10005,\n\t\tVisibility: \"private\",\n\t})\n\n\tcreateZone(t, provider, &dns.ManagedZone{\n\t\tName:          \"svc-local-peer\",\n\t\tDnsName:       \"svc.local.\",\n\t\tId:            10006,\n\t\tVisibility:    \"private\",\n\t\tPeeringConfig: &dns.ManagedZonePeeringConfig{TargetNetwork: nil},\n\t})\n\n\treturn provider\n}\n\nfunc newGoogleProvider(t *testing.T, domainFilter *endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, dryRun bool, records []*endpoint.Endpoint, zonesErr, recordsErr error) *GoogleProvider {\n\tprovider := &GoogleProvider{\n\t\tproject:      \"zalando-external-dns-test\",\n\t\tdryRun:       false,\n\t\tdomainFilter: domainFilter,\n\t\tzoneIDFilter: zoneIDFilter,\n\t\tresourceRecordSetsClient: &mockResourceRecordSetsClient{\n\t\t\trecordsErr: recordsErr,\n\t\t},\n\t\tmanagedZonesClient: &mockManagedZonesClient{\n\t\t\tzonesErr: zonesErr,\n\t\t},\n\t\tchangesClient: &mockChangesClient{},\n\t}\n\n\tcreateZone(t, provider, &dns.ManagedZone{\n\t\tName:    \"zone-1-ext-dns-test-2-gcp-zalan-do\",\n\t\tDnsName: \"zone-1.ext-dns-test-2.gcp.zalan.do.\",\n\t})\n\n\tcreateZone(t, provider, &dns.ManagedZone{\n\t\tName:    \"zone-2-ext-dns-test-2-gcp-zalan-do\",\n\t\tDnsName: \"zone-2.ext-dns-test-2.gcp.zalan.do.\",\n\t})\n\n\tcreateZone(t, provider, &dns.ManagedZone{\n\t\tName:    \"zone-3-ext-dns-test-2-gcp-zalan-do\",\n\t\tDnsName: \"zone-3.ext-dns-test-2.gcp.zalan.do.\",\n\t})\n\n\t// filtered out by domain filter\n\tcreateZone(t, provider, &dns.ManagedZone{\n\t\tName:    \"zone-4-ext-dns-test-3-gcp-zalan-do\",\n\t\tDnsName: \"zone-4.ext-dns-test-3.gcp.zalan.do.\",\n\t})\n\n\tsetupGoogleRecords(t, provider, records)\n\n\tprovider.dryRun = dryRun\n\n\treturn provider\n}\n\nfunc createZone(t *testing.T, p *GoogleProvider, zone *dns.ManagedZone) {\n\tzone.Description = \"Testing zone for kubernetes.io/external-dns\"\n\n\tif _, err := p.managedZonesClient.Create(\"zalando-external-dns-test\", zone).Do(); err != nil {\n\t\tvar errs *googleapi.Error\n\t\tif !errors.As(err, &errs) || errs.Code != http.StatusConflict {\n\t\t\trequire.NoError(t, err)\n\t\t}\n\t}\n}\n\nfunc setupGoogleRecords(t *testing.T, provider *GoogleProvider, endpoints []*endpoint.Endpoint) {\n\tclearGoogleRecords(t, provider, \"zone-1-ext-dns-test-2-gcp-zalan-do\")\n\tclearGoogleRecords(t, provider, \"zone-2-ext-dns-test-2-gcp-zalan-do\")\n\tclearGoogleRecords(t, provider, \"zone-3-ext-dns-test-2-gcp-zalan-do\")\n\n\tctx := t.Context()\n\trecords, _ := provider.Records(ctx)\n\n\tvalidateEndpoints(t, records, []*endpoint.Endpoint{})\n\n\trequire.NoError(t, provider.ApplyChanges(t.Context(), &plan.Changes{\n\t\tCreate: endpoints,\n\t}))\n\n\trecords, _ = provider.Records(ctx)\n\n\tvalidateEndpoints(t, records, endpoints)\n}\n\nfunc clearGoogleRecords(t *testing.T, provider *GoogleProvider, zone string) {\n\trecordSets := []*dns.ResourceRecordSet{}\n\n\tprovider.resourceRecordSetsClient.List(provider.project, zone).Pages(t.Context(), func(resp *dns.ResourceRecordSetsListResponse) error {\n\t\tfor _, r := range resp.Rrsets {\n\t\t\tswitch r.Type {\n\t\t\tcase endpoint.RecordTypeA, endpoint.RecordTypeCNAME:\n\t\t\t\trecordSets = append(recordSets, r)\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n\n\tif len(recordSets) != 0 {\n\t\t_, err := provider.changesClient.Create(provider.project, zone, &dns.Change{\n\t\t\tDeletions: recordSets,\n\t\t}).Do()\n\t\trequire.NoError(t, err)\n\t}\n}\n\nfunc validateEndpoints(t *testing.T, endpoints []*endpoint.Endpoint, expected []*endpoint.Endpoint) {\n\tassert.True(t, testutils.SameEndpoints(endpoints, expected), \"actual and expected endpoints don't match. %s:%s\", endpoints, expected)\n}\n"
  },
  {
    "path": "provider/inmemory/inmemory.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage inmemory\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"maps\"\n\t\"strings\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"k8s.io/apimachinery/pkg/util/sets\"\n\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\nvar (\n\t// ErrZoneAlreadyExists error returned when zone cannot be created when it already exists\n\tErrZoneAlreadyExists = errors.New(\"specified zone already exists\")\n\t// ErrZoneNotFound error returned when specified zone does not exists\n\tErrZoneNotFound = errors.New(\"specified zone not found\")\n\t// ErrRecordAlreadyExists when create request is sent but record already exists\n\tErrRecordAlreadyExists = errors.New(\"record already exists\")\n\t// ErrRecordNotFound when update/delete request is sent but record not found\n\tErrRecordNotFound = errors.New(\"record not found\")\n\t// ErrDuplicateRecordFound when record is repeated in create/update/delete\n\tErrDuplicateRecordFound = errors.New(\"invalid batch request\")\n)\n\n// InMemoryProvider - dns provider only used for testing purposes\n// initialized as dns provider with no records\ntype InMemoryProvider struct {\n\tprovider.BaseProvider\n\tdomain         endpoint.DomainFilterInterface\n\tclient         *inMemoryClient\n\tfilter         *filter\n\tOnApplyChanges func(ctx context.Context, changes *plan.Changes)\n\tOnRecords      func()\n}\n\n// New creates an InMemory provider from the given configuration.\nfunc New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) {\n\treturn newProvider(InMemoryInitZones(cfg.InMemoryZones), InMemoryWithDomain(domainFilter), InMemoryWithLogging()), nil\n}\n\n// InMemoryOption allows to extend in-memory provider\n// TODO: review this pattern, and consider inline with other providers\ntype InMemoryOption func(*InMemoryProvider)\n\n// InMemoryWithLogging injects logging when ApplyChanges is called\nfunc InMemoryWithLogging() InMemoryOption {\n\treturn func(p *InMemoryProvider) {\n\t\tp.OnApplyChanges = func(_ context.Context, changes *plan.Changes) {\n\t\t\tfor _, v := range changes.Create {\n\t\t\t\tlog.Infof(\"CREATE: %v\", v)\n\t\t\t}\n\t\t\tfor _, v := range changes.UpdateOld {\n\t\t\t\tlog.Infof(\"UPDATE (old): %v\", v)\n\t\t\t}\n\t\t\tfor _, v := range changes.UpdateNew {\n\t\t\t\tlog.Infof(\"UPDATE (new): %v\", v)\n\t\t\t}\n\t\t\tfor _, v := range changes.Delete {\n\t\t\t\tlog.Infof(\"DELETE: %v\", v)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// InMemoryWithDomain modifies the domain on which dns zones are filtered\nfunc InMemoryWithDomain(domainFilter *endpoint.DomainFilter) InMemoryOption {\n\treturn func(p *InMemoryProvider) {\n\t\tp.domain = domainFilter\n\t}\n}\n\n// InMemoryInitZones pre-seeds the InMemoryProvider with given zones\nfunc InMemoryInitZones(zones []string) InMemoryOption {\n\treturn func(p *InMemoryProvider) {\n\t\tfor _, z := range zones {\n\t\t\tif err := p.CreateZone(z); err != nil {\n\t\t\t\tlog.Warnf(\"Unable to initialize zones for inmemory provider\")\n\t\t\t}\n\t\t}\n\t}\n}\n\n// NewInMemoryProvider returns InMemoryProvider DNS provider interface implementation\nfunc NewInMemoryProvider(opts ...InMemoryOption) *InMemoryProvider {\n\treturn newProvider(opts...)\n}\n\n// newProvider returns InMemoryProvider DNS provider interface implementation\nfunc newProvider(opts ...InMemoryOption) *InMemoryProvider {\n\tim := &InMemoryProvider{\n\t\tfilter:         &filter{},\n\t\tOnApplyChanges: func(_ context.Context, _ *plan.Changes) {},\n\t\tOnRecords:      func() {},\n\t\tdomain:         endpoint.NewDomainFilter([]string{\"\"}),\n\t\tclient:         newInMemoryClient(),\n\t}\n\n\tfor _, opt := range opts {\n\t\topt(im)\n\t}\n\n\treturn im\n}\n\n// CreateZone adds new zone if not present\nfunc (im *InMemoryProvider) CreateZone(newZone string) error {\n\treturn im.client.CreateZone(newZone)\n}\n\n// Zones returns filtered zones as specified by domain\nfunc (im *InMemoryProvider) Zones() map[string]string {\n\treturn im.filter.Zones(im.client.Zones())\n}\n\n// Records returns the list of endpoints\nfunc (im *InMemoryProvider) Records(_ context.Context) ([]*endpoint.Endpoint, error) {\n\tdefer im.OnRecords()\n\n\tendpoints := make([]*endpoint.Endpoint, 0)\n\n\tfor zoneID := range im.Zones() {\n\t\trecords, err := im.client.Records(zoneID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tendpoints = append(endpoints, copyEndpoints(records)...)\n\t}\n\n\treturn endpoints, nil\n}\n\n// ApplyChanges simply modifies records in memory\n// error checking occurs before any modifications are made, i.e. batch processing\n// create record - record should not exist\n// update/delete record - record should exist\n// create/update/delete lists should not have overlapping records\nfunc (im *InMemoryProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {\n\tdefer im.OnApplyChanges(ctx, changes)\n\n\tperZoneChanges := map[string]*plan.Changes{}\n\n\tzones := im.Zones()\n\tfor zoneID := range zones {\n\t\tperZoneChanges[zoneID] = &plan.Changes{}\n\t}\n\n\tfor _, ep := range changes.Create {\n\t\tzoneID := im.filter.EndpointZoneID(ep, zones)\n\t\tif zoneID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tperZoneChanges[zoneID].Create = append(perZoneChanges[zoneID].Create, ep)\n\t}\n\tfor _, ep := range changes.UpdateNew {\n\t\tzoneID := im.filter.EndpointZoneID(ep, zones)\n\t\tif zoneID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tperZoneChanges[zoneID].UpdateNew = append(perZoneChanges[zoneID].UpdateNew, ep)\n\t}\n\tfor _, ep := range changes.UpdateOld {\n\t\tzoneID := im.filter.EndpointZoneID(ep, zones)\n\t\tif zoneID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tperZoneChanges[zoneID].UpdateOld = append(perZoneChanges[zoneID].UpdateOld, ep)\n\t}\n\tfor _, ep := range changes.Delete {\n\t\tzoneID := im.filter.EndpointZoneID(ep, zones)\n\t\tif zoneID == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tperZoneChanges[zoneID].Delete = append(perZoneChanges[zoneID].Delete, ep)\n\t}\n\n\tfor zoneID := range perZoneChanges {\n\t\tchange := &plan.Changes{\n\t\t\tCreate:    perZoneChanges[zoneID].Create,\n\t\t\tUpdateNew: perZoneChanges[zoneID].UpdateNew,\n\t\t\tUpdateOld: perZoneChanges[zoneID].UpdateOld,\n\t\t\tDelete:    perZoneChanges[zoneID].Delete,\n\t\t}\n\t\terr := im.client.ApplyChanges(ctx, zoneID, change)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc copyEndpoints(endpoints []*endpoint.Endpoint) []*endpoint.Endpoint {\n\trecords := make([]*endpoint.Endpoint, 0, len(endpoints))\n\tfor _, ep := range endpoints {\n\t\tnewEp := endpoint.NewEndpointWithTTL(ep.DNSName, ep.RecordType, ep.RecordTTL, ep.Targets...).WithSetIdentifier(ep.SetIdentifier)\n\t\tnewEp.Labels = endpoint.NewLabels()\n\t\tmaps.Copy(newEp.Labels, ep.Labels)\n\t\tnewEp.ProviderSpecific = append(endpoint.ProviderSpecific(nil), ep.ProviderSpecific...)\n\t\trecords = append(records, newEp)\n\t}\n\treturn records\n}\n\ntype filter struct {\n\tdomain string\n}\n\n// Zones filters map[zoneID]zoneName for names having f.domain as suffix\nfunc (f *filter) Zones(zones map[string]string) map[string]string {\n\tresult := map[string]string{}\n\tfor zoneID, zoneName := range zones {\n\t\tif strings.HasSuffix(zoneName, f.domain) {\n\t\t\tresult[zoneID] = zoneName\n\t\t}\n\t}\n\treturn result\n}\n\n// EndpointZoneID determines zoneID for endpoint from map[zoneID]zoneName by taking longest suffix zoneName match in endpoint DNSName\n// returns empty string if no match found\nfunc (f *filter) EndpointZoneID(endpoint *endpoint.Endpoint, zones map[string]string) string {\n\tvar matchZoneID, matchZoneName string\n\tfor zoneID, zoneName := range zones {\n\t\tif strings.HasSuffix(endpoint.DNSName, zoneName) && len(zoneName) > len(matchZoneName) {\n\t\t\tmatchZoneName = zoneName\n\t\t\tmatchZoneID = zoneID\n\t\t}\n\t}\n\treturn matchZoneID\n}\n\ntype zone map[endpoint.EndpointKey]*endpoint.Endpoint\n\ntype inMemoryClient struct {\n\tzones map[string]zone\n}\n\nfunc newInMemoryClient() *inMemoryClient {\n\treturn &inMemoryClient{map[string]zone{}}\n}\n\nfunc (c *inMemoryClient) Records(zone string) ([]*endpoint.Endpoint, error) {\n\tif _, ok := c.zones[zone]; !ok {\n\t\treturn nil, ErrZoneNotFound\n\t}\n\n\tvar records []*endpoint.Endpoint\n\tfor _, rec := range c.zones[zone] {\n\t\trecords = append(records, rec)\n\t}\n\treturn records, nil\n}\n\nfunc (c *inMemoryClient) Zones() map[string]string {\n\tzones := map[string]string{}\n\tfor zone := range c.zones {\n\t\tzones[zone] = zone\n\t}\n\treturn zones\n}\n\nfunc (c *inMemoryClient) CreateZone(zone string) error {\n\tif _, ok := c.zones[zone]; ok {\n\t\treturn ErrZoneAlreadyExists\n\t}\n\tc.zones[zone] = map[endpoint.EndpointKey]*endpoint.Endpoint{}\n\n\treturn nil\n}\n\nfunc (c *inMemoryClient) ApplyChanges(_ context.Context, zoneID string, changes *plan.Changes) error {\n\tif err := c.validateChangeBatch(zoneID, changes); err != nil {\n\t\treturn err\n\t}\n\tfor _, newEndpoint := range changes.Create {\n\t\tc.zones[zoneID][newEndpoint.Key()] = newEndpoint\n\t}\n\tfor _, updateEndpoint := range changes.UpdateNew {\n\t\tc.zones[zoneID][updateEndpoint.Key()] = updateEndpoint\n\t}\n\tfor _, deleteEndpoint := range changes.Delete {\n\t\tdelete(c.zones[zoneID], deleteEndpoint.Key())\n\t}\n\treturn nil\n}\n\nfunc (c *inMemoryClient) updateMesh(mesh sets.Set[endpoint.EndpointKey], record *endpoint.Endpoint) error {\n\tif mesh.Has(record.Key()) {\n\t\treturn ErrDuplicateRecordFound\n\t}\n\tmesh.Insert(record.Key())\n\treturn nil\n}\n\n// validateChangeBatch validates that the changes passed to InMemory DNS provider is valid\nfunc (c *inMemoryClient) validateChangeBatch(zone string, changes *plan.Changes) error {\n\tcurZone, ok := c.zones[zone]\n\tif !ok {\n\t\treturn ErrZoneNotFound\n\t}\n\tmesh := sets.New[endpoint.EndpointKey]()\n\tfor _, newEndpoint := range changes.Create {\n\t\tif _, ok := curZone[newEndpoint.Key()]; ok {\n\t\t\treturn ErrRecordAlreadyExists\n\t\t}\n\t\tif err := c.updateMesh(mesh, newEndpoint); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tfor _, updateEndpoint := range changes.UpdateNew {\n\t\tif _, ok := curZone[updateEndpoint.Key()]; !ok {\n\t\t\treturn ErrRecordNotFound\n\t\t}\n\t\tif err := c.updateMesh(mesh, updateEndpoint); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tfor _, updateOldEndpoint := range changes.UpdateOld {\n\t\tif rec, ok := curZone[updateOldEndpoint.Key()]; !ok || rec.Targets[0] != updateOldEndpoint.Targets[0] {\n\t\t\treturn ErrRecordNotFound\n\t\t}\n\t}\n\tfor _, deleteEndpoint := range changes.Delete {\n\t\tif rec, ok := curZone[deleteEndpoint.Key()]; !ok || rec.Targets[0] != deleteEndpoint.Targets[0] {\n\t\t\treturn ErrRecordNotFound\n\t\t}\n\t\tif err := c.updateMesh(mesh, deleteEndpoint); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "provider/inmemory/inmemory_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage inmemory\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/internal/testutils\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\nvar _ provider.Provider = &InMemoryProvider{}\n\nfunc TestInMemoryProvider(t *testing.T) {\n\tt.Run(\"Records\", testInMemoryRecords)\n\tt.Run(\"validateChangeBatch\", testInMemoryValidateChangeBatch)\n\tt.Run(\"ApplyChanges\", testInMemoryApplyChanges)\n\tt.Run(\"NewInMemoryProvider\", testNewInMemoryProvider)\n\tt.Run(\"CreateZone\", testInMemoryCreateZone)\n}\n\nfunc testInMemoryRecords(t *testing.T) {\n\tfor _, ti := range []struct {\n\t\ttitle       string\n\t\tzone        string\n\t\texpectError bool\n\t\tinit        map[string]zone\n\t\texpected    []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\ttitle:       \"no records, no zone\",\n\t\t\tzone:        \"\",\n\t\t\tinit:        map[string]zone{},\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\ttitle: \"records, wrong zone\",\n\t\t\tzone:  \"net\",\n\t\t\tinit: map[string]zone{\n\t\t\t\t\"org\": {},\n\t\t\t\t\"com\": {},\n\t\t\t},\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\ttitle: \"records, zone with records\",\n\t\t\tzone:  \"org\",\n\t\t\tinit: map[string]zone{\n\t\t\t\t\"org\": makeZone(\n\t\t\t\t\t\"example.org\", \"8.8.8.8\", endpoint.RecordTypeA,\n\t\t\t\t\t\"example.org\", \"\", endpoint.RecordTypeTXT,\n\t\t\t\t\t\"foo.org\", \"4.4.4.4\", endpoint.RecordTypeCNAME,\n\t\t\t\t),\n\t\t\t\t\"com\": makeZone(\"example.com\", \"4.4.4.4\", endpoint.RecordTypeCNAME),\n\t\t\t},\n\t\t\texpectError: false,\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeTXT,\n\t\t\t\t\tTargets:    endpoint.Targets{\"\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"4.4.4.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(ti.title, func(t *testing.T) {\n\t\t\tc := newInMemoryClient()\n\t\t\tc.zones = ti.init\n\t\t\tim := NewInMemoryProvider()\n\t\t\tim.client = c\n\t\t\tf := filter{domain: ti.zone}\n\t\t\tim.filter = &f\n\t\t\trecords, err := im.Records(t.Context())\n\t\t\tif ti.expectError {\n\t\t\t\tassert.Nil(t, records)\n\t\t\t\tassert.EqualError(t, err, ErrZoneNotFound.Error())\n\t\t\t} else {\n\t\t\t\trequire.NoError(t, err)\n\t\t\t\tassert.True(t, testutils.SameEndpoints(ti.expected, records), \"Endpoints not the same: Expected: %+v Records: %+v\", ti.expected, records)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc testInMemoryValidateChangeBatch(t *testing.T) {\n\tinit := map[string]zone{\n\t\t\"org\": makeZone(\n\t\t\t\"example.org\", \"8.8.8.8\", endpoint.RecordTypeA,\n\t\t\t\"example.org\", \"\", endpoint.RecordTypeTXT,\n\t\t\t\"foo.org\", \"bar.org\", endpoint.RecordTypeCNAME,\n\t\t\t\"foo.bar.org\", \"5.5.5.5\", endpoint.RecordTypeA,\n\t\t),\n\t\t\"com\": makeZone(\"example.com\", \"another-example.com\", endpoint.RecordTypeCNAME),\n\t}\n\tfor _, ti := range []struct {\n\t\ttitle       string\n\t\texpectError bool\n\t\terrorType   error\n\t\tinit        map[string]zone\n\t\tchanges     *plan.Changes\n\t\tzone        string\n\t}{\n\t\t{\n\t\t\ttitle:       \"no zones, no update\",\n\t\t\texpectError: true,\n\t\t\tzone:        \"\",\n\t\t\tinit:        map[string]zone{},\n\t\t\tchanges: &plan.Changes{\n\t\t\t\tCreate:    []*endpoint.Endpoint{},\n\t\t\t\tUpdateNew: []*endpoint.Endpoint{},\n\t\t\t\tUpdateOld: []*endpoint.Endpoint{},\n\t\t\t\tDelete:    []*endpoint.Endpoint{},\n\t\t\t},\n\t\t\terrorType: ErrZoneNotFound,\n\t\t},\n\t\t{\n\t\t\ttitle:       \"zones, no update\",\n\t\t\texpectError: true,\n\t\t\tzone:        \"\",\n\t\t\tinit:        init,\n\t\t\tchanges: &plan.Changes{\n\t\t\t\tCreate:    []*endpoint.Endpoint{},\n\t\t\t\tUpdateNew: []*endpoint.Endpoint{},\n\t\t\t\tUpdateOld: []*endpoint.Endpoint{},\n\t\t\t\tDelete:    []*endpoint.Endpoint{},\n\t\t\t},\n\t\t\terrorType: ErrZoneNotFound,\n\t\t},\n\t\t{\n\t\t\ttitle:       \"zones, update, wrong zone\",\n\t\t\texpectError: true,\n\t\t\tzone:        \"test\",\n\t\t\tinit:        init,\n\t\t\tchanges: &plan.Changes{\n\t\t\t\tCreate:    []*endpoint.Endpoint{},\n\t\t\t\tUpdateNew: []*endpoint.Endpoint{},\n\t\t\t\tUpdateOld: []*endpoint.Endpoint{},\n\t\t\t\tDelete:    []*endpoint.Endpoint{},\n\t\t\t},\n\t\t\terrorType: ErrZoneNotFound,\n\t\t},\n\t\t{\n\t\t\ttitle:       \"zones, update, right zone, invalid batch - already exists\",\n\t\t\texpectError: true,\n\t\t\tzone:        \"org\",\n\t\t\tinit:        init,\n\t\t\tchanges: &plan.Changes{\n\t\t\t\tCreate: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tUpdateNew: []*endpoint.Endpoint{},\n\t\t\t\tUpdateOld: []*endpoint.Endpoint{},\n\t\t\t\tDelete:    []*endpoint.Endpoint{},\n\t\t\t},\n\t\t\terrorType: ErrRecordAlreadyExists,\n\t\t},\n\t\t{\n\t\t\ttitle:       \"zones, update, right zone, invalid batch - record not found for update\",\n\t\t\texpectError: true,\n\t\t\tzone:        \"org\",\n\t\t\tinit:        init,\n\t\t\tchanges: &plan.Changes{\n\t\t\t\tCreate: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:    \"foo.org\",\n\t\t\t\t\t\tTargets:    endpoint.Targets{\"4.4.4.4\"},\n\t\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:    \"foo.org\",\n\t\t\t\t\t\tTargets:    endpoint.Targets{\"4.4.4.4\"},\n\t\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tUpdateOld: []*endpoint.Endpoint{},\n\t\t\t\tDelete:    []*endpoint.Endpoint{},\n\t\t\t},\n\t\t\terrorType: ErrRecordNotFound,\n\t\t},\n\t\t{\n\t\t\ttitle:       \"zones, update, right zone, invalid batch - record not found for update\",\n\t\t\texpectError: true,\n\t\t\tzone:        \"org\",\n\t\t\tinit:        init,\n\t\t\tchanges: &plan.Changes{\n\t\t\t\tCreate: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:    \"foo.org\",\n\t\t\t\t\t\tTargets:    endpoint.Targets{\"4.4.4.4\"},\n\t\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:    \"foo.org\",\n\t\t\t\t\t\tTargets:    endpoint.Targets{\"4.4.4.4\"},\n\t\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tUpdateOld: []*endpoint.Endpoint{},\n\t\t\t\tDelete:    []*endpoint.Endpoint{},\n\t\t\t},\n\t\t\terrorType: ErrRecordNotFound,\n\t\t},\n\t\t{\n\t\t\ttitle:       \"zones, update, right zone, invalid batch - duplicated create\",\n\t\t\texpectError: true,\n\t\t\tzone:        \"org\",\n\t\t\tinit:        init,\n\t\t\tchanges: &plan.Changes{\n\t\t\t\tCreate: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:    \"foo.org\",\n\t\t\t\t\t\tTargets:    endpoint.Targets{\"4.4.4.4\"},\n\t\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:    \"foo.org\",\n\t\t\t\t\t\tTargets:    endpoint.Targets{\"4.4.4.4\"},\n\t\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tUpdateNew: []*endpoint.Endpoint{},\n\t\t\t\tUpdateOld: []*endpoint.Endpoint{},\n\t\t\t\tDelete:    []*endpoint.Endpoint{},\n\t\t\t},\n\t\t\terrorType: ErrDuplicateRecordFound,\n\t\t},\n\t\t{\n\t\t\ttitle:       \"zones, update, right zone, invalid batch - duplicated update/delete\",\n\t\t\texpectError: true,\n\t\t\tzone:        \"org\",\n\t\t\tinit:        init,\n\t\t\tchanges: &plan.Changes{\n\t\t\t\tCreate: []*endpoint.Endpoint{},\n\t\t\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tUpdateOld: []*endpoint.Endpoint{},\n\t\t\t\tDelete: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\terrorType: ErrDuplicateRecordFound,\n\t\t},\n\t\t{\n\t\t\ttitle:       \"zones, update, right zone, invalid batch - duplicated update\",\n\t\t\texpectError: true,\n\t\t\tzone:        \"org\",\n\t\t\tinit:        init,\n\t\t\tchanges: &plan.Changes{\n\t\t\t\tCreate: []*endpoint.Endpoint{},\n\t\t\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tUpdateOld: []*endpoint.Endpoint{},\n\t\t\t\tDelete:    []*endpoint.Endpoint{},\n\t\t\t},\n\t\t\terrorType: ErrDuplicateRecordFound,\n\t\t},\n\t\t{\n\t\t\ttitle:       \"zones, update, right zone, invalid batch - wrong update old\",\n\t\t\texpectError: true,\n\t\t\tzone:        \"org\",\n\t\t\tinit:        init,\n\t\t\tchanges: &plan.Changes{\n\t\t\t\tCreate:    []*endpoint.Endpoint{},\n\t\t\t\tUpdateNew: []*endpoint.Endpoint{},\n\t\t\t\tUpdateOld: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:    \"new.org\",\n\t\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tDelete: []*endpoint.Endpoint{},\n\t\t\t},\n\t\t\terrorType: ErrRecordNotFound,\n\t\t},\n\t\t{\n\t\t\ttitle:       \"zones, update, right zone, invalid batch - wrong delete\",\n\t\t\texpectError: true,\n\t\t\tzone:        \"org\",\n\t\t\tinit:        init,\n\t\t\tchanges: &plan.Changes{\n\t\t\t\tCreate:    []*endpoint.Endpoint{},\n\t\t\t\tUpdateNew: []*endpoint.Endpoint{},\n\t\t\t\tUpdateOld: []*endpoint.Endpoint{},\n\t\t\t\tDelete: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:    \"new.org\",\n\t\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\terrorType: ErrRecordNotFound,\n\t\t},\n\t\t{\n\t\t\ttitle:       \"zones, update, right zone, valid batch - delete\",\n\t\t\texpectError: false,\n\t\t\tzone:        \"org\",\n\t\t\tinit:        init,\n\t\t\tchanges: &plan.Changes{\n\t\t\t\tCreate:    []*endpoint.Endpoint{},\n\t\t\t\tUpdateNew: []*endpoint.Endpoint{},\n\t\t\t\tUpdateOld: []*endpoint.Endpoint{},\n\t\t\t\tDelete: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:    \"foo.bar.org\",\n\t\t\t\t\t\tTargets:    endpoint.Targets{\"5.5.5.5\"},\n\t\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:       \"zones, update, right zone, valid batch - update and create\",\n\t\t\texpectError: false,\n\t\t\tzone:        \"org\",\n\t\t\tinit:        init,\n\t\t\tchanges: &plan.Changes{\n\t\t\t\tCreate: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:    \"foo.bar.new.org\",\n\t\t\t\t\t\tTargets:    endpoint.Targets{\"4.8.8.9\"},\n\t\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:    \"foo.bar.org\",\n\t\t\t\t\t\tTargets:    endpoint.Targets{\"4.8.8.4\"},\n\t\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tUpdateOld: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:    \"foo.bar.org\",\n\t\t\t\t\t\tTargets:    endpoint.Targets{\"5.5.5.5\"},\n\t\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tDelete: []*endpoint.Endpoint{},\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(ti.title, func(t *testing.T) {\n\t\t\tc := &inMemoryClient{}\n\t\t\tc.zones = ti.init\n\t\t\tichanges := &plan.Changes{\n\t\t\t\tCreate:    ti.changes.Create,\n\t\t\t\tUpdateNew: ti.changes.UpdateNew,\n\t\t\t\tUpdateOld: ti.changes.UpdateOld,\n\t\t\t\tDelete:    ti.changes.Delete,\n\t\t\t}\n\t\t\terr := c.validateChangeBatch(ti.zone, ichanges)\n\t\t\tif ti.expectError {\n\t\t\t\tassert.EqualError(t, err, ti.errorType.Error())\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc getInitData() map[string]zone {\n\treturn map[string]zone{\n\t\t\"org\": makeZone(\"example.org\", \"8.8.8.8\", endpoint.RecordTypeA,\n\t\t\t\"example.org\", \"\", endpoint.RecordTypeTXT,\n\t\t\t\"foo.org\", \"4.4.4.4\", endpoint.RecordTypeCNAME,\n\t\t\t\"foo.bar.org\", \"5.5.5.5\", endpoint.RecordTypeA,\n\t\t),\n\t\t\"com\": makeZone(\"example.com\", \"4.4.4.4\", endpoint.RecordTypeCNAME),\n\t}\n}\n\nfunc testInMemoryApplyChanges(t *testing.T) {\n\tfor _, ti := range []struct {\n\t\ttitle              string\n\t\texpectError        bool\n\t\tinit               map[string]zone\n\t\tchanges            *plan.Changes\n\t\texpectedZonesState map[string]zone\n\t}{\n\t\t{\n\t\t\ttitle:       \"unmatched zone, should be ignored in the apply step\",\n\t\t\texpectError: false,\n\t\t\tchanges: &plan.Changes{\n\t\t\t\tCreate: []*endpoint.Endpoint{{\n\t\t\t\t\tDNSName:    \"example.de\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t}},\n\t\t\t\tUpdateNew: []*endpoint.Endpoint{},\n\t\t\t\tUpdateOld: []*endpoint.Endpoint{},\n\t\t\t\tDelete:    []*endpoint.Endpoint{},\n\t\t\t},\n\t\t\texpectedZonesState: getInitData(),\n\t\t},\n\t\t{\n\t\t\ttitle:       \"expect error\",\n\t\t\texpectError: true,\n\t\t\tchanges: &plan.Changes{\n\t\t\t\tCreate: []*endpoint.Endpoint{},\n\t\t\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tUpdateOld: []*endpoint.Endpoint{},\n\t\t\t\tDelete: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:       \"zones, update, right zone, valid batch - delete\",\n\t\t\texpectError: false,\n\t\t\tchanges: &plan.Changes{\n\t\t\t\tCreate:    []*endpoint.Endpoint{},\n\t\t\t\tUpdateNew: []*endpoint.Endpoint{},\n\t\t\t\tUpdateOld: []*endpoint.Endpoint{},\n\t\t\t\tDelete: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:    \"foo.bar.org\",\n\t\t\t\t\t\tTargets:    endpoint.Targets{\"5.5.5.5\"},\n\t\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedZonesState: map[string]zone{\n\t\t\t\t\"org\": makeZone(\"example.org\", \"8.8.8.8\", endpoint.RecordTypeA,\n\t\t\t\t\t\"example.org\", \"\", endpoint.RecordTypeTXT,\n\t\t\t\t\t\"foo.org\", \"4.4.4.4\", endpoint.RecordTypeCNAME,\n\t\t\t\t),\n\t\t\t\t\"com\": makeZone(\"example.com\", \"4.4.4.4\", endpoint.RecordTypeCNAME),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:       \"zones, update, right zone, valid batch - update, create, delete\",\n\t\t\texpectError: false,\n\t\t\tchanges: &plan.Changes{\n\t\t\t\tCreate: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:    \"foo.bar.new.org\",\n\t\t\t\t\t\tTargets:    endpoint.Targets{\"4.8.8.9\"},\n\t\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\t\tLabels:     endpoint.NewLabels(),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:    \"foo.bar.org\",\n\t\t\t\t\t\tTargets:    endpoint.Targets{\"4.8.8.4\"},\n\t\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\t\tLabels:     endpoint.NewLabels(),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tUpdateOld: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:    \"foo.bar.org\",\n\t\t\t\t\t\tTargets:    endpoint.Targets{\"5.5.5.5\"},\n\t\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\t\tLabels:     endpoint.NewLabels(),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tDelete: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\t\tLabels:     endpoint.NewLabels(),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedZonesState: map[string]zone{\n\t\t\t\t\"org\": makeZone(\n\t\t\t\t\t\"example.org\", \"\", endpoint.RecordTypeTXT,\n\t\t\t\t\t\"foo.org\", \"4.4.4.4\", endpoint.RecordTypeCNAME,\n\t\t\t\t\t\"foo.bar.org\", \"4.8.8.4\", endpoint.RecordTypeA,\n\t\t\t\t\t\"foo.bar.new.org\", \"4.8.8.9\", endpoint.RecordTypeA,\n\t\t\t\t),\n\t\t\t\t\"com\": makeZone(\"example.com\", \"4.4.4.4\", endpoint.RecordTypeCNAME),\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(ti.title, func(t *testing.T) {\n\t\t\tim := NewInMemoryProvider()\n\t\t\tc := &inMemoryClient{}\n\t\t\tc.zones = getInitData()\n\t\t\tim.client = c\n\n\t\t\terr := im.ApplyChanges(t.Context(), ti.changes)\n\t\t\tif ti.expectError {\n\t\t\t\tassert.Error(t, err)\n\t\t\t} else {\n\t\t\t\trequire.NoError(t, err)\n\t\t\t\tassert.Equal(t, ti.expectedZonesState, c.zones)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc testNewInMemoryProvider(t *testing.T) {\n\tcfg := NewInMemoryProvider()\n\tassert.NotNil(t, cfg.client)\n}\n\nfunc testInMemoryCreateZone(t *testing.T) {\n\tim := NewInMemoryProvider()\n\terr := im.CreateZone(\"zone\")\n\trequire.NoError(t, err)\n\terr = im.CreateZone(\"zone\")\n\trequire.EqualError(t, err, ErrZoneAlreadyExists.Error())\n}\n\nfunc makeZone(s ...string) map[endpoint.EndpointKey]*endpoint.Endpoint {\n\tif len(s)%3 != 0 {\n\t\tpanic(\"makeZone arguments must be multiple of 3\")\n\t}\n\n\toutput := map[endpoint.EndpointKey]*endpoint.Endpoint{}\n\tfor i := 0; i < len(s); i += 3 {\n\t\tep := endpoint.NewEndpoint(s[i], s[i+2], s[i+1])\n\t\toutput[ep.Key()] = ep\n\t}\n\n\treturn output\n}\n"
  },
  {
    "path": "provider/linode/linode.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage linode\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/linode/linodego\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"golang.org/x/oauth2\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n)\n\n// LinodeDomainClient interface to ease testing\ntype LinodeDomainClient interface {\n\tListDomainRecords(ctx context.Context, domainID int, opts *linodego.ListOptions) ([]linodego.DomainRecord, error)\n\tListDomains(ctx context.Context, opts *linodego.ListOptions) ([]linodego.Domain, error)\n\tCreateDomainRecord(ctx context.Context, domainID int, domainrecord linodego.DomainRecordCreateOptions) (*linodego.DomainRecord, error)\n\tDeleteDomainRecord(ctx context.Context, domainID int, id int) error\n\tUpdateDomainRecord(ctx context.Context, domainID int, id int, domainrecord linodego.DomainRecordUpdateOptions) (*linodego.DomainRecord, error)\n}\n\n// LinodeProvider is an implementation of Provider for Digital Ocean's DNS.\ntype LinodeProvider struct {\n\tprovider.BaseProvider\n\tClient       LinodeDomainClient\n\tdomainFilter *endpoint.DomainFilter\n\tDryRun       bool\n}\n\n// LinodeChanges All API calls calculated from the plan\ntype LinodeChanges struct {\n\tCreates []LinodeChangeCreate\n\tDeletes []LinodeChangeDelete\n\tUpdates []LinodeChangeUpdate\n}\n\n// LinodeChangeCreate Linode Domain Record Creates\ntype LinodeChangeCreate struct {\n\tDomain  linodego.Domain\n\tOptions linodego.DomainRecordCreateOptions\n}\n\n// LinodeChangeUpdate Linode Domain Record Updates\ntype LinodeChangeUpdate struct {\n\tDomain       linodego.Domain\n\tDomainRecord linodego.DomainRecord\n\tOptions      linodego.DomainRecordUpdateOptions\n}\n\n// LinodeChangeDelete Linode Domain Record Deletes\ntype LinodeChangeDelete struct {\n\tDomain       linodego.Domain\n\tDomainRecord linodego.DomainRecord\n}\n\n// New creates a Linode provider from the given configuration.\nfunc New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) {\n\treturn newProvider(domainFilter, cfg.DryRun)\n}\n\n// newProvider initializes a new Linode DNS based Provider.\nfunc newProvider(domainFilter *endpoint.DomainFilter, dryRun bool) (*LinodeProvider, error) {\n\ttoken, ok := os.LookupEnv(\"LINODE_TOKEN\")\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"no token found\")\n\t}\n\n\ttokenSource := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})\n\n\toauth2Client := &http.Client{\n\t\tTransport: &oauth2.Transport{\n\t\t\tSource: tokenSource,\n\t\t},\n\t}\n\n\tlinodeClient := linodego.NewClient(oauth2Client)\n\tlinodeClient.SetUserAgent(fmt.Sprintf(\"%s linodego/%s\", externaldns.UserAgent(), linodego.Version))\n\n\treturn &LinodeProvider{\n\t\tClient:       &linodeClient,\n\t\tdomainFilter: domainFilter,\n\t\tDryRun:       dryRun,\n\t}, nil\n}\n\n// Zones return the list of hosted zones.\nfunc (p *LinodeProvider) Zones(ctx context.Context) ([]linodego.Domain, error) {\n\tzones, err := p.fetchZones(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn zones, nil\n}\n\n// Records returns the list of records in a given zone.\nfunc (p *LinodeProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {\n\tzones, err := p.Zones(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar endpoints []*endpoint.Endpoint\n\n\tfor _, zone := range zones {\n\t\trecords, err := p.fetchRecords(ctx, zone.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tfor _, r := range records {\n\t\t\tif provider.SupportedRecordType(string(r.Type)) {\n\t\t\t\tname := fmt.Sprintf(\"%s.%s\", r.Name, zone.Domain)\n\n\t\t\t\t// root name is identified by the empty string and should be\n\t\t\t\t// translated to zone name for the endpoint entry.\n\t\t\t\tif r.Name == \"\" {\n\t\t\t\t\tname = zone.Domain\n\t\t\t\t}\n\n\t\t\t\tendpoints = append(endpoints, endpoint.NewEndpointWithTTL(name, string(r.Type), endpoint.TTL(r.TTLSec), r.Target))\n\t\t\t}\n\t\t}\n\t}\n\n\treturn endpoints, nil\n}\n\nfunc (p *LinodeProvider) fetchRecords(ctx context.Context, domainID int) ([]linodego.DomainRecord, error) {\n\trecords, err := p.Client.ListDomainRecords(ctx, domainID, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn records, nil\n}\n\nfunc (p *LinodeProvider) fetchZones(ctx context.Context) ([]linodego.Domain, error) {\n\tvar zones []linodego.Domain\n\n\tallZones, err := p.Client.ListDomains(ctx, linodego.NewListOptions(0, \"\"))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor _, zone := range allZones {\n\t\tif !p.domainFilter.Match(zone.Domain) {\n\t\t\tcontinue\n\t\t}\n\n\t\tzones = append(zones, zone)\n\t}\n\n\treturn zones, nil\n}\n\n// submitChanges takes a zone and a collection of Changes and sends them as a single transaction.\nfunc (p *LinodeProvider) submitChanges(ctx context.Context, changes LinodeChanges) error {\n\tfor _, change := range changes.Creates {\n\t\tlogFields := log.Fields{\n\t\t\t\"record\":   change.Options.Name,\n\t\t\t\"type\":     change.Options.Type,\n\t\t\t\"action\":   \"Create\",\n\t\t\t\"zoneName\": change.Domain.Domain,\n\t\t\t\"zoneID\":   change.Domain.ID,\n\t\t}\n\n\t\tlog.WithFields(logFields).Info(\"Creating record.\")\n\n\t\tif p.DryRun {\n\t\t\tlog.WithFields(logFields).Info(\"Would create record.\")\n\t\t} else if _, err := p.Client.CreateDomainRecord(ctx, change.Domain.ID, change.Options); err != nil {\n\t\t\tlog.WithFields(logFields).Errorf(\n\t\t\t\t\"Failed to Create record: %v\",\n\t\t\t\terr,\n\t\t\t)\n\t\t}\n\t}\n\n\tfor _, change := range changes.Deletes {\n\t\tlogFields := log.Fields{\n\t\t\t\"record\":   change.DomainRecord.Name,\n\t\t\t\"type\":     change.DomainRecord.Type,\n\t\t\t\"action\":   \"Delete\",\n\t\t\t\"zoneName\": change.Domain.Domain,\n\t\t\t\"zoneID\":   change.Domain.ID,\n\t\t}\n\n\t\tlog.WithFields(logFields).Info(\"Deleting record.\")\n\n\t\tif p.DryRun {\n\t\t\tlog.WithFields(logFields).Info(\"Would delete record.\")\n\t\t} else if err := p.Client.DeleteDomainRecord(ctx, change.Domain.ID, change.DomainRecord.ID); err != nil {\n\t\t\tlog.WithFields(logFields).Errorf(\n\t\t\t\t\"Failed to Delete record: %v\",\n\t\t\t\terr,\n\t\t\t)\n\t\t}\n\t}\n\n\tfor _, change := range changes.Updates {\n\t\tlogFields := log.Fields{\n\t\t\t\"record\":   change.Options.Name,\n\t\t\t\"type\":     change.Options.Type,\n\t\t\t\"action\":   \"Update\",\n\t\t\t\"zoneName\": change.Domain.Domain,\n\t\t\t\"zoneID\":   change.Domain.ID,\n\t\t}\n\n\t\tlog.WithFields(logFields).Info(\"Updating record.\")\n\n\t\tif p.DryRun {\n\t\t\tlog.WithFields(logFields).Info(\"Would update record.\")\n\t\t} else if _, err := p.Client.UpdateDomainRecord(ctx, change.Domain.ID, change.DomainRecord.ID, change.Options); err != nil {\n\t\t\tlog.WithFields(logFields).Errorf(\n\t\t\t\t\"Failed to Update record: %v\",\n\t\t\t\terr,\n\t\t\t)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc getWeight(recordType linodego.DomainRecordType) *int {\n\tweight := 1\n\n\t// NS records do not support having weight\n\tif recordType == linodego.RecordTypeNS {\n\t\tweight = 0\n\t}\n\treturn &weight\n}\n\nfunc getPort() *int {\n\tport := 0\n\treturn &port\n}\n\nfunc getPriority() *int {\n\tpriority := 0\n\treturn &priority\n}\n\n// ApplyChanges applies a given set of changes in a given zone.\nfunc (p *LinodeProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {\n\trecordsByZoneID := make(map[string][]linodego.DomainRecord)\n\n\tzones, err := p.fetchZones(ctx)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tzonesByID := make(map[string]linodego.Domain)\n\n\tzoneNameIDMapper := provider.ZoneIDName{}\n\n\tfor _, z := range zones {\n\t\tzoneNameIDMapper.Add(strconv.Itoa(z.ID), z.Domain)\n\t\tzonesByID[strconv.Itoa(z.ID)] = z\n\t}\n\n\t// Fetch records for each zone\n\tfor _, zone := range zones {\n\t\trecords, err := p.fetchRecords(ctx, zone.ID)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\trecordsByZoneID[strconv.Itoa(zone.ID)] = append(recordsByZoneID[strconv.Itoa(zone.ID)], records...)\n\t}\n\n\tcreatesByZone := endpointsByZone(zoneNameIDMapper, changes.Create)\n\tupdatesByZone := endpointsByZone(zoneNameIDMapper, changes.UpdateNew)\n\tdeletesByZone := endpointsByZone(zoneNameIDMapper, changes.Delete)\n\n\tvar linodeCreates []LinodeChangeCreate\n\tvar linodeUpdates []LinodeChangeUpdate\n\tvar linodeDeletes []LinodeChangeDelete\n\n\t// Generate Creates\n\tfor zoneID, creates := range createsByZone {\n\t\tzone := zonesByID[zoneID]\n\n\t\tif len(creates) == 0 {\n\t\t\tlog.WithFields(log.Fields{\n\t\t\t\t\"zoneID\":   zoneID,\n\t\t\t\t\"zoneName\": zone.Domain,\n\t\t\t}).Debug(\"Skipping Zone, no creates found.\")\n\t\t\tcontinue\n\t\t}\n\n\t\trecords := recordsByZoneID[zoneID]\n\n\t\tfor _, ep := range creates {\n\t\t\tmatchedRecords := getRecordID(records, zone, ep)\n\n\t\t\tif len(matchedRecords) != 0 {\n\t\t\t\tlog.WithFields(log.Fields{\n\t\t\t\t\t\"zoneID\":     zoneID,\n\t\t\t\t\t\"zoneName\":   zone.Domain,\n\t\t\t\t\t\"dnsName\":    ep.DNSName,\n\t\t\t\t\t\"recordType\": ep.RecordType,\n\t\t\t\t}).Warn(\"Records found which should not exist. Not touching it.\")\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\trecordType, err := convertRecordType(ep.RecordType)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tfor _, target := range ep.Targets {\n\t\t\t\tlinodeCreates = append(linodeCreates, LinodeChangeCreate{\n\t\t\t\t\tDomain: zone,\n\t\t\t\t\tOptions: linodego.DomainRecordCreateOptions{\n\t\t\t\t\t\tTarget:   target,\n\t\t\t\t\t\tName:     getStrippedRecordName(zone, ep),\n\t\t\t\t\t\tType:     recordType,\n\t\t\t\t\t\tWeight:   getWeight(recordType),\n\t\t\t\t\t\tPort:     getPort(),\n\t\t\t\t\t\tPriority: getPriority(),\n\t\t\t\t\t\tTTLSec:   int(ep.RecordTTL),\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n\n\t// Generate Updates\n\tfor zoneID, updates := range updatesByZone {\n\t\tzone := zonesByID[zoneID]\n\n\t\tif len(updates) == 0 {\n\t\t\tlog.WithFields(log.Fields{\n\t\t\t\t\"zoneID\":   zoneID,\n\t\t\t\t\"zoneName\": zone.Domain,\n\t\t\t}).Debug(\"Skipping Zone, no updates found.\")\n\t\t\tcontinue\n\t\t}\n\n\t\trecords := recordsByZoneID[zoneID]\n\n\t\tfor _, ep := range updates {\n\t\t\tmatchedRecords := getRecordID(records, zone, ep)\n\n\t\t\tif len(matchedRecords) == 0 {\n\t\t\t\tlog.WithFields(log.Fields{\n\t\t\t\t\t\"zoneID\":     zoneID,\n\t\t\t\t\t\"dnsName\":    ep.DNSName,\n\t\t\t\t\t\"zoneName\":   zone.Domain,\n\t\t\t\t\t\"recordType\": ep.RecordType,\n\t\t\t\t}).Warn(\"Update Records not found.\")\n\t\t\t}\n\n\t\t\trecordType, err := convertRecordType(ep.RecordType)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tmatchedRecordsByTarget := make(map[string]linodego.DomainRecord)\n\n\t\t\tfor _, record := range matchedRecords {\n\t\t\t\tmatchedRecordsByTarget[record.Target] = record\n\t\t\t}\n\n\t\t\tfor _, target := range ep.Targets {\n\t\t\t\tif record, ok := matchedRecordsByTarget[target]; ok {\n\t\t\t\t\tlog.WithFields(log.Fields{\n\t\t\t\t\t\t\"zoneID\":     zoneID,\n\t\t\t\t\t\t\"dnsName\":    ep.DNSName,\n\t\t\t\t\t\t\"zoneName\":   zone.Domain,\n\t\t\t\t\t\t\"recordType\": ep.RecordType,\n\t\t\t\t\t\t\"target\":     target,\n\t\t\t\t\t}).Warn(\"Updating Existing Target\")\n\n\t\t\t\t\tlinodeUpdates = append(linodeUpdates, LinodeChangeUpdate{\n\t\t\t\t\t\tDomain:       zone,\n\t\t\t\t\t\tDomainRecord: record,\n\t\t\t\t\t\tOptions: linodego.DomainRecordUpdateOptions{\n\t\t\t\t\t\t\tTarget:   target,\n\t\t\t\t\t\t\tName:     getStrippedRecordName(zone, ep),\n\t\t\t\t\t\t\tType:     recordType,\n\t\t\t\t\t\t\tWeight:   getWeight(recordType),\n\t\t\t\t\t\t\tPort:     getPort(),\n\t\t\t\t\t\t\tPriority: getPriority(),\n\t\t\t\t\t\t\tTTLSec:   int(ep.RecordTTL),\n\t\t\t\t\t\t},\n\t\t\t\t\t})\n\n\t\t\t\t\tdelete(matchedRecordsByTarget, target)\n\t\t\t\t} else {\n\t\t\t\t\t// Record did not previously exist, create new 'target'\n\t\t\t\t\tlog.WithFields(log.Fields{\n\t\t\t\t\t\t\"zoneID\":     zoneID,\n\t\t\t\t\t\t\"dnsName\":    ep.DNSName,\n\t\t\t\t\t\t\"zoneName\":   zone.Domain,\n\t\t\t\t\t\t\"recordType\": ep.RecordType,\n\t\t\t\t\t\t\"target\":     target,\n\t\t\t\t\t}).Warn(\"Creating New Target\")\n\n\t\t\t\t\tlinodeCreates = append(linodeCreates, LinodeChangeCreate{\n\t\t\t\t\t\tDomain: zone,\n\t\t\t\t\t\tOptions: linodego.DomainRecordCreateOptions{\n\t\t\t\t\t\t\tTarget:   target,\n\t\t\t\t\t\t\tName:     getStrippedRecordName(zone, ep),\n\t\t\t\t\t\t\tType:     recordType,\n\t\t\t\t\t\t\tWeight:   getWeight(recordType),\n\t\t\t\t\t\t\tPort:     getPort(),\n\t\t\t\t\t\t\tPriority: getPriority(),\n\t\t\t\t\t\t\tTTLSec:   int(ep.RecordTTL),\n\t\t\t\t\t\t},\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Any remaining records have been removed, delete them\n\t\t\tfor _, record := range matchedRecordsByTarget {\n\t\t\t\tlog.WithFields(log.Fields{\n\t\t\t\t\t\"zoneID\":     zoneID,\n\t\t\t\t\t\"dnsName\":    ep.DNSName,\n\t\t\t\t\t\"zoneName\":   zone.Domain,\n\t\t\t\t\t\"recordType\": ep.RecordType,\n\t\t\t\t\t\"target\":     record.Target,\n\t\t\t\t}).Warn(\"Deleting Target\")\n\n\t\t\t\tlinodeDeletes = append(linodeDeletes, LinodeChangeDelete{\n\t\t\t\t\tDomain:       zone,\n\t\t\t\t\tDomainRecord: record,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n\n\t// Generate Deletes\n\tfor zoneID, deletes := range deletesByZone {\n\t\tzone := zonesByID[zoneID]\n\n\t\tif len(deletes) == 0 {\n\t\t\tlog.WithFields(log.Fields{\n\t\t\t\t\"zoneID\":   zoneID,\n\t\t\t\t\"zoneName\": zone.Domain,\n\t\t\t}).Debug(\"Skipping Zone, no deletes found.\")\n\t\t\tcontinue\n\t\t}\n\n\t\trecords := recordsByZoneID[zoneID]\n\n\t\tfor _, ep := range deletes {\n\t\t\tmatchedRecords := getRecordID(records, zone, ep)\n\n\t\t\tif len(matchedRecords) == 0 {\n\t\t\t\tlog.WithFields(log.Fields{\n\t\t\t\t\t\"zoneID\":     zoneID,\n\t\t\t\t\t\"dnsName\":    ep.DNSName,\n\t\t\t\t\t\"zoneName\":   zone.Domain,\n\t\t\t\t\t\"recordType\": ep.RecordType,\n\t\t\t\t}).Warn(\"Records to Delete not found.\")\n\t\t\t}\n\n\t\t\tfor _, record := range matchedRecords {\n\t\t\t\tlinodeDeletes = append(linodeDeletes, LinodeChangeDelete{\n\t\t\t\t\tDomain:       zone,\n\t\t\t\t\tDomainRecord: record,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n\n\treturn p.submitChanges(ctx, LinodeChanges{\n\t\tCreates: linodeCreates,\n\t\tDeletes: linodeDeletes,\n\t\tUpdates: linodeUpdates,\n\t})\n}\n\nfunc endpointsByZone(zoneNameIDMapper provider.ZoneIDName, endpoints []*endpoint.Endpoint) map[string][]endpoint.Endpoint {\n\tendpointsByZone := make(map[string][]endpoint.Endpoint)\n\n\tfor _, ep := range endpoints {\n\t\tzoneID, _ := zoneNameIDMapper.FindZone(ep.DNSName)\n\t\tif zoneID == \"\" {\n\t\t\tlog.Debugf(\"Skipping record %s because no hosted zone matching record DNS Name was detected\", ep.DNSName)\n\t\t\tcontinue\n\t\t}\n\t\tendpointsByZone[zoneID] = append(endpointsByZone[zoneID], *ep)\n\t}\n\n\treturn endpointsByZone\n}\n\nfunc convertRecordType(recordType string) (linodego.DomainRecordType, error) {\n\tswitch recordType {\n\tcase \"A\":\n\t\treturn linodego.RecordTypeA, nil\n\tcase \"AAAA\":\n\t\treturn linodego.RecordTypeAAAA, nil\n\tcase \"CNAME\":\n\t\treturn linodego.RecordTypeCNAME, nil\n\tcase \"TXT\":\n\t\treturn linodego.RecordTypeTXT, nil\n\tcase \"SRV\":\n\t\treturn linodego.RecordTypeSRV, nil\n\tcase \"NS\":\n\t\treturn linodego.RecordTypeNS, nil\n\tdefault:\n\t\treturn \"\", fmt.Errorf(\"invalid Record Type: %s\", recordType)\n\t}\n}\n\nfunc getStrippedRecordName(zone linodego.Domain, ep endpoint.Endpoint) string {\n\t// Handle root\n\tif ep.DNSName == zone.Domain {\n\t\treturn \"\"\n\t}\n\n\treturn strings.TrimSuffix(ep.DNSName, \".\"+zone.Domain)\n}\n\nfunc getRecordID(records []linodego.DomainRecord, zone linodego.Domain, ep endpoint.Endpoint) []linodego.DomainRecord {\n\tvar matchedRecords []linodego.DomainRecord\n\n\tfor _, record := range records {\n\t\tif record.Name == getStrippedRecordName(zone, ep) && string(record.Type) == ep.RecordType {\n\t\t\tmatchedRecords = append(matchedRecords, record)\n\t\t}\n\t}\n\n\treturn matchedRecords\n}\n"
  },
  {
    "path": "provider/linode/linode_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage linode\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"testing\"\n\n\t\"github.com/linode/linodego\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/plan\"\n)\n\ntype MockDomainClient struct {\n\tmock.Mock\n}\n\nfunc (m *MockDomainClient) ListDomainRecords(ctx context.Context, domainID int, opts *linodego.ListOptions) ([]linodego.DomainRecord, error) {\n\targs := m.Called(ctx, domainID, opts)\n\treturn args.Get(0).([]linodego.DomainRecord), args.Error(1)\n}\n\nfunc (m *MockDomainClient) ListDomains(ctx context.Context, opts *linodego.ListOptions) ([]linodego.Domain, error) {\n\targs := m.Called(ctx, opts)\n\treturn args.Get(0).([]linodego.Domain), args.Error(1)\n}\n\nfunc (m *MockDomainClient) CreateDomainRecord(ctx context.Context, domainID int, opts linodego.DomainRecordCreateOptions) (*linodego.DomainRecord, error) {\n\targs := m.Called(ctx, domainID, opts)\n\treturn args.Get(0).(*linodego.DomainRecord), args.Error(1)\n}\n\nfunc (m *MockDomainClient) DeleteDomainRecord(ctx context.Context, domainID int, recordID int) error {\n\targs := m.Called(ctx, domainID, recordID)\n\treturn args.Error(0)\n}\n\nfunc (m *MockDomainClient) UpdateDomainRecord(ctx context.Context, domainID int, recordID int, opts linodego.DomainRecordUpdateOptions) (*linodego.DomainRecord, error) {\n\targs := m.Called(ctx, domainID, recordID, opts)\n\treturn args.Get(0).(*linodego.DomainRecord), args.Error(1)\n}\n\nfunc createZones() []linodego.Domain {\n\treturn []linodego.Domain{\n\t\t{ID: 1, Domain: \"foo.com\"},\n\t\t{ID: 2, Domain: \"bar.io\"},\n\t\t{ID: 3, Domain: \"baz.com\"},\n\t}\n}\n\nfunc createFooRecords() []linodego.DomainRecord {\n\treturn []linodego.DomainRecord{{\n\t\tID:     11,\n\t\tType:   linodego.RecordTypeA,\n\t\tName:   \"\",\n\t\tTarget: \"targetFoo\",\n\t}, {\n\t\tID:     12,\n\t\tType:   linodego.RecordTypeTXT,\n\t\tName:   \"\",\n\t\tTarget: \"txt\",\n\t}, {\n\t\tID:     13,\n\t\tType:   linodego.RecordTypeCAA,\n\t\tName:   \"foo.com\",\n\t\tTarget: \"\",\n\t}}\n}\n\nfunc createBarRecords() []linodego.DomainRecord {\n\treturn []linodego.DomainRecord{}\n}\n\nfunc createBazRecords() []linodego.DomainRecord {\n\treturn []linodego.DomainRecord{{\n\t\tID:     31,\n\t\tType:   linodego.RecordTypeA,\n\t\tName:   \"\",\n\t\tTarget: \"targetBaz\",\n\t}, {\n\t\tID:     32,\n\t\tType:   linodego.RecordTypeTXT,\n\t\tName:   \"\",\n\t\tTarget: \"txt\",\n\t}, {\n\t\tID:     33,\n\t\tType:   linodego.RecordTypeA,\n\t\tName:   \"api\",\n\t\tTarget: \"targetBaz\",\n\t}, {\n\t\tID:     34,\n\t\tType:   linodego.RecordTypeTXT,\n\t\tName:   \"api\",\n\t\tTarget: \"txt\",\n\t}}\n}\n\nfunc TestLinodeConvertRecordType(t *testing.T) {\n\trecord, err := convertRecordType(\"A\")\n\trequire.NoError(t, err)\n\tassert.Equal(t, linodego.RecordTypeA, record)\n\n\trecord, err = convertRecordType(\"AAAA\")\n\trequire.NoError(t, err)\n\tassert.Equal(t, linodego.RecordTypeAAAA, record)\n\n\trecord, err = convertRecordType(\"CNAME\")\n\trequire.NoError(t, err)\n\tassert.Equal(t, linodego.RecordTypeCNAME, record)\n\n\trecord, err = convertRecordType(\"TXT\")\n\trequire.NoError(t, err)\n\tassert.Equal(t, linodego.RecordTypeTXT, record)\n\n\trecord, err = convertRecordType(\"SRV\")\n\trequire.NoError(t, err)\n\tassert.Equal(t, linodego.RecordTypeSRV, record)\n\n\trecord, err = convertRecordType(\"NS\")\n\trequire.NoError(t, err)\n\tassert.Equal(t, linodego.RecordTypeNS, record)\n\n\t_, err = convertRecordType(\"INVALID\")\n\trequire.Error(t, err)\n}\n\nfunc TestNewProvider(t *testing.T) {\n\tt.Setenv(\"LINODE_TOKEN\", \"xxxxxxxxxxxxxxxxx\")\n\t_, err := newProvider(endpoint.NewDomainFilter([]string{\"ext-dns-test.zalando.to.\"}), true)\n\trequire.NoError(t, err)\n\n\t_ = os.Unsetenv(\"LINODE_TOKEN\")\n\t_, err = newProvider(endpoint.NewDomainFilter([]string{\"ext-dns-test.zalando.to.\"}), true)\n\trequire.Error(t, err)\n}\n\nfunc TestLinodeStripRecordName(t *testing.T) {\n\tassert.Equal(t, \"api\", getStrippedRecordName(linodego.Domain{\n\t\tDomain: \"example.com\",\n\t}, endpoint.Endpoint{\n\t\tDNSName: \"api.example.com\",\n\t}))\n\n\tassert.Empty(t, getStrippedRecordName(linodego.Domain{\n\t\tDomain: \"example.com\",\n\t}, endpoint.Endpoint{\n\t\tDNSName: \"example.com\",\n\t}))\n}\n\nfunc TestLinodeFetchZonesNoFilters(t *testing.T) {\n\tmockDomainClient := MockDomainClient{}\n\n\tprovider := &LinodeProvider{\n\t\tClient:       &mockDomainClient,\n\t\tdomainFilter: endpoint.NewDomainFilter([]string{}),\n\t\tDryRun:       false,\n\t}\n\n\tmockDomainClient.On(\n\t\t\"ListDomains\",\n\t\tmock.Anything,\n\t\tmock.Anything,\n\t).Return(createZones(), nil).Once()\n\n\texpected := createZones()\n\tactual, err := provider.fetchZones(t.Context())\n\trequire.NoError(t, err)\n\n\tmockDomainClient.AssertExpectations(t)\n\tassert.Equal(t, expected, actual)\n}\n\nfunc TestLinodeFetchZonesWithFilter(t *testing.T) {\n\tmockDomainClient := MockDomainClient{}\n\n\tprovider := &LinodeProvider{\n\t\tClient:       &mockDomainClient,\n\t\tdomainFilter: endpoint.NewDomainFilter([]string{\".com\"}),\n\t\tDryRun:       false,\n\t}\n\n\tmockDomainClient.On(\n\t\t\"ListDomains\",\n\t\tmock.Anything,\n\t\tmock.Anything,\n\t).Return(createZones(), nil).Once()\n\n\texpected := []linodego.Domain{\n\t\t{ID: 1, Domain: \"foo.com\"},\n\t\t{ID: 3, Domain: \"baz.com\"},\n\t}\n\tactual, err := provider.fetchZones(t.Context())\n\trequire.NoError(t, err)\n\n\tmockDomainClient.AssertExpectations(t)\n\tassert.Equal(t, expected, actual)\n}\n\nfunc TestLinodeGetStrippedRecordName(t *testing.T) {\n\tassert.Empty(t, getStrippedRecordName(linodego.Domain{\n\t\tDomain: \"foo.com\",\n\t}, endpoint.Endpoint{\n\t\tDNSName: \"foo.com\",\n\t}))\n\n\tassert.Equal(t, \"api\", getStrippedRecordName(linodego.Domain{\n\t\tDomain: \"foo.com\",\n\t}, endpoint.Endpoint{\n\t\tDNSName: \"api.foo.com\",\n\t}))\n}\n\nfunc TestLinodeRecords(t *testing.T) {\n\tmockDomainClient := MockDomainClient{}\n\n\tprovider := &LinodeProvider{\n\t\tClient:       &mockDomainClient,\n\t\tdomainFilter: endpoint.NewDomainFilter([]string{}),\n\t\tDryRun:       false,\n\t}\n\n\tmockDomainClient.On(\n\t\t\"ListDomains\",\n\t\tmock.Anything,\n\t\tmock.Anything,\n\t).Return(createZones(), nil).Once()\n\n\tmockDomainClient.On(\n\t\t\"ListDomainRecords\",\n\t\tmock.Anything,\n\t\t1,\n\t\tmock.Anything,\n\t).Return(createFooRecords(), nil).Once()\n\tmockDomainClient.On(\n\t\t\"ListDomainRecords\",\n\t\tmock.Anything,\n\t\t2,\n\t\tmock.Anything,\n\t).Return(createBarRecords(), nil).Once()\n\tmockDomainClient.On(\n\t\t\"ListDomainRecords\",\n\t\tmock.Anything,\n\t\t3,\n\t\tmock.Anything,\n\t).Return(createBazRecords(), nil).Once()\n\n\tactual, err := provider.Records(t.Context())\n\trequire.NoError(t, err)\n\n\texpected := []*endpoint.Endpoint{\n\t\t{DNSName: \"foo.com\", Targets: []string{\"targetFoo\"}, RecordType: \"A\", RecordTTL: 0, Labels: endpoint.NewLabels()},\n\t\t{DNSName: \"foo.com\", Targets: []string{\"txt\"}, RecordType: \"TXT\", RecordTTL: 0, Labels: endpoint.NewLabels()},\n\t\t{DNSName: \"baz.com\", Targets: []string{\"targetBaz\"}, RecordType: \"A\", RecordTTL: 0, Labels: endpoint.NewLabels()},\n\t\t{DNSName: \"baz.com\", Targets: []string{\"txt\"}, RecordType: \"TXT\", RecordTTL: 0, Labels: endpoint.NewLabels()},\n\t\t{DNSName: \"api.baz.com\", Targets: []string{\"targetBaz\"}, RecordType: \"A\", RecordTTL: 0, Labels: endpoint.NewLabels()},\n\t\t{DNSName: \"api.baz.com\", Targets: []string{\"txt\"}, RecordType: \"TXT\", RecordTTL: 0, Labels: endpoint.NewLabels()},\n\t}\n\n\tmockDomainClient.AssertExpectations(t)\n\tassert.Equal(t, expected, actual)\n}\n\nfunc TestLinodeApplyChanges(t *testing.T) {\n\tmockDomainClient := MockDomainClient{}\n\n\tprovider := &LinodeProvider{\n\t\tClient:       &mockDomainClient,\n\t\tdomainFilter: endpoint.NewDomainFilter([]string{}),\n\t\tDryRun:       false,\n\t}\n\n\t// Dummy Data\n\tmockDomainClient.On(\n\t\t\"ListDomains\",\n\t\tmock.Anything,\n\t\tmock.Anything,\n\t).Return(createZones(), nil).Once()\n\n\tmockDomainClient.On(\n\t\t\"ListDomainRecords\",\n\t\tmock.Anything,\n\t\t1,\n\t\tmock.Anything,\n\t).Return(createFooRecords(), nil).Once()\n\tmockDomainClient.On(\n\t\t\"ListDomainRecords\",\n\t\tmock.Anything,\n\t\t2,\n\t\tmock.Anything,\n\t).Return(createBarRecords(), nil).Once()\n\tmockDomainClient.On(\n\t\t\"ListDomainRecords\",\n\t\tmock.Anything,\n\t\t3,\n\t\tmock.Anything,\n\t).Return(createBazRecords(), nil).Once()\n\n\t// Apply Actions\n\tmockDomainClient.On(\n\t\t\"DeleteDomainRecord\",\n\t\tmock.Anything,\n\t\t3,\n\t\t33,\n\t).Return(nil).Once()\n\n\tmockDomainClient.On(\n\t\t\"DeleteDomainRecord\",\n\t\tmock.Anything,\n\t\t3,\n\t\t34,\n\t).Return(nil).Once()\n\n\tmockDomainClient.On(\n\t\t\"UpdateDomainRecord\",\n\t\tmock.Anything,\n\t\t1,\n\t\t11,\n\t\tlinodego.DomainRecordUpdateOptions{\n\t\t\tType: \"A\", Name: \"\", Target: \"targetFoo\",\n\t\t\tPriority: getPriority(), Weight: getWeight(linodego.RecordTypeA), Port: getPort(), TTLSec: 300,\n\t\t},\n\t).Return(&linodego.DomainRecord{}, nil).Once()\n\n\tmockDomainClient.On(\n\t\t\"CreateDomainRecord\",\n\t\tmock.Anything,\n\t\t2,\n\t\tlinodego.DomainRecordCreateOptions{\n\t\t\tType: \"A\", Name: \"create\", Target: \"targetBar\",\n\t\t\tPriority: getPriority(), Weight: getWeight(linodego.RecordTypeA), Port: getPort(), TTLSec: 0,\n\t\t},\n\t).Return(&linodego.DomainRecord{}, nil).Once()\n\n\tmockDomainClient.On(\n\t\t\"CreateDomainRecord\",\n\t\tmock.Anything,\n\t\t2,\n\t\tlinodego.DomainRecordCreateOptions{\n\t\t\tType: \"A\", Name: \"\", Target: \"targetBar\",\n\t\t\tPriority: getPriority(), Weight: getWeight(linodego.RecordTypeA), Port: getPort(), TTLSec: 0,\n\t\t},\n\t).Return(&linodego.DomainRecord{}, nil).Once()\n\n\terr := provider.ApplyChanges(t.Context(), &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{{\n\t\t\tDNSName:    \"create.bar.io\",\n\t\t\tRecordType: \"A\",\n\t\t\tTargets:    []string{\"targetBar\"},\n\t\t}, {\n\t\t\tDNSName:    \"bar.io\",\n\t\t\tRecordType: \"A\",\n\t\t\tTargets:    []string{\"targetBar\"},\n\t\t}, {\n\t\t\t// This record should be skipped as it already exists\n\t\t\tDNSName:    \"foo.com\",\n\t\t\tRecordType: \"TXT\",\n\t\t\tTargets:    []string{\"txt\"},\n\t\t}},\n\t\tDelete: []*endpoint.Endpoint{{\n\t\t\tDNSName:    \"api.baz.com\",\n\t\t\tRecordType: \"A\",\n\t\t}, {\n\t\t\tDNSName:    \"api.baz.com\",\n\t\t\tRecordType: \"TXT\",\n\t\t}},\n\t\tUpdateNew: []*endpoint.Endpoint{{\n\t\t\tDNSName:    \"foo.com\",\n\t\t\tRecordType: \"A\",\n\t\t\tRecordTTL:  300,\n\t\t\tTargets:    []string{\"targetFoo\"},\n\t\t}},\n\t\tUpdateOld: []*endpoint.Endpoint{},\n\t})\n\trequire.NoError(t, err)\n\n\tmockDomainClient.AssertExpectations(t)\n}\n\nfunc TestLinodeApplyChangesTargetAdded(t *testing.T) {\n\tmockDomainClient := MockDomainClient{}\n\n\tprovider := &LinodeProvider{\n\t\tClient:       &mockDomainClient,\n\t\tdomainFilter: endpoint.NewDomainFilter([]string{}),\n\t\tDryRun:       false,\n\t}\n\n\t// Dummy Data\n\tmockDomainClient.On(\n\t\t\"ListDomains\",\n\t\tmock.Anything,\n\t\tmock.Anything,\n\t).Return([]linodego.Domain{{Domain: \"example.com\", ID: 1}}, nil).Once()\n\n\tmockDomainClient.On(\n\t\t\"ListDomainRecords\",\n\t\tmock.Anything,\n\t\t1,\n\t\tmock.Anything,\n\t).Return([]linodego.DomainRecord{{ID: 11, Name: \"\", Type: \"A\", Target: \"targetA\"}}, nil).Once()\n\n\t// Apply Actions\n\tmockDomainClient.On(\n\t\t\"UpdateDomainRecord\",\n\t\tmock.Anything,\n\t\t1,\n\t\t11,\n\t\tlinodego.DomainRecordUpdateOptions{\n\t\t\tType: \"A\", Name: \"\", Target: \"targetA\",\n\t\t\tPriority: getPriority(), Weight: getWeight(linodego.RecordTypeA), Port: getPort(),\n\t\t},\n\t).Return(&linodego.DomainRecord{}, nil).Once()\n\n\tmockDomainClient.On(\n\t\t\"CreateDomainRecord\",\n\t\tmock.Anything,\n\t\t1,\n\t\tlinodego.DomainRecordCreateOptions{\n\t\t\tType: \"A\", Name: \"\", Target: \"targetB\",\n\t\t\tPriority: getPriority(), Weight: getWeight(linodego.RecordTypeA), Port: getPort(),\n\t\t},\n\t).Return(&linodego.DomainRecord{}, nil).Once()\n\n\terr := provider.ApplyChanges(t.Context(), &plan.Changes{\n\t\t// From 1 target to 2\n\t\tUpdateNew: []*endpoint.Endpoint{{\n\t\t\tDNSName:    \"example.com\",\n\t\t\tRecordType: \"A\",\n\t\t\tTargets:    []string{\"targetA\", \"targetB\"},\n\t\t}},\n\t\tUpdateOld: []*endpoint.Endpoint{},\n\t})\n\trequire.NoError(t, err)\n\n\tmockDomainClient.AssertExpectations(t)\n}\n\nfunc TestLinodeApplyChangesTargetRemoved(t *testing.T) {\n\tmockDomainClient := MockDomainClient{}\n\n\tprovider := &LinodeProvider{\n\t\tClient:       &mockDomainClient,\n\t\tdomainFilter: endpoint.NewDomainFilter([]string{}),\n\t\tDryRun:       false,\n\t}\n\n\t// Dummy Data\n\tmockDomainClient.On(\n\t\t\"ListDomains\",\n\t\tmock.Anything,\n\t\tmock.Anything,\n\t).Return([]linodego.Domain{{Domain: \"example.com\", ID: 1}}, nil).Once()\n\n\tmockDomainClient.On(\n\t\t\"ListDomainRecords\",\n\t\tmock.Anything,\n\t\t1,\n\t\tmock.Anything,\n\t).Return([]linodego.DomainRecord{{ID: 11, Name: \"\", Type: \"A\", Target: \"targetA\"}, {ID: 12, Type: \"A\", Name: \"\", Target: \"targetB\"}}, nil).Once()\n\n\t// Apply Actions\n\tmockDomainClient.On(\n\t\t\"UpdateDomainRecord\",\n\t\tmock.Anything,\n\t\t1,\n\t\t12,\n\t\tlinodego.DomainRecordUpdateOptions{\n\t\t\tType: \"A\", Name: \"\", Target: \"targetB\",\n\t\t\tPriority: getPriority(), Weight: getWeight(linodego.RecordTypeA), Port: getPort(),\n\t\t},\n\t).Return(&linodego.DomainRecord{}, nil).Once()\n\n\tmockDomainClient.On(\n\t\t\"DeleteDomainRecord\",\n\t\tmock.Anything,\n\t\t1,\n\t\t11,\n\t).Return(nil).Once()\n\n\terr := provider.ApplyChanges(t.Context(), &plan.Changes{\n\t\t// From 2 targets to 1\n\t\tUpdateNew: []*endpoint.Endpoint{{\n\t\t\tDNSName:    \"example.com\",\n\t\t\tRecordType: \"A\",\n\t\t\tTargets:    []string{\"targetB\"},\n\t\t}},\n\t\tUpdateOld: []*endpoint.Endpoint{},\n\t})\n\trequire.NoError(t, err)\n\n\tmockDomainClient.AssertExpectations(t)\n}\n\nfunc TestLinodeApplyChangesNoChanges(t *testing.T) {\n\tmockDomainClient := MockDomainClient{}\n\n\tprovider := &LinodeProvider{\n\t\tClient:       &mockDomainClient,\n\t\tdomainFilter: endpoint.NewDomainFilter([]string{}),\n\t\tDryRun:       false,\n\t}\n\n\t// Dummy Data\n\tmockDomainClient.On(\n\t\t\"ListDomains\",\n\t\tmock.Anything,\n\t\tmock.Anything,\n\t).Return([]linodego.Domain{{Domain: \"example.com\", ID: 1}}, nil).Once()\n\n\tmockDomainClient.On(\n\t\t\"ListDomainRecords\",\n\t\tmock.Anything,\n\t\t1,\n\t\tmock.Anything,\n\t).Return([]linodego.DomainRecord{{ID: 11, Name: \"\", Type: \"A\", Target: \"targetA\"}}, nil).Once()\n\n\terr := provider.ApplyChanges(t.Context(), &plan.Changes{})\n\trequire.NoError(t, err)\n\n\tmockDomainClient.AssertExpectations(t)\n}\n"
  },
  {
    "path": "provider/ns1/ns1.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage ns1\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n\t\"strings\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\tapi \"gopkg.in/ns1/ns1-go.v2/rest\"\n\t\"gopkg.in/ns1/ns1-go.v2/rest/model/dns\"\n\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\nconst (\n\t// ns1Create is a ChangeAction enum value\n\tns1Create = \"CREATE\"\n\t// ns1Delete is a ChangeAction enum value\n\tns1Delete = \"DELETE\"\n\t// ns1Update is a ChangeAction enum value\n\tns1Update = \"UPDATE\"\n\t// defaultTTL is the default ttl for ttls that are not set\n\tdefaultTTL = 10\n)\n\n// NS1DomainClient is a subset of the NS1 API the provider uses, to ease testing\ntype NS1DomainClient interface {\n\tCreateRecord(r *dns.Record) (*http.Response, error)\n\tDeleteRecord(zone string, domain string, t string) (*http.Response, error)\n\tUpdateRecord(r *dns.Record) (*http.Response, error)\n\tGetZone(zone string) (*dns.Zone, *http.Response, error)\n\tListZones() ([]*dns.Zone, *http.Response, error)\n}\n\n// NS1DomainService wraps the API and fulfills the NS1DomainClient interface\ntype NS1DomainService struct {\n\tservice *api.Client\n}\n\n// CreateRecord wraps the Create method of the API's Record service\nfunc (n NS1DomainService) CreateRecord(r *dns.Record) (*http.Response, error) {\n\treturn n.service.Records.Create(r)\n}\n\n// DeleteRecord wraps the Delete method of the API's Record service\nfunc (n NS1DomainService) DeleteRecord(zone string, domain string, t string) (*http.Response, error) {\n\treturn n.service.Records.Delete(zone, domain, t)\n}\n\n// UpdateRecord wraps the Update method of the API's Record service\nfunc (n NS1DomainService) UpdateRecord(r *dns.Record) (*http.Response, error) {\n\treturn n.service.Records.Update(r)\n}\n\n// GetZone wraps the Get method of the API's Zones service\nfunc (n NS1DomainService) GetZone(zone string) (*dns.Zone, *http.Response, error) {\n\treturn n.service.Zones.Get(zone, true)\n}\n\n// ListZones wraps the List method of the API's Zones service\nfunc (n NS1DomainService) ListZones() ([]*dns.Zone, *http.Response, error) {\n\treturn n.service.Zones.List()\n}\n\n// NS1Config passes cli args to the NS1Provider\ntype NS1Config struct {\n\tDomainFilter  *endpoint.DomainFilter\n\tZoneIDFilter  provider.ZoneIDFilter\n\tNS1Endpoint   string\n\tNS1IgnoreSSL  bool\n\tDryRun        bool\n\tMinTTLSeconds int\n}\n\n// NS1Provider is the NS1 provider\ntype NS1Provider struct {\n\tprovider.BaseProvider\n\tclient        NS1DomainClient\n\tdomainFilter  *endpoint.DomainFilter\n\tzoneIDFilter  provider.ZoneIDFilter\n\tdryRun        bool\n\tminTTLSeconds int\n}\n\n// New creates an NS1 provider from the given configuration.\nfunc New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) {\n\treturn newProvider(\n\t\tNS1Config{\n\t\t\tDomainFilter:  domainFilter,\n\t\t\tZoneIDFilter:  provider.NewZoneIDFilter(cfg.ZoneIDFilter),\n\t\t\tNS1Endpoint:   cfg.NS1Endpoint,\n\t\t\tNS1IgnoreSSL:  cfg.NS1IgnoreSSL,\n\t\t\tDryRun:        cfg.DryRun,\n\t\t\tMinTTLSeconds: cfg.NS1MinTTLSeconds,\n\t\t},\n\t)\n}\n\n// newProvider creates a new NS1 Provider\nfunc newProvider(config NS1Config) (*NS1Provider, error) {\n\treturn newNS1ProviderWithHTTPClient(config, http.DefaultClient)\n}\n\nfunc newNS1ProviderWithHTTPClient(config NS1Config, client *http.Client) (*NS1Provider, error) {\n\ttoken, ok := os.LookupEnv(\"NS1_APIKEY\")\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"NS1_APIKEY environment variable is not set\")\n\t}\n\tclientArgs := []func(*api.Client){api.SetAPIKey(token)}\n\tif config.NS1Endpoint != \"\" {\n\t\tlog.Infof(\"ns1-endpoint flag is set, targeting endpoint at %s\", config.NS1Endpoint)\n\t\tclientArgs = append(clientArgs, api.SetEndpoint(config.NS1Endpoint))\n\t}\n\n\tif config.NS1IgnoreSSL {\n\t\tlog.Info(\"ns1-ignoressl flag is True, skipping SSL verification\")\n\t\tdefaultTransport := http.DefaultTransport.(*http.Transport)\n\t\ttr := &http.Transport{\n\t\t\tProxy:                 defaultTransport.Proxy,\n\t\t\tDialContext:           defaultTransport.DialContext,\n\t\t\tMaxIdleConns:          defaultTransport.MaxIdleConns,\n\t\t\tIdleConnTimeout:       defaultTransport.IdleConnTimeout,\n\t\t\tExpectContinueTimeout: defaultTransport.ExpectContinueTimeout,\n\t\t\tTLSHandshakeTimeout:   defaultTransport.TLSHandshakeTimeout,\n\t\t\tTLSClientConfig:       &tls.Config{InsecureSkipVerify: true},\n\t\t}\n\t\tclient.Transport = tr\n\t}\n\n\tapiClient := api.NewClient(client, clientArgs...)\n\n\treturn &NS1Provider{\n\t\tclient:        NS1DomainService{apiClient},\n\t\tdomainFilter:  config.DomainFilter,\n\t\tzoneIDFilter:  config.ZoneIDFilter,\n\t\tminTTLSeconds: config.MinTTLSeconds,\n\t}, nil\n}\n\n// Records returns the endpoints this provider knows about\nfunc (p *NS1Provider) Records(_ context.Context) ([]*endpoint.Endpoint, error) {\n\tzones, err := p.zonesFiltered()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar endpoints []*endpoint.Endpoint\n\n\tfor _, zone := range zones {\n\t\t// TODO handle Header Codes\n\t\tzoneData, _, err := p.client.GetZone(zone.String())\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tfor _, record := range zoneData.Records {\n\t\t\tif provider.SupportedRecordType(record.Type) {\n\t\t\t\tendpoints = append(endpoints, endpoint.NewEndpointWithTTL(\n\t\t\t\t\trecord.Domain,\n\t\t\t\t\trecord.Type,\n\t\t\t\t\tendpoint.TTL(record.TTL),\n\t\t\t\t\trecord.ShortAns...,\n\t\t\t\t),\n\t\t\t\t)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn endpoints, nil\n}\n\n// ns1BuildRecord returns a dns.Record for a change set\nfunc (p *NS1Provider) ns1BuildRecord(zoneName string, change *ns1Change) *dns.Record {\n\trecord := dns.NewRecord(zoneName, change.Endpoint.DNSName, change.Endpoint.RecordType, map[string]string{}, []string{})\n\tfor _, v := range change.Endpoint.Targets {\n\t\trecord.AddAnswer(dns.NewAnswer(strings.Split(v, \" \")))\n\t}\n\t// set default ttl, but respect minTTLSeconds\n\tttl := max(p.minTTLSeconds, defaultTTL)\n\tif change.Endpoint.RecordTTL.IsConfigured() {\n\t\tttl = int(change.Endpoint.RecordTTL)\n\t}\n\trecord.TTL = ttl\n\n\treturn record\n}\n\n// ns1SubmitChanges takes an array of changes and sends them to NS1\nfunc (p *NS1Provider) ns1SubmitChanges(changes []*ns1Change) error {\n\t// return early if there is nothing to change\n\tif len(changes) == 0 {\n\t\treturn nil\n\t}\n\n\tzones, err := p.zonesFiltered()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// separate into per-zone change sets to be passed to the API.\n\tchangesByZone := ns1ChangesByZone(zones, changes)\n\tfor zoneName, changes := range changesByZone {\n\t\tfor _, change := range changes {\n\t\t\trecord := p.ns1BuildRecord(zoneName, change)\n\t\t\tlogFields := log.Fields{\n\t\t\t\t\"record\": record.Domain,\n\t\t\t\t\"type\":   record.Type,\n\t\t\t\t\"ttl\":    record.TTL,\n\t\t\t\t\"action\": change.Action,\n\t\t\t\t\"zone\":   zoneName,\n\t\t\t}\n\n\t\t\tlog.WithFields(logFields).Info(\"Changing record.\")\n\n\t\t\tif p.dryRun {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tswitch change.Action {\n\t\t\tcase ns1Create:\n\t\t\t\t_, err := p.client.CreateRecord(record)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\tcase ns1Delete:\n\t\t\t\t_, err := p.client.DeleteRecord(zoneName, record.Domain, record.Type)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\tcase ns1Update:\n\t\t\t\t_, err := p.client.UpdateRecord(record)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n\n// Zones returns the list of hosted zones.\nfunc (p *NS1Provider) zonesFiltered() ([]*dns.Zone, error) {\n\t// TODO handle Header Codes\n\tzones, _, err := p.client.ListZones()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar toReturn []*dns.Zone\n\n\tfor _, z := range zones {\n\t\tif p.domainFilter.Match(z.Zone) && p.zoneIDFilter.Match(z.ID) {\n\t\t\ttoReturn = append(toReturn, z)\n\t\t\tlog.Debugf(\"Matched %s\", z.Zone)\n\t\t} else {\n\t\t\tlog.Debugf(\"Filtered %s\", z.Zone)\n\t\t}\n\t}\n\n\treturn toReturn, nil\n}\n\n// ns1Change differentiates between ChangeActions\ntype ns1Change struct {\n\tAction   string\n\tEndpoint *endpoint.Endpoint\n}\n\n// ApplyChanges applies a given set of changes in a given zone.\nfunc (p *NS1Provider) ApplyChanges(_ context.Context, changes *plan.Changes) error {\n\tcombinedChanges := make([]*ns1Change, 0, len(changes.Create)+len(changes.UpdateNew)+len(changes.Delete))\n\n\tcombinedChanges = append(combinedChanges, newNS1Changes(ns1Create, changes.Create)...)\n\tcombinedChanges = append(combinedChanges, newNS1Changes(ns1Update, changes.UpdateNew)...)\n\tcombinedChanges = append(combinedChanges, newNS1Changes(ns1Delete, changes.Delete)...)\n\n\treturn p.ns1SubmitChanges(combinedChanges)\n}\n\n// newNS1Changes returns a collection of Changes based on the given records and action.\nfunc newNS1Changes(action string, endpoints []*endpoint.Endpoint) []*ns1Change {\n\tchanges := make([]*ns1Change, 0, len(endpoints))\n\n\tfor _, ep := range endpoints {\n\t\tchanges = append(changes, &ns1Change{\n\t\t\tAction:   action,\n\t\t\tEndpoint: ep,\n\t\t},\n\t\t)\n\t}\n\n\treturn changes\n}\n\n// ns1ChangesByZone separates a multi-zone change into a single change per zone.\nfunc ns1ChangesByZone(zones []*dns.Zone, changeSets []*ns1Change) map[string][]*ns1Change {\n\tchanges := make(map[string][]*ns1Change)\n\tzoneNameIDMapper := provider.ZoneIDName{}\n\tfor _, z := range zones {\n\t\tzoneNameIDMapper.Add(z.Zone, z.Zone)\n\t\tchanges[z.Zone] = []*ns1Change{}\n\t}\n\n\tfor _, c := range changeSets {\n\t\tzone, _ := zoneNameIDMapper.FindZone(c.Endpoint.DNSName)\n\t\tif zone == \"\" {\n\t\t\tlog.Debugf(\"Skipping record %s because no hosted zone matching record DNS Name was detected\", c.Endpoint.DNSName)\n\t\t\tcontinue\n\t\t}\n\t\tchanges[zone] = append(changes[zone], c)\n\t}\n\n\treturn changes\n}\n"
  },
  {
    "path": "provider/ns1/ns1_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage ns1\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\t\"github.com/stretchr/testify/require\"\n\tapi \"gopkg.in/ns1/ns1-go.v2/rest\"\n\t\"gopkg.in/ns1/ns1-go.v2/rest/model/dns\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\ntype MockNS1DomainClient struct {\n\tmock.Mock\n}\n\nfunc (m *MockNS1DomainClient) CreateRecord(_ *dns.Record) (*http.Response, error) {\n\treturn &http.Response{}, nil\n}\n\nfunc (m *MockNS1DomainClient) DeleteRecord(_ string, _ string, _ string) (*http.Response, error) {\n\treturn &http.Response{}, nil\n}\n\nfunc (m *MockNS1DomainClient) UpdateRecord(_ *dns.Record) (*http.Response, error) {\n\treturn &http.Response{}, nil\n}\n\nfunc (m *MockNS1DomainClient) GetZone(zone string) (*dns.Zone, *http.Response, error) {\n\tr := &dns.ZoneRecord{\n\t\tDomain:   \"test.foo.com\",\n\t\tShortAns: []string{\"2.2.2.2\"},\n\t\tTTL:      3600,\n\t\tType:     \"A\",\n\t\tID:       \"123456789abcdefghijklmno\",\n\t}\n\tz := &dns.Zone{\n\t\tZone:    \"foo.com\",\n\t\tRecords: []*dns.ZoneRecord{r},\n\t\tTTL:     3600,\n\t\tID:      \"12345678910111213141516a\",\n\t}\n\n\tif zone == \"foo.com\" {\n\t\treturn z, nil, nil\n\t}\n\treturn nil, nil, nil\n}\n\nfunc (m *MockNS1DomainClient) ListZones() ([]*dns.Zone, *http.Response, error) {\n\tzones := []*dns.Zone{\n\t\t{Zone: \"foo.com\", ID: \"12345678910111213141516a\"},\n\t\t{Zone: \"bar.com\", ID: \"12345678910111213141516b\"},\n\t}\n\treturn zones, nil, nil\n}\n\ntype MockNS1GetZoneFail struct{}\n\nfunc (m *MockNS1GetZoneFail) CreateRecord(_ *dns.Record) (*http.Response, error) {\n\treturn &http.Response{}, nil\n}\n\nfunc (m *MockNS1GetZoneFail) DeleteRecord(_ string, _ string, _ string) (*http.Response, error) {\n\treturn &http.Response{}, nil\n}\n\nfunc (m *MockNS1GetZoneFail) UpdateRecord(_ *dns.Record) (*http.Response, error) {\n\treturn &http.Response{}, nil\n}\n\nfunc (m *MockNS1GetZoneFail) GetZone(_ string) (*dns.Zone, *http.Response, error) {\n\treturn nil, nil, api.ErrZoneMissing\n}\n\nfunc (m *MockNS1GetZoneFail) ListZones() ([]*dns.Zone, *http.Response, error) {\n\tzones := []*dns.Zone{\n\t\t{Zone: \"foo.com\", ID: \"12345678910111213141516a\"},\n\t\t{Zone: \"bar.com\", ID: \"12345678910111213141516b\"},\n\t}\n\treturn zones, nil, nil\n}\n\ntype MockNS1ListZonesFail struct{}\n\nfunc (m *MockNS1ListZonesFail) CreateRecord(_ *dns.Record) (*http.Response, error) {\n\treturn &http.Response{}, nil\n}\n\nfunc (m *MockNS1ListZonesFail) DeleteRecord(_ string, _ string, _ string) (*http.Response, error) {\n\treturn &http.Response{}, nil\n}\n\nfunc (m *MockNS1ListZonesFail) UpdateRecord(_ *dns.Record) (*http.Response, error) {\n\treturn &http.Response{}, nil\n}\n\nfunc (m *MockNS1ListZonesFail) GetZone(_ string) (*dns.Zone, *http.Response, error) {\n\treturn &dns.Zone{}, &http.Response{}, nil\n}\n\nfunc (m *MockNS1ListZonesFail) ListZones() ([]*dns.Zone, *http.Response, error) {\n\treturn nil, nil, fmt.Errorf(\"no zones available\")\n}\n\nfunc TestNS1Records(t *testing.T) {\n\tprovider := &NS1Provider{\n\t\tclient:        &MockNS1DomainClient{},\n\t\tdomainFilter:  endpoint.NewDomainFilter([]string{\"foo.com.\"}),\n\t\tzoneIDFilter:  provider.NewZoneIDFilter([]string{\"\"}),\n\t\tminTTLSeconds: 3600,\n\t}\n\tctx := t.Context()\n\n\trecords, err := provider.Records(ctx)\n\trequire.NoError(t, err)\n\tassert.Len(t, records, 1)\n\n\tprovider.client = &MockNS1GetZoneFail{}\n\t_, err = provider.Records(ctx)\n\trequire.Error(t, err)\n\n\tprovider.client = &MockNS1ListZonesFail{}\n\t_, err = provider.Records(ctx)\n\trequire.Error(t, err)\n}\n\nfunc TestNewNS1Provider(t *testing.T) {\n\tt.Setenv(\"NS1_APIKEY\", \"xxxxxxxxxxxxxxxxx\")\n\ttestNS1Config := NS1Config{\n\t\tDomainFilter: endpoint.NewDomainFilter([]string{\"foo.com.\"}),\n\t\tZoneIDFilter: provider.NewZoneIDFilter([]string{\"\"}),\n\t\tDryRun:       false,\n\t}\n\t_, err := newProvider(testNS1Config)\n\trequire.NoError(t, err)\n\n\t_ = os.Unsetenv(\"NS1_APIKEY\")\n\t_, err = newProvider(testNS1Config)\n\trequire.Error(t, err)\n}\n\nfunc TestNS1Zones(t *testing.T) {\n\tprovider := &NS1Provider{\n\t\tclient:       &MockNS1DomainClient{},\n\t\tdomainFilter: endpoint.NewDomainFilter([]string{\"foo.com.\"}),\n\t\tzoneIDFilter: provider.NewZoneIDFilter([]string{\"\"}),\n\t}\n\n\tzones, err := provider.zonesFiltered()\n\trequire.NoError(t, err)\n\n\tvalidateNS1Zones(t, zones, []*dns.Zone{\n\t\t{Zone: \"foo.com\"},\n\t})\n}\n\nfunc validateNS1Zones(t *testing.T, zones []*dns.Zone, expected []*dns.Zone) {\n\trequire.Len(t, zones, len(expected))\n\n\tfor i, zone := range zones {\n\t\tassert.Equal(t, expected[i].Zone, zone.Zone)\n\t}\n}\n\nfunc TestNS1BuildRecord(t *testing.T) {\n\tchange := &ns1Change{\n\t\tAction: ns1Create,\n\t\tEndpoint: &endpoint.Endpoint{\n\t\t\tDNSName:    \"new\",\n\t\t\tTargets:    endpoint.Targets{\"target\"},\n\t\t\tRecordType: \"A\",\n\t\t},\n\t}\n\n\tprovider := &NS1Provider{\n\t\tclient:        &MockNS1DomainClient{},\n\t\tdomainFilter:  endpoint.NewDomainFilter([]string{\"foo.com.\"}),\n\t\tzoneIDFilter:  provider.NewZoneIDFilter([]string{\"\"}),\n\t\tminTTLSeconds: 300,\n\t}\n\n\trecord := provider.ns1BuildRecord(\"foo.com\", change)\n\tassert.Equal(t, \"foo.com\", record.Zone)\n\tassert.Equal(t, \"new.foo.com\", record.Domain)\n\tassert.Equal(t, 300, record.TTL)\n\n\tchangeWithTTL := &ns1Change{\n\t\tAction: ns1Create,\n\t\tEndpoint: &endpoint.Endpoint{\n\t\t\tDNSName:    \"new-b\",\n\t\t\tTargets:    endpoint.Targets{\"target\"},\n\t\t\tRecordType: \"A\",\n\t\t\tRecordTTL:  3600,\n\t\t},\n\t}\n\trecord = provider.ns1BuildRecord(\"foo.com\", changeWithTTL)\n\tassert.Equal(t, \"foo.com\", record.Zone)\n\tassert.Equal(t, \"new-b.foo.com\", record.Domain)\n\tassert.Equal(t, 3600, record.TTL)\n}\n\nfunc TestNS1ApplyChanges(t *testing.T) {\n\tchanges := &plan.Changes{}\n\tprovider := &NS1Provider{\n\t\tclient: &MockNS1DomainClient{},\n\t}\n\tchanges.Create = []*endpoint.Endpoint{\n\t\t{DNSName: \"new.foo.com\", Targets: endpoint.Targets{\"target\"}},\n\t\t{DNSName: \"new.subdomain.bar.com\", Targets: endpoint.Targets{\"target\"}},\n\t}\n\tchanges.Delete = []*endpoint.Endpoint{{DNSName: \"test.foo.com\", Targets: endpoint.Targets{\"target\"}}}\n\tchanges.UpdateNew = []*endpoint.Endpoint{{DNSName: \"test.foo.com\", Targets: endpoint.Targets{\"target-new\"}}}\n\terr := provider.ApplyChanges(t.Context(), changes)\n\trequire.NoError(t, err)\n\n\t// empty changes\n\tchanges.Create = []*endpoint.Endpoint{}\n\tchanges.Delete = []*endpoint.Endpoint{}\n\tchanges.UpdateNew = []*endpoint.Endpoint{}\n\terr = provider.ApplyChanges(t.Context(), changes)\n\trequire.NoError(t, err)\n}\n\nfunc TestNewNS1Changes(t *testing.T) {\n\tendpoints := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"testa.foo.com\",\n\t\t\tTargets:    endpoint.Targets{\"target-old\"},\n\t\t\tRecordType: \"A\",\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"testba.bar.com\",\n\t\t\tTargets:    endpoint.Targets{\"target-new\"},\n\t\t\tRecordType: \"A\",\n\t\t},\n\t}\n\texpected := []*ns1Change{\n\t\t{\n\t\t\tAction:   \"ns1Create\",\n\t\t\tEndpoint: endpoints[0],\n\t\t},\n\t\t{\n\t\t\tAction:   \"ns1Create\",\n\t\t\tEndpoint: endpoints[1],\n\t\t},\n\t}\n\tchanges := newNS1Changes(\"ns1Create\", endpoints)\n\trequire.Len(t, changes, len(expected))\n\tassert.Equal(t, expected, changes)\n}\n\nfunc TestNewNS1ChangesByZone(t *testing.T) {\n\tprovider := &NS1Provider{\n\t\tclient: &MockNS1DomainClient{},\n\t}\n\tzones, _ := provider.zonesFiltered()\n\tchangeSets := []*ns1Change{\n\t\t{\n\t\t\tAction: \"ns1Create\",\n\t\t\tEndpoint: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"new.foo.com\",\n\t\t\t\tTargets:    endpoint.Targets{\"target\"},\n\t\t\t\tRecordType: \"A\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tAction: \"ns1Create\",\n\t\t\tEndpoint: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"unrelated.bar.com\",\n\t\t\t\tTargets:    endpoint.Targets{\"target\"},\n\t\t\t\tRecordType: \"A\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tAction: \"ns1Delete\",\n\t\t\tEndpoint: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"test.foo.com\",\n\t\t\t\tTargets:    endpoint.Targets{\"target\"},\n\t\t\t\tRecordType: \"A\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tAction: \"ns1Update\",\n\t\t\tEndpoint: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"test.foo.com\",\n\t\t\t\tTargets:    endpoint.Targets{\"target-new\"},\n\t\t\t\tRecordType: \"A\",\n\t\t\t},\n\t\t},\n\t}\n\n\tchanges := ns1ChangesByZone(zones, changeSets)\n\tassert.Len(t, changes[\"bar.com\"], 1)\n\tassert.Len(t, changes[\"foo.com\"], 3)\n}\n"
  },
  {
    "path": "provider/oci/cache.go",
    "content": "/*\nCopyright 2023 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage oci\n\nimport (\n\t\"time\"\n\n\t\"github.com/oracle/oci-go-sdk/v65/dns\"\n)\n\ntype zoneCache struct {\n\tage      time.Time\n\tduration time.Duration\n\tzones    map[string]dns.ZoneSummary\n}\n\nfunc (z *zoneCache) Reset(zones map[string]dns.ZoneSummary) {\n\tif z.duration > time.Duration(0) {\n\t\tz.age = time.Now()\n\t\tz.zones = zones\n\t}\n}\n\nfunc (z *zoneCache) Get() map[string]dns.ZoneSummary {\n\treturn z.zones\n}\n\nfunc (z *zoneCache) Expired() bool {\n\treturn len(z.zones) < 1 || time.Since(z.age) > z.duration\n}\n"
  },
  {
    "path": "provider/oci/cache_test.go",
    "content": "/*\nCopyright 2023 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage oci\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/oracle/oci-go-sdk/v65/dns\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestZoneCache(t *testing.T) {\n\tnow := time.Now()\n\tvar testCases = map[string]struct {\n\t\tz       *zoneCache\n\t\texpired bool\n\t}{\n\t\t\"inactive-zone-cache\": {\n\t\t\t&zoneCache{\n\t\t\t\tduration: 0 * time.Second,\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t\"empty-active-zone-cache\": {\n\t\t\t&zoneCache{\n\t\t\t\tduration: 30 * time.Second,\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t\"expired-zone-cache\": {\n\t\t\t&zoneCache{\n\t\t\t\tage:      now.Add(300 * time.Second),\n\t\t\t\tduration: 30 * time.Second,\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t\t\"active-zone-cache\": {\n\t\t\t&zoneCache{\n\t\t\t\tzones: map[string]dns.ZoneSummary{\n\t\t\t\t\tzoneIdBaz: testPrivateZoneSummaryBaz,\n\t\t\t\t},\n\t\t\t\tduration: 30 * time.Second,\n\t\t\t},\n\t\t\ttrue,\n\t\t},\n\t}\n\n\tfor name, testCase := range testCases {\n\t\tt.Run(name, func(t *testing.T) {\n\t\t\tassert.Equal(t, testCase.expired, testCase.z.Expired())\n\t\t\tvar resetZoneLength = 1\n\t\t\tif testCase.z.duration == 0 {\n\t\t\t\tresetZoneLength = 0\n\t\t\t}\n\t\t\ttestCase.z.Reset(map[string]dns.ZoneSummary{\n\t\t\t\tzoneIdQux: testPrivateZoneSummaryQux,\n\t\t\t})\n\t\t\tassert.Len(t, testCase.z.Get(), resetZoneLength)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "provider/oci/oci.go",
    "content": "/*\nCopyright 2018 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage oci\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/goccy/go-yaml\"\n\t\"github.com/oracle/oci-go-sdk/v65/common\"\n\t\"github.com/oracle/oci-go-sdk/v65/common/auth\"\n\t\"github.com/oracle/oci-go-sdk/v65/dns\"\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\nconst defaultTTL = 300\n\n// OCIAuthConfig holds connection parameters for the OCI API.\ntype OCIAuthConfig struct {\n\tRegion               string `yaml:\"region\"`\n\tTenancyID            string `yaml:\"tenancy\"`\n\tUserID               string `yaml:\"user\"`\n\tPrivateKey           string `yaml:\"key\"`\n\tFingerprint          string `yaml:\"fingerprint\"`\n\tPassphrase           string `yaml:\"passphrase\"`\n\tUseInstancePrincipal bool   `yaml:\"useInstancePrincipal\"`\n\tUseWorkloadIdentity  bool   `yaml:\"useWorkloadIdentity\"`\n}\n\n// OCIConfig holds the configuration for the OCI Provider.\ntype OCIConfig struct {\n\tAuth              OCIAuthConfig `yaml:\"auth\"`\n\tCompartmentID     string        `yaml:\"compartment\"`\n\tZoneCacheDuration time.Duration\n}\n\n// OCIProvider is an implementation of Provider for Oracle Cloud Infrastructure\n// (OCI) DNS.\ntype OCIProvider struct {\n\tprovider.BaseProvider\n\tclient ociDNSClient\n\tcfg    OCIConfig\n\n\tdomainFilter *endpoint.DomainFilter\n\tzoneIDFilter provider.ZoneIDFilter\n\tzoneScope    string\n\tzoneCache    *zoneCache\n\tdryRun       bool\n}\n\n// ociDNSClient is the subset of the OCI DNS API required by the OCI Provider.\ntype ociDNSClient interface {\n\tListZones(ctx context.Context, request dns.ListZonesRequest) (response dns.ListZonesResponse, err error)\n\tGetZoneRecords(ctx context.Context, request dns.GetZoneRecordsRequest) (response dns.GetZoneRecordsResponse, err error)\n\tPatchZoneRecords(ctx context.Context, request dns.PatchZoneRecordsRequest) (response dns.PatchZoneRecordsResponse, err error)\n}\n\n// New creates an OCI provider from the given configuration.\nfunc New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) {\n\tvar config *OCIConfig\n\tif cfg.OCIAuthInstancePrincipal {\n\t\tif len(cfg.OCICompartmentOCID) == 0 {\n\t\t\treturn nil, fmt.Errorf(\"instance principal authentication requested, but no compartment OCID provided\")\n\t\t}\n\t\tauthConfig := OCIAuthConfig{UseInstancePrincipal: true}\n\t\tconfig = &OCIConfig{Auth: authConfig, CompartmentID: cfg.OCICompartmentOCID}\n\t} else {\n\t\tvar err error\n\t\tif config, err = loadOCIConfig(cfg.OCIConfigFile); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tconfig.ZoneCacheDuration = cfg.OCIZoneCacheDuration\n\treturn newProvider(*config, domainFilter, provider.NewZoneIDFilter(cfg.ZoneIDFilter), cfg.OCIZoneScope, cfg.DryRun)\n}\n\n// loadOCIConfig reads and parses the OCI ExternalDNS config file at the given path.\nfunc loadOCIConfig(path string) (*OCIConfig, error) {\n\tcontents, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"reading OCI config file %q: %w\", path, err)\n\t}\n\n\tcfg := OCIConfig{}\n\tif err := yaml.Unmarshal(contents, &cfg); err != nil {\n\t\treturn nil, fmt.Errorf(\"parsing OCI config file %q: %w\", path, err)\n\t}\n\treturn &cfg, nil\n}\n\n// newProvider initializes a new OCI DNS based Provider.\nfunc newProvider(cfg OCIConfig, domainFilter *endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, zoneScope string, dryRun bool) (*OCIProvider, error) {\n\tvar client ociDNSClient\n\tvar err error\n\tvar configProvider common.ConfigurationProvider\n\tif cfg.Auth.UseInstancePrincipal && cfg.Auth.UseWorkloadIdentity {\n\t\treturn nil, errors.New(\"only one of 'useInstancePrincipal' and 'useWorkloadIdentity' may be enabled for Oracle authentication\")\n\t}\n\tswitch {\n\tcase cfg.Auth.UseWorkloadIdentity:\n\t\t// OCI SDK requires specific, dynamic environment variables for workload identity.\n\t\tif err := os.Setenv(auth.ResourcePrincipalVersionEnvVar, auth.ResourcePrincipalVersion2_2); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"unable to set OCI SDK environment variable: %s: %w\", auth.ResourcePrincipalVersionEnvVar, err)\n\t\t}\n\t\tif err := os.Setenv(auth.ResourcePrincipalRegionEnvVar, cfg.Auth.Region); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"unable to set OCI SDK environment variable: %s: %w\", auth.ResourcePrincipalRegionEnvVar, err)\n\t\t}\n\t\tconfigProvider, err = auth.OkeWorkloadIdentityConfigurationProvider()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error creating OCI workload identity config provider: %w\", err)\n\t\t}\n\tcase cfg.Auth.UseInstancePrincipal:\n\t\tconfigProvider, err = auth.InstancePrincipalConfigurationProvider()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error creating OCI instance principal config provider: %w\", err)\n\t\t}\n\tdefault:\n\t\tconfigProvider = common.NewRawConfigurationProvider(\n\t\t\tcfg.Auth.TenancyID,\n\t\t\tcfg.Auth.UserID,\n\t\t\tcfg.Auth.Region,\n\t\t\tcfg.Auth.Fingerprint,\n\t\t\tcfg.Auth.PrivateKey,\n\t\t\t&cfg.Auth.Passphrase,\n\t\t)\n\t}\n\n\tclient, err = dns.NewDnsClientWithConfigurationProvider(configProvider)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"initializing OCI DNS API client: %w\", err)\n\t}\n\n\treturn &OCIProvider{\n\t\tclient:       client,\n\t\tcfg:          cfg,\n\t\tdomainFilter: domainFilter,\n\t\tzoneIDFilter: zoneIDFilter,\n\t\tzoneScope:    zoneScope,\n\t\tzoneCache: &zoneCache{\n\t\t\tduration: cfg.ZoneCacheDuration,\n\t\t},\n\t\tdryRun: dryRun,\n\t}, nil\n}\n\nfunc (p *OCIProvider) zones(ctx context.Context) (map[string]dns.ZoneSummary, error) {\n\tif !p.zoneCache.Expired() {\n\t\tlog.Debug(\"Using cached zones list\")\n\t\treturn p.zoneCache.zones, nil\n\t}\n\tzones := make(map[string]dns.ZoneSummary)\n\tscopes := []dns.GetZoneScopeEnum{dns.GetZoneScopeEnum(p.zoneScope)}\n\t// If the zone scope is empty, list all zones types.\n\tif p.zoneScope == \"\" {\n\t\tscopes = dns.GetGetZoneScopeEnumValues()\n\t}\n\tlog.Debugf(\"Matching zones against domain filters: %v\", p.domainFilter.Filters)\n\tfor _, scope := range scopes {\n\t\tif err := p.addPaginatedZones(ctx, zones, scope); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tif len(zones) == 0 {\n\t\tlog.Warnf(\"No zones in compartment %q match domain filters %v\", p.cfg.CompartmentID, p.domainFilter)\n\t}\n\tp.zoneCache.Reset(zones)\n\treturn zones, nil\n}\n\n// Merge Endpoints with the same Name and Type into a single endpoint with multiple Targets.\nfunc mergeEndpointsMultiTargets(endpoints []*endpoint.Endpoint) []*endpoint.Endpoint {\n\tendpointsByNameType := map[string][]*endpoint.Endpoint{}\n\n\tfor _, ep := range endpoints {\n\t\tkey := fmt.Sprintf(\"%s-%s\", ep.DNSName, ep.RecordType)\n\t\tendpointsByNameType[key] = append(endpointsByNameType[key], ep)\n\t}\n\n\t// If there were no merges, return endpoints.\n\tif len(endpointsByNameType) == len(endpoints) {\n\t\treturn endpoints\n\t}\n\n\t// Otherwise, create a new list of endpoints with the consolidated targets.\n\tvar mergedEndpoints []*endpoint.Endpoint\n\tfor _, ep := range endpointsByNameType {\n\t\tdnsName := ep[0].DNSName\n\t\trecordType := ep[0].RecordType\n\t\trecordTTL := ep[0].RecordTTL\n\n\t\ttargets := make([]string, len(ep))\n\t\tfor i, e := range ep {\n\t\t\ttargets[i] = e.Targets[0]\n\t\t}\n\n\t\te := endpoint.NewEndpointWithTTL(dnsName, recordType, recordTTL, targets...)\n\t\tmergedEndpoints = append(mergedEndpoints, e)\n\t}\n\n\treturn mergedEndpoints\n}\n\nfunc (p *OCIProvider) addPaginatedZones(ctx context.Context, zones map[string]dns.ZoneSummary, scope dns.GetZoneScopeEnum) error {\n\tvar page *string\n\t// Loop until we have listed all zones.\n\tfor {\n\t\tresp, err := p.client.ListZones(ctx, dns.ListZonesRequest{\n\t\t\tCompartmentId: &p.cfg.CompartmentID,\n\t\t\tZoneType:      dns.ListZonesZoneTypePrimary,\n\t\t\tScope:         dns.ListZonesScopeEnum(scope),\n\t\t\tPage:          page,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn provider.NewSoftErrorf(\"listing zones in %s: %w\", p.cfg.CompartmentID, err)\n\t\t}\n\t\tfor _, zone := range resp.Items {\n\t\t\tif p.domainFilter.Match(*zone.Name) && p.zoneIDFilter.Match(*zone.Id) {\n\t\t\t\tzones[*zone.Id] = zone\n\t\t\t\tlog.Debugf(\"Matched %q (%q)\", *zone.Name, *zone.Id)\n\t\t\t} else {\n\t\t\t\tlog.Debugf(\"Filtered %q (%q)\", *zone.Name, *zone.Id)\n\t\t\t}\n\t\t}\n\t\tif page = resp.OpcNextPage; resp.OpcNextPage == nil {\n\t\t\tbreak\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (p *OCIProvider) newFilteredRecordOperations(endpoints []*endpoint.Endpoint, opType dns.RecordOperationOperationEnum) []dns.RecordOperation {\n\tvar ops []dns.RecordOperation\n\tfor _, ep := range endpoints {\n\t\tif ep == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif p.domainFilter.Match(ep.DNSName) {\n\t\t\tfor _, t := range ep.Targets {\n\t\t\t\tsingleTargetEp := &endpoint.Endpoint{\n\t\t\t\t\tDNSName:          ep.DNSName,\n\t\t\t\t\tTargets:          []string{t},\n\t\t\t\t\tRecordType:       ep.RecordType,\n\t\t\t\t\tRecordTTL:        ep.RecordTTL,\n\t\t\t\t\tLabels:           ep.Labels,\n\t\t\t\t\tProviderSpecific: ep.ProviderSpecific,\n\t\t\t\t}\n\t\t\t\tops = append(ops, newRecordOperation(singleTargetEp, opType))\n\t\t\t}\n\t\t}\n\t}\n\treturn ops\n}\n\n// Records returns the list of records in a given hosted zone.\nfunc (p *OCIProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {\n\tzones, err := p.zones(ctx)\n\tif err != nil {\n\t\treturn nil, provider.NewSoftErrorf(\"getting zones: %w\", err)\n\t}\n\n\tvar endpoints []*endpoint.Endpoint\n\tfor _, zone := range zones {\n\t\tvar page *string\n\t\tfor {\n\t\t\tresp, err := p.client.GetZoneRecords(ctx, dns.GetZoneRecordsRequest{\n\t\t\t\tZoneNameOrId:  zone.Id,\n\t\t\t\tPage:          page,\n\t\t\t\tCompartmentId: &p.cfg.CompartmentID,\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\treturn nil, provider.NewSoftErrorf(\"getting records for zone %q: %w\", *zone.Id, err)\n\t\t\t}\n\n\t\t\tfor _, record := range resp.Items {\n\t\t\t\tif !provider.SupportedRecordType(*record.Rtype) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tendpoints = append(endpoints,\n\t\t\t\t\tendpoint.NewEndpointWithTTL(\n\t\t\t\t\t\t*record.Domain,\n\t\t\t\t\t\t*record.Rtype,\n\t\t\t\t\t\tendpoint.TTL(*record.Ttl),\n\t\t\t\t\t\t*record.Rdata,\n\t\t\t\t\t),\n\t\t\t\t)\n\t\t\t}\n\n\t\t\tif page = resp.OpcNextPage; resp.OpcNextPage == nil {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\tendpoints = mergeEndpointsMultiTargets(endpoints)\n\n\treturn endpoints, nil\n}\n\n// ApplyChanges applies a given set of changes to a given zone.\nfunc (p *OCIProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {\n\tlog.Debugf(\"Processing changes: %+v\", changes)\n\n\tvar ops []dns.RecordOperation\n\tops = append(ops, p.newFilteredRecordOperations(changes.Create, dns.RecordOperationOperationAdd)...)\n\n\tops = append(ops, p.newFilteredRecordOperations(changes.UpdateNew, dns.RecordOperationOperationAdd)...)\n\tops = append(ops, p.newFilteredRecordOperations(changes.UpdateOld, dns.RecordOperationOperationRemove)...)\n\n\tops = append(ops, p.newFilteredRecordOperations(changes.Delete, dns.RecordOperationOperationRemove)...)\n\n\tif len(ops) == 0 {\n\t\tlog.Info(\"All records are already up to date\")\n\t\treturn nil\n\t}\n\n\tzones, err := p.zones(ctx)\n\tif err != nil {\n\t\treturn provider.NewSoftErrorf(\"fetching zones: %w\", err)\n\t}\n\n\t// Separate into per-zone change sets to be passed to OCI API.\n\topsByZone := operationsByZone(zones, ops)\n\tfor zoneID, ops := range opsByZone {\n\t\tlog.Infof(\"Change zone: %q\", zoneID)\n\t\tfor _, op := range ops {\n\t\t\tlog.Info(op)\n\t\t}\n\t}\n\n\tif p.dryRun {\n\t\treturn nil\n\t}\n\n\tfor zoneID, ops := range opsByZone {\n\t\tif _, err := p.client.PatchZoneRecords(ctx, dns.PatchZoneRecordsRequest{\n\t\t\tCompartmentId:           &p.cfg.CompartmentID,\n\t\t\tZoneNameOrId:            &zoneID,\n\t\t\tPatchZoneRecordsDetails: dns.PatchZoneRecordsDetails{Items: ops},\n\t\t}); err != nil {\n\t\t\treturn provider.NewSoftError(err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// AdjustEndpoints modifies the endpoints as needed by the specific provider\nfunc (p *OCIProvider) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) {\n\tvar adjustedEndpoints []*endpoint.Endpoint\n\tfor _, e := range endpoints {\n\t\t// OCI DNS does not support the set-identifier attribute, so we remove it to avoid plan failure\n\t\tif e.SetIdentifier != \"\" {\n\t\t\tlog.Warnf(\"Adjusting endpont: %v. Ignoring unsupported annotation 'set-identifier': %s\", *e, e.SetIdentifier)\n\t\t\te.SetIdentifier = \"\"\n\t\t}\n\t\tadjustedEndpoints = append(adjustedEndpoints, e)\n\t}\n\treturn adjustedEndpoints, nil\n}\n\n// newRecordOperation returns a RecordOperation based on a given endpoint.\nfunc newRecordOperation(ep *endpoint.Endpoint, opType dns.RecordOperationOperationEnum) dns.RecordOperation {\n\ttargets := make([]string, len(ep.Targets))\n\tcopy(targets, ep.Targets)\n\tif ep.RecordType == endpoint.RecordTypeCNAME {\n\t\ttargets[0] = provider.EnsureTrailingDot(targets[0])\n\t}\n\trdata := strings.Join(targets, \" \")\n\n\tttl := defaultTTL\n\tif ep.RecordTTL.IsConfigured() {\n\t\tttl = int(ep.RecordTTL)\n\t}\n\n\treturn dns.RecordOperation{\n\t\tDomain:    &ep.DNSName,\n\t\tRdata:     &rdata,\n\t\tTtl:       &ttl,\n\t\tRtype:     &ep.RecordType,\n\t\tOperation: opType,\n\t}\n}\n\n// operationsByZone segments a slice of RecordOperations by their zone.\nfunc operationsByZone(zones map[string]dns.ZoneSummary, ops []dns.RecordOperation) map[string][]dns.RecordOperation {\n\tchanges := make(map[string][]dns.RecordOperation)\n\n\tzoneNameIDMapper := provider.ZoneIDName{}\n\tfor _, z := range zones {\n\t\tzoneNameIDMapper.Add(*z.Id, *z.Name)\n\t\tchanges[*z.Id] = []dns.RecordOperation{}\n\t}\n\n\tfor _, op := range ops {\n\t\tif zoneID, _ := zoneNameIDMapper.FindZone(*op.Domain); zoneID != \"\" {\n\t\t\tchanges[zoneID] = append(changes[zoneID], op)\n\t\t} else {\n\t\t\tlog.Warnf(\"No matching zone for record operation %s\", op)\n\t\t}\n\t}\n\n\t// Remove zones that don't have any changes.\n\tfor zone, ops := range changes {\n\t\tif len(ops) == 0 {\n\t\t\tdelete(changes, zone)\n\t\t}\n\t}\n\n\treturn changes\n}\n"
  },
  {
    "path": "provider/oci/oci_test.go",
    "content": "/*\nCopyright 2018 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage oci\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"sort\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/oracle/oci-go-sdk/v65/common\"\n\t\"github.com/oracle/oci-go-sdk/v65/dns\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\ntype mockOCIDNSClient struct{}\n\nvar (\n\tzoneIdQux                 = \"ocid1.dns-zone.oc1..123456ef0bfbb5c251b9713fd7bf8959\"\n\tzoneNameQux               = \"qux.com\"\n\ttestPrivateZoneSummaryQux = dns.ZoneSummary{\n\t\tId:   &zoneIdQux,\n\t\tName: &zoneNameQux,\n\t}\n\tzoneIdBaz                 = \"ocid1.dns-zone.oc1..789012ef0bfbb5c251b9713fd7bf8959\"\n\tzoneNameBaz               = \"baz.com\"\n\ttestPrivateZoneSummaryBaz = dns.ZoneSummary{\n\t\tId:   &zoneIdBaz,\n\t\tName: &zoneNameBaz,\n\t}\n\ttestGlobalZoneSummaryFoo = dns.ZoneSummary{\n\t\tId:   common.String(\"ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959\"),\n\t\tName: common.String(\"foo.com\"),\n\t}\n\ttestGlobalZoneSummaryBar = dns.ZoneSummary{\n\t\tId:   common.String(\"ocid1.dns-zone.oc1..502aeddba262b92fd13ed7874f6f1404\"),\n\t\tName: common.String(\"bar.com\"),\n\t}\n)\n\nfunc buildZoneResponseItems(scope dns.ListZonesScopeEnum, privateZones, globalZones []dns.ZoneSummary) []dns.ZoneSummary {\n\tswitch string(scope) {\n\tcase \"PRIVATE\":\n\t\treturn privateZones\n\tcase \"GLOBAL\":\n\t\treturn globalZones\n\tdefault:\n\t\treturn append(privateZones, globalZones...)\n\t}\n}\n\nfunc (c *mockOCIDNSClient) ListZones(_ context.Context, request dns.ListZonesRequest) (dns.ListZonesResponse, error) {\n\tif request.Page == nil || *request.Page == \"0\" {\n\t\treturn dns.ListZonesResponse{\n\t\t\tItems:       buildZoneResponseItems(request.Scope, []dns.ZoneSummary{testPrivateZoneSummaryBaz}, []dns.ZoneSummary{testGlobalZoneSummaryFoo}),\n\t\t\tOpcNextPage: common.String(\"1\"),\n\t\t}, nil\n\t}\n\treturn dns.ListZonesResponse{\n\t\tItems: buildZoneResponseItems(request.Scope, []dns.ZoneSummary{testPrivateZoneSummaryQux}, []dns.ZoneSummary{testGlobalZoneSummaryBar}),\n\t}, nil\n}\n\nfunc (c *mockOCIDNSClient) GetZoneRecords(_ context.Context, request dns.GetZoneRecordsRequest) (dns.GetZoneRecordsResponse, error) {\n\tvar response dns.GetZoneRecordsResponse\n\tvar err error\n\tif request.ZoneNameOrId == nil {\n\t\treturn response, err\n\t}\n\n\tswitch *request.ZoneNameOrId {\n\tcase \"ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959\":\n\t\tif request.Page == nil || *request.Page == \"0\" {\n\t\t\tresponse.Items = []dns.Record{{\n\t\t\t\tDomain: common.String(\"foo.foo.com\"),\n\t\t\t\tRdata:  common.String(\"127.0.0.1\"),\n\t\t\t\tRtype:  common.String(endpoint.RecordTypeA),\n\t\t\t\tTtl:    common.Int(defaultTTL),\n\t\t\t}, {\n\t\t\t\tDomain: common.String(\"foo.foo.com\"),\n\t\t\t\tRdata:  common.String(\"heritage=external-dns,external-dns/owner=default,external-dns/resource=service/default/my-svc\"),\n\t\t\t\tRtype:  common.String(endpoint.RecordTypeTXT),\n\t\t\t\tTtl:    common.Int(defaultTTL),\n\t\t\t}}\n\t\t\tresponse.OpcNextPage = common.String(\"1\")\n\t\t} else {\n\t\t\tresponse.Items = []dns.Record{{\n\t\t\t\tDomain: common.String(\"bar.foo.com\"),\n\t\t\t\tRdata:  common.String(\"bar.com.\"),\n\t\t\t\tRtype:  common.String(endpoint.RecordTypeCNAME),\n\t\t\t\tTtl:    common.Int(defaultTTL),\n\t\t\t}}\n\t\t}\n\tcase \"ocid1.dns-zone.oc1..502aeddba262b92fd13ed7874f6f1404\":\n\t\tif request.Page == nil || *request.Page == \"0\" {\n\t\t\tresponse.Items = []dns.Record{{\n\t\t\t\tDomain: common.String(\"foo.bar.com\"),\n\t\t\t\tRdata:  common.String(\"127.0.0.1\"),\n\t\t\t\tRtype:  common.String(endpoint.RecordTypeA),\n\t\t\t\tTtl:    common.Int(defaultTTL),\n\t\t\t}}\n\t\t}\n\t}\n\treturn response, err\n}\n\nfunc (c *mockOCIDNSClient) PatchZoneRecords(_ context.Context, _ dns.PatchZoneRecordsRequest) (dns.PatchZoneRecordsResponse, error) {\n\treturn dns.PatchZoneRecordsResponse{}, nil\n}\n\n// newOCIProvider creates an OCI provider with API calls mocked out.\nfunc newOCIProvider(client ociDNSClient, domainFilter *endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, zoneScope string, dryRun bool) *OCIProvider {\n\treturn &OCIProvider{\n\t\tclient: client,\n\t\tcfg: OCIConfig{\n\t\t\tCompartmentID: \"ocid1.compartment.oc1..aaaaaaaaujjg4lf3v6uaqeml7xfk7stzvrxeweaeyolhh75exuoqxpqjb4qq\",\n\t\t},\n\t\tdomainFilter: domainFilter,\n\t\tzoneIDFilter: zoneIDFilter,\n\t\tzoneScope:    zoneScope,\n\t\tzoneCache: &zoneCache{\n\t\t\tduration: 0 * time.Second,\n\t\t},\n\t\tdryRun: dryRun,\n\t}\n}\n\nfunc validateOCIZones(t *testing.T, actual, expected map[string]dns.ZoneSummary) {\n\trequire.Len(t, actual, len(expected))\n\n\tfor k, a := range actual {\n\t\te, ok := expected[k]\n\t\trequire.True(t, ok, \"unexpected zone %q (%q)\", *a.Name, *a.Id)\n\t\trequire.Equal(t, e, a)\n\t}\n}\n\nfunc TestNewOCIProvider(t *testing.T) {\n\ttestCases := map[string]struct {\n\t\tconfig OCIConfig\n\t\terr    error\n\t}{\n\t\t\"valid\": {\n\t\t\tconfig: OCIConfig{\n\t\t\t\tAuth: OCIAuthConfig{\n\t\t\t\t\tTenancyID:   \"ocid1.tenancy.oc1..aaaaaaaaxf3fuazosc6xng7l75rj6uist5jb6ken64t3qltimxnkymddqbma\",\n\t\t\t\t\tUserID:      \"ocid1.user.oc1..aaaaaaaahx2vpvm4of5nqq3t274ike7ygyk2aexvokk3gyv4eyumzqajcrvq\",\n\t\t\t\t\tRegion:      \"us-ashburn-1\",\n\t\t\t\t\tFingerprint: \"48:ba:d4:21:63:53:db:10:65:20:d4:09:ce:01:f5:97\",\n\t\t\t\t\tPrivateKey: `-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAv2JspZyO14kqcO/X4iz3ZdcyAf1GQJqYsBb6wyrlU0PB9Fee\nH23/HLtMSqeqo+2KQHmdV1OHFQ/S6tx7zcBaby/+2b+z3/gJO4PGxohe2812AJ/J\nW8Fp/4EnwbaRqDhoLN7ms0/e566zE3z40kCSW0NAIzv/F+0nNaka1xrypBqzvaNm\nN49dAGvqWRpzFFUb8CbvKmgE6c/H4a2zVNW3G7/K6Og4HQGeEP3NKSVvi0BiQlvd\ntVJTg7084kKcrngsS2N3qI3pzsr5wgpzPPefuPHWRKokZ20kpu8tXdFt+mAC2NHh\neWbtY3jsR6JFaXCyZLMXInwDvRgdP0T5+uh8WwIDAQABAoIBAG0rr94omDLKw7L4\nnaUfEWC+iIAqAdEIXuDTuudpqLb+h7zh3gj/re6tyK8tRWGNNrfgp6gQtZWGGUJv\n0w9jEjMqpa2AdRLlYh7Y5KKLV9D6Or3QaAQ3KEffXNZbVmsnAgXWgLL4dKakOPJ8\n71LAEryMeCGhL7puRVeOxwi9Dnwc4pcloimdggw/uwVHMK9eY5ylyt5ziiiWfhAo\ncnNJNPHRSTqSiCoEhk/8BLZT5gxf1YX0hVSEdQh2WNyxmPmVSC9uuzKOqcEBfHf5\nhmLnsUET1REM9IxCLqC9ebW263lIO/KdGiCu+YgIdwIi3wrLhaKXAZQmp4oMvWlE\nn5eYlcECgYEA5AhctPWCQBCJhcD39pSWgnSq1O9bt8yQi2P2stqlxKV9ZBepCK49\nOT42OYPUgWn7/y//6/LLzsPY58VTDHF3xZN1qu+fU0IM22D3Jqc19pnfVEb6TXSc\n0jJIiaYCWTdqRQ4p2DuDcI+EzRB+V1Z7tFWxshZWXwNvtMXNoYPOYaUCgYEA1ttn\nR3pCuGYJ5XbBwPzD5J+hvdZ6TQf8oTDraUBPxjtFOr7ea42T6KeYRFvnK2AQDnKL\nMw3I55lNO4I2W9gahUFG28dhxEuxeyvXGqXEJvPCUYePstab/BkUrm7/jkS3CLcJ\ndlRXjqOfGwi5+NPUZMoOkZ54ZR4ZpdhIAeEpBf8CgYEAyMyMRlVCowNs9jkcoSfq\n+Wme3O8BhvI9/mDCZnCfNHC94Bvtn1U/WF7uBOuPf35Ch05PQAiHa8WOBVn/bZ+l\nZngZT7K+S+SHyc6zFHh9zm9k96Og2f/r8DSTJ5Ll0oY3sCNuuZh+f+oBeUoi1umy\n+PPVDAsbd4NhJIBiOO4GGHkCgYA1p4i9Es0Cm4ixItzzwqtwtmR/scXM4se1wS+o\nkwTY7gg1yWBl328mVGPz/jdWX6Di2rvkPfcDzwa4a6YDfY3x5QE69Sl3CagCqEoJ\nP4giahEGpyG9eVZuuBywCswKzSIgLQVR5XIQDtA2whEfEFcj7EmDF93c8o1ZGw+w\nWHgUJQKBgEXr0HgxGG+v8bsXdrJ87Avx/nuA2rrFfECDPa4zuPkEK+cSFibdAq/H\nu6OIV+z59AD2s84gxR+KLzEDfQAqBt7cVA5ZH6hrO+bkCtK9ycLL+koOuB+1EV+Y\nhKRtDhmSdWBo3tJK12RrAe4t7CUe8gMgTvU7ExlcA3xQkseFPx9K\n-----END RSA PRIVATE KEY-----\n`,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"invalid\": {\n\t\t\tconfig: OCIConfig{\n\t\t\t\tAuth: OCIAuthConfig{\n\t\t\t\t\tTenancyID:   \"ocid1.tenancy.oc1..aaaaaaaaxf3fuazosc6xng7l75rj6uist5jb6ken64t3qltimxnkymddqbma\",\n\t\t\t\t\tUserID:      \"ocid1.user.oc1..aaaaaaaahx2vpvm4of5nqq3t274ike7ygyk2aexvokk3gyv4eyumzqajcrvq\",\n\t\t\t\t\tRegion:      \"us-ashburn-1\",\n\t\t\t\t\tFingerprint: \"48:ba:d4:21:63:53:db:10:65:20:d4:09:ce:01:f5:97\",\n\t\t\t\t\tPrivateKey: `-----BEGIN RSA PRIVATE KEY-----\n`,\n\t\t\t\t},\n\t\t\t},\n\t\t\terr: errors.New(\"initializing OCI DNS API client: can not create client, bad configuration: PEM data was not found in buffer\"),\n\t\t},\n\t\t\"invalid-auth-methods\": {\n\t\t\tconfig: OCIConfig{\n\t\t\t\tAuth: OCIAuthConfig{\n\t\t\t\t\tRegion:               \"us-ashburn-1\",\n\t\t\t\t\tUseInstancePrincipal: true,\n\t\t\t\t\tUseWorkloadIdentity:  true,\n\t\t\t\t},\n\t\t\t},\n\t\t\terr: errors.New(\"only one of 'useInstancePrincipal' and 'useWorkloadIdentity' may be enabled for Oracle authentication\"),\n\t\t},\n\t}\n\tfor name, tc := range testCases {\n\t\tt.Run(name, func(t *testing.T) {\n\t\t\t_, err := newProvider(\n\t\t\t\ttc.config,\n\t\t\t\tendpoint.NewDomainFilter([]string{\"com\"}),\n\t\t\t\tprovider.NewZoneIDFilter([]string{\"\"}),\n\t\t\t\tstring(dns.GetZoneScopeGlobal),\n\t\t\t\tfalse,\n\t\t\t)\n\t\t\tif err == nil {\n\t\t\t\trequire.NoError(t, err)\n\t\t\t} else {\n\t\t\t\t// have to use prefix testing because the expected instance-principal error strings vary after a known prefix\n\t\t\t\trequire.Truef(t, strings.HasPrefix(err.Error(), tc.err.Error()), \"observed: %s\", err.Error())\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestOCIZones(t *testing.T) {\n\tfooZoneId := \"ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959\"\n\tbarZoneId := \"ocid1.dns-zone.oc1..502aeddba262b92fd13ed7874f6f1404\"\n\ttestCases := []struct {\n\t\tname         string\n\t\tdomainFilter *endpoint.DomainFilter\n\t\tzoneIDFilter provider.ZoneIDFilter\n\t\tzoneScope    string\n\t\texpected     map[string]dns.ZoneSummary\n\t}{\n\t\t{\n\t\t\tname:         \"AllZones\",\n\t\t\tdomainFilter: endpoint.NewDomainFilter([]string{\"com\"}),\n\t\t\tzoneIDFilter: provider.NewZoneIDFilter([]string{\"\"}),\n\t\t\tzoneScope:    \"\",\n\t\t\texpected: map[string]dns.ZoneSummary{\n\t\t\t\tfooZoneId: testGlobalZoneSummaryFoo,\n\t\t\t\tbarZoneId: testGlobalZoneSummaryBar,\n\t\t\t\tzoneIdBaz: testPrivateZoneSummaryBaz,\n\t\t\t\tzoneIdQux: testPrivateZoneSummaryQux,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:         \"Privatezones\",\n\t\t\tdomainFilter: endpoint.NewDomainFilter([]string{\"com\"}),\n\t\t\tzoneIDFilter: provider.NewZoneIDFilter([]string{\"\"}),\n\t\t\tzoneScope:    \"PRIVATE\",\n\t\t\texpected: map[string]dns.ZoneSummary{\n\t\t\t\tzoneIdBaz: testPrivateZoneSummaryBaz,\n\t\t\t\tzoneIdQux: testPrivateZoneSummaryQux,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:         \"DomainFilter_com\",\n\t\t\tdomainFilter: endpoint.NewDomainFilter([]string{\"com\"}),\n\t\t\tzoneIDFilter: provider.NewZoneIDFilter([]string{\"\"}),\n\t\t\tzoneScope:    \"GLOBAL\",\n\t\t\texpected: map[string]dns.ZoneSummary{\n\t\t\t\tfooZoneId: testGlobalZoneSummaryFoo,\n\t\t\t\tbarZoneId: testGlobalZoneSummaryBar,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:         \"DomainFilter_foo.com\",\n\t\t\tdomainFilter: endpoint.NewDomainFilter([]string{\"foo.com\"}),\n\t\t\tzoneIDFilter: provider.NewZoneIDFilter([]string{\"\"}),\n\t\t\tzoneScope:    \"GLOBAL\",\n\t\t\texpected: map[string]dns.ZoneSummary{\n\t\t\t\tfooZoneId: {\n\t\t\t\t\tId:   common.String(fooZoneId),\n\t\t\t\t\tName: common.String(\"foo.com\"),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:         \"ZoneIDFilter_ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959\",\n\t\t\tdomainFilter: endpoint.NewDomainFilter([]string{\"\"}),\n\t\t\tzoneIDFilter: provider.NewZoneIDFilter([]string{\"ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959\"}),\n\t\t\tzoneScope:    \"GLOBAL\",\n\t\t\texpected: map[string]dns.ZoneSummary{\n\t\t\t\tfooZoneId: {\n\t\t\t\t\tId:   common.String(fooZoneId),\n\t\t\t\t\tName: common.String(\"foo.com\"),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tprovider := newOCIProvider(&mockOCIDNSClient{}, tc.domainFilter, tc.zoneIDFilter, tc.zoneScope, false)\n\t\t\tzones, err := provider.zones(t.Context())\n\t\t\trequire.NoError(t, err)\n\t\t\tvalidateOCIZones(t, zones, tc.expected)\n\t\t})\n\t}\n}\n\nfunc TestOCIRecords(t *testing.T) {\n\ttestCases := []struct {\n\t\tname         string\n\t\tdomainFilter *endpoint.DomainFilter\n\t\tzoneIDFilter provider.ZoneIDFilter\n\t\texpected     []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\tname:         \"unfiltered\",\n\t\t\tdomainFilter: endpoint.NewDomainFilter([]string{\"\"}),\n\t\t\tzoneIDFilter: provider.NewZoneIDFilter([]string{\"\"}),\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpointWithTTL(\"foo.foo.com\", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), \"127.0.0.1\"),\n\t\t\t\tendpoint.NewEndpointWithTTL(\"foo.foo.com\", endpoint.RecordTypeTXT, endpoint.TTL(defaultTTL), \"heritage=external-dns,external-dns/owner=default,external-dns/resource=service/default/my-svc\"),\n\t\t\t\tendpoint.NewEndpointWithTTL(\"bar.foo.com\", endpoint.RecordTypeCNAME, endpoint.TTL(defaultTTL), \"bar.com.\"),\n\t\t\t\tendpoint.NewEndpointWithTTL(\"foo.bar.com\", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), \"127.0.0.1\"),\n\t\t\t},\n\t\t}, {\n\t\t\tname:         \"DomainFilter_foo.com\",\n\t\t\tdomainFilter: endpoint.NewDomainFilter([]string{\"foo.com\"}),\n\t\t\tzoneIDFilter: provider.NewZoneIDFilter([]string{\"\"}),\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpointWithTTL(\"foo.foo.com\", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), \"127.0.0.1\"),\n\t\t\t\tendpoint.NewEndpointWithTTL(\"foo.foo.com\", endpoint.RecordTypeTXT, endpoint.TTL(defaultTTL), \"heritage=external-dns,external-dns/owner=default,external-dns/resource=service/default/my-svc\"),\n\t\t\t\tendpoint.NewEndpointWithTTL(\"bar.foo.com\", endpoint.RecordTypeCNAME, endpoint.TTL(defaultTTL), \"bar.com.\"),\n\t\t\t},\n\t\t}, {\n\t\t\tname:         \"ZoneIDFilter_ocid1.dns-zone.oc1..502aeddba262b92fd13ed7874f6f1404\",\n\t\t\tdomainFilter: endpoint.NewDomainFilter([]string{\"\"}),\n\t\t\tzoneIDFilter: provider.NewZoneIDFilter([]string{\"ocid1.dns-zone.oc1..502aeddba262b92fd13ed7874f6f1404\"}),\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpointWithTTL(\"foo.bar.com\", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), \"127.0.0.1\"),\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tprovider := newOCIProvider(&mockOCIDNSClient{}, tc.domainFilter, tc.zoneIDFilter, \"\", false)\n\t\t\tendpoints, err := provider.Records(t.Context())\n\t\t\trequire.NoError(t, err)\n\t\t\trequire.ElementsMatch(t, tc.expected, endpoints)\n\t\t})\n\t}\n}\n\nfunc TestNewRecordOperation(t *testing.T) {\n\ttestCases := []struct {\n\t\tname     string\n\t\tep       *endpoint.Endpoint\n\t\topType   dns.RecordOperationOperationEnum\n\t\texpected dns.RecordOperation\n\t}{\n\t\t{\n\t\t\tname:   \"A_record\",\n\t\t\topType: dns.RecordOperationOperationAdd,\n\t\t\tep: endpoint.NewEndpointWithTTL(\n\t\t\t\t\"foo.foo.com\",\n\t\t\t\tendpoint.RecordTypeA,\n\t\t\t\tendpoint.TTL(defaultTTL),\n\t\t\t\t\"127.0.0.1\"),\n\t\t\texpected: dns.RecordOperation{\n\t\t\t\tDomain:    common.String(\"foo.foo.com\"),\n\t\t\t\tRdata:     common.String(\"127.0.0.1\"),\n\t\t\t\tRtype:     common.String(\"A\"),\n\t\t\t\tTtl:       common.Int(300),\n\t\t\t\tOperation: dns.RecordOperationOperationAdd,\n\t\t\t},\n\t\t}, {\n\t\t\tname:   \"TXT_record\",\n\t\t\topType: dns.RecordOperationOperationAdd,\n\t\t\tep: endpoint.NewEndpointWithTTL(\n\t\t\t\t\"foo.foo.com\",\n\t\t\t\tendpoint.RecordTypeTXT,\n\t\t\t\tendpoint.TTL(defaultTTL),\n\t\t\t\t\"heritage=external-dns,external-dns/owner=default,external-dns/resource=service/default/my-svc\"),\n\t\t\texpected: dns.RecordOperation{\n\t\t\t\tDomain:    common.String(\"foo.foo.com\"),\n\t\t\t\tRdata:     common.String(\"heritage=external-dns,external-dns/owner=default,external-dns/resource=service/default/my-svc\"),\n\t\t\t\tRtype:     common.String(\"TXT\"),\n\t\t\t\tTtl:       common.Int(300),\n\t\t\t\tOperation: dns.RecordOperationOperationAdd,\n\t\t\t},\n\t\t}, {\n\t\t\tname:   \"CNAME_record\",\n\t\t\topType: dns.RecordOperationOperationAdd,\n\t\t\tep: endpoint.NewEndpointWithTTL(\n\t\t\t\t\"foo.foo.com\",\n\t\t\t\tendpoint.RecordTypeCNAME,\n\t\t\t\tendpoint.TTL(defaultTTL),\n\t\t\t\t\"bar.com.\"),\n\t\t\texpected: dns.RecordOperation{\n\t\t\t\tDomain:    common.String(\"foo.foo.com\"),\n\t\t\t\tRdata:     common.String(\"bar.com.\"),\n\t\t\t\tRtype:     common.String(\"CNAME\"),\n\t\t\t\tTtl:       common.Int(300),\n\t\t\t\tOperation: dns.RecordOperationOperationAdd,\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\top := newRecordOperation(tc.ep, tc.opType)\n\t\t\trequire.Equal(t, tc.expected, op)\n\t\t})\n\t}\n}\n\nfunc TestOperationsByZone(t *testing.T) {\n\ttestCases := []struct {\n\t\tname     string\n\t\tzones    map[string]dns.ZoneSummary\n\t\tops      []dns.RecordOperation\n\t\texpected map[string][]dns.RecordOperation\n\t}{\n\t\t{\n\t\t\tname: \"basic\",\n\t\t\tzones: map[string]dns.ZoneSummary{\n\t\t\t\t\"foo\": {\n\t\t\t\t\tId:   common.String(\"foo\"),\n\t\t\t\t\tName: common.String(\"foo.com\"),\n\t\t\t\t},\n\t\t\t\t\"bar\": {\n\t\t\t\t\tId:   common.String(\"bar\"),\n\t\t\t\t\tName: common.String(\"bar.com\"),\n\t\t\t\t},\n\t\t\t},\n\t\t\tops: []dns.RecordOperation{\n\t\t\t\t{\n\t\t\t\t\tDomain:    common.String(\"foo.foo.com\"),\n\t\t\t\t\tRdata:     common.String(\"127.0.0.1\"),\n\t\t\t\t\tRtype:     common.String(\"A\"),\n\t\t\t\t\tTtl:       common.Int(300),\n\t\t\t\t\tOperation: dns.RecordOperationOperationAdd,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDomain:    common.String(\"foo.bar.com\"),\n\t\t\t\t\tRdata:     common.String(\"127.0.0.1\"),\n\t\t\t\t\tRtype:     common.String(\"A\"),\n\t\t\t\t\tTtl:       common.Int(300),\n\t\t\t\t\tOperation: dns.RecordOperationOperationAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: map[string][]dns.RecordOperation{\n\t\t\t\t\"foo\": {\n\t\t\t\t\t{\n\t\t\t\t\t\tDomain:    common.String(\"foo.foo.com\"),\n\t\t\t\t\t\tRdata:     common.String(\"127.0.0.1\"),\n\t\t\t\t\t\tRtype:     common.String(\"A\"),\n\t\t\t\t\t\tTtl:       common.Int(300),\n\t\t\t\t\t\tOperation: dns.RecordOperationOperationAdd,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t\"bar\": {\n\t\t\t\t\t{\n\t\t\t\t\t\tDomain:    common.String(\"foo.bar.com\"),\n\t\t\t\t\t\tRdata:     common.String(\"127.0.0.1\"),\n\t\t\t\t\t\tRtype:     common.String(\"A\"),\n\t\t\t\t\t\tTtl:       common.Int(300),\n\t\t\t\t\t\tOperation: dns.RecordOperationOperationAdd,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t}, {\n\t\t\tname: \"does_not_include_zones_with_no_changes\",\n\t\t\tzones: map[string]dns.ZoneSummary{\n\t\t\t\t\"foo\": {\n\t\t\t\t\tId:   common.String(\"foo\"),\n\t\t\t\t\tName: common.String(\"foo.com\"),\n\t\t\t\t},\n\t\t\t\t\"bar\": {\n\t\t\t\t\tId:   common.String(\"bar\"),\n\t\t\t\t\tName: common.String(\"bar.com\"),\n\t\t\t\t},\n\t\t\t},\n\t\t\tops: []dns.RecordOperation{\n\t\t\t\t{\n\t\t\t\t\tDomain:    common.String(\"foo.foo.com\"),\n\t\t\t\t\tRdata:     common.String(\"127.0.0.1\"),\n\t\t\t\t\tRtype:     common.String(\"A\"),\n\t\t\t\t\tTtl:       common.Int(300),\n\t\t\t\t\tOperation: dns.RecordOperationOperationAdd,\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: map[string][]dns.RecordOperation{\n\t\t\t\t\"foo\": {\n\t\t\t\t\t{\n\t\t\t\t\t\tDomain:    common.String(\"foo.foo.com\"),\n\t\t\t\t\t\tRdata:     common.String(\"127.0.0.1\"),\n\t\t\t\t\t\tRtype:     common.String(\"A\"),\n\t\t\t\t\t\tTtl:       common.Int(300),\n\t\t\t\t\t\tOperation: dns.RecordOperationOperationAdd,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tresult := operationsByZone(tc.zones, tc.ops)\n\t\t\trequire.Equal(t, tc.expected, result)\n\t\t})\n\t}\n}\n\ntype mutableMockOCIDNSClient struct {\n\tzones   map[string]dns.ZoneSummary\n\trecords map[string]map[string]dns.Record\n}\n\nfunc newMutableMockOCIDNSClient(zones []dns.ZoneSummary, recordsByZone map[string][]dns.Record) *mutableMockOCIDNSClient {\n\tc := &mutableMockOCIDNSClient{\n\t\tzones:   make(map[string]dns.ZoneSummary),\n\t\trecords: make(map[string]map[string]dns.Record),\n\t}\n\n\tfor _, zone := range zones {\n\t\tc.zones[*zone.Id] = zone\n\t\tc.records[*zone.Id] = make(map[string]dns.Record)\n\t}\n\n\tfor zoneID, records := range recordsByZone {\n\t\tfor _, record := range records {\n\t\t\tc.records[zoneID][ociRecordKey(*record.Rtype, *record.Domain, *record.Rdata)] = record\n\t\t}\n\t}\n\n\treturn c\n}\n\nfunc (c *mutableMockOCIDNSClient) ListZones(_ context.Context, _ dns.ListZonesRequest) (dns.ListZonesResponse, error) {\n\tvar zones []dns.ZoneSummary\n\tfor _, v := range c.zones {\n\t\tzones = append(zones, v)\n\t}\n\treturn dns.ListZonesResponse{Items: zones}, nil\n}\n\nfunc (c *mutableMockOCIDNSClient) GetZoneRecords(_ context.Context, request dns.GetZoneRecordsRequest) (dns.GetZoneRecordsResponse, error) {\n\tvar response dns.GetZoneRecordsResponse\n\tif request.ZoneNameOrId == nil {\n\t\treturn response, errors.New(\"no name or id\")\n\t}\n\n\trecords, ok := c.records[*request.ZoneNameOrId]\n\tif !ok {\n\t\treturn response, errors.New(\"zone not found\")\n\t}\n\n\tvar items []dns.Record\n\tfor _, v := range records {\n\t\titems = append(items, v)\n\t}\n\n\tresponse.Items = items\n\treturn response, nil\n}\n\nfunc ociRecordKey(rType, domain string, ip string) string {\n\trdata := \"\"\n\tif rType == \"A\" { // adds support for multi-targets with same rtype and domain\n\t\trdata = \"_\" + ip\n\t}\n\treturn rType + \"_\" + domain + rdata\n}\n\nfunc sortEndpointTargets(endpoints []*endpoint.Endpoint) {\n\tfor _, ep := range endpoints {\n\t\tsort.Strings([]string(ep.Targets))\n\t}\n}\n\nfunc (c *mutableMockOCIDNSClient) PatchZoneRecords(_ context.Context, request dns.PatchZoneRecordsRequest) (dns.PatchZoneRecordsResponse, error) {\n\tvar response dns.PatchZoneRecordsResponse\n\tif request.ZoneNameOrId == nil {\n\t\treturn response, errors.New(\"no name or id\")\n\t}\n\n\trecords, ok := c.records[*request.ZoneNameOrId]\n\tif !ok {\n\t\treturn response, errors.New(\"zone not found\")\n\t}\n\n\t// Ensure that ADD operations occur after REMOVE.\n\tsort.Slice(request.Items, func(i, j int) bool {\n\t\treturn request.Items[i].Operation > request.Items[j].Operation\n\t})\n\n\tfor _, op := range request.Items {\n\t\tk := ociRecordKey(*op.Rtype, *op.Domain, *op.Rdata)\n\t\tswitch op.Operation {\n\t\tcase dns.RecordOperationOperationAdd:\n\t\t\trecords[k] = dns.Record{\n\t\t\t\tDomain: op.Domain,\n\t\t\t\tRtype:  op.Rtype,\n\t\t\t\tRdata:  op.Rdata,\n\t\t\t\tTtl:    op.Ttl,\n\t\t\t}\n\t\tcase dns.RecordOperationOperationRemove:\n\t\t\tdelete(records, k)\n\t\tdefault:\n\t\t\treturn response, fmt.Errorf(\"unsupported operation %q\", op.Operation)\n\t\t}\n\t}\n\treturn response, nil\n}\n\n// TestMutableMockOCIDNSClient exists because one must always test one's tests\n// right...?\nfunc TestMutableMockOCIDNSClient(t *testing.T) {\n\tzones := []dns.ZoneSummary{{\n\t\tId:   common.String(\"ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959\"),\n\t\tName: common.String(\"foo.com\"),\n\t}}\n\trecords := map[string][]dns.Record{\n\t\t\"ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959\": {{\n\t\t\tDomain: common.String(\"foo.foo.com\"),\n\t\t\tRdata:  common.String(\"127.0.0.1\"),\n\t\t\tRtype:  common.String(endpoint.RecordTypeA),\n\t\t\tTtl:    common.Int(defaultTTL),\n\t\t}, {\n\t\t\tDomain: common.String(\"foo.foo.com\"),\n\t\t\tRdata:  common.String(\"heritage=external-dns,external-dns/owner=default,external-dns/resource=service/default/my-svc\"),\n\t\t\tRtype:  common.String(endpoint.RecordTypeTXT),\n\t\t\tTtl:    common.Int(defaultTTL),\n\t\t}},\n\t}\n\tclient := newMutableMockOCIDNSClient(zones, records)\n\n\t// First ListZones.\n\tzonesResponse, err := client.ListZones(t.Context(), dns.ListZonesRequest{})\n\trequire.NoError(t, err)\n\trequire.Len(t, zonesResponse.Items, 1)\n\trequire.Equal(t, zonesResponse.Items, zones)\n\n\t// GetZoneRecords for that zone.\n\trecordsResponse, err := client.GetZoneRecords(t.Context(), dns.GetZoneRecordsRequest{\n\t\tZoneNameOrId: zones[0].Id,\n\t})\n\trequire.NoError(t, err)\n\trequire.Len(t, recordsResponse.Items, 2)\n\trequire.ElementsMatch(t, recordsResponse.Items, records[\"ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959\"])\n\n\t// Remove the A record.\n\t_, err = client.PatchZoneRecords(t.Context(), dns.PatchZoneRecordsRequest{\n\t\tZoneNameOrId: common.String(\"ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959\"),\n\t\tPatchZoneRecordsDetails: dns.PatchZoneRecordsDetails{\n\t\t\tItems: []dns.RecordOperation{{\n\t\t\t\tDomain:    common.String(\"foo.foo.com\"),\n\t\t\t\tRdata:     common.String(\"127.0.0.1\"),\n\t\t\t\tRtype:     common.String(\"A\"),\n\t\t\t\tTtl:       common.Int(300),\n\t\t\t\tOperation: dns.RecordOperationOperationRemove,\n\t\t\t}},\n\t\t},\n\t})\n\trequire.NoError(t, err)\n\n\t// GetZoneRecords again and check the A record was removed.\n\trecordsResponse, err = client.GetZoneRecords(t.Context(), dns.GetZoneRecordsRequest{\n\t\tZoneNameOrId: zones[0].Id,\n\t})\n\trequire.NoError(t, err)\n\trequire.Len(t, recordsResponse.Items, 1)\n\trequire.Equal(t, recordsResponse.Items[0], records[\"ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959\"][1])\n\n\t// Add the A record back.\n\t_, err = client.PatchZoneRecords(t.Context(), dns.PatchZoneRecordsRequest{\n\t\tZoneNameOrId: common.String(\"ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959\"),\n\t\tPatchZoneRecordsDetails: dns.PatchZoneRecordsDetails{\n\t\t\tItems: []dns.RecordOperation{{\n\t\t\t\tDomain:    common.String(\"foo.foo.com\"),\n\t\t\t\tRdata:     common.String(\"127.0.0.1\"),\n\t\t\t\tRtype:     common.String(\"A\"),\n\t\t\t\tTtl:       common.Int(300),\n\t\t\t\tOperation: dns.RecordOperationOperationAdd,\n\t\t\t}},\n\t\t},\n\t})\n\trequire.NoError(t, err)\n\n\t// GetZoneRecords and check we're back in the original state\n\trecordsResponse, err = client.GetZoneRecords(t.Context(), dns.GetZoneRecordsRequest{\n\t\tZoneNameOrId: zones[0].Id,\n\t})\n\trequire.NoError(t, err)\n\trequire.Len(t, recordsResponse.Items, 2)\n\trequire.ElementsMatch(t, recordsResponse.Items, records[\"ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959\"])\n}\n\nfunc TestOCIApplyChanges(t *testing.T) {\n\n\ttestCases := []struct {\n\t\tname              string\n\t\tzones             []dns.ZoneSummary\n\t\trecords           map[string][]dns.Record\n\t\tchanges           *plan.Changes\n\t\tdryRun            bool\n\t\terr               error\n\t\texpectedEndpoints []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\tname: \"add\",\n\t\t\tzones: []dns.ZoneSummary{{\n\t\t\t\tId:   common.String(\"ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959\"),\n\t\t\t\tName: common.String(\"foo.com\"),\n\t\t\t}},\n\t\t\tchanges: &plan.Changes{\n\t\t\t\tCreate: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL(\n\t\t\t\t\t\"foo.foo.com\",\n\t\t\t\t\tendpoint.RecordTypeA,\n\t\t\t\t\tendpoint.TTL(defaultTTL),\n\t\t\t\t\t\"127.0.0.1\",\n\t\t\t\t)},\n\t\t\t},\n\t\t\texpectedEndpoints: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL(\n\t\t\t\t\"foo.foo.com\",\n\t\t\t\tendpoint.RecordTypeA,\n\t\t\t\tendpoint.TTL(defaultTTL),\n\t\t\t\t\"127.0.0.1\",\n\t\t\t)},\n\t\t}, {\n\t\t\tname: \"remove\",\n\t\t\tzones: []dns.ZoneSummary{{\n\t\t\t\tId:   common.String(\"ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959\"),\n\t\t\t\tName: common.String(\"foo.com\"),\n\t\t\t}},\n\t\t\trecords: map[string][]dns.Record{\n\t\t\t\t\"ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959\": {{\n\t\t\t\t\tDomain: common.String(\"foo.foo.com\"),\n\t\t\t\t\tRdata:  common.String(\"127.0.0.1\"),\n\t\t\t\t\tRtype:  common.String(endpoint.RecordTypeA),\n\t\t\t\t\tTtl:    common.Int(defaultTTL),\n\t\t\t\t}, {\n\t\t\t\t\tDomain: common.String(\"foo.foo.com\"),\n\t\t\t\t\tRdata:  common.String(\"heritage=external-dns,external-dns/owner=default,external-dns/resource=service/default/my-svc\"),\n\t\t\t\t\tRtype:  common.String(endpoint.RecordTypeTXT),\n\t\t\t\t\tTtl:    common.Int(defaultTTL),\n\t\t\t\t}},\n\t\t\t},\n\t\t\tchanges: &plan.Changes{\n\t\t\t\tDelete: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL(\n\t\t\t\t\t\"foo.foo.com\",\n\t\t\t\t\tendpoint.RecordTypeTXT,\n\t\t\t\t\tendpoint.TTL(defaultTTL),\n\t\t\t\t\t\"127.0.0.1\",\n\t\t\t\t)},\n\t\t\t},\n\t\t\texpectedEndpoints: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL(\n\t\t\t\t\"foo.foo.com\",\n\t\t\t\tendpoint.RecordTypeA,\n\t\t\t\tendpoint.TTL(defaultTTL),\n\t\t\t\t\"127.0.0.1\",\n\t\t\t)},\n\t\t}, {\n\t\t\tname: \"update\",\n\t\t\tzones: []dns.ZoneSummary{{\n\t\t\t\tId:   common.String(\"ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959\"),\n\t\t\t\tName: common.String(\"foo.com\"),\n\t\t\t}},\n\t\t\trecords: map[string][]dns.Record{\n\t\t\t\t\"ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959\": {{\n\t\t\t\t\tDomain: common.String(\"foo.foo.com\"),\n\t\t\t\t\tRdata:  common.String(\"127.0.0.1\"),\n\t\t\t\t\tRtype:  common.String(endpoint.RecordTypeA),\n\t\t\t\t\tTtl:    common.Int(defaultTTL),\n\t\t\t\t}},\n\t\t\t},\n\t\t\tchanges: &plan.Changes{\n\t\t\t\tUpdateOld: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL(\n\t\t\t\t\t\"foo.foo.com\",\n\t\t\t\t\tendpoint.RecordTypeA,\n\t\t\t\t\tendpoint.TTL(defaultTTL),\n\t\t\t\t\t\"127.0.0.1\",\n\t\t\t\t)},\n\t\t\t\tUpdateNew: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL(\n\t\t\t\t\t\"foo.foo.com\",\n\t\t\t\t\tendpoint.RecordTypeA,\n\t\t\t\t\tendpoint.TTL(defaultTTL),\n\t\t\t\t\t\"10.0.0.1\",\n\t\t\t\t)},\n\t\t\t},\n\t\t\texpectedEndpoints: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL(\n\t\t\t\t\"foo.foo.com\",\n\t\t\t\tendpoint.RecordTypeA,\n\t\t\t\tendpoint.TTL(defaultTTL),\n\t\t\t\t\"10.0.0.1\",\n\t\t\t)},\n\t\t}, {\n\t\t\tname: \"dry_run_no_changes\",\n\t\t\tzones: []dns.ZoneSummary{{\n\t\t\t\tId:   common.String(\"ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959\"),\n\t\t\t\tName: common.String(\"foo.com\"),\n\t\t\t}},\n\t\t\trecords: map[string][]dns.Record{\n\t\t\t\t\"ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959\": {{\n\t\t\t\t\tDomain: common.String(\"foo.foo.com\"),\n\t\t\t\t\tRdata:  common.String(\"127.0.0.1\"),\n\t\t\t\t\tRtype:  common.String(endpoint.RecordTypeA),\n\t\t\t\t\tTtl:    common.Int(defaultTTL),\n\t\t\t\t}},\n\t\t\t},\n\t\t\tchanges: &plan.Changes{\n\t\t\t\tDelete: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL(\n\t\t\t\t\t\"foo.foo.com\",\n\t\t\t\t\tendpoint.RecordTypeA,\n\t\t\t\t\tendpoint.TTL(defaultTTL),\n\t\t\t\t\t\"127.0.0.1\",\n\t\t\t\t)},\n\t\t\t},\n\t\t\tdryRun: true,\n\t\t\texpectedEndpoints: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL(\n\t\t\t\t\"foo.foo.com\",\n\t\t\t\tendpoint.RecordTypeA,\n\t\t\t\tendpoint.TTL(defaultTTL),\n\t\t\t\t\"127.0.0.1\",\n\t\t\t)},\n\t\t}, {\n\t\t\tname: \"add_remove_update\",\n\t\t\tzones: []dns.ZoneSummary{{\n\t\t\t\tId:   common.String(\"ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959\"),\n\t\t\t\tName: common.String(\"foo.com\"),\n\t\t\t}},\n\t\t\trecords: map[string][]dns.Record{\n\t\t\t\t\"ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959\": {{\n\t\t\t\t\tDomain: common.String(\"foo.foo.com\"),\n\t\t\t\t\tRdata:  common.String(\"127.0.0.1\"),\n\t\t\t\t\tRtype:  common.String(endpoint.RecordTypeA),\n\t\t\t\t\tTtl:    common.Int(defaultTTL),\n\t\t\t\t}, {\n\t\t\t\t\tDomain: common.String(\"car.foo.com\"),\n\t\t\t\t\tRdata:  common.String(\"bar.com.\"),\n\t\t\t\t\tRtype:  common.String(endpoint.RecordTypeCNAME),\n\t\t\t\t\tTtl:    common.Int(defaultTTL),\n\t\t\t\t}, {\n\t\t\t\t\tDomain: common.String(\"bar.foo.com\"),\n\t\t\t\t\tRdata:  common.String(\"baz.com.\"),\n\t\t\t\t\tRtype:  common.String(endpoint.RecordTypeCNAME),\n\t\t\t\t\tTtl:    common.Int(defaultTTL),\n\t\t\t\t}},\n\t\t\t},\n\t\t\tchanges: &plan.Changes{\n\t\t\t\tDelete: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL(\n\t\t\t\t\t\"foo.foo.com\",\n\t\t\t\t\tendpoint.RecordTypeA,\n\t\t\t\t\tendpoint.TTL(defaultTTL),\n\t\t\t\t\t\"127.0.0.1\",\n\t\t\t\t)},\n\t\t\t\tUpdateOld: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL(\n\t\t\t\t\t\"car.foo.com\",\n\t\t\t\t\tendpoint.RecordTypeCNAME,\n\t\t\t\t\tendpoint.TTL(defaultTTL),\n\t\t\t\t\t\"baz.com.\",\n\t\t\t\t)},\n\t\t\t\tUpdateNew: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL(\n\t\t\t\t\t\"bar.foo.com\",\n\t\t\t\t\tendpoint.RecordTypeCNAME,\n\t\t\t\t\tendpoint.TTL(defaultTTL),\n\t\t\t\t\t\"foo.bar.com.\",\n\t\t\t\t)},\n\t\t\t\tCreate: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL(\n\t\t\t\t\t\"baz.foo.com\",\n\t\t\t\t\tendpoint.RecordTypeA,\n\t\t\t\t\tendpoint.TTL(defaultTTL),\n\t\t\t\t\t\"127.0.0.1\",\n\t\t\t\t)},\n\t\t\t},\n\t\t\texpectedEndpoints: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpointWithTTL(\n\t\t\t\t\t\"bar.foo.com\",\n\t\t\t\t\tendpoint.RecordTypeCNAME,\n\t\t\t\t\tendpoint.TTL(defaultTTL),\n\t\t\t\t\t\"foo.bar.com.\",\n\t\t\t\t),\n\t\t\t\tendpoint.NewEndpointWithTTL(\n\t\t\t\t\t\"baz.foo.com\",\n\t\t\t\t\tendpoint.RecordTypeA,\n\t\t\t\t\tendpoint.TTL(defaultTTL),\n\t\t\t\t\t\"127.0.0.1\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"combine_multi_target\",\n\t\t\tzones: []dns.ZoneSummary{{\n\t\t\t\tId:   common.String(\"ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959\"),\n\t\t\t\tName: common.String(\"foo.com\"),\n\t\t\t}},\n\n\t\t\tchanges: &plan.Changes{\n\t\t\t\tCreate: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL(\n\t\t\t\t\t\"foo.foo.com\",\n\t\t\t\t\tendpoint.RecordTypeA,\n\t\t\t\t\tendpoint.TTL(defaultTTL),\n\t\t\t\t\t\"192.168.1.2\",\n\t\t\t\t), endpoint.NewEndpointWithTTL(\n\t\t\t\t\t\"foo.foo.com\",\n\t\t\t\t\tendpoint.RecordTypeA,\n\t\t\t\t\tendpoint.TTL(defaultTTL),\n\t\t\t\t\t\"192.168.2.5\",\n\t\t\t\t)},\n\t\t\t},\n\t\t\texpectedEndpoints: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL(\n\t\t\t\t\"foo.foo.com\",\n\t\t\t\tendpoint.RecordTypeA,\n\t\t\t\tendpoint.TTL(defaultTTL), \"192.168.1.2\", \"192.168.2.5\",\n\t\t\t)},\n\t\t},\n\t\t{\n\t\t\tname: \"remove_from_multi_target\",\n\t\t\tzones: []dns.ZoneSummary{{\n\t\t\t\tId:   common.String(\"ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959\"),\n\t\t\t\tName: common.String(\"foo.com\"),\n\t\t\t}},\n\t\t\trecords: map[string][]dns.Record{\n\t\t\t\t\"ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959\": {{\n\t\t\t\t\tDomain: common.String(\"foo.foo.com\"),\n\t\t\t\t\tRdata:  common.String(\"192.168.1.2\"),\n\t\t\t\t\tRtype:  common.String(endpoint.RecordTypeA),\n\t\t\t\t\tTtl:    common.Int(defaultTTL),\n\t\t\t\t}, {\n\t\t\t\t\tDomain: common.String(\"foo.foo.com\"),\n\t\t\t\t\tRdata:  common.String(\"192.168.2.5\"),\n\t\t\t\t\tRtype:  common.String(endpoint.RecordTypeA),\n\t\t\t\t\tTtl:    common.Int(defaultTTL),\n\t\t\t\t}},\n\t\t\t},\n\t\t\tchanges: &plan.Changes{\n\t\t\t\tDelete: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL(\n\t\t\t\t\t\"foo.foo.com\",\n\t\t\t\t\tendpoint.RecordTypeA,\n\t\t\t\t\tendpoint.TTL(defaultTTL),\n\t\t\t\t\t\"192.168.1.2\",\n\t\t\t\t)},\n\t\t\t},\n\t\t\texpectedEndpoints: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL(\n\t\t\t\t\"foo.foo.com\",\n\t\t\t\tendpoint.RecordTypeA,\n\t\t\t\tendpoint.TTL(defaultTTL), \"192.168.2.5\",\n\t\t\t)},\n\t\t},\n\t\t{\n\t\t\tname: \"update_multi_target\",\n\t\t\tzones: []dns.ZoneSummary{{\n\t\t\t\tId:   common.String(\"ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959\"),\n\t\t\t\tName: common.String(\"foo.com\"),\n\t\t\t}},\n\t\t\trecords: map[string][]dns.Record{\n\t\t\t\t\"ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959\": {{\n\t\t\t\t\tDomain: common.String(\"first.foo.com\"),\n\t\t\t\t\tRdata:  common.String(\"10.77.4.5\"),\n\t\t\t\t\tRtype:  common.String(endpoint.RecordTypeA),\n\t\t\t\t\tTtl:    common.Int(defaultTTL),\n\t\t\t\t}},\n\t\t\t},\n\t\t\tchanges: &plan.Changes{\n\t\t\t\tUpdateOld: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL(\n\t\t\t\t\t\"first.foo.com\",\n\t\t\t\t\tendpoint.RecordTypeA,\n\t\t\t\t\tendpoint.TTL(defaultTTL),\n\t\t\t\t\t\"10.77.4.5\",\n\t\t\t\t)},\n\t\t\t\tUpdateNew: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL(\n\t\t\t\t\t\"first.foo.com\",\n\t\t\t\t\tendpoint.RecordTypeA,\n\t\t\t\t\tendpoint.TTL(defaultTTL),\n\t\t\t\t\t\"10.77.6.10\",\n\t\t\t\t)},\n\t\t\t},\n\t\t\texpectedEndpoints: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL(\n\t\t\t\t\"first.foo.com\",\n\t\t\t\tendpoint.RecordTypeA,\n\t\t\t\tendpoint.TTL(defaultTTL),\n\t\t\t\t\"10.77.6.10\",\n\t\t\t)},\n\t\t},\n\t\t{\n\t\t\tname: \"increase_multi_target\",\n\t\t\tzones: []dns.ZoneSummary{{\n\t\t\t\tId:   common.String(\"ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959\"),\n\t\t\t\tName: common.String(\"foo.com\"),\n\t\t\t}},\n\t\t\trecords: map[string][]dns.Record{\n\t\t\t\t\"ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959\": {{\n\t\t\t\t\tDomain: common.String(\"first.foo.com\"),\n\t\t\t\t\tRdata:  common.String(\"10.77.4.5\"),\n\t\t\t\t\tRtype:  common.String(endpoint.RecordTypeA),\n\t\t\t\t\tTtl:    common.Int(defaultTTL),\n\t\t\t\t}},\n\t\t\t},\n\t\t\tchanges: &plan.Changes{\n\t\t\t\tCreate: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL(\n\t\t\t\t\t\"first.foo.com\",\n\t\t\t\t\tendpoint.RecordTypeA,\n\t\t\t\t\tendpoint.TTL(defaultTTL),\n\t\t\t\t\t\"10.77.6.10\",\n\t\t\t\t)},\n\t\t\t},\n\t\t\texpectedEndpoints: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL(\n\t\t\t\t\"first.foo.com\",\n\t\t\t\tendpoint.RecordTypeA,\n\t\t\t\tendpoint.TTL(defaultTTL),\n\t\t\t\t\"10.77.4.5\", \"10.77.6.10\",\n\t\t\t)},\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tclient := newMutableMockOCIDNSClient(tc.zones, tc.records)\n\t\t\tprovider := newOCIProvider(\n\t\t\t\tclient,\n\t\t\t\tendpoint.NewDomainFilter([]string{\"\"}),\n\t\t\t\tprovider.NewZoneIDFilter([]string{\"\"}),\n\t\t\t\t\"\",\n\t\t\t\ttc.dryRun,\n\t\t\t)\n\n\t\t\tctx := t.Context()\n\t\t\terr := provider.ApplyChanges(ctx, tc.changes)\n\t\t\trequire.Equal(t, tc.err, err)\n\t\t\tendpoints, err := provider.Records(ctx)\n\t\t\trequire.NoError(t, err)\n\t\t\tsortEndpointTargets(endpoints)\n\t\t\tsortEndpointTargets(tc.expectedEndpoints)\n\t\t\trequire.ElementsMatch(t, tc.expectedEndpoints, endpoints)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "provider/ovh/ovh.go",
    "content": "/*\nCopyright 2020 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage ovh\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/url\"\n\t\"slices\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/miekg/dns\"\n\t\"github.com/ovh/go-ovh/ovh\"\n\t\"github.com/patrickmn/go-cache\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"golang.org/x/sync/errgroup\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/internal/idna\"\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n\n\t\"go.uber.org/ratelimit\"\n)\n\nconst (\n\tdefaultTTL = 0\n\tovhCreate  = iota\n\tovhDelete\n\tovhUpdate\n)\n\nvar (\n\t// ErrRecordToMutateNotFound when ApplyChange has to update/delete and didn't found the record in the existing zone (Change with no record ID)\n\tErrRecordToMutateNotFound = errors.New(\"record to mutate not found in current zone\")\n)\n\n// OVHProvider is an implementation of Provider for OVH DNS.\ntype OVHProvider struct {\n\tprovider.BaseProvider\n\n\tclient ovhClient\n\n\tapiRateLimiter ratelimit.Limiter\n\n\tdomainFilter *endpoint.DomainFilter\n\n\t// DryRun enables dry-run mode\n\tDryRun bool\n\n\t// EnableCNAMERelativeTarget controls if CNAME target should be sent with relative format.\n\t// Previous implementations of the OVHProvider always added a final dot as for absolut format.\n\t// Default value is false, all CNAME are transformed into absolut format.\n\t// Setting this to true will allow relative format to be sent to DNS zone.\n\tEnableCNAMERelativeTarget bool\n\n\t// UseCache controls if the OVHProvider will cache records in memory, and serve them\n\t// without recontacting the OVHcloud API if the SOA of the domain zone hasn't changed.\n\t// Note that, when disabling cache, OVHcloud API has rate-limiting that will hit if\n\t// your refresh rate/number of records is too big, which might cause issue with the\n\t// provider.\n\t// Default value: true\n\tUseCache       bool\n\tlastRunRecords []ovhRecord\n\tlastRunZones   []string\n\n\tcacheInstance *cache.Cache\n\tdnsClient     dnsClient\n}\n\ntype ovhClient interface {\n\tPostWithContext(context.Context, string, any, any) error\n\tPutWithContext(context.Context, string, any, any) error\n\tGetWithContext(context.Context, string, any) error\n\tDeleteWithContext(context.Context, string, any) error\n}\n\ntype dnsClient interface {\n\tExchangeContext(ctx context.Context, m *dns.Msg, a string) (*dns.Msg, time.Duration, error)\n}\n\ntype ovhRecordFields struct {\n\tovhRecordFieldUpdate\n\tFieldType string `json:\"fieldType\"`\n}\n\ntype ovhRecordFieldUpdate struct {\n\tSubDomain string `json:\"subDomain\"`\n\tTTL       int64  `json:\"ttl\"`\n\tTarget    string `json:\"target\"`\n}\n\ntype ovhRecord struct {\n\tovhRecordFields\n\tID   uint64 `json:\"id\"`\n\tZone string `json:\"zone\"`\n}\n\nfunc (r ovhRecord) String() string {\n\treturn \"record#\" + strconv.Itoa(int(r.ID)) + \": \" + r.FieldType + \" | \" + r.SubDomain + \" => \" + r.Target + \" (\" + strconv.Itoa(int(r.TTL)) + \")\"\n}\n\ntype ovhChange struct {\n\tovhRecord\n\tAction int\n}\n\n// New creates an OVH provider from the given configuration.\nfunc New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) {\n\treturn newProvider(domainFilter, cfg.OVHEndpoint, cfg.OVHApiRateLimit, cfg.OVHEnableCNAMERelative, cfg.DryRun)\n}\n\n// newProvider initializes a new OVH DNS based Provider.\nfunc newProvider(\n\tdomainFilter *endpoint.DomainFilter,\n\tendpoint string,\n\tapiRateLimit int,\n\tenableCNAMERelative,\n\tdryRun bool) (*OVHProvider, error) {\n\tclient, err := ovh.NewEndpointClient(endpoint)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tclient.UserAgent = externaldns.UserAgent()\n\n\treturn &OVHProvider{\n\t\tclient:                    client,\n\t\tdomainFilter:              domainFilter,\n\t\tapiRateLimiter:            ratelimit.New(apiRateLimit),\n\t\tDryRun:                    dryRun,\n\t\tcacheInstance:             cache.New(cache.NoExpiration, cache.NoExpiration),\n\t\tdnsClient:                 new(dns.Client),\n\t\tUseCache:                  true,\n\t\tEnableCNAMERelativeTarget: enableCNAMERelative,\n\t}, nil\n}\n\n// Records returns the list of records in all relevant zones.\nfunc (p *OVHProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {\n\tzones, records, err := p.zonesRecords(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tp.lastRunRecords = records\n\tp.lastRunZones = zones\n\tendpoints := ovhGroupByNameAndType(records)\n\tlog.Infof(\"OVH: %d endpoints have been found\", len(endpoints))\n\treturn endpoints, nil\n}\n\nfunc planChangesByZoneName(zones []string, changes *plan.Changes) (map[string]*plan.Changes, error) {\n\tzoneNameIDMapper := provider.ZoneIDName{}\n\tfor _, zone := range zones {\n\t\tzoneNameIDMapper.Add(zone, zone)\n\t}\n\n\toutput := map[string]*plan.Changes{}\n\tfor _, endpt := range changes.Delete {\n\t\tzoneName, _ := zoneNameIDMapper.FindZone(endpt.DNSName)\n\t\tif zoneName == \"\" {\n\t\t\treturn nil, provider.NewSoftErrorf(\"record %q have not found matching DNS zone in OVH provider\", endpt.DNSName)\n\t\t}\n\t\tif _, ok := output[zoneName]; !ok {\n\t\t\toutput[zoneName] = &plan.Changes{}\n\t\t}\n\t\toutput[zoneName].Delete = append(output[zoneName].Delete, endpt)\n\t}\n\tfor _, endpt := range changes.Create {\n\t\tzoneName, _ := zoneNameIDMapper.FindZone(endpt.DNSName)\n\t\tif zoneName == \"\" {\n\t\t\treturn nil, provider.NewSoftErrorf(\"record %q have not found matching DNS zone in OVH provider\", endpt.DNSName)\n\t\t}\n\t\tif _, ok := output[zoneName]; !ok {\n\t\t\toutput[zoneName] = &plan.Changes{}\n\t\t}\n\t\toutput[zoneName].Create = append(output[zoneName].Create, endpt)\n\t}\n\tfor _, endpt := range changes.UpdateOld {\n\t\tzoneName, _ := zoneNameIDMapper.FindZone(endpt.DNSName)\n\t\tif zoneName == \"\" {\n\t\t\treturn nil, provider.NewSoftErrorf(\"record %q have not found matching DNS zone in OVH provider\", endpt.DNSName)\n\t\t}\n\t\tif _, ok := output[zoneName]; !ok {\n\t\t\toutput[zoneName] = &plan.Changes{}\n\t\t}\n\t\toutput[zoneName].UpdateOld = append(output[zoneName].UpdateOld, endpt)\n\t}\n\tfor _, endpt := range changes.UpdateNew {\n\t\tzoneName, _ := zoneNameIDMapper.FindZone(endpt.DNSName)\n\t\tif zoneName == \"\" {\n\t\t\treturn nil, provider.NewSoftErrorf(\"record %q have not found matching DNS zone in OVH provider\", endpt.DNSName)\n\t\t}\n\t\tif _, ok := output[zoneName]; !ok {\n\t\t\toutput[zoneName] = &plan.Changes{}\n\t\t}\n\t\toutput[zoneName].UpdateNew = append(output[zoneName].UpdateNew, endpt)\n\t}\n\n\treturn output, nil\n}\n\nfunc (p *OVHProvider) computeSingleZoneChanges(_ context.Context, zoneName string, existingRecords []ovhRecord, changes *plan.Changes) ([]ovhChange, error) {\n\tallChanges := []ovhChange{}\n\tvar computedChanges []ovhChange\n\n\tcomputedChanges, existingRecords = p.newOvhChangeCreateDelete(ovhCreate, changes.Create, zoneName, existingRecords)\n\tallChanges = append(allChanges, computedChanges...)\n\tcomputedChanges, existingRecords = p.newOvhChangeCreateDelete(ovhDelete, changes.Delete, zoneName, existingRecords)\n\tallChanges = append(allChanges, computedChanges...)\n\n\tvar err error\n\tcomputedChanges, err = p.newOvhChangeUpdate(changes.UpdateOld, changes.UpdateNew, zoneName, existingRecords)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tallChanges = append(allChanges, computedChanges...)\n\n\treturn allChanges, nil\n}\n\nfunc (p *OVHProvider) handleSingleZoneUpdate(ctx context.Context, zoneName string, existingRecords []ovhRecord, changes *plan.Changes) error {\n\tallChanges, err := p.computeSingleZoneChanges(ctx, zoneName, existingRecords, changes)\n\tif err != nil {\n\t\treturn err\n\t}\n\tlog.Infof(\"OVH: %q: %d changes will be done\", zoneName, len(allChanges))\n\n\teg, ctxErrGroup := errgroup.WithContext(ctx)\n\tfor _, change := range allChanges {\n\t\teg.Go(func() error {\n\t\t\treturn p.change(ctxErrGroup, change)\n\t\t})\n\t}\n\n\terr = eg.Wait()\n\n\t// do not refresh zone if errors: some records might haven't been processed yet, hence the zone will be in an inconsistent state\n\t// if modification of the zone was in error, invalidating the cache to make sure next run will start freshly\n\tif err == nil {\n\t\terr = p.refresh(ctx, zoneName)\n\t} else {\n\t\tp.invalidateCache(zoneName)\n\t}\n\n\treturn err\n}\n\n// ApplyChanges applies a given set of changes in a given zone.\nfunc (p *OVHProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {\n\tzones, records := p.lastRunZones, p.lastRunRecords\n\tdefer func() {\n\t\tp.lastRunRecords = []ovhRecord{}\n\t\tp.lastRunZones = []string{}\n\t}()\n\n\tif log.IsLevelEnabled(log.DebugLevel) {\n\t\tfor _, change := range changes.Create {\n\t\t\tlog.Debugf(\"OVH: changes CREATE dns:%q / targets:%v / type:%s\", change.DNSName, change.Targets, change.RecordType)\n\t\t}\n\t\tfor _, change := range changes.UpdateOld {\n\t\t\tlog.Debugf(\"OVH: changes UPDATEOLD dns:%q / targets:%v / type:%s\", change.DNSName, change.Targets, change.RecordType)\n\t\t}\n\t\tfor _, change := range changes.UpdateNew {\n\t\t\tlog.Debugf(\"OVH: changes UPDATENEW dns:%q / targets:%v / type:%s\", change.DNSName, change.Targets, change.RecordType)\n\t\t}\n\t\tfor _, change := range changes.Delete {\n\t\t\tlog.Debugf(\"OVH: changes DELETE dns:%q / targets:%v / type:%s\", change.DNSName, change.Targets, change.RecordType)\n\t\t}\n\t}\n\n\tchangesByZoneName, err := planChangesByZoneName(zones, changes)\n\tif err != nil {\n\t\treturn err\n\t}\n\teg, ctx := errgroup.WithContext(ctx)\n\n\tfor zoneName, changes := range changesByZoneName {\n\t\teg.Go(func() error {\n\t\t\treturn p.handleSingleZoneUpdate(ctx, zoneName, records, changes)\n\t\t})\n\t}\n\n\tif err := eg.Wait(); err != nil {\n\t\treturn provider.NewSoftError(err)\n\t}\n\n\treturn nil\n}\n\nfunc (p *OVHProvider) refresh(ctx context.Context, zone string) error {\n\tlog.Debugf(\"OVH: Refresh %s zone\", zone)\n\n\t// Zone has been altered so we invalidate the cache\n\t// so that the next run will reload it.\n\tp.invalidateCache(zone)\n\n\tp.apiRateLimiter.Take()\n\tif p.DryRun {\n\t\tlog.Infof(\"OVH: Dry-run: Would have refresh DNS zone %q\", zone)\n\t\treturn nil\n\t}\n\tif err := p.client.PostWithContext(ctx, fmt.Sprintf(\"/domain/zone/%s/refresh\", url.PathEscape(zone)), nil, nil); err != nil {\n\t\treturn provider.NewSoftError(err)\n\t}\n\n\treturn nil\n}\n\nfunc (p *OVHProvider) change(ctx context.Context, change ovhChange) error {\n\tp.apiRateLimiter.Take()\n\n\tswitch change.Action {\n\tcase ovhCreate:\n\t\tlog.Debugf(\"OVH: Add an entry to %s\", change.String())\n\t\tif p.DryRun {\n\t\t\tlog.Infof(\"OVH: Dry-run: Would have created a DNS record for zone %s\", change.Zone)\n\t\t\treturn nil\n\t\t}\n\t\treturn p.client.PostWithContext(ctx, fmt.Sprintf(\"/domain/zone/%s/record\", url.PathEscape(change.Zone)), change.ovhRecordFields, nil)\n\tcase ovhDelete:\n\t\tif change.ID == 0 {\n\t\t\treturn ErrRecordToMutateNotFound\n\t\t}\n\t\tlog.Debugf(\"OVH: Delete an entry to %s\", change.String())\n\t\tif p.DryRun {\n\t\t\tlog.Infof(\"OVH: Dry-run: Would have deleted a DNS record for zone %s\", change.Zone)\n\t\t\treturn nil\n\t\t}\n\t\treturn p.client.DeleteWithContext(ctx, fmt.Sprintf(\"/domain/zone/%s/record/%d\", url.PathEscape(change.Zone), change.ID), nil)\n\tcase ovhUpdate:\n\t\tif change.ID == 0 {\n\t\t\treturn ErrRecordToMutateNotFound\n\t\t}\n\t\tlog.Debugf(\"OVH: Update an entry to %s\", change.String())\n\t\tif p.DryRun {\n\t\t\tlog.Infof(\"OVH: Dry-run: Would have updated a DNS record for zone %s\", change.Zone)\n\t\t\treturn nil\n\t\t}\n\t\treturn p.client.PutWithContext(ctx, fmt.Sprintf(\"/domain/zone/%s/record/%d\", url.PathEscape(change.Zone), change.ID), change.ovhRecordFieldUpdate, nil)\n\tdefault:\n\t\treturn nil\n\t}\n}\n\nfunc (p *OVHProvider) invalidateCache(zone string) {\n\tp.cacheInstance.Delete(zone + \"#soa\")\n}\n\nfunc (p *OVHProvider) zonesRecords(ctx context.Context) ([]string, []ovhRecord, error) {\n\tvar allRecords []ovhRecord\n\tzones, err := p.zones(ctx)\n\tif err != nil {\n\t\treturn nil, nil, provider.NewSoftError(err)\n\t}\n\n\tchRecords := make(chan []ovhRecord, len(zones))\n\teg, ctx := errgroup.WithContext(ctx)\n\tfor _, zone := range zones {\n\t\teg.Go(func() error { return p.records(ctx, &zone, chRecords) })\n\t}\n\tif err := eg.Wait(); err != nil {\n\t\treturn nil, nil, provider.NewSoftError(err)\n\t}\n\tclose(chRecords)\n\tfor records := range chRecords {\n\t\tallRecords = append(allRecords, records...)\n\t}\n\treturn zones, allRecords, nil\n}\n\nfunc (p *OVHProvider) zones(ctx context.Context) ([]string, error) {\n\tvar zones []string\n\tvar filteredZones []string\n\n\tp.apiRateLimiter.Take()\n\tif err := p.client.GetWithContext(ctx, \"/domain/zone\", &zones); err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor _, zoneName := range zones {\n\t\tif p.domainFilter == nil || p.domainFilter.Match(zoneName) {\n\t\t\tfilteredZones = append(filteredZones, zoneName)\n\t\t}\n\t}\n\tlog.Infof(\"OVH: %d zones found\", len(filteredZones))\n\treturn filteredZones, nil\n}\n\ntype ovhSoa struct {\n\tServer  string `json:\"server\"`\n\tSerial  uint32 `json:\"serial\"`\n\trecords []ovhRecord\n}\n\nfunc (p *OVHProvider) records(ctx context.Context, zone *string, records chan<- []ovhRecord) error {\n\tvar recordsIds []uint64\n\tovhRecords := make([]ovhRecord, len(recordsIds))\n\teg, ctxErrGroup := errgroup.WithContext(ctx)\n\n\tif p.UseCache {\n\t\tif cachedSoaItf, ok := p.cacheInstance.Get(*zone + \"#soa\"); ok {\n\t\t\tcachedSoa := cachedSoaItf.(ovhSoa)\n\n\t\t\tlog.Debugf(\"OVH: zone %s: Checking SOA against %v\", *zone, cachedSoa.Serial)\n\n\t\t\tm := new(dns.Msg)\n\t\t\tm.SetQuestion(dns.Fqdn(*zone), dns.TypeSOA)\n\t\t\tin, _, err := p.dnsClient.ExchangeContext(ctx, m, strings.TrimSuffix(cachedSoa.Server, \".\")+\":53\")\n\t\t\tif err == nil {\n\t\t\t\tif s, ok := in.Answer[0].(*dns.SOA); ok {\n\t\t\t\t\tif s.Serial == cachedSoa.Serial {\n\t\t\t\t\t\tlog.Debugf(\"OVH: zone %s: SOA from cache is valid\", *zone)\n\t\t\t\t\t\trecords <- cachedSoa.records\n\t\t\t\t\t\treturn nil\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tp.invalidateCache(*zone)\n\t\t}\n\t}\n\n\tlog.Debugf(\"OVH: Getting records for %s from API\", *zone)\n\n\tp.apiRateLimiter.Take()\n\tvar soa ovhSoa\n\tif p.UseCache {\n\t\tif err := p.client.GetWithContext(ctx, \"/domain/zone/\"+url.PathEscape(*zone)+\"/soa\", &soa); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif err := p.client.GetWithContext(ctx, fmt.Sprintf(\"/domain/zone/%s/record\", url.PathEscape(*zone)), &recordsIds); err != nil {\n\t\treturn err\n\t}\n\tchRecords := make(chan ovhRecord, len(recordsIds))\n\tfor _, id := range recordsIds {\n\t\teg.Go(func() error { return p.record(ctxErrGroup, zone, id, chRecords) })\n\t}\n\tif err := eg.Wait(); err != nil {\n\t\treturn err\n\t}\n\tclose(chRecords)\n\tfor record := range chRecords {\n\t\tovhRecords = append(ovhRecords, record)\n\t}\n\n\tif p.UseCache {\n\t\tsoa.records = ovhRecords\n\t\t_ = p.cacheInstance.Add(*zone+\"#soa\", soa, cache.DefaultExpiration)\n\t}\n\n\trecords <- ovhRecords\n\treturn nil\n}\n\nfunc (p *OVHProvider) record(ctx context.Context, zone *string, id uint64, records chan<- ovhRecord) error {\n\trecord := ovhRecord{}\n\n\tlog.Debugf(\"OVH: Getting record %d for %s\", id, *zone)\n\n\tp.apiRateLimiter.Take()\n\tif err := p.client.GetWithContext(ctx, fmt.Sprintf(\"/domain/zone/%s/record/%d\", url.PathEscape(*zone), id), &record); err != nil {\n\t\treturn err\n\t}\n\tif provider.SupportedRecordType(record.FieldType) {\n\t\tlog.Debugf(\"OVH: Record %d for %s is %+v\", id, *zone, record)\n\t\trecords <- record\n\t}\n\treturn nil\n}\n\nfunc ovhGroupByNameAndType(records []ovhRecord) []*endpoint.Endpoint {\n\tendpoints := []*endpoint.Endpoint{}\n\n\t// group supported records by name and type\n\tgroups := map[string][]ovhRecord{}\n\n\tfor _, r := range records {\n\t\tgroupBy := r.Zone + \"//\" + r.SubDomain + \"//\" + r.FieldType\n\t\tif _, ok := groups[groupBy]; !ok {\n\t\t\tgroups[groupBy] = []ovhRecord{}\n\t\t}\n\n\t\tgroups[groupBy] = append(groups[groupBy], r)\n\t}\n\n\t// create single endpoint with all the targets for each name/type\n\tfor _, records := range groups {\n\t\tvar targets []string\n\t\tfor _, record := range records {\n\t\t\ttargets = append(targets, record.Target)\n\t\t}\n\t\tep := endpoint.NewEndpointWithTTL(\n\t\t\tstrings.TrimPrefix(records[0].SubDomain+\".\"+records[0].Zone, \".\"),\n\t\t\trecords[0].FieldType,\n\t\t\tendpoint.TTL(records[0].TTL),\n\t\t\ttargets...,\n\t\t)\n\t\tendpoints = append(endpoints, ep)\n\t}\n\n\treturn endpoints\n}\n\nfunc (p *OVHProvider) newOvhChangeCreateDelete(action int, endpoints []*endpoint.Endpoint, zone string, existingRecords []ovhRecord) ([]ovhChange, []ovhRecord) {\n\tvar ovhChanges []ovhChange\n\tvar toDeleteIds []int\n\n\tfor _, e := range endpoints {\n\t\tfor _, target := range e.Targets {\n\t\t\tchange := ovhChange{\n\t\t\t\tAction: action,\n\t\t\t\tovhRecord: ovhRecord{\n\t\t\t\t\tZone: zone,\n\t\t\t\t\tovhRecordFields: ovhRecordFields{\n\t\t\t\t\t\tFieldType: e.RecordType,\n\t\t\t\t\t\tovhRecordFieldUpdate: ovhRecordFieldUpdate{\n\t\t\t\t\t\t\tSubDomain: convertDNSNameIntoSubDomain(e.DNSName, zone),\n\t\t\t\t\t\t\tTTL:       defaultTTL,\n\t\t\t\t\t\t\tTarget:    target,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}\n\t\t\tp.formatCNAMETarget(&change)\n\t\t\tif e.RecordTTL.IsConfigured() {\n\t\t\t\tchange.TTL = int64(e.RecordTTL)\n\t\t\t}\n\n\t\t\t// The Zone might have multiple records with the same target. In order to avoid applying the action to the\n\t\t\t// same OVH record, we remove a record from the list when a match is found.\n\t\t\tif action == ovhDelete {\n\t\t\t\tfor i, rec := range existingRecords {\n\t\t\t\t\tif rec.Zone == change.Zone && rec.SubDomain == change.SubDomain && rec.FieldType == change.FieldType && rec.Target == change.Target && !slices.Contains(toDeleteIds, i) {\n\t\t\t\t\t\tchange.ID = rec.ID\n\t\t\t\t\t\ttoDeleteIds = append(toDeleteIds, i)\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tovhChanges = append(ovhChanges, change)\n\t\t}\n\t}\n\n\tif len(toDeleteIds) > 0 {\n\t\t// Copy the records because we need to mutate the list.\n\t\tnewExistingRecords := make([]ovhRecord, 0, len(existingRecords)-len(toDeleteIds))\n\t\tfor id := range existingRecords {\n\t\t\tif slices.Contains(toDeleteIds, id) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tnewExistingRecords = append(newExistingRecords, existingRecords[id])\n\t\t}\n\t\texistingRecords = newExistingRecords\n\t}\n\n\treturn ovhChanges, existingRecords\n}\n\nfunc convertDNSNameIntoSubDomain(DNSName string, zoneName string) string { // nolint: gocritic // captLocal\n\tif DNSName == zoneName {\n\t\treturn \"\"\n\t}\n\n\tif name, err := idna.Profile.ToUnicode(DNSName); err == nil {\n\t\tDNSName = name\n\t}\n\tif name, err := idna.Profile.ToUnicode(zoneName); err == nil {\n\t\tzoneName = name\n\t}\n\n\treturn strings.TrimSuffix(DNSName, \".\"+zoneName)\n}\n\nfunc normalizeDNSName(dnsName string) string {\n\treturn strings.TrimSpace(strings.ToLower(dnsName))\n}\n\nfunc (p *OVHProvider) newOvhChangeUpdate(endpointsOld []*endpoint.Endpoint, endpointsNew []*endpoint.Endpoint, zone string, existingRecords []ovhRecord) ([]ovhChange, error) {\n\tzoneNameIDMapper := provider.ZoneIDName{}\n\tzoneNameIDMapper.Add(zone, zone)\n\n\toldEndpointByTypeAndName := map[string]*endpoint.Endpoint{}\n\tnewEndpointByTypeAndName := map[string]*endpoint.Endpoint{}\n\toldRecordsInZone := map[string][]ovhRecord{}\n\n\tfor _, e := range endpointsOld {\n\t\tsub := convertDNSNameIntoSubDomain(e.DNSName, zone)\n\t\toldEndpointByTypeAndName[normalizeDNSName(e.RecordType+\"//\"+sub)] = e\n\t}\n\tfor _, e := range endpointsNew {\n\t\tsub := convertDNSNameIntoSubDomain(e.DNSName, zone)\n\t\tnewEndpointByTypeAndName[normalizeDNSName(e.RecordType+\"//\"+sub)] = e\n\t}\n\n\tfor id := range oldEndpointByTypeAndName {\n\t\tfor _, record := range existingRecords {\n\t\t\tif id == normalizeDNSName(record.FieldType+\"//\"+record.SubDomain) {\n\t\t\t\toldRecordsInZone[id] = append(oldRecordsInZone[id], record)\n\t\t\t}\n\t\t}\n\t}\n\n\tvar changes []ovhChange\n\n\tfor id := range oldEndpointByTypeAndName {\n\t\toldRecords := slices.Clone(oldRecordsInZone[id])\n\t\tendpointsNew, ok := newEndpointByTypeAndName[id]\n\t\tif !ok {\n\t\t\treturn nil, errors.New(\"unrecoverable error: couldn't find the matching record in the update.New\")\n\t\t}\n\n\t\tvar toInsertTarget []string\n\n\t\tfor _, target := range endpointsNew.Targets {\n\t\t\tvar toDelete = -1\n\n\t\t\tfor i, record := range oldRecords {\n\t\t\t\tif target == record.Target {\n\t\t\t\t\ttoDelete = i\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif toDelete >= 0 {\n\t\t\t\toldRecords = slices.Delete(oldRecords, toDelete, toDelete+1)\n\t\t\t} else {\n\t\t\t\ttoInsertTarget = append(toInsertTarget, target)\n\t\t\t}\n\t\t}\n\n\t\tcreateChangeConvertedToUpdateChange := []int{}\n\t\tfor i, target := range toInsertTarget {\n\t\t\tif len(oldRecords) == 0 {\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\trecord := oldRecords[0]\n\t\t\toldRecords = slices.Delete(oldRecords, 0, 1)\n\t\t\trecord.Target = target\n\n\t\t\tif endpointsNew.RecordTTL.IsConfigured() {\n\t\t\t\trecord.TTL = int64(endpointsNew.RecordTTL)\n\t\t\t} else {\n\t\t\t\trecord.TTL = defaultTTL\n\t\t\t}\n\n\t\t\tchange := ovhChange{\n\t\t\t\tAction:    ovhUpdate,\n\t\t\t\tovhRecord: record,\n\t\t\t}\n\t\t\tp.formatCNAMETarget(&change)\n\t\t\tchanges = append(changes, change)\n\t\t\tcreateChangeConvertedToUpdateChange = append(createChangeConvertedToUpdateChange, i)\n\t\t}\n\n\t\tnewToInsertTarget := make([]string, 0, len(toInsertTarget)-len(createChangeConvertedToUpdateChange))\n\t\tfor i := range toInsertTarget {\n\t\t\tif slices.Contains(createChangeConvertedToUpdateChange, i) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tnewToInsertTarget = append(newToInsertTarget, toInsertTarget[i])\n\t\t}\n\t\ttoInsertTarget = newToInsertTarget\n\n\t\tif len(toInsertTarget) > 0 {\n\t\t\tfor _, target := range toInsertTarget {\n\t\t\t\trecordTTL := int64(defaultTTL)\n\t\t\t\tif endpointsNew.RecordTTL.IsConfigured() {\n\t\t\t\t\trecordTTL = int64(endpointsNew.RecordTTL)\n\t\t\t\t}\n\n\t\t\t\tchange := ovhChange{\n\t\t\t\t\tAction: ovhCreate,\n\t\t\t\t\tovhRecord: ovhRecord{\n\t\t\t\t\t\tZone: zone,\n\t\t\t\t\t\tovhRecordFields: ovhRecordFields{\n\t\t\t\t\t\t\tFieldType: endpointsNew.RecordType,\n\t\t\t\t\t\t\tovhRecordFieldUpdate: ovhRecordFieldUpdate{\n\t\t\t\t\t\t\t\tSubDomain: convertDNSNameIntoSubDomain(endpointsNew.DNSName, zone),\n\t\t\t\t\t\t\t\tTTL:       recordTTL,\n\t\t\t\t\t\t\t\tTarget:    target,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t}\n\t\t\t\tp.formatCNAMETarget(&change)\n\t\t\t\tchanges = append(changes, change)\n\t\t\t}\n\t\t}\n\n\t\tif len(oldRecords) > 0 {\n\t\t\tfor i := range oldRecords {\n\t\t\t\tchanges = append(changes, ovhChange{\n\t\t\t\t\tAction:    ovhDelete,\n\t\t\t\t\tovhRecord: oldRecords[i],\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n\n\treturn changes, nil\n}\n\nfunc (c *ovhChange) String() string {\n\tvar action string\n\tswitch c.Action {\n\tcase ovhCreate:\n\t\taction = \"create\"\n\tcase ovhUpdate:\n\t\taction = \"update\"\n\tcase ovhDelete:\n\t\taction = \"delete\"\n\tdefault:\n\t\taction = \"unknown\"\n\t}\n\n\tif c.ID != 0 {\n\t\treturn fmt.Sprintf(\"%s zone (ID : %d) action(%s) : %s %d IN %s %s\", c.Zone, c.ID, action, c.SubDomain, c.TTL, c.FieldType, c.Target)\n\t}\n\treturn fmt.Sprintf(\"%s zone action(%s) : %s %d IN %s %s\", c.Zone, action, c.SubDomain, c.TTL, c.FieldType, c.Target)\n}\n\nfunc (p *OVHProvider) formatCNAMETarget(change *ovhChange) {\n\tif change.FieldType != endpoint.RecordTypeCNAME {\n\t\treturn\n\t}\n\n\tif p.EnableCNAMERelativeTarget {\n\t\treturn\n\t}\n\n\tif strings.HasSuffix(change.Target, \".\") {\n\t\treturn\n\t}\n\n\tchange.Target += \".\"\n}\n"
  },
  {
    "path": "provider/ovh/ovh_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage ovh\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"sort\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/maxatome/go-testdeep/td\"\n\t\"github.com/miekg/dns\"\n\t\"github.com/ovh/go-ovh/ovh\"\n\t\"github.com/patrickmn/go-cache\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\t\"go.uber.org/ratelimit\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/plan\"\n)\n\ntype mockOvhClient struct {\n\tmock.Mock\n}\n\nfunc (c *mockOvhClient) PostWithContext(_ context.Context, endpoint string, input any, output any) error {\n\tstub := c.Called(endpoint, input)\n\tdata, err := json.Marshal(stub.Get(0))\n\tif err != nil {\n\t\treturn err\n\t}\n\tjson.Unmarshal(data, output)\n\treturn stub.Error(1)\n}\n\nfunc (c *mockOvhClient) PutWithContext(_ context.Context, endpoint string, input any, output any) error {\n\tstub := c.Called(endpoint, input)\n\tdata, err := json.Marshal(stub.Get(0))\n\tif err != nil {\n\t\treturn err\n\t}\n\tjson.Unmarshal(data, output)\n\treturn stub.Error(1)\n}\n\nfunc (c *mockOvhClient) GetWithContext(_ context.Context, endpoint string, output any) error {\n\tstub := c.Called(endpoint)\n\tdata, err := json.Marshal(stub.Get(0))\n\tif err != nil {\n\t\treturn err\n\t}\n\tjson.Unmarshal(data, output)\n\treturn stub.Error(1)\n}\n\nfunc (c *mockOvhClient) DeleteWithContext(_ context.Context, endpoint string, output any) error {\n\tstub := c.Called(endpoint)\n\tdata, err := json.Marshal(stub.Get(0))\n\tif err != nil {\n\t\treturn err\n\t}\n\tjson.Unmarshal(data, output)\n\treturn stub.Error(1)\n}\n\ntype mockDnsClient struct {\n\tmock.Mock\n}\n\nfunc (c *mockDnsClient) ExchangeContext(ctx context.Context, m *dns.Msg, addr string) (*dns.Msg, time.Duration, error) {\n\targs := c.Called(ctx, m, addr)\n\n\tmsg := args.Get(0).(*dns.Msg)\n\terr := args.Error(1)\n\n\treturn msg, time.Duration(0), err\n}\n\nfunc TestOvhZones(t *testing.T) {\n\tassert := assert.New(t)\n\tclient := new(mockOvhClient)\n\tprovider := &OVHProvider{\n\t\tclient:         client,\n\t\tapiRateLimiter: ratelimit.New(10),\n\t\tdomainFilter:   endpoint.NewDomainFilter([]string{\"com\"}),\n\t\tcacheInstance:  cache.New(cache.NoExpiration, cache.NoExpiration),\n\t\tdnsClient:      new(mockDnsClient),\n\t}\n\n\t// Basic zones\n\tclient.On(\"GetWithContext\", \"/domain/zone\").Return([]string{\"example.com\", \"example.net\"}, nil).Once()\n\tdomains, err := provider.zones(t.Context())\n\tassert.NoError(err)\n\tassert.Contains(domains, \"example.com\")\n\tassert.NotContains(domains, \"example.net\")\n\tclient.AssertExpectations(t)\n\n\t// Error on getting zones\n\tclient.On(\"GetWithContext\", \"/domain/zone\").Return(nil, ovh.ErrAPIDown).Once()\n\tdomains, err = provider.zones(t.Context())\n\tassert.Error(err)\n\tassert.Nil(domains)\n\tclient.AssertExpectations(t)\n}\n\nfunc TestOvhZoneRecords(t *testing.T) {\n\tassert := assert.New(t)\n\tclient := new(mockOvhClient)\n\tprovider := &OVHProvider{client: client, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration), dnsClient: nil, UseCache: true}\n\n\t// Basic zones records\n\tt.Log(\"Basic zones records\")\n\tclient.On(\"GetWithContext\", \"/domain/zone\").Return([]string{\"example.org\"}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.org/soa\").Return(ovhSoa{Server: \"ns.example.org.\", Serial: 2022090901}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.org/record\").Return([]uint64{24, 42}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.org/record/24\").Return(ovhRecord{ID: 24, Zone: \"example.org\", ovhRecordFields: ovhRecordFields{FieldType: \"NS\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh\", TTL: 10, Target: \"203.0.113.42\"}}}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.org/record/42\").Return(ovhRecord{ID: 42, Zone: \"example.org\", ovhRecordFields: ovhRecordFields{FieldType: \"A\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh\", TTL: 10, Target: \"203.0.113.42\"}}}, nil).Once()\n\tzones, records, err := provider.zonesRecords(t.Context())\n\tassert.NoError(err)\n\tassert.ElementsMatch(zones, []string{\"example.org\"})\n\tassert.ElementsMatch(records, []ovhRecord{{ID: 42, Zone: \"example.org\", ovhRecordFields: ovhRecordFields{FieldType: \"A\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh\", TTL: 10, Target: \"203.0.113.42\"}}}, {ID: 24, Zone: \"example.org\", ovhRecordFields: ovhRecordFields{FieldType: \"NS\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh\", TTL: 10, Target: \"203.0.113.42\"}}}})\n\tclient.AssertExpectations(t)\n\n\t// Error on getting zones list\n\tt.Log(\"Error on getting zones list\")\n\tclient.On(\"GetWithContext\", \"/domain/zone\").Return(nil, ovh.ErrAPIDown).Once()\n\tzones, records, err = provider.zonesRecords(t.Context())\n\tassert.Error(err)\n\tassert.Nil(zones)\n\tassert.Nil(records)\n\tclient.AssertExpectations(t)\n\n\t// Error on getting zone SOA\n\tt.Log(\"Error on getting zone SOA\")\n\tprovider.cacheInstance = cache.New(cache.NoExpiration, cache.NoExpiration)\n\tclient.On(\"GetWithContext\", \"/domain/zone\").Return([]string{\"example.org\"}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.org/soa\").Return(nil, ovh.ErrAPIDown).Once()\n\tzones, records, err = provider.zonesRecords(t.Context())\n\tassert.Error(err)\n\tassert.Nil(zones)\n\tassert.Nil(records)\n\tclient.AssertExpectations(t)\n\n\t// Error on getting zone records\n\tt.Log(\"Error on getting zone records\")\n\tclient.On(\"GetWithContext\", \"/domain/zone\").Return([]string{\"example.org\"}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.org/soa\").Return(ovhSoa{Server: \"ns.example.org.\", Serial: 2022090902}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.org/record\").Return(nil, ovh.ErrAPIDown).Once()\n\tzones, records, err = provider.zonesRecords(t.Context())\n\tassert.Error(err)\n\tassert.Nil(zones)\n\tassert.Nil(records)\n\tclient.AssertExpectations(t)\n\n\t// Error on getting zone record detail\n\tt.Log(\"Error on getting zone record detail\")\n\tclient.On(\"GetWithContext\", \"/domain/zone\").Return([]string{\"example.org\"}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.org/soa\").Return(ovhSoa{Server: \"ns.example.org.\", Serial: 2022090902}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.org/record\").Return([]uint64{42}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.org/record/42\").Return(nil, ovh.ErrAPIDown).Once()\n\tzones, records, err = provider.zonesRecords(t.Context())\n\tassert.Error(err)\n\tassert.Nil(zones)\n\tassert.Nil(records)\n\tclient.AssertExpectations(t)\n}\n\nfunc TestOvhZoneRecordsCache(t *testing.T) {\n\tassert := assert.New(t)\n\tclient := new(mockOvhClient)\n\tdnsClient := new(mockDnsClient)\n\tprovider := &OVHProvider{client: client, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration), dnsClient: dnsClient, UseCache: true}\n\n\t// First call, cache miss\n\tt.Log(\"First call, cache miss\")\n\tclient.On(\"GetWithContext\", \"/domain/zone\").Return([]string{\"example.org\"}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.org/soa\").Return(ovhSoa{Server: \"ns.example.org.\", Serial: 2022090901}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.org/record\").Return([]uint64{24, 42}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.org/record/24\").Return(ovhRecord{ID: 24, Zone: \"example.org\", ovhRecordFields: ovhRecordFields{FieldType: \"NS\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh\", TTL: 10, Target: \"203.0.113.42\"}}}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.org/record/42\").Return(ovhRecord{ID: 42, Zone: \"example.org\", ovhRecordFields: ovhRecordFields{FieldType: \"A\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh\", TTL: 10, Target: \"203.0.113.42\"}}}, nil).Once()\n\n\tzones, records, err := provider.zonesRecords(t.Context())\n\tassert.NoError(err)\n\tassert.ElementsMatch(zones, []string{\"example.org\"})\n\tassert.ElementsMatch(records, []ovhRecord{{ID: 42, Zone: \"example.org\", ovhRecordFields: ovhRecordFields{FieldType: \"A\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh\", TTL: 10, Target: \"203.0.113.42\"}}}, {ID: 24, Zone: \"example.org\", ovhRecordFields: ovhRecordFields{FieldType: \"NS\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh\", TTL: 10, Target: \"203.0.113.42\"}}}})\n\tclient.AssertExpectations(t)\n\tdnsClient.AssertExpectations(t)\n\n\t// reset mock\n\tclient = new(mockOvhClient)\n\tdnsClient = new(mockDnsClient)\n\tprovider.client, provider.dnsClient = client, dnsClient\n\n\t// second call, cache hit\n\tt.Log(\"second call, cache hit\")\n\tclient.On(\"GetWithContext\", \"/domain/zone\").Return([]string{\"example.org\"}, nil).Once()\n\tdnsClient.On(\"ExchangeContext\", mock.AnythingOfType(\"*context.cancelCtx\"), mock.AnythingOfType(\"*dns.Msg\"), \"ns.example.org:53\").\n\t\tReturn(&dns.Msg{Answer: []dns.RR{&dns.SOA{Serial: 2022090901}}}, nil)\n\tzones, records, err = provider.zonesRecords(t.Context())\n\tassert.NoError(err)\n\tassert.ElementsMatch(zones, []string{\"example.org\"})\n\tassert.ElementsMatch(records, []ovhRecord{{ID: 42, Zone: \"example.org\", ovhRecordFields: ovhRecordFields{FieldType: \"A\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh\", TTL: 10, Target: \"203.0.113.42\"}}}, {ID: 24, Zone: \"example.org\", ovhRecordFields: ovhRecordFields{FieldType: \"NS\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh\", TTL: 10, Target: \"203.0.113.42\"}}}})\n\tclient.AssertExpectations(t)\n\tdnsClient.AssertExpectations(t)\n\n\t// reset mock\n\tclient = new(mockOvhClient)\n\tdnsClient = new(mockDnsClient)\n\tprovider.client, provider.dnsClient = client, dnsClient\n\n\t// third call, cache out of date\n\tt.Log(\"third call, cache out of date\")\n\tclient.On(\"GetWithContext\", \"/domain/zone\").Return([]string{\"example.org\"}, nil).Once()\n\tdnsClient.On(\"ExchangeContext\", mock.AnythingOfType(\"*context.cancelCtx\"), mock.AnythingOfType(\"*dns.Msg\"), \"ns.example.org:53\").\n\t\tReturn(&dns.Msg{Answer: []dns.RR{&dns.SOA{Serial: 2022090902}}}, nil)\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.org/soa\").Return(ovhSoa{Server: \"ns.example.org.\", Serial: 2022090902}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.org/record\").Return([]uint64{24}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.org/record/24\").Return(ovhRecord{ID: 24, Zone: \"example.org\", ovhRecordFields: ovhRecordFields{FieldType: \"NS\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh\", TTL: 10, Target: \"203.0.113.42\"}}}, nil).Once()\n\n\tzones, records, err = provider.zonesRecords(t.Context())\n\tassert.NoError(err)\n\tassert.ElementsMatch(zones, []string{\"example.org\"})\n\tassert.ElementsMatch(records, []ovhRecord{{ID: 24, Zone: \"example.org\", ovhRecordFields: ovhRecordFields{FieldType: \"NS\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh\", TTL: 10, Target: \"203.0.113.42\"}}}})\n\tclient.AssertExpectations(t)\n\tdnsClient.AssertExpectations(t)\n\n\t// reset mock\n\tclient = new(mockOvhClient)\n\tdnsClient = new(mockDnsClient)\n\tprovider.client, provider.dnsClient = client, dnsClient\n\n\t// fourth call, cache hit\n\tt.Log(\"fourth call, cache hit\")\n\tclient.On(\"GetWithContext\", \"/domain/zone\").Return([]string{\"example.org\"}, nil).Once()\n\tdnsClient.On(\"ExchangeContext\", mock.AnythingOfType(\"*context.cancelCtx\"), mock.AnythingOfType(\"*dns.Msg\"), \"ns.example.org:53\").\n\t\tReturn(&dns.Msg{Answer: []dns.RR{&dns.SOA{Serial: 2022090902}}}, nil)\n\n\tzones, records, err = provider.zonesRecords(t.Context())\n\tassert.NoError(err)\n\tassert.ElementsMatch(zones, []string{\"example.org\"})\n\tassert.ElementsMatch(records, []ovhRecord{{ID: 24, Zone: \"example.org\", ovhRecordFields: ovhRecordFields{FieldType: \"NS\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh\", TTL: 10, Target: \"203.0.113.42\"}}}})\n\tclient.AssertExpectations(t)\n\tdnsClient.AssertExpectations(t)\n\n\t// reset mock\n\tclient = new(mockOvhClient)\n\tdnsClient = new(mockDnsClient)\n\tprovider.client, provider.dnsClient = client, dnsClient\n\n\t// fifth call, dns issue\n\tt.Log(\"fourth call, cache hit\")\n\tclient.On(\"GetWithContext\", \"/domain/zone\").Return([]string{\"example.org\"}, nil).Once()\n\tdnsClient.On(\"ExchangeContext\", mock.AnythingOfType(\"*context.cancelCtx\"), mock.AnythingOfType(\"*dns.Msg\"), \"ns.example.org:53\").\n\t\tReturn(&dns.Msg{Answer: []dns.RR{}}, errors.New(\"dns issue\"))\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.org/soa\").Return(ovhSoa{Server: \"ns.example.org.\", Serial: 2022090903}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.org/record\").Return([]uint64{24, 42}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.org/record/24\").Return(ovhRecord{ID: 24, Zone: \"example.org\", ovhRecordFields: ovhRecordFields{FieldType: \"NS\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh\", TTL: 10, Target: \"203.0.113.42\"}}}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.org/record/42\").Return(ovhRecord{ID: 42, Zone: \"example.org\", ovhRecordFields: ovhRecordFields{FieldType: \"A\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh\", TTL: 10, Target: \"203.0.113.42\"}}}, nil).Once()\n\n\tzones, records, err = provider.zonesRecords(t.Context())\n\tassert.NoError(err)\n\tassert.ElementsMatch(zones, []string{\"example.org\"})\n\tassert.ElementsMatch(records, []ovhRecord{{ID: 42, Zone: \"example.org\", ovhRecordFields: ovhRecordFields{FieldType: \"A\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh\", TTL: 10, Target: \"203.0.113.42\"}}}, {ID: 24, Zone: \"example.org\", ovhRecordFields: ovhRecordFields{FieldType: \"NS\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh\", TTL: 10, Target: \"203.0.113.42\"}}}})\n\tclient.AssertExpectations(t)\n\tdnsClient.AssertExpectations(t)\n}\n\nfunc TestOvhRecords(t *testing.T) {\n\tassert := assert.New(t)\n\tclient := new(mockOvhClient)\n\tprovider := &OVHProvider{client: client, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration)}\n\n\t// Basic zones records\n\tclient.On(\"GetWithContext\", \"/domain/zone\").Return([]string{\"example.org\", \"example.net\"}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.org/record\").Return([]uint64{24, 42}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.org/record/24\").Return(ovhRecord{ID: 24, Zone: \"example.org\", ovhRecordFields: ovhRecordFields{FieldType: \"A\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"\", TTL: 10, Target: \"203.0.113.42\"}}}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.org/record/42\").Return(ovhRecord{ID: 42, Zone: \"example.org\", ovhRecordFields: ovhRecordFields{FieldType: \"CNAME\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"www\", TTL: 10, Target: \"example.org.\"}}}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.net/record\").Return([]uint64{24, 42}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.net/record/24\").Return(ovhRecord{ID: 24, Zone: \"example.net\", ovhRecordFields: ovhRecordFields{FieldType: \"A\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh\", TTL: 10, Target: \"203.0.113.42\"}}}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.net/record/42\").Return(ovhRecord{ID: 42, Zone: \"example.net\", ovhRecordFields: ovhRecordFields{FieldType: \"A\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh\", TTL: 10, Target: \"203.0.113.43\"}}}, nil).Once()\n\tendpoints, err := provider.Records(t.Context())\n\tassert.NoError(err)\n\t// Little fix for multi targets endpoint\n\tfor _, endpoint := range endpoints {\n\t\tsort.Strings(endpoint.Targets)\n\t}\n\tassert.ElementsMatch(endpoints, []*endpoint.Endpoint{\n\t\t{DNSName: \"example.org\", RecordType: \"A\", RecordTTL: 10, Labels: endpoint.NewLabels(), Targets: []string{\"203.0.113.42\"}},\n\t\t{DNSName: \"www.example.org\", RecordType: \"CNAME\", RecordTTL: 10, Labels: endpoint.NewLabels(), Targets: []string{\"example.org\"}},\n\t\t{DNSName: \"ovh.example.net\", RecordType: \"A\", RecordTTL: 10, Labels: endpoint.NewLabels(), Targets: []string{\"203.0.113.42\", \"203.0.113.43\"}},\n\t})\n\tclient.AssertExpectations(t)\n\n\t// Error getting zone\n\tclient.On(\"GetWithContext\", \"/domain/zone\").Return(nil, ovh.ErrAPIDown).Once()\n\tendpoints, err = provider.Records(t.Context())\n\tassert.Error(err)\n\tassert.Nil(endpoints)\n\tclient.AssertExpectations(t)\n}\n\nfunc TestOvhComputeChanges(t *testing.T) {\n\texistingRecords := []ovhRecord{\n\t\t{\n\t\t\tID:   1,\n\t\t\tZone: \"example.net\",\n\t\t\tovhRecordFields: ovhRecordFields{\n\t\t\t\tFieldType: \"A\",\n\t\t\t\tovhRecordFieldUpdate: ovhRecordFieldUpdate{\n\t\t\t\t\tSubDomain: \"\",\n\t\t\t\t\tTarget:    \"203.0.113.42\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tchanges := plan.Changes{\n\t\tUpdateOld: []*endpoint.Endpoint{\n\t\t\t{DNSName: \"example.net\", RecordType: \"A\", Targets: []string{\"203.0.113.42\"}},\n\t\t},\n\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\t{DNSName: \"example.net\", RecordType: \"A\", Targets: []string{\"203.0.113.43\", \"203.0.113.42\"}},\n\t\t},\n\t}\n\n\tprovider := &OVHProvider{client: nil, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration)}\n\tovhChanges, err := provider.computeSingleZoneChanges(t.Context(), \"example.net\", existingRecords, &changes)\n\ttd.CmpNoError(t, err)\n\ttd.Cmp(t, ovhChanges, []ovhChange{\n\t\t{\n\t\t\tAction: ovhCreate,\n\t\t\tovhRecord: ovhRecord{\n\t\t\t\tZone: \"example.net\",\n\t\t\t\tovhRecordFields: ovhRecordFields{\n\t\t\t\t\tFieldType: \"A\",\n\t\t\t\t\tovhRecordFieldUpdate: ovhRecordFieldUpdate{\n\t\t\t\t\t\tSubDomain: \"\",\n\t\t\t\t\t\tTarget:    \"203.0.113.43\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t})\n\n}\n\nfunc TestOvhRefresh(t *testing.T) {\n\tclient := new(mockOvhClient)\n\tprovider := &OVHProvider{client: client, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration)}\n\n\t// Basic zone refresh\n\tclient.On(\"PostWithContext\", \"/domain/zone/example.net/refresh\", nil).Return(nil, nil).Once()\n\tprovider.refresh(t.Context(), \"example.net\")\n\tclient.AssertExpectations(t)\n}\n\nfunc TestOvhNewChange(t *testing.T) {\n\tprovider := &OVHProvider{client: nil, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration)}\n\n\tendpoints := []*endpoint.Endpoint{\n\t\t{DNSName: \".example.net\", RecordType: \"A\", RecordTTL: 10, Targets: []string{\"203.0.113.42\"}},\n\t\t{DNSName: \"ovh.example.net\", RecordType: \"A\", Targets: []string{\"203.0.113.43\"}},\n\t\t{DNSName: \"ovh2.example.net\", RecordType: \"CNAME\", Targets: []string{\"ovh.example.net\"}},\n\t\t{DNSName: \"test.example.org\"},\n\t}\n\n\t// Create change\n\tchanges, _ := provider.newOvhChangeCreateDelete(ovhCreate, endpoints, \"example.net\", []ovhRecord{})\n\ttd.Cmp(t, changes, []ovhChange{\n\t\t{Action: ovhCreate, ovhRecord: ovhRecord{Zone: \"example.net\", ovhRecordFields: ovhRecordFields{FieldType: \"A\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"\", TTL: 10, Target: \"203.0.113.42\"}}}},\n\t\t{Action: ovhCreate, ovhRecord: ovhRecord{Zone: \"example.net\", ovhRecordFields: ovhRecordFields{FieldType: \"A\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh\", TTL: defaultTTL, Target: \"203.0.113.43\"}}}},\n\t\t{Action: ovhCreate, ovhRecord: ovhRecord{Zone: \"example.net\", ovhRecordFields: ovhRecordFields{FieldType: \"CNAME\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh2\", TTL: defaultTTL, Target: \"ovh.example.net.\"}}}},\n\t})\n\n\t// Delete change\n\tendpoints = []*endpoint.Endpoint{\n\t\t{DNSName: \"ovh.example.net\", RecordType: \"A\", Targets: []string{\"203.0.113.42\", \"203.0.113.42\", \"203.0.113.43\"}},\n\t}\n\trecords := []ovhRecord{\n\t\t{ID: 42, Zone: \"example.net\", ovhRecordFields: ovhRecordFields{FieldType: \"A\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh\", Target: \"203.0.113.43\"}}},\n\t\t{ID: 43, Zone: \"example.net\", ovhRecordFields: ovhRecordFields{FieldType: \"A\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh\", Target: \"203.0.113.42\"}}},\n\t\t{ID: 44, Zone: \"example.net\", ovhRecordFields: ovhRecordFields{FieldType: \"A\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh\", Target: \"203.0.113.42\"}}},\n\t}\n\tchanges, _ = provider.newOvhChangeCreateDelete(ovhDelete, endpoints, \"example.net\", records)\n\ttd.Cmp(t, changes, []ovhChange{\n\t\t{Action: ovhDelete, ovhRecord: ovhRecord{ID: 43, Zone: \"example.net\", ovhRecordFields: ovhRecordFields{FieldType: \"A\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh\", TTL: defaultTTL, Target: \"203.0.113.42\"}}}},\n\t\t{Action: ovhDelete, ovhRecord: ovhRecord{ID: 44, Zone: \"example.net\", ovhRecordFields: ovhRecordFields{FieldType: \"A\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh\", TTL: defaultTTL, Target: \"203.0.113.42\"}}}},\n\t\t{Action: ovhDelete, ovhRecord: ovhRecord{ID: 42, Zone: \"example.net\", ovhRecordFields: ovhRecordFields{FieldType: \"A\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh\", TTL: defaultTTL, Target: \"203.0.113.43\"}}}},\n\t})\n\n\t// Create change with CNAME relative\n\tendpoints = []*endpoint.Endpoint{\n\t\t{DNSName: \".example.net\", RecordType: \"A\", RecordTTL: 10, Targets: []string{\"203.0.113.42\"}},\n\t\t{DNSName: \"ovh.example.net\", RecordType: \"A\", Targets: []string{\"203.0.113.43\"}},\n\t\t{DNSName: \"ovh2.example.net\", RecordType: \"CNAME\", Targets: []string{\"ovh\"}},\n\t\t{DNSName: \"test.example.org\"},\n\t}\n\n\tprovider = &OVHProvider{client: nil, EnableCNAMERelativeTarget: true, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration)}\n\tchanges, _ = provider.newOvhChangeCreateDelete(ovhCreate, endpoints, \"example.net\", []ovhRecord{})\n\ttd.Cmp(t, changes, []ovhChange{\n\t\t{Action: ovhCreate, ovhRecord: ovhRecord{Zone: \"example.net\", ovhRecordFields: ovhRecordFields{FieldType: \"A\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"\", TTL: 10, Target: \"203.0.113.42\"}}}},\n\t\t{Action: ovhCreate, ovhRecord: ovhRecord{Zone: \"example.net\", ovhRecordFields: ovhRecordFields{FieldType: \"A\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh\", TTL: defaultTTL, Target: \"203.0.113.43\"}}}},\n\t\t{Action: ovhCreate, ovhRecord: ovhRecord{Zone: \"example.net\", ovhRecordFields: ovhRecordFields{FieldType: \"CNAME\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh2\", TTL: defaultTTL, Target: \"ovh\"}}}},\n\t})\n\n\t// Test with CNAME when target has already final dot\n\tendpoints = []*endpoint.Endpoint{\n\t\t{DNSName: \".example.net\", RecordType: \"A\", RecordTTL: 10, Targets: []string{\"203.0.113.42\"}},\n\t\t{DNSName: \"ovh.example.net\", RecordType: \"A\", Targets: []string{\"203.0.113.43\"}},\n\t\t{DNSName: \"ovh2.example.net\", RecordType: \"CNAME\", Targets: []string{\"ovh.example.com.\"}},\n\t\t{DNSName: \"test.example.org\"},\n\t}\n\n\tprovider = &OVHProvider{client: nil, EnableCNAMERelativeTarget: false, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration)}\n\tchanges, _ = provider.newOvhChangeCreateDelete(ovhCreate, endpoints, \"example.net\", []ovhRecord{})\n\ttd.Cmp(t, changes, []ovhChange{\n\t\t{Action: ovhCreate, ovhRecord: ovhRecord{Zone: \"example.net\", ovhRecordFields: ovhRecordFields{FieldType: \"A\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"\", TTL: 10, Target: \"203.0.113.42\"}}}},\n\t\t{Action: ovhCreate, ovhRecord: ovhRecord{Zone: \"example.net\", ovhRecordFields: ovhRecordFields{FieldType: \"A\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh\", TTL: defaultTTL, Target: \"203.0.113.43\"}}}},\n\t\t{Action: ovhCreate, ovhRecord: ovhRecord{Zone: \"example.net\", ovhRecordFields: ovhRecordFields{FieldType: \"CNAME\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh2\", TTL: defaultTTL, Target: \"ovh.example.com.\"}}}},\n\t})\n}\n\nfunc TestOvhApplyChanges(t *testing.T) {\n\tclient := new(mockOvhClient)\n\tprovider := &OVHProvider{client: client, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration)}\n\tchanges := plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\t{DNSName: \"example.net\", RecordType: \"A\", RecordTTL: 10, Targets: []string{\"203.0.113.42\"}},\n\t\t},\n\t\tDelete: []*endpoint.Endpoint{\n\t\t\t{DNSName: \"ovh.example.net\", RecordType: \"A\", Targets: []string{\"203.0.113.43\"}},\n\t\t},\n\t}\n\n\tclient.On(\"GetWithContext\", \"/domain/zone\").Return([]string{\"example.net\"}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.net/record\").Return([]uint64{42}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.net/record/42\").Return(ovhRecord{ID: 42, Zone: \"example.net\", ovhRecordFields: ovhRecordFields{FieldType: \"A\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh\", TTL: 10, Target: \"203.0.113.43\"}}}, nil).Once()\n\tclient.On(\"PostWithContext\", \"/domain/zone/example.net/record\", ovhRecordFields{FieldType: \"A\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"\", TTL: 10, Target: \"203.0.113.42\"}}).Return(nil, nil).Once()\n\tclient.On(\"DeleteWithContext\", \"/domain/zone/example.net/record/42\").Return(nil, nil).Once()\n\tclient.On(\"PostWithContext\", \"/domain/zone/example.net/refresh\", nil).Return(nil, nil).Once()\n\n\t_, err := provider.Records(t.Context())\n\ttd.CmpNoError(t, err)\n\t// Basic changes\n\ttd.CmpNoError(t, provider.ApplyChanges(t.Context(), &changes))\n\tclient.AssertExpectations(t)\n\n\t// Apply change failed\n\tclient = new(mockOvhClient)\n\tprovider.client = client\n\tclient.On(\"GetWithContext\", \"/domain/zone\").Return([]string{\"example.net\"}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.net/record\").Return([]uint64{}, nil).Once()\n\tclient.On(\"PostWithContext\", \"/domain/zone/example.net/record\", ovhRecordFields{FieldType: \"A\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"\", TTL: 10, Target: \"203.0.113.42\"}}).Return(nil, ovh.ErrAPIDown).Once()\n\n\t_, err = provider.Records(t.Context())\n\ttd.CmpNoError(t, err)\n\ttd.CmpError(t, provider.ApplyChanges(t.Context(), &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\t{DNSName: \"example.net\", RecordType: \"A\", RecordTTL: 10, Targets: []string{\"203.0.113.42\"}},\n\t\t},\n\t}))\n\tclient.AssertExpectations(t)\n\n\t// Refresh failed\n\tclient = new(mockOvhClient)\n\tprovider.client = client\n\tclient.On(\"GetWithContext\", \"/domain/zone\").Return([]string{\"example.net\"}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.net/record\").Return([]uint64{}, nil).Once()\n\tclient.On(\"PostWithContext\", \"/domain/zone/example.net/record\", ovhRecordFields{FieldType: \"A\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"\", TTL: 10, Target: \"203.0.113.42\"}}).Return(nil, nil).Once()\n\tclient.On(\"PostWithContext\", \"/domain/zone/example.net/refresh\", nil).Return(nil, ovh.ErrAPIDown).Once()\n\n\t_, err = provider.Records(t.Context())\n\ttd.CmpNoError(t, err)\n\ttd.CmpError(t, provider.ApplyChanges(t.Context(), &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\t{DNSName: \"example.net\", RecordType: \"A\", RecordTTL: 10, Targets: []string{\"203.0.113.42\"}},\n\t\t},\n\t}))\n\tclient.AssertExpectations(t)\n\n\t// Test Dry-Run\n\tclient = new(mockOvhClient)\n\tprovider = &OVHProvider{client: client, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration), DryRun: true}\n\tchanges = plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\t{DNSName: \"example.net\", RecordType: \"A\", RecordTTL: 10, Targets: []string{\"203.0.113.42\"}},\n\t\t},\n\t\tDelete: []*endpoint.Endpoint{\n\t\t\t{DNSName: \"ovh.example.net\", RecordType: \"A\", Targets: []string{\"203.0.113.43\"}},\n\t\t},\n\t}\n\n\tclient.On(\"GetWithContext\", \"/domain/zone\").Return([]string{\"example.net\"}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.net/record\").Return([]uint64{42}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.net/record/42\").Return(ovhRecord{ID: 42, Zone: \"example.net\", ovhRecordFields: ovhRecordFields{FieldType: \"A\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh\", TTL: 10, Target: \"203.0.113.43\"}}}, nil).Once()\n\n\t_, err = provider.Records(t.Context())\n\ttd.CmpNoError(t, err)\n\ttd.CmpNoError(t, provider.ApplyChanges(t.Context(), &changes))\n\tclient.AssertExpectations(t)\n\n\t// Test Update\n\tclient = new(mockOvhClient)\n\tprovider = &OVHProvider{client: client, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration), DryRun: false}\n\tchanges = plan.Changes{\n\t\tUpdateOld: []*endpoint.Endpoint{\n\t\t\t{DNSName: \"example.net\", RecordType: \"A\", RecordTTL: 10, Targets: []string{\"203.0.113.42\"}},\n\t\t},\n\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\t{DNSName: \"example.net\", RecordType: \"A\", RecordTTL: 10, Targets: []string{\"203.0.113.43\"}},\n\t\t},\n\t}\n\n\tclient.On(\"GetWithContext\", \"/domain/zone\").Return([]string{\"example.net\"}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.net/record\").Return([]uint64{42}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.net/record/42\").Return(ovhRecord{ID: 42, Zone: \"example.net\", ovhRecordFields: ovhRecordFields{FieldType: \"A\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"\", TTL: 10, Target: \"203.0.113.42\"}}}, nil).Once()\n\tclient.On(\"PutWithContext\", \"/domain/zone/example.net/record/42\", ovhRecordFieldUpdate{SubDomain: \"\", TTL: 10, Target: \"203.0.113.43\"}).Return(nil, nil).Once()\n\tclient.On(\"PostWithContext\", \"/domain/zone/example.net/refresh\", nil).Return(nil, nil).Once()\n\n\t_, err = provider.Records(t.Context())\n\ttd.CmpNoError(t, err)\n\ttd.CmpNoError(t, provider.ApplyChanges(t.Context(), &changes))\n\tclient.AssertExpectations(t)\n\n\t// Test Update DryRun\n\tclient = new(mockOvhClient)\n\tprovider = &OVHProvider{client: client, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration), DryRun: true}\n\tchanges = plan.Changes{\n\t\tUpdateOld: []*endpoint.Endpoint{\n\t\t\t{DNSName: \"example.net\", RecordType: \"A\", RecordTTL: 10, Targets: []string{\"203.0.113.42\"}},\n\t\t},\n\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\t{DNSName: \"example.net\", RecordType: \"A\", RecordTTL: 10, Targets: []string{\"203.0.113.43\"}},\n\t\t},\n\t}\n\n\tclient.On(\"GetWithContext\", \"/domain/zone\").Return([]string{\"example.net\"}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.net/record\").Return([]uint64{42}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.net/record/42\").Return(ovhRecord{ID: 42, Zone: \"example.net\", ovhRecordFields: ovhRecordFields{FieldType: \"A\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"\", TTL: 10, Target: \"203.0.113.42\"}}}, nil).Once()\n\n\t_, err = provider.Records(t.Context())\n\ttd.CmpNoError(t, err)\n\ttd.CmpNoError(t, provider.ApplyChanges(t.Context(), &changes))\n\tclient.AssertExpectations(t)\n\n\t// Test Update 2 records => 1 record\n\tclient = new(mockOvhClient)\n\tprovider = &OVHProvider{client: client, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration), DryRun: false}\n\tchanges = plan.Changes{\n\t\tUpdateOld: []*endpoint.Endpoint{\n\t\t\t{DNSName: \"example.net\", RecordType: \"A\", RecordTTL: 10, Targets: []string{\"203.0.113.42\", \"203.0.113.43\"}},\n\t\t},\n\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\t{DNSName: \"example.net\", RecordType: \"A\", RecordTTL: 10, Targets: []string{\"203.0.113.43\"}},\n\t\t},\n\t}\n\n\tclient.On(\"GetWithContext\", \"/domain/zone\").Return([]string{\"example.net\"}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.net/record\").Return([]uint64{42, 43}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.net/record/42\").Return(ovhRecord{ID: 42, Zone: \"example.net\", ovhRecordFields: ovhRecordFields{FieldType: \"A\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"\", TTL: 10, Target: \"203.0.113.42\"}}}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/example.net/record/43\").Return(ovhRecord{ID: 43, Zone: \"example.net\", ovhRecordFields: ovhRecordFields{FieldType: \"A\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"\", TTL: 10, Target: \"203.0.113.43\"}}}, nil).Once()\n\tclient.On(\"DeleteWithContext\", \"/domain/zone/example.net/record/42\").Return(nil, nil).Once()\n\tclient.On(\"PostWithContext\", \"/domain/zone/example.net/refresh\", nil).Return(nil, nil).Once()\n\n\t_, err = provider.Records(t.Context())\n\ttd.CmpNoError(t, err)\n\ttd.CmpNoError(t, provider.ApplyChanges(t.Context(), &changes))\n\tclient.AssertExpectations(t)\n}\n\nfunc TestOvhApplyChangesPunyCode(t *testing.T) {\n\tclient := new(mockOvhClient)\n\tprovider := &OVHProvider{client: client, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration)}\n\tchanges := plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\t{DNSName: \"example.testécassé.fr\", RecordType: \"A\", RecordTTL: 10, Targets: []string{\"203.0.113.42\"}},\n\t\t},\n\t}\n\n\tclient.On(\"GetWithContext\", \"/domain/zone\").Return([]string{\"xn--testcass-e1ae.fr\"}, nil).Once()\n\tclient.On(\"GetWithContext\", \"/domain/zone/xn--testcass-e1ae.fr/record\").Return([]uint64{}, nil).Once()\n\tclient.On(\"PostWithContext\", \"/domain/zone/xn--testcass-e1ae.fr/record\", ovhRecordFields{FieldType: \"A\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"example\", TTL: 10, Target: \"203.0.113.42\"}}).Return(nil, nil).Once()\n\tclient.On(\"PostWithContext\", \"/domain/zone/xn--testcass-e1ae.fr/refresh\", nil).Return(nil, nil).Once()\n\n\t_, err := provider.Records(t.Context())\n\ttd.CmpNoError(t, err)\n\t// Basic changes\n\ttd.CmpNoError(t, provider.ApplyChanges(t.Context(), &changes))\n\tclient.AssertExpectations(t)\n}\n\nfunc TestOvhChange(t *testing.T) {\n\tassert := assert.New(t)\n\tclient := new(mockOvhClient)\n\tprovider := &OVHProvider{client: client, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration)}\n\n\t// Record creation\n\tclient.On(\"PostWithContext\", \"/domain/zone/example.net/record\", ovhRecordFields{ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh\"}}).Return(nil, nil).Once()\n\tassert.NoError(provider.change(t.Context(), ovhChange{\n\t\tAction:    ovhCreate,\n\t\tovhRecord: ovhRecord{Zone: \"example.net\", ovhRecordFields: ovhRecordFields{ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh\"}}},\n\t}))\n\tclient.AssertExpectations(t)\n\n\t// Record deletion\n\tclient.On(\"DeleteWithContext\", \"/domain/zone/example.net/record/42\").Return(nil, nil).Once()\n\tassert.NoError(provider.change(t.Context(), ovhChange{\n\t\tAction:    ovhDelete,\n\t\tovhRecord: ovhRecord{ID: 42, Zone: \"example.net\", ovhRecordFields: ovhRecordFields{ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh\"}}},\n\t}))\n\tclient.AssertExpectations(t)\n\n\t// Record deletion error\n\tassert.Error(provider.change(t.Context(), ovhChange{\n\t\tAction:    ovhDelete,\n\t\tovhRecord: ovhRecord{Zone: \"example.net\", ovhRecordFields: ovhRecordFields{ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh\"}}},\n\t}))\n\tclient.AssertExpectations(t)\n}\n\nfunc TestOvhRecordString(t *testing.T) {\n\trecord := ovhRecord{ID: 24, Zone: \"example.org\", ovhRecordFields: ovhRecordFields{FieldType: \"A\", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: \"ovh\", TTL: 10, Target: \"203.0.113.42\"}}}\n\n\ttd.Cmp(t, record.String(), \"record#24: A | ovh => 203.0.113.42 (10)\")\n}\n\nfunc TestNewOvhProvider(t *testing.T) {\n\tdomainFilter := &endpoint.DomainFilter{}\n\t_, err := newProvider(domainFilter, \"ovh-eu\", 20, false, true)\n\ttd.CmpError(t, err)\n\n\tt.Setenv(\"OVH_APPLICATION_KEY\", \"aaaaaa\")\n\tt.Setenv(\"OVH_APPLICATION_SECRET\", \"bbbbbb\")\n\tt.Setenv(\"OVH_CONSUMER_KEY\", \"cccccc\")\n\n\t_, err = newProvider(domainFilter, \"ovh-eu\", 20, false, true)\n\ttd.CmpNoError(t, err)\n}\n"
  },
  {
    "path": "provider/pdns/pdns.go",
    "content": "/*\nCopyright 2018 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage pdns\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/tls\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"math\"\n\t\"net\"\n\t\"net/http\"\n\t\"slices\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\tpgo \"github.com/ffledgling/pdns-go\"\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/pkg/tlsutils\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\ntype pdnsChangeType string\n\nconst (\n\tapiBase = \"/api/v1\"\n\n\tdefaultTTL = 300\n\n\t// PdnsDelete and PdnsReplace are effectively an enum for \"pgo.RrSet.changetype\"\n\t// TODO: Can we somehow get this from the pgo swagger client library itself?\n\n\t// PdnsDelete : PowerDNS changetype used for deleting rrsets\n\t// ref: https://doc.powerdns.com/authoritative/http-api/zone.html#rrset (see \"changetype\")\n\tPdnsDelete pdnsChangeType = \"DELETE\"\n\t// PdnsReplace : PowerDNS changetype for creating, updating and patching rrsets\n\tPdnsReplace pdnsChangeType = \"REPLACE\"\n\t// Number of times to retry failed PDNS requests\n\tretryLimit = 3\n\t// time in milliseconds\n\tretryAfterTime = 250 * time.Millisecond\n)\n\n// record types which require to have trailing dot\nvar trailingTypes = []string{\n\tendpoint.RecordTypeCNAME,\n\tendpoint.RecordTypeMX,\n\tendpoint.RecordTypeSRV,\n\tendpoint.RecordTypeNS,\n\tendpoint.RecordTypePTR,\n\t\"ALIAS\",\n}\n\n// PDNSConfig is comprised of the fields necessary to create a new PDNSProvider\ntype PDNSConfig struct {\n\tDomainFilter *endpoint.DomainFilter\n\tDryRun       bool\n\tServer       string\n\tServerID     string\n\tAPIKey       string\n\tTLSConfig    TLSConfig\n}\n\n// TLSConfig is comprised of the TLS-related fields necessary to create a new PDNSProvider\ntype TLSConfig struct {\n\tSkipTLSVerify         bool\n\tCAFilePath            string\n\tClientCertFilePath    string\n\tClientCertKeyFilePath string\n}\n\nfunc (tlsConfig *TLSConfig) setHTTPClient(pdnsClientConfig *pgo.Configuration) error {\n\tlog.Debug(\"Configuring TLS for PDNS Provider.\")\n\ttlsClientConfig, err := tlsutils.NewTLSConfig(\n\t\ttlsConfig.ClientCertFilePath,\n\t\ttlsConfig.ClientCertKeyFilePath,\n\t\ttlsConfig.CAFilePath,\n\t\t\"\",\n\t\ttlsConfig.SkipTLSVerify,\n\t\ttls.VersionTLS12,\n\t)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Timeouts taken from net.http.DefaultTransport\n\ttransporter := &http.Transport{\n\t\tProxy: http.ProxyFromEnvironment,\n\t\tDialContext: (&net.Dialer{\n\t\t\tTimeout:   30 * time.Second,\n\t\t\tKeepAlive: 30 * time.Second,\n\t\t\tDualStack: true,\n\t\t}).DialContext,\n\t\tMaxIdleConns:          100,\n\t\tIdleConnTimeout:       90 * time.Second,\n\t\tTLSHandshakeTimeout:   10 * time.Second,\n\t\tExpectContinueTimeout: 1 * time.Second,\n\t\tTLSClientConfig:       tlsClientConfig,\n\t}\n\tpdnsClientConfig.HTTPClient = &http.Client{\n\t\tTransport: transporter,\n\t}\n\n\treturn nil\n}\n\n// Function for debug printing\nfunc stringifyHTTPResponseBody(r *http.Response) string {\n\tif r == nil {\n\t\treturn \"\"\n\t}\n\n\tbuf := new(bytes.Buffer)\n\t_, _ = buf.ReadFrom(r.Body)\n\treturn buf.String()\n}\n\n// PDNSAPIProvider : Interface used and extended by the PDNSAPIClient struct as\n// well as mock APIClients used in testing\ntype PDNSAPIProvider interface {\n\tListZones() ([]pgo.Zone, *http.Response, error)\n\tListZone(zoneID string) (pgo.Zone, *http.Response, error)\n\tPatchZone(zoneID string, zoneStruct pgo.Zone) (*http.Response, error)\n}\n\n// PDNSAPIClient : Struct that encapsulates all the PowerDNS specific implementation details\ntype PDNSAPIClient struct {\n\tdryRun   bool\n\tserverID string\n\tauthCtx  context.Context\n\tclient   *pgo.APIClient\n}\n\n// ListZones : Method returns all enabled zones from PowerDNS\n// ref: https://doc.powerdns.com/authoritative/http-api/zone.html#get--servers-server_id-zones\nfunc (c *PDNSAPIClient) ListZones() ([]pgo.Zone, *http.Response, error) {\n\tvar zones []pgo.Zone\n\tvar resp *http.Response\n\tvar err error\n\tfor i := range retryLimit {\n\t\tzones, resp, err = c.client.ZonesApi.ListZones(c.authCtx, c.serverID)\n\t\tif err != nil {\n\t\t\tlog.Debugf(\"Unable to fetch zones %v\", err)\n\t\t\tlog.Debugf(\"Retrying ListZones() ... %d\", i)\n\t\t\ttime.Sleep(retryAfterTime * (1 << uint(i)))\n\t\t\tcontinue\n\t\t}\n\t\treturn zones, resp, err\n\t}\n\n\treturn zones, resp, provider.NewSoftErrorf(\"unable to list zones: %v\", err)\n}\n\n// partitionZones returns a slice of zones that adhere to the domain filter and a slice of ones that do not adhere to the filter.\nfunc partitionZones(zones []pgo.Zone, domainFilter *endpoint.DomainFilter) ([]pgo.Zone, []pgo.Zone) {\n\tif domainFilter == nil || !domainFilter.IsConfigured() {\n\t\treturn zones, nil\n\t}\n\n\tvar filtered, residual []pgo.Zone\n\tfor _, zone := range zones {\n\t\tif domainFilter.Match(zone.Name) {\n\t\t\tfiltered = append(filtered, zone)\n\t\t} else {\n\t\t\tresidual = append(residual, zone)\n\t\t}\n\t}\n\n\treturn filtered, residual\n}\n\n// ListZone : Method returns the details of a specific zone from PowerDNS\n// ref: https://doc.powerdns.com/authoritative/http-api/zone.html#get--servers-server_id-zones-zone_id\nfunc (c *PDNSAPIClient) ListZone(zoneID string) (pgo.Zone, *http.Response, error) {\n\tfor i := range retryLimit {\n\t\tzone, resp, err := c.client.ZonesApi.ListZone(c.authCtx, c.serverID, zoneID)\n\t\tif err != nil {\n\t\t\tlog.Debugf(\"Unable to fetch zone %v\", err)\n\t\t\tlog.Debugf(\"Retrying ListZone() ... %d\", i)\n\t\t\ttime.Sleep(retryAfterTime * (1 << uint(i)))\n\t\t\tcontinue\n\t\t}\n\t\treturn zone, resp, err\n\t}\n\n\treturn pgo.Zone{}, nil, provider.NewSoftErrorf(\"unable to list zone\")\n}\n\n// PatchZone : Method used to update the contents of a particular zone from PowerDNS\n// ref: https://doc.powerdns.com/authoritative/http-api/zone.html#patch--servers-server_id-zones-zone_id\nfunc (c *PDNSAPIClient) PatchZone(zoneID string, zoneStruct pgo.Zone) (*http.Response, error) {\n\tvar resp *http.Response\n\tvar err error\n\tfor i := range retryLimit {\n\t\tresp, err = c.client.ZonesApi.PatchZone(c.authCtx, c.serverID, zoneID, zoneStruct)\n\t\tif err != nil {\n\t\t\tlog.Debugf(\"Unable to patch zone %v\", err)\n\t\t\tlog.Debugf(\"Retrying PatchZone() ... %d\", i)\n\t\t\ttime.Sleep(retryAfterTime * (1 << uint(i)))\n\t\t\tcontinue\n\t\t}\n\t\treturn resp, err\n\t}\n\n\treturn resp, provider.NewSoftErrorf(\"unable to patch zone: %v\", err)\n}\n\n// PDNSProvider is an implementation of the Provider interface for PowerDNS\ntype PDNSProvider struct {\n\tprovider.BaseProvider\n\tclient       PDNSAPIProvider\n\tdomainFilter *endpoint.DomainFilter\n}\n\n// New creates a PowerDNS provider from the given configuration.\nfunc New(ctx context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) {\n\treturn newProvider(\n\t\tctx,\n\t\tPDNSConfig{\n\t\t\tDomainFilter: domainFilter,\n\t\t\tDryRun:       cfg.DryRun,\n\t\t\tServer:       cfg.PDNSServer,\n\t\t\tServerID:     cfg.PDNSServerID,\n\t\t\tAPIKey:       cfg.PDNSAPIKey,\n\t\t\tTLSConfig: TLSConfig{\n\t\t\t\tSkipTLSVerify:         cfg.PDNSSkipTLSVerify,\n\t\t\t\tCAFilePath:            cfg.TLSCA,\n\t\t\t\tClientCertFilePath:    cfg.TLSClientCert,\n\t\t\t\tClientCertKeyFilePath: cfg.TLSClientCertKey,\n\t\t\t},\n\t\t},\n\t)\n}\n\n// newProvider initializes a new PowerDNS based Provider.\nfunc newProvider(ctx context.Context, config PDNSConfig) (*PDNSProvider, error) {\n\t// Do some input validation\n\n\tif config.APIKey == \"\" {\n\t\treturn nil, errors.New(\"missing API Key for PDNS. Specify using --pdns-api-key=\")\n\t}\n\n\t// We do not support dry running, exit safely instead of surprising the user\n\t// TODO: Add Dry Run support\n\tif config.DryRun {\n\t\treturn nil, errors.New(\"PDNS Provider does not currently support dry-run\")\n\t}\n\n\tif config.Server == \"localhost\" {\n\t\tlog.Warnf(\"PDNS Server is set to localhost, this may not be what you want. Specify using --pdns-server=\")\n\t}\n\n\tpdnsClientConfig := pgo.NewConfiguration()\n\tpdnsClientConfig.BasePath = config.Server + apiBase\n\tif err := config.TLSConfig.setHTTPClient(pdnsClientConfig); err != nil {\n\t\treturn nil, err\n\t}\n\n\tprovider := &PDNSProvider{\n\t\tclient: &PDNSAPIClient{\n\t\t\tdryRun:   config.DryRun,\n\t\t\tserverID: config.ServerID,\n\t\t\tauthCtx:  context.WithValue(ctx, pgo.ContextAPIKey, pgo.APIKey{Key: config.APIKey}),\n\t\t\tclient:   pgo.NewAPIClient(pdnsClientConfig),\n\t\t},\n\t\tdomainFilter: config.DomainFilter,\n\t}\n\treturn provider, nil\n}\n\n// filteredZones fetches all zones from the PowerDNS API and partitions them\n// using the provider's domain filter. It returns the matching zones, the\n// non-matching (residual) zones, and any error from the API call.\nfunc (p *PDNSProvider) filteredZones() ([]pgo.Zone, []pgo.Zone, error) {\n\tzones, _, err := p.client.ListZones()\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\tfiltered, residual := partitionZones(zones, p.domainFilter)\n\treturn filtered, residual, nil\n}\n\nfunc (p *PDNSProvider) GetDomainFilter() endpoint.DomainFilterInterface {\n\t// Return all zones the provider manages so the controller can intersect\n\t// with --domain-filter on its own. Do NOT apply p.domainFilter here;\n\t// double-filtering would produce an empty filter when no zones match,\n\t// silently failing open instead of letting the controller see the\n\t// mismatch and produce a safe empty plan.\n\tzones, _, err := p.client.ListZones()\n\tif err != nil {\n\t\tlog.Errorf(\"Unable to fetch zones from PowerDNS API: %v\", err)\n\t\treturn &endpoint.DomainFilter{}\n\t}\n\n\tzoneNames := make([]string, 0, 2*len(zones))\n\tfor _, zone := range zones {\n\t\tzoneNames = append(zoneNames, zone.Name, \".\"+zone.Name)\n\t}\n\treturn endpoint.NewDomainFilter(zoneNames)\n}\n\n// hasAliasAnnotation checks if the endpoint has the alias annotation set to true\nfunc (p *PDNSProvider) hasAliasAnnotation(ep *endpoint.Endpoint) bool {\n\tvalue, exists := ep.GetProviderSpecificProperty(\"alias\")\n\treturn exists && value == \"true\"\n}\n\nfunc (p *PDNSProvider) convertRRSetToEndpoints(rr pgo.RrSet) []*endpoint.Endpoint {\n\tendpoints := make([]*endpoint.Endpoint, 0)\n\ttargets := make([]string, 0)\n\trrType_ := rr.Type_\n\n\tfor _, record := range rr.Records {\n\t\t// If a record is \"Disabled\", it's not supposed to be \"visible\"\n\t\tif !record.Disabled {\n\t\t\ttargets = append(targets, record.Content)\n\t\t}\n\t}\n\tif rr.Type_ == \"ALIAS\" {\n\t\trrType_ = endpoint.RecordTypeCNAME\n\t}\n\tendpoints = append(endpoints, endpoint.NewEndpointWithTTL(rr.Name, rrType_, endpoint.TTL(rr.Ttl), targets...))\n\treturn endpoints\n}\n\n// ConvertEndpointsToZones marshals endpoints into pdns compatible Zone structs\nfunc (p *PDNSProvider) ConvertEndpointsToZones(eps []*endpoint.Endpoint, changetype pdnsChangeType) ([]pgo.Zone, error) {\n\tvar zoneList = make([]pgo.Zone, 0)\n\tendpoints := make([]*endpoint.Endpoint, len(eps))\n\tcopy(endpoints, eps)\n\n\t// Sort the endpoints array so we have deterministic inserts\n\tsort.SliceStable(endpoints,\n\t\tfunc(i, j int) bool {\n\t\t\t// We only care about sorting endpoints with the same dnsname\n\t\t\tif endpoints[i].DNSName == endpoints[j].DNSName {\n\t\t\t\treturn endpoints[i].RecordType < endpoints[j].RecordType\n\t\t\t}\n\t\t\treturn endpoints[i].DNSName < endpoints[j].DNSName\n\t\t})\n\n\tfilteredZones, residualZones, err := p.filteredZones()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Sort the zone by length of the name in descending order, we use this\n\t// property later to ensure we add a record to the longest matching zone\n\n\tsort.SliceStable(filteredZones, func(i, j int) bool { return len(filteredZones[i].Name) > len(filteredZones[j].Name) })\n\n\t// NOTE: Complexity of this loop is O(FilteredZones*Endpoints).\n\t// A possibly faster implementation would be a search of the reversed\n\t// DNSName in a trie of Zone names, which should be O(Endpoints), but at this point it's not\n\t// necessary.\n\tfor _, zone := range filteredZones {\n\t\tzone.Rrsets = []pgo.RrSet{}\n\t\tfor i := 0; i < len(endpoints); {\n\t\t\tep := endpoints[i]\n\t\t\tdnsname := provider.EnsureTrailingDot(ep.DNSName)\n\t\t\tif dnsname == zone.Name || strings.HasSuffix(dnsname, \".\"+zone.Name) {\n\t\t\t\t// The assumption here is that there will only ever be one target\n\t\t\t\t// per (ep.DNSName, ep.RecordType) tuple, which holds true for\n\t\t\t\t// external-dns v5.0.0-alpha onwards\n\t\t\t\trecords := []pgo.Record{}\n\t\t\t\tRecordType_ := ep.RecordType\n\t\t\t\tfor _, t := range ep.Targets {\n\t\t\t\t\tif slices.Contains(trailingTypes, ep.RecordType) {\n\t\t\t\t\t\tt = provider.EnsureTrailingDot(t)\n\t\t\t\t\t}\n\t\t\t\t\trecords = append(records, pgo.Record{Content: t})\n\t\t\t\t}\n\n\t\t\t\t// Check if we should use ALIAS instead of CNAME:\n\t\t\t\t// 1. APEX records (dnsname == zone.Name) always use ALIAS\n\t\t\t\t// 2. If annotation external-dns.alpha.kubernetes.io/alias=true is set\n\t\t\t\t//    (can be set via --prefer-alias flag globally or per-resource annotation)\n\t\t\t\tif ep.RecordType == endpoint.RecordTypeCNAME {\n\t\t\t\t\tuseAlias := dnsname == zone.Name || p.hasAliasAnnotation(ep)\n\t\t\t\t\tif useAlias {\n\t\t\t\t\t\tlog.Debugf(\"Converting CNAME record %q to ALIAS\", dnsname)\n\t\t\t\t\t\tRecordType_ = \"ALIAS\"\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\trrset := pgo.RrSet{\n\t\t\t\t\tName:       dnsname,\n\t\t\t\t\tType_:      RecordType_,\n\t\t\t\t\tRecords:    records,\n\t\t\t\t\tChangetype: string(changetype),\n\t\t\t\t}\n\n\t\t\t\t// DELETEs explicitly forbid a TTL, therefore only PATCHes need the TTL\n\t\t\t\tif changetype == PdnsReplace {\n\t\t\t\t\tif int64(ep.RecordTTL) > int64(math.MaxInt32) {\n\t\t\t\t\t\treturn nil, provider.NewSoftErrorf(\"value of record TTL overflows, limited to int32\")\n\t\t\t\t\t}\n\t\t\t\t\tif ep.RecordTTL == 0 {\n\t\t\t\t\t\t// No TTL was specified for the record, we use the default\n\t\t\t\t\t\trrset.Ttl = int32(defaultTTL)\n\t\t\t\t\t} else {\n\t\t\t\t\t\trrset.Ttl = int32(ep.RecordTTL)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tzone.Rrsets = append(zone.Rrsets, rrset)\n\n\t\t\t\t// \"pop\" endpoint if it's matched\n\t\t\t\tendpoints = append(endpoints[0:i], endpoints[i+1:]...)\n\t\t\t} else {\n\t\t\t\t// If we didn't pop anything, we move to the next item in the list\n\t\t\t\ti++\n\t\t\t}\n\t\t}\n\t\tif len(zone.Rrsets) > 0 {\n\t\t\tzoneList = append(zoneList, zone)\n\t\t}\n\t}\n\n\t// residualZones is unsorted by name length like its counterpart\n\t// since we only care to remove endpoints that do not match domain filter\n\tfor _, zone := range residualZones {\n\t\tfor i := 0; i < len(endpoints); {\n\t\t\tep := endpoints[i]\n\t\t\tdnsname := provider.EnsureTrailingDot(ep.DNSName)\n\t\t\tif dnsname == zone.Name || strings.HasSuffix(dnsname, \".\"+zone.Name) {\n\t\t\t\t// \"pop\" endpoint if it's matched to a residual zone... essentially a no-op\n\t\t\t\tlog.Debugf(\"Ignoring Endpoint because it was matched to a zone that was not specified within Domain Filter(s): %s\", dnsname)\n\t\t\t\tendpoints = append(endpoints[0:i], endpoints[i+1:]...)\n\t\t\t} else {\n\t\t\t\ti++\n\t\t\t}\n\t\t}\n\t}\n\t// If we still have some endpoints left, it means we couldn't find a matching zone (filtered or residual) for them\n\t// We warn instead of hard fail here because we don't want a misconfig to cause everything to go down\n\tif len(endpoints) > 0 {\n\t\tlog.Warnf(\"No matching zones were found for the following endpoints: %+v\", endpoints)\n\t}\n\n\tlog.Debugf(\"Zone List generated from Endpoints: %+v\", zoneList)\n\n\treturn zoneList, nil\n}\n\n// mutateRecords takes a list of endpoints and creates, replaces or deletes them based on the changetype\nfunc (p *PDNSProvider) mutateRecords(endpoints []*endpoint.Endpoint, changetype pdnsChangeType) error {\n\tzonelist, err := p.ConvertEndpointsToZones(endpoints, changetype)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, zone := range zonelist {\n\t\tjso, err := json.Marshal(zone)\n\t\tif err != nil {\n\t\t\tlog.Errorf(\"JSON Marshal for zone struct failed!\")\n\t\t} else {\n\t\t\tlog.Debugf(\"Struct for PatchZone:\\n%s\", string(jso))\n\t\t}\n\t\tresp, err := p.client.PatchZone(zone.Id, zone)\n\t\tif err != nil {\n\t\t\tlog.Debugf(\"PDNS API response: %s\", stringifyHTTPResponseBody(resp))\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// Records returns all DNS records controlled by the configured PDNS server (for all zones)\nfunc (p *PDNSProvider) Records(_ context.Context) ([]*endpoint.Endpoint, error) {\n\tfilteredZones, _, err := p.filteredZones()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar endpoints []*endpoint.Endpoint\n\n\tfor _, zone := range filteredZones {\n\t\tz, _, err := p.client.ListZone(zone.Id)\n\t\tif err != nil {\n\t\t\treturn nil, provider.NewSoftErrorf(\"unable to fetch records: %v\", err)\n\t\t}\n\n\t\tfor _, rr := range z.Rrsets {\n\t\t\tendpoints = append(endpoints, p.convertRRSetToEndpoints(rr)...)\n\t\t}\n\t}\n\n\tlog.Debugf(\"Records fetched:\\n%+v\", endpoints)\n\treturn endpoints, nil\n}\n\n// AdjustEndpoints performs checks on the provided endpoints and will skip any potentially failing changes.\nfunc (p *PDNSProvider) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) {\n\tvar validEndpoints []*endpoint.Endpoint\n\tfor i := range endpoints {\n\t\tif !endpoints[i].CheckEndpoint() {\n\t\t\tlog.Warnf(\"Ignoring Endpoint because of invalid %v record formatting: {Target: '%v'}\", endpoints[i].RecordType, endpoints[i].Targets)\n\t\t\tcontinue\n\t\t}\n\t\tvalidEndpoints = append(validEndpoints, endpoints[i])\n\t}\n\treturn validEndpoints, nil\n}\n\n// ApplyChanges takes a list of changes (endpoints) and updates the PDNS server\n// by sending the correct HTTP PATCH requests to a matching zone\nfunc (p *PDNSProvider) ApplyChanges(_ context.Context, changes *plan.Changes) error {\n\tstartTime := time.Now()\n\n\t// Create\n\tfor _, change := range changes.Create {\n\t\tlog.Infof(\"CREATE: %+v\", change)\n\t}\n\t// We only attempt to mutate records if there are any to mutate.  A\n\t// call to mutate records with an empty list of endpoints is still a\n\t// valid call and a no-op, but we might as well not make the call to\n\t// prevent unnecessary logging\n\tif len(changes.Create) > 0 {\n\t\t// \"Replacing\" non-existent records creates them\n\t\terr := p.mutateRecords(changes.Create, PdnsReplace)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// Update\n\tfor _, change := range changes.UpdateOld {\n\t\t// Since PDNS \"Patches\", we don't need to specify the \"old\"\n\t\t// record. The Update New change type will automatically take\n\t\t// care of replacing the old RRSet with the new one We simply\n\t\t// leave this logging here for information\n\t\tlog.Debugf(\"UPDATE-OLD (ignored): %+v\", change)\n\t}\n\n\tfor _, change := range changes.UpdateNew {\n\t\tlog.Infof(\"UPDATE-NEW: %+v\", change)\n\t}\n\tif len(changes.UpdateNew) > 0 {\n\t\terr := p.mutateRecords(changes.UpdateNew, PdnsReplace)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// Delete\n\tfor _, change := range changes.Delete {\n\t\tlog.Infof(\"DELETE: %+v\", change)\n\t}\n\tif len(changes.Delete) > 0 {\n\t\terr := p.mutateRecords(changes.Delete, PdnsDelete)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tlog.Infof(\"Changes pushed out to PowerDNS in %s\\n\", time.Since(startTime))\n\treturn nil\n}\n"
  },
  {
    "path": "provider/pdns/pdns_test.go",
    "content": "/*\nCopyright 2018 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage pdns\n\nimport (\n\t\"context\"\n\t\"net/http\"\n\t\"regexp\"\n\t\"strings\"\n\t\"testing\"\n\n\tpgo \"github.com/ffledgling/pdns-go\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/suite\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\n// FIXME: What do we do about labels?\n\nvar (\n\t// Simple RRSets that contain 1 A record and 1 TXT record\n\tRRSetSimpleARecord = pgo.RrSet{\n\t\tName:  \"example.com.\",\n\t\tType_: endpoint.RecordTypeA,\n\t\tTtl:   300,\n\t\tRecords: []pgo.Record{\n\t\t\t{Content: \"8.8.8.8\", Disabled: false, SetPtr: false},\n\t\t},\n\t}\n\tRRSetSimpleTXTRecord = pgo.RrSet{\n\t\tName:  \"example.com.\",\n\t\tType_: endpoint.RecordTypeTXT,\n\t\tTtl:   300,\n\t\tRecords: []pgo.Record{\n\t\t\t{Content: \"\\\"heritage=external-dns,external-dns/owner=tower-pdns\\\"\", Disabled: false, SetPtr: false},\n\t\t},\n\t}\n\tRRSetLongARecord = pgo.RrSet{\n\t\tName:  \"a.very.long.domainname.example.com.\",\n\t\tType_: endpoint.RecordTypeA,\n\t\tTtl:   300,\n\t\tRecords: []pgo.Record{\n\t\t\t{Content: \"8.8.8.8\", Disabled: false, SetPtr: false},\n\t\t},\n\t}\n\tRRSetLongTXTRecord = pgo.RrSet{\n\t\tName:  \"a.very.long.domainname.example.com.\",\n\t\tType_: endpoint.RecordTypeTXT,\n\t\tTtl:   300,\n\t\tRecords: []pgo.Record{\n\t\t\t{Content: \"\\\"heritage=external-dns,external-dns/owner=tower-pdns\\\"\", Disabled: false, SetPtr: false},\n\t\t},\n\t}\n\t// RRSet with one record disabled\n\tRRSetDisabledRecord = pgo.RrSet{\n\t\tName:  \"example.com.\",\n\t\tType_: endpoint.RecordTypeA,\n\t\tTtl:   300,\n\t\tRecords: []pgo.Record{\n\t\t\t{Content: \"8.8.8.8\", Disabled: false, SetPtr: false},\n\t\t\t{Content: \"8.8.4.4\", Disabled: true, SetPtr: false},\n\t\t},\n\t}\n\n\tRRSetCNAMERecord = pgo.RrSet{\n\t\tName:  \"cname.example.com.\",\n\t\tType_: endpoint.RecordTypeCNAME,\n\t\tTtl:   300,\n\t\tRecords: []pgo.Record{\n\t\t\t{Content: \"example.com.\", Disabled: false, SetPtr: false},\n\t\t},\n\t}\n\n\tRRSetALIASRecord = pgo.RrSet{\n\t\tName:  \"alias.example.com.\",\n\t\tType_: \"ALIAS\",\n\t\tTtl:   300,\n\t\tRecords: []pgo.Record{\n\t\t\t{Content: \"example.by.any.other.name.com.\", Disabled: false, SetPtr: false},\n\t\t},\n\t}\n\n\tRRSetTXTRecord = pgo.RrSet{\n\t\tName:  \"example.com.\",\n\t\tType_: endpoint.RecordTypeTXT,\n\t\tTtl:   300,\n\t\tRecords: []pgo.Record{\n\t\t\t{Content: \"'would smell as sweet'\", Disabled: false, SetPtr: false},\n\t\t},\n\t}\n\n\t// Multiple PDNS records in an RRSet of a single type\n\tRRSetMultipleRecords = pgo.RrSet{\n\t\tName:  \"example.com.\",\n\t\tType_: endpoint.RecordTypeA,\n\t\tTtl:   300,\n\t\tRecords: []pgo.Record{\n\t\t\t{Content: \"8.8.8.8\", Disabled: false, SetPtr: false},\n\t\t\t{Content: \"8.8.4.4\", Disabled: false, SetPtr: false},\n\t\t\t{Content: \"4.4.4.4\", Disabled: false, SetPtr: false},\n\t\t},\n\t}\n\n\t// RRSet with MX record\n\tRRSetMXRecord = pgo.RrSet{\n\t\tName:  \"example.com.\",\n\t\tType_: endpoint.RecordTypeMX,\n\t\tTtl:   300,\n\t\tRecords: []pgo.Record{\n\t\t\t{Content: \"10 mailhost1.example.com\", Disabled: false, SetPtr: false},\n\t\t\t{Content: \"10 mailhost2.example.com\", Disabled: false, SetPtr: false},\n\t\t},\n\t}\n\n\t// RRSet with SRV record\n\tRRSetSRVRecord = pgo.RrSet{\n\t\tName:  \"_service._tls.example.com.\",\n\t\tType_: endpoint.RecordTypeSRV,\n\t\tTtl:   300,\n\t\tRecords: []pgo.Record{\n\t\t\t{Content: \"100 1 443 service.example.com\", Disabled: false, SetPtr: false},\n\t\t},\n\t}\n\n\t// RRSet with NS record\n\tRRSetNSRecord = pgo.RrSet{\n\t\tName:  \"sub.example.com.\",\n\t\tType_: endpoint.RecordTypeNS,\n\t\tTtl:   300,\n\t\tRecords: []pgo.Record{\n\t\t\t{Content: \"ns1.example.com\", Disabled: false, SetPtr: false},\n\t\t\t{Content: \"ns2.example.com\", Disabled: false, SetPtr: false},\n\t\t},\n\t}\n\n\t// RRSet with PTR record\n\tRRSetPTRRecord = pgo.RrSet{\n\t\tName:  \"4.3.2.1.in-addr.arpa.\",\n\t\tType_: endpoint.RecordTypePTR,\n\t\tTtl:   300,\n\t\tRecords: []pgo.Record{\n\t\t\t{Content: \"host.example.com\", Disabled: false, SetPtr: false},\n\t\t},\n\t}\n\n\tendpointsDisabledRecord = []*endpoint.Endpoint{\n\t\tendpoint.NewEndpointWithTTL(\"example.com\", endpoint.RecordTypeA, endpoint.TTL(300), \"8.8.8.8\"),\n\t}\n\n\tendpointsSimpleRecord = []*endpoint.Endpoint{\n\t\tendpoint.NewEndpointWithTTL(\"example.com\", endpoint.RecordTypeA, endpoint.TTL(300), \"8.8.8.8\"),\n\t\tendpoint.NewEndpointWithTTL(\"example.com\", endpoint.RecordTypeTXT, endpoint.TTL(300), \"\\\"heritage=external-dns,external-dns/owner=tower-pdns\\\"\"),\n\t}\n\n\tendpointsLongRecord = []*endpoint.Endpoint{\n\t\tendpoint.NewEndpointWithTTL(\"a.very.long.domainname.example.com\", endpoint.RecordTypeA, endpoint.TTL(300), \"8.8.8.8\"),\n\t\tendpoint.NewEndpointWithTTL(\"a.very.long.domainname.example.com\", endpoint.RecordTypeTXT, endpoint.TTL(300), \"\\\"heritage=external-dns,external-dns/owner=tower-pdns\\\"\"),\n\t}\n\n\tendpointsNonexistantZone = []*endpoint.Endpoint{\n\t\tendpoint.NewEndpointWithTTL(\"does.not.exist.com\", endpoint.RecordTypeA, endpoint.TTL(300), \"8.8.8.8\"),\n\t\tendpoint.NewEndpointWithTTL(\"does.not.exist.com\", endpoint.RecordTypeTXT, endpoint.TTL(300), \"\\\"heritage=external-dns,external-dns/owner=tower-pdns\\\"\"),\n\t}\n\tendpointsMultipleRecords = []*endpoint.Endpoint{\n\t\tendpoint.NewEndpointWithTTL(\"example.com\", endpoint.RecordTypeA, endpoint.TTL(300), \"8.8.8.8\", \"8.8.4.4\", \"4.4.4.4\"),\n\t}\n\n\tendpointsMXRecord = []*endpoint.Endpoint{\n\t\tendpoint.NewEndpointWithTTL(\"mail.example.com\", endpoint.RecordTypeMX, endpoint.TTL(300), \"10 example.com\"),\n\t}\n\n\tendpointsMXRecordInvalidFormatTooManyArgs = []*endpoint.Endpoint{\n\t\tendpoint.NewEndpointWithTTL(\"mail.example.com\", endpoint.RecordTypeMX, endpoint.TTL(300), \"10 example.com abc\"),\n\t}\n\n\tendpointsMultipleMXRecordsWithSingleInvalid = []*endpoint.Endpoint{\n\t\tendpoint.NewEndpointWithTTL(\"mail.example.com\", endpoint.RecordTypeMX, endpoint.TTL(300), \"abc example.com\"),\n\t\tendpoint.NewEndpointWithTTL(\"mail.example.com\", endpoint.RecordTypeMX, endpoint.TTL(300), \"20 backup.example.com\"),\n\t}\n\n\tendpointsMultipleInvalidMXRecords = []*endpoint.Endpoint{\n\t\tendpoint.NewEndpointWithTTL(\"mail.example.com\", endpoint.RecordTypeMX, endpoint.TTL(300), \"example.com\"),\n\t\tendpoint.NewEndpointWithTTL(\"mail.example.com\", endpoint.RecordTypeMX, endpoint.TTL(300), \"backup.example.com\"),\n\t}\n\n\tendpointsMixedRecords = []*endpoint.Endpoint{\n\t\tendpoint.NewEndpointWithTTL(\"cname.example.com\", endpoint.RecordTypeCNAME, endpoint.TTL(300), \"example.com\"),\n\t\tendpoint.NewEndpointWithTTL(\"example.com\", endpoint.RecordTypeTXT, endpoint.TTL(300), \"'would smell as sweet'\"),\n\t\tendpoint.NewEndpointWithTTL(\"example.com\", endpoint.RecordTypeA, endpoint.TTL(300), \"8.8.8.8\", \"8.8.4.4\", \"4.4.4.4\"),\n\t\tendpoint.NewEndpointWithTTL(\"alias.example.com\", endpoint.RecordTypeCNAME, endpoint.TTL(300), \"example.by.any.other.name.com\"),\n\t\tendpoint.NewEndpointWithTTL(\"example.com\", endpoint.RecordTypeMX, endpoint.TTL(300), \"10 mailhost1.example.com\", \"10 mailhost2.example.com\"),\n\t\tendpoint.NewEndpointWithTTL(\"_service._tls.example.com\", endpoint.RecordTypeSRV, endpoint.TTL(300), \"100 1 443 service.example.com\"),\n\t\tendpoint.NewEndpointWithTTL(\"sub.example.com\", endpoint.RecordTypeNS, endpoint.TTL(300), \"ns1.example.com\", \"ns2.example.com\"),\n\t\tendpoint.NewEndpointWithTTL(\"4.3.2.1.in-addr.arpa\", endpoint.RecordTypePTR, endpoint.TTL(300), \"host.example.com\"),\n\t}\n\n\tendpointsMultipleZones = []*endpoint.Endpoint{\n\t\tendpoint.NewEndpointWithTTL(\"example.com\", endpoint.RecordTypeA, endpoint.TTL(300), \"8.8.8.8\"),\n\t\tendpoint.NewEndpointWithTTL(\"example.com\", endpoint.RecordTypeTXT, endpoint.TTL(300), \"\\\"heritage=external-dns,external-dns/owner=tower-pdns\\\"\"),\n\t\tendpoint.NewEndpointWithTTL(\"mock.test\", endpoint.RecordTypeA, endpoint.TTL(300), \"9.9.9.9\"),\n\t\tendpoint.NewEndpointWithTTL(\"mock.test\", endpoint.RecordTypeTXT, endpoint.TTL(300), \"\\\"heritage=external-dns,external-dns/owner=tower-pdns\\\"\"),\n\t}\n\n\tendpointsMultipleZones2 = []*endpoint.Endpoint{\n\t\tendpoint.NewEndpointWithTTL(\"example.com\", endpoint.RecordTypeA, endpoint.TTL(300), \"8.8.8.8\"),\n\t\tendpoint.NewEndpointWithTTL(\"example.com\", endpoint.RecordTypeTXT, endpoint.TTL(300), \"\\\"heritage=external-dns,external-dns/owner=tower-pdns\\\"\"),\n\t\tendpoint.NewEndpointWithTTL(\"abcd.mock.test\", endpoint.RecordTypeA, endpoint.TTL(300), \"9.9.9.9\"),\n\t\tendpoint.NewEndpointWithTTL(\"abcd.mock.test\", endpoint.RecordTypeTXT, endpoint.TTL(300), \"\\\"heritage=external-dns,external-dns/owner=tower-pdns\\\"\"),\n\t}\n\n\tendpointsMultipleZonesWithNoExist = []*endpoint.Endpoint{\n\t\tendpoint.NewEndpointWithTTL(\"example.com\", endpoint.RecordTypeA, endpoint.TTL(300), \"8.8.8.8\"),\n\t\tendpoint.NewEndpointWithTTL(\"example.com\", endpoint.RecordTypeTXT, endpoint.TTL(300), \"\\\"heritage=external-dns,external-dns/owner=tower-pdns\\\"\"),\n\t\tendpoint.NewEndpointWithTTL(\"abcd.mock.noexist\", endpoint.RecordTypeA, endpoint.TTL(300), \"9.9.9.9\"),\n\t\tendpoint.NewEndpointWithTTL(\"abcd.mock.noexist\", endpoint.RecordTypeTXT, endpoint.TTL(300), \"\\\"heritage=external-dns,external-dns/owner=tower-pdns\\\"\"),\n\t}\n\tendpointsMultipleZonesWithLongRecordNotInDomainFilter = []*endpoint.Endpoint{\n\t\tendpoint.NewEndpointWithTTL(\"example.com\", endpoint.RecordTypeA, endpoint.TTL(300), \"8.8.8.8\"),\n\t\tendpoint.NewEndpointWithTTL(\"example.com\", endpoint.RecordTypeTXT, endpoint.TTL(300), \"\\\"heritage=external-dns,external-dns/owner=tower-pdns\\\"\"),\n\t\tendpoint.NewEndpointWithTTL(\"a.very.long.domainname.example.com\", endpoint.RecordTypeA, endpoint.TTL(300), \"9.9.9.9\"),\n\t\tendpoint.NewEndpointWithTTL(\"a.very.long.domainname.example.com\", endpoint.RecordTypeTXT, endpoint.TTL(300), \"\\\"heritage=external-dns,external-dns/owner=tower-pdns\\\"\"),\n\t}\n\tendpointsMultipleZonesWithSimilarRecordNotInDomainFilter = []*endpoint.Endpoint{\n\t\tendpoint.NewEndpointWithTTL(\"example.com\", endpoint.RecordTypeA, endpoint.TTL(300), \"8.8.8.8\"),\n\t\tendpoint.NewEndpointWithTTL(\"example.com\", endpoint.RecordTypeTXT, endpoint.TTL(300), \"\\\"heritage=external-dns,external-dns/owner=tower-pdns\\\"\"),\n\t\tendpoint.NewEndpointWithTTL(\"test.simexample.com\", endpoint.RecordTypeA, endpoint.TTL(300), \"9.9.9.9\"),\n\t\tendpoint.NewEndpointWithTTL(\"test.simexample.com\", endpoint.RecordTypeTXT, endpoint.TTL(300), \"\\\"heritage=external-dns,external-dns/owner=tower-pdns\\\"\"),\n\t}\n\tendpointsApexRecords = []*endpoint.Endpoint{\n\t\tendpoint.NewEndpointWithTTL(\"cname.example.com\", endpoint.RecordTypeTXT, endpoint.TTL(300), \"\\\"heritage=external-dns,external-dns/owner=tower-pdns\\\"\"),\n\t\tendpoint.NewEndpointWithTTL(\"cname.example.com\", endpoint.RecordTypeCNAME, endpoint.TTL(300), \"example.by.any.other.name.com\"),\n\t\tendpoint.NewEndpointWithTTL(\"example.com\", endpoint.RecordTypeTXT, endpoint.TTL(300), \"\\\"heritage=external-dns,external-dns/owner=tower-pdns\\\"\"),\n\t\tendpoint.NewEndpointWithTTL(\"example.com\", endpoint.RecordTypeCNAME, endpoint.TTL(300), \"example.by.any.other.name.com\"),\n\t}\n\n\t// Endpoint with alias annotation\n\tendpointWithAliasAnnotation = endpoint.NewEndpointWithTTL(\"sub.example.com\", endpoint.RecordTypeCNAME, endpoint.TTL(300), \"target.example.com\").WithProviderSpecific(\"alias\", \"true\")\n\n\t// Endpoints for preferAlias test\n\tendpointsPreferAlias = []*endpoint.Endpoint{\n\t\tendpoint.NewEndpointWithTTL(\"sub.example.com\", endpoint.RecordTypeCNAME, endpoint.TTL(300), \"target.example.com\"),\n\t}\n\n\tZoneEmptyToPreferAliasPatch = pgo.Zone{\n\t\tId:    \"example.com.\",\n\t\tName:  \"example.com.\",\n\t\tType_: \"Zone\",\n\t\tUrl:   \"/api/v1/servers/localhost/zones/example.com.\",\n\t\tKind:  \"Native\",\n\t\tRrsets: []pgo.RrSet{\n\t\t\t{\n\t\t\t\tName:       \"sub.example.com.\",\n\t\t\t\tType_:      \"ALIAS\",\n\t\t\t\tTtl:        300,\n\t\t\t\tChangetype: \"REPLACE\",\n\t\t\t\tRecords: []pgo.Record{\n\t\t\t\t\t{\n\t\t\t\t\t\tContent:  \"target.example.com.\",\n\t\t\t\t\t\tDisabled: false,\n\t\t\t\t\t\tSetPtr:   false,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tComments: []pgo.Comment(nil),\n\t\t\t},\n\t\t},\n\t}\n\n\tZoneEmptyToCNAMEPatch = pgo.Zone{\n\t\tId:    \"example.com.\",\n\t\tName:  \"example.com.\",\n\t\tType_: \"Zone\",\n\t\tUrl:   \"/api/v1/servers/localhost/zones/example.com.\",\n\t\tKind:  \"Native\",\n\t\tRrsets: []pgo.RrSet{\n\t\t\t{\n\t\t\t\tName:       \"sub.example.com.\",\n\t\t\t\tType_:      endpoint.RecordTypeCNAME,\n\t\t\t\tTtl:        300,\n\t\t\t\tChangetype: \"REPLACE\",\n\t\t\t\tRecords: []pgo.Record{\n\t\t\t\t\t{\n\t\t\t\t\t\tContent:  \"target.example.com.\",\n\t\t\t\t\t\tDisabled: false,\n\t\t\t\t\t\tSetPtr:   false,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tComments: []pgo.Comment(nil),\n\t\t\t},\n\t\t},\n\t}\n\n\tZoneEmpty = pgo.Zone{\n\t\t// Opaque zone id (string), assigned by the server, should not be interpreted by the application. Guaranteed to be safe for embedding in URLs.\n\t\tId: \"example.com.\",\n\t\t// Name of the zone (e.g. “example.com.”) MUST have a trailing dot\n\t\tName: \"example.com.\",\n\t\t// Set to “Zone”\n\t\tType_: \"Zone\",\n\t\t// API endpoint for this zone\n\t\tUrl: \"/api/v1/servers/localhost/zones/example.com.\",\n\t\t// Zone kind, one of “Native”, “Master”, “Slave”\n\t\tKind: \"Native\",\n\t\t// RRSets in this zone\n\t\tRrsets: []pgo.RrSet{},\n\t}\n\n\tZoneEmptySimilar = pgo.Zone{\n\t\tId:     \"simexample.com.\",\n\t\tName:   \"simexample.com.\",\n\t\tType_:  \"Zone\",\n\t\tUrl:    \"/api/v1/servers/localhost/zones/simexample.com.\",\n\t\tKind:   \"Native\",\n\t\tRrsets: []pgo.RrSet{},\n\t}\n\n\tZoneEmptyLong = pgo.Zone{\n\t\tId:     \"long.domainname.example.com.\",\n\t\tName:   \"long.domainname.example.com.\",\n\t\tType_:  \"Zone\",\n\t\tUrl:    \"/api/v1/servers/localhost/zones/long.domainname.example.com.\",\n\t\tKind:   \"Native\",\n\t\tRrsets: []pgo.RrSet{},\n\t}\n\n\tZoneEmpty2 = pgo.Zone{\n\t\tId:     \"mock.test.\",\n\t\tName:   \"mock.test.\",\n\t\tType_:  \"Zone\",\n\t\tUrl:    \"/api/v1/servers/localhost/zones/mock.test.\",\n\t\tKind:   \"Native\",\n\t\tRrsets: []pgo.RrSet{},\n\t}\n\n\tZoneMixed = pgo.Zone{\n\t\tId:     \"example.com.\",\n\t\tName:   \"example.com.\",\n\t\tType_:  \"Zone\",\n\t\tUrl:    \"/api/v1/servers/localhost/zones/example.com.\",\n\t\tKind:   \"Native\",\n\t\tRrsets: []pgo.RrSet{RRSetCNAMERecord, RRSetTXTRecord, RRSetMultipleRecords, RRSetALIASRecord, RRSetMXRecord, RRSetSRVRecord, RRSetNSRecord, RRSetPTRRecord},\n\t}\n\n\tZoneEmptyToSimplePatch = pgo.Zone{\n\t\tId:    \"example.com.\",\n\t\tName:  \"example.com.\",\n\t\tType_: \"Zone\",\n\t\tUrl:   \"/api/v1/servers/localhost/zones/example.com.\",\n\t\tKind:  \"Native\",\n\t\tRrsets: []pgo.RrSet{\n\t\t\t{\n\t\t\t\tName:       \"example.com.\",\n\t\t\t\tType_:      endpoint.RecordTypeA,\n\t\t\t\tTtl:        300,\n\t\t\t\tChangetype: \"REPLACE\",\n\t\t\t\tRecords: []pgo.Record{\n\t\t\t\t\t{\n\t\t\t\t\t\tContent:  \"8.8.8.8\",\n\t\t\t\t\t\tDisabled: false,\n\t\t\t\t\t\tSetPtr:   false,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tComments: []pgo.Comment(nil),\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:       \"example.com.\",\n\t\t\t\tType_:      endpoint.RecordTypeTXT,\n\t\t\t\tTtl:        300,\n\t\t\t\tChangetype: \"REPLACE\",\n\t\t\t\tRecords: []pgo.Record{\n\t\t\t\t\t{\n\t\t\t\t\t\tContent:  \"\\\"heritage=external-dns,external-dns/owner=tower-pdns\\\"\",\n\t\t\t\t\t\tDisabled: false,\n\t\t\t\t\t\tSetPtr:   false,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tComments: []pgo.Comment(nil),\n\t\t\t},\n\t\t},\n\t}\n\n\tZoneEmptyToSimplePatchLongRecordIgnoredInDomainFilter = pgo.Zone{\n\t\tId:    \"example.com.\",\n\t\tName:  \"example.com.\",\n\t\tType_: \"Zone\",\n\t\tUrl:   \"/api/v1/servers/localhost/zones/example.com.\",\n\t\tKind:  \"Native\",\n\t\tRrsets: []pgo.RrSet{\n\t\t\t{\n\t\t\t\tName:       \"a.very.long.domainname.example.com.\",\n\t\t\t\tType_:      endpoint.RecordTypeA,\n\t\t\t\tTtl:        300,\n\t\t\t\tChangetype: \"REPLACE\",\n\t\t\t\tRecords: []pgo.Record{\n\t\t\t\t\t{\n\t\t\t\t\t\tContent:  \"9.9.9.9\",\n\t\t\t\t\t\tDisabled: false,\n\t\t\t\t\t\tSetPtr:   false,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tComments: []pgo.Comment(nil),\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:       \"a.very.long.domainname.example.com.\",\n\t\t\t\tType_:      endpoint.RecordTypeTXT,\n\t\t\t\tTtl:        300,\n\t\t\t\tChangetype: \"REPLACE\",\n\t\t\t\tRecords: []pgo.Record{\n\t\t\t\t\t{\n\t\t\t\t\t\tContent:  \"\\\"heritage=external-dns,external-dns/owner=tower-pdns\\\"\",\n\t\t\t\t\t\tDisabled: false,\n\t\t\t\t\t\tSetPtr:   false,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tComments: []pgo.Comment(nil),\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:       \"example.com.\",\n\t\t\t\tType_:      endpoint.RecordTypeA,\n\t\t\t\tTtl:        300,\n\t\t\t\tChangetype: \"REPLACE\",\n\t\t\t\tRecords: []pgo.Record{\n\t\t\t\t\t{\n\t\t\t\t\t\tContent:  \"8.8.8.8\",\n\t\t\t\t\t\tDisabled: false,\n\t\t\t\t\t\tSetPtr:   false,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tComments: []pgo.Comment(nil),\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:       \"example.com.\",\n\t\t\t\tType_:      endpoint.RecordTypeTXT,\n\t\t\t\tTtl:        300,\n\t\t\t\tChangetype: \"REPLACE\",\n\t\t\t\tRecords: []pgo.Record{\n\t\t\t\t\t{\n\t\t\t\t\t\tContent:  \"\\\"heritage=external-dns,external-dns/owner=tower-pdns\\\"\",\n\t\t\t\t\t\tDisabled: false,\n\t\t\t\t\t\tSetPtr:   false,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tComments: []pgo.Comment(nil),\n\t\t\t},\n\t\t},\n\t}\n\n\tZoneEmptyToLongPatch = pgo.Zone{\n\t\tId:    \"long.domainname.example.com.\",\n\t\tName:  \"long.domainname.example.com.\",\n\t\tType_: \"Zone\",\n\t\tUrl:   \"/api/v1/servers/localhost/zones/long.domainname.example.com.\",\n\t\tKind:  \"Native\",\n\t\tRrsets: []pgo.RrSet{\n\t\t\t{\n\t\t\t\tName:       \"a.very.long.domainname.example.com.\",\n\t\t\t\tType_:      endpoint.RecordTypeA,\n\t\t\t\tTtl:        300,\n\t\t\t\tChangetype: \"REPLACE\",\n\t\t\t\tRecords: []pgo.Record{\n\t\t\t\t\t{\n\t\t\t\t\t\tContent:  \"8.8.8.8\",\n\t\t\t\t\t\tDisabled: false,\n\t\t\t\t\t\tSetPtr:   false,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tComments: []pgo.Comment(nil),\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:       \"a.very.long.domainname.example.com.\",\n\t\t\t\tType_:      endpoint.RecordTypeTXT,\n\t\t\t\tTtl:        300,\n\t\t\t\tChangetype: \"REPLACE\",\n\t\t\t\tRecords: []pgo.Record{\n\t\t\t\t\t{\n\t\t\t\t\t\tContent:  \"\\\"heritage=external-dns,external-dns/owner=tower-pdns\\\"\",\n\t\t\t\t\t\tDisabled: false,\n\t\t\t\t\t\tSetPtr:   false,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tComments: []pgo.Comment(nil),\n\t\t\t},\n\t\t},\n\t}\n\n\tZoneEmptyToSimplePatch2 = pgo.Zone{\n\t\tId:    \"mock.test.\",\n\t\tName:  \"mock.test.\",\n\t\tType_: \"Zone\",\n\t\tUrl:   \"/api/v1/servers/localhost/zones/mock.test.\",\n\t\tKind:  \"Native\",\n\t\tRrsets: []pgo.RrSet{\n\t\t\t{\n\t\t\t\tName:       \"mock.test.\",\n\t\t\t\tType_:      endpoint.RecordTypeA,\n\t\t\t\tTtl:        300,\n\t\t\t\tChangetype: \"REPLACE\",\n\t\t\t\tRecords: []pgo.Record{\n\t\t\t\t\t{\n\t\t\t\t\t\tContent:  \"9.9.9.9\",\n\t\t\t\t\t\tDisabled: false,\n\t\t\t\t\t\tSetPtr:   false,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tComments: []pgo.Comment(nil),\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:       \"mock.test.\",\n\t\t\t\tType_:      endpoint.RecordTypeTXT,\n\t\t\t\tTtl:        300,\n\t\t\t\tChangetype: \"REPLACE\",\n\t\t\t\tRecords: []pgo.Record{\n\t\t\t\t\t{\n\t\t\t\t\t\tContent:  \"\\\"heritage=external-dns,external-dns/owner=tower-pdns\\\"\",\n\t\t\t\t\t\tDisabled: false,\n\t\t\t\t\t\tSetPtr:   false,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tComments: []pgo.Comment(nil),\n\t\t\t},\n\t\t},\n\t}\n\n\tZoneEmptyToSimplePatch3 = pgo.Zone{\n\t\tId:    \"mock.test.\",\n\t\tName:  \"mock.test.\",\n\t\tType_: \"Zone\",\n\t\tUrl:   \"/api/v1/servers/localhost/zones/mock.test.\",\n\t\tKind:  \"Native\",\n\t\tRrsets: []pgo.RrSet{\n\t\t\t{\n\t\t\t\tName:       \"abcd.mock.test.\",\n\t\t\t\tType_:      endpoint.RecordTypeA,\n\t\t\t\tTtl:        300,\n\t\t\t\tChangetype: \"REPLACE\",\n\t\t\t\tRecords: []pgo.Record{\n\t\t\t\t\t{\n\t\t\t\t\t\tContent:  \"9.9.9.9\",\n\t\t\t\t\t\tDisabled: false,\n\t\t\t\t\t\tSetPtr:   false,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tComments: []pgo.Comment(nil),\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:       \"abcd.mock.test.\",\n\t\t\t\tType_:      endpoint.RecordTypeTXT,\n\t\t\t\tTtl:        300,\n\t\t\t\tChangetype: \"REPLACE\",\n\t\t\t\tRecords: []pgo.Record{\n\t\t\t\t\t{\n\t\t\t\t\t\tContent:  \"\\\"heritage=external-dns,external-dns/owner=tower-pdns\\\"\",\n\t\t\t\t\t\tDisabled: false,\n\t\t\t\t\t\tSetPtr:   false,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tComments: []pgo.Comment(nil),\n\t\t\t},\n\t\t},\n\t}\n\n\tZoneEmptyToSimpleDelete = pgo.Zone{\n\t\tId:    \"example.com.\",\n\t\tName:  \"example.com.\",\n\t\tType_: \"Zone\",\n\t\tUrl:   \"/api/v1/servers/localhost/zones/example.com.\",\n\t\tKind:  \"Native\",\n\t\tRrsets: []pgo.RrSet{\n\t\t\t{\n\t\t\t\tName:       \"example.com.\",\n\t\t\t\tType_:      endpoint.RecordTypeA,\n\t\t\t\tChangetype: \"DELETE\",\n\t\t\t\tRecords: []pgo.Record{\n\t\t\t\t\t{\n\t\t\t\t\t\tContent:  \"8.8.8.8\",\n\t\t\t\t\t\tDisabled: false,\n\t\t\t\t\t\tSetPtr:   false,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tComments: []pgo.Comment(nil),\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:       \"example.com.\",\n\t\t\t\tType_:      endpoint.RecordTypeTXT,\n\t\t\t\tChangetype: \"DELETE\",\n\t\t\t\tRecords: []pgo.Record{\n\t\t\t\t\t{\n\t\t\t\t\t\tContent:  \"\\\"heritage=external-dns,external-dns/owner=tower-pdns\\\"\",\n\t\t\t\t\t\tDisabled: false,\n\t\t\t\t\t\tSetPtr:   false,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tComments: []pgo.Comment(nil),\n\t\t\t},\n\t\t},\n\t}\n\n\tZoneEmptyToApexPatch = pgo.Zone{\n\t\tId:    \"example.com.\",\n\t\tName:  \"example.com.\",\n\t\tType_: \"Zone\",\n\t\tUrl:   \"/api/v1/servers/localhost/zones/example.com.\",\n\t\tKind:  \"Native\",\n\t\tRrsets: []pgo.RrSet{\n\t\t\t{\n\t\t\t\tName:       \"cname.example.com.\",\n\t\t\t\tType_:      endpoint.RecordTypeCNAME,\n\t\t\t\tTtl:        300,\n\t\t\t\tChangetype: \"REPLACE\",\n\t\t\t\tRecords: []pgo.Record{\n\t\t\t\t\t{\n\t\t\t\t\t\tContent:  \"example.by.any.other.name.com.\",\n\t\t\t\t\t\tDisabled: false,\n\t\t\t\t\t\tSetPtr:   false,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tComments: []pgo.Comment(nil),\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:       \"cname.example.com.\",\n\t\t\t\tType_:      endpoint.RecordTypeTXT,\n\t\t\t\tTtl:        300,\n\t\t\t\tChangetype: \"REPLACE\",\n\t\t\t\tRecords: []pgo.Record{\n\t\t\t\t\t{\n\t\t\t\t\t\tContent:  \"\\\"heritage=external-dns,external-dns/owner=tower-pdns\\\"\",\n\t\t\t\t\t\tDisabled: false,\n\t\t\t\t\t\tSetPtr:   false,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tComments: []pgo.Comment(nil),\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:       \"example.com.\",\n\t\t\t\tType_:      \"ALIAS\",\n\t\t\t\tTtl:        300,\n\t\t\t\tChangetype: \"REPLACE\",\n\t\t\t\tRecords: []pgo.Record{\n\t\t\t\t\t{\n\t\t\t\t\t\tContent:  \"example.by.any.other.name.com.\",\n\t\t\t\t\t\tDisabled: false,\n\t\t\t\t\t\tSetPtr:   false,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tComments: []pgo.Comment(nil),\n\t\t\t},\n\t\t\t{\n\t\t\t\tName:       \"example.com.\",\n\t\t\t\tType_:      endpoint.RecordTypeTXT,\n\t\t\t\tTtl:        300,\n\t\t\t\tChangetype: \"REPLACE\",\n\t\t\t\tRecords: []pgo.Record{\n\t\t\t\t\t{\n\t\t\t\t\t\tContent:  \"\\\"heritage=external-dns,external-dns/owner=tower-pdns\\\"\",\n\t\t\t\t\t\tDisabled: false,\n\t\t\t\t\t\tSetPtr:   false,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tComments: []pgo.Comment(nil),\n\t\t\t},\n\t\t},\n\t}\n\n\tDomainFilterListSingle = endpoint.NewDomainFilter([]string{\"example.com\"})\n\n\tDomainFilterListMultiple = endpoint.NewDomainFilter([]string{\"example.com\", \"mock.com\"})\n\n\tDomainFilterListEmpty = endpoint.NewDomainFilter([]string{})\n\n\tRegexDomainFilter = endpoint.NewRegexDomainFilter(regexp.MustCompile(\"example.com\"), nil)\n)\n\n/******************************************************************************/\n// API that returns a zone with multiple record types\ntype PDNSAPIClientStub struct{}\n\nfunc (c *PDNSAPIClientStub) ListZones() ([]pgo.Zone, *http.Response, error) {\n\treturn []pgo.Zone{ZoneMixed}, nil, nil\n}\n\nfunc (c *PDNSAPIClientStub) ListZone(_ string) (pgo.Zone, *http.Response, error) {\n\treturn ZoneMixed, nil, nil\n}\n\nfunc (c *PDNSAPIClientStub) PatchZone(_ string, _ pgo.Zone) (*http.Response, error) {\n\treturn &http.Response{}, nil\n}\n\n/******************************************************************************/\n// API that returns a zones with no records\ntype PDNSAPIClientStubEmptyZones struct {\n\t// Keep track of all zones we receive via PatchZone\n\tpatchedZones []pgo.Zone\n}\n\nfunc (c *PDNSAPIClientStubEmptyZones) ListZones() ([]pgo.Zone, *http.Response, error) {\n\treturn []pgo.Zone{ZoneEmpty, ZoneEmptyLong, ZoneEmpty2}, nil, nil\n}\n\nfunc (c *PDNSAPIClientStubEmptyZones) ListZone(zoneID string) (pgo.Zone, *http.Response, error) {\n\tswitch {\n\tcase strings.Contains(zoneID, \"example.com\"):\n\t\treturn ZoneEmpty, nil, nil\n\tcase strings.Contains(zoneID, \"mock.test\"):\n\t\treturn ZoneEmpty2, nil, nil\n\tcase strings.Contains(zoneID, \"long.domainname.example.com\"):\n\t\treturn ZoneEmptyLong, nil, nil\n\t}\n\treturn pgo.Zone{}, nil, nil\n}\n\nfunc (c *PDNSAPIClientStubEmptyZones) PatchZone(_ string, zoneStruct pgo.Zone) (*http.Response, error) {\n\tc.patchedZones = append(c.patchedZones, zoneStruct)\n\treturn &http.Response{}, nil\n}\n\n/******************************************************************************/\n// API that returns error on PatchZone()\ntype PDNSAPIClientStubPatchZoneFailure struct {\n\t// Anonymous struct for composition\n\tPDNSAPIClientStubEmptyZones\n}\n\n// Just overwrite the PatchZone method to introduce a failure\nfunc (c *PDNSAPIClientStubPatchZoneFailure) PatchZone(_ string, _ pgo.Zone) (*http.Response, error) {\n\treturn nil, provider.NewSoftErrorf(\"Generic PDNS Error\")\n}\n\n/******************************************************************************/\n// API that returns error on ListZone()\ntype PDNSAPIClientStubListZoneFailure struct {\n\t// Anonymous struct for composition\n\tPDNSAPIClientStubEmptyZones\n}\n\n// Just overwrite the ListZone method to introduce a failure\nfunc (c *PDNSAPIClientStubListZoneFailure) ListZone(_ string) (pgo.Zone, *http.Response, error) {\n\treturn pgo.Zone{}, nil, provider.NewSoftErrorf(\"Generic PDNS Error\")\n}\n\n/******************************************************************************/\n// API that returns error on ListZones() (Zones - plural)\ntype PDNSAPIClientStubListZonesFailure struct {\n\t// Anonymous struct for composition\n\tPDNSAPIClientStubEmptyZones\n}\n\n// Just overwrite the ListZones method to introduce a failure\nfunc (c *PDNSAPIClientStubListZonesFailure) ListZones() ([]pgo.Zone, *http.Response, error) {\n\treturn []pgo.Zone{}, nil, provider.NewSoftErrorf(\"Generic PDNS Error\")\n}\n\n/******************************************************************************/\n// API that returns zone partitions given DomainFilter(s)\ntype PDNSAPIClientStubPartitionZones struct {\n\t// Anonymous struct for composition\n\tPDNSAPIClientStubEmptyZones\n}\n\nfunc (c *PDNSAPIClientStubPartitionZones) ListZones() ([]pgo.Zone, *http.Response, error) {\n\treturn []pgo.Zone{ZoneEmpty, ZoneEmpty2, ZoneEmptySimilar}, nil, nil\n}\n\nfunc (c *PDNSAPIClientStubPartitionZones) ListZone(zoneID string) (pgo.Zone, *http.Response, error) {\n\tswitch {\n\tcase strings.Contains(zoneID, \"example.com\"):\n\t\treturn ZoneEmpty, nil, nil\n\tcase strings.Contains(zoneID, \"mock.test\"):\n\t\treturn ZoneEmpty2, nil, nil\n\tcase strings.Contains(zoneID, \"simexample.com\"):\n\t\treturn ZoneEmptySimilar, nil, nil\n\t}\n\treturn pgo.Zone{}, nil, nil\n}\n\n/******************************************************************************/\n// Configurable API stub that performs real domain-filter partitioning.\n// Use it to test the intersection logic between ListZones results and the\n// provider's domain filter.\ntype PDNSAPIClientStubConfigurable struct {\n\tzones   []pgo.Zone\n\tlistErr error\n}\n\nfunc (c *PDNSAPIClientStubConfigurable) ListZones() ([]pgo.Zone, *http.Response, error) {\n\tif c.listErr != nil {\n\t\treturn nil, nil, c.listErr\n\t}\n\treturn c.zones, nil, nil\n}\n\nfunc (c *PDNSAPIClientStubConfigurable) ListZone(_ string) (pgo.Zone, *http.Response, error) {\n\treturn pgo.Zone{}, nil, nil\n}\n\nfunc (c *PDNSAPIClientStubConfigurable) PatchZone(_ string, _ pgo.Zone) (*http.Response, error) {\n\treturn &http.Response{}, nil\n}\n\n/******************************************************************************/\n\ntype NewPDNSProviderTestSuite struct {\n\tsuite.Suite\n}\n\nfunc (suite *NewPDNSProviderTestSuite) TestPDNSProviderCreate() {\n\t_, err := newProvider(\n\t\tcontext.Background(),\n\t\tPDNSConfig{\n\t\t\tServer:       \"http://localhost:8081\",\n\t\t\tDomainFilter: endpoint.NewDomainFilter([]string{\"\"}),\n\t\t})\n\tsuite.Error(err, \"--pdns-api-key should be specified\")\n\n\t_, err = newProvider(\n\t\tcontext.Background(),\n\t\tPDNSConfig{\n\t\t\tServer:       \"http://localhost:8081\",\n\t\t\tAPIKey:       \"foo\",\n\t\t\tDomainFilter: endpoint.NewDomainFilter([]string{\"example.com\", \"example.org\"}),\n\t\t})\n\tsuite.NoError(err, \"--domain-filter should raise no error\")\n\n\t_, err = newProvider(\n\t\tcontext.Background(),\n\t\tPDNSConfig{\n\t\t\tServer:       \"http://localhost:8081\",\n\t\t\tAPIKey:       \"foo\",\n\t\t\tDomainFilter: endpoint.NewDomainFilter([]string{\"\"}),\n\t\t\tDryRun:       true,\n\t\t})\n\tsuite.Error(err, \"--dry-run should raise an error\")\n\n\t// This is our \"regular\" code path, no error should be thrown\n\t_, err = newProvider(\n\t\tcontext.Background(),\n\t\tPDNSConfig{\n\t\t\tServer:       \"http://localhost:8081\",\n\t\t\tAPIKey:       \"foo\",\n\t\t\tDomainFilter: endpoint.NewDomainFilter([]string{\"\"}),\n\t\t})\n\tsuite.NoError(err, \"Regular case should raise no error\")\n}\n\nfunc (suite *NewPDNSProviderTestSuite) TestPDNSProviderCreateTLS() {\n\tnewProvider := func(TLSConfig TLSConfig) error {\n\t\t_, err := newProvider(\n\t\t\tcontext.Background(),\n\t\t\tPDNSConfig{APIKey: \"foo\", TLSConfig: TLSConfig})\n\t\treturn err\n\t}\n\n\tsuite.NoError(newProvider(TLSConfig{SkipTLSVerify: true}), \"Disabled TLS Config should raise no error\")\n\n\tsuite.NoError(newProvider(TLSConfig{\n\t\tSkipTLSVerify:         true,\n\t\tCAFilePath:            \"../../internal/testresources/ca.pem\",\n\t\tClientCertFilePath:    \"../../internal/testresources/client-cert.pem\",\n\t\tClientCertKeyFilePath: \"../../internal/testresources/client-cert-key.pem\",\n\t}), \"Disabled TLS Config with additional flags should raise no error\")\n\n\tsuite.NoError(newProvider(TLSConfig{}), \"Enabled TLS Config without --tls-ca should raise no error\")\n\n\tsuite.NoError(newProvider(TLSConfig{\n\t\tCAFilePath: \"../../internal/testresources/ca.pem\",\n\t}), \"Enabled TLS Config with --tls-ca should raise no error\")\n\n\tsuite.Error(newProvider(TLSConfig{\n\t\tCAFilePath:         \"../../internal/testresources/ca.pem\",\n\t\tClientCertFilePath: \"../../internal/testresources/client-cert.pem\",\n\t}), \"Enabled TLS Config with --tls-client-cert only should raise an error\")\n\n\tsuite.Error(newProvider(TLSConfig{\n\t\tCAFilePath:            \"../../internal/testresources/ca.pem\",\n\t\tClientCertKeyFilePath: \"../../internal/testresources/client-cert-key.pem\",\n\t}), \"Enabled TLS Config with --tls-client-cert-key only should raise an error\")\n\n\tsuite.NoError(newProvider(TLSConfig{\n\t\tCAFilePath:            \"../../internal/testresources/ca.pem\",\n\t\tClientCertFilePath:    \"../../internal/testresources/client-cert.pem\",\n\t\tClientCertKeyFilePath: \"../../internal/testresources/client-cert-key.pem\",\n\t}), \"Enabled TLS Config with all flags should raise no error\")\n}\n\nfunc (suite *NewPDNSProviderTestSuite) TestPDNSHasAliasAnnotation() {\n\tp := &PDNSProvider{}\n\n\t// Test endpoint without alias annotation\n\tepWithoutAlias := endpoint.NewEndpoint(\"test.example.com\", endpoint.RecordTypeCNAME, \"target.example.com\")\n\tsuite.False(p.hasAliasAnnotation(epWithoutAlias))\n\n\t// Test endpoint with alias=false\n\tepWithAliasFalse := endpoint.NewEndpoint(\"test.example.com\", endpoint.RecordTypeCNAME, \"target.example.com\")\n\tepWithAliasFalse.ProviderSpecific = endpoint.ProviderSpecific{\n\t\t{Name: \"alias\", Value: \"false\"},\n\t}\n\tsuite.False(p.hasAliasAnnotation(epWithAliasFalse))\n\n\t// Test endpoint with alias=true\n\tepWithAliasTrue := endpoint.NewEndpoint(\"test.example.com\", endpoint.RecordTypeCNAME, \"target.example.com\")\n\tepWithAliasTrue.ProviderSpecific = endpoint.ProviderSpecific{\n\t\t{Name: \"alias\", Value: \"true\"},\n\t}\n\tsuite.True(p.hasAliasAnnotation(epWithAliasTrue))\n\n\t// Test endpoint with other provider specific but no alias\n\tepWithOtherPS := endpoint.NewEndpoint(\"test.example.com\", endpoint.RecordTypeCNAME, \"target.example.com\")\n\tepWithOtherPS.ProviderSpecific = endpoint.ProviderSpecific{\n\t\t{Name: \"other\", Value: \"value\"},\n\t}\n\tsuite.False(p.hasAliasAnnotation(epWithOtherPS))\n}\n\nfunc (suite *NewPDNSProviderTestSuite) TestPDNSRRSetToEndpoints() {\n\t// Function definition: convertRRSetToEndpoints(rr pgo.RrSet) (endpoints []*endpoint.Endpoint, _ error)\n\n\t// Create a new provider to run tests against\n\tp := &PDNSProvider{\n\t\tclient: &PDNSAPIClientStub{},\n\t}\n\n\t/* given an RRSet with three records, we test:\n\t   - We correctly create corresponding endpoints\n\t*/\n\teps := p.convertRRSetToEndpoints(RRSetMultipleRecords)\n\tsuite.Equal(endpointsMultipleRecords, eps)\n\n\t/* Given an RRSet with two records, one of which is disabled, we test:\n\t   - We can correctly convert the RRSet into a list of valid endpoints\n\t   - We correctly discard/ignore the disabled record.\n\t*/\n\teps = p.convertRRSetToEndpoints(RRSetDisabledRecord)\n\tsuite.Equal(endpointsDisabledRecord, eps)\n}\n\nfunc (suite *NewPDNSProviderTestSuite) TestPDNSRecords() {\n\t// Function definition: Records() (endpoints []*endpoint.Endpoint, _ error)\n\n\t// Create a new provider to run tests against\n\tp := &PDNSProvider{\n\t\tclient: &PDNSAPIClientStub{},\n\t}\n\n\tctx := context.Background()\n\n\t/* We test that endpoints are returned correctly for a Zone when Records() is called\n\t */\n\teps, err := p.Records(ctx)\n\tsuite.Require().NoError(err)\n\tsuite.Equal(endpointsMixedRecords, eps)\n\n\t// Test failures are handled correctly\n\t// Create a new provider to run tests against\n\tp = &PDNSProvider{\n\t\tclient: &PDNSAPIClientStubListZoneFailure{},\n\t}\n\t_, err = p.Records(ctx)\n\tsuite.Error(err)\n\tsuite.ErrorIs(err, provider.SoftError)\n\n\tp = &PDNSProvider{\n\t\tclient: &PDNSAPIClientStubListZonesFailure{},\n\t}\n\t_, err = p.Records(ctx)\n\tsuite.Error(err)\n\tsuite.ErrorIs(err, provider.SoftError)\n}\n\nfunc (suite *NewPDNSProviderTestSuite) TestPDNSConvertEndpointsToZones() {\n\t// Function definition: ConvertEndpointsToZones(endpoints []*endpoint.Endpoint, changetype pdnsChangeType) (zonelist []pgo.Zone, _ error)\n\n\t// Create a new provider to run tests against\n\tp := &PDNSProvider{\n\t\tclient: &PDNSAPIClientStubEmptyZones{},\n\t}\n\n\t// Check inserting endpoints from a single zone\n\tzlist, err := p.ConvertEndpointsToZones(endpointsSimpleRecord, PdnsReplace)\n\tsuite.NoError(err)\n\tsuite.Equal([]pgo.Zone{ZoneEmptyToSimplePatch}, zlist)\n\n\t// Check deleting endpoints from a single zone\n\tzlist, err = p.ConvertEndpointsToZones(endpointsSimpleRecord, PdnsDelete)\n\tsuite.NoError(err)\n\tsuite.Equal([]pgo.Zone{ZoneEmptyToSimpleDelete}, zlist)\n\n\t// Check endpoints from multiple zones #1\n\tzlist, err = p.ConvertEndpointsToZones(endpointsMultipleZones, PdnsReplace)\n\tsuite.NoError(err)\n\tsuite.Equal([]pgo.Zone{ZoneEmptyToSimplePatch, ZoneEmptyToSimplePatch2}, zlist)\n\n\t// Check endpoints from multiple zones #2\n\tzlist, err = p.ConvertEndpointsToZones(endpointsMultipleZones2, PdnsReplace)\n\tsuite.NoError(err)\n\tsuite.Equal([]pgo.Zone{ZoneEmptyToSimplePatch, ZoneEmptyToSimplePatch3}, zlist)\n\n\t// Check endpoints from multiple zones where some endpoints which don't exist\n\tzlist, err = p.ConvertEndpointsToZones(endpointsMultipleZonesWithNoExist, PdnsReplace)\n\tsuite.NoError(err)\n\tsuite.Equal([]pgo.Zone{ZoneEmptyToSimplePatch}, zlist)\n\n\t// Check endpoints from a zone that does not exist\n\tzlist, err = p.ConvertEndpointsToZones(endpointsNonexistantZone, PdnsReplace)\n\tsuite.NoError(err)\n\tsuite.Equal([]pgo.Zone{}, zlist)\n\n\t// Check endpoints that match multiple zones (one longer than other), is assigned to the right zone\n\tzlist, err = p.ConvertEndpointsToZones(endpointsLongRecord, PdnsReplace)\n\tsuite.NoError(err)\n\tsuite.Equal([]pgo.Zone{ZoneEmptyToLongPatch}, zlist)\n\n\t// Check endpoints of type CNAME, ALIAS, MX, SRV, and NS always have their values end with a trailing dot.\n\tzlist, err = p.ConvertEndpointsToZones(endpointsMixedRecords, PdnsReplace)\n\tsuite.NoError(err)\n\n\ttrailingTypes := map[string]bool{\n\t\tendpoint.RecordTypeCNAME: true,\n\t\t\"ALIAS\":                  true,\n\t\tendpoint.RecordTypeMX:    true,\n\t\tendpoint.RecordTypeSRV:   true,\n\t\tendpoint.RecordTypeNS:    true,\n\t\tendpoint.RecordTypePTR:   true,\n\t}\n\n\tfor _, z := range zlist {\n\t\tfor _, rs := range z.Rrsets {\n\t\t\tif trailingTypes[rs.Type_] {\n\t\t\t\tfor _, r := range rs.Records {\n\t\t\t\t\tsuite.Equal(uint8(0x2e), r.Content[len(r.Content)-1])\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// Check endpoints of type CNAME are converted to ALIAS on the domain apex\n\tzlist, err = p.ConvertEndpointsToZones(endpointsApexRecords, PdnsReplace)\n\tsuite.NoError(err)\n\tsuite.Equal([]pgo.Zone{ZoneEmptyToApexPatch}, zlist)\n\n\t// Check endpoints of type CNAME remain CNAME when no alias annotation is set\n\tzlist, err = p.ConvertEndpointsToZones(endpointsPreferAlias, PdnsReplace)\n\tsuite.NoError(err)\n\tsuite.Equal([]pgo.Zone{ZoneEmptyToCNAMEPatch}, zlist)\n\n\t// Check endpoints with alias annotation are converted to ALIAS\n\t// Note: The --prefer-alias flag now works via PostProcessor wrapper which sets the alias annotation\n\tzlist, err = p.ConvertEndpointsToZones([]*endpoint.Endpoint{endpointWithAliasAnnotation}, PdnsReplace)\n\tsuite.NoError(err)\n\tsuite.Equal([]pgo.Zone{ZoneEmptyToPreferAliasPatch}, zlist)\n}\n\nfunc (suite *NewPDNSProviderTestSuite) TestPDNSConvertEndpointsToZonesPartitionZones() {\n\t// Test DomainFilters\n\tp := &PDNSProvider{\n\t\tclient:       &PDNSAPIClientStubPartitionZones{},\n\t\tdomainFilter: endpoint.NewDomainFilter([]string{\"example.com\"}),\n\t}\n\n\t// Check inserting endpoints from a single zone which is specified in DomainFilter\n\tzlist, err := p.ConvertEndpointsToZones(endpointsSimpleRecord, PdnsReplace)\n\tsuite.Require().NoError(err)\n\tsuite.Equal([]pgo.Zone{ZoneEmptyToSimplePatch}, zlist)\n\n\t// Check deleting endpoints from a single zone which is specified in DomainFilter\n\tzlist, err = p.ConvertEndpointsToZones(endpointsSimpleRecord, PdnsDelete)\n\tsuite.Require().NoError(err)\n\tsuite.Equal([]pgo.Zone{ZoneEmptyToSimpleDelete}, zlist)\n\n\t// Check endpoints from multiple zones # which one is specified in DomainFilter and one is not\n\tzlist, err = p.ConvertEndpointsToZones(endpointsMultipleZones, PdnsReplace)\n\tsuite.NoError(err)\n\tsuite.Equal([]pgo.Zone{ZoneEmptyToSimplePatch}, zlist)\n\n\t// Check endpoints from multiple zones where some endpoints which don't exist and one that does\n\t// and is part of DomainFilter\n\tzlist, err = p.ConvertEndpointsToZones(endpointsMultipleZonesWithNoExist, PdnsReplace)\n\tsuite.NoError(err)\n\tsuite.Equal([]pgo.Zone{ZoneEmptyToSimplePatch}, zlist)\n\n\t// Check endpoints from a zone that does not exist\n\tzlist, err = p.ConvertEndpointsToZones(endpointsNonexistantZone, PdnsReplace)\n\tsuite.NoError(err)\n\tsuite.Equal([]pgo.Zone{}, zlist)\n\n\t// Check endpoints that match multiple zones (one longer than other), is assigned to the right zone when the longer\n\t// zone is not part of the DomainFilter\n\tzlist, err = p.ConvertEndpointsToZones(endpointsMultipleZonesWithLongRecordNotInDomainFilter, PdnsReplace)\n\tsuite.NoError(err)\n\tsuite.Equal([]pgo.Zone{ZoneEmptyToSimplePatchLongRecordIgnoredInDomainFilter}, zlist)\n\n\t// Check endpoints that match multiple zones (one longer than other and one is very similar)\n\t// is assigned to the right zone when the similar zone is not part of the DomainFilter\n\tzlist, err = p.ConvertEndpointsToZones(endpointsMultipleZonesWithSimilarRecordNotInDomainFilter, PdnsReplace)\n\tsuite.NoError(err)\n\tsuite.Equal([]pgo.Zone{ZoneEmptyToSimplePatch}, zlist)\n}\n\nfunc (suite *NewPDNSProviderTestSuite) TestPDNSmutateRecords() {\n\t// Function definition: mutateRecords(endpoints []*endpoint.Endpoint, changetype pdnsChangeType) error\n\n\t// Create a new provider to run tests against\n\tc := &PDNSAPIClientStubEmptyZones{}\n\tp := &PDNSProvider{\n\t\tclient: c,\n\t}\n\n\t// Check inserting endpoints from a single zone\n\terr := p.mutateRecords(endpointsSimpleRecord, pdnsChangeType(\"REPLACE\"))\n\tsuite.NoError(err)\n\tsuite.Equal([]pgo.Zone{ZoneEmptyToSimplePatch}, c.patchedZones)\n\n\t// Reset the \"patchedZones\"\n\tc.patchedZones = []pgo.Zone{}\n\n\t// Check deleting endpoints from a single zone\n\terr = p.mutateRecords(endpointsSimpleRecord, pdnsChangeType(\"DELETE\"))\n\tsuite.NoError(err)\n\tsuite.Equal([]pgo.Zone{ZoneEmptyToSimpleDelete}, c.patchedZones)\n\n\t// Check we fail correctly when patching fails for whatever reason\n\tp = &PDNSProvider{\n\t\tclient: &PDNSAPIClientStubPatchZoneFailure{},\n\t}\n\t// Check inserting endpoints from a single zone\n\terr = p.mutateRecords(endpointsSimpleRecord, pdnsChangeType(\"REPLACE\"))\n\tsuite.Error(err)\n\tsuite.ErrorIs(err, provider.SoftError)\n}\n\nfunc (suite *NewPDNSProviderTestSuite) TestPDNSClientPartitionZones() {\n\tzoneList := []pgo.Zone{\n\t\tZoneEmpty,\n\t\tZoneEmpty2,\n\t}\n\n\tpartitionResultFilteredEmptyFilter := []pgo.Zone{\n\t\tZoneEmpty,\n\t\tZoneEmpty2,\n\t}\n\n\tpartitionResultResidualEmptyFilter := ([]pgo.Zone)(nil)\n\n\tpartitionResultFilteredSingleFilter := []pgo.Zone{\n\t\tZoneEmpty,\n\t}\n\n\tpartitionResultResidualSingleFilter := []pgo.Zone{\n\t\tZoneEmpty2,\n\t}\n\n\tpartitionResultFilteredMultipleFilter := []pgo.Zone{\n\t\tZoneEmpty,\n\t}\n\n\tpartitionResultResidualMultipleFilter := []pgo.Zone{\n\t\tZoneEmpty2,\n\t}\n\n\t// Check filtered, residual zones when no domain filter specified\n\tfilteredZones, residualZones := partitionZones(zoneList, DomainFilterListEmpty)\n\tsuite.Equal(partitionResultFilteredEmptyFilter, filteredZones)\n\tsuite.Equal(partitionResultResidualEmptyFilter, residualZones)\n\n\t// Check filtered, residual zones when a single domain filter specified\n\tfilteredZones, residualZones = partitionZones(zoneList, DomainFilterListSingle)\n\tsuite.Equal(partitionResultFilteredSingleFilter, filteredZones)\n\tsuite.Equal(partitionResultResidualSingleFilter, residualZones)\n\n\t// Check filtered, residual zones when a multiple domain filter specified\n\tfilteredZones, residualZones = partitionZones(zoneList, DomainFilterListMultiple)\n\tsuite.Equal(partitionResultFilteredMultipleFilter, filteredZones)\n\tsuite.Equal(partitionResultResidualMultipleFilter, residualZones)\n\n\tfilteredZones, residualZones = partitionZones(zoneList, RegexDomainFilter)\n\tsuite.Equal(partitionResultFilteredSingleFilter, filteredZones)\n\tsuite.Equal(partitionResultResidualSingleFilter, residualZones)\n}\n\n// Validate whether invalid endpoints are removed by AdjustEndpoints\nfunc (suite *NewPDNSProviderTestSuite) TestPDNSAdjustEndpoints() {\n\t// Function definition: AdjustEndpoints(endpoints []*endpoint.Endpoint) []*endpoint.Endpoint\n\n\t// Create a new provider to run tests against\n\tp := &PDNSProvider{}\n\n\ttests := []struct {\n\t\tdescription string\n\t\tendpoints   []*endpoint.Endpoint\n\t\texpected    []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\tdescription: \"Valid MX endpoint is not removed\",\n\t\t\tendpoints:   endpointsMXRecord,\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpointWithTTL(\"mail.example.com\", endpoint.RecordTypeMX, endpoint.TTL(300), \"10 example.com\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tdescription: \"Invalid MX endpoint with too many arguments is removed\",\n\t\t\tendpoints:   endpointsMXRecordInvalidFormatTooManyArgs,\n\t\t\texpected:    []*endpoint.Endpoint([]*endpoint.Endpoint(nil)),\n\t\t},\n\t\t{\n\t\t\tdescription: \"Invalid MX endpoint is removed among valid endpoints\",\n\t\t\tendpoints:   endpointsMultipleMXRecordsWithSingleInvalid,\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpointWithTTL(\"mail.example.com\", endpoint.RecordTypeMX, endpoint.TTL(300), \"20 backup.example.com\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tdescription: \"Multiple invalid MX endpoints are removed\",\n\t\t\tendpoints:   endpointsMultipleInvalidMXRecords,\n\t\t\texpected:    []*endpoint.Endpoint([]*endpoint.Endpoint(nil)),\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tactual, err := p.AdjustEndpoints(tt.endpoints)\n\t\tsuite.NoError(err)\n\t\tsuite.Equal(tt.expected, actual)\n\t}\n}\n\nfunc (suite *NewPDNSProviderTestSuite) TestPDNSGetDomainFilter() {\n\tallZones := []pgo.Zone{ZoneEmpty, ZoneEmptyLong, ZoneEmpty2} // example.com., long.domainname.example.com., mock.test.\n\n\ttests := []struct {\n\t\tname         string\n\t\tclient       PDNSAPIProvider\n\t\tdomainFilter *endpoint.DomainFilter\n\t\t// domains we expect the returned filter to match\n\t\tshouldMatch []string\n\t\t// domains we expect the returned filter NOT to match\n\t\tshouldNotMatch []string\n\t}{\n\t\t{\n\t\t\tname: \"no domain filter — all zones from API are in scope\",\n\t\t\tclient: &PDNSAPIClientStubConfigurable{\n\t\t\t\tzones: allZones,\n\t\t\t},\n\t\t\tdomainFilter:   nil,\n\t\t\tshouldMatch:    []string{\"example.com\", \"long.domainname.example.com\", \"mock.test\", \"sub.example.com\", \"sub.mock.test\"},\n\t\t\tshouldNotMatch: []string{\"other.com\"},\n\t\t},\n\t\t{\n\t\t\tname: \"domain filter set — all API zones still returned (controller handles intersection with --domain-filter)\",\n\t\t\tclient: &PDNSAPIClientStubConfigurable{\n\t\t\t\tzones: allZones,\n\t\t\t},\n\t\t\tdomainFilter: endpoint.NewDomainFilter([]string{\"example.com\"}),\n\t\t\t// GetDomainFilter returns all API zones, not the filtered subset;\n\t\t\t// the controller intersects with --domain-filter on its own\n\t\t\tshouldMatch:    []string{\"example.com\", \"long.domainname.example.com\", \"mock.test\", \"sub.example.com\", \"sub.mock.test\"},\n\t\t\tshouldNotMatch: []string{\"other.com\"},\n\t\t},\n\t\t{\n\t\t\tname: \"domain filter excludes all API zones — all zones still returned (no silent fail-open)\",\n\t\t\tclient: &PDNSAPIClientStubConfigurable{\n\t\t\t\tzones: allZones,\n\t\t\t},\n\t\t\tdomainFilter: endpoint.NewDomainFilter([]string{\"notexist.org\"}),\n\t\t\t// All provider-managed zones are returned; when the controller\n\t\t\t// intersects with --domain-filter=notexist.org, nothing matches\n\t\t\t// and the plan is safely empty\n\t\t\tshouldMatch:    []string{\"example.com\", \"mock.test\", \"long.domainname.example.com\"},\n\t\t\tshouldNotMatch: []string{\"notexist.org\", \"other.com\"},\n\t\t},\n\t\t{\n\t\t\tname: \"ListZones error — returns empty filter (fail-open)\",\n\t\t\tclient: &PDNSAPIClientStubConfigurable{\n\t\t\t\tlistErr: provider.NewSoftErrorf(\"API unreachable\"),\n\t\t\t},\n\t\t\tdomainFilter: nil,\n\t\t\t// empty DomainFilter matches everything\n\t\t\tshouldMatch:    []string{\"anything.com\", \"example.com\"},\n\t\t\tshouldNotMatch: []string{},\n\t\t},\n\t\t{\n\t\t\tname: \"API returns single zone — that zone is returned regardless of domain filter\",\n\t\t\tclient: &PDNSAPIClientStubConfigurable{\n\t\t\t\tzones: []pgo.Zone{ZoneEmpty}, // only example.com.\n\t\t\t},\n\t\t\tdomainFilter:   endpoint.NewDomainFilter([]string{\"example.com\"}),\n\t\t\tshouldMatch:    []string{\"example.com\", \"sub.example.com\"},\n\t\t\tshouldNotMatch: []string{\"mock.test\", \"other.com\"},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tsuite.Run(tt.name, func() {\n\t\t\tp := &PDNSProvider{\n\t\t\t\tclient:       tt.client,\n\t\t\t\tdomainFilter: tt.domainFilter,\n\t\t\t}\n\t\t\tdf := p.GetDomainFilter()\n\t\t\tfor _, domain := range tt.shouldMatch {\n\t\t\t\tsuite.True(df.Match(domain), \"expected filter to match %q\", domain)\n\t\t\t}\n\t\t\tfor _, domain := range tt.shouldNotMatch {\n\t\t\t\tsuite.False(df.Match(domain), \"expected filter NOT to match %q\", domain)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestNewPDNSProviderTestSuite(t *testing.T) {\n\tsuite.Run(t, new(NewPDNSProviderTestSuite))\n}\n\n// TestPDNSPartitionZonesRegexBehavior compares two regex forms for --domain-filter\n// and shows how the choice of regex affects zone partitioning correctness.\nfunc TestPDNSPartitionZonesRegexBehavior(t *testing.T) {\n\tnewZone := func(name string) pgo.Zone {\n\t\treturn pgo.Zone{Id: name, Name: name, Type_: \"Zone\", Kind: \"Native\", Rrsets: []pgo.RrSet{}}\n\t}\n\n\tzoneNames := func(zz []pgo.Zone) []string {\n\t\tnames := make([]string, len(zz))\n\t\tfor i, z := range zz {\n\t\t\tnames[i] = z.Name\n\t\t}\n\t\treturn names\n\t}\n\n\ttests := []struct {\n\t\tname         string\n\t\tzones        []pgo.Zone\n\t\tregex        string\n\t\tregexExclude string\n\t\tassertions   func(t *testing.T, filtered []pgo.Zone, residual []pgo.Zone)\n\t}{\n\t\t{\n\t\t\t// Worst case: no subdomain zone exists at all.\n\t\t\t// Both apex zones fail the regex → filtered is empty →\n\t\t\t// ConvertEndpointsToZones logs \"Ignoring Endpoint\" for every record.\n\t\t\t//\n\t\t\t//   \"example.com\" → no label prefix  → residual  ← BUG\n\t\t\t//   \"other.com\"   → no match at all  → residual  ← BUG\n\t\t\tname: \"complete wipeout: subdomain-only regex with only apex zones leaves filtered empty\",\n\t\t\tzones: []pgo.Zone{\n\t\t\t\tnewZone(\"example.com.\"),\n\t\t\t\tnewZone(\"other.com.\"),\n\t\t\t},\n\t\t\tregex: `^[\\w-]+\\.example\\.com$`,\n\t\t\tassertions: func(t *testing.T, filtered []pgo.Zone, residual []pgo.Zone) {\n\t\t\t\tassert.Empty(t, filtered,\n\t\t\t\t\t\"no zone matches the subdomain-only regex — every record will be ignored\")\n\t\t\t\tassert.ElementsMatch(t, []string{\"example.com.\", \"other.com.\"}, zoneNames(residual),\n\t\t\t\t\t\"both zones land in residual: records in example.com. silently dropped\")\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t// Partial match: a sub-zone happens to exist, so sub.example.com. is\n\t\t\t// managed but the apex example.com. and the deep zone are still lost.\n\t\t\t//\n\t\t\t//   \"example.com\"                 → no label at all        → residual  ← BUG\n\t\t\t//   \"sub.example.com\"             → one label \"sub\"        → filtered\n\t\t\t//   \"long.domainname.example.com\" → [\\w-]+ can't span dots → residual  ← BUG\n\t\t\t//   \"simexample.com\"              → no .example.com suffix  → residual  ✓\n\t\t\t//   \"mock.test\"                   → no match                → residual  ✓\n\t\t\tname: \"partial match: subdomain-only regex misses apex and multi-label zones\",\n\t\t\tzones: []pgo.Zone{\n\t\t\t\tnewZone(\"example.com.\"),\n\t\t\t\tnewZone(\"sub.example.com.\"),\n\t\t\t\tnewZone(\"long.domainname.example.com.\"),\n\t\t\t\tnewZone(\"simexample.com.\"),\n\t\t\t\tnewZone(\"mock.test.\"),\n\t\t\t},\n\t\t\tregex: `^[\\w-]+\\.example\\.com$`,\n\t\t\tassertions: func(t *testing.T, filtered []pgo.Zone, residual []pgo.Zone) {\n\t\t\t\tassert.Equal(t, []string{\"sub.example.com.\"}, zoneNames(filtered),\n\t\t\t\t\t\"only the single-label subdomain zone matches\")\n\t\t\t\tassert.Contains(t, zoneNames(residual), \"example.com.\",\n\t\t\t\t\t\"zone apex lands in residual: its records would be ignored\")\n\t\t\t\tassert.Contains(t, zoneNames(residual), \"long.domainname.example.com.\",\n\t\t\t\t\t\"multi-label zone lands in residual: [\\\\w-]+ cannot span dots\")\n\t\t\t\tassert.Contains(t, zoneNames(residual), \"simexample.com.\")\n\t\t\t\tassert.Contains(t, zoneNames(residual), \"mock.test.\")\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t// Exclusion regex takes priority: zones matching regexExclusion are\n\t\t\t// rejected before the inclusion regex is checked.\n\t\t\t//\n\t\t\t//   \"example.com\"         → inclusion matches, exclusion does not → filtered  ✓\n\t\t\t//   \"staging.example.com\" → inclusion matches, exclusion matches  → residual  ✓\n\t\t\t//   \"prod.example.com\"    → inclusion matches, exclusion does not → filtered  ✓\n\t\t\t//   \"mock.test\"           → inclusion does not match              → residual  ✓\n\t\t\tname:         \"exclusion regex overrides inclusion: staging zones are excluded\",\n\t\t\tregexExclude: `^staging\\.`,\n\t\t\tzones: []pgo.Zone{\n\t\t\t\tnewZone(\"example.com.\"),\n\t\t\t\tnewZone(\"staging.example.com.\"),\n\t\t\t\tnewZone(\"prod.example.com.\"),\n\t\t\t\tnewZone(\"mock.test.\"),\n\t\t\t},\n\t\t\tregex: `^([\\w-]+\\.)*example\\.com$`,\n\t\t\tassertions: func(t *testing.T, filtered []pgo.Zone, residual []pgo.Zone) {\n\t\t\t\tassert.ElementsMatch(t, []string{\"example.com.\", \"prod.example.com.\"}, zoneNames(filtered),\n\t\t\t\t\t\"only non-excluded example.com zones must be filtered\")\n\t\t\t\tassert.ElementsMatch(t, []string{\"staging.example.com.\", \"mock.test.\"}, zoneNames(residual),\n\t\t\t\t\t\"staging zone is excluded by regexExclusion; mock.test does not match inclusion\")\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t// ([\\w-]+\\.)* with zero repetitions matches the apex; one or more\n\t\t\t// repetitions match subdomain zones at any depth.\n\t\t\t// Suffix similarity (simexample.com) is rejected by the dot-boundary.\n\t\t\t//\n\t\t\t//   \"example.com\"                 → 0 repetitions          → filtered  ✓\n\t\t\t//   \"sub.example.com\"             → 1 repetition \"sub.\"    → filtered  ✓\n\t\t\t//   \"long.domainname.example.com\" → 2 repetitions          → filtered  ✓\n\t\t\t//   \"simexample.com\"              → no dot-boundary match   → residual  ✓\n\t\t\t//   \"mock.test\"                   → no match                → residual  ✓\n\t\t\tname: \"zone-aware regex (* quantifier) matches apex and all subdomain depths\",\n\t\t\tzones: []pgo.Zone{\n\t\t\t\tnewZone(\"example.com.\"),\n\t\t\t\tnewZone(\"sub.example.com.\"),\n\t\t\t\tnewZone(\"long.domainname.example.com.\"),\n\t\t\t\tnewZone(\"simexample.com.\"),\n\t\t\t\tnewZone(\"mock.test.\"),\n\t\t\t},\n\t\t\tregex: `^([\\w-]+\\.)*example\\.com$`,\n\t\t\tassertions: func(t *testing.T, filtered []pgo.Zone, residual []pgo.Zone) {\n\t\t\t\tassert.ElementsMatch(t,\n\t\t\t\t\t[]string{\"example.com.\", \"sub.example.com.\", \"long.domainname.example.com.\"},\n\t\t\t\t\tzoneNames(filtered),\n\t\t\t\t\t\"apex and all subdomain zones must be filtered\")\n\t\t\t\tassert.ElementsMatch(t, []string{\"simexample.com.\", \"mock.test.\"}, zoneNames(residual),\n\t\t\t\t\t\"only truly unrelated zones must be residual\")\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tvar exclusion *regexp.Regexp\n\t\t\tif tt.regexExclude != \"\" {\n\t\t\t\texclusion = regexp.MustCompile(tt.regexExclude)\n\t\t\t}\n\t\t\tdf := endpoint.NewRegexDomainFilter(regexp.MustCompile(tt.regex), exclusion)\n\t\t\tfiltered, residual := partitionZones(tt.zones, df)\n\t\t\ttt.assertions(t, filtered, residual)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "provider/pihole/client.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage pihole\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/cookiejar\"\n\t\"net/url\"\n\t\"strings\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"golang.org/x/net/html\"\n\n\textdnshttp \"sigs.k8s.io/external-dns/pkg/http\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\n// piholeAPI declares the \"API\" actions performed against the Pihole server.\ntype piholeAPI interface {\n\t// listRecords returns endpoints for the given record type (A or CNAME).\n\tlistRecords(ctx context.Context, rtype string) ([]*endpoint.Endpoint, error)\n\t// createRecord will create a new record for the given endpoint.\n\tcreateRecord(ctx context.Context, ep *endpoint.Endpoint) error\n\t// deleteRecord will delete the given record.\n\tdeleteRecord(ctx context.Context, ep *endpoint.Endpoint) error\n}\n\n// piholeClient implements the piholeAPI.\ntype piholeClient struct {\n\tcfg        PiholeConfig\n\thttpClient *http.Client\n\ttoken      string\n}\n\n// newPiholeClient creates a new Pihole API client.\nfunc newPiholeClient(cfg PiholeConfig) (piholeAPI, error) {\n\tif cfg.Server == \"\" {\n\t\treturn nil, ErrNoPiholeServer\n\t}\n\n\t// Setup a persistent cookiejar for storing PHP session information\n\t// This call will never return an error\n\tjar, _ := cookiejar.New(&cookiejar.Options{})\n\t// Setup an HTTP client using the cookiejar\n\thttpClient := &http.Client{\n\t\tJar: jar,\n\t\tTransport: &http.Transport{\n\t\t\tTLSClientConfig: &tls.Config{\n\t\t\t\tInsecureSkipVerify: cfg.TLSInsecureSkipVerify,\n\t\t\t},\n\t\t},\n\t}\n\n\tcl := extdnshttp.NewInstrumentedClient(httpClient)\n\n\tp := &piholeClient{\n\t\tcfg:        cfg,\n\t\thttpClient: cl,\n\t}\n\n\tif cfg.Password != \"\" {\n\t\tif err := p.retrieveNewToken(context.Background()); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn p, nil\n}\n\nfunc (p *piholeClient) listRecords(ctx context.Context, rtype string) ([]*endpoint.Endpoint, error) {\n\tform := &url.Values{}\n\tform.Add(\"action\", \"get\")\n\tif p.token != \"\" {\n\t\tform.Add(\"token\", p.token)\n\t}\n\n\turl, err := p.urlForRecordType(rtype)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tlog.Debugf(\"Listing %s records from %s\", rtype, url)\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(form.Encode()))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treq.Header.Add(\"content-type\", \"application/x-www-form-urlencoded\")\n\n\tbody, err := p.do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer body.Close()\n\traw, err := io.ReadAll(body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Response is a map of \"data\" to a list of lists where the first element in each\n\t// list is the dns name and the second is the target.\n\t// Pi-Hole does not allow for a record to have multiple targets.\n\tvar res map[string][][]string\n\tif err := json.Unmarshal(raw, &res); err != nil {\n\t\t// Unfortunately this could also just mean we needed to authenticate (still returns a 200).\n\t\t// Thankfully the body is a short and concise error.\n\t\terr = errors.New(string(raw))\n\t\tif strings.Contains(err.Error(), \"expired\") && p.cfg.Password != \"\" {\n\t\t\t// Try to fetch a new token and redo the request.\n\t\t\t// Full error message at time of writing:\n\t\t\t// \"Not allowed (login session invalid or expired, please relogin on the Pi-hole dashboard)!\"\n\t\t\tlog.Info(\"Pihole token has expired, fetching a new one\")\n\t\t\tif err := p.retrieveNewToken(ctx); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\treturn p.listRecords(ctx, rtype)\n\t\t}\n\t\t// Return raw body as error.\n\t\treturn nil, err\n\t}\n\n\tout := make([]*endpoint.Endpoint, 0)\n\tdata, ok := res[\"data\"]\n\tif !ok {\n\t\treturn out, nil\n\t}\nloop:\n\tfor _, rec := range data {\n\t\tname := rec[0]\n\t\ttarget := rec[1]\n\t\tif !p.cfg.DomainFilter.Match(name) {\n\t\t\tlog.Debugf(\"Skipping %s that does not match domain filter\", name)\n\t\t\tcontinue\n\t\t}\n\t\tswitch rtype {\n\t\tcase endpoint.RecordTypeA:\n\t\t\tif strings.Contains(target, \":\") {\n\t\t\t\tcontinue loop\n\t\t\t}\n\t\tcase endpoint.RecordTypeAAAA:\n\t\t\tif strings.Contains(target, \".\") {\n\t\t\t\tcontinue loop\n\t\t\t}\n\t\t}\n\t\tout = append(out, &endpoint.Endpoint{\n\t\t\tDNSName:    name,\n\t\t\tTargets:    []string{target},\n\t\t\tRecordType: rtype,\n\t\t})\n\t}\n\n\treturn out, nil\n}\n\nfunc (p *piholeClient) createRecord(ctx context.Context, ep *endpoint.Endpoint) error {\n\treturn p.apply(ctx, \"add\", ep)\n}\n\nfunc (p *piholeClient) deleteRecord(ctx context.Context, ep *endpoint.Endpoint) error {\n\treturn p.apply(ctx, \"delete\", ep)\n}\n\nfunc (p *piholeClient) aRecordsScript() string {\n\treturn fmt.Sprintf(\"%s/admin/scripts/pi-hole/php/customdns.php\", p.cfg.Server)\n}\n\nfunc (p *piholeClient) cnameRecordsScript() string {\n\treturn fmt.Sprintf(\"%s/admin/scripts/pi-hole/php/customcname.php\", p.cfg.Server)\n}\n\nfunc (p *piholeClient) urlForRecordType(rtype string) (string, error) {\n\tswitch rtype {\n\tcase endpoint.RecordTypeA, endpoint.RecordTypeAAAA:\n\t\treturn p.aRecordsScript(), nil\n\tcase endpoint.RecordTypeCNAME:\n\t\treturn p.cnameRecordsScript(), nil\n\tdefault:\n\t\treturn \"\", fmt.Errorf(\"unsupported record type: %s\", rtype)\n\t}\n}\n\ntype actionResponse struct {\n\tSuccess bool   `json:\"success\"`\n\tMessage string `json:\"message\"`\n}\n\nfunc (p *piholeClient) apply(ctx context.Context, action string, ep *endpoint.Endpoint) error {\n\tif !p.cfg.DomainFilter.Match(ep.DNSName) {\n\t\tlog.Debugf(\"Skipping %s %s that does not match domain filter\", action, ep.DNSName)\n\t\treturn nil\n\t}\n\turl, err := p.urlForRecordType(ep.RecordType)\n\tif err != nil {\n\t\tlog.Warnf(\"Skipping unsupported endpoint %s %s %v\", ep.DNSName, ep.RecordType, ep.Targets)\n\t\treturn nil\n\t}\n\n\tif p.cfg.DryRun {\n\t\tlog.Infof(\"DRY RUN: %s %s IN %s -> %s\", action, ep.DNSName, ep.RecordType, ep.Targets[0])\n\t\treturn nil\n\t}\n\n\tlog.Infof(\"%s %s IN %s -> %s\", action, ep.DNSName, ep.RecordType, ep.Targets[0])\n\n\tform := p.newDNSActionForm(action, ep)\n\tif strings.Contains(ep.DNSName, \"*\") {\n\t\treturn provider.NewSoftError(errors.New(\"UNSUPPORTED: Pihole DNS names cannot return wildcard\"))\n\t}\n\treq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(form.Encode()))\n\tif err != nil {\n\t\treturn err\n\t}\n\treq.Header.Add(\"content-type\", \"application/x-www-form-urlencoded\")\n\n\tbody, err := p.do(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer body.Close()\n\n\traw, err := io.ReadAll(body)\n\tif err != nil {\n\t\treturn nil\n\t}\n\n\tvar res actionResponse\n\tif err := json.Unmarshal(raw, &res); err != nil {\n\t\t// Unfortunately this could also be a generic server or auth error.\n\t\terr = errors.New(string(raw))\n\t\tif strings.Contains(err.Error(), \"expired\") && p.cfg.Password != \"\" {\n\t\t\t// Try to fetch a new token and redo the request.\n\t\t\tlog.Info(\"Pihole token has expired, fetching a new one\")\n\t\t\tif err := p.retrieveNewToken(ctx); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\treturn p.apply(ctx, action, ep)\n\t\t}\n\t\t// Return raw body as error.\n\t\treturn err\n\t}\n\n\tif !res.Success {\n\t\treturn errors.New(res.Message)\n\t}\n\n\treturn nil\n}\n\nfunc (p *piholeClient) retrieveNewToken(ctx context.Context) error {\n\tif p.cfg.Password == \"\" {\n\t\treturn nil\n\t}\n\n\tform := &url.Values{}\n\tform.Add(\"pw\", p.cfg.Password)\n\turl := fmt.Sprintf(\"%s/admin/index.php?login\", p.cfg.Server)\n\tlog.Debugf(\"Fetching new token from %s\", url)\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(form.Encode()))\n\tif err != nil {\n\t\treturn err\n\t}\n\treq.Header.Add(\"content-type\", \"application/x-www-form-urlencoded\")\n\n\tbody, err := p.do(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer body.Close()\n\n\t// If successful the request will redirect us to an HTML page with a hidden\n\t// div containing the token...The token gives us access to other PHP\n\t// endpoints via a form value.\n\tp.token, err = parseTokenFromLogin(body)\n\treturn err\n}\n\nfunc (p *piholeClient) newDNSActionForm(action string, ep *endpoint.Endpoint) *url.Values {\n\tform := &url.Values{}\n\tform.Add(\"action\", action)\n\tform.Add(\"domain\", ep.DNSName)\n\tswitch ep.RecordType {\n\tcase endpoint.RecordTypeA, endpoint.RecordTypeAAAA:\n\t\tform.Add(\"ip\", ep.Targets[0])\n\tcase endpoint.RecordTypeCNAME:\n\t\tform.Add(\"target\", ep.Targets[0])\n\t}\n\tif p.token != \"\" {\n\t\tform.Add(\"token\", p.token)\n\t}\n\treturn form\n}\n\nfunc (p *piholeClient) do(req *http.Request) (io.ReadCloser, error) {\n\tres, err := p.httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif res.StatusCode != http.StatusOK {\n\t\tdefer res.Body.Close()\n\t\treturn nil, fmt.Errorf(\"received non-200 status code from request: %s\", res.Status)\n\t}\n\treturn res.Body, nil\n}\n\nfunc parseTokenFromLogin(body io.ReadCloser) (string, error) {\n\tdoc, err := html.Parse(body)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\ttokenNode := getElementById(doc, \"token\")\n\tif tokenNode == nil {\n\t\treturn \"\", errors.New(\"could not parse token from login response\")\n\t}\n\n\treturn tokenNode.FirstChild.Data, nil\n}\n\nfunc getAttribute(n *html.Node, key string) (string, bool) {\n\tfor _, attr := range n.Attr {\n\t\tif attr.Key == key {\n\t\t\treturn attr.Val, true\n\t\t}\n\t}\n\treturn \"\", false\n}\n\nfunc hasID(n *html.Node, id string) bool {\n\tif n.Type == html.ElementNode {\n\t\ts, ok := getAttribute(n, \"id\")\n\t\tif ok && s == id {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc traverse(n *html.Node, id string) *html.Node {\n\tif hasID(n, id) {\n\t\treturn n\n\t}\n\n\tfor c := n.FirstChild; c != nil; c = c.NextSibling {\n\t\tresult := traverse(c, id)\n\t\tif result != nil {\n\t\t\treturn result\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc getElementById(n *html.Node, id string) *html.Node {\n\treturn traverse(n, id)\n}\n"
  },
  {
    "path": "provider/pihole/clientV6.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage pihole\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/tls\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"strings\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\n\textdnshttp \"sigs.k8s.io/external-dns/pkg/http\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\nconst (\n\tcontentTypeJSON = \"application/json\"\n\tapiAuthPath     = \"/api/auth\"\n\tapiConfigDNS    = \"/api/config/dns\"\n)\n\n// piholeClient implements the piholeAPI.\ntype piholeClientV6 struct {\n\tcfg        PiholeConfig\n\thttpClient *http.Client\n\ttoken      string\n}\n\n// newPiholeClient creates a new Pihole API V6 client.\nfunc newPiholeClientV6(cfg PiholeConfig) (piholeAPI, error) {\n\tif cfg.Server == \"\" {\n\t\treturn nil, ErrNoPiholeServer\n\t}\n\n\t// Setup an HTTP client\n\thttpClient := &http.Client{\n\t\tTransport: &http.Transport{\n\t\t\tTLSClientConfig: &tls.Config{\n\t\t\t\tInsecureSkipVerify: cfg.TLSInsecureSkipVerify,\n\t\t\t},\n\t\t},\n\t}\n\n\tcl := extdnshttp.NewInstrumentedClient(httpClient)\n\n\tp := &piholeClientV6{\n\t\tcfg:        cfg,\n\t\thttpClient: cl,\n\t}\n\n\tif cfg.Password != \"\" {\n\t\tif err := p.retrieveNewToken(context.Background()); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn p, nil\n}\n\nfunc (p *piholeClientV6) getConfigValue(ctx context.Context, rtype string) ([]string, error) {\n\tapiUrl, err := p.urlForRecordType(rtype)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tlog.Debugf(\"Listing %s records from %s\", rtype, apiUrl)\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, apiUrl, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tjRes, err := p.do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Parse JSON response\n\tvar apiResponse ApiRecordsResponse\n\tif err := json.Unmarshal(jRes, &apiResponse); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to unmarshal error response: %w\", err)\n\t}\n\n\t// Pi-Hole does not allow for a record to have multiple targets.\n\tvar results []string\n\tif endpoint.RecordTypeCNAME == rtype {\n\t\tresults = apiResponse.Config.DNS.CnameRecords\n\t} else {\n\t\tresults = apiResponse.Config.DNS.Hosts\n\t}\n\n\treturn results, nil\n}\n\nfunc (p *piholeClientV6) listRecords(ctx context.Context, rtype string) ([]*endpoint.Endpoint, error) {\n\tresults, err := p.getConfigValue(ctx, rtype)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tendpoints := make(map[string]*endpoint.Endpoint)\n\n\tfor _, rec := range results {\n\t\trecs := strings.FieldsFunc(rec, func(r rune) bool {\n\t\t\treturn r == ' ' || r == ','\n\t\t})\n\t\tif len(recs) < 2 {\n\t\t\tlog.Warnf(\"skipping record %s: invalid format received from PiHole\", rec)\n\t\t\tcontinue\n\t\t}\n\t\tvar DNSName, Target string\n\t\tvar Ttl = endpoint.TTL(0)\n\t\t// A/AAAA record format is target(IP) DNSName\n\t\tDNSName, Target = recs[1], recs[0]\n\t\tswitch rtype {\n\t\tcase endpoint.RecordTypeA:\n\t\t\t// PiHole return A and AAAA records. Filter to only keep the A records\n\t\t\tif endpoint.SuitableType(Target) != endpoint.RecordTypeA {\n\t\t\t\tcontinue\n\t\t\t}\n\t\tcase endpoint.RecordTypeAAAA:\n\t\t\t// PiHole return A and AAAA records. Filter to only keep the AAAA records\n\t\t\tif endpoint.SuitableType(Target) != endpoint.RecordTypeAAAA {\n\t\t\t\tcontinue\n\t\t\t}\n\t\tcase endpoint.RecordTypeCNAME:\n\t\t\t// PiHole return only CNAME records.\n\t\t\t// CNAME format is DNSName,target, ttl?\n\t\t\tDNSName, Target = recs[0], recs[1]\n\t\t\tif len(recs) == 3 { // TTL is present\n\t\t\t\t// Parse string to int64 first\n\t\t\t\tif ttlInt, err := strconv.ParseInt(recs[2], 10, 64); err == nil {\n\t\t\t\t\tTtl = endpoint.TTL(ttlInt)\n\t\t\t\t} else {\n\t\t\t\t\tlog.Warnf(\"failed to parse TTL value received from PiHole '%s': %v; using a TTL of %d\", recs[2], err, Ttl)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tep := endpoint.NewEndpointWithTTL(DNSName, rtype, Ttl, Target)\n\n\t\tif oldEp, ok := endpoints[DNSName]; ok {\n\t\t\tep.Targets = append(oldEp.Targets, Target) // nolint: gocritic // appendAssign\n\t\t}\n\n\t\tendpoints[DNSName] = ep\n\t}\n\n\tout := make([]*endpoint.Endpoint, 0, len(endpoints))\n\tfor _, ep := range endpoints {\n\t\tout = append(out, ep)\n\t}\n\treturn out, nil\n}\n\nfunc (p *piholeClientV6) createRecord(ctx context.Context, ep *endpoint.Endpoint) error {\n\treturn p.apply(ctx, http.MethodPut, ep)\n}\n\nfunc (p *piholeClientV6) deleteRecord(ctx context.Context, ep *endpoint.Endpoint) error {\n\treturn p.apply(ctx, http.MethodDelete, ep)\n}\n\nfunc (p *piholeClientV6) aRecordsScript() string {\n\treturn fmt.Sprintf(\"%s\"+apiConfigDNS+\"/hosts\", p.cfg.Server)\n}\n\nfunc (p *piholeClientV6) cnameRecordsScript() string {\n\treturn fmt.Sprintf(\"%s\"+apiConfigDNS+\"/cnameRecords\", p.cfg.Server)\n}\n\nfunc (p *piholeClientV6) urlForRecordType(rtype string) (string, error) {\n\tswitch rtype {\n\tcase endpoint.RecordTypeA, endpoint.RecordTypeAAAA:\n\t\treturn p.aRecordsScript(), nil\n\tcase endpoint.RecordTypeCNAME:\n\t\treturn p.cnameRecordsScript(), nil\n\tdefault:\n\t\treturn \"\", fmt.Errorf(\"unsupported record type: %s\", rtype)\n\t}\n}\n\n// ApiAuthResponse Define a struct to match the JSON response /auth/app structure\ntype ApiAuthResponse struct {\n\tSession struct {\n\t\tValid    bool   `json:\"valid\"`\n\t\tTOTP     bool   `json:\"totp\"`\n\t\tSID      string `json:\"sid\"`\n\t\tCSRF     string `json:\"csrf\"`\n\t\tValidity int    `json:\"validity\"`\n\t\tMessage  string `json:\"message\"`\n\t} `json:\"session\"`\n\tTook float64 `json:\"took\"`\n}\n\n// ApiErrorResponse Define struct to match the JSON structure\ntype ApiErrorResponse struct {\n\tError struct {\n\t\tKey     string `json:\"key\"`\n\t\tMessage string `json:\"message\"`\n\t\tHint    string `json:\"hint\"`\n\t} `json:\"error\"`\n\tTook float64 `json:\"took\"`\n}\n\n// ApiRecordsResponse Define struct to match JSON structure\ntype ApiRecordsResponse struct {\n\tConfig struct {\n\t\tDNS struct {\n\t\t\tHosts        []string `json:\"hosts\"`\n\t\t\tCnameRecords []string `json:\"cnameRecords\"`\n\t\t} `json:\"dns\"`\n\t} `json:\"config\"`\n\tTook float64 `json:\"took\"`\n}\n\nfunc (p *piholeClientV6) generateApiUrl(baseUrl, params string) string {\n\treturn fmt.Sprintf(\"%s/%s\", baseUrl, url.PathEscape(params))\n}\n\nfunc (p *piholeClientV6) apply(ctx context.Context, action string, ep *endpoint.Endpoint) error {\n\tif !p.cfg.DomainFilter.Match(ep.DNSName) {\n\t\tlog.Debugf(\"Skipping : %s %s that does not match domain filter\", action, ep.DNSName)\n\t\treturn nil\n\t}\n\tapiUrl, err := p.urlForRecordType(ep.RecordType)\n\tif err != nil {\n\t\tlog.Warnf(\"Skipping : unsupported endpoint %s %s %v\", ep.DNSName, ep.RecordType, ep.Targets)\n\t\treturn nil\n\t}\n\n\tif len(ep.Targets) == 0 {\n\t\tlog.Infof(\"Skipping : missing targets  %s %s %s\", action, ep.DNSName, ep.RecordType)\n\t\treturn nil\n\t}\n\n\t// Get the current record\n\tif strings.Contains(ep.DNSName, \"*\") {\n\t\treturn provider.NewSoftError(errors.New(\"UNSUPPORTED: Pihole DNS names cannot return wildcard\"))\n\t}\n\n\tif ep.RecordType == endpoint.RecordTypeCNAME && len(ep.Targets) > 1 {\n\t\treturn provider.NewSoftError(errors.New(\"UNSUPPORTED: Pihole CNAME records cannot have multiple targets\"))\n\t}\n\n\tfor _, target := range ep.Targets {\n\t\tif p.cfg.DryRun {\n\t\t\tlog.Infof(\"DRY RUN: %s %s IN %s -> %s\", action, ep.DNSName, ep.RecordType, target)\n\t\t\tcontinue\n\t\t}\n\n\t\tlog.Infof(\"%s %s IN %s -> %s\", action, ep.DNSName, ep.RecordType, target)\n\n\t\ttargetApiUrl := apiUrl\n\n\t\tswitch ep.RecordType {\n\t\tcase endpoint.RecordTypeA, endpoint.RecordTypeAAAA:\n\t\t\ttargetApiUrl = p.generateApiUrl(targetApiUrl, fmt.Sprintf(\"%s %s\", target, ep.DNSName))\n\t\tcase endpoint.RecordTypeCNAME:\n\t\t\tif ep.RecordTTL.IsConfigured() {\n\t\t\t\ttargetApiUrl = p.generateApiUrl(targetApiUrl, fmt.Sprintf(\"%s,%s,%d\", ep.DNSName, target, ep.RecordTTL))\n\t\t\t} else {\n\t\t\t\ttargetApiUrl = p.generateApiUrl(targetApiUrl, fmt.Sprintf(\"%s,%s\", ep.DNSName, target))\n\t\t\t}\n\t\t}\n\t\treq, err := http.NewRequestWithContext(ctx, action, targetApiUrl, nil)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t_, err = p.do(req)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (p *piholeClientV6) retrieveNewToken(ctx context.Context) error {\n\tif p.cfg.Password == \"\" {\n\t\treturn nil\n\t}\n\n\tapiUrl := fmt.Sprintf(\"%s\"+apiAuthPath, p.cfg.Server)\n\tlog.Debugf(\"Fetching new token from %s\", apiUrl)\n\n\t// Define the JSON payload\n\tjsonData := []byte(`{\"password\":\"` + p.cfg.Password + `\"}`)\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiUrl, bytes.NewBuffer(jsonData))\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tjRes, err := p.do(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Parse JSON response\n\tvar apiResponse ApiAuthResponse\n\tif err := json.Unmarshal(jRes, &apiResponse); err != nil {\n\t\tlog.Errorf(\"Auth Query : failed to unmarshal error response: %v\", err)\n\t} else if apiResponse.Session.SID != \"\" {\n\t\t// Set the token\n\t\tp.token = apiResponse.Session.SID\n\t}\n\treturn err\n}\n\nfunc (p *piholeClientV6) checkTokenValidity(ctx context.Context) (bool, error) {\n\tif p.token == \"\" {\n\t\treturn false, nil\n\t}\n\n\tapiUrl := fmt.Sprintf(\"%s\"+apiAuthPath, p.cfg.Server)\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, apiUrl, nil)\n\tif err != nil {\n\t\treturn false, nil\n\t}\n\treq.Header.Add(\"content-type\", contentTypeJSON)\n\tif p.token != \"\" {\n\t\treq.Header.Add(\"X-FTL-SID\", p.token)\n\t}\n\tres, err := p.httpClient.Do(req)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\tjRes, err := io.ReadAll(res.Body)\n\tdefer res.Body.Close()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\t// Parse JSON response\n\tvar apiResponse ApiAuthResponse\n\tif err := json.Unmarshal(jRes, &apiResponse); err != nil {\n\t\treturn false, fmt.Errorf(\"failed to unmarshal error response: %w\", err)\n\t}\n\treturn apiResponse.Session.Valid, nil\n}\n\nfunc (p *piholeClientV6) do(req *http.Request) ([]byte, error) {\n\treq.Header.Add(\"content-type\", contentTypeJSON)\n\tif p.token != \"\" {\n\t\treq.Header.Add(\"X-FTL-SID\", p.token)\n\t}\n\tres, err := p.httpClient.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tjRes, err := io.ReadAll(res.Body)\n\tdefer res.Body.Close()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif res.StatusCode != http.StatusOK &&\n\t\tres.StatusCode != http.StatusCreated &&\n\t\tres.StatusCode != http.StatusNoContent {\n\t\t// Parse JSON response\n\t\tvar apiError ApiErrorResponse\n\t\tif err := json.Unmarshal(jRes, &apiError); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to unmarshal error response: %w\", err)\n\t\t}\n\t\t// Ignore if the entry already exists when adding a record\n\t\tif strings.Contains(apiError.Error.Message, \"Item already present\") {\n\t\t\treturn jRes, nil\n\t\t}\n\t\t// Ignore if the entry does not exist when deleting a record\n\t\tif res.StatusCode == http.StatusNotFound && req.Method == http.MethodDelete {\n\t\t\treturn jRes, nil\n\t\t}\n\t\tif log.IsLevelEnabled(log.DebugLevel) {\n\t\t\tlog.Debugf(\"Error on request %s\", req.URL)\n\t\t\tif req.Body != nil {\n\t\t\t\tlog.Debugf(\"Body of the request %s\", req.Body)\n\t\t\t}\n\t\t}\n\n\t\tif res.StatusCode == http.StatusUnauthorized && p.token != \"\" {\n\t\t\ttryCount := 1\n\t\t\tmaxRetries := 3\n\t\t\t// Try to fetch a new token and redo the request.\n\t\t\tfor tryCount <= maxRetries {\n\t\t\t\tvalid, err := p.checkTokenValidity(req.Context())\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\t\t\t\tif !valid {\n\t\t\t\t\tlog.Debugf(\"Pihole token has expired, fetching a new one. Try (%d/%d)\", tryCount, maxRetries)\n\t\t\t\t\tif err := p.retrieveNewToken(req.Context()); err != nil {\n\t\t\t\t\t\treturn nil, err\n\t\t\t\t\t}\n\t\t\t\t\ttryCount++\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif tryCount > maxRetries {\n\t\t\t\treturn nil, errors.New(\"max tries reached for token renewal\")\n\t\t\t}\n\t\t\treturn p.do(req)\n\t\t}\n\t\treturn nil, fmt.Errorf(\"received %d status code from request: [%s] %s (%s) - %fs\", res.StatusCode, apiError.Error.Key, apiError.Error.Message, apiError.Error.Hint, apiError.Took)\n\t}\n\treturn jRes, nil\n}\n"
  },
  {
    "path": "provider/pihole/clientV6_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage pihole\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/google/go-cmp/cmp\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\nfunc newTestServerV6(t *testing.T, hdlr http.HandlerFunc) *httptest.Server {\n\tt.Helper()\n\tsvr := httptest.NewServer(hdlr)\n\treturn svr\n}\n\ntype errorTransportV6 struct{}\n\nfunc (t *errorTransportV6) RoundTrip(_ *http.Request) (*http.Response, error) {\n\treturn nil, errors.New(\"network error\")\n}\n\nfunc TestNewPiholeClientV6(t *testing.T) {\n\t// Test correct error on no server provided\n\t_, err := newPiholeClientV6(PiholeConfig{APIVersion: \"6\"})\n\tif err == nil {\n\t\tt.Error(\"Expected error from config with no server\")\n\t} else if !errors.Is(err, ErrNoPiholeServer) {\n\t\tt.Error(\"Expected ErrNoPiholeServer, got\", err)\n\t}\n\n\t// Test new client with no password. Should create the client cleanly.\n\tcl, err := newPiholeClientV6(PiholeConfig{\n\t\tServer:     \"test\",\n\t\tAPIVersion: \"6\",\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := cl.(*piholeClientV6); !ok {\n\t\tt.Error(\"Did not create a new pihole client\")\n\t}\n\n\t// Create a test server\n\tsrvr := newTestServerV6(t, func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Path == \"/api/auth\" && r.Method == http.MethodPost {\n\t\t\tvar requestData map[string]string\n\t\t\terr := json.NewDecoder(r.Body).Decode(&requestData)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\t\t\tdefer r.Body.Close()\n\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\n\t\t\tif requestData[\"password\"] != \"correct\" {\n\t\t\t\t// Return unsuccessful authentication response\n\t\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\t\t_, err = w.Write([]byte(`{\n\t\t\t\t\"session\": {\n\t\t\t\t\t\"valid\": false,\n\t\t\t\t\t\"totp\": false,\n\t\t\t\t\t\"sid\": null,\n\t\t\t\t\t\"validity\": -1,\n\t\t\t\t\t\"message\": \"password incorrect\"\n\t\t\t\t},\n\t\t\t\t\"took\": 0.2\n\t\t\t}`))\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatal(err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Return successful authentication response\n\t\t\t_, err = w.Write([]byte(`{\n\t\t\t\"session\": {\n\t\t\t\t\"valid\": true,\n\t\t\t\t\"totp\": false,\n\t\t\t\t\"sid\": \"supersecret\",\n\t\t\t\t\"csrf\": \"csrfvalue\",\n\t\t\t\t\"validity\": 1800,\n\t\t\t\t\"message\": \"password correct\"\n\t\t\t},\n\t\t\t\"took\": 0.18\n\t\t}`))\n\t\t} else {\n\t\t\thttp.NotFound(w, r)\n\t\t}\n\t})\n\tdefer srvr.Close()\n\n\t// Test invalid password\n\t_, err = newPiholeClientV6(\n\t\tPiholeConfig{Server: srvr.URL, APIVersion: \"6\", Password: \"wrong\"},\n\t)\n\tif err == nil {\n\t\tt.Error(\"Expected error for creating client with invalid password\")\n\t}\n\n\t// Test correct password\n\tcl, err = newPiholeClientV6(\n\t\tPiholeConfig{Server: srvr.URL, APIVersion: \"6\", Password: \"correct\"},\n\t)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif cl.(*piholeClientV6).token != \"supersecret\" {\n\t\tt.Error(\"Parsed invalid token from login response:\", cl.(*piholeClient).token)\n\t}\n}\n\nfunc TestListRecordsV6(t *testing.T) {\n\t// Create a test server\n\tsrvr := newTestServerV6(t, func(w http.ResponseWriter, r *http.Request) {\n\t\tswitch {\n\t\tcase r.URL.Path == \"/api/config/dns/hosts\" && r.Method == http.MethodGet:\n\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\n\t\t\t// Return A records\n\t\t\tif _, err := w.Write([]byte(`{\n\t\t\t\t\"config\": {\n\t\t\t\t\t\"dns\": {\n\t\t\t\t\t\t\"hosts\": [\n\t\t\t\t\t\t\t\"192.168.178.33 service1.example.com\",\n\t\t\t\t\t\t\t\"192.168.178.34 service2.example.com\",\n\t\t\t\t\t\t\t\"192.168.178.34 service3.example.com\",\n\t\t\t\t\t\t\t\"192.168.178.35 service8.example.com\",\n\t\t\t\t\t\t\t\"192.168.178.36 service8.example.com\",\n\t\t\t\t\t\t\t\"fc00::1:192:168:1:1 service4.example.com\",\n\t\t\t\t\t\t\t\"fc00::1:192:168:1:2 service5.example.com\",\n\t\t\t\t\t\t\t\"fc00::1:192:168:1:3 service6.example.com\",\n\t\t\t\t\t\t\t\"::ffff:192.168.20.3 service7.example.com\",\n\t\t\t\t\t\t\t\"fc00::1:192:168:1:4 service9.example.com\",\n\t\t\t\t\t\t\t\"fc00::1:192:168:1:5 service9.example.com\",\n\t\t\t\t\t\t\t\"192.168.20.3 service7.example.com\"\n\t\t\t\t\t\t]\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\t\"took\": 5\n\t\t\t}`)); err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\t\tcase r.URL.Path == \"/api/config/dns/cnameRecords\" && r.Method == http.MethodGet:\n\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\n\t\t\t// Return A records\n\t\t\tw.Write([]byte(`{\n\t\t\t\t\"config\": {\n\t\t\t\t\t\"dns\": {\n\t\t\t\t\t\t\"cnameRecords\": [\n\t\t\t\t\t\t\t\"source1.example.com,target1.domain.com,1000\",\n\t\t\t\t\t\t\t\"source2.example.com,target2.domain.com,50\",\n\t\t\t\t\t\t\t\"source3.example.com,target3.domain.com\"\n\t\t\t\t\t\t]\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\t\"took\": 5\n\t\t\t}`))\n\t\tdefault:\n\t\t\thttp.NotFound(w, r)\n\t\t}\n\t})\n\tdefer srvr.Close()\n\n\t// Create a client\n\tcfg := PiholeConfig{\n\t\tServer:     srvr.URL,\n\t\tAPIVersion: \"6\",\n\t}\n\tcl, err := newPiholeClientV6(cfg)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Ensure A records were parsed correctly\n\texpected := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName: \"service1.example.com\",\n\t\t\tTargets: []string{\"192.168.178.33\"},\n\t\t},\n\t\t{\n\t\t\tDNSName: \"service2.example.com\",\n\t\t\tTargets: []string{\"192.168.178.34\"},\n\t\t},\n\t\t{\n\t\t\tDNSName: \"service3.example.com\",\n\t\t\tTargets: []string{\"192.168.178.34\"},\n\t\t},\n\t\t{\n\t\t\tDNSName: \"service7.example.com\",\n\t\t\tTargets: []string{\"192.168.20.3\"},\n\t\t},\n\t\t{\n\t\t\tDNSName: \"service8.example.com\",\n\t\t\tTargets: []string{\"192.168.178.35\", \"192.168.178.36\"},\n\t\t},\n\t}\n\t// Test retrieve A records unfiltered\n\tarecs, err := cl.listRecords(t.Context(), endpoint.RecordTypeA)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\texpectedMap := make(map[string]*endpoint.Endpoint)\n\tfor _, ep := range expected {\n\t\texpectedMap[ep.DNSName] = ep\n\t}\n\tfor _, rec := range arecs {\n\t\tif ep, ok := expectedMap[rec.DNSName]; ok {\n\t\t\tif cmp.Diff(ep.Targets, rec.Targets) != \"\" {\n\t\t\t\tt.Errorf(\"Got invalid targets for %s: %v, expected: %v\", rec.DNSName, rec.Targets, ep.Targets)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Ensure AAAA records were parsed correctly\n\texpected = []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName: \"service4.example.com\",\n\t\t\tTargets: []string{\"fc00::1:192:168:1:1\"},\n\t\t},\n\t\t{\n\t\t\tDNSName: \"service5.example.com\",\n\t\t\tTargets: []string{\"fc00::1:192:168:1:2\"},\n\t\t},\n\t\t{\n\t\t\tDNSName: \"service6.example.com\",\n\t\t\tTargets: []string{\"fc00::1:192:168:1:3\"},\n\t\t},\n\t\t{\n\t\t\tDNSName: \"service7.example.com\",\n\t\t\tTargets: []string{\"::ffff:192.168.20.3\"},\n\t\t},\n\t\t{\n\t\t\tDNSName: \"service9.example.com\",\n\t\t\tTargets: []string{\"fc00::1:192:168:1:4\", \"fc00::1:192:168:1:5\"},\n\t\t},\n\t}\n\n\t// Test retrieve AAAA records unfiltered\n\tarecs, err = cl.listRecords(t.Context(), endpoint.RecordTypeAAAA)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(arecs) != len(expected) {\n\t\tt.Fatalf(\"Expected %d AAAA records returned, got: %d\", len(expected), len(arecs))\n\t}\n\n\texpectedMap = make(map[string]*endpoint.Endpoint)\n\tfor _, ep := range expected {\n\t\texpectedMap[ep.DNSName] = ep\n\t}\n\tfor _, rec := range arecs {\n\t\tif ep, ok := expectedMap[rec.DNSName]; ok {\n\t\t\tif cmp.Diff(ep.Targets, rec.Targets) != \"\" {\n\t\t\t\tt.Errorf(\"Got invalid targets for %s: %v, expected: %v\", rec.DNSName, rec.Targets, ep.Targets)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Ensure CNAME records were parsed correctly\n\texpected = []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:   \"source1.example.com\",\n\t\t\tTargets:   []string{\"target1.domain.com\"},\n\t\t\tRecordTTL: 1000,\n\t\t},\n\t\t{\n\t\t\tDNSName:   \"source2.example.com\",\n\t\t\tTargets:   []string{\"target2.domain.com\"},\n\t\t\tRecordTTL: 50,\n\t\t},\n\t\t{\n\t\t\tDNSName: \"source3.example.com\",\n\t\t\tTargets: []string{\"target3.domain.com\"},\n\t\t},\n\t}\n\n\t// Test retrieve CNAME records unfiltered\n\tcnamerecs, err := cl.listRecords(t.Context(), endpoint.RecordTypeCNAME)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(cnamerecs) != len(expected) {\n\t\tt.Fatalf(\"Expected %d CAME records returned, got: %d\", len(expected), len(cnamerecs))\n\t}\n\n\texpectedMap = make(map[string]*endpoint.Endpoint)\n\tfor _, ep := range expected {\n\t\texpectedMap[ep.DNSName] = ep\n\t}\n\tfor _, rec := range arecs {\n\t\tif ep, ok := expectedMap[rec.DNSName]; ok {\n\t\t\tif cmp.Diff(ep.Targets, rec.Targets) != \"\" {\n\t\t\t\tt.Errorf(\"Got invalid targets for %s: %v, expected: %v\", rec.DNSName, rec.Targets, ep.Targets)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Note: filtered tests are not needed since A/AAAA records are tested filtered already\n\t// and cnameRecords have their own element\n\n\t// unsupported type\n\t_, err = cl.listRecords(t.Context(), endpoint.RecordTypeNAPTR)\n\tif err == nil || err.Error() != fmt.Sprintf(\"unsupported record type: %s\", endpoint.RecordTypeNAPTR) {\n\t\tt.Fatal(\"Expected error for using unsupported record type\")\n\t}\n}\n\nfunc TestErrorsV6(t *testing.T) {\n\t// Error test cases\n\n\t// Create a client\n\tcfgErrURL := PiholeConfig{\n\t\tServer:     \"not an url\",\n\t\tAPIVersion: \"6\",\n\t}\n\tclErrURL, err := newPiholeClientV6(cfgErrURL)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t_, err = clErrURL.listRecords(t.Context(), endpoint.RecordTypeCNAME)\n\tif err == nil {\n\t\tt.Fatal(\"Expected error for using invalid URL\")\n\t}\n\t_, err = clErrURL.listRecords(nil, endpoint.RecordTypeCNAME)\n\tif err == nil {\n\t\tt.Fatal(\"Expected error for nil context\")\n\t}\n\t// Unmarshalling error\n\tsrvrErrJson := newTestServerV6(t, func(w http.ResponseWriter, _ *http.Request) {\n\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\n\t\t// Return A records\n\t\tw.Write([]byte(`I am not JSON`))\n\t})\n\tdefer srvrErrJson.Close()\n\t// Create a client\n\tcfgErr := PiholeConfig{\n\t\tServer:     srvrErrJson.URL,\n\t\tAPIVersion: \"6\",\n\t}\n\tclErr, _ := newPiholeClientV6(cfgErr)\n\n\tresp, err := clErr.listRecords(t.Context(), endpoint.RecordTypeA)\n\tif err == nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif !strings.HasPrefix(err.Error(), \"failed to unmarshal error response:\") {\n\t\tt.Fatal(\"Expected unmarshalling error, got:\", err)\n\t}\n\n\t// bad record format return by server\n\tsrvrErr := newTestServerV6(t, func(w http.ResponseWriter, r *http.Request) {\n\t\tswitch {\n\t\tcase r.URL.Path == \"/api/config/dns/hosts\" && r.Method == http.MethodGet:\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\n\t\t\t// Return A records\n\t\t\tw.Write([]byte(`{\n\t\t\t\t\"config\": {\n\t\t\t\t\t\"dns\": {\n\t\t\t\t\t\t\"hosts\": [\n\t\t\t\t\t\t\t\"192.168.178.33\"\n\t\t\t\t\t\t]\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\t\"took\": 5\n\t\t\t}`))\n\t\tcase r.URL.Path == \"/api/config/dns/cnameRecords\" && r.Method == http.MethodGet:\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\n\t\t\t// Return A records\n\t\t\tw.Write([]byte(`{\n\t\t\t\t\"config\": {\n\t\t\t\t\t\"dns\": {\n\t\t\t\t\t\t\"cnameRecords\": [\n\t\t\t\t\t\t\t\"source1.example.com,target1.domain.com,100\",\n\t\t\t\t\t\t\t\"source2.example.com,target2.domain.com,not_an_integer\"\n\t\t\t\t\t\t]\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\t\"took\": 5\n\t\t\t}`))\n\t\tdefault:\n\t\t\thttp.NotFound(w, r)\n\t\t}\n\t})\n\tdefer srvrErr.Close()\n\n\t// Create a client\n\tcfgErr = PiholeConfig{\n\t\tServer:     srvrErr.URL,\n\t\tAPIVersion: \"6\",\n\t}\n\tclErr, _ = newPiholeClientV6(cfgErr)\n\n\tresp, err = clErr.listRecords(t.Context(), endpoint.RecordTypeA)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(resp) != 0 {\n\t\tt.Fatal(\"Expected no records returned, got:\", len(resp))\n\t}\n\tresp, err = clErr.listRecords(t.Context(), endpoint.RecordTypeCNAME)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(resp) != 2 {\n\t\tt.Fatal(\"Expected one records returned, got:\", len(resp))\n\t}\n\n\texpected := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:   \"source1.example.com\",\n\t\t\tTargets:   []string{\"target1.domain.com\"},\n\t\t\tRecordTTL: 100,\n\t\t},\n\t\t{\n\t\t\tDNSName: \"source2.example.com\",\n\t\t\tTargets: []string{\"target2.domain.com\"},\n\t\t},\n\t}\n\n\texpectedMap := make(map[string]*endpoint.Endpoint)\n\tfor _, ep := range expected {\n\t\texpectedMap[ep.DNSName] = ep\n\t}\n\tfor _, rec := range resp {\n\t\tif ep, ok := expectedMap[rec.DNSName]; ok {\n\t\t\tif cmp.Diff(ep.Targets, rec.Targets) != \"\" {\n\t\t\t\tt.Errorf(\"Got invalid targets for %s: %v, expected: %v\", rec.DNSName, rec.Targets, ep.Targets)\n\t\t\t}\n\t\t\tif ep.RecordTTL != rec.RecordTTL {\n\t\t\t\tt.Errorf(\"Got invalid TTL for %s: %d, expected: %d\", rec.DNSName, rec.RecordTTL, ep.RecordTTL)\n\t\t\t}\n\t\t} else {\n\t\t\tt.Errorf(\"Unexpected record found: %s\", rec.DNSName)\n\t\t}\n\t}\n\n}\n\nfunc TestTokenValidity(t *testing.T) {\n\tsrvok := newTestServerV6(t, func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Path == \"/api/auth\" && r.Method == http.MethodGet {\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\n\t\t\t// Return bad content\n\t\t\tw.Write([]byte(`{\n\t\t\t\"session\": {\n\t\t\t\t\"valid\": true,\n\t\t\t\t\"totp\": false,\n\t\t\t\t\"sid\": \"supersecret\",\n\t\t\t\t\"csrf\": \"csrfvalue\",\n\t\t\t\t\"validity\": 1800,\n\t\t\t\t\"message\": \"password correct\"\n\t\t\t},\n\t\t\t\"took\": 0.17\n\t\t\t}`))\n\t\t}\n\t})\n\t// Create a client\n\tcfgOK := PiholeConfig{\n\t\tServer:     srvok.URL,\n\t\tAPIVersion: \"6\",\n\t}\n\tclOK, err := newPiholeClientV6(cfgOK)\n\tclOK.(*piholeClientV6).token = \"valid\"\n\tvalidity, err := clOK.(*piholeClientV6).checkTokenValidity(t.Context())\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !validity {\n\t\tt.Fatal(\"Should be valid\")\n\t}\n\n\t// Create a test server\n\tsrvr := newTestServerV6(t, func(w http.ResponseWriter, r *http.Request) {\n\n\t\tif r.URL.Path == \"/api/auth\" && r.Method == http.MethodGet {\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\n\t\t\t// Return bad content\n\t\t\tw.Write([]byte(`Not a JSON`))\n\t\t}\n\t})\n\tdefer srvr.Close()\n\t//\n\t// Create a client\n\tcfg := PiholeConfig{\n\t\tServer:     srvr.URL,\n\t\tAPIVersion: \"6\",\n\t}\n\tcl, err := newPiholeClientV6(cfg)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tvalidity, err = cl.(*piholeClientV6).checkTokenValidity(t.Context())\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif validity {\n\t\tt.Fatal(\"Should be invalid : no token\")\n\t}\n\t// Test token validity\n\tcl.(*piholeClientV6).token = \"valid\"\n\n\tvalidity, err = cl.(*piholeClientV6).checkTokenValidity(nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif validity {\n\t\tt.Fatal(\"Should be invalid : nil context\")\n\t}\n\n\tvalidity, err = cl.(*piholeClientV6).checkTokenValidity(t.Context())\n\tif err == nil {\n\t\tt.Fatal(\"Should be invalid : failed to unmarshal error\")\n\t}\n\tif !strings.HasPrefix(err.Error(), \"failed to unmarshal error response\") {\n\t\tt.Fatal(\"Expected unmarshalling error, got:\", err)\n\t}\n\tif validity {\n\t\tt.Fatal(\"Should be invalid : unmarshalling error\")\n\t}\n}\n\nfunc TestDo(t *testing.T) {\n\n\tsrvDo := newTestServerV6(t, func(w http.ResponseWriter, r *http.Request) {\n\t\tswitch {\n\t\tcase r.URL.Path == \"/api/auth/ok\" && r.Method == http.MethodGet:\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t// Return bad content\n\t\t\tw.Write([]byte(`{\n\t\t\t\"session\": {\n\t\t\t\t\"valid\": true,\n\t\t\t\t\"totp\": false,\n\t\t\t\t\"sid\": \"supersecret\",\n\t\t\t\t\"csrf\": \"csrfvalue\",\n\t\t\t\t\"validity\": 1800,\n\t\t\t\t\"message\": \"password correct\"\n\t\t\t},\n\t\t\t\"took\": 0.16\n\t\t\t}`))\n\t\tcase r.URL.Path == \"/api/auth\" && r.Method == http.MethodPost:\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t// Return bad content\n\t\t\tw.Write([]byte(`{\n\t\t\t\"session\": {\n\t\t\t\t\"valid\": false,\n\t\t\t\t\"totp\": false,\n\t\t\t\t\"sid\": \"\",\n\t\t\t\t\"csrf\": \"csrfvalue\",\n\t\t\t\t\"validity\": 1800,\n\t\t\t\t\"message\": \"password correct\"\n\t\t\t},\n\t\t\t\"took\": 0.15\n\t\t\t}`))\n\t\tcase r.URL.Path == \"/api/auth\" && r.Method == http.MethodGet:\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\t// Return bad content\n\t\t\tw.Write([]byte(`{\n\t\t\t\"error\": {\n\t\t\t\t\"key\": \"401\",\n\t\t\t\t\"message\": \"Expired token\",\n\t\t\t\t\"hint\": \"Expired token\"\n\t\t\t},\n\t\t\t\"took\": 0.14\n\t\t\t}`))\n\t\tcase r.URL.Path == \"/api/auth/418\" && r.Method == http.MethodGet:\n\t\t\tw.WriteHeader(http.StatusTeapot)\n\t\t\t// Return bad content\n\t\t\tw.Write([]byte(`{\n\t\t\t\"error\": {\n\t\t\t\t\"key\": \"418\",\n\t\t\t\t\"message\": \"I'm a teapot\",\n\t\t\t\t\"hint\": \"It is a teapot\"\n\t\t\t},\n\t\t\t\"took\": 0.13\n\t\t\t}`))\n\t\tcase r.URL.Path == \"/api/auth/nojson\" && r.Method == http.MethodGet:\n\t\t\t// Return bad content\n\t\t\tw.WriteHeader(http.StatusTeapot)\n\t\t\tw.Write([]byte(`Not a JSON`))\n\t\tcase r.URL.Path == \"/api/auth/401\" && r.Method == http.MethodGet:\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\t// Return bad content\n\t\t\tw.Write([]byte(`{\n\t\t\t\"error\": {\n\t\t\t\t\"key\": \"401\",\n\t\t\t\t\"message\": \"Expired token\",\n\t\t\t\t\"hint\": \"Expired token\"\n\t\t\t},\n\t\t\t\"took\": 0.10\n\t\t\t}`))\n\t\t}\n\t})\n\tdefer srvDo.Close()\n\n\t// Create a client\n\tcfg := PiholeConfig{\n\t\tServer:     srvDo.URL,\n\t\tAPIVersion: \"6\",\n\t}\n\tcl, err := newPiholeClientV6(cfg)\n\tcl.(*piholeClientV6).token = \"valid\"\n\n\trq, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, srvDo.URL+\"/api/auth/ok\", nil)\n\tresp, err := cl.(*piholeClientV6).do(rq)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(resp) == 0 {\n\t\tt.Fatal(\"Should have a response\")\n\t}\n\t// Test not handled error code\n\trq, _ = http.NewRequestWithContext(t.Context(), http.MethodGet, srvDo.URL+\"/api/auth/418\", nil)\n\tresp, err = cl.(*piholeClientV6).do(rq)\n\tif resp != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err == nil {\n\t\tt.Fatal(\"Should have an error\")\n\t}\n\tif !strings.HasPrefix(err.Error(), \"received 418 status code from request\") {\n\t\tt.Fatal(\"Expected error for unexpected status code, got:\", err)\n\t}\n\t// Test error on non JSON response\n\trq, _ = http.NewRequestWithContext(t.Context(), http.MethodGet, srvDo.URL+\"/api/auth/nojson\", nil)\n\tresp, err = cl.(*piholeClientV6).do(rq)\n\tif resp != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err == nil {\n\t\tt.Fatal(\"Should have an error\")\n\t}\n\tif !strings.HasPrefix(err.Error(), \"failed to unmarshal error response\") {\n\t\tt.Fatal(\"Expected error for unmarshal\", err)\n\t}\n\t// Test Unauthorized retry failed\n\trq, _ = http.NewRequestWithContext(t.Context(), http.MethodGet, srvDo.URL+\"/api/auth/401\", nil)\n\tresp, err = cl.(*piholeClientV6).do(rq)\n\tif resp != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err == nil {\n\t\tt.Fatal(\"Should have an error\")\n\t}\n\tif !strings.HasPrefix(err.Error(), \"max tries reached for token renewal\") {\n\t\tt.Fatal(\"Expected error for max tries reached\", err)\n\t}\n}\n\nfunc TestDoRetryOne(t *testing.T) {\n\tnbCall := 0\n\tsrvRetry := newTestServerV6(t, func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Path == \"/api/auth\" && r.Method == http.MethodGet {\n\t\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t// Return bad content\n\t\t\tw.Write([]byte(`{\n\t\t\t\"session\": {\n\t\t\t\t\"valid\": true,\n\t\t\t\t\"totp\": false,\n\t\t\t\t\"sid\": \"123465468\",\n\t\t\t\t\"csrf\": \"csrfvalue\",\n\t\t\t\t\"validity\": 1800,\n\t\t\t\t\"message\": \"password correct\"\n\t\t\t},\n\t\t\t\"took\": 0.24\n\t\t\t}`))\n\t\t} else if r.URL.Path == \"/api/auth/401\" && r.Method == http.MethodGet {\n\t\t\tif nbCall == 0 {\n\t\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t\t\t// Return bad content\n\t\t\t\tw.Write([]byte(`{\n\t\t\t\t\"error\": {\n\t\t\t\t\t\"key\": \"401\",\n\t\t\t\t\t\"message\": \"Expired token\",\n\t\t\t\t\t\"hint\": \"Expired token\"\n\t\t\t\t},\n\t\t\t\t\"took\": 0.25\n\t\t\t\t}`))\n\t\t\t} else {\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\t// Return bad content\n\t\t\t\tw.Write([]byte(`Success`))\n\t\t\t}\n\t\t\tnbCall += 1\n\t\t}\n\t})\n\tdefer srvRetry.Close()\n\t// Create a client\n\tcfgRetryOK := PiholeConfig{\n\t\tServer:     srvRetry.URL,\n\t\tAPIVersion: \"6\",\n\t}\n\tclRetryOK, err := newPiholeClientV6(cfgRetryOK)\n\tclRetryOK.(*piholeClientV6).token = \"valid\"\n\t// Test Unauthorized refresh OK\n\trq, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, srvRetry.URL+\"/api/auth/401\", nil)\n\tresp, err := clRetryOK.(*piholeClientV6).do(rq)\n\tif err != nil {\n\t\tt.Fatal(\"Should succeed\", err)\n\t}\n\tif string(resp) != \"Success\" {\n\t\tt.Fatal(\"Should have a response\")\n\t}\n\n}\n\nfunc TestDoV6AdditionalCases(t *testing.T) {\n\tt.Run(\"http client error\", func(t *testing.T) {\n\t\tclient := &piholeClientV6{\n\t\t\thttpClient: &http.Client{\n\t\t\t\tTransport: &errorTransportV6{},\n\t\t\t},\n\t\t}\n\t\treq, _ := http.NewRequest(http.MethodGet, \"http://localhost\", nil)\n\t\t_, err := client.do(req)\n\t\tif err == nil {\n\t\t\tt.Fatal(\"expected an error, but got none\")\n\t\t}\n\t\tif !strings.Contains(err.Error(), \"network error\") {\n\t\t\tt.Fatalf(\"expected error to contain 'network error', but got '%v'\", err)\n\t\t}\n\t})\n\n\tt.Run(\"item already present\", func(t *testing.T) {\n\t\tserver := newTestServerV6(t, func(w http.ResponseWriter, _ *http.Request) {\n\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\tw.Write([]byte(`{\n\t\t\t\t\"error\": {\n\t\t\t\t\t\"key\": \"bad_request\",\n\t\t\t\t\t\"message\": \"Item already present\",\n\t\t\t\t\t\"hint\": \"The item you're trying to add already exists\"\n\t\t\t\t},\n\t\t\t\t\"took\": 0.1\n\t\t\t}`))\n\t\t})\n\t\tdefer server.Close()\n\n\t\tclient := &piholeClientV6{\n\t\t\thttpClient: server.Client(),\n\t\t\ttoken:      \"test-token\",\n\t\t}\n\t\treq, _ := http.NewRequest(http.MethodPut, server.URL+\"/api/test\", nil)\n\t\tresp, err := client.do(req)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"expected no error for 'Item already present', but got '%v'\", err)\n\t\t}\n\t\tif resp == nil {\n\t\t\tt.Fatal(\"expected response, but got nil\")\n\t\t}\n\t})\n\n\tt.Run(\"404 on DELETE\", func(t *testing.T) {\n\t\tserver := newTestServerV6(t, func(w http.ResponseWriter, _ *http.Request) {\n\t\t\tw.WriteHeader(http.StatusNotFound)\n\t\t\tw.Write([]byte(`{\n\t\t\t\t\"error\": {\n\t\t\t\t\t\"key\": \"not_found\",\n\t\t\t\t\t\"message\": \"Item not found\",\n\t\t\t\t\t\"hint\": \"The item you're trying to delete does not exist\"\n\t\t\t\t},\n\t\t\t\t\"took\": 0.1\n\t\t\t}`))\n\t\t})\n\t\tdefer server.Close()\n\n\t\tclient := &piholeClientV6{\n\t\t\thttpClient: server.Client(),\n\t\t\ttoken:      \"test-token\",\n\t\t}\n\t\treq, _ := http.NewRequest(http.MethodDelete, server.URL+\"/api/test\", nil)\n\t\tresp, err := client.do(req)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"expected no error for 404 on DELETE, but got '%v'\", err)\n\t\t}\n\t\tif resp == nil {\n\t\t\tt.Fatal(\"expected response, but got nil\")\n\t\t}\n\t})\n}\n\nfunc TestCreateRecordV6(t *testing.T) {\n\tvar ep *endpoint.Endpoint\n\tsrvr := newTestServerV6(t, func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method == http.MethodPut && (r.URL.Path == \"/api/config/dns/hosts/192.168.1.1 test.example.com\" ||\n\t\t\tr.URL.Path == \"/api/config/dns/hosts/fc00::1:192:168:1:1 test.example.com\" ||\n\t\t\tr.URL.Path == \"/api/config/dns/cnameRecords/source1.example.com,target1.domain.com\" ||\n\t\t\tr.URL.Path == \"/api/config/dns/hosts/192.168.1.2 test.example.com\" ||\n\t\t\tr.URL.Path == \"/api/config/dns/hosts/192.168.1.3 test.example.com\" ||\n\t\t\tr.URL.Path == \"/api/config/dns/hosts/fc00::1:192:168:1:2 test.example.com\" ||\n\t\t\tr.URL.Path == \"/api/config/dns/hosts/fc00::1:192:168:1:3 test.example.com\" ||\n\t\t\tr.URL.Path == \"/api/config/dns/cnameRecords/source2.example.com,target2.domain.com,500\") {\n\n\t\t\t// Return A records\n\t\t\tw.WriteHeader(http.StatusCreated)\n\t\t} else {\n\t\t\thttp.NotFound(w, r)\n\t\t}\n\t})\n\tdefer srvr.Close()\n\n\t// Create a client\n\tcfg := PiholeConfig{\n\t\tServer:       srvr.URL,\n\t\tAPIVersion:   \"6\",\n\t\tDomainFilter: endpoint.NewDomainFilter([]string{\"example.com\"}),\n\t}\n\tcl, err := newPiholeClientV6(cfg)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Test create A record\n\tep = &endpoint.Endpoint{\n\t\tDNSName:    \"test.example.com\",\n\t\tTargets:    []string{\"192.168.1.1\"},\n\t\tRecordType: endpoint.RecordTypeA,\n\t}\n\tif err := cl.createRecord(t.Context(), ep); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Test create multiple A records\n\tep = &endpoint.Endpoint{\n\t\tDNSName:    \"test.example.com\",\n\t\tTargets:    []string{\"192.168.1.2\", \"192.168.1.3\"},\n\t\tRecordType: endpoint.RecordTypeA,\n\t}\n\tif err := cl.createRecord(t.Context(), ep); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Test create AAAA record\n\tep = &endpoint.Endpoint{\n\t\tDNSName:    \"test.example.com\",\n\t\tTargets:    []string{\"fc00::1:192:168:1:1\"},\n\t\tRecordType: endpoint.RecordTypeAAAA,\n\t}\n\tif err := cl.createRecord(t.Context(), ep); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Test create multiple AAAA records\n\tep = &endpoint.Endpoint{\n\t\tDNSName:    \"test.example.com\",\n\t\tTargets:    []string{\"fc00::1:192:168:1:2\", \"fc00::1:192:168:1:3\"},\n\t\tRecordType: endpoint.RecordTypeAAAA,\n\t}\n\tif err := cl.createRecord(t.Context(), ep); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Test create CNAME record\n\tep = &endpoint.Endpoint{\n\t\tDNSName:    \"source1.example.com\",\n\t\tTargets:    []string{\"target1.domain.com\"},\n\t\tRecordType: endpoint.RecordTypeCNAME,\n\t}\n\tif err := cl.createRecord(t.Context(), ep); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Test create CNAME record with TTL\n\tep = &endpoint.Endpoint{\n\t\tDNSName:    \"source2.example.com\",\n\t\tTargets:    []string{\"target2.domain.com\"},\n\t\tRecordTTL:  endpoint.TTL(500),\n\t\tRecordType: endpoint.RecordTypeCNAME,\n\t}\n\tif err := cl.createRecord(t.Context(), ep); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Test create CNAME record with multiple targets and ensure it fails\n\tep = &endpoint.Endpoint{\n\t\tDNSName:    \"source3.example.com\",\n\t\tTargets:    []string{\"target3.domain.com\", \"target4.domain.com\"},\n\t\tRecordType: endpoint.RecordTypeCNAME,\n\t}\n\tif err := cl.createRecord(t.Context(), ep); err == nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Test create a wildcard record and ensure it fails\n\tep = &endpoint.Endpoint{\n\t\tDNSName:    \"*.example.com\",\n\t\tTargets:    []string{\"192.168.1.1\"},\n\t\tRecordType: endpoint.RecordTypeA,\n\t}\n\tif err := cl.createRecord(t.Context(), ep); err == nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Skip not matching domain\n\tep = &endpoint.Endpoint{\n\t\tDNSName:    \"foo.bar.com\",\n\t\tTargets:    []string{\"192.168.1.1\"},\n\t\tRecordType: endpoint.RecordTypeA,\n\t}\n\terr = cl.createRecord(t.Context(), ep)\n\tif err != nil {\n\t\tt.Fatal(\"Should not return error on non filtered domain\")\n\t}\n\n\t// Not supported type\n\tep = &endpoint.Endpoint{\n\t\tDNSName:    \"test.example.com\",\n\t\tTargets:    []string{\"192.168.1.1\"},\n\t\tRecordType: \"not a type\",\n\t}\n\terr = cl.createRecord(t.Context(), ep)\n\tif err != nil {\n\t\tt.Fatal(\"Should not return error on unsupported type\")\n\t}\n\n\t// Create a client\n\tcfgDr := PiholeConfig{\n\t\tServer:       srvr.URL,\n\t\tAPIVersion:   \"6\",\n\t\tDomainFilter: endpoint.NewDomainFilter([]string{\"example.com\"}),\n\t\tDryRun:       true,\n\t}\n\tclDr, err := newPiholeClientV6(cfgDr)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Skip Dry Run\n\tep = &endpoint.Endpoint{\n\t\tDNSName:    \"test.example.com\",\n\t\tTargets:    []string{\"192.168.1.1\"},\n\t\tRecordType: endpoint.RecordTypeA,\n\t}\n\terr = clDr.createRecord(t.Context(), ep)\n\tif err != nil {\n\t\tt.Fatal(\"Should not return error on dry run\")\n\t}\n\t// skip missing targets\n\tep = &endpoint.Endpoint{\n\t\tDNSName:    \"test.example.com\",\n\t\tTargets:    []string{},\n\t\tRecordType: endpoint.RecordTypeA,\n\t}\n\terr = clDr.createRecord(t.Context(), ep)\n\tif err != nil {\n\t\tt.Fatal(\"Should not return error on missing targets\")\n\t}\n}\n\nfunc TestDeleteRecordV6(t *testing.T) {\n\tvar ep *endpoint.Endpoint\n\tsrvr := newTestServerV6(t, func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method == http.MethodDelete && (r.URL.Path == \"/api/config/dns/hosts/192.168.1.1 test.example.com\" ||\n\t\t\tr.URL.Path == \"/api/config/dns/hosts/fc00::1:192:168:1:1 test.example.com\" ||\n\t\t\tr.URL.Path == \"/api/config/dns/cnameRecords/source1.example.com,target1.domain.com\" ||\n\t\t\tr.URL.Path == \"/api/config/dns/cnameRecords/source2.example.com,target2.domain.com,500\") {\n\n\t\t\t// Return A records\n\t\t\tw.WriteHeader(http.StatusNoContent)\n\t\t} else {\n\t\t\thttp.NotFound(w, r)\n\t\t}\n\t})\n\tdefer srvr.Close()\n\n\t// Create a client\n\tcfg := PiholeConfig{\n\t\tServer:     srvr.URL,\n\t\tAPIVersion: \"6\",\n\t}\n\tcl, err := newPiholeClientV6(cfg)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Test delete A record\n\tep = &endpoint.Endpoint{\n\t\tDNSName:    \"test.example.com\",\n\t\tTargets:    []string{\"192.168.1.1\"},\n\t\tRecordType: endpoint.RecordTypeA,\n\t}\n\tif err := cl.deleteRecord(t.Context(), ep); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Test delete AAAA record\n\tep = &endpoint.Endpoint{\n\t\tDNSName:    \"test.example.com\",\n\t\tTargets:    []string{\"fc00::1:192:168:1:1\"},\n\t\tRecordType: endpoint.RecordTypeAAAA,\n\t}\n\tif err := cl.deleteRecord(t.Context(), ep); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Test delete CNAME record\n\tep = &endpoint.Endpoint{\n\t\tDNSName:    \"source1.example.com\",\n\t\tTargets:    []string{\"target1.domain.com\"},\n\t\tRecordType: endpoint.RecordTypeCNAME,\n\t}\n\tif err := cl.deleteRecord(t.Context(), ep); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Test delete CNAME record with TTL\n\tep = &endpoint.Endpoint{\n\t\tDNSName:    \"source2.example.com\",\n\t\tTargets:    []string{\"target2.domain.com\"},\n\t\tRecordTTL:  endpoint.TTL(500),\n\t\tRecordType: endpoint.RecordTypeCNAME,\n\t}\n\tif err := cl.deleteRecord(t.Context(), ep); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n"
  },
  {
    "path": "provider/pihole/client_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage pihole\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\nfunc newTestServer(t *testing.T, hdlr http.HandlerFunc) *httptest.Server {\n\tt.Helper()\n\tsvr := httptest.NewServer(hdlr)\n\treturn svr\n}\n\nfunc TestNewPiholeClient(t *testing.T) {\n\t// Test correct error on no server provided\n\t_, err := newPiholeClient(PiholeConfig{})\n\tif err == nil {\n\t\tt.Error(\"Expected error from config with no server\")\n\t} else if !errors.Is(err, ErrNoPiholeServer) {\n\t\tt.Error(\"Expected ErrNoPiholeServer, got\", err)\n\t}\n\n\t// Test new client with no password. Should create the\n\t// client cleanly.\n\tcl, err := newPiholeClient(PiholeConfig{\n\t\tServer: \"test\",\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, ok := cl.(*piholeClient); !ok {\n\t\tt.Error(\"Did not create a new pihole client\")\n\t}\n\n\t// Create a test server for auth tests\n\tsrvr := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {\n\t\terr := r.ParseForm()\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tpw := r.Form.Get(\"pw\")\n\t\tif pw != \"correct\" {\n\t\t\t// Pihole actually server side renders the fact that you failed, normal 200\n\t\t\t_, err = w.Write([]byte(\"Invalid\"))\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t\t// This is a subset of what happens on successful login\n\t\t_, err = w.Write([]byte(`\n\t\t<!doctype html>\n\t\t<html lang=\"en\">\n\t\t\t<body>\n\t\t\t\t<div id=\"token\" hidden>supersecret</div>\n\t\t\t</body>\n\t\t</html>\n\t\t`))\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t})\n\tdefer srvr.Close()\n\n\t// Test invalid password\n\t_, err = newPiholeClient(\n\t\tPiholeConfig{Server: srvr.URL, Password: \"wrong\"},\n\t)\n\tif err == nil {\n\t\tt.Error(\"Expected error for creating client with invalid password\")\n\t}\n\n\t// Test correct password\n\tcl, err = newPiholeClient(\n\t\tPiholeConfig{Server: srvr.URL, Password: \"correct\"},\n\t)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif cl.(*piholeClient).token != \"supersecret\" {\n\t\tt.Error(\"Parsed invalid token from login response:\", cl.(*piholeClient).token)\n\t}\n}\n\n// Helper function to validate records against expected values\nfunc ValidateRecords(t *testing.T, records []*endpoint.Endpoint, expected [][]string, expectedCount int, recordType string) {\n\tt.Helper()\n\tif len(records) != expectedCount {\n\t\tt.Fatalf(\"Expected %d %s records returned, got: %d\", expectedCount, recordType, len(records))\n\t}\n\tfor idx, rec := range records {\n\t\tif rec.DNSName != expected[idx][0] {\n\t\t\tt.Errorf(\"Got invalid DNS Name: %s, expected: %s\", rec.DNSName, expected[idx][0])\n\t\t}\n\t\tif rec.Targets[0] != expected[idx][1] {\n\t\t\tt.Errorf(\"Got invalid target: %s, expected: %s\", rec.Targets[0], expected[idx][1])\n\t\t}\n\t}\n}\n\n// Helper function to test record retrieval for a specific type\nfunc CheckRecordRetrieval(t *testing.T, cl *piholeClient, recordType string, expected [][]string, expectedCount int) {\n\tt.Helper()\n\trecords, err := cl.listRecords(t.Context(), recordType)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tValidateRecords(t, records, expected, expectedCount, recordType)\n}\n\nfunc TestListRecords(t *testing.T) {\n\tsrvr := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {\n\t\terr := r.ParseForm()\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif r.Form.Get(\"action\") != \"get\" {\n\t\t\tt.Error(\"Expected 'get' action in form from client\")\n\t\t}\n\t\tif strings.Contains(r.URL.Path, \"cname\") {\n\t\t\t_, err = w.Write([]byte(`\n\t\t\t{\n\t\t\t\t\"data\": [\n\t\t\t\t\t[\"test4.example.com\", \"cname.example.com\"],\n\t\t\t\t\t[\"test5.example.com\", \"cname.example.com\"],\n\t\t\t\t\t[\"test6.match.com\", \"cname.example.com\"]\n\t\t\t\t]\n\t\t\t}\n\t\t\t`))\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t\t// Pihole makes no distinction between A and AAAA records\n\t\t_, err = w.Write([]byte(`\n\t\t{\n\t\t\t\"data\": [\n\t\t\t\t[\"test1.example.com\", \"192.168.1.1\"],\n\t\t\t\t[\"test2.example.com\", \"192.168.1.2\"],\n\t\t\t\t[\"test3.match.com\", \"192.168.1.3\"],\n\t\t\t\t[\"test1.example.com\", \"fc00::1:192:168:1:1\"],\n\t\t\t\t[\"test2.example.com\", \"fc00::1:192:168:1:2\"],\n\t\t\t\t[\"test3.match.com\", \"fc00::1:192:168:1:3\"]\n\t\t\t]\n\t\t}\n\t\t`))\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t})\n\tdefer srvr.Close()\n\n\t// Create a client\n\tcfg := PiholeConfig{\n\t\tServer: srvr.URL,\n\t}\n\tcl, err := newPiholeClient(cfg)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Test retrieve A records unfiltered\n\tCheckRecordRetrieval(t, cl.(*piholeClient), endpoint.RecordTypeA, [][]string{\n\t\t{\"test1.example.com\", \"192.168.1.1\"},\n\t\t{\"test2.example.com\", \"192.168.1.2\"},\n\t\t{\"test3.match.com\", \"192.168.1.3\"},\n\t}, 3)\n\n\t// Test retrieve AAAA records unfiltered\n\tCheckRecordRetrieval(t, cl.(*piholeClient), endpoint.RecordTypeAAAA, [][]string{\n\t\t{\"test1.example.com\", \"fc00::1:192:168:1:1\"},\n\t\t{\"test2.example.com\", \"fc00::1:192:168:1:2\"},\n\t\t{\"test3.match.com\", \"fc00::1:192:168:1:3\"},\n\t}, 3)\n\n\t// Test retrieve CNAME records unfiltered\n\tCheckRecordRetrieval(t, cl.(*piholeClient), endpoint.RecordTypeCNAME, [][]string{\n\t\t{\"test4.example.com\", \"cname.example.com\"},\n\t\t{\"test5.example.com\", \"cname.example.com\"},\n\t\t{\"test6.match.com\", \"cname.example.com\"},\n\t}, 3)\n\n\t// Same tests but with a domain filter\n\tcfg.DomainFilter = endpoint.NewDomainFilter([]string{\"match.com\"})\n\tcl, err = newPiholeClient(cfg)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Test retrieve A records filtered\n\tCheckRecordRetrieval(t, cl.(*piholeClient), endpoint.RecordTypeA, [][]string{\n\t\t{\"test3.match.com\", \"192.168.1.3\"},\n\t}, 1)\n\n\t// Test retrieve AAAA records filtered\n\tCheckRecordRetrieval(t, cl.(*piholeClient), endpoint.RecordTypeAAAA, [][]string{\n\t\t{\"test3.match.com\", \"fc00::1:192:168:1:3\"},\n\t}, 1)\n\n\t// Test retrieve CNAME records filtered\n\tCheckRecordRetrieval(t, cl.(*piholeClient), endpoint.RecordTypeCNAME, [][]string{\n\t\t{\"test6.match.com\", \"cname.example.com\"},\n\t}, 1)\n\n}\n\nfunc TestErrorScenarios(t *testing.T) {\n\t// Test errors token\n\tsrvrErr := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {\n\t\terr := r.ParseForm()\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tpw := r.Form.Get(\"pw\")\n\t\tif pw != \"\" {\n\t\t\tif pw != \"correct\" {\n\t\t\t\t_, err = w.Write([]byte(\"Invalid\"))\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatal(err)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tif strings.Contains(r.URL.Path, \"admin/scripts/pi-hole/php/customcname.php\") && r.Form.Get(\"token\") == \"correct\" {\n\t\t\t_, err = w.Write([]byte(`\n\t\t\t\t{\n\t\t\t\t\t\"nodata\": [\n\t\t\t\t\t\t[\"nodata\", \"no\"]\n\t\t\t\t\t]\n\t\t\t\t}\n\t\t\t`))\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\t\t}\n\t})\n\tdefer srvrErr.Close()\n\n\tcfgExpired := PiholeConfig{\n\t\tServer: srvrErr.URL,\n\t}\n\tclExpired, err := newPiholeClient(cfgExpired)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// set clExpired.token to a valid token\n\tclExpired.(*piholeClient).token = \"expired\"\n\tclExpired.(*piholeClient).cfg.Password = \"notcorrect\"\n\n\t_, err = clExpired.listRecords(t.Context(), \"notarealrecordtype\")\n\tif err == nil {\n\t\tt.Fatal(\"Should return error, type is unknown ! \")\n\t}\n\t_, err = clExpired.listRecords(t.Context(), endpoint.RecordTypeCNAME)\n\tif err == nil {\n\t\tt.Fatal(\"Should return error on failed auth ! \")\n\t}\n\tclExpired.(*piholeClient).token = \"correct\"\n\tclExpired.(*piholeClient).cfg.Password = \"correct\"\n\tcnamerecs, err := clExpired.listRecords(t.Context(), endpoint.RecordTypeCNAME)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(cnamerecs) != 0 {\n\t\tt.Fatal(\"Should return empty on missing data in response ! \")\n\t}\n}\n\nfunc TestCreateRecord(t *testing.T) {\n\tvar ep *endpoint.Endpoint\n\tsrvr := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {\n\t\tr.ParseForm()\n\t\tif r.Form.Get(\"action\") != \"add\" {\n\t\t\tt.Error(\"Expected 'add' action in form from client\")\n\t\t}\n\t\tif r.Form.Get(\"domain\") != ep.DNSName {\n\t\t\tt.Error(\"Invalid domain in form:\", r.Form.Get(\"domain\"), \"Expected:\", ep.DNSName)\n\t\t}\n\t\tswitch ep.RecordType {\n\t\tcase endpoint.RecordTypeA:\n\t\t\tif r.Form.Get(\"ip\") != ep.Targets[0] {\n\t\t\t\tt.Error(\"Invalid ip in form:\", r.Form.Get(\"ip\"), \"Expected:\", ep.Targets[0])\n\t\t\t}\n\t\t// Pihole makes no distinction between A and AAAA records\n\t\tcase endpoint.RecordTypeAAAA:\n\t\t\tif r.Form.Get(\"ip\") != ep.Targets[0] {\n\t\t\t\tt.Error(\"Invalid ip in form:\", r.Form.Get(\"ip\"), \"Expected:\", ep.Targets[0])\n\t\t\t}\n\t\tcase endpoint.RecordTypeCNAME:\n\t\t\tif r.Form.Get(\"target\") != ep.Targets[0] {\n\t\t\t\tt.Error(\"Invalid target in form:\", r.Form.Get(\"target\"), \"Expected:\", ep.Targets[0])\n\t\t\t}\n\t\t}\n\t\tout, err := json.Marshal(actionResponse{\n\t\t\tSuccess: true,\n\t\t\tMessage: \"\",\n\t\t})\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tw.Write(out)\n\t})\n\tdefer srvr.Close()\n\n\t// Create a client\n\tcfg := PiholeConfig{\n\t\tServer:       srvr.URL,\n\t\tDomainFilter: endpoint.NewDomainFilter([]string{\"example.com\"}),\n\t}\n\tcl, err := newPiholeClient(cfg)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Test create A record\n\tep = &endpoint.Endpoint{\n\t\tDNSName:    \"test.example.com\",\n\t\tTargets:    []string{\"192.168.1.1\"},\n\t\tRecordType: endpoint.RecordTypeA,\n\t}\n\tif err := cl.createRecord(t.Context(), ep); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Test create AAAA record\n\tep = &endpoint.Endpoint{\n\t\tDNSName:    \"test.example.com\",\n\t\tTargets:    []string{\"fc00::1:192:168:1:1\"},\n\t\tRecordType: endpoint.RecordTypeAAAA,\n\t}\n\tif err := cl.createRecord(t.Context(), ep); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Test create CNAME record\n\tep = &endpoint.Endpoint{\n\t\tDNSName:    \"test.example.com\",\n\t\tTargets:    []string{\"test.cname.com\"},\n\t\tRecordType: endpoint.RecordTypeCNAME,\n\t}\n\tif err := cl.createRecord(t.Context(), ep); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Test create a wildcard record and ensure it fails\n\tep = &endpoint.Endpoint{\n\t\tDNSName:    \"*.example.com\",\n\t\tTargets:    []string{\"192.168.1.1\"},\n\t\tRecordType: endpoint.RecordTypeA,\n\t}\n\tcl.(*piholeClient).token = \"correct\"\n\tif err := cl.createRecord(t.Context(), ep); err == nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Skip not matching domain\n\tep = &endpoint.Endpoint{\n\t\tDNSName:    \"foo.bar.com\",\n\t\tTargets:    []string{\"192.168.1.1\"},\n\t\tRecordType: endpoint.RecordTypeA,\n\t}\n\tif err := cl.createRecord(t.Context(), ep); err != nil {\n\t\tt.Fatal(\"Should not return error on non filtered domain\")\n\t}\n\n\t// Not supported type\n\tep = &endpoint.Endpoint{\n\t\tDNSName:    \"test.example.com\",\n\t\tTargets:    []string{\"192.168.1.1\"},\n\t\tRecordType: \"not a type\",\n\t}\n\tif err := cl.createRecord(t.Context(), ep); err != nil {\n\t\tt.Fatal(\"Should not return error on unsupported type\")\n\t}\n\n\t// Create a client\n\tcfgDr := PiholeConfig{\n\t\tServer:       srvr.URL,\n\t\tDomainFilter: endpoint.NewDomainFilter([]string{\"example.com\"}),\n\t\tDryRun:       true,\n\t}\n\tclDr, err := newPiholeClient(cfgDr)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Skip Dry Run\n\tep = &endpoint.Endpoint{\n\t\tDNSName:    \"test.example.com\",\n\t\tTargets:    []string{\"192.168.1.1\"},\n\t\tRecordType: endpoint.RecordTypeA,\n\t}\n\tif err := clDr.createRecord(t.Context(), ep); err != nil {\n\t\tt.Fatal(\"Should not return error on dry run\")\n\t}\n\n}\n\nfunc TestDeleteRecord(t *testing.T) {\n\tvar ep *endpoint.Endpoint\n\tsrvr := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {\n\t\tr.ParseForm()\n\t\tif r.Form.Get(\"action\") != \"delete\" {\n\t\t\tt.Error(\"Expected 'delete' action in form from client\")\n\t\t}\n\t\tif r.Form.Get(\"domain\") != ep.DNSName {\n\t\t\tt.Error(\"Invalid domain in form:\", r.Form.Get(\"domain\"), \"Expected:\", ep.DNSName)\n\t\t}\n\t\tswitch ep.RecordType {\n\t\tcase endpoint.RecordTypeA:\n\t\t\tif r.Form.Get(\"ip\") != ep.Targets[0] {\n\t\t\t\tt.Error(\"Invalid ip in form:\", r.Form.Get(\"ip\"), \"Expected:\", ep.Targets[0])\n\t\t\t}\n\t\t// Pihole makes no distinction between A and AAAA records\n\t\tcase endpoint.RecordTypeAAAA:\n\t\t\tif r.Form.Get(\"ip\") != ep.Targets[0] {\n\t\t\t\tt.Error(\"Invalid ip in form:\", r.Form.Get(\"ip\"), \"Expected:\", ep.Targets[0])\n\t\t\t}\n\t\tcase endpoint.RecordTypeCNAME:\n\t\t\tif r.Form.Get(\"target\") != ep.Targets[0] {\n\t\t\t\tt.Error(\"Invalid target in form:\", r.Form.Get(\"target\"), \"Expected:\", ep.Targets[0])\n\t\t\t}\n\t\t}\n\t\tout, err := json.Marshal(actionResponse{\n\t\t\tSuccess: true,\n\t\t\tMessage: \"\",\n\t\t})\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tw.Write(out)\n\t})\n\tdefer srvr.Close()\n\n\t// Create a client\n\tcfg := PiholeConfig{\n\t\tServer: srvr.URL,\n\t}\n\tcl, err := newPiholeClient(cfg)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Test delete A record\n\tep = &endpoint.Endpoint{\n\t\tDNSName:    \"test.example.com\",\n\t\tTargets:    []string{\"192.168.1.1\"},\n\t\tRecordType: endpoint.RecordTypeA,\n\t}\n\tif err := cl.deleteRecord(t.Context(), ep); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Test delete AAAA record\n\tep = &endpoint.Endpoint{\n\t\tDNSName:    \"test.example.com\",\n\t\tTargets:    []string{\"fc00::1:192:168:1:1\"},\n\t\tRecordType: endpoint.RecordTypeAAAA,\n\t}\n\tif err := cl.deleteRecord(t.Context(), ep); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Test delete CNAME record\n\tep = &endpoint.Endpoint{\n\t\tDNSName:    \"test.example.com\",\n\t\tTargets:    []string{\"test.cname.com\"},\n\t\tRecordType: endpoint.RecordTypeCNAME,\n\t}\n\tif err := cl.deleteRecord(t.Context(), ep); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n"
  },
  {
    "path": "provider/pihole/pihole.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage pihole\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"slices\"\n\n\t\"github.com/google/go-cmp/cmp\"\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\n// ErrNoPiholeServer is returned when there is no Pihole server configured\n// in the environment.\nvar ErrNoPiholeServer = errors.New(\"no pihole server found in the environment or flags\")\n\nconst (\n\twarningMsg = \"Pi-hole v5 API support is deprecated. Set --pihole-api-version=\\\"6\\\" to use the Pi-hole v6 API. The v5 API will be removed in a future release.\"\n)\n\n// PiholeProvider is an implementation of Provider for Pi-hole Local DNS.\ntype PiholeProvider struct {\n\tprovider.BaseProvider\n\tapi        piholeAPI\n\tapiVersion string\n}\n\n// PiholeConfig is used for configuring a PiholeProvider.\ntype PiholeConfig struct {\n\t// The root URL of the Pi-hole server.\n\tServer string\n\t// An optional password if the server is protected.\n\tPassword string\n\t// Disable verification of TLS certificates.\n\tTLSInsecureSkipVerify bool\n\t// A filter to apply when looking up and applying records.\n\tDomainFilter *endpoint.DomainFilter\n\t// Do nothing and log what would have changed to stdout.\n\tDryRun bool\n\t// PiHole API version =<5 or >=6, default is 5\n\tAPIVersion string\n}\n\n// Helper struct for de-duping DNS entry updates.\ntype piholeEntryKey struct {\n\tTarget     string\n\tRecordType string\n}\n\n// New creates a Pi-hole provider from the given configuration.\nfunc New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) {\n\treturn newProvider(\n\t\tPiholeConfig{\n\t\t\tServer:                cfg.PiholeServer,\n\t\t\tPassword:              cfg.PiholePassword,\n\t\t\tTLSInsecureSkipVerify: cfg.PiholeTLSInsecureSkipVerify,\n\t\t\tDomainFilter:          domainFilter,\n\t\t\tDryRun:                cfg.DryRun,\n\t\t\tAPIVersion:            cfg.PiholeApiVersion,\n\t\t},\n\t)\n}\n\n// newProvider initializes a new Pi-hole Local DNS based Provider.\nfunc newProvider(cfg PiholeConfig) (*PiholeProvider, error) {\n\tvar api piholeAPI\n\tvar err error\n\tswitch cfg.APIVersion {\n\tcase \"6\":\n\t\tapi, err = newPiholeClientV6(cfg)\n\tdefault:\n\t\tlog.Warn(warningMsg)\n\t\tapi, err = newPiholeClient(cfg)\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &PiholeProvider{api: api, apiVersion: cfg.APIVersion}, nil\n}\n\n// Records implements Provider, populating a slice of endpoints from\n// Pi-Hole local DNS.\nfunc (p *PiholeProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {\n\taRecords, err := p.api.listRecords(ctx, endpoint.RecordTypeA)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\taaaaRecords, err := p.api.listRecords(ctx, endpoint.RecordTypeAAAA)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcnameRecords, err := p.api.listRecords(ctx, endpoint.RecordTypeCNAME)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\taRecords = append(aRecords, aaaaRecords...)\n\treturn append(aRecords, cnameRecords...), nil\n}\n\n// ApplyChanges implements Provider, syncing desired state with the Pi-hole server Local DNS.\nfunc (p *PiholeProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {\n\t// Handle pure deletes first.\n\tfor _, ep := range changes.Delete {\n\t\tif err := p.api.deleteRecord(ctx, ep); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// Handle updated state - there are no endpoints for updating in place.\n\tupdateNew := make(map[piholeEntryKey]*endpoint.Endpoint)\n\tfor _, ep := range changes.UpdateNew {\n\t\tkey := piholeEntryKey{ep.DNSName, ep.RecordType}\n\n\t\t// If the API version is 6, we need to handle multiple targets for the same DNS name.\n\t\tif p.apiVersion == \"6\" {\n\t\t\tif existing, ok := updateNew[key]; ok {\n\t\t\t\texisting.Targets = append(existing.Targets, ep.Targets...)\n\n\t\t\t\t// Deduplicate targets\n\t\t\t\tslices.Sort(existing.Targets)\n\t\t\t\texisting.Targets = slices.Compact(existing.Targets)\n\n\t\t\t\tep = existing\n\t\t\t}\n\t\t}\n\t\tupdateNew[key] = ep\n\t}\n\n\tfor _, ep := range changes.UpdateOld {\n\t\t// Check if this existing entry has an exact match for an updated entry and skip it if so.\n\t\tkey := piholeEntryKey{ep.DNSName, ep.RecordType}\n\t\tif newRecord := updateNew[key]; newRecord != nil {\n\t\t\t// If the API version is 6, we need to handle multiple targets for the same DNS name.\n\t\t\tif p.apiVersion == \"6\" {\n\t\t\t\tif cmp.Diff(ep.Targets, newRecord.Targets) == \"\" {\n\t\t\t\t\tdelete(updateNew, key)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// For API version <= 5, we only check the first target.\n\t\t\t\tif newRecord.Targets[0] == ep.Targets[0] {\n\t\t\t\t\tdelete(updateNew, key)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif err := p.api.deleteRecord(ctx, ep); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\n\t// Handle pure creates before applying new updated state.\n\tfor _, ep := range changes.Create {\n\t\tif err := p.api.createRecord(ctx, ep); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tfor _, ep := range updateNew {\n\t\tif err := p.api.createRecord(ctx, ep); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "provider/pihole/piholeV6_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage pihole\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"testing\"\n\n\t\"github.com/google/go-cmp/cmp\"\n\t\"github.com/google/go-cmp/cmp/cmpopts\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/plan\"\n)\n\nvar (\n\tendpointSort = cmpopts.SortSlices(func(x, y *endpoint.Endpoint) bool {\n\t\tif x.DNSName < y.DNSName {\n\t\t\treturn true\n\t\t}\n\t\tif x.DNSName > y.DNSName {\n\t\t\treturn false\n\t\t}\n\t\tif x.RecordType < y.RecordType {\n\t\t\treturn true\n\t\t}\n\t\tif x.RecordType > y.RecordType {\n\t\t\treturn false\n\t\t}\n\t\treturn x.Targets.String() < y.Targets.String()\n\t})\n)\n\ntype testPiholeClientV6 struct {\n\tendpoints []*endpoint.Endpoint\n\trequests  *requestTrackerV6\n\ttrigger   string\n}\n\nfunc (t *testPiholeClientV6) listRecords(_ context.Context, rtype string) ([]*endpoint.Endpoint, error) {\n\tout := make([]*endpoint.Endpoint, 0)\n\tif t.trigger == \"AERROR\" {\n\t\treturn nil, errors.New(\"AERROR\")\n\t}\n\tif t.trigger == \"AAAAERROR\" {\n\t\treturn nil, errors.New(\"AAAAERROR\")\n\t}\n\tif t.trigger == \"CNAMEERROR\" {\n\t\treturn nil, errors.New(\"CNAMEERROR\")\n\t}\n\tfor _, ep := range t.endpoints {\n\t\tif ep.RecordType == rtype {\n\t\t\tout = append(out, ep)\n\t\t}\n\t}\n\treturn out, nil\n}\n\nfunc (t *testPiholeClientV6) createRecord(_ context.Context, ep *endpoint.Endpoint) error {\n\tt.endpoints = append(t.endpoints, ep)\n\tt.requests.createRequests = append(t.requests.createRequests, ep)\n\treturn nil\n}\n\nfunc (t *testPiholeClientV6) deleteRecord(_ context.Context, ep *endpoint.Endpoint) error {\n\tnewEPs := make([]*endpoint.Endpoint, 0)\n\tfor _, existing := range t.endpoints {\n\t\tif existing.DNSName != ep.DNSName || cmp.Diff(existing.Targets, ep.Targets) != \"\" || existing.RecordType != ep.RecordType {\n\t\t\tnewEPs = append(newEPs, existing)\n\t\t}\n\t}\n\tt.endpoints = newEPs\n\tt.requests.deleteRequests = append(t.requests.deleteRequests, ep)\n\treturn nil\n}\n\ntype requestTrackerV6 struct {\n\tcreateRequests []*endpoint.Endpoint\n\tdeleteRequests []*endpoint.Endpoint\n}\n\nfunc (r *requestTrackerV6) clear() {\n\tr.createRequests = nil\n\tr.deleteRequests = nil\n}\n\nfunc TestErrorHandling(t *testing.T) {\n\trequests := requestTrackerV6{}\n\tp := &PiholeProvider{\n\t\tapi:        &testPiholeClientV6{endpoints: make([]*endpoint.Endpoint, 0), requests: &requests},\n\t\tapiVersion: \"6\",\n\t}\n\n\tp.api.(*testPiholeClientV6).trigger = \"AERROR\"\n\t_, err := p.Records(t.Context())\n\tif err.Error() != \"AERROR\" {\n\t\tt.Fatal(err)\n\t}\n\n\tp.api.(*testPiholeClientV6).trigger = \"AAAAERROR\"\n\t_, err = p.Records(t.Context())\n\tif err.Error() != \"AAAAERROR\" {\n\t\tt.Fatal(err)\n\t}\n\n\tp.api.(*testPiholeClientV6).trigger = \"CNAMEERROR\"\n\t_, err = p.Records(t.Context())\n\tif err.Error() != \"CNAMEERROR\" {\n\t\tt.Fatal(err)\n\t}\n\n}\n\nfunc TestNewPiholeProviderV6(t *testing.T) {\n\t// Test invalid configuration\n\t_, err := newProvider(PiholeConfig{APIVersion: \"7\"})\n\tif err == nil {\n\t\tt.Error(\"Expected error from invalid configuration\")\n\t}\n\t// Test valid configuration\n\t_, err = newProvider(PiholeConfig{Server: \"test.example.com\", APIVersion: \"6\"})\n\tif err != nil {\n\t\tt.Error(\"Expected no error from valid configuration, got:\", err)\n\t}\n}\n\nfunc TestProviderV6(t *testing.T) {\n\trequests := requestTrackerV6{}\n\tp := &PiholeProvider{\n\t\tapi:        &testPiholeClientV6{endpoints: make([]*endpoint.Endpoint, 0), requests: &requests},\n\t\tapiVersion: \"6\",\n\t}\n\n\tt.Run(\"Initial Records\", func(t *testing.T) {\n\t\trecords, err := p.Records(t.Context())\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif len(records) != 0 {\n\t\t\tt.Fatal(\"Expected empty list of records, got:\", records)\n\t\t}\n\t})\n\n\tt.Run(\"Create Records\", func(t *testing.T) {\n\t\trecords := []*endpoint.Endpoint{\n\t\t\t{DNSName: \"test1.example.com\", Targets: []string{\"192.168.1.1\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t{DNSName: \"test2.example.com\", Targets: []string{\"192.168.1.2\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t{DNSName: \"test3.example.com\", Targets: []string{\"192.168.1.3\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t{DNSName: \"test1.example.com\", Targets: []string{\"fc00::1:192:168:1:1\"}, RecordType: endpoint.RecordTypeAAAA},\n\t\t\t{DNSName: \"test2.example.com\", Targets: []string{\"fc00::1:192:168:1:2\"}, RecordType: endpoint.RecordTypeAAAA},\n\t\t\t{DNSName: \"test3.example.com\", Targets: []string{\"fc00::1:192:168:1:3\"}, RecordType: endpoint.RecordTypeAAAA},\n\t\t}\n\t\tif err := p.ApplyChanges(t.Context(), &plan.Changes{Create: records}); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\tnewRecords, err := p.Records(t.Context())\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif !cmp.Equal(newRecords, records, cmpopts.IgnoreUnexported(endpoint.Endpoint{}), endpointSort) {\n\t\t\tt.Error(\"Records are not equal:\", cmp.Diff(newRecords, records, cmpopts.IgnoreUnexported(endpoint.Endpoint{}), endpointSort))\n\t\t}\n\t\tif !cmp.Equal(requests.createRequests, records, cmpopts.IgnoreUnexported(endpoint.Endpoint{}), endpointSort) {\n\t\t\tt.Error(\"Create requests are not equal:\", cmp.Diff(requests.createRequests, records, cmpopts.IgnoreUnexported(endpoint.Endpoint{}), endpointSort))\n\t\t}\n\t\tif len(requests.deleteRequests) != 0 {\n\t\t\tt.Fatal(\"Expected no delete requests, got:\", requests.deleteRequests)\n\t\t}\n\t\trequests.clear()\n\t})\n\n\tt.Run(\"Delete Records\", func(t *testing.T) {\n\t\trecordToDeleteA := &endpoint.Endpoint{DNSName: \"test3.example.com\", Targets: []string{\"192.168.1.3\"}, RecordType: endpoint.RecordTypeA}\n\t\tif err := p.ApplyChanges(t.Context(), &plan.Changes{Delete: []*endpoint.Endpoint{recordToDeleteA}}); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\trecordToDeleteAAAA := &endpoint.Endpoint{DNSName: \"test3.example.com\", Targets: []string{\"fc00::1:192:168:1:3\"}, RecordType: endpoint.RecordTypeAAAA}\n\t\tif err := p.ApplyChanges(t.Context(), &plan.Changes{Delete: []*endpoint.Endpoint{recordToDeleteAAAA}}); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\texpectedRecords := []*endpoint.Endpoint{\n\t\t\t{DNSName: \"test1.example.com\", Targets: []string{\"192.168.1.1\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t{DNSName: \"test2.example.com\", Targets: []string{\"192.168.1.2\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t{DNSName: \"test1.example.com\", Targets: []string{\"fc00::1:192:168:1:1\"}, RecordType: endpoint.RecordTypeAAAA},\n\t\t\t{DNSName: \"test2.example.com\", Targets: []string{\"fc00::1:192:168:1:2\"}, RecordType: endpoint.RecordTypeAAAA},\n\t\t}\n\t\tnewRecords, err := p.Records(t.Context())\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif !cmp.Equal(newRecords, expectedRecords, cmpopts.IgnoreUnexported(endpoint.Endpoint{}), endpointSort) {\n\t\t\tt.Error(\"Records are not equal:\", cmp.Diff(newRecords, expectedRecords, cmpopts.IgnoreUnexported(endpoint.Endpoint{}), endpointSort))\n\t\t}\n\t\tif len(requests.createRequests) != 0 {\n\t\t\tt.Fatal(\"Expected no create requests, got:\", requests.createRequests)\n\t\t}\n\t\texpectedDeletes := []*endpoint.Endpoint{recordToDeleteA, recordToDeleteAAAA}\n\t\tif !cmp.Equal(requests.deleteRequests, expectedDeletes, cmpopts.IgnoreUnexported(endpoint.Endpoint{}), endpointSort) {\n\t\t\tt.Error(\"Delete requests are not equal:\", cmp.Diff(requests.deleteRequests, expectedDeletes, cmpopts.IgnoreUnexported(endpoint.Endpoint{}), endpointSort))\n\t\t}\n\t\trequests.clear()\n\t})\n\n\tt.Run(\"Update Records\", func(t *testing.T) {\n\t\tupdateOld := []*endpoint.Endpoint{\n\t\t\t{DNSName: \"test2.example.com\", Targets: []string{\"192.168.1.2\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t{DNSName: \"test2.example.com\", Targets: []string{\"fc00::1:192:168:1:2\"}, RecordType: endpoint.RecordTypeAAAA},\n\t\t}\n\t\tupdateNew := []*endpoint.Endpoint{\n\t\t\t{DNSName: \"test2.example.com\", Targets: []string{\"10.0.0.1\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t{DNSName: \"test2.example.com\", Targets: []string{\"fc00::1:10:0:0:1\"}, RecordType: endpoint.RecordTypeAAAA},\n\t\t}\n\t\tif err := p.ApplyChanges(t.Context(), &plan.Changes{UpdateOld: updateOld, UpdateNew: updateNew}); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\texpectedRecords := []*endpoint.Endpoint{\n\t\t\t{DNSName: \"test1.example.com\", Targets: []string{\"192.168.1.1\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t{DNSName: \"test2.example.com\", Targets: []string{\"10.0.0.1\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t{DNSName: \"test1.example.com\", Targets: []string{\"fc00::1:192:168:1:1\"}, RecordType: endpoint.RecordTypeAAAA},\n\t\t\t{DNSName: \"test2.example.com\", Targets: []string{\"fc00::1:10:0:0:1\"}, RecordType: endpoint.RecordTypeAAAA},\n\t\t}\n\t\tnewRecords, err := p.Records(t.Context())\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif !cmp.Equal(newRecords, expectedRecords, cmpopts.IgnoreUnexported(endpoint.Endpoint{}), endpointSort) {\n\t\t\tt.Error(\"Records are not equal:\", cmp.Diff(newRecords, expectedRecords, cmpopts.IgnoreUnexported(endpoint.Endpoint{}), endpointSort))\n\t\t}\n\t\tif !cmp.Equal(requests.createRequests, updateNew, cmpopts.IgnoreUnexported(endpoint.Endpoint{}), endpointSort) {\n\t\t\tt.Error(\"Create requests are not equal:\", cmp.Diff(requests.createRequests, updateNew, cmpopts.IgnoreUnexported(endpoint.Endpoint{}), endpointSort))\n\t\t}\n\t\tif !cmp.Equal(requests.deleteRequests, updateOld, cmpopts.IgnoreUnexported(endpoint.Endpoint{})) {\n\t\t\tt.Error(\"Delete requests are not equal:\", cmp.Diff(requests.deleteRequests, updateOld, cmpopts.IgnoreUnexported(endpoint.Endpoint{})))\n\t\t}\n\t\trequests.clear()\n\t})\n}\n\nfunc TestProviderV6MultipleTargets(t *testing.T) {\n\trequests := requestTrackerV6{}\n\tp := &PiholeProvider{\n\t\tapi:        &testPiholeClientV6{endpoints: make([]*endpoint.Endpoint, 0), requests: &requests},\n\t\tapiVersion: \"6\",\n\t}\n\n\tt.Run(\"Update with multiple targets - merge and deduplicate\", func(t *testing.T) {\n\t\t// Create initial records with multiple targets\n\t\tinitialRecords := []*endpoint.Endpoint{\n\t\t\t{DNSName: \"multi.example.com\", Targets: []string{\"192.168.1.1\", \"192.168.1.2\"}, RecordType: endpoint.RecordTypeA},\n\t\t}\n\t\tif err := p.ApplyChanges(t.Context(), &plan.Changes{Create: initialRecords}); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\trequests.clear()\n\n\t\t// Update with new targets that should be merged\n\t\tupdateOld := []*endpoint.Endpoint{\n\t\t\t{DNSName: \"multi.example.com\", Targets: []string{\"192.168.1.1\", \"192.168.1.2\"}, RecordType: endpoint.RecordTypeA},\n\t\t}\n\t\tupdateNew := []*endpoint.Endpoint{\n\t\t\t{DNSName: \"multi.example.com\", Targets: []string{\"192.168.1.3\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t{DNSName: \"multi.example.com\", Targets: []string{\"192.168.1.4\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t{DNSName: \"multi.example.com\", Targets: []string{\"192.168.1.3\"}, RecordType: endpoint.RecordTypeA}, // Duplicate to test deduplication\n\t\t}\n\t\tif err := p.ApplyChanges(t.Context(), &plan.Changes{UpdateOld: updateOld, UpdateNew: updateNew}); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\t// Verify that targets were merged and deduplicated\n\t\texpectedCreate := []*endpoint.Endpoint{\n\t\t\t{DNSName: \"multi.example.com\", Targets: []string{\"192.168.1.3\", \"192.168.1.4\"}, RecordType: endpoint.RecordTypeA},\n\t\t}\n\t\tif len(requests.createRequests) != 1 {\n\t\t\tt.Fatalf(\"Expected 1 create request, got %d\", len(requests.createRequests))\n\t\t}\n\t\tif !cmp.Equal(requests.createRequests[0].Targets, expectedCreate[0].Targets) {\n\t\t\tt.Error(\"Targets not merged correctly:\", cmp.Diff(requests.createRequests[0].Targets, expectedCreate[0].Targets))\n\t\t}\n\t\tif len(requests.deleteRequests) != 1 {\n\t\t\tt.Fatalf(\"Expected 1 delete request, got %d\", len(requests.deleteRequests))\n\t\t}\n\t\trequests.clear()\n\t})\n\n\tt.Run(\"Update with exact match - should skip delete\", func(t *testing.T) {\n\t\t// Update where old and new have the same targets (exact match)\n\t\tupdateOld := []*endpoint.Endpoint{\n\t\t\t{DNSName: \"multi.example.com\", Targets: []string{\"192.168.1.3\", \"192.168.1.4\"}, RecordType: endpoint.RecordTypeA},\n\t\t}\n\t\tupdateNew := []*endpoint.Endpoint{\n\t\t\t{DNSName: \"multi.example.com\", Targets: []string{\"192.168.1.3\", \"192.168.1.4\"}, RecordType: endpoint.RecordTypeA},\n\t\t}\n\t\tif err := p.ApplyChanges(t.Context(), &plan.Changes{UpdateOld: updateOld, UpdateNew: updateNew}); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\t// Should not create or delete anything since targets are the same\n\t\tif len(requests.createRequests) != 0 {\n\t\t\tt.Fatalf(\"Expected no create requests for exact match, got %d\", len(requests.createRequests))\n\t\t}\n\t\tif len(requests.deleteRequests) != 0 {\n\t\t\tt.Fatalf(\"Expected no delete requests for exact match, got %d\", len(requests.deleteRequests))\n\t\t}\n\t\trequests.clear()\n\t})\n}\n"
  },
  {
    "path": "provider/pihole/pihole_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage pihole\n\nimport (\n\t\"context\"\n\t\"reflect\"\n\t\"testing\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\tlogtest \"sigs.k8s.io/external-dns/internal/testutils/log\"\n\t\"sigs.k8s.io/external-dns/plan\"\n)\n\ntype testPiholeClient struct {\n\tendpoints []*endpoint.Endpoint\n\trequests  *requestTracker\n}\n\nfunc (t *testPiholeClient) listRecords(_ context.Context, rtype string) ([]*endpoint.Endpoint, error) {\n\tout := make([]*endpoint.Endpoint, 0)\n\tfor _, ep := range t.endpoints {\n\t\tif ep.RecordType == rtype {\n\t\t\tout = append(out, ep)\n\t\t}\n\t}\n\treturn out, nil\n}\n\nfunc (t *testPiholeClient) createRecord(_ context.Context, ep *endpoint.Endpoint) error {\n\tt.endpoints = append(t.endpoints, ep)\n\tt.requests.createRequests = append(t.requests.createRequests, ep)\n\treturn nil\n}\n\nfunc (t *testPiholeClient) deleteRecord(_ context.Context, ep *endpoint.Endpoint) error {\n\tnewEPs := make([]*endpoint.Endpoint, 0)\n\tfor _, existing := range t.endpoints {\n\t\tif existing.DNSName != ep.DNSName && existing.Targets[0] != ep.Targets[0] {\n\t\t\tnewEPs = append(newEPs, existing)\n\t\t}\n\t}\n\tt.endpoints = newEPs\n\tt.requests.deleteRequests = append(t.requests.deleteRequests, ep)\n\treturn nil\n}\n\ntype requestTracker struct {\n\tcreateRequests []*endpoint.Endpoint\n\tdeleteRequests []*endpoint.Endpoint\n}\n\nfunc (r *requestTracker) clear() {\n\tr.createRequests = nil\n\tr.deleteRequests = nil\n}\n\nfunc TestNewProvider(t *testing.T) {\n\t// Test invalid configuration\n\t_, err := newProvider(PiholeConfig{})\n\tif err == nil {\n\t\tt.Error(\"Expected error from invalid configuration\")\n\t}\n\t// Test valid configuration\n\t_, err = newProvider(PiholeConfig{Server: \"test.example.com\"})\n\tif err != nil {\n\t\tt.Error(\"Expected no error from valid configuration, got:\", err)\n\t}\n}\n\nfunc TestNewPiholeProvider_APIVersions(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tconfig  PiholeConfig\n\t\twantMsg bool\n\t}{\n\t\t{\n\t\t\tname: \"API version 5 with server\",\n\t\t\tconfig: PiholeConfig{\n\t\t\t\tAPIVersion: \"5\",\n\t\t\t\tServer:     \"test.example.com\",\n\t\t\t},\n\t\t\twantMsg: true,\n\t\t},\n\t\t{\n\t\t\tname: \"API version 6 with server\",\n\t\t\tconfig: PiholeConfig{\n\t\t\t\tAPIVersion: \"6\",\n\t\t\t\tServer:     \"test.example.com\",\n\t\t\t},\n\t\t\twantMsg: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\thook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t)\n\t\t\t_, err := newProvider(tt.config)\n\t\t\trequire.NoError(t, err)\n\t\t\tif tt.wantMsg {\n\t\t\t\tlogtest.TestHelperLogContains(warningMsg, hook, t)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestProvider_InitialState(t *testing.T) {\n\trequests := requestTracker{}\n\tp := &PiholeProvider{\n\t\tapi: &testPiholeClient{endpoints: make([]*endpoint.Endpoint, 0), requests: &requests},\n\t}\n\trecords, err := p.Records(t.Context())\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(records) != 0 {\n\t\tt.Fatal(\"Expected empty list of records, got:\", records)\n\t}\n}\n\nfunc TestProvider_CreateRecords(t *testing.T) {\n\trequests := requestTracker{}\n\tp := &PiholeProvider{\n\t\tapi: &testPiholeClient{endpoints: make([]*endpoint.Endpoint, 0), requests: &requests},\n\t}\n\trecords := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"test1.example.com\",\n\t\t\tTargets:    []string{\"192.168.1.1\"},\n\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"test2.example.com\",\n\t\t\tTargets:    []string{\"192.168.1.2\"},\n\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"test3.example.com\",\n\t\t\tTargets:    []string{\"192.168.1.3\"},\n\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"test1.example.com\",\n\t\t\tTargets:    []string{\"fc00::1:192:168:1:1\"},\n\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"test2.example.com\",\n\t\t\tTargets:    []string{\"fc00::1:192:168:1:2\"},\n\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"test3.example.com\",\n\t\t\tTargets:    []string{\"fc00::1:192:168:1:3\"},\n\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t},\n\t}\n\tif err := p.ApplyChanges(t.Context(), &plan.Changes{\n\t\tCreate: records,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnewRecords, err := p.Records(t.Context())\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(newRecords) != 6 {\n\t\tt.Fatal(\"Expected list of 6 records, got:\", records)\n\t}\n\tif len(requests.createRequests) != 6 {\n\t\tt.Fatal(\"Expected 6 create requests, got:\", requests.createRequests)\n\t}\n\tif len(requests.deleteRequests) != 0 {\n\t\tt.Fatal(\"Expected no delete requests, got:\", requests.deleteRequests)\n\t}\n\tfor idx, record := range records {\n\t\tif newRecords[idx].DNSName != record.DNSName {\n\t\t\tt.Error(\"DNS Name malformed on retrieval, got:\", newRecords[idx].DNSName, \"expected:\", record.DNSName)\n\t\t}\n\t\tif newRecords[idx].Targets[0] != record.Targets[0] {\n\t\t\tt.Error(\"Targets malformed on retrieval, got:\", newRecords[idx].Targets, \"expected:\", record.Targets)\n\t\t}\n\t\tif !reflect.DeepEqual(requests.createRequests[idx], record) {\n\t\t\tt.Error(\"Unexpected create request, got:\", newRecords[idx].DNSName, \"expected:\", record.DNSName)\n\t\t}\n\t}\n\trequests.clear()\n}\n\nfunc TestProvider_DeleteRecords(t *testing.T) {\n\trequests := requestTracker{}\n\tp := &PiholeProvider{\n\t\tapi: &testPiholeClient{endpoints: make([]*endpoint.Endpoint, 0), requests: &requests},\n\t}\n\trecords := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"test1.example.com\",\n\t\t\tTargets:    []string{\"192.168.1.1\"},\n\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"test2.example.com\",\n\t\t\tTargets:    []string{\"192.168.1.2\"},\n\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"test1.example.com\",\n\t\t\tTargets:    []string{\"fc00::1:192:168:1:1\"},\n\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"test2.example.com\",\n\t\t\tTargets:    []string{\"fc00::1:192:168:1:2\"},\n\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t},\n\t}\n\t// Create initial records\n\tif err := p.ApplyChanges(t.Context(), &plan.Changes{\n\t\tCreate: records,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\trecordToDeleteA := endpoint.Endpoint{\n\t\tDNSName:    \"test3.example.com\",\n\t\tTargets:    []string{\"192.168.1.3\"},\n\t\tRecordType: endpoint.RecordTypeA,\n\t}\n\tif err := p.ApplyChanges(t.Context(), &plan.Changes{\n\t\tDelete: []*endpoint.Endpoint{\n\t\t\t&recordToDeleteA,\n\t\t},\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\trecordToDeleteAAAA := endpoint.Endpoint{\n\t\tDNSName:    \"test3.example.com\",\n\t\tTargets:    []string{\"fc00::1:192:168:1:3\"},\n\t\tRecordType: endpoint.RecordTypeAAAA,\n\t}\n\tif err := p.ApplyChanges(t.Context(), &plan.Changes{\n\t\tDelete: []*endpoint.Endpoint{\n\t\t\t&recordToDeleteAAAA,\n\t\t},\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnewRecords, err := p.Records(t.Context())\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(newRecords) != 4 {\n\t\tt.Fatal(\"Expected list of 4 records, got:\", records)\n\t}\n\tif len(requests.createRequests) != 4 {\n\t\tt.Fatal(\"Expected 4 create requests, got:\", requests.createRequests)\n\t}\n\tif len(requests.deleteRequests) != 2 {\n\t\tt.Fatal(\"Expected 2 delete request, got:\", requests.deleteRequests)\n\t}\n\tfor idx, record := range records {\n\t\tif newRecords[idx].DNSName != record.DNSName {\n\t\t\tt.Error(\"DNS Name malformed on retrieval, got:\", newRecords[idx].DNSName, \"expected:\", record.DNSName)\n\t\t}\n\t\tif newRecords[idx].Targets[0] != record.Targets[0] {\n\t\t\tt.Error(\"Targets malformed on retrieval, got:\", newRecords[idx].Targets, \"expected:\", record.Targets)\n\t\t}\n\t}\n\tif !reflect.DeepEqual(requests.deleteRequests[0], &recordToDeleteA) {\n\t\tt.Error(\"Unexpected delete request, got:\", requests.deleteRequests[0], \"expected:\", recordToDeleteA)\n\t}\n\tif !reflect.DeepEqual(requests.deleteRequests[1], &recordToDeleteAAAA) {\n\t\tt.Error(\"Unexpected delete request, got:\", requests.deleteRequests[1], \"expected:\", recordToDeleteAAAA)\n\t}\n\trequests.clear()\n}\n\nfunc TestProvider_UpdateRecords(t *testing.T) {\n\trequests := requestTracker{}\n\tp := &PiholeProvider{\n\t\tapi: &testPiholeClient{endpoints: make([]*endpoint.Endpoint, 0), requests: &requests},\n\t}\n\t// Create initial records\n\tinitialRecords := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"test1.example.com\",\n\t\t\tTargets:    []string{\"192.168.1.1\"},\n\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"test2.example.com\",\n\t\t\tTargets:    []string{\"192.168.1.2\"},\n\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"test1.example.com\",\n\t\t\tTargets:    []string{\"fc00::1:192:168:1:1\"},\n\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"test2.example.com\",\n\t\t\tTargets:    []string{\"fc00::1:192:168:1:2\"},\n\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t},\n\t}\n\tif err := p.ApplyChanges(t.Context(), &plan.Changes{\n\t\tCreate: initialRecords,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\trequests.clear()\n\t// Update records\n\tupdateOld := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"test1.example.com\",\n\t\t\tTargets:    []string{\"192.168.1.1\"},\n\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"test2.example.com\",\n\t\t\tTargets:    []string{\"192.168.1.2\"},\n\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"test1.example.com\",\n\t\t\tTargets:    []string{\"fc00::1:192:168:1:1\"},\n\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"test2.example.com\",\n\t\t\tTargets:    []string{\"fc00::1:192:168:1:2\"},\n\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t},\n\t}\n\tupdateNew := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"test1.example.com\",\n\t\t\tTargets:    []string{\"192.168.1.1\"},\n\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"test2.example.com\",\n\t\t\tTargets:    []string{\"10.0.0.1\"},\n\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"test1.example.com\",\n\t\t\tTargets:    []string{\"fc00::1:192:168:1:1\"},\n\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"test2.example.com\",\n\t\t\tTargets:    []string{\"fc00::1:10:0:0:1\"},\n\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t},\n\t}\n\tif err := p.ApplyChanges(t.Context(), &plan.Changes{\n\t\tUpdateOld: updateOld,\n\t\tUpdateNew: updateNew,\n\t}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tnewRecords, err := p.Records(t.Context())\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif len(newRecords) != 4 {\n\t\tt.Fatal(\"Expected list of 4 records, got:\", newRecords)\n\t}\n\tif len(requests.createRequests) != 2 {\n\t\tt.Fatal(\"Expected 2 create request, got:\", requests.createRequests)\n\t}\n\tif len(requests.deleteRequests) != 2 {\n\t\tt.Fatal(\"Expected 2 delete request, got:\", requests.deleteRequests)\n\t}\n\tfor idx, record := range updateNew {\n\t\tif newRecords[idx].DNSName != record.DNSName {\n\t\t\tt.Error(\"DNS Name malformed on retrieval, got:\", newRecords[idx].DNSName, \"expected:\", record.DNSName)\n\t\t}\n\t\tif newRecords[idx].Targets[0] != record.Targets[0] {\n\t\t\tt.Error(\"Targets malformed on retrieval, got:\", newRecords[idx].Targets, \"expected:\", record.Targets)\n\t\t}\n\t}\n\texpectedCreateA := endpoint.Endpoint{\n\t\tDNSName:    \"test2.example.com\",\n\t\tTargets:    []string{\"10.0.0.1\"},\n\t\tRecordType: endpoint.RecordTypeA,\n\t}\n\texpectedDeleteA := endpoint.Endpoint{\n\t\tDNSName:    \"test2.example.com\",\n\t\tTargets:    []string{\"192.168.1.2\"},\n\t\tRecordType: endpoint.RecordTypeA,\n\t}\n\texpectedCreateAAAA := endpoint.Endpoint{\n\t\tDNSName:    \"test2.example.com\",\n\t\tTargets:    []string{\"fc00::1:10:0:0:1\"},\n\t\tRecordType: endpoint.RecordTypeAAAA,\n\t}\n\texpectedDeleteAAAA := endpoint.Endpoint{\n\t\tDNSName:    \"test2.example.com\",\n\t\tTargets:    []string{\"fc00::1:192:168:1:2\"},\n\t\tRecordType: endpoint.RecordTypeAAAA,\n\t}\n\tfor _, request := range requests.createRequests {\n\t\tswitch request.RecordType {\n\t\tcase endpoint.RecordTypeA:\n\t\t\tif !reflect.DeepEqual(request, &expectedCreateA) {\n\t\t\t\tt.Error(\"Unexpected create request, got:\", request, \"expected:\", &expectedCreateA)\n\t\t\t}\n\t\tcase endpoint.RecordTypeAAAA:\n\t\t\tif !reflect.DeepEqual(request, &expectedCreateAAAA) {\n\t\t\t\tt.Error(\"Unexpected create request, got:\", request, \"expected:\", &expectedCreateAAAA)\n\t\t\t}\n\t\t}\n\t}\n\tfor _, request := range requests.deleteRequests {\n\t\tswitch request.RecordType {\n\t\tcase endpoint.RecordTypeA:\n\t\t\tif !reflect.DeepEqual(request, &expectedDeleteA) {\n\t\t\t\tt.Error(\"Unexpected delete request, got:\", request, \"expected:\", &expectedDeleteA)\n\t\t\t}\n\t\tcase endpoint.RecordTypeAAAA:\n\t\t\tif !reflect.DeepEqual(request, &expectedDeleteAAAA) {\n\t\t\t\tt.Error(\"Unexpected delete request, got:\", request, \"expected:\", &expectedDeleteAAAA)\n\t\t\t}\n\t\t}\n\t}\n\trequests.clear()\n}\n"
  },
  {
    "path": "provider/plural/client.go",
    "content": "/*\nCopyright 2022 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage plural\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\n\t\"github.com/Yamashou/gqlgenc/clientv2\"\n\t\"github.com/pluralsh/gqlclient\"\n\t\"github.com/pluralsh/gqlclient/pkg/utils\"\n)\n\ntype authedTransport struct {\n\tkey     string\n\twrapped http.RoundTripper\n}\n\nfunc (t *authedTransport) RoundTrip(req *http.Request) (*http.Response, error) {\n\treq.Header.Set(\"Authorization\", \"Bearer \"+t.key)\n\treturn t.wrapped.RoundTrip(req)\n}\n\ntype Client interface {\n\tDnsRecords() ([]*DnsRecord, error)\n\tCreateRecord(record *DnsRecord) (*DnsRecord, error)\n\tDeleteRecord(name, ttype string) error\n}\n\ntype Config struct {\n\tToken    string\n\tEndpoint string\n\tCluster  string\n\tProvider string\n}\n\ntype client struct {\n\tctx          context.Context\n\tpluralClient *gqlclient.Client\n\tconfig       *Config\n}\n\ntype DnsRecord struct {\n\tType    string\n\tName    string\n\tRecords []string\n}\n\nfunc NewClient(conf *Config) (Client, error) {\n\tbase, err := conf.BaseUrl()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\thttpClient := http.Client{\n\t\tTransport: &authedTransport{\n\t\t\tkey:     conf.Token,\n\t\t\twrapped: http.DefaultTransport,\n\t\t},\n\t}\n\tendpoint := base + \"/gql\"\n\treturn &client{\n\t\tctx:          context.Background(),\n\t\tpluralClient: gqlclient.NewClient(&httpClient, endpoint, &clientv2.Options{}),\n\t\tconfig:       conf,\n\t}, nil\n}\n\nfunc (c *Config) BaseUrl() (string, error) {\n\thost := \"https://app.plural.sh\"\n\tif c.Endpoint != \"\" {\n\t\thost = fmt.Sprintf(\"https://%s\", c.Endpoint)\n\t\tif _, err := url.Parse(host); err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t}\n\treturn host, nil\n}\n\nfunc (client *client) DnsRecords() ([]*DnsRecord, error) {\n\tresp, err := client.pluralClient.GetDNSRecords(client.ctx, client.config.Cluster, gqlclient.Provider(strings.ToUpper(client.config.Provider)))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\trecords := make([]*DnsRecord, 0)\n\tfor _, edge := range resp.DNSRecords.Edges {\n\t\tif edge.Node != nil {\n\t\t\trecord := &DnsRecord{\n\t\t\t\tType:    string(edge.Node.Type),\n\t\t\t\tName:    edge.Node.Name,\n\t\t\t\tRecords: utils.ConvertStringArrayPointer(edge.Node.Records),\n\t\t\t}\n\t\t\trecords = append(records, record)\n\t\t}\n\t}\n\treturn records, nil\n}\n\nfunc (client *client) CreateRecord(record *DnsRecord) (*DnsRecord, error) {\n\tprovider := gqlclient.Provider(strings.ToUpper(client.config.Provider))\n\tcluster := client.config.Cluster\n\tattr := gqlclient.DNSRecordAttributes{\n\t\tName:    record.Name,\n\t\tType:    gqlclient.DNSRecordType(record.Type),\n\t\tRecords: []*string{},\n\t}\n\n\tfor _, record := range record.Records {\n\t\tattr.Records = append(attr.Records, &record)\n\t}\n\n\tresp, err := client.pluralClient.CreateDNSRecord(client.ctx, cluster, provider, attr)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &DnsRecord{\n\t\tType:    string(resp.CreateDNSRecord.Type),\n\t\tName:    resp.CreateDNSRecord.Name,\n\t\tRecords: utils.ConvertStringArrayPointer(resp.CreateDNSRecord.Records),\n\t}, nil\n}\n\nfunc (client *client) DeleteRecord(name, ttype string) error {\n\tif _, err := client.pluralClient.DeleteDNSRecord(client.ctx, name, gqlclient.DNSRecordType(ttype)); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "provider/plural/plural.go",
    "content": "/*\nCopyright 2022 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage plural\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\nconst (\n\tCreateAction = \"c\"\n\tDeleteAction = \"d\"\n)\n\ntype PluralProvider struct {\n\tprovider.BaseProvider\n\tClient Client\n}\n\ntype RecordChange struct {\n\tAction string\n\tRecord *DnsRecord\n}\n\n// New creates a Plural provider from the given configuration.\nfunc New(_ context.Context, cfg *externaldns.Config, _ *endpoint.DomainFilter) (provider.Provider, error) {\n\treturn newProvider(cfg.PluralCluster, cfg.PluralProvider)\n}\n\nfunc newProvider(cluster, provider string) (*PluralProvider, error) {\n\ttoken := os.Getenv(\"PLURAL_ACCESS_TOKEN\")\n\tif token == \"\" {\n\t\treturn nil, fmt.Errorf(\"no plural access token provided, you must set the PLURAL_ACCESS_TOKEN env var\")\n\t}\n\n\tconfig := &Config{\n\t\tToken:    token,\n\t\tEndpoint: os.Getenv(\"PLURAL_ENDPOINT\"),\n\t\tCluster:  cluster,\n\t\tProvider: provider,\n\t}\n\n\tcl, err := NewClient(config)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &PluralProvider{\n\t\tClient: cl,\n\t}, nil\n}\n\nfunc (p *PluralProvider) Records(_ context.Context) ([]*endpoint.Endpoint, error) {\n\trecords, err := p.Client.DnsRecords()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tendpoints := make([]*endpoint.Endpoint, len(records))\n\tfor i, record := range records {\n\t\tendpoints[i] = endpoint.NewEndpoint(record.Name, record.Type, record.Records...)\n\t}\n\treturn endpoints, nil\n}\n\nfunc (p *PluralProvider) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) {\n\treturn endpoints, nil\n}\n\nfunc (p *PluralProvider) ApplyChanges(_ context.Context, diffs *plan.Changes) error {\n\tvar changes []*RecordChange\n\tfor _, ep := range diffs.Create {\n\t\tchanges = append(changes, makeChange(CreateAction, ep.Targets, ep))\n\t}\n\n\tfor _, desired := range diffs.UpdateNew {\n\t\tchanges = append(changes, makeChange(CreateAction, desired.Targets, desired))\n\t}\n\n\tfor _, deleted := range diffs.Delete {\n\t\tchanges = append(changes, makeChange(DeleteAction, []string{}, deleted))\n\t}\n\n\treturn p.applyChanges(changes)\n}\n\nfunc makeChange(change string, target []string, endpoint *endpoint.Endpoint) *RecordChange {\n\treturn &RecordChange{\n\t\tAction: change,\n\t\tRecord: &DnsRecord{\n\t\t\tName:    endpoint.DNSName,\n\t\t\tType:    endpoint.RecordType,\n\t\t\tRecords: target,\n\t\t},\n\t}\n}\n\nfunc (p *PluralProvider) applyChanges(changes []*RecordChange) error {\n\tfor _, change := range changes {\n\t\tlogFields := log.Fields{\n\t\t\t\"name\":   change.Record.Name,\n\t\t\t\"type\":   change.Record.Type,\n\t\t\t\"action\": change.Action,\n\t\t}\n\t\tlog.WithFields(logFields).Info(\"Changing record.\")\n\n\t\tif change.Action == CreateAction {\n\t\t\t_, err := p.Client.CreateRecord(change.Record)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif change.Action == DeleteAction {\n\t\t\tif err := p.Client.DeleteRecord(change.Record.Name, change.Record.Type); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "provider/plural/plural_test.go",
    "content": "/*\nCopyright 2022 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage plural\n\nimport (\n\t\"testing\"\n\n\t\"sigs.k8s.io/external-dns/plan\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/internal/testutils\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\ntype ClientStub struct {\n\tmockDnsRecords []*DnsRecord\n}\n\n// CreateRecord provides a mock function with given fields: record\nfunc (c *ClientStub) CreateRecord(record *DnsRecord) (*DnsRecord, error) {\n\tc.mockDnsRecords = append(c.mockDnsRecords, record)\n\treturn record, nil\n}\n\n// DeleteRecord provides a mock function with given fields: name, ttype\nfunc (c *ClientStub) DeleteRecord(name string, ttype string) error {\n\tnewRecords := make([]*DnsRecord, 0)\n\tfor _, record := range c.mockDnsRecords {\n\t\tif record.Name == name && record.Type == ttype {\n\t\t\tcontinue\n\t\t}\n\t\tnewRecords = append(newRecords, record)\n\t}\n\tc.mockDnsRecords = newRecords\n\treturn nil\n}\n\n// DnsRecords provides a mock function with given fields:\nfunc (c *ClientStub) DnsRecords() ([]*DnsRecord, error) {\n\treturn c.mockDnsRecords, nil\n}\n\nfunc newPluralProvider(pluralDNSRecord []*DnsRecord) *PluralProvider {\n\tif pluralDNSRecord == nil {\n\t\tpluralDNSRecord = make([]*DnsRecord, 0)\n\t}\n\treturn &PluralProvider{\n\t\tBaseProvider: provider.BaseProvider{},\n\t\tClient: &ClientStub{\n\t\t\tmockDnsRecords: pluralDNSRecord,\n\t\t},\n\t}\n}\n\nfunc TestPluralRecords(t *testing.T) {\n\ttests := []struct {\n\t\tname              string\n\t\texpectedEndpoints []*endpoint.Endpoint\n\t\trecords           []*DnsRecord\n\t}{\n\t\t{\n\t\t\tname: \"check records\",\n\t\t\trecords: []*DnsRecord{\n\t\t\t\t{\n\t\t\t\t\tType:    endpoint.RecordTypeA,\n\t\t\t\t\tName:    \"example.com\",\n\t\t\t\t\tRecords: []string{\"123.123.123.122\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tType:    endpoint.RecordTypeA,\n\t\t\t\t\tName:    \"nginx.example.com\",\n\t\t\t\t\tRecords: []string{\"123.123.123.123\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tType:    endpoint.RecordTypeCNAME,\n\t\t\t\t\tName:    \"hack.example.com\",\n\t\t\t\t\tRecords: []string{\"bluecatnetworks.com\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tType:    endpoint.RecordTypeTXT,\n\t\t\t\t\tName:    \"kdb.example.com\",\n\t\t\t\t\tRecords: []string{\"heritage=external-dns,external-dns/owner=default,external-dns/resource=service/openshift-ingress/router-default\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedEndpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"123.123.123.122\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"nginx.example.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"123.123.123.123\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"hack.example.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"bluecatnetworks.com\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"kdb.example.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeTXT,\n\t\t\t\t\tTargets:    endpoint.Targets{\"heritage=external-dns,external-dns/owner=default,external-dns/resource=service/openshift-ingress/router-default\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, test := range tests {\n\t\tt.Run(test.name, func(t *testing.T) {\n\t\t\tprovider := newPluralProvider(test.records)\n\n\t\t\tactual, err := provider.Records(t.Context())\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\t\t\tvalidateEndpoints(t, actual, test.expectedEndpoints)\n\t\t})\n\t}\n}\n\nfunc TestPluralApplyChangesCreate(t *testing.T) {\n\ttests := []struct {\n\t\tname              string\n\t\texpectedEndpoints []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\tname: \"create new endpoints\",\n\t\t\texpectedEndpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"123.123.123.122\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"nginx.example.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"123.123.123.123\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"hack.example.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"bluecatnetworks.com\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"kdb.example.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeTXT,\n\t\t\t\t\tTargets:    endpoint.Targets{\"heritage=external-dns,external-dns/owner=default,external-dns/resource=service/openshift-ingress/router-default\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, test := range tests {\n\t\tt.Run(test.name, func(t *testing.T) {\n\t\t\tprovider := newPluralProvider(nil)\n\n\t\t\t// no records\n\t\t\tactual, err := provider.Records(t.Context())\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\t\t\tassert.Empty(t, actual, \"expected no entries\")\n\n\t\t\terr = provider.ApplyChanges(t.Context(), &plan.Changes{Create: test.expectedEndpoints})\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\n\t\t\tactual, err = provider.Records(t.Context())\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\t\t\tvalidateEndpoints(t, actual, test.expectedEndpoints)\n\t\t})\n\t}\n}\n\nfunc TestPluralApplyChangesDelete(t *testing.T) {\n\ttests := []struct {\n\t\tname              string\n\t\trecords           []*DnsRecord\n\t\tdeleteEndpoints   []*endpoint.Endpoint\n\t\texpectedEndpoints []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\tname: \"delete not existing record\",\n\t\t\trecords: []*DnsRecord{\n\t\t\t\t{\n\t\t\t\t\tType:    endpoint.RecordTypeA,\n\t\t\t\t\tName:    \"example.com\",\n\t\t\t\t\tRecords: []string{\"123.123.123.122\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tType:    endpoint.RecordTypeA,\n\t\t\t\t\tName:    \"nginx.example.com\",\n\t\t\t\t\tRecords: []string{\"123.123.123.123\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tType:    endpoint.RecordTypeCNAME,\n\t\t\t\t\tName:    \"hack.example.com\",\n\t\t\t\t\tRecords: []string{\"bluecatnetworks.com\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tType:    endpoint.RecordTypeTXT,\n\t\t\t\t\tName:    \"kdb.example.com\",\n\t\t\t\t\tRecords: []string{\"heritage=external-dns,external-dns/owner=default,external-dns/resource=service/openshift-ingress/router-default\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tdeleteEndpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"fake.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedEndpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"123.123.123.122\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"nginx.example.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"123.123.123.123\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"hack.example.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"bluecatnetworks.com\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"kdb.example.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeTXT,\n\t\t\t\t\tTargets:    endpoint.Targets{\"heritage=external-dns,external-dns/owner=default,external-dns/resource=service/openshift-ingress/router-default\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"delete one record\",\n\t\t\trecords: []*DnsRecord{\n\t\t\t\t{\n\t\t\t\t\tType:    endpoint.RecordTypeA,\n\t\t\t\t\tName:    \"example.com\",\n\t\t\t\t\tRecords: []string{\"123.123.123.122\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tType:    endpoint.RecordTypeA,\n\t\t\t\t\tName:    \"nginx.example.com\",\n\t\t\t\t\tRecords: []string{\"123.123.123.123\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tType:    endpoint.RecordTypeCNAME,\n\t\t\t\t\tName:    \"hack.example.com\",\n\t\t\t\t\tRecords: []string{\"bluecatnetworks.com\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tType:    endpoint.RecordTypeTXT,\n\t\t\t\t\tName:    \"kdb.example.com\",\n\t\t\t\t\tRecords: []string{\"heritage=external-dns,external-dns/owner=default,external-dns/resource=service/openshift-ingress/router-default\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tdeleteEndpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"kdb.example.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeTXT,\n\t\t\t\t\tTargets:    endpoint.Targets{\"heritage=external-dns,external-dns/owner=default,external-dns/resource=service/openshift-ingress/router-default\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedEndpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"123.123.123.122\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"nginx.example.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"123.123.123.123\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"hack.example.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"bluecatnetworks.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"delete all records\",\n\t\t\trecords: []*DnsRecord{\n\t\t\t\t{\n\t\t\t\t\tType:    endpoint.RecordTypeA,\n\t\t\t\t\tName:    \"example.com\",\n\t\t\t\t\tRecords: []string{\"123.123.123.122\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tType:    endpoint.RecordTypeA,\n\t\t\t\t\tName:    \"nginx.example.com\",\n\t\t\t\t\tRecords: []string{\"123.123.123.123\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tType:    endpoint.RecordTypeCNAME,\n\t\t\t\t\tName:    \"hack.example.com\",\n\t\t\t\t\tRecords: []string{\"bluecatnetworks.com\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tType:    endpoint.RecordTypeTXT,\n\t\t\t\t\tName:    \"kdb.example.com\",\n\t\t\t\t\tRecords: []string{\"heritage=external-dns,external-dns/owner=default,external-dns/resource=service/openshift-ingress/router-default\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tdeleteEndpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"kdb.example.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeTXT,\n\t\t\t\t\tTargets:    endpoint.Targets{\"heritage=external-dns,external-dns/owner=default,external-dns/resource=service/openshift-ingress/router-default\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"123.123.123.122\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"nginx.example.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"123.123.123.123\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"hack.example.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"bluecatnetworks.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedEndpoints: []*endpoint.Endpoint{},\n\t\t},\n\t}\n\n\tfor _, test := range tests {\n\t\tt.Run(test.name, func(t *testing.T) {\n\t\t\tprovider := newPluralProvider(test.records)\n\n\t\t\terr := provider.ApplyChanges(t.Context(), &plan.Changes{Delete: test.deleteEndpoints})\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\n\t\t\tactual, err := provider.Records(t.Context())\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\t\t\tvalidateEndpoints(t, actual, test.expectedEndpoints)\n\t\t})\n\t}\n}\n\nfunc validateEndpoints(t *testing.T, endpoints []*endpoint.Endpoint, expected []*endpoint.Endpoint) {\n\tassert.True(t, testutils.SameEndpoints(endpoints, expected), \"expected and actual endpoints don't match. %s:%s\", endpoints, expected)\n}\n"
  },
  {
    "path": "provider/provider.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage provider\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"strings\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/plan\"\n)\n\n// SoftError is an error, that provider will only log as error instead\n// of fatal. It is meant for error propagation from providers to tell\n// that this is a transient error.\nvar SoftError error = errors.New(\"soft error\") //nolint:staticcheck\n\n// NewSoftErrorf creates a SoftError with formats according to a format specifier and returns the string as a\nfunc NewSoftErrorf(format string, a ...any) error {\n\treturn NewSoftError(fmt.Errorf(format, a...))\n}\n\n// NewSoftError creates a SoftError from the given error\nfunc NewSoftError(err error) error {\n\treturn errors.Join(SoftError, err)\n}\n\n// Provider defines the interface DNS providers should implement.\ntype Provider interface {\n\tRecords(ctx context.Context) ([]*endpoint.Endpoint, error)\n\tApplyChanges(ctx context.Context, changes *plan.Changes) error\n\t// AdjustEndpoints canonicalizes a set of candidate endpoints.\n\t// It is called with a set of candidate endpoints obtained from the various sources.\n\t// It returns a set modified as required by the provider. The provider is responsible for\n\t// adding, removing, and modifying the ProviderSpecific properties to match\n\t// the endpoints that the provider returns in `Records` so that the change plan will not have\n\t// unnecessary (potentially failing) changes. It may also modify other fields, add, or remove\n\t// Endpoints. It is permitted to modify the supplied endpoints.\n\tAdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error)\n\tGetDomainFilter() endpoint.DomainFilterInterface\n}\n\ntype BaseProvider struct{}\n\nfunc (b BaseProvider) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) {\n\treturn endpoints, nil\n}\n\nfunc (b BaseProvider) GetDomainFilter() endpoint.DomainFilterInterface {\n\treturn &endpoint.DomainFilter{}\n}\n\ntype contextKey struct {\n\tname string\n}\n\nfunc (k *contextKey) String() string { return \"provider context value \" + k.name }\n\n// RecordsContextKey is a context key. It can be used during ApplyChanges\n// to access previously cached records. The associated value will be of\n// type []*endpoint.Endpoint.\nvar RecordsContextKey = &contextKey{\"records\"}\n\n// EnsureTrailingDot ensures that the hostname receives a trailing dot if it hasn't already.\nfunc EnsureTrailingDot(hostname string) string {\n\tif net.ParseIP(hostname) != nil {\n\t\treturn hostname\n\t}\n\n\treturn strings.TrimSuffix(hostname, \".\") + \".\"\n}\n\n// Difference tells which entries need to be respectively\n// added, removed, or left untouched for \"current\" to be transformed to \"desired\"\nfunc Difference(current, desired []string) ([]string, []string, []string) {\n\tadd, remove, leave := []string{}, []string{}, []string{}\n\tindex := make(map[string]struct{}, len(current))\n\tfor _, x := range current {\n\t\tindex[x] = struct{}{}\n\t}\n\tfor _, x := range desired {\n\t\tif _, found := index[x]; found {\n\t\t\tleave = append(leave, x)\n\t\t\tdelete(index, x)\n\t\t} else {\n\t\t\tadd = append(add, x)\n\t\t\tdelete(index, x)\n\t\t}\n\t}\n\tfor x := range index {\n\t\tremove = append(remove, x)\n\t}\n\treturn add, remove, leave\n}\n"
  },
  {
    "path": "provider/provider_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage provider\n\nimport (\n\t\"io\"\n\t\"os\"\n\t\"testing\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestMain(m *testing.M) {\n\tlog.SetOutput(io.Discard)\n\tos.Exit(m.Run())\n}\n\nfunc TestEnsureTrailingDot(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\tinput, expected string\n\t}{\n\t\t{\"example.org\", \"example.org.\"},\n\t\t{\"example.org.\", \"example.org.\"},\n\t\t{\"8.8.8.8\", \"8.8.8.8\"},\n\t} {\n\t\toutput := EnsureTrailingDot(tc.input)\n\n\t\tif output != tc.expected {\n\t\t\tt.Errorf(\"expected %s, got %s\", tc.expected, output)\n\t\t}\n\t}\n}\n\nfunc TestDifference(t *testing.T) {\n\tcurrent := []string{\"foo\", \"bar\"}\n\tdesired := []string{\"bar\", \"baz\"}\n\tadd, remove, leave := Difference(current, desired)\n\tassert.Equal(t, []string{\"baz\"}, add)\n\tassert.Equal(t, []string{\"foo\"}, remove)\n\tassert.Equal(t, []string{\"bar\"}, leave)\n}\n"
  },
  {
    "path": "provider/recordfilter.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage provider\n\n// SupportedRecordType returns true only for supported record types.\n// Currently A, AAAA, CNAME, SRV, TXT and NS record types are supported.\nfunc SupportedRecordType(recordType string) bool {\n\tswitch recordType {\n\tcase \"A\", \"AAAA\", \"CNAME\", \"SRV\", \"TXT\", \"NS\":\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n"
  },
  {
    "path": "provider/recordfilter_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage provider\n\nimport \"testing\"\n\nfunc TestRecordTypeFilter(t *testing.T) {\n\trecords := []struct {\n\t\trtype  string\n\t\texpect bool\n\t}{\n\t\t{\n\t\t\t\"A\",\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"AAAA\",\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"CNAME\",\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"TXT\",\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t\"MX\",\n\t\t\tfalse,\n\t\t},\n\t}\n\tfor _, r := range records {\n\t\tgot := SupportedRecordType(r.rtype)\n\t\tif r.expect != got {\n\t\t\tt.Errorf(\"wrong record type %s: expect %v, but got %v\", r.rtype, r.expect, got)\n\t\t}\n\n\t}\n}\n"
  },
  {
    "path": "provider/rfc2136/rfc2136.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage rfc2136\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"errors\"\n\t\"fmt\"\n\t\"math/rand\"\n\t\"net\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/bodgit/tsig\"\n\t\"github.com/bodgit/tsig/gss\"\n\t\"github.com/miekg/dns\"\n\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/pkg/tlsutils\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\nconst (\n\t// maximum time DNS client can be off from server for an update to succeed\n\tclockSkew = 300\n)\n\n// rfc2136 provider type\ntype rfc2136Provider struct {\n\tprovider.BaseProvider\n\tnameservers     []string\n\tzoneNames       []string\n\ttsigKeyName     string\n\ttsigSecret      string\n\ttsigSecretAlg   string\n\tinsecure        bool\n\taxfr            bool\n\tminTTL          time.Duration\n\tbatchChangeSize int\n\ttlsConfig       TLSConfig\n\tcreatePTR       bool\n\n\t// options specific to rfc3645 gss-tsig support\n\tgssTsig      bool\n\tkrb5Username string\n\tkrb5Password string\n\tkrb5Realm    string\n\n\t// only consider hosted zones managing domains ending in this suffix\n\tdomainFilter *endpoint.DomainFilter\n\tdryRun       bool\n\tactions      rfc2136Actions\n\n\t// Counter for load balancing, and error handling\n\tcounter int\n\tmu      sync.Mutex // Mutex for thread-safe counter\n\n\t// Load balancing strategy \"round-robin\", \"random\", or \"disabled\"\n\tloadBalancingStrategy string\n\n\t// Random number generator for random load balancing\n\trandGen *rand.Rand\n\n\t// Last error encountered\n\tlastErr error\n}\n\n// TLSConfig is comprised of the TLS-related fields necessary if we are using DNS over TLS\ntype TLSConfig struct {\n\tUseTLS                bool\n\tSkipTLSVerify         bool\n\tCAFilePath            string\n\tClientCertFilePath    string\n\tClientCertKeyFilePath string\n}\n\n// Map of supported TSIG algorithms\nvar tsigAlgs = map[string]string{\n\t\"hmac-sha1\":   dns.HmacSHA1,\n\t\"hmac-sha224\": dns.HmacSHA224,\n\t\"hmac-sha256\": dns.HmacSHA256,\n\t\"hmac-sha384\": dns.HmacSHA384,\n\t\"hmac-sha512\": dns.HmacSHA512,\n}\n\ntype rfc2136Actions interface {\n\tSendMessage(msg *dns.Msg) error\n\tIncomeTransfer(m *dns.Msg, nameserver string) (env chan *dns.Envelope, err error)\n}\n\n// New creates an RFC2136 provider from the given configuration.\nfunc New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) {\n\ttlsConfig := TLSConfig{\n\t\tUseTLS:                cfg.RFC2136UseTLS,\n\t\tSkipTLSVerify:         cfg.RFC2136SkipTLSVerify,\n\t\tCAFilePath:            cfg.TLSCA,\n\t\tClientCertFilePath:    cfg.TLSClientCert,\n\t\tClientCertKeyFilePath: cfg.TLSClientCertKey,\n\t}\n\treturn newProvider(cfg.RFC2136Host, cfg.RFC2136Port, cfg.RFC2136Zone, cfg.RFC2136Insecure, cfg.RFC2136TSIGKeyName, cfg.RFC2136TSIGSecret, cfg.RFC2136TSIGSecretAlg, cfg.RFC2136TAXFR, domainFilter, cfg.DryRun, cfg.RFC2136MinTTL, cfg.RFC2136CreatePTR, cfg.RFC2136GSSTSIG, cfg.RFC2136KerberosUsername, cfg.RFC2136KerberosPassword, cfg.RFC2136KerberosRealm, cfg.RFC2136BatchChangeSize, tlsConfig, cfg.RFC2136LoadBalancingStrategy, nil)\n}\n\n// newProvider is a factory function for OpenStack rfc2136 providers\nfunc newProvider(hosts []string, port int, zoneNames []string, insecure bool, keyName, secret, secretAlg string, axfr bool, domainFilter *endpoint.DomainFilter, dryRun bool, minTTL time.Duration, createPTR, gssTsig bool, krb5Username, krb5Password, krb5Realm string, batchChangeSize int, tlsConfig TLSConfig, loadBalancingStrategy string, actions rfc2136Actions) (provider.Provider, error) {\n\tsecretAlgChecked, ok := tsigAlgs[secretAlg]\n\tif !ok && !insecure && !gssTsig {\n\t\treturn nil, fmt.Errorf(\"%s is not supported TSIG algorithm\", secretAlg)\n\t}\n\n\t// Set zone to root if no set\n\tif len(zoneNames) == 0 {\n\t\tzoneNames = append(zoneNames, \".\")\n\t}\n\n\t// Sort zones\n\tsort.Slice(zoneNames, func(i, j int) bool {\n\t\treturn len(strings.Split(zoneNames[i], \".\")) > len(strings.Split(zoneNames[j], \".\"))\n\t})\n\n\tvar nameservers []string\n\tfor _, host := range hosts {\n\t\thost = net.JoinHostPort(host, strconv.Itoa(port))\n\t\tnameservers = append(nameservers, host)\n\t}\n\n\tr := &rfc2136Provider{\n\t\tnameservers:           nameservers,\n\t\tzoneNames:             zoneNames,\n\t\tinsecure:              insecure,\n\t\tgssTsig:               gssTsig,\n\t\tcreatePTR:             createPTR,\n\t\tkrb5Username:          krb5Username,\n\t\tkrb5Password:          krb5Password,\n\t\tkrb5Realm:             strings.ToUpper(krb5Realm),\n\t\tdomainFilter:          domainFilter,\n\t\tdryRun:                dryRun,\n\t\taxfr:                  axfr,\n\t\tminTTL:                minTTL,\n\t\tbatchChangeSize:       batchChangeSize,\n\t\ttlsConfig:             tlsConfig,\n\t\tloadBalancingStrategy: loadBalancingStrategy,\n\t\trandGen:               rand.New(rand.NewSource(time.Now().UnixNano())),\n\t\tcounter:               0,\n\t\tlastErr:               nil,\n\t}\n\tif actions != nil {\n\t\tr.actions = actions\n\t} else {\n\t\tr.actions = r\n\t}\n\n\tif !insecure {\n\t\tr.tsigKeyName = dns.Fqdn(keyName)\n\t\tr.tsigSecret = secret\n\t\tr.tsigSecretAlg = secretAlgChecked\n\t}\n\n\tlog.Infof(\"Configured RFC2136 with zones '%v' and nameservers '%v'\", r.zoneNames, hosts)\n\treturn r, nil\n}\n\n// KeyData will return TKEY name and TSIG handle to use for followon actions with a secure connection\nfunc (r *rfc2136Provider) KeyData(nameserver string) (string, *gss.Client, error) {\n\thandle, err := gss.NewClient(new(dns.Client))\n\tif err != nil {\n\t\treturn \"\", handle, err\n\t}\n\n\tkeyName, _, err := handle.NegotiateContextWithCredentials(nameserver, r.krb5Realm, r.krb5Username, r.krb5Password)\n\tif err != nil {\n\t\treturn keyName, handle, err\n\t}\n\n\treturn keyName, handle, nil\n}\n\n// Records returns the list of records.\nfunc (r *rfc2136Provider) Records(_ context.Context) ([]*endpoint.Endpoint, error) {\n\trrs, err := r.List()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar eps []*endpoint.Endpoint\n\nOuterLoop:\n\tfor _, rr := range rrs {\n\t\tlog.Debugf(\"Record=%s\", rr)\n\n\t\tif rr.Header().Class != dns.ClassINET {\n\t\t\tcontinue\n\t\t}\n\n\t\trrFqdn := rr.Header().Name\n\t\trrTTL := endpoint.TTL(rr.Header().Ttl)\n\t\tvar rrType string\n\t\tvar rrValues []string\n\t\tswitch rr.Header().Rrtype {\n\t\tcase dns.TypeCNAME:\n\t\t\trrValues = []string{rr.(*dns.CNAME).Target}\n\t\t\trrType = \"CNAME\"\n\t\tcase dns.TypeA:\n\t\t\trrValues = []string{rr.(*dns.A).A.String()}\n\t\t\trrType = \"A\"\n\t\tcase dns.TypeAAAA:\n\t\t\trrValues = []string{rr.(*dns.AAAA).AAAA.String()}\n\t\t\trrType = \"AAAA\"\n\t\tcase dns.TypeTXT:\n\t\t\trrValues = (rr.(*dns.TXT).Txt)\n\t\t\trrType = \"TXT\"\n\t\tcase dns.TypeNS:\n\t\t\trrValues = []string{rr.(*dns.NS).Ns}\n\t\t\trrType = \"NS\"\n\t\tcase dns.TypePTR:\n\t\t\trrValues = []string{rr.(*dns.PTR).Ptr}\n\t\t\trrType = \"PTR\"\n\t\tdefault:\n\t\t\tcontinue // Unhandled record type\n\t\t}\n\n\t\tfor idx, existingEndpoint := range eps {\n\t\t\tif existingEndpoint.DNSName == strings.TrimSuffix(rrFqdn, \".\") && existingEndpoint.RecordType == rrType {\n\t\t\t\teps[idx].Targets = append(eps[idx].Targets, rrValues...)\n\t\t\t\tcontinue OuterLoop\n\t\t\t}\n\t\t}\n\n\t\tep := endpoint.NewEndpointWithTTL(\n\t\t\trrFqdn,\n\t\t\trrType,\n\t\t\trrTTL,\n\t\t\trrValues...,\n\t\t)\n\n\t\teps = append(eps, ep)\n\t}\n\n\treturn eps, nil\n}\n\nfunc (r *rfc2136Provider) IncomeTransfer(m *dns.Msg, nameserver string) (chan *dns.Envelope, error) {\n\tt := new(dns.Transfer)\n\tif !r.insecure && !r.gssTsig {\n\t\tt.TsigSecret = map[string]string{r.tsigKeyName: r.tsigSecret}\n\t}\n\n\tc, err := makeClient(r, nameserver)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error setting up TLS: %w\", err)\n\t}\n\tconn, err := c.Dial(nameserver)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to connect for transfer: %w\", err)\n\t}\n\tt.Conn = conn\n\treturn t.In(m, nameserver)\n}\n\nfunc (r *rfc2136Provider) List() ([]dns.RR, error) {\n\tif !r.axfr {\n\t\tlog.Debug(\"axfr is disabled\")\n\t\treturn make([]dns.RR, 0), nil\n\t}\n\n\trecords := make([]dns.RR, 0)\n\tfor _, zone := range r.zoneNames {\n\t\tlog.Debugf(\"Fetching records for '%q'\", zone)\n\n\t\tm := new(dns.Msg)\n\t\tm.SetAxfr(dns.Fqdn(zone))\n\t\tif !r.insecure && !r.gssTsig {\n\t\t\tm.SetTsig(r.tsigKeyName, r.tsigSecretAlg, clockSkew, time.Now().Unix())\n\t\t}\n\n\t\tvar lastErr error\n\t\tfor i := 0; i < len(r.nameservers); i++ {\n\t\t\tnameserver := r.getNextNameserver()\n\t\t\tlog.Debugf(\"Fetching records from nameserver: %s\", nameserver)\n\n\t\t\tenv, err := r.actions.IncomeTransfer(m, nameserver)\n\t\t\tif err != nil {\n\t\t\t\tlastErr = fmt.Errorf(\"failed to fetch records via AXFR: %w\", err)\n\t\t\t\tr.lastErr = lastErr\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tfor e := range env {\n\t\t\t\tif e.Error != nil {\n\t\t\t\t\tif errors.Is(e.Error, dns.ErrSoa) {\n\t\t\t\t\t\tlog.Error(\"AXFR error: unexpected response received from the server\")\n\t\t\t\t\t} else {\n\t\t\t\t\t\tlog.Errorf(\"AXFR error: %v\", e.Error)\n\t\t\t\t\t}\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\trecords = append(records, e.RR...)\n\t\t\t}\n\t\t\t// If records were fetched successfully, break out of the loop\n\t\t\tif len(records) > 0 {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif lastErr != nil {\n\t\t\tr.lastErr = lastErr\n\t\t\treturn nil, provider.NewSoftError(lastErr)\n\t\t}\n\t}\n\n\treturn records, nil\n}\n\nfunc (r *rfc2136Provider) AddReverseRecord(ip string, hostname string) error {\n\tchanges := r.GenerateReverseRecord(ip, hostname)\n\treturn r.ApplyChanges(context.Background(), &plan.Changes{Create: changes})\n}\n\nfunc (r *rfc2136Provider) RemoveReverseRecord(ip string, hostname string) error {\n\tchanges := r.GenerateReverseRecord(ip, hostname)\n\treturn r.ApplyChanges(context.Background(), &plan.Changes{Delete: changes})\n}\n\nfunc (r *rfc2136Provider) GenerateReverseRecord(ip string, hostname string) []*endpoint.Endpoint {\n\t// Generate PTR notation record starting from the IP address\n\tvar records []*endpoint.Endpoint\n\n\tlog.Debugf(\"Reverse zone is: %s %s\", ip, dns.Fqdn(ip))\n\treverseAddress, _ := dns.ReverseAddr(ip)\n\n\t// PTR\n\trecords = append(records, &endpoint.Endpoint{\n\t\tDNSName:    reverseAddress[:len(reverseAddress)-1],\n\t\tRecordType: \"PTR\",\n\t\tTargets:    endpoint.Targets{hostname},\n\t})\n\n\treturn records\n}\n\n// ApplyChanges applies a given set of changes in a given zone.\nfunc (r *rfc2136Provider) ApplyChanges(_ context.Context, changes *plan.Changes) error {\n\tlog.Debugf(\"ApplyChanges (Create: %d, UpdateOld: %d, UpdateNew: %d, Delete: %d)\", len(changes.Create), len(changes.UpdateOld), len(changes.UpdateNew), len(changes.Delete))\n\n\tvar errs []error\n\n\tfor c, chunk := range chunkBy(changes.Create, r.batchChangeSize) {\n\t\tlog.Debugf(\"Processing batch %d of create changes\", c)\n\n\t\tm := make(map[string]*dns.Msg)\n\t\tm[\".\"] = new(dns.Msg) // Add the root zone\n\t\tfor _, z := range r.zoneNames {\n\t\t\tz = dns.Fqdn(z)\n\t\t\tm[z] = new(dns.Msg)\n\t\t}\n\t\tfor _, ep := range chunk {\n\t\t\tif !r.domainFilter.Match(ep.DNSName) {\n\t\t\t\tlog.Debugf(\"Skipping record %s because it was filtered out by the specified --domain-filter\", ep.DNSName)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tzone := findMsgZone(ep, r.zoneNames)\n\t\t\tm[zone].SetUpdate(zone)\n\n\t\t\tr.AddRecord(m[zone], ep)\n\n\t\t\tif r.createPTR && (ep.RecordType == \"A\" || ep.RecordType == \"AAAA\") {\n\t\t\t\tr.AddReverseRecord(ep.Targets[0], ep.DNSName)\n\t\t\t}\n\t\t}\n\n\t\t// only send if there are records available\n\t\tfor _, z := range m {\n\t\t\tif len(z.Ns) > 0 {\n\t\t\t\tif err := r.actions.SendMessage(z); err != nil {\n\t\t\t\t\tlog.Errorf(\"RFC2136 create record failed: %v\", err)\n\t\t\t\t\terrs = append(errs, err)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tfor c, chunk := range chunkBy(changes.UpdateNew, r.batchChangeSize) {\n\t\tlog.Debugf(\"Processing batch %d of update changes\", c)\n\n\t\tm := make(map[string]*dns.Msg)\n\t\tm[\".\"] = new(dns.Msg) // Add the root zone\n\t\tfor _, z := range r.zoneNames {\n\t\t\tz = dns.Fqdn(z)\n\t\t\tm[z] = new(dns.Msg)\n\t\t}\n\n\t\tfor i, ep := range chunk {\n\t\t\tif !r.domainFilter.Match(ep.DNSName) {\n\t\t\t\tlog.Debugf(\"Skipping record %s because it was filtered out by the specified --domain-filter\", ep.DNSName)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tzone := findMsgZone(ep, r.zoneNames)\n\t\t\tm[zone].SetUpdate(zone)\n\n\t\t\t// calculate corresponding index in the unsplitted UpdateOld for current endpoint ep in chunk\n\t\t\tj := (c * r.batchChangeSize) + i\n\t\t\tr.UpdateRecord(m[zone], changes.UpdateOld[j], ep)\n\t\t\tif r.createPTR && (ep.RecordType == \"A\" || ep.RecordType == \"AAAA\") {\n\t\t\t\tr.RemoveReverseRecord(changes.UpdateOld[j].Targets[0], ep.DNSName)\n\t\t\t\tr.AddReverseRecord(ep.Targets[0], ep.DNSName)\n\t\t\t}\n\t\t}\n\n\t\t// only send if there are records available\n\t\tfor _, z := range m {\n\t\t\tif len(z.Ns) > 0 {\n\t\t\t\tif err := r.actions.SendMessage(z); err != nil {\n\t\t\t\t\tlog.Errorf(\"RFC2136 update record failed: %v\", err)\n\t\t\t\t\terrs = append(errs, err)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tfor c, chunk := range chunkBy(changes.Delete, r.batchChangeSize) {\n\t\tlog.Debugf(\"Processing batch %d of delete changes\", c)\n\n\t\tm := make(map[string]*dns.Msg)\n\t\tm[\".\"] = new(dns.Msg) // Add the root zone\n\t\tfor _, z := range r.zoneNames {\n\t\t\tz = dns.Fqdn(z)\n\t\t\tm[z] = new(dns.Msg)\n\t\t}\n\t\tfor _, ep := range chunk {\n\t\t\tif !r.domainFilter.Match(ep.DNSName) {\n\t\t\t\tlog.Debugf(\"Skipping record %s because it was filtered out by the specified --domain-filter\", ep.DNSName)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tzone := findMsgZone(ep, r.zoneNames)\n\t\t\tm[zone].SetUpdate(zone)\n\n\t\t\tr.RemoveRecord(m[zone], ep)\n\t\t\tif r.createPTR && (ep.RecordType == \"A\" || ep.RecordType == \"AAAA\") {\n\t\t\t\tr.RemoveReverseRecord(ep.Targets[0], ep.DNSName)\n\t\t\t}\n\t\t}\n\n\t\t// only send if there are records available\n\t\tfor _, z := range m {\n\t\t\tif len(z.Ns) > 0 {\n\t\t\t\tif err := r.actions.SendMessage(z); err != nil {\n\t\t\t\t\tlog.Errorf(\"RFC2136 delete record failed: %v\", err)\n\t\t\t\t\terrs = append(errs, err)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tif len(errs) > 0 {\n\t\treturn provider.NewSoftErrorf(\"RFC2136 had errors in one or more of its batches: %v\", errs)\n\t}\n\n\treturn nil\n}\n\nfunc (r *rfc2136Provider) UpdateRecord(m *dns.Msg, oldEp *endpoint.Endpoint, newEp *endpoint.Endpoint) error {\n\terr := r.RemoveRecord(m, oldEp)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn r.AddRecord(m, newEp)\n}\n\nfunc (r *rfc2136Provider) AddRecord(m *dns.Msg, ep *endpoint.Endpoint) error {\n\tlog.Debugf(\"AddRecord.ep=%s\", ep)\n\n\tttl := int64(r.minTTL.Seconds())\n\tif ep.RecordTTL.IsConfigured() && int64(ep.RecordTTL) > ttl {\n\t\tttl = int64(ep.RecordTTL)\n\t}\n\n\tfor _, target := range ep.Targets {\n\t\tnewRR := fmt.Sprintf(\"%s %d %s %s\", ep.DNSName, ttl, ep.RecordType, target)\n\t\tlog.Infof(\"Adding RR: %s\", newRR)\n\n\t\trr, err := dns.NewRR(newRR)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to build RR: %w\", err)\n\t\t}\n\n\t\tm.Insert([]dns.RR{rr})\n\t}\n\n\treturn nil\n}\n\nfunc (r *rfc2136Provider) RemoveRecord(m *dns.Msg, ep *endpoint.Endpoint) error {\n\tlog.Debugf(\"RemoveRecord.ep=%s\", ep)\n\tfor _, target := range ep.Targets {\n\t\tnewRR := fmt.Sprintf(\"%s %d %s %s\", ep.DNSName, ep.RecordTTL, ep.RecordType, target)\n\t\tlog.Infof(\"Removing RR: %s\", newRR)\n\n\t\trr, err := dns.NewRR(newRR)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to build RR: %w\", err)\n\t\t}\n\n\t\tm.Remove([]dns.RR{rr})\n\t}\n\n\treturn nil\n}\n\nfunc (r *rfc2136Provider) getNextNameserver() string {\n\tif len(r.nameservers) == 1 {\n\t\treturn r.nameservers[0]\n\t}\n\n\tr.mu.Lock()\n\tdefer r.mu.Unlock()\n\n\tif r.lastErr != nil {\n\t\tlog.Warnf(\"Last operation failed for nameserver %s\", r.nameservers[r.counter])\n\t\tlog.Warnf(\"Last operation error message: %v\", r.lastErr)\n\t}\n\n\tvar nameserver string\n\tswitch r.loadBalancingStrategy {\n\tcase \"random\":\n\t\tfor {\n\t\t\tnameserver = r.nameservers[r.randGen.Intn(len(r.nameservers))]\n\t\t\t// Ensure that we don't get the same nameserver as the last one\n\t\t\tif nameserver != r.nameservers[r.counter] {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\tcase \"round-robin\":\n\t\tnameserver = r.nameservers[r.counter]\n\t\tr.counter = (r.counter + 1) % len(r.nameservers)\n\tdefault:\n\t\tif r.lastErr != nil {\n\t\t\tr.counter = (r.counter + 1) % len(r.nameservers)\n\t\t\tnameserver = r.nameservers[r.counter]\n\t\t} else {\n\t\t\tnameserver = r.nameservers[r.counter]\n\t\t}\n\t}\n\n\t// Last error has been logged, reset it for the next operation\n\tr.lastErr = nil\n\treturn nameserver\n}\n\nfunc (r *rfc2136Provider) SendMessage(msg *dns.Msg) error {\n\tif r.dryRun {\n\t\tlog.Debugf(\"SendMessage.skipped\")\n\t\treturn nil\n\t}\n\tlog.Debugf(\"SendMessage\")\n\n\tvar lastErr error\n\tfor i := 0; i < len(r.nameservers); i++ {\n\t\tnameserver := r.getNextNameserver()\n\t\tlog.Debugf(\"Sending message to nameserver: %s\", nameserver)\n\n\t\tc, err := makeClient(r, nameserver)\n\t\tif err != nil {\n\t\t\tlastErr = fmt.Errorf(\"error setting up TLS: %w\", err)\n\t\t\tr.lastErr = lastErr\n\t\t\tcontinue\n\t\t}\n\n\t\tif !r.insecure {\n\t\t\tif r.gssTsig {\n\t\t\t\tkeyName, handle, err := r.KeyData(nameserver)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlastErr = err\n\t\t\t\t\tr.lastErr = lastErr\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tdefer handle.Close()\n\t\t\t\tdefer handle.DeleteContext(keyName)\n\n\t\t\t\tc.TsigProvider = handle\n\n\t\t\t\tmsg.SetTsig(keyName, tsig.GSS, clockSkew, time.Now().Unix())\n\t\t\t} else {\n\t\t\t\tc.TsigProvider = tsig.HMAC{r.tsigKeyName: r.tsigSecret}\n\t\t\t\tmsg.SetTsig(r.tsigKeyName, r.tsigSecretAlg, clockSkew, time.Now().Unix())\n\t\t\t}\n\t\t}\n\n\t\tresp, _, err := c.Exchange(msg, nameserver)\n\t\tif err != nil {\n\t\t\tif resp != nil && resp.Rcode != dns.RcodeSuccess {\n\t\t\t\tlog.Infof(\"error in dns.Client.Exchange: %s\", err)\n\t\t\t\tlastErr = err\n\t\t\t\tr.lastErr = lastErr\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tlog.Warnf(\"warn in dns.Client.Exchange: %s\", err)\n\t\t\tlastErr = err\n\t\t\tr.lastErr = lastErr\n\t\t\tcontinue\n\t\t}\n\t\tif resp != nil && resp.Rcode != dns.RcodeSuccess {\n\t\t\tlog.Infof(\"Bad dns.Client.Exchange response: %s\", resp)\n\t\t\tlastErr = fmt.Errorf(\"bad return code: %s\", dns.RcodeToString[resp.Rcode])\n\t\t\tr.lastErr = lastErr\n\t\t\tcontinue\n\t\t}\n\n\t\tlog.Debugf(\"SendMessage.success\")\n\t\treturn nil\n\t}\n\n\tr.lastErr = lastErr\n\treturn provider.NewSoftError(lastErr)\n}\n\nfunc chunkBy(slice []*endpoint.Endpoint, chunkSize int) [][]*endpoint.Endpoint {\n\tvar chunks [][]*endpoint.Endpoint\n\n\tfor i := 0; i < len(slice); i += chunkSize {\n\t\tend := min(i+chunkSize, len(slice))\n\n\t\tchunks = append(chunks, slice[i:end])\n\t}\n\n\treturn chunks\n}\n\nfunc findMsgZone(ep *endpoint.Endpoint, zoneNames []string) string {\n\tfor _, zone := range zoneNames {\n\t\tif strings.HasSuffix(ep.DNSName, zone) {\n\t\t\treturn dns.Fqdn(zone)\n\t\t}\n\t}\n\n\tlog.Warnf(\"No available zone found for %s, set it to 'root'\", ep.DNSName)\n\treturn dns.Fqdn(\".\")\n}\n\nfunc makeClient(r *rfc2136Provider, nameserver string) (*dns.Client, error) {\n\tc := new(dns.Client)\n\n\t// Remove port from nameserver\n\tnameserver = strings.Split(nameserver, \":\")[0]\n\n\tif r.tlsConfig.UseTLS {\n\t\tlog.Debug(\"RFC2136 Connecting via TLS\")\n\t\tc.Net = \"tcp-tls\"\n\t\ttlsConfig, err := tlsutils.NewTLSConfig(\n\t\t\tr.tlsConfig.ClientCertFilePath,\n\t\t\tr.tlsConfig.ClientCertKeyFilePath,\n\t\t\tr.tlsConfig.CAFilePath,\n\t\t\tnameserver, // Use the current nameserver\n\t\t\tr.tlsConfig.SkipTLSVerify,\n\t\t\t// Per RFC9103\n\t\t\ttls.VersionTLS13,\n\t\t)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif tlsConfig.NextProtos == nil {\n\t\t\t// Per RFC9103\n\t\t\ttlsConfig.NextProtos = []string{\"dot\"}\n\t\t}\n\t\tc.TLSConfig = tlsConfig\n\t} else {\n\t\tc.Net = \"tcp\"\n\t}\n\n\treturn c, nil\n}\n"
  },
  {
    "path": "provider/rfc2136/rfc2136_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage rfc2136\n\nimport (\n\t\"crypto/tls\"\n\t\"fmt\"\n\t\"math/rand\"\n\t\"os\"\n\t\"regexp\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/miekg/dns\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\ntype rfc2136Stub struct {\n\toutput                []*dns.Envelope\n\tupdateMsgs            []*dns.Msg\n\tcreateMsgs            []*dns.Msg\n\tnameservers           []string\n\tcounter               int\n\trandGen               *rand.Rand\n\tlastNameserver        string\n\tloadBalancingStrategy string\n}\n\nfunc newStub() *rfc2136Stub {\n\treturn &rfc2136Stub{\n\t\toutput:                make([]*dns.Envelope, 0),\n\t\tupdateMsgs:            make([]*dns.Msg, 0),\n\t\tcreateMsgs:            make([]*dns.Msg, 0),\n\t\tnameservers:           []string{\"\"},\n\t\trandGen:               rand.New(rand.NewSource(time.Now().UnixNano())),\n\t\tloadBalancingStrategy: \"round-robin\",\n\t}\n}\n\nfunc newStubLB(strategy string, nameservers []string) *rfc2136Stub {\n\treturn &rfc2136Stub{\n\t\toutput:                make([]*dns.Envelope, 0),\n\t\tupdateMsgs:            make([]*dns.Msg, 0),\n\t\tcreateMsgs:            make([]*dns.Msg, 0),\n\t\tnameservers:           nameservers,\n\t\trandGen:               rand.New(rand.NewSource(time.Now().UnixNano())),\n\t\tloadBalancingStrategy: strategy,\n\t}\n}\n\nfunc (r *rfc2136Stub) getNextNameserver() string {\n\tif len(r.nameservers) == 1 {\n\t\treturn r.nameservers[0]\n\t}\n\n\tswitch r.loadBalancingStrategy {\n\tcase \"random\":\n\t\treturn r.nameservers[r.randGen.Intn(len(r.nameservers))]\n\tcase \"round-robin\":\n\t\tnameserver := r.nameservers[r.counter]\n\t\tr.counter = (r.counter + 1) % len(r.nameservers)\n\n\t\treturn nameserver\n\tdefault:\n\t\treturn r.nameservers[0]\n\t}\n}\n\nfunc getSortedChanges(msgs []*dns.Msg) []string {\n\tr := []string{}\n\tfor _, d := range msgs {\n\t\t// only care about section after the ZONE SECTION: as the id: needs stripped out in order to sort and grantee the order when sorting\n\t\tr = append(r, strings.Split(d.String(), \"ZONE SECTION:\")[1])\n\t}\n\tsort.Strings(r)\n\treturn r\n}\n\nfunc (r *rfc2136Stub) SendMessage(msg *dns.Msg) error {\n\tr.lastNameserver = r.getNextNameserver()\n\tlog.Info(\"Sending message to nameserver: \", r.lastNameserver)\n\tzone := extractZoneFromMessage(msg.String())\n\t// Make sure the zone starts with . to make sure HasSuffix does not match forbar.com for zone bar.com\n\tif !strings.HasPrefix(zone, \".\") {\n\t\tzone = \".\" + zone\n\t}\n\tlog.Infof(\"zone=%s\", zone)\n\tlines := extractUpdateSectionFromMessage(msg)\n\tfor _, line := range lines {\n\t\t// break at first empty line\n\t\tif len(strings.TrimSpace(line)) == 0 {\n\t\t\tbreak\n\t\t}\n\n\t\tline = strings.ReplaceAll(line, \"\\t\", \" \")\n\t\tlog.Info(line)\n\t\trecord := strings.Split(line, \" \")[0]\n\t\tif !strings.HasSuffix(record, zone) {\n\t\t\terr := fmt.Errorf(\"Message contains updates outside of it's zone.  zone=%v record=%v\", zone, record)\n\t\t\tlog.Error(err)\n\t\t\treturn err\n\t\t}\n\n\t\tif strings.Contains(line, \" NONE \") {\n\t\t\tr.updateMsgs = append(r.updateMsgs, msg)\n\t\t} else if strings.Contains(line, \" IN \") {\n\t\t\tr.createMsgs = append(r.createMsgs, msg)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (r *rfc2136Stub) setOutput(output []string) error {\n\tr.output = make([]*dns.Envelope, len(output))\n\tfor i, e := range output {\n\t\trr, err := dns.NewRR(e)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tr.output[i] = &dns.Envelope{\n\t\t\tRR: []dns.RR{rr},\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (r *rfc2136Stub) IncomeTransfer(m *dns.Msg, _ string) (chan *dns.Envelope, error) {\n\toutChan := make(chan *dns.Envelope)\n\tgo func() {\n\t\tfor _, e := range r.output {\n\n\t\t\tvar responseEnvelope *dns.Envelope\n\t\t\tfor _, record := range e.RR {\n\t\t\t\tfor _, q := range m.Question {\n\t\t\t\t\tif strings.HasSuffix(record.Header().Name, q.Name) {\n\t\t\t\t\t\tif responseEnvelope == nil {\n\t\t\t\t\t\t\tresponseEnvelope = &dns.Envelope{}\n\t\t\t\t\t\t}\n\t\t\t\t\t\tresponseEnvelope.RR = append(responseEnvelope.RR, record)\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif responseEnvelope == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\toutChan <- responseEnvelope\n\t\t}\n\t\tclose(outChan)\n\t}()\n\n\treturn outChan, nil\n}\n\nfunc createRfc2136StubProvider(stub *rfc2136Stub, zoneNames ...string) (provider.Provider, error) {\n\ttlsConfig := TLSConfig{\n\t\tUseTLS:                false,\n\t\tSkipTLSVerify:         false,\n\t\tCAFilePath:            \"\",\n\t\tClientCertFilePath:    \"\",\n\t\tClientCertKeyFilePath: \"\",\n\t}\n\treturn newProvider([]string{\"\"}, 0, zoneNames, false, \"key\", \"secret\", \"hmac-sha512\", true, &endpoint.DomainFilter{}, false, 300*time.Second, false, false, \"\", \"\", \"\", 50, tlsConfig, \"\", stub)\n}\n\nfunc createRfc2136StubProviderWithHosts(stub *rfc2136Stub) (provider.Provider, error) {\n\ttlsConfig := TLSConfig{\n\t\tUseTLS:                false,\n\t\tSkipTLSVerify:         false,\n\t\tCAFilePath:            \"\",\n\t\tClientCertFilePath:    \"\",\n\t\tClientCertKeyFilePath: \"\",\n\t}\n\treturn newProvider([]string{\"rfc2136-host1\", \"rfc2136-host2\", \"rfc2136-host3\"}, 0, nil, false, \"key\", \"secret\", \"hmac-sha512\", true, &endpoint.DomainFilter{}, false, 300*time.Second, false, false, \"\", \"\", \"\", 50, tlsConfig, \"\", stub)\n}\n\nfunc createRfc2136TLSStubProvider(stub *rfc2136Stub, tlsConfig TLSConfig) (provider.Provider, error) {\n\treturn newProvider([]string{\"rfc2136-host\"}, 0, nil, false, \"key\", \"secret\", \"hmac-sha512\", true, &endpoint.DomainFilter{}, false, 300*time.Second, false, false, \"\", \"\", \"\", 50, tlsConfig, \"\", stub)\n}\n\nfunc createRfc2136TLSStubProviderWithHosts(stub *rfc2136Stub, tlsConfig TLSConfig) (provider.Provider, error) {\n\treturn newProvider([]string{\"rfc2136-host1\", \"rfc2136-host2\"}, 0, nil, false, \"key\", \"secret\", \"hmac-sha512\", true, &endpoint.DomainFilter{}, false, 300*time.Second, false, false, \"\", \"\", \"\", 50, tlsConfig, \"\", stub)\n}\n\nfunc createRfc2136StubProviderWithReverse(stub *rfc2136Stub) (provider.Provider, error) {\n\ttlsConfig := TLSConfig{\n\t\tUseTLS:                false,\n\t\tSkipTLSVerify:         false,\n\t\tCAFilePath:            \"\",\n\t\tClientCertFilePath:    \"\",\n\t\tClientCertKeyFilePath: \"\",\n\t}\n\n\tzones := []string{\"foo.com\", \"3.2.1.in-addr.arpa\"}\n\treturn newProvider([]string{\"\"}, 0, zones, false, \"key\", \"secret\", \"hmac-sha512\", true, endpoint.NewDomainFilter(zones), false, 300*time.Second, true, false, \"\", \"\", \"\", 50, tlsConfig, \"\", stub)\n}\n\nfunc createRfc2136StubProviderWithZones(stub *rfc2136Stub) (provider.Provider, error) {\n\ttlsConfig := TLSConfig{\n\t\tUseTLS:                false,\n\t\tSkipTLSVerify:         false,\n\t\tCAFilePath:            \"\",\n\t\tClientCertFilePath:    \"\",\n\t\tClientCertKeyFilePath: \"\",\n\t}\n\tzones := []string{\"foo.com\", \"foobar.com\"}\n\treturn newProvider([]string{\"\"}, 0, zones, false, \"key\", \"secret\", \"hmac-sha512\", true, &endpoint.DomainFilter{}, false, 300*time.Second, false, false, \"\", \"\", \"\", 50, tlsConfig, \"\", stub)\n}\n\nfunc createRfc2136StubProviderWithZonesFilters(stub *rfc2136Stub) (provider.Provider, error) {\n\ttlsConfig := TLSConfig{\n\t\tUseTLS:                false,\n\t\tSkipTLSVerify:         false,\n\t\tCAFilePath:            \"\",\n\t\tClientCertFilePath:    \"\",\n\t\tClientCertKeyFilePath: \"\",\n\t}\n\tzones := []string{\"foo.com\", \"foobar.com\"}\n\treturn newProvider([]string{\"\"}, 0, zones, false, \"key\", \"secret\", \"hmac-sha512\", true, endpoint.NewDomainFilter(zones), false, 300*time.Second, false, false, \"\", \"\", \"\", 50, tlsConfig, \"\", stub)\n}\n\nfunc createRfc2136StubProviderWithStrategy(stub *rfc2136Stub, strategy string) (provider.Provider, error) {\n\ttlsConfig := TLSConfig{\n\t\tUseTLS:                false,\n\t\tSkipTLSVerify:         false,\n\t\tCAFilePath:            \"\",\n\t\tClientCertFilePath:    \"\",\n\t\tClientCertKeyFilePath: \"\",\n\t}\n\treturn newProvider([]string{\"rfc2136-host1\", \"rfc2136-host2\", \"rfc2136-host3\"}, 0, nil, false, \"key\", \"secret\", \"hmac-sha512\", true, &endpoint.DomainFilter{}, false, 300*time.Second, false, false, \"\", \"\", \"\", 50, tlsConfig, strategy, stub)\n}\n\nfunc createRfc2136StubProviderWithBatchChangeSize(stub *rfc2136Stub, batchChangeSize int) (provider.Provider, error) {\n\ttlsConfig := TLSConfig{\n\t\tUseTLS:                false,\n\t\tSkipTLSVerify:         false,\n\t\tCAFilePath:            \"\",\n\t\tClientCertFilePath:    \"\",\n\t\tClientCertKeyFilePath: \"\",\n\t}\n\treturn newProvider([]string{\"\"}, 0, nil, false, \"key\", \"secret\", \"hmac-sha512\", true, &endpoint.DomainFilter{}, false, 300*time.Second, false, false, \"\", \"\", \"\", batchChangeSize, tlsConfig, \"\", stub)\n}\n\nfunc extractUpdateSectionFromMessage(msg fmt.Stringer) []string {\n\tconst searchPattern = \"UPDATE SECTION:\"\n\tupdateSectionOffset := strings.Index(msg.String(), searchPattern)\n\treturn strings.Split(strings.TrimSpace(msg.String()[updateSectionOffset+len(searchPattern):]), \"\\n\")\n}\n\nfunc extractZoneFromMessage(msg string) string {\n\tre := regexp.MustCompile(`ZONE SECTION:\\n;(?P<ZONE>[\\.,\\-,\\w,\\d]+)\\t`)\n\tmatches := re.FindStringSubmatch(msg)\n\treturn matches[re.SubexpIndex(\"ZONE\")]\n}\n\n// TestRfc2136GetRecordsMultipleTargets simulates a single record with multiple targets.\nfunc TestRfc2136GetRecordsMultipleTargets(t *testing.T) {\n\tstub := newStub()\n\terr := stub.setOutput([]string{\n\t\t\"foo.com 3600 IN A 1.1.1.1\",\n\t\t\"foo.com 3600 IN A 2.2.2.2\",\n\t})\n\tassert.NoError(t, err)\n\n\tprovider, err := createRfc2136StubProvider(stub)\n\tassert.NoError(t, err)\n\n\trecs, err := provider.Records(t.Context())\n\tassert.NoError(t, err)\n\n\tassert.Len(t, recs, 1, \"expected single record\")\n\tassert.Equal(t, \"foo.com\", recs[0].DNSName)\n\tassert.Len(t, recs[0].Targets, 2, \"expected two targets\")\n\tassert.True(t, recs[0].Targets[0] == \"1.1.1.1\" || recs[0].Targets[1] == \"1.1.1.1\") // ignore order\n\tassert.True(t, recs[0].Targets[0] == \"2.2.2.2\" || recs[0].Targets[1] == \"2.2.2.2\") // ignore order\n\tassert.Equal(t, \"A\", recs[0].RecordType)\n\tassert.Equal(t, recs[0].RecordTTL, endpoint.TTL(3600))\n\tassert.Empty(t, recs[0].Labels, \"expected no labels\")\n\tassert.Empty(t, recs[0].ProviderSpecific, \"expected no provider specific config\")\n}\n\nfunc TestRfc2136PTRCreation(t *testing.T) {\n\tstub := newStub()\n\tprovider, err := createRfc2136StubProviderWithReverse(stub)\n\tassert.NoError(t, err)\n\n\terr = provider.ApplyChanges(t.Context(), &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"demo.foo.com\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tTargets:    []string{\"1.2.3.4\"},\n\t\t\t},\n\t\t},\n\t})\n\tassert.NoError(t, err)\n\tassert.Len(t, stub.createMsgs, 2, \"expected two records, one A and one PTR\")\n\tcreateMsgs := getSortedChanges(stub.createMsgs)\n\tassert.Contains(t, strings.Join(strings.Fields(createMsgs[0]), \" \"), \"4.3.2.1.in-addr.arpa. 300 IN PTR demo.foo.com.\", \"excpeted a PTR record\")\n\tassert.Contains(t, strings.Join(strings.Fields(createMsgs[1]), \" \"), \"demo.foo.com. 300 IN A 1.2.3.4\", \"expected an A record\")\n}\n\nfunc TestRfc2136TLSConfig(t *testing.T) {\n\tstub := newStub()\n\n\tcaFile, err := os.CreateTemp(t.TempDir(), \"rfc2136-test-XXXXXXXX.crt\")\n\trequire.NoError(t, err)\n\tdefer os.Remove(caFile.Name())\n\t_, err = caFile.Write([]byte(\n\t\t`-----BEGIN CERTIFICATE-----\nMIH+MIGxAhR2n1aQk0ONrQ8QQfa6GCzFWLmTXTAFBgMrZXAwITELMAkGA1UEBhMC\nREUxEjAQBgNVBAMMCWxvY2FsaG9zdDAgFw0yMzEwMjQwNzI5NDNaGA8yMTIzMDkz\nMDA3Mjk0M1owITELMAkGA1UEBhMCREUxEjAQBgNVBAMMCWxvY2FsaG9zdDAqMAUG\nAytlcAMhAA1FzGJXuQdOpKv02SEl7SIA8SP8RVRI0QTi1bUFiFBLMAUGAytlcANB\nADiCKRUGDMyafSSYhl0KXoiXrFOxvhrGM5l15L4q82JM5Qb8wv0gNrnbGTZlInuv\nouB5ZN+05DzKCQhBekMnygQ=\n-----END CERTIFICATE-----\n`))\n\n\ttlsConfig := TLSConfig{\n\t\tUseTLS:                true,\n\t\tSkipTLSVerify:         false,\n\t\tCAFilePath:            caFile.Name(),\n\t\tClientCertFilePath:    \"\",\n\t\tClientCertKeyFilePath: \"\",\n\t}\n\n\tprovider, err := createRfc2136TLSStubProvider(stub, tlsConfig)\n\trequire.NoError(t, err)\n\n\trawProvider := provider.(*rfc2136Provider)\n\n\tclient, err := makeClient(rawProvider, rawProvider.nameservers[0])\n\trequire.NoError(t, err)\n\n\tassert.Equal(t, \"tcp-tls\", client.Net)\n\tassert.False(t, client.TLSConfig.InsecureSkipVerify)\n\tassert.Equal(t, \"rfc2136-host\", client.TLSConfig.ServerName)\n\tassert.Equal(t, uint16(tls.VersionTLS13), client.TLSConfig.MinVersion)\n\tassert.Equal(t, []string{\"dot\"}, client.TLSConfig.NextProtos)\n}\n\nfunc TestRfc2136TLSConfigWithMultiHosts(t *testing.T) {\n\tstub := newStub()\n\n\tcaFile, err := os.CreateTemp(t.TempDir(), \"rfc2136-test-XXXXXXXX.crt\")\n\tassert.NoError(t, err)\n\tdefer os.Remove(caFile.Name())\n\t_, err = caFile.Write([]byte(\n\t\t`-----BEGIN CERTIFICATE-----\nMIH+MIGxAhR2n1aQk0ONrQ8QQfa6GCzFWLmTXTAFBgMrZXAwITELMAkGA1UEBhMC\nREUxEjAQBgNVBAMMCWxvY2FsaG9zdDAgFw0yMzEwMjQwNzI5NDNaGA8yMTIzMDkz\nMDA3Mjk0M1owITELMAkGA1UEBhMCREUxEjAQBgNVBAMMCWxvY2FsaG9zdDAqMAUG\nAytlcAMhAA1FzGJXuQdOpKv02SEl7SIA8SP8RVRI0QTi1bUFiFBLMAUGAytlcANB\nADiCKRUGDMyafSSYhl0KXoiXrFOxvhrGM5l15L4q82JM5Qb8wv0gNrnbGTZlInuv\nouB5ZN+05DzKCQhBekMnygQ=\n-----END CERTIFICATE-----\n`))\n\n\ttlsConfig := TLSConfig{\n\t\tUseTLS:                true,\n\t\tSkipTLSVerify:         false,\n\t\tCAFilePath:            caFile.Name(),\n\t\tClientCertFilePath:    \"\",\n\t\tClientCertKeyFilePath: \"\",\n\t}\n\n\tprovider, err := createRfc2136TLSStubProviderWithHosts(stub, tlsConfig)\n\tassert.NoError(t, err)\n\n\trawProvider := provider.(*rfc2136Provider)\n\n\tfor _, ns := range rawProvider.nameservers {\n\t\tclient, err := makeClient(rawProvider, ns)\n\t\tassert.NoError(t, err)\n\n\t\t// strip port from ns\n\t\tns = strings.Split(ns, \":\")[0]\n\n\t\tassert.Equal(t, \"tcp-tls\", client.Net)\n\t\tassert.False(t, client.TLSConfig.InsecureSkipVerify)\n\t\tassert.Equal(t, ns, client.TLSConfig.ServerName)\n\t\tassert.Equal(t, uint16(tls.VersionTLS13), client.TLSConfig.MinVersion)\n\t\tassert.Equal(t, []string{\"dot\"}, client.TLSConfig.NextProtos)\n\t}\n}\n\nfunc TestRfc2136TLSConfigNoVerify(t *testing.T) {\n\tstub := newStub()\n\n\tcaFile, err := os.CreateTemp(t.TempDir(), \"rfc2136-test-XXXXXXXX.crt\")\n\tassert.NoError(t, err)\n\tdefer os.Remove(caFile.Name())\n\t_, err = caFile.Write([]byte(\n\t\t`-----BEGIN CERTIFICATE-----\nMIH+MIGxAhR2n1aQk0ONrQ8QQfa6GCzFWLmTXTAFBgMrZXAwITELMAkGA1UEBhMC\nREUxEjAQBgNVBAMMCWxvY2FsaG9zdDAgFw0yMzEwMjQwNzI5NDNaGA8yMTIzMDkz\nMDA3Mjk0M1owITELMAkGA1UEBhMCREUxEjAQBgNVBAMMCWxvY2FsaG9zdDAqMAUG\nAytlcAMhAA1FzGJXuQdOpKv02SEl7SIA8SP8RVRI0QTi1bUFiFBLMAUGAytlcANB\nADiCKRUGDMyafSSYhl0KXoiXrFOxvhrGM5l15L4q82JM5Qb8wv0gNrnbGTZlInuv\nouB5ZN+05DzKCQhBekMnygQ=\n-----END CERTIFICATE-----\n`))\n\n\ttlsConfig := TLSConfig{\n\t\tUseTLS:                true,\n\t\tSkipTLSVerify:         true,\n\t\tCAFilePath:            caFile.Name(),\n\t\tClientCertFilePath:    \"\",\n\t\tClientCertKeyFilePath: \"\",\n\t}\n\n\tprovider, err := createRfc2136TLSStubProvider(stub, tlsConfig)\n\tassert.NoError(t, err)\n\n\trawProvider := provider.(*rfc2136Provider)\n\n\tclient, err := makeClient(rawProvider, rawProvider.nameservers[0])\n\tassert.NoError(t, err)\n\n\tassert.Equal(t, \"tcp-tls\", client.Net)\n\tassert.True(t, client.TLSConfig.InsecureSkipVerify)\n\tassert.Equal(t, \"rfc2136-host\", client.TLSConfig.ServerName)\n\tassert.Equal(t, uint16(tls.VersionTLS13), client.TLSConfig.MinVersion)\n\tassert.Equal(t, []string{\"dot\"}, client.TLSConfig.NextProtos)\n}\n\nfunc TestRfc2136TLSConfigClientAuth(t *testing.T) {\n\tstub := newStub()\n\n\tcaFile, err := os.CreateTemp(t.TempDir(), \"rfc2136-test-XXXXXXXX.crt\")\n\tassert.NoError(t, err)\n\tdefer os.Remove(caFile.Name())\n\t_, err = caFile.Write([]byte(\n\t\t`-----BEGIN CERTIFICATE-----\nMIH+MIGxAhR2n1aQk0ONrQ8QQfa6GCzFWLmTXTAFBgMrZXAwITELMAkGA1UEBhMC\nREUxEjAQBgNVBAMMCWxvY2FsaG9zdDAgFw0yMzEwMjQwNzI5NDNaGA8yMTIzMDkz\nMDA3Mjk0M1owITELMAkGA1UEBhMCREUxEjAQBgNVBAMMCWxvY2FsaG9zdDAqMAUG\nAytlcAMhAA1FzGJXuQdOpKv02SEl7SIA8SP8RVRI0QTi1bUFiFBLMAUGAytlcANB\nADiCKRUGDMyafSSYhl0KXoiXrFOxvhrGM5l15L4q82JM5Qb8wv0gNrnbGTZlInuv\nouB5ZN+05DzKCQhBekMnygQ=\n-----END CERTIFICATE-----\n`))\n\n\tcertFile, err := os.CreateTemp(t.TempDir(), \"rfc2136-test-XXXXXXXX-client.crt\")\n\tassert.NoError(t, err)\n\tdefer os.Remove(certFile.Name())\n\t_, err = certFile.Write([]byte(\n\t\t`-----BEGIN CERTIFICATE-----\nMIIBfDCCAQICFANNDjPVDMTPm63C0jZ9M3H5I7GJMAoGCCqGSM49BAMCMCExCzAJ\nBgNVBAYTAkRFMRIwEAYDVQQDDAlsb2NhbGhvc3QwIBcNMjMxMDI0MDcyMTU1WhgP\nMjEyMzA5MzAwNzIxNTVaMCExCzAJBgNVBAYTAkRFMRIwEAYDVQQDDAlsb2NhbGhv\nc3QwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQj7rjkeUEvjBT++IBMnIWgmI9VIjFx\n4VUGFmzPEawOckdnKW4fBdePiItsgePDVK4Oys5bzfSDhl6aAPCe16pwvljB7yIm\nxLJ+ytWk7OV/s10cmlaczrEtNeUjV1X9MTMwCgYIKoZIzj0EAwIDaAAwZQIwcZl8\nTrwwsyX3A0enXB1ih+nruF8Q9f9Rmm2pNcbEv24QIW/P2HGQm9qfx4lrYa7hAjEA\ngoRP/fRfTTTLwLg8UBpUAmALX8A8HBSBaUlTTQcaImbcwU4DRSbv5JEA8tM1mWrA\n-----END CERTIFICATE-----\n`))\n\n\tkeyFile, err := os.CreateTemp(t.TempDir(), \"rfc2136-test-XXXXXXXX-client.key\")\n\tassert.NoError(t, err)\n\tdefer os.Remove(keyFile.Name())\n\t_, err = keyFile.Write([]byte(\n\t\t`-----BEGIN PRIVATE KEY-----\nMIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDD5B+aPE+TuHCvW1f7L\nU8jEPVXHv1fvCR8uBSsf1qdPo929XGpt5y5QfIGdW3NUeHWhZANiAAQj7rjkeUEv\njBT++IBMnIWgmI9VIjFx4VUGFmzPEawOckdnKW4fBdePiItsgePDVK4Oys5bzfSD\nhl6aAPCe16pwvljB7yImxLJ+ytWk7OV/s10cmlaczrEtNeUjV1X9MTM=\n-----END PRIVATE KEY-----\n`))\n\n\ttlsConfig := TLSConfig{\n\t\tUseTLS:                true,\n\t\tSkipTLSVerify:         false,\n\t\tCAFilePath:            caFile.Name(),\n\t\tClientCertFilePath:    certFile.Name(),\n\t\tClientCertKeyFilePath: keyFile.Name(),\n\t}\n\n\tprovider, err := createRfc2136TLSStubProvider(stub, tlsConfig)\n\tlog.Infof(\"provider, err is: %s\", err)\n\tassert.NoError(t, err)\n\n\trawProvider := provider.(*rfc2136Provider)\n\n\tclient, err := makeClient(rawProvider, rawProvider.nameservers[0])\n\tlog.Infof(\"client, err is: %v\", client)\n\tlog.Infof(\"client, err is: %s\", err)\n\tassert.NoError(t, err)\n\n\tassert.Equal(t, \"tcp-tls\", client.Net)\n\tassert.False(t, client.TLSConfig.InsecureSkipVerify)\n\tassert.Equal(t, \"rfc2136-host\", client.TLSConfig.ServerName)\n\tassert.Equal(t, uint16(tls.VersionTLS13), client.TLSConfig.MinVersion)\n\tassert.Equal(t, []string{\"dot\"}, client.TLSConfig.NextProtos)\n}\n\nfunc TestRfc2136GetRecords(t *testing.T) {\n\tstub := newStub()\n\terr := stub.setOutput([]string{\n\t\t\"v4.barfoo.com 3600 TXT test1\",\n\t\t\"v1.foo.com 3600 TXT test2\",\n\t\t\"v2.bar.com 3600 A 8.8.8.8\",\n\t\t\"v3.bar.com 3600 TXT bbbb\",\n\t\t\"v2.foo.com 3600 CNAME cccc\",\n\t\t\"v1.foobar.com 3600 TXT dddd\",\n\t})\n\tassert.NoError(t, err)\n\n\tprovider, err := createRfc2136StubProvider(stub, \"barfoo.com\", \"foo.com\", \"bar.com\", \"foobar.com\")\n\tassert.NoError(t, err)\n\n\trecs, err := provider.Records(t.Context())\n\tassert.NoError(t, err)\n\n\tassert.Len(t, recs, 6)\n\tassert.True(t, contains(recs, \"v1.foo.com\"))\n\tassert.True(t, contains(recs, \"v2.bar.com\"))\n\tassert.True(t, contains(recs, \"v2.foo.com\"))\n}\n\n// Make sure the test version of SendMessage raises an error\n// if a zone update ever contains records outside of its zone\n// as the TestRfc2136ApplyChanges tests all assume this\nfunc TestRfc2136SendMessage(t *testing.T) {\n\tstub := newStub()\n\n\tm := new(dns.Msg)\n\tm.SetUpdate(\"foo.com.\")\n\trr, err := dns.NewRR(fmt.Sprintf(\"%s %d %s %s\", \"v1.foo.com.\", 0, \"A\", \"1.2.3.4\"))\n\tm.Insert([]dns.RR{rr})\n\n\terr = stub.SendMessage(m)\n\tassert.NoError(t, err)\n\n\trr, err = dns.NewRR(fmt.Sprintf(\"%s %d %s %s\", \"v1.bar.com.\", 0, \"A\", \"1.2.3.4\"))\n\tm.Insert([]dns.RR{rr})\n\n\terr = stub.SendMessage(m)\n\tassert.Error(t, err)\n\n\tm.SetUpdate(\".\")\n\terr = stub.SendMessage(m)\n\tassert.NoError(t, err)\n}\n\n// These tests are use the . root zone with no filters\nfunc TestRfc2136ApplyChanges(t *testing.T) {\n\tstub := newStub()\n\tprovider, err := createRfc2136StubProvider(stub)\n\tassert.NoError(t, err)\n\n\tp := &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"v1.foo.com\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tTargets:    []string{\"1.2.3.4\"},\n\t\t\t\tRecordTTL:  endpoint.TTL(400),\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"v1.foobar.com\",\n\t\t\t\tRecordType: \"TXT\",\n\t\t\t\tTargets:    []string{\"boom\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"ns.foobar.com\",\n\t\t\t\tRecordType: \"NS\",\n\t\t\t\tTargets:    []string{\"boom\"},\n\t\t\t},\n\t\t},\n\t\tDelete: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"v2.foo.com\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tTargets:    []string{\"1.2.3.4\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"v2.foobar.com\",\n\t\t\t\tRecordType: \"TXT\",\n\t\t\t\tTargets:    []string{\"boom2\"},\n\t\t\t},\n\t\t},\n\t}\n\n\terr = provider.ApplyChanges(t.Context(), p)\n\tassert.NoError(t, err)\n\n\tassert.Len(t, stub.createMsgs, 3)\n\tassert.Contains(t, stub.createMsgs[0].String(), \"v1.foo.com\")\n\tassert.Contains(t, stub.createMsgs[0].String(), \"1.2.3.4\")\n\n\tassert.Contains(t, stub.createMsgs[1].String(), \"v1.foobar.com\")\n\tassert.Contains(t, stub.createMsgs[1].String(), \"boom\")\n\n\tassert.Contains(t, stub.createMsgs[2].String(), \"ns.foobar.com\")\n\tassert.Contains(t, stub.createMsgs[2].String(), \"boom\")\n\n\tassert.Len(t, stub.updateMsgs, 2)\n\tassert.Contains(t, stub.updateMsgs[0].String(), \"v2.foo.com\")\n\tassert.Contains(t, stub.updateMsgs[1].String(), \"v2.foobar.com\")\n}\n\n// These tests all use the foo.com and foobar.com zones with no filters\n// createMsgs and updateMsgs need sorted when are used\nfunc TestRfc2136ApplyChangesWithZones(t *testing.T) {\n\tstub := newStub()\n\tprovider, err := createRfc2136StubProviderWithZones(stub)\n\tassert.NoError(t, err)\n\n\tp := &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"v1.foo.com\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tTargets:    []string{\"1.2.3.4\"},\n\t\t\t\tRecordTTL:  endpoint.TTL(400),\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"v1.foobar.com\",\n\t\t\t\tRecordType: \"TXT\",\n\t\t\t\tTargets:    []string{\"boom\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"ns.foobar.com\",\n\t\t\t\tRecordType: \"NS\",\n\t\t\t\tTargets:    []string{\"boom\"},\n\t\t\t},\n\t\t},\n\t\tDelete: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"v2.foo.com\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tTargets:    []string{\"1.2.3.4\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"v2.foobar.com\",\n\t\t\t\tRecordType: \"TXT\",\n\t\t\t\tTargets:    []string{\"boom2\"},\n\t\t\t},\n\t\t},\n\t}\n\n\terr = provider.ApplyChanges(t.Context(), p)\n\tassert.NoError(t, err)\n\n\tassert.Len(t, stub.createMsgs, 3)\n\tcreateMsgs := getSortedChanges(stub.createMsgs)\n\tassert.Len(t, createMsgs, 3)\n\n\tassert.Contains(t, createMsgs[0], \"v1.foo.com\")\n\tassert.Contains(t, createMsgs[0], \"1.2.3.4\")\n\n\tassert.Contains(t, createMsgs[1], \"v1.foobar.com\")\n\tassert.Contains(t, createMsgs[1], \"boom\")\n\n\tassert.Contains(t, createMsgs[2], \"ns.foobar.com\")\n\tassert.Contains(t, createMsgs[2], \"boom\")\n\n\tassert.Len(t, stub.updateMsgs, 2)\n\tupdateMsgs := getSortedChanges(stub.updateMsgs)\n\tassert.Len(t, updateMsgs, 2)\n\n\tassert.Contains(t, updateMsgs[0], \"v2.foo.com\")\n\tassert.Contains(t, updateMsgs[1], \"v2.foobar.com\")\n}\n\n// These tests use the foo.com and foobar.com zones and with filters set to both zones\n// createMsgs and updateMsgs need sorted when are used\nfunc TestRfc2136ApplyChangesWithZonesFilters(t *testing.T) {\n\tstub := newStub()\n\tprovider, err := createRfc2136StubProviderWithZonesFilters(stub)\n\tassert.NoError(t, err)\n\n\tp := &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"v1.foo.com\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tTargets:    []string{\"1.2.3.4\"},\n\t\t\t\tRecordTTL:  endpoint.TTL(400),\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"v1.foobar.com\",\n\t\t\t\tRecordType: \"TXT\",\n\t\t\t\tTargets:    []string{\"boom\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"ns.foobar.com\",\n\t\t\t\tRecordType: \"NS\",\n\t\t\t\tTargets:    []string{\"boom\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"filtered-out.foo.bar\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tTargets:    []string{\"1.2.3.4\"},\n\t\t\t\tRecordTTL:  endpoint.TTL(400),\n\t\t\t},\n\t\t},\n\t\tDelete: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"v2.foo.com\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tTargets:    []string{\"1.2.3.4\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"v2.foobar.com\",\n\t\t\t\tRecordType: \"TXT\",\n\t\t\t\tTargets:    []string{\"boom2\"},\n\t\t\t},\n\t\t},\n\t}\n\n\terr = provider.ApplyChanges(t.Context(), p)\n\tassert.NoError(t, err)\n\n\tassert.Len(t, stub.createMsgs, 3)\n\tcreateMsgs := getSortedChanges(stub.createMsgs)\n\tassert.Len(t, createMsgs, 3)\n\n\tassert.Contains(t, createMsgs[0], \"v1.foo.com\")\n\tassert.Contains(t, createMsgs[0], \"1.2.3.4\")\n\n\tassert.Contains(t, createMsgs[1], \"v1.foobar.com\")\n\tassert.Contains(t, createMsgs[1], \"boom\")\n\n\tassert.Contains(t, createMsgs[2], \"ns.foobar.com\")\n\tassert.Contains(t, createMsgs[2], \"boom\")\n\n\tfor _, s := range createMsgs {\n\t\tassert.NotContains(t, s, \"filtered-out.foo.bar\")\n\t}\n\n\tassert.Len(t, stub.updateMsgs, 2)\n\tupdateMsgs := getSortedChanges(stub.updateMsgs)\n\tassert.Len(t, updateMsgs, 2)\n\n\tassert.Contains(t, updateMsgs[0], \"v2.foo.com\")\n\tassert.Contains(t, updateMsgs[1], \"v2.foobar.com\")\n\n}\n\nfunc TestRfc2136ApplyChangesWithDifferentTTLs(t *testing.T) {\n\tstub := newStub()\n\n\tprovider, err := createRfc2136StubProvider(stub)\n\tassert.NoError(t, err)\n\n\tp := &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"v1.foo.com\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tTargets:    []string{\"2.1.1.1\"},\n\t\t\t\tRecordTTL:  endpoint.TTL(400),\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"v2.foo.com\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tTargets:    []string{\"3.2.2.2\"},\n\t\t\t\tRecordTTL:  endpoint.TTL(200),\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"v3.foo.com\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tTargets:    []string{\"4.3.3.3\"},\n\t\t\t},\n\t\t},\n\t}\n\n\terr = provider.ApplyChanges(t.Context(), p)\n\tassert.NoError(t, err)\n\n\tcreateRecords := extractUpdateSectionFromMessage(stub.createMsgs[0])\n\tassert.Len(t, createRecords, 3)\n\tassert.Contains(t, createRecords[0], \"v1.foo.com\")\n\tassert.Contains(t, createRecords[0], \"2.1.1.1\")\n\tassert.Contains(t, createRecords[0], \"400\")\n\tassert.Contains(t, createRecords[1], \"v2.foo.com\")\n\tassert.Contains(t, createRecords[1], \"3.2.2.2\")\n\tassert.Contains(t, createRecords[1], \"300\")\n\tassert.Contains(t, createRecords[2], \"v3.foo.com\")\n\tassert.Contains(t, createRecords[2], \"4.3.3.3\")\n\tassert.Contains(t, createRecords[2], \"300\")\n}\n\nfunc TestRfc2136ApplyChangesWithUpdate(t *testing.T) {\n\tstub := newStub()\n\n\tprovider, err := createRfc2136StubProvider(stub)\n\tassert.NoError(t, err)\n\n\tp := &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"v1.foo.com\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tTargets:    []string{\"1.2.3.4\"},\n\t\t\t\tRecordTTL:  endpoint.TTL(400),\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"v1.foobar.com\",\n\t\t\t\tRecordType: \"TXT\",\n\t\t\t\tTargets:    []string{\"boom\"},\n\t\t\t},\n\t\t},\n\t}\n\n\terr = provider.ApplyChanges(t.Context(), p)\n\tassert.NoError(t, err)\n\n\tp = &plan.Changes{\n\t\tUpdateOld: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"v1.foo.com\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tTargets:    []string{\"1.2.3.4\"},\n\t\t\t\tRecordTTL:  endpoint.TTL(400),\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"v1.foobar.com\",\n\t\t\t\tRecordType: \"TXT\",\n\t\t\t\tTargets:    []string{\"boom\"},\n\t\t\t},\n\t\t},\n\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"v1.foo.com\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tTargets:    []string{\"1.2.3.5\"},\n\t\t\t\tRecordTTL:  endpoint.TTL(400),\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"v1.foobar.com\",\n\t\t\t\tRecordType: \"TXT\",\n\t\t\t\tTargets:    []string{\"kablui\"},\n\t\t\t},\n\t\t},\n\t}\n\n\terr = provider.ApplyChanges(t.Context(), p)\n\tassert.NoError(t, err)\n\n\tassert.Len(t, stub.createMsgs, 4)\n\tassert.Len(t, stub.updateMsgs, 2)\n\n\tassert.Contains(t, stub.createMsgs[0].String(), \"v1.foo.com\")\n\tassert.Contains(t, stub.createMsgs[0].String(), \"1.2.3.4\")\n\tassert.Contains(t, stub.createMsgs[2].String(), \"v1.foo.com\")\n\tassert.Contains(t, stub.createMsgs[2].String(), \"1.2.3.5\")\n\n\tassert.Contains(t, stub.updateMsgs[0].String(), \"v1.foo.com\")\n\tassert.Contains(t, stub.updateMsgs[0].String(), \"1.2.3.4\")\n\n\tassert.Contains(t, stub.createMsgs[1].String(), \"v1.foobar.com\")\n\tassert.Contains(t, stub.createMsgs[1].String(), \"boom\")\n\tassert.Contains(t, stub.createMsgs[3].String(), \"v1.foobar.com\")\n\tassert.Contains(t, stub.createMsgs[3].String(), \"kablui\")\n\n\tassert.Contains(t, stub.updateMsgs[1].String(), \"v1.foobar.com\")\n\tassert.Contains(t, stub.updateMsgs[1].String(), \"boom\")\n}\n\nfunc TestChunkBy(t *testing.T) {\n\tvar records []*endpoint.Endpoint\n\n\tfor range 10 {\n\t\trecords = append(records, &endpoint.Endpoint{\n\t\t\tDNSName:    \"v1.foo.com\",\n\t\t\tRecordType: \"A\",\n\t\t\tTargets:    []string{\"1.1.2.2\"},\n\t\t\tRecordTTL:  endpoint.TTL(400),\n\t\t})\n\t}\n\n\tchunks := chunkBy(records, 2)\n\tif len(chunks) != 5 {\n\t\tt.Errorf(\"incorrect number of chunks returned\")\n\t}\n}\n\nfunc contains(arr []*endpoint.Endpoint, name string) bool {\n\tfor _, a := range arr {\n\t\tif a.DNSName == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// TestCreateRfc2136StubProviderWithHosts validates the stub provider initializes with multiple nameservers.\nfunc TestCreateRfc2136StubProviderWithHosts(t *testing.T) {\n\tstub := newStub()\n\tprovider, err := createRfc2136StubProviderWithHosts(stub)\n\trequire.NoError(t, err)\n\n\trawProvider, ok := provider.(*rfc2136Provider)\n\tassert.True(t, ok, \"expected provider to be of type *rfc2136Provider\")\n\n\tassert.Len(t, rawProvider.nameservers, 3)\n\tassert.Equal(t, \"rfc2136-host1:0\", rawProvider.nameservers[0])\n\tassert.Equal(t, \"rfc2136-host2:0\", rawProvider.nameservers[1])\n\tassert.Equal(t, \"rfc2136-host3:0\", rawProvider.nameservers[2])\n}\n\n// TestRoundRobinLoadBalancing tests the round-robin load balancing strategy.\nfunc TestRoundRobinLoadBalancing(t *testing.T) {\n\tstub := newStubLB(\"round-robin\", []string{\"rfc2136-host1\", \"rfc2136-host2\", \"rfc2136-host3\"})\n\t_, err := createRfc2136StubProviderWithStrategy(stub, \"round-robin\")\n\trequire.NoError(t, err)\n\n\tm := new(dns.Msg)\n\tm.SetUpdate(\"foo.com.\")\n\trr, err := dns.NewRR(fmt.Sprintf(\"%s %d %s %s\", \"v1.foo.com.\", 0, \"A\", \"1.2.3.4\"))\n\tm.Insert([]dns.RR{rr})\n\n\tfor i := range 10 {\n\t\terr := stub.SendMessage(m)\n\t\tassert.NoError(t, err)\n\t\texpectedNameserver := \"rfc2136-host\" + strconv.Itoa((i%3)+1)\n\t\tassert.Equal(t, expectedNameserver, stub.lastNameserver)\n\t}\n}\n\n// TestRandomLoadBalancing tests the random load balancing strategy.\nfunc TestRandomLoadBalancing(t *testing.T) {\n\tstub := newStubLB(\"random\", []string{\"rfc2136-host1\", \"rfc2136-host2\", \"rfc2136-host3\"})\n\t_, err := createRfc2136StubProviderWithStrategy(stub, \"random\")\n\trequire.NoError(t, err)\n\n\tm := new(dns.Msg)\n\tm.SetUpdate(\"foo.com.\")\n\trr, err := dns.NewRR(fmt.Sprintf(\"%s %d %s %s\", \"v1.foo.com.\", 0, \"A\", \"1.2.3.4\"))\n\tm.Insert([]dns.RR{rr})\n\n\tnameserverCounts := map[string]int{}\n\n\tfor range 25 {\n\t\terr := stub.SendMessage(m)\n\t\tassert.NoError(t, err)\n\t\tnameserverCounts[stub.lastNameserver]++\n\t}\n\n\tassert.Greater(t, len(nameserverCounts), 1, \"Expected multiple nameservers to be used in random strategy\")\n}\n\n// TestRfc2136ApplyChangesWithMultipleChunks tests Updates with multiple chunks\nfunc TestRfc2136ApplyChangesWithMultipleChunks(t *testing.T) {\n\tstub := newStub()\n\n\tprovider, err := createRfc2136StubProviderWithBatchChangeSize(stub, 2)\n\tassert.NoError(t, err)\n\n\tvar oldRecords []*endpoint.Endpoint\n\tvar newRecords []*endpoint.Endpoint\n\n\tfor i := 1; i <= 4; i++ {\n\t\toldRecords = append(oldRecords, &endpoint.Endpoint{\n\t\t\tDNSName:    fmt.Sprintf(\"%s%d%s\", \"v\", i, \".foo.com\"),\n\t\t\tRecordType: \"A\",\n\t\t\tTargets:    []string{fmt.Sprintf(\"10.0.0.%d\", i)},\n\t\t\tRecordTTL:  endpoint.TTL(400),\n\t\t})\n\t\tnewRecords = append(newRecords, &endpoint.Endpoint{\n\t\t\tDNSName:    fmt.Sprintf(\"%s%d%s\", \"v\", i, \".foo.com\"),\n\t\t\tRecordType: \"A\",\n\t\t\tTargets:    []string{fmt.Sprintf(\"10.0.1.%d\", i)},\n\t\t\tRecordTTL:  endpoint.TTL(400),\n\t\t})\n\t}\n\n\tp := &plan.Changes{\n\t\tUpdateOld: oldRecords,\n\t\tUpdateNew: newRecords,\n\t}\n\n\terr = provider.ApplyChanges(t.Context(), p)\n\tassert.NoError(t, err)\n\n\tassert.Len(t, stub.updateMsgs, 4)\n\n\tassert.Contains(t, stub.updateMsgs[0].String(), \"\\nv1.foo.com.\\t0\\tNONE\\tA\\t10.0.0.1\\nv1.foo.com.\\t400\\tIN\\tA\\t10.0.1.1\\n\")\n\tassert.Contains(t, stub.updateMsgs[0].String(), \"\\nv2.foo.com.\\t0\\tNONE\\tA\\t10.0.0.2\\nv2.foo.com.\\t400\\tIN\\tA\\t10.0.1.2\\n\")\n\tassert.Contains(t, stub.updateMsgs[2].String(), \"\\nv3.foo.com.\\t0\\tNONE\\tA\\t10.0.0.3\\nv3.foo.com.\\t400\\tIN\\tA\\t10.0.1.3\\n\")\n\tassert.Contains(t, stub.updateMsgs[2].String(), \"\\nv4.foo.com.\\t0\\tNONE\\tA\\t10.0.0.4\\nv4.foo.com.\\t400\\tIN\\tA\\t10.0.1.4\\n\")\n}\n\n// Test stub that simulates nameserver connection failures\ntype failingRfc2136Stub struct {\n\trfc2136Stub\n}\n\nfunc (r *failingRfc2136Stub) SendMessage(_ *dns.Msg) error {\n\treturn fmt.Errorf(\"failed to connect: dial tcp: lookup unreachable-nameserver: no such host\")\n}\n\nfunc (r *failingRfc2136Stub) IncomeTransfer(_ *dns.Msg, _ string) (chan *dns.Envelope, error) {\n\treturn nil, fmt.Errorf(\"failed to connect for transfer: dial tcp: lookup unreachable-nameserver: no such host\")\n}\n\n// Test that nameserver failures return SoftError to prevent crashes\nfunc TestRfc2136NameserverFailureReturnsSoftError(t *testing.T) {\n\t// Create a stub that will fail all operations\n\tfailingStub := &failingRfc2136Stub{\n\t\trfc2136Stub: rfc2136Stub{\n\t\t\toutput:                make([]*dns.Envelope, 0),\n\t\t\tupdateMsgs:            make([]*dns.Msg, 0),\n\t\t\tcreateMsgs:            make([]*dns.Msg, 0),\n\t\t\tnameservers:           []string{\"unreachable-nameserver:53\"},\n\t\t\trandGen:               rand.New(rand.NewSource(time.Now().UnixNano())),\n\t\t\tloadBalancingStrategy: \"round-robin\",\n\t\t},\n\t}\n\n\ttlsConfig := TLSConfig{\n\t\tUseTLS:                false,\n\t\tSkipTLSVerify:         false,\n\t\tCAFilePath:            \"\",\n\t\tClientCertFilePath:    \"\",\n\t\tClientCertKeyFilePath: \"\",\n\t}\n\n\tproviderInstance, err := newProvider(\n\t\t[]string{\"unreachable-nameserver\"},\n\t\t53,\n\t\t[]string{\"example.com\"},\n\t\tfalse,\n\t\t\"key\",\n\t\t\"secret\",\n\t\t\"hmac-sha512\",\n\t\ttrue,\n\t\t&endpoint.DomainFilter{},\n\t\tfalse,\n\t\t300*time.Second,\n\t\tfalse,\n\t\tfalse,\n\t\t\"\",\n\t\t\"\",\n\t\t\"\",\n\t\t50,\n\t\ttlsConfig,\n\t\t\"round-robin\",\n\t\tfailingStub,\n\t)\n\tassert.NoError(t, err)\n\n\t// Test that Records() returns a SoftError when nameserver fails\n\t_, err = providerInstance.Records(t.Context())\n\tassert.Error(t, err)\n\tassert.ErrorIs(t, err, provider.SoftError, \"Expected SoftError when nameserver fails\")\n\n\t// Test that ApplyChanges() returns a SoftError when nameserver fails\n\tp := &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"test.example.com\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tTargets:    []string{\"1.2.3.4\"},\n\t\t\t},\n\t\t},\n\t}\n\terr = providerInstance.ApplyChanges(t.Context(), p)\n\tassert.Error(t, err)\n\tassert.ErrorIs(t, err, provider.SoftError, \"Expected SoftError when nameserver fails in ApplyChanges\")\n}\n"
  },
  {
    "path": "provider/scaleway/interface.go",
    "content": "/*\nCopyright 2020 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage scaleway\n\nimport (\n\tdomain \"github.com/scaleway/scaleway-sdk-go/api/domain/v2beta1\"\n\t\"github.com/scaleway/scaleway-sdk-go/scw\"\n)\n\n// DomainAPI is an interface matching the domain.API struct\ntype DomainAPI interface {\n\tListDNSZones(req *domain.ListDNSZonesRequest, opts ...scw.RequestOption) (*domain.ListDNSZonesResponse, error)\n\tListDNSZoneRecords(req *domain.ListDNSZoneRecordsRequest, opts ...scw.RequestOption) (*domain.ListDNSZoneRecordsResponse, error)\n\tUpdateDNSZoneRecords(req *domain.UpdateDNSZoneRecordsRequest, opts ...scw.RequestOption) (*domain.UpdateDNSZoneRecordsResponse, error)\n}\n"
  },
  {
    "path": "provider/scaleway/scaleway.go",
    "content": "/*\nCopyright 2020 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage scaleway\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"strconv\"\n\t\"strings\"\n\n\tdomain \"github.com/scaleway/scaleway-sdk-go/api/domain/v2beta1\"\n\t\"github.com/scaleway/scaleway-sdk-go/scw\"\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\nconst (\n\tdefaultTTL              uint32 = 300\n\tscalewayDefaultPriority uint32 = 0\n\tscalewayPriorityKey     string = \"scw/priority\"\n)\n\n// ScalewayProvider implements the DNS provider for Scaleway DNS\ntype ScalewayProvider struct {\n\tprovider.BaseProvider\n\tdomainAPI DomainAPI\n\tdryRun    bool\n\t// only consider hosted zones managing domains ending in this suffix\n\tdomainFilter *endpoint.DomainFilter\n}\n\n// ScalewayChange differentiates between ChangActions\ntype ScalewayChange struct {\n\tAction string\n\tRecord []domain.Record\n}\n\n// New creates a Scaleway provider from the given configuration.\nfunc New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) {\n\treturn newProvider(domainFilter, cfg.DryRun)\n}\n\n// newProvider initializes a new Scaleway DNS provider\nfunc newProvider(domainFilter *endpoint.DomainFilter, dryRun bool) (*ScalewayProvider, error) {\n\tvar err error\n\tdefaultPageSize := uint64(1000)\n\tif envPageSize, ok := os.LookupEnv(\"SCW_DEFAULT_PAGE_SIZE\"); ok {\n\t\tdefaultPageSize, err = strconv.ParseUint(envPageSize, 10, 32)\n\t\tif err != nil {\n\t\t\tlog.Infof(\"Ignoring default page size %s, defaulting to 1000\", envPageSize)\n\t\t\tdefaultPageSize = 1000\n\t\t}\n\t}\n\n\tp := &scw.Profile{}\n\tc, err := scw.LoadConfig()\n\tif err != nil {\n\t\tlog.Warnf(\"Cannot load config: %v\", err)\n\t} else {\n\t\tp, err = c.GetActiveProfile()\n\t\tif err != nil {\n\t\t\tlog.Warnf(\"Cannot get active profile: %v\", err)\n\t\t}\n\t}\n\n\tscwClient, err := scw.NewClient(\n\t\tscw.WithProfile(p),\n\t\tscw.WithEnv(),\n\t\tscw.WithUserAgent(externaldns.UserAgent()),\n\t\tscw.WithDefaultPageSize(uint32(defaultPageSize)),\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif _, ok := scwClient.GetAccessKey(); !ok {\n\t\treturn nil, fmt.Errorf(\"access key no set\")\n\t}\n\n\tif _, ok := scwClient.GetSecretKey(); !ok {\n\t\treturn nil, fmt.Errorf(\"secret key no set\")\n\t}\n\n\tdomainAPI := domain.NewAPI(scwClient)\n\n\treturn &ScalewayProvider{\n\t\tdomainAPI:    domainAPI,\n\t\tdryRun:       dryRun,\n\t\tdomainFilter: domainFilter,\n\t}, nil\n}\n\n// AdjustEndpoints is used to normalize the endoints\nfunc (p *ScalewayProvider) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) {\n\teps := make([]*endpoint.Endpoint, len(endpoints))\n\tfor i := range endpoints {\n\t\teps[i] = endpoints[i]\n\t\tif !eps[i].RecordTTL.IsConfigured() {\n\t\t\teps[i].RecordTTL = endpoint.TTL(defaultTTL)\n\t\t}\n\t\tif _, ok := eps[i].GetProviderSpecificProperty(scalewayPriorityKey); !ok {\n\t\t\teps[i] = eps[i].WithProviderSpecific(scalewayPriorityKey, fmt.Sprintf(\"%d\", scalewayDefaultPriority))\n\t\t}\n\t}\n\treturn eps, nil\n}\n\n// Zones returns the list of hosted zones.\nfunc (p *ScalewayProvider) Zones(ctx context.Context) ([]*domain.DNSZone, error) {\n\tres := []*domain.DNSZone{}\n\n\tdnsZones, err := p.domainAPI.ListDNSZones(&domain.ListDNSZonesRequest{}, scw.WithAllPages(), scw.WithContext(ctx))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor _, dnsZone := range dnsZones.DNSZones {\n\t\tif p.domainFilter.Match(getCompleteZoneName(dnsZone)) {\n\t\t\tres = append(res, dnsZone)\n\t\t}\n\t}\n\n\treturn res, nil\n}\n\n// Records returns the list of records in a given zone.\nfunc (p *ScalewayProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {\n\tendpoints := map[string]*endpoint.Endpoint{}\n\tdnsZones, err := p.Zones(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor _, zone := range dnsZones {\n\t\trecordsResp, err := p.domainAPI.ListDNSZoneRecords(&domain.ListDNSZoneRecordsRequest{\n\t\t\tDNSZone: getCompleteZoneName(zone),\n\t\t}, scw.WithAllPages())\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tfor _, record := range recordsResp.Records {\n\t\t\tname := record.Name + \".\"\n\n\t\t\t// trim any leading or ending dot\n\t\t\tfullRecordName := strings.Trim(name+getCompleteZoneName(zone), \".\")\n\n\t\t\tif !provider.SupportedRecordType(record.Type.String()) {\n\t\t\t\tlog.Infof(\"Skipping record %s because type %s is not supported\", fullRecordName, record.Type.String())\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// in external DNS, same endpoint have the same ttl and same priority\n\t\t\t// it's not the case in Scaleway DNS. It should never happen, but if\n\t\t\t// the record is modified without going through ExternalDNS, we could have\n\t\t\t// different priorities of ttls for a same name.\n\t\t\t// In this case, we juste take the first one.\n\t\t\tif existingEndpoint, ok := endpoints[record.Type.String()+\"/\"+fullRecordName]; ok {\n\t\t\t\texistingEndpoint.Targets = append(existingEndpoint.Targets, record.Data)\n\t\t\t\tlog.Infof(\"Appending target %s to record %s, using TTL and priority of target %s\", record.Data, fullRecordName, existingEndpoint.Targets[0])\n\t\t\t} else {\n\t\t\t\tep := endpoint.NewEndpointWithTTL(fullRecordName, record.Type.String(), endpoint.TTL(record.TTL), record.Data)\n\t\t\t\tep = ep.WithProviderSpecific(scalewayPriorityKey, fmt.Sprintf(\"%d\", record.Priority))\n\t\t\t\tendpoints[record.Type.String()+\"/\"+fullRecordName] = ep\n\t\t\t}\n\t\t}\n\t}\n\treturnedEndpoints := []*endpoint.Endpoint{}\n\tfor _, ep := range endpoints {\n\t\treturnedEndpoints = append(returnedEndpoints, ep)\n\t}\n\n\treturn returnedEndpoints, nil\n}\n\n// ApplyChanges applies a set of changes in a zone.\nfunc (p *ScalewayProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {\n\trequests, err := p.generateApplyRequests(ctx, changes)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, req := range requests {\n\t\tlogChanges(req)\n\t\tif p.dryRun {\n\t\t\tlog.Info(\"Running in dry run mode\")\n\t\t\tcontinue\n\t\t}\n\t\t_, err := p.domainAPI.UpdateDNSZoneRecords(req, scw.WithContext(ctx))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (p *ScalewayProvider) generateApplyRequests(ctx context.Context, changes *plan.Changes) ([]*domain.UpdateDNSZoneRecordsRequest, error) {\n\treturnedRequests := []*domain.UpdateDNSZoneRecordsRequest{}\n\trecordsToAdd := map[string]*domain.RecordChangeAdd{}\n\trecordsToDelete := map[string][]*domain.RecordChange{}\n\n\tdnsZones, err := p.Zones(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tzoneNameMapper := provider.ZoneIDName{}\n\tfor _, zone := range dnsZones {\n\t\tzoneName := getCompleteZoneName(zone)\n\t\tzoneNameMapper.Add(zoneName, zoneName)\n\t\trecordsToAdd[zoneName] = &domain.RecordChangeAdd{\n\t\t\tRecords: []*domain.Record{},\n\t\t}\n\t\trecordsToDelete[zoneName] = []*domain.RecordChange{}\n\t}\n\n\tlog.Debugf(\"Following records present in updateOld\")\n\tfor _, c := range changes.UpdateOld {\n\t\tzone, _ := zoneNameMapper.FindZone(c.DNSName)\n\t\tif zone == \"\" {\n\t\t\tlog.Infof(\"Ignore record %s since it's not handled by ExternalDNS\", c.DNSName)\n\t\t\tcontinue\n\t\t}\n\t\trecordsToDelete[zone] = append(recordsToDelete[zone], endpointToScalewayRecordsChangeDelete(zone, c)...)\n\t\tlog.Debugf(\"%s\", c.String())\n\t}\n\n\tlog.Debugf(\"Following records present in delete\")\n\tfor _, c := range changes.Delete {\n\t\tzone, _ := zoneNameMapper.FindZone(c.DNSName)\n\t\tif zone == \"\" {\n\t\t\tlog.Infof(\"Ignore record %s since it's not handled by ExternalDNS\", c.DNSName)\n\t\t\tcontinue\n\t\t}\n\t\trecordsToDelete[zone] = append(recordsToDelete[zone], endpointToScalewayRecordsChangeDelete(zone, c)...)\n\t\tlog.Debugf(\"%s\", c.String())\n\t}\n\n\tlog.Debugf(\"Following records present in create\")\n\tfor _, c := range changes.Create {\n\t\tzone, _ := zoneNameMapper.FindZone(c.DNSName)\n\t\tif zone == \"\" {\n\t\t\tlog.Infof(\"Ignore record %s since it's not handled by ExternalDNS\", c.DNSName)\n\t\t\tcontinue\n\t\t}\n\t\trecordsToAdd[zone].Records = append(recordsToAdd[zone].Records, endpointToScalewayRecords(zone, c)...)\n\t\tlog.Debugf(\"%s\", c.String())\n\t}\n\n\tlog.Debugf(\"Following records present in updateNew\")\n\tfor _, c := range changes.UpdateNew {\n\t\tzone, _ := zoneNameMapper.FindZone(c.DNSName)\n\t\tif zone == \"\" {\n\t\t\tlog.Infof(\"Ignore record %s since it's not handled by ExternalDNS\", c.DNSName)\n\t\t\tcontinue\n\t\t}\n\t\trecordsToAdd[zone].Records = append(recordsToAdd[zone].Records, endpointToScalewayRecords(zone, c)...)\n\t\tlog.Debugf(\"%s\", c.String())\n\t}\n\n\tfor _, zone := range dnsZones {\n\t\tzoneName := getCompleteZoneName(zone)\n\t\treq := &domain.UpdateDNSZoneRecordsRequest{\n\t\t\tDNSZone: zoneName,\n\t\t\tChanges: recordsToDelete[zoneName],\n\t\t}\n\t\treq.Changes = append(req.Changes, &domain.RecordChange{\n\t\t\tAdd: recordsToAdd[zoneName],\n\t\t})\n\t\t// ignore sending empty update requests\n\t\tif len(req.Changes) == 1 && len(req.Changes[0].Add.Records) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\treturnedRequests = append(returnedRequests, req)\n\t}\n\n\treturn returnedRequests, nil\n}\n\nfunc getCompleteZoneName(zone *domain.DNSZone) string {\n\tsubdomain := zone.Subdomain + \".\"\n\tif zone.Subdomain == \"\" {\n\t\tsubdomain = \"\"\n\t}\n\treturn subdomain + zone.Domain\n}\n\nfunc endpointToScalewayRecords(zoneName string, ep *endpoint.Endpoint) []*domain.Record {\n\t// no annotation results in a TTL of 0, default to 300 for consistency with other providers\n\tttl := defaultTTL\n\tif ep.RecordTTL.IsConfigured() {\n\t\tttl = uint32(ep.RecordTTL)\n\t}\n\tpriority := scalewayDefaultPriority\n\tif prop, ok := ep.GetProviderSpecificProperty(scalewayPriorityKey); ok {\n\t\tprio, err := strconv.ParseUint(prop, 10, 32)\n\t\tif err != nil {\n\t\t\tlog.Errorf(\"Failed parsing value of %s: %s: %v; using priority of %d\", scalewayPriorityKey, prop, err, scalewayDefaultPriority)\n\t\t} else {\n\t\t\tpriority = uint32(prio)\n\t\t}\n\t}\n\n\trecords := []*domain.Record{}\n\n\tfor _, target := range ep.Targets {\n\t\tfinalTargetName := target\n\t\tif domain.RecordType(ep.RecordType) == domain.RecordTypeCNAME {\n\t\t\tfinalTargetName = provider.EnsureTrailingDot(target)\n\t\t}\n\n\t\trecords = append(records, &domain.Record{\n\t\t\tData:     finalTargetName,\n\t\t\tName:     strings.Trim(strings.TrimSuffix(ep.DNSName, zoneName), \". \"),\n\t\t\tPriority: priority,\n\t\t\tTTL:      ttl,\n\t\t\tType:     domain.RecordType(ep.RecordType),\n\t\t})\n\t}\n\n\treturn records\n}\n\nfunc endpointToScalewayRecordsChangeDelete(zoneName string, ep *endpoint.Endpoint) []*domain.RecordChange {\n\trecords := []*domain.RecordChange{}\n\n\tfor _, target := range ep.Targets {\n\t\tfinalTargetName := target\n\t\tif domain.RecordType(ep.RecordType) == domain.RecordTypeCNAME {\n\t\t\tfinalTargetName = provider.EnsureTrailingDot(target)\n\t\t}\n\n\t\trecords = append(records, &domain.RecordChange{\n\t\t\tDelete: &domain.RecordChangeDelete{\n\t\t\t\tIDFields: &domain.RecordIdentifier{\n\t\t\t\t\tData: &finalTargetName,\n\t\t\t\t\tName: strings.Trim(strings.TrimSuffix(ep.DNSName, zoneName), \". \"),\n\t\t\t\t\tType: domain.RecordType(ep.RecordType),\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t}\n\n\treturn records\n}\n\nfunc logChanges(req *domain.UpdateDNSZoneRecordsRequest) {\n\tif !log.IsLevelEnabled(log.InfoLevel) {\n\t\treturn\n\t}\n\tlog.Infof(\"Updating zone %s\", req.DNSZone)\n\tfor _, change := range req.Changes {\n\t\tif change.Add != nil {\n\t\t\tfor _, add := range change.Add.Records {\n\t\t\t\tname := add.Name + \".\"\n\t\t\t\tif add.Name == \"\" {\n\t\t\t\t\tname = \"\"\n\t\t\t\t}\n\n\t\t\t\tlogFields := log.Fields{\n\t\t\t\t\t\"record\":   name + req.DNSZone,\n\t\t\t\t\t\"type\":     add.Type.String(),\n\t\t\t\t\t\"ttl\":      add.TTL,\n\t\t\t\t\t\"priority\": add.Priority,\n\t\t\t\t\t\"data\":     add.Data,\n\t\t\t\t}\n\t\t\t\tlog.WithFields(logFields).Info(\"Adding record\")\n\t\t\t}\n\t\t} else if change.Delete != nil {\n\t\t\tname := change.Delete.IDFields.Name + \".\"\n\t\t\tif change.Delete.IDFields.Name == \"\" {\n\t\t\t\tname = \"\"\n\t\t\t}\n\n\t\t\tlogFields := log.Fields{\n\t\t\t\t\"record\": name + req.DNSZone,\n\t\t\t\t\"type\":   change.Delete.IDFields.Type.String(),\n\t\t\t\t\"data\":   *change.Delete.IDFields.Data,\n\t\t\t}\n\n\t\t\tlog.WithFields(logFields).Info(\"Deleting record\")\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "provider/scaleway/scaleway_test.go",
    "content": "/*\nCopyright 2020 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage scaleway\n\nimport (\n\t\"io\"\n\t\"os\"\n\t\"reflect\"\n\t\"testing\"\n\n\tdomain \"github.com/scaleway/scaleway-sdk-go/api/domain/v2beta1\"\n\t\"github.com/scaleway/scaleway-sdk-go/scw\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/plan\"\n)\n\ntype mockScalewayDomain struct {\n\t*domain.API\n}\n\nfunc (m *mockScalewayDomain) ListDNSZones(_ *domain.ListDNSZonesRequest, _ ...scw.RequestOption) (*domain.ListDNSZonesResponse, error) {\n\treturn &domain.ListDNSZonesResponse{\n\t\tDNSZones: []*domain.DNSZone{\n\t\t\t{\n\t\t\t\tDomain:    \"example.com\",\n\t\t\t\tSubdomain: \"\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tDomain:    \"example.com\",\n\t\t\t\tSubdomain: \"test\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tDomain:    \"dummy.me\",\n\t\t\t\tSubdomain: \"\",\n\t\t\t},\n\t\t\t{\n\t\t\t\tDomain:    \"dummy.me\",\n\t\t\t\tSubdomain: \"test\",\n\t\t\t},\n\t\t},\n\t}, nil\n}\n\nfunc (m *mockScalewayDomain) ListDNSZoneRecords(req *domain.ListDNSZoneRecordsRequest, _ ...scw.RequestOption) (*domain.ListDNSZoneRecordsResponse, error) {\n\trecords := []*domain.Record{}\n\tif req.DNSZone == \"example.com\" {\n\t\trecords = []*domain.Record{\n\t\t\t{\n\t\t\t\tData:     \"1.1.1.1\",\n\t\t\t\tName:     \"one\",\n\t\t\t\tTTL:      300,\n\t\t\t\tPriority: 0,\n\t\t\t\tType:     domain.RecordTypeA,\n\t\t\t},\n\t\t\t{\n\t\t\t\tData:     \"1.1.1.2\",\n\t\t\t\tName:     \"two\",\n\t\t\t\tTTL:      300,\n\t\t\t\tPriority: 0,\n\t\t\t\tType:     domain.RecordTypeA,\n\t\t\t},\n\t\t\t{\n\t\t\t\tData:     \"1.1.1.3\",\n\t\t\t\tName:     \"two\",\n\t\t\t\tTTL:      300,\n\t\t\t\tPriority: 0,\n\t\t\t\tType:     domain.RecordTypeA,\n\t\t\t},\n\t\t}\n\t} else if req.DNSZone == \"test.example.com\" {\n\t\trecords = []*domain.Record{\n\t\t\t{\n\t\t\t\tData:     \"1.1.1.1\",\n\t\t\t\tName:     \"\",\n\t\t\t\tTTL:      300,\n\t\t\t\tPriority: 0,\n\t\t\t\tType:     domain.RecordTypeA,\n\t\t\t},\n\t\t\t{\n\t\t\t\tData:     \"test.example.com.\",\n\t\t\t\tName:     \"two\",\n\t\t\t\tTTL:      600,\n\t\t\t\tPriority: 30,\n\t\t\t\tType:     domain.RecordTypeCNAME,\n\t\t\t},\n\t\t}\n\t}\n\treturn &domain.ListDNSZoneRecordsResponse{\n\t\tRecords: records,\n\t}, nil\n}\n\nfunc (m *mockScalewayDomain) UpdateDNSZoneRecords(_ *domain.UpdateDNSZoneRecordsRequest, _ ...scw.RequestOption) (*domain.UpdateDNSZoneRecordsResponse, error) {\n\treturn &domain.UpdateDNSZoneRecordsResponse{}, nil\n}\n\nfunc TestScalewayProvider_NewScalewayProvider(t *testing.T) {\n\tprofile := `profiles:\n  foo:\n    access_key: SCWXXXXXXXXXXXXXXXXX\n    secret_key: 11111111-1111-1111-1111-111111111111\n`\n\ttmpDir := t.TempDir()\n\terr := os.WriteFile(tmpDir+\"/config.yaml\", []byte(profile), 0600)\n\tif err != nil {\n\t\tt.Errorf(\"failed : %s\", err)\n\t}\n\tt.Setenv(scw.ScwActiveProfileEnv, \"foo\")\n\tt.Setenv(scw.ScwConfigPathEnv, tmpDir+\"/config.yaml\")\n\t_, err = newProvider(endpoint.NewDomainFilter([]string{\"example.com\"}), true)\n\tif err != nil {\n\t\tt.Errorf(\"failed : %s\", err)\n\t}\n\n\tt.Setenv(scw.ScwAccessKeyEnv, \"SCWXXXXXXXXXXXXXXXXX\")\n\tt.Setenv(scw.ScwSecretKeyEnv, \"11111111-1111-1111-1111-111111111111\")\n\t_, err = newProvider(endpoint.NewDomainFilter([]string{\"example.com\"}), true)\n\tif err != nil {\n\t\tt.Errorf(\"failed : %s\", err)\n\t}\n\n\t_ = os.Unsetenv(scw.ScwSecretKeyEnv)\n\t_, err = newProvider(endpoint.NewDomainFilter([]string{\"example.com\"}), true)\n\tif err == nil {\n\t\tt.Errorf(\"expected to fail\")\n\t}\n\n\tt.Setenv(scw.ScwSecretKeyEnv, \"dummy\")\n\t_, err = newProvider(endpoint.NewDomainFilter([]string{\"example.com\"}), true)\n\tif err == nil {\n\t\tt.Errorf(\"expected to fail\")\n\t}\n\n\t_ = os.Unsetenv(scw.ScwAccessKeyEnv)\n\tt.Setenv(scw.ScwSecretKeyEnv, \"11111111-1111-1111-1111-111111111111\")\n\t_, err = newProvider(endpoint.NewDomainFilter([]string{\"example.com\"}), true)\n\tif err == nil {\n\t\tt.Errorf(\"expected to fail\")\n\t}\n\n\tt.Setenv(scw.ScwAccessKeyEnv, \"dummy\")\n\t_, err = newProvider(endpoint.NewDomainFilter([]string{\"example.com\"}), true)\n\tif err == nil {\n\t\tt.Errorf(\"expected to fail\")\n\t}\n}\n\nfunc TestScalewayProvider_OptionnalConfigFile(t *testing.T) {\n\tlog.SetOutput(io.Discard)\n\tt.Setenv(scw.ScwAccessKeyEnv, \"SCWXXXXXXXXXXXXXXXXX\")\n\tt.Setenv(scw.ScwSecretKeyEnv, \"11111111-1111-1111-1111-111111111111\")\n\n\t_, err := newProvider(endpoint.NewDomainFilter([]string{\"example.com\"}), true)\n\tassert.NoError(t, err)\n}\n\nfunc TestScalewayProvider_AdjustEndpoints(t *testing.T) {\n\tprovider := &ScalewayProvider{}\n\n\tbefore := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"one.example.com\",\n\t\t\tRecordTTL:  300,\n\t\t\tRecordType: \"A\",\n\t\t\tTargets:    []string{\"1.1.1.1\"},\n\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t{\n\t\t\t\t\tName:  scalewayPriorityKey,\n\t\t\t\t\tValue: \"0\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"two.example.com\",\n\t\t\tRecordTTL:  0,\n\t\t\tRecordType: \"A\",\n\t\t\tTargets:    []string{\"1.1.1.1\"},\n\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t{\n\t\t\t\t\tName:  scalewayPriorityKey,\n\t\t\t\t\tValue: \"10\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:          \"three.example.com\",\n\t\t\tRecordTTL:        600,\n\t\t\tRecordType:       \"A\",\n\t\t\tTargets:          []string{\"1.1.1.1\"},\n\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t},\n\t}\n\n\texpected := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"one.example.com\",\n\t\t\tRecordTTL:  300,\n\t\t\tRecordType: \"A\",\n\t\t\tTargets:    []string{\"1.1.1.1\"},\n\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t{\n\t\t\t\t\tName:  scalewayPriorityKey,\n\t\t\t\t\tValue: \"0\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"two.example.com\",\n\t\t\tRecordTTL:  300,\n\t\t\tRecordType: \"A\",\n\t\t\tTargets:    []string{\"1.1.1.1\"},\n\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t{\n\t\t\t\t\tName:  scalewayPriorityKey,\n\t\t\t\t\tValue: \"10\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"three.example.com\",\n\t\t\tRecordTTL:  600,\n\t\t\tRecordType: \"A\",\n\t\t\tTargets:    []string{\"1.1.1.1\"},\n\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t{\n\t\t\t\t\tName:  scalewayPriorityKey,\n\t\t\t\t\tValue: \"0\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tafter, err := provider.AdjustEndpoints(before)\n\trequire.NoError(t, err)\n\tfor i := range after {\n\t\tif !checkRecordEquality(after[i], expected[i]) {\n\t\t\tt.Errorf(\"got record %s instead of %s\", after[i], expected[i])\n\t\t}\n\t}\n}\n\nfunc TestScalewayProvider_Zones(t *testing.T) {\n\tmocked := mockScalewayDomain{nil}\n\tprovider := &ScalewayProvider{\n\t\tdomainAPI:    &mocked,\n\t\tdomainFilter: endpoint.NewDomainFilter([]string{\"example.com\"}),\n\t}\n\n\texpected := []*domain.DNSZone{\n\t\t{\n\t\t\tDomain:    \"example.com\",\n\t\t\tSubdomain: \"\",\n\t\t},\n\t\t{\n\t\t\tDomain:    \"example.com\",\n\t\t\tSubdomain: \"test\",\n\t\t},\n\t}\n\tzones, err := provider.Zones(t.Context())\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\trequire.Len(t, zones, len(expected))\n\tfor i, zone := range zones {\n\t\tassert.Equal(t, expected[i], zone)\n\t}\n}\n\nfunc TestScalewayProvider_Records(t *testing.T) {\n\tmocked := mockScalewayDomain{nil}\n\tprovider := &ScalewayProvider{\n\t\tdomainAPI:    &mocked,\n\t\tdomainFilter: endpoint.NewDomainFilter([]string{\"example.com\"}),\n\t}\n\n\texpected := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"one.example.com\",\n\t\t\tRecordTTL:  300,\n\t\t\tRecordType: \"A\",\n\t\t\tTargets:    []string{\"1.1.1.1\"},\n\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t{\n\t\t\t\t\tName:  scalewayPriorityKey,\n\t\t\t\t\tValue: \"0\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"two.example.com\",\n\t\t\tRecordTTL:  300,\n\t\t\tRecordType: \"A\",\n\t\t\tTargets:    []string{\"1.1.1.2\", \"1.1.1.3\"},\n\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t{\n\t\t\t\t\tName:  scalewayPriorityKey,\n\t\t\t\t\tValue: \"0\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"test.example.com\",\n\t\t\tRecordTTL:  300,\n\t\t\tRecordType: \"A\",\n\t\t\tTargets:    []string{\"1.1.1.1\"},\n\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t{\n\t\t\t\t\tName:  scalewayPriorityKey,\n\t\t\t\t\tValue: \"0\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"two.test.example.com\",\n\t\t\tRecordTTL:  600,\n\t\t\tRecordType: \"CNAME\",\n\t\t\tTargets:    []string{\"test.example.com\"},\n\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t{\n\t\t\t\t\tName:  scalewayPriorityKey,\n\t\t\t\t\tValue: \"30\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\trecords, err := provider.Records(t.Context())\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\trequire.Len(t, records, len(expected))\n\tfor _, record := range records {\n\t\tfound := false\n\t\tfor _, expectedRecord := range expected {\n\t\t\tif checkRecordEquality(record, expectedRecord) {\n\t\t\t\tfound = true\n\t\t\t}\n\t\t}\n\t\tassert.True(t, found)\n\t}\n}\n\n// this test is really ugly since we are working on maps, so array are randomly sorted\n// feel free to modify if you have a better idea\nfunc TestScalewayProvider_generateApplyRequests(t *testing.T) {\n\tmocked := mockScalewayDomain{nil}\n\tprovider := &ScalewayProvider{\n\t\tdomainAPI:    &mocked,\n\t\tdomainFilter: endpoint.NewDomainFilter([]string{\"example.com\"}),\n\t}\n\n\texpected := []*domain.UpdateDNSZoneRecordsRequest{\n\t\t{\n\t\t\tDNSZone: \"example.com\",\n\t\t\tChanges: []*domain.RecordChange{\n\t\t\t\t{\n\t\t\t\t\tAdd: &domain.RecordChangeAdd{\n\t\t\t\t\t\tRecords: []*domain.Record{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tData:     \"1.1.1.1\",\n\t\t\t\t\t\t\t\tName:     \"\",\n\t\t\t\t\t\t\t\tTTL:      300,\n\t\t\t\t\t\t\t\tType:     domain.RecordTypeA,\n\t\t\t\t\t\t\t\tPriority: 0,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tData:     \"1.1.1.2\",\n\t\t\t\t\t\t\t\tName:     \"\",\n\t\t\t\t\t\t\t\tTTL:      300,\n\t\t\t\t\t\t\t\tType:     domain.RecordTypeA,\n\t\t\t\t\t\t\t\tPriority: 0,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tData:     \"2.2.2.2\",\n\t\t\t\t\t\t\t\tName:     \"me\",\n\t\t\t\t\t\t\t\tTTL:      600,\n\t\t\t\t\t\t\t\tType:     domain.RecordTypeA,\n\t\t\t\t\t\t\t\tPriority: 30,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDelete: &domain.RecordChangeDelete{\n\t\t\t\t\t\tIDFields: &domain.RecordIdentifier{\n\t\t\t\t\t\t\tData: scw.StringPtr(\"3.3.3.3\"),\n\t\t\t\t\t\t\tName: \"me\",\n\t\t\t\t\t\t\tType: domain.RecordTypeA,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDelete: &domain.RecordChangeDelete{\n\t\t\t\t\t\tIDFields: &domain.RecordIdentifier{\n\t\t\t\t\t\t\tData: scw.StringPtr(\"1.1.1.1\"),\n\t\t\t\t\t\t\tName: \"here\",\n\t\t\t\t\t\t\tType: domain.RecordTypeA,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDelete: &domain.RecordChangeDelete{\n\t\t\t\t\t\tIDFields: &domain.RecordIdentifier{\n\t\t\t\t\t\t\tData: scw.StringPtr(\"1.1.1.2\"),\n\t\t\t\t\t\t\tName: \"here\",\n\t\t\t\t\t\t\tType: domain.RecordTypeA,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSZone: \"test.example.com\",\n\t\t\tChanges: []*domain.RecordChange{\n\t\t\t\t{\n\t\t\t\t\tAdd: &domain.RecordChangeAdd{\n\t\t\t\t\t\tRecords: []*domain.Record{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tData:     \"example.com.\",\n\t\t\t\t\t\t\t\tName:     \"\",\n\t\t\t\t\t\t\t\tTTL:      600,\n\t\t\t\t\t\t\t\tType:     domain.RecordTypeCNAME,\n\t\t\t\t\t\t\t\tPriority: 20,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tData:     \"1.2.3.4\",\n\t\t\t\t\t\t\t\tName:     \"my\",\n\t\t\t\t\t\t\t\tTTL:      300,\n\t\t\t\t\t\t\t\tType:     domain.RecordTypeA,\n\t\t\t\t\t\t\t\tPriority: 0,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tData:     \"5.6.7.8\",\n\t\t\t\t\t\t\t\tName:     \"my\",\n\t\t\t\t\t\t\t\tTTL:      300,\n\t\t\t\t\t\t\t\tType:     domain.RecordTypeA,\n\t\t\t\t\t\t\t\tPriority: 0,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDelete: &domain.RecordChangeDelete{\n\t\t\t\t\t\tIDFields: &domain.RecordIdentifier{\n\t\t\t\t\t\t\tData: scw.StringPtr(\"1.1.1.1\"),\n\t\t\t\t\t\t\tName: \"here.is.my\",\n\t\t\t\t\t\t\tType: domain.RecordTypeA,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDelete: &domain.RecordChangeDelete{\n\t\t\t\t\t\tIDFields: &domain.RecordIdentifier{\n\t\t\t\t\t\t\tData: scw.StringPtr(\"4.4.4.4\"),\n\t\t\t\t\t\t\tName: \"my\",\n\t\t\t\t\t\t\tType: domain.RecordTypeA,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDelete: &domain.RecordChangeDelete{\n\t\t\t\t\t\tIDFields: &domain.RecordIdentifier{\n\t\t\t\t\t\t\tData: scw.StringPtr(\"5.5.5.5\"),\n\t\t\t\t\t\t\tName: \"my\",\n\t\t\t\t\t\t\tType: domain.RecordTypeA,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tchanges := &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"example.com\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tTargets:    []string{\"1.1.1.1\", \"1.1.1.2\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"test.example.com\",\n\t\t\t\tRecordType: \"CNAME\",\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  scalewayPriorityKey,\n\t\t\t\t\t\tValue: \"20\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tRecordTTL: 600,\n\t\t\t\tTargets:   []string{\"example.com\"},\n\t\t\t},\n\t\t},\n\t\tDelete: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"here.example.com\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tTargets:    []string{\"1.1.1.1\", \"1.1.1.2\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"here.is.my.test.example.com\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tTargets:    []string{\"1.1.1.1\"},\n\t\t\t},\n\t\t},\n\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName: \"me.example.com\",\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  scalewayPriorityKey,\n\t\t\t\t\t\tValue: \"30\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tRecordTTL:  600,\n\t\t\t\tTargets:    []string{\"2.2.2.2\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"my.test.example.com\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tTargets:    []string{\"1.2.3.4\", \"5.6.7.8\"},\n\t\t\t},\n\t\t},\n\t\tUpdateOld: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName: \"me.example.com\",\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:  scalewayPriorityKey,\n\t\t\t\t\t\tValue: \"1234\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tTargets:    []string{\"3.3.3.3\"},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"my.test.example.com\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tTargets:    []string{\"4.4.4.4\", \"5.5.5.5\"},\n\t\t\t},\n\t\t},\n\t}\n\n\trequests, err := provider.generateApplyRequests(t.Context(), changes)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\trequire.Len(t, requests, len(expected))\n\ttotal := int(len(expected))\n\tfor _, req := range requests {\n\t\tfor _, exp := range expected {\n\t\t\tif checkScalewayReqChanges(req, exp) {\n\t\t\t\ttotal--\n\t\t\t}\n\t\t}\n\t}\n\tassert.Equal(t, 0, total)\n}\n\nfunc checkRecordEquality(record1, record2 *endpoint.Endpoint) bool {\n\treturn record1.Targets.Same(record2.Targets) &&\n\t\trecord1.DNSName == record2.DNSName &&\n\t\trecord1.RecordTTL == record2.RecordTTL &&\n\t\trecord1.RecordType == record2.RecordType &&\n\t\treflect.DeepEqual(record1.ProviderSpecific, record2.ProviderSpecific)\n}\n\nfunc checkScalewayReqChanges(r1, r2 *domain.UpdateDNSZoneRecordsRequest) bool {\n\tif r1.DNSZone != r2.DNSZone {\n\t\treturn false\n\t}\n\tif len(r1.Changes) != len(r2.Changes) {\n\t\treturn false\n\t}\n\ttotal := int(len(r1.Changes))\n\tfor _, c1 := range r1.Changes {\n\t\tfor _, c2 := range r2.Changes {\n\t\t\t// we only have 1 add per request\n\t\t\tif c1.Add != nil && c2.Add != nil && checkScalewayRecords(c1.Add.Records, c2.Add.Records) {\n\t\t\t\ttotal--\n\t\t\t} else if c1.Delete != nil && c2.Delete != nil {\n\t\t\t\tif *c1.Delete.IDFields.Data == *c2.Delete.IDFields.Data && c1.Delete.IDFields.Name == c2.Delete.IDFields.Name && c1.Delete.IDFields.Type == c2.Delete.IDFields.Type {\n\t\t\t\t\ttotal--\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn total == 0\n}\n\nfunc checkScalewayRecords(rs1, rs2 []*domain.Record) bool {\n\tif len(rs1) != len(rs2) {\n\t\treturn false\n\t}\n\ttotal := int(len(rs1))\n\tfor _, r1 := range rs1 {\n\t\tfor _, r2 := range rs2 {\n\t\t\tif r1.Data == r2.Data &&\n\t\t\t\tr1.Name == r2.Name &&\n\t\t\t\tr1.Priority == r2.Priority &&\n\t\t\t\tr1.TTL == r2.TTL &&\n\t\t\t\tr1.Type == r2.Type {\n\t\t\t\ttotal--\n\t\t\t}\n\t\t}\n\t}\n\treturn total == 0\n}\n"
  },
  {
    "path": "provider/transip/transip.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage transip\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/transip/gotransip/v6\"\n\t\"github.com/transip/gotransip/v6/domain\"\n\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\nconst (\n\t// 60 seconds is the current minimal TTL for TransIP and will replace unconfigured\n\t// TTL's for Endpoints\n\tdefaultTTL = 60\n)\n\n// TransIPProvider is an implementation of Provider for TransIP.\ntype TransIPProvider struct {\n\tprovider.BaseProvider\n\tdomainRepo   domain.Repository\n\tdomainFilter *endpoint.DomainFilter\n\tdryRun       bool\n\n\tzoneMap provider.ZoneIDName\n}\n\n// New creates a TransIP provider from the given configuration.\nfunc New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) {\n\treturn newProvider(cfg.TransIPAccountName, cfg.TransIPPrivateKeyFile, domainFilter, cfg.DryRun)\n}\n\n// newProvider initializes a new TransIP Provider.\nfunc newProvider(accountName, privateKeyFile string, domainFilter *endpoint.DomainFilter, dryRun bool) (*TransIPProvider, error) {\n\t// check given arguments\n\tif accountName == \"\" {\n\t\treturn nil, errors.New(\"required --transip-account not set\")\n\t}\n\n\tif privateKeyFile == \"\" {\n\t\treturn nil, errors.New(\"required --transip-keyfile not set\")\n\t}\n\n\tvar apiMode gotransip.APIMode\n\tif dryRun {\n\t\tapiMode = gotransip.APIModeReadOnly\n\t} else {\n\t\tapiMode = gotransip.APIModeReadWrite\n\t}\n\n\t// create new TransIP API client\n\tclient, err := gotransip.NewClient(gotransip.ClientConfiguration{\n\t\tAccountName:    accountName,\n\t\tPrivateKeyPath: privateKeyFile,\n\t\tMode:           apiMode,\n\t})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"could not setup TransIP API client: %w\", err)\n\t}\n\n\t// return TransIPProvider struct\n\treturn &TransIPProvider{\n\t\tdomainRepo:   domain.Repository{Client: client},\n\t\tdomainFilter: domainFilter,\n\t\tdryRun:       dryRun,\n\t\tzoneMap:      provider.ZoneIDName{},\n\t}, nil\n}\n\n// ApplyChanges applies a given set of changes in a given zone.\nfunc (p *TransIPProvider) ApplyChanges(_ context.Context, changes *plan.Changes) error {\n\t// fetch all zones we currently have\n\t// this does NOT include any DNS entries, so we'll have to fetch these for\n\t// each zone that gets updated\n\tzones, err := p.domainRepo.GetAll()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// refresh zone mapping\n\tzoneMap := provider.ZoneIDName{}\n\tfor _, zone := range zones {\n\t\t// TransIP API doesn't expose a unique identifier for zones, other than\n\t\t// the domain name itself\n\t\tzoneMap.Add(zone.Name, zone.Name)\n\t}\n\tp.zoneMap = zoneMap\n\n\t// first remove obsolete DNS records\n\tfor _, ep := range changes.Delete {\n\t\tepLog := log.WithFields(log.Fields{\n\t\t\t\"record\": ep.DNSName,\n\t\t\t\"type\":   ep.RecordType,\n\t\t})\n\t\tepLog.Info(\"endpoint has to go\")\n\n\t\tzoneName, entries, err := p.entriesForEndpoint(ep)\n\t\tif err != nil {\n\t\t\tepLog.WithError(err).Error(\"could not get DNS entries\")\n\t\t\treturn err\n\t\t}\n\n\t\tepLog = epLog.WithField(\"zone\", zoneName)\n\n\t\tif len(entries) == 0 {\n\t\t\tepLog.Info(\"no matching entries found\")\n\t\t\tcontinue\n\t\t}\n\n\t\tif p.dryRun {\n\t\t\tepLog.Info(\"not removing DNS entries in dry-run mode\")\n\t\t\tcontinue\n\t\t}\n\n\t\tfor _, entry := range entries {\n\t\t\tlog.WithFields(log.Fields{\n\t\t\t\t\"domain\":  zoneName,\n\t\t\t\t\"name\":    entry.Name,\n\t\t\t\t\"type\":    entry.Type,\n\t\t\t\t\"content\": entry.Content,\n\t\t\t\t\"ttl\":     entry.Expire,\n\t\t\t}).Info(\"removing DNS entry\")\n\n\t\t\terr = p.domainRepo.RemoveDNSEntry(zoneName, entry)\n\t\t\tif err != nil {\n\t\t\t\tepLog.WithError(err).Error(\"could not remove DNS entry\")\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\n\t// then create new DNS records\n\tfor _, ep := range changes.Create {\n\t\tepLog := log.WithFields(log.Fields{\n\t\t\t\"record\": ep.DNSName,\n\t\t\t\"type\":   ep.RecordType,\n\t\t})\n\t\tepLog.Info(\"endpoint should be created\")\n\n\t\tzoneName, err := p.zoneNameForDNSName(ep.DNSName)\n\t\tif err != nil {\n\t\t\tepLog.WithError(err).Warn(\"could not find zone for endpoint\")\n\t\t\tcontinue\n\t\t}\n\n\t\tepLog = epLog.WithField(\"zone\", zoneName)\n\n\t\tif p.dryRun {\n\t\t\tepLog.Info(\"not adding DNS entries in dry-run mode\")\n\t\t\tcontinue\n\t\t}\n\n\t\tfor _, entry := range dnsEntriesForEndpoint(ep, zoneName) {\n\t\t\tlog.WithFields(log.Fields{\n\t\t\t\t\"domain\":  zoneName,\n\t\t\t\t\"name\":    entry.Name,\n\t\t\t\t\"type\":    entry.Type,\n\t\t\t\t\"content\": entry.Content,\n\t\t\t\t\"ttl\":     entry.Expire,\n\t\t\t}).Info(\"creating DNS entry\")\n\n\t\t\terr = p.domainRepo.AddDNSEntry(zoneName, entry)\n\t\t\tif err != nil {\n\t\t\t\tepLog.WithError(err).Error(\"could not add DNS entry\")\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\n\t// then update existing DNS records\n\tfor _, ep := range changes.UpdateNew {\n\t\tepLog := log.WithFields(log.Fields{\n\t\t\t\"record\": ep.DNSName,\n\t\t\t\"type\":   ep.RecordType,\n\t\t})\n\t\tepLog.Debug(\"endpoint needs updating\")\n\n\t\tzoneName, entries, err := p.entriesForEndpoint(ep)\n\t\tif err != nil {\n\t\t\tepLog.WithError(err).Error(\"could not get DNS entries\")\n\t\t\treturn err\n\t\t}\n\n\t\tepLog = epLog.WithField(\"zone\", zoneName)\n\n\t\tif len(entries) == 0 {\n\t\t\tepLog.Info(\"no matching entries found\")\n\t\t\tcontinue\n\t\t}\n\n\t\tnewEntries := dnsEntriesForEndpoint(ep, zoneName)\n\n\t\t// check to see if actually anything changed in the DNSEntry set\n\t\tif dnsEntriesAreEqual(newEntries, entries) {\n\t\t\tepLog.Debug(\"not updating identical DNS entries\")\n\t\t\tcontinue\n\t\t}\n\n\t\tif p.dryRun {\n\t\t\tepLog.Info(\"not updating DNS entries in dry-run mode\")\n\t\t\tcontinue\n\t\t}\n\n\t\t// TransIP API client does have an UpdateDNSEntry call but that does only\n\t\t// allow you to update the content of a DNSEntry, not the TTL\n\t\t// to work around this, remove the old entry first and add the new entry\n\t\tfor _, entry := range entries {\n\t\t\tlog.WithFields(log.Fields{\n\t\t\t\t\"domain\":  zoneName,\n\t\t\t\t\"name\":    entry.Name,\n\t\t\t\t\"type\":    entry.Type,\n\t\t\t\t\"content\": entry.Content,\n\t\t\t\t\"ttl\":     entry.Expire,\n\t\t\t}).Info(\"removing DNS entry\")\n\n\t\t\terr = p.domainRepo.RemoveDNSEntry(zoneName, entry)\n\t\t\tif err != nil {\n\t\t\t\tepLog.WithError(err).Error(\"could not remove DNS entry\")\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\tfor _, entry := range newEntries {\n\t\t\tlog.WithFields(log.Fields{\n\t\t\t\t\"domain\":  zoneName,\n\t\t\t\t\"name\":    entry.Name,\n\t\t\t\t\"type\":    entry.Type,\n\t\t\t\t\"content\": entry.Content,\n\t\t\t\t\"ttl\":     entry.Expire,\n\t\t\t}).Info(\"adding DNS entry\")\n\n\t\t\terr = p.domainRepo.AddDNSEntry(zoneName, entry)\n\t\t\tif err != nil {\n\t\t\t\tepLog.WithError(err).Error(\"could not add DNS entry\")\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// Records returns the list of records in all zones\nfunc (p *TransIPProvider) Records(_ context.Context) ([]*endpoint.Endpoint, error) {\n\tzones, err := p.domainRepo.GetAll()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar endpoints []*endpoint.Endpoint\n\t// go over all zones and their DNS entries and create endpoints for them\n\tfor _, zone := range zones {\n\t\tentries, err := p.domainRepo.GetDNSEntries(zone.Name)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tfor _, r := range entries {\n\t\t\tif !provider.SupportedRecordType(r.Type) {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tname := endpointNameForRecord(r, zone.Name)\n\t\t\tendpoints = append(endpoints, endpoint.NewEndpointWithTTL(name, r.Type, endpoint.TTL(r.Expire), r.Content))\n\t\t}\n\t}\n\n\treturn endpoints, nil\n}\n\nfunc (p *TransIPProvider) entriesForEndpoint(ep *endpoint.Endpoint) (string, []domain.DNSEntry, error) {\n\tzoneName, err := p.zoneNameForDNSName(ep.DNSName)\n\tif err != nil {\n\t\treturn \"\", nil, err\n\t}\n\n\tepName := recordNameForEndpoint(ep, zoneName)\n\tdnsEntries, err := p.domainRepo.GetDNSEntries(zoneName)\n\tif err != nil {\n\t\treturn zoneName, nil, err\n\t}\n\n\tmatches := []domain.DNSEntry{}\n\tfor _, entry := range dnsEntries {\n\t\tif ep.RecordType != entry.Type {\n\t\t\tcontinue\n\t\t}\n\n\t\tif entry.Name == epName {\n\t\t\tmatches = append(matches, entry)\n\t\t}\n\t}\n\n\treturn zoneName, matches, nil\n}\n\n// endpointNameForRecord returns \"www.example.org\" for DNSEntry with Name \"www\" and\n// Domain with Name \"example.org\"\nfunc endpointNameForRecord(r domain.DNSEntry, zoneName string) string {\n\t// root name is identified by \"@\" and should be translated to domain name for\n\t// the endpoint entry.\n\tif r.Name == \"@\" {\n\t\treturn zoneName\n\t}\n\n\treturn fmt.Sprintf(\"%s.%s\", r.Name, zoneName)\n}\n\n// recordNameForEndpoint returns \"www\" for Endpoint with DNSName \"www.example.org\"\n// and Domain with Name \"example.org\"\nfunc recordNameForEndpoint(ep *endpoint.Endpoint, zoneName string) string {\n\t// root name is identified by \"@\" and should be translated to domain name for\n\t// the endpoint entry.\n\tif ep.DNSName == zoneName {\n\t\treturn \"@\"\n\t}\n\n\treturn strings.TrimSuffix(ep.DNSName, \".\"+zoneName)\n}\n\n// getMinimalValidTTL returns max between given Endpoint's RecordTTL and\n// defaultTTL\nfunc getMinimalValidTTL(ep *endpoint.Endpoint) int {\n\t// TTL cannot be lower than defaultTTL\n\tif ep.RecordTTL < defaultTTL {\n\t\treturn defaultTTL\n\t}\n\n\treturn int(ep.RecordTTL)\n}\n\n// dnsEntriesAreEqual compares the entries in 2 sets and returns true if the\n// content of the entries is equal\nfunc dnsEntriesAreEqual(a, b []domain.DNSEntry) bool {\n\tif len(a) != len(b) {\n\t\treturn false\n\t}\n\n\tmatch := 0\n\tfor _, aa := range a {\n\t\tfor _, bb := range b {\n\t\t\tif aa.Content != bb.Content {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif aa.Name != bb.Name {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif aa.Expire != bb.Expire {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif aa.Type != bb.Type {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tmatch++\n\t\t}\n\t}\n\n\treturn (len(a) == match)\n}\n\n// dnsEntriesForEndpoint creates DNS entries for given endpoint and returns\n// resulting DNS entry set\nfunc dnsEntriesForEndpoint(ep *endpoint.Endpoint, zoneName string) []domain.DNSEntry {\n\tttl := getMinimalValidTTL(ep)\n\n\tentries := []domain.DNSEntry{}\n\tfor _, target := range ep.Targets {\n\t\t// external hostnames require a trailing dot in TransIP API\n\t\tif ep.RecordType == \"CNAME\" {\n\t\t\ttarget = provider.EnsureTrailingDot(target)\n\t\t}\n\n\t\tentries = append(entries, domain.DNSEntry{\n\t\t\tName:    recordNameForEndpoint(ep, zoneName),\n\t\t\tExpire:  ttl,\n\t\t\tType:    ep.RecordType,\n\t\t\tContent: target,\n\t\t})\n\t}\n\n\treturn entries\n}\n\n// zoneForZoneName returns the zone mapped to given name or error if zone could\n// not be found\nfunc (p *TransIPProvider) zoneNameForDNSName(name string) (string, error) {\n\t_, zoneName := p.zoneMap.FindZone(name)\n\tif zoneName == \"\" {\n\t\treturn \"\", fmt.Errorf(\"could not find zoneName for %s\", name)\n\t}\n\n\treturn zoneName, nil\n}\n"
  },
  {
    "path": "provider/transip/transip_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage transip\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\t\"github.com/transip/gotransip/v6/domain\"\n\t\"github.com/transip/gotransip/v6/rest\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\nfunc newTestProvider() *TransIPProvider {\n\treturn &TransIPProvider{\n\t\tzoneMap: provider.ZoneIDName{},\n\t}\n}\n\nfunc TestTransIPDnsEntriesAreEqual(t *testing.T) {\n\t// test with equal set\n\ta := []domain.DNSEntry{\n\t\t{\n\t\t\tName:    \"www.example.org\",\n\t\t\tType:    \"CNAME\",\n\t\t\tExpire:  3600,\n\t\t\tContent: \"www.example.com\",\n\t\t},\n\t\t{\n\t\t\tName:    \"www.example.com\",\n\t\t\tType:    \"A\",\n\t\t\tExpire:  3600,\n\t\t\tContent: \"192.168.0.1\",\n\t\t},\n\t}\n\n\tb := []domain.DNSEntry{\n\t\t{\n\t\t\tName:    \"www.example.com\",\n\t\t\tType:    \"A\",\n\t\t\tExpire:  3600,\n\t\t\tContent: \"192.168.0.1\",\n\t\t},\n\t\t{\n\t\t\tName:    \"www.example.org\",\n\t\t\tType:    \"CNAME\",\n\t\t\tExpire:  3600,\n\t\t\tContent: \"www.example.com\",\n\t\t},\n\t}\n\n\tassert.True(t, dnsEntriesAreEqual(a, b))\n\n\t// change type on one of b's records\n\tb[1].Type = \"NS\"\n\tassert.False(t, dnsEntriesAreEqual(a, b))\n\tb[1].Type = \"CNAME\"\n\n\t// change ttl on one of b's records\n\tb[1].Expire = 1800\n\tassert.False(t, dnsEntriesAreEqual(a, b))\n\tb[1].Expire = 3600\n\n\t// change name on one of b's records\n\tb[1].Name = \"example.org\"\n\tassert.False(t, dnsEntriesAreEqual(a, b))\n\n\t// remove last entry of b\n\tb = b[:1]\n\tassert.False(t, dnsEntriesAreEqual(a, b))\n}\n\nfunc TestTransIPGetMinimalValidTTL(t *testing.T) {\n\t// test with 'unconfigured' TTL\n\tep := &endpoint.Endpoint{}\n\tassert.Equal(t, defaultTTL, getMinimalValidTTL(ep))\n\n\t// test with lower than minimal ttl\n\tep.RecordTTL = (defaultTTL - 1)\n\tassert.Equal(t, defaultTTL, getMinimalValidTTL(ep))\n\n\t// test with higher than minimal ttl\n\tep.RecordTTL = (defaultTTL + 1)\n\tassert.Equal(t, defaultTTL+1, getMinimalValidTTL(ep))\n}\n\nfunc TestTransIPRecordNameForEndpoint(t *testing.T) {\n\tep := &endpoint.Endpoint{\n\t\tDNSName: \"example.org\",\n\t}\n\td := domain.Domain{\n\t\tName: \"example.org\",\n\t}\n\n\tassert.Equal(t, \"@\", recordNameForEndpoint(ep, d.Name))\n\n\tep.DNSName = \"www.example.org\"\n\tassert.Equal(t, \"www\", recordNameForEndpoint(ep, d.Name))\n}\n\nfunc TestTransIPEndpointNameForRecord(t *testing.T) {\n\tr := domain.DNSEntry{\n\t\tName: \"@\",\n\t}\n\td := domain.Domain{\n\t\tName: \"example.org\",\n\t}\n\n\tassert.Equal(t, d.Name, endpointNameForRecord(r, d.Name))\n\n\tr.Name = \"www\"\n\tassert.Equal(t, \"www.example.org\", endpointNameForRecord(r, d.Name))\n}\n\nfunc TestTransIPAddEndpointToEntries(t *testing.T) {\n\t// prepare endpoint\n\tep := &endpoint.Endpoint{\n\t\tDNSName:    \"www.example.org\",\n\t\tRecordType: \"A\",\n\t\tRecordTTL:  1800,\n\t\tTargets: []string{\n\t\t\t\"192.168.0.1\",\n\t\t\t\"192.168.0.2\",\n\t\t},\n\t}\n\n\t// prepare zone with DNS entry set\n\tzone := domain.Domain{\n\t\tName: \"example.org\",\n\t}\n\n\t// add endpoint to zone's entries\n\tresult := dnsEntriesForEndpoint(ep, zone.Name)\n\n\tif assert.Len(t, result, 2) {\n\t\tassert.Equal(t, \"www\", result[0].Name)\n\t\tassert.Equal(t, \"A\", result[0].Type)\n\t\tassert.Equal(t, \"192.168.0.1\", result[0].Content)\n\t\tassert.Equal(t, 1800, result[0].Expire)\n\t\tassert.Equal(t, \"www\", result[1].Name)\n\t\tassert.Equal(t, \"A\", result[1].Type)\n\t\tassert.Equal(t, \"192.168.0.2\", result[1].Content)\n\t\tassert.Equal(t, 1800, result[1].Expire)\n\t}\n\n\t// try again with CNAME\n\tep.RecordType = \"CNAME\"\n\tep.Targets = []string{\"foo.bar\"}\n\tresult = dnsEntriesForEndpoint(ep, zone.Name)\n\tif assert.Len(t, result, 1) {\n\t\tassert.Equal(t, \"CNAME\", result[0].Type)\n\t\tassert.Equal(t, \"foo.bar.\", result[0].Content)\n\t}\n}\n\nfunc TestZoneNameForDNSName(t *testing.T) {\n\tp := newTestProvider()\n\tp.zoneMap.Add(\"example.com\", \"example.com\")\n\n\tzoneName, err := p.zoneNameForDNSName(\"www.example.com\")\n\tif assert.NoError(t, err) {\n\t\tassert.Equal(t, \"example.com\", zoneName)\n\t}\n\n\t_, err = p.zoneNameForDNSName(\"www.example.org\")\n\tif assert.Error(t, err) {\n\t\tassert.Equal(t, \"could not find zoneName for www.example.org\", err.Error())\n\t}\n}\n\n// fakeClient mocks the REST API client\ntype fakeClient struct {\n\tgetFunc func(rest.Request, any) error\n}\n\nfunc (f *fakeClient) Get(request rest.Request, dest any) error {\n\tif f.getFunc == nil {\n\t\treturn errors.New(\"GET not defined\")\n\t}\n\n\treturn f.getFunc(request, dest)\n}\n\nfunc (f *fakeClient) Put(_ rest.Request) error {\n\treturn errors.New(\"PUT not implemented\")\n}\n\nfunc (f *fakeClient) Post(_ rest.Request) error {\n\treturn errors.New(\"POST not implemented\")\n}\n\nfunc (f *fakeClient) Delete(_ rest.Request) error {\n\treturn errors.New(\"DELETE not implemented\")\n}\n\nfunc (f *fakeClient) Patch(_ rest.Request) error {\n\treturn errors.New(\"PATCH not implemented\")\n}\n\nfunc (f *fakeClient) PatchWithResponse(_ rest.Request) (rest.Response, error) {\n\treturn rest.Response{}, errors.New(\"PATCH with response not implemented\")\n}\n\nfunc (f *fakeClient) PostWithResponse(_ rest.Request) (rest.Response, error) {\n\treturn rest.Response{}, errors.New(\"POST with response not implemented\")\n}\n\nfunc (f *fakeClient) PutWithResponse(_ rest.Request) (rest.Response, error) {\n\treturn rest.Response{}, errors.New(\"PUT with response not implemented\")\n}\n\nfunc TestProviderRecords(t *testing.T) {\n\t// set up the fake REST client\n\tclient := &fakeClient{}\n\tclient.getFunc = func(req rest.Request, dest any) error {\n\t\tvar data []byte\n\t\tswitch {\n\t\tcase req.Endpoint == \"/domains\":\n\t\t\t// return list of some domain names\n\t\t\t// only, other fields are not used\n\t\t\tdata = []byte(`{\"domains\":[{\"name\":\"example.org\"}, {\"name\":\"example.com\"}]}`)\n\t\tcase strings.HasSuffix(req.Endpoint, \"/dns\"):\n\t\t\t// return list of DNS entries\n\t\t\t// also some unsupported types\n\t\t\tdata = []byte(`{\"dnsEntries\":[{\"name\":\"www\", \"expire\":1234, \"type\":\"CNAME\", \"content\":\"@\"},{\"type\":\"MX\"},{\"type\":\"AAAA\"}]}`)\n\t\t}\n\n\t\t// unmarshal the prepared return data into the given destination type\n\t\treturn json.Unmarshal(data, &dest)\n\t}\n\n\t// set up provider\n\tp := newTestProvider()\n\tp.domainRepo = domain.Repository{Client: client}\n\n\tendpoints, err := p.Records(t.Context())\n\tif assert.NoError(t, err) {\n\t\tif assert.Len(t, endpoints, 4) {\n\t\t\tassert.Equal(t, \"www.example.org\", endpoints[0].DNSName)\n\t\t\tassert.Equal(t, \"@\", endpoints[0].Targets[0])\n\t\t\tassert.Equal(t, \"CNAME\", endpoints[0].RecordType)\n\t\t\tassert.Empty(t, endpoints[0].Labels)\n\t\t\tassert.EqualValues(t, 1234, endpoints[0].RecordTTL)\n\t\t}\n\t}\n}\n\nfunc TestProviderEntriesForEndpoint(t *testing.T) {\n\t// set up fake REST client\n\tclient := &fakeClient{}\n\n\t// set up provider\n\tp := newTestProvider()\n\tp.domainRepo = domain.Repository{Client: client}\n\tp.zoneMap.Add(\"example.com\", \"example.com\")\n\n\t// get entries for endpoint with unknown zone\n\t_, _, err := p.entriesForEndpoint(&endpoint.Endpoint{\n\t\tDNSName: \"www.example.org\",\n\t})\n\tif assert.Error(t, err) {\n\t\tassert.Equal(t, \"could not find zoneName for www.example.org\", err.Error())\n\t}\n\n\t// get entries for endpoint with known zone but client returns error\n\t// we leave GET functions undefined so we know which error to expect\n\tzoneName, _, err := p.entriesForEndpoint(&endpoint.Endpoint{\n\t\tDNSName: \"www.example.com\",\n\t})\n\tif assert.Error(t, err) {\n\t\tassert.Equal(t, \"GET not defined\", err.Error())\n\t}\n\tassert.Equal(t, \"example.com\", zoneName)\n\n\t// to be able to return a valid set of DNS entries through the API, we define\n\t// some first, then JSON encode them and have the fake API client's Get function\n\t// return that\n\t// in this set are some entries that do and others that don't match the given\n\t// endpoint\n\tdnsEntries := []domain.DNSEntry{\n\t\t{\n\t\t\tName:    \"www\",\n\t\t\tType:    \"A\",\n\t\t\tExpire:  3600,\n\t\t\tContent: \"1.2.3.4\",\n\t\t},\n\t\t{\n\t\t\tName:    \"ftp\",\n\t\t\tType:    \"A\",\n\t\t\tExpire:  86400,\n\t\t\tContent: \"3.4.5.6\",\n\t\t},\n\t\t{\n\t\t\tName:    \"www\",\n\t\t\tType:    \"A\",\n\t\t\tExpire:  3600,\n\t\t\tContent: \"2.3.4.5\",\n\t\t},\n\t\t{\n\t\t\tName:    \"www\",\n\t\t\tType:    \"CNAME\",\n\t\t\tExpire:  3600,\n\t\t\tContent: \"@\",\n\t\t},\n\t}\n\tvar v struct {\n\t\tDNSEntries []domain.DNSEntry `json:\"dnsEntries\"`\n\t}\n\tv.DNSEntries = dnsEntries\n\treturnData, err := json.Marshal(&v)\n\trequire.NoError(t, err)\n\n\t// define GET function\n\tclient.getFunc = func(_ rest.Request, dest any) error {\n\t\t// unmarshal the prepared return data into the given dnsEntriesWrapper\n\t\treturn json.Unmarshal(returnData, &dest)\n\t}\n\t_, entries, err := p.entriesForEndpoint(&endpoint.Endpoint{\n\t\tDNSName:    \"www.example.com\",\n\t\tRecordType: \"A\",\n\t})\n\tif assert.NoError(t, err) {\n\t\tif assert.Len(t, entries, 2) {\n\t\t\t// only first and third entry should be returned\n\t\t\tassert.Equal(t, dnsEntries[0], entries[0])\n\t\t\tassert.Equal(t, dnsEntries[2], entries[1])\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "provider/webhook/api/httpapi.go",
    "content": "/*\nCopyright 2023 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage api\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"net\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n\n\tlog \"github.com/sirupsen/logrus\"\n)\n\nconst (\n\tMediaTypeFormatAndVersion = \"application/external.dns.webhook+json;version=1\"\n\tContentTypeHeader         = \"Content-Type\"\n\tUrlAdjustEndpoints        = \"/adjustendpoints\"\n\tUrlApplyChanges           = \"/applychanges\"\n\tUrlRecords                = \"/records\"\n)\n\ntype WebhookServer struct {\n\tProvider provider.Provider\n}\n\nfunc (p *WebhookServer) RecordsHandler(w http.ResponseWriter, req *http.Request) {\n\tswitch req.Method {\n\tcase http.MethodGet:\n\t\trecords, err := p.Provider.Records(context.Background())\n\t\tif err != nil {\n\t\t\tlog.Errorf(\"Failed to get Records: %v\", err)\n\t\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\t\tw.Header().Set(ContentTypeHeader, MediaTypeFormatAndVersion)\n\t\tw.WriteHeader(http.StatusOK)\n\t\tif err := json.NewEncoder(w).Encode(records); err != nil {\n\t\t\tlog.Errorf(\"Failed to encode records: %v\", err)\n\t\t}\n\t\treturn\n\tcase http.MethodPost:\n\t\tvar changes plan.Changes\n\t\tif err := json.NewDecoder(req.Body).Decode(&changes); err != nil {\n\t\t\tlog.Errorf(\"Failed to decode changes: %v\", err)\n\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\treturn\n\t\t}\n\t\terr := p.Provider.ApplyChanges(context.Background(), &changes)\n\t\tif err != nil {\n\t\t\tlog.Errorf(\"Failed to apply changes: %v\", err)\n\t\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\t\tw.WriteHeader(http.StatusNoContent)\n\t\treturn\n\tdefault:\n\t\tlog.Errorf(\"Unsupported method %s\", req.Method)\n\t\tw.WriteHeader(http.StatusBadRequest)\n\t}\n}\n\nfunc (p *WebhookServer) AdjustEndpointsHandler(w http.ResponseWriter, req *http.Request) {\n\tif req.Method != http.MethodPost {\n\t\tlog.Errorf(\"Unsupported method %s\", req.Method)\n\t\tw.WriteHeader(http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tvar pve []*endpoint.Endpoint\n\tif err := json.NewDecoder(req.Body).Decode(&pve); err != nil {\n\t\tlog.Errorf(\"Failed to decode in adjustEndpointsHandler: %v\", err)\n\t\tw.WriteHeader(http.StatusBadRequest)\n\t\treturn\n\t}\n\tw.Header().Set(ContentTypeHeader, MediaTypeFormatAndVersion)\n\tpve, err := p.Provider.AdjustEndpoints(pve)\n\tif err != nil {\n\t\tlog.Errorf(\"Failed to call adjust endpoints: %v\", err)\n\t\tw.WriteHeader(http.StatusInternalServerError)\n\t}\n\tif err := json.NewEncoder(w).Encode(&pve); err != nil {\n\t\tlog.Errorf(\"Failed to encode in adjustEndpointsHandler: %v\", err)\n\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\treturn\n\t}\n}\n\nfunc (p *WebhookServer) NegotiateHandler(w http.ResponseWriter, _ *http.Request) {\n\tw.Header().Set(ContentTypeHeader, MediaTypeFormatAndVersion)\n\terr := json.NewEncoder(w).Encode(p.Provider.GetDomainFilter())\n\tif err != nil {\n\t\tw.WriteHeader(http.StatusInternalServerError)\n\t}\n}\n\n// StartHTTPApi starts a HTTP server given any provider.\n// the function takes an optional channel as input which is used to signal that the server has started.\n// The server will listen on port `providerPort`.\n// The server will respond to the following endpoints:\n// - / (GET): initialization, negotiates headers and returns the domain filter\n// - /records (GET): returns the current records\n// - /records (POST): applies the changes\n// - /adjustendpoints (POST): executes the AdjustEndpoints method\nfunc StartHTTPApi(provider provider.Provider, startedChan chan struct{}, readTimeout, writeTimeout time.Duration, providerPort string) {\n\tp := WebhookServer{\n\t\tProvider: provider,\n\t}\n\n\tm := http.NewServeMux()\n\tm.HandleFunc(\"/\", p.NegotiateHandler)\n\tm.HandleFunc(UrlRecords, p.RecordsHandler)\n\tm.HandleFunc(UrlAdjustEndpoints, p.AdjustEndpointsHandler)\n\n\ts := &http.Server{\n\t\tAddr:         providerPort,\n\t\tHandler:      m,\n\t\tReadTimeout:  readTimeout,\n\t\tWriteTimeout: writeTimeout,\n\t}\n\n\tl, err := net.Listen(\"tcp\", providerPort)\n\tif err != nil {\n\t\tlog.Fatal(err)\n\t}\n\n\tif startedChan != nil {\n\t\tstartedChan <- struct{}{}\n\t}\n\n\tif err := s.Serve(l); err != nil {\n\t\tlog.Fatal(err)\n\t}\n}\n"
  },
  {
    "path": "provider/webhook/api/httpapi_test.go",
    "content": "/*\nCopyright 2023 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage api\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/plan\"\n)\n\nvar records []*endpoint.Endpoint\n\ntype FakeWebhookProvider struct {\n\terr           error\n\tdomainFilter  *endpoint.DomainFilter\n\tassertChanges func(*plan.Changes)\n}\n\nfunc (p FakeWebhookProvider) Records(_ context.Context) ([]*endpoint.Endpoint, error) {\n\tif p.err != nil {\n\t\treturn nil, p.err\n\t}\n\treturn records, nil\n}\n\nfunc (p FakeWebhookProvider) ApplyChanges(_ context.Context, changes *plan.Changes) error {\n\tif p.err != nil {\n\t\treturn p.err\n\t}\n\trecords = append(records, changes.Create...)\n\tif p.assertChanges != nil {\n\t\tp.assertChanges(changes)\n\t}\n\treturn nil\n}\n\nfunc (p FakeWebhookProvider) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) {\n\t// for simplicity, we do not adjust endpoints in this test\n\tif p.err != nil {\n\t\treturn nil, p.err\n\t}\n\treturn endpoints, nil\n}\n\nfunc (p FakeWebhookProvider) GetDomainFilter() endpoint.DomainFilterInterface {\n\treturn p.domainFilter\n}\n\nfunc TestMain(m *testing.M) {\n\trecords = []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"foo.bar.com\",\n\t\t\tRecordType: \"A\",\n\t\t},\n\t}\n\tm.Run()\n}\n\nfunc TestRecordsHandlerRecords(t *testing.T) {\n\treq := httptest.NewRequest(http.MethodGet, UrlRecords, nil)\n\tw := httptest.NewRecorder()\n\n\tproviderAPIServer := &WebhookServer{\n\t\tProvider: &FakeWebhookProvider{\n\t\t\tdomainFilter: endpoint.NewDomainFilter([]string{\"foo.bar.com\"}),\n\t\t},\n\t}\n\tproviderAPIServer.RecordsHandler(w, req)\n\tres := w.Result()\n\trequire.Equal(t, http.StatusOK, res.StatusCode)\n\t// require that the res has the same endpoints as the records slice\n\tdefer res.Body.Close()\n\trequire.NotNil(t, res.Body)\n\tvar endpoints []*endpoint.Endpoint\n\tif err := json.NewDecoder(res.Body).Decode(&endpoints); err != nil {\n\t\tt.Errorf(\"Failed to decode response body: %s\", err.Error())\n\t}\n\trequire.Equal(t, records, endpoints)\n}\n\nfunc TestRecordsHandlerRecordsWithErrors(t *testing.T) {\n\treq := httptest.NewRequest(http.MethodGet, UrlRecords, nil)\n\tw := httptest.NewRecorder()\n\n\tproviderAPIServer := &WebhookServer{\n\t\tProvider: &FakeWebhookProvider{\n\t\t\terr: fmt.Errorf(\"error\"),\n\t\t},\n\t}\n\tproviderAPIServer.RecordsHandler(w, req)\n\tres := w.Result()\n\trequire.Equal(t, http.StatusInternalServerError, res.StatusCode)\n}\n\nfunc TestRecordsHandlerApplyChangesWithBadRequest(t *testing.T) {\n\treq := httptest.NewRequest(http.MethodPost, \"/applychanges\", nil)\n\tw := httptest.NewRecorder()\n\n\tproviderAPIServer := &WebhookServer{\n\t\tProvider: &FakeWebhookProvider{},\n\t}\n\tproviderAPIServer.RecordsHandler(w, req)\n\tres := w.Result()\n\trequire.Equal(t, http.StatusBadRequest, res.StatusCode)\n}\n\nfunc TestRecordsHandlerApplyChangesWithValidRequest(t *testing.T) {\n\tchanges := &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"foo.bar.com\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tTargets:    endpoint.Targets{},\n\t\t\t},\n\t\t},\n\t}\n\tj, err := json.Marshal(changes)\n\trequire.NoError(t, err)\n\n\treader := bytes.NewReader(j)\n\n\treq := httptest.NewRequest(http.MethodPost, UrlApplyChanges, reader)\n\tw := httptest.NewRecorder()\n\n\tproviderAPIServer := &WebhookServer{\n\t\tProvider: &FakeWebhookProvider{},\n\t}\n\tproviderAPIServer.RecordsHandler(w, req)\n\tres := w.Result()\n\trequire.Equal(t, http.StatusNoContent, res.StatusCode)\n}\n\nfunc TestRecordsHandlerApplyChangesWithErrors(t *testing.T) {\n\tchanges := &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"foo.bar.com\",\n\t\t\t\tRecordType: \"A\",\n\t\t\t\tTargets:    endpoint.Targets{},\n\t\t\t},\n\t\t},\n\t}\n\tj, err := json.Marshal(changes)\n\trequire.NoError(t, err)\n\n\treader := bytes.NewReader(j)\n\n\treq := httptest.NewRequest(http.MethodPost, UrlApplyChanges, reader)\n\tw := httptest.NewRecorder()\n\n\tproviderAPIServer := &WebhookServer{\n\t\tProvider: &FakeWebhookProvider{\n\t\t\terr: fmt.Errorf(\"error\"),\n\t\t},\n\t}\n\tproviderAPIServer.RecordsHandler(w, req)\n\tres := w.Result()\n\trequire.Equal(t, http.StatusInternalServerError, res.StatusCode)\n}\n\nfunc TestRecordsHandlerWithWrongHTTPMethod(t *testing.T) {\n\treq := httptest.NewRequest(http.MethodPut, UrlRecords, nil)\n\tw := httptest.NewRecorder()\n\n\tproviderAPIServer := &WebhookServer{\n\t\tProvider: &FakeWebhookProvider{},\n\t}\n\tproviderAPIServer.RecordsHandler(w, req)\n\tres := w.Result()\n\trequire.Equal(t, http.StatusBadRequest, res.StatusCode)\n}\n\nfunc TestRecordsHandlerWithMixedCase(t *testing.T) {\n\tinput := `{\"Create\":[{\"dnsName\":\"foo\"}],\"updateOld\":[{\"dnsName\":\"bar\"}],\"updateNew\":[{\"dnsName\":\"baz\"}],\"Delete\":[{\"dnsName\":\"qux\"}]}`\n\treq := httptest.NewRequest(http.MethodPost, UrlRecords, strings.NewReader(input))\n\tw := httptest.NewRecorder()\n\n\trecords = []*endpoint.Endpoint{}\n\n\tproviderAPIServer := &WebhookServer{\n\t\tProvider: &FakeWebhookProvider{\n\t\t\tassertChanges: func(changes *plan.Changes) {\n\t\t\t\tt.Helper()\n\t\t\t\trequire.Equal(t, []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName: \"foo\",\n\t\t\t\t\t},\n\t\t\t\t}, changes.Create)\n\t\t\t\trequire.Equal(t, []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName: \"bar\",\n\t\t\t\t\t},\n\t\t\t\t}, changes.UpdateOld)\n\t\t\t\trequire.Equal(t, []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName: \"qux\",\n\t\t\t\t\t},\n\t\t\t\t}, changes.Delete)\n\t\t\t},\n\t\t},\n\t}\n\tproviderAPIServer.RecordsHandler(w, req)\n\tres := w.Result()\n\trequire.Equal(t, http.StatusNoContent, res.StatusCode)\n\tassert.Len(t, records, 1)\n}\n\nfunc TestAdjustEndpointsHandlerWithInvalidRequest(t *testing.T) {\n\treq := httptest.NewRequest(http.MethodPost, UrlAdjustEndpoints, nil)\n\tw := httptest.NewRecorder()\n\n\tproviderAPIServer := &WebhookServer{\n\t\tProvider: &FakeWebhookProvider{},\n\t}\n\tproviderAPIServer.AdjustEndpointsHandler(w, req)\n\tres := w.Result()\n\trequire.Equal(t, http.StatusBadRequest, res.StatusCode)\n\n\treq = httptest.NewRequest(http.MethodGet, UrlAdjustEndpoints, nil)\n\n\tproviderAPIServer.AdjustEndpointsHandler(w, req)\n\tres = w.Result()\n\trequire.Equal(t, http.StatusBadRequest, res.StatusCode)\n}\n\nfunc TestAdjustEndpointsHandlerWithValidRequest(t *testing.T) {\n\tpve := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"foo.bar.com\",\n\t\t\tRecordType: \"A\",\n\t\t\tTargets:    endpoint.Targets{},\n\t\t\tRecordTTL:  0,\n\t\t},\n\t}\n\n\tj, err := json.Marshal(pve)\n\trequire.NoError(t, err)\n\n\treader := bytes.NewReader(j)\n\treq := httptest.NewRequest(http.MethodPost, UrlAdjustEndpoints, reader)\n\tw := httptest.NewRecorder()\n\n\tproviderAPIServer := &WebhookServer{\n\t\tProvider: &FakeWebhookProvider{},\n\t}\n\tproviderAPIServer.AdjustEndpointsHandler(w, req)\n\tres := w.Result()\n\trequire.Equal(t, http.StatusOK, res.StatusCode)\n\trequire.NotNil(t, res.Body)\n}\n\nfunc TestAdjustEndpointsHandlerWithError(t *testing.T) {\n\tpve := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"foo.bar.com\",\n\t\t\tRecordType: \"A\",\n\t\t\tTargets:    endpoint.Targets{},\n\t\t\tRecordTTL:  0,\n\t\t},\n\t}\n\n\tj, err := json.Marshal(pve)\n\trequire.NoError(t, err)\n\n\treader := bytes.NewReader(j)\n\treq := httptest.NewRequest(http.MethodPost, UrlAdjustEndpoints, reader)\n\tw := httptest.NewRecorder()\n\n\tproviderAPIServer := &WebhookServer{\n\t\tProvider: &FakeWebhookProvider{\n\t\t\terr: fmt.Errorf(\"error\"),\n\t\t},\n\t}\n\tproviderAPIServer.AdjustEndpointsHandler(w, req)\n\tres := w.Result()\n\trequire.Equal(t, http.StatusInternalServerError, res.StatusCode)\n\trequire.NotNil(t, res.Body)\n}\n\nfunc TestStartHTTPApi(t *testing.T) {\n\tstartedChan := make(chan struct{})\n\tgo StartHTTPApi(FakeWebhookProvider{}, startedChan, 5*time.Second, 10*time.Second, \"127.0.0.1:8887\")\n\t<-startedChan\n\tresp, err := http.Get(\"http://127.0.0.1:8887\")\n\trequire.NoError(t, err)\n\t// check that resp has a valid domain filter\n\tdefer resp.Body.Close()\n\n\tdf := endpoint.DomainFilter{}\n\tb, err := io.ReadAll(resp.Body)\n\trequire.NoError(t, err)\n\trequire.NoError(t, df.UnmarshalJSON(b))\n}\n\nfunc TestNegotiateHandler_Success(t *testing.T) {\n\tprovider := &FakeWebhookProvider{\n\t\tdomainFilter: endpoint.NewDomainFilter([]string{\"foo.bar.com\"}),\n\t}\n\tserver := &WebhookServer{Provider: provider}\n\tw := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodGet, \"/\", nil)\n\n\tserver.NegotiateHandler(w, req)\n\tres := w.Result()\n\tdefer res.Body.Close()\n\n\trequire.Equal(t, http.StatusOK, res.StatusCode)\n\trequire.Equal(t, MediaTypeFormatAndVersion, res.Header.Get(ContentTypeHeader))\n\n\tdf := &endpoint.DomainFilter{}\n\tbody, err := io.ReadAll(res.Body)\n\trequire.NoError(t, err)\n\trequire.NoError(t, df.UnmarshalJSON(body))\n\trequire.Equal(t, provider.domainFilter, df)\n}\n\nfunc TestNegotiateHandler_FiltersWithSpecialEncodings(t *testing.T) {\n\tprovider := &FakeWebhookProvider{\n\t\tdomainFilter: endpoint.NewDomainFilter([]string{\"\\\\u001a\", \"\\\\Xfoo.\\\\u2028, \\\\u0000.com\", \"<invalid json>\"}),\n\t}\n\tserver := &WebhookServer{Provider: provider}\n\tw := httptest.NewRecorder()\n\treq := httptest.NewRequest(http.MethodGet, \"/\", nil)\n\n\tserver.NegotiateHandler(w, req)\n\tres := w.Result()\n\tdefer res.Body.Close()\n\n\trequire.Equal(t, http.StatusOK, res.StatusCode)\n}\n"
  },
  {
    "path": "provider/webhook/webhook.go",
    "content": "/*\nCopyright 2023 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage webhook\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"time\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\textdnshttp \"sigs.k8s.io/external-dns/pkg/http\"\n\t\"sigs.k8s.io/external-dns/pkg/metrics\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n\twebhookapi \"sigs.k8s.io/external-dns/provider/webhook/api\"\n\n\t\"github.com/cenkalti/backoff/v5\"\n\t\"github.com/prometheus/client_golang/prometheus\"\n\tlog \"github.com/sirupsen/logrus\"\n)\n\nconst (\n\tacceptHeader = \"Accept\"\n\tmaxRetries   = 5\n)\n\nvar (\n\trecordsErrorsGauge = metrics.NewGaugeWithOpts(\n\t\tprometheus.GaugeOpts{\n\t\t\tSubsystem: \"webhook_provider\",\n\t\t\tName:      \"records_errors_total\",\n\t\t\tHelp:      \"Errors with Records method\",\n\t\t},\n\t)\n\trecordsRequestsGauge = metrics.NewGaugeWithOpts(\n\t\tprometheus.GaugeOpts{\n\t\t\tSubsystem: \"webhook_provider\",\n\t\t\tName:      \"records_requests_total\",\n\t\t\tHelp:      \"Requests with Records method\",\n\t\t},\n\t)\n\tapplyChangesErrorsGauge = metrics.NewGaugeWithOpts(\n\t\tprometheus.GaugeOpts{\n\t\t\tSubsystem: \"webhook_provider\",\n\t\t\tName:      \"applychanges_errors_total\",\n\t\t\tHelp:      \"Errors with ApplyChanges method\",\n\t\t},\n\t)\n\tapplyChangesRequestsGauge = metrics.NewGaugeWithOpts(\n\t\tprometheus.GaugeOpts{\n\t\t\tSubsystem: \"webhook_provider\",\n\t\t\tName:      \"applychanges_requests_total\",\n\t\t\tHelp:      \"Requests with ApplyChanges method\",\n\t\t},\n\t)\n\tadjustEndpointsErrorsGauge = metrics.NewGaugeWithOpts(\n\t\tprometheus.GaugeOpts{\n\t\t\tSubsystem: \"webhook_provider\",\n\t\t\tName:      \"adjustendpoints_errors_total\",\n\t\t\tHelp:      \"Errors with AdjustEndpoints method\",\n\t\t},\n\t)\n\tadjustEndpointsRequestsGauge = metrics.NewGaugeWithOpts(\n\t\tprometheus.GaugeOpts{\n\t\t\tSubsystem: \"webhook_provider\",\n\t\t\tName:      \"adjustendpoints_requests_total\",\n\t\t\tHelp:      \"Requests with AdjustEndpoints method\",\n\t\t},\n\t)\n)\n\ntype WebhookProvider struct {\n\tclient          *http.Client\n\tremoteServerURL *url.URL\n\tDomainFilter    *endpoint.DomainFilter\n}\n\nfunc init() {\n\tmetrics.RegisterMetric.MustRegister(recordsErrorsGauge)\n\tmetrics.RegisterMetric.MustRegister(recordsRequestsGauge)\n\tmetrics.RegisterMetric.MustRegister(applyChangesErrorsGauge)\n\tmetrics.RegisterMetric.MustRegister(applyChangesRequestsGauge)\n\tmetrics.RegisterMetric.MustRegister(adjustEndpointsErrorsGauge)\n\tmetrics.RegisterMetric.MustRegister(adjustEndpointsRequestsGauge)\n}\n\n// New creates a webhook provider from the given configuration.\nfunc New(ctx context.Context, cfg *externaldns.Config, _ *endpoint.DomainFilter) (provider.Provider, error) {\n\treturn newProvider(ctx, cfg.WebhookProviderURL, cfg.WebhookProviderReadTimeout, cfg.WebhookProviderWriteTimeout)\n}\n\nfunc newProvider(ctx context.Context, u string, readTimeout, writeTimeout time.Duration) (*WebhookProvider, error) {\n\tparsedURL, err := url.Parse(u)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// covers the entire round-trip — writing the request body + waiting for + reading the response\n\tclient := &http.Client{Timeout: readTimeout + writeTimeout}\n\n\t// negotiate API information\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treq.Header.Set(acceptHeader, webhookapi.MediaTypeFormatAndVersion)\n\n\tresp, err := requestWithRetry(client, req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to connect to webhook: %w\", err)\n\t}\n\tdefer extdnshttp.DrainAndClose(resp.Body)\n\n\tif ct := resp.Header.Get(webhookapi.ContentTypeHeader); ct != webhookapi.MediaTypeFormatAndVersion {\n\t\treturn nil, fmt.Errorf(\"wrong content type returned from server: %s\", ct)\n\t}\n\n\tdf := &endpoint.DomainFilter{}\n\tif err := json.NewDecoder(resp.Body).Decode(df); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to unmarshal response body of DomainFilter: %w\", err)\n\t}\n\n\treturn &WebhookProvider{\n\t\tclient:          client,\n\t\tremoteServerURL: parsedURL,\n\t\tDomainFilter:    df,\n\t}, nil\n}\n\nfunc requestWithRetry(client *http.Client, req *http.Request) (*http.Response, error) {\n\tresp, err := backoff.Retry(req.Context(), func() (*http.Response, error) {\n\t\t// Reset the body before each attempt so retries send the full payload.\n\t\t// GetBody is set automatically by http.NewRequest for in-memory body types;\n\t\t// it is nil for GET requests (no body), so the block is safely skipped.\n\t\tif req.GetBody != nil {\n\t\t\tbody, err := req.GetBody()\n\t\t\tif err != nil {\n\t\t\t\treturn nil, backoff.Permanent(fmt.Errorf(\"failed to reset request body: %w\", err))\n\t\t\t}\n\t\t\treq.Body = body\n\t\t}\n\n\t\tresp, err := client.Do(req)\n\t\tif err != nil {\n\t\t\tlog.Debugf(\"Failed to connect to webhook: %v\", err)\n\t\t\treturn nil, err\n\t\t}\n\t\t// 5xx: retryable server error\n\t\tif resp.StatusCode >= http.StatusInternalServerError {\n\t\t\textdnshttp.DrainAndClose(resp.Body)\n\t\t\treturn nil, fmt.Errorf(\"server error: status code %d\", resp.StatusCode)\n\t\t}\n\t\t// 3xx/4xx: permanent error, not worth retrying.\n\t\t// Note: http.Client follows redirects automatically, so a 3xx here means\n\t\t// either a non-redirect 3xx (e.g. 304) or a custom CheckRedirect policy;\n\t\t// it does not mean a normal redirect was left unhandled.\n\t\t// we currently only use 200 as success, but considering okay all 2XX for future usage\n\t\tif resp.StatusCode >= http.StatusMultipleChoices {\n\t\t\textdnshttp.DrainAndClose(resp.Body)\n\t\t\treturn nil, backoff.Permanent(fmt.Errorf(\"unexpected status code %d\", resp.StatusCode))\n\t\t}\n\t\treturn resp, nil\n\t}, backoff.WithMaxTries(maxRetries))\n\treturn resp, err\n}\n\n// Records will make a GET call to remoteServerURL/records and return the results\nfunc (p WebhookProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {\n\trecordsRequestsGauge.Gauge.Inc()\n\tu := p.remoteServerURL.JoinPath(\"records\").String()\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)\n\tif err != nil {\n\t\trecordsErrorsGauge.Gauge.Inc()\n\t\tlog.Debugf(\"Failed to create request: %s\", err.Error())\n\t\treturn nil, err\n\t}\n\treq.Header.Set(acceptHeader, webhookapi.MediaTypeFormatAndVersion)\n\tresp, err := p.client.Do(req)\n\tif err != nil {\n\t\trecordsErrorsGauge.Gauge.Inc()\n\t\tlog.Debugf(\"Failed to perform request: %s\", err.Error())\n\t\treturn nil, err\n\t}\n\tdefer extdnshttp.DrainAndClose(resp.Body)\n\n\tif resp.StatusCode != http.StatusOK {\n\t\trecordsErrorsGauge.Gauge.Inc()\n\t\tlog.Debugf(\"Failed to get records with code %d\", resp.StatusCode)\n\t\terr := fmt.Errorf(\"failed to get records with code %d\", resp.StatusCode)\n\t\tif isRetryableError(resp.StatusCode) {\n\t\t\treturn nil, provider.NewSoftError(err)\n\t\t}\n\t\treturn nil, err\n\t}\n\n\tvar endpoints []*endpoint.Endpoint\n\tif err := json.NewDecoder(resp.Body).Decode(&endpoints); err != nil {\n\t\trecordsErrorsGauge.Gauge.Inc()\n\t\tlog.Debugf(\"Failed to decode response body: %s\", err.Error())\n\t\treturn nil, err\n\t}\n\treturn endpoints, nil\n}\n\n// ApplyChanges will make a POST to remoteServerURL/records with the changes\nfunc (p WebhookProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {\n\tapplyChangesRequestsGauge.Gauge.Inc()\n\tu := p.remoteServerURL.JoinPath(webhookapi.UrlRecords).String()\n\n\tb := new(bytes.Buffer)\n\tif err := json.NewEncoder(b).Encode(changes); err != nil {\n\t\tapplyChangesErrorsGauge.Gauge.Inc()\n\t\tlog.Debugf(\"Failed to encode changes: %s\", err.Error())\n\t\treturn err\n\t}\n\n\treq, err := http.NewRequestWithContext(ctx, http.MethodPost, u, b)\n\tif err != nil {\n\t\tapplyChangesErrorsGauge.Gauge.Inc()\n\t\tlog.Debugf(\"Failed to create request: %s\", err.Error())\n\t\treturn err\n\t}\n\n\treq.Header.Set(webhookapi.ContentTypeHeader, webhookapi.MediaTypeFormatAndVersion)\n\n\tresp, err := p.client.Do(req)\n\tif err != nil {\n\t\tapplyChangesErrorsGauge.Gauge.Inc()\n\t\tlog.Debugf(\"Failed to perform request: %s\", err.Error())\n\t\treturn err\n\t}\n\n\tdefer extdnshttp.DrainAndClose(resp.Body)\n\n\tif resp.StatusCode != http.StatusNoContent {\n\t\tapplyChangesErrorsGauge.Gauge.Inc()\n\t\tlog.Debugf(\"Failed to apply changes with code %d\", resp.StatusCode)\n\t\terr := fmt.Errorf(\"failed to apply changes with code %d\", resp.StatusCode)\n\t\tif isRetryableError(resp.StatusCode) {\n\t\t\treturn provider.NewSoftError(err)\n\t\t}\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// AdjustEndpoints will call the provider doing a POST on `/adjustendpoints` which will return a list of modified endpoints\n// based on a provider-specific requirement.\n// This method returns an empty slice in case there is a technical error on the provider's side so that no endpoints will be considered.\nfunc (p WebhookProvider) AdjustEndpoints(e []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) {\n\tadjustEndpointsRequestsGauge.Gauge.Inc()\n\tvar endpoints []*endpoint.Endpoint\n\tu, err := url.JoinPath(p.remoteServerURL.String(), webhookapi.UrlAdjustEndpoints)\n\tif err != nil {\n\t\tadjustEndpointsErrorsGauge.Gauge.Inc()\n\t\tlog.Debugf(\"Failed to join path, %s\", err)\n\t\treturn nil, err\n\t}\n\n\tb := new(bytes.Buffer)\n\tif err := json.NewEncoder(b).Encode(e); err != nil {\n\t\tadjustEndpointsErrorsGauge.Gauge.Inc()\n\t\tlog.Debugf(\"Failed to encode endpoints, %s\", err)\n\t\treturn nil, err\n\t}\n\n\treq, err := http.NewRequest(http.MethodPost, u, b)\n\tif err != nil {\n\t\tadjustEndpointsErrorsGauge.Gauge.Inc()\n\t\tlog.Debugf(\"Failed to create new HTTP request, %s\", err)\n\t\treturn nil, err\n\t}\n\n\treq.Header.Set(webhookapi.ContentTypeHeader, webhookapi.MediaTypeFormatAndVersion)\n\treq.Header.Set(acceptHeader, webhookapi.MediaTypeFormatAndVersion)\n\n\tresp, err := p.client.Do(req)\n\tif err != nil {\n\t\tadjustEndpointsErrorsGauge.Gauge.Inc()\n\t\tlog.Debugf(\"Failed executing http request, %s\", err)\n\t\treturn nil, err\n\t}\n\t// drainAndClose is deferred here and runs after json.Decode below consumes the\n\t// success-path body, so the drain only sees any trailing bytes left unread.\n\tdefer extdnshttp.DrainAndClose(resp.Body)\n\n\tif resp.StatusCode != http.StatusOK {\n\t\tadjustEndpointsErrorsGauge.Gauge.Inc()\n\t\tlog.Debugf(\"Failed to AdjustEndpoints with code %d\", resp.StatusCode)\n\t\terr := fmt.Errorf(\"failed to AdjustEndpoints with code %d\", resp.StatusCode)\n\t\tif isRetryableError(resp.StatusCode) {\n\t\t\treturn nil, provider.NewSoftError(err)\n\t\t}\n\t\treturn nil, err\n\t}\n\n\tif err := json.NewDecoder(resp.Body).Decode(&endpoints); err != nil {\n\t\tadjustEndpointsErrorsGauge.Gauge.Inc()\n\t\tlog.Debugf(\"Failed to decode response body: %s\", err.Error())\n\t\treturn nil, err\n\t}\n\n\treturn endpoints, nil\n}\n\n// GetDomainFilter make calls to get the serialized version of the domain filter\nfunc (p WebhookProvider) GetDomainFilter() endpoint.DomainFilterInterface {\n\treturn p.DomainFilter\n}\n\n// isRetryableError returns true for HTTP status codes between 500 and 510 (inclusive)\nfunc isRetryableError(statusCode int) bool {\n\treturn statusCode >= http.StatusInternalServerError && statusCode <= http.StatusNotExtended\n}\n"
  },
  {
    "path": "provider/webhook/webhook_test.go",
    "content": "/*\nCopyright 2023 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage webhook\n\nimport (\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"net/url\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n\twebhookapi \"sigs.k8s.io/external-dns/provider/webhook/api\"\n)\n\nconst (\n\ttestReadTimeout  = 5 * time.Millisecond\n\ttestWriteTimeout = 10 * time.Millisecond\n)\n\nfunc TestNewWebhookProvider_InvalidURL(t *testing.T) {\n\t_, err := newProvider(t.Context(), \"://invalid-url\", testReadTimeout, testWriteTimeout)\n\trequire.Error(t, err)\n}\n\nfunc TestNewWebhookProvider_HTTPRequestFailure(t *testing.T) {\n\t_, err := newProvider(t.Context(), \"http://nonexistent.url\", testReadTimeout, testWriteTimeout)\n\trequire.Error(t, err)\n}\n\nfunc TestNewWebhookProvider_InvalidResponseBody(t *testing.T) {\n\tsvr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.Header().Set(webhookapi.ContentTypeHeader, webhookapi.MediaTypeFormatAndVersion)\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(\"invalid-json\")) // Invalid JSON\n\t}))\n\tdefer svr.Close()\n\n\t_, err := newProvider(t.Context(), svr.URL, testReadTimeout, testWriteTimeout)\n\trequire.Error(t, err)\n\trequire.Contains(t, err.Error(), \"failed to unmarshal response body of DomainFilter\")\n}\n\nfunc TestNewWebhookProvider_Non2XXStatusCode(t *testing.T) {\n\tsvr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusBadRequest)\n\t}))\n\tdefer svr.Close()\n\n\t_, err := newProvider(t.Context(), svr.URL, testReadTimeout, testWriteTimeout)\n\trequire.Error(t, err)\n\trequire.Contains(t, err.Error(), \"unexpected status code 400\")\n}\n\nfunc TestNewWebhookProvider_WrongContentTypeHeader(t *testing.T) {\n\tsvr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Path == \"/\" {\n\t\t\tw.Header().Set(webhookapi.ContentTypeHeader, webhookapi.MediaTypeFormatAndVersion+\"wrong\")\n\t\t\t_, _ = w.Write([]byte(`{}`))\n\t\t\treturn\n\t\t}\n\t}))\n\tdefer svr.Close()\n\n\t_, err := newProvider(t.Context(), svr.URL, testReadTimeout, testWriteTimeout)\n\trequire.Error(t, err)\n\trequire.Contains(t, err.Error(), \"wrong content type returned from server\")\n}\n\nfunc TestInvalidDomainFilter(t *testing.T) {\n\tsvr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Path == \"/\" {\n\t\t\tw.Header().Set(webhookapi.ContentTypeHeader, webhookapi.MediaTypeFormatAndVersion)\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\treturn\n\t\t}\n\t\tw.Write([]byte(`[{\n\t\t\t\"dnsName\" : \"test.example.com\"\n\t\t}]`))\n\t}))\n\tdefer svr.Close()\n\n\t_, err := newProvider(t.Context(), svr.URL, testReadTimeout, testWriteTimeout)\n\trequire.Error(t, err)\n}\n\nfunc TestValidDomainfilter(t *testing.T) {\n\t// initialize domain filter\n\tdomainFilter := endpoint.NewDomainFilter([]string{\"example.com\"})\n\tsvr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Path == \"/\" {\n\t\t\tw.Header().Set(webhookapi.ContentTypeHeader, webhookapi.MediaTypeFormatAndVersion)\n\t\t\tjson.NewEncoder(w).Encode(domainFilter)\n\t\t\treturn\n\t\t}\n\t}))\n\tdefer svr.Close()\n\n\tp, err := newProvider(t.Context(), svr.URL, testReadTimeout, testWriteTimeout)\n\trequire.NoError(t, err)\n\trequire.Equal(t, p.GetDomainFilter(), endpoint.NewDomainFilter([]string{\"example.com\"}))\n}\n\nfunc TestRecords(t *testing.T) {\n\tsvr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Path == \"/\" {\n\t\t\tw.Header().Set(webhookapi.ContentTypeHeader, webhookapi.MediaTypeFormatAndVersion)\n\t\t\tw.Write([]byte(`{}`))\n\t\t\treturn\n\t\t}\n\t\tassert.Equal(t, \"/records\", r.URL.Path)\n\t\tw.Write([]byte(`[{\n\t\t\t\"dnsName\" : \"test.example.com\"\n\t\t}]`))\n\t}))\n\tdefer svr.Close()\n\n\tprovider, err := newProvider(t.Context(), svr.URL, testReadTimeout, testWriteTimeout)\n\trequire.NoError(t, err)\n\tendpoints, err := provider.Records(t.Context())\n\trequire.NoError(t, err)\n\trequire.NotNil(t, endpoints)\n\trequire.Equal(t, []*endpoint.Endpoint{{\n\t\tDNSName: \"test.example.com\",\n\t}}, endpoints)\n}\n\nfunc TestRecordsWithErrors(t *testing.T) {\n\tsvr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Path == \"/\" {\n\t\t\tw.Header().Set(webhookapi.ContentTypeHeader, webhookapi.MediaTypeFormatAndVersion)\n\t\t\tw.Write([]byte(`{}`))\n\t\t\treturn\n\t\t}\n\t\tassert.Equal(t, \"/records\", r.URL.Path)\n\t\tw.WriteHeader(http.StatusInternalServerError)\n\t}))\n\tdefer svr.Close()\n\n\tp, err := newProvider(t.Context(), svr.URL, testReadTimeout, testWriteTimeout)\n\trequire.NoError(t, err)\n\t_, err = p.Records(t.Context())\n\trequire.Error(t, err)\n\trequire.ErrorIs(t, err, provider.SoftError)\n}\n\nfunc TestRecords_HTTPRequestErrorMissingHost0(t *testing.T) {\n\twpr := WebhookProvider{\n\t\tremoteServerURL: &url.URL{Scheme: \"http\", Host: \"example\\\\x00.com\", Path: \"\\\\x00\"},\n\t\tclient:          &http.Client{},\n\t}\n\n\t_, err := wpr.Records(t.Context())\n\trequire.Error(t, err)\n\trequire.Contains(t, err.Error(), \"invalid URL escape\")\n}\n\nfunc TestRecords_HTTPRequestErrorMissingHost(t *testing.T) {\n\twpr := WebhookProvider{\n\t\tremoteServerURL: &url.URL{Host: \"example.com\", Path: \"\\\\x00\"},\n\t\tclient:          &http.Client{},\n\t}\n\n\t_, err := wpr.Records(t.Context())\n\trequire.Error(t, err)\n\trequire.Contains(t, err.Error(), \"unsupported protocol scheme\")\n}\n\nfunc TestRecords_DecodeError(t *testing.T) {\n\tsvr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Path == webhookapi.UrlRecords {\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t_, _ = w.Write([]byte(\"invalid-json\")) // Simulate invalid JSON response\n\t\t\treturn\n\t\t}\n\t}))\n\tdefer svr.Close()\n\n\tparsedURL, _ := url.Parse(svr.URL)\n\tp := WebhookProvider{\n\t\tremoteServerURL: parsedURL,\n\t\tclient:          &http.Client{},\n\t}\n\n\t_, err := p.Records(t.Context())\n\trequire.Error(t, err)\n\trequire.Contains(t, err.Error(), \"invalid character 'i' looking for beginning of value\")\n}\n\nfunc TestRecords_NonOKStatusCode(t *testing.T) {\n\tsvr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusNetworkAuthenticationRequired)\n\t\treturn\n\t}))\n\tdefer svr.Close()\n\n\tparsedURL, _ := url.Parse(svr.URL)\n\n\tp := WebhookProvider{\n\t\tremoteServerURL: &url.URL{Scheme: parsedURL.Scheme, Host: parsedURL.Host},\n\t\tclient:          &http.Client{},\n\t}\n\n\t_, err := p.Records(t.Context())\n\trequire.Error(t, err)\n\tassert.Contains(t, err.Error(), \"failed to get records with code 511\")\n}\n\nfunc TestApplyChanges(t *testing.T) {\n\tsuccessfulApplyChanges := true\n\tsvr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Path == \"/\" {\n\t\t\tw.Header().Set(webhookapi.ContentTypeHeader, webhookapi.MediaTypeFormatAndVersion)\n\t\t\tw.Write([]byte(`{}`))\n\t\t\treturn\n\t\t}\n\t\tassert.Equal(t, \"/records\", r.URL.Path)\n\t\tif successfulApplyChanges {\n\t\t\tw.WriteHeader(http.StatusNoContent)\n\t\t} else {\n\t\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\t}\n\t}))\n\tdefer svr.Close()\n\n\tp, err := newProvider(t.Context(), svr.URL, testReadTimeout, testWriteTimeout)\n\trequire.NoError(t, err)\n\terr = p.ApplyChanges(t.Context(), nil)\n\trequire.NoError(t, err)\n\n\tsuccessfulApplyChanges = false\n\n\terr = p.ApplyChanges(t.Context(), nil)\n\trequire.Error(t, err)\n\trequire.ErrorIs(t, err, provider.SoftError)\n}\n\nfunc TestApplyChanges_HTTPNewRequestErrorWrongHost(t *testing.T) {\n\twpr := WebhookProvider{\n\t\tremoteServerURL: &url.URL{Host: \"exa\\\\x00mple.com\"},\n\t\tclient:          &http.Client{},\n\t}\n\n\terr := wpr.ApplyChanges(t.Context(), nil)\n\trequire.Error(t, err)\n\trequire.Contains(t, err.Error(), \"invalid URL escape\")\n}\n\nfunc TestApplyChanges_GetFailed(t *testing.T) {\n\tp := WebhookProvider{\n\t\tremoteServerURL: &url.URL{Host: \"localhost\"},\n\t\tclient:          &http.Client{},\n\t}\n\n\terr := p.ApplyChanges(t.Context(), &plan.Changes{})\n\trequire.Error(t, err)\n\tassert.Contains(t, err.Error(), \"unsupported protocol scheme\")\n}\n\nfunc TestApplyChanges_StatusCodeError(t *testing.T) {\n\tsvr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Path == \"/\" {\n\t\t\tw.Header().Set(webhookapi.ContentTypeHeader, webhookapi.MediaTypeFormatAndVersion)\n\t\t\tw.Write([]byte(`{}`))\n\t\t\treturn\n\t\t}\n\t\tassert.Equal(t, webhookapi.UrlRecords, r.URL.Path)\n\t\tw.WriteHeader(http.StatusNetworkAuthenticationRequired)\n\t}))\n\tdefer svr.Close()\n\n\tp, err := newProvider(t.Context(), svr.URL, testReadTimeout, testWriteTimeout)\n\trequire.NoError(t, err)\n\n\terr = p.ApplyChanges(t.Context(), nil)\n\trequire.Error(t, err)\n\trequire.NotErrorIs(t, err, provider.SoftError)\n\tassert.Contains(t, err.Error(), \"failed to apply changes with code 511\")\n}\n\nfunc TestAdjustEndpoints(t *testing.T) {\n\tsvr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Path == \"/\" {\n\t\t\tw.Header().Set(webhookapi.ContentTypeHeader, webhookapi.MediaTypeFormatAndVersion)\n\t\t\tw.Write([]byte(`{}`))\n\t\t\treturn\n\t\t}\n\t\tassert.Equal(t, webhookapi.UrlAdjustEndpoints, r.URL.Path)\n\n\t\tvar endpoints []*endpoint.Endpoint\n\t\tdefer r.Body.Close()\n\t\tb, err := io.ReadAll(r.Body)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\terr = json.Unmarshal(b, &endpoints)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\tfor _, e := range endpoints {\n\t\t\te.RecordTTL = 0\n\t\t}\n\t\tj, _ := json.Marshal(endpoints)\n\t\tw.Write(j)\n\t}))\n\tdefer svr.Close()\n\n\tprovider, err := newProvider(t.Context(), svr.URL, testReadTimeout, testWriteTimeout)\n\trequire.NoError(t, err)\n\tendpoints := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"test.example.com\",\n\t\t\tRecordTTL:  10,\n\t\t\tRecordType: \"A\",\n\t\t\tTargets: endpoint.Targets{\n\t\t\t\t\"\",\n\t\t\t},\n\t\t},\n\t}\n\tadjustedEndpoints, err := provider.AdjustEndpoints(endpoints)\n\trequire.NoError(t, err)\n\trequire.Equal(t, []*endpoint.Endpoint{{\n\t\tDNSName:    \"test.example.com\",\n\t\tRecordTTL:  0,\n\t\tRecordType: \"A\",\n\t\tTargets: endpoint.Targets{\n\t\t\t\"\",\n\t\t},\n\t}}, adjustedEndpoints)\n}\n\nfunc TestAdjustendpointsWithError(t *testing.T) {\n\tsvr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Path == \"/\" {\n\t\t\tw.Header().Set(webhookapi.ContentTypeHeader, webhookapi.MediaTypeFormatAndVersion)\n\t\t\tw.Write([]byte(`{}`))\n\t\t\treturn\n\t\t}\n\t\tassert.Equal(t, webhookapi.UrlAdjustEndpoints, r.URL.Path)\n\t\tw.WriteHeader(http.StatusInternalServerError)\n\t}))\n\tdefer svr.Close()\n\n\tp, err := newProvider(t.Context(), svr.URL, testReadTimeout, testWriteTimeout)\n\trequire.NoError(t, err)\n\tendpoints := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"test.example.com\",\n\t\t\tRecordTTL:  10,\n\t\t\tRecordType: \"A\",\n\t\t\tTargets: endpoint.Targets{\n\t\t\t\t\"\",\n\t\t\t},\n\t\t},\n\t}\n\t_, err = p.AdjustEndpoints(endpoints)\n\trequire.Error(t, err)\n\trequire.ErrorIs(t, err, provider.SoftError)\n}\n\n// test apply changes with an endpoint with a provider specific property\nfunc TestApplyChangesWithProviderSpecificProperty(t *testing.T) {\n\tsvr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Path == \"/\" {\n\t\t\tw.Header().Set(webhookapi.ContentTypeHeader, webhookapi.MediaTypeFormatAndVersion)\n\t\t\tw.Write([]byte(`{}`))\n\t\t\treturn\n\t\t}\n\t\tif r.URL.Path == \"/records\" {\n\t\t\tw.Header().Set(webhookapi.ContentTypeHeader, webhookapi.MediaTypeFormatAndVersion)\n\t\t\t// assert that the request contains the provider-specific property\n\t\t\tvar changes plan.Changes\n\t\t\tdefer r.Body.Close()\n\t\t\tb, err := io.ReadAll(r.Body)\n\t\t\tassert.NoError(t, err)\n\t\t\terr = json.Unmarshal(b, &changes)\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.Len(t, changes.Create, 1)\n\t\t\tassert.Len(t, changes.Create[0].ProviderSpecific, 1)\n\t\t\tassert.Equal(t, \"prop1\", changes.Create[0].ProviderSpecific[0].Name)\n\t\t\tassert.Equal(t, \"value1\", changes.Create[0].ProviderSpecific[0].Value)\n\t\t\tw.WriteHeader(http.StatusNoContent)\n\t\t\treturn\n\t\t}\n\t}))\n\tdefer svr.Close()\n\n\tp, err := newProvider(t.Context(), svr.URL, testReadTimeout, testWriteTimeout)\n\trequire.NoError(t, err)\n\te := &endpoint.Endpoint{\n\t\tDNSName:    \"test.example.com\",\n\t\tRecordTTL:  10,\n\t\tRecordType: \"A\",\n\t\tTargets: endpoint.Targets{\n\t\t\t\"\",\n\t\t},\n\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\tendpoint.ProviderSpecificProperty{\n\t\t\t\tName:  \"prop1\",\n\t\t\t\tValue: \"value1\",\n\t\t\t},\n\t\t},\n\t}\n\terr = p.ApplyChanges(t.Context(), &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\te,\n\t\t},\n\t})\n\trequire.NoError(t, err)\n}\n\nfunc TestAdjustEndpoints_JoinPathError(t *testing.T) {\n\twpr := WebhookProvider{\n\t\tremoteServerURL: &url.URL{Scheme: \"http\", Host: \"example\\\\x00.com\"},\n\t}\n\n\t_, err := wpr.AdjustEndpoints(nil)\n\trequire.Error(t, err)\n\trequire.Contains(t, err.Error(), \"invalid URL escape\")\n}\n\nfunc TestAdjustEndpoints_HTTPRequestErrorMissingHost(t *testing.T) {\n\twpr := WebhookProvider{\n\t\tremoteServerURL: &url.URL{Host: \"example.com\", Path: \"\\\\x00\"},\n\t\tclient:          &http.Client{},\n\t}\n\n\t_, err := wpr.AdjustEndpoints(nil)\n\trequire.Error(t, err)\n\trequire.Contains(t, err.Error(), \"unsupported protocol scheme\") // Ensure the \"BINGO\" log is triggered\n}\n\nfunc TestAdjustEndpoints_NonOKStatusCode(t *testing.T) {\n\tsvr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusNetworkAuthenticationRequired)\n\t\treturn\n\t}))\n\tdefer svr.Close()\n\n\tparsedURL, _ := url.Parse(svr.URL)\n\n\tp := WebhookProvider{\n\t\tremoteServerURL: &url.URL{Scheme: parsedURL.Scheme, Host: parsedURL.Host},\n\t\tclient:          &http.Client{},\n\t}\n\n\tendpoints := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"test.example.com\",\n\t\t\tRecordTTL:  10,\n\t\t\tRecordType: \"A\",\n\t\t\tTargets:    endpoint.Targets{\"\"},\n\t\t},\n\t}\n\n\t_, err := p.AdjustEndpoints(endpoints)\n\trequire.Error(t, err)\n\tassert.Contains(t, err.Error(), \"failed to AdjustEndpoints with code 511\")\n}\n\nfunc TestAdjustEndpoints_DecodeError(t *testing.T) {\n\tsvr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.URL.Path == webhookapi.UrlAdjustEndpoints {\n\t\t\tw.Header().Set(webhookapi.ContentTypeHeader, webhookapi.MediaTypeFormatAndVersion)\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t_, _ = w.Write([]byte(\"invalid-json\")) // Simulate invalid JSON response\n\t\t\treturn\n\t\t}\n\t}))\n\tdefer svr.Close()\n\n\tparsedURL, _ := url.Parse(svr.URL)\n\tp := WebhookProvider{\n\t\tremoteServerURL: parsedURL,\n\t\tclient:          &http.Client{},\n\t}\n\n\tvar endpoints []*endpoint.Endpoint\n\n\t_, err := p.AdjustEndpoints(endpoints)\n\trequire.Error(t, err)\n\trequire.Contains(t, err.Error(), \"invalid character 'i' looking for beginning of value\")\n}\n\nfunc TestRequestWithRetry_Success(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t\tio.WriteString(w, \"ok\")\n\t}))\n\tdefer server.Close()\n\n\tclient := &http.Client{Timeout: 2 * time.Second}\n\treq, err := http.NewRequest(http.MethodGet, server.URL, nil)\n\trequire.NoError(t, err)\n\n\tresp, err := requestWithRetry(client, req)\n\trequire.NoError(t, err)\n\trequire.NotNil(t, resp)\n\trequire.Equal(t, http.StatusOK, resp.StatusCode)\n}\n\nfunc TestRequestWithRetry_NonRetriableStatus(t *testing.T) {\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tw.WriteHeader(http.StatusBadRequest)\n\t}))\n\tdefer server.Close()\n\n\tclient := &http.Client{Timeout: 2 * time.Second}\n\treq, err := http.NewRequest(http.MethodGet, server.URL, nil)\n\trequire.NoError(t, err)\n\n\tresp, err := requestWithRetry(client, req)\n\trequire.Error(t, err)\n\trequire.Nil(t, resp)\n}\n\nfunc TestRequestWithRetry_ServerErrorRetried(t *testing.T) {\n\tattempts := 0\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {\n\t\tattempts++\n\t\tif attempts < 3 {\n\t\t\tw.WriteHeader(http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\t\tw.WriteHeader(http.StatusOK)\n\t\tio.WriteString(w, \"ok\")\n\t}))\n\tdefer server.Close()\n\n\tclient := &http.Client{Timeout: 2 * time.Second}\n\treq, err := http.NewRequestWithContext(t.Context(), http.MethodGet, server.URL, nil)\n\trequire.NoError(t, err)\n\n\tresp, err := requestWithRetry(client, req)\n\trequire.NoError(t, err)\n\trequire.NotNil(t, resp)\n\trequire.Equal(t, http.StatusOK, resp.StatusCode)\n\trequire.Equal(t, 3, attempts)\n}\n"
  },
  {
    "path": "provider/zone_id_filter.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage provider\n\nimport \"strings\"\n\n// ZoneIDFilter holds a list of zone ids to filter by\ntype ZoneIDFilter struct {\n\tZoneIDs []string\n}\n\n// NewZoneIDFilter returns a new ZoneIDFilter given a list of zone ids\nfunc NewZoneIDFilter(zoneIDs []string) ZoneIDFilter {\n\treturn ZoneIDFilter{zoneIDs}\n}\n\n// Match checks whether a zone matches one of the provided zone ids\nfunc (f ZoneIDFilter) Match(zoneID string) bool {\n\t// An empty filter includes all zones.\n\tif len(f.ZoneIDs) == 0 {\n\t\treturn true\n\t}\n\tif len(f.ZoneIDs) == 1 && f.ZoneIDs[0] == \"\" {\n\t\treturn true\n\t}\n\n\tfor _, id := range f.ZoneIDs {\n\t\tif strings.HasSuffix(zoneID, id) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\n// IsConfigured returns true if DomainFilter is configured, false otherwise\nfunc (f ZoneIDFilter) IsConfigured() bool {\n\tif len(f.ZoneIDs) == 1 {\n\t\treturn f.ZoneIDs[0] != \"\"\n\t}\n\treturn len(f.ZoneIDs) > 0\n}\n"
  },
  {
    "path": "provider/zone_id_filter_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage provider\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\ntype zoneIDFilterTest struct {\n\tzoneIDFilter []string\n\tzone         string\n\texpected     bool\n}\n\ntype zoneIdFilterTestIsConfigured struct {\n\tzoneIDFilter []string\n\texpected     bool\n}\n\nfunc TestZoneIDFilterMatch(t *testing.T) {\n\tzone := \"/hostedzone/ZTST1\"\n\n\tfor _, tt := range []zoneIDFilterTest{\n\t\t{\n\t\t\t[]string{},\n\t\t\tzone,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t[]string{\"\"},\n\t\t\tzone,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t[]string{\"/hostedzone/ZTST1\"},\n\t\t\tzone,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t[]string{\"/hostedzone/ZTST2\"},\n\t\t\tzone,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t[]string{\"ZTST1\"},\n\t\t\tzone,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t[]string{\"ZTST2\"},\n\t\t\tzone,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t[]string{\"/hostedzone/ZTST1\", \"/hostedzone/ZTST2\"},\n\t\t\tzone,\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t[]string{\"/hostedzone/ZTST2\", \"/hostedzone/ZTST3\"},\n\t\t\tzone,\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t[]string{\"/hostedzone/ZTST2\", \"/hostedzone/ZTST1\"},\n\t\t\tzone,\n\t\t\ttrue,\n\t\t},\n\t} {\n\t\tzoneIDFilter := NewZoneIDFilter(tt.zoneIDFilter)\n\t\tassert.Equal(t, tt.expected, zoneIDFilter.Match(tt.zone))\n\t}\n}\n\nfunc TestZoneIDFilterIsConfigured(t *testing.T) {\n\tfor _, tt := range []zoneIdFilterTestIsConfigured{\n\t\t{\n\t\t\t[]string{\"\"},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t[]string{},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t[]string{\"/hostedzone/ZTST2\"},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t[]string{\"/hostedzone/ZTST2\", \"hostedzone/ZTST2\"},\n\t\t\ttrue,\n\t\t},\n\t\t{\n\t\t\t[]string{\"/ZSTS2\"},\n\t\t\ttrue,\n\t\t},\n\t} {\n\t\tzoneIDFilter := NewZoneIDFilter(tt.zoneIDFilter)\n\t\tassert.Equal(t, tt.expected, zoneIDFilter.IsConfigured())\n\t}\n}\n"
  },
  {
    "path": "provider/zone_tag_filter.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage provider\n\nimport (\n\t\"strings\"\n)\n\n// ZoneTagFilter holds a list of zone tags to filter by\ntype ZoneTagFilter struct {\n\ttagsMap map[string]string\n}\n\n// NewZoneTagFilter returns a new ZoneTagFilter given a list of zone tags\nfunc NewZoneTagFilter(tags []string) ZoneTagFilter {\n\tif len(tags) == 1 && len(tags[0]) == 0 {\n\t\ttags = []string{}\n\t}\n\ttagsMap := make(map[string]string)\n\t// tags pre-processing, to make sure the pre-processing is not happening at the time of filtering\n\tfor _, tag := range tags {\n\t\tparts := strings.SplitN(tag, \"=\", 2)\n\t\tkey := strings.TrimSpace(parts[0])\n\t\tif key == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif len(parts) == 2 {\n\t\t\tvalue := strings.TrimSpace(parts[1])\n\t\t\ttagsMap[key] = value\n\t\t} else {\n\t\t\ttagsMap[key] = \"\"\n\t\t}\n\t}\n\treturn ZoneTagFilter{tagsMap: tagsMap}\n}\n\n// Match checks whether a zone's set of tags matches the provided tag values\nfunc (f ZoneTagFilter) Match(tagsMap map[string]string) bool {\n\tfor key, v := range f.tagsMap {\n\t\tif value, hasTag := tagsMap[key]; !hasTag || (v != \"\" && value != v) {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// IsEmpty returns true if there are no tags for the filter\nfunc (f ZoneTagFilter) IsEmpty() bool {\n\treturn len(f.tagsMap) == 0\n}\n"
  },
  {
    "path": "provider/zone_tag_filter_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage provider\n\nimport (\n\t\"fmt\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nvar basicZoneTags = []struct {\n\tname       string\n\ttagsFilter []string\n\tzoneTags   map[string]string\n\tmatches    bool\n}{\n\t{\n\t\t\"single tag no match\", []string{\"tag1=value1\"}, map[string]string{\"tag0\": \"value0\"}, false,\n\t},\n\t{\n\t\t\"single tag matches\", []string{\"tag1=value1\"}, map[string]string{\"tag1\": \"value1\"}, true,\n\t},\n\t{\n\t\t\"multiple tags no value match\", []string{\"tag1=value1\"}, map[string]string{\"tag0\": \"value0\", \"tag1\": \"value2\"}, false,\n\t},\n\t{\n\t\t\"multiple tags matches\", []string{\"tag1=value1\"}, map[string]string{\"tag0\": \"value0\", \"tag1\": \"value1\"}, true,\n\t},\n\t{\n\t\t\"tag name no match\", []string{\"tag1\"}, map[string]string{\"tag0\": \"value0\"}, false,\n\t},\n\t{\n\t\t\"tag name matches\", []string{\"tag1\"}, map[string]string{\"tag1\": \"value1\"}, true,\n\t},\n\t{\n\t\t\"multiple filter no match\", []string{\"tag1=value1\", \"tag2=value2\"}, map[string]string{\"tag1\": \"value1\"}, false,\n\t},\n\t{\n\t\t\"multiple filter matches\", []string{\"tag1=value1\", \"tag2=value2\"}, map[string]string{\"tag2\": \"value2\", \"tag1\": \"value1\", \"tag3\": \"value3\"}, true,\n\t},\n\t{\n\t\t\"empty tag filter matches all\", []string{\"\"}, map[string]string{\"tag0\": \"value0\"}, true,\n\t},\n\t{\n\t\t\"tag filter without key and equal sign\", []string{\"tag1=value1\", \"=haha\"}, map[string]string{\"tag1\": \"value1\"}, true,\n\t},\n}\n\nfunc TestZoneTagFilterMatch(t *testing.T) {\n\tfor _, tc := range basicZoneTags {\n\t\tzoneTagFilter := NewZoneTagFilter(tc.tagsFilter)\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tassert.Equal(t, tc.matches, zoneTagFilter.Match(tc.zoneTags))\n\t\t})\n\t}\n}\n\nfunc TestZoneTagFilterNotSupportedFormat(t *testing.T) {\n\ttests := []struct {\n\t\tdesc string\n\t\ttags []string\n\t\twant map[string]string\n\t}{\n\t\t{desc: \"multiple or separate values with commas\", tags: []string{\"key1=val1,key2=val2\"}, want: map[string]string{\"key1\": \"val1,key2=val2\"}},\n\t\t{desc: \"exclude tag\", tags: []string{\"!key1\"}, want: map[string]string{\"!key1\": \"\"}},\n\t\t{desc: \"exclude tags\", tags: []string{\"!key1=val\"}, want: map[string]string{\"!key1\": \"val\"}},\n\t\t{desc: \"key is empty\", tags: []string{\"=val\"}, want: map[string]string{}},\n\t}\n\tfor _, tc := range tests {\n\t\tt.Run(fmt.Sprintf(\"%s\", tc.desc), func(t *testing.T) {\n\t\t\tgot := NewZoneTagFilter(tc.tags)\n\t\t\tassert.Equal(t, tc.want, got.tagsMap)\n\t\t})\n\t}\n}\n\nfunc TestZoneTagFilterMatchGeneratedValues(t *testing.T) {\n\ttests := []struct {\n\t\tfilters int\n\t\tzones   int\n\t\tsource  filterZoneTags\n\t}{\n\t\t{10, 30, generateTagFilterAndZoneTagsForMatch(10, 30)},\n\t\t{5, 40, generateTagFilterAndZoneTagsForMatch(5, 40)},\n\t\t{30, 50, generateTagFilterAndZoneTagsForMatch(30, 50)},\n\t}\n\tfor _, tc := range tests {\n\t\tt.Run(fmt.Sprintf(\"filters:%d zones:%d\", tc.filters, tc.zones), func(t *testing.T) {\n\t\t\tassert.True(t, tc.source.ZoneTagFilter.Match(tc.source.inputTags))\n\t\t})\n\t}\n}\n\nfunc TestZoneTagFilterNotMatchGeneratedValues(t *testing.T) {\n\ttests := []struct {\n\t\tfilters int\n\t\tzones   int\n\t\tsource  filterZoneTags\n\t}{\n\t\t{10, 30, generateTagFilterAndZoneTagsForNotMatch(10, 30)},\n\t\t{5, 40, generateTagFilterAndZoneTagsForNotMatch(5, 40)},\n\t\t{30, 50, generateTagFilterAndZoneTagsForNotMatch(30, 50)},\n\t}\n\tfor _, tc := range tests {\n\t\tt.Run(fmt.Sprintf(\"filters:%d zones:%d\", tc.filters, tc.zones), func(t *testing.T) {\n\t\t\tassert.False(t, tc.source.ZoneTagFilter.Match(tc.source.inputTags))\n\t\t})\n\t}\n}\n\n// benchmarks\nfunc BenchmarkZoneTagFilterMatchBasic(b *testing.B) {\n\tfor _, tc := range basicZoneTags {\n\t\tzoneTagFilter := NewZoneTagFilter(tc.tagsFilter)\n\t\tfor b.Loop() {\n\t\t\tzoneTagFilter.Match(tc.zoneTags)\n\t\t}\n\t}\n}\n\nvar benchFixtures = []struct {\n\tsource filterZoneTags\n}{\n\t// match\n\t{generateTagFilterAndZoneTagsForMatch(10, 30)},\n\t{generateTagFilterAndZoneTagsForMatch(5, 40)},\n\t{generateTagFilterAndZoneTagsForMatch(30, 50)},\n\t// \tno match\n\t{generateTagFilterAndZoneTagsForNotMatch(10, 30)},\n\t{generateTagFilterAndZoneTagsForNotMatch(5, 40)},\n\t{generateTagFilterAndZoneTagsForNotMatch(30, 50)},\n}\n\nfunc BenchmarkZoneTagFilterComplex(b *testing.B) {\n\tfor _, tc := range benchFixtures {\n\t\tfor b.Loop() {\n\t\t\ttc.source.ZoneTagFilter.Match(tc.source.inputTags)\n\t\t}\n\t}\n}\n\n// test doubles\ntype filterZoneTags struct {\n\tZoneTagFilter\n\tinputTags map[string]string\n}\n\n// generateTagFilterAndZoneTagsForMatch generates filter tags and zone tags that do match.\nfunc generateTagFilterAndZoneTagsForMatch(filter, zone int) filterZoneTags {\n\treturn generateTagFilterAndZoneTags(filter, zone, true)\n}\n\n// generateTagFilterAndZoneTagsForNotMatch generates filter tags and zone tags that do not match.\nfunc generateTagFilterAndZoneTagsForNotMatch(filter, zone int) filterZoneTags {\n\treturn generateTagFilterAndZoneTags(filter, zone, false)\n}\n\n// generateTagFilterAndZoneTags generates filter tags and zone tags based on the match parameter.\nfunc generateTagFilterAndZoneTags(filter, zone int, match bool) filterZoneTags {\n\tvalidate(filter, zone)\n\ttoFilterTags := make([]string, 0, filter)\n\tinputTags := make(map[string]string, zone)\n\n\tfor i := range filter {\n\t\ttagIndex := i\n\t\tif !match {\n\t\t\ttagIndex += 50\n\t\t}\n\t\ttoFilterTags = append(toFilterTags, fmt.Sprintf(\"tag-%d=value-%d\", tagIndex, i))\n\t}\n\n\tfor i := range zone {\n\t\ttagIndex := i\n\t\tif !match {\n\t\t\t// Make sure the input tags are different from the filter tags\n\t\t\ttagIndex += 2\n\t\t}\n\t\tinputTags[fmt.Sprintf(\"tag-%d\", i)] = fmt.Sprintf(\"value-%d\", tagIndex)\n\t}\n\n\treturn filterZoneTags{NewZoneTagFilter(toFilterTags), inputTags}\n}\n\nfunc validate(filter int, zone int) {\n\tif zone > 50 {\n\t\tpanic(\"zone number is too high\")\n\t}\n\tif filter > zone {\n\t\tpanic(\"filter number is too high\")\n\t}\n}\n"
  },
  {
    "path": "provider/zone_type_filter.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage provider\n\nimport (\n\troute53types \"github.com/aws/aws-sdk-go-v2/service/route53/types\"\n)\n\nconst (\n\tzoneTypePublic  = \"public\"\n\tzoneTypePrivate = \"private\"\n)\n\n// ZoneTypeFilter holds a zone type to filter for.\ntype ZoneTypeFilter struct {\n\tzoneType string\n}\n\n// NewZoneTypeFilter returns a new ZoneTypeFilter given a zone type to filter for.\nfunc NewZoneTypeFilter(zoneType string) ZoneTypeFilter {\n\treturn ZoneTypeFilter{zoneType: zoneType}\n}\n\n// Match checks whether a zone matches the zone type that's filtered for.\nfunc (f ZoneTypeFilter) Match(rawZoneType any) bool {\n\t// An empty zone filter includes all hosted zones.\n\tif f.zoneType == \"\" {\n\t\treturn true\n\t}\n\n\tswitch zoneType := rawZoneType.(type) {\n\t// Given a zone type we return true if the given zone matches this type.\n\tcase string:\n\t\tswitch f.zoneType {\n\t\tcase zoneTypePublic:\n\t\t\treturn zoneType == zoneTypePublic\n\t\tcase zoneTypePrivate:\n\t\t\treturn zoneType == zoneTypePrivate\n\t\t}\n\tcase route53types.HostedZone:\n\t\t// If the zone has no config we assume it's a public zone since the config's field\n\t\t// `PrivateZone` is false by default in go.\n\t\tif zoneType.Config == nil {\n\t\t\treturn f.zoneType == zoneTypePublic\n\t\t}\n\n\t\tswitch f.zoneType {\n\t\tcase zoneTypePublic:\n\t\t\treturn !zoneType.Config.PrivateZone\n\t\tcase zoneTypePrivate:\n\t\t\treturn zoneType.Config.PrivateZone\n\t\t}\n\t}\n\n\t// We return false on any other path, e.g. unknown zone type filter value.\n\treturn false\n}\n"
  },
  {
    "path": "provider/zone_type_filter_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage provider\n\nimport (\n\t\"testing\"\n\n\troute53types \"github.com/aws/aws-sdk-go-v2/service/route53/types\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestZoneTypeFilterMatch(t *testing.T) {\n\tpublicZoneStr := \"public\"\n\tprivateZoneStr := \"private\"\n\tpublicZoneAWS := route53types.HostedZone{Config: &route53types.HostedZoneConfig{PrivateZone: false}}\n\tprivateZoneAWS := route53types.HostedZone{Config: &route53types.HostedZoneConfig{PrivateZone: true}}\n\n\tfor _, tc := range []struct {\n\t\tzoneTypeFilter string\n\t\tmatches        bool\n\t\tzones          []any\n\t}{\n\t\t{\n\t\t\t\"\", true, []any{publicZoneStr, privateZoneStr, route53types.HostedZone{}},\n\t\t},\n\t\t{\n\t\t\t\"public\", true, []any{publicZoneStr, publicZoneAWS, route53types.HostedZone{}},\n\t\t},\n\t\t{\n\t\t\t\"public\", false, []any{privateZoneStr, privateZoneAWS},\n\t\t},\n\t\t{\n\t\t\t\"private\", true, []any{privateZoneStr, privateZoneAWS},\n\t\t},\n\t\t{\n\t\t\t\"private\", false, []any{publicZoneStr, publicZoneAWS, route53types.HostedZone{}},\n\t\t},\n\t\t{\n\t\t\t\"unknown\", false, []any{publicZoneStr},\n\t\t},\n\t} {\n\t\tt.Run(tc.zoneTypeFilter, func(t *testing.T) {\n\t\t\tzoneTypeFilter := NewZoneTypeFilter(tc.zoneTypeFilter)\n\t\t\tfor _, zone := range tc.zones {\n\t\t\t\tassert.Equal(t, tc.matches, zoneTypeFilter.Match(zone))\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "provider/zonefinder.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage provider\n\nimport (\n\t\"strings\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"sigs.k8s.io/external-dns/internal/idna\"\n)\n\ntype ZoneIDName map[string]string\n\nfunc (z ZoneIDName) Add(zoneID, zoneName string) {\n\tvar err error\n\tz[zoneID], err = idna.Profile.ToUnicode(zoneName)\n\tif err != nil {\n\t\tlog.Warnf(\"failed to convert zonename %q to its Unicode form: %v\", zoneName, err)\n\t\tz[zoneID] = zoneName\n\t}\n}\n\n// FindZone identifies the most suitable DNS zone for a given hostname.\n// It returns the zone ID and name that best match the hostname.\n//\n// The function processes the hostname by splitting it into labels and\n// converting each label to its Unicode form using IDNA (Internationalized\n// Domain Names for Applications) standards.\n//\n// Labels containing underscores ('_') are skipped during Unicode conversion.\n// This is because underscores are often used in special DNS records (e.g.,\n// SRV records as per RFC 2782, or TXT record for services) that are not\n// IDNA-aware and cannot represent non-ASCII labels. Skipping these labels\n// ensures compatibility with such use cases.\nfunc (z ZoneIDName) FindZone(hostname string) (string, string) {\n\tvar name string\n\tdomainLabels := strings.Split(hostname, \".\")\n\tfor i, label := range domainLabels {\n\t\tif strings.Contains(label, \"_\") {\n\t\t\tcontinue\n\t\t}\n\t\tconvertedLabel, err := idna.Profile.ToUnicode(label)\n\t\tif err != nil {\n\t\t\tlog.Warnf(\"Failed to convert label %q of hostname %q to its Unicode form: %v\", label, hostname, err)\n\t\t\tconvertedLabel = label\n\t\t}\n\t\tdomainLabels[i] = convertedLabel\n\t}\n\tname = strings.Join(domainLabels, \".\")\n\n\tvar suitableZoneID, suitableZoneName string\n\n\tfor zoneID, zoneName := range z {\n\t\tif name == zoneName || strings.HasSuffix(name, \".\"+zoneName) {\n\t\t\tif suitableZoneName == \"\" || len(zoneName) > len(suitableZoneName) {\n\t\t\t\tsuitableZoneID = zoneID\n\t\t\t\tsuitableZoneName = zoneName\n\t\t\t}\n\t\t}\n\t}\n\treturn suitableZoneID, suitableZoneName\n}\n"
  },
  {
    "path": "provider/zonefinder_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage provider\n\nimport (\n\t\"testing\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/assert\"\n\n\tlogtest \"sigs.k8s.io/external-dns/internal/testutils/log\"\n)\n\nfunc TestZoneIDName(t *testing.T) {\n\tz := ZoneIDName{}\n\tz.Add(\"123456\", \"foo.bar\")\n\tz.Add(\"123456\", \"qux.baz\")\n\tz.Add(\"654321\", \"foo.qux.baz\")\n\tz.Add(\"987654\", \"エイミー.みんな\")\n\tz.Add(\"123123\", \"_metadata.example.com\")\n\tz.Add(\"1231231\", \"_foo._metadata.example.com\")\n\tz.Add(\"456456\", \"_metadata.エイミー.みんな\")\n\tz.Add(\"123412\", \"*.example.com\")\n\t// adding a zone as punycode, see that it is injected as unicode/international format\n\tz.Add(\"234567\", \"xn--testcass-e1ae.fr\")\n\n\tassert.Equal(t, ZoneIDName{\n\t\t\"123456\":  \"qux.baz\",\n\t\t\"654321\":  \"foo.qux.baz\",\n\t\t\"987654\":  \"エイミー.みんな\",\n\t\t\"123123\":  \"_metadata.example.com\",\n\t\t\"1231231\": \"_foo._metadata.example.com\",\n\t\t\"456456\":  \"_metadata.エイミー.みんな\",\n\t\t\"123412\":  \"*.example.com\",\n\t\t\"234567\":  \"testécassé.fr\",\n\t}, z)\n\n\t// simple entry in a domain\n\tzoneID, zoneName := z.FindZone(\"name.qux.baz\")\n\tassert.Equal(t, \"qux.baz\", zoneName)\n\tassert.Equal(t, \"123456\", zoneID)\n\n\t// simple entry in a domain's subdomain.\n\tzoneID, zoneName = z.FindZone(\"name.foo.qux.baz\")\n\tassert.Equal(t, \"foo.qux.baz\", zoneName)\n\tassert.Equal(t, \"654321\", zoneID)\n\n\t// no possible zone for entry\n\tzoneID, zoneName = z.FindZone(\"name.qux.foo\")\n\tassert.Empty(t, zoneName)\n\tassert.Empty(t, zoneID)\n\n\t// no possible zone for entry of a substring to valid a zone\n\tzoneID, zoneName = z.FindZone(\"nomatch-foo.bar\")\n\tassert.Empty(t, zoneName)\n\tassert.Empty(t, zoneID)\n\n\t// entry's suffix matches a subdomain but doesn't belong there\n\tzoneID, zoneName = z.FindZone(\"name-foo.qux.baz\")\n\tassert.Equal(t, \"qux.baz\", zoneName)\n\tassert.Equal(t, \"123456\", zoneID)\n\n\t// entry is an exact match of the domain (e.g. azure provider)\n\tzoneID, zoneName = z.FindZone(\"foo.qux.baz\")\n\tassert.Equal(t, \"foo.qux.baz\", zoneName)\n\tassert.Equal(t, \"654321\", zoneID)\n\n\t// entry gets normalized before finding\n\tzoneID, zoneName = z.FindZone(\"xn--eckh0ome.xn--q9jyb4c\")\n\tassert.Equal(t, \"エイミー.みんな\", zoneName)\n\tassert.Equal(t, \"987654\", zoneID)\n\n\tzoneID, zoneName = z.FindZone(\"_foo._metadata.example.com\")\n\tassert.Equal(t, \"_foo._metadata.example.com\", zoneName)\n\tassert.Equal(t, \"1231231\", zoneID)\n\n\tzoneID, zoneName = z.FindZone(\"*.example.com\")\n\tassert.Equal(t, \"*.example.com\", zoneName)\n\tassert.Equal(t, \"123412\", zoneID)\n\n\t// looking for a zone that has been inserted as punycode\n\tzoneID, zoneName = z.FindZone(\"example.testécassé.fr\")\n\tassert.Equal(t, \"testécassé.fr\", zoneName)\n\tassert.Equal(t, \"234567\", zoneID)\n\n\tzoneID, zoneName = z.FindZone(\"example.xn--testcass-e1ae.fr\")\n\tassert.Equal(t, \"testécassé.fr\", zoneName)\n\tassert.Equal(t, \"234567\", zoneID)\n\n\thook := logtest.LogsUnderTestWithLogLevel(log.WarnLevel, t)\n\t_, _ = z.FindZone(\"xn--not-a-valid-punycode\")\n\n\tlogtest.TestHelperLogContains(\"Failed to convert label \\\"xn--not-a-valid-punycode\\\" of hostname \\\"xn--not-a-valid-punycode\\\" to its Unicode form: idna: invalid label\", hook, t)\n}\n"
  },
  {
    "path": "registry/OWNERS",
    "content": "# See the OWNERS docs at https://go.k8s.io/owners\n\nlabels:\n- registry\n"
  },
  {
    "path": "registry/awssd/OWNERS",
    "content": "# See the OWNERS docs at https://go.k8s.io/owners\n\nlabels:\n- awssd\n"
  },
  {
    "path": "registry/awssd/registry.go",
    "content": "/*\nCopyright 2018 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage awssd\n\nimport (\n\t\"context\"\n\t\"errors\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n\t\"sigs.k8s.io/external-dns/registry\"\n)\n\n// AWSSDRegistry implements registry interface with ownership information associated via the Description field of SD Service\ntype AWSSDRegistry struct {\n\tprovider provider.Provider\n\townerID  string\n}\n\n// New creates an AWSSDRegistry from the given configuration.\nfunc New(cfg *externaldns.Config, p provider.Provider) (registry.Registry, error) {\n\treturn newRegistry(p, cfg.TXTOwnerID)\n}\n\n// newRegistry returns implementation of registry for AWS SD\nfunc newRegistry(provider provider.Provider, ownerID string) (*AWSSDRegistry, error) {\n\tif ownerID == \"\" {\n\t\treturn nil, errors.New(\"owner id cannot be empty\")\n\t}\n\treturn &AWSSDRegistry{\n\t\tprovider: provider,\n\t\townerID:  ownerID,\n\t}, nil\n}\n\nfunc (sdr *AWSSDRegistry) GetDomainFilter() endpoint.DomainFilterInterface {\n\treturn sdr.provider.GetDomainFilter()\n}\n\nfunc (sdr *AWSSDRegistry) OwnerID() string {\n\treturn sdr.ownerID\n}\n\n// Records calls AWS SD API and expects AWS SD provider to provider Owner/Resource information as a serialized\n// value in the AWSSDDescriptionLabel value in the Labels map\nfunc (sdr *AWSSDRegistry) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {\n\trecords, err := sdr.provider.Records(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor _, record := range records {\n\t\tlabels, err := endpoint.NewLabelsFromStringPlain(record.Labels[endpoint.AWSSDDescriptionLabel])\n\t\tif err != nil {\n\t\t\t// if we fail to parse the output then simply assume the endpoint is not managed by any instance of External DNS\n\t\t\trecord.Labels = endpoint.NewLabels()\n\t\t\tcontinue\n\t\t}\n\t\trecord.Labels = labels\n\t}\n\n\treturn records, nil\n}\n\n// ApplyChanges filters out records not owned the External-DNS, additionally it adds the required label\n// inserted in the AWS SD instance as a CreateID field\nfunc (sdr *AWSSDRegistry) ApplyChanges(ctx context.Context, changes *plan.Changes) error {\n\tfilteredChanges := &plan.Changes{\n\t\tCreate:    changes.Create,\n\t\tUpdateNew: endpoint.FilterEndpointsByOwnerID(sdr.ownerID, changes.UpdateNew),\n\t\tUpdateOld: endpoint.FilterEndpointsByOwnerID(sdr.ownerID, changes.UpdateOld),\n\t\tDelete:    endpoint.FilterEndpointsByOwnerID(sdr.ownerID, changes.Delete),\n\t}\n\n\tsdr.updateLabels(filteredChanges.Create)\n\tsdr.updateLabels(filteredChanges.UpdateNew)\n\tsdr.updateLabels(filteredChanges.UpdateOld)\n\tsdr.updateLabels(filteredChanges.Delete)\n\n\treturn sdr.provider.ApplyChanges(ctx, filteredChanges)\n}\n\nfunc (sdr *AWSSDRegistry) updateLabels(endpoints []*endpoint.Endpoint) {\n\tfor _, ep := range endpoints {\n\t\tif ep.Labels == nil {\n\t\t\tep.Labels = make(map[string]string)\n\t\t}\n\t\tep.Labels[endpoint.OwnerLabelKey] = sdr.ownerID\n\t\tep.Labels[endpoint.AWSSDDescriptionLabel] = ep.Labels.SerializePlain(false)\n\t}\n}\n\n// AdjustEndpoints modifies the endpoints as needed by the specific provider\nfunc (sdr *AWSSDRegistry) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) {\n\treturn sdr.provider.AdjustEndpoints(endpoints)\n}\n"
  },
  {
    "path": "registry/awssd/registry_test.go",
    "content": "/*\nCopyright 2018 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage awssd\n\nimport (\n\t\"context\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/internal/testutils\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\ntype inMemoryProvider struct {\n\tprovider.BaseProvider\n\tendpoints      []*endpoint.Endpoint\n\tonApplyChanges func(changes *plan.Changes)\n}\n\nfunc (p *inMemoryProvider) Records(_ context.Context) ([]*endpoint.Endpoint, error) {\n\treturn p.endpoints, nil\n}\n\nfunc (p *inMemoryProvider) ApplyChanges(_ context.Context, changes *plan.Changes) error {\n\tp.onApplyChanges(changes)\n\treturn nil\n}\n\nfunc newInMemoryProvider(endpoints []*endpoint.Endpoint, onApplyChanges func(changes *plan.Changes)) *inMemoryProvider {\n\treturn &inMemoryProvider{\n\t\tendpoints:      endpoints,\n\t\tonApplyChanges: onApplyChanges,\n\t}\n}\n\nfunc TestAWSSDRegistry_newRegistry(t *testing.T) {\n\tp := newInMemoryProvider(nil, nil)\n\t_, err := newRegistry(p, \"\")\n\trequire.Error(t, err)\n\n\t_, err = newRegistry(p, \"owner\")\n\trequire.NoError(t, err)\n}\n\nfunc TestAWSSDRegistryTest_Records(t *testing.T) {\n\tp := newInMemoryProvider([]*endpoint.Endpoint{\n\t\tnewEndpointWithOwnerAndDescription(\"foo1.test-zone.example.org\", \"1.2.3.4\", endpoint.RecordTypeA, \"\", \"\"),\n\t\tnewEndpointWithOwnerAndDescription(\"foo2.test-zone.example.org\", \"1.2.3.4\", endpoint.RecordTypeA, \"owner\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\"),\n\t\tnewEndpointWithOwnerAndDescription(\"foo3.test-zone.example.org\", \"my-domain.com\", endpoint.RecordTypeCNAME, \"\", \"\"),\n\t\tnewEndpointWithOwnerAndDescription(\"foo4.test-zone.example.org\", \"my-domain.com\", endpoint.RecordTypeCNAME, \"owner\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\"),\n\t}, nil)\n\texpectedRecords := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"foo1.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"foo2.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"owner\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"foo3.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"my-domain.com\"},\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"foo4.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"my-domain.com\"},\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"owner\",\n\t\t\t},\n\t\t},\n\t}\n\n\tr, _ := newRegistry(p, \"records-owner\")\n\trecords, _ := r.Records(t.Context())\n\n\tassert.True(t, testutils.SameEndpoints(records, expectedRecords))\n}\n\nfunc TestAWSSDRegistry_Records_ApplyChanges(t *testing.T) {\n\tchanges := &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\tendpoint.NewEndpoint(\"new-record-1.test-zone.example.org\", endpoint.RecordTypeCNAME, \"new-loadbalancer-1.lb.com\"),\n\t\t},\n\t\tDelete: []*endpoint.Endpoint{\n\t\t\tendpoint.NewEndpoint(\"foobar.test-zone.example.org\", endpoint.RecordTypeA, \"1.2.3.4\").\n\t\t\t\tWithLabel(endpoint.OwnerLabelKey, \"owner\"),\n\t\t},\n\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\tendpoint.NewEndpoint(\"tar.test-zone.example.org\", endpoint.RecordTypeCNAME, \"new-tar.loadbalancer.com\").\n\t\t\t\tWithLabel(endpoint.OwnerLabelKey, \"owner\"),\n\t\t},\n\t\tUpdateOld: []*endpoint.Endpoint{\n\t\t\tendpoint.NewEndpoint(\"tar.test-zone.example.org\", endpoint.RecordTypeCNAME, \"tar.loadbalancer.com\").\n\t\t\t\tWithLabel(endpoint.OwnerLabelKey, \"owner\"),\n\t\t},\n\t}\n\texpected := &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwnerAndDescription(\"new-record-1.test-zone.example.org\", \"new-loadbalancer-1.lb.com\", endpoint.RecordTypeCNAME, \"owner\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\"),\n\t\t},\n\t\tDelete: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwnerAndDescription(\"foobar.test-zone.example.org\", \"1.2.3.4\", endpoint.RecordTypeA, \"owner\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\"),\n\t\t},\n\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwnerAndDescription(\"tar.test-zone.example.org\", \"new-tar.loadbalancer.com\", endpoint.RecordTypeCNAME, \"owner\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\"),\n\t\t},\n\t\tUpdateOld: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwnerAndDescription(\"tar.test-zone.example.org\", \"tar.loadbalancer.com\", endpoint.RecordTypeCNAME, \"owner\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\"),\n\t\t},\n\t}\n\tp := newInMemoryProvider(nil, func(got *plan.Changes) {\n\t\tmExpected := map[string][]*endpoint.Endpoint{\n\t\t\t\"Create\":    expected.Create,\n\t\t\t\"UpdateNew\": expected.UpdateNew,\n\t\t\t\"UpdateOld\": expected.UpdateOld,\n\t\t\t\"Delete\":    expected.Delete,\n\t\t}\n\t\tmGot := map[string][]*endpoint.Endpoint{\n\t\t\t\"Create\":    got.Create,\n\t\t\t\"UpdateNew\": got.UpdateNew,\n\t\t\t\"UpdateOld\": got.UpdateOld,\n\t\t\t\"Delete\":    got.Delete,\n\t\t}\n\t\tassert.True(t, testutils.SamePlanChanges(mGot, mExpected))\n\t})\n\tr, err := newRegistry(p, \"owner\")\n\trequire.NoError(t, err)\n\n\terr = r.ApplyChanges(t.Context(), changes)\n\trequire.NoError(t, err)\n}\n\nfunc newEndpointWithOwnerAndDescription(dnsName, target, recordType, ownerID string, description string) *endpoint.Endpoint {\n\te := endpoint.NewEndpoint(dnsName, recordType, target)\n\te.Labels[endpoint.OwnerLabelKey] = ownerID\n\te.Labels[endpoint.AWSSDDescriptionLabel] = description\n\treturn e\n}\n"
  },
  {
    "path": "registry/dynamodb/OWNERS",
    "content": "# See the OWNERS docs at https://go.k8s.io/owners\n\nlabels:\n- dynamodb\n"
  },
  {
    "path": "registry/dynamodb/registry.go",
    "content": "/*\nCopyright 2023 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage dynamodb\n\nimport (\n\t\"context\"\n\tb64 \"encoding/base64\"\n\t\"errors\"\n\t\"fmt\"\n\t\"maps\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/aws/aws-sdk-go-v2/aws\"\n\t\"github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue\"\n\tawsdynamodb \"github.com/aws/aws-sdk-go-v2/service/dynamodb\"\n\tdynamodbtypes \"github.com/aws/aws-sdk-go-v2/service/dynamodb/types\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"k8s.io/apimachinery/pkg/util/sets\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n\tprovideraws \"sigs.k8s.io/external-dns/provider/aws\"\n\t\"sigs.k8s.io/external-dns/registry\"\n\t\"sigs.k8s.io/external-dns/registry/mapper\"\n)\n\n// DynamoDBAPI is the subset of the AWS DynamoDB API that we actually use.  Add methods as required. Signatures must match exactly.\ntype DynamoDBAPI interface {\n\tDescribeTable(context.Context, *awsdynamodb.DescribeTableInput, ...func(*awsdynamodb.Options)) (*awsdynamodb.DescribeTableOutput, error)\n\tScan(context.Context, *awsdynamodb.ScanInput, ...func(*awsdynamodb.Options)) (*awsdynamodb.ScanOutput, error)\n\tBatchExecuteStatement(context.Context, *awsdynamodb.BatchExecuteStatementInput, ...func(*awsdynamodb.Options)) (*awsdynamodb.BatchExecuteStatementOutput, error)\n}\n\n// DynamoDBRegistry implements registry interface with ownership implemented via an AWS DynamoDB table.\ntype DynamoDBRegistry struct {\n\tprovider provider.Provider\n\townerID  string // refers to the owner id of the current instance\n\n\tdynamodbAPI DynamoDBAPI\n\ttable       string\n\n\t// For migration from TXT registry\n\tmapper              mapper.NameMapper\n\twildcardReplacement string\n\tmanagedRecordTypes  []string\n\texcludeRecordTypes  []string\n\ttxtEncryptAESKey    []byte\n\n\t// cache the dynamodb records owned by us.\n\tlabels         map[endpoint.EndpointKey]endpoint.Labels\n\torphanedLabels sets.Set[endpoint.EndpointKey]\n\n\t// cache the records in memory and update on an interval instead.\n\trecordsCache            []*endpoint.Endpoint\n\trecordsCacheRefreshTime time.Time\n\tcacheInterval           time.Duration\n}\n\nconst dynamodbAttributeMigrate = \"dynamodb/needs-migration\"\n\n// DynamoDB allows a maximum batch size of 25 items.\nvar dynamodbMaxBatchSize uint8 = 25\n\n// New creates a DynamoDBRegistry from the given configuration.\nfunc New(cfg *externaldns.Config, p provider.Provider) (registry.Registry, error) {\n\tclient := awsdynamodb.NewFromConfig(provideraws.CreateDefaultV2Config(cfg), WithRegion(cfg.AWSDynamoDBRegion))\n\treturn newRegistry(p, cfg.TXTOwnerID, client,\n\t\tcfg.AWSDynamoDBTable, cfg.TXTPrefix, cfg.TXTSuffix, cfg.TXTWildcardReplacement,\n\t\tcfg.ManagedDNSRecordTypes, cfg.ExcludeDNSRecordTypes, []byte(cfg.TXTEncryptAESKey), cfg.TXTCacheInterval)\n}\n\n// newRegistry returns a new DynamoDBRegistry object.\nfunc newRegistry(\n\tprovider provider.Provider,\n\townerID string, dynamodbAPI DynamoDBAPI,\n\ttable, txtPrefix, txtSuffix, txtWildcardReplacement string,\n\tmanagedRecordTypes, excludeRecordTypes []string, txtEncryptAESKey []byte,\n\tcacheInterval time.Duration) (*DynamoDBRegistry, error) {\n\tif ownerID == \"\" {\n\t\treturn nil, errors.New(\"owner id cannot be empty\")\n\t}\n\tif table == \"\" {\n\t\treturn nil, errors.New(\"table cannot be empty\")\n\t}\n\n\t// TODO: encryption logic duplicated in TXT registry; refactor into common utility function.\n\tif len(txtEncryptAESKey) == 0 {\n\t\ttxtEncryptAESKey = nil\n\t} else if len(txtEncryptAESKey) != 32 {\n\t\tvar err error\n\t\tif txtEncryptAESKey, err = b64.StdEncoding.DecodeString(string(txtEncryptAESKey)); err != nil || len(txtEncryptAESKey) != 32 {\n\t\t\treturn nil, errors.New(\"the AES Encryption key must be 32 bytes long, in either plain text or base64-encoded format\")\n\t\t}\n\t}\n\tif len(txtPrefix) > 0 && len(txtSuffix) > 0 {\n\t\treturn nil, errors.New(\"txt-prefix and txt-suffix are mutually exclusive\")\n\t}\n\n\treturn &DynamoDBRegistry{\n\t\tprovider:            provider,\n\t\townerID:             ownerID,\n\t\tdynamodbAPI:         dynamodbAPI,\n\t\ttable:               table,\n\t\tmapper:              mapper.NewAffixNameMapper(txtPrefix, txtSuffix, txtWildcardReplacement),\n\t\twildcardReplacement: txtWildcardReplacement,\n\t\tmanagedRecordTypes:  managedRecordTypes,\n\t\texcludeRecordTypes:  excludeRecordTypes,\n\t\ttxtEncryptAESKey:    txtEncryptAESKey,\n\t\tcacheInterval:       cacheInterval,\n\t}, nil\n}\n\nfunc (im *DynamoDBRegistry) GetDomainFilter() endpoint.DomainFilterInterface {\n\treturn im.provider.GetDomainFilter()\n}\n\nfunc (im *DynamoDBRegistry) OwnerID() string {\n\treturn im.ownerID\n}\n\n// Records returns the current records from the registry.\nfunc (im *DynamoDBRegistry) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {\n\t// If we have the zones cached AND we have refreshed the cache since the\n\t// last given interval, then just use the cached results.\n\tif im.recordsCache != nil && time.Since(im.recordsCacheRefreshTime) < im.cacheInterval {\n\t\tlog.Debug(\"Using cached records.\")\n\t\treturn im.recordsCache, nil\n\t}\n\n\tif im.labels == nil {\n\t\tif err := im.readLabels(ctx); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\trecords, err := im.provider.Records(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\torphanedLabels := sets.KeySet(im.labels)\n\tendpoints := make([]*endpoint.Endpoint, 0, len(records))\n\tlabelMap := map[endpoint.EndpointKey]endpoint.Labels{}\n\ttxtRecordsMap := map[endpoint.EndpointKey]*endpoint.Endpoint{}\n\tfor _, record := range records {\n\t\tkey := record.Key()\n\t\tif labels := im.labels[key]; labels != nil {\n\t\t\trecord.Labels = labels\n\t\t\torphanedLabels.Delete(key)\n\t\t} else {\n\t\t\trecord.Labels = endpoint.NewLabels()\n\n\t\t\tif record.RecordType == endpoint.RecordTypeTXT {\n\t\t\t\t// We simply assume that TXT records for the TXT registry will always have only one target.\n\t\t\t\tif labels, err := endpoint.NewLabelsFromString(record.Targets[0], im.txtEncryptAESKey); err == nil {\n\t\t\t\t\tendpointName, recordType := im.mapper.ToEndpointName(record.DNSName)\n\t\t\t\t\tkey := endpoint.EndpointKey{\n\t\t\t\t\t\tDNSName:       endpointName,\n\t\t\t\t\t\tSetIdentifier: record.SetIdentifier,\n\t\t\t\t\t}\n\t\t\t\t\tif recordType == endpoint.RecordTypeAAAA {\n\t\t\t\t\t\tkey.RecordType = recordType\n\t\t\t\t\t}\n\t\t\t\t\tlabelMap[key] = labels\n\t\t\t\t\ttxtRecordsMap[key] = record\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tendpoints = append(endpoints, record)\n\t}\n\n\tim.orphanedLabels = orphanedLabels\n\n\t// Migrate label data from TXT registry.\n\tif len(labelMap) > 0 {\n\t\tfor _, ep := range endpoints {\n\t\t\tif _, ok := im.labels[ep.Key()]; ok {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tdnsNameSplit := strings.Split(ep.DNSName, \".\")\n\t\t\t// If specified, replace a leading asterisk in the generated txt record name with some other string\n\t\t\tif im.wildcardReplacement != \"\" && dnsNameSplit[0] == \"*\" {\n\t\t\t\tdnsNameSplit[0] = im.wildcardReplacement\n\t\t\t}\n\t\t\tdnsName := strings.Join(dnsNameSplit, \".\")\n\t\t\tkey := endpoint.EndpointKey{\n\t\t\t\tDNSName:       dnsName,\n\t\t\t\tSetIdentifier: ep.SetIdentifier,\n\t\t\t}\n\t\t\tif ep.RecordType == endpoint.RecordTypeAAAA {\n\t\t\t\tkey.RecordType = ep.RecordType\n\t\t\t}\n\t\t\tif labels, ok := labelMap[key]; ok {\n\t\t\t\tmaps.Copy(ep.Labels, labels)\n\t\t\t\tep.SetProviderSpecificProperty(dynamodbAttributeMigrate, \"true\")\n\t\t\t\tdelete(txtRecordsMap, key)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Remove any unused TXT ownership records owned by us\n\tif len(txtRecordsMap) > 0 && !plan.IsManagedRecord(endpoint.RecordTypeTXT, im.managedRecordTypes, im.excludeRecordTypes) {\n\t\tlog.Infof(\"Old TXT ownership records will not be deleted because \\\"TXT\\\" is not in the set of managed record types.\")\n\t}\n\tfor _, record := range txtRecordsMap {\n\t\trecord.Labels[endpoint.OwnerLabelKey] = im.ownerID\n\t\tendpoints = append(endpoints, record)\n\t}\n\n\t// Update the cache.\n\tif im.cacheInterval > 0 {\n\t\tim.recordsCache = endpoints\n\t\tim.recordsCacheRefreshTime = time.Now()\n\t}\n\n\treturn endpoints, nil\n}\n\n// ApplyChanges updates the DNS provider and DynamoDB table with the changes.\nfunc (im *DynamoDBRegistry) ApplyChanges(ctx context.Context, changes *plan.Changes) error {\n\tfilteredChanges := &plan.Changes{\n\t\tCreate:    changes.Create,\n\t\tUpdateNew: endpoint.FilterEndpointsByOwnerID(im.ownerID, changes.UpdateNew),\n\t\tUpdateOld: endpoint.FilterEndpointsByOwnerID(im.ownerID, changes.UpdateOld),\n\t\tDelete:    endpoint.FilterEndpointsByOwnerID(im.ownerID, changes.Delete),\n\t}\n\n\tstatements := make([]dynamodbtypes.BatchStatementRequest, 0, len(filteredChanges.Create)+len(filteredChanges.UpdateNew))\n\tfor _, r := range filteredChanges.Create {\n\t\tif r.Labels == nil {\n\t\t\tr.Labels = make(map[string]string)\n\t\t}\n\t\tr.Labels[endpoint.OwnerLabelKey] = im.ownerID\n\n\t\tkey := r.Key()\n\t\toldLabels := im.labels[key]\n\t\tif oldLabels == nil {\n\t\t\tstatements = im.appendInsert(statements, key, r.Labels)\n\t\t} else {\n\t\t\tim.orphanedLabels.Delete(key)\n\t\t\tstatements = im.appendUpdate(statements, key, oldLabels, r.Labels)\n\t\t}\n\n\t\tim.labels[key] = r.Labels\n\t\tif im.cacheInterval > 0 {\n\t\t\tim.addToCache(r)\n\t\t}\n\t}\n\n\tfor _, r := range filteredChanges.Delete {\n\t\tdelete(im.labels, r.Key())\n\t\tif im.cacheInterval > 0 {\n\t\t\tim.removeFromCache(r)\n\t\t}\n\t}\n\n\toldLabels := make(map[endpoint.EndpointKey]endpoint.Labels, len(filteredChanges.UpdateOld))\n\tneedMigration := map[endpoint.EndpointKey]bool{}\n\tfor _, r := range filteredChanges.UpdateOld {\n\t\toldLabels[r.Key()] = r.Labels\n\n\t\tif _, ok := r.GetProviderSpecificProperty(dynamodbAttributeMigrate); ok {\n\t\t\tneedMigration[r.Key()] = true\n\t\t}\n\n\t\t// remove old version of record from cache\n\t\tif im.cacheInterval > 0 {\n\t\t\tim.removeFromCache(r)\n\t\t}\n\t}\n\n\tfor _, r := range filteredChanges.UpdateNew {\n\t\tkey := r.Key()\n\t\tif needMigration[key] {\n\t\t\tstatements = im.appendInsert(statements, key, r.Labels)\n\t\t\t// Invalidate the records cache so the next sync deletes the TXT ownership record\n\t\t\tim.recordsCache = nil\n\t\t} else {\n\t\t\tstatements = im.appendUpdate(statements, key, oldLabels[key], r.Labels)\n\t\t}\n\n\t\t// add new version of record to caches\n\t\tim.labels[key] = r.Labels\n\t\tif im.cacheInterval > 0 {\n\t\t\tim.addToCache(r)\n\t\t}\n\t}\n\n\terr := im.executeStatements(ctx, statements, func(request dynamodbtypes.BatchStatementRequest, response dynamodbtypes.BatchStatementResponse) error {\n\t\tvar context string\n\t\tif strings.HasPrefix(*request.Statement, \"INSERT\") {\n\t\t\tif response.Error.Code == dynamodbtypes.BatchStatementErrorCodeEnumDuplicateItem {\n\t\t\t\t// We lost a race with a different owner or another owner has an orphaned ownership record.\n\t\t\t\tkey, err := fromDynamoKey(request.Parameters[0])\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t\tfor i, ep := range filteredChanges.Create {\n\t\t\t\t\tif ep.Key() == key {\n\t\t\t\t\t\tlog.Infof(\"Skipping endpoint %v because owner does not match\", ep)\n\t\t\t\t\t\tfilteredChanges.Create = append(filteredChanges.Create[:i], filteredChanges.Create[i+1:]...)\n\t\t\t\t\t\t// The dynamodb insertion failed; remove from our cache.\n\t\t\t\t\t\tim.removeFromCache(ep)\n\t\t\t\t\t\tdelete(im.labels, key)\n\t\t\t\t\t\treturn nil\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tvar record string\n\t\t\tif err := attributevalue.Unmarshal(request.Parameters[0], &record); err != nil {\n\t\t\t\treturn fmt.Errorf(\"inserting dynamodb record: %w\", err)\n\t\t\t}\n\t\t\tcontext = fmt.Sprintf(\"inserting dynamodb record %q\", record)\n\t\t} else {\n\t\t\tvar record string\n\t\t\tif err := attributevalue.Unmarshal(request.Parameters[1], &record); err != nil {\n\t\t\t\treturn fmt.Errorf(\"inserting dynamodb record: %w\", err)\n\t\t\t}\n\t\t\tcontext = fmt.Sprintf(\"updating dynamodb record %q\", record)\n\t\t}\n\t\treturn fmt.Errorf(\"%s: %s: %s\", context, response.Error.Code, *response.Error.Message)\n\t})\n\tif err != nil {\n\t\tim.recordsCache = nil\n\t\tim.labels = nil\n\t\treturn err\n\t}\n\n\t// When caching is enabled, disable the provider from using the cache.\n\tif im.cacheInterval > 0 {\n\t\tctx = context.WithValue(ctx, provider.RecordsContextKey, nil)\n\t}\n\terr = im.provider.ApplyChanges(ctx, filteredChanges)\n\tif err != nil {\n\t\tim.recordsCache = nil\n\t\tim.labels = nil\n\t\treturn err\n\t}\n\n\tstatements = make([]dynamodbtypes.BatchStatementRequest, 0, len(filteredChanges.Delete)+len(im.orphanedLabels))\n\tfor _, r := range filteredChanges.Delete {\n\t\tstatements = im.appendDelete(statements, r.Key())\n\t}\n\tfor r := range im.orphanedLabels {\n\t\tstatements = im.appendDelete(statements, r)\n\t\tdelete(im.labels, r)\n\t}\n\tim.orphanedLabels = nil\n\treturn im.executeStatements(ctx, statements, func(request dynamodbtypes.BatchStatementRequest, response dynamodbtypes.BatchStatementResponse) error {\n\t\tim.labels = nil\n\t\trecord, err := fromDynamoKey(request.Parameters[0])\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"deleting dynamodb record: %w\", err)\n\t\t}\n\t\treturn fmt.Errorf(\"deleting dynamodb record %q: %s: %s\", record, response.Error.Code, *response.Error.Message)\n\t})\n}\n\n// AdjustEndpoints modifies the endpoints as needed by the specific provider.\nfunc (im *DynamoDBRegistry) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) {\n\treturn im.provider.AdjustEndpoints(endpoints)\n}\n\nfunc (im *DynamoDBRegistry) readLabels(ctx context.Context) error {\n\ttable, err := im.dynamodbAPI.DescribeTable(ctx, &awsdynamodb.DescribeTableInput{\n\t\tTableName: aws.String(im.table),\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"describing table %q: %w\", im.table, err)\n\t}\n\n\tfoundKey := false\n\tfor _, def := range table.Table.AttributeDefinitions {\n\t\tif *def.AttributeName == \"k\" {\n\t\t\tif def.AttributeType != dynamodbtypes.ScalarAttributeTypeS {\n\t\t\t\treturn fmt.Errorf(\"table %q attribute \\\"k\\\" must have type \\\"S\\\"\", im.table)\n\t\t\t}\n\t\t\tfoundKey = true\n\t\t}\n\t}\n\tif !foundKey {\n\t\treturn fmt.Errorf(\"table %q must have attribute \\\"k\\\" of type \\\"S\\\"\", im.table)\n\t}\n\n\tif *table.Table.KeySchema[0].AttributeName != \"k\" {\n\t\treturn fmt.Errorf(\"table %q must have hash key \\\"k\\\"\", im.table)\n\t}\n\tif len(table.Table.KeySchema) > 1 {\n\t\treturn fmt.Errorf(\"table %q must not have a range key\", im.table)\n\t}\n\n\tlabels := map[endpoint.EndpointKey]endpoint.Labels{}\n\tscanPaginator := awsdynamodb.NewScanPaginator(im.dynamodbAPI, &awsdynamodb.ScanInput{\n\t\tTableName:        aws.String(im.table),\n\t\tFilterExpression: aws.String(\"o = :ownerval\"),\n\t\tExpressionAttributeValues: map[string]dynamodbtypes.AttributeValue{\n\t\t\t\":ownerval\": &dynamodbtypes.AttributeValueMemberS{Value: im.ownerID},\n\t\t},\n\t\tProjectionExpression: aws.String(\"k,l\"),\n\t\tConsistentRead:       aws.Bool(true),\n\t})\n\tfor scanPaginator.HasMorePages() {\n\t\toutput, err := scanPaginator.NextPage(ctx)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"scanning table %q: %w\", im.table, err)\n\t\t}\n\t\tfor _, item := range output.Items {\n\t\t\tk, err := fromDynamoKey(item[\"k\"])\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"querying dynamodb for key: %w\", err)\n\t\t\t}\n\t\t\tl, err := fromDynamoLabels(item[\"l\"], im.ownerID)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"querying dynamodb for labels: %w\", err)\n\t\t\t}\n\n\t\t\tlabels[k] = l\n\t\t}\n\t}\n\n\tim.labels = labels\n\treturn nil\n}\n\nfunc fromDynamoKey(key dynamodbtypes.AttributeValue) (endpoint.EndpointKey, error) {\n\tvar ep string\n\tif err := attributevalue.Unmarshal(key, &ep); err != nil {\n\t\treturn endpoint.EndpointKey{}, fmt.Errorf(\"unmarshalling endpoint key: %w\", err)\n\t}\n\tsplit := strings.SplitN(ep, \"#\", 3)\n\treturn endpoint.EndpointKey{\n\t\tDNSName:       split[0],\n\t\tRecordType:    split[1],\n\t\tSetIdentifier: split[2],\n\t}, nil\n}\n\nfunc toDynamoKey(key endpoint.EndpointKey) dynamodbtypes.AttributeValue {\n\treturn &dynamodbtypes.AttributeValueMemberS{\n\t\tValue: fmt.Sprintf(\"%s#%s#%s\", key.DNSName, key.RecordType, key.SetIdentifier),\n\t}\n}\n\nfunc fromDynamoLabels(label dynamodbtypes.AttributeValue, owner string) (endpoint.Labels, error) {\n\tlabels := endpoint.NewLabels()\n\tif err := attributevalue.Unmarshal(label, &labels); err != nil {\n\t\treturn endpoint.Labels{}, fmt.Errorf(\"unmarshalling labels: %w\", err)\n\t}\n\tlabels[endpoint.OwnerLabelKey] = owner\n\treturn labels, nil\n}\n\nfunc toDynamoLabels(labels endpoint.Labels) dynamodbtypes.AttributeValue {\n\tlabelMap := make(map[string]dynamodbtypes.AttributeValue, len(labels))\n\tfor k, v := range labels {\n\t\tif k == endpoint.OwnerLabelKey {\n\t\t\tcontinue\n\t\t}\n\t\tlabelMap[k] = &dynamodbtypes.AttributeValueMemberS{Value: v}\n\t}\n\treturn &dynamodbtypes.AttributeValueMemberM{Value: labelMap}\n}\n\nfunc (im *DynamoDBRegistry) appendInsert(statements []dynamodbtypes.BatchStatementRequest, key endpoint.EndpointKey, newL endpoint.Labels) []dynamodbtypes.BatchStatementRequest {\n\treturn append(statements, dynamodbtypes.BatchStatementRequest{\n\t\tStatement:      aws.String(fmt.Sprintf(\"INSERT INTO %q VALUE {'k':?, 'o':?, 'l':?}\", im.table)),\n\t\tConsistentRead: aws.Bool(true),\n\t\tParameters: []dynamodbtypes.AttributeValue{\n\t\t\ttoDynamoKey(key),\n\t\t\t&dynamodbtypes.AttributeValueMemberS{\n\t\t\t\tValue: im.ownerID,\n\t\t\t},\n\t\t\ttoDynamoLabels(newL),\n\t\t},\n\t})\n}\n\nfunc (im *DynamoDBRegistry) appendUpdate(statements []dynamodbtypes.BatchStatementRequest, key endpoint.EndpointKey, old endpoint.Labels, newE endpoint.Labels) []dynamodbtypes.BatchStatementRequest {\n\tif len(old) == len(newE) {\n\t\tequal := true\n\t\tfor k, v := range old {\n\t\t\tif newV, exists := newE[k]; !exists || v != newV {\n\t\t\t\tequal = false\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif equal {\n\t\t\treturn statements\n\t\t}\n\t}\n\n\treturn append(statements, dynamodbtypes.BatchStatementRequest{\n\t\tStatement: aws.String(fmt.Sprintf(\"UPDATE %q SET \\\"l\\\"=? WHERE \\\"k\\\"=?\", im.table)),\n\t\tParameters: []dynamodbtypes.AttributeValue{\n\t\t\ttoDynamoLabels(newE),\n\t\t\ttoDynamoKey(key),\n\t\t},\n\t})\n}\n\nfunc (im *DynamoDBRegistry) appendDelete(statements []dynamodbtypes.BatchStatementRequest, key endpoint.EndpointKey) []dynamodbtypes.BatchStatementRequest {\n\treturn append(statements, dynamodbtypes.BatchStatementRequest{\n\t\tStatement: aws.String(fmt.Sprintf(\"DELETE FROM %q WHERE \\\"k\\\"=? AND \\\"o\\\"=?\", im.table)),\n\t\tParameters: []dynamodbtypes.AttributeValue{\n\t\t\ttoDynamoKey(key),\n\t\t\t&dynamodbtypes.AttributeValueMemberS{Value: im.ownerID},\n\t\t},\n\t})\n}\n\nfunc (im *DynamoDBRegistry) executeStatements(ctx context.Context, statements []dynamodbtypes.BatchStatementRequest, handleErr func(request dynamodbtypes.BatchStatementRequest, response dynamodbtypes.BatchStatementResponse) error) error {\n\tfor len(statements) > 0 {\n\t\tvar chunk []dynamodbtypes.BatchStatementRequest\n\t\tif len(statements) > int(dynamodbMaxBatchSize) {\n\t\t\tchunk = statements[:dynamodbMaxBatchSize]\n\t\t\tstatements = statements[dynamodbMaxBatchSize:]\n\t\t} else {\n\t\t\tchunk = statements\n\t\t\tstatements = nil\n\t\t}\n\n\t\toutput, err := im.dynamodbAPI.BatchExecuteStatement(ctx, &awsdynamodb.BatchExecuteStatementInput{\n\t\t\tStatements: chunk,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tfor i, response := range output.Responses {\n\t\t\trequest := chunk[i]\n\t\t\tif response.Error == nil {\n\t\t\t\top, _, _ := strings.Cut(*request.Statement, \" \")\n\t\t\t\tvar key string\n\t\t\t\tif op == \"UPDATE\" {\n\t\t\t\t\tif err := attributevalue.Unmarshal(request.Parameters[1], &key); err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tif err := attributevalue.Unmarshal(request.Parameters[0], &key); err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tlog.Infof(\"%s dynamodb record %q\", op, key)\n\t\t\t} else {\n\t\t\t\tif err := handleErr(request, response); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (im *DynamoDBRegistry) addToCache(ep *endpoint.Endpoint) {\n\tif im.recordsCache != nil {\n\t\tim.recordsCache = append(im.recordsCache, ep)\n\t}\n}\n\nfunc (im *DynamoDBRegistry) removeFromCache(ep *endpoint.Endpoint) {\n\tif im.recordsCache == nil || ep == nil {\n\t\treturn\n\t}\n\n\tfor i, e := range im.recordsCache {\n\t\tif e.DNSName == ep.DNSName && e.RecordType == ep.RecordType && e.SetIdentifier == ep.SetIdentifier && e.Targets.Same(ep.Targets) {\n\t\t\t// We found a match; delete the endpoint from the cache.\n\t\t\tim.recordsCache = append(im.recordsCache[:i], im.recordsCache[i+1:]...)\n\t\t\treturn\n\t\t}\n\t}\n}\n\nfunc WithRegion(region string) func(*awsdynamodb.Options) {\n\tif region == \"\" {\n\t\treturn nil\n\t}\n\treturn func(opts *awsdynamodb.Options) {\n\t\topts.Region = region\n\t}\n}\n"
  },
  {
    "path": "registry/dynamodb/registry_test.go",
    "content": "/*\nCopyright 2023 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage dynamodb\n\nimport (\n\t\"context\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/aws/aws-sdk-go-v2/aws\"\n\t\"github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue\"\n\t\"github.com/aws/aws-sdk-go-v2/service/dynamodb\"\n\tdynamodbtypes \"github.com/aws/aws-sdk-go-v2/service/dynamodb/types\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\t\"k8s.io/apimachinery/pkg/util/sets\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/internal/testutils\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n\t\"sigs.k8s.io/external-dns/provider/inmemory\"\n)\n\nconst (\n\ttestZone = \"test-zone.example.org\"\n)\n\nfunc TestDynamoDBRegistryNew(t *testing.T) {\n\tapi, p := newDynamoDBAPIStub(t, nil)\n\n\t_, err := newRegistry(p, \"test-owner\", api, \"test-table\", \"\", \"\", \"\", []string{}, []string{}, []byte(\"\"), time.Hour)\n\trequire.NoError(t, err)\n\n\t_, err = newRegistry(p, \"test-owner\", api, \"test-table\", \"testPrefix\", \"\", \"\", []string{}, []string{}, []byte(\"\"), time.Hour)\n\trequire.NoError(t, err)\n\n\t_, err = newRegistry(p, \"test-owner\", api, \"test-table\", \"\", \"testSuffix\", \"\", []string{}, []string{}, []byte(\"\"), time.Hour)\n\trequire.NoError(t, err)\n\n\t_, err = newRegistry(p, \"test-owner\", api, \"test-table\", \"\", \"\", \"testWildcard\", []string{}, []string{}, []byte(\"\"), time.Hour)\n\trequire.NoError(t, err)\n\n\t_, err = newRegistry(p, \"test-owner\", api, \"test-table\", \"\", \"\", \"testWildcard\", []string{}, []string{}, []byte(\";k&l)nUC/33:{?d{3)54+,AD?]SX%yh^\"), time.Hour)\n\trequire.NoError(t, err)\n\n\t_, err = newRegistry(p, \"\", api, \"test-table\", \"\", \"\", \"\", []string{}, []string{}, []byte(\"\"), time.Hour)\n\trequire.EqualError(t, err, \"owner id cannot be empty\")\n\n\t_, err = newRegistry(p, \"test-owner\", api, \"\", \"\", \"\", \"\", []string{}, []string{}, []byte(\"\"), time.Hour)\n\trequire.EqualError(t, err, \"table cannot be empty\")\n\n\t_, err = newRegistry(p, \"test-owner\", api, \"test-table\", \"\", \"\", \"\", []string{}, []string{}, []byte(\";k&l)nUC/33:{?d{3)54+,AD?]SX%yh^x\"), time.Hour)\n\trequire.EqualError(t, err, \"the AES Encryption key must be 32 bytes long, in either plain text or base64-encoded format\")\n\n\t_, err = newRegistry(p, \"test-owner\", api, \"test-table\", \"testPrefix\", \"testSuffix\", \"\", []string{}, []string{}, []byte(\"\"), time.Hour)\n\trequire.EqualError(t, err, \"txt-prefix and txt-suffix are mutually exclusive\")\n}\n\nfunc TestDynamoDBRegistryNew_EncryptionConfig(t *testing.T) {\n\tapi, p := newDynamoDBAPIStub(t, nil)\n\n\ttests := []struct {\n\t\tencEnabled      bool\n\t\taesKeyRaw       []byte\n\t\taesKeySanitized []byte\n\t\terrorExpected   bool\n\t}{\n\t\t{\n\t\t\tencEnabled:      true,\n\t\t\taesKeyRaw:       []byte(\"123456789012345678901234567890asdfasdfasdfasdfa12\"),\n\t\t\taesKeySanitized: []byte{},\n\t\t\terrorExpected:   true,\n\t\t},\n\t\t{\n\t\t\tencEnabled:      true,\n\t\t\taesKeyRaw:       []byte(\"passphrasewhichneedstobe32bytes!\"),\n\t\t\taesKeySanitized: []byte(\"passphrasewhichneedstobe32bytes!\"),\n\t\t\terrorExpected:   false,\n\t\t},\n\t\t{\n\t\t\tencEnabled:      true,\n\t\t\taesKeyRaw:       []byte(\"ZPitL0NGVQBZbTD6DwXJzD8RiStSazzYXQsdUowLURY=\"),\n\t\t\taesKeySanitized: []byte{100, 248, 173, 47, 67, 70, 85, 0, 89, 109, 48, 250, 15, 5, 201, 204, 63, 17, 137, 43, 82, 107, 60, 216, 93, 11, 29, 82, 140, 11, 81, 22},\n\t\t\terrorExpected:   false,\n\t\t},\n\t}\n\tfor _, test := range tests {\n\t\tactual, err := newRegistry(p, \"test-owner\", api, \"test-table\", \"\", \"\", \"\", []string{}, []string{}, test.aesKeyRaw, time.Hour)\n\t\tif test.errorExpected {\n\t\t\trequire.Error(t, err)\n\t\t} else {\n\t\t\trequire.NoError(t, err)\n\t\t\tassert.Equal(t, test.aesKeySanitized, actual.txtEncryptAESKey)\n\t\t}\n\t}\n}\n\nfunc TestDynamoDBRegistryRecordsBadTable(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\tname     string\n\t\tsetup    func(desc *dynamodbtypes.TableDescription)\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname: \"missing attribute k\",\n\t\t\tsetup: func(desc *dynamodbtypes.TableDescription) {\n\t\t\t\tdesc.AttributeDefinitions[0].AttributeName = aws.String(\"wrong\")\n\t\t\t},\n\t\t\texpected: \"table \\\"test-table\\\" must have attribute \\\"k\\\" of type \\\"S\\\"\",\n\t\t},\n\t\t{\n\t\t\tname: \"wrong attribute type\",\n\t\t\tsetup: func(desc *dynamodbtypes.TableDescription) {\n\t\t\t\tdesc.AttributeDefinitions[0].AttributeType = \"SS\"\n\t\t\t},\n\t\t\texpected: \"table \\\"test-table\\\" attribute \\\"k\\\" must have type \\\"S\\\"\",\n\t\t},\n\t\t{\n\t\t\tname: \"wrong key\",\n\t\t\tsetup: func(desc *dynamodbtypes.TableDescription) {\n\t\t\t\tdesc.KeySchema[0].AttributeName = aws.String(\"wrong\")\n\t\t\t},\n\t\t\texpected: \"table \\\"test-table\\\" must have hash key \\\"k\\\"\",\n\t\t},\n\t\t{\n\t\t\tname: \"has range key\",\n\t\t\tsetup: func(desc *dynamodbtypes.TableDescription) {\n\t\t\t\tdesc.AttributeDefinitions = append(desc.AttributeDefinitions, dynamodbtypes.AttributeDefinition{\n\t\t\t\t\tAttributeName: aws.String(\"o\"),\n\t\t\t\t\tAttributeType: dynamodbtypes.ScalarAttributeTypeS,\n\t\t\t\t})\n\t\t\t\tdesc.KeySchema = append(desc.KeySchema, dynamodbtypes.KeySchemaElement{\n\t\t\t\t\tAttributeName: aws.String(\"o\"),\n\t\t\t\t\tKeyType:       dynamodbtypes.KeyTypeRange,\n\t\t\t\t})\n\t\t\t},\n\t\t\texpected: \"table \\\"test-table\\\" must not have a range key\",\n\t\t},\n\t} {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tapi, p := newDynamoDBAPIStub(t, nil)\n\t\t\ttc.setup(&api.tableDescription)\n\n\t\t\tr, _ := newRegistry(p, \"test-owner\", api, \"test-table\", \"\", \"\", \"\", []string{}, []string{}, nil, time.Hour)\n\n\t\t\t_, err := r.Records(t.Context())\n\t\t\tassert.EqualError(t, err, tc.expected)\n\t\t})\n\t}\n}\n\nfunc TestDynamoDBRegistryRecords(t *testing.T) {\n\tapi, p := newDynamoDBAPIStub(t, nil)\n\n\tctx := t.Context()\n\texpectedRecords := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"foo.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"foo.loadbalancer.com\"},\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"bar.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"my-domain.com\"},\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/my-ingress\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:       \"baz.test-zone.example.org\",\n\t\t\tTargets:       endpoint.Targets{\"1.1.1.1\"},\n\t\t\tRecordType:    endpoint.RecordTypeA,\n\t\t\tSetIdentifier: \"set-1\",\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/my-ingress\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:       \"baz.test-zone.example.org\",\n\t\t\tTargets:       endpoint.Targets{\"2.2.2.2\"},\n\t\t\tRecordType:    endpoint.RecordTypeA,\n\t\t\tSetIdentifier: \"set-2\",\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/other-ingress\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:       \"migrate.test-zone.example.org\",\n\t\t\tTargets:       endpoint.Targets{\"3.3.3.3\"},\n\t\t\tRecordType:    endpoint.RecordTypeA,\n\t\t\tSetIdentifier: \"set-3\",\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/other-ingress\",\n\t\t\t},\n\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t{\n\t\t\t\t\tName:  dynamodbAttributeMigrate,\n\t\t\t\t\tValue: \"true\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:       \"txt.orphaned.test-zone.example.org\",\n\t\t\tTargets:       endpoint.Targets{\"\\\"heritage=external-dns,external-dns/owner=test-owner,external-dns/resource=ingress/default/other-ingress\\\"\"},\n\t\t\tRecordType:    endpoint.RecordTypeTXT,\n\t\t\tSetIdentifier: \"set-3\",\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"test-owner\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:       \"txt.baz.test-zone.example.org\",\n\t\t\tTargets:       endpoint.Targets{\"\\\"heritage=external-dns,external-dns/owner=test-owner,external-dns/resource=ingress/default/other-ingress\\\"\"},\n\t\t\tRecordType:    endpoint.RecordTypeTXT,\n\t\t\tSetIdentifier: \"set-2\",\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"test-owner\",\n\t\t\t},\n\t\t},\n\t}\n\n\tr, _ := newRegistry(p, \"test-owner\", api, \"test-table\", \"txt.\", \"\", \"\", []string{}, []string{}, nil, time.Hour)\n\t_ = p.(*wrappedProvider).Provider.ApplyChanges(t.Context(), &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\tendpoint.NewEndpoint(\"migrate.test-zone.example.org\", endpoint.RecordTypeA, \"3.3.3.3\").WithSetIdentifier(\"set-3\"),\n\t\t\tendpoint.NewEndpoint(\"txt.migrate.test-zone.example.org\", endpoint.RecordTypeTXT, \"\\\"heritage=external-dns,external-dns/owner=test-owner,external-dns/resource=ingress/default/other-ingress\\\"\").WithSetIdentifier(\"set-3\"),\n\t\t\tendpoint.NewEndpoint(\"txt.orphaned.test-zone.example.org\", endpoint.RecordTypeTXT, \"\\\"heritage=external-dns,external-dns/owner=test-owner,external-dns/resource=ingress/default/other-ingress\\\"\").WithSetIdentifier(\"set-3\"),\n\t\t\tendpoint.NewEndpoint(\"txt.baz.test-zone.example.org\", endpoint.RecordTypeTXT, \"\\\"heritage=external-dns,external-dns/owner=test-owner,external-dns/resource=ingress/default/other-ingress\\\"\").WithSetIdentifier(\"set-2\"),\n\t\t},\n\t})\n\n\trecords, err := r.Records(ctx)\n\trequire.NoError(t, err)\n\n\tassert.True(t, testutils.SameEndpoints(records, expectedRecords))\n}\n\nfunc TestDynamoDBRegistryApplyChanges(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\tname            string\n\t\tmaxBatchSize    uint8\n\t\tstubConfig      DynamoDBStubConfig\n\t\taddRecords      []*endpoint.Endpoint\n\t\tchanges         plan.Changes\n\t\texpectedError   string\n\t\texpectedRecords []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\tname: \"create\",\n\t\t\tchanges: plan.Changes{\n\t\t\t\tCreate: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:       \"new.test-zone.example.org\",\n\t\t\t\t\t\tTargets:       endpoint.Targets{\"new.loadbalancer.com\"},\n\t\t\t\t\t\tRecordType:    endpoint.RecordTypeCNAME,\n\t\t\t\t\t\tSetIdentifier: \"set-new\",\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/new-ingress\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tstubConfig: DynamoDBStubConfig{\n\t\t\t\tExpectInsert: map[string]map[string]string{\n\t\t\t\t\t\"new.test-zone.example.org#CNAME#set-new\": {endpoint.ResourceLabelKey: \"ingress/default/new-ingress\"},\n\t\t\t\t},\n\t\t\t\tExpectDelete: sets.New(\"quux.test-zone.example.org#A#set-2\"),\n\t\t\t},\n\t\t\texpectedRecords: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.test-zone.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"foo.loadbalancer.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey: \"\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"bar.test-zone.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"my-domain.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/my-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:       \"baz.test-zone.example.org\",\n\t\t\t\t\tTargets:       endpoint.Targets{\"1.1.1.1\"},\n\t\t\t\t\tRecordType:    endpoint.RecordTypeA,\n\t\t\t\t\tSetIdentifier: \"set-1\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/my-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:       \"baz.test-zone.example.org\",\n\t\t\t\t\tTargets:       endpoint.Targets{\"2.2.2.2\"},\n\t\t\t\t\tRecordType:    endpoint.RecordTypeA,\n\t\t\t\t\tSetIdentifier: \"set-2\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/other-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:       \"new.test-zone.example.org\",\n\t\t\t\t\tTargets:       endpoint.Targets{\"new.loadbalancer.com\"},\n\t\t\t\t\tRecordType:    endpoint.RecordTypeCNAME,\n\t\t\t\t\tSetIdentifier: \"set-new\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/new-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:         \"create more entries than DynamoDB batch size limit\",\n\t\t\tmaxBatchSize: 2,\n\t\t\tchanges: plan.Changes{\n\t\t\t\tCreate: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:       \"new1.test-zone.example.org\",\n\t\t\t\t\t\tTargets:       endpoint.Targets{\"new1.loadbalancer.com\"},\n\t\t\t\t\t\tRecordType:    endpoint.RecordTypeCNAME,\n\t\t\t\t\t\tSetIdentifier: \"set-new\",\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/new1-ingress\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:       \"new2.test-zone.example.org\",\n\t\t\t\t\t\tTargets:       endpoint.Targets{\"new2.loadbalancer.com\"},\n\t\t\t\t\t\tRecordType:    endpoint.RecordTypeCNAME,\n\t\t\t\t\t\tSetIdentifier: \"set-new\",\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/new2-ingress\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:       \"new3.test-zone.example.org\",\n\t\t\t\t\t\tTargets:       endpoint.Targets{\"new3.loadbalancer.com\"},\n\t\t\t\t\t\tRecordType:    endpoint.RecordTypeCNAME,\n\t\t\t\t\t\tSetIdentifier: \"set-new\",\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/new3-ingress\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tstubConfig: DynamoDBStubConfig{\n\t\t\t\tExpectInsert: map[string]map[string]string{\n\t\t\t\t\t\"new1.test-zone.example.org#CNAME#set-new\": {endpoint.ResourceLabelKey: \"ingress/default/new1-ingress\"},\n\t\t\t\t\t\"new2.test-zone.example.org#CNAME#set-new\": {endpoint.ResourceLabelKey: \"ingress/default/new2-ingress\"},\n\t\t\t\t\t\"new3.test-zone.example.org#CNAME#set-new\": {endpoint.ResourceLabelKey: \"ingress/default/new3-ingress\"},\n\t\t\t\t},\n\t\t\t\tExpectDelete: sets.New(\"quux.test-zone.example.org#A#set-2\"),\n\t\t\t},\n\t\t\texpectedRecords: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.test-zone.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"foo.loadbalancer.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey: \"\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"bar.test-zone.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"my-domain.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/my-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:       \"baz.test-zone.example.org\",\n\t\t\t\t\tTargets:       endpoint.Targets{\"1.1.1.1\"},\n\t\t\t\t\tRecordType:    endpoint.RecordTypeA,\n\t\t\t\t\tSetIdentifier: \"set-1\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/my-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:       \"baz.test-zone.example.org\",\n\t\t\t\t\tTargets:       endpoint.Targets{\"2.2.2.2\"},\n\t\t\t\t\tRecordType:    endpoint.RecordTypeA,\n\t\t\t\t\tSetIdentifier: \"set-2\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/other-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:       \"new1.test-zone.example.org\",\n\t\t\t\t\tTargets:       endpoint.Targets{\"new1.loadbalancer.com\"},\n\t\t\t\t\tRecordType:    endpoint.RecordTypeCNAME,\n\t\t\t\t\tSetIdentifier: \"set-new\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/new1-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:       \"new2.test-zone.example.org\",\n\t\t\t\t\tTargets:       endpoint.Targets{\"new2.loadbalancer.com\"},\n\t\t\t\t\tRecordType:    endpoint.RecordTypeCNAME,\n\t\t\t\t\tSetIdentifier: \"set-new\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/new2-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:       \"new3.test-zone.example.org\",\n\t\t\t\t\tTargets:       endpoint.Targets{\"new3.loadbalancer.com\"},\n\t\t\t\t\tRecordType:    endpoint.RecordTypeCNAME,\n\t\t\t\t\tSetIdentifier: \"set-new\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/new3-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"create orphaned\",\n\t\t\tchanges: plan.Changes{\n\t\t\t\tCreate: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:       \"quux.test-zone.example.org\",\n\t\t\t\t\t\tTargets:       endpoint.Targets{\"5.5.5.5\"},\n\t\t\t\t\t\tRecordType:    endpoint.RecordTypeA,\n\t\t\t\t\t\tSetIdentifier: \"set-2\",\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/quux-ingress\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tstubConfig: DynamoDBStubConfig{},\n\t\t\texpectedRecords: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.test-zone.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"foo.loadbalancer.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey: \"\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"bar.test-zone.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"my-domain.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/my-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:       \"baz.test-zone.example.org\",\n\t\t\t\t\tTargets:       endpoint.Targets{\"1.1.1.1\"},\n\t\t\t\t\tRecordType:    endpoint.RecordTypeA,\n\t\t\t\t\tSetIdentifier: \"set-1\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/my-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:       \"baz.test-zone.example.org\",\n\t\t\t\t\tTargets:       endpoint.Targets{\"2.2.2.2\"},\n\t\t\t\t\tRecordType:    endpoint.RecordTypeA,\n\t\t\t\t\tSetIdentifier: \"set-2\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/other-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:       \"quux.test-zone.example.org\",\n\t\t\t\t\tTargets:       endpoint.Targets{\"5.5.5.5\"},\n\t\t\t\t\tRecordType:    endpoint.RecordTypeA,\n\t\t\t\t\tSetIdentifier: \"set-2\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/quux-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"create orphaned change\",\n\t\t\tchanges: plan.Changes{\n\t\t\t\tCreate: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:       \"quux.test-zone.example.org\",\n\t\t\t\t\t\tTargets:       endpoint.Targets{\"5.5.5.5\"},\n\t\t\t\t\t\tRecordType:    endpoint.RecordTypeA,\n\t\t\t\t\t\tSetIdentifier: \"set-2\",\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/new-ingress\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tstubConfig: DynamoDBStubConfig{\n\t\t\t\tExpectUpdate: map[string]map[string]string{\n\t\t\t\t\t\"quux.test-zone.example.org#A#set-2\": {endpoint.ResourceLabelKey: \"ingress/default/new-ingress\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedRecords: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.test-zone.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"foo.loadbalancer.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey: \"\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"bar.test-zone.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"my-domain.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/my-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:       \"baz.test-zone.example.org\",\n\t\t\t\t\tTargets:       endpoint.Targets{\"1.1.1.1\"},\n\t\t\t\t\tRecordType:    endpoint.RecordTypeA,\n\t\t\t\t\tSetIdentifier: \"set-1\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/my-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:       \"baz.test-zone.example.org\",\n\t\t\t\t\tTargets:       endpoint.Targets{\"2.2.2.2\"},\n\t\t\t\t\tRecordType:    endpoint.RecordTypeA,\n\t\t\t\t\tSetIdentifier: \"set-2\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/other-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:       \"quux.test-zone.example.org\",\n\t\t\t\t\tTargets:       endpoint.Targets{\"5.5.5.5\"},\n\t\t\t\t\tRecordType:    endpoint.RecordTypeA,\n\t\t\t\t\tSetIdentifier: \"set-2\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/new-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"create duplicate\",\n\t\t\tchanges: plan.Changes{\n\t\t\t\tCreate: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:       \"new.test-zone.example.org\",\n\t\t\t\t\t\tTargets:       endpoint.Targets{\"new.loadbalancer.com\"},\n\t\t\t\t\t\tRecordType:    endpoint.RecordTypeCNAME,\n\t\t\t\t\t\tSetIdentifier: \"set-new\",\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/new-ingress\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tstubConfig: DynamoDBStubConfig{\n\t\t\t\tExpectInsertError: map[string]dynamodbtypes.BatchStatementErrorCodeEnum{\n\t\t\t\t\t\"new.test-zone.example.org#CNAME#set-new\": dynamodbtypes.BatchStatementErrorCodeEnumDuplicateItem,\n\t\t\t\t},\n\t\t\t\tExpectDelete: sets.New(\"quux.test-zone.example.org#A#set-2\"),\n\t\t\t},\n\t\t\texpectedRecords: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.test-zone.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"foo.loadbalancer.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey: \"\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"bar.test-zone.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"my-domain.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/my-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:       \"baz.test-zone.example.org\",\n\t\t\t\t\tTargets:       endpoint.Targets{\"1.1.1.1\"},\n\t\t\t\t\tRecordType:    endpoint.RecordTypeA,\n\t\t\t\t\tSetIdentifier: \"set-1\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/my-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:       \"baz.test-zone.example.org\",\n\t\t\t\t\tTargets:       endpoint.Targets{\"2.2.2.2\"},\n\t\t\t\t\tRecordType:    endpoint.RecordTypeA,\n\t\t\t\t\tSetIdentifier: \"set-2\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/other-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"create error\",\n\t\t\tchanges: plan.Changes{\n\t\t\t\tCreate: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:       \"new.test-zone.example.org\",\n\t\t\t\t\t\tTargets:       endpoint.Targets{\"new.loadbalancer.com\"},\n\t\t\t\t\t\tRecordType:    endpoint.RecordTypeCNAME,\n\t\t\t\t\t\tSetIdentifier: \"set-new\",\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/new-ingress\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tstubConfig: DynamoDBStubConfig{\n\t\t\t\tExpectInsertError: map[string]dynamodbtypes.BatchStatementErrorCodeEnum{\n\t\t\t\t\t\"new.test-zone.example.org#CNAME#set-new\": \"TestingError\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedError: \"inserting dynamodb record \\\"new.test-zone.example.org#CNAME#set-new\\\": TestingError: testing error\",\n\t\t\texpectedRecords: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.test-zone.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"foo.loadbalancer.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey: \"\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"bar.test-zone.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"my-domain.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/my-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:       \"baz.test-zone.example.org\",\n\t\t\t\t\tTargets:       endpoint.Targets{\"1.1.1.1\"},\n\t\t\t\t\tRecordType:    endpoint.RecordTypeA,\n\t\t\t\t\tSetIdentifier: \"set-1\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/my-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:       \"baz.test-zone.example.org\",\n\t\t\t\t\tTargets:       endpoint.Targets{\"2.2.2.2\"},\n\t\t\t\t\tRecordType:    endpoint.RecordTypeA,\n\t\t\t\t\tSetIdentifier: \"set-2\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/other-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"update\",\n\t\t\tchanges: plan.Changes{\n\t\t\t\tUpdateOld: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:    \"bar.test-zone.example.org\",\n\t\t\t\t\t\tTargets:    endpoint.Targets{\"my-domain.com\"},\n\t\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/my-ingress\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:    \"bar.test-zone.example.org\",\n\t\t\t\t\t\tTargets:    endpoint.Targets{\"new-domain.com\"},\n\t\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/my-ingress\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tstubConfig: DynamoDBStubConfig{\n\t\t\t\tExpectDelete: sets.New(\"quux.test-zone.example.org#A#set-2\"),\n\t\t\t},\n\t\t\texpectedRecords: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.test-zone.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"foo.loadbalancer.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey: \"\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"bar.test-zone.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"new-domain.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/my-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:       \"baz.test-zone.example.org\",\n\t\t\t\t\tTargets:       endpoint.Targets{\"1.1.1.1\"},\n\t\t\t\t\tRecordType:    endpoint.RecordTypeA,\n\t\t\t\t\tSetIdentifier: \"set-1\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/my-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:       \"baz.test-zone.example.org\",\n\t\t\t\t\tTargets:       endpoint.Targets{\"2.2.2.2\"},\n\t\t\t\t\tRecordType:    endpoint.RecordTypeA,\n\t\t\t\t\tSetIdentifier: \"set-2\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/other-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"update change\",\n\t\t\tchanges: plan.Changes{\n\t\t\t\tUpdateOld: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:    \"bar.test-zone.example.org\",\n\t\t\t\t\t\tTargets:    endpoint.Targets{\"my-domain.com\"},\n\t\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/my-ingress\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:    \"bar.test-zone.example.org\",\n\t\t\t\t\t\tTargets:    endpoint.Targets{\"new-domain.com\"},\n\t\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/new-ingress\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tstubConfig: DynamoDBStubConfig{\n\t\t\t\tExpectDelete: sets.New(\"quux.test-zone.example.org#A#set-2\"),\n\t\t\t\tExpectUpdate: map[string]map[string]string{\n\t\t\t\t\t\"bar.test-zone.example.org#CNAME#\": {endpoint.ResourceLabelKey: \"ingress/default/new-ingress\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedRecords: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.test-zone.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"foo.loadbalancer.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey: \"\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"bar.test-zone.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"new-domain.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/new-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:       \"baz.test-zone.example.org\",\n\t\t\t\t\tTargets:       endpoint.Targets{\"1.1.1.1\"},\n\t\t\t\t\tRecordType:    endpoint.RecordTypeA,\n\t\t\t\t\tSetIdentifier: \"set-1\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/my-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:       \"baz.test-zone.example.org\",\n\t\t\t\t\tTargets:       endpoint.Targets{\"2.2.2.2\"},\n\t\t\t\t\tRecordType:    endpoint.RecordTypeA,\n\t\t\t\t\tSetIdentifier: \"set-2\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/other-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"update migrate\",\n\t\t\taddRecords: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:       \"txt.bar.test-zone.example.org\",\n\t\t\t\t\tTargets:       endpoint.Targets{\"\\\"heritage=external-dns,external-dns/owner=test-owner,external-dns/resource=ingress/default/new-ingress\\\"\"},\n\t\t\t\t\tRecordType:    endpoint.RecordTypeTXT,\n\t\t\t\t\tSetIdentifier: \"set-1\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tchanges: plan.Changes{\n\t\t\t\tUpdateOld: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:    \"bar.test-zone.example.org\",\n\t\t\t\t\t\tTargets:    endpoint.Targets{\"my-domain.com\"},\n\t\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/my-ingress\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tName:  dynamodbAttributeMigrate,\n\t\t\t\t\t\t\t\tValue: \"true\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:    \"bar.test-zone.example.org\",\n\t\t\t\t\t\tTargets:    endpoint.Targets{\"my-domain.com\"},\n\t\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/new-ingress\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tstubConfig: DynamoDBStubConfig{\n\t\t\t\tExpectDelete: sets.New(\"quux.test-zone.example.org#A#set-2\"),\n\t\t\t\tExpectInsert: map[string]map[string]string{\n\t\t\t\t\t\"bar.test-zone.example.org#CNAME#\": {endpoint.ResourceLabelKey: \"ingress/default/new-ingress\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedRecords: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.test-zone.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"foo.loadbalancer.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey: \"\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"bar.test-zone.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"my-domain.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/new-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:       \"baz.test-zone.example.org\",\n\t\t\t\t\tTargets:       endpoint.Targets{\"1.1.1.1\"},\n\t\t\t\t\tRecordType:    endpoint.RecordTypeA,\n\t\t\t\t\tSetIdentifier: \"set-1\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/my-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:       \"baz.test-zone.example.org\",\n\t\t\t\t\tTargets:       endpoint.Targets{\"2.2.2.2\"},\n\t\t\t\t\tRecordType:    endpoint.RecordTypeA,\n\t\t\t\t\tSetIdentifier: \"set-2\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/other-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:       \"txt.bar.test-zone.example.org\",\n\t\t\t\t\tTargets:       endpoint.Targets{\"\\\"heritage=external-dns,external-dns/owner=test-owner,external-dns/resource=ingress/default/new-ingress\\\"\"},\n\t\t\t\t\tRecordType:    endpoint.RecordTypeTXT,\n\t\t\t\t\tSetIdentifier: \"set-1\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey: \"test-owner\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"update error\",\n\t\t\tchanges: plan.Changes{\n\t\t\t\tUpdateOld: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:    \"bar.test-zone.example.org\",\n\t\t\t\t\t\tTargets:    endpoint.Targets{\"my-domain.com\"},\n\t\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/my-ingress\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:    \"bar.test-zone.example.org\",\n\t\t\t\t\t\tTargets:    endpoint.Targets{\"new-domain.com\"},\n\t\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/new-ingress\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tstubConfig: DynamoDBStubConfig{\n\t\t\t\tExpectUpdateError: map[string]dynamodbtypes.BatchStatementErrorCodeEnum{\n\t\t\t\t\t\"bar.test-zone.example.org#CNAME#\": \"TestingError\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedError: \"updating dynamodb record \\\"bar.test-zone.example.org#CNAME#\\\": TestingError: testing error\",\n\t\t\texpectedRecords: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.test-zone.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"foo.loadbalancer.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey: \"\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"bar.test-zone.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"my-domain.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/my-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:       \"baz.test-zone.example.org\",\n\t\t\t\t\tTargets:       endpoint.Targets{\"1.1.1.1\"},\n\t\t\t\t\tRecordType:    endpoint.RecordTypeA,\n\t\t\t\t\tSetIdentifier: \"set-1\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/my-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:       \"baz.test-zone.example.org\",\n\t\t\t\t\tTargets:       endpoint.Targets{\"2.2.2.2\"},\n\t\t\t\t\tRecordType:    endpoint.RecordTypeA,\n\t\t\t\t\tSetIdentifier: \"set-2\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/other-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"delete\",\n\t\t\tchanges: plan.Changes{\n\t\t\t\tDelete: []*endpoint.Endpoint{\n\t\t\t\t\t{\n\t\t\t\t\t\tDNSName:    \"bar.test-zone.example.org\",\n\t\t\t\t\t\tTargets:    endpoint.Targets{\"my-domain.com\"},\n\t\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/my-ingress\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tstubConfig: DynamoDBStubConfig{\n\t\t\t\tExpectDelete: sets.New(\"bar.test-zone.example.org#CNAME#\", \"quux.test-zone.example.org#A#set-2\"),\n\t\t\t},\n\t\t\texpectedRecords: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.test-zone.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"foo.loadbalancer.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey: \"\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:       \"baz.test-zone.example.org\",\n\t\t\t\t\tTargets:       endpoint.Targets{\"1.1.1.1\"},\n\t\t\t\t\tRecordType:    endpoint.RecordTypeA,\n\t\t\t\t\tSetIdentifier: \"set-1\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/my-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:       \"baz.test-zone.example.org\",\n\t\t\t\t\tTargets:       endpoint.Targets{\"2.2.2.2\"},\n\t\t\t\t\tRecordType:    endpoint.RecordTypeA,\n\t\t\t\t\tSetIdentifier: \"set-2\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\tendpoint.OwnerLabelKey:    \"test-owner\",\n\t\t\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/other-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\toriginalMaxBatchSize := dynamodbMaxBatchSize\n\t\t\tif tc.maxBatchSize > 0 {\n\t\t\t\tdynamodbMaxBatchSize = tc.maxBatchSize\n\t\t\t}\n\n\t\t\tapi, p := newDynamoDBAPIStub(t, &tc.stubConfig)\n\t\t\tif len(tc.addRecords) > 0 {\n\t\t\t\t_ = p.(*wrappedProvider).Provider.ApplyChanges(t.Context(), &plan.Changes{\n\t\t\t\t\tCreate: tc.addRecords,\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tctx := t.Context()\n\n\t\t\tr, _ := newRegistry(p, \"test-owner\", api, \"test-table\", \"txt.\", \"\", \"\", []string{}, []string{}, nil, time.Hour)\n\t\t\t_, err := r.Records(ctx)\n\t\t\trequire.NoError(t, err)\n\n\t\t\terr = r.ApplyChanges(ctx, &tc.changes)\n\t\t\tif tc.expectedError == \"\" {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t} else {\n\t\t\t\tassert.EqualError(t, err, tc.expectedError)\n\t\t\t}\n\n\t\t\tassert.Empty(t, tc.stubConfig.ExpectInsert, \"all expected inserts made\")\n\t\t\tassert.Empty(t, tc.stubConfig.ExpectDelete, \"all expected deletions made\")\n\n\t\t\trecords, err := r.Records(ctx)\n\t\t\trequire.NoError(t, err)\n\t\t\tassert.True(t, testutils.SameEndpoints(records, tc.expectedRecords))\n\n\t\t\tr.recordsCache = nil\n\t\t\trecords, err = r.Records(ctx)\n\t\t\trequire.NoError(t, err)\n\t\t\tassert.True(t, testutils.SameEndpoints(records, tc.expectedRecords))\n\t\t\tif tc.expectedError == \"\" {\n\t\t\t\tassert.Empty(t, r.orphanedLabels)\n\t\t\t}\n\n\t\t\tdynamodbMaxBatchSize = originalMaxBatchSize\n\t\t})\n\t}\n}\n\n// DynamoDBAPIStub is a minimal implementation of DynamoDBAPI, used primarily for unit testing.\ntype DynamoDBStub struct {\n\tt                *testing.T\n\tstubConfig       *DynamoDBStubConfig\n\ttableDescription dynamodbtypes.TableDescription\n\tchangesApplied   bool\n}\n\ntype DynamoDBStubConfig struct {\n\tExpectInsert      map[string]map[string]string\n\tExpectInsertError map[string]dynamodbtypes.BatchStatementErrorCodeEnum\n\tExpectUpdate      map[string]map[string]string\n\tExpectUpdateError map[string]dynamodbtypes.BatchStatementErrorCodeEnum\n\tExpectDelete      sets.Set[string]\n}\n\ntype wrappedProvider struct {\n\tprovider.Provider\n\tstub *DynamoDBStub\n}\n\nfunc (w *wrappedProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {\n\tassert.False(w.stub.t, w.stub.changesApplied, \"ApplyChanges already called\")\n\tw.stub.changesApplied = true\n\treturn w.Provider.ApplyChanges(ctx, changes)\n}\n\nfunc newDynamoDBAPIStub(t *testing.T, stubConfig *DynamoDBStubConfig) (*DynamoDBStub, provider.Provider) {\n\tstub := &DynamoDBStub{\n\t\tt:          t,\n\t\tstubConfig: stubConfig,\n\t\ttableDescription: dynamodbtypes.TableDescription{\n\t\t\tAttributeDefinitions: []dynamodbtypes.AttributeDefinition{\n\t\t\t\t{\n\t\t\t\t\tAttributeName: aws.String(\"k\"),\n\t\t\t\t\tAttributeType: dynamodbtypes.ScalarAttributeTypeS,\n\t\t\t\t},\n\t\t\t},\n\t\t\tKeySchema: []dynamodbtypes.KeySchemaElement{\n\t\t\t\t{\n\t\t\t\t\tAttributeName: aws.String(\"k\"),\n\t\t\t\t\tKeyType:       dynamodbtypes.KeyTypeHash,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tp := inmemory.NewInMemoryProvider()\n\t_ = p.CreateZone(testZone)\n\t_ = p.ApplyChanges(t.Context(), &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\tendpoint.NewEndpoint(\"foo.test-zone.example.org\", endpoint.RecordTypeCNAME, \"foo.loadbalancer.com\"),\n\t\t\tendpoint.NewEndpoint(\"bar.test-zone.example.org\", endpoint.RecordTypeCNAME, \"my-domain.com\"),\n\t\t\tendpoint.NewEndpoint(\"baz.test-zone.example.org\", endpoint.RecordTypeA, \"1.1.1.1\").WithSetIdentifier(\"set-1\"),\n\t\t\tendpoint.NewEndpoint(\"baz.test-zone.example.org\", endpoint.RecordTypeA, \"2.2.2.2\").WithSetIdentifier(\"set-2\"),\n\t\t},\n\t})\n\treturn stub, &wrappedProvider{\n\t\tProvider: p,\n\t\tstub:     stub,\n\t}\n}\n\nfunc (r *DynamoDBStub) DescribeTable(ctx context.Context, input *dynamodb.DescribeTableInput, _ ...func(*dynamodb.Options)) (*dynamodb.DescribeTableOutput, error) {\n\tassert.NotNil(r.t, ctx)\n\tassert.Equal(r.t, \"test-table\", *input.TableName, \"table name\")\n\treturn &dynamodb.DescribeTableOutput{\n\t\tTable: &r.tableDescription,\n\t}, nil\n}\n\nfunc (r *DynamoDBStub) Scan(ctx context.Context, input *dynamodb.ScanInput, _ ...func(*dynamodb.Options)) (*dynamodb.ScanOutput, error) {\n\tassert.NotNil(r.t, ctx)\n\tassert.Equal(r.t, \"test-table\", *input.TableName, \"table name\")\n\tassert.Equal(r.t, \"o = :ownerval\", *input.FilterExpression)\n\tassert.Len(r.t, input.ExpressionAttributeValues, 1)\n\tvar owner string\n\tassert.NoError(r.t, attributevalue.Unmarshal(input.ExpressionAttributeValues[\":ownerval\"], &owner))\n\tassert.Equal(r.t, \"test-owner\", owner)\n\tassert.Equal(r.t, \"k,l\", *input.ProjectionExpression)\n\tassert.True(r.t, *input.ConsistentRead)\n\treturn &dynamodb.ScanOutput{\n\t\tItems: []map[string]dynamodbtypes.AttributeValue{\n\t\t\t{\n\t\t\t\t\"k\": &dynamodbtypes.AttributeValueMemberS{Value: \"bar.test-zone.example.org#CNAME#\"},\n\t\t\t\t\"l\": &dynamodbtypes.AttributeValueMemberM{Value: map[string]dynamodbtypes.AttributeValue{\n\t\t\t\t\tendpoint.ResourceLabelKey: &dynamodbtypes.AttributeValueMemberS{Value: \"ingress/default/my-ingress\"},\n\t\t\t\t}},\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"k\": &dynamodbtypes.AttributeValueMemberS{Value: \"baz.test-zone.example.org#A#set-1\"},\n\t\t\t\t\"l\": &dynamodbtypes.AttributeValueMemberM{Value: map[string]dynamodbtypes.AttributeValue{\n\t\t\t\t\tendpoint.ResourceLabelKey: &dynamodbtypes.AttributeValueMemberS{Value: \"ingress/default/my-ingress\"},\n\t\t\t\t}},\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"k\": &dynamodbtypes.AttributeValueMemberS{Value: \"baz.test-zone.example.org#A#set-2\"},\n\t\t\t\t\"l\": &dynamodbtypes.AttributeValueMemberM{Value: map[string]dynamodbtypes.AttributeValue{\n\t\t\t\t\tendpoint.ResourceLabelKey: &dynamodbtypes.AttributeValueMemberS{Value: \"ingress/default/other-ingress\"},\n\t\t\t\t}},\n\t\t\t},\n\t\t\t{\n\t\t\t\t\"k\": &dynamodbtypes.AttributeValueMemberS{Value: \"quux.test-zone.example.org#A#set-2\"},\n\t\t\t\t\"l\": &dynamodbtypes.AttributeValueMemberM{Value: map[string]dynamodbtypes.AttributeValue{\n\t\t\t\t\tendpoint.ResourceLabelKey: &dynamodbtypes.AttributeValueMemberS{Value: \"ingress/default/quux-ingress\"},\n\t\t\t\t}},\n\t\t\t},\n\t\t},\n\t}, nil\n}\n\nfunc (r *DynamoDBStub) BatchExecuteStatement(context context.Context, input *dynamodb.BatchExecuteStatementInput, _ ...func(*dynamodb.Options)) (*dynamodb.BatchExecuteStatementOutput, error) {\n\tassert.NotNil(r.t, context)\n\thasDelete := strings.HasPrefix(strings.ToLower(*input.Statements[0].Statement), \"delete\")\n\tassert.Equal(r.t, hasDelete, r.changesApplied, \"delete after provider changes, everything else before\")\n\tassert.LessOrEqual(r.t, len(input.Statements), 25)\n\tresponses := make([]dynamodbtypes.BatchStatementResponse, 0, len(input.Statements))\n\n\tfor _, statement := range input.Statements {\n\t\tassert.Equal(r.t, hasDelete, strings.HasPrefix(strings.ToLower(*statement.Statement), \"delete\"))\n\t\tswitch *statement.Statement {\n\t\tcase \"DELETE FROM \\\"test-table\\\" WHERE \\\"k\\\"=? AND \\\"o\\\"=?\":\n\t\t\tassert.True(r.t, r.changesApplied, \"unexpected delete before provider changes\")\n\n\t\t\tvar key string\n\t\t\trequire.NoError(r.t, attributevalue.Unmarshal(statement.Parameters[0], &key))\n\t\t\tassert.True(r.t, r.stubConfig.ExpectDelete.Has(key), \"unexpected delete for key %q\", key)\n\t\t\tr.stubConfig.ExpectDelete.Delete(key)\n\n\t\t\tvar testOwner string\n\t\t\tassert.NoError(r.t, attributevalue.Unmarshal(statement.Parameters[1], &testOwner))\n\t\t\tassert.Equal(r.t, \"test-owner\", testOwner)\n\n\t\t\tresponses = append(responses, dynamodbtypes.BatchStatementResponse{})\n\n\t\tcase \"INSERT INTO \\\"test-table\\\" VALUE {'k':?, 'o':?, 'l':?}\":\n\t\t\tassert.False(r.t, r.changesApplied, \"unexpected insert after provider changes\")\n\n\t\t\tvar key string\n\t\t\tassert.NoError(r.t, attributevalue.Unmarshal(statement.Parameters[0], &key))\n\t\t\tif code, ok := r.stubConfig.ExpectInsertError[key]; ok {\n\t\t\t\tdelete(r.stubConfig.ExpectInsertError, key)\n\t\t\t\tresponses = append(responses, dynamodbtypes.BatchStatementResponse{\n\t\t\t\t\tError: &dynamodbtypes.BatchStatementError{\n\t\t\t\t\t\tCode:    code,\n\t\t\t\t\t\tMessage: aws.String(\"testing error\"),\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\texpectedLabels, found := r.stubConfig.ExpectInsert[key]\n\t\t\tassert.True(r.t, found, \"unexpected insert for key %q\", key)\n\t\t\tdelete(r.stubConfig.ExpectInsert, key)\n\n\t\t\tvar testOwner string\n\t\t\trequire.NoError(r.t, attributevalue.Unmarshal(statement.Parameters[1], &testOwner))\n\t\t\tassert.Equal(r.t, \"test-owner\", testOwner)\n\n\t\t\tvar labels map[string]string\n\t\t\terr := attributevalue.Unmarshal(statement.Parameters[2], &labels)\n\t\t\tassert.NoError(r.t, err)\n\n\t\t\tfor label, value := range labels {\n\t\t\t\texpectedValue, found := expectedLabels[label]\n\t\t\t\tassert.True(r.t, found, \"insert for key %q has unexpected label %q\", key, label)\n\t\t\t\tdelete(expectedLabels, label)\n\t\t\t\tassert.Equal(r.t, expectedValue, value, \"insert for key %q label %q value\", key, label)\n\t\t\t}\n\n\t\t\tfor label := range expectedLabels {\n\t\t\t\tr.t.Errorf(\"insert for key %q did not get expected label %q\", key, label)\n\t\t\t}\n\n\t\t\tresponses = append(responses, dynamodbtypes.BatchStatementResponse{})\n\n\t\tcase \"UPDATE \\\"test-table\\\" SET \\\"l\\\"=? WHERE \\\"k\\\"=?\":\n\t\t\tassert.False(r.t, r.changesApplied, \"unexpected update after provider changes\")\n\n\t\t\tvar key string\n\t\t\tassert.NoError(r.t, attributevalue.Unmarshal(statement.Parameters[1], &key))\n\t\t\tif code, exists := r.stubConfig.ExpectUpdateError[key]; exists {\n\t\t\t\tdelete(r.stubConfig.ExpectInsertError, key)\n\t\t\t\tresponses = append(responses, dynamodbtypes.BatchStatementResponse{\n\t\t\t\t\tError: &dynamodbtypes.BatchStatementError{\n\t\t\t\t\t\tCode:    code,\n\t\t\t\t\t\tMessage: aws.String(\"testing error\"),\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\texpectedLabels, found := r.stubConfig.ExpectUpdate[key]\n\t\t\tassert.True(r.t, found, \"unexpected update for key %q\", key)\n\t\t\tdelete(r.stubConfig.ExpectUpdate, key)\n\n\t\t\tvar labels map[string]string\n\t\t\tassert.NoError(r.t, attributevalue.Unmarshal(statement.Parameters[0], &labels))\n\n\t\t\tfor label, value := range labels {\n\t\t\t\texpectedValue, found := expectedLabels[label]\n\t\t\t\tassert.True(r.t, found, \"update for key %q has unexpected label %q\", key, label)\n\t\t\t\tdelete(expectedLabels, label)\n\t\t\t\tassert.Equal(r.t, expectedValue, value, \"update for key %q label %q value\", key, label)\n\t\t\t}\n\n\t\t\tfor label := range expectedLabels {\n\t\t\t\tr.t.Errorf(\"update for key %q did not get expected label %q\", key, label)\n\t\t\t}\n\n\t\t\tresponses = append(responses, dynamodbtypes.BatchStatementResponse{})\n\n\t\tdefault:\n\t\t\tr.t.Errorf(\"unexpected statement: %s\", *statement.Statement)\n\t\t}\n\t}\n\n\treturn &dynamodb.BatchExecuteStatementOutput{\n\t\tResponses: responses,\n\t}, nil\n}\n"
  },
  {
    "path": "registry/factory/registry.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage factory\n\nimport (\n\t\"fmt\"\n\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\t\"sigs.k8s.io/external-dns/provider\"\n\t\"sigs.k8s.io/external-dns/registry\"\n\t\"sigs.k8s.io/external-dns/registry/awssd\"\n\t\"sigs.k8s.io/external-dns/registry/dynamodb\"\n\t\"sigs.k8s.io/external-dns/registry/noop\"\n\t\"sigs.k8s.io/external-dns/registry/txt\"\n)\n\n// RegistryConstructor is a function that creates a Registry from configuration and a provider.\ntype RegistryConstructor func(cfg *externaldns.Config, p provider.Provider) (registry.Registry, error)\n\n// Select creates a registry based on the given configuration.\nfunc Select(cfg *externaldns.Config, p provider.Provider) (registry.Registry, error) {\n\tconstructor, ok := registries(cfg.Registry)\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"unknown registry: %s\", cfg.Registry)\n\t}\n\treturn constructor(cfg, p)\n}\n\n// registries looks up the constructor for the named registry.\nfunc registries(selector string) (RegistryConstructor, bool) {\n\tm := map[string]RegistryConstructor{\n\t\texternaldns.RegistryDynamoDB: dynamodb.New,\n\t\texternaldns.RegistryNoop:     noop.New,\n\t\texternaldns.RegistryTXT:      txt.New,\n\t\texternaldns.RegistryAWSSD:    awssd.New,\n\t}\n\tc, ok := m[selector]\n\treturn c, ok\n}\n"
  },
  {
    "path": "registry/factory/registry_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage factory\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\t\"sigs.k8s.io/external-dns/provider\"\n\tfakeprovider \"sigs.k8s.io/external-dns/provider/fakes\"\n\t\"sigs.k8s.io/external-dns/provider/inmemory\"\n\t\"sigs.k8s.io/external-dns/registry\"\n\t\"sigs.k8s.io/external-dns/registry/awssd\"\n\t\"sigs.k8s.io/external-dns/registry/dynamodb\"\n\t\"sigs.k8s.io/external-dns/registry/noop\"\n\t\"sigs.k8s.io/external-dns/registry/txt\"\n)\n\nvar (\n\t_ registry.Registry = &awssd.AWSSDRegistry{}\n\t_ registry.Registry = &dynamodb.DynamoDBRegistry{}\n\t_ registry.Registry = &noop.NoopRegistry{}\n\t_ registry.Registry = &txt.TXTRegistry{}\n)\n\nfunc TestSelectRegistry(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tcfg      *externaldns.Config\n\t\tprovider provider.Provider\n\t\twantErr  bool\n\t\twantType string\n\t}{\n\t\t{\n\t\t\tname: \"dynamoDB registry\",\n\t\t\tcfg: &externaldns.Config{\n\t\t\t\tRegistry:               externaldns.RegistryDynamoDB,\n\t\t\t\tAWSDynamoDBRegion:      \"us-west-2\",\n\t\t\t\tAWSDynamoDBTable:       \"test-table\",\n\t\t\t\tTXTOwnerID:             \"owner-id\",\n\t\t\t\tTXTWildcardReplacement: \"wildcard\",\n\t\t\t\tManagedDNSRecordTypes:  []string{\"A\", \"CNAME\"},\n\t\t\t\tExcludeDNSRecordTypes:  []string{\"TXT\"},\n\t\t\t\tTXTCacheInterval:       60,\n\t\t\t},\n\t\t\tprovider: &fakeprovider.MockProvider{},\n\t\t\twantErr:  false,\n\t\t\twantType: \"DynamoDBRegistry\",\n\t\t},\n\t\t{\n\t\t\tname: \"noop registry\",\n\t\t\tcfg: &externaldns.Config{\n\t\t\t\tRegistry: externaldns.RegistryNoop,\n\t\t\t},\n\t\t\tprovider: &fakeprovider.MockProvider{},\n\t\t\twantErr:  false,\n\t\t\twantType: \"NoopRegistry\",\n\t\t},\n\t\t{\n\t\t\tname: \"TXT registry\",\n\t\t\tcfg: &externaldns.Config{\n\t\t\t\tRegistry:               externaldns.RegistryTXT,\n\t\t\t\tTXTPrefix:              \"prefix\",\n\t\t\t\tTXTOwnerID:             \"owner-id\",\n\t\t\t\tTXTCacheInterval:       60,\n\t\t\t\tTXTWildcardReplacement: \"wildcard\",\n\t\t\t\tManagedDNSRecordTypes:  []string{\"A\", \"CNAME\"},\n\t\t\t\tExcludeDNSRecordTypes:  []string{\"TXT\"},\n\t\t\t},\n\t\t\tprovider: &fakeprovider.MockProvider{},\n\t\t\twantErr:  false,\n\t\t\twantType: \"TXTRegistry\",\n\t\t},\n\t\t{\n\t\t\tname: \"aws-sd registry\",\n\t\t\tcfg: &externaldns.Config{\n\t\t\t\tRegistry:   externaldns.RegistryAWSSD,\n\t\t\t\tTXTOwnerID: \"owner-id\",\n\t\t\t},\n\t\t\tprovider: &fakeprovider.MockProvider{},\n\t\t\twantErr:  false,\n\t\t\twantType: \"AWSSDRegistry\",\n\t\t},\n\t\t{\n\t\t\tname: \"unknown registry\",\n\t\t\tcfg: &externaldns.Config{\n\t\t\t\tRegistry: \"unknown\",\n\t\t\t},\n\t\t\tprovider: &fakeprovider.MockProvider{},\n\t\t\twantErr:  true,\n\t\t\twantType: \"\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\treg, err := Select(tt.cfg, tt.provider)\n\n\t\t\tif tt.wantErr {\n\t\t\t\trequire.Nil(t, reg)\n\t\t\t\trequire.Error(t, err)\n\t\t\t} else {\n\t\t\t\tassert.NotNil(t, reg)\n\t\t\t\trequire.NoError(t, err)\n\t\t\t\tassert.Contains(t, reflect.TypeOf(reg).String(), tt.wantType)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSelectRegistryUnknown(t *testing.T) {\n\tcfg := externaldns.NewConfig()\n\tcfg.Registry = \"nope\"\n\n\treg, err := Select(cfg, inmemory.NewInMemoryProvider())\n\trequire.Error(t, err)\n\trequire.Nil(t, reg)\n}\n"
  },
  {
    "path": "registry/mapper/mapper.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage mapper\n\nimport (\n\t\"strings\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\nconst (\n\trecordTemplate = \"%{record_type}\"\n)\n\nvar (\n\tsupportedRecords = []string{\n\t\tendpoint.RecordTypeA,\n\t\tendpoint.RecordTypeAAAA,\n\t\tendpoint.RecordTypeCNAME,\n\t\tendpoint.RecordTypeNS,\n\t\tendpoint.RecordTypeMX,\n\t\tendpoint.RecordTypePTR,\n\t\tendpoint.RecordTypeSRV,\n\t\tendpoint.RecordTypeNAPTR,\n\t}\n)\n\n// NameMapper is the interface for mapping between the endpoint for the source\n// and the endpoint for the TXT record.\ntype NameMapper interface {\n\tToEndpointName(string) (string, string)\n\tToTXTName(string, string) string\n}\n\n// AffixNameMapper is a name mapper based on prefix/suffix affixes.\ntype AffixNameMapper struct {\n\tprefix              string\n\tsuffix              string\n\twildcardReplacement string\n}\n\n// NewAffixNameMapper returns a new AffixNameMapper.\nfunc NewAffixNameMapper(prefix, suffix, wildcardReplacement string) AffixNameMapper {\n\treturn AffixNameMapper{\n\t\tprefix:              strings.ToLower(prefix),\n\t\tsuffix:              strings.ToLower(suffix),\n\t\twildcardReplacement: strings.ToLower(wildcardReplacement),\n\t}\n}\n\nfunc (a AffixNameMapper) ToEndpointName(dns string) (string, string) {\n\tlowerDNSName := strings.ToLower(dns)\n\n\t// drop prefix\n\tif a.isPrefix() {\n\t\treturn a.dropAffixExtractType(lowerDNSName)\n\t}\n\n\t// drop suffix\n\tif a.isSuffix() {\n\t\tdc := strings.Count(a.suffix, \".\")\n\t\tDNSName := strings.SplitN(lowerDNSName, \".\", 2+dc)\n\t\tdomainWithSuffix := strings.Join(DNSName[:1+dc], \".\")\n\n\t\tr, rType := a.dropAffixExtractType(domainWithSuffix)\n\t\tif !strings.Contains(lowerDNSName, \".\") {\n\t\t\treturn r, rType\n\t\t}\n\t\treturn r + \".\" + DNSName[1+dc], rType\n\t}\n\treturn \"\", \"\"\n}\n\nfunc (a AffixNameMapper) ToTXTName(dns, recordType string) string {\n\tDNSName := strings.SplitN(dns, \".\", 2)\n\trecordType = strings.ToLower(recordType)\n\trecordT := recordType + \"-\"\n\n\tprefix := a.normalizeAffixTemplate(a.prefix, recordType)\n\tsuffix := a.normalizeAffixTemplate(a.suffix, recordType)\n\n\t// If specified, replace a leading asterisk in the generated txt record name with some other string\n\tif a.wildcardReplacement != \"\" && DNSName[0] == \"*\" {\n\t\tDNSName[0] = a.wildcardReplacement\n\t}\n\n\tif !a.recordTypeInAffix() {\n\t\tDNSName[0] = recordT + DNSName[0]\n\t}\n\n\tif len(DNSName) < 2 {\n\t\treturn prefix + DNSName[0] + suffix\n\t}\n\n\treturn prefix + DNSName[0] + suffix + \".\" + DNSName[1]\n}\n\nfunc (a AffixNameMapper) recordTypeInAffix() bool {\n\tif strings.Contains(a.prefix, recordTemplate) {\n\t\treturn true\n\t}\n\tif strings.Contains(a.suffix, recordTemplate) {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc (a AffixNameMapper) normalizeAffixTemplate(afix, recordType string) string {\n\tif strings.Contains(afix, recordTemplate) {\n\t\treturn strings.ReplaceAll(afix, recordTemplate, recordType)\n\t}\n\treturn afix\n}\n\nfunc (a AffixNameMapper) isPrefix() bool {\n\treturn len(a.suffix) == 0\n}\n\nfunc (a AffixNameMapper) isSuffix() bool {\n\treturn len(a.prefix) == 0 && len(a.suffix) > 0\n}\n\nfunc (a AffixNameMapper) dropAffixTemplate(name string) string {\n\treturn strings.ReplaceAll(name, recordTemplate, \"\")\n}\n\n// dropAffixExtractType strips TXT record to find an endpoint name it manages.\n// It also returns the record type.\nfunc (a AffixNameMapper) dropAffixExtractType(name string) (string, string) {\n\tprefix := a.prefix\n\tsuffix := a.suffix\n\n\tif a.recordTypeInAffix() {\n\t\tfor _, t := range supportedRecords {\n\t\t\ttLower := strings.ToLower(t)\n\t\t\tiPrefix := strings.ReplaceAll(prefix, recordTemplate, tLower)\n\t\t\tiSuffix := strings.ReplaceAll(suffix, recordTemplate, tLower)\n\n\t\t\tif a.isPrefix() && strings.HasPrefix(name, iPrefix) {\n\t\t\t\treturn strings.TrimPrefix(name, iPrefix), t\n\t\t\t}\n\n\t\t\tif a.isSuffix() && strings.HasSuffix(name, iSuffix) {\n\t\t\t\treturn strings.TrimSuffix(name, iSuffix), t\n\t\t\t}\n\t\t}\n\n\t\t// handle old TXT records\n\t\tprefix = a.dropAffixTemplate(prefix)\n\t\tsuffix = a.dropAffixTemplate(suffix)\n\t}\n\n\tif a.isPrefix() && strings.HasPrefix(name, prefix) {\n\t\treturn extractRecordTypeDefaultPosition(strings.TrimPrefix(name, prefix))\n\t}\n\n\tif a.isSuffix() && strings.HasSuffix(name, suffix) {\n\t\treturn extractRecordTypeDefaultPosition(strings.TrimSuffix(name, suffix))\n\t}\n\n\treturn \"\", \"\"\n}\n\n// extractRecordTypeDefaultPosition extracts record type from the default position\n// when not using '%{record_type}' in the prefix/suffix\nfunc extractRecordTypeDefaultPosition(name string) (string, string) {\n\tnameS := strings.Split(name, \"-\")\n\tfor _, t := range supportedRecords {\n\t\tif nameS[0] == strings.ToLower(t) {\n\t\t\treturn strings.TrimPrefix(name, nameS[0]+\"-\"), t\n\t\t}\n\t}\n\treturn name, \"\"\n}\n"
  },
  {
    "path": "registry/mapper/mapper_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage mapper\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\nvar (\n\t_ NameMapper = AffixNameMapper{}\n)\n\nfunc TestAffixNameMapper_ToEndpointName(t *testing.T) {\n\ttests := []struct {\n\t\tname             string\n\t\tmapper           AffixNameMapper\n\t\tinput            string\n\t\twantEndpointName string\n\t\twantRecordType   string\n\t}{\n\t\t{\n\t\t\tname:             \"prefix with A record type in affix\",\n\t\t\tmapper:           NewAffixNameMapper(\"%{record_type}-\", \"\", \"\"),\n\t\t\tinput:            \"a-foo.example.com\",\n\t\t\twantEndpointName: \"foo.example.com\",\n\t\t\twantRecordType:   endpoint.RecordTypeA,\n\t\t},\n\t\t{\n\t\t\tname:             \"prefix with AAAA record type in affix\",\n\t\t\tmapper:           NewAffixNameMapper(\"%{record_type}-\", \"\", \"\"),\n\t\t\tinput:            \"aaaa-foo.example.com\",\n\t\t\twantEndpointName: \"foo.example.com\",\n\t\t\twantRecordType:   endpoint.RecordTypeAAAA,\n\t\t},\n\t\t{\n\t\t\tname:             \"prefix with CNAME record type in affix\",\n\t\t\tmapper:           NewAffixNameMapper(\"%{record_type}-\", \"\", \"\"),\n\t\t\tinput:            \"cname-foo.example.com\",\n\t\t\twantEndpointName: \"foo.example.com\",\n\t\t\twantRecordType:   endpoint.RecordTypeCNAME,\n\t\t},\n\t\t{\n\t\t\tname:             \"prefix with NS record type in affix\",\n\t\t\tmapper:           NewAffixNameMapper(\"%{record_type}-\", \"\", \"\"),\n\t\t\tinput:            \"ns-foo.example.com\",\n\t\t\twantEndpointName: \"foo.example.com\",\n\t\t\twantRecordType:   endpoint.RecordTypeNS,\n\t\t},\n\t\t{\n\t\t\tname:             \"prefix with MX record type in affix\",\n\t\t\tmapper:           NewAffixNameMapper(\"%{record_type}-\", \"\", \"\"),\n\t\t\tinput:            \"mx-foo.example.com\",\n\t\t\twantEndpointName: \"foo.example.com\",\n\t\t\twantRecordType:   endpoint.RecordTypeMX,\n\t\t},\n\t\t{\n\t\t\tname:             \"prefix with SRV record type in affix\",\n\t\t\tmapper:           NewAffixNameMapper(\"%{record_type}-\", \"\", \"\"),\n\t\t\tinput:            \"srv-foo.example.com\",\n\t\t\twantEndpointName: \"foo.example.com\",\n\t\t\twantRecordType:   endpoint.RecordTypeSRV,\n\t\t},\n\t\t{\n\t\t\tname:             \"prefix with PTR record type in affix\",\n\t\t\tmapper:           NewAffixNameMapper(\"%{record_type}-\", \"\", \"\"),\n\t\t\tinput:            \"ptr-2.49.168.192.in-addr.arpa\",\n\t\t\twantEndpointName: \"2.49.168.192.in-addr.arpa\",\n\t\t\twantRecordType:   endpoint.RecordTypePTR,\n\t\t},\n\t\t{\n\t\t\tname:             \"prefix with NAPTR record type in affix\",\n\t\t\tmapper:           NewAffixNameMapper(\"%{record_type}-\", \"\", \"\"),\n\t\t\tinput:            \"naptr-foo.example.com\",\n\t\t\twantEndpointName: \"foo.example.com\",\n\t\t\twantRecordType:   endpoint.RecordTypeNAPTR,\n\t\t},\n\t\t{\n\t\t\tname:             \"suffix with A record type in affix\",\n\t\t\tmapper:           NewAffixNameMapper(\"\", \"-%{record_type}\", \"\"),\n\t\t\tinput:            \"foo-a.example.com\",\n\t\t\twantEndpointName: \"foo.example.com\",\n\t\t\twantRecordType:   endpoint.RecordTypeA,\n\t\t},\n\t\t{\n\t\t\tname:             \"suffix with CNAME record type in affix\",\n\t\t\tmapper:           NewAffixNameMapper(\"\", \"-%{record_type}\", \"\"),\n\t\t\tinput:            \"foo-cname.example.com\",\n\t\t\twantEndpointName: \"foo.example.com\",\n\t\t\twantRecordType:   endpoint.RecordTypeCNAME,\n\t\t},\n\t\t{\n\t\t\tname:             \"no affix with A record\",\n\t\t\tmapper:           NewAffixNameMapper(\"\", \"\", \"\"),\n\t\t\tinput:            \"a-foo.example.com\",\n\t\t\twantEndpointName: \"foo.example.com\",\n\t\t\twantRecordType:   endpoint.RecordTypeA,\n\t\t},\n\t\t{\n\t\t\tname:             \"no affix with AAAA record\",\n\t\t\tmapper:           NewAffixNameMapper(\"\", \"\", \"\"),\n\t\t\tinput:            \"aaaa-foo.example.com\",\n\t\t\twantEndpointName: \"foo.example.com\",\n\t\t\twantRecordType:   endpoint.RecordTypeAAAA,\n\t\t},\n\t\t{\n\t\t\tname:             \"no affix with CNAME record\",\n\t\t\tmapper:           NewAffixNameMapper(\"\", \"\", \"\"),\n\t\t\tinput:            \"cname-foo.example.com\",\n\t\t\twantEndpointName: \"foo.example.com\",\n\t\t\twantRecordType:   endpoint.RecordTypeCNAME,\n\t\t},\n\t\t{\n\t\t\tname:             \"no affix with NS record\",\n\t\t\tmapper:           NewAffixNameMapper(\"\", \"\", \"\"),\n\t\t\tinput:            \"ns-foo.example.com\",\n\t\t\twantEndpointName: \"foo.example.com\",\n\t\t\twantRecordType:   endpoint.RecordTypeNS,\n\t\t},\n\t\t{\n\t\t\tname:             \"no affix with MX record\",\n\t\t\tmapper:           NewAffixNameMapper(\"\", \"\", \"\"),\n\t\t\tinput:            \"mx-foo.example.com\",\n\t\t\twantEndpointName: \"foo.example.com\",\n\t\t\twantRecordType:   endpoint.RecordTypeMX,\n\t\t},\n\t\t{\n\t\t\tname:             \"no affix with SRV record\",\n\t\t\tmapper:           NewAffixNameMapper(\"\", \"\", \"\"),\n\t\t\tinput:            \"srv-foo.example.com\",\n\t\t\twantEndpointName: \"foo.example.com\",\n\t\t\twantRecordType:   endpoint.RecordTypeSRV,\n\t\t},\n\t\t{\n\t\t\tname:             \"default prefix with PTR record\",\n\t\t\tmapper:           NewAffixNameMapper(\"\", \"\", \"\"),\n\t\t\tinput:            \"ptr-2.49.168.192.in-addr.arpa\",\n\t\t\twantEndpointName: \"2.49.168.192.in-addr.arpa\",\n\t\t\twantRecordType:   endpoint.RecordTypePTR,\n\t\t},\n\t\t{\n\t\t\tname:             \"no affix with NAPTR record\",\n\t\t\tmapper:           NewAffixNameMapper(\"\", \"\", \"\"),\n\t\t\tinput:            \"naptr-foo.example.com\",\n\t\t\twantEndpointName: \"foo.example.com\",\n\t\t\twantRecordType:   endpoint.RecordTypeNAPTR,\n\t\t},\n\t\t{\n\t\t\tname:             \"suffix with txt record\",\n\t\t\tmapper:           NewAffixNameMapper(\"\", \"\", \"\"),\n\t\t\tinput:            \"txt-foo.example.com\",\n\t\t\twantEndpointName: \"txt-foo.example.com\",\n\t\t\twantRecordType:   \"\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgotName, gotType := tt.mapper.ToEndpointName(tt.input)\n\t\t\tassert.Equal(t, tt.wantEndpointName, gotName)\n\t\t\tassert.Equal(t, tt.wantRecordType, gotType)\n\t\t})\n\t}\n\n\t// Verify all supported records are tested\n\ttestedRecords := make(map[string]bool)\n\tfor _, tt := range tests {\n\t\tif tt.wantRecordType != \"\" {\n\t\t\ttestedRecords[tt.wantRecordType] = true\n\t\t}\n\t}\n\n\tfor _, recordType := range supportedRecords {\n\t\tassert.True(t, testedRecords[recordType], \"Record type %s is in supportedRecords but not tested in TestAffixNameMapper_ToEndpointName\", recordType)\n\t}\n}\n\nfunc TestAffixNameMapper_ToTXTName(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tmapper      AffixNameMapper\n\t\tdns         string\n\t\trecordType  string\n\t\twantTXTName string\n\t}{\n\t\t{\n\t\t\tname:        \"prefix with A record type in affix\",\n\t\t\tmapper:      NewAffixNameMapper(\"%{record_type}-\", \"\", \"\"),\n\t\t\tdns:         \"foo.example.com\",\n\t\t\trecordType:  endpoint.RecordTypeA,\n\t\t\twantTXTName: \"a-foo.example.com\",\n\t\t},\n\t\t{\n\t\t\tname:        \"prefix with AAAA record type in affix\",\n\t\t\tmapper:      NewAffixNameMapper(\"%{record_type}-\", \"\", \"\"),\n\t\t\tdns:         \"foo.example.com\",\n\t\t\trecordType:  endpoint.RecordTypeAAAA,\n\t\t\twantTXTName: \"aaaa-foo.example.com\",\n\t\t},\n\t\t{\n\t\t\tname:        \"prefix with CNAME record type in affix\",\n\t\t\tmapper:      NewAffixNameMapper(\"%{record_type}-\", \"\", \"\"),\n\t\t\tdns:         \"foo.example.com\",\n\t\t\trecordType:  endpoint.RecordTypeCNAME,\n\t\t\twantTXTName: \"cname-foo.example.com\",\n\t\t},\n\t\t{\n\t\t\tname:        \"prefix with NS record type in affix\",\n\t\t\tmapper:      NewAffixNameMapper(\"%{record_type}-\", \"\", \"\"),\n\t\t\tdns:         \"foo.example.com\",\n\t\t\trecordType:  endpoint.RecordTypeNS,\n\t\t\twantTXTName: \"ns-foo.example.com\",\n\t\t},\n\t\t{\n\t\t\tname:        \"prefix with MX record type in affix\",\n\t\t\tmapper:      NewAffixNameMapper(\"%{record_type}-\", \"\", \"\"),\n\t\t\tdns:         \"foo.example.com\",\n\t\t\trecordType:  endpoint.RecordTypeMX,\n\t\t\twantTXTName: \"mx-foo.example.com\",\n\t\t},\n\t\t{\n\t\t\tname:        \"prefix with SRV record type in affix\",\n\t\t\tmapper:      NewAffixNameMapper(\"%{record_type}-\", \"\", \"\"),\n\t\t\tdns:         \"foo.example.com\",\n\t\t\trecordType:  endpoint.RecordTypeSRV,\n\t\t\twantTXTName: \"srv-foo.example.com\",\n\t\t},\n\t\t{\n\t\t\tname:        \"prefix with PTR record type in affix\",\n\t\t\tmapper:      NewAffixNameMapper(\"%{record_type}-\", \"\", \"\"),\n\t\t\tdns:         \"2.49.168.192.in-addr.arpa\",\n\t\t\trecordType:  endpoint.RecordTypePTR,\n\t\t\twantTXTName: \"ptr-2.49.168.192.in-addr.arpa\",\n\t\t},\n\t\t{\n\t\t\tname:        \"prefix with NAPTR record type in affix\",\n\t\t\tmapper:      NewAffixNameMapper(\"%{record_type}-\", \"\", \"\"),\n\t\t\tdns:         \"foo.example.com\",\n\t\t\trecordType:  endpoint.RecordTypeNAPTR,\n\t\t\twantTXTName: \"naptr-foo.example.com\",\n\t\t},\n\t\t{\n\t\t\tname:        \"suffix with A record type in affix\",\n\t\t\tmapper:      NewAffixNameMapper(\"\", \"-%{record_type}\", \"\"),\n\t\t\tdns:         \"foo.example.com\",\n\t\t\trecordType:  endpoint.RecordTypeA,\n\t\t\twantTXTName: \"foo-a.example.com\",\n\t\t},\n\t\t{\n\t\t\tname:        \"suffix with CNAME record type in affix\",\n\t\t\tmapper:      NewAffixNameMapper(\"\", \"-%{record_type}\", \"\"),\n\t\t\tdns:         \"foo.example.com\",\n\t\t\trecordType:  endpoint.RecordTypeCNAME,\n\t\t\twantTXTName: \"foo-cname.example.com\",\n\t\t},\n\t\t{\n\t\t\tname:        \"wildcard replacement with A record\",\n\t\t\tmapper:      NewAffixNameMapper(\"txt-\", \"\", \"wild\"),\n\t\t\tdns:         \"*.example.com\",\n\t\t\trecordType:  endpoint.RecordTypeA,\n\t\t\twantTXTName: \"txt-a-wild.example.com\",\n\t\t},\n\t\t{\n\t\t\tname:        \"wildcard replacement with MX record\",\n\t\t\tmapper:      NewAffixNameMapper(\"txt-\", \"\", \"wild\"),\n\t\t\tdns:         \"*.example.com\",\n\t\t\trecordType:  endpoint.RecordTypeMX,\n\t\t\twantTXTName: \"txt-mx-wild.example.com\",\n\t\t},\n\t\t{\n\t\t\tname:        \"no affix with A record\",\n\t\t\tmapper:      NewAffixNameMapper(\"\", \"\", \"\"),\n\t\t\tdns:         \"foo.example.com\",\n\t\t\trecordType:  endpoint.RecordTypeA,\n\t\t\twantTXTName: \"a-foo.example.com\",\n\t\t},\n\t\t{\n\t\t\tname:        \"no affix with AAAA record\",\n\t\t\tmapper:      NewAffixNameMapper(\"\", \"\", \"\"),\n\t\t\tdns:         \"foo.example.com\",\n\t\t\trecordType:  endpoint.RecordTypeAAAA,\n\t\t\twantTXTName: \"aaaa-foo.example.com\",\n\t\t},\n\t\t{\n\t\t\tname:        \"no affix with CNAME record\",\n\t\t\tmapper:      NewAffixNameMapper(\"\", \"\", \"\"),\n\t\t\tdns:         \"foo.example.com\",\n\t\t\trecordType:  endpoint.RecordTypeCNAME,\n\t\t\twantTXTName: \"cname-foo.example.com\",\n\t\t},\n\t\t{\n\t\t\tname:        \"no affix with NS record\",\n\t\t\tmapper:      NewAffixNameMapper(\"\", \"\", \"\"),\n\t\t\tdns:         \"foo.example.com\",\n\t\t\trecordType:  endpoint.RecordTypeNS,\n\t\t\twantTXTName: \"ns-foo.example.com\",\n\t\t},\n\t\t{\n\t\t\tname:        \"no affix with MX record\",\n\t\t\tmapper:      NewAffixNameMapper(\"\", \"\", \"\"),\n\t\t\tdns:         \"foo.example.com\",\n\t\t\trecordType:  endpoint.RecordTypeMX,\n\t\t\twantTXTName: \"mx-foo.example.com\",\n\t\t},\n\t\t{\n\t\t\tname:        \"no affix with SRV record\",\n\t\t\tmapper:      NewAffixNameMapper(\"\", \"\", \"\"),\n\t\t\tdns:         \"foo.example.com\",\n\t\t\trecordType:  endpoint.RecordTypeSRV,\n\t\t\twantTXTName: \"srv-foo.example.com\",\n\t\t},\n\t\t{\n\t\t\tname:        \"default prefix with PTR record\",\n\t\t\tmapper:      NewAffixNameMapper(\"\", \"\", \"\"),\n\t\t\tdns:         \"2.49.168.192.in-addr.arpa\",\n\t\t\trecordType:  endpoint.RecordTypePTR,\n\t\t\twantTXTName: \"ptr-2.49.168.192.in-addr.arpa\",\n\t\t},\n\t\t{\n\t\t\tname:        \"no affix with NAPTR record\",\n\t\t\tmapper:      NewAffixNameMapper(\"\", \"\", \"\"),\n\t\t\tdns:         \"foo.example.com\",\n\t\t\trecordType:  endpoint.RecordTypeNAPTR,\n\t\t\twantTXTName: \"naptr-foo.example.com\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := tt.mapper.ToTXTName(tt.dns, tt.recordType)\n\t\t\tassert.Equal(t, tt.wantTXTName, got)\n\t\t})\n\t}\n\n\t// Verify all supported records are tested\n\ttestedRecords := make(map[string]bool)\n\tfor _, tt := range tests {\n\t\ttestedRecords[tt.recordType] = true\n\t}\n\n\tfor _, recordType := range supportedRecords {\n\t\tassert.True(t, testedRecords[recordType], \"Record type %s is in supportedRecords but not tested in TestAffixNameMapper_ToTXTName\", recordType)\n\t}\n}\n\nfunc TestAffixNameMapper_RecordTypeInAffix(t *testing.T) {\n\ttests := []struct {\n\t\tname   string\n\t\tmapper AffixNameMapper\n\t\twant   bool\n\t}{\n\t\t{\n\t\t\tname:   \"prefix contains record type\",\n\t\t\tmapper: NewAffixNameMapper(\"%{record_type}-\", \"\", \"\"),\n\t\t\twant:   true,\n\t\t},\n\t\t{\n\t\t\tname:   \"suffix contains record type\",\n\t\t\tmapper: NewAffixNameMapper(\"\", \"-%{record_type}\", \"\"),\n\t\t\twant:   true,\n\t\t},\n\t\t{\n\t\t\tname:   \"no record type in affix\",\n\t\t\tmapper: NewAffixNameMapper(\"txt-\", \"-txt\", \"\"),\n\t\t\twant:   false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := tt.mapper.recordTypeInAffix()\n\t\t\tassert.Equal(t, tt.want, got)\n\t\t})\n\t}\n}\n\nfunc TestToEndpointNameNewTXT(t *testing.T) {\n\ttests := []struct {\n\t\tname       string\n\t\tmapper     NameMapper\n\t\tdomain     string\n\t\ttxtDomain  string\n\t\trecordType string\n\t}{\n\t\t{\n\t\t\tname:       \"prefix\",\n\t\t\tmapper:     NewAffixNameMapper(\"foo\", \"\", \"\"),\n\t\t\tdomain:     \"example.com\",\n\t\t\trecordType: \"A\",\n\t\t\ttxtDomain:  \"fooa-example.com\",\n\t\t},\n\t\t{\n\t\t\tname:       \"suffix\",\n\t\t\tmapper:     NewAffixNameMapper(\"\", \"foo\", \"\"),\n\t\t\tdomain:     \"example\",\n\t\t\trecordType: \"AAAA\",\n\t\t\ttxtDomain:  \"aaaa-examplefoo\",\n\t\t},\n\t\t{\n\t\t\tname:       \"suffix\",\n\t\t\tmapper:     NewAffixNameMapper(\"\", \"foo\", \"\"),\n\t\t\tdomain:     \"example.com\",\n\t\t\trecordType: \"AAAA\",\n\t\t\ttxtDomain:  \"aaaa-examplefoo.com\",\n\t\t},\n\t\t{\n\t\t\tname:       \"prefix with dash\",\n\t\t\tmapper:     NewAffixNameMapper(\"foo-\", \"\", \"\"),\n\t\t\tdomain:     \"example.com\",\n\t\t\trecordType: \"A\",\n\t\t\ttxtDomain:  \"foo-a-example.com\",\n\t\t},\n\t\t{\n\t\t\tname:       \"suffix with dash\",\n\t\t\tmapper:     NewAffixNameMapper(\"\", \"-foo\", \"\"),\n\t\t\tdomain:     \"example.com\",\n\t\t\trecordType: \"CNAME\",\n\t\t\ttxtDomain:  \"cname-example-foo.com\",\n\t\t},\n\t\t{\n\t\t\tname:       \"prefix with dot\",\n\t\t\tmapper:     NewAffixNameMapper(\"foo.\", \"\", \"\"),\n\t\t\tdomain:     \"example.com\",\n\t\t\trecordType: \"CNAME\",\n\t\t\ttxtDomain:  \"foo.cname-example.com\",\n\t\t},\n\t\t{\n\t\t\tname:       \"suffix with dot\",\n\t\t\tmapper:     NewAffixNameMapper(\"\", \".foo\", \"\"),\n\t\t\tdomain:     \"example.com\",\n\t\t\trecordType: \"CNAME\",\n\t\t\ttxtDomain:  \"cname-example.foo.com\",\n\t\t},\n\t\t{\n\t\t\tname:       \"prefix with multiple dots\",\n\t\t\tmapper:     NewAffixNameMapper(\"foo.bar.\", \"\", \"\"),\n\t\t\tdomain:     \"example.com\",\n\t\t\trecordType: \"CNAME\",\n\t\t\ttxtDomain:  \"foo.bar.cname-example.com\",\n\t\t},\n\t\t{\n\t\t\tname:       \"suffix with multiple dots\",\n\t\t\tmapper:     NewAffixNameMapper(\"\", \".foo.bar.test\", \"\"),\n\t\t\tdomain:     \"example.com\",\n\t\t\trecordType: \"CNAME\",\n\t\t\ttxtDomain:  \"cname-example.foo.bar.test.com\",\n\t\t},\n\t\t{\n\t\t\tname:       \"templated prefix\",\n\t\t\tmapper:     NewAffixNameMapper(\"%{record_type}-foo\", \"\", \"\"),\n\t\t\tdomain:     \"example.com\",\n\t\t\trecordType: \"A\",\n\t\t\ttxtDomain:  \"a-fooexample.com\",\n\t\t},\n\t\t{\n\t\t\tname:       \"templated suffix\",\n\t\t\tmapper:     NewAffixNameMapper(\"\", \"foo-%{record_type}\", \"\"),\n\t\t\tdomain:     \"example.com\",\n\t\t\trecordType: \"A\",\n\t\t\ttxtDomain:  \"examplefoo-a.com\",\n\t\t},\n\t\t{\n\t\t\tname:       \"templated prefix with dot\",\n\t\t\tmapper:     NewAffixNameMapper(\"%{record_type}foo.\", \"\", \"\"),\n\t\t\tdomain:     \"example.com\",\n\t\t\trecordType: \"CNAME\",\n\t\t\ttxtDomain:  \"cnamefoo.example.com\",\n\t\t},\n\t\t{\n\t\t\tname:       \"templated suffix with dot\",\n\t\t\tmapper:     NewAffixNameMapper(\"\", \".foo%{record_type}\", \"\"),\n\t\t\tdomain:     \"example.com\",\n\t\t\trecordType: \"A\",\n\t\t\ttxtDomain:  \"example.fooa.com\",\n\t\t},\n\t\t{\n\t\t\tname:       \"templated prefix with multiple dots\",\n\t\t\tmapper:     NewAffixNameMapper(\"bar.%{record_type}.foo.\", \"\", \"\"),\n\t\t\tdomain:     \"example.com\",\n\t\t\trecordType: \"CNAME\",\n\t\t\ttxtDomain:  \"bar.cname.foo.example.com\",\n\t\t},\n\t\t{\n\t\t\tname:       \"templated suffix with multiple dots\",\n\t\t\tmapper:     NewAffixNameMapper(\"\", \".foo%{record_type}.bar\", \"\"),\n\t\t\tdomain:     \"example.com\",\n\t\t\trecordType: \"A\",\n\t\t\ttxtDomain:  \"example.fooa.bar.com\",\n\t\t},\n\t}\n\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\ttxtDomain := tc.mapper.ToTXTName(tc.domain, tc.recordType)\n\t\t\tassert.Equal(t, tc.txtDomain, txtDomain)\n\n\t\t\tdomain, _ := tc.mapper.ToEndpointName(txtDomain)\n\t\t\tassert.Equal(t, tc.domain, domain)\n\t\t})\n\t}\n}\n\nfunc TestDropPrefix(t *testing.T) {\n\tmapper := NewAffixNameMapper(\"foo-%{record_type}-\", \"\", \"\")\n\texpectedOutput := \"test.example.com\"\n\n\ttests := []string{\n\t\t\"foo-cname-test.example.com\",\n\t\t\"foo-a-test.example.com\",\n\t\t\"foo--test.example.com\",\n\t}\n\n\tfor _, tc := range tests {\n\t\tt.Run(tc, func(t *testing.T) {\n\t\t\tactualOutput, _ := mapper.dropAffixExtractType(tc)\n\t\t\tassert.Equal(t, expectedOutput, actualOutput)\n\t\t})\n\t}\n}\n\nfunc TestDropSuffix(t *testing.T) {\n\tmapper := NewAffixNameMapper(\"\", \"-%{record_type}-foo\", \"\")\n\texpectedOutput := \"test.example.com\"\n\n\ttests := []string{\n\t\t\"test-a-foo.example.com\",\n\t\t\"test--foo.example.com\",\n\t}\n\n\tfor _, tc := range tests {\n\t\tt.Run(tc, func(t *testing.T) {\n\t\t\tr := strings.SplitN(tc, \".\", 2)\n\t\t\trClean, _ := mapper.dropAffixExtractType(r[0])\n\t\t\tactualOutput := rClean + \".\" + r[1]\n\t\t\tassert.Equal(t, expectedOutput, actualOutput)\n\t\t})\n\t}\n}\n\nfunc TestExtractRecordTypeDefaultPosition(t *testing.T) {\n\ttests := []struct {\n\t\tinput        string\n\t\texpectedName string\n\t\texpectedType string\n\t}{\n\t\t{\n\t\t\tinput:        \"ns-zone.example.com\",\n\t\t\texpectedName: \"zone.example.com\",\n\t\t\texpectedType: \"NS\",\n\t\t},\n\t\t{\n\t\t\tinput:        \"aaaa-zone.example.com\",\n\t\t\texpectedName: \"zone.example.com\",\n\t\t\texpectedType: \"AAAA\",\n\t\t},\n\t\t{\n\t\t\tinput:        \"ptr-zone.example.com\",\n\t\t\texpectedName: \"zone.example.com\",\n\t\t\texpectedType: \"PTR\",\n\t\t},\n\t\t{\n\t\t\tinput:        \"zone.example.com\",\n\t\t\texpectedName: \"zone.example.com\",\n\t\t\texpectedType: \"\",\n\t\t},\n\t}\n\n\tfor _, tc := range tests {\n\t\tt.Run(tc.input, func(t *testing.T) {\n\t\t\tactualName, actualType := extractRecordTypeDefaultPosition(tc.input)\n\t\t\tassert.Equal(t, tc.expectedName, actualName)\n\t\t\tassert.Equal(t, tc.expectedType, actualType)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "registry/noop/OWNERS",
    "content": "# See the OWNERS docs at https://go.k8s.io/owners\n\nlabels:\n- noop\n"
  },
  {
    "path": "registry/noop/noop.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage noop\n\nimport (\n\t\"context\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n\t\"sigs.k8s.io/external-dns/registry\"\n)\n\n// NoopRegistry implements registry interface without ownership directly propagating changes to dns provider\ntype NoopRegistry struct {\n\tprovider provider.Provider\n}\n\n// New creates a NoopRegistry from the given configuration.\nfunc New(_ *externaldns.Config, p provider.Provider) (registry.Registry, error) {\n\treturn newRegistry(p), nil\n}\n\n// newRegistry returns new NoopRegistry object\nfunc newRegistry(provider provider.Provider) *NoopRegistry {\n\treturn &NoopRegistry{\n\t\tprovider: provider,\n\t}\n}\n\nfunc (im *NoopRegistry) GetDomainFilter() endpoint.DomainFilterInterface {\n\treturn im.provider.GetDomainFilter()\n}\n\nfunc (im *NoopRegistry) OwnerID() string {\n\treturn \"\"\n}\n\n// Records returns the current records from the dns provider\nfunc (im *NoopRegistry) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {\n\treturn im.provider.Records(ctx)\n}\n\n// ApplyChanges propagates changes to the dns provider\nfunc (im *NoopRegistry) ApplyChanges(ctx context.Context, changes *plan.Changes) error {\n\treturn im.provider.ApplyChanges(ctx, changes)\n}\n\n// AdjustEndpoints modifies the endpoints as needed by the specific provider\nfunc (im *NoopRegistry) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) {\n\treturn im.provider.AdjustEndpoints(endpoints)\n}\n"
  },
  {
    "path": "registry/noop/noop_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage noop\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/internal/testutils\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider/inmemory\"\n)\n\nfunc TestNoopRegistry(t *testing.T) {\n\tt.Run(\"NewNoopRegistry\", testNoopInit)\n\tt.Run(\"Records\", testNoopRecords)\n\tt.Run(\"ApplyChanges\", testNoopApplyChanges)\n}\n\nfunc testNoopInit(t *testing.T) {\n\tp := inmemory.NewInMemoryProvider()\n\tr := newRegistry(p)\n\tvar err error\n\trequire.NoError(t, err)\n\tassert.Equal(t, p, r.provider)\n}\n\nfunc testNoopRecords(t *testing.T) {\n\tctx := t.Context()\n\tp := inmemory.NewInMemoryProvider()\n\tp.CreateZone(\"org\")\n\tinmemoryRecords := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"example.org\",\n\t\t\tTargets:    endpoint.Targets{\"example-lb.com\"},\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t},\n\t}\n\tp.ApplyChanges(ctx, &plan.Changes{\n\t\tCreate: inmemoryRecords,\n\t})\n\n\tr := newRegistry(p)\n\n\teps, err := r.Records(ctx)\n\trequire.NoError(t, err)\n\tassert.True(t, testutils.SameEndpoints(eps, inmemoryRecords))\n}\n\nfunc testNoopApplyChanges(t *testing.T) {\n\t// do some prep\n\tp := inmemory.NewInMemoryProvider()\n\tp.CreateZone(\"org\")\n\tinmemoryRecords := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"example.org\",\n\t\t\tTargets:    endpoint.Targets{\"old-lb.com\"},\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t},\n\t}\n\texpectedUpdate := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"example.org\",\n\t\t\tTargets:    endpoint.Targets{\"new-example-lb.com\"},\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"new-record.org\",\n\t\t\tTargets:    endpoint.Targets{\"new-lb.org\"},\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t},\n\t}\n\n\tctx := t.Context()\n\tp.ApplyChanges(ctx, &plan.Changes{\n\t\tCreate: inmemoryRecords,\n\t})\n\n\t// wrong changes\n\tr := newRegistry(p)\n\terr := r.ApplyChanges(ctx, &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t},\n\t\t},\n\t})\n\tassert.EqualError(t, err, inmemory.ErrRecordAlreadyExists.Error())\n\n\t// correct changes\n\trequire.NoError(t, r.ApplyChanges(ctx, &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"new-record.org\",\n\t\t\t\tTargets:    endpoint.Targets{\"new-lb.org\"},\n\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t},\n\t\t},\n\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\tTargets:    endpoint.Targets{\"new-example-lb.com\"},\n\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t},\n\t\t},\n\t\tUpdateOld: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\tTargets:    endpoint.Targets{\"old-lb.com\"},\n\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t},\n\t\t},\n\t}))\n\tres, _ := p.Records(ctx)\n\tassert.True(t, testutils.SameEndpoints(res, expectedUpdate))\n}\n"
  },
  {
    "path": "registry/registry.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage registry\n\nimport (\n\t\"context\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/plan\"\n)\n\n// Registry tracks ownership of DNS records managed by external-dns.\ntype Registry interface {\n\t// Records returns all DNS records known to the registry, including ownership metadata.\n\tRecords(ctx context.Context) ([]*endpoint.Endpoint, error)\n\t// ApplyChanges propagates the given changes to the DNS provider and updates ownership records accordingly.\n\tApplyChanges(ctx context.Context, changes *plan.Changes) error\n\t// AdjustEndpoints normalises endpoints before they are processed by the planner.\n\tAdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error)\n\t// GetDomainFilter returns the domain filter configured for the underlying provider.\n\tGetDomainFilter() endpoint.DomainFilterInterface\n\t// OwnerID returns the owner identifier used to claim DNS records.\n\tOwnerID() string\n}\n"
  },
  {
    "path": "registry/txt/OWNERS",
    "content": "# See the OWNERS docs at https://go.k8s.io/owners\n\nlabels:\n- txt\n"
  },
  {
    "path": "registry/txt/encryption_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage txt\n\nimport (\n\t\"fmt\"\n\t\"slices\"\n\t\"strconv\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider/inmemory\"\n)\n\nfunc TestNewTXTRegistryEncryptionConfig(t *testing.T) {\n\tp := inmemory.NewInMemoryProvider()\n\ttests := []struct {\n\t\tencEnabled      bool\n\t\taesKeyRaw       []byte\n\t\taesKeySanitized []byte\n\t\terrorExpected   bool\n\t}{\n\t\t{\n\t\t\tencEnabled:      true,\n\t\t\taesKeyRaw:       []byte(\"123456789012345678901234567890asdfasdfasdfasdfa12\"),\n\t\t\taesKeySanitized: []byte{},\n\t\t\terrorExpected:   true,\n\t\t},\n\t\t{\n\t\t\tencEnabled:      true,\n\t\t\taesKeyRaw:       []byte(\"passphrasewhichneedstobe32bytes!\"),\n\t\t\taesKeySanitized: []byte(\"passphrasewhichneedstobe32bytes!\"),\n\t\t\terrorExpected:   false,\n\t\t},\n\t\t{\n\t\t\tencEnabled:      true,\n\t\t\taesKeyRaw:       []byte(\"ZPitL0NGVQBZbTD6DwXJzD8RiStSazzYXQsdUowLURY=\"),\n\t\t\taesKeySanitized: []byte{100, 248, 173, 47, 67, 70, 85, 0, 89, 109, 48, 250, 15, 5, 201, 204, 63, 17, 137, 43, 82, 107, 60, 216, 93, 11, 29, 82, 140, 11, 81, 22},\n\t\t\terrorExpected:   false,\n\t\t},\n\t}\n\tfor _, test := range tests {\n\t\tactual, err := newRegistry(p, \"txt.\", \"\", \"owner\", time.Hour, \"\", []string{}, []string{}, test.encEnabled, test.aesKeyRaw, \"\")\n\t\tif test.errorExpected {\n\t\t\trequire.Error(t, err)\n\t\t} else {\n\t\t\trequire.NoError(t, err)\n\t\t\tassert.Equal(t, test.aesKeySanitized, actual.txtEncryptAESKey)\n\t\t}\n\t}\n}\n\nfunc TestGenerateTXTGenerateTextRecordEncryptionWihDecryption(t *testing.T) {\n\tp := inmemory.NewInMemoryProvider()\n\t_ = p.CreateZone(testZone)\n\n\ttests := []struct {\n\t\trecord    *endpoint.Endpoint\n\t\tdecrypted string\n\t}{\n\t\t{\n\t\t\trecord:    newEndpointWithOwner(\"foo.test-zone.example.org\", \"new-foo.loadbalancer.com\", endpoint.RecordTypeCNAME, \"owner-2\"),\n\t\t\tdecrypted: \"heritage=external-dns,external-dns/owner=owner-2\",\n\t\t},\n\t\t{\n\t\t\trecord:    newEndpointWithOwnerAndLabels(\"foo.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"owner-1\", endpoint.Labels{endpoint.OwnedRecordLabelKey: \"foo.test-zone.example.org\"}),\n\t\t\tdecrypted: \"heritage=external-dns,external-dns/ownedRecord=foo.test-zone.example.org,external-dns/owner=owner-1\",\n\t\t},\n\t\t{\n\t\t\trecord:    newEndpointWithOwnerAndLabels(\"bar.test-zone.example.org\", \"cluster-b\", endpoint.RecordTypeCNAME, \"owner-1\", endpoint.Labels{endpoint.ResourceLabelKey: \"ingress/default/foo-127\"}),\n\t\t\tdecrypted: \"heritage=external-dns,external-dns/owner=owner-1,external-dns/resource=ingress/default/foo-127\",\n\t\t},\n\t\t{\n\t\t\trecord:    newEndpointWithOwner(\"dualstack.test-zone.example.org\", \"1.1.1.1\", endpoint.RecordTypeA, \"owner-0\"),\n\t\t\tdecrypted: \"heritage=external-dns,external-dns/owner=owner-0\",\n\t\t},\n\t}\n\n\twithEncryptionKeys := []string{\n\t\t\"passphrasewhichneedstobe32bytes!\",\n\t\t\"ZPitL0NGVQBZbTD6DwXJzD8RiStSazzYXQsdUowLURY=\",\n\t\t\"01234567890123456789012345678901\",\n\t}\n\n\tfor _, test := range tests {\n\t\tfor _, k := range withEncryptionKeys {\n\t\t\tt.Run(fmt.Sprintf(\"key '%s' with decrypted result '%s'\", k, test.decrypted), func(t *testing.T) {\n\t\t\t\tkey := []byte(k)\n\t\t\t\tr, err := newRegistry(p, \"\", \"\", \"owner\", time.Minute, \"\", []string{}, []string{}, true, key, \"\")\n\t\t\t\tassert.NoError(t, err, \"Error creating TXT registry\")\n\t\t\t\ttxtRecords := r.generateTXTRecord(test.record)\n\t\t\t\tassert.Len(t, txtRecords, len(test.record.Targets))\n\n\t\t\t\tfor _, txt := range txtRecords {\n\t\t\t\t\t// should return a TXT record with the encryption nonce label. At the moment nonce is not set as label.\n\t\t\t\t\tassert.NotContains(t, txt.Labels, \"txt-encryption-nonce\")\n\n\t\t\t\t\tassert.Len(t, txt.Targets, 1)\n\t\t\t\t\tassert.LessOrEqual(t, len(txt.Targets), 1)\n\n\t\t\t\t\t// decrypt targets\n\t\t\t\t\tfor _, target := range txtRecords[0].Targets {\n\t\t\t\t\t\tencryptedText, errUnquote := strconv.Unquote(target)\n\t\t\t\t\t\tassert.NoError(t, errUnquote, \"Error unquoting the encrypted text\")\n\n\t\t\t\t\t\tactual, nonce, errDecrypt := endpoint.DecryptText(encryptedText, r.txtEncryptAESKey)\n\t\t\t\t\t\tassert.NoError(t, errDecrypt, \"Error decrypting the encrypted text\")\n\n\t\t\t\t\t\tassert.True(t, strings.HasPrefix(encryptedText, nonce),\n\t\t\t\t\t\t\t\"Nonce '%s' should be a prefix of the encrypted text: '%s'\", nonce, encryptedText)\n\t\t\t\t\t\tassert.Equal(t, test.decrypted, actual)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\t}\n}\n\nfunc TestApplyRecordsWithEncryption(t *testing.T) {\n\tctx := t.Context()\n\tp := inmemory.NewInMemoryProvider()\n\t_ = p.CreateZone(\"org\")\n\n\tkey := []byte(\"ZPitL0NGVQBZbTD6DwXJzD8RiStSazzYXQsdUowLURY=\")\n\n\tr, _ := newRegistry(p, \"\", \"\", \"owner\", time.Hour, \"\", []string{}, []string{}, true, key, \"\")\n\n\t_ = r.ApplyChanges(ctx, &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwner(\"new-record-1.test-zone.example.org\", \"new-loadbalancer-1.lb.com\", endpoint.RecordTypeCNAME, \"owner\"),\n\t\t\tnewTXTEndpointWithOwnedRecord(\"new-record-2.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", \"new-record-1.test-zone.example.org\"),\n\t\t\tnewEndpointWithOwner(\"example.org\", \"new-loadbalancer-3.org\", endpoint.RecordTypeCNAME, \"owner\"),\n\t\t\tnewTXTEndpointWithOwnedRecord(\"main.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", \"example\"),\n\t\t\tnewEndpointWithOwner(\"tar.org\", \"tar.loadbalancer.com\", endpoint.RecordTypeCNAME, \"owner-2\"),\n\t\t\tnewEndpointWithOwner(\"thing3.org\", \"1.2.3.4\", endpoint.RecordTypeA, \"owner\"),\n\t\t\tnewEndpointWithOwner(\"thing4.org\", \"2001:DB8::2\", endpoint.RecordTypeAAAA, \"owner\"),\n\t\t},\n\t})\n\n\tallPlainTextTargetsToAssert := []string{\n\t\t\"heritage=external-dns,external-dns/\",\n\t\t\"tar.loadbalancer.com\",\n\t\t\"new-loadbalancer-1.lb.com\",\n\t\t\"2001:DB8::2\",\n\t\t\"new-loadbalancer-3.org\",\n\t\t\"1.2.3.4\",\n\t}\n\n\trecords, _ := p.Records(ctx)\n\tassert.Len(t, records, 14)\n\tfor _, r := range records {\n\t\tif r.RecordType == endpoint.RecordTypeTXT && (strings.HasPrefix(r.DNSName, \"cname-\") || strings.HasPrefix(r.DNSName, \"txt-new-\")) {\n\t\t\tassert.NotContains(t, r.Labels, \"txt-encryption-nonce\")\n\t\t\t// assuming single target, it should be not a plain text\n\t\t\tassert.NotContains(t, r.Targets[0], \"heritage=external-dns\")\n\t\t}\n\t\t// All TXT records with new- prefix should have the encryption nonce label and be in plain text\n\t\tif r.RecordType == endpoint.RecordTypeTXT && strings.HasPrefix(r.DNSName, \"new-\") {\n\t\t\tassert.Contains(t, r.Labels, \"txt-encryption-nonce\")\n\t\t\t// assuming single target, it should be in a plain text\n\t\t\tassert.Contains(t, r.Targets[0], \"heritage=external-dns,external-dns/\")\n\t\t}\n\t\t// All CNAME, A and AAAA TXT records should have the encryption nonce label\n\t\tif slices.Contains([]string{\"CNAME\", \"A\", \"AAAA\"}, r.RecordType) {\n\t\t\tassert.Contains(t, r.Labels, \"txt-encryption-nonce\")\n\t\t\t// validate that target is in plain text\n\t\t\tassert.Contains(t, allPlainTextTargetsToAssert, r.Targets[0])\n\t\t}\n\t}\n}\n\nfunc TestApplyRecordsWithEncryptionKeyChanged(t *testing.T) {\n\tctx := t.Context()\n\tp := inmemory.NewInMemoryProvider()\n\t_ = p.CreateZone(\"org\")\n\n\twithEncryptionKeys := []string{\n\t\t\"passphrasewhichneedstobe32bytes!\",\n\t\t\"ZPitL0NGVQBZbTD6DwXJzD8RiStSazzYXQsdUowLURY=\",\n\t\t\"01234567890123456789012345678901\",\n\t}\n\n\tfor _, key := range withEncryptionKeys {\n\t\tr, _ := newRegistry(p, \"\", \"\", \"owner\", time.Hour, \"\", []string{}, []string{}, true, []byte(key), \"\")\n\t\t_ = r.ApplyChanges(ctx, &plan.Changes{\n\t\t\tCreate: []*endpoint.Endpoint{\n\t\t\t\tnewEndpointWithOwner(\"new-record-1.test-zone.example.org\", \"new-loadbalancer-1.lb.com\", endpoint.RecordTypeCNAME, \"owner\"),\n\t\t\t\tnewTXTEndpointWithOwnedRecord(\"new-record-2.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", \"new-record-1.test-zone.example.org\"),\n\t\t\t\tnewEndpointWithOwner(\"example.org\", \"new-loadbalancer-3.org\", endpoint.RecordTypeCNAME, \"owner\"),\n\t\t\t\tnewTXTEndpointWithOwnedRecord(\"main.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", \"example\"),\n\t\t\t\tnewEndpointWithOwner(\"tar.org\", \"tar.loadbalancer.com\", endpoint.RecordTypeCNAME, \"owner-2\"),\n\t\t\t\tnewEndpointWithOwner(\"thing3.org\", \"1.2.3.4\", endpoint.RecordTypeA, \"owner\"),\n\t\t\t\tnewEndpointWithOwner(\"thing4.org\", \"2001:DB8::2\", endpoint.RecordTypeAAAA, \"owner\"),\n\t\t\t},\n\t\t})\n\t}\n\n\trecords, _ := p.Records(ctx)\n\tassert.Len(t, records, 14)\n}\n\nfunc TestApplyRecordsOnEncryptionKeyChangeWithKeyIdLabel(t *testing.T) {\n\tctx := t.Context()\n\tp := inmemory.NewInMemoryProvider()\n\t_ = p.CreateZone(\"org\")\n\n\twithEncryptionKeys := []string{\n\t\t\"passphrasewhichneedstobe32bytes!\",\n\t\t\"ZPitL0NGVQBZbTD6DwXJzD8RiStSazzYXQsdUowLURY=\",\n\t\t\"01234567890123456789012345678901\",\n\t}\n\n\tfor i, key := range withEncryptionKeys {\n\t\tr, _ := newRegistry(p, \"\", \"\", \"owner\", time.Hour, \"\", []string{}, []string{}, true, []byte(key), \"\")\n\t\tkeyId := fmt.Sprintf(\"key-id-%d\", i)\n\t\tchanges := []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwnerAndOwnedRecordWithKeyIDLabel(\"new-record-1.test-zone.example.org\", \"new-loadbalancer-1.lb.com\", endpoint.RecordTypeCNAME, \"owner\", \"\", keyId),\n\t\t\tnewEndpointWithOwnerAndOwnedRecordWithKeyIDLabel(\"new-record-2.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\", \"new-record-1.test-zone.example.org\", keyId),\n\t\t\tnewEndpointWithOwnerAndOwnedRecordWithKeyIDLabel(\"example.org\", \"new-loadbalancer-3.org\", endpoint.RecordTypeCNAME, \"owner\", \"\", keyId),\n\t\t\tnewEndpointWithOwnerAndOwnedRecordWithKeyIDLabel(\"main.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\", \"example\", keyId),\n\t\t\tnewEndpointWithOwnerAndOwnedRecordWithKeyIDLabel(\"tar.org\", \"tar.loadbalancer.com\", endpoint.RecordTypeCNAME, \"owner-2\", \"\", keyId),\n\t\t\tnewEndpointWithOwnerAndOwnedRecordWithKeyIDLabel(\"thing3.org\", \"1.2.3.4\", endpoint.RecordTypeA, \"owner\", \"\", keyId),\n\t\t\tnewEndpointWithOwnerAndOwnedRecordWithKeyIDLabel(\"thing4.org\", \"2001:DB8::2\", endpoint.RecordTypeAAAA, \"owner\", \"\", keyId),\n\t\t}\n\n\t\tif i == 0 {\n\t\t\t_ = r.ApplyChanges(ctx, &plan.Changes{\n\t\t\t\tCreate: changes,\n\t\t\t})\n\t\t} else {\n\t\t\t_ = r.ApplyChanges(t.Context(), &plan.Changes{\n\t\t\t\tUpdateNew: changes,\n\t\t\t})\n\t\t}\n\n\t}\n\n\trecords, _ := p.Records(ctx)\n\tassert.Len(t, records, 14)\n\n\tencryptionNonce := map[string]bool{}\n\n\tfor _, r := range records {\n\t\tif slices.Contains([]string{\"A\", \"AAAA\"}, r.RecordType) || (r.RecordType == \"CNAME\" && strings.HasPrefix(r.DNSName, \"new-\")) {\n\t\t\tassert.Contains(t, r.Labels, \"key-id\")\n\t\t\tassert.Equal(t, \"key-id-2\", r.Labels[\"key-id\"])\n\t\t\t// add encryption nonce to track the number of unique nonce\n\t\t\tencryptionNonce[r.Labels[\"txt-encryption-nonce\"]] = true\n\t\t} else if r.RecordType == endpoint.RecordTypeTXT {\n\t\t\tif hasPrefixFromSlice(r.DNSName, []string{\"cname-\", \"txt-new-\", \"a-\", \"aaaa-\", \"txt-\"}) {\n\t\t\t\tassert.NotContains(t, r.Labels, \"key-id\")\n\t\t\t} else {\n\t\t\t\tassert.Contains(t, r.Labels, \"key-id\", r.DNSName)\n\t\t\t\tassert.Equal(t, \"key-id-0\", r.Labels[\"key-id\"], r.DNSName)\n\t\t\t\t// add encryption nonce to track the number of unique nonce\n\t\t\t\tencryptionNonce[r.Labels[\"txt-encryption-nonce\"]] = true\n\t\t\t}\n\t\t}\n\t}\n\tassert.LessOrEqual(t, len(encryptionNonce), 5)\n}\n\nfunc hasPrefixFromSlice(str string, prefixes []string) bool {\n\tfor _, prefix := range prefixes {\n\t\tif strings.HasPrefix(str, prefix) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc newEndpointWithOwnerAndOwnedRecordWithKeyIDLabel(dnsName, target, recordType, ownerID string, resource string, keyId string) *endpoint.Endpoint {\n\te := endpoint.NewEndpoint(dnsName, recordType, target)\n\te.Labels[endpoint.OwnerLabelKey] = ownerID\n\te.Labels[endpoint.ResourceLabelKey] = resource\n\te.Labels[\"key-id\"] = keyId\n\treturn e\n}\n"
  },
  {
    "path": "registry/txt/registry.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage txt\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"maps\"\n\n\t\"strings\"\n\t\"time\"\n\n\tb64 \"encoding/base64\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\t\"sigs.k8s.io/external-dns/registry\"\n\t\"sigs.k8s.io/external-dns/registry/mapper\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n)\n\nconst (\n\tproviderSpecificForceUpdate = \"txt/force-update\"\n)\n\n// TXTRegistry implements registry interface with ownership implemented via associated TXT records\ntype TXTRegistry struct {\n\tprovider provider.Provider\n\townerID  string // refers to the owner id of the current instance\n\tmapper   mapper.NameMapper\n\n\t// cache the records in memory and update on an interval instead.\n\trecordsCache            []*endpoint.Endpoint\n\trecordsCacheRefreshTime time.Time\n\tcacheInterval           time.Duration\n\n\t// optional string to use to replace the asterisk in wildcard entries - without using this,\n\t// registry TXT records corresponding to wildcard records will be invalid (and rejected by most providers), due to\n\t// having a '*' appear (not as the first character) - see https://tools.ietf.org/html/rfc1034#section-4.3.3\n\twildcardReplacement string\n\n\tmanagedRecordTypes []string\n\texcludeRecordTypes []string\n\n\t// encrypt text records\n\ttxtEncryptEnabled bool\n\ttxtEncryptAESKey  []byte\n\n\t// Handle Owner ID migration\n\toldOwnerID string\n\n\t// existingTXTs is the TXT records that already exist in the zone so that\n\t// ApplyChanges() can skip re-creating them. See the struct below for details.\n\texistingTXTs *existingTXTs\n}\n\n// existingTXTs stores pre‑existing TXT records to avoid duplicate creation.\n// It relies on the fact that Records() is always called **before** ApplyChanges()\n// within a single reconciliation cycle.\ntype existingTXTs struct {\n\tentries map[recordKey]struct{}\n}\n\ntype recordKey struct {\n\tdnsName       string\n\tsetIdentifier string\n}\n\nfunc newExistingTXTs() *existingTXTs {\n\treturn &existingTXTs{\n\t\tentries: make(map[recordKey]struct{}),\n\t}\n}\n\nfunc (im *existingTXTs) add(r *endpoint.Endpoint) {\n\tkey := recordKey{\n\t\tdnsName:       r.DNSName,\n\t\tsetIdentifier: r.SetIdentifier,\n\t}\n\tim.entries[key] = struct{}{}\n}\n\n// isAbsent returns true when there is no entry for the given name in the store.\n// This is intended for the \"if absent -> create\" pattern.\nfunc (im *existingTXTs) isAbsent(ep *endpoint.Endpoint) bool {\n\tkey := recordKey{\n\t\tdnsName:       ep.DNSName,\n\t\tsetIdentifier: ep.SetIdentifier,\n\t}\n\t_, ok := im.entries[key]\n\treturn !ok\n}\n\nfunc (im *existingTXTs) reset() {\n\t// Reset the existing TXT records for the next reconciliation loop.\n\t// This is necessary because the existing TXT records are only relevant for the current reconciliation cycle.\n\tim.entries = make(map[recordKey]struct{})\n}\n\n// New creates a TXTRegistry from the given configuration.\nfunc New(cfg *externaldns.Config, p provider.Provider) (registry.Registry, error) {\n\treturn newRegistry(p, cfg.TXTPrefix, cfg.TXTSuffix, cfg.TXTOwnerID,\n\t\tcfg.TXTCacheInterval, cfg.TXTWildcardReplacement,\n\t\tcfg.ManagedDNSRecordTypes, cfg.ExcludeDNSRecordTypes,\n\t\tcfg.TXTEncryptEnabled, []byte(cfg.TXTEncryptAESKey), cfg.TXTOwnerOld)\n}\n\n// newRegistry returns a new TXTRegistry object. When newFormatOnly is true, it will only\n// generate new format TXT records, otherwise it generates both old and new formats for\n// backwards compatibility.\nfunc newRegistry(provider provider.Provider, txtPrefix, txtSuffix, ownerID string,\n\tcacheInterval time.Duration, txtWildcardReplacement string,\n\tmanagedRecordTypes, excludeRecordTypes []string,\n\ttxtEncryptEnabled bool, txtEncryptAESKey []byte,\n\toldOwnerID string) (*TXTRegistry, error) {\n\tif ownerID == \"\" {\n\t\treturn nil, errors.New(\"owner id cannot be empty\")\n\t}\n\n\t// TODO: encryption logic duplicated in DynamoDB registry; refactor into common utility function.\n\tif len(txtEncryptAESKey) == 0 {\n\t\ttxtEncryptAESKey = nil\n\t} else if len(txtEncryptAESKey) != 32 {\n\t\tvar err error\n\t\tif txtEncryptAESKey, err = b64.StdEncoding.DecodeString(string(txtEncryptAESKey)); err != nil || len(txtEncryptAESKey) != 32 {\n\t\t\treturn nil, errors.New(\"the AES Encryption key must be 32 bytes long, in either plain text or base64-encoded format\")\n\t\t}\n\t}\n\n\tif txtEncryptEnabled && txtEncryptAESKey == nil {\n\t\treturn nil, errors.New(\"the AES Encryption key must be set when TXT record encryption is enabled\")\n\t}\n\n\tif len(txtPrefix) > 0 && len(txtSuffix) > 0 {\n\t\treturn nil, errors.New(\"txt-prefix and txt-suffix are mutual exclusive\")\n\t}\n\n\treturn &TXTRegistry{\n\t\tprovider:            provider,\n\t\townerID:             ownerID,\n\t\tmapper:              mapper.NewAffixNameMapper(txtPrefix, txtSuffix, txtWildcardReplacement),\n\t\tcacheInterval:       cacheInterval,\n\t\twildcardReplacement: txtWildcardReplacement,\n\t\tmanagedRecordTypes:  managedRecordTypes,\n\t\texcludeRecordTypes:  excludeRecordTypes,\n\t\ttxtEncryptEnabled:   txtEncryptEnabled,\n\t\ttxtEncryptAESKey:    txtEncryptAESKey,\n\t\toldOwnerID:          oldOwnerID,\n\t\texistingTXTs:        newExistingTXTs(),\n\t}, nil\n}\n\nfunc (im *TXTRegistry) GetDomainFilter() endpoint.DomainFilterInterface {\n\treturn im.provider.GetDomainFilter()\n}\n\nfunc (im *TXTRegistry) OwnerID() string {\n\treturn im.ownerID\n}\n\n// Records returns the current records from the registry excluding TXT Records\n// If TXT records was created previously to indicate ownership its corresponding value\n// will be added to the endpoints Labels map\nfunc (im *TXTRegistry) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {\n\t// existingTXTs must always hold the latest TXT records, so it needs to be reset every time.\n\t// Previously, it was reset with a defer after ApplyChanges, but ApplyChanges is not called\n\t// when plan.HasChanges() is false (i.e., when there are no changes to apply).\n\t// In that case, stale TXT record information could remain, so we reset it here instead.\n\tim.existingTXTs.reset()\n\n\t// If we have the zones cached AND we have refreshed the cache since the\n\t// last given interval, then just use the cached results.\n\tif im.recordsCache != nil && time.Since(im.recordsCacheRefreshTime) < im.cacheInterval {\n\t\tlog.Debug(\"Using cached records.\")\n\t\treturn im.recordsCache, nil\n\t}\n\n\trecords, err := im.provider.Records(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tendpoints := []*endpoint.Endpoint{}\n\n\tlabelMap := map[endpoint.EndpointKey]endpoint.Labels{}\n\ttxtRecordsMap := map[string]struct{}{}\n\n\tfor _, record := range records {\n\t\tif record.RecordType != endpoint.RecordTypeTXT {\n\t\t\tendpoints = append(endpoints, record)\n\t\t\tcontinue\n\t\t}\n\t\t// We simply assume that TXT records for the registry will always have only one target.\n\t\t// If there are no targets (e.g for routing policy based records in google), direct targets will be empty\n\t\tif len(record.Targets) == 0 {\n\t\t\tlog.Errorf(\"TXT record has no targets %s\", record.DNSName)\n\t\t\tcontinue\n\t\t}\n\t\tlabels, err := endpoint.NewLabelsFromString(record.Targets[0], im.txtEncryptAESKey)\n\t\tif errors.Is(err, endpoint.ErrInvalidHeritage) {\n\t\t\t// if no heritage is found or it is invalid\n\t\t\t// case when value of txt record cannot be identified\n\t\t\t// record will not be removed as it will have empty owner\n\t\t\tendpoints = append(endpoints, record)\n\t\t\tcontinue\n\t\t}\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tendpointName, recordType := im.mapper.ToEndpointName(record.DNSName)\n\t\tkey := endpoint.EndpointKey{\n\t\t\tDNSName:       endpointName,\n\t\t\tRecordType:    recordType,\n\t\t\tSetIdentifier: record.SetIdentifier,\n\t\t}\n\t\tlabelMap[key] = labels\n\t\ttxtRecordsMap[record.DNSName] = struct{}{}\n\t\tim.existingTXTs.add(record)\n\t}\n\n\tfor _, ep := range endpoints {\n\t\tif ep.Labels == nil {\n\t\t\tep.Labels = endpoint.NewLabels()\n\t\t}\n\t\tdnsNameSplit := strings.Split(ep.DNSName, \".\")\n\t\t// If specified, replace a leading asterisk in the generated txt record name with some other string\n\t\tif im.wildcardReplacement != \"\" && dnsNameSplit[0] == \"*\" {\n\t\t\tdnsNameSplit[0] = im.wildcardReplacement\n\t\t}\n\t\tdnsName := strings.Join(dnsNameSplit, \".\")\n\t\tkey := endpoint.EndpointKey{\n\t\t\tDNSName:       dnsName,\n\t\t\tRecordType:    ep.RecordType,\n\t\t\tSetIdentifier: ep.SetIdentifier,\n\t\t}\n\n\t\t// AWS Alias records have \"new\" format encoded as type \"cname\"\n\t\tif isAlias, found := ep.GetBoolProviderSpecificProperty(\"alias\"); found && isAlias && ep.RecordType == endpoint.RecordTypeA {\n\t\t\tkey.RecordType = endpoint.RecordTypeCNAME\n\t\t}\n\n\t\t// Handle both new and old registry format with the preference for the new one\n\t\tlabels, labelsExist := labelMap[key]\n\t\tif !labelsExist && ep.RecordType != endpoint.RecordTypeAAAA {\n\t\t\tkey.RecordType = \"\"\n\t\t\tlabels, labelsExist = labelMap[key]\n\t\t}\n\t\tif labelsExist {\n\t\t\tmaps.Copy(ep.Labels, labels)\n\t\t}\n\n\t\tif im.oldOwnerID != \"\" && ep.Labels[endpoint.OwnerLabelKey] == im.oldOwnerID {\n\t\t\tep.Labels[endpoint.OwnerLabelKey] = im.ownerID\n\t\t}\n\n\t\t// TODO: remove this migration logic in some future release\n\t\t// Handle the migration of TXT records created before the new format (introduced in v0.12.0).\n\t\t// The migration is done for the TXT records owned by this instance only.\n\t\tif len(txtRecordsMap) > 0 && ep.Labels[endpoint.OwnerLabelKey] == im.ownerID {\n\t\t\tif plan.IsManagedRecord(ep.RecordType, im.managedRecordTypes, im.excludeRecordTypes) {\n\t\t\t\t// Get desired TXT records and detect the missing ones\n\t\t\t\tdesiredTXTs := im.generateTXTRecord(ep)\n\t\t\t\tfor _, desiredTXT := range desiredTXTs {\n\t\t\t\t\tif _, exists := txtRecordsMap[desiredTXT.DNSName]; !exists {\n\t\t\t\t\t\tep.WithProviderSpecific(providerSpecificForceUpdate, \"true\")\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// Update the cache.\n\tif im.cacheInterval > 0 {\n\t\tim.recordsCache = endpoints\n\t\tim.recordsCacheRefreshTime = time.Now()\n\t}\n\n\treturn endpoints, nil\n}\n\n// generateTXTRecord generates TXT records in either both formats (old and new) or new format only,\n// depending on the newFormatOnly configuration. The old format is maintained for backwards\n// compatibility but can be disabled to reduce the number of DNS records.\nfunc (im *TXTRegistry) generateTXTRecord(r *endpoint.Endpoint) []*endpoint.Endpoint {\n\treturn im.generateTXTRecordWithFilter(r, func(_ *endpoint.Endpoint) bool { return true })\n}\n\nfunc (im *TXTRegistry) generateTXTRecordWithFilter(r *endpoint.Endpoint, filter func(*endpoint.Endpoint) bool) []*endpoint.Endpoint {\n\tendpoints := make([]*endpoint.Endpoint, 0)\n\n\t// Always create new format record\n\trecordType := r.RecordType\n\t// AWS Alias records are encoded as type \"cname\"\n\tif isAlias, found := r.GetBoolProviderSpecificProperty(\"alias\"); found && isAlias && recordType == endpoint.RecordTypeA {\n\t\trecordType = endpoint.RecordTypeCNAME\n\t}\n\n\tif im.oldOwnerID != \"\" && r.Labels[endpoint.OwnerLabelKey] == im.oldOwnerID {\n\t\tr.Labels[endpoint.OwnerLabelKey] = im.ownerID\n\t}\n\n\ttxtNew := endpoint.NewEndpoint(im.mapper.ToTXTName(r.DNSName, recordType), endpoint.RecordTypeTXT, r.Labels.Serialize(true, im.txtEncryptEnabled, im.txtEncryptAESKey))\n\tif txtNew != nil {\n\t\ttxtNew.WithSetIdentifier(r.SetIdentifier)\n\t\ttxtNew.Labels[endpoint.OwnedRecordLabelKey] = r.DNSName\n\t\ttxtNew.ProviderSpecific = r.ProviderSpecific\n\t\tif filter(txtNew) {\n\t\t\tendpoints = append(endpoints, txtNew)\n\t\t}\n\t}\n\treturn endpoints\n}\n\n// ApplyChanges updates dns provider with the changes\n// for each created/deleted record it will also take into account TXT records for creation/deletion\nfunc (im *TXTRegistry) ApplyChanges(ctx context.Context, changes *plan.Changes) error {\n\tfilteredChanges := &plan.Changes{\n\t\tCreate:    changes.Create,\n\t\tUpdateNew: endpoint.FilterEndpointsByOwnerID(im.ownerID, changes.UpdateNew),\n\t\tUpdateOld: endpoint.FilterEndpointsByOwnerID(im.ownerID, changes.UpdateOld),\n\t\tDelete:    endpoint.FilterEndpointsByOwnerID(im.ownerID, changes.Delete),\n\t}\n\n\tfor _, r := range filteredChanges.Create {\n\t\tif r.Labels == nil {\n\t\t\tr.Labels = make(map[string]string)\n\t\t}\n\t\tr.Labels[endpoint.OwnerLabelKey] = im.ownerID\n\n\t\tfilteredChanges.Create = append(filteredChanges.Create, im.generateTXTRecordWithFilter(r, im.existingTXTs.isAbsent)...)\n\n\t\tif im.cacheInterval > 0 {\n\t\t\tim.addToCache(r)\n\t\t}\n\t}\n\n\tfor _, r := range filteredChanges.Delete {\n\t\t// when we delete TXT records for which value has changed (due to new label) this would still work because\n\t\t// !!! TXT record value is uniquely generated from the Labels of the endpoint. Hence old TXT record can be uniquely reconstructed\n\t\t// !!! After migration to the new TXT registry format we can drop records in old format here!!!\n\t\tfilteredChanges.Delete = append(filteredChanges.Delete, im.generateTXTRecord(r)...)\n\n\t\tif im.cacheInterval > 0 {\n\t\t\tim.removeFromCache(r)\n\t\t}\n\t}\n\n\t// make sure TXT records are consistently updated as well\n\tfor _, r := range filteredChanges.UpdateOld {\n\t\t// when we updateOld TXT records for which value has changed (due to new label) this would still work because\n\t\t// !!! TXT record value is uniquely generated from the Labels of the endpoint. Hence old TXT record can be uniquely reconstructed\n\t\tfilteredChanges.UpdateOld = append(filteredChanges.UpdateOld, im.generateTXTRecord(r)...)\n\t\t// remove old version of record from cache\n\t\tif im.cacheInterval > 0 {\n\t\t\tim.removeFromCache(r)\n\t\t}\n\t}\n\n\t// make sure TXT records are consistently updated as well\n\tfor _, r := range filteredChanges.UpdateNew {\n\t\tfilteredChanges.UpdateNew = append(filteredChanges.UpdateNew, im.generateTXTRecord(r)...)\n\t\t// add new version of record to cache\n\t\tif im.cacheInterval > 0 {\n\t\t\tim.addToCache(r)\n\t\t}\n\t}\n\n\t// when caching is enabled, disable the provider from using the cache\n\tif im.cacheInterval > 0 {\n\t\tctx = context.WithValue(ctx, provider.RecordsContextKey, nil)\n\t}\n\treturn im.provider.ApplyChanges(ctx, filteredChanges)\n}\n\n// AdjustEndpoints modifies the endpoints as needed by the specific provider\nfunc (im *TXTRegistry) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) {\n\treturn im.provider.AdjustEndpoints(endpoints)\n}\n\nfunc (im *TXTRegistry) addToCache(ep *endpoint.Endpoint) {\n\tif im.recordsCache != nil {\n\t\tim.recordsCache = append(im.recordsCache, ep)\n\t}\n}\n\nfunc (im *TXTRegistry) removeFromCache(ep *endpoint.Endpoint) {\n\tif im.recordsCache == nil || ep == nil {\n\t\treturn\n\t}\n\n\tfor i, e := range im.recordsCache {\n\t\tif e.DNSName == ep.DNSName && e.RecordType == ep.RecordType && e.SetIdentifier == ep.SetIdentifier && e.Targets.Same(ep.Targets) {\n\t\t\t// We found a match delete the endpoint from the cache.\n\t\t\tim.recordsCache = append(im.recordsCache[:i], im.recordsCache[i+1:]...)\n\t\t\treturn\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "registry/txt/registry_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage txt\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"reflect\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"sigs.k8s.io/external-dns/registry/mapper\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/internal/testutils\"\n\tlogtest \"sigs.k8s.io/external-dns/internal/testutils/log\"\n\t\"sigs.k8s.io/external-dns/plan\"\n\t\"sigs.k8s.io/external-dns/provider\"\n\t\"sigs.k8s.io/external-dns/provider/inmemory\"\n)\n\nconst (\n\ttestZone = \"test-zone.example.org\"\n)\n\nfunc TestTXTRegistry(t *testing.T) {\n\tt.Run(\"TestNewTXTRegistry\", testTXTRegistryNew)\n\tt.Run(\"TestRecords\", testTXTRegistryRecords)\n\tt.Run(\"TestApplyChanges\", testTXTRegistryApplyChanges)\n\tt.Run(\"TestMissingRecords\", testTXTRegistryMissingRecords)\n}\n\nfunc testTXTRegistryNew(t *testing.T) {\n\tp := inmemory.NewInMemoryProvider()\n\t_, err := newRegistry(p, \"txt\", \"\", \"\", time.Hour, \"\", []string{}, []string{}, false, nil, \"\")\n\trequire.Error(t, err)\n\n\t_, err = newRegistry(p, \"\", \"txt\", \"\", time.Hour, \"\", []string{}, []string{}, false, nil, \"\")\n\trequire.Error(t, err)\n\n\tr, err := newRegistry(p, \"txt\", \"\", \"owner\", time.Hour, \"\", []string{}, []string{}, false, nil, \"\")\n\trequire.NoError(t, err)\n\tassert.Equal(t, p, r.provider)\n\n\tr, err = newRegistry(p, \"\", \"txt\", \"owner\", time.Hour, \"\", []string{}, []string{}, false, nil, \"\")\n\trequire.NoError(t, err)\n\n\t_, err = newRegistry(p, \"txt\", \"txt\", \"owner\", time.Hour, \"\", []string{}, []string{}, false, nil, \"\")\n\trequire.Error(t, err)\n\n\t_, ok := r.mapper.(mapper.AffixNameMapper)\n\trequire.True(t, ok)\n\tassert.Equal(t, \"owner\", r.ownerID)\n\tassert.Equal(t, p, r.provider)\n\n\taesKey := []byte(\";k&l)nUC/33:{?d{3)54+,AD?]SX%yh^\")\n\t_, err = newRegistry(p, \"\", \"\", \"owner\", time.Hour, \"\", []string{}, []string{}, false, nil, \"\")\n\trequire.NoError(t, err)\n\n\t_, err = newRegistry(p, \"\", \"\", \"owner\", time.Hour, \"\", []string{}, []string{}, false, aesKey, \"\")\n\trequire.NoError(t, err)\n\n\t_, err = newRegistry(p, \"\", \"\", \"owner\", time.Hour, \"\", []string{}, []string{}, true, nil, \"\")\n\trequire.Error(t, err)\n\n\tr, err = newRegistry(p, \"\", \"\", \"owner\", time.Hour, \"\", []string{}, []string{}, true, aesKey, \"\")\n\trequire.NoError(t, err)\n\n\t_, ok = r.mapper.(mapper.AffixNameMapper)\n\tassert.True(t, ok)\n}\n\nfunc testTXTRegistryRecords(t *testing.T) {\n\tt.Run(\"With prefix\", testTXTRegistryRecordsPrefixed)\n\tt.Run(\"With suffix\", testTXTRegistryRecordsSuffixed)\n\tt.Run(\"No prefix\", testTXTRegistryRecordsNoPrefix)\n\tt.Run(\"With templated prefix\", testTXTRegistryRecordsPrefixedTemplated)\n\tt.Run(\"With templated suffix\", testTXTRegistryRecordsSuffixedTemplated)\n}\n\nfunc testTXTRegistryRecordsPrefixed(t *testing.T) {\n\tctx := t.Context()\n\tp := inmemory.NewInMemoryProvider()\n\terr := p.CreateZone(testZone)\n\trequire.NoError(t, err)\n\terr = p.ApplyChanges(ctx, &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwnerAndLabels(\"foo.test-zone.example.org\", \"foo.loadbalancer.com\", endpoint.RecordTypeCNAME, \"\", endpoint.Labels{\"foo\": \"somefoo\"}),\n\t\t\tnewEndpointWithOwnerAndLabels(\"bar.test-zone.example.org\", \"my-domain.com\", endpoint.RecordTypeCNAME, \"\", endpoint.Labels{\"bar\": \"somebar\"}),\n\t\t\tnewEndpointWithOwner(\"txt.bar.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"txt.bar.test-zone.example.org\", \"baz.test-zone.example.org\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewEndpointWithOwner(\"qux.test-zone.example.org\", \"random\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwnerAndLabels(\"tar.test-zone.example.org\", \"tar.loadbalancer.com\", endpoint.RecordTypeCNAME, \"\", endpoint.Labels{\"tar\": \"sometar\"}),\n\t\t\tnewEndpointWithOwner(\"TxT.tar.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner-2\\\"\", endpoint.RecordTypeTXT, \"\"), // case-insensitive TXT prefix\n\t\t\tnewEndpointWithOwner(\"foobar.test-zone.example.org\", \"foobar.loadbalancer.com\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewEndpointWithOwner(\"foobar.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"multiple.test-zone.example.org\", \"lb1.loadbalancer.com\", endpoint.RecordTypeCNAME, \"\").WithSetIdentifier(\"test-set-1\"),\n\t\t\tnewEndpointWithOwner(\"multiple.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\").WithSetIdentifier(\"test-set-1\"),\n\t\t\tnewEndpointWithOwner(\"multiple.test-zone.example.org\", \"lb2.loadbalancer.com\", endpoint.RecordTypeCNAME, \"\").WithSetIdentifier(\"test-set-2\"),\n\t\t\tnewEndpointWithOwner(\"multiple.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\").WithSetIdentifier(\"test-set-2\"),\n\t\t\tnewEndpointWithOwner(\"*.wildcard.test-zone.example.org\", \"foo.loadbalancer.com\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewEndpointWithOwner(\"txt.wc.wildcard.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"dualstack.test-zone.example.org\", \"1.1.1.1\", endpoint.RecordTypeA, \"\"),\n\t\t\tnewEndpointWithOwner(\"txt.dualstack.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"dualstack.test-zone.example.org\", \"2001:DB8::1\", endpoint.RecordTypeAAAA, \"\"),\n\t\t\tnewEndpointWithOwner(\"txt.aaaa-dualstack.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner-2\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"mail.test-zone.example.org\", \"10 onemail.example.com\", endpoint.RecordTypeMX, \"\"),\n\t\t\tnewEndpointWithOwner(\"txt.mx-mail.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewMultiTargetEndpointWithOwner(\n\t\t\t\t\"_sip._udp.sip1.test-zone.example.org\",\n\t\t\t\t[]string{\"1 50 5060 sip1-n1.test-zone.example.org\", \"1 50 5060 sip1-n2.test-zone.example.org\"},\n\t\t\t\tendpoint.RecordTypeSRV,\n\t\t\t\t\"\",\n\t\t\t),\n\t\t\tnewEndpointWithOwner(\"txt._sip._udp.sip1.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"sip1.test-zone.example.org\", `10 \"U\" \"SIP+DTU\" \"\" _sip._udp.sip1.test-zone.example.org.`, endpoint.RecordTypeNAPTR, \"\"),\n\t\t\tnewEndpointWithOwner(\"txt.sip1.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t},\n\t})\n\trequire.NoError(t, err)\n\texpectedRecords := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"foo.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"foo.loadbalancer.com\"},\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"\",\n\t\t\t\t\"foo\":                  \"somefoo\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"bar.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"my-domain.com\"},\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"owner\",\n\t\t\t\t\"bar\":                  \"somebar\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"txt.bar.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"baz.test-zone.example.org\"},\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"qux.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"random\"},\n\t\t\tRecordType: endpoint.RecordTypeTXT,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"tar.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"tar.loadbalancer.com\"},\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"owner-2\",\n\t\t\t\t\"tar\":                  \"sometar\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"foobar.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"foobar.loadbalancer.com\"},\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:       \"multiple.test-zone.example.org\",\n\t\t\tTargets:       endpoint.Targets{\"lb1.loadbalancer.com\"},\n\t\t\tSetIdentifier: \"test-set-1\",\n\t\t\tRecordType:    endpoint.RecordTypeCNAME,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:       \"multiple.test-zone.example.org\",\n\t\t\tTargets:       endpoint.Targets{\"lb2.loadbalancer.com\"},\n\t\t\tSetIdentifier: \"test-set-2\",\n\t\t\tRecordType:    endpoint.RecordTypeCNAME,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"*.wildcard.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"foo.loadbalancer.com\"},\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"owner\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"dualstack.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"1.1.1.1\"},\n\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"owner\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"dualstack.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"2001:DB8::1\"},\n\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"owner-2\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"mail.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"10 onemail.example.com\"},\n\t\t\tRecordType: endpoint.RecordTypeMX,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"owner\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName: \"_sip._udp.sip1.test-zone.example.org\",\n\t\t\tTargets: endpoint.Targets{\n\t\t\t\t\"1 50 5060 sip1-n1.test-zone.example.org\",\n\t\t\t\t\"1 50 5060 sip1-n2.test-zone.example.org\",\n\t\t\t},\n\t\t\tRecordType: endpoint.RecordTypeSRV,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"owner\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"sip1.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{`10 \"U\" \"SIP+DTU\" \"\" _sip._udp.sip1.test-zone.example.org.`},\n\t\t\tRecordType: endpoint.RecordTypeNAPTR,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"owner\",\n\t\t\t},\n\t\t},\n\t}\n\n\tr, _ := newRegistry(p, \"txt.\", \"\", \"owner\", time.Hour, \"wc\", []string{}, []string{}, false, nil, \"\")\n\trecords, _ := r.Records(ctx)\n\n\tassert.True(t, testutils.SameEndpoints(records, expectedRecords))\n\n\t// Ensure prefix is case-insensitive\n\tr, _ = newRegistry(p, \"TxT.\", \"\", \"owner\", time.Hour, \"wc\", []string{}, []string{}, false, nil, \"\")\n\trecords, _ = r.Records(ctx)\n\n\tassert.True(t, testutils.SameEndpoints(records, expectedRecords))\n}\n\nfunc testTXTRegistryRecordsSuffixed(t *testing.T) {\n\tctx := t.Context()\n\tp := inmemory.NewInMemoryProvider()\n\terr := p.CreateZone(testZone)\n\trequire.NoError(t, err)\n\terr = p.ApplyChanges(ctx, &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwnerAndLabels(\"foo.test-zone.example.org\", \"foo.loadbalancer.com\", endpoint.RecordTypeCNAME, \"\", endpoint.Labels{\"foo\": \"somefoo\"}),\n\t\t\tnewEndpointWithOwnerAndLabels(\"bar.test-zone.example.org\", \"my-domain.com\", endpoint.RecordTypeCNAME, \"\", endpoint.Labels{\"bar\": \"somebar\"}),\n\t\t\tnewEndpointWithOwner(\"bar-txt.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"bar-txt.test-zone.example.org\", \"baz.test-zone.example.org\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewEndpointWithOwner(\"qux.test-zone.example.org\", \"random\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwnerAndLabels(\"tar.test-zone.example.org\", \"tar.loadbalancer.com\", endpoint.RecordTypeCNAME, \"\", endpoint.Labels{\"tar\": \"sometar\"}),\n\t\t\tnewEndpointWithOwner(\"tar-TxT.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner-2\\\"\", endpoint.RecordTypeTXT, \"\"), // case-insensitive TXT prefix\n\t\t\tnewEndpointWithOwner(\"foobar.test-zone.example.org\", \"foobar.loadbalancer.com\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewEndpointWithOwner(\"foobar.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"multiple.test-zone.example.org\", \"lb1.loadbalancer.com\", endpoint.RecordTypeCNAME, \"\").WithSetIdentifier(\"test-set-1\"),\n\t\t\tnewEndpointWithOwner(\"multiple.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\").WithSetIdentifier(\"test-set-1\"),\n\t\t\tnewEndpointWithOwner(\"multiple.test-zone.example.org\", \"lb2.loadbalancer.com\", endpoint.RecordTypeCNAME, \"\").WithSetIdentifier(\"test-set-2\"),\n\t\t\tnewEndpointWithOwner(\"multiple.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\").WithSetIdentifier(\"test-set-2\"),\n\t\t\tnewEndpointWithOwner(\"dualstack.test-zone.example.org\", \"1.1.1.1\", endpoint.RecordTypeA, \"\"),\n\t\t\tnewEndpointWithOwner(\"dualstack-txt.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"dualstack.test-zone.example.org\", \"2001:DB8::1\", endpoint.RecordTypeAAAA, \"\"),\n\t\t\tnewEndpointWithOwner(\"aaaa-dualstack-txt.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner-2\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"mail.test-zone.example.org\", \"10 onemail.example.com\", endpoint.RecordTypeMX, \"\"),\n\t\t\tnewEndpointWithOwner(\"mx-mail-txt.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewMultiTargetEndpointWithOwner(\n\t\t\t\t\"_sip._udp.sip1.test-zone.example.org\",\n\t\t\t\t[]string{\"1 50 5060 sip1-n1.test-zone.example.org\", \"1 50 5060 sip1-n2.test-zone.example.org\"},\n\t\t\t\tendpoint.RecordTypeSRV,\n\t\t\t\t\"\",\n\t\t\t),\n\t\t\tnewEndpointWithOwner(\"_sip-txt._udp.sip1.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"sip1.test-zone.example.org\", `10 \"U\" \"SIP+DTU\" \"\" _sip._udp.sip1.test-zone.example.org.`, endpoint.RecordTypeNAPTR, \"\"),\n\t\t\tnewEndpointWithOwner(\"sip1-txt.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t},\n\t})\n\trequire.NoError(t, err)\n\texpectedRecords := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"foo.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"foo.loadbalancer.com\"},\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"\",\n\t\t\t\t\"foo\":                  \"somefoo\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"bar.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"my-domain.com\"},\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"owner\",\n\t\t\t\t\"bar\":                  \"somebar\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"bar-txt.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"baz.test-zone.example.org\"},\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"qux.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"random\"},\n\t\t\tRecordType: endpoint.RecordTypeTXT,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"tar.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"tar.loadbalancer.com\"},\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"owner-2\",\n\t\t\t\t\"tar\":                  \"sometar\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"foobar.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"foobar.loadbalancer.com\"},\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:       \"multiple.test-zone.example.org\",\n\t\t\tTargets:       endpoint.Targets{\"lb1.loadbalancer.com\"},\n\t\t\tSetIdentifier: \"test-set-1\",\n\t\t\tRecordType:    endpoint.RecordTypeCNAME,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:       \"multiple.test-zone.example.org\",\n\t\t\tTargets:       endpoint.Targets{\"lb2.loadbalancer.com\"},\n\t\t\tSetIdentifier: \"test-set-2\",\n\t\t\tRecordType:    endpoint.RecordTypeCNAME,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"dualstack.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"1.1.1.1\"},\n\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"owner\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"dualstack.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"2001:DB8::1\"},\n\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"owner-2\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"mail.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"10 onemail.example.com\"},\n\t\t\tRecordType: endpoint.RecordTypeMX,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"owner\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName: \"_sip._udp.sip1.test-zone.example.org\",\n\t\t\tTargets: endpoint.Targets{\n\t\t\t\t\"1 50 5060 sip1-n1.test-zone.example.org\",\n\t\t\t\t\"1 50 5060 sip1-n2.test-zone.example.org\",\n\t\t\t},\n\t\t\tRecordType: endpoint.RecordTypeSRV,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"owner\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"sip1.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{`10 \"U\" \"SIP+DTU\" \"\" _sip._udp.sip1.test-zone.example.org.`},\n\t\t\tRecordType: endpoint.RecordTypeNAPTR,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"owner\",\n\t\t\t},\n\t\t},\n\t}\n\n\tr, _ := newRegistry(p, \"\", \"-txt\", \"owner\", time.Hour, \"\", []string{}, []string{}, false, nil, \"\")\n\trecords, _ := r.Records(ctx)\n\n\tassert.True(t, testutils.SameEndpoints(records, expectedRecords))\n\n\t// Ensure prefix is case-insensitive\n\tr, _ = newRegistry(p, \"\", \"-TxT\", \"owner\", time.Hour, \"\", []string{}, []string{}, false, nil, \"\")\n\trecords, _ = r.Records(ctx)\n\n\tassert.True(t, testutils.SameEndpointLabels(records, expectedRecords))\n}\n\nfunc testTXTRegistryRecordsNoPrefix(t *testing.T) {\n\tp := inmemory.NewInMemoryProvider()\n\tctx := t.Context()\n\terr := p.CreateZone(testZone)\n\trequire.NoError(t, err)\n\terr = p.ApplyChanges(ctx, &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwner(\"foo.test-zone.example.org\", \"foo.loadbalancer.com\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewEndpointWithOwner(\"bar.test-zone.example.org\", \"my-domain.com\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewEndpointWithOwner(\"alias.test-zone.example.org\", \"my-domain.com\", endpoint.RecordTypeA, \"\").WithProviderSpecific(\"alias\", \"true\"),\n\t\t\tnewEndpointWithOwner(\"cname-alias.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"txt.bar.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"txt.bar.test-zone.example.org\", \"baz.test-zone.example.org\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewEndpointWithOwner(\"qux.test-zone.example.org\", \"random\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"tar.test-zone.example.org\", \"tar.loadbalancer.com\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewEndpointWithOwner(\"txt.tar.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner-2\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"foobar.test-zone.example.org\", \"foobar.loadbalancer.com\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewEndpointWithOwner(\"foobar.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"dualstack.test-zone.example.org\", \"1.1.1.1\", endpoint.RecordTypeA, \"\"),\n\t\t\tnewEndpointWithOwner(\"dualstack.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"dualstack.test-zone.example.org\", \"2001:DB8::1\", endpoint.RecordTypeAAAA, \"\"),\n\t\t\tnewEndpointWithOwner(\"aaaa-dualstack.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner-2\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"mail.test-zone.example.org\", \"10 onemail.example.com\", endpoint.RecordTypeMX, \"\"),\n\t\t\tnewEndpointWithOwner(\"mx-mail.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewMultiTargetEndpointWithOwner(\n\t\t\t\t\"_sip._udp.sip1.test-zone.example.org\",\n\t\t\t\t[]string{\"1 50 5060 sip1-n1.test-zone.example.org\", \"1 50 5060 sip1-n2.test-zone.example.org\"},\n\t\t\t\tendpoint.RecordTypeSRV,\n\t\t\t\t\"\",\n\t\t\t),\n\t\t\tnewEndpointWithOwner(\"_sip._udp.sip1.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"sip1.test-zone.example.org\", `10 \"U\" \"SIP+DTU\" \"\" _sip._udp.sip1.test-zone.example.org.`, endpoint.RecordTypeNAPTR, \"\"),\n\t\t\tnewEndpointWithOwner(\"sip1.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t},\n\t})\n\trequire.NoError(t, err)\n\texpectedRecords := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"foo.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"foo.loadbalancer.com\"},\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"bar.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"my-domain.com\"},\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"alias.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"my-domain.com\"},\n\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"owner\",\n\t\t\t},\n\t\t\tProviderSpecific: []endpoint.ProviderSpecificProperty{\n\t\t\t\t{\n\t\t\t\t\tName:  \"alias\",\n\t\t\t\t\tValue: \"true\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"txt.bar.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"baz.test-zone.example.org\"},\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey:    \"owner\",\n\t\t\t\tendpoint.ResourceLabelKey: \"ingress/default/my-ingress\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"qux.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"random\"},\n\t\t\tRecordType: endpoint.RecordTypeTXT,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"tar.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"tar.loadbalancer.com\"},\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"foobar.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"foobar.loadbalancer.com\"},\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"owner\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"dualstack.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"1.1.1.1\"},\n\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"owner\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"dualstack.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"2001:DB8::1\"},\n\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"owner-2\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"mail.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"10 onemail.example.com\"},\n\t\t\tRecordType: endpoint.RecordTypeMX,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"owner\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName: \"_sip._udp.sip1.test-zone.example.org\",\n\t\t\tTargets: endpoint.Targets{\n\t\t\t\t\"1 50 5060 sip1-n1.test-zone.example.org\",\n\t\t\t\t\"1 50 5060 sip1-n2.test-zone.example.org\",\n\t\t\t},\n\t\t\tRecordType: endpoint.RecordTypeSRV,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"owner\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"sip1.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{`10 \"U\" \"SIP+DTU\" \"\" _sip._udp.sip1.test-zone.example.org.`},\n\t\t\tRecordType: endpoint.RecordTypeNAPTR,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"owner\",\n\t\t\t},\n\t\t},\n\t}\n\n\tr, _ := newRegistry(p, \"\", \"\", \"owner\", time.Hour, \"\", []string{}, []string{}, false, nil, \"\")\n\trecords, _ := r.Records(ctx)\n\n\tassert.True(t, testutils.SameEndpoints(records, expectedRecords))\n}\n\nfunc testTXTRegistryRecordsPrefixedTemplated(t *testing.T) {\n\tctx := t.Context()\n\tp := inmemory.NewInMemoryProvider()\n\terr := p.CreateZone(testZone)\n\trequire.NoError(t, err)\n\terr = p.ApplyChanges(ctx, &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwner(\"foo.test-zone.example.org\", \"1.1.1.1\", endpoint.RecordTypeA, \"\"),\n\t\t\tnewEndpointWithOwner(\"txt-a.foo.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"mail.test-zone.example.org\", \"10 onemail.example.com\", endpoint.RecordTypeMX, \"\"),\n\t\t\tnewEndpointWithOwner(\"txt-mx.mail.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t},\n\t})\n\trequire.NoError(t, err)\n\texpectedRecords := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"foo.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"1.1.1.1\"},\n\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"owner\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"mail.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"10 onemail.example.com\"},\n\t\t\tRecordType: endpoint.RecordTypeMX,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"owner\",\n\t\t\t},\n\t\t},\n\t}\n\n\tr, _ := newRegistry(p, \"txt-%{record_type}.\", \"\", \"owner\", time.Hour, \"wc\", []string{}, []string{}, false, nil, \"\")\n\trecords, _ := r.Records(ctx)\n\n\tassert.True(t, testutils.SameEndpoints(records, expectedRecords))\n\n\tr, _ = newRegistry(p, \"TxT-%{record_type}.\", \"\", \"owner\", time.Hour, \"wc\", []string{}, []string{}, false, nil, \"\")\n\trecords, _ = r.Records(ctx)\n\n\tassert.True(t, testutils.SameEndpoints(records, expectedRecords))\n}\n\nfunc testTXTRegistryRecordsSuffixedTemplated(t *testing.T) {\n\tctx := t.Context()\n\tp := inmemory.NewInMemoryProvider()\n\terr := p.CreateZone(testZone)\n\trequire.NoError(t, err)\n\terr = p.ApplyChanges(ctx, &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwner(\"bar.test-zone.example.org\", \"8.8.8.8\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewEndpointWithOwner(\"bartxtcname.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"mail.test-zone.example.org\", \"10 onemail.example.com\", endpoint.RecordTypeMX, \"\"),\n\t\t\tnewEndpointWithOwner(\"mailtxt.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t},\n\t})\n\trequire.NoError(t, err)\n\texpectedRecords := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"bar.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"owner\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"mail.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"10 onemail.example.com\"},\n\t\t\tRecordType: endpoint.RecordTypeMX,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"owner\",\n\t\t\t},\n\t\t},\n\t}\n\n\tr, _ := newRegistry(p, \"\", \"txt%{record_type}\", \"owner\", time.Hour, \"wc\", []string{}, []string{}, false, nil, \"\")\n\trecords, _ := r.Records(ctx)\n\n\tassert.True(t, testutils.SameEndpoints(records, expectedRecords))\n\n\tr, _ = newRegistry(p, \"\", \"TxT%{record_type}\", \"owner\", time.Hour, \"wc\", []string{}, []string{}, false, nil, \"\")\n\trecords, _ = r.Records(ctx)\n\n\tassert.True(t, testutils.SameEndpoints(records, expectedRecords))\n}\n\nfunc testTXTRegistryApplyChanges(t *testing.T) {\n\tt.Run(\"With Prefix\", testTXTRegistryApplyChangesWithPrefix)\n\tt.Run(\"With Templated Prefix\", testTXTRegistryApplyChangesWithTemplatedPrefix)\n\tt.Run(\"With Templated Suffix\", testTXTRegistryApplyChangesWithTemplatedSuffix)\n\tt.Run(\"With Suffix\", testTXTRegistryApplyChangesWithSuffix)\n\tt.Run(\"No prefix\", testTXTRegistryApplyChangesNoPrefix)\n}\n\nfunc testTXTRegistryApplyChangesWithPrefix(t *testing.T) {\n\tp := inmemory.NewInMemoryProvider()\n\t_ = p.CreateZone(testZone)\n\tvar ctxEndpoints []*endpoint.Endpoint\n\tctx := context.WithValue(t.Context(), provider.RecordsContextKey, ctxEndpoints)\n\tp.OnApplyChanges = func(ctx context.Context, _ *plan.Changes) {\n\t\tassert.Equal(t, ctxEndpoints, ctx.Value(provider.RecordsContextKey))\n\t}\n\t_ = p.ApplyChanges(ctx, &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwner(\"foo.test-zone.example.org\", \"foo.loadbalancer.com\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewEndpointWithOwner(\"bar.test-zone.example.org\", \"my-domain.com\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewEndpointWithOwner(\"txt.bar.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"txt.bar.test-zone.example.org\", \"baz.test-zone.example.org\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewEndpointWithOwner(\"qux.test-zone.example.org\", \"random\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"tar.test-zone.example.org\", \"tar.loadbalancer.com\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewEndpointWithOwner(\"txt.tar.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"txt.cname-tar.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"foobar.test-zone.example.org\", \"foobar.loadbalancer.com\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewEndpointWithOwner(\"txt.foobar.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"txt.cname-foobar.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"multiple.test-zone.example.org\", \"lb1.loadbalancer.com\", endpoint.RecordTypeCNAME, \"\").WithSetIdentifier(\"test-set-1\"),\n\t\t\tnewEndpointWithOwner(\"txt.multiple.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\").WithSetIdentifier(\"test-set-1\"),\n\t\t\tnewEndpointWithOwner(\"txt.cname-multiple.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\").WithSetIdentifier(\"test-set-1\"),\n\t\t\tnewEndpointWithOwner(\"multiple.test-zone.example.org\", \"lb2.loadbalancer.com\", endpoint.RecordTypeCNAME, \"\").WithSetIdentifier(\"test-set-2\"),\n\t\t\tnewEndpointWithOwner(\"txt.multiple.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\").WithSetIdentifier(\"test-set-2\"),\n\t\t\tnewEndpointWithOwner(\"txt.cname-multiple.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\").WithSetIdentifier(\"test-set-2\"),\n\t\t},\n\t})\n\tr, _ := newRegistry(p, \"txt.\", \"\", \"owner\", time.Hour, \"\", []string{}, []string{}, false, nil, \"\")\n\n\tchanges := &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\tnewCNAMEEndpointWithOwnerResource(\"new-record-1.test-zone.example.org\", \"new-loadbalancer-1.lb.com\", \"owner\", \"ingress/default/my-ingress\"),\n\t\t\tnewCNAMEEndpointWithOwnerResource(\"multiple.test-zone.example.org\", \"lb3.loadbalancer.com\", \"owner\", \"ingress/default/my-ingress\").WithSetIdentifier(\"test-set-3\"),\n\t\t\tnewCNAMEEndpointWithOwnerResource(\"example\", \"new-loadbalancer-1.lb.com\", \"owner\", \"ingress/default/my-ingress\"),\n\t\t},\n\t\tDelete: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwner(\"foobar.test-zone.example.org\", \"foobar.loadbalancer.com\", endpoint.RecordTypeCNAME, \"owner\"),\n\t\t\tnewEndpointWithOwner(\"multiple.test-zone.example.org\", \"lb1.loadbalancer.com\", endpoint.RecordTypeCNAME, \"owner\").WithSetIdentifier(\"test-set-1\"),\n\t\t},\n\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\tnewCNAMEEndpointWithOwnerResource(\"tar.test-zone.example.org\", \"new-tar.loadbalancer.com\", \"owner\", \"ingress/default/my-ingress-2\"),\n\t\t\tnewCNAMEEndpointWithOwnerResource(\"multiple.test-zone.example.org\", \"new.loadbalancer.com\", \"owner\", \"ingress/default/my-ingress-2\").WithSetIdentifier(\"test-set-2\"),\n\t\t},\n\t\tUpdateOld: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwner(\"tar.test-zone.example.org\", \"tar.loadbalancer.com\", endpoint.RecordTypeCNAME, \"owner\"),\n\t\t\tnewEndpointWithOwner(\"multiple.test-zone.example.org\", \"lb2.loadbalancer.com\", endpoint.RecordTypeCNAME, \"owner\").WithSetIdentifier(\"test-set-2\"),\n\t\t},\n\t}\n\texpected := &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\tnewCNAMEEndpointWithOwnerResource(\"new-record-1.test-zone.example.org\", \"new-loadbalancer-1.lb.com\", \"owner\", \"ingress/default/my-ingress\"),\n\t\t\tnewTXTEndpointWithOwnedRecord(\"txt.cname-new-record-1.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress\\\"\", \"new-record-1.test-zone.example.org\"),\n\t\t\tnewCNAMEEndpointWithOwnerResource(\"multiple.test-zone.example.org\", \"lb3.loadbalancer.com\", \"owner\", \"ingress/default/my-ingress\").WithSetIdentifier(\"test-set-3\"),\n\t\t\tnewTXTEndpointWithOwnedRecord(\"txt.cname-multiple.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress\\\"\", \"multiple.test-zone.example.org\").WithSetIdentifier(\"test-set-3\"),\n\t\t\tnewCNAMEEndpointWithOwnerResource(\"example\", \"new-loadbalancer-1.lb.com\", \"owner\", \"ingress/default/my-ingress\"),\n\t\t\tnewTXTEndpointWithOwnedRecord(\"txt.cname-example\", \"\\\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress\\\"\", \"example\"),\n\t\t},\n\t\tDelete: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwner(\"foobar.test-zone.example.org\", \"foobar.loadbalancer.com\", endpoint.RecordTypeCNAME, \"owner\"),\n\t\t\tnewTXTEndpointWithOwnedRecord(\"txt.cname-foobar.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", \"foobar.test-zone.example.org\"),\n\t\t\tnewEndpointWithOwner(\"multiple.test-zone.example.org\", \"lb1.loadbalancer.com\", endpoint.RecordTypeCNAME, \"owner\").WithSetIdentifier(\"test-set-1\"),\n\t\t\tnewTXTEndpointWithOwnedRecord(\"txt.cname-multiple.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", \"multiple.test-zone.example.org\").WithSetIdentifier(\"test-set-1\"),\n\t\t},\n\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\tnewCNAMEEndpointWithOwnerResource(\"tar.test-zone.example.org\", \"new-tar.loadbalancer.com\", \"owner\", \"ingress/default/my-ingress-2\"),\n\t\t\tnewTXTEndpointWithOwnedRecord(\"txt.cname-tar.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress-2\\\"\", \"tar.test-zone.example.org\"),\n\t\t\tnewCNAMEEndpointWithOwnerResource(\"multiple.test-zone.example.org\", \"new.loadbalancer.com\", \"owner\", \"ingress/default/my-ingress-2\").WithSetIdentifier(\"test-set-2\"),\n\t\t\tnewTXTEndpointWithOwnedRecord(\"txt.cname-multiple.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress-2\\\"\", \"multiple.test-zone.example.org\").WithSetIdentifier(\"test-set-2\"),\n\t\t},\n\t\tUpdateOld: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwner(\"tar.test-zone.example.org\", \"tar.loadbalancer.com\", endpoint.RecordTypeCNAME, \"owner\"),\n\t\t\tnewTXTEndpointWithOwnedRecord(\"txt.cname-tar.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", \"tar.test-zone.example.org\"),\n\t\t\tnewEndpointWithOwner(\"multiple.test-zone.example.org\", \"lb2.loadbalancer.com\", endpoint.RecordTypeCNAME, \"owner\").WithSetIdentifier(\"test-set-2\"),\n\t\t\tnewTXTEndpointWithOwnedRecord(\"txt.cname-multiple.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", \"multiple.test-zone.example.org\").WithSetIdentifier(\"test-set-2\"),\n\t\t},\n\t}\n\tp.OnApplyChanges = func(ctx context.Context, got *plan.Changes) {\n\t\tmExpected := map[string][]*endpoint.Endpoint{\n\t\t\t\"Create\":    expected.Create,\n\t\t\t\"UpdateNew\": expected.UpdateNew,\n\t\t\t\"UpdateOld\": expected.UpdateOld,\n\t\t\t\"Delete\":    expected.Delete,\n\t\t}\n\t\tmGot := map[string][]*endpoint.Endpoint{\n\t\t\t\"Create\":    got.Create,\n\t\t\t\"UpdateNew\": got.UpdateNew,\n\t\t\t\"UpdateOld\": got.UpdateOld,\n\t\t\t\"Delete\":    got.Delete,\n\t\t}\n\t\tassert.True(t, testutils.SamePlanChanges(mGot, mExpected))\n\t\tassert.Nil(t, ctx.Value(provider.RecordsContextKey))\n\t}\n\terr := r.ApplyChanges(ctx, changes)\n\trequire.NoError(t, err)\n}\n\nfunc testTXTRegistryApplyChangesWithTemplatedPrefix(t *testing.T) {\n\tp := inmemory.NewInMemoryProvider()\n\terr := p.CreateZone(testZone)\n\trequire.NoError(t, err)\n\tvar ctxEndpoints []*endpoint.Endpoint\n\tctx := context.WithValue(t.Context(), provider.RecordsContextKey, ctxEndpoints)\n\tp.OnApplyChanges = func(ctx context.Context, _ *plan.Changes) {\n\t\tassert.Equal(t, ctxEndpoints, ctx.Value(provider.RecordsContextKey))\n\t}\n\t_ = p.ApplyChanges(ctx, &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{},\n\t})\n\tr, _ := newRegistry(p, \"prefix%{record_type}.\", \"\", \"owner-1\", time.Hour, \"\", []string{}, []string{}, false, nil, \"\")\n\tchanges := &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\tnewCNAMEEndpointWithOwnerResource(\"new-record-1.test-zone.example.org\", \"new-loadbalancer-1.lb.com\", \"owner-1\", \"ingress/default/my-ingress\"),\n\t\t},\n\t\tDelete:    []*endpoint.Endpoint{},\n\t\tUpdateOld: []*endpoint.Endpoint{},\n\t\tUpdateNew: []*endpoint.Endpoint{},\n\t}\n\texpected := &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\tnewCNAMEEndpointWithOwnerResource(\"new-record-1.test-zone.example.org\", \"new-loadbalancer-1.lb.com\", \"owner-1\", \"ingress/default/my-ingress\"),\n\t\t\tnewTXTEndpointWithOwnedRecord(\"prefixcname.new-record-1.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner-1,external-dns/resource=ingress/default/my-ingress\\\"\", \"new-record-1.test-zone.example.org\"),\n\t\t},\n\t}\n\tp.OnApplyChanges = func(ctx context.Context, got *plan.Changes) {\n\t\tmExpected := map[string][]*endpoint.Endpoint{\n\t\t\t\"Create\":    expected.Create,\n\t\t\t\"UpdateNew\": expected.UpdateNew,\n\t\t\t\"UpdateOld\": expected.UpdateOld,\n\t\t\t\"Delete\":    expected.Delete,\n\t\t}\n\t\tmGot := map[string][]*endpoint.Endpoint{\n\t\t\t\"Create\":    got.Create,\n\t\t\t\"UpdateNew\": got.UpdateNew,\n\t\t\t\"UpdateOld\": got.UpdateOld,\n\t\t\t\"Delete\":    got.Delete,\n\t\t}\n\t\tassert.True(t, testutils.SamePlanChanges(mGot, mExpected))\n\t\tassert.Nil(t, ctx.Value(provider.RecordsContextKey))\n\t}\n\terr = r.ApplyChanges(ctx, changes)\n\trequire.NoError(t, err)\n}\n\nfunc testTXTRegistryApplyChangesWithTemplatedSuffix(t *testing.T) {\n\tp := inmemory.NewInMemoryProvider()\n\t_ = p.CreateZone(testZone)\n\tvar ctxEndpoints []*endpoint.Endpoint\n\tctx := context.WithValue(t.Context(), provider.RecordsContextKey, ctxEndpoints)\n\tp.OnApplyChanges = func(ctx context.Context, _ *plan.Changes) {\n\t\tassert.Equal(t, ctxEndpoints, ctx.Value(provider.RecordsContextKey))\n\t}\n\tr, _ := newRegistry(p, \"\", \"-%{record_type}suffix\", \"owner-2\", time.Hour, \"\", []string{}, []string{}, false, nil, \"\")\n\tchanges := &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\tnewCNAMEEndpointWithOwnerResource(\"new-record-1.test-zone.example.org\", \"new-loadbalancer-1.lb.com\", \"owner-2\", \"ingress/default/my-ingress\"),\n\t\t},\n\t\tDelete:    []*endpoint.Endpoint{},\n\t\tUpdateOld: []*endpoint.Endpoint{},\n\t\tUpdateNew: []*endpoint.Endpoint{},\n\t}\n\texpected := &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\tnewCNAMEEndpointWithOwnerResource(\"new-record-1.test-zone.example.org\", \"new-loadbalancer-1.lb.com\", \"owner-2\", \"ingress/default/my-ingress\"),\n\t\t\tnewTXTEndpointWithOwnedRecord(\"new-record-1-cnamesuffix.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner-2,external-dns/resource=ingress/default/my-ingress\\\"\", \"new-record-1.test-zone.example.org\"),\n\t\t},\n\t}\n\tp.OnApplyChanges = func(ctx context.Context, got *plan.Changes) {\n\t\tmExpected := map[string][]*endpoint.Endpoint{\n\t\t\t\"Create\":    expected.Create,\n\t\t\t\"UpdateNew\": expected.UpdateNew,\n\t\t\t\"UpdateOld\": expected.UpdateOld,\n\t\t\t\"Delete\":    expected.Delete,\n\t\t}\n\t\tmGot := map[string][]*endpoint.Endpoint{\n\t\t\t\"Create\":    got.Create,\n\t\t\t\"UpdateNew\": got.UpdateNew,\n\t\t\t\"UpdateOld\": got.UpdateOld,\n\t\t\t\"Delete\":    got.Delete,\n\t\t}\n\t\tassert.True(t, testutils.SamePlanChanges(mGot, mExpected))\n\t\tassert.Nil(t, ctx.Value(provider.RecordsContextKey))\n\t}\n\terr := r.ApplyChanges(ctx, changes)\n\trequire.NoError(t, err)\n}\n\nfunc testTXTRegistryApplyChangesWithSuffix(t *testing.T) {\n\tp := inmemory.NewInMemoryProvider()\n\terr := p.CreateZone(testZone)\n\trequire.NoError(t, err)\n\tvar ctxEndpoints []*endpoint.Endpoint\n\tctx := context.WithValue(t.Context(), provider.RecordsContextKey, ctxEndpoints)\n\tp.OnApplyChanges = func(ctx context.Context, _ *plan.Changes) {\n\t\tassert.Equal(t, ctxEndpoints, ctx.Value(provider.RecordsContextKey))\n\t}\n\terr = p.ApplyChanges(ctx, &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwner(\"foo.test-zone.example.org\", \"foo.loadbalancer.com\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewEndpointWithOwner(\"bar.test-zone.example.org\", \"my-domain.com\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewEndpointWithOwner(\"bar-txt.test-zone.example.org\", \"baz.test-zone.example.org\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewEndpointWithOwner(\"bar-txt.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"cname-bar-txt.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"qux.test-zone.example.org\", \"random\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"tar.test-zone.example.org\", \"tar.loadbalancer.com\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewEndpointWithOwner(\"tar-txt.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"cname-tar-txt.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"foobar.test-zone.example.org\", \"foobar.loadbalancer.com\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewEndpointWithOwner(\"foobar-txt.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"cname-foobar-txt.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"multiple.test-zone.example.org\", \"lb1.loadbalancer.com\", endpoint.RecordTypeCNAME, \"\").WithSetIdentifier(\"test-set-1\"),\n\t\t\tnewEndpointWithOwner(\"multiple-txt.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\").WithSetIdentifier(\"test-set-1\"),\n\t\t\tnewEndpointWithOwner(\"cname-multiple-txt.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\").WithSetIdentifier(\"test-set-1\"),\n\t\t\tnewEndpointWithOwner(\"multiple.test-zone.example.org\", \"lb2.loadbalancer.com\", endpoint.RecordTypeCNAME, \"\").WithSetIdentifier(\"test-set-2\"),\n\t\t\tnewEndpointWithOwner(\"multiple-txt.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\").WithSetIdentifier(\"test-set-2\"),\n\t\t\tnewEndpointWithOwner(\"cname-multiple-txt.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\").WithSetIdentifier(\"test-set-2\"),\n\t\t},\n\t})\n\trequire.NoError(t, err)\n\tr, _ := newRegistry(p, \"\", \"-txt\", \"owner\", time.Hour, \"wildcard\", []string{}, []string{}, false, nil, \"\")\n\n\tchanges := &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\tnewCNAMEEndpointWithOwnerResource(\"new-record-1.test-zone.example.org\", \"new-loadbalancer-1.lb.com\", \"owner\", \"ingress/default/my-ingress\"),\n\t\t\tnewCNAMEEndpointWithOwnerResource(\"multiple.test-zone.example.org\", \"lb3.loadbalancer.com\", \"owner\", \"ingress/default/my-ingress\").WithSetIdentifier(\"test-set-3\"),\n\t\t\tnewCNAMEEndpointWithOwnerResource(\"example\", \"new-loadbalancer-1.lb.com\", \"owner\", \"ingress/default/my-ingress\"),\n\t\t\tnewCNAMEEndpointWithOwnerResource(\"*.wildcard.test-zone.example.org\", \"new-loadbalancer-1.lb.com\", \"owner\", \"ingress/default/my-ingress\"),\n\t\t},\n\t\tDelete: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwner(\"foobar.test-zone.example.org\", \"foobar.loadbalancer.com\", endpoint.RecordTypeCNAME, \"owner\"),\n\t\t\tnewEndpointWithOwner(\"multiple.test-zone.example.org\", \"lb1.loadbalancer.com\", endpoint.RecordTypeCNAME, \"owner\").WithSetIdentifier(\"test-set-1\"),\n\t\t},\n\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\tnewCNAMEEndpointWithOwnerResource(\"tar.test-zone.example.org\", \"new-tar.loadbalancer.com\", \"owner\", \"ingress/default/my-ingress-2\"),\n\t\t\tnewCNAMEEndpointWithOwnerResource(\"multiple.test-zone.example.org\", \"new.loadbalancer.com\", \"owner\", \"ingress/default/my-ingress-2\").WithSetIdentifier(\"test-set-2\"),\n\t\t},\n\t\tUpdateOld: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwner(\"tar.test-zone.example.org\", \"tar.loadbalancer.com\", endpoint.RecordTypeCNAME, \"owner\"),\n\t\t\tnewEndpointWithOwner(\"multiple.test-zone.example.org\", \"lb2.loadbalancer.com\", endpoint.RecordTypeCNAME, \"owner\").WithSetIdentifier(\"test-set-2\"),\n\t\t},\n\t}\n\texpected := &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\tnewCNAMEEndpointWithOwnerResource(\"new-record-1.test-zone.example.org\", \"new-loadbalancer-1.lb.com\", \"owner\", \"ingress/default/my-ingress\"),\n\t\t\tnewTXTEndpointWithOwnedRecord(\"cname-new-record-1-txt.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress\\\"\", \"new-record-1.test-zone.example.org\"),\n\t\t\tnewCNAMEEndpointWithOwnerResource(\"multiple.test-zone.example.org\", \"lb3.loadbalancer.com\", \"owner\", \"ingress/default/my-ingress\").WithSetIdentifier(\"test-set-3\"),\n\t\t\tnewTXTEndpointWithOwnedRecord(\"cname-multiple-txt.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress\\\"\", \"multiple.test-zone.example.org\").WithSetIdentifier(\"test-set-3\"),\n\t\t\tnewCNAMEEndpointWithOwnerResource(\"example\", \"new-loadbalancer-1.lb.com\", \"owner\", \"ingress/default/my-ingress\"),\n\t\t\tnewTXTEndpointWithOwnedRecord(\"cname-example-txt\", \"\\\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress\\\"\", \"example\"),\n\t\t\tnewCNAMEEndpointWithOwnerResource(\"*.wildcard.test-zone.example.org\", \"new-loadbalancer-1.lb.com\", \"owner\", \"ingress/default/my-ingress\"),\n\t\t\tnewTXTEndpointWithOwnedRecord(\"cname-wildcard-txt.wildcard.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress\\\"\", \"*.wildcard.test-zone.example.org\"),\n\t\t},\n\t\tDelete: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwner(\"foobar.test-zone.example.org\", \"foobar.loadbalancer.com\", endpoint.RecordTypeCNAME, \"owner\"),\n\t\t\tnewTXTEndpointWithOwnedRecord(\"cname-foobar-txt.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", \"foobar.test-zone.example.org\"),\n\t\t\tnewEndpointWithOwner(\"multiple.test-zone.example.org\", \"lb1.loadbalancer.com\", endpoint.RecordTypeCNAME, \"owner\").WithSetIdentifier(\"test-set-1\"),\n\t\t\tnewTXTEndpointWithOwnedRecord(\"cname-multiple-txt.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", \"multiple.test-zone.example.org\").WithSetIdentifier(\"test-set-1\"),\n\t\t},\n\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\tnewCNAMEEndpointWithOwnerResource(\"tar.test-zone.example.org\", \"new-tar.loadbalancer.com\", \"owner\", \"ingress/default/my-ingress-2\"),\n\t\t\tnewTXTEndpointWithOwnedRecord(\"cname-tar-txt.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress-2\\\"\", \"tar.test-zone.example.org\"),\n\t\t\tnewCNAMEEndpointWithOwnerResource(\"multiple.test-zone.example.org\", \"new.loadbalancer.com\", \"owner\", \"ingress/default/my-ingress-2\").WithSetIdentifier(\"test-set-2\"),\n\t\t\tnewTXTEndpointWithOwnedRecord(\"cname-multiple-txt.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress-2\\\"\", \"multiple.test-zone.example.org\").WithSetIdentifier(\"test-set-2\"),\n\t\t},\n\t\tUpdateOld: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwner(\"tar.test-zone.example.org\", \"tar.loadbalancer.com\", endpoint.RecordTypeCNAME, \"owner\"),\n\t\t\tnewTXTEndpointWithOwnedRecord(\"cname-tar-txt.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", \"tar.test-zone.example.org\"),\n\t\t\tnewEndpointWithOwner(\"multiple.test-zone.example.org\", \"lb2.loadbalancer.com\", endpoint.RecordTypeCNAME, \"owner\").WithSetIdentifier(\"test-set-2\"),\n\t\t\tnewTXTEndpointWithOwnedRecord(\"cname-multiple-txt.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", \"multiple.test-zone.example.org\").WithSetIdentifier(\"test-set-2\"),\n\t\t},\n\t}\n\tp.OnApplyChanges = func(ctx context.Context, got *plan.Changes) {\n\t\tmExpected := map[string][]*endpoint.Endpoint{\n\t\t\t\"Create\":    expected.Create,\n\t\t\t\"UpdateNew\": expected.UpdateNew,\n\t\t\t\"UpdateOld\": expected.UpdateOld,\n\t\t\t\"Delete\":    expected.Delete,\n\t\t}\n\t\tmGot := map[string][]*endpoint.Endpoint{\n\t\t\t\"Create\":    got.Create,\n\t\t\t\"UpdateNew\": got.UpdateNew,\n\t\t\t\"UpdateOld\": got.UpdateOld,\n\t\t\t\"Delete\":    got.Delete,\n\t\t}\n\t\tassert.True(t, testutils.SamePlanChanges(mGot, mExpected))\n\t\tassert.Nil(t, ctx.Value(provider.RecordsContextKey))\n\t}\n\terr = r.ApplyChanges(ctx, changes)\n\trequire.NoError(t, err)\n}\n\nfunc testTXTRegistryApplyChangesNoPrefix(t *testing.T) {\n\tp := inmemory.NewInMemoryProvider()\n\terr := p.CreateZone(testZone)\n\trequire.NoError(t, err)\n\tvar ctxEndpoints []*endpoint.Endpoint\n\tctx := context.WithValue(t.Context(), provider.RecordsContextKey, ctxEndpoints)\n\tp.OnApplyChanges = func(ctx context.Context, _ *plan.Changes) {\n\t\tassert.Equal(t, ctxEndpoints, ctx.Value(provider.RecordsContextKey))\n\t}\n\terr = p.ApplyChanges(ctx, &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwner(\"foo.test-zone.example.org\", \"foo.loadbalancer.com\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewEndpointWithOwner(\"bar.test-zone.example.org\", \"my-domain.com\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewEndpointWithOwner(\"txt.bar.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"txt.bar.test-zone.example.org\", \"baz.test-zone.example.org\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewEndpointWithOwner(\"qux.test-zone.example.org\", \"random\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"tar.test-zone.example.org\", \"tar.loadbalancer.com\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewEndpointWithOwner(\"txt.tar.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"foobar.test-zone.example.org\", \"foobar.loadbalancer.com\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewEndpointWithOwner(\"foobar.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"cname-foobar.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t},\n\t})\n\trequire.NoError(t, err)\n\tr, _ := newRegistry(p, \"\", \"\", \"owner\", time.Hour, \"\", []string{}, []string{}, false, nil, \"\")\n\n\tchanges := &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwner(\"new-record-1.test-zone.example.org\", \"new-loadbalancer-1.lb.com\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewEndpointWithOwner(\"example\", \"new-loadbalancer-1.lb.com\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewEndpointWithOwner(\"new-alias.test-zone.example.org\", \"my-domain.com\", endpoint.RecordTypeA, \"\").WithProviderSpecific(\"alias\", \"true\"),\n\t\t},\n\t\tDelete: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwner(\"foobar.test-zone.example.org\", \"foobar.loadbalancer.com\", endpoint.RecordTypeCNAME, \"owner\"),\n\t\t},\n\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwner(\"tar.test-zone.example.org\", \"new-tar.loadbalancer.com\", endpoint.RecordTypeCNAME, \"owner-2\"),\n\t\t},\n\t\tUpdateOld: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwner(\"tar.test-zone.example.org\", \"tar.loadbalancer.com\", endpoint.RecordTypeCNAME, \"owner-2\"),\n\t\t},\n\t}\n\texpected := &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwner(\"new-record-1.test-zone.example.org\", \"new-loadbalancer-1.lb.com\", endpoint.RecordTypeCNAME, \"owner\"),\n\t\t\tnewTXTEndpointWithOwnedRecord(\"cname-new-record-1.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", \"new-record-1.test-zone.example.org\"),\n\t\t\tnewEndpointWithOwner(\"example\", \"new-loadbalancer-1.lb.com\", endpoint.RecordTypeCNAME, \"owner\"),\n\t\t\tnewTXTEndpointWithOwnedRecord(\"cname-example\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", \"example\"),\n\t\t\tnewEndpointWithOwner(\"new-alias.test-zone.example.org\", \"my-domain.com\", endpoint.RecordTypeA, \"owner\").WithProviderSpecific(\"alias\", \"true\"),\n\t\t\tnewTXTEndpointWithOwnedRecord(\"cname-new-alias.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", \"new-alias.test-zone.example.org\").WithProviderSpecific(\"alias\", \"true\"),\n\t\t},\n\t\tDelete: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwner(\"foobar.test-zone.example.org\", \"foobar.loadbalancer.com\", endpoint.RecordTypeCNAME, \"owner\"),\n\t\t\tnewTXTEndpointWithOwnedRecord(\"cname-foobar.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", \"foobar.test-zone.example.org\"),\n\t\t},\n\t\tUpdateNew: []*endpoint.Endpoint{},\n\t\tUpdateOld: []*endpoint.Endpoint{},\n\t}\n\tp.OnApplyChanges = func(ctx context.Context, got *plan.Changes) {\n\t\tmExpected := map[string][]*endpoint.Endpoint{\n\t\t\t\"Create\":    expected.Create,\n\t\t\t\"UpdateNew\": expected.UpdateNew,\n\t\t\t\"UpdateOld\": expected.UpdateOld,\n\t\t\t\"Delete\":    expected.Delete,\n\t\t}\n\t\tmGot := map[string][]*endpoint.Endpoint{\n\t\t\t\"Create\":    got.Create,\n\t\t\t\"UpdateNew\": got.UpdateNew,\n\t\t\t\"UpdateOld\": got.UpdateOld,\n\t\t\t\"Delete\":    got.Delete,\n\t\t}\n\t\tassert.True(t, testutils.SamePlanChanges(mGot, mExpected))\n\t\tassert.Nil(t, ctx.Value(provider.RecordsContextKey))\n\t}\n\terr = r.ApplyChanges(ctx, changes)\n\trequire.NoError(t, err)\n}\n\nfunc testTXTRegistryMissingRecords(t *testing.T) {\n\tt.Run(\"No prefix\", testTXTRegistryMissingRecordsNoPrefix)\n\tt.Run(\"With Prefix\", testTXTRegistryMissingRecordsWithPrefix)\n}\n\nfunc testTXTRegistryMissingRecordsNoPrefix(t *testing.T) {\n\tctx := t.Context()\n\tp := inmemory.NewInMemoryProvider()\n\terr := p.CreateZone(testZone)\n\trequire.NoError(t, err)\n\terr = p.ApplyChanges(ctx, &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwner(\"oldformat.test-zone.example.org\", \"foo.loadbalancer.com\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewEndpointWithOwner(\"oldformat.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"oldformat2.test-zone.example.org\", \"bar.loadbalancer.com\", endpoint.RecordTypeA, \"\"),\n\t\t\tnewEndpointWithOwner(\"oldformat2.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"newformat.test-zone.example.org\", \"foobar.nameserver.com\", endpoint.RecordTypeNS, \"\"),\n\t\t\tnewEndpointWithOwner(\"ns-newformat.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"newformat.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"noheritage.test-zone.example.org\", \"random\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"oldformat-otherowner.test-zone.example.org\", \"bar.loadbalancer.com\", endpoint.RecordTypeA, \"\"),\n\t\t\tnewEndpointWithOwner(\"oldformat-otherowner.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=otherowner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tendpoint.NewEndpoint(\"unmanaged1.test-zone.example.org\", endpoint.RecordTypeA, \"unmanaged1.loadbalancer.com\"),\n\t\t\tendpoint.NewEndpoint(\"unmanaged2.test-zone.example.org\", endpoint.RecordTypeCNAME, \"unmanaged2.loadbalancer.com\"),\n\t\t\tnewEndpointWithOwner(\"this-is-a-63-characters-long-label-that-we-do-expect-will-work.test-zone.example.org\", \"foo.loadbalancer.com\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewEndpointWithOwner(\"this-is-a-63-characters-long-label-that-we-do-expect-will-work.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t},\n\t})\n\trequire.NoError(t, err)\n\texpectedRecords := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"oldformat.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"foo.loadbalancer.com\"},\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\tLabels: map[string]string{\n\t\t\t\t// owner was added from the TXT record's target\n\t\t\t\tendpoint.OwnerLabelKey: \"owner\",\n\t\t\t},\n\t\t\tProviderSpecific: []endpoint.ProviderSpecificProperty{\n\t\t\t\t{\n\t\t\t\t\tName:  \"txt/force-update\",\n\t\t\t\t\tValue: \"true\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"oldformat2.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"bar.loadbalancer.com\"},\n\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"owner\",\n\t\t\t},\n\t\t\tProviderSpecific: []endpoint.ProviderSpecificProperty{\n\t\t\t\t{\n\t\t\t\t\tName:  \"txt/force-update\",\n\t\t\t\t\tValue: \"true\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"newformat.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"foobar.nameserver.com\"},\n\t\t\tRecordType: endpoint.RecordTypeNS,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"owner\",\n\t\t\t},\n\t\t},\n\t\t// Only TXT records with the wrong heritage are returned by Records()\n\t\t{\n\t\t\tDNSName:    \"noheritage.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"random\"},\n\t\t\tRecordType: endpoint.RecordTypeTXT,\n\t\t\tLabels: map[string]string{\n\t\t\t\t// No owner because it's not external-dns heritage\n\t\t\t\tendpoint.OwnerLabelKey: \"\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"oldformat-otherowner.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"bar.loadbalancer.com\"},\n\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\tLabels: map[string]string{\n\t\t\t\t// Records() retrieves all the records of the zone, no matter the owner\n\t\t\t\tendpoint.OwnerLabelKey: \"otherowner\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"unmanaged1.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"unmanaged1.loadbalancer.com\"},\n\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"unmanaged2.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"unmanaged2.loadbalancer.com\"},\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"this-is-a-63-characters-long-label-that-we-do-expect-will-work.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"foo.loadbalancer.com\"},\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"owner\",\n\t\t\t},\n\t\t},\n\t}\n\n\tr, _ := newRegistry(p, \"\", \"\", \"owner\", time.Hour, \"wc\", []string{endpoint.RecordTypeCNAME, endpoint.RecordTypeA, endpoint.RecordTypeNS}, []string{}, false, nil, \"\")\n\trecords, _ := r.Records(ctx)\n\n\tassert.True(t, testutils.SameEndpoints(records, expectedRecords))\n}\n\nfunc testTXTRegistryMissingRecordsWithPrefix(t *testing.T) {\n\tctx := t.Context()\n\tp := inmemory.NewInMemoryProvider()\n\terr := p.CreateZone(testZone)\n\trequire.NoError(t, err)\n\terr = p.ApplyChanges(ctx, &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwner(\"oldformat.test-zone.example.org\", \"foo.loadbalancer.com\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewEndpointWithOwner(\"txt.oldformat.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"oldformat2.test-zone.example.org\", \"bar.loadbalancer.com\", endpoint.RecordTypeA, \"\"),\n\t\t\tnewEndpointWithOwner(\"txt.oldformat2.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"newformat.test-zone.example.org\", \"foobar.nameserver.com\", endpoint.RecordTypeNS, \"\"),\n\t\t\tnewEndpointWithOwner(\"txt.ns-newformat.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"oldformat3.test-zone.example.org\", \"random\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"txt.oldformat3.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"txt.newformat.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"noheritage.test-zone.example.org\", \"random\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"oldformat-otherowner.test-zone.example.org\", \"bar.loadbalancer.com\", endpoint.RecordTypeA, \"\"),\n\t\t\tnewEndpointWithOwner(\"txt.oldformat-otherowner.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=otherowner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tendpoint.NewEndpoint(\"unmanaged1.test-zone.example.org\", endpoint.RecordTypeA, \"unmanaged1.loadbalancer.com\"),\n\t\t\tendpoint.NewEndpoint(\"unmanaged2.test-zone.example.org\", endpoint.RecordTypeCNAME, \"unmanaged2.loadbalancer.com\"),\n\t\t},\n\t})\n\trequire.NoError(t, err)\n\texpectedRecords := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"oldformat.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"foo.loadbalancer.com\"},\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\tLabels: map[string]string{\n\t\t\t\t// owner was added from the TXT record's target\n\t\t\t\tendpoint.OwnerLabelKey: \"owner\",\n\t\t\t},\n\t\t\tProviderSpecific: []endpoint.ProviderSpecificProperty{\n\t\t\t\t{\n\t\t\t\t\tName:  \"txt/force-update\",\n\t\t\t\t\tValue: \"true\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"oldformat2.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"bar.loadbalancer.com\"},\n\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"owner\",\n\t\t\t},\n\t\t\tProviderSpecific: []endpoint.ProviderSpecificProperty{\n\t\t\t\t{\n\t\t\t\t\tName:  \"txt/force-update\",\n\t\t\t\t\tValue: \"true\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"oldformat3.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"random\"},\n\t\t\tRecordType: endpoint.RecordTypeTXT,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"owner\",\n\t\t\t},\n\t\t\tProviderSpecific: []endpoint.ProviderSpecificProperty{\n\t\t\t\t{\n\t\t\t\t\tName:  \"txt/force-update\",\n\t\t\t\t\tValue: \"true\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"newformat.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"foobar.nameserver.com\"},\n\t\t\tRecordType: endpoint.RecordTypeNS,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnerLabelKey: \"owner\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"noheritage.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"random\"},\n\t\t\tRecordType: endpoint.RecordTypeTXT,\n\t\t\tLabels: map[string]string{\n\t\t\t\t// No owner because it's not external-dns heritage\n\t\t\t\tendpoint.OwnerLabelKey: \"\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"oldformat-otherowner.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"bar.loadbalancer.com\"},\n\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\tLabels: map[string]string{\n\t\t\t\t// All the records of the zone are retrieved, no matter the owner\n\t\t\t\tendpoint.OwnerLabelKey: \"otherowner\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"unmanaged1.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"unmanaged1.loadbalancer.com\"},\n\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t},\n\t\t{\n\t\t\tDNSName:    \"unmanaged2.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"unmanaged2.loadbalancer.com\"},\n\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t},\n\t}\n\n\tr, _ := newRegistry(p, \"txt.\", \"\", \"owner\", time.Hour, \"wc\", []string{endpoint.RecordTypeCNAME, endpoint.RecordTypeA, endpoint.RecordTypeNS, endpoint.RecordTypeTXT}, []string{}, false, nil, \"\")\n\trecords, _ := r.Records(ctx)\n\n\tassert.True(t, testutils.SameEndpoints(records, expectedRecords))\n}\n\nfunc TestCacheMethods(t *testing.T) {\n\tcache := []*endpoint.Endpoint{\n\t\tnewEndpointWithOwner(\"thing.com\", \"1.2.3.4\", \"A\", \"owner\"),\n\t\tnewEndpointWithOwner(\"thing1.com\", \"1.2.3.6\", \"A\", \"owner\"),\n\t\tnewEndpointWithOwner(\"thing2.com\", \"1.2.3.4\", \"CNAME\", \"owner\"),\n\t\tnewEndpointWithOwner(\"thing3.com\", \"1.2.3.4\", \"A\", \"owner\"),\n\t\tnewEndpointWithOwner(\"thing4.com\", \"1.2.3.4\", \"A\", \"owner\"),\n\t}\n\tregistry := &TXTRegistry{\n\t\trecordsCache:  cache,\n\t\tcacheInterval: time.Hour,\n\t}\n\n\texpectedCacheAfterAdd := []*endpoint.Endpoint{\n\t\tnewEndpointWithOwner(\"thing.com\", \"1.2.3.4\", \"A\", \"owner\"),\n\t\tnewEndpointWithOwner(\"thing1.com\", \"1.2.3.6\", \"A\", \"owner\"),\n\t\tnewEndpointWithOwner(\"thing2.com\", \"1.2.3.4\", \"CNAME\", \"owner\"),\n\t\tnewEndpointWithOwner(\"thing3.com\", \"1.2.3.4\", \"A\", \"owner\"),\n\t\tnewEndpointWithOwner(\"thing4.com\", \"1.2.3.4\", \"A\", \"owner\"),\n\t\tnewEndpointWithOwner(\"thing4.com\", \"2001:DB8::1\", \"AAAA\", \"owner\"),\n\t\tnewEndpointWithOwner(\"thing5.com\", \"1.2.3.5\", \"A\", \"owner\"),\n\t}\n\n\texpectedCacheAfterUpdate := []*endpoint.Endpoint{\n\t\tnewEndpointWithOwner(\"thing1.com\", \"1.2.3.6\", \"A\", \"owner\"),\n\t\tnewEndpointWithOwner(\"thing2.com\", \"1.2.3.4\", \"CNAME\", \"owner\"),\n\t\tnewEndpointWithOwner(\"thing3.com\", \"1.2.3.4\", \"A\", \"owner\"),\n\t\tnewEndpointWithOwner(\"thing4.com\", \"1.2.3.4\", \"A\", \"owner\"),\n\t\tnewEndpointWithOwner(\"thing5.com\", \"1.2.3.5\", \"A\", \"owner\"),\n\t\tnewEndpointWithOwner(\"thing.com\", \"1.2.3.6\", \"A\", \"owner2\"),\n\t\tnewEndpointWithOwner(\"thing4.com\", \"2001:DB8::2\", \"AAAA\", \"owner\"),\n\t}\n\n\texpectedCacheAfterDelete := []*endpoint.Endpoint{\n\t\tnewEndpointWithOwner(\"thing1.com\", \"1.2.3.6\", \"A\", \"owner\"),\n\t\tnewEndpointWithOwner(\"thing2.com\", \"1.2.3.4\", \"CNAME\", \"owner\"),\n\t\tnewEndpointWithOwner(\"thing3.com\", \"1.2.3.4\", \"A\", \"owner\"),\n\t\tnewEndpointWithOwner(\"thing4.com\", \"1.2.3.4\", \"A\", \"owner\"),\n\t\tnewEndpointWithOwner(\"thing5.com\", \"1.2.3.5\", \"A\", \"owner\"),\n\t}\n\t// test add cache\n\tregistry.addToCache(newEndpointWithOwner(\"thing4.com\", \"2001:DB8::1\", \"AAAA\", \"owner\"))\n\tregistry.addToCache(newEndpointWithOwner(\"thing5.com\", \"1.2.3.5\", \"A\", \"owner\"))\n\n\tif !reflect.DeepEqual(expectedCacheAfterAdd, registry.recordsCache) {\n\t\tt.Fatalf(\"expected endpoints should match endpoints from cache: expected %v, but got %v\", expectedCacheAfterAdd, registry.recordsCache)\n\t}\n\n\t// test update cache\n\tregistry.removeFromCache(newEndpointWithOwner(\"thing.com\", \"1.2.3.4\", \"A\", \"owner\"))\n\tregistry.addToCache(newEndpointWithOwner(\"thing.com\", \"1.2.3.6\", \"A\", \"owner2\"))\n\tregistry.removeFromCache(newEndpointWithOwner(\"thing4.com\", \"2001:DB8::1\", \"AAAA\", \"owner\"))\n\tregistry.addToCache(newEndpointWithOwner(\"thing4.com\", \"2001:DB8::2\", \"AAAA\", \"owner\"))\n\t// ensure it was updated\n\tif !reflect.DeepEqual(expectedCacheAfterUpdate, registry.recordsCache) {\n\t\tt.Fatalf(\"expected endpoints should match endpoints from cache: expected %v, but got %v\", expectedCacheAfterUpdate, registry.recordsCache)\n\t}\n\n\t// test deleting a record\n\tregistry.removeFromCache(newEndpointWithOwner(\"thing.com\", \"1.2.3.6\", \"A\", \"owner2\"))\n\tregistry.removeFromCache(newEndpointWithOwner(\"thing4.com\", \"2001:DB8::2\", \"AAAA\", \"owner\"))\n\t// ensure it was deleted\n\tif !reflect.DeepEqual(expectedCacheAfterDelete, registry.recordsCache) {\n\t\tt.Fatalf(\"expected endpoints should match endpoints from cache: expected %v, but got %v\", expectedCacheAfterDelete, registry.recordsCache)\n\t}\n}\n\nfunc TestNewTXTScheme(t *testing.T) {\n\tp := inmemory.NewInMemoryProvider()\n\terr := p.CreateZone(testZone)\n\trequire.NoError(t, err)\n\tvar ctxEndpoints []*endpoint.Endpoint\n\tctx := context.WithValue(t.Context(), provider.RecordsContextKey, ctxEndpoints)\n\tp.OnApplyChanges = func(ctx context.Context, _ *plan.Changes) {\n\t\tassert.Equal(t, ctxEndpoints, ctx.Value(provider.RecordsContextKey))\n\t}\n\terr = p.ApplyChanges(ctx, &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwner(\"foo.test-zone.example.org\", \"foo.loadbalancer.com\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewEndpointWithOwner(\"bar.test-zone.example.org\", \"my-domain.com\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewEndpointWithOwner(\"txt.bar.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"txt.bar.test-zone.example.org\", \"baz.test-zone.example.org\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewEndpointWithOwner(\"qux.test-zone.example.org\", \"random\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"tar.test-zone.example.org\", \"tar.loadbalancer.com\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewEndpointWithOwner(\"txt.tar.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"foobar.test-zone.example.org\", \"foobar.loadbalancer.com\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewEndpointWithOwner(\"foobar.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"cname-foobar.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t},\n\t})\n\trequire.NoError(t, err)\n\tr, err := newRegistry(p, \"\", \"\", \"owner\", time.Hour, \"\", []string{}, []string{}, false, nil, \"\")\n\trequire.NoError(t, err)\n\n\tchanges := &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwner(\"new-record-1.test-zone.example.org\", \"new-loadbalancer-1.lb.com\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewEndpointWithOwner(\"example\", \"new-loadbalancer-1.lb.com\", endpoint.RecordTypeCNAME, \"\"),\n\t\t},\n\t\tDelete: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwner(\"foobar.test-zone.example.org\", \"foobar.loadbalancer.com\", endpoint.RecordTypeCNAME, \"owner\"),\n\t\t},\n\t\tUpdateNew: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwner(\"tar.test-zone.example.org\", \"new-tar.loadbalancer.com\", endpoint.RecordTypeCNAME, \"owner-2\"),\n\t\t},\n\t\tUpdateOld: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwner(\"tar.test-zone.example.org\", \"tar.loadbalancer.com\", endpoint.RecordTypeCNAME, \"owner-2\"),\n\t\t},\n\t}\n\texpected := &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwner(\"new-record-1.test-zone.example.org\", \"new-loadbalancer-1.lb.com\", endpoint.RecordTypeCNAME, \"owner\"),\n\t\t\tnewTXTEndpointWithOwnedRecord(\"cname-new-record-1.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", \"new-record-1.test-zone.example.org\"),\n\t\t\tnewEndpointWithOwner(\"example\", \"new-loadbalancer-1.lb.com\", endpoint.RecordTypeCNAME, \"owner\"),\n\t\t\tnewTXTEndpointWithOwnedRecord(\"cname-example\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", \"example\"),\n\t\t},\n\t\tDelete: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwner(\"foobar.test-zone.example.org\", \"foobar.loadbalancer.com\", endpoint.RecordTypeCNAME, \"owner\"),\n\t\t\tnewTXTEndpointWithOwnedRecord(\"cname-foobar.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=owner\\\"\", \"foobar.test-zone.example.org\"),\n\t\t},\n\t\tUpdateNew: []*endpoint.Endpoint{},\n\t\tUpdateOld: []*endpoint.Endpoint{},\n\t}\n\tp.OnApplyChanges = func(ctx context.Context, got *plan.Changes) {\n\t\tmExpected := map[string][]*endpoint.Endpoint{\n\t\t\t\"Create\":    expected.Create,\n\t\t\t\"UpdateNew\": expected.UpdateNew,\n\t\t\t\"UpdateOld\": expected.UpdateOld,\n\t\t\t\"Delete\":    expected.Delete,\n\t\t}\n\t\tmGot := map[string][]*endpoint.Endpoint{\n\t\t\t\"Create\":    got.Create,\n\t\t\t\"UpdateNew\": got.UpdateNew,\n\t\t\t\"UpdateOld\": got.UpdateOld,\n\t\t\t\"Delete\":    got.Delete,\n\t\t}\n\t\tassert.True(t, testutils.SamePlanChanges(mGot, mExpected))\n\t\tassert.Nil(t, ctx.Value(provider.RecordsContextKey))\n\t}\n\terr = r.ApplyChanges(ctx, changes)\n\trequire.NoError(t, err)\n}\n\nfunc TestGenerateTXT(t *testing.T) {\n\trecord := newEndpointWithOwner(\"foo.test-zone.example.org\", \"new-foo.loadbalancer.com\", endpoint.RecordTypeCNAME, \"owner\")\n\texpectedTXT := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"cname-foo.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"\\\"heritage=external-dns,external-dns/owner=owner\\\"\"},\n\t\t\tRecordType: endpoint.RecordTypeTXT,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnedRecordLabelKey: \"foo.test-zone.example.org\",\n\t\t\t},\n\t\t},\n\t}\n\tp := inmemory.NewInMemoryProvider()\n\terr := p.CreateZone(testZone)\n\trequire.NoError(t, err)\n\tr, _ := newRegistry(p, \"\", \"\", \"owner\", time.Hour, \"\", []string{}, []string{}, false, nil, \"\")\n\tgotTXT := r.generateTXTRecord(record)\n\tassert.Equal(t, expectedTXT, gotTXT)\n}\n\nfunc TestGenerateTXTWithMigration(t *testing.T) {\n\trecord := newEndpointWithOwner(\"foo.test-zone.example.org\", \"1.2.3.4\", endpoint.RecordTypeA, \"owner\")\n\texpectedTXTBeforeMigration := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"a-foo.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"\\\"heritage=external-dns,external-dns/owner=owner\\\"\"},\n\t\t\tRecordType: endpoint.RecordTypeTXT,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnedRecordLabelKey: \"foo.test-zone.example.org\",\n\t\t\t},\n\t\t},\n\t}\n\tp := inmemory.NewInMemoryProvider()\n\terr := p.CreateZone(testZone)\n\trequire.NoError(t, err)\n\tr, _ := newRegistry(p, \"\", \"\", \"owner\", time.Hour, \"\", []string{}, []string{}, false, nil, \"\")\n\tgotTXTBeforeMigration := r.generateTXTRecord(record)\n\tassert.Equal(t, expectedTXTBeforeMigration, gotTXTBeforeMigration)\n\n\texpectedTXTAfterMigration := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"a-foo.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"\\\"heritage=external-dns,external-dns/owner=foobar\\\"\"},\n\t\t\tRecordType: endpoint.RecordTypeTXT,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnedRecordLabelKey: \"foo.test-zone.example.org\",\n\t\t\t},\n\t\t},\n\t}\n\n\trMigrated, _ := newRegistry(p, \"\", \"\", \"foobar\", time.Hour, \"\", []string{}, []string{}, false, nil, \"owner\")\n\tgotTXTAfterMigration := rMigrated.generateTXTRecord(record)\n\tassert.Equal(t, expectedTXTAfterMigration, gotTXTAfterMigration)\n\n}\n\nfunc TestGenerateTXTForAAAA(t *testing.T) {\n\trecord := newEndpointWithOwner(\"foo.test-zone.example.org\", \"2001:DB8::1\", endpoint.RecordTypeAAAA, \"owner\")\n\texpectedTXT := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"aaaa-foo.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"\\\"heritage=external-dns,external-dns/owner=owner\\\"\"},\n\t\t\tRecordType: endpoint.RecordTypeTXT,\n\t\t\tLabels: map[string]string{\n\t\t\t\tendpoint.OwnedRecordLabelKey: \"foo.test-zone.example.org\",\n\t\t\t},\n\t\t},\n\t}\n\tp := inmemory.NewInMemoryProvider()\n\terr := p.CreateZone(testZone)\n\trequire.NoError(t, err)\n\tr, _ := newRegistry(p, \"\", \"\", \"owner\", time.Hour, \"\", []string{}, []string{}, false, nil, \"\")\n\tgotTXT := r.generateTXTRecord(record)\n\tassert.Equal(t, expectedTXT, gotTXT)\n}\n\nfunc TestFailGenerateTXT(t *testing.T) {\n\n\tcnameRecord := &endpoint.Endpoint{\n\t\tDNSName:    \"foo-some-really-big-name-not-supported-and-will-fail-000000000000000000.test-zone.example.org\",\n\t\tTargets:    endpoint.Targets{\"new-foo.loadbalancer.com\"},\n\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\tLabels:     map[string]string{},\n\t}\n\t// A bad DNS name returns empty expected TXT\n\texpectedTXT := make([]*endpoint.Endpoint, 0)\n\tp := inmemory.NewInMemoryProvider()\n\terr := p.CreateZone(testZone)\n\trequire.NoError(t, err)\n\tr, _ := newRegistry(p, \"\", \"\", \"owner\", time.Hour, \"\", []string{}, []string{}, false, nil, \"\")\n\tgotTXT := r.generateTXTRecord(cnameRecord)\n\tassert.Equal(t, expectedTXT, gotTXT)\n}\n\nfunc TestTXTRegistryApplyChangesEncrypt(t *testing.T) {\n\tp := inmemory.NewInMemoryProvider()\n\terr := p.CreateZone(testZone)\n\trequire.NoError(t, err)\n\tvar ctxEndpoints []*endpoint.Endpoint\n\tctx := context.WithValue(t.Context(), provider.RecordsContextKey, ctxEndpoints)\n\n\terr = p.ApplyChanges(ctx, &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwner(\"foobar.test-zone.example.org\", \"foobar.loadbalancer.com\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\tnewTXTEndpointWithOwnedRecord(\"txt.cname-foobar.test-zone.example.org\", \"\\\"h8UQ6jelUFUsEIn7SbFktc2MYXPx/q8lySqI4VwfVtVaIbb2nkHWV/88KKbuLtu7fJNzMir8ELVeVnRSY01KdiIuj7ledqZe5ailEjQaU5Z6uEKd5pgs6sH8\\\"\", \"foobar.test-zone.example.org\"),\n\t\t},\n\t})\n\trequire.NoError(t, err)\n\n\tr, _ := newRegistry(p, \"txt.\", \"\", \"owner\", time.Hour, \"\", []string{}, []string{}, true, []byte(\"12345678901234567890123456789012\"), \"\")\n\trecords, _ := r.Records(ctx)\n\tchanges := &plan.Changes{\n\t\tDelete: records,\n\t}\n\n\t// ensure that encryption nonce gets reused when deleting records\n\texpected := &plan.Changes{\n\t\tDelete: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwner(\"foobar.test-zone.example.org\", \"foobar.loadbalancer.com\", endpoint.RecordTypeCNAME, \"owner\"),\n\t\t\tnewTXTEndpointWithOwnedRecord(\"txt.cname-foobar.test-zone.example.org\", \"\\\"h8UQ6jelUFUsEIn7SbFktc2MYXPx/q8lySqI4VwfVtVaIbb2nkHWV/88KKbuLtu7fJNzMir8ELVeVnRSY01KdiIuj7ledqZe5ailEjQaU5Z6uEKd5pgs6sH8\\\"\", \"foobar.test-zone.example.org\"),\n\t\t},\n\t}\n\n\tp.OnApplyChanges = func(ctx context.Context, got *plan.Changes) {\n\t\tmExpected := map[string][]*endpoint.Endpoint{\n\t\t\t\"Delete\": expected.Delete,\n\t\t}\n\t\tmGot := map[string][]*endpoint.Endpoint{\n\t\t\t\"Delete\": got.Delete,\n\t\t}\n\t\tassert.True(t, testutils.SamePlanChanges(mGot, mExpected))\n\t\tassert.Nil(t, ctx.Value(provider.RecordsContextKey))\n\t}\n\terr = r.ApplyChanges(ctx, changes)\n\trequire.NoError(t, err)\n}\n\n// TestMultiClusterDifferentRecordTypeOwnership validates the registry handles environments where the same zone is managed by\n// external-dns in different clusters and the ingress record type is different. For example one uses A records and the other\n// uses CNAME. In this environment the first cluster that establishes the owner record should maintain ownership even\n// if the same ingress host is deployed to the other. With the introduction of Dual Record support each record type\n// was treated independently and would cause each cluster to fight over ownership. This tests ensure that the default\n// Dual Stack record support only treats AAAA records independently and while keeping A and CNAME record ownership intact.\nfunc TestMultiClusterDifferentRecordTypeOwnership(t *testing.T) {\n\tctx := t.Context()\n\tp := inmemory.NewInMemoryProvider()\n\terr := p.CreateZone(testZone)\n\trequire.NoError(t, err)\n\terr = p.ApplyChanges(ctx, &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\t// records on cluster using A record for ingress address\n\t\t\tnewEndpointWithOwner(\"bar.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=cat,external-dns/resource=ingress/default/foo\\\"\", endpoint.RecordTypeTXT, \"\"),\n\t\t\tnewEndpointWithOwner(\"bar.test-zone.example.org\", \"1.2.3.4\", endpoint.RecordTypeA, \"\"),\n\t\t},\n\t})\n\trequire.NoError(t, err)\n\n\tr, _ := newRegistry(p, \"_owner.\", \"\", \"bar\", time.Hour, \"\", []string{}, []string{}, false, nil, \"\")\n\trecords, _ := r.Records(ctx)\n\n\t// new cluster has same ingress host as other cluster and uses CNAME ingress address\n\tcname := &endpoint.Endpoint{\n\t\tDNSName:    \"bar.test-zone.example.org\",\n\t\tTargets:    endpoint.Targets{\"cluster-b\"},\n\t\tRecordType: \"CNAME\",\n\t\tLabels: map[string]string{\n\t\t\tendpoint.ResourceLabelKey: \"ingress/default/foo-127\",\n\t\t},\n\t}\n\tdesired := []*endpoint.Endpoint{cname}\n\n\tpl := &plan.Plan{\n\t\tPolicies:       []plan.Policy{&plan.SyncPolicy{}},\n\t\tCurrent:        records,\n\t\tDesired:        desired,\n\t\tManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME},\n\t}\n\n\tchanges := pl.Calculate()\n\tp.OnApplyChanges = func(_ context.Context, changes *plan.Changes) {\n\t\tgot := map[string][]*endpoint.Endpoint{\n\t\t\t\"Create\":    changes.Create,\n\t\t\t\"UpdateNew\": changes.UpdateNew,\n\t\t\t\"UpdateOld\": changes.UpdateOld,\n\t\t\t\"Delete\":    changes.Delete,\n\t\t}\n\t\texpected := map[string][]*endpoint.Endpoint{\n\t\t\t\"Create\":    {},\n\t\t\t\"UpdateNew\": {},\n\t\t\t\"UpdateOld\": {},\n\t\t\t\"Delete\":    {},\n\t\t}\n\t\ttestutils.SamePlanChanges(got, expected)\n\t}\n\n\terr = r.ApplyChanges(ctx, changes.Changes)\n\trequire.NoError(t, err)\n}\n\nfunc TestGenerateTXTRecordWithNewFormatOnly(t *testing.T) {\n\tp := inmemory.NewInMemoryProvider()\n\ttestCases := []struct {\n\t\tname            string\n\t\tendpoint        *endpoint.Endpoint\n\t\texpectedRecords int\n\t\texpectedPrefix  string\n\t\tdescription     string\n\t}{\n\t\t{\n\t\t\tname:            \"legacy format enabled - standard record\",\n\t\t\tendpoint:        newEndpointWithOwner(\"foo.test-zone.example.org\", \"1.2.3.4\", endpoint.RecordTypeA, \"owner\"),\n\t\t\texpectedRecords: 1,\n\t\t\texpectedPrefix:  \"a-\",\n\t\t\tdescription:     \"Should generate only new format TXT records\",\n\t\t},\n\t\t{\n\t\t\tname:            \"new format only - standard record\",\n\t\t\tendpoint:        newEndpointWithOwner(\"foo.test-zone.example.org\", \"1.2.3.4\", endpoint.RecordTypeA, \"owner\"),\n\t\t\texpectedRecords: 1,\n\t\t\texpectedPrefix:  \"a-\",\n\t\t\tdescription:     \"Should only generate new format TXT record\",\n\t\t},\n\t\t{\n\t\t\tname:            \"legacy format enabled - AAAA record\",\n\t\t\tendpoint:        newEndpointWithOwner(\"foo.test-zone.example.org\", \"2001:db8::1\", endpoint.RecordTypeAAAA, \"owner\"),\n\t\t\texpectedRecords: 1,\n\t\t\texpectedPrefix:  \"aaaa-\",\n\t\t\tdescription:     \"Should only generate new format for AAAA records regardless of setting\",\n\t\t},\n\t\t{\n\t\t\tname:            \"new format only - AAAA record\",\n\t\t\tendpoint:        newEndpointWithOwner(\"foo.test-zone.example.org\", \"2001:db8::1\", endpoint.RecordTypeAAAA, \"owner\"),\n\t\t\texpectedRecords: 1,\n\t\t\texpectedPrefix:  \"aaaa-\",\n\t\t\tdescription:     \"Should only generate new format for AAAA records\",\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tr, _ := newRegistry(p, \"\", \"\", \"owner\", time.Hour, \"\", []string{}, []string{}, false, nil, \"\")\n\t\t\trecords := r.generateTXTRecord(tc.endpoint)\n\n\t\t\tassert.Len(t, records, tc.expectedRecords, tc.description)\n\n\t\t\tfor _, record := range records {\n\t\t\t\tassert.Equal(t, endpoint.RecordTypeTXT, record.RecordType)\n\t\t\t}\n\n\t\t\tif tc.endpoint.RecordType == endpoint.RecordTypeAAAA {\n\t\t\t\thasNewFormat := false\n\t\t\t\tfor _, record := range records {\n\t\t\t\t\tif strings.HasPrefix(record.DNSName, tc.expectedPrefix) {\n\t\t\t\t\t\thasNewFormat = true\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tassert.True(t, hasNewFormat,\n\t\t\t\t\t\"Should have at least one record with prefix %s when using new format\", tc.expectedPrefix)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestApplyChangesWithNewFormatOnly(t *testing.T) {\n\tp := inmemory.NewInMemoryProvider()\n\terr := p.CreateZone(testZone)\n\trequire.NoError(t, err)\n\tctx := t.Context()\n\n\tr, _ := newRegistry(p, \"\", \"\", \"owner\", time.Hour, \"\", []string{}, []string{}, false, nil, \"\")\n\n\tchanges := &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\tnewEndpointWithOwner(\"new-record.test-zone.example.org\", \"1.2.3.4\", endpoint.RecordTypeA, \"owner\"),\n\t\t},\n\t}\n\n\terr = r.ApplyChanges(ctx, changes)\n\trequire.NoError(t, err)\n\n\trecords, err := p.Records(ctx)\n\trequire.NoError(t, err)\n\n\tvar txtRecords []*endpoint.Endpoint\n\tfor _, record := range records {\n\t\tif record.RecordType == endpoint.RecordTypeTXT {\n\t\t\ttxtRecords = append(txtRecords, record)\n\t\t}\n\t}\n\n\tassert.Len(t, txtRecords, 1, \"Should only create one TXT record in new format\")\n\n\tif len(txtRecords) > 0 {\n\t\tassert.True(t, strings.HasPrefix(txtRecords[0].DNSName, \"a-\"),\n\t\t\t\"TXT record should have 'a-' prefix when using new format only\")\n\t}\n}\n\nfunc TestTXTRegistryRecordsWithEmptyTargets(t *testing.T) {\n\tctx := t.Context()\n\tp := inmemory.NewInMemoryProvider()\n\terr := p.CreateZone(testZone)\n\trequire.NoError(t, err)\n\terr = p.ApplyChanges(ctx, &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\t{\n\t\t\t\tDNSName:    \"empty-targets.test-zone.example.org\",\n\t\t\t\tRecordType: endpoint.RecordTypeTXT,\n\t\t\t\tTargets:    endpoint.Targets{},\n\t\t\t},\n\t\t\t{\n\t\t\t\tDNSName:    \"valid-targets.test-zone.example.org\",\n\t\t\t\tRecordType: endpoint.RecordTypeTXT,\n\t\t\t\tTargets:    endpoint.Targets{\"target1\"},\n\t\t\t},\n\t\t},\n\t})\n\trequire.NoError(t, err)\n\n\tr, _ := newRegistry(p, \"\", \"\", \"owner\", time.Hour, \"\", []string{}, []string{}, false, nil, \"\")\n\thook := logtest.LogsUnderTestWithLogLevel(log.ErrorLevel, t)\n\trecords, err := r.Records(ctx)\n\trequire.NoError(t, err)\n\n\texpectedRecords := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"valid-targets.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"target1\"},\n\t\t\tRecordType: endpoint.RecordTypeTXT,\n\t\t\tLabels:     map[string]string{},\n\t\t},\n\t}\n\n\tassert.True(t, testutils.SameEndpoints(records, expectedRecords))\n\n\tlogtest.TestHelperLogContains(\"TXT record has no targets empty-targets.test-zone.example.org\", hook, t)\n}\n\n// TestTXTRegistryRecreatesMissingRecords reproduces issue #4914.\n// It verifies that External‑DNS recreates A/CNAME records that were accidentally deleted while their corresponding TXT records remain.\n// An InMemoryProvider is used because, like Route53, it throws an error when attempting to create a duplicate record.\nfunc TestTXTRegistryRecreatesMissingRecords(t *testing.T) {\n\townerId := \"owner\"\n\ttests := []struct {\n\t\tname           string\n\t\tdesired        []*endpoint.Endpoint\n\t\texisting       []*endpoint.Endpoint\n\t\texpectedCreate []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\tname: \"Recreate missing A record when TXT exists\",\n\t\t\tdesired: []*endpoint.Endpoint{\n\t\t\t\tnewEndpointWithOwner(\"new-record-1.test-zone.example.org\", \"1.1.1.1\", endpoint.RecordTypeA, \"\"),\n\t\t\t},\n\t\t\texisting: []*endpoint.Endpoint{\n\t\t\t\tnewEndpointWithOwner(\"new-record-1.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=\"+ownerId+\"\\\"\", endpoint.RecordTypeTXT, ownerId),\n\t\t\t\tnewEndpointWithOwner(\"a-new-record-1.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=\"+ownerId+\"\\\"\", endpoint.RecordTypeTXT, ownerId),\n\t\t\t},\n\t\t\texpectedCreate: []*endpoint.Endpoint{\n\t\t\t\tnewEndpointWithOwner(\"new-record-1.test-zone.example.org\", \"1.1.1.1\", endpoint.RecordTypeA, ownerId),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"Recreate missing AAAA record when TXT exists\",\n\t\t\tdesired: []*endpoint.Endpoint{\n\t\t\t\tnewEndpointWithOwner(\"new-record-1.test-zone.example.org\", \"2001:db8::1\", endpoint.RecordTypeAAAA, \"\"),\n\t\t\t},\n\t\t\texisting: []*endpoint.Endpoint{\n\t\t\t\tnewEndpointWithOwner(\"new-record-1.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=\"+ownerId+\"\\\"\", endpoint.RecordTypeTXT, ownerId),\n\t\t\t\tnewEndpointWithOwner(\"aaaa-new-record-1.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=\"+ownerId+\"\\\"\", endpoint.RecordTypeTXT, ownerId),\n\t\t\t},\n\t\t\texpectedCreate: []*endpoint.Endpoint{\n\t\t\t\tnewEndpointWithOwner(\"new-record-1.test-zone.example.org\", \"2001:db8::1\", endpoint.RecordTypeAAAA, ownerId),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"Recreate missing CNAME record when TXT exists\",\n\t\t\tdesired: []*endpoint.Endpoint{\n\t\t\t\tnewEndpointWithOwner(\"new-record-1.test-zone.example.org\", \"new-loadbalancer-1.lb.com\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\t},\n\t\t\texisting: []*endpoint.Endpoint{\n\t\t\t\tnewEndpointWithOwner(\"new-record-1.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=\"+ownerId+\"\\\"\", endpoint.RecordTypeTXT, ownerId),\n\t\t\t\tnewEndpointWithOwner(\"cname-new-record-1.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=\"+ownerId+\"\\\"\", endpoint.RecordTypeTXT, ownerId),\n\t\t\t},\n\t\t\texpectedCreate: []*endpoint.Endpoint{\n\t\t\t\tnewEndpointWithOwner(\"new-record-1.test-zone.example.org\", \"new-loadbalancer-1.lb.com\", endpoint.RecordTypeCNAME, ownerId)},\n\t\t},\n\t\t{\n\t\t\tname: \"Recreate missing A and CNAME records when TXT exists\",\n\t\t\tdesired: []*endpoint.Endpoint{\n\t\t\t\tnewEndpointWithOwner(\"new-record-1.test-zone.example.org\", \"1.1.1.1\", endpoint.RecordTypeA, \"\"),\n\t\t\t\tnewEndpointWithOwner(\"new-record-2.test-zone.example.org\", \"new-loadbalancer-1.lb.com\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\t},\n\t\t\texisting: []*endpoint.Endpoint{\n\t\t\t\tnewEndpointWithOwner(\"new-record-1.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=\"+ownerId+\"\\\"\", endpoint.RecordTypeTXT, ownerId),\n\t\t\t\tnewEndpointWithOwner(\"new-record-2.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=\"+ownerId+\"\\\"\", endpoint.RecordTypeTXT, ownerId),\n\t\t\t\tnewEndpointWithOwner(\"a-new-record-1.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=\"+ownerId+\"\\\"\", endpoint.RecordTypeTXT, ownerId),\n\t\t\t\tnewEndpointWithOwner(\"cname-new-record-2.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=\"+ownerId+\"\\\"\", endpoint.RecordTypeTXT, ownerId),\n\t\t\t},\n\t\t\texpectedCreate: []*endpoint.Endpoint{\n\t\t\t\tnewEndpointWithOwner(\"new-record-1.test-zone.example.org\", \"1.1.1.1\", endpoint.RecordTypeA, ownerId),\n\t\t\t\tnewEndpointWithOwner(\"new-record-2.test-zone.example.org\", \"new-loadbalancer-1.lb.com\", endpoint.RecordTypeCNAME, ownerId),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"Recreate missing A records when TXT and CNAME exists\",\n\t\t\tdesired: []*endpoint.Endpoint{\n\t\t\t\tnewEndpointWithOwner(\"new-record-1.test-zone.example.org\", \"1.1.1.1\", endpoint.RecordTypeA, \"\"),\n\t\t\t\tnewEndpointWithOwner(\"new-record-2.test-zone.example.org\", \"new-loadbalancer-1.lb.com\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\t},\n\t\t\texisting: []*endpoint.Endpoint{\n\t\t\t\tnewEndpointWithOwner(\"new-record-2.test-zone.example.org\", \"new-loadbalancer-1.lb.com\", endpoint.RecordTypeCNAME, ownerId),\n\t\t\t\tnewEndpointWithOwner(\"new-record-1.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=\"+ownerId+\"\\\"\", endpoint.RecordTypeTXT, ownerId),\n\t\t\t\tnewEndpointWithOwner(\"new-record-2.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=\"+ownerId+\"\\\"\", endpoint.RecordTypeTXT, ownerId),\n\t\t\t\tnewEndpointWithOwner(\"a-new-record-1.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=\"+ownerId+\"\\\"\", endpoint.RecordTypeTXT, ownerId),\n\t\t\t\tnewEndpointWithOwner(\"cname-new-record-2.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=\"+ownerId+\"\\\"\", endpoint.RecordTypeTXT, ownerId),\n\t\t\t},\n\t\t\texpectedCreate: []*endpoint.Endpoint{\n\t\t\t\tnewEndpointWithOwner(\"new-record-1.test-zone.example.org\", \"1.1.1.1\", endpoint.RecordTypeA, ownerId),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"Only one A record is missing among several existing records\",\n\t\t\tdesired: []*endpoint.Endpoint{\n\t\t\t\tnewEndpointWithOwner(\"record-1.test-zone.example.org\", \"1.1.1.1\", endpoint.RecordTypeA, \"\"),\n\t\t\t\tnewEndpointWithOwner(\"record-2.test-zone.example.org\", \"1.1.1.2\", endpoint.RecordTypeA, \"\"),\n\t\t\t\tnewEndpointWithOwner(\"record-3.test-zone.example.org\", \"1.1.1.3\", endpoint.RecordTypeA, \"\"),\n\t\t\t\tnewEndpointWithOwner(\"record-4.test-zone.example.org\", \"2001:db8::4\", endpoint.RecordTypeAAAA, \"\"),\n\t\t\t\tnewEndpointWithOwner(\"record-5.test-zone.example.org\", \"cluster-b\", endpoint.RecordTypeCNAME, \"\"),\n\t\t\t},\n\t\t\texisting: []*endpoint.Endpoint{\n\t\t\t\tnewEndpointWithOwner(\"record-1.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=\"+ownerId+\"\\\"\", endpoint.RecordTypeTXT, ownerId),\n\t\t\t\tnewEndpointWithOwner(\"a-record-1.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=\"+ownerId+\"\\\"\", endpoint.RecordTypeTXT, ownerId),\n\n\t\t\t\tnewEndpointWithOwner(\"record-2.test-zone.example.org\", \"1.1.1.2\", endpoint.RecordTypeA, ownerId),\n\t\t\t\tnewEndpointWithOwner(\"record-2.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=\"+ownerId+\"\\\"\", endpoint.RecordTypeTXT, ownerId),\n\t\t\t\tnewEndpointWithOwner(\"a-record-2.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=\"+ownerId+\"\\\"\", endpoint.RecordTypeTXT, ownerId),\n\n\t\t\t\tnewEndpointWithOwner(\"record-3.test-zone.example.org\", \"1.1.1.3\", endpoint.RecordTypeA, ownerId),\n\t\t\t\tnewEndpointWithOwner(\"record-3.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=\"+ownerId+\"\\\"\", endpoint.RecordTypeTXT, ownerId),\n\t\t\t\tnewEndpointWithOwner(\"a-record-3.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=\"+ownerId+\"\\\"\", endpoint.RecordTypeTXT, ownerId),\n\n\t\t\t\tnewEndpointWithOwner(\"record-4.test-zone.example.org\", \"2001:db8::4\", endpoint.RecordTypeAAAA, ownerId),\n\t\t\t\tnewEndpointWithOwner(\"record-4.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=\"+ownerId+\"\\\"\", endpoint.RecordTypeTXT, ownerId),\n\t\t\t\tnewEndpointWithOwner(\"aaaa-record-4.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=\"+ownerId+\"\\\"\", endpoint.RecordTypeTXT, ownerId),\n\n\t\t\t\tnewEndpointWithOwner(\"record-5.test-zone.example.org\", \"cluster-b\", endpoint.RecordTypeCNAME, ownerId),\n\t\t\t\tnewEndpointWithOwner(\"record-5.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=\"+ownerId+\"\\\"\", endpoint.RecordTypeTXT, ownerId),\n\t\t\t\tnewEndpointWithOwner(\"cname-record-5.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=\"+ownerId+\"\\\"\", endpoint.RecordTypeTXT, ownerId),\n\t\t\t},\n\t\t\texpectedCreate: []*endpoint.Endpoint{\n\t\t\t\tnewEndpointWithOwner(\"record-1.test-zone.example.org\", \"1.1.1.1\", endpoint.RecordTypeA, ownerId),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"Should not recreate TXT records for existing A records without owner\",\n\t\t\tdesired: []*endpoint.Endpoint{\n\t\t\t\tnewEndpointWithOwner(\"record-1.test-zone.example.org\", \"1.1.1.1\", endpoint.RecordTypeA, \"\"),\n\t\t\t},\n\t\t\texisting: []*endpoint.Endpoint{\n\t\t\t\tnewEndpointWithOwner(\"record-1.test-zone.example.org\", \"1.1.1.1\", endpoint.RecordTypeA, ownerId),\n\t\t\t\t// Missing TXT record for the existing A record\n\t\t\t},\n\t\t\texpectedCreate: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\tname: \"Should not recreate TXT records for existing A records with another owner\",\n\t\t\tdesired: []*endpoint.Endpoint{\n\t\t\t\tnewEndpointWithOwner(\"record-1.test-zone.example.org\", \"1.1.1.1\", endpoint.RecordTypeA, \"\"),\n\t\t\t},\n\t\t\texisting: []*endpoint.Endpoint{\n\t\t\t\t// This test uses the `ownerId` variable, and \"another-owner\" simulates a different owner.\n\t\t\t\t// In this case, TXT records should not be recreated.\n\t\t\t\tnewEndpointWithOwner(\"record-1.test-zone.example.org\", \"1.1.1.1\", endpoint.RecordTypeA, \"another-owner\"),\n\t\t\t\tnewEndpointWithOwner(\"a-record-1.test-zone.example.org\", \"\\\"heritage=external-dns,external-dns/owner=\"+\"another-owner\"+\"\\\"\", endpoint.RecordTypeTXT, \"another-owner\"),\n\t\t\t},\n\t\t\texpectedCreate: []*endpoint.Endpoint{},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tfor _, setIdentifier := range []string{\"\", \"set-identifier\"} {\n\t\t\tfor pName, policy := range plan.Policies {\n\t\t\t\t// Clone inputs per policy to avoid data races when using t.Parallel.\n\t\t\t\tdesired := cloneEndpointsWithOpts(tt.desired, func(e *endpoint.Endpoint) {\n\t\t\t\t\te.WithSetIdentifier(setIdentifier)\n\t\t\t\t})\n\t\t\t\texisting := cloneEndpointsWithOpts(tt.existing, func(e *endpoint.Endpoint) {\n\t\t\t\t\te.WithSetIdentifier(setIdentifier)\n\t\t\t\t})\n\t\t\t\texpectedCreate := cloneEndpointsWithOpts(tt.expectedCreate, func(e *endpoint.Endpoint) {\n\t\t\t\t\te.WithSetIdentifier(setIdentifier)\n\t\t\t\t})\n\n\t\t\t\tt.Run(fmt.Sprintf(\"%s with %s policy and setIdentifier=%s\", tt.name, pName, setIdentifier), func(t *testing.T) {\n\t\t\t\t\tt.Parallel()\n\t\t\t\t\tctx := t.Context()\n\t\t\t\t\tp := inmemory.NewInMemoryProvider()\n\n\t\t\t\t\t// Given: Register existing records\n\t\t\t\t\terr := p.CreateZone(testZone)\n\t\t\t\t\trequire.NoError(t, err)\n\t\t\t\t\terr = p.ApplyChanges(ctx, &plan.Changes{Create: existing})\n\t\t\t\t\tassert.NoError(t, err)\n\n\t\t\t\t\t// The first ApplyChanges call should create the expected records.\n\t\t\t\t\t// Subsequent calls are expected to be no-ops (i.e., no additional creates).\n\t\t\t\t\tisCalled := false\n\t\t\t\t\tp.OnApplyChanges = func(_ context.Context, changes *plan.Changes) {\n\t\t\t\t\t\tif isCalled {\n\t\t\t\t\t\t\tassert.Empty(t, changes.Create, \"ApplyChanges should not be called multiple times with new changes\")\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tassert.True(t,\n\t\t\t\t\t\t\t\ttestutils.SameEndpoints(changes.Create, expectedCreate),\n\t\t\t\t\t\t\t\t\"Expected create changes: %v, but got: %v\", expectedCreate, changes.Create,\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tassert.Empty(t, changes.UpdateNew, \"UpdateNew should be empty\")\n\t\t\t\t\t\tassert.Empty(t, changes.UpdateOld, \"UpdateOld should be empty\")\n\t\t\t\t\t\tassert.Empty(t, changes.Delete, \"Delete should be empty\")\n\t\t\t\t\t\tisCalled = true\n\t\t\t\t\t}\n\n\t\t\t\t\t// When: Apply changes to recreate missing A records\n\t\t\t\t\tmanagedRecords := []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME, endpoint.RecordTypeAAAA, endpoint.RecordTypeTXT}\n\t\t\t\t\tregistry, err := newRegistry(p, \"\", \"\", ownerId, time.Hour, \"\", managedRecords, nil, false, nil, \"\")\n\t\t\t\t\tassert.NoError(t, err)\n\n\t\t\t\t\texpectedRecords := append(existing, expectedCreate...) // nolint:gocritic\n\n\t\t\t\t\t// Simulate the reconciliation loop by executing multiple times\n\t\t\t\t\treconciliationLoops := 3\n\t\t\t\t\tfor i := range reconciliationLoops {\n\t\t\t\t\t\trecords, err := registry.Records(ctx)\n\t\t\t\t\t\tassert.NoError(t, err)\n\t\t\t\t\t\tpl := &plan.Plan{\n\t\t\t\t\t\t\tPolicies:       []plan.Policy{policy},\n\t\t\t\t\t\t\tCurrent:        records,\n\t\t\t\t\t\t\tDesired:        desired,\n\t\t\t\t\t\t\tManagedRecords: managedRecords,\n\t\t\t\t\t\t\tOwnerID:        ownerId,\n\t\t\t\t\t\t}\n\t\t\t\t\t\tpln := pl.Calculate()\n\t\t\t\t\t\terr = registry.ApplyChanges(ctx, pln.Changes)\n\t\t\t\t\t\tassert.NoError(t, err)\n\n\t\t\t\t\t\t// Then: Verify that the missing records are recreated or the existing records are not modified\n\t\t\t\t\t\trecords, err = p.Records(ctx)\n\t\t\t\t\t\tassert.NoError(t, err)\n\t\t\t\t\t\tassert.True(t, testutils.SameEndpoints(records, expectedRecords),\n\t\t\t\t\t\t\t\"Expected records after reconciliation loop #%d: %v, but got: %v\",\n\t\t\t\t\t\t\ti, expectedRecords, records,\n\t\t\t\t\t\t)\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc TestTXTRecordMigration(t *testing.T) {\n\tctx := t.Context()\n\tp := inmemory.NewInMemoryProvider()\n\terr := p.CreateZone(testZone)\n\trequire.NoError(t, err)\n\n\tr, _ := newRegistry(p, \"%{record_type}-\", \"\", \"foo\", time.Hour, \"\", []string{}, []string{}, false, nil, \"\")\n\n\terr = r.ApplyChanges(ctx, &plan.Changes{\n\t\tCreate: []*endpoint.Endpoint{\n\t\t\t// records on cluster using A record for ingress address\n\t\t\tnewEndpointWithOwnerAndLabels(\"bar.test-zone.example.org\", \"1.2.3.4\", endpoint.RecordTypeA, \"foo\", endpoint.Labels{endpoint.OwnerLabelKey: \"owner\"}),\n\t\t},\n\t})\n\trequire.NoError(t, err)\n\n\tcreatedRecords, _ := r.Records(ctx)\n\n\tnewTXTRecord := r.generateTXTRecord(createdRecords[0])\n\n\texpectedTXTRecords := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"a-bar.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"\\\"heritage=external-dns,external-dns/owner=foo\\\"\"},\n\t\t\tRecordType: endpoint.RecordTypeTXT,\n\t\t},\n\t}\n\n\tassert.Equal(t, expectedTXTRecords[0].Targets, newTXTRecord[0].Targets)\n\n\tr, _ = newRegistry(p, \"%{record_type}-\", \"\", \"foobar\", time.Hour, \"\", []string{}, []string{}, false, nil, \"foo\")\n\n\tupdatedRecords, _ := r.Records(ctx)\n\n\tupdatedTXTRecord := r.generateTXTRecord(updatedRecords[0])\n\n\texpectedFinalTXT := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:    \"a-bar.test-zone.example.org\",\n\t\t\tTargets:    endpoint.Targets{\"\\\"heritage=external-dns,external-dns/owner=foobar\\\"\"},\n\t\t\tRecordType: endpoint.RecordTypeTXT,\n\t\t},\n\t}\n\n\tassert.Equal(t, updatedTXTRecord[0].Targets, expectedFinalTXT[0].Targets)\n}\n\n// TestRecreateRecordAfterDeletion ensures that when A and TXT records are deleted,\n// both are correctly recreated in subsequent reconciliation loops.\n// This prevents regression of the issue where stale TXT record state\n// caused ExternalDNS to skip recreating TXT records after deletion.\nfunc TestRecreateRecordAfterDeletion(t *testing.T) {\n\townerID := \"foo\"\n\tctx := t.Context()\n\tp := inmemory.NewInMemoryProvider()\n\terr := p.CreateZone(testZone)\n\trequire.NoError(t, err)\n\n\tr, _ := newRegistry(p, \"%{record_type}-\", \"\", \"foo\", 0, \"\", []string{endpoint.RecordTypeA}, []string{}, false, nil, \"\")\n\n\tcreatedRecords := newEndpointWithOwnerAndLabels(\"bar.test-zone.example.org\", \"1.2.3.4\", endpoint.RecordTypeA, ownerID, nil)\n\ttxtRecord := r.generateTXTRecord(createdRecords)\n\n\t// 1. Create initial A and TXT records.\n\tcreates := append([]*endpoint.Endpoint{createdRecords}, txtRecord...)\n\terr = p.ApplyChanges(ctx, &plan.Changes{\n\t\tCreate: creates,\n\t})\n\tassert.NoError(t, err)\n\n\t// 2. Simulate a \"no change\" reconciliation (ApplyChanges won't be called).\n\tdesired := []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName: \"bar.test-zone.example.org\",\n\t\t\tTargets: endpoint.Targets{\n\t\t\t\t\"1.2.3.4\",\n\t\t\t},\n\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t},\n\t}\n\n\trecords, err := r.Records(ctx)\n\tassert.NoError(t, err)\n\n\tcalculated := &plan.Plan{\n\t\tPolicies:       []plan.Policy{&plan.SyncPolicy{}},\n\t\tManagedRecords: []string{endpoint.RecordTypeA},\n\t\tCurrent:        records,\n\t\tDesired:        desired,\n\t\tOwnerID:        ownerID,\n\t}\n\tcalculated = calculated.Calculate()\n\t// ApplyChanges is not called to simulate no changes.\n\tassert.False(t, calculated.Changes.HasChanges(), \"There should be no changes\")\n\n\t// 3. Delete both A and TXT records (simulate manual deletion)\n\tdeletes := append([]*endpoint.Endpoint{createdRecords}, txtRecord...)\n\terr = p.ApplyChanges(ctx, &plan.Changes{\n\t\tDelete: deletes,\n\t})\n\tassert.NoError(t, err)\n\n\t// 4. Run reconciliation again — both A and TXT should be recreated.\n\trecords, err = r.Records(ctx)\n\tassert.NoError(t, err)\n\n\tcalculated = &plan.Plan{\n\t\tPolicies:       []plan.Policy{&plan.SyncPolicy{}},\n\t\tManagedRecords: []string{endpoint.RecordTypeA},\n\t\tCurrent:        records,\n\t\tDesired:        desired,\n\t\tOwnerID:        ownerID,\n\t}\n\tcalculated = calculated.Calculate()\n\tif !calculated.Changes.HasChanges() {\n\t\tassert.Fail(t, \"There should be changes\")\n\t}\n\n\terr = r.ApplyChanges(ctx, calculated.Changes)\n\tassert.NoError(t, err)\n\n\t// 5. Verify that both A and TXT records are recreated successfully.\n\trecords, err = p.Records(ctx)\n\tassert.NoError(t, err)\n\tassert.True(t, testutils.SameEndpoints(records, append(desired, txtRecord...)), \"Expected records after reconciliation: %v, but got: %v\", append(desired, txtRecord...), records)\n}\n"
  },
  {
    "path": "registry/txt/utils_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage txt\n\nimport (\n\t\"maps\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\nfunc newEndpointWithOwner(dnsName, target, recordType, ownerID string) *endpoint.Endpoint {\n\treturn newEndpointWithOwnerAndLabels(dnsName, target, recordType, ownerID, nil)\n}\n\nfunc newMultiTargetEndpointWithOwner(dnsName string, targets endpoint.Targets, recordType, ownerID string) *endpoint.Endpoint {\n\treturn newMultiTargetEndpointWithOwnerAndLabels(dnsName, targets, recordType, ownerID, nil)\n}\n\nfunc newTXTEndpointWithOwnedRecord(dnsName, target, ownedRecord string) *endpoint.Endpoint {\n\treturn newEndpointWithOwnerAndLabels(dnsName, target, endpoint.RecordTypeTXT, \"\", endpoint.Labels{endpoint.OwnedRecordLabelKey: ownedRecord})\n}\n\nfunc newMultiTargetEndpointWithOwnerAndLabels(dnsName string, targets endpoint.Targets, recordType, ownerID string, labels endpoint.Labels) *endpoint.Endpoint {\n\te := endpoint.NewEndpoint(dnsName, recordType, targets...)\n\te.Labels[endpoint.OwnerLabelKey] = ownerID\n\tmaps.Copy(e.Labels, labels)\n\treturn e\n}\n\nfunc newEndpointWithOwnerAndLabels(dnsName, target, recordType, ownerID string, labels endpoint.Labels) *endpoint.Endpoint {\n\te := endpoint.NewEndpoint(dnsName, recordType, target)\n\te.Labels[endpoint.OwnerLabelKey] = ownerID\n\tmaps.Copy(e.Labels, labels)\n\treturn e\n}\n\nfunc newCNAMEEndpointWithOwnerResource(dnsName, target, ownerID, resource string) *endpoint.Endpoint {\n\te := endpoint.NewEndpoint(dnsName, endpoint.RecordTypeCNAME, target)\n\te.Labels[endpoint.OwnerLabelKey] = ownerID\n\te.Labels[endpoint.ResourceLabelKey] = resource\n\treturn e\n}\n\n// This is primarily used to prevent data races when running tests in parallel (t.Parallel).\nfunc cloneEndpointsWithOpts(list []*endpoint.Endpoint, opt ...func(*endpoint.Endpoint)) []*endpoint.Endpoint {\n\tcloned := make([]*endpoint.Endpoint, len(list))\n\tfor i, e := range list {\n\t\tcloned[i] = cloneEndpointWithOpts(e, opt...)\n\t}\n\treturn cloned\n}\n\nfunc cloneEndpointWithOpts(e *endpoint.Endpoint, opt ...func(*endpoint.Endpoint)) *endpoint.Endpoint {\n\ttargets := make(endpoint.Targets, len(e.Targets))\n\tcopy(targets, e.Targets)\n\n\t// SameEndpoints treats nil and empty maps/slices as different.\n\t// To avoid introducing unintended differences, we retain nil when original is nil.\n\tvar labels endpoint.Labels\n\tif e.Labels != nil {\n\t\tlabels = make(endpoint.Labels, len(e.Labels))\n\t\tmaps.Copy(labels, e.Labels)\n\t}\n\n\tvar providerSpecific endpoint.ProviderSpecific\n\tif e.ProviderSpecific != nil {\n\t\tproviderSpecific = make(endpoint.ProviderSpecific, len(e.ProviderSpecific))\n\t\tfor i, p := range e.ProviderSpecific {\n\t\t\tproviderSpecific[i] = p\n\t\t}\n\t}\n\n\tttl := e.RecordTTL\n\n\tep := &endpoint.Endpoint{\n\t\tDNSName:          e.DNSName,\n\t\tTargets:          targets,\n\t\tRecordType:       e.RecordType,\n\t\tRecordTTL:        ttl,\n\t\tLabels:           labels,\n\t\tProviderSpecific: providerSpecific,\n\t\tSetIdentifier:    e.SetIdentifier,\n\t}\n\tfor _, o := range opt {\n\t\to(ep)\n\t}\n\treturn ep\n}\n"
  },
  {
    "path": "scripts/OWNERS",
    "content": "# See the OWNERS docs at https://go.k8s.io/owners\n\nlabels:\n- scripts\n"
  },
  {
    "path": "scripts/aws-cleanup-legacy-txt-records.py",
    "content": "#!/usr/bin/env python\n\n# Copyright 2025 The Kubernetes Authors.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# Warning: The script deletes all records that match certain values. It could delete both legacy and new records if there is no way to differentiate them.\n\n# This Python script is designed to help migrate DNS management to `external-dns` by cleaning up legacy TXT records in AWS Route 53.\n# It identifies and deletes TXT records that match a specified pattern, ensuring that `external-dns` can take over managing these resources.\n# The script performs the following steps:\n#\n# 1. **Setup and Configuration**:\n#    - Imports necessary libraries (`boto3`, `argparse`, etc.).\n#    - Defines constants and utility functions.\n#    - Parses command-line arguments for configuration.\n#\n# 2. **Record Class**:\n#    - Represents a DNS record with methods to check if it should be deleted.\n#\n# 3. **Main Functionality**:\n#    - Connects to AWS Route 53 using `boto3`.\n#    - Support single zone cleanup at a time.\n#    - Lists and filters TXT records based on the specified pattern.\n#    - Deletes the filtered records in batches, with an option for a dry run or actual deletion.\n#\n# 4. **Execution**:\n#    - The script is executed with command-line arguments specifying the hosted zone ID, record pattern, total items to delete, batch size, and whether to perform a dry run or actual deletion.\n#    - Check 'To Run script' section for more details\n\n# WARNING: run this script at your own RISK. This will delete all the TXT records that do contain certain string.\n# To Run script\n# 1. Python, pip and pipenv installed https://pipenv.pypa.io/en/latest/\n# 2. AWS Access https://docs.aws.amazon.com/signin/latest/userguide/command-line-sign-in.html\n# 3. pipenv shell\n# 4. pip install boto3\n# 5. python scripts/aws-cleanup-legacy-txt-records.py --help\n# 6. DRY RUN python scripts/aws-cleanup-legacy-txt-records.py --zone-id ASDFQEQREWRQADF --record-match text\n# 6.1 Before execution consider to stop `external-dns`\n# 7. Execute Deletion. First few times with reduced number of items\n# - python scripts/aws-cleanup-legacy-txt-records.py --zone-id ASDFQEQREWRQADF --total-items 3 --batch-delete-count 1 --record-match 'external-dns'\n# - python scripts/aws-cleanup-legacy-txt-records.py --zone-id ASDFQEQREWRQADF --total-items 10000 --batch-delete-count 50 --run --record-match \"external-dns/owner=default\"\n\n# python scripts/aws-cleanup-legacy-txt-records.py --help\n# python scripts/aws-cleanup-legacy-txt-records.py --zone-id Z06155043AVN8RVC88TYY --total-items 300 --batch-delete-count 20 --record-match \"external-dns/owner=default\" --run\n\nimport boto3\nfrom botocore.config import Config as AwsConfig\nimport json, argparse, os, uuid, time\n\nMAX_ITEMS=300 # max is 300 https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/route53/client/list_resource_record_sets.html\nSLEEP=1 # in seconds, required to make sure Route53 API is not throttled\nSESSION_ID=uuid.uuid4()\n\ndef json_prettify(data):\n    return json.dumps(data, indent=4, default=str)\n\nclass Record:\n\n    def __init__(self, record):\n        # static\n        self.type = 'TXT'\n        self.record = record\n        self.name = record['Name']\n        self.resource_records = record['ResourceRecords']\n        resource_record = ''\n        for r in self.resource_records:\n            resource_record += r['Value']\n        self.resource_record = resource_record\n\n    def is_for_deletion(self, contains):\n\n        if contains in self.resource_record:\n            return True\n        return False\n\n    def __str__(self):\n        return f'record: name: {self.name}, type: {self.type}, records: {self.resource_record}'\n\nclass Config:\n\n    def __init__(self, zone_id, contain, total_items, batch, run):\n        self.zone_id = zone_id\n        self.record_contain = contain\n        self.total_items = total_items\n        self.batch_size = batch\n        self.run = run\n        self.contain = contain\n\ndef records(config: Config) -> None:\n    print(f\"calculate TXT records to cleanup for 'zone:{config.zone_id}' and 'max records:{config.total_items}'\")\n    # https://botocore.amazonaws.com/v1/documentation/api/latest/reference/config.html\n    cfg = AwsConfig(\n        user_agent=f\"ExternalDNS/boto3-{SESSION_ID}\",\n    )\n    r53client = boto3.client('route53', config=cfg)\n    dns_records_to_cleanup = []\n    items = 0\n    try:\n        params = {\n            'HostedZoneId': config.zone_id,\n            'MaxItems': str(MAX_ITEMS),\n        }\n        dns_in_iteration = r53client.list_resource_record_sets(**params)\n        elements = dns_in_iteration['ResourceRecordSets']\n        for el in elements:\n            if el['Type'] == 'TXT':\n                record = Record(el)\n                if record.is_for_deletion(config.contain):\n                    dns_records_to_cleanup.append(record)\n                    print(\"to cleanup >>\", record)\n                    items += 1\n                    if items >= config.total_items:\n                        break\n\n        while len(elements) > 0 and 'NextRecordName' in dns_in_iteration.keys() and items < config.total_items:\n            dns_in_iteration = r53client.list_resource_record_sets(\n                HostedZoneId= config.zone_id,\n                StartRecordName= dns_in_iteration['NextRecordName'],\n                MaxItems= str(MAX_ITEMS),\n            )\n            elements = dns_in_iteration['ResourceRecordSets']\n            for el in elements:\n                if el['Type'] == 'TXT':\n                    record = Record(el)\n                    if record.is_for_deletion(config.contain):\n                        dns_records_to_cleanup.append(record)\n                        print(\"to cleanup >>\", record)\n                        items += 1\n                        if items >= config.total_items:\n                            break\n\n        if len(dns_records_to_cleanup) > 0:\n            delete_records(r53client, config, dns_records_to_cleanup)\n        else:\n            print(\"No 'TXT' records found to cleanup....\")\n    except Exception as e:\n        print(f\"An error occurred: {e}\")\n        os._exit(os.EX_OSERR)\n\ndef delete_records(client: boto3.client, config: Config, records: list[Record]) -> None:\n    total=len(records)\n    print(f\"will cleanup '{total}' records with batch '{config.batch_size}' at a time\")\n    count = 0\n\n    if config.run:\n        print(\"deletion of records!!\")\n    else:\n        print(\"dry run execution\")\n\n    for i in range(0, total, config.batch_size):\n        if config.batch_size <= 0:\n            break\n        batch = records[i:min(i + config.batch_size, total)]\n        count += config.batch_size\n        if count >= total:\n            count = total\n\n        changes = []\n\n        for el in batch:\n            # https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/route53/client/change_resource_record_sets.html\n            changes.append({\n                            'Action': 'DELETE',\n                            'ResourceRecordSet': el.record\n                        })\n\n        print(f\"BATCH deletion(start). {len(changes)} records > {changes}\")\n\n        if config.run:\n            client.change_resource_record_sets(\n                HostedZoneId=config.zone_id,\n                ChangeBatch={\n                    \"Comment\": \"external-dns legacy record cleanup. batch of \",\n                    \"Changes\": changes,\n                }\n            )\n            time.sleep(SLEEP)\n\n        print(f\"BATCH deletion(success). {count}/{total}(deleted/total)\")\n\nif __name__ == \"__main__\":\n    parser = argparse.ArgumentParser(description=\"Cleanup legacy TXT records\")\n    parser.add_argument(\"--zone-id\", type=str, required=True, help=\"Hosted Zone ID for which to run a cleanup.\")\n    parser.add_argument(\"--record-match\", type=str, required=True, help=\"Record to match specific value. Example 'external-dns/owner=default'\")\n    parser.add_argument(\"--total-items\", type=int, required=False, default=10, help=\"Number of items to delete. Default to 10\")\n    parser.add_argument(\"--batch-delete-count\", type=int, required=False, default=2, help=\"Number of items to delete in single DELETE batch. Default to 2\")\n    parser.add_argument(\"--run\", action=\"store_true\", help=\"Execute the cleanup. The tool will do a dry-run if --run is not specified.\")\n\n    answer = input(\"Run this script at your own RISKS!!! Please enter 'yes' or 'no': \")\n    if answer != 'yes':\n        os._exit(0)\n\n    print(f\"Session ID  '{SESSION_ID}'\")\n\n    args = parser.parse_args()\n    print(\"arguments:\",args)\n    cfg = Config(\n        zone_id=args.zone_id,\n        contain=args.record_match,\n        total_items=args.total_items,\n        batch=args.batch_delete_count,\n        run=args.run,\n    )\n    records(cfg)\n"
  },
  {
    "path": "scripts/e2e-test.sh",
    "content": "#!/bin/bash\n\nset -e\n\nKO_VERSION=\"0.18.0\"\nKIND_VERSION=\"0.30.0\"\nALPINE_VERSION=\"3.22\"\nKUBECTL_VERSION=\"1.35.0\"\n\necho \"Starting end-to-end tests for external-dns with local provider...\"\n\n# Install kind\necho \"Installing kind...\"\ncurl -Lo ./kind https://kind.sigs.k8s.io/dl/v${KIND_VERSION}/kind-linux-amd64\nchmod +x ./kind\nsudo mv ./kind /usr/local/bin/kind\n\n# Cleanup function\ncleanup() {\n    echo \"Cleaning up...\"\n    kind delete cluster 2>/dev/null || true\n}\n\n# Create kind cluster\necho \"Creating kind cluster...\"\nkind delete cluster 2>/dev/null || true\nkind create cluster\n\n# Set trap to cleanup on script exit\ntrap cleanup EXIT\n\n# Install kubectl\necho \"Installing kubectl...\"\ncurl -LO \"https://dl.k8s.io/release/v${KUBECTL_VERSION}/bin/linux/amd64/kubectl\"\nchmod +x kubectl\nsudo mv kubectl /usr/local/bin/kubectl\n\n# Install ko\necho \"Installing ko...\"\ncurl -sSfL \"https://github.com/ko-build/ko/releases/download/v${KO_VERSION}/ko_${KO_VERSION}_linux_x86_64.tar.gz\" > ko.tar.gz\ntar xzf ko.tar.gz ko\nchmod +x ./ko\nsudo mv ko /usr/local/bin/ko\n\n# Build external-dns\necho \"Building external-dns...\"\n# Use ko with --local to save the image to Docker daemon\nEXTERNAL_DNS_IMAGE_FULL=$(KO_DOCKER_REPO=ko.local VERSION=$(git describe --tags --always --dirty) \\\n    ko build --tags \"$(git describe --tags --always --dirty)\" --bare --sbom none \\\n    --platform=linux/amd64 --local .)\necho \"Built image: $EXTERNAL_DNS_IMAGE_FULL\"\n\n# Extract image name and tag (strip the @sha256 digest for kind load and kustomize)\nEXTERNAL_DNS_IMAGE=\"${EXTERNAL_DNS_IMAGE_FULL%%@*}\"\necho \"Using image reference: $EXTERNAL_DNS_IMAGE\"\n\n# apply etcd deployment as provider\necho \"Applying etcd\"\nkubectl apply -f e2e/provider/etcd.yaml\n\n# wait for etcd to be ready\necho \"Waiting for etcd to be ready...\"\nkubectl wait --for=condition=ready --timeout=120s pod -l app=etcd\n\n# apply coredns deployment\necho \"Applying CoreDNS\"\nkubectl apply -f e2e/provider/coredns.yaml\n\n# wait for coredns to be ready\necho \"Waiting for CoreDNS to be ready...\"\nkubectl wait --for=condition=available --timeout=120s deployment/coredns\n\n# Build a DNS testing image with dig\necho \"Building DNS test image with dig...\"\ndocker build -t dns-test:v1 -f - . <<EOF\nFROM alpine:${ALPINE_VERSION}\nRUN apk add --no-cache bind-tools curl\nENTRYPOINT [\"sh\"]\nEOF\n\n# Load all images into kind cluster\necho \"Loading Docker images into kind cluster...\"\nkind load docker-image \"$EXTERNAL_DNS_IMAGE\"\nkind load docker-image dns-test:v1\n\n# Deploy ExternalDNS to the cluster\necho \"Deploying external-dns with custom arguments...\"\n\n# Create temporary directory for kustomization\nTEMP_KUSTOMIZE_DIR=$(mktemp -d)\ncp -r kustomize/* \"$TEMP_KUSTOMIZE_DIR/\"\n\n# Create patch file on the fly\ncat <<EOF > \"$TEMP_KUSTOMIZE_DIR/deployment-args-patch.yaml\"\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: external-dns\nspec:\n  template:\n    spec:\n      hostNetwork: true\n      dnsPolicy: ClusterFirstWithHostNet\n      containers:\n        - name: external-dns\n          args:\n            - --source=service\n            - --provider=coredns\n            - --txt-owner-id=external.dns\n            - --policy=sync\n            - --log-level=debug\n          env:\n            - name: ETCD_URLS\n              value: http://etcd-0.etcd:2379\nEOF\n\n# Update kustomization.yaml to include the patch\ncat <<EOF > \"$TEMP_KUSTOMIZE_DIR/kustomization.yaml\"\napiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\n\nimages:\n  - name: registry.k8s.io/external-dns/external-dns\n    newName: ${EXTERNAL_DNS_IMAGE%%:*}\n    newTag: ${EXTERNAL_DNS_IMAGE##*:}\n\nresources:\n  - ./external-dns-deployment.yaml\n  - ./external-dns-serviceaccount.yaml\n  - ./external-dns-clusterrole.yaml\n  - ./external-dns-clusterrolebinding.yaml\n\npatchesStrategicMerge:\n  - ./deployment-args-patch.yaml\nEOF\n\n# Apply the kustomization\nkubectl kustomize \"$TEMP_KUSTOMIZE_DIR\" | kubectl apply -f -\n\n# add a wait for the deployment to be available\nkubectl wait --for=condition=available --timeout=60s deployment/external-dns || true\n\nkubectl describe pods -l app=external-dns\nkubectl describe deployment external-dns\nkubectl logs -l app=external-dns\n\n# Cleanup temporary directory\nrm -rf \"$TEMP_KUSTOMIZE_DIR\"\n\n# Apply kubernetes yaml with service\necho \"Applying Kubernetes service...\"\nkubectl apply -f e2e\n\n# Check that the records are present\necho \"Checking services again...\"\nkubectl get svc -owide\nkubectl logs -l app=external-dns\n\n# Check that the DNS records are present using our DNS server\necho \"Testing DNS server functionality...\"\n\n# Get the node IP where the pod is running (since we're using hostNetwork)\nNODE_IP=$(kubectl get nodes -o jsonpath='{.items[0].status.addresses[?(@.type==\"InternalIP\")].address}')\necho \"Node IP: $NODE_IP\"\n\n# Test our DNS server with dig, with retry logic\necho \"Testing DNS server with dig (with retries)...\"\n\n# Create DNS test job that uses dig to query our DNS server with retries\ncat <<EOF | kubectl apply -f -\napiVersion: batch/v1\nkind: Job\nmetadata:\n  name: dns-server-test-job\n  labels:\n    app: dns-server-test\nspec:\n  backoffLimit: 0\n  template:\n    metadata:\n      labels:\n        app: dns-server-test\n    spec:\n      restartPolicy: Never\n      hostNetwork: true\n      containers:\n      - name: dns-server-test\n        image: dns-test:v1\n        command:\n        - /bin/sh\n        - -c\n        - |\n          echo \"Testing DNS server at $NODE_IP:5353\"\n          echo \"=== Testing DNS server with dig (retrying for up to 180s) ===\"\n          MAX_ATTEMPTS=18\n          ATTEMPT=1\n          while [ \\$ATTEMPT -le \\$MAX_ATTEMPTS ]; do\n            echo \"Attempt \\$ATTEMPT/\\$MAX_ATTEMPTS: Querying externaldns-e2e.external.dns A record\"\n            RESULT=\\$(dig @$NODE_IP -p 5353 externaldns-e2e.external.dns A +short +timeout=5 2>/dev/null)\n            if echo \"\\$RESULT\" | grep -qE '^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+$'; then\n              echo \"DNS query successful: \\$RESULT\"\n              exit 0\n            fi\n            echo \"DNS query returned empty result, retrying in 10s...\"\n            sleep 10\n            ATTEMPT=\\$((ATTEMPT + 1))\n          done\n          echo \"DNS query failed after \\$MAX_ATTEMPTS attempts\"\n          exit 1\n\nEOF\n\n# Wait for the job to complete\necho \"Waiting for DNS server test job to complete...\"\nkubectl wait --for=condition=complete --timeout=240s job/dns-server-test-job || true\n\n# Check job status and get results\necho \"DNS server test job results:\"\nkubectl logs job/dns-server-test-job\n\n# Final validation\nJOB_SUCCEEDED=$(kubectl get job dns-server-test-job -o jsonpath='{.status.succeeded}')\nif [ \"$JOB_SUCCEEDED\" = \"1\" ]; then\n    echo \"SUCCESS: DNS server test completed successfully\"\n    TEST_PASSED=true\nelse\n    echo \"WARNING: DNS server test job did not complete successfully\"\n    kubectl describe job dns-server-test-job\n    TEST_PASSED=false\nfi\n\n# Cleanup the test job\nkubectl delete job dns-server-test-job\n\necho \"End-to-end test completed!\"\n\nif [ \"$TEST_PASSED\" != \"true\" ]; then\n    exit 1\nfi\n"
  },
  {
    "path": "scripts/generate-crd.sh",
    "content": "#!/usr/bin/env bash\n# Copyright 2026 The Kubernetes Authors.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# generate-crd.sh\n#\n# This script generates Kubernetes Custom Resource Definitions (CRDs) and related\n# deepcopy code for external-dns using controller-gen from controller-tools.\n#\n## What this script does:\n# 1. Generates DeepCopy methods for types in the endpoint package\n# 2. Generates CRD manifests for API types in the apis package\n# 3. Copies CRDs to the Helm chart directory\n#\n# Usage:\n#   ./scripts/generate-crd.sh\n#   make crd  # calls this script\n\nset -euo pipefail\n\n# Get the script directory\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n# Get the project root (parent of scripts directory)\nPROJECT_ROOT=\"$(cd \"${SCRIPT_DIR}/..\" && pwd)\"\n\ncd \"${PROJECT_ROOT}\"\n\n# Define tool commands (using tools from go.tool.mod)\nCONTROLLER_GEN=\"go tool -modfile=go.tool.mod controller-gen\"\nYQ=\"go tool -modfile=go.tool.mod yq\"\nYAMLFMT=\"go tool -modfile=go.tool.mod yamlfmt\"\n\necho \" Generating CRDs using controller-gen...\"\n\n# Step 1: Generate deepcopy methods for endpoint types\n# This creates zz_generated.deepcopy.go with DeepCopy/DeepCopyInto/DeepCopyObject methods\n# The 'object' generator adds these methods for types marked with +kubebuilder:object markers\necho \"  → Generating deepcopy for endpoint package...\"\n${CONTROLLER_GEN} object crd:crdVersions=v1 paths=\"./endpoint/...\"\n\n# Clean up empty import statements from generated files\n# controller-gen sometimes adds empty import() blocks which create noise in diffs\nfind ./endpoint -name \"zz_generated.deepcopy.go\" -exec gofmt -s -w {} \\;\n\n# Step 2: Generate CRD manifests for API types\n# - Generates CRDs from Go types with kubebuilder markers\n# - Outputs to stdout, formats with yamlfmt, then splits into individual files\n# - Each CRD is saved to config/crd/standard/<crd-name>.yaml\necho \"  → Generating CRDs for apis package...\"\n${CONTROLLER_GEN} object crd:crdVersions=v1 paths=\"./apis/...\" output:crd:stdout | \\\n    ${YAMLFMT} - | \\\n    ${YQ} eval '.' --no-doc --split-exp '\"./config/crd/standard/\" + .metadata.name + \".yaml\"'\n\n# Clean up empty import statements from generated files\nfind ./apis -name \"zz_generated.deepcopy.go\" -exec gofmt -s -w {} \\;\n\n# Step 3: Copy CRDs to Helm chart with filtered annotations\n# - Reads CRDs from config/crd/standard/\n# - Filters annotations to only keep kubernetes.io/* (removes controller-gen annotations)\n# - Splits and saves to charts/external-dns/crds/ for Helm chart packaging\necho \"  → Copying CRDs to chart directory...\"\n${YQ} eval '.metadata.annotations |= with_entries(select(.key | test(\"kubernetes\\.io\")))' \\\n    --no-doc --split-exp '\"./charts/external-dns/crds/\" + .metadata.name + \".yaml\"' \\\n    ./config/crd/standard/*.yaml\n\necho -e \"  ✅ CRD generation complete\"\n"
  },
  {
    "path": "scripts/get-sha256.sh",
    "content": "#!/bin/bash\n\nIMAGE=$1\n\necho -n \"image: \"\ncrane digest \"${IMAGE}\"\necho \"architecture\"\ncrane manifest \"${IMAGE}\" | jq -r '.manifests.[] | .platform.architecture, .digest'\n"
  },
  {
    "path": "scripts/helm-tools.sh",
    "content": "#!/bin/bash\n\nset -e\n\n# JSON Schema https://json-schema.org/\n# JSON Schema spec https://json-schema.org/draft/2020-12/json-schema-validation\n# Helm Schema https://helm.sh/docs/topics/charts/#schema-files\n\n# Execute\n# scripts/helm-tools.sh\n# scripts/helm-tools.sh -h\n# scripts/helm-tools.sh --install\n# scripts/helm-tools.sh --diff\n# scripts/helm-tools.sh --schema\n# scripts/helm-tools.sh --lint\n# scripts/helm-tools.sh --docs\n# scripts/helm-tools.sh --helm-template\n# scripts/helm-tools.sh --helm-unittest\n\nshow_help() {\ncat << EOF\n'external-dns' helm linter helper commands\n\nUsage: $(basename \"$0\") <options>\n    -d, --diff          Schema diff validation\n    --docs              Re-generate helm documentation\n    -h, --help          Display help\n    -i, --install       Install required tooling\n    -l, --lint          Lint chart\n    -s, --schema        Generate schema\n    --helm-unittest     Run helm unittest(s)\n    --helm-template     Run helm template\n    --show-docs         Show available documentation\nEOF\n}\n\ninstall() {\n  if [[ -x $(which helm) ]]; then\n      echo \"installing https://github.com/losisin/helm-values-schema-json.git plugin\"\n      helm plugin install https://github.com/losisin/helm-values-schema-json.git --verify=false | true\n      helm plugin update schema\n      helm plugin list | grep \"schema\"\n\n      helm plugin install https://github.com/helm-unittest/helm-unittest.git --verify=false | true\n      helm plugin update unittest\n      helm plugin list | grep \"unittest\"\n\n      echo \"installing helm-docs\"\n      go install github.com/norwoodj/helm-docs/cmd/helm-docs@latest | true\n\n      if [[ -x $(which brew) ]]; then\n        echo \"installing chart-testing https://github.com/helm/chart-testing\"\n        brew install chart-testing\n      fi\n    else\n      echo \"helm is not installed\"\n      echo \"install helm https://helm.sh/docs/intro/install/ and try again\"\n      exit 1\n  fi\n}\n\nupdate_schema() {\n  cd charts/external-dns\n  # uses .schema.yamle\n  helm schema\n}\n\ndiff_schema() {\n  cd charts/external-dns\n  helm schema \\\n    --output diff-schema.schema.json\n  trap 'rm -rf -- \"diff-schema.schema.json\"' EXIT\n  CURRENT_SCHEMA=$(cat values.schema.json)\n  GENERATED_SCHEMA=$(cat diff-schema.schema.json)\n  if [ \"$CURRENT_SCHEMA\" != \"$GENERATED_SCHEMA\" ]; then\n    echo \"Schema must be re-generated! Run 'scripts/helm-tools.sh --schema'\" 1>&2\n    diff -Nau diff-schema.schema.json values.schema.json\n    exit 1\n  fi\n}\n\nlint_chart() {\n  cd charts/external-dns\n  helm lint . --debug --strict \\\n  --values values.yaml \\\n  --values ci/ci-values.yaml\n  # lint with chart testing tool\n  ct lint --target-branch=master --check-version-increment=false\n}\n\nhelm_docs() {\n  cd charts/external-dns\n  helm-docs\n}\n\nhelm_unittest() {\n  helm unittest -f 'tests/*_test.yaml' --color charts/external-dns\n}\n\nhelm_template() {\n  helm template external-dns charts/external-dns \\\n\t\t--output-dir _scratch \\\n\t\t-n kube-system\n}\n\nshow_docs() {\n  open \"https://github.com/losisin/helm-values-schema-json?tab=readme-ov-file\"\n}\n\nfunction main() {\n  case $1 in\n    --show-docs)\n      show_docs\n      ;;\n    --helm-unittest)\n      helm_unittest\n      ;;\n    --helm-template)\n      helm_template\n      ;;\n    -d|--diff)\n      diff_schema\n      ;;\n    --docs)\n      helm_docs\n      ;;\n    -i|--install)\n      install\n      ;;\n    -l|--lint)\n      lint_chart\n      ;;\n    -s|--schema)\n      update_schema\n      ;;\n    -h|--help)\n      show_help\n      ;;\n    *)\n      echo \"unknown sub-command\" >&2\n      show_help\n      exit 1\n      ;;\n  esac\n}\n\nmain \"$@\"\n"
  },
  {
    "path": "scripts/install-ko.sh",
    "content": "#!/usr/bin/env bash\n\n# Copyright 2022 The Kubernetes Authors.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nset -o errexit\nset -o nounset\nset -o pipefail\n\nif ! command -v ko &> /dev/null; then\n  cd \"$(dirname \"${BASH_SOURCE[0]}\")\" || exit 1\n  go install github.com/google/ko@v0.17.1\nfi\n"
  },
  {
    "path": "scripts/install-tools.sh",
    "content": "#!/usr/bin/env bash\n\n# Copyright 2025 The Kubernetes Authors.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# renovate: datasource=github-releases depName=golangci/golangci-lint\nGOLANG_CI_LINTER_VERSION=v2.7.2\n\n# Execute\n# scripts/install-tools.sh\n# scripts/install-tools.sh -h\n# scripts/install-tools.sh --generator\n# scripts/install-tools.sh --golangci\n\nshow_help() {\ncat << EOF\n'external-dns' helm linter helper commands\n\nUsage: $(basename \"$0\") <options>\n    -h, --help          Display help\n    --generator         Install generator\n    --golangci          Install golangci linter\nEOF\n}\n\ninstall_golangci() {\n  local install=false\n  if [[ -x $(which golangci-lint) ]]; then\n      local version=$(golangci-lint version --short)\n      if [[ \"${version}\" == \"${GOLANG_CI_LINTER_VERSION#v}\" ]]; then\n          install=false\n        else\n          install=true\n      fi\n    else\n      install=true\n  fi\n  if [[ \"$install\" == true ]]; then\n      curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/9f61b0f53f80672872fced07b6874397c3ed197b/install.sh \\\n        | sh -s -- -b $(go env GOPATH)/bin \"${GOLANG_CI_LINTER_VERSION}\"\n  fi\n}\n\nfunction main() {\n  case $1 in\n    --golangci)\n      install_golangci\n      ;;\n    -h|--help)\n      show_help\n      ;;\n    *)\n      echo \"unknown sub-command\" >&2\n      show_help\n      exit 1\n      ;;\n  esac\n}\n\nmain \"$@\"\n"
  },
  {
    "path": "scripts/releaser.sh",
    "content": "#!/bin/bash\nset -e\n\n\n\nfunction generate_changelog {\n  MERGED_PRS=\"$1\"\n\n  echo\n  echo \"## :warning: Breaking Changes\"\n  echo\n  cat \"${MERGED_PRS}\" | grep \"\\!\" || true # no breaking change, section should be removed.\n\n  echo\n  echo \"## :rocket: Features\"\n  echo\n  cat \"${MERGED_PRS}\" | grep feat[:\\(]\n\n  echo\n  echo \"## :bug: Bug fixes\"\n  echo\n  cat \"${MERGED_PRS}\" | grep fix[:\\(]\n\n  echo\n  echo \"## :memo: Documentation\"\n  echo\n  cat \"${MERGED_PRS}\" | grep docs[:\\(]\n\n  echo\n  echo \"## :package: Others\"\n  echo\n  cat \"${MERGED_PRS}\" | grep -v \"\\!\" | grep -v feat[:\\(] | grep -v fix[:\\(] | grep -v docs[:\\(]\n\n  echo\n  echo \"## :package: Docker Image\"\n  echo\n  echo \"\\`\\`\\`sh\"\n  echo \"# This pull command only works when it's released\"\n  echo \"docker pull registry.k8s.io/external-dns/external-dns:${VERSION}\"\n  echo \"\\`\\`\\`\"\n\n}\n\nfunction create_release {\n  generate_changelog | sort # | gh release create \"$1\" -t \"$1\" -F -\n}\n\nfunction latest_release {\n  gh release list -L 10 --json name,isLatest --jq '.[] | select(.isLatest)|.name'\n}\n\nfunction latest_release_date {\n  gh release list -L 10 --json name,isLatest,publishedAt --jq '.[] | select(.isLatest)|.publishedAt'\n}\n\nfunction latest_release_ts {\n  gh release list -L 10 --json name,isLatest,publishedAt --jq '.[] | select(.isLatest)|.publishedAt | fromdateiso8601'\n}\n\nif [ $# -ne 1 ]; then\n    echo \"** DRY RUN **\"\nfi\n\nprintf \"Latest release: %s (%s)\\n\" $(latest_release) $(latest_release_date)\n\nTIMESTAMP=$(latest_release_ts)\nMERGED_PRS=$(mktemp)\ngh pr list \\\n  --state merged \\\n  --json author,number,mergeCommit,mergedAt,url,title \\\n  --limit 999 \\\n  --jq \".[] |\n    select (.mergedAt | fromdateiso8601 > ${TIMESTAMP}) | \\\n    \\\"- \\(.title) by @\\(.author.login) in #\\(.number)\\\"\n  \" | sort > \"${MERGED_PRS}\"\n\nif [ $# -ne 1 ]; then\n  export VERSION=\"v0.x.0\"\n  generate_changelog \"${MERGED_PRS}\"\n  echo \"** DRY RUN **\"\n  echo\n  echo \"To create a release: ./releaser.sh v0.x.0\"\nelse\n  export VERSION=\"$1\"\n  generate_changelog \"${MERGED_PRS}\" | gh release create \"${VERSION}\" -t \"${VERSION}\" -p -F -\nfi\n\nrm -f \"${MERGED_PRS}\"\n"
  },
  {
    "path": "scripts/update_route53_k8s_txt_owner.py",
    "content": "#!/usr/bin/env python\n\n# Copyright 2018 The Kubernetes Authors.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#     http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# This is a script that we wrote to try to help the migration over to using external-dns.\n# This script looks at kubernetes ingresses and services (which are the two things we have\n# external-dns looking at) and compares them to existing TXT and A records in route53 to\n# find out where there are gaps.  It then assigns the heritage and owner TXT records where\n# needed so external-dns can take over managing those resources. You can modify the script\n# to only look at one or the other if needed.\n#\n# pip install kubernetes boto3\n\nimport boto3\nfrom kubernetes import client, config\n\n# replace with your hosted zone id\nhosted_zone_id = ''\n# replace with your txt-owner-id you are using\n# inside of your external-dns controller\ntxt_owner_id = ''\n\n# change to false if you have external-dns not looking at services\nexternal_dns_manages_services = True\n\n# change to false if you have external-dns not looking at ingresses\nexternal_dns_manages_ingresses = True\n\nconfig.load_kube_config()\n\n# grab all the domains that k8s thinks it is going to\n# manage (services with domainName specified and\n# ingress hosts)\nk8s_domains = []\n\nif external_dns_manages_services:\n    v1 = client.CoreV1Api()\n    svcs = v1.list_service_for_all_namespaces()\n    for i in svcs.items:\n        annotations = i.metadata.annotations\n        if annotations is not None and 'domainName' in annotations:\n            k8s_domains.extend(annotations['domainName'].split(','))\n\nif external_dns_manages_ingresses:\n    ev1 = client.NetworkingV1Api()\n    ings = ev1.list_ingress_for_all_namespaces()\n    for i in ings.items:\n        for r in i.spec.rules:\n            if r.host not in k8s_domains:\n                k8s_domains.append(r.host)\n\n\nr53client = boto3.client('route53')\n\n# grab the existing route53 domains and identify gaps where a domain may be\n# missing a txt record pair\nexisting_r53_txt_domains=[]\nexisting_r53_domains=[]\nhas_next = True\nnext_record_name, next_record_type='',''\n\nwhile has_next:\n    if next_record_name is not '' and next_record_type is not '':\n        resource_records = r53client.list_resource_record_sets(HostedZoneId=hosted_zone_id,\n                                                               StartRecordName=next_record_name,\n                                                               StartRecordType=next_record_type)\n    else:\n        resource_records = r53client.list_resource_record_sets(HostedZoneId=hosted_zone_id)\n\n    for r in resource_records['ResourceRecordSets']:\n        if r['Type'] == 'TXT':\n            existing_r53_txt_domains.append(r['Name'][:-1])\n        elif r['Type'] == 'A':\n            existing_r53_domains.append(r['Name'][:-1])\n    has_next = resource_records['IsTruncated']\n    if has_next:\n        next_record_name, next_record_type = resource_records['NextRecordName'], resource_records['NextRecordType']\n\n# grab only the domains in route53 that kubernetes is managing\nr53_k8s_domains = [r for r in k8s_domains if r in existing_r53_domains]\n# from those find the ones that do not have matching txt entries\nmissing_k8s_txt = [r for r in r53_k8s_domains if r not in existing_r53_txt_domains]\n\n# make the change batch for the route53 call, modify this as needed\nchange_batch=[]\nfor r in missing_k8s_txt:\n    change_batch.append(\n        {\n            'Action': 'CREATE',\n            'ResourceRecordSet': {\n                'Name': r,\n                'Type': 'TXT',\n                'TTL': 300,\n                'ResourceRecords': [\n                    {\n                        'Value': '\\heritage=external-dns,owner=\"' + txt_owner_id + '\\\"'\n                    },\n                ]\n            }\n        })\n\nprint('This will create the following resources')\nprint(change_batch)\nresponse = input(\"Good to go? \")\n\nif response.lower() in ['y', 'yes', 'yup', 'ok', 'sure', 'why not', 'why not?']:\n    print('Updating route53')\n    change_response = r53client.change_resource_record_sets(\n                            HostedZoneId=hosted_zone_id,\n                            ChangeBatch={\n                              'Changes': change_batch\n                        })\n    print('Submitted change request to route53. Details below.')\n    print(change_response)\nelse:\n    print('No changes were made')\n"
  },
  {
    "path": "scripts/version-updater.sh",
    "content": "#!/bin/bash\nset -e\n\nPREV_TAG=$1\nNEW_TAG=$2\n\nsed -i -e \"s/newTag: .*/newTag: ${NEW_TAG}/g\" kustomize/kustomization.yaml\ngit add kustomize/kustomization.yaml\n\nsed -i -e \"s/${PREV_TAG}/${NEW_TAG}/g\" *.md docs/*.md docs/*/*.md\ngit add *.md docs/*.md docs/*/*.md\n\ngit commit -sm \"chore(release): updates kustomize & docs with ${NEW_TAG}\"\n"
  },
  {
    "path": "source/OWNERS",
    "content": "# See the OWNERS docs at https://go.k8s.io/owners\n\nlabels:\n- source\n"
  },
  {
    "path": "source/ambassador_host.go",
    "content": "/*\nCopyright 2020 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\n\tambassador \"github.com/datawire/ambassador/pkg/api/getambassador.io/v2\"\n\tlog \"github.com/sirupsen/logrus\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\t\"k8s.io/apimachinery/pkg/runtime\"\n\t\"k8s.io/apimachinery/pkg/runtime/schema\"\n\t\"k8s.io/client-go/dynamic\"\n\t\"k8s.io/client-go/dynamic/dynamicinformer\"\n\tkubeinformers \"k8s.io/client-go/informers\"\n\t\"k8s.io/client-go/kubernetes\"\n\t\"k8s.io/client-go/kubernetes/scheme\"\n\n\t\"sigs.k8s.io/external-dns/source/types\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n\t\"sigs.k8s.io/external-dns/source/informers\"\n)\n\nconst (\n\t// ambHostAnnotation is the annotation in the Host that maps to a Service\n\tambHostAnnotation = \"external-dns.ambassador-service\"\n\t// groupName is the group name for the Ambassador API\n\tgroupName = \"getambassador.io\"\n)\n\nvar (\n\tschemeGroupVersion = schema.GroupVersion{Group: groupName, Version: \"v2\"}\n\tambHostGVR         = schemeGroupVersion.WithResource(\"hosts\")\n)\n\n// ambassadorHostSource is an implementation of Source for Ambassador Host objects.\n// The IngressRoute implementation uses the spec.virtualHost.fqdn value for the hostname.\n// Use annotations.TargetKey to explicitly set Endpoint.\n//\n// +externaldns:source:name=ambassador-host\n// +externaldns:source:category=Ingress Controllers\n// +externaldns:source:description=Creates DNS entries from Ambassador Host resources\n// +externaldns:source:resources=Host.getambassador.io\n// +externaldns:source:filters=annotation,label\n// +externaldns:source:namespace=all,single\n// +externaldns:source:fqdn-template=false\n// +externaldns:source:events=false\n// +externaldns:source:provider-specific=true\ntype ambassadorHostSource struct {\n\tdynamicKubeClient      dynamic.Interface\n\tkubeClient             kubernetes.Interface\n\tnamespace              string\n\tannotationFilter       string\n\tambassadorHostInformer kubeinformers.GenericInformer\n\tunstructuredConverter  *unstructuredConverter\n\tlabelSelector          labels.Selector\n}\n\n// NewAmbassadorHostSource creates a new ambassadorHostSource with the given config.\nfunc NewAmbassadorHostSource(\n\tctx context.Context,\n\tdynamicKubeClient dynamic.Interface,\n\tkubeClient kubernetes.Interface,\n\tcfg *Config,\n) (Source, error) {\n\t// Use shared informer to listen for add/update/delete of Host in the specified namespace.\n\t// Set resync period to 0, to prevent processing when nothing has changed.\n\tinformerFactory := dynamicinformer.NewFilteredDynamicSharedInformerFactory(dynamicKubeClient, 0, cfg.Namespace, nil)\n\tambassadorHostInformer := informerFactory.ForResource(ambHostGVR)\n\n\t// Add default resource event handlers to properly initialize informer.\n\t_, _ = ambassadorHostInformer.Informer().AddEventHandler(informers.DefaultEventHandler())\n\n\tinformerFactory.Start(ctx.Done())\n\n\t// wait for the local cache to be populated.\n\tif err := informers.WaitForDynamicCacheSync(ctx, informerFactory); err != nil {\n\t\treturn nil, err\n\t}\n\n\tuc, err := newUnstructuredConverter()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to setup Unstructured Converter: %w\", err)\n\t}\n\n\treturn &ambassadorHostSource{\n\t\tdynamicKubeClient:      dynamicKubeClient,\n\t\tkubeClient:             kubeClient,\n\t\tnamespace:              cfg.Namespace,\n\t\tannotationFilter:       cfg.AnnotationFilter,\n\t\tambassadorHostInformer: ambassadorHostInformer,\n\t\tunstructuredConverter:  uc,\n\t\tlabelSelector:          cfg.LabelFilter,\n\t}, nil\n}\n\n// Endpoints returns endpoint objects for each host-target combination that should be processed.\n// Retrieves all Hosts in the source's namespace(s).\nfunc (sc *ambassadorHostSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) {\n\thosts, err := sc.ambassadorHostInformer.Lister().ByNamespace(sc.namespace).List(sc.labelSelector)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Get a list of Ambassador Host resources\n\tvar ambassadorHosts []*ambassador.Host\n\tfor _, hostObj := range hosts {\n\t\tunstructuredHost, ok := hostObj.(*unstructured.Unstructured)\n\t\tif !ok {\n\t\t\treturn nil, errors.New(\"could not convert\")\n\t\t}\n\n\t\thost := &ambassador.Host{}\n\t\terr := sc.unstructuredConverter.scheme.Convert(unstructuredHost, host, nil)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tambassadorHosts = append(ambassadorHosts, host)\n\t}\n\n\t// Filter Ambassador Hosts\n\tambassadorHosts, err = annotations.Filter(ambassadorHosts, sc.annotationFilter)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to filter Ambassador Hosts by annotation: %w\", err)\n\t}\n\n\tvar endpoints []*endpoint.Endpoint\n\n\tfor _, host := range ambassadorHosts {\n\t\tfullname := fmt.Sprintf(\"%s/%s\", host.Namespace, host.Name)\n\n\t\t// look for the \"external-dns.ambassador-service\" annotation. If it is not there then just ignore this `Host`\n\t\tservice, found := host.Annotations[ambHostAnnotation]\n\t\tif !found {\n\t\t\tlog.Debugf(\"Host %s ignored: no annotation %q found\", fullname, ambHostAnnotation)\n\t\t\tcontinue\n\t\t}\n\n\t\ttargets := annotations.TargetsFromTargetAnnotation(host.Annotations)\n\t\tif len(targets) == 0 {\n\t\t\ttargets, err = sc.targetsFromAmbassadorLoadBalancer(ctx, service)\n\t\t\tif err != nil {\n\t\t\t\tlog.Warningf(\"Could not find targets for service %s for Host %s: %v\", service, fullname, err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\n\t\thostEndpoints := sc.endpointsFromHost(host, targets)\n\t\tif endpoint.HasNoEmptyEndpoints(hostEndpoints, types.AmbassadorHost, host) {\n\t\t\tcontinue\n\t\t}\n\n\t\tlog.Debugf(\"Endpoints generated from Host: %s: %v\", fullname, hostEndpoints)\n\t\tendpoints = append(endpoints, hostEndpoints...)\n\t}\n\n\treturn MergeEndpoints(endpoints), nil\n}\n\n// endpointsFromHost extracts the endpoints from a Host object\nfunc (sc *ambassadorHostSource) endpointsFromHost(host *ambassador.Host, targets endpoint.Targets) []*endpoint.Endpoint {\n\tvar endpoints []*endpoint.Endpoint\n\n\tresource := fmt.Sprintf(\"host/%s/%s\", host.Namespace, host.Name)\n\tproviderSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(host.Annotations)\n\tttl := annotations.TTLFromAnnotations(host.Annotations, resource)\n\n\tif host.Spec != nil {\n\t\thostname := host.Spec.Hostname\n\t\tif hostname != \"\" {\n\t\t\tendpoints = append(endpoints, endpoint.EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...)\n\t\t}\n\t}\n\n\treturn endpoints\n}\n\nfunc (sc *ambassadorHostSource) targetsFromAmbassadorLoadBalancer(ctx context.Context, service string) (endpoint.Targets, error) {\n\tlbNamespace, lbName, err := parseAmbLoadBalancerService(service)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tsvc, err := sc.kubeClient.CoreV1().Services(lbNamespace).Get(ctx, lbName, metav1.GetOptions{})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\ttargets := extractLoadBalancerTargets(svc, false)\n\n\treturn targets, nil\n}\n\n// parseAmbLoadBalancerService returns a name/namespace tuple from the annotation in\n// an Ambassador Host CRD\n//\n// This is a thing because Ambassador has historically supported cross-namespace\n// references using a name.namespace syntax, but here we want to also support\n// namespace/name.\n//\n// Returns namespace, name, error.\n\nfunc parseAmbLoadBalancerService(service string) (string, string, error) {\n\t// Start by assuming that we have namespace/name.\n\tparts := strings.Split(service, \"/\")\n\n\tif len(parts) == 1 {\n\t\t// No \"/\" at all, so let's try for name.namespace. To be consistent with the\n\t\t// rest of Ambassador, use SplitN to limit this to one split, so that e.g.\n\t\t// svc.foo.bar uses service \"svc\" in namespace \"foo.bar\".\n\t\tparts = strings.SplitN(service, \".\", 2)\n\n\t\tif len(parts) == 2 {\n\t\t\t// We got a namespace, great.\n\t\t\tname := parts[0]\n\t\t\tnamespace := parts[1]\n\n\t\t\treturn namespace, name, nil\n\t\t}\n\n\t\t// If here, we have no separator, so the whole string is the service, and\n\t\t// we can assume the default namespace.\n\t\tname := service\n\t\tnamespace := \"default\"\n\n\t\treturn namespace, name, nil\n\t} else if len(parts) == 2 {\n\t\t// This is \"namespace/name\". Note that the name could be qualified,\n\t\t// which is fine.\n\t\tnamespace := parts[0]\n\t\tname := parts[1]\n\n\t\treturn namespace, name, nil\n\t}\n\n\t// If we got here, this string is simply ill-formatted. Return an error.\n\treturn \"\", \"\", fmt.Errorf(\"invalid external-dns service: %s\", service)\n}\n\nfunc (sc *ambassadorHostSource) AddEventHandler(_ context.Context, _ func()) {\n}\n\n// unstructuredConverter handles conversions between unstructured.Unstructured and Ambassador types\ntype unstructuredConverter struct {\n\t// scheme holds an initializer for converting Unstructured to a type\n\tscheme *runtime.Scheme\n}\n\n// newUnstructuredConverter returns a new unstructuredConverter initialized\nfunc newUnstructuredConverter() (*unstructuredConverter, error) {\n\tuc := &unstructuredConverter{\n\t\tscheme: runtime.NewScheme(),\n\t}\n\n\t// Setup converter to understand custom CRD types\n\tambassador.AddToScheme(uc.scheme)\n\n\t// Add the core types we need\n\tif err := scheme.AddToScheme(uc.scheme); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn uc, nil\n}\n"
  },
  {
    "path": "source/ambassador_host_test.go",
    "content": "/*\nCopyright 2019 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"fmt\"\n\t\"testing\"\n\n\tambassador \"github.com/datawire/ambassador/pkg/api/getambassador.io/v2\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\t\"github.com/stretchr/testify/suite\"\n\tv1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\t\"k8s.io/apimachinery/pkg/runtime\"\n\tfakeDynamic \"k8s.io/client-go/dynamic/fake\"\n\tfakeKube \"k8s.io/client-go/kubernetes/fake\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n)\n\nconst defaultAmbassadorNamespace = \"ambassador\"\nconst defaultAmbassadorServiceName = \"ambassador\"\n\n// This is a compile-time validation that ambassadorHostSource is a Source.\nvar _ Source = &ambassadorHostSource{}\n\ntype AmbassadorSuite struct {\n\tsuite.Suite\n}\n\nfunc TestAmbassadorSource(t *testing.T) {\n\tsuite.Run(t, new(AmbassadorSuite))\n\tt.Run(\"Interface\", testAmbassadorSourceImplementsSource)\n}\n\n// testAmbassadorSourceImplementsSource tests that ambassadorHostSource is a valid Source.\nfunc testAmbassadorSourceImplementsSource(t *testing.T) {\n\trequire.Implements(t, (*Source)(nil), new(ambassadorHostSource))\n}\n\nfunc TestAmbassadorHostSource(t *testing.T) {\n\tt.Parallel()\n\n\thostAnnotation := fmt.Sprintf(\"%s/%s\", defaultAmbassadorNamespace, defaultAmbassadorServiceName)\n\n\tfor _, ti := range []struct {\n\t\ttitle            string\n\t\tannotationFilter string\n\t\tlabelSelector    labels.Selector\n\t\thost             ambassador.Host\n\t\tservice          v1.Service\n\t\texpected         []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\ttitle:         \"Simple host\",\n\t\t\tlabelSelector: labels.Everything(),\n\t\t\thost: ambassador.Host{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"basic-host\",\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\tambHostAnnotation: hostAnnotation,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: &ambassador.HostSpec{\n\t\t\t\t\tHostname: \"www.example.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tservice: v1.Service{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: defaultAmbassadorServiceName,\n\t\t\t\t},\n\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\t\tIngress: []v1.LoadBalancerIngress{{\n\t\t\t\t\t\t\tIP: \"1.1.1.1\",\n\t\t\t\t\t\t}},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"www.example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.1.1.1\"},\n\t\t\t\t},\n\t\t\t},\n\t\t}, {\n\t\t\ttitle:         \"Service with load balancer hostname\",\n\t\t\tlabelSelector: labels.Everything(),\n\t\t\thost: ambassador.Host{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"basic-host\",\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\tambHostAnnotation: hostAnnotation,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: &ambassador.HostSpec{\n\t\t\t\t\tHostname: \"www.example.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tservice: v1.Service{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: defaultAmbassadorServiceName,\n\t\t\t\t},\n\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\t\tIngress: []v1.LoadBalancerIngress{{\n\t\t\t\t\t\t\tHostname: \"dns.google\",\n\t\t\t\t\t\t}},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"www.example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"dns.google\"},\n\t\t\t\t},\n\t\t\t},\n\t\t}, {\n\t\t\ttitle:         \"Service with external IP\",\n\t\t\tlabelSelector: labels.Everything(),\n\t\t\thost: ambassador.Host{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"service-external-ip\",\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\tambHostAnnotation: hostAnnotation,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: &ambassador.HostSpec{\n\t\t\t\t\tHostname: \"www.example.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tservice: v1.Service{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: defaultAmbassadorServiceName,\n\t\t\t\t},\n\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\tExternalIPs: []string{\"2.2.2.2\"},\n\t\t\t\t},\n\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\t\tIngress: []v1.LoadBalancerIngress{{\n\t\t\t\t\t\t\tIP: \"1.1.1.1\",\n\t\t\t\t\t\t}},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"www.example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"2.2.2.2\"},\n\t\t\t\t},\n\t\t\t},\n\t\t}, {\n\t\t\ttitle:         \"Host with target annotation\",\n\t\t\tlabelSelector: labels.Everything(),\n\t\t\thost: ambassador.Host{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"basic-host\",\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\tambHostAnnotation:     hostAnnotation,\n\t\t\t\t\t\tannotations.TargetKey: \"3.3.3.3\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: &ambassador.HostSpec{\n\t\t\t\t\tHostname: \"www.example.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tservice: v1.Service{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: defaultAmbassadorServiceName,\n\t\t\t\t},\n\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\t\tIngress: []v1.LoadBalancerIngress{{\n\t\t\t\t\t\t\tIP: \"1.1.1.1\",\n\t\t\t\t\t\t}},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"www.example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"3.3.3.3\"},\n\t\t\t\t},\n\t\t\t},\n\t\t}, {\n\t\t\ttitle:         \"Host with TTL annotation\",\n\t\t\tlabelSelector: labels.Everything(),\n\t\t\thost: ambassador.Host{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"basic-host\",\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\tambHostAnnotation:  hostAnnotation,\n\t\t\t\t\t\tannotations.TtlKey: \"180\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: &ambassador.HostSpec{\n\t\t\t\t\tHostname: \"www.example.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tservice: v1.Service{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: defaultAmbassadorServiceName,\n\t\t\t\t},\n\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\t\tIngress: []v1.LoadBalancerIngress{{\n\t\t\t\t\t\t\tIP: \"1.1.1.1\",\n\t\t\t\t\t\t}},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"www.example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.1.1.1\"},\n\t\t\t\t\tRecordTTL:  180,\n\t\t\t\t},\n\t\t\t},\n\t\t}, {\n\t\t\ttitle:         \"Host with provider specific annotation\",\n\t\t\tlabelSelector: labels.Everything(),\n\t\t\thost: ambassador.Host{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"basic-host\",\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\tambHostAnnotation:                hostAnnotation,\n\t\t\t\t\t\tannotations.CloudflareProxiedKey: \"true\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: &ambassador.HostSpec{\n\t\t\t\t\tHostname: \"www.example.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tservice: v1.Service{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: defaultAmbassadorServiceName,\n\t\t\t\t},\n\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\t\tIngress: []v1.LoadBalancerIngress{{\n\t\t\t\t\t\t\tIP: \"1.1.1.1\",\n\t\t\t\t\t\t}},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"www.example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.1.1.1\"},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{{\n\t\t\t\t\t\tName:  \"external-dns.alpha.kubernetes.io/cloudflare-proxied\",\n\t\t\t\t\t\tValue: \"true\",\n\t\t\t\t\t}},\n\t\t\t\t},\n\t\t\t},\n\t\t}, {\n\t\t\ttitle:         \"Host with missing Ambassador annotation\",\n\t\t\tlabelSelector: labels.Everything(),\n\t\t\thost: ambassador.Host{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"basic-host\",\n\t\t\t\t},\n\t\t\t\tSpec: &ambassador.HostSpec{\n\t\t\t\t\tHostname: \"www.example.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tservice: v1.Service{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: defaultAmbassadorServiceName,\n\t\t\t\t},\n\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\t\tIngress: []v1.LoadBalancerIngress{{\n\t\t\t\t\t\t\tIP: \"1.1.1.1\",\n\t\t\t\t\t\t}},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t}, {\n\t\t\ttitle:            \"valid matching annotation filter expression\",\n\t\t\tannotationFilter: \"kubernetes.io/ingress.class in (external-ingress)\",\n\t\t\tlabelSelector:    labels.Everything(),\n\t\t\thost: ambassador.Host{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"basic-host\",\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\tambHostAnnotation:             hostAnnotation,\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\": \"external-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: &ambassador.HostSpec{\n\t\t\t\t\tHostname: \"www.example.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tservice: v1.Service{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: defaultAmbassadorServiceName,\n\t\t\t\t},\n\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\t\tIngress: []v1.LoadBalancerIngress{{\n\t\t\t\t\t\t\tIP: \"1.1.1.1\",\n\t\t\t\t\t\t}},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"www.example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.1.1.1\"},\n\t\t\t\t},\n\t\t\t},\n\t\t}, {\n\t\t\ttitle:            \"valid non-matching annotation filter expression\",\n\t\t\tannotationFilter: \"kubernetes.io/ingress.class in (external-ingress)\",\n\t\t\tlabelSelector:    labels.Everything(),\n\t\t\thost: ambassador.Host{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"basic-host\",\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\tambHostAnnotation:             hostAnnotation,\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\": \"internal-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: &ambassador.HostSpec{\n\t\t\t\t\tHostname: \"www.example.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tservice: v1.Service{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: defaultAmbassadorServiceName,\n\t\t\t\t},\n\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\t\tIngress: []v1.LoadBalancerIngress{{\n\t\t\t\t\t\t\tIP: \"1.1.1.1\",\n\t\t\t\t\t\t}},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t}, {\n\t\t\ttitle:            \"invalid annotation filter expression\",\n\t\t\tannotationFilter: \"kubernetes.io/ingress.class in (invalid-ingress)\",\n\t\t\tlabelSelector:    labels.Everything(),\n\t\t\thost: ambassador.Host{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"basic-host\",\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\tambHostAnnotation:             hostAnnotation,\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\": \"external-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: &ambassador.HostSpec{\n\t\t\t\t\tHostname: \"www.example.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tservice: v1.Service{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: defaultAmbassadorServiceName,\n\t\t\t\t},\n\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\t\tIngress: []v1.LoadBalancerIngress{{\n\t\t\t\t\t\t\tIP: \"1.1.1.1\",\n\t\t\t\t\t\t}},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t}, {\n\t\t\ttitle:            \"valid non-matching annotation filter label\",\n\t\t\tannotationFilter: \"kubernetes.io/ingress.class=external-ingress\",\n\t\t\tlabelSelector:    labels.Everything(),\n\t\t\thost: ambassador.Host{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"basic-host\",\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\tambHostAnnotation:             hostAnnotation,\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\": \"internal-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: &ambassador.HostSpec{\n\t\t\t\t\tHostname: \"www.example.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tservice: v1.Service{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: defaultAmbassadorServiceName,\n\t\t\t\t},\n\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\t\tIngress: []v1.LoadBalancerIngress{{\n\t\t\t\t\t\t\tIP: \"1.1.1.1\",\n\t\t\t\t\t\t}},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:         \"valid non-matching label filter expression\",\n\t\t\tlabelSelector: labels.SelectorFromSet(labels.Set{\"kubernetes.io/ingress.class\": \"external-ingress\"}),\n\t\t\thost: ambassador.Host{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"basic-host\",\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\tambHostAnnotation:             hostAnnotation,\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\": \"internal-ingress\",\n\t\t\t\t\t},\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\": \"internal-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: &ambassador.HostSpec{\n\t\t\t\t\tHostname: \"www.example.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tservice: v1.Service{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: defaultAmbassadorServiceName,\n\t\t\t\t},\n\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\t\tIngress: []v1.LoadBalancerIngress{{\n\t\t\t\t\t\t\tIP: \"1.1.1.1\",\n\t\t\t\t\t\t}},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:         \"valid matching label filter expression for single host\",\n\t\t\tlabelSelector: labels.SelectorFromSet(labels.Set{\"kubernetes.io/ingress.class\": \"external-ingress\"}),\n\t\t\thost: ambassador.Host{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"basic-host\",\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\tambHostAnnotation: hostAnnotation,\n\t\t\t\t\t},\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\": \"external-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: &ambassador.HostSpec{\n\t\t\t\t\tHostname: \"www.example.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tservice: v1.Service{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: defaultAmbassadorServiceName,\n\t\t\t\t},\n\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\t\tIngress: []v1.LoadBalancerIngress{{\n\t\t\t\t\t\t\tIP:       \"1.1.1.1\",\n\t\t\t\t\t\t\tHostname: \"dns.google\",\n\t\t\t\t\t\t}},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"www.example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.1.1.1\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"www.example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"dns.google\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:            \"valid matching label filter expression and matching annotation filter\",\n\t\t\tannotationFilter: \"kubernetes.io/ingress.class in (external-ingress)\",\n\t\t\tlabelSelector:    labels.SelectorFromSet(labels.Set{\"kubernetes.io/ingress.class\": \"external-ingress\"}),\n\t\t\thost: ambassador.Host{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"basic-host\",\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\tambHostAnnotation:             hostAnnotation,\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\": \"external-ingress\",\n\t\t\t\t\t},\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\": \"external-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: &ambassador.HostSpec{\n\t\t\t\t\tHostname: \"www.example.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tservice: v1.Service{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: defaultAmbassadorServiceName,\n\t\t\t\t},\n\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\t\tIngress: []v1.LoadBalancerIngress{{\n\t\t\t\t\t\t\tIP:       \"1.1.1.1\",\n\t\t\t\t\t\t\tHostname: \"dns.google\",\n\t\t\t\t\t\t}},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"www.example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.1.1.1\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"www.example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"dns.google\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:            \"valid non matching label filter expression and valid matching annotation filter\",\n\t\t\tannotationFilter: \"kubernetes.io/ingress.class in (external-ingress)\",\n\t\t\tlabelSelector:    labels.SelectorFromSet(labels.Set{\"kubernetes.io/ingress.class\": \"external-ingress\"}),\n\t\t\thost: ambassador.Host{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"basic-host\",\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\tambHostAnnotation:             hostAnnotation,\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\": \"external-ingress\",\n\t\t\t\t\t},\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\": \"internal-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: &ambassador.HostSpec{\n\t\t\t\t\tHostname: \"www.example.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tservice: v1.Service{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: defaultAmbassadorServiceName,\n\t\t\t\t},\n\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\t\tIngress: []v1.LoadBalancerIngress{{\n\t\t\t\t\t\t\tIP:       \"1.1.1.1\",\n\t\t\t\t\t\t\tHostname: \"dns.google\",\n\t\t\t\t\t\t}},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:            \"valid matching label filter expression and non matching annotation filter\",\n\t\t\tannotationFilter: \"kubernetes.io/ingress.class in (external-ingress)\",\n\t\t\tlabelSelector:    labels.SelectorFromSet(labels.Set{\"kubernetes.io/ingress.class\": \"external-ingress\"}),\n\t\t\thost: ambassador.Host{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"basic-host\",\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\tambHostAnnotation:             hostAnnotation,\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\": \"internal-ingress\",\n\t\t\t\t\t},\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\": \"external-ingress\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: &ambassador.HostSpec{\n\t\t\t\t\tHostname: \"www.example.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tservice: v1.Service{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: defaultAmbassadorServiceName,\n\t\t\t\t},\n\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\t\tIngress: []v1.LoadBalancerIngress{{\n\t\t\t\t\t\t\tIP:       \"1.1.1.1\",\n\t\t\t\t\t\t\tHostname: \"dns.google\",\n\t\t\t\t\t\t}},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t} {\n\n\t\tt.Run(ti.title, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\tfakeKubernetesClient := fakeKube.NewSimpleClientset()\n\t\t\tambassadorScheme := runtime.NewScheme()\n\t\t\tambassador.AddToScheme(ambassadorScheme)\n\t\t\tfakeDynamicClient := fakeDynamic.NewSimpleDynamicClient(ambassadorScheme)\n\n\t\t\tnamespace := \"default\"\n\n\t\t\t// Create Ambassador service\n\t\t\t_, err := fakeKubernetesClient.CoreV1().Services(defaultAmbassadorNamespace).Create(t.Context(), &ti.service, metav1.CreateOptions{})\n\t\t\tassert.NoError(t, err)\n\n\t\t\t// Create host resource\n\t\t\thost, err := createAmbassadorHost(&ti.host)\n\t\t\tassert.NoError(t, err)\n\n\t\t\t_, err = fakeDynamicClient.Resource(ambHostGVR).Namespace(namespace).Create(t.Context(), host, metav1.CreateOptions{})\n\t\t\tassert.NoError(t, err)\n\n\t\t\tsource, err := NewAmbassadorHostSource(t.Context(), fakeDynamicClient, fakeKubernetesClient,\n\t\t\t\t&Config{\n\t\t\t\t\tNamespace:        namespace,\n\t\t\t\t\tAnnotationFilter: ti.annotationFilter,\n\t\t\t\t\tLabelFilter:      ti.labelSelector,\n\t\t\t\t})\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.NotNil(t, source)\n\n\t\t\tendpoints, err := source.Endpoints(t.Context())\n\t\t\tassert.NoError(t, err)\n\t\t\t// Validate returned endpoints against expected endpoints.\n\t\t\tvalidateEndpoints(t, endpoints, ti.expected)\n\t\t})\n\t}\n}\n\nfunc createAmbassadorHost(host *ambassador.Host) (*unstructured.Unstructured, error) {\n\tobj := &unstructured.Unstructured{}\n\tuc, _ := newUnstructuredConverter()\n\terr := uc.scheme.Convert(host, obj, nil)\n\n\treturn obj, err\n}\n\n// TestParseAmbLoadBalancerService tests our parsing of Ambassador service info.\nfunc TestParseAmbLoadBalancerService(t *testing.T) {\n\tvectors := []struct {\n\t\tinput  string\n\t\tns     string\n\t\tsvc    string\n\t\terrstr string\n\t}{\n\t\t{\"svc\", \"default\", \"svc\", \"\"},\n\t\t{\"ns/svc\", \"ns\", \"svc\", \"\"},\n\t\t{\"svc.ns\", \"ns\", \"svc\", \"\"},\n\t\t{\"svc.ns.foo.bar\", \"ns.foo.bar\", \"svc\", \"\"},\n\t\t{\"ns/svc/foo/bar\", \"\", \"\", \"invalid external-dns service: ns/svc/foo/bar\"},\n\t\t{\"ns/svc/foo.bar\", \"\", \"\", \"invalid external-dns service: ns/svc/foo.bar\"},\n\t\t{\"ns.foo/svc/bar\", \"\", \"\", \"invalid external-dns service: ns.foo/svc/bar\"},\n\t}\n\n\tfor _, v := range vectors {\n\t\tns, svc, err := parseAmbLoadBalancerService(v.input)\n\n\t\terrstr := \"\"\n\n\t\tif err != nil {\n\t\t\terrstr = err.Error()\n\t\t}\n\t\tif v.ns != ns {\n\t\t\tt.Errorf(\"%s: got ns \\\"%s\\\", wanted \\\"%s\\\"\", v.input, ns, v.ns)\n\t\t}\n\n\t\tif v.svc != svc {\n\t\t\tt.Errorf(\"%s: got svc \\\"%s\\\", wanted \\\"%s\\\"\", v.input, svc, v.svc)\n\t\t}\n\n\t\tif v.errstr != errstr {\n\t\t\tt.Errorf(\"%s: got err \\\"%s\\\", wanted \\\"%s\\\"\", v.input, errstr, v.errstr)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "source/annotations/annotations.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n    http://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage annotations\n\nimport (\n\t\"math\"\n)\n\nconst (\n\t// DefaultAnnotationPrefix is the default annotation prefix used by external-dns\n\tDefaultAnnotationPrefix = \"external-dns.alpha.kubernetes.io/\"\n\n\tttlMinimum = 1\n\tttlMaximum = math.MaxInt32\n)\n\nvar (\n\t// AnnotationKeyPrefix is set on all annotations consumed by external-dns (outside of user templates)\n\t// to provide easy filtering. Can be customized via SetAnnotationPrefix.\n\tAnnotationKeyPrefix = DefaultAnnotationPrefix\n\n\t// CloudflareProxiedKey The annotation used for determining if traffic will go through Cloudflare\n\tCloudflareProxiedKey        = AnnotationKeyPrefix + \"cloudflare-proxied\"\n\tCloudflareCustomHostnameKey = AnnotationKeyPrefix + \"cloudflare-custom-hostname\"\n\tCloudflareRegionKey         = AnnotationKeyPrefix + \"cloudflare-region-key\"\n\tCloudflareRecordCommentKey  = AnnotationKeyPrefix + \"cloudflare-record-comment\"\n\tCloudflareTagsKey           = AnnotationKeyPrefix + \"cloudflare-tags\"\n\n\tAWSPrefix        = AnnotationKeyPrefix + \"aws-\"\n\tCoreDNSPrefix    = AnnotationKeyPrefix + \"coredns-\"\n\tSCWPrefix        = AnnotationKeyPrefix + \"scw-\"\n\tWebhookPrefix    = AnnotationKeyPrefix + \"webhook-\"\n\tCloudflarePrefix = AnnotationKeyPrefix + \"cloudflare-\"\n\n\tTtlKey           = AnnotationKeyPrefix + \"ttl\"\n\tSetIdentifierKey = AnnotationKeyPrefix + \"set-identifier\"\n\tAliasKey         = AnnotationKeyPrefix + \"alias\"\n\tRecordTypeKey    = AnnotationKeyPrefix + \"record-type\"\n\tTargetKey        = AnnotationKeyPrefix + \"target\"\n\t// ControllerKey The annotation used for figuring out which controller is responsible\n\tControllerKey = AnnotationKeyPrefix + \"controller\"\n\t// HostnameKey The annotation used for defining the desired hostname\n\tHostnameKey = AnnotationKeyPrefix + \"hostname\"\n\t// AccessKey The annotation used for specifying whether the public or private interface address is used\n\tAccessKey = AnnotationKeyPrefix + \"access\"\n\t// EndpointsTypeKey The annotation used for specifying the type of endpoints to use for headless services\n\tEndpointsTypeKey = AnnotationKeyPrefix + \"endpoints-type\"\n\t// Ingress the annotation used to determine if the gateway is implemented by an Ingress object\n\tIngress = AnnotationKeyPrefix + \"ingress\"\n\t// IngressHostnameSourceKey The annotation used to determine the source of hostnames for ingresses.  This is an optional field - all\n\t// available hostname sources are used if not specified.\n\tIngressHostnameSourceKey = AnnotationKeyPrefix + \"ingress-hostname-source\"\n\t// ControllerValue The value of the controller annotation so that we feel responsible\n\tControllerValue = \"dns-controller\"\n\t// InternalHostnameKey The annotation used for defining the desired hostname\n\tInternalHostnameKey = AnnotationKeyPrefix + \"internal-hostname\"\n\t// The annotation used for defining the desired hostname source for gateways\n\tGatewayHostnameSourceKey = AnnotationKeyPrefix + \"gateway-hostname-source\"\n)\n\n// SetAnnotationPrefix sets a custom annotation prefix and rebuilds all annotation keys.\n// This must be called before any sources are initialized.\n// The prefix must end with '/'.\nfunc SetAnnotationPrefix(prefix string) {\n\tAnnotationKeyPrefix = prefix\n\n\t// Cloudflare annotations\n\tCloudflareProxiedKey = AnnotationKeyPrefix + \"cloudflare-proxied\"\n\tCloudflareCustomHostnameKey = AnnotationKeyPrefix + \"cloudflare-custom-hostname\"\n\tCloudflareRegionKey = AnnotationKeyPrefix + \"cloudflare-region-key\"\n\tCloudflareRecordCommentKey = AnnotationKeyPrefix + \"cloudflare-record-comment\"\n\tCloudflareTagsKey = AnnotationKeyPrefix + \"cloudflare-tags\"\n\n\t// Provider prefixes\n\tAWSPrefix = AnnotationKeyPrefix + \"aws-\"\n\tCoreDNSPrefix = AnnotationKeyPrefix + \"coredns-\"\n\tSCWPrefix = AnnotationKeyPrefix + \"scw-\"\n\tWebhookPrefix = AnnotationKeyPrefix + \"webhook-\"\n\tCloudflarePrefix = AnnotationKeyPrefix + \"cloudflare-\"\n\n\t// Core annotations\n\tTtlKey = AnnotationKeyPrefix + \"ttl\"\n\tSetIdentifierKey = AnnotationKeyPrefix + \"set-identifier\"\n\tAliasKey = AnnotationKeyPrefix + \"alias\"\n\tRecordTypeKey = AnnotationKeyPrefix + \"record-type\"\n\tTargetKey = AnnotationKeyPrefix + \"target\"\n\tControllerKey = AnnotationKeyPrefix + \"controller\"\n\tHostnameKey = AnnotationKeyPrefix + \"hostname\"\n\tAccessKey = AnnotationKeyPrefix + \"access\"\n\tEndpointsTypeKey = AnnotationKeyPrefix + \"endpoints-type\"\n\tIngress = AnnotationKeyPrefix + \"ingress\"\n\tIngressHostnameSourceKey = AnnotationKeyPrefix + \"ingress-hostname-source\"\n\tInternalHostnameKey = AnnotationKeyPrefix + \"internal-hostname\"\n\tGatewayHostnameSourceKey = AnnotationKeyPrefix + \"gateway-hostname-source\"\n}\n"
  },
  {
    "path": "source/annotations/annotations_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage annotations\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestSetAnnotationPrefix(t *testing.T) {\n\tt.Cleanup(func() { SetAnnotationPrefix(DefaultAnnotationPrefix) })\n\n\t// Test custom prefix\n\tcustomPrefix := \"custom.io/\"\n\tSetAnnotationPrefix(customPrefix)\n\n\tassert.Equal(t, customPrefix, AnnotationKeyPrefix)\n\tassert.Equal(t, \"custom.io/hostname\", HostnameKey)\n\tassert.Equal(t, \"custom.io/internal-hostname\", InternalHostnameKey)\n\tassert.Equal(t, \"custom.io/ttl\", TtlKey)\n\tassert.Equal(t, \"custom.io/target\", TargetKey)\n\tassert.Equal(t, \"custom.io/controller\", ControllerKey)\n\tassert.Equal(t, \"custom.io/cloudflare-proxied\", CloudflareProxiedKey)\n\tassert.Equal(t, \"custom.io/cloudflare-custom-hostname\", CloudflareCustomHostnameKey)\n\tassert.Equal(t, \"custom.io/cloudflare-region-key\", CloudflareRegionKey)\n\tassert.Equal(t, \"custom.io/cloudflare-record-comment\", CloudflareRecordCommentKey)\n\tassert.Equal(t, \"custom.io/cloudflare-tags\", CloudflareTagsKey)\n\tassert.Equal(t, \"custom.io/aws-\", AWSPrefix)\n\tassert.Equal(t, \"custom.io/coredns-\", CoreDNSPrefix)\n\tassert.Equal(t, \"custom.io/scw-\", SCWPrefix)\n\tassert.Equal(t, \"custom.io/webhook-\", WebhookPrefix)\n\tassert.Equal(t, \"custom.io/cloudflare-\", CloudflarePrefix)\n\tassert.Equal(t, \"custom.io/set-identifier\", SetIdentifierKey)\n\tassert.Equal(t, \"custom.io/alias\", AliasKey)\n\tassert.Equal(t, \"custom.io/access\", AccessKey)\n\tassert.Equal(t, \"custom.io/endpoints-type\", EndpointsTypeKey)\n\tassert.Equal(t, \"custom.io/ingress\", Ingress)\n\tassert.Equal(t, \"custom.io/ingress-hostname-source\", IngressHostnameSourceKey)\n\n\t// ControllerValue should remain constant\n\tassert.Equal(t, \"dns-controller\", ControllerValue)\n}\n\nfunc TestDefaultAnnotationPrefix(t *testing.T) {\n\tt.Cleanup(func() { SetAnnotationPrefix(DefaultAnnotationPrefix) })\n\tSetAnnotationPrefix(DefaultAnnotationPrefix)\n\tassert.Equal(t, DefaultAnnotationPrefix, AnnotationKeyPrefix)\n\tassert.Equal(t, DefaultAnnotationPrefix+\"hostname\", HostnameKey)\n\tassert.Equal(t, DefaultAnnotationPrefix+\"internal-hostname\", InternalHostnameKey)\n\tassert.Equal(t, DefaultAnnotationPrefix+\"ttl\", TtlKey)\n\tassert.Equal(t, DefaultAnnotationPrefix+\"controller\", ControllerKey)\n}\n\nfunc TestSetAnnotationPrefixMultipleTimes(t *testing.T) {\n\tt.Cleanup(func() { SetAnnotationPrefix(DefaultAnnotationPrefix) })\n\n\t// Set first custom prefix\n\tSetAnnotationPrefix(\"first.io/\")\n\tassert.Equal(t, \"first.io/\", AnnotationKeyPrefix)\n\tassert.Equal(t, \"first.io/hostname\", HostnameKey)\n\n\t// Set second custom prefix\n\tSetAnnotationPrefix(\"second.io/\")\n\tassert.Equal(t, \"second.io/\", AnnotationKeyPrefix)\n\tassert.Equal(t, \"second.io/hostname\", HostnameKey)\n\n\t// Restore to default\n\tSetAnnotationPrefix(DefaultAnnotationPrefix)\n\tassert.Equal(t, DefaultAnnotationPrefix, AnnotationKeyPrefix)\n\tassert.Equal(t, DefaultAnnotationPrefix+\"hostname\", HostnameKey)\n}\n"
  },
  {
    "path": "source/annotations/filter.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n    http://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage annotations\n\nimport (\n\t\"strings\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n)\n\n// AnnotatedObject represents any Kubernetes object with annotations\ntype AnnotatedObject interface {\n\tGetAnnotations() map[string]string\n}\n\n// Filter filters a slice of objects by annotation selector.\n// Returns all items if annotationFilter is empty.\nfunc Filter[T AnnotatedObject](items []T, filter string) ([]T, error) {\n\tif filter == \"\" || strings.TrimSpace(filter) == \"\" {\n\t\treturn items, nil\n\t}\n\tselector, err := ParseFilter(filter)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif selector.Empty() {\n\t\treturn items, nil\n\t}\n\n\tfiltered := make([]T, 0, len(items))\n\tfor _, item := range items {\n\t\tif selector.Matches(labels.Set(item.GetAnnotations())) {\n\t\t\tfiltered = append(filtered, item)\n\t\t}\n\t}\n\tlog.Debugf(\"filtered '%d' services out of '%d' with annotation filter '%s'\", len(filtered), len(items), filter)\n\treturn filtered, nil\n}\n"
  },
  {
    "path": "source/annotations/filter_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n    http://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage annotations\n\nimport (\n\t\"testing\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\tlogtest \"sigs.k8s.io/external-dns/internal/testutils/log\"\n)\n\n// Mock object implementing AnnotatedObject\ntype mockObj struct {\n\tannotations map[string]string\n}\n\nfunc (m mockObj) GetAnnotations() map[string]string {\n\treturn m.annotations\n}\n\nfunc TestFilter(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\titems       []mockObj\n\t\tfilter      string\n\t\texpected    []mockObj\n\t\texpectError bool\n\t}{\n\t\t{\n\t\t\tname: \"Empty filter returns all\",\n\t\t\titems: []mockObj{\n\t\t\t\t{annotations: map[string]string{\"foo\": \"bar\"}},\n\t\t\t\t{annotations: map[string]string{\"baz\": \"qux\"}},\n\t\t\t},\n\t\t\tfilter: \"\",\n\t\t\texpected: []mockObj{\n\t\t\t\t{annotations: map[string]string{\"foo\": \"bar\"}},\n\t\t\t\t{annotations: map[string]string{\"baz\": \"qux\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"Matching items\",\n\t\t\titems: []mockObj{\n\t\t\t\t{annotations: map[string]string{\"foo\": \"bar\"}},\n\t\t\t\t{annotations: map[string]string{\"foo\": \"baz\"}},\n\t\t\t},\n\t\t\tfilter: \"foo=bar\",\n\t\t\texpected: []mockObj{\n\t\t\t\t{annotations: map[string]string{\"foo\": \"bar\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"No matching items\",\n\t\t\titems: []mockObj{\n\t\t\t\t{annotations: map[string]string{\"foo\": \"baz\"}},\n\t\t\t},\n\t\t\tfilter:   \"foo=bar\",\n\t\t\texpected: []mockObj{},\n\t\t},\n\t\t{\n\t\t\tname: \"Whitespace filter returns all\",\n\t\t\titems: []mockObj{\n\t\t\t\t{annotations: map[string]string{\"foo\": \"bar\"}},\n\t\t\t\t{annotations: map[string]string{\"baz\": \"qux\"}},\n\t\t\t},\n\t\t\tfilter: \"   \",\n\t\t\texpected: []mockObj{\n\t\t\t\t{annotations: map[string]string{\"foo\": \"bar\"}},\n\t\t\t\t{annotations: map[string]string{\"baz\": \"qux\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"empty filter returns all\",\n\t\t\titems: []mockObj{\n\t\t\t\t{annotations: map[string]string{\"foo\": \"bar\"}},\n\t\t\t\t{annotations: map[string]string{\"baz\": \"qux\"}},\n\t\t\t},\n\t\t\tfilter: \"\",\n\t\t\texpected: []mockObj{\n\t\t\t\t{annotations: map[string]string{\"foo\": \"bar\"}},\n\t\t\t\t{annotations: map[string]string{\"baz\": \"qux\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"invalid filter returns error\",\n\t\t\titems: []mockObj{\n\t\t\t\t{annotations: map[string]string{\"foo\": \"bar\"}},\n\t\t\t\t{annotations: map[string]string{\"baz\": \"qux\"}},\n\t\t\t},\n\t\t\tfilter: \"=invalid\",\n\t\t\texpected: []mockObj{\n\t\t\t\t{annotations: map[string]string{\"foo\": \"bar\"}},\n\t\t\t\t{annotations: map[string]string{\"baz\": \"qux\"}},\n\t\t\t},\n\t\t\texpectError: true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult, err := Filter(tt.items, tt.filter)\n\t\t\tif tt.expectError {\n\t\t\t\trequire.Error(t, err)\n\t\t\t} else {\n\t\t\t\trequire.NoError(t, err)\n\t\t\t\tassert.Equal(t, tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestFilter_LogOutput(t *testing.T) {\n\thook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t)\n\n\titems := []mockObj{\n\t\t{annotations: map[string]string{\"foo\": \"bar\"}},\n\t\t{annotations: map[string]string{\"foo\": \"baz\"}},\n\t}\n\tfilter := \"foo=bar\"\n\t_, _ = Filter(items, filter)\n\n\tlogtest.TestHelperLogContains(\"filtered '1' services out of '2' with annotation filter 'foo=bar'\", hook, t)\n}\n"
  },
  {
    "path": "source/annotations/processors.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n    http://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage annotations\n\nimport (\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\nconst (\n\tskipCtrlMsg = \"Skipping '%s/%s/%s' because controller '%s' value does not match, found: '%s', required: '%s'\"\n)\n\nfunc hasAliasFromAnnotations(annotations map[string]string) bool {\n\taliasAnnotation, ok := annotations[AliasKey]\n\treturn ok && aliasAnnotation == \"true\"\n}\n\n// TTLFromAnnotations extracts the TTL from the annotations of the given resource.\nfunc TTLFromAnnotations(annotations map[string]string, resource string) endpoint.TTL {\n\tttlNotConfigured := endpoint.TTL(0)\n\tttlAnnotation, ok := annotations[TtlKey]\n\tif !ok {\n\t\treturn ttlNotConfigured\n\t}\n\tttlValue, err := parseTTL(ttlAnnotation)\n\tif err != nil {\n\t\tlog.Warnf(\"%s: %q is not a valid TTL value: %v\", resource, ttlAnnotation, err)\n\t\treturn ttlNotConfigured\n\t}\n\tif ttlValue < ttlMinimum || ttlValue > ttlMaximum {\n\t\tlog.Warnf(\"TTL value %q must be between [%d, %d]\", ttlValue, ttlMinimum, ttlMaximum)\n\t\treturn ttlNotConfigured\n\t}\n\treturn endpoint.TTL(ttlValue)\n}\n\n// IsControllerMismatch returns true when the resource should be skipped because\n// the controller annotation is present and does not match the expected controller value.\n// It also logs the reason.\nfunc IsControllerMismatch(\n\tentity metav1.ObjectMetaAccessor,\n\trType string,\n) bool {\n\tvalue, ok := entity.GetObjectMeta().GetAnnotations()[ControllerKey]\n\tif ok && value != ControllerValue {\n\t\tlog.Debugf(skipCtrlMsg, rType, entity.GetObjectMeta().GetNamespace(), entity.GetObjectMeta().GetName(), ControllerKey, value, ControllerValue)\n\t\treturn true\n\t}\n\treturn false\n}\n\n// parseTTL parses TTL from string, returning duration in seconds.\n// parseTTL supports both integers like \"600\" and durations based\n// on Go Duration like \"10m\", hence \"600\" and \"10m\" represent the same value.\n//\n// Note: for durations like \"1.5s\" the fraction is omitted (resulting in 1 second for the example).\nfunc parseTTL(s string) (int64, error) {\n\tttlDuration, errDuration := time.ParseDuration(s)\n\tif errDuration != nil {\n\t\tttlInt, err := strconv.ParseInt(s, 10, 64)\n\t\tif err != nil {\n\t\t\treturn 0, errDuration\n\t\t}\n\t\treturn ttlInt, nil\n\t}\n\n\treturn int64(ttlDuration.Seconds()), nil\n}\n\n// ParseFilter parses an annotation filter string into a labels.Selector.\n// Returns nil if the annotation filter is invalid.\nfunc ParseFilter(annotationFilter string) (labels.Selector, error) {\n\tlabelSelector, err := metav1.ParseToLabelSelector(annotationFilter)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tselector, err := metav1.LabelSelectorAsSelector(labelSelector)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn selector, nil\n}\n\n// TargetsFromTargetAnnotation gets endpoints from optional \"target\" annotation.\n// Returns empty endpoints array if none are found.\nfunc TargetsFromTargetAnnotation(annotations map[string]string) endpoint.Targets {\n\tvar targets endpoint.Targets\n\t// Get the desired hostname of the ingress from the annotation.\n\ttargetAnnotation, ok := annotations[TargetKey]\n\tif ok && targetAnnotation != \"\" {\n\t\t// splits the hostname annotation and removes the trailing periods\n\t\ttargetsList := SplitHostnameAnnotation(targetAnnotation)\n\t\tfor _, targetHostname := range targetsList {\n\t\t\ttargetHostname = strings.TrimSuffix(targetHostname, \".\")\n\t\t\ttargets = append(targets, targetHostname)\n\t\t}\n\t}\n\treturn targets\n}\n\n// HostnamesFromAnnotations extracts the hostnames from the given annotations map.\n// It returns a slice of hostnames if the HostnameKey annotation is present, otherwise it returns nil.\nfunc HostnamesFromAnnotations(input map[string]string) []string {\n\treturn extractHostnamesFromAnnotations(input, HostnameKey)\n}\n\n// InternalHostnamesFromAnnotations extracts the internal hostnames from the given annotations map.\n// It returns a slice of internal hostnames if the InternalHostnameKey annotation is present, otherwise it returns nil.\nfunc InternalHostnamesFromAnnotations(input map[string]string) []string {\n\treturn extractHostnamesFromAnnotations(input, InternalHostnameKey)\n}\n\n// SplitHostnameAnnotation splits a comma-separated hostname annotation string into a slice of hostnames.\n// It trims any leading or trailing whitespace and removes any spaces within the anno\nfunc SplitHostnameAnnotation(input string) []string {\n\treturn strings.Split(strings.TrimSpace(strings.ReplaceAll(input, \" \", \"\")), \",\")\n}\n\nfunc extractHostnamesFromAnnotations(input map[string]string, key string) []string {\n\tannotation, ok := input[key]\n\tif !ok {\n\t\treturn nil\n\t}\n\treturn SplitHostnameAnnotation(annotation)\n}\n"
  },
  {
    "path": "source/annotations/processors_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n    http://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage annotations\n\nimport (\n\t\"fmt\"\n\t\"testing\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/assert\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\tlogtest \"sigs.k8s.io/external-dns/internal/testutils/log\"\n)\n\n// helper implementing metav1.ObjectMetaAccessor for tests\ntype objectUnderTest struct {\n\tmeta metav1.ObjectMeta\n}\n\nfunc (t *objectUnderTest) GetObjectMeta() metav1.Object { return &t.meta }\n\nfunc TestParseAnnotationFilter(t *testing.T) {\n\ttests := []struct {\n\t\tname             string\n\t\tannotationFilter string\n\t\texpectedSelector labels.Selector\n\t\texpectError      bool\n\t}{\n\t\t{\n\t\t\tname:             \"valid annotation filter\",\n\t\t\tannotationFilter: \"key1=value1,key2=value2\",\n\t\t\texpectedSelector: labels.Set{\"key1\": \"value1\", \"key2\": \"value2\"}.AsSelector(),\n\t\t\texpectError:      false,\n\t\t},\n\t\t{\n\t\t\tname:             \"invalid annotation filter\",\n\t\t\tannotationFilter: \"key1==value1\",\n\t\t\texpectedSelector: labels.Set{\"key1\": \"value1\"}.AsSelector(),\n\t\t\texpectError:      false,\n\t\t},\n\t\t{\n\t\t\tname:             \"empty annotation filter\",\n\t\t\tannotationFilter: \"\",\n\t\t\texpectedSelector: labels.Set{}.AsSelector(),\n\t\t\texpectError:      false,\n\t\t},\n\t\t{\n\t\t\tname:             \"wrong annotation filter\",\n\t\t\tannotationFilter: \"=test\",\n\t\t\texpectedSelector: nil,\n\t\t\texpectError:      true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tselector, err := ParseFilter(tt.annotationFilter)\n\t\t\tif tt.expectError {\n\t\t\t\tassert.Error(t, err)\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t\tassert.Equal(t, tt.expectedSelector, selector)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestTargetsFromTargetAnnotation(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tannotations map[string]string\n\t\texpected    endpoint.Targets\n\t}{\n\t\t{\n\t\t\tname:        \"no target annotation\",\n\t\t\tannotations: map[string]string{},\n\t\t\texpected:    endpoint.Targets(nil),\n\t\t},\n\t\t{\n\t\t\tname: \"single target annotation\",\n\t\t\tannotations: map[string]string{\n\t\t\t\tTargetKey: \"example.com\",\n\t\t\t},\n\t\t\texpected: endpoint.Targets{\"example.com\"},\n\t\t},\n\t\t{\n\t\t\tname: \"multiple target annotations\",\n\t\t\tannotations: map[string]string{\n\t\t\t\tTargetKey: \"example.com,example.org\",\n\t\t\t},\n\t\t\texpected: endpoint.Targets{\"example.com\", \"example.org\"},\n\t\t},\n\t\t{\n\t\t\tname: \"target annotation with trailing periods\",\n\t\t\tannotations: map[string]string{\n\t\t\t\tTargetKey: \"example.com.,example.org.\",\n\t\t\t},\n\t\t\texpected: endpoint.Targets{\"example.com\", \"example.org\"},\n\t\t},\n\t\t{\n\t\t\tname: \"target annotation with spaces\",\n\t\t\tannotations: map[string]string{\n\t\t\t\tTargetKey: \" example.com , example.org \",\n\t\t\t},\n\t\t\texpected: endpoint.Targets{\"example.com\", \"example.org\"},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := TargetsFromTargetAnnotation(tt.annotations)\n\t\t\tassert.Equal(t, tt.expected, result)\n\t\t})\n\t}\n}\n\nfunc TestTTLFromAnnotations(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tannotations map[string]string\n\t\tresource    string\n\t\texpectedTTL endpoint.TTL\n\t}{\n\t\t{\n\t\t\tname:        \"no TTL annotation\",\n\t\t\tannotations: map[string]string{},\n\t\t\tresource:    \"test-resource\",\n\t\t\texpectedTTL: endpoint.TTL(0),\n\t\t},\n\t\t{\n\t\t\tname: \"valid TTL annotation\",\n\t\t\tannotations: map[string]string{\n\t\t\t\tTtlKey: \"600\",\n\t\t\t},\n\t\t\tresource:    \"test-resource\",\n\t\t\texpectedTTL: endpoint.TTL(600),\n\t\t},\n\t\t{\n\t\t\tname: \"invalid TTL annotation\",\n\t\t\tannotations: map[string]string{\n\t\t\t\tTtlKey: \"invalid\",\n\t\t\t},\n\t\t\tresource:    \"test-resource\",\n\t\t\texpectedTTL: endpoint.TTL(0),\n\t\t},\n\t\t{\n\t\t\tname: \"TTL annotation out of range\",\n\t\t\tannotations: map[string]string{\n\t\t\t\tTtlKey: \"999999\",\n\t\t\t},\n\t\t\tresource:    \"test-resource\",\n\t\t\texpectedTTL: endpoint.TTL(999999),\n\t\t},\n\t\t{\n\t\t\tname:        \"TTL annotation not present\",\n\t\t\tannotations: map[string]string{\"foo\": \"bar\"},\n\t\t\texpectedTTL: endpoint.TTL(0),\n\t\t},\n\t\t{\n\t\t\tname:        \"TTL annotation value is not a number\",\n\t\t\tannotations: map[string]string{TtlKey: \"foo\"},\n\t\t\texpectedTTL: endpoint.TTL(0),\n\t\t},\n\t\t{\n\t\t\tname:        \"TTL annotation value is empty\",\n\t\t\tannotations: map[string]string{TtlKey: \"\"},\n\t\t\texpectedTTL: endpoint.TTL(0),\n\t\t},\n\t\t{\n\t\t\tname:        \"TTL annotation value is negative number\",\n\t\t\tannotations: map[string]string{TtlKey: \"-1\"},\n\t\t\texpectedTTL: endpoint.TTL(0),\n\t\t},\n\t\t{\n\t\t\tname:        \"TTL annotation value is too high\",\n\t\t\tannotations: map[string]string{TtlKey: fmt.Sprintf(\"%d\", 1<<32)},\n\t\t\texpectedTTL: endpoint.TTL(0),\n\t\t},\n\t\t{\n\t\t\tname:        \"TTL annotation value is set correctly using integer\",\n\t\t\tannotations: map[string]string{TtlKey: \"60\"},\n\t\t\texpectedTTL: endpoint.TTL(60),\n\t\t},\n\t\t{\n\t\t\tname:        \"TTL annotation value is set correctly using duration (whole)\",\n\t\t\tannotations: map[string]string{TtlKey: \"10m\"},\n\t\t\texpectedTTL: endpoint.TTL(600),\n\t\t},\n\t\t{\n\t\t\tname:        \"TTL annotation value is set correctly using duration (fractional)\",\n\t\t\tannotations: map[string]string{TtlKey: \"20.5s\"},\n\t\t\texpectedTTL: endpoint.TTL(20),\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tttl := TTLFromAnnotations(tt.annotations, tt.resource)\n\t\t\tassert.Equal(t, tt.expectedTTL, ttl)\n\t\t})\n\t}\n}\n\nfunc TestGetAliasFromAnnotations(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tannotations map[string]string\n\t\texpected    bool\n\t}{\n\t\t{\n\t\t\tname:        \"alias annotation exists and is true\",\n\t\t\tannotations: map[string]string{AliasKey: \"true\"},\n\t\t\texpected:    true,\n\t\t},\n\t\t{\n\t\t\tname:        \"alias annotation exists and is false\",\n\t\t\tannotations: map[string]string{AliasKey: \"false\"},\n\t\t\texpected:    false,\n\t\t},\n\t\t{\n\t\t\tname:        \"alias annotation does not exist\",\n\t\t\tannotations: map[string]string{},\n\t\t\texpected:    false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := hasAliasFromAnnotations(tt.annotations)\n\t\t\tassert.Equal(t, tt.expected, result)\n\t\t})\n\t}\n}\n\nfunc TestHostnamesFromAnnotations(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tannotations map[string]string\n\t\texpected    []string\n\t}{\n\t\t{\n\t\t\tname:        \"no hostname annotation\",\n\t\t\tannotations: map[string]string{},\n\t\t\texpected:    nil,\n\t\t},\n\t\t{\n\t\t\tname: \"single hostname annotation\",\n\t\t\tannotations: map[string]string{\n\t\t\t\tHostnameKey: \"example.com\",\n\t\t\t},\n\t\t\texpected: []string{\"example.com\"},\n\t\t},\n\t\t{\n\t\t\tname: \"multiple hostname annotations\",\n\t\t\tannotations: map[string]string{\n\t\t\t\tHostnameKey: \"example.com,example.org\",\n\t\t\t},\n\t\t\texpected: []string{\"example.com\", \"example.org\"},\n\t\t},\n\t\t{\n\t\t\tname: \"hostname annotation with spaces\",\n\t\t\tannotations: map[string]string{\n\t\t\t\tHostnameKey: \" example.com , example.org \",\n\t\t\t},\n\t\t\texpected: []string{\"example.com\", \"example.org\"},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := HostnamesFromAnnotations(tt.annotations)\n\t\t\tassert.Equal(t, tt.expected, result)\n\t\t})\n\t}\n}\n\nfunc TestSplitHostnameAnnotation(t *testing.T) {\n\ttests := []struct {\n\t\tname       string\n\t\tannotation string\n\t\texpected   []string\n\t}{\n\t\t{\n\t\t\tname:       \"empty annotation\",\n\t\t\tannotation: \"\",\n\t\t\texpected:   []string{\"\"},\n\t\t},\n\t\t{\n\t\t\tname:       \"single hostname\",\n\t\t\tannotation: \"example.com\",\n\t\t\texpected:   []string{\"example.com\"},\n\t\t},\n\t\t{\n\t\t\tname:       \"multiple hostnames\",\n\t\t\tannotation: \"example.com,example.org\",\n\t\t\texpected:   []string{\"example.com\", \"example.org\"},\n\t\t},\n\t\t{\n\t\t\tname:       \"hostnames with spaces\",\n\t\t\tannotation: \" example.com , example.org \",\n\t\t\texpected:   []string{\"example.com\", \"example.org\"},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := SplitHostnameAnnotation(tt.annotation)\n\t\t\tassert.Equal(t, tt.expected, result)\n\t\t})\n\t}\n}\n\nfunc TestInternalHostnamesFromAnnotations(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tannotations map[string]string\n\t\texpected    []string\n\t}{\n\t\t{\n\t\t\tname:        \"no internal hostname annotation\",\n\t\t\tannotations: map[string]string{},\n\t\t\texpected:    nil,\n\t\t},\n\t\t{\n\t\t\tname: \"single internal hostname annotation\",\n\t\t\tannotations: map[string]string{\n\t\t\t\tInternalHostnameKey: \"internal.example.com\",\n\t\t\t},\n\t\t\texpected: []string{\"internal.example.com\"},\n\t\t},\n\t\t{\n\t\t\tname: \"multiple internal hostname annotations\",\n\t\t\tannotations: map[string]string{\n\t\t\t\tInternalHostnameKey: \"internal.example.com,internal.example.org\",\n\t\t\t},\n\t\t\texpected: []string{\"internal.example.com\", \"internal.example.org\"},\n\t\t},\n\t\t{\n\t\t\tname: \"internal hostname annotation with spaces\",\n\t\t\tannotations: map[string]string{\n\t\t\t\tInternalHostnameKey: \" internal.example.com , internal.example.org \",\n\t\t\t},\n\t\t\texpected: []string{\"internal.example.com\", \"internal.example.org\"},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := InternalHostnamesFromAnnotations(tt.annotations)\n\t\t\tassert.Equal(t, tt.expected, result)\n\t\t})\n\t}\n}\n\nfunc TestIsControllerMismatch(t *testing.T) {\n\ttests := []struct {\n\t\tname         string\n\t\tannotations  map[string]string\n\t\tentity       objectUnderTest\n\t\tresourceType string\n\t\tdebugMsg     string\n\t\texpected     bool\n\t}{\n\t\t{\n\t\t\tname: \"no controller annotation\",\n\t\t\tentity: objectUnderTest{\n\t\t\t\tmeta: metav1.ObjectMeta{\n\t\t\t\t\tName:        \"my-service\",\n\t\t\t\t\tNamespace:   \"default\",\n\t\t\t\t\tAnnotations: map[string]string{},\n\t\t\t\t},\n\t\t\t},\n\t\t\tresourceType: \"service\",\n\t\t\texpected:     false,\n\t\t},\n\t\t{\n\t\t\tname: \"non-matching controller annotation\",\n\t\t\tentity: objectUnderTest{\n\t\t\t\tmeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"my-service\",\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\tControllerKey: \"other-controller\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tdebugMsg:     fmt.Sprintf(\"Skipping 'service/default/my-service' because controller '%s' value does not match, found: 'other-controller', required: '%s'\", ControllerKey, ControllerValue),\n\t\t\tresourceType: \"service\",\n\t\t\texpected:     true,\n\t\t},\n\t\t{\n\t\t\tname: \"empty controller value with annotation\",\n\t\t\tentity: objectUnderTest{\n\t\t\t\tmeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test-ingress\",\n\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\tControllerKey: \"\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tdebugMsg:     fmt.Sprintf(\"Skipping 'ingress/kube-system/test-ingress' because controller '%s' value does not match, found: '', required: '%s'\", ControllerKey, ControllerValue),\n\t\t\tresourceType: \"ingress\",\n\t\t\texpected:     true,\n\t\t},\n\t\t{\n\t\t\tname: \"nil annotations\",\n\t\t\tentity: objectUnderTest{\n\t\t\t\tmeta: metav1.ObjectMeta{\n\t\t\t\t\tName:        \"service\",\n\t\t\t\t\tNamespace:   \"default\",\n\t\t\t\t\tAnnotations: nil,\n\t\t\t\t},\n\t\t\t},\n\t\t\tresourceType: \"service\",\n\t\t\texpected:     false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\thook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t)\n\n\t\t\tresult := IsControllerMismatch(&tt.entity, tt.resourceType)\n\t\t\tassert.Equal(t, tt.expected, result)\n\n\t\t\tif tt.debugMsg != \"\" {\n\t\t\t\tlogtest.TestHelperLogContains(tt.debugMsg, hook, t)\n\t\t\t} else {\n\t\t\t\tlogtest.TestHelperLogNotContains(\"Skipping\", hook, t)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "source/annotations/provider_specific.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n    http://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage annotations\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\nfunc ProviderSpecificAnnotations(annotations map[string]string) (endpoint.ProviderSpecific, string) {\n\tproviderSpecificAnnotations := endpoint.ProviderSpecific{}\n\n\tif hasAliasFromAnnotations(annotations) {\n\t\tproviderSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{\n\t\t\tName:  \"alias\",\n\t\t\tValue: \"true\",\n\t\t})\n\t}\n\tif v, ok := annotations[RecordTypeKey]; ok {\n\t\tproviderSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{\n\t\t\tName:  endpoint.ProviderSpecificRecordType,\n\t\t\tValue: v,\n\t\t})\n\t}\n\tsetIdentifier := \"\"\n\tfor k, v := range annotations {\n\t\tif k == SetIdentifierKey {\n\t\t\tsetIdentifier = v\n\t\t} else if attr, ok := strings.CutPrefix(k, AWSPrefix); ok {\n\t\t\tproviderSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{\n\t\t\t\tName:  fmt.Sprintf(\"aws/%s\", attr),\n\t\t\t\tValue: v,\n\t\t\t})\n\t\t} else if attr, ok := strings.CutPrefix(k, SCWPrefix); ok {\n\t\t\tproviderSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{\n\t\t\t\tName:  fmt.Sprintf(\"scw/%s\", attr),\n\t\t\t\tValue: v,\n\t\t\t})\n\t\t} else if attr, ok := strings.CutPrefix(k, WebhookPrefix); ok {\n\t\t\t// Support for wildcard annotations for webhook providers\n\t\t\tproviderSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{\n\t\t\t\tName:  fmt.Sprintf(\"webhook/%s\", attr),\n\t\t\t\tValue: v,\n\t\t\t})\n\t\t} else if attr, ok := strings.CutPrefix(k, CoreDNSPrefix); ok {\n\t\t\tproviderSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{\n\t\t\t\tName:  fmt.Sprintf(\"coredns/%s\", attr),\n\t\t\t\tValue: v,\n\t\t\t})\n\t\t} else if strings.HasPrefix(k, CloudflarePrefix) {\n\t\t\t// TODO: unlike other providers which normalise to \"provider/attr\",\n\t\t\t// Cloudflare retains the full annotation key as the property name\n\t\t\t// (e.g. \"external-dns.alpha.kubernetes.io/cloudflare-proxied\").\n\t\t\t// This is why RetainProviderProperties has a special case for cloudflare.\n\t\t\t// Should be aligned with the standard convention in a future change.\n\t\t\tswitch {\n\t\t\tcase strings.Contains(k, CloudflareCustomHostnameKey):\n\t\t\t\tproviderSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{\n\t\t\t\t\tName:  CloudflareCustomHostnameKey,\n\t\t\t\t\tValue: v,\n\t\t\t\t})\n\t\t\tcase strings.Contains(k, CloudflareProxiedKey):\n\t\t\t\tproviderSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{\n\t\t\t\t\tName:  CloudflareProxiedKey,\n\t\t\t\t\tValue: v,\n\t\t\t\t})\n\t\t\tcase strings.Contains(k, CloudflareRegionKey):\n\t\t\t\tproviderSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{\n\t\t\t\t\tName:  CloudflareRegionKey,\n\t\t\t\t\tValue: v,\n\t\t\t\t})\n\t\t\tcase strings.Contains(k, CloudflareRecordCommentKey):\n\t\t\t\tproviderSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{\n\t\t\t\t\tName:  CloudflareRecordCommentKey,\n\t\t\t\t\tValue: v,\n\t\t\t\t})\n\t\t\tcase strings.Contains(k, CloudflareTagsKey):\n\t\t\t\tproviderSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{\n\t\t\t\t\tName:  CloudflareTagsKey,\n\t\t\t\t\tValue: v,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n\treturn providerSpecificAnnotations, setIdentifier\n}\n"
  },
  {
    "path": "source/annotations/provider_specific_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n    http://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage annotations\n\nimport (\n\t\"strconv\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\nfunc TestProviderSpecificAnnotations(t *testing.T) {\n\ttests := []struct {\n\t\tname          string\n\t\tannotations   map[string]string\n\t\texpected      endpoint.ProviderSpecific\n\t\tsetIdentifier string\n\t}{\n\t\t{\n\t\t\tname:          \"no annotations\",\n\t\t\tannotations:   map[string]string{},\n\t\t\texpected:      endpoint.ProviderSpecific{},\n\t\t\tsetIdentifier: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"Cloudflare proxied annotation\",\n\t\t\tannotations: map[string]string{\n\t\t\t\tCloudflareProxiedKey: \"true\",\n\t\t\t},\n\t\t\texpected: endpoint.ProviderSpecific{\n\t\t\t\t{Name: CloudflareProxiedKey, Value: \"true\"},\n\t\t\t},\n\t\t\tsetIdentifier: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"Cloudflare custom hostname annotation\",\n\t\t\tannotations: map[string]string{\n\t\t\t\tCloudflareCustomHostnameKey: \"custom.example.com\",\n\t\t\t},\n\t\t\texpected: endpoint.ProviderSpecific{\n\t\t\t\t{Name: CloudflareCustomHostnameKey, Value: \"custom.example.com\"},\n\t\t\t},\n\t\t\tsetIdentifier: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"AWS annotation\",\n\t\t\tannotations: map[string]string{\n\t\t\t\t\"external-dns.alpha.kubernetes.io/aws-weight\": \"100\",\n\t\t\t},\n\t\t\texpected: endpoint.ProviderSpecific{\n\t\t\t\t{Name: \"aws/weight\", Value: \"100\"},\n\t\t\t},\n\t\t\tsetIdentifier: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"CoreDNS annotation\",\n\t\t\tannotations: map[string]string{\n\t\t\t\t\"external-dns.alpha.kubernetes.io/coredns-group\": \"g1\",\n\t\t\t},\n\t\t\texpected: endpoint.ProviderSpecific{\n\t\t\t\t{Name: \"coredns/group\", Value: \"g1\"},\n\t\t\t},\n\t\t\tsetIdentifier: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"Set identifier annotation\",\n\t\t\tannotations: map[string]string{\n\t\t\t\tSetIdentifierKey: \"identifier\",\n\t\t\t},\n\t\t\texpected:      endpoint.ProviderSpecific{},\n\t\t\tsetIdentifier: \"identifier\",\n\t\t},\n\t\t{\n\t\t\tname: \"Record type annotation\",\n\t\t\tannotations: map[string]string{\n\t\t\t\tRecordTypeKey: \"ptr\",\n\t\t\t},\n\t\t\texpected: endpoint.ProviderSpecific{\n\t\t\t\t{Name: endpoint.ProviderSpecificRecordType, Value: \"ptr\"},\n\t\t\t},\n\t\t\tsetIdentifier: \"\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult, setIdentifier := ProviderSpecificAnnotations(tt.annotations)\n\t\t\tassert.Equal(t, tt.expected, result)\n\t\t\tassert.Equal(t, tt.setIdentifier, setIdentifier)\n\n\t\t\tfor _, prop := range result {\n\t\t\t\tslashIdx := strings.Index(prop.Name, \"/\")\n\t\t\t\tif slashIdx == -1 || strings.HasPrefix(prop.Name, CloudflarePrefix) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tassert.NotContains(t, prop.Name[:slashIdx], \".\",\n\t\t\t\t\t\"property %q uses a full annotation name; only cloudflare is allowed to — use the short \\\"provider/attr\\\" form instead\", prop.Name)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetProviderSpecificCloudflareAnnotations(t *testing.T) {\n\n\tfor _, tc := range []struct {\n\t\ttitle         string\n\t\tannotations   map[string]string\n\t\texpectedKey   string\n\t\texpectedValue string\n\t}{\n\t\t{\n\t\t\ttitle:         \"Cloudflare tags annotation is set correctly\",\n\t\t\tannotations:   map[string]string{CloudflareTagsKey: \"env:test,owner:team-a\"},\n\t\t\texpectedKey:   CloudflareTagsKey,\n\t\t\texpectedValue: \"env:test,owner:team-a\",\n\t\t},\n\t\t{\n\t\t\ttitle: \"Cloudflare tags annotation among another annotations is set correctly\",\n\t\t\tannotations: map[string]string{\n\t\t\t\t\"random annotation 1\": \"random value 1\",\n\t\t\t\tCloudflareTagsKey:     \"env:test,owner:team-b\",\n\t\t\t\t\"random annotation 2\": \"random value 2\"},\n\t\t\texpectedKey:   CloudflareTagsKey,\n\t\t\texpectedValue: \"env:test,owner:team-b\",\n\t\t},\n\t} {\n\t\tt.Run(tc.title, func(t *testing.T) {\n\t\t\tproviderSpecificAnnotations, _ := ProviderSpecificAnnotations(tc.annotations)\n\t\t\tfor _, providerSpecificAnnotation := range providerSpecificAnnotations {\n\t\t\t\tif providerSpecificAnnotation.Name == tc.expectedKey {\n\t\t\t\t\tassert.Equal(t, tc.expectedValue, providerSpecificAnnotation.Value)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\tt.Errorf(\"Cloudflare provider specific annotation %s is not set correctly to %s\", tc.expectedKey, tc.expectedValue)\n\t\t})\n\t}\n\n\tfor _, tc := range []struct {\n\t\ttitle         string\n\t\tannotations   map[string]string\n\t\texpectedKey   string\n\t\texpectedValue bool\n\t}{\n\t\t{\n\t\t\ttitle:         \"Cloudflare proxied annotation is set correctly to true\",\n\t\t\tannotations:   map[string]string{CloudflareProxiedKey: \"true\"},\n\t\t\texpectedKey:   CloudflareProxiedKey,\n\t\t\texpectedValue: true,\n\t\t},\n\t\t{\n\t\t\ttitle:         \"Cloudflare proxied annotation is set correctly to false\",\n\t\t\tannotations:   map[string]string{CloudflareProxiedKey: \"false\"},\n\t\t\texpectedKey:   CloudflareProxiedKey,\n\t\t\texpectedValue: false,\n\t\t},\n\t\t{\n\t\t\ttitle: \"Cloudflare proxied annotation among another annotations is set correctly to true\",\n\t\t\tannotations: map[string]string{\n\t\t\t\t\"random annotation 1\": \"random value 1\",\n\t\t\t\tCloudflareProxiedKey:  \"false\",\n\t\t\t\t\"random annotation 2\": \"random value 2\",\n\t\t\t},\n\t\t\texpectedKey:   CloudflareProxiedKey,\n\t\t\texpectedValue: false,\n\t\t},\n\t} {\n\t\tt.Run(tc.title, func(t *testing.T) {\n\t\t\tproviderSpecificAnnotations, _ := ProviderSpecificAnnotations(tc.annotations)\n\t\t\tfor _, providerSpecificAnnotation := range providerSpecificAnnotations {\n\t\t\t\tif providerSpecificAnnotation.Name == tc.expectedKey {\n\t\t\t\t\tassert.Equal(t, strconv.FormatBool(tc.expectedValue), providerSpecificAnnotation.Value)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\tt.Errorf(\"Cloudflare provider specific annotation %s is not set correctly to %v\", tc.expectedKey, tc.expectedValue)\n\t\t})\n\t}\n\n\tfor _, tc := range []struct {\n\t\ttitle         string\n\t\tannotations   map[string]string\n\t\texpectedKey   string\n\t\texpectedValue string\n\t}{\n\t\t{\n\t\t\ttitle:         \"Cloudflare region key annotation is set correctly\",\n\t\t\tannotations:   map[string]string{CloudflareRegionKey: \"us\"},\n\t\t\texpectedKey:   CloudflareRegionKey,\n\t\t\texpectedValue: \"us\",\n\t\t},\n\t\t{\n\t\t\ttitle: \"Cloudflare region key annotation among another annotations is set correctly\",\n\t\t\tannotations: map[string]string{\n\t\t\t\t\"random annotation 1\": \"random value 1\",\n\t\t\t\tCloudflareRegionKey:   \"us\",\n\t\t\t\t\"random annotation 2\": \"random value 2\",\n\t\t\t},\n\t\t\texpectedKey:   CloudflareRegionKey,\n\t\t\texpectedValue: \"us\",\n\t\t},\n\t\t{\n\t\t\ttitle: \"Cloudflare DNS record comment annotation is set correctly\",\n\t\t\tannotations: map[string]string{\n\t\t\t\tCloudflareRecordCommentKey: \"comment\",\n\t\t\t},\n\t\t\texpectedKey:   CloudflareRecordCommentKey,\n\t\t\texpectedValue: \"comment\",\n\t\t},\n\t} {\n\t\tt.Run(tc.title, func(t *testing.T) {\n\t\t\tproviderSpecificAnnotations, _ := ProviderSpecificAnnotations(tc.annotations)\n\t\t\tfor _, providerSpecificAnnotation := range providerSpecificAnnotations {\n\t\t\t\tif providerSpecificAnnotation.Name == tc.expectedKey {\n\t\t\t\t\tassert.Equal(t, tc.expectedValue, providerSpecificAnnotation.Value)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\tt.Errorf(\"Cloudflare provider specific annotation %s is not set correctly to %v\", tc.expectedKey, tc.expectedValue)\n\t\t})\n\t}\n\n\tfor _, tc := range []struct {\n\t\ttitle         string\n\t\tannotations   map[string]string\n\t\texpectedKey   string\n\t\texpectedValue string\n\t}{\n\t\t{\n\t\t\ttitle:         \"Cloudflare custom hostname annotation is set correctly\",\n\t\t\tannotations:   map[string]string{CloudflareCustomHostnameKey: \"a.foo.fancybar.com\"},\n\t\t\texpectedKey:   CloudflareCustomHostnameKey,\n\t\t\texpectedValue: \"a.foo.fancybar.com\",\n\t\t},\n\t\t{\n\t\t\ttitle: \"Cloudflare custom hostname annotation among another annotations is set correctly\",\n\t\t\tannotations: map[string]string{\n\t\t\t\t\"random annotation 1\":       \"random value 1\",\n\t\t\t\tCloudflareCustomHostnameKey: \"a.foo.fancybar.com\",\n\t\t\t\t\"random annotation 2\":       \"random value 2\"},\n\t\t\texpectedKey:   CloudflareCustomHostnameKey,\n\t\t\texpectedValue: \"a.foo.fancybar.com\",\n\t\t},\n\t} {\n\t\tt.Run(tc.title, func(t *testing.T) {\n\t\t\tproviderSpecificAnnotations, _ := ProviderSpecificAnnotations(tc.annotations)\n\t\t\tfor _, providerSpecificAnnotation := range providerSpecificAnnotations {\n\t\t\t\tif providerSpecificAnnotation.Name == tc.expectedKey {\n\t\t\t\t\tassert.Equal(t, tc.expectedValue, providerSpecificAnnotation.Value)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\tt.Errorf(\"Cloudflare provider specific annotation %s is not set correctly to %s\", tc.expectedKey, tc.expectedValue)\n\t\t})\n\t}\n}\n\nfunc TestGetProviderSpecificAliasAnnotations(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\ttitle         string\n\t\tannotations   map[string]string\n\t\texpectedKey   string\n\t\texpectedValue bool\n\t}{\n\t\t{\n\t\t\ttitle:         \"alias annotation is set correctly to true\",\n\t\t\tannotations:   map[string]string{AliasKey: \"true\"},\n\t\t\texpectedKey:   AliasKey,\n\t\t\texpectedValue: true,\n\t\t},\n\t\t{\n\t\t\ttitle: \"alias annotation among another annotations is set correctly to true\",\n\t\t\tannotations: map[string]string{\n\t\t\t\t\"random annotation 1\": \"random value 1\",\n\t\t\t\tAliasKey:              \"true\",\n\t\t\t\t\"random annotation 2\": \"random value 2\",\n\t\t\t},\n\t\t\texpectedKey:   AliasKey,\n\t\t\texpectedValue: true,\n\t\t},\n\t} {\n\t\tt.Run(tc.title, func(t *testing.T) {\n\t\t\tproviderSpecificAnnotations, _ := ProviderSpecificAnnotations(tc.annotations)\n\t\t\tfor _, providerSpecificAnnotation := range providerSpecificAnnotations {\n\t\t\t\tif providerSpecificAnnotation.Name == \"alias\" {\n\t\t\t\t\tassert.Equal(t, strconv.FormatBool(tc.expectedValue), providerSpecificAnnotation.Value)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\tt.Errorf(\"provider specific annotation alias is not set correctly to %v\", tc.expectedValue)\n\t\t})\n\t}\n\n\tfor _, tc := range []struct {\n\t\ttitle       string\n\t\tannotations map[string]string\n\t}{\n\t\t{\n\t\t\ttitle:       \"alias annotation is set to false\",\n\t\t\tannotations: map[string]string{AliasKey: \"false\"},\n\t\t},\n\t\t{\n\t\t\ttitle: \"alias annotation is not set\",\n\t\t\tannotations: map[string]string{\n\t\t\t\t\"random annotation 1\": \"random value 1\",\n\t\t\t\t\"random annotation 2\": \"random value 2\",\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(tc.title, func(t *testing.T) {\n\t\t\tproviderSpecificAnnotations, _ := ProviderSpecificAnnotations(tc.annotations)\n\t\t\tfor _, providerSpecificAnnotation := range providerSpecificAnnotations {\n\t\t\t\tif providerSpecificAnnotation.Name == \"alias\" {\n\t\t\t\t\tt.Error(\"provider specific annotation alias is not expected to be set\")\n\t\t\t\t}\n\t\t\t}\n\n\t\t})\n\t}\n}\n\n// TestProviderSpecificPropertyNameConvention enforces that only Cloudflare may\n// emit the full annotation name (e.g. \"external-dns.alpha.kubernetes.io/cloudflare-proxied\")\n// as a property name. All other providers must normalise to the short \"provider/attr\" form\n// (e.g. \"aws/weight\"). If a new provider (e.g. azure-, ovh-) is added but accidentally\n// outputs the full annotation name, this test will catch it.\nfunc TestProviderSpecificPropertyNameConvention(t *testing.T) {\n\tannotations := map[string]string{\n\t\tAnnotationKeyPrefix + \"aws-weight\":        \"10\",\n\t\tAnnotationKeyPrefix + \"scw-something\":     \"val\",\n\t\tAnnotationKeyPrefix + \"webhook-something\": \"val\",\n\t\tAnnotationKeyPrefix + \"coredns-group\":     \"g1\",\n\t\tCloudflareProxiedKey:                      \"true\",\n\t\tCloudflareTagsKey:                         \"tag1\",\n\t\tCloudflareRegionKey:                       \"us\",\n\t\tCloudflareRecordCommentKey:                \"comment\",\n\t\tCloudflareCustomHostnameKey:               \"host.example.com\",\n\t\tAliasKey:                                  \"true\",\n\t}\n\n\tprops, _ := ProviderSpecificAnnotations(annotations)\n\tfor _, prop := range props {\n\t\tname := prop.Name\n\t\tslashIdx := strings.Index(name, \"/\")\n\t\tif slashIdx == -1 {\n\t\t\t// No slash: provider-agnostic property (e.g. \"alias\") — always OK.\n\t\t\tcontinue\n\t\t}\n\t\t// Cloudflare exception: retains the full annotation name.\n\t\tif strings.HasPrefix(name, CloudflarePrefix) {\n\t\t\tcontinue\n\t\t}\n\t\t// All other providers must use the short \"provider/attr\" form.\n\t\t// The segment before \"/\" must be a plain word with no dots.\n\t\tproviderSegment := name[:slashIdx]\n\t\tassert.NotContains(t, providerSegment, \".\",\n\t\t\t\"property %q uses a full annotation name; only cloudflare is allowed to — use the short \\\"provider/attr\\\" form instead\", name)\n\t}\n}\n\nfunc TestGetProviderSpecificIdentifierAnnotations(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\ttitle              string\n\t\tannotations        map[string]string\n\t\texpectedResult     map[string]string\n\t\texpectedIdentifier string\n\t}{\n\t\t{\n\t\t\ttitle: \"aws- provider specific annotations are set correctly\",\n\t\t\tannotations: map[string]string{\n\t\t\t\t\"external-dns.alpha.kubernetes.io/aws-annotation-1\": \"value 1\",\n\t\t\t\tSetIdentifierKey: \"id1\",\n\t\t\t\t\"external-dns.alpha.kubernetes.io/aws-annotation-2\": \"value 2\",\n\t\t\t},\n\t\t\texpectedResult: map[string]string{\n\t\t\t\t\"aws/annotation-1\": \"value 1\",\n\t\t\t\t\"aws/annotation-2\": \"value 2\",\n\t\t\t},\n\t\t\texpectedIdentifier: \"id1\",\n\t\t},\n\t\t{\n\t\t\ttitle: \"scw- provider specific annotations are set correctly\",\n\t\t\tannotations: map[string]string{\n\t\t\t\t\"external-dns.alpha.kubernetes.io/scw-annotation-1\": \"value 1\",\n\t\t\t\tSetIdentifierKey: \"id1\",\n\t\t\t\t\"external-dns.alpha.kubernetes.io/scw-annotation-2\": \"value 2\",\n\t\t\t},\n\t\t\texpectedResult: map[string]string{\n\t\t\t\t\"scw/annotation-1\": \"value 1\",\n\t\t\t\t\"scw/annotation-2\": \"value 2\",\n\t\t\t},\n\t\t\texpectedIdentifier: \"id1\",\n\t\t},\n\t\t{\n\t\t\ttitle: \"webhook- provider specific annotations are set correctly\",\n\t\t\tannotations: map[string]string{\n\t\t\t\t\"external-dns.alpha.kubernetes.io/webhook-annotation-1\": \"value 1\",\n\t\t\t\tSetIdentifierKey: \"id1\",\n\t\t\t\t\"external-dns.alpha.kubernetes.io/webhook-annotation-2\": \"value 2\",\n\t\t\t},\n\t\t\texpectedResult: map[string]string{\n\t\t\t\t\"webhook/annotation-1\": \"value 1\",\n\t\t\t\t\"webhook/annotation-2\": \"value 2\",\n\t\t\t},\n\t\t\texpectedIdentifier: \"id1\",\n\t\t},\n\t} {\n\t\tt.Run(tc.title, func(t *testing.T) {\n\t\t\tproviderSpecificAnnotations, identifier := ProviderSpecificAnnotations(tc.annotations)\n\t\t\tassert.Equal(t, tc.expectedIdentifier, identifier)\n\t\t\tfor expectedAnnotationKey, expectedAnnotationValue := range tc.expectedResult {\n\t\t\t\texpectedResultFound := false\n\t\t\t\tfor _, providerSpecificAnnotation := range providerSpecificAnnotations {\n\t\t\t\t\tif providerSpecificAnnotation.Name == expectedAnnotationKey {\n\t\t\t\t\t\tassert.Equal(t, expectedAnnotationValue, providerSpecificAnnotation.Value)\n\t\t\t\t\t\texpectedResultFound = true\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif !expectedResultFound {\n\t\t\t\t\tt.Errorf(\"provider specific annotation %s has not been set\", expectedAnnotationKey)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "source/compatibility.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"strings\"\n\n\tv1 \"k8s.io/api/core/v1\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\nconst (\n\tmateAnnotationKey     = \"zalando.org/dnsname\"\n\tmoleculeAnnotationKey = \"domainName\"\n\t// kopsDNSControllerHostnameAnnotationKey is the annotation used for defining the desired hostname when kOps DNS controller compatibility mode\n\tkopsDNSControllerHostnameAnnotationKey = \"dns.alpha.kubernetes.io/external\"\n\t// kopsDNSControllerInternalHostnameAnnotationKey is the annotation used for defining the desired hostname when kOps DNS controller compatibility mode\n\tkopsDNSControllerInternalHostnameAnnotationKey = \"dns.alpha.kubernetes.io/internal\"\n)\n\n// legacyEndpointsFromService tries to retrieve Endpoints from Services\n// annotated with legacy annotations.\nfunc legacyEndpointsFromService(svc *v1.Service, sc *serviceSource) ([]*endpoint.Endpoint, error) {\n\tswitch sc.compatibility {\n\tcase \"mate\":\n\t\treturn legacyEndpointsFromMateService(svc), nil\n\tcase \"molecule\":\n\t\treturn legacyEndpointsFromMoleculeService(svc), nil\n\tcase \"kops-dns-controller\":\n\t\treturn legacyEndpointsFromDNSControllerService(svc, sc)\n\t}\n\n\treturn []*endpoint.Endpoint{}, nil\n}\n\n// legacyEndpointsFromMateService tries to retrieve Endpoints from Services\n// annotated with Mate's annotation semantics.\nfunc legacyEndpointsFromMateService(svc *v1.Service) []*endpoint.Endpoint {\n\tvar endpoints []*endpoint.Endpoint\n\n\t// Get the desired hostname of the service from the annotation.\n\thostname, ok := svc.Annotations[mateAnnotationKey]\n\tif !ok {\n\t\treturn nil\n\t}\n\n\t// Create a corresponding endpoint for each configured external entrypoint.\n\tfor _, lb := range svc.Status.LoadBalancer.Ingress {\n\t\tif lb.IP != \"\" {\n\t\t\tendpoints = append(endpoints, endpoint.NewEndpoint(hostname, endpoint.RecordTypeA, lb.IP))\n\t\t}\n\t\tif lb.Hostname != \"\" {\n\t\t\tendpoints = append(endpoints, endpoint.NewEndpoint(hostname, endpoint.RecordTypeCNAME, lb.Hostname))\n\t\t}\n\t}\n\n\treturn endpoints\n}\n\n// legacyEndpointsFromMoleculeService tries to retrieve Endpoints from Services\n// annotated with Molecule Software's annotation semantics.\nfunc legacyEndpointsFromMoleculeService(svc *v1.Service) []*endpoint.Endpoint {\n\tvar endpoints []*endpoint.Endpoint\n\n\t// Check that the Service opted-in to being processed.\n\tif svc.Labels[\"dns\"] != \"route53\" {\n\t\treturn nil\n\t}\n\n\t// Get the desired hostname of the service from the annotation.\n\thostnameAnnotation, ok := svc.Annotations[moleculeAnnotationKey]\n\tif !ok {\n\t\treturn nil\n\t}\n\n\thostnameList := strings.SplitSeq(strings.ReplaceAll(hostnameAnnotation, \" \", \"\"), \",\")\n\n\tfor hostname := range hostnameList {\n\t\t// Create a corresponding endpoint for each configured external entrypoint.\n\t\tfor _, lb := range svc.Status.LoadBalancer.Ingress {\n\t\t\tif lb.IP != \"\" {\n\t\t\t\tendpoints = append(endpoints, endpoint.NewEndpoint(hostname, endpoint.RecordTypeA, lb.IP))\n\t\t\t}\n\t\t\tif lb.Hostname != \"\" {\n\t\t\t\tendpoints = append(endpoints, endpoint.NewEndpoint(hostname, endpoint.RecordTypeCNAME, lb.Hostname))\n\t\t\t}\n\t\t}\n\t}\n\n\treturn endpoints\n}\n\n// legacyEndpointsFromDNSControllerService tries to retrieve Endpoints from Services\n// annotated with DNS Controller's annotation semantics*.\nfunc legacyEndpointsFromDNSControllerService(svc *v1.Service, sc *serviceSource) ([]*endpoint.Endpoint, error) {\n\tswitch svc.Spec.Type {\n\tcase v1.ServiceTypeNodePort:\n\t\treturn legacyEndpointsFromDNSControllerNodePortService(svc, sc)\n\tcase v1.ServiceTypeLoadBalancer:\n\t\treturn legacyEndpointsFromDNSControllerLoadBalancerService(svc), nil\n\t}\n\n\treturn []*endpoint.Endpoint{}, nil\n}\n\n// legacyEndpointsFromDNSControllerNodePortService implements DNS controller's semantics for NodePort services.\n// It will use node role label to check if the node has the \"node\" role. This means control plane nodes and other\n// roles will not be used as targets.\nfunc legacyEndpointsFromDNSControllerNodePortService(svc *v1.Service, sc *serviceSource) ([]*endpoint.Endpoint, error) {\n\tvar endpoints []*endpoint.Endpoint\n\n\t// Get the desired hostname of the service from the annotations.\n\thostnameAnnotation, isExternal := svc.Annotations[kopsDNSControllerHostnameAnnotationKey]\n\tinternalHostnameAnnotation, isInternal := svc.Annotations[kopsDNSControllerInternalHostnameAnnotationKey]\n\n\tif !isExternal && !isInternal {\n\t\treturn nil, nil\n\t}\n\n\t// if both annotations are set, we just return empty, mimicking what dns-controller does\n\tif isInternal && isExternal {\n\t\treturn nil, nil\n\t}\n\n\tnodes, err := sc.nodeInformer.Lister().List(labels.Everything())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar hostnameList []string\n\tif isExternal {\n\t\thostnameList = strings.Split(strings.ReplaceAll(hostnameAnnotation, \" \", \"\"), \",\")\n\t} else {\n\t\thostnameList = strings.Split(strings.ReplaceAll(internalHostnameAnnotation, \" \", \"\"), \",\")\n\t}\n\n\tfor _, hostname := range hostnameList {\n\t\tfor _, node := range nodes {\n\t\t\t_, isNode := node.Labels[\"node-role.kubernetes.io/node\"]\n\t\t\tif !isNode {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfor _, address := range node.Status.Addresses {\n\t\t\t\trecordType := endpoint.SuitableType(address.Address)\n\t\t\t\t// IPv6 addresses are labeled as NodeInternalIP despite being usable externally as well.\n\t\t\t\tif isExternal && (address.Type == v1.NodeExternalIP || (sc.exposeInternalIPv6 && address.Type == v1.NodeInternalIP && recordType == endpoint.RecordTypeAAAA)) {\n\t\t\t\t\tendpoints = append(endpoints, endpoint.NewEndpoint(hostname, recordType, address.Address))\n\t\t\t\t}\n\t\t\t\tif isInternal && address.Type == v1.NodeInternalIP {\n\t\t\t\t\tendpoints = append(endpoints, endpoint.NewEndpoint(hostname, recordType, address.Address))\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn endpoints, nil\n}\n\n// legacyEndpointsFromDNSControllerLoadBalancerService will respect both annotations, but\n// will not care if the load balancer actually is internal or not.\nfunc legacyEndpointsFromDNSControllerLoadBalancerService(svc *v1.Service) []*endpoint.Endpoint {\n\tvar endpoints []*endpoint.Endpoint\n\n\t// Get the desired hostname of the service from the annotations.\n\thostnameAnnotation, hasExternal := svc.Annotations[kopsDNSControllerHostnameAnnotationKey]\n\tinternalHostnameAnnotation, hasInternal := svc.Annotations[kopsDNSControllerInternalHostnameAnnotationKey]\n\n\tif !hasExternal && !hasInternal {\n\t\treturn nil\n\t}\n\n\tvar hostnameList []string\n\tif hasExternal {\n\t\thostnameList = append(hostnameList, strings.Split(strings.ReplaceAll(hostnameAnnotation, \" \", \"\"), \",\")...)\n\t}\n\tif hasInternal {\n\t\thostnameList = append(hostnameList, strings.Split(strings.ReplaceAll(internalHostnameAnnotation, \" \", \"\"), \",\")...)\n\t}\n\n\tfor _, hostname := range hostnameList {\n\t\t// Create a corresponding endpoint for each configured external entrypoint.\n\t\tfor _, lb := range svc.Status.LoadBalancer.Ingress {\n\t\t\tif lb.IP != \"\" {\n\t\t\t\tendpoints = append(endpoints, endpoint.NewEndpoint(hostname, endpoint.RecordTypeA, lb.IP))\n\t\t\t}\n\t\t\tif lb.Hostname != \"\" {\n\t\t\t\tendpoints = append(endpoints, endpoint.NewEndpoint(hostname, endpoint.RecordTypeCNAME, lb.Hostname))\n\t\t\t}\n\t\t}\n\t}\n\n\treturn endpoints\n}\n"
  },
  {
    "path": "source/connector.go",
    "content": "/*\nCopyright 2018 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\t\"encoding/gob\"\n\t\"net\"\n\t\"time\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\nconst (\n\tdialTimeout = 30 * time.Second\n)\n\n// connectorSource is an implementation of Source that provides endpoints by connecting\n// to a remote tcp server. The encoding/decoding is done using encoder/gob package.\n//\n// +externaldns:source:name=connector\n// +externaldns:source:category=Special\n// +externaldns:source:description=Connects to a remote TCP server to receive DNS endpoints\n// +externaldns:source:resources=Remote TCP Server\n// +externaldns:source:filters=\n// +externaldns:source:namespace=\n// +externaldns:source:fqdn-template=false\n// +externaldns:source:provider-specific=false\ntype connectorSource struct {\n\tremoteServer string\n}\n\n// NewConnectorSource creates a new connectorSource with the given config.\nfunc NewConnectorSource(remoteServer string) (Source, error) {\n\treturn &connectorSource{\n\t\tremoteServer: remoteServer,\n\t}, nil\n}\n\n// Endpoints returns endpoint objects.\nfunc (cs *connectorSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) {\n\tendpoints := []*endpoint.Endpoint{}\n\n\tconn, err := net.DialTimeout(\"tcp\", cs.remoteServer, dialTimeout)\n\tif err != nil {\n\t\tlog.Errorf(\"Connection error: %v\", err)\n\t\treturn nil, err\n\t}\n\tdefer conn.Close()\n\n\tdecoder := gob.NewDecoder(conn)\n\tif err := decoder.Decode(&endpoints); err != nil {\n\t\tlog.Errorf(\"Decode error: %v\", err)\n\t\treturn nil, err\n\t}\n\n\tlog.Debugf(\"Received endpoints: %#v\", endpoints)\n\n\treturn MergeEndpoints(endpoints), nil\n}\n\nfunc (cs *connectorSource) AddEventHandler(_ context.Context, _ func()) {}\n"
  },
  {
    "path": "source/connector_test.go",
    "content": "/*\nCopyright 2018 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"encoding/gob\"\n\t\"net\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/suite\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\ntype ConnectorSuite struct {\n\tsuite.Suite\n}\n\nfunc (suite *ConnectorSuite) SetupTest() {\n}\n\nfunc startServerToServeTargets(t *testing.T, endpoints []*endpoint.Endpoint) net.Listener {\n\tln, err := net.Listen(\"tcp\", \"localhost:0\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tgo func() {\n\t\tconn, err := ln.Accept()\n\t\tif err != nil {\n\t\t\tln.Close()\n\t\t\treturn\n\t\t}\n\t\tenc := gob.NewEncoder(conn)\n\t\tenc.Encode(endpoints)\n\t\tln.Close()\n\t}()\n\tt.Logf(\"Server listening on %s\", ln.Addr().String())\n\treturn ln\n}\n\nfunc TestConnectorSource(t *testing.T) {\n\tt.Parallel()\n\n\tsuite.Run(t, new(ConnectorSuite))\n\tt.Run(\"Interface\", testConnectorSourceImplementsSource)\n\tt.Run(\"Endpoints\", testConnectorSourceEndpoints)\n}\n\n// testConnectorSourceImplementsSource tests that connectorSource is a valid Source.\nfunc testConnectorSourceImplementsSource(t *testing.T) {\n\tassert.Implements(t, (*Source)(nil), new(connectorSource))\n}\n\n// testConnectorSourceEndpoints tests that NewConnectorSource doesn't return an error.\nfunc testConnectorSourceEndpoints(t *testing.T) {\n\tfor _, ti := range []struct {\n\t\ttitle       string\n\t\tserver      bool\n\t\texpected    []*endpoint.Endpoint\n\t\texpectError bool\n\t}{\n\t\t{\n\t\t\ttitle:       \"invalid remote server\",\n\t\t\tserver:      false,\n\t\t\texpectError: true,\n\t\t},\n\t\t{\n\t\t\ttitle:       \"valid remote server with no endpoints\",\n\t\t\tserver:      true,\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\ttitle:  \"valid remote server\",\n\t\t\tserver: true,\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"abc.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  180,\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\ttitle:  \"valid remote server with multiple endpoints\",\n\t\t\tserver: true,\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"abc.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  180,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"xyz.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"abc.example.org\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  180,\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectError: false,\n\t\t},\n\t} {\n\n\t\tt.Run(ti.title, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\taddr := \"localhost:9999\"\n\t\t\tif ti.server {\n\t\t\t\tln := startServerToServeTargets(t, ti.expected)\n\t\t\t\tdefer ln.Close()\n\t\t\t\taddr = ln.Addr().String()\n\t\t\t}\n\t\t\tcs, _ := NewConnectorSource(addr)\n\n\t\t\tendpoints, err := cs.Endpoints(t.Context())\n\t\t\tif ti.expectError {\n\t\t\t\tassert.Error(t, err)\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t}\n\n\t\t\t// Validate returned endpoints against expected endpoints.\n\t\t\tvalidateEndpoints(t, endpoints, ti.expected)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "source/contour_httpproxy.go",
    "content": "/*\nCopyright 2020 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"text/template\"\n\n\tprojectcontour \"github.com/projectcontour/contour/apis/projectcontour/v1\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\t\"k8s.io/client-go/dynamic\"\n\t\"k8s.io/client-go/dynamic/dynamicinformer\"\n\tkubeinformers \"k8s.io/client-go/informers\"\n\n\t\"sigs.k8s.io/external-dns/source/types\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n\t\"sigs.k8s.io/external-dns/source/fqdn\"\n\t\"sigs.k8s.io/external-dns/source/informers\"\n)\n\n// HTTPProxySource is an implementation of Source for ProjectContour HTTPProxy objects.\n// The HTTPProxy implementation uses the spec.virtualHost.fqdn value for the hostname.\n// Use annotations.TargetKey to explicitly set Endpoint.\n//\n// +externaldns:source:name=contour-httpproxy\n// +externaldns:source:category=Ingress Controllers\n// +externaldns:source:description=Creates DNS entries from Contour HTTPProxy resources\n// +externaldns:source:resources=HTTPProxy.projectcontour.io\n// +externaldns:source:filters=annotation\n// +externaldns:source:namespace=all,single\n// +externaldns:source:fqdn-template=true\n// +externaldns:source:provider-specific=true\ntype httpProxySource struct {\n\tdynamicKubeClient        dynamic.Interface\n\tnamespace                string\n\tannotationFilter         string\n\tfqdnTemplate             *template.Template\n\tcombineFQDNAnnotation    bool\n\tignoreHostnameAnnotation bool\n\thttpProxyInformer        kubeinformers.GenericInformer\n\tunstructuredConverter    *UnstructuredConverter\n}\n\n// NewContourHTTPProxySource creates a new contourHTTPProxySource with the given config.\nfunc NewContourHTTPProxySource(\n\tctx context.Context,\n\tdynamicKubeClient dynamic.Interface,\n\tcfg *Config,\n) (Source, error) {\n\ttmpl, err := fqdn.ParseTemplate(cfg.FQDNTemplate)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Use shared informer to listen for add/update/delete of HTTPProxys in the specified namespace.\n\t// Set resync period to 0, to prevent processing when nothing has changed.\n\tinformerFactory := dynamicinformer.NewFilteredDynamicSharedInformerFactory(dynamicKubeClient, 0, cfg.Namespace, nil)\n\thttpProxyInformer := informerFactory.ForResource(projectcontour.HTTPProxyGVR)\n\n\t// Add default resource event handlers to properly initialize informer.\n\t_, _ = httpProxyInformer.Informer().AddEventHandler(informers.DefaultEventHandler())\n\n\tinformerFactory.Start(ctx.Done())\n\n\t// wait for the local cache to be populated.\n\tif err := informers.WaitForDynamicCacheSync(ctx, informerFactory); err != nil {\n\t\treturn nil, err\n\t}\n\n\tuc, err := NewUnstructuredConverter()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to setup Unstructured Converter: %w\", err)\n\t}\n\n\treturn &httpProxySource{\n\t\tdynamicKubeClient:        dynamicKubeClient,\n\t\tnamespace:                cfg.Namespace,\n\t\tannotationFilter:         cfg.AnnotationFilter,\n\t\tfqdnTemplate:             tmpl,\n\t\tcombineFQDNAnnotation:    cfg.CombineFQDNAndAnnotation,\n\t\tignoreHostnameAnnotation: cfg.IgnoreHostnameAnnotation,\n\t\thttpProxyInformer:        httpProxyInformer,\n\t\tunstructuredConverter:    uc,\n\t}, nil\n}\n\n// Endpoints returns endpoint objects for each host-target combination that should be processed.\n// Retrieves all HTTPProxy resources in the source's namespace(s).\nfunc (sc *httpProxySource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) {\n\thps, err := sc.httpProxyInformer.Lister().ByNamespace(sc.namespace).List(labels.Everything())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar httpProxies []*projectcontour.HTTPProxy\n\tfor _, hp := range hps {\n\t\tunstructuredHP, ok := hp.(*unstructured.Unstructured)\n\t\tif !ok {\n\t\t\treturn nil, errors.New(\"could not convert\")\n\t\t}\n\n\t\thpConverted := &projectcontour.HTTPProxy{}\n\t\terr := sc.unstructuredConverter.scheme.Convert(unstructuredHP, hpConverted, nil)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to convert to HTTPProxy: %w\", err)\n\t\t}\n\t\thttpProxies = append(httpProxies, hpConverted)\n\t}\n\n\thttpProxies, err = annotations.Filter(httpProxies, sc.annotationFilter)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to filter HTTPProxies: %w\", err)\n\t}\n\n\tendpoints := []*endpoint.Endpoint{}\n\n\tfor _, hp := range httpProxies {\n\t\tif annotations.IsControllerMismatch(hp, types.ContourHTTPProxy) {\n\t\t\tcontinue\n\t\t}\n\n\t\thpEndpoints := sc.endpointsFromHTTPProxy(hp)\n\n\t\t// apply template if fqdn is missing on HTTPProxy\n\t\thpEndpoints, err = fqdn.CombineWithTemplatedEndpoints(\n\t\t\thpEndpoints,\n\t\t\tsc.fqdnTemplate,\n\t\t\tsc.combineFQDNAnnotation,\n\t\t\tfunc() ([]*endpoint.Endpoint, error) { return sc.endpointsFromTemplate(hp) },\n\t\t)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif endpoint.HasNoEmptyEndpoints(hpEndpoints, types.ContourHTTPProxy, hp) {\n\t\t\tcontinue\n\t\t}\n\n\t\tlog.Debugf(\"Endpoints generated from HTTPProxy: %s/%s: %v\", hp.Namespace, hp.Name, hpEndpoints)\n\t\tendpoints = append(endpoints, hpEndpoints...)\n\t}\n\n\treturn MergeEndpoints(endpoints), nil\n}\n\nfunc (sc *httpProxySource) endpointsFromTemplate(httpProxy *projectcontour.HTTPProxy) ([]*endpoint.Endpoint, error) {\n\thostnames, err := fqdn.ExecTemplate(sc.fqdnTemplate, httpProxy)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tresource := fmt.Sprintf(\"HTTPProxy/%s/%s\", httpProxy.Namespace, httpProxy.Name)\n\n\tttl := annotations.TTLFromAnnotations(httpProxy.Annotations, resource)\n\n\ttargets := annotations.TargetsFromTargetAnnotation(httpProxy.Annotations)\n\tif len(targets) == 0 {\n\t\tfor _, lb := range httpProxy.Status.LoadBalancer.Ingress {\n\t\t\tif lb.IP != \"\" {\n\t\t\t\ttargets = append(targets, lb.IP)\n\t\t\t}\n\t\t\tif lb.Hostname != \"\" {\n\t\t\t\ttargets = append(targets, lb.Hostname)\n\t\t\t}\n\t\t}\n\t}\n\n\tproviderSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(httpProxy.Annotations)\n\n\tvar endpoints []*endpoint.Endpoint\n\tfor _, hostname := range hostnames {\n\t\tendpoints = append(endpoints, endpoint.EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...)\n\t}\n\treturn endpoints, nil\n}\n\n// endpointsFromHTTPProxyConfig extracts the endpoints from a Contour HTTPProxy object\nfunc (sc *httpProxySource) endpointsFromHTTPProxy(httpProxy *projectcontour.HTTPProxy) []*endpoint.Endpoint {\n\tresource := fmt.Sprintf(\"HTTPProxy/%s/%s\", httpProxy.Namespace, httpProxy.Name)\n\n\tttl := annotations.TTLFromAnnotations(httpProxy.Annotations, resource)\n\n\ttargets := annotations.TargetsFromTargetAnnotation(httpProxy.Annotations)\n\n\tif len(targets) == 0 {\n\t\tfor _, lb := range httpProxy.Status.LoadBalancer.Ingress {\n\t\t\tif lb.IP != \"\" {\n\t\t\t\ttargets = append(targets, lb.IP)\n\t\t\t}\n\t\t\tif lb.Hostname != \"\" {\n\t\t\t\ttargets = append(targets, lb.Hostname)\n\t\t\t}\n\t\t}\n\t}\n\n\tproviderSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(httpProxy.Annotations)\n\n\tvar endpoints []*endpoint.Endpoint\n\n\tif virtualHost := httpProxy.Spec.VirtualHost; virtualHost != nil {\n\t\tif fqdn := virtualHost.Fqdn; fqdn != \"\" {\n\t\t\tendpoints = append(endpoints, endpoint.EndpointsForHostname(fqdn, targets, ttl, providerSpecific, setIdentifier, resource)...)\n\t\t}\n\t}\n\n\t// Skip endpoints if we do not want entries from annotations\n\tif !sc.ignoreHostnameAnnotation {\n\t\thostnameList := annotations.HostnamesFromAnnotations(httpProxy.Annotations)\n\t\tfor _, hostname := range hostnameList {\n\t\t\tendpoints = append(endpoints, endpoint.EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...)\n\t\t}\n\t}\n\n\treturn endpoints\n}\n\nfunc (sc *httpProxySource) AddEventHandler(_ context.Context, handler func()) {\n\tlog.Debug(\"Adding event handler for httpproxy\")\n\n\t// Right now there is no way to remove event handler from informer, see:\n\t// https://github.com/kubernetes/kubernetes/issues/79610\n\t_, _ = sc.httpProxyInformer.Informer().AddEventHandler(eventHandlerFunc(handler))\n}\n"
  },
  {
    "path": "source/contour_httpproxy_test.go",
    "content": "/*\nCopyright 2020 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"testing\"\n\n\tfakeDynamic \"k8s.io/client-go/dynamic/fake\"\n\n\tprojectcontour \"github.com/projectcontour/contour/apis/projectcontour/v1\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\t\"github.com/stretchr/testify/suite\"\n\tv1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured\"\n\t\"k8s.io/apimachinery/pkg/runtime\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n)\n\n// This is a compile-time validation that httpProxySource is a Source.\nvar _ Source = &httpProxySource{}\n\ntype HTTPProxySuite struct {\n\tsuite.Suite\n\tsource    Source\n\thttpProxy *projectcontour.HTTPProxy\n}\n\nfunc newDynamicKubernetesClient() (*fakeDynamic.FakeDynamicClient, *runtime.Scheme) {\n\ts := runtime.NewScheme()\n\t_ = projectcontour.AddToScheme(s)\n\treturn fakeDynamic.NewSimpleDynamicClient(s), s\n}\n\ntype fakeLoadBalancerService struct {\n\tips       []string\n\thostnames []string\n\tnamespace string\n\tname      string\n}\n\nfunc (ig fakeLoadBalancerService) Service() *v1.Service {\n\tsvc := &v1.Service{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tNamespace: ig.namespace,\n\t\t\tName:      ig.name,\n\t\t},\n\t\tStatus: v1.ServiceStatus{\n\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\tIngress: []v1.LoadBalancerIngress{},\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, ip := range ig.ips {\n\t\tsvc.Status.LoadBalancer.Ingress = append(svc.Status.LoadBalancer.Ingress, v1.LoadBalancerIngress{\n\t\t\tIP: ip,\n\t\t})\n\t}\n\tfor _, hostname := range ig.hostnames {\n\t\tsvc.Status.LoadBalancer.Ingress = append(svc.Status.LoadBalancer.Ingress, v1.LoadBalancerIngress{\n\t\t\tHostname: hostname,\n\t\t})\n\t}\n\n\treturn svc\n}\n\nfunc (suite *HTTPProxySuite) SetupTest() {\n\tfakeDynamicClient, s := newDynamicKubernetesClient()\n\tvar err error\n\n\tsuite.source, err = NewContourHTTPProxySource(\n\t\tcontext.TODO(),\n\t\tfakeDynamicClient,\n\t\t&Config{\n\t\t\tNamespace:    \"default\",\n\t\t\tFQDNTemplate: \"{{.Name}}\",\n\t\t},\n\t)\n\tsuite.NoError(err, \"should initialize httpproxy source\")\n\n\tsuite.httpProxy = (fakeHTTPProxy{\n\t\tname:      \"foo-httpproxy-with-targets\",\n\t\tnamespace: \"default\",\n\t\thost:      \"example.com\",\n\t}).HTTPProxy()\n\n\t// Convert to unstructured\n\tunstructuredHTTPProxy, err := convertHTTPProxyToUnstructured(suite.httpProxy, s)\n\tif err != nil {\n\t\tsuite.Error(err)\n\t}\n\n\t_, err = fakeDynamicClient.Resource(projectcontour.HTTPProxyGVR).Namespace(suite.httpProxy.Namespace).Create(context.Background(), unstructuredHTTPProxy, metav1.CreateOptions{})\n\tsuite.NoError(err, \"should succeed\")\n}\n\nfunc (suite *HTTPProxySuite) TestResourceLabelIsSet() {\n\tendpoints, _ := suite.source.Endpoints(context.Background())\n\tfor _, ep := range endpoints {\n\t\tsuite.Equal(\"httpproxy/default/foo-httpproxy-with-targets\", ep.Labels[endpoint.ResourceLabelKey], \"should set correct resource label\")\n\t}\n}\n\nfunc convertHTTPProxyToUnstructured(hp *projectcontour.HTTPProxy, s *runtime.Scheme) (*unstructured.Unstructured, error) {\n\tunstructuredHTTPProxy := &unstructured.Unstructured{}\n\tif err := s.Convert(hp, unstructuredHTTPProxy, context.Background()); err != nil {\n\t\treturn nil, err\n\t}\n\treturn unstructuredHTTPProxy, nil\n}\n\nfunc TestHTTPProxy(t *testing.T) {\n\tt.Parallel()\n\n\tsuite.Run(t, new(HTTPProxySuite))\n\tt.Run(\"endpointsFromHTTPProxy\", testEndpointsFromHTTPProxy)\n\tt.Run(\"Endpoints\", testHTTPProxyEndpoints)\n}\n\nfunc TestNewContourHTTPProxySource(t *testing.T) {\n\tt.Parallel()\n\n\tfor _, ti := range []struct {\n\t\ttitle                    string\n\t\tannotationFilter         string\n\t\tfqdnTemplate             string\n\t\tcombineFQDNAndAnnotation bool\n\t\texpectError              bool\n\t}{\n\t\t{\n\t\t\ttitle:        \"invalid template\",\n\t\t\texpectError:  true,\n\t\t\tfqdnTemplate: \"{{.Name\",\n\t\t},\n\t\t{\n\t\t\ttitle:       \"valid empty template\",\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\ttitle:        \"valid template\",\n\t\t\texpectError:  false,\n\t\t\tfqdnTemplate: \"{{.Name}}-{{.Namespace}}.ext-dns.test.com\",\n\t\t},\n\t\t{\n\t\t\ttitle:        \"valid template\",\n\t\t\texpectError:  false,\n\t\t\tfqdnTemplate: \"{{.Name}}-{{.Namespace}}.ext-dns.test.com, {{.Name}}-{{.Namespace}}.ext-dna.test.com\",\n\t\t},\n\t\t{\n\t\t\ttitle:                    \"valid template\",\n\t\t\texpectError:              false,\n\t\t\tfqdnTemplate:             \"{{.Name}}-{{.Namespace}}.ext-dns.test.com, {{.Name}}-{{.Namespace}}.ext-dna.test.com\",\n\t\t\tcombineFQDNAndAnnotation: true,\n\t\t},\n\t\t{\n\t\t\ttitle:            \"non-empty annotation filter label\",\n\t\t\texpectError:      false,\n\t\t\tannotationFilter: \"contour.heptio.com/ingress.class=contour\",\n\t\t},\n\t} {\n\n\t\tt.Run(ti.title, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\tfakeDynamicClient, _ := newDynamicKubernetesClient()\n\n\t\t\t_, err := NewContourHTTPProxySource(\n\t\t\t\tt.Context(),\n\t\t\t\tfakeDynamicClient,\n\t\t\t\t&Config{\n\t\t\t\t\tAnnotationFilter:         ti.annotationFilter,\n\t\t\t\t\tFQDNTemplate:             ti.fqdnTemplate,\n\t\t\t\t\tCombineFQDNAndAnnotation: ti.combineFQDNAndAnnotation,\n\t\t\t\t},\n\t\t\t)\n\t\t\tif ti.expectError {\n\t\t\t\tassert.Error(t, err)\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc testEndpointsFromHTTPProxy(t *testing.T) {\n\tt.Parallel()\n\n\tfor _, ti := range []struct {\n\t\ttitle     string\n\t\thttpProxy fakeHTTPProxy\n\t\texpected  []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\ttitle: \"one rule.host one lb.hostname\",\n\t\t\thttpProxy: fakeHTTPProxy{\n\t\t\t\thost: \"foo.bar\", // Kubernetes requires removal of trailing dot\n\t\t\t\tloadBalancer: fakeLoadBalancerService{\n\t\t\t\t\thostnames: []string{\"lb.com\"}, // Kubernetes omits the trailing dot\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"one rule.host one lb.IP\",\n\t\t\thttpProxy: fakeHTTPProxy{\n\t\t\t\thost: \"foo.bar\",\n\t\t\t\tloadBalancer: fakeLoadBalancerService{\n\t\t\t\t\tips: []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"one rule.host two lb.IP and two lb.Hostname\",\n\t\t\thttpProxy: fakeHTTPProxy{\n\t\t\t\thost: \"foo.bar\",\n\t\t\t\tloadBalancer: fakeLoadBalancerService{\n\t\t\t\t\tips:       []string{\"8.8.8.8\", \"127.0.0.1\"},\n\t\t\t\t\thostnames: []string{\"elb.com\", \"alb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\", \"127.0.0.1\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"elb.com\", \"alb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:     \"no rule.host\",\n\t\t\thttpProxy: fakeHTTPProxy{},\n\t\t\texpected:  []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:     \"no targets\",\n\t\t\thttpProxy: fakeHTTPProxy{},\n\t\t\texpected:  []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle: \"delegate httpproxy\",\n\t\t\thttpProxy: fakeHTTPProxy{\n\t\t\t\tdelegate: true,\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle: \"provider-specific annotation is converted to endpoint property\",\n\t\t\thttpProxy: fakeHTTPProxy{\n\t\t\t\thost: \"foo.bar\",\n\t\t\t\tannotations: map[string]string{\n\t\t\t\t\tannotations.AWSPrefix + \"weight\": \"10\",\n\t\t\t\t},\n\t\t\t\tloadBalancer: fakeLoadBalancerService{\n\t\t\t\t\tips: []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{Name: \"aws/weight\", Value: \"10\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t} {\n\n\t\tt.Run(ti.title, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\tsource, err := newTestHTTPProxySource()\n\t\t\trequire.NoError(t, err)\n\n\t\t\tendpoints := source.endpointsFromHTTPProxy(ti.httpProxy.HTTPProxy())\n\t\t\tvalidateEndpoints(t, endpoints, ti.expected)\n\t\t})\n\t}\n}\n\nfunc testHTTPProxyEndpoints(t *testing.T) {\n\tt.Parallel()\n\n\tnamespace := \"testing\"\n\tfor _, ti := range []struct {\n\t\ttitle                    string\n\t\ttargetNamespace          string\n\t\tannotationFilter         string\n\t\tloadBalancer             fakeLoadBalancerService\n\t\thttpProxyItems           []fakeHTTPProxy\n\t\texpected                 []*endpoint.Endpoint\n\t\texpectError              bool\n\t\tfqdnTemplate             string\n\t\tcombineFQDNAndAnnotation bool\n\t\tignoreHostnameAnnotation bool\n\t}{\n\t\t{\n\t\t\ttitle:           \"no httpproxy\",\n\t\t\ttargetNamespace: \"\",\n\t\t},\n\t\t{\n\t\t\ttitle:           \"two simple httpproxys\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tloadBalancer: fakeLoadBalancerService{\n\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\thostnames: []string{\"lb.com\"},\n\t\t\t},\n\t\t\thttpProxyItems: []fakeHTTPProxy{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\thost:      \"example.org\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake2\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\thost:      \"new.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"new.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"new.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"two simple httpproxys on different namespaces\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tloadBalancer: fakeLoadBalancerService{\n\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\thostnames: []string{\"lb.com\"},\n\t\t\t},\n\t\t\thttpProxyItems: []fakeHTTPProxy{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: \"testing1\",\n\t\t\t\t\thost:      \"example.org\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake2\",\n\t\t\t\t\tnamespace: \"testing2\",\n\t\t\t\t\thost:      \"new.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"new.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"new.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"two simple httpproxys on different namespaces and a target namespace\",\n\t\t\ttargetNamespace: \"testing1\",\n\t\t\tloadBalancer: fakeLoadBalancerService{\n\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\thostnames: []string{\"lb.com\"},\n\t\t\t},\n\t\t\thttpProxyItems: []fakeHTTPProxy{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: \"testing1\",\n\t\t\t\t\thost:      \"example.org\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake2\",\n\t\t\t\t\tnamespace: \"testing2\",\n\t\t\t\t\thost:      \"new.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:            \"valid matching annotation filter expression\",\n\t\t\ttargetNamespace:  \"\",\n\t\t\tannotationFilter: \"contour.heptio.com/ingress.class in (alb, contour)\",\n\t\t\tloadBalancer: fakeLoadBalancerService{\n\t\t\t\tips: []string{\"8.8.8.8\"},\n\t\t\t},\n\t\t\thttpProxyItems: []fakeHTTPProxy{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\t\"contour.heptio.com/ingress.class\": \"contour\",\n\t\t\t\t\t},\n\t\t\t\t\thost: \"example.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:            \"valid non-matching annotation filter expression\",\n\t\t\ttargetNamespace:  \"\",\n\t\t\tannotationFilter: \"contour.heptio.com/ingress.class in (alb, contour)\",\n\t\t\tloadBalancer: fakeLoadBalancerService{\n\t\t\t\tips: []string{\"8.8.8.8\"},\n\t\t\t},\n\t\t\thttpProxyItems: []fakeHTTPProxy{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\t\"contour.heptio.com/ingress.class\": \"tectonic\",\n\t\t\t\t\t},\n\t\t\t\t\thost: \"example.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:            \"invalid annotation filter expression\",\n\t\t\ttargetNamespace:  \"\",\n\t\t\tannotationFilter: \"contour.heptio.com/ingress.name in (a b)\",\n\t\t\tloadBalancer: fakeLoadBalancerService{\n\t\t\t\tips: []string{\"8.8.8.8\"},\n\t\t\t},\n\t\t\thttpProxyItems: []fakeHTTPProxy{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\t\"contour.heptio.com/ingress.class\": \"alb\",\n\t\t\t\t\t},\n\t\t\t\t\thost: \"example.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected:    []*endpoint.Endpoint{},\n\t\t\texpectError: true,\n\t\t},\n\t\t{\n\t\t\ttitle:            \"valid matching annotation filter label\",\n\t\t\ttargetNamespace:  \"\",\n\t\t\tannotationFilter: \"contour.heptio.com/ingress.class=contour\",\n\t\t\tloadBalancer: fakeLoadBalancerService{\n\t\t\t\tips: []string{\"8.8.8.8\"},\n\t\t\t},\n\t\t\thttpProxyItems: []fakeHTTPProxy{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\t\"contour.heptio.com/ingress.class\": \"contour\",\n\t\t\t\t\t},\n\t\t\t\t\thost: \"example.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:            \"valid non-matching annotation filter label\",\n\t\t\ttargetNamespace:  \"\",\n\t\t\tannotationFilter: \"contour.heptio.com/ingress.class=contour\",\n\t\t\tloadBalancer: fakeLoadBalancerService{\n\t\t\t\tips: []string{\"8.8.8.8\"},\n\t\t\t},\n\t\t\thttpProxyItems: []fakeHTTPProxy{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\t\"contour.heptio.com/ingress.class\": \"alb\",\n\t\t\t\t\t},\n\t\t\t\t\thost: \"example.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"our controller type is dns-controller\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tloadBalancer: fakeLoadBalancerService{\n\t\t\t\tips: []string{\"8.8.8.8\"},\n\t\t\t},\n\t\t\thttpProxyItems: []fakeHTTPProxy{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.ControllerKey: annotations.ControllerValue,\n\t\t\t\t\t},\n\t\t\t\t\thost: \"example.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"different controller types are ignored\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tloadBalancer: fakeLoadBalancerService{\n\t\t\t\tips: []string{\"8.8.8.8\"},\n\t\t\t},\n\t\t\thttpProxyItems: []fakeHTTPProxy{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.ControllerKey: \"some-other-tool\",\n\t\t\t\t\t},\n\t\t\t\t\thost: \"example.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"template for httpproxy if host is missing\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tloadBalancer: fakeLoadBalancerService{\n\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\thostnames: []string{\"elb.com\"},\n\t\t\t},\n\t\t\thttpProxyItems: []fakeHTTPProxy{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.ControllerKey: annotations.ControllerValue,\n\t\t\t\t\t},\n\t\t\t\t\thost: \"\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"fake1.ext-dns.test.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"fake1.ext-dns.test.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"elb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{.Name}}.ext-dns.test.com\",\n\t\t},\n\t\t{\n\t\t\ttitle:           \"another controller annotation skipped even with template\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tloadBalancer: fakeLoadBalancerService{\n\t\t\t\tips: []string{\"8.8.8.8\"},\n\t\t\t},\n\t\t\thttpProxyItems: []fakeHTTPProxy{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.ControllerKey: \"other-controller\",\n\t\t\t\t\t},\n\t\t\t\t\thost: \"\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected:     []*endpoint.Endpoint{},\n\t\t\tfqdnTemplate: \"{{.Name}}.ext-dns.test.com\",\n\t\t},\n\t\t{\n\t\t\ttitle:           \"multiple FQDN template hostnames\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tloadBalancer: fakeLoadBalancerService{\n\t\t\t\tips: []string{\"8.8.8.8\"},\n\t\t\t},\n\t\t\thttpProxyItems: []fakeHTTPProxy{\n\t\t\t\t{\n\t\t\t\t\tname:        \"fake1\",\n\t\t\t\t\tnamespace:   namespace,\n\t\t\t\t\tannotations: map[string]string{},\n\t\t\t\t\thost:        \"\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"fake1.ext-dns.test.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"fake1.ext-dna.test.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{.Name}}.ext-dns.test.com, {{.Name}}.ext-dna.test.com\",\n\t\t},\n\t\t{\n\t\t\ttitle:           \"multiple FQDN template hostnames\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tloadBalancer: fakeLoadBalancerService{\n\t\t\t\tips: []string{\"8.8.8.8\"},\n\t\t\t},\n\t\t\thttpProxyItems: []fakeHTTPProxy{\n\t\t\t\t{\n\t\t\t\t\tname:        \"fake1\",\n\t\t\t\t\tnamespace:   namespace,\n\t\t\t\t\tannotations: map[string]string{},\n\t\t\t\t\thost:        \"\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake2\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"httpproxy-target.com\",\n\t\t\t\t\t},\n\t\t\t\t\thost: \"example.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"fake1.ext-dns.test.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"fake1.ext-dna.test.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"httpproxy-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"fake2.ext-dns.test.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"httpproxy-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"fake2.ext-dna.test.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"httpproxy-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate:             \"{{.Name}}.ext-dns.test.com, {{.Name}}.ext-dna.test.com\",\n\t\t\tcombineFQDNAndAnnotation: true,\n\t\t},\n\t\t{\n\t\t\ttitle:           \"httpproxy rules with annotation\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tloadBalancer: fakeLoadBalancerService{\n\t\t\t\tips: []string{\"8.8.8.8\"},\n\t\t\t},\n\t\t\thttpProxyItems: []fakeHTTPProxy{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"httpproxy-target.com\",\n\t\t\t\t\t},\n\t\t\t\t\thost: \"example.org\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake2\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"httpproxy-target.com\",\n\t\t\t\t\t},\n\t\t\t\t\thost: \"example2.org\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake3\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"1.2.3.4\",\n\t\t\t\t\t},\n\t\t\t\t\thost: \"example3.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"httpproxy-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example2.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"httpproxy-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example3.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"httpproxy rules with hostname annotation\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tloadBalancer: fakeLoadBalancerService{\n\t\t\t\tips: []string{\"1.2.3.4\"},\n\t\t\t},\n\t\t\thttpProxyItems: []fakeHTTPProxy{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.HostnameKey: \"dns-through-hostname.com\",\n\t\t\t\t\t},\n\t\t\t\t\thost: \"example.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"dns-through-hostname.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"httpproxy rules with hostname annotation having multiple hostnames\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tloadBalancer: fakeLoadBalancerService{\n\t\t\t\tips: []string{\"1.2.3.4\"},\n\t\t\t},\n\t\t\thttpProxyItems: []fakeHTTPProxy{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.HostnameKey: \"dns-through-hostname.com, another-dns-through-hostname.com\",\n\t\t\t\t\t},\n\t\t\t\t\thost: \"example.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"dns-through-hostname.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"another-dns-through-hostname.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"httpproxy rules with hostname and target annotation\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tloadBalancer: fakeLoadBalancerService{\n\t\t\t\tips: []string{},\n\t\t\t},\n\t\t\thttpProxyItems: []fakeHTTPProxy{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.HostnameKey: \"dns-through-hostname.com\",\n\t\t\t\t\t\tannotations.TargetKey:   \"httpproxy-target.com\",\n\t\t\t\t\t},\n\t\t\t\t\thost: \"example.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"httpproxy-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"dns-through-hostname.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"httpproxy-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"httpproxy rules with annotation and custom TTL\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tloadBalancer: fakeLoadBalancerService{\n\t\t\t\tips: []string{\"8.8.8.8\"},\n\t\t\t},\n\t\t\thttpProxyItems: []fakeHTTPProxy{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"httpproxy-target.com\",\n\t\t\t\t\t\tannotations.TtlKey:    \"6\",\n\t\t\t\t\t},\n\t\t\t\t\thost: \"example.org\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake2\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"httpproxy-target.com\",\n\t\t\t\t\t\tannotations.TtlKey:    \"1\",\n\t\t\t\t\t},\n\t\t\t\t\thost: \"example2.org\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake3\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"httpproxy-target.com\",\n\t\t\t\t\t\tannotations.TtlKey:    \"10s\",\n\t\t\t\t\t},\n\t\t\t\t\thost: \"example3.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"httpproxy-target.com\"},\n\t\t\t\t\tRecordTTL:  endpoint.TTL(6),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example2.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"httpproxy-target.com\"},\n\t\t\t\t\tRecordTTL:  endpoint.TTL(1),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example3.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"httpproxy-target.com\"},\n\t\t\t\t\tRecordTTL:  endpoint.TTL(10),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"template for httpproxy with annotation\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tloadBalancer: fakeLoadBalancerService{\n\t\t\t\tips:       []string{},\n\t\t\t\thostnames: []string{},\n\t\t\t},\n\t\t\thttpProxyItems: []fakeHTTPProxy{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"httpproxy-target.com\",\n\t\t\t\t\t},\n\t\t\t\t\thost: \"\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake2\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"httpproxy-target.com\",\n\t\t\t\t\t},\n\t\t\t\t\thost: \"\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake3\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"1.2.3.4\",\n\t\t\t\t\t},\n\t\t\t\t\thost: \"\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"fake1.ext-dns.test.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"httpproxy-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"fake2.ext-dns.test.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"httpproxy-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"fake3.ext-dns.test.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{.Name}}.ext-dns.test.com\",\n\t\t},\n\t\t{\n\t\t\ttitle:           \"httpproxy with empty annotation\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tloadBalancer: fakeLoadBalancerService{\n\t\t\t\tips:       []string{},\n\t\t\t\thostnames: []string{},\n\t\t\t},\n\t\t\thttpProxyItems: []fakeHTTPProxy{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"\",\n\t\t\t\t\t},\n\t\t\t\t\thost: \"\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected:     []*endpoint.Endpoint{},\n\t\t\tfqdnTemplate: \"{{.Name}}.ext-dns.test.com\",\n\t\t},\n\t\t{\n\t\t\ttitle:           \"ignore hostname annotations\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tloadBalancer: fakeLoadBalancerService{\n\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\thostnames: []string{\"lb.com\"},\n\t\t\t},\n\t\t\thttpProxyItems: []fakeHTTPProxy{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.HostnameKey: \"ignore.me\",\n\t\t\t\t\t},\n\t\t\t\t\thost: \"example.org\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake2\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.HostnameKey: \"ignore.me.too\",\n\t\t\t\t\t},\n\t\t\t\t\thost: \"new.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"new.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"new.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tignoreHostnameAnnotation: true,\n\t\t},\n\t} {\n\n\t\tt.Run(ti.title, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\thttpProxies := make([]*projectcontour.HTTPProxy, 0)\n\t\t\tfor _, item := range ti.httpProxyItems {\n\t\t\t\titem.loadBalancer = ti.loadBalancer\n\t\t\t\thttpProxies = append(httpProxies, item.HTTPProxy())\n\t\t\t}\n\n\t\t\tfakeDynamicClient, scheme := newDynamicKubernetesClient()\n\t\t\tfor _, httpProxy := range httpProxies {\n\t\t\t\tconverted, err := convertHTTPProxyToUnstructured(httpProxy, scheme)\n\t\t\t\trequire.NoError(t, err)\n\t\t\t\t_, err = fakeDynamicClient.Resource(projectcontour.HTTPProxyGVR).Namespace(httpProxy.Namespace).Create(t.Context(), converted, metav1.CreateOptions{})\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\thttpProxySource, err := NewContourHTTPProxySource(\n\t\t\t\tt.Context(),\n\t\t\t\tfakeDynamicClient,\n\t\t\t\t&Config{\n\t\t\t\t\tNamespace:                ti.targetNamespace,\n\t\t\t\t\tAnnotationFilter:         ti.annotationFilter,\n\t\t\t\t\tFQDNTemplate:             ti.fqdnTemplate,\n\t\t\t\t\tCombineFQDNAndAnnotation: ti.combineFQDNAndAnnotation,\n\t\t\t\t\tIgnoreHostnameAnnotation: ti.ignoreHostnameAnnotation,\n\t\t\t\t},\n\t\t\t)\n\t\t\trequire.NoError(t, err)\n\n\t\t\tres, err := httpProxySource.Endpoints(t.Context())\n\t\t\tif ti.expectError {\n\t\t\t\tassert.Error(t, err)\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t}\n\n\t\t\tvalidateEndpoints(t, res, ti.expected)\n\t\t})\n\t}\n}\n\n// httpproxy specific helper functions\nfunc newTestHTTPProxySource() (*httpProxySource, error) {\n\tfakeDynamicClient, _ := newDynamicKubernetesClient()\n\n\tsrc, err := NewContourHTTPProxySource(\n\t\tcontext.TODO(),\n\t\tfakeDynamicClient,\n\t\t&Config{\n\t\t\tFQDNTemplate: \"{{.Name}}\",\n\t\t},\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tirsrc, ok := src.(*httpProxySource)\n\tif !ok {\n\t\treturn nil, errors.New(\"underlying source type was not httpproxy\")\n\t}\n\n\treturn irsrc, nil\n}\n\ntype fakeHTTPProxy struct {\n\tnamespace   string\n\tname        string\n\tannotations map[string]string\n\n\thost         string\n\tdelegate     bool\n\tloadBalancer fakeLoadBalancerService\n}\n\nfunc (ir fakeHTTPProxy) HTTPProxy() *projectcontour.HTTPProxy {\n\tvar spec projectcontour.HTTPProxySpec\n\tif ir.delegate {\n\t\tspec = projectcontour.HTTPProxySpec{}\n\t} else {\n\t\tspec = projectcontour.HTTPProxySpec{\n\t\t\tVirtualHost: &projectcontour.VirtualHost{\n\t\t\t\tFqdn: ir.host,\n\t\t\t},\n\t\t}\n\t}\n\n\tlb := v1.LoadBalancerStatus{\n\t\tIngress: []v1.LoadBalancerIngress{},\n\t}\n\n\tfor _, ip := range ir.loadBalancer.ips {\n\t\tlb.Ingress = append(lb.Ingress, v1.LoadBalancerIngress{\n\t\t\tIP: ip,\n\t\t})\n\t}\n\tfor _, hostname := range ir.loadBalancer.hostnames {\n\t\tlb.Ingress = append(lb.Ingress, v1.LoadBalancerIngress{\n\t\t\tHostname: hostname,\n\t\t})\n\t}\n\n\thttpProxy := &projectcontour.HTTPProxy{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tNamespace:   ir.namespace,\n\t\t\tName:        ir.name,\n\t\t\tAnnotations: ir.annotations,\n\t\t},\n\t\tSpec: spec,\n\t\tStatus: projectcontour.HTTPProxyStatus{\n\t\t\tLoadBalancer: lb,\n\t\t},\n\t}\n\n\treturn httpProxy\n}\n"
  },
  {
    "path": "source/crd.go",
    "content": "/*\nCopyright 2018 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\n\t\"k8s.io/apimachinery/pkg/util/wait\"\n\t\"k8s.io/apimachinery/pkg/watch\"\n\t\"k8s.io/client-go/tools/cache\"\n\n\t\"sigs.k8s.io/external-dns/pkg/events\"\n\t\"sigs.k8s.io/external-dns/source/types\"\n\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\t\"k8s.io/apimachinery/pkg/runtime\"\n\t\"k8s.io/apimachinery/pkg/runtime/schema\"\n\t\"k8s.io/apimachinery/pkg/runtime/serializer\"\n\t\"k8s.io/client-go/kubernetes\"\n\t\"k8s.io/client-go/rest\"\n\t\"k8s.io/client-go/tools/clientcmd\"\n\n\tapiv1alpha1 \"sigs.k8s.io/external-dns/apis/v1alpha1\"\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\n// crdSource is an implementation of Source that provides endpoints by listing\n// specified CRD and fetching Endpoints embedded in Spec.\n//\n// +externaldns:source:name=crd\n// +externaldns:source:category=ExternalDNS\n// +externaldns:source:description=Creates DNS entries from DNSEndpoint CRD resources\n// +externaldns:source:resources=DNSEndpoint.externaldns.k8s.io\n// +externaldns:source:filters=annotation,label\n// +externaldns:source:namespace=all,single\n// +externaldns:source:fqdn-template=false\n// +externaldns:source:events=true\n// +externaldns:source:provider-specific=true\ntype crdSource struct {\n\tcrdClient        rest.Interface\n\tnamespace        string\n\tcrdResource      string\n\tcodec            runtime.ParameterCodec\n\tannotationFilter string\n\tlabelSelector    labels.Selector\n\tinformer         cache.SharedInformer\n}\n\n// NewCRDClientForAPIVersionKind return rest client for the given apiVersion and kind of the CRD\nfunc NewCRDClientForAPIVersionKind(\n\tclient kubernetes.Interface,\n\tcfg *Config,\n) (*rest.RESTClient, *runtime.Scheme, error) {\n\tkubeConfig := cfg.KubeConfig\n\tif kubeConfig == \"\" {\n\t\tif _, err := os.Stat(clientcmd.RecommendedHomeFile); err == nil {\n\t\t\tkubeConfig = clientcmd.RecommendedHomeFile\n\t\t}\n\t}\n\n\t// TODO: GetRestConfig logic is duplicated from store.go, refactor to avoid duplication\n\tconfig, err := clientcmd.BuildConfigFromFlags(cfg.APIServerURL, kubeConfig)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\tgroupVersion, err := schema.ParseGroupVersion(cfg.CRDSourceAPIVersion)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\tapiResourceList, err := client.Discovery().ServerResourcesForGroupVersion(groupVersion.String())\n\tif err != nil {\n\t\treturn nil, nil, fmt.Errorf(\"error listing resources in GroupVersion %q: %w\", groupVersion.String(), err)\n\t}\n\n\tvar crdAPIResource *metav1.APIResource\n\tfor _, apiResource := range apiResourceList.APIResources {\n\t\tif apiResource.Kind == cfg.CRDSourceKind {\n\t\t\tcrdAPIResource = &apiResource\n\t\t\tbreak\n\t\t}\n\t}\n\tif crdAPIResource == nil {\n\t\treturn nil, nil, fmt.Errorf(\"unable to find Resource Kind %q in GroupVersion %q\", cfg.CRDSourceKind, cfg.CRDSourceAPIVersion)\n\t}\n\n\tscheme := runtime.NewScheme()\n\t_ = apiv1alpha1.AddToScheme(scheme)\n\n\tconfig.GroupVersion = &groupVersion\n\tconfig.APIPath = \"/apis\"\n\tconfig.NegotiatedSerializer = serializer.WithoutConversionCodecFactory{CodecFactory: serializer.NewCodecFactory(scheme)}\n\n\tcrdClient, err := rest.UnversionedRESTClientFor(config)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\treturn crdClient, scheme, nil\n}\n\n// NewCRDSource creates a new crdSource with the given config.\nfunc NewCRDSource(\n\tcrdClient rest.Interface,\n\tcfg *Config,\n\tscheme *runtime.Scheme) (Source, error) {\n\tsourceCrd := crdSource{\n\t\tcrdResource:      strings.ToLower(cfg.CRDSourceKind) + \"s\",\n\t\tnamespace:        cfg.Namespace,\n\t\tannotationFilter: cfg.AnnotationFilter,\n\t\tlabelSelector:    cfg.LabelFilter,\n\t\tcrdClient:        crdClient,\n\t\tcodec:            runtime.NewParameterCodec(scheme),\n\t}\n\tif cfg.UpdateEvents {\n\t\t// external-dns already runs its sync-handler periodically (controlled by `--interval` flag) to ensure any\n\t\t// missed or dropped events are handled. specify resync period 0 to avoid unnecessary sync handler invocations.\n\t\tsourceCrd.informer = cache.NewSharedInformer(\n\t\t\t&cache.ListWatch{\n\t\t\t\tListWithContextFunc: func(ctx context.Context, lo metav1.ListOptions) (runtime.Object, error) {\n\t\t\t\t\treturn sourceCrd.List(ctx, &lo)\n\t\t\t\t},\n\t\t\t\tWatchFuncWithContext: func(ctx context.Context, lo metav1.ListOptions) (watch.Interface, error) {\n\t\t\t\t\treturn sourceCrd.watch(ctx, &lo)\n\t\t\t\t},\n\t\t\t},\n\t\t\t&apiv1alpha1.DNSEndpoint{},\n\t\t\t0)\n\t\tgo sourceCrd.informer.Run(wait.NeverStop)\n\t}\n\treturn &sourceCrd, nil\n}\n\nfunc (cs *crdSource) AddEventHandler(_ context.Context, handler func()) {\n\tif cs.informer != nil {\n\t\tlog.Debug(\"Adding event handler for CRD\")\n\t\t// Right now there is no way to remove event handler from informer, see:\n\t\t// https://github.com/kubernetes/kubernetes/issues/79610\n\t\t_, _ = cs.informer.AddEventHandler(\n\t\t\tcache.ResourceEventHandlerFuncs{\n\t\t\t\tAddFunc: func(_ any) {\n\t\t\t\t\thandler()\n\t\t\t\t},\n\t\t\t\tUpdateFunc: func(_ any, _ any) {\n\t\t\t\t\thandler()\n\t\t\t\t},\n\t\t\t\tDeleteFunc: func(_ any) {\n\t\t\t\t\thandler()\n\t\t\t\t},\n\t\t\t},\n\t\t)\n\t}\n}\n\n// Endpoints returns endpoint objects.\nfunc (cs *crdSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) {\n\tendpoints := []*endpoint.Endpoint{}\n\n\tvar (\n\t\tresult *apiv1alpha1.DNSEndpointList\n\t\terr    error\n\t)\n\n\tresult, err = cs.List(ctx, &metav1.ListOptions{LabelSelector: cs.labelSelector.String()})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\titemPtrs := make([]*apiv1alpha1.DNSEndpoint, len(result.Items))\n\tfor i := range result.Items {\n\t\titemPtrs[i] = &result.Items[i]\n\t}\n\n\tfiltered, err := annotations.Filter(itemPtrs, cs.annotationFilter)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor _, dnsEndpoint := range filtered {\n\t\tvar crdEndpoints []*endpoint.Endpoint\n\t\tfor _, ep := range dnsEndpoint.Spec.Endpoints {\n\t\t\tif (ep.RecordType == endpoint.RecordTypeCNAME || ep.RecordType == endpoint.RecordTypeA || ep.RecordType == endpoint.RecordTypeAAAA) && len(ep.Targets) < 1 {\n\t\t\t\tlog.Debugf(\"Endpoint %s with DNSName %s has an empty list of targets, allowing it to pass through for default-targets processing\", dnsEndpoint.Name, ep.DNSName)\n\t\t\t}\n\t\t\tillegalTarget := false\n\t\t\tfor _, target := range ep.Targets {\n\t\t\t\tswitch ep.RecordType {\n\t\t\t\tcase endpoint.RecordTypeTXT, endpoint.RecordTypeMX:\n\t\t\t\t\tcontinue // TXT records allow arbitrary text, skip validation; MX records can have trailing dot but it's not required, skip validation\n\t\t\t\tcase endpoint.RecordTypeCNAME:\n\t\t\t\t\tcontinue // RFC 1035 §5.1: trailing dot denotes an absolute FQDN in zone file notation; both forms are valid\n\t\t\t\t}\n\n\t\t\t\thasDot := strings.HasSuffix(target, \".\")\n\n\t\t\t\tswitch ep.RecordType {\n\t\t\t\tcase endpoint.RecordTypeNAPTR:\n\t\t\t\t\tillegalTarget = !hasDot // Must have trailing dot\n\t\t\t\tdefault:\n\t\t\t\t\tillegalTarget = hasDot // Must NOT have trailing dot\n\t\t\t\t}\n\n\t\t\t\tif illegalTarget {\n\t\t\t\t\tfixed := target + \".\"\n\t\t\t\t\tif ep.RecordType != endpoint.RecordTypeNAPTR {\n\t\t\t\t\t\tfixed = strings.TrimSuffix(target, \".\")\n\t\t\t\t\t}\n\t\t\t\t\tlog.Warnf(\"Endpoint %s/%s with DNSName %s has an illegal target %q for %s record — use %q not %q.\",\n\t\t\t\t\t\tdnsEndpoint.Namespace, dnsEndpoint.Name, ep.DNSName, target, ep.RecordType, fixed, target)\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif illegalTarget {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tep.WithLabel(endpoint.ResourceLabelKey, fmt.Sprintf(\"crd/%s/%s\", dnsEndpoint.Namespace, dnsEndpoint.Name))\n\n\t\t\tcrdEndpoints = append(crdEndpoints, ep)\n\t\t}\n\n\t\tendpoint.AttachRefObject(crdEndpoints, events.NewObjectReference(dnsEndpoint, types.CRD))\n\t\tendpoints = append(endpoints, crdEndpoints...)\n\n\t\tif dnsEndpoint.Status.ObservedGeneration == dnsEndpoint.Generation {\n\t\t\tcontinue\n\t\t}\n\n\t\tdnsEndpoint.Status.ObservedGeneration = dnsEndpoint.Generation\n\t\t// Update the ObservedGeneration\n\t\t_, err = cs.UpdateStatus(ctx, dnsEndpoint)\n\t\tif err != nil {\n\t\t\tlog.Warnf(\"Could not update ObservedGeneration of the CRD: %v\", err)\n\t\t}\n\t}\n\n\treturn MergeEndpoints(endpoints), nil\n}\n\nfunc (cs *crdSource) watch(ctx context.Context, opts *metav1.ListOptions) (watch.Interface, error) {\n\topts.Watch = true\n\treturn cs.crdClient.Get().\n\t\tNamespace(cs.namespace).\n\t\tResource(cs.crdResource).\n\t\tVersionedParams(opts, cs.codec).\n\t\tWatch(ctx)\n}\n\nfunc (cs *crdSource) List(ctx context.Context, opts *metav1.ListOptions) (*apiv1alpha1.DNSEndpointList, error) {\n\tresult := &apiv1alpha1.DNSEndpointList{}\n\treturn result, cs.crdClient.Get().\n\t\tNamespace(cs.namespace).\n\t\tResource(cs.crdResource).\n\t\tVersionedParams(opts, cs.codec).\n\t\tDo(ctx).\n\t\tInto(result)\n}\n\nfunc (cs *crdSource) UpdateStatus(ctx context.Context, dnsEndpoint *apiv1alpha1.DNSEndpoint) (*apiv1alpha1.DNSEndpoint, error) {\n\tresult := &apiv1alpha1.DNSEndpoint{}\n\treturn result, cs.crdClient.Put().\n\t\tNamespace(dnsEndpoint.Namespace).\n\t\tResource(cs.crdResource).\n\t\tName(dnsEndpoint.Name).\n\t\tSubResource(\"status\").\n\t\tBody(dnsEndpoint).\n\t\tDo(ctx).\n\t\tInto(result)\n}\n"
  },
  {
    "path": "source/crd_test.go",
    "content": "/*\nCopyright 2018 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"math/rand\"\n\t\"net/http\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/require\"\n\t\"github.com/stretchr/testify/suite\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\t\"k8s.io/apimachinery/pkg/runtime\"\n\t\"k8s.io/apimachinery/pkg/runtime/schema\"\n\t\"k8s.io/apimachinery/pkg/runtime/serializer\"\n\t\"k8s.io/client-go/rest\"\n\t\"k8s.io/client-go/rest/fake\"\n\t\"k8s.io/client-go/tools/cache\"\n\tcachetesting \"k8s.io/client-go/tools/cache/testing\"\n\n\t\"sigs.k8s.io/external-dns/source/types\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\tk8stypes \"k8s.io/apimachinery/pkg/types\"\n\n\tapiv1alpha1 \"sigs.k8s.io/external-dns/apis/v1alpha1\"\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/internal/testutils\"\n\tlogtest \"sigs.k8s.io/external-dns/internal/testutils/log\"\n)\n\ntype CRDSuite struct {\n\tsuite.Suite\n}\n\nfunc (suite *CRDSuite) SetupTest() {\n}\n\nfunc defaultHeader() http.Header {\n\theader := http.Header{}\n\theader.Set(\"Content-Type\", runtime.ContentTypeJSON)\n\treturn header\n}\n\nfunc objBody(codec runtime.Encoder, obj runtime.Object) io.ReadCloser {\n\treturn io.NopCloser(bytes.NewReader([]byte(runtime.EncodeOrDie(codec, obj))))\n}\n\nfunc fakeRESTClient(endpoints []*endpoint.Endpoint, apiVersion, kind, namespace, name string, annotations map[string]string, labels map[string]string, _ *testing.T) rest.Interface {\n\tgroupVersion, _ := schema.ParseGroupVersion(apiVersion)\n\tscheme := runtime.NewScheme()\n\t_ = apiv1alpha1.AddToScheme(scheme)\n\n\tdnsEndpointList := apiv1alpha1.DNSEndpointList{}\n\tdnsEndpoint := &apiv1alpha1.DNSEndpoint{\n\t\tTypeMeta: metav1.TypeMeta{\n\t\t\tAPIVersion: apiVersion,\n\t\t\tKind:       kind,\n\t\t},\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:        name,\n\t\t\tNamespace:   namespace,\n\t\t\tAnnotations: annotations,\n\t\t\tLabels:      labels,\n\t\t\tGeneration:  1,\n\t\t},\n\t\tSpec: apiv1alpha1.DNSEndpointSpec{\n\t\t\tEndpoints: endpoints,\n\t\t},\n\t}\n\n\tcodecFactory := serializer.WithoutConversionCodecFactory{\n\t\tCodecFactory: serializer.NewCodecFactory(scheme),\n\t}\n\tclient := &fake.RESTClient{\n\t\tGroupVersion:         groupVersion,\n\t\tVersionedAPIPath:     \"/apis/\" + apiVersion,\n\t\tNegotiatedSerializer: codecFactory,\n\t\tClient: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) {\n\t\t\tcodec := codecFactory.LegacyCodec(groupVersion)\n\t\t\tswitch p, m := req.URL.Path, req.Method; {\n\t\t\tcase p == \"/apis/\"+apiVersion+\"/\"+strings.ToLower(kind)+\"s\" && m == http.MethodGet:\n\t\t\t\tfallthrough\n\t\t\tcase p == \"/apis/\"+apiVersion+\"/namespaces/\"+namespace+\"/\"+strings.ToLower(kind)+\"s\" && m == http.MethodGet:\n\t\t\t\tdnsEndpointList.Items = dnsEndpointList.Items[:0]\n\t\t\t\tdnsEndpointList.Items = append(dnsEndpointList.Items, *dnsEndpoint)\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Header: defaultHeader(), Body: objBody(codec, &dnsEndpointList)}, nil\n\t\t\tcase strings.HasPrefix(p, \"/apis/\"+apiVersion+\"/namespaces/\") && strings.HasSuffix(p, strings.ToLower(kind)+\"s\") && m == http.MethodGet:\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Header: defaultHeader(), Body: objBody(codec, &dnsEndpointList)}, nil\n\t\t\tcase p == \"/apis/\"+apiVersion+\"/namespaces/\"+namespace+\"/\"+strings.ToLower(kind)+\"s/\"+name+\"/status\" && m == http.MethodPut:\n\t\t\t\tdecoder := json.NewDecoder(req.Body)\n\n\t\t\t\tvar body apiv1alpha1.DNSEndpoint\n\t\t\t\terr := decoder.Decode(&body)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\t\t\t\tdnsEndpoint.Status.ObservedGeneration = body.Status.ObservedGeneration\n\t\t\t\treturn &http.Response{StatusCode: http.StatusOK, Header: defaultHeader(), Body: objBody(codec, dnsEndpoint)}, nil\n\t\t\tdefault:\n\t\t\t\treturn nil, fmt.Errorf(\"unexpected request: %#v\\n%#v\", req.URL, req)\n\t\t\t}\n\t\t}),\n\t}\n\n\treturn client\n}\n\nfunc TestCRDSource(t *testing.T) {\n\tsuite.Run(t, new(CRDSuite))\n\tt.Run(\"Interface\", testCRDSourceImplementsSource)\n\tt.Run(\"Endpoints\", testCRDSourceEndpoints)\n}\n\n// testCRDSourceImplementsSource tests that crdSource is a valid Source.\nfunc testCRDSourceImplementsSource(t *testing.T) {\n\trequire.Implements(t, (*Source)(nil), new(crdSource))\n}\n\n// testCRDSourceEndpoints tests various scenarios of using CRD source.\nfunc testCRDSourceEndpoints(t *testing.T) {\n\tfor _, ti := range []struct {\n\t\ttitle                string\n\t\tregisteredNamespace  string\n\t\tnamespace            string\n\t\tregisteredAPIVersion string\n\t\tapiVersion           string\n\t\tregisteredKind       string\n\t\tkind                 string\n\t\tendpoints            []*endpoint.Endpoint\n\t\texpectEndpoints      bool\n\t\texpectError          bool\n\t\tannotationFilter     string\n\t\tlabelFilter          string\n\t\tannotations          map[string]string\n\t\tlabels               map[string]string\n\t}{\n\t\t{\n\t\t\ttitle:                \"invalid crd api version\",\n\t\t\tregisteredAPIVersion: \"test.k8s.io/v1alpha1\",\n\t\t\tapiVersion:           \"blah.k8s.io/v1alpha1\",\n\t\t\tregisteredKind:       \"DNSEndpoint\",\n\t\t\tkind:                 \"DNSEndpoint\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"abc.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  180,\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectEndpoints: false,\n\t\t\texpectError:     true,\n\t\t},\n\t\t{\n\t\t\ttitle:                \"invalid crd kind\",\n\t\t\tregisteredAPIVersion: apiv1alpha1.GroupVersion.String(),\n\t\t\tapiVersion:           apiv1alpha1.GroupVersion.String(),\n\t\t\tregisteredKind:       apiv1alpha1.DNSEndpointKind,\n\t\t\tkind:                 \"JustEndpoint\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"abc.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  180,\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectEndpoints: false,\n\t\t\texpectError:     true,\n\t\t},\n\t\t{\n\t\t\ttitle:                \"endpoints within a specific namespace\",\n\t\t\tregisteredAPIVersion: apiv1alpha1.GroupVersion.String(),\n\t\t\tapiVersion:           apiv1alpha1.GroupVersion.String(),\n\t\t\tregisteredKind:       apiv1alpha1.DNSEndpointKind,\n\t\t\tkind:                 apiv1alpha1.DNSEndpointKind,\n\t\t\tnamespace:            \"foo\",\n\t\t\tregisteredNamespace:  \"foo\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"abc.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  180,\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectEndpoints: true,\n\t\t\texpectError:     false,\n\t\t},\n\t\t{\n\t\t\ttitle:                \"no endpoints within a specific namespace\",\n\t\t\tregisteredAPIVersion: apiv1alpha1.GroupVersion.String(),\n\t\t\tapiVersion:           apiv1alpha1.GroupVersion.String(),\n\t\t\tregisteredKind:       apiv1alpha1.DNSEndpointKind,\n\t\t\tkind:                 apiv1alpha1.DNSEndpointKind,\n\t\t\tnamespace:            \"foo\",\n\t\t\tregisteredNamespace:  \"bar\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"abc.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  180,\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectEndpoints: false,\n\t\t\texpectError:     false,\n\t\t},\n\t\t{\n\t\t\ttitle:                \"valid crd with no targets (relies on default-targets)\",\n\t\t\tregisteredAPIVersion: apiv1alpha1.GroupVersion.String(),\n\t\t\tapiVersion:           apiv1alpha1.GroupVersion.String(),\n\t\t\tregisteredKind:       apiv1alpha1.DNSEndpointKind,\n\t\t\tkind:                 apiv1alpha1.DNSEndpointKind,\n\t\t\tnamespace:            \"foo\",\n\t\t\tregisteredNamespace:  \"foo\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"no-targets.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  180,\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectEndpoints: true,\n\t\t\texpectError:     false,\n\t\t},\n\t\t{\n\t\t\ttitle:                \"valid crd gvk with single endpoint\",\n\t\t\tregisteredAPIVersion: apiv1alpha1.GroupVersion.String(),\n\t\t\tapiVersion:           apiv1alpha1.GroupVersion.String(),\n\t\t\tregisteredKind:       apiv1alpha1.DNSEndpointKind,\n\t\t\tkind:                 apiv1alpha1.DNSEndpointKind,\n\t\t\tnamespace:            \"foo\",\n\t\t\tregisteredNamespace:  \"foo\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"abc.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  180,\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectEndpoints: true,\n\t\t\texpectError:     false,\n\t\t},\n\t\t{\n\t\t\ttitle:                \"valid crd gvk with multiple endpoints\",\n\t\t\tregisteredAPIVersion: apiv1alpha1.GroupVersion.String(),\n\t\t\tapiVersion:           apiv1alpha1.GroupVersion.String(),\n\t\t\tregisteredKind:       apiv1alpha1.DNSEndpointKind,\n\t\t\tkind:                 apiv1alpha1.DNSEndpointKind,\n\t\t\tnamespace:            \"foo\",\n\t\t\tregisteredNamespace:  \"foo\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"abc.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  180,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"xyz.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"abc.example.org\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  180,\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectEndpoints: true,\n\t\t\texpectError:     false,\n\t\t},\n\t\t{\n\t\t\ttitle:                \"valid crd gvk with annotation and non matching annotation filter\",\n\t\t\tregisteredAPIVersion: apiv1alpha1.GroupVersion.String(),\n\t\t\tapiVersion:           apiv1alpha1.GroupVersion.String(),\n\t\t\tregisteredKind:       apiv1alpha1.DNSEndpointKind,\n\t\t\tkind:                 apiv1alpha1.DNSEndpointKind,\n\t\t\tnamespace:            \"foo\",\n\t\t\tregisteredNamespace:  \"foo\",\n\t\t\tannotations:          map[string]string{\"test\": \"that\"},\n\t\t\tannotationFilter:     \"test=filter_something_else\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"abc.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  180,\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectEndpoints: false,\n\t\t\texpectError:     false,\n\t\t},\n\t\t{\n\t\t\ttitle:                \"valid crd gvk with annotation and matching annotation filter\",\n\t\t\tregisteredAPIVersion: apiv1alpha1.GroupVersion.String(),\n\t\t\tapiVersion:           apiv1alpha1.GroupVersion.String(),\n\t\t\tregisteredKind:       apiv1alpha1.DNSEndpointKind,\n\t\t\tkind:                 apiv1alpha1.DNSEndpointKind,\n\t\t\tnamespace:            \"foo\",\n\t\t\tregisteredNamespace:  \"foo\",\n\t\t\tannotations:          map[string]string{\"test\": \"that\"},\n\t\t\tannotationFilter:     \"test=that\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"abc.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  180,\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectEndpoints: true,\n\t\t\texpectError:     false,\n\t\t},\n\t\t{\n\t\t\ttitle:                \"valid crd gvk with label and non matching label filter\",\n\t\t\tregisteredAPIVersion: apiv1alpha1.GroupVersion.String(),\n\t\t\tapiVersion:           apiv1alpha1.GroupVersion.String(),\n\t\t\tregisteredKind:       apiv1alpha1.DNSEndpointKind,\n\t\t\tkind:                 apiv1alpha1.DNSEndpointKind,\n\t\t\tnamespace:            \"foo\",\n\t\t\tregisteredNamespace:  \"foo\",\n\t\t\tlabels:               map[string]string{\"test\": \"that\"},\n\t\t\tlabelFilter:          \"test=filter_something_else\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"abc.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  180,\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectEndpoints: false,\n\t\t\texpectError:     false,\n\t\t},\n\t\t{\n\t\t\ttitle:                \"valid crd gvk with label and matching label filter\",\n\t\t\tregisteredAPIVersion: apiv1alpha1.GroupVersion.String(),\n\t\t\tapiVersion:           apiv1alpha1.GroupVersion.String(),\n\t\t\tregisteredKind:       apiv1alpha1.DNSEndpointKind,\n\t\t\tkind:                 apiv1alpha1.DNSEndpointKind,\n\t\t\tnamespace:            \"foo\",\n\t\t\tregisteredNamespace:  \"foo\",\n\t\t\tlabels:               map[string]string{\"test\": \"that\"},\n\t\t\tlabelFilter:          \"test=that\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"abc.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  180,\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectEndpoints: true,\n\t\t\texpectError:     false,\n\t\t},\n\t\t{\n\t\t\ttitle:                \"Create NS record\",\n\t\t\tregisteredAPIVersion: apiv1alpha1.GroupVersion.String(),\n\t\t\tapiVersion:           apiv1alpha1.GroupVersion.String(),\n\t\t\tregisteredKind:       apiv1alpha1.DNSEndpointKind,\n\t\t\tkind:                 apiv1alpha1.DNSEndpointKind,\n\t\t\tnamespace:            \"foo\",\n\t\t\tregisteredNamespace:  \"foo\",\n\t\t\tlabels:               map[string]string{\"test\": \"that\"},\n\t\t\tlabelFilter:          \"test=that\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"abc.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"ns1.k8s.io\", \"ns2.k8s.io\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeNS,\n\t\t\t\t\tRecordTTL:  180,\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectEndpoints: true,\n\t\t\texpectError:     false,\n\t\t},\n\t\t{\n\t\t\ttitle:                \"Create SRV record\",\n\t\t\tregisteredAPIVersion: apiv1alpha1.GroupVersion.String(),\n\t\t\tapiVersion:           apiv1alpha1.GroupVersion.String(),\n\t\t\tregisteredKind:       apiv1alpha1.DNSEndpointKind,\n\t\t\tkind:                 apiv1alpha1.DNSEndpointKind,\n\t\t\tnamespace:            \"foo\",\n\t\t\tregisteredNamespace:  \"foo\",\n\t\t\tlabels:               map[string]string{\"test\": \"that\"},\n\t\t\tlabelFilter:          \"test=that\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"_svc._tcp.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"0 0 80 abc.example.org\", \"0 0 80 def.example.org\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeSRV,\n\t\t\t\t\tRecordTTL:  180,\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectEndpoints: true,\n\t\t\texpectError:     false,\n\t\t},\n\t\t{\n\t\t\ttitle:                \"Create NAPTR record\",\n\t\t\tregisteredAPIVersion: apiv1alpha1.GroupVersion.String(),\n\t\t\tapiVersion:           apiv1alpha1.GroupVersion.String(),\n\t\t\tregisteredKind:       apiv1alpha1.DNSEndpointKind,\n\t\t\tkind:                 apiv1alpha1.DNSEndpointKind,\n\t\t\tnamespace:            \"foo\",\n\t\t\tregisteredNamespace:  \"foo\",\n\t\t\tlabels:               map[string]string{\"test\": \"that\"},\n\t\t\tlabelFilter:          \"test=that\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{`100 10 \"S\" \"SIP+D2U\" \"!^.*$!sip:customer-service@example.org!\" _sip._udp.example.org.`, `102 10 \"S\" \"SIP+D2T\" \"!^.*$!sip:customer-service@example.org!\" _sip._tcp.example.org.`},\n\t\t\t\t\tRecordType: endpoint.RecordTypeNAPTR,\n\t\t\t\t\tRecordTTL:  180,\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectEndpoints: true,\n\t\t\texpectError:     false,\n\t\t},\n\t\t{\n\t\t\ttitle:                \"CNAME target with trailing dot (RFC 1035 §5.1 absolute FQDN) is valid\",\n\t\t\tregisteredAPIVersion: apiv1alpha1.GroupVersion.String(),\n\t\t\tapiVersion:           apiv1alpha1.GroupVersion.String(),\n\t\t\tregisteredKind:       apiv1alpha1.DNSEndpointKind,\n\t\t\tkind:                 apiv1alpha1.DNSEndpointKind,\n\t\t\tnamespace:            \"foo\",\n\t\t\tregisteredNamespace:  \"foo\",\n\t\t\tlabels:               map[string]string{\"test\": \"that\"},\n\t\t\tlabelFilter:          \"test=that\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"foo.example.org.\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  180,\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectEndpoints: true,\n\t\t},\n\t\t{\n\t\t\ttitle:                \"CNAME target without trailing dot (relative name)\",\n\t\t\tregisteredAPIVersion: apiv1alpha1.GroupVersion.String(),\n\t\t\tapiVersion:           apiv1alpha1.GroupVersion.String(),\n\t\t\tregisteredKind:       apiv1alpha1.DNSEndpointKind,\n\t\t\tkind:                 apiv1alpha1.DNSEndpointKind,\n\t\t\tnamespace:            \"foo\",\n\t\t\tregisteredNamespace:  \"foo\",\n\t\t\tlabels:               map[string]string{\"test\": \"that\"},\n\t\t\tlabelFilter:          \"test=that\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"internal.example.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"backend.cluster.local\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  300,\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectEndpoints: true,\n\t\t},\n\t\t{\n\t\t\ttitle:                \"illegal target NAPTR\",\n\t\t\tregisteredAPIVersion: apiv1alpha1.GroupVersion.String(),\n\t\t\tapiVersion:           apiv1alpha1.GroupVersion.String(),\n\t\t\tregisteredKind:       apiv1alpha1.DNSEndpointKind,\n\t\t\tkind:                 apiv1alpha1.DNSEndpointKind,\n\t\t\tnamespace:            \"foo\",\n\t\t\tregisteredNamespace:  \"foo\",\n\t\t\tlabels:               map[string]string{\"test\": \"that\"},\n\t\t\tlabelFilter:          \"test=that\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{`100 10 \"S\" \"SIP+D2U\" \"!^.*$!sip:customer-service@example.org!\" _sip._udp.example.org`, `102 10 \"S\" \"SIP+D2T\" \"!^.*$!sip:customer-service@example.org!\" _sip._tcp.example.org`},\n\t\t\t\t\tRecordType: endpoint.RecordTypeNAPTR,\n\t\t\t\t\tRecordTTL:  180,\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectEndpoints: false,\n\t\t\texpectError:     false,\n\t\t},\n\t\t{\n\t\t\ttitle:                \"valid target TXT\",\n\t\t\tregisteredAPIVersion: apiv1alpha1.GroupVersion.String(),\n\t\t\tapiVersion:           apiv1alpha1.GroupVersion.String(),\n\t\t\tregisteredKind:       apiv1alpha1.DNSEndpointKind,\n\t\t\tkind:                 apiv1alpha1.DNSEndpointKind,\n\t\t\tnamespace:            \"foo\",\n\t\t\tregisteredNamespace:  \"foo\",\n\t\t\tlabels:               map[string]string{\"test\": \"that\"},\n\t\t\tlabelFilter:          \"test=that\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"foo.example.org.\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeTXT,\n\t\t\t\t\tRecordTTL:  180,\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectEndpoints: true,\n\t\t\texpectError:     false,\n\t\t},\n\t\t{\n\t\t\ttitle:                \"illegal target A\",\n\t\t\tregisteredAPIVersion: apiv1alpha1.GroupVersion.String(),\n\t\t\tapiVersion:           apiv1alpha1.GroupVersion.String(),\n\t\t\tregisteredKind:       apiv1alpha1.DNSEndpointKind,\n\t\t\tkind:                 apiv1alpha1.DNSEndpointKind,\n\t\t\tnamespace:            \"foo\",\n\t\t\tregisteredNamespace:  \"foo\",\n\t\t\tlabels:               map[string]string{\"test\": \"that\"},\n\t\t\tlabelFilter:          \"test=that\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4.\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  180,\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectEndpoints: false,\n\t\t\texpectError:     false,\n\t\t},\n\t\t{\n\t\t\ttitle:                \"MX Record allowing trailing dot in target\",\n\t\t\tregisteredAPIVersion: apiv1alpha1.GroupVersion.String(),\n\t\t\tapiVersion:           apiv1alpha1.GroupVersion.String(),\n\t\t\tregisteredKind:       apiv1alpha1.DNSEndpointKind,\n\t\t\tkind:                 apiv1alpha1.DNSEndpointKind,\n\t\t\tnamespace:            \"foo\",\n\t\t\tregisteredNamespace:  \"foo\",\n\t\t\tlabels:               map[string]string{\"test\": \"that\"},\n\t\t\tlabelFilter:          \"test=that\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"example.com.\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeMX,\n\t\t\t\t\tRecordTTL:  180,\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectEndpoints: true,\n\t\t\texpectError:     false,\n\t\t},\n\t\t{\n\t\t\ttitle:                \"MX Record without trailing dot in target\",\n\t\t\tregisteredAPIVersion: apiv1alpha1.GroupVersion.String(),\n\t\t\tapiVersion:           apiv1alpha1.GroupVersion.String(),\n\t\t\tregisteredKind:       apiv1alpha1.DNSEndpointKind,\n\t\t\tkind:                 apiv1alpha1.DNSEndpointKind,\n\t\t\tnamespace:            \"foo\",\n\t\t\tregisteredNamespace:  \"foo\",\n\t\t\tlabels:               map[string]string{\"test\": \"that\"},\n\t\t\tlabelFilter:          \"test=that\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"example.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeMX,\n\t\t\t\t\tRecordTTL:  180,\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectEndpoints: true,\n\t\t\texpectError:     false,\n\t\t},\n\t\t{\n\t\t\ttitle:                \"provider-specific properties are passed through from DNSEndpoint spec\",\n\t\t\tregisteredAPIVersion: apiv1alpha1.GroupVersion.String(),\n\t\t\tapiVersion:           apiv1alpha1.GroupVersion.String(),\n\t\t\tregisteredKind:       apiv1alpha1.DNSEndpointKind,\n\t\t\tkind:                 apiv1alpha1.DNSEndpointKind,\n\t\t\tnamespace:            \"foo\",\n\t\t\tregisteredNamespace:  \"foo\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"subdomain.example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"other.example.org\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  180,\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{Name: \"aws/failover\", Value: \"PRIMARY\"},\n\t\t\t\t\t\t{Name: \"aws/health-check-id\", Value: \"asdf1234-as12-as12-as12-asdf12345678\"},\n\t\t\t\t\t\t{Name: \"aws/evaluate-target-health\", Value: \"true\"},\n\t\t\t\t\t},\n\t\t\t\t\tSetIdentifier: \"some-unique-id\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectEndpoints: true,\n\t\t},\n\t} {\n\t\tt.Run(ti.title, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\trestClient := fakeRESTClient(ti.endpoints, ti.registeredAPIVersion, ti.registeredKind, ti.registeredNamespace, \"test\", ti.annotations, ti.labels, t)\n\t\t\tgroupVersion, err := schema.ParseGroupVersion(ti.apiVersion)\n\t\t\trequire.NoError(t, err)\n\t\t\trequire.NotNil(t, groupVersion)\n\n\t\t\tscheme := runtime.NewScheme()\n\t\t\terr = apiv1alpha1.AddToScheme(scheme)\n\t\t\trequire.NoError(t, err)\n\n\t\t\tlabelSelector, err := labels.Parse(ti.labelFilter)\n\t\t\trequire.NoError(t, err)\n\n\t\t\t// At present, client-go's fake.RESTClient (used by crd_test.go) is known to cause race conditions when used\n\t\t\t// with informers: https://github.com/kubernetes/kubernetes/issues/95372\n\t\t\t// So don't start the informer during testing.\n\t\t\tcs, err := NewCRDSource(restClient, &Config{\n\t\t\t\tNamespace:        ti.namespace,\n\t\t\t\tAnnotationFilter: ti.annotationFilter,\n\t\t\t\tLabelFilter:      labelSelector,\n\t\t\t\tCRDSourceKind:    ti.kind,\n\t\t\t\tUpdateEvents:     false,\n\t\t\t}, scheme)\n\t\t\trequire.NoError(t, err)\n\n\t\t\treceivedEndpoints, err := cs.Endpoints(t.Context())\n\t\t\tif ti.expectError {\n\t\t\t\trequire.Errorf(t, err, \"Received err %v\", err)\n\t\t\t} else {\n\t\t\t\trequire.NoErrorf(t, err, \"Received err %v\", err)\n\t\t\t}\n\n\t\t\tif len(receivedEndpoints) == 0 && !ti.expectEndpoints {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err == nil {\n\t\t\t\tvalidateCRDResource(t, cs, ti.expectError)\n\t\t\t}\n\n\t\t\t// Validate received endpoints against expected endpoints.\n\t\t\tvalidateEndpoints(t, receivedEndpoints, ti.endpoints)\n\n\t\t\tfor _, e := range receivedEndpoints {\n\t\t\t\t// TODO: at the moment not all sources apply ResourceLabelKey\n\t\t\t\trequire.GreaterOrEqual(t, len(e.Labels), 1, \"endpoint must have at least one label\")\n\t\t\t\trequire.Contains(t, e.Labels, endpoint.ResourceLabelKey, \"endpoint must include the ResourceLabelKey label\")\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCRDSourceIllegalTargetWarnings(t *testing.T) {\n\tfor _, ti := range []struct {\n\t\ttitle       string\n\t\tendpoints   []*endpoint.Endpoint\n\t\twantWarning string\n\t}{\n\t\t{\n\t\t\ttitle: \"A record with trailing dot warns with fix suggestion\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4.\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  180,\n\t\t\t\t},\n\t\t\t},\n\t\t\twantWarning: `illegal target \"1.2.3.4.\" for A record — use \"1.2.3.4\" not \"1.2.3.4.\"`,\n\t\t},\n\t\t{\n\t\t\ttitle: \"NAPTR record without trailing dot warns with fix suggestion\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"_sip._udp.example.org\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeNAPTR,\n\t\t\t\t\tRecordTTL:  180,\n\t\t\t\t},\n\t\t\t},\n\t\t\twantWarning: `illegal target \"_sip._udp.example.org\" for NAPTR record — use \"_sip._udp.example.org.\" not \"_sip._udp.example.org\"`,\n\t\t},\n\t\t{\n\t\t\ttitle: \"CNAME with empty targets produces no warning\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  180,\n\t\t\t\t},\n\t\t\t},\n\t\t\twantWarning: ``,\n\t\t},\n\t} {\n\t\tt.Run(ti.title, func(t *testing.T) {\n\t\t\thook := logtest.LogsUnderTestWithLogLevel(log.WarnLevel, t)\n\n\t\t\trestClient := fakeRESTClient(ti.endpoints, apiv1alpha1.GroupVersion.String(), apiv1alpha1.DNSEndpointKind, \"foo\", \"test\", nil, nil, t)\n\n\t\t\tscheme := runtime.NewScheme()\n\t\t\trequire.NoError(t, apiv1alpha1.AddToScheme(scheme))\n\n\t\t\tcs, err := NewCRDSource(restClient, &Config{\n\t\t\t\tNamespace:        \"foo\",\n\t\t\t\tAnnotationFilter: \"\",\n\t\t\t\tLabelFilter:      labels.Everything(),\n\t\t\t\tCRDSourceKind:    apiv1alpha1.DNSEndpointKind,\n\t\t\t\tUpdateEvents:     false,\n\t\t\t}, scheme)\n\t\t\trequire.NoError(t, err)\n\n\t\t\t_, err = cs.Endpoints(t.Context())\n\t\t\trequire.NoError(t, err)\n\n\t\t\tif ti.wantWarning == \"\" {\n\t\t\t\trequire.Empty(t, hook.Entries, \"expected no warnings to be logged\")\n\t\t\t} else {\n\t\t\t\tlogtest.TestHelperLogContainsWithLogLevel(ti.wantWarning, log.WarnLevel, hook, t)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestCRDSource_NoInformer(t *testing.T) {\n\tcs := &crdSource{informer: nil}\n\tcalled := false\n\n\tcs.AddEventHandler(t.Context(), func() { called = true })\n\trequire.False(t, called, \"handler must not be called when informer is nil\")\n}\n\nfunc TestCRDSource_AddEventHandler_Add(t *testing.T) {\n\tctx := t.Context()\n\twatcher, cs := helperCreateWatcherWithInformer(t)\n\n\tvar counter atomic.Int32\n\tcs.AddEventHandler(ctx, func() {\n\t\tcounter.Add(1)\n\t})\n\n\tobj := &unstructured.Unstructured{}\n\tobj.SetName(\"test\")\n\n\twatcher.Add(obj)\n\n\trequire.Eventually(t, func() bool {\n\t\treturn counter.Load() == 1\n\t}, 2*time.Second, 10*time.Millisecond)\n}\n\nfunc TestCRDSource_AddEventHandler_Update(t *testing.T) {\n\tctx := t.Context()\n\twatcher, cs := helperCreateWatcherWithInformer(t)\n\n\tvar counter atomic.Int32\n\tcs.AddEventHandler(ctx, func() {\n\t\tcounter.Add(1)\n\t})\n\n\tobj := unstructured.Unstructured{}\n\tobj.SetName(\"test\")\n\tobj.SetNamespace(\"default\")\n\tobj.SetUID(\"9be5b64e-3ee9-11f0-88ee-1eb95c6fd730\")\n\n\twatcher.Add(&obj)\n\n\trequire.Eventually(t, func() bool {\n\t\treturn len(watcher.Items) == 1\n\t}, 2*time.Second, 10*time.Millisecond)\n\n\tmodified := obj.DeepCopy()\n\tmodified.SetLabels(map[string]string{\"new-label\": \"this\"})\n\twatcher.Modify(modified)\n\n\trequire.Eventually(t, func() bool {\n\t\treturn len(watcher.Items) == 1\n\t}, 2*time.Second, 10*time.Millisecond)\n\n\trequire.Eventually(t, func() bool {\n\t\treturn counter.Load() == 2\n\t}, 2*time.Second, 10*time.Millisecond)\n}\n\nfunc TestCRDSource_AddEventHandler_Delete(t *testing.T) {\n\tctx := t.Context()\n\twatcher, cs := helperCreateWatcherWithInformer(t)\n\n\tvar counter atomic.Int32\n\tcs.AddEventHandler(ctx, func() {\n\t\tcounter.Add(1)\n\t})\n\n\tobj := &unstructured.Unstructured{}\n\tobj.SetName(\"test\")\n\n\twatcher.Delete(obj)\n\n\trequire.Eventually(t, func() bool {\n\t\treturn counter.Load() == 1\n\t}, 2*time.Second, 10*time.Millisecond)\n}\n\nfunc TestCRDSource_Watch(t *testing.T) {\n\tscheme := runtime.NewScheme()\n\terr := apiv1alpha1.AddToScheme(scheme)\n\trequire.NoError(t, err)\n\n\tvar watchCalled bool\n\n\tcodecFactory := serializer.WithoutConversionCodecFactory{\n\t\tCodecFactory: serializer.NewCodecFactory(scheme),\n\t}\n\n\tversionApiPath := fmt.Sprintf(\"/apis/%s\", apiv1alpha1.GroupVersion.String())\n\n\tclient := &fake.RESTClient{\n\t\tGroupVersion:         apiv1alpha1.GroupVersion,\n\t\tVersionedAPIPath:     versionApiPath,\n\t\tNegotiatedSerializer: codecFactory,\n\t\tClient: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) {\n\t\t\tif req.URL.Path == fmt.Sprintf(\"%s/namespaces/test-ns/dnsendpoints\", versionApiPath) &&\n\t\t\t\treq.URL.Query().Get(\"watch\") == \"true\" {\n\t\t\t\twatchCalled = true\n\t\t\t\treturn &http.Response{\n\t\t\t\t\tStatusCode: http.StatusOK,\n\t\t\t\t\tHeader:     make(http.Header),\n\t\t\t\t}, nil\n\t\t\t}\n\t\t\tt.Errorf(\"unexpected request: %v\", req.URL)\n\t\t\treturn nil, fmt.Errorf(\"unexpected request: %v\", req.URL)\n\t\t}),\n\t}\n\n\tcs := &crdSource{\n\t\tcrdClient:   client,\n\t\tnamespace:   \"test-ns\",\n\t\tcrdResource: \"dnsendpoints\",\n\t\tcodec:       runtime.NewParameterCodec(scheme),\n\t}\n\n\topts := &metav1.ListOptions{}\n\n\t_, err = cs.watch(t.Context(), opts)\n\trequire.NoError(t, err)\n\trequire.True(t, watchCalled)\n\trequire.True(t, opts.Watch)\n}\n\nfunc validateCRDResource(t *testing.T, src Source, expectError bool) {\n\tt.Helper()\n\tcs := src.(*crdSource)\n\tresult, err := cs.List(t.Context(), &metav1.ListOptions{})\n\tif expectError {\n\t\trequire.Errorf(t, err, \"Received err %v\", err)\n\t} else {\n\t\trequire.NoErrorf(t, err, \"Received err %v\", err)\n\t}\n\n\tfor _, dnsEndpoint := range result.Items {\n\t\tif dnsEndpoint.Status.ObservedGeneration != dnsEndpoint.Generation {\n\t\t\trequire.Errorf(t, err, \"Unexpected CRD resource result: ObservedGenerations <%v> is not equal to Generation<%v>\", dnsEndpoint.Status.ObservedGeneration, dnsEndpoint.Generation)\n\t\t}\n\t}\n}\n\nfunc TestDNSEndpointsWithSetResourceLabels(t *testing.T) {\n\n\ttypeCounts := map[string]int{\n\t\tendpoint.RecordTypeA:     3,\n\t\tendpoint.RecordTypeCNAME: 2,\n\t\tendpoint.RecordTypeNS:    7,\n\t\tendpoint.RecordTypeNAPTR: 1,\n\t}\n\n\tcrds := generateTestFixtureDNSEndpointsByType(\"test-ns\", typeCounts)\n\n\tfor _, crd := range crds.Items {\n\t\tfor _, ep := range crd.Spec.Endpoints {\n\t\t\trequire.Empty(t, ep.Labels, \"endpoint not have labels set\")\n\t\t\trequire.NotContains(t, ep.Labels, endpoint.ResourceLabelKey, \"endpoint must not include the ResourceLabelKey label\")\n\t\t}\n\t}\n\n\tscheme := runtime.NewScheme()\n\terr := apiv1alpha1.AddToScheme(scheme)\n\trequire.NoError(t, err)\n\n\tcodecFactory := serializer.WithoutConversionCodecFactory{\n\t\tCodecFactory: serializer.NewCodecFactory(scheme),\n\t}\n\n\tclient := &fake.RESTClient{\n\t\tGroupVersion:         apiv1alpha1.GroupVersion,\n\t\tVersionedAPIPath:     fmt.Sprintf(\"/apis/%s\", apiv1alpha1.GroupVersion.String()),\n\t\tNegotiatedSerializer: codecFactory,\n\t\tClient: fake.CreateHTTPClient(func(_ *http.Request) (*http.Response, error) {\n\t\t\treturn &http.Response{\n\t\t\t\tStatusCode: http.StatusOK,\n\t\t\t\tHeader:     make(http.Header),\n\t\t\t\tBody:       objBody(codecFactory.LegacyCodec(apiv1alpha1.GroupVersion), &crds),\n\t\t\t}, nil\n\t\t}),\n\t}\n\n\tcs := &crdSource{\n\t\tcrdClient:     client,\n\t\tnamespace:     \"test-ns\",\n\t\tcrdResource:   \"dnsendpoints\",\n\t\tcodec:         runtime.NewParameterCodec(scheme),\n\t\tlabelSelector: labels.Everything(),\n\t}\n\n\tres, err := cs.Endpoints(t.Context())\n\trequire.NoError(t, err)\n\n\tfor _, ep := range res {\n\t\trequire.Contains(t, ep.Labels, endpoint.ResourceLabelKey)\n\t}\n}\n\nfunc TestProcessEndpoint_CRD_RefObjectExist(t *testing.T) {\n\ttypeCounts := map[string]int{\n\t\tendpoint.RecordTypeA:    2,\n\t\tendpoint.RecordTypeAAAA: 3,\n\t}\n\n\telements := generateTestFixtureDNSEndpointsByType(\"test-ns\", typeCounts)\n\n\tscheme := runtime.NewScheme()\n\terr := apiv1alpha1.AddToScheme(scheme)\n\trequire.NoError(t, err)\n\n\tcodecFactory := serializer.WithoutConversionCodecFactory{\n\t\tCodecFactory: serializer.NewCodecFactory(scheme),\n\t}\n\n\t// TODO: reduce duplication and move to pkg/client/fakes\n\tclient := &fake.RESTClient{\n\t\tGroupVersion:         apiv1alpha1.GroupVersion,\n\t\tVersionedAPIPath:     fmt.Sprintf(\"/apis/%s\", apiv1alpha1.GroupVersion.String()),\n\t\tNegotiatedSerializer: codecFactory,\n\t\tClient: fake.CreateHTTPClient(func(_ *http.Request) (*http.Response, error) {\n\t\t\treturn &http.Response{\n\t\t\t\tStatusCode: http.StatusOK,\n\t\t\t\tHeader:     make(http.Header),\n\t\t\t\tBody:       objBody(codecFactory.LegacyCodec(apiv1alpha1.GroupVersion), &elements),\n\t\t\t}, nil\n\t\t}),\n\t}\n\n\tcs := &crdSource{\n\t\tcrdClient:     client,\n\t\tnamespace:     \"test-ns\",\n\t\tcrdResource:   \"dnsendpoints\",\n\t\tcodec:         runtime.NewParameterCodec(scheme),\n\t\tlabelSelector: labels.Everything(),\n\t}\n\n\tendpoints, err := cs.Endpoints(t.Context())\n\trequire.NoError(t, err)\n\ttestutils.AssertEndpointsHaveRefObject(t, endpoints, types.CRD, len(elements.Items))\n}\n\nfunc helperCreateWatcherWithInformer(t *testing.T) (*cachetesting.FakeControllerSource, crdSource) {\n\tt.Helper()\n\tctx := t.Context()\n\n\twatcher := cachetesting.NewFakeControllerSource()\n\n\tinformer := cache.NewSharedInformer(watcher, &unstructured.Unstructured{}, 0)\n\n\tgo informer.RunWithContext(ctx)\n\n\trequire.Eventually(t, func() bool {\n\t\treturn cache.WaitForCacheSync(ctx.Done(), informer.HasSynced)\n\t}, 2*time.Second, 10*time.Millisecond)\n\n\tcs := &crdSource{\n\t\tinformer: informer,\n\t}\n\n\treturn watcher, *cs\n}\n\n// generateTestFixtureDNSEndpointsByType generates DNSEndpoint CRDs according to the provided counts per RecordType.\nfunc generateTestFixtureDNSEndpointsByType(namespace string, typeCounts map[string]int) apiv1alpha1.DNSEndpointList {\n\tvar result []apiv1alpha1.DNSEndpoint\n\tidx := 0\n\tfor rt, count := range typeCounts {\n\t\tfor range count {\n\t\t\tresult = append(result, apiv1alpha1.DNSEndpoint{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      fmt.Sprintf(\"dnsendpoint-%s-%d\", rt, idx),\n\t\t\t\t\tNamespace: namespace,\n\t\t\t\t\tUID:       k8stypes.UID(fmt.Sprintf(\"uid-%d\", idx)),\n\t\t\t\t},\n\t\t\t\tSpec: apiv1alpha1.DNSEndpointSpec{\n\t\t\t\t\tEndpoints: []*endpoint.Endpoint{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tDNSName:    strings.ToLower(fmt.Sprintf(\"%s-%d.example.com\", rt, idx)),\n\t\t\t\t\t\t\tRecordType: rt,\n\t\t\t\t\t\t\tTargets:    endpoint.Targets{fmt.Sprintf(\"192.0.2.%d\", idx)},\n\t\t\t\t\t\t\tRecordTTL:  300,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t})\n\t\t\tidx++\n\t\t}\n\t}\n\t// Shuffle the result to ensure randomness in the order.\n\trand.New(rand.NewSource(time.Now().UnixNano()))\n\trand.Shuffle(len(result), func(i, j int) {\n\t\tresult[i], result[j] = result[j], result[i]\n\t})\n\n\treturn apiv1alpha1.DNSEndpointList{\n\t\tItems: result,\n\t}\n}\n"
  },
  {
    "path": "source/empty.go",
    "content": "/*\nCopyright 2019 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\n// emptySource is a Source that returns no endpoints.\n//\n// +externaldns:source:name=empty\n// +externaldns:source:category=Testing\n// +externaldns:source:description=Returns no endpoints (used for testing or as a placeholder)\n// +externaldns:source:resources=None\n// +externaldns:source:filters=\n// +externaldns:source:namespace=\n// +externaldns:source:fqdn-template=false\n// +externaldns:source:provider-specific=false\ntype emptySource struct{}\n\nfunc (e *emptySource) AddEventHandler(_ context.Context, _ func()) {\n}\n\n// Endpoints collects endpoints of all nested Sources and returns them in a single slice.\nfunc (e *emptySource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) {\n\treturn []*endpoint.Endpoint{}, nil\n}\n\n// NewEmptySource creates a new emptySource.\nfunc NewEmptySource() Source {\n\treturn &emptySource{}\n}\n"
  },
  {
    "path": "source/empty_test.go",
    "content": "/*\nCopyright 2019 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"testing\"\n)\n\nfunc TestEmptySourceReturnsEmpty(t *testing.T) {\n\te := NewEmptySource()\n\n\tendpoints, err := e.Endpoints(t.Context())\n\tif err != nil {\n\t\tt.Errorf(\"Expected no error but got %s\", err.Error())\n\t}\n\n\tcount := len(endpoints)\n\tif count != 0 {\n\t\tt.Errorf(\"Expected 0 endpoints but got %d\", count)\n\t}\n}\n"
  },
  {
    "path": "source/endpoint_benchmark_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n    http://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\t\"encoding/binary\"\n\t\"fmt\"\n\t\"math/rand/v2\"\n\t\"net\"\n\t\"strconv\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\tcorev1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\tkubeinformers \"k8s.io/client-go/informers\"\n\tcoreinformers \"k8s.io/client-go/informers/core/v1\"\n\t\"k8s.io/client-go/kubernetes/fake\"\n\n\t\"sigs.k8s.io/external-dns/source/informers\"\n\n\tv1alpha3 \"istio.io/api/networking/v1alpha3\"\n\tistiov1a \"istio.io/client-go/pkg/apis/networking/v1\"\n\n\t\"k8s.io/client-go/tools/cache\"\n)\n\nfunc BenchmarkEndpointTargetsFromServicesMedium(b *testing.B) {\n\tsvcInformer, err := svcInformerWithServices(36, 1000)\n\tassert.NoError(b, err)\n\n\tsel := map[string]string{\"app\": \"nginx\", \"env\": \"prod\"}\n\n\tfor b.Loop() {\n\t\ttargets, _ := EndpointTargetsFromServices(svcInformer, \"default\", sel)\n\t\tassert.Equal(b, 36, targets.Len())\n\t}\n}\n\nfunc BenchmarkEndpointTargetsFromServicesMediumIterateOverGateways(b *testing.B) {\n\tsvcInformer, err := svcInformerWithServices(36, 500)\n\tassert.NoError(b, err)\n\n\tgateways := fixturesIstioGatewaySvcWithLabels(15, 70)\n\n\tfor b.Loop() {\n\t\tfor _, gateway := range gateways {\n\t\t\t_, _ = EndpointTargetsFromServices(svcInformer, gateway.Namespace, gateway.Spec.Selector)\n\t\t}\n\t}\n}\n\nfunc BenchmarkEndpointTargetsFromServicesHigh(b *testing.B) {\n\tsvcInformer, err := svcInformerWithServices(36, 40000)\n\tassert.NoError(b, err)\n\tsel := map[string]string{\"app\": \"nginx\", \"env\": \"prod\"}\n\n\tfor b.Loop() {\n\t\ttargets, _ := EndpointTargetsFromServices(svcInformer, \"default\", sel)\n\t\tassert.Equal(b, 36, targets.Len())\n\t}\n}\n\n// This benchmark tests the performance of EndpointTargetsFromServices with a high number of services and gateways.\nfunc BenchmarkEndpointTargetsFromServicesHighIterateOverGateways(b *testing.B) {\n\tsvcInformer, err := svcInformerWithServices(36, 40000)\n\tassert.NoError(b, err)\n\n\tgateways := fixturesIstioGatewaySvcWithLabels(50, 1000)\n\n\tfor b.Loop() {\n\t\tfor _, gateway := range gateways {\n\t\t\t_, _ = EndpointTargetsFromServices(svcInformer, gateway.Namespace, gateway.Spec.Selector)\n\t\t}\n\t}\n}\n\n// helperToPopulateFakeClientWithServices populates a fake Kubernetes client with a specified services.\nfunc svcInformerWithServices(toLookup, underTest int) (coreinformers.ServiceInformer, error) {\n\tclient := fake.NewClientset()\n\tinformerFactory := kubeinformers.NewSharedInformerFactoryWithOptions(client, 0, kubeinformers.WithNamespace(\"default\"))\n\tsvcInformer := informerFactory.Core().V1().Services()\n\tctx := context.Background()\n\n\t_, err := svcInformer.Informer().AddEventHandler(informers.DefaultEventHandler())\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to add event handler: %w\", err)\n\t}\n\n\tservices := fixturesSvcWithLabels(toLookup, underTest)\n\tfor _, svc := range services {\n\t\t_, err := client.CoreV1().Services(svc.Namespace).Create(ctx, svc, metav1.CreateOptions{})\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to create service %s: %w\", svc.Name, err)\n\t\t}\n\t}\n\n\tstopCh := make(chan struct{})\n\tdefer close(stopCh)\n\tinformerFactory.Start(stopCh)\n\tcache.WaitForCacheSync(stopCh, svcInformer.Informer().HasSynced)\n\treturn svcInformer, nil\n}\n\n// fixturesSvcWithLabels creates a list of Services for testing purposes.\n// It generates a specified number of services with static labels and random labels.\n// The first `toLookup` services have specific labels, while the next `underTest` services have random labels.\nfunc fixturesSvcWithLabels(toLookup, underTest int) []*corev1.Service {\n\tvar services []*corev1.Service\n\n\tvar randomLabels = func(input int) map[string]string {\n\t\tif input%3 == 0 {\n\t\t\t// every third service has no labels\n\t\t\treturn map[string]string{}\n\t\t}\n\t\treturn map[string]string{\n\t\t\t\"app\":                                fmt.Sprintf(\"service-%d\", rand.IntN(100)),\n\t\t\tfmt.Sprintf(\"key%d\", rand.IntN(100)): fmt.Sprintf(\"value%d\", rand.IntN(100)),\n\t\t}\n\t}\n\n\tvar randomIPs = func() []string {\n\t\tip := rand.Uint32()\n\t\tbuf := make([]byte, 4)\n\t\tbinary.LittleEndian.PutUint32(buf, ip)\n\t\treturn []string{net.IP(buf).String()}\n\t}\n\n\tvar createService = func(name string, namespace string, selector map[string]string) *corev1.Service {\n\t\treturn &corev1.Service{\n\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\tName:      name,\n\t\t\t\tNamespace: namespace,\n\t\t\t},\n\t\t\tSpec: corev1.ServiceSpec{\n\t\t\t\tSelector:    selector,\n\t\t\t\tExternalIPs: randomIPs(),\n\t\t\t},\n\t\t}\n\t}\n\n\t// services with specific labels\n\tfor i := range toLookup {\n\t\tsvc := createService(\"nginx-svc-\"+strconv.Itoa(i), \"default\", map[string]string{\"app\": \"nginx\", \"env\": \"prod\"})\n\t\tservices = append(services, svc)\n\t}\n\n\t// services with random labels\n\tfor i := range underTest {\n\t\tsvc := createService(\"random-svc-\"+strconv.Itoa(i), \"default\", randomLabels(i))\n\t\tservices = append(services, svc)\n\t}\n\n\t// Shuffle the services to ensure randomness\n\tfor range 3 {\n\t\trand.Shuffle(len(services), func(i, j int) {\n\t\t\tservices[i], services[j] = services[j], services[i]\n\t\t})\n\t}\n\n\treturn services\n}\n\n// fixturesIstioGatewaySvcWithLabels creates a list of Services for testing purposes.\n// It generates a specified number of gateways with static labels and random labels.\n// The first `toLookup` services have specific labels, while the next `underTest` services have random labels.\nfunc fixturesIstioGatewaySvcWithLabels(toLookup, underTest int) []*istiov1a.Gateway {\n\tvar result []*istiov1a.Gateway\n\n\tvar randomLabels = func(input int) map[string]string {\n\t\tif input%3 == 0 {\n\t\t\t// every third service has no labels\n\t\t\treturn map[string]string{}\n\t\t}\n\t\treturn map[string]string{\n\t\t\t\"app\":                                fmt.Sprintf(\"service-%d\", rand.IntN(100)),\n\t\t\tfmt.Sprintf(\"key%d\", rand.IntN(100)): fmt.Sprintf(\"value%d\", rand.IntN(100)),\n\t\t}\n\t}\n\n\tvar createGateway = func(name string, namespace string, selector map[string]string) *istiov1a.Gateway {\n\t\treturn &istiov1a.Gateway{\n\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\tName:      name,\n\t\t\t\tNamespace: namespace,\n\t\t\t},\n\t\t\tSpec: v1alpha3.Gateway{\n\t\t\t\tSelector: selector,\n\t\t\t\tServers: []*v1alpha3.Server{\n\t\t\t\t\t{\n\t\t\t\t\t\tPort:  &v1alpha3.Port{},\n\t\t\t\t\t\tHosts: []string{\"*\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t}\n\t// services with specific labels\n\tfor i := range toLookup {\n\t\tsvc := createGateway(\"istio-gw-\"+strconv.Itoa(i), \"default\", map[string]string{\"app\": \"nginx\", \"env\": \"prod\"})\n\t\tresult = append(result, svc)\n\t}\n\n\t// services with random labels\n\tfor i := range underTest {\n\t\tsvc := createGateway(\"istio-random-svc-\"+strconv.Itoa(i), \"default\", randomLabels(i))\n\t\tresult = append(result, svc)\n\t}\n\n\t// Shuffle the services to ensure randomness\n\tfor range 3 {\n\t\trand.Shuffle(len(result), func(i, j int) {\n\t\t\tresult[i], result[j] = result[j], result[i]\n\t\t})\n\t}\n\n\treturn result\n}\n"
  },
  {
    "path": "source/endpoints.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n    http://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"fmt\"\n\n\t\"k8s.io/apimachinery/pkg/labels\"\n\tcoreinformers \"k8s.io/client-go/informers/core/v1\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\n// EndpointTargetsFromServices retrieves endpoint targets from services in a given namespace\n// that match the specified selector. It returns external IPs or load balancer addresses.\n//\n// TODO: add support for service.Spec.Ports (type NodePort) and service.Spec.ClusterIPs (type ClusterIP)\nfunc EndpointTargetsFromServices(svcInformer coreinformers.ServiceInformer, namespace string, selector map[string]string) (endpoint.Targets, error) {\n\ttargets := endpoint.Targets{}\n\n\tservices, err := svcInformer.Lister().Services(namespace).List(labels.Everything())\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to list labels for services in namespace %q: %w\", namespace, err)\n\t}\n\n\tfor _, service := range services {\n\t\tif !MatchesServiceSelector(selector, service.Spec.Selector) {\n\t\t\tcontinue\n\t\t}\n\n\t\tif len(service.Spec.ExternalIPs) > 0 {\n\t\t\ttargets = append(targets, service.Spec.ExternalIPs...)\n\t\t\tcontinue\n\t\t}\n\n\t\tfor _, lb := range service.Status.LoadBalancer.Ingress {\n\t\t\tif lb.IP != \"\" {\n\t\t\t\ttargets = append(targets, lb.IP)\n\t\t\t} else if lb.Hostname != \"\" {\n\t\t\t\ttargets = append(targets, lb.Hostname)\n\t\t\t}\n\t\t}\n\t}\n\treturn endpoint.NewTargets(targets...), nil\n}\n"
  },
  {
    "path": "source/endpoints_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n    http://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\tcorev1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\tkubeinformers \"k8s.io/client-go/informers\"\n\t\"k8s.io/client-go/kubernetes/fake\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\nfunc TestEndpointTargetsFromServices(t *testing.T) {\n\ttests := []struct {\n\t\tname      string\n\t\tservices  []*corev1.Service\n\t\tnamespace string\n\t\tselector  map[string]string\n\t\texpected  endpoint.Targets\n\t\twantErr   bool\n\t}{\n\t\t{\n\t\t\tname:      \"no services\",\n\t\t\tservices:  []*corev1.Service{},\n\t\t\tnamespace: \"default\",\n\t\t\tselector:  map[string]string{\"app\": \"nginx\"},\n\t\t\texpected:  endpoint.Targets{},\n\t\t},\n\t\t{\n\t\t\tname: \"matching service with external IPs\",\n\t\t\tservices: []*corev1.Service{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"svc1\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.ServiceSpec{\n\t\t\t\t\t\tSelector:    map[string]string{\"app\": \"nginx\"},\n\t\t\t\t\t\tExternalIPs: []string{\"192.0.2.1\", \"158.123.32.23\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnamespace: \"default\",\n\t\t\tselector:  map[string]string{\"app\": \"nginx\"},\n\t\t\texpected:  endpoint.Targets{\"158.123.32.23\", \"192.0.2.1\"},\n\t\t},\n\t\t{\n\t\t\tname: \"matching service with duplicate external IPs\",\n\t\t\tservices: []*corev1.Service{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"svc1\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.ServiceSpec{\n\t\t\t\t\t\tSelector:    map[string]string{\"app\": \"nginx\"},\n\t\t\t\t\t\tExternalIPs: []string{\"192.0.2.1\", \"192.0.2.1\", \"158.123.32.23\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnamespace: \"default\",\n\t\t\tselector:  map[string]string{\"app\": \"nginx\"},\n\t\t\texpected:  endpoint.Targets{\"158.123.32.23\", \"192.0.2.1\"},\n\t\t},\n\t\t{\n\t\t\tname: \"no matching service as service without selector\",\n\t\t\tservices: []*corev1.Service{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"svc1\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.ServiceSpec{\n\t\t\t\t\t\tExternalIPs: []string{\"192.0.2.1\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnamespace: \"default\",\n\t\t\tselector:  map[string]string{\"app\": \"nginx\"},\n\t\t\texpected:  endpoint.Targets{},\n\t\t},\n\t\t{\n\t\t\tname: \"matching service with load balancer IP\",\n\t\t\tservices: []*corev1.Service{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"svc2\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.ServiceSpec{\n\t\t\t\t\t\tSelector: map[string]string{\"app\": \"nginx\"},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.ServiceStatus{\n\t\t\t\t\t\tLoadBalancer: corev1.LoadBalancerStatus{\n\t\t\t\t\t\t\tIngress: []corev1.LoadBalancerIngress{\n\t\t\t\t\t\t\t\t{IP: \"192.0.2.2\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnamespace: \"default\",\n\t\t\tselector:  map[string]string{\"app\": \"nginx\"},\n\t\t\texpected:  endpoint.Targets{\"192.0.2.2\"},\n\t\t},\n\t\t{\n\t\t\tname: \"matching service with load balancer hostname\",\n\t\t\tservices: []*corev1.Service{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"svc3\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.ServiceSpec{\n\t\t\t\t\t\tSelector: map[string]string{\"app\": \"nginx\"},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.ServiceStatus{\n\t\t\t\t\t\tLoadBalancer: corev1.LoadBalancerStatus{\n\t\t\t\t\t\t\tIngress: []corev1.LoadBalancerIngress{\n\t\t\t\t\t\t\t\t{Hostname: \"lb.example.com\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnamespace: \"default\",\n\t\t\tselector:  map[string]string{\"app\": \"nginx\"},\n\t\t\texpected:  endpoint.Targets{\"lb.example.com\"},\n\t\t},\n\t\t{\n\t\t\tname: \"no matching services\",\n\t\t\tservices: []*corev1.Service{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"svc4\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.ServiceSpec{\n\t\t\t\t\t\tSelector: map[string]string{\"app\": \"apache\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnamespace: \"default\",\n\t\t\tselector:  map[string]string{\"app\": \"nginx\"},\n\t\t\texpected:  endpoint.Targets{},\n\t\t},\n\t\t{\n\t\t\tname: \"multiple selectors\",\n\t\t\tservices: []*corev1.Service{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"fake\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.ServiceSpec{\n\t\t\t\t\t\tSelector:    map[string]string{\"app\": \"apache\", \"version\": \"v1\"},\n\t\t\t\t\t\tExternalIPs: []string{\"158.123.32.23\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnamespace: \"default\",\n\t\t\tselector:  map[string]string{\"version\": \"v1\"},\n\t\t\texpected:  endpoint.Targets{\"158.123.32.23\"},\n\t\t},\n\t\t{\n\t\t\tname: \"complex selectors\",\n\t\t\tservices: []*corev1.Service{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"fake\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.ServiceSpec{\n\t\t\t\t\t\tSelector: map[string]string{\n\t\t\t\t\t\t\t\"app\":     \"demo\",\n\t\t\t\t\t\t\t\"env\":     \"prod\",\n\t\t\t\t\t\t\t\"team\":    \"devops\",\n\t\t\t\t\t\t\t\"version\": \"v1\",\n\t\t\t\t\t\t\t\"release\": \"stable\",\n\t\t\t\t\t\t\t\"track\":   \"daily\",\n\t\t\t\t\t\t\t\"tier\":    \"backend\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\tExternalIPs: []string{\"158.123.32.23\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnamespace: \"default\",\n\t\t\tselector: map[string]string{\n\t\t\t\t\"version\": \"v1\",\n\t\t\t\t\"release\": \"stable\",\n\t\t\t\t\"tier\":    \"backend\",\n\t\t\t\t\"app\":     \"demo\",\n\t\t\t},\n\t\t\texpected: endpoint.Targets{\"158.123.32.23\"},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tclient := fake.NewClientset()\n\t\t\tinformerFactory := kubeinformers.NewSharedInformerFactoryWithOptions(client, 0,\n\t\t\t\tkubeinformers.WithNamespace(tt.namespace))\n\t\t\tserviceInformer := informerFactory.Core().V1().Services()\n\n\t\t\tfor _, svc := range tt.services {\n\t\t\t\t_, err := client.CoreV1().Services(tt.namespace).Create(t.Context(), svc, metav1.CreateOptions{})\n\t\t\t\tassert.NoError(t, err)\n\n\t\t\t\terr = serviceInformer.Informer().GetIndexer().Add(svc)\n\t\t\t\tassert.NoError(t, err)\n\t\t\t}\n\n\t\t\tresult, err := EndpointTargetsFromServices(serviceInformer, tt.namespace, tt.selector)\n\t\t\tif tt.wantErr {\n\t\t\t\tassert.Error(t, err)\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t\tassert.Equal(t, tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestEndpointTargetsFromServicesWithFixtures(t *testing.T) {\n\tsvcInformer, err := svcInformerWithServices(2, 9)\n\tassert.NoError(t, err)\n\n\tsel := map[string]string{\"app\": \"nginx\", \"env\": \"prod\"}\n\n\ttargets, err := EndpointTargetsFromServices(svcInformer, \"default\", sel)\n\tassert.NoError(t, err)\n\tassert.Equal(t, 2, targets.Len())\n}\n"
  },
  {
    "path": "source/f5_transportserver.go",
    "content": "/*\nCopyright 2022 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\n\tf5 \"github.com/F5Networks/k8s-bigip-ctlr/v2/config/apis/cis/v1\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\t\"k8s.io/apimachinery/pkg/runtime\"\n\t\"k8s.io/apimachinery/pkg/runtime/schema\"\n\t\"k8s.io/client-go/dynamic\"\n\t\"k8s.io/client-go/dynamic/dynamicinformer\"\n\tkubeinformers \"k8s.io/client-go/informers\"\n\t\"k8s.io/client-go/kubernetes\"\n\t\"k8s.io/client-go/kubernetes/scheme\"\n\n\t\"sigs.k8s.io/external-dns/source/informers\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n)\n\nvar f5TransportServerGVR = schema.GroupVersionResource{\n\tGroup:    \"cis.f5.com\",\n\tVersion:  \"v1\",\n\tResource: \"transportservers\",\n}\n\n// transportServerSource is an implementation of Source for F5 TransportServer objects.\n//\n// +externaldns:source:name=f5-transportserver\n// +externaldns:source:category=Load Balancers\n// +externaldns:source:description=Creates DNS entries from F5 TransportServer resources\n// +externaldns:source:resources=TransportServer.cis.f5.com\n// +externaldns:source:filters=annotation\n// +externaldns:source:namespace=all,single\n// +externaldns:source:fqdn-template=false\n// +externaldns:source:provider-specific=false\ntype f5TransportServerSource struct {\n\tdynamicKubeClient       dynamic.Interface\n\ttransportServerInformer kubeinformers.GenericInformer\n\tkubeClient              kubernetes.Interface\n\tannotationFilter        string\n\tnamespace               string\n\tunstructuredConverter   *unstructuredConverter\n}\n\nfunc NewF5TransportServerSource(\n\tctx context.Context,\n\tdynamicKubeClient dynamic.Interface,\n\tkubeClient kubernetes.Interface,\n\tcfg *Config,\n) (Source, error) {\n\tinformerFactory := dynamicinformer.NewFilteredDynamicSharedInformerFactory(dynamicKubeClient, 0, cfg.Namespace, nil)\n\ttransportServerInformer := informerFactory.ForResource(f5TransportServerGVR)\n\n\t_, _ = transportServerInformer.Informer().AddEventHandler(informers.DefaultEventHandler())\n\n\tinformerFactory.Start(ctx.Done())\n\n\t// wait for the local cache to be populated.\n\tif err := informers.WaitForDynamicCacheSync(ctx, informerFactory); err != nil {\n\t\treturn nil, err\n\t}\n\n\tuc, err := newTSUnstructuredConverter()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to setup unstructured converter: %w\", err)\n\t}\n\n\treturn &f5TransportServerSource{\n\t\tdynamicKubeClient:       dynamicKubeClient,\n\t\ttransportServerInformer: transportServerInformer,\n\t\tkubeClient:              kubeClient,\n\t\tnamespace:               cfg.Namespace,\n\t\tannotationFilter:        cfg.AnnotationFilter,\n\t\tunstructuredConverter:   uc,\n\t}, nil\n}\n\n// Endpoints returns endpoint objects for each host-target combination that should be processed.\n// Retrieves all TransportServers in the source's namespace(s).\nfunc (ts *f5TransportServerSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) {\n\ttransportServerObjects, err := ts.transportServerInformer.Lister().ByNamespace(ts.namespace).List(labels.Everything())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar transportServers []*f5.TransportServer\n\tfor _, tsObj := range transportServerObjects {\n\t\tunstructuredHost, ok := tsObj.(*unstructured.Unstructured)\n\t\tif !ok {\n\t\t\treturn nil, errors.New(\"could not convert\")\n\t\t}\n\n\t\ttransportServer := &f5.TransportServer{}\n\t\terr := ts.unstructuredConverter.scheme.Convert(unstructuredHost, transportServer, nil)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\ttransportServers = append(transportServers, transportServer)\n\t}\n\n\ttransportServers, err = annotations.Filter(transportServers, ts.annotationFilter)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to filter TransportServers: %w\", err)\n\t}\n\n\tendpoints := ts.endpointsFromTransportServers(transportServers)\n\n\treturn MergeEndpoints(endpoints), nil\n}\n\nfunc (ts *f5TransportServerSource) AddEventHandler(_ context.Context, handler func()) {\n\tlog.Debug(\"Adding event handler for TransportServer\")\n\n\t_, _ = ts.transportServerInformer.Informer().AddEventHandler(eventHandlerFunc(handler))\n}\n\n// endpointsFromTransportServers extracts the endpoints from a slice of TransportServers\nfunc (ts *f5TransportServerSource) endpointsFromTransportServers(transportServers []*f5.TransportServer) []*endpoint.Endpoint {\n\tvar endpoints []*endpoint.Endpoint\n\n\tfor _, transportServer := range transportServers {\n\t\tif !hasValidTransportServerIP(transportServer) {\n\t\t\tlog.Warnf(\"F5 TransportServer %s/%s is missing a valid IP address, skipping endpoint creation.\",\n\t\t\t\ttransportServer.Namespace, transportServer.Name)\n\t\t\tcontinue\n\t\t}\n\n\t\tresource := fmt.Sprintf(\"f5-transportserver/%s/%s\", transportServer.Namespace, transportServer.Name)\n\n\t\tttl := annotations.TTLFromAnnotations(transportServer.Annotations, resource)\n\n\t\ttargets := annotations.TargetsFromTargetAnnotation(transportServer.Annotations)\n\t\tif len(targets) == 0 && transportServer.Spec.VirtualServerAddress != \"\" {\n\t\t\ttargets = append(targets, transportServer.Spec.VirtualServerAddress)\n\t\t}\n\t\tif len(targets) == 0 && transportServer.Status.VSAddress != \"\" {\n\t\t\ttargets = append(targets, transportServer.Status.VSAddress)\n\t\t}\n\n\t\tendpoints = append(endpoints, endpoint.EndpointsForHostname(transportServer.Spec.Host, targets, ttl, nil, \"\", resource)...)\n\t}\n\n\treturn endpoints\n}\n\n// newUnstructuredConverter returns a new unstructuredConverter initialized\nfunc newTSUnstructuredConverter() (*unstructuredConverter, error) {\n\tuc := &unstructuredConverter{\n\t\tscheme: runtime.NewScheme(),\n\t}\n\n\t// Add the core types we need\n\tuc.scheme.AddKnownTypes(f5TransportServerGVR.GroupVersion(), &f5.TransportServer{}, &f5.TransportServerList{})\n\tif err := scheme.AddToScheme(uc.scheme); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn uc, nil\n}\n\nfunc hasValidTransportServerIP(vs *f5.TransportServer) bool {\n\tnormalizedAddress := strings.ToLower(vs.Status.VSAddress)\n\treturn normalizedAddress != \"none\" && normalizedAddress != \"\"\n}\n"
  },
  {
    "path": "source/f5_transportserver_test.go",
    "content": "/*\nCopyright 2022 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured\"\n\t\"k8s.io/apimachinery/pkg/runtime\"\n\tfakeDynamic \"k8s.io/client-go/dynamic/fake\"\n\tfakeKube \"k8s.io/client-go/kubernetes/fake\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n\n\tf5 \"github.com/F5Networks/k8s-bigip-ctlr/v2/config/apis/cis/v1\"\n)\n\nconst defaultF5TransportServerNamespace = \"transportserver\"\n\nfunc TestF5TransportServerEndpoints(t *testing.T) {\n\tt.Parallel()\n\n\ttests := []struct {\n\t\tname             string\n\t\tannotationFilter string\n\t\ttransportServer  f5.TransportServer\n\t\texpected         []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\tname:             \"F5 TransportServer with target annotation\",\n\t\t\tannotationFilter: \"\",\n\t\t\ttransportServer: f5.TransportServer{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: f5TransportServerGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"TransportServer\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test-vs\",\n\t\t\t\t\tNamespace: defaultF5TransportServerNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"192.168.1.150\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: f5.TransportServerSpec{\n\t\t\t\t\tHost:                 \"www.example.com\",\n\t\t\t\t\tVirtualServerAddress: \"192.168.1.100\",\n\t\t\t\t},\n\t\t\t\tStatus: f5.CustomResourceStatus{\n\t\t\t\t\tVSAddress: \"192.168.1.200\",\n\t\t\t\t\tStatus:    \"OK\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"www.example.com\",\n\t\t\t\t\tTargets:    []string{\"192.168.1.150\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"f5-transportserver/transportserver/test-vs\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:             \"F5 TransportServer with host and VirtualServerAddress set\",\n\t\t\tannotationFilter: \"\",\n\t\t\ttransportServer: f5.TransportServer{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: f5TransportServerGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"TransportServer\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test-vs\",\n\t\t\t\t\tNamespace: defaultF5TransportServerNamespace,\n\t\t\t\t},\n\t\t\t\tSpec: f5.TransportServerSpec{\n\t\t\t\t\tHost:                 \"www.example.com\",\n\t\t\t\t\tVirtualServerAddress: \"192.168.1.100\",\n\t\t\t\t},\n\t\t\t\tStatus: f5.CustomResourceStatus{\n\t\t\t\t\tVSAddress: \"192.168.1.200\",\n\t\t\t\t\tStatus:    \"OK\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"www.example.com\",\n\t\t\t\t\tTargets:    []string{\"192.168.1.100\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"f5-transportserver/transportserver/test-vs\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:             \"F5 TransportServer with host set and IP address from the status field\",\n\t\t\tannotationFilter: \"\",\n\t\t\ttransportServer: f5.TransportServer{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: f5TransportServerGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"TransportServer\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test-vs\",\n\t\t\t\t\tNamespace: defaultF5TransportServerNamespace,\n\t\t\t\t},\n\t\t\t\tSpec: f5.TransportServerSpec{\n\t\t\t\t\tHost: \"www.example.com\",\n\t\t\t\t},\n\t\t\t\tStatus: f5.CustomResourceStatus{\n\t\t\t\t\tVSAddress: \"192.168.1.100\",\n\t\t\t\t\tStatus:    \"OK\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"www.example.com\",\n\t\t\t\t\tTargets:    []string{\"192.168.1.100\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"f5-transportserver/transportserver/test-vs\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:             \"F5 TransportServer with no IP address set\",\n\t\t\tannotationFilter: \"\",\n\t\t\ttransportServer: f5.TransportServer{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: f5TransportServerGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"TransportServer\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test-vs\",\n\t\t\t\t\tNamespace: defaultF5TransportServerNamespace,\n\t\t\t\t},\n\t\t\t\tSpec: f5.TransportServerSpec{\n\t\t\t\t\tHost: \"www.example.com\",\n\t\t\t\t},\n\t\t\t\tStatus: f5.CustomResourceStatus{\n\t\t\t\t\tVSAddress: \"\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname:             \"F5 TransportServer with matching annotation filter\",\n\t\t\tannotationFilter: \"foo=bar\",\n\t\t\ttransportServer: f5.TransportServer{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: f5TransportServerGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"TransportServer\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test-vs\",\n\t\t\t\t\tNamespace: defaultF5TransportServerNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"foo\": \"bar\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: f5.TransportServerSpec{\n\t\t\t\t\tHost:                 \"www.example.com\",\n\t\t\t\t\tVirtualServerAddress: \"192.168.1.100\",\n\t\t\t\t},\n\t\t\t\tStatus: f5.CustomResourceStatus{\n\t\t\t\t\tVSAddress: \"192.168.1.100\",\n\t\t\t\t\tStatus:    \"OK\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"www.example.com\",\n\t\t\t\t\tTargets:    []string{\"192.168.1.100\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"f5-transportserver/transportserver/test-vs\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:             \"F5 TransportServer with non-matching annotation filter\",\n\t\t\tannotationFilter: \"foo=bar\",\n\t\t\ttransportServer: f5.TransportServer{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: f5TransportServerGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"TransportServer\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test-vs\",\n\t\t\t\t\tNamespace: defaultF5TransportServerNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"bar\": \"foo\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: f5.TransportServerSpec{\n\t\t\t\t\tHost:                 \"www.example.com\",\n\t\t\t\t\tVirtualServerAddress: \"192.168.1.100\",\n\t\t\t\t},\n\t\t\t\tStatus: f5.CustomResourceStatus{\n\t\t\t\t\tVSAddress: \"192.168.1.100\",\n\t\t\t\t\tStatus:    \"OK\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"F5 TransportServer TTL annotation\",\n\t\t\ttransportServer: f5.TransportServer{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: f5TransportServerGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"TransportServer\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test-vs\",\n\t\t\t\t\tNamespace: defaultF5TransportServerNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/ttl\": \"600\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: f5.TransportServerSpec{\n\t\t\t\t\tHost:                 \"www.example.com\",\n\t\t\t\t\tVirtualServerAddress: \"192.168.1.100\",\n\t\t\t\t},\n\t\t\t\tStatus: f5.CustomResourceStatus{\n\t\t\t\t\tVSAddress: \"192.168.1.100\",\n\t\t\t\t\tStatus:    \"OK\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"www.example.com\",\n\t\t\t\t\tTargets:    []string{\"192.168.1.100\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  600,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"f5-transportserver/transportserver/test-vs\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"F5 TransportServer with error status but valid IP\",\n\t\t\ttransportServer: f5.TransportServer{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: f5TransportServerGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"TransportServer\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test-ts\",\n\t\t\t\t\tNamespace: defaultF5TransportServerNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/ttl\": \"600\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: f5.TransportServerSpec{\n\t\t\t\t\tHost:                 \"www.example.com\",\n\t\t\t\t\tVirtualServerAddress: \"192.168.1.100\",\n\t\t\t\t},\n\t\t\t\tStatus: f5.CustomResourceStatus{\n\t\t\t\t\tVSAddress: \"192.168.1.100\",\n\t\t\t\t\tStatus:    \"ERROR\",\n\t\t\t\t\tError:     \"Some error status message\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"www.example.com\",\n\t\t\t\t\tTargets:    []string{\"192.168.1.100\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  600,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"f5-transportserver/transportserver/test-ts\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"F5 TransportServer with missing IP address and OK status\",\n\t\t\ttransportServer: f5.TransportServer{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: f5TransportServerGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"TransportServer\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test-ts\",\n\t\t\t\t\tNamespace: defaultF5TransportServerNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/ttl\": \"600\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: f5.TransportServerSpec{\n\t\t\t\t\tHost:      \"www.example.com\",\n\t\t\t\t\tIPAMLabel: \"test\",\n\t\t\t\t},\n\t\t\t\tStatus: f5.CustomResourceStatus{\n\t\t\t\t\tVSAddress: \"None\",\n\t\t\t\t\tStatus:    \"OK\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"F5 TransportServer does not support provider-specific annotations\",\n\t\t\ttransportServer: f5.TransportServer{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: f5TransportServerGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"TransportServer\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test-vs\",\n\t\t\t\t\tNamespace: defaultF5TransportServerNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\tannotations.AWSPrefix + \"weight\": \"10\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: f5.TransportServerSpec{\n\t\t\t\t\tHost:                 \"www.example.com\",\n\t\t\t\t\tVirtualServerAddress: \"192.168.1.100\",\n\t\t\t\t},\n\t\t\t\tStatus: f5.CustomResourceStatus{\n\t\t\t\t\tVSAddress: \"192.168.1.100\",\n\t\t\t\t\tStatus:    \"OK\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"www.example.com\",\n\t\t\t\t\tTargets:    []string{\"192.168.1.100\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"f5-transportserver/transportserver/test-vs\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tfakeKubernetesClient := fakeKube.NewSimpleClientset()\n\t\t\tscheme := runtime.NewScheme()\n\t\t\tscheme.AddKnownTypes(f5TransportServerGVR.GroupVersion(), &f5.TransportServer{}, &f5.TransportServerList{})\n\t\t\tfakeDynamicClient := fakeDynamic.NewSimpleDynamicClient(scheme)\n\n\t\t\ttransportServer := unstructured.Unstructured{}\n\n\t\t\ttransportServerJSON, err := json.Marshal(tc.transportServer)\n\t\t\trequire.NoError(t, err)\n\t\t\tassert.NoError(t, transportServer.UnmarshalJSON(transportServerJSON))\n\n\t\t\t// Create TransportServer resources\n\t\t\t_, err = fakeDynamicClient.Resource(f5TransportServerGVR).Namespace(defaultF5TransportServerNamespace).Create(t.Context(), &transportServer, metav1.CreateOptions{})\n\t\t\tassert.NoError(t, err)\n\n\t\t\tsource, err := NewF5TransportServerSource(t.Context(), fakeDynamicClient, fakeKubernetesClient,\n\t\t\t\t&Config{\n\t\t\t\t\tNamespace:        defaultF5TransportServerNamespace,\n\t\t\t\t\tAnnotationFilter: tc.annotationFilter,\n\t\t\t\t})\n\t\t\trequire.NoError(t, err)\n\t\t\tassert.NotNil(t, source)\n\n\t\t\tcount := &unstructured.UnstructuredList{}\n\t\t\tfor len(count.Items) < 1 {\n\t\t\t\tcount, _ = fakeDynamicClient.Resource(f5TransportServerGVR).Namespace(defaultF5TransportServerNamespace).List(t.Context(), metav1.ListOptions{})\n\t\t\t}\n\n\t\t\tendpoints, err := source.Endpoints(t.Context())\n\t\t\trequire.NoError(t, err)\n\t\t\tassert.Len(t, endpoints, len(tc.expected))\n\t\t\tassert.Equal(t, tc.expected, endpoints)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "source/f5_virtualserver.go",
    "content": "/*\nCopyright 2022 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\n\tf5 \"github.com/F5Networks/k8s-bigip-ctlr/v2/config/apis/cis/v1\"\n\tlog \"github.com/sirupsen/logrus\"\n\t\"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\t\"k8s.io/apimachinery/pkg/runtime\"\n\t\"k8s.io/apimachinery/pkg/runtime/schema\"\n\t\"k8s.io/client-go/dynamic\"\n\t\"k8s.io/client-go/dynamic/dynamicinformer\"\n\tkubeinformers \"k8s.io/client-go/informers\"\n\t\"k8s.io/client-go/kubernetes\"\n\t\"k8s.io/client-go/kubernetes/scheme\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n\t\"sigs.k8s.io/external-dns/source/informers\"\n)\n\nvar f5VirtualServerGVR = schema.GroupVersionResource{\n\tGroup:    \"cis.f5.com\",\n\tVersion:  \"v1\",\n\tResource: \"virtualservers\",\n}\n\n// virtualServerSource is an implementation of Source for F5 VirtualServer objects.\n//\n// +externaldns:source:name=f5-virtualserver\n// +externaldns:source:category=Load Balancers\n// +externaldns:source:description=Creates DNS entries from F5 VirtualServer resources\n// +externaldns:source:resources=VirtualServer.cis.f5.com\n// +externaldns:source:filters=annotation\n// +externaldns:source:namespace=all,single\n// +externaldns:source:fqdn-template=false\n// +externaldns:source:provider-specific=false\ntype f5VirtualServerSource struct {\n\tdynamicKubeClient     dynamic.Interface\n\tvirtualServerInformer kubeinformers.GenericInformer\n\tkubeClient            kubernetes.Interface\n\tannotationFilter      string\n\tnamespace             string\n\tunstructuredConverter *unstructuredConverter\n}\n\nfunc NewF5VirtualServerSource(\n\tctx context.Context,\n\tdynamicKubeClient dynamic.Interface,\n\tkubeClient kubernetes.Interface,\n\tcfg *Config,\n) (Source, error) {\n\tinformerFactory := dynamicinformer.NewFilteredDynamicSharedInformerFactory(dynamicKubeClient, 0, cfg.Namespace, nil)\n\tvirtualServerInformer := informerFactory.ForResource(f5VirtualServerGVR)\n\n\t_, _ = virtualServerInformer.Informer().AddEventHandler(informers.DefaultEventHandler())\n\n\tinformerFactory.Start(ctx.Done())\n\n\t// wait for the local cache to be populated.\n\tif err := informers.WaitForDynamicCacheSync(ctx, informerFactory); err != nil {\n\t\treturn nil, err\n\t}\n\n\tuc, err := newVSUnstructuredConverter()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to setup unstructured converter: %w\", err)\n\t}\n\n\treturn &f5VirtualServerSource{\n\t\tdynamicKubeClient:     dynamicKubeClient,\n\t\tvirtualServerInformer: virtualServerInformer,\n\t\tkubeClient:            kubeClient,\n\t\tnamespace:             cfg.Namespace,\n\t\tannotationFilter:      cfg.AnnotationFilter,\n\t\tunstructuredConverter: uc,\n\t}, nil\n}\n\n// Endpoints returns endpoint objects for each host-target combination that should be processed.\n// Retrieves all VirtualServers in the source's namespace(s).\nfunc (vs *f5VirtualServerSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) {\n\tvirtualServerObjects, err := vs.virtualServerInformer.Lister().ByNamespace(vs.namespace).List(labels.Everything())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar virtualServers []*f5.VirtualServer\n\tfor _, vsObj := range virtualServerObjects {\n\t\tunstructuredHost, ok := vsObj.(*unstructured.Unstructured)\n\t\tif !ok {\n\t\t\treturn nil, errors.New(\"could not convert\")\n\t\t}\n\n\t\tvirtualServer := &f5.VirtualServer{}\n\t\terr := vs.unstructuredConverter.scheme.Convert(unstructuredHost, virtualServer, nil)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tvirtualServers = append(virtualServers, virtualServer)\n\t}\n\n\tvirtualServers, err = annotations.Filter(virtualServers, vs.annotationFilter)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to filter VirtualServers: %w\", err)\n\t}\n\n\tendpoints := vs.endpointsFromVirtualServers(virtualServers)\n\n\treturn MergeEndpoints(endpoints), nil\n}\n\nfunc (vs *f5VirtualServerSource) AddEventHandler(_ context.Context, handler func()) {\n\tlog.Debug(\"Adding event handler for VirtualServer\")\n\n\t_, _ = vs.virtualServerInformer.Informer().AddEventHandler(eventHandlerFunc(handler))\n}\n\n// endpointsFromVirtualServers extracts the endpoints from a slice of VirtualServers\nfunc (vs *f5VirtualServerSource) endpointsFromVirtualServers(virtualServers []*f5.VirtualServer) []*endpoint.Endpoint {\n\tvar endpoints []*endpoint.Endpoint\n\n\tfor _, virtualServer := range virtualServers {\n\t\tif !hasValidVirtualServerIP(virtualServer) {\n\t\t\tlog.Warnf(\"F5 VirtualServer %s/%s is missing a valid IP address, skipping endpoint creation.\",\n\t\t\t\tvirtualServer.Namespace, virtualServer.Name)\n\t\t\tcontinue\n\t\t}\n\n\t\tresource := fmt.Sprintf(\"f5-virtualserver/%s/%s\", virtualServer.Namespace, virtualServer.Name)\n\n\t\tttl := annotations.TTLFromAnnotations(virtualServer.Annotations, resource)\n\n\t\ttargets := annotations.TargetsFromTargetAnnotation(virtualServer.Annotations)\n\t\tif len(targets) == 0 && virtualServer.Spec.VirtualServerAddress != \"\" {\n\t\t\ttargets = append(targets, virtualServer.Spec.VirtualServerAddress)\n\t\t}\n\n\t\tif len(targets) == 0 && virtualServer.Status.VSAddress != \"\" {\n\t\t\ttargets = append(targets, virtualServer.Status.VSAddress)\n\t\t}\n\n\t\tendpoints = append(endpoints, endpoint.EndpointsForHostname(virtualServer.Spec.Host, targets, ttl, nil, \"\", resource)...)\n\n\t\tfor _, alias := range virtualServer.Spec.HostAliases {\n\t\t\tif alias != \"\" {\n\t\t\t\tendpoints = append(endpoints, endpoint.EndpointsForHostname(alias, targets, ttl, nil, \"\", resource)...)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn endpoints\n}\n\n// newUnstructuredConverter returns a new unstructuredConverter initialized\nfunc newVSUnstructuredConverter() (*unstructuredConverter, error) {\n\tuc := &unstructuredConverter{\n\t\tscheme: runtime.NewScheme(),\n\t}\n\n\t// Add the core types we need\n\tuc.scheme.AddKnownTypes(f5VirtualServerGVR.GroupVersion(), &f5.VirtualServer{}, &f5.VirtualServerList{})\n\tif err := scheme.AddToScheme(uc.scheme); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn uc, nil\n}\n\nfunc hasValidVirtualServerIP(vs *f5.VirtualServer) bool {\n\tnormalizedAddress := strings.ToLower(vs.Status.VSAddress)\n\treturn normalizedAddress != \"none\" && normalizedAddress != \"\"\n}\n"
  },
  {
    "path": "source/f5_virtualserver_test.go",
    "content": "/*\nCopyright 2022 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured\"\n\t\"k8s.io/apimachinery/pkg/runtime\"\n\tfakeDynamic \"k8s.io/client-go/dynamic/fake\"\n\tfakeKube \"k8s.io/client-go/kubernetes/fake\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n\n\tf5 \"github.com/F5Networks/k8s-bigip-ctlr/v2/config/apis/cis/v1\"\n)\n\nconst defaultF5VirtualServerNamespace = \"virtualserver\"\n\nfunc TestF5VirtualServerEndpoints(t *testing.T) {\n\tt.Parallel()\n\ttests := []struct {\n\t\tname             string\n\t\tannotationFilter string\n\t\tvirtualServer    f5.VirtualServer\n\t\texpected         []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\tname:             \"F5 VirtualServer with target annotation\",\n\t\t\tannotationFilter: \"\",\n\t\t\tvirtualServer: f5.VirtualServer{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: f5VirtualServerGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"VirtualServer\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test-vs\",\n\t\t\t\t\tNamespace: defaultF5VirtualServerNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"192.168.1.150\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: f5.VirtualServerSpec{\n\t\t\t\t\tHost:                 \"www.example.com\",\n\t\t\t\t\tVirtualServerAddress: \"192.168.1.100\",\n\t\t\t\t},\n\t\t\t\tStatus: f5.CustomResourceStatus{\n\t\t\t\t\tVSAddress: \"192.168.1.200\",\n\t\t\t\t\tStatus:    \"OK\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"www.example.com\",\n\t\t\t\t\tTargets:    []string{\"192.168.1.150\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"f5-virtualserver/virtualserver/test-vs\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:             \"F5 VirtualServer with host and virtualServerAddress set\",\n\t\t\tannotationFilter: \"\",\n\t\t\tvirtualServer: f5.VirtualServer{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: f5VirtualServerGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"VirtualServer\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test-vs\",\n\t\t\t\t\tNamespace: defaultF5VirtualServerNamespace,\n\t\t\t\t},\n\t\t\t\tSpec: f5.VirtualServerSpec{\n\t\t\t\t\tHost:                 \"www.example.com\",\n\t\t\t\t\tVirtualServerAddress: \"192.168.1.100\",\n\t\t\t\t},\n\t\t\t\tStatus: f5.CustomResourceStatus{\n\t\t\t\t\tVSAddress: \"192.168.1.200\",\n\t\t\t\t\tStatus:    \"OK\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"www.example.com\",\n\t\t\t\t\tTargets:    []string{\"192.168.1.100\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"f5-virtualserver/virtualserver/test-vs\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:             \"F5 VirtualServer with host set and IP address from the status field\",\n\t\t\tannotationFilter: \"\",\n\t\t\tvirtualServer: f5.VirtualServer{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: f5VirtualServerGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"VirtualServer\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test-vs\",\n\t\t\t\t\tNamespace: defaultF5VirtualServerNamespace,\n\t\t\t\t},\n\t\t\t\tSpec: f5.VirtualServerSpec{\n\t\t\t\t\tHost: \"www.example.com\",\n\t\t\t\t},\n\t\t\t\tStatus: f5.CustomResourceStatus{\n\t\t\t\t\tVSAddress: \"192.168.1.100\",\n\t\t\t\t\tStatus:    \"OK\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"www.example.com\",\n\t\t\t\t\tTargets:    []string{\"192.168.1.100\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"f5-virtualserver/virtualserver/test-vs\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:             \"F5 VirtualServer with no IP address set\",\n\t\t\tannotationFilter: \"\",\n\t\t\tvirtualServer: f5.VirtualServer{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: f5VirtualServerGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"VirtualServer\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test-vs\",\n\t\t\t\t\tNamespace: defaultF5VirtualServerNamespace,\n\t\t\t\t},\n\t\t\t\tSpec: f5.VirtualServerSpec{\n\t\t\t\t\tHost: \"www.example.com\",\n\t\t\t\t},\n\t\t\t\tStatus: f5.CustomResourceStatus{\n\t\t\t\t\tVSAddress: \"\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname:             \"F5 VirtualServer with matching annotation filter\",\n\t\t\tannotationFilter: \"foo=bar\",\n\t\t\tvirtualServer: f5.VirtualServer{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: f5VirtualServerGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"VirtualServer\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test-vs\",\n\t\t\t\t\tNamespace: defaultF5VirtualServerNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"foo\": \"bar\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: f5.VirtualServerSpec{\n\t\t\t\t\tHost:                 \"www.example.com\",\n\t\t\t\t\tVirtualServerAddress: \"192.168.1.100\",\n\t\t\t\t},\n\t\t\t\tStatus: f5.CustomResourceStatus{\n\t\t\t\t\tVSAddress: \"192.168.1.100\",\n\t\t\t\t\tStatus:    \"OK\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"www.example.com\",\n\t\t\t\t\tTargets:    []string{\"192.168.1.100\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"f5-virtualserver/virtualserver/test-vs\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:             \"F5 VirtualServer with non-matching annotation filter\",\n\t\t\tannotationFilter: \"foo=bar\",\n\t\t\tvirtualServer: f5.VirtualServer{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: f5VirtualServerGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"VirtualServer\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test-vs\",\n\t\t\t\t\tNamespace: defaultF5VirtualServerNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"bar\": \"foo\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: f5.VirtualServerSpec{\n\t\t\t\t\tHost:                 \"www.example.com\",\n\t\t\t\t\tVirtualServerAddress: \"192.168.1.100\",\n\t\t\t\t},\n\t\t\t\tStatus: f5.CustomResourceStatus{\n\t\t\t\t\tVSAddress: \"192.168.1.100\",\n\t\t\t\t\tStatus:    \"OK\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"F5 VirtualServer TTL annotation\",\n\t\t\tvirtualServer: f5.VirtualServer{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: f5VirtualServerGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"VirtualServer\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test-vs\",\n\t\t\t\t\tNamespace: defaultF5VirtualServerNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/ttl\": \"600\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: f5.VirtualServerSpec{\n\t\t\t\t\tHost:                 \"www.example.com\",\n\t\t\t\t\tVirtualServerAddress: \"192.168.1.100\",\n\t\t\t\t},\n\t\t\t\tStatus: f5.CustomResourceStatus{\n\t\t\t\t\tVSAddress: \"192.168.1.100\",\n\t\t\t\t\tStatus:    \"OK\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"www.example.com\",\n\t\t\t\t\tTargets:    []string{\"192.168.1.100\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  600,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"f5-virtualserver/virtualserver/test-vs\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"F5 VirtualServer with error status but valid IP\",\n\t\t\tvirtualServer: f5.VirtualServer{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: f5VirtualServerGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"VirtualServer\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test-vs\",\n\t\t\t\t\tNamespace: defaultF5VirtualServerNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/ttl\": \"600\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: f5.VirtualServerSpec{\n\t\t\t\t\tHost:                 \"www.example.com\",\n\t\t\t\t\tVirtualServerAddress: \"192.168.1.100\",\n\t\t\t\t},\n\t\t\t\tStatus: f5.CustomResourceStatus{\n\t\t\t\t\tVSAddress: \"192.168.1.100\",\n\t\t\t\t\tStatus:    \"ERROR\",\n\t\t\t\t\tError:     \"Some error status message\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"www.example.com\",\n\t\t\t\t\tTargets:    []string{\"192.168.1.100\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  600,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"f5-virtualserver/virtualserver/test-vs\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"F5 VirtualServer with missing IP address and OK status\",\n\t\t\tvirtualServer: f5.VirtualServer{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: f5VirtualServerGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"VirtualServer\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test-vs\",\n\t\t\t\t\tNamespace: defaultF5VirtualServerNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/ttl\": \"600\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: f5.VirtualServerSpec{\n\t\t\t\t\tHost:      \"www.example.com\",\n\t\t\t\t\tIPAMLabel: \"test\",\n\t\t\t\t},\n\t\t\t\tStatus: f5.CustomResourceStatus{\n\t\t\t\t\tVSAddress: \"None\",\n\t\t\t\t\tStatus:    \"OK\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"F5 VirtualServer with hostAliases\",\n\t\t\tvirtualServer: f5.VirtualServer{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: f5VirtualServerGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"VirtualServer\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test-vs\",\n\t\t\t\t\tNamespace: defaultF5VirtualServerNamespace,\n\t\t\t\t},\n\t\t\t\tSpec: f5.VirtualServerSpec{\n\t\t\t\t\tHost:                 \"www.example.com\",\n\t\t\t\t\tVirtualServerAddress: \"192.168.1.100\",\n\t\t\t\t\tHostAliases:          []string{\"alias1.example.com\", \"alias2.example.com\"},\n\t\t\t\t},\n\t\t\t\tStatus: f5.CustomResourceStatus{\n\t\t\t\t\tVSAddress: \"192.168.1.100\",\n\t\t\t\t\tStatus:    \"OK\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"alias1.example.com\",\n\t\t\t\t\tTargets:    []string{\"192.168.1.100\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"f5-virtualserver/virtualserver/test-vs\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"alias2.example.com\",\n\t\t\t\t\tTargets:    []string{\"192.168.1.100\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"f5-virtualserver/virtualserver/test-vs\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"www.example.com\",\n\t\t\t\t\tTargets:    []string{\"192.168.1.100\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"f5-virtualserver/virtualserver/test-vs\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"F5 VirtualServer with hostAliases and target annotation\",\n\t\t\tvirtualServer: f5.VirtualServer{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: f5VirtualServerGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"VirtualServer\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test-vs\",\n\t\t\t\t\tNamespace: defaultF5VirtualServerNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"192.168.1.150\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: f5.VirtualServerSpec{\n\t\t\t\t\tHost:                 \"www.example.com\",\n\t\t\t\t\tVirtualServerAddress: \"192.168.1.100\",\n\t\t\t\t\tHostAliases:          []string{\"alias1.example.com\", \"alias2.example.com\"},\n\t\t\t\t},\n\t\t\t\tStatus: f5.CustomResourceStatus{\n\t\t\t\t\tVSAddress: \"192.168.1.100\",\n\t\t\t\t\tStatus:    \"OK\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"www.example.com\",\n\t\t\t\t\tTargets:    []string{\"192.168.1.150\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"f5-virtualserver/virtualserver/test-vs\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"alias1.example.com\",\n\t\t\t\t\tTargets:    []string{\"192.168.1.150\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"f5-virtualserver/virtualserver/test-vs\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"alias2.example.com\",\n\t\t\t\t\tTargets:    []string{\"192.168.1.150\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"f5-virtualserver/virtualserver/test-vs\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"F5 VirtualServer with hostAliases and TTL annotation\",\n\t\t\tvirtualServer: f5.VirtualServer{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: f5VirtualServerGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"VirtualServer\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test-vs\",\n\t\t\t\t\tNamespace: defaultF5VirtualServerNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/ttl\": \"300\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: f5.VirtualServerSpec{\n\t\t\t\t\tHost:                 \"www.example.com\",\n\t\t\t\t\tVirtualServerAddress: \"192.168.1.100\",\n\t\t\t\t\tHostAliases:          []string{\"alias1.example.com\", \"alias2.example.com\"},\n\t\t\t\t},\n\t\t\t\tStatus: f5.CustomResourceStatus{\n\t\t\t\t\tVSAddress: \"192.168.1.100\",\n\t\t\t\t\tStatus:    \"OK\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"www.example.com\",\n\t\t\t\t\tTargets:    []string{\"192.168.1.100\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  300,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"f5-virtualserver/virtualserver/test-vs\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"alias1.example.com\",\n\t\t\t\t\tTargets:    []string{\"192.168.1.100\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  300,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"f5-virtualserver/virtualserver/test-vs\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"alias2.example.com\",\n\t\t\t\t\tTargets:    []string{\"192.168.1.100\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  300,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"f5-virtualserver/virtualserver/test-vs\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"F5 VirtualServer with empty hostAliases\",\n\t\t\tvirtualServer: f5.VirtualServer{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: f5VirtualServerGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"VirtualServer\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test-vs\",\n\t\t\t\t\tNamespace: defaultF5VirtualServerNamespace,\n\t\t\t\t},\n\t\t\t\tSpec: f5.VirtualServerSpec{\n\t\t\t\t\tHost:                 \"www.example.com\",\n\t\t\t\t\tVirtualServerAddress: \"192.168.1.100\",\n\t\t\t\t\tHostAliases:          []string{},\n\t\t\t\t},\n\t\t\t\tStatus: f5.CustomResourceStatus{\n\t\t\t\t\tVSAddress: \"192.168.1.100\",\n\t\t\t\t\tStatus:    \"OK\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"www.example.com\",\n\t\t\t\t\tTargets:    []string{\"192.168.1.100\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"f5-virtualserver/virtualserver/test-vs\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"F5 VirtualServer with hostAliases containing empty strings\",\n\t\t\tvirtualServer: f5.VirtualServer{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: f5VirtualServerGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"VirtualServer\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test-vs\",\n\t\t\t\t\tNamespace: defaultF5VirtualServerNamespace,\n\t\t\t\t},\n\t\t\t\tSpec: f5.VirtualServerSpec{\n\t\t\t\t\tHost:                 \"www.example.com\",\n\t\t\t\t\tVirtualServerAddress: \"192.168.1.100\",\n\t\t\t\t\tHostAliases:          []string{\"alias1.example.com\", \"\", \"alias2.example.com\"},\n\t\t\t\t},\n\t\t\t\tStatus: f5.CustomResourceStatus{\n\t\t\t\t\tVSAddress: \"192.168.1.100\",\n\t\t\t\t\tStatus:    \"OK\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"www.example.com\",\n\t\t\t\t\tTargets:    []string{\"192.168.1.100\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"f5-virtualserver/virtualserver/test-vs\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"alias1.example.com\",\n\t\t\t\t\tTargets:    []string{\"192.168.1.100\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"f5-virtualserver/virtualserver/test-vs\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"alias2.example.com\",\n\t\t\t\t\tTargets:    []string{\"192.168.1.100\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"f5-virtualserver/virtualserver/test-vs\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"F5 VirtualServer does not support provider-specific annotations\",\n\t\t\tvirtualServer: f5.VirtualServer{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: f5VirtualServerGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"VirtualServer\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test-vs\",\n\t\t\t\t\tNamespace: defaultF5VirtualServerNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\tannotations.AWSPrefix + \"weight\": \"10\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: f5.VirtualServerSpec{\n\t\t\t\t\tHost:                 \"www.example.com\",\n\t\t\t\t\tVirtualServerAddress: \"192.168.1.100\",\n\t\t\t\t},\n\t\t\t\tStatus: f5.CustomResourceStatus{\n\t\t\t\t\tVSAddress: \"192.168.1.100\",\n\t\t\t\t\tStatus:    \"OK\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"www.example.com\",\n\t\t\t\t\tTargets:    []string{\"192.168.1.100\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"f5-virtualserver/virtualserver/test-vs\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tfakeKubernetesClient := fakeKube.NewClientset()\n\t\t\tscheme := runtime.NewScheme()\n\t\t\tscheme.AddKnownTypes(f5VirtualServerGVR.GroupVersion(), &f5.VirtualServer{}, &f5.VirtualServerList{})\n\t\t\tfakeDynamicClient := fakeDynamic.NewSimpleDynamicClient(scheme)\n\n\t\t\tvirtualServer := unstructured.Unstructured{}\n\n\t\t\tvirtualServerJSON, err := json.Marshal(tc.virtualServer)\n\t\t\trequire.NoError(t, err)\n\t\t\tassert.NoError(t, virtualServer.UnmarshalJSON(virtualServerJSON))\n\n\t\t\t// Create VirtualServer resources\n\t\t\t_, err = fakeDynamicClient.Resource(f5VirtualServerGVR).Namespace(defaultF5VirtualServerNamespace).Create(t.Context(), &virtualServer, metav1.CreateOptions{})\n\t\t\tassert.NoError(t, err)\n\n\t\t\tsource, err := NewF5VirtualServerSource(t.Context(), fakeDynamicClient, fakeKubernetesClient,\n\t\t\t\t&Config{\n\t\t\t\t\tNamespace:        defaultF5VirtualServerNamespace,\n\t\t\t\t\tAnnotationFilter: tc.annotationFilter,\n\t\t\t\t})\n\t\t\trequire.NoError(t, err)\n\t\t\tassert.NotNil(t, source)\n\n\t\t\tcount := &unstructured.UnstructuredList{}\n\t\t\tfor len(count.Items) < 1 {\n\t\t\t\tcount, _ = fakeDynamicClient.Resource(f5VirtualServerGVR).Namespace(defaultF5VirtualServerNamespace).List(t.Context(), metav1.ListOptions{})\n\t\t\t}\n\n\t\t\tendpoints, err := source.Endpoints(t.Context())\n\t\t\trequire.NoError(t, err)\n\t\t\tassert.Len(t, endpoints, len(tc.expected))\n\t\t\tvalidateEndpoints(t, endpoints, tc.expected)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "source/fake.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\n/*\nNote: currently only supports IP targets (A records), not hostname targets\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"math/rand\"\n\t\"net\"\n\n\tv1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/pkg/events\"\n\t\"sigs.k8s.io/external-dns/source/types\"\n)\n\n// fakeSource is an implementation of Source that provides dummy endpoints for\n// testing/dry-running of dns providers without needing an attached Kubernetes cluster.\n//\n// +externaldns:source:name=fake\n// +externaldns:source:category=Testing\n// +externaldns:source:description=Provides dummy endpoints for testing and dry-running\n// +externaldns:source:resources=Fake Endpoints\n// +externaldns:source:filters=\n// +externaldns:source:namespace=\n// +externaldns:source:fqdn-template=true\n// +externaldns:source:events=true\n// +externaldns:source:provider-specific=false\ntype fakeSource struct {\n\tdnsName string\n}\n\nconst (\n\tdefaultFQDNTemplate = \"example.com\"\n)\n\n// NewFakeSource creates a new fakeSource with the given config.\nfunc NewFakeSource(fqdnTemplate string) (Source, error) {\n\tif fqdnTemplate == \"\" {\n\t\tfqdnTemplate = defaultFQDNTemplate\n\t}\n\n\treturn &fakeSource{\n\t\tdnsName: fqdnTemplate,\n\t}, nil\n}\n\nfunc (sc *fakeSource) AddEventHandler(_ context.Context, _ func()) {\n}\n\n// Endpoints returns endpoint objects.\nfunc (sc *fakeSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) {\n\tendpoints := make([]*endpoint.Endpoint, 10)\n\n\tfor i := range 10 {\n\t\tendpoints[i] = sc.generateEndpoint()\n\t}\n\n\treturn MergeEndpoints(endpoints), nil\n}\n\nfunc (sc *fakeSource) generateEndpoint() *endpoint.Endpoint {\n\tep := endpoint.NewEndpoint(\n\t\tgenerateDNSName(4, sc.dnsName),\n\t\tendpoint.RecordTypeA,\n\t\tgenerateIPAddress(),\n\t)\n\tep.SetIdentifier = types.Fake\n\tep.WithRefObject(events.NewObjectReference(&v1.Pod{\n\t\tTypeMeta: metav1.TypeMeta{\n\t\t\tKind:       \"Pod\",\n\t\t\tAPIVersion: \"v1\",\n\t\t},\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:      types.Fake + \"-\" + ep.DNSName,\n\t\t\tNamespace: v1.NamespaceDefault,\n\t\t},\n\t}, types.Fake))\n\treturn ep\n}\n\nfunc generateIPAddress() string {\n\t// 192.0.2.[1-255] is reserved by RFC 5737 for documentation and examples\n\treturn net.IPv4(\n\t\tbyte(192),\n\t\tbyte(0),\n\t\tbyte(2),\n\t\tbyte(rand.Intn(253)+1),\n\t).String()\n}\n\nvar letterRunes = []rune(\"abcdefghijklmnopqrstuvwxyz\")\n\nfunc generateDNSName(prefixLength int, dnsName string) string {\n\tprefixBytes := make([]rune, prefixLength)\n\n\tfor i := range prefixBytes {\n\t\tprefixBytes[i] = letterRunes[rand.Intn(len(letterRunes))]\n\t}\n\n\tprefixStr := string(prefixBytes)\n\n\treturn fmt.Sprintf(\"%s.%s\", prefixStr, dnsName)\n}\n"
  },
  {
    "path": "source/fake_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\t\"net\"\n\t\"regexp\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\n// Validate that FakeSource is a source\nvar _ Source = &fakeSource{}\n\nfunc generateTestEndpoints() []*endpoint.Endpoint {\n\tsc, _ := NewFakeSource(\"\")\n\n\tendpoints, _ := sc.Endpoints(context.Background())\n\n\treturn endpoints\n}\n\nfunc TestFakeSourceReturnsTenEndpoints(t *testing.T) {\n\tendpoints := generateTestEndpoints()\n\n\tcount := len(endpoints)\n\n\tif count != 10 {\n\t\tt.Error(count)\n\t}\n}\n\nfunc TestFakeEndpointsBelongToDomain(t *testing.T) {\n\tvalidRecord := regexp.MustCompile(`^[a-z]{4}\\.example\\.com$`)\n\n\tendpoints := generateTestEndpoints()\n\n\tfor _, e := range endpoints {\n\t\tvalid := validRecord.MatchString(e.DNSName)\n\n\t\tif !valid {\n\t\t\tt.Error(e.DNSName)\n\t\t}\n\t}\n}\n\nfunc TestFakeEndpointsResolveToIPAddresses(t *testing.T) {\n\tendpoints := generateTestEndpoints()\n\n\tfor _, e := range endpoints {\n\t\tip := net.ParseIP(e.Targets[0])\n\n\t\tif ip == nil {\n\t\t\tt.Error(e)\n\t\t}\n\t}\n}\n\nfunc TestFakeSource_GenerateEndpoint_RefObject(t *testing.T) {\n\tsc, _ := NewFakeSource(\"example.com\")\n\tfs := sc.(*fakeSource)\n\n\tep := fs.generateEndpoint()\n\trequire.NotNil(t, ep, \"endpoint should not be nil\")\n\trequire.NotNil(t, ep.RefObject())\n\trequire.Equal(t, \"Pod\", ep.RefObject().Kind)\n}\n"
  },
  {
    "path": "source/fqdn/fqdn.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage fqdn\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"maps\"\n\t\"reflect\"\n\t\"slices\"\n\t\"strings\"\n\t\"text/template\"\n\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/runtime\"\n\t\"k8s.io/apimachinery/pkg/runtime/schema\"\n\t\"k8s.io/client-go/kubernetes/scheme\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\nfunc ParseTemplate(input string) (*template.Template, error) {\n\tif input == \"\" {\n\t\treturn nil, nil\n\t}\n\tfuncs := template.FuncMap{\n\t\t\"contains\":   strings.Contains,\n\t\t\"trimPrefix\": strings.TrimPrefix,\n\t\t\"trimSuffix\": strings.TrimSuffix,\n\t\t\"trim\":       strings.TrimSpace,\n\t\t\"toLower\":    strings.ToLower,\n\t\t\"replace\":    replace,\n\t\t\"isIPv6\":     isIPv6String,\n\t\t\"isIPv4\":     isIPv4String,\n\t\t\"hasKey\":     hasKey,\n\t\t\"fromJson\":   fromJson,\n\t}\n\treturn template.New(\"endpoint\").Funcs(funcs).Parse(input)\n}\n\ntype kubeObject interface {\n\truntime.Object\n\tmetav1.Object\n}\n\n// ExecTemplate executes a template against a Kubernetes object and returns hostnames.\n// It infers Kind if TypeMeta is missing. Returns error if obj is nil or execution fails.\nfunc ExecTemplate(tmpl *template.Template, obj kubeObject) ([]string, error) {\n\tif obj == nil {\n\t\treturn nil, fmt.Errorf(\"object is nil\")\n\t}\n\t// Kubernetes API doesn't populate TypeMeta (Kind/APIVersion) when retrieving\n\t// objects via informers. because the client already knows what type it requested. This reduces payload size.\n\t// Set it so templates can use .Kind and .APIVersion\n\t// TODO: all sources to transform Informer().SetTransform()\n\tgvk := obj.GetObjectKind().GroupVersionKind()\n\tif gvk.Kind == \"\" {\n\t\tgvks, _, err := scheme.Scheme.ObjectKinds(obj)\n\t\tif err == nil && len(gvks) > 0 {\n\t\t\tgvk = gvks[0]\n\t\t} else {\n\t\t\t// Fallback to reflection for types not in scheme\n\t\t\tgvk = schema.GroupVersionKind{Kind: reflect.TypeOf(obj).Elem().Name()}\n\t\t}\n\t\tobj.GetObjectKind().SetGroupVersionKind(gvk)\n\t}\n\n\tvar buf bytes.Buffer\n\tif err := tmpl.Execute(&buf, obj); err != nil {\n\t\tkind := obj.GetObjectKind().GroupVersionKind().Kind\n\t\treturn nil, fmt.Errorf(\"failed to apply template on %s %s/%s: %w\", kind, obj.GetNamespace(), obj.GetName(), err)\n\t}\n\thosts := strings.Split(buf.String(), \",\")\n\thostnames := make(map[string]struct{}, len(hosts))\n\tfor _, name := range hosts {\n\t\tname = strings.TrimSpace(name)\n\t\tname = strings.TrimSuffix(name, \".\")\n\t\tif name != \"\" {\n\t\t\thostnames[name] = struct{}{}\n\t\t}\n\t}\n\treturn slices.Sorted(maps.Keys(hostnames)), nil\n}\n\n// replace all instances of oldValue with newValue in target string.\n// adheres to syntax from https://masterminds.github.io/sprig/strings.html.\nfunc replace(oldValue, newValue, target string) string {\n\treturn strings.ReplaceAll(target, oldValue, newValue)\n}\n\n// isIPv6String reports whether the target string is an IPv6 address,\n// including IPv4-mapped IPv6 addresses.\nfunc isIPv6String(target string) bool {\n\treturn endpoint.SuitableType(target) == endpoint.RecordTypeAAAA\n}\n\n// isIPv4String reports whether the target string is an IPv4 address.\nfunc isIPv4String(target string) bool {\n\treturn endpoint.SuitableType(target) == endpoint.RecordTypeA\n}\n\n// hasKey checks if a key exists in a map. This is needed because Go templates'\n// `index` function returns the zero value (\"\") for missing keys, which is\n// indistinguishable from keys with empty values. Kubernetes uses empty-value\n// labels for markers (e.g., `service.kubernetes.io/headless: \"\"`), so we need\n// explicit key existence checking.\nfunc hasKey(m map[string]string, key string) bool {\n\t_, ok := m[key]\n\treturn ok\n}\n\n// fromJson decodes a JSON string into a Go value (map, slice, etc.).\n// This enables templates to work with structured data stored as JSON strings\n// in complex labels or annotations or Configmap data fields, e.g. ranging over a list of entries:\n//\n//\t{{ range $entry := (index .Data \"entries\" | fromJson) }}{{ index $entry \"dns\" }},{{ end }}\n//\n// Returns nil if the input is not valid JSON.\nfunc fromJson(v string) any {\n\tvar output any\n\t_ = json.Unmarshal([]byte(v), &output)\n\treturn output\n}\n\n// CombineWithTemplatedEndpoints merges annotation-based endpoints with template-based endpoints\n// according to the FQDN template configuration.\n//\n// Logic:\n//   - If fqdnTemplate is nil, returns original endpoints unchanged\n//   - If combineFQDNAnnotation is true, appends templated endpoints to existing\n//   - If combineFQDNAnnotation is false and endpoints is empty, uses templated endpoints\n//   - If combineFQDNAnnotation is false and endpoints exist, returns original unchanged\nfunc CombineWithTemplatedEndpoints(\n\tendpoints []*endpoint.Endpoint,\n\tfqdnTemplate *template.Template,\n\tcombineFQDNAnnotation bool,\n\ttemplateFunc func() ([]*endpoint.Endpoint, error),\n) ([]*endpoint.Endpoint, error) {\n\tif fqdnTemplate == nil {\n\t\treturn endpoints, nil\n\t}\n\n\tif !combineFQDNAnnotation && len(endpoints) > 0 {\n\t\treturn endpoints, nil\n\t}\n\n\ttemplatedEndpoints, err := templateFunc()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get endpoints from template: %w\", err)\n\t}\n\n\tif combineFQDNAnnotation {\n\t\treturn append(endpoints, templatedEndpoints...), nil\n\t}\n\treturn templatedEndpoints, nil\n}\n"
  },
  {
    "path": "source/fqdn/fqdn_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage fqdn\n\nimport (\n\t\"errors\"\n\t\"testing\"\n\t\"text/template\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\tcorev1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/runtime\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\nfunc TestParseTemplate(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\tname                     string\n\t\tannotationFilter         string\n\t\tfqdnTemplate             string\n\t\tcombineFQDNAndAnnotation bool\n\t\texpectError              bool\n\t}{\n\t\t{\n\t\t\tname:         \"invalid template\",\n\t\t\texpectError:  true,\n\t\t\tfqdnTemplate: \"{{.Name\",\n\t\t},\n\t\t{\n\t\t\tname:        \"valid empty template\",\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\tname:         \"valid template\",\n\t\t\texpectError:  false,\n\t\t\tfqdnTemplate: \"{{.Name}}-{{.Namespace}}.ext-dns.test.com\",\n\t\t},\n\t\t{\n\t\t\tname:         \"valid template\",\n\t\t\texpectError:  false,\n\t\t\tfqdnTemplate: \"{{.Name}}-{{.Namespace}}.ext-dns.test.com, {{.Name}}-{{.Namespace}}.ext-dna.test.com\",\n\t\t},\n\t\t{\n\t\t\tname:                     \"valid template\",\n\t\t\texpectError:              false,\n\t\t\tfqdnTemplate:             \"{{.Name}}-{{.Namespace}}.ext-dns.test.com, {{.Name}}-{{.Namespace}}.ext-dna.test.com\",\n\t\t\tcombineFQDNAndAnnotation: true,\n\t\t},\n\t\t{\n\t\t\tname:             \"non-empty annotation filter label\",\n\t\t\texpectError:      false,\n\t\t\tannotationFilter: \"kubernetes.io/ingress.class=nginx\",\n\t\t},\n\t\t{\n\t\t\tname:         \"replace template function\",\n\t\t\texpectError:  false,\n\t\t\tfqdnTemplate: \"{{\\\"hello.world\\\" | replace \\\".\\\" \\\"-\\\"}}.ext-dns.test.com\",\n\t\t},\n\t\t{\n\t\t\tname:         \"isIPv4 template function with valid IPv4\",\n\t\t\texpectError:  false,\n\t\t\tfqdnTemplate: \"{{if isIPv4 \\\"192.168.1.1\\\"}}valid{{else}}invalid{{end}}.ext-dns.test.com\",\n\t\t},\n\t\t{\n\t\t\tname:         \"isIPv4 template function with invalid IPv4\",\n\t\t\texpectError:  false,\n\t\t\tfqdnTemplate: \"{{if isIPv4 \\\"not.an.ip.addr\\\"}}valid{{else}}invalid{{end}}.ext-dns.test.com\",\n\t\t},\n\t\t{\n\t\t\tname:         \"isIPv6 template function with valid IPv6\",\n\t\t\texpectError:  false,\n\t\t\tfqdnTemplate: \"{{if isIPv6 \\\"2001:db8::1\\\"}}valid{{else}}invalid{{end}}.ext-dns.test.com\",\n\t\t},\n\t\t{\n\t\t\tname:         \"isIPv6 template function with invalid IPv6\",\n\t\t\texpectError:  false,\n\t\t\tfqdnTemplate: \"{{if isIPv6 \\\"not:ipv6:addr\\\"}}valid{{else}}invalid{{end}}.ext-dns.test.com\",\n\t\t},\n\t} {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t_, err := ParseTemplate(tt.fqdnTemplate)\n\t\t\tif tt.expectError {\n\t\t\t\tassert.Error(t, err)\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestExecTemplate(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\ttmpl    string\n\t\tobj     kubeObject\n\t\twant    []string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"simple template\",\n\t\t\ttmpl: \"{{ .Name }}.example.com, {{ .Namespace }}.example.org\",\n\t\t\tobj: &testObject{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test\",\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []string{\"default.example.org\", \"test.example.com\"},\n\t\t},\n\t\t{\n\t\t\tname: \"multiple hostnames\",\n\t\t\ttmpl: \"{{.Name}}.example.com, {{.Name}}.example.org\",\n\t\t\tobj: &testObject{\n\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test\",\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []string{\"test.example.com\", \"test.example.org\"},\n\t\t},\n\t\t{\n\t\t\tname: \"trim spaces\",\n\t\t\ttmpl: \"  {{ trim .Name}}.example.com. \",\n\t\t\tobj: &testObject{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \" test \",\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []string{\"test.example.com\"},\n\t\t},\n\t\t{\n\t\t\tname: \"trim prefix\",\n\t\t\ttmpl: `{{ trimPrefix .Name \"the-\" }}.example.com`,\n\t\t\tobj: &testObject{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"the-test\",\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []string{\"test.example.com\"},\n\t\t},\n\t\t{\n\t\t\tname: \"trim suffix\",\n\t\t\ttmpl: `{{ trimSuffix .Name \"-v2\" }}.example.com`,\n\t\t\tobj: &testObject{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test-v2\",\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []string{\"test.example.com\"},\n\t\t},\n\t\t{\n\t\t\tname: \"replace dash\",\n\t\t\ttmpl: `{{ replace \"-\" \".\" .Name }}.example.com`,\n\t\t\tobj: &testObject{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test-v2\",\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []string{\"test.v2.example.com\"},\n\t\t},\n\t\t{\n\t\t\tname: \"annotations and labels\",\n\t\t\ttmpl: \"{{.Labels.environment }}.example.com, {{ index .ObjectMeta.Annotations \\\"alb.ingress.kubernetes.io/scheme\\\" }}.{{ .Labels.environment }}.{{ index .ObjectMeta.Annotations \\\"dns.company.com/zone\\\" }}\",\n\t\t\tobj: &testObject{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test\",\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"test.example.com, test.example.org\",\n\t\t\t\t\t\t\"kubernetes.io/role/internal-elb\":           \"true\",\n\t\t\t\t\t\t\"alb.ingress.kubernetes.io/scheme\":          \"internal\",\n\t\t\t\t\t\t\"dns.company.com/zone\":                      \"company.org\",\n\t\t\t\t\t},\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\"environment\": \"production\",\n\t\t\t\t\t\t\"app\":         \"myapp\",\n\t\t\t\t\t\t\"tier\":        \"backend\",\n\t\t\t\t\t\t\"role\":        \"worker\",\n\t\t\t\t\t\t\"version\":     \"1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []string{\"internal.production.company.org\", \"production.example.com\"},\n\t\t},\n\t\t{\n\t\t\tname: \"labels to lowercase\",\n\t\t\ttmpl: \"{{ toLower .Labels.department }}.example.org\",\n\t\t\tobj: &testObject{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test\",\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\"department\": \"FINANCE\",\n\t\t\t\t\t\t\"app\":        \"myapp\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []string{\"finance.example.org\"},\n\t\t},\n\t\t{\n\t\t\tname: \"generate multiple hostnames with if condition\",\n\t\t\ttmpl: \"{{ if contains (index .ObjectMeta.Annotations \\\"external-dns.alpha.kubernetes.io/hostname\\\") \\\"example.com\\\" }}{{ toLower .Labels.hostoverride }}{{end}}\",\n\t\t\tobj: &testObject{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"test\",\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\"hostoverride\": \"abrakadabra.google.com\",\n\t\t\t\t\t\t\"app\":          \"myapp\",\n\t\t\t\t\t},\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"test.example.com\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []string{\"abrakadabra.google.com\"},\n\t\t},\n\t\t{\n\t\t\tname: \"ignore empty template output\",\n\t\t\ttmpl: \"{{ if eq .Name \\\"other\\\" }}{{ .Name }}.example.com{{ end }}\",\n\t\t\tobj: &testObject{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"test\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: nil,\n\t\t},\n\t\t{\n\t\t\tname: \"ignore trailing comma output\",\n\t\t\ttmpl: \"{{ .Name }}.example.com,\",\n\t\t\tobj: &testObject{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"test\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []string{\"test.example.com\"},\n\t\t},\n\t\t{\n\t\t\tname: \"contains label with empty value\",\n\t\t\ttmpl: `{{if hasKey .Labels \"service.kubernetes.io/headless\"}}{{ .Name }}.example.com,{{end}}`,\n\t\t\tobj: &testObject{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"test\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\"service.kubernetes.io/headless\": \"\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []string{\"test.example.com\"},\n\t\t},\n\t\t{\n\t\t\tname: \"result only contains unique values\",\n\t\t\ttmpl: `{{ .Name }}.example.com,{{ .Name }}.example.com,{{ .Name }}.example.com`,\n\t\t\tobj: &testObject{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"test\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\"service.kubernetes.io/headless\": \"\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []string{\"test.example.com\"},\n\t\t},\n\t\t{\n\t\t\tname: \"dns entries in labels\",\n\t\t\ttmpl: `\n{{ if hasKey .Labels \"records\" }}{{ range $entry := (index .Labels \"records\" | fromJson) }}{{ index $entry \"dns\" }},{{ end }}{{ end }}`,\n\t\t\tobj: &testObject{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"test\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\"records\": `\n[{\"dns\":\"entry1.internal.tld\",\"target\":\"10.10.10.10\"},{\"dns\":\"entry2.example.tld\",\"target\":\"my.cluster.local\"}]`,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []string{\"entry1.internal.tld\", \"entry2.example.tld\"},\n\t\t},\n\t\t{\n\t\t\tname: \"configmap with multiple entries\",\n\t\t\ttmpl: `{{ range $entry := (index .Data \"entries\" | fromJson) }}{{ index $entry \"dns\" }},{{ end }}`,\n\t\t\tobj: &corev1.ConfigMap{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"test-configmap\",\n\t\t\t\t},\n\t\t\t\tData: map[string]string{\n\t\t\t\t\t\"entries\": `\n[{\"dns\":\"entry1.internal.tld\",\"target\":\"10.10.10.10\"},{\"dns\":\"entry2.example.tld\",\"target\":\"my.cluster.local\"}]`,\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []string{\"entry1.internal.tld\", \"entry2.example.tld\"},\n\t\t},\n\t\t{\n\t\t\tname: \"rancher publicEndpoints annotation\",\n\t\t\ttmpl: `\n{{ range $entry := (index .Annotations \"field.cattle.io/publicEndpoints\" | fromJson) }}{{ index $entry \"hostname\" }},{{ end }}`,\n\t\t\tobj: &testObject{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"test\",\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"field.cattle.io/publicEndpoints\": `\n\t\t\t\t\t\t\t[{\"addresses\":[\"\"],\"port\":80,\"protocol\":\"HTTP\",\n\t\t\t\t\t\t\t\t\"serviceName\":\"development:keycloak-ha-service\",\n\t\t\t\t\t\t\t\t\"ingressName\":\"development:keycloak-ha-ingress\",\n\t\t\t\t\t\t\t\t\"hostname\":\"keycloak.snip.com\",\"allNodes\":false\n\t\t\t\t\t\t\t}]`,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []string{\"keycloak.snip.com\"},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\ttmpl, err := ParseTemplate(tt.tmpl)\n\t\t\trequire.NoError(t, err)\n\n\t\t\tgot, err := ExecTemplate(tmpl, tt.obj)\n\t\t\trequire.NoError(t, err)\n\t\t\tassert.Equal(t, tt.want, got)\n\t\t})\n\t}\n}\n\nfunc TestExecTemplateEmptyObject(t *testing.T) {\n\ttmpl, err := ParseTemplate(\"{{ toLower .Labels.department }}.example.org\")\n\trequire.NoError(t, err)\n\t_, err = ExecTemplate(tmpl, nil)\n\tassert.Error(t, err)\n}\n\nfunc TestExecTemplatePopulatesEmptyKind(t *testing.T) {\n\t// Test that Kind is populated when initially empty (simulates informer behavior)\n\ttmpl, err := ParseTemplate(\"{{ .Kind }}.{{ .Name }}.example.com\")\n\trequire.NoError(t, err)\n\n\t// Create object with empty TypeMeta (Kind == \"\")\n\tobj := &testObject{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:      \"test\",\n\t\t\tNamespace: \"default\",\n\t\t},\n\t}\n\n\t// Kind should be empty initially\n\tassert.Empty(t, obj.GetObjectKind().GroupVersionKind().Kind)\n\n\tgot, err := ExecTemplate(tmpl, obj)\n\trequire.NoError(t, err)\n\n\t// Kind should now be populated via reflection\n\tassert.Equal(t, \"testObject\", obj.GetObjectKind().GroupVersionKind().Kind)\n\tassert.Equal(t, []string{\"testObject.test.example.com\"}, got)\n}\n\nfunc TestExecTemplatePreservesExistingKind(t *testing.T) {\n\t// Test that existing Kind is not overwritten\n\ttmpl, err := ParseTemplate(\"{{ .Kind }}.{{ .Name }}.example.com\")\n\trequire.NoError(t, err)\n\n\tobj := &testObject{\n\t\tTypeMeta: metav1.TypeMeta{\n\t\t\tKind:       \"CustomKind\",\n\t\t\tAPIVersion: \"v1\",\n\t\t},\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:      \"test\",\n\t\t\tNamespace: \"default\",\n\t\t},\n\t}\n\n\tgot, err := ExecTemplate(tmpl, obj)\n\trequire.NoError(t, err)\n\n\t// Kind should remain unchanged\n\tassert.Equal(t, \"CustomKind\", obj.GetObjectKind().GroupVersionKind().Kind)\n\tassert.Equal(t, []string{\"CustomKind.test.example.com\"}, got)\n}\n\nfunc TestFqdnTemplate(t *testing.T) {\n\ttests := []struct {\n\t\tname          string\n\t\tfqdnTemplate  string\n\t\texpectedError bool\n\t}{\n\t\t{\n\t\t\tname:          \"empty template\",\n\t\t\tfqdnTemplate:  \"\",\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname:          \"valid template\",\n\t\t\tfqdnTemplate:  \"{{ .Name }}.example.com\",\n\t\t\texpectedError: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\ttmpl, err := ParseTemplate(tt.fqdnTemplate)\n\t\t\tif tt.expectedError {\n\t\t\t\trequire.Error(t, err)\n\t\t\t\tassert.Nil(t, tmpl)\n\t\t\t} else {\n\t\t\t\trequire.NoError(t, err)\n\t\t\t\tif tt.fqdnTemplate == \"\" {\n\t\t\t\t\tassert.Nil(t, tmpl)\n\t\t\t\t} else {\n\t\t\t\t\tassert.NotNil(t, tmpl)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestReplace(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\tname     string\n\t\toldValue string\n\t\tnewValue string\n\t\ttarget   string\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname:     \"simple replacement\",\n\t\t\toldValue: \"old\",\n\t\t\tnewValue: \"new\",\n\t\t\ttarget:   \"old-value\",\n\t\t\texpected: \"new-value\",\n\t\t},\n\t\t{\n\t\t\tname:     \"multiple replacements\",\n\t\t\toldValue: \".\",\n\t\t\tnewValue: \"-\",\n\t\t\ttarget:   \"hello.world.com\",\n\t\t\texpected: \"hello-world-com\",\n\t\t},\n\t\t{\n\t\t\tname:     \"no replacement needed\",\n\t\t\toldValue: \"x\",\n\t\t\tnewValue: \"y\",\n\t\t\ttarget:   \"hello-world\",\n\t\t\texpected: \"hello-world\",\n\t\t},\n\t\t{\n\t\t\tname:     \"empty strings\",\n\t\t\toldValue: \"\",\n\t\t\tnewValue: \"\",\n\t\t\ttarget:   \"test\",\n\t\t\texpected: \"test\",\n\t\t},\n\t} {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := replace(tt.oldValue, tt.newValue, tt.target)\n\t\t\tassert.Equal(t, tt.expected, result)\n\t\t})\n\t}\n}\n\nfunc TestIsIPv6String(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\tname     string\n\t\tinput    string\n\t\texpected bool\n\t}{\n\t\t{\n\t\t\tname:     \"valid IPv6\",\n\t\t\tinput:    \"2001:db8::1\",\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"valid IPv6 with multiple segments\",\n\t\t\tinput:    \"2001:0db8:85a3:0000:0000:8a2e:0370:7334\",\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"valid IPv4-mapped IPv6\",\n\t\t\tinput:    \"::ffff:192.168.1.1\",\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid IPv6\",\n\t\t\tinput:    \"not:ipv6:addr\",\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"IPv4 address\",\n\t\t\tinput:    \"192.168.1.1\",\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"empty string\",\n\t\t\tinput:    \"\",\n\t\t\texpected: false,\n\t\t},\n\t} {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := isIPv6String(tt.input)\n\t\t\tassert.Equal(t, tt.expected, result)\n\t\t})\n\t}\n}\n\nfunc TestIsIPv4String(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\tname     string\n\t\tinput    string\n\t\texpected bool\n\t}{\n\t\t{\n\t\t\tname:     \"valid IPv4\",\n\t\t\tinput:    \"192.168.1.1\",\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid IPv4\",\n\t\t\tinput:    \"256.256.256.256\",\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"IPv6 address\",\n\t\t\tinput:    \"2001:db8::1\",\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid format\",\n\t\t\tinput:    \"not.an.ip\",\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"empty string\",\n\t\t\tinput:    \"\",\n\t\t\texpected: false,\n\t\t},\n\t} {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := isIPv4String(tt.input)\n\t\t\tassert.Equal(t, tt.expected, result)\n\t\t})\n\t}\n}\n\nfunc TestHasKey(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\tname     string\n\t\tm        map[string]string\n\t\tkey      string\n\t\texpected bool\n\t}{\n\t\t{\n\t\t\tname:     \"key exists with non-empty value\",\n\t\t\tm:        map[string]string{\"foo\": \"bar\"},\n\t\t\tkey:      \"foo\",\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"key exists with empty value\",\n\t\t\tm:        map[string]string{\"service.kubernetes.io/headless\": \"\"},\n\t\t\tkey:      \"service.kubernetes.io/headless\",\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"key does not exist\",\n\t\t\tm:        map[string]string{\"foo\": \"bar\"},\n\t\t\tkey:      \"baz\",\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"nil map\",\n\t\t\tm:        nil,\n\t\t\tkey:      \"foo\",\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"empty map\",\n\t\t\tm:        map[string]string{},\n\t\t\tkey:      \"foo\",\n\t\t\texpected: false,\n\t\t},\n\t} {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := hasKey(tt.m, tt.key)\n\t\t\tassert.Equal(t, tt.expected, result)\n\t\t})\n\t}\n}\n\nfunc TestFromJson(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\tname     string\n\t\tinput    string\n\t\texpected any\n\t}{\n\t\t{\n\t\t\tname:     \"map of strings\",\n\t\t\tinput:    `{\"dns\":\"entry1.internal.tld\",\"target\":\"10.10.10.10\"}`,\n\t\t\texpected: map[string]any{\"dns\": \"entry1.internal.tld\", \"target\": \"10.10.10.10\"},\n\t\t},\n\t\t{\n\t\t\tname:  \"slice of maps\",\n\t\t\tinput: `[{\"dns\":\"entry1.internal.tld\",\"target\":\"10.10.10.10\"},{\"dns\":\"entry2.example.tld\",\"target\":\"my.cluster.local\"}]`,\n\t\t\texpected: []any{\n\t\t\t\tmap[string]any{\"dns\": \"entry1.internal.tld\", \"target\": \"10.10.10.10\"},\n\t\t\t\tmap[string]any{\"dns\": \"entry2.example.tld\", \"target\": \"my.cluster.local\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:     \"null input\",\n\t\t\tinput:    \"null\",\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname:     \"empty object\",\n\t\t\tinput:    \"{}\",\n\t\t\texpected: map[string]any{},\n\t\t},\n\t\t{\n\t\t\tname:     \"string value\",\n\t\t\tinput:    `\"hello\"`,\n\t\t\texpected: \"hello\",\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid json\",\n\t\t\tinput:    \"not valid json\",\n\t\t\texpected: nil,\n\t\t},\n\t} {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := fromJson(tt.input)\n\t\t\tassert.Equal(t, tt.expected, result)\n\t\t})\n\t}\n}\n\ntype testObject struct {\n\tmetav1.TypeMeta\n\tmetav1.ObjectMeta\n}\n\nfunc (t *testObject) DeepCopyObject() runtime.Object {\n\treturn &testObject{\n\t\tTypeMeta:   t.TypeMeta,\n\t\tObjectMeta: *t.ObjectMeta.DeepCopy(),\n\t}\n}\n\nfunc TestExecTemplateExecutionError(t *testing.T) {\n\ttmpl, err := ParseTemplate(\"{{ call .Name }}\")\n\trequire.NoError(t, err)\n\n\tobj := &metav1.PartialObjectMetadata{\n\t\tTypeMeta: metav1.TypeMeta{\n\t\t\tKind: \"TestKind\",\n\t\t},\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:      \"test-name\",\n\t\t\tNamespace: \"default\",\n\t\t},\n\t}\n\n\t_, err = ExecTemplate(tmpl, obj)\n\trequire.Error(t, err)\n\tassert.Contains(t, err.Error(), \"failed to apply template on TestKind default/test-name\")\n}\n\nfunc TestCombineWithTemplatedEndpoints(t *testing.T) {\n\t// Create a dummy template for tests that need one\n\tdummyTemplate := template.Must(template.New(\"test\").Parse(\"{{.Name}}\"))\n\n\tannotationEndpoints := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"annotation.example.com\", endpoint.RecordTypeA, \"1.2.3.4\"),\n\t}\n\ttemplatedEndpoints := []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"template.example.com\", endpoint.RecordTypeA, \"5.6.7.8\"),\n\t}\n\n\tsuccessTemplateFunc := func() ([]*endpoint.Endpoint, error) {\n\t\treturn templatedEndpoints, nil\n\t}\n\terrorTemplateFunc := func() ([]*endpoint.Endpoint, error) {\n\t\treturn nil, errors.New(\"template error\")\n\t}\n\n\ttests := []struct {\n\t\tname                  string\n\t\tendpoints             []*endpoint.Endpoint\n\t\tfqdnTemplate          *template.Template\n\t\tcombineFQDNAnnotation bool\n\t\ttemplateFunc          func() ([]*endpoint.Endpoint, error)\n\t\twant                  []*endpoint.Endpoint\n\t\twantErr               bool\n\t}{\n\t\t{\n\t\t\tname:         \"nil template returns original endpoints\",\n\t\t\tendpoints:    annotationEndpoints,\n\t\t\tfqdnTemplate: nil,\n\t\t\ttemplateFunc: successTemplateFunc,\n\t\t\twant:         annotationEndpoints,\n\t\t},\n\t\t{\n\t\t\tname:         \"combine=false with existing endpoints returns original\",\n\t\t\tendpoints:    annotationEndpoints,\n\t\t\tfqdnTemplate: dummyTemplate,\n\t\t\ttemplateFunc: successTemplateFunc,\n\t\t\twant:         annotationEndpoints,\n\t\t},\n\t\t{\n\t\t\tname:         \"combine=false with empty endpoints returns templated\",\n\t\t\tendpoints:    []*endpoint.Endpoint{},\n\t\t\tfqdnTemplate: dummyTemplate,\n\t\t\ttemplateFunc: successTemplateFunc,\n\t\t\twant:         templatedEndpoints,\n\t\t},\n\t\t{\n\t\t\tname:                  \"combine=true appends templated to existing\",\n\t\t\tendpoints:             annotationEndpoints,\n\t\t\tfqdnTemplate:          dummyTemplate,\n\t\t\tcombineFQDNAnnotation: true,\n\t\t\ttemplateFunc:          successTemplateFunc,\n\t\t\twant:                  append(annotationEndpoints, templatedEndpoints...),\n\t\t},\n\t\t{\n\t\t\tname:                  \"combine=true with empty endpoints returns templated\",\n\t\t\tendpoints:             []*endpoint.Endpoint{},\n\t\t\tfqdnTemplate:          dummyTemplate,\n\t\t\tcombineFQDNAnnotation: true,\n\t\t\ttemplateFunc:          successTemplateFunc,\n\t\t\twant:                  templatedEndpoints,\n\t\t},\n\t\t{\n\t\t\tname:         \"template error is propagated\",\n\t\t\tendpoints:    []*endpoint.Endpoint{},\n\t\t\tfqdnTemplate: dummyTemplate,\n\t\t\ttemplateFunc: errorTemplateFunc,\n\t\t\twant:         nil,\n\t\t\twantErr:      true,\n\t\t},\n\t\t{\n\t\t\tname:         \"nil endpoints with combine=false returns templated\",\n\t\t\tendpoints:    nil,\n\t\t\tfqdnTemplate: dummyTemplate,\n\t\t\ttemplateFunc: successTemplateFunc,\n\t\t\twant:         templatedEndpoints,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, err := CombineWithTemplatedEndpoints(\n\t\t\t\ttt.endpoints,\n\t\t\t\ttt.fqdnTemplate,\n\t\t\t\ttt.combineFQDNAnnotation,\n\t\t\t\ttt.templateFunc,\n\t\t\t)\n\t\t\tif tt.wantErr {\n\t\t\t\trequire.Error(t, err)\n\t\t\t\trequire.ErrorContains(t, err, \"failed to get endpoints from template\")\n\t\t\t\treturn\n\t\t\t}\n\t\t\trequire.NoError(t, err)\n\t\t\tassert.Equal(t, tt.want, got)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "source/gateway.go",
    "content": "/*\nCopyright 2021 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"sort\"\n\t\"strings\"\n\t\"text/template\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\tcorev1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\t\"k8s.io/apimachinery/pkg/types\"\n\tkubeinformers \"k8s.io/client-go/informers\"\n\tcoreinformers \"k8s.io/client-go/informers/core/v1\"\n\t\"k8s.io/client-go/tools/cache\"\n\tv1 \"sigs.k8s.io/gateway-api/apis/v1\"\n\t\"sigs.k8s.io/gateway-api/apis/v1beta1\"\n\tgateway \"sigs.k8s.io/gateway-api/pkg/client/clientset/versioned\"\n\tgwinformers \"sigs.k8s.io/gateway-api/pkg/client/informers/externalversions\"\n\tinformers_v1beta1 \"sigs.k8s.io/gateway-api/pkg/client/informers/externalversions/apis/v1beta1\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n\t\"sigs.k8s.io/external-dns/source/fqdn\"\n\t\"sigs.k8s.io/external-dns/source/informers\"\n)\n\nconst (\n\tgatewayGroup                               = \"gateway.networking.k8s.io\"\n\tgatewayKind                                = \"Gateway\"\n\tgatewayHostnameSourceAnnotationOnlyValue   = \"annotation-only\"\n\tgatewayHostnameSourceDefinedHostsOnlyValue = \"defined-hosts-only\"\n)\n\ntype gatewayRoute interface {\n\t// Object returns the underlying route object to be used by templates.\n\tObject() kubeObject\n\t// Metadata returns the route's metadata.\n\tMetadata() *metav1.ObjectMeta\n\t// Hostnames returns the route's specified hostnames.\n\tHostnames() []v1.Hostname\n\t// ParentRefs returns the route's parent references as defined in the route spec.\n\tParentRefs() []v1.ParentReference\n\t// Protocol returns the route's protocol type.\n\tProtocol() v1.ProtocolType\n\t// RouteStatus returns the route's common status.\n\tRouteStatus() v1.RouteStatus\n}\n\ntype newGatewayRouteInformerFunc func(gwinformers.SharedInformerFactory) gatewayRouteInformer\n\ntype gatewayRouteInformer interface {\n\tList(namespace string, selector labels.Selector) ([]gatewayRoute, error)\n\tInformer() cache.SharedIndexInformer\n}\n\nfunc newGatewayInformerFactory(client gateway.Interface, namespace string, labelSelector labels.Selector) gwinformers.SharedInformerFactory {\n\tvar opts []gwinformers.SharedInformerOption\n\tif namespace != \"\" {\n\t\topts = append(opts, gwinformers.WithNamespace(namespace))\n\t}\n\tif labelSelector != nil && !labelSelector.Empty() {\n\t\tlbls := labelSelector.String()\n\t\topts = append(opts, gwinformers.WithTweakListOptions(func(o *metav1.ListOptions) {\n\t\t\to.LabelSelector = lbls\n\t\t}))\n\t}\n\treturn gwinformers.NewSharedInformerFactoryWithOptions(client, 0, opts...)\n}\n\n// gatewayRouteSource is an implementation of Source for Gateway API Route objects.\n//\n// +externaldns:source:name=gateway-httproute\n// +externaldns:source:category=Gateway API\n// +externaldns:source:description=Creates DNS entries from Gateway API HTTPRoute resources\n// +externaldns:source:resources=HTTPRoute.gateway.networking.k8s.io\n// +externaldns:source:filters=annotation,label\n// +externaldns:source:namespace=all,single\n// +externaldns:source:fqdn-template=true\n// +externaldns:source:provider-specific=true\n//\n// +externaldns:source:name=gateway-grpcroute\n// +externaldns:source:category=Gateway API\n// +externaldns:source:description=Creates DNS entries from Gateway API GRPCRoute resources\n// +externaldns:source:resources=GRPCRoute.gateway.networking.k8s.io\n// +externaldns:source:filters=annotation,label\n// +externaldns:source:namespace=all,single\n// +externaldns:source:fqdn-template=true\n// +externaldns:source:provider-specific=true\n//\n// +externaldns:source:name=gateway-tcproute\n// +externaldns:source:category=Gateway API\n// +externaldns:source:description=Creates DNS entries from Gateway API TCPRoute resources\n// +externaldns:source:resources=TCPRoute.gateway.networking.k8s.io\n// +externaldns:source:filters=annotation,label\n// +externaldns:source:namespace=all,single\n// +externaldns:source:fqdn-template=true\n// +externaldns:source:provider-specific=true\n//\n// +externaldns:source:name=gateway-tlsroute\n// +externaldns:source:category=Gateway API\n// +externaldns:source:description=Creates DNS entries from Gateway API TLSRoute resources\n// +externaldns:source:resources=TLSRoute.gateway.networking.k8s.io\n// +externaldns:source:filters=annotation,label\n// +externaldns:source:namespace=all,single\n// +externaldns:source:fqdn-template=true\n// +externaldns:source:provider-specific=true\n//\n// +externaldns:source:name=gateway-udproute\n// +externaldns:source:category=Gateway API\n// +externaldns:source:description=Creates DNS entries from Gateway API UDPRoute resources\n// +externaldns:source:resources=UDPRoute.gateway.networking.k8s.io\n// +externaldns:source:filters=annotation,label\n// +externaldns:source:namespace=all,single\n// +externaldns:source:fqdn-template=true\n// +externaldns:source:provider-specific=true\ntype gatewayRouteSource struct {\n\tgwName      string\n\tgwNamespace string\n\tgwLabels    labels.Selector\n\tgwInformer  informers_v1beta1.GatewayInformer\n\n\trtKind        string\n\trtNamespace   string\n\trtLabels      labels.Selector\n\trtAnnotations labels.Selector\n\trtInformer    gatewayRouteInformer\n\n\tnsInformer coreinformers.NamespaceInformer\n\n\tfqdnTemplate             *template.Template\n\tcombineFQDNAnnotation    bool\n\tignoreHostnameAnnotation bool\n}\n\nfunc newGatewayRouteSource(\n\tctx context.Context,\n\tclients ClientGenerator,\n\tconfig *Config,\n\tkind string,\n\tnewInformerFn newGatewayRouteInformerFunc) (Source, error) {\n\tgwLabels, err := getLabelSelector(config.GatewayLabelFilter)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\trtLabels := config.LabelFilter\n\tif rtLabels == nil {\n\t\trtLabels = labels.Everything()\n\t}\n\trtAnnotations, err := getLabelSelector(config.AnnotationFilter)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ttmpl, err := fqdn.ParseTemplate(config.FQDNTemplate)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tclient, err := clients.GatewayClient()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tinformerFactory := newGatewayInformerFactory(client, config.GatewayNamespace, gwLabels)\n\tgwInformer := informerFactory.Gateway().V1beta1().Gateways() // TODO: Gateway informer should be shared across gateway sources.\n\tgwInformer.Informer()                                        // Register with factory before starting.\n\n\trtInformerFactory := informerFactory\n\tif config.Namespace != config.GatewayNamespace || !selectorsEqual(rtLabels, gwLabels) {\n\t\trtInformerFactory = newGatewayInformerFactory(client, config.Namespace, rtLabels)\n\t}\n\trtInformer := newInformerFn(rtInformerFactory)\n\trtInformer.Informer() // Register with factory before starting.\n\n\tkubeClient, err := clients.KubeClient()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tkubeInformerFactory := kubeinformers.NewSharedInformerFactory(kubeClient, 0)\n\tnsInformer := kubeInformerFactory.Core().V1().Namespaces() // TODO: Namespace informer should be shared across gateway sources.\n\tnsInformer.Informer()                                      // Register with factory before starting.\n\n\tinformerFactory.Start(ctx.Done())\n\tkubeInformerFactory.Start(ctx.Done())\n\tif rtInformerFactory != informerFactory {\n\t\trtInformerFactory.Start(ctx.Done())\n\n\t\tif err := informers.WaitForCacheSync(ctx, rtInformerFactory); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tif err := informers.WaitForCacheSync(ctx, informerFactory); err != nil {\n\t\treturn nil, err\n\t}\n\tif err := informers.WaitForCacheSync(ctx, kubeInformerFactory); err != nil {\n\t\treturn nil, err\n\t}\n\n\tsrc := &gatewayRouteSource{\n\t\tgwName:      config.GatewayName,\n\t\tgwNamespace: config.GatewayNamespace,\n\t\tgwLabels:    gwLabels,\n\t\tgwInformer:  gwInformer,\n\n\t\trtKind:        kind,\n\t\trtNamespace:   config.Namespace,\n\t\trtLabels:      rtLabels,\n\t\trtAnnotations: rtAnnotations,\n\t\trtInformer:    rtInformer,\n\n\t\tnsInformer: nsInformer,\n\n\t\tfqdnTemplate:             tmpl,\n\t\tcombineFQDNAnnotation:    config.CombineFQDNAndAnnotation,\n\t\tignoreHostnameAnnotation: config.IgnoreHostnameAnnotation,\n\t}\n\treturn src, nil\n}\n\nfunc (src *gatewayRouteSource) AddEventHandler(_ context.Context, handler func()) {\n\tlog.Debugf(\"Adding event handlers for %s\", src.rtKind)\n\teventHandler := eventHandlerFunc(handler)\n\t_, _ = src.gwInformer.Informer().AddEventHandler(eventHandler)\n\t_, _ = src.rtInformer.Informer().AddEventHandler(eventHandler)\n\t_, _ = src.nsInformer.Informer().AddEventHandler(eventHandler)\n}\n\nfunc (src *gatewayRouteSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) {\n\tvar endpoints []*endpoint.Endpoint\n\troutes, err := src.rtInformer.List(src.rtNamespace, src.rtLabels)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tgateways, err := src.gwInformer.Lister().Gateways(src.gwNamespace).List(src.gwLabels)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tnamespaces, err := src.nsInformer.Lister().List(labels.Everything())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tkind := strings.ToLower(src.rtKind)\n\tresolver := newGatewayRouteResolver(src, gateways, namespaces)\n\tfor _, rt := range routes {\n\t\t// Filter by annotations.\n\t\tmeta := rt.Metadata()\n\t\tannots := meta.Annotations\n\t\tif !src.rtAnnotations.Matches(labels.Set(annots)) {\n\t\t\tcontinue\n\t\t}\n\n\t\tif annotations.IsControllerMismatch(meta, src.rtKind) {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Get Route hostnames and their targets.\n\t\thostTargets, err := resolver.resolve(rt)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\t// TODO: does not follow the pattern of other sources to log empty hostTargets\n\t\tif len(hostTargets) == 0 {\n\t\t\tlog.Debugf(\"No endpoints could be generated from %s %s/%s\", src.rtKind, meta.Namespace, meta.Name)\n\t\t\tcontinue\n\t\t}\n\n\t\t// Create endpoints from hostnames and targets.\n\t\tvar routeEndpoints []*endpoint.Endpoint\n\t\tresource := fmt.Sprintf(\"%s/%s/%s\", kind, meta.Namespace, meta.Name)\n\t\tproviderSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(annots)\n\t\tttl := annotations.TTLFromAnnotations(annots, resource)\n\t\tfor host, targets := range hostTargets {\n\t\t\trouteEndpoints = append(routeEndpoints, endpoint.EndpointsForHostname(host, targets, ttl, providerSpecific, setIdentifier, resource)...)\n\t\t}\n\t\tlog.Debugf(\"Endpoints generated from %s %s/%s: %v\", src.rtKind, meta.Namespace, meta.Name, routeEndpoints)\n\n\t\tendpoints = append(endpoints, routeEndpoints...)\n\t}\n\treturn MergeEndpoints(endpoints), nil\n}\n\nfunc namespacedName(namespace, name string) types.NamespacedName {\n\treturn types.NamespacedName{Namespace: namespace, Name: name}\n}\n\ntype gatewayRouteResolver struct {\n\tsrc *gatewayRouteSource\n\tgws map[types.NamespacedName]gatewayListeners\n\tnss map[string]*corev1.Namespace\n}\n\ntype gatewayListeners struct {\n\tgateway   *v1beta1.Gateway\n\tlisteners map[v1.SectionName][]v1.Listener\n}\n\nfunc newGatewayRouteResolver(src *gatewayRouteSource, gateways []*v1beta1.Gateway, namespaces []*corev1.Namespace) *gatewayRouteResolver {\n\t// Create Gateway Listener lookup table.\n\tgws := make(map[types.NamespacedName]gatewayListeners, len(gateways))\n\tfor _, gw := range gateways {\n\t\tlss := make(map[v1.SectionName][]v1.Listener, len(gw.Spec.Listeners)+1)\n\t\tfor i, lis := range gw.Spec.Listeners {\n\t\t\tlss[lis.Name] = gw.Spec.Listeners[i : i+1]\n\t\t}\n\t\tlss[\"\"] = gw.Spec.Listeners\n\t\tgws[namespacedName(gw.Namespace, gw.Name)] = gatewayListeners{\n\t\t\tgateway:   gw,\n\t\t\tlisteners: lss,\n\t\t}\n\t}\n\t// Create Namespace lookup table.\n\tnss := make(map[string]*corev1.Namespace, len(namespaces))\n\tfor _, ns := range namespaces {\n\t\tnss[ns.Name] = ns\n\t}\n\treturn &gatewayRouteResolver{\n\t\tsrc: src,\n\t\tgws: gws,\n\t\tnss: nss,\n\t}\n}\n\nfunc (c *gatewayRouteResolver) resolve(rt gatewayRoute) (map[string]endpoint.Targets, error) {\n\trtHosts, err := c.hosts(rt)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\thostTargets := make(map[string]endpoint.Targets)\n\n\trouteParentRefs := rt.ParentRefs()\n\n\tif len(routeParentRefs) == 0 {\n\t\tlog.Debugf(\"No parent references found for %s %s/%s\", c.src.rtKind, rt.Metadata().Namespace, rt.Metadata().Name)\n\t\treturn hostTargets, nil\n\t}\n\n\tmeta := rt.Metadata()\n\tfor _, rps := range rt.RouteStatus().Parents {\n\t\t// Confirm the Parent is the standard Gateway kind.\n\t\tref := rps.ParentRef\n\t\tnamespace := strVal((*string)(ref.Namespace), meta.Namespace)\n\t\t// Ensure that the parent reference is in the routeParentRefs list\n\t\tif !gwRouteHasParentRef(routeParentRefs, ref, meta) {\n\t\t\tlog.Debugf(\"Parent reference %s/%s not found in routeParentRefs for %s %s/%s\", namespace, string(ref.Name), c.src.rtKind, meta.Namespace, meta.Name)\n\t\t\tcontinue\n\t\t}\n\n\t\tgroup := strVal((*string)(ref.Group), gatewayGroup)\n\t\tkind := strVal((*string)(ref.Kind), gatewayKind)\n\t\tif group != gatewayGroup || kind != gatewayKind {\n\t\t\tlog.Debugf(\"Unsupported parent %s/%s for %s %s/%s\", group, kind, c.src.rtKind, meta.Namespace, meta.Name)\n\t\t\tcontinue\n\t\t}\n\t\t// Lookup the Gateway and its Listeners.\n\t\tgw, ok := c.gws[namespacedName(namespace, string(ref.Name))]\n\t\tif !ok {\n\t\t\tlog.Debugf(\"Gateway %s/%s not found for %s %s/%s\", namespace, ref.Name, c.src.rtKind, meta.Namespace, meta.Name)\n\t\t\tcontinue\n\t\t}\n\t\t// Confirm the Gateway has the correct name, if specified.\n\t\tif c.src.gwName != \"\" && c.src.gwName != gw.gateway.Name {\n\t\t\tlog.Debugf(\"Gateway %s/%s does not match %s %s/%s\", namespace, ref.Name, c.src.gwName, meta.Namespace, meta.Name)\n\t\t\tcontinue\n\t\t}\n\n\t\t// Confirm the Gateway has accepted the Route.\n\t\tif !gwRouteIsAccepted(rps.Conditions) {\n\t\t\tlog.Debugf(\"Gateway %s/%s has not accepted the current generation %s %s/%s\", namespace, ref.Name, c.src.rtKind, meta.Namespace, meta.Name)\n\t\t\tcontinue\n\t\t}\n\n\t\t// Match the Route to all possible Listeners.\n\t\tmatch := false\n\t\tsection := sectionVal(ref.SectionName, \"\")\n\t\tlisteners := gw.listeners[section]\n\t\tfor i := range listeners {\n\t\t\tlis := &listeners[i]\n\t\t\t// Confirm that the Listener and Route protocols match.\n\t\t\tif !gwProtocolMatches(rt.Protocol(), lis.Protocol) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t// Confirm that the Listener and Route ports match, if specified.\n\t\t\t// EXPERIMENTAL: https://gateway-api.sigs.k8s.io/geps/gep-957/\n\t\t\tif ref.Port != nil && *ref.Port != lis.Port {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t// Confirm that the Listener allows the Route (based on namespace and kind).\n\t\t\tif !c.routeIsAllowed(gw.gateway, lis, rt) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t// Find all overlapping hostnames between the Route and Listener.\n\t\t\t// For {TCP,UDP}Routes, all annotation-generated hostnames should match since the Listener doesn't specify a hostname.\n\t\t\t// For {HTTP,TLS}Routes, hostnames (including any annotation-generated) will be required to match any Listeners specified hostname.\n\t\t\tgwHost := \"\"\n\t\t\tif lis.Hostname != nil {\n\t\t\t\tgwHost = string(*lis.Hostname)\n\t\t\t}\n\t\t\tfor _, rtHost := range rtHosts {\n\t\t\t\tif gwHost == \"\" && rtHost == \"\" {\n\t\t\t\t\t// For {HTTP,TLS}Routes, this means the Route and the Listener both allow _any_ hostnames.\n\t\t\t\t\t// For {TCP,UDP}Routes, this should always happen since neither specifies hostnames.\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\thost, ok := gwMatchingHost(gwHost, rtHost)\n\t\t\t\tif !ok {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\toverride := annotations.TargetsFromTargetAnnotation(gw.gateway.Annotations)\n\t\t\t\thostTargets[host] = append(hostTargets[host], override...)\n\t\t\t\tif len(override) == 0 {\n\t\t\t\t\tfor _, addr := range gw.gateway.Status.Addresses {\n\t\t\t\t\t\thostTargets[host] = append(hostTargets[host], addr.Value)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tmatch = true\n\t\t\t}\n\t\t}\n\t\tif !match {\n\t\t\tlog.Debugf(\"Gateway %s/%s section %q does not match %s %s/%s hostnames %q\", namespace, ref.Name, section, c.src.rtKind, meta.Namespace, meta.Name, rtHosts)\n\t\t}\n\t}\n\t// If a Gateway has multiple matching Listeners for the same host, then we'll\n\t// add its IPs to the target list multiple times and should dedupe them.\n\tfor host, targets := range hostTargets {\n\t\thostTargets[host] = uniqueTargets(targets)\n\t}\n\treturn hostTargets, nil\n}\n\nfunc (c *gatewayRouteResolver) hosts(rt gatewayRoute) ([]string, error) {\n\tvar hostnames []string\n\tfor _, name := range rt.Hostnames() {\n\t\thostnames = append(hostnames, string(name))\n\t}\n\t// TODO: The combine-fqdn-annotation flag is similarly vague.\n\tif c.src.fqdnTemplate != nil && (len(hostnames) == 0 || c.src.combineFQDNAnnotation) {\n\t\thosts, err := fqdn.ExecTemplate(c.src.fqdnTemplate, rt.Object())\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\thostnames = append(hostnames, hosts...)\n\t}\n\n\thostNameAnnotation, hostNameAnnotationExists := rt.Metadata().Annotations[annotations.GatewayHostnameSourceKey]\n\tif !hostNameAnnotationExists {\n\t\t// This means that the route doesn't specify a hostname and should use any provided by\n\t\t// attached Gateway Listeners. This is only useful for {HTTP,TLS}Routes, but it doesn't\n\t\t// break {TCP,UDP}Routes.\n\t\tif len(rt.Hostnames()) == 0 {\n\t\t\thostnames = append(hostnames, \"\")\n\t\t}\n\t\tif !c.src.ignoreHostnameAnnotation {\n\t\t\thostnames = append(hostnames, annotations.HostnamesFromAnnotations(rt.Metadata().Annotations)...)\n\t\t}\n\t\treturn hostnames, nil\n\t}\n\n\tswitch strings.ToLower(hostNameAnnotation) {\n\tcase gatewayHostnameSourceAnnotationOnlyValue:\n\t\tif c.src.ignoreHostnameAnnotation {\n\t\t\treturn []string{}, nil\n\t\t}\n\t\treturn annotations.HostnamesFromAnnotations(rt.Metadata().Annotations), nil\n\tcase gatewayHostnameSourceDefinedHostsOnlyValue:\n\t\t// Explicitly use only defined hostnames (route spec and optional template result)\n\t\treturn hostnames, nil\n\tdefault:\n\t\t// Invalid value provided: warn and fall back to default behavior (as if the annotation is absent)\n\t\tlog.Warnf(\"Invalid value for %q on %s/%s: %q. Falling back to default behavior.\",\n\t\t\tannotations.GatewayHostnameSourceKey, rt.Metadata().Namespace, rt.Metadata().Name, hostNameAnnotation)\n\t\tif len(rt.Hostnames()) == 0 {\n\t\t\thostnames = append(hostnames, \"\")\n\t\t}\n\t\tif !c.src.ignoreHostnameAnnotation {\n\t\t\thostnames = append(hostnames, annotations.HostnamesFromAnnotations(rt.Metadata().Annotations)...)\n\t\t}\n\t\treturn hostnames, nil\n\t}\n}\n\nfunc (c *gatewayRouteResolver) routeIsAllowed(gw *v1beta1.Gateway, lis *v1.Listener, rt gatewayRoute) bool {\n\tmeta := rt.Metadata()\n\tallow := lis.AllowedRoutes\n\n\t// Check the route's namespace.\n\tfrom := v1.NamespacesFromSame\n\tif allow != nil && allow.Namespaces != nil && allow.Namespaces.From != nil {\n\t\tfrom = *allow.Namespaces.From\n\t}\n\tswitch from {\n\tcase v1.NamespacesFromAll:\n\t\t// OK\n\tcase v1.NamespacesFromSame:\n\t\tif gw.Namespace != meta.Namespace {\n\t\t\treturn false\n\t\t}\n\tcase v1.NamespacesFromSelector:\n\t\tselector, err := metav1.LabelSelectorAsSelector(allow.Namespaces.Selector)\n\t\tif err != nil {\n\t\t\tlog.Debugf(\"Gateway %s/%s section %q has invalid namespace selector: %v\", gw.Namespace, gw.Name, lis.Name, err)\n\t\t\treturn false\n\t\t}\n\t\t// Get namespace.\n\t\tns, ok := c.nss[meta.Namespace]\n\t\tif !ok {\n\t\t\tlog.Errorf(\"Namespace not found for %s %s/%s\", c.src.rtKind, meta.Namespace, meta.Name)\n\t\t\treturn false\n\t\t}\n\t\tif !selector.Matches(labels.Set(ns.Labels)) {\n\t\t\treturn false\n\t\t}\n\tdefault:\n\t\tlog.Debugf(\"Gateway %s/%s section %q has unknown namespace from %q\", gw.Namespace, gw.Name, lis.Name, from)\n\t\treturn false\n\t}\n\n\t// Check the route's kind, if any are specified by the listener.\n\t// TODO: Do we need to consider SupportedKinds in the ListenerStatus instead of the Spec?\n\t// We only support core kinds and already check the protocol... Does this matter at all?\n\tif allow == nil || len(allow.Kinds) == 0 {\n\t\treturn true\n\t}\n\tgvk := rt.Object().GetObjectKind().GroupVersionKind()\n\tfor _, gk := range allow.Kinds {\n\t\tgroup := strVal((*string)(gk.Group), gatewayGroup)\n\t\tif gvk.Group == group && gvk.Kind == string(gk.Kind) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc gwRouteHasParentRef(routeParentRefs []v1.ParentReference, ref v1.ParentReference, meta *metav1.ObjectMeta) bool {\n\t// Ensure that the parent reference is in the routeParentRefs list\n\tnamespace := strVal((*string)(ref.Namespace), meta.Namespace)\n\tgroup := strVal((*string)(ref.Group), gatewayGroup)\n\tkind := strVal((*string)(ref.Kind), gatewayKind)\n\tfor _, rpr := range routeParentRefs {\n\t\trprGroup := strVal((*string)(rpr.Group), gatewayGroup)\n\t\trprKind := strVal((*string)(rpr.Kind), gatewayKind)\n\t\tif rprGroup != group || rprKind != kind {\n\t\t\tcontinue\n\t\t}\n\t\trprNamespace := strVal((*string)(rpr.Namespace), meta.Namespace)\n\t\tif string(rpr.Name) != string(ref.Name) || rprNamespace != namespace {\n\t\t\tcontinue\n\t\t}\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc gwRouteIsAccepted(conds []metav1.Condition) bool {\n\tfor _, c := range conds {\n\t\tif v1.RouteConditionType(c.Type) == v1.RouteConditionAccepted {\n\t\t\treturn c.Status == metav1.ConditionTrue\n\t\t}\n\t}\n\treturn false\n}\n\nfunc uniqueTargets(targets endpoint.Targets) endpoint.Targets {\n\tif len(targets) < 2 {\n\t\treturn targets\n\t}\n\tsort.Strings([]string(targets))\n\tprev := targets[0]\n\tn := 1\n\tfor _, v := range targets[1:] {\n\t\tif v == prev {\n\t\t\tcontinue\n\t\t}\n\t\tprev = v\n\t\ttargets[n] = v\n\t\tn++\n\t}\n\treturn targets[:n]\n}\n\n// gwProtocolMatches returns whether a and b are the same protocol,\n// where HTTP and HTTPS are considered the same.\n// and TLS and TCP are considered the same.\nfunc gwProtocolMatches(a, b v1.ProtocolType) bool {\n\tif a == v1.HTTPSProtocolType {\n\t\ta = v1.HTTPProtocolType\n\t}\n\tif b == v1.HTTPSProtocolType {\n\t\tb = v1.HTTPProtocolType\n\t}\n\t// if Listener is TLS and Route is TCP set Listener type to TCP as to pass true and return valid match\n\tif a == v1.TCPProtocolType && b == v1.TLSProtocolType {\n\t\tb = v1.TCPProtocolType\n\t}\n\treturn a == b\n}\n\n// gwMatchingHost returns the most-specific overlapping host and a bool indicating if one was found.\n// Hostnames that are prefixed with a wildcard label (`*.`) are interpreted as a suffix match.\n// That means that \"*.example.com\" would match both \"test.example.com\" and \"foo.test.example.com\",\n// but not \"example.com\". An empty string matches anything.\nfunc gwMatchingHost(a, b string) (string, bool) {\n\tvar ok bool\n\tif a, ok = gwHost(a); !ok {\n\t\treturn \"\", false\n\t}\n\tif b, ok = gwHost(b); !ok {\n\t\treturn \"\", false\n\t}\n\n\tif a == \"\" {\n\t\treturn b, true\n\t}\n\tif b == \"\" || a == b {\n\t\treturn a, true\n\t}\n\tif na, nb := len(a), len(b); nb < na || (na == nb && strings.HasPrefix(b, \"*.\")) {\n\t\ta, b = b, a\n\t}\n\tif strings.HasPrefix(a, \"*.\") && strings.HasSuffix(b, a[1:]) {\n\t\treturn b, true\n\t}\n\treturn \"\", false\n}\n\n// gwHost returns the canonical host and a value indicating if it's valid.\nfunc gwHost(host string) (string, bool) {\n\tif host == \"\" {\n\t\treturn \"\", true\n\t}\n\tif isIPAddr(host) || !isDNS1123Domain(strings.TrimPrefix(host, \"*.\")) {\n\t\treturn \"\", false\n\t}\n\treturn toLowerCaseASCII(host), true\n}\n\n// isIPAddr returns whether s in an IP address.\nfunc isIPAddr(s string) bool {\n\treturn endpoint.SuitableType(s) != endpoint.RecordTypeCNAME\n}\n\n// isDNS1123Domain returns whether s is a valid domain name according to RFC 1123.\nfunc isDNS1123Domain(s string) bool {\n\tif n := len(s); n == 0 || n > 255 {\n\t\treturn false\n\t}\n\tfor lbl, rest := \"\", s; rest != \"\"; {\n\t\tif lbl, rest, _ = strings.Cut(rest, \".\"); !isDNS1123Label(lbl) {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// isDNS1123Label returns whether s is a valid domain label according to RFC 1123.\nfunc isDNS1123Label(s string) bool {\n\tn := len(s)\n\tif n == 0 || n > 63 {\n\t\treturn false\n\t}\n\tif !isAlphaNum(s[0]) || !isAlphaNum(s[n-1]) {\n\t\treturn false\n\t}\n\tfor i, k := 1, n-1; i < k; i++ {\n\t\tif b := s[i]; b != '-' && !isAlphaNum(b) {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc isAlphaNum(b byte) bool {\n\tswitch {\n\tcase 'a' <= b && b <= 'z',\n\t\t'A' <= b && b <= 'Z',\n\t\t'0' <= b && b <= '9':\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc strVal(ptr *string, def string) string {\n\tif ptr == nil || *ptr == \"\" {\n\t\treturn def\n\t}\n\treturn *ptr\n}\n\nfunc sectionVal(ptr *v1.SectionName, def v1.SectionName) v1.SectionName {\n\tif ptr == nil || *ptr == \"\" {\n\t\treturn def\n\t}\n\treturn *ptr\n}\n\nfunc selectorsEqual(a, b labels.Selector) bool {\n\tif a == nil || b == nil {\n\t\treturn a == b\n\t}\n\taReq, aOK := a.DeepCopySelector().Requirements()\n\tbReq, bOK := b.DeepCopySelector().Requirements()\n\tif aOK != bOK || len(aReq) != len(bReq) {\n\t\treturn false\n\t}\n\tsort.Stable(labels.ByKey(aReq))\n\tsort.Stable(labels.ByKey(bReq))\n\tfor i, r := range aReq {\n\t\tif !r.Equal(bReq[i]) {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n"
  },
  {
    "path": "source/gateway_grpcroute.go",
    "content": "/*\nCopyright 2022 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\tv1 \"sigs.k8s.io/gateway-api/apis/v1\"\n\tinformers \"sigs.k8s.io/gateway-api/pkg/client/informers/externalversions\"\n\tinformers_v1 \"sigs.k8s.io/gateway-api/pkg/client/informers/externalversions/apis/v1\"\n)\n\n// NewGatewayGRPCRouteSource creates a new Gateway GRPCRoute source with the given config.\nfunc NewGatewayGRPCRouteSource(ctx context.Context, clients ClientGenerator, config *Config) (Source, error) {\n\treturn newGatewayRouteSource(ctx, clients, config, \"GRPCRoute\", func(factory informers.SharedInformerFactory) gatewayRouteInformer {\n\t\treturn &gatewayGRPCRouteInformer{factory.Gateway().V1().GRPCRoutes()}\n\t})\n}\n\ntype gatewayGRPCRoute struct{ route v1.GRPCRoute } // NOTE: Must update TypeMeta in List when changing the APIVersion.\n\nfunc (rt *gatewayGRPCRoute) Object() kubeObject               { return &rt.route }\nfunc (rt *gatewayGRPCRoute) Metadata() *metav1.ObjectMeta     { return &rt.route.ObjectMeta }\nfunc (rt *gatewayGRPCRoute) Hostnames() []v1.Hostname         { return rt.route.Spec.Hostnames }\nfunc (rt *gatewayGRPCRoute) ParentRefs() []v1.ParentReference { return rt.route.Spec.ParentRefs }\nfunc (rt *gatewayGRPCRoute) Protocol() v1.ProtocolType        { return v1.HTTPSProtocolType }\nfunc (rt *gatewayGRPCRoute) RouteStatus() v1.RouteStatus      { return rt.route.Status.RouteStatus }\n\ntype gatewayGRPCRouteInformer struct {\n\tinformers_v1.GRPCRouteInformer\n}\n\nfunc (inf gatewayGRPCRouteInformer) List(namespace string, selector labels.Selector) ([]gatewayRoute, error) {\n\tlist, err := inf.GRPCRouteInformer.Lister().GRPCRoutes(namespace).List(selector)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\troutes := make([]gatewayRoute, len(list))\n\tfor i, rt := range list {\n\t\t// List results are supposed to be treated as read-only.\n\t\t// We make a shallow copy since we're only interested in setting the TypeMeta.\n\t\tclone := *rt\n\t\tclone.TypeMeta = metav1.TypeMeta{\n\t\t\tAPIVersion: v1.GroupVersion.String(),\n\t\t\tKind:       \"GRPCRoute\",\n\t\t}\n\t\troutes[i] = &gatewayGRPCRoute{clone}\n\t}\n\treturn routes, nil\n}\n"
  },
  {
    "path": "source/gateway_grpcroute_test.go",
    "content": "/*\nCopyright 2022 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/require\"\n\tcorev1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\tkubefake \"k8s.io/client-go/kubernetes/fake\"\n\tv1 \"sigs.k8s.io/gateway-api/apis/v1\"\n\tv1beta1 \"sigs.k8s.io/gateway-api/apis/v1beta1\"\n\tgatewayfake \"sigs.k8s.io/gateway-api/pkg/client/clientset/versioned/fake\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n)\n\nfunc TestGatewayGRPCRouteSourceEndpoints(t *testing.T) {\n\tt.Parallel()\n\n\tctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)\n\tdefer cancel()\n\n\tgwClient := gatewayfake.NewSimpleClientset()\n\tkubeClient := kubefake.NewClientset()\n\tclients := new(MockClientGenerator)\n\tclients.On(\"GatewayClient\").Return(gwClient, nil)\n\tclients.On(\"KubeClient\").Return(kubeClient, nil)\n\n\tns := &corev1.Namespace{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName: \"default\",\n\t\t},\n\t}\n\t_, err := kubeClient.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{})\n\trequire.NoError(t, err, \"failed to create Namespace\")\n\n\tips := []string{\"10.64.0.1\", \"10.64.0.2\"}\n\tgw := &v1beta1.Gateway{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:      \"internal\",\n\t\t\tNamespace: \"default\",\n\t\t},\n\t\tSpec: v1.GatewaySpec{\n\t\t\tListeners: []v1.Listener{{\n\t\t\t\tProtocol: v1.HTTPSProtocolType,\n\t\t\t}},\n\t\t},\n\t\tStatus: gatewayStatus(ips...),\n\t}\n\t_, err = gwClient.GatewayV1beta1().Gateways(gw.Namespace).Create(ctx, gw, metav1.CreateOptions{})\n\trequire.NoError(t, err, \"failed to create Gateway\")\n\n\trt := &v1.GRPCRoute{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:      \"api\",\n\t\t\tNamespace: \"default\",\n\t\t\tAnnotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"api-annotation.foobar.internal\",\n\t\t\t},\n\t\t},\n\t\tSpec: v1.GRPCRouteSpec{\n\t\t\tHostnames: []v1.Hostname{\"api-hostnames.foobar.internal\"},\n\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\tgwParentRef(\"default\", \"internal\"),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tStatus: v1.GRPCRouteStatus{\n\t\t\tRouteStatus: gwRouteStatus(gwParentRef(\"default\", \"internal\")),\n\t\t},\n\t}\n\t_, err = gwClient.GatewayV1().GRPCRoutes(rt.Namespace).Create(ctx, rt, metav1.CreateOptions{})\n\trequire.NoError(t, err, \"failed to create GRPCRoute\")\n\n\tsrc, err := NewGatewayGRPCRouteSource(ctx, clients, &Config{\n\t\tFQDNTemplate:             \"{{.Name}}-template.foobar.internal\",\n\t\tCombineFQDNAndAnnotation: true,\n\t})\n\trequire.NoError(t, err, \"failed to create Gateway GRPCRoute Source\")\n\n\tendpoints, err := src.Endpoints(ctx)\n\trequire.NoError(t, err, \"failed to get Endpoints\")\n\tvalidateEndpoints(t, endpoints, []*endpoint.Endpoint{\n\t\tnewTestEndpoint(\"api-annotation.foobar.internal\", ips...),\n\t\tnewTestEndpoint(\"api-hostnames.foobar.internal\", ips...),\n\t\tnewTestEndpoint(\"api-template.foobar.internal\", ips...),\n\t})\n}\n"
  },
  {
    "path": "source/gateway_hostname.go",
    "content": "// Copyright 2011 The Go Authors. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license that can be found in the LICENSE file.\n\n// See:\n// \t- https://golang.org/LICENSE\n// \t- https://golang.org/src/crypto/x509/verify.go\n\npackage source\n\nimport (\n\t\"unicode/utf8\"\n)\n\n// TODO: refactor common DNS label functions into a shared package.\n// toLowerCaseASCII returns a lower-case version of in. See RFC 6125 6.4.1. We use\n// an explicitly ASCII function to avoid any sharp corners resulting from\n// performing Unicode operations on DNS labels.\nfunc toLowerCaseASCII(in string) string {\n\t// If the string is already lower-case then there's nothing to do.\n\tisAlreadyLowerCase := true\n\tfor _, c := range in {\n\t\tif c == utf8.RuneError {\n\t\t\t// If we get a UTF-8 error then there might be\n\t\t\t// upper-case ASCII bytes in the invalid sequence.\n\t\t\tisAlreadyLowerCase = false\n\t\t\tbreak\n\t\t}\n\t\tif 'A' <= c && c <= 'Z' {\n\t\t\tisAlreadyLowerCase = false\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif isAlreadyLowerCase {\n\t\treturn in\n\t}\n\n\tout := []byte(in)\n\tfor i, c := range out {\n\t\tif 'A' <= c && c <= 'Z' {\n\t\t\tout[i] += 'a' - 'A'\n\t\t}\n\t}\n\treturn string(out)\n}\n"
  },
  {
    "path": "source/gateway_httproute.go",
    "content": "/*\nCopyright 2021 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\tv1 \"sigs.k8s.io/gateway-api/apis/v1\"\n\tv1beta1 \"sigs.k8s.io/gateway-api/apis/v1beta1\"\n\tinformers \"sigs.k8s.io/gateway-api/pkg/client/informers/externalversions\"\n\tinformers_v1beta1 \"sigs.k8s.io/gateway-api/pkg/client/informers/externalversions/apis/v1beta1\"\n)\n\n// NewGatewayHTTPRouteSource creates a new Gateway HTTPRoute source with the given config.\nfunc NewGatewayHTTPRouteSource(ctx context.Context, clients ClientGenerator, config *Config) (Source, error) {\n\treturn newGatewayRouteSource(ctx, clients, config, \"HTTPRoute\", func(factory informers.SharedInformerFactory) gatewayRouteInformer {\n\t\treturn &gatewayHTTPRouteInformer{factory.Gateway().V1beta1().HTTPRoutes()}\n\t})\n}\n\ntype gatewayHTTPRoute struct{ route v1.HTTPRoute } // NOTE: Must update TypeMeta in List when changing the APIVersion.\n\nfunc (rt *gatewayHTTPRoute) Object() kubeObject               { return &rt.route }\nfunc (rt *gatewayHTTPRoute) Metadata() *metav1.ObjectMeta     { return &rt.route.ObjectMeta }\nfunc (rt *gatewayHTTPRoute) Hostnames() []v1.Hostname         { return rt.route.Spec.Hostnames }\nfunc (rt *gatewayHTTPRoute) ParentRefs() []v1.ParentReference { return rt.route.Spec.ParentRefs }\nfunc (rt *gatewayHTTPRoute) Protocol() v1.ProtocolType        { return v1.HTTPProtocolType }\nfunc (rt *gatewayHTTPRoute) RouteStatus() v1.RouteStatus      { return rt.route.Status.RouteStatus }\n\ntype gatewayHTTPRouteInformer struct {\n\tinformers_v1beta1.HTTPRouteInformer\n}\n\nfunc (inf gatewayHTTPRouteInformer) List(namespace string, selector labels.Selector) ([]gatewayRoute, error) {\n\tlist, err := inf.HTTPRouteInformer.Lister().HTTPRoutes(namespace).List(selector)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\troutes := make([]gatewayRoute, len(list))\n\tfor i, rt := range list {\n\t\t// List results are supposed to be treated as read-only.\n\t\t// We make a shallow copy since we're only interested in setting the TypeMeta.\n\t\tclone := *rt\n\t\tclone.TypeMeta = metav1.TypeMeta{\n\t\t\tAPIVersion: v1beta1.GroupVersion.String(),\n\t\t\tKind:       \"HTTPRoute\",\n\t\t}\n\t\troutes[i] = &gatewayHTTPRoute{v1.HTTPRoute(clone)}\n\t}\n\treturn routes, nil\n}\n"
  },
  {
    "path": "source/gateway_httproute_test.go",
    "content": "/*\nCopyright 2021 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\t\"testing\"\n\t\"time\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/require\"\n\tcorev1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\tkubefake \"k8s.io/client-go/kubernetes/fake\"\n\tv1 \"sigs.k8s.io/gateway-api/apis/v1\"\n\tv1beta1 \"sigs.k8s.io/gateway-api/apis/v1beta1\"\n\tgatewayfake \"sigs.k8s.io/gateway-api/pkg/client/clientset/versioned/fake\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\tlogtest \"sigs.k8s.io/external-dns/internal/testutils/log\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n)\n\nfunc mustGetLabelSelector(s string) labels.Selector {\n\tv, err := getLabelSelector(s)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\treturn v\n}\n\nfunc gatewayStatus(ips ...string) v1.GatewayStatus {\n\ttyp := v1.IPAddressType\n\taddrs := make([]v1.GatewayStatusAddress, len(ips))\n\tfor i, ip := range ips {\n\t\taddrs[i] = v1.GatewayStatusAddress{Type: &typ, Value: ip}\n\t}\n\treturn v1.GatewayStatus{Addresses: addrs}\n}\n\nfunc httpRouteStatus(refs ...v1.ParentReference) v1.HTTPRouteStatus {\n\treturn v1.HTTPRouteStatus{RouteStatus: gwRouteStatus(refs...)}\n}\n\nfunc gwRouteStatus(refs ...v1.ParentReference) v1.RouteStatus {\n\tvar v v1.RouteStatus\n\tfor _, ref := range refs {\n\t\tv.Parents = append(v.Parents, v1.RouteParentStatus{\n\t\t\tParentRef: ref,\n\t\t\tConditions: []metav1.Condition{\n\t\t\t\t{\n\t\t\t\t\tType:   string(v1.RouteConditionAccepted),\n\t\t\t\t\tStatus: metav1.ConditionTrue,\n\t\t\t\t},\n\t\t\t},\n\t\t})\n\t}\n\treturn v\n}\n\nfunc omWithGeneration(meta metav1.ObjectMeta, generation int64) metav1.ObjectMeta {\n\tmeta.Generation = generation\n\treturn meta\n}\n\nfunc rsWithoutAccepted(routeStatus v1.HTTPRouteStatus) v1.HTTPRouteStatus {\n\tfor _, parent := range routeStatus.Parents {\n\t\tfor j := range parent.Conditions {\n\t\t\tcond := &parent.Conditions[j]\n\t\t\tif cond.Type == string(v1.RouteConditionAccepted) {\n\t\t\t\tcond.Type = \"NotAccepted\" // fake type to test for having no accepted condition\n\t\t\t}\n\t\t}\n\t}\n\n\treturn routeStatus\n}\n\nfunc gwParentRef(namespace, name string, options ...gwParentRefOption) v1.ParentReference {\n\tgroup := v1.Group(\"gateway.networking.k8s.io\")\n\tkind := v1.Kind(\"Gateway\")\n\tref := v1.ParentReference{\n\t\tGroup:     &group,\n\t\tKind:      &kind,\n\t\tName:      v1.ObjectName(name),\n\t\tNamespace: (*v1.Namespace)(&namespace),\n\t}\n\tfor _, opt := range options {\n\t\topt(&ref)\n\t}\n\treturn ref\n}\n\ntype gwParentRefOption func(*v1.ParentReference)\n\nfunc withSectionName(name v1.SectionName) gwParentRefOption {\n\treturn func(ref *v1.ParentReference) { ref.SectionName = &name }\n}\n\nfunc withPortNumber(port v1.PortNumber) gwParentRefOption {\n\treturn func(ref *v1.ParentReference) { ref.Port = &port }\n}\n\nfunc newTestEndpoint(dnsName string, targets ...string) *endpoint.Endpoint {\n\treturn newTestEndpointWithTTL(dnsName, endpoint.RecordTypeA, 0, targets...)\n}\n\nfunc newTestEndpointWithTTL(dnsName, recordType string, ttl int64, targets ...string) *endpoint.Endpoint {\n\treturn &endpoint.Endpoint{\n\t\tDNSName:    dnsName,\n\t\tTargets:    append([]string(nil), targets...), // clone targets\n\t\tRecordType: recordType,\n\t\tRecordTTL:  endpoint.TTL(ttl),\n\t}\n}\n\nfunc TestGatewayHTTPRouteSourceEndpoints(t *testing.T) {\n\tfromAll := v1.NamespacesFromAll\n\tfromSame := v1.NamespacesFromSame\n\tfromSelector := v1.NamespacesFromSelector\n\tallowAllNamespaces := &v1.AllowedRoutes{\n\t\tNamespaces: &v1.RouteNamespaces{\n\t\t\tFrom: &fromAll,\n\t\t},\n\t}\n\tobjectMeta := func(namespace, name string) metav1.ObjectMeta {\n\t\treturn metav1.ObjectMeta{\n\t\t\tName:      name,\n\t\t\tNamespace: namespace,\n\t\t}\n\t}\n\tnamespaces := func(names ...string) []*corev1.Namespace {\n\t\tv := make([]*corev1.Namespace, len(names))\n\t\tfor i, name := range names {\n\t\t\tv[i] = &corev1.Namespace{ObjectMeta: objectMeta(\"\", name)}\n\t\t}\n\t\treturn v\n\t}\n\thostnames := func(names ...v1.Hostname) []v1.Hostname { return names }\n\n\ttests := []struct {\n\t\ttitle           string\n\t\tconfig          Config\n\t\tnamespaces      []*corev1.Namespace\n\t\tgateways        []*v1beta1.Gateway\n\t\troutes          []*v1beta1.HTTPRoute\n\t\tendpoints       []*endpoint.Endpoint\n\t\tlogExpectations []string\n\t}{\n\t\t{\n\t\t\ttitle: \"GatewayName\",\n\t\t\tconfig: Config{\n\t\t\t\tGatewayName: \"gateway-name\",\n\t\t\t},\n\t\t\tnamespaces: namespaces(\"gateway-namespace\", \"route-namespace\"),\n\t\t\tgateways: []*v1beta1.Gateway{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: objectMeta(\"gateway-namespace\", \"gateway-name\"),\n\t\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\t\tListeners: []v1.Listener{{\n\t\t\t\t\t\t\tProtocol:      v1.HTTPProtocolType,\n\t\t\t\t\t\t\tAllowedRoutes: allowAllNamespaces,\n\t\t\t\t\t\t}},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: gatewayStatus(\"1.2.3.4\"),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: objectMeta(\"gateway-namespace\", \"not-gateway-name\"),\n\t\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\t\tListeners: []v1.Listener{{\n\t\t\t\t\t\t\tProtocol:      v1.HTTPProtocolType,\n\t\t\t\t\t\t\tAllowedRoutes: allowAllNamespaces,\n\t\t\t\t\t\t}},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: gatewayStatus(\"2.3.4.5\"),\n\t\t\t\t},\n\t\t\t},\n\t\t\troutes: []*v1beta1.HTTPRoute{{\n\t\t\t\tObjectMeta: objectMeta(\"route-namespace\", \"test\"),\n\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\tHostnames: hostnames(\"test.example.internal\"),\n\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\tgwParentRef(\"gateway-namespace\", \"gateway-name\"),\n\t\t\t\t\t\t\tgwParentRef(\"gateway-namespace\", \"not-gateway-name\"),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: httpRouteStatus( // The route is attached to both gateways.\n\t\t\t\t\tgwParentRef(\"gateway-namespace\", \"gateway-name\"),\n\t\t\t\t\tgwParentRef(\"gateway-namespace\", \"not-gateway-name\"),\n\t\t\t\t),\n\t\t\t}},\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tnewTestEndpoint(\"test.example.internal\", \"1.2.3.4\"),\n\t\t\t},\n\t\t\tlogExpectations: []string{\n\t\t\t\t\"Gateway gateway-namespace/not-gateway-name does not match gateway-name route-namespace/test\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"GatewayNameNoneAccepted\",\n\t\t\tconfig: Config{\n\t\t\t\tGatewayName: \"gateway-name\",\n\t\t\t},\n\t\t\tnamespaces: namespaces(\"gateway-namespace\", \"route-namespace\"),\n\t\t\tgateways: []*v1beta1.Gateway{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: omWithGeneration(objectMeta(\"gateway-namespace\", \"gateway-name\"), 2),\n\t\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\t\tListeners: []v1.Listener{{\n\t\t\t\t\t\t\tProtocol:      v1.HTTPProtocolType,\n\t\t\t\t\t\t\tAllowedRoutes: allowAllNamespaces,\n\t\t\t\t\t\t}},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: gatewayStatus(\"1.2.3.4\"),\n\t\t\t\t},\n\t\t\t},\n\t\t\troutes: []*v1beta1.HTTPRoute{{\n\t\t\t\tObjectMeta: omWithGeneration(objectMeta(\"route-namespace\", \"old-test\"), 5),\n\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\tHostnames: hostnames(\"test.example.internal\"),\n\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\tgwParentRef(\"gateway-namespace\", \"gateway-name\"),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: rsWithoutAccepted(httpRouteStatus(gwParentRef(\"gateway-namespace\", \"gateway-name\"))),\n\t\t\t}},\n\t\t\tendpoints: []*endpoint.Endpoint{},\n\t\t\tlogExpectations: []string{\n\t\t\t\t\"Gateway gateway-namespace/gateway-name has not accepted the current generation HTTPRoute route-namespace/old-test\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"GatewayNamespace\",\n\t\t\tconfig: Config{\n\t\t\t\tGatewayNamespace: \"gateway-namespace\",\n\t\t\t},\n\t\t\tnamespaces: namespaces(\"gateway-namespace\", \"not-gateway-namespace\", \"route-namespace\"),\n\t\t\tgateways: []*v1beta1.Gateway{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: objectMeta(\"gateway-namespace\", \"test\"),\n\t\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\t\tListeners: []v1.Listener{{\n\t\t\t\t\t\t\tProtocol:      v1.HTTPProtocolType,\n\t\t\t\t\t\t\tAllowedRoutes: allowAllNamespaces,\n\t\t\t\t\t\t}},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: gatewayStatus(\"1.2.3.4\"),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: objectMeta(\"not-gateway-namespace\", \"test\"),\n\t\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\t\tListeners: []v1.Listener{{Protocol: v1.HTTPProtocolType}},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: gatewayStatus(\"2.3.4.5\"),\n\t\t\t\t},\n\t\t\t},\n\t\t\troutes: []*v1beta1.HTTPRoute{{\n\t\t\t\tObjectMeta: objectMeta(\"route-namespace\", \"test\"),\n\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\tHostnames: hostnames(\"test.example.internal\"),\n\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\tgwParentRef(\"gateway-namespace\", \"test\"),\n\t\t\t\t\t\t\tgwParentRef(\"not-gateway-namespace\", \"test\"),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: httpRouteStatus( // The route is attached to both gateways.\n\t\t\t\t\tgwParentRef(\"gateway-namespace\", \"test\"),\n\t\t\t\t\tgwParentRef(\"not-gateway-namespace\", \"test\"),\n\t\t\t\t),\n\t\t\t}},\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tnewTestEndpoint(\"test.example.internal\", \"1.2.3.4\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"RouteNamespace\",\n\t\t\tconfig: Config{\n\t\t\t\tNamespace: \"route-namespace\",\n\t\t\t},\n\t\t\tnamespaces: namespaces(\"gateway-namespace\", \"route-namespace\", \"not-route-namespace\"),\n\t\t\tgateways: []*v1beta1.Gateway{{\n\t\t\t\tObjectMeta: objectMeta(\"gateway-namespace\", \"test\"),\n\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\tListeners: []v1.Listener{{\n\t\t\t\t\t\tProtocol:      v1.HTTPProtocolType,\n\t\t\t\t\t\tAllowedRoutes: allowAllNamespaces,\n\t\t\t\t\t}},\n\t\t\t\t},\n\t\t\t\tStatus: gatewayStatus(\"1.2.3.4\"),\n\t\t\t}},\n\t\t\troutes: []*v1beta1.HTTPRoute{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: objectMeta(\"route-namespace\", \"test\"),\n\t\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\t\tHostnames: hostnames(\"route-namespace.example.internal\"),\n\t\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\t\tgwParentRef(\"gateway-namespace\", \"test\"),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: httpRouteStatus(gwParentRef(\"gateway-namespace\", \"test\")),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: objectMeta(\"not-route-namespace\", \"test\"),\n\t\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\t\tHostnames: hostnames(\"not-route-namespace.example.internal\"),\n\t\t\t\t\t},\n\t\t\t\t\tStatus: httpRouteStatus(gwParentRef(\"gateway-namespace\", \"test\")),\n\t\t\t\t},\n\t\t\t},\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tnewTestEndpoint(\"route-namespace.example.internal\", \"1.2.3.4\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"GatewayLabelFilter\",\n\t\t\tconfig: Config{\n\t\t\t\tGatewayLabelFilter: \"foo=bar\",\n\t\t\t},\n\t\t\tnamespaces: namespaces(\"default\"),\n\t\t\tgateways: []*v1beta1.Gateway{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"labels-match\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tLabels:    map[string]string{\"foo\": \"bar\"},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\t\tListeners: []v1.Listener{{Protocol: v1.HTTPProtocolType}},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: gatewayStatus(\"1.2.3.4\"),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"labels-dont-match\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tLabels:    map[string]string{\"foo\": \"qux\"},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\t\tListeners: []v1.Listener{{Protocol: v1.HTTPProtocolType}},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: gatewayStatus(\"2.3.4.5\"),\n\t\t\t\t},\n\t\t\t},\n\t\t\troutes: []*v1beta1.HTTPRoute{{\n\t\t\t\tObjectMeta: objectMeta(\"default\", \"test\"),\n\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\tHostnames: hostnames(\"test.example.internal\"),\n\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\tgwParentRef(\"default\", \"labels-match\"),\n\t\t\t\t\t\t\tgwParentRef(\"default\", \"labels-dont-match\"),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: httpRouteStatus( // The route is attached to both gateways.\n\t\t\t\t\tgwParentRef(\"default\", \"labels-match\"),\n\t\t\t\t\tgwParentRef(\"default\", \"labels-dont-match\"),\n\t\t\t\t),\n\t\t\t}},\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tnewTestEndpoint(\"test.example.internal\", \"1.2.3.4\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"RouteLabelFilter\",\n\t\t\tconfig: Config{\n\t\t\t\tLabelFilter: mustGetLabelSelector(\"foo=bar\"),\n\t\t\t},\n\t\t\tnamespaces: namespaces(\"default\"),\n\t\t\tgateways: []*v1beta1.Gateway{{\n\t\t\t\tObjectMeta: objectMeta(\"default\", \"test\"),\n\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\tListeners: []v1.Listener{{Protocol: v1.HTTPProtocolType}},\n\t\t\t\t},\n\t\t\t\tStatus: gatewayStatus(\"1.2.3.4\"),\n\t\t\t}},\n\t\t\troutes: []*v1beta1.HTTPRoute{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"labels-match\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tLabels:    map[string]string{\"foo\": \"bar\"},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\t\tHostnames: hostnames(\"labels-match.example.internal\"),\n\t\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\t\tgwParentRef(\"default\", \"test\"),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: httpRouteStatus(gwParentRef(\"default\", \"test\")),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"labels-dont-match\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tLabels:    map[string]string{\"foo\": \"qux\"},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\t\tHostnames: hostnames(\"labels-dont-match.example.internal\"),\n\t\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\t\tgwParentRef(\"default\", \"test\"),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: httpRouteStatus(gwParentRef(\"default\", \"test\")),\n\t\t\t\t},\n\t\t\t},\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tnewTestEndpoint(\"labels-match.example.internal\", \"1.2.3.4\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"RouteAnnotationFilter\",\n\t\t\tconfig: Config{\n\t\t\t\tAnnotationFilter: \"foo=bar\",\n\t\t\t},\n\t\t\tnamespaces: namespaces(\"default\"),\n\t\t\tgateways: []*v1beta1.Gateway{{\n\t\t\t\tObjectMeta: objectMeta(\"default\", \"test\"),\n\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\tListeners: []v1.Listener{{Protocol: v1.HTTPProtocolType}},\n\t\t\t\t},\n\t\t\t\tStatus: gatewayStatus(\"1.2.3.4\"),\n\t\t\t}},\n\t\t\troutes: []*v1beta1.HTTPRoute{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:        \"annotations-match\",\n\t\t\t\t\t\tNamespace:   \"default\",\n\t\t\t\t\t\tAnnotations: map[string]string{\"foo\": \"bar\"},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\t\tHostnames: hostnames(\"annotations-match.example.internal\"),\n\t\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\t\tgwParentRef(\"default\", \"test\"),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: httpRouteStatus(gwParentRef(\"default\", \"test\")),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:        \"annotations-dont-match\",\n\t\t\t\t\t\tNamespace:   \"default\",\n\t\t\t\t\t\tAnnotations: map[string]string{\"foo\": \"qux\"},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\t\tHostnames: hostnames(\"annotations-dont-match.example.internal\"),\n\t\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\t\tgwParentRef(\"default\", \"test\"),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: httpRouteStatus(gwParentRef(\"default\", \"test\")),\n\t\t\t\t},\n\t\t\t},\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tnewTestEndpoint(\"annotations-match.example.internal\", \"1.2.3.4\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:      \"SkipControllerAnnotation\",\n\t\t\tconfig:     Config{},\n\t\t\tnamespaces: namespaces(\"default\"),\n\t\t\tgateways: []*v1beta1.Gateway{{\n\t\t\t\tObjectMeta: objectMeta(\"default\", \"test\"),\n\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\tListeners: []v1.Listener{{Protocol: v1.HTTPProtocolType}},\n\t\t\t\t},\n\t\t\t\tStatus: gatewayStatus(\"1.2.3.4\"),\n\t\t\t}},\n\t\t\troutes: []*v1beta1.HTTPRoute{{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"api\",\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\tannotations.ControllerKey: \"something-else\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\tgwParentRef(\"default\", \"test\"),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tHostnames: hostnames(\"api.example.internal\"),\n\t\t\t\t},\n\t\t\t\tStatus: httpRouteStatus(gwParentRef(\"default\", \"test\")),\n\t\t\t}},\n\t\t\tendpoints: nil,\n\t\t},\n\t\t{\n\t\t\ttitle:      \"MultipleGateways\",\n\t\t\tconfig:     Config{},\n\t\t\tnamespaces: namespaces(\"default\"),\n\t\t\tgateways: []*v1beta1.Gateway{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: objectMeta(\"default\", \"one\"),\n\t\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\t\tListeners: []v1.Listener{{Protocol: v1.HTTPProtocolType}},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: gatewayStatus(\"1.2.3.4\"),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: objectMeta(\"default\", \"two\"),\n\t\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\t\tListeners: []v1.Listener{{Protocol: v1.HTTPProtocolType}},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: gatewayStatus(\"2.3.4.5\"),\n\t\t\t\t},\n\t\t\t},\n\t\t\troutes: []*v1beta1.HTTPRoute{{\n\t\t\t\tObjectMeta: objectMeta(\"default\", \"test\"),\n\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\tHostnames: hostnames(\"test.example.internal\"),\n\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\tgwParentRef(\"default\", \"one\"),\n\t\t\t\t\t\t\tgwParentRef(\"default\", \"two\"),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: httpRouteStatus(\n\t\t\t\t\tgwParentRef(\"default\", \"one\"),\n\t\t\t\t\tgwParentRef(\"default\", \"two\"),\n\t\t\t\t),\n\t\t\t}},\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tnewTestEndpoint(\"test.example.internal\", \"1.2.3.4\", \"2.3.4.5\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:      \"MultipleListeners\",\n\t\t\tconfig:     Config{},\n\t\t\tnamespaces: namespaces(\"default\"),\n\t\t\tgateways: []*v1beta1.Gateway{{\n\t\t\t\tObjectMeta: objectMeta(\"default\", \"one\"),\n\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\tListeners: []v1.Listener{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:     \"foo\",\n\t\t\t\t\t\t\tProtocol: v1.HTTPProtocolType,\n\t\t\t\t\t\t\tHostname: hostnamePtr(\"foo.example.internal\"),\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:     \"bar\",\n\t\t\t\t\t\t\tProtocol: v1.HTTPProtocolType,\n\t\t\t\t\t\t\tHostname: hostnamePtr(\"bar.example.internal\"),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: gatewayStatus(\"1.2.3.4\"),\n\t\t\t}},\n\t\t\troutes: []*v1beta1.HTTPRoute{{\n\t\t\t\tObjectMeta: objectMeta(\"default\", \"test\"),\n\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\tHostnames: hostnames(\"*.example.internal\"),\n\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\tgwParentRef(\"default\", \"one\"),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: httpRouteStatus(\n\t\t\t\t\tgwParentRef(\"default\", \"one\"),\n\t\t\t\t),\n\t\t\t}},\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tnewTestEndpoint(\"foo.example.internal\", \"1.2.3.4\"),\n\t\t\t\tnewTestEndpoint(\"bar.example.internal\", \"1.2.3.4\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:      \"SectionNameMatch\",\n\t\t\tconfig:     Config{},\n\t\t\tnamespaces: namespaces(\"default\"),\n\t\t\tgateways: []*v1beta1.Gateway{{\n\t\t\t\tObjectMeta: objectMeta(\"default\", \"test\"),\n\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\tListeners: []v1.Listener{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:     \"foo\",\n\t\t\t\t\t\t\tProtocol: v1.HTTPProtocolType,\n\t\t\t\t\t\t\tHostname: hostnamePtr(\"foo.example.internal\"),\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:     \"bar\",\n\t\t\t\t\t\t\tProtocol: v1.HTTPProtocolType,\n\t\t\t\t\t\t\tHostname: hostnamePtr(\"bar.example.internal\"),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: gatewayStatus(\"1.2.3.4\"),\n\t\t\t}},\n\t\t\troutes: []*v1beta1.HTTPRoute{{\n\t\t\t\tObjectMeta: objectMeta(\"default\", \"test\"),\n\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\tHostnames: hostnames(\"*.example.internal\"),\n\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\tgwParentRef(\"default\", \"test\", withSectionName(\"foo\")),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: httpRouteStatus(\n\t\t\t\t\tgwParentRef(\"default\", \"test\", withSectionName(\"foo\")),\n\t\t\t\t),\n\t\t\t}},\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tnewTestEndpoint(\"foo.example.internal\", \"1.2.3.4\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t// EXPERIMENTAL: https://gateway-api.sigs.k8s.io/geps/gep-957/\n\t\t\ttitle:      \"PortNumberMatch\",\n\t\t\tconfig:     Config{},\n\t\t\tnamespaces: namespaces(\"default\"),\n\t\t\tgateways: []*v1beta1.Gateway{{\n\t\t\t\tObjectMeta: objectMeta(\"default\", \"test\"),\n\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\tListeners: []v1.Listener{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:     \"foo\",\n\t\t\t\t\t\t\tProtocol: v1.HTTPProtocolType,\n\t\t\t\t\t\t\tHostname: hostnamePtr(\"foo.example.internal\"),\n\t\t\t\t\t\t\tPort:     80,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:     \"bar\",\n\t\t\t\t\t\t\tProtocol: v1.HTTPProtocolType,\n\t\t\t\t\t\t\tHostname: hostnamePtr(\"bar.example.internal\"),\n\t\t\t\t\t\t\tPort:     80,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:     \"qux\",\n\t\t\t\t\t\t\tProtocol: v1.HTTPProtocolType,\n\t\t\t\t\t\t\tHostname: hostnamePtr(\"qux.example.internal\"),\n\t\t\t\t\t\t\tPort:     8080,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: gatewayStatus(\"1.2.3.4\"),\n\t\t\t}},\n\t\t\troutes: []*v1beta1.HTTPRoute{{\n\t\t\t\tObjectMeta: objectMeta(\"default\", \"test\"),\n\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\tHostnames: hostnames(\"*.example.internal\"),\n\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\tgwParentRef(\"default\", \"test\", withPortNumber(80)),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: httpRouteStatus(\n\t\t\t\t\tgwParentRef(\"default\", \"test\", withPortNumber(80)),\n\t\t\t\t),\n\t\t\t}},\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tnewTestEndpoint(\"foo.example.internal\", \"1.2.3.4\"),\n\t\t\t\tnewTestEndpoint(\"bar.example.internal\", \"1.2.3.4\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:      \"WildcardInGateway\",\n\t\t\tconfig:     Config{},\n\t\t\tnamespaces: namespaces(\"default\"),\n\t\t\tgateways: []*v1beta1.Gateway{{\n\t\t\t\tObjectMeta: objectMeta(\"default\", \"test\"),\n\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\tListeners: []v1.Listener{{\n\t\t\t\t\t\tProtocol: v1.HTTPProtocolType,\n\t\t\t\t\t\tHostname: hostnamePtr(\"*.example.internal\"),\n\t\t\t\t\t}},\n\t\t\t\t},\n\t\t\t\tStatus: gatewayStatus(\"1.2.3.4\"),\n\t\t\t}},\n\t\t\troutes: []*v1beta1.HTTPRoute{{\n\t\t\t\tObjectMeta: objectMeta(\"default\", \"no-hostname\"),\n\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\tgwParentRef(\"default\", \"test\"),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tHostnames: []v1.Hostname{\n\t\t\t\t\t\t\"foo.example.internal\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: httpRouteStatus(gwParentRef(\"default\", \"test\")),\n\t\t\t}},\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tnewTestEndpoint(\"foo.example.internal\", \"1.2.3.4\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:      \"WildcardInRoute\",\n\t\t\tconfig:     Config{},\n\t\t\tnamespaces: namespaces(\"default\"),\n\t\t\tgateways: []*v1beta1.Gateway{{\n\t\t\t\tObjectMeta: objectMeta(\"default\", \"test\"),\n\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\tListeners: []v1.Listener{{\n\t\t\t\t\t\tProtocol: v1.HTTPProtocolType,\n\t\t\t\t\t\tHostname: hostnamePtr(\"foo.example.internal\"),\n\t\t\t\t\t}},\n\t\t\t\t},\n\t\t\t\tStatus: gatewayStatus(\"1.2.3.4\"),\n\t\t\t}},\n\t\t\troutes: []*v1beta1.HTTPRoute{{\n\t\t\t\tObjectMeta: objectMeta(\"default\", \"no-hostname\"),\n\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\tgwParentRef(\"default\", \"test\"),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tHostnames: []v1.Hostname{\n\t\t\t\t\t\t\"*.example.internal\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: httpRouteStatus(gwParentRef(\"default\", \"test\")),\n\t\t\t}},\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tnewTestEndpoint(\"foo.example.internal\", \"1.2.3.4\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:      \"WildcardInRouteAndGateway\",\n\t\t\tconfig:     Config{},\n\t\t\tnamespaces: namespaces(\"default\"),\n\t\t\tgateways: []*v1beta1.Gateway{{\n\t\t\t\tObjectMeta: objectMeta(\"default\", \"test\"),\n\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\tListeners: []v1.Listener{{\n\t\t\t\t\t\tProtocol: v1.HTTPProtocolType,\n\t\t\t\t\t\tHostname: hostnamePtr(\"*.example.internal\"),\n\t\t\t\t\t}},\n\t\t\t\t},\n\t\t\t\tStatus: gatewayStatus(\"1.2.3.4\"),\n\t\t\t}},\n\t\t\troutes: []*v1beta1.HTTPRoute{{\n\t\t\t\tObjectMeta: objectMeta(\"default\", \"no-hostname\"),\n\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\tgwParentRef(\"default\", \"test\"),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tHostnames: []v1.Hostname{\n\t\t\t\t\t\t\"*.example.internal\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: httpRouteStatus(gwParentRef(\"default\", \"test\")),\n\t\t\t}},\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tnewTestEndpoint(\"*.example.internal\", \"1.2.3.4\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:      \"NoRouteHostname\",\n\t\t\tconfig:     Config{},\n\t\t\tnamespaces: namespaces(\"default\"),\n\t\t\tgateways: []*v1beta1.Gateway{{\n\t\t\t\tObjectMeta: objectMeta(\"default\", \"test\"),\n\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\tListeners: []v1.Listener{{\n\t\t\t\t\t\tProtocol: v1.HTTPProtocolType,\n\t\t\t\t\t\tHostname: hostnamePtr(\"foo.example.internal\"),\n\t\t\t\t\t}},\n\t\t\t\t},\n\t\t\t\tStatus: gatewayStatus(\"1.2.3.4\"),\n\t\t\t}},\n\t\t\troutes: []*v1beta1.HTTPRoute{{\n\t\t\t\tObjectMeta: objectMeta(\"default\", \"no-hostname\"),\n\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\tgwParentRef(\"default\", \"test\"),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tHostnames: nil,\n\t\t\t\t},\n\t\t\t\tStatus: httpRouteStatus(gwParentRef(\"default\", \"test\")),\n\t\t\t}},\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tnewTestEndpoint(\"foo.example.internal\", \"1.2.3.4\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:      \"NoGateways\",\n\t\t\tconfig:     Config{},\n\t\t\tnamespaces: namespaces(\"default\"),\n\t\t\tgateways:   nil,\n\t\t\troutes: []*v1beta1.HTTPRoute{{\n\t\t\t\tObjectMeta: objectMeta(\"default\", \"test\"),\n\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\tHostnames: hostnames(\"example.internal\"),\n\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\tParentRefs: []v1.ParentReference{},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: httpRouteStatus(),\n\t\t\t}},\n\t\t\tendpoints: nil,\n\t\t},\n\t\t{\n\t\t\ttitle:      \"NoHostnames\",\n\t\t\tconfig:     Config{},\n\t\t\tnamespaces: namespaces(\"default\"),\n\t\t\tgateways: []*v1beta1.Gateway{{\n\t\t\t\tObjectMeta: objectMeta(\"default\", \"test\"),\n\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\tListeners: []v1.Listener{{Protocol: v1.HTTPProtocolType}},\n\t\t\t\t},\n\t\t\t\tStatus: gatewayStatus(\"1.2.3.4\"),\n\t\t\t}},\n\t\t\troutes: []*v1beta1.HTTPRoute{{\n\t\t\t\tObjectMeta: objectMeta(\"default\", \"no-hostname\"),\n\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\tgwParentRef(\"default\", \"test\"),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tHostnames: nil,\n\t\t\t\t},\n\t\t\t\tStatus: httpRouteStatus(gwParentRef(\"default\", \"test\")),\n\t\t\t}},\n\t\t\tendpoints: nil,\n\t\t},\n\t\t{\n\t\t\ttitle:      \"HostnameAnnotation\",\n\t\t\tconfig:     Config{},\n\t\t\tnamespaces: namespaces(\"default\"),\n\t\t\tgateways: []*v1beta1.Gateway{{\n\t\t\t\tObjectMeta: objectMeta(\"default\", \"test\"),\n\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\tListeners: []v1.Listener{{Protocol: v1.HTTPProtocolType}},\n\t\t\t\t},\n\t\t\t\tStatus: gatewayStatus(\"1.2.3.4\"),\n\t\t\t}},\n\t\t\troutes: []*v1beta1.HTTPRoute{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"without-hostame\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.HostnameKey: \"annotation.without-hostname.internal\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\t\tgwParentRef(\"default\", \"test\"),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\tHostnames: nil,\n\t\t\t\t\t},\n\t\t\t\t\tStatus: httpRouteStatus(gwParentRef(\"default\", \"test\")),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"with-hostame\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.HostnameKey: \"annotation.with-hostname.internal\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\t\tgwParentRef(\"default\", \"test\"),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\tHostnames: hostnames(\"with-hostname.internal\"),\n\t\t\t\t\t},\n\t\t\t\t\tStatus: httpRouteStatus(gwParentRef(\"default\", \"test\")),\n\t\t\t\t},\n\t\t\t},\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tnewTestEndpoint(\"annotation.without-hostname.internal\", \"1.2.3.4\"),\n\t\t\t\tnewTestEndpoint(\"annotation.with-hostname.internal\", \"1.2.3.4\"),\n\t\t\t\tnewTestEndpoint(\"with-hostname.internal\", \"1.2.3.4\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"IgnoreHostnameAnnotation\",\n\t\t\tconfig: Config{\n\t\t\t\tIgnoreHostnameAnnotation: true,\n\t\t\t},\n\t\t\tnamespaces: namespaces(\"default\"),\n\t\t\tgateways: []*v1beta1.Gateway{{\n\t\t\t\tObjectMeta: objectMeta(\"default\", \"test\"),\n\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\tListeners: []v1.Listener{{Protocol: v1.HTTPProtocolType}},\n\t\t\t\t},\n\t\t\t\tStatus: gatewayStatus(\"1.2.3.4\"),\n\t\t\t}},\n\t\t\troutes: []*v1beta1.HTTPRoute{{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"with-hostame\",\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\tannotations.HostnameKey: \"annotation.with-hostname.internal\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\tHostnames: hostnames(\"with-hostname.internal\"),\n\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\tgwParentRef(\"default\", \"test\"),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: httpRouteStatus(gwParentRef(\"default\", \"test\")),\n\t\t\t}},\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tnewTestEndpoint(\"with-hostname.internal\", \"1.2.3.4\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"FQDNTemplate\",\n\t\t\tconfig: Config{\n\t\t\t\tFQDNTemplate: \"{{.Name}}.zero.internal, {{.Name}}.one.internal. ,  {{.Name}}.two.internal  \",\n\t\t\t},\n\t\t\tnamespaces: namespaces(\"default\"),\n\t\t\tgateways: []*v1beta1.Gateway{{\n\t\t\t\tObjectMeta: objectMeta(\"default\", \"test\"),\n\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\tListeners: []v1.Listener{{Protocol: v1.HTTPProtocolType}},\n\t\t\t\t},\n\t\t\t\tStatus: gatewayStatus(\"1.2.3.4\"),\n\t\t\t}},\n\t\t\troutes: []*v1beta1.HTTPRoute{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: objectMeta(\"default\", \"fqdn-with-hostnames\"),\n\t\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\t\tHostnames: hostnames(\"fqdn-with-hostnames.internal\"),\n\t\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\t\tgwParentRef(\"default\", \"test\"),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: httpRouteStatus(gwParentRef(\"default\", \"test\")),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: objectMeta(\"default\", \"fqdn-without-hostnames\"),\n\t\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\t\tgwParentRef(\"default\", \"test\"),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\tHostnames: nil,\n\t\t\t\t\t},\n\t\t\t\t\tStatus: httpRouteStatus(gwParentRef(\"default\", \"test\")),\n\t\t\t\t},\n\t\t\t},\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tnewTestEndpoint(\"fqdn-without-hostnames.zero.internal\", \"1.2.3.4\"),\n\t\t\t\tnewTestEndpoint(\"fqdn-without-hostnames.one.internal\", \"1.2.3.4\"),\n\t\t\t\tnewTestEndpoint(\"fqdn-without-hostnames.two.internal\", \"1.2.3.4\"),\n\t\t\t\tnewTestEndpoint(\"fqdn-with-hostnames.internal\", \"1.2.3.4\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"CombineFQDN\",\n\t\t\tconfig: Config{\n\t\t\t\tFQDNTemplate:             \"combine-{{.Name}}.internal\",\n\t\t\t\tCombineFQDNAndAnnotation: true,\n\t\t\t},\n\t\t\tnamespaces: namespaces(\"default\"),\n\t\t\tgateways: []*v1beta1.Gateway{{\n\t\t\t\tObjectMeta: objectMeta(\"default\", \"test\"),\n\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\tListeners: []v1.Listener{{Protocol: v1.HTTPProtocolType}},\n\t\t\t\t},\n\t\t\t\tStatus: gatewayStatus(\"1.2.3.4\"),\n\t\t\t}},\n\t\t\troutes: []*v1beta1.HTTPRoute{{\n\t\t\t\tObjectMeta: objectMeta(\"default\", \"fqdn-with-hostnames\"),\n\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\tgwParentRef(\"default\", \"test\"),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tHostnames: hostnames(\"fqdn-with-hostnames.internal\"),\n\t\t\t\t},\n\t\t\t\tStatus: httpRouteStatus(gwParentRef(\"default\", \"test\")),\n\t\t\t}},\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tnewTestEndpoint(\"fqdn-with-hostnames.internal\", \"1.2.3.4\"),\n\t\t\t\tnewTestEndpoint(\"combine-fqdn-with-hostnames.internal\", \"1.2.3.4\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:      \"TTL\",\n\t\t\tconfig:     Config{},\n\t\t\tnamespaces: namespaces(\"default\"),\n\t\t\tgateways: []*v1beta1.Gateway{{\n\t\t\t\tObjectMeta: objectMeta(\"default\", \"test\"),\n\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\tListeners: []v1.Listener{{Protocol: v1.HTTPProtocolType}},\n\t\t\t\t},\n\t\t\t\tStatus: gatewayStatus(\"1.2.3.4\"),\n\t\t\t}},\n\t\t\troutes: []*v1beta1.HTTPRoute{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:        \"valid-ttl\",\n\t\t\t\t\t\tNamespace:   \"default\",\n\t\t\t\t\t\tAnnotations: map[string]string{annotations.TtlKey: \"15s\"},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\t\tHostnames: hostnames(\"valid-ttl.internal\"),\n\t\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\t\tgwParentRef(\"default\", \"test\"),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: httpRouteStatus(gwParentRef(\"default\", \"test\")),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:        \"invalid-ttl\",\n\t\t\t\t\t\tNamespace:   \"default\",\n\t\t\t\t\t\tAnnotations: map[string]string{annotations.TtlKey: \"abc\"},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\t\tHostnames: hostnames(\"invalid-ttl.internal\"),\n\t\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\t\tgwParentRef(\"default\", \"test\"),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: httpRouteStatus(gwParentRef(\"default\", \"test\")),\n\t\t\t\t},\n\t\t\t},\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tnewTestEndpoint(\"invalid-ttl.internal\", \"1.2.3.4\"),\n\t\t\t\tnewTestEndpointWithTTL(\"valid-ttl.internal\", endpoint.RecordTypeA, 15, \"1.2.3.4\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:      \"ProviderAnnotations\",\n\t\t\tconfig:     Config{},\n\t\t\tnamespaces: namespaces(\"default\"),\n\t\t\tgateways: []*v1beta1.Gateway{{\n\t\t\t\tObjectMeta: objectMeta(\"default\", \"test\"),\n\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\tListeners: []v1.Listener{{Protocol: v1.HTTPProtocolType}},\n\t\t\t\t},\n\t\t\t\tStatus: gatewayStatus(\"1.2.3.4\"),\n\t\t\t}},\n\t\t\troutes: []*v1beta1.HTTPRoute{{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"provider-annotations\",\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\tannotations.SetIdentifierKey: \"test-set-identifier\",\n\t\t\t\t\t\tannotations.AliasKey:         \"true\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\tgwParentRef(\"default\", \"test\"),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tHostnames: hostnames(\"provider-annotations.com\"),\n\t\t\t\t},\n\t\t\t\tStatus: httpRouteStatus(gwParentRef(\"default\", \"test\")),\n\t\t\t}},\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tnewTestEndpoint(\"provider-annotations.com\", \"1.2.3.4\").\n\t\t\t\t\tWithProviderSpecific(\"alias\", \"true\").\n\t\t\t\t\tWithSetIdentifier(\"test-set-identifier\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:      \"DifferentHostnameDifferentGateway\",\n\t\t\tconfig:     Config{},\n\t\t\tnamespaces: namespaces(\"default\"),\n\t\t\tgateways: []*v1beta1.Gateway{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: objectMeta(\"default\", \"one\"),\n\t\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\t\tListeners: []v1.Listener{{\n\t\t\t\t\t\t\tHostname: hostnamePtr(\"*.one.internal\"),\n\t\t\t\t\t\t\tProtocol: v1.HTTPProtocolType,\n\t\t\t\t\t\t}},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: gatewayStatus(\"1.2.3.4\"),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: objectMeta(\"default\", \"two\"),\n\t\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\t\tListeners: []v1.Listener{{\n\t\t\t\t\t\t\tHostname: hostnamePtr(\"*.two.internal\"),\n\t\t\t\t\t\t\tProtocol: v1.HTTPProtocolType,\n\t\t\t\t\t\t}},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: gatewayStatus(\"2.3.4.5\"),\n\t\t\t\t},\n\t\t\t},\n\t\t\troutes: []*v1beta1.HTTPRoute{{\n\t\t\t\tObjectMeta: objectMeta(\"default\", \"test\"),\n\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\tgwParentRef(\"default\", \"one\"),\n\t\t\t\t\t\t\tgwParentRef(\"default\", \"two\"),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tHostnames: hostnames(\"test.one.internal\", \"test.two.internal\"),\n\t\t\t\t},\n\t\t\t\tStatus: httpRouteStatus(\n\t\t\t\t\tgwParentRef(\"default\", \"one\"),\n\t\t\t\t\tgwParentRef(\"default\", \"two\"),\n\t\t\t\t),\n\t\t\t}},\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tnewTestEndpoint(\"test.one.internal\", \"1.2.3.4\"),\n\t\t\t\tnewTestEndpoint(\"test.two.internal\", \"2.3.4.5\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:      \"AllowedRoutesSameNamespace\",\n\t\t\tconfig:     Config{},\n\t\t\tnamespaces: namespaces(\"same-namespace\", \"other-namespace\"),\n\t\t\tgateways: []*v1beta1.Gateway{{\n\t\t\t\tObjectMeta: objectMeta(\"same-namespace\", \"test\"),\n\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\tListeners: []v1.Listener{{\n\t\t\t\t\t\tProtocol: v1.HTTPProtocolType,\n\t\t\t\t\t\tAllowedRoutes: &v1.AllowedRoutes{\n\t\t\t\t\t\t\tNamespaces: &v1.RouteNamespaces{\n\t\t\t\t\t\t\t\tFrom: &fromSame,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t}},\n\t\t\t\t},\n\t\t\t\tStatus: gatewayStatus(\"1.2.3.4\"),\n\t\t\t}},\n\t\t\troutes: []*v1beta1.HTTPRoute{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: objectMeta(\"same-namespace\", \"test\"),\n\t\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\t\tHostnames: hostnames(\"same-namespace.example.internal\"),\n\t\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\t\tgwParentRef(\"same-namespace\", \"test\"),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: httpRouteStatus(gwParentRef(\"same-namespace\", \"test\")),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: objectMeta(\"other-namespace\", \"test\"),\n\t\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\t\tHostnames: hostnames(\"other-namespace.example.internal\"),\n\t\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\t\tgwParentRef(\"same-namespace\", \"test\"),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: httpRouteStatus(gwParentRef(\"same-namespace\", \"test\")),\n\t\t\t\t},\n\t\t\t},\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tnewTestEndpoint(\"same-namespace.example.internal\", \"1.2.3.4\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:  \"AllowedRoutesNamespaceSelector\",\n\t\t\tconfig: Config{},\n\t\t\tnamespaces: []*corev1.Namespace{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName: \"default\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:   \"foo\",\n\t\t\t\t\t\tLabels: map[string]string{\"team\": \"foo\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:   \"bar\",\n\t\t\t\t\t\tLabels: map[string]string{\"team\": \"bar\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tgateways: []*v1beta1.Gateway{{\n\t\t\t\tObjectMeta: objectMeta(\"default\", \"test\"),\n\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\tListeners: []v1.Listener{{\n\t\t\t\t\t\tProtocol: v1.HTTPProtocolType,\n\t\t\t\t\t\tAllowedRoutes: &v1.AllowedRoutes{\n\t\t\t\t\t\t\tNamespaces: &v1.RouteNamespaces{\n\t\t\t\t\t\t\t\tFrom: &fromSelector,\n\t\t\t\t\t\t\t\tSelector: &metav1.LabelSelector{\n\t\t\t\t\t\t\t\t\tMatchLabels: map[string]string{\"team\": \"foo\"},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t}},\n\t\t\t\t},\n\t\t\t\tStatus: gatewayStatus(\"1.2.3.4\"),\n\t\t\t}},\n\t\t\troutes: []*v1beta1.HTTPRoute{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: objectMeta(\"foo\", \"test\"),\n\t\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\t\tHostnames: hostnames(\"foo.example.internal\"),\n\t\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\t\tgwParentRef(\"default\", \"test\"),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: httpRouteStatus(gwParentRef(\"default\", \"test\")),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: objectMeta(\"bar\", \"test\"),\n\t\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\t\tHostnames: hostnames(\"bar.example.internal\"),\n\t\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\t\tgwParentRef(\"default\", \"test\"),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: httpRouteStatus(gwParentRef(\"default\", \"test\")),\n\t\t\t\t},\n\t\t\t},\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tnewTestEndpoint(\"foo.example.internal\", \"1.2.3.4\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:      \"MissingNamespace\",\n\t\t\tconfig:     Config{},\n\t\t\tnamespaces: nil,\n\t\t\tgateways: []*v1beta1.Gateway{{\n\t\t\t\tObjectMeta: objectMeta(\"default\", \"test\"),\n\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\tListeners: []v1.Listener{{\n\t\t\t\t\t\tProtocol: v1.HTTPProtocolType,\n\t\t\t\t\t\tAllowedRoutes: &v1.AllowedRoutes{\n\t\t\t\t\t\t\tNamespaces: &v1.RouteNamespaces{\n\t\t\t\t\t\t\t\t// Namespace selector triggers namespace lookup.\n\t\t\t\t\t\t\t\tFrom: &fromSelector,\n\t\t\t\t\t\t\t\tSelector: &metav1.LabelSelector{\n\t\t\t\t\t\t\t\t\tMatchLabels: map[string]string{\"foo\": \"bar\"},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t}},\n\t\t\t\t},\n\t\t\t\tStatus: gatewayStatus(\"1.2.3.4\"),\n\t\t\t}},\n\t\t\troutes: []*v1beta1.HTTPRoute{{\n\t\t\t\tObjectMeta: objectMeta(\"default\", \"test\"),\n\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\tgwParentRef(\"default\", \"test\"),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tHostnames: hostnames(\"example.internal\"),\n\t\t\t\t},\n\t\t\t\tStatus: httpRouteStatus(gwParentRef(\"default\", \"test\")),\n\t\t\t}},\n\t\t\tendpoints: nil,\n\t\t},\n\t\t{\n\t\t\ttitle: \"AnnotationOverride\",\n\t\t\tconfig: Config{\n\t\t\t\tGatewayNamespace: \"gateway-namespace\",\n\t\t\t},\n\t\t\tnamespaces: namespaces(\"gateway-namespace\", \"route-namespace\"),\n\t\t\tgateways: []*v1beta1.Gateway{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"overridden-gateway\",\n\t\t\t\t\t\tNamespace: \"gateway-namespace\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.TargetKey: \"4.3.2.1\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\t\tListeners: []v1.Listener{{\n\t\t\t\t\t\t\tProtocol:      v1.HTTPProtocolType,\n\t\t\t\t\t\t\tAllowedRoutes: allowAllNamespaces,\n\t\t\t\t\t\t}},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: gatewayStatus(\"1.2.3.4\"),\n\t\t\t\t},\n\t\t\t},\n\t\t\troutes: []*v1beta1.HTTPRoute{{\n\t\t\t\tObjectMeta: objectMeta(\"route-namespace\", \"test\"),\n\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\tHostnames: hostnames(\"test.example.internal\"),\n\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\tgwParentRef(\"gateway-namespace\", \"overridden-gateway\"),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: httpRouteStatus( // The route is attached to both gateways.\n\t\t\t\t\tgwParentRef(\"gateway-namespace\", \"overridden-gateway\"),\n\t\t\t\t),\n\t\t\t}},\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tnewTestEndpoint(\"test.example.internal\", \"4.3.2.1\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"MutlipleGatewaysOneAnnotationOverride\",\n\t\t\tconfig: Config{\n\t\t\t\tGatewayNamespace: \"gateway-namespace\",\n\t\t\t},\n\t\t\tnamespaces: namespaces(\"gateway-namespace\", \"route-namespace\"),\n\t\t\tgateways: []*v1beta1.Gateway{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"overridden-gateway\",\n\t\t\t\t\t\tNamespace: \"gateway-namespace\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.TargetKey: \"4.3.2.1\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\t\tListeners: []v1.Listener{{\n\t\t\t\t\t\t\tProtocol:      v1.HTTPProtocolType,\n\t\t\t\t\t\t\tAllowedRoutes: allowAllNamespaces,\n\t\t\t\t\t\t}},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: gatewayStatus(\"1.2.3.4\"),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: objectMeta(\"gateway-namespace\", \"test\"),\n\t\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\t\tListeners: []v1.Listener{{\n\t\t\t\t\t\t\tProtocol:      v1.HTTPProtocolType,\n\t\t\t\t\t\t\tAllowedRoutes: allowAllNamespaces,\n\t\t\t\t\t\t}},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: gatewayStatus(\"2.3.4.5\"),\n\t\t\t\t},\n\t\t\t},\n\t\t\troutes: []*v1beta1.HTTPRoute{{\n\t\t\t\tObjectMeta: objectMeta(\"route-namespace\", \"test\"),\n\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\tHostnames: hostnames(\"test.example.internal\"),\n\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\tgwParentRef(\"gateway-namespace\", \"overridden-gateway\"),\n\t\t\t\t\t\t\tgwParentRef(\"gateway-namespace\", \"test\"),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: httpRouteStatus(\n\t\t\t\t\tgwParentRef(\"gateway-namespace\", \"overridden-gateway\"),\n\t\t\t\t\tgwParentRef(\"gateway-namespace\", \"test\"),\n\t\t\t\t),\n\t\t\t}},\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tnewTestEndpoint(\"test.example.internal\", \"4.3.2.1\", \"2.3.4.5\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:      \"MultipleGatewaysMultipleRoutes\",\n\t\t\tconfig:     Config{},\n\t\t\tnamespaces: namespaces(\"default\"),\n\t\t\tgateways: []*v1beta1.Gateway{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: objectMeta(\"default\", \"one\"),\n\t\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\t\tListeners: []v1.Listener{{Protocol: v1.HTTPProtocolType}},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: gatewayStatus(\"1.2.3.4\"),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: objectMeta(\"default\", \"two\"),\n\t\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\t\tListeners: []v1.Listener{{Protocol: v1.HTTPProtocolType}},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: gatewayStatus(\"2.3.4.5\"),\n\t\t\t\t},\n\t\t\t},\n\t\t\troutes: []*v1beta1.HTTPRoute{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: objectMeta(\"default\", \"one\"),\n\t\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\t\tHostnames: hostnames(\"test.one.internal\"),\n\t\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\t\tgwParentRef(\"default\", \"one\"),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: httpRouteStatus(\n\t\t\t\t\t\tgwParentRef(\"default\", \"one\"),\n\t\t\t\t\t),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: objectMeta(\"default\", \"two\"),\n\t\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\t\tHostnames: hostnames(\"test.two.internal\"),\n\t\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\t\tgwParentRef(\"default\", \"two\"),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: httpRouteStatus(\n\t\t\t\t\t\tgwParentRef(\"default\", \"two\"),\n\t\t\t\t\t),\n\t\t\t\t},\n\t\t\t},\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tnewTestEndpoint(\"test.one.internal\", \"1.2.3.4\"),\n\t\t\t\tnewTestEndpoint(\"test.two.internal\", \"2.3.4.5\"),\n\t\t\t},\n\t\t\tlogExpectations: []string{\n\t\t\t\t\"Endpoints generated from HTTPRoute default/one: [test.one.internal 0 IN A  1.2.3.4 []]\",\n\t\t\t\t\"Endpoints generated from HTTPRoute default/two: [test.two.internal 0 IN A  2.3.4.5 []]\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"NoParentRefs\",\n\t\t\tconfig: Config{\n\t\t\t\tGatewayNamespace: \"gateway-namespace\",\n\t\t\t},\n\t\t\tnamespaces: namespaces(\"gateway-namespace\", \"route-namespace\"),\n\t\t\tgateways: []*v1beta1.Gateway{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: objectMeta(\"gateway-namespace\", \"test\"),\n\t\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\t\tListeners: []v1.Listener{{\n\t\t\t\t\t\t\tProtocol:      v1.HTTPProtocolType,\n\t\t\t\t\t\t\tAllowedRoutes: allowAllNamespaces,\n\t\t\t\t\t\t}},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: gatewayStatus(\"1.2.3.4\"),\n\t\t\t\t},\n\t\t\t},\n\t\t\troutes: []*v1beta1.HTTPRoute{{\n\t\t\t\tObjectMeta: objectMeta(\"route-namespace\", \"test\"),\n\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\tHostnames: hostnames(\"test.example.internal\"),\n\t\t\t\t},\n\t\t\t\tStatus: httpRouteStatus(gwParentRef(\"gateway-namespace\", \"test\")),\n\t\t\t}},\n\t\t\tendpoints: []*endpoint.Endpoint{},\n\t\t\tlogExpectations: []string{\n\t\t\t\t\"No parent references found for HTTPRoute route-namespace/test\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"ParentRefsMismatch\",\n\t\t\tconfig: Config{\n\t\t\t\tGatewayNamespace: \"gateway-namespace\",\n\t\t\t},\n\t\t\tnamespaces: namespaces(\"gateway-namespace\", \"route-namespace\"),\n\t\t\tgateways: []*v1beta1.Gateway{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: objectMeta(\"gateway-namespace\", \"test\"),\n\t\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\t\tListeners: []v1.Listener{{\n\t\t\t\t\t\t\tProtocol:      v1.HTTPProtocolType,\n\t\t\t\t\t\t\tAllowedRoutes: allowAllNamespaces,\n\t\t\t\t\t\t}},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: gatewayStatus(\"1.2.3.4\"),\n\t\t\t\t},\n\t\t\t},\n\t\t\troutes: []*v1beta1.HTTPRoute{{\n\t\t\t\tObjectMeta: objectMeta(\"route-namespace\", \"test\"),\n\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\tHostnames: hostnames(\"test.example.internal\"),\n\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\tgwParentRef(\"gateway-namespace\", \"default-gateway\"),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: httpRouteStatus(gwParentRef(\"gateway-namespace\", \"other-gateway\")),\n\t\t\t}},\n\t\t\tendpoints: []*endpoint.Endpoint{},\n\t\t\tlogExpectations: []string{\n\t\t\t\t\"Parent reference gateway-namespace/other-gateway not found in routeParentRefs for HTTPRoute route-namespace/test\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"SourceAnnotation\",\n\t\t\tconfig: Config{\n\t\t\t\tGatewayNamespace: \"gateway-namespace\",\n\t\t\t},\n\t\t\tnamespaces: namespaces(\"gateway-namespace\", \"route-namespace\"),\n\t\t\tgateways: []*v1beta1.Gateway{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: objectMeta(\"gateway-namespace\", \"test\"),\n\t\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\t\tListeners: []v1.Listener{{\n\t\t\t\t\t\t\tProtocol:      v1.HTTPProtocolType,\n\t\t\t\t\t\t\tAllowedRoutes: allowAllNamespaces,\n\t\t\t\t\t\t}},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: gatewayStatus(\"1.2.3.4\"),\n\t\t\t\t},\n\t\t\t},\n\t\t\troutes: []*v1beta1.HTTPRoute{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:        \"route-test\",\n\t\t\t\t\t\tNamespace:   \"test\",\n\t\t\t\t\t\tAnnotations: map[string]string{annotations.GatewayHostnameSourceKey: \"defined-hosts-only\", annotations.HostnameKey: \"test.org.internal\"},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\t\tHostnames: hostnames(\"test.example.internal\"),\n\t\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\t\tgwParentRef(\"gateway-namespace\", \"test\"),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: httpRouteStatus(gwParentRef(\"gateway-namespace\", \"test\")),\n\t\t\t\t},\n\t\t\t},\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tnewTestEndpoint(\"test.example.internal\", \"1.2.3.4\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"OnlyAnnotationHost\",\n\t\t\tconfig: Config{\n\t\t\t\tGatewayNamespace: \"gateway-namespace\",\n\t\t\t},\n\t\t\tnamespaces: namespaces(\"gateway-namespace\", \"route-namespace\"),\n\t\t\tgateways: []*v1beta1.Gateway{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: objectMeta(\"gateway-namespace\", \"test\"),\n\t\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\t\tListeners: []v1.Listener{{\n\t\t\t\t\t\t\tProtocol:      v1.HTTPProtocolType,\n\t\t\t\t\t\t\tAllowedRoutes: allowAllNamespaces,\n\t\t\t\t\t\t}},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: gatewayStatus(\"1.2.3.4\"),\n\t\t\t\t},\n\t\t\t},\n\t\t\troutes: []*v1beta1.HTTPRoute{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:        \"route-test\",\n\t\t\t\t\t\tNamespace:   \"test\",\n\t\t\t\t\t\tAnnotations: map[string]string{annotations.GatewayHostnameSourceKey: \"annotation-only\", annotations.HostnameKey: \"test.org.internal\"},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\t\tHostnames: hostnames(\"test.example.internal\"),\n\t\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\t\tgwParentRef(\"gateway-namespace\", \"test\"),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: httpRouteStatus(gwParentRef(\"gateway-namespace\", \"test\")),\n\t\t\t\t},\n\t\t\t},\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tnewTestEndpoint(\"test.org.internal\", \"1.2.3.4\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:      \"InvalidSourceAnnotation\",\n\t\t\tconfig:     Config{},\n\t\t\tnamespaces: namespaces(\"default\"),\n\t\t\tgateways: []*v1beta1.Gateway{{\n\t\t\t\tObjectMeta: objectMeta(\"default\", \"test\"),\n\t\t\t\tSpec: v1.GatewaySpec{\n\t\t\t\t\tListeners: []v1.Listener{{Protocol: v1.HTTPProtocolType}},\n\t\t\t\t},\n\t\t\t\tStatus: gatewayStatus(\"1.2.3.4\"),\n\t\t\t}},\n\t\t\troutes: []*v1beta1.HTTPRoute{{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"invalid-annotation\",\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\tannotations.GatewayHostnameSourceKey: \"invalid-value\",\n\t\t\t\t\t\tannotations.HostnameKey:              \"annotation.invalid.internal\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: v1.HTTPRouteSpec{\n\t\t\t\t\tHostnames: hostnames(\"route.invalid.internal\"),\n\t\t\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\t\t\tgwParentRef(\"default\", \"test\"),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: httpRouteStatus(gwParentRef(\"default\", \"test\")),\n\t\t\t}},\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tnewTestEndpoint(\"route.invalid.internal\", \"1.2.3.4\"),\n\t\t\t\tnewTestEndpoint(\"annotation.invalid.internal\", \"1.2.3.4\"),\n\t\t\t},\n\t\t\tlogExpectations: []string{\n\t\t\t\t\"Invalid value for \\\"external-dns.alpha.kubernetes.io/gateway-hostname-source\\\" on default/invalid-annotation: \\\"invalid-value\\\". Falling back to default behavior.\",\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.title, func(t *testing.T) {\n\t\t\tif len(tt.logExpectations) == 0 {\n\t\t\t\tt.Parallel()\n\t\t\t}\n\n\t\t\tctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)\n\t\t\tdefer cancel()\n\n\t\t\tgwClient := gatewayfake.NewSimpleClientset()\n\t\t\tfor _, gw := range tt.gateways {\n\t\t\t\t_, err := gwClient.GatewayV1beta1().Gateways(gw.Namespace).Create(ctx, gw, metav1.CreateOptions{})\n\t\t\t\trequire.NoError(t, err, \"failed to create Gateway\")\n\n\t\t\t}\n\t\t\tfor _, rt := range tt.routes {\n\t\t\t\t_, err := gwClient.GatewayV1beta1().HTTPRoutes(rt.Namespace).Create(ctx, rt, metav1.CreateOptions{})\n\t\t\t\trequire.NoError(t, err, \"failed to create HTTPRoute\")\n\t\t\t}\n\t\t\tkubeClient := kubefake.NewClientset()\n\t\t\tfor _, ns := range tt.namespaces {\n\t\t\t\t_, err := kubeClient.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{})\n\t\t\t\trequire.NoError(t, err, \"failed to create Namespace\")\n\t\t\t}\n\n\t\t\tclients := new(MockClientGenerator)\n\t\t\tclients.On(\"GatewayClient\").Return(gwClient, nil)\n\t\t\tclients.On(\"KubeClient\").Return(kubeClient, nil)\n\n\t\t\tsrc, err := NewGatewayHTTPRouteSource(ctx, clients, &tt.config)\n\t\t\trequire.NoError(t, err, \"failed to create Gateway HTTPRoute Source\")\n\n\t\t\thook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t)\n\n\t\t\tendpoints, err := src.Endpoints(ctx)\n\t\t\trequire.NoError(t, err, \"failed to get Endpoints\")\n\t\t\tvalidateEndpoints(t, endpoints, tt.endpoints)\n\n\t\t\tfor _, msg := range tt.logExpectations {\n\t\t\t\tlogtest.TestHelperLogContains(msg, hook, t)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc hostnamePtr(val v1.Hostname) *v1.Hostname { return &val }\n"
  },
  {
    "path": "source/gateway_tcproute.go",
    "content": "/*\nCopyright 2021 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\tv1 \"sigs.k8s.io/gateway-api/apis/v1\"\n\t\"sigs.k8s.io/gateway-api/apis/v1alpha2\"\n\tinformers \"sigs.k8s.io/gateway-api/pkg/client/informers/externalversions\"\n\tinformers_v1a2 \"sigs.k8s.io/gateway-api/pkg/client/informers/externalversions/apis/v1alpha2\"\n)\n\n// NewGatewayTCPRouteSource creates a new Gateway TCPRoute source with the given config.\nfunc NewGatewayTCPRouteSource(ctx context.Context, clients ClientGenerator, config *Config) (Source, error) {\n\treturn newGatewayRouteSource(ctx, clients, config, \"TCPRoute\", func(factory informers.SharedInformerFactory) gatewayRouteInformer {\n\t\treturn &gatewayTCPRouteInformer{factory.Gateway().V1alpha2().TCPRoutes()}\n\t})\n}\n\ntype gatewayTCPRoute struct{ route v1alpha2.TCPRoute } // NOTE: Must update TypeMeta in List when changing the APIVersion.\n\nfunc (rt *gatewayTCPRoute) Object() kubeObject               { return &rt.route }\nfunc (rt *gatewayTCPRoute) Metadata() *metav1.ObjectMeta     { return &rt.route.ObjectMeta }\nfunc (rt *gatewayTCPRoute) Hostnames() []v1.Hostname         { return nil }\nfunc (rt *gatewayTCPRoute) ParentRefs() []v1.ParentReference { return rt.route.Spec.ParentRefs }\nfunc (rt *gatewayTCPRoute) Protocol() v1.ProtocolType        { return v1.TCPProtocolType }\nfunc (rt *gatewayTCPRoute) RouteStatus() v1.RouteStatus      { return rt.route.Status.RouteStatus }\n\ntype gatewayTCPRouteInformer struct {\n\tinformers_v1a2.TCPRouteInformer\n}\n\nfunc (inf gatewayTCPRouteInformer) List(namespace string, selector labels.Selector) ([]gatewayRoute, error) {\n\tlist, err := inf.TCPRouteInformer.Lister().TCPRoutes(namespace).List(selector)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\troutes := make([]gatewayRoute, len(list))\n\tfor i, rt := range list {\n\t\t// List results are supposed to be treated as read-only.\n\t\t// We make a shallow copy since we're only interested in setting the TypeMeta.\n\t\tclone := *rt\n\t\tclone.TypeMeta = metav1.TypeMeta{\n\t\t\tAPIVersion: v1alpha2.GroupVersion.String(),\n\t\t\tKind:       \"TCPRoute\",\n\t\t}\n\t\troutes[i] = &gatewayTCPRoute{clone}\n\t}\n\treturn routes, nil\n}\n"
  },
  {
    "path": "source/gateway_tcproute_test.go",
    "content": "/*\nCopyright 2021 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/require\"\n\tcorev1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\tkubefake \"k8s.io/client-go/kubernetes/fake\"\n\tv1 \"sigs.k8s.io/gateway-api/apis/v1\"\n\t\"sigs.k8s.io/gateway-api/apis/v1alpha2\"\n\tv1beta1 \"sigs.k8s.io/gateway-api/apis/v1beta1\"\n\tgatewayfake \"sigs.k8s.io/gateway-api/pkg/client/clientset/versioned/fake\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n)\n\nfunc TestGatewayTCPRouteSourceEndpoints(t *testing.T) {\n\tt.Parallel()\n\n\tctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)\n\tdefer cancel()\n\n\tgwClient := gatewayfake.NewSimpleClientset()\n\tkubeClient := kubefake.NewClientset()\n\tclients := new(MockClientGenerator)\n\tclients.On(\"GatewayClient\").Return(gwClient, nil)\n\tclients.On(\"KubeClient\").Return(kubeClient, nil)\n\n\tns := &corev1.Namespace{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName: \"default\",\n\t\t},\n\t}\n\t_, err := kubeClient.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{})\n\trequire.NoError(t, err, \"failed to create Namespace\")\n\n\tips := []string{\"10.64.0.1\", \"10.64.0.2\"}\n\tgw := &v1beta1.Gateway{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:      \"internal\",\n\t\t\tNamespace: \"default\",\n\t\t},\n\t\tSpec: v1.GatewaySpec{\n\t\t\tListeners: []v1.Listener{{\n\t\t\t\tProtocol: v1.TCPProtocolType,\n\t\t\t}},\n\t\t},\n\t\tStatus: gatewayStatus(ips...),\n\t}\n\t_, err = gwClient.GatewayV1beta1().Gateways(gw.Namespace).Create(ctx, gw, metav1.CreateOptions{})\n\trequire.NoError(t, err, \"failed to create Gateway\")\n\n\trt := &v1alpha2.TCPRoute{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:      \"api\",\n\t\t\tNamespace: \"default\",\n\t\t\tAnnotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"api-annotation.foobar.internal\",\n\t\t\t},\n\t\t},\n\t\tSpec: v1alpha2.TCPRouteSpec{\n\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\tgwParentRef(\"default\", \"internal\"),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tStatus: v1alpha2.TCPRouteStatus{\n\t\t\tRouteStatus: gwRouteStatus(gwParentRef(\"default\", \"internal\")),\n\t\t},\n\t}\n\t_, err = gwClient.GatewayV1alpha2().TCPRoutes(rt.Namespace).Create(ctx, rt, metav1.CreateOptions{})\n\trequire.NoError(t, err, \"failed to create TCPRoute\")\n\n\tsrc, err := NewGatewayTCPRouteSource(ctx, clients, &Config{\n\t\tFQDNTemplate:             \"{{.Name}}-template.foobar.internal\",\n\t\tCombineFQDNAndAnnotation: true,\n\t})\n\trequire.NoError(t, err, \"failed to create Gateway TCPRoute Source\")\n\n\tendpoints, err := src.Endpoints(ctx)\n\trequire.NoError(t, err, \"failed to get Endpoints\")\n\tvalidateEndpoints(t, endpoints, []*endpoint.Endpoint{\n\t\tnewTestEndpoint(\"api-annotation.foobar.internal\", ips...),\n\t\tnewTestEndpoint(\"api-template.foobar.internal\", ips...),\n\t})\n}\n"
  },
  {
    "path": "source/gateway_test.go",
    "content": "/*\nCopyright 2023 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\n\tv1 \"sigs.k8s.io/gateway-api/apis/v1\"\n)\n\nfunc TestGatewayMatchingHost(t *testing.T) {\n\ttests := []struct {\n\t\tdesc string\n\t\ta, b string\n\t\thost string\n\t\tok   bool\n\t}{\n\t\t{\n\t\t\tdesc: \"ipv4-rejected\",\n\t\t\ta:    \"1.2.3.4\",\n\t\t\tok:   false,\n\t\t},\n\t\t{\n\t\t\tdesc: \"ipv6-rejected\",\n\t\t\ta:    \"2001:0db8:85a3:0000:0000:8a2e:0370:7334\",\n\t\t\tok:   false,\n\t\t},\n\t\t{\n\t\t\tdesc: \"empty-matches-empty\",\n\t\t\tok:   true,\n\t\t},\n\t\t{\n\t\t\tdesc: \"empty-matches-nonempty\",\n\t\t\ta:    \"example.net\",\n\t\t\thost: \"example.net\",\n\t\t\tok:   true,\n\t\t},\n\t\t{\n\t\t\tdesc: \"simple-match\",\n\t\t\ta:    \"example.net\",\n\t\t\tb:    \"example.net\",\n\t\t\thost: \"example.net\",\n\t\t\tok:   true,\n\t\t},\n\t\t{\n\t\t\tdesc: \"wildcard-matches-longer\",\n\t\t\ta:    \"*.example.net\",\n\t\t\tb:    \"test.example.net\",\n\t\t\thost: \"test.example.net\",\n\t\t\tok:   true,\n\t\t},\n\t\t{\n\t\t\tdesc: \"wildcard-matches-equal-length\",\n\t\t\ta:    \"*.example.net\",\n\t\t\tb:    \"a.example.net\",\n\t\t\thost: \"a.example.net\",\n\t\t\tok:   true,\n\t\t},\n\t\t{\n\t\t\tdesc: \"wildcard-matches-multiple-subdomains\",\n\t\t\ta:    \"*.example.net\",\n\t\t\tb:    \"foo.bar.test.example.net\",\n\t\t\thost: \"foo.bar.test.example.net\",\n\t\t\tok:   true,\n\t\t},\n\t\t{\n\t\t\tdesc: \"wildcard-doesnt-match-parent\",\n\t\t\ta:    \"*.example.net\",\n\t\t\tb:    \"example.net\",\n\t\t\tok:   false,\n\t\t},\n\t\t{\n\t\t\tdesc: \"wildcard-must-be-complete-label\",\n\t\t\ta:    \"*example.net\",\n\t\t\tb:    \"test.example.net\",\n\t\t\tok:   false,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.desc, func(t *testing.T) {\n\t\t\tfor range 2 {\n\t\t\t\tif host, ok := gwMatchingHost(tt.a, tt.b); host != tt.host || ok != tt.ok {\n\t\t\t\t\tt.Errorf(\n\t\t\t\t\t\t\"gwMatchingHost(%q, %q); got: %q, %v; want: %q, %v\",\n\t\t\t\t\t\ttt.a, tt.b, host, ok, tt.host, tt.ok,\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\ttt.a, tt.b = tt.b, tt.a\n\t\t\t}\n\t\t})\n\n\t}\n}\n\nfunc TestGatewayMatchingProtocol(t *testing.T) {\n\ttests := []struct {\n\t\troute, lis string\n\t\tdesc       string\n\t\tok         bool\n\t}{\n\t\t{\n\t\t\tdesc:  \"protocol-matches-lis-https-route-http\",\n\t\t\troute: \"HTTP\",\n\t\t\tlis:   \"HTTPS\",\n\t\t\tok:    true,\n\t\t},\n\t\t{\n\t\t\tdesc:  \"protocol-match-invalid-list-https-route-tcp\",\n\t\t\troute: \"TCP\",\n\t\t\tlis:   \"HTTPS\",\n\t\t\tok:    false,\n\t\t},\n\t\t{\n\t\t\tdesc:  \"protocol-match-valid-lis-tls-route-tls\",\n\t\t\troute: \"TLS\",\n\t\t\tlis:   \"TLS\",\n\t\t\tok:    true,\n\t\t},\n\t\t{\n\t\t\tdesc:  \"protocol-match-valid-lis-TLS-route-TCP\",\n\t\t\troute: \"TCP\",\n\t\t\tlis:   \"TLS\",\n\t\t\tok:    true,\n\t\t},\n\t\t{\n\t\t\tdesc:  \"protocol-match-valid-lis-TLS-route-TCP\",\n\t\t\troute: \"TLS\",\n\t\t\tlis:   \"TCP\",\n\t\t\tok:    false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.desc, func(t *testing.T) {\n\t\t\tfor range 2 {\n\t\t\t\tif ok := gwProtocolMatches(v1.ProtocolType(tt.route), v1.ProtocolType(tt.lis)); ok != tt.ok {\n\t\t\t\t\tt.Errorf(\n\t\t\t\t\t\t\"gwProtocolMatches(%q, %q); got: %v; want: %v\",\n\t\t\t\t\t\ttt.route, tt.lis, ok, tt.ok,\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\t// tt.a, tt.b = tt.b, tt.a\n\t\t\t}\n\t\t})\n\n\t}\n}\n\nfunc TestIsDNS1123Domain(t *testing.T) {\n\ttests := []struct {\n\t\tdesc string\n\t\tin   string\n\t\tok   bool\n\t}{\n\t\t{\n\t\t\tdesc: \"empty\",\n\t\t\tok:   false,\n\t\t},\n\t\t{\n\t\t\tdesc: \"label-too-long\",\n\t\t\tin:   strings.Repeat(\"x\", 64) + \".example.net\",\n\t\t\tok:   false,\n\t\t},\n\t\t{\n\t\t\tdesc: \"domain-too-long\",\n\t\t\tin:   strings.Repeat(\"testing.\", 256/(len(\"testing.\"))) + \"example.net\",\n\t\t\tok:   false,\n\t\t},\n\t\t{\n\t\t\tdesc: \"hostname\",\n\t\t\tin:   \"example\",\n\t\t\tok:   true,\n\t\t},\n\t\t{\n\t\t\tdesc: \"domain\",\n\t\t\tin:   \"example.net\",\n\t\t\tok:   true,\n\t\t},\n\t\t{\n\t\t\tdesc: \"subdomain\",\n\t\t\tin:   \"test.example.net\",\n\t\t\tok:   true,\n\t\t},\n\t\t{\n\t\t\tdesc: \"dashes\",\n\t\t\tin:   \"test-with-dash.example.net\",\n\t\t\tok:   true,\n\t\t},\n\t\t{\n\t\t\tdesc: \"dash-prefix\",\n\t\t\tin:   \"-dash-prefix.example.net\",\n\t\t\tok:   false,\n\t\t},\n\t\t{\n\t\t\tdesc: \"dash-suffix\",\n\t\t\tin:   \"dash-suffix-.example.net\",\n\t\t\tok:   false,\n\t\t},\n\t\t{\n\t\t\tdesc: \"underscore\",\n\t\t\tin:   \"under_score.example.net\",\n\t\t\tok:   false,\n\t\t},\n\t\t{\n\t\t\tdesc: \"plus\",\n\t\t\tin:   \"pl+us.example.net\",\n\t\t\tok:   false,\n\t\t},\n\t\t{\n\t\t\tdesc: \"brackets\",\n\t\t\tin:   \"bra[k]ets.example.net\",\n\t\t\tok:   false,\n\t\t},\n\t\t{\n\t\t\tdesc: \"parens\",\n\t\t\tin:   \"pa[re]ns.example.net\",\n\t\t\tok:   false,\n\t\t},\n\t\t{\n\t\t\tdesc: \"wild\",\n\t\t\tin:   \"*.example.net\",\n\t\t\tok:   false,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.desc, func(t *testing.T) {\n\t\t\tif ok := isDNS1123Domain(tt.in); ok != tt.ok {\n\t\t\t\tt.Errorf(\"isDNS1123Domain(%q); got: %v; want: %v\", tt.in, ok, tt.ok)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "source/gateway_tlsroute.go",
    "content": "/*\nCopyright 2021 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\tv1 \"sigs.k8s.io/gateway-api/apis/v1\"\n\t\"sigs.k8s.io/gateway-api/apis/v1alpha2\"\n\tinformers \"sigs.k8s.io/gateway-api/pkg/client/informers/externalversions\"\n\tinformers_v1a2 \"sigs.k8s.io/gateway-api/pkg/client/informers/externalversions/apis/v1alpha2\"\n)\n\n// NewGatewayTLSRouteSource creates a new Gateway TLSRoute source with the given config.\nfunc NewGatewayTLSRouteSource(ctx context.Context, clients ClientGenerator, config *Config) (Source, error) {\n\treturn newGatewayRouteSource(ctx, clients, config, \"TLSRoute\", func(factory informers.SharedInformerFactory) gatewayRouteInformer {\n\t\treturn &gatewayTLSRouteInformer{factory.Gateway().V1alpha2().TLSRoutes()}\n\t})\n}\n\ntype gatewayTLSRoute struct{ route v1alpha2.TLSRoute } // NOTE: Must update TypeMeta in List when changing the APIVersion.\n\nfunc (rt *gatewayTLSRoute) Object() kubeObject               { return &rt.route }\nfunc (rt *gatewayTLSRoute) Metadata() *metav1.ObjectMeta     { return &rt.route.ObjectMeta }\nfunc (rt *gatewayTLSRoute) Hostnames() []v1.Hostname         { return rt.route.Spec.Hostnames }\nfunc (rt *gatewayTLSRoute) ParentRefs() []v1.ParentReference { return rt.route.Spec.ParentRefs }\nfunc (rt *gatewayTLSRoute) Protocol() v1.ProtocolType        { return v1.TLSProtocolType }\nfunc (rt *gatewayTLSRoute) RouteStatus() v1.RouteStatus      { return rt.route.Status.RouteStatus }\n\ntype gatewayTLSRouteInformer struct {\n\tinformers_v1a2.TLSRouteInformer\n}\n\nfunc (inf gatewayTLSRouteInformer) List(namespace string, selector labels.Selector) ([]gatewayRoute, error) {\n\tlist, err := inf.TLSRouteInformer.Lister().TLSRoutes(namespace).List(selector)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\troutes := make([]gatewayRoute, len(list))\n\tfor i, rt := range list {\n\t\t// List results are supposed to be treated as read-only.\n\t\t// We make a shallow copy since we're only interested in setting the TypeMeta.\n\t\tclone := *rt\n\t\tclone.TypeMeta = metav1.TypeMeta{\n\t\t\tAPIVersion: v1alpha2.GroupVersion.String(),\n\t\t\tKind:       \"TLSRoute\",\n\t\t}\n\t\troutes[i] = &gatewayTLSRoute{clone}\n\t}\n\treturn routes, nil\n}\n"
  },
  {
    "path": "source/gateway_tlsroute_test.go",
    "content": "/*\nCopyright 2021 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/require\"\n\tcorev1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\tkubefake \"k8s.io/client-go/kubernetes/fake\"\n\tv1 \"sigs.k8s.io/gateway-api/apis/v1\"\n\t\"sigs.k8s.io/gateway-api/apis/v1alpha2\"\n\tv1beta1 \"sigs.k8s.io/gateway-api/apis/v1beta1\"\n\tgatewayfake \"sigs.k8s.io/gateway-api/pkg/client/clientset/versioned/fake\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n)\n\nfunc TestGatewayTLSRouteSourceEndpoints(t *testing.T) {\n\tt.Parallel()\n\n\tctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)\n\tdefer cancel()\n\n\tgwClient := gatewayfake.NewSimpleClientset()\n\tkubeClient := kubefake.NewClientset()\n\tclients := new(MockClientGenerator)\n\tclients.On(\"GatewayClient\").Return(gwClient, nil)\n\tclients.On(\"KubeClient\").Return(kubeClient, nil)\n\n\tns := &corev1.Namespace{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName: \"default\",\n\t\t},\n\t}\n\t_, err := kubeClient.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{})\n\trequire.NoError(t, err, \"failed to create Namespace\")\n\n\tips := []string{\"10.64.0.1\", \"10.64.0.2\"}\n\tgw := &v1beta1.Gateway{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:      \"internal\",\n\t\t\tNamespace: \"default\",\n\t\t},\n\t\tSpec: v1.GatewaySpec{\n\t\t\tListeners: []v1.Listener{{\n\t\t\t\tProtocol: v1.TLSProtocolType,\n\t\t\t}},\n\t\t},\n\t\tStatus: gatewayStatus(ips...),\n\t}\n\t_, err = gwClient.GatewayV1beta1().Gateways(gw.Namespace).Create(ctx, gw, metav1.CreateOptions{})\n\trequire.NoError(t, err, \"failed to create Gateway\")\n\n\trt := &v1alpha2.TLSRoute{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:      \"api\",\n\t\t\tNamespace: \"default\",\n\t\t\tAnnotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"api-annotation.foobar.internal\",\n\t\t\t},\n\t\t},\n\t\tSpec: v1alpha2.TLSRouteSpec{\n\t\t\tHostnames: []v1.Hostname{\"api-hostnames.foobar.internal\"},\n\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\tgwParentRef(\"default\", \"internal\"),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tStatus: v1alpha2.TLSRouteStatus{\n\t\t\tRouteStatus: gwRouteStatus(gwParentRef(\"default\", \"internal\")),\n\t\t},\n\t}\n\t_, err = gwClient.GatewayV1alpha2().TLSRoutes(rt.Namespace).Create(ctx, rt, metav1.CreateOptions{})\n\trequire.NoError(t, err, \"failed to create TLSRoute\")\n\n\tsrc, err := NewGatewayTLSRouteSource(ctx, clients, &Config{\n\t\tFQDNTemplate:             \"{{.Name}}-template.foobar.internal\",\n\t\tCombineFQDNAndAnnotation: true,\n\t})\n\trequire.NoError(t, err, \"failed to create Gateway TLSRoute Source\")\n\n\tendpoints, err := src.Endpoints(ctx)\n\trequire.NoError(t, err, \"failed to get Endpoints\")\n\tvalidateEndpoints(t, endpoints, []*endpoint.Endpoint{\n\t\tnewTestEndpoint(\"api-annotation.foobar.internal\", ips...),\n\t\tnewTestEndpoint(\"api-hostnames.foobar.internal\", ips...),\n\t\tnewTestEndpoint(\"api-template.foobar.internal\", ips...),\n\t})\n}\n"
  },
  {
    "path": "source/gateway_udproute.go",
    "content": "/*\nCopyright 2021 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\tv1 \"sigs.k8s.io/gateway-api/apis/v1\"\n\t\"sigs.k8s.io/gateway-api/apis/v1alpha2\"\n\tinformers \"sigs.k8s.io/gateway-api/pkg/client/informers/externalversions\"\n\tinformers_v1a2 \"sigs.k8s.io/gateway-api/pkg/client/informers/externalversions/apis/v1alpha2\"\n)\n\n// NewGatewayUDPRouteSource creates a new Gateway UDPRoute source with the given config.\nfunc NewGatewayUDPRouteSource(ctx context.Context, clients ClientGenerator, config *Config) (Source, error) {\n\treturn newGatewayRouteSource(ctx, clients, config, \"UDPRoute\", func(factory informers.SharedInformerFactory) gatewayRouteInformer {\n\t\treturn &gatewayUDPRouteInformer{factory.Gateway().V1alpha2().UDPRoutes()}\n\t})\n}\n\ntype gatewayUDPRoute struct{ route v1alpha2.UDPRoute } // NOTE: Must update TypeMeta in List when changing the APIVersion.\n\nfunc (rt *gatewayUDPRoute) Object() kubeObject               { return &rt.route }\nfunc (rt *gatewayUDPRoute) Metadata() *metav1.ObjectMeta     { return &rt.route.ObjectMeta }\nfunc (rt *gatewayUDPRoute) Hostnames() []v1.Hostname         { return nil }\nfunc (rt *gatewayUDPRoute) ParentRefs() []v1.ParentReference { return rt.route.Spec.ParentRefs }\nfunc (rt *gatewayUDPRoute) Protocol() v1.ProtocolType        { return v1.UDPProtocolType }\nfunc (rt *gatewayUDPRoute) RouteStatus() v1.RouteStatus      { return rt.route.Status.RouteStatus }\n\ntype gatewayUDPRouteInformer struct {\n\tinformers_v1a2.UDPRouteInformer\n}\n\nfunc (inf gatewayUDPRouteInformer) List(namespace string, selector labels.Selector) ([]gatewayRoute, error) {\n\tlist, err := inf.UDPRouteInformer.Lister().UDPRoutes(namespace).List(selector)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\troutes := make([]gatewayRoute, len(list))\n\tfor i, rt := range list {\n\t\t// List results are supposed to be treated as read-only.\n\t\t// We make a shallow copy since we're only interested in setting the TypeMeta.\n\t\tclone := *rt\n\t\tclone.TypeMeta = metav1.TypeMeta{\n\t\t\tAPIVersion: v1alpha2.GroupVersion.String(),\n\t\t\tKind:       \"UDPRoute\",\n\t\t}\n\t\troutes[i] = &gatewayUDPRoute{clone}\n\t}\n\treturn routes, nil\n}\n"
  },
  {
    "path": "source/gateway_udproute_test.go",
    "content": "/*\nCopyright 2021 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/require\"\n\tcorev1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\tkubefake \"k8s.io/client-go/kubernetes/fake\"\n\tv1 \"sigs.k8s.io/gateway-api/apis/v1\"\n\t\"sigs.k8s.io/gateway-api/apis/v1alpha2\"\n\tv1beta1 \"sigs.k8s.io/gateway-api/apis/v1beta1\"\n\tgatewayfake \"sigs.k8s.io/gateway-api/pkg/client/clientset/versioned/fake\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n)\n\nfunc TestGatewayUDPRouteSourceEndpoints(t *testing.T) {\n\tt.Parallel()\n\n\tctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)\n\tdefer cancel()\n\n\tgwClient := gatewayfake.NewSimpleClientset()\n\tkubeClient := kubefake.NewClientset()\n\tclients := new(MockClientGenerator)\n\tclients.On(\"GatewayClient\").Return(gwClient, nil)\n\tclients.On(\"KubeClient\").Return(kubeClient, nil)\n\n\tns := &corev1.Namespace{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName: \"default\",\n\t\t},\n\t}\n\t_, err := kubeClient.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{})\n\trequire.NoError(t, err, \"failed to create Namespace\")\n\n\tips := []string{\"10.64.0.1\", \"10.64.0.2\"}\n\tgw := &v1beta1.Gateway{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:      \"internal\",\n\t\t\tNamespace: \"default\",\n\t\t},\n\t\tSpec: v1.GatewaySpec{\n\t\t\tListeners: []v1.Listener{{\n\t\t\t\tProtocol: v1.UDPProtocolType,\n\t\t\t}},\n\t\t},\n\t\tStatus: gatewayStatus(ips...),\n\t}\n\t_, err = gwClient.GatewayV1beta1().Gateways(gw.Namespace).Create(ctx, gw, metav1.CreateOptions{})\n\trequire.NoError(t, err, \"failed to create Gateway\")\n\n\trt := &v1alpha2.UDPRoute{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:      \"api\",\n\t\t\tNamespace: \"default\",\n\t\t\tAnnotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"api-annotation.foobar.internal\",\n\t\t\t},\n\t\t},\n\t\tSpec: v1alpha2.UDPRouteSpec{\n\t\t\tCommonRouteSpec: v1.CommonRouteSpec{\n\t\t\t\tParentRefs: []v1.ParentReference{\n\t\t\t\t\tgwParentRef(\"default\", \"internal\"),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tStatus: v1alpha2.UDPRouteStatus{\n\t\t\tRouteStatus: gwRouteStatus(gwParentRef(\"default\", \"internal\")),\n\t\t},\n\t}\n\t_, err = gwClient.GatewayV1alpha2().UDPRoutes(rt.Namespace).Create(ctx, rt, metav1.CreateOptions{})\n\trequire.NoError(t, err, \"failed to create UDPRoute\")\n\n\tsrc, err := NewGatewayUDPRouteSource(ctx, clients, &Config{\n\t\tFQDNTemplate:             \"{{.Name}}-template.foobar.internal\",\n\t\tCombineFQDNAndAnnotation: true,\n\t})\n\trequire.NoError(t, err, \"failed to create Gateway UDPRoute Source\")\n\n\tendpoints, err := src.Endpoints(ctx)\n\trequire.NoError(t, err, \"failed to get Endpoints\")\n\tvalidateEndpoints(t, endpoints, []*endpoint.Endpoint{\n\t\tnewTestEndpoint(\"api-annotation.foobar.internal\", ips...),\n\t\tnewTestEndpoint(\"api-template.foobar.internal\", ips...),\n\t})\n}\n"
  },
  {
    "path": "source/gloo_proxy.go",
    "content": "/*\nCopyright 2020n The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"maps\"\n\t\"strings\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\tcorev1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\t\"k8s.io/apimachinery/pkg/runtime/schema\"\n\t\"k8s.io/client-go/dynamic\"\n\t\"k8s.io/client-go/dynamic/dynamicinformer\"\n\t\"k8s.io/client-go/kubernetes\"\n\n\tkubeinformers \"k8s.io/client-go/informers\"\n\tcoreinformers \"k8s.io/client-go/informers/core/v1\"\n\tnetinformers \"k8s.io/client-go/informers/networking/v1\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n\t\"sigs.k8s.io/external-dns/source/informers\"\n)\n\nvar (\n\tproxyGVR = schema.GroupVersionResource{\n\t\tGroup:    \"gloo.solo.io\",\n\t\tVersion:  \"v1\",\n\t\tResource: \"proxies\",\n\t}\n\tvirtualServiceGVR = schema.GroupVersionResource{\n\t\tGroup:    \"gateway.solo.io\",\n\t\tVersion:  \"v1\",\n\t\tResource: \"virtualservices\",\n\t}\n\tgatewayGVR = schema.GroupVersionResource{\n\t\tGroup:    \"gateway.solo.io\",\n\t\tVersion:  \"v1\",\n\t\tResource: \"gateways\",\n\t}\n)\n\n// Basic redefinition of \"Proxy\" CRD : https://github.com/solo-io/gloo/blob/v1.4.6/projects/gloo/pkg/api/v1/proxy.pb.go\ntype proxy struct {\n\tmetav1.TypeMeta `json:\",inline\"`\n\tMetadata        metav1.ObjectMeta `json:\"metadata\"`\n\tSpec            proxySpec         `json:\"spec\"`\n}\n\ntype proxySpec struct {\n\tListeners []proxySpecListener `json:\"listeners,omitempty\"`\n}\n\ntype proxySpecListener struct {\n\tHTTPListener   proxySpecHTTPListener `json:\"httpListener\"`\n\tMetadataStatic proxyMetadataStatic   `json:\"metadataStatic\"`\n}\n\ntype proxyMetadataStatic struct {\n\tSource []proxyMetadataStaticSource `json:\"sources,omitempty\"`\n}\n\ntype proxyMetadataStaticSource struct {\n\tResourceKind string                               `json:\"resourceKind,omitempty\"`\n\tResourceRef  proxyMetadataStaticSourceResourceRef `json:\"resourceRef\"`\n}\n\ntype proxyMetadataStaticSourceResourceRef struct {\n\tName      string `json:\"name,omitempty\"`\n\tNamespace string `json:\"namespace,omitempty\"`\n}\n\ntype proxySpecHTTPListener struct {\n\tVirtualHosts []proxyVirtualHost `json:\"virtualHosts,omitempty\"`\n}\n\ntype proxyVirtualHost struct {\n\tDomains        []string                       `json:\"domains,omitempty\"`\n\tMetadata       proxyVirtualHostMetadata       `json:\"metadata\"`\n\tMetadataStatic proxyVirtualHostMetadataStatic `json:\"metadataStatic\"`\n}\n\ntype proxyVirtualHostMetadata struct {\n\tSource []proxyVirtualHostMetadataSource `json:\"sources,omitempty\"`\n}\n\ntype proxyVirtualHostMetadataStatic struct {\n\tSource []proxyVirtualHostMetadataStaticSource `json:\"sources\"`\n}\n\ntype proxyVirtualHostMetadataSource struct {\n\tKind      string `json:\"kind,omitempty\"`\n\tName      string `json:\"name,omitempty\"`\n\tNamespace string `json:\"namespace,omitempty\"`\n}\n\ntype proxyVirtualHostMetadataStaticSource struct {\n\tResourceKind string                                    `json:\"resourceKind\"`\n\tResourceRef  proxyVirtualHostMetadataSourceResourceRef `json:\"resourceRef\"`\n}\n\ntype proxyVirtualHostMetadataSourceResourceRef struct {\n\tproxyVirtualHost\n\tName      string `json:\"name,omitempty\"`\n\tNamespace string `json:\"namespace,omitempty\"`\n}\n\n// glooSource is an implementation of Source for Gloo Proxy objects.\n//\n// +externaldns:source:name=gloo-proxy\n// +externaldns:source:category=Service Mesh\n// +externaldns:source:description=Creates DNS entries from Gloo Proxy resources\n// +externaldns:source:resources=Proxy.gloo.solo.io\n// +externaldns:source:filters=\n// +externaldns:source:namespace=all,single\n// +externaldns:source:fqdn-template=false\n// +externaldns:source:provider-specific=true\ntype glooSource struct {\n\tserviceInformer        coreinformers.ServiceInformer\n\tingressInformer        netinformers.IngressInformer\n\tproxyInformer          kubeinformers.GenericInformer\n\tvirtualServiceInformer kubeinformers.GenericInformer\n\tgatewayInformer        kubeinformers.GenericInformer\n\t// TODO: glooNamespaces is the list of namespaces to scan for Gloo Proxies. All namespace access is still required\n\tglooNamespaces []string\n}\n\n// NewGlooSource creates a new glooSource with the given config\nfunc NewGlooSource(\n\tctx context.Context,\n\tdynamicKubeClient dynamic.Interface,\n\tkubeClient kubernetes.Interface,\n\tcfg *Config) (Source, error) {\n\tinformerFactory := kubeinformers.NewSharedInformerFactory(kubeClient, 0)\n\tserviceInformer := informerFactory.Core().V1().Services()\n\tingressInformer := informerFactory.Networking().V1().Ingresses()\n\n\t_, _ = serviceInformer.Informer().AddEventHandler(informers.DefaultEventHandler())\n\t_, _ = ingressInformer.Informer().AddEventHandler(informers.DefaultEventHandler())\n\n\tdynamicInformerFactory := dynamicinformer.NewDynamicSharedInformerFactory(dynamicKubeClient, 0)\n\n\tproxyInformer := dynamicInformerFactory.ForResource(proxyGVR)\n\tvirtualServiceInformer := dynamicInformerFactory.ForResource(virtualServiceGVR)\n\tgatewayInformer := dynamicInformerFactory.ForResource(gatewayGVR)\n\n\t_, _ = proxyInformer.Informer().AddEventHandler(informers.DefaultEventHandler())\n\t_, _ = virtualServiceInformer.Informer().AddEventHandler(informers.DefaultEventHandler())\n\t_, _ = gatewayInformer.Informer().AddEventHandler(informers.DefaultEventHandler())\n\n\tinformerFactory.Start(ctx.Done())\n\tdynamicInformerFactory.Start(ctx.Done())\n\tif err := informers.WaitForCacheSync(ctx, informerFactory); err != nil {\n\t\treturn nil, err\n\t}\n\tif err := informers.WaitForDynamicCacheSync(ctx, dynamicInformerFactory); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &glooSource{\n\t\tserviceInformer,\n\t\tingressInformer,\n\t\tproxyInformer,\n\t\tvirtualServiceInformer,\n\t\tgatewayInformer,\n\t\tcfg.GlooNamespaces,\n\t}, nil\n}\n\nfunc (gs *glooSource) AddEventHandler(_ context.Context, _ func()) {\n}\n\n// Endpoints returns endpoint objects\nfunc (gs *glooSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) {\n\tendpoints := []*endpoint.Endpoint{}\n\n\tfor _, ns := range gs.glooNamespaces {\n\t\tproxyObjects, err := gs.proxyInformer.Lister().ByNamespace(ns).List(labels.Everything())\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tfor _, obj := range proxyObjects {\n\t\t\tunstructuredObj, ok := obj.(*unstructured.Unstructured)\n\t\t\tif !ok {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\tjsonData, err := json.Marshal(unstructuredObj.Object)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\tvar proxy proxy\n\t\t\tif err = json.Unmarshal(jsonData, &proxy); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tlog.Debugf(\"Gloo: Find %s proxy\", proxy.Metadata.Name)\n\n\t\t\tproxyTargets := annotations.TargetsFromTargetAnnotation(proxy.Metadata.Annotations)\n\t\t\tif len(proxyTargets) == 0 {\n\t\t\t\tproxyTargets, err = gs.targetsFromGatewayIngress(&proxy)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif len(proxyTargets) == 0 {\n\t\t\t\tproxyTargets, err = gs.proxyTargets(proxy.Metadata.Name, ns)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\t\t\t}\n\t\t\tlog.Debugf(\"Gloo[%s]: Find %d target(s) (%+v)\", proxy.Metadata.Name, len(proxyTargets), proxyTargets)\n\n\t\t\tproxyEndpoints, err := gs.generateEndpointsFromProxy(&proxy, proxyTargets)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tlog.Debugf(\"Gloo[%s]: Generate %d endpoint(s)\", proxy.Metadata.Name, len(proxyEndpoints))\n\t\t\tendpoints = append(endpoints, proxyEndpoints...)\n\t\t}\n\t}\n\treturn MergeEndpoints(endpoints), nil\n}\n\nfunc (gs *glooSource) generateEndpointsFromProxy(proxy *proxy, targets endpoint.Targets) ([]*endpoint.Endpoint, error) {\n\tendpoints := []*endpoint.Endpoint{}\n\n\tresource := fmt.Sprintf(\"proxy/%s/%s\", proxy.Metadata.Namespace, proxy.Metadata.Name)\n\n\tfor _, listener := range proxy.Spec.Listeners {\n\t\tfor _, virtualHost := range listener.HTTPListener.VirtualHosts {\n\t\t\tants, err := gs.annotationsFromProxySource(virtualHost)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tttl := annotations.TTLFromAnnotations(ants, resource)\n\t\t\tproviderSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(ants)\n\t\t\tfor _, domain := range virtualHost.Domains {\n\t\t\t\tendpoints = append(endpoints, endpoint.EndpointsForHostname(strings.TrimSuffix(domain, \".\"), targets, ttl, providerSpecific, setIdentifier, \"\")...)\n\t\t\t}\n\t\t}\n\t}\n\treturn endpoints, nil\n}\n\nfunc (gs *glooSource) annotationsFromProxySource(virtualHost proxyVirtualHost) (map[string]string, error) {\n\tants := map[string]string{}\n\tfor _, src := range virtualHost.Metadata.Source {\n\t\tif src.Kind != \"*v1.VirtualService\" {\n\t\t\tlog.Debugf(\"Unsupported listener source. Expecting '*v1.VirtualService', got (%s)\", src.Kind)\n\t\t\tcontinue\n\t\t}\n\n\t\tvirtualServiceObj, err := gs.virtualServiceInformer.Lister().ByNamespace(src.Namespace).Get(src.Name)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tunstructuredVirtualService, ok := virtualServiceObj.(*unstructured.Unstructured)\n\t\tif !ok {\n\t\t\tlog.Error(\"unexpected object: it is not *unstructured.Unstructured\")\n\t\t\tcontinue\n\t\t}\n\n\t\tmaps.Copy(ants, unstructuredVirtualService.GetAnnotations())\n\t}\n\n\tfor _, src := range virtualHost.MetadataStatic.Source {\n\t\tif src.ResourceKind != \"*v1.VirtualService\" {\n\t\t\tlog.Debugf(\"Unsupported listener source. Expecting '*v1.VirtualService', got (%s)\", src.ResourceKind)\n\t\t\tcontinue\n\t\t}\n\t\tvirtualServiceObj, err := gs.virtualServiceInformer.Lister().ByNamespace(src.ResourceRef.Namespace).Get(src.ResourceRef.Name)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tunstructuredVirtualService, ok := virtualServiceObj.(*unstructured.Unstructured)\n\t\tif !ok {\n\t\t\tlog.Error(\"unexpected object: it is not *unstructured.Unstructured\")\n\t\t\tcontinue\n\t\t}\n\n\t\tmaps.Copy(ants, unstructuredVirtualService.GetAnnotations())\n\t}\n\treturn ants, nil\n}\n\nfunc (gs *glooSource) proxyTargets(name string, namespace string) (endpoint.Targets, error) {\n\tsvc, err := gs.serviceInformer.Lister().Services(namespace).Get(name)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar targets endpoint.Targets\n\tswitch svc.Spec.Type {\n\tcase corev1.ServiceTypeLoadBalancer:\n\t\tfor _, lb := range svc.Status.LoadBalancer.Ingress {\n\t\t\tif lb.IP != \"\" {\n\t\t\t\ttargets = append(targets, lb.IP)\n\t\t\t}\n\t\t\tif lb.Hostname != \"\" {\n\t\t\t\ttargets = append(targets, lb.Hostname)\n\t\t\t}\n\t\t}\n\tdefault:\n\t\tlog.WithField(\"gateway\", name).WithField(\"service\", svc).Warn(\"Gloo: Proxy service type not supported\")\n\t}\n\treturn targets, nil\n}\n\nfunc (gs *glooSource) targetsFromGatewayIngress(proxy *proxy) (endpoint.Targets, error) {\n\ttargets := make(endpoint.Targets, 0)\n\n\tfor _, listener := range proxy.Spec.Listeners {\n\t\tfor _, source := range listener.MetadataStatic.Source {\n\t\t\tif source.ResourceKind != \"*v1.Gateway\" {\n\t\t\t\tlog.Debugf(\"Unsupported listener source. Expecting '*v1.Gateway', got (%s)\", source.ResourceKind)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tgatewayObj, err := gs.gatewayInformer.Lister().ByNamespace(source.ResourceRef.Namespace).Get(source.ResourceRef.Name)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tunstructuredGateway, ok := gatewayObj.(*unstructured.Unstructured)\n\t\t\tif !ok {\n\t\t\t\tlog.Error(\"unexpected object: it is not *unstructured.Unstructured\")\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif ingressStr, ok := unstructuredGateway.GetAnnotations()[annotations.Ingress]; ok && ingressStr != \"\" {\n\t\t\t\tnamespace, name, err := ParseIngress(ingressStr)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, fmt.Errorf(\"failed to parse Ingress annotation on Gateway (%s/%s): %w\", unstructuredGateway.GetNamespace(), unstructuredGateway.GetName(), err)\n\t\t\t\t}\n\t\t\t\tif namespace == \"\" {\n\t\t\t\t\tnamespace = unstructuredGateway.GetNamespace()\n\t\t\t\t}\n\n\t\t\t\tingress, err := gs.ingressInformer.Lister().Ingresses(namespace).Get(name)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\n\t\t\t\tfor _, lb := range ingress.Status.LoadBalancer.Ingress {\n\t\t\t\t\tif lb.IP != \"\" {\n\t\t\t\t\t\ttargets = append(targets, lb.IP)\n\t\t\t\t\t} else if lb.Hostname != \"\" {\n\t\t\t\t\t\ttargets = append(targets, lb.Hostname)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn targets, nil\n}\n"
  },
  {
    "path": "source/gloo_proxy_test.go",
    "content": "/*\nCopyright 2020n The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\tcorev1 \"k8s.io/api/core/v1\"\n\tnetworkingv1 \"k8s.io/api/networking/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured\"\n\t\"k8s.io/apimachinery/pkg/runtime\"\n\t\"k8s.io/apimachinery/pkg/runtime/schema\"\n\tfakeDynamic \"k8s.io/client-go/dynamic/fake\"\n\tfakeKube \"k8s.io/client-go/kubernetes/fake\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\n// This is a compile-time validation that glooSource is a Source.\nvar _ Source = &glooSource{}\n\nconst defaultGlooNamespace = \"gloo-system\"\n\n// Internal proxy test\nvar internalProxy = proxy{\n\tTypeMeta: metav1.TypeMeta{\n\t\tAPIVersion: proxyGVR.GroupVersion().String(),\n\t\tKind:       \"Proxy\",\n\t},\n\tMetadata: metav1.ObjectMeta{\n\t\tName:      \"internal\",\n\t\tNamespace: defaultGlooNamespace,\n\t},\n\tSpec: proxySpec{\n\t\tListeners: []proxySpecListener{\n\t\t\t{\n\t\t\t\tHTTPListener: proxySpecHTTPListener{\n\t\t\t\t\tVirtualHosts: []proxyVirtualHost{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tDomains: []string{\"a.test\", \"b.test\"},\n\t\t\t\t\t\t\tMetadata: proxyVirtualHostMetadata{\n\t\t\t\t\t\t\t\tSource: []proxyVirtualHostMetadataSource{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tKind:      \"*v1.Unknown\",\n\t\t\t\t\t\t\t\t\t\tName:      \"my-unknown-svc\",\n\t\t\t\t\t\t\t\t\t\tNamespace: \"unknown\",\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tDomains: []string{\"c.test\"},\n\t\t\t\t\t\t\tMetadata: proxyVirtualHostMetadata{\n\t\t\t\t\t\t\t\tSource: []proxyVirtualHostMetadataSource{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tKind:      \"*v1.VirtualService\",\n\t\t\t\t\t\t\t\t\t\tName:      \"my-internal-svc\",\n\t\t\t\t\t\t\t\t\t\tNamespace: \"internal\",\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t},\n}\n\nvar internalProxySvc = corev1.Service{\n\tObjectMeta: metav1.ObjectMeta{\n\t\tName:      internalProxy.Metadata.Name,\n\t\tNamespace: internalProxy.Metadata.Namespace,\n\t},\n\tSpec: corev1.ServiceSpec{\n\t\tType: corev1.ServiceTypeLoadBalancer,\n\t},\n\tStatus: corev1.ServiceStatus{\n\t\tLoadBalancer: corev1.LoadBalancerStatus{\n\t\t\tIngress: []corev1.LoadBalancerIngress{\n\t\t\t\t{\n\t\t\t\t\tIP: \"203.0.113.1\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tIP: \"203.0.113.2\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tIP: \"203.0.113.3\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t},\n}\n\nvar internalProxySource = metav1.PartialObjectMetadata{\n\tTypeMeta: metav1.TypeMeta{\n\t\tAPIVersion: virtualServiceGVR.GroupVersion().String(),\n\t\tKind:       \"VirtualService\",\n\t},\n\tObjectMeta: metav1.ObjectMeta{\n\t\tName:      internalProxy.Spec.Listeners[0].HTTPListener.VirtualHosts[1].Metadata.Source[0].Name,\n\t\tNamespace: internalProxy.Spec.Listeners[0].HTTPListener.VirtualHosts[1].Metadata.Source[0].Namespace,\n\t\tAnnotations: map[string]string{\n\t\t\t\"external-dns.alpha.kubernetes.io/ttl\":                          \"42\",\n\t\t\t\"external-dns.alpha.kubernetes.io/aws-geolocation-country-code\": \"LU\",\n\t\t\t\"external-dns.alpha.kubernetes.io/set-identifier\":               \"identifier\",\n\t\t},\n\t},\n}\n\n// External proxy test\nvar externalProxy = proxy{\n\tTypeMeta: metav1.TypeMeta{\n\t\tAPIVersion: proxyGVR.GroupVersion().String(),\n\t\tKind:       \"Proxy\",\n\t},\n\tMetadata: metav1.ObjectMeta{\n\t\tName:      \"external\",\n\t\tNamespace: defaultGlooNamespace,\n\t},\n\tSpec: proxySpec{\n\t\tListeners: []proxySpecListener{\n\t\t\t{\n\t\t\t\tHTTPListener: proxySpecHTTPListener{\n\t\t\t\t\tVirtualHosts: []proxyVirtualHost{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tDomains: []string{\"d.test\"},\n\t\t\t\t\t\t\tMetadata: proxyVirtualHostMetadata{\n\t\t\t\t\t\t\t\tSource: []proxyVirtualHostMetadataSource{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tKind:      \"*v1.Unknown\",\n\t\t\t\t\t\t\t\t\t\tName:      \"my-unknown-svc\",\n\t\t\t\t\t\t\t\t\t\tNamespace: \"unknown\",\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tDomains: []string{\"e.test\"},\n\t\t\t\t\t\t\tMetadata: proxyVirtualHostMetadata{\n\t\t\t\t\t\t\t\tSource: []proxyVirtualHostMetadataSource{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tKind:      \"*v1.VirtualService\",\n\t\t\t\t\t\t\t\t\t\tName:      \"my-external-svc\",\n\t\t\t\t\t\t\t\t\t\tNamespace: \"external\",\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t},\n}\n\nvar externalProxySvc = corev1.Service{\n\tObjectMeta: metav1.ObjectMeta{\n\t\tName:      externalProxy.Metadata.Name,\n\t\tNamespace: externalProxy.Metadata.Namespace,\n\t},\n\tSpec: corev1.ServiceSpec{\n\t\tType: corev1.ServiceTypeLoadBalancer,\n\t},\n\tStatus: corev1.ServiceStatus{\n\t\tLoadBalancer: corev1.LoadBalancerStatus{\n\t\t\tIngress: []corev1.LoadBalancerIngress{\n\t\t\t\t{\n\t\t\t\t\tHostname: \"a.example.org\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tHostname: \"b.example.org\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tHostname: \"c.example.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t},\n}\n\nvar externalProxySource = metav1.PartialObjectMetadata{\n\tTypeMeta: metav1.TypeMeta{\n\t\tAPIVersion: virtualServiceGVR.GroupVersion().String(),\n\t\tKind:       \"VirtualService\",\n\t},\n\tObjectMeta: metav1.ObjectMeta{\n\t\tName:      externalProxy.Spec.Listeners[0].HTTPListener.VirtualHosts[1].Metadata.Source[0].Name,\n\t\tNamespace: externalProxy.Spec.Listeners[0].HTTPListener.VirtualHosts[1].Metadata.Source[0].Namespace,\n\t\tAnnotations: map[string]string{\n\t\t\t\"external-dns.alpha.kubernetes.io/ttl\":                          \"24\",\n\t\t\t\"external-dns.alpha.kubernetes.io/aws-geolocation-country-code\": \"JP\",\n\t\t\t\"external-dns.alpha.kubernetes.io/set-identifier\":               \"identifier-external\",\n\t\t},\n\t},\n}\n\n// Proxy with metadata static test\nvar proxyWithMetadataStatic = proxy{\n\tTypeMeta: metav1.TypeMeta{\n\t\tAPIVersion: proxyGVR.GroupVersion().String(),\n\t\tKind:       \"Proxy\",\n\t},\n\tMetadata: metav1.ObjectMeta{\n\t\tName:      \"internal-static\",\n\t\tNamespace: defaultGlooNamespace,\n\t},\n\tSpec: proxySpec{\n\t\tListeners: []proxySpecListener{\n\t\t\t{\n\t\t\t\tHTTPListener: proxySpecHTTPListener{\n\t\t\t\t\tVirtualHosts: []proxyVirtualHost{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tDomains: []string{\"f.test\", \"g.test\"},\n\t\t\t\t\t\t\tMetadataStatic: proxyVirtualHostMetadataStatic{\n\t\t\t\t\t\t\t\tSource: []proxyVirtualHostMetadataStaticSource{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tResourceKind: \"*v1.Unknown\",\n\t\t\t\t\t\t\t\t\t\tResourceRef: proxyVirtualHostMetadataSourceResourceRef{\n\t\t\t\t\t\t\t\t\t\t\tName:      \"my-unknown-svc\",\n\t\t\t\t\t\t\t\t\t\t\tNamespace: \"unknown\",\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tDomains: []string{\"h.test\"},\n\t\t\t\t\t\t\tMetadataStatic: proxyVirtualHostMetadataStatic{\n\t\t\t\t\t\t\t\tSource: []proxyVirtualHostMetadataStaticSource{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tResourceKind: \"*v1.VirtualService\",\n\t\t\t\t\t\t\t\t\t\tResourceRef: proxyVirtualHostMetadataSourceResourceRef{\n\t\t\t\t\t\t\t\t\t\t\tName:      \"my-internal-static-svc\",\n\t\t\t\t\t\t\t\t\t\t\tNamespace: \"internal-static\",\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t},\n}\n\nvar proxyWithMetadataStaticSvc = corev1.Service{\n\tObjectMeta: metav1.ObjectMeta{\n\t\tName:      proxyWithMetadataStatic.Metadata.Name,\n\t\tNamespace: proxyWithMetadataStatic.Metadata.Namespace,\n\t},\n\tSpec: corev1.ServiceSpec{\n\t\tType: corev1.ServiceTypeLoadBalancer,\n\t},\n\tStatus: corev1.ServiceStatus{\n\t\tLoadBalancer: corev1.LoadBalancerStatus{\n\t\t\tIngress: []corev1.LoadBalancerIngress{\n\t\t\t\t{\n\t\t\t\t\tIP: \"203.0.115.1\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tIP: \"203.0.115.2\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tIP: \"203.0.115.3\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t},\n}\n\nvar proxyWithMetadataStaticSource = metav1.PartialObjectMetadata{\n\tTypeMeta: metav1.TypeMeta{\n\t\tAPIVersion: virtualServiceGVR.GroupVersion().String(),\n\t\tKind:       \"VirtualService\",\n\t},\n\tObjectMeta: metav1.ObjectMeta{\n\t\tName:      proxyWithMetadataStatic.Spec.Listeners[0].HTTPListener.VirtualHosts[1].MetadataStatic.Source[0].ResourceRef.Name,\n\t\tNamespace: proxyWithMetadataStatic.Spec.Listeners[0].HTTPListener.VirtualHosts[1].MetadataStatic.Source[0].ResourceRef.Namespace,\n\t\tAnnotations: map[string]string{\n\t\t\t\"external-dns.alpha.kubernetes.io/ttl\":                          \"420\",\n\t\t\t\"external-dns.alpha.kubernetes.io/aws-geolocation-country-code\": \"ES\",\n\t\t\t\"external-dns.alpha.kubernetes.io/set-identifier\":               \"identifier\",\n\t\t},\n\t},\n}\n\n// Proxy with target annotation test\nvar targetAnnotatedProxy = proxy{\n\tTypeMeta: metav1.TypeMeta{\n\t\tAPIVersion: proxyGVR.GroupVersion().String(),\n\t\tKind:       \"Proxy\",\n\t},\n\tMetadata: metav1.ObjectMeta{\n\t\tName:      \"target-ann\",\n\t\tNamespace: defaultGlooNamespace,\n\t\tAnnotations: map[string]string{\n\t\t\t\"external-dns.alpha.kubernetes.io/target\": \"203.2.45.7\",\n\t\t},\n\t},\n\tSpec: proxySpec{\n\t\tListeners: []proxySpecListener{\n\t\t\t{\n\t\t\t\tHTTPListener: proxySpecHTTPListener{\n\t\t\t\t\tVirtualHosts: []proxyVirtualHost{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tDomains: []string{\"i.test\"},\n\t\t\t\t\t\t\tMetadata: proxyVirtualHostMetadata{\n\t\t\t\t\t\t\t\tSource: []proxyVirtualHostMetadataSource{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tKind:      \"*v1.Unknown\",\n\t\t\t\t\t\t\t\t\t\tName:      \"my-unknown-svc\",\n\t\t\t\t\t\t\t\t\t\tNamespace: \"unknown\",\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tDomains: []string{\"j.test\"},\n\t\t\t\t\t\t\tMetadata: proxyVirtualHostMetadata{\n\t\t\t\t\t\t\t\tSource: []proxyVirtualHostMetadataSource{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tKind:      \"*v1.VirtualService\",\n\t\t\t\t\t\t\t\t\t\tName:      \"my-annotated-svc\",\n\t\t\t\t\t\t\t\t\t\tNamespace: \"internal\",\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t},\n}\n\nvar targetAnnotatedProxySvc = corev1.Service{\n\tObjectMeta: metav1.ObjectMeta{\n\t\tName:      targetAnnotatedProxy.Metadata.Name,\n\t\tNamespace: targetAnnotatedProxy.Metadata.Namespace,\n\t},\n\tSpec: corev1.ServiceSpec{\n\t\tType: corev1.ServiceTypeLoadBalancer,\n\t},\n\tStatus: corev1.ServiceStatus{\n\t\tLoadBalancer: corev1.LoadBalancerStatus{\n\t\t\tIngress: []corev1.LoadBalancerIngress{\n\t\t\t\t{\n\t\t\t\t\tIP: \"203.1.115.1\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tIP: \"203.1.115.2\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tIP: \"203.1.115.3\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t},\n}\n\nvar targetAnnotatedProxySource = metav1.PartialObjectMetadata{\n\tTypeMeta: metav1.TypeMeta{\n\t\tAPIVersion: virtualServiceGVR.GroupVersion().String(),\n\t\tKind:       \"VirtualService\",\n\t},\n\tObjectMeta: metav1.ObjectMeta{\n\t\tName:      targetAnnotatedProxy.Spec.Listeners[0].HTTPListener.VirtualHosts[1].Metadata.Source[0].Name,\n\t\tNamespace: targetAnnotatedProxy.Spec.Listeners[0].HTTPListener.VirtualHosts[1].Metadata.Source[0].Namespace,\n\t\tAnnotations: map[string]string{\n\t\t\t\"external-dns.alpha.kubernetes.io/ttl\":                          \"460\",\n\t\t\t\"external-dns.alpha.kubernetes.io/aws-geolocation-country-code\": \"IT\",\n\t\t\t\"external-dns.alpha.kubernetes.io/set-identifier\":               \"identifier-annotated\",\n\t\t},\n\t},\n}\n\n// Proxy backed by Ingress\nvar gatewayIngressAnnotatedProxy = proxy{\n\tTypeMeta: metav1.TypeMeta{\n\t\tAPIVersion: proxyGVR.GroupVersion().String(),\n\t\tKind:       \"Proxy\",\n\t},\n\tMetadata: metav1.ObjectMeta{\n\t\tName:      \"gateway-ingress-annotated\",\n\t\tNamespace: defaultGlooNamespace,\n\t},\n\tSpec: proxySpec{\n\t\tListeners: []proxySpecListener{\n\t\t\t{\n\t\t\t\tHTTPListener: proxySpecHTTPListener{\n\t\t\t\t\tVirtualHosts: []proxyVirtualHost{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tDomains: []string{\"k.test\"},\n\t\t\t\t\t\t\tMetadataStatic: proxyVirtualHostMetadataStatic{\n\t\t\t\t\t\t\t\tSource: []proxyVirtualHostMetadataStaticSource{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tResourceKind: \"*v1.Unknown\",\n\t\t\t\t\t\t\t\t\t\tResourceRef: proxyVirtualHostMetadataSourceResourceRef{\n\t\t\t\t\t\t\t\t\t\t\tName:      \"my-unknown-svc\",\n\t\t\t\t\t\t\t\t\t\t\tNamespace: \"unknown\",\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tMetadataStatic: proxyMetadataStatic{\n\t\t\t\t\tSource: []proxyMetadataStaticSource{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tResourceKind: \"*v1.Gateway\",\n\t\t\t\t\t\t\tResourceRef: proxyMetadataStaticSourceResourceRef{\n\t\t\t\t\t\t\t\tName:      \"gateway-ingress-annotated\",\n\t\t\t\t\t\t\t\tNamespace: defaultGlooNamespace,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t},\n}\n\nvar gatewayIngressAnnotatedProxyGateway = metav1.PartialObjectMetadata{\n\tTypeMeta: metav1.TypeMeta{\n\t\tAPIVersion: gatewayGVR.GroupVersion().String(),\n\t\tKind:       \"Gateway\",\n\t},\n\tObjectMeta: metav1.ObjectMeta{\n\t\tName:      gatewayIngressAnnotatedProxy.Spec.Listeners[0].MetadataStatic.Source[0].ResourceRef.Name,\n\t\tNamespace: gatewayIngressAnnotatedProxy.Spec.Listeners[0].MetadataStatic.Source[0].ResourceRef.Namespace,\n\t\tAnnotations: map[string]string{\n\t\t\t\"external-dns.alpha.kubernetes.io/ingress\": fmt.Sprintf(\"%s/%s\", gatewayIngressAnnotatedProxy.Spec.Listeners[0].MetadataStatic.Source[0].ResourceRef.Namespace, gatewayIngressAnnotatedProxy.Spec.Listeners[0].MetadataStatic.Source[0].ResourceRef.Name),\n\t\t},\n\t},\n}\n\nvar gatewayIngressAnnotatedProxyIngress = networkingv1.Ingress{\n\tObjectMeta: metav1.ObjectMeta{\n\t\tName:      gatewayIngressAnnotatedProxy.Spec.Listeners[0].MetadataStatic.Source[0].ResourceRef.Name,\n\t\tNamespace: gatewayIngressAnnotatedProxy.Spec.Listeners[0].MetadataStatic.Source[0].ResourceRef.Namespace,\n\t},\n\tStatus: networkingv1.IngressStatus{\n\t\tLoadBalancer: networkingv1.IngressLoadBalancerStatus{\n\t\t\tIngress: []networkingv1.IngressLoadBalancerIngress{\n\t\t\t\t{\n\t\t\t\t\tHostname: \"example.com\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t},\n}\n\nfunc TestGlooSource(t *testing.T) {\n\tt.Parallel()\n\n\tfakeKubernetesClient := fakeKube.NewSimpleClientset()\n\tfakeDynamicClient := fakeDynamic.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(),\n\t\tmap[schema.GroupVersionResource]string{\n\t\t\tproxyGVR:          \"ProxyList\",\n\t\t\tvirtualServiceGVR: \"VirtualServiceList\",\n\t\t\tgatewayGVR:        \"GatewayList\",\n\t\t})\n\n\tinternalProxyUnstructured := unstructured.Unstructured{}\n\texternalProxyUnstructured := unstructured.Unstructured{}\n\tgatewayIngressAnnotatedProxyUnstructured := unstructured.Unstructured{}\n\tgatewayIngressAnnotatedProxyGatewayUnstructured := unstructured.Unstructured{}\n\tproxyMetadataStaticUnstructured := unstructured.Unstructured{}\n\ttargetAnnotatedProxyUnstructured := unstructured.Unstructured{}\n\n\tinternalProxySourceUnstructured := unstructured.Unstructured{}\n\texternalProxySourceUnstructured := unstructured.Unstructured{}\n\tproxyMetadataStaticSourceUnstructured := unstructured.Unstructured{}\n\ttargetAnnotatedProxySourceUnstructured := unstructured.Unstructured{}\n\n\tinternalProxyAsJSON, err := json.Marshal(internalProxy)\n\tassert.NoError(t, err)\n\n\texternalProxyAsJSON, err := json.Marshal(externalProxy)\n\tassert.NoError(t, err)\n\n\tgatewayIngressAnnotatedProxyAsJSON, err := json.Marshal(gatewayIngressAnnotatedProxy)\n\tassert.NoError(t, err)\n\n\tgatewayIngressAnnotatedProxyGatewayAsJSON, err := json.Marshal(gatewayIngressAnnotatedProxyGateway)\n\tassert.NoError(t, err)\n\n\tproxyMetadataStaticAsJSON, err := json.Marshal(proxyWithMetadataStatic)\n\tassert.NoError(t, err)\n\n\ttargetAnnotatedProxyAsJSON, err := json.Marshal(targetAnnotatedProxy)\n\tassert.NoError(t, err)\n\n\tinternalProxySvcAsJSON, err := json.Marshal(internalProxySource)\n\tassert.NoError(t, err)\n\n\texternalProxySvcAsJSON, err := json.Marshal(externalProxySource)\n\tassert.NoError(t, err)\n\n\tproxyMetadataStaticSvcAsJSON, err := json.Marshal(proxyWithMetadataStaticSource)\n\tassert.NoError(t, err)\n\n\ttargetAnnotatedProxySvcAsJSON, err := json.Marshal(targetAnnotatedProxySource)\n\tassert.NoError(t, err)\n\n\tassert.NoError(t, internalProxyUnstructured.UnmarshalJSON(internalProxyAsJSON))\n\tassert.NoError(t, externalProxyUnstructured.UnmarshalJSON(externalProxyAsJSON))\n\tassert.NoError(t, gatewayIngressAnnotatedProxyUnstructured.UnmarshalJSON(gatewayIngressAnnotatedProxyAsJSON))\n\tassert.NoError(t, gatewayIngressAnnotatedProxyGatewayUnstructured.UnmarshalJSON(gatewayIngressAnnotatedProxyGatewayAsJSON))\n\tassert.NoError(t, proxyMetadataStaticUnstructured.UnmarshalJSON(proxyMetadataStaticAsJSON))\n\tassert.NoError(t, targetAnnotatedProxyUnstructured.UnmarshalJSON(targetAnnotatedProxyAsJSON))\n\n\tassert.NoError(t, internalProxySourceUnstructured.UnmarshalJSON(internalProxySvcAsJSON))\n\tassert.NoError(t, externalProxySourceUnstructured.UnmarshalJSON(externalProxySvcAsJSON))\n\tassert.NoError(t, proxyMetadataStaticSourceUnstructured.UnmarshalJSON(proxyMetadataStaticSvcAsJSON))\n\tassert.NoError(t, targetAnnotatedProxySourceUnstructured.UnmarshalJSON(targetAnnotatedProxySvcAsJSON))\n\n\t_, err = fakeKubernetesClient.CoreV1().Services(internalProxySvc.GetNamespace()).Create(t.Context(), &internalProxySvc, metav1.CreateOptions{})\n\tassert.NoError(t, err)\n\t_, err = fakeKubernetesClient.CoreV1().Services(externalProxySvc.GetNamespace()).Create(t.Context(), &externalProxySvc, metav1.CreateOptions{})\n\tassert.NoError(t, err)\n\t_, err = fakeKubernetesClient.CoreV1().Services(proxyWithMetadataStaticSvc.GetNamespace()).Create(t.Context(), &proxyWithMetadataStaticSvc, metav1.CreateOptions{})\n\tassert.NoError(t, err)\n\t_, err = fakeKubernetesClient.CoreV1().Services(targetAnnotatedProxySvc.GetNamespace()).Create(t.Context(), &targetAnnotatedProxySvc, metav1.CreateOptions{})\n\tassert.NoError(t, err)\n\n\t_, err = fakeKubernetesClient.NetworkingV1().Ingresses(gatewayIngressAnnotatedProxyIngress.GetNamespace()).Create(t.Context(), &gatewayIngressAnnotatedProxyIngress, metav1.CreateOptions{})\n\tassert.NoError(t, err)\n\n\t// Create proxy resources\n\t_, err = fakeDynamicClient.Resource(proxyGVR).Namespace(defaultGlooNamespace).Create(t.Context(), &internalProxyUnstructured, metav1.CreateOptions{})\n\tassert.NoError(t, err)\n\t_, err = fakeDynamicClient.Resource(proxyGVR).Namespace(defaultGlooNamespace).Create(t.Context(), &externalProxyUnstructured, metav1.CreateOptions{})\n\tassert.NoError(t, err)\n\t_, err = fakeDynamicClient.Resource(proxyGVR).Namespace(defaultGlooNamespace).Create(t.Context(), &proxyMetadataStaticUnstructured, metav1.CreateOptions{})\n\tassert.NoError(t, err)\n\t_, err = fakeDynamicClient.Resource(proxyGVR).Namespace(defaultGlooNamespace).Create(t.Context(), &targetAnnotatedProxyUnstructured, metav1.CreateOptions{})\n\tassert.NoError(t, err)\n\t_, err = fakeDynamicClient.Resource(proxyGVR).Namespace(defaultGlooNamespace).Create(t.Context(), &gatewayIngressAnnotatedProxyUnstructured, metav1.CreateOptions{})\n\tassert.NoError(t, err)\n\n\t// Create proxy source\n\t_, err = fakeDynamicClient.Resource(virtualServiceGVR).Namespace(internalProxySource.Namespace).Create(t.Context(), &internalProxySourceUnstructured, metav1.CreateOptions{})\n\tassert.NoError(t, err)\n\t_, err = fakeDynamicClient.Resource(virtualServiceGVR).Namespace(externalProxySource.Namespace).Create(t.Context(), &externalProxySourceUnstructured, metav1.CreateOptions{})\n\tassert.NoError(t, err)\n\t_, err = fakeDynamicClient.Resource(virtualServiceGVR).Namespace(proxyWithMetadataStaticSource.Namespace).Create(t.Context(), &proxyMetadataStaticSourceUnstructured, metav1.CreateOptions{})\n\tassert.NoError(t, err)\n\t_, err = fakeDynamicClient.Resource(virtualServiceGVR).Namespace(targetAnnotatedProxySource.Namespace).Create(t.Context(), &targetAnnotatedProxySourceUnstructured, metav1.CreateOptions{})\n\tassert.NoError(t, err)\n\n\t// Create gateway resource\n\t_, err = fakeDynamicClient.Resource(gatewayGVR).Namespace(gatewayIngressAnnotatedProxyGateway.Namespace).Create(t.Context(), &gatewayIngressAnnotatedProxyGatewayUnstructured, metav1.CreateOptions{})\n\tassert.NoError(t, err)\n\n\tsource, err := NewGlooSource(t.Context(), fakeDynamicClient, fakeKubernetesClient, &Config{\n\t\tGlooNamespaces: []string{defaultGlooNamespace},\n\t})\n\tassert.NoError(t, err)\n\tassert.NotNil(t, source)\n\n\tendpoints, err := source.Endpoints(t.Context())\n\tassert.NoError(t, err)\n\tassert.Len(t, endpoints, 11)\n\n\tassert.ElementsMatch(t, endpoints, []*endpoint.Endpoint{\n\t\t{\n\t\t\tDNSName:          \"a.test\",\n\t\t\tTargets:          []string{internalProxySvc.Status.LoadBalancer.Ingress[0].IP, internalProxySvc.Status.LoadBalancer.Ingress[1].IP, internalProxySvc.Status.LoadBalancer.Ingress[2].IP},\n\t\t\tRecordType:       endpoint.RecordTypeA,\n\t\t\tRecordTTL:        0,\n\t\t\tLabels:           endpoint.Labels{},\n\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t},\n\t\t{\n\t\t\tDNSName:          \"b.test\",\n\t\t\tTargets:          []string{internalProxySvc.Status.LoadBalancer.Ingress[0].IP, internalProxySvc.Status.LoadBalancer.Ingress[1].IP, internalProxySvc.Status.LoadBalancer.Ingress[2].IP},\n\t\t\tRecordType:       endpoint.RecordTypeA,\n\t\t\tRecordTTL:        0,\n\t\t\tLabels:           endpoint.Labels{},\n\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t},\n\t\t{\n\t\t\tDNSName:       \"c.test\",\n\t\t\tTargets:       []string{internalProxySvc.Status.LoadBalancer.Ingress[0].IP, internalProxySvc.Status.LoadBalancer.Ingress[1].IP, internalProxySvc.Status.LoadBalancer.Ingress[2].IP},\n\t\t\tRecordType:    endpoint.RecordTypeA,\n\t\t\tSetIdentifier: \"identifier\",\n\t\t\tRecordTTL:     42,\n\t\t\tLabels:        endpoint.Labels{},\n\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\tendpoint.ProviderSpecificProperty{\n\t\t\t\t\tName:  \"aws/geolocation-country-code\",\n\t\t\t\t\tValue: \"LU\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:          \"d.test\",\n\t\t\tTargets:          []string{externalProxySvc.Status.LoadBalancer.Ingress[0].Hostname, externalProxySvc.Status.LoadBalancer.Ingress[1].Hostname, externalProxySvc.Status.LoadBalancer.Ingress[2].Hostname},\n\t\t\tRecordType:       endpoint.RecordTypeCNAME,\n\t\t\tRecordTTL:        0,\n\t\t\tLabels:           endpoint.Labels{},\n\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t},\n\t\t{\n\t\t\tDNSName:       \"e.test\",\n\t\t\tTargets:       []string{externalProxySvc.Status.LoadBalancer.Ingress[0].Hostname, externalProxySvc.Status.LoadBalancer.Ingress[1].Hostname, externalProxySvc.Status.LoadBalancer.Ingress[2].Hostname},\n\t\t\tRecordType:    endpoint.RecordTypeCNAME,\n\t\t\tSetIdentifier: \"identifier-external\",\n\t\t\tRecordTTL:     24,\n\t\t\tLabels:        endpoint.Labels{},\n\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\tendpoint.ProviderSpecificProperty{\n\t\t\t\t\tName:  \"aws/geolocation-country-code\",\n\t\t\t\t\tValue: \"JP\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:          \"f.test\",\n\t\t\tTargets:          []string{proxyWithMetadataStaticSvc.Status.LoadBalancer.Ingress[0].IP, proxyWithMetadataStaticSvc.Status.LoadBalancer.Ingress[1].IP, proxyWithMetadataStaticSvc.Status.LoadBalancer.Ingress[2].IP},\n\t\t\tRecordType:       endpoint.RecordTypeA,\n\t\t\tRecordTTL:        0,\n\t\t\tLabels:           endpoint.Labels{},\n\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t},\n\t\t{\n\t\t\tDNSName:          \"g.test\",\n\t\t\tTargets:          []string{proxyWithMetadataStaticSvc.Status.LoadBalancer.Ingress[0].IP, proxyWithMetadataStaticSvc.Status.LoadBalancer.Ingress[1].IP, proxyWithMetadataStaticSvc.Status.LoadBalancer.Ingress[2].IP},\n\t\t\tRecordType:       endpoint.RecordTypeA,\n\t\t\tRecordTTL:        0,\n\t\t\tLabels:           endpoint.Labels{},\n\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t},\n\t\t{\n\t\t\tDNSName:       \"h.test\",\n\t\t\tTargets:       []string{proxyWithMetadataStaticSvc.Status.LoadBalancer.Ingress[0].IP, proxyWithMetadataStaticSvc.Status.LoadBalancer.Ingress[1].IP, proxyWithMetadataStaticSvc.Status.LoadBalancer.Ingress[2].IP},\n\t\t\tRecordType:    endpoint.RecordTypeA,\n\t\t\tSetIdentifier: \"identifier\",\n\t\t\tRecordTTL:     420,\n\t\t\tLabels:        endpoint.Labels{},\n\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\tendpoint.ProviderSpecificProperty{\n\t\t\t\t\tName:  \"aws/geolocation-country-code\",\n\t\t\t\t\tValue: \"ES\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:          \"i.test\",\n\t\t\tTargets:          []string{\"203.2.45.7\"},\n\t\t\tRecordType:       endpoint.RecordTypeA,\n\t\t\tLabels:           endpoint.Labels{},\n\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t},\n\t\t{\n\t\t\tDNSName:       \"j.test\",\n\t\t\tTargets:       []string{\"203.2.45.7\"},\n\t\t\tRecordType:    endpoint.RecordTypeA,\n\t\t\tSetIdentifier: \"identifier-annotated\",\n\t\t\tRecordTTL:     460,\n\t\t\tLabels:        endpoint.Labels{},\n\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\tendpoint.ProviderSpecificProperty{\n\t\t\t\t\tName:  \"aws/geolocation-country-code\",\n\t\t\t\t\tValue: \"IT\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tDNSName:          \"k.test\",\n\t\t\tTargets:          []string{gatewayIngressAnnotatedProxyIngress.Status.LoadBalancer.Ingress[0].Hostname},\n\t\t\tRecordType:       endpoint.RecordTypeCNAME,\n\t\t\tRecordTTL:        0,\n\t\t\tLabels:           endpoint.Labels{},\n\t\t\tProviderSpecific: endpoint.ProviderSpecific{}},\n\t})\n}\n"
  },
  {
    "path": "source/informers/fake.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n    http://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage informers\n\nimport (\n\t\"github.com/stretchr/testify/mock\"\n\tcorev1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/util/intstr\"\n\tcorev1lister \"k8s.io/client-go/listers/core/v1\"\n\tdiscoveryv1lister \"k8s.io/client-go/listers/discovery/v1\"\n\t\"k8s.io/client-go/tools/cache\"\n)\n\ntype FakeServiceInformer struct {\n\tmock.Mock\n}\n\nfunc (f *FakeServiceInformer) Informer() cache.SharedIndexInformer {\n\targs := f.Called()\n\treturn args.Get(0).(cache.SharedIndexInformer)\n}\n\nfunc (f *FakeServiceInformer) Lister() corev1lister.ServiceLister {\n\treturn corev1lister.NewServiceLister(f.Informer().GetIndexer())\n}\n\ntype FakeEndpointSliceInformer struct {\n\tmock.Mock\n}\n\nfunc (f *FakeEndpointSliceInformer) Informer() cache.SharedIndexInformer {\n\targs := f.Called()\n\treturn args.Get(0).(cache.SharedIndexInformer)\n}\n\nfunc (f *FakeEndpointSliceInformer) Lister() discoveryv1lister.EndpointSliceLister {\n\treturn discoveryv1lister.NewEndpointSliceLister(f.Informer().GetIndexer())\n}\n\ntype FakeNodeInformer struct {\n\tmock.Mock\n}\n\nfunc (f *FakeNodeInformer) Informer() cache.SharedIndexInformer {\n\targs := f.Called()\n\treturn args.Get(0).(cache.SharedIndexInformer)\n}\n\nfunc (f *FakeNodeInformer) Lister() corev1lister.NodeLister {\n\treturn corev1lister.NewNodeLister(f.Informer().GetIndexer())\n}\n\nfunc fakeService() *corev1.Service {\n\treturn &corev1.Service{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:        \"fake-service\",\n\t\t\tNamespace:   \"ns\",\n\t\t\tLabels:      map[string]string{\"env\": \"prod\", \"team\": \"devops\"},\n\t\t\tAnnotations: map[string]string{\"description\": \"Enriched service object\"},\n\t\t\tUID:         \"1234\",\n\t\t},\n\t\tSpec: corev1.ServiceSpec{\n\t\t\tSelector:    map[string]string{\"app\": \"demo\"},\n\t\t\tExternalIPs: []string{\"1.2.3.4\"},\n\t\t\tPorts: []corev1.ServicePort{\n\t\t\t\t{\n\t\t\t\t\tName:       \"http\",\n\t\t\t\t\tPort:       80,\n\t\t\t\t\tTargetPort: intstr.FromInt32(8080),\n\t\t\t\t\tProtocol:   corev1.ProtocolTCP,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tName:       \"https\",\n\t\t\t\t\tPort:       443,\n\t\t\t\t\tTargetPort: intstr.FromInt32(8443),\n\t\t\t\t\tProtocol:   corev1.ProtocolTCP,\n\t\t\t\t},\n\t\t\t},\n\t\t\tType: corev1.ServiceTypeLoadBalancer,\n\t\t},\n\t\tStatus: corev1.ServiceStatus{\n\t\t\tLoadBalancer: corev1.LoadBalancerStatus{\n\t\t\t\tIngress: []corev1.LoadBalancerIngress{\n\t\t\t\t\t{IP: \"5.6.7.8\", Hostname: \"lb.example.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tConditions: []metav1.Condition{\n\t\t\t\t{\n\t\t\t\t\tType:               \"Available\",\n\t\t\t\t\tStatus:             metav1.ConditionTrue,\n\t\t\t\t\tReason:             \"MinimumReplicasAvailable\",\n\t\t\t\t\tMessage:            \"Service is available\",\n\t\t\t\t\tLastTransitionTime: metav1.Now(),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "source/informers/handlers.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n    http://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage informers\n\nimport (\n\tlog \"github.com/sirupsen/logrus\"\n\t\"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured\"\n\t\"k8s.io/client-go/tools/cache\"\n)\n\nfunc DefaultEventHandler(handlers ...func()) cache.ResourceEventHandler {\n\treturn cache.ResourceEventHandlerFuncs{\n\t\tAddFunc: func(obj any) {\n\t\t\tif u, ok := obj.(*unstructured.Unstructured); ok {\n\t\t\t\tlog.WithFields(log.Fields{\n\t\t\t\t\t\"apiVersion\": u.GetAPIVersion(),\n\t\t\t\t\t\"kind\":       u.GetKind(),\n\t\t\t\t\t\"namespace\":  u.GetNamespace(),\n\t\t\t\t\t\"name\":       u.GetName(),\n\t\t\t\t}).Debug(\"added\")\n\t\t\t\tfor _, handler := range handlers {\n\t\t\t\t\thandler()\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "source/informers/handlers_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n    http://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage informers\n\nimport (\n\t\"testing\"\n\n\t\"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured\"\n)\n\nfunc TestDefaultEventHandler_AddFunc(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tobj      any\n\t\texpected bool\n\t}{\n\t\t{\n\t\t\tname:     \"calls handler for unstructured object\",\n\t\t\tobj:      &unstructured.Unstructured{},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"does not call handler for unknown object\",\n\t\t\tobj:      \"not-unstructured\",\n\t\t\texpected: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tcalled := false\n\t\t\thandler := DefaultEventHandler(func() { called = true })\n\t\t\thandler.OnAdd(tt.obj, true)\n\t\t\tif called != tt.expected {\n\t\t\t\tt.Errorf(\"handler called = %v, want %v\", called, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "source/informers/indexers.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n    http://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage informers\n\nimport (\n\t\"fmt\"\n\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\t\"k8s.io/apimachinery/pkg/types\"\n\t\"k8s.io/client-go/tools/cache\"\n\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n)\n\nconst (\n\tIndexWithSelectors = \"withSelectors\"\n)\n\ntype IndexSelectorOptions struct {\n\tannotationFilter labels.Selector\n\tlabelSelector    labels.Selector\n}\n\nfunc IndexSelectorWithAnnotationFilter(input string) func(options *IndexSelectorOptions) {\n\treturn func(options *IndexSelectorOptions) {\n\t\tif input == \"\" {\n\t\t\treturn\n\t\t}\n\t\tselector, err := annotations.ParseFilter(input)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\toptions.annotationFilter = selector\n\t}\n}\n\nfunc IndexSelectorWithLabelSelector(input labels.Selector) func(options *IndexSelectorOptions) {\n\treturn func(options *IndexSelectorOptions) {\n\t\toptions.labelSelector = input\n\t}\n}\n\n// IndexerWithOptions is a generic function that allows adding multiple indexers\n// to a SharedIndexInformer for a specific Kubernetes resource type T. It accepts\n// a variadic list of indexer functions, which define custom indexing logic.\n//\n// Each indexer function is applied to objects of type T, enabling flexible and\n// reusable indexing based on annotations, labels, or other criteria.\n//\n// Example usage:\n// err := IndexerWithOptions[*v1.Pod](\n//\n//\tIndexSelectorWithAnnotationFilter(\"example-annotation\"),\n//\tIndexSelectorWithLabelSelector(labels.SelectorFromSet(labels.Set{\"app\": \"my-app\"})),\n//\n// )\n//\n// This function ensures type safety and simplifies the process of adding\n// custom indexers to informers.\nfunc IndexerWithOptions[T metav1.Object](optFns ...func(options *IndexSelectorOptions)) cache.Indexers {\n\toptions := IndexSelectorOptions{}\n\tfor _, fn := range optFns {\n\t\tfn(&options)\n\t}\n\n\treturn cache.Indexers{\n\t\tIndexWithSelectors: func(obj any) ([]string, error) {\n\t\t\tentity, ok := obj.(T)\n\t\t\tif !ok {\n\t\t\t\treturn nil, fmt.Errorf(\"object is not of type %T\", new(T))\n\t\t\t}\n\n\t\t\tif options.annotationFilter != nil && !options.annotationFilter.Matches(labels.Set(entity.GetAnnotations())) {\n\t\t\t\treturn nil, nil\n\t\t\t}\n\n\t\t\tif options.labelSelector != nil && !options.labelSelector.Matches(labels.Set(entity.GetLabels())) {\n\t\t\t\treturn nil, nil\n\t\t\t}\n\t\t\tkey := types.NamespacedName{Namespace: entity.GetNamespace(), Name: entity.GetName()}.String()\n\t\t\treturn []string{key}, nil\n\t\t},\n\t}\n}\n\n// GetByKey retrieves an object of type T (metav1.Object) from the given cache.Indexer by its key.\n// It returns the object and an error if the retrieval or type assertion fails.\n// If the object does not exist, it returns the zero value of T and nil.\nfunc GetByKey[T metav1.Object](indexer cache.Indexer, key string) (T, error) {\n\tvar entity T\n\tobj, exists, err := indexer.GetByKey(key)\n\tif err != nil || !exists {\n\t\treturn entity, err\n\t}\n\n\tentity, ok := obj.(T)\n\tif !ok {\n\t\treturn entity, fmt.Errorf(\"object is not of type %T\", new(T))\n\t}\n\treturn entity, nil\n}\n"
  },
  {
    "path": "source/informers/indexers_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n    http://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage informers\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\tcorev1 \"k8s.io/api/core/v1\"\n\t\"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\t\"k8s.io/client-go/tools/cache\"\n\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n)\n\nfunc TestIndexerWithOptions_FilterByAnnotation(t *testing.T) {\n\tindexers := IndexerWithOptions[*unstructured.Unstructured](\n\t\tIndexSelectorWithAnnotationFilter(\"example-annotation\"),\n\t)\n\n\tobj := &unstructured.Unstructured{}\n\tobj.SetAnnotations(map[string]string{\"example-annotation\": \"value\"})\n\tobj.SetNamespace(\"default\")\n\tobj.SetName(\"test-object\")\n\n\tkeys, err := indexers[IndexWithSelectors](obj)\n\tassert.NoError(t, err)\n\tassert.Equal(t, []string{\"default/test-object\"}, keys)\n}\n\nfunc TestIndexerWithOptions_FilterByLabel(t *testing.T) {\n\tlabelSelector := labels.SelectorFromSet(labels.Set{\"app\": \"nginx\"})\n\tindexers := IndexerWithOptions[*corev1.Pod](\n\t\tIndexSelectorWithLabelSelector(labelSelector),\n\t)\n\n\tobj := &corev1.Pod{}\n\tobj.SetLabels(map[string]string{\"app\": \"nginx\"})\n\tobj.SetNamespace(\"default\")\n\tobj.SetName(\"test-object\")\n\n\tkeys, err := indexers[IndexWithSelectors](obj)\n\tassert.NoError(t, err)\n\tassert.Equal(t, []string{\"default/test-object\"}, keys)\n}\n\nfunc TestIndexerWithOptions_NoMatch(t *testing.T) {\n\tlabelSelector := labels.SelectorFromSet(labels.Set{\"app\": \"nginx\"})\n\tindexers := IndexerWithOptions[*unstructured.Unstructured](\n\t\tIndexSelectorWithLabelSelector(labelSelector),\n\t)\n\n\tobj := &unstructured.Unstructured{}\n\tobj.SetLabels(map[string]string{\"app\": \"apache\"})\n\tobj.SetNamespace(\"default\")\n\tobj.SetName(\"test-object\")\n\n\tkeys, err := indexers[IndexWithSelectors](obj)\n\tassert.NoError(t, err)\n\tassert.Nil(t, keys)\n}\n\nfunc TestIndexerWithOptions_InvalidType(t *testing.T) {\n\tindexers := IndexerWithOptions[*unstructured.Unstructured]()\n\n\tobj := \"invalid-object\"\n\n\tkeys, err := indexers[IndexWithSelectors](obj)\n\tassert.Error(t, err)\n\tassert.Nil(t, keys)\n\tassert.Contains(t, err.Error(), \"object is not of type\")\n}\n\nfunc TestIndexerWithOptions_EmptyOptions(t *testing.T) {\n\tindexers := IndexerWithOptions[*unstructured.Unstructured]()\n\n\tobj := &unstructured.Unstructured{}\n\tobj.SetNamespace(\"default\")\n\tobj.SetName(\"test-object\")\n\n\tkeys, err := indexers[\"withSelectors\"](obj)\n\tassert.NoError(t, err)\n\tassert.Equal(t, []string{\"default/test-object\"}, keys)\n}\n\nfunc TestIndexerWithOptions_AnnotationFilterNoMatch(t *testing.T) {\n\tindexers := IndexerWithOptions[*unstructured.Unstructured](\n\t\tIndexSelectorWithAnnotationFilter(\"example-annotation=value\"),\n\t)\n\n\tobj := &unstructured.Unstructured{}\n\tobj.SetAnnotations(map[string]string{\"other-annotation\": \"value\"})\n\tobj.SetNamespace(\"default\")\n\tobj.SetName(\"test-object\")\n\n\tkeys, err := indexers[IndexWithSelectors](obj)\n\tassert.NoError(t, err)\n\tassert.Nil(t, keys)\n}\n\nfunc TestIndexSelectorWithAnnotationFilter(t *testing.T) {\n\ttests := []struct {\n\t\tname           string\n\t\tinput          string\n\t\texpectedFilter labels.Selector\n\t}{\n\t\t{\n\t\t\tname:           \"valid input\",\n\t\t\tinput:          \"key=value\",\n\t\t\texpectedFilter: func() labels.Selector { s, _ := annotations.ParseFilter(\"key=value\"); return s }(),\n\t\t},\n\t\t{\n\t\t\tname:           \"empty input\",\n\t\t\tinput:          \"\",\n\t\t\texpectedFilter: nil,\n\t\t},\n\t\t{\n\t\t\tname:           \"key only filter\",\n\t\t\tinput:          \"app\",\n\t\t\texpectedFilter: func() labels.Selector { s, _ := annotations.ParseFilter(\"app\"); return s }(),\n\t\t},\n\t\t{\n\t\t\tname:           \"poisoned input\",\n\t\t\tinput:          \"=app\",\n\t\t\texpectedFilter: nil,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\toptions := &IndexSelectorOptions{}\n\t\t\tIndexSelectorWithAnnotationFilter(tt.input)(options)\n\t\t\tassert.Equal(t, tt.expectedFilter, options.annotationFilter)\n\t\t})\n\t}\n}\n\nfunc TestGetByKey_ObjectExists(t *testing.T) {\n\tindexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{})\n\tpod := &corev1.Pod{}\n\tpod.SetNamespace(\"default\")\n\tpod.SetName(\"test-pod\")\n\n\terr := indexer.Add(pod)\n\tassert.NoError(t, err)\n\n\tresult, err := GetByKey[*corev1.Pod](indexer, \"default/test-pod\")\n\tassert.NoError(t, err)\n\tassert.NotNil(t, result)\n\tassert.Equal(t, \"test-pod\", result.GetName())\n}\n\nfunc TestGetByKey_ObjectDoesNotExist(t *testing.T) {\n\tindexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{})\n\n\tresult, err := GetByKey[*corev1.Pod](indexer, \"default/non-existent-pod\")\n\tassert.NoError(t, err)\n\tassert.Nil(t, result)\n}\n\nfunc TestGetByKey_TypeAssertionFailure(t *testing.T) {\n\tindexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{})\n\tservice := &corev1.Service{}\n\tservice.SetNamespace(\"default\")\n\tservice.SetName(\"test-service\")\n\n\terr := indexer.Add(service)\n\tassert.NoError(t, err)\n\n\tresult, err := GetByKey[*corev1.Pod](indexer, \"default/test-service\")\n\tassert.Error(t, err)\n\tassert.Contains(t, err.Error(), \"object is not of type\")\n\tassert.Nil(t, result)\n}\n"
  },
  {
    "path": "source/informers/informers.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage informers\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"reflect\"\n\t\"time\"\n\n\t\"k8s.io/apimachinery/pkg/runtime/schema\"\n)\n\nconst (\n\tdefaultRequestTimeout = 60\n)\n\ntype informerFactory interface {\n\tWaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool\n}\n\ntype dynamicInformerFactory interface {\n\tWaitForCacheSync(stopCh <-chan struct{}) map[schema.GroupVersionResource]bool\n}\n\nfunc WaitForCacheSync(ctx context.Context, factory informerFactory) error {\n\treturn waitForCacheSync(ctx, factory.WaitForCacheSync)\n}\n\nfunc WaitForDynamicCacheSync(ctx context.Context, factory dynamicInformerFactory) error {\n\treturn waitForCacheSync(ctx, factory.WaitForCacheSync)\n}\n\n// waitForCacheSync waits for informer caches to sync with a default timeout.\n// Returns an error if any cache fails to sync, wrapping the context error if a timeout occurred.\nfunc waitForCacheSync[K comparable](ctx context.Context, waitFunc func(<-chan struct{}) map[K]bool) error {\n\t// The function receives a ctx but then creates a new timeout,\n\t// effectively overriding whatever deadline the caller may have set.\n\t// If the caller passed a context with a 30s timeout, this function ignores it and waits 60s anyway.\n\ttimeout := defaultRequestTimeout * time.Second\n\tctx, cancel := context.WithTimeout(ctx, timeout)\n\tdefer cancel()\n\tfor typ, done := range waitFunc(ctx.Done()) {\n\t\tif !done {\n\t\t\tif ctx.Err() != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to sync %v after %s: %w\", typ, timeout, ctx.Err())\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"failed to sync %v\", typ)\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "source/informers/informers_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage informers\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"k8s.io/apimachinery/pkg/runtime/schema\"\n)\n\ntype mockInformerFactory struct {\n\tsyncResults map[reflect.Type]bool\n}\n\nfunc (m *mockInformerFactory) WaitForCacheSync(_ <-chan struct{}) map[reflect.Type]bool {\n\treturn m.syncResults\n}\n\ntype mockDynamicInformerFactory struct {\n\tsyncResults map[schema.GroupVersionResource]bool\n}\n\nfunc (m *mockDynamicInformerFactory) WaitForCacheSync(_ <-chan struct{}) map[schema.GroupVersionResource]bool {\n\treturn m.syncResults\n}\n\nfunc TestWaitForCacheSync(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tsyncResults map[reflect.Type]bool\n\t\texpectError bool\n\t\terrorMsg    string\n\t}{\n\t\t{\n\t\t\tname:        \"all caches synced\",\n\t\t\tsyncResults: map[reflect.Type]bool{reflect.TypeFor[string](): true},\n\t\t},\n\t\t{\n\t\t\tname:        \"some caches not synced\",\n\t\t\tsyncResults: map[reflect.Type]bool{reflect.TypeFor[string](): false},\n\t\t\texpectError: true,\n\t\t\terrorMsg:    \"failed to sync string with timeout 1m0s\",\n\t\t},\n\t\t{\n\t\t\tname:        \"context timeout\",\n\t\t\tsyncResults: map[reflect.Type]bool{reflect.TypeFor[string](): false},\n\t\t\texpectError: true,\n\t\t\terrorMsg:    \"failed to sync string with timeout 1m0s\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tctx := t.Context()\n\n\t\t\tfactory := &mockInformerFactory{syncResults: tt.syncResults}\n\t\t\terr := WaitForCacheSync(ctx, factory)\n\n\t\t\tif tt.expectError {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\tassert.Errorf(t, err, tt.errorMsg)\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestWaitForDynamicCacheSync(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tsyncResults map[schema.GroupVersionResource]bool\n\t\texpectError bool\n\t\terrorMsg    string\n\t}{\n\t\t{\n\t\t\tname:        \"all caches synced\",\n\t\t\tsyncResults: map[schema.GroupVersionResource]bool{{}: true},\n\t\t},\n\t\t{\n\t\t\tname:        \"some caches not synced\",\n\t\t\tsyncResults: map[schema.GroupVersionResource]bool{{}: false},\n\t\t\texpectError: true,\n\t\t\terrorMsg:    \"failed to sync string with timeout 1m0s\",\n\t\t},\n\t\t{\n\t\t\tname:        \"context timeout\",\n\t\t\tsyncResults: map[schema.GroupVersionResource]bool{{}: false},\n\t\t\texpectError: true,\n\t\t\terrorMsg:    \"failed to sync string with timeout 1m0s\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tctx := t.Context()\n\n\t\t\tfactory := &mockDynamicInformerFactory{syncResults: tt.syncResults}\n\t\t\terr := WaitForDynamicCacheSync(ctx, factory)\n\n\t\t\tif tt.expectError {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\tassert.Errorf(t, err, tt.errorMsg)\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "source/informers/transfomers.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n    http://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage informers\n\nimport (\n\tcorev1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/client-go/tools/cache\"\n)\n\ntype TransformOptions struct {\n\tspecSelector    bool\n\tspecExternalIps bool\n\tstatusLb        bool\n}\n\nfunc TransformerWithOptions[T metav1.Object](optFns ...func(options *TransformOptions)) cache.TransformFunc {\n\toptions := TransformOptions{}\n\tfor _, fn := range optFns {\n\t\tfn(&options)\n\t}\n\treturn func(obj any) (any, error) {\n\t\t// only transform if the object is a Service at the moment\n\t\tentity, ok := obj.(*corev1.Service)\n\t\tif !ok {\n\t\t\treturn nil, nil\n\t\t}\n\t\tif entity.UID == \"\" {\n\t\t\t// Pod was already transformed and we must be idempotent.\n\t\t\treturn entity, nil\n\t\t}\n\t\tsvc := &corev1.Service{\n\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\tName:              entity.Name,\n\t\t\t\tNamespace:         entity.Namespace,\n\t\t\t\tDeletionTimestamp: entity.DeletionTimestamp,\n\t\t\t},\n\t\t\tSpec:   corev1.ServiceSpec{},\n\t\t\tStatus: corev1.ServiceStatus{},\n\t\t}\n\t\tif options.specSelector {\n\t\t\tsvc.Spec.Selector = entity.Spec.Selector\n\t\t}\n\t\tif options.specExternalIps {\n\t\t\tsvc.Spec.ExternalIPs = entity.Spec.ExternalIPs\n\t\t}\n\t\tif options.statusLb {\n\t\t\tsvc.Status.LoadBalancer = entity.Status.LoadBalancer\n\t\t}\n\t\treturn svc, nil\n\t}\n}\n\n// TransformWithSpecSelector enables copying the Service's .spec.selector field.\nfunc TransformWithSpecSelector() func(options *TransformOptions) {\n\treturn func(options *TransformOptions) {\n\t\toptions.specSelector = true\n\t}\n}\n\n// TransformWithSpecExternalIPs enables copying the Service's .spec.externalIPs field.\nfunc TransformWithSpecExternalIPs() func(options *TransformOptions) {\n\treturn func(options *TransformOptions) {\n\t\toptions.specExternalIps = true\n\t}\n}\n\n// TransformWithStatusLoadBalancer enables copying the Service's .status.loadBalancer field.\nfunc TransformWithStatusLoadBalancer() func(options *TransformOptions) {\n\treturn func(options *TransformOptions) {\n\t\toptions.statusLb = true\n\t}\n}\n"
  },
  {
    "path": "source/informers/transformers_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n    http://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage informers\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\tcorev1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\tkubeinformers \"k8s.io/client-go/informers\"\n\t\"k8s.io/client-go/kubernetes/fake\"\n)\n\nfunc TestTransformerWithOptions_Service(t *testing.T) {\n\tbase := fakeService()\n\n\ttests := []struct {\n\t\tname    string\n\t\toptions []func(*TransformOptions)\n\t\tasserts func(any)\n\t}{\n\t\t{\n\t\t\tname:    \"minimalistic object\",\n\t\t\toptions: nil,\n\t\t\tasserts: func(obj any) {\n\t\t\t\tsvc, ok := obj.(*corev1.Service)\n\t\t\t\tassert.True(t, ok)\n\t\t\t\tassert.Empty(t, svc.UID)\n\t\t\t\tassert.NotEmpty(t, svc.Name)\n\t\t\t\tassert.NotEmpty(t, svc.Namespace)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:    \"with selector\",\n\t\t\toptions: []func(*TransformOptions){TransformWithSpecSelector()},\n\t\t\tasserts: func(obj any) {\n\t\t\t\tsvc, ok := obj.(*corev1.Service)\n\t\t\t\tassert.True(t, ok)\n\t\t\t\tassert.NotEmpty(t, svc.Spec.Selector)\n\t\t\t\tassert.Empty(t, svc.Spec.ExternalIPs)\n\t\t\t\tassert.Empty(t, svc.Status.LoadBalancer.Ingress)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:    \"with selector\",\n\t\t\toptions: []func(*TransformOptions){TransformWithSpecSelector()},\n\t\t\tasserts: func(obj any) {\n\t\t\t\tsvc, ok := obj.(*corev1.Service)\n\t\t\t\tassert.True(t, ok)\n\t\t\t\tassert.NotEmpty(t, svc.Spec.Selector)\n\t\t\t\tassert.Empty(t, svc.Spec.ExternalIPs)\n\t\t\t\tassert.Empty(t, svc.Status.LoadBalancer.Ingress)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:    \"with loadBalancer\",\n\t\t\toptions: []func(*TransformOptions){TransformWithStatusLoadBalancer()},\n\t\t\tasserts: func(obj any) {\n\t\t\t\tsvc, ok := obj.(*corev1.Service)\n\t\t\t\tassert.True(t, ok)\n\t\t\t\tassert.Empty(t, svc.Spec.Selector)\n\t\t\t\tassert.Empty(t, svc.Spec.ExternalIPs)\n\t\t\t\tassert.NotEmpty(t, svc.Status.LoadBalancer.Ingress)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"all options\",\n\t\t\toptions: []func(*TransformOptions){\n\t\t\t\tTransformWithSpecSelector(),\n\t\t\t\tTransformWithSpecExternalIPs(),\n\t\t\t\tTransformWithStatusLoadBalancer(),\n\t\t\t},\n\t\t\tasserts: func(obj any) {\n\t\t\t\tsvc, ok := obj.(*corev1.Service)\n\t\t\t\tassert.True(t, ok)\n\t\t\t\tassert.NotEmpty(t, svc.Spec.Selector)\n\t\t\t\tassert.NotEmpty(t, svc.Spec.ExternalIPs)\n\t\t\t\tassert.NotEmpty(t, svc.Status.LoadBalancer.Ingress)\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\ttransform := TransformerWithOptions[*corev1.Service](tt.options...)\n\t\t\tgot, err := transform(base)\n\t\t\trequire.NoError(t, err)\n\t\t\ttt.asserts(got)\n\t\t})\n\t}\n\n\tt.Run(\"non-service input\", func(t *testing.T) {\n\t\ttransform := TransformerWithOptions[*corev1.Service]()\n\t\tout, err := transform(\"not-a-service\")\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t\t}\n\t\tif out != nil {\n\t\t\tt.Errorf(\"expected nil output for non-service input, got %v\", out)\n\t\t}\n\t})\n}\n\nfunc TestTransformer_Service_WithFakeClient(t *testing.T) {\n\tt.Run(\"with transformer\", func(t *testing.T) {\n\t\tctx := t.Context()\n\t\tsvc := fakeService()\n\t\tfakeClient := fake.NewClientset()\n\n\t\t_, err := fakeClient.CoreV1().Services(svc.Namespace).Create(ctx, svc, metav1.CreateOptions{})\n\t\trequire.NoError(t, err)\n\n\t\tfactory := kubeinformers.NewSharedInformerFactoryWithOptions(fakeClient, 0, kubeinformers.WithNamespace(svc.Namespace))\n\t\tserviceInformer := factory.Core().V1().Services()\n\t\terr = serviceInformer.Informer().SetTransform(TransformerWithOptions[*corev1.Service](\n\t\t\tTransformWithSpecSelector(),\n\t\t\tTransformWithSpecExternalIPs(),\n\t\t\tTransformWithStatusLoadBalancer(),\n\t\t))\n\t\trequire.NoError(t, err)\n\n\t\tfactory.Start(ctx.Done())\n\t\terr = WaitForCacheSync(ctx, factory)\n\t\trequire.NoError(t, err)\n\n\t\tgot, err := serviceInformer.Lister().Services(svc.Namespace).Get(svc.Name)\n\t\trequire.NoError(t, err)\n\n\t\tassert.Equal(t, svc.Spec.Selector, got.Spec.Selector)\n\t\tassert.Equal(t, svc.Spec.ExternalIPs, got.Spec.ExternalIPs)\n\t\tassert.Equal(t, svc.Status.LoadBalancer.Ingress, got.Status.LoadBalancer.Ingress)\n\t\tassert.NotEqual(t, svc.Annotations, got.Annotations)\n\t\tassert.NotEqual(t, svc.Labels, got.Labels)\n\t})\n\n\tt.Run(\"without transformer\", func(t *testing.T) {\n\t\tctx := t.Context()\n\t\tsvc := fakeService()\n\t\tfakeClient := fake.NewClientset()\n\n\t\t_, err := fakeClient.CoreV1().Services(svc.Namespace).Create(ctx, svc, metav1.CreateOptions{})\n\t\trequire.NoError(t, err)\n\n\t\tfactory := kubeinformers.NewSharedInformerFactoryWithOptions(fakeClient, 0, kubeinformers.WithNamespace(svc.Namespace))\n\t\tserviceInformer := factory.Core().V1().Services()\n\n\t\terr = serviceInformer.Informer().GetIndexer().Add(svc)\n\t\trequire.NoError(t, err)\n\n\t\tfactory.Start(ctx.Done())\n\t\terr = WaitForCacheSync(ctx, factory)\n\t\trequire.NoError(t, err)\n\n\t\tgot, err := serviceInformer.Lister().Services(svc.Namespace).Get(svc.Name)\n\t\trequire.NoError(t, err)\n\n\t\tassert.Equal(t, map[string]string{\"app\": \"demo\"}, got.Spec.Selector)\n\t\tassert.Equal(t, []string{\"1.2.3.4\"}, got.Spec.ExternalIPs)\n\t\tassert.Equal(t, svc.Status.LoadBalancer.Ingress, got.Status.LoadBalancer.Ingress)\n\t\tassert.Equal(t, svc.Annotations, got.Annotations)\n\t\tassert.Equal(t, svc.Labels, got.Labels)\n\t})\n}\n"
  },
  {
    "path": "source/ingress.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\t\"text/template\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\tnetworkv1 \"k8s.io/api/networking/v1\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\t\"k8s.io/apimachinery/pkg/selection\"\n\tkubeinformers \"k8s.io/client-go/informers\"\n\tnetinformers \"k8s.io/client-go/informers/networking/v1\"\n\t\"k8s.io/client-go/kubernetes\"\n\n\t\"sigs.k8s.io/external-dns/pkg/events\"\n\t\"sigs.k8s.io/external-dns/source/informers\"\n\t\"sigs.k8s.io/external-dns/source/types\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n\t\"sigs.k8s.io/external-dns/source/fqdn\"\n)\n\nconst (\n\t// Possible values for the ingress-hostname-source annotation\n\tIngressHostnameSourceAnnotationOnlyValue   = \"annotation-only\"\n\tIngressHostnameSourceDefinedHostsOnlyValue = \"defined-hosts-only\"\n\n\tIngressClassAnnotationKey = \"kubernetes.io/ingress.class\"\n)\n\n// ingressSource is an implementation of Source for Kubernetes ingress objects.\n// Ingress implementation will use the spec.rules.host value for the hostname\n// Use annotations.TargetKey to explicitly set Endpoint. (useful if the ingress\n// controller does not update, or to override with alternative endpoint)\n//\n// +externaldns:source:name=ingress\n// +externaldns:source:category=Kubernetes Core\n// +externaldns:source:description=Creates DNS entries based on Kubernetes Ingress resources\n// +externaldns:source:resources=Ingress\n// +externaldns:source:filters=annotation,label\n// +externaldns:source:namespace=all,single\n// +externaldns:source:fqdn-template=true\n// +externaldns:source:provider-specific=true\n// +externaldns:source:events=true\ntype ingressSource struct {\n\tclient                   kubernetes.Interface\n\tnamespace                string\n\tannotationFilter         string\n\tingressClassNames        []string\n\tfqdnTemplate             *template.Template\n\tcombineFQDNAnnotation    bool\n\tignoreHostnameAnnotation bool\n\tingressInformer          netinformers.IngressInformer\n\tignoreIngressTLSSpec     bool\n\tignoreIngressRulesSpec   bool\n\tlabelSelector            labels.Selector\n}\n\n// NewIngressSource creates a new ingressSource with the given config.\nfunc NewIngressSource(\n\tctx context.Context,\n\tkubeClient kubernetes.Interface,\n\tcfg *Config) (Source, error) {\n\ttmpl, err := fqdn.ParseTemplate(cfg.FQDNTemplate)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// ensure that ingress class is only set in either the ingressClassNames or\n\t// annotationFilter but not both\n\tif cfg.IngressClassNames != nil && cfg.AnnotationFilter != \"\" {\n\t\tselector, err := getLabelSelector(cfg.AnnotationFilter)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\trequirements, _ := selector.Requirements()\n\t\tfor _, requirement := range requirements {\n\t\t\tif requirement.Key() == IngressClassAnnotationKey {\n\t\t\t\treturn nil, errors.New(\"--ingress-class is mutually exclusive with the kubernetes.io/ingress.class annotation filter\")\n\t\t\t}\n\t\t}\n\t}\n\t// Use shared informer to listen for add/update/delete of ingresses in the specified namespace.\n\t// Set resync period to 0, to prevent processing when nothing has changed.\n\tinformerFactory := kubeinformers.NewSharedInformerFactoryWithOptions(kubeClient, 0, kubeinformers.WithNamespace(cfg.Namespace))\n\tingressInformer := informerFactory.Networking().V1().Ingresses()\n\n\t// Add default resource event handlers to properly initialize informer.\n\t_, _ = ingressInformer.Informer().AddEventHandler(informers.DefaultEventHandler())\n\n\tinformerFactory.Start(ctx.Done())\n\n\t// wait for the local cache to be populated.\n\tif err := informers.WaitForCacheSync(ctx, informerFactory); err != nil {\n\t\treturn nil, err\n\t}\n\n\tsc := &ingressSource{\n\t\tclient:                   kubeClient,\n\t\tnamespace:                cfg.Namespace,\n\t\tannotationFilter:         cfg.AnnotationFilter,\n\t\tingressClassNames:        cfg.IngressClassNames,\n\t\tfqdnTemplate:             tmpl,\n\t\tcombineFQDNAnnotation:    cfg.CombineFQDNAndAnnotation,\n\t\tignoreHostnameAnnotation: cfg.IgnoreHostnameAnnotation,\n\t\tingressInformer:          ingressInformer,\n\t\tignoreIngressTLSSpec:     cfg.IgnoreIngressTLSSpec,\n\t\tignoreIngressRulesSpec:   cfg.IgnoreIngressRulesSpec,\n\t\tlabelSelector:            cfg.LabelFilter,\n\t}\n\treturn sc, nil\n}\n\n// Endpoints returns endpoint objects for each host-target combination that should be processed.\n// Retrieves all ingress resources on all namespaces\nfunc (sc *ingressSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) {\n\tingresses, err := sc.ingressInformer.Lister().Ingresses(sc.namespace).List(sc.labelSelector)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tingresses, err = annotations.Filter(ingresses, sc.annotationFilter)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tingresses, err = sc.filterByIngressClass(ingresses)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tendpoints := []*endpoint.Endpoint{}\n\n\tfor _, ing := range ingresses {\n\t\tif annotations.IsControllerMismatch(ing, types.Ingress) {\n\t\t\tcontinue\n\t\t}\n\n\t\tingEndpoints := endpointsFromIngress(ing, sc.ignoreHostnameAnnotation, sc.ignoreIngressTLSSpec, sc.ignoreIngressRulesSpec)\n\n\t\t// apply template if host is missing on ingress\n\t\tingEndpoints, err = fqdn.CombineWithTemplatedEndpoints(\n\t\t\tingEndpoints,\n\t\t\tsc.fqdnTemplate,\n\t\t\tsc.combineFQDNAnnotation,\n\t\t\tfunc() ([]*endpoint.Endpoint, error) { return sc.endpointsFromTemplate(ing) },\n\t\t)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif endpoint.HasNoEmptyEndpoints(ingEndpoints, types.Ingress, ing) {\n\t\t\tcontinue\n\t\t}\n\n\t\tendpoint.AttachRefObject(ingEndpoints, events.NewObjectReference(ing, types.Ingress))\n\n\t\tlog.Debugf(\"Endpoints generated from ingress: %s/%s: %v\", ing.Namespace, ing.Name, ingEndpoints)\n\t\tendpoints = append(endpoints, ingEndpoints...)\n\t}\n\n\treturn MergeEndpoints(endpoints), nil\n}\n\nfunc (sc *ingressSource) endpointsFromTemplate(ing *networkv1.Ingress) ([]*endpoint.Endpoint, error) {\n\thostnames, err := fqdn.ExecTemplate(sc.fqdnTemplate, ing)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tresource := fmt.Sprintf(\"ingress/%s/%s\", ing.Namespace, ing.Name)\n\n\tttl := annotations.TTLFromAnnotations(ing.Annotations, resource)\n\n\ttargets := annotations.TargetsFromTargetAnnotation(ing.Annotations)\n\tif len(targets) == 0 {\n\t\ttargets = targetsFromIngressStatus(ing.Status)\n\t}\n\n\tproviderSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(ing.Annotations)\n\n\tvar endpoints []*endpoint.Endpoint\n\tfor _, hostname := range hostnames {\n\t\tendpoints = append(endpoints, endpoint.EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...)\n\t}\n\treturn endpoints, nil\n}\n\n// filterByIngressClass filters a list of ingresses based on a required ingress\n// class\nfunc (sc *ingressSource) filterByIngressClass(ingresses []*networkv1.Ingress) ([]*networkv1.Ingress, error) {\n\t// if no class filter is specified then there's nothing to do\n\tif len(sc.ingressClassNames) == 0 {\n\t\treturn ingresses, nil\n\t}\n\n\tclassNameReq, err := labels.NewRequirement(IngressClassAnnotationKey, selection.In, sc.ingressClassNames)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tselector := labels.NewSelector()\n\tselector = selector.Add(*classNameReq)\n\n\tfilteredList := []*networkv1.Ingress{}\n\n\tfor _, ingress := range ingresses {\n\t\tvar matched = false\n\n\t\tfor _, nameFilter := range sc.ingressClassNames {\n\t\t\tif ingress.Spec.IngressClassName != nil && len(*ingress.Spec.IngressClassName) > 0 {\n\t\t\t\tif nameFilter == *ingress.Spec.IngressClassName {\n\t\t\t\t\tmatched = true\n\t\t\t\t}\n\t\t\t} else if matchLabelSelector(selector, ingress.Annotations) {\n\t\t\t\tmatched = true\n\t\t\t}\n\n\t\t\tif matched {\n\t\t\t\tfilteredList = append(filteredList, ingress)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif !matched {\n\t\t\tlog.Debugf(\"Discarding ingress %s/%s because it does not match required ingress classes %v\", ingress.Namespace, ingress.Name, sc.ingressClassNames)\n\t\t}\n\t}\n\n\treturn filteredList, nil\n}\n\n// endpointsFromIngress extracts the endpoints from ingress object\nfunc endpointsFromIngress(ing *networkv1.Ingress, ignoreHostnameAnnotation bool, ignoreIngressTLSSpec bool, ignoreIngressRulesSpec bool) []*endpoint.Endpoint {\n\tresource := fmt.Sprintf(\"ingress/%s/%s\", ing.Namespace, ing.Name)\n\n\tttl := annotations.TTLFromAnnotations(ing.Annotations, resource)\n\n\ttargets := annotations.TargetsFromTargetAnnotation(ing.Annotations)\n\n\tif len(targets) == 0 {\n\t\ttargets = targetsFromIngressStatus(ing.Status)\n\t}\n\n\tproviderSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(ing.Annotations)\n\n\t// Gather endpoints defined on hosts sections of the ingress\n\tvar definedHostsEndpoints []*endpoint.Endpoint\n\t// Skip endpoints if we do not want entries from Rules section\n\tif !ignoreIngressRulesSpec {\n\t\tfor _, rule := range ing.Spec.Rules {\n\t\t\tif rule.Host == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdefinedHostsEndpoints = append(definedHostsEndpoints, endpoint.EndpointsForHostname(rule.Host, targets, ttl, providerSpecific, setIdentifier, resource)...)\n\t\t}\n\t}\n\n\t// Skip endpoints if we do not want entries from tls spec section\n\tif !ignoreIngressTLSSpec {\n\t\tfor _, tls := range ing.Spec.TLS {\n\t\t\tfor _, host := range tls.Hosts {\n\t\t\t\tif host == \"\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tdefinedHostsEndpoints = append(definedHostsEndpoints, endpoint.EndpointsForHostname(host, targets, ttl, providerSpecific, setIdentifier, resource)...)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Gather endpoints defined on annotations in the ingress\n\tvar annotationEndpoints []*endpoint.Endpoint\n\tif !ignoreHostnameAnnotation {\n\t\tfor _, hostname := range annotations.HostnamesFromAnnotations(ing.Annotations) {\n\t\t\tannotationEndpoints = append(annotationEndpoints, endpoint.EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...)\n\t\t}\n\t}\n\n\t// Determine which hostnames to consider in our final list\n\thostnameSourceAnnotation, hostnameSourceAnnotationExists := ing.Annotations[annotations.IngressHostnameSourceKey]\n\tif !hostnameSourceAnnotationExists {\n\t\treturn append(definedHostsEndpoints, annotationEndpoints...)\n\t}\n\n\t// Include endpoints according to the hostname source annotation in our final list\n\tvar endpoints []*endpoint.Endpoint\n\tif strings.ToLower(hostnameSourceAnnotation) == IngressHostnameSourceDefinedHostsOnlyValue {\n\t\tendpoints = append(endpoints, definedHostsEndpoints...)\n\t}\n\tif strings.ToLower(hostnameSourceAnnotation) == IngressHostnameSourceAnnotationOnlyValue {\n\t\tendpoints = append(endpoints, annotationEndpoints...)\n\t}\n\treturn endpoints\n}\n\n// targetsFromIngressStatus extracts targets from ingress load balancer status.\n// Both IP and Hostname can be set simultaneously (Kubernetes API does not enforce\n// mutual exclusivity), so we collect both when present.\nfunc targetsFromIngressStatus(status networkv1.IngressStatus) endpoint.Targets {\n\tvar targets endpoint.Targets\n\n\tfor _, lb := range status.LoadBalancer.Ingress {\n\t\tif lb.IP != \"\" {\n\t\t\ttargets = append(targets, lb.IP)\n\t\t}\n\t\tif lb.Hostname != \"\" {\n\t\t\ttargets = append(targets, lb.Hostname)\n\t\t}\n\t}\n\n\treturn targets\n}\n\nfunc (sc *ingressSource) AddEventHandler(_ context.Context, handler func()) {\n\tlog.Debug(\"Adding event handler for ingress\")\n\n\t// Right now there is no way to remove event handler from informer, see:\n\t// https://github.com/kubernetes/kubernetes/issues/79610\n\t_, _ = sc.ingressInformer.Informer().AddEventHandler(eventHandlerFunc(handler))\n}\n"
  },
  {
    "path": "source/ingress_fqdn_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n    http://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"sigs.k8s.io/external-dns/internal/testutils\"\n\n\tnetworkv1 \"k8s.io/api/networking/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\t\"k8s.io/client-go/kubernetes/fake\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\nfunc TestIngressSourceNewNodeSourceWithFqdn(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\ttitle            string\n\t\tannotationFilter string\n\t\tfqdnTemplate     string\n\t\texpectError      bool\n\t}{\n\t\t{\n\t\t\ttitle:        \"invalid template\",\n\t\t\texpectError:  true,\n\t\t\tfqdnTemplate: \"{{.Name\",\n\t\t},\n\t\t{\n\t\t\ttitle:       \"valid empty template\",\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\ttitle:        \"valid template\",\n\t\t\texpectError:  false,\n\t\t\tfqdnTemplate: \"{{.Name}}-{{.Namespace}}.ext-dns.test.com\",\n\t\t},\n\t\t{\n\t\t\ttitle:        \"complex template\",\n\t\t\texpectError:  false,\n\t\t\tfqdnTemplate: \"{{range .Status.Addresses}}{{if and (eq .Type \\\"ExternalIP\\\") (isIPv4 .Address)}}{{.Address | replace \\\".\\\" \\\"-\\\"}}{{break}}{{end}}{{end}}.ext-dns.test.com\",\n\t\t},\n\t\t{\n\t\t\ttitle:        \"valid template with multiple hosts\",\n\t\t\texpectError:  false,\n\t\t\tfqdnTemplate: \"{{.Name}}-{{.Namespace}}.ext-dns.test.com, {{.Name}}-{{.Namespace}}.ext-dna.test.com\",\n\t\t},\n\t} {\n\t\tt.Run(tt.title, func(t *testing.T) {\n\t\t\t_, err := NewIngressSource(\n\t\t\t\tt.Context(),\n\t\t\t\tfake.NewClientset(),\n\t\t\t\t&Config{\n\t\t\t\t\tFQDNTemplate: tt.fqdnTemplate,\n\t\t\t\t\tLabelFilter:  labels.NewSelector(),\n\t\t\t\t},\n\t\t\t)\n\n\t\t\tif tt.expectError {\n\t\t\t\tassert.Error(t, err)\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestIngressSourceFqdnTemplatingExamples(t *testing.T) {\n\n\tfor _, tt := range []struct {\n\t\ttitle        string\n\t\tingresses    []*networkv1.Ingress\n\t\tfqdnTemplate string\n\t\texpected     []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\ttitle: \"templating resolve Ingress source hostnames to IP\",\n\t\t\tingresses: []*networkv1.Ingress{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-ingress\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: networkv1.IngressSpec{\n\t\t\t\t\t\tIngressClassName: testutils.ToPtr(\"my-ingress\"),\n\t\t\t\t\t\tRules: []networkv1.IngressRule{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tHost: \"example.org\",\n\t\t\t\t\t\t\t\tIngressRuleValue: networkv1.IngressRuleValue{\n\t\t\t\t\t\t\t\t\tHTTP: &networkv1.HTTPIngressRuleValue{\n\t\t\t\t\t\t\t\t\t\tPaths: []networkv1.HTTPIngressPath{\n\t\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\tBackend: networkv1.IngressBackend{\n\t\t\t\t\t\t\t\t\t\t\t\t\tService: &networkv1.IngressServiceBackend{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tName: \"my-service\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tPort: networkv1.ServiceBackendPort{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tName: \"http\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t\t\tPathType: testutils.ToPtr(networkv1.PathTypePrefix),\n\t\t\t\t\t\t\t\t\t\t\t\tPath:     \"/\",\n\t\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: networkv1.IngressStatus{\n\t\t\t\t\t\tLoadBalancer: networkv1.IngressLoadBalancerStatus{\n\t\t\t\t\t\t\tIngress: []networkv1.IngressLoadBalancerIngress{\n\t\t\t\t\t\t\t\t{Hostname: \"10.200.130.84.nip.io\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{.Name }}.nip.io\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"10.200.130.84.nip.io\"}},\n\t\t\t\t{DNSName: \"my-ingress.nip.io\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"10.200.130.84.nip.io\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"templating resolve hostnames with nip.io\",\n\t\t\tingresses: []*networkv1.Ingress{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-ingress\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: networkv1.IngressSpec{\n\t\t\t\t\t\tIngressClassName: testutils.ToPtr(\"my-ingress\"),\n\t\t\t\t\t\tRules: []networkv1.IngressRule{\n\t\t\t\t\t\t\t{Host: \"example.org\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: networkv1.IngressStatus{\n\t\t\t\t\t\tLoadBalancer: networkv1.IngressLoadBalancerStatus{\n\t\t\t\t\t\t\tIngress: []networkv1.IngressLoadBalancerIngress{\n\t\t\t\t\t\t\t\t{Hostname: \"10.200.130.84.nip.io\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: `{{ range .Status.LoadBalancer.Ingress }}{{ if contains .Hostname \"nip.io\" }}example.org{{end}}{{end}}`,\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"10.200.130.84.nip.io\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"templating resolve hostnames with nip.io and target annotation\",\n\t\t\tingresses: []*networkv1.Ingress{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-ingress\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\": \"10.200.130.84\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: networkv1.IngressSpec{\n\t\t\t\t\t\tIngressClassName: testutils.ToPtr(\"my-ingress\"),\n\t\t\t\t\t\tRules: []networkv1.IngressRule{\n\t\t\t\t\t\t\t{Host: \"example.org\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: networkv1.IngressStatus{\n\t\t\t\t\t\tLoadBalancer: networkv1.IngressLoadBalancerStatus{\n\t\t\t\t\t\t\tIngress: []networkv1.IngressLoadBalancerIngress{\n\t\t\t\t\t\t\t\t{Hostname: \"10.200.130.84.nip.io\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: `{{ range .Status.LoadBalancer.Ingress }}{{ if contains .Hostname \"nip.io\" }}tld.org{{break}}{{end}}{{end}}`,\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.200.130.84\"}},\n\t\t\t\t{DNSName: \"tld.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.200.130.84\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"templating resolve hostnames with nip.io and status IP\",\n\t\t\tingresses: []*networkv1.Ingress{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-ingress\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: networkv1.IngressSpec{\n\t\t\t\t\t\tIngressClassName: testutils.ToPtr(\"my-ingress\"),\n\t\t\t\t\t\tRules: []networkv1.IngressRule{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tHost: \"example.org\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: networkv1.IngressStatus{\n\t\t\t\t\t\tLoadBalancer: networkv1.IngressLoadBalancerStatus{\n\t\t\t\t\t\t\tIngress: []networkv1.IngressLoadBalancerIngress{\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tIP: \"10.200.130.84\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"nip.io\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.200.130.84\"}},\n\t\t\t\t{DNSName: \"nip.io\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.200.130.84\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"templating resolve with different hostnames and rules\",\n\t\t\tingresses: []*networkv1.Ingress{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-ingress\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: networkv1.IngressSpec{\n\t\t\t\t\t\tIngressClassName: testutils.ToPtr(\"ingress-with-override\"),\n\t\t\t\t\t\tRules: []networkv1.IngressRule{\n\t\t\t\t\t\t\t{Host: \"foo.bar.com\"},\n\t\t\t\t\t\t\t{Host: \"bar.bar.com\"},\n\t\t\t\t\t\t\t{Host: \"bar.baz.com\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: networkv1.IngressStatus{\n\t\t\t\t\t\tLoadBalancer: networkv1.IngressLoadBalancerStatus{\n\t\t\t\t\t\t\tIngress: []networkv1.IngressLoadBalancerIngress{\n\t\t\t\t\t\t\t\t{IP: \"192.16.15.25\"},\n\t\t\t\t\t\t\t\t{Hostname: \"abc.org\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: `{{ range .Spec.Rules }}{{ if contains .Host \"bar.com\" }}{{ .Host }}.internal{{break}}{{end}}{{end}}`,\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.bar.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"192.16.15.25\"}},\n\t\t\t\t{DNSName: \"foo.bar.com\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"abc.org\"}},\n\t\t\t\t{DNSName: \"bar.bar.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"192.16.15.25\"}},\n\t\t\t\t{DNSName: \"bar.bar.com\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"abc.org\"}},\n\t\t\t\t{DNSName: \"bar.baz.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"192.16.15.25\"}},\n\t\t\t\t{DNSName: \"bar.baz.com\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"abc.org\"}},\n\t\t\t\t{DNSName: \"foo.bar.com.internal\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"192.16.15.25\"}},\n\t\t\t\t{DNSName: \"foo.bar.com.internal\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"abc.org\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"templating resolve with rules and tls\",\n\t\t\tingresses: []*networkv1.Ingress{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-ingress\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: networkv1.IngressSpec{\n\t\t\t\t\t\tIngressClassName: testutils.ToPtr(\"ingress-with-override\"),\n\t\t\t\t\t\tRules: []networkv1.IngressRule{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tHost: \"foo.bar.com\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\tTLS: []networkv1.IngressTLS{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tHosts: []string{\"https-example.foo.com\", \"https-example.bar.com\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: networkv1.IngressStatus{\n\t\t\t\t\t\tLoadBalancer: networkv1.IngressLoadBalancerStatus{\n\t\t\t\t\t\t\tIngress: []networkv1.IngressLoadBalancerIngress{\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tIP: \"10.09.15.25\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: `{{ .Name }}.test.org,{{ range .Spec.TLS }}{{ range $value := .Hosts }}{{ $value | replace \".\" \"-\" }}.internal{{break}}{{end}}{{end}}`,\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.bar.com\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"10.09.15.25\"}},\n\t\t\t\t{DNSName: \"https-example.foo.com\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"10.09.15.25\"}},\n\t\t\t\t{DNSName: \"https-example.bar.com\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"10.09.15.25\"}},\n\t\t\t\t{DNSName: \"my-ingress.test.org\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"10.09.15.25\"}},\n\t\t\t\t{DNSName: \"https-example-foo-com.internal\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"10.09.15.25\"}},\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(tt.title, func(t *testing.T) {\n\t\t\tkubeClient := fake.NewClientset()\n\n\t\t\tfor _, el := range tt.ingresses {\n\t\t\t\t_, err := kubeClient.NetworkingV1().Ingresses(el.Namespace).Create(t.Context(), el, metav1.CreateOptions{})\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\tsrc, err := NewIngressSource(\n\t\t\t\tt.Context(),\n\t\t\t\tkubeClient,\n\t\t\t\t&Config{\n\t\t\t\t\tFQDNTemplate:             tt.fqdnTemplate,\n\t\t\t\t\tCombineFQDNAndAnnotation: true,\n\t\t\t\t\tLabelFilter:              labels.Everything(),\n\t\t\t\t},\n\t\t\t)\n\n\t\t\trequire.NoError(t, err)\n\n\t\t\tendpoints, err := src.Endpoints(t.Context())\n\t\t\trequire.NoError(t, err)\n\n\t\t\tvalidateEndpoints(t, endpoints, tt.expected)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "source/ingress_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\t\"github.com/stretchr/testify/suite\"\n\tnetworkv1 \"k8s.io/api/networking/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\t\"k8s.io/apimachinery/pkg/runtime\"\n\t\"k8s.io/client-go/kubernetes/fake\"\n\n\t\"sigs.k8s.io/external-dns/internal/testutils\"\n\t\"sigs.k8s.io/external-dns/source/types\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n)\n\n// Validates that ingressSource is a Source\nvar _ Source = &ingressSource{}\n\ntype IngressSuite struct {\n\tsuite.Suite\n\tsc             Source\n\tfooWithTargets *networkv1.Ingress\n}\n\nfunc (suite *IngressSuite) SetupTest() {\n\tfakeClient := fake.NewClientset()\n\n\tsuite.fooWithTargets = (fakeIngress{\n\t\tname:      \"foo-with-targets\",\n\t\tnamespace: \"default\",\n\t\tdnsnames:  []string{\"foo\"},\n\t\tips:       []string{\"8.8.8.8\", \"2606:4700:4700::1111\"},\n\t\thostnames: []string{\"v1\"},\n\t}).Ingress()\n\t_, err := fakeClient.NetworkingV1().Ingresses(suite.fooWithTargets.Namespace).Create(context.Background(), suite.fooWithTargets, metav1.CreateOptions{})\n\tsuite.NoError(err, \"should succeed\")\n\n\tsuite.sc, err = NewIngressSource(\n\t\tcontext.TODO(),\n\t\tfakeClient,\n\t\t&Config{\n\t\t\tFQDNTemplate: \"{{.Name}}\",\n\t\t\tLabelFilter:  labels.Everything(),\n\t\t},\n\t)\n\tsuite.NoError(err, \"should initialize ingress source\")\n}\n\nfunc (suite *IngressSuite) TestResourceLabelIsSet() {\n\tendpoints, _ := suite.sc.Endpoints(context.Background())\n\tfor _, ep := range endpoints {\n\t\tsuite.Equal(\"ingress/default/foo-with-targets\", ep.Labels[endpoint.ResourceLabelKey], \"should set correct resource label\")\n\t}\n}\n\nfunc TestIngress(t *testing.T) {\n\tt.Parallel()\n\n\tsuite.Run(t, new(IngressSuite))\n\tt.Run(\"endpointsFromIngress\", testEndpointsFromIngress)\n\tt.Run(\"endpointsFromIngressHostnameSourceAnnotation\", testEndpointsFromIngressHostnameSourceAnnotation)\n\tt.Run(\"Endpoints\", testIngressEndpoints)\n}\n\nfunc TestNewIngressSource(t *testing.T) {\n\tt.Parallel()\n\n\tfor _, ti := range []struct {\n\t\ttitle                    string\n\t\tannotationFilter         string\n\t\tfqdnTemplate             string\n\t\tcombineFQDNAndAnnotation bool\n\t\texpectError              bool\n\t\tingressClassNames        []string\n\t}{\n\t\t{\n\t\t\ttitle:            \"non-empty annotation filter label\",\n\t\t\texpectError:      false,\n\t\t\tannotationFilter: \"kubernetes.io/ingress.class=nginx\",\n\t\t},\n\t\t{\n\t\t\ttitle:             \"non-empty ingress class name list\",\n\t\t\texpectError:       false,\n\t\t\tingressClassNames: []string{\"internal\", \"external\"},\n\t\t},\n\t\t{\n\t\t\ttitle:             \"ingress class name and annotation filter jointly specified\",\n\t\t\texpectError:       true,\n\t\t\tingressClassNames: []string{\"internal\", \"external\"},\n\t\t\tannotationFilter:  \"kubernetes.io/ingress.class=nginx\",\n\t\t},\n\t} {\n\n\t\tt.Run(ti.title, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\t_, err := NewIngressSource(\n\t\t\t\tt.Context(),\n\t\t\t\tfake.NewClientset(),\n\t\t\t\t&Config{\n\t\t\t\t\tAnnotationFilter:         ti.annotationFilter,\n\t\t\t\t\tFQDNTemplate:             ti.fqdnTemplate,\n\t\t\t\t\tCombineFQDNAndAnnotation: ti.combineFQDNAndAnnotation,\n\t\t\t\t\tIngressClassNames:        ti.ingressClassNames,\n\t\t\t\t},\n\t\t\t)\n\t\t\tif ti.expectError {\n\t\t\t\tassert.Error(t, err)\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc testEndpointsFromIngress(t *testing.T) {\n\tt.Parallel()\n\n\tfor _, ti := range []struct {\n\t\ttitle                    string\n\t\tingress                  fakeIngress\n\t\tignoreHostnameAnnotation bool\n\t\tignoreIngressTLSSpec     bool\n\t\tignoreIngressRulesSpec   bool\n\t\texpected                 []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\ttitle: \"one rule.host one lb.hostname\",\n\t\t\tingress: fakeIngress{\n\t\t\t\tdnsnames:  []string{\"foo.bar\"}, // Kubernetes requires removal of trailing dot\n\t\t\t\thostnames: []string{\"lb.com\"},  // Kubernetes omits the trailing dot\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"one rule.host one lb.IP\",\n\t\t\tingress: fakeIngress{\n\t\t\t\tdnsnames: []string{\"foo.bar\"},\n\t\t\t\tips:      []string{\"8.8.8.8\"},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"one rule.host one lb.IPv6\",\n\t\t\tingress: fakeIngress{\n\t\t\t\tdnsnames: []string{\"foo.bar\"},\n\t\t\t\tips:      []string{\"2606:4700:4700::1111\"},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"2606:4700:4700::1111\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"one rule.host two lb.IP, two lb.IPv6 and two lb.Hostname\",\n\t\t\tingress: fakeIngress{\n\t\t\t\tdnsnames:  []string{\"foo.bar\"},\n\t\t\t\tips:       []string{\"8.8.8.8\", \"127.0.0.1\", \"2606:4700:4700::1111\", \"2606:4700:4700::1001\"},\n\t\t\t\thostnames: []string{\"elb.com\", \"alb.com\"},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\", \"127.0.0.1\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"2606:4700:4700::1111\", \"2606:4700:4700::1001\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"elb.com\", \"alb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"no rule.host\",\n\t\t\tingress: fakeIngress{\n\t\t\t\tips:       []string{\"8.8.8.8\", \"127.0.0.1\"},\n\t\t\t\thostnames: []string{\"elb.com\", \"alb.com\"},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle: \"one empty rule.host\",\n\t\t\tingress: fakeIngress{\n\t\t\t\tdnsnames:  []string{\"\"},\n\t\t\t\tips:       []string{\"8.8.8.8\", \"127.0.0.1\"},\n\t\t\t\thostnames: []string{\"elb.com\", \"alb.com\"},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle: \"no targets\",\n\t\t\tingress: fakeIngress{\n\t\t\t\tdnsnames: []string{\"\"},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle: \"ignore rules with one rule.host one lb.hostname\",\n\t\t\tingress: fakeIngress{\n\t\t\t\tdnsnames:  []string{\"test\"},   // Kubernetes requires removal of trailing dot\n\t\t\t\thostnames: []string{\"lb.com\"}, // Kubernetes omits the trailing dot\n\t\t\t},\n\t\t\texpected:               []*endpoint.Endpoint{},\n\t\t\tignoreIngressRulesSpec: true,\n\t\t},\n\t\t{\n\t\t\ttitle: \"invalid hostname does not generate endpoints\",\n\t\t\tingress: fakeIngress{\n\t\t\t\tdnsnames: []string{\"this-is-an-exceedingly-long-label-that-external-dns-should-reject.example.org\"},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t} {\n\t\tt.Run(ti.title, func(t *testing.T) {\n\t\t\trealIngress := ti.ingress.Ingress()\n\t\t\tvalidateEndpoints(t, endpointsFromIngress(realIngress, ti.ignoreHostnameAnnotation, ti.ignoreIngressTLSSpec, ti.ignoreIngressRulesSpec), ti.expected)\n\t\t})\n\t}\n}\n\nfunc testEndpointsFromIngressHostnameSourceAnnotation(t *testing.T) {\n\t// Host names and host name annotation provided, with various values of the ingress-hostname-source annotation\n\tfor _, ti := range []struct {\n\t\ttitle    string\n\t\tingress  fakeIngress\n\t\texpected []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\ttitle: \"No ingress-hostname-source annotation, one rule.host, one annotation host\",\n\t\t\tingress: fakeIngress{\n\t\t\t\tdnsnames:    []string{\"foo.bar\"},\n\t\t\t\tannotations: map[string]string{annotations.HostnameKey: \"foo.baz\"},\n\t\t\t\thostnames:   []string{\"lb.com\"},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.baz\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"No ingress-hostname-source annotation, one rule.host\",\n\t\t\tingress: fakeIngress{\n\t\t\t\tdnsnames:  []string{\"foo.bar\"},\n\t\t\t\thostnames: []string{\"lb.com\"},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"No ingress-hostname-source annotation, one rule.host, one annotation host\",\n\t\t\tingress: fakeIngress{\n\t\t\t\tdnsnames:    []string{\"foo.bar\"},\n\t\t\t\tannotations: map[string]string{annotations.HostnameKey: \"foo.baz\"},\n\t\t\t\thostnames:   []string{\"lb.com\"},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.baz\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"Ingress-hostname-source=defined-hosts-only, one rule.host, one annotation host\",\n\t\t\tingress: fakeIngress{\n\t\t\t\tdnsnames:    []string{\"foo.bar\"},\n\t\t\t\tannotations: map[string]string{annotations.HostnameKey: \"foo.baz\", annotations.IngressHostnameSourceKey: \"defined-hosts-only\"},\n\t\t\t\thostnames:   []string{\"lb.com\"},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"Ingress-hostname-source=annotation-only, one rule.host, one annotation host\",\n\t\t\tingress: fakeIngress{\n\t\t\t\tdnsnames:    []string{\"foo.bar\"},\n\t\t\t\tannotations: map[string]string{annotations.HostnameKey: \"foo.baz\", annotations.IngressHostnameSourceKey: \"annotation-only\"},\n\t\t\t\thostnames:   []string{\"lb.com\"},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.baz\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(ti.title, func(t *testing.T) {\n\t\t\trealIngress := ti.ingress.Ingress()\n\t\t\tvalidateEndpoints(t, endpointsFromIngress(realIngress, false, false, false), ti.expected)\n\t\t})\n\t}\n}\n\nfunc testIngressEndpoints(t *testing.T) {\n\tt.Parallel()\n\n\tnamespace := \"testing\"\n\tfor _, ti := range []struct {\n\t\ttitle                    string\n\t\ttargetNamespace          string\n\t\tannotationFilter         string\n\t\tingressItems             []fakeIngress\n\t\texpected                 []*endpoint.Endpoint\n\t\texpectError              bool\n\t\tfqdnTemplate             string\n\t\tcombineFQDNAndAnnotation bool\n\t\tignoreHostnameAnnotation bool\n\t\tignoreIngressTLSSpec     bool\n\t\tignoreIngressRulesSpec   bool\n\t\tingressLabelSelector     labels.Selector\n\t\tingressClassNames        []string\n\t}{\n\t\t{\n\t\t\ttitle:           \"no ingress\",\n\t\t\ttargetNamespace: \"\",\n\t\t},\n\t\t{\n\t\t\ttitle:           \"two simple ingresses\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tingressItems: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tdnsnames:  []string{\"example.org\"},\n\t\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake2\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tdnsnames:  []string{\"new.org\"},\n\t\t\t\t\thostnames: []string{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"new.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"ipv6 ingress\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tingressItems: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tdnsnames:  []string{\"example.org\"},\n\t\t\t\t\tips:       []string{\"2001:DB8::1\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"2001:DB8::1\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"dualstack ingress\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tingressItems: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tdnsnames:  []string{\"example.org\"},\n\t\t\t\t\tips:       []string{\"8.8.8.8\", \"2001:DB8::1\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"2001:DB8::1\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:                  \"ignore rules\",\n\t\t\ttargetNamespace:        \"\",\n\t\t\tignoreIngressRulesSpec: true,\n\t\t\tingressItems: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tdnsnames:  []string{\"example.org\"},\n\t\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake2\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tdnsnames:  []string{\"new.org\"},\n\t\t\t\t\thostnames: []string{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"two simple ingresses on different namespaces\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tingressItems: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: \"testing1\",\n\t\t\t\t\tdnsnames:  []string{\"example.org\"},\n\t\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake2\",\n\t\t\t\t\tnamespace: \"testing2\",\n\t\t\t\t\tdnsnames:  []string{\"new.org\"},\n\t\t\t\t\thostnames: []string{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"new.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"two simple ingresses on different namespaces with target namespace\",\n\t\t\ttargetNamespace: \"testing1\",\n\t\t\tingressItems: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: \"testing1\",\n\t\t\t\t\tdnsnames:  []string{\"example.org\"},\n\t\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake2\",\n\t\t\t\t\tnamespace: \"testing2\",\n\t\t\t\t\tdnsnames:  []string{\"new.org\"},\n\t\t\t\t\thostnames: []string{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:            \"valid matching annotation filter expression\",\n\t\t\ttargetNamespace:  \"\",\n\t\t\tannotationFilter: \"kubernetes.io/ingress.class in (alb, nginx)\",\n\t\t\tingressItems: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\": \"nginx\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: []string{\"example.org\"},\n\t\t\t\t\tips:      []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:            \"valid non-matching annotation filter expression\",\n\t\t\ttargetNamespace:  \"\",\n\t\t\tannotationFilter: \"kubernetes.io/ingress.class in (alb, nginx)\",\n\t\t\tingressItems: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\": \"tectonic\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: []string{\"example.org\"},\n\t\t\t\t\tips:      []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:            \"invalid annotation filter expression\",\n\t\t\ttargetNamespace:  \"\",\n\t\t\tannotationFilter: \"kubernetes.io/ingress.name in (a b)\",\n\t\t\tingressItems: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\": \"alb\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: []string{\"example.org\"},\n\t\t\t\t\tips:      []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected:    []*endpoint.Endpoint{},\n\t\t\texpectError: true,\n\t\t},\n\t\t{\n\t\t\ttitle:            \"valid matching annotation filter label\",\n\t\t\ttargetNamespace:  \"\",\n\t\t\tannotationFilter: \"kubernetes.io/ingress.class=nginx\",\n\t\t\tingressItems: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\": \"nginx\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: []string{\"example.org\"},\n\t\t\t\t\tips:      []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:            \"valid non-matching annotation filter label\",\n\t\t\ttargetNamespace:  \"\",\n\t\t\tannotationFilter: \"kubernetes.io/ingress.class=nginx\",\n\t\t\tingressItems: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\": \"alb\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: []string{\"example.org\"},\n\t\t\t\t\tips:      []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"our controller type is dns-controller\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tingressItems: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.ControllerKey: annotations.ControllerValue,\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: []string{\"example.org\"},\n\t\t\t\t\tips:      []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"different controller types are ignored\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tingressItems: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.ControllerKey: \"some-other-tool\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: []string{\"example.org\"},\n\t\t\t\t\tips:      []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"template for ingress if host is missing\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tingressItems: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.ControllerKey: annotations.ControllerValue,\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames:  []string{},\n\t\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\t\thostnames: []string{\"elb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"fake1.ext-dns.test.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"fake1.ext-dns.test.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"elb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{.Name}}.ext-dns.test.com\",\n\t\t},\n\t\t{\n\t\t\ttitle:           \"another controller annotation skipped even with template\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tingressItems: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.ControllerKey: \"other-controller\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: []string{},\n\t\t\t\t\tips:      []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected:     []*endpoint.Endpoint{},\n\t\t\tfqdnTemplate: \"{{.Name}}.ext-dns.test.com\",\n\t\t},\n\t\t{\n\t\t\ttitle:           \"multiple FQDN template hostnames\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tingressItems: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:        \"fake1\",\n\t\t\t\t\tnamespace:   namespace,\n\t\t\t\t\tannotations: map[string]string{},\n\t\t\t\t\tdnsnames:    []string{},\n\t\t\t\t\tips:         []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"fake1.ext-dns.test.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"fake1.ext-dna.test.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{.Name}}.ext-dns.test.com, {{.Name}}.ext-dna.test.com\",\n\t\t},\n\t\t{\n\t\t\ttitle:           \"multiple FQDN template hostnames\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tingressItems: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:        \"fake1\",\n\t\t\t\t\tnamespace:   namespace,\n\t\t\t\t\tannotations: map[string]string{},\n\t\t\t\t\tdnsnames:    []string{},\n\t\t\t\t\tips:         []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake2\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"ingress-target.com\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: []string{\"example.org\"},\n\t\t\t\t\tips:      []string{},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"fake1.ext-dns.test.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"fake1.ext-dna.test.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"ingress-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"fake2.ext-dns.test.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"ingress-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"fake2.ext-dna.test.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"ingress-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate:             \"{{.Name}}.ext-dns.test.com, {{.Name}}.ext-dna.test.com\",\n\t\t\tcombineFQDNAndAnnotation: true,\n\t\t},\n\t\t{\n\t\t\ttitle:           \"ingress rules with annotation\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tingressItems: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"ingress-target.com\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: []string{\"example.org\"},\n\t\t\t\t\tips:      []string{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake2\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"ingress-target.com\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: []string{\"example2.org\"},\n\t\t\t\t\tips:      []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake3\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"1.2.3.4\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: []string{\"example3.org\"},\n\t\t\t\t\tips:      []string{},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"ingress-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example2.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"ingress-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example3.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"ingress rules with single tls having single hostname\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tingressItems: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:        \"fake1\",\n\t\t\t\t\tnamespace:   namespace,\n\t\t\t\t\ttlsdnsnames: [][]string{{\"example.org\"}},\n\t\t\t\t\tips:         []string{\"1.2.3.4\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"ingress rules with single tls having multiple hostnames\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tingressItems: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:        \"fake1\",\n\t\t\t\t\tnamespace:   namespace,\n\t\t\t\t\ttlsdnsnames: [][]string{{\"example.org\", \"example2.org\"}},\n\t\t\t\t\tips:         []string{\"1.2.3.4\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example2.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"ingress rules with multiple tls having multiple hostnames\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tingressItems: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:        \"fake1\",\n\t\t\t\t\tnamespace:   namespace,\n\t\t\t\t\ttlsdnsnames: [][]string{{\"example.org\", \"example2.org\"}, {\"example3.org\", \"example4.org\"}},\n\t\t\t\t\tips:         []string{\"1.2.3.4\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example2.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example3.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example4.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"ingress rules with hostname annotation\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tingressItems: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.HostnameKey: \"dns-through-hostname.com\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: []string{\"example.org\"},\n\t\t\t\t\tips:      []string{\"1.2.3.4\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"dns-through-hostname.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"ingress rules with hostname annotation having multiple hostnames\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tingressItems: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.HostnameKey: \"dns-through-hostname.com, another-dns-through-hostname.com\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: []string{\"example.org\"},\n\t\t\t\t\tips:      []string{\"1.2.3.4\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"dns-through-hostname.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"another-dns-through-hostname.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"ingress rules with hostname and target annotation\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tingressItems: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.HostnameKey: \"dns-through-hostname.com\",\n\t\t\t\t\t\tannotations.TargetKey:   \"ingress-target.com\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: []string{\"example.org\"},\n\t\t\t\t\tips:      []string{},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"ingress-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"dns-through-hostname.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"ingress-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"ingress rules with annotation and custom TTL\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tingressItems: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"ingress-target.com\",\n\t\t\t\t\t\tannotations.TtlKey:    \"6\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: []string{\"example.org\"},\n\t\t\t\t\tips:      []string{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake2\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"ingress-target.com\",\n\t\t\t\t\t\tannotations.TtlKey:    \"1\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: []string{\"example2.org\"},\n\t\t\t\t\tips:      []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake3\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"ingress-target.com\",\n\t\t\t\t\t\tannotations.TtlKey:    \"10s\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: []string{\"example3.org\"},\n\t\t\t\t\tips:      []string{\"8.8.4.4\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"ingress-target.com\"},\n\t\t\t\t\tRecordTTL:  endpoint.TTL(6),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example2.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"ingress-target.com\"},\n\t\t\t\t\tRecordTTL:  endpoint.TTL(1),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example3.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"ingress-target.com\"},\n\t\t\t\t\tRecordTTL:  endpoint.TTL(10),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"ingress rules with alias and target annotation\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tingressItems: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"ingress-target.com\",\n\t\t\t\t\t\tannotations.AliasKey:  \"true\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: []string{\"example.org\"},\n\t\t\t\t\tips:      []string{},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"ingress-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{{\n\t\t\t\t\t\tName: \"alias\", Value: \"true\",\n\t\t\t\t\t}},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"ingress rules with alias set false and target annotation\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tingressItems: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"ingress-target.com\",\n\t\t\t\t\t\tannotations.AliasKey:  \"false\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: []string{\"example.org\"},\n\t\t\t\t\tips:      []string{},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"ingress-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"template for ingress with annotation\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tingressItems: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"ingress-target.com\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames:  []string{},\n\t\t\t\t\tips:       []string{},\n\t\t\t\t\thostnames: []string{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake2\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"ingress-target.com\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: []string{},\n\t\t\t\t\tips:      []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake3\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"1.2.3.4\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames:  []string{},\n\t\t\t\t\tips:       []string{},\n\t\t\t\t\thostnames: []string{},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"fake1.ext-dns.test.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"ingress-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"fake2.ext-dns.test.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"ingress-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"fake3.ext-dns.test.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{.Name}}.ext-dns.test.com\",\n\t\t},\n\t\t{\n\t\t\ttitle:           \"Ingress with empty annotation\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tingressItems: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames:  []string{},\n\t\t\t\t\tips:       []string{},\n\t\t\t\t\thostnames: []string{},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected:     []*endpoint.Endpoint{},\n\t\t\tfqdnTemplate: \"{{.Name}}.ext-dns.test.com\",\n\t\t},\n\t\t{\n\t\t\ttitle:                    \"ignore hostname annotation\",\n\t\t\ttargetNamespace:          \"\",\n\t\t\tignoreHostnameAnnotation: true,\n\t\t\tingressItems: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tdnsnames:  []string{\"example.org\"},\n\t\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake2\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.HostnameKey: \"dns-through-hostname.com\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames:  []string{\"new.org\"},\n\t\t\t\t\thostnames: []string{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"new.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:                \"ignore tls section\",\n\t\t\ttargetNamespace:      \"\",\n\t\t\tignoreIngressTLSSpec: true,\n\t\t\tingressItems: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:        \"fake1\",\n\t\t\t\t\tnamespace:   namespace,\n\t\t\t\t\ttlsdnsnames: [][]string{{\"example.org\"}},\n\t\t\t\t\tips:         []string{\"1.2.3.4\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:                \"reading tls section\",\n\t\t\ttargetNamespace:      \"\",\n\t\t\tignoreIngressTLSSpec: false,\n\t\t\tingressItems: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:        \"fake1\",\n\t\t\t\t\tnamespace:   namespace,\n\t\t\t\t\ttlsdnsnames: [][]string{{\"example.org\"}},\n\t\t\t\t\tips:         []string{\"1.2.3.4\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:             \"ingressClassName filtering\",\n\t\t\ttargetNamespace:   \"\",\n\t\t\tingressClassNames: []string{\"public\", \"dmz\"},\n\t\t\tingressItems: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:        \"none\",\n\t\t\t\t\tnamespace:   namespace,\n\t\t\t\t\ttlsdnsnames: [][]string{{\"none.example.org\"}},\n\t\t\t\t\tips:         []string{\"1.0.0.0\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:             \"fake-public\",\n\t\t\t\t\tnamespace:        namespace,\n\t\t\t\t\ttlsdnsnames:      [][]string{{\"example.org\"}},\n\t\t\t\t\tips:              []string{\"1.2.3.4\"},\n\t\t\t\t\tingressClassName: \"public\", // match\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:             \"fake-internal\",\n\t\t\t\t\tnamespace:        namespace,\n\t\t\t\t\ttlsdnsnames:      [][]string{{\"int.example.org\"}},\n\t\t\t\t\tips:              []string{\"2.3.4.5\"},\n\t\t\t\t\tingressClassName: \"internal\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:             \"fake-dmz\",\n\t\t\t\t\tnamespace:        namespace,\n\t\t\t\t\ttlsdnsnames:      [][]string{{\"dmz.example.org\"}},\n\t\t\t\t\tips:              []string{\"3.4.5.6\"},\n\t\t\t\t\tingressClassName: \"dmz\", // match\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:        \"annotated-dmz\",\n\t\t\t\t\tnamespace:   namespace,\n\t\t\t\t\ttlsdnsnames: [][]string{{\"annodmz.example.org\"}},\n\t\t\t\t\tips:         []string{\"4.5.6.7\"},\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\": \"dmz\", // match\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:        \"fake-internal-annotated-dmz\",\n\t\t\t\t\tnamespace:   namespace,\n\t\t\t\t\ttlsdnsnames: [][]string{{\"int-annodmz.example.org\"}},\n\t\t\t\t\tips:         []string{\"5.6.7.8\"},\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\": \"dmz\", // match but ignored (non-empty ingressClassName)\n\t\t\t\t\t},\n\t\t\t\t\tingressClassName: \"internal\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:        \"fake-dmz-annotated-internal\",\n\t\t\t\t\tnamespace:   namespace,\n\t\t\t\t\ttlsdnsnames: [][]string{{\"dmz-annoint.example.org\"}},\n\t\t\t\t\tips:         []string{\"6.7.8.9\"},\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\": \"internal\",\n\t\t\t\t\t},\n\t\t\t\t\tingressClassName: \"dmz\", // match\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:        \"empty-annotated-dmz\",\n\t\t\t\t\tnamespace:   namespace,\n\t\t\t\t\ttlsdnsnames: [][]string{{\"empty-annotdmz.example.org\"}},\n\t\t\t\t\tips:         []string{\"7.8.9.0\"},\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\": \"dmz\", // match (empty ingressClassName)\n\t\t\t\t\t},\n\t\t\t\t\tingressClassName: \"\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:        \"empty-annotated-internal\",\n\t\t\t\t\tnamespace:   namespace,\n\t\t\t\t\ttlsdnsnames: [][]string{{\"empty-annotint.example.org\"}},\n\t\t\t\t\tips:         []string{\"8.9.0.1\"},\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\": \"internal\",\n\t\t\t\t\t},\n\t\t\t\t\tingressClassName: \"\",\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"dmz.example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"3.4.5.6\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"annodmz.example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"4.5.6.7\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"dmz-annoint.example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"6.7.8.9\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"empty-annotdmz.example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"7.8.9.0\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tingressLabelSelector: labels.SelectorFromSet(labels.Set{\"app\": \"web-external\"}),\n\t\t\ttitle:                \"ingress with matching labels\",\n\t\t\ttargetNamespace:      \"\",\n\t\t\tingressItems: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tdnsnames:  []string{\"example.org\"},\n\t\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\t\tlabels:    map[string]string{\"app\": \"web-external\", \"name\": \"reverse-proxy\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tingressLabelSelector: labels.SelectorFromSet(labels.Set{\"app\": \"web-external\"}),\n\t\t\ttitle:                \"ingress without matching labels\",\n\t\t\ttargetNamespace:      \"\",\n\t\t\tingressItems: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tdnsnames:  []string{\"example.org\"},\n\t\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\t\tlabels:    map[string]string{\"app\": \"web-internal\", \"name\": \"reverse-proxy\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t} {\n\n\t\tt.Run(ti.title, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\tfakeClient := fake.NewClientset()\n\t\t\tfor _, item := range ti.ingressItems {\n\t\t\t\tingress := item.Ingress()\n\t\t\t\t_, err := fakeClient.NetworkingV1().Ingresses(ingress.Namespace).Create(t.Context(), ingress, metav1.CreateOptions{})\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\tif ti.ingressLabelSelector == nil {\n\t\t\t\tti.ingressLabelSelector = labels.Everything()\n\t\t\t}\n\n\t\t\tsource, _ := NewIngressSource(\n\t\t\t\tt.Context(),\n\t\t\t\tfakeClient,\n\t\t\t\t&Config{\n\t\t\t\t\tNamespace:                ti.targetNamespace,\n\t\t\t\t\tAnnotationFilter:         ti.annotationFilter,\n\t\t\t\t\tFQDNTemplate:             ti.fqdnTemplate,\n\t\t\t\t\tCombineFQDNAndAnnotation: ti.combineFQDNAndAnnotation,\n\t\t\t\t\tIgnoreHostnameAnnotation: ti.ignoreHostnameAnnotation,\n\t\t\t\t\tIgnoreIngressTLSSpec:     ti.ignoreIngressTLSSpec,\n\t\t\t\t\tIgnoreIngressRulesSpec:   ti.ignoreIngressRulesSpec,\n\t\t\t\t\tLabelFilter:              ti.ingressLabelSelector,\n\t\t\t\t\tIngressClassNames:        ti.ingressClassNames,\n\t\t\t\t},\n\t\t\t)\n\t\t\t// Informer cache has all of the ingresses. Retrieve and validate their endpoints.\n\t\t\tres, err := source.Endpoints(t.Context())\n\t\t\tif ti.expectError {\n\t\t\t\trequire.Error(t, err)\n\t\t\t} else {\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\t\t\tvalidateEndpoints(t, res, ti.expected)\n\n\t\t\t// TODO; when all resources have the resource label, we could add this check to the validateEndpoints function.\n\t\t\tfor _, ep := range res {\n\t\t\t\trequire.Contains(t, ep.Labels, endpoint.ResourceLabelKey)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// ingress specific helper functions\ntype fakeIngress struct {\n\tdnsnames         []string\n\ttlsdnsnames      [][]string\n\tips              []string\n\thostnames        []string\n\tnamespace        string\n\tname             string\n\tannotations      map[string]string\n\tlabels           map[string]string\n\tingressClassName string\n}\n\nfunc (ing fakeIngress) Ingress() *networkv1.Ingress {\n\tingress := &networkv1.Ingress{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tNamespace:   ing.namespace,\n\t\t\tName:        ing.name,\n\t\t\tAnnotations: ing.annotations,\n\t\t\tLabels:      ing.labels,\n\t\t},\n\t\tSpec: networkv1.IngressSpec{\n\t\t\tRules:            []networkv1.IngressRule{},\n\t\t\tIngressClassName: &ing.ingressClassName,\n\t\t},\n\t\tStatus: networkv1.IngressStatus{\n\t\t\tLoadBalancer: networkv1.IngressLoadBalancerStatus{\n\t\t\t\tIngress: []networkv1.IngressLoadBalancerIngress{},\n\t\t\t},\n\t\t},\n\t}\n\tfor _, dnsname := range ing.dnsnames {\n\t\tingress.Spec.Rules = append(ingress.Spec.Rules, networkv1.IngressRule{\n\t\t\tHost: dnsname,\n\t\t})\n\t}\n\tfor _, hosts := range ing.tlsdnsnames {\n\t\tingress.Spec.TLS = append(ingress.Spec.TLS, networkv1.IngressTLS{\n\t\t\tHosts: hosts,\n\t\t})\n\t}\n\tfor _, ip := range ing.ips {\n\t\tingress.Status.LoadBalancer.Ingress = append(ingress.Status.LoadBalancer.Ingress, networkv1.IngressLoadBalancerIngress{\n\t\t\tIP: ip,\n\t\t})\n\t}\n\tfor _, hostname := range ing.hostnames {\n\t\tingress.Status.LoadBalancer.Ingress = append(ingress.Status.LoadBalancer.Ingress, networkv1.IngressLoadBalancerIngress{\n\t\t\tHostname: hostname,\n\t\t})\n\t}\n\treturn ingress\n}\n\nfunc TestIngressWithConfiguration(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\ttitle     string\n\t\tingresses []*networkv1.Ingress\n\t\tcfg       *Config\n\t\texpected  []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\ttitle: \"hostname and targets configured as annotations\",\n\t\t\tingresses: []*networkv1.Ingress{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-ingress\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.HostnameKey: \"bla.example.org\",\n\t\t\t\t\t\t\tannotations.TargetKey:   \"target.example.org\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: networkv1.IngressSpec{\n\t\t\t\t\t\tIngressClassName: testutils.ToPtr(\"nginx\"),\n\t\t\t\t\t\tRules: []networkv1.IngressRule{\n\t\t\t\t\t\t\t{Host: \"app.example.com\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: networkv1.IngressStatus{\n\t\t\t\t\t\tLoadBalancer: networkv1.IngressLoadBalancerStatus{\n\t\t\t\t\t\t\tIngress: []networkv1.IngressLoadBalancerIngress{\n\t\t\t\t\t\t\t\t{IP: \"1.2.3.4\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"app.example.com\", Targets: endpoint.Targets{\"target.example.org\"}, RecordType: endpoint.RecordTypeCNAME},\n\t\t\t\t{DNSName: \"bla.example.org\", Targets: endpoint.Targets{\"target.example.org\"}, RecordType: endpoint.RecordTypeCNAME},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"ingress with spec.tls with wildcard domains and tls not ignored\",\n\t\t\tcfg: &Config{\n\t\t\t\tIgnoreIngressTLSSpec: false,\n\t\t\t},\n\t\t\tingresses: []*networkv1.Ingress{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:        \"my-ingress\",\n\t\t\t\t\t\tNamespace:   \"default\",\n\t\t\t\t\t\tAnnotations: map[string]string{},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: networkv1.IngressSpec{\n\t\t\t\t\t\tIngressClassName: testutils.ToPtr(\"alb\"),\n\t\t\t\t\t\tTLS: []networkv1.IngressTLS{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tHosts: []string{\"*.example.com\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\tRules: []networkv1.IngressRule{\n\t\t\t\t\t\t\t{Host: \"abc.example.com\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: networkv1.IngressStatus{\n\t\t\t\t\t\tLoadBalancer: networkv1.IngressLoadBalancerStatus{\n\t\t\t\t\t\t\tIngress: []networkv1.IngressLoadBalancerIngress{\n\t\t\t\t\t\t\t\t{IP: \"1.2.3.4\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"abc.example.com\", Targets: endpoint.Targets{\"1.2.3.4\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t\t{DNSName: \"*.example.com\", Targets: endpoint.Targets{\"1.2.3.4\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"ingress with spec.tls with wildcard domains and tls is ignored\",\n\t\t\tcfg: &Config{\n\t\t\t\tIgnoreIngressTLSSpec: true,\n\t\t\t},\n\t\t\tingresses: []*networkv1.Ingress{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-ingress\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: networkv1.IngressSpec{\n\t\t\t\t\t\tIngressClassName: testutils.ToPtr(\"alb\"),\n\t\t\t\t\t\tTLS: []networkv1.IngressTLS{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tHosts: []string{\"*.example.com\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\tRules: []networkv1.IngressRule{\n\t\t\t\t\t\t\t{Host: \"abc.example.com\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: networkv1.IngressStatus{\n\t\t\t\t\t\tLoadBalancer: networkv1.IngressLoadBalancerStatus{\n\t\t\t\t\t\t\tIngress: []networkv1.IngressLoadBalancerIngress{\n\t\t\t\t\t\t\t\t{IP: \"1.2.3.4\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"abc.example.com\", Targets: endpoint.Targets{\"1.2.3.4\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"ingress with when AWS ALB controller and NLB type generates two targets for CNAME\",\n\t\t\tingresses: []*networkv1.Ingress{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-ingress\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\t\"alb.ingress.kubernetes.io/enable-frontend-nlb\": \"true\",\n\t\t\t\t\t\t\t\"alb.ingress.kubernetes.io/frontend-nlb-scheme\": \"internal\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: networkv1.IngressSpec{\n\t\t\t\t\t\tIngressClassName: testutils.ToPtr(\"alb\"),\n\t\t\t\t\t\tRules: []networkv1.IngressRule{\n\t\t\t\t\t\t\t{Host: \"some.subdomain.mydomain.com\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: networkv1.IngressStatus{\n\t\t\t\t\t\tLoadBalancer: networkv1.IngressLoadBalancerStatus{\n\t\t\t\t\t\t\tIngress: []networkv1.IngressLoadBalancerIngress{\n\t\t\t\t\t\t\t\t{Hostname: \"internal-k8s-some-domain.us-east-1.elb.amazonaws.com\"},\n\t\t\t\t\t\t\t\t{Hostname: \"k8s-another-domain-nlb-123456789.elb.us-east-1.amazonaws.com\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"some.subdomain.mydomain.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets: endpoint.Targets{\n\t\t\t\t\t\t\"internal-k8s-some-domain.us-east-1.elb.amazonaws.com\",\n\t\t\t\t\t\t\"k8s-another-domain-nlb-123456789.elb.us-east-1.amazonaws.com\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"ingress with when AWS ALB controller and NLB with target annotation and CNAME with single target\",\n\t\t\tingresses: []*networkv1.Ingress{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-ingress\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\t\"alb.ingress.kubernetes.io/enable-frontend-nlb\": \"true\",\n\t\t\t\t\t\t\t\"alb.ingress.kubernetes.io/frontend-nlb-scheme\": \"internal\",\n\t\t\t\t\t\t\tannotations.TargetKey:                           \"k8s-another-domain-nlb-123456789.elb.us-east-1.amazonaws.com\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: networkv1.IngressSpec{\n\t\t\t\t\t\tIngressClassName: testutils.ToPtr(\"alb\"),\n\t\t\t\t\t\tRules: []networkv1.IngressRule{\n\t\t\t\t\t\t\t{Host: \"some.subdomain.mydomain.com\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: networkv1.IngressStatus{\n\t\t\t\t\t\tLoadBalancer: networkv1.IngressLoadBalancerStatus{\n\t\t\t\t\t\t\tIngress: []networkv1.IngressLoadBalancerIngress{\n\t\t\t\t\t\t\t\t{Hostname: \"internal-k8s-some-domain.us-east-1.elb.amazonaws.com\"},\n\t\t\t\t\t\t\t\t{Hostname: \"k8s-another-domain-nlb-123456789.elb.us-east-1.amazonaws.com\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"some.subdomain.mydomain.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"k8s-another-domain-nlb-123456789.elb.us-east-1.amazonaws.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"no annotations, multiple ingresses, mixed IP and Hostname targets\",\n\t\t\tingresses: []*networkv1.Ingress{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-ingress\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: networkv1.IngressSpec{\n\t\t\t\t\t\tIngressClassName: testutils.ToPtr(\"alb\"),\n\t\t\t\t\t\tRules: []networkv1.IngressRule{\n\t\t\t\t\t\t\t{Host: \"app.example.com\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: networkv1.IngressStatus{\n\t\t\t\t\t\tLoadBalancer: networkv1.IngressLoadBalancerStatus{\n\t\t\t\t\t\t\tIngress: []networkv1.IngressLoadBalancerIngress{\n\t\t\t\t\t\t\t\t{IP: \"1.2.3.4\"},\n\t\t\t\t\t\t\t\t{Hostname: \"foo.tld\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"app.example.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"app.example.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"foo.tld\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(tt.title, func(t *testing.T) {\n\t\t\tkubeClient := fake.NewClientset()\n\n\t\t\tfor _, el := range tt.ingresses {\n\t\t\t\t_, err := kubeClient.NetworkingV1().Ingresses(el.Namespace).Create(t.Context(), el, metav1.CreateOptions{})\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\tif tt.cfg == nil {\n\t\t\t\ttt.cfg = &Config{}\n\t\t\t}\n\t\t\ttt.cfg.LabelFilter = labels.Everything()\n\n\t\t\tsrc, err := NewIngressSource(\n\t\t\t\tt.Context(),\n\t\t\t\tkubeClient,\n\t\t\t\ttt.cfg,\n\t\t\t)\n\t\t\trequire.NoError(t, err)\n\t\t\tendpoints, err := src.Endpoints(t.Context())\n\t\t\trequire.NoError(t, err)\n\t\t\tvalidateEndpoints(t, endpoints, tt.expected)\n\t\t})\n\t}\n}\n\nfunc TestProcessEndpoint_Ingress_RefObjectExist(t *testing.T) {\n\telements := []runtime.Object{\n\t\t&networkv1.Ingress{\n\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\tName: \"foo\",\n\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\tannotations.HostnameKey: \"foo.example.com\",\n\t\t\t\t\tannotations.TargetKey:   \"1.2.3\",\n\t\t\t\t},\n\t\t\t\tUID: \"uid-1\",\n\t\t\t},\n\t\t},\n\t\t&networkv1.Ingress{\n\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\tName: \"bar\",\n\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\tannotations.HostnameKey: \"bar.example.com\",\n\t\t\t\t\tannotations.TargetKey:   \"3.4.5\",\n\t\t\t\t},\n\t\t\t\tUID: \"uid-2\",\n\t\t\t},\n\t\t},\n\t}\n\n\tfakeClient := fake.NewClientset(elements...)\n\n\tclient, err := NewIngressSource(\n\t\tt.Context(),\n\t\tfakeClient,\n\t\t&Config{\n\t\t\tLabelFilter: labels.Everything(),\n\t\t},\n\t)\n\trequire.NoError(t, err)\n\n\tendpoints, err := client.Endpoints(t.Context())\n\trequire.NoError(t, err)\n\ttestutils.AssertEndpointsHaveRefObject(t, endpoints, types.Ingress, len(elements))\n}\n"
  },
  {
    "path": "source/istio_gateway.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\t\"text/template\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\tnetworkingv1beta1 \"istio.io/client-go/pkg/apis/networking/v1beta1\"\n\tistioclient \"istio.io/client-go/pkg/clientset/versioned\"\n\tistioinformers \"istio.io/client-go/pkg/informers/externalversions\"\n\tnetworkingv1beta1informer \"istio.io/client-go/pkg/informers/externalversions/networking/v1beta1\"\n\tcorev1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\tkubeinformers \"k8s.io/client-go/informers\"\n\tcoreinformers \"k8s.io/client-go/informers/core/v1\"\n\tnetinformers \"k8s.io/client-go/informers/networking/v1\"\n\t\"k8s.io/client-go/kubernetes\"\n\n\t\"sigs.k8s.io/external-dns/source/types\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n\t\"sigs.k8s.io/external-dns/source/fqdn\"\n\t\"sigs.k8s.io/external-dns/source/informers\"\n)\n\n// IstioGatewayIngressSource is the annotation used to determine if the gateway is implemented by an Ingress object\n// instead of a standard LoadBalancer service type\n// Using var instead of const because annotation keys can be customized\nvar IstioGatewayIngressSource = annotations.Ingress\n\n// gatewaySource is an implementation of Source for Istio Gateway objects.\n// The gateway implementation uses the spec.servers.hosts values for the hostnames.\n// Use annotations.TargetKey to explicitly set Endpoint.\n//\n// +externaldns:source:name=istio-gateway\n// +externaldns:source:category=Service Mesh\n// +externaldns:source:description=Creates DNS entries from Istio Gateway resources\n// +externaldns:source:resources=Gateway.networking.istio.io\n// +externaldns:source:filters=annotation\n// +externaldns:source:namespace=all,single\n// +externaldns:source:fqdn-template=true\n// +externaldns:source:provider-specific=true\ntype gatewaySource struct {\n\tkubeClient               kubernetes.Interface\n\tistioClient              istioclient.Interface\n\tnamespace                string\n\tannotationFilter         string\n\tfqdnTemplate             *template.Template\n\tcombineFQDNAnnotation    bool\n\tignoreHostnameAnnotation bool\n\tserviceInformer          coreinformers.ServiceInformer\n\tgatewayInformer          networkingv1beta1informer.GatewayInformer\n\tingressInformer          netinformers.IngressInformer\n}\n\n// NewIstioGatewaySource creates a new gatewaySource with the given config.\nfunc NewIstioGatewaySource(\n\tctx context.Context,\n\tkubeClient kubernetes.Interface,\n\tistioClient istioclient.Interface,\n\tcfg *Config,\n) (Source, error) {\n\ttmpl, err := fqdn.ParseTemplate(cfg.FQDNTemplate)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Use shared informers to listen for add/update/delete of services/pods/nodes in the specified namespace.\n\t// Set resync period to 0, to prevent processing when nothing has changed\n\tinformerFactory := kubeinformers.NewSharedInformerFactoryWithOptions(kubeClient, 0, kubeinformers.WithNamespace(cfg.Namespace))\n\tserviceInformer := informerFactory.Core().V1().Services()\n\tistioInformerFactory := istioinformers.NewSharedInformerFactory(istioClient, 0)\n\tgatewayInformer := istioInformerFactory.Networking().V1beta1().Gateways()\n\tingressInformer := informerFactory.Networking().V1().Ingresses()\n\n\t_, _ = ingressInformer.Informer().AddEventHandler(informers.DefaultEventHandler())\n\n\t// Add default resource event handlers to properly initialize informer.\n\t_, _ = serviceInformer.Informer().AddEventHandler(informers.DefaultEventHandler())\n\terr = serviceInformer.Informer().SetTransform(informers.TransformerWithOptions[*corev1.Service](\n\t\tinformers.TransformWithSpecSelector(),\n\t\tinformers.TransformWithSpecExternalIPs(),\n\t\tinformers.TransformWithStatusLoadBalancer(),\n\t))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t_, _ = gatewayInformer.Informer().AddEventHandler(informers.DefaultEventHandler())\n\n\tinformerFactory.Start(ctx.Done())\n\tistioInformerFactory.Start(ctx.Done())\n\n\t// wait for the local cache to be populated.\n\tif err := informers.WaitForCacheSync(ctx, informerFactory); err != nil {\n\t\treturn nil, err\n\t}\n\tif err := informers.WaitForCacheSync(ctx, istioInformerFactory); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &gatewaySource{\n\t\tkubeClient:               kubeClient,\n\t\tistioClient:              istioClient,\n\t\tnamespace:                cfg.Namespace,\n\t\tannotationFilter:         cfg.AnnotationFilter,\n\t\tfqdnTemplate:             tmpl,\n\t\tcombineFQDNAnnotation:    cfg.CombineFQDNAndAnnotation,\n\t\tignoreHostnameAnnotation: cfg.IgnoreHostnameAnnotation,\n\t\tserviceInformer:          serviceInformer,\n\t\tgatewayInformer:          gatewayInformer,\n\t\tingressInformer:          ingressInformer,\n\t}, nil\n}\n\n// Endpoints returns endpoint objects for each host-target combination that should be processed.\n// Retrieves all gateway resources in the source's namespace(s).\nfunc (sc *gatewaySource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) {\n\tgwList, err := sc.istioClient.NetworkingV1beta1().Gateways(sc.namespace).List(ctx, metav1.ListOptions{})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tgateways := gwList.Items\n\tgateways, err = annotations.Filter(gateways, sc.annotationFilter)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar endpoints []*endpoint.Endpoint\n\n\tlog.Debugf(\"Found %d gateways in namespace %s\", len(gateways), sc.namespace)\n\n\tfor _, gateway := range gateways {\n\t\tif annotations.IsControllerMismatch(gateway, types.IstioGateway) {\n\t\t\tcontinue\n\t\t}\n\n\t\tgwHostnames := sc.hostNamesFromGateway(gateway)\n\n\t\tlog.Debugf(\"Processing gateway '%s/%s.%s' and hosts %q\", gateway.Namespace, gateway.APIVersion, gateway.Name, strings.Join(gwHostnames, \",\"))\n\n\t\tgwEndpoints, err := sc.endpointsFromGateway(gwHostnames, gateway)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\t// apply template if host is missing on gateway\n\t\tgwEndpoints, err = fqdn.CombineWithTemplatedEndpoints(\n\t\t\tgwEndpoints,\n\t\t\tsc.fqdnTemplate,\n\t\t\tsc.combineFQDNAnnotation,\n\t\t\tfunc() ([]*endpoint.Endpoint, error) {\n\t\t\t\thostnames, err := fqdn.ExecTemplate(sc.fqdnTemplate, gateway)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\t\t\t\treturn sc.endpointsFromGateway(hostnames, gateway)\n\t\t\t},\n\t\t)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif endpoint.HasNoEmptyEndpoints(gwEndpoints, types.IstioGateway, gateway) {\n\t\t\tcontinue\n\t\t}\n\n\t\tlog.Debugf(\"Endpoints generated from %q '%s/%s.%s': %q\", gateway.Kind, gateway.Namespace, gateway.APIVersion, gateway.Name, gwEndpoints)\n\t\tendpoints = append(endpoints, gwEndpoints...)\n\t}\n\n\treturn MergeEndpoints(endpoints), nil\n}\n\n// AddEventHandler adds an event handler that should be triggered if the watched Istio Gateway changes.\nfunc (sc *gatewaySource) AddEventHandler(_ context.Context, handler func()) {\n\tlog.Debug(\"Adding event handler for Istio Gateway\")\n\n\t_, _ = sc.gatewayInformer.Informer().AddEventHandler(eventHandlerFunc(handler))\n}\n\nfunc (sc *gatewaySource) targetsFromIngress(ingressStr string, gateway *networkingv1beta1.Gateway) (endpoint.Targets, error) {\n\tnamespace, name, err := ParseIngress(ingressStr)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse Ingress annotation on Gateway (%s/%s): %w\", gateway.Namespace, gateway.Name, err)\n\t}\n\tif namespace == \"\" {\n\t\tnamespace = gateway.Namespace\n\t}\n\n\ttargets := make(endpoint.Targets, 0)\n\n\tingress, err := sc.ingressInformer.Lister().Ingresses(namespace).Get(name)\n\tif err != nil {\n\t\tlog.Error(err)\n\t\treturn nil, err\n\t}\n\tfor _, lb := range ingress.Status.LoadBalancer.Ingress {\n\t\tif lb.IP != \"\" {\n\t\t\ttargets = append(targets, lb.IP)\n\t\t} else if lb.Hostname != \"\" {\n\t\t\ttargets = append(targets, lb.Hostname)\n\t\t}\n\t}\n\treturn targets, nil\n}\n\nfunc (sc *gatewaySource) targetsFromGateway(gateway *networkingv1beta1.Gateway) (endpoint.Targets, error) {\n\ttargets := annotations.TargetsFromTargetAnnotation(gateway.Annotations)\n\tif len(targets) > 0 {\n\t\treturn targets, nil\n\t}\n\n\tingressStr, ok := gateway.Annotations[IstioGatewayIngressSource]\n\tif ok && ingressStr != \"\" {\n\t\treturn sc.targetsFromIngress(ingressStr, gateway)\n\t}\n\n\treturn EndpointTargetsFromServices(sc.serviceInformer, sc.namespace, gateway.Spec.Selector)\n}\n\n// endpointsFromGatewayConfig extracts the endpoints from an Istio Gateway Config object\nfunc (sc *gatewaySource) endpointsFromGateway(hostnames []string, gateway *networkingv1beta1.Gateway) ([]*endpoint.Endpoint, error) {\n\tvar endpoints []*endpoint.Endpoint\n\tvar err error\n\n\ttargets, err := sc.targetsFromGateway(gateway)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif len(targets) == 0 {\n\t\treturn endpoints, nil\n\t}\n\n\tresource := fmt.Sprintf(\"gateway/%s/%s\", gateway.Namespace, gateway.Name)\n\tttl := annotations.TTLFromAnnotations(gateway.Annotations, resource)\n\tproviderSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(gateway.Annotations)\n\n\tfor _, host := range hostnames {\n\t\tendpoints = append(endpoints, endpoint.EndpointsForHostname(host, targets, ttl, providerSpecific, setIdentifier, resource)...)\n\t}\n\n\treturn endpoints, nil\n}\n\nfunc (sc *gatewaySource) hostNamesFromGateway(gateway *networkingv1beta1.Gateway) []string {\n\tvar hostnames []string\n\tfor _, server := range gateway.Spec.Servers {\n\t\tfor _, host := range server.Hosts {\n\t\t\tif host == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tparts := strings.Split(host, \"/\")\n\n\t\t\t// If the input hostname is of the form my-namespace/foo.bar.com, remove the namespace\n\t\t\t// before appending it to the list of endpoints to create\n\t\t\tif len(parts) == 2 {\n\t\t\t\thost = parts[1]\n\t\t\t}\n\n\t\t\tif host != \"*\" {\n\t\t\t\thostnames = append(hostnames, host)\n\t\t\t}\n\t\t}\n\t}\n\n\tif !sc.ignoreHostnameAnnotation {\n\t\thostnames = append(hostnames, annotations.HostnamesFromAnnotations(gateway.Annotations)...)\n\t}\n\n\treturn hostnames\n}\n"
  },
  {
    "path": "source/istio_gateway_fqdn_test.go",
    "content": "/*\nCopyright 2026 The Kubernetes Authors.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n    http://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\tistionetworking \"istio.io/api/networking/v1beta1\"\n\tnetworkingv1beta1 \"istio.io/client-go/pkg/apis/networking/v1beta1\"\n\tistiofake \"istio.io/client-go/pkg/clientset/versioned/fake\"\n\tv1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/util/intstr\"\n\t\"k8s.io/client-go/kubernetes/fake\"\n\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\nfunc TestIstioGatewaySourceNewSourceWithFqdn(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\ttitle            string\n\t\tannotationFilter string\n\t\tfqdnTemplate     string\n\t\texpectError      bool\n\t}{\n\t\t{\n\t\t\ttitle:        \"invalid template\",\n\t\t\texpectError:  true,\n\t\t\tfqdnTemplate: \"{{.Name\",\n\t\t},\n\t\t{\n\t\t\ttitle:       \"valid empty template\",\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\ttitle:        \"valid template\",\n\t\t\texpectError:  false,\n\t\t\tfqdnTemplate: \"{{.Name}}-{{.Namespace}}.ext-dns.test.com\",\n\t\t},\n\t\t{\n\t\t\ttitle:        \"valid template with multiple hosts\",\n\t\t\texpectError:  false,\n\t\t\tfqdnTemplate: \"{{.Name}}-{{.Namespace}}.ext-dns.test.com, {{.Name}}-{{.Namespace}}.ext-dna.test.com\",\n\t\t},\n\t} {\n\t\tt.Run(tt.title, func(t *testing.T) {\n\t\t\t_, err := NewIstioGatewaySource(\n\t\t\t\tt.Context(),\n\t\t\t\tfake.NewClientset(),\n\t\t\t\tistiofake.NewSimpleClientset(),\n\t\t\t\t&Config{\n\t\t\t\t\tNamespace:                \"\",\n\t\t\t\t\tAnnotationFilter:         tt.annotationFilter,\n\t\t\t\t\tFQDNTemplate:             tt.fqdnTemplate,\n\t\t\t\t\tCombineFQDNAndAnnotation: false,\n\t\t\t\t\tIgnoreHostnameAnnotation: false,\n\t\t\t\t},\n\t\t\t)\n\n\t\t\tif tt.expectError {\n\t\t\t\tassert.Error(t, err)\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestIstioGatewaySourceFqdnTemplatingExamples(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\ttitle        string\n\t\tgateways     []*networkingv1beta1.Gateway\n\t\tservices     []*v1.Service\n\t\tfqdnTemplate string\n\t\tcombineFqdn  bool\n\t\texpected     []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\ttitle:        \"simple templating with gateway name\",\n\t\t\tfqdnTemplate: \"{{.Name}}.test.com\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{DNSName: \"my-gateway.test.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t\tgateways: []*networkingv1beta1.Gateway{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-gateway\",\n\t\t\t\t\t\tNamespace: \"istio-system\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: istionetworking.Gateway{\n\t\t\t\t\t\tSelector: map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t\tServers: []*istionetworking.Server{\n\t\t\t\t\t\t\t{Hosts: []string{\"example.org\"}},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tservices: []*v1.Service{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"istio-ingressgateway\",\n\t\t\t\t\t\tNamespace: \"istio-system\",\n\t\t\t\t\t\tLabels:    map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:     v1.ServiceTypeLoadBalancer,\n\t\t\t\t\t\tSelector: map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\t\t\tIngress: []v1.LoadBalancerIngress{{IP: \"1.2.3.4\"}},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"templating with fqdn combine disabled\",\n\t\t\tfqdnTemplate: \"{{.Name}}.test.com\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t\tcombineFqdn: true,\n\t\t\tgateways: []*networkingv1beta1.Gateway{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-gateway\",\n\t\t\t\t\t\tNamespace: \"istio-system\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: istionetworking.Gateway{\n\t\t\t\t\t\tSelector: map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t\tServers: []*istionetworking.Server{\n\t\t\t\t\t\t\t{Hosts: []string{\"example.org\"}},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tservices: []*v1.Service{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"istio-ingressgateway\",\n\t\t\t\t\t\tNamespace: \"istio-system\",\n\t\t\t\t\t\tLabels:    map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:     v1.ServiceTypeLoadBalancer,\n\t\t\t\t\t\tSelector: map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\t\t\tIngress: []v1.LoadBalancerIngress{{IP: \"1.2.3.4\"}},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"templating with namespace\",\n\t\t\tfqdnTemplate: \"{{.Name}}.{{.Namespace}}.cluster.local\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"api.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"5.6.7.8\"}},\n\t\t\t\t{DNSName: \"api-gateway.kube-system.cluster.local\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"::ffff:192.1.56.10\"}},\n\t\t\t\t{DNSName: \"api-gateway.production.cluster.local\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"5.6.7.8\"}},\n\t\t\t},\n\t\t\tgateways: []*networkingv1beta1.Gateway{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"api-gateway\",\n\t\t\t\t\t\tNamespace: \"production\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: istionetworking.Gateway{\n\t\t\t\t\t\tSelector: map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t\tServers: []*istionetworking.Server{\n\t\t\t\t\t\t\t{Hosts: []string{\"api.example.org\"}},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"api-gateway\",\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: istionetworking.Gateway{\n\t\t\t\t\t\tSelector: map[string]string{\"istio\": \"ingressgateway-extra\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tservices: []*v1.Service{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"istio-ingressgateway\",\n\t\t\t\t\t\tNamespace: \"production\",\n\t\t\t\t\t\tLabels:    map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:     v1.ServiceTypeLoadBalancer,\n\t\t\t\t\t\tSelector: map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\t\t\tIngress: []v1.LoadBalancerIngress{{IP: \"5.6.7.8\"}},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"kube-metrics-server\",\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tLabels:    map[string]string{\"istio\": \"ingressgateway-extra\"},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:     v1.ServiceTypeLoadBalancer,\n\t\t\t\t\t\tSelector: map[string]string{\"istio\": \"ingressgateway-extra\"},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\t\t\tIngress: []v1.LoadBalancerIngress{{IP: \"::ffff:192.1.56.10\"}},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"templating with complex fqdn template\",\n\t\t\tfqdnTemplate: \"{{.Name}}.example.com,{{.Name}}.example.org\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"multi-gateway.example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.0.0.1\"}},\n\t\t\t\t{DNSName: \"multi-gateway.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.0.0.1\"}},\n\t\t\t},\n\t\t\tgateways: []*networkingv1beta1.Gateway{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"multi-gateway\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: istionetworking.Gateway{\n\t\t\t\t\t\tSelector: map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t\tServers:  []*istionetworking.Server{},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tservices: []*v1.Service{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"istio-ingressgateway\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tLabels:    map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:     v1.ServiceTypeLoadBalancer,\n\t\t\t\t\t\tSelector: map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\t\t\tIngress: []v1.LoadBalancerIngress{{IP: \"10.0.0.1\"}},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"combine FQDN annotation with template\",\n\t\t\tfqdnTemplate: \"{{.Name}}.internal.example.com\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"app.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"172.16.0.1\"}},\n\t\t\t\t{DNSName: \"combined-gateway.internal.example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"172.16.0.1\"}},\n\t\t\t},\n\t\t\tgateways: []*networkingv1beta1.Gateway{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"combined-gateway\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: istionetworking.Gateway{\n\t\t\t\t\t\tSelector: map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t\tServers: []*istionetworking.Server{\n\t\t\t\t\t\t\t{Hosts: []string{\"app.example.org\"}},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tservices: []*v1.Service{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"istio-ingressgateway\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\t\"istio\": \"ingressgateway\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:     v1.ServiceTypeLoadBalancer,\n\t\t\t\t\t\tSelector: map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\t\t\tIngress: []v1.LoadBalancerIngress{{IP: \"172.16.0.1\"}},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"templating with labels\",\n\t\t\tgateways: []*networkingv1beta1.Gateway{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"labeled-gateway\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\t\"environment\": \"staging\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.TargetKey: \"203.0.113.1\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: istionetworking.Gateway{\n\t\t\t\t\t\tSelector: map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t\tServers:  []*istionetworking.Server{},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{.Name}}.{{.Labels.environment}}.example.com\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"labeled-gateway.staging.example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"203.0.113.1\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"srv record with node port and cluster ip services without external ips\",\n\t\t\tfqdnTemplate: \"{{.Name}}.example.com\",\n\t\t\texpected:     []*endpoint.Endpoint{},\n\t\t\tgateways: []*networkingv1beta1.Gateway{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"labeled-gateway\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: istionetworking.Gateway{\n\t\t\t\t\t\tSelector: map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t\tServers:  []*istionetworking.Server{},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tservices: []*v1.Service{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"test-node-port\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\t\"istio\": \"ingressgateway\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:      v1.ServiceTypeNodePort,\n\t\t\t\t\t\tSelector:  map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t\tClusterIP: \"10.96.41.133\",\n\t\t\t\t\t\tPorts: []v1.ServicePort{\n\t\t\t\t\t\t\t{Name: \"dns\", Port: 8082, TargetPort: intstr.FromInt32(8083), Protocol: v1.ProtocolUDP, NodePort: 30083},\n\t\t\t\t\t\t\t{Name: \"dns-tcp\", Port: 2525, TargetPort: intstr.FromInt32(25256), NodePort: 25565},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"test-cluster-ip\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tLabels:    map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:      v1.ServiceTypeClusterIP,\n\t\t\t\t\t\tSelector:  map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t\tClusterIP: \"10.96.41.133\",\n\t\t\t\t\t\tPorts: []v1.ServicePort{\n\t\t\t\t\t\t\t{Name: \"dns\", Port: 53, TargetPort: intstr.FromInt32(30053), Protocol: v1.ProtocolUDP},\n\t\t\t\t\t\t\t{Name: \"dns-tcp\", Port: 53, TargetPort: intstr.FromInt32(30054), NodePort: 25565},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"srv record with node port and cluster ip services with external ips\",\n\t\t\tfqdnTemplate: \"{{.Name}}.tld.org\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"nodeport-external.tld.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"192.168.132.253\"}},\n\t\t\t},\n\t\t\tgateways: []*networkingv1beta1.Gateway{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"nodeport-external\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: istionetworking.Gateway{\n\t\t\t\t\t\tSelector: map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tservices: []*v1.Service{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"test-node-port\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\t\"istio\": \"ingressgateway\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:        v1.ServiceTypeNodePort,\n\t\t\t\t\t\tSelector:    map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t\tClusterIP:   \"10.96.41.133\",\n\t\t\t\t\t\tExternalIPs: []string{\"192.168.132.253\"},\n\t\t\t\t\t\tPorts: []v1.ServicePort{\n\t\t\t\t\t\t\t{Name: \"dns\", Port: 8082, TargetPort: intstr.FromInt32(8083), Protocol: v1.ProtocolUDP, NodePort: 30083},\n\t\t\t\t\t\t\t{Name: \"dns-tcp\", Port: 2525, TargetPort: intstr.FromInt32(25256), NodePort: 25565},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"with host as subdomain in reversed order\",\n\t\t\tfqdnTemplate: \"{{ range $server := .Spec.Servers }}{{ range $host := $server.Hosts }}{{ $host }}.{{ $server.Port.Name }}.{{ $server.Port.Number }}.tld.com,{{ end }}{{ end }}\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"www.bookinfo\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"192.168.132.253\"}},\n\t\t\t\t{DNSName: \"bookinfo\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"192.168.132.253\"}},\n\t\t\t\t{DNSName: \"www.bookinfo.http.443.tld.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"192.168.132.253\"}},\n\t\t\t\t{DNSName: \"bookinfo.dns.8080.tld.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"192.168.132.253\"}},\n\t\t\t},\n\t\t\tgateways: []*networkingv1beta1.Gateway{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"nodeport-external\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: istionetworking.Gateway{\n\t\t\t\t\t\tSelector: map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t\tServers: []*istionetworking.Server{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tHosts: []string{\"www.bookinfo\"},\n\t\t\t\t\t\t\t\tName:  \"main\",\n\t\t\t\t\t\t\t\tPort:  &istionetworking.Port{Number: 443, Name: \"http\", Protocol: \"HTTPS\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tHosts: []string{\"bookinfo\"},\n\t\t\t\t\t\t\t\tName:  \"debug\",\n\t\t\t\t\t\t\t\tPort:  &istionetworking.Port{Number: 8080, Name: \"dns\", Protocol: \"UDP\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tservices: []*v1.Service{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"test-node-port\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\t\"istio\": \"ingressgateway\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:        v1.ServiceTypeNodePort,\n\t\t\t\t\t\tSelector:    map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t\tClusterIP:   \"10.96.41.133\",\n\t\t\t\t\t\tExternalIPs: []string{\"192.168.132.253\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(tt.title, func(t *testing.T) {\n\t\t\tkubeClient := fake.NewClientset()\n\t\t\tistioClient := istiofake.NewSimpleClientset()\n\n\t\t\tfor _, svc := range tt.services {\n\t\t\t\t_, err := kubeClient.CoreV1().Services(svc.Namespace).Create(t.Context(), svc, metav1.CreateOptions{})\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\tfor _, gw := range tt.gateways {\n\t\t\t\t_, err := istioClient.NetworkingV1beta1().Gateways(gw.Namespace).Create(t.Context(), gw, metav1.CreateOptions{})\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\tsrc, err := NewIstioGatewaySource(\n\t\t\t\tt.Context(),\n\t\t\t\tkubeClient,\n\t\t\t\tistioClient,\n\t\t\t\t&Config{\n\t\t\t\t\tNamespace:                \"\",\n\t\t\t\t\tAnnotationFilter:         \"\",\n\t\t\t\t\tFQDNTemplate:             tt.fqdnTemplate,\n\t\t\t\t\tCombineFQDNAndAnnotation: !tt.combineFqdn,\n\t\t\t\t\tIgnoreHostnameAnnotation: false,\n\t\t\t\t},\n\t\t\t)\n\t\t\trequire.NoError(t, err)\n\n\t\t\tendpoints, err := src.Endpoints(t.Context())\n\t\t\trequire.NoError(t, err)\n\n\t\t\tvalidateEndpoints(t, endpoints, tt.expected)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "source/istio_gateway_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\t\"github.com/stretchr/testify/suite\"\n\tistionetworking \"istio.io/api/networking/v1beta1\"\n\tnetworkingv1beta1 \"istio.io/client-go/pkg/apis/networking/v1beta1\"\n\tistiofake \"istio.io/client-go/pkg/clientset/versioned/fake\"\n\tv1 \"k8s.io/api/core/v1\"\n\tnetworkv1 \"k8s.io/api/networking/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/types\"\n\t\"k8s.io/apimachinery/pkg/util/intstr\"\n\t\"k8s.io/client-go/kubernetes/fake\"\n\n\t\"sigs.k8s.io/external-dns/internal/testutils\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\n// This is a compile-time validation that gatewaySource is a Source.\nvar _ Source = &gatewaySource{}\n\ntype GatewaySuite struct {\n\tsuite.Suite\n\tsource     Source\n\tlbServices []*v1.Service\n\tingresses  []*networkv1.Ingress\n}\n\nfunc (suite *GatewaySuite) SetupTest() {\n\tfakeKubernetesClient := fake.NewClientset()\n\tfakeIstioClient := istiofake.NewSimpleClientset()\n\tvar err error\n\n\tsuite.lbServices = []*v1.Service{\n\t\t(fakeIngressGatewayService{\n\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\thostnames: []string{\"v1\"},\n\t\t\tnamespace: \"istio-system\",\n\t\t\tname:      \"istio-gateway1\",\n\t\t}).Service(),\n\t\t(fakeIngressGatewayService{\n\t\t\tips:       []string{\"1.1.1.1\"},\n\t\t\thostnames: []string{\"v42\"},\n\t\t\tnamespace: \"istio-other\",\n\t\t\tname:      \"istio-gateway2\",\n\t\t}).Service(),\n\t}\n\n\tfor _, service := range suite.lbServices {\n\t\t_, err = fakeKubernetesClient.CoreV1().Services(service.Namespace).Create(context.Background(), service, metav1.CreateOptions{})\n\t\tsuite.NoError(err, \"should succeed\")\n\t}\n\n\tsuite.ingresses = []*networkv1.Ingress{\n\t\t(fakeIngress{\n\t\t\tips:       []string{\"2.2.2.2\"},\n\t\t\thostnames: []string{\"v2\"},\n\t\t\tnamespace: \"istio-system\",\n\t\t\tname:      \"istio-ingress\",\n\t\t}).Ingress(),\n\t\t(fakeIngress{\n\t\t\tips:       []string{\"3.3.3.3\"},\n\t\t\thostnames: []string{\"v62\"},\n\t\t\tnamespace: \"istio-system\",\n\t\t\tname:      \"istio-ingress2\",\n\t\t}).Ingress(),\n\t}\n\n\tfor _, ingress := range suite.ingresses {\n\t\t_, err = fakeKubernetesClient.NetworkingV1().Ingresses(ingress.Namespace).Create(context.Background(), ingress, metav1.CreateOptions{})\n\t\tsuite.NoError(err, \"should succeed\")\n\t}\n\n\tsuite.source, err = NewIstioGatewaySource(\n\t\tcontext.TODO(),\n\t\tfakeKubernetesClient,\n\t\tfakeIstioClient,\n\t\t&Config{\n\t\t\tFQDNTemplate: \"{{.Name}}\",\n\t\t},\n\t)\n\tsuite.NoError(err, \"should initialize gateway source\")\n\tsuite.NoError(err, \"should succeed\")\n}\n\nfunc (suite *GatewaySuite) TestResourceLabelIsSet() {\n\tendpoints, _ := suite.source.Endpoints(context.Background())\n\tfor _, ep := range endpoints {\n\t\tsuite.Equal(\"gateway/default/foo-gateway-with-targets\", ep.Labels[endpoint.ResourceLabelKey], \"should set correct resource label\")\n\t}\n}\n\nfunc TestGateway(t *testing.T) {\n\tt.Parallel()\n\n\tsuite.Run(t, new(GatewaySuite))\n\tt.Run(\"endpointsFromGatewayConfig\", testEndpointsFromGatewayConfig)\n\tt.Run(\"Endpoints\", testGatewayEndpoints)\n}\n\nfunc TestNewIstioGatewaySource(t *testing.T) {\n\tt.Parallel()\n\n\tfor _, ti := range []struct {\n\t\ttitle                    string\n\t\tannotationFilter         string\n\t\tfqdnTemplate             string\n\t\tcombineFQDNAndAnnotation bool\n\t\texpectError              bool\n\t}{\n\t\t{\n\t\t\ttitle:        \"invalid template\",\n\t\t\texpectError:  true,\n\t\t\tfqdnTemplate: \"{{.Name\",\n\t\t},\n\t\t{\n\t\t\ttitle:       \"valid empty template\",\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\ttitle:        \"valid template\",\n\t\t\texpectError:  false,\n\t\t\tfqdnTemplate: \"{{.Name}}-{{.Namespace}}.ext-dns.test.com\",\n\t\t},\n\t\t{\n\t\t\ttitle:        \"valid template\",\n\t\t\texpectError:  false,\n\t\t\tfqdnTemplate: \"{{.Name}}-{{.Namespace}}.ext-dns.test.com, {{.Name}}-{{.Namespace}}.ext-dna.test.com\",\n\t\t},\n\t\t{\n\t\t\ttitle:                    \"valid template\",\n\t\t\texpectError:              false,\n\t\t\tfqdnTemplate:             \"{{.Name}}-{{.Namespace}}.ext-dns.test.com, {{.Name}}-{{.Namespace}}.ext-dna.test.com\",\n\t\t\tcombineFQDNAndAnnotation: true,\n\t\t},\n\t\t{\n\t\t\ttitle:            \"non-empty annotation filter label\",\n\t\t\texpectError:      false,\n\t\t\tannotationFilter: \"kubernetes.io/gateway.class=nginx\",\n\t\t},\n\t} {\n\n\t\tt.Run(ti.title, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\t_, err := NewIstioGatewaySource(\n\t\t\t\tt.Context(),\n\t\t\t\tfake.NewClientset(),\n\t\t\t\tistiofake.NewSimpleClientset(),\n\t\t\t\t&Config{\n\t\t\t\t\tFQDNTemplate:             ti.fqdnTemplate,\n\t\t\t\t\tCombineFQDNAndAnnotation: ti.combineFQDNAndAnnotation,\n\t\t\t\t\tAnnotationFilter:         ti.annotationFilter,\n\t\t\t\t},\n\t\t\t)\n\t\t\tif ti.expectError {\n\t\t\t\tassert.Error(t, err)\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc testEndpointsFromGatewayConfig(t *testing.T) {\n\tt.Parallel()\n\n\tfor _, ti := range []struct {\n\t\ttitle      string\n\t\tlbServices []fakeIngressGatewayService\n\t\tingresses  []fakeIngress\n\t\tconfig     fakeGatewayConfig\n\t\texpected   []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\ttitle: \"one rule.host one lb.hostname\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\thostnames: []string{\"lb.com\"}, // Kubernetes omits the trailing dot\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfig: fakeGatewayConfig{\n\t\t\t\tdnsnames: [][]string{\n\t\t\t\t\t{\"foo.bar\"}, // Kubernetes requires removal of trailing dot\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"one namespaced rule.host one lb.hostname\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\thostnames: []string{\"lb.com\"}, // Kubernetes omits the trailing dot\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfig: fakeGatewayConfig{\n\t\t\t\tdnsnames: [][]string{\n\t\t\t\t\t{\"my-namespace/foo.bar\"}, // Kubernetes requires removal of trailing dot\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"one rule.host one lb.IP\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips: []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfig: fakeGatewayConfig{\n\t\t\t\tdnsnames: [][]string{\n\t\t\t\t\t{\"foo.bar\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"one rule.host one ingress.IP\",\n\t\t\tingresses: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname: \"ingress1\",\n\t\t\t\t\tips:  []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfig: fakeGatewayConfig{\n\t\t\t\tannotations: map[string]string{\n\t\t\t\t\tIstioGatewayIngressSource: \"ingress1\",\n\t\t\t\t},\n\t\t\t\tdnsnames: [][]string{\n\t\t\t\t\t{\"foo.bar\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"one rule.host two lb.IP and two lb.Hostname\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips:       []string{\"8.8.8.8\", \"127.0.0.1\"},\n\t\t\t\t\thostnames: []string{\"elb.com\", \"alb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfig: fakeGatewayConfig{\n\t\t\t\tdnsnames: [][]string{\n\t\t\t\t\t{\"foo.bar\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\", \"127.0.0.1\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"elb.com\", \"alb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"one rule.host two ingress.IP and two ingress.Hostname\",\n\t\t\tingresses: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"ingress1\",\n\t\t\t\t\tips:       []string{\"8.8.8.8\", \"127.0.0.1\"},\n\t\t\t\t\thostnames: []string{\"elb.com\", \"alb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfig: fakeGatewayConfig{\n\t\t\t\tannotations: map[string]string{\n\t\t\t\t\tIstioGatewayIngressSource: \"ingress1\",\n\t\t\t\t},\n\t\t\t\tdnsnames: [][]string{\n\t\t\t\t\t{\"foo.bar\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\", \"127.0.0.1\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"elb.com\", \"alb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"no rule.host\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips:         []string{\"8.8.8.8\", \"127.0.0.1\"},\n\t\t\t\t\thostnames:   []string{\"elb.com\", \"alb.com\"},\n\t\t\t\t\texternalIPs: []string{\"1.1.1.1\", \"2.2.2.2\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfig: fakeGatewayConfig{\n\t\t\t\tdnsnames: [][]string{},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle: \"one empty rule.host\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips:         []string{\"8.8.8.8\", \"127.0.0.1\"},\n\t\t\t\t\thostnames:   []string{\"elb.com\", \"alb.com\"},\n\t\t\t\t\texternalIPs: []string{\"1.1.1.1\", \"2.2.2.2\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfig: fakeGatewayConfig{\n\t\t\t\tdnsnames: [][]string{\n\t\t\t\t\t{\"\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle: \"one empty rule.host with gateway ingress annotation\",\n\t\t\tingresses: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"ingress1\",\n\t\t\t\t\tips:       []string{\"8.8.8.8\", \"127.0.0.1\"},\n\t\t\t\t\thostnames: []string{\"elb.com\", \"alb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfig: fakeGatewayConfig{\n\t\t\t\tannotations: map[string]string{\n\t\t\t\t\tIstioGatewayIngressSource: \"ingress1\",\n\t\t\t\t},\n\t\t\t\tdnsnames: [][]string{\n\t\t\t\t\t{\"\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:      \"no targets\",\n\t\t\tlbServices: []fakeIngressGatewayService{{}},\n\t\t\tconfig: fakeGatewayConfig{\n\t\t\t\tdnsnames: [][]string{\n\t\t\t\t\t{\"\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle: \"one gateway, two ingressgateway loadbalancer hostnames\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\thostnames: []string{\"lb.com\"},\n\t\t\t\t\tnamespace: \"istio-other\",\n\t\t\t\t\tname:      \"gateway1\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\thostnames: []string{\"lb2.com\"},\n\t\t\t\t\tnamespace: \"istio-other\",\n\t\t\t\t\tname:      \"gateway2\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfig: fakeGatewayConfig{\n\t\t\t\tdnsnames: [][]string{\n\t\t\t\t\t{\"foo.bar\"}, // Kubernetes requires removal of trailing dot\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\", \"lb2.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"one gateway, ingress in separate namespace\",\n\t\t\tingresses: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\thostnames: []string{\"lb.com\"},\n\t\t\t\t\tnamespace: \"istio-other2\",\n\t\t\t\t\tname:      \"ingress1\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\thostnames: []string{\"lb2.com\"},\n\t\t\t\t\tnamespace: \"istio-other\",\n\t\t\t\t\tname:      \"ingress2\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfig: fakeGatewayConfig{\n\t\t\t\tannotations: map[string]string{\n\t\t\t\t\tIstioGatewayIngressSource: \"istio-other2/ingress1\",\n\t\t\t\t},\n\t\t\t\tdnsnames: [][]string{\n\t\t\t\t\t{\"foo.bar\"}, // Kubernetes requires removal of trailing dot\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"one rule.host one lb.externalIP\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\texternalIPs: []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfig: fakeGatewayConfig{\n\t\t\t\tdnsnames: [][]string{\n\t\t\t\t\t{\"foo.bar\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"one rule.host two lb.IP, two lb.Hostname and two lb.externalIP\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips:         []string{\"8.8.8.8\", \"127.0.0.1\"},\n\t\t\t\t\thostnames:   []string{\"elb.com\", \"alb.com\"},\n\t\t\t\t\texternalIPs: []string{\"1.1.1.1\", \"2.2.2.2\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfig: fakeGatewayConfig{\n\t\t\t\tdnsnames: [][]string{\n\t\t\t\t\t{\"foo.bar\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.1.1.1\", \"2.2.2.2\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"provider-specific annotation is converted to endpoint property\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips: []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfig: fakeGatewayConfig{\n\t\t\t\tannotations: map[string]string{\n\t\t\t\t\tannotations.AWSPrefix + \"weight\": \"10\",\n\t\t\t\t},\n\t\t\t\tdnsnames: [][]string{\n\t\t\t\t\t{\"foo.bar\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{Name: \"aws/weight\", Value: \"10\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t} {\n\n\t\tt.Run(ti.title, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\tgatewayCfg := ti.config.Config()\n\t\t\tsource, err := newTestGatewaySource(ti.lbServices, ti.ingresses)\n\t\t\trequire.NoError(t, err)\n\t\t\thostnames := source.hostNamesFromGateway(gatewayCfg)\n\t\t\tendpoints, err := source.endpointsFromGateway(hostnames, gatewayCfg)\n\t\t\trequire.NoError(t, err)\n\t\t\tvalidateEndpoints(t, endpoints, ti.expected)\n\t\t})\n\t}\n}\n\nfunc testGatewayEndpoints(t *testing.T) {\n\tt.Parallel()\n\n\tfor _, ti := range []struct {\n\t\ttitle                    string\n\t\ttargetNamespace          string\n\t\tannotationFilter         string\n\t\tlbServices               []fakeIngressGatewayService\n\t\tingresses                []fakeIngress\n\t\tconfigItems              []fakeGatewayConfig\n\t\texpected                 []*endpoint.Endpoint\n\t\texpectError              bool\n\t\tfqdnTemplate             string\n\t\tcombineFQDNAndAnnotation bool\n\t\tignoreHostnameAnnotation bool\n\t}{\n\t\t{\n\t\t\ttitle:           \"no gateway\",\n\t\t\ttargetNamespace: \"\",\n\t\t},\n\t\t{\n\t\t\ttitle:           \"two simple gateways, one ingressgateway loadbalancer service\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\t\thostnames: []string{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfigItems: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: \"\",\n\t\t\t\t\tdnsnames:  [][]string{{\"example.org\"}},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake2\",\n\t\t\t\t\tnamespace: \"\",\n\t\t\t\t\tdnsnames:  [][]string{{\"new.org\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"new.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"new.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"two simple gateways on different namespaces, one ingressgateway loadbalancer service\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\t\thostnames: []string{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfigItems: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: \"\",\n\t\t\t\t\tdnsnames:  [][]string{{\"example.org\"}},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake2\",\n\t\t\t\t\tnamespace: \"\",\n\t\t\t\t\tdnsnames:  [][]string{{\"new.org\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"new.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"new.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"two simple gateways on different namespaces and a target namespace, one ingressgateway loadbalancer service\",\n\t\t\ttargetNamespace: \"testing1\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\t\thostnames: []string{\"lb.com\"},\n\t\t\t\t\tnamespace: \"testing1\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfigItems: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: \"testing1\",\n\t\t\t\t\tdnsnames:  [][]string{{\"example.org\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"one simple gateways on different namespace and a target namespace, one ingress service\",\n\t\t\ttargetNamespace: \"testing1\",\n\t\t\tingresses: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"ingress1\",\n\t\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\t\thostnames: []string{\"lb.com\"},\n\t\t\t\t\tnamespace: \"testing2\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfigItems: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: \"testing1\",\n\t\t\t\t\tdnsnames:  [][]string{{\"example.org\"}},\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tIstioGatewayIngressSource: \"testing2/ingress1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:            \"valid matching annotation filter expression\",\n\t\t\ttargetNamespace:  \"\",\n\t\t\tannotationFilter: \"kubernetes.io/gateway.class in (alb, nginx)\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips: []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfigItems: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: \"\",\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\t\"kubernetes.io/gateway.class\": \"nginx\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: [][]string{{\"example.org\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:            \"valid non-matching annotation filter expression\",\n\t\t\ttargetNamespace:  \"\",\n\t\t\tannotationFilter: \"kubernetes.io/gateway.class in (alb, nginx)\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips: []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfigItems: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: \"\",\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\t\"kubernetes.io/gateway.class\": \"tectonic\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: [][]string{{\"example.org\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:            \"invalid annotation filter expression\",\n\t\t\ttargetNamespace:  \"\",\n\t\t\tannotationFilter: \"kubernetes.io/gateway.name in (a b)\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips: []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfigItems: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: \"\",\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\t\"kubernetes.io/gateway.class\": \"alb\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: [][]string{{\"example.org\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected:    []*endpoint.Endpoint{},\n\t\t\texpectError: true,\n\t\t},\n\t\t{\n\t\t\ttitle:            \"valid matching annotation filter label\",\n\t\t\ttargetNamespace:  \"\",\n\t\t\tannotationFilter: \"kubernetes.io/gateway.class=nginx\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips: []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfigItems: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: \"\",\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\t\"kubernetes.io/gateway.class\": \"nginx\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: [][]string{{\"example.org\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:            \"valid non-matching annotation filter label\",\n\t\t\ttargetNamespace:  \"\",\n\t\t\tannotationFilter: \"kubernetes.io/gateway.class=nginx\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips: []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfigItems: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: \"\",\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\t\"kubernetes.io/gateway.class\": \"alb\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: [][]string{{\"example.org\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"our controller type is dns-controller\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips: []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfigItems: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: \"\",\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.ControllerKey: annotations.ControllerValue,\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: [][]string{{\"example.org\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"different controller types are ignored\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips: []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfigItems: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: \"\",\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.ControllerKey: \"some-other-tool\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: [][]string{{\"example.org\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"template for gateway if host is missing\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\t\thostnames: []string{\"elb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfigItems: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: \"\",\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.ControllerKey: annotations.ControllerValue,\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: [][]string{},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"fake1.ext-dns.test.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"fake1.ext-dns.test.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"elb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{.Name}}.ext-dns.test.com\",\n\t\t},\n\t\t{\n\t\t\ttitle:           \"another controller annotation skipped even with template\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips: []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfigItems: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: \"\",\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.ControllerKey: \"other-controller\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: [][]string{},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected:     []*endpoint.Endpoint{},\n\t\t\tfqdnTemplate: \"{{.Name}}.ext-dns.test.com\",\n\t\t},\n\t\t{\n\t\t\ttitle:           \"multiple FQDN template hostnames\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips: []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfigItems: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:        \"fake1\",\n\t\t\t\t\tnamespace:   \"\",\n\t\t\t\t\tannotations: map[string]string{},\n\t\t\t\t\tdnsnames:    [][]string{},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"fake1.ext-dns.test.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"fake1.ext-dna.test.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{.Name}}.ext-dns.test.com, {{.Name}}.ext-dna.test.com\",\n\t\t},\n\t\t{\n\t\t\ttitle:           \"multiple FQDN template hostnames\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips: []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfigItems: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:        \"fake1\",\n\t\t\t\t\tnamespace:   \"\",\n\t\t\t\t\tannotations: map[string]string{},\n\t\t\t\t\tdnsnames:    [][]string{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake2\",\n\t\t\t\t\tnamespace: \"\",\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"gateway-target.com\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: [][]string{{\"example.org\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"fake1.ext-dns.test.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"fake1.ext-dna.test.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"gateway-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"fake2.ext-dns.test.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"gateway-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"fake2.ext-dna.test.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"gateway-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate:             \"{{.Name}}.ext-dns.test.com, {{.Name}}.ext-dna.test.com\",\n\t\t\tcombineFQDNAndAnnotation: true,\n\t\t},\n\t\t{\n\t\t\ttitle:           \"gateway rules with annotation\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips: []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfigItems: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: \"\",\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"gateway-target.com\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: [][]string{{\"example.org\"}},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake2\",\n\t\t\t\t\tnamespace: \"\",\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"gateway-target.com\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: [][]string{{\"example2.org\"}},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake3\",\n\t\t\t\t\tnamespace: \"\",\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tIstioGatewayIngressSource: \"not-real/ingress1\",\n\t\t\t\t\t\tannotations.TargetKey:     \"1.2.3.4\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: [][]string{{\"example3.org\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"gateway-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example2.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"gateway-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example3.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"gateway rules with hostname annotation\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips: []string{\"1.2.3.4\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfigItems: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: \"\",\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.HostnameKey: \"dns-through-hostname.com\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: [][]string{{\"example.org\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"dns-through-hostname.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"gateway rules with hostname annotation having multiple hostnames\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips: []string{\"1.2.3.4\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfigItems: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: \"\",\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.HostnameKey: \"dns-through-hostname.com, another-dns-through-hostname.com\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: [][]string{{\"example.org\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"dns-through-hostname.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"another-dns-through-hostname.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"gateway rules with hostname and target annotation\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips: []string{},\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfigItems: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: \"\",\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.HostnameKey: \"dns-through-hostname.com\",\n\t\t\t\t\t\tannotations.TargetKey:   \"gateway-target.com\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: [][]string{{\"example.org\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"gateway-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"dns-through-hostname.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"gateway-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"gateway rules with hostname, target and ingress annotation\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips: []string{},\n\t\t\t\t},\n\t\t\t},\n\t\t\tingresses: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname: \"ingress1\",\n\t\t\t\t\tips:  []string{},\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfigItems: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: \"\",\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tIstioGatewayIngressSource: \"ingress1\",\n\t\t\t\t\t\tannotations.HostnameKey:   \"dns-through-hostname.com\",\n\t\t\t\t\t\tannotations.TargetKey:     \"gateway-target.com\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: [][]string{{\"example.org\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"gateway-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"dns-through-hostname.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"gateway-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"gateway rules with annotation and custom TTL\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips: []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfigItems: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: \"\",\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"gateway-target.com\",\n\t\t\t\t\t\tannotations.TtlKey:    \"6\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: [][]string{{\"example.org\"}},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake2\",\n\t\t\t\t\tnamespace: \"\",\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"gateway-target.com\",\n\t\t\t\t\t\tannotations.TtlKey:    \"1\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: [][]string{{\"example2.org\"}},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake3\",\n\t\t\t\t\tnamespace: \"\",\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"gateway-target.com\",\n\t\t\t\t\t\tannotations.TtlKey:    \"10s\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: [][]string{{\"example3.org\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"gateway-target.com\"},\n\t\t\t\t\tRecordTTL:  endpoint.TTL(6),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example2.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"gateway-target.com\"},\n\t\t\t\t\tRecordTTL:  endpoint.TTL(1),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example3.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"gateway-target.com\"},\n\t\t\t\t\tRecordTTL:  endpoint.TTL(10),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"template for gateway with annotation\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips:       []string{},\n\t\t\t\t\thostnames: []string{},\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfigItems: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: \"\",\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"gateway-target.com\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: [][]string{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake2\",\n\t\t\t\t\tnamespace: \"\",\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"gateway-target.com\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: [][]string{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake3\",\n\t\t\t\t\tnamespace: \"\",\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"1.2.3.4\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: [][]string{},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"fake1.ext-dns.test.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"gateway-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"fake2.ext-dns.test.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"gateway-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"fake3.ext-dns.test.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{.Name}}.ext-dns.test.com\",\n\t\t},\n\t\t{\n\t\t\ttitle:           \"Ingress with empty annotation\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips:       []string{},\n\t\t\t\t\thostnames: []string{},\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfigItems: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: \"\",\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: [][]string{},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected:     []*endpoint.Endpoint{},\n\t\t\tfqdnTemplate: \"{{.Name}}.ext-dns.test.com\",\n\t\t},\n\t\t{\n\t\t\ttitle:           \"Gateway with empty ingress annotation\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips:       []string{},\n\t\t\t\t\thostnames: []string{},\n\t\t\t\t},\n\t\t\t},\n\t\t\tingresses: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"ingress1\",\n\t\t\t\t\tips:       []string{},\n\t\t\t\t\thostnames: []string{},\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfigItems: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: \"\",\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tIstioGatewayIngressSource: \"\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: [][]string{},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected:     []*endpoint.Endpoint{},\n\t\t\tfqdnTemplate: \"{{.Name}}.ext-dns.test.com\",\n\t\t},\n\t\t{\n\t\t\ttitle:           \"ignore hostname annotations\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\t\thostnames: []string{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfigItems: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: \"\",\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.HostnameKey: \"ignore.me\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: [][]string{{\"example.org\"}},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake2\",\n\t\t\t\t\tnamespace: \"\",\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.HostnameKey: \"ignore.me.too\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: [][]string{{\"new.org\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"new.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"new.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tignoreHostnameAnnotation: true,\n\t\t},\n\t\t{\n\t\t\ttitle:           \"gateways with wildcard host\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips: []string{\"1.2.3.4\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfigItems: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: \"\",\n\t\t\t\t\tdnsnames:  [][]string{{\"*\"}},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake2\",\n\t\t\t\t\tnamespace: \"\",\n\t\t\t\t\tdnsnames:  [][]string{{\"some-namespace/*\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"gateways with wildcard host and hostname annotation\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips: []string{\"1.2.3.4\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfigItems: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: \"\",\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.HostnameKey: \"fake1.dns-through-hostname.com\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: [][]string{{\"*\"}},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake2\",\n\t\t\t\t\tnamespace: \"\",\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.HostnameKey: \"fake2.dns-through-hostname.com\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: [][]string{{\"some-namespace/*\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"fake1.dns-through-hostname.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"fake2.dns-through-hostname.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"gateways with ingress annotation; ingress not found\",\n\t\t\ttargetNamespace: \"\",\n\t\t\tingresses: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname: \"ingress1\",\n\t\t\t\t\tips:  []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tconfigItems: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: \"\",\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tIstioGatewayIngressSource: \"ingress2\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: [][]string{{\"new.org\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected:    []*endpoint.Endpoint{},\n\t\t\texpectError: true,\n\t\t},\n\t} {\n\n\t\tt.Run(ti.title, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\tfakeKubernetesClient := fake.NewClientset()\n\t\t\ttargetNamespace := ti.targetNamespace\n\n\t\t\tfor _, lb := range ti.lbServices {\n\t\t\t\tservice := lb.Service()\n\t\t\t\t_, err := fakeKubernetesClient.CoreV1().Services(service.Namespace).Create(t.Context(), service, metav1.CreateOptions{})\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\tfor _, ing := range ti.ingresses {\n\t\t\t\tingress := ing.Ingress()\n\t\t\t\tif ingress.Namespace != targetNamespace {\n\t\t\t\t\ttargetNamespace = v1.NamespaceAll\n\t\t\t\t}\n\t\t\t\t_, err := fakeKubernetesClient.NetworkingV1().Ingresses(ingress.Namespace).Create(t.Context(), ingress, metav1.CreateOptions{})\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\tfakeIstioClient := istiofake.NewSimpleClientset()\n\t\t\tfor _, config := range ti.configItems {\n\t\t\t\tgatewayCfg := config.Config()\n\t\t\t\t_, err := fakeIstioClient.NetworkingV1beta1().Gateways(ti.targetNamespace).Create(t.Context(), gatewayCfg, metav1.CreateOptions{})\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\tgatewaySource, err := NewIstioGatewaySource(\n\t\t\t\tt.Context(),\n\t\t\t\tfakeKubernetesClient,\n\t\t\t\tfakeIstioClient,\n\t\t\t\t&Config{\n\t\t\t\t\tNamespace:                targetNamespace,\n\t\t\t\t\tFQDNTemplate:             ti.fqdnTemplate,\n\t\t\t\t\tCombineFQDNAndAnnotation: ti.combineFQDNAndAnnotation,\n\t\t\t\t\tIgnoreHostnameAnnotation: ti.ignoreHostnameAnnotation,\n\t\t\t\t\tAnnotationFilter:         ti.annotationFilter,\n\t\t\t\t},\n\t\t\t)\n\t\t\trequire.NoError(t, err)\n\n\t\t\tres, err := gatewaySource.Endpoints(t.Context())\n\t\t\tif ti.expectError {\n\t\t\t\tassert.Error(t, err)\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t}\n\n\t\t\tvalidateEndpoints(t, res, ti.expected)\n\t\t})\n\t}\n}\n\nfunc TestGatewaySource_GWSelectorMatchServiceSelector(t *testing.T) {\n\ttests := []struct {\n\t\tname      string\n\t\tselectors map[string]string\n\t\texpected  []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\tname: \"gw single selector match with single service selector\",\n\t\t\tselectors: map[string]string{\n\t\t\t\t\"version\": \"v1\",\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"example.org\", endpoint.RecordTypeA, \"10.10.10.255\").WithLabel(\"resource\", \"gateway/default/fake-gateway\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"gw selector match all service selectors\",\n\t\t\tselectors: map[string]string{\n\t\t\t\t\"app\":     \"demo\",\n\t\t\t\t\"env\":     \"prod\",\n\t\t\t\t\"team\":    \"devops\",\n\t\t\t\t\"version\": \"v1\",\n\t\t\t\t\"release\": \"stable\",\n\t\t\t\t\"track\":   \"daily\",\n\t\t\t\t\"tier\":    \"backend\",\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"example.org\", endpoint.RecordTypeA, \"10.10.10.255\").WithLabel(\"resource\", \"gateway/default/fake-gateway\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"gw selector has subset of service selectors\",\n\t\t\tselectors: map[string]string{\n\t\t\t\t\"version\": \"v1\",\n\t\t\t\t\"release\": \"stable\",\n\t\t\t\t\"tier\":    \"backend\",\n\t\t\t\t\"app\":     \"demo\",\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"example.org\", endpoint.RecordTypeA, \"10.10.10.255\").WithLabel(\"resource\", \"gateway/default/fake-gateway\"),\n\t\t\t},\n\t\t},\n\t}\n\n\tfor i, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tfakeKubeClient := fake.NewClientset()\n\t\t\tfakeIstioClient := istiofake.NewSimpleClientset()\n\n\t\t\tsvc := &v1.Service{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"fake-service\",\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\tUID:       types.UID(fmt.Sprintf(\"fake-service-uid-%d\", i)),\n\t\t\t\t},\n\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\tSelector: map[string]string{\n\t\t\t\t\t\t\"app\":     \"demo\",\n\t\t\t\t\t\t\"env\":     \"prod\",\n\t\t\t\t\t\t\"team\":    \"devops\",\n\t\t\t\t\t\t\"version\": \"v1\",\n\t\t\t\t\t\t\"release\": \"stable\",\n\t\t\t\t\t\t\"track\":   \"daily\",\n\t\t\t\t\t\t\"tier\":    \"backend\",\n\t\t\t\t\t},\n\t\t\t\t\tExternalIPs: []string{\"10.10.10.255\"},\n\t\t\t\t},\n\t\t\t}\n\t\t\t_, err := fakeKubeClient.CoreV1().Services(svc.Namespace).Create(t.Context(), svc, metav1.CreateOptions{})\n\t\t\trequire.NoError(t, err)\n\n\t\t\tgw := &networkingv1beta1.Gateway{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"fake-gateway\",\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t},\n\t\t\t\tSpec: istionetworking.Gateway{\n\t\t\t\t\tServers: []*istionetworking.Server{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tHosts: []string{\"example.org\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSelector: tt.selectors,\n\t\t\t\t},\n\t\t\t}\n\n\t\t\t_, err = fakeIstioClient.NetworkingV1beta1().Gateways(gw.Namespace).Create(t.Context(), gw, metav1.CreateOptions{})\n\t\t\trequire.NoError(t, err)\n\n\t\t\tsrc, err := NewIstioGatewaySource(\n\t\t\t\tt.Context(),\n\t\t\t\tfakeKubeClient,\n\t\t\t\tfakeIstioClient,\n\t\t\t\t&Config{},\n\t\t\t)\n\t\t\trequire.NoError(t, err)\n\t\t\trequire.NotNil(t, src)\n\n\t\t\tres, err := src.Endpoints(t.Context())\n\t\t\trequire.NoError(t, err)\n\n\t\t\tvalidateEndpoints(t, res, tt.expected)\n\t\t})\n\t}\n}\n\nfunc TestTransformerInIstioGatewaySource(t *testing.T) {\n\tsvc := &v1.Service{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:      \"fake-service\",\n\t\t\tNamespace: \"default\",\n\t\t\tLabels: map[string]string{\n\t\t\t\t\"label1\": \"value1\",\n\t\t\t\t\"label2\": \"value2\",\n\t\t\t\t\"label3\": \"value3\",\n\t\t\t},\n\t\t\tAnnotations: map[string]string{\n\t\t\t\t\"user-annotation\": \"value\",\n\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"test-hostname\",\n\t\t\t\t\"external-dns.alpha.kubernetes.io/random\":   \"value\",\n\t\t\t\t\"other/annotation\":                          \"value\",\n\t\t\t},\n\t\t\tUID: \"someuid\",\n\t\t},\n\t\tSpec: v1.ServiceSpec{\n\t\t\tSelector: map[string]string{\n\t\t\t\t\"selector\":  \"one\",\n\t\t\t\t\"selector2\": \"two\",\n\t\t\t\t\"selector3\": \"three\",\n\t\t\t},\n\t\t\tExternalIPs: []string{\"1.2.3.4\"},\n\t\t\tPorts: []v1.ServicePort{\n\t\t\t\t{\n\t\t\t\t\tName:       \"http\",\n\t\t\t\t\tPort:       80,\n\t\t\t\t\tTargetPort: intstr.FromInt32(8080),\n\t\t\t\t\tProtocol:   v1.ProtocolTCP,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tName:       \"https\",\n\t\t\t\t\tPort:       443,\n\t\t\t\t\tTargetPort: intstr.FromInt32(8443),\n\t\t\t\t\tProtocol:   v1.ProtocolTCP,\n\t\t\t\t},\n\t\t\t},\n\t\t\tType: v1.ServiceTypeLoadBalancer,\n\t\t},\n\t\tStatus: v1.ServiceStatus{\n\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\tIngress: []v1.LoadBalancerIngress{\n\t\t\t\t\t{IP: \"5.6.7.8\", Hostname: \"lb.example.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tConditions: []metav1.Condition{\n\t\t\t\t{\n\t\t\t\t\tType:               \"Available\",\n\t\t\t\t\tStatus:             metav1.ConditionTrue,\n\t\t\t\t\tReason:             \"MinimumReplicasAvailable\",\n\t\t\t\t\tMessage:            \"Service is available\",\n\t\t\t\t\tLastTransitionTime: metav1.Now(),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tfakeClient := fake.NewClientset()\n\n\t_, err := fakeClient.CoreV1().Services(svc.Namespace).Create(t.Context(), svc, metav1.CreateOptions{})\n\trequire.NoError(t, err)\n\n\tsrc, err := NewIstioGatewaySource(\n\t\tt.Context(),\n\t\tfakeClient,\n\t\tistiofake.NewSimpleClientset(),\n\t\t&Config{})\n\trequire.NoError(t, err)\n\tgwSource, ok := src.(*gatewaySource)\n\trequire.True(t, ok)\n\n\trService, err := gwSource.serviceInformer.Lister().Services(svc.Namespace).Get(svc.Name)\n\trequire.NoError(t, err)\n\n\tassert.Equal(t, \"fake-service\", rService.Name)\n\tassert.Empty(t, rService.Labels)\n\tassert.Empty(t, rService.Annotations)\n\tassert.Empty(t, rService.UID)\n\tassert.NotEmpty(t, rService.Status.LoadBalancer)\n\tassert.Empty(t, rService.Status.Conditions)\n\tassert.Equal(t, map[string]string{\n\t\t\"selector\":  \"one\",\n\t\t\"selector2\": \"two\",\n\t\t\"selector3\": \"three\",\n\t}, rService.Spec.Selector)\n}\n\nfunc TestSingleGatewayMultipleServicesPointingToSameLoadBalancer(t *testing.T) {\n\tfakeKubeClient := fake.NewClientset()\n\tfakeIstioClient := istiofake.NewSimpleClientset()\n\n\tgw := &networkingv1beta1.Gateway{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:      \"argocd\",\n\t\t\tNamespace: \"argocd\",\n\t\t},\n\t\tSpec: istionetworking.Gateway{\n\t\t\tServers: []*istionetworking.Server{\n\t\t\t\t{\n\t\t\t\t\tHosts: []string{\"example.org\"},\n\t\t\t\t\tTls: &istionetworking.ServerTLSSettings{\n\t\t\t\t\t\tHttpsRedirect: true,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tHosts: []string{\"example.org\"},\n\t\t\t\t\tTls: &istionetworking.ServerTLSSettings{\n\t\t\t\t\t\tServerCertificate: IstioGatewayIngressSource,\n\t\t\t\t\t\tMode:              istionetworking.ServerTLSSettings_SIMPLE,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tSelector: map[string]string{\n\t\t\t\t\"istio\": \"ingressgateway\",\n\t\t\t},\n\t\t},\n\t}\n\n\tservices := []*v1.Service{\n\t\t{\n\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\tName:      \"istio-ingressgateway\",\n\t\t\t\tNamespace: \"default\",\n\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\"app\":   \"istio-ingressgateway\",\n\t\t\t\t\t\"istio\": \"ingressgateway\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\tType:                  v1.ServiceTypeLoadBalancer,\n\t\t\t\tClusterIP:             \"10.118.223.3\",\n\t\t\t\tClusterIPs:            []string{\"10.118.223.3\"},\n\t\t\t\tExternalTrafficPolicy: v1.ServiceExternalTrafficPolicyCluster,\n\t\t\t\tIPFamilies:            []v1.IPFamily{v1.IPv4Protocol},\n\t\t\t\tIPFamilyPolicy:        testutils.ToPtr(v1.IPFamilyPolicySingleStack),\n\t\t\t\tPorts: []v1.ServicePort{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:       \"http2\",\n\t\t\t\t\t\tPort:       80,\n\t\t\t\t\t\tProtocol:   v1.ProtocolTCP,\n\t\t\t\t\t\tTargetPort: intstr.FromInt32(8080),\n\t\t\t\t\t\tNodePort:   30127,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSelector: map[string]string{\n\t\t\t\t\t\"app\":   \"istio-ingressgateway\",\n\t\t\t\t\t\"istio\": \"ingressgateway\",\n\t\t\t\t},\n\t\t\t\tSessionAffinity: v1.ServiceAffinityNone,\n\t\t\t},\n\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\tIngress: []v1.LoadBalancerIngress{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tIP:     \"34.66.66.77\",\n\t\t\t\t\t\t\tIPMode: testutils.ToPtr(v1.LoadBalancerIPModeVIP),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\tName:      \"istio-ingressgatewayudp\",\n\t\t\t\tNamespace: \"default\",\n\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\"app\":   \"istio-ingressgatewayudp\",\n\t\t\t\t\t\"istio\": \"ingressgateway\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\tType:                  v1.ServiceTypeLoadBalancer,\n\t\t\t\tClusterIP:             \"10.118.220.130\",\n\t\t\t\tClusterIPs:            []string{\"10.118.220.130\"},\n\t\t\t\tExternalTrafficPolicy: v1.ServiceExternalTrafficPolicyCluster,\n\t\t\t\tIPFamilies:            []v1.IPFamily{v1.IPv4Protocol},\n\t\t\t\tIPFamilyPolicy:        testutils.ToPtr(v1.IPFamilyPolicySingleStack),\n\t\t\t\tPorts: []v1.ServicePort{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:       \"upd-dns\",\n\t\t\t\t\t\tPort:       53,\n\t\t\t\t\t\tProtocol:   v1.ProtocolUDP,\n\t\t\t\t\t\tTargetPort: intstr.FromInt32(5353),\n\t\t\t\t\t\tNodePort:   30873,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSelector: map[string]string{\n\t\t\t\t\t\"app\":   \"istio-ingressgatewayudp\",\n\t\t\t\t\t\"istio\": \"ingressgateway\",\n\t\t\t\t},\n\t\t\t\tSessionAffinity: v1.ServiceAffinityNone,\n\t\t\t},\n\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\tIngress: []v1.LoadBalancerIngress{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tIP:     \"34.66.66.77\",\n\t\t\t\t\t\t\tIPMode: testutils.ToPtr(v1.LoadBalancerIPModeVIP),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tassert.NotNil(t, services)\n\n\tfor _, svc := range services {\n\t\t_, err := fakeKubeClient.CoreV1().Services(svc.Namespace).Create(t.Context(), svc, metav1.CreateOptions{})\n\t\trequire.NoError(t, err)\n\t}\n\n\t_, err := fakeIstioClient.NetworkingV1beta1().Gateways(gw.Namespace).Create(t.Context(), gw, metav1.CreateOptions{})\n\trequire.NoError(t, err)\n\n\tsrc, err := NewIstioGatewaySource(\n\t\tt.Context(),\n\t\tfakeKubeClient,\n\t\tfakeIstioClient,\n\t\t&Config{},\n\t)\n\trequire.NoError(t, err)\n\trequire.NotNil(t, src)\n\n\tgot, err := src.Endpoints(t.Context())\n\trequire.NoError(t, err)\n\n\tvalidateEndpoints(t, got, []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"example.org\", endpoint.RecordTypeA, \"34.66.66.77\").WithLabel(endpoint.ResourceLabelKey, \"gateway/argocd/argocd\"),\n\t})\n}\n\n// gateway specific helper functions\nfunc newTestGatewaySource(loadBalancerList []fakeIngressGatewayService, ingressList []fakeIngress) (*gatewaySource, error) {\n\tfakeKubernetesClient := fake.NewClientset()\n\tfakeIstioClient := istiofake.NewSimpleClientset()\n\n\tfor _, lb := range loadBalancerList {\n\t\tservice := lb.Service()\n\t\t_, err := fakeKubernetesClient.CoreV1().Services(service.Namespace).Create(context.Background(), service, metav1.CreateOptions{})\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tfor _, ing := range ingressList {\n\t\tingress := ing.Ingress()\n\t\t_, err := fakeKubernetesClient.NetworkingV1().Ingresses(ingress.Namespace).Create(context.Background(), ingress, metav1.CreateOptions{})\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tsrc, err := NewIstioGatewaySource(\n\t\tcontext.TODO(),\n\t\tfakeKubernetesClient,\n\t\tfakeIstioClient,\n\t\t&Config{\n\t\t\tFQDNTemplate: \"{{.FQDN}}\",\n\t\t},\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tgwsrc, ok := src.(*gatewaySource)\n\tif !ok {\n\t\treturn nil, errors.New(\"underlying source type was not gateway\")\n\t}\n\n\treturn gwsrc, nil\n}\n\ntype fakeIngressGatewayService struct {\n\tips         []string\n\thostnames   []string\n\tnamespace   string\n\tname        string\n\tselector    map[string]string\n\texternalIPs []string\n}\n\nfunc (ig fakeIngressGatewayService) Service() *v1.Service {\n\tsvc := &v1.Service{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tNamespace: ig.namespace,\n\t\t\tName:      ig.name,\n\t\t},\n\t\tStatus: v1.ServiceStatus{\n\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\tIngress: []v1.LoadBalancerIngress{},\n\t\t\t},\n\t\t},\n\t\tSpec: v1.ServiceSpec{\n\t\t\tSelector:    ig.selector,\n\t\t\tExternalIPs: ig.externalIPs,\n\t\t},\n\t}\n\n\tfor _, ip := range ig.ips {\n\t\tsvc.Status.LoadBalancer.Ingress = append(svc.Status.LoadBalancer.Ingress, v1.LoadBalancerIngress{\n\t\t\tIP: ip,\n\t\t})\n\t}\n\tfor _, hostname := range ig.hostnames {\n\t\tsvc.Status.LoadBalancer.Ingress = append(svc.Status.LoadBalancer.Ingress, v1.LoadBalancerIngress{\n\t\t\tHostname: hostname,\n\t\t})\n\t}\n\n\treturn svc\n}\n\ntype fakeGatewayConfig struct {\n\tnamespace   string\n\tname        string\n\tannotations map[string]string\n\tdnsnames    [][]string\n\tselector    map[string]string\n}\n\nfunc (c fakeGatewayConfig) Config() *networkingv1beta1.Gateway {\n\tgw := &networkingv1beta1.Gateway{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:        c.name,\n\t\t\tNamespace:   c.namespace,\n\t\t\tAnnotations: c.annotations,\n\t\t},\n\t\tSpec: istionetworking.Gateway{\n\t\t\tServers:  nil,\n\t\t\tSelector: c.selector,\n\t\t},\n\t}\n\n\tvar servers []*istionetworking.Server\n\tfor _, dnsnames := range c.dnsnames {\n\t\tservers = append(servers, &istionetworking.Server{\n\t\t\tHosts: dnsnames,\n\t\t})\n\t}\n\n\tgw.Spec.Servers = servers\n\n\treturn gw\n}\n"
  },
  {
    "path": "source/istio_virtualservice.go",
    "content": "/*\nCopyright 2020 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"cmp\"\n\t\"context\"\n\t\"fmt\"\n\t\"slices\"\n\t\"strings\"\n\t\"text/template\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\tv1beta1 \"istio.io/client-go/pkg/apis/networking/v1beta1\"\n\tistioclient \"istio.io/client-go/pkg/clientset/versioned\"\n\tistioinformers \"istio.io/client-go/pkg/informers/externalversions\"\n\tnetworkingv1beta1informer \"istio.io/client-go/pkg/informers/externalversions/networking/v1beta1\"\n\tcorev1 \"k8s.io/api/core/v1\"\n\t\"k8s.io/apimachinery/pkg/api/errors\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\tkubeinformers \"k8s.io/client-go/informers\"\n\tcoreinformers \"k8s.io/client-go/informers/core/v1\"\n\tnetinformers \"k8s.io/client-go/informers/networking/v1\"\n\t\"k8s.io/client-go/kubernetes\"\n\n\t\"sigs.k8s.io/external-dns/source/types\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n\t\"sigs.k8s.io/external-dns/source/fqdn\"\n\t\"sigs.k8s.io/external-dns/source/informers\"\n)\n\n// IstioMeshGateway is the built in gateway for all sidecars\nconst IstioMeshGateway = \"mesh\"\n\n// virtualServiceSource is an implementation of Source for Istio VirtualService objects.\n// The implementation uses the spec.hosts values for the hostnames.\n// Use annotations.TargetKey to explicitly set Endpoint.\n//\n// +externaldns:source:name=istio-virtualservice\n// +externaldns:source:category=Service Mesh\n// +externaldns:source:description=Creates DNS entries from Istio VirtualService resources\n// +externaldns:source:resources=VirtualService.networking.istio.io\n// +externaldns:source:filters=annotation\n// +externaldns:source:namespace=all,single\n// +externaldns:source:fqdn-template=true\n// +externaldns:source:provider-specific=true\ntype virtualServiceSource struct {\n\tkubeClient               kubernetes.Interface\n\tistioClient              istioclient.Interface\n\tnamespace                string\n\tannotationFilter         string\n\tfqdnTemplate             *template.Template\n\tcombineFQDNAnnotation    bool\n\tignoreHostnameAnnotation bool\n\tserviceInformer          coreinformers.ServiceInformer\n\tvServiceInformer         networkingv1beta1informer.VirtualServiceInformer\n\tgatewayInformer          networkingv1beta1informer.GatewayInformer\n\tingressInformer          netinformers.IngressInformer\n}\n\n// NewIstioVirtualServiceSource creates a new virtualServiceSource with the given config.\nfunc NewIstioVirtualServiceSource(\n\tctx context.Context,\n\tkubeClient kubernetes.Interface,\n\tistioClient istioclient.Interface,\n\tcfg *Config,\n) (Source, error) {\n\ttmpl, err := fqdn.ParseTemplate(cfg.FQDNTemplate)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Use shared informers to listen for add/update/delete of services/pods/nodes in the specified namespace.\n\t// Set resync period to 0, to prevent processing when nothing has changed\n\tinformerFactory := kubeinformers.NewSharedInformerFactoryWithOptions(kubeClient, 0, kubeinformers.WithNamespace(cfg.Namespace))\n\tserviceInformer := informerFactory.Core().V1().Services()\n\tistioInformerFactory := istioinformers.NewSharedInformerFactoryWithOptions(istioClient, 0, istioinformers.WithNamespace(cfg.Namespace))\n\tvirtualServiceInformer := istioInformerFactory.Networking().V1beta1().VirtualServices()\n\tgatewayInformer := istioInformerFactory.Networking().V1beta1().Gateways()\n\tingressInformer := informerFactory.Networking().V1().Ingresses()\n\n\t_, _ = ingressInformer.Informer().AddEventHandler(informers.DefaultEventHandler())\n\n\t// Add default resource event handlers to properly initialize informer.\n\t_, _ = serviceInformer.Informer().AddEventHandler(informers.DefaultEventHandler())\n\terr = serviceInformer.Informer().SetTransform(informers.TransformerWithOptions[*corev1.Service](\n\t\tinformers.TransformWithSpecSelector(),\n\t\tinformers.TransformWithSpecExternalIPs(),\n\t\tinformers.TransformWithStatusLoadBalancer(),\n\t))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t_, _ = virtualServiceInformer.Informer().AddEventHandler(informers.DefaultEventHandler())\n\n\t_, _ = gatewayInformer.Informer().AddEventHandler(informers.DefaultEventHandler())\n\n\tinformerFactory.Start(ctx.Done())\n\tistioInformerFactory.Start(ctx.Done())\n\n\t// wait for the local cache to be populated.\n\tif err := informers.WaitForCacheSync(ctx, informerFactory); err != nil {\n\t\treturn nil, err\n\t}\n\tif err := informers.WaitForCacheSync(ctx, istioInformerFactory); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &virtualServiceSource{\n\t\tkubeClient:               kubeClient,\n\t\tistioClient:              istioClient,\n\t\tnamespace:                cfg.Namespace,\n\t\tannotationFilter:         cfg.AnnotationFilter,\n\t\tfqdnTemplate:             tmpl,\n\t\tcombineFQDNAnnotation:    cfg.CombineFQDNAndAnnotation,\n\t\tignoreHostnameAnnotation: cfg.IgnoreHostnameAnnotation,\n\t\tserviceInformer:          serviceInformer,\n\t\tvServiceInformer:         virtualServiceInformer,\n\t\tgatewayInformer:          gatewayInformer,\n\t\tingressInformer:          ingressInformer,\n\t}, nil\n}\n\n// Endpoints returns endpoint objects for each host-target combination that should be processed.\n// Retrieves all VirtualService resources in the source's namespace(s).\nfunc (sc *virtualServiceSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) {\n\tvirtualServices, err := sc.vServiceInformer.Lister().VirtualServices(sc.namespace).List(labels.Everything())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvirtualServices, err = annotations.Filter(virtualServices, sc.annotationFilter)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar endpoints []*endpoint.Endpoint\n\n\tlog.Debugf(\"Found %d virtualservice in namespace %s\", len(virtualServices), sc.namespace)\n\n\tfor _, vService := range virtualServices {\n\t\tif annotations.IsControllerMismatch(vService, types.IstioVirtualService) {\n\t\t\tcontinue\n\t\t}\n\n\t\tgwEndpoints, err := sc.endpointsFromVirtualService(ctx, vService)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\t// apply template if host is missing on VirtualService\n\t\tgwEndpoints, err = fqdn.CombineWithTemplatedEndpoints(\n\t\t\tgwEndpoints,\n\t\t\tsc.fqdnTemplate,\n\t\t\tsc.combineFQDNAnnotation,\n\t\t\tfunc() ([]*endpoint.Endpoint, error) { return sc.endpointsFromTemplate(ctx, vService) },\n\t\t)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif endpoint.HasNoEmptyEndpoints(gwEndpoints, types.IstioVirtualService, vService) {\n\t\t\tcontinue\n\t\t}\n\n\t\tlog.Debugf(\"Endpoints generated from %q '%s/%s.%s': %q\", vService.Kind, vService.Namespace, vService.APIVersion, vService.Name, gwEndpoints)\n\t\tendpoints = append(endpoints, gwEndpoints...)\n\t}\n\n\treturn MergeEndpoints(endpoints), nil\n}\n\n// AddEventHandler adds an event handler that should be triggered if the watched Istio VirtualService changes.\nfunc (sc *virtualServiceSource) AddEventHandler(_ context.Context, handler func()) {\n\tlog.Debug(\"Adding event handler for Istio VirtualService\")\n\n\t_, _ = sc.vServiceInformer.Informer().AddEventHandler(eventHandlerFunc(handler))\n}\n\nfunc (sc *virtualServiceSource) getGateway(_ context.Context, gatewayStr string, virtualService *v1beta1.VirtualService) (*v1beta1.Gateway, error) {\n\tif gatewayStr == \"\" || gatewayStr == IstioMeshGateway {\n\t\t// This refers to \"all sidecars in the mesh\"; ignore.\n\t\treturn nil, nil\n\t}\n\n\tnamespace, name, err := ParseIngress(gatewayStr)\n\tif err != nil {\n\t\tlog.Debugf(\"Failed parsing gatewayStr %s of VirtualService %s/%s\", gatewayStr, virtualService.Namespace, virtualService.Name)\n\t\treturn nil, err\n\t}\n\tnamespace = cmp.Or(namespace, virtualService.Namespace)\n\n\tgateway, err := sc.gatewayInformer.Lister().Gateways(namespace).Get(name)\n\tif errors.IsNotFound(err) {\n\t\tlog.Warnf(\"VirtualService (%s/%s) references non-existent gateway: %s \", virtualService.Namespace, virtualService.Name, gatewayStr)\n\t\treturn gateway, nil\n\t} else if err != nil {\n\t\tlog.Errorf(\"Failed retrieving gateway %s referenced by VirtualService %s/%s: %v\", gatewayStr, virtualService.Namespace, virtualService.Name, err)\n\t\treturn nil, err\n\t}\n\tif gateway == nil {\n\t\tlog.Debugf(\"Gateway %s referenced by VirtualService %s/%s not found: %v\", gatewayStr, virtualService.Namespace, virtualService.Name, err)\n\t\treturn gateway, nil\n\t}\n\treturn gateway, nil\n}\n\nfunc (sc *virtualServiceSource) endpointsFromTemplate(ctx context.Context, virtualService *v1beta1.VirtualService) ([]*endpoint.Endpoint, error) {\n\thostnames, err := fqdn.ExecTemplate(sc.fqdnTemplate, virtualService)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tresource := fmt.Sprintf(\"virtualservice/%s/%s\", virtualService.Namespace, virtualService.Name)\n\n\tttl := annotations.TTLFromAnnotations(virtualService.Annotations, resource)\n\n\tproviderSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(virtualService.Annotations)\n\n\tvar endpoints []*endpoint.Endpoint\n\tfor _, hostname := range hostnames {\n\t\ttargets, err := sc.targetsFromVirtualService(ctx, virtualService, hostname)\n\t\tif err != nil {\n\t\t\treturn endpoints, err\n\t\t}\n\t\tendpoints = append(endpoints, endpoint.EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...)\n\t}\n\treturn endpoints, nil\n}\n\n// append a target to the list of targets unless it's already in the list\nfunc appendUnique(targets []string, target string) []string {\n\tif slices.Contains(targets, target) {\n\t\treturn targets\n\t}\n\treturn append(targets, target)\n}\n\nfunc (sc *virtualServiceSource) targetsFromVirtualService(ctx context.Context, vService *v1beta1.VirtualService, vsHost string) ([]string, error) {\n\tvar targets []string\n\t// for each host we need to iterate through the gateways because each host might match for only one of the gateways\n\tfor _, gateway := range vService.Spec.Gateways {\n\t\tgw, err := sc.getGateway(ctx, gateway, vService)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif gw == nil {\n\t\t\tcontinue\n\t\t}\n\t\tif !virtualServiceBindsToGateway(vService, gw, vsHost) {\n\t\t\tcontinue\n\t\t}\n\t\ttgs, err := sc.targetsFromGateway(gw)\n\t\tif err != nil {\n\t\t\treturn targets, err\n\t\t}\n\t\tfor _, target := range tgs {\n\t\t\ttargets = appendUnique(targets, target)\n\t\t}\n\t}\n\treturn targets, nil\n}\n\n// endpointsFromVirtualService extracts the endpoints from an Istio VirtualService Config object\nfunc (sc *virtualServiceSource) endpointsFromVirtualService(ctx context.Context, vService *v1beta1.VirtualService) ([]*endpoint.Endpoint, error) {\n\tvar endpoints []*endpoint.Endpoint\n\tvar err error\n\n\tresource := fmt.Sprintf(\"virtualservice/%s/%s\", vService.Namespace, vService.Name)\n\n\tttl := annotations.TTLFromAnnotations(vService.Annotations, resource)\n\n\ttargetsFromAnnotation := annotations.TargetsFromTargetAnnotation(vService.Annotations)\n\n\tproviderSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(vService.Annotations)\n\n\tfor _, host := range vService.Spec.Hosts {\n\t\tif host == \"\" || host == \"*\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tparts := strings.Split(host, \"/\")\n\n\t\t// If the input hostname is of the form my-namespace/foo.bar.com, remove the namespace\n\t\t// before appending it to the list of endpoints to create\n\t\tif len(parts) == 2 {\n\t\t\thost = parts[1]\n\t\t}\n\n\t\ttargets := targetsFromAnnotation\n\t\tif len(targets) == 0 {\n\t\t\ttargets, err = sc.targetsFromVirtualService(ctx, vService, host)\n\t\t\tif err != nil {\n\t\t\t\treturn endpoints, err\n\t\t\t}\n\t\t}\n\n\t\tendpoints = append(endpoints, endpoint.EndpointsForHostname(host, targets, ttl, providerSpecific, setIdentifier, resource)...)\n\t}\n\n\t// Skip endpoints if we do not want entries from annotations\n\tif !sc.ignoreHostnameAnnotation {\n\t\thostnameList := annotations.HostnamesFromAnnotations(vService.Annotations)\n\t\tfor _, hostname := range hostnameList {\n\t\t\ttargets := targetsFromAnnotation\n\t\t\tif len(targets) == 0 {\n\t\t\t\ttargets, err = sc.targetsFromVirtualService(ctx, vService, hostname)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn endpoints, err\n\t\t\t\t}\n\t\t\t}\n\t\t\tendpoints = append(endpoints, endpoint.EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...)\n\t\t}\n\t}\n\n\treturn endpoints, nil\n}\n\n// checks if the given VirtualService should actually bind to the given gateway\n// see requirements here: https://istio.io/docs/reference/config/networking/gateway/#Server\nfunc virtualServiceBindsToGateway(vService *v1beta1.VirtualService, gateway *v1beta1.Gateway, vsHost string) bool {\n\tisValid := false\n\tif len(vService.Spec.ExportTo) == 0 {\n\t\tisValid = true\n\t} else {\n\t\tfor _, ns := range vService.Spec.ExportTo {\n\t\t\tif ns == \"*\" || ns == gateway.Namespace || (ns == \".\" && gateway.Namespace == vService.Namespace) {\n\t\t\t\tisValid = true\n\t\t\t}\n\t\t}\n\t}\n\tif !isValid {\n\t\treturn false\n\t}\n\n\tfor _, server := range gateway.Spec.Servers {\n\t\tfor _, host := range server.Hosts {\n\t\t\tnamespace := \"*\"\n\t\t\tparts := strings.Split(host, \"/\")\n\t\t\tif len(parts) == 2 {\n\t\t\t\tnamespace = parts[0]\n\t\t\t\thost = parts[1]\n\t\t\t} else if len(parts) != 1 {\n\t\t\t\tlog.Debugf(\"Gateway %s/%s has invalid host %s\", gateway.Namespace, gateway.Name, host)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif namespace == \"*\" || namespace == vService.Namespace || (namespace == \".\" && vService.Namespace == gateway.Namespace) {\n\t\t\t\tif host == \"*\" {\n\t\t\t\t\treturn true\n\t\t\t\t}\n\n\t\t\t\tsuffixMatch := false\n\t\t\t\tif strings.HasPrefix(host, \"*.\") {\n\t\t\t\t\tsuffixMatch = true\n\t\t\t\t}\n\n\t\t\t\tif host == vsHost || (suffixMatch && strings.HasSuffix(vsHost, host[1:])) {\n\t\t\t\t\treturn true\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn false\n}\n\nfunc (sc *virtualServiceSource) targetsFromIngress(ingressStr string, gateway *v1beta1.Gateway) (endpoint.Targets, error) {\n\tnamespace, name, err := ParseIngress(ingressStr)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse Ingress annotation on Gateway (%s/%s): %w\", gateway.Namespace, gateway.Name, err)\n\t}\n\tif namespace == \"\" {\n\t\tnamespace = gateway.Namespace\n\t}\n\n\tingress, err := sc.ingressInformer.Lister().Ingresses(namespace).Get(name)\n\tif err != nil {\n\t\tlog.Error(err)\n\t\treturn nil, err\n\t}\n\n\ttargets := make(endpoint.Targets, 0)\n\n\tfor _, lb := range ingress.Status.LoadBalancer.Ingress {\n\t\tif lb.IP != \"\" {\n\t\t\ttargets = append(targets, lb.IP)\n\t\t} else if lb.Hostname != \"\" {\n\t\t\ttargets = append(targets, lb.Hostname)\n\t\t}\n\t}\n\treturn targets, nil\n}\n\nfunc (sc *virtualServiceSource) targetsFromGateway(gateway *v1beta1.Gateway) (endpoint.Targets, error) {\n\ttargets := annotations.TargetsFromTargetAnnotation(gateway.Annotations)\n\tif len(targets) > 0 {\n\t\treturn targets, nil\n\t}\n\n\tingressStr, ok := gateway.Annotations[IstioGatewayIngressSource]\n\tif ok && ingressStr != \"\" {\n\t\treturn sc.targetsFromIngress(ingressStr, gateway)\n\t}\n\n\treturn EndpointTargetsFromServices(sc.serviceInformer, sc.namespace, gateway.Spec.Selector)\n}\n"
  },
  {
    "path": "source/istio_virtualservice_fqdn_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n    http://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\tistionetworking \"istio.io/api/networking/v1beta1\"\n\tnetworkingv1beta1 \"istio.io/client-go/pkg/apis/networking/v1beta1\"\n\tistiofake \"istio.io/client-go/pkg/clientset/versioned/fake\"\n\tv1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/client-go/kubernetes/fake\"\n\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\nfunc TestIstioVirtualServiceSourceNewSourceWithFqdn(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\ttitle            string\n\t\tannotationFilter string\n\t\tfqdnTemplate     string\n\t\texpectError      bool\n\t}{\n\t\t{\n\t\t\ttitle:        \"invalid template\",\n\t\t\texpectError:  true,\n\t\t\tfqdnTemplate: \"{{.Name\",\n\t\t},\n\t\t{\n\t\t\ttitle:       \"valid empty template\",\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\ttitle:        \"valid template\",\n\t\t\texpectError:  false,\n\t\t\tfqdnTemplate: \"{{.Name}}-{{.Namespace}}.ext-dns.test.com\",\n\t\t},\n\t\t{\n\t\t\ttitle:        \"valid template with multiple hosts\",\n\t\t\texpectError:  false,\n\t\t\tfqdnTemplate: \"{{.Name}}-{{.Namespace}}.ext-dns.test.com, {{.Name}}-{{.Namespace}}.ext-dna.test.com\",\n\t\t},\n\t} {\n\t\tt.Run(tt.title, func(t *testing.T) {\n\t\t\t_, err := NewIstioVirtualServiceSource(\n\t\t\t\tt.Context(),\n\t\t\t\tfake.NewClientset(),\n\t\t\t\tistiofake.NewSimpleClientset(),\n\t\t\t\t&Config{\n\t\t\t\t\tNamespace:                \"\",\n\t\t\t\t\tAnnotationFilter:         \"\",\n\t\t\t\t\tFQDNTemplate:             tt.fqdnTemplate,\n\t\t\t\t\tCombineFQDNAndAnnotation: false,\n\t\t\t\t\tIgnoreHostnameAnnotation: false,\n\t\t\t\t},\n\t\t\t)\n\n\t\t\tif tt.expectError {\n\t\t\t\tassert.Error(t, err)\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestIstioVirtualServiceSourceFqdnTemplatingExamples(t *testing.T) {\n\tannotations.SetAnnotationPrefix(\"external-dns.alpha.kubernetes.io/\")\n\tfor _, tt := range []struct {\n\t\ttitle           string\n\t\tvirtualServices []*networkingv1beta1.VirtualService\n\t\tgateways        []*networkingv1beta1.Gateway\n\t\tservices        []*v1.Service\n\t\tfqdnTemplate    string\n\t\tcombineFqdn     bool\n\t\texpected        []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\ttitle:        \"simple templating with virtualservice name\",\n\t\t\tfqdnTemplate: \"{{.Name}}.test.com\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"app.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{DNSName: \"my-virtualservice.test.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t\tvirtualServices: []*networkingv1beta1.VirtualService{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-virtualservice\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: istionetworking.VirtualService{\n\t\t\t\t\t\tHosts:    []string{\"app.example.org\"},\n\t\t\t\t\t\tGateways: []string{\"my-gateway\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tgateways: []*networkingv1beta1.Gateway{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-gateway\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: istionetworking.Gateway{\n\t\t\t\t\t\tSelector: map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t\tServers: []*istionetworking.Server{\n\t\t\t\t\t\t\t{Hosts: []string{\"*\"}},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tservices: []*v1.Service{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"istio-ingressgateway\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tLabels:    map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:     v1.ServiceTypeLoadBalancer,\n\t\t\t\t\t\tSelector: map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\t\t\tIngress: []v1.LoadBalancerIngress{{IP: \"1.2.3.4\"}},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"templating with fqdn combine disabled\",\n\t\t\tfqdnTemplate: \"{{.Name}}.test.com\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"app.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t\tcombineFqdn: true,\n\t\t\tvirtualServices: []*networkingv1beta1.VirtualService{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-virtualservice\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: istionetworking.VirtualService{\n\t\t\t\t\t\tHosts:    []string{\"app.example.org\"},\n\t\t\t\t\t\tGateways: []string{\"my-gateway\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tgateways: []*networkingv1beta1.Gateway{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-gateway\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: istionetworking.Gateway{\n\t\t\t\t\t\tSelector: map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t\tServers: []*istionetworking.Server{\n\t\t\t\t\t\t\t{Hosts: []string{\"*\"}},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tservices: []*v1.Service{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"istio-ingressgateway\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tLabels:    map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:     v1.ServiceTypeLoadBalancer,\n\t\t\t\t\t\tSelector: map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\t\t\tIngress: []v1.LoadBalancerIngress{{IP: \"1.2.3.4\"}},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"templating with namespace\",\n\t\t\tfqdnTemplate: \"{{.Name}}.{{.Namespace}}.cluster.local\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"api.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"5.6.7.8\"}},\n\t\t\t\t{DNSName: \"api-service.production.cluster.local\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"5.6.7.8\"}},\n\t\t\t\t{DNSName: \"web.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"::ffff:192.1.56.10\"}},\n\t\t\t\t{DNSName: \"web-service.staging.cluster.local\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"::ffff:192.1.56.10\"}},\n\t\t\t},\n\t\t\tvirtualServices: []*networkingv1beta1.VirtualService{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"api-service\",\n\t\t\t\t\t\tNamespace: \"production\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: istionetworking.VirtualService{\n\t\t\t\t\t\tHosts:    []string{\"api.example.org\"},\n\t\t\t\t\t\tGateways: []string{\"api-gateway\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"web-service\",\n\t\t\t\t\t\tNamespace: \"staging\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: istionetworking.VirtualService{\n\t\t\t\t\t\tHosts:    []string{\"web.example.org\"},\n\t\t\t\t\t\tGateways: []string{\"web-gateway\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tgateways: []*networkingv1beta1.Gateway{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"api-gateway\",\n\t\t\t\t\t\tNamespace: \"production\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: istionetworking.Gateway{\n\t\t\t\t\t\tSelector: map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t\tServers: []*istionetworking.Server{\n\t\t\t\t\t\t\t{Hosts: []string{\"*\"}},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"web-gateway\",\n\t\t\t\t\t\tNamespace: \"staging\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: istionetworking.Gateway{\n\t\t\t\t\t\tSelector: map[string]string{\"istio\": \"ingressgateway-staging\"},\n\t\t\t\t\t\tServers: []*istionetworking.Server{\n\t\t\t\t\t\t\t{Hosts: []string{\"*\"}},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tservices: []*v1.Service{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"istio-ingressgateway\",\n\t\t\t\t\t\tNamespace: \"production\",\n\t\t\t\t\t\tLabels:    map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:     v1.ServiceTypeLoadBalancer,\n\t\t\t\t\t\tSelector: map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\t\t\tIngress: []v1.LoadBalancerIngress{{IP: \"5.6.7.8\"}},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"istio-ingressgateway-staging\",\n\t\t\t\t\t\tNamespace: \"staging\",\n\t\t\t\t\t\tLabels:    map[string]string{\"istio\": \"ingressgateway-staging\"},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:     v1.ServiceTypeLoadBalancer,\n\t\t\t\t\t\tSelector: map[string]string{\"istio\": \"ingressgateway-staging\"},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\t\t\tIngress: []v1.LoadBalancerIngress{{IP: \"::ffff:192.1.56.10\"}},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"templating with multiple fqdn templates\",\n\t\t\tfqdnTemplate: \"{{.Name}}.example.com,{{.Name}}.example.org\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"multi-host.example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.0.0.1\"}},\n\t\t\t\t{DNSName: \"multi-host.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.0.0.1\"}},\n\t\t\t},\n\t\t\tvirtualServices: []*networkingv1beta1.VirtualService{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"multi-host\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: istionetworking.VirtualService{\n\t\t\t\t\t\tGateways: []string{\"my-gateway\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tgateways: []*networkingv1beta1.Gateway{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-gateway\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: istionetworking.Gateway{\n\t\t\t\t\t\tSelector: map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t\tServers: []*istionetworking.Server{\n\t\t\t\t\t\t\t{Hosts: []string{\"*\"}},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tservices: []*v1.Service{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"istio-ingressgateway\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tLabels:    map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:     v1.ServiceTypeLoadBalancer,\n\t\t\t\t\t\tSelector: map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\t\t\tIngress: []v1.LoadBalancerIngress{{IP: \"10.0.0.1\"}},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"combine FQDN annotation with template\",\n\t\t\tfqdnTemplate: \"{{.Name}}.internal.example.com\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"app.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"172.16.0.1\"}},\n\t\t\t\t{DNSName: \"combined-vs.internal.example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"172.16.0.1\"}},\n\t\t\t},\n\t\t\tvirtualServices: []*networkingv1beta1.VirtualService{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"combined-vs\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: istionetworking.VirtualService{\n\t\t\t\t\t\tHosts:    []string{\"app.example.org\"},\n\t\t\t\t\t\tGateways: []string{\"my-gateway\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tgateways: []*networkingv1beta1.Gateway{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-gateway\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: istionetworking.Gateway{\n\t\t\t\t\t\tSelector: map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t\tServers: []*istionetworking.Server{\n\t\t\t\t\t\t\t{Hosts: []string{\"*\"}},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tservices: []*v1.Service{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"istio-ingressgateway\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tLabels:    map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:     v1.ServiceTypeLoadBalancer,\n\t\t\t\t\t\tSelector: map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\t\t\tIngress: []v1.LoadBalancerIngress{{IP: \"172.16.0.1\"}},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"complex templating with labels and hosts\",\n\t\t\tfqdnTemplate: \"{{ if .Labels.env }}{{.Name}}.{{.Labels.env}}.ex{{ end }}\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"labeled-vs.dev.ex\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"172.16.0.1\"}},\n\t\t\t},\n\t\t\tvirtualServices: []*networkingv1beta1.VirtualService{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"labeled-vs\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\t\"env\": \"dev\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: istionetworking.VirtualService{\n\t\t\t\t\t\tGateways: []string{\"my-gateway\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"no-labels\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: istionetworking.VirtualService{\n\t\t\t\t\t\tGateways: []string{\"my-gateway\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tgateways: []*networkingv1beta1.Gateway{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-gateway\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: istionetworking.Gateway{\n\t\t\t\t\t\tSelector: map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t\tServers: []*istionetworking.Server{\n\t\t\t\t\t\t\t{Hosts: []string{\"*\"}},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tservices: []*v1.Service{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"istio-ingressgateway\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:     v1.ServiceTypeLoadBalancer,\n\t\t\t\t\t\tSelector: map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\t\t\tIngress: []v1.LoadBalancerIngress{{IP: \"172.16.0.1\"}},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"templating with cross-namespace gateway reference\",\n\t\t\tfqdnTemplate: \"{{.Name}}.{{.Namespace}}.svc.cluster.local\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"cross-ns.example.org\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"lb.example.com\"}},\n\t\t\t\t{DNSName: \"cross-ns-vs.app-namespace.svc.cluster.local\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"lb.example.com\"}},\n\t\t\t},\n\t\t\tvirtualServices: []*networkingv1beta1.VirtualService{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"cross-ns-vs\",\n\t\t\t\t\t\tNamespace: \"app-namespace\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: istionetworking.VirtualService{\n\t\t\t\t\t\tHosts:    []string{\"cross-ns.example.org\"},\n\t\t\t\t\t\tGateways: []string{\"istio-system/shared-gateway\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tgateways: []*networkingv1beta1.Gateway{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"shared-gateway\",\n\t\t\t\t\t\tNamespace: \"istio-system\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: istionetworking.Gateway{\n\t\t\t\t\t\tSelector: map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t\tServers: []*istionetworking.Server{\n\t\t\t\t\t\t\t{Hosts: []string{\"*\"}},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tservices: []*v1.Service{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"istio-ingressgateway\",\n\t\t\t\t\t\tNamespace: \"istio-system\",\n\t\t\t\t\t\tLabels:    map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:     v1.ServiceTypeLoadBalancer,\n\t\t\t\t\t\tSelector: map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\t\t\tIngress: []v1.LoadBalancerIngress{{Hostname: \"lb.example.com\"}},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"virtualservice with multiple hosts in spec\",\n\t\t\tfqdnTemplate: \"{{.Name}}.internal.local\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"app1.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"192.168.1.100\"}},\n\t\t\t\t{DNSName: \"app2.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"192.168.1.100\"}},\n\t\t\t\t{DNSName: \"app3.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"192.168.1.100\"}},\n\t\t\t\t{DNSName: \"multi-host-vs.internal.local\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"192.168.1.100\"}},\n\t\t\t},\n\t\t\tvirtualServices: []*networkingv1beta1.VirtualService{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"multi-host-vs\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: istionetworking.VirtualService{\n\t\t\t\t\t\tHosts:    []string{\"app1.example.org\", \"app2.example.org\", \"app3.example.org\"},\n\t\t\t\t\t\tGateways: []string{\"my-gateway\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tgateways: []*networkingv1beta1.Gateway{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-gateway\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: istionetworking.Gateway{\n\t\t\t\t\t\tSelector: map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t\tServers: []*istionetworking.Server{\n\t\t\t\t\t\t\t{Hosts: []string{\"*\"}},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tservices: []*v1.Service{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"istio-ingressgateway\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tLabels:    map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:     v1.ServiceTypeLoadBalancer,\n\t\t\t\t\t\tSelector: map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\t\t\tIngress: []v1.LoadBalancerIngress{{IP: \"192.168.1.100\"}},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"virtualservice with no matching gateway (no endpoints from spec)\",\n\t\t\tfqdnTemplate: \"{{.Name}}.fallback.local\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"orphan.example.org\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"fallback.local\"}},\n\t\t\t},\n\t\t\tvirtualServices: []*networkingv1beta1.VirtualService{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"orphan-vs\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.TargetKey: \"fallback.local\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: istionetworking.VirtualService{\n\t\t\t\t\t\tHosts:    []string{\"orphan.example.org\"},\n\t\t\t\t\t\tGateways: []string{\"non-existent-gateway\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"templating with annotations expansion\",\n\t\t\tfqdnTemplate: `{{ index .ObjectMeta.Annotations \"dns.company.com/subdomain\" }}.company.local`,\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"api-v2.company.local\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"192.168.1.100\"}},\n\t\t\t},\n\t\t\tvirtualServices: []*networkingv1beta1.VirtualService{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"annotated-vs\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\t\"dns.company.com/subdomain\": \"api-v2\",\n\t\t\t\t\t\t\tannotations.TargetKey:       \"10.20.30.40\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: istionetworking.VirtualService{\n\t\t\t\t\t\tGateways: []string{\"my-gateway\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tgateways: []*networkingv1beta1.Gateway{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-gateway\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: istionetworking.Gateway{\n\t\t\t\t\t\tSelector: map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t\tServers: []*istionetworking.Server{\n\t\t\t\t\t\t\t{Hosts: []string{\"*\"}},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tservices: []*v1.Service{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"istio\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:     v1.ServiceTypeLoadBalancer,\n\t\t\t\t\t\tSelector: map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\t\t\tIngress: []v1.LoadBalancerIngress{{IP: \"192.168.1.100\"}},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(tt.title, func(t *testing.T) {\n\t\t\tkubeClient := fake.NewClientset()\n\t\t\tistioClient := istiofake.NewSimpleClientset()\n\n\t\t\tfor _, svc := range tt.services {\n\t\t\t\t_, err := kubeClient.CoreV1().Services(svc.Namespace).Create(t.Context(), svc, metav1.CreateOptions{})\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\tfor _, gw := range tt.gateways {\n\t\t\t\t_, err := istioClient.NetworkingV1beta1().Gateways(gw.Namespace).Create(t.Context(), gw, metav1.CreateOptions{})\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\tfor _, vs := range tt.virtualServices {\n\t\t\t\t_, err := istioClient.NetworkingV1beta1().VirtualServices(vs.Namespace).Create(t.Context(), vs, metav1.CreateOptions{})\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\tsrc, err := NewIstioVirtualServiceSource(\n\t\t\t\tt.Context(),\n\t\t\t\tkubeClient,\n\t\t\t\tistioClient,\n\t\t\t\t&Config{\n\t\t\t\t\tNamespace:                \"\",\n\t\t\t\t\tAnnotationFilter:         \"\",\n\t\t\t\t\tFQDNTemplate:             tt.fqdnTemplate,\n\t\t\t\t\tCombineFQDNAndAnnotation: !tt.combineFqdn,\n\t\t\t\t\tIgnoreHostnameAnnotation: false,\n\t\t\t\t},\n\t\t\t)\n\t\t\trequire.NoError(t, err)\n\n\t\t\tendpoints, err := src.Endpoints(t.Context())\n\t\t\trequire.NoError(t, err)\n\n\t\t\tvalidateEndpoints(t, endpoints, tt.expected)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "source/istio_virtualservice_test.go",
    "content": "/*\nCopyright 2020 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\t\"github.com/stretchr/testify/suite\"\n\t\"istio.io/api/meta/v1alpha1\"\n\tistionetworking \"istio.io/api/networking/v1beta1\"\n\tnetworkingv1beta1 \"istio.io/client-go/pkg/apis/networking/v1beta1\"\n\tistiofake \"istio.io/client-go/pkg/clientset/versioned/fake\"\n\tv1 \"k8s.io/api/core/v1\"\n\tnetworkv1 \"k8s.io/api/networking/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/types\"\n\t\"k8s.io/apimachinery/pkg/util/intstr\"\n\t\"k8s.io/client-go/kubernetes/fake\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n)\n\n// This is a compile-time validation that istioVirtualServiceSource is a Source.\nvar _ Source = &virtualServiceSource{}\n\ntype VirtualServiceSuite struct {\n\tsuite.Suite\n\tsource     Source\n\tlbServices []*v1.Service\n\tingresses  []*networkv1.Ingress\n\tgwconfig   *networkingv1beta1.Gateway\n\tvsconfig   *networkingv1beta1.VirtualService\n}\n\nfunc (suite *VirtualServiceSuite) SetupTest() {\n\tfakeKubernetesClient := fake.NewClientset()\n\tfakeIstioClient := istiofake.NewSimpleClientset()\n\tvar err error\n\n\tsuite.lbServices = []*v1.Service{\n\t\t(fakeIngressGatewayService{\n\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\thostnames: []string{\"v1\"},\n\t\t\tnamespace: \"istio-system\",\n\t\t\tname:      \"istio-gateway1\",\n\t\t}).Service(),\n\t\t(fakeIngressGatewayService{\n\t\t\tips:       []string{\"1.1.1.1\"},\n\t\t\thostnames: []string{\"v42\"},\n\t\t\tnamespace: \"istio-system\",\n\t\t\tname:      \"istio-gateway2\",\n\t\t}).Service(),\n\t}\n\n\tfor _, service := range suite.lbServices {\n\t\t_, err = fakeKubernetesClient.CoreV1().Services(service.Namespace).Create(context.Background(), service, metav1.CreateOptions{})\n\t\tsuite.NoError(err, \"should succeed\")\n\t}\n\n\tsuite.ingresses = []*networkv1.Ingress{\n\t\t(fakeIngress{\n\t\t\tips:       []string{\"2.2.2.2\"},\n\t\t\thostnames: []string{\"v2\"},\n\t\t\tnamespace: \"istio-system\",\n\t\t\tname:      \"istio-ingress\",\n\t\t}).Ingress(),\n\t\t(fakeIngress{\n\t\t\tips:       []string{\"3.3.3.3\"},\n\t\t\thostnames: []string{\"v62\"},\n\t\t\tnamespace: \"istio-system\",\n\t\t\tname:      \"istio-ingress2\",\n\t\t}).Ingress(),\n\t}\n\n\tfor _, ingress := range suite.ingresses {\n\t\t_, err = fakeKubernetesClient.NetworkingV1().Ingresses(ingress.Namespace).Create(context.Background(), ingress, metav1.CreateOptions{})\n\t\tsuite.NoError(err, \"should succeed\")\n\t}\n\n\tsuite.gwconfig = (fakeGatewayConfig{\n\t\tname:      \"foo-gateway-with-targets\",\n\t\tnamespace: \"istio-system\",\n\t\tdnsnames:  [][]string{{\"*\"}},\n\t}).Config()\n\t_, err = fakeIstioClient.NetworkingV1beta1().Gateways(suite.gwconfig.Namespace).Create(context.Background(), suite.gwconfig, metav1.CreateOptions{})\n\tsuite.NoError(err, \"should succeed\")\n\n\tsuite.vsconfig = (fakeVirtualServiceConfig{\n\t\tname:      \"foo-virtualservice\",\n\t\tnamespace: \"istio-other\",\n\t\tgateways:  []string{\"istio-system/foo-gateway-with-targets\"},\n\t\tdnsnames:  []string{\"foo\"},\n\t}).Config()\n\t_, err = fakeIstioClient.NetworkingV1beta1().VirtualServices(suite.vsconfig.Namespace).Create(context.Background(), suite.vsconfig, metav1.CreateOptions{})\n\tsuite.NoError(err, \"should succeed\")\n\n\tsuite.source, err = NewIstioVirtualServiceSource(\n\t\tcontext.TODO(),\n\t\tfakeKubernetesClient,\n\t\tfakeIstioClient,\n\t\t&Config{\n\t\t\tFQDNTemplate: \"{{.Name}}\",\n\t\t},\n\t)\n\tsuite.NoError(err, \"should initialize virtualservice source\")\n}\n\nfunc (suite *VirtualServiceSuite) TestResourceLabelIsSet() {\n\tendpoints, err := suite.source.Endpoints(context.Background())\n\tsuite.NoError(err, \"should succeed\")\n\tsuite.Len(endpoints, 2, \"should return the correct number of endpoints\")\n\tfor _, ep := range endpoints {\n\t\tsuite.Equal(\"virtualservice/istio-other/foo-virtualservice\", ep.Labels[endpoint.ResourceLabelKey], \"should set correct resource label\")\n\t}\n}\n\nfunc TestVirtualService(t *testing.T) {\n\tt.Parallel()\n\n\tsuite.Run(t, new(VirtualServiceSuite))\n\tt.Run(\"virtualServiceBindsToGateway\", testVirtualServiceBindsToGateway)\n\tt.Run(\"endpointsFromVirtualServiceConfig\", testEndpointsFromVirtualServiceConfig)\n\tt.Run(\"Endpoints\", testVirtualServiceEndpoints)\n\tt.Run(\"gatewaySelectorMatchesService\", testGatewaySelectorMatchesService)\n}\n\nfunc TestNewIstioVirtualServiceSource(t *testing.T) {\n\tt.Parallel()\n\n\tfor _, ti := range []struct {\n\t\ttitle                    string\n\t\tannotationFilter         string\n\t\tfqdnTemplate             string\n\t\tcombineFQDNAndAnnotation bool\n\t\texpectError              bool\n\t}{\n\t\t{\n\t\t\ttitle:        \"invalid template\",\n\t\t\texpectError:  true,\n\t\t\tfqdnTemplate: \"{{.Name\",\n\t\t},\n\t\t{\n\t\t\ttitle:       \"valid empty template\",\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\ttitle:        \"valid template\",\n\t\t\texpectError:  false,\n\t\t\tfqdnTemplate: \"{{.Name}}-{{.Namespace}}.ext-dns.test.com\",\n\t\t},\n\t\t{\n\t\t\ttitle:        \"valid template\",\n\t\t\texpectError:  false,\n\t\t\tfqdnTemplate: \"{{.Name}}-{{.Namespace}}.ext-dns.test.com, {{.Name}}-{{.Namespace}}.ext-dna.test.com\",\n\t\t},\n\t\t{\n\t\t\ttitle:                    \"valid template\",\n\t\t\texpectError:              false,\n\t\t\tfqdnTemplate:             \"{{.Name}}-{{.Namespace}}.ext-dns.test.com, {{.Name}}-{{.Namespace}}.ext-dna.test.com\",\n\t\t\tcombineFQDNAndAnnotation: true,\n\t\t},\n\t\t{\n\t\t\ttitle:            \"non-empty annotation filter label\",\n\t\t\texpectError:      false,\n\t\t\tannotationFilter: \"kubernetes.io/gateway.class=nginx\",\n\t\t},\n\t} {\n\n\t\tt.Run(ti.title, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\t_, err := NewIstioVirtualServiceSource(\n\t\t\t\tt.Context(),\n\t\t\t\tfake.NewClientset(),\n\t\t\t\tistiofake.NewSimpleClientset(),\n\t\t\t\t&Config{\n\t\t\t\t\tFQDNTemplate:             ti.fqdnTemplate,\n\t\t\t\t\tCombineFQDNAndAnnotation: ti.combineFQDNAndAnnotation,\n\t\t\t\t\tAnnotationFilter:         ti.annotationFilter,\n\t\t\t\t},\n\t\t\t)\n\t\t\tif ti.expectError {\n\t\t\t\tassert.Error(t, err)\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc testVirtualServiceBindsToGateway(t *testing.T) {\n\tfor _, ti := range []struct {\n\t\ttitle    string\n\t\tgwconfig fakeGatewayConfig\n\t\tvsconfig fakeVirtualServiceConfig\n\t\tvsHost   string\n\t\texpected bool\n\t}{\n\t\t{\n\t\t\ttitle: \"matching host *\",\n\t\t\tgwconfig: fakeGatewayConfig{\n\t\t\t\tdnsnames: [][]string{{\"*\"}},\n\t\t\t},\n\t\t\tvsconfig: fakeVirtualServiceConfig{},\n\t\t\tvsHost:   \"foo.bar\",\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\ttitle: \"matching host *.<domain>\",\n\t\t\tgwconfig: fakeGatewayConfig{\n\t\t\t\tdnsnames: [][]string{{\"*.foo.bar\"}},\n\t\t\t},\n\t\t\tvsconfig: fakeVirtualServiceConfig{},\n\t\t\tvsHost:   \"baz.foo.bar\",\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\ttitle: \"not matching host *.<domain>\",\n\t\t\tgwconfig: fakeGatewayConfig{\n\t\t\t\tdnsnames: [][]string{{\"*.foo.bar\"}},\n\t\t\t},\n\t\t\tvsconfig: fakeVirtualServiceConfig{},\n\t\t\tvsHost:   \"foo.bar\",\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\ttitle: \"not matching host *.<domain>\",\n\t\t\tgwconfig: fakeGatewayConfig{\n\t\t\t\tdnsnames: [][]string{{\"*.foo.bar\"}},\n\t\t\t},\n\t\t\tvsconfig: fakeVirtualServiceConfig{},\n\t\t\tvsHost:   \"bazfoo.bar\",\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\ttitle: \"not matching host *.<domain>\",\n\t\t\tgwconfig: fakeGatewayConfig{\n\t\t\t\tdnsnames: [][]string{{\"*.foo.bar\"}},\n\t\t\t},\n\t\t\tvsconfig: fakeVirtualServiceConfig{},\n\t\t\tvsHost:   \"*foo.bar\",\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\ttitle: \"matching host */*\",\n\t\t\tgwconfig: fakeGatewayConfig{\n\t\t\t\tdnsnames: [][]string{{\"*/*\"}},\n\t\t\t},\n\t\t\tvsconfig: fakeVirtualServiceConfig{},\n\t\t\tvsHost:   \"foo.bar\",\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\ttitle: \"matching host <namespace>/*\",\n\t\t\tgwconfig: fakeGatewayConfig{\n\t\t\t\tnamespace: \"istio-system\",\n\t\t\t\tdnsnames:  [][]string{{\"myns/*\"}},\n\t\t\t},\n\t\t\tvsconfig: fakeVirtualServiceConfig{\n\t\t\t\tnamespace: \"myns\",\n\t\t\t},\n\t\t\tvsHost:   \"foo.bar\",\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\ttitle: \"matching host ./*\",\n\t\t\tgwconfig: fakeGatewayConfig{\n\t\t\t\tnamespace: \"istio-system\",\n\t\t\t\tdnsnames:  [][]string{{\"./*\"}},\n\t\t\t},\n\t\t\tvsconfig: fakeVirtualServiceConfig{\n\t\t\t\tnamespace: \"istio-system\",\n\t\t\t},\n\t\t\tvsHost:   \"foo.bar\",\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\ttitle: \"not matching host ./*\",\n\t\t\tgwconfig: fakeGatewayConfig{\n\t\t\t\tnamespace: \"istio-system\",\n\t\t\t\tdnsnames:  [][]string{{\"./*\"}},\n\t\t\t},\n\t\t\tvsconfig: fakeVirtualServiceConfig{\n\t\t\t\tnamespace: \"myns\",\n\t\t\t},\n\t\t\tvsHost:   \"foo.bar\",\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\ttitle: \"not matching host <namespace>/*\",\n\t\t\tgwconfig: fakeGatewayConfig{\n\t\t\t\tnamespace: \"istio-system\",\n\t\t\t\tdnsnames:  [][]string{{\"myns/*\"}},\n\t\t\t},\n\t\t\tvsconfig: fakeVirtualServiceConfig{\n\t\t\t\tnamespace: \"otherns\",\n\t\t\t},\n\t\t\tvsHost:   \"foo.bar\",\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\ttitle: \"not matching host <namespace>/*\",\n\t\t\tgwconfig: fakeGatewayConfig{\n\t\t\t\tnamespace: \"istio-system\",\n\t\t\t\tdnsnames:  [][]string{{\"myns/*\"}},\n\t\t\t},\n\t\t\tvsconfig: fakeVirtualServiceConfig{\n\t\t\t\tnamespace: \"otherns\",\n\t\t\t},\n\t\t\tvsHost:   \"foo.bar\",\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\ttitle: \"matching exportTo *\",\n\t\t\tgwconfig: fakeGatewayConfig{\n\t\t\t\tnamespace: \"istio-system\",\n\t\t\t\tdnsnames:  [][]string{{\"*\"}},\n\t\t\t},\n\t\t\tvsconfig: fakeVirtualServiceConfig{\n\t\t\t\tnamespace: \"otherns\",\n\t\t\t\texportTo:  \"*\",\n\t\t\t},\n\t\t\tvsHost:   \"foo.bar\",\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\ttitle: \"matching exportTo <namespace>\",\n\t\t\tgwconfig: fakeGatewayConfig{\n\t\t\t\tnamespace: \"istio-system\",\n\t\t\t\tdnsnames:  [][]string{{\"*\"}},\n\t\t\t},\n\t\t\tvsconfig: fakeVirtualServiceConfig{\n\t\t\t\tnamespace: \"otherns\",\n\t\t\t\texportTo:  \"istio-system\",\n\t\t\t},\n\t\t\tvsHost:   \"foo.bar\",\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\ttitle: \"not matching exportTo <namespace>\",\n\t\t\tgwconfig: fakeGatewayConfig{\n\t\t\t\tnamespace: \"istio-system\",\n\t\t\t\tdnsnames:  [][]string{{\"*\"}},\n\t\t\t},\n\t\t\tvsconfig: fakeVirtualServiceConfig{\n\t\t\t\tnamespace: \"otherns\",\n\t\t\t\texportTo:  \"myns\",\n\t\t\t},\n\t\t\tvsHost:   \"foo.bar\",\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\ttitle: \"not matching exportTo .\",\n\t\t\tgwconfig: fakeGatewayConfig{\n\t\t\t\tnamespace: \"istio-system\",\n\t\t\t\tdnsnames:  [][]string{{\"*\"}},\n\t\t\t},\n\t\t\tvsconfig: fakeVirtualServiceConfig{\n\t\t\t\tnamespace: \"otherns\",\n\t\t\t\texportTo:  \".\",\n\t\t\t},\n\t\t\tvsHost:   \"foo.bar\",\n\t\t\texpected: false,\n\t\t},\n\t} {\n\t\tt.Run(ti.title, func(t *testing.T) {\n\t\t\tvsconfig := ti.vsconfig.Config()\n\t\t\tgwconfig := ti.gwconfig.Config()\n\t\t\trequire.Equal(t, ti.expected, virtualServiceBindsToGateway(vsconfig, gwconfig, ti.vsHost))\n\t\t})\n\t}\n}\n\nfunc testEndpointsFromVirtualServiceConfig(t *testing.T) {\n\tt.Parallel()\n\n\tfor _, ti := range []struct {\n\t\ttitle      string\n\t\tlbServices []fakeIngressGatewayService\n\t\tingresses  []fakeIngress\n\t\tgwconfig   fakeGatewayConfig\n\t\tvsconfig   fakeVirtualServiceConfig\n\t\texpected   []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\ttitle: \"one rule.host one lb.hostname\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\thostnames: []string{\"lb.com\"}, // Kubernetes omits the trailing dot\n\t\t\t\t},\n\t\t\t},\n\t\t\tgwconfig: fakeGatewayConfig{\n\t\t\t\tname:     \"mygw\",\n\t\t\t\tdnsnames: [][]string{{\"*\"}},\n\t\t\t},\n\t\t\tvsconfig: fakeVirtualServiceConfig{\n\t\t\t\tgateways: []string{\"mygw\"},\n\t\t\t\tdnsnames: []string{\"foo.bar\"},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"one rule.host one lb.IP\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips: []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tgwconfig: fakeGatewayConfig{\n\t\t\t\tname:     \"mygw\",\n\t\t\t\tdnsnames: [][]string{{\"*\"}},\n\t\t\t},\n\t\t\tvsconfig: fakeVirtualServiceConfig{\n\t\t\t\tgateways: []string{\"mygw\"},\n\t\t\t\tdnsnames: []string{\"foo.bar\"},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"one rule.host one lb.externalIPs\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\texternalIPs: []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tgwconfig: fakeGatewayConfig{\n\t\t\t\tname:     \"mygw\",\n\t\t\t\tdnsnames: [][]string{{\"*\"}},\n\t\t\t},\n\t\t\tvsconfig: fakeVirtualServiceConfig{\n\t\t\t\tgateways: []string{\"mygw\"},\n\t\t\t\tdnsnames: []string{\"foo.bar\"},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"one rule.host two lb.IP and two lb.Hostname\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips:       []string{\"8.8.8.8\", \"127.0.0.1\"},\n\t\t\t\t\thostnames: []string{\"elb.com\", \"alb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tgwconfig: fakeGatewayConfig{\n\t\t\t\tname:     \"mygw\",\n\t\t\t\tdnsnames: [][]string{{\"*\"}},\n\t\t\t},\n\t\t\tvsconfig: fakeVirtualServiceConfig{\n\t\t\t\tgateways: []string{\"mygw\"},\n\t\t\t\tdnsnames: []string{\"foo.bar\"},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\", \"127.0.0.1\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"elb.com\", \"alb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"one rule.host two lb.IP and two lb.Hostname and two lb.externalIPs\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips:         []string{\"8.8.8.8\", \"127.0.0.1\"},\n\t\t\t\t\thostnames:   []string{\"elb.com\", \"alb.com\"},\n\t\t\t\t\texternalIPs: []string{\"1.1.1.1\", \"2.2.2.2\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tgwconfig: fakeGatewayConfig{\n\t\t\t\tname:     \"mygw\",\n\t\t\t\tdnsnames: [][]string{{\"*\"}},\n\t\t\t},\n\t\t\tvsconfig: fakeVirtualServiceConfig{\n\t\t\t\tgateways: []string{\"mygw\"},\n\t\t\t\tdnsnames: []string{\"foo.bar\"},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.1.1.1\", \"2.2.2.2\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"no rule.host\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips:         []string{\"8.8.8.8\", \"127.0.0.1\"},\n\t\t\t\t\thostnames:   []string{\"elb.com\", \"alb.com\"},\n\t\t\t\t\texternalIPs: []string{\"1.1.1.1\", \"2.2.2.2\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tgwconfig: fakeGatewayConfig{\n\t\t\t\tname:     \"mygw\",\n\t\t\t\tdnsnames: [][]string{{\"*\"}},\n\t\t\t},\n\t\t\tvsconfig: fakeVirtualServiceConfig{\n\t\t\t\tgateways: []string{\"mygw\"},\n\t\t\t\tdnsnames: []string{},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle: \"no rule.gateway\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips:         []string{\"8.8.8.8\", \"127.0.0.1\"},\n\t\t\t\t\thostnames:   []string{\"elb.com\", \"alb.com\"},\n\t\t\t\t\texternalIPs: []string{\"1.1.1.1\", \"2.2.2.2\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tgwconfig: fakeGatewayConfig{\n\t\t\t\tname:     \"mygw\",\n\t\t\t\tdnsnames: [][]string{{\"*\"}},\n\t\t\t},\n\t\t\tvsconfig: fakeVirtualServiceConfig{\n\t\t\t\tgateways: []string{},\n\t\t\t\tdnsnames: []string{\"foo.bar\"},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle: \"one empty rule.host\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips:         []string{\"8.8.8.8\", \"127.0.0.1\"},\n\t\t\t\t\thostnames:   []string{\"elb.com\", \"alb.com\"},\n\t\t\t\t\texternalIPs: []string{\"1.1.1.1\", \"2.2.2.2\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tgwconfig: fakeGatewayConfig{\n\t\t\t\tdnsnames: [][]string{\n\t\t\t\t\t{\"\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:      \"no targets\",\n\t\t\tlbServices: []fakeIngressGatewayService{{}},\n\t\t\tgwconfig: fakeGatewayConfig{\n\t\t\t\tname:     \"mygw\",\n\t\t\t\tdnsnames: [][]string{{\"*\"}},\n\t\t\t},\n\t\t\tvsconfig: fakeVirtualServiceConfig{\n\t\t\t\tgateways: []string{},\n\t\t\t\tdnsnames: []string{\"foo.bar\"},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle: \"matching selectors for service and gateway\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tname: \"service1\",\n\t\t\t\t\tselector: map[string]string{\n\t\t\t\t\t\t\"app\": \"myservice\",\n\t\t\t\t\t},\n\t\t\t\t\thostnames: []string{\"elb.com\", \"alb.com\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname: \"service2\",\n\t\t\t\t\tselector: map[string]string{\n\t\t\t\t\t\t\"app\": \"otherservice\",\n\t\t\t\t\t},\n\t\t\t\t\tips: []string{\"8.8.8.8\", \"127.0.0.1\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tgwconfig: fakeGatewayConfig{\n\t\t\t\tname:     \"mygw\",\n\t\t\t\tdnsnames: [][]string{{\"*\"}},\n\t\t\t\tselector: map[string]string{\n\t\t\t\t\t\"app\": \"myservice\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tvsconfig: fakeVirtualServiceConfig{\n\t\t\t\tgateways: []string{\"mygw\"},\n\t\t\t\tdnsnames: []string{\"foo.bar\"},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"elb.com\", \"alb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"ingress gateway annotation same namespace\",\n\t\t\tingresses: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"ingress1\",\n\t\t\t\t\thostnames: []string{\"alb.com\", \"elb.com\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname: \"ingress2\",\n\t\t\t\t\tips:  []string{\"127.0.0.1\", \"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tgwconfig: fakeGatewayConfig{\n\t\t\t\tname:     \"mygw\",\n\t\t\t\tdnsnames: [][]string{{\"*\"}},\n\t\t\t\tannotations: map[string]string{\n\t\t\t\t\tIstioGatewayIngressSource: \"ingress1\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tvsconfig: fakeVirtualServiceConfig{\n\t\t\t\tgateways: []string{\"mygw\"},\n\t\t\t\tdnsnames: []string{\"foo.bar\"},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"alb.com\", \"elb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"ingress gateway annotation separate namespace\",\n\t\t\tingresses: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"ingress1\",\n\t\t\t\t\tnamespace: \"ingress\",\n\t\t\t\t\thostnames: []string{\"alb.com\", \"elb.com\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"ingress2\",\n\t\t\t\t\tnamespace: \"ingress\",\n\t\t\t\t\tips:       []string{\"127.0.0.1\", \"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tgwconfig: fakeGatewayConfig{\n\t\t\t\tname:     \"mygw\",\n\t\t\t\tdnsnames: [][]string{{\"*\"}},\n\t\t\t\tannotations: map[string]string{\n\t\t\t\t\tIstioGatewayIngressSource: \"ingress/ingress2\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tvsconfig: fakeVirtualServiceConfig{\n\t\t\t\tgateways: []string{\"mygw\"},\n\t\t\t\tdnsnames: []string{\"foo.bar\"},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"127.0.0.1\", \"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"provider-specific annotation is converted to endpoint property\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips: []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tgwconfig: fakeGatewayConfig{\n\t\t\t\tname:     \"mygw\",\n\t\t\t\tdnsnames: [][]string{{\"*\"}},\n\t\t\t},\n\t\t\tvsconfig: fakeVirtualServiceConfig{\n\t\t\t\tannotations: map[string]string{\n\t\t\t\t\tannotations.AWSPrefix + \"weight\": \"10\",\n\t\t\t\t},\n\t\t\t\tgateways: []string{\"mygw\"},\n\t\t\t\tdnsnames: []string{\"foo.bar\"},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{Name: \"aws/weight\", Value: \"10\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t} {\n\n\t\tt.Run(ti.title, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\tfor i := range ti.ingresses {\n\t\t\t\tif ti.ingresses[i].namespace == \"\" {\n\t\t\t\t\tti.ingresses[i].namespace = \"test\"\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif ti.gwconfig.namespace == \"\" {\n\t\t\t\tti.gwconfig.namespace = \"test\"\n\t\t\t}\n\n\t\t\tif ti.vsconfig.namespace == \"\" {\n\t\t\t\tti.vsconfig.namespace = \"test\"\n\t\t\t}\n\n\t\t\tif source, err := newTestVirtualServiceSource(ti.lbServices, ti.ingresses, []fakeGatewayConfig{ti.gwconfig}); err != nil {\n\t\t\t\trequire.NoError(t, err)\n\t\t\t} else if endpoints, err := source.endpointsFromVirtualService(t.Context(), ti.vsconfig.Config()); err != nil {\n\t\t\t\trequire.NoError(t, err)\n\t\t\t} else {\n\t\t\t\tvalidateEndpoints(t, endpoints, ti.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc testVirtualServiceEndpoints(t *testing.T) {\n\tt.Parallel()\n\n\tnamespace := \"testing\"\n\tfor _, ti := range []struct {\n\t\ttitle                    string\n\t\ttargetNamespace          string\n\t\tannotationFilter         string\n\t\tlbServices               []fakeIngressGatewayService\n\t\tingresses                []fakeIngress\n\t\tgwConfigs                []fakeGatewayConfig\n\t\tvsConfigs                []fakeVirtualServiceConfig\n\t\texpected                 []*endpoint.Endpoint\n\t\texpectError              bool\n\t\tfqdnTemplate             string\n\t\tcombineFQDNAndAnnotation bool\n\t\tignoreHostnameAnnotation bool\n\t}{\n\t\t{\n\t\t\ttitle: \"two simple virtualservices with one gateway each, one ingressgateway loadbalancer service\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\t\thostnames: []string{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tgwConfigs: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tdnsnames:  [][]string{{\"example.org\"}},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake2\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tdnsnames:  [][]string{{\"new.org\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\tvsConfigs: []fakeVirtualServiceConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tgateways:  []string{\"fake1\"},\n\t\t\t\t\tdnsnames:  []string{\"example.org\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs2\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tgateways:  []string{\"fake2\"},\n\t\t\t\t\tdnsnames:  []string{\"new.org\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"new.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"new.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"two simple virtualservices with one gateway each, one ingress\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\t\thostnames: []string{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tingresses: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"ingress1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\t\thostnames: []string{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tgwConfigs: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tdnsnames:  [][]string{{\"example.org\"}},\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tIstioGatewayIngressSource: \"ingress1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake2\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tdnsnames:  [][]string{{\"new.org\"}},\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tIstioGatewayIngressSource: \"ingress1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tvsConfigs: []fakeVirtualServiceConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tgateways:  []string{\"fake1\"},\n\t\t\t\t\tdnsnames:  []string{\"example.org\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs2\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tgateways:  []string{\"fake2\"},\n\t\t\t\t\tdnsnames:  []string{\"new.org\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"new.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"new.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"one virtualservice with two gateways, one ingressgateway loadbalancer service\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tgwConfigs: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"gw1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tdnsnames:  [][]string{{\"*\"}},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"gw2\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tdnsnames:  [][]string{{\"*\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\tvsConfigs: []fakeVirtualServiceConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tgateways:  []string{\"gw1\", \"gw2\"},\n\t\t\t\t\tdnsnames:  []string{\"example.org\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"one virtualservice with two gateways, one ingressgateway loadbalancer service with externalIPs\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tnamespace:   namespace,\n\t\t\t\t\texternalIPs: []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tgwConfigs: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"gw1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tdnsnames:  [][]string{{\"*\"}},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"gw2\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tdnsnames:  [][]string{{\"*\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\tvsConfigs: []fakeVirtualServiceConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tgateways:  []string{\"gw1\", \"gw2\"},\n\t\t\t\t\tdnsnames:  []string{\"example.org\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"two simple virtualservices on different namespaces with the same target gateway, one ingressgateway loadbalancer service\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\t\thostnames: []string{\"lb.com\"},\n\t\t\t\t\tnamespace: \"istio-system\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tgwConfigs: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: \"istio-system\",\n\t\t\t\t\tdnsnames:  [][]string{{\"*\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\tvsConfigs: []fakeVirtualServiceConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs1\",\n\t\t\t\t\tnamespace: \"testing1\",\n\t\t\t\t\tgateways:  []string{\"istio-system/fake1\"},\n\t\t\t\t\tdnsnames:  []string{\"example.org\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs2\",\n\t\t\t\t\tnamespace: \"testing2\",\n\t\t\t\t\tgateways:  []string{\"istio-system/fake1\"},\n\t\t\t\t\tdnsnames:  []string{\"new.org\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"new.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"new.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"two simple virtualservices with one gateway on different namespaces and a target namespace, one ingressgateway loadbalancer service\",\n\t\t\ttargetNamespace: \"testing1\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\t\thostnames: []string{\"lb.com\"},\n\t\t\t\t\tnamespace: \"testing1\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tgwConfigs: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: \"testing1\",\n\t\t\t\t\tdnsnames:  [][]string{{\"*\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\tvsConfigs: []fakeVirtualServiceConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs1\",\n\t\t\t\t\tnamespace: \"testing1\",\n\t\t\t\t\tgateways:  []string{\"testing1/fake1\"},\n\t\t\t\t\tdnsnames:  []string{\"example.org\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs2\",\n\t\t\t\t\tnamespace: \"testing2\",\n\t\t\t\t\tgateways:  []string{\"testing1/fake1\"},\n\t\t\t\t\tdnsnames:  []string{\"new.org\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"two simple virtualservices with one gateway on different namespaces and a target namespace, one ingressgateway loadbalancer service with externalIPs\",\n\t\t\ttargetNamespace: \"testing1\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\texternalIPs: []string{\"8.8.8.8\"},\n\t\t\t\t\tnamespace:   \"testing1\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tgwConfigs: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: \"testing1\",\n\t\t\t\t\tdnsnames:  [][]string{{\"*\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\tvsConfigs: []fakeVirtualServiceConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs1\",\n\t\t\t\t\tnamespace: \"testing1\",\n\t\t\t\t\tgateways:  []string{\"testing1/fake1\"},\n\t\t\t\t\tdnsnames:  []string{\"example.org\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs2\",\n\t\t\t\t\tnamespace: \"testing2\",\n\t\t\t\t\tgateways:  []string{\"testing1/fake1\"},\n\t\t\t\t\tdnsnames:  []string{\"new.org\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"two simple virtualservices with one gateway on different namespaces and a target namespace, one ingress\",\n\t\t\ttargetNamespace: \"testing1\",\n\t\t\tingresses: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"ingress1\",\n\t\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\t\thostnames: []string{\"lb.com\"},\n\t\t\t\t\tnamespace: \"testing1\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tgwConfigs: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: \"testing1\",\n\t\t\t\t\tdnsnames:  [][]string{{\"*\"}},\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tIstioGatewayIngressSource: \"ingress1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tvsConfigs: []fakeVirtualServiceConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs1\",\n\t\t\t\t\tnamespace: \"testing1\",\n\t\t\t\t\tgateways:  []string{\"testing1/fake1\"},\n\t\t\t\t\tdnsnames:  []string{\"example.org\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs2\",\n\t\t\t\t\tnamespace: \"testing2\",\n\t\t\t\t\tgateways:  []string{\"testing1/fake1\"},\n\t\t\t\t\tdnsnames:  []string{\"new.org\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:            \"valid matching annotation filter expression\",\n\t\t\tannotationFilter: \"kubernetes.io/virtualservice.class in (alb, nginx)\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t},\n\t\t\t},\n\t\t\tgwConfigs: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tdnsnames:  [][]string{{\"*\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\tvsConfigs: []fakeVirtualServiceConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\t\"kubernetes.io/virtualservice.class\": \"nginx\",\n\t\t\t\t\t},\n\t\t\t\t\tgateways: []string{\"fake1\"},\n\t\t\t\t\tdnsnames: []string{\"example.org\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:            \"valid non-matching annotation filter expression\",\n\t\t\tannotationFilter: \"kubernetes.io/gateway.class in (alb, nginx)\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t},\n\t\t\t},\n\t\t\tgwConfigs: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tdnsnames:  [][]string{{\"*\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\tvsConfigs: []fakeVirtualServiceConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\t\"kubernetes.io/virtualservice.class\": \"tectonic\",\n\t\t\t\t\t},\n\t\t\t\t\tgateways: []string{\"fake1\"},\n\t\t\t\t\tdnsnames: []string{\"example.org\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:            \"invalid annotation filter expression\",\n\t\t\tannotationFilter: \"kubernetes.io/gateway.name in (a b)\",\n\t\t\texpected:         []*endpoint.Endpoint{},\n\t\t\texpectError:      true,\n\t\t},\n\t\t{\n\t\t\ttitle: \"gateway ingress annotation; ingress not found\",\n\t\t\tingresses: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"ingress1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tgwConfigs: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tdnsnames:  [][]string{{\"*\"}},\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tIstioGatewayIngressSource: \"ingress2\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tvsConfigs: []fakeVirtualServiceConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tgateways:  []string{\"fake1\"},\n\t\t\t\t\tdnsnames:  []string{\"example.org\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected:    []*endpoint.Endpoint{},\n\t\t\texpectError: true,\n\t\t},\n\t\t{\n\t\t\ttitle: \"our controller type is dns-controller\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t},\n\t\t\t},\n\t\t\tgwConfigs: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tdnsnames:  [][]string{{\"*\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\tvsConfigs: []fakeVirtualServiceConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.ControllerKey: annotations.ControllerValue,\n\t\t\t\t\t},\n\t\t\t\t\tgateways: []string{\"fake1\"},\n\t\t\t\t\tdnsnames: []string{\"example.org\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"different controller types are ignored\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t},\n\t\t\t},\n\t\t\tgwConfigs: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tdnsnames:  [][]string{{\"*\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\tvsConfigs: []fakeVirtualServiceConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.ControllerKey: \"some-other-tool\",\n\t\t\t\t\t},\n\t\t\t\t\tgateways: []string{\"fake1\"},\n\t\t\t\t\tdnsnames: []string{\"example.org\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle: \"template for virtualservice if host is missing\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\t\thostnames: []string{\"elb.com\"},\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t},\n\t\t\t},\n\t\t\tgwConfigs: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tdnsnames:  [][]string{{\"*\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\tvsConfigs: []fakeVirtualServiceConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tgateways:  []string{\"fake1\"},\n\t\t\t\t\tdnsnames:  []string{\"\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"vs1.ext-dns.test.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"vs1.ext-dns.test.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"elb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{.Name}}.ext-dns.test.com\",\n\t\t},\n\t\t{\n\t\t\ttitle: \"multiple FQDN template hostnames\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t},\n\t\t\t},\n\t\t\tgwConfigs: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tdnsnames:  [][]string{{\"*\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\tvsConfigs: []fakeVirtualServiceConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tgateways:  []string{\"fake1\"},\n\t\t\t\t\tdnsnames:  []string{\"\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"vs1.ext-dns.test.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"vs1.ext-dna.test.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{.Name}}.ext-dns.test.com, {{.Name}}.ext-dna.test.com\",\n\t\t},\n\t\t{\n\t\t\ttitle: \"multiple FQDN template hostnames with restricted gw.hosts\",\n\t\t\tgwConfigs: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"gateway-target.com\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: [][]string{{\"*.org\", \"*.ext-dns.test.com\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\tvsConfigs: []fakeVirtualServiceConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tgateways:  []string{\"fake1\"},\n\t\t\t\t\tdnsnames:  []string{\"example.org\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs2\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tgateways:  []string{\"fake1\"},\n\t\t\t\t\tdnsnames:  []string{},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"vs1.ext-dns.test.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"gateway-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"vs2.ext-dns.test.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"gateway-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"gateway-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate:             \"{{.Name}}.ext-dns.test.com, {{.Name}}.ext-dna.test.com\",\n\t\t\tcombineFQDNAndAnnotation: true,\n\t\t},\n\t\t{\n\t\t\ttitle: \"virtualservice with target annotation\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t},\n\t\t\t},\n\t\t\tvsConfigs: []fakeVirtualServiceConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tgateways:  []string{\"fake1\"},\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"virtualservice-target.com\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: []string{\"example.org\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs2\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tgateways:  []string{\"fake1\"},\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"virtualservice-target.com\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: []string{\"example2.org\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"virtualservice-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example2.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"virtualservice-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"virtualservice; gateway with target annotation\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t},\n\t\t\t},\n\t\t\tgwConfigs: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tdnsnames:  [][]string{{\"*\"}},\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"gateway-target.com\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tvsConfigs: []fakeVirtualServiceConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tgateways:  []string{\"fake1\"},\n\t\t\t\t\tdnsnames:  []string{\"example.org\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs2\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tgateways:  []string{\"fake1\"},\n\t\t\t\t\tdnsnames:  []string{\"example2.org\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"gateway-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example2.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"gateway-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"virtualservice; gateway with target and ingress annotation\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t},\n\t\t\t},\n\t\t\tingresses: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"ingress1\",\n\t\t\t\t\tips:       []string{\"1.1.1.1\"},\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t},\n\t\t\t},\n\t\t\tgwConfigs: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tdnsnames:  [][]string{{\"*\"}},\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey:     \"gateway-target.com\",\n\t\t\t\t\t\tIstioGatewayIngressSource: \"ingress1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tvsConfigs: []fakeVirtualServiceConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tgateways:  []string{\"fake1\"},\n\t\t\t\t\tdnsnames:  []string{\"example.org\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs2\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tgateways:  []string{\"fake1\"},\n\t\t\t\t\tdnsnames:  []string{\"example2.org\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"gateway-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example2.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"gateway-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"virtualservice with hostname annotation\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips:       []string{\"1.2.3.4\"},\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t},\n\t\t\t},\n\t\t\tgwConfigs: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tdnsnames:  [][]string{{\"*\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\tvsConfigs: []fakeVirtualServiceConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tgateways:  []string{\"fake1\"},\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.HostnameKey: \"dns-through-hostname.com\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: []string{\"example.org\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"dns-through-hostname.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"virtualservice with hostname annotation having multiple hostnames, restricted by gw.hosts\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips:       []string{\"1.2.3.4\"},\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t},\n\t\t\t},\n\t\t\tgwConfigs: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tdnsnames:  [][]string{{\"*.bar.com\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\tvsConfigs: []fakeVirtualServiceConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tgateways:  []string{\"fake1\"},\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.HostnameKey: \"foo.bar.com, another-dns-through-hostname.com\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: []string{\"baz.bar.org\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"virtualservices with annotation and custom TTL\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t},\n\t\t\t},\n\t\t\tgwConfigs: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tdnsnames:  [][]string{{\"*\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\tvsConfigs: []fakeVirtualServiceConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tgateways:  []string{\"fake1\"},\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TtlKey: \"6\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: []string{\"example.org\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs2\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tgateways:  []string{\"fake1\"},\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TtlKey: \"1\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: []string{\"example2.org\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t\tRecordTTL:  endpoint.TTL(6),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example2.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t\tRecordTTL:  endpoint.TTL(1),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"template for virtualservice; gateway with target annotation\",\n\t\t\tgwConfigs: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"gateway-target.com\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: [][]string{{\"*\"}},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake2\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"gateway-target.com\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: [][]string{{\"*\"}},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake3\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.TargetKey: \"1.2.3.4\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: [][]string{{\"*\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\tvsConfigs: []fakeVirtualServiceConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tgateways:  []string{\"fake1\"},\n\t\t\t\t\tdnsnames:  []string{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs2\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tgateways:  []string{\"fake2\"},\n\t\t\t\t\tdnsnames:  []string{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs3\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tgateways:  []string{\"fake3\"},\n\t\t\t\t\tdnsnames:  []string{},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"vs1.ext-dns.test.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"gateway-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"vs2.ext-dns.test.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"gateway-target.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"vs3.ext-dns.test.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{.Name}}.ext-dns.test.com\",\n\t\t},\n\t\t{\n\t\t\ttitle: \"ignore hostname annotations\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tips:       []string{\"8.8.8.8\"},\n\t\t\t\t\thostnames: []string{\"lb.com\"},\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t},\n\t\t\t},\n\t\t\tgwConfigs: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tdnsnames:  [][]string{{\"*\"}},\n\t\t\t\t},\n\t\t\t},\n\t\t\tvsConfigs: []fakeVirtualServiceConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs1\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tgateways:  []string{\"fake1\"},\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.HostnameKey: \"ignore.me\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: []string{\"example.org\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs2\",\n\t\t\t\t\tnamespace: namespace,\n\t\t\t\t\tgateways:  []string{\"fake1\"},\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tannotations.HostnameKey: \"ignore.me.too\",\n\t\t\t\t\t},\n\t\t\t\t\tdnsnames: []string{\"new.org\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"new.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"new.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets{\"lb.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tignoreHostnameAnnotation: true,\n\t\t},\n\t\t{\n\t\t\ttitle: \"complex setup with multiple gateways and multiple vs.hosts only matching some of the gateway\",\n\t\t\tlbServices: []fakeIngressGatewayService{\n\t\t\t\t{\n\t\t\t\t\tname: \"svc1\",\n\t\t\t\t\tselector: map[string]string{\n\t\t\t\t\t\t\"app\": \"igw1\",\n\t\t\t\t\t},\n\t\t\t\t\thostnames: []string{\"target1.com\"},\n\t\t\t\t\tnamespace: \"istio-system\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname: \"svc2\",\n\t\t\t\t\tselector: map[string]string{\n\t\t\t\t\t\t\"app\": \"igw2\",\n\t\t\t\t\t},\n\t\t\t\t\thostnames: []string{\"target2.com\"},\n\t\t\t\t\tnamespace: \"testing1\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname: \"svc3\",\n\t\t\t\t\tselector: map[string]string{\n\t\t\t\t\t\t\"app\": \"igw3\",\n\t\t\t\t\t},\n\t\t\t\t\thostnames: []string{\"target3.com\"},\n\t\t\t\t\tnamespace: \"testing2\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tingresses: []fakeIngress{\n\t\t\t\t{\n\t\t\t\t\tname:      \"ingress1\",\n\t\t\t\t\tnamespace: \"testing1\",\n\t\t\t\t\thostnames: []string{\"target4.com\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"ingress3\",\n\t\t\t\t\tnamespace: \"testing3\",\n\t\t\t\t\thostnames: []string{\"target5.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tgwConfigs: []fakeGatewayConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake1\",\n\t\t\t\t\tnamespace: \"istio-system\",\n\t\t\t\t\tdnsnames:  [][]string{{\"*\"}},\n\t\t\t\t\tselector: map[string]string{\n\t\t\t\t\t\t\"app\": \"igw1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake2\",\n\t\t\t\t\tnamespace: \"testing1\",\n\t\t\t\t\tdnsnames:  [][]string{{\"*.baz.com\"}, {\"*.bar.com\"}},\n\t\t\t\t\tselector: map[string]string{\n\t\t\t\t\t\t\"app\": \"igw2\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake3\",\n\t\t\t\t\tnamespace: \"testing2\",\n\t\t\t\t\tdnsnames:  [][]string{{\"*.bax.com\", \"*.bar.com\"}},\n\t\t\t\t\tselector: map[string]string{\n\t\t\t\t\t\t\"app\": \"igw3\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"fake4\",\n\t\t\t\t\tnamespace: \"testing3\",\n\t\t\t\t\tdnsnames:  [][]string{{\"*.bax.com\", \"*.bar.com\"}},\n\t\t\t\t\tselector: map[string]string{\n\t\t\t\t\t\t\"app\": \"igw4\",\n\t\t\t\t\t},\n\t\t\t\t\tannotations: map[string]string{\n\t\t\t\t\t\tIstioGatewayIngressSource: \"testing1/ingress1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tvsConfigs: []fakeVirtualServiceConfig{\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs1\",\n\t\t\t\t\tnamespace: \"testing3\",\n\t\t\t\t\tgateways:  []string{\"istio-system/fake1\", \"testing1/fake2\"},\n\t\t\t\t\tdnsnames:  []string{\"somedomain.com\", \"foo.bar.com\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs2\",\n\t\t\t\t\tnamespace: \"testing2\",\n\t\t\t\t\tgateways:  []string{\"testing1/fake2\", \"fake3\"},\n\t\t\t\t\tdnsnames:  []string{\"hello.bar.com\", \"hello.bax.com\", \"hello.bak.com\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs3\",\n\t\t\t\t\tnamespace: \"testing1\",\n\t\t\t\t\tgateways:  []string{\"istio-system/fake1\", \"testing2/fake3\"},\n\t\t\t\t\tdnsnames:  []string{\"world.bax.com\", \"world.bak.com\"},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tname:      \"vs4\",\n\t\t\t\t\tnamespace: \"testing1\",\n\t\t\t\t\tgateways:  []string{\"istio-system/fake1\", \"testing3/fake4\"},\n\t\t\t\t\tdnsnames:  []string{\"foo.bax.com\", \"foo.bak.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"somedomain.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"target1.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bar.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"target1.com\", \"target2.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"hello.bar.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"target2.com\", \"target3.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"hello.bax.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"target3.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"world.bak.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"target1.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"world.bax.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"target1.com\", \"target3.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bak.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"target1.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.bax.com\",\n\t\t\t\t\tTargets:    endpoint.Targets{\"target1.com\", \"target4.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{.Name}}.ext-dns.test.com\",\n\t\t},\n\t} {\n\n\t\tt.Run(ti.title, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\tvar gateways []*networkingv1beta1.Gateway\n\t\t\tvar virtualservices []*networkingv1beta1.VirtualService\n\n\t\t\tfor _, gwItem := range ti.gwConfigs {\n\t\t\t\tgateways = append(gateways, gwItem.Config())\n\t\t\t}\n\t\t\tfor _, vsItem := range ti.vsConfigs {\n\t\t\t\tvirtualservices = append(virtualservices, vsItem.Config())\n\t\t\t}\n\n\t\t\tfakeKubernetesClient := fake.NewClientset()\n\n\t\t\tfor _, lb := range ti.lbServices {\n\t\t\t\tservice := lb.Service()\n\t\t\t\t_, err := fakeKubernetesClient.CoreV1().Services(service.Namespace).Create(t.Context(), service, metav1.CreateOptions{})\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\tfor _, ing := range ti.ingresses {\n\t\t\t\tingress := ing.Ingress()\n\t\t\t\t_, err := fakeKubernetesClient.NetworkingV1().Ingresses(ingress.Namespace).Create(t.Context(), ingress, metav1.CreateOptions{})\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\tfakeIstioClient := istiofake.NewSimpleClientset()\n\n\t\t\tfor _, gateway := range gateways {\n\t\t\t\t_, err := fakeIstioClient.NetworkingV1beta1().Gateways(gateway.Namespace).Create(t.Context(), gateway, metav1.CreateOptions{})\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\tfor _, vService := range virtualservices {\n\t\t\t\t_, err := fakeIstioClient.NetworkingV1beta1().VirtualServices(vService.Namespace).Create(t.Context(), vService, metav1.CreateOptions{})\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\tvirtualServiceSource, err := NewIstioVirtualServiceSource(\n\t\t\t\tt.Context(),\n\t\t\t\tfakeKubernetesClient,\n\t\t\t\tfakeIstioClient,\n\t\t\t\t&Config{\n\t\t\t\t\tNamespace:                ti.targetNamespace,\n\t\t\t\t\tAnnotationFilter:         ti.annotationFilter,\n\t\t\t\t\tFQDNTemplate:             ti.fqdnTemplate,\n\t\t\t\t\tCombineFQDNAndAnnotation: ti.combineFQDNAndAnnotation,\n\t\t\t\t\tIgnoreHostnameAnnotation: ti.ignoreHostnameAnnotation,\n\t\t\t\t},\n\t\t\t)\n\t\t\trequire.NoError(t, err)\n\n\t\t\tres, err := virtualServiceSource.Endpoints(t.Context())\n\t\t\tif ti.expectError {\n\t\t\t\tassert.Error(t, err)\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t}\n\n\t\t\tvalidateEndpoints(t, res, ti.expected)\n\t\t})\n\t}\n}\n\nfunc testGatewaySelectorMatchesService(t *testing.T) {\n\tfor _, ti := range []struct {\n\t\ttitle      string\n\t\tgwSelector map[string]string\n\t\tlbSelector map[string]string\n\t\texpected   bool\n\t}{\n\t\t{\n\t\t\ttitle:      \"gw selector matches lb selector\",\n\t\t\tgwSelector: map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\tlbSelector: map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\texpected:   true,\n\t\t},\n\t\t{\n\t\t\ttitle:      \"gw selector matches lb selector partially\",\n\t\t\tgwSelector: map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\tlbSelector: map[string]string{\"release\": \"istio\", \"istio\": \"ingressgateway\"},\n\t\t\texpected:   true,\n\t\t},\n\t\t{\n\t\t\ttitle:      \"gw selector does not match lb selector\",\n\t\t\tgwSelector: map[string]string{\"app\": \"mytest\"},\n\t\t\tlbSelector: map[string]string{\"istio\": \"ingressgateway\"},\n\t\t\texpected:   false,\n\t\t},\n\t} {\n\t\tt.Run(ti.title, func(t *testing.T) {\n\t\t\trequire.Equal(t, ti.expected, MatchesServiceSelector(ti.gwSelector, ti.lbSelector))\n\t\t})\n\t}\n}\n\nfunc newTestVirtualServiceSource(loadBalancerList []fakeIngressGatewayService, ingressList []fakeIngress, gwList []fakeGatewayConfig) (*virtualServiceSource, error) {\n\tfakeKubernetesClient := fake.NewClientset()\n\tfakeIstioClient := istiofake.NewSimpleClientset()\n\n\tfor _, lb := range loadBalancerList {\n\t\tservice := lb.Service()\n\t\t_, err := fakeKubernetesClient.CoreV1().Services(service.Namespace).Create(context.Background(), service, metav1.CreateOptions{})\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tfor _, ing := range ingressList {\n\t\tingress := ing.Ingress()\n\t\t_, err := fakeKubernetesClient.NetworkingV1().Ingresses(ingress.Namespace).Create(context.Background(), ingress, metav1.CreateOptions{})\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tfor _, gw := range gwList {\n\t\tgwObj := gw.Config()\n\t\t// use create instead of add\n\t\t// https://github.com/kubernetes/client-go/blob/92512ee2b8cf6696e9909245624175b7f0c971d9/testing/fixture.go#LL336C3-L336C52\n\t\t_, err := fakeIstioClient.NetworkingV1beta1().Gateways(gw.namespace).Create(context.Background(), gwObj, metav1.CreateOptions{})\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tsrc, err := NewIstioVirtualServiceSource(\n\t\tcontext.TODO(),\n\t\tfakeKubernetesClient,\n\t\tfakeIstioClient,\n\t\t&Config{\n\t\t\tFQDNTemplate: \"{{ .Name }}\",\n\t\t},\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvssrc, ok := src.(*virtualServiceSource)\n\tif !ok {\n\t\treturn nil, errors.New(\"underlying source type was not virtualservice\")\n\t}\n\n\treturn vssrc, nil\n}\n\ntype fakeVirtualServiceConfig struct {\n\tnamespace   string\n\tname        string\n\tgateways    []string\n\tannotations map[string]string\n\tdnsnames    []string\n\texportTo    string\n}\n\nfunc (c fakeVirtualServiceConfig) Config() *networkingv1beta1.VirtualService {\n\tvs := istionetworking.VirtualService{\n\t\tGateways: c.gateways,\n\t\tHosts:    c.dnsnames,\n\t}\n\tif c.exportTo != \"\" {\n\t\tvs.ExportTo = []string{c.exportTo}\n\t}\n\n\treturn &networkingv1beta1.VirtualService{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:        c.name,\n\t\t\tNamespace:   c.namespace,\n\t\t\tAnnotations: c.annotations,\n\t\t},\n\t\tSpec: *vs.DeepCopy(),\n\t}\n}\n\nfunc TestVirtualServiceSourceGetGateway(t *testing.T) {\n\ttype fields struct {\n\t\tvirtualServiceSource *virtualServiceSource\n\t}\n\ttype args struct {\n\t\tctx            context.Context\n\t\tgatewayStr     string\n\t\tvirtualService *networkingv1beta1.VirtualService\n\t}\n\ttests := []struct {\n\t\tname           string\n\t\tfields         fields\n\t\targs           args\n\t\twant           *networkingv1beta1.Gateway\n\t\texpectedErrStr string\n\t}{\n\t\t{name: \"EmptyGateway\", fields: fields{\n\t\t\tvirtualServiceSource: func() *virtualServiceSource { vs, _ := newTestVirtualServiceSource(nil, nil, nil); return vs }(),\n\t\t}, args: args{\n\t\t\tctx:            t.Context(),\n\t\t\tgatewayStr:     \"\",\n\t\t\tvirtualService: nil,\n\t\t}, want: nil, expectedErrStr: \"\"},\n\t\t{name: \"MeshGateway\", fields: fields{\n\t\t\tvirtualServiceSource: func() *virtualServiceSource { vs, _ := newTestVirtualServiceSource(nil, nil, nil); return vs }(),\n\t\t}, args: args{\n\t\t\tctx:            t.Context(),\n\t\t\tgatewayStr:     IstioMeshGateway,\n\t\t\tvirtualService: nil,\n\t\t}, want: nil, expectedErrStr: \"\"},\n\t\t{name: \"MissingGateway\", fields: fields{\n\t\t\tvirtualServiceSource: func() *virtualServiceSource { vs, _ := newTestVirtualServiceSource(nil, nil, nil); return vs }(),\n\t\t}, args: args{\n\t\t\tctx:        t.Context(),\n\t\t\tgatewayStr: \"doesnt/exist\",\n\t\t\tvirtualService: &networkingv1beta1.VirtualService{\n\t\t\t\tTypeMeta:   metav1.TypeMeta{},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"exist\", Namespace: \"doesnt\"},\n\t\t\t\tSpec:       istionetworking.VirtualService{},\n\t\t\t\tStatus:     v1alpha1.IstioStatus{},\n\t\t\t},\n\t\t}, want: nil, expectedErrStr: \"\"},\n\t\t{name: \"InvalidGatewayStr\", fields: fields{\n\t\t\tvirtualServiceSource: func() *virtualServiceSource { vs, _ := newTestVirtualServiceSource(nil, nil, nil); return vs }(),\n\t\t}, args: args{\n\t\t\tctx:            t.Context(),\n\t\t\tgatewayStr:     \"1/2/3/\",\n\t\t\tvirtualService: &networkingv1beta1.VirtualService{},\n\t\t}, want: nil, expectedErrStr: \"invalid ingress name (name or namespace/name) found \\\"1/2/3/\\\"\"},\n\t\t{name: \"ExistingGateway\", fields: fields{\n\t\t\tvirtualServiceSource: func() *virtualServiceSource {\n\t\t\t\tvs, _ := newTestVirtualServiceSource(nil, nil, []fakeGatewayConfig{{\n\t\t\t\t\tnamespace: \"bar\",\n\t\t\t\t\tname:      \"foo\",\n\t\t\t\t}})\n\t\t\t\treturn vs\n\t\t\t}(),\n\t\t}, args: args{\n\t\t\tctx:        t.Context(),\n\t\t\tgatewayStr: \"bar/foo\",\n\t\t\tvirtualService: &networkingv1beta1.VirtualService{\n\t\t\t\tTypeMeta:   metav1.TypeMeta{},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"foo\", Namespace: \"bar\"},\n\t\t\t\tSpec:       istionetworking.VirtualService{},\n\t\t\t\tStatus:     v1alpha1.IstioStatus{},\n\t\t\t},\n\t\t}, want: &networkingv1beta1.Gateway{\n\t\t\tTypeMeta:   metav1.TypeMeta{},\n\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"foo\", Namespace: \"bar\"},\n\t\t\tSpec:       istionetworking.Gateway{},\n\t\t\tStatus:     v1alpha1.IstioStatus{},\n\t\t}, expectedErrStr: \"\"},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot, err := tt.fields.virtualServiceSource.getGateway(tt.args.ctx, tt.args.gatewayStr, tt.args.virtualService)\n\t\t\tif tt.expectedErrStr != \"\" {\n\t\t\t\tassert.EqualError(t, err, tt.expectedErrStr, \"getGateway(%v, %v, %v)\", tt.args.ctx, tt.args.gatewayStr, tt.args.virtualService)\n\t\t\t\treturn\n\t\t\t} else {\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\t\t\tif tt.want != nil && got != nil {\n\t\t\t\ttt.want.Spec.ProtoReflect()\n\t\t\t\ttt.want.Status.ProtoReflect()\n\t\t\t\tassert.Equalf(t, tt.want, got, \"getGateway(%v, %v, %v)\", tt.args.ctx, tt.args.gatewayStr, tt.args.virtualService)\n\t\t\t} else {\n\t\t\t\tassert.Equalf(t, tt.want, got, \"getGateway(%v, %v, %v)\", tt.args.ctx, tt.args.gatewayStr, tt.args.virtualService)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestIstioVirtualServiceSource_GWServiceSelectorMatchServiceSelector(t *testing.T) {\n\ttests := []struct {\n\t\tname      string\n\t\tselectors map[string]string\n\t\texpected  []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\tname: \"gw single selector match with single service selector\",\n\t\t\tselectors: map[string]string{\n\t\t\t\t\"version\": \"v1\",\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"example.org\", endpoint.RecordTypeA, \"10.10.10.255\").WithLabel(\"resource\", \"virtualservice/default/fake-vservice\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"gw selector match all service selectors\",\n\t\t\tselectors: map[string]string{\n\t\t\t\t\"app\":     \"demo\",\n\t\t\t\t\"env\":     \"prod\",\n\t\t\t\t\"team\":    \"devops\",\n\t\t\t\t\"version\": \"v1\",\n\t\t\t\t\"release\": \"stable\",\n\t\t\t\t\"track\":   \"daily\",\n\t\t\t\t\"tier\":    \"backend\",\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"example.org\", endpoint.RecordTypeA, \"10.10.10.255\").WithLabel(\"resource\", \"virtualservice/default/fake-vservice\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"gw selector has subset of service selectors\",\n\t\t\tselectors: map[string]string{\n\t\t\t\t\"version\": \"v1\",\n\t\t\t\t\"release\": \"stable\",\n\t\t\t\t\"tier\":    \"backend\",\n\t\t\t\t\"app\":     \"demo\",\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"example.org\", endpoint.RecordTypeA, \"10.10.10.255\").WithLabel(\"resource\", \"virtualservice/default/fake-vservice\"),\n\t\t\t},\n\t\t},\n\t}\n\tfor i, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tfakeKubeClient := fake.NewClientset()\n\t\t\tfakeIstioClient := istiofake.NewSimpleClientset()\n\n\t\t\tsvc := &v1.Service{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"fake-service\",\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\tUID:       types.UID(fmt.Sprintf(\"fake-service-uid-%d\", i)),\n\t\t\t\t},\n\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\tSelector: map[string]string{\n\t\t\t\t\t\t\"app\":     \"demo\",\n\t\t\t\t\t\t\"env\":     \"prod\",\n\t\t\t\t\t\t\"team\":    \"devops\",\n\t\t\t\t\t\t\"version\": \"v1\",\n\t\t\t\t\t\t\"release\": \"stable\",\n\t\t\t\t\t\t\"track\":   \"daily\",\n\t\t\t\t\t\t\"tier\":    \"backend\",\n\t\t\t\t\t},\n\t\t\t\t\tExternalIPs: []string{\"10.10.10.255\"},\n\t\t\t\t},\n\t\t\t}\n\n\t\t\t_, err := fakeKubeClient.CoreV1().Services(svc.Namespace).Create(t.Context(), svc, metav1.CreateOptions{})\n\t\t\trequire.NoError(t, err)\n\n\t\t\tgw := &networkingv1beta1.Gateway{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"fake-gateway\",\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t},\n\t\t\t\tSpec: istionetworking.Gateway{\n\t\t\t\t\tServers: []*istionetworking.Server{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tHosts: []string{\"example.org\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSelector: tt.selectors,\n\t\t\t\t},\n\t\t\t}\n\n\t\t\t_, err = fakeIstioClient.NetworkingV1beta1().Gateways(gw.Namespace).Create(t.Context(), gw, metav1.CreateOptions{})\n\t\t\trequire.NoError(t, err)\n\n\t\t\tgwService := &networkingv1beta1.VirtualService{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"fake-vservice\", Namespace: \"default\"},\n\t\t\t\tSpec: istionetworking.VirtualService{\n\t\t\t\t\tGateways: []string{gw.Namespace + \"/\" + gw.Name},\n\t\t\t\t\tHosts:    []string{\"example.org\"},\n\t\t\t\t\tExportTo: []string{\"*\"},\n\t\t\t\t},\n\t\t\t}\n\t\t\t_, err = fakeIstioClient.NetworkingV1beta1().VirtualServices(gwService.Namespace).Create(t.Context(), gwService, metav1.CreateOptions{})\n\t\t\trequire.NoError(t, err)\n\n\t\t\tsrc, err := NewIstioVirtualServiceSource(\n\t\t\t\tt.Context(),\n\t\t\t\tfakeKubeClient,\n\t\t\t\tfakeIstioClient,\n\t\t\t\t&Config{},\n\t\t\t)\n\t\t\trequire.NoError(t, err)\n\t\t\trequire.NotNil(t, src)\n\n\t\t\tres, err := src.Endpoints(t.Context())\n\t\t\trequire.NoError(t, err)\n\n\t\t\tvalidateEndpoints(t, res, tt.expected)\n\t\t})\n\t}\n}\n\nfunc TestTransformerInIstioGatewayVirtualServiceSource(t *testing.T) {\n\tsvc := &v1.Service{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:      \"fake-service\",\n\t\t\tNamespace: \"default\",\n\t\t\tLabels: map[string]string{\n\t\t\t\t\"label1\": \"value1\",\n\t\t\t\t\"label2\": \"value2\",\n\t\t\t\t\"label3\": \"value3\",\n\t\t\t},\n\t\t\tAnnotations: map[string]string{\n\t\t\t\t\"user-annotation\": \"value\",\n\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"test-hostname\",\n\t\t\t\t\"external-dns.alpha.kubernetes.io/random\":   \"value\",\n\t\t\t\t\"other/annotation\":                          \"value\",\n\t\t\t},\n\t\t\tUID: \"someuid\",\n\t\t},\n\t\tSpec: v1.ServiceSpec{\n\t\t\tSelector: map[string]string{\n\t\t\t\t\"selector\":  \"one\",\n\t\t\t\t\"selector2\": \"two\",\n\t\t\t\t\"selector3\": \"three\",\n\t\t\t},\n\t\t\tExternalIPs: []string{\"1.2.3.4\"},\n\t\t\tPorts: []v1.ServicePort{\n\t\t\t\t{\n\t\t\t\t\tName:       \"http\",\n\t\t\t\t\tPort:       80,\n\t\t\t\t\tTargetPort: intstr.FromInt32(8080),\n\t\t\t\t\tProtocol:   v1.ProtocolTCP,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tName:       \"https\",\n\t\t\t\t\tPort:       443,\n\t\t\t\t\tTargetPort: intstr.FromInt32(8443),\n\t\t\t\t\tProtocol:   v1.ProtocolTCP,\n\t\t\t\t},\n\t\t\t},\n\t\t\tType: v1.ServiceTypeLoadBalancer,\n\t\t},\n\t\tStatus: v1.ServiceStatus{\n\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\tIngress: []v1.LoadBalancerIngress{\n\t\t\t\t\t{IP: \"5.6.7.8\", Hostname: \"lb.example.com\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\tConditions: []metav1.Condition{\n\t\t\t\t{\n\t\t\t\t\tType:               \"Available\",\n\t\t\t\t\tStatus:             metav1.ConditionTrue,\n\t\t\t\t\tReason:             \"MinimumReplicasAvailable\",\n\t\t\t\t\tMessage:            \"Service is available\",\n\t\t\t\t\tLastTransitionTime: metav1.Now(),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tfakeClient := fake.NewClientset()\n\n\t_, err := fakeClient.CoreV1().Services(svc.Namespace).Create(t.Context(), svc, metav1.CreateOptions{})\n\trequire.NoError(t, err)\n\n\tsrc, err := NewIstioVirtualServiceSource(\n\t\tt.Context(),\n\t\tfakeClient,\n\t\tistiofake.NewSimpleClientset(),\n\t\t&Config{})\n\trequire.NoError(t, err)\n\tgwSource, ok := src.(*virtualServiceSource)\n\trequire.True(t, ok)\n\n\trService, err := gwSource.serviceInformer.Lister().Services(svc.Namespace).Get(svc.Name)\n\trequire.NoError(t, err)\n\n\tassert.Equal(t, svc.Name, rService.Name)\n\tassert.Empty(t, rService.Labels)\n\tassert.Empty(t, rService.Annotations)\n\tassert.Empty(t, rService.UID)\n\tassert.NotEmpty(t, rService.Status.LoadBalancer)\n\tassert.Empty(t, rService.Status.Conditions)\n\tassert.Equal(t, map[string]string{\n\t\t\"selector\":  \"one\",\n\t\t\"selector2\": \"two\",\n\t\t\"selector3\": \"three\",\n\t}, rService.Spec.Selector)\n}\n"
  },
  {
    "path": "source/kong_tcpingress.go",
    "content": "/*\nCopyright 2021 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\tcorev1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\t\"k8s.io/apimachinery/pkg/runtime\"\n\t\"k8s.io/apimachinery/pkg/runtime/schema\"\n\t\"k8s.io/client-go/dynamic\"\n\t\"k8s.io/client-go/dynamic/dynamicinformer\"\n\tkubeinformers \"k8s.io/client-go/informers\"\n\t\"k8s.io/client-go/kubernetes\"\n\t\"k8s.io/client-go/kubernetes/scheme\"\n\n\t\"sigs.k8s.io/external-dns/source/types\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n\t\"sigs.k8s.io/external-dns/source/informers\"\n)\n\nvar kongGroupdVersionResource = schema.GroupVersionResource{\n\tGroup:    \"configuration.konghq.com\",\n\tVersion:  \"v1beta1\",\n\tResource: \"tcpingresses\",\n}\n\n// kongTCPIngressSource is an implementation of Source for Kong TCPIngress objects.\n//\n// +externaldns:source:name=kong-tcpingress\n// +externaldns:source:category=Ingress Controllers\n// +externaldns:source:description=Creates DNS entries from Kong TCPIngress resources\n// +externaldns:source:resources=TCPIngress.configuration.konghq.com\n// +externaldns:source:filters=annotation\n// +externaldns:source:namespace=all,single\n// +externaldns:source:fqdn-template=false\n// +externaldns:source:provider-specific=true\ntype kongTCPIngressSource struct {\n\tannotationFilter         string\n\tignoreHostnameAnnotation bool\n\tdynamicKubeClient        dynamic.Interface\n\tkongTCPIngressInformer   kubeinformers.GenericInformer\n\tkubeClient               kubernetes.Interface\n\tnamespace                string\n\tunstructuredConverter    *unstructuredConverter\n}\n\n// NewKongTCPIngressSource creates a new kongTCPIngressSource with the given config.\nfunc NewKongTCPIngressSource(\n\tctx context.Context,\n\tdynamicKubeClient dynamic.Interface, kubeClient kubernetes.Interface,\n\tcfg *Config,\n) (Source, error) {\n\t// Use shared informer to listen for add/update/delete of Host in the specified namespace.\n\t// Set resync period to 0, to prevent processing when nothing has changed.\n\tinformerFactory := dynamicinformer.NewFilteredDynamicSharedInformerFactory(dynamicKubeClient, 0, cfg.Namespace, nil)\n\tkongTCPIngressInformer := informerFactory.ForResource(kongGroupdVersionResource)\n\n\t// Add default resource event handlers to properly initialize informer.\n\t_, _ = kongTCPIngressInformer.Informer().AddEventHandler(informers.DefaultEventHandler())\n\n\tinformerFactory.Start(ctx.Done())\n\n\t// wait for the local cache to be populated.\n\tif err := informers.WaitForDynamicCacheSync(ctx, informerFactory); err != nil {\n\t\treturn nil, err\n\t}\n\n\tuc, err := newKongUnstructuredConverter()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to setup Unstructured Converter: %w\", err)\n\t}\n\n\treturn &kongTCPIngressSource{\n\t\tannotationFilter:         cfg.AnnotationFilter,\n\t\tignoreHostnameAnnotation: cfg.IgnoreHostnameAnnotation,\n\t\tdynamicKubeClient:        dynamicKubeClient,\n\t\tkongTCPIngressInformer:   kongTCPIngressInformer,\n\t\tkubeClient:               kubeClient,\n\t\tnamespace:                cfg.Namespace,\n\t\tunstructuredConverter:    uc,\n\t}, nil\n}\n\n// Endpoints returns endpoint objects for each host-target combination that should be processed.\n// Retrieves all TCPIngresses in the source's namespace(s).\nfunc (sc *kongTCPIngressSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) {\n\ttis, err := sc.kongTCPIngressInformer.Lister().ByNamespace(sc.namespace).List(labels.Everything())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar tcpIngresses []*TCPIngress\n\tfor _, tcpIngressObj := range tis {\n\t\tunstructuredHost, ok := tcpIngressObj.(*unstructured.Unstructured)\n\t\tif !ok {\n\t\t\treturn nil, errors.New(\"could not convert\")\n\t\t}\n\n\t\ttcpIngress := &TCPIngress{}\n\t\terr := sc.unstructuredConverter.scheme.Convert(unstructuredHost, tcpIngress, nil)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\ttcpIngresses = append(tcpIngresses, tcpIngress)\n\t}\n\n\ttcpIngresses, err = annotations.Filter(tcpIngresses, sc.annotationFilter)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to filter TCPIngresses: %w\", err)\n\t}\n\n\tvar endpoints []*endpoint.Endpoint\n\tfor _, tcpIngress := range tcpIngresses {\n\t\ttargets := annotations.TargetsFromTargetAnnotation(tcpIngress.Annotations)\n\t\tif len(targets) == 0 {\n\t\t\tfor _, lb := range tcpIngress.Status.LoadBalancer.Ingress {\n\t\t\t\tif lb.IP != \"\" {\n\t\t\t\t\ttargets = append(targets, lb.IP)\n\t\t\t\t}\n\t\t\t\tif lb.Hostname != \"\" {\n\t\t\t\t\ttargets = append(targets, lb.Hostname)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tfullname := fmt.Sprintf(\"%s/%s\", tcpIngress.Namespace, tcpIngress.Name)\n\n\t\tingressEndpoints := sc.endpointsFromTCPIngress(tcpIngress, targets)\n\t\tif endpoint.HasNoEmptyEndpoints(ingressEndpoints, types.KongTCPIngress, tcpIngress) {\n\t\t\tcontinue\n\t\t}\n\n\t\tlog.Debugf(\"Endpoints generated from TCPIngress: %s: %v\", fullname, ingressEndpoints)\n\t\tendpoints = append(endpoints, ingressEndpoints...)\n\t}\n\n\treturn MergeEndpoints(endpoints), nil\n}\n\n// endpointsFromTCPIngress extracts the endpoints from a TCPIngress object\nfunc (sc *kongTCPIngressSource) endpointsFromTCPIngress(tcpIngress *TCPIngress, targets endpoint.Targets) []*endpoint.Endpoint {\n\tvar endpoints []*endpoint.Endpoint\n\n\tresource := fmt.Sprintf(\"tcpingress/%s/%s\", tcpIngress.Namespace, tcpIngress.Name)\n\n\tttl := annotations.TTLFromAnnotations(tcpIngress.Annotations, resource)\n\n\tproviderSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(tcpIngress.Annotations)\n\n\tif !sc.ignoreHostnameAnnotation {\n\t\thostnameList := annotations.HostnamesFromAnnotations(tcpIngress.Annotations)\n\t\tfor _, hostname := range hostnameList {\n\t\t\tendpoints = append(endpoints, endpoint.EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...)\n\t\t}\n\t}\n\n\tif tcpIngress.Spec.Rules != nil {\n\t\tfor _, rule := range tcpIngress.Spec.Rules {\n\t\t\tif rule.Host != \"\" {\n\t\t\t\tendpoints = append(endpoints, endpoint.EndpointsForHostname(rule.Host, targets, ttl, providerSpecific, setIdentifier, resource)...)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn endpoints\n}\n\nfunc (sc *kongTCPIngressSource) AddEventHandler(_ context.Context, handler func()) {\n\tlog.Debug(\"Adding event handler for TCPIngress\")\n\n\t// Right now there is no way to remove event handler from informer, see:\n\t// https://github.com/kubernetes/kubernetes/issues/79610\n\t_, _ = sc.kongTCPIngressInformer.Informer().AddEventHandler(eventHandlerFunc(handler))\n}\n\n// newUnstructuredConverter returns a new unstructuredConverter initialized\nfunc newKongUnstructuredConverter() (*unstructuredConverter, error) {\n\tuc := &unstructuredConverter{\n\t\tscheme: runtime.NewScheme(),\n\t}\n\n\t// Add the core types we need\n\tuc.scheme.AddKnownTypes(kongGroupdVersionResource.GroupVersion(), &TCPIngress{}, &TCPIngressList{})\n\tif err := scheme.AddToScheme(uc.scheme); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn uc, nil\n}\n\n// Kong types based on https://github.com/Kong/kubernetes-ingress-controller/blob/v1.2.0/pkg/apis/configuration/v1beta1/types.go to facilitate testing\n// When trying to import them from the Kong repo as a dependency it required upgrading the k8s.io/client-go and k8s.io/apimachinery which seemed\n// cause several changes in how the mock clients were working that resulted in a bunch of failures in other tests\n// If that is dealt with at some point the below can be removed and replaced with an actual import\ntype TCPIngress struct {\n\tmetav1.TypeMeta   `json:\",inline\"`\n\tmetav1.ObjectMeta `json:\"metadata\"`\n\n\tSpec   tcpIngressSpec   `json:\"spec\"`\n\tStatus tcpIngressStatus `json:\"status\"`\n}\n\ntype TCPIngressList struct {\n\tmetav1.TypeMeta `json:\",inline\"`\n\tmetav1.ListMeta `json:\"metadata\"`\n\tItems           []TCPIngress `json:\"items\"`\n}\n\ntype tcpIngressSpec struct {\n\tRules []tcpIngressRule `json:\"rules,omitempty\"`\n\tTLS   []tcpIngressTLS  `json:\"tls,omitempty\"`\n}\n\ntype tcpIngressTLS struct {\n\tHosts      []string `json:\"hosts,omitempty\"`\n\tSecretName string   `json:\"secretName,omitempty\"`\n}\n\ntype tcpIngressStatus struct {\n\tLoadBalancer corev1.LoadBalancerStatus `json:\"loadBalancer\"`\n}\n\ntype tcpIngressRule struct {\n\tHost    string            `json:\"host,omitempty\"`\n\tPort    int               `json:\"port,omitempty\"`\n\tBackend tcpIngressBackend `json:\"backend\"`\n}\n\ntype tcpIngressBackend struct {\n\tServiceName string `json:\"serviceName\"`\n\tServicePort int    `json:\"servicePort\"`\n}\n\nfunc (in *tcpIngressBackend) DeepCopyInto(out *tcpIngressBackend) {\n\t*out = *in\n}\n\nfunc (in *tcpIngressBackend) DeepCopy() *tcpIngressBackend {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(tcpIngressBackend)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\nfunc (in *tcpIngressRule) DeepCopyInto(out *tcpIngressRule) {\n\t*out = *in\n\tout.Backend = in.Backend\n}\n\nfunc (in *tcpIngressRule) DeepCopy() *tcpIngressRule {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(tcpIngressRule)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\nfunc (in *tcpIngressSpec) DeepCopyInto(out *tcpIngressSpec) {\n\t*out = *in\n\tif in.Rules != nil {\n\t\tin, out := &in.Rules, &out.Rules\n\t\t*out = make([]tcpIngressRule, len(*in))\n\t\tcopy(*out, *in)\n\t}\n\tif in.TLS != nil {\n\t\tin, out := &in.TLS, &out.TLS\n\t\t*out = make([]tcpIngressTLS, len(*in))\n\t\tfor i := range *in {\n\t\t\t(*in)[i].DeepCopyInto(&(*out)[i])\n\t\t}\n\t}\n}\n\nfunc (in *tcpIngressSpec) DeepCopy() *tcpIngressSpec {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(tcpIngressSpec)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\nfunc (in *tcpIngressStatus) DeepCopyInto(out *tcpIngressStatus) {\n\t*out = *in\n\tin.LoadBalancer.DeepCopyInto(&out.LoadBalancer)\n}\n\nfunc (in *tcpIngressStatus) DeepCopy() *tcpIngressStatus {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(tcpIngressStatus)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\nfunc (in *tcpIngressTLS) DeepCopyInto(out *tcpIngressTLS) {\n\t*out = *in\n\tif in.Hosts != nil {\n\t\tin, out := &in.Hosts, &out.Hosts\n\t\t*out = make([]string, len(*in))\n\t\tcopy(*out, *in)\n\t}\n}\n\nfunc (in *tcpIngressTLS) DeepCopy() *tcpIngressTLS {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(tcpIngressTLS)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\nfunc (in *TCPIngress) DeepCopyInto(out *TCPIngress) {\n\t*out = *in\n\tout.TypeMeta = in.TypeMeta\n\tin.ObjectMeta.DeepCopyInto(&out.ObjectMeta)\n\tin.Spec.DeepCopyInto(&out.Spec)\n\tin.Status.DeepCopyInto(&out.Status)\n}\n\nfunc (in *TCPIngress) DeepCopy() *TCPIngress {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(TCPIngress)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\nfunc (in *TCPIngress) DeepCopyObject() runtime.Object {\n\tif c := in.DeepCopy(); c != nil {\n\t\treturn c\n\t}\n\treturn nil\n}\n\nfunc (in *TCPIngressList) DeepCopyInto(out *TCPIngressList) {\n\t*out = *in\n\tout.TypeMeta = in.TypeMeta\n\tin.ListMeta.DeepCopyInto(&out.ListMeta)\n\tif in.Items != nil {\n\t\tin, out := &in.Items, &out.Items\n\t\t*out = make([]TCPIngress, len(*in))\n\t\tfor i := range *in {\n\t\t\t(*in)[i].DeepCopyInto(&(*out)[i])\n\t\t}\n\t}\n}\n\nfunc (in *TCPIngressList) DeepCopy() *TCPIngressList {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(TCPIngressList)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\nfunc (in *TCPIngressList) DeepCopyObject() runtime.Object {\n\tif c := in.DeepCopy(); c != nil {\n\t\treturn c\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "source/kong_tcpingress_test.go",
    "content": "/*\nCopyright 2021 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\tcorev1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured\"\n\t\"k8s.io/apimachinery/pkg/runtime\"\n\tfakeDynamic \"k8s.io/client-go/dynamic/fake\"\n\tfakeKube \"k8s.io/client-go/kubernetes/fake\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n)\n\n// This is a compile-time validation that kongTCPIngressSource is a Source.\nvar _ Source = &kongTCPIngressSource{}\n\nconst defaultKongNamespace = \"kong\"\n\nfunc TestKongTCPIngressEndpoints(t *testing.T) {\n\tt.Parallel()\n\n\tfor _, ti := range []struct {\n\t\ttitle                    string\n\t\ttcpProxy                 TCPIngress\n\t\tignoreHostnameAnnotation bool\n\t\texpected                 []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\ttitle: \"TCPIngress with hostname annotation\",\n\t\t\ttcpProxy: TCPIngress{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: kongGroupdVersionResource.GroupVersion().String(),\n\t\t\t\t\tKind:       \"TCPIngress\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"tcp-ingress-annotation\",\n\t\t\t\t\tNamespace: defaultKongNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"a.example.com\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":               \"kong\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: tcpIngressSpec{\n\t\t\t\t\tRules: []tcpIngressRule{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tPort: 30000,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tPort: 30001,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: tcpIngressStatus{\n\t\t\t\t\tLoadBalancer: corev1.LoadBalancerStatus{\n\t\t\t\t\t\tIngress: []corev1.LoadBalancerIngress{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tHostname: \"a691234567a314e71861a4303f06a3bd-1291189659.us-east-1.elb.amazonaws.com\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"a.example.com\",\n\t\t\t\t\tTargets:    []string{\"a691234567a314e71861a4303f06a3bd-1291189659.us-east-1.elb.amazonaws.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"tcpingress/kong/tcp-ingress-annotation\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"TCPIngress using SNI\",\n\t\t\ttcpProxy: TCPIngress{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: kongGroupdVersionResource.GroupVersion().String(),\n\t\t\t\t\tKind:       \"TCPIngress\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"tcp-ingress-sni\",\n\t\t\t\t\tNamespace: defaultKongNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\": \"kong\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: tcpIngressSpec{\n\t\t\t\t\tRules: []tcpIngressRule{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tPort: 30002,\n\t\t\t\t\t\t\tHost: \"b.example.com\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tPort: 30003,\n\t\t\t\t\t\t\tHost: \"c.example.com\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: tcpIngressStatus{\n\t\t\t\t\tLoadBalancer: corev1.LoadBalancerStatus{\n\t\t\t\t\t\tIngress: []corev1.LoadBalancerIngress{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tHostname: \"a123456769a314e71861a4303f06a3bd-1291189659.us-east-1.elb.amazonaws.com\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"b.example.com\",\n\t\t\t\t\tTargets:    []string{\"a123456769a314e71861a4303f06a3bd-1291189659.us-east-1.elb.amazonaws.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"tcpingress/kong/tcp-ingress-sni\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"c.example.com\",\n\t\t\t\t\tTargets:    []string{\"a123456769a314e71861a4303f06a3bd-1291189659.us-east-1.elb.amazonaws.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"tcpingress/kong/tcp-ingress-sni\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"TCPIngress with hostname annotation and using SNI\",\n\t\t\ttcpProxy: TCPIngress{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: kongGroupdVersionResource.GroupVersion().String(),\n\t\t\t\t\tKind:       \"TCPIngress\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"tcp-ingress-both\",\n\t\t\t\t\tNamespace: defaultKongNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"d.example.com\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":               \"kong\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: tcpIngressSpec{\n\t\t\t\t\tRules: []tcpIngressRule{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tPort: 30004,\n\t\t\t\t\t\t\tHost: \"e.example.com\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tPort: 30005,\n\t\t\t\t\t\t\tHost: \"f.example.com\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: tcpIngressStatus{\n\t\t\t\t\tLoadBalancer: corev1.LoadBalancerStatus{\n\t\t\t\t\t\tIngress: []corev1.LoadBalancerIngress{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tHostname: \"a12e71861a4303f063456769a314a3bd-1291189659.us-east-1.elb.amazonaws.com\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"d.example.com\",\n\t\t\t\t\tTargets:    []string{\"a12e71861a4303f063456769a314a3bd-1291189659.us-east-1.elb.amazonaws.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"tcpingress/kong/tcp-ingress-both\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"e.example.com\",\n\t\t\t\t\tTargets:    []string{\"a12e71861a4303f063456769a314a3bd-1291189659.us-east-1.elb.amazonaws.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"tcpingress/kong/tcp-ingress-both\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"f.example.com\",\n\t\t\t\t\tTargets:    []string{\"a12e71861a4303f063456769a314a3bd-1291189659.us-east-1.elb.amazonaws.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"tcpingress/kong/tcp-ingress-both\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"TCPIngress ignoring hostname annotation\",\n\t\t\ttcpProxy: TCPIngress{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: kongGroupdVersionResource.GroupVersion().String(),\n\t\t\t\t\tKind:       \"TCPIngress\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"tcp-ingress-both\",\n\t\t\t\t\tNamespace: defaultKongNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"d.example.com\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":               \"kong\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: tcpIngressSpec{\n\t\t\t\t\tRules: []tcpIngressRule{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tPort: 30004,\n\t\t\t\t\t\t\tHost: \"e.example.com\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tPort: 30005,\n\t\t\t\t\t\t\tHost: \"f.example.com\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: tcpIngressStatus{\n\t\t\t\t\tLoadBalancer: corev1.LoadBalancerStatus{\n\t\t\t\t\t\tIngress: []corev1.LoadBalancerIngress{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tHostname: \"a12e71861a4303f063456769a314a3bd-1291189659.us-east-1.elb.amazonaws.com\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tignoreHostnameAnnotation: true,\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"e.example.com\",\n\t\t\t\t\tTargets:    []string{\"a12e71861a4303f063456769a314a3bd-1291189659.us-east-1.elb.amazonaws.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"tcpingress/kong/tcp-ingress-both\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"f.example.com\",\n\t\t\t\t\tTargets:    []string{\"a12e71861a4303f063456769a314a3bd-1291189659.us-east-1.elb.amazonaws.com\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"tcpingress/kong/tcp-ingress-both\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"TCPIngress with target annotation\",\n\t\t\ttcpProxy: TCPIngress{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: kongGroupdVersionResource.GroupVersion().String(),\n\t\t\t\t\tKind:       \"TCPIngress\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"tcp-ingress-sni\",\n\t\t\t\t\tNamespace: defaultKongNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":             \"kong\",\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\": \"203.2.45.7\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: tcpIngressSpec{\n\t\t\t\t\tRules: []tcpIngressRule{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tPort: 30002,\n\t\t\t\t\t\t\tHost: \"b.example.com\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tPort: 30003,\n\t\t\t\t\t\t\tHost: \"c.example.com\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: tcpIngressStatus{\n\t\t\t\t\tLoadBalancer: corev1.LoadBalancerStatus{\n\t\t\t\t\t\tIngress: []corev1.LoadBalancerIngress{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tHostname: \"a123456769a314e71861a4303f06a3bd-1291189659.us-east-1.elb.amazonaws.com\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"b.example.com\",\n\t\t\t\t\tTargets:    []string{\"203.2.45.7\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"tcpingress/kong/tcp-ingress-sni\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"c.example.com\",\n\t\t\t\t\tTargets:    []string{\"203.2.45.7\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"tcpingress/kong/tcp-ingress-sni\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"TCPIngress with provider-specific annotation\",\n\t\t\ttcpProxy: TCPIngress{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: kongGroupdVersionResource.GroupVersion().String(),\n\t\t\t\t\tKind:       \"TCPIngress\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"tcp-ingress-provider-specific\",\n\t\t\t\t\tNamespace: defaultKongNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"a.example.com\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":               \"kong\",\n\t\t\t\t\t\tannotations.AWSPrefix + \"weight\":            \"10\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: tcpIngressStatus{\n\t\t\t\t\tLoadBalancer: corev1.LoadBalancerStatus{\n\t\t\t\t\t\tIngress: []corev1.LoadBalancerIngress{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tIP: \"1.2.3.4\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"a.example.com\",\n\t\t\t\t\tTargets:    []string{\"1.2.3.4\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"tcpingress/kong/tcp-ingress-provider-specific\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{Name: \"aws/weight\", Value: \"10\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t} {\n\n\t\tt.Run(ti.title, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\tfakeKubernetesClient := fakeKube.NewSimpleClientset()\n\t\t\tscheme := runtime.NewScheme()\n\t\t\tscheme.AddKnownTypes(kongGroupdVersionResource.GroupVersion(), &TCPIngress{}, &TCPIngressList{})\n\t\t\tfakeDynamicClient := fakeDynamic.NewSimpleDynamicClient(scheme)\n\n\t\t\ttcpi := unstructured.Unstructured{}\n\n\t\t\ttcpIngressAsJSON, err := json.Marshal(ti.tcpProxy)\n\t\t\tassert.NoError(t, err)\n\n\t\t\tassert.NoError(t, tcpi.UnmarshalJSON(tcpIngressAsJSON))\n\n\t\t\t// Create proxy resources\n\t\t\t_, err = fakeDynamicClient.Resource(kongGroupdVersionResource).Namespace(defaultKongNamespace).Create(t.Context(), &tcpi, metav1.CreateOptions{})\n\t\t\tassert.NoError(t, err)\n\n\t\t\tsource, err := NewKongTCPIngressSource(t.Context(), fakeDynamicClient, fakeKubernetesClient,\n\t\t\t\t&Config{\n\t\t\t\t\tNamespace:                defaultKongNamespace,\n\t\t\t\t\tAnnotationFilter:         \"kubernetes.io/ingress.class=kong\",\n\t\t\t\t\tIgnoreHostnameAnnotation: ti.ignoreHostnameAnnotation,\n\t\t\t\t})\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.NotNil(t, source)\n\n\t\t\tcount := &unstructured.UnstructuredList{}\n\t\t\tfor len(count.Items) < 1 {\n\t\t\t\tcount, _ = fakeDynamicClient.Resource(kongGroupdVersionResource).Namespace(defaultKongNamespace).List(t.Context(), metav1.ListOptions{})\n\t\t\t}\n\n\t\t\tendpoints, err := source.Endpoints(t.Context())\n\t\t\tassert.NoError(t, err)\n\t\t\tvalidateEndpoints(t, endpoints, ti.expected)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "source/main_test.go",
    "content": "/*\nCopyright 2026 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"os\"\n\t\"testing\"\n\n\t\"k8s.io/client-go/features\"\n)\n\nfunc TestMain(m *testing.M) {\n\t// Disable WatchListClient to prevent 10s timeouts when using fake clients.\n\t// Since client-go v0.35, WatchListClient is enabled by default, but fake\n\t// clients don't emit the required bookmark events, causing reflectors to\n\t// stall for 10 seconds before falling back to the legacy list/watch path.\n\t// Only disable if it is actually on; pre-v0.35 client-go defaults it to\n\t// false so this is a no-op there, but makes the intent explicit.\n\tif features.FeatureGates().Enabled(features.WatchListClient) {\n\t\ttype featureGatesSetter interface {\n\t\t\tfeatures.Gates\n\t\t\tSet(features.Feature, bool) error\n\t\t}\n\t\tif gates, ok := features.FeatureGates().(featureGatesSetter); ok {\n\t\t\t_ = gates.Set(features.WatchListClient, false)\n\t\t}\n\t}\n\tos.Exit(m.Run())\n}\n"
  },
  {
    "path": "source/node.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"text/template\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\tv1 \"k8s.io/api/core/v1\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\tkubeinformers \"k8s.io/client-go/informers\"\n\tcoreinformers \"k8s.io/client-go/informers/core/v1\"\n\t\"k8s.io/client-go/kubernetes\"\n\n\t\"sigs.k8s.io/external-dns/source/types\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/pkg/events\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n\t\"sigs.k8s.io/external-dns/source/fqdn\"\n\t\"sigs.k8s.io/external-dns/source/informers\"\n)\n\n// nodeSource is an implementation of Source for Kubernetes Node objects.\n//\n// +externaldns:source:name=node\n// +externaldns:source:category=Kubernetes Core\n// +externaldns:source:description=Creates DNS entries based on Kubernetes Node resources\n// +externaldns:source:resources=Node\n// +externaldns:source:filters=annotation,label\n// +externaldns:source:namespace=all\n// +externaldns:source:fqdn-template=true\n// +externaldns:source:provider-specific=false\n// +externaldns:source:events=true\ntype nodeSource struct {\n\tclient                kubernetes.Interface\n\tannotationFilter      string\n\tfqdnTemplate          *template.Template\n\tcombineFQDNAnnotation bool\n\n\tnodeInformer         coreinformers.NodeInformer\n\tlabelSelector        labels.Selector\n\texcludeUnschedulable bool\n\texposeInternalIPv6   bool\n}\n\n// NewNodeSource creates a new nodeSource with the given config.\nfunc NewNodeSource(\n\tctx context.Context,\n\tkubeClient kubernetes.Interface,\n\tcfg *Config) (Source, error) {\n\ttmpl, err := fqdn.ParseTemplate(cfg.FQDNTemplate)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Use shared informers to listen for add/update/delete of nodes.\n\t// Set resync period to 0, to prevent processing when nothing has changed\n\tinformerFactory := kubeinformers.NewSharedInformerFactoryWithOptions(kubeClient, 0)\n\tnodeInformer := informerFactory.Core().V1().Nodes()\n\n\t// Add default resource event handler to properly initialize informer.\n\t_, _ = nodeInformer.Informer().AddEventHandler(informers.DefaultEventHandler())\n\n\tinformerFactory.Start(ctx.Done())\n\n\t// wait for the local cache to be populated.\n\tif err := informers.WaitForCacheSync(ctx, informerFactory); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &nodeSource{\n\t\tclient:                kubeClient,\n\t\tannotationFilter:      cfg.AnnotationFilter,\n\t\tfqdnTemplate:          tmpl,\n\t\tcombineFQDNAnnotation: cfg.CombineFQDNAndAnnotation,\n\t\tnodeInformer:          nodeInformer,\n\t\tlabelSelector:         cfg.LabelFilter,\n\t\texcludeUnschedulable:  cfg.ExcludeUnschedulable,\n\t\texposeInternalIPv6:    cfg.ExposeInternalIPv6,\n\t}, nil\n}\n\n// Endpoints returns endpoint objects for each service that should be processed.\nfunc (ns *nodeSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) {\n\tnodes, err := ns.nodeInformer.Lister().List(ns.labelSelector)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tnodes, err = annotations.Filter(nodes, ns.annotationFilter)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tendpoints := make([]*endpoint.Endpoint, 0)\n\n\t// create endpoints for all nodes\n\tfor _, node := range nodes {\n\t\tif annotations.IsControllerMismatch(node, types.Node) {\n\t\t\tcontinue\n\t\t}\n\n\t\tif node.Spec.Unschedulable && ns.excludeUnschedulable {\n\t\t\tlog.Debugf(\"Skipping node %s because it is unschedulable\", node.Name)\n\t\t\tcontinue\n\t\t}\n\n\t\tlog.Debugf(\"creating endpoint for node %s\", node.Name)\n\n\t\t// Only generate node name endpoints when there's no template or when combining\n\t\tvar nodeEndpoints []*endpoint.Endpoint\n\t\tif ns.fqdnTemplate == nil || ns.combineFQDNAnnotation {\n\t\t\tnodeEndpoints, err = ns.endpointsForDNSNames(node, []string{node.Name})\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\n\t\tnodeEndpoints, err = fqdn.CombineWithTemplatedEndpoints(\n\t\t\tnodeEndpoints,\n\t\t\tns.fqdnTemplate,\n\t\t\tns.combineFQDNAnnotation,\n\t\t\tfunc() ([]*endpoint.Endpoint, error) { return ns.endpointsFromNodeTemplate(node) },\n\t\t)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif len(nodeEndpoints) == 0 {\n\t\t\tlog.Debugf(\"No endpoints could be generated from node %s\", node.Name)\n\t\t\tcontinue\n\t\t}\n\n\t\tendpoint.AttachRefObject(nodeEndpoints, events.NewObjectReference(node, types.Node))\n\n\t\tendpoints = append(endpoints, nodeEndpoints...)\n\t}\n\n\treturn MergeEndpoints(endpoints), nil\n}\n\nfunc (ns *nodeSource) AddEventHandler(_ context.Context, handler func()) {\n\t_, _ = ns.nodeInformer.Informer().AddEventHandler(eventHandlerFunc(handler))\n}\n\n// endpointsFromNodeTemplate creates endpoints using DNS names from the FQDN template.\nfunc (ns *nodeSource) endpointsFromNodeTemplate(node *v1.Node) ([]*endpoint.Endpoint, error) {\n\tnames, err := fqdn.ExecTemplate(ns.fqdnTemplate, node)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor _, name := range names {\n\t\tlog.Debugf(\"applied template for %s, converting to %s\", node.Name, name)\n\t}\n\n\treturn ns.endpointsForDNSNames(node, names)\n}\n\n// endpointsForDNSNames creates endpoints for the given DNS names using the node's addresses.\nfunc (ns *nodeSource) endpointsForDNSNames(node *v1.Node, dnsNames []string) ([]*endpoint.Endpoint, error) {\n\tttl := annotations.TTLFromAnnotations(node.Annotations, fmt.Sprintf(\"node/%s\", node.Name))\n\n\taddrs := annotations.TargetsFromTargetAnnotation(node.Annotations)\n\tif len(addrs) == 0 {\n\t\tvar err error\n\t\taddrs, err = ns.nodeAddresses(node)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to get node address from %s: %w\", node.Name, err)\n\t\t}\n\t}\n\n\tvar endpoints []*endpoint.Endpoint\n\tfor _, dns := range dnsNames {\n\t\tlog.Debugf(\"adding endpoint with %d targets\", len(addrs))\n\n\t\tfor _, addr := range addrs {\n\t\t\tep := endpoint.NewEndpointWithTTL(dns, endpoint.SuitableType(addr), ttl, addr)\n\t\t\tep.WithLabel(endpoint.ResourceLabelKey, fmt.Sprintf(\"node/%s\", node.Name))\n\t\t\tlog.Debugf(\"adding endpoint %s target %s\", ep, addr)\n\t\t\tendpoints = append(endpoints, ep)\n\t\t}\n\t}\n\n\treturn MergeEndpoints(endpoints), nil\n}\n\n// nodeAddress returns the node's externalIP and if that's not found, the node's internalIP\n// basically what k8s.io/kubernetes/pkg/util/node.GetPreferredNodeAddress does\nfunc (ns *nodeSource) nodeAddresses(node *v1.Node) ([]string, error) {\n\taddresses := map[v1.NodeAddressType][]string{\n\t\tv1.NodeExternalIP: {},\n\t\tv1.NodeInternalIP: {},\n\t}\n\tvar internalIpv6Addresses []string\n\n\tfor _, addr := range node.Status.Addresses {\n\t\t// IPv6 InternalIP addresses have special handling.\n\t\t// Refer to https://github.com/kubernetes-sigs/external-dns/pull/5192 for more details.\n\t\tif addr.Type == v1.NodeInternalIP && endpoint.SuitableType(addr.Address) == endpoint.RecordTypeAAAA {\n\t\t\tinternalIpv6Addresses = append(internalIpv6Addresses, addr.Address)\n\t\t}\n\t\taddresses[addr.Type] = append(addresses[addr.Type], addr.Address)\n\t}\n\n\tif len(addresses[v1.NodeExternalIP]) > 0 {\n\t\tif ns.exposeInternalIPv6 {\n\t\t\treturn append(addresses[v1.NodeExternalIP], internalIpv6Addresses...), nil\n\t\t}\n\t\treturn addresses[v1.NodeExternalIP], nil\n\t}\n\n\tif len(addresses[v1.NodeInternalIP]) > 0 {\n\t\treturn addresses[v1.NodeInternalIP], nil\n\t}\n\n\treturn nil, fmt.Errorf(\"could not find node address for %s\", node.Name)\n}\n"
  },
  {
    "path": "source/node_fqdn_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\tv1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\t\"k8s.io/client-go/kubernetes/fake\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\nfunc TestNodeSourceNewNodeSourceWithFqdn(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\ttitle            string\n\t\tannotationFilter string\n\t\tfqdnTemplate     string\n\t\texpectError      bool\n\t}{\n\t\t{\n\t\t\ttitle:        \"invalid template\",\n\t\t\texpectError:  true,\n\t\t\tfqdnTemplate: \"{{.Name\",\n\t\t},\n\t\t{\n\t\t\ttitle:       \"valid empty template\",\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\ttitle:        \"valid template\",\n\t\t\texpectError:  false,\n\t\t\tfqdnTemplate: \"{{.Name}}-{{.Namespace}}.ext-dns.test.com\",\n\t\t},\n\t\t{\n\t\t\ttitle:        \"complex template\",\n\t\t\texpectError:  false,\n\t\t\tfqdnTemplate: \"{{range .Status.Addresses}}{{if and (eq .Type \\\"ExternalIP\\\") (isIPv4 .Address)}}{{.Address | replace \\\".\\\" \\\"-\\\"}}{{break}}{{end}}{{end}}.ext-dns.test.com\",\n\t\t},\n\t} {\n\t\tt.Run(tt.title, func(t *testing.T) {\n\t\t\t_, err := NewNodeSource(\n\t\t\t\tt.Context(),\n\t\t\t\tfake.NewClientset(),\n\t\t\t\t&Config{\n\t\t\t\t\tAnnotationFilter:         tt.annotationFilter,\n\t\t\t\t\tFQDNTemplate:             tt.fqdnTemplate,\n\t\t\t\t\tCombineFQDNAndAnnotation: false,\n\t\t\t\t\tExcludeUnschedulable:     true,\n\t\t\t\t\tExposeInternalIPv6:       true,\n\t\t\t\t\tLabelFilter:              labels.Everything(),\n\t\t\t\t},\n\t\t\t)\n\t\t\tif tt.expectError {\n\t\t\t\tassert.Error(t, err)\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestNodeSourceFqdnTemplatingExamples(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\ttitle        string\n\t\tnodes        []*v1.Node\n\t\tfqdnTemplate string\n\t\texpected     []*endpoint.Endpoint\n\t\tcombineFQDN  bool\n\t}{\n\t\t{\n\t\t\ttitle: \"templating expansion with multiple domains\",\n\t\t\tnodes: []*v1.Node{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName: \"ip-10-1-176-5.internal\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"10.1.176.1\"},\n\t\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"fc00:f853:ccd:e793::1\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{.Name}}.domainA.com,{{.Name}}.domainB.com\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"ip-10-1-176-5.internal.domainA.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.1.176.1\"}},\n\t\t\t\t{DNSName: \"ip-10-1-176-5.internal.domainA.com\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"fc00:f853:ccd:e793::1\"}},\n\t\t\t\t{DNSName: \"ip-10-1-176-5.internal.domainB.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.1.176.1\"}},\n\t\t\t\t{DNSName: \"ip-10-1-176-5.internal.domainB.com\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"fc00:f853:ccd:e793::1\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"templating contains namespace when node namespace is not a valid variable\",\n\t\t\tnodes: []*v1.Node{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName: \"node-name\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"10.1.176.1\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{.Name}}.domainA.com,{{ .Name }}.{{ .Namespace }}.example.tld\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"node-name.domainA.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.1.176.1\"}},\n\t\t\t\t{DNSName: \"node-name..example.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.1.176.1\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"templating with external IP and range of addresses\",\n\t\t\tnodes: []*v1.Node{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName: \"ip-10-1-176-1\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"243.186.136.160\"},\n\t\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"fc00:f853:ccd:e793::1\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{ range .Status.Addresses }}{{if and (eq .Type \\\"ExternalIP\\\") (isIPv4 .Address)}}ip-{{ .Address | replace \\\".\\\" \\\"-\\\" }}{{ break }}{{ end }}{{ end }}.example.com\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"ip-243-186-136-160.example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"243.186.136.160\"}},\n\t\t\t\t{DNSName: \"ip-243-186-136-160.example.com\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"fc00:f853:ccd:e793::1\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"templating with name definition and ipv4 check\",\n\t\t\tnodes: []*v1.Node{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName: \"node-name-ip\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"243.186.136.160\"},\n\t\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"fc00:f853:ccd:e793::1\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{ $name := .Name }}{{ range .Status.Addresses }}{{if (isIPv4 .Address)}}{{ $name }}.ipv4{{ break }}{{ end }}{{ end }}.example.com\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"node-name-ip.ipv4.example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"243.186.136.160\"}},\n\t\t\t\t{DNSName: \"node-name-ip.ipv4.example.com\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"fc00:f853:ccd:e793::1\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"templating with hostname annotation\",\n\t\t\tnodes: []*v1.Node{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName: \"ip-10-1-176-1\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"ip-10-1-176-1.internal.domain.com\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"243.186.136.160\"},\n\t\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"fc00:f853:ccd:e793::1\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{.Name}}.example.com\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"ip-10-1-176-1.example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"243.186.136.160\"}},\n\t\t\t\t{DNSName: \"ip-10-1-176-1.example.com\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"fc00:f853:ccd:e793::1\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"templating when target annotation and no external IP\",\n\t\t\tnodes: []*v1.Node{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:   \"node-name\",\n\t\t\t\t\t\tLabels: nil,\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\": \"203.2.45.22\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"243.186.136.160\"},\n\t\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"fc00:f853:ccd:e793::1\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{.Name}}.example.com\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"node-name.example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"203.2.45.22\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"templating with simple annotation expansion\",\n\t\t\tnodes: []*v1.Node{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName: \"node-name\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\t\"workload\": \"cluster-resources\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"243.186.136.160\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{ .Name }}.{{ .Annotations.workload }}.domain.tld\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"node-name.cluster-resources.domain.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"243.186.136.160\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"templating with complex labels expansion\",\n\t\t\tnodes: []*v1.Node{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName: \"node-name\",\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\t\"topology.kubernetes.io/region\": \"eu-west-1\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\tAnnotations: nil,\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.NodeSpec{\n\t\t\t\t\t\tUnschedulable: false,\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"243.186.136.160\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{ .Name }}.{{ index .ObjectMeta.Labels \\\"topology.kubernetes.io/region\\\" }}.domain.tld\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"node-name.eu-west-1.domain.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"243.186.136.160\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"templating with shared all domain\",\n\t\t\tnodes: []*v1.Node{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName: \"node-name-1\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"243.186.136.160\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName: \"node-name-2\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"243.186.136.178\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{ .Name }}.domain.tld,all.example.com\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"all.example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"243.186.136.160\", \"243.186.136.178\"}},\n\t\t\t\t{DNSName: \"node-name-1.domain.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"243.186.136.160\"}},\n\t\t\t\t{DNSName: \"node-name-2.domain.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"243.186.136.178\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:       \"templating with shared all domain and fqdn combination annotation\",\n\t\t\tcombineFQDN: true,\n\t\t\tnodes: []*v1.Node{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"node-name-1\"},\n\t\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\t\tAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: \"243.186.136.160\"}},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"node-name-2\"},\n\t\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\t\tAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: \"243.186.136.178\"}},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{ .Name }}.domain.tld,all.example.com\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"all.example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"243.186.136.160\", \"243.186.136.178\"}},\n\t\t\t\t{DNSName: \"node-name-1.domain.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"243.186.136.160\"}},\n\t\t\t\t{DNSName: \"node-name-2.domain.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"243.186.136.178\"}},\n\t\t\t\t{DNSName: \"node-name-1\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"243.186.136.160\"}},\n\t\t\t\t{DNSName: \"node-name-2\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"243.186.136.178\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"templating with kind-based FQDNs\",\n\t\t\tfqdnTemplate: `{{ if eq .Kind \"Pod\" }}{{.Name}}.pod.tld{{ end }}\n\t\t\t\t{{ if eq .Kind \"Node\" }}{{.Name}}.{{.Status.NodeInfo.Architecture}}.node.tld{{ end }}`,\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"node-name-1.arm64.node.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.0.0.1\"}},\n\t\t\t\t{DNSName: \"node-name-2.x86_64.node.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.0.0.2\"}},\n\t\t\t},\n\t\t\tcombineFQDN: false,\n\t\t\tnodes: []*v1.Node{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"node-name-1\"},\n\t\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\t\tAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: \"10.0.0.1\"}},\n\t\t\t\t\t\tNodeInfo: v1.NodeSystemInfo{\n\t\t\t\t\t\t\tArchitecture: \"arm64\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.NodeSpec{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"node-name-2\"},\n\t\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\t\tAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: \"10.0.0.2\"}},\n\t\t\t\t\t\tNodeInfo: v1.NodeSystemInfo{\n\t\t\t\t\t\t\tArchitecture: \"x86_64\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(tt.title, func(t *testing.T) {\n\t\t\tkubeClient := fake.NewClientset()\n\n\t\t\tfor _, node := range tt.nodes {\n\t\t\t\t_, err := kubeClient.CoreV1().Nodes().Create(t.Context(), node, metav1.CreateOptions{})\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\tsrc, err := NewNodeSource(\n\t\t\t\tt.Context(),\n\t\t\t\tkubeClient,\n\t\t\t\t&Config{\n\t\t\t\t\tFQDNTemplate:             tt.fqdnTemplate,\n\t\t\t\t\tExcludeUnschedulable:     true,\n\t\t\t\t\tExposeInternalIPv6:       true,\n\t\t\t\t\tCombineFQDNAndAnnotation: tt.combineFQDN,\n\t\t\t\t\tLabelFilter:              labels.Everything(),\n\t\t\t\t},\n\t\t\t)\n\t\t\trequire.NoError(t, err)\n\n\t\t\tendpoints, err := src.Endpoints(t.Context())\n\t\t\trequire.NoError(t, err)\n\n\t\t\tvalidateEndpoints(t, endpoints, tt.expected)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "source/node_test.go",
    "content": "/*\nCopyright 2019 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"fmt\"\n\t\"maps\"\n\t\"math/rand\"\n\t\"testing\"\n\t\"time\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/mock\"\n\tcorev1lister \"k8s.io/client-go/listers/core/v1\"\n\t\"k8s.io/client-go/tools/cache\"\n\n\t\"sigs.k8s.io/external-dns/source/types\"\n\n\t\"sigs.k8s.io/external-dns/internal/testutils\"\n\tlogtest \"sigs.k8s.io/external-dns/internal/testutils/log\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\tv1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\t\"k8s.io/apimachinery/pkg/runtime\"\n\t\"k8s.io/client-go/kubernetes/fake\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\nfunc TestNodeSource(t *testing.T) {\n\tt.Parallel()\n\n\tt.Run(\"NewNodeSource\", testNodeSourceNewNodeSource)\n\tt.Run(\"Endpoints\", testNodeSourceEndpoints)\n\tt.Run(\"EndpointsIPv6\", testNodeEndpointsWithIPv6)\n}\n\n// testNodeSourceNewNodeSource tests that NewNodeService doesn't return an error.\nfunc testNodeSourceNewNodeSource(t *testing.T) {\n\tt.Parallel()\n\n\tfor _, ti := range []struct {\n\t\ttitle            string\n\t\tannotationFilter string\n\t\tfqdnTemplate     string\n\t\texpectError      bool\n\t}{\n\t\t{\n\t\t\ttitle:        \"invalid template\",\n\t\t\texpectError:  true,\n\t\t\tfqdnTemplate: \"{{.Name\",\n\t\t},\n\t\t{\n\t\t\ttitle:       \"valid empty template\",\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\ttitle:        \"valid template\",\n\t\t\texpectError:  false,\n\t\t\tfqdnTemplate: \"{{.Name}}-{{.Namespace}}.ext-dns.test.com\",\n\t\t},\n\t\t{\n\t\t\ttitle:        \"complex template\",\n\t\t\texpectError:  false,\n\t\t\tfqdnTemplate: \"{{range .Status.Addresses}}{{if and (eq .Type \\\"ExternalIP\\\") (isIPv4 .Address)}}{{.Address | replace \\\".\\\" \\\"-\\\"}}{{break}}{{end}}{{end}}.ext-dns.test.com\",\n\t\t},\n\t\t{\n\t\t\ttitle:            \"non-empty annotation filter label\",\n\t\t\texpectError:      false,\n\t\t\tannotationFilter: \"kubernetes.io/ingress.class=nginx\",\n\t\t},\n\t} {\n\n\t\tt.Run(ti.title, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\t_, err := NewNodeSource(\n\t\t\t\tt.Context(),\n\t\t\t\tfake.NewClientset(),\n\t\t\t\t&Config{\n\t\t\t\t\tAnnotationFilter:     ti.annotationFilter,\n\t\t\t\t\tFQDNTemplate:         ti.fqdnTemplate,\n\t\t\t\t\tLabelFilter:          labels.Everything(),\n\t\t\t\t\tExcludeUnschedulable: true,\n\t\t\t\t\tExposeInternalIPv6:   true,\n\t\t\t\t},\n\t\t\t)\n\n\t\t\tif ti.expectError {\n\t\t\t\tassert.Error(t, err)\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// testNodeSourceEndpoints tests that various node generate the correct endpoints.\nfunc testNodeSourceEndpoints(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\ttitle                string\n\t\tannotationFilter     string\n\t\tlabelSelector        string\n\t\tfqdnTemplate         string\n\t\tnodeName             string\n\t\tnodeAddresses        []v1.NodeAddress\n\t\tlabels               map[string]string\n\t\tannotations          map[string]string\n\t\texcludeUnschedulable bool // default to false\n\t\texposeInternalIPv6   bool // default to true for this version. Change later when the next minor version is released.\n\t\tunschedulable        bool // default to false\n\t\texpected             []*endpoint.Endpoint\n\t\texpectError          bool\n\t\texpectedLogs         []string\n\t\texpectedAbsentLogs   []string\n\t}{\n\t\t{\n\t\t\ttitle:              \"node with short hostname returns one endpoint\",\n\t\t\tnodeName:           \"node1\",\n\t\t\texposeInternalIPv6: true,\n\t\t\tnodeAddresses:      []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: \"1.2.3.4\"}},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{RecordType: \"A\", DNSName: \"node1\", Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:              \"node with fqdn returns one endpoint\",\n\t\t\tnodeName:           \"node1.example.org\",\n\t\t\texposeInternalIPv6: true,\n\t\t\tnodeAddresses:      []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: \"1.2.3.4\"}},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{RecordType: \"A\", DNSName: \"node1.example.org\", Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:              \"ipv6 node with fqdn returns one endpoint\",\n\t\t\tnodeName:           \"node1.example.org\",\n\t\t\texposeInternalIPv6: true,\n\t\t\tnodeAddresses:      []v1.NodeAddress{{Type: v1.NodeInternalIP, Address: \"2001:DB8::8\"}},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{RecordType: \"AAAA\", DNSName: \"node1.example.org\", Targets: endpoint.Targets{\"2001:DB8::8\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:              \"node with fqdn template returns endpoint with expanded hostname\",\n\t\t\tfqdnTemplate:       \"{{.Name}}.example.org\",\n\t\t\tnodeName:           \"node1\",\n\t\t\texposeInternalIPv6: true,\n\t\t\tnodeAddresses:      []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: \"1.2.3.4\"}},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{RecordType: \"A\", DNSName: \"node1.example.org\", Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:              \"node with fqdn and fqdn template returns one endpoint\",\n\t\t\tfqdnTemplate:       \"{{.Name}}.example.org\",\n\t\t\tnodeName:           \"node1.example.org\",\n\t\t\texposeInternalIPv6: true,\n\t\t\tnodeAddresses:      []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: \"1.2.3.4\"}},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{RecordType: \"A\", DNSName: \"node1.example.org.example.org\", Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:              \"node with fqdn template returns two endpoints with multiple IP addresses and expanded hostname\",\n\t\t\tfqdnTemplate:       \"{{.Name}}.example.org\",\n\t\t\tnodeName:           \"node1\",\n\t\t\texposeInternalIPv6: true,\n\t\t\tnodeAddresses:      []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: \"1.2.3.4\"}, {Type: v1.NodeExternalIP, Address: \"5.6.7.8\"}},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{RecordType: \"A\", DNSName: \"node1.example.org\", Targets: endpoint.Targets{\"1.2.3.4\", \"5.6.7.8\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:              \"node with fqdn template returns two endpoints with dual-stack IP addresses and expanded hostname\",\n\t\t\tfqdnTemplate:       \"{{.Name}}.example.org\",\n\t\t\tnodeName:           \"node1\",\n\t\t\texposeInternalIPv6: true,\n\t\t\tnodeAddresses:      []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: \"1.2.3.4\"}, {Type: v1.NodeInternalIP, Address: \"2001:DB8::8\"}},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{RecordType: \"A\", DNSName: \"node1.example.org\", Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{RecordType: \"AAAA\", DNSName: \"node1.example.org\", Targets: endpoint.Targets{\"2001:DB8::8\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:              \"node with both external and internal IP returns an endpoint with external IP\",\n\t\t\tnodeName:           \"node1\",\n\t\t\texposeInternalIPv6: true,\n\t\t\tnodeAddresses:      []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: \"1.2.3.4\"}, {Type: v1.NodeInternalIP, Address: \"2.3.4.5\"}},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{RecordType: \"A\", DNSName: \"node1\", Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:              \"node with both external, internal, and IPv6 IP returns endpoints with external IPs\",\n\t\t\tnodeName:           \"node1\",\n\t\t\texposeInternalIPv6: true,\n\t\t\tnodeAddresses:      []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: \"1.2.3.4\"}, {Type: v1.NodeInternalIP, Address: \"2.3.4.5\"}, {Type: v1.NodeInternalIP, Address: \"2001:DB8::8\"}},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{RecordType: \"A\", DNSName: \"node1\", Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{RecordType: \"AAAA\", DNSName: \"node1\", Targets: endpoint.Targets{\"2001:DB8::8\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:              \"node with only internal IP returns an endpoint with internal IP\",\n\t\t\tnodeName:           \"node1\",\n\t\t\texposeInternalIPv6: true,\n\t\t\tnodeAddresses:      []v1.NodeAddress{{Type: v1.NodeInternalIP, Address: \"2.3.4.5\"}},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{RecordType: \"A\", DNSName: \"node1\", Targets: endpoint.Targets{\"2.3.4.5\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:              \"node with only internal IPs returns endpoints with internal IPs\",\n\t\t\tnodeName:           \"node1\",\n\t\t\texposeInternalIPv6: true,\n\t\t\tnodeAddresses:      []v1.NodeAddress{{Type: v1.NodeInternalIP, Address: \"2.3.4.5\"}, {Type: v1.NodeInternalIP, Address: \"2001:DB8::8\"}},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{RecordType: \"A\", DNSName: \"node1\", Targets: endpoint.Targets{\"2.3.4.5\"}},\n\t\t\t\t{RecordType: \"AAAA\", DNSName: \"node1\", Targets: endpoint.Targets{\"2001:DB8::8\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:              \"node with only internal IPs with expose internal IP as false shouldn't return AAAA endpoints with internal IPs\",\n\t\t\tnodeName:           \"node1\",\n\t\t\texposeInternalIPv6: false,\n\t\t\tnodeAddresses:      []v1.NodeAddress{{Type: v1.NodeInternalIP, Address: \"2.3.4.5\"}, {Type: v1.NodeInternalIP, Address: \"2001:DB8::9\"}},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{RecordType: \"A\", DNSName: \"node1\", Targets: endpoint.Targets{\"2.3.4.5\"}},\n\t\t\t\t{RecordType: \"AAAA\", DNSName: \"node1\", Targets: endpoint.Targets{\"2001:DB8::9\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:              \"node with neither external nor internal IP returns no endpoints\",\n\t\t\tnodeName:           \"node1\",\n\t\t\texposeInternalIPv6: true,\n\t\t\tnodeAddresses:      []v1.NodeAddress{},\n\t\t\texpectError:        true,\n\t\t},\n\t\t{\n\t\t\ttitle:              \"node with target annotation\",\n\t\t\tnodeName:           \"node1.example.org\",\n\t\t\texposeInternalIPv6: true,\n\t\t\tnodeAddresses:      []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: \"1.2.3.4\"}},\n\t\t\tannotations: map[string]string{\n\t\t\t\t\"external-dns.alpha.kubernetes.io/target\": \"203.2.45.7\",\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{RecordType: \"A\", DNSName: \"node1.example.org\", Targets: endpoint.Targets{\"203.2.45.7\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:              \"annotated node without annotation filter returns endpoint\",\n\t\t\tnodeName:           \"node1\",\n\t\t\texposeInternalIPv6: true,\n\t\t\tnodeAddresses:      []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: \"1.2.3.4\"}},\n\t\t\tannotations: map[string]string{\n\t\t\t\t\"service.beta.kubernetes.io/external-traffic\": \"OnlyLocal\",\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{RecordType: \"A\", DNSName: \"node1\", Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:              \"annotated node with matching annotation filter returns endpoint\",\n\t\t\tannotationFilter:   \"service.beta.kubernetes.io/external-traffic in (Global, OnlyLocal)\",\n\t\t\tnodeName:           \"node1\",\n\t\t\texposeInternalIPv6: true,\n\t\t\tnodeAddresses:      []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: \"1.2.3.4\"}},\n\t\t\tannotations: map[string]string{\n\t\t\t\t\"service.beta.kubernetes.io/external-traffic\": \"OnlyLocal\",\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{RecordType: \"A\", DNSName: \"node1\", Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:              \"annotated node with non-matching annotation filter returns nothing\",\n\t\t\tannotationFilter:   \"service.beta.kubernetes.io/external-traffic in (Global, OnlyLocal)\",\n\t\t\tnodeName:           \"node1\",\n\t\t\texposeInternalIPv6: true,\n\t\t\tnodeAddresses:      []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: \"1.2.3.4\"}},\n\t\t\tannotations: map[string]string{\n\t\t\t\t\"service.beta.kubernetes.io/external-traffic\": \"SomethingElse\",\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:              \"labeled node with matching label selector returns endpoint\",\n\t\t\tlabelSelector:      \"service.beta.kubernetes.io/external-traffic in (Global, OnlyLocal)\",\n\t\t\tnodeName:           \"node1\",\n\t\t\texposeInternalIPv6: true,\n\t\t\tnodeAddresses:      []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: \"1.2.3.4\"}},\n\t\t\tlabels: map[string]string{\n\t\t\t\t\"service.beta.kubernetes.io/external-traffic\": \"OnlyLocal\",\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{RecordType: \"A\", DNSName: \"node1\", Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:              \"labeled node with non-matching label selector returns nothing\",\n\t\t\tlabelSelector:      \"service.beta.kubernetes.io/external-traffic in (Global, OnlyLocal)\",\n\t\t\tnodeName:           \"node1\",\n\t\t\texposeInternalIPv6: true,\n\t\t\tnodeAddresses:      []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: \"1.2.3.4\"}},\n\t\t\tlabels: map[string]string{\n\t\t\t\t\"service.beta.kubernetes.io/external-traffic\": \"SomethingElse\",\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:              \"our controller type is dns-controller\",\n\t\t\tnodeName:           \"node1\",\n\t\t\texposeInternalIPv6: true,\n\t\t\tnodeAddresses:      []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: \"1.2.3.4\"}},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.ControllerKey: annotations.ControllerValue,\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{RecordType: \"A\", DNSName: \"node1\", Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:              \"different controller types are ignored\",\n\t\t\tnodeName:           \"node1\",\n\t\t\texposeInternalIPv6: true,\n\t\t\tnodeAddresses:      []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: \"1.2.3.4\"}},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.ControllerKey: \"not-dns-controller\",\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:              \"ttl not annotated should have RecordTTL.IsConfigured set to false\",\n\t\t\tnodeName:           \"node1\",\n\t\t\texposeInternalIPv6: true,\n\t\t\tnodeAddresses:      []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: \"1.2.3.4\"}},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{RecordType: \"A\", DNSName: \"node1\", Targets: endpoint.Targets{\"1.2.3.4\"}, RecordTTL: endpoint.TTL(0)},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:              \"ttl annotated but invalid should have RecordTTL.IsConfigured set to false\",\n\t\t\tnodeName:           \"node1\",\n\t\t\texposeInternalIPv6: true,\n\t\t\tnodeAddresses:      []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: \"1.2.3.4\"}},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.TtlKey: \"foo\",\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{RecordType: \"A\", DNSName: \"node1\", Targets: endpoint.Targets{\"1.2.3.4\"}, RecordTTL: endpoint.TTL(0)},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:              \"ttl annotated and is valid should set Record.TTL\",\n\t\t\tnodeName:           \"node1\",\n\t\t\texposeInternalIPv6: true,\n\t\t\tnodeAddresses:      []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: \"1.2.3.4\"}},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.TtlKey: \"10\",\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{RecordType: \"A\", DNSName: \"node1\", Targets: endpoint.Targets{\"1.2.3.4\"}, RecordTTL: endpoint.TTL(10)},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:                \"unschedulable node return nothing with excludeUnschedulable=true\",\n\t\t\tnodeName:             \"node1\",\n\t\t\texposeInternalIPv6:   true,\n\t\t\tnodeAddresses:        []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: \"1.2.3.4\"}},\n\t\t\tunschedulable:        true,\n\t\t\texcludeUnschedulable: true,\n\t\t\texpected:             []*endpoint.Endpoint{},\n\t\t\texpectedLogs: []string{\n\t\t\t\t\"Skipping node node1 because it is unschedulable\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:                \"unschedulable node returns node with excludeUnschedulable=false\",\n\t\t\tnodeName:             \"node1\",\n\t\t\tnodeAddresses:        []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: \"1.2.3.4\"}},\n\t\t\tunschedulable:        true,\n\t\t\texcludeUnschedulable: false,\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{RecordType: \"A\", DNSName: \"node1\", Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t\texpectedAbsentLogs: []string{\n\t\t\t\t\"Skipping node node1 because it is unschedulable\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:              \"provider-specific annotation is not supported and is ignored\",\n\t\t\tnodeName:           \"node1\",\n\t\t\texposeInternalIPv6: true,\n\t\t\tnodeAddresses:      []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: \"1.2.3.4\"}},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.AWSPrefix + \"weight\": \"10\",\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{RecordType: \"A\", DNSName: \"node1\", Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(tc.title, func(t *testing.T) {\n\t\t\thook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t)\n\n\t\t\tlabelSelector := labels.Everything()\n\t\t\tif tc.labelSelector != \"\" {\n\t\t\t\tvar err error\n\t\t\t\tlabelSelector, err = labels.Parse(tc.labelSelector)\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\t// Create a Kubernetes testing client\n\t\t\tkubeClient := fake.NewClientset()\n\n\t\t\tnode := &v1.Node{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:        tc.nodeName,\n\t\t\t\t\tLabels:      tc.labels,\n\t\t\t\t\tAnnotations: tc.annotations,\n\t\t\t\t},\n\t\t\t\tSpec: v1.NodeSpec{\n\t\t\t\t\tUnschedulable: tc.unschedulable,\n\t\t\t\t},\n\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\tAddresses: tc.nodeAddresses,\n\t\t\t\t},\n\t\t\t}\n\n\t\t\t_, err := kubeClient.CoreV1().Nodes().Create(t.Context(), node, metav1.CreateOptions{})\n\t\t\trequire.NoError(t, err)\n\n\t\t\t// Create our object under test and get the endpoints.\n\t\t\tclient, err := NewNodeSource(\n\t\t\t\tt.Context(),\n\t\t\t\tkubeClient,\n\t\t\t\t&Config{\n\t\t\t\t\tAnnotationFilter:     tc.annotationFilter,\n\t\t\t\t\tFQDNTemplate:         tc.fqdnTemplate,\n\t\t\t\t\tLabelFilter:          labelSelector,\n\t\t\t\t\tExposeInternalIPv6:   tc.exposeInternalIPv6,\n\t\t\t\t\tExcludeUnschedulable: tc.excludeUnschedulable,\n\t\t\t\t},\n\t\t\t)\n\t\t\trequire.NoError(t, err)\n\n\t\t\tendpoints, err := client.Endpoints(t.Context())\n\t\t\tif tc.expectError {\n\t\t\t\trequire.Error(t, err)\n\t\t\t} else {\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\t// Validate returned endpoints against desired endpoints.\n\t\t\tvalidateEndpoints(t, endpoints, tc.expected)\n\n\t\t\tfor _, entry := range tc.expectedLogs {\n\t\t\t\tlogtest.TestHelperLogContains(entry, hook, t)\n\t\t\t}\n\t\t\tfor _, entry := range tc.expectedAbsentLogs {\n\t\t\t\tlogtest.TestHelperLogNotContains(entry, hook, t)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc testNodeEndpointsWithIPv6(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\ttitle                string\n\t\tannotationFilter     string\n\t\tlabelSelector        string\n\t\tfqdnTemplate         string\n\t\tnodeName             string\n\t\tnodeAddresses        []v1.NodeAddress\n\t\tlabels               map[string]string\n\t\tannotations          map[string]string\n\t\texcludeUnschedulable bool // defaults to false\n\t\texposeInternalIPv6   bool // default to true for this version. Change later when the next minor version is released.\n\t\tunschedulable        bool // default to false\n\t\texpected             []*endpoint.Endpoint\n\t\texpectError          bool\n\t}{\n\t\t{\n\t\t\ttitle:              \"node with only internal IPs should return internal IPvs irrespective of exposeInternalIPv6\",\n\t\t\tnodeName:           \"node1\",\n\t\t\texposeInternalIPv6: false,\n\t\t\tnodeAddresses:      []v1.NodeAddress{{Type: v1.NodeInternalIP, Address: \"2.3.4.5\"}, {Type: v1.NodeInternalIP, Address: \"2001:DB8::9\"}},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{RecordType: \"A\", DNSName: \"node1\", Targets: endpoint.Targets{\"2.3.4.5\"}},\n\t\t\t\t{RecordType: \"AAAA\", DNSName: \"node1\", Targets: endpoint.Targets{\"2001:DB8::9\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:              \"node with both external, internal, and IPv6 IP returns endpoints with external IPs\",\n\t\t\tnodeName:           \"node1\",\n\t\t\texposeInternalIPv6: false,\n\t\t\tnodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: \"1.2.3.4\"}, {\n\t\t\t\tType:    v1.NodeExternalIP,\n\t\t\t\tAddress: \"2001:DB8::8\",\n\t\t\t}, {Type: v1.NodeInternalIP, Address: \"2001:DB8::9\"}},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{RecordType: \"A\", DNSName: \"node1\", Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{RecordType: \"AAAA\", DNSName: \"node1\", Targets: endpoint.Targets{\"2001:DB8::8\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:              \"node with both external and internal IPs should return internal IPv6 if exposeInternalIPv6 is true\",\n\t\t\tnodeName:           \"node1\",\n\t\t\texposeInternalIPv6: true,\n\t\t\tnodeAddresses: []v1.NodeAddress{\n\t\t\t\t{Type: v1.NodeExternalIP, Address: \"1.2.3.5\"},\n\t\t\t\t{Type: v1.NodeInternalIP, Address: \"2001:DB8::9\"},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{RecordType: \"A\", DNSName: \"node1\", Targets: endpoint.Targets{\"1.2.3.5\"}},\n\t\t\t\t{RecordType: \"AAAA\", DNSName: \"node1\", Targets: endpoint.Targets{\"2001:DB8::9\"}},\n\t\t\t},\n\t\t},\n\t} {\n\t\tlabelSelector := labels.Everything()\n\t\tif tc.labelSelector != \"\" {\n\t\t\tvar err error\n\t\t\tlabelSelector, err = labels.Parse(tc.labelSelector)\n\t\t\trequire.NoError(t, err)\n\t\t}\n\n\t\t// Create a Kubernetes testing client\n\t\tkubeClient := fake.NewClientset()\n\n\t\tnode := &v1.Node{\n\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\tName:        tc.nodeName,\n\t\t\t\tLabels:      tc.labels,\n\t\t\t\tAnnotations: tc.annotations,\n\t\t\t},\n\t\t\tSpec: v1.NodeSpec{\n\t\t\t\tUnschedulable: tc.unschedulable,\n\t\t\t},\n\t\t\tStatus: v1.NodeStatus{\n\t\t\t\tAddresses: tc.nodeAddresses,\n\t\t\t},\n\t\t}\n\n\t\t_, err := kubeClient.CoreV1().Nodes().Create(t.Context(), node, metav1.CreateOptions{})\n\t\trequire.NoError(t, err)\n\n\t\t// Create our object under test and get the endpoints.\n\t\tclient, err := NewNodeSource(\n\t\t\tt.Context(),\n\t\t\tkubeClient,\n\t\t\t&Config{\n\t\t\t\tAnnotationFilter:     tc.annotationFilter,\n\t\t\t\tFQDNTemplate:         tc.fqdnTemplate,\n\t\t\t\tLabelFilter:          labelSelector,\n\t\t\t\tExposeInternalIPv6:   tc.exposeInternalIPv6,\n\t\t\t\tExcludeUnschedulable: tc.excludeUnschedulable,\n\t\t\t},\n\t\t)\n\t\trequire.NoError(t, err)\n\n\t\tendpoints, err := client.Endpoints(t.Context())\n\t\tif tc.expectError {\n\t\t\trequire.Error(t, err)\n\t\t} else {\n\t\t\trequire.NoError(t, err)\n\t\t}\n\n\t\t// Validate returned endpoints against desired endpoints.\n\t\tvalidateEndpoints(t, endpoints, tc.expected)\n\n\t\t// TODO; when all resources have the resource label, we could add this check to the validateEndpoints function.\n\t\tfor _, ep := range endpoints {\n\t\t\trequire.Contains(t, ep.Labels, endpoint.ResourceLabelKey)\n\t\t}\n\t}\n}\n\nfunc TestResourceLabelIsSetForEachNodeEndpoint(t *testing.T) {\n\tkubeClient := fake.NewClientset()\n\n\tnodes := helperNodeBuilder().\n\t\twithNode(map[string]string{\"tenant\": \"1\"}).\n\t\twithNode(map[string]string{\"tenant\": \"2\"}).\n\t\twithNode(map[string]string{\"tenant\": \"3\"}).\n\t\twithNode(map[string]string{\"tenant\": \"4\"}).\n\t\tbuild()\n\n\tfor _, node := range nodes.Items {\n\t\t_, err := kubeClient.CoreV1().Nodes().Create(t.Context(), &node, metav1.CreateOptions{})\n\t\trequire.NoError(t, err, \"Failed to create node %s\", node.Name)\n\t}\n\n\tclient, err := NewNodeSource(\n\t\tt.Context(),\n\t\tkubeClient,\n\t\t&Config{\n\t\t\tLabelFilter: labels.Everything(),\n\t\t},\n\t)\n\trequire.NoError(t, err)\n\n\tgot, err := client.Endpoints(t.Context())\n\trequire.NoError(t, err)\n\tfor _, ep := range got {\n\t\tassert.NotEmpty(t, ep.Labels, \"Labels should not be empty for endpoint %s\", ep.DNSName)\n\t\tassert.Contains(t, ep.Labels, endpoint.ResourceLabelKey)\n\t}\n}\n\nfunc TestProcessEndpoint_Node_RefObjectExist(t *testing.T) {\n\telements := []runtime.Object{\n\t\t&v1.Node{\n\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\tName: \"foo\",\n\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\tannotations.HostnameKey: \"foo.example.com\",\n\t\t\t\t\tannotations.TargetKey:   \"1.2.3\",\n\t\t\t\t},\n\t\t\t\tUID: \"uid-1\",\n\t\t\t},\n\t\t},\n\t\t&v1.Node{\n\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\tName: \"bar\",\n\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\tannotations.HostnameKey: \"bar.example.com\",\n\t\t\t\t\tannotations.TargetKey:   \"3.4.5\",\n\t\t\t\t},\n\t\t\t\tUID: \"uid-2\",\n\t\t\t},\n\t\t},\n\t}\n\n\tfakeClient := fake.NewClientset(elements...)\n\n\tclient, err := NewNodeSource(\n\t\tt.Context(),\n\t\tfakeClient,\n\t\t&Config{\n\t\t\tLabelFilter: labels.Everything(),\n\t\t},\n\t)\n\trequire.NoError(t, err)\n\n\tendpoints, err := client.Endpoints(t.Context())\n\trequire.NoError(t, err)\n\ttestutils.AssertEndpointsHaveRefObject(t, endpoints, types.Node, len(elements))\n}\n\nfunc TestNodeSource_AddEventHandler(t *testing.T) {\n\tfakeInformer := new(fakeNodeInformer)\n\tinf := testInformer{}\n\tfakeInformer.On(\"Informer\").Return(&inf)\n\n\tnSource := &nodeSource{\n\t\tnodeInformer: fakeInformer,\n\t}\n\n\thandlerCalled := false\n\thandler := func() { handlerCalled = true }\n\n\tnSource.AddEventHandler(t.Context(), handler)\n\n\tfakeInformer.AssertNumberOfCalls(t, \"Informer\", 1)\n\tassert.False(t, handlerCalled)\n\tassert.Equal(t, 1, inf.times)\n}\n\ntype fakeNodeInformer struct {\n\tmock.Mock\n}\n\nfunc (f *fakeNodeInformer) Informer() cache.SharedIndexInformer {\n\targs := f.Called()\n\treturn args.Get(0).(cache.SharedIndexInformer)\n}\n\nfunc (f *fakeNodeInformer) Lister() corev1lister.NodeLister {\n\treturn corev1lister.NewNodeLister(f.Informer().GetIndexer())\n}\n\ntype nodeListBuilder struct {\n\tnodes []v1.Node\n}\n\nfunc helperNodeBuilder() *nodeListBuilder {\n\treturn &nodeListBuilder{nodes: []v1.Node{}}\n}\n\nfunc (b *nodeListBuilder) withNode(labels map[string]string) *nodeListBuilder {\n\tidx := len(b.nodes) + 1\n\tnodeName := fmt.Sprintf(\"ip-10-1-176-%d.internal\", idx)\n\tb.nodes = append(b.nodes, v1.Node{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName: nodeName,\n\t\t\tLabels: func() map[string]string {\n\t\t\t\tbase := map[string]string{\n\t\t\t\t\t\"test-label\":                    \"test-value\",\n\t\t\t\t\t\"name\":                          nodeName,\n\t\t\t\t\t\"topology.kubernetes.io/region\": \"eu-west-1\",\n\t\t\t\t\t\"node.kubernetes.io/lifecycle\":  \"spot\",\n\t\t\t\t}\n\t\t\t\tmaps.Copy(base, labels)\n\t\t\t\treturn base\n\t\t\t}(),\n\t\t\tAnnotations: map[string]string{\n\t\t\t\t\"volumes.kubernetes.io/controller-managed-attach-detach\": \"true\",\n\t\t\t\t\"alpha.kubernetes.io/provided-node-ip\":                   fmt.Sprintf(\"10.1.176.%d\", idx),\n\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\":              fmt.Sprintf(\"node-%d.example.com\", idx),\n\t\t\t},\n\t\t},\n\t\tSpec: v1.NodeSpec{\n\t\t\tUnschedulable: false,\n\t\t},\n\t\tStatus: v1.NodeStatus{\n\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t{Type: v1.NodeInternalIP, Address: fmt.Sprintf(\"10.1.176.%d\", idx)},\n\t\t\t\t{Type: v1.NodeInternalIP, Address: fmt.Sprintf(\"fc00:f853:ccd:e793::%d\", idx)},\n\t\t\t},\n\t\t},\n\t})\n\n\treturn b\n}\n\nfunc (b *nodeListBuilder) build() v1.NodeList {\n\tif len(b.nodes) > 1 {\n\t\t// Shuffle the result to ensure randomness in the order.\n\t\trand.New(rand.NewSource(time.Now().UnixNano()))\n\t\trand.Shuffle(len(b.nodes), func(i, j int) {\n\t\t\tb.nodes[i], b.nodes[j] = b.nodes[j], b.nodes[i]\n\t\t})\n\t}\n\treturn v1.NodeList{Items: b.nodes}\n}\n"
  },
  {
    "path": "source/openshift_route.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"text/template\"\n\t\"time\"\n\n\troutev1 \"github.com/openshift/api/route/v1\"\n\t\"github.com/openshift/client-go/route/clientset/versioned\"\n\textInformers \"github.com/openshift/client-go/route/informers/externalversions\"\n\trouteInformer \"github.com/openshift/client-go/route/informers/externalversions/route/v1\"\n\tlog \"github.com/sirupsen/logrus\"\n\tcorev1 \"k8s.io/api/core/v1\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\n\t\"sigs.k8s.io/external-dns/source/types\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n\t\"sigs.k8s.io/external-dns/source/fqdn\"\n\t\"sigs.k8s.io/external-dns/source/informers\"\n)\n\n// ocpRouteSource is an implementation of Source for OpenShift Route objects.\n// The Route implementation will use the Route spec.host field for the hostname,\n// and the Route status' canonicalHostname field as the target.\n// The annotations.TargetKey can be used to explicitly set an alternative\n// endpoint, if desired.\n//\n// +externaldns:source:name=openshift-route\n// +externaldns:source:category=OpenShift\n// +externaldns:source:description=Creates DNS entries from OpenShift Route resources\n// +externaldns:source:resources=Route.route.openshift.io\n// +externaldns:source:filters=annotation,label\n// +externaldns:source:namespace=all,single\n// +externaldns:source:fqdn-template=true\n// +externaldns:source:provider-specific=true\ntype ocpRouteSource struct {\n\tclient                   versioned.Interface\n\tnamespace                string\n\tannotationFilter         string\n\tfqdnTemplate             *template.Template\n\tcombineFQDNAnnotation    bool\n\tignoreHostnameAnnotation bool\n\trouteInformer            routeInformer.RouteInformer\n\tlabelSelector            labels.Selector\n\tocpRouterName            string\n}\n\n// NewOcpRouteSource creates a new ocpRouteSource with the given config.\nfunc NewOcpRouteSource(\n\tctx context.Context,\n\tocpClient versioned.Interface,\n\tcfg *Config,\n) (Source, error) {\n\ttmpl, err := fqdn.ParseTemplate(cfg.FQDNTemplate)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Use a shared informer to listen for add/update/delete of Routes in the specified namespace.\n\t// Set resync period to 0, to prevent processing when nothing has changed.\n\tinformerFactory := extInformers.NewSharedInformerFactoryWithOptions(ocpClient, 0*time.Second, extInformers.WithNamespace(cfg.Namespace))\n\tinformer := informerFactory.Route().V1().Routes()\n\n\t// Add default resource event handlers to properly initialize informer.\n\t_, _ = informer.Informer().AddEventHandler(informers.DefaultEventHandler())\n\n\tinformerFactory.Start(ctx.Done())\n\n\t// wait for the local cache to be populated.\n\tif err := informers.WaitForCacheSync(ctx, informerFactory); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &ocpRouteSource{\n\t\tclient:                   ocpClient,\n\t\tnamespace:                cfg.Namespace,\n\t\tannotationFilter:         cfg.AnnotationFilter,\n\t\tfqdnTemplate:             tmpl,\n\t\tcombineFQDNAnnotation:    cfg.CombineFQDNAndAnnotation,\n\t\tignoreHostnameAnnotation: cfg.IgnoreHostnameAnnotation,\n\t\trouteInformer:            informer,\n\t\tlabelSelector:            cfg.LabelFilter,\n\t\tocpRouterName:            cfg.OCPRouterName,\n\t}, nil\n}\n\nfunc (ors *ocpRouteSource) AddEventHandler(_ context.Context, handler func()) {\n\tlog.Debug(\"Adding event handler for openshift route\")\n\n\t// Right now there is no way to remove event handler from informer, see:\n\t// https://github.com/kubernetes/kubernetes/issues/79610\n\t_, _ = ors.routeInformer.Informer().AddEventHandler(eventHandlerFunc(handler))\n}\n\n// Endpoints returns endpoint objects for each host-target combination that should be processed.\n// Retrieves all OpenShift Route resources on all namespaces, unless an explicit namespace\n// is specified in ocpRouteSource.\nfunc (ors *ocpRouteSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) {\n\tocpRoutes, err := ors.routeInformer.Lister().Routes(ors.namespace).List(ors.labelSelector)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tocpRoutes, err = annotations.Filter(ocpRoutes, ors.annotationFilter)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tendpoints := []*endpoint.Endpoint{}\n\n\tfor _, ocpRoute := range ocpRoutes {\n\t\tif annotations.IsControllerMismatch(ocpRoute, types.OpenShiftRoute) {\n\t\t\tcontinue\n\t\t}\n\n\t\torEndpoints := ors.endpointsFromOcpRoute(ocpRoute, ors.ignoreHostnameAnnotation)\n\n\t\t// apply template if host is missing on OpenShift Route\n\t\torEndpoints, err = fqdn.CombineWithTemplatedEndpoints(\n\t\t\torEndpoints,\n\t\t\tors.fqdnTemplate,\n\t\t\tors.combineFQDNAnnotation,\n\t\t\tfunc() ([]*endpoint.Endpoint, error) { return ors.endpointsFromTemplate(ocpRoute) },\n\t\t)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif endpoint.HasNoEmptyEndpoints(orEndpoints, types.OpenShiftRoute, ocpRoute) {\n\t\t\tcontinue\n\t\t}\n\n\t\tlog.Debugf(\"Endpoints generated from OpenShift Route: %s/%s: %v\", ocpRoute.Namespace, ocpRoute.Name, orEndpoints)\n\t\tendpoints = append(endpoints, orEndpoints...)\n\t}\n\n\treturn MergeEndpoints(endpoints), nil\n}\n\nfunc (ors *ocpRouteSource) endpointsFromTemplate(ocpRoute *routev1.Route) ([]*endpoint.Endpoint, error) {\n\thostnames, err := fqdn.ExecTemplate(ors.fqdnTemplate, ocpRoute)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tresource := fmt.Sprintf(\"route/%s/%s\", ocpRoute.Namespace, ocpRoute.Name)\n\n\tttl := annotations.TTLFromAnnotations(ocpRoute.Annotations, resource)\n\n\ttargets := annotations.TargetsFromTargetAnnotation(ocpRoute.Annotations)\n\tif len(targets) == 0 {\n\t\ttargetsFromRoute, _ := ors.getTargetsFromRouteStatus(ocpRoute.Status)\n\t\ttargets = targetsFromRoute\n\t}\n\n\tproviderSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(ocpRoute.Annotations)\n\n\tvar endpoints []*endpoint.Endpoint\n\tfor _, hostname := range hostnames {\n\t\tendpoints = append(endpoints, endpoint.EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...)\n\t}\n\treturn endpoints, nil\n}\n\n// endpointsFromOcpRoute extracts the endpoints from a OpenShift Route object\nfunc (ors *ocpRouteSource) endpointsFromOcpRoute(ocpRoute *routev1.Route, ignoreHostnameAnnotation bool) []*endpoint.Endpoint {\n\tvar endpoints []*endpoint.Endpoint\n\n\tresource := fmt.Sprintf(\"route/%s/%s\", ocpRoute.Namespace, ocpRoute.Name)\n\n\tttl := annotations.TTLFromAnnotations(ocpRoute.Annotations, resource)\n\n\ttargets := annotations.TargetsFromTargetAnnotation(ocpRoute.Annotations)\n\ttargetsFromRoute, host := ors.getTargetsFromRouteStatus(ocpRoute.Status)\n\n\tif len(targets) == 0 {\n\t\ttargets = targetsFromRoute\n\t}\n\n\tproviderSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(ocpRoute.Annotations)\n\n\tif host != \"\" {\n\t\tendpoints = append(endpoints, endpoint.EndpointsForHostname(host, targets, ttl, providerSpecific, setIdentifier, resource)...)\n\t}\n\n\t// Skip endpoints if we do not want entries from annotations\n\tif !ignoreHostnameAnnotation {\n\t\thostnameList := annotations.HostnamesFromAnnotations(ocpRoute.Annotations)\n\t\tfor _, hostname := range hostnameList {\n\t\t\tendpoints = append(endpoints, endpoint.EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...)\n\t\t}\n\t}\n\treturn endpoints\n}\n\n// getTargetsFromRouteStatus returns the router's canonical hostname and host\n// either for the given router if it admitted the route\n// or for the first (in the status list) router that admitted the route.\nfunc (ors *ocpRouteSource) getTargetsFromRouteStatus(status routev1.RouteStatus) (endpoint.Targets, string) {\n\tfor _, ing := range status.Ingress {\n\t\t// if this Ingress didn't admit the route or it doesn't have the canonical hostname, then ignore it\n\t\tif ingressConditionStatus(&ing, routev1.RouteAdmitted) != corev1.ConditionTrue || ing.RouterCanonicalHostname == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\t// if the router name is specified for the Route source and it matches the route's ingress name, then return it\n\t\tif ors.ocpRouterName != \"\" && ors.ocpRouterName == ing.RouterName {\n\t\t\treturn endpoint.Targets{ing.RouterCanonicalHostname}, ing.Host\n\t\t}\n\n\t\t// if the router name is not specified in the Route source then return the first ingress\n\t\tif ors.ocpRouterName == \"\" {\n\t\t\treturn endpoint.Targets{ing.RouterCanonicalHostname}, ing.Host\n\t\t}\n\t}\n\treturn endpoint.Targets{}, \"\"\n}\n\nfunc ingressConditionStatus(ingress *routev1.RouteIngress, t routev1.RouteIngressConditionType) corev1.ConditionStatus {\n\tfor _, condition := range ingress.Conditions {\n\t\tif t != condition.Type {\n\t\t\tcontinue\n\t\t}\n\t\treturn condition.Status\n\t}\n\treturn corev1.ConditionUnknown\n}\n"
  },
  {
    "path": "source/openshift_route_fqdn_test.go",
    "content": "/*\nCopyright 2026 The Kubernetes Authors.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n    http://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"testing\"\n\n\troutev1 \"github.com/openshift/api/route/v1\"\n\t\"github.com/openshift/client-go/route/clientset/versioned/fake\"\n\t\"github.com/stretchr/testify/require\"\n\tcorev1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\t\"k8s.io/apimachinery/pkg/util/intstr\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n)\n\nfunc TestOpenShiftFqdnTemplatingExamples(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\ttitle        string\n\t\tocpRoute     []*routev1.Route\n\t\tfqdnTemplate string\n\t\tcombineFqdn  bool\n\t\texpected     []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\ttitle:        \"simple templating\",\n\t\t\tfqdnTemplate: \"{{.Name}}.tld.com\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"router-default.example.com\"}},\n\t\t\t\t{DNSName: \"my-gateway.tld.com\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"router-default.example.com\"}},\n\t\t\t},\n\t\t\tocpRoute: []*routev1.Route{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-gateway\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: routev1.RouteSpec{\n\t\t\t\t\t\tHost: \"example.org\",\n\t\t\t\t\t\tTo: routev1.RouteTargetReference{\n\t\t\t\t\t\t\tKind: \"Service\",\n\t\t\t\t\t\t\tName: \"my-service\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\tTLS: &routev1.TLSConfig{},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: routev1.RouteStatus{\n\t\t\t\t\t\tIngress: []routev1.RouteIngress{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tHost:                    \"example.org\",\n\t\t\t\t\t\t\t\tRouterCanonicalHostname: \"router-default.example.com\",\n\t\t\t\t\t\t\t\tConditions: []routev1.RouteIngressCondition{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tType:   routev1.RouteAdmitted,\n\t\t\t\t\t\t\t\t\t\tStatus: corev1.ConditionTrue,\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"templating with fqdn combine disabled\",\n\t\t\tfqdnTemplate: \"{{.Name}}.tld.com\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"router-default.example.com\"}},\n\t\t\t},\n\t\t\tcombineFqdn: true,\n\t\t\tocpRoute: []*routev1.Route{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-gateway\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: routev1.RouteSpec{},\n\t\t\t\t\tStatus: routev1.RouteStatus{\n\t\t\t\t\t\tIngress: []routev1.RouteIngress{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tHost:                    \"example.org\",\n\t\t\t\t\t\t\t\tRouterCanonicalHostname: \"router-default.example.com\",\n\t\t\t\t\t\t\t\tConditions: []routev1.RouteIngressCondition{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tType:   routev1.RouteAdmitted,\n\t\t\t\t\t\t\t\t\t\tStatus: corev1.ConditionTrue,\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"templating with namespace\",\n\t\t\tfqdnTemplate: \"{{.Name}}.{{.Namespace}}.tld.com\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"my-gateway.kube-system.tld.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.1.1.0\"}},\n\t\t\t},\n\t\t\tcombineFqdn: true,\n\t\t\tocpRoute: []*routev1.Route{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-gateway\",\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.TargetKey: \"10.1.1.0\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"templating with complex fqdn template\",\n\t\t\tfqdnTemplate: \"{{ .Name }}.{{ .Namespace }}.tld.com,{{ if .Labels.env }}{{ .Labels.env }}.private{{ end }}\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"no-labels-route-3.default.tld.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.1.1.3\"}},\n\t\t\t\t{DNSName: \"route-2.default.tld.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.1.1.3\"}},\n\t\t\t\t{DNSName: \"dev.private\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.1.1.3\"}},\n\t\t\t\t{DNSName: \"route-1.kube-system.tld.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.1.1.0\"}},\n\t\t\t\t{DNSName: \"prod.private\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.1.1.0\"}},\n\t\t\t},\n\t\t\tcombineFqdn: true,\n\t\t\tocpRoute: []*routev1.Route{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"route-1\",\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\t\"env\": \"prod\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\t\"env\":                 \"prod\",\n\t\t\t\t\t\t\tannotations.TargetKey: \"10.1.1.0\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"route-2\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\t\"env\": \"dev\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.TargetKey: \"10.1.1.3\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"no-labels-route-3\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.TargetKey: \"10.1.1.3\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"template that skips when field is missing\",\n\t\t\tfqdnTemplate: \"{{ if and .Spec.Port .Spec.Port.TargetPort }}{{ .Name }}.{{ .Spec.Port.TargetPort }}.tld.com{{ end }}\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"route-1.80.tld.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.1.1.0\"}},\n\t\t\t},\n\t\t\tocpRoute: []*routev1.Route{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"route-1\",\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.TargetKey: \"10.1.1.0\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: routev1.RouteSpec{\n\t\t\t\t\t\tPort: &routev1.RoutePort{\n\t\t\t\t\t\t\tTargetPort: intstr.FromString(\"80\"),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: routev1.RouteStatus{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"route-2\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.TargetKey: \"10.1.1.3\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"get canonical hostnames for admitted routes\",\n\t\t\tfqdnTemplate: \"{{ $name := .Name }}{{ range $ingress := .Status.Ingress }}{{ range $ingress.Conditions }}{{ if and (eq .Type \\\"Admitted\\\") (eq .Status \\\"True\\\") }}{{ $ingress.Host }},{{ end }}{{ end }}{{ end }}\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"cluster.example.org\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"router-dmz.apps.dmz.example.com\"}},\n\t\t\t\t{DNSName: \"apps.example.org\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"router-dmz.apps.dmz.example.com\"}},\n\t\t\t},\n\t\t\tocpRoute: []*routev1.Route{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:        \"my-route\",\n\t\t\t\t\t\tNamespace:   \"kube-system\",\n\t\t\t\t\t\tAnnotations: map[string]string{},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: routev1.RouteStatus{\n\t\t\t\t\t\tIngress: []routev1.RouteIngress{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tHost:                    \"cluster.example.org\",\n\t\t\t\t\t\t\t\tRouterCanonicalHostname: \"router-dmz.apps.dmz.example.com\",\n\t\t\t\t\t\t\t\tConditions: []routev1.RouteIngressCondition{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tType:   routev1.RouteAdmitted,\n\t\t\t\t\t\t\t\t\t\tStatus: corev1.ConditionTrue,\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tHost:                    \"apps.example.org\",\n\t\t\t\t\t\t\t\tRouterCanonicalHostname: \"router-internal.apps.internal.example.com\",\n\t\t\t\t\t\t\t\tConditions: []routev1.RouteIngressCondition{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tType:   routev1.RouteAdmitted,\n\t\t\t\t\t\t\t\t\t\tStatus: corev1.ConditionTrue,\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tHost:                    \"wrong.example.org\",\n\t\t\t\t\t\t\t\tRouterCanonicalHostname: \"router-default.apps.cluster.example.com\",\n\t\t\t\t\t\t\t\tConditions: []routev1.RouteIngressCondition{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tType:   routev1.RouteAdmitted,\n\t\t\t\t\t\t\t\t\t\tStatus: corev1.ConditionFalse,\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"route-2\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.TargetKey: \"10.1.1.3\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"get canonical hostnames for admitted routes without prefix\",\n\t\t\tfqdnTemplate: \"{{ $name := .Name }}{{ range $ingress := .Status.Ingress }}{{ range $ingress.Conditions }}{{ if and (eq .Type \\\"Admitted\\\") (eq .Status \\\"True\\\") }}{{ with $ingress.RouterCanonicalHostname }}{{ $name }}.{{ trimPrefix . \\\"router-\\\" }},{{ end }}{{ end }}{{ end }}{{ end }}\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"cluster.example.org\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"router-dmz.apps.dmz.example.com\"}},\n\t\t\t\t{DNSName: \"my-route.dmz.apps.dmz.example.com\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"router-dmz.apps.dmz.example.com\"}},\n\t\t\t\t{DNSName: \"my-route.internal.apps.internal.example.com\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"router-dmz.apps.dmz.example.com\"}},\n\t\t\t},\n\t\t\tocpRoute: []*routev1.Route{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:        \"my-route\",\n\t\t\t\t\t\tNamespace:   \"kube-system\",\n\t\t\t\t\t\tAnnotations: map[string]string{},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: routev1.RouteStatus{\n\t\t\t\t\t\tIngress: []routev1.RouteIngress{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tHost:                    \"cluster.example.org\",\n\t\t\t\t\t\t\t\tRouterCanonicalHostname: \"router-dmz.apps.dmz.example.com\",\n\t\t\t\t\t\t\t\tConditions: []routev1.RouteIngressCondition{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tType:   routev1.RouteAdmitted,\n\t\t\t\t\t\t\t\t\t\tStatus: corev1.ConditionTrue,\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tHost:                    \"apps.example.org\",\n\t\t\t\t\t\t\t\tRouterCanonicalHostname: \"router-internal.apps.internal.example.com\",\n\t\t\t\t\t\t\t\tConditions: []routev1.RouteIngressCondition{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tType:   routev1.RouteAdmitted,\n\t\t\t\t\t\t\t\t\t\tStatus: corev1.ConditionTrue,\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tHost:                    \"wrong.example.org\",\n\t\t\t\t\t\t\t\tRouterCanonicalHostname: \"router-default.apps.cluster.example.com\",\n\t\t\t\t\t\t\t\tConditions: []routev1.RouteIngressCondition{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tType:   routev1.RouteAdmitted,\n\t\t\t\t\t\t\t\t\t\tStatus: corev1.ConditionFalse,\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"route-2\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.TargetKey: \"10.1.1.3\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(tt.title, func(t *testing.T) {\n\t\t\tkubeClient := fake.NewClientset()\n\t\t\tfor _, ocp := range tt.ocpRoute {\n\t\t\t\t_, err := kubeClient.RouteV1().Routes(ocp.Namespace).Create(t.Context(), ocp, metav1.CreateOptions{})\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\tsrc, err := NewOcpRouteSource(\n\t\t\t\tt.Context(),\n\t\t\t\tkubeClient,\n\t\t\t\t&Config{\n\t\t\t\t\tNamespace:                \"\",\n\t\t\t\t\tAnnotationFilter:         \"\",\n\t\t\t\t\tFQDNTemplate:             tt.fqdnTemplate,\n\t\t\t\t\tCombineFQDNAndAnnotation: !tt.combineFqdn,\n\t\t\t\t\tLabelFilter:              labels.Everything(),\n\t\t\t\t\tOCPRouterName:            \"\",\n\t\t\t\t},\n\t\t\t)\n\t\t\trequire.NoError(t, err)\n\n\t\t\tendpoints, err := src.Endpoints(t.Context())\n\t\t\trequire.NoError(t, err)\n\n\t\t\tvalidateEndpoints(t, endpoints, tt.expected)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "source/openshift_route_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\t\"github.com/stretchr/testify/suite\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\n\troutev1 \"github.com/openshift/api/route/v1\"\n\t\"github.com/openshift/client-go/route/clientset/versioned/fake\"\n\tcorev1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n)\n\ntype OCPRouteSuite struct {\n\tsuite.Suite\n\tsc               Source\n\trouteWithTargets *routev1.Route\n}\n\nfunc (suite *OCPRouteSuite) SetupTest() {\n\tfakeClient := fake.NewClientset()\n\tvar err error\n\n\tsuite.sc, err = NewOcpRouteSource(\n\t\tcontext.TODO(),\n\t\tfakeClient,\n\t\t&Config{\n\t\t\tFQDNTemplate: \"{{.Name}}\",\n\t\t\tLabelFilter:  labels.Everything(),\n\t\t},\n\t)\n\n\tsuite.routeWithTargets = &routev1.Route{\n\t\tSpec: routev1.RouteSpec{\n\t\t\tHost: \"my-domain.com\",\n\t\t},\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tNamespace:   \"default\",\n\t\t\tName:        \"route-with-targets\",\n\t\t\tAnnotations: map[string]string{},\n\t\t},\n\t\tStatus: routev1.RouteStatus{\n\t\t\tIngress: []routev1.RouteIngress{\n\t\t\t\t{\n\t\t\t\t\tRouterCanonicalHostname: \"apps.my-domain.com\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tsuite.NoError(err, \"should initialize route source\")\n\n\t_, err = fakeClient.RouteV1().Routes(suite.routeWithTargets.Namespace).Create(context.Background(), suite.routeWithTargets, metav1.CreateOptions{})\n\tsuite.NoError(err, \"should successfully create route\")\n}\n\nfunc (suite *OCPRouteSuite) TestResourceLabelIsSet() {\n\tendpoints, _ := suite.sc.Endpoints(context.Background())\n\tfor _, ep := range endpoints {\n\t\tsuite.Equal(\"route/default/route-with-targets\", ep.Labels[endpoint.ResourceLabelKey], \"should set correct resource label\")\n\t}\n}\n\nfunc TestOcpRouteSource(t *testing.T) {\n\tt.Parallel()\n\n\tsuite.Run(t, new(OCPRouteSuite))\n\tt.Run(\"Interface\", testOcpRouteSourceImplementsSource)\n\tt.Run(\"NewOcpRouteSource\", testOcpRouteSourceNewOcpRouteSource)\n\tt.Run(\"Endpoints\", testOcpRouteSourceEndpoints)\n}\n\n// testOcpRouteSourceImplementsSource tests that ocpRouteSource is a valid Source.\nfunc testOcpRouteSourceImplementsSource(t *testing.T) {\n\tassert.Implements(t, (*Source)(nil), new(ocpRouteSource))\n}\n\n// testOcpRouteSourceNewOcpRouteSource tests that NewOcpRouteSource doesn't return an error.\nfunc testOcpRouteSourceNewOcpRouteSource(t *testing.T) {\n\tt.Parallel()\n\n\tfor _, ti := range []struct {\n\t\ttitle            string\n\t\tannotationFilter string\n\t\tfqdnTemplate     string\n\t\texpectError      bool\n\t\tlabelFilter      string\n\t}{\n\t\t{\n\t\t\ttitle:        \"invalid template\",\n\t\t\texpectError:  true,\n\t\t\tfqdnTemplate: \"{{.Name\",\n\t\t},\n\t\t{\n\t\t\ttitle:       \"valid empty template\",\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\ttitle:        \"valid template\",\n\t\t\texpectError:  false,\n\t\t\tfqdnTemplate: \"{{.Name}}-{{.Namespace}}.ext-dns.test.com\",\n\t\t},\n\t\t{\n\t\t\ttitle:            \"non-empty annotation filter label\",\n\t\t\texpectError:      false,\n\t\t\tannotationFilter: \"kubernetes.io/ingress.class=nginx\",\n\t\t},\n\t\t{\n\t\t\ttitle:       \"valid label selector\",\n\t\t\texpectError: false,\n\t\t\tlabelFilter: \"app=web-external\",\n\t\t},\n\t} {\n\n\t\tlabelSelector, err := labels.Parse(ti.labelFilter)\n\t\trequire.NoError(t, err)\n\t\tt.Run(ti.title, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\t_, err := NewOcpRouteSource(\n\t\t\t\tt.Context(),\n\t\t\t\tfake.NewClientset(),\n\t\t\t\t&Config{\n\t\t\t\t\tAnnotationFilter: ti.annotationFilter,\n\t\t\t\t\tFQDNTemplate:     ti.fqdnTemplate,\n\t\t\t\t\tLabelFilter:      labelSelector,\n\t\t\t\t},\n\t\t\t)\n\n\t\t\tif ti.expectError {\n\t\t\t\tassert.Error(t, err)\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// testOcpRouteSourceEndpoints tests that various OCP routes generate the correct endpoints.\nfunc testOcpRouteSourceEndpoints(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\ttitle         string\n\t\tocpRoute      *routev1.Route\n\t\texpected      []*endpoint.Endpoint\n\t\texpectError   bool\n\t\tlabelFilter   string\n\t\tocpRouterName string\n\t}{\n\t\t{\n\t\t\ttitle: \"route with basic hostname and route status target\",\n\t\t\tocpRoute: &routev1.Route{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\tName:      \"route-with-target\",\n\t\t\t\t},\n\t\t\t\tStatus: routev1.RouteStatus{\n\t\t\t\t\tIngress: []routev1.RouteIngress{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tHost:                    \"my-domain.com\",\n\t\t\t\t\t\t\tRouterCanonicalHostname: \"apps.my-domain.com\",\n\t\t\t\t\t\t\tConditions: []routev1.RouteIngressCondition{\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tType:   routev1.RouteAdmitted,\n\t\t\t\t\t\t\t\t\tStatus: corev1.ConditionTrue,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"my-domain.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets: []string{\n\t\t\t\t\t\t\"apps.my-domain.com\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"route with basic hostname, route status target and ocpRouterName defined\",\n\t\t\tocpRoute: &routev1.Route{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\tName:      \"route-with-target\",\n\t\t\t\t},\n\t\t\t\tStatus: routev1.RouteStatus{\n\t\t\t\t\tIngress: []routev1.RouteIngress{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tHost:                    \"my-domain.com\",\n\t\t\t\t\t\t\tRouterName:              \"default\",\n\t\t\t\t\t\t\tRouterCanonicalHostname: \"router-default.my-domain.com\",\n\t\t\t\t\t\t\tConditions: []routev1.RouteIngressCondition{\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tType:   routev1.RouteAdmitted,\n\t\t\t\t\t\t\t\t\tStatus: corev1.ConditionTrue,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tocpRouterName: \"default\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"my-domain.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets: []string{\n\t\t\t\t\t\t\"router-default.my-domain.com\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"route with basic hostname, route status target, one ocpRouterName and two router canonical names\",\n\t\t\tocpRoute: &routev1.Route{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\tName:      \"route-with-target\",\n\t\t\t\t},\n\t\t\t\tStatus: routev1.RouteStatus{\n\t\t\t\t\tIngress: []routev1.RouteIngress{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tHost:                    \"my-domain.com\",\n\t\t\t\t\t\t\tRouterName:              \"default\",\n\t\t\t\t\t\t\tRouterCanonicalHostname: \"router-default.my-domain.com\",\n\t\t\t\t\t\t\tConditions: []routev1.RouteIngressCondition{\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tType:   routev1.RouteAdmitted,\n\t\t\t\t\t\t\t\t\tStatus: corev1.ConditionTrue,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tHost:                    \"my-domain.com\",\n\t\t\t\t\t\t\tRouterName:              \"test\",\n\t\t\t\t\t\t\tRouterCanonicalHostname: \"router-test.my-domain.com\",\n\t\t\t\t\t\t\tConditions: []routev1.RouteIngressCondition{\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tType:   routev1.RouteAdmitted,\n\t\t\t\t\t\t\t\t\tStatus: corev1.ConditionTrue,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tocpRouterName: \"default\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"my-domain.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets: []string{\n\t\t\t\t\t\t\"router-default.my-domain.com\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"route not admitted by the given router\",\n\t\t\tocpRoute: &routev1.Route{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\tName:      \"route-with-target\",\n\t\t\t\t},\n\t\t\t\tStatus: routev1.RouteStatus{\n\t\t\t\t\tIngress: []routev1.RouteIngress{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tHost:                    \"my-domain.com\",\n\t\t\t\t\t\t\tRouterName:              \"default\",\n\t\t\t\t\t\t\tRouterCanonicalHostname: \"router-default.my-domain.com\",\n\t\t\t\t\t\t\tConditions: []routev1.RouteIngressCondition{\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tType:   routev1.RouteAdmitted,\n\t\t\t\t\t\t\t\t\tStatus: corev1.ConditionTrue,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tHost:                    \"my-domain.com\",\n\t\t\t\t\t\t\tRouterName:              \"test\",\n\t\t\t\t\t\t\tRouterCanonicalHostname: \"router-test.my-domain.com\",\n\t\t\t\t\t\t\tConditions: []routev1.RouteIngressCondition{\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tType:   routev1.RouteAdmitted,\n\t\t\t\t\t\t\t\t\tStatus: corev1.ConditionFalse,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tocpRouterName: \"test\",\n\t\t\texpected:      []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle: \"route not admitted by any router\",\n\t\t\tocpRoute: &routev1.Route{\n\t\t\t\tSpec: routev1.RouteSpec{\n\t\t\t\t\tHost: \"my-domain.com\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\tName:      \"route-with-target\",\n\t\t\t\t},\n\t\t\t\tStatus: routev1.RouteStatus{\n\t\t\t\t\tIngress: []routev1.RouteIngress{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tHost:                    \"my-domain.com\",\n\t\t\t\t\t\t\tRouterName:              \"default\",\n\t\t\t\t\t\t\tRouterCanonicalHostname: \"router-default.my-domain.com\",\n\t\t\t\t\t\t\tConditions: []routev1.RouteIngressCondition{\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tType:   routev1.RouteAdmitted,\n\t\t\t\t\t\t\t\t\tStatus: corev1.ConditionFalse,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tHost:                    \"my-domain.com\",\n\t\t\t\t\t\t\tRouterName:              \"test\",\n\t\t\t\t\t\t\tRouterCanonicalHostname: \"router-test.my-domain.com\",\n\t\t\t\t\t\t\tConditions: []routev1.RouteIngressCondition{\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tType:   routev1.RouteAdmitted,\n\t\t\t\t\t\t\t\t\tStatus: corev1.ConditionFalse,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle: \"route admitted by first appropriate router\",\n\t\t\tocpRoute: &routev1.Route{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\tName:      \"route-with-target\",\n\t\t\t\t},\n\t\t\t\tStatus: routev1.RouteStatus{\n\t\t\t\t\tIngress: []routev1.RouteIngress{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tHost:                    \"my-domain.com\",\n\t\t\t\t\t\t\tRouterName:              \"default\",\n\t\t\t\t\t\t\tRouterCanonicalHostname: \"router-default.my-domain.com\",\n\t\t\t\t\t\t\tConditions: []routev1.RouteIngressCondition{\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tType:   routev1.RouteAdmitted,\n\t\t\t\t\t\t\t\t\tStatus: corev1.ConditionFalse,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tHost:                    \"my-domain.com\",\n\t\t\t\t\t\t\tRouterName:              \"test\",\n\t\t\t\t\t\t\tRouterCanonicalHostname: \"router-test.my-domain.com\",\n\t\t\t\t\t\t\tConditions: []routev1.RouteIngressCondition{\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tType:   routev1.RouteAdmitted,\n\t\t\t\t\t\t\t\t\tStatus: corev1.ConditionTrue,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"my-domain.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets: []string{\n\t\t\t\t\t\t\"router-test.my-domain.com\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"route with incorrect externalDNS controller annotation\",\n\t\t\tocpRoute: &routev1.Route{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\tName:      \"route-with-ignore-annotation\",\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/controller\": \"foo\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle: \"route with basic hostname and annotation target\",\n\t\t\tocpRoute: &routev1.Route{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\tName:      \"route-with-annotation-target\",\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\": \"my.site.foo.com\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: routev1.RouteStatus{\n\t\t\t\t\tIngress: []routev1.RouteIngress{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tHost:                    \"my-annotation-domain.com\",\n\t\t\t\t\t\t\tRouterName:              \"default\",\n\t\t\t\t\t\t\tRouterCanonicalHostname: \"router-default.my-domain.com\",\n\t\t\t\t\t\t\tConditions: []routev1.RouteIngressCondition{\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tType:   routev1.RouteAdmitted,\n\t\t\t\t\t\t\t\t\tStatus: corev1.ConditionTrue,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"my-annotation-domain.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets: []string{\n\t\t\t\t\t\t\"my.site.foo.com\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:       \"route with matching labels\",\n\t\t\tlabelFilter: \"app=web-external\",\n\t\t\tocpRoute: &routev1.Route{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\tName:      \"route-with-matching-labels\",\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\": \"my.site.foo.com\",\n\t\t\t\t\t},\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\"app\":  \"web-external\",\n\t\t\t\t\t\t\"name\": \"service-frontend\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: routev1.RouteStatus{\n\t\t\t\t\tIngress: []routev1.RouteIngress{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tHost:                    \"my-annotation-domain.com\",\n\t\t\t\t\t\t\tRouterName:              \"default\",\n\t\t\t\t\t\t\tRouterCanonicalHostname: \"router-default.my-domain.com\",\n\t\t\t\t\t\t\tConditions: []routev1.RouteIngressCondition{\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tType:   routev1.RouteAdmitted,\n\t\t\t\t\t\t\t\t\tStatus: corev1.ConditionTrue,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"my-annotation-domain.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets: []string{\n\t\t\t\t\t\t\"my.site.foo.com\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:       \"route without matching labels\",\n\t\t\tlabelFilter: \"app=web-external\",\n\t\t\tocpRoute: &routev1.Route{\n\t\t\t\tSpec: routev1.RouteSpec{\n\t\t\t\t\tHost: \"my-annotation-domain.com\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\tName:      \"route-without-matching-labels\",\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\": \"my.site.foo.com\",\n\t\t\t\t\t},\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\"app\":  \"web-internal\",\n\t\t\t\t\t\t\"name\": \"service-frontend\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle: \"route with provider-specific annotation\",\n\t\t\tocpRoute: &routev1.Route{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\tName:      \"route-with-provider-specific\",\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\tannotations.AWSPrefix + \"weight\": \"10\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: routev1.RouteStatus{\n\t\t\t\t\tIngress: []routev1.RouteIngress{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tHost:                    \"my-domain.com\",\n\t\t\t\t\t\t\tRouterCanonicalHostname: \"apps.my-domain.com\",\n\t\t\t\t\t\t\tConditions: []routev1.RouteIngressCondition{\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tType:   routev1.RouteAdmitted,\n\t\t\t\t\t\t\t\t\tStatus: corev1.ConditionTrue,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"my-domain.com\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    []string{\"apps.my-domain.com\"},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{Name: \"aws/weight\", Value: \"10\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(tc.title, func(t *testing.T) {\n\t\t\tt.Parallel()\n\t\t\t// Create a Kubernetes testing client\n\t\t\tfakeClient := fake.NewClientset()\n\t\t\t_, err := fakeClient.RouteV1().Routes(tc.ocpRoute.Namespace).Create(t.Context(), tc.ocpRoute, metav1.CreateOptions{})\n\t\t\trequire.NoError(t, err)\n\n\t\t\tlabelSelector, err := labels.Parse(tc.labelFilter)\n\t\t\trequire.NoError(t, err)\n\n\t\t\tsource, err := NewOcpRouteSource(\n\t\t\t\tt.Context(),\n\t\t\t\tfakeClient,\n\t\t\t\t&Config{\n\t\t\t\t\tFQDNTemplate:  \"{{.Name}}\",\n\t\t\t\t\tLabelFilter:   labelSelector,\n\t\t\t\t\tOCPRouterName: tc.ocpRouterName,\n\t\t\t\t},\n\t\t\t)\n\t\t\trequire.NoError(t, err)\n\n\t\t\tres, err := source.Endpoints(t.Context())\n\t\t\tif tc.expectError {\n\t\t\t\trequire.Error(t, err)\n\t\t\t} else {\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\t// Validate returned endpoints against desired endpoints.\n\t\t\tvalidateEndpoints(t, res, tc.expected)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "source/pod.go",
    "content": "/*\nCopyright 2021 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\n// TODO:\n// support\n// - set-identifier for endpoints created\n// - set resource aka fmt.Sprintf(\"pod/%s/%s\", pod.Namespace, pod.Name)\npackage source\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"text/template\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\tcorev1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\n\tkubeinformers \"k8s.io/client-go/informers\"\n\tcoreinformers \"k8s.io/client-go/informers/core/v1\"\n\t\"k8s.io/client-go/kubernetes\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/pkg/events\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n\t\"sigs.k8s.io/external-dns/source/fqdn\"\n\t\"sigs.k8s.io/external-dns/source/informers\"\n\t\"sigs.k8s.io/external-dns/source/types\"\n)\n\n// podSource is an implementation of Source for Kubernetes Pod objects.\n//\n// +externaldns:source:name=pod\n// +externaldns:source:category=Kubernetes Core\n// +externaldns:source:description=Creates DNS entries based on Kubernetes Pod resources\n// +externaldns:source:resources=Pod\n// +externaldns:source:filters=annotation,label\n// +externaldns:source:namespace=all,single\n// +externaldns:source:fqdn-template=true\n// +externaldns:source:provider-specific=false\n// +externaldns:source:events=true\ntype podSource struct {\n\tclient                kubernetes.Interface\n\tnamespace             string\n\tfqdnTemplate          *template.Template\n\tcombineFQDNAnnotation bool\n\n\tpodInformer              coreinformers.PodInformer\n\tnodeInformer             coreinformers.NodeInformer\n\tcompatibility            string\n\tignoreNonHostNetworkPods bool\n\tpodSourceDomain          string\n}\n\n// NewPodSource creates a new podSource with the given config.\nfunc NewPodSource(\n\tctx context.Context,\n\tkubeClient kubernetes.Interface,\n\tcfg *Config,\n) (Source, error) {\n\tnamespace := cfg.Namespace\n\tannotationFilter := cfg.AnnotationFilter\n\tlabelSelector := cfg.LabelFilter\n\n\tinformerFactory := kubeinformers.NewSharedInformerFactoryWithOptions(kubeClient, 0, kubeinformers.WithNamespace(namespace))\n\tpodInformer := informerFactory.Core().V1().Pods()\n\tnodeInformer := informerFactory.Core().V1().Nodes()\n\n\terr := podInformer.Informer().AddIndexers(informers.IndexerWithOptions[*corev1.Pod](\n\t\tinformers.IndexSelectorWithAnnotationFilter(annotationFilter),\n\t\tinformers.IndexSelectorWithLabelSelector(labelSelector),\n\t))\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to add indexers to pod informer: %w\", err)\n\t}\n\n\t_, _ = podInformer.Informer().AddEventHandler(informers.DefaultEventHandler())\n\n\tif cfg.FQDNTemplate == \"\" {\n\t\t// Transformer is used to reduce the memory usage of the informer.\n\t\t// The pod informer will otherwise store a full in-memory, go-typed copy of all pod schemas in the cluster.\n\t\t// If watchList is not used it will not prevent memory bursts on the initial informer sync.\n\t\t// When fqdnTemplate is used the entire pod needs to be provided to the rendering call, but the informer itself becomes unneeded.\n\t\t_ = podInformer.Informer().SetTransform(func(i any) (any, error) {\n\t\t\tpod, ok := i.(*corev1.Pod)\n\t\t\tif !ok {\n\t\t\t\treturn nil, fmt.Errorf(\"object is not a pod\")\n\t\t\t}\n\t\t\t// UID is retained so that event correlation works; the transform\n\t\t\t// is idempotent by construction.\n\t\t\treturn &corev1.Pod{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t// Name/namespace must always be kept for the informer to work.\n\t\t\t\t\tName:      pod.Name,\n\t\t\t\t\tNamespace: pod.Namespace,\n\t\t\t\t\t// Used by the controller. This includes non-external-dns prefixed annotations.\n\t\t\t\t\tAnnotations: pod.Annotations,\n\t\t\t\t\tUID:         pod.UID,\n\t\t\t\t},\n\t\t\t\tSpec: corev1.PodSpec{\n\t\t\t\t\tHostNetwork: pod.Spec.HostNetwork,\n\t\t\t\t\tNodeName:    pod.Spec.NodeName,\n\t\t\t\t},\n\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\tPodIP: pod.Status.PodIP,\n\t\t\t\t},\n\t\t\t}, nil\n\t\t})\n\t}\n\n\t_, _ = nodeInformer.Informer().AddEventHandler(informers.DefaultEventHandler())\n\n\tinformerFactory.Start(ctx.Done())\n\n\t// wait for the local cache to be populated.\n\tif err := informers.WaitForCacheSync(ctx, informerFactory); err != nil {\n\t\treturn nil, err\n\t}\n\n\ttmpl, err := fqdn.ParseTemplate(cfg.FQDNTemplate)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &podSource{\n\t\tclient:                   kubeClient,\n\t\tpodInformer:              podInformer,\n\t\tnodeInformer:             nodeInformer,\n\t\tnamespace:                namespace,\n\t\tcompatibility:            cfg.Compatibility,\n\t\tignoreNonHostNetworkPods: cfg.IgnoreNonHostNetworkPods,\n\t\tpodSourceDomain:          cfg.PodSourceDomain,\n\t\tfqdnTemplate:             tmpl,\n\t\tcombineFQDNAnnotation:    cfg.CombineFQDNAndAnnotation,\n\t}, nil\n}\n\nfunc (ps *podSource) AddEventHandler(_ context.Context, handler func()) {\n\t_, _ = ps.podInformer.Informer().AddEventHandler(eventHandlerFunc(handler))\n}\n\nfunc (ps *podSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) {\n\tindexKeys := ps.podInformer.Informer().GetIndexer().ListIndexFuncValues(informers.IndexWithSelectors)\n\n\tendpoints := make([]*endpoint.Endpoint, 0)\n\tfor _, key := range indexKeys {\n\t\tpod, err := informers.GetByKey[*corev1.Pod](ps.podInformer.Informer().GetIndexer(), key)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tpodEndpoints := ps.endpointsFromPodAnnotations(pod)\n\n\t\tpodEndpoints, err = fqdn.CombineWithTemplatedEndpoints(\n\t\t\tpodEndpoints,\n\t\t\tps.fqdnTemplate,\n\t\t\tps.combineFQDNAnnotation,\n\t\t\tfunc() ([]*endpoint.Endpoint, error) { return ps.endpointsFromPodTemplate(pod) },\n\t\t)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tendpoint.AttachRefObject(podEndpoints, events.NewObjectReference(pod, types.Pod))\n\n\t\tendpoints = append(endpoints, podEndpoints...)\n\t}\n\n\treturn MergeEndpoints(endpoints), nil\n}\n\nfunc (ps *podSource) endpointsFromPodAnnotations(pod *corev1.Pod) []*endpoint.Endpoint {\n\tendpointMap := make(map[endpoint.EndpointKey][]string)\n\tps.addPodEndpointsToEndpointMap(endpointMap, pod)\n\n\tvar endpoints []*endpoint.Endpoint\n\tfor key, targets := range endpointMap {\n\t\tendpoints = append(endpoints, endpoint.NewEndpointWithTTL(key.DNSName, key.RecordType, key.RecordTTL, targets...))\n\t}\n\treturn endpoints\n}\n\nfunc (ps *podSource) endpointsFromPodTemplate(pod *corev1.Pod) ([]*endpoint.Endpoint, error) {\n\thostsMap, err := ps.hostsFromTemplate(pod)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar endpoints []*endpoint.Endpoint\n\tfor key, targets := range hostsMap {\n\t\tendpoints = append(endpoints, endpoint.NewEndpointWithTTL(key.DNSName, key.RecordType, key.RecordTTL, targets...))\n\t}\n\treturn endpoints, nil\n}\n\nfunc (ps *podSource) addPodEndpointsToEndpointMap(endpointMap map[endpoint.EndpointKey][]string, pod *corev1.Pod) {\n\tif ps.ignoreNonHostNetworkPods && !pod.Spec.HostNetwork {\n\t\tlog.Debugf(\"skipping pod %s. hostNetwork=false\", pod.Name)\n\t\treturn\n\t}\n\n\ttargets := annotations.TargetsFromTargetAnnotation(pod.Annotations)\n\n\tps.addInternalHostnameAnnotationEndpoints(endpointMap, pod, targets)\n\tps.addHostnameAnnotationEndpoints(endpointMap, pod, targets)\n\tps.addKopsDNSControllerEndpoints(endpointMap, pod)\n\tps.addPodSourceDomainEndpoints(endpointMap, pod, targets)\n}\n\nfunc (ps *podSource) addInternalHostnameAnnotationEndpoints(endpointMap map[endpoint.EndpointKey][]string, pod *corev1.Pod, targets []string) {\n\tif domainAnnotation, ok := pod.Annotations[annotations.InternalHostnameKey]; ok {\n\t\tdomainList := annotations.SplitHostnameAnnotation(domainAnnotation)\n\t\tfor _, domain := range domainList {\n\t\t\tif len(targets) == 0 {\n\t\t\t\taddToEndpointMap(endpointMap, pod, domain, endpoint.SuitableType(pod.Status.PodIP), pod.Status.PodIP)\n\t\t\t} else {\n\t\t\t\taddTargetsToEndpointMap(endpointMap, pod, targets, domain)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (ps *podSource) addHostnameAnnotationEndpoints(endpointMap map[endpoint.EndpointKey][]string, pod *corev1.Pod, targets []string) {\n\tif domainAnnotation, ok := pod.Annotations[annotations.HostnameKey]; ok {\n\t\tdomainList := annotations.SplitHostnameAnnotation(domainAnnotation)\n\t\tif len(targets) == 0 {\n\t\t\tps.addPodNodeEndpointsToEndpointMap(endpointMap, pod, domainList)\n\t\t} else {\n\t\t\taddTargetsToEndpointMap(endpointMap, pod, targets, domainList...)\n\t\t}\n\t}\n}\n\nfunc (ps *podSource) addKopsDNSControllerEndpoints(endpointMap map[endpoint.EndpointKey][]string, pod *corev1.Pod) {\n\tif ps.compatibility == \"kops-dns-controller\" {\n\t\tif domainAnnotation, ok := pod.Annotations[kopsDNSControllerInternalHostnameAnnotationKey]; ok {\n\t\t\tdomainList := annotations.SplitHostnameAnnotation(domainAnnotation)\n\t\t\tfor _, domain := range domainList {\n\t\t\t\taddToEndpointMap(endpointMap, pod, domain, endpoint.SuitableType(pod.Status.PodIP), pod.Status.PodIP)\n\t\t\t}\n\t\t}\n\n\t\tif domainAnnotation, ok := pod.Annotations[kopsDNSControllerHostnameAnnotationKey]; ok {\n\t\t\tdomainList := annotations.SplitHostnameAnnotation(domainAnnotation)\n\t\t\tps.addPodNodeEndpointsToEndpointMap(endpointMap, pod, domainList)\n\t\t}\n\t}\n}\n\nfunc (ps *podSource) addPodSourceDomainEndpoints(endpointMap map[endpoint.EndpointKey][]string, pod *corev1.Pod, targets []string) {\n\tif ps.podSourceDomain != \"\" {\n\t\tdomain := pod.Name + \".\" + ps.podSourceDomain\n\t\tif len(targets) == 0 {\n\t\t\taddToEndpointMap(endpointMap, pod, domain, endpoint.SuitableType(pod.Status.PodIP), pod.Status.PodIP)\n\t\t}\n\t\taddTargetsToEndpointMap(endpointMap, pod, targets, domain)\n\t}\n}\n\nfunc (ps *podSource) addPodNodeEndpointsToEndpointMap(endpointMap map[endpoint.EndpointKey][]string, pod *corev1.Pod, domainList []string) {\n\tnode, err := ps.nodeInformer.Lister().Get(pod.Spec.NodeName)\n\tif err != nil {\n\t\tlog.Debugf(\"Get node[%s] of pod[%s] error: %v; ignoring\", pod.Spec.NodeName, pod.GetName(), err)\n\t\treturn\n\t}\n\tfor _, domain := range domainList {\n\t\tfor _, address := range node.Status.Addresses {\n\t\t\trecordType := endpoint.SuitableType(address.Address)\n\t\t\t// IPv6 addresses are labeled as NodeInternalIP despite being usable externally as well.\n\t\t\tif address.Type == corev1.NodeExternalIP || (address.Type == corev1.NodeInternalIP && recordType == endpoint.RecordTypeAAAA) {\n\t\t\t\taddToEndpointMap(endpointMap, pod, domain, recordType, address.Address)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (ps *podSource) hostsFromTemplate(pod *corev1.Pod) (map[endpoint.EndpointKey][]string, error) {\n\thosts, err := fqdn.ExecTemplate(ps.fqdnTemplate, pod)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"skipping generating endpoints from template for pod %s: %w\", pod.Name, err)\n\t}\n\n\tresult := make(map[endpoint.EndpointKey][]string)\n\tfor _, target := range hosts {\n\t\tfor _, address := range pod.Status.PodIPs {\n\t\t\tif address.IP == \"\" {\n\t\t\t\tlog.Debugf(\"skipping pod %q. PodIP is empty with phase %q\", pod.Name, pod.Status.Phase)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tkey := endpoint.EndpointKey{\n\t\t\t\tDNSName:    target,\n\t\t\t\tRecordType: endpoint.SuitableType(address.IP),\n\t\t\t\tRecordTTL:  annotations.TTLFromAnnotations(pod.Annotations, fmt.Sprintf(\"pod/%s\", pod.Name)),\n\t\t\t}\n\t\t\tresult[key] = append(result[key], address.IP)\n\t\t}\n\t}\n\n\treturn result, nil\n}\n\nfunc addTargetsToEndpointMap(endpointMap map[endpoint.EndpointKey][]string, pod *corev1.Pod, targets []string, domainList ...string) {\n\tfor _, domain := range domainList {\n\t\tfor _, target := range targets {\n\t\t\taddToEndpointMap(endpointMap, pod, domain, endpoint.SuitableType(target), target)\n\t\t}\n\t}\n}\n\nfunc addToEndpointMap(endpointMap map[endpoint.EndpointKey][]string, pod *corev1.Pod, domain string, recordType string, address string) {\n\tkey := endpoint.EndpointKey{\n\t\tDNSName:    domain,\n\t\tRecordType: recordType,\n\t\tRecordTTL:  annotations.TTLFromAnnotations(pod.Annotations, fmt.Sprintf(\"pod/%s\", pod.Name)),\n\t}\n\tif _, ok := endpointMap[key]; !ok {\n\t\tendpointMap[key] = []string{}\n\t}\n\tendpointMap[key] = append(endpointMap[key], address)\n}\n"
  },
  {
    "path": "source/pod_fqdn_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\tv1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/client-go/kubernetes/fake\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\nfunc TestNewPodSourceWithFqdn(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\ttitle            string\n\t\tannotationFilter string\n\t\tfqdnTemplate     string\n\t\texpectError      bool\n\t}{\n\t\t{\n\t\t\ttitle:        \"invalid template\",\n\t\t\texpectError:  true,\n\t\t\tfqdnTemplate: \"{{.Name\",\n\t\t},\n\t\t{\n\t\t\ttitle:       \"valid empty template\",\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\ttitle:        \"valid template\",\n\t\t\texpectError:  false,\n\t\t\tfqdnTemplate: \"{{.Name}}-{{.Namespace}}.ext-dns.test.com\",\n\t\t},\n\t} {\n\t\tt.Run(tt.title, func(t *testing.T) {\n\t\t\t_, err := NewPodSource(\n\t\t\t\tt.Context(),\n\t\t\t\tfake.NewClientset(),\n\t\t\t\t&Config{\n\t\t\t\t\tFQDNTemplate: tt.fqdnTemplate,\n\t\t\t\t})\n\n\t\t\tif tt.expectError {\n\t\t\t\tassert.Error(t, err)\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPodSourceFqdnTemplatingExamples(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\ttitle        string\n\t\tpods         []*v1.Pod\n\t\tnodes        []*v1.Node\n\t\tfqdnTemplate string\n\t\texpected     []*endpoint.Endpoint\n\t\tcombineFQDN  bool\n\t\tsourceDomain string\n\t}{\n\t\t{\n\t\t\ttitle: \"templating expansion with multiple domains\",\n\t\t\tpods: []*v1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-pod-1\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.PodStatus{\n\t\t\t\t\t\tPodIP: \"100.67.94.101\",\n\t\t\t\t\t\tPodIPs: []v1.PodIP{\n\t\t\t\t\t\t\t{IP: \"100.67.94.101\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{ .Name }}.domainA.com,{{ .Name }}.domainB.com\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"my-pod-1.domainA.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"100.67.94.101\"}},\n\t\t\t\t{DNSName: \"my-pod-1.domainB.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"100.67.94.101\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"templating expansion with multiple domains and fqdn combine and pod source domain\",\n\t\t\tcombineFQDN:  true,\n\t\t\tsourceDomain: \"example.org\",\n\t\t\tpods: []*v1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-pod-1\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.PodSpec{\n\t\t\t\t\t\tNodeName: \"node-1.internal\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.PodStatus{\n\t\t\t\t\t\tPodIP: \"100.67.94.101\",\n\t\t\t\t\t\tPodIPs: []v1.PodIP{\n\t\t\t\t\t\t\t{IP: \"100.67.94.101\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnodes: []*v1.Node{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName: \"node-1.internal\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"10.1.192.139\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{ .Name }}.domainA.com,{{ .Name }}.domainB.com\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"my-pod-1.domainA.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"100.67.94.101\"}},\n\t\t\t\t{DNSName: \"my-pod-1.domainB.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"100.67.94.101\"}},\n\t\t\t\t{DNSName: \"my-pod-1.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"100.67.94.101\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"templating with domain per namespace\",\n\t\t\tpods: []*v1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"pod-1\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.PodStatus{\n\t\t\t\t\t\tPodIP: \"100.67.94.101\",\n\t\t\t\t\t\tPodIPs: []v1.PodIP{\n\t\t\t\t\t\t\t{IP: \"100.67.94.101\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"pod-2\",\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.PodStatus{\n\t\t\t\t\t\tPodIP: \"100.67.94.102\",\n\t\t\t\t\t\tPodIPs: []v1.PodIP{\n\t\t\t\t\t\t\t{IP: \"100.67.94.102\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{ .Name }}.{{ .Namespace }}.example.org\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"pod-1.default.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"100.67.94.101\"}},\n\t\t\t\t{DNSName: \"pod-2.kube-system.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"100.67.94.102\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"templating with pod and multiple ips for types A and AAAA\",\n\t\t\tpods: []*v1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"pod-1\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.PodStatus{\n\t\t\t\t\t\tPodIP: \"100.67.94.101\",\n\t\t\t\t\t\tPodIPs: []v1.PodIP{\n\t\t\t\t\t\t\t{IP: \"100.67.94.101\"},\n\t\t\t\t\t\t\t{IP: \"2041:0000:140F::875B:131B\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{ .Name }}.example.org\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"pod-1.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"100.67.94.101\"}},\n\t\t\t\t{DNSName: \"pod-1.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2041:0000:140F::875B:131B\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"templating with pod and target annotation that is currently not overriding target IPs\",\n\t\t\tpods: []*v1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"pod-1\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\": \"203.2.45.22\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.PodStatus{\n\t\t\t\t\t\tPodIP: \"100.67.94.101\",\n\t\t\t\t\t\tPodIPs: []v1.PodIP{\n\t\t\t\t\t\t\t{IP: \"100.67.94.101\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{ .Name }}.example.org\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"pod-1.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"100.67.94.101\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"templating with pod and host annotation that is currently not overriding hostname\",\n\t\t\tpods: []*v1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"pod-1\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"ip-10-1-176-1.internal.domain.com\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.PodStatus{\n\t\t\t\t\t\tPodIP: \"100.67.94.101\",\n\t\t\t\t\t\tPodIPs: []v1.PodIP{\n\t\t\t\t\t\t\t{IP: \"100.67.94.101\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{ .Name }}.example.org\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"pod-1.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"100.67.94.101\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"templating with simple annotation expansion\",\n\t\t\tpods: []*v1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"pod-1\",\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\t\"workload\": \"cluster-resources\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.PodStatus{\n\t\t\t\t\t\tPodIP: \"100.67.94.101\",\n\t\t\t\t\t\tPodIPs: []v1.PodIP{\n\t\t\t\t\t\t\t{IP: \"100.67.94.101\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"pod-2\",\n\t\t\t\t\t\tNamespace: \"workloads\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\t\"workload\": \"workloads\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.PodStatus{\n\t\t\t\t\t\tPodIP: \"100.67.94.102\",\n\t\t\t\t\t\tPodIPs: []v1.PodIP{\n\t\t\t\t\t\t\t{IP: \"100.67.94.102\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{ .Name }}.{{ .Annotations.workload }}.domain.tld\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"pod-1.cluster-resources.domain.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"100.67.94.101\"}},\n\t\t\t\t{DNSName: \"pod-2.workloads.domain.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"100.67.94.102\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"templating with complex label expansion\",\n\t\t\tpods: []*v1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"pod-1\",\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\t\"topology.kubernetes.io/region\": \"eu-west-1a\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.PodStatus{\n\t\t\t\t\t\tPodIP: \"100.67.94.101\",\n\t\t\t\t\t\tPodIPs: []v1.PodIP{\n\t\t\t\t\t\t\t{IP: \"100.67.94.101\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"pod-2\",\n\t\t\t\t\t\tNamespace: \"workloads\",\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\t\"topology.kubernetes.io/region\": \"eu-west-1b\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.PodStatus{\n\t\t\t\t\t\tPodIP: \"100.67.94.102\",\n\t\t\t\t\t\tPodIPs: []v1.PodIP{\n\t\t\t\t\t\t\t{IP: \"100.67.94.102\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{ .Name }}.{{ index .ObjectMeta.Labels \\\"topology.kubernetes.io/region\\\" }}.domain.tld\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"pod-1.eu-west-1a.domain.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"100.67.94.101\"}},\n\t\t\t\t{DNSName: \"pod-2.eu-west-1b.domain.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"100.67.94.102\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"templating with shared all domain\",\n\t\t\tpods: []*v1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"pod-1\",\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.PodStatus{\n\t\t\t\t\t\tPodIP: \"100.67.94.101\",\n\t\t\t\t\t\tPodIPs: []v1.PodIP{\n\t\t\t\t\t\t\t{IP: \"100.67.94.101\"},\n\t\t\t\t\t\t\t{IP: \"100.67.94.102\"},\n\t\t\t\t\t\t\t{IP: \"100.67.94.103\"},\n\t\t\t\t\t\t\t{IP: \"2041:0000:140F::875B:131B\"},\n\t\t\t\t\t\t\t{IP: \"::11.22.33.44\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"pods-all.domain.tld\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"pods-all.domain.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"100.67.94.101\", \"100.67.94.102\", \"100.67.94.103\"}},\n\t\t\t\t{DNSName: \"pods-all.domain.tld\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2041:0000:140F::875B:131B\", \"::11.22.33.44\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"templating with fqdn template and IP not set as pod failed\",\n\t\t\tpods: []*v1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"pod-1\",\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.PodStatus{\n\t\t\t\t\t\tPhase: v1.PodRunning,\n\t\t\t\t\t\tPodIP: \"100.67.94.101\",\n\t\t\t\t\t\tPodIPs: []v1.PodIP{\n\t\t\t\t\t\t\t{IP: \"100.67.94.101\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"pod-2\",\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.PodStatus{\n\t\t\t\t\t\tPhase: v1.PodFailed,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{ .Name }}.domain.tld\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"pod-1.domain.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"100.67.94.101\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"fqdn templating with label conditional and kind check\",\n\t\t\tfqdnTemplate: `{{ if eq .Kind \"Pod\" }}{{ range $k, $v := .Labels }}{{ if and (contains $k \"app\")\n\t\t\t\t(contains $v \"my-service-\") }}{{ $.Name }}.{{ $v }}.pod.tld.org{{ printf \",\" }}{{ end }}{{ end }}{{ end }}`,\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"pod-1.my-service-1.pod.tld.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"100.67.94.101\"}},\n\t\t\t\t{DNSName: \"pod-2.my-service-2.pod.tld.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"100.67.94.102\"}},\n\t\t\t},\n\t\t\tpods: []*v1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"pod-1\",\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\t\"app1\": \"my-service-1\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.PodStatus{\n\t\t\t\t\t\tPhase: v1.PodRunning,\n\t\t\t\t\t\tPodIP: \"100.67.94.101\",\n\t\t\t\t\t\tPodIPs: []v1.PodIP{\n\t\t\t\t\t\t\t{IP: \"100.67.94.101\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"pod-2\",\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\t\"app2\": \"my-service-2\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.PodStatus{\n\t\t\t\t\t\tPhase: v1.PodRunning,\n\t\t\t\t\t\tPodIPs: []v1.PodIP{\n\t\t\t\t\t\t\t{IP: \"100.67.94.102\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(tt.title, func(t *testing.T) {\n\t\t\tkubeClient := fake.NewClientset()\n\n\t\t\tfor _, node := range tt.nodes {\n\t\t\t\t_, err := kubeClient.CoreV1().Nodes().Create(t.Context(), node, metav1.CreateOptions{})\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\tfor _, pod := range tt.pods {\n\t\t\t\t_, err := kubeClient.CoreV1().Pods(pod.Namespace).Create(t.Context(), pod, metav1.CreateOptions{})\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\tsrc, err := NewPodSource(\n\t\t\t\tt.Context(),\n\t\t\t\tkubeClient,\n\t\t\t\t&Config{\n\t\t\t\t\tFQDNTemplate:             tt.fqdnTemplate,\n\t\t\t\t\tCombineFQDNAndAnnotation: tt.combineFQDN,\n\t\t\t\t\tPodSourceDomain:          tt.sourceDomain,\n\t\t\t\t})\n\t\t\trequire.NoError(t, err)\n\n\t\t\tendpoints, err := src.Endpoints(t.Context())\n\t\t\trequire.NoError(t, err)\n\n\t\t\tvalidateEndpoints(t, endpoints, tt.expected)\n\t\t})\n\t}\n}\n\nfunc TestPodSourceFqdnTemplatingExamples_Failed(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\ttitle        string\n\t\tpods         []*v1.Pod\n\t\tnodes        []*v1.Node\n\t\tfqdnTemplate string\n\t\texpected     []*endpoint.Endpoint\n\t\tcombineFQDN  bool\n\t\tsourceDomain string\n\t}{\n\t\t{\n\t\t\ttitle: \"templating with fqdn template correct but value does not exist\",\n\t\t\tpods: []*v1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"pod-1\",\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.PodStatus{\n\t\t\t\t\t\tPodIP: \"100.67.94.101\",\n\t\t\t\t\t\tPodIPs: []v1.PodIP{\n\t\t\t\t\t\t\t{IP: \"100.67.94.101\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{ .Name }}.{{ .ThisNotExist }}.domain.tld\",\n\t\t\texpected:     []*endpoint.Endpoint{},\n\t\t},\n\t} {\n\t\tt.Run(tt.title, func(t *testing.T) {\n\t\t\tkubeClient := fake.NewClientset()\n\n\t\t\tfor _, node := range tt.nodes {\n\t\t\t\t_, err := kubeClient.CoreV1().Nodes().Create(t.Context(), node, metav1.CreateOptions{})\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\tfor _, pod := range tt.pods {\n\t\t\t\t_, err := kubeClient.CoreV1().Pods(pod.Namespace).Create(t.Context(), pod, metav1.CreateOptions{})\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\tsrc, err := NewPodSource(\n\t\t\t\tt.Context(),\n\t\t\t\tkubeClient,\n\t\t\t\t&Config{\n\t\t\t\t\tFQDNTemplate:             tt.fqdnTemplate,\n\t\t\t\t\tCombineFQDNAndAnnotation: tt.combineFQDN,\n\t\t\t\t\tPodSourceDomain:          tt.sourceDomain,\n\t\t\t\t})\n\t\t\trequire.NoError(t, err)\n\n\t\t\t_, err = src.Endpoints(t.Context())\n\t\t\trequire.Error(t, err)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "source/pod_indexer_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n    http://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"fmt\"\n\t\"math/rand/v2\"\n\t\"net\"\n\t\"strconv\"\n\t\"testing\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\tcorev1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/client-go/kubernetes/fake\"\n\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n)\n\ntype podSpec struct {\n\tnamespace   string\n\tlabels      map[string]string\n\tannotations map[string]string\n\t// with labels and annotations\n\ttotalTarget int\n\t// without provided labels and annotations\n\ttotalRandom int\n}\n\nfunc fixtureCreatePodsWithNodes(input []podSpec) []*corev1.Pod {\n\tvar pods []*corev1.Pod\n\n\tvar createPod = func(index int, spec podSpec) *corev1.Pod {\n\t\treturn &corev1.Pod{\n\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\tName:      fmt.Sprintf(\"pod-%d-%s\", index, uuid.NewString()),\n\t\t\t\tNamespace: spec.namespace,\n\t\t\t\tLabels: func() map[string]string {\n\t\t\t\t\tif spec.totalTarget > index {\n\t\t\t\t\t\treturn spec.labels\n\t\t\t\t\t}\n\t\t\t\t\treturn map[string]string{\n\t\t\t\t\t\t\"app\":   fmt.Sprintf(\"my-app-%d\", rand.IntN(10)),\n\t\t\t\t\t\t\"index\": strconv.Itoa(index),\n\t\t\t\t\t}\n\t\t\t\t}(),\n\t\t\t\tAnnotations: func() map[string]string {\n\t\t\t\t\tif spec.totalTarget > index {\n\t\t\t\t\t\treturn spec.annotations\n\t\t\t\t\t}\n\t\t\t\t\treturn map[string]string{\n\t\t\t\t\t\t\"key1\": fmt.Sprintf(\"value-%d\", rand.IntN(10)),\n\t\t\t\t\t}\n\t\t\t\t}(),\n\t\t\t},\n\t\t\tSpec: corev1.PodSpec{},\n\t\t\tStatus: corev1.PodStatus{\n\t\t\t\tPhase: corev1.PodRunning,\n\t\t\t\tPodIPs: []corev1.PodIP{\n\t\t\t\t\t{IP: net.IPv4(192, byte(rand.IntN(250)), byte(rand.IntN(250)), byte(index)).String()},\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t}\n\n\tfor _, el := range input {\n\t\ttotalPods := el.totalTarget + el.totalRandom\n\t\tfor i := range totalPods {\n\t\t\tpods = append(pods, createPod(i, el))\n\t\t}\n\t}\n\n\tfor range 3 {\n\t\trand.Shuffle(len(pods), func(i, j int) {\n\t\t\tpods[i], pods[j] = pods[j], pods[i]\n\t\t})\n\t}\n\t// assign nodes to pods\n\tfor i, pod := range pods {\n\t\tpod.Spec.NodeName = fmt.Sprintf(\"node-%d\", i/5) // Assign 5 pods per node\n\t}\n\treturn pods\n}\n\nfunc TestPodsWithAnnotationsAndLabels(t *testing.T) {\n\t// total target pods 700\n\t// total random pods 3950\n\tpods := fixtureCreatePodsWithNodes([]podSpec{\n\t\t{\n\t\t\tnamespace:   \"dev\",\n\t\t\tlabels:      map[string]string{\"app\": \"nginx\", \"env\": \"dev\", \"agent\": \"enabled\"},\n\t\t\tannotations: map[string]string{\"arch\": \"amd64\"},\n\t\t\ttotalTarget: 300,\n\t\t\ttotalRandom: 700,\n\t\t},\n\t\t{\n\t\t\tnamespace:   \"prod\",\n\t\t\tlabels:      map[string]string{\"app\": \"nginx\", \"env\": \"prod\", \"agent\": \"enabled\"},\n\t\t\tannotations: map[string]string{\"arch\": \"amd64\"},\n\t\t\ttotalTarget: 150,\n\t\t\ttotalRandom: 2700,\n\t\t},\n\t\t{\n\t\t\tnamespace:   \"default\",\n\t\t\tlabels:      map[string]string{\"app\": \"nginx\", \"agent\": \"disabled\"},\n\t\t\tannotations: map[string]string{\"arch\": \"amd64\"},\n\t\t\ttotalTarget: 250,\n\t\t\ttotalRandom: 450,\n\t\t},\n\t\t{\n\t\t\tnamespace:   \"kube-system\",\n\t\t\tlabels:      map[string]string{},\n\t\t\tannotations: map[string]string{},\n\t\t\ttotalTarget: 0,\n\t\t\ttotalRandom: 100,\n\t\t},\n\t})\n\n\tclient := fake.NewClientset()\n\n\tnodes := map[string]bool{}\n\n\tfor _, pod := range pods {\n\t\tif _, exists := nodes[pod.Spec.NodeName]; !exists {\n\t\t\tnodes[pod.Spec.NodeName] = true\n\t\t\tnode := &corev1.Node{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: pod.Spec.NodeName,\n\t\t\t\t},\n\t\t\t}\n\t\t\tif _, err := client.CoreV1().Nodes().Create(t.Context(), node, metav1.CreateOptions{}); err != nil {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t}\n\t\t}\n\t\tif _, err := client.CoreV1().Pods(pod.Namespace).Create(t.Context(), pod, metav1.CreateOptions{}); err != nil {\n\t\t\tassert.NoError(t, err)\n\t\t}\n\t}\n\n\ttests := []struct {\n\t\tname                  string\n\t\tnamespace             string\n\t\tlabelSelector         string\n\t\tannotationFilter      string\n\t\texpectedEndpointCount int\n\t}{\n\t\t{\n\t\t\tname:                  \"prod namespace with labels\",\n\t\t\tnamespace:             \"prod\",\n\t\t\tlabelSelector:         \"app=nginx\",\n\t\t\texpectedEndpointCount: 150,\n\t\t},\n\t\t{\n\t\t\tname:                  \"prod namespace with annotations\",\n\t\t\tnamespace:             \"prod\",\n\t\t\tannotationFilter:      \"arch=amd64\",\n\t\t\texpectedEndpointCount: 150,\n\t\t},\n\t\t{\n\t\t\tname:                  \"prod namespace with annotations and labels not exists\",\n\t\t\tnamespace:             \"prod\",\n\t\t\tlabelSelector:         \"app=not-exists\",\n\t\t\tannotationFilter:      \"arch=amd64\",\n\t\t\texpectedEndpointCount: 0,\n\t\t},\n\t\t{\n\t\t\tname:                  \"all namespaces with correct annotations and labels\",\n\t\t\tnamespace:             \"\",\n\t\t\tlabelSelector:         \"app=nginx,agent=enabled\",\n\t\t\tannotationFilter:      \"arch=amd64\",\n\t\t\texpectedEndpointCount: 450, // 300 from dev + 150 from prod\n\t\t},\n\t\t{\n\t\t\tname:                  \"all namespaces with loose annotations and labels\",\n\t\t\tnamespace:             \"\",\n\t\t\tlabelSelector:         \"app=nginx\",\n\t\t\tannotationFilter:      \"arch=amd64\",\n\t\t\texpectedEndpointCount: 700, // 300 from dev + 150 from prod + 250 from default\n\t\t},\n\t\t{\n\t\t\tname:                  \"all namespaces with loose annotations and labels\",\n\t\t\tnamespace:             \"\",\n\t\t\tlabelSelector:         \"agent\",\n\t\t\tannotationFilter:      \"arch\",\n\t\t\texpectedEndpointCount: 700,\n\t\t},\n\t\t{\n\t\t\tname:                  \"all namespaces without filters\",\n\t\t\tnamespace:             \"\",\n\t\t\tlabelSelector:         \"\",\n\t\t\tannotationFilter:      \"\",\n\t\t\texpectedEndpointCount: 4650,\n\t\t},\n\t\t{\n\t\t\tname:                  \"single namespace without filters\",\n\t\t\tnamespace:             \"default\",\n\t\t\tlabelSelector:         \"\",\n\t\t\tannotationFilter:      \"\",\n\t\t\texpectedEndpointCount: 700,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tselector, _ := annotations.ParseFilter(tt.labelSelector)\n\t\t\tpSource, err := NewPodSource(\n\t\t\t\tt.Context(), client,\n\t\t\t\t&Config{\n\t\t\t\t\tNamespace:        tt.namespace,\n\t\t\t\t\tFQDNTemplate:     \"{{ .Name }}.tld.org\",\n\t\t\t\t\tAnnotationFilter: tt.annotationFilter,\n\t\t\t\t\tLabelFilter:      selector,\n\t\t\t\t})\n\t\t\trequire.NoError(t, err)\n\n\t\t\tendpoints, err := pSource.Endpoints(t.Context())\n\t\t\trequire.NoError(t, err)\n\n\t\t\tassert.Len(t, endpoints, tt.expectedEndpointCount)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "source/pod_test.go",
    "content": "/*\nCopyright 2021 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"fmt\"\n\t\"math/rand\"\n\t\"testing\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\t\"github.com/stretchr/testify/require\"\n\tcorev1 \"k8s.io/api/core/v1\"\n\tv1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/runtime\"\n\tcorev1lister \"k8s.io/client-go/listers/core/v1\"\n\t\"k8s.io/client-go/tools/cache\"\n\n\t\"sigs.k8s.io/external-dns/source/types\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/internal/testutils\"\n\tlogtest \"sigs.k8s.io/external-dns/internal/testutils/log\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n\n\t\"k8s.io/client-go/kubernetes/fake\"\n)\n\n// testPodSource tests that various services generate the correct endpoints.\nfunc TestPodSource(t *testing.T) {\n\tt.Parallel()\n\n\tfor _, tc := range []struct {\n\t\ttitle                    string\n\t\ttargetNamespace          string\n\t\tcompatibility            string\n\t\tignoreNonHostNetworkPods bool\n\t\tPodSourceDomain          string\n\t\texpected                 []*endpoint.Endpoint\n\t\texpectError              bool\n\t\tnodes                    []*corev1.Node\n\t\tpods                     []*corev1.Pod\n\t}{\n\t\t{\n\t\t\t\"create IPv4 records based on pod's external and internal IPs\",\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\ttrue,\n\t\t\t\"\",\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"a.foo.example.org\", Targets: endpoint.Targets{\"54.10.11.1\", \"54.10.11.2\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t\t{DNSName: \"internal.a.foo.example.org\", Targets: endpoint.Targets{\"10.0.1.1\", \"10.0.1.2\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t},\n\t\t\tfalse,\n\t\t\tnodesFixturesIPv4(),\n\t\t\t[]*corev1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-pod1\",\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.InternalHostnameKey: \"internal.a.foo.example.org\",\n\t\t\t\t\t\t\tannotations.HostnameKey:         \"a.foo.example.org\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.PodSpec{\n\t\t\t\t\t\tHostNetwork: true,\n\t\t\t\t\t\tNodeName:    \"my-node1\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\tPodIP: \"10.0.1.1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-pod2\",\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.InternalHostnameKey: \"internal.a.foo.example.org\",\n\t\t\t\t\t\t\tannotations.HostnameKey:         \"a.foo.example.org\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.PodSpec{\n\t\t\t\t\t\tHostNetwork: true,\n\t\t\t\t\t\tNodeName:    \"my-node2\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\tPodIP: \"10.0.1.2\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"create IPv4 records based on pod's external and internal IPs using DNS Controller annotations\",\n\t\t\t\"\",\n\t\t\t\"kops-dns-controller\",\n\t\t\ttrue,\n\t\t\t\"\",\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"a.foo.example.org\", Targets: endpoint.Targets{\"54.10.11.1\", \"54.10.11.2\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t\t{DNSName: \"internal.a.foo.example.org\", Targets: endpoint.Targets{\"10.0.1.1\", \"10.0.1.2\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t},\n\t\t\tfalse,\n\t\t\tnodesFixturesIPv4(),\n\t\t\t[]*corev1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-pod1\",\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tkopsDNSControllerInternalHostnameAnnotationKey: \"internal.a.foo.example.org\",\n\t\t\t\t\t\t\tkopsDNSControllerHostnameAnnotationKey:         \"a.foo.example.org\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.PodSpec{\n\t\t\t\t\t\tHostNetwork: true,\n\t\t\t\t\t\tNodeName:    \"my-node1\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\tPodIP: \"10.0.1.1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-pod2\",\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tkopsDNSControllerInternalHostnameAnnotationKey: \"internal.a.foo.example.org\",\n\t\t\t\t\t\t\tkopsDNSControllerHostnameAnnotationKey:         \"a.foo.example.org\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.PodSpec{\n\t\t\t\t\t\tHostNetwork: true,\n\t\t\t\t\t\tNodeName:    \"my-node2\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\tPodIP: \"10.0.1.2\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"create IPv6 records based on pod's external and internal IPs\",\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\ttrue,\n\t\t\t\"\",\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"a.foo.example.org\", Targets: endpoint.Targets{\"2001:DB8::1\", \"2001:DB8::2\"}, RecordType: endpoint.RecordTypeAAAA},\n\t\t\t\t{DNSName: \"internal.a.foo.example.org\", Targets: endpoint.Targets{\"2001:DB8::1\", \"2001:DB8::2\"}, RecordType: endpoint.RecordTypeAAAA},\n\t\t\t},\n\t\t\tfalse,\n\t\t\tnodesFixturesIPv6(),\n\t\t\t[]*corev1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-pod1\",\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.InternalHostnameKey: \"internal.a.foo.example.org\",\n\t\t\t\t\t\t\tannotations.HostnameKey:         \"a.foo.example.org\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.PodSpec{\n\t\t\t\t\t\tHostNetwork: true,\n\t\t\t\t\t\tNodeName:    \"my-node1\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\tPodIP: \"2001:DB8::1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-pod2\",\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.InternalHostnameKey: \"internal.a.foo.example.org\",\n\t\t\t\t\t\t\tannotations.HostnameKey:         \"a.foo.example.org\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.PodSpec{\n\t\t\t\t\t\tHostNetwork: true,\n\t\t\t\t\t\tNodeName:    \"my-node2\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\tPodIP: \"2001:DB8::2\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"create IPv6 records based on pod's external and internal IPs using DNS Controller annotations\",\n\t\t\t\"\",\n\t\t\t\"kops-dns-controller\",\n\t\t\ttrue,\n\t\t\t\"\",\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"a.foo.example.org\", Targets: endpoint.Targets{\"2001:DB8::1\", \"2001:DB8::2\"}, RecordType: endpoint.RecordTypeAAAA},\n\t\t\t\t{DNSName: \"internal.a.foo.example.org\", Targets: endpoint.Targets{\"2001:DB8::1\", \"2001:DB8::2\"}, RecordType: endpoint.RecordTypeAAAA},\n\t\t\t},\n\t\t\tfalse,\n\t\t\tnodesFixturesIPv6(),\n\t\t\t[]*corev1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-pod1\",\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tkopsDNSControllerInternalHostnameAnnotationKey: \"internal.a.foo.example.org\",\n\t\t\t\t\t\t\tkopsDNSControllerHostnameAnnotationKey:         \"a.foo.example.org\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.PodSpec{\n\t\t\t\t\t\tHostNetwork: true,\n\t\t\t\t\t\tNodeName:    \"my-node1\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\tPodIP: \"2001:DB8::1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-pod2\",\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tkopsDNSControllerInternalHostnameAnnotationKey: \"internal.a.foo.example.org\",\n\t\t\t\t\t\t\tkopsDNSControllerHostnameAnnotationKey:         \"a.foo.example.org\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.PodSpec{\n\t\t\t\t\t\tHostNetwork: true,\n\t\t\t\t\t\tNodeName:    \"my-node2\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\tPodIP: \"2001:DB8::2\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"create records based on pod's target annotation\",\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\ttrue,\n\t\t\t\"\",\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"a.foo.example.org\", Targets: endpoint.Targets{\"208.1.2.1\", \"208.1.2.2\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t\t{DNSName: \"internal.a.foo.example.org\", Targets: endpoint.Targets{\"208.1.2.1\", \"208.1.2.2\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t},\n\t\t\tfalse,\n\t\t\tnodesFixturesIPv4(),\n\t\t\t[]*corev1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-pod1\",\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.InternalHostnameKey: \"internal.a.foo.example.org\",\n\t\t\t\t\t\t\tannotations.HostnameKey:         \"a.foo.example.org\",\n\t\t\t\t\t\t\tannotations.TargetKey:           \"208.1.2.1\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.PodSpec{\n\t\t\t\t\t\tHostNetwork: true,\n\t\t\t\t\t\tNodeName:    \"my-node1\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\tPodIP: \"10.0.1.1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-pod2\",\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.InternalHostnameKey: \"internal.a.foo.example.org\",\n\t\t\t\t\t\t\tannotations.HostnameKey:         \"a.foo.example.org\",\n\t\t\t\t\t\t\tannotations.TargetKey:           \"208.1.2.2\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.PodSpec{\n\t\t\t\t\t\tHostNetwork: true,\n\t\t\t\t\t\tNodeName:    \"my-node2\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\tPodIP: \"10.0.1.2\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"create multiple records\",\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\ttrue,\n\t\t\t\"\",\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"a.foo.example.org\", Targets: endpoint.Targets{\"54.10.11.1\"}, RecordType: endpoint.RecordTypeA, RecordTTL: endpoint.TTL(5400)},\n\t\t\t\t{DNSName: \"a.foo.example.org\", Targets: endpoint.Targets{\"2001:DB8::1\"}, RecordType: endpoint.RecordTypeAAAA, RecordTTL: endpoint.TTL(5400)},\n\t\t\t\t{DNSName: \"b.foo.example.org\", Targets: endpoint.Targets{\"54.10.11.2\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t},\n\t\t\tfalse,\n\t\t\t[]*corev1.Node{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName: \"my-node1\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.NodeStatus{\n\t\t\t\t\t\tAddresses: []corev1.NodeAddress{\n\t\t\t\t\t\t\t{Type: corev1.NodeExternalIP, Address: \"54.10.11.1\"},\n\t\t\t\t\t\t\t{Type: corev1.NodeInternalIP, Address: \"2001:DB8::1\"},\n\t\t\t\t\t\t\t{Type: corev1.NodeInternalIP, Address: \"10.0.1.1\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName: \"my-node2\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.NodeStatus{\n\t\t\t\t\t\tAddresses: []corev1.NodeAddress{\n\t\t\t\t\t\t\t{Type: corev1.NodeExternalIP, Address: \"54.10.11.2\"},\n\t\t\t\t\t\t\t{Type: corev1.NodeInternalIP, Address: \"10.0.1.2\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]*corev1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-pod1\",\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.HostnameKey: \"a.foo.example.org\",\n\t\t\t\t\t\t\tannotations.TtlKey:      \"1h30m\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.PodSpec{\n\t\t\t\t\t\tHostNetwork: true,\n\t\t\t\t\t\tNodeName:    \"my-node1\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\tPodIP: \"10.0.1.1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-pod2\",\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.HostnameKey: \"b.foo.example.org\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.PodSpec{\n\t\t\t\t\t\tHostNetwork: true,\n\t\t\t\t\t\tNodeName:    \"my-node2\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\tPodIP: \"10.0.1.2\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"pods with hostNetwore=false should be ignored\",\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\ttrue,\n\t\t\t\"\",\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"a.foo.example.org\", Targets: endpoint.Targets{\"54.10.11.1\"}, RecordType: endpoint.RecordTypeA, RecordTTL: endpoint.TTL(1)},\n\t\t\t\t{DNSName: \"internal.a.foo.example.org\", Targets: endpoint.Targets{\"10.0.1.1\"}, RecordType: endpoint.RecordTypeA, RecordTTL: endpoint.TTL(1)},\n\t\t\t},\n\t\t\tfalse,\n\t\t\tnodesFixturesIPv4(),\n\t\t\t[]*corev1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-pod1\",\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.InternalHostnameKey: \"internal.a.foo.example.org\",\n\t\t\t\t\t\t\tannotations.HostnameKey:         \"a.foo.example.org\",\n\t\t\t\t\t\t\tannotations.TtlKey:              \"1s\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.PodSpec{\n\t\t\t\t\t\tHostNetwork: true,\n\t\t\t\t\t\tNodeName:    \"my-node1\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\tPodIP: \"10.0.1.1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-pod2\",\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.InternalHostnameKey: \"internal.a.foo.example.org\",\n\t\t\t\t\t\t\tannotations.HostnameKey:         \"a.foo.example.org\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.PodSpec{\n\t\t\t\t\t\tHostNetwork: false,\n\t\t\t\t\t\tNodeName:    \"my-node2\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\tPodIP: \"100.0.1.2\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"only watch a given namespace\",\n\t\t\t\"kube-system\",\n\t\t\t\"\",\n\t\t\ttrue,\n\t\t\t\"\",\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"a.foo.example.org\", Targets: endpoint.Targets{\"54.10.11.1\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t\t{DNSName: \"internal.a.foo.example.org\", Targets: endpoint.Targets{\"10.0.1.1\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t},\n\t\t\tfalse,\n\t\t\tnodesFixturesIPv4(),\n\t\t\t[]*corev1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-pod1\",\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.InternalHostnameKey: \"internal.a.foo.example.org\",\n\t\t\t\t\t\t\tannotations.HostnameKey:         \"a.foo.example.org\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.PodSpec{\n\t\t\t\t\t\tHostNetwork: true,\n\t\t\t\t\t\tNodeName:    \"my-node1\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\tPodIP: \"10.0.1.1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-pod2\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.InternalHostnameKey: \"internal.a.foo.example.org\",\n\t\t\t\t\t\t\tannotations.HostnameKey:         \"a.foo.example.org\",\n\t\t\t\t\t\t\tannotations.TtlKey:              \"1s\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.PodSpec{\n\t\t\t\t\t\tHostNetwork: true,\n\t\t\t\t\t\tNodeName:    \"my-node2\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\tPodIP: \"100.0.1.2\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"split record for internal hostname annotation\",\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\ttrue,\n\t\t\t\"\",\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"internal.a.foo.example.org\", Targets: endpoint.Targets{\"10.0.1.1\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t\t{DNSName: \"internal.b.foo.example.org\", Targets: endpoint.Targets{\"10.0.1.1\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t},\n\t\t\tfalse,\n\t\t\t[]*corev1.Node{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName: \"my-node1\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.NodeStatus{\n\t\t\t\t\t\tAddresses: []corev1.NodeAddress{\n\t\t\t\t\t\t\t{Type: corev1.NodeInternalIP, Address: \"10.0.1.1\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]*corev1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-pod1\",\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.InternalHostnameKey: \"internal.a.foo.example.org,internal.b.foo.example.org\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.PodSpec{\n\t\t\t\t\t\tHostNetwork: true,\n\t\t\t\t\t\tNodeName:    \"my-node1\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\tPodIP: \"10.0.1.1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"create IPv4 records for non-host network pods\",\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\t\"example.org\",\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"my-pod1.example.org\", Targets: endpoint.Targets{\"192.168.1.1\"}, RecordType: endpoint.RecordTypeA, RecordTTL: endpoint.TTL(60)},\n\t\t\t\t{DNSName: \"my-pod2.example.org\", Targets: endpoint.Targets{\"192.168.1.2\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t},\n\t\t\tfalse,\n\t\t\tnodesFixturesIPv4(),\n\t\t\t[]*corev1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-pod1\",\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.TtlKey: \"1m\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.PodSpec{\n\t\t\t\t\t\tHostNetwork: false,\n\t\t\t\t\t\tNodeName:    \"my-node1\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\tPodIP: \"192.168.1.1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:        \"my-pod2\",\n\t\t\t\t\t\tNamespace:   \"kube-system\",\n\t\t\t\t\t\tAnnotations: map[string]string{},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.PodSpec{\n\t\t\t\t\t\tHostNetwork: false,\n\t\t\t\t\t\tNodeName:    \"my-node2\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\tPodIP: \"192.168.1.2\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"create records based on internal hostname annotation for non-host network pod\",\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\t\"\",\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"internal.a.foo.example.org\", Targets: endpoint.Targets{\"192.168.1.1\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t},\n\t\t\tfalse,\n\t\t\tnil,\n\t\t\t[]*corev1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-pod1\",\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.InternalHostnameKey: \"internal.a.foo.example.org\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.PodSpec{\n\t\t\t\t\t\tHostNetwork: false,\n\t\t\t\t\t\tNodeName:    \"my-node1\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\tPodIP: \"192.168.1.1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"create records based on internal hostname annotation for host network pod\",\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\t\"\",\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"internal.a.foo.example.org\", Targets: endpoint.Targets{\"192.168.1.1\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t},\n\t\t\tfalse,\n\t\t\tnodesFixturesIPv4(),\n\t\t\t[]*corev1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-pod1\",\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.InternalHostnameKey: \"internal.a.foo.example.org\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.PodSpec{\n\t\t\t\t\t\tHostNetwork: true,\n\t\t\t\t\t\tNodeName:    \"my-node1\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\tPodIP: \"192.168.1.1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"create records based on pod's target annotation with pod source domain\",\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\ttrue,\n\t\t\t\"example.org\",\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"my-pod1.example.org\", Targets: endpoint.Targets{\"208.1.2.1\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t\t{DNSName: \"my-pod2.example.org\", Targets: endpoint.Targets{\"208.1.2.2\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t\t{DNSName: \"a.foo.example.org\", Targets: endpoint.Targets{\"208.1.2.1\", \"208.1.2.2\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t\t{DNSName: \"internal.a.foo.example.org\", Targets: endpoint.Targets{\"208.1.2.1\", \"208.1.2.2\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t},\n\t\t\tfalse,\n\t\t\tnodesFixturesIPv4(),\n\t\t\t[]*corev1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-pod1\",\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.InternalHostnameKey: \"internal.a.foo.example.org\",\n\t\t\t\t\t\t\tannotations.HostnameKey:         \"a.foo.example.org\",\n\t\t\t\t\t\t\tannotations.TargetKey:           \"208.1.2.1\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.PodSpec{\n\t\t\t\t\t\tHostNetwork: true,\n\t\t\t\t\t\tNodeName:    \"my-node1\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\tPodIP: \"10.0.1.1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-pod2\",\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.InternalHostnameKey: \"internal.a.foo.example.org\",\n\t\t\t\t\t\t\tannotations.HostnameKey:         \"a.foo.example.org\",\n\t\t\t\t\t\t\tannotations.TargetKey:           \"208.1.2.2\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.PodSpec{\n\t\t\t\t\t\tHostNetwork: true,\n\t\t\t\t\t\tNodeName:    \"my-node2\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\tPodIP: \"10.0.1.2\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"host network pod on a missing node\",\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\ttrue,\n\t\t\t\"\",\n\t\t\t[]*endpoint.Endpoint{},\n\t\t\tfalse,\n\t\t\tnodesFixturesIPv4(),\n\t\t\t[]*corev1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-pod1\",\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.HostnameKey: \"a.foo.example.org\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.PodSpec{\n\t\t\t\t\t\tHostNetwork: true,\n\t\t\t\t\t\tNodeName:    \"missing-node\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\tPodIP: \"10.0.1.1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"provider-specific annotation is not supported and is ignored\",\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\ttrue,\n\t\t\t\"\",\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"a.foo.example.org\", Targets: endpoint.Targets{\"54.10.11.1\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t},\n\t\t\tfalse,\n\t\t\tnodesFixturesIPv4(),\n\t\t\t[]*corev1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"my-pod1\",\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.HostnameKey:          \"a.foo.example.org\",\n\t\t\t\t\t\t\tannotations.AWSPrefix + \"weight\": \"10\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.PodSpec{\n\t\t\t\t\t\tHostNetwork: true,\n\t\t\t\t\t\tNodeName:    \"my-node1\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\tPodIP: \"10.0.1.1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(tc.title, func(t *testing.T) {\n\t\t\tkubernetes := fake.NewClientset()\n\t\t\tctx := t.Context()\n\n\t\t\t// Create the nodes\n\t\t\tfor _, node := range tc.nodes {\n\t\t\t\tif _, err := kubernetes.CoreV1().Nodes().Create(ctx, node, metav1.CreateOptions{}); err != nil {\n\t\t\t\t\tt.Fatal(err)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Create the pods\n\t\t\tfor _, pod := range tc.pods {\n\t\t\t\tpods := kubernetes.CoreV1().Pods(pod.Namespace)\n\n\t\t\t\tif _, err := pods.Create(ctx, pod, metav1.CreateOptions{}); err != nil {\n\t\t\t\t\tt.Fatal(err)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tclient, err := NewPodSource(ctx, kubernetes, &Config{\n\t\t\t\tNamespace:                tc.targetNamespace,\n\t\t\t\tCompatibility:            tc.compatibility,\n\t\t\t\tIgnoreNonHostNetworkPods: tc.ignoreNonHostNetworkPods,\n\t\t\t\tPodSourceDomain:          tc.PodSourceDomain,\n\t\t\t})\n\t\t\trequire.NoError(t, err)\n\n\t\t\tendpoints, err := client.Endpoints(ctx)\n\t\t\tif tc.expectError {\n\t\t\t\trequire.Error(t, err)\n\t\t\t} else {\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\t// Validate returned endpoints against desired endpoints.\n\t\t\tvalidateEndpoints(t, endpoints, tc.expected)\n\n\t\t\tfor _, ep := range endpoints {\n\t\t\t\t// TODO: source should always set the resource label key. currently not supported by the pod source.\n\t\t\t\trequire.Empty(t, ep.Labels, \"Labels should not be empty for endpoint %s\", ep.DNSName)\n\t\t\t\trequire.NotContains(t, ep.Labels, endpoint.ResourceLabelKey)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPodSourceLogs(t *testing.T) {\n\tt.Parallel()\n\t// Generate unique pod names to avoid log conflicts across parallel tests.\n\t// Since logs are globally shared, using the same pod names could cause\n\t// false positives in unexpectedDebugLogs assertions.\n\tsuffix := fmt.Sprintf(\"%d\", rand.Intn(100000))\n\tfor _, tc := range []struct {\n\t\ttitle                    string\n\t\tignoreNonHostNetworkPods bool\n\t\tpods                     []*corev1.Pod\n\t\tnodes                    []*corev1.Node\n\t\texpectedDebugLogs        []string\n\t\tunexpectedDebugLogs      []string\n\t}{\n\t\t{\n\t\t\t\"pods with hostNetwore=false should be skipped logging\",\n\t\t\ttrue,\n\t\t\t[]*corev1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      fmt.Sprintf(\"my-pod1-%s\", suffix),\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.InternalHostnameKey: \"internal.a.foo.example.org\",\n\t\t\t\t\t\t\tannotations.HostnameKey:         \"a.foo.example.org\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.PodSpec{\n\t\t\t\t\t\tHostNetwork: false,\n\t\t\t\t\t\tNodeName:    \"my-node1\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\tPodIP: \"100.0.1.1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnodesFixturesIPv4(),\n\t\t\t[]string{fmt.Sprintf(\"skipping pod my-pod1-%s. hostNetwork=false\", suffix)},\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"host network pod on a missing node\",\n\t\t\ttrue,\n\t\t\t[]*corev1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      fmt.Sprintf(\"missing-node-pod-%s\", suffix),\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.HostnameKey: \"a.foo.example.org\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.PodSpec{\n\t\t\t\t\t\tHostNetwork: true,\n\t\t\t\t\t\tNodeName:    \"missing-node\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\tPodIP: \"10.0.1.1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnodesFixturesIPv4(),\n\t\t\t[]string{\n\t\t\t\tfmt.Sprintf(`Get node[missing-node] of pod[missing-node-pod-%s] error: node \"missing-node\" not found; ignoring`, suffix),\n\t\t\t},\n\t\t\tnil,\n\t\t},\n\t\t{\n\t\t\t\"mixed valid and hostNetwork=false pods with missing node\",\n\t\t\ttrue,\n\t\t\t[]*corev1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      fmt.Sprintf(\"valid-pod-%s\", suffix),\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.HostnameKey: \"valid.foo.example.org\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.PodSpec{\n\t\t\t\t\t\tHostNetwork: true,\n\t\t\t\t\t\tNodeName:    \"my-node1\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\tPodIP: \"10.0.1.1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      fmt.Sprintf(\"non-hostnet-pod-%s\", suffix),\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.HostnameKey: \"nonhost.foo.example.org\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.PodSpec{\n\t\t\t\t\t\tHostNetwork: false,\n\t\t\t\t\t\tNodeName:    \"my-node2\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\tPodIP: \"100.0.1.2\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      fmt.Sprintf(\"missing-node-pod-%s\", suffix),\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.HostnameKey: \"missing.foo.example.org\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.PodSpec{\n\t\t\t\t\t\tHostNetwork: true,\n\t\t\t\t\t\tNodeName:    \"missing-node\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\tPodIP: \"10.0.1.3\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnodesFixturesIPv4(),\n\t\t\t[]string{\n\t\t\t\tfmt.Sprintf(\"skipping pod non-hostnet-pod-%s. hostNetwork=false\", suffix),\n\t\t\t\tfmt.Sprintf(`Get node[missing-node] of pod[missing-node-pod-%s] error: node \"missing-node\" not found; ignoring`, suffix),\n\t\t\t},\n\t\t\t[]string{\n\t\t\t\tfmt.Sprintf(\"skipping pod valid-pod-%s. hostNetwork=false\", suffix),\n\t\t\t\tfmt.Sprintf(`Get node[my-node1] of pod[valid-pod-%s] error: node \"my-node1\" not found; ignoring`, suffix),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"valid pods with hostNetwork=true should not generate logs\",\n\t\t\ttrue,\n\t\t\t[]*corev1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      fmt.Sprintf(\"valid-pod-%s\", suffix),\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.HostnameKey: \"valid.foo.example.org\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.PodSpec{\n\t\t\t\t\t\tHostNetwork: true,\n\t\t\t\t\t\tNodeName:    \"my-node1\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\tPodIP: \"10.0.1.1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnodesFixturesIPv4(),\n\t\t\tnil,\n\t\t\t[]string{\n\t\t\t\tfmt.Sprintf(\"skipping pod valid-pod-%s. hostNetwork=false\", suffix),\n\t\t\t\tfmt.Sprintf(`Get node[my-node1] of pod[valid-pod-%s] error: node \"my-node1\" not found; ignoring`, suffix),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"when ignoreNonHostNetworkPods=false, no skip logs should be generated\",\n\t\t\tfalse,\n\t\t\t[]*corev1.Pod{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      fmt.Sprintf(\"my-pod1-%s\", suffix),\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.InternalHostnameKey: \"internal.a.foo.example.org\",\n\t\t\t\t\t\t\tannotations.HostnameKey:         \"a.foo.example.org\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: corev1.PodSpec{\n\t\t\t\t\t\tHostNetwork: false,\n\t\t\t\t\t\tNodeName:    \"my-node1\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: corev1.PodStatus{\n\t\t\t\t\t\tPodIP: \"10.0.1.1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tnodesFixturesIPv4(),\n\t\t\tnil,\n\t\t\t[]string{\n\t\t\t\tfmt.Sprintf(\"skipping pod my-pod1-%s. hostNetwork=false\", suffix),\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(tc.title, func(t *testing.T) {\n\t\t\tkubernetes := fake.NewClientset()\n\t\t\tctx := t.Context()\n\t\t\t// Create the nodes\n\t\t\tfor _, node := range tc.nodes {\n\t\t\t\tif _, err := kubernetes.CoreV1().Nodes().Create(ctx, node, metav1.CreateOptions{}); err != nil {\n\t\t\t\t\tt.Fatal(err)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Create the pods\n\t\t\tfor _, pod := range tc.pods {\n\t\t\t\tpods := kubernetes.CoreV1().Pods(pod.Namespace)\n\n\t\t\t\tif _, err := pods.Create(ctx, pod, metav1.CreateOptions{}); err != nil {\n\t\t\t\t\tt.Fatal(err)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tsrc, err := NewPodSource(ctx, kubernetes, &Config{\n\t\t\t\tFQDNTemplate:             \"\",\n\t\t\t\tIgnoreNonHostNetworkPods: tc.ignoreNonHostNetworkPods,\n\t\t\t})\n\t\t\trequire.NoError(t, err)\n\n\t\t\thook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t)\n\n\t\t\t_, err = src.Endpoints(ctx)\n\t\t\trequire.NoError(t, err)\n\n\t\t\t// Check if all expected logs are present in actual logs.\n\t\t\t// We don't do an exact match because logs are globally shared,\n\t\t\t// making precise comparisons difficult\n\t\t\tfor _, expectedLog := range tc.expectedDebugLogs {\n\t\t\t\tlogtest.TestHelperLogContains(expectedLog, hook, t)\n\t\t\t}\n\n\t\t\t// Check that no unexpected logs are present.\n\t\t\t// This ensures that logs are not generated inappropriately.\n\t\t\tfor _, unexpectedLog := range tc.unexpectedDebugLogs {\n\t\t\t\tlogtest.TestHelperLogNotContains(unexpectedLog, hook, t)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestPodSource_AddEventHandler(t *testing.T) {\n\tfakeInformer := new(fakePodInformer)\n\tinf := testInformer{}\n\tfakeInformer.On(\"Informer\").Return(&inf)\n\n\tpSource := &podSource{\n\t\tpodInformer: fakeInformer,\n\t}\n\n\thandlerCalled := false\n\thandler := func() { handlerCalled = true }\n\n\tpSource.AddEventHandler(t.Context(), handler)\n\n\tfakeInformer.AssertNumberOfCalls(t, \"Informer\", 1)\n\tassert.False(t, handlerCalled)\n\tassert.Equal(t, 1, inf.times)\n}\n\ntype fakePodInformer struct {\n\tmock.Mock\n}\n\nfunc (f *fakePodInformer) Informer() cache.SharedIndexInformer {\n\targs := f.Called()\n\treturn args.Get(0).(cache.SharedIndexInformer)\n}\n\nfunc (f *fakePodInformer) Lister() corev1lister.PodLister {\n\treturn corev1lister.NewPodLister(f.Informer().GetIndexer())\n}\n\nfunc nodesFixturesIPv6() []*corev1.Node {\n\treturn []*corev1.Node{\n\t\t{\n\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\tName: \"my-node1\",\n\t\t\t},\n\t\t\tStatus: corev1.NodeStatus{\n\t\t\t\tAddresses: []corev1.NodeAddress{\n\t\t\t\t\t{Type: corev1.NodeInternalIP, Address: \"2001:DB8::1\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\tName: \"my-node2\",\n\t\t\t},\n\t\t\tStatus: corev1.NodeStatus{\n\t\t\t\tAddresses: []corev1.NodeAddress{\n\t\t\t\t\t{Type: corev1.NodeInternalIP, Address: \"2001:DB8::2\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n}\n\nfunc nodesFixturesIPv4() []*corev1.Node {\n\treturn []*corev1.Node{\n\t\t{\n\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\tName: \"my-node1\",\n\t\t\t},\n\t\t\tStatus: corev1.NodeStatus{\n\t\t\t\tAddresses: []corev1.NodeAddress{\n\t\t\t\t\t{Type: corev1.NodeExternalIP, Address: \"54.10.11.1\"},\n\t\t\t\t\t{Type: corev1.NodeInternalIP, Address: \"10.0.1.1\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\tName: \"my-node2\",\n\t\t\t},\n\t\t\tStatus: corev1.NodeStatus{\n\t\t\t\tAddresses: []corev1.NodeAddress{\n\t\t\t\t\t{Type: corev1.NodeExternalIP, Address: \"54.10.11.2\"},\n\t\t\t\t\t{Type: corev1.NodeInternalIP, Address: \"10.0.1.2\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n}\n\nfunc TestPodTransformerInPodSource(t *testing.T) {\n\tt.Run(\"transformer set\", func(t *testing.T) {\n\t\tctx := t.Context()\n\t\tfakeClient := fake.NewClientset()\n\n\t\tpod := &v1.Pod{\n\t\t\tSpec: v1.PodSpec{\n\t\t\t\tContainers: []v1.Container{{\n\t\t\t\t\tName: \"test\",\n\t\t\t\t}},\n\t\t\t\tHostname:    \"test-hostname\",\n\t\t\t\tNodeName:    \"test-node\",\n\t\t\t\tHostNetwork: true,\n\t\t\t},\n\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\tNamespace: \"test-ns\",\n\t\t\t\tName:      \"test-name\",\n\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\"label1\": \"value1\",\n\t\t\t\t\t\"label2\": \"value2\",\n\t\t\t\t\t\"label3\": \"value3\",\n\t\t\t\t},\n\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\"user-annotation\": \"value\",\n\t\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"test-hostname\",\n\t\t\t\t\t\"external-dns.alpha.kubernetes.io/random\":   \"value\",\n\t\t\t\t\t\"other/annotation\":                          \"value\",\n\t\t\t\t},\n\t\t\t\tUID: \"someuid\",\n\t\t\t},\n\t\t\tStatus: v1.PodStatus{\n\t\t\t\tPodIP:  \"127.0.0.1\",\n\t\t\t\tHostIP: \"127.0.0.2\",\n\t\t\t\tConditions: []v1.PodCondition{{\n\t\t\t\t\tType:   v1.PodReady,\n\t\t\t\t\tStatus: v1.ConditionTrue,\n\t\t\t\t}, {\n\t\t\t\t\tType:   v1.ContainersReady,\n\t\t\t\t\tStatus: v1.ConditionFalse,\n\t\t\t\t}},\n\t\t\t},\n\t\t}\n\n\t\t_, err := fakeClient.CoreV1().Pods(pod.Namespace).Create(t.Context(), pod, metav1.CreateOptions{})\n\t\trequire.NoError(t, err)\n\n\t\t// Should not error when creating the source\n\t\tsrc, err := NewPodSource(ctx, fakeClient, &Config{})\n\t\trequire.NoError(t, err)\n\t\tps, ok := src.(*podSource)\n\t\trequire.True(t, ok)\n\n\t\tretrieved, err := ps.podInformer.Lister().Pods(\"test-ns\").Get(\"test-name\")\n\t\trequire.NoError(t, err)\n\n\t\t// Metadata\n\t\tassert.Equal(t, \"test-name\", retrieved.Name)\n\t\tassert.Equal(t, \"test-ns\", retrieved.Namespace)\n\t\tassert.NotEmpty(t, retrieved.UID)\n\t\tassert.Empty(t, retrieved.Labels)\n\t\t// Filtered\n\t\tassert.Equal(t, map[string]string{\n\t\t\t\"user-annotation\": \"value\",\n\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"test-hostname\",\n\t\t\t\"external-dns.alpha.kubernetes.io/random\":   \"value\",\n\t\t\t\"other/annotation\":                          \"value\",\n\t\t}, retrieved.Annotations)\n\n\t\t// Spec\n\t\tassert.Empty(t, retrieved.Spec.Containers)\n\t\tassert.Empty(t, retrieved.Spec.Hostname)\n\t\tassert.Equal(t, \"test-node\", retrieved.Spec.NodeName)\n\t\tassert.True(t, retrieved.Spec.HostNetwork)\n\n\t\t// Status\n\t\tassert.Empty(t, retrieved.Status.ContainerStatuses)\n\t\tassert.Empty(t, retrieved.Status.InitContainerStatuses)\n\t\tassert.Empty(t, retrieved.Status.HostIP)\n\t\tassert.Equal(t, \"127.0.0.1\", retrieved.Status.PodIP)\n\t\tassert.Empty(t, retrieved.Status.Conditions)\n\t})\n\n\tt.Run(\"transformer is not used when fqdnTemplate is set\", func(t *testing.T) {\n\t\tfakeClient := fake.NewClientset()\n\n\t\tpod := &v1.Pod{\n\t\t\tSpec: v1.PodSpec{\n\t\t\t\tContainers: []v1.Container{{\n\t\t\t\t\tName: \"test\",\n\t\t\t\t}},\n\t\t\t\tHostname:    \"test-hostname\",\n\t\t\t\tNodeName:    \"test-node\",\n\t\t\t\tHostNetwork: true,\n\t\t\t},\n\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\tNamespace: \"test-ns\",\n\t\t\t\tName:      \"test-name\",\n\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\"label1\": \"value1\",\n\t\t\t\t\t\"label2\": \"value2\",\n\t\t\t\t\t\"label3\": \"value3\",\n\t\t\t\t},\n\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\"user-annotation\": \"value\",\n\t\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"test-hostname\",\n\t\t\t\t\t\"external-dns.alpha.kubernetes.io/random\":   \"value\",\n\t\t\t\t\t\"other/annotation\":                          \"value\",\n\t\t\t\t},\n\t\t\t\tUID: \"someuid\",\n\t\t\t},\n\t\t\tStatus: v1.PodStatus{\n\t\t\t\tPodIP:  \"127.0.0.1\",\n\t\t\t\tHostIP: \"127.0.0.2\",\n\t\t\t\tConditions: []v1.PodCondition{{\n\t\t\t\t\tType:   v1.PodReady,\n\t\t\t\t\tStatus: v1.ConditionTrue,\n\t\t\t\t}, {\n\t\t\t\t\tType:   v1.ContainersReady,\n\t\t\t\t\tStatus: v1.ConditionFalse,\n\t\t\t\t}},\n\t\t\t},\n\t\t}\n\n\t\t_, err := fakeClient.CoreV1().Pods(pod.Namespace).Create(t.Context(), pod, metav1.CreateOptions{})\n\t\trequire.NoError(t, err)\n\n\t\t// Should not error when creating the source\n\t\tsrc, err := NewPodSource(t.Context(), fakeClient, &Config{\n\t\t\tFQDNTemplate: \"template\",\n\t\t})\n\t\trequire.NoError(t, err)\n\t\tps, ok := src.(*podSource)\n\t\trequire.True(t, ok)\n\n\t\tretrieved, err := ps.podInformer.Lister().Pods(\"test-ns\").Get(\"test-name\")\n\t\trequire.NoError(t, err)\n\n\t\t// Metadata\n\t\tassert.Equal(t, \"test-name\", retrieved.Name)\n\t\tassert.Equal(t, \"test-ns\", retrieved.Namespace)\n\t\tassert.NotEmpty(t, retrieved.UID)\n\t\tassert.NotEmpty(t, retrieved.Labels)\n\t})\n}\n\nfunc TestProcessEndpoint_Pod_RefObjectExist(t *testing.T) {\n\telements := []runtime.Object{\n\t\t&v1.Pod{\n\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\tNamespace: \"01\",\n\t\t\t\tName:      \"foo\",\n\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\tannotations.HostnameKey: \"foo.example.com\",\n\t\t\t\t\tannotations.TargetKey:   \"1.2.3\",\n\t\t\t\t},\n\t\t\t\tUID: \"uid-1\",\n\t\t\t},\n\t\t},\n\t\t&v1.Pod{\n\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\tNamespace: \"02\",\n\t\t\t\tName:      \"bar\",\n\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\tannotations.HostnameKey: \"bar.example.com\",\n\t\t\t\t\tannotations.TargetKey:   \"3.4.5\",\n\t\t\t\t},\n\t\t\t\tUID: \"uid-2\",\n\t\t\t},\n\t\t},\n\t}\n\n\tfakeClient := fake.NewClientset(elements...)\n\n\tclient, err := NewPodSource(\n\t\tt.Context(),\n\t\tfakeClient,\n\t\t&Config{},\n\t)\n\trequire.NoError(t, err)\n\n\tendpoints, err := client.Endpoints(t.Context())\n\trequire.NoError(t, err)\n\ttestutils.AssertEndpointsHaveRefObject(t, endpoints, types.Pod, len(elements))\n}\n"
  },
  {
    "path": "source/service.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"maps\"\n\t\"net\"\n\t\"slices\"\n\t\"sort\"\n\t\"strings\"\n\t\"text/template\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\tv1 \"k8s.io/api/core/v1\"\n\tdiscoveryv1 \"k8s.io/api/discovery/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\tapitypes \"k8s.io/apimachinery/pkg/types\"\n\tkubeinformers \"k8s.io/client-go/informers\"\n\tcoreinformers \"k8s.io/client-go/informers/core/v1\"\n\tdiscoveryinformers \"k8s.io/client-go/informers/discovery/v1\"\n\t\"k8s.io/client-go/kubernetes\"\n\t\"k8s.io/client-go/tools/cache\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/pkg/events\"\n\t\"sigs.k8s.io/external-dns/provider\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n\t\"sigs.k8s.io/external-dns/source/fqdn\"\n\t\"sigs.k8s.io/external-dns/source/informers\"\n\t\"sigs.k8s.io/external-dns/source/types\"\n)\n\nvar (\n\tknownServiceTypes = map[v1.ServiceType]struct{}{\n\t\tv1.ServiceTypeClusterIP:    {}, // Default service type exposes the service on a cluster-internal IP.\n\t\tv1.ServiceTypeNodePort:     {}, // Exposes the service on each node's IP at a static port.\n\t\tv1.ServiceTypeLoadBalancer: {}, // Exposes the service externally using a cloud provider's load balancer.\n\t\tv1.ServiceTypeExternalName: {}, // Maps the service to an external DNS name.\n\t}\n\tserviceNameIndexKey = \"serviceName\"\n)\n\n// serviceSource is an implementation of Source for Kubernetes service objects.\n// It will find all services that are under our jurisdiction, i.e. annotated\n// desired hostname and matching or no controller annotation. For each of the\n// matched services' entrypoints it will return a corresponding\n// Endpoint object.\n// +externaldns:source:name=service\n// +externaldns:source:category=Kubernetes Core\n// +externaldns:source:description=Creates DNS entries based on Kubernetes Service resources\n// +externaldns:source:resources=Service\n// +externaldns:source:filters=annotation,label\n// +externaldns:source:namespace=all,single\n// +externaldns:source:fqdn-template=true\n// +externaldns:source:provider-specific=true\n// +externaldns:source:events=true\ntype serviceSource struct {\n\tclient                kubernetes.Interface\n\tnamespace             string\n\tannotationFilter      string\n\tlabelSelector         labels.Selector\n\tfqdnTemplate          *template.Template\n\tcombineFQDNAnnotation bool\n\n\tignoreHostnameAnnotation       bool\n\tpublishInternal                bool\n\tpublishHostIP                  bool\n\talwaysPublishNotReadyAddresses bool\n\tresolveLoadBalancerHostname    bool\n\tlistenEndpointEvents           bool\n\tserviceInformer                coreinformers.ServiceInformer\n\tendpointSlicesInformer         discoveryinformers.EndpointSliceInformer\n\tpodInformer                    coreinformers.PodInformer\n\tnodeInformer                   coreinformers.NodeInformer\n\tserviceTypeFilter              *serviceTypes\n\texposeInternalIPv6             bool\n\texcludeUnschedulable           bool\n\n\t// process Services with legacy annotations\n\tcompatibility string\n}\n\n// NewServiceSource creates a new serviceSource with the given config.\nfunc NewServiceSource(\n\tctx context.Context,\n\tkubeClient kubernetes.Interface,\n\tconfig *Config,\n) (Source, error) {\n\ttmpl, err := fqdn.ParseTemplate(config.FQDNTemplate)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tnamespace := config.Namespace\n\n\t// Use shared informers to listen for add/update/delete of services/pods/nodes in the specified namespace.\n\t// Set the resync period to 0 to prevent processing when nothing has changed\n\tinformerFactory := kubeinformers.NewSharedInformerFactoryWithOptions(kubeClient, 0, kubeinformers.WithNamespace(namespace))\n\tserviceInformer := informerFactory.Core().V1().Services()\n\n\t// Add default resource event handlers to properly initialize informer.\n\t_, _ = serviceInformer.Informer().AddEventHandler(informers.DefaultEventHandler())\n\n\t// Transform the slice into a map so it will be way much easier and fast to filter later\n\tsTypesFilter, err := newServiceTypesFilter(config.ServiceTypeFilter)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar endpointSlicesInformer discoveryinformers.EndpointSliceInformer\n\tvar podInformer coreinformers.PodInformer\n\tif sTypesFilter.isRequired(v1.ServiceTypeNodePort, v1.ServiceTypeClusterIP) {\n\t\tendpointSlicesInformer = informerFactory.Discovery().V1().EndpointSlices()\n\t\tpodInformer = informerFactory.Core().V1().Pods()\n\n\t\t_, _ = endpointSlicesInformer.Informer().AddEventHandler(informers.DefaultEventHandler())\n\t\t_, _ = podInformer.Informer().AddEventHandler(informers.DefaultEventHandler())\n\n\t\t// TODO: move to shared indexer in informer package\n\t\t// Add an indexer to the EndpointSlice informer to index by the service name label\n\t\terr = endpointSlicesInformer.Informer().AddIndexers(cache.Indexers{\n\t\t\tserviceNameIndexKey: func(obj any) ([]string, error) {\n\t\t\t\tendpointSlice, ok := obj.(*discoveryv1.EndpointSlice)\n\t\t\t\tif !ok {\n\t\t\t\t\t// This should never happen because the Informer should only contain EndpointSlice objects\n\t\t\t\t\treturn nil, fmt.Errorf(\"expected %T but got %T instead\", endpointSlice, obj)\n\t\t\t\t}\n\t\t\t\tserviceName := endpointSlice.Labels[discoveryv1.LabelServiceName]\n\t\t\t\tif serviceName == \"\" {\n\t\t\t\t\treturn nil, nil\n\t\t\t\t}\n\t\t\t\tkey := apitypes.NamespacedName{Namespace: endpointSlice.Namespace, Name: serviceName}.String()\n\t\t\t\treturn []string{key}, nil\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\t// TODO: move to shared transformer in informer package\n\t\t// Transformer is used to reduce the memory usage of the informer.\n\t\t// The pod informer will otherwise store a full in-memory, go-typed copy of all pod schemas in the cluster.\n\t\t// If watchList is not used it will not prevent memory bursts on the initial informer sync.\n\t\t_ = podInformer.Informer().SetTransform(func(i any) (any, error) {\n\t\t\tpod, ok := i.(*v1.Pod)\n\t\t\tif !ok {\n\t\t\t\treturn nil, fmt.Errorf(\"object is not a pod\")\n\t\t\t}\n\t\t\tif pod.UID == \"\" {\n\t\t\t\t// Pod was already transformed and we must be idempotent.\n\t\t\t\treturn pod, nil\n\t\t\t}\n\n\t\t\t// All pod level annotations we're interested in start with a common prefix\n\t\t\tpodAnnotations := map[string]string{}\n\t\t\tfor key, value := range pod.Annotations {\n\t\t\t\tif strings.HasPrefix(key, annotations.AnnotationKeyPrefix) {\n\t\t\t\t\tpodAnnotations[key] = value\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn &v1.Pod{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t// Name/namespace must always be kept for the informer to work.\n\t\t\t\t\tName:      pod.Name,\n\t\t\t\t\tNamespace: pod.Namespace,\n\t\t\t\t\t// Used to match services.\n\t\t\t\t\tLabels:            pod.Labels,\n\t\t\t\t\tAnnotations:       podAnnotations,\n\t\t\t\t\tDeletionTimestamp: pod.DeletionTimestamp,\n\t\t\t\t},\n\t\t\t\tSpec: v1.PodSpec{\n\t\t\t\t\tHostname: pod.Spec.Hostname,\n\t\t\t\t\tNodeName: pod.Spec.NodeName,\n\t\t\t\t},\n\t\t\t\tStatus: v1.PodStatus{\n\t\t\t\t\tHostIP:     pod.Status.HostIP,\n\t\t\t\t\tPhase:      pod.Status.Phase,\n\t\t\t\t\tConditions: pod.Status.Conditions,\n\t\t\t\t},\n\t\t\t}, nil\n\t\t})\n\t}\n\n\tvar nodeInformer coreinformers.NodeInformer\n\tif sTypesFilter.isRequired(v1.ServiceTypeNodePort) {\n\t\tnodeInformer = informerFactory.Core().V1().Nodes()\n\t\t_, _ = nodeInformer.Informer().AddEventHandler(informers.DefaultEventHandler())\n\t}\n\n\tinformerFactory.Start(ctx.Done())\n\n\t// wait for the local cache to be populated.\n\tif err := informers.WaitForCacheSync(ctx, informerFactory); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &serviceSource{\n\t\tclient:                         kubeClient,\n\t\tnamespace:                      namespace,\n\t\tannotationFilter:               config.AnnotationFilter,\n\t\tcompatibility:                  config.Compatibility,\n\t\tfqdnTemplate:                   tmpl,\n\t\tcombineFQDNAnnotation:          config.CombineFQDNAndAnnotation,\n\t\tignoreHostnameAnnotation:       config.IgnoreHostnameAnnotation,\n\t\tpublishInternal:                config.PublishInternal,\n\t\tpublishHostIP:                  config.PublishHostIP,\n\t\talwaysPublishNotReadyAddresses: config.AlwaysPublishNotReadyAddresses,\n\t\tserviceInformer:                serviceInformer,\n\t\tendpointSlicesInformer:         endpointSlicesInformer,\n\t\tpodInformer:                    podInformer,\n\t\tnodeInformer:                   nodeInformer,\n\t\tserviceTypeFilter:              sTypesFilter,\n\t\tlabelSelector:                  config.LabelFilter,\n\t\tresolveLoadBalancerHostname:    config.ResolveLoadBalancerHostname,\n\t\tlistenEndpointEvents:           config.ListenEndpointEvents,\n\t\texposeInternalIPv6:             config.ExposeInternalIPv6,\n\t\texcludeUnschedulable:           config.ExcludeUnschedulable,\n\t}, nil\n}\n\n// Endpoints return endpoint objects for each service that should be processed.\nfunc (sc *serviceSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) {\n\tservices, err := sc.serviceInformer.Lister().Services(sc.namespace).List(sc.labelSelector)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// filter on service types if at least one has been provided\n\tservices = sc.filterByServiceType(services)\n\n\tservices, err = annotations.Filter(services, sc.annotationFilter)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tendpoints := make([]*endpoint.Endpoint, 0)\n\n\tfor _, svc := range services {\n\t\tif annotations.IsControllerMismatch(svc, types.Service) {\n\t\t\tcontinue\n\t\t}\n\n\t\tsvcEndpoints := sc.endpoints(svc)\n\n\t\t// process legacy annotations if no endpoints were returned and compatibility mode is enabled.\n\t\tif len(svcEndpoints) == 0 && sc.compatibility != \"\" {\n\t\t\tsvcEndpoints, err = legacyEndpointsFromService(svc, sc)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t}\n\n\t\t// apply template if none of the above is found\n\t\tsvcEndpoints, err = fqdn.CombineWithTemplatedEndpoints(\n\t\t\tsvcEndpoints,\n\t\t\tsc.fqdnTemplate,\n\t\t\tsc.combineFQDNAnnotation,\n\t\t\tfunc() ([]*endpoint.Endpoint, error) { return sc.endpointsFromTemplate(svc) },\n\t\t)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif endpoint.HasNoEmptyEndpoints(svcEndpoints, types.Service, svc) {\n\t\t\tcontinue\n\t\t}\n\n\t\tendpoint.AttachRefObject(svcEndpoints, events.NewObjectReference(svc, types.Service))\n\n\t\tlog.Debugf(\"Endpoints generated from service: %s/%s: %v\", svc.Namespace, svc.Name, svcEndpoints)\n\t\tendpoints = append(endpoints, svcEndpoints...)\n\t}\n\n\t// this sorting is required to make merging work.\n\t// after we merge endpoints that have same DNS, we want to ensure that we end up with the same service being an \"owner\"\n\t// of all those records, as otherwise each time we update, we will end up with a different service that gets data merged in\n\t// and that will cause external-dns to recreate dns record due to different service owner in TXT record.\n\t// if new service is added or old one removed, that might cause existing record to get re-created due to potentially new\n\t// owner being selected. Which is fine, since it shouldn't happen often and shouldn't cause any disruption.\n\tif len(endpoints) > 1 {\n\t\tsort.Slice(endpoints, func(i, j int) bool {\n\t\t\treturn endpoints[i].Labels[endpoint.ResourceLabelKey] < endpoints[j].Labels[endpoint.ResourceLabelKey]\n\t\t})\n\t\tmergedEndpoints := make(map[endpoint.EndpointKey][]*endpoint.Endpoint)\n\t\tfor _, ep := range endpoints {\n\t\t\tkey := ep.Key()\n\t\t\tif existing, ok := mergedEndpoints[key]; ok {\n\t\t\t\tif existing[0].RecordType == endpoint.RecordTypeCNAME {\n\t\t\t\t\tlog.Debugf(\"CNAME %s with multiple targets found\", ep.DNSName)\n\t\t\t\t\tmergedEndpoints[key] = append(existing, ep)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\texisting[0].Targets = append(existing[0].Targets, ep.Targets...)\n\t\t\t\texisting[0].Targets = endpoint.NewTargets(existing[0].Targets...)\n\t\t\t\tmergedEndpoints[key] = existing\n\t\t\t} else {\n\t\t\t\tep.Targets = endpoint.NewTargets(ep.Targets...)\n\t\t\t\tmergedEndpoints[key] = []*endpoint.Endpoint{ep}\n\t\t\t}\n\t\t}\n\t\tprocessed := make([]*endpoint.Endpoint, 0, len(mergedEndpoints))\n\t\tfor _, ep := range mergedEndpoints {\n\t\t\tprocessed = append(processed, ep...)\n\t\t}\n\t\tendpoints = processed\n\n\t\t// Use stable sort to not disrupt the order of services\n\t\tsort.SliceStable(endpoints, func(i, j int) bool {\n\t\t\tif endpoints[i].DNSName != endpoints[j].DNSName {\n\t\t\t\treturn endpoints[i].DNSName < endpoints[j].DNSName\n\t\t\t}\n\t\t\treturn endpoints[i].RecordType < endpoints[j].RecordType\n\t\t})\n\t}\n\n\treturn MergeEndpoints(endpoints), nil\n}\n\n// extractHeadlessEndpoints extracts endpoints from a headless service using the \"Endpoints\" Kubernetes API resource\nfunc (sc *serviceSource) extractHeadlessEndpoints(svc *v1.Service, hostname string, ttl endpoint.TTL) []*endpoint.Endpoint {\n\tvar endpoints []*endpoint.Endpoint\n\n\tselector, err := annotations.ParseFilter(labels.Set(svc.Spec.Selector).AsSelectorPreValidated().String())\n\tif err != nil {\n\t\treturn nil\n\t}\n\n\tserviceKey := cache.ObjectName{Namespace: svc.Namespace, Name: svc.Name}.String()\n\trawEndpointSlices, err := sc.endpointSlicesInformer.Informer().GetIndexer().ByIndex(serviceNameIndexKey, serviceKey)\n\tif err != nil {\n\t\tlog.Errorf(\"Get EndpointSlices of service[%s] error:%v\", svc.GetName(), err)\n\t\treturn nil\n\t}\n\n\tendpointSlices := convertToEndpointSlices(rawEndpointSlices)\n\tpods, err := sc.podInformer.Lister().Pods(svc.Namespace).List(selector)\n\tif err != nil {\n\t\tlog.Errorf(\"List Pods of service[%s] error:%v\", svc.GetName(), err)\n\t\treturn endpoints\n\t}\n\n\tendpointsType := getEndpointsTypeFromAnnotations(svc.Annotations)\n\tpublishPodIPs := endpointsType != EndpointsTypeNodeExternalIP && endpointsType != EndpointsTypeHostIP && !sc.publishHostIP\n\tpublishNotReadyAddresses := svc.Spec.PublishNotReadyAddresses || sc.alwaysPublishNotReadyAddresses\n\n\ttargetsByHeadlessDomainAndType := sc.processHeadlessEndpointsFromSlices(\n\t\tpods, endpointSlices, hostname, endpointsType, publishPodIPs, publishNotReadyAddresses)\n\tendpoints = buildHeadlessEndpoints(svc, targetsByHeadlessDomainAndType, ttl)\n\n\treturn endpoints\n}\n\n// Helper to convert raw objects to EndpointSlice\nfunc convertToEndpointSlices(rawEndpointSlices []any) []*discoveryv1.EndpointSlice {\n\tendpointSlices := make([]*discoveryv1.EndpointSlice, 0, len(rawEndpointSlices))\n\tfor _, obj := range rawEndpointSlices {\n\t\tendpointSlice, ok := obj.(*discoveryv1.EndpointSlice)\n\t\tif !ok {\n\t\t\tlog.Errorf(\"Expected EndpointSlice but got %T instead, skipping\", obj)\n\t\t\tcontinue\n\t\t}\n\t\tendpointSlices = append(endpointSlices, endpointSlice)\n\t}\n\treturn endpointSlices\n}\n\n// processHeadlessEndpointsFromSlices processes EndpointSlices specifically for headless services\n// and returns deduped targets by domain/type.\n// TODO: Consider refactoring with generics when available: https://github.com/kubernetes/kubernetes/issues/133544\nfunc (sc *serviceSource) processHeadlessEndpointsFromSlices(\n\tpods []*v1.Pod,\n\tendpointSlices []*discoveryv1.EndpointSlice,\n\thostname string,\n\tendpointsType string,\n\tpublishPodIPs bool,\n\tpublishNotReadyAddresses bool,\n) map[endpoint.EndpointKey]endpoint.Targets {\n\ttargetsByHeadlessDomainAndType := make(map[endpoint.EndpointKey]endpoint.Targets)\n\tfor _, endpointSlice := range endpointSlices {\n\t\tfor _, ep := range endpointSlice.Endpoints {\n\t\t\tif !conditionToBool(ep.Conditions.Ready) && !publishNotReadyAddresses {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif publishPodIPs && endpointSlice.AddressType != discoveryv1.AddressTypeIPv4 && endpointSlice.AddressType != discoveryv1.AddressTypeIPv6 {\n\t\t\t\tlog.Debugf(\"Skipping EndpointSlice %s/%s because its address type is unsupported: %s\", endpointSlice.Namespace, endpointSlice.Name, endpointSlice.AddressType)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tpod := findPodForEndpoint(ep, pods)\n\t\t\tif pod == nil {\n\t\t\t\tif ep.TargetRef != nil {\n\t\t\t\t\tlog.Errorf(\"Pod %s not found for address %v\", ep.TargetRef.Name, ep)\n\t\t\t\t} else {\n\t\t\t\t\tlog.Errorf(\"Pod not found for endpoint with nil TargetRef: %v\", ep)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\theadlessDomains := []string{hostname}\n\t\t\tif pod.Spec.Hostname != \"\" {\n\t\t\t\theadlessDomains = append(headlessDomains, fmt.Sprintf(\"%s.%s\", pod.Spec.Hostname, hostname))\n\t\t\t}\n\t\t\tfor _, headlessDomain := range headlessDomains {\n\t\t\t\ttargets := sc.getTargetsForDomain(pod, ep, endpointSlice, endpointsType, headlessDomain)\n\t\t\t\tfor _, target := range targets {\n\t\t\t\t\tkey := endpoint.EndpointKey{\n\t\t\t\t\t\tDNSName:    headlessDomain,\n\t\t\t\t\t\tRecordType: endpoint.SuitableType(target),\n\t\t\t\t\t}\n\t\t\t\t\ttargetsByHeadlessDomainAndType[key] = append(targetsByHeadlessDomainAndType[key], target)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\t// Return a copy of the map to prevent external modifications\n\tresult := make(map[endpoint.EndpointKey]endpoint.Targets, len(targetsByHeadlessDomainAndType))\n\tfor k, v := range targetsByHeadlessDomainAndType {\n\t\tresult[k] = append(endpoint.Targets(nil), v...)\n\t}\n\treturn result\n}\n\n// Helper to find pod for endpoint\nfunc findPodForEndpoint(ep discoveryv1.Endpoint, pods []*v1.Pod) *v1.Pod {\n\tif ep.TargetRef == nil || ep.TargetRef.APIVersion != \"\" || ep.TargetRef.Kind != \"Pod\" {\n\t\tlog.Debugf(\"Skipping address because its target is not a pod: %v\", ep)\n\t\treturn nil\n\t}\n\tfor _, v := range pods {\n\t\tif v.Name == ep.TargetRef.Name {\n\t\t\treturn v\n\t\t}\n\t}\n\treturn nil\n}\n\n// Helper to get targets for domain\nfunc (sc *serviceSource) getTargetsForDomain(\n\tpod *v1.Pod,\n\tep discoveryv1.Endpoint,\n\tendpointSlice *discoveryv1.EndpointSlice,\n\tendpointsType, headlessDomain string) endpoint.Targets {\n\ttargets := annotations.TargetsFromTargetAnnotation(pod.Annotations)\n\tif len(targets) == 0 {\n\t\tswitch {\n\t\tcase endpointsType == EndpointsTypeNodeExternalIP:\n\t\t\tif sc.nodeInformer == nil {\n\t\t\t\tlog.Warnf(\"Skipping EndpointSlice %s/%s as --service-type-filter disable node informer\", endpointSlice.Namespace, endpointSlice.Name)\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tnode, err := sc.nodeInformer.Lister().Get(pod.Spec.NodeName)\n\t\t\tif err != nil {\n\t\t\t\tlog.Errorf(\"Get node[%s] of pod[%s] error: %v; not adding any NodeExternalIP endpoints\", pod.Spec.NodeName, pod.GetName(), err)\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tfor _, address := range node.Status.Addresses {\n\t\t\t\tif address.Type == v1.NodeExternalIP || (sc.exposeInternalIPv6 && address.Type == v1.NodeInternalIP && endpoint.SuitableType(address.Address) == endpoint.RecordTypeAAAA) {\n\t\t\t\t\ttargets = append(targets, address.Address)\n\t\t\t\t\tlog.Debugf(\"Generating matching endpoint %s with NodeExternalIP %s\", headlessDomain, address.Address)\n\t\t\t\t}\n\t\t\t}\n\t\tcase endpointsType == EndpointsTypeHostIP || sc.publishHostIP:\n\t\t\ttargets = endpoint.Targets{pod.Status.HostIP}\n\t\t\tlog.Debugf(\"Generating matching endpoint %s with HostIP %s\", headlessDomain, pod.Status.HostIP)\n\t\tdefault:\n\t\t\tif len(ep.Addresses) == 0 {\n\t\t\t\tlog.Warnf(\"EndpointSlice %s/%s has no addresses for endpoint %v\", endpointSlice.Namespace, endpointSlice.Name, ep)\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\taddress := ep.Addresses[0]\n\t\t\ttargets = endpoint.Targets{address}\n\t\t\tlog.Debugf(\"Generating matching endpoint %s with EndpointSliceAddress IP %s\", headlessDomain, address)\n\t\t}\n\t}\n\treturn targets\n}\n\n// Helper to build endpoints from deduped targets\nfunc buildHeadlessEndpoints(svc *v1.Service, targetsByHeadlessDomainAndType map[endpoint.EndpointKey]endpoint.Targets, ttl endpoint.TTL) []*endpoint.Endpoint {\n\tvar endpoints []*endpoint.Endpoint\n\theadlessKeys := make([]endpoint.EndpointKey, 0, len(targetsByHeadlessDomainAndType))\n\tfor headlessKey := range targetsByHeadlessDomainAndType {\n\t\theadlessKeys = append(headlessKeys, headlessKey)\n\t}\n\tsort.Slice(headlessKeys, func(i, j int) bool {\n\t\tif headlessKeys[i].DNSName != headlessKeys[j].DNSName {\n\t\t\treturn headlessKeys[i].DNSName < headlessKeys[j].DNSName\n\t\t}\n\t\treturn headlessKeys[i].RecordType < headlessKeys[j].RecordType\n\t})\n\tfor _, headlessKey := range headlessKeys {\n\t\tallTargets := targetsByHeadlessDomainAndType[headlessKey]\n\t\ttargets := []string{}\n\t\tdeduppedTargets := map[string]struct{}{}\n\t\tfor _, target := range allTargets {\n\t\t\tif _, ok := deduppedTargets[target]; ok {\n\t\t\t\tlog.Debugf(\"Removing duplicate target %s\", target)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdeduppedTargets[target] = struct{}{}\n\t\t\ttargets = append(targets, target)\n\t\t}\n\t\tvar ep *endpoint.Endpoint\n\t\tif ttl.IsConfigured() {\n\t\t\tep = endpoint.NewEndpointWithTTL(headlessKey.DNSName, headlessKey.RecordType, ttl, targets...)\n\t\t} else {\n\t\t\tep = endpoint.NewEndpoint(headlessKey.DNSName, headlessKey.RecordType, targets...)\n\t\t}\n\t\tif ep != nil {\n\t\t\tep.WithLabel(endpoint.ResourceLabelKey, fmt.Sprintf(\"service/%s/%s\", svc.Namespace, svc.Name))\n\t\t\tendpoints = append(endpoints, ep)\n\t\t}\n\t}\n\treturn endpoints\n}\n\nfunc (sc *serviceSource) endpointsFromTemplate(svc *v1.Service) ([]*endpoint.Endpoint, error) {\n\thostnames, err := fqdn.ExecTemplate(sc.fqdnTemplate, svc)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tproviderSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(svc.Annotations)\n\n\tvar endpoints []*endpoint.Endpoint\n\tfor _, hostname := range hostnames {\n\t\tendpoints = append(endpoints, sc.generateEndpoints(svc, hostname, providerSpecific, setIdentifier, false)...)\n\t}\n\n\treturn endpoints, nil\n}\n\n// endpointsFromService extracts the endpoints from a service object\nfunc (sc *serviceSource) endpoints(svc *v1.Service) []*endpoint.Endpoint {\n\tvar endpoints []*endpoint.Endpoint\n\n\t// Skip endpoints if we do not want entries from annotations or service is excluded\n\tif sc.ignoreHostnameAnnotation {\n\t\treturn endpoints\n\t}\n\n\tproviderSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(svc.Annotations)\n\tvar hostnameList []string\n\tvar internalHostnameList []string\n\n\thostnameList = annotations.HostnamesFromAnnotations(svc.Annotations)\n\tfor _, hostname := range hostnameList {\n\t\tendpoints = append(endpoints, sc.generateEndpoints(svc, hostname, providerSpecific, setIdentifier, false)...)\n\t}\n\n\tinternalHostnameList = annotations.InternalHostnamesFromAnnotations(svc.Annotations)\n\tfor _, hostname := range internalHostnameList {\n\t\tendpoints = append(endpoints, sc.generateEndpoints(svc, hostname, providerSpecific, setIdentifier, true)...)\n\t}\n\n\treturn endpoints\n}\n\n// filterByServiceType filters services according to their types\nfunc (sc *serviceSource) filterByServiceType(services []*v1.Service) []*v1.Service {\n\tif !sc.serviceTypeFilter.enabled || len(services) == 0 {\n\t\treturn services\n\t}\n\tvar result []*v1.Service\n\tfor _, service := range services {\n\t\tif sc.serviceTypeFilter.isProcessed(service.Spec.Type) {\n\t\t\tresult = append(result, service)\n\t\t}\n\t}\n\tlog.Debugf(\"filtered %d services out of %d with service types filter %q\", len(result), len(services), slices.Collect(maps.Keys(sc.serviceTypeFilter.types)))\n\treturn result\n}\n\nfunc (sc *serviceSource) generateEndpoints(svc *v1.Service, hostname string, providerSpecific endpoint.ProviderSpecific, setIdentifier string, useClusterIP bool) []*endpoint.Endpoint {\n\thostname = strings.TrimSuffix(hostname, \".\")\n\n\tresource := fmt.Sprintf(\"service/%s/%s\", svc.Namespace, svc.Name)\n\n\tttl := annotations.TTLFromAnnotations(svc.Annotations, resource)\n\n\ttargets := annotations.TargetsFromTargetAnnotation(svc.Annotations)\n\n\tendpoints := make([]*endpoint.Endpoint, 0)\n\n\tif len(targets) == 0 {\n\t\tswitch svc.Spec.Type {\n\t\tcase v1.ServiceTypeLoadBalancer:\n\t\t\tif useClusterIP {\n\t\t\t\ttargets = extractServiceIps(svc)\n\t\t\t} else {\n\t\t\t\ttargets = extractLoadBalancerTargets(svc, sc.resolveLoadBalancerHostname)\n\t\t\t}\n\t\tcase v1.ServiceTypeClusterIP:\n\t\t\tif svc.Spec.ClusterIP == v1.ClusterIPNone {\n\t\t\t\tendpoints = append(endpoints, sc.extractHeadlessEndpoints(svc, hostname, ttl)...)\n\t\t\t} else if useClusterIP || sc.publishInternal {\n\t\t\t\ttargets = extractServiceIps(svc)\n\t\t\t}\n\t\tcase v1.ServiceTypeNodePort:\n\t\t\t// add the nodeTargets and extract an SRV endpoint\n\t\t\tvar err error\n\t\t\ttargets, err = sc.extractNodePortTargets(svc)\n\t\t\tif err != nil {\n\t\t\t\tlog.Errorf(\"Unable to extract targets from service %s/%s error: %v\", svc.Namespace, svc.Name, err)\n\t\t\t\treturn endpoints\n\t\t\t}\n\t\t\tendpoints = append(endpoints, sc.extractNodePortEndpoints(svc, hostname, ttl)...)\n\t\tcase v1.ServiceTypeExternalName:\n\t\t\ttargets = extractServiceExternalName(svc)\n\t\t}\n\n\t\tfor _, en := range endpoints {\n\t\t\ten.ProviderSpecific = providerSpecific\n\t\t\ten.SetIdentifier = setIdentifier\n\t\t}\n\t}\n\n\tendpoints = append(endpoints, endpoint.EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...)\n\n\treturn endpoints\n}\n\nfunc extractServiceIps(svc *v1.Service) endpoint.Targets {\n\tif svc.Spec.ClusterIP == v1.ClusterIPNone {\n\t\tlog.Debugf(\"Unable to associate %s headless service with a Cluster IP\", svc.Name)\n\t\treturn endpoint.Targets{}\n\t}\n\treturn endpoint.Targets{svc.Spec.ClusterIP}\n}\n\nfunc extractServiceExternalName(svc *v1.Service) endpoint.Targets {\n\tif len(svc.Spec.ExternalIPs) > 0 {\n\t\treturn svc.Spec.ExternalIPs\n\t}\n\treturn endpoint.Targets{svc.Spec.ExternalName}\n}\n\nfunc extractLoadBalancerTargets(svc *v1.Service, resolveLoadBalancerHostname bool) endpoint.Targets {\n\tif len(svc.Spec.ExternalIPs) > 0 {\n\t\treturn svc.Spec.ExternalIPs\n\t}\n\n\t// Create a corresponding endpoint for each configured external entrypoint.\n\tvar targets endpoint.Targets\n\tfor _, lb := range svc.Status.LoadBalancer.Ingress {\n\t\tif lb.IP != \"\" {\n\t\t\ttargets = append(targets, lb.IP)\n\t\t}\n\t\tif lb.Hostname != \"\" {\n\t\t\tif resolveLoadBalancerHostname {\n\t\t\t\tips, err := net.LookupIP(lb.Hostname)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlog.Errorf(\"Unable to resolve %q: %v\", lb.Hostname, err)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tfor _, ip := range ips {\n\t\t\t\t\ttargets = append(targets, ip.String())\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\ttargets = append(targets, lb.Hostname)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn targets\n}\n\nfunc isPodStatusReady(status v1.PodStatus) bool {\n\tfor _, c := range status.Conditions {\n\t\tif c.Type == v1.PodReady {\n\t\t\treturn c.Status == v1.ConditionTrue\n\t\t}\n\t}\n\treturn false\n}\n\n// nodesExternalTrafficPolicyTypeLocal returns nodes that have running pods for the given\n// NodePort service with externalTrafficPolicy=Local. Only nodes at the highest available\n// pod readiness level are returned — nodes with lower readiness are excluded when better\n// ones exist.\nfunc (sc *serviceSource) nodesExternalTrafficPolicyTypeLocal(svc *v1.Service) []*v1.Node {\n\t// Pod states ranked by readiness then termination; PodRunning phase is a precondition.\n\t// Values start at 1 so that the zero value of the bestPriority map acts as a\n\t// \"node not yet seen\" sentinel, making max() correct on the first pod without\n\t// special-casing.\n\tconst (\n\t\tnotReady            = iota + 1 // PodRunning, not ready\n\t\treadyTerminating               // PodRunning, ready, terminating\n\t\treadyNonTerminating            // PodRunning, ready, non-terminating\n\t)\n\n\tbestPriority := map[*v1.Node]int{}\n\tmaxPriority := 0\n\n\tfor _, v := range sc.pods(svc) {\n\t\tif v.Status.Phase != v1.PodRunning {\n\t\t\tcontinue\n\t\t}\n\t\tnode, err := sc.nodeInformer.Lister().Get(v.Spec.NodeName)\n\t\tif err != nil {\n\t\t\tlog.Debugf(\"Skipping pod %s/%s: node %s not found\", v.Namespace, v.Name, v.Spec.NodeName)\n\t\t\tcontinue\n\t\t}\n\n\t\tp := notReady\n\t\tif isPodStatusReady(v.Status) {\n\t\t\tp = readyTerminating\n\t\t\tif v.GetDeletionTimestamp() == nil {\n\t\t\t\tp = readyNonTerminating\n\t\t\t}\n\t\t}\n\t\tbestPriority[node] = max(bestPriority[node], p)\n\t\tmaxPriority = max(maxPriority, p)\n\t}\n\n\tswitch maxPriority {\n\tcase 0:\n\t\treturn nil\n\tcase notReady:\n\t\tlog.Debugf(\"No ready pods found, falling back to running pods\")\n\tcase readyTerminating:\n\t\tlog.Debugf(\"No non-terminating ready pods found, falling back to terminating ready pods\")\n\tcase readyNonTerminating:\n\t\tlog.Debugf(\"Ready non-terminating pods found\")\n\t}\n\n\t// Only return nodes at the highest readiness level available across the cluster.\n\tnodes := make([]*v1.Node, 0, len(bestPriority))\n\tfor node, p := range bestPriority {\n\t\tif p == maxPriority {\n\t\t\tnodes = append(nodes, node)\n\t\t}\n\t}\n\treturn nodes\n}\n\n// pods retrieve a slice of pods associated with the given Service\nfunc (sc *serviceSource) pods(svc *v1.Service) []*v1.Pod {\n\tselector, err := annotations.ParseFilter(labels.Set(svc.Spec.Selector).AsSelectorPreValidated().String())\n\tif err != nil {\n\t\treturn nil\n\t}\n\tpods, err := sc.podInformer.Lister().Pods(svc.Namespace).List(selector)\n\tif err != nil {\n\t\treturn nil\n\t}\n\n\treturn pods\n}\n\nfunc (sc *serviceSource) extractNodePortTargets(svc *v1.Service) (endpoint.Targets, error) {\n\tvar (\n\t\tinternalIPs endpoint.Targets\n\t\texternalIPs endpoint.Targets\n\t\tipv6IPs     endpoint.Targets\n\t\tnodes       []*v1.Node\n\t)\n\n\tif svc.Spec.ExternalTrafficPolicy == v1.ServiceExternalTrafficPolicyTypeLocal {\n\t\tnodes = sc.nodesExternalTrafficPolicyTypeLocal(svc)\n\t} else {\n\t\tvar err error\n\t\tnodes, err = sc.nodeInformer.Lister().List(labels.Everything())\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tfor _, node := range nodes {\n\t\tif node.Spec.Unschedulable && sc.excludeUnschedulable {\n\t\t\tlog.Debugf(\"Skipping node %s - unschedulable\", node.Name)\n\t\t\tcontinue\n\t\t}\n\t\tfor _, address := range node.Status.Addresses {\n\t\t\tswitch address.Type {\n\t\t\tcase v1.NodeExternalIP:\n\t\t\t\texternalIPs = append(externalIPs, address.Address)\n\t\t\tcase v1.NodeInternalIP:\n\t\t\t\tinternalIPs = append(internalIPs, address.Address)\n\t\t\t\tif endpoint.SuitableType(address.Address) == endpoint.RecordTypeAAAA {\n\t\t\t\t\tipv6IPs = append(ipv6IPs, address.Address)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\taccess := getAccessFromAnnotations(svc.Annotations)\n\tswitch access {\n\tcase \"public\":\n\t\tif sc.exposeInternalIPv6 {\n\t\t\treturn append(externalIPs, ipv6IPs...), nil\n\t\t}\n\t\treturn externalIPs, nil\n\tcase \"private\":\n\t\treturn internalIPs, nil\n\t}\n\n\tif len(externalIPs) > 0 {\n\t\tif sc.exposeInternalIPv6 {\n\t\t\treturn append(externalIPs, ipv6IPs...), nil\n\t\t}\n\t\treturn externalIPs, nil\n\t}\n\n\treturn internalIPs, nil\n}\n\nfunc (sc *serviceSource) extractNodePortEndpoints(svc *v1.Service, hostname string, ttl endpoint.TTL) []*endpoint.Endpoint {\n\tvar endpoints []*endpoint.Endpoint\n\n\tfor _, port := range svc.Spec.Ports {\n\t\tif port.NodePort > 0 {\n\t\t\t// following the RFC 2782, SRV record must have a following format\n\t\t\t// _service._proto.name. TTL class SRV priority weight port\n\t\t\t// see https://en.wikipedia.org/wiki/SRV_record\n\n\t\t\t// build a target with a priority of 0, weight of 50, and pointing the given port on the given host\n\t\t\ttarget := fmt.Sprintf(\"0 50 %d %s\", port.NodePort, provider.EnsureTrailingDot(hostname))\n\n\t\t\t// take the service name from the K8s Service object\n\t\t\t// it is safe to use since it is DNS compatible\n\t\t\t// see https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-label-names\n\t\t\tserviceName := svc.Name\n\n\t\t\t// figure out the protocol\n\t\t\tprotocol := strings.ToLower(string(port.Protocol))\n\t\t\tif protocol == \"\" {\n\t\t\t\tprotocol = \"tcp\"\n\t\t\t}\n\n\t\t\trecordName := fmt.Sprintf(\"_%s._%s.%s\", serviceName, protocol, hostname)\n\n\t\t\tvar ep *endpoint.Endpoint\n\t\t\tif ttl.IsConfigured() {\n\t\t\t\tep = endpoint.NewEndpointWithTTL(recordName, endpoint.RecordTypeSRV, ttl, target)\n\t\t\t} else {\n\t\t\t\tep = endpoint.NewEndpoint(recordName, endpoint.RecordTypeSRV, target)\n\t\t\t}\n\n\t\t\tif ep != nil {\n\t\t\t\tep.WithLabel(endpoint.ResourceLabelKey, fmt.Sprintf(\"service/%s/%s\", svc.Namespace, svc.Name))\n\t\t\t\tendpoints = append(endpoints, ep)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn endpoints\n}\n\nfunc (sc *serviceSource) AddEventHandler(_ context.Context, handler func()) {\n\tlog.Debug(\"Adding event handler for service\")\n\n\t// Right now there is no way to remove event handler from informer, see:\n\t// https://github.com/kubernetes/kubernetes/issues/79610\n\t_, _ = sc.serviceInformer.Informer().AddEventHandler(eventHandlerFunc(handler))\n\tif sc.listenEndpointEvents && sc.serviceTypeFilter.isRequired(v1.ServiceTypeNodePort, v1.ServiceTypeClusterIP) {\n\t\t_, _ = sc.endpointSlicesInformer.Informer().AddEventHandler(eventHandlerFunc(handler))\n\t}\n}\n\ntype serviceTypes struct {\n\tenabled bool\n\ttypes   map[v1.ServiceType]bool\n}\n\n// newServiceTypesFilter processes a slice of service type filter strings and returns a serviceTypes struct.\n// It validates the filter against known Kubernetes service types. If the filter is empty or contains an empty string,\n// service type filtering is disabled. If an unknown type is found, an error is returned.\nfunc newServiceTypesFilter(filter []string) (*serviceTypes, error) {\n\tif len(filter) == 0 || slices.Contains(filter, \"\") {\n\t\treturn &serviceTypes{\n\t\t\tenabled: false,\n\t\t}, nil\n\t}\n\tresult := make(map[v1.ServiceType]bool)\n\tfor _, serviceType := range filter {\n\t\tif _, ok := knownServiceTypes[v1.ServiceType(serviceType)]; !ok {\n\t\t\treturn nil, fmt.Errorf(\"unsupported service type filter: %q. Supported types are: %q\", serviceType, slices.Collect(maps.Keys(knownServiceTypes)))\n\t\t}\n\t\tresult[v1.ServiceType(serviceType)] = true\n\t}\n\n\treturn &serviceTypes{\n\t\tenabled: true,\n\t\ttypes:   result,\n\t}, nil\n}\n\nfunc (sc *serviceTypes) isProcessed(serviceType v1.ServiceType) bool {\n\treturn !sc.enabled || sc.types[serviceType]\n}\n\n// isRequired returns true if service type filtering is disabled or if any of the provided service types are present in the filter.\n// If no options are provided, it returns true.\nfunc (sc *serviceTypes) isRequired(opts ...v1.ServiceType) bool {\n\tif len(opts) == 0 || !sc.enabled {\n\t\treturn true\n\t}\n\tfor _, opt := range opts {\n\t\tif _, ok := sc.types[opt]; ok {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// conditionToBool converts an EndpointConditions condition to a bool value.\nfunc conditionToBool(v *bool) bool {\n\tif v == nil {\n\t\treturn true // nil should be interpreted as \"true\" as per EndpointConditions spec\n\t}\n\treturn *v\n}\n"
  },
  {
    "path": "source/service_fqdn_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n    http://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"fmt\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n\tv1 \"k8s.io/api/core/v1\"\n\tdiscoveryv1 \"k8s.io/api/discovery/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\t\"k8s.io/apimachinery/pkg/util/intstr\"\n\t\"k8s.io/client-go/kubernetes/fake\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/internal/testutils\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n)\n\nfunc TestServiceSourceFqdnTemplatingExamples(t *testing.T) {\n\n\tfor _, tt := range []struct {\n\t\ttitle              string\n\t\tservices           []*v1.Service\n\t\tendpointSlices     []*discoveryv1.EndpointSlice\n\t\tfqdnTemplate       string\n\t\tcombineFQDN        bool\n\t\tpublishHostIp      bool\n\t\tserviceTypesFilter []string\n\t\texpected           []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\ttitle:       \"templating with multiple services\",\n\t\t\tcombineFQDN: true,\n\t\t\tservices: []*v1.Service{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tName:      \"service-1\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:      v1.ServiceTypeClusterIP,\n\t\t\t\t\t\tClusterIP: \"170.19.58.167\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.ServiceStatus{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace: \"kube-system\",\n\t\t\t\t\t\tName:      \"service-2\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:      v1.ServiceTypeClusterIP,\n\t\t\t\t\t\tClusterIP: \"127.20.24.218\",\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.ServiceStatus{},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{ .Name }}.{{ .Namespace }}.example.tld, all.example.org\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"all.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"127.20.24.218\", \"170.19.58.167\"}},\n\t\t\t\t{DNSName: \"service-1.default.example.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"170.19.58.167\"}},\n\t\t\t\t{DNSName: \"service-2.kube-system.example.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"127.20.24.218\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:       \"templating resolve service source with internal hostnames\",\n\t\t\tcombineFQDN: true,\n\t\t\tservices: []*v1.Service{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tName:      \"service-one\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.InternalHostnameKey: \"service-one.internal.tld,service-one.internal.example.tld\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:       v1.ServiceTypeLoadBalancer,\n\t\t\t\t\t\tClusterIP:  \"192.240.240.3\",\n\t\t\t\t\t\tClusterIPs: []string{\"192.240.240.3\", \"192.240.240.4\"},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\t\t\tIngress: []v1.LoadBalancerIngress{\n\t\t\t\t\t\t\t\t{Hostname: \"service-one.example.tld\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{.Name }}.example.tld\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"service-one.example.tld\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"service-one.example.tld\"}},\n\t\t\t\t{DNSName: \"service-one.internal.example.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"192.240.240.3\"}},\n\t\t\t\t{DNSName: \"service-one.internal.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"192.240.240.3\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"templating resolve service by service type\",\n\t\t\tservices: []*v1.Service{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tName:      \"service-one\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType: v1.ServiceTypeLoadBalancer,\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\t\t\tIngress: []v1.LoadBalancerIngress{\n\t\t\t\t\t\t\t\t{Hostname: \"service-one.example.tld\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tName:      \"service-two\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:         v1.ServiceTypeExternalName,\n\t\t\t\t\t\tExternalName: \"bucket-name.s3.us-east-1.amazonaws.com\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: `{{ if eq .Spec.Type \"ExternalName\" }}{{ .Name }}.external.example.tld{{ end}}`,\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"service-two.external.example.tld\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"bucket-name.s3.us-east-1.amazonaws.com\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:       \"templating resolve service with selector\",\n\t\t\tcombineFQDN: false,\n\t\t\tservices: []*v1.Service{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tName:      \"service-one\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:         v1.ServiceTypeExternalName,\n\t\t\t\t\t\tExternalName: \"api.example.tld\",\n\t\t\t\t\t\tSelector: map[string]string{\n\t\t\t\t\t\t\t\"app\": \"my-app\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.ServiceStatus{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tName:      \"service-two\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:         v1.ServiceTypeExternalName,\n\t\t\t\t\t\tExternalName: \"www.bucket-name.amazonaws.com\",\n\t\t\t\t\t\tSelector: map[string]string{\n\t\t\t\t\t\t\t\"app\": \"my-website\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: `{{ if eq (index .Spec.Selector \"app\") \"my-website\" }}www.{{ .Name }}.website.example.tld{{ end}}`,\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"www.service-two.website.example.tld\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"www.bucket-name.amazonaws.com\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:              \"fqdn with endpoint-type annotation and loose service type filtering\",\n\t\t\tserviceTypesFilter: []string{},\n\t\t\tservices: []*v1.Service{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace: \"svc-ns\",\n\t\t\t\t\t\tName:      \"svc-one\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.EndpointsTypeKey: EndpointsTypeNodeExternalIP,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:       v1.ServiceTypeClusterIP,\n\t\t\t\t\t\tClusterIP:  v1.ClusterIPNone,\n\t\t\t\t\t\tClusterIPs: []string{v1.ClusterIPNone},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tendpointSlices: []*discoveryv1.EndpointSlice{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"svc-one-xxxxx\",\n\t\t\t\t\t\tNamespace: \"svc-ns\",\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\tdiscoveryv1.LabelServiceName: \"svc-one\",\n\t\t\t\t\t\t\tv1.IsHeadlessService:         \"\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tAddressType: discoveryv1.AddressTypeIPv4,\n\t\t\t\t\tEndpoints: []discoveryv1.Endpoint{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tAddresses: []string{\"100.66.2.246\"},\n\t\t\t\t\t\t\tHostname:  testutils.ToPtr(\"ip-10-1-164-158.internal\"),\n\t\t\t\t\t\t\tNodeName:  testutils.ToPtr(\"test-node\"),\n\t\t\t\t\t\t\tTargetRef: &v1.ObjectReference{\n\t\t\t\t\t\t\t\tKind:      \"Pod\",\n\t\t\t\t\t\t\t\tName:      \"pod-1\",\n\t\t\t\t\t\t\t\tNamespace: \"svc-ns\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tAddresses: []string{\"100.66.2.247\"},\n\t\t\t\t\t\t\tHostname:  testutils.ToPtr(\"ip-10-1-164-158.internal\"),\n\t\t\t\t\t\t\tNodeName:  testutils.ToPtr(\"test-node\"),\n\t\t\t\t\t\t\tTargetRef: &v1.ObjectReference{\n\t\t\t\t\t\t\t\tKind:      \"Pod\",\n\t\t\t\t\t\t\t\tName:      \"pod-2\",\n\t\t\t\t\t\t\t\tNamespace: \"svc-ns\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{.Name}}.{{.Namespace}}.cluster.com\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"ip-10-1-164-158.internal.svc-one.svc-ns.cluster.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"203.0.113.10\"}},\n\t\t\t\t{DNSName: \"svc-one.svc-ns.cluster.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"203.0.113.10\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:              \"fqdn with endpoint-type annotation and service type filtering does not include required type\",\n\t\t\tserviceTypesFilter: []string{string(v1.ServiceTypeClusterIP)},\n\t\t\tservices: []*v1.Service{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace: \"svc-ns\",\n\t\t\t\t\t\tName:      \"svc-one\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\tannotations.EndpointsTypeKey: EndpointsTypeNodeExternalIP,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:       v1.ServiceTypeClusterIP,\n\t\t\t\t\t\tClusterIP:  v1.ClusterIPNone,\n\t\t\t\t\t\tClusterIPs: []string{v1.ClusterIPNone},\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tendpointSlices: []*discoveryv1.EndpointSlice{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tName:      \"svc-one-xxxxx\",\n\t\t\t\t\t\tNamespace: \"svc-ns\",\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\tdiscoveryv1.LabelServiceName: \"svc-one\",\n\t\t\t\t\t\t\tv1.IsHeadlessService:         \"\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tAddressType: discoveryv1.AddressTypeIPv4,\n\t\t\t\t\tEndpoints: []discoveryv1.Endpoint{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tAddresses: []string{\"100.66.2.246\"},\n\t\t\t\t\t\t\tHostname:  testutils.ToPtr(\"ip-10-1-164-158.internal\"),\n\t\t\t\t\t\t\tNodeName:  testutils.ToPtr(\"test-node\"),\n\t\t\t\t\t\t\tTargetRef: &v1.ObjectReference{\n\t\t\t\t\t\t\t\tKind:      \"Pod\",\n\t\t\t\t\t\t\t\tName:      \"pod-1\",\n\t\t\t\t\t\t\t\tNamespace: \"svc-ns\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tAddresses: []string{\"100.66.2.247\"},\n\t\t\t\t\t\t\tHostname:  testutils.ToPtr(\"ip-10-1-164-158.internal\"),\n\t\t\t\t\t\t\tNodeName:  testutils.ToPtr(\"test-node\"),\n\t\t\t\t\t\t\tTargetRef: &v1.ObjectReference{\n\t\t\t\t\t\t\t\tKind:      \"Pod\",\n\t\t\t\t\t\t\t\tName:      \"pod-2\",\n\t\t\t\t\t\t\t\tNamespace: \"svc-ns\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: \"{{.Name}}.{{.Namespace}}.cluster.com\",\n\t\t\texpected:     []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle: \"templating resolve service with zone PreferSameTrafficDistribution and topology.kubernetes.io/zone annotation\",\n\t\t\tservices: []*v1.Service{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tName:      \"service-one\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\t\"topology.kubernetes.io/zone\": \"us-west-1a\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:        v1.ServiceTypeClusterIP,\n\t\t\t\t\t\tClusterIP:   \"192.51.100.22\",\n\t\t\t\t\t\tExternalIPs: []string{\"198.51.100.30\"},\n\t\t\t\t\t\t// https://kubernetes.io/docs/reference/networking/virtual-ips/#traffic-distribution\n\t\t\t\t\t\tTrafficDistribution: testutils.ToPtr(\"PreferSameZone\"),\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.ServiceStatus{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tName:      \"service-two\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\t\"topology.kubernetes.io/zone\": \"us-west-1c\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:                v1.ServiceTypeClusterIP,\n\t\t\t\t\t\tClusterIP:           \"192.51.100.5\",\n\t\t\t\t\t\tExternalIPs:         []string{\"198.51.100.32\"},\n\t\t\t\t\t\tTrafficDistribution: testutils.ToPtr(\"PreferSameZone\"),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tName:      \"service-three\",\n\t\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\t\"topology.kubernetes.io/zone\": \"us-west-1a\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:                v1.ServiceTypeClusterIP,\n\t\t\t\t\t\tClusterIP:           \"192.51.100.33\",\n\t\t\t\t\t\tExternalIPs:         []string{\"198.51.100.70\"},\n\t\t\t\t\t\tTrafficDistribution: testutils.ToPtr(\"PreferClose\"),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t// printf is used to ensure the template is evaluated as a string, as the TrafficDistribution field is a pointer.\n\t\t\tfqdnTemplate: `{{ $annotations := .ObjectMeta.Annotations }}{{ .Name }}{{ if eq (.Spec.TrafficDistribution | printf) \"PreferSameZone\" }}.zone.{{ index $annotations \"topology.kubernetes.io/zone\"  }}{{ else }}.close{{ end }}.example.tld`,\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"service-one.zone.us-west-1a.example.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"192.51.100.22\"}},\n\t\t\t\t{DNSName: \"service-two.zone.us-west-1c.example.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"192.51.100.5\"}},\n\t\t\t\t{DNSName: \"service-three.close.example.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"192.51.100.33\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"templating resolve services with specific port names\",\n\t\t\tservices: []*v1.Service{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tName:      \"service-one\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:      v1.ServiceTypeClusterIP,\n\t\t\t\t\t\tClusterIP: \"192.51.100.22\",\n\t\t\t\t\t\tPorts: []v1.ServicePort{\n\t\t\t\t\t\t\t{Name: \"http\", Port: 8080},\n\t\t\t\t\t\t\t{Name: \"debug\", Port: 8082},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tName:      \"service-two\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:      v1.ServiceTypeClusterIP,\n\t\t\t\t\t\tClusterIP: \"192.51.100.5\",\n\t\t\t\t\t\tPorts: []v1.ServicePort{\n\t\t\t\t\t\t\t{Name: \"http\", Port: 8080},\n\t\t\t\t\t\t\t{Name: \"http2\", Port: 8086},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tName:      \"service-three\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:      v1.ServiceTypeClusterIP,\n\t\t\t\t\t\tClusterIP: \"2041:0000:140F::875B:131B\",\n\t\t\t\t\t\tPorts: []v1.ServicePort{\n\t\t\t\t\t\t\t{Name: \"debug\", Port: 8082},\n\t\t\t\t\t\t\t{Name: \"http2\", Port: 8086},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: `{{ $name := .Name }}{{ range .Spec.Ports -}}{{ $name }}{{ if eq .Name \"http2\" }}.http2{{ else if eq .Name \"debug\" }}.debug{{ end }}.example.tld.{{printf \",\" }}{{ end }}`,\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"service-one.debug.example.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"192.51.100.22\"}},\n\t\t\t\t{DNSName: \"service-one.example.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"192.51.100.22\"}},\n\t\t\t\t{DNSName: \"service-three.debug.example.tld\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2041:0000:140F::875B:131B\"}},\n\t\t\t\t{DNSName: \"service-three.http2.example.tld\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2041:0000:140F::875B:131B\"}},\n\t\t\t\t{DNSName: \"service-two.example.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"192.51.100.5\"}},\n\t\t\t\t{DNSName: \"service-two.http2.example.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"192.51.100.5\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:         \"templating resolves headless services\",\n\t\t\tpublishHostIp: false,\n\t\t\tservices: []*v1.Service{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tName:      \"service-one\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:       v1.ServiceTypeClusterIP,\n\t\t\t\t\t\tClusterIP:  v1.ClusterIPNone,\n\t\t\t\t\t\tIPFamilies: []v1.IPFamily{v1.IPv4Protocol},\n\t\t\t\t\t\tPorts: []v1.ServicePort{\n\t\t\t\t\t\t\t{Name: \"http\", Port: 8080},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tName:      \"service-two\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:       v1.ServiceTypeClusterIP,\n\t\t\t\t\t\tClusterIP:  v1.ClusterIPNone,\n\t\t\t\t\t\tIPFamilies: []v1.IPFamily{v1.IPv4Protocol, v1.IPv6Protocol},\n\t\t\t\t\t\tPorts: []v1.ServicePort{\n\t\t\t\t\t\t\t{Name: \"http\", Port: 8080},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tName:      \"service-three\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:       v1.ServiceTypeClusterIP,\n\t\t\t\t\t\tClusterIP:  v1.ClusterIPNone,\n\t\t\t\t\t\tIPFamilies: []v1.IPFamily{v1.IPv4Protocol},\n\t\t\t\t\t\tPorts: []v1.ServicePort{\n\t\t\t\t\t\t\t{Name: \"debug\", Port: 8082},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tendpointSlices: []*discoveryv1.EndpointSlice{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tName:      \"service-one-xxxxx\",\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\tdiscoveryv1.LabelServiceName: \"service-one\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tAddressType: discoveryv1.AddressTypeIPv4,\n\t\t\t\t\tEndpoints: []discoveryv1.Endpoint{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tAddresses: []string{\"100.66.2.241\"},\n\t\t\t\t\t\t\tHostname:  testutils.ToPtr(\"ip-10-1-164-158.internal\"),\n\t\t\t\t\t\t\tTargetRef: &v1.ObjectReference{\n\t\t\t\t\t\t\t\tKind:      \"Pod\",\n\t\t\t\t\t\t\t\tName:      \"pod-1\",\n\t\t\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tName:      \"service-two-xxxxx\",\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\tdiscoveryv1.LabelServiceName: \"service-two\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tAddressType: discoveryv1.AddressTypeIPv4,\n\t\t\t\t\tEndpoints: []discoveryv1.Endpoint{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tAddresses: []string{\"100.66.2.244\"},\n\t\t\t\t\t\t\tHostname:  testutils.ToPtr(\"ip-10-1-164-152.internal\"),\n\t\t\t\t\t\t\tTargetRef: &v1.ObjectReference{\n\t\t\t\t\t\t\t\tKind:      \"Pod\",\n\t\t\t\t\t\t\t\tName:      \"pod-2\",\n\t\t\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tName:      \"service-three-xxxxx\",\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\tdiscoveryv1.LabelServiceName: \"service-three\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tAddressType: discoveryv1.AddressTypeIPv4,\n\t\t\t\t\tEndpoints: []discoveryv1.Endpoint{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tAddresses: []string{\"100.66.2.246\"},\n\t\t\t\t\t\t\tHostname:  testutils.ToPtr(\"ip-10-1-164-158.internal\"),\n\t\t\t\t\t\t\tTargetRef: &v1.ObjectReference{\n\t\t\t\t\t\t\t\tKind:      \"Pod\",\n\t\t\t\t\t\t\t\tName:      \"pod-3\",\n\t\t\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tAddresses: []string{\"100.66.2.247\"},\n\t\t\t\t\t\t\tHostname:  testutils.ToPtr(\"ip-10-1-164-158.internal\"),\n\t\t\t\t\t\t\tTargetRef: &v1.ObjectReference{\n\t\t\t\t\t\t\t\tKind:      \"Pod\",\n\t\t\t\t\t\t\t\tName:      \"pod-4\",\n\t\t\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: `{{ .Name }}.org.tld`,\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"ip-10-1-164-152.internal.service-two.org.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"100.66.2.244\"}},\n\t\t\t\t{DNSName: \"ip-10-1-164-158.internal.service-one.org.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"100.66.2.241\"}},\n\t\t\t\t{DNSName: \"ip-10-1-164-158.internal.service-three.org.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"100.66.2.246\", \"100.66.2.247\"}},\n\t\t\t\t{DNSName: \"service-one.org.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"100.66.2.241\"}},\n\t\t\t\t{DNSName: \"service-three.org.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"100.66.2.246\", \"100.66.2.247\"}},\n\t\t\t\t{DNSName: \"service-two.org.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"100.66.2.244\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:         \"templating resolves headless services with publishHostIp set to true\",\n\t\t\tpublishHostIp: true,\n\t\t\tservices: []*v1.Service{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tName:      \"service-one\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:       v1.ServiceTypeClusterIP,\n\t\t\t\t\t\tClusterIP:  v1.ClusterIPNone,\n\t\t\t\t\t\tIPFamilies: []v1.IPFamily{v1.IPv4Protocol},\n\t\t\t\t\t\tPorts: []v1.ServicePort{\n\t\t\t\t\t\t\t{Name: \"http\", Port: 8080},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tName:      \"service-two\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:       v1.ServiceTypeClusterIP,\n\t\t\t\t\t\tClusterIP:  v1.ClusterIPNone,\n\t\t\t\t\t\tIPFamilies: []v1.IPFamily{v1.IPv4Protocol, v1.IPv6Protocol},\n\t\t\t\t\t\tPorts: []v1.ServicePort{\n\t\t\t\t\t\t\t{Name: \"http\", Port: 8080},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tName:      \"service-three\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:       v1.ServiceTypeClusterIP,\n\t\t\t\t\t\tClusterIP:  v1.ClusterIPNone,\n\t\t\t\t\t\tIPFamilies: []v1.IPFamily{v1.IPv4Protocol},\n\t\t\t\t\t\tPorts: []v1.ServicePort{\n\t\t\t\t\t\t\t{Name: \"debug\", Port: 8082},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tendpointSlices: []*discoveryv1.EndpointSlice{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tName:      \"service-one-xxxxx\",\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\tdiscoveryv1.LabelServiceName: \"service-one\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tAddressType: discoveryv1.AddressTypeIPv4,\n\t\t\t\t\tEndpoints: []discoveryv1.Endpoint{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tAddresses: []string{\"100.66.2.241\"},\n\t\t\t\t\t\t\tHostname:  testutils.ToPtr(\"ip-10-1-164-158.internal\"),\n\t\t\t\t\t\t\tTargetRef: &v1.ObjectReference{\n\t\t\t\t\t\t\t\tKind:      \"Pod\",\n\t\t\t\t\t\t\t\tName:      \"pod-1\",\n\t\t\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tName:      \"service-two-xxxxx\",\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\tdiscoveryv1.LabelServiceName: \"service-two\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tAddressType: discoveryv1.AddressTypeIPv4,\n\t\t\t\t\tEndpoints: []discoveryv1.Endpoint{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tAddresses: []string{\"100.66.2.244\"},\n\t\t\t\t\t\t\tHostname:  testutils.ToPtr(\"ip-10-1-164-152.internal\"),\n\t\t\t\t\t\t\tTargetRef: &v1.ObjectReference{\n\t\t\t\t\t\t\t\tKind:      \"Pod\",\n\t\t\t\t\t\t\t\tName:      \"pod-2\",\n\t\t\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tName:      \"service-three-xxxxx\",\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\tdiscoveryv1.LabelServiceName: \"service-three\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tAddressType: discoveryv1.AddressTypeIPv4,\n\t\t\t\t\tEndpoints: []discoveryv1.Endpoint{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tAddresses: []string{\"100.66.2.246\"},\n\t\t\t\t\t\t\tHostname:  testutils.ToPtr(\"ip-10-1-164-158.internal\"),\n\t\t\t\t\t\t\tTargetRef: &v1.ObjectReference{\n\t\t\t\t\t\t\t\tKind:      \"Pod\",\n\t\t\t\t\t\t\t\tName:      \"pod-3\",\n\t\t\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tAddresses: []string{\"100.66.2.247\"},\n\t\t\t\t\t\t\tHostname:  testutils.ToPtr(\"ip-10-1-164-158.internal\"),\n\t\t\t\t\t\t\tTargetRef: &v1.ObjectReference{\n\t\t\t\t\t\t\t\tKind:      \"Pod\",\n\t\t\t\t\t\t\t\tName:      \"pod-4\",\n\t\t\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: `{{ .Name }}.org.tld`,\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"ip-10-1-164-152.internal.service-two.org.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.1.20.40\"}},\n\t\t\t\t{DNSName: \"ip-10-1-164-158.internal.service-one.org.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.1.20.40\"}},\n\t\t\t\t{DNSName: \"ip-10-1-164-158.internal.service-three.org.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.1.20.40\", \"10.1.20.41\"}},\n\t\t\t\t{DNSName: \"service-one.org.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.1.20.40\"}},\n\t\t\t\t{DNSName: \"service-three.org.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.1.20.40\", \"10.1.20.41\"}},\n\t\t\t\t{DNSName: \"service-two.org.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.1.20.40\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"templating resolve NodePort services with specific port names\",\n\t\t\tservices: []*v1.Service{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tName:      \"service-one\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:      v1.ServiceTypeNodePort,\n\t\t\t\t\t\tClusterIP: \"10.96.41.131\", Ports: []v1.ServicePort{\n\t\t\t\t\t\t\t{Name: \"http\", Port: 80, TargetPort: intstr.FromInt32(8080), NodePort: 30080},\n\t\t\t\t\t\t\t{Name: \"debug\", Port: 8082, TargetPort: intstr.FromInt32(8082), NodePort: 30082},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tName:      \"service-two\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:      v1.ServiceTypeClusterIP,\n\t\t\t\t\t\tClusterIP: \"10.96.41.132\",\n\t\t\t\t\t\tPorts: []v1.ServicePort{\n\t\t\t\t\t\t\t{Name: \"http\", Port: 8080},\n\t\t\t\t\t\t\t{Name: \"http2\", Port: 8086},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tName:      \"service-three\",\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:      v1.ServiceTypeNodePort,\n\t\t\t\t\t\tClusterIP: \"10.96.41.133\",\n\t\t\t\t\t\tPorts: []v1.ServicePort{\n\t\t\t\t\t\t\t{Name: \"debug\", Port: 8082, TargetPort: intstr.FromInt32(8083), Protocol: v1.ProtocolUDP, NodePort: 30083},\n\t\t\t\t\t\t\t{Name: \"minecraft\", Port: 2525, TargetPort: intstr.FromInt32(25256), NodePort: 25565},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tfqdnTemplate: `{{ if eq .Spec.Type \"NodePort\" }}{{ range .Spec.Ports }}{{ .Name }}.host.tld{{printf \",\" }}{{end}}{{ end }}`,\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"_service-one._tcp.debug.host.tld\", RecordType: endpoint.RecordTypeSRV, Targets: endpoint.Targets{\"0 50 30080 debug.host.tld.\", \"0 50 30082 debug.host.tld.\"}},\n\t\t\t\t{DNSName: \"_service-one._tcp.http.host.tld\", RecordType: endpoint.RecordTypeSRV, Targets: endpoint.Targets{\"0 50 30080 http.host.tld.\", \"0 50 30082 http.host.tld.\"}},\n\t\t\t\t{DNSName: \"_service-three._tcp.debug.host.tld\", RecordType: endpoint.RecordTypeSRV, Targets: endpoint.Targets{\"0 50 25565 debug.host.tld.\"}},\n\t\t\t\t{DNSName: \"_service-three._tcp.minecraft.host.tld\", RecordType: endpoint.RecordTypeSRV, Targets: endpoint.Targets{\"0 50 25565 minecraft.host.tld.\"}},\n\t\t\t\t{DNSName: \"_service-three._udp.debug.host.tld\", RecordType: endpoint.RecordTypeSRV, Targets: endpoint.Targets{\"0 50 30083 debug.host.tld.\"}},\n\t\t\t\t{DNSName: \"_service-three._udp.minecraft.host.tld\", RecordType: endpoint.RecordTypeSRV, Targets: endpoint.Targets{\"0 50 30083 minecraft.host.tld.\"}},\n\t\t\t\t{DNSName: \"debug.host.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"203.0.113.10\"}},\n\t\t\t\t{DNSName: \"http.host.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"203.0.113.10\"}},\n\t\t\t\t{DNSName: \"minecraft.host.tld\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"203.0.113.10\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"templating resolves headless services with Kind check and label contains\",\n\t\t\tfqdnTemplate: `{{ if eq .Kind \"Service\" }}{{ range $key, $value := .Labels }}\n\t\t\t\t{{ if and (contains $key \"app\") (contains $value \"my-service-\") }}\n\t\t\t\t{{ $.Name }}.{{ $value }}.example.com,{{ end }}{{ end }}{{ end }}`,\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"service-one.my-service-123.example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"100.66.2.241\"}},\n\t\t\t\t{DNSName: \"service-two.my-service-345.example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"100.66.2.244\"}},\n\t\t\t},\n\t\t\tservices: []*v1.Service{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tName:      \"service-one\",\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\t\"app1\": \"my-service-123\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:       v1.ServiceTypeClusterIP,\n\t\t\t\t\t\tClusterIP:  v1.ClusterIPNone,\n\t\t\t\t\t\tIPFamilies: []v1.IPFamily{v1.IPv4Protocol},\n\t\t\t\t\t\tPorts: []v1.ServicePort{\n\t\t\t\t\t\t\t{Name: \"http\", Port: 8080},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tName:      \"service-two\",\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\t\"app2\": \"my-service-345\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:       v1.ServiceTypeClusterIP,\n\t\t\t\t\t\tClusterIP:  v1.ClusterIPNone,\n\t\t\t\t\t\tIPFamilies: []v1.IPFamily{v1.IPv4Protocol},\n\t\t\t\t\t\tPorts: []v1.ServicePort{\n\t\t\t\t\t\t\t{Name: \"http\", Port: 8080},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tendpointSlices: []*discoveryv1.EndpointSlice{\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tName:      \"service-one-xxxxx\",\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\tdiscoveryv1.LabelServiceName: \"service-one\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tAddressType: discoveryv1.AddressTypeIPv4,\n\t\t\t\t\tEndpoints: []discoveryv1.Endpoint{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tAddresses: []string{\"100.66.2.241\"},\n\t\t\t\t\t\t\tTargetRef: &v1.ObjectReference{\n\t\t\t\t\t\t\t\tKind:      \"Pod\",\n\t\t\t\t\t\t\t\tName:      \"pod-1\",\n\t\t\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\tName:      \"service-two-xxxxx\",\n\t\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\tdiscoveryv1.LabelServiceName: \"service-two\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tAddressType: discoveryv1.AddressTypeIPv4,\n\t\t\t\t\tEndpoints: []discoveryv1.Endpoint{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tAddresses: []string{\"100.66.2.244\"},\n\t\t\t\t\t\t\tTargetRef: &v1.ObjectReference{\n\t\t\t\t\t\t\t\tKind:      \"Pod\",\n\t\t\t\t\t\t\t\tName:      \"pod-2\",\n\t\t\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(tt.title, func(t *testing.T) {\n\t\t\tkubeClient := fake.NewClientset()\n\n\t\t\tfor _, el := range tt.services {\n\t\t\t\t_, err := kubeClient.CoreV1().Services(el.Namespace).Create(t.Context(), el, metav1.CreateOptions{})\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\t_, err := kubeClient.CoreV1().Nodes().Create(t.Context(), &v1.Node{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"test-node\"},\n\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"203.0.113.10\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"10.0.0.10\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}, metav1.CreateOptions{})\n\t\t\trequire.NoError(t, err)\n\n\t\t\t// Create endpoints and pods for the services\n\t\t\tfor _, el := range tt.endpointSlices {\n\t\t\t\t_, err := kubeClient.DiscoveryV1().EndpointSlices(el.Namespace).Create(t.Context(), el, metav1.CreateOptions{})\n\t\t\t\trequire.NoError(t, err)\n\t\t\t\tfor i, ep := range el.Endpoints {\n\t\t\t\t\t_, err = kubeClient.CoreV1().Pods(el.Namespace).Create(t.Context(), &v1.Pod{\n\t\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\t\tName:      ep.TargetRef.Name,\n\t\t\t\t\t\t\tNamespace: el.Namespace,\n\t\t\t\t\t\t},\n\t\t\t\t\t\tSpec: v1.PodSpec{\n\t\t\t\t\t\t\tHostname: func() string {\n\t\t\t\t\t\t\t\tif ep.Hostname != nil {\n\t\t\t\t\t\t\t\t\treturn *ep.Hostname\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\treturn \"\"\n\t\t\t\t\t\t\t}(),\n\t\t\t\t\t\t\tNodeName: \"test-node\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\tStatus: v1.PodStatus{\n\t\t\t\t\t\t\tHostIP: fmt.Sprintf(\"10.1.20.4%d\", i),\n\t\t\t\t\t\t},\n\t\t\t\t\t}, metav1.CreateOptions{})\n\t\t\t\t\trequire.NoError(t, err)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tcfg := &Config{\n\t\t\t\tFQDNTemplate:                   tt.fqdnTemplate,\n\t\t\t\tCombineFQDNAndAnnotation:       tt.combineFQDN,\n\t\t\t\tPublishHostIP:                  tt.publishHostIp,\n\t\t\t\tServiceTypeFilter:              tt.serviceTypesFilter,\n\t\t\t\tPublishInternal:                true,\n\t\t\t\tAlwaysPublishNotReadyAddresses: true,\n\t\t\t\tExposeInternalIPv6:             true,\n\t\t\t\tExcludeUnschedulable:           true,\n\t\t\t\tLabelFilter:                    labels.Everything(),\n\t\t\t}\n\n\t\t\tsrc, err := NewServiceSource(\n\t\t\t\tt.Context(),\n\t\t\t\tkubeClient,\n\t\t\t\tcfg,\n\t\t\t)\n\t\t\trequire.NoError(t, err)\n\n\t\t\tendpoints, err := src.Endpoints(t.Context())\n\t\t\trequire.NoError(t, err)\n\n\t\t\tvalidateEndpoints(t, endpoints, tt.expected)\n\n\t\t\t// TODO; when all resources have the resource label, we could add this check to the validateEndpoints function.\n\t\t\tfor _, ep := range endpoints {\n\t\t\t\trequire.Contains(t, ep.Labels, endpoint.ResourceLabelKey)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "source/service_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"maps\"\n\t\"math/rand\"\n\t\"net\"\n\t\"sort\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\t\"github.com/stretchr/testify/suite\"\n\tappsv1 \"k8s.io/api/apps/v1\"\n\tv1 \"k8s.io/api/core/v1\"\n\tdiscoveryv1 \"k8s.io/api/discovery/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\t\"k8s.io/apimachinery/pkg/runtime\"\n\t\"k8s.io/apimachinery/pkg/util/intstr\"\n\tkubeinformers \"k8s.io/client-go/informers\"\n\t\"k8s.io/client-go/kubernetes/fake\"\n\n\t\"sigs.k8s.io/external-dns/source/types\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/internal/testutils\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n\t\"sigs.k8s.io/external-dns/source/informers\"\n)\n\ntype ServiceSuite struct {\n\tsuite.Suite\n\tsc             Source\n\tfooWithTargets *v1.Service\n}\n\nfunc (suite *ServiceSuite) SetupTest() {\n\tfakeClient := fake.NewClientset()\n\n\tsuite.fooWithTargets = &v1.Service{\n\t\tSpec: v1.ServiceSpec{\n\t\t\tType: v1.ServiceTypeLoadBalancer,\n\t\t},\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tNamespace:   \"default\",\n\t\t\tName:        \"foo-with-targets\",\n\t\t\tAnnotations: map[string]string{},\n\t\t},\n\t\tStatus: v1.ServiceStatus{\n\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\tIngress: []v1.LoadBalancerIngress{\n\t\t\t\t\t{IP: \"8.8.8.8\"},\n\t\t\t\t\t{Hostname: \"foo\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\t_, err := fakeClient.CoreV1().Services(suite.fooWithTargets.Namespace).Create(context.Background(), suite.fooWithTargets, metav1.CreateOptions{})\n\tsuite.NoError(err, \"should successfully create service\")\n\n\tsuite.sc, err = NewServiceSource(\n\t\tcontext.TODO(),\n\t\tfakeClient,\n\t\t&Config{\n\t\t\tFQDNTemplate: \"{{.Name}}\",\n\t\t\tLabelFilter:  labels.Everything(),\n\t\t},\n\t)\n\tsuite.NoError(err, \"should initialize service source\")\n}\n\nfunc (suite *ServiceSuite) TestResourceLabelIsSet() {\n\tendpoints, _ := suite.sc.Endpoints(context.Background())\n\tfor _, ep := range endpoints {\n\t\tsuite.Equal(\"service/default/foo-with-targets\", ep.Labels[endpoint.ResourceLabelKey], \"should set correct resource label\")\n\t}\n}\n\nfunc TestServiceSource(t *testing.T) {\n\tt.Parallel()\n\n\tsuite.Run(t, new(ServiceSuite))\n\tt.Run(\"Interface\", testServiceSourceImplementsSource)\n\tt.Run(\"NewServiceSource\", testServiceSourceNewServiceSource)\n\tt.Run(\"Endpoints\", testServiceSourceEndpoints)\n\tt.Run(\"MultipleServices\", testMultipleServicesEndpoints)\n}\n\n// testServiceSourceImplementsSource tests that serviceSource is a valid Source.\nfunc testServiceSourceImplementsSource(t *testing.T) {\n\tassert.Implements(t, (*Source)(nil), new(serviceSource))\n}\n\n// testServiceSourceNewServiceSource tests that NewServiceSource doesn't return an error.\nfunc testServiceSourceNewServiceSource(t *testing.T) {\n\tt.Parallel()\n\n\tfor _, tc := range []struct {\n\t\ttitle              string\n\t\tannotationFilter   string\n\t\tfqdnTemplate       string\n\t\tserviceTypesFilter []string\n\t\texpectError        bool\n\t}{\n\t\t{\n\t\t\ttitle:        \"invalid template\",\n\t\t\texpectError:  true,\n\t\t\tfqdnTemplate: \"{{.Name\",\n\t\t},\n\t\t{\n\t\t\ttitle:       \"valid empty template\",\n\t\t\texpectError: false,\n\t\t},\n\t\t{\n\t\t\ttitle:        \"valid template\",\n\t\t\texpectError:  false,\n\t\t\tfqdnTemplate: \"{{.Name}}-{{.Namespace}}.ext-dns.test.com\",\n\t\t},\n\t\t{\n\t\t\ttitle:            \"non-empty annotation filter label\",\n\t\t\texpectError:      false,\n\t\t\tannotationFilter: \"kubernetes.io/ingress.class=nginx\",\n\t\t},\n\t\t{\n\t\t\ttitle:              \"non-empty service types filter\",\n\t\t\texpectError:        false,\n\t\t\tserviceTypesFilter: []string{string(v1.ServiceTypeClusterIP)},\n\t\t},\n\t} {\n\n\t\tt.Run(tc.title, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\t_, err := NewServiceSource(\n\t\t\t\tt.Context(),\n\t\t\t\tfake.NewClientset(),\n\t\t\t\t&Config{\n\t\t\t\t\tFQDNTemplate:      tc.fqdnTemplate,\n\t\t\t\t\tAnnotationFilter:  tc.annotationFilter,\n\t\t\t\t\tServiceTypeFilter: tc.serviceTypesFilter,\n\t\t\t\t\tLabelFilter:       labels.Everything(),\n\t\t\t\t},\n\t\t\t)\n\n\t\t\tif tc.expectError {\n\t\t\t\tassert.Error(t, err)\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// testServiceSourceEndpoints tests that various services generate the correct endpoints.\nfunc testServiceSourceEndpoints(t *testing.T) {\n\texampleDotComIP4, err := net.DefaultResolver.LookupNetIP(t.Context(), \"ip4\", \"example.com\")\n\tassert.NoError(t, err)\n\texampleDotComIP6, err := net.DefaultResolver.LookupNetIP(t.Context(), \"ip6\", \"example.com\")\n\tassert.NoError(t, err)\n\n\tt.Parallel()\n\n\tfor _, tc := range []struct {\n\t\ttitle                       string\n\t\ttargetNamespace             string\n\t\tannotationFilter            string\n\t\tsvcNamespace                string\n\t\tsvcName                     string\n\t\tsvcType                     v1.ServiceType\n\t\tcompatibility               string\n\t\tfqdnTemplate                string\n\t\tcombineFQDNAndAnnotation    bool\n\t\tignoreHostnameAnnotation    bool\n\t\tlabels                      map[string]string\n\t\tannotations                 map[string]string\n\t\tclusterIP                   string\n\t\texternalIPs                 []string\n\t\tlbs                         []string\n\t\tserviceTypesFilter          []string\n\t\texpected                    []*endpoint.Endpoint\n\t\texpectError                 bool\n\t\tserviceLabelSelector        string\n\t\tresolveLoadBalancerHostname bool\n\t}{\n\t\t{\n\t\t\ttitle:              \"no annotated services return no endpoints\",\n\t\t\tsvcNamespace:       \"testing\",\n\t\t\tsvcName:            \"foo\",\n\t\t\tsvcType:            v1.ServiceTypeLoadBalancer,\n\t\t\tlabels:             map[string]string{},\n\t\t\tannotations:        map[string]string{},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected:           []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:                    \"no annotated services return no endpoints when ignoring annotations\",\n\t\t\tsvcNamespace:             \"testing\",\n\t\t\tsvcName:                  \"foo\",\n\t\t\tsvcType:                  v1.ServiceTypeLoadBalancer,\n\t\t\tignoreHostnameAnnotation: true,\n\t\t\tlabels:                   map[string]string{},\n\t\t\tannotations:              map[string]string{},\n\t\t\texternalIPs:              []string{},\n\t\t\tlbs:                      []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter:       []string{},\n\t\t\texpected:                 []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"annotated services return an endpoint with target IP\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeLoadBalancer,\n\t\t\tlabels:       map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter: []string{string(v1.ServiceTypeLoadBalancer)},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:                    \"hostname annotation on services is ignored\",\n\t\t\tsvcNamespace:             \"testing\",\n\t\t\tsvcName:                  \"foo\",\n\t\t\tsvcType:                  v1.ServiceTypeLoadBalancer,\n\t\t\tignoreHostnameAnnotation: true,\n\t\t\tlabels:                   map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected:           []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"annotated ClusterIp aren't processed without explicit authorization\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeClusterIP,\n\t\t\tlabels:       map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t},\n\t\t\tclusterIP:          \"1.2.3.4\",\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected:           []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:              \"FQDN template with multiple hostnames return an endpoint with target IP\",\n\t\t\tsvcNamespace:       \"testing\",\n\t\t\tsvcName:            \"foo\",\n\t\t\tsvcType:            v1.ServiceTypeLoadBalancer,\n\t\t\tfqdnTemplate:       \"{{.Name}}.fqdn.org,{{.Name}}.fqdn.com\",\n\t\t\tlabels:             map[string]string{},\n\t\t\tannotations:        map[string]string{},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter: []string{string(v1.ServiceTypeLoadBalancer), string(v1.ServiceTypeNodePort)},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.fqdn.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{DNSName: \"foo.fqdn.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:              \"with excluded service type should not generate endpoints\",\n\t\t\tsvcNamespace:       \"testing\",\n\t\t\tsvcName:            \"foo\",\n\t\t\tsvcType:            v1.ServiceTypeLoadBalancer,\n\t\t\tfqdnTemplate:       \"{{.Name}}.fqdn.org,{{.Name}}.fqdn.com\",\n\t\t\tlabels:             map[string]string{},\n\t\t\tannotations:        map[string]string{},\n\t\t\tlbs:                []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter: []string{string(v1.ServiceTypeNodePort)},\n\t\t\texpected:           []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:                    \"FQDN template with multiple hostnames return an endpoint with target IP when ignoring annotations\",\n\t\t\tsvcNamespace:             \"testing\",\n\t\t\tsvcName:                  \"foo\",\n\t\t\tsvcType:                  v1.ServiceTypeLoadBalancer,\n\t\t\tfqdnTemplate:             \"{{.Name}}.fqdn.org,{{.Name}}.fqdn.com\",\n\t\t\tignoreHostnameAnnotation: true,\n\t\t\tlabels:                   map[string]string{},\n\t\t\tannotations:              map[string]string{},\n\t\t\texternalIPs:              []string{},\n\t\t\tlbs:                      []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter:       []string{},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.fqdn.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{DNSName: \"foo.fqdn.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:                    \"FQDN template and annotation both with multiple hostnames return an endpoint with target IP\",\n\t\t\tsvcNamespace:             \"testing\",\n\t\t\tsvcName:                  \"foo\",\n\t\t\tsvcType:                  v1.ServiceTypeLoadBalancer,\n\t\t\tfqdnTemplate:             \"{{.Name}}.fqdn.org,{{.Name}}.fqdn.com\",\n\t\t\tcombineFQDNAndAnnotation: true,\n\t\t\tlabels:                   map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org., bar.example.org.\",\n\t\t\t},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{DNSName: \"bar.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{DNSName: \"foo.fqdn.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{DNSName: \"foo.fqdn.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:                    \"FQDN template and annotation both with multiple hostnames while ignoring annotations will only return FQDN endpoints\",\n\t\t\tsvcNamespace:             \"testing\",\n\t\t\tsvcName:                  \"foo\",\n\t\t\tsvcType:                  v1.ServiceTypeLoadBalancer,\n\t\t\tfqdnTemplate:             \"{{.Name}}.fqdn.org,{{.Name}}.fqdn.com\",\n\t\t\tcombineFQDNAndAnnotation: true,\n\t\t\tignoreHostnameAnnotation: true,\n\t\t\tlabels:                   map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org., bar.example.org.\",\n\t\t\t},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.fqdn.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{DNSName: \"foo.fqdn.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"annotated services with multiple hostnames return an endpoint with target IP\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeLoadBalancer,\n\t\t\tlabels:       map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org., bar.example.org.\",\n\t\t\t},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{DNSName: \"bar.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"annotated services with multiple hostnames and without trailing period return an endpoint with target IP\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeLoadBalancer,\n\t\t\tlabels:       map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org, bar.example.org\",\n\t\t\t},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{DNSName: \"bar.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"annotated services return an endpoint with target hostname\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeLoadBalancer,\n\t\t\tlabels:       map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"lb.example.com\"}, // Kubernetes omits the trailing dot\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"lb.example.com\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"annotated services return an endpoint with hostname then resolve hostname\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeLoadBalancer,\n\t\t\tlabels:       map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t},\n\t\t\texternalIPs:                 []string{},\n\t\t\tlbs:                         []string{\"example.com\"}, // Use a resolvable hostname for testing.\n\t\t\tserviceTypesFilter:          []string{},\n\t\t\tresolveLoadBalancerHostname: true,\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: testutils.NewTargetsFromAddr(exampleDotComIP4)},\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: testutils.NewTargetsFromAddr(exampleDotComIP6)},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"annotated services can omit trailing dot\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeLoadBalancer,\n\t\t\tlabels:       map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org\", // Trailing dot is omitted\n\t\t\t},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\", \"lb.example.com\"}, // Kubernetes omits the trailing dot\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"lb.example.com\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"our controller type is kops dns controller\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeLoadBalancer,\n\t\t\tlabels:       map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.ControllerKey: annotations.ControllerValue,\n\t\t\t\tannotations.HostnameKey:   \"foo.example.org.\",\n\t\t\t},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter: []string{string(v1.ServiceTypeLoadBalancer), string(v1.ServiceTypeNodePort)},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"different controller types are ignored even (with template specified)\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeLoadBalancer,\n\t\t\tfqdnTemplate: \"{{.Name}}.ext-dns.test.com\",\n\t\t\tlabels:       map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.ControllerKey: \"some-other-tool\",\n\t\t\t\tannotations.HostnameKey:   \"foo.example.org.\",\n\t\t\t},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected:           []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"services are found in target namespace\",\n\t\t\ttargetNamespace: \"testing\",\n\t\t\tsvcNamespace:    \"testing\",\n\t\t\tsvcName:         \"foo\",\n\t\t\tsvcType:         v1.ServiceTypeLoadBalancer,\n\t\t\tlabels:          map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:           \"services that are not in target namespace are ignored\",\n\t\t\ttargetNamespace: \"testing\",\n\t\t\tsvcNamespace:    \"other-testing\",\n\t\t\tsvcName:         \"foo\",\n\t\t\tsvcType:         v1.ServiceTypeLoadBalancer,\n\t\t\tlabels:          map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected:           []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"services are found in all namespaces\",\n\t\t\tsvcNamespace: \"other-testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeLoadBalancer,\n\t\t\tlabels:       map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:            \"valid matching annotation filter expression\",\n\t\t\tannotationFilter: \"service.beta.kubernetes.io/external-traffic in (Global, OnlyLocal)\",\n\t\t\tsvcNamespace:     \"testing\",\n\t\t\tsvcName:          \"foo\",\n\t\t\tsvcType:          v1.ServiceTypeLoadBalancer,\n\t\t\tlabels:           map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey:                       \"foo.example.org.\",\n\t\t\t\t\"service.beta.kubernetes.io/external-traffic\": \"OnlyLocal\",\n\t\t\t},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:            \"valid non-matching annotation filter expression\",\n\t\t\tannotationFilter: \"service.beta.kubernetes.io/external-traffic in (Global, OnlyLocal)\",\n\t\t\tsvcNamespace:     \"testing\",\n\t\t\tsvcName:          \"foo\",\n\t\t\tsvcType:          v1.ServiceTypeLoadBalancer,\n\t\t\tlabels:           map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey:                       \"foo.example.org.\",\n\t\t\t\t\"service.beta.kubernetes.io/external-traffic\": \"SomethingElse\",\n\t\t\t},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected:           []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:            \"invalid annotation filter expression\",\n\t\t\tannotationFilter: \"service.beta.kubernetes.io/external-traffic in (Global OnlyLocal)\",\n\t\t\tsvcNamespace:     \"testing\",\n\t\t\tsvcName:          \"foo\",\n\t\t\tsvcType:          v1.ServiceTypeLoadBalancer,\n\t\t\tlabels:           map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey:                       \"foo.example.org.\",\n\t\t\t\t\"service.beta.kubernetes.io/external-traffic\": \"OnlyLocal\",\n\t\t\t},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected:           []*endpoint.Endpoint{},\n\t\t\texpectError:        true,\n\t\t},\n\t\t{\n\t\t\ttitle:            \"valid matching annotation filter label\",\n\t\t\tannotationFilter: \"service.beta.kubernetes.io/external-traffic=Global\",\n\t\t\tsvcNamespace:     \"testing\",\n\t\t\tsvcName:          \"foo\",\n\t\t\tsvcType:          v1.ServiceTypeLoadBalancer,\n\t\t\tlabels:           map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey:                       \"foo.example.org.\",\n\t\t\t\t\"service.beta.kubernetes.io/external-traffic\": \"Global\",\n\t\t\t},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:            \"valid non-matching annotation filter label\",\n\t\t\tannotationFilter: \"service.beta.kubernetes.io/external-traffic=Global\",\n\t\t\tsvcNamespace:     \"testing\",\n\t\t\tsvcName:          \"foo\",\n\t\t\tsvcType:          v1.ServiceTypeLoadBalancer,\n\t\t\tlabels:           map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey:                       \"foo.example.org.\",\n\t\t\t\t\"service.beta.kubernetes.io/external-traffic\": \"OnlyLocal\",\n\t\t\t},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected:           []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"no external entrypoints return no endpoints\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeLoadBalancer,\n\t\t\tlabels:       map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected:           []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"annotated service with externalIPs returns a single endpoint with multiple targets\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeLoadBalancer,\n\t\t\tlabels:       map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t},\n\t\t\texternalIPs:        []string{\"10.2.3.4\", \"11.2.3.4\"},\n\t\t\tlbs:                []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.2.3.4\", \"11.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"multiple external entrypoints return a single endpoint with multiple targets\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeLoadBalancer,\n\t\t\tlabels:       map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\", \"8.8.8.8\"},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\", \"8.8.8.8\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"services annotated with legacy mate annotations are ignored in default mode\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeLoadBalancer,\n\t\t\tlabels:       map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\t\"zalando.org/dnsname\": \"foo.example.org.\",\n\t\t\t},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected:           []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:         \"services annotated with legacy mate annotations return an endpoint in compatibility mode\",\n\t\t\tsvcNamespace:  \"testing\",\n\t\t\tsvcName:       \"foo\",\n\t\t\tsvcType:       v1.ServiceTypeLoadBalancer,\n\t\t\tcompatibility: \"mate\",\n\t\t\tlabels:        map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\t\"zalando.org/dnsname\": \"foo.example.org.\",\n\t\t\t},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:         \"services annotated with legacy molecule annotations return an endpoint in compatibility mode\",\n\t\t\tsvcNamespace:  \"testing\",\n\t\t\tsvcName:       \"foo\",\n\t\t\tsvcType:       v1.ServiceTypeLoadBalancer,\n\t\t\tcompatibility: \"molecule\",\n\t\t\tlabels: map[string]string{\n\t\t\t\t\"dns\": \"route53\",\n\t\t\t},\n\t\t\tannotations: map[string]string{\n\t\t\t\t\"domainName\": \"foo.example.org., bar.example.org\",\n\t\t\t},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{DNSName: \"bar.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:         \"load balancer services annotated with DNS Controller annotations return an endpoint with A and CNAME targets in compatibility mode\",\n\t\t\tsvcNamespace:  \"testing\",\n\t\t\tsvcName:       \"foo\",\n\t\t\tsvcType:       v1.ServiceTypeLoadBalancer,\n\t\t\tcompatibility: \"kops-dns-controller\",\n\t\t\tlabels:        map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tkopsDNSControllerInternalHostnameAnnotationKey: \"internal.foo.example.org\",\n\t\t\t},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\", \"lb.example.com\"},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"internal.foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{DNSName: \"internal.foo.example.org\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"lb.example.com\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:         \"load balancer services annotated with DNS Controller annotations return an endpoint with both annotations in compatibility mode\",\n\t\t\tsvcNamespace:  \"testing\",\n\t\t\tsvcName:       \"foo\",\n\t\t\tsvcType:       v1.ServiceTypeLoadBalancer,\n\t\t\tcompatibility: \"kops-dns-controller\",\n\t\t\tlabels:        map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tkopsDNSControllerInternalHostnameAnnotationKey: \"internal.foo.example.org., internal.bar.example.org\",\n\t\t\t\tkopsDNSControllerHostnameAnnotationKey:         \"foo.example.org., bar.example.org\",\n\t\t\t},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{DNSName: \"bar.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{DNSName: \"internal.foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{DNSName: \"internal.bar.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:              \"not annotated services with set fqdnTemplate return an endpoint with target IP\",\n\t\t\tsvcNamespace:       \"testing\",\n\t\t\tsvcName:            \"foo\",\n\t\t\tsvcType:            v1.ServiceTypeLoadBalancer,\n\t\t\tfqdnTemplate:       \"{{.Name}}.bar.example.com\",\n\t\t\tlabels:             map[string]string{},\n\t\t\tannotations:        map[string]string{},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\", \"elb.com\"},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.bar.example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{DNSName: \"foo.bar.example.com\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"elb.com\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"annotated services with set fqdnTemplate annotation takes precedence\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeLoadBalancer,\n\t\t\tfqdnTemplate: \"{{.Name}}.bar.example.com\",\n\t\t\tlabels:       map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\", \"elb.com\"},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"elb.com\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:         \"compatibility annotated services with tmpl. compatibility takes precedence\",\n\t\t\tsvcNamespace:  \"testing\",\n\t\t\tsvcName:       \"foo\",\n\t\t\tsvcType:       v1.ServiceTypeLoadBalancer,\n\t\t\tcompatibility: \"mate\",\n\t\t\tfqdnTemplate:  \"{{.Name}}.bar.example.com\",\n\t\t\tlabels:        map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\t\"zalando.org/dnsname\": \"mate.example.org.\",\n\t\t\t},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"mate.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:              \"not annotated services with unknown tmpl field should not return anything\",\n\t\t\tsvcNamespace:       \"testing\",\n\t\t\tsvcName:            \"foo\",\n\t\t\tsvcType:            v1.ServiceTypeLoadBalancer,\n\t\t\tfqdnTemplate:       \"{{.Calibre}}.bar.example.com\",\n\t\t\tlabels:             map[string]string{},\n\t\t\tannotations:        map[string]string{},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected:           []*endpoint.Endpoint{},\n\t\t\texpectError:        true,\n\t\t},\n\t\t{\n\t\t\ttitle:        \"ttl not annotated should have RecordTTL.IsConfigured set to false\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeLoadBalancer,\n\t\t\tlabels:       map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}, RecordTTL: endpoint.TTL(0)},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"ttl annotated but invalid should have RecordTTL.IsConfigured set to false\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeLoadBalancer,\n\t\t\tlabels:       map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t\tannotations.TtlKey:      \"foo\",\n\t\t\t},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}, RecordTTL: endpoint.TTL(0)},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"ttl annotated and is valid should set Record.TTL\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeLoadBalancer,\n\t\t\tlabels:       map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t\tannotations.TtlKey:      \"10\",\n\t\t\t},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}, RecordTTL: endpoint.TTL(10)},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"ttl annotated (in duration format) and is valid should set Record.TTL\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeLoadBalancer,\n\t\t\tlabels:       map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t\tannotations.TtlKey:      \"1m\",\n\t\t\t},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}, RecordTTL: endpoint.TTL(60)},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"Negative ttl is not valid\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeLoadBalancer,\n\t\t\tlabels:       map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t\tannotations.TtlKey:      \"-10\",\n\t\t\t},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}, RecordTTL: endpoint.TTL(0)},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"filter on service types should include matching services\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeLoadBalancer,\n\t\t\tlabels:       map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter: []string{string(v1.ServiceTypeLoadBalancer)},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"filter on service types should exclude non-matching services\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeNodePort,\n\t\t\tlabels:       map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t},\n\t\t\tlbs:                []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter: []string{string(v1.ServiceTypeLoadBalancer)},\n\t\t\texpected:           []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"internal-host annotated and host annotated clusterip services return an endpoint with Cluster IP\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeClusterIP,\n\t\t\tlabels:       map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey:         \"foo.example.org.\",\n\t\t\t\tannotations.InternalHostnameKey: \"foo.internal.example.org.\",\n\t\t\t},\n\t\t\tclusterIP:          \"1.1.1.1\",\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.internal.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.1.1.1\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"internal-host annotated loadbalancer services return an endpoint with Cluster IP\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeLoadBalancer,\n\t\t\tlabels:       map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.InternalHostnameKey: \"foo.internal.example.org.\",\n\t\t\t},\n\t\t\tclusterIP:          \"1.1.1.1\",\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.internal.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.1.1.1\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"internal-host annotated and host annotated loadbalancer services return an endpoint with Cluster IP and an endpoint with lb IP\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeLoadBalancer,\n\t\t\tlabels:       map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey:         \"foo.example.org.\",\n\t\t\t\tannotations.InternalHostnameKey: \"foo.internal.example.org.\",\n\t\t\t},\n\t\t\tclusterIP:          \"1.1.1.1\",\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.internal.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.1.1.1\"}},\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"service with matching labels and fqdn filter should be included\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"fqdn\",\n\t\t\tsvcType:      v1.ServiceTypeLoadBalancer,\n\t\t\tlabels: map[string]string{\n\t\t\t\t\"app\": \"web-external\",\n\t\t\t},\n\t\t\tclusterIP:            \"1.1.1.1\",\n\t\t\texternalIPs:          []string{},\n\t\t\tlbs:                  []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter:   []string{},\n\t\t\tserviceLabelSelector: \"app=web-external\",\n\t\t\tfqdnTemplate:         \"{{.Name}}.bar.example.com\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"fqdn.bar.example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"service with matching labels and hostname annotation should be included\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeLoadBalancer,\n\t\t\tlabels: map[string]string{\n\t\t\t\t\"app\": \"web-external\",\n\t\t\t},\n\t\t\tclusterIP:            \"1.1.1.1\",\n\t\t\texternalIPs:          []string{},\n\t\t\tlbs:                  []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter:   []string{},\n\t\t\tserviceLabelSelector: \"app=web-external\",\n\t\t\tannotations:          map[string]string{annotations.HostnameKey: \"annotation.bar.example.com\"},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"annotation.bar.example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"service without matching labels and fqdn filter should be excluded\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"fqdn\",\n\t\t\tsvcType:      v1.ServiceTypeLoadBalancer,\n\t\t\tlabels: map[string]string{\n\t\t\t\t\"app\": \"web-internal\",\n\t\t\t},\n\t\t\tclusterIP:            \"1.1.1.1\",\n\t\t\texternalIPs:          []string{},\n\t\t\tlbs:                  []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter:   []string{},\n\t\t\tserviceLabelSelector: \"app=web-external\",\n\t\t\tfqdnTemplate:         \"{{.Name}}.bar.example.com\",\n\t\t\texpected:             []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"service without matching labels and hostname annotation should be excluded\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeLoadBalancer,\n\t\t\tlabels: map[string]string{\n\t\t\t\t\"app\": \"web-internal\",\n\t\t\t},\n\t\t\tclusterIP:            \"1.1.1.1\",\n\t\t\texternalIPs:          []string{},\n\t\t\tlbs:                  []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter:   []string{},\n\t\t\tserviceLabelSelector: \"app=web-external\",\n\t\t\tannotations:          map[string]string{annotations.HostnameKey: \"annotation.bar.example.com\"},\n\t\t\texpected:             []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:              \"dual-stack load-balancer service gets both addresses\",\n\t\t\tsvcNamespace:       \"testing\",\n\t\t\tsvcName:            \"foobar\",\n\t\t\tsvcType:            v1.ServiceTypeLoadBalancer,\n\t\t\tlabels:             map[string]string{},\n\t\t\tclusterIP:          \"1.1.1.2,2001:db8::2\",\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.1.1.1\", \"2001:db8::1\"},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\tannotations:        map[string]string{annotations.HostnameKey: \"foobar.example.org\"},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foobar.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.1.1.1\"}},\n\t\t\t\t{DNSName: \"foobar.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:db8::1\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:              \"IPv6-only load-balancer service gets IPv6 endpoint\",\n\t\t\tsvcNamespace:       \"testing\",\n\t\t\tsvcName:            \"foobar-v6\",\n\t\t\tsvcType:            v1.ServiceTypeLoadBalancer,\n\t\t\tlabels:             map[string]string{},\n\t\t\tclusterIP:          \"2001:db8::1\",\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"2001:db8::2\"},\n\t\t\tserviceTypesFilter: []string{},\n\t\t\tannotations:        map[string]string{annotations.HostnameKey: \"foobar-v6.example.org\"},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foobar-v6.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:db8::2\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:              \"provider-specific annotation is converted to endpoint property\",\n\t\t\tsvcNamespace:       \"testing\",\n\t\t\tsvcName:            \"foo\",\n\t\t\tsvcType:            v1.ServiceTypeLoadBalancer,\n\t\t\tlabels:             map[string]string{},\n\t\t\texternalIPs:        []string{},\n\t\t\tlbs:                []string{\"1.2.3.4\"},\n\t\t\tserviceTypesFilter: []string{string(v1.ServiceTypeLoadBalancer)},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey:          \"foo.example.org\",\n\t\t\t\tannotations.AWSPrefix + \"weight\": \"10\",\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"foo.example.org\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{Name: \"aws/weight\", Value: \"10\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t} {\n\n\t\tt.Run(tc.title, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\t// Create a Kubernetes testing client\n\t\t\tkubernetes := fake.NewClientset()\n\n\t\t\t// Create a service to test against\n\t\t\tingresses := []v1.LoadBalancerIngress{}\n\t\t\tfor _, lb := range tc.lbs {\n\t\t\t\tif net.ParseIP(lb) != nil {\n\t\t\t\t\tingresses = append(ingresses, v1.LoadBalancerIngress{IP: lb})\n\t\t\t\t} else {\n\t\t\t\t\tingresses = append(ingresses, v1.LoadBalancerIngress{Hostname: lb})\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tservice := &v1.Service{\n\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\tType:        tc.svcType,\n\t\t\t\t\tClusterIP:   tc.clusterIP,\n\t\t\t\t\tExternalIPs: tc.externalIPs,\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tNamespace:   tc.svcNamespace,\n\t\t\t\t\tName:        tc.svcName,\n\t\t\t\t\tLabels:      tc.labels,\n\t\t\t\t\tAnnotations: tc.annotations,\n\t\t\t\t},\n\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\t\tIngress: ingresses,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}\n\n\t\t\t_, err := kubernetes.CoreV1().Services(service.Namespace).Create(t.Context(), service, metav1.CreateOptions{})\n\t\t\trequire.NoError(t, err)\n\n\t\t\tsourceLabel := labels.Everything()\n\t\t\tif tc.serviceLabelSelector != \"\" {\n\t\t\t\tsourceLabel, err = labels.Parse(tc.serviceLabelSelector)\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\t// Create our object under test and get the endpoints.\n\t\t\tclient, err := NewServiceSource(t.Context(), kubernetes,\n\t\t\t\t&Config{\n\t\t\t\t\tFQDNTemplate:                tc.fqdnTemplate,\n\t\t\t\t\tAnnotationFilter:            tc.annotationFilter,\n\t\t\t\t\tServiceTypeFilter:           tc.serviceTypesFilter,\n\t\t\t\t\tCombineFQDNAndAnnotation:    tc.combineFQDNAndAnnotation,\n\t\t\t\t\tCompatibility:               tc.compatibility,\n\t\t\t\t\tNamespace:                   tc.targetNamespace,\n\t\t\t\t\tResolveLoadBalancerHostname: tc.resolveLoadBalancerHostname,\n\t\t\t\t\tIgnoreHostnameAnnotation:    tc.ignoreHostnameAnnotation,\n\t\t\t\t\tLabelFilter:                 sourceLabel,\n\t\t\t\t},\n\t\t\t)\n\n\t\t\trequire.NoError(t, err)\n\n\t\t\tres, err := client.Endpoints(t.Context())\n\t\t\tif tc.expectError {\n\t\t\t\trequire.Error(t, err)\n\t\t\t} else {\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\t// Validate returned endpoints against desired endpoints.\n\t\t\tvalidateEndpoints(t, res, tc.expected)\n\t\t})\n\t}\n}\n\n// testMultipleServicesEndpoints tests that multiple services generate correct merged endpoints\nfunc testMultipleServicesEndpoints(t *testing.T) {\n\tt.Parallel()\n\n\tfor _, tc := range []struct {\n\t\ttitle                    string\n\t\ttargetNamespace          string\n\t\tannotationFilter         string\n\t\tsvcNamespace             string\n\t\tsvcName                  string\n\t\tsvcType                  v1.ServiceType\n\t\tcompatibility            string\n\t\tfqdnTemplate             string\n\t\tcombineFQDNAndAnnotation bool\n\t\tignoreHostnameAnnotation bool\n\t\tlabels                   map[string]string\n\t\tclusterIP                string\n\t\tservices                 map[string]map[string]string\n\t\tserviceTypesFilter       []string\n\t\texpected                 []*endpoint.Endpoint\n\t\texpectError              bool\n\t}{\n\t\t{\n\t\t\t\"test service returns a correct end point\",\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeLoadBalancer,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\tfalse,\n\t\t\tmap[string]string{},\n\t\t\t\"\",\n\t\t\tmap[string]map[string]string{\n\t\t\t\t\"1.2.3.4\": {annotations.HostnameKey: \"foo.example.org\"},\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}, Labels: map[string]string{endpoint.ResourceLabelKey: \"service/testing/foo1.2.3.4\"}},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"multiple services that share same DNS should be merged into one endpoint\",\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeLoadBalancer,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\tfalse,\n\t\t\tmap[string]string{},\n\t\t\t\"\",\n\t\t\tmap[string]map[string]string{\n\t\t\t\t\"1.2.3.4\": {annotations.HostnameKey: \"foo.example.org\"},\n\t\t\t\t\"1.2.3.5\": {annotations.HostnameKey: \"foo.example.org\"},\n\t\t\t\t\"1.2.3.6\": {annotations.HostnameKey: \"foo.example.org\"},\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\", \"1.2.3.5\", \"1.2.3.6\"}, Labels: map[string]string{endpoint.ResourceLabelKey: \"service/testing/foo1.2.3.4\"}},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"test that services with different hostnames do not get merged together\",\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeLoadBalancer,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\tfalse,\n\t\t\tmap[string]string{},\n\t\t\t\"\",\n\t\t\tmap[string]map[string]string{\n\t\t\t\t\"1.2.3.5\":  {annotations.HostnameKey: \"foo.example.org\"},\n\t\t\t\t\"10.1.1.3\": {annotations.HostnameKey: \"bar.example.org\"},\n\t\t\t\t\"10.1.1.1\": {annotations.HostnameKey: \"bar.example.org\"},\n\t\t\t\t\"1.2.3.4\":  {annotations.HostnameKey: \"foo.example.org\"},\n\t\t\t\t\"10.1.1.2\": {annotations.HostnameKey: \"bar.example.org\"},\n\t\t\t\t\"20.1.1.1\": {annotations.HostnameKey: \"foobar.example.org\"},\n\t\t\t\t\"1.2.3.6\":  {annotations.HostnameKey: \"foo.example.org\"},\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\", \"1.2.3.5\", \"1.2.3.6\"}, Labels: map[string]string{endpoint.ResourceLabelKey: \"service/testing/foo1.2.3.4\"}},\n\t\t\t\t{DNSName: \"bar.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.1.1.1\", \"10.1.1.2\", \"10.1.1.3\"}, Labels: map[string]string{endpoint.ResourceLabelKey: \"service/testing/foo10.1.1.1\"}},\n\t\t\t\t{DNSName: \"foobar.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"20.1.1.1\"}, Labels: map[string]string{endpoint.ResourceLabelKey: \"service/testing/foo20.1.1.1\"}},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"test that services with different set-identifier do not get merged together\",\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeLoadBalancer,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\tfalse,\n\t\t\tmap[string]string{},\n\t\t\t\"\",\n\t\t\tmap[string]map[string]string{\n\t\t\t\t\"1.2.3.5\":  {annotations.HostnameKey: \"foo.example.org\", annotations.SetIdentifierKey: \"a\"},\n\t\t\t\t\"10.1.1.3\": {annotations.HostnameKey: \"foo.example.org\", annotations.SetIdentifierKey: \"b\"},\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.5\"}, Labels: map[string]string{endpoint.ResourceLabelKey: \"service/testing/foo1.2.3.5\"}, SetIdentifier: \"a\"},\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.1.1.3\"}, Labels: map[string]string{endpoint.ResourceLabelKey: \"service/testing/foo10.1.1.3\"}, SetIdentifier: \"b\"},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"test that services with CNAME types do not get merged together\",\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeLoadBalancer,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\tfalse,\n\t\t\tmap[string]string{},\n\t\t\t\"\",\n\t\t\tmap[string]map[string]string{\n\t\t\t\t\"a.elb.com\": {annotations.HostnameKey: \"foo.example.org\"},\n\t\t\t\t\"b.elb.com\": {annotations.HostnameKey: \"foo.example.org\"},\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"a.elb.com\"}, Labels: map[string]string{endpoint.ResourceLabelKey: \"service/testing/fooa.elb.com\"}},\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"b.elb.com\"}, Labels: map[string]string{endpoint.ResourceLabelKey: \"service/testing/foob.elb.com\"}},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t} {\n\n\t\tt.Run(tc.title, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\t// Create a Kubernetes testing client\n\t\t\tkubernetes := fake.NewClientset()\n\n\t\t\t// Create services to test against\n\t\t\tfor lb, ants := range tc.services {\n\t\t\t\tingresses := []v1.LoadBalancerIngress{}\n\t\t\t\tingresses = append(ingresses, v1.LoadBalancerIngress{IP: lb})\n\n\t\t\t\tservice := &v1.Service{\n\t\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\t\tType:      tc.svcType,\n\t\t\t\t\t\tClusterIP: tc.clusterIP,\n\t\t\t\t\t},\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace:   tc.svcNamespace,\n\t\t\t\t\t\tName:        tc.svcName + lb,\n\t\t\t\t\t\tLabels:      tc.labels,\n\t\t\t\t\t\tAnnotations: ants,\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\t\t\tIngress: ingresses,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t}\n\n\t\t\t\t_, err := kubernetes.CoreV1().Services(service.Namespace).Create(t.Context(), service, metav1.CreateOptions{})\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\t// Create our object under test and get the endpoints.\n\t\t\tclient, err := NewServiceSource(t.Context(), kubernetes,\n\t\t\t\t&Config{\n\t\t\t\t\tFQDNTemplate:             tc.fqdnTemplate,\n\t\t\t\t\tAnnotationFilter:         tc.annotationFilter,\n\t\t\t\t\tServiceTypeFilter:        tc.serviceTypesFilter,\n\t\t\t\t\tCombineFQDNAndAnnotation: tc.combineFQDNAndAnnotation,\n\t\t\t\t\tCompatibility:            tc.compatibility,\n\t\t\t\t\tNamespace:                tc.targetNamespace,\n\t\t\t\t\tIgnoreHostnameAnnotation: tc.ignoreHostnameAnnotation,\n\t\t\t\t\tLabelFilter:              labels.Everything(),\n\t\t\t\t},\n\t\t\t)\n\t\t\trequire.NoError(t, err)\n\n\t\t\tres, err := client.Endpoints(t.Context())\n\t\t\tif tc.expectError {\n\t\t\t\trequire.Error(t, err)\n\t\t\t} else {\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\t// Validate returned endpoints against desired endpoints.\n\t\t\tvalidateEndpoints(t, res, tc.expected)\n\t\t\t// Test that endpoint resourceLabelKey matches desired endpoint\n\t\t\tsort.SliceStable(res, func(i, j int) bool {\n\t\t\t\treturn strings.Compare(res[i].DNSName, res[j].DNSName) < 0\n\t\t\t})\n\t\t\tsort.SliceStable(tc.expected, func(i, j int) bool {\n\t\t\t\treturn strings.Compare(tc.expected[i].DNSName, tc.expected[j].DNSName) < 0\n\t\t\t})\n\n\t\t\tfor i := range res {\n\t\t\t\tif res[i].Labels[endpoint.ResourceLabelKey] != tc.expected[i].Labels[endpoint.ResourceLabelKey] {\n\t\t\t\t\tt.Errorf(\"expected %s, got %s\", tc.expected[i].Labels[endpoint.ResourceLabelKey], res[i].Labels[endpoint.ResourceLabelKey])\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\n// testServiceSourceEndpoints tests that various services generate the correct endpoints.\nfunc TestClusterIpServices(t *testing.T) {\n\tt.Parallel()\n\n\tfor _, tc := range []struct {\n\t\ttitle                    string\n\t\ttargetNamespace          string\n\t\tannotationFilter         string\n\t\tsvcNamespace             string\n\t\tsvcName                  string\n\t\tsvcType                  v1.ServiceType\n\t\tcompatibility            string\n\t\tfqdnTemplate             string\n\t\tignoreHostnameAnnotation bool\n\t\tlabels                   map[string]string\n\t\tannotations              map[string]string\n\t\tclusterIP                string\n\t\texpected                 []*endpoint.Endpoint\n\t\texpectError              bool\n\t\tlabelSelector            string\n\t}{\n\t\t{\n\t\t\ttitle:        \"hostname annotated ClusterIp services return an endpoint with Cluster IP\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeClusterIP,\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t},\n\t\t\tclusterIP: \"1.2.3.4\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"target annotated ClusterIp services return an endpoint with the specified A\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeClusterIP,\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t\tannotations.TargetKey:   \"4.3.2.1\",\n\t\t\t},\n\t\t\tclusterIP: \"1.2.3.4\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"4.3.2.1\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"target annotated ClusterIp services return an endpoint with the specified CNAME\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeClusterIP,\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t\tannotations.TargetKey:   \"bar.example.org.\",\n\t\t\t},\n\t\t\tclusterIP: \"1.2.3.4\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"bar.example.org\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"target annotated ClusterIp services return an endpoint with the specified AAAA\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeClusterIP,\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t\tannotations.TargetKey:   \"2001:DB8::1\",\n\t\t\t},\n\t\t\tclusterIP: \"1.2.3.4\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:DB8::1\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"multiple target annotated ClusterIp services return an endpoint with the specified CNAMES\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeClusterIP,\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t\tannotations.TargetKey:   \"bar.example.org.,baz.example.org.\",\n\t\t\t},\n\t\t\tclusterIP: \"1.2.3.4\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"bar.example.org\", \"baz.example.org\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"multiple target annotated ClusterIp services return two endpoints with the specified CNAMES and AAAA\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeClusterIP,\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t\tannotations.TargetKey:   \"bar.example.org.,baz.example.org.,2001:DB8::1\",\n\t\t\t},\n\t\t\tclusterIP: \"1.2.3.4\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"bar.example.org\", \"baz.example.org\"}},\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:DB8::1\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:                    \"hostname annotated ClusterIp services are ignored\",\n\t\t\tsvcNamespace:             \"testing\",\n\t\t\tsvcName:                  \"foo\",\n\t\t\tsvcType:                  v1.ServiceTypeClusterIP,\n\t\t\tignoreHostnameAnnotation: true,\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t},\n\t\t\tclusterIP: \"1.2.3.4\",\n\t\t\texpected:  []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:                    \"hostname and target annotated ClusterIp services are ignored\",\n\t\t\tsvcNamespace:             \"testing\",\n\t\t\tsvcName:                  \"foo\",\n\t\t\tsvcType:                  v1.ServiceTypeClusterIP,\n\t\t\tignoreHostnameAnnotation: true,\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t\tannotations.TargetKey:   \"bar.example.org.\",\n\t\t\t},\n\t\t\tclusterIP: \"1.2.3.4\",\n\t\t\texpected:  []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"hostname and target annotated ClusterIp services return an endpoint with the specified CNAME\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeClusterIP,\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t\tannotations.TargetKey:   \"bar.example.org.\",\n\t\t\t},\n\t\t\tclusterIP: \"1.2.3.4\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"bar.example.org\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"non-annotated ClusterIp services with set fqdnTemplate return an endpoint with target IP\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeClusterIP,\n\t\t\tfqdnTemplate: \"{{.Name}}.bar.example.com\",\n\t\t\tclusterIP:    \"4.5.6.7\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.bar.example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"4.5.6.7\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"Headless services do not generate endpoints\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeClusterIP,\n\t\t\tclusterIP:    v1.ClusterIPNone,\n\t\t\texpected:     []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"Headless services generate endpoints when target is specified\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeClusterIP,\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t\tannotations.TargetKey:   \"bar.example.org.\",\n\t\t\t},\n\t\t\tclusterIP: v1.ClusterIPNone,\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"bar.example.org\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:        \"ClusterIP service with matching label generates an endpoint\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeClusterIP,\n\t\t\tfqdnTemplate: \"{{.Name}}.bar.example.com\",\n\t\t\tlabels:       map[string]string{\"app\": \"web-internal\"},\n\t\t\tclusterIP:    \"4.5.6.7\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.bar.example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"4.5.6.7\"}},\n\t\t\t},\n\t\t\tlabelSelector: \"app=web-internal\",\n\t\t},\n\t\t{\n\t\t\ttitle:        \"ClusterIP service with matching label and target generates a CNAME endpoint\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeClusterIP,\n\t\t\tfqdnTemplate: \"{{.Name}}.bar.example.com\",\n\t\t\tlabels:       map[string]string{\"app\": \"web-internal\"},\n\t\t\tannotations:  map[string]string{annotations.TargetKey: \"bar.example.com.\"},\n\t\t\tclusterIP:    \"4.5.6.7\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.bar.example.com\", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{\"bar.example.com\"}},\n\t\t\t},\n\t\t\tlabelSelector: \"app=web-internal\",\n\t\t},\n\t\t{\n\t\t\ttitle:         \"ClusterIP service without matching label generates an endpoint\",\n\t\t\tsvcNamespace:  \"testing\",\n\t\t\tsvcName:       \"foo\",\n\t\t\tsvcType:       v1.ServiceTypeClusterIP,\n\t\t\tfqdnTemplate:  \"{{.Name}}.bar.example.com\",\n\t\t\tlabels:        map[string]string{\"app\": \"web-internal\"},\n\t\t\tclusterIP:     \"4.5.6.7\",\n\t\t\texpected:      []*endpoint.Endpoint{},\n\t\t\tlabelSelector: \"app=web-external\",\n\t\t},\n\t\t{\n\t\t\ttitle:        \"invalid hostname does not generate endpoints\",\n\t\t\tsvcNamespace: \"testing\",\n\t\t\tsvcName:      \"foo\",\n\t\t\tsvcType:      v1.ServiceTypeClusterIP,\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"this-is-an-exceedingly-long-label-that-external-dns-should-reject.example.org.\",\n\t\t\t},\n\t\t\tclusterIP: \"1.2.3.4\",\n\t\t\texpected:  []*endpoint.Endpoint{},\n\t\t},\n\t} {\n\n\t\tt.Run(tc.title, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\t// Create a Kubernetes testing client\n\t\t\tkubernetes := fake.NewClientset()\n\n\t\t\t// Create a service to test against\n\t\t\tservice := &v1.Service{\n\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\tType:      tc.svcType,\n\t\t\t\t\tClusterIP: tc.clusterIP,\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tNamespace:   tc.svcNamespace,\n\t\t\t\t\tName:        tc.svcName,\n\t\t\t\t\tLabels:      tc.labels,\n\t\t\t\t\tAnnotations: tc.annotations,\n\t\t\t\t},\n\t\t\t}\n\n\t\t\t_, err := kubernetes.CoreV1().Services(service.Namespace).Create(t.Context(), service, metav1.CreateOptions{})\n\t\t\trequire.NoError(t, err)\n\n\t\t\tlabelSelector := labels.Everything()\n\t\t\tif tc.labelSelector != \"\" {\n\t\t\t\tlabelSelector, err = labels.Parse(tc.labelSelector)\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\t\t\t// Create our object under test and get the endpoints.\n\t\t\tclient, _ := NewServiceSource(t.Context(), kubernetes,\n\t\t\t\t&Config{\n\t\t\t\t\tFQDNTemplate:             tc.fqdnTemplate,\n\t\t\t\t\tAnnotationFilter:         tc.annotationFilter,\n\t\t\t\t\tCompatibility:            tc.compatibility,\n\t\t\t\t\tNamespace:                tc.targetNamespace,\n\t\t\t\t\tPublishInternal:          true,\n\t\t\t\t\tIgnoreHostnameAnnotation: tc.ignoreHostnameAnnotation,\n\t\t\t\t\tLabelFilter:              labelSelector,\n\t\t\t\t},\n\t\t\t)\n\t\t\trequire.NoError(t, err)\n\n\t\t\tendpoints, err := client.Endpoints(t.Context())\n\t\t\tif tc.expectError {\n\t\t\t\trequire.Error(t, err)\n\t\t\t} else {\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\t// Validate returned endpoints against desired endpoints.\n\t\t\tvalidateEndpoints(t, endpoints, tc.expected)\n\t\t})\n\t}\n}\n\n// testNodePortServices tests that various services generate the correct endpoints.\nfunc TestServiceSourceNodePortServices(t *testing.T) {\n\tt.Parallel()\n\n\tfor _, tc := range []struct {\n\t\ttitle                    string\n\t\ttargetNamespace          string\n\t\tannotationFilter         string\n\t\tsvcNamespace             string\n\t\tsvcName                  string\n\t\tsvcType                  v1.ServiceType\n\t\tsvcTrafficPolicy         v1.ServiceExternalTrafficPolicyType\n\t\tcompatibility            string\n\t\tfqdnTemplate             string\n\t\tignoreHostnameAnnotation bool\n\t\texposeInternalIPv6       bool\n\t\tignoreUnscheduledNodes   bool\n\t\tlabels                   map[string]string\n\t\tannotations              map[string]string\n\t\tlbs                      []string\n\t\texpected                 []*endpoint.Endpoint\n\t\texpectError              bool\n\t\tnodes                    []*v1.Node\n\t\tpodNames                 []string\n\t\tnodeIndex                []int\n\t\tphases                   []v1.PodPhase\n\t\tconditions               []v1.PodCondition\n\t\tlabelSelector            labels.Selector\n\t\tdeletionTimestamp        []*metav1.Time\n\t}{\n\t\t{\n\t\t\ttitle:            \"annotated NodePort services return an endpoint with IP addresses of the cluster's nodes\",\n\t\t\tsvcNamespace:     \"testing\",\n\t\t\tsvcName:          \"foo\",\n\t\t\tsvcType:          v1.ServiceTypeNodePort,\n\t\t\tsvcTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeCluster,\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"_foo._tcp.foo.example.org\", Targets: endpoint.Targets{\"0 50 30192 foo.example.org.\"}, RecordType: endpoint.RecordTypeSRV},\n\t\t\t\t{DNSName: \"foo.example.org\", Targets: endpoint.Targets{\"54.10.11.1\", \"54.10.11.2\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t\t{DNSName: \"foo.example.org\", Targets: endpoint.Targets{\"2001:DB8::1\", \"2001:DB8::3\"}, RecordType: endpoint.RecordTypeAAAA},\n\t\t\t},\n\t\t\tnodes: []*v1.Node{{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"node1\",\n\t\t\t\t},\n\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"54.10.11.1\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"10.0.1.1\"},\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"2001:DB8::1\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"2001:DB8::2\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}, {\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"node2\",\n\t\t\t\t},\n\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"54.10.11.2\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"10.0.1.2\"},\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"2001:DB8::3\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"2001:DB8::4\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}},\n\t\t},\n\t\t{\n\t\t\ttitle:                    \"hostname annotated NodePort services are ignored\",\n\t\t\tsvcNamespace:             \"testing\",\n\t\t\tsvcName:                  \"foo\",\n\t\t\tsvcType:                  v1.ServiceTypeNodePort,\n\t\t\tsvcTrafficPolicy:         v1.ServiceExternalTrafficPolicyTypeCluster,\n\t\t\tignoreHostnameAnnotation: true,\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t},\n\t\t\tnodes: []*v1.Node{{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"node1\",\n\t\t\t\t},\n\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"54.10.11.1\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"10.0.1.1\"},\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"2001:DB8::1\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"2001:DB8::2\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}, {\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"node2\",\n\t\t\t\t},\n\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"54.10.11.2\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"10.0.1.2\"},\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"2001:DB8::3\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"2001:DB8::4\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:            \"non-annotated NodePort services with set fqdnTemplate return an endpoint with target IP\",\n\t\t\tsvcNamespace:     \"testing\",\n\t\t\tsvcName:          \"foo\",\n\t\t\tsvcType:          v1.ServiceTypeNodePort,\n\t\t\tsvcTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeCluster,\n\t\t\tfqdnTemplate:     \"{{.Name}}.bar.example.com\",\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"_foo._tcp.foo.bar.example.com\", Targets: endpoint.Targets{\"0 50 30192 foo.bar.example.com.\"}, RecordType: endpoint.RecordTypeSRV},\n\t\t\t\t{DNSName: \"foo.bar.example.com\", Targets: endpoint.Targets{\"54.10.11.1\", \"54.10.11.2\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t\t{DNSName: \"foo.bar.example.com\", Targets: endpoint.Targets{\"2001:DB8::1\", \"2001:DB8::3\"}, RecordType: endpoint.RecordTypeAAAA},\n\t\t\t},\n\t\t\tnodes: []*v1.Node{{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"node1\",\n\t\t\t\t},\n\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"54.10.11.1\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"10.0.1.1\"},\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"2001:DB8::1\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"2001:DB8::2\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}, {\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"node2\",\n\t\t\t\t},\n\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"54.10.11.2\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"10.0.1.2\"},\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"2001:DB8::3\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"2001:DB8::4\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}},\n\t\t},\n\t\t{\n\t\t\ttitle:            \"annotated NodePort services return an endpoint with IP addresses of the private cluster's nodes\",\n\t\t\tsvcNamespace:     \"testing\",\n\t\t\tsvcName:          \"foo\",\n\t\t\tsvcType:          v1.ServiceTypeNodePort,\n\t\t\tsvcTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeCluster,\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"_foo._tcp.foo.example.org\", Targets: endpoint.Targets{\"0 50 30192 foo.example.org.\"}, RecordType: endpoint.RecordTypeSRV},\n\t\t\t\t{DNSName: \"foo.example.org\", Targets: endpoint.Targets{\"10.0.1.1\", \"10.0.1.2\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t\t{DNSName: \"foo.example.org\", Targets: endpoint.Targets{\"2001:DB8::1\", \"2001:DB8::2\"}, RecordType: endpoint.RecordTypeAAAA},\n\t\t\t},\n\t\t\tnodes: []*v1.Node{{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"node1\",\n\t\t\t\t},\n\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"10.0.1.1\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"2001:DB8::1\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}, {\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"node2\",\n\t\t\t\t},\n\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"10.0.1.2\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"2001:DB8::2\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}},\n\t\t},\n\t\t{\n\t\t\ttitle:            \"annotated NodePort services with ExternalTrafficPolicy=Local return an endpoint with IP addresses of the cluster's nodes where pods is running only\",\n\t\t\tsvcNamespace:     \"testing\",\n\t\t\tsvcName:          \"foo\",\n\t\t\tsvcType:          v1.ServiceTypeNodePort,\n\t\t\tsvcTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeLocal,\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"_foo._tcp.foo.example.org\", Targets: endpoint.Targets{\"0 50 30192 foo.example.org.\"}, RecordType: endpoint.RecordTypeSRV},\n\t\t\t\t{DNSName: \"foo.example.org\", Targets: endpoint.Targets{\"54.10.11.2\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t\t{DNSName: \"foo.example.org\", Targets: endpoint.Targets{\"2001:DB8::3\"}, RecordType: endpoint.RecordTypeAAAA},\n\t\t\t},\n\t\t\tnodes: []*v1.Node{{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"node1\",\n\t\t\t\t},\n\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"54.10.11.1\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"10.0.1.1\"},\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"2001:DB8::1\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"2001:DB8::2\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}, {\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"node2\",\n\t\t\t\t},\n\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"54.10.11.2\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"10.0.1.2\"},\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"2001:DB8::3\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"2001:DB8::4\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}},\n\t\t\tpodNames:          []string{\"pod-0\"},\n\t\t\tnodeIndex:         []int{1},\n\t\t\tphases:            []v1.PodPhase{v1.PodRunning},\n\t\t\tconditions:        []v1.PodCondition{{Type: v1.PodReady, Status: v1.ConditionFalse}},\n\t\t\tdeletionTimestamp: []*metav1.Time{{}},\n\t\t},\n\t\t{\n\t\t\ttitle:            \"annotated NodePort services with ExternalTrafficPolicy=Local and multiple pods on a single node return an endpoint with unique IP addresses of the cluster's nodes where pods is running only\",\n\t\t\tsvcNamespace:     \"testing\",\n\t\t\tsvcName:          \"foo\",\n\t\t\tsvcType:          v1.ServiceTypeNodePort,\n\t\t\tsvcTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeLocal,\n\t\t\tlabels:           map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"_foo._tcp.foo.example.org\", Targets: endpoint.Targets{\"0 50 30192 foo.example.org.\"}, RecordType: endpoint.RecordTypeSRV},\n\t\t\t\t{DNSName: \"foo.example.org\", Targets: endpoint.Targets{\"54.10.11.2\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t\t{DNSName: \"foo.example.org\", Targets: endpoint.Targets{\"2001:DB8::3\"}, RecordType: endpoint.RecordTypeAAAA},\n\t\t\t},\n\t\t\tnodes: []*v1.Node{{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"node1\",\n\t\t\t\t},\n\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"54.10.11.1\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"10.0.1.1\"},\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"2001:DB8::1\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"2001:DB8::2\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}, {\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"node2\",\n\t\t\t\t},\n\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"54.10.11.2\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"10.0.1.2\"},\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"2001:DB8::3\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"2001:DB8::4\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}},\n\t\t\tpodNames:  []string{\"pod-0\", \"pod-1\"},\n\t\t\tnodeIndex: []int{1, 1},\n\t\t\tphases:    []v1.PodPhase{v1.PodRunning, v1.PodRunning},\n\t\t\tconditions: []v1.PodCondition{\n\t\t\t\t{Type: v1.PodReady, Status: v1.ConditionFalse},\n\t\t\t\t{Type: v1.PodReady, Status: v1.ConditionFalse},\n\t\t\t},\n\t\t\tdeletionTimestamp: []*metav1.Time{{}, {}},\n\t\t},\n\t\t{\n\t\t\ttitle:            \"annotated NodePort services with ExternalTrafficPolicy=Local return pods in Ready & Running state\",\n\t\t\tsvcNamespace:     \"testing\",\n\t\t\tsvcName:          \"foo\",\n\t\t\tsvcType:          v1.ServiceTypeNodePort,\n\t\t\tsvcTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeLocal,\n\t\t\tlabels:           map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"_foo._tcp.foo.example.org\", Targets: endpoint.Targets{\"0 50 30192 foo.example.org.\"}, RecordType: endpoint.RecordTypeSRV},\n\t\t\t\t{DNSName: \"foo.example.org\", Targets: endpoint.Targets{\"54.10.11.1\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t},\n\t\t\tnodes: []*v1.Node{{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"node1\",\n\t\t\t\t},\n\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"54.10.11.1\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"10.0.1.1\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}, {\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"node2\",\n\t\t\t\t},\n\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"54.10.11.2\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"10.0.1.2\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}},\n\t\t\tpodNames:  []string{\"pod-0\", \"pod-1\"},\n\t\t\tnodeIndex: []int{0, 1},\n\t\t\tphases:    []v1.PodPhase{v1.PodRunning, v1.PodRunning},\n\t\t\tconditions: []v1.PodCondition{\n\t\t\t\t{Type: v1.PodReady, Status: v1.ConditionTrue},\n\t\t\t\t{Type: v1.PodReady, Status: v1.ConditionFalse},\n\t\t\t},\n\t\t\tdeletionTimestamp: []*metav1.Time{{}, {}},\n\t\t},\n\t\t{\n\t\t\ttitle:            \"annotated NodePort services with ExternalTrafficPolicy=Local return pods in Ready & Running state & not in Terminating\",\n\t\t\tsvcNamespace:     \"testing\",\n\t\t\tsvcName:          \"foo\",\n\t\t\tsvcType:          v1.ServiceTypeNodePort,\n\t\t\tsvcTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeLocal,\n\t\t\tlabels:           map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"_foo._tcp.foo.example.org\", Targets: endpoint.Targets{\"0 50 30192 foo.example.org.\"}, RecordType: endpoint.RecordTypeSRV},\n\t\t\t\t{DNSName: \"foo.example.org\", Targets: endpoint.Targets{\"54.10.11.1\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t},\n\t\t\tnodes: []*v1.Node{{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"node1\",\n\t\t\t\t},\n\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"54.10.11.1\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"10.0.1.1\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}, {\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"node2\",\n\t\t\t\t},\n\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"54.10.11.2\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"10.0.1.2\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}, {\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"node3\",\n\t\t\t\t},\n\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"54.10.11.3\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"10.0.1.3\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}},\n\t\t\tpodNames:  []string{\"pod-0\", \"pod-1\", \"pod-2\"},\n\t\t\tnodeIndex: []int{0, 1, 2},\n\t\t\tphases:    []v1.PodPhase{v1.PodRunning, v1.PodRunning, v1.PodRunning},\n\t\t\tconditions: []v1.PodCondition{\n\t\t\t\t{Type: v1.PodReady, Status: v1.ConditionTrue},\n\t\t\t\t{Type: v1.PodReady, Status: v1.ConditionFalse},\n\t\t\t\t{Type: v1.PodReady, Status: v1.ConditionTrue},\n\t\t\t},\n\t\t\tdeletionTimestamp: []*metav1.Time{nil, nil, {}},\n\t\t},\n\t\t{\n\t\t\ttitle:            \"access=private annotation NodePort services return an endpoint with private IP addresses of the cluster's nodes\",\n\t\t\tsvcNamespace:     \"testing\",\n\t\t\tsvcName:          \"foo\",\n\t\t\tsvcType:          v1.ServiceTypeNodePort,\n\t\t\tsvcTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeCluster,\n\t\t\tlabels:           map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t\tannotations.AccessKey:   \"private\",\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"_foo._tcp.foo.example.org\", Targets: endpoint.Targets{\"0 50 30192 foo.example.org.\"}, RecordType: endpoint.RecordTypeSRV},\n\t\t\t\t{DNSName: \"foo.example.org\", Targets: endpoint.Targets{\"10.0.1.1\", \"10.0.1.2\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t\t{DNSName: \"foo.example.org\", Targets: endpoint.Targets{\"2001:DB8::1\", \"2001:DB8::2\"}, RecordType: endpoint.RecordTypeAAAA},\n\t\t\t},\n\t\t\tnodes: []*v1.Node{{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"node1\",\n\t\t\t\t},\n\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"54.10.11.1\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"10.0.1.1\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"2001:DB8::1\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}, {\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"node2\",\n\t\t\t\t},\n\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"54.10.11.2\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"10.0.1.2\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"2001:DB8::2\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}},\n\t\t},\n\t\t{\n\t\t\ttitle:            \"access=public annotation NodePort services return an endpoint with external IP addresses of the cluster's nodes if exposeInternalIPv6 is unset\",\n\t\t\tsvcNamespace:     \"testing\",\n\t\t\tsvcName:          \"foo\",\n\t\t\tsvcType:          v1.ServiceTypeNodePort,\n\t\t\tsvcTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeCluster,\n\t\t\tlabels:           map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t\tannotations.AccessKey:   \"public\",\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"_foo._tcp.foo.example.org\", Targets: endpoint.Targets{\"0 50 30192 foo.example.org.\"}, RecordType: endpoint.RecordTypeSRV},\n\t\t\t\t{DNSName: \"foo.example.org\", Targets: endpoint.Targets{\"54.10.11.1\", \"54.10.11.2\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t\t{DNSName: \"foo.example.org\", Targets: endpoint.Targets{\"2001:DB8::1\", \"2001:DB8::3\"}, RecordType: endpoint.RecordTypeAAAA},\n\t\t\t},\n\t\t\tnodes: []*v1.Node{{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"node1\",\n\t\t\t\t},\n\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"54.10.11.1\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"10.0.1.1\"},\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"2001:DB8::1\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"2001:DB8::2\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}, {\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"node2\",\n\t\t\t\t},\n\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"54.10.11.2\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"10.0.1.2\"},\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"2001:DB8::3\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"2001:DB8::4\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}},\n\t\t},\n\t\t{\n\t\t\ttitle:            \"access=public annotation NodePort services return an endpoint with public IP addresses of the cluster's nodes if exposeInternalIPv6 is set to true\",\n\t\t\tsvcNamespace:     \"testing\",\n\t\t\tsvcName:          \"foo\",\n\t\t\tsvcType:          v1.ServiceTypeNodePort,\n\t\t\tsvcTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeCluster,\n\t\t\tlabels:           map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t\tannotations.AccessKey:   \"public\",\n\t\t\t},\n\t\t\texposeInternalIPv6: true,\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"_foo._tcp.foo.example.org\", Targets: endpoint.Targets{\"0 50 30192 foo.example.org.\"}, RecordType: endpoint.RecordTypeSRV},\n\t\t\t\t{DNSName: \"foo.example.org\", Targets: endpoint.Targets{\"54.10.11.1\", \"54.10.11.2\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t\t{DNSName: \"foo.example.org\", Targets: endpoint.Targets{\"2001:DB8::1\", \"2001:DB8::2\", \"2001:DB8::3\", \"2001:DB8::4\"}, RecordType: endpoint.RecordTypeAAAA},\n\t\t\t},\n\t\t\tnodes: []*v1.Node{{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"node1\",\n\t\t\t\t},\n\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"54.10.11.1\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"10.0.1.1\"},\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"2001:DB8::1\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"2001:DB8::2\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}, {\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"node2\",\n\t\t\t\t},\n\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"54.10.11.2\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"10.0.1.2\"},\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"2001:DB8::3\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"2001:DB8::4\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}},\n\t\t},\n\t\t{\n\t\t\ttitle:            \"node port services annotated DNS Controller annotations return an endpoint where all targets has the node role\",\n\t\t\tsvcNamespace:     \"testing\",\n\t\t\tsvcName:          \"foo\",\n\t\t\tsvcType:          v1.ServiceTypeNodePort,\n\t\t\tsvcTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeCluster,\n\t\t\tcompatibility:    \"kops-dns-controller\",\n\t\t\tlabels:           map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tkopsDNSControllerInternalHostnameAnnotationKey: \"internal.foo.example.org., internal.bar.example.org\",\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"internal.foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.0.1.1\"}},\n\t\t\t\t{DNSName: \"internal.foo.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:DB8::1\"}},\n\t\t\t\t{DNSName: \"internal.bar.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.0.1.1\"}},\n\t\t\t\t{DNSName: \"internal.bar.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:DB8::1\"}},\n\t\t\t},\n\t\t\tnodes: []*v1.Node{{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"node1\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\"node-role.kubernetes.io/node\": \"\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"54.10.11.1\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"10.0.1.1\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"2001:DB8::1\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}, {\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"node2\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\"node-role.kubernetes.io/control-plane\": \"\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"54.10.11.2\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"10.0.1.2\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"2001:DB8::2\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}},\n\t\t},\n\t\t{\n\t\t\ttitle:            \"node port services annotated with internal DNS Controller annotations return an endpoint in compatibility mode\",\n\t\t\tsvcNamespace:     \"testing\",\n\t\t\tsvcName:          \"foo\",\n\t\t\tsvcType:          v1.ServiceTypeNodePort,\n\t\t\tsvcTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeCluster,\n\t\t\tcompatibility:    \"kops-dns-controller\",\n\t\t\tannotations: map[string]string{\n\t\t\t\tkopsDNSControllerInternalHostnameAnnotationKey: \"internal.foo.example.org., internal.bar.example.org\",\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"internal.foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.0.1.1\", \"10.0.1.2\"}},\n\t\t\t\t{DNSName: \"internal.foo.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:DB8::1\", \"2001:DB8::2\"}},\n\t\t\t\t{DNSName: \"internal.bar.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.0.1.1\", \"10.0.1.2\"}},\n\t\t\t\t{DNSName: \"internal.bar.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:DB8::1\", \"2001:DB8::2\"}},\n\t\t\t},\n\t\t\tnodes: []*v1.Node{{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"node1\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\"node-role.kubernetes.io/node\": \"\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"54.10.11.1\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"10.0.1.1\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"2001:DB8::1\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}, {\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"node2\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\"node-role.kubernetes.io/node\": \"\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"54.10.11.2\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"10.0.1.2\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"2001:DB8::2\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}},\n\t\t},\n\t\t{\n\t\t\ttitle:              \"node port services annotated with external DNS Controller annotations return an endpoint in compatibility mode with exposeInternalIPv6 flag set\",\n\t\t\tsvcNamespace:       \"testing\",\n\t\t\tsvcName:            \"foo\",\n\t\t\tsvcType:            v1.ServiceTypeNodePort,\n\t\t\tsvcTrafficPolicy:   v1.ServiceExternalTrafficPolicyTypeCluster,\n\t\t\tcompatibility:      \"kops-dns-controller\",\n\t\t\texposeInternalIPv6: true,\n\t\t\tannotations: map[string]string{\n\t\t\t\tkopsDNSControllerHostnameAnnotationKey: \"foo.example.org., bar.example.org\",\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"54.10.11.1\", \"54.10.11.2\"}},\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:DB8::1\", \"2001:DB8::2\"}},\n\t\t\t\t{DNSName: \"bar.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"54.10.11.1\", \"54.10.11.2\"}},\n\t\t\t\t{DNSName: \"bar.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:DB8::1\", \"2001:DB8::2\"}},\n\t\t\t},\n\t\t\tnodes: []*v1.Node{{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"node1\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\"node-role.kubernetes.io/node\": \"\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"54.10.11.1\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"10.0.1.1\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"2001:DB8::1\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}, {\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"node2\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\"node-role.kubernetes.io/node\": \"\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"54.10.11.2\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"10.0.1.2\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"2001:DB8::2\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}},\n\t\t},\n\t\t{\n\t\t\ttitle:            \"node port services annotated with both kops dns controller annotations return an empty set of addons\",\n\t\t\tsvcNamespace:     \"testing\",\n\t\t\tsvcName:          \"foo\",\n\t\t\tsvcType:          v1.ServiceTypeNodePort,\n\t\t\tsvcTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeCluster,\n\t\t\tcompatibility:    \"kops-dns-controller\",\n\t\t\tlabels:           map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tkopsDNSControllerInternalHostnameAnnotationKey: \"internal.foo.example.org., internal.bar.example.org\",\n\t\t\t\tkopsDNSControllerHostnameAnnotationKey:         \"foo.example.org., bar.example.org\",\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t\tnodes: []*v1.Node{{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"node1\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\"node-role.kubernetes.io/node\": \"\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"54.10.11.1\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"10.0.1.1\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"2001:DB8::1\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}, {\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"node2\",\n\t\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\t\"node-role.kubernetes.io/node\": \"\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"54.10.11.2\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"10.0.1.2\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"2001:DB8::2\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}},\n\t\t},\n\t\t{\n\t\t\ttitle:                  \"NodePort services ignore unschedulable node\",\n\t\t\tignoreUnscheduledNodes: true,\n\t\t\tsvcNamespace:           \"testing\",\n\t\t\tsvcName:                \"foo\",\n\t\t\tsvcType:                v1.ServiceTypeNodePort,\n\t\t\tsvcTrafficPolicy:       v1.ServiceExternalTrafficPolicyTypeCluster,\n\t\t\tlabels:                 map[string]string{},\n\t\t\tannotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t\tannotations.AccessKey:   \"public\",\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"_foo._tcp.foo.example.org\", Targets: endpoint.Targets{\"0 50 30192 foo.example.org.\"}, RecordType: endpoint.RecordTypeSRV},\n\t\t\t\t{DNSName: \"foo.example.org\", Targets: endpoint.Targets{\"54.10.11.2\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t\t{DNSName: \"foo.example.org\", Targets: endpoint.Targets{\"2001:DB8::3\"}, RecordType: endpoint.RecordTypeAAAA},\n\t\t\t},\n\t\t\tnodes: []*v1.Node{{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"node1\",\n\t\t\t\t},\n\t\t\t\tSpec: v1.NodeSpec{\n\t\t\t\t\tUnschedulable: true,\n\t\t\t\t},\n\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"54.10.11.1\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"10.0.1.1\"},\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"2001:DB8::1\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"2001:DB8::2\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}, {\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName: \"node2\",\n\t\t\t\t},\n\t\t\t\tSpec: v1.NodeSpec{\n\t\t\t\t\tUnschedulable: false,\n\t\t\t\t},\n\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"54.10.11.2\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"10.0.1.2\"},\n\t\t\t\t\t\t{Type: v1.NodeExternalIP, Address: \"2001:DB8::3\"},\n\t\t\t\t\t\t{Type: v1.NodeInternalIP, Address: \"2001:DB8::4\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t}},\n\t\t},\n\t} {\n\n\t\tt.Run(tc.title, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\t// Create a Kubernetes testing client\n\t\t\tkubernetes := fake.NewClientset()\n\n\t\t\t// Create the nodes\n\t\t\tfor _, node := range tc.nodes {\n\t\t\t\tif _, err := kubernetes.CoreV1().Nodes().Create(t.Context(), node, metav1.CreateOptions{}); err != nil {\n\t\t\t\t\tt.Fatal(err)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Create  pods\n\t\t\tfor i, podname := range tc.podNames {\n\t\t\t\tpod := &v1.Pod{\n\t\t\t\t\tSpec: v1.PodSpec{\n\t\t\t\t\t\tContainers: []v1.Container{},\n\t\t\t\t\t\tHostname:   podname,\n\t\t\t\t\t\tNodeName:   tc.nodes[tc.nodeIndex[i]].Name,\n\t\t\t\t\t},\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace:         tc.svcNamespace,\n\t\t\t\t\t\tName:              podname,\n\t\t\t\t\t\tLabels:            tc.labels,\n\t\t\t\t\t\tAnnotations:       tc.annotations,\n\t\t\t\t\t\tDeletionTimestamp: tc.deletionTimestamp[i],\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.PodStatus{\n\t\t\t\t\t\tPhase:      tc.phases[i],\n\t\t\t\t\t\tConditions: []v1.PodCondition{tc.conditions[i]},\n\t\t\t\t\t},\n\t\t\t\t}\n\n\t\t\t\t_, err := kubernetes.CoreV1().Pods(tc.svcNamespace).Create(t.Context(), pod, metav1.CreateOptions{})\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\t// Create a service to test against\n\t\t\tservice := &v1.Service{\n\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\tType:                  tc.svcType,\n\t\t\t\t\tExternalTrafficPolicy: tc.svcTrafficPolicy,\n\t\t\t\t\tPorts: []v1.ServicePort{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tNodePort: 30192,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tNamespace:   tc.svcNamespace,\n\t\t\t\t\tName:        tc.svcName,\n\t\t\t\t\tLabels:      tc.labels,\n\t\t\t\t\tAnnotations: tc.annotations,\n\t\t\t\t},\n\t\t\t}\n\n\t\t\t_, err := kubernetes.CoreV1().Services(service.Namespace).Create(t.Context(), service, metav1.CreateOptions{})\n\t\t\trequire.NoError(t, err)\n\n\t\t\t// Create our object under test and get the endpoints.\n\t\t\tclient, _ := NewServiceSource(t.Context(), kubernetes,\n\t\t\t\t&Config{\n\t\t\t\t\tFQDNTemplate:             tc.fqdnTemplate,\n\t\t\t\t\tAnnotationFilter:         tc.annotationFilter,\n\t\t\t\t\tCompatibility:            tc.compatibility,\n\t\t\t\t\tNamespace:                tc.targetNamespace,\n\t\t\t\t\tIgnoreHostnameAnnotation: tc.ignoreHostnameAnnotation,\n\t\t\t\t\tExposeInternalIPv6:       tc.exposeInternalIPv6,\n\t\t\t\t\tExcludeUnschedulable:     tc.ignoreUnscheduledNodes,\n\t\t\t\t\tLabelFilter:              labels.Everything(),\n\t\t\t\t},\n\t\t\t)\n\t\t\trequire.NoError(t, err)\n\n\t\t\tendpoints, err := client.Endpoints(t.Context())\n\t\t\tif tc.expectError {\n\t\t\t\trequire.Error(t, err)\n\t\t\t} else {\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\t// Validate returned endpoints against desired endpoints.\n\t\t\tvalidateEndpoints(t, endpoints, tc.expected)\n\t\t})\n\t}\n}\n\n// TestHeadlessServices tests that headless services generate the correct endpoints.\nfunc TestHeadlessServices(t *testing.T) {\n\tt.Parallel()\n\n\tfor _, tc := range []struct {\n\t\ttitle                    string\n\t\ttargetNamespace          string\n\t\tsvcNamespace             string\n\t\tsvcName                  string\n\t\tsvcType                  v1.ServiceType\n\t\tcompatibility            string\n\t\tfqdnTemplate             string\n\t\tignoreHostnameAnnotation bool\n\t\texposeInternalIPv6       bool\n\t\tlabels                   map[string]string\n\t\tsvcAnnotations           map[string]string\n\t\tpodAnnotations           map[string]string\n\t\tclusterIP                string\n\t\tpodIPs                   []string\n\t\thostIPs                  []string\n\t\tselector                 map[string]string\n\t\tlbs                      []string\n\t\tpodnames                 []string\n\t\thostnames                []string\n\t\tpodsReady                []bool\n\t\tpublishNotReadyAddresses bool\n\t\tnodes                    []v1.Node\n\t\tserviceTypesFilter       []string\n\t\texpected                 []*endpoint.Endpoint\n\t\texpectError              bool\n\t}{\n\t\t{\n\t\t\t\"annotated Headless services return IPv4 endpoints for each selected Pod\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeClusterIP,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\tfalse,\n\t\t\tmap[string]string{\"component\": \"foo\"},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.HostnameKey: \"service.example.org\",\n\t\t\t},\n\t\t\tmap[string]string{},\n\t\t\tv1.ClusterIPNone,\n\t\t\t[]string{\"1.1.1.1\", \"1.1.1.2\"},\n\t\t\t[]string{\"\", \"\"},\n\t\t\tmap[string]string{\n\t\t\t\t\"component\": \"foo\",\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]string{\"foo-0\", \"foo-1\"},\n\t\t\t[]string{\"foo-0\", \"foo-1\"},\n\t\t\t[]bool{true, true},\n\t\t\tfalse,\n\t\t\t[]v1.Node{},\n\t\t\t[]string{},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo-0.service.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.1.1.1\"}},\n\t\t\t\t{DNSName: \"foo-1.service.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.1.1.2\"}},\n\t\t\t\t{DNSName: \"service.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.1.1.1\", \"1.1.1.2\"}},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"annotated Headless services return IPv6 endpoints for each selected Pod\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeClusterIP,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\tfalse,\n\t\t\tmap[string]string{\"component\": \"foo\"},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.HostnameKey: \"service.example.org\",\n\t\t\t},\n\t\t\tmap[string]string{},\n\t\t\tv1.ClusterIPNone,\n\t\t\t[]string{\"2001:db8::1\", \"2001:db8::2\"},\n\t\t\t[]string{\"\", \"\"},\n\t\t\tmap[string]string{\n\t\t\t\t\"component\": \"foo\",\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]string{\"foo-0\", \"foo-1\"},\n\t\t\t[]string{\"foo-0\", \"foo-1\"},\n\t\t\t[]bool{true, true},\n\t\t\tfalse,\n\t\t\t[]v1.Node{},\n\t\t\t[]string{string(v1.ServiceTypeClusterIP), string(v1.ServiceTypeLoadBalancer)},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo-0.service.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:db8::1\"}},\n\t\t\t\t{DNSName: \"foo-1.service.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:db8::2\"}},\n\t\t\t\t{DNSName: \"service.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:db8::1\", \"2001:db8::2\"}},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"hostname annotated Headless services are ignored\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeClusterIP,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\ttrue,\n\t\t\tfalse,\n\t\t\tmap[string]string{\"component\": \"foo\"},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.HostnameKey: \"service.example.org\",\n\t\t\t},\n\t\t\tmap[string]string{},\n\t\t\tv1.ClusterIPNone,\n\t\t\t[]string{\"1.1.1.1\", \"1.1.1.2\"},\n\t\t\t[]string{\"\", \"\"},\n\t\t\tmap[string]string{\n\t\t\t\t\"component\": \"foo\",\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]string{\"foo-0\", \"foo-1\"},\n\t\t\t[]string{\"foo-0\", \"foo-1\"},\n\t\t\t[]bool{true, true},\n\t\t\tfalse,\n\t\t\t[]v1.Node{},\n\t\t\t[]string{},\n\t\t\t[]*endpoint.Endpoint{},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"annotated Headless services return IPv4 endpoints with TTL for each selected Pod\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeClusterIP,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\tfalse,\n\t\t\tmap[string]string{\"component\": \"foo\"},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.HostnameKey: \"service.example.org\",\n\t\t\t\tannotations.TtlKey:      \"1\",\n\t\t\t},\n\t\t\tmap[string]string{},\n\t\t\tv1.ClusterIPNone,\n\t\t\t[]string{\"1.1.1.1\", \"1.1.1.2\"},\n\t\t\t[]string{\"\", \"\"},\n\t\t\tmap[string]string{\n\t\t\t\t\"component\": \"foo\",\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]string{\"foo-0\", \"foo-1\"},\n\t\t\t[]string{\"foo-0\", \"foo-1\"},\n\t\t\t[]bool{true, true},\n\t\t\tfalse,\n\t\t\t[]v1.Node{},\n\t\t\t[]string{},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo-0.service.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.1.1.1\"}, RecordTTL: endpoint.TTL(1)},\n\t\t\t\t{DNSName: \"foo-1.service.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.1.1.2\"}, RecordTTL: endpoint.TTL(1)},\n\t\t\t\t{DNSName: \"service.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.1.1.1\", \"1.1.1.2\"}, RecordTTL: endpoint.TTL(1)},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"annotated Headless services return IPv6 endpoints with TTL for each selected Pod\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeClusterIP,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\tfalse,\n\t\t\tmap[string]string{\"component\": \"foo\"},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.HostnameKey: \"service.example.org\",\n\t\t\t\tannotations.TtlKey:      \"1\",\n\t\t\t},\n\t\t\tmap[string]string{},\n\t\t\tv1.ClusterIPNone,\n\t\t\t[]string{\"2001:db8::1\", \"2001:db8::2\"},\n\t\t\t[]string{\"\", \"\"},\n\t\t\tmap[string]string{\n\t\t\t\t\"component\": \"foo\",\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]string{\"foo-0\", \"foo-1\"},\n\t\t\t[]string{\"foo-0\", \"foo-1\"},\n\t\t\t[]bool{true, true},\n\t\t\tfalse,\n\t\t\t[]v1.Node{},\n\t\t\t[]string{},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo-0.service.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:db8::1\"}, RecordTTL: endpoint.TTL(1)},\n\t\t\t\t{DNSName: \"foo-1.service.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:db8::2\"}, RecordTTL: endpoint.TTL(1)},\n\t\t\t\t{DNSName: \"service.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:db8::1\", \"2001:db8::2\"}, RecordTTL: endpoint.TTL(1)},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"annotated Headless services return endpoints for each selected Pod, which are in running state\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeClusterIP,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\tfalse,\n\t\t\tmap[string]string{\"component\": \"foo\"},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.HostnameKey: \"service.example.org\",\n\t\t\t},\n\t\t\tmap[string]string{},\n\t\t\tv1.ClusterIPNone,\n\t\t\t[]string{\"1.1.1.1\", \"1.1.1.2\"},\n\t\t\t[]string{\"\", \"\"},\n\t\t\tmap[string]string{\n\t\t\t\t\"component\": \"foo\",\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]string{\"foo-0\", \"foo-1\"},\n\t\t\t[]string{\"foo-0\", \"foo-1\"},\n\t\t\t[]bool{true, false},\n\t\t\tfalse,\n\t\t\t[]v1.Node{},\n\t\t\t[]string{},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo-0.service.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.1.1.1\"}},\n\t\t\t\t{DNSName: \"service.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.1.1.1\"}},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"annotated Headless services return endpoints for all Pod if publishNotReadyAddresses is set\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeClusterIP,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\tfalse,\n\t\t\tmap[string]string{\"component\": \"foo\"},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.HostnameKey: \"service.example.org\",\n\t\t\t},\n\t\t\tmap[string]string{},\n\t\t\tv1.ClusterIPNone,\n\t\t\t[]string{\"1.1.1.1\", \"1.1.1.2\"},\n\t\t\t[]string{\"\", \"\"},\n\t\t\tmap[string]string{\n\t\t\t\t\"component\": \"foo\",\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]string{\"foo-0\", \"foo-1\"},\n\t\t\t[]string{\"foo-0\", \"foo-1\"},\n\t\t\t[]bool{true, false},\n\t\t\ttrue,\n\t\t\t[]v1.Node{},\n\t\t\t[]string{},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo-0.service.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.1.1.1\"}},\n\t\t\t\t{DNSName: \"foo-1.service.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.1.1.2\"}},\n\t\t\t\t{DNSName: \"service.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.1.1.1\", \"1.1.1.2\"}},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"annotated Headless services return endpoints for pods missing hostname\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeClusterIP,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\tfalse,\n\t\t\tmap[string]string{\"component\": \"foo\"},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.HostnameKey: \"service.example.org\",\n\t\t\t},\n\t\t\tmap[string]string{},\n\t\t\tv1.ClusterIPNone,\n\t\t\t[]string{\"1.1.1.1\", \"1.1.1.2\"},\n\t\t\t[]string{\"\", \"\"},\n\t\t\tmap[string]string{\n\t\t\t\t\"component\": \"foo\",\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]string{\"foo-0\", \"foo-1\"},\n\t\t\t[]string{\"\", \"\"},\n\t\t\t[]bool{true, true},\n\t\t\tfalse,\n\t\t\t[]v1.Node{},\n\t\t\t[]string{},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"service.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.1.1.1\", \"1.1.1.2\"}},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"annotated Headless services return only a unique set of IPv4 targets\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeClusterIP,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\tfalse,\n\t\t\tmap[string]string{\"component\": \"foo\"},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.HostnameKey: \"service.example.org\",\n\t\t\t},\n\t\t\tmap[string]string{},\n\t\t\tv1.ClusterIPNone,\n\t\t\t[]string{\"1.1.1.1\", \"1.1.1.1\", \"1.1.1.2\"},\n\t\t\t[]string{\"\", \"\", \"\"},\n\t\t\tmap[string]string{\n\t\t\t\t\"component\": \"foo\",\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]string{\"foo-0\", \"foo-1\", \"foo-3\"},\n\t\t\t[]string{\"\", \"\", \"\"},\n\t\t\t[]bool{true, true, true},\n\t\t\tfalse,\n\t\t\t[]v1.Node{},\n\t\t\t[]string{},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"service.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.1.1.1\", \"1.1.1.2\"}},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"annotated Headless services return only a unique set of IPv6 targets\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeClusterIP,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\tfalse,\n\t\t\tmap[string]string{\"component\": \"foo\"},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.HostnameKey: \"service.example.org\",\n\t\t\t},\n\t\t\tmap[string]string{},\n\t\t\tv1.ClusterIPNone,\n\t\t\t[]string{\"2001:db8::1\", \"2001:db8::1\", \"2001:db8::2\"},\n\t\t\t[]string{\"\", \"\", \"\"},\n\t\t\tmap[string]string{\n\t\t\t\t\"component\": \"foo\",\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]string{\"foo-0\", \"foo-1\", \"foo-3\"},\n\t\t\t[]string{\"\", \"\", \"\"},\n\t\t\t[]bool{true, true, true},\n\t\t\tfalse,\n\t\t\t[]v1.Node{},\n\t\t\t[]string{},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"service.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:db8::1\", \"2001:db8::2\"}},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"annotated Headless services return IPv4 targets from pod annotation\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeClusterIP,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\tfalse,\n\t\t\tmap[string]string{\"component\": \"foo\"},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.HostnameKey: \"service.example.org\",\n\t\t\t},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.TargetKey: \"1.2.3.4\",\n\t\t\t},\n\t\t\tv1.ClusterIPNone,\n\t\t\t[]string{\"1.1.1.1\"},\n\t\t\t[]string{\"\"},\n\t\t\tmap[string]string{\n\t\t\t\t\"component\": \"foo\",\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]string{\"foo\"},\n\t\t\t[]string{\"\", \"\", \"\"},\n\t\t\t[]bool{true, true, true},\n\t\t\tfalse,\n\t\t\t[]v1.Node{},\n\t\t\t[]string{string(v1.ServiceTypeClusterIP)},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"service.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"annotated Headless services return IPv6 targets from pod annotation\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeClusterIP,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\tfalse,\n\t\t\tmap[string]string{\"component\": \"foo\"},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.HostnameKey: \"service.example.org\",\n\t\t\t},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.TargetKey: \"2001:db8::4\",\n\t\t\t},\n\t\t\tv1.ClusterIPNone,\n\t\t\t[]string{\"2001:db8::1\"},\n\t\t\t[]string{\"\"},\n\t\t\tmap[string]string{\n\t\t\t\t\"component\": \"foo\",\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]string{\"foo\"},\n\t\t\t[]string{\"\", \"\", \"\"},\n\t\t\t[]bool{true, true, true},\n\t\t\tfalse,\n\t\t\t[]v1.Node{},\n\t\t\t[]string{},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"service.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:db8::4\"}},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"annotated Headless services return IPv4 targets from node external IP if endpoints-type annotation is set\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeClusterIP,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\tfalse,\n\t\t\tmap[string]string{\"component\": \"foo\"},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.HostnameKey:      \"service.example.org\",\n\t\t\t\tannotations.EndpointsTypeKey: EndpointsTypeNodeExternalIP,\n\t\t\t},\n\t\t\tmap[string]string{},\n\t\t\tv1.ClusterIPNone,\n\t\t\t[]string{\"1.1.1.1\"},\n\t\t\t[]string{\"\"},\n\t\t\tmap[string]string{\n\t\t\t\t\"component\": \"foo\",\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]string{\"foo\"},\n\t\t\t[]string{\"\", \"\", \"\"},\n\t\t\t[]bool{true, true, true},\n\t\t\tfalse,\n\t\t\t[]v1.Node{\n\t\t\t\t{\n\t\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tType:    v1.NodeExternalIP,\n\t\t\t\t\t\t\t\tAddress: \"1.2.3.4\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"service.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"annotated Headless services return only external IPv6 targets from node IP if endpoints-type annotation is set and exposeInternalIPv6 flag is unset\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeClusterIP,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\tfalse,\n\t\t\tmap[string]string{\"component\": \"foo\"},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.HostnameKey:      \"service.example.org\",\n\t\t\t\tannotations.EndpointsTypeKey: EndpointsTypeNodeExternalIP,\n\t\t\t},\n\t\t\tmap[string]string{},\n\t\t\tv1.ClusterIPNone,\n\t\t\t[]string{\"2001:db8::1\"},\n\t\t\t[]string{\"\"},\n\t\t\tmap[string]string{\n\t\t\t\t\"component\": \"foo\",\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]string{\"foo\"},\n\t\t\t[]string{\"\", \"\", \"\"},\n\t\t\t[]bool{true, true, true},\n\t\t\tfalse,\n\t\t\t[]v1.Node{\n\t\t\t\t{\n\t\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tType:    v1.NodeInternalIP,\n\t\t\t\t\t\t\t\tAddress: \"2001:db8::4\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tType:    v1.NodeExternalIP,\n\t\t\t\t\t\t\t\tAddress: \"2001:db8::5\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"service.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:db8::5\"}},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"annotated Headless services return IPv6 targets from node external IP if endpoints-type annotation is set and exposeInternalIPv6 flag set\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeClusterIP,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\ttrue,\n\t\t\tmap[string]string{\"component\": \"foo\"},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.HostnameKey:      \"service.example.org\",\n\t\t\t\tannotations.EndpointsTypeKey: EndpointsTypeNodeExternalIP,\n\t\t\t},\n\t\t\tmap[string]string{},\n\t\t\tv1.ClusterIPNone,\n\t\t\t[]string{\"2001:db8::1\"},\n\t\t\t[]string{\"\"},\n\t\t\tmap[string]string{\n\t\t\t\t\"component\": \"foo\",\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]string{\"foo\"},\n\t\t\t[]string{\"\", \"\", \"\"},\n\t\t\t[]bool{true, true, true},\n\t\t\tfalse,\n\t\t\t[]v1.Node{\n\t\t\t\t{\n\t\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tType:    v1.NodeInternalIP,\n\t\t\t\t\t\t\t\tAddress: \"2001:db8::4\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"service.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:db8::4\"}},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"annotated Headless services return dual-stack targets from node external IP if endpoints-type annotation is set and exposeInternalIPv6 flag set\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeClusterIP,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\ttrue,\n\t\t\tmap[string]string{\"component\": \"foo\"},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.HostnameKey:      \"service.example.org\",\n\t\t\t\tannotations.EndpointsTypeKey: EndpointsTypeNodeExternalIP,\n\t\t\t},\n\t\t\tmap[string]string{},\n\t\t\tv1.ClusterIPNone,\n\t\t\t[]string{\"1.1.1.1\"},\n\t\t\t[]string{\"\"},\n\t\t\tmap[string]string{\n\t\t\t\t\"component\": \"foo\",\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]string{\"foo\"},\n\t\t\t[]string{\"\", \"\", \"\"},\n\t\t\t[]bool{true, true, true},\n\t\t\tfalse,\n\t\t\t[]v1.Node{\n\t\t\t\t{\n\t\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tType:    v1.NodeExternalIP,\n\t\t\t\t\t\t\t\tAddress: \"1.2.3.4\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tType:    v1.NodeInternalIP,\n\t\t\t\t\t\t\t\tAddress: \"2001:db8::4\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"service.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{DNSName: \"service.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:db8::4\"}},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"annotated Headless services return IPv4 targets from hostIP if endpoints-type annotation is set\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeClusterIP,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\tfalse,\n\t\t\tmap[string]string{\"component\": \"foo\"},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.HostnameKey:      \"service.example.org\",\n\t\t\t\tannotations.EndpointsTypeKey: EndpointsTypeHostIP,\n\t\t\t},\n\t\t\tmap[string]string{},\n\t\t\tv1.ClusterIPNone,\n\t\t\t[]string{\"1.1.1.1\"},\n\t\t\t[]string{\"1.2.3.4\"},\n\t\t\tmap[string]string{\n\t\t\t\t\"component\": \"foo\",\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]string{\"foo\"},\n\t\t\t[]string{\"\", \"\", \"\"},\n\t\t\t[]bool{true, true, true},\n\t\t\tfalse,\n\t\t\t[]v1.Node{},\n\t\t\t[]string{},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"service.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"annotated Headless services return IPv6 targets from hostIP if endpoints-type annotation is set\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeClusterIP,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\tfalse,\n\t\t\tmap[string]string{\"component\": \"foo\"},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.HostnameKey:      \"service.example.org\",\n\t\t\t\tannotations.EndpointsTypeKey: EndpointsTypeHostIP,\n\t\t\t},\n\t\t\tmap[string]string{},\n\t\t\tv1.ClusterIPNone,\n\t\t\t[]string{\"2001:db8::1\"},\n\t\t\t[]string{\"2001:db8::4\"},\n\t\t\tmap[string]string{\n\t\t\t\t\"component\": \"foo\",\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]string{\"foo\"},\n\t\t\t[]string{\"\", \"\", \"\"},\n\t\t\t[]bool{true, true, true},\n\t\t\tfalse,\n\t\t\t[]v1.Node{},\n\t\t\t[]string{},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"service.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:db8::4\"}},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"headless service with endpoints-type annotation is outside of serviceTypeFilter scope\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeClusterIP,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\tfalse,\n\t\t\tmap[string]string{\"component\": \"foo\"},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.HostnameKey:      \"service.example.org\",\n\t\t\t\tannotations.EndpointsTypeKey: EndpointsTypeNodeExternalIP,\n\t\t\t},\n\t\t\tmap[string]string{},\n\t\t\tv1.ClusterIPNone,\n\t\t\t[]string{\"2001:db8::1\"},\n\t\t\t[]string{\"2001:db8::4\"},\n\t\t\tmap[string]string{\n\t\t\t\t\"component\": \"foo\",\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]string{\"foo\"},\n\t\t\t[]string{\"\", \"\", \"\"},\n\t\t\t[]bool{true, true, true},\n\t\t\tfalse,\n\t\t\t[]v1.Node{\n\t\t\t\t{\n\t\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tType:    v1.NodeExternalIP,\n\t\t\t\t\t\t\t\tAddress: \"1.2.3.4\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tType:    v1.NodeInternalIP,\n\t\t\t\t\t\t\t\tAddress: \"10.0.10.12\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]string{string(v1.ServiceTypeClusterIP)},\n\t\t\t[]*endpoint.Endpoint{},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"headless service with endpoints-type annotation is in the scope of serviceTypeFilter\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeClusterIP,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\tfalse,\n\t\t\tmap[string]string{\"component\": \"foo\"},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.HostnameKey:      \"service.example.org\",\n\t\t\t\tannotations.EndpointsTypeKey: EndpointsTypeNodeExternalIP,\n\t\t\t},\n\t\t\tmap[string]string{},\n\t\t\tv1.ClusterIPNone,\n\t\t\t[]string{\"2001:db8::1\"},\n\t\t\t[]string{\"1.2.3.4\"},\n\t\t\tmap[string]string{\n\t\t\t\t\"component\": \"foo\",\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]string{\"foo\"},\n\t\t\t[]string{\"\", \"\", \"\"},\n\t\t\t[]bool{true, true, true},\n\t\t\tfalse,\n\t\t\t[]v1.Node{\n\t\t\t\t{\n\t\t\t\t\tStatus: v1.NodeStatus{\n\t\t\t\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tType:    v1.NodeExternalIP,\n\t\t\t\t\t\t\t\tAddress: \"1.2.3.4\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tType:    v1.NodeInternalIP,\n\t\t\t\t\t\t\t\tAddress: \"10.0.10.12\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t[]string{string(v1.ServiceTypeClusterIP), string(v1.ServiceTypeNodePort)},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"service.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t} {\n\n\t\tt.Run(tc.title, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\t// Create a Kubernetes testing client\n\t\t\tkubernetes := fake.NewClientset()\n\n\t\t\tservice := &v1.Service{\n\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\tType:                     tc.svcType,\n\t\t\t\t\tClusterIP:                tc.clusterIP,\n\t\t\t\t\tSelector:                 tc.selector,\n\t\t\t\t\tPublishNotReadyAddresses: tc.publishNotReadyAddresses,\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tNamespace:   tc.svcNamespace,\n\t\t\t\t\tName:        tc.svcName,\n\t\t\t\t\tLabels:      tc.labels,\n\t\t\t\t\tAnnotations: tc.svcAnnotations,\n\t\t\t\t},\n\t\t\t\tStatus: v1.ServiceStatus{},\n\t\t\t}\n\t\t\t_, err := kubernetes.CoreV1().Services(service.Namespace).Create(t.Context(), service, metav1.CreateOptions{})\n\t\t\trequire.NoError(t, err)\n\n\t\t\tvar endpointSliceEndpoints []discoveryv1.Endpoint\n\t\t\tfor i, podName := range tc.podnames {\n\t\t\t\tpod := &v1.Pod{\n\t\t\t\t\tSpec: v1.PodSpec{\n\t\t\t\t\t\tContainers: []v1.Container{},\n\t\t\t\t\t\tHostname:   tc.hostnames[i],\n\t\t\t\t\t},\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace:   tc.svcNamespace,\n\t\t\t\t\t\tName:        podName,\n\t\t\t\t\t\tLabels:      tc.labels,\n\t\t\t\t\t\tAnnotations: tc.podAnnotations,\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.PodStatus{\n\t\t\t\t\t\tPodIP:  tc.podIPs[i],\n\t\t\t\t\t\tHostIP: tc.hostIPs[i],\n\t\t\t\t\t},\n\t\t\t\t}\n\n\t\t\t\t_, err = kubernetes.CoreV1().Pods(tc.svcNamespace).Create(t.Context(), pod, metav1.CreateOptions{})\n\t\t\t\trequire.NoError(t, err)\n\n\t\t\t\tep := discoveryv1.Endpoint{\n\t\t\t\t\tAddresses: []string{tc.podIPs[i]},\n\t\t\t\t\tTargetRef: &v1.ObjectReference{\n\t\t\t\t\t\tAPIVersion: \"\",\n\t\t\t\t\t\tKind:       \"Pod\",\n\t\t\t\t\t\tName:       podName,\n\t\t\t\t\t},\n\t\t\t\t\tConditions: discoveryv1.EndpointConditions{\n\t\t\t\t\t\tReady: &tc.podsReady[i],\n\t\t\t\t\t},\n\t\t\t\t}\n\t\t\t\tendpointSliceEndpoints = append(endpointSliceEndpoints, ep)\n\t\t\t}\n\t\t\tendpointSliceLabels := maps.Clone(tc.labels)\n\t\t\tendpointSliceLabels[discoveryv1.LabelServiceName] = tc.svcName\n\t\t\tendpointSlice := &discoveryv1.EndpointSlice{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tNamespace: tc.svcNamespace,\n\t\t\t\t\tName:      tc.svcName,\n\t\t\t\t\tLabels:    endpointSliceLabels,\n\t\t\t\t},\n\t\t\t\tAddressType: discoveryv1.AddressTypeIPv4,\n\t\t\t\tEndpoints:   endpointSliceEndpoints,\n\t\t\t}\n\t\t\t_, err = kubernetes.DiscoveryV1().EndpointSlices(tc.svcNamespace).Create(t.Context(), endpointSlice, metav1.CreateOptions{})\n\t\t\trequire.NoError(t, err)\n\t\t\tfor _, node := range tc.nodes {\n\t\t\t\t_, err = kubernetes.CoreV1().Nodes().Create(t.Context(), &node, metav1.CreateOptions{})\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\t// Create our object under test and get the endpoints.\n\t\t\tclient, _ := NewServiceSource(t.Context(), kubernetes,\n\t\t\t\t&Config{\n\t\t\t\t\tFQDNTemplate:             tc.fqdnTemplate,\n\t\t\t\t\tServiceTypeFilter:        tc.serviceTypesFilter,\n\t\t\t\t\tCompatibility:            tc.compatibility,\n\t\t\t\t\tNamespace:                tc.targetNamespace,\n\t\t\t\t\tIgnoreHostnameAnnotation: tc.ignoreHostnameAnnotation,\n\t\t\t\t\tExposeInternalIPv6:       tc.exposeInternalIPv6,\n\t\t\t\t\tLabelFilter:              labels.Everything(),\n\t\t\t\t},\n\t\t\t)\n\t\t\trequire.NoError(t, err)\n\n\t\t\tendpoints, err := client.Endpoints(t.Context())\n\t\t\tif tc.expectError {\n\t\t\t\trequire.Error(t, err)\n\t\t\t} else {\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\t// Validate returned endpoints against desired endpoints.\n\t\t\tvalidateEndpoints(t, endpoints, tc.expected)\n\t\t})\n\t}\n}\n\nfunc TestMultipleServicesPointingToSameLoadBalancer(t *testing.T) {\n\tkubernetes := fake.NewClientset()\n\n\tservices := []*v1.Service{\n\t\t{\n\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\tName:      \"istio-ingressgateway\",\n\t\t\t\tNamespace: \"default\",\n\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\"app\":   \"istio-ingressgateway\",\n\t\t\t\t\t\"istio\": \"ingressgateway\",\n\t\t\t\t},\n\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"example.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\tType:                  v1.ServiceTypeLoadBalancer,\n\t\t\t\tClusterIP:             \"10.118.223.3\",\n\t\t\t\tClusterIPs:            []string{\"10.118.223.3\"},\n\t\t\t\tExternalTrafficPolicy: v1.ServiceExternalTrafficPolicyCluster,\n\t\t\t\tIPFamilies:            []v1.IPFamily{v1.IPv4Protocol},\n\t\t\t\tIPFamilyPolicy:        testutils.ToPtr(v1.IPFamilyPolicySingleStack),\n\t\t\t\tPorts: []v1.ServicePort{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:       \"http2\",\n\t\t\t\t\t\tPort:       80,\n\t\t\t\t\t\tProtocol:   v1.ProtocolTCP,\n\t\t\t\t\t\tTargetPort: intstr.FromInt32(8080),\n\t\t\t\t\t\tNodePort:   30127,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSelector: map[string]string{\n\t\t\t\t\t\"app\":   \"istio-ingressgateway\",\n\t\t\t\t\t\"istio\": \"ingressgateway\",\n\t\t\t\t},\n\t\t\t\tSessionAffinity: v1.ServiceAffinityNone,\n\t\t\t},\n\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\tIngress: []v1.LoadBalancerIngress{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tIP:     \"34.66.66.77\",\n\t\t\t\t\t\t\tIPMode: testutils.ToPtr(v1.LoadBalancerIPModeVIP),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\tName:      \"istio-ingressgatewayudp\",\n\t\t\t\tNamespace: \"default\",\n\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\"app\":   \"istio-ingressgatewayudp\",\n\t\t\t\t\t\"istio\": \"ingressgateway\",\n\t\t\t\t},\n\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"example.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\tType:                  v1.ServiceTypeLoadBalancer,\n\t\t\t\tClusterIP:             \"10.118.220.130\",\n\t\t\t\tClusterIPs:            []string{\"10.118.220.130\"},\n\t\t\t\tExternalTrafficPolicy: v1.ServiceExternalTrafficPolicyCluster,\n\t\t\t\tIPFamilies:            []v1.IPFamily{v1.IPv4Protocol},\n\t\t\t\tIPFamilyPolicy:        testutils.ToPtr(v1.IPFamilyPolicySingleStack),\n\t\t\t\tPorts: []v1.ServicePort{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:       \"upd-dns\",\n\t\t\t\t\t\tPort:       53,\n\t\t\t\t\t\tProtocol:   v1.ProtocolUDP,\n\t\t\t\t\t\tTargetPort: intstr.FromInt32(5353),\n\t\t\t\t\t\tNodePort:   30873,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSelector: map[string]string{\n\t\t\t\t\t\"app\":   \"istio-ingressgatewayudp\",\n\t\t\t\t\t\"istio\": \"ingressgateway\",\n\t\t\t\t},\n\t\t\t\tSessionAffinity: v1.ServiceAffinityNone,\n\t\t\t},\n\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\t\tIngress: []v1.LoadBalancerIngress{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tIP:     \"34.66.66.77\",\n\t\t\t\t\t\t\tIPMode: testutils.ToPtr(v1.LoadBalancerIPModeVIP),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tassert.NotNil(t, services)\n\n\tfor _, svc := range services {\n\t\t_, err := kubernetes.CoreV1().Services(svc.Namespace).Create(t.Context(), svc, metav1.CreateOptions{})\n\t\trequire.NoError(t, err)\n\t}\n\n\tsrc, err := NewServiceSource(t.Context(), kubernetes,\n\t\t&Config{\n\t\t\tNamespace:            v1.NamespaceAll,\n\t\t\tExcludeUnschedulable: true,\n\t\t\tLabelFilter:          labels.Everything(),\n\t\t},\n\t)\n\trequire.NoError(t, err)\n\tassert.NotNil(t, src)\n\n\tgot, err := src.Endpoints(t.Context())\n\trequire.NoError(t, err)\n\n\tvalidateEndpoints(t, got, []*endpoint.Endpoint{\n\t\tendpoint.NewEndpoint(\"example.org\", endpoint.RecordTypeA, \"34.66.66.77\").WithLabel(endpoint.ResourceLabelKey, \"service/default/istio-ingressgateway\"),\n\t})\n}\n\nfunc TestMultipleHeadlessServicesPointingToPodsOnTheSameNode(t *testing.T) {\n\tkubernetes := fake.NewClientset()\n\n\theadless := []*v1.Service{\n\t\t{\n\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\tName:      \"kafka\",\n\t\t\t\tNamespace: \"default\",\n\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\"app\": \"kafka\",\n\t\t\t\t},\n\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\tannotations.HostnameKey: \"example.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\tType:                  v1.ServiceTypeClusterIP,\n\t\t\t\tClusterIP:             v1.ClusterIPNone,\n\t\t\t\tClusterIPs:            []string{v1.ClusterIPNone},\n\t\t\t\tInternalTrafficPolicy: testutils.ToPtr(v1.ServiceInternalTrafficPolicyCluster),\n\t\t\t\tIPFamilies:            []v1.IPFamily{v1.IPv4Protocol},\n\t\t\t\tIPFamilyPolicy:        testutils.ToPtr(v1.IPFamilyPolicySingleStack),\n\t\t\t\tPorts: []v1.ServicePort{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:       \"web\",\n\t\t\t\t\t\tPort:       80,\n\t\t\t\t\t\tProtocol:   v1.ProtocolTCP,\n\t\t\t\t\t\tTargetPort: intstr.FromInt32(80),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSelector: map[string]string{\n\t\t\t\t\t\"app\": \"kafka\",\n\t\t\t\t},\n\t\t\t\tSessionAffinity: v1.ServiceAffinityNone,\n\t\t\t},\n\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\tName:      \"kafka-2\",\n\t\t\t\tNamespace: \"default\",\n\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\"app\": \"kafka\",\n\t\t\t\t},\n\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\tannotations.HostnameKey: \"example.org\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\tType:                  v1.ServiceTypeClusterIP,\n\t\t\t\tClusterIP:             v1.ClusterIPNone,\n\t\t\t\tClusterIPs:            []string{v1.ClusterIPNone},\n\t\t\t\tInternalTrafficPolicy: testutils.ToPtr(v1.ServiceInternalTrafficPolicyCluster),\n\t\t\t\tIPFamilies:            []v1.IPFamily{v1.IPv4Protocol},\n\t\t\t\tIPFamilyPolicy:        testutils.ToPtr(v1.IPFamilyPolicySingleStack),\n\t\t\t\tPorts: []v1.ServicePort{\n\t\t\t\t\t{\n\t\t\t\t\t\tName:       \"web\",\n\t\t\t\t\t\tPort:       80,\n\t\t\t\t\t\tProtocol:   v1.ProtocolTCP,\n\t\t\t\t\t\tTargetPort: intstr.FromInt32(80),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSelector: map[string]string{\n\t\t\t\t\t\"app\": \"kafka\",\n\t\t\t\t},\n\t\t\t\tSessionAffinity: v1.ServiceAffinityNone,\n\t\t\t},\n\t\t\tStatus: v1.ServiceStatus{\n\t\t\t\tLoadBalancer: v1.LoadBalancerStatus{},\n\t\t\t},\n\t\t},\n\t}\n\n\tassert.NotNil(t, headless)\n\n\tpods := []*v1.Pod{\n\t\t{\n\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\tName:      \"kafka-0\",\n\t\t\t\tNamespace: \"default\",\n\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\"app\":                                 \"kafka\",\n\t\t\t\t\tappsv1.PodIndexLabel:                  \"0\",\n\t\t\t\t\tappsv1.ControllerRevisionHashLabelKey: \"kafka-b8d79cdb6\",\n\t\t\t\t\tappsv1.StatefulSetPodNameLabel:        \"kafka-0\",\n\t\t\t\t},\n\t\t\t\tOwnerReferences: []metav1.OwnerReference{\n\t\t\t\t\t{\n\t\t\t\t\t\tAPIVersion: \"apps/v1\",\n\t\t\t\t\t\tKind:       \"StatefulSet\",\n\t\t\t\t\t\tName:       \"kafka\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tSpec: v1.PodSpec{\n\t\t\t\tHostname:  \"kafka-0\",\n\t\t\t\tSubdomain: \"kafka\",\n\t\t\t\tNodeName:  \"local-dev-worker\",\n\t\t\t\tContainers: []v1.Container{\n\t\t\t\t\t{\n\t\t\t\t\t\tName: \"nginx\",\n\t\t\t\t\t\tPorts: []v1.ContainerPort{\n\t\t\t\t\t\t\t{Name: \"web\", ContainerPort: 80, Protocol: v1.ProtocolTCP},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tStatus: v1.PodStatus{\n\t\t\t\tPhase:   v1.PodRunning,\n\t\t\t\tPodIP:   \"10.244.1.2\",\n\t\t\t\tPodIPs:  []v1.PodIP{{IP: \"10.244.1.2\"}},\n\t\t\t\tHostIP:  \"172.18.0.2\",\n\t\t\t\tHostIPs: []v1.HostIP{{IP: \"172.18.0.2\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\tName:      \"kafka-1\",\n\t\t\t\tNamespace: \"default\",\n\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\"app\":                                 \"kafka\",\n\t\t\t\t\tappsv1.PodIndexLabel:                  \"1\",\n\t\t\t\t\tappsv1.ControllerRevisionHashLabelKey: \"kafka-b8d79cdb6\",\n\t\t\t\t\tappsv1.StatefulSetPodNameLabel:        \"kafka-1\",\n\t\t\t\t},\n\t\t\t\tOwnerReferences: []metav1.OwnerReference{\n\t\t\t\t\t{\n\t\t\t\t\t\tAPIVersion: \"apps/v1\",\n\t\t\t\t\t\tKind:       \"StatefulSet\",\n\t\t\t\t\t\tName:       \"kafka\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tSpec: v1.PodSpec{\n\t\t\t\tHostname:  \"kafka-1\",\n\t\t\t\tSubdomain: \"kafka\",\n\t\t\t\tNodeName:  \"local-dev-worker\",\n\t\t\t\tContainers: []v1.Container{\n\t\t\t\t\t{\n\t\t\t\t\t\tName: \"nginx\",\n\t\t\t\t\t\tPorts: []v1.ContainerPort{\n\t\t\t\t\t\t\t{Name: \"web\", ContainerPort: 80, Protocol: v1.ProtocolTCP},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tStatus: v1.PodStatus{\n\t\t\t\tPhase:   v1.PodRunning,\n\t\t\t\tPodIP:   \"10.244.1.3\",\n\t\t\t\tPodIPs:  []v1.PodIP{{IP: \"10.244.1.3\"}},\n\t\t\t\tHostIP:  \"172.18.0.2\",\n\t\t\t\tHostIPs: []v1.HostIP{{IP: \"172.18.0.2\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\tName:      \"kafka-2\",\n\t\t\t\tNamespace: \"default\",\n\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\"app\":                                 \"kafka\",\n\t\t\t\t\tappsv1.PodIndexLabel:                  \"2\",\n\t\t\t\t\tappsv1.ControllerRevisionHashLabelKey: \"kafka-b8d79cdb6\",\n\t\t\t\t\tappsv1.StatefulSetPodNameLabel:        \"kafka-2\",\n\t\t\t\t},\n\t\t\t\tOwnerReferences: []metav1.OwnerReference{\n\t\t\t\t\t{\n\t\t\t\t\t\tAPIVersion: \"apps/v1\",\n\t\t\t\t\t\tKind:       \"StatefulSet\",\n\t\t\t\t\t\tName:       \"kafka\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tSpec: v1.PodSpec{\n\t\t\t\tHostname:  \"kafka-2\",\n\t\t\t\tSubdomain: \"kafka\",\n\t\t\t\tNodeName:  \"local-dev-worker\",\n\t\t\t\tContainers: []v1.Container{\n\t\t\t\t\t{\n\t\t\t\t\t\tName: \"nginx\",\n\t\t\t\t\t\tPorts: []v1.ContainerPort{\n\t\t\t\t\t\t\t{Name: \"web\", ContainerPort: 80, Protocol: v1.ProtocolTCP},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tStatus: v1.PodStatus{\n\t\t\t\tPhase:   v1.PodRunning,\n\t\t\t\tPodIP:   \"10.244.1.4\",\n\t\t\t\tPodIPs:  []v1.PodIP{{IP: \"10.244.1.4\"}},\n\t\t\t\tHostIP:  \"172.18.0.2\",\n\t\t\t\tHostIPs: []v1.HostIP{{IP: \"172.18.0.2\"}},\n\t\t\t},\n\t\t},\n\t}\n\tassert.Len(t, pods, 3)\n\n\tendpoints := []*discoveryv1.EndpointSlice{\n\t\t{\n\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\tName:      \"kafka-xhrc9\",\n\t\t\t\tNamespace: \"default\",\n\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\"app\":                        \"kafka\",\n\t\t\t\t\tdiscoveryv1.LabelServiceName: \"kafka\",\n\t\t\t\t\tdiscoveryv1.LabelManagedBy:   \"endpointslice-controller.k8s.io\",\n\t\t\t\t\tv1.IsHeadlessService:         \"\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tAddressType: discoveryv1.AddressTypeIPv4,\n\t\t\tEndpoints: []discoveryv1.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tAddresses: []string{\"10.244.1.2\"},\n\t\t\t\t\tHostname:  testutils.ToPtr(\"kafka-0\"),\n\t\t\t\t\tNodeName:  testutils.ToPtr(\"local-dev-worker\"),\n\t\t\t\t\tTargetRef: &v1.ObjectReference{\n\t\t\t\t\t\tKind:      \"Pod\",\n\t\t\t\t\t\tName:      \"kafka-0\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tConditions: discoveryv1.EndpointConditions{\n\t\t\t\t\t\tReady:       testutils.ToPtr(true),\n\t\t\t\t\t\tServing:     testutils.ToPtr(true),\n\t\t\t\t\t\tTerminating: testutils.ToPtr(false),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tAddresses: []string{\"10.244.1.3\"},\n\t\t\t\t\tHostname:  testutils.ToPtr(\"kafka-1\"),\n\t\t\t\t\tNodeName:  testutils.ToPtr(\"local-dev-worker\"),\n\t\t\t\t\tTargetRef: &v1.ObjectReference{\n\t\t\t\t\t\tKind:      \"Pod\",\n\t\t\t\t\t\tName:      \"kafka-1\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tConditions: discoveryv1.EndpointConditions{\n\t\t\t\t\t\tReady:       testutils.ToPtr(true),\n\t\t\t\t\t\tServing:     testutils.ToPtr(true),\n\t\t\t\t\t\tTerminating: testutils.ToPtr(false),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tAddresses: []string{\"10.244.1.4\"},\n\t\t\t\t\tHostname:  testutils.ToPtr(\"kafka-2\"),\n\t\t\t\t\tNodeName:  testutils.ToPtr(\"local-dev-worker\"),\n\t\t\t\t\tTargetRef: &v1.ObjectReference{\n\t\t\t\t\t\tKind:      \"Pod\",\n\t\t\t\t\t\tName:      \"kafka-2\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tConditions: discoveryv1.EndpointConditions{\n\t\t\t\t\t\tReady:       testutils.ToPtr(true),\n\t\t\t\t\t\tServing:     testutils.ToPtr(true),\n\t\t\t\t\t\tTerminating: testutils.ToPtr(false),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\tName:      \"kafka-2-svwsg\",\n\t\t\t\tNamespace: \"default\",\n\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\"app\":                        \"kafka\",\n\t\t\t\t\tdiscoveryv1.LabelServiceName: \"kafka-2\",\n\t\t\t\t\tdiscoveryv1.LabelManagedBy:   \"endpointslice-controller.k8s.io\",\n\t\t\t\t\tv1.IsHeadlessService:         \"\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tAddressType: discoveryv1.AddressTypeIPv4,\n\t\t\tEndpoints: []discoveryv1.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tAddresses: []string{\"10.244.1.2\"},\n\t\t\t\t\tHostname:  testutils.ToPtr(\"kafka-0\"),\n\t\t\t\t\tNodeName:  testutils.ToPtr(\"local-dev-worker\"),\n\t\t\t\t\tTargetRef: &v1.ObjectReference{\n\t\t\t\t\t\tKind:      \"Pod\",\n\t\t\t\t\t\tName:      \"kafka-0\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tConditions: discoveryv1.EndpointConditions{\n\t\t\t\t\t\tReady:       testutils.ToPtr(true),\n\t\t\t\t\t\tServing:     testutils.ToPtr(true),\n\t\t\t\t\t\tTerminating: testutils.ToPtr(false),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tAddresses: []string{\"10.244.1.3\"},\n\t\t\t\t\tHostname:  testutils.ToPtr(\"kafka-1\"),\n\t\t\t\t\tNodeName:  testutils.ToPtr(\"local-dev-worker\"),\n\t\t\t\t\tTargetRef: &v1.ObjectReference{\n\t\t\t\t\t\tKind:      \"Pod\",\n\t\t\t\t\t\tName:      \"kafka-1\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tConditions: discoveryv1.EndpointConditions{\n\t\t\t\t\t\tReady:       testutils.ToPtr(true),\n\t\t\t\t\t\tServing:     testutils.ToPtr(true),\n\t\t\t\t\t\tTerminating: testutils.ToPtr(false),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tAddresses: []string{\"10.244.1.4\"},\n\t\t\t\t\tHostname:  testutils.ToPtr(\"kafka-2\"),\n\t\t\t\t\tNodeName:  testutils.ToPtr(\"local-dev-worker\"),\n\t\t\t\t\tTargetRef: &v1.ObjectReference{\n\t\t\t\t\t\tKind:      \"Pod\",\n\t\t\t\t\t\tName:      \"kafka-2\",\n\t\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t\t},\n\t\t\t\t\tConditions: discoveryv1.EndpointConditions{\n\t\t\t\t\t\tReady:       testutils.ToPtr(true),\n\t\t\t\t\t\tServing:     testutils.ToPtr(true),\n\t\t\t\t\t\tTerminating: testutils.ToPtr(false),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, svc := range headless {\n\t\t_, err := kubernetes.CoreV1().Services(svc.Namespace).Create(t.Context(), svc, metav1.CreateOptions{})\n\t\trequire.NoError(t, err)\n\t}\n\n\tfor _, pod := range pods {\n\t\t_, err := kubernetes.CoreV1().Pods(pod.Namespace).Create(t.Context(), pod, metav1.CreateOptions{})\n\t\trequire.NoError(t, err)\n\t}\n\n\tfor _, ep := range endpoints {\n\t\t_, err := kubernetes.DiscoveryV1().EndpointSlices(ep.Namespace).Create(t.Context(), ep, metav1.CreateOptions{})\n\t\trequire.NoError(t, err)\n\t}\n\n\tsrc, err := NewServiceSource(t.Context(), kubernetes,\n\t\t&Config{\n\t\t\tNamespace:            v1.NamespaceAll,\n\t\t\tLabelFilter:          labels.Everything(),\n\t\t\tExcludeUnschedulable: true,\n\t\t},\n\t)\n\trequire.NoError(t, err)\n\tassert.NotNil(t, src)\n\n\tgot, err := src.Endpoints(t.Context())\n\trequire.NoError(t, err)\n\n\twant := []*endpoint.Endpoint{\n\t\t// TODO: root domain records should not be created. Address them in a follow-up PR.\n\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.244.1.2\", \"10.244.1.3\", \"10.244.1.4\"}},\n\t\t{DNSName: \"kafka-0.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.244.1.2\"}},\n\t\t{DNSName: \"kafka-1.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.244.1.3\"}},\n\t\t{DNSName: \"kafka-2.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.244.1.4\"}},\n\t}\n\n\tvalidateEndpoints(t, got, want)\n}\n\n// TestHeadlessServices tests that headless services generate the correct endpoints.\nfunc TestHeadlessServicesHostIP(t *testing.T) {\n\tt.Parallel()\n\n\tfor _, tc := range []struct {\n\t\ttitle                    string\n\t\ttargetNamespace          string\n\t\tsvcNamespace             string\n\t\tsvcName                  string\n\t\tsvcType                  v1.ServiceType\n\t\tcompatibility            string\n\t\tfqdnTemplate             string\n\t\tignoreHostnameAnnotation bool\n\t\tlabels                   map[string]string\n\t\tannotations              map[string]string\n\t\tclusterIP                string\n\t\thostIPs                  []string\n\t\tselector                 map[string]string\n\t\tlbs                      []string\n\t\tpodnames                 []string\n\t\thostnames                []string\n\t\tpodsReady                []bool\n\t\ttargetRefs               []*v1.ObjectReference\n\t\tpublishNotReadyAddresses bool\n\t\texpected                 []*endpoint.Endpoint\n\t\texpectError              bool\n\t}{\n\t\t{\n\t\t\t\"annotated Headless services return IPv4 endpoints for each selected Pod\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeClusterIP,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\tmap[string]string{\"component\": \"foo\"},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.HostnameKey: \"service.example.org\",\n\t\t\t},\n\t\t\tv1.ClusterIPNone,\n\t\t\t[]string{\"1.1.1.1\", \"1.1.1.2\"},\n\t\t\tmap[string]string{\n\t\t\t\t\"component\": \"foo\",\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]string{\"foo-0\", \"foo-1\"},\n\t\t\t[]string{\"foo-0\", \"foo-1\"},\n\t\t\t[]bool{true, true},\n\t\t\t[]*v1.ObjectReference{\n\t\t\t\t{APIVersion: \"\", Kind: \"Pod\", Name: \"foo-0\"},\n\t\t\t\t{APIVersion: \"\", Kind: \"Pod\", Name: \"foo-1\"},\n\t\t\t},\n\t\t\tfalse,\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo-0.service.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.1.1.1\"}},\n\t\t\t\t{DNSName: \"foo-1.service.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.1.1.2\"}},\n\t\t\t\t{DNSName: \"service.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.1.1.1\", \"1.1.1.2\"}},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"annotated Headless services return IPv6 endpoints for each selected Pod\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeClusterIP,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\tmap[string]string{\"component\": \"foo\"},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.HostnameKey: \"service.example.org\",\n\t\t\t},\n\t\t\tv1.ClusterIPNone,\n\t\t\t[]string{\"2001:db8::1\", \"2001:db8::2\"},\n\t\t\tmap[string]string{\n\t\t\t\t\"component\": \"foo\",\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]string{\"foo-0\", \"foo-1\"},\n\t\t\t[]string{\"foo-0\", \"foo-1\"},\n\t\t\t[]bool{true, true},\n\t\t\t[]*v1.ObjectReference{\n\t\t\t\t{APIVersion: \"\", Kind: \"Pod\", Name: \"foo-0\"},\n\t\t\t\t{APIVersion: \"\", Kind: \"Pod\", Name: \"foo-1\"},\n\t\t\t},\n\t\t\tfalse,\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo-0.service.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:db8::1\"}},\n\t\t\t\t{DNSName: \"foo-1.service.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:db8::2\"}},\n\t\t\t\t{DNSName: \"service.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:db8::1\", \"2001:db8::2\"}},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"hostname annotated Headless services are ignored\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeClusterIP,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\ttrue,\n\t\t\tmap[string]string{\"component\": \"foo\"},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.HostnameKey: \"service.example.org\",\n\t\t\t},\n\t\t\tv1.ClusterIPNone,\n\t\t\t[]string{\"1.1.1.1\", \"1.1.1.2\"},\n\t\t\tmap[string]string{\n\t\t\t\t\"component\": \"foo\",\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]string{\"foo-0\", \"foo-1\"},\n\t\t\t[]string{\"foo-0\", \"foo-1\"},\n\t\t\t[]bool{true, true},\n\t\t\t[]*v1.ObjectReference{\n\t\t\t\t{APIVersion: \"\", Kind: \"Pod\", Name: \"foo-0\"},\n\t\t\t\t{APIVersion: \"\", Kind: \"Pod\", Name: \"foo-1\"},\n\t\t\t},\n\t\t\tfalse,\n\t\t\t[]*endpoint.Endpoint{},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"annotated Headless services return IPv4 endpoints with TTL for each selected Pod\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeClusterIP,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\tmap[string]string{\"component\": \"foo\"},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.HostnameKey: \"service.example.org\",\n\t\t\t\tannotations.TtlKey:      \"1\",\n\t\t\t},\n\t\t\tv1.ClusterIPNone,\n\t\t\t[]string{\"1.1.1.1\", \"1.1.1.2\"},\n\t\t\tmap[string]string{\n\t\t\t\t\"component\": \"foo\",\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]string{\"foo-0\", \"foo-1\"},\n\t\t\t[]string{\"foo-0\", \"foo-1\"},\n\t\t\t[]bool{true, true},\n\t\t\t[]*v1.ObjectReference{\n\t\t\t\t{APIVersion: \"\", Kind: \"Pod\", Name: \"foo-0\"},\n\t\t\t\t{APIVersion: \"\", Kind: \"Pod\", Name: \"foo-1\"},\n\t\t\t},\n\t\t\tfalse,\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo-0.service.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.1.1.1\"}, RecordTTL: endpoint.TTL(1)},\n\t\t\t\t{DNSName: \"foo-1.service.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.1.1.2\"}, RecordTTL: endpoint.TTL(1)},\n\t\t\t\t{DNSName: \"service.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.1.1.1\", \"1.1.1.2\"}, RecordTTL: endpoint.TTL(1)},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"annotated Headless services return IPv6 endpoints with TTL for each selected Pod\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeClusterIP,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\tmap[string]string{\"component\": \"foo\"},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.HostnameKey: \"service.example.org\",\n\t\t\t\tannotations.TtlKey:      \"1\",\n\t\t\t},\n\t\t\tv1.ClusterIPNone,\n\t\t\t[]string{\"2001:db8::1\", \"2001:db8::2\"},\n\t\t\tmap[string]string{\n\t\t\t\t\"component\": \"foo\",\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]string{\"foo-0\", \"foo-1\"},\n\t\t\t[]string{\"foo-0\", \"foo-1\"},\n\t\t\t[]bool{true, true},\n\t\t\t[]*v1.ObjectReference{\n\t\t\t\t{APIVersion: \"\", Kind: \"Pod\", Name: \"foo-0\"},\n\t\t\t\t{APIVersion: \"\", Kind: \"Pod\", Name: \"foo-1\"},\n\t\t\t},\n\t\t\tfalse,\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo-0.service.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:db8::1\"}, RecordTTL: endpoint.TTL(1)},\n\t\t\t\t{DNSName: \"foo-1.service.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:db8::2\"}, RecordTTL: endpoint.TTL(1)},\n\t\t\t\t{DNSName: \"service.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:db8::1\", \"2001:db8::2\"}, RecordTTL: endpoint.TTL(1)},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"annotated Headless services return endpoints for each selected Pod, which are in running state\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeClusterIP,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\tmap[string]string{\"component\": \"foo\"},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.HostnameKey: \"service.example.org\",\n\t\t\t},\n\t\t\tv1.ClusterIPNone,\n\t\t\t[]string{\"1.1.1.1\", \"1.1.1.2\"},\n\t\t\tmap[string]string{\n\t\t\t\t\"component\": \"foo\",\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]string{\"foo-0\", \"foo-1\"},\n\t\t\t[]string{\"foo-0\", \"foo-1\"},\n\t\t\t[]bool{true, false},\n\t\t\t[]*v1.ObjectReference{\n\t\t\t\t{APIVersion: \"\", Kind: \"Pod\", Name: \"foo-0\"},\n\t\t\t\t{APIVersion: \"\", Kind: \"Pod\", Name: \"foo-1\"},\n\t\t\t},\n\t\t\tfalse,\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo-0.service.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.1.1.1\"}},\n\t\t\t\t{DNSName: \"service.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.1.1.1\"}},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"annotated Headless services return endpoints for all Pod if publishNotReadyAddresses is set\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeClusterIP,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\tmap[string]string{\"component\": \"foo\"},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.HostnameKey: \"service.example.org\",\n\t\t\t},\n\t\t\tv1.ClusterIPNone,\n\t\t\t[]string{\"1.1.1.1\", \"1.1.1.2\"},\n\t\t\tmap[string]string{\n\t\t\t\t\"component\": \"foo\",\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]string{\"foo-0\", \"foo-1\"},\n\t\t\t[]string{\"foo-0\", \"foo-1\"},\n\t\t\t[]bool{true, false},\n\t\t\t[]*v1.ObjectReference{\n\t\t\t\t{APIVersion: \"\", Kind: \"Pod\", Name: \"foo-0\"},\n\t\t\t\t{APIVersion: \"\", Kind: \"Pod\", Name: \"foo-1\"},\n\t\t\t},\n\t\t\ttrue,\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo-0.service.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.1.1.1\"}},\n\t\t\t\t{DNSName: \"foo-1.service.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.1.1.2\"}},\n\t\t\t\t{DNSName: \"service.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.1.1.1\", \"1.1.1.2\"}},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"annotated Headless services return IPv4 endpoints for pods missing hostname\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeClusterIP,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\tmap[string]string{\"component\": \"foo\"},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.HostnameKey: \"service.example.org\",\n\t\t\t},\n\t\t\tv1.ClusterIPNone,\n\t\t\t[]string{\"1.1.1.1\", \"1.1.1.2\"},\n\t\t\tmap[string]string{\n\t\t\t\t\"component\": \"foo\",\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]string{\"foo-0\", \"foo-1\"},\n\t\t\t[]string{\"\", \"\"},\n\t\t\t[]bool{true, true},\n\t\t\t[]*v1.ObjectReference{\n\t\t\t\t{APIVersion: \"\", Kind: \"Pod\", Name: \"foo-0\"},\n\t\t\t\t{APIVersion: \"\", Kind: \"Pod\", Name: \"foo-1\"},\n\t\t\t},\n\t\t\tfalse,\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"service.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.1.1.1\", \"1.1.1.2\"}},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"annotated Headless services return IPv6 endpoints for pods missing hostname\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeClusterIP,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\tmap[string]string{\"component\": \"foo\"},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.HostnameKey: \"service.example.org\",\n\t\t\t},\n\t\t\tv1.ClusterIPNone,\n\t\t\t[]string{\"2001:db8::1\", \"2001:db8::2\"},\n\t\t\tmap[string]string{\n\t\t\t\t\"component\": \"foo\",\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]string{\"foo-0\", \"foo-1\"},\n\t\t\t[]string{\"\", \"\"},\n\t\t\t[]bool{true, true},\n\t\t\t[]*v1.ObjectReference{\n\t\t\t\t{APIVersion: \"\", Kind: \"Pod\", Name: \"foo-0\"},\n\t\t\t\t{APIVersion: \"\", Kind: \"Pod\", Name: \"foo-1\"},\n\t\t\t},\n\t\t\tfalse,\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"service.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:db8::1\", \"2001:db8::2\"}},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"annotated Headless services without a targetRef has no endpoints\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeClusterIP,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\tmap[string]string{\"component\": \"foo\"},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.HostnameKey: \"service.example.org\",\n\t\t\t},\n\t\t\tv1.ClusterIPNone,\n\t\t\t[]string{\"1.1.1.1\"},\n\t\t\tmap[string]string{\n\t\t\t\t\"component\": \"foo\",\n\t\t\t},\n\t\t\t[]string{},\n\t\t\t[]string{\"foo-0\"},\n\t\t\t[]string{\"foo-0\"},\n\t\t\t[]bool{true, true},\n\t\t\t[]*v1.ObjectReference{nil},\n\t\t\tfalse,\n\t\t\t[]*endpoint.Endpoint{},\n\t\t\tfalse,\n\t\t},\n\t} {\n\n\t\tt.Run(tc.title, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\tkubernetes := fake.NewClientset()\n\n\t\t\tservice := &v1.Service{\n\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\tType:                     tc.svcType,\n\t\t\t\t\tClusterIP:                tc.clusterIP,\n\t\t\t\t\tSelector:                 tc.selector,\n\t\t\t\t\tPublishNotReadyAddresses: tc.publishNotReadyAddresses,\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tNamespace:   tc.svcNamespace,\n\t\t\t\t\tName:        tc.svcName,\n\t\t\t\t\tLabels:      tc.labels,\n\t\t\t\t\tAnnotations: tc.annotations,\n\t\t\t\t},\n\t\t\t\tStatus: v1.ServiceStatus{},\n\t\t\t}\n\t\t\t_, err := kubernetes.CoreV1().Services(service.Namespace).Create(t.Context(), service, metav1.CreateOptions{})\n\t\t\trequire.NoError(t, err)\n\n\t\t\tvar endpointsSlicesEndpoints []discoveryv1.Endpoint\n\t\t\tfor i, podname := range tc.podnames {\n\t\t\t\tpod := &v1.Pod{\n\t\t\t\t\tSpec: v1.PodSpec{\n\t\t\t\t\t\tContainers: []v1.Container{},\n\t\t\t\t\t\tHostname:   tc.hostnames[i],\n\t\t\t\t\t},\n\t\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\t\tNamespace:   tc.svcNamespace,\n\t\t\t\t\t\tName:        podname,\n\t\t\t\t\t\tLabels:      tc.labels,\n\t\t\t\t\t\tAnnotations: tc.annotations,\n\t\t\t\t\t},\n\t\t\t\t\tStatus: v1.PodStatus{\n\t\t\t\t\t\tHostIP: tc.hostIPs[i],\n\t\t\t\t\t},\n\t\t\t\t}\n\n\t\t\t\t_, err = kubernetes.CoreV1().Pods(tc.svcNamespace).Create(t.Context(), pod, metav1.CreateOptions{})\n\t\t\t\trequire.NoError(t, err)\n\n\t\t\t\tep := discoveryv1.Endpoint{\n\t\t\t\t\tAddresses: []string{\"4.3.2.1\"},\n\t\t\t\t\tTargetRef: tc.targetRefs[i],\n\t\t\t\t\tConditions: discoveryv1.EndpointConditions{\n\t\t\t\t\t\tReady: &tc.podsReady[i],\n\t\t\t\t\t},\n\t\t\t\t}\n\t\t\t\tendpointsSlicesEndpoints = append(endpointsSlicesEndpoints, ep)\n\t\t\t}\n\t\t\tendpointSliceLabels := maps.Clone(tc.labels)\n\t\t\tendpointSliceLabels[discoveryv1.LabelServiceName] = tc.svcName\n\t\t\tendpointSlice := &discoveryv1.EndpointSlice{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tNamespace: tc.svcNamespace,\n\t\t\t\t\tName:      tc.svcName,\n\t\t\t\t\tLabels:    endpointSliceLabels,\n\t\t\t\t},\n\t\t\t\tAddressType: discoveryv1.AddressTypeIPv4,\n\t\t\t\tEndpoints:   endpointsSlicesEndpoints,\n\t\t\t}\n\t\t\t_, err = kubernetes.DiscoveryV1().EndpointSlices(tc.svcNamespace).Create(t.Context(), endpointSlice, metav1.CreateOptions{})\n\t\t\trequire.NoError(t, err)\n\n\t\t\t// Create our object under test and get the endpoints.\n\t\t\tclient, _ := NewServiceSource(t.Context(), kubernetes,\n\t\t\t\t&Config{\n\t\t\t\t\tNamespace:                tc.targetNamespace,\n\t\t\t\t\tLabelFilter:              labels.Everything(),\n\t\t\t\t\tCompatibility:            tc.compatibility,\n\t\t\t\t\tFQDNTemplate:             tc.fqdnTemplate,\n\t\t\t\t\tIgnoreHostnameAnnotation: tc.ignoreHostnameAnnotation,\n\t\t\t\t\tExcludeUnschedulable:     true,\n\t\t\t\t\tPublishHostIP:            true,\n\t\t\t\t\tPublishInternal:          true,\n\t\t\t\t},\n\t\t\t)\n\t\t\trequire.NoError(t, err)\n\n\t\t\tendpoints, err := client.Endpoints(t.Context())\n\t\t\tif tc.expectError {\n\t\t\t\trequire.Error(t, err)\n\t\t\t} else {\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\t// Validate returned endpoints against desired endpoints.\n\t\t\tvalidateEndpoints(t, endpoints, tc.expected)\n\n\t\t\t// TODO; when all resources have the resource label, we could add this check to the validateEndpoints function.\n\t\t\tfor _, ep := range endpoints {\n\t\t\t\trequire.Contains(t, ep.Labels, endpoint.ResourceLabelKey)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestExternalServices tests that external services generate the correct endpoints.\nfunc TestExternalServices(t *testing.T) {\n\tt.Parallel()\n\n\tfor _, tc := range []struct {\n\t\ttitle                    string\n\t\ttargetNamespace          string\n\t\tsvcNamespace             string\n\t\tsvcName                  string\n\t\tsvcType                  v1.ServiceType\n\t\tcompatibility            string\n\t\tfqdnTemplate             string\n\t\tignoreHostnameAnnotation bool\n\t\tlabels                   map[string]string\n\t\tannotations              map[string]string\n\t\texternalName             string\n\t\texternalIPs              []string\n\t\tserviceTypeFilter        []string\n\t\texpected                 []*endpoint.Endpoint\n\t\texpectError              bool\n\t}{\n\t\t{\n\t\t\t\"external services return an A endpoint for the external name that is an IPv4 address\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeExternalName,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\tmap[string]string{\"component\": \"foo\"},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.HostnameKey: \"service.example.org\",\n\t\t\t},\n\t\t\t\"111.111.111.111\",\n\t\t\t[]string{},\n\t\t\t[]string{string(v1.ServiceTypeNodePort), string(v1.ServiceTypeExternalName)},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"service.example.org\", Targets: endpoint.Targets{\"111.111.111.111\"}, RecordType: endpoint.RecordTypeA},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"external services return an AAAA endpoint for the external name that is an IPv6 address\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeExternalName,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\tmap[string]string{\"component\": \"foo\"},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.HostnameKey: \"service.example.org\",\n\t\t\t},\n\t\t\t\"2001:db8::111\",\n\t\t\t[]string{},\n\t\t\t[]string{},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"service.example.org\", Targets: endpoint.Targets{\"2001:db8::111\"}, RecordType: endpoint.RecordTypeAAAA},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"external services return a CNAME endpoint for the external name that is a domain\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeExternalName,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\tmap[string]string{\"component\": \"foo\"},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.HostnameKey: \"service.example.org\",\n\t\t\t},\n\t\t\t\"remote.example.com\",\n\t\t\t[]string{},\n\t\t\t[]string{string(v1.ServiceTypeExternalName)},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"service.example.org\", Targets: endpoint.Targets{\"remote.example.com\"}, RecordType: endpoint.RecordTypeCNAME},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"annotated ExternalName service with externalIPs returns a single endpoint with multiple targets\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeExternalName,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\tmap[string]string{\"component\": \"foo\"},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.HostnameKey: \"service.example.org\",\n\t\t\t},\n\t\t\t\"service.example.org\",\n\t\t\t[]string{\"10.2.3.4\", \"11.2.3.4\"},\n\t\t\t[]string{},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"service.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.2.3.4\", \"11.2.3.4\"}},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"annotated ExternalName service with externalIPs of dualstack addresses returns 2 endpoints with multiple targets\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeExternalName,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\tmap[string]string{\"component\": \"foo\"},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.HostnameKey: \"service.example.org\",\n\t\t\t},\n\t\t\t\"service.example.org\",\n\t\t\t[]string{\"10.2.3.4\", \"11.2.3.4\", \"2001:db8::1\", \"2001:db8::2\"},\n\t\t\t[]string{string(v1.ServiceTypeNodePort), string(v1.ServiceTypeExternalName)},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"service.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.2.3.4\", \"11.2.3.4\"}},\n\t\t\t\t{DNSName: \"service.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:db8::1\", \"2001:db8::2\"}},\n\t\t\t},\n\t\t\tfalse,\n\t\t},\n\t\t{\n\t\t\t\"annotated ExternalName service with externalIPs of dualstack and excluded in serviceTypeFilter\",\n\t\t\t\"\",\n\t\t\t\"testing\",\n\t\t\t\"foo\",\n\t\t\tv1.ServiceTypeExternalName,\n\t\t\t\"\",\n\t\t\t\"\",\n\t\t\tfalse,\n\t\t\tmap[string]string{\"component\": \"foo\"},\n\t\t\tmap[string]string{\n\t\t\t\tannotations.HostnameKey: \"service.example.org\",\n\t\t\t},\n\t\t\t\"service.example.org\",\n\t\t\t[]string{\"10.2.3.4\", \"11.2.3.4\", \"2001:db8::1\", \"2001:db8::2\"},\n\t\t\t[]string{string(v1.ServiceTypeNodePort), string(v1.ServiceTypeClusterIP)},\n\t\t\t[]*endpoint.Endpoint{},\n\t\t\tfalse,\n\t\t},\n\t} {\n\n\t\tt.Run(tc.title, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\t// Create a Kubernetes testing client\n\t\t\tkubernetes := fake.NewClientset()\n\n\t\t\tservice := &v1.Service{\n\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\tType:         tc.svcType,\n\t\t\t\t\tExternalName: tc.externalName,\n\t\t\t\t\tExternalIPs:  tc.externalIPs,\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tNamespace:   tc.svcNamespace,\n\t\t\t\t\tName:        tc.svcName,\n\t\t\t\t\tLabels:      tc.labels,\n\t\t\t\t\tAnnotations: tc.annotations,\n\t\t\t\t},\n\t\t\t\tStatus: v1.ServiceStatus{},\n\t\t\t}\n\t\t\t_, err := kubernetes.CoreV1().Services(service.Namespace).Create(t.Context(), service, metav1.CreateOptions{})\n\t\t\trequire.NoError(t, err)\n\n\t\t\t// Create our object under test and get the endpoints.\n\t\t\tclient, _ := NewServiceSource(t.Context(), kubernetes,\n\t\t\t\t&Config{\n\t\t\t\t\tFQDNTemplate:             tc.fqdnTemplate,\n\t\t\t\t\tCompatibility:            tc.compatibility,\n\t\t\t\t\tServiceTypeFilter:        tc.serviceTypeFilter,\n\t\t\t\t\tNamespace:                tc.targetNamespace,\n\t\t\t\t\tIgnoreHostnameAnnotation: tc.ignoreHostnameAnnotation,\n\t\t\t\t\tExcludeUnschedulable:     true,\n\t\t\t\t\tLabelFilter:              labels.Everything(),\n\t\t\t\t},\n\t\t\t)\n\t\t\trequire.NoError(t, err)\n\n\t\t\tendpoints, err := client.Endpoints(t.Context())\n\t\t\tif tc.expectError {\n\t\t\t\trequire.Error(t, err)\n\t\t\t} else {\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\t// Validate returned endpoints against desired endpoints.\n\t\t\tvalidateEndpoints(t, endpoints, tc.expected)\n\n\t\t\t// TODO; when all resources have the resource label, we could add this check to the validateEndpoints function.\n\t\t\tfor _, ep := range endpoints {\n\t\t\t\trequire.Contains(t, ep.Labels, endpoint.ResourceLabelKey)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc BenchmarkServiceEndpoints(b *testing.B) {\n\tkubernetes := fake.NewClientset()\n\n\tservice := &v1.Service{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tNamespace: \"testing\",\n\t\t\tName:      \"foo\",\n\t\t\tAnnotations: map[string]string{\n\t\t\t\tannotations.HostnameKey: \"foo.example.org.\",\n\t\t\t},\n\t\t},\n\t\tStatus: v1.ServiceStatus{\n\t\t\tLoadBalancer: v1.LoadBalancerStatus{\n\t\t\t\tIngress: []v1.LoadBalancerIngress{\n\t\t\t\t\t{IP: \"1.2.3.4\"},\n\t\t\t\t\t{IP: \"8.8.8.8\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\t_, err := kubernetes.CoreV1().Services(service.Namespace).Create(b.Context(), service, metav1.CreateOptions{})\n\trequire.NoError(b, err)\n\n\tclient, err := NewServiceSource(b.Context(), kubernetes,\n\t\t&Config{\n\t\t\tNamespace:            v1.NamespaceAll,\n\t\t\tExcludeUnschedulable: true,\n\t\t\tLabelFilter:          labels.Everything(),\n\t\t},\n\t)\n\trequire.NoError(b, err)\n\n\tfor b.Loop() {\n\t\t_, err := client.Endpoints(b.Context())\n\t\trequire.NoError(b, err)\n\t}\n}\n\nfunc TestNewServiceSourceInformersEnabled(t *testing.T) {\n\ttests := []struct {\n\t\tname      string\n\t\tasserts   func(svc *serviceSource)\n\t\tsvcFilter []string\n\t}{\n\t\t{\n\t\t\tname: \"serviceTypeFilter is set to empty\",\n\t\t\tasserts: func(svc *serviceSource) {\n\t\t\t\tassert.NotNil(t, svc)\n\t\t\t\tassert.NotNil(t, svc.serviceTypeFilter)\n\t\t\t\tassert.False(t, svc.serviceTypeFilter.enabled)\n\t\t\t\tassert.NotNil(t, svc.nodeInformer)\n\t\t\t\tassert.NotNil(t, svc.serviceInformer)\n\t\t\t\tassert.NotNil(t, svc.endpointSlicesInformer)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:      \"serviceTypeFilter contains NodePort\",\n\t\t\tsvcFilter: []string{string(v1.ServiceTypeClusterIP)},\n\t\t\tasserts: func(svc *serviceSource) {\n\t\t\t\tassert.NotNil(t, svc)\n\t\t\t\tassert.NotNil(t, svc.serviceTypeFilter)\n\t\t\t\tassert.True(t, svc.serviceTypeFilter.enabled)\n\t\t\t\tassert.NotNil(t, svc.serviceInformer)\n\t\t\t\tassert.Nil(t, svc.nodeInformer)\n\t\t\t\tassert.NotNil(t, svc.endpointSlicesInformer)\n\t\t\t\tassert.NotNil(t, svc.podInformer)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:      \"serviceTypeFilter contains NodePort and ExternalName\",\n\t\t\tsvcFilter: []string{string(v1.ServiceTypeNodePort), string(v1.ServiceTypeExternalName)},\n\t\t\tasserts: func(svc *serviceSource) {\n\t\t\t\tassert.NotNil(t, svc)\n\t\t\t\tassert.NotNil(t, svc.serviceTypeFilter)\n\t\t\t\tassert.True(t, svc.serviceTypeFilter.enabled)\n\t\t\t\tassert.NotNil(t, svc.serviceInformer)\n\t\t\t\tassert.NotNil(t, svc.nodeInformer)\n\t\t\t\tassert.NotNil(t, svc.endpointSlicesInformer)\n\t\t\t\tassert.NotNil(t, svc.podInformer)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:      \"serviceTypeFilter contains ExternalName\",\n\t\t\tsvcFilter: []string{string(v1.ServiceTypeExternalName)},\n\t\t\tasserts: func(svc *serviceSource) {\n\t\t\t\tassert.NotNil(t, svc)\n\t\t\t\tassert.NotNil(t, svc.serviceTypeFilter)\n\t\t\t\tassert.True(t, svc.serviceTypeFilter.enabled)\n\t\t\t\tassert.NotNil(t, svc.serviceInformer)\n\t\t\t\tassert.Nil(t, svc.nodeInformer)\n\t\t\t\tassert.Nil(t, svc.endpointSlicesInformer)\n\t\t\t\tassert.Nil(t, svc.podInformer)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:      \"serviceTypeFilter contains LoadBalancer\",\n\t\t\tsvcFilter: []string{string(v1.ServiceTypeLoadBalancer)},\n\t\t\tasserts: func(svc *serviceSource) {\n\t\t\t\tassert.NotNil(t, svc)\n\t\t\t\tassert.NotNil(t, svc.serviceTypeFilter)\n\t\t\t\tassert.True(t, svc.serviceTypeFilter.enabled)\n\t\t\t\tassert.NotNil(t, svc.serviceInformer)\n\t\t\t\tassert.Nil(t, svc.nodeInformer)\n\t\t\t\tassert.Nil(t, svc.endpointSlicesInformer)\n\t\t\t\tassert.Nil(t, svc.podInformer)\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tsvc, err := NewServiceSource(t.Context(), fake.NewClientset(),\n\t\t\t\t&Config{\n\t\t\t\t\tNamespace:                      \"default\",\n\t\t\t\t\tServiceTypeFilter:              tc.svcFilter,\n\t\t\t\t\tAlwaysPublishNotReadyAddresses: true,\n\t\t\t\t\tLabelFilter:                    labels.Everything(),\n\t\t\t\t},\n\t\t\t)\n\t\t\trequire.NoError(t, err)\n\t\t\tsvcSrc, ok := svc.(*serviceSource)\n\t\t\tif !ok {\n\t\t\t\trequire.Fail(t, \"expected serviceSource\")\n\t\t\t}\n\t\t\ttc.asserts(svcSrc)\n\t\t})\n\t}\n}\n\nfunc TestNewServiceSourceWithServiceTypeFilters_Unsupported(t *testing.T) {\n\tserviceTypeFilter := []string{\"ClusterIP\", \"ServiceTypeNotExist\"}\n\n\tsvc, err := NewServiceSource(t.Context(), fake.NewClientset(),\n\t\t&Config{\n\t\t\tNamespace:         \"default\",\n\t\t\tServiceTypeFilter: serviceTypeFilter,\n\t\t\tLabelFilter:       labels.Everything(),\n\t\t},\n\t)\n\trequire.Errorf(t, err, \"unsupported service type filter: \\\"UnknownType\\\". Supported types are: [\\\"ClusterIP\\\" \\\"NodePort\\\" \\\"LoadBalancer\\\" \\\"ExternalName\\\"]\")\n\trequire.Nil(t, svc, \"ServiceSource should be nil when an unsupported service type is provided\")\n}\n\nfunc TestNewServiceTypes(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tfilter      []string\n\t\twantEnabled bool\n\t\twantTypes   map[v1.ServiceType]bool\n\t\twantErr     bool\n\t}{\n\t\t{\n\t\t\tname:        \"empty filter disables serviceTypes\",\n\t\t\tfilter:      []string{},\n\t\t\twantEnabled: false,\n\t\t\twantTypes:   nil,\n\t\t\twantErr:     false,\n\t\t},\n\t\t{\n\t\t\tname:        \"filter with empty string disables serviceTypes\",\n\t\t\tfilter:      []string{\"\"},\n\t\t\twantEnabled: false,\n\t\t\twantTypes:   nil,\n\t\t\twantErr:     false,\n\t\t},\n\t\t{\n\t\t\tname:        \"valid filter enables serviceTypes\",\n\t\t\tfilter:      []string{string(v1.ServiceTypeClusterIP), string(v1.ServiceTypeNodePort)},\n\t\t\twantEnabled: true,\n\t\t\twantTypes: map[v1.ServiceType]bool{\n\t\t\t\tv1.ServiceTypeClusterIP: true,\n\t\t\t\tv1.ServiceTypeNodePort:  true,\n\t\t\t},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname:        \"filter with unknown type returns error\",\n\t\t\tfilter:      []string{\"UnknownType\"},\n\t\t\twantEnabled: false,\n\t\t\twantTypes:   nil,\n\t\t\twantErr:     true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tst, err := newServiceTypesFilter(tt.filter)\n\t\t\tif tt.wantErr {\n\t\t\t\tassert.Error(t, err)\n\t\t\t\tassert.Nil(t, st)\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t\tassert.Equal(t, tt.wantEnabled, st.enabled)\n\t\t\t\tif tt.wantTypes != nil {\n\t\t\t\t\tassert.Equal(t, tt.wantTypes, st.types)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestFilterByServiceType_WithFixture(t *testing.T) {\n\ttests := []struct {\n\t\tname            string\n\t\tfilter          *serviceTypes\n\t\tcurrentServices []*v1.Service\n\t\texpected        int\n\t}{\n\t\t{\n\t\t\tname: \"all types of services with filter enabled for ServiceTypeNodePort and ServiceTypeClusterIP\",\n\t\t\tcurrentServices: createTestServicesByType(\"kube-system\", map[v1.ServiceType]int{\n\t\t\t\tv1.ServiceTypeLoadBalancer: 3,\n\t\t\t\tv1.ServiceTypeNodePort:     4,\n\t\t\t\tv1.ServiceTypeClusterIP:    5,\n\t\t\t\tv1.ServiceTypeExternalName: 2,\n\t\t\t}),\n\t\t\tfilter: &serviceTypes{\n\t\t\t\tenabled: true,\n\t\t\t\ttypes: map[v1.ServiceType]bool{\n\t\t\t\t\tv1.ServiceTypeNodePort:  true,\n\t\t\t\t\tv1.ServiceTypeClusterIP: true,\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: 4 + 5,\n\t\t},\n\t\t{\n\t\t\tname: \"all types of services with filter enabled for ServiceTypeLoadBalancer\",\n\t\t\tcurrentServices: createTestServicesByType(\"default\", map[v1.ServiceType]int{\n\t\t\t\tv1.ServiceTypeLoadBalancer: 3,\n\t\t\t\tv1.ServiceTypeNodePort:     4,\n\t\t\t\tv1.ServiceTypeClusterIP:    5,\n\t\t\t\tv1.ServiceTypeExternalName: 2,\n\t\t\t}),\n\t\t\tfilter: &serviceTypes{\n\t\t\t\tenabled: true,\n\t\t\t\ttypes: map[v1.ServiceType]bool{\n\t\t\t\t\tv1.ServiceTypeLoadBalancer: true,\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: 3,\n\t\t},\n\t\t{\n\t\t\tname: \"enabled for ServiceTypeLoadBalancer when not all types are present\",\n\t\t\tcurrentServices: createTestServicesByType(\"default\", map[v1.ServiceType]int{\n\t\t\t\tv1.ServiceTypeNodePort:     4,\n\t\t\t\tv1.ServiceTypeClusterIP:    5,\n\t\t\t\tv1.ServiceTypeExternalName: 2,\n\t\t\t}),\n\t\t\tfilter: &serviceTypes{\n\t\t\t\tenabled: true,\n\t\t\t\ttypes: map[v1.ServiceType]bool{\n\t\t\t\t\tv1.ServiceTypeLoadBalancer: true,\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: 0,\n\t\t},\n\t\t{\n\t\t\tname: \"filter disabled returns all services\",\n\t\t\tcurrentServices: createTestServicesByType(\"default\", map[v1.ServiceType]int{\n\t\t\t\tv1.ServiceTypeLoadBalancer: 3,\n\t\t\t\tv1.ServiceTypeNodePort:     4,\n\t\t\t\tv1.ServiceTypeClusterIP:    5,\n\t\t\t\tv1.ServiceTypeExternalName: 2,\n\t\t\t}),\n\t\t\tfilter: &serviceTypes{\n\t\t\t\tenabled: false,\n\t\t\t\ttypes:   map[v1.ServiceType]bool{},\n\t\t\t},\n\t\t\texpected: 14,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tsc := &serviceSource{serviceTypeFilter: tt.filter}\n\t\t\tassert.NotNil(t, sc)\n\t\t\tgot := sc.filterByServiceType(tt.currentServices)\n\t\t\tassert.Len(t, got, tt.expected)\n\t\t})\n\t}\n}\n\nfunc TestEndpointSlicesIndexer(t *testing.T) {\n\tctx := t.Context()\n\tfakeClient := fake.NewClientset()\n\n\t// Create a dummy EndpointSlice without the service name label\n\tendpointSlice := &discoveryv1.EndpointSlice{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:      \"test-slice\",\n\t\t\tNamespace: \"default\",\n\t\t\tLabels:    map[string]string{}, // No discoveryv1.LabelServiceName\n\t\t},\n\t}\n\t_, err := fakeClient.DiscoveryV1().EndpointSlices(\"default\").Create(ctx, endpointSlice, metav1.CreateOptions{})\n\trequire.NoError(t, err)\n\n\t// Should not error when creating the source\n\tsrc, err := NewServiceSource(ctx, fakeClient,\n\t\t&Config{\n\t\t\tFQDNTemplate:         \"{{.Name}}\",\n\t\t\tNamespace:            \"default\",\n\t\t\tExcludeUnschedulable: true,\n\t\t\tLabelFilter:          labels.Everything(),\n\t\t},\n\t)\n\trequire.NoError(t, err)\n\tss, ok := src.(*serviceSource)\n\trequire.True(t, ok)\n\n\t// Try to get EndpointSlices by index; should not panic or error, should return empty slice\n\tindexer := ss.endpointSlicesInformer.Informer().GetIndexer()\n\tslices, err := indexer.ByIndex(serviceNameIndexKey, \"default/foo\")\n\trequire.NoError(t, err)\n\trequire.Empty(t, slices)\n\n\t// Insert an object of the wrong type into the indexer; indexFunc should return an error and Add() should panic\n\trequire.PanicsWithError(t,\n\t\t\"unable to calculate an index entry for key \\\"default/not-an-endpointslice\\\" on index \\\"serviceName\\\": \"+\n\t\t\t\"expected *v1.EndpointSlice but got *v1.Service instead\",\n\t\tfunc() {\n\t\t\t_ = indexer.Add(&v1.Service{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"not-an-endpointslice\",\n\t\t\t\t\tNamespace: \"default\",\n\t\t\t\t},\n\t\t\t})\n\t\t})\n}\n\nfunc TestPodTransformerInServiceSource(t *testing.T) {\n\tctx := t.Context()\n\tfakeClient := fake.NewClientset()\n\n\tpod := &v1.Pod{\n\t\tSpec: v1.PodSpec{\n\t\t\tContainers: []v1.Container{{\n\t\t\t\tName: \"test\",\n\t\t\t}},\n\t\t\tHostname: \"test-hostname\",\n\t\t\tNodeName: \"test-node\",\n\t\t},\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tNamespace: \"test-ns\",\n\t\t\tName:      \"test-name\",\n\t\t\tLabels: map[string]string{\n\t\t\t\t\"label1\": \"value1\",\n\t\t\t\t\"label2\": \"value2\",\n\t\t\t\t\"label3\": \"value3\",\n\t\t\t},\n\t\t\tAnnotations: map[string]string{\n\t\t\t\t\"user-annotation\": \"value\",\n\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"test-hostname\",\n\t\t\t\t\"external-dns.alpha.kubernetes.io/random\":   \"value\",\n\t\t\t\t\"other/annotation\":                          \"value\",\n\t\t\t},\n\t\t\tUID: \"someuid\",\n\t\t},\n\t\tStatus: v1.PodStatus{\n\t\t\tPodIP:  \"127.0.0.1\",\n\t\t\tHostIP: \"127.0.0.2\",\n\t\t\tConditions: []v1.PodCondition{{\n\t\t\t\tType:   v1.PodReady,\n\t\t\t\tStatus: v1.ConditionTrue,\n\t\t\t}, {\n\t\t\t\tType:   v1.ContainersReady,\n\t\t\t\tStatus: v1.ConditionFalse,\n\t\t\t}},\n\t\t},\n\t}\n\n\t_, err := fakeClient.CoreV1().Pods(pod.Namespace).Create(t.Context(), pod, metav1.CreateOptions{})\n\trequire.NoError(t, err)\n\t// Should not error when creating the source\n\tsrc, err := NewServiceSource(ctx, fakeClient,\n\t\t&Config{\n\t\t\tFQDNTemplate: \"{{.Name}}\",\n\t\t\tLabelFilter:  labels.Everything(),\n\t\t},\n\t)\n\trequire.NoError(t, err)\n\tss, ok := src.(*serviceSource)\n\trequire.True(t, ok)\n\n\tretrieved, err := ss.podInformer.Lister().Pods(\"test-ns\").Get(\"test-name\")\n\trequire.NoError(t, err)\n\n\t// Metadata\n\tassert.Equal(t, \"test-name\", retrieved.Name)\n\tassert.Equal(t, \"test-ns\", retrieved.Namespace)\n\tassert.Empty(t, retrieved.UID)\n\tassert.Equal(t, map[string]string{\n\t\t\"label1\": \"value1\",\n\t\t\"label2\": \"value2\",\n\t\t\"label3\": \"value3\",\n\t}, retrieved.Labels)\n\t// Filtered\n\tassert.Equal(t, map[string]string{\n\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"test-hostname\",\n\t\t\"external-dns.alpha.kubernetes.io/random\":   \"value\",\n\t}, retrieved.Annotations)\n\n\t// Spec\n\tassert.Empty(t, retrieved.Spec.Containers)\n\tassert.Equal(t, \"test-hostname\", retrieved.Spec.Hostname)\n\tassert.Equal(t, \"test-node\", retrieved.Spec.NodeName)\n\n\t// Status\n\tassert.Empty(t, retrieved.Status.ContainerStatuses)\n\tassert.Empty(t, retrieved.Status.InitContainerStatuses)\n\tassert.Equal(t, \"127.0.0.2\", retrieved.Status.HostIP)\n\tassert.Empty(t, retrieved.Status.PodIP)\n\tassert.ElementsMatch(t, []v1.PodCondition{{\n\t\tType:   v1.PodReady,\n\t\tStatus: v1.ConditionTrue,\n\t}, {\n\t\tType:   v1.ContainersReady,\n\t\tStatus: v1.ConditionFalse,\n\t}}, retrieved.Status.Conditions)\n}\n\n// createTestServicesByType creates the requested number of services per type in the given namespace.\nfunc createTestServicesByType(ns string, typeCounts map[v1.ServiceType]int) []*v1.Service {\n\tvar services []*v1.Service\n\tidx := 0\n\tfor svcType, count := range typeCounts {\n\t\tfor range count {\n\t\t\tsvc := &v1.Service{\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      fmt.Sprintf(\"svc-%s-%d\", svcType, idx),\n\t\t\t\t\tNamespace: ns,\n\t\t\t\t},\n\t\t\t\tSpec: v1.ServiceSpec{\n\t\t\t\t\tType: svcType,\n\t\t\t\t},\n\t\t\t}\n\t\t\tif svcType == v1.ServiceTypeExternalName {\n\t\t\t\tsvc.Spec.ExternalName = fmt.Sprintf(\"external-%d.example.com\", idx)\n\t\t\t}\n\t\t\tservices = append(services, svc)\n\t\t\tidx++\n\t\t}\n\t}\n\t// Shuffle the resulting services to ensure randomness in the order.\n\trand.New(rand.NewSource(time.Now().UnixNano()))\n\trand.Shuffle(len(services), func(i, j int) {\n\t\tservices[i], services[j] = services[j], services[i]\n\t})\n\treturn services\n}\n\nfunc TestServiceTypes_isNodeInformerRequired(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tfilter   []string\n\t\trequired []v1.ServiceType\n\t\twant     bool\n\t}{\n\t\t{\n\t\t\tname:     \"NodePort required and filter is empty\",\n\t\t\tfilter:   []string{},\n\t\t\trequired: []v1.ServiceType{v1.ServiceTypeNodePort},\n\t\t\twant:     true,\n\t\t},\n\t\t{\n\t\t\tname:     \"NodePort type present\",\n\t\t\tfilter:   []string{string(v1.ServiceTypeNodePort)},\n\t\t\trequired: []v1.ServiceType{v1.ServiceTypeNodePort},\n\t\t\twant:     true,\n\t\t},\n\t\t{\n\t\t\tname:     \"NodePort type absent, filter enabled\",\n\t\t\tfilter:   []string{string(v1.ServiceTypeLoadBalancer)},\n\t\t\trequired: []v1.ServiceType{v1.ServiceTypeNodePort},\n\t\t\twant:     false,\n\t\t},\n\t\t{\n\t\t\tname:     \"NodePort and other filters present\",\n\t\t\tfilter:   []string{string(v1.ServiceTypeLoadBalancer), string(v1.ServiceTypeNodePort)},\n\t\t\trequired: []v1.ServiceType{v1.ServiceTypeNodePort},\n\t\t\twant:     true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tfilter, _ := newServiceTypesFilter(tt.filter)\n\t\t\tgot := filter.isRequired(tt.required...)\n\t\t\tassert.Equal(t, tt.want, got)\n\t\t})\n\t}\n}\n\nfunc TestServiceSource_AddEventHandler(t *testing.T) {\n\tvar fakeServiceInformer *informers.FakeServiceInformer\n\tvar fakeEdpInformer *informers.FakeEndpointSliceInformer\n\tvar fakeNodeInformer *informers.FakeNodeInformer\n\ttests := []struct {\n\t\tname    string\n\t\tfilter  []string\n\t\ttimes   int\n\t\tasserts func(t *testing.T)\n\t}{\n\t\t{\n\t\t\tname:   \"AddEventHandler should trigger all event handlers when empty filter is provided\",\n\t\t\tfilter: []string{},\n\t\t\ttimes:  2,\n\t\t\tasserts: func(t *testing.T) {\n\t\t\t\tfakeServiceInformer.AssertNumberOfCalls(t, \"Informer\", 1)\n\t\t\t\tfakeEdpInformer.AssertNumberOfCalls(t, \"Informer\", 1)\n\t\t\t\tfakeNodeInformer.AssertNumberOfCalls(t, \"Informer\", 0)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:   \"AddEventHandler should trigger only service event handler\",\n\t\t\tfilter: []string{string(v1.ServiceTypeExternalName), string(v1.ServiceTypeLoadBalancer)},\n\t\t\ttimes:  1,\n\t\t\tasserts: func(t *testing.T) {\n\t\t\t\tfakeServiceInformer.AssertNumberOfCalls(t, \"Informer\", 1)\n\t\t\t\tfakeEdpInformer.AssertNumberOfCalls(t, \"Informer\", 0)\n\t\t\t\tfakeNodeInformer.AssertNumberOfCalls(t, \"Informer\", 0)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:   \"AddEventHandler should configure only service event handler\",\n\t\t\tfilter: []string{string(v1.ServiceTypeExternalName), string(v1.ServiceTypeLoadBalancer), string(v1.ServiceTypeClusterIP)},\n\t\t\ttimes:  2,\n\t\t\tasserts: func(t *testing.T) {\n\t\t\t\tfakeServiceInformer.AssertNumberOfCalls(t, \"Informer\", 1)\n\t\t\t\tfakeEdpInformer.AssertNumberOfCalls(t, \"Informer\", 1)\n\t\t\t\tfakeNodeInformer.AssertNumberOfCalls(t, \"Informer\", 0)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:   \"AddEventHandler should configure all service event handlers\",\n\t\t\tfilter: []string{string(v1.ServiceTypeNodePort)},\n\t\t\ttimes:  2,\n\t\t\tasserts: func(t *testing.T) {\n\t\t\t\tfakeServiceInformer.AssertNumberOfCalls(t, \"Informer\", 1)\n\t\t\t\tfakeEdpInformer.AssertNumberOfCalls(t, \"Informer\", 1)\n\t\t\t\tfakeNodeInformer.AssertNumberOfCalls(t, \"Informer\", 0)\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tfakeServiceInformer = new(informers.FakeServiceInformer)\n\t\t\tinfSvc := testInformer{}\n\t\t\tfakeServiceInformer.On(\"Informer\").Return(&infSvc)\n\n\t\t\tfakeEdpInformer = new(informers.FakeEndpointSliceInformer)\n\t\t\tinfEdp := testInformer{}\n\t\t\tfakeEdpInformer.On(\"Informer\").Return(&infEdp)\n\n\t\t\tfakeNodeInformer = new(informers.FakeNodeInformer)\n\t\t\tinfNode := testInformer{}\n\t\t\tfakeNodeInformer.On(\"Informer\").Return(&infNode)\n\n\t\t\tfilter, _ := newServiceTypesFilter(tt.filter)\n\n\t\t\tsvcSource := &serviceSource{\n\t\t\t\tendpointSlicesInformer: fakeEdpInformer,\n\t\t\t\tserviceInformer:        fakeServiceInformer,\n\t\t\t\tnodeInformer:           fakeNodeInformer,\n\t\t\t\tserviceTypeFilter:      filter,\n\t\t\t\tlistenEndpointEvents:   true,\n\t\t\t}\n\n\t\t\tsvcSource.AddEventHandler(t.Context(), func() {})\n\n\t\t\tassert.Equal(t, tt.times, infSvc.times+infEdp.times+infNode.times)\n\n\t\t\ttt.asserts(t)\n\t\t})\n\t}\n}\n\n// Test helper functions created during extractHeadlessEndpoints refactoring\nfunc TestConvertToEndpointSlices(t *testing.T) {\n\tt.Run(\"converts valid EndpointSlices\", func(t *testing.T) {\n\t\tvalidSlice := &discoveryv1.EndpointSlice{\n\t\t\tObjectMeta:  metav1.ObjectMeta{Name: \"valid-slice\"},\n\t\t\tAddressType: discoveryv1.AddressTypeIPv4,\n\t\t}\n\n\t\trawObjects := []any{validSlice}\n\t\tresult := convertToEndpointSlices(rawObjects)\n\n\t\tassert.Len(t, result, 1)\n\t\tassert.Equal(t, \"valid-slice\", result[0].Name)\n\t})\n\n\tt.Run(\"skips invalid objects\", func(t *testing.T) {\n\t\tinvalidObject := \"not-an-endpoint-slice\"\n\t\tvalidSlice := &discoveryv1.EndpointSlice{\n\t\t\tObjectMeta:  metav1.ObjectMeta{Name: \"valid-slice\"},\n\t\t\tAddressType: discoveryv1.AddressTypeIPv4,\n\t\t}\n\n\t\trawObjects := []any{invalidObject, validSlice}\n\t\tresult := convertToEndpointSlices(rawObjects)\n\n\t\tassert.Len(t, result, 1)\n\t\tassert.Equal(t, \"valid-slice\", result[0].Name)\n\t})\n\n\tt.Run(\"handles empty input\", func(t *testing.T) {\n\t\tresult := convertToEndpointSlices([]any{})\n\t\tassert.Empty(t, result)\n\t})\n\n\tt.Run(\"handles all invalid objects\", func(t *testing.T) {\n\t\trawObjects := []any{\"invalid1\", 123, map[string]string{\"key\": \"value\"}}\n\t\tresult := convertToEndpointSlices(rawObjects)\n\t\tassert.Empty(t, result)\n\t})\n}\n\n// Test for processEndpointSlice: publishPodIPs true and pod == nil\n\nfunc TestProcessEndpointSlices_PublishPodIPsPodNil(t *testing.T) {\n\tsc := &serviceSource{}\n\n\tendpointSlice := &discoveryv1.EndpointSlice{\n\t\tObjectMeta:  metav1.ObjectMeta{Name: \"slice1\", Namespace: \"default\"},\n\t\tAddressType: discoveryv1.AddressTypeIPv4,\n\t\tEndpoints: []discoveryv1.Endpoint{\n\t\t\t{\n\t\t\t\tTargetRef:  &v1.ObjectReference{Kind: \"Pod\", Name: \"missing-pod\"},\n\t\t\t\tConditions: discoveryv1.EndpointConditions{Ready: testutils.ToPtr(true)},\n\t\t\t},\n\t\t},\n\t}\n\tpods := []*v1.Pod{} // No pods, so pod == nil\n\thostname := \"test.example.com\"\n\tendpointsType := \"IPv4\"\n\tpublishPodIPs := true\n\tpublishNotReadyAddresses := false\n\n\tresult := sc.processHeadlessEndpointsFromSlices(\n\t\tpods, []*discoveryv1.EndpointSlice{endpointSlice},\n\t\thostname, endpointsType, publishPodIPs, publishNotReadyAddresses)\n\tassert.Empty(t, result, \"No targets should be added when pod is nil and publishPodIPs is true\")\n}\n\n// Test for processEndpointSlice: publishPodIPs true and unsupported address type triggers log.Debugf skip\nfunc TestProcessEndpointSlices_PublishPodIPsUnsupportedAddressType(t *testing.T) {\n\tsc := &serviceSource{}\n\n\tendpointSlice := &discoveryv1.EndpointSlice{\n\t\tObjectMeta:  metav1.ObjectMeta{Name: \"slice2\", Namespace: \"default\"},\n\t\tAddressType: discoveryv1.AddressTypeFQDN, // unsupported type\n\t\tEndpoints: []discoveryv1.Endpoint{\n\t\t\t{\n\t\t\t\tTargetRef:  &v1.ObjectReference{Kind: \"Pod\", Name: \"some-pod\"},\n\t\t\t\tConditions: discoveryv1.EndpointConditions{Ready: testutils.ToPtr(true)},\n\t\t\t},\n\t\t},\n\t}\n\tpods := []*v1.Pod{{ObjectMeta: metav1.ObjectMeta{Name: \"some-pod\"}}}\n\thostname := \"test.example.com\"\n\tendpointsType := \"FQDN\"\n\tpublishPodIPs := true\n\tpublishNotReadyAddresses := false\n\n\tresult := sc.processHeadlessEndpointsFromSlices(\n\t\tpods, []*discoveryv1.EndpointSlice{endpointSlice},\n\t\thostname, endpointsType, publishPodIPs, publishNotReadyAddresses)\n\tassert.Empty(t, result, \"No targets should be added for unsupported address type when publishPodIPs is true\")\n}\n\n// Test for missing coverage: publishPodIPs false scenario\nfunc TestProcessEndpointSlices_PublishPodIPsFalse(t *testing.T) {\n\tsc := &serviceSource{}\n\n\tendpointSlice := &discoveryv1.EndpointSlice{\n\t\tObjectMeta:  metav1.ObjectMeta{Name: \"slice1\", Namespace: \"default\"},\n\t\tAddressType: discoveryv1.AddressTypeIPv4,\n\t\tEndpoints: []discoveryv1.Endpoint{\n\t\t\t{\n\t\t\t\tTargetRef:  &v1.ObjectReference{Kind: \"Pod\", Name: \"test-pod\"},\n\t\t\t\tConditions: discoveryv1.EndpointConditions{Ready: testutils.ToPtr(true)},\n\t\t\t\tAddresses:  []string{\"10.0.0.1\"},\n\t\t\t},\n\t\t},\n\t}\n\tpods := []*v1.Pod{{\n\t\tObjectMeta: metav1.ObjectMeta{Name: \"test-pod\"},\n\t\tStatus:     v1.PodStatus{PodIP: \"10.0.0.1\"},\n\t}}\n\thostname := \"test.example.com\"\n\tendpointsType := \"IPv4\"\n\tpublishPodIPs := false // This should allow processing\n\tpublishNotReadyAddresses := false\n\n\tresult := sc.processHeadlessEndpointsFromSlices(\n\t\tpods, []*discoveryv1.EndpointSlice{endpointSlice},\n\t\thostname, endpointsType, publishPodIPs, publishNotReadyAddresses)\n\tassert.NotEmpty(t, result, \"Targets should be added when publishPodIPs is false\")\n}\n\n// Test for missing coverage: not ready endpoints with publishNotReadyAddresses true\nfunc TestProcessEndpointSlices_NotReadyWithPublishNotReady(t *testing.T) {\n\tsc := &serviceSource{}\n\n\tendpointSlice := &discoveryv1.EndpointSlice{\n\t\tObjectMeta:  metav1.ObjectMeta{Name: \"slice1\", Namespace: \"default\"},\n\t\tAddressType: discoveryv1.AddressTypeIPv4,\n\t\tEndpoints: []discoveryv1.Endpoint{\n\t\t\t{\n\t\t\t\tTargetRef:  &v1.ObjectReference{Kind: \"Pod\", Name: \"test-pod\"},\n\t\t\t\tConditions: discoveryv1.EndpointConditions{Ready: testutils.ToPtr(false)}, // Not ready\n\t\t\t\tAddresses:  []string{\"10.0.0.1\"},\n\t\t\t},\n\t\t},\n\t}\n\tpods := []*v1.Pod{{\n\t\tObjectMeta: metav1.ObjectMeta{Name: \"test-pod\"},\n\t\tStatus:     v1.PodStatus{PodIP: \"10.0.0.1\"},\n\t}}\n\thostname := \"test.example.com\"\n\tendpointsType := \"IPv4\"\n\tpublishPodIPs := false\n\tpublishNotReadyAddresses := true // This should allow not-ready endpoints\n\n\tresult := sc.processHeadlessEndpointsFromSlices(\n\t\tpods, []*discoveryv1.EndpointSlice{endpointSlice},\n\t\thostname, endpointsType, publishPodIPs, publishNotReadyAddresses)\n\tassert.NotEmpty(t, result, \"Not ready endpoints should be processed when publishNotReadyAddresses is true\")\n}\n\n// Test getTargetsForDomain with empty ep.Addresses\nfunc TestGetTargetsForDomain_EmptyAddresses(t *testing.T) {\n\tsc := &serviceSource{}\n\tpod := &v1.Pod{\n\t\tObjectMeta: metav1.ObjectMeta{Name: \"test-pod\"},\n\t\tStatus:     v1.PodStatus{PodIP: \"10.0.0.1\"},\n\t}\n\tep := discoveryv1.Endpoint{\n\t\tAddresses: []string{}, // Empty addresses\n\t}\n\tendpointSlice := &discoveryv1.EndpointSlice{\n\t\tObjectMeta:  metav1.ObjectMeta{Name: \"test-slice\", Namespace: \"default\"},\n\t\tAddressType: discoveryv1.AddressTypeIPv4,\n\t}\n\tendpointsType := \"IPv4\"\n\theadlessDomain := \"test.example.com\"\n\n\ttargets := sc.getTargetsForDomain(pod, ep, endpointSlice, endpointsType, headlessDomain)\n\tassert.Empty(t, targets, \"Should return empty targets when ep.Addresses is empty\")\n}\n\n// Test getTargetsForDomain with EndpointsTypeHostIP\nfunc TestGetTargetsForDomain_HostIP(t *testing.T) {\n\tsc := &serviceSource{publishHostIP: false}\n\tpod := &v1.Pod{\n\t\tObjectMeta: metav1.ObjectMeta{Name: \"test-pod\"},\n\t\tStatus:     v1.PodStatus{HostIP: \"192.168.1.100\", PodIP: \"10.0.0.1\"},\n\t}\n\tep := discoveryv1.Endpoint{\n\t\tAddresses: []string{\"10.0.0.1\"},\n\t}\n\tendpointSlice := &discoveryv1.EndpointSlice{\n\t\tObjectMeta:  metav1.ObjectMeta{Name: \"test-slice\", Namespace: \"default\"},\n\t\tAddressType: discoveryv1.AddressTypeIPv4,\n\t}\n\tendpointsType := EndpointsTypeHostIP\n\theadlessDomain := \"test.example.com\"\n\n\ttargets := sc.getTargetsForDomain(pod, ep, endpointSlice, endpointsType, headlessDomain)\n\tassert.Contains(t, targets, \"192.168.1.100\", \"Should return HostIP when endpointsType is HostIP\")\n}\n\n// Test getTargetsForDomain with NodeExternalIP and nodeInformer\nfunc TestGetTargetsForDomain_NodeExternalIP(t *testing.T) {\n\t// Create a fake node informer with a node\n\tnode := &v1.Node{\n\t\tObjectMeta: metav1.ObjectMeta{Name: \"test-node\"},\n\t\tStatus: v1.NodeStatus{\n\t\t\tAddresses: []v1.NodeAddress{\n\t\t\t\t{Type: v1.NodeExternalIP, Address: \"203.0.113.10\"},\n\t\t\t\t{Type: v1.NodeInternalIP, Address: \"10.0.0.10\"},\n\t\t\t},\n\t\t},\n\t}\n\n\tclient := fake.NewClientset(node)\n\tkubeInformers := kubeinformers.NewSharedInformerFactory(client, 0)\n\tnodeInformer := kubeInformers.Core().V1().Nodes()\n\n\t// Add the node to the informer\n\tnodeInformer.Informer().GetStore().Add(node)\n\n\tsc := &serviceSource{\n\t\tnodeInformer: nodeInformer,\n\t}\n\n\tpod := &v1.Pod{\n\t\tObjectMeta: metav1.ObjectMeta{Name: \"test-pod\"},\n\t\tSpec:       v1.PodSpec{NodeName: \"test-node\"},\n\t\tStatus:     v1.PodStatus{PodIP: \"10.0.0.1\"},\n\t}\n\tep := discoveryv1.Endpoint{\n\t\tAddresses: []string{\"10.0.0.1\"},\n\t}\n\tendpointSlice := &discoveryv1.EndpointSlice{\n\t\tObjectMeta:  metav1.ObjectMeta{Name: \"test-slice\", Namespace: \"default\"},\n\t\tAddressType: discoveryv1.AddressTypeIPv4,\n\t}\n\tendpointsType := EndpointsTypeNodeExternalIP\n\theadlessDomain := \"test.example.com\"\n\n\ttargets := sc.getTargetsForDomain(pod, ep, endpointSlice, endpointsType, headlessDomain)\n\tassert.Contains(t, targets, \"203.0.113.10\", \"Should return NodeExternalIP\")\n}\n\nfunc TestFindPodForEndpoint(t *testing.T) {\n\tpods := []*v1.Pod{\n\t\t{\n\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"pod1\"},\n\t\t},\n\t\t{\n\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"pod2\"},\n\t\t},\n\t}\n\n\tt.Run(\"finds matching pod\", func(t *testing.T) {\n\t\tendpoint := discoveryv1.Endpoint{\n\t\t\tTargetRef: &v1.ObjectReference{\n\t\t\t\tKind: \"Pod\",\n\t\t\t\tName: \"pod1\",\n\t\t\t},\n\t\t}\n\n\t\tresult := findPodForEndpoint(endpoint, pods)\n\t\tassert.NotNil(t, result)\n\t\tassert.Equal(t, \"pod1\", result.Name)\n\t})\n\n\tt.Run(\"returns nil for nil TargetRef\", func(t *testing.T) {\n\t\tendpoint := discoveryv1.Endpoint{\n\t\t\tTargetRef: nil,\n\t\t}\n\n\t\tresult := findPodForEndpoint(endpoint, pods)\n\t\tassert.Nil(t, result)\n\t})\n\n\tt.Run(\"returns nil for non-Pod kind\", func(t *testing.T) {\n\t\tendpoint := discoveryv1.Endpoint{\n\t\t\tTargetRef: &v1.ObjectReference{\n\t\t\t\tKind: \"Service\",\n\t\t\t\tName: \"pod1\",\n\t\t\t},\n\t\t}\n\n\t\tresult := findPodForEndpoint(endpoint, pods)\n\t\tassert.Nil(t, result)\n\t})\n\n\tt.Run(\"returns nil for non-empty APIVersion\", func(t *testing.T) {\n\t\tendpoint := discoveryv1.Endpoint{\n\t\t\tTargetRef: &v1.ObjectReference{\n\t\t\t\tKind:       \"Pod\",\n\t\t\t\tName:       \"pod1\",\n\t\t\t\tAPIVersion: \"v1\",\n\t\t\t},\n\t\t}\n\n\t\tresult := findPodForEndpoint(endpoint, pods)\n\t\tassert.Nil(t, result)\n\t})\n\n\tt.Run(\"returns nil for non-existent pod\", func(t *testing.T) {\n\t\tendpoint := discoveryv1.Endpoint{\n\t\t\tTargetRef: &v1.ObjectReference{\n\t\t\t\tKind: \"Pod\",\n\t\t\t\tName: \"non-existent-pod\",\n\t\t\t},\n\t\t}\n\n\t\tresult := findPodForEndpoint(endpoint, pods)\n\t\tassert.Nil(t, result)\n\t})\n}\n\nfunc TestBuildHeadlessEndpoints(t *testing.T) {\n\tsvc := &v1.Service{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:      \"test-service\",\n\t\t\tNamespace: \"default\",\n\t\t},\n\t}\n\n\tt.Run(\"builds endpoints from targets\", func(t *testing.T) {\n\t\ttargetsByHeadlessDomainAndType := map[endpoint.EndpointKey]endpoint.Targets{\n\t\t\t{DNSName: \"test.example.com\", RecordType: endpoint.RecordTypeA}:    {\"1.2.3.4\", \"5.6.7.8\"},\n\t\t\t{DNSName: \"test.example.com\", RecordType: endpoint.RecordTypeAAAA}: {\"2001:db8::1\"},\n\t\t}\n\n\t\tresult := buildHeadlessEndpoints(svc, targetsByHeadlessDomainAndType, endpoint.TTL(0))\n\n\t\tassert.Len(t, result, 2)\n\n\t\t// Check A record\n\t\taRecord := findEndpointByType(result, endpoint.RecordTypeA)\n\t\tassert.NotNil(t, aRecord)\n\t\tassert.Equal(t, \"test.example.com\", aRecord.DNSName)\n\t\tassert.Contains(t, aRecord.Targets, \"1.2.3.4\")\n\t\tassert.Contains(t, aRecord.Targets, \"5.6.7.8\")\n\t\tassert.Equal(t, \"service/default/test-service\", aRecord.Labels[endpoint.ResourceLabelKey])\n\n\t\t// Check AAAA record\n\t\taaaaRecord := findEndpointByType(result, endpoint.RecordTypeAAAA)\n\t\tassert.NotNil(t, aaaaRecord)\n\t\tassert.Equal(t, \"test.example.com\", aaaaRecord.DNSName)\n\t\tassert.Contains(t, aaaaRecord.Targets, \"2001:db8::1\")\n\t})\n\n\tt.Run(\"deduplicates targets\", func(t *testing.T) {\n\t\ttargetsByHeadlessDomainAndType := map[endpoint.EndpointKey]endpoint.Targets{\n\t\t\t{DNSName: \"test.example.com\", RecordType: endpoint.RecordTypeA}: {\"1.2.3.4\", \"1.2.3.4\", \"5.6.7.8\"},\n\t\t}\n\n\t\tresult := buildHeadlessEndpoints(svc, targetsByHeadlessDomainAndType, endpoint.TTL(0))\n\n\t\tassert.Len(t, result, 1)\n\t\tassert.Len(t, result[0].Targets, 2)\n\t\tassert.Contains(t, result[0].Targets, \"1.2.3.4\")\n\t\tassert.Contains(t, result[0].Targets, \"5.6.7.8\")\n\t})\n\n\tt.Run(\"handles TTL configuration\", func(t *testing.T) {\n\t\ttargetsByHeadlessDomainAndType := map[endpoint.EndpointKey]endpoint.Targets{\n\t\t\t{DNSName: \"test.example.com\", RecordType: endpoint.RecordTypeA}: {\"1.2.3.4\"},\n\t\t}\n\n\t\tresult := buildHeadlessEndpoints(svc, targetsByHeadlessDomainAndType, endpoint.TTL(300))\n\n\t\tassert.Len(t, result, 1)\n\t\tassert.Equal(t, endpoint.TTL(300), result[0].RecordTTL)\n\t})\n\n\tt.Run(\"sorts endpoints deterministically\", func(t *testing.T) {\n\t\ttargetsByHeadlessDomainAndType := map[endpoint.EndpointKey]endpoint.Targets{\n\t\t\t{DNSName: \"z.example.com\", RecordType: endpoint.RecordTypeA}:    {\"1.2.3.4\"},\n\t\t\t{DNSName: \"a.example.com\", RecordType: endpoint.RecordTypeA}:    {\"5.6.7.8\"},\n\t\t\t{DNSName: \"a.example.com\", RecordType: endpoint.RecordTypeAAAA}: {\"2001:db8::1\"},\n\t\t}\n\n\t\tresult := buildHeadlessEndpoints(svc, targetsByHeadlessDomainAndType, endpoint.TTL(0))\n\n\t\tassert.Len(t, result, 3)\n\t\t// Should be sorted by DNSName first, then by RecordType\n\t\tassert.Equal(t, \"a.example.com\", result[0].DNSName)\n\t\tassert.Equal(t, endpoint.RecordTypeA, result[0].RecordType)\n\t\tassert.Equal(t, \"a.example.com\", result[1].DNSName)\n\t\tassert.Equal(t, endpoint.RecordTypeAAAA, result[1].RecordType)\n\t\tassert.Equal(t, \"z.example.com\", result[2].DNSName)\n\t})\n\n\tt.Run(\"handles empty targets\", func(t *testing.T) {\n\t\tresult := buildHeadlessEndpoints(svc, map[endpoint.EndpointKey]endpoint.Targets{}, endpoint.TTL(0))\n\t\tassert.Empty(t, result)\n\t})\n}\n\n// Test for missing coverage: pod with hostname creates additional headless domains\nfunc TestProcessEndpointSlices_PodWithHostname(t *testing.T) {\n\tsc := &serviceSource{}\n\n\tendpointSlice := &discoveryv1.EndpointSlice{\n\t\tObjectMeta:  metav1.ObjectMeta{Name: \"slice1\", Namespace: \"default\"},\n\t\tAddressType: discoveryv1.AddressTypeIPv4,\n\t\tEndpoints: []discoveryv1.Endpoint{\n\t\t\t{\n\t\t\t\tTargetRef:  &v1.ObjectReference{Kind: \"Pod\", Name: \"test-pod\"},\n\t\t\t\tConditions: discoveryv1.EndpointConditions{Ready: testutils.ToPtr(true)},\n\t\t\t\tAddresses:  []string{\"10.0.0.1\"},\n\t\t\t},\n\t\t},\n\t}\n\tpods := []*v1.Pod{{\n\t\tObjectMeta: metav1.ObjectMeta{Name: \"test-pod\"},\n\t\tSpec:       v1.PodSpec{Hostname: \"my-pod\"}, // Non-empty hostname\n\t\tStatus:     v1.PodStatus{PodIP: \"10.0.0.1\"},\n\t}}\n\thostname := \"test.example.com\"\n\tendpointsType := \"IPv4\"\n\tpublishPodIPs := false\n\tpublishNotReadyAddresses := false\n\n\tresult := sc.processHeadlessEndpointsFromSlices(\n\t\tpods, []*discoveryv1.EndpointSlice{endpointSlice},\n\t\thostname, endpointsType, publishPodIPs, publishNotReadyAddresses)\n\n\tassert.NotEmpty(t, result, \"Should create targets for pod with hostname\")\n\n\t// Check that both the base hostname and pod-specific hostname are created\n\tvar foundBaseHostname, foundPodHostname bool\n\tfor key := range result {\n\t\tif key.DNSName == \"test.example.com\" {\n\t\t\tfoundBaseHostname = true\n\t\t}\n\t\tif key.DNSName == \"my-pod.test.example.com\" {\n\t\t\tfoundPodHostname = true\n\t\t}\n\t}\n\n\tassert.True(t, foundBaseHostname, \"Should create endpoint for base hostname\")\n\tassert.True(t, foundPodHostname, \"Should create endpoint for pod-specific hostname when pod.Spec.Hostname is set\")\n}\n\nfunc TestProcessEndpoint_Service_RefObjectExist(t *testing.T) {\n\telements := []runtime.Object{\n\t\t&v1.Service{\n\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\tNamespace: \"01\",\n\t\t\t\tName:      \"foo\",\n\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\tannotations.HostnameKey: \"foo.example.com\",\n\t\t\t\t\tannotations.TargetKey:   \"1.2.3\",\n\t\t\t\t},\n\t\t\t\tUID: \"uid-1\",\n\t\t\t},\n\t\t},\n\t\t&v1.Service{\n\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\tNamespace: \"02\",\n\t\t\t\tName:      \"bar\",\n\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\tannotations.HostnameKey: \"bar.example.com\",\n\t\t\t\t\tannotations.TargetKey:   \"3.4.5\",\n\t\t\t\t},\n\t\t\t\tUID: \"uid-2\",\n\t\t\t},\n\t\t},\n\t}\n\n\tfakeClient := fake.NewClientset(elements...)\n\n\tclient, err := NewServiceSource(\n\t\tt.Context(),\n\t\tfakeClient,\n\t\t&Config{\n\t\t\tLabelFilter: labels.Everything(),\n\t\t},\n\t)\n\trequire.NoError(t, err)\n\n\tendpoints, err := client.Endpoints(t.Context())\n\trequire.NoError(t, err)\n\ttestutils.AssertEndpointsHaveRefObject(t, endpoints, types.Service, len(elements))\n}\n\nfunc TestNodesExternalTrafficPolicyTypeLocal(t *testing.T) {\n\tnow := metav1.Now()\n\n\tmakeNode := func(name, ip string) *v1.Node {\n\t\treturn &v1.Node{\n\t\t\tObjectMeta: metav1.ObjectMeta{Name: name},\n\t\t\tStatus:     v1.NodeStatus{Addresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: ip}}},\n\t\t}\n\t}\n\n\tmakePod := func(name, nodeName string, ready bool, deletionTimestamp *metav1.Time) *v1.Pod {\n\t\treadiness := v1.ConditionFalse\n\t\tif ready {\n\t\t\treadiness = v1.ConditionTrue\n\t\t}\n\t\treturn &v1.Pod{\n\t\t\tObjectMeta: metav1.ObjectMeta{Name: name, Namespace: \"testing\", DeletionTimestamp: deletionTimestamp},\n\t\t\tSpec:       v1.PodSpec{NodeName: nodeName},\n\t\t\tStatus: v1.PodStatus{\n\t\t\t\tPhase:      v1.PodRunning,\n\t\t\t\tConditions: []v1.PodCondition{{Type: v1.PodReady, Status: readiness}},\n\t\t\t},\n\t\t}\n\t}\n\n\tmakeResourceSource := func(t *testing.T, nodes []*v1.Node, pods []*v1.Pod) *serviceSource {\n\t\tt.Helper()\n\t\tclient := fake.NewClientset()\n\t\tinf := kubeinformers.NewSharedInformerFactory(client, 0)\n\t\tnodeInformer := inf.Core().V1().Nodes()\n\t\tpodInformer := inf.Core().V1().Pods()\n\t\tfor _, n := range nodes {\n\t\t\trequire.NoError(t, nodeInformer.Informer().GetStore().Add(n))\n\t\t}\n\t\tfor _, p := range pods {\n\t\t\trequire.NoError(t, podInformer.Informer().GetStore().Add(p))\n\t\t}\n\t\treturn &serviceSource{podInformer: podInformer, nodeInformer: nodeInformer}\n\t}\n\n\tsvc := &v1.Service{\n\t\tObjectMeta: metav1.ObjectMeta{Name: \"foo\", Namespace: \"testing\"},\n\t\tSpec: v1.ServiceSpec{\n\t\t\tType:                  v1.ServiceTypeNodePort,\n\t\t\tExternalTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeLocal,\n\t\t},\n\t}\n\n\t// nodeNames extracts node names from a result slice for easy assertion.\n\tnodeNames := func(nodes []*v1.Node) []string {\n\t\tnames := make([]string, len(nodes))\n\t\tfor i, n := range nodes {\n\t\t\tnames[i] = n.Name\n\t\t}\n\t\tsort.Strings(names)\n\t\treturn names\n\t}\n\n\tt.Run(\"best pod wins during rolling update regardless of iteration order\", func(t *testing.T) {\n\t\t// node1: not-ready replacement pod + ready existing pod (rolling update)\n\t\t// node2: single ready pod\n\t\t// The informer cache is a Go map so pod iteration order is randomised\n\t\t// each call. Over 100 iterations pod-0 will be processed before pod-1\n\t\t// roughly half the time, reliably triggering the bug if present.\n\t\tsc := makeResourceSource(t,\n\t\t\t[]*v1.Node{makeNode(\"node1\", \"54.10.11.1\"), makeNode(\"node2\", \"54.10.11.2\")},\n\t\t\t[]*v1.Pod{\n\t\t\t\tmakePod(\"pod-0\", \"node1\", false, nil), // not-ready replacement\n\t\t\t\tmakePod(\"pod-1\", \"node1\", true, nil),  // ready existing\n\t\t\t\tmakePod(\"pod-2\", \"node2\", true, nil),  // ready\n\t\t\t},\n\t\t)\n\t\tfor i := range 100 {\n\t\t\tgot := nodeNames(sc.nodesExternalTrafficPolicyTypeLocal(svc))\n\t\t\trequire.Containsf(t, got, \"node1\", \"iteration %d: node1 dropped despite having a ready pod\", i)\n\t\t\trequire.Containsf(t, got, \"node2\", \"iteration %d: node2 dropped despite having a ready pod\", i)\n\t\t}\n\t})\n\n\tt.Run(\"best pod state wins per node deterministically\", func(t *testing.T) {\n\t\t// Explicitly verify that each priority upgrade path lands in the correct tier,\n\t\t// independent of iteration order.\n\t\t//   node1: notReady pod + readyNonTerminating pod  → must appear in nodes (top tier)\n\t\t//   node2: notReady pod + readyTerminating pod     → must appear in nodesReady (mid tier)\n\t\t//   node3: notReady pod only                       → must appear in nodesRunning (low tier)\n\t\t// Because node1 is in the top tier the fallback switch returns only node1.\n\t\tsc := makeResourceSource(t,\n\t\t\t[]*v1.Node{\n\t\t\t\tmakeNode(\"node1\", \"54.10.11.1\"),\n\t\t\t\tmakeNode(\"node2\", \"54.10.11.2\"),\n\t\t\t\tmakeNode(\"node3\", \"54.10.11.3\"),\n\t\t\t},\n\t\t\t[]*v1.Pod{\n\t\t\t\tmakePod(\"pod-0\", \"node1\", false, nil), // notReady\n\t\t\t\tmakePod(\"pod-1\", \"node1\", true, nil),  // readyNonTerminating — upgrades node1\n\t\t\t\tmakePod(\"pod-2\", \"node2\", false, nil), // notReady\n\t\t\t\tmakePod(\"pod-3\", \"node2\", true, &now), // readyTerminating — upgrades node2\n\t\t\t\tmakePod(\"pod-4\", \"node3\", false, nil), // notReady only\n\t\t\t},\n\t\t)\n\t\tgot := nodeNames(sc.nodesExternalTrafficPolicyTypeLocal(svc))\n\t\tassert.Equal(t, []string{\"node1\"}, got, \"fallback should select the top-tier node only\")\n\t})\n\n\tt.Run(\"falls back to nodesReady when all ready pods are terminating\", func(t *testing.T) {\n\t\tsc := makeResourceSource(t,\n\t\t\t[]*v1.Node{makeNode(\"node1\", \"54.10.11.1\")},\n\t\t\t[]*v1.Pod{makePod(\"pod-0\", \"node1\", true, &now)}, // ready + terminating\n\t\t)\n\t\tgot := nodeNames(sc.nodesExternalTrafficPolicyTypeLocal(svc))\n\t\tassert.Equal(t, []string{\"node1\"}, got)\n\t})\n\n\tt.Run(\"falls back to nodesRunning when no pod is ready\", func(t *testing.T) {\n\t\tsc := makeResourceSource(t,\n\t\t\t[]*v1.Node{makeNode(\"node1\", \"54.10.11.1\")},\n\t\t\t[]*v1.Pod{makePod(\"pod-0\", \"node1\", false, nil)}, // running, not ready\n\t\t)\n\t\tgot := nodeNames(sc.nodesExternalTrafficPolicyTypeLocal(svc))\n\t\tassert.Equal(t, []string{\"node1\"}, got)\n\t})\n\n\tt.Run(\"skips pods that are not in Running phase\", func(t *testing.T) {\n\t\tpendingPod := &v1.Pod{\n\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"pod-pending\", Namespace: \"testing\"},\n\t\t\tSpec:       v1.PodSpec{NodeName: \"node1\"},\n\t\t\tStatus:     v1.PodStatus{Phase: v1.PodPending},\n\t\t}\n\t\tsc := makeResourceSource(t,\n\t\t\t[]*v1.Node{makeNode(\"node1\", \"54.10.11.1\")},\n\t\t\t[]*v1.Pod{pendingPod},\n\t\t)\n\t\tgot := nodeNames(sc.nodesExternalTrafficPolicyTypeLocal(svc))\n\t\tassert.Empty(t, got)\n\t})\n\n\tt.Run(\"skips pods whose node is not found in the informer\", func(t *testing.T) {\n\t\tsc := makeResourceSource(t,\n\t\t\t[]*v1.Node{}, // node1 not registered\n\t\t\t[]*v1.Pod{makePod(\"pod-0\", \"node1\", true, nil)},\n\t\t)\n\t\tgot := nodeNames(sc.nodesExternalTrafficPolicyTypeLocal(svc))\n\t\tassert.Empty(t, got)\n\t})\n\n\tt.Run(\"returns nil when pod list is empty\", func(t *testing.T) {\n\t\tsc := makeResourceSource(t,\n\t\t\t[]*v1.Node{makeNode(\"node1\", \"54.10.11.1\")},\n\t\t\t[]*v1.Pod{},\n\t\t)\n\t\tgot := sc.nodesExternalTrafficPolicyTypeLocal(svc)\n\t\tassert.Nil(t, got)\n\t})\n\n\tt.Run(\"returns only non-terminating ready nodes when mixed with terminating ready nodes\", func(t *testing.T) {\n\t\tsc := makeResourceSource(t,\n\t\t\t[]*v1.Node{makeNode(\"node1\", \"54.10.11.1\"), makeNode(\"node2\", \"54.10.11.2\")},\n\t\t\t[]*v1.Pod{\n\t\t\t\tmakePod(\"pod-0\", \"node1\", true, nil),  // ready, non-terminating\n\t\t\t\tmakePod(\"pod-1\", \"node2\", true, &now), // ready, terminating\n\t\t\t},\n\t\t)\n\t\tgot := nodeNames(sc.nodesExternalTrafficPolicyTypeLocal(svc))\n\t\tassert.Equal(t, []string{\"node1\"}, got)\n\t})\n}\n\n// Helper function to find endpoint by record type\nfunc findEndpointByType(endpoints []*endpoint.Endpoint, recordType string) *endpoint.Endpoint {\n\tfor _, ep := range endpoints {\n\t\tif ep.RecordType == recordType {\n\t\t\treturn ep\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "source/shared_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"reflect\"\n\t\"sort\"\n\t\"testing\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\nfunc sortEndpoints(endpoints []*endpoint.Endpoint) {\n\tfor _, ep := range endpoints {\n\t\tsort.Strings([]string(ep.Targets))\n\t}\n\tsort.Slice(endpoints, func(i, k int) bool {\n\t\t// Sort by DNSName, RecordType, and Targets\n\t\tei, ek := endpoints[i], endpoints[k]\n\t\tif ei.DNSName != ek.DNSName {\n\t\t\treturn ei.DNSName < ek.DNSName\n\t\t}\n\t\tif ei.RecordType != ek.RecordType {\n\t\t\treturn ei.RecordType < ek.RecordType\n\t\t}\n\t\t// Targets are sorted ahead of time.\n\t\tfor j, ti := range ei.Targets {\n\t\t\tif j >= len(ek.Targets) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tif tk := ek.Targets[j]; ti != tk {\n\t\t\t\treturn ti < tk\n\t\t\t}\n\t\t}\n\t\treturn false\n\t})\n}\n\nfunc validateEndpoints(t *testing.T, endpoints, expected []*endpoint.Endpoint) {\n\tt.Helper()\n\n\tif len(endpoints) != len(expected) {\n\t\tt.Fatalf(\"expected %d endpoints, got %d\", len(expected), len(endpoints))\n\t}\n\n\t// Make sure endpoints are sorted - validateEndpoint() depends on it.\n\tsortEndpoints(endpoints)\n\tsortEndpoints(expected)\n\n\tfor i := range endpoints {\n\t\tvalidateEndpoint(t, endpoints[i], expected[i])\n\t}\n}\n\nfunc validateEndpoint(t *testing.T, endpoint, expected *endpoint.Endpoint) {\n\tt.Helper()\n\n\tif endpoint.DNSName != expected.DNSName {\n\t\tt.Errorf(\"DNSName expected %q, got %q\", expected.DNSName, endpoint.DNSName)\n\t}\n\n\tif !endpoint.Targets.Same(expected.Targets) {\n\t\tt.Errorf(\"Targets expected %q, got %q\", expected.Targets, endpoint.Targets)\n\t}\n\n\tif endpoint.RecordTTL != expected.RecordTTL {\n\t\tt.Errorf(\"RecordTTL expected %v, got %v\", expected.RecordTTL, endpoint.RecordTTL)\n\t}\n\n\t// if a non-empty record type is expected, check that it matches.\n\tif endpoint.RecordType != expected.RecordType {\n\t\tt.Errorf(\"RecordType expected %q, got %q\", expected.RecordType, endpoint.RecordType)\n\t}\n\n\t// if non-empty labels are expected, check that they match.\n\tif expected.Labels != nil && !reflect.DeepEqual(endpoint.Labels, expected.Labels) {\n\t\tt.Errorf(\"Labels expected %s, got %s\", expected.Labels, endpoint.Labels)\n\t}\n\n\tif (len(expected.ProviderSpecific) != 0 || len(endpoint.ProviderSpecific) != 0) &&\n\t\t!reflect.DeepEqual(endpoint.ProviderSpecific, expected.ProviderSpecific) {\n\t\tt.Errorf(\"ProviderSpecific expected %s, got %s\", expected.ProviderSpecific, endpoint.ProviderSpecific)\n\t}\n\n\tif endpoint.SetIdentifier != expected.SetIdentifier {\n\t\tt.Errorf(\"SetIdentifier expected %q, got %q\", expected.SetIdentifier, endpoint.SetIdentifier)\n\t}\n}\n"
  },
  {
    "path": "source/skipper_routegroup.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/tls\"\n\t\"crypto/x509\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"strings\"\n\t\"sync\"\n\t\"text/template\"\n\t\"time\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\n\t\"sigs.k8s.io/external-dns/source/types\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n\t\"sigs.k8s.io/external-dns/source/fqdn\"\n)\n\nconst (\n\tdefaultIdleConnTimeout = 30 * time.Second\n\t// DefaultRoutegroupVersion is the default version for route groups.\n\tDefaultRoutegroupVersion     = \"zalando.org/v1\"\n\trouteGroupListResource       = \"/apis/%s/routegroups\"\n\trouteGroupNamespacedResource = \"/apis/%s/namespaces/%s/routegroups\"\n)\n\n// +externaldns:source:name=skipper-routegroup\n// +externaldns:source:category=Ingress Controllers\n// +externaldns:source:description=Creates DNS entries from Skipper RouteGroup resources\n// +externaldns:source:resources=RouteGroup.zalando.org\n// +externaldns:source:filters=annotation\n// +externaldns:source:namespace=all,single\n// +externaldns:source:fqdn-template=true\n// +externaldns:source:provider-specific=true\ntype routeGroupSource struct {\n\tcli                      routeGroupListClient\n\tapiServer                string\n\tnamespace                string\n\tapiEndpoint              string\n\tannotationFilter         string\n\tfqdnTemplate             *template.Template\n\tcombineFQDNAnnotation    bool\n\tignoreHostnameAnnotation bool\n}\n\n// for testing\ntype routeGroupListClient interface {\n\tgetRouteGroupList(string) (*routeGroupList, error)\n}\n\ntype routeGroupClient struct {\n\tmu        sync.Mutex\n\tquit      chan struct{}\n\tclient    *http.Client\n\ttoken     string\n\ttokenFile string\n}\n\nfunc newRouteGroupClient(token, tokenPath string, timeout time.Duration) *routeGroupClient {\n\tconst (\n\t\ttokenFile  = \"/var/run/secrets/kubernetes.io/serviceaccount/token\"\n\t\trootCAFile = \"/var/run/secrets/kubernetes.io/serviceaccount/ca.crt\"\n\t)\n\tif tokenPath != \"\" {\n\t\ttokenPath = tokenFile\n\t}\n\n\ttr := &http.Transport{\n\t\tDialContext: (&net.Dialer{\n\t\t\tTimeout:   timeout,\n\t\t\tKeepAlive: 30 * time.Second,\n\t\t\tDualStack: true,\n\t\t}).DialContext,\n\t\tTLSHandshakeTimeout:   3 * time.Second,\n\t\tResponseHeaderTimeout: timeout,\n\t\tIdleConnTimeout:       defaultIdleConnTimeout,\n\t\tMaxIdleConns:          5,\n\t\tMaxIdleConnsPerHost:   5,\n\t}\n\tcli := &routeGroupClient{\n\t\tclient: &http.Client{\n\t\t\tTransport: tr,\n\t\t},\n\t\tquit:      make(chan struct{}),\n\t\ttokenFile: tokenPath,\n\t\ttoken:     token,\n\t}\n\n\tgo func() {\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-time.After(tr.IdleConnTimeout):\n\t\t\t\ttr.CloseIdleConnections()\n\t\t\t\tcli.updateToken()\n\t\t\tcase <-cli.quit:\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}()\n\n\t// in cluster config, errors are treated as not running in cluster\n\tcli.updateToken()\n\n\t// cluster internal use custom CA to reach TLS endpoint\n\trootCA, err := os.ReadFile(rootCAFile)\n\tif err != nil {\n\t\treturn cli\n\t}\n\tcertPool := x509.NewCertPool()\n\tif !certPool.AppendCertsFromPEM(rootCA) {\n\t\treturn cli\n\t}\n\n\ttr.TLSClientConfig = &tls.Config{\n\t\tMinVersion: tls.VersionTLS12,\n\t\tRootCAs:    certPool,\n\t}\n\n\treturn cli\n}\n\nfunc (cli *routeGroupClient) updateToken() {\n\tif cli.tokenFile == \"\" {\n\t\treturn\n\t}\n\n\ttoken, err := os.ReadFile(cli.tokenFile)\n\tif err != nil {\n\t\tlog.Errorf(\"Failed to read token from file (%s): %v\", cli.tokenFile, err)\n\t\treturn\n\t}\n\n\tcli.mu.Lock()\n\tcli.token = string(token)\n\tcli.mu.Unlock()\n}\n\nfunc (cli *routeGroupClient) getToken() string {\n\tcli.mu.Lock()\n\tdefer cli.mu.Unlock()\n\treturn cli.token\n}\n\nfunc (cli *routeGroupClient) getRouteGroupList(url string) (*routeGroupList, error) {\n\tresp, err := cli.get(url)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"failed to get routegroup list from %s, got: %s\", url, resp.Status)\n\t}\n\n\tvar rgs routeGroupList\n\terr = json.NewDecoder(resp.Body).Decode(&rgs)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &rgs, nil\n}\n\nfunc (cli *routeGroupClient) get(url string) (*http.Response, error) {\n\treq, err := http.NewRequest(http.MethodGet, url, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn cli.do(req)\n}\n\nfunc (cli *routeGroupClient) do(req *http.Request) (*http.Response, error) {\n\tif tok := cli.getToken(); tok != \"\" && req.Header.Get(\"Authorization\") == \"\" {\n\t\treq.Header.Set(\"Authorization\", \"Bearer \"+tok)\n\t}\n\treturn cli.client.Do(req)\n}\n\n// NewRouteGroupSource creates a new routeGroupSource with the given config.\nfunc NewRouteGroupSource(cfg *Config, token, tokenPath, apiServerURL string) (Source, error) {\n\ttmpl, err := fqdn.ParseTemplate(cfg.FQDNTemplate)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\trouteGroupVersion := cfg.SkipperRouteGroupVersion\n\tif routeGroupVersion == \"\" {\n\t\trouteGroupVersion = DefaultRoutegroupVersion\n\t}\n\tcli := newRouteGroupClient(token, tokenPath, cfg.RequestTimeout)\n\n\tu, err := url.Parse(apiServerURL)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tapiServer := u.String()\n\t// strip port if well known port, because of TLS certificate match\n\tif u.Scheme == \"https\" && u.Port() == \"443\" {\n\t\t// correctly handle IPv6 addresses by keeping surrounding `[]`.\n\t\tapiServer = \"https://\" + strings.TrimSuffix(u.Host, \":443\")\n\t}\n\n\tapiEndpoint := apiServer + fmt.Sprintf(routeGroupListResource, routeGroupVersion)\n\tif cfg.Namespace != \"\" {\n\t\tapiEndpoint = apiServer + fmt.Sprintf(routeGroupNamespacedResource, routeGroupVersion, cfg.Namespace)\n\t}\n\n\treturn &routeGroupSource{\n\t\tcli:                      cli,\n\t\tapiServer:                apiServer,\n\t\tnamespace:                cfg.Namespace,\n\t\tapiEndpoint:              apiEndpoint,\n\t\tannotationFilter:         cfg.AnnotationFilter,\n\t\tfqdnTemplate:             tmpl,\n\t\tcombineFQDNAnnotation:    cfg.CombineFQDNAndAnnotation,\n\t\tignoreHostnameAnnotation: cfg.IgnoreHostnameAnnotation,\n\t}, nil\n}\n\n// AddEventHandler for routegroup is currently a no op, because we do not implement caching, yet.\nfunc (sc *routeGroupSource) AddEventHandler(_ context.Context, _ func()) {}\n\n// Endpoints returns endpoint objects for each host-target combination that should be processed.\n// Retrieves all routeGroup resources on all namespaces.\n// Logic is ported from ingress without fqdnTemplate\nfunc (sc *routeGroupSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) {\n\trgList, err := sc.cli.getRouteGroupList(sc.apiEndpoint)\n\tif err != nil {\n\t\tlog.Errorf(\"Failed to get RouteGroup list: %v\", err)\n\t\treturn nil, err\n\t}\n\n\tfiltered, err := annotations.Filter(rgList.Items, sc.annotationFilter)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tendpoints := []*endpoint.Endpoint{}\n\tfor _, rg := range filtered {\n\t\tif annotations.IsControllerMismatch(rg, types.SkipperRouteGroup) {\n\t\t\tcontinue\n\t\t}\n\n\t\teps := sc.endpointsFromRouteGroup(rg)\n\n\t\teps, err = fqdn.CombineWithTemplatedEndpoints(\n\t\t\teps,\n\t\t\tsc.fqdnTemplate,\n\t\t\tsc.combineFQDNAnnotation,\n\t\t\tfunc() ([]*endpoint.Endpoint, error) { return sc.endpointsFromTemplate(rg) },\n\t\t)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif endpoint.HasNoEmptyEndpoints(eps, types.OpenShiftRoute, rg) {\n\t\t\tcontinue\n\t\t}\n\n\t\tlog.Debugf(\"Endpoints generated from ingress: %s/%s: %v\", rg.Metadata.Namespace, rg.Metadata.Name, eps)\n\t\tendpoints = append(endpoints, eps...)\n\t}\n\n\treturn MergeEndpoints(endpoints), nil\n}\n\nfunc (sc *routeGroupSource) endpointsFromTemplate(rg *routeGroup) ([]*endpoint.Endpoint, error) {\n\t// Process the whole template string\n\tvar buf bytes.Buffer\n\terr := sc.fqdnTemplate.Execute(&buf, rg)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to apply template on routegroup %s/%s: %w\", rg.Metadata.Namespace, rg.Metadata.Name, err)\n\t}\n\n\thostnames := buf.String()\n\n\tresource := fmt.Sprintf(\"routegroup/%s/%s\", rg.Metadata.Namespace, rg.Metadata.Name)\n\n\t// error handled in endpointsFromRouteGroup(), otherwise duplicate log\n\tttl := annotations.TTLFromAnnotations(rg.Metadata.Annotations, resource)\n\n\ttargets := annotations.TargetsFromTargetAnnotation(rg.Metadata.Annotations)\n\n\tif len(targets) == 0 {\n\t\ttargets = targetsFromRouteGroupStatus(rg.Status)\n\t}\n\n\tproviderSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(rg.Metadata.Annotations)\n\n\tvar endpoints []*endpoint.Endpoint\n\t// splits the FQDN template and removes the trailing periods\n\thostnameList := strings.SplitSeq(strings.ReplaceAll(hostnames, \" \", \"\"), \",\")\n\tfor hostname := range hostnameList {\n\t\thostname = strings.TrimSuffix(hostname, \".\")\n\t\tendpoints = append(endpoints, endpoint.EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...)\n\t}\n\treturn endpoints, nil\n}\n\n// annotation logic ported from source/ingress.go without Spec.TLS part, because it's not supported in RouteGroup\nfunc (sc *routeGroupSource) endpointsFromRouteGroup(rg *routeGroup) []*endpoint.Endpoint {\n\tendpoints := []*endpoint.Endpoint{}\n\n\tresource := fmt.Sprintf(\"routegroup/%s/%s\", rg.Metadata.Namespace, rg.Metadata.Name)\n\n\tttl := annotations.TTLFromAnnotations(rg.Metadata.Annotations, resource)\n\n\ttargets := annotations.TargetsFromTargetAnnotation(rg.Metadata.Annotations)\n\tif len(targets) == 0 {\n\t\tfor _, lb := range rg.Status.LoadBalancer.RouteGroup {\n\t\t\tif lb.IP != \"\" {\n\t\t\t\ttargets = append(targets, lb.IP)\n\t\t\t}\n\t\t\tif lb.Hostname != \"\" {\n\t\t\t\ttargets = append(targets, lb.Hostname)\n\t\t\t}\n\t\t}\n\t}\n\n\tproviderSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(rg.Metadata.Annotations)\n\n\tfor _, src := range rg.Spec.Hosts {\n\t\tif src == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tendpoints = append(endpoints, endpoint.EndpointsForHostname(src, targets, ttl, providerSpecific, setIdentifier, resource)...)\n\t}\n\n\t// Skip endpoints if we do not want entries from annotations\n\tif !sc.ignoreHostnameAnnotation {\n\t\thostnameList := annotations.HostnamesFromAnnotations(rg.Metadata.Annotations)\n\t\tfor _, hostname := range hostnameList {\n\t\t\tendpoints = append(endpoints, endpoint.EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...)\n\t\t}\n\t}\n\treturn endpoints\n}\n\nfunc targetsFromRouteGroupStatus(status routeGroupStatus) endpoint.Targets {\n\tvar targets endpoint.Targets\n\n\tfor _, lb := range status.LoadBalancer.RouteGroup {\n\t\tif lb.IP != \"\" {\n\t\t\ttargets = append(targets, lb.IP)\n\t\t}\n\t\tif lb.Hostname != \"\" {\n\t\t\ttargets = append(targets, lb.Hostname)\n\t\t}\n\t}\n\n\treturn targets\n}\n\ntype routeGroupList struct {\n\tKind       string                 `json:\"kind\"`\n\tAPIVersion string                 `json:\"apiVersion\"`\n\tMetadata   routeGroupListMetadata `json:\"metadata\"`\n\tItems      []*routeGroup          `json:\"items\"`\n}\n\ntype routeGroupListMetadata struct {\n\tSelfLink        string `json:\"selfLink\"`\n\tResourceVersion string `json:\"resourceVersion\"`\n}\n\ntype routeGroup struct {\n\tMetadata metav1.ObjectMeta `json:\"metadata\"`\n\tSpec     routeGroupSpec    `json:\"spec\"`\n\tStatus   routeGroupStatus  `json:\"status\"`\n}\n\nfunc (rg *routeGroup) GetObjectMeta() metav1.Object {\n\treturn rg.Metadata.GetObjectMeta()\n}\n\ntype routeGroupSpec struct {\n\tHosts []string `json:\"hosts\"`\n}\n\ntype routeGroupStatus struct {\n\tLoadBalancer routeGroupLoadBalancerStatus `json:\"loadBalancer\"`\n}\n\ntype routeGroupLoadBalancerStatus struct {\n\tRouteGroup []routeGroupLoadBalancer `json:\"routeGroup\"`\n}\n\ntype routeGroupLoadBalancer struct {\n\tIP       string `json:\"ip,omitempty\"`\n\tHostname string `json:\"hostname,omitempty\"`\n}\n\nfunc (rg *routeGroup) GetAnnotations() map[string]string {\n\treturn rg.Metadata.Annotations\n}\n"
  },
  {
    "path": "source/skipper_routegroup_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"errors\"\n\t\"testing\"\n\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n\t\"sigs.k8s.io/external-dns/source/fqdn\"\n)\n\nfunc createTestRouteGroup(ns, name string, annotations map[string]string, hosts []string, destinations []routeGroupLoadBalancer) *routeGroup {\n\treturn &routeGroup{\n\t\tMetadata: metav1.ObjectMeta{\n\t\t\tNamespace:   ns,\n\t\t\tName:        name,\n\t\t\tAnnotations: annotations,\n\t\t},\n\t\tSpec: routeGroupSpec{\n\t\t\tHosts: hosts,\n\t\t},\n\t\tStatus: routeGroupStatus{\n\t\t\tLoadBalancer: routeGroupLoadBalancerStatus{\n\t\t\t\tRouteGroup: destinations,\n\t\t\t},\n\t\t},\n\t}\n}\n\nfunc TestEndpointsFromRouteGroups(t *testing.T) {\n\tt.Parallel()\n\n\tfor _, tt := range []struct {\n\t\tname   string\n\t\tsource *routeGroupSource\n\t\trg     *routeGroup\n\t\twant   []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\tname:   \"Empty routegroup should return empty endpoints\",\n\t\t\tsource: &routeGroupSource{},\n\t\t\trg:     &routeGroup{},\n\t\t\twant:   []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\tname:   \"Routegroup without hosts and destinations create no endpoints\",\n\t\t\tsource: &routeGroupSource{},\n\t\t\trg:     createTestRouteGroup(\"namespace1\", \"rg1\", nil, nil, nil),\n\t\t\twant:   []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\tname:   \"Routegroup without hosts create no endpoints\",\n\t\t\tsource: &routeGroupSource{},\n\t\t\trg: createTestRouteGroup(\"namespace1\", \"rg1\", nil, nil, []routeGroupLoadBalancer{\n\t\t\t\t{\n\t\t\t\t\tHostname: \"lb.example.org\",\n\t\t\t\t},\n\t\t\t}),\n\t\t\twant: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\tname:   \"Routegroup without destinations create no endpoints\",\n\t\t\tsource: &routeGroupSource{},\n\t\t\trg:     createTestRouteGroup(\"namespace1\", \"rg1\", nil, []string{\"rg1.k8s.example\"}, nil),\n\t\t\twant:   []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\tname:   \"Routegroup with hosts and destinations creates an endpoint\",\n\t\t\tsource: &routeGroupSource{},\n\t\t\trg: createTestRouteGroup(\"namespace1\", \"rg1\", nil, []string{\"rg1.k8s.example\"}, []routeGroupLoadBalancer{\n\t\t\t\t{\n\t\t\t\t\tHostname: \"lb.example.org\",\n\t\t\t\t},\n\t\t\t}),\n\t\t\twant: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"rg1.k8s.example\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets([]string{\"lb.example.org\"}),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:   \"Routegroup with hostname annotation, creates endpoints from the annotation \",\n\t\t\tsource: &routeGroupSource{},\n\t\t\trg: createTestRouteGroup(\n\t\t\t\t\"namespace1\",\n\t\t\t\t\"rg1\",\n\t\t\t\tmap[string]string{\n\t\t\t\t\tannotations.HostnameKey: \"my.example\",\n\t\t\t\t},\n\t\t\t\t[]string{\"rg1.k8s.example\"},\n\t\t\t\t[]routeGroupLoadBalancer{\n\t\t\t\t\t{\n\t\t\t\t\t\tHostname: \"lb.example.org\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t),\n\t\t\twant: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"rg1.k8s.example\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets([]string{\"lb.example.org\"}),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"my.example\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets([]string{\"lb.example.org\"}),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:   \"Routegroup with hosts and destinations and ignoreHostnameAnnotation creates endpoints but ignores annotation\",\n\t\t\tsource: &routeGroupSource{ignoreHostnameAnnotation: true},\n\t\t\trg: createTestRouteGroup(\n\t\t\t\t\"namespace1\",\n\t\t\t\t\"rg1\",\n\t\t\t\tmap[string]string{\n\t\t\t\t\tannotations.HostnameKey: \"my.example\",\n\t\t\t\t},\n\t\t\t\t[]string{\"rg1.k8s.example\"},\n\t\t\t\t[]routeGroupLoadBalancer{\n\t\t\t\t\t{\n\t\t\t\t\t\tHostname: \"lb.example.org\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t),\n\t\t\twant: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"rg1.k8s.example\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets([]string{\"lb.example.org\"}),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:   \"Routegroup with hosts and destinations and ttl creates an endpoint with ttl\",\n\t\t\tsource: &routeGroupSource{ignoreHostnameAnnotation: true},\n\t\t\trg: createTestRouteGroup(\n\t\t\t\t\"namespace1\",\n\t\t\t\t\"rg1\",\n\t\t\t\tmap[string]string{\n\t\t\t\t\tannotations.TtlKey: \"2189\",\n\t\t\t\t},\n\t\t\t\t[]string{\"rg1.k8s.example\"},\n\t\t\t\t[]routeGroupLoadBalancer{\n\t\t\t\t\t{\n\t\t\t\t\t\tHostname: \"lb.example.org\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t),\n\t\t\twant: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"rg1.k8s.example\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets([]string{\"lb.example.org\"}),\n\t\t\t\t\tRecordTTL:  endpoint.TTL(2189),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:   \"Routegroup with hosts and destination IP creates an endpoint\",\n\t\t\tsource: &routeGroupSource{},\n\t\t\trg: createTestRouteGroup(\n\t\t\t\t\"namespace1\",\n\t\t\t\t\"rg1\",\n\t\t\t\tnil,\n\t\t\t\t[]string{\"rg1.k8s.example\"},\n\t\t\t\t[]routeGroupLoadBalancer{\n\t\t\t\t\t{\n\t\t\t\t\t\tIP: \"1.5.1.4\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t),\n\t\t\twant: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"rg1.k8s.example\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets([]string{\"1.5.1.4\"}),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:   \"Routegroup with hosts and destination IPv6 creates an endpoint\",\n\t\t\tsource: &routeGroupSource{},\n\t\t\trg: createTestRouteGroup(\n\t\t\t\t\"namespace1\",\n\t\t\t\t\"rg1\",\n\t\t\t\tnil,\n\t\t\t\t[]string{\"rg1.k8s.example\"},\n\t\t\t\t[]routeGroupLoadBalancer{\n\t\t\t\t\t{\n\t\t\t\t\t\tIP: \"2001:DB8::1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t),\n\t\t\twant: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"rg1.k8s.example\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t\t\t\tTargets:    endpoint.Targets([]string{\"2001:DB8::1\"}),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:   \"Routegroup with hosts and mixed destinations creates endpoints\",\n\t\t\tsource: &routeGroupSource{},\n\t\t\trg: createTestRouteGroup(\n\t\t\t\t\"namespace1\",\n\t\t\t\t\"rg1\",\n\t\t\t\tnil,\n\t\t\t\t[]string{\"rg1.k8s.example\"},\n\t\t\t\t[]routeGroupLoadBalancer{\n\t\t\t\t\t{\n\t\t\t\t\t\tHostname: \"lb.example.org\",\n\t\t\t\t\t\tIP:       \"1.5.1.4\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t),\n\t\t\twant: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"rg1.k8s.example\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets([]string{\"1.5.1.4\"}),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"rg1.k8s.example\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets([]string{\"lb.example.org\"}),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:   \"Routegroup with hosts and mixed destinations (IPv6) creates endpoints\",\n\t\t\tsource: &routeGroupSource{},\n\t\t\trg: createTestRouteGroup(\n\t\t\t\t\"namespace1\",\n\t\t\t\t\"rg1\",\n\t\t\t\tnil,\n\t\t\t\t[]string{\"rg1.k8s.example\"},\n\t\t\t\t[]routeGroupLoadBalancer{\n\t\t\t\t\t{\n\t\t\t\t\t\tHostname: \"lb.example.org\",\n\t\t\t\t\t\tIP:       \"2001:DB8::1\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t),\n\t\t\twant: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"rg1.k8s.example\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeAAAA,\n\t\t\t\t\tTargets:    endpoint.Targets([]string{\"2001:DB8::1\"}),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"rg1.k8s.example\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets([]string{\"lb.example.org\"}),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:   \"Routegroup with provider-specific annotation creates endpoint with provider-specific property\",\n\t\t\tsource: &routeGroupSource{},\n\t\t\trg: createTestRouteGroup(\n\t\t\t\t\"namespace1\",\n\t\t\t\t\"rg1\",\n\t\t\t\tmap[string]string{\n\t\t\t\t\tannotations.AWSPrefix + \"weight\": \"10\",\n\t\t\t\t},\n\t\t\t\t[]string{\"rg1.k8s.example\"},\n\t\t\t\t[]routeGroupLoadBalancer{\n\t\t\t\t\t{\n\t\t\t\t\t\tHostname: \"lb.example.org\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t),\n\t\t\twant: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"rg1.k8s.example\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets([]string{\"lb.example.org\"}),\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{Name: \"aws/weight\", Value: \"10\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := tt.source.endpointsFromRouteGroup(tt.rg)\n\n\t\t\tvalidateEndpoints(t, got, tt.want)\n\t\t})\n\t}\n}\n\ntype fakeRouteGroupClient struct {\n\treturnErr bool\n\trg        *routeGroupList\n}\n\nfunc (f *fakeRouteGroupClient) getRouteGroupList(string) (*routeGroupList, error) {\n\tif f.returnErr {\n\t\treturn nil, errors.New(\"Fake route group list error\")\n\t}\n\treturn f.rg, nil\n}\n\nfunc TestRouteGroupsEndpoints(t *testing.T) {\n\tfor _, tt := range []struct {\n\t\tname         string\n\t\tsource       *routeGroupSource\n\t\tfqdnTemplate string\n\t\twant         []*endpoint.Endpoint\n\t\twantErr      bool\n\t}{\n\t\t{\n\t\t\tname: \"Empty routegroup should return empty endpoints\",\n\t\t\tsource: &routeGroupSource{\n\t\t\t\tcli: &fakeRouteGroupClient{\n\t\t\t\t\trg: &routeGroupList{},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant:    []*endpoint.Endpoint{},\n\t\t\twantErr: false,\n\t\t},\n\t\t{\n\t\t\tname: \"Single routegroup should return endpoints\",\n\t\t\tsource: &routeGroupSource{\n\t\t\t\tcli: &fakeRouteGroupClient{\n\t\t\t\t\trg: &routeGroupList{\n\t\t\t\t\t\tItems: []*routeGroup{\n\t\t\t\t\t\t\tcreateTestRouteGroup(\n\t\t\t\t\t\t\t\t\"namespace1\",\n\t\t\t\t\t\t\t\t\"rg1\",\n\t\t\t\t\t\t\t\tnil,\n\t\t\t\t\t\t\t\t[]string{\"rg1.k8s.example\"},\n\t\t\t\t\t\t\t\t[]routeGroupLoadBalancer{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tHostname: \"lb.example.org\",\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"rg1.k8s.example\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets([]string{\"lb.example.org\"}),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:         \"Single routegroup with combineFQDNAnnotation with fqdn template should return endpoints from fqdnTemplate and routegroup\",\n\t\t\tfqdnTemplate: \"{{.Metadata.Name}}.{{.Metadata.Namespace}}.example\",\n\t\t\tsource: &routeGroupSource{\n\t\t\t\tcombineFQDNAnnotation: true,\n\t\t\t\tcli: &fakeRouteGroupClient{\n\t\t\t\t\trg: &routeGroupList{\n\t\t\t\t\t\tItems: []*routeGroup{\n\t\t\t\t\t\t\tcreateTestRouteGroup(\n\t\t\t\t\t\t\t\t\"namespace1\",\n\t\t\t\t\t\t\t\t\"rg1\",\n\t\t\t\t\t\t\t\tnil,\n\t\t\t\t\t\t\t\t[]string{\"rg1.k8s.example\"},\n\t\t\t\t\t\t\t\t[]routeGroupLoadBalancer{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tHostname: \"lb.example.org\",\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"rg1.k8s.example\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets([]string{\"lb.example.org\"}),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"rg1.namespace1.example\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets([]string{\"lb.example.org\"}),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:         \"Single routegroup without, with fqdn template should return endpoints from fqdnTemplate\",\n\t\t\tfqdnTemplate: \"{{.Metadata.Name}}.{{.Metadata.Namespace}}.example\",\n\t\t\tsource: &routeGroupSource{\n\t\t\t\tcli: &fakeRouteGroupClient{\n\t\t\t\t\trg: &routeGroupList{\n\t\t\t\t\t\tItems: []*routeGroup{\n\t\t\t\t\t\t\tcreateTestRouteGroup(\n\t\t\t\t\t\t\t\t\"namespace1\",\n\t\t\t\t\t\t\t\t\"rg1\",\n\t\t\t\t\t\t\t\tnil,\n\t\t\t\t\t\t\t\tnil,\n\t\t\t\t\t\t\t\t[]routeGroupLoadBalancer{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tHostname: \"lb.example.org\",\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"rg1.namespace1.example\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets([]string{\"lb.example.org\"}),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:         \"Single routegroup without combineFQDNAnnotation with fqdn template should return endpoints not from fqdnTemplate\",\n\t\t\tfqdnTemplate: \"{{.Metadata.Name}}.{{.Metadata.Namespace}}.example\",\n\t\t\tsource: &routeGroupSource{\n\t\t\t\tcli: &fakeRouteGroupClient{\n\t\t\t\t\trg: &routeGroupList{\n\t\t\t\t\t\tItems: []*routeGroup{\n\t\t\t\t\t\t\tcreateTestRouteGroup(\n\t\t\t\t\t\t\t\t\"namespace1\",\n\t\t\t\t\t\t\t\t\"rg1\",\n\t\t\t\t\t\t\t\tnil,\n\t\t\t\t\t\t\t\t[]string{\"rg1.k8s.example\"},\n\t\t\t\t\t\t\t\t[]routeGroupLoadBalancer{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tHostname: \"lb.example.org\",\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"rg1.k8s.example\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets([]string{\"lb.example.org\"}),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"Single routegroup with TTL should return endpoint with TTL\",\n\t\t\tsource: &routeGroupSource{\n\t\t\t\tcli: &fakeRouteGroupClient{\n\t\t\t\t\trg: &routeGroupList{\n\t\t\t\t\t\tItems: []*routeGroup{\n\t\t\t\t\t\t\tcreateTestRouteGroup(\n\t\t\t\t\t\t\t\t\"namespace1\",\n\t\t\t\t\t\t\t\t\"rg1\",\n\t\t\t\t\t\t\t\tmap[string]string{\n\t\t\t\t\t\t\t\t\tannotations.TtlKey: \"2189\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t[]string{\"rg1.k8s.example\"},\n\t\t\t\t\t\t\t\t[]routeGroupLoadBalancer{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tHostname: \"lb.example.org\",\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"rg1.k8s.example\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets([]string{\"lb.example.org\"}),\n\t\t\t\t\tRecordTTL:  endpoint.TTL(2189),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"Routegroup with hosts and mixed destinations creates endpoints\",\n\t\t\tsource: &routeGroupSource{\n\t\t\t\tcli: &fakeRouteGroupClient{\n\t\t\t\t\trg: &routeGroupList{\n\t\t\t\t\t\tItems: []*routeGroup{\n\t\t\t\t\t\t\tcreateTestRouteGroup(\n\t\t\t\t\t\t\t\t\"namespace1\",\n\t\t\t\t\t\t\t\t\"rg1\",\n\t\t\t\t\t\t\t\tnil,\n\t\t\t\t\t\t\t\t[]string{\"rg1.k8s.example\"},\n\t\t\t\t\t\t\t\t[]routeGroupLoadBalancer{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tHostname: \"lb.example.org\",\n\t\t\t\t\t\t\t\t\t\tIP:       \"1.5.1.4\",\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"rg1.k8s.example\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeA,\n\t\t\t\t\tTargets:    endpoint.Targets([]string{\"1.5.1.4\"}),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"rg1.k8s.example\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets([]string{\"lb.example.org\"}),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"multiple routegroups should return endpoints\",\n\t\t\tsource: &routeGroupSource{\n\t\t\t\tcli: &fakeRouteGroupClient{\n\t\t\t\t\trg: &routeGroupList{\n\t\t\t\t\t\tItems: []*routeGroup{\n\t\t\t\t\t\t\tcreateTestRouteGroup(\n\t\t\t\t\t\t\t\t\"namespace1\",\n\t\t\t\t\t\t\t\t\"rg1\",\n\t\t\t\t\t\t\t\tnil,\n\t\t\t\t\t\t\t\t[]string{\"rg1.k8s.example\"},\n\t\t\t\t\t\t\t\t[]routeGroupLoadBalancer{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tHostname: \"lb.example.org\",\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\tcreateTestRouteGroup(\n\t\t\t\t\t\t\t\t\"namespace1\",\n\t\t\t\t\t\t\t\t\"rg2\",\n\t\t\t\t\t\t\t\tnil,\n\t\t\t\t\t\t\t\t[]string{\"rg2.k8s.example\"},\n\t\t\t\t\t\t\t\t[]routeGroupLoadBalancer{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tHostname: \"lb.example.org\",\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\tcreateTestRouteGroup(\n\t\t\t\t\t\t\t\t\"namespace2\",\n\t\t\t\t\t\t\t\t\"rg3\",\n\t\t\t\t\t\t\t\tnil,\n\t\t\t\t\t\t\t\t[]string{\"rg3.k8s.example\"},\n\t\t\t\t\t\t\t\t[]routeGroupLoadBalancer{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tHostname: \"lb.example.org\",\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\tcreateTestRouteGroup(\n\t\t\t\t\t\t\t\t\"namespace3\",\n\t\t\t\t\t\t\t\t\"rg\",\n\t\t\t\t\t\t\t\tnil,\n\t\t\t\t\t\t\t\t[]string{\"rg.k8s.example\"},\n\t\t\t\t\t\t\t\t[]routeGroupLoadBalancer{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tHostname: \"lb2.example.org\",\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"rg1.k8s.example\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets([]string{\"lb.example.org\"}),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"rg2.k8s.example\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets([]string{\"lb.example.org\"}),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"rg3.k8s.example\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets([]string{\"lb.example.org\"}),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"rg.k8s.example\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets([]string{\"lb2.example.org\"}),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"multiple routegroups with filter annotations should return only filtered endpoints\",\n\t\t\tsource: &routeGroupSource{\n\t\t\t\tannotationFilter: \"kubernetes.io/ingress.class=skipper\",\n\t\t\t\tcli: &fakeRouteGroupClient{\n\t\t\t\t\trg: &routeGroupList{\n\t\t\t\t\t\tItems: []*routeGroup{\n\t\t\t\t\t\t\tcreateTestRouteGroup(\n\t\t\t\t\t\t\t\t\"namespace1\",\n\t\t\t\t\t\t\t\t\"rg1\",\n\t\t\t\t\t\t\t\tmap[string]string{\n\t\t\t\t\t\t\t\t\t\"kubernetes.io/ingress.class\": \"skipper\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t[]string{\"rg1.k8s.example\"},\n\t\t\t\t\t\t\t\t[]routeGroupLoadBalancer{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tHostname: \"lb.example.org\",\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\tcreateTestRouteGroup(\n\t\t\t\t\t\t\t\t\"namespace1\",\n\t\t\t\t\t\t\t\t\"rg2\",\n\t\t\t\t\t\t\t\tmap[string]string{\n\t\t\t\t\t\t\t\t\t\"kubernetes.io/ingress.class\": \"nginx\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t[]string{\"rg2.k8s.example\"},\n\t\t\t\t\t\t\t\t[]routeGroupLoadBalancer{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tHostname: \"lb.example.org\",\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\tcreateTestRouteGroup(\n\t\t\t\t\t\t\t\t\"namespace2\",\n\t\t\t\t\t\t\t\t\"rg3\",\n\t\t\t\t\t\t\t\tmap[string]string{\n\t\t\t\t\t\t\t\t\t\"kubernetes.io/ingress.class\": \"\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t[]string{\"rg3.k8s.example\"},\n\t\t\t\t\t\t\t\t[]routeGroupLoadBalancer{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tHostname: \"lb.example.org\",\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\tcreateTestRouteGroup(\n\t\t\t\t\t\t\t\t\"namespace3\",\n\t\t\t\t\t\t\t\t\"rg\",\n\t\t\t\t\t\t\t\tnil,\n\t\t\t\t\t\t\t\t[]string{\"rg.k8s.example\"},\n\t\t\t\t\t\t\t\t[]routeGroupLoadBalancer{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tHostname: \"lb2.example.org\",\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"rg1.k8s.example\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets([]string{\"lb.example.org\"}),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"multiple routegroups with set operation annotation filter should return only filtered endpoints\",\n\t\t\tsource: &routeGroupSource{\n\t\t\t\tannotationFilter: \"kubernetes.io/ingress.class in (nginx, skipper)\",\n\t\t\t\tcli: &fakeRouteGroupClient{\n\t\t\t\t\trg: &routeGroupList{\n\t\t\t\t\t\tItems: []*routeGroup{\n\t\t\t\t\t\t\tcreateTestRouteGroup(\n\t\t\t\t\t\t\t\t\"namespace1\",\n\t\t\t\t\t\t\t\t\"rg1\",\n\t\t\t\t\t\t\t\tmap[string]string{\n\t\t\t\t\t\t\t\t\t\"kubernetes.io/ingress.class\": \"skipper\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t[]string{\"rg1.k8s.example\"},\n\t\t\t\t\t\t\t\t[]routeGroupLoadBalancer{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tHostname: \"lb.example.org\",\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\tcreateTestRouteGroup(\n\t\t\t\t\t\t\t\t\"namespace1\",\n\t\t\t\t\t\t\t\t\"rg2\",\n\t\t\t\t\t\t\t\tmap[string]string{\n\t\t\t\t\t\t\t\t\t\"kubernetes.io/ingress.class\": \"nginx\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t[]string{\"rg2.k8s.example\"},\n\t\t\t\t\t\t\t\t[]routeGroupLoadBalancer{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tHostname: \"lb.example.org\",\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\tcreateTestRouteGroup(\n\t\t\t\t\t\t\t\t\"namespace2\",\n\t\t\t\t\t\t\t\t\"rg3\",\n\t\t\t\t\t\t\t\tmap[string]string{\n\t\t\t\t\t\t\t\t\t\"kubernetes.io/ingress.class\": \"\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t[]string{\"rg3.k8s.example\"},\n\t\t\t\t\t\t\t\t[]routeGroupLoadBalancer{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tHostname: \"lb.example.org\",\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\tcreateTestRouteGroup(\n\t\t\t\t\t\t\t\t\"namespace3\",\n\t\t\t\t\t\t\t\t\"rg\",\n\t\t\t\t\t\t\t\tnil,\n\t\t\t\t\t\t\t\t[]string{\"rg.k8s.example\"},\n\t\t\t\t\t\t\t\t[]routeGroupLoadBalancer{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tHostname: \"lb2.example.org\",\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"rg1.k8s.example\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets([]string{\"lb.example.org\"}),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"rg2.k8s.example\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets([]string{\"lb.example.org\"}),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"multiple routegroups with controller annotation filter should not return filtered endpoints\",\n\t\t\tsource: &routeGroupSource{\n\t\t\t\tcli: &fakeRouteGroupClient{\n\t\t\t\t\trg: &routeGroupList{\n\t\t\t\t\t\tItems: []*routeGroup{\n\t\t\t\t\t\t\tcreateTestRouteGroup(\n\t\t\t\t\t\t\t\t\"namespace1\",\n\t\t\t\t\t\t\t\t\"rg1\",\n\t\t\t\t\t\t\t\tmap[string]string{\n\t\t\t\t\t\t\t\t\tannotations.ControllerKey: annotations.ControllerValue,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t[]string{\"rg1.k8s.example\"},\n\t\t\t\t\t\t\t\t[]routeGroupLoadBalancer{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tHostname: \"lb.example.org\",\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\tcreateTestRouteGroup(\n\t\t\t\t\t\t\t\t\"namespace1\",\n\t\t\t\t\t\t\t\t\"rg2\",\n\t\t\t\t\t\t\t\tmap[string]string{\n\t\t\t\t\t\t\t\t\tannotations.ControllerKey: \"dns\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t[]string{\"rg2.k8s.example\"},\n\t\t\t\t\t\t\t\t[]routeGroupLoadBalancer{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tHostname: \"lb.example.org\",\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\tcreateTestRouteGroup(\n\t\t\t\t\t\t\t\t\"namespace2\",\n\t\t\t\t\t\t\t\t\"rg3\",\n\t\t\t\t\t\t\t\tnil,\n\t\t\t\t\t\t\t\t[]string{\"rg3.k8s.example\"},\n\t\t\t\t\t\t\t\t[]routeGroupLoadBalancer{\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\tHostname: \"lb.example.org\",\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"rg1.k8s.example\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets([]string{\"lb.example.org\"}),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"rg3.k8s.example\",\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tTargets:    endpoint.Targets([]string{\"lb.example.org\"}),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif tt.fqdnTemplate != \"\" {\n\t\t\t\ttmpl, err := fqdn.ParseTemplate(tt.fqdnTemplate)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatalf(\"Failed to parse template: %v\", err)\n\t\t\t\t}\n\t\t\t\ttt.source.fqdnTemplate = tmpl\n\t\t\t}\n\n\t\t\tgot, err := tt.source.Endpoints(t.Context())\n\t\t\tif err != nil && !tt.wantErr {\n\t\t\t\tt.Errorf(\"Got error, but does not want to get an error: %v\", err)\n\t\t\t}\n\t\t\tif tt.wantErr && err == nil {\n\t\t\t\tt.Fatal(\"Got no error, but we want to get an error\")\n\t\t\t}\n\n\t\t\tvalidateEndpoints(t, got, tt.want)\n\t\t})\n\t}\n}\n\nfunc TestResourceLabelIsSet(t *testing.T) {\n\tsource := &routeGroupSource{\n\t\tcli: &fakeRouteGroupClient{\n\t\t\trg: &routeGroupList{\n\t\t\t\tItems: []*routeGroup{\n\t\t\t\t\tcreateTestRouteGroup(\n\t\t\t\t\t\t\"namespace1\",\n\t\t\t\t\t\t\"rg1\",\n\t\t\t\t\t\tnil,\n\t\t\t\t\t\t[]string{\"rg1.k8s.example\"},\n\t\t\t\t\t\t[]routeGroupLoadBalancer{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tHostname: \"lb.example.org\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tgot, _ := source.Endpoints(t.Context())\n\tfor _, ep := range got {\n\t\tif _, ok := ep.Labels[endpoint.ResourceLabelKey]; !ok {\n\t\t\tt.Errorf(\"Failed to set resource label on ep %v\", ep)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "source/source.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\t\"k8s.io/apimachinery/pkg/runtime\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n)\n\nconst (\n\tEndpointsTypeNodeExternalIP = \"NodeExternalIP\"\n\tEndpointsTypeHostIP         = \"HostIP\"\n)\n\n// Source defines the interface Endpoint sources should implement.\ntype Source interface {\n\tEndpoints(ctx context.Context) ([]*endpoint.Endpoint, error)\n\t// AddEventHandler adds an event handler that should be triggered if something in source changes\n\tAddEventHandler(context.Context, func())\n}\n\ntype kubeObject interface {\n\truntime.Object\n\tmetav1.Object\n}\n\nfunc getAccessFromAnnotations(input map[string]string) string {\n\treturn input[annotations.AccessKey]\n}\n\nfunc getEndpointsTypeFromAnnotations(annots map[string]string) string {\n\treturn annots[annotations.EndpointsTypeKey]\n}\n\nfunc getLabelSelector(annotationFilter string) (labels.Selector, error) {\n\tlabelSelector, err := metav1.ParseToLabelSelector(annotationFilter)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn metav1.LabelSelectorAsSelector(labelSelector)\n}\n\nfunc matchLabelSelector(selector labels.Selector, srcAnnotations map[string]string) bool {\n\treturn selector.Matches(labels.Set(srcAnnotations))\n}\n\ntype eventHandlerFunc func()\n\nfunc (fn eventHandlerFunc) OnAdd(_ any, _ bool) { fn() }\nfunc (fn eventHandlerFunc) OnUpdate(_, _ any)   { fn() }\nfunc (fn eventHandlerFunc) OnDelete(_ any)      { fn() }\n"
  },
  {
    "path": "source/source_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n)\n\nfunc TestGetLabelSelector(t *testing.T) {\n\ttests := []struct {\n\t\tname             string\n\t\tannotationFilter string\n\t\texpectError      bool\n\t\texpectedSelector string\n\t}{\n\t\t{\n\t\t\tname:             \"Valid label selector\",\n\t\t\tannotationFilter: \"key1=value1,key2=value2\",\n\t\t\texpectedSelector: \"key1=value1,key2=value2\",\n\t\t},\n\t\t{\n\t\t\tname:             \"Invalid label selector\",\n\t\t\tannotationFilter: \"key1==value1\",\n\t\t\texpectedSelector: \"key1=value1\",\n\t\t},\n\t\t{\n\t\t\tname:             \"Empty label selector\",\n\t\t\tannotationFilter: \"\",\n\t\t\texpectedSelector: \"\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tselector, err := getLabelSelector(tt.annotationFilter)\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.Equal(t, tt.expectedSelector, selector.String())\n\t\t})\n\t}\n}\n\nfunc TestMatchLabelSelector(t *testing.T) {\n\ttests := []struct {\n\t\tname           string\n\t\tselector       labels.Selector\n\t\tsrcAnnotations map[string]string\n\t\texpectedMatch  bool\n\t}{\n\t\t{\n\t\t\tname:           \"Matching label selector\",\n\t\t\tselector:       labels.SelectorFromSet(labels.Set{\"key1\": \"value1\"}),\n\t\t\tsrcAnnotations: map[string]string{\"key1\": \"value1\", \"key2\": \"value2\"},\n\t\t\texpectedMatch:  true,\n\t\t},\n\t\t{\n\t\t\tname:           \"Non-matching label selector\",\n\t\t\tselector:       labels.SelectorFromSet(labels.Set{\"key1\": \"value1\"}),\n\t\t\tsrcAnnotations: map[string]string{\"key2\": \"value2\"},\n\t\t\texpectedMatch:  false,\n\t\t},\n\t\t{\n\t\t\tname:           \"Empty label selector\",\n\t\t\tselector:       labels.NewSelector(),\n\t\t\tsrcAnnotations: map[string]string{\"key1\": \"value1\"},\n\t\t\texpectedMatch:  true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := matchLabelSelector(tt.selector, tt.srcAnnotations)\n\t\t\tassert.Equal(t, tt.expectedMatch, result)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "source/store.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"sync\"\n\t\"time\"\n\n\topenshift \"github.com/openshift/client-go/route/clientset/versioned\"\n\tlog \"github.com/sirupsen/logrus\"\n\tistioclient \"istio.io/client-go/pkg/clientset/versioned\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\t\"k8s.io/client-go/dynamic\"\n\t\"k8s.io/client-go/kubernetes\"\n\t\"k8s.io/client-go/rest\"\n\t\"k8s.io/client-go/tools/clientcmd\"\n\tgateway \"sigs.k8s.io/gateway-api/pkg/client/clientset/versioned\"\n\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\tkubeclient \"sigs.k8s.io/external-dns/pkg/client\"\n\t\"sigs.k8s.io/external-dns/source/types\"\n)\n\n// ErrSourceNotFound is returned when a requested source doesn't exist.\nvar ErrSourceNotFound = errors.New(\"source not found\")\n\n// Config holds shared configuration options for all Sources.\n// This struct centralizes all source-related configuration to avoid parameter proliferation\n// in individual source constructors. It follows the configuration pattern where a single\n// config object is passed rather than individual parameters.\n//\n// Common Configuration Fields:\n// - Namespace: Target namespace for source operations\n// - AnnotationFilter: Filter sources by annotation patterns\n// - LabelFilter: Filter sources by label selectors\n// - FQDNTemplate: Template for generating fully qualified domain names\n// - CombineFQDNAndAnnotation: Whether to combine FQDN template with annotations\n// - IgnoreHostnameAnnotation: Whether to ignore hostname annotations\n//\n// The config is created from externaldns.Config via NewSourceConfig() which handles\n// type conversions and validation.\ntype Config struct {\n\tNamespace                      string\n\tAnnotationFilter               string\n\tLabelFilter                    labels.Selector\n\tIngressClassNames              []string\n\tFQDNTemplate                   string\n\tTargetTemplate                 string\n\tFQDNTargetTemplate             string\n\tCombineFQDNAndAnnotation       bool\n\tIgnoreHostnameAnnotation       bool\n\tIgnoreNonHostNetworkPods       bool\n\tIgnoreIngressTLSSpec           bool\n\tIgnoreIngressRulesSpec         bool\n\tListenEndpointEvents           bool\n\tGatewayName                    string\n\tGatewayNamespace               string\n\tGatewayLabelFilter             string\n\tCompatibility                  string\n\tProvider                       string\n\tPodSourceDomain                string\n\tPublishInternal                bool\n\tPublishHostIP                  bool\n\tAlwaysPublishNotReadyAddresses bool\n\tConnectorServer                string\n\tCRDSourceAPIVersion            string\n\tCRDSourceKind                  string\n\tKubeConfig                     string\n\tAPIServerURL                   string\n\tServiceTypeFilter              []string\n\tGlooNamespaces                 []string\n\tSkipperRouteGroupVersion       string\n\tRequestTimeout                 time.Duration\n\tDefaultTargets                 []string\n\tForceDefaultTargets            bool\n\tOCPRouterName                  string\n\tUpdateEvents                   bool\n\tResolveLoadBalancerHostname    bool\n\tTraefikEnableLegacy            bool\n\tTraefikDisableNew              bool\n\tExcludeUnschedulable           bool\n\tExposeInternalIPv6             bool\n\tExcludeTargetNets              []string\n\tTargetNetFilter                []string\n\tNAT64Networks                  []string\n\tMinTTL                         time.Duration\n\tUnstructuredResources          []string\n\tPreferAlias                    bool\n\n\tsources []string\n\n\t// clientGen is lazily initialized on first access for efficiency\n\tclientGen     *SingletonClientGenerator\n\tclientGenOnce sync.Once\n}\n\nfunc NewSourceConfig(cfg *externaldns.Config) *Config {\n\t// error is explicitly ignored because the filter is already validated in validation.ValidateConfig\n\tlabelSelector, _ := labels.Parse(cfg.LabelFilter)\n\treturn &Config{\n\t\tNamespace:                      cfg.Namespace,\n\t\tAnnotationFilter:               cfg.AnnotationFilter,\n\t\tLabelFilter:                    labelSelector,\n\t\tIngressClassNames:              cfg.IngressClassNames,\n\t\tCombineFQDNAndAnnotation:       cfg.CombineFQDNAndAnnotation,\n\t\tIgnoreHostnameAnnotation:       cfg.IgnoreHostnameAnnotation,\n\t\tIgnoreNonHostNetworkPods:       cfg.IgnoreNonHostNetworkPods,\n\t\tIgnoreIngressTLSSpec:           cfg.IgnoreIngressTLSSpec,\n\t\tIgnoreIngressRulesSpec:         cfg.IgnoreIngressRulesSpec,\n\t\tListenEndpointEvents:           cfg.ListenEndpointEvents,\n\t\tGatewayName:                    cfg.GatewayName,\n\t\tGatewayNamespace:               cfg.GatewayNamespace,\n\t\tGatewayLabelFilter:             cfg.GatewayLabelFilter,\n\t\tCompatibility:                  cfg.Compatibility,\n\t\tPodSourceDomain:                cfg.PodSourceDomain,\n\t\tPublishInternal:                cfg.PublishInternal,\n\t\tPublishHostIP:                  cfg.PublishHostIP,\n\t\tProvider:                       cfg.Provider,\n\t\tAlwaysPublishNotReadyAddresses: cfg.AlwaysPublishNotReadyAddresses,\n\t\tConnectorServer:                cfg.ConnectorSourceServer,\n\t\tCRDSourceAPIVersion:            cfg.CRDSourceAPIVersion,\n\t\tCRDSourceKind:                  cfg.CRDSourceKind,\n\t\tKubeConfig:                     cfg.KubeConfig,\n\t\tAPIServerURL:                   cfg.APIServerURL,\n\t\tServiceTypeFilter:              cfg.ServiceTypeFilter,\n\t\tGlooNamespaces:                 cfg.GlooNamespaces,\n\t\tSkipperRouteGroupVersion:       cfg.SkipperRouteGroupVersion,\n\t\tRequestTimeout:                 cfg.RequestTimeout,\n\t\tDefaultTargets:                 cfg.DefaultTargets,\n\t\tForceDefaultTargets:            cfg.ForceDefaultTargets,\n\t\tOCPRouterName:                  cfg.OCPRouterName,\n\t\tUpdateEvents:                   cfg.UpdateEvents,\n\t\tResolveLoadBalancerHostname:    cfg.ResolveServiceLoadBalancerHostname,\n\t\tTraefikEnableLegacy:            cfg.TraefikEnableLegacy,\n\t\tTraefikDisableNew:              cfg.TraefikDisableNew,\n\t\tExcludeUnschedulable:           cfg.ExcludeUnschedulable,\n\t\tExposeInternalIPv6:             cfg.ExposeInternalIPV6,\n\t\tExcludeTargetNets:              cfg.ExcludeTargetNets,\n\t\tTargetNetFilter:                cfg.TargetNetFilter,\n\t\tNAT64Networks:                  cfg.NAT64Networks,\n\t\tMinTTL:                         cfg.MinTTL,\n\t\tUnstructuredResources:          cfg.UnstructuredResources,\n\t\tFQDNTemplate:                   cfg.FQDNTemplate,\n\t\tTargetTemplate:                 cfg.TargetTemplate,\n\t\tFQDNTargetTemplate:             cfg.FQDNTargetTemplate,\n\t\tPreferAlias:                    cfg.PreferAlias,\n\t\tsources:                        cfg.Sources,\n\t}\n}\n\n// ClientGenerator returns a SingletonClientGenerator from this Config's connection settings.\n// The generator is created once and cached for subsequent calls.\n// This ensures consistent Kubernetes client creation across all sources using this configuration.\n//\n// The timeout behavior is special-cased: when UpdateEvents is true, the timeout is set to 0\n// (no timeout) to allow long-running watch operations for event-driven source updates.\nfunc (cfg *Config) ClientGenerator() *SingletonClientGenerator {\n\tcfg.clientGenOnce.Do(func() {\n\t\tcfg.clientGen = &SingletonClientGenerator{\n\t\t\tKubeConfig:   cfg.KubeConfig,\n\t\t\tAPIServerURL: cfg.APIServerURL,\n\t\t\tRequestTimeout: func() time.Duration {\n\t\t\t\tif cfg.UpdateEvents {\n\t\t\t\t\treturn 0\n\t\t\t\t}\n\t\t\t\treturn cfg.RequestTimeout\n\t\t\t}(),\n\t\t}\n\t})\n\treturn cfg.clientGen\n}\n\n// ClientGenerator provides clients for various Kubernetes APIs and external services.\n// This interface abstracts client creation and enables dependency injection for testing.\n// It uses the singleton pattern to ensure only one instance of each client is created\n// and reused across multiple source instances.\n//\n// Supported Client Types:\n// - KubeClient: Standard Kubernetes API client\n// - GatewayClient: Gateway API client for Gateway resources\n// - IstioClient: Istio service mesh client\n// - DynamicKubernetesClient: Dynamic client for custom resources\n// - OpenShiftClient: OpenShift-specific client for Route resources\n// - RESTConfig: Instrumented REST config for creating custom clients\n//\n// The singleton behavior is implemented in SingletonClientGenerator which uses\n// sync.Once to guarantee single initialization of each client type.\ntype ClientGenerator interface {\n\tKubeClient() (kubernetes.Interface, error)\n\tGatewayClient() (gateway.Interface, error)\n\tIstioClient() (istioclient.Interface, error)\n\tDynamicKubernetesClient() (dynamic.Interface, error)\n\tOpenShiftClient() (openshift.Interface, error)\n\tRESTConfig() (*rest.Config, error)\n}\n\n// SingletonClientGenerator stores provider clients and guarantees that only one instance of each client\n// will be generated throughout the application lifecycle.\n//\n// Thread Safety: Uses sync.Once for each client type to ensure thread-safe initialization.\n// This is important because external-dns may create multiple sources concurrently.\n//\n// Memory Efficiency: Prevents creating multiple instances of expensive client objects\n// that maintain their own connection pools and caches.\n//\n// Configuration: Clients are configured using KubeConfig, APIServerURL, and RequestTimeout\n// which are set during SingletonClientGenerator initialization.\n//\n// TODO: Fix error handling pattern in client methods. Current implementation has a bug where\n// errors are only returned on the first call due to sync.Once behavior. If initialization fails\n// on the first call, subsequent calls return (nil, nil) instead of (nil, originalError), which\n// can lead to nil pointer dereferences. Solution: Store error in a field alongside the client,\n// similar to how the client itself is stored. Example:\n//\n//\ttype SingletonClientGenerator struct {\n//\t    restConfig    *rest.Config\n//\t    restConfigErr error        // Store error persistently\n//\t    restConfigOnce sync.Once\n//\t}\n//\n//\tfunc (p *SingletonClientGenerator) RESTConfig() (*rest.Config, error) {\n//\t    p.restConfigOnce.Do(func() {\n//\t        p.restConfig, p.restConfigErr = kubeclient.InstrumentedRESTConfig(...)\n//\t    })\n//\t    return p.restConfig, p.restConfigErr  // Return stored error\n//\t}\n//\n// This pattern should be applied to all client methods: KubeClient, GatewayClient,\n// DynamicKubernetesClient, OpenShiftClient, and RESTConfig.\ntype SingletonClientGenerator struct {\n\tKubeConfig      string\n\tAPIServerURL    string\n\tRequestTimeout  time.Duration\n\trestConfig      *rest.Config\n\tkubeClient      kubernetes.Interface\n\tgatewayClient   gateway.Interface\n\tistioClient     *istioclient.Clientset\n\tdynKubeClient   dynamic.Interface\n\topenshiftClient openshift.Interface\n\trestConfigOnce  sync.Once\n\tkubeOnce        sync.Once\n\tgatewayOnce     sync.Once\n\tistioOnce       sync.Once\n\tdynCliOnce      sync.Once\n\topenshiftOnce   sync.Once\n}\n\n// KubeClient generates a kube client if it was not created before\nfunc (p *SingletonClientGenerator) KubeClient() (kubernetes.Interface, error) {\n\tvar err error\n\tp.kubeOnce.Do(func() {\n\t\tp.kubeClient, err = kubeclient.NewKubeClient(p.KubeConfig, p.APIServerURL, p.RequestTimeout)\n\t})\n\treturn p.kubeClient, err\n}\n\n// RESTConfig generates an instrumented REST config if it was not created before.\n// The config includes request timeout handling and metrics instrumentation.\n// This is useful for sources that need to create custom clients (e.g., controller-runtime clients).\nfunc (p *SingletonClientGenerator) RESTConfig() (*rest.Config, error) {\n\tvar err error\n\tp.restConfigOnce.Do(func() {\n\t\tp.restConfig, err = kubeclient.InstrumentedRESTConfig(p.KubeConfig, p.APIServerURL, p.RequestTimeout)\n\t})\n\treturn p.restConfig, err\n}\n\n// GatewayClient generates a gateway client if it was not created before\nfunc (p *SingletonClientGenerator) GatewayClient() (gateway.Interface, error) {\n\tvar err error\n\tp.gatewayOnce.Do(func() {\n\t\tvar config *rest.Config\n\t\tconfig, err = p.RESTConfig()\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tp.gatewayClient, err = gateway.NewForConfig(config)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tlog.Infof(\"Created GatewayAPI client %s\", config.Host)\n\t})\n\treturn p.gatewayClient, err\n}\n\n// IstioClient generates an istio go client if it was not created before\nfunc (p *SingletonClientGenerator) IstioClient() (istioclient.Interface, error) {\n\tvar err error\n\tp.istioOnce.Do(func() {\n\t\tp.istioClient, err = NewIstioClient(p.KubeConfig, p.APIServerURL)\n\t})\n\treturn p.istioClient, err\n}\n\n// DynamicKubernetesClient generates a dynamic client if it was not created before\nfunc (p *SingletonClientGenerator) DynamicKubernetesClient() (dynamic.Interface, error) {\n\tvar err error\n\tp.dynCliOnce.Do(func() {\n\t\tvar config *rest.Config\n\t\tconfig, err = p.RESTConfig()\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tp.dynKubeClient, err = dynamic.NewForConfig(config)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tlog.Infof(\"Created Dynamic Kubernetes client %s\", config.Host)\n\t})\n\treturn p.dynKubeClient, err\n}\n\n// OpenShiftClient generates an openshift client if it was not created before\nfunc (p *SingletonClientGenerator) OpenShiftClient() (openshift.Interface, error) {\n\tvar err error\n\tp.openshiftOnce.Do(func() {\n\t\tvar config *rest.Config\n\t\tconfig, err = p.RESTConfig()\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tp.openshiftClient, err = openshift.NewForConfig(config)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tlog.Infof(\"Created OpenShift client %s\", config.Host)\n\t})\n\treturn p.openshiftClient, err\n}\n\n// ByNames returns multiple Sources given multiple names.\nfunc ByNames(ctx context.Context, cfg *Config, p ClientGenerator) ([]Source, error) {\n\tsources := make([]Source, 0, len(cfg.sources))\n\tfor _, name := range cfg.sources {\n\t\tsource, err := BuildWithConfig(ctx, name, p, cfg)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tsources = append(sources, source)\n\t}\n\n\treturn sources, nil\n}\n\n// BuildWithConfig creates a Source implementation using the factory pattern.\n// This function serves as the central registry for all available source types.\n//\n// Source Selection: Uses a string identifier to determine which source type to create.\n// This allows for runtime configuration and easy extension with new source types.\n//\n// Error Handling: Returns ErrSourceNotFound for unsupported source types,\n// allowing callers to handle unknown sources gracefully.\n//\n// Supported Source Types:\n// - \"node\": Kubernetes nodes\n// - \"service\": Kubernetes services\n// - \"ingress\": Kubernetes ingresses\n// - \"pod\": Kubernetes pods\n// - \"gateway-*\": Gateway API resources (httproute, grpcroute, tlsroute, tcproute, udproute)\n// - \"istio-*\": Istio resources (gateway, virtualservice)\n// - \"ambassador-host\": Ambassador Host resources\n// - \"contour-httpproxy\": Contour HTTPProxy resources\n// - \"gloo-proxy\": Gloo proxy resources\n// - \"traefik-proxy\": Traefik proxy resources\n// - \"openshift-route\": OpenShift Route resources\n// - \"crd\": Custom Resource Definitions\n// - \"skipper-routegroup\": Skipper RouteGroup resources\n// - \"kong-tcpingress\": Kong TCP Ingress resources\n// - \"f5-*\": F5 resources (virtualserver, transportserver)\n// - \"fake\": Fake source for testing\n// - \"connector\": Connector source for external systems\n//\n// Design Note: Gateway API sources use a different pattern (direct constructor calls)\n// because they have simpler initialization requirements.\nfunc BuildWithConfig(ctx context.Context, source string, p ClientGenerator, cfg *Config) (Source, error) {\n\tswitch source {\n\tcase types.Node:\n\t\treturn buildNodeSource(ctx, p, cfg)\n\tcase types.Service:\n\t\treturn buildServiceSource(ctx, p, cfg)\n\tcase types.Ingress:\n\t\treturn buildIngressSource(ctx, p, cfg)\n\tcase types.Pod:\n\t\treturn buildPodSource(ctx, p, cfg)\n\tcase types.GatewayHttpRoute:\n\t\treturn NewGatewayHTTPRouteSource(ctx, p, cfg)\n\tcase types.GatewayGrpcRoute:\n\t\treturn NewGatewayGRPCRouteSource(ctx, p, cfg)\n\tcase types.GatewayTlsRoute:\n\t\treturn NewGatewayTLSRouteSource(ctx, p, cfg)\n\tcase types.GatewayTcpRoute:\n\t\treturn NewGatewayTCPRouteSource(ctx, p, cfg)\n\tcase types.GatewayUdpRoute:\n\t\treturn NewGatewayUDPRouteSource(ctx, p, cfg)\n\tcase types.IstioGateway:\n\t\treturn buildIstioGatewaySource(ctx, p, cfg)\n\tcase types.IstioVirtualService:\n\t\treturn buildIstioVirtualServiceSource(ctx, p, cfg)\n\tcase types.AmbassadorHost:\n\t\treturn buildAmbassadorHostSource(ctx, p, cfg)\n\tcase types.ContourHTTPProxy:\n\t\treturn buildContourHTTPProxySource(ctx, p, cfg)\n\tcase types.GlooProxy:\n\t\treturn buildGlooProxySource(ctx, p, cfg)\n\tcase types.TraefikProxy:\n\t\treturn buildTraefikProxySource(ctx, p, cfg)\n\tcase types.OpenShiftRoute:\n\t\treturn buildOpenShiftRouteSource(ctx, p, cfg)\n\tcase types.Fake:\n\t\treturn NewFakeSource(cfg.FQDNTemplate)\n\tcase types.Connector:\n\t\treturn NewConnectorSource(cfg.ConnectorServer)\n\tcase types.CRD:\n\t\treturn buildCRDSource(ctx, p, cfg)\n\tcase types.SkipperRouteGroup:\n\t\treturn buildSkipperRouteGroupSource(ctx, cfg)\n\tcase types.KongTCPIngress:\n\t\treturn buildKongTCPIngressSource(ctx, p, cfg)\n\tcase types.F5VirtualServer:\n\t\treturn buildF5VirtualServerSource(ctx, p, cfg)\n\tcase types.F5TransportServer:\n\t\treturn buildF5TransportServerSource(ctx, p, cfg)\n\tcase types.Unstructured:\n\t\treturn buildUnstructuredSource(ctx, p, cfg)\n\t}\n\treturn nil, ErrSourceNotFound\n}\n\n// Source Builder Functions\n//\n// The following functions follow a standardized pattern for creating source instances.\n// This standardization improves code consistency, maintainability, and readability.\n//\n// Standardized Function Signature Pattern:\n//\n//\tfunc buildXXXSource(ctx context.Context, p ClientGenerator, cfg *Config) (Source, error)\n//\n// Standardized Constructor Parameter Pattern (where applicable):\n//  1. ctx (context.Context) - Always first when supported by the source constructor\n//  2. client(s) (kubernetes.Interface, dynamic.Interface, etc.) - Kubernetes clients\n//  3. namespace (string) - Target namespace for the source\n//  4. annotationFilter (string) - Filter for annotations\n//  5. labelFilter (labels.Selector) - Filter for labels (when applicable)\n//  6. fqdnTemplate (string) - FQDN template for DNS record generation\n//  7. combineFQDNAndAnnotation (bool) - Whether to combine FQDN template with annotations\n//  8. ...other parameters - Source-specific parameters in logical order\n//\n// Design Principles:\n// - Each source type has its own specific requirements and dependencies\n// - Separating build functions allows for clearer code organization and easier maintenance\n// - Individual functions enable straightforward error handling and independent testing\n// - Modularity makes it easier to add new source types or modify existing ones\n// - Consistent parameter ordering reduces cognitive load when working with multiple sources\n//\n// Note: Some sources may deviate from the standard pattern due to their unique requirements\n// (e.g., RouteGroupSource doesn't use ClientGenerator, GlooSource doesn't accept context)\n// buildNodeSource creates a Node source for exposing node information as DNS records.\n// Follows standard pattern: ctx, client, annotationFilter, fqdnTemplate, labelFilter, ...other\nfunc buildNodeSource(ctx context.Context, p ClientGenerator, cfg *Config) (Source, error) {\n\tclient, err := p.KubeClient()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn NewNodeSource(ctx, client, cfg)\n}\n\n// buildServiceSource creates a Service source for exposing Kubernetes services as DNS records.\n// Follows standard pattern: ctx, client, namespace, annotationFilter, fqdnTemplate, ...other\nfunc buildServiceSource(ctx context.Context, p ClientGenerator, cfg *Config) (Source, error) {\n\tclient, err := p.KubeClient()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn NewServiceSource(ctx, client, cfg)\n}\n\n// buildIngressSource creates an Ingress source for exposing Kubernetes ingresses as DNS records.\n// Follows standard pattern: ctx, client, namespace, annotationFilter, fqdnTemplate, ...other\nfunc buildIngressSource(ctx context.Context, p ClientGenerator, cfg *Config) (Source, error) {\n\tclient, err := p.KubeClient()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn NewIngressSource(ctx, client, cfg)\n}\n\n// buildPodSource creates a Pod source for exposing Kubernetes pods as DNS records.\n// Follows standard pattern: ctx, client, namespace, ...other (no annotation/label filters)\nfunc buildPodSource(ctx context.Context, p ClientGenerator, cfg *Config) (Source, error) {\n\tclient, err := p.KubeClient()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn NewPodSource(ctx, client, cfg)\n}\n\n// buildIstioGatewaySource creates an Istio Gateway source for exposing Istio gateways as DNS records.\n// Requires both Kubernetes and Istio clients. Follows standard parameter pattern.\nfunc buildIstioGatewaySource(ctx context.Context, p ClientGenerator, cfg *Config) (Source, error) {\n\tkubernetesClient, err := p.KubeClient()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tistioClient, err := p.IstioClient()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn NewIstioGatewaySource(ctx, kubernetesClient, istioClient, cfg)\n}\n\n// buildIstioVirtualServiceSource creates an Istio VirtualService source for exposing virtual services as DNS records.\n// Requires both Kubernetes and Istio clients. Follows standard parameter pattern.\nfunc buildIstioVirtualServiceSource(ctx context.Context, p ClientGenerator, cfg *Config) (Source, error) {\n\tkubernetesClient, err := p.KubeClient()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tistioClient, err := p.IstioClient()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn NewIstioVirtualServiceSource(ctx, kubernetesClient, istioClient, cfg)\n}\n\nfunc buildAmbassadorHostSource(ctx context.Context, p ClientGenerator, cfg *Config) (Source, error) {\n\tkubernetesClient, err := p.KubeClient()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdynamicClient, err := p.DynamicKubernetesClient()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn NewAmbassadorHostSource(ctx, dynamicClient, kubernetesClient, cfg)\n}\n\nfunc buildContourHTTPProxySource(ctx context.Context, p ClientGenerator, cfg *Config) (Source, error) {\n\tdynamicClient, err := p.DynamicKubernetesClient()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn NewContourHTTPProxySource(ctx, dynamicClient, cfg)\n}\n\n// buildGlooProxySource creates a Gloo source for exposing Gloo proxies as DNS records.\n// Requires both dynamic and standard Kubernetes clients.\n// Note: Does not accept context parameter in constructor (legacy design).\nfunc buildGlooProxySource(ctx context.Context, p ClientGenerator, cfg *Config) (Source, error) {\n\tkubernetesClient, err := p.KubeClient()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdynamicClient, err := p.DynamicKubernetesClient()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn NewGlooSource(ctx, dynamicClient, kubernetesClient, cfg)\n}\n\nfunc buildTraefikProxySource(ctx context.Context, p ClientGenerator, cfg *Config) (Source, error) {\n\tkubernetesClient, err := p.KubeClient()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdynamicClient, err := p.DynamicKubernetesClient()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn NewTraefikSource(ctx, dynamicClient, kubernetesClient, cfg)\n}\n\nfunc buildOpenShiftRouteSource(ctx context.Context, p ClientGenerator, cfg *Config) (Source, error) {\n\tocpClient, err := p.OpenShiftClient()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn NewOcpRouteSource(ctx, ocpClient, cfg)\n}\n\n// buildCRDSource creates a CRD source for exposing custom resources as DNS records.\n// Uses a specialized CRD client created via NewCRDClientForAPIVersionKind.\n// Parameter order: crdClient, namespace, kind, annotationFilter, labelFilter, scheme, updateEvents\nfunc buildCRDSource(_ context.Context, p ClientGenerator, cfg *Config) (Source, error) {\n\tclient, err := p.KubeClient()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tcrdClient, scheme, err := NewCRDClientForAPIVersionKind(client, cfg)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn NewCRDSource(crdClient, cfg, scheme)\n}\n\n// buildSkipperRouteGroupSource creates a Skipper RouteGroup source for exposing route groups as DNS records.\n// Special case: Does not use ClientGenerator pattern, instead manages its own authentication.\n// Retrieves bearer token from REST config for API server authentication.\nfunc buildSkipperRouteGroupSource(_ context.Context, cfg *Config) (Source, error) {\n\tapiServerURL := cfg.APIServerURL\n\ttokenPath := \"\"\n\ttoken := \"\"\n\trestConfig, err := kubeclient.GetRestConfig(cfg.KubeConfig, cfg.APIServerURL)\n\tif err == nil {\n\t\tapiServerURL = restConfig.Host\n\t\ttokenPath = restConfig.BearerTokenFile\n\t\ttoken = restConfig.BearerToken\n\t}\n\treturn NewRouteGroupSource(cfg, token, tokenPath, apiServerURL)\n}\n\nfunc buildKongTCPIngressSource(ctx context.Context, p ClientGenerator, cfg *Config) (Source, error) {\n\tkubernetesClient, err := p.KubeClient()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdynamicClient, err := p.DynamicKubernetesClient()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn NewKongTCPIngressSource(ctx, dynamicClient, kubernetesClient, cfg)\n}\n\nfunc buildF5VirtualServerSource(ctx context.Context, p ClientGenerator, cfg *Config) (Source, error) {\n\tkubernetesClient, err := p.KubeClient()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdynamicClient, err := p.DynamicKubernetesClient()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn NewF5VirtualServerSource(ctx, dynamicClient, kubernetesClient, cfg)\n}\n\nfunc buildF5TransportServerSource(ctx context.Context, p ClientGenerator, cfg *Config) (Source, error) {\n\tkubernetesClient, err := p.KubeClient()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdynamicClient, err := p.DynamicKubernetesClient()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn NewF5TransportServerSource(ctx, dynamicClient, kubernetesClient, cfg)\n}\n\nfunc buildUnstructuredSource(ctx context.Context, p ClientGenerator, cfg *Config) (Source, error) {\n\tkubeClient, err := p.KubeClient()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdynamicClient, err := p.DynamicKubernetesClient()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn NewUnstructuredFQDNSource(ctx, dynamicClient, kubeClient, cfg)\n}\n\n// NewIstioClient returns a new Istio client object. It uses the configured\n// KubeConfig attribute to connect to the cluster. If KubeConfig isn't provided\n// it defaults to using the recommended default.\n// NB: Istio controls the creation of the underlying Kubernetes client, so we\n// have no ability to tack on transport wrappers (e.g., Prometheus request\n// wrappers) to the client's config at this level. Furthermore, the Istio client\n// constructor does not expose the ability to override the Kubernetes API server endpoint,\n// so the apiServerURL config attribute has no effect.\nfunc NewIstioClient(kubeConfig string, apiServerURL string) (*istioclient.Clientset, error) {\n\tif kubeConfig == \"\" {\n\t\tif _, err := os.Stat(clientcmd.RecommendedHomeFile); err == nil {\n\t\t\tkubeConfig = clientcmd.RecommendedHomeFile\n\t\t}\n\t}\n\n\trestCfg, err := clientcmd.BuildConfigFromFlags(apiServerURL, kubeConfig)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tic, err := istioclient.NewForConfig(restCfg)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create istio client: %w\", err)\n\t}\n\n\treturn ic, nil\n}\n"
  },
  {
    "path": "source/store_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"testing\"\n\t\"time\"\n\n\topenshift \"github.com/openshift/client-go/route/clientset/versioned\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/mock\"\n\t\"github.com/stretchr/testify/require\"\n\t\"github.com/stretchr/testify/suite\"\n\tistioclient \"istio.io/client-go/pkg/clientset/versioned\"\n\tistiofake \"istio.io/client-go/pkg/clientset/versioned/fake\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\t\"k8s.io/apimachinery/pkg/runtime\"\n\t\"k8s.io/apimachinery/pkg/runtime/schema\"\n\t\"k8s.io/client-go/dynamic\"\n\tfakeDynamic \"k8s.io/client-go/dynamic/fake\"\n\t\"k8s.io/client-go/kubernetes\"\n\tfakeKube \"k8s.io/client-go/kubernetes/fake\"\n\t\"k8s.io/client-go/rest\"\n\tgateway \"sigs.k8s.io/gateway-api/pkg/client/clientset/versioned\"\n\n\t\"sigs.k8s.io/external-dns/source/types\"\n)\n\ntype MockClientGenerator struct {\n\tmock.Mock\n\tkubeClient              kubernetes.Interface\n\tgatewayClient           gateway.Interface\n\tistioClient             istioclient.Interface\n\tdynamicKubernetesClient dynamic.Interface\n\topenshiftClient         openshift.Interface\n}\n\nfunc (m *MockClientGenerator) KubeClient() (kubernetes.Interface, error) {\n\targs := m.Called()\n\tif args.Error(1) == nil {\n\t\tm.kubeClient = args.Get(0).(kubernetes.Interface)\n\t\treturn m.kubeClient, nil\n\t}\n\treturn nil, args.Error(1)\n}\n\nfunc (m *MockClientGenerator) GatewayClient() (gateway.Interface, error) {\n\targs := m.Called()\n\tif args.Error(1) != nil {\n\t\treturn nil, args.Error(1)\n\t}\n\tm.gatewayClient = args.Get(0).(gateway.Interface)\n\treturn m.gatewayClient, nil\n}\n\nfunc (m *MockClientGenerator) IstioClient() (istioclient.Interface, error) {\n\targs := m.Called()\n\tif args.Error(1) == nil {\n\t\tm.istioClient = args.Get(0).(istioclient.Interface)\n\t\treturn m.istioClient, nil\n\t}\n\treturn nil, args.Error(1)\n}\n\nfunc (m *MockClientGenerator) DynamicKubernetesClient() (dynamic.Interface, error) {\n\targs := m.Called()\n\tif args.Error(1) == nil {\n\t\tm.dynamicKubernetesClient = args.Get(0).(dynamic.Interface)\n\t\treturn m.dynamicKubernetesClient, nil\n\t}\n\treturn nil, args.Error(1)\n}\n\nfunc (m *MockClientGenerator) OpenShiftClient() (openshift.Interface, error) {\n\targs := m.Called()\n\tif args.Error(1) == nil {\n\t\tm.openshiftClient = args.Get(0).(openshift.Interface)\n\t\treturn m.openshiftClient, nil\n\t}\n\treturn nil, args.Error(1)\n}\n\nfunc (m *MockClientGenerator) RESTConfig() (*rest.Config, error) {\n\targs := m.Called()\n\tif args.Error(1) == nil {\n\t\treturn args.Get(0).(*rest.Config), nil\n\t}\n\treturn nil, args.Error(1)\n}\n\ntype ByNamesTestSuite struct {\n\tsuite.Suite\n}\n\nfunc (suite *ByNamesTestSuite) TestAllInitialized() {\n\tmockClientGenerator := new(MockClientGenerator)\n\tmockClientGenerator.On(\"KubeClient\").Return(fakeKube.NewSimpleClientset(), nil)\n\tmockClientGenerator.On(\"IstioClient\").Return(istiofake.NewSimpleClientset(), nil)\n\tmockClientGenerator.On(\"DynamicKubernetesClient\").Return(fakeDynamic.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(),\n\t\tmap[schema.GroupVersionResource]string{\n\t\t\t{\n\t\t\t\tGroup:    \"projectcontour.io\",\n\t\t\t\tVersion:  \"v1\",\n\t\t\t\tResource: \"httpproxies\",\n\t\t\t}: \"HTTPPRoxiesList\",\n\t\t\t{\n\t\t\t\tGroup:    \"contour.heptio.com\",\n\t\t\t\tVersion:  \"v1beta1\",\n\t\t\t\tResource: \"tcpingresses\",\n\t\t\t}: \"TCPIngressesList\",\n\t\t\t{\n\t\t\t\tGroup:    \"configuration.konghq.com\",\n\t\t\t\tVersion:  \"v1beta1\",\n\t\t\t\tResource: \"tcpingresses\",\n\t\t\t}: \"TCPIngressesList\",\n\t\t\t{\n\t\t\t\tGroup:    \"cis.f5.com\",\n\t\t\t\tVersion:  \"v1\",\n\t\t\t\tResource: \"virtualservers\",\n\t\t\t}: \"VirtualServersList\",\n\t\t\t{\n\t\t\t\tGroup:    \"cis.f5.com\",\n\t\t\t\tVersion:  \"v1\",\n\t\t\t\tResource: \"transportservers\",\n\t\t\t}: \"TransportServersList\",\n\t\t\t{\n\t\t\t\tGroup:    \"traefik.containo.us\",\n\t\t\t\tVersion:  \"v1alpha1\",\n\t\t\t\tResource: \"ingressroutes\",\n\t\t\t}: \"IngressRouteList\",\n\t\t\t{\n\t\t\t\tGroup:    \"traefik.containo.us\",\n\t\t\t\tVersion:  \"v1alpha1\",\n\t\t\t\tResource: \"ingressroutetcps\",\n\t\t\t}: \"IngressRouteTCPList\",\n\t\t\t{\n\t\t\t\tGroup:    \"traefik.containo.us\",\n\t\t\t\tVersion:  \"v1alpha1\",\n\t\t\t\tResource: \"ingressrouteudps\",\n\t\t\t}: \"IngressRouteUDPList\",\n\t\t\t{\n\t\t\t\tGroup:    \"traefik.io\",\n\t\t\t\tVersion:  \"v1alpha1\",\n\t\t\t\tResource: \"ingressroutes\",\n\t\t\t}: \"IngressRouteList\",\n\t\t\t{\n\t\t\t\tGroup:    \"traefik.io\",\n\t\t\t\tVersion:  \"v1alpha1\",\n\t\t\t\tResource: \"ingressroutetcps\",\n\t\t\t}: \"IngressRouteTCPList\",\n\t\t\t{\n\t\t\t\tGroup:    \"traefik.io\",\n\t\t\t\tVersion:  \"v1alpha1\",\n\t\t\t\tResource: \"ingressrouteudps\",\n\t\t\t}: \"IngressRouteUDPList\",\n\t\t}), nil)\n\n\tss := []string{\n\t\ttypes.Service, types.Ingress, types.IstioGateway, types.ContourHTTPProxy,\n\t\ttypes.KongTCPIngress, types.F5VirtualServer, types.F5TransportServer, types.TraefikProxy, types.Fake,\n\t}\n\tsources, err := ByNames(context.TODO(), &Config{\n\t\tsources: ss,\n\t}, mockClientGenerator)\n\tsuite.NoError(err, \"should not generate errors\")\n\tsuite.Len(sources, 9, \"should generate all nine sources\")\n}\n\nfunc (suite *ByNamesTestSuite) TestOnlyFake() {\n\tmockClientGenerator := new(MockClientGenerator)\n\tmockClientGenerator.On(\"KubeClient\").Return(fakeKube.NewClientset(), nil)\n\n\tsources, err := ByNames(context.TODO(), &Config{\n\t\tsources: []string{types.Fake},\n\t}, mockClientGenerator)\n\tsuite.NoError(err, \"should not generate errors\")\n\tsuite.Len(sources, 1, \"should generate fake source\")\n\tsuite.Nil(mockClientGenerator.kubeClient, \"client should not be created\")\n}\n\nfunc (suite *ByNamesTestSuite) TestSourceNotFound() {\n\tmockClientGenerator := new(MockClientGenerator)\n\tmockClientGenerator.On(\"KubeClient\").Return(fakeKube.NewClientset(), nil)\n\tsources, err := ByNames(context.TODO(), &Config{\n\t\tsources: []string{\"foo\"},\n\t}, mockClientGenerator)\n\tsuite.Equal(err, ErrSourceNotFound, \"should return source not found\")\n\tsuite.Empty(sources, \"should not returns any source\")\n}\n\nfunc (suite *ByNamesTestSuite) TestKubeClientFails() {\n\tmockClientGenerator := new(MockClientGenerator)\n\tmockClientGenerator.On(\"KubeClient\").Return(nil, errors.New(\"foo\"))\n\n\tsourceUnderTest := []string{\n\t\ttypes.Node, types.Service, types.Ingress, types.Pod, types.IstioGateway, types.IstioVirtualService,\n\t\ttypes.AmbassadorHost, types.GlooProxy, types.TraefikProxy, types.CRD, types.KongTCPIngress,\n\t\ttypes.F5VirtualServer, types.F5TransportServer,\n\t}\n\n\tfor _, source := range sourceUnderTest {\n\t\t_, err := ByNames(context.TODO(), &Config{\n\t\t\tsources: []string{source},\n\t\t}, mockClientGenerator)\n\t\tsuite.Error(err, source+\" should return an error if kubernetes client cannot be created\")\n\t}\n}\n\nfunc (suite *ByNamesTestSuite) TestIstioClientFails() {\n\tmockClientGenerator := new(MockClientGenerator)\n\tmockClientGenerator.On(\"KubeClient\").Return(fakeKube.NewSimpleClientset(), nil)\n\tmockClientGenerator.On(\"IstioClient\").Return(nil, errors.New(\"foo\"))\n\tmockClientGenerator.On(\"DynamicKubernetesClient\").Return(nil, errors.New(\"foo\"))\n\n\tsourcesDependentOnIstioClient := []string{types.IstioGateway, types.IstioVirtualService}\n\n\tfor _, source := range sourcesDependentOnIstioClient {\n\t\t_, err := ByNames(context.TODO(), &Config{\n\t\t\tsources: []string{source},\n\t\t}, mockClientGenerator)\n\t\tsuite.Error(err, source+\" should return an error if istio client cannot be created\")\n\t}\n}\n\nfunc (suite *ByNamesTestSuite) TestDynamicKubernetesClientFails() {\n\tmockClientGenerator := new(MockClientGenerator)\n\tmockClientGenerator.On(\"KubeClient\").Return(fakeKube.NewClientset(), nil)\n\tmockClientGenerator.On(\"IstioClient\").Return(istiofake.NewSimpleClientset(), nil)\n\tmockClientGenerator.On(\"DynamicKubernetesClient\").Return(nil, errors.New(\"foo\"))\n\n\tsourcesDependentOnDynamicKubernetesClient := []string{\n\t\ttypes.AmbassadorHost, types.ContourHTTPProxy, types.GlooProxy, types.TraefikProxy,\n\t\ttypes.KongTCPIngress, types.F5VirtualServer, types.F5TransportServer,\n\t}\n\n\tfor _, source := range sourcesDependentOnDynamicKubernetesClient {\n\t\t_, err := ByNames(context.TODO(), &Config{\n\t\t\tsources: []string{source},\n\t\t}, mockClientGenerator)\n\t\tsuite.Error(err, source+\" should return an error if dynamic kubernetes client cannot be created\")\n\t}\n}\n\nfunc TestByNames(t *testing.T) {\n\tsuite.Run(t, new(ByNamesTestSuite))\n}\n\ntype minimalMockClientGenerator struct{}\n\nvar errMock = errors.New(\"mock not implemented\")\n\nfunc (m *minimalMockClientGenerator) KubeClient() (kubernetes.Interface, error) { return nil, errMock }\nfunc (m *minimalMockClientGenerator) GatewayClient() (gateway.Interface, error) { return nil, errMock }\nfunc (m *minimalMockClientGenerator) IstioClient() (istioclient.Interface, error) {\n\treturn nil, errMock\n}\n\nfunc (m *minimalMockClientGenerator) DynamicKubernetesClient() (dynamic.Interface, error) {\n\treturn nil, errMock\n}\nfunc (m *minimalMockClientGenerator) OpenShiftClient() (openshift.Interface, error) {\n\treturn nil, errMock\n}\nfunc (m *minimalMockClientGenerator) RESTConfig() (*rest.Config, error) { return nil, errMock }\n\nfunc TestBuildWithConfig_InvalidSource(t *testing.T) {\n\tctx := t.Context()\n\tp := &minimalMockClientGenerator{}\n\tcfg := &Config{LabelFilter: labels.NewSelector()}\n\n\tsrc, err := BuildWithConfig(ctx, \"not-a-source\", p, cfg)\n\tif src != nil {\n\t\tt.Errorf(\"expected nil source for invalid type, got: %v\", src)\n\t}\n\tif !errors.Is(err, ErrSourceNotFound) {\n\t\tt.Errorf(\"expected ErrSourceNotFound, got: %v\", err)\n\t}\n}\n\nfunc TestConfig_ClientGenerator(t *testing.T) {\n\tcfg := &Config{\n\t\tKubeConfig:     \"/path/to/kubeconfig\",\n\t\tAPIServerURL:   \"https://api.example.com\",\n\t\tRequestTimeout: 30 * time.Second,\n\t\tUpdateEvents:   false,\n\t}\n\n\tgen := cfg.ClientGenerator()\n\n\tassert.Equal(t, \"/path/to/kubeconfig\", gen.KubeConfig)\n\tassert.Equal(t, \"https://api.example.com\", gen.APIServerURL)\n\tassert.Equal(t, 30*time.Second, gen.RequestTimeout)\n}\n\nfunc TestConfig_ClientGenerator_UpdateEvents(t *testing.T) {\n\tcfg := &Config{\n\t\tKubeConfig:     \"/path/to/kubeconfig\",\n\t\tAPIServerURL:   \"https://api.example.com\",\n\t\tRequestTimeout: 30 * time.Second,\n\t\tUpdateEvents:   true, // Special case\n\t}\n\n\tgen := cfg.ClientGenerator()\n\n\tassert.Equal(t, time.Duration(0), gen.RequestTimeout, \"UpdateEvents should set timeout to 0\")\n}\n\nfunc TestConfig_ClientGenerator_Caching(t *testing.T) {\n\tcfg := &Config{\n\t\tKubeConfig:     \"/path/to/kubeconfig\",\n\t\tAPIServerURL:   \"https://api.example.com\",\n\t\tRequestTimeout: 30 * time.Second,\n\t\tUpdateEvents:   false,\n\t}\n\n\t// Call ClientGenerator twice\n\tgen1 := cfg.ClientGenerator()\n\tgen2 := cfg.ClientGenerator()\n\n\t// Should return the same instance (cached)\n\tassert.Same(t, gen1, gen2, \"ClientGenerator should return the same cached instance\")\n}\n\n// TestSingletonClientGenerator_RESTConfig_TimeoutPropagation verifies timeout configuration\nfunc TestSingletonClientGenerator_RESTConfig_TimeoutPropagation(t *testing.T) {\n\ttestCases := []struct {\n\t\tname           string\n\t\trequestTimeout time.Duration\n\t}{\n\t\t{\n\t\t\tname:           \"30 second timeout\",\n\t\t\trequestTimeout: 30 * time.Second,\n\t\t},\n\t\t{\n\t\t\tname:           \"60 second timeout\",\n\t\t\trequestTimeout: 60 * time.Second,\n\t\t},\n\t\t{\n\t\t\tname:           \"zero timeout (for watches)\",\n\t\t\trequestTimeout: 0,\n\t\t},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tgen := &SingletonClientGenerator{\n\t\t\t\tKubeConfig:     \"\",\n\t\t\t\tAPIServerURL:   \"\",\n\t\t\t\tRequestTimeout: tc.requestTimeout,\n\t\t\t}\n\n\t\t\t// Verify the generator was configured with correct timeout\n\t\t\tassert.Equal(t, tc.requestTimeout, gen.RequestTimeout,\n\t\t\t\t\"SingletonClientGenerator should have the configured RequestTimeout\")\n\n\t\t\tconfig, err := gen.RESTConfig()\n\n\t\t\t// Even if config creation failed, verify the timeout was set in generator\n\t\t\tassert.Equal(t, tc.requestTimeout, gen.RequestTimeout,\n\t\t\t\t\"RequestTimeout should remain unchanged after RESTConfig() call\")\n\n\t\t\t// If config was successfully created, verify timeout propagated correctly\n\t\t\tif err == nil {\n\t\t\t\trequire.NotNil(t, config, \"Config should not be nil when error is nil\")\n\t\t\t\tassert.Equal(t, tc.requestTimeout, config.Timeout,\n\t\t\t\t\t\"REST config should have timeout matching RequestTimeout field\")\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestConfig_ClientGenerator_RESTConfig_Integration verifies Config → ClientGenerator → RESTConfig flow\nfunc TestConfig_ClientGenerator_RESTConfig_Integration(t *testing.T) {\n\tt.Run(\"normal timeout is propagated\", func(t *testing.T) {\n\t\tcfg := &Config{\n\t\t\tKubeConfig:     \"\",\n\t\t\tAPIServerURL:   \"\",\n\t\t\tRequestTimeout: 45 * time.Second,\n\t\t\tUpdateEvents:   false,\n\t\t}\n\n\t\tgen := cfg.ClientGenerator()\n\n\t\t// Verify ClientGenerator has correct timeout\n\t\tassert.Equal(t, 45*time.Second, gen.RequestTimeout,\n\t\t\t\"ClientGenerator should have the configured RequestTimeout\")\n\n\t\tconfig, err := gen.RESTConfig()\n\n\t\t// Even if config creation fails, the timeout setting should be correct\n\t\tassert.Equal(t, 45*time.Second, gen.RequestTimeout,\n\t\t\t\"RequestTimeout should remain 45s after RESTConfig() call\")\n\n\t\tif err == nil {\n\t\t\trequire.NotNil(t, config, \"Config should not be nil when error is nil\")\n\t\t\tassert.Equal(t, 45*time.Second, config.Timeout,\n\t\t\t\t\"RESTConfig should propagate the timeout\")\n\t\t}\n\t})\n\n\tt.Run(\"UpdateEvents sets timeout to zero\", func(t *testing.T) {\n\t\tcfg := &Config{\n\t\t\tKubeConfig:     \"\",\n\t\t\tAPIServerURL:   \"\",\n\t\t\tRequestTimeout: 45 * time.Second,\n\t\t\tUpdateEvents:   true, // Should override to 0\n\t\t}\n\n\t\tgen := cfg.ClientGenerator()\n\n\t\t// When UpdateEvents=true, ClientGenerator sets timeout to 0 (for long-running watches)\n\t\tassert.Equal(t, time.Duration(0), gen.RequestTimeout,\n\t\t\t\"ClientGenerator should have zero timeout when UpdateEvents=true\")\n\n\t\tconfig, err := gen.RESTConfig()\n\n\t\t// Verify the timeout is 0, regardless of whether config was created\n\t\tassert.Equal(t, time.Duration(0), gen.RequestTimeout,\n\t\t\t\"RequestTimeout should remain 0 after RESTConfig() call\")\n\n\t\tif err == nil {\n\t\t\trequire.NotNil(t, config, \"Config should not be nil when error is nil\")\n\t\t\tassert.Equal(t, time.Duration(0), config.Timeout,\n\t\t\t\t\"RESTConfig should have zero timeout for watch operations\")\n\t\t}\n\t})\n}\n\n// TestSingletonClientGenerator_RESTConfig_SharedAcrossClients verifies singleton is shared\nfunc TestSingletonClientGenerator_RESTConfig_SharedAcrossClients(t *testing.T) {\n\tgen := &SingletonClientGenerator{\n\t\tKubeConfig:     \"/nonexistent/path/to/kubeconfig\",\n\t\tAPIServerURL:   \"\",\n\t\tRequestTimeout: 30 * time.Second,\n\t}\n\n\t// Get REST config multiple times\n\trestConfig1, err1 := gen.RESTConfig()\n\trestConfig2, err2 := gen.RESTConfig()\n\trestConfig3, err3 := gen.RESTConfig()\n\n\t// Verify singleton behavior - all should return same instance\n\tassert.Same(t, restConfig1, restConfig2, \"RESTConfig should return same instance on second call\")\n\tassert.Same(t, restConfig1, restConfig3, \"RESTConfig should return same instance on third call\")\n\n\t// Verify the internal field matches\n\tassert.Same(t, restConfig1, gen.restConfig,\n\t\t\"Internal restConfig field should match returned value\")\n\n\t// Verify first call had error (no valid kubeconfig)\n\tassert.Error(t, err1, \"First call should return error when kubeconfig is invalid\")\n\n\t// Due to sync.Once bug, subsequent calls won't return the error\n\t// This is documented in the TODO comment on SingletonClientGenerator\n\trequire.NoError(t, err2, \"Second call does not return error due to sync.Once bug\")\n\trequire.NoError(t, err3, \"Third call does not return error due to sync.Once bug\")\n}\n"
  },
  {
    "path": "source/traefik_proxy.go",
    "content": "/*\nCopyright 2022 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"regexp\"\n\t\"strings\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\t\"k8s.io/apimachinery/pkg/runtime\"\n\t\"k8s.io/apimachinery/pkg/runtime/schema\"\n\t\"k8s.io/client-go/dynamic\"\n\t\"k8s.io/client-go/dynamic/dynamicinformer\"\n\tkubeinformers \"k8s.io/client-go/informers\"\n\t\"k8s.io/client-go/kubernetes\"\n\t\"k8s.io/client-go/kubernetes/scheme\"\n\t\"k8s.io/client-go/tools/cache\"\n\n\t\"sigs.k8s.io/external-dns/source/types\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n\t\"sigs.k8s.io/external-dns/source/informers\"\n)\n\nvar (\n\tingressRouteGVR = schema.GroupVersionResource{\n\t\tGroup:    \"traefik.io\",\n\t\tVersion:  \"v1alpha1\",\n\t\tResource: \"ingressroutes\",\n\t}\n\tingressRouteTCPGVR = schema.GroupVersionResource{\n\t\tGroup:    \"traefik.io\",\n\t\tVersion:  \"v1alpha1\",\n\t\tResource: \"ingressroutetcps\",\n\t}\n\tingressRouteUDPGVR = schema.GroupVersionResource{\n\t\tGroup:    \"traefik.io\",\n\t\tVersion:  \"v1alpha1\",\n\t\tResource: \"ingressrouteudps\",\n\t}\n\toldIngressRouteGVR = schema.GroupVersionResource{\n\t\tGroup:    \"traefik.containo.us\",\n\t\tVersion:  \"v1alpha1\",\n\t\tResource: \"ingressroutes\",\n\t}\n\toldIngressRouteTCPGVR = schema.GroupVersionResource{\n\t\tGroup:    \"traefik.containo.us\",\n\t\tVersion:  \"v1alpha1\",\n\t\tResource: \"ingressroutetcps\",\n\t}\n\toldIngressRouteUDPGVR = schema.GroupVersionResource{\n\t\tGroup:    \"traefik.containo.us\",\n\t\tVersion:  \"v1alpha1\",\n\t\tResource: \"ingressrouteudps\",\n\t}\n)\n\nvar (\n\ttraefikHostExtractor  = regexp.MustCompile(`(?:HostSNI|HostHeader|Host)\\s*\\(\\s*(\\x60.*?\\x60)\\s*\\)`)\n\ttraefikValueProcessor = regexp.MustCompile(`\\x60([^,\\x60]+)\\x60`)\n)\n\n// +externaldns:source:name=traefik-proxy\n// +externaldns:source:category=Ingress Controllers\n// +externaldns:source:description=Creates DNS entries from Traefik IngressRoute, IngressRouteTCP, and IngressRouteUDP resources\n// +externaldns:source:resources=IngressRoute.traefik.io,IngressRouteTCP.traefik.io,IngressRouteUDP.traefik.io\n// +externaldns:source:filters=annotation\n// +externaldns:source:namespace=all,single\n// +externaldns:source:fqdn-template=false\n// +externaldns:source:provider-specific=true\ntype traefikSource struct {\n\tdynamicKubeClient          dynamic.Interface\n\tkubeClient                 kubernetes.Interface\n\tannotationFilter           string\n\tnamespace                  string\n\tignoreHostnameAnnotation   bool\n\tingressRouteInformer       kubeinformers.GenericInformer\n\tingressRouteTcpInformer    kubeinformers.GenericInformer\n\tingressRouteUdpInformer    kubeinformers.GenericInformer\n\toldIngressRouteInformer    kubeinformers.GenericInformer\n\toldIngressRouteTcpInformer kubeinformers.GenericInformer\n\toldIngressRouteUdpInformer kubeinformers.GenericInformer\n\tunstructuredConverter      *unstructuredConverter\n}\n\nfunc NewTraefikSource(\n\tctx context.Context,\n\tdynamicKubeClient dynamic.Interface,\n\tkubeClient kubernetes.Interface,\n\tcfg *Config,\n) (Source, error) {\n\t// Use shared informer to listen for add/update/delete of Host in the specified namespace.\n\t// Set resync period to 0, to prevent processing when nothing has changed.\n\tinformerFactory := dynamicinformer.NewFilteredDynamicSharedInformerFactory(dynamicKubeClient, 0, cfg.Namespace, nil)\n\tvar ingressRouteInformer, ingressRouteTcpInformer, ingressRouteUdpInformer kubeinformers.GenericInformer\n\tvar oldIngressRouteInformer, oldIngressRouteTcpInformer, oldIngressRouteUdpInformer kubeinformers.GenericInformer\n\n\t// Add default resource event handlers to properly initialize informers.\n\tif !cfg.TraefikDisableNew {\n\t\tingressRouteInformer = informerFactory.ForResource(ingressRouteGVR)\n\t\tingressRouteTcpInformer = informerFactory.ForResource(ingressRouteTCPGVR)\n\t\tingressRouteUdpInformer = informerFactory.ForResource(ingressRouteUDPGVR)\n\t\t_, _ = ingressRouteInformer.Informer().AddEventHandler(informers.DefaultEventHandler())\n\t\t_, _ = ingressRouteTcpInformer.Informer().AddEventHandler(informers.DefaultEventHandler())\n\t\t_, _ = ingressRouteUdpInformer.Informer().AddEventHandler(informers.DefaultEventHandler())\n\t}\n\tif cfg.TraefikEnableLegacy {\n\t\toldIngressRouteInformer = informerFactory.ForResource(oldIngressRouteGVR)\n\t\toldIngressRouteTcpInformer = informerFactory.ForResource(oldIngressRouteTCPGVR)\n\t\toldIngressRouteUdpInformer = informerFactory.ForResource(oldIngressRouteUDPGVR)\n\t\t_, _ = oldIngressRouteInformer.Informer().AddEventHandler(informers.DefaultEventHandler())\n\t\t_, _ = oldIngressRouteTcpInformer.Informer().AddEventHandler(informers.DefaultEventHandler())\n\t\t_, _ = oldIngressRouteUdpInformer.Informer().AddEventHandler(informers.DefaultEventHandler())\n\t}\n\n\tinformerFactory.Start(ctx.Done())\n\n\t// wait for the local cache to be populated.\n\tif err := informers.WaitForDynamicCacheSync(ctx, informerFactory); err != nil {\n\t\treturn nil, err\n\t}\n\n\tuc, err := newTraefikUnstructuredConverter()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to setup Unstructured Converter: %w\", err)\n\t}\n\n\treturn &traefikSource{\n\t\tannotationFilter:           cfg.AnnotationFilter,\n\t\tignoreHostnameAnnotation:   cfg.IgnoreHostnameAnnotation,\n\t\tdynamicKubeClient:          dynamicKubeClient,\n\t\tingressRouteInformer:       ingressRouteInformer,\n\t\tingressRouteTcpInformer:    ingressRouteTcpInformer,\n\t\tingressRouteUdpInformer:    ingressRouteUdpInformer,\n\t\toldIngressRouteInformer:    oldIngressRouteInformer,\n\t\toldIngressRouteTcpInformer: oldIngressRouteTcpInformer,\n\t\toldIngressRouteUdpInformer: oldIngressRouteUdpInformer,\n\t\tkubeClient:                 kubeClient,\n\t\tnamespace:                  cfg.Namespace,\n\t\tunstructuredConverter:      uc,\n\t}, nil\n}\n\nfunc (ts *traefikSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) {\n\tvar endpoints []*endpoint.Endpoint\n\n\tif ts.ingressRouteInformer != nil {\n\t\tingressRouteEndpoints, err := ts.ingressRouteEndpoints()\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tendpoints = append(endpoints, ingressRouteEndpoints...)\n\t}\n\tif ts.oldIngressRouteInformer != nil {\n\t\toldIngressRouteEndpoints, err := ts.oldIngressRouteEndpoints()\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tendpoints = append(endpoints, oldIngressRouteEndpoints...)\n\t}\n\tif ts.ingressRouteTcpInformer != nil {\n\t\tingressRouteTcpEndpoints, err := ts.ingressRouteTCPEndpoints()\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tendpoints = append(endpoints, ingressRouteTcpEndpoints...)\n\t}\n\tif ts.oldIngressRouteTcpInformer != nil {\n\t\toldIngressRouteTcpEndpoints, err := ts.oldIngressRouteTCPEndpoints()\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tendpoints = append(endpoints, oldIngressRouteTcpEndpoints...)\n\t}\n\tif ts.ingressRouteUdpInformer != nil {\n\t\tingressRouteUdpEndpoints, err := ts.ingressRouteUDPEndpoints()\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tendpoints = append(endpoints, ingressRouteUdpEndpoints...)\n\t}\n\tif ts.oldIngressRouteUdpInformer != nil {\n\t\toldIngressRouteUdpEndpoints, err := ts.oldIngressRouteUDPEndpoints()\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tendpoints = append(endpoints, oldIngressRouteUdpEndpoints...)\n\t}\n\n\treturn MergeEndpoints(endpoints), nil\n}\n\n// ingressRouteEndpoints extracts endpoints from all IngressRoute objects\nfunc (ts *traefikSource) ingressRouteEndpoints() ([]*endpoint.Endpoint, error) {\n\treturn extractEndpoints(\n\t\tts.ingressRouteInformer.Lister(),\n\t\tts.namespace,\n\t\tfunc(u *unstructured.Unstructured) (*IngressRoute, error) {\n\t\t\ttyped := &IngressRoute{}\n\t\t\treturn typed, ts.unstructuredConverter.scheme.Convert(u, typed, nil)\n\t\t},\n\t\tts.annotationFilter,\n\t\tfunc(r *IngressRoute, targets endpoint.Targets) []*endpoint.Endpoint {\n\t\t\treturn ts.endpointsFromIngressRoute(r, targets)\n\t\t},\n\t)\n}\n\n// ingressRouteTCPEndpoints extracts endpoints from all IngressRouteTCP objects\nfunc (ts *traefikSource) ingressRouteTCPEndpoints() ([]*endpoint.Endpoint, error) {\n\tvar endpoints []*endpoint.Endpoint\n\n\tirs, err := ts.ingressRouteTcpInformer.Lister().ByNamespace(ts.namespace).List(labels.Everything())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar ingressRouteTCPs []*IngressRouteTCP\n\tfor _, ingressRouteTCPObj := range irs {\n\t\tunstructuredHost, ok := ingressRouteTCPObj.(*unstructured.Unstructured)\n\t\tif !ok {\n\t\t\treturn nil, errors.New(\"could not convert IngressRouteTCP object to unstructured\")\n\t\t}\n\n\t\tingressRouteTCP := &IngressRouteTCP{}\n\t\terr := ts.unstructuredConverter.scheme.Convert(unstructuredHost, ingressRouteTCP, nil)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tingressRouteTCPs = append(ingressRouteTCPs, ingressRouteTCP)\n\t}\n\n\tingressRouteTCPs, err = annotations.Filter(ingressRouteTCPs, ts.annotationFilter)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to filter IngressRouteTCP: %w\", err)\n\t}\n\n\tfor _, ingressRouteTCP := range ingressRouteTCPs {\n\t\tvar targets endpoint.Targets\n\n\t\ttargets = append(targets, annotations.TargetsFromTargetAnnotation(ingressRouteTCP.Annotations)...)\n\n\t\tfullname := fmt.Sprintf(\"%s/%s\", ingressRouteTCP.Namespace, ingressRouteTCP.Name)\n\n\t\tingressEndpoints := ts.endpointsFromIngressRouteTCP(ingressRouteTCP, targets)\n\t\tif endpoint.HasNoEmptyEndpoints(ingressEndpoints, types.TraefikProxy, ingressRouteTCP) {\n\t\t\tcontinue\n\t\t}\n\n\t\tlog.Debugf(\"Endpoints generated from IngressRouteTCP: %s: %v\", fullname, ingressEndpoints)\n\t\tendpoints = append(endpoints, ingressEndpoints...)\n\t}\n\n\treturn endpoints, nil\n}\n\n// ingressRouteUDPEndpoints extracts endpoints from all IngressRouteUDP objects\nfunc (ts *traefikSource) ingressRouteUDPEndpoints() ([]*endpoint.Endpoint, error) {\n\treturn extractEndpoints(\n\t\tts.ingressRouteUdpInformer.Lister(),\n\t\tts.namespace,\n\t\tfunc(u *unstructured.Unstructured) (*IngressRouteUDP, error) {\n\t\t\ttyped := &IngressRouteUDP{}\n\t\t\treturn typed, ts.unstructuredConverter.scheme.Convert(u, typed, nil)\n\t\t},\n\t\tts.annotationFilter,\n\t\tts.endpointsFromIngressRouteUDP,\n\t)\n}\n\n// oldIngressRouteEndpoints extracts endpoints from all IngressRoute objects\nfunc (ts *traefikSource) oldIngressRouteEndpoints() ([]*endpoint.Endpoint, error) {\n\treturn extractEndpoints(\n\t\tts.oldIngressRouteInformer.Lister(),\n\t\tts.namespace,\n\t\tfunc(u *unstructured.Unstructured) (*IngressRoute, error) {\n\t\t\ttyped := &IngressRoute{}\n\t\t\treturn typed, ts.unstructuredConverter.scheme.Convert(u, typed, nil)\n\t\t},\n\t\tts.annotationFilter,\n\t\tfunc(r *IngressRoute, targets endpoint.Targets) []*endpoint.Endpoint {\n\t\t\treturn ts.endpointsFromIngressRoute(r, targets)\n\t\t},\n\t)\n}\n\n// oldIngressRouteTCPEndpoints extracts endpoints from all IngressRouteTCP objects\nfunc (ts *traefikSource) oldIngressRouteTCPEndpoints() ([]*endpoint.Endpoint, error) {\n\treturn extractEndpoints(\n\t\tts.oldIngressRouteTcpInformer.Lister(),\n\t\tts.namespace,\n\t\tfunc(u *unstructured.Unstructured) (*IngressRouteTCP, error) {\n\t\t\ttyped := &IngressRouteTCP{}\n\t\t\treturn typed, ts.unstructuredConverter.scheme.Convert(u, typed, nil)\n\t\t},\n\t\tts.annotationFilter,\n\t\tts.endpointsFromIngressRouteTCP,\n\t)\n}\n\n// oldIngressRouteUDPEndpoints extracts endpoints from all IngressRouteUDP objects\nfunc (ts *traefikSource) oldIngressRouteUDPEndpoints() ([]*endpoint.Endpoint, error) {\n\treturn extractEndpoints(\n\t\tts.oldIngressRouteUdpInformer.Lister(),\n\t\tts.namespace,\n\t\tfunc(u *unstructured.Unstructured) (*IngressRouteUDP, error) {\n\t\t\ttyped := &IngressRouteUDP{}\n\t\t\treturn typed, ts.unstructuredConverter.scheme.Convert(u, typed, nil)\n\t\t},\n\t\tts.annotationFilter,\n\t\tts.endpointsFromIngressRouteUDP,\n\t)\n}\n\n// endpointsFromIngressRoute extracts the endpoints from a IngressRoute object\nfunc (ts *traefikSource) endpointsFromIngressRoute(ingressRoute *IngressRoute, targets endpoint.Targets) []*endpoint.Endpoint {\n\tvar endpoints []*endpoint.Endpoint\n\n\tresource := fmt.Sprintf(\"ingressroute/%s/%s\", ingressRoute.Namespace, ingressRoute.Name)\n\n\tttl := annotations.TTLFromAnnotations(ingressRoute.Annotations, resource)\n\n\tproviderSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(ingressRoute.Annotations)\n\n\tif !ts.ignoreHostnameAnnotation {\n\t\thostnameList := annotations.HostnamesFromAnnotations(ingressRoute.Annotations)\n\t\tfor _, hostname := range hostnameList {\n\t\t\tendpoints = append(endpoints, endpoint.EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...)\n\t\t}\n\t}\n\n\tfor _, route := range ingressRoute.Spec.Routes {\n\t\tfor _, hostEntry := range traefikHostExtractor.FindAllString(route.Match, -1) {\n\t\t\tfor _, host := range traefikValueProcessor.FindAllString(hostEntry, -1) {\n\t\t\t\thost = strings.Trim(host, \"`\")\n\n\t\t\t\t// Checking for host = * is required, as Host(`*`) can be set\n\t\t\t\tif host != \"*\" && host != \"\" {\n\t\t\t\t\tendpoints = append(endpoints, endpoint.EndpointsForHostname(host, targets, ttl, providerSpecific, setIdentifier, resource)...)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn endpoints\n}\n\n// endpointsFromIngressRouteTCP extracts the endpoints from a IngressRouteTCP object\nfunc (ts *traefikSource) endpointsFromIngressRouteTCP(ingressRoute *IngressRouteTCP, targets endpoint.Targets) []*endpoint.Endpoint {\n\tvar endpoints []*endpoint.Endpoint\n\n\tresource := fmt.Sprintf(\"ingressroutetcp/%s/%s\", ingressRoute.Namespace, ingressRoute.Name)\n\n\tttl := annotations.TTLFromAnnotations(ingressRoute.Annotations, resource)\n\n\tproviderSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(ingressRoute.Annotations)\n\n\tif !ts.ignoreHostnameAnnotation {\n\t\thostnameList := annotations.HostnamesFromAnnotations(ingressRoute.Annotations)\n\t\tfor _, hostname := range hostnameList {\n\t\t\tendpoints = append(endpoints, endpoint.EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...)\n\t\t}\n\t}\n\n\tfor _, route := range ingressRoute.Spec.Routes {\n\t\tfor _, hostEntry := range traefikHostExtractor.FindAllString(route.Match, -1) {\n\t\t\tfor _, host := range traefikValueProcessor.FindAllString(hostEntry, -1) {\n\t\t\t\thost = strings.Trim(host, \"`\")\n\t\t\t\t// Checking for host = * is required, as HostSNI(`*`) can be set\n\t\t\t\t// in the case of TLS passthrough\n\t\t\t\tif host != \"*\" && host != \"\" {\n\t\t\t\t\tendpoints = append(endpoints, endpoint.EndpointsForHostname(host, targets, ttl, providerSpecific, setIdentifier, resource)...)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn endpoints\n}\n\n// endpointsFromIngressRouteUDP extracts the endpoints from a IngressRouteUDP object\nfunc (ts *traefikSource) endpointsFromIngressRouteUDP(ingressRoute *IngressRouteUDP, targets endpoint.Targets) []*endpoint.Endpoint {\n\tvar endpoints []*endpoint.Endpoint\n\n\tresource := fmt.Sprintf(\"ingressrouteudp/%s/%s\", ingressRoute.Namespace, ingressRoute.Name)\n\n\tttl := annotations.TTLFromAnnotations(ingressRoute.Annotations, resource)\n\n\tproviderSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(ingressRoute.Annotations)\n\n\tif !ts.ignoreHostnameAnnotation {\n\t\thostnameList := annotations.HostnamesFromAnnotations(ingressRoute.Annotations)\n\t\tfor _, hostname := range hostnameList {\n\t\t\tendpoints = append(endpoints, endpoint.EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...)\n\t\t}\n\t}\n\n\treturn endpoints\n}\n\nfunc (ts *traefikSource) AddEventHandler(_ context.Context, handler func()) {\n\t// Right now there is no way to remove event handler from informer, see:\n\t// https://github.com/kubernetes/kubernetes/issues/79610\n\tlog.Debug(\"Adding event handler for IngressRoute\")\n\tif ts.ingressRouteInformer != nil {\n\t\t_, _ = ts.ingressRouteInformer.Informer().AddEventHandler(eventHandlerFunc(handler))\n\t}\n\tif ts.oldIngressRouteInformer != nil {\n\t\t_, _ = ts.oldIngressRouteInformer.Informer().AddEventHandler(eventHandlerFunc(handler))\n\t}\n\tlog.Debug(\"Adding event handler for IngressRouteTCP\")\n\tif ts.ingressRouteTcpInformer != nil {\n\t\t_, _ = ts.ingressRouteTcpInformer.Informer().AddEventHandler(eventHandlerFunc(handler))\n\t}\n\tif ts.oldIngressRouteTcpInformer != nil {\n\t\t_, _ = ts.oldIngressRouteTcpInformer.Informer().AddEventHandler(eventHandlerFunc(handler))\n\t}\n\tlog.Debug(\"Adding event handler for IngressRouteUDP\")\n\tif ts.ingressRouteUdpInformer != nil {\n\t\t_, _ = ts.ingressRouteUdpInformer.Informer().AddEventHandler(eventHandlerFunc(handler))\n\t}\n\tif ts.oldIngressRouteUdpInformer != nil {\n\t\t_, _ = ts.oldIngressRouteUdpInformer.Informer().AddEventHandler(eventHandlerFunc(handler))\n\t}\n}\n\n// newTraefikUnstructuredConverter returns a new unstructuredConverter initialized\nfunc newTraefikUnstructuredConverter() (*unstructuredConverter, error) {\n\tuc := &unstructuredConverter{\n\t\tscheme: runtime.NewScheme(),\n\t}\n\n\t// Add the core types we need\n\tuc.scheme.AddKnownTypes(ingressRouteGVR.GroupVersion(), &IngressRoute{}, &IngressRouteList{})\n\tuc.scheme.AddKnownTypes(oldIngressRouteGVR.GroupVersion(), &IngressRoute{}, &IngressRouteList{})\n\tuc.scheme.AddKnownTypes(ingressRouteTCPGVR.GroupVersion(), &IngressRouteTCP{}, &IngressRouteTCPList{})\n\tuc.scheme.AddKnownTypes(oldIngressRouteTCPGVR.GroupVersion(), &IngressRouteTCP{}, &IngressRouteTCPList{})\n\tuc.scheme.AddKnownTypes(ingressRouteUDPGVR.GroupVersion(), &IngressRouteUDP{}, &IngressRouteUDPList{})\n\tuc.scheme.AddKnownTypes(oldIngressRouteUDPGVR.GroupVersion(), &IngressRouteUDP{}, &IngressRouteUDPList{})\n\tif err := scheme.AddToScheme(uc.scheme); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn uc, nil\n}\n\n// Basic redefinition of Traefik 2's CRD: https://github.com/traefik/traefik/tree/v2.8.7/pkg/provider/kubernetes/crd/traefik/v1alpha1\n\n// traefikIngressRouteSpec defines the desired state of IngressRoute.\ntype traefikIngressRouteSpec struct {\n\t// Routes defines the list of routes.\n\tRoutes []traefikRoute `json:\"routes\"`\n}\n\n// traefikRoute holds the HTTP route configuration.\ntype traefikRoute struct {\n\t// Match defines the router's rule.\n\t// More info: https://doc.traefik.io/traefik/v2.9/routing/routers/#rule\n\tMatch string `json:\"match\"`\n}\n\n// IngressRoute is the CRD implementation of a Traefik HTTP Router.\ntype IngressRoute struct {\n\tmetav1.TypeMeta `json:\",inline\"`\n\t// Standard object's metadata.\n\t// More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata\n\tmetav1.ObjectMeta `json:\"metadata\"`\n\n\tSpec traefikIngressRouteSpec `json:\"spec\"`\n}\n\n// IngressRouteList is a collection of IngressRoute.\ntype IngressRouteList struct {\n\tmetav1.TypeMeta `json:\",inline\"`\n\t// Standard object's metadata.\n\t// More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata\n\tmetav1.ListMeta `json:\"metadata\"`\n\n\t// Items is the list of IngressRoute.\n\tItems []IngressRoute `json:\"items\"`\n}\n\n// traefikIngressRouteTCPSpec defines the desired state of IngressRouteTCP.\ntype traefikIngressRouteTCPSpec struct {\n\tRoutes []traefikRouteTCP `json:\"routes\"`\n}\n\n// traefikRouteTCP holds the TCP route configuration.\ntype traefikRouteTCP struct {\n\t// Match defines the router's rule.\n\t// More info: https://doc.traefik.io/traefik/v2.9/routing/routers/#rule_1\n\tMatch string `json:\"match\"`\n}\n\n// IngressRouteTCP is the CRD implementation of a Traefik TCP Router.\ntype IngressRouteTCP struct {\n\tmetav1.TypeMeta `json:\",inline\"`\n\t// Standard object's metadata.\n\t// More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata\n\tmetav1.ObjectMeta `json:\"metadata\"`\n\n\tSpec traefikIngressRouteTCPSpec `json:\"spec\"`\n}\n\n// IngressRouteTCPList is a collection of IngressRouteTCP.\ntype IngressRouteTCPList struct {\n\tmetav1.TypeMeta `json:\",inline\"`\n\t// Standard object's metadata.\n\t// More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata\n\tmetav1.ListMeta `json:\"metadata\"`\n\n\t// Items is the list of IngressRouteTCP.\n\tItems []IngressRouteTCP `json:\"items\"`\n}\n\n// IngressRouteUDP is a CRD implementation of a Traefik UDP Router.\ntype IngressRouteUDP struct {\n\tmetav1.TypeMeta `json:\",inline\"`\n\t// Standard object's metadata.\n\t// More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata\n\tmetav1.ObjectMeta `json:\"metadata\"`\n}\n\n// IngressRouteUDPList is a collection of IngressRouteUDP.\ntype IngressRouteUDPList struct {\n\tmetav1.TypeMeta `json:\",inline\"`\n\t// Standard object's metadata.\n\t// More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata\n\tmetav1.ListMeta `json:\"metadata\"`\n\n\t// Items is the list of IngressRouteUDP.\n\tItems []IngressRouteUDP `json:\"items\"`\n}\n\n// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.\nfunc (in *IngressRoute) DeepCopyInto(out *IngressRoute) {\n\t*out = *in\n\tout.TypeMeta = in.TypeMeta\n\tin.ObjectMeta.DeepCopyInto(&out.ObjectMeta)\n\tin.Spec.DeepCopyInto(&out.Spec)\n}\n\n// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IngressRoute.\nfunc (in *IngressRoute) DeepCopy() *IngressRoute {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(IngressRoute)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\n// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.\nfunc (in *IngressRoute) DeepCopyObject() runtime.Object {\n\tif c := in.DeepCopy(); c != nil {\n\t\treturn c\n\t}\n\treturn nil\n}\n\n// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.\nfunc (in *IngressRouteList) DeepCopyInto(out *IngressRouteList) {\n\t*out = *in\n\tout.TypeMeta = in.TypeMeta\n\tin.ListMeta.DeepCopyInto(&out.ListMeta)\n\tif in.Items != nil {\n\t\tin, out := &in.Items, &out.Items\n\t\t*out = make([]IngressRoute, len(*in))\n\t\tfor i := range *in {\n\t\t\t(*in)[i].DeepCopyInto(&(*out)[i])\n\t\t}\n\t}\n}\n\n// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IngressRouteList.\nfunc (in *IngressRouteList) DeepCopy() *IngressRouteList {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(IngressRouteList)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\n// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.\nfunc (in *IngressRouteList) DeepCopyObject() runtime.Object {\n\tif c := in.DeepCopy(); c != nil {\n\t\treturn c\n\t}\n\treturn nil\n}\n\n// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.\nfunc (in *traefikIngressRouteSpec) DeepCopyInto(out *traefikIngressRouteSpec) {\n\t*out = *in\n\tif in.Routes != nil {\n\t\tin, out := &in.Routes, &out.Routes\n\t\t*out = make([]traefikRoute, len(*in))\n\t\tfor i := range *in {\n\t\t\t(*in)[i].DeepCopyInto(&(*out)[i])\n\t\t}\n\t}\n}\n\n// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IngressRouteSpec.\nfunc (in *traefikIngressRouteSpec) DeepCopy() *traefikIngressRouteSpec {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(traefikIngressRouteSpec)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\n// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.\nfunc (in *traefikRoute) DeepCopyInto(out *traefikRoute) {\n\t*out = *in\n}\n\n// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Route.\nfunc (in *traefikRoute) DeepCopy() *traefikRoute {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(traefikRoute)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\n// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.\nfunc (in *IngressRouteTCP) DeepCopyInto(out *IngressRouteTCP) {\n\t*out = *in\n\tout.TypeMeta = in.TypeMeta\n\tin.ObjectMeta.DeepCopyInto(&out.ObjectMeta)\n\tin.Spec.DeepCopyInto(&out.Spec)\n}\n\n// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IngressRouteTCP.\nfunc (in *IngressRouteTCP) DeepCopy() *IngressRouteTCP {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(IngressRouteTCP)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\n// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.\nfunc (in *IngressRouteTCP) DeepCopyObject() runtime.Object {\n\tif c := in.DeepCopy(); c != nil {\n\t\treturn c\n\t}\n\treturn nil\n}\n\n// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.\nfunc (in *IngressRouteTCPList) DeepCopyInto(out *IngressRouteTCPList) {\n\t*out = *in\n\tout.TypeMeta = in.TypeMeta\n\tin.ListMeta.DeepCopyInto(&out.ListMeta)\n\tif in.Items != nil {\n\t\tin, out := &in.Items, &out.Items\n\t\t*out = make([]IngressRouteTCP, len(*in))\n\t\tfor i := range *in {\n\t\t\t(*in)[i].DeepCopyInto(&(*out)[i])\n\t\t}\n\t}\n}\n\n// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IngressRouteTCPList.\nfunc (in *IngressRouteTCPList) DeepCopy() *IngressRouteTCPList {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(IngressRouteTCPList)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\n// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.\nfunc (in *IngressRouteTCPList) DeepCopyObject() runtime.Object {\n\tif c := in.DeepCopy(); c != nil {\n\t\treturn c\n\t}\n\treturn nil\n}\n\n// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.\nfunc (in *traefikIngressRouteTCPSpec) DeepCopyInto(out *traefikIngressRouteTCPSpec) {\n\t*out = *in\n\tif in.Routes != nil {\n\t\tin, out := &in.Routes, &out.Routes\n\t\t*out = make([]traefikRouteTCP, len(*in))\n\t\tfor i := range *in {\n\t\t\t(*in)[i].DeepCopyInto(&(*out)[i])\n\t\t}\n\t}\n}\n\n// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IngressRouteTCPSpec.\nfunc (in *traefikIngressRouteTCPSpec) DeepCopy() *traefikIngressRouteTCPSpec {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(traefikIngressRouteTCPSpec)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\n// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.\nfunc (in *traefikRouteTCP) DeepCopyInto(out *traefikRouteTCP) {\n\t*out = *in\n}\n\n// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RouteTCP.\nfunc (in *traefikRouteTCP) DeepCopy() *traefikRouteTCP {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(traefikRouteTCP)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\n// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.\nfunc (in *IngressRouteUDP) DeepCopyInto(out *IngressRouteUDP) {\n\t*out = *in\n\tout.TypeMeta = in.TypeMeta\n\tin.ObjectMeta.DeepCopyInto(&out.ObjectMeta)\n}\n\n// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IngressRouteUDP.\nfunc (in *IngressRouteUDP) DeepCopy() *IngressRouteUDP {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(IngressRouteUDP)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\n// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.\nfunc (in *IngressRouteUDP) DeepCopyObject() runtime.Object {\n\tif c := in.DeepCopy(); c != nil {\n\t\treturn c\n\t}\n\treturn nil\n}\n\n// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.\nfunc (in *IngressRouteUDPList) DeepCopyInto(out *IngressRouteUDPList) {\n\t*out = *in\n\tout.TypeMeta = in.TypeMeta\n\tin.ListMeta.DeepCopyInto(&out.ListMeta)\n\tif in.Items != nil {\n\t\tin, out := &in.Items, &out.Items\n\t\t*out = make([]IngressRouteUDP, len(*in))\n\t\tfor i := range *in {\n\t\t\t(*in)[i].DeepCopyInto(&(*out)[i])\n\t\t}\n\t}\n}\n\n// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IngressRouteUDPList.\nfunc (in *IngressRouteUDPList) DeepCopy() *IngressRouteUDPList {\n\tif in == nil {\n\t\treturn nil\n\t}\n\tout := new(IngressRouteUDPList)\n\tin.DeepCopyInto(out)\n\treturn out\n}\n\n// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.\nfunc (in *IngressRouteUDPList) DeepCopyObject() runtime.Object {\n\tif c := in.DeepCopy(); c != nil {\n\t\treturn c\n\t}\n\treturn nil\n}\n\n// GetAnnotations returns the annotations of the IngressRoute.\nfunc (in *IngressRoute) GetAnnotations() map[string]string {\n\treturn in.Annotations\n}\n\n// GetAnnotations returns the annotations of the IngressRouteTCP.\nfunc (in *IngressRouteTCP) GetAnnotations() map[string]string {\n\treturn in.Annotations\n}\n\n// GetAnnotations returns the annotations of the IngressRouteUDP.\nfunc (in *IngressRouteUDP) GetAnnotations() map[string]string {\n\treturn in.Annotations\n}\n\n// extractEndpoints is a generic function that extracts endpoints from Kubernetes resources.\n// It performs the following steps:\n// 1. Lists all objects in the specified namespace using the provided informer.\n// 2. Converts the unstructured objects to the desired type using the convertFunc.\n// 3. Filters the converted objects based on the annotation filter.\n// 4. Generates endpoints for each filtered object using the generateEndpoints function.\n// Returns a list of generated endpoints or an error if any step fails.\nfunc extractEndpoints[T annotations.AnnotatedObject](\n\tinformer cache.GenericLister,\n\tnamespace string,\n\tconvertFunc func(*unstructured.Unstructured) (T, error),\n\tannotationFilter string,\n\tgenerateEndpoints func(T, endpoint.Targets) []*endpoint.Endpoint,\n) ([]*endpoint.Endpoint, error) {\n\tvar endpoints []*endpoint.Endpoint\n\n\tobjs, err := informer.ByNamespace(namespace).List(labels.Everything())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar typedObjs []T\n\tfor _, obj := range objs {\n\t\tunstructuredObj, ok := obj.(*unstructured.Unstructured)\n\t\tif !ok {\n\t\t\treturn nil, errors.New(\"failed to cast to unstructured.Unstructured\")\n\t\t}\n\n\t\ttyped, err := convertFunc(unstructuredObj)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\ttypedObjs = append(typedObjs, typed)\n\t}\n\n\ttypedObjs, err = annotations.Filter(typedObjs, annotationFilter)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor _, item := range typedObjs {\n\t\ttargets := annotations.TargetsFromTargetAnnotation(item.GetAnnotations())\n\n\t\tname := getObjectFullName(item)\n\t\tingressEndpoints := generateEndpoints(item, targets)\n\n\t\tif len(ingressEndpoints) == 0 {\n\t\t\tlog.Debugf(\"No endpoints could be generated from Host %s\", name)\n\t\t\tcontinue\n\t\t}\n\n\t\tlog.Debugf(\"Endpoints generated from %s: %v\", name, ingressEndpoints)\n\t\tendpoints = append(endpoints, ingressEndpoints...)\n\t}\n\n\treturn endpoints, nil\n}\n\nfunc getObjectFullName(obj any) string {\n\tswitch o := obj.(type) {\n\tcase *IngressRouteUDP:\n\t\treturn fmt.Sprintf(\"%s/%s\", o.Namespace, o.Name)\n\tcase *IngressRoute:\n\t\treturn fmt.Sprintf(\"%s/%s\", o.Namespace, o.Name)\n\tcase *IngressRouteTCP:\n\t\treturn fmt.Sprintf(\"%s/%s\", o.Namespace, o.Name)\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n"
  },
  {
    "path": "source/traefik_proxy_test.go",
    "content": "/*\nCopyright 2022 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/mock\"\n\t\"github.com/stretchr/testify/require\"\n\t\"k8s.io/apimachinery/pkg/runtime/schema\"\n\t\"k8s.io/client-go/tools/cache\"\n\n\t\"github.com/stretchr/testify/assert\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured\"\n\t\"k8s.io/apimachinery/pkg/runtime\"\n\tfakeDynamic \"k8s.io/client-go/dynamic/fake\"\n\tfakeKube \"k8s.io/client-go/kubernetes/fake\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n)\n\n// This is a compile-time validation that traefikSource is a Source.\nvar _ Source = &traefikSource{}\n\nconst defaultTraefikNamespace = \"traefik\"\n\nfunc TestTraefikProxyIngressRouteEndpoints(t *testing.T) {\n\tt.Parallel()\n\n\tfor _, ti := range []struct {\n\t\ttitle                    string\n\t\tingressRoute             IngressRoute\n\t\tignoreHostnameAnnotation bool\n\t\texpected                 []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\ttitle: \"IngressRoute with hostname annotation\",\n\t\t\tingressRoute: IngressRoute{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: ingressRouteGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"IngressRoute\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"ingressroute-annotation\",\n\t\t\t\t\tNamespace: defaultTraefikNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"a.example.com\",\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\":   \"target.domain.tld\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":               \"traefik\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"a.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroute/traefik/ingressroute-annotation\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"IngressRoute with host rule\",\n\t\t\tingressRoute: IngressRoute{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: ingressRouteGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"IngressRoute\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"ingressroute-host-match\",\n\t\t\t\t\tNamespace: defaultTraefikNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\": \"target.domain.tld\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":             \"traefik\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: traefikIngressRouteSpec{\n\t\t\t\t\tRoutes: []traefikRoute{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tMatch: \"Host(`b.example.com`)\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"b.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroute/traefik/ingressroute-host-match\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"IngressRoute with hostheader rule\",\n\t\t\tingressRoute: IngressRoute{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: ingressRouteGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"IngressRoute\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"ingressroute-hostheader-match\",\n\t\t\t\t\tNamespace: defaultTraefikNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\": \"target.domain.tld\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":             \"traefik\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: traefikIngressRouteSpec{\n\t\t\t\t\tRoutes: []traefikRoute{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tMatch: \"HostHeader(`c.example.com`)\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"c.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroute/traefik/ingressroute-hostheader-match\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"IngressRoute with multiple host rules\",\n\t\t\tingressRoute: IngressRoute{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: ingressRouteGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"IngressRoute\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"ingressroute-multi-host-match\",\n\t\t\t\t\tNamespace: defaultTraefikNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\": \"target.domain.tld\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":             \"traefik\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: traefikIngressRouteSpec{\n\t\t\t\t\tRoutes: []traefikRoute{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tMatch: \"Host(`d.example.com`) || Host(`e.example.com`)\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"d.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroute/traefik/ingressroute-multi-host-match\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"e.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroute/traefik/ingressroute-multi-host-match\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"IngressRoute with multiple host rules and annotation\",\n\t\t\tingressRoute: IngressRoute{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: ingressRouteGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"IngressRoute\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"ingressroute-multi-host-annotations-match\",\n\t\t\t\t\tNamespace: defaultTraefikNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"f.example.com\",\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\":   \"target.domain.tld\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":               \"traefik\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: traefikIngressRouteSpec{\n\t\t\t\t\tRoutes: []traefikRoute{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tMatch: \"Host(`g.example.com`, `h.example.com`)\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"f.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroute/traefik/ingressroute-multi-host-annotations-match\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"g.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroute/traefik/ingressroute-multi-host-annotations-match\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"h.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroute/traefik/ingressroute-multi-host-annotations-match\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"IngressRoute ignoring annotation\",\n\t\t\tingressRoute: IngressRoute{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: ingressRouteGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"IngressRoute\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"ingressroute-multi-host-annotations-match\",\n\t\t\t\t\tNamespace: defaultTraefikNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"f.example.com\",\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\":   \"target.domain.tld\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":               \"traefik\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: traefikIngressRouteSpec{\n\t\t\t\t\tRoutes: []traefikRoute{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tMatch: \"Host(`g.example.com`, `h.example.com`)\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tignoreHostnameAnnotation: true,\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"g.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroute/traefik/ingressroute-multi-host-annotations-match\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"h.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroute/traefik/ingressroute-multi-host-annotations-match\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"IngressRoute omit wildcard\",\n\t\t\tingressRoute: IngressRoute{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: ingressRouteGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"IngressRoute\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"ingressroute-omit-wildcard-host\",\n\t\t\t\t\tNamespace: defaultTraefikNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\": \"target.domain.tld\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":             \"traefik\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: traefikIngressRouteSpec{\n\t\t\t\t\tRoutes: []traefikRoute{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tMatch: \"Host(`*`)\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\ttitle: \"IngressRoute with provider-specific annotation\",\n\t\t\tingressRoute: IngressRoute{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: ingressRouteGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"IngressRoute\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"ingressroute-provider-specific\",\n\t\t\t\t\tNamespace: defaultTraefikNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\tannotations.HostnameKey:          \"a.example.com\",\n\t\t\t\t\t\tannotations.TargetKey:            \"target.domain.tld\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":    \"traefik\",\n\t\t\t\t\t\tannotations.AWSPrefix + \"weight\": \"10\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"a.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroute/traefik/ingressroute-provider-specific\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{Name: \"aws/weight\", Value: \"10\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(ti.title, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\tfakeKubernetesClient := fakeKube.NewSimpleClientset()\n\t\t\tscheme := runtime.NewScheme()\n\t\t\tscheme.AddKnownTypes(ingressRouteGVR.GroupVersion(), &IngressRoute{}, &IngressRouteList{})\n\t\t\tscheme.AddKnownTypes(ingressRouteTCPGVR.GroupVersion(), &IngressRouteTCP{}, &IngressRouteTCPList{})\n\t\t\tscheme.AddKnownTypes(ingressRouteUDPGVR.GroupVersion(), &IngressRouteUDP{}, &IngressRouteUDPList{})\n\t\t\tscheme.AddKnownTypes(oldIngressRouteGVR.GroupVersion(), &IngressRoute{}, &IngressRouteList{})\n\t\t\tscheme.AddKnownTypes(oldIngressRouteTCPGVR.GroupVersion(), &IngressRouteTCP{}, &IngressRouteTCPList{})\n\t\t\tscheme.AddKnownTypes(oldIngressRouteUDPGVR.GroupVersion(), &IngressRouteUDP{}, &IngressRouteUDPList{})\n\t\t\tfakeDynamicClient := fakeDynamic.NewSimpleDynamicClient(scheme)\n\n\t\t\tir := unstructured.Unstructured{}\n\n\t\t\tingressRouteAsJSON, err := json.Marshal(ti.ingressRoute)\n\t\t\tassert.NoError(t, err)\n\n\t\t\tassert.NoError(t, ir.UnmarshalJSON(ingressRouteAsJSON))\n\n\t\t\t// Create proxy resources\n\t\t\t_, err = fakeDynamicClient.Resource(ingressRouteGVR).Namespace(defaultTraefikNamespace).Create(t.Context(), &ir, metav1.CreateOptions{})\n\t\t\tassert.NoError(t, err)\n\n\t\t\tsource, err := NewTraefikSource(t.Context(), fakeDynamicClient, fakeKubernetesClient,\n\t\t\t\t&Config{\n\t\t\t\t\tNamespace:                defaultTraefikNamespace,\n\t\t\t\t\tAnnotationFilter:         \"kubernetes.io/ingress.class=traefik\",\n\t\t\t\t\tIgnoreHostnameAnnotation: ti.ignoreHostnameAnnotation,\n\t\t\t\t})\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.NotNil(t, source)\n\n\t\t\tcount := &unstructured.UnstructuredList{}\n\t\t\tfor len(count.Items) < 1 {\n\t\t\t\tcount, _ = fakeDynamicClient.Resource(ingressRouteGVR).Namespace(defaultTraefikNamespace).List(t.Context(), metav1.ListOptions{})\n\t\t\t}\n\n\t\t\tendpoints, err := source.Endpoints(t.Context())\n\t\t\tassert.NoError(t, err)\n\t\t\tvalidateEndpoints(t, endpoints, ti.expected)\n\t\t})\n\t}\n}\n\nfunc TestTraefikProxyIngressRouteTCPEndpoints(t *testing.T) {\n\tt.Parallel()\n\n\tfor _, ti := range []struct {\n\t\ttitle                    string\n\t\tingressRouteTCP          IngressRouteTCP\n\t\tignoreHostnameAnnotation bool\n\t\texpected                 []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\ttitle: \"IngressRouteTCP with hostname annotation\",\n\t\t\tingressRouteTCP: IngressRouteTCP{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: ingressRouteTCPGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"IngressRouteTCP\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"ingressroutetcp-annotation\",\n\t\t\t\t\tNamespace: defaultTraefikNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"a.example.com\",\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\":   \"target.domain.tld\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":               \"traefik\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"a.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroutetcp/traefik/ingressroutetcp-annotation\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"IngressRouteTCP with host sni rule\",\n\t\t\tingressRouteTCP: IngressRouteTCP{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: ingressRouteTCPGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"IngressRouteTCP\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"ingressroutetcp-hostsni-match\",\n\t\t\t\t\tNamespace: defaultTraefikNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\": \"target.domain.tld\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":             \"traefik\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: traefikIngressRouteTCPSpec{\n\t\t\t\t\tRoutes: []traefikRouteTCP{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tMatch: \"HostSNI(`b.example.com`)\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"b.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroutetcp/traefik/ingressroutetcp-hostsni-match\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"IngressRouteTCP with multiple host sni rules\",\n\t\t\tingressRouteTCP: IngressRouteTCP{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: ingressRouteTCPGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"IngressRouteTCP\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"ingressroutetcp-multi-host-match\",\n\t\t\t\t\tNamespace: defaultTraefikNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\": \"target.domain.tld\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":             \"traefik\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: traefikIngressRouteTCPSpec{\n\t\t\t\t\tRoutes: []traefikRouteTCP{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tMatch: \"HostSNI(`d.example.com`) || HostSNI(`e.example.com`)\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"d.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroutetcp/traefik/ingressroutetcp-multi-host-match\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"e.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroutetcp/traefik/ingressroutetcp-multi-host-match\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"IngressRouteTCP with multiple host sni rules and annotation\",\n\t\t\tingressRouteTCP: IngressRouteTCP{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: ingressRouteTCPGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"IngressRouteTCP\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"ingressroutetcp-multi-host-annotations-match\",\n\t\t\t\t\tNamespace: defaultTraefikNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"f.example.com\",\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\":   \"target.domain.tld\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":               \"traefik\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: traefikIngressRouteTCPSpec{\n\t\t\t\t\tRoutes: []traefikRouteTCP{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tMatch: \"HostSNI(`g.example.com`, `h.example.com`)\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"f.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroutetcp/traefik/ingressroutetcp-multi-host-annotations-match\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"g.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroutetcp/traefik/ingressroutetcp-multi-host-annotations-match\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"h.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroutetcp/traefik/ingressroutetcp-multi-host-annotations-match\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"IngressRouteTCP ignoring annotation\",\n\t\t\tingressRouteTCP: IngressRouteTCP{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: ingressRouteTCPGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"IngressRouteTCP\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"ingressroutetcp-multi-host-annotations-match\",\n\t\t\t\t\tNamespace: defaultTraefikNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"f.example.com\",\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\":   \"target.domain.tld\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":               \"traefik\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: traefikIngressRouteTCPSpec{\n\t\t\t\t\tRoutes: []traefikRouteTCP{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tMatch: \"HostSNI(`g.example.com`, `h.example.com`)\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tignoreHostnameAnnotation: true,\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"g.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroutetcp/traefik/ingressroutetcp-multi-host-annotations-match\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"h.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroutetcp/traefik/ingressroutetcp-multi-host-annotations-match\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"IngressRouteTCP omit wildcard host sni\",\n\t\t\tingressRouteTCP: IngressRouteTCP{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: ingressRouteTCPGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"IngressRouteTCP\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"ingressroutetcp-omit-wildcard-host\",\n\t\t\t\t\tNamespace: defaultTraefikNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\": \"target.domain.tld\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":             \"traefik\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: traefikIngressRouteTCPSpec{\n\t\t\t\t\tRoutes: []traefikRouteTCP{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tMatch: \"HostSNI(`*`)\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: nil,\n\t\t},\n\t} {\n\t\tt.Run(ti.title, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\tfakeKubernetesClient := fakeKube.NewSimpleClientset()\n\t\t\tscheme := runtime.NewScheme()\n\t\t\tscheme.AddKnownTypes(ingressRouteGVR.GroupVersion(), &IngressRoute{}, &IngressRouteList{})\n\t\t\tscheme.AddKnownTypes(ingressRouteTCPGVR.GroupVersion(), &IngressRouteTCP{}, &IngressRouteTCPList{})\n\t\t\tscheme.AddKnownTypes(ingressRouteUDPGVR.GroupVersion(), &IngressRouteUDP{}, &IngressRouteUDPList{})\n\t\t\tscheme.AddKnownTypes(oldIngressRouteGVR.GroupVersion(), &IngressRoute{}, &IngressRouteList{})\n\t\t\tscheme.AddKnownTypes(oldIngressRouteTCPGVR.GroupVersion(), &IngressRouteTCP{}, &IngressRouteTCPList{})\n\t\t\tscheme.AddKnownTypes(oldIngressRouteUDPGVR.GroupVersion(), &IngressRouteUDP{}, &IngressRouteUDPList{})\n\t\t\tfakeDynamicClient := fakeDynamic.NewSimpleDynamicClient(scheme)\n\n\t\t\tir := unstructured.Unstructured{}\n\n\t\t\tingressRouteAsJSON, err := json.Marshal(ti.ingressRouteTCP)\n\t\t\trequire.NoError(t, err)\n\n\t\t\trequire.NoError(t, ir.UnmarshalJSON(ingressRouteAsJSON))\n\n\t\t\t// Create proxy resources\n\t\t\t_, err = fakeDynamicClient.Resource(ingressRouteTCPGVR).Namespace(defaultTraefikNamespace).Create(t.Context(), &ir, metav1.CreateOptions{})\n\t\t\trequire.NoError(t, err)\n\n\t\t\tsource, err := NewTraefikSource(t.Context(), fakeDynamicClient, fakeKubernetesClient,\n\t\t\t\t&Config{\n\t\t\t\t\tNamespace:                defaultTraefikNamespace,\n\t\t\t\t\tAnnotationFilter:         \"kubernetes.io/ingress.class=traefik\",\n\t\t\t\t\tIgnoreHostnameAnnotation: ti.ignoreHostnameAnnotation,\n\t\t\t\t})\n\t\t\trequire.NoError(t, err)\n\t\t\tassert.NotNil(t, source)\n\n\t\t\tcount := &unstructured.UnstructuredList{}\n\t\t\tfor len(count.Items) < 1 {\n\t\t\t\tcount, _ = fakeDynamicClient.Resource(ingressRouteTCPGVR).Namespace(defaultTraefikNamespace).List(t.Context(), metav1.ListOptions{})\n\t\t\t}\n\n\t\t\tendpoints, err := source.Endpoints(t.Context())\n\t\t\trequire.NoError(t, err)\n\t\t\tvalidateEndpoints(t, endpoints, ti.expected)\n\t\t})\n\t}\n}\n\nfunc TestTraefikProxyIngressRouteUDPEndpoints(t *testing.T) {\n\tt.Parallel()\n\n\tfor _, ti := range []struct {\n\t\ttitle                    string\n\t\tingressRouteUDP          IngressRouteUDP\n\t\tignoreHostnameAnnotation bool\n\t\texpected                 []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\ttitle: \"IngressRouteTCP with hostname annotation\",\n\t\t\tingressRouteUDP: IngressRouteUDP{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: ingressRouteUDPGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"IngressRouteUDP\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"ingressrouteudp-annotation\",\n\t\t\t\t\tNamespace: defaultTraefikNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"a.example.com\",\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\":   \"target.domain.tld\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":               \"traefik\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"a.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressrouteudp/traefik/ingressrouteudp-annotation\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"IngressRouteTCP with multiple hostname annotation\",\n\t\t\tingressRouteUDP: IngressRouteUDP{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: ingressRouteUDPGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"IngressRouteUDP\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"ingressrouteudp-multi-annotation\",\n\t\t\t\t\tNamespace: defaultTraefikNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"a.example.com, b.example.com\",\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\":   \"target.domain.tld\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":               \"traefik\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"a.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressrouteudp/traefik/ingressrouteudp-multi-annotation\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"b.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressrouteudp/traefik/ingressrouteudp-multi-annotation\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"IngressRouteTCP ignoring hostname annotation\",\n\t\t\tingressRouteUDP: IngressRouteUDP{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: ingressRouteUDPGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"IngressRouteUDP\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"ingressrouteudp-annotation\",\n\t\t\t\t\tNamespace: defaultTraefikNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"a.example.com\",\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\":   \"target.domain.tld\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":               \"traefik\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tignoreHostnameAnnotation: true,\n\t\t\texpected:                 nil,\n\t\t},\n\t} {\n\t\tt.Run(ti.title, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\tfakeKubernetesClient := fakeKube.NewSimpleClientset()\n\t\t\tscheme := runtime.NewScheme()\n\t\t\tscheme.AddKnownTypes(ingressRouteGVR.GroupVersion(), &IngressRoute{}, &IngressRouteList{})\n\t\t\tscheme.AddKnownTypes(ingressRouteTCPGVR.GroupVersion(), &IngressRouteTCP{}, &IngressRouteTCPList{})\n\t\t\tscheme.AddKnownTypes(ingressRouteUDPGVR.GroupVersion(), &IngressRouteUDP{}, &IngressRouteUDPList{})\n\t\t\tscheme.AddKnownTypes(oldIngressRouteGVR.GroupVersion(), &IngressRoute{}, &IngressRouteList{})\n\t\t\tscheme.AddKnownTypes(oldIngressRouteTCPGVR.GroupVersion(), &IngressRouteTCP{}, &IngressRouteTCPList{})\n\t\t\tscheme.AddKnownTypes(oldIngressRouteUDPGVR.GroupVersion(), &IngressRouteUDP{}, &IngressRouteUDPList{})\n\t\t\tfakeDynamicClient := fakeDynamic.NewSimpleDynamicClient(scheme)\n\n\t\t\tir := unstructured.Unstructured{}\n\n\t\t\tingressRouteAsJSON, err := json.Marshal(ti.ingressRouteUDP)\n\t\t\tassert.NoError(t, err)\n\n\t\t\tassert.NoError(t, ir.UnmarshalJSON(ingressRouteAsJSON))\n\n\t\t\t// Create proxy resources\n\t\t\t_, err = fakeDynamicClient.Resource(ingressRouteUDPGVR).Namespace(defaultTraefikNamespace).Create(t.Context(), &ir, metav1.CreateOptions{})\n\t\t\tassert.NoError(t, err)\n\n\t\t\tsource, err := NewTraefikSource(t.Context(), fakeDynamicClient, fakeKubernetesClient,\n\t\t\t\t&Config{\n\t\t\t\t\tNamespace:                defaultTraefikNamespace,\n\t\t\t\t\tAnnotationFilter:         \"kubernetes.io/ingress.class=traefik\",\n\t\t\t\t\tIgnoreHostnameAnnotation: ti.ignoreHostnameAnnotation,\n\t\t\t\t})\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.NotNil(t, source)\n\n\t\t\tcount := &unstructured.UnstructuredList{}\n\t\t\tfor len(count.Items) < 1 {\n\t\t\t\tcount, _ = fakeDynamicClient.Resource(ingressRouteUDPGVR).Namespace(defaultTraefikNamespace).List(t.Context(), metav1.ListOptions{})\n\t\t\t}\n\n\t\t\tendpoints, err := source.Endpoints(t.Context())\n\t\t\tassert.NoError(t, err)\n\t\t\tvalidateEndpoints(t, endpoints, ti.expected)\n\t\t})\n\t}\n}\n\nfunc TestTraefikProxyOldIngressRouteEndpoints(t *testing.T) {\n\tt.Parallel()\n\n\tfor _, ti := range []struct {\n\t\ttitle                    string\n\t\tingressRoute             IngressRoute\n\t\tignoreHostnameAnnotation bool\n\t\texpected                 []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\ttitle: \"IngressRoute with hostname annotation\",\n\t\t\tingressRoute: IngressRoute{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: oldIngressRouteGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"IngressRoute\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"ingressroute-annotation\",\n\t\t\t\t\tNamespace: defaultTraefikNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"a.example.com\",\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\":   \"target.domain.tld\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":               \"traefik\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"a.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroute/traefik/ingressroute-annotation\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"IngressRoute with host rule\",\n\t\t\tingressRoute: IngressRoute{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: oldIngressRouteGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"IngressRoute\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"ingressroute-host-match\",\n\t\t\t\t\tNamespace: defaultTraefikNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\": \"target.domain.tld\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":             \"traefik\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: traefikIngressRouteSpec{\n\t\t\t\t\tRoutes: []traefikRoute{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tMatch: \"Host(`b.example.com`)\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"b.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroute/traefik/ingressroute-host-match\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"IngressRoute with hostheader rule\",\n\t\t\tingressRoute: IngressRoute{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: oldIngressRouteGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"IngressRoute\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"ingressroute-hostheader-match\",\n\t\t\t\t\tNamespace: defaultTraefikNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\": \"target.domain.tld\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":             \"traefik\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: traefikIngressRouteSpec{\n\t\t\t\t\tRoutes: []traefikRoute{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tMatch: \"HostHeader(`c.example.com`)\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"c.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroute/traefik/ingressroute-hostheader-match\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"IngressRoute with multiple host rules\",\n\t\t\tingressRoute: IngressRoute{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: oldIngressRouteGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"IngressRoute\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"ingressroute-multi-host-match\",\n\t\t\t\t\tNamespace: defaultTraefikNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\": \"target.domain.tld\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":             \"traefik\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: traefikIngressRouteSpec{\n\t\t\t\t\tRoutes: []traefikRoute{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tMatch: \"Host(`d.example.com`) || Host(`e.example.com`)\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"d.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroute/traefik/ingressroute-multi-host-match\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"e.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroute/traefik/ingressroute-multi-host-match\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"IngressRoute with multiple host rules and annotation\",\n\t\t\tingressRoute: IngressRoute{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: oldIngressRouteGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"IngressRoute\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"ingressroute-multi-host-annotations-match\",\n\t\t\t\t\tNamespace: defaultTraefikNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"f.example.com\",\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\":   \"target.domain.tld\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":               \"traefik\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: traefikIngressRouteSpec{\n\t\t\t\t\tRoutes: []traefikRoute{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tMatch: \"Host(`g.example.com`, `h.example.com`)\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"f.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroute/traefik/ingressroute-multi-host-annotations-match\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"g.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroute/traefik/ingressroute-multi-host-annotations-match\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"h.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroute/traefik/ingressroute-multi-host-annotations-match\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"IngressRoute ignoring annotation\",\n\t\t\tingressRoute: IngressRoute{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: oldIngressRouteGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"IngressRoute\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"ingressroute-multi-host-annotations-match\",\n\t\t\t\t\tNamespace: defaultTraefikNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"f.example.com\",\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\":   \"target.domain.tld\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":               \"traefik\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: traefikIngressRouteSpec{\n\t\t\t\t\tRoutes: []traefikRoute{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tMatch: \"Host(`g.example.com`, `h.example.com`)\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tignoreHostnameAnnotation: true,\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"g.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroute/traefik/ingressroute-multi-host-annotations-match\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"h.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroute/traefik/ingressroute-multi-host-annotations-match\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"IngressRoute omit wildcard\",\n\t\t\tingressRoute: IngressRoute{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: oldIngressRouteGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"IngressRoute\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"ingressroute-omit-wildcard-host\",\n\t\t\t\t\tNamespace: defaultTraefikNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\": \"target.domain.tld\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":             \"traefik\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: traefikIngressRouteSpec{\n\t\t\t\t\tRoutes: []traefikRoute{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tMatch: \"Host(`*`)\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: nil,\n\t\t},\n\t} {\n\t\tt.Run(ti.title, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\tfakeKubernetesClient := fakeKube.NewSimpleClientset()\n\t\t\tscheme := runtime.NewScheme()\n\t\t\tscheme.AddKnownTypes(ingressRouteGVR.GroupVersion(), &IngressRoute{}, &IngressRouteList{})\n\t\t\tscheme.AddKnownTypes(ingressRouteTCPGVR.GroupVersion(), &IngressRouteTCP{}, &IngressRouteTCPList{})\n\t\t\tscheme.AddKnownTypes(ingressRouteUDPGVR.GroupVersion(), &IngressRouteUDP{}, &IngressRouteUDPList{})\n\t\t\tscheme.AddKnownTypes(oldIngressRouteGVR.GroupVersion(), &IngressRoute{}, &IngressRouteList{})\n\t\t\tscheme.AddKnownTypes(oldIngressRouteTCPGVR.GroupVersion(), &IngressRouteTCP{}, &IngressRouteTCPList{})\n\t\t\tscheme.AddKnownTypes(oldIngressRouteUDPGVR.GroupVersion(), &IngressRouteUDP{}, &IngressRouteUDPList{})\n\t\t\tfakeDynamicClient := fakeDynamic.NewSimpleDynamicClient(scheme)\n\n\t\t\tir := unstructured.Unstructured{}\n\n\t\t\tingressRouteAsJSON, err := json.Marshal(ti.ingressRoute)\n\t\t\tassert.NoError(t, err)\n\n\t\t\tassert.NoError(t, ir.UnmarshalJSON(ingressRouteAsJSON))\n\n\t\t\t// Create proxy resources\n\t\t\t_, err = fakeDynamicClient.Resource(oldIngressRouteGVR).Namespace(defaultTraefikNamespace).Create(t.Context(), &ir, metav1.CreateOptions{})\n\t\t\tassert.NoError(t, err)\n\n\t\t\tsource, err := NewTraefikSource(t.Context(), fakeDynamicClient, fakeKubernetesClient,\n\t\t\t\t&Config{\n\t\t\t\t\tNamespace:                defaultTraefikNamespace,\n\t\t\t\t\tAnnotationFilter:         \"kubernetes.io/ingress.class=traefik\",\n\t\t\t\t\tIgnoreHostnameAnnotation: ti.ignoreHostnameAnnotation,\n\t\t\t\t\tTraefikEnableLegacy:      true,\n\t\t\t\t})\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.NotNil(t, source)\n\n\t\t\tcount := &unstructured.UnstructuredList{}\n\t\t\tfor len(count.Items) < 1 {\n\t\t\t\tcount, _ = fakeDynamicClient.Resource(oldIngressRouteGVR).Namespace(defaultTraefikNamespace).List(t.Context(), metav1.ListOptions{})\n\t\t\t}\n\n\t\t\tendpoints, err := source.Endpoints(t.Context())\n\t\t\tassert.NoError(t, err)\n\t\t\tvalidateEndpoints(t, endpoints, ti.expected)\n\t\t})\n\t}\n}\n\nfunc TestTraefikProxyOldIngressRouteTCPEndpoints(t *testing.T) {\n\tt.Parallel()\n\n\tfor _, ti := range []struct {\n\t\ttitle                    string\n\t\tingressRouteTCP          IngressRouteTCP\n\t\tignoreHostnameAnnotation bool\n\t\texpected                 []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\ttitle: \"IngressRouteTCP with hostname annotation\",\n\t\t\tingressRouteTCP: IngressRouteTCP{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: oldIngressRouteTCPGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"IngressRouteTCP\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"ingressroutetcp-annotation\",\n\t\t\t\t\tNamespace: defaultTraefikNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"a.example.com\",\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\":   \"target.domain.tld\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":               \"traefik\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"a.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroutetcp/traefik/ingressroutetcp-annotation\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"IngressRouteTCP with host sni rule\",\n\t\t\tingressRouteTCP: IngressRouteTCP{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: oldIngressRouteTCPGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"IngressRouteTCP\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"ingressroutetcp-hostsni-match\",\n\t\t\t\t\tNamespace: defaultTraefikNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\": \"target.domain.tld\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":             \"traefik\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: traefikIngressRouteTCPSpec{\n\t\t\t\t\tRoutes: []traefikRouteTCP{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tMatch: \"HostSNI(`b.example.com`)\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"b.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroutetcp/traefik/ingressroutetcp-hostsni-match\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"IngressRouteTCP with multiple host sni rules\",\n\t\t\tingressRouteTCP: IngressRouteTCP{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: oldIngressRouteTCPGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"IngressRouteTCP\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"ingressroutetcp-multi-host-match\",\n\t\t\t\t\tNamespace: defaultTraefikNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\": \"target.domain.tld\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":             \"traefik\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: traefikIngressRouteTCPSpec{\n\t\t\t\t\tRoutes: []traefikRouteTCP{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tMatch: \"HostSNI(`d.example.com`) || HostSNI(`e.example.com`)\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"d.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroutetcp/traefik/ingressroutetcp-multi-host-match\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"e.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroutetcp/traefik/ingressroutetcp-multi-host-match\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"IngressRouteTCP with multiple host sni rules and annotation\",\n\t\t\tingressRouteTCP: IngressRouteTCP{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: oldIngressRouteTCPGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"IngressRouteTCP\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"ingressroutetcp-multi-host-annotations-match\",\n\t\t\t\t\tNamespace: defaultTraefikNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"f.example.com\",\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\":   \"target.domain.tld\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":               \"traefik\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: traefikIngressRouteTCPSpec{\n\t\t\t\t\tRoutes: []traefikRouteTCP{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tMatch: \"HostSNI(`g.example.com`, `h.example.com`)\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"f.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroutetcp/traefik/ingressroutetcp-multi-host-annotations-match\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"g.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroutetcp/traefik/ingressroutetcp-multi-host-annotations-match\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"h.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroutetcp/traefik/ingressroutetcp-multi-host-annotations-match\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"IngressRouteTCP ignoring annotation\",\n\t\t\tingressRouteTCP: IngressRouteTCP{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: oldIngressRouteTCPGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"IngressRouteTCP\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"ingressroutetcp-multi-host-annotations-match\",\n\t\t\t\t\tNamespace: defaultTraefikNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"f.example.com\",\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\":   \"target.domain.tld\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":               \"traefik\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: traefikIngressRouteTCPSpec{\n\t\t\t\t\tRoutes: []traefikRouteTCP{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tMatch: \"HostSNI(`g.example.com`, `h.example.com`)\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tignoreHostnameAnnotation: true,\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"g.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroutetcp/traefik/ingressroutetcp-multi-host-annotations-match\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"h.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroutetcp/traefik/ingressroutetcp-multi-host-annotations-match\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"IngressRouteTCP omit wildcard host sni\",\n\t\t\tingressRouteTCP: IngressRouteTCP{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: oldIngressRouteTCPGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"IngressRouteTCP\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"ingressroutetcp-omit-wildcard-host\",\n\t\t\t\t\tNamespace: defaultTraefikNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\": \"target.domain.tld\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":             \"traefik\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tSpec: traefikIngressRouteTCPSpec{\n\t\t\t\t\tRoutes: []traefikRouteTCP{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tMatch: \"HostSNI(`*`)\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: nil,\n\t\t},\n\t} {\n\t\tt.Run(ti.title, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\tfakeKubernetesClient := fakeKube.NewSimpleClientset()\n\t\t\tscheme := runtime.NewScheme()\n\t\t\tscheme.AddKnownTypes(ingressRouteGVR.GroupVersion(), &IngressRoute{}, &IngressRouteList{})\n\t\t\tscheme.AddKnownTypes(ingressRouteTCPGVR.GroupVersion(), &IngressRouteTCP{}, &IngressRouteTCPList{})\n\t\t\tscheme.AddKnownTypes(ingressRouteUDPGVR.GroupVersion(), &IngressRouteUDP{}, &IngressRouteUDPList{})\n\t\t\tscheme.AddKnownTypes(oldIngressRouteGVR.GroupVersion(), &IngressRoute{}, &IngressRouteList{})\n\t\t\tscheme.AddKnownTypes(oldIngressRouteTCPGVR.GroupVersion(), &IngressRouteTCP{}, &IngressRouteTCPList{})\n\t\t\tscheme.AddKnownTypes(oldIngressRouteUDPGVR.GroupVersion(), &IngressRouteUDP{}, &IngressRouteUDPList{})\n\t\t\tfakeDynamicClient := fakeDynamic.NewSimpleDynamicClient(scheme)\n\n\t\t\tir := unstructured.Unstructured{}\n\n\t\t\tingressRouteAsJSON, err := json.Marshal(ti.ingressRouteTCP)\n\t\t\tassert.NoError(t, err)\n\n\t\t\tassert.NoError(t, ir.UnmarshalJSON(ingressRouteAsJSON))\n\n\t\t\t// Create proxy resources\n\t\t\t_, err = fakeDynamicClient.Resource(oldIngressRouteTCPGVR).Namespace(defaultTraefikNamespace).Create(t.Context(), &ir, metav1.CreateOptions{})\n\t\t\tassert.NoError(t, err)\n\n\t\t\tsource, err := NewTraefikSource(t.Context(), fakeDynamicClient, fakeKubernetesClient,\n\t\t\t\t&Config{\n\t\t\t\t\tNamespace:                defaultTraefikNamespace,\n\t\t\t\t\tAnnotationFilter:         \"kubernetes.io/ingress.class=traefik\",\n\t\t\t\t\tIgnoreHostnameAnnotation: ti.ignoreHostnameAnnotation,\n\t\t\t\t\tTraefikEnableLegacy:      true,\n\t\t\t\t})\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.NotNil(t, source)\n\n\t\t\tcount := &unstructured.UnstructuredList{}\n\t\t\tfor len(count.Items) < 1 {\n\t\t\t\tcount, _ = fakeDynamicClient.Resource(oldIngressRouteTCPGVR).Namespace(defaultTraefikNamespace).List(t.Context(), metav1.ListOptions{})\n\t\t\t}\n\n\t\t\tendpoints, err := source.Endpoints(t.Context())\n\t\t\tassert.NoError(t, err)\n\t\t\tvalidateEndpoints(t, endpoints, ti.expected)\n\t\t})\n\t}\n}\n\nfunc TestTraefikProxyOldIngressRouteUDPEndpoints(t *testing.T) {\n\tt.Parallel()\n\n\tfor _, ti := range []struct {\n\t\ttitle                    string\n\t\tingressRouteUDP          IngressRouteUDP\n\t\tignoreHostnameAnnotation bool\n\t\texpected                 []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\ttitle: \"IngressRouteTCP with hostname annotation\",\n\t\t\tingressRouteUDP: IngressRouteUDP{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: oldIngressRouteUDPGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"IngressRouteUDP\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"ingressrouteudp-annotation\",\n\t\t\t\t\tNamespace: defaultTraefikNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"a.example.com\",\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\":   \"target.domain.tld\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":               \"traefik\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"a.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressrouteudp/traefik/ingressrouteudp-annotation\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"IngressRouteTCP with multiple hostname annotation\",\n\t\t\tingressRouteUDP: IngressRouteUDP{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: oldIngressRouteUDPGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"IngressRouteUDP\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"ingressrouteudp-multi-annotation\",\n\t\t\t\t\tNamespace: defaultTraefikNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"a.example.com, b.example.com\",\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\":   \"target.domain.tld\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":               \"traefik\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"a.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressrouteudp/traefik/ingressrouteudp-multi-annotation\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"b.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressrouteudp/traefik/ingressrouteudp-multi-annotation\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"IngressRouteTCP ignoring hostname annotation\",\n\t\t\tingressRouteUDP: IngressRouteUDP{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: oldIngressRouteUDPGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"IngressRouteUDP\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"ingressrouteudp-annotation\",\n\t\t\t\t\tNamespace: defaultTraefikNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"a.example.com\",\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\":   \"target.domain.tld\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":               \"traefik\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tignoreHostnameAnnotation: true,\n\t\t\texpected:                 nil,\n\t\t},\n\t} {\n\t\tt.Run(ti.title, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\tfakeKubernetesClient := fakeKube.NewSimpleClientset()\n\t\t\tscheme := runtime.NewScheme()\n\t\t\tscheme.AddKnownTypes(ingressRouteGVR.GroupVersion(), &IngressRoute{}, &IngressRouteList{})\n\t\t\tscheme.AddKnownTypes(ingressRouteTCPGVR.GroupVersion(), &IngressRouteTCP{}, &IngressRouteTCPList{})\n\t\t\tscheme.AddKnownTypes(ingressRouteUDPGVR.GroupVersion(), &IngressRouteUDP{}, &IngressRouteUDPList{})\n\t\t\tscheme.AddKnownTypes(oldIngressRouteGVR.GroupVersion(), &IngressRoute{}, &IngressRouteList{})\n\t\t\tscheme.AddKnownTypes(oldIngressRouteTCPGVR.GroupVersion(), &IngressRouteTCP{}, &IngressRouteTCPList{})\n\t\t\tscheme.AddKnownTypes(oldIngressRouteUDPGVR.GroupVersion(), &IngressRouteUDP{}, &IngressRouteUDPList{})\n\t\t\tfakeDynamicClient := fakeDynamic.NewSimpleDynamicClient(scheme)\n\n\t\t\tir := unstructured.Unstructured{}\n\n\t\t\tingressRouteAsJSON, err := json.Marshal(ti.ingressRouteUDP)\n\t\t\tassert.NoError(t, err)\n\n\t\t\tassert.NoError(t, ir.UnmarshalJSON(ingressRouteAsJSON))\n\n\t\t\t// Create proxy resources\n\t\t\t_, err = fakeDynamicClient.Resource(oldIngressRouteUDPGVR).Namespace(defaultTraefikNamespace).Create(t.Context(), &ir, metav1.CreateOptions{})\n\t\t\tassert.NoError(t, err)\n\n\t\t\tsource, err := NewTraefikSource(t.Context(), fakeDynamicClient, fakeKubernetesClient,\n\t\t\t\t&Config{\n\t\t\t\t\tNamespace:                defaultTraefikNamespace,\n\t\t\t\t\tAnnotationFilter:         \"kubernetes.io/ingress.class=traefik\",\n\t\t\t\t\tIgnoreHostnameAnnotation: ti.ignoreHostnameAnnotation,\n\t\t\t\t\tTraefikEnableLegacy:      true,\n\t\t\t\t})\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.NotNil(t, source)\n\n\t\t\tcount := &unstructured.UnstructuredList{}\n\t\t\tfor len(count.Items) < 1 {\n\t\t\t\tcount, _ = fakeDynamicClient.Resource(oldIngressRouteUDPGVR).Namespace(defaultTraefikNamespace).List(t.Context(), metav1.ListOptions{})\n\t\t\t}\n\n\t\t\tendpoints, err := source.Endpoints(t.Context())\n\t\t\tassert.NoError(t, err)\n\t\t\tvalidateEndpoints(t, endpoints, ti.expected)\n\t\t})\n\t}\n}\n\nfunc TestTraefikAPIGroupFlags(t *testing.T) {\n\tt.Parallel()\n\n\tfor _, ti := range []struct {\n\t\ttitle                    string\n\t\tingressRoute             IngressRoute\n\t\tgvr                      schema.GroupVersionResource\n\t\tignoreHostnameAnnotation bool\n\t\tenableLegacy             bool\n\t\tdisableNew               bool\n\t\texpected                 []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\ttitle: \"IngressRoute.traefik.containo.us with the legacy API group enabled\",\n\t\t\tingressRoute: IngressRoute{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: oldIngressRouteGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"IngressRoute\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"ingressroute-annotation\",\n\t\t\t\t\tNamespace: defaultTraefikNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"a.example.com\",\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\":   \"target.domain.tld\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":               \"traefik\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tgvr:          oldIngressRouteGVR,\n\t\t\tenableLegacy: true,\n\t\t\tdisableNew:   false,\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"a.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroute/traefik/ingressroute-annotation\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"IngressRoute.traefik.containo.us with the legacy API group disabled\",\n\t\t\tingressRoute: IngressRoute{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: oldIngressRouteGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"IngressRoute\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"ingressroute-annotation\",\n\t\t\t\t\tNamespace: defaultTraefikNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"a.example.com\",\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\":   \"target.domain.tld\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":               \"traefik\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tgvr:          oldIngressRouteGVR,\n\t\t\tenableLegacy: false,\n\t\t\tdisableNew:   false,\n\t\t},\n\t\t{\n\t\t\ttitle: \"IngressRoute.traefik.io with the new API group enabled\",\n\t\t\tingressRoute: IngressRoute{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: ingressRouteGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"IngressRoute\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"ingressroute-annotation\",\n\t\t\t\t\tNamespace: defaultTraefikNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"a.example.com\",\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\":   \"target.domain.tld\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":               \"traefik\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tgvr:          ingressRouteGVR,\n\t\t\tenableLegacy: true,\n\t\t\tdisableNew:   false,\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName:    \"a.example.com\",\n\t\t\t\t\tTargets:    []string{\"target.domain.tld\"},\n\t\t\t\t\tRecordType: endpoint.RecordTypeCNAME,\n\t\t\t\t\tRecordTTL:  0,\n\t\t\t\t\tLabels: endpoint.Labels{\n\t\t\t\t\t\t\"resource\": \"ingressroute/traefik/ingressroute-annotation\",\n\t\t\t\t\t},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"IngressRoute.traefik.io with the new API group disabled\",\n\t\t\tingressRoute: IngressRoute{\n\t\t\t\tTypeMeta: metav1.TypeMeta{\n\t\t\t\t\tAPIVersion: ingressRouteGVR.GroupVersion().String(),\n\t\t\t\t\tKind:       \"IngressRoute\",\n\t\t\t\t},\n\t\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\t\tName:      \"ingressroute-annotation\",\n\t\t\t\t\tNamespace: defaultTraefikNamespace,\n\t\t\t\t\tAnnotations: map[string]string{\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/hostname\": \"a.example.com\",\n\t\t\t\t\t\t\"external-dns.alpha.kubernetes.io/target\":   \"target.domain.tld\",\n\t\t\t\t\t\t\"kubernetes.io/ingress.class\":               \"traefik\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tgvr:          ingressRouteGVR,\n\t\t\tenableLegacy: true,\n\t\t\tdisableNew:   true,\n\t\t},\n\t} {\n\t\tt.Run(ti.title, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\tfakeKubernetesClient := fakeKube.NewSimpleClientset()\n\t\t\tscheme := runtime.NewScheme()\n\t\t\tscheme.AddKnownTypes(ingressRouteGVR.GroupVersion(), &IngressRoute{}, &IngressRouteList{})\n\t\t\tscheme.AddKnownTypes(ingressRouteTCPGVR.GroupVersion(), &IngressRouteTCP{}, &IngressRouteTCPList{})\n\t\t\tscheme.AddKnownTypes(ingressRouteUDPGVR.GroupVersion(), &IngressRouteUDP{}, &IngressRouteUDPList{})\n\t\t\tscheme.AddKnownTypes(oldIngressRouteGVR.GroupVersion(), &IngressRoute{}, &IngressRouteList{})\n\t\t\tscheme.AddKnownTypes(oldIngressRouteTCPGVR.GroupVersion(), &IngressRouteTCP{}, &IngressRouteTCPList{})\n\t\t\tscheme.AddKnownTypes(oldIngressRouteUDPGVR.GroupVersion(), &IngressRouteUDP{}, &IngressRouteUDPList{})\n\t\t\tfakeDynamicClient := fakeDynamic.NewSimpleDynamicClient(scheme)\n\n\t\t\tir := unstructured.Unstructured{}\n\n\t\t\tingressRouteAsJSON, err := json.Marshal(ti.ingressRoute)\n\t\t\tassert.NoError(t, err)\n\n\t\t\tassert.NoError(t, ir.UnmarshalJSON(ingressRouteAsJSON))\n\n\t\t\t// Create proxy resources\n\t\t\t_, err = fakeDynamicClient.Resource(ti.gvr).Namespace(defaultTraefikNamespace).Create(t.Context(), &ir, metav1.CreateOptions{})\n\t\t\tassert.NoError(t, err)\n\n\t\t\tsource, err := NewTraefikSource(t.Context(), fakeDynamicClient, fakeKubernetesClient,\n\t\t\t\t&Config{\n\t\t\t\t\tNamespace:                defaultTraefikNamespace,\n\t\t\t\t\tAnnotationFilter:         \"kubernetes.io/ingress.class=traefik\",\n\t\t\t\t\tIgnoreHostnameAnnotation: ti.ignoreHostnameAnnotation,\n\t\t\t\t\tTraefikEnableLegacy:      ti.enableLegacy,\n\t\t\t\t\tTraefikDisableNew:        ti.disableNew,\n\t\t\t\t})\n\t\t\tassert.NoError(t, err)\n\t\t\tassert.NotNil(t, source)\n\n\t\t\tcount := &unstructured.UnstructuredList{}\n\t\t\tfor len(count.Items) < 1 {\n\t\t\t\tcount, _ = fakeDynamicClient.Resource(ti.gvr).Namespace(defaultTraefikNamespace).List(t.Context(), metav1.ListOptions{})\n\t\t\t}\n\n\t\t\tendpoints, err := source.Endpoints(t.Context())\n\t\t\tassert.NoError(t, err)\n\t\t\tvalidateEndpoints(t, endpoints, ti.expected)\n\t\t})\n\t}\n}\n\nfunc TestAddEventHandler_AllBranches(t *testing.T) {\n\tctx := t.Context()\n\thandlerCalled := false\n\thandler := func() { handlerCalled = true }\n\n\tinf := testInformer{}\n\tfakeInformer := new(FakeInformer)\n\tfakeInformer.On(\"Informer\").Return(&inf)\n\n\tcases := []struct {\n\t\tname string\n\t\tts   *traefikSource\n\t\twant int\n\t}{\n\t\t{\"all nil\", &traefikSource{}, 0},\n\t\t{\"all set\", &traefikSource{\n\t\t\tingressRouteInformer:       fakeInformer,\n\t\t\toldIngressRouteInformer:    fakeInformer,\n\t\t\tingressRouteTcpInformer:    fakeInformer,\n\t\t\toldIngressRouteTcpInformer: fakeInformer,\n\t\t\tingressRouteUdpInformer:    fakeInformer,\n\t\t\toldIngressRouteUdpInformer: fakeInformer,\n\t\t}, 6},\n\t\t{\"some set\", &traefikSource{\n\t\t\tingressRouteInformer:       fakeInformer,\n\t\t\toldIngressRouteInformer:    fakeInformer,\n\t\t\tingressRouteTcpInformer:    nil,\n\t\t\toldIngressRouteTcpInformer: fakeInformer,\n\t\t\tingressRouteUdpInformer:    nil,\n\t\t\toldIngressRouteUdpInformer: nil,\n\t\t}, 3},\n\t}\n\n\tfor _, test := range cases {\n\t\tt.Run(test.name, func(t *testing.T) {\n\t\t\ttest.ts.AddEventHandler(ctx, handler)\n\t\t\tassert.Equal(t, test.want, inf.times)\n\t\t\tassert.False(t, handlerCalled)\n\n\t\t\tif test.want > 0 {\n\t\t\t\tfakeInformer.AssertExpectations(t)\n\t\t\t\tfakeInformer.AssertCalled(t, \"Informer\")\n\t\t\t} else {\n\t\t\t\tfakeInformer.AssertNotCalled(t, \"Informer\")\n\t\t\t}\n\t\t\t// reset the call count\n\t\t\tinf.times = 0\n\t\t})\n\t}\n}\n\ntype FakeInformer struct {\n\tmock.Mock\n\tlister cache.GenericLister\n}\n\nfunc (f *FakeInformer) Informer() cache.SharedIndexInformer {\n\targs := f.Called()\n\treturn args.Get(0).(cache.SharedIndexInformer)\n}\n\nfunc (f *FakeInformer) Lister() cache.GenericLister {\n\treturn f.lister\n}\n\ntype testInformer struct {\n\tcache.SharedIndexInformer\n\n\ttimes int\n}\n\nfunc (t *testInformer) AddEventHandler(_ cache.ResourceEventHandler) (cache.ResourceEventHandlerRegistration, error) {\n\tt.times += 1\n\treturn nil, fmt.Errorf(\"not implemented\")\n}\n"
  },
  {
    "path": "source/types/types.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage types\n\ntype Type = string\n\nconst (\n\tNode                Type = \"node\"\n\tService             Type = \"service\"\n\tIngress             Type = \"ingress\"\n\tPod                 Type = \"pod\"\n\tGatewayHttpRoute    Type = \"gateway-httproute\"\n\tGatewayGrpcRoute    Type = \"gateway-grpcroute\"\n\tGatewayTlsRoute     Type = \"gateway-tlsroute\"\n\tGatewayTcpRoute     Type = \"gateway-tcproute\"\n\tGatewayUdpRoute     Type = \"gateway-udproute\"\n\tIstioGateway        Type = \"istio-gateway\"\n\tIstioVirtualService Type = \"istio-virtualservice\"\n\tAmbassadorHost      Type = \"ambassador-host\"\n\tContourHTTPProxy    Type = \"contour-httpproxy\"\n\tGlooProxy           Type = \"gloo-proxy\"\n\tTraefikProxy        Type = \"traefik-proxy\"\n\tOpenShiftRoute      Type = \"openshift-route\"\n\tFake                Type = \"fake\"\n\tConnector           Type = \"connector\"\n\tCRD                 Type = \"crd\"\n\tSkipperRouteGroup   Type = \"skipper-routegroup\"\n\tKongTCPIngress      Type = \"kong-tcpingress\"\n\tF5VirtualServer     Type = \"f5-virtualserver\"\n\tF5TransportServer   Type = \"f5-transportserver\"\n\tUnstructured        Type = \"unstructured\"\n)\n"
  },
  {
    "path": "source/unstructured.go",
    "content": "/*\nCopyright 2026 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"maps\"\n\t\"slices\"\n\t\"strings\"\n\t\"text/template\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured\"\n\t\"k8s.io/apimachinery/pkg/runtime/schema\"\n\t\"k8s.io/client-go/discovery\"\n\t\"k8s.io/client-go/discovery/cached/memory\"\n\t\"k8s.io/client-go/dynamic\"\n\t\"k8s.io/client-go/dynamic/dynamicinformer\"\n\tkubeinformers \"k8s.io/client-go/informers\"\n\t\"k8s.io/client-go/kubernetes\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/pkg/events\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n\t\"sigs.k8s.io/external-dns/source/fqdn\"\n\t\"sigs.k8s.io/external-dns/source/informers\"\n\t\"sigs.k8s.io/external-dns/source/types\"\n)\n\n// unstructuredSource is a Source that creates DNS records from unstructured resources.\n//\n// +externaldns:source:name=unstructured\n// +externaldns:source:category=Custom Resources\n// +externaldns:source:description=Creates DNS entries from unstructured Kubernetes resources\n// +externaldns:source:resources=Unstructured\n// +externaldns:source:filters=annotation,label\n// +externaldns:source:namespace=all,single\n// +externaldns:source:fqdn-template=true\n// +externaldns:source:provider-specific=false\n// +externaldns:source:events=false\ntype unstructuredSource struct {\n\tcombineFqdnAnnotation bool\n\tfqdnTemplate          *template.Template\n\ttargetTemplate        *template.Template\n\tfqdnTargetTemplate    *template.Template\n\tinformers             []kubeinformers.GenericInformer\n}\n\n// NewUnstructuredFQDNSource creates a new unstructuredSource.\nfunc NewUnstructuredFQDNSource(\n\tctx context.Context,\n\tdynamicClient dynamic.Interface,\n\tkubeClient kubernetes.Interface,\n\tcfg *Config,\n) (Source, error) {\n\tfqdnTmpl, err := fqdn.ParseTemplate(cfg.FQDNTemplate)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\ttargetTmpl, err := fqdn.ParseTemplate(cfg.TargetTemplate)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfqdnTargetTmpl, err := fqdn.ParseTemplate(cfg.FQDNTargetTemplate)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tgvrs, err := discoverResources(kubeClient, cfg.UnstructuredResources)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Create a single informer factory for all resources\n\tinformerFactory := dynamicinformer.NewFilteredDynamicSharedInformerFactory(\n\t\tdynamicClient,\n\t\t0,\n\t\tcfg.Namespace,\n\t\tnil,\n\t)\n\n\t// Create informers for each resource\n\tresourceInformers := make([]kubeinformers.GenericInformer, 0, len(gvrs))\n\tfor _, gvr := range gvrs {\n\t\tinformer := informerFactory.ForResource(gvr)\n\n\t\t// Add indexers for efficient lookups by namespace and labels (must be before AddEventHandler)\n\t\terr := informer.Informer().AddIndexers(\n\t\t\tinformers.IndexerWithOptions[*unstructured.Unstructured](\n\t\t\t\tinformers.IndexSelectorWithAnnotationFilter(cfg.AnnotationFilter),\n\t\t\t\tinformers.IndexSelectorWithLabelSelector(cfg.LabelFilter),\n\t\t\t),\n\t\t)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\t_, _ = informer.Informer().AddEventHandler(informers.DefaultEventHandler())\n\t\tresourceInformers = append(resourceInformers, informer)\n\t}\n\n\tinformerFactory.Start(ctx.Done())\n\tif err := informers.WaitForDynamicCacheSync(ctx, informerFactory); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &unstructuredSource{\n\t\tfqdnTemplate:          fqdnTmpl,\n\t\ttargetTemplate:        targetTmpl,\n\t\tfqdnTargetTemplate:    fqdnTargetTmpl,\n\t\tinformers:             resourceInformers,\n\t\tcombineFqdnAnnotation: cfg.CombineFQDNAndAnnotation,\n\t}, nil\n}\n\n// Endpoints returns the list of endpoints from unstructured resources.\nfunc (us *unstructuredSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) {\n\tvar endpoints []*endpoint.Endpoint\n\n\tfor _, informer := range us.informers {\n\t\tresourceEndpoints, err := us.endpointsFromInformer(informer)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tendpoints = append(endpoints, resourceEndpoints...)\n\t}\n\n\treturn endpoints, nil\n}\n\n// endpointsFromInformer returns endpoints for a single resource type.\nfunc (us *unstructuredSource) endpointsFromInformer(informer kubeinformers.GenericInformer) ([]*endpoint.Endpoint, error) {\n\tvar endpoints []*endpoint.Endpoint\n\n\t// Get objects that match the indexer filter (annotation and label selectors)\n\tindexKeys := informer.Informer().GetIndexer().ListIndexFuncValues(informers.IndexWithSelectors)\n\tif len(indexKeys) == 0 {\n\t\treturn nil, nil\n\t}\n\tfor _, key := range indexKeys {\n\t\tobj, err := informers.GetByKey[*unstructured.Unstructured](informer.Informer().GetIndexer(), key)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tel := newUnstructuredWrapper(obj)\n\n\t\tif annotations.IsControllerMismatch(el, types.Unstructured) {\n\t\t\tcontinue\n\t\t}\n\n\t\thosts := annotations.HostnamesFromAnnotations(el.GetAnnotations())\n\t\taddrs := annotations.TargetsFromTargetAnnotation(el.GetAnnotations())\n\t\tannotationEdps := EndpointsForHostsAndTargets(hosts, addrs)\n\n\t\tfqdnTargetEdps, err := fqdn.CombineWithTemplatedEndpoints(\n\t\t\tannotationEdps, us.fqdnTargetTemplate, us.combineFqdnAnnotation,\n\t\t\tfunc() ([]*endpoint.Endpoint, error) {\n\t\t\t\treturn us.endpointsFromFQDNTargetTemplate(el)\n\t\t\t},\n\t\t)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tedps, err := fqdn.CombineWithTemplatedEndpoints(\n\t\t\tfqdnTargetEdps, us.fqdnTemplate, us.combineFqdnAnnotation,\n\t\t\tfunc() ([]*endpoint.Endpoint, error) {\n\t\t\t\treturn us.endpointsFromTemplate(el)\n\t\t\t},\n\t\t)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tttl := annotations.TTLFromAnnotations(el.GetAnnotations(),\n\t\t\tfmt.Sprintf(\"%s/%s\", strings.ToLower(el.GetKind()), el.GetName()))\n\n\t\tfor _, ep := range edps {\n\t\t\tep.\n\t\t\t\tWithRefObject(events.NewObjectReference(el, types.Unstructured)).\n\t\t\t\tWithLabel(endpoint.ResourceLabelKey,\n\t\t\t\t\tfmt.Sprintf(\"%s/%s/%s\", strings.ToLower(el.GetKind()), el.GetNamespace(), el.GetName())).\n\t\t\t\tWithMinTTL(int64(ttl))\n\t\t\tendpoints = append(endpoints, ep)\n\t\t}\n\t}\n\n\treturn MergeEndpoints(endpoints), nil\n}\n\n// endpointsFromTemplate creates endpoints using DNS names from the FQDN template.\nfunc (us *unstructuredSource) endpointsFromTemplate(el *unstructuredWrapper) ([]*endpoint.Endpoint, error) {\n\thostnames, err := fqdn.ExecTemplate(us.fqdnTemplate, el)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif len(hostnames) == 0 {\n\t\treturn nil, nil\n\t}\n\n\tvar targets []string\n\tif us.targetTemplate != nil {\n\t\ttargets, err = fqdn.ExecTemplate(us.targetTemplate, el)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn EndpointsForHostsAndTargets(hostnames, targets), nil\n}\n\n// endpointsFromFQDNTargetTemplate creates endpoints from a template that returns host:target pairs.\n// Each pair creates a single endpoint with 1:1 mapping between host and target.\nfunc (us *unstructuredSource) endpointsFromFQDNTargetTemplate(el *unstructuredWrapper) ([]*endpoint.Endpoint, error) {\n\tpairs, err := fqdn.ExecTemplate(us.fqdnTargetTemplate, el)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif len(pairs) == 0 {\n\t\treturn nil, nil\n\t}\n\n\tendpoints := make([]*endpoint.Endpoint, 0, len(pairs))\n\tfor _, pair := range pairs {\n\t\t// Split at first colon (hostnames can't contain colons, IPv6 targets can)\n\t\tparts := strings.SplitN(pair, \":\", 2)\n\t\tif len(parts) != 2 {\n\t\t\tlog.Debugf(\"Skipping invalid host:target pair %q from %s %s/%s: missing ':' separator\",\n\t\t\t\tpair, strings.ToLower(el.GetKind()), el.GetNamespace(), el.GetName())\n\t\t\tcontinue\n\t\t}\n\n\t\thost := strings.TrimSpace(parts[0])\n\t\ttarget := strings.TrimSpace(parts[1])\n\t\tif host == \"\" || target == \"\" {\n\t\t\tlog.Debugf(\"Skipping incomplete host:target pair %q from %s %s/%s: field may not yet be populated\",\n\t\t\t\tpair, strings.ToLower(el.GetKind()), el.GetNamespace(), el.GetName())\n\t\t\tcontinue\n\t\t}\n\n\t\tendpoints = append(endpoints, endpoint.NewEndpoint(host, endpoint.SuitableType(target), target))\n\t}\n\n\treturn MergeEndpoints(endpoints), nil\n}\n\n// AddEventHandler adds an event handler that is called when resources change.\nfunc (us *unstructuredSource) AddEventHandler(_ context.Context, handler func()) {\n\tfor _, informer := range us.informers {\n\t\t_, _ = informer.Informer().AddEventHandler(eventHandlerFunc(handler))\n\t}\n}\n\n// unstructuredWrapper wraps an unstructured.Unstructured to provide both\n// typed-style template access ({{ .Name }}, {{ .Namespace }}) and raw map access\n// ({{ .Spec.field }}, {{ index .Status.interfaces 0 \"ipAddress\" }}).\n// By embedding *unstructured.Unstructured, it implements kubeObject (runtime.Object + metav1.Object).\ntype unstructuredWrapper struct {\n\t*unstructured.Unstructured\n\n\t// Typed-style convenience fields (like typed Kubernetes objects)\n\tName        string\n\tNamespace   string\n\tKind        string\n\tAPIVersion  string\n\tLabels      map[string]string\n\tAnnotations map[string]string\n\n\t// Raw map sections for custom field access\n\tMetadata map[string]any\n\tSpec     map[string]any\n\tStatus   map[string]any\n}\n\nfunc (u *unstructuredWrapper) GetObjectMeta() metav1.Object {\n\treturn u.Unstructured\n}\n\n// newUnstructuredWrapper creates a wrapper around an *unstructured.Unstructured,\n// exposing typed convenience fields for templates alongside raw map sections.\nfunc newUnstructuredWrapper(u *unstructured.Unstructured) *unstructuredWrapper {\n\tw := &unstructuredWrapper{\n\t\tUnstructured: u,\n\t\tName:         u.GetName(),\n\t\tNamespace:    u.GetNamespace(),\n\t\tKind:         u.GetKind(),\n\t\tAPIVersion:   u.GetAPIVersion(),\n\t\tLabels:       u.GetLabels(),\n\t\tAnnotations:  u.GetAnnotations(),\n\t}\n\n\t// Extract common sections\n\tif metadata, ok := u.Object[\"metadata\"].(map[string]any); ok {\n\t\tw.Metadata = metadata\n\t}\n\tif spec, ok := u.Object[\"spec\"].(map[string]any); ok {\n\t\tw.Spec = spec\n\t}\n\tif status, ok := u.Object[\"status\"].(map[string]any); ok {\n\t\tw.Status = status\n\t}\n\n\treturn w\n}\n\n// discoverResources parses and validates resource identifiers against the cluster.\n// It uses a cached discovery client to minimize API calls.\nfunc discoverResources(kubeClient kubernetes.Interface, resources []string) ([]schema.GroupVersionResource, error) {\n\tcachedDiscovery := memory.NewMemCacheClient(kubeClient.Discovery())\n\tgvrs := make([]schema.GroupVersionResource, 0, len(resources))\n\n\tfor _, r := range resources {\n\t\t// Handle core API resources (e.g., \"configmaps.v1\" -> \"configmaps.v1.\")\n\t\tif strings.Count(r, \".\") == 1 {\n\t\t\tr += \".\"\n\t\t}\n\n\t\tgvr, _ := schema.ParseResourceArg(r)\n\t\tif gvr == nil {\n\t\t\treturn nil, fmt.Errorf(\"invalid resource identifier %q: expected format resource.version.group (e.g., certificates.v1.cert-manager.io)\", r)\n\t\t}\n\n\t\tif err := validateResource(cachedDiscovery, *gvr); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tgvrs = append(gvrs, *gvr)\n\t}\n\n\treturn gvrs, nil\n}\n\n// validateResource validates that a resource exists in the cluster.\n// It uses the Discovery API to verify the resource is available.\nfunc validateResource(discoveryClient discovery.DiscoveryInterface, gvr schema.GroupVersionResource) error {\n\tgv := gvr.GroupVersion().String()\n\n\tapiResourceList, err := discoveryClient.ServerResourcesForGroupVersion(gv)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to discover resources for %q: %w\", gv, err)\n\t}\n\n\tfor i := range apiResourceList.APIResources {\n\t\tif apiResourceList.APIResources[i].Name == gvr.Resource {\n\t\t\treturn nil\n\t\t}\n\t}\n\n\treturn fmt.Errorf(\"resource %q not found in %q\", gvr.Resource, gv)\n}\n\n// EndpointsForHostsAndTargets creates endpoints by grouping targets by record type\n// and creating an endpoint for each hostname/record-type combination.\n// The function returns endpoints in deterministic order (sorted by record type).\nfunc EndpointsForHostsAndTargets(hostnames, targets []string) []*endpoint.Endpoint {\n\tif len(hostnames) == 0 || len(targets) == 0 {\n\t\treturn nil\n\t}\n\n\t// Deduplicate hostnames\n\thostSet := make(map[string]struct{}, len(hostnames))\n\tfor _, h := range hostnames {\n\t\thostSet[h] = struct{}{}\n\t}\n\tsortedHosts := slices.Sorted(maps.Keys(hostSet))\n\n\t// Group and deduplicate targets by record type\n\ttargetsByType := make(map[string]map[string]struct{})\n\tfor _, target := range targets {\n\t\trecordType := endpoint.SuitableType(target)\n\t\tif targetsByType[recordType] == nil {\n\t\t\ttargetsByType[recordType] = make(map[string]struct{})\n\t\t}\n\t\ttargetsByType[recordType][target] = struct{}{}\n\t}\n\n\t// Resolve to sorted slices once\n\tsortedTypes := slices.Sorted(maps.Keys(targetsByType))\n\tsortedTargets := make(map[string][]string, len(targetsByType))\n\tfor _, recordType := range sortedTypes {\n\t\tsortedTargets[recordType] = slices.Sorted(maps.Keys(targetsByType[recordType]))\n\t}\n\n\tendpoints := make([]*endpoint.Endpoint, 0, len(sortedHosts)*len(sortedTypes))\n\tfor _, hostname := range sortedHosts {\n\t\tfor _, recordType := range sortedTypes {\n\t\t\tendpoints = append(endpoints, endpoint.NewEndpoint(hostname, recordType, sortedTargets[recordType]...))\n\t\t}\n\t}\n\n\treturn endpoints\n}\n"
  },
  {
    "path": "source/unstructured_converter.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\tprojectcontour \"github.com/projectcontour/contour/apis/projectcontour/v1\"\n\t\"k8s.io/apimachinery/pkg/runtime\"\n\t\"k8s.io/client-go/kubernetes/scheme\"\n)\n\n// UnstructuredConverter handles conversions between unstructured.Unstructured and Contour types\ntype UnstructuredConverter struct {\n\t// scheme holds an initializer for converting Unstructured to a type\n\tscheme *runtime.Scheme\n}\n\n// NewUnstructuredConverter returns a new UnstructuredConverter initialized\nfunc NewUnstructuredConverter() (*UnstructuredConverter, error) {\n\tuc := &UnstructuredConverter{\n\t\tscheme: runtime.NewScheme(),\n\t}\n\n\t// Setup converter to understand custom CRD types\n\t_ = projectcontour.AddToScheme(uc.scheme)\n\n\t// Add the core types we need\n\tif err := scheme.AddToScheme(uc.scheme); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn uc, nil\n}\n"
  },
  {
    "path": "source/unstructured_fqdn_test.go",
    "content": "/*\nCopyright 2026 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\t\"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n\t\"sigs.k8s.io/external-dns/source/fqdn\"\n)\n\nfunc TestUnstructuredFqdnTemplatingExamples(t *testing.T) {\n\ttype cfg struct {\n\t\tresources          []string\n\t\tfqdnTemplate       string\n\t\ttargetTemplate     string\n\t\tfqdnTargetTemplate string\n\t\tlabelFilter        string\n\t\tcombine            bool\n\t}\n\tfor _, tt := range []struct {\n\t\ttitle    string\n\t\tcfg      cfg\n\t\tobjects  []*unstructured.Unstructured\n\t\texpected []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\ttitle: \"ConfigMap with comma-separated hostnames\",\n\t\t\tcfg: cfg{\n\t\t\t\tresources:      []string{\"configmaps.v1\"},\n\t\t\t\tfqdnTemplate:   `{{index .Object.data \"hostnames\"}}`,\n\t\t\t\ttargetTemplate: `{{index .Object.data \"target\"}}`,\n\t\t\t},\n\t\t\tobjects: []*unstructured.Unstructured{\n\t\t\t\t{\n\t\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\t\"apiVersion\": \"v1\",\n\t\t\t\t\t\t\"kind\":       \"ConfigMap\",\n\t\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\t\"name\":      \"multi-dns\",\n\t\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\t\t\"annotations\": map[string]any{\n\t\t\t\t\t\t\t\tannotations.ControllerKey: annotations.ControllerValue,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"data\": map[string]any{\n\t\t\t\t\t\t\t\"hostnames\": \"entry1.internal.tld,entry2.example.tld\",\n\t\t\t\t\t\t\t\"target\":    \"10.10.10.10\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"entry1.internal.tld\", endpoint.RecordTypeA, \"10.10.10.10\").\n\t\t\t\t\tWithLabel(endpoint.ResourceLabelKey, \"configmap/default/multi-dns\"),\n\t\t\t\tendpoint.NewEndpoint(\"entry2.example.tld\", endpoint.RecordTypeA, \"10.10.10.10\").\n\t\t\t\t\tWithLabel(endpoint.ResourceLabelKey, \"configmap/default/multi-dns\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"with IP address\",\n\t\t\tcfg: cfg{\n\t\t\t\tresources:      []string{\"virtualmachineinstances.v1.kubevirt.io\"},\n\t\t\t\tfqdnTemplate:   `{{.Name}}.{{index .Status.interfaces 0 \"name\"}}.vmi.com`,\n\t\t\t\ttargetTemplate: `{{index .Status.interfaces 0 \"ipAddress\"}}`,\n\t\t\t},\n\t\t\tobjects: []*unstructured.Unstructured{\n\t\t\t\t{\n\t\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\t\"apiVersion\": \"kubevirt.io/v1\",\n\t\t\t\t\t\t\"kind\":       \"VirtualMachineInstance\",\n\t\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\t\"name\":      \"my-vm\",\n\t\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"status\": map[string]any{\n\t\t\t\t\t\t\t\"interfaces\": []any{\n\t\t\t\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\t\t\t\"ipAddress\": \"10.244.1.50\",\n\t\t\t\t\t\t\t\t\t\"name\":      \"main\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"my-vm.main.vmi.com\", endpoint.RecordTypeA, \"10.244.1.50\").\n\t\t\t\t\tWithLabel(endpoint.ResourceLabelKey, \"virtualmachineinstance/default/my-vm\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"Crossplane RDSInstance with endpoint\",\n\t\t\tcfg: cfg{\n\t\t\t\tresources:      []string{\"rdsinstances.v1alpha1.rds.aws.crossplane.io\"},\n\t\t\t\tfqdnTemplate:   \"{{.Name}}.db.example.com\",\n\t\t\t\ttargetTemplate: \"{{.Status.atProvider.endpoint.address}}\",\n\t\t\t},\n\t\t\tobjects: []*unstructured.Unstructured{\n\t\t\t\t{\n\t\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\t\"apiVersion\": \"rds.aws.crossplane.io/v1alpha1\",\n\t\t\t\t\t\t\"kind\":       \"RDSInstance\",\n\t\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\t\"name\":      \"prod-postgres\",\n\t\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"status\": map[string]any{\n\t\t\t\t\t\t\t\"atProvider\": map[string]any{\n\t\t\t\t\t\t\t\t\"endpoint\": map[string]any{\n\t\t\t\t\t\t\t\t\t\"address\": \"prod-postgres.abc123.us-east-1.rds\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"prod-postgres.db.example.com\", endpoint.RecordTypeCNAME, \"prod-postgres.abc123.us-east-1.rds.\").\n\t\t\t\t\tWithLabel(endpoint.ResourceLabelKey, \"rdsinstance/default/prod-postgres\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"multiple VirtualMachineInstances\",\n\t\t\tcfg: cfg{\n\t\t\t\tresources:      []string{\"virtualmachineinstances.v1.kubevirt.io\"},\n\t\t\t\tfqdnTemplate:   \"{{.Name}}.vmi.example.com\",\n\t\t\t\ttargetTemplate: `{{index .Status.interfaces 0 \"ipAddress\"}}`,\n\t\t\t},\n\t\t\tobjects: []*unstructured.Unstructured{\n\t\t\t\t{\n\t\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\t\"apiVersion\": \"kubevirt.io/v1\",\n\t\t\t\t\t\t\"kind\":       \"VirtualMachineInstance\",\n\t\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\t\"name\":      \"vm-one\",\n\t\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"status\": map[string]any{\n\t\t\t\t\t\t\t\"interfaces\": []any{\n\t\t\t\t\t\t\t\tmap[string]any{\"ipAddress\": \"10.244.1.10\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\t\"apiVersion\": \"kubevirt.io/v1\",\n\t\t\t\t\t\t\"kind\":       \"VirtualMachineInstance\",\n\t\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\t\"name\":      \"vm-two\",\n\t\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"status\": map[string]any{\n\t\t\t\t\t\t\t\"interfaces\": []any{\n\t\t\t\t\t\t\t\tmap[string]any{\"ipAddress\": \"10.244.1.20\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"vm-one.vmi.example.com\", endpoint.RecordTypeA, \"10.244.1.10\").\n\t\t\t\t\tWithLabel(endpoint.ResourceLabelKey, \"virtualmachineinstance/default/vm-one\"),\n\t\t\t\tendpoint.NewEndpoint(\"vm-two.vmi.example.com\", endpoint.RecordTypeA, \"10.244.1.20\").\n\t\t\t\t\tWithLabel(endpoint.ResourceLabelKey, \"virtualmachineinstance/default/vm-two\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"multiple hosts from template\",\n\t\t\tcfg: cfg{\n\t\t\t\tresources:      []string{\"proxyservices.v1beta1.proxyconfigs.acme.corp\"},\n\t\t\t\tfqdnTemplate:   \"{{.Name}}.mesh.com,{{.Name}}.internal.com\",\n\t\t\t\ttargetTemplate: \"{{index .Spec.hosts 0}}\",\n\t\t\t},\n\t\t\tobjects: []*unstructured.Unstructured{\n\t\t\t\t{\n\t\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\t\"apiVersion\": \"proxyconfigs.acme.corp/v1beta1\",\n\t\t\t\t\t\t\"kind\":       \"ProxyService\",\n\t\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\t\"name\":      \"reviews\",\n\t\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"spec\": map[string]any{\n\t\t\t\t\t\t\t\"hosts\": []any{\n\t\t\t\t\t\t\t\t\"promo.svc.local\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"reviews.internal.com\", endpoint.RecordTypeCNAME, \"promo.svc.local\").\n\t\t\t\t\tWithLabel(endpoint.ResourceLabelKey, \"proxyservice/default/reviews\"),\n\t\t\t\tendpoint.NewEndpoint(\"reviews.mesh.com\", endpoint.RecordTypeCNAME, \"promo.svc.local\").\n\t\t\t\t\tWithLabel(endpoint.ResourceLabelKey, \"proxyservice/default/reviews\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"with labels\",\n\t\t\tcfg: cfg{\n\t\t\t\tresources:      []string{\"applications.v1alpha1.argoproj.io\"},\n\t\t\t\tfqdnTemplate:   `{{index .Labels \"app.kubernetes.io/instance\"}}.apps.com`,\n\t\t\t\ttargetTemplate: \"{{.Status.loadBalancer}}\",\n\t\t\t},\n\t\t\tobjects: []*unstructured.Unstructured{\n\t\t\t\t{\n\t\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\t\"apiVersion\": \"argoproj.io/v1alpha1\",\n\t\t\t\t\t\t\"kind\":       \"Application\",\n\t\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\t\"name\":      \"guestbook\",\n\t\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\t\t\"labels\": map[string]any{\n\t\t\t\t\t\t\t\t\"app.kubernetes.io/instance\": \"guestbook-prod\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"status\": map[string]any{\n\t\t\t\t\t\t\t\"loadBalancer\": \"lb.example.com\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"guestbook-prod.apps.com\", endpoint.RecordTypeCNAME, \"lb.example.com\").\n\t\t\t\t\tWithLabel(endpoint.ResourceLabelKey, \"application/default/guestbook\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"with ttl annotation set\",\n\t\t\tcfg: cfg{\n\t\t\t\tresources:      []string{\"applications.v1alpha1.argoproj.io\"},\n\t\t\t\tfqdnTemplate:   `{{index .Labels \"app.kubernetes.io/instance\"}}.apps.com`,\n\t\t\t\ttargetTemplate: \"{{.Status.loadBalancer}}\",\n\t\t\t},\n\t\t\tobjects: []*unstructured.Unstructured{\n\t\t\t\t{\n\t\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\t\"apiVersion\": \"argoproj.io/v1alpha1\",\n\t\t\t\t\t\t\"kind\":       \"Application\",\n\t\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\t\"name\":      \"guestbook\",\n\t\t\t\t\t\t\t\"namespace\": \"ns\",\n\t\t\t\t\t\t\t\"labels\": map[string]any{\n\t\t\t\t\t\t\t\t\"app.kubernetes.io/instance\": \"guestbook-prod\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"annotations\": map[string]any{\n\t\t\t\t\t\t\t\tannotations.TtlKey: \"300\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"status\": map[string]any{\n\t\t\t\t\t\t\t\"loadBalancer\": \"lb.example.com\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpointWithTTL(\"guestbook-prod.apps.com\", endpoint.RecordTypeCNAME, 300, \"lb.example.com\").\n\t\t\t\t\tWithLabel(endpoint.ResourceLabelKey, \"application/ns/guestbook\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"two different resource types - VirtualMachineInstance and RDSInstance\",\n\t\t\tcfg: cfg{\n\t\t\t\tresources: []string{\n\t\t\t\t\t\"virtualmachineinstances.v1.kubevirt.io\",\n\t\t\t\t\t\"rdsinstances.v1alpha1.rds.aws.crossplane.io\",\n\t\t\t\t},\n\t\t\t\tfqdnTemplate: \"{{.Name}}.{{.Namespace}}.com\",\n\t\t\t\ttargetTemplate: `\n{{if .Status.interfaces}}{{index .Status.interfaces 0 \"ipAddress\"}}{{else}}{{.Status.atProvider.endpoint.address}}{{end}}`,\n\t\t\t},\n\t\t\tobjects: []*unstructured.Unstructured{\n\t\t\t\t{\n\t\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\t\"apiVersion\": \"kubevirt.io/v1\",\n\t\t\t\t\t\t\"kind\":       \"VirtualMachineInstance\",\n\t\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\t\"name\":      \"my-vm\",\n\t\t\t\t\t\t\t\"namespace\": \"vms\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"status\": map[string]any{\n\t\t\t\t\t\t\t\"interfaces\": []any{\n\t\t\t\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\t\t\t\"ipAddress\": \"10.244.1.100\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\t\"apiVersion\": \"rds.aws.crossplane.io/v1alpha1\",\n\t\t\t\t\t\t\"kind\":       \"RDSInstance\",\n\t\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\t\"name\":      \"my-db\",\n\t\t\t\t\t\t\t\"namespace\": \"databases\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"status\": map[string]any{\n\t\t\t\t\t\t\t\"atProvider\": map[string]any{\n\t\t\t\t\t\t\t\t\"endpoint\": map[string]any{\n\t\t\t\t\t\t\t\t\t\"address\": \"my-db.abc123.us-west-2.rds\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"my-db.databases.com\", endpoint.RecordTypeCNAME, \"my-db.abc123.us-west-2.rds\").\n\t\t\t\t\tWithLabel(endpoint.ResourceLabelKey, \"rdsinstance/databases/my-db\"),\n\t\t\t\tendpoint.NewEndpoint(\"my-vm.vms.com\", endpoint.RecordTypeA, \"10.244.1.100\").\n\t\t\t\t\tWithLabel(endpoint.ResourceLabelKey, \"virtualmachineinstance/vms/my-vm\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"two different resource types with same template\",\n\t\t\tcfg: cfg{\n\t\t\t\tresources: []string{\n\t\t\t\t\t\"virtualmachineinstances.v1.kubevirt.io\",\n\t\t\t\t\t\"targetgroupbindings.v1beta1.elbv2.k8s.aws\",\n\t\t\t\t},\n\t\t\t\tfqdnTemplate:   \"{{.Name}}.{{.Kind}}.example.com\",\n\t\t\t\ttargetTemplate: \"{{.Status.target}}\",\n\t\t\t},\n\t\t\tobjects: []*unstructured.Unstructured{\n\t\t\t\t{\n\t\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\t\"apiVersion\": \"kubevirt.io/v1\",\n\t\t\t\t\t\t\"kind\":       \"VirtualMachineInstance\",\n\t\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\t\"name\":      \"web-server\",\n\t\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"status\": map[string]any{\n\t\t\t\t\t\t\t\"target\": \"192.168.1.10\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\t\"apiVersion\": \"elbv2.k8s.aws/v1beta1\",\n\t\t\t\t\t\t\"kind\":       \"TargetGroupBinding\",\n\t\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\t\"name\":      \"api-tgb\",\n\t\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"status\": map[string]any{\n\t\t\t\t\t\t\t\"target\": \"lb.api.example.com\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"api-tgb.TargetGroupBinding.example.com\", endpoint.RecordTypeCNAME, \"lb.api.example.com\").\n\t\t\t\t\tWithLabel(endpoint.ResourceLabelKey, \"targetgroupbinding/default/api-tgb\"),\n\t\t\t\tendpoint.NewEndpoint(\"web-server.VirtualMachineInstance.example.com\", endpoint.RecordTypeA, \"192.168.1.10\").\n\t\t\t\t\tWithLabel(endpoint.ResourceLabelKey, \"virtualmachineinstance/default/web-server\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"combined annotations and template\",\n\t\t\tcfg: cfg{\n\t\t\t\tresources:      []string{\"virtualmachineinstances.v1.kubevirt.io\"},\n\t\t\t\tfqdnTemplate:   \"{{.Name}}.template.example.com\",\n\t\t\t\ttargetTemplate: `{{index .Status.interfaces 0 \"ipAddress\"}}`,\n\t\t\t\tcombine:        true,\n\t\t\t},\n\t\t\tobjects: []*unstructured.Unstructured{\n\t\t\t\t{\n\t\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\t\"apiVersion\": \"kubevirt.io/v1\",\n\t\t\t\t\t\t\"kind\":       \"VirtualMachineInstance\",\n\t\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\t\"name\":      \"my-vm\",\n\t\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\t\t\"annotations\": map[string]any{\n\t\t\t\t\t\t\t\tannotations.HostnameKey: \"my-vm.annotation.example.com\",\n\t\t\t\t\t\t\t\tannotations.TargetKey:   \"192.168.1.100\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"status\": map[string]any{\n\t\t\t\t\t\t\t\"interfaces\": []any{\n\t\t\t\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\t\t\t\"ipAddress\": \"10.244.1.50\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"my-vm.annotation.example.com\", endpoint.RecordTypeA, \"192.168.1.100\").\n\t\t\t\t\tWithLabel(endpoint.ResourceLabelKey, \"virtualmachineinstance/default/my-vm\"),\n\t\t\t\tendpoint.NewEndpoint(\"my-vm.template.example.com\", endpoint.RecordTypeA, \"10.244.1.50\").\n\t\t\t\t\tWithLabel(endpoint.ResourceLabelKey, \"virtualmachineinstance/default/my-vm\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"three different resource types\",\n\t\t\tcfg: cfg{\n\t\t\t\tresources: []string{\n\t\t\t\t\t\"virtualmachineinstances.v1.kubevirt.io\",\n\t\t\t\t\t\"targetgroupbinding.v1beta1.elbv2.k8s.aws\",\n\t\t\t\t\t\"apisixroute.v2.apisix.apache.org\",\n\t\t\t\t},\n\t\t\t\tfqdnTemplate: `\n{{if eq .Kind \"VirtualMachineInstance\"}}{{.Name}}.vm.com{{end}},\n{{if eq .Kind \"TargetGroupBinding\"}}{{.Name}}.tgb.com{{end}},\n{{if eq .Kind \"ApisixRoute\"}}{{.Name}}.route.com{{end}}`,\n\t\t\t\ttargetTemplate: `\n{{if eq .Kind \"VirtualMachineInstance\"}}{{index .Status.interfaces 0 \"ipAddress\"}}{{end}},\n{{if eq .Kind \"TargetGroupBinding\"}}{{.Status.loadBalancerHostname}}{{end}},\n{{if eq .Kind \"ApisixRoute\"}}{{.Status.apisix.gateway}}{{end}}`,\n\t\t\t},\n\t\t\tobjects: []*unstructured.Unstructured{\n\t\t\t\t{\n\t\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\t\"apiVersion\": \"kubevirt.io/v1\",\n\t\t\t\t\t\t\"kind\":       \"VirtualMachineInstance\",\n\t\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\t\"name\":      \"my-vm\",\n\t\t\t\t\t\t\t\"namespace\": \"vms\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"status\": map[string]any{\n\t\t\t\t\t\t\t\"interfaces\": []any{\n\t\t\t\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\t\t\t\"ipAddress\": \"10.0.0.1\",\n\t\t\t\t\t\t\t\t\t\"name\":      \"default\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\t\"apiVersion\": \"elbv2.k8s.aws/v1beta1\",\n\t\t\t\t\t\t\"kind\":       \"TargetGroupBinding\",\n\t\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\t\"name\":      \"my-tgb\",\n\t\t\t\t\t\t\t\"namespace\": \"apps\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"status\": map[string]any{\n\t\t\t\t\t\t\t\"loadBalancerHostname\": \"my-alb.us-east-1.elb.amazonaws.com\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\t\"apiVersion\": \"apisix.apache.org/v2\",\n\t\t\t\t\t\t\"kind\":       \"ApisixRoute\",\n\t\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\t\"name\":      \"httpbin\",\n\t\t\t\t\t\t\t\"namespace\": \"ingress-apisix\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"spec\": map[string]any{\n\t\t\t\t\t\t\t\"ingressClassName\": \"apisix\",\n\t\t\t\t\t\t\t\"http\": []any{\n\t\t\t\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\t\t\t\"name\": \"httpbin\",\n\t\t\t\t\t\t\t\t\t\"match\": map[string]any{\n\t\t\t\t\t\t\t\t\t\t\"paths\": []any{\"/ip\"},\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\"backends\": []any{\n\t\t\t\t\t\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\t\t\t\t\t\"serviceName\": \"httpbin\",\n\t\t\t\t\t\t\t\t\t\t\t\"servicePort\": int64(80),\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"status\": map[string]any{\n\t\t\t\t\t\t\t\"apisix\": map[string]any{\n\t\t\t\t\t\t\t\t\"gateway\": \"apisix-gateway.ingress-apisix.svc.cluster.local\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"httpbin.route.com\", endpoint.RecordTypeCNAME, \"apisix-gateway.ingress-apisix.svc.cluster.local\").\n\t\t\t\t\tWithLabel(endpoint.ResourceLabelKey, \"apisixroute/ingress-apisix/httpbin\"),\n\t\t\t\tendpoint.NewEndpoint(\"my-tgb.tgb.com\", endpoint.RecordTypeCNAME, \"my-alb.us-east-1.elb.amazonaws.com\").\n\t\t\t\t\tWithLabel(endpoint.ResourceLabelKey, \"targetgroupbinding/apps/my-tgb\"),\n\t\t\t\tendpoint.NewEndpoint(\"my-vm.vm.com\", endpoint.RecordTypeA, \"10.0.0.1\").\n\t\t\t\t\tWithLabel(endpoint.ResourceLabelKey, \"virtualmachineinstance/vms/my-vm\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"ACK S3 Bucket with FieldExport to ConfigMap\",\n\t\t\tcfg: cfg{\n\t\t\t\tresources: []string{\n\t\t\t\t\t\"buckets.v1alpha1.s3.services.k8s.aws\",\n\t\t\t\t\t\"fieldexports.v1alpha1.services.k8s.aws\",\n\t\t\t\t\t\"configmap.v1\"},\n\t\t\t\tfqdnTemplate: `{{if eq .Kind \"ConfigMap\"}}{{.Name}}.s3.example.com{{end}}`,\n\t\t\t\ttargetTemplate: `\n{{if eq .Kind \"ConfigMap\"}}{{$url := index .Object.data \"default.export-user-data-bucket\"}}{{trimSuffix (trimPrefix $url \"https://\") \"/\"}}{{end}}`,\n\t\t\t\tlabelFilter: \"app.kubernetes.io/name=example-app\",\n\t\t\t},\n\t\t\tobjects: []*unstructured.Unstructured{\n\t\t\t\t{\n\t\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\t\"apiVersion\": \"s3.services.k8s.aws/v1alpha1\",\n\t\t\t\t\t\t\"kind\":       \"Bucket\",\n\t\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\t\"name\":      \"application-user-data\",\n\t\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\t\t\"labels\": map[string]any{\n\t\t\t\t\t\t\t\t\"app.kubernetes.io/name\":    \"example-app\",\n\t\t\t\t\t\t\t\t\"app.kubernetes.io/part-of\": \"exported-config\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"annotations\": map[string]any{\n\t\t\t\t\t\t\t\tannotations.ControllerKey: annotations.ControllerValue,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"spec\": map[string]any{\n\t\t\t\t\t\t\t\"name\": \"doc-example-bucket\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\t\"apiVersion\": \"services.k8s.aws/v1alpha1\",\n\t\t\t\t\t\t\"kind\":       \"FieldExport\",\n\t\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\t\"name\":      \"export-user-data-bucket\",\n\t\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\t\t\"labels\": map[string]any{\n\t\t\t\t\t\t\t\t\"app.kubernetes.io/name\":    \"example-app\",\n\t\t\t\t\t\t\t\t\"app.kubernetes.io/part-of\": \"exported-config\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"annotations\": map[string]any{\n\t\t\t\t\t\t\t\tannotations.ControllerKey: annotations.ControllerValue,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"spec\": map[string]any{\n\t\t\t\t\t\t\t\"to\": map[string]any{\n\t\t\t\t\t\t\t\t\"name\":      \"application-user-data-cm\",\n\t\t\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\t\t\t\"kind\":      \"configmap\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"from\": map[string]any{\n\t\t\t\t\t\t\t\t\"path\": \".status.location\",\n\t\t\t\t\t\t\t\t\"resource\": map[string]any{\n\t\t\t\t\t\t\t\t\t\"group\":     \"s3.services.k8s.aws\",\n\t\t\t\t\t\t\t\t\t\"kind\":      \"Bucket\",\n\t\t\t\t\t\t\t\t\t\"name\":      \"application-user-data\",\n\t\t\t\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\t\"apiVersion\": \"v1\",\n\t\t\t\t\t\t\"kind\":       \"ConfigMap\",\n\t\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\t\"name\":      \"application-user-data-cm\",\n\t\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\t\t\"labels\": map[string]any{\n\t\t\t\t\t\t\t\t\"app.kubernetes.io/name\":    \"example-app\",\n\t\t\t\t\t\t\t\t\"app.kubernetes.io/part-of\": \"exported-config\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"annotations\": map[string]any{\n\t\t\t\t\t\t\t\tannotations.ControllerKey: annotations.ControllerValue,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"data\": map[string]any{\n\t\t\t\t\t\t\t\"default.export-user-data-bucket\": \"https://doc-example-bucket.s3.amazonaws.com/\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"application-user-data-cm.s3.example.com\", endpoint.RecordTypeCNAME, \"doc-example-bucket.s3.amazonaws.com\").\n\t\t\t\t\tWithLabel(endpoint.ResourceLabelKey, \"configmap/default/application-user-data-cm\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"EndpointSlice for headless service with per-pod DNS\",\n\t\t\tcfg: cfg{\n\t\t\t\tresources: []string{\"endpointslices.v1.discovery.k8s.io\"},\n\t\t\t\tfqdnTargetTemplate: `\n{{if and (eq .Kind \"EndpointSlice\") (hasKey .Labels \"service.kubernetes.io/headless\")}}\n{{range $ep := .Object.endpoints}}{{if $ep.conditions.ready}}{{range $ep.addresses}}{{$ep.targetRef.name}}.pod.com:{{.}},{{end}}{{end}}{{end}}{{end}}`,\n\t\t\t},\n\t\t\tobjects: []*unstructured.Unstructured{\n\t\t\t\t{\n\t\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\t\"apiVersion\": \"discovery.k8s.io/v1\",\n\t\t\t\t\t\t\"kind\":       \"EndpointSlice\",\n\t\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\t\"name\":      \"test-headless-abc12\",\n\t\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\t\t\"labels\": map[string]any{\n\t\t\t\t\t\t\t\t\"endpointslice.kubernetes.io/managed-by\": \"endpointslice-controller.k8s.io\",\n\t\t\t\t\t\t\t\t\"kubernetes.io/service-name\":             \"test-headless\",\n\t\t\t\t\t\t\t\t\"service.kubernetes.io/headless\":         \"\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"addressType\": \"IPv4\",\n\t\t\t\t\t\t\"endpoints\": []any{\n\t\t\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\t\t\"addresses\": []any{\"10.244.1.2\", \"2001:db8::1\"},\n\t\t\t\t\t\t\t\t\"conditions\": map[string]any{\n\t\t\t\t\t\t\t\t\t\"ready\": true,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"nodeName\": \"worker1\",\n\t\t\t\t\t\t\t\t\"targetRef\": map[string]any{\n\t\t\t\t\t\t\t\t\t\"kind\":      \"Pod\",\n\t\t\t\t\t\t\t\t\t\"name\":      \"app-abc12\",\n\t\t\t\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\t\t\"addresses\": []any{\"10.244.2.3\", \"10.244.2.4\"},\n\t\t\t\t\t\t\t\t\"conditions\": map[string]any{\n\t\t\t\t\t\t\t\t\t\"ready\": true,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"nodeName\": \"worker2\",\n\t\t\t\t\t\t\t\t\"targetRef\": map[string]any{\n\t\t\t\t\t\t\t\t\t\"kind\":      \"Pod\",\n\t\t\t\t\t\t\t\t\t\"name\":      \"app-def34\",\n\t\t\t\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"ports\": []any{\n\t\t\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\t\t\"name\":     \"http\",\n\t\t\t\t\t\t\t\t\"port\":     int64(80),\n\t\t\t\t\t\t\t\t\"protocol\": \"TCP\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"app-abc12.pod.com\", endpoint.RecordTypeA, \"10.244.1.2\").\n\t\t\t\t\tWithLabel(endpoint.ResourceLabelKey, \"endpointslice/default/test-headless-abc12\"),\n\t\t\t\tendpoint.NewEndpoint(\"app-abc12.pod.com\", endpoint.RecordTypeAAAA, \"2001:db8::1\").\n\t\t\t\t\tWithLabel(endpoint.ResourceLabelKey, \"endpointslice/default/test-headless-abc12\"),\n\t\t\t\tendpoint.NewEndpoint(\"app-def34.pod.com\", endpoint.RecordTypeA, \"10.244.2.3\", \"10.244.2.4\").\n\t\t\t\t\tWithLabel(endpoint.ResourceLabelKey, \"endpointslice/default/test-headless-abc12\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"EndpointSlice for headless service with single FQDN per EndpointSlice\",\n\t\t\tcfg: cfg{\n\t\t\t\tresources: []string{\"endpointslices.v1.discovery.k8s.io\"},\n\t\t\t\tfqdnTargetTemplate: `\n{{if and (eq .Kind \"EndpointSlice\") (hasKey .Labels \"service.kubernetes.io/headless\")}}\n{{$svcName := index .Labels \"kubernetes.io/service-name\"}}{{range $ep := .Object.endpoints}}\n{{if $ep.conditions.ready}}{{range $ep.addresses}}{{$svcName}}.example.com:{{.}},{{end}}{{end}}{{end}}{{end}}`,\n\t\t\t},\n\t\t\tobjects: []*unstructured.Unstructured{\n\t\t\t\t{\n\t\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\t\"apiVersion\": \"discovery.k8s.io/v1\",\n\t\t\t\t\t\t\"kind\":       \"EndpointSlice\",\n\t\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\t\"name\":      \"test-headless-abc12\",\n\t\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\t\t\"labels\": map[string]any{\n\t\t\t\t\t\t\t\t\"endpointslice.kubernetes.io/managed-by\": \"endpointslice-controller.k8s.io\",\n\t\t\t\t\t\t\t\t\"kubernetes.io/service-name\":             \"my-headless\",\n\t\t\t\t\t\t\t\t\"service.kubernetes.io/headless\":         \"\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"addressType\": \"IPv4\",\n\t\t\t\t\t\t\"endpoints\": []any{\n\t\t\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\t\t\"addresses\": []any{\"10.244.1.2\"},\n\t\t\t\t\t\t\t\t\"conditions\": map[string]any{\n\t\t\t\t\t\t\t\t\t\"ready\": true,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"nodeName\": \"worker1\",\n\t\t\t\t\t\t\t\t\"targetRef\": map[string]any{\n\t\t\t\t\t\t\t\t\t\"kind\":      \"Pod\",\n\t\t\t\t\t\t\t\t\t\"name\":      \"app-abc12\",\n\t\t\t\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\t\t\"addresses\": []any{\"10.244.2.3\", \"10.244.2.4\"},\n\t\t\t\t\t\t\t\t\"conditions\": map[string]any{\n\t\t\t\t\t\t\t\t\t\"ready\": true,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"nodeName\": \"worker2\",\n\t\t\t\t\t\t\t\t\"targetRef\": map[string]any{\n\t\t\t\t\t\t\t\t\t\"kind\":      \"Pod\",\n\t\t\t\t\t\t\t\t\t\"name\":      \"app-def34\",\n\t\t\t\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"ports\": []any{\n\t\t\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\t\t\"name\":     \"http\",\n\t\t\t\t\t\t\t\t\"port\":     int64(80),\n\t\t\t\t\t\t\t\t\"protocol\": \"TCP\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"my-headless.example.com\", endpoint.RecordTypeA, \"10.244.1.2\", \"10.244.2.3\", \"10.244.2.4\").\n\t\t\t\t\tWithLabel(endpoint.ResourceLabelKey, \"endpointslice/default/test-headless-abc12\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"fqdnTargetTemplate returns no values when condition not met\",\n\t\t\tcfg: cfg{\n\t\t\t\tresources: []string{\"endpointslices.v1.discovery.k8s.io\"},\n\t\t\t\tfqdnTargetTemplate: `\n{{if and (eq .Kind \"EndpointSlice\") (hasKey .Labels \"service.kubernetes.io/headless\")}}\n{{range $ep := .Object.endpoints}}{{if $ep.conditions.ready}}{{range $ep.addresses}}{{$ep.targetRef.name}}.pod.com:{{.}},{{end}}{{end}}{{end}}{{end}}`,\n\t\t\t},\n\t\t\tobjects: []*unstructured.Unstructured{\n\t\t\t\t{\n\t\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\t\"apiVersion\": \"discovery.k8s.io/v1\",\n\t\t\t\t\t\t\"kind\":       \"EndpointSlice\",\n\t\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\t\"name\":      \"regular-service-abc12\",\n\t\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\t\t\"labels\": map[string]any{\n\t\t\t\t\t\t\t\t\"endpointslice.kubernetes.io/managed-by\": \"endpointslice-controller.k8s.io\",\n\t\t\t\t\t\t\t\t\"kubernetes.io/service-name\":             \"regular-service\",\n\t\t\t\t\t\t\t\t// Note: missing service.kubernetes.io/headless label\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"addressType\": \"IPv4\",\n\t\t\t\t\t\t\"endpoints\": []any{\n\t\t\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\t\t\"addresses\": []any{\"10.244.1.2\"},\n\t\t\t\t\t\t\t\t\"conditions\": map[string]any{\n\t\t\t\t\t\t\t\t\t\"ready\": true,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\"targetRef\": map[string]any{\n\t\t\t\t\t\t\t\t\t\"kind\":      \"Pod\",\n\t\t\t\t\t\t\t\t\t\"name\":      \"app-abc12\",\n\t\t\t\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\ttitle: \"both fqdnTargetTemplate and fqdnTemplate set - endpoints from both are combined\",\n\t\t\tcfg: cfg{\n\t\t\t\tresources:          []string{\"virtualmachineinstances.v1.kubevirt.io\"},\n\t\t\t\tfqdnTargetTemplate: `{{range $iface := .Status.interfaces}}{{$.Name}}-{{index $iface \"name\"}}.ifaces.example.com:{{index $iface \"ipAddress\"}},{{end}}`,\n\t\t\t\tfqdnTemplate:       \"{{.Name}}.vmi.example.com\",\n\t\t\t\ttargetTemplate:     `{{index .Status.interfaces 0 \"ipAddress\"}}`,\n\t\t\t\tcombine:            true,\n\t\t\t},\n\t\t\tobjects: []*unstructured.Unstructured{\n\t\t\t\t{\n\t\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\t\"apiVersion\": \"kubevirt.io/v1\",\n\t\t\t\t\t\t\"kind\":       \"VirtualMachineInstance\",\n\t\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\t\"name\":      \"my-vm\",\n\t\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"status\": map[string]any{\n\t\t\t\t\t\t\t\"interfaces\": []any{\n\t\t\t\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\t\t\t\"name\":      \"eth0\",\n\t\t\t\t\t\t\t\t\t\"ipAddress\": \"10.244.1.50\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\t\t\t\"name\":      \"eth1\",\n\t\t\t\t\t\t\t\t\t\"ipAddress\": \"192.168.1.50\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t// from fqdnTargetTemplate: per-interface 1:1 host:IP pairs\n\t\t\t\tendpoint.NewEndpoint(\"my-vm-eth0.ifaces.example.com\", endpoint.RecordTypeA, \"10.244.1.50\").\n\t\t\t\t\tWithLabel(endpoint.ResourceLabelKey, \"virtualmachineinstance/default/my-vm\"),\n\t\t\t\tendpoint.NewEndpoint(\"my-vm-eth1.ifaces.example.com\", endpoint.RecordTypeA, \"192.168.1.50\").\n\t\t\t\t\tWithLabel(endpoint.ResourceLabelKey, \"virtualmachineinstance/default/my-vm\"),\n\t\t\t\t// from fqdnTemplate + targetTemplate: service-level record for the primary interface\n\t\t\t\tendpoint.NewEndpoint(\"my-vm.vmi.example.com\", endpoint.RecordTypeA, \"10.244.1.50\").\n\t\t\t\t\tWithLabel(endpoint.ResourceLabelKey, \"virtualmachineinstance/default/my-vm\"),\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(tt.title, func(t *testing.T) {\n\t\t\tkubeClient, dynamicClient := setupUnstructuredTestClients(t, tt.cfg.resources, tt.objects)\n\n\t\t\tvar selector labels.Selector\n\t\t\tif tt.cfg.labelFilter != \"\" {\n\t\t\t\tvar err error\n\t\t\t\tselector, err = labels.Parse(tt.cfg.labelFilter)\n\t\t\t\trequire.NoError(t, err)\n\t\t\t} else {\n\t\t\t\tselector = labels.Everything()\n\t\t\t}\n\n\t\t\tsrc, err := NewUnstructuredFQDNSource(\n\t\t\t\tt.Context(),\n\t\t\t\tdynamicClient,\n\t\t\t\tkubeClient,\n\t\t\t\t&Config{\n\t\t\t\t\tLabelFilter:              selector,\n\t\t\t\t\tUnstructuredResources:    tt.cfg.resources,\n\t\t\t\t\tFQDNTemplate:             tt.cfg.fqdnTemplate,\n\t\t\t\t\tTargetTemplate:           tt.cfg.targetTemplate,\n\t\t\t\t\tFQDNTargetTemplate:       tt.cfg.fqdnTargetTemplate,\n\t\t\t\t\tCombineFQDNAndAnnotation: tt.cfg.combine,\n\t\t\t\t},\n\t\t\t)\n\t\t\trequire.NoError(t, err)\n\n\t\t\tendpoints, err := src.Endpoints(t.Context())\n\t\t\trequire.NoError(t, err)\n\n\t\t\tvalidateEndpoints(t, endpoints, tt.expected)\n\t\t})\n\t}\n}\n\nfunc TestUnstructuredWrapper_Templating(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\ttmpl    string\n\t\tobj     *unstructured.Unstructured\n\t\twant    []string\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"typed-style Name and Namespace access\",\n\t\t\ttmpl: \"{{.Name}}.{{.Namespace}}.example.com\",\n\t\t\tobj: &unstructured.Unstructured{\n\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\"apiVersion\": \"kubevirt.io/v1\",\n\t\t\t\t\t\"kind\":       \"VirtualMachineInstance\",\n\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\"name\":      \"my-vm\",\n\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []string{\"my-vm.default.example.com\"},\n\t\t},\n\t\t{\n\t\t\tname: \"raw Metadata map access\",\n\t\t\ttmpl: \"{{.Metadata.name}}.{{.Metadata.namespace}}.example.com\",\n\t\t\tobj: &unstructured.Unstructured{\n\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\"apiVersion\": \"kubevirt.io/v1\",\n\t\t\t\t\t\"kind\":       \"VirtualMachineInstance\",\n\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\"name\":      \"my-vm\",\n\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []string{\"my-vm.default.example.com\"},\n\t\t},\n\t\t{\n\t\t\tname: \"nested Status field access\",\n\t\t\ttmpl: \"{{.Status.atProvider.endpoint.address}}\",\n\t\t\tobj: &unstructured.Unstructured{\n\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\"apiVersion\": \"rds.aws.crossplane.io/v1alpha1\",\n\t\t\t\t\t\"kind\":       \"RDSInstance\",\n\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\"name\":      \"prod-db\",\n\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t},\n\t\t\t\t\t\"status\": map[string]any{\n\t\t\t\t\t\t\"atProvider\": map[string]any{\n\t\t\t\t\t\t\t\"endpoint\": map[string]any{\n\t\t\t\t\t\t\t\t\"address\": \"prod-db.abc123.rds.amazonaws.com\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []string{\"prod-db.abc123.rds.amazonaws.com\"},\n\t\t},\n\t\t{\n\t\t\tname: \"array index access via Status\",\n\t\t\ttmpl: `{{index .Status.interfaces 0 \"ipAddress\"}}`,\n\t\t\tobj: &unstructured.Unstructured{\n\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\"apiVersion\": \"kubevirt.io/v1\",\n\t\t\t\t\t\"kind\":       \"VirtualMachineInstance\",\n\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\"name\":      \"vm-1\",\n\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t},\n\t\t\t\t\t\"status\": map[string]any{\n\t\t\t\t\t\t\"interfaces\": []any{\n\t\t\t\t\t\t\tmap[string]any{\n\t\t\t\t\t\t\t\t\"ipAddress\": \"10.244.1.50\",\n\t\t\t\t\t\t\t\t\"name\":      \"default\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []string{\"10.244.1.50\"},\n\t\t},\n\t\t{\n\t\t\tname: \"typed-style Labels access\",\n\t\t\ttmpl: `{{index .Labels \"app.kubernetes.io/instance\"}}.example.com`,\n\t\t\tobj: &unstructured.Unstructured{\n\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\"apiVersion\": \"argoproj.io/v1alpha1\",\n\t\t\t\t\t\"kind\":       \"Application\",\n\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\"name\":      \"guestbook\",\n\t\t\t\t\t\t\"namespace\": \"argocd\",\n\t\t\t\t\t\t\"labels\": map[string]any{\n\t\t\t\t\t\t\t\"app.kubernetes.io/instance\": \"guestbook-prod\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []string{\"guestbook-prod.example.com\"},\n\t\t},\n\t\t{\n\t\t\tname: \"Kind and APIVersion access\",\n\t\t\ttmpl: \"{{.Kind}}.{{.APIVersion}}.example.com\",\n\t\t\tobj: &unstructured.Unstructured{\n\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\"apiVersion\": \"kubevirt.io/v1\",\n\t\t\t\t\t\"kind\":       \"VirtualMachineInstance\",\n\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\"name\":      \"test\",\n\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []string{\"VirtualMachineInstance.kubevirt.io/v1.example.com\"},\n\t\t},\n\t\t{\n\t\t\tname: \"Spec hosts array\",\n\t\t\ttmpl: `{{index .Spec.hosts 0}}`,\n\t\t\tobj: &unstructured.Unstructured{\n\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\"apiVersion\": \"networking.istio.io/v1beta1\",\n\t\t\t\t\t\"kind\":       \"VirtualService\",\n\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\"name\":      \"reviews\",\n\t\t\t\t\t\t\"namespace\": \"bookinfo\",\n\t\t\t\t\t},\n\t\t\t\t\t\"spec\": map[string]any{\n\t\t\t\t\t\t\"hosts\": []any{\n\t\t\t\t\t\t\t\"reviews.bookinfo.svc.cluster.local\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: []string{\"reviews.bookinfo.svc.cluster.local\"},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\ttmpl, err := fqdn.ParseTemplate(tt.tmpl)\n\t\t\trequire.NoError(t, err)\n\n\t\t\twrapped := newUnstructuredWrapper(tt.obj)\n\t\t\tgot, err := fqdn.ExecTemplate(tmpl, wrapped)\n\t\t\tif tt.wantErr {\n\t\t\t\trequire.Error(t, err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\trequire.NoError(t, err)\n\t\t\tassert.Equal(t, tt.want, got)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "source/unstructured_test.go",
    "content": "/*\nCopyright 2026 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured\"\n\t\"k8s.io/apimachinery/pkg/labels\"\n\t\"k8s.io/apimachinery/pkg/runtime\"\n\t\"k8s.io/apimachinery/pkg/runtime/schema\"\n\tdiscoveryfake \"k8s.io/client-go/discovery/fake\"\n\t\"k8s.io/client-go/dynamic\"\n\tdynamicfake \"k8s.io/client-go/dynamic/fake\"\n\t\"k8s.io/client-go/kubernetes\"\n\t\"k8s.io/client-go/kubernetes/fake\"\n\n\t\"sigs.k8s.io/external-dns/internal/testutils\"\n\t\"sigs.k8s.io/external-dns/source/types\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n)\n\nfunc TestUnstructuredWrapperImplementsKubeObject(t *testing.T) {\n\tu := &unstructured.Unstructured{\n\t\tObject: map[string]any{\n\t\t\t\"apiVersion\": \"kubevirt.io/v1\",\n\t\t\t\"kind\":       \"VirtualMachineInstance\",\n\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\"name\":      \"test-vm\",\n\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\"labels\": map[string]any{\n\t\t\t\t\t\"app\": \"test\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\twrapped := newUnstructuredWrapper(u)\n\tassert.Equal(t, \"test-vm\", wrapped.Name)\n\tassert.Equal(t, \"default\", wrapped.Namespace)\n\tassert.Equal(t, \"VirtualMachineInstance\", wrapped.Kind)\n\tassert.Equal(t, \"kubevirt.io/v1\", wrapped.APIVersion)\n\tassert.Equal(t, map[string]string{\"app\": \"test\"}, wrapped.Labels)\n\tassert.Equal(t, \"test-vm\", wrapped.GetName())\n\tassert.Equal(t, \"default\", wrapped.GetNamespace())\n\tassert.Same(t, u, wrapped.Unstructured)\n\t// Verify it implements runtime.Object via embedding\n\tgvk := wrapped.GetObjectKind().GroupVersionKind()\n\tassert.Equal(t, \"VirtualMachineInstance\", gvk.Kind)\n}\n\nfunc TestUnstructured_DifferentScenarios(t *testing.T) {\n\ttype cfg struct {\n\t\tresources        []string\n\t\tlabelSelector    string\n\t\tannotationFilter string\n\t\tcombine          bool\n\t}\n\n\tfor _, tt := range []struct {\n\t\ttitle    string\n\t\tcfg      cfg\n\t\tobjects  []*unstructured.Unstructured\n\t\texpected []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\ttitle: \"read from annotations with IPv6 target\",\n\t\t\tcfg: cfg{\n\t\t\t\tresources: []string{\"virtualmachineinstances.v1.kubevirt.io\"},\n\t\t\t},\n\t\t\tobjects: []*unstructured.Unstructured{\n\t\t\t\t{\n\t\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\t\"apiVersion\": \"kubevirt.io/v1\",\n\t\t\t\t\t\t\"kind\":       \"VirtualMachineInstance\",\n\t\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\t\"name\":      \"my-vm\",\n\t\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\t\t\"annotations\": map[string]any{\n\t\t\t\t\t\t\t\tannotations.HostnameKey: \"my-vm.example.com\",\n\t\t\t\t\t\t\t\tannotations.TargetKey:   \"::1234:5678\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"my-vm.example.com\", endpoint.RecordTypeAAAA, \"::1234:5678\").\n\t\t\t\t\tWithLabel(endpoint.ResourceLabelKey, \"virtualmachineinstance/default/my-vm\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"rancher node with ttl\",\n\t\t\tcfg: cfg{\n\t\t\t\tresources: []string{\"nodes.v3.management.cattle.io\"},\n\t\t\t},\n\t\t\tobjects: []*unstructured.Unstructured{\n\t\t\t\t{\n\t\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\t\"apiVersion\": \"management.cattle.io/v3\",\n\t\t\t\t\t\t\"kind\":       \"Node\",\n\t\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\t\"name\":      \"my-node-1\",\n\t\t\t\t\t\t\t\"namespace\": \"cattle-system\",\n\t\t\t\t\t\t\t\"labels\": map[string]any{\n\t\t\t\t\t\t\t\t\"cattle.io/creator\":                    \"norman\",\n\t\t\t\t\t\t\t\t\"node-role.kubernetes.io/controlplane\": \"true\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"annotations\": map[string]any{\n\t\t\t\t\t\t\t\tannotations.HostnameKey: \"my-node-1.nodes.example.com\",\n\t\t\t\t\t\t\t\tannotations.TargetKey:   \"203.0.113.10\",\n\t\t\t\t\t\t\t\tannotations.TtlKey:      \"300\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"spec\": map[string]any{\n\t\t\t\t\t\t\t\"clusterName\": \"c-abcde\",\n\t\t\t\t\t\t\t\"hostname\":    \"my-node-1\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpointWithTTL(\"my-node-1.nodes.example.com\", endpoint.RecordTypeA, 300, \"203.0.113.10\").\n\t\t\t\t\tWithLabel(endpoint.ResourceLabelKey, \"node/cattle-system/my-node-1\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"with controller annotations match\",\n\t\t\tcfg: cfg{\n\t\t\t\tresources: []string{\"replicationgroups.v1.elasticache.upbound.io\"},\n\t\t\t},\n\t\t\tobjects: []*unstructured.Unstructured{\n\t\t\t\t{\n\t\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\t\"apiVersion\": \"elasticache.upbound.io/v1\",\n\t\t\t\t\t\t\"kind\":       \"ReplicationGroup\",\n\t\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\t\"name\":      \"cache\",\n\t\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\t\t\"annotations\": map[string]any{\n\t\t\t\t\t\t\t\tannotations.HostnameKey:   \"my-vm.redis.tld\",\n\t\t\t\t\t\t\t\tannotations.TargetKey:     \"1.1.1.0\",\n\t\t\t\t\t\t\t\tannotations.ControllerKey: annotations.ControllerValue,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"my-vm.redis.tld\", endpoint.RecordTypeA, \"1.1.1.0\").\n\t\t\t\t\tWithLabel(endpoint.ResourceLabelKey, \"replicationgroup/default/cache\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"with controller annotations do not match\",\n\t\t\tcfg: cfg{\n\t\t\t\tresources: []string{\"replicationgroups.v1.elasticache.upbound.io\"},\n\t\t\t},\n\t\t\tobjects: []*unstructured.Unstructured{\n\t\t\t\t{\n\t\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\t\"apiVersion\": \"elasticache.upbound.io/v1\",\n\t\t\t\t\t\t\"kind\":       \"ReplicationGroup\",\n\t\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\t\"name\":      \"my-vm\",\n\t\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\t\t\"annotations\": map[string]any{\n\t\t\t\t\t\t\t\tannotations.HostnameKey:   \"my-vm.redis.tld\",\n\t\t\t\t\t\t\t\tannotations.TargetKey:     \"10.10.10.0\",\n\t\t\t\t\t\t\t\tannotations.ControllerKey: \"custom-controller\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle: \"labelSelector matches\",\n\t\t\tcfg: cfg{\n\t\t\t\tresources:     []string{\"virtualmachineinstances.v1.kubevirt.io\"},\n\t\t\t\tlabelSelector: \"env=prod\",\n\t\t\t},\n\t\t\tobjects: []*unstructured.Unstructured{\n\t\t\t\t{\n\t\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\t\"apiVersion\": \"kubevirt.io/v1\",\n\t\t\t\t\t\t\"kind\":       \"VirtualMachineInstance\",\n\t\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\t\"name\":      \"prod-vm\",\n\t\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\t\t\"labels\": map[string]any{\n\t\t\t\t\t\t\t\t\"env\": \"prod\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"annotations\": map[string]any{\n\t\t\t\t\t\t\t\tannotations.HostnameKey: \"prod-vm.example.com\",\n\t\t\t\t\t\t\t\tannotations.TargetKey:   \"10.0.0.1\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\t\"apiVersion\": \"kubevirt.io/v1\",\n\t\t\t\t\t\t\"kind\":       \"VirtualMachineInstance\",\n\t\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\t\"name\":      \"dev-vm\",\n\t\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\t\t\"labels\": map[string]any{\n\t\t\t\t\t\t\t\t\"env\": \"dev\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"annotations\": map[string]any{\n\t\t\t\t\t\t\t\tannotations.HostnameKey: \"dev-vm.example.com\",\n\t\t\t\t\t\t\t\tannotations.TargetKey:   \"10.0.0.2\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"prod-vm.example.com\", endpoint.RecordTypeA, \"10.0.0.1\").\n\t\t\t\t\tWithLabel(endpoint.ResourceLabelKey, \"virtualmachineinstance/default/prod-vm\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"labelSelector no match\",\n\t\t\tcfg: cfg{\n\t\t\t\tresources:     []string{\"virtualmachineinstances.v1.kubevirt.io\"},\n\t\t\t\tlabelSelector: \"env=staging\",\n\t\t\t},\n\t\t\tobjects: []*unstructured.Unstructured{\n\t\t\t\t{\n\t\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\t\"apiVersion\": \"kubevirt.io/v1\",\n\t\t\t\t\t\t\"kind\":       \"VirtualMachineInstance\",\n\t\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\t\"name\":      \"prod-vm\",\n\t\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\t\t\"labels\": map[string]any{\n\t\t\t\t\t\t\t\t\"env\": \"prod\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"annotations\": map[string]any{\n\t\t\t\t\t\t\t\tannotations.HostnameKey: \"prod-vm.example.com\",\n\t\t\t\t\t\t\t\tannotations.TargetKey:   \"10.0.0.1\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle: \"annotationFilter matches\",\n\t\t\tcfg: cfg{\n\t\t\t\tresources:        []string{\"virtualmachineinstances.v1.kubevirt.io\"},\n\t\t\t\tannotationFilter: \"team=platform\",\n\t\t\t},\n\t\t\tobjects: []*unstructured.Unstructured{\n\t\t\t\t{\n\t\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\t\"apiVersion\": \"kubevirt.io/v1\",\n\t\t\t\t\t\t\"kind\":       \"VirtualMachineInstance\",\n\t\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\t\"name\":      \"platform-vm\",\n\t\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\t\t\"annotations\": map[string]any{\n\t\t\t\t\t\t\t\t\"team\":                  \"platform\",\n\t\t\t\t\t\t\t\tannotations.HostnameKey: \"platform-vm.example.com\",\n\t\t\t\t\t\t\t\tannotations.TargetKey:   \"10.0.0.1\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\t\"apiVersion\": \"kubevirt.io/v1\",\n\t\t\t\t\t\t\"kind\":       \"VirtualMachineInstance\",\n\t\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\t\"name\":      \"backend-vm\",\n\t\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\t\t\"annotations\": map[string]any{\n\t\t\t\t\t\t\t\t\"team\":                  \"backend\",\n\t\t\t\t\t\t\t\tannotations.HostnameKey: \"backend-vm.example.com\",\n\t\t\t\t\t\t\t\tannotations.TargetKey:   \"10.0.0.2\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"platform-vm.example.com\", endpoint.RecordTypeA, \"10.0.0.1\").\n\t\t\t\t\tWithLabel(endpoint.ResourceLabelKey, \"virtualmachineinstance/default/platform-vm\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"annotationFilter no match\",\n\t\t\tcfg: cfg{\n\t\t\t\tresources:        []string{\"virtualmachineinstances.v1.kubevirt.io\"},\n\t\t\t\tannotationFilter: \"team=security\",\n\t\t\t},\n\t\t\tobjects: []*unstructured.Unstructured{\n\t\t\t\t{\n\t\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\t\"apiVersion\": \"kubevirt.io/v1\",\n\t\t\t\t\t\t\"kind\":       \"VirtualMachineInstance\",\n\t\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\t\"name\":      \"platform-vm\",\n\t\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\t\t\"annotations\": map[string]any{\n\t\t\t\t\t\t\t\t\"team\":                  \"platform\",\n\t\t\t\t\t\t\t\tannotations.HostnameKey: \"platform-vm.example.com\",\n\t\t\t\t\t\t\t\tannotations.TargetKey:   \"10.0.0.1\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle: \"labelSelector and annotationFilter combined\",\n\t\t\tcfg: cfg{\n\t\t\t\tresources:        []string{\"virtualmachineinstances.v1.kubevirt.io\"},\n\t\t\t\tlabelSelector:    \"env=prod\",\n\t\t\t\tannotationFilter: \"team=platform\",\n\t\t\t},\n\t\t\tobjects: []*unstructured.Unstructured{\n\t\t\t\t{\n\t\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\t\"apiVersion\": \"kubevirt.io/v1\",\n\t\t\t\t\t\t\"kind\":       \"VirtualMachineInstance\",\n\t\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\t\"name\":      \"prod-platform-vm\",\n\t\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\t\t\"labels\": map[string]any{\n\t\t\t\t\t\t\t\t\"env\": \"prod\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"annotations\": map[string]any{\n\t\t\t\t\t\t\t\t\"team\":                  \"platform\",\n\t\t\t\t\t\t\t\tannotations.HostnameKey: \"prod-platform-vm.example.com\",\n\t\t\t\t\t\t\t\tannotations.TargetKey:   \"10.0.0.1\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\t\"apiVersion\": \"kubevirt.io/v1\",\n\t\t\t\t\t\t\"kind\":       \"VirtualMachineInstance\",\n\t\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\t\"name\":      \"prod-backend-vm\",\n\t\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\t\t\"labels\": map[string]any{\n\t\t\t\t\t\t\t\t\"env\": \"prod\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"annotations\": map[string]any{\n\t\t\t\t\t\t\t\t\"team\":                  \"backend\",\n\t\t\t\t\t\t\t\tannotations.HostnameKey: \"prod-backend-vm.example.com\",\n\t\t\t\t\t\t\t\tannotations.TargetKey:   \"10.0.0.2\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\t\"apiVersion\": \"kubevirt.io/v1\",\n\t\t\t\t\t\t\"kind\":       \"VirtualMachineInstance\",\n\t\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\t\"name\":      \"dev-platform-vm\",\n\t\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\t\t\"labels\": map[string]any{\n\t\t\t\t\t\t\t\t\"env\": \"dev\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"annotations\": map[string]any{\n\t\t\t\t\t\t\t\t\"team\":                  \"platform\",\n\t\t\t\t\t\t\t\tannotations.HostnameKey: \"dev-platform-vm.example.com\",\n\t\t\t\t\t\t\t\tannotations.TargetKey:   \"10.0.0.3\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"prod-platform-vm.example.com\", endpoint.RecordTypeA, \"10.0.0.1\").\n\t\t\t\t\tWithLabel(endpoint.ResourceLabelKey, \"virtualmachineinstance/default/prod-platform-vm\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"provider-specific annotation is not supported and is ignored\",\n\t\t\tcfg: cfg{\n\t\t\t\tresources: []string{\"machines.v1beta1.cluster.x-k8s.io\"},\n\t\t\t},\n\t\t\tobjects: []*unstructured.Unstructured{\n\t\t\t\t{\n\t\t\t\t\tObject: map[string]any{\n\t\t\t\t\t\t\"apiVersion\": \"cluster.x-k8s.io/v1beta1\",\n\t\t\t\t\t\t\"kind\":       \"Machine\",\n\t\t\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\t\t\"name\":      \"control-plane\",\n\t\t\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\t\t\"labels\": map[string]any{\n\t\t\t\t\t\t\t\t\"cluster.x-k8s.io/cluster-name\":  \"test-cluster\",\n\t\t\t\t\t\t\t\t\"cluster.x-k8s.io/control-plane\": \"\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"annotations\": map[string]any{\n\t\t\t\t\t\t\t\tannotations.HostnameKey:      \"control-plane.example.com\",\n\t\t\t\t\t\t\t\tannotations.TargetKey:        \"10.0.0.1\",\n\t\t\t\t\t\t\t\tannotations.CloudflarePrefix: \"cloudflare-specific-annotation\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\t\"spec\": map[string]any{\n\t\t\t\t\t\t\t\"clusterName\": \"test-cluster\",\n\t\t\t\t\t\t\t\"bootstrap\": map[string]any{\n\t\t\t\t\t\t\t\t\"dataSecretName\": \"control-plane-bootstrap\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"version\": \"v1.26.0\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"control-plane.example.com\", endpoint.RecordTypeA, \"10.0.0.1\").\n\t\t\t\t\tWithLabel(endpoint.ResourceLabelKey, \"machine/default/control-plane\"),\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(tt.title, func(t *testing.T) {\n\t\t\tkubeClient, dynamicClient := setupUnstructuredTestClients(t, tt.cfg.resources, tt.objects)\n\n\t\t\tlabelSelector := labels.Everything()\n\t\t\tif tt.cfg.labelSelector != \"\" {\n\t\t\t\tvar err error\n\t\t\t\tlabelSelector, err = labels.Parse(tt.cfg.labelSelector)\n\t\t\t\trequire.NoError(t, err)\n\t\t\t}\n\n\t\t\tsrc, err := NewUnstructuredFQDNSource(\n\t\t\t\tt.Context(),\n\t\t\t\tdynamicClient,\n\t\t\t\tkubeClient,\n\t\t\t\t&Config{\n\t\t\t\t\tAnnotationFilter:         tt.cfg.annotationFilter,\n\t\t\t\t\tLabelFilter:              labelSelector,\n\t\t\t\t\tUnstructuredResources:    tt.cfg.resources,\n\t\t\t\t\tCombineFQDNAndAnnotation: tt.cfg.combine,\n\t\t\t\t},\n\t\t\t)\n\t\t\trequire.NoError(t, err)\n\n\t\t\tendpoints, err := src.Endpoints(t.Context())\n\t\t\trequire.NoError(t, err)\n\n\t\t\tvalidateEndpoints(t, endpoints, tt.expected)\n\n\t\t\tfor _, ep := range endpoints {\n\t\t\t\trequire.Contains(t, ep.Labels, endpoint.ResourceLabelKey)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestProcessEndpoint_Unstructured_RefObjectExist(t *testing.T) {\n\tresources := []string{\"virtualmachineinstances.v1.kubevirt.io\"}\n\tobjects := []*unstructured.Unstructured{\n\t\t{\n\t\t\tObject: map[string]any{\n\t\t\t\t\"apiVersion\": \"kubevirt.io/v1\",\n\t\t\t\t\"kind\":       \"VirtualMachineInstance\",\n\t\t\t\t\"metadata\": map[string]any{\n\t\t\t\t\t\"name\":      \"prod-platform-vm\",\n\t\t\t\t\t\"namespace\": \"default\",\n\t\t\t\t\t\"labels\": map[string]any{\n\t\t\t\t\t\t\"env\": \"prod\",\n\t\t\t\t\t},\n\t\t\t\t\t\"annotations\": map[string]any{\n\t\t\t\t\t\t\"team\":                  \"platform\",\n\t\t\t\t\t\tannotations.HostnameKey: \"prod-platform-vm.example.com\",\n\t\t\t\t\t\tannotations.TargetKey:   \"10.0.0.1\",\n\t\t\t\t\t},\n\t\t\t\t\t\"uid\": \"12345\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tkubeClient, dynamicClient := setupUnstructuredTestClients(t, resources, objects)\n\n\tsrc, err := NewUnstructuredFQDNSource(\n\t\tt.Context(),\n\t\tdynamicClient,\n\t\tkubeClient,\n\t\t&Config{\n\t\t\tLabelFilter:           labels.Everything(),\n\t\t\tUnstructuredResources: resources,\n\t\t},\n\t)\n\trequire.NoError(t, err)\n\n\tendpoints, err := src.Endpoints(t.Context())\n\trequire.NoError(t, err)\n\ttestutils.AssertEndpointsHaveRefObject(t, endpoints, types.Unstructured, len(objects))\n}\n\nfunc TestEndpointsForHostsAndTargets(t *testing.T) {\n\ttests := []struct {\n\t\tname      string\n\t\thostnames []string\n\t\ttargets   []string\n\t\texpected  []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\tname:      \"empty hostnames returns nil\",\n\t\t\thostnames: []string{},\n\t\t\ttargets:   []string{\"192.168.1.1\"},\n\t\t\texpected:  nil,\n\t\t},\n\t\t{\n\t\t\tname:      \"empty targets returns nil\",\n\t\t\thostnames: []string{\"example.com\"},\n\t\t\ttargets:   []string{},\n\t\t\texpected:  nil,\n\t\t},\n\t\t{\n\t\t\tname:      \"duplicate hostname with IPv4 and IPv6 targets\",\n\t\t\thostnames: []string{\"example.com\", \"example.com\"},\n\t\t\ttargets:   []string{\"192.168.1.1\", \"192.168.1.1\", \"2001:db8::1\"},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeA, \"192.168.1.1\"),\n\t\t\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeAAAA, \"2001:db8::1\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:      \"multiple hostnames with single target\",\n\t\t\thostnames: []string{\"example.com\", \"www.example.com\"},\n\t\t\ttargets:   []string{\"192.168.1.1\"},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeA, \"192.168.1.1\"),\n\t\t\t\tendpoint.NewEndpoint(\"www.example.com\", endpoint.RecordTypeA, \"192.168.1.1\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:      \"multiple of each type maintains grouping\",\n\t\t\thostnames: []string{\"example.com\"},\n\t\t\ttargets:   []string{\"192.168.1.1\", \"192.168.1.2\", \"2001:db8::1\", \"2001:db8::2\", \"a.example.com\", \"b.example.com\"},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeA, \"192.168.1.1\", \"192.168.1.2\"),\n\t\t\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeAAAA, \"2001:db8::1\", \"2001:db8::2\"),\n\t\t\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeCNAME, \"a.example.com\", \"b.example.com\"),\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tresult := EndpointsForHostsAndTargets(tc.hostnames, tc.targets)\n\t\t\tif tc.expected == nil {\n\t\t\t\tassert.Nil(t, result)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tvalidateEndpoints(t, result, tc.expected)\n\t\t})\n\t}\n}\n\n// setupUnstructuredTestClients creates fake kube and dynamic clients with the given resources and objects.\nfunc setupUnstructuredTestClients(t *testing.T, resources []string, objects []*unstructured.Unstructured) (\n\tkubernetes.Interface, dynamic.Interface,\n) {\n\tt.Helper()\n\n\t// Parse all resource identifiers and build apiVersion → GVR map in one pass\n\tgvrs := make([]schema.GroupVersionResource, 0, len(resources))\n\tapiVersionToGVR := make(map[string]schema.GroupVersionResource, len(resources))\n\tfor _, res := range resources {\n\t\tif strings.Count(res, \".\") == 1 {\n\t\t\tres += \".\"\n\t\t}\n\t\tgvr, _ := schema.ParseResourceArg(res)\n\t\trequire.NotNil(t, gvr, \"invalid resource identifier: %s\", res)\n\t\tgvrs = append(gvrs, *gvr)\n\t\tapiVersionToGVR[gvr.GroupVersion().String()] = *gvr\n\t}\n\n\t// Derive kind and list kind from objects\n\tgvrToKind := make(map[schema.GroupVersionResource]string, len(gvrs))\n\tgvrToListKind := make(map[schema.GroupVersionResource]string, len(gvrs))\n\tfor _, obj := range objects {\n\t\tif gvr, ok := apiVersionToGVR[obj.GetAPIVersion()]; ok {\n\t\t\tgvrToKind[gvr] = obj.GetKind()\n\t\t\tgvrToListKind[gvr] = obj.GetKind() + \"List\"\n\t\t}\n\t}\n\n\t// Build discovery resource lists\n\tapiResourceLists := make([]*metav1.APIResourceList, 0, len(gvrs))\n\tfor _, gvr := range gvrs {\n\t\tapiResourceLists = append(apiResourceLists, &metav1.APIResourceList{\n\t\t\tGroupVersion: gvr.GroupVersion().String(),\n\t\t\tAPIResources: []metav1.APIResource{{\n\t\t\t\tName:       gvr.Resource,\n\t\t\t\tNamespaced: true,\n\t\t\t\tKind:       gvrToKind[gvr],\n\t\t\t}},\n\t\t})\n\t}\n\n\tkubeClient := fake.NewClientset()\n\tkubeClient.Discovery().(*discoveryfake.FakeDiscovery).Resources = apiResourceLists\n\n\tdynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), gvrToListKind)\n\n\tfor _, obj := range objects {\n\t\tgvr, ok := apiVersionToGVR[obj.GetAPIVersion()]\n\t\trequire.True(t, ok, \"no resource found for apiVersion %s\", obj.GetAPIVersion())\n\t\t_, err := dynamicClient.Resource(gvr).Namespace(obj.GetNamespace()).Create(\n\t\t\tt.Context(), obj, metav1.CreateOptions{})\n\t\trequire.NoError(t, err)\n\t}\n\n\treturn kubeClient, dynamicClient\n}\n"
  },
  {
    "path": "source/utils.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n    http://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"fmt\"\n\t\"slices\"\n\t\"strings\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\n// ParseIngress parses an ingress string in the format \"namespace/name\" or \"name\".\n// It returns the namespace and name extracted from the string, or an error if the format is invalid.\n// If the namespace is not provided, it defaults to an empty string.\nfunc ParseIngress(ingress string) (string, string, error) {\n\tvar namespace, name string\n\tvar err error\n\tparts := strings.Split(ingress, \"/\")\n\tswitch len(parts) {\n\tcase 2:\n\t\tnamespace, name = parts[0], parts[1]\n\tcase 1:\n\t\tname = parts[0]\n\tdefault:\n\t\terr = fmt.Errorf(\"invalid ingress name (name or namespace/name) found %q\", ingress)\n\t}\n\n\treturn namespace, name, err\n}\n\n// MatchesServiceSelector checks if all key-value pairs in the selector map\n// are present and match the corresponding key-value pairs in the svcSelector map.\n// It returns true if all pairs match, otherwise it returns false.\nfunc MatchesServiceSelector(selector, svcSelector map[string]string) bool {\n\tfor k, v := range selector {\n\t\tif lbl, ok := svcSelector[k]; !ok || lbl != v {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// MergeEndpoints merges endpoints with the same key (DNSName + RecordType + SetIdentifier + RecordTTL)\n// by combining their targets. CNAME endpoints are not merged (per DNS spec) but are deduplicated.\n// This is useful when multiple resources (e.g., pods, nodes) contribute targets to the same DNS record.\n//\n// TODO: move this to endpoint/utils.go\nfunc MergeEndpoints(endpoints []*endpoint.Endpoint) []*endpoint.Endpoint {\n\tif len(endpoints) == 0 {\n\t\treturn endpoints\n\t}\n\n\tendpointMap := make(map[endpoint.EndpointKey]*endpoint.Endpoint)\n\tcnameTargets := make(map[string]string) // DNSName+SetIdentifier -> first target seen\n\n\tfor _, ep := range endpoints {\n\t\tif ep.RecordType == endpoint.RecordTypeCNAME && len(ep.Targets) == 0 {\n\t\t\tlog.Debugf(\"Skipping CNAME endpoint %q with no targets\", ep.DNSName)\n\t\t\tcontinue\n\t\t}\n\n\t\tkey := endpoint.EndpointKey{\n\t\t\tDNSName:       ep.DNSName,\n\t\t\tRecordType:    ep.RecordType,\n\t\t\tSetIdentifier: ep.SetIdentifier,\n\t\t\tRecordTTL:     ep.RecordTTL,\n\t\t}\n\t\t// CNAME records can only have one target per DNS spec, and they should not be merged.\n\t\tif ep.RecordType == endpoint.RecordTypeCNAME {\n\t\t\tkey.Target = ep.Targets[0]\n\t\t\tcnameKey := ep.DNSName + \"/\" + ep.SetIdentifier\n\t\t\tif existing, ok := cnameTargets[cnameKey]; ok && existing != ep.Targets[0] {\n\t\t\t\t// This will be caught by the provider when it tries to create the record, but log a warning here to make it more obvious.\n\t\t\t\t// TODO: add metric for CNAME conflicts\n\t\t\t\tlog.Warnf(\"Only one CNAME per name — %s CNAME %s and %s CNAME %s is invalid DNS. A resolver wouldn't know which canonical name to follow.\", ep.DNSName, existing, ep.DNSName, ep.Targets[0])\n\t\t\t}\n\t\t\tcnameTargets[cnameKey] = ep.Targets[0]\n\t\t}\n\t\tif existing, ok := endpointMap[key]; ok {\n\t\t\texisting.Targets = append(existing.Targets, ep.Targets...)\n\t\t} else {\n\t\t\tendpointMap[key] = ep\n\t\t}\n\t}\n\n\tresult := make([]*endpoint.Endpoint, 0, len(endpointMap))\n\tfor _, ep := range endpointMap {\n\t\tslices.Sort(ep.Targets)\n\t\tep.Targets = slices.Compact(ep.Targets)\n\t\tresult = append(result, ep)\n\t}\n\n\treturn result\n}\n"
  },
  {
    "path": "source/utils_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n    http://www.apache.org/licenses/LICENSE-2.0\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage source\n\nimport (\n\t\"testing\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/assert\"\n\tv1 \"k8s.io/api/core/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/internal/testutils\"\n\tlogtest \"sigs.k8s.io/external-dns/internal/testutils/log\"\n\t\"sigs.k8s.io/external-dns/source/types\"\n)\n\nfunc TestParseIngress(t *testing.T) {\n\ttests := []struct {\n\t\tname      string\n\t\tingress   string\n\t\twantNS    string\n\t\twantName  string\n\t\twantError bool\n\t}{\n\t\t{\n\t\t\tname:      \"valid namespace and name\",\n\t\t\tingress:   \"default/test-ingress\",\n\t\t\twantNS:    \"default\",\n\t\t\twantName:  \"test-ingress\",\n\t\t\twantError: false,\n\t\t},\n\t\t{\n\t\t\tname:      \"only name provided\",\n\t\t\tingress:   \"test-ingress\",\n\t\t\twantNS:    \"\",\n\t\t\twantName:  \"test-ingress\",\n\t\t\twantError: false,\n\t\t},\n\t\t{\n\t\t\tname:      \"invalid format\",\n\t\t\tingress:   \"default/test/ingress\",\n\t\t\twantNS:    \"\",\n\t\t\twantName:  \"\",\n\t\t\twantError: true,\n\t\t},\n\t\t{\n\t\t\tname:      \"empty string\",\n\t\t\tingress:   \"\",\n\t\t\twantNS:    \"\",\n\t\t\twantName:  \"\",\n\t\t\twantError: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgotNS, gotName, err := ParseIngress(tt.ingress)\n\t\t\tif tt.wantError {\n\t\t\t\tassert.Error(t, err)\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t}\n\t\t\tassert.Equal(t, tt.wantNS, gotNS)\n\t\t\tassert.Equal(t, tt.wantName, gotName)\n\t\t})\n\t}\n}\n\nfunc TestSelectorMatchesService(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tselector    map[string]string\n\t\tsvcSelector map[string]string\n\t\texpected    bool\n\t}{\n\t\t{\n\t\t\tname:        \"all key-value pairs match\",\n\t\t\tselector:    map[string]string{\"app\": \"nginx\", \"env\": \"prod\"},\n\t\t\tsvcSelector: map[string]string{\"app\": \"nginx\", \"env\": \"prod\"},\n\t\t\texpected:    true,\n\t\t},\n\t\t{\n\t\t\tname:        \"one key-value pair does not match\",\n\t\t\tselector:    map[string]string{\"app\": \"nginx\", \"env\": \"prod\"},\n\t\t\tsvcSelector: map[string]string{\"app\": \"nginx\", \"env\": \"dev\"},\n\t\t\texpected:    false,\n\t\t},\n\t\t{\n\t\t\tname:        \"key not present in svcSelector\",\n\t\t\tselector:    map[string]string{\"app\": \"nginx\", \"env\": \"prod\"},\n\t\t\tsvcSelector: map[string]string{\"app\": \"nginx\"},\n\t\t\texpected:    false,\n\t\t},\n\t\t{\n\t\t\tname:        \"empty selector\",\n\t\t\tselector:    map[string]string{},\n\t\t\tsvcSelector: map[string]string{\"app\": \"nginx\", \"env\": \"prod\"},\n\t\t\texpected:    true,\n\t\t},\n\t\t{\n\t\t\tname:        \"empty svcSelector\",\n\t\t\tselector:    map[string]string{\"app\": \"nginx\", \"env\": \"prod\"},\n\t\t\tsvcSelector: map[string]string{},\n\t\t\texpected:    false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := MatchesServiceSelector(tt.selector, tt.svcSelector)\n\t\t\tassert.Equal(t, tt.expected, result)\n\t\t})\n\t}\n}\n\nfunc TestMergeEndpoints(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tinput    []*endpoint.Endpoint\n\t\texpected []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\tname:     \"nil input returns nil\",\n\t\t\tinput:    nil,\n\t\t\texpected: nil,\n\t\t},\n\t\t{\n\t\t\tname:     \"empty input returns empty\",\n\t\t\tinput:    []*endpoint.Endpoint{},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\tname: \"single endpoint unchanged\",\n\t\t\tinput: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"different keys not merged\",\n\t\t\tinput: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"a.example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{DNSName: \"b.example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"5.6.7.8\"}},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"a.example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{DNSName: \"b.example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"5.6.7.8\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"same DNSName different RecordType not merged\",\n\t\t\tinput: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{DNSName: \"example.com\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:db8::1\"}},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{DNSName: \"example.com\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:db8::1\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"same key merged with sorted targets\",\n\t\t\tinput: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"5.6.7.8\"}},\n\t\t\t\t{DNSName: \"example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\", \"5.6.7.8\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"multiple endpoints same key merged\",\n\t\t\tinput: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"3.3.3.3\"}},\n\t\t\t\t{DNSName: \"example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.1.1.1\"}},\n\t\t\t\t{DNSName: \"example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"2.2.2.2\"}},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.1.1.1\", \"2.2.2.2\", \"3.3.3.3\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"mixed merge and no merge\",\n\t\t\tinput: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"a.example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.1.1.1\"}},\n\t\t\t\t{DNSName: \"b.example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"2.2.2.2\"}},\n\t\t\t\t{DNSName: \"a.example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"3.3.3.3\"}},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"a.example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.1.1.1\", \"3.3.3.3\"}},\n\t\t\t\t{DNSName: \"b.example.com\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"2.2.2.2\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"duplicate targets deduplicated\",\n\t\t\tinput: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeA, \"1.2.3.4\", \"1.2.3.4\", \"5.6.7.8\"),\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeA, \"1.2.3.4\", \"5.6.7.8\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"duplicate targets across merged endpoints deduplicated\",\n\t\t\tinput: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeA, \"1.2.3.4\"),\n\t\t\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeA, \"1.2.3.4\", \"5.6.7.8\"),\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeA, \"1.2.3.4\", \"5.6.7.8\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"CNAME endpoints not merged\",\n\t\t\tinput: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeCNAME, \"a.elb.com\"),\n\t\t\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeCNAME, \"b.elb.com\"),\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeCNAME, \"a.elb.com\"),\n\t\t\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeCNAME, \"b.elb.com\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"CNAME with no targets is skipped\",\n\t\t\tinput: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeCNAME),\n\t\t\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeA, \"1.2.3.4\"),\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeA, \"1.2.3.4\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"identical CNAME endpoints deduplicated\",\n\t\t\tinput: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeCNAME, \"a.elb.com\"),\n\t\t\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeCNAME, \"a.elb.com\"),\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeCNAME, \"a.elb.com\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"same key with different TTL not merged\",\n\t\t\tinput: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpointWithTTL(\"example.com\", endpoint.RecordTypeA, 300, \"1.2.3.4\"),\n\t\t\t\tendpoint.NewEndpointWithTTL(\"example.com\", endpoint.RecordTypeA, 600, \"5.6.7.8\"),\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpointWithTTL(\"example.com\", endpoint.RecordTypeA, 300, \"1.2.3.4\"),\n\t\t\t\tendpoint.NewEndpointWithTTL(\"example.com\", endpoint.RecordTypeA, 600, \"5.6.7.8\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"same DNSName and RecordType with different SetIdentifier not merged\",\n\t\t\tinput: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeA, \"1.2.3.4\").WithSetIdentifier(\"us-east-1\"),\n\t\t\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeA, \"5.6.7.8\").WithSetIdentifier(\"eu-west-1\"),\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeA, \"1.2.3.4\").WithSetIdentifier(\"us-east-1\"),\n\t\t\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeA, \"5.6.7.8\").WithSetIdentifier(\"eu-west-1\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"same DNSName, RecordType and SetIdentifier targets are merged\",\n\t\t\tinput: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeA, \"1.2.3.4\").WithSetIdentifier(\"us-east-1\"),\n\t\t\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeA, \"5.6.7.8\").WithSetIdentifier(\"us-east-1\"),\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeA, \"1.2.3.4\", \"5.6.7.8\").WithSetIdentifier(\"us-east-1\"),\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := MergeEndpoints(tt.input)\n\t\t\tassert.ElementsMatch(t, tt.expected, result)\n\t\t})\n\t}\n}\n\nfunc TestMergeEndpoints_RefObjects(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tinput    func() []*endpoint.Endpoint\n\t\texpected func(*testing.T, []*endpoint.Endpoint)\n\t}{\n\t\t{\n\t\t\tname:  \"empty input\",\n\t\t\tinput: func() []*endpoint.Endpoint { return []*endpoint.Endpoint{} },\n\t\t\texpected: func(t *testing.T, ep []*endpoint.Endpoint) {\n\t\t\t\tassert.Empty(t, ep)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"single endpoint\",\n\t\t\tinput: func() []*endpoint.Endpoint {\n\t\t\t\treturn []*endpoint.Endpoint{\n\t\t\t\t\ttestutils.NewEndpointWithRef(\"example.com\", \"1.2.3.4\", &v1.Service{\n\t\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"foo\", Namespace: \"default\", UID: \"123\"},\n\t\t\t\t\t}, types.Service),\n\t\t\t\t}\n\t\t\t},\n\t\t\texpected: func(t *testing.T, ep []*endpoint.Endpoint) {\n\t\t\t\tassert.Len(t, ep, 1)\n\t\t\t\tassert.Equal(t, types.Service, ep[0].RefObject().Source)\n\t\t\t\tassert.Equal(t, \"foo\", ep[0].RefObject().Name)\n\t\t\t\tassert.Equal(t, \"123\", string(ep[0].RefObject().UID))\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"two endpoints merged and only single refObject preserved\",\n\t\t\tinput: func() []*endpoint.Endpoint {\n\t\t\t\treturn []*endpoint.Endpoint{\n\t\t\t\t\ttestutils.NewEndpointWithRef(\"a.example.com\", \"1.1.1.1\", &v1.Service{\n\t\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"foo\", Namespace: \"default\", UID: \"123\"},\n\t\t\t\t\t}, types.Service),\n\t\t\t\t\ttestutils.NewEndpointWithRef(\"a.example.com\", \"1.1.1.1\", &v1.Service{\n\t\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"bar\", Namespace: \"ns\", UID: \"345\"},\n\t\t\t\t\t}, types.Service),\n\t\t\t\t}\n\t\t\t},\n\t\t\texpected: func(t *testing.T, ep []*endpoint.Endpoint) {\n\t\t\t\tassert.Len(t, ep, 1)\n\t\t\t\tassert.Equal(t, types.Service, ep[0].RefObject().Source)\n\t\t\t\tassert.Equal(t, \"foo\", ep[0].RefObject().Name)\n\t\t\t\tassert.Equal(t, \"123\", string(ep[0].RefObject().UID))\n\t\t\t\tassert.NotEqual(t, \"345\", string(ep[0].RefObject().UID))\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"two endpoints not merged and two refObject preserved\",\n\t\t\tinput: func() []*endpoint.Endpoint {\n\t\t\t\treturn []*endpoint.Endpoint{\n\t\t\t\t\ttestutils.NewEndpointWithRef(\"a.example.com\", \"1.1.1.1\", &v1.Service{\n\t\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"foo\", Namespace: \"default\", UID: \"123\"},\n\t\t\t\t\t}, types.Service),\n\t\t\t\t\ttestutils.NewEndpointWithRef(\"b.example.com\", \"1.1.1.2\", &v1.Service{\n\t\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"bar\", Namespace: \"ns\", UID: \"345\"},\n\t\t\t\t\t}, types.Service),\n\t\t\t\t}\n\t\t\t},\n\t\t\texpected: func(t *testing.T, ep []*endpoint.Endpoint) {\n\t\t\t\tassert.Len(t, ep, 2)\n\t\t\t\tassert.NotEqual(t, ep[0], ep[1])\n\t\t\t\tfor _, el := range ep {\n\t\t\t\t\tassert.Equal(t, types.Service, el.RefObject().Source)\n\t\t\t\t\tassert.Contains(t, []string{\"foo\", \"bar\"}, el.RefObject().Name)\n\t\t\t\t\tassert.Contains(t, []string{\"123\", \"345\"}, string(el.RefObject().UID))\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := MergeEndpoints(tt.input())\n\t\t\ttt.expected(t, result)\n\t\t})\n\t}\n}\n\nfunc TestMergeEndpointsLogging(t *testing.T) {\n\tt.Run(\"warns on CNAME conflict\", func(t *testing.T) {\n\t\thook := logtest.LogsUnderTestWithLogLevel(log.WarnLevel, t)\n\n\t\tMergeEndpoints([]*endpoint.Endpoint{\n\t\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeCNAME, \"a.elb.com\"),\n\t\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeCNAME, \"b.elb.com\"),\n\t\t})\n\n\t\tlogtest.TestHelperLogContainsWithLogLevel(\"Only one CNAME per name\", log.WarnLevel, hook, t)\n\t\tlogtest.TestHelperLogContains(\"example.com CNAME a.elb.com\", hook, t)\n\t\tlogtest.TestHelperLogContains(\"example.com CNAME b.elb.com\", hook, t)\n\t})\n\n\tt.Run(\"no warning for identical CNAMEs\", func(t *testing.T) {\n\t\thook := logtest.LogsUnderTestWithLogLevel(log.WarnLevel, t)\n\n\t\tMergeEndpoints([]*endpoint.Endpoint{\n\t\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeCNAME, \"a.elb.com\"),\n\t\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeCNAME, \"a.elb.com\"),\n\t\t})\n\n\t\tlogtest.TestHelperLogNotContains(\"Only one CNAME per name\", hook, t)\n\t})\n\n\tt.Run(\"no warning for same DNSName with different SetIdentifier\", func(t *testing.T) {\n\t\thook := logtest.LogsUnderTestWithLogLevel(log.WarnLevel, t)\n\n\t\tMergeEndpoints([]*endpoint.Endpoint{\n\t\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeCNAME, \"a.elb.com\").WithSetIdentifier(\"weight-1\"),\n\t\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeCNAME, \"b.elb.com\").WithSetIdentifier(\"weight-2\"),\n\t\t})\n\n\t\tlogtest.TestHelperLogNotContains(\"Only one CNAME per name\", hook, t)\n\t})\n\n\tt.Run(\"debug log for CNAME with no targets\", func(t *testing.T) {\n\t\thook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t)\n\n\t\tMergeEndpoints([]*endpoint.Endpoint{\n\t\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeCNAME),\n\t\t})\n\n\t\tlogtest.TestHelperLogContainsWithLogLevel(\"Skipping CNAME endpoint\", log.DebugLevel, hook, t)\n\t\tlogtest.TestHelperLogContains(\"example.com\", hook, t)\n\t})\n}\n"
  },
  {
    "path": "source/wrappers/dedupsource.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage wrappers\n\nimport (\n\t\"context\"\n\t\"strings\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source\"\n)\n\n// dedupSource is a Source that removes duplicate endpoints from its wrapped source.\ntype dedupSource struct {\n\tsource source.Source\n}\n\n// NewDedupSource creates a new dedupSource wrapping the provided Source.\nfunc NewDedupSource(source source.Source) source.Source {\n\treturn &dedupSource{source: source}\n}\n\n// Endpoints collects endpoints from its wrapped source and returns them without duplicates.\nfunc (ms *dedupSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) {\n\tlog.Debug(\"dedupSource: collecting endpoints and removing duplicates\")\n\tresult := make([]*endpoint.Endpoint, 0)\n\tcollected := make(map[string]struct{})\n\n\tendpoints, err := ms.source.Endpoints(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor _, ep := range endpoints {\n\t\tif ep == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\t// validate endpoint before normalization\n\t\tif ok := ep.CheckEndpoint(); !ok {\n\t\t\tlog.Warnf(\"Skipping endpoint [%s:%s] due to invalid configuration [%s:%s]\", ep.SetIdentifier, ep.DNSName, ep.RecordType, strings.Join(ep.Targets, \",\"))\n\t\t\tcontinue\n\t\t}\n\n\t\tif len(ep.Targets) > 1 {\n\t\t\tep.Targets = endpoint.NewTargets(ep.Targets...)\n\t\t}\n\n\t\tidentifier := strings.Join([]string{ep.RecordType, ep.DNSName, ep.SetIdentifier, ep.Targets.String()}, \"/\")\n\n\t\tif _, ok := collected[identifier]; ok {\n\t\t\tlog.Debugf(\"Removing duplicate endpoint %s\", ep)\n\t\t\tcontinue\n\t\t}\n\n\t\tcollected[identifier] = struct{}{}\n\t\tresult = append(result, ep)\n\t}\n\n\treturn result, nil\n}\n\nfunc (ms *dedupSource) AddEventHandler(ctx context.Context, handler func()) {\n\tlog.Debug(\"dedupSource: adding event handler\")\n\tms.source.AddEventHandler(ctx, handler)\n}\n"
  },
  {
    "path": "source/wrappers/dedupsource_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage wrappers\n\nimport (\n\t\"testing\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\t\"github.com/stretchr/testify/require\"\n\tv1 \"k8s.io/api/core/v1\"\n\tnetworkingv1 \"k8s.io/api/networking/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/internal/testutils\"\n\tlogtest \"sigs.k8s.io/external-dns/internal/testutils/log\"\n\t\"sigs.k8s.io/external-dns/source\"\n\t\"sigs.k8s.io/external-dns/source/types\"\n)\n\n// Validates that dedupSource is a Source\nvar _ source.Source = &dedupSource{}\n\n// TestDedupEndpoints tests that duplicates from the wrapped source are removed.\nfunc TestDedupEndpoints(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\ttitle     string\n\t\tendpoints []*endpoint.Endpoint\n\t\texpected  []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\t\"one endpoint returns one endpoint\",\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"two different endpoints return two endpoints\",\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{DNSName: \"bar.example.org\", Targets: endpoint.Targets{\"4.5.6.7\"}},\n\t\t\t},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{DNSName: \"bar.example.org\", Targets: endpoint.Targets{\"4.5.6.7\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"two endpoints with same dnsname and different targets return two endpoints\",\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{DNSName: \"foo.example.org\", Targets: endpoint.Targets{\"4.5.6.7\"}},\n\t\t\t},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{DNSName: \"foo.example.org\", Targets: endpoint.Targets{\"4.5.6.7\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"two endpoints with different dnsname and same target return two endpoints\",\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{DNSName: \"bar.example.org\", Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{DNSName: \"bar.example.org\", Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"two endpoints with same dnsname and same target return one endpoint\",\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{DNSName: \"foo.example.org\", Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"two endpoints with same dnsname, same type, and same target return one endpoint\",\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"two endpoints with same dnsname, different record type, and same target return two endpoints\",\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:db8::1\"}},\n\t\t\t},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:db8::1\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"two endpoints with same dnsname, one with record type, one without, and same target return two endpoints\",\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{DNSName: \"foo.example.org\", Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t\t{DNSName: \"foo.example.org\", Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"two endpoints with same dnsname, same type, same target but different SetIdentifier return two endpoints\",\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}, SetIdentifier: \"us-east-1\"},\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}, SetIdentifier: \"eu-west-1\"},\n\t\t\t},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}, SetIdentifier: \"us-east-1\"},\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}, SetIdentifier: \"eu-west-1\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"two endpoints with same dnsname, same type, same target and same SetIdentifier return one endpoint\",\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}, SetIdentifier: \"us-east-1\"},\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}, SetIdentifier: \"us-east-1\"},\n\t\t\t},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}, SetIdentifier: \"us-east-1\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"no endpoints returns empty endpoints\",\n\t\t\t[]*endpoint.Endpoint{},\n\t\t\t[]*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\t\"one endpoint with multiple targets returns one endpoint and targets without duplicates\",\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\", \"34.66.66.77\", \"34.66.66.77\"}},\n\t\t\t},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\", \"34.66.66.77\"}},\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(tc.title, func(t *testing.T) {\n\t\t\tmockSource := new(testutils.MockSource)\n\t\t\tmockSource.On(\"Endpoints\").Return(tc.endpoints, nil)\n\n\t\t\t// Create our object under test and get the endpoints.\n\t\t\tsource := NewDedupSource(mockSource)\n\n\t\t\tendpoints, err := source.Endpoints(t.Context())\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\n\t\t\t// Validate returned endpoints against desired endpoints.\n\t\t\tvalidateEndpoints(t, endpoints, tc.expected)\n\n\t\t\t// Validate that the mock source was called.\n\t\t\tmockSource.AssertExpectations(t)\n\t\t})\n\t}\n}\n\nfunc TestDedupSource_AddEventHandler(t *testing.T) {\n\ttests := []struct {\n\t\ttitle string\n\t\tinput []string\n\t\ttimes int\n\t}{\n\t\t{\n\t\t\ttitle: \"should add event handler\",\n\t\t\ttimes: 1,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.title, func(t *testing.T) {\n\t\t\tmockSource := testutils.NewMockSource()\n\n\t\t\tsrc := NewDedupSource(mockSource)\n\t\t\tsrc.AddEventHandler(t.Context(), func() {})\n\n\t\t\tmockSource.AssertNumberOfCalls(t, \"AddEventHandler\", tt.times)\n\t\t})\n\t}\n}\n\nfunc TestDedupEndpointsValidation(t *testing.T) {\n\ttests := []struct {\n\t\tname      string\n\t\tendpoints []*endpoint.Endpoint\n\t\texpected  []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\tname: \"mix of SRV records\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"_service._tcp.example.org\", RecordType: endpoint.RecordTypeSRV, Targets: endpoint.Targets{\"10 5 443 target.example.org.\"}}, // valid\n\t\t\t\t{DNSName: \"_service._tcp.example.org\", RecordType: endpoint.RecordTypeSRV, Targets: endpoint.Targets{\"11 5 target.example.org\"}},      // invalid\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"_service._tcp.example.org\", RecordType: endpoint.RecordTypeSRV, Targets: endpoint.Targets{\"10 5 443 target.example.org.\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"invalid SRV record - missing priority\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"_service._tcp.example.org\", RecordType: endpoint.RecordTypeSRV, Targets: endpoint.Targets{\"5 443 target.example.org\"}},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\tname: \"valid MX record\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeMX, Targets: endpoint.Targets{\"10 mail.example.org\"}},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeMX, Targets: endpoint.Targets{\"10 mail.example.org\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"invalid MX record - missing priority\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeMX, Targets: endpoint.Targets{\"mail.example.org\"}},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\tname: \"valid NAPTR record\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeNAPTR, Targets: endpoint.Targets{\"100 10 \\\"u\\\" \\\"E2U+sip\\\" \\\"!^.*$!sip:info@example.org!\\\" .\"}},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeNAPTR, Targets: endpoint.Targets{\"100 10 \\\"u\\\" \\\"E2U+sip\\\" \\\"!^.*$!sip:info@example.org!\\\" .\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"invalid NAPTR record - incomplete format\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeNAPTR, Targets: endpoint.Targets{\"100 10 \\\"u\\\"\"}}, // invalid\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeNAPTR, Targets: endpoint.Targets{\"100 10 \\\"u\\\"\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"mixed valid and invalid records\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"_service._tcp.example.org\", RecordType: endpoint.RecordTypeSRV, Targets: endpoint.Targets{\"10 5 443\"}}, // invalid\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeMX, Targets: endpoint.Targets{\"mail.example.org\"}},        // invalid\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"MX record with alias=true is filtered out\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeMX, Targets: endpoint.Targets{\"10 mail.example.org\"}, ProviderSpecific: endpoint.ProviderSpecific{{Name: \"alias\", Value: \"true\"}}},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\tname: \"A record with alias=true is kept\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"192.168.1.1\"}, ProviderSpecific: endpoint.ProviderSpecific{{Name: \"alias\", Value: \"true\"}}},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"192.168.1.1\"}, ProviderSpecific: endpoint.ProviderSpecific{{Name: \"alias\", Value: \"true\"}}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"SRV record with alias=true is filtered out\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"_sip._tcp.example.org\", RecordType: endpoint.RecordTypeSRV, Targets: endpoint.Targets{\"10 5 5060 sip.example.org.\"}, ProviderSpecific: endpoint.ProviderSpecific{{Name: \"alias\", Value: \"true\"}}},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\tname: \"mixed valid and invalid TXT, A, AAAA records\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeTXT, Targets: endpoint.Targets{\"v=spf1 include:example.com ~all\"}}, // valid\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeTXT, Targets: endpoint.Targets{\"\"}},                                // invalid\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"192.168.1.1\"}},                       // valid\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"not-an-ip\"}},                         // invalid\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:db8::1\"}},                    // valid\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"invalid-ipv6\"}},                   // invalid\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeTXT, Targets: endpoint.Targets{\"v=spf1 include:example.com ~all\"}},\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeTXT, Targets: endpoint.Targets{\"\"}},\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"192.168.1.1\"}},\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:db8::1\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"valid PTR record with reverse DNS name\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"2.49.168.192.in-addr.arpa\", RecordType: endpoint.RecordTypePTR, Targets: endpoint.Targets{\"web.example.com\"}},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"2.49.168.192.in-addr.arpa\", RecordType: endpoint.RecordTypePTR, Targets: endpoint.Targets{\"web.example.com\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"invalid PTR record - non-reverse DNS name\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"web.example.com\", RecordType: endpoint.RecordTypePTR, Targets: endpoint.Targets{\"other.example.com\"}},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\tname: \"invalid PTR record - target is an IP\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"1.0.0.10.in-addr.arpa\", RecordType: endpoint.RecordTypePTR, Targets: endpoint.Targets{\"10.0.0.1\"}},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\tname: \"A record with record-type annotation passes through\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}, ProviderSpecific: endpoint.ProviderSpecific{{Name: endpoint.ProviderSpecificRecordType, Value: \"PTR\"}}},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}, ProviderSpecific: endpoint.ProviderSpecific{{Name: endpoint.ProviderSpecificRecordType, Value: \"PTR\"}}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"duplicate A records with same record-type annotation are deduped\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}, ProviderSpecific: endpoint.ProviderSpecific{{Name: endpoint.ProviderSpecificRecordType, Value: \"PTR\"}}},\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}, ProviderSpecific: endpoint.ProviderSpecific{{Name: endpoint.ProviderSpecificRecordType, Value: \"PTR\"}}},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}, ProviderSpecific: endpoint.ProviderSpecific{{Name: endpoint.ProviderSpecificRecordType, Value: \"PTR\"}}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"A records with and without record-type annotation are deduped by identity key\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}, ProviderSpecific: endpoint.ProviderSpecific{{Name: endpoint.ProviderSpecificRecordType, Value: \"PTR\"}}},\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"1.2.3.4\"}, ProviderSpecific: endpoint.ProviderSpecific{{Name: endpoint.ProviderSpecificRecordType, Value: \"PTR\"}}},\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tmockSource := new(testutils.MockSource)\n\t\t\tmockSource.On(\"Endpoints\").Return(tt.endpoints, nil)\n\n\t\t\tsr := NewDedupSource(mockSource)\n\t\t\tendpoints, err := sr.Endpoints(t.Context())\n\t\t\trequire.NoError(t, err)\n\n\t\t\tvalidateEndpoints(t, endpoints, tt.expected)\n\t\t\tmockSource.AssertExpectations(t)\n\t\t})\n\t}\n}\n\nfunc TestDedupSource_WarnsOnInvalidEndpoint(t *testing.T) {\n\ttests := []struct {\n\t\tname       string\n\t\tendpoint   *endpoint.Endpoint\n\t\twantLogMsg string\n\t}{\n\t\t{\n\t\t\tname: \"invalid SRV record\",\n\t\t\tendpoint: &endpoint.Endpoint{\n\t\t\t\tDNSName:       \"example.org\",\n\t\t\t\tRecordType:    endpoint.RecordTypeSRV,\n\t\t\t\tSetIdentifier: \"default/svc/my-service\",\n\t\t\t\tTargets:       endpoint.Targets{\"10 mail.example.org\"},\n\t\t\t},\n\t\t\twantLogMsg: \"Skipping endpoint [default/svc/my-service:example.org] due to invalid configuration [SRV:10 mail.example.org]\",\n\t\t},\n\t\t{\n\t\t\tname: \"unsupported alias on MX record\",\n\t\t\tendpoint: &endpoint.Endpoint{\n\t\t\t\tDNSName:          \"example.org\",\n\t\t\t\tRecordType:       endpoint.RecordTypeMX,\n\t\t\t\tTargets:          endpoint.Targets{\"10 mail.example.org\"},\n\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{{Name: \"alias\", Value: \"true\"}},\n\t\t\t},\n\t\t\twantLogMsg: \"Endpoint example.org of type MX does not support alias records\",\n\t\t},\n\t\t{\n\t\t\tname: \"invalid PTR record with non-reverse DNS name\",\n\t\t\tendpoint: &endpoint.Endpoint{\n\t\t\t\tDNSName:    \"web.example.org\",\n\t\t\t\tRecordType: endpoint.RecordTypePTR,\n\t\t\t\tTargets:    endpoint.Targets{\"other.example.org\"},\n\t\t\t},\n\t\t\twantLogMsg: \"Skipping endpoint [:web.example.org] due to invalid configuration [PTR:other.example.org]\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\thook := logtest.LogsUnderTestWithLogLevel(log.WarnLevel, t)\n\n\t\t\tmockSource := new(testutils.MockSource)\n\t\t\tmockSource.On(\"Endpoints\").Return([]*endpoint.Endpoint{tt.endpoint}, nil)\n\n\t\t\tsrc := NewDedupSource(mockSource)\n\t\t\t_, err := src.Endpoints(t.Context())\n\t\t\trequire.NoError(t, err)\n\n\t\t\tlogtest.TestHelperLogContains(tt.wantLogMsg, hook, t)\n\t\t})\n\t}\n}\n\nfunc TestDedupSource_RefObjects(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tinput    func() []*endpoint.Endpoint\n\t\texpected func(*testing.T, []*endpoint.Endpoint)\n\t}{\n\t\t{\n\t\t\tname:  \"empty input\",\n\t\t\tinput: func() []*endpoint.Endpoint { return []*endpoint.Endpoint{} },\n\t\t\texpected: func(t *testing.T, ep []*endpoint.Endpoint) {\n\t\t\t\trequire.Empty(t, ep)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"single endpoint with RefObject preserved\",\n\t\t\tinput: func() []*endpoint.Endpoint {\n\t\t\t\treturn []*endpoint.Endpoint{\n\t\t\t\t\ttestutils.NewEndpointWithRef(\"example.com\", \"1.2.3.4\", &v1.Service{\n\t\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"foo\", Namespace: \"default\", UID: \"123\"},\n\t\t\t\t\t}, types.Service),\n\t\t\t\t}\n\t\t\t},\n\t\t\texpected: func(t *testing.T, ep []*endpoint.Endpoint) {\n\t\t\t\trequire.Len(t, ep, 1)\n\t\t\t\trequire.NotNil(t, ep[0].RefObject())\n\t\t\t\trequire.Equal(t, types.Service, ep[0].RefObject().Source)\n\t\t\t\trequire.Equal(t, \"foo\", ep[0].RefObject().Name)\n\t\t\t\trequire.Equal(t, \"123\", string(ep[0].RefObject().UID))\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"duplicate endpoints with same source type - first RefObject preserved\",\n\t\t\tinput: func() []*endpoint.Endpoint {\n\t\t\t\treturn []*endpoint.Endpoint{\n\t\t\t\t\ttestutils.NewEndpointWithRef(\"example.com\", \"1.2.3.4\", &v1.Service{\n\t\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"first-svc\", Namespace: \"default\", UID: \"uid-first\"},\n\t\t\t\t\t}, types.Service),\n\t\t\t\t\ttestutils.NewEndpointWithRef(\"example.com\", \"1.2.3.4\", &v1.Service{\n\t\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"second-svc\", Namespace: \"other\", UID: \"uid-second\"},\n\t\t\t\t\t}, types.Service),\n\t\t\t\t}\n\t\t\t},\n\t\t\texpected: func(t *testing.T, ep []*endpoint.Endpoint) {\n\t\t\t\trequire.Len(t, ep, 1)\n\t\t\t\trequire.NotNil(t, ep[0].RefObject())\n\t\t\t\trequire.Equal(t, types.Service, ep[0].RefObject().Source)\n\t\t\t\trequire.Equal(t, \"first-svc\", ep[0].RefObject().Name)\n\t\t\t\trequire.Equal(t, \"uid-first\", string(ep[0].RefObject().UID))\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"duplicate endpoints with different source types - first RefObject preserved\",\n\t\t\tinput: func() []*endpoint.Endpoint {\n\t\t\t\treturn []*endpoint.Endpoint{\n\t\t\t\t\ttestutils.NewEndpointWithRef(\"example.com\", \"1.2.3.4\", &v1.Service{\n\t\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"my-service\", Namespace: \"default\", UID: \"svc-uid\"},\n\t\t\t\t\t}, types.Service),\n\t\t\t\t\ttestutils.NewEndpointWithRef(\"example.com\", \"1.2.3.4\", &networkingv1.Ingress{\n\t\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"my-ingress\", Namespace: \"default\", UID: \"ing-uid\"},\n\t\t\t\t\t}, types.Ingress),\n\t\t\t\t}\n\t\t\t},\n\t\t\texpected: func(t *testing.T, ep []*endpoint.Endpoint) {\n\t\t\t\trequire.Len(t, ep, 1)\n\t\t\t\trequire.NotNil(t, ep[0].RefObject())\n\t\t\t\t// First endpoint (Service) wins, Ingress is discarded\n\t\t\t\trequire.Equal(t, types.Service, ep[0].RefObject().Source)\n\t\t\t\trequire.Equal(t, \"my-service\", ep[0].RefObject().Name)\n\t\t\t\trequire.Equal(t, \"svc-uid\", string(ep[0].RefObject().UID))\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"duplicate endpoints - Ingress first, Service second - Ingress RefObject preserved\",\n\t\t\tinput: func() []*endpoint.Endpoint {\n\t\t\t\treturn []*endpoint.Endpoint{\n\t\t\t\t\ttestutils.NewEndpointWithRef(\"example.com\", \"1.2.3.4\", &networkingv1.Ingress{\n\t\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"my-ingress\", Namespace: \"default\", UID: \"ing-uid\"},\n\t\t\t\t\t}, types.Ingress),\n\t\t\t\t\ttestutils.NewEndpointWithRef(\"example.com\", \"1.2.3.4\", &v1.Service{\n\t\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"my-service\", Namespace: \"default\", UID: \"svc-uid\"},\n\t\t\t\t\t}, types.Service),\n\t\t\t\t}\n\t\t\t},\n\t\t\texpected: func(t *testing.T, ep []*endpoint.Endpoint) {\n\t\t\t\trequire.Len(t, ep, 1)\n\t\t\t\trequire.NotNil(t, ep[0].RefObject())\n\t\t\t\t// First endpoint (Ingress) wins, Service is discarded\n\t\t\t\trequire.Equal(t, types.Ingress, ep[0].RefObject().Source)\n\t\t\t\trequire.Equal(t, \"my-ingress\", ep[0].RefObject().Name)\n\t\t\t\trequire.Equal(t, \"ing-uid\", string(ep[0].RefObject().UID))\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"non-duplicate endpoints with different source types - both RefObjects preserved\",\n\t\t\tinput: func() []*endpoint.Endpoint {\n\t\t\t\treturn []*endpoint.Endpoint{\n\t\t\t\t\ttestutils.NewEndpointWithRef(\"a.example.com\", \"1.1.1.1\", &v1.Service{\n\t\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"my-service\", Namespace: \"default\", UID: \"123\"},\n\t\t\t\t\t}, types.Service),\n\t\t\t\t\ttestutils.NewEndpointWithRef(\"b.example.com\", \"2.2.2.2\", &networkingv1.Ingress{\n\t\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"my-ingress\", Namespace: \"default\", UID: \"234\"},\n\t\t\t\t\t}, types.Ingress),\n\t\t\t\t}\n\t\t\t},\n\t\t\texpected: func(t *testing.T, ep []*endpoint.Endpoint) {\n\t\t\t\trequire.Len(t, ep, 2)\n\n\t\t\t\t// Find endpoints by DNS name since order may vary\n\t\t\t\tvar svcEndpoint, ingEndpoint *endpoint.Endpoint\n\t\t\t\tfor _, e := range ep {\n\t\t\t\t\tif e.DNSName == \"a.example.com\" {\n\t\t\t\t\t\tsvcEndpoint = e\n\t\t\t\t\t} else if e.DNSName == \"b.example.com\" {\n\t\t\t\t\t\tingEndpoint = e\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\trequire.NotNil(t, svcEndpoint)\n\t\t\t\trequire.NotNil(t, svcEndpoint.RefObject())\n\t\t\t\trequire.Equal(t, types.Service, svcEndpoint.RefObject().Source)\n\t\t\t\trequire.Equal(t, \"my-service\", svcEndpoint.RefObject().Name)\n\n\t\t\t\trequire.NotNil(t, ingEndpoint)\n\t\t\t\trequire.NotNil(t, ingEndpoint.RefObject())\n\t\t\t\trequire.Equal(t, types.Ingress, ingEndpoint.RefObject().Source)\n\t\t\t\trequire.Equal(t, \"my-ingress\", ingEndpoint.RefObject().Name)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"three duplicate endpoints from different sources - first RefObject preserved\",\n\t\t\tinput: func() []*endpoint.Endpoint {\n\t\t\t\treturn []*endpoint.Endpoint{\n\t\t\t\t\ttestutils.NewEndpointWithRef(\"example.com\", \"1.2.3.4\", &v1.Service{\n\t\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"my-service\", Namespace: \"default\", UID: \"123\"},\n\t\t\t\t\t}, types.Service),\n\t\t\t\t\ttestutils.NewEndpointWithRef(\"example.com\", \"1.2.3.4\", &networkingv1.Ingress{\n\t\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"my-ingress\", Namespace: \"default\", UID: \"345\"},\n\t\t\t\t\t}, types.Ingress),\n\t\t\t\t\ttestutils.NewEndpointWithRef(\"example.com\", \"1.2.3.4\", &v1.Pod{\n\t\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"my-pod\", Namespace: \"default\", UID: \"456\"},\n\t\t\t\t\t}, types.Pod),\n\t\t\t\t}\n\t\t\t},\n\t\t\texpected: func(t *testing.T, ep []*endpoint.Endpoint) {\n\t\t\t\trequire.Len(t, ep, 1)\n\t\t\t\trequire.NotNil(t, ep[0].RefObject())\n\t\t\t\t// First endpoint (Service) wins\n\t\t\t\trequire.Equal(t, types.Service, ep[0].RefObject().Source)\n\t\t\t\trequire.Equal(t, \"my-service\", ep[0].RefObject().Name)\n\t\t\t\trequire.Equal(t, \"123\", string(ep[0].RefObject().UID))\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"duplicate endpoints with one having nil RefObject - first RefObject preserved\",\n\t\t\tinput: func() []*endpoint.Endpoint {\n\t\t\t\treturn []*endpoint.Endpoint{\n\t\t\t\t\ttestutils.NewEndpointWithRef(\"example.com\", \"1.2.3.4\", &v1.Service{\n\t\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"my-service\", Namespace: \"default\", UID: \"123\"},\n\t\t\t\t\t}, types.Service),\n\t\t\t\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeA, \"1.2.3.4\"),\n\t\t\t\t}\n\t\t\t},\n\t\t\texpected: func(t *testing.T, ep []*endpoint.Endpoint) {\n\t\t\t\trequire.Len(t, ep, 1)\n\t\t\t\trequire.NotNil(t, ep[0].RefObject())\n\t\t\t\trequire.Equal(t, types.Service, ep[0].RefObject().Source)\n\t\t\t\trequire.Equal(t, \"123\", string(ep[0].RefObject().UID))\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"duplicate endpoints with first having nil RefObject - nil preserved\",\n\t\t\tinput: func() []*endpoint.Endpoint {\n\t\t\t\treturn []*endpoint.Endpoint{\n\t\t\t\t\tendpoint.NewEndpoint(\"example.com\", endpoint.RecordTypeA, \"1.2.3.4\"),\n\t\t\t\t\ttestutils.NewEndpointWithRef(\"example.com\", \"1.2.3.4\", &v1.Service{\n\t\t\t\t\t\tObjectMeta: metav1.ObjectMeta{Name: \"my-service\", Namespace: \"default\", UID: \"345\"},\n\t\t\t\t\t}, types.Service),\n\t\t\t\t}\n\t\t\t},\n\t\t\texpected: func(t *testing.T, ep []*endpoint.Endpoint) {\n\t\t\t\trequire.Len(t, ep, 1)\n\t\t\t\t// First endpoint (without RefObject) wins\n\t\t\t\trequire.Nil(t, ep[0].RefObject())\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tmockSource := new(testutils.MockSource)\n\t\t\tmockSource.On(\"Endpoints\").Return(tt.input(), nil)\n\n\t\t\tsrc := NewDedupSource(mockSource)\n\t\t\tendpoints, err := src.Endpoints(t.Context())\n\t\t\trequire.NoError(t, err)\n\n\t\t\ttt.expected(t, endpoints)\n\t\t\tmockSource.AssertExpectations(t)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "source/wrappers/multisource.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage wrappers\n\nimport (\n\t\"context\"\n\t\"reflect\"\n\t\"strings\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source\"\n)\n\n// multiSource is a Source that merges the endpoints of its nested Sources.\ntype multiSource struct {\n\tchildren            []source.Source\n\tdefaultTargets      []string\n\tforceDefaultTargets bool\n}\n\n// Endpoints collects endpoints of all nested Sources and returns them in a single slice.\nfunc (ms *multiSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) {\n\tlog.Debugf(\"multiSource: collecting endpoints from %d child sources and removing duplicates\", len(ms.children))\n\tresult := []*endpoint.Endpoint{}\n\thasDefaultTargets := len(ms.defaultTargets) > 0\n\n\tfor _, s := range ms.children {\n\t\tendpoints, err := s.Endpoints(ctx)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif !hasDefaultTargets {\n\t\t\tresult = append(result, endpoints...)\n\t\t\tcontinue\n\t\t}\n\n\t\tfor _, ep := range endpoints {\n\t\t\thasSourceTargets := len(ep.Targets) > 0\n\n\t\t\tif ms.forceDefaultTargets || !hasSourceTargets {\n\t\t\t\teps := endpoint.EndpointsForHostname(ep.DNSName, ms.defaultTargets, ep.RecordTTL, ep.ProviderSpecific, ep.SetIdentifier, \"\")\n\t\t\t\tfor _, e := range eps {\n\t\t\t\t\te.Labels = ep.Labels\n\t\t\t\t}\n\t\t\t\tresult = append(result, eps...)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tlog.Warnf(\"Source provided targets for %q (%s), ignoring default targets [%s] due to new behavior. Use --force-default-targets to revert to old behavior.\", ep.DNSName, ep.RecordType, strings.Join(ms.defaultTargets, \", \"))\n\t\t\tresult = append(result, ep)\n\t\t}\n\t}\n\n\treturn result, nil\n}\n\nfunc (ms *multiSource) AddEventHandler(ctx context.Context, handler func()) {\n\tlog.Debugf(\"multiSource: adding event handler for %d child sources\", len(ms.children))\n\tfor _, s := range ms.children {\n\t\tlog.Debugf(\"multiSource: adding event handler for child %q\", reflect.TypeOf(s).String())\n\t\ts.AddEventHandler(ctx, handler)\n\t}\n}\n\n// NewMultiSource creates a new multiSource.\nfunc NewMultiSource(children []source.Source, defaultTargets []string, forceDefaultTargets bool) source.Source {\n\treturn &multiSource{children: children, defaultTargets: defaultTargets, forceDefaultTargets: forceDefaultTargets}\n}\n"
  },
  {
    "path": "source/wrappers/multisource_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage wrappers\n\nimport (\n\t\"errors\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"sigs.k8s.io/external-dns/source\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/internal/testutils\"\n)\n\nfunc TestMultiSource(t *testing.T) {\n\tt.Parallel()\n\n\tt.Run(\"Interface\", testMultiSourceImplementsSource)\n\tt.Run(\"Endpoints\", testMultiSourceEndpoints)\n\tt.Run(\"EndpointsWithError\", testMultiSourceEndpointsWithError)\n\tt.Run(\"EndpointsDefaultTargets\", testMultiSourceEndpointsDefaultTargets)\n}\n\n// testMultiSourceImplementsSource tests that multiSource is a valid Source.\nfunc testMultiSourceImplementsSource(t *testing.T) {\n\tassert.Implements(t, (*source.Source)(nil), new(multiSource))\n}\n\n// testMultiSourceEndpoints tests merged endpoints from children are returned.\nfunc testMultiSourceEndpoints(t *testing.T) {\n\tfoo := &endpoint.Endpoint{DNSName: \"foo\", Targets: endpoint.Targets{\"8.8.8.8\"}}\n\tbar := &endpoint.Endpoint{DNSName: \"bar\", Targets: endpoint.Targets{\"8.8.4.4\"}}\n\n\tfor _, tc := range []struct {\n\t\ttitle           string\n\t\tnestedEndpoints [][]*endpoint.Endpoint\n\t\texpected        []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\t\"no child sources return no endpoints\",\n\t\t\tnil,\n\t\t\t[]*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\t\"single empty child source returns no endpoints\",\n\t\t\t[][]*endpoint.Endpoint{{}},\n\t\t\t[]*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\t\"single non-empty child source returns child's endpoints\",\n\t\t\t[][]*endpoint.Endpoint{{foo.DeepCopy()}},\n\t\t\t[]*endpoint.Endpoint{foo.DeepCopy()},\n\t\t},\n\t\t{\n\t\t\t\"multiple non-empty child sources returns merged children's endpoints\",\n\t\t\t[][]*endpoint.Endpoint{{foo.DeepCopy()}, {bar.DeepCopy()}},\n\t\t\t[]*endpoint.Endpoint{foo.DeepCopy(), bar.DeepCopy()},\n\t\t},\n\t} {\n\n\t\tt.Run(tc.title, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\t// Prepare the nested mock sources.\n\t\t\tsources := make([]source.Source, 0, len(tc.nestedEndpoints))\n\n\t\t\t// Populate the nested mock sources.\n\t\t\tfor _, endpoints := range tc.nestedEndpoints {\n\t\t\t\tsrc := new(testutils.MockSource)\n\t\t\t\tsrc.On(\"Endpoints\").Return(endpoints, nil)\n\n\t\t\t\tsources = append(sources, src)\n\t\t\t}\n\n\t\t\t// Create our object under test and get the endpoints.\n\t\t\tsource := NewMultiSource(sources, nil, false)\n\n\t\t\t// Get endpoints from the source.\n\t\t\tendpoints, err := source.Endpoints(t.Context())\n\t\t\trequire.NoError(t, err)\n\n\t\t\t// Validate returned endpoints against desired endpoints.\n\t\t\tvalidateEndpoints(t, endpoints, tc.expected)\n\n\t\t\t// Validate that the nested sources were called.\n\t\t\tfor _, src := range sources {\n\t\t\t\tsrc.(*testutils.MockSource).AssertExpectations(t)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// testMultiSourceEndpointsWithError tests that an error by a nested source is bubbled up.\nfunc testMultiSourceEndpointsWithError(t *testing.T) {\n\t// Create the expected error.\n\terrSomeError := errors.New(\"some error\")\n\n\t// Create a mocked source returning that error.\n\tsrc := new(testutils.MockSource)\n\tsrc.On(\"Endpoints\").Return(nil, errSomeError)\n\n\t// Create our object under test and get the endpoints.\n\tsource := NewMultiSource([]source.Source{src}, nil, false)\n\n\t// Get endpoints from our source.\n\t_, err := source.Endpoints(t.Context())\n\tassert.EqualError(t, err, \"some error\")\n\n\t// Validate that the nested source was called.\n\tsrc.AssertExpectations(t)\n}\n\nfunc testMultiSourceEndpointsDefaultTargets(t *testing.T) {\n\tt.Run(\"Defaults applied when source targets are empty\", func(t *testing.T) {\n\t\tdefaultTargetsA := []string{\"127.0.0.1\", \"127.0.0.2\"}\n\t\tdefaultTargetsAAAA := []string{\"2001:db8::1\"}\n\t\tdefaultTargetsCName := []string{\"foo.example.org\"}\n\t\tdefaultTargets := append(defaultTargetsA, defaultTargetsCName...) // nolint: gocritic // appendAssign\n\t\tdefaultTargets = append(defaultTargets, defaultTargetsAAAA...)    // nolint: gocritic // appendAssign\n\t\tlabels := endpoint.Labels{\"foo\": \"bar\"}\n\n\t\t// Endpoints FROM SOURCE has NO targets\n\t\tsourceEndpoints := []*endpoint.Endpoint{\n\t\t\t{DNSName: \"foo\", Targets: endpoint.Targets{}, Labels: labels},\n\t\t\t{DNSName: \"bar\", Targets: endpoint.Targets{}, Labels: labels},\n\t\t}\n\n\t\t// Expected endpoints SHOULD HAVE the default targets applied\n\t\texpectedEndpoints := []*endpoint.Endpoint{\n\t\t\t{DNSName: \"foo\", Targets: defaultTargetsA, RecordType: \"A\", Labels: labels},\n\t\t\t{DNSName: \"bar\", Targets: defaultTargetsA, RecordType: \"A\", Labels: labels},\n\t\t\t{DNSName: \"foo\", Targets: defaultTargetsAAAA, RecordType: \"AAAA\", Labels: labels},\n\t\t\t{DNSName: \"bar\", Targets: defaultTargetsAAAA, RecordType: \"AAAA\", Labels: labels},\n\t\t\t{DNSName: \"foo\", Targets: defaultTargetsCName, RecordType: \"CNAME\", Labels: labels},\n\t\t\t{DNSName: \"bar\", Targets: defaultTargetsCName, RecordType: \"CNAME\", Labels: labels},\n\t\t}\n\n\t\tsrc := new(testutils.MockSource)\n\t\tsrc.On(\"Endpoints\").Return(sourceEndpoints, nil)\n\n\t\t// Test with forceDefaultTargets=false (default behavior)\n\t\tsource := NewMultiSource([]source.Source{src}, defaultTargets, false)\n\n\t\tendpoints, err := source.Endpoints(t.Context())\n\t\trequire.NoError(t, err)\n\n\t\tvalidateEndpoints(t, endpoints, expectedEndpoints)\n\n\t\tsrc.AssertExpectations(t)\n\t})\n\n\tt.Run(\"Defaults NOT applied when source targets exist\", func(t *testing.T) {\n\t\tdefaultTargets := []string{\"127.0.0.1\"} // Default target\n\t\tlabels := endpoint.Labels{\"foo\": \"bar\"}\n\n\t\t// Endpoints FROM SOURCE HAS targets\n\t\tsourceEndpoints := []*endpoint.Endpoint{\n\t\t\t{DNSName: \"foo\", Targets: endpoint.Targets{\"8.8.8.8\"}, Labels: labels},\n\t\t\t{DNSName: \"bar\", Targets: endpoint.Targets{\"8.8.4.4\"}, Labels: labels},\n\t\t}\n\n\t\t// Expected endpoints SHOULD MATCH the source endpoints (defaults ignored)\n\t\texpectedEndpoints := []*endpoint.Endpoint{\n\t\t\t{DNSName: \"foo\", Targets: endpoint.Targets{\"8.8.8.8\"}, Labels: labels},\n\t\t\t{DNSName: \"bar\", Targets: endpoint.Targets{\"8.8.4.4\"}, Labels: labels},\n\t\t}\n\n\t\tsrc := new(testutils.MockSource)\n\t\tsrc.On(\"Endpoints\").Return(sourceEndpoints, nil)\n\n\t\t// Test with forceDefaultTargets=false (default behavior)\n\t\tsource := NewMultiSource([]source.Source{src}, defaultTargets, false)\n\n\t\tendpoints, err := source.Endpoints(t.Context())\n\t\trequire.NoError(t, err)\n\n\t\tvalidateEndpoints(t, endpoints, expectedEndpoints)\n\n\t\tsrc.AssertExpectations(t)\n\t})\n\n\tt.Run(\"Defaults forced when source targets exist and flag is set\", func(t *testing.T) {\n\t\tdefaultTargetsA := []string{\"127.0.0.1\", \"127.0.0.2\"}\n\t\tdefaultTargetsAAAA := []string{\"2001:db8::1\"}\n\t\tdefaultTargetsCName := []string{\"foo.example.org\"}\n\t\tdefaultTargets := append(defaultTargetsA, defaultTargetsCName...) // nolint: gocritic // appendAssign\n\t\tdefaultTargets = append(defaultTargets, defaultTargetsAAAA...)    // nolint: gocritic // appendAssign\n\t\tlabels := endpoint.Labels{\"foo\": \"bar\"}\n\n\t\t// Endpoints FROM SOURCE HAS targets\n\t\tsourceEndpoints := []*endpoint.Endpoint{\n\t\t\t{DNSName: \"foo\", Targets: endpoint.Targets{\"8.8.8.8\"}, Labels: labels},\n\t\t\t{DNSName: \"bar\", Targets: endpoint.Targets{\"8.8.4.4\"}, Labels: labels},\n\t\t}\n\n\t\t// Expected endpoints SHOULD HAVE the default targets applied (old behavior)\n\t\texpectedEndpoints := []*endpoint.Endpoint{\n\t\t\t{DNSName: \"foo\", Targets: defaultTargetsA, RecordType: \"A\", Labels: labels},\n\t\t\t{DNSName: \"bar\", Targets: defaultTargetsA, RecordType: \"A\", Labels: labels},\n\t\t\t{DNSName: \"foo\", Targets: defaultTargetsAAAA, RecordType: \"AAAA\", Labels: labels},\n\t\t\t{DNSName: \"bar\", Targets: defaultTargetsAAAA, RecordType: \"AAAA\", Labels: labels},\n\t\t\t{DNSName: \"foo\", Targets: defaultTargetsCName, RecordType: \"CNAME\", Labels: labels},\n\t\t\t{DNSName: \"bar\", Targets: defaultTargetsCName, RecordType: \"CNAME\", Labels: labels},\n\t\t}\n\n\t\tsrc := new(testutils.MockSource)\n\t\tsrc.On(\"Endpoints\").Return(sourceEndpoints, nil)\n\n\t\t// Test with forceDefaultTargets=true (legacy behavior)\n\t\tsource := NewMultiSource([]source.Source{src}, defaultTargets, true)\n\n\t\tendpoints, err := source.Endpoints(t.Context())\n\t\trequire.NoError(t, err)\n\n\t\tvalidateEndpoints(t, endpoints, expectedEndpoints)\n\n\t\tsrc.AssertExpectations(t)\n\t})\n\n\tt.Run(\"Defaults applied when source targets are empty and flag is set\", func(t *testing.T) {\n\t\tdefaultTargetsA := []string{\"127.0.0.1\", \"127.0.0.2\"}\n\t\tdefaultTargetsAAAA := []string{\"2001:db8::1\"}\n\t\tdefaultTargetsCName := []string{\"foo.example.org\"}\n\t\tdefaultTargets := append(defaultTargetsA, defaultTargetsAAAA...) // nolint: gocritic // appendAssign\n\t\tdefaultTargets = append(defaultTargets, defaultTargetsCName...)  // nolint: gocritic // appendAssign\n\n\t\tlabels := endpoint.Labels{\"foo\": \"bar\"}\n\n\t\t// Endpoints FROM SOURCE has NO targets\n\t\tsourceEndpoints := []*endpoint.Endpoint{\n\t\t\t{DNSName: \"empty-target-test\", Targets: endpoint.Targets{}, Labels: labels},\n\t\t}\n\n\t\t// Expected endpoints SHOULD HAVE the default targets applied\n\t\texpectedEndpoints := []*endpoint.Endpoint{\n\t\t\t{DNSName: \"empty-target-test\", Targets: defaultTargetsA, RecordType: \"A\", Labels: labels},\n\t\t\t{DNSName: \"empty-target-test\", Targets: defaultTargetsAAAA, RecordType: \"AAAA\", Labels: labels},\n\t\t\t{DNSName: \"empty-target-test\", Targets: defaultTargetsCName, RecordType: \"CNAME\", Labels: labels},\n\t\t}\n\n\t\tsrc := new(testutils.MockSource)\n\t\tsrc.On(\"Endpoints\").Return(sourceEndpoints, nil)\n\n\t\t// Test with forceDefaultTargets=true\n\t\tsource := NewMultiSource([]source.Source{src}, defaultTargets, true)\n\n\t\tendpoints, err := source.Endpoints(t.Context())\n\t\trequire.NoError(t, err)\n\n\t\tvalidateEndpoints(t, endpoints, expectedEndpoints)\n\n\t\tsrc.AssertExpectations(t)\n\t})\n}\n\nfunc TestMultiSource_AddEventHandler(t *testing.T) {\n\ttests := []struct {\n\t\ttitle   string\n\t\tsources []source.Source\n\t\ttimes   int\n\t}{\n\t\t{\n\t\t\ttitle:   \"should not add event handler when sources are empty\",\n\t\t\tsources: []source.Source{},\n\t\t\ttimes:   0,\n\t\t},\n\t\t{\n\t\t\ttitle: \"should add event handler when sources not empty\",\n\t\t\tsources: []source.Source{\n\t\t\t\ttestutils.NewMockSource(),\n\t\t\t\ttestutils.NewMockSource(),\n\t\t\t\ttestutils.NewMockSource(),\n\t\t\t},\n\t\t\ttimes: 3,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.title, func(t *testing.T) {\n\t\t\tsrc := NewMultiSource(tt.sources, []string{}, true)\n\t\t\tsrc.AddEventHandler(t.Context(), func() {})\n\n\t\t\tcount := 0\n\n\t\t\tfor _, mockSource := range tt.sources {\n\t\t\t\tmSource := mockSource.(*testutils.MockSource)\n\t\t\t\tmSource.AssertNumberOfCalls(t, \"AddEventHandler\", 1)\n\t\t\t\tcount += 1\n\t\t\t}\n\n\t\t\tassert.Equal(t, tt.times, count)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "source/wrappers/nat64source.go",
    "content": "/*\nCopyright 2024 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage wrappers\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/netip\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source\"\n)\n\nvar (\n\taddrFromSlice = netip.AddrFromSlice\n)\n\n// nat64Source is a Source that adds A endpoints for AAAA records including an NAT64 address.\ntype nat64Source struct {\n\tsource        source.Source\n\tnat64Prefixes []netip.Prefix\n}\n\n// NewNAT64Source creates a new nat64Source wrapping the provided Source.\nfunc NewNAT64Source(source source.Source, nat64Prefixes []string) (source.Source, error) {\n\tparsedNAT64Prefixes := make([]netip.Prefix, 0)\n\tfor _, prefix := range nat64Prefixes {\n\t\tpPrefix, err := netip.ParsePrefix(prefix)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tif pPrefix.Bits() != 96 {\n\t\t\treturn nil, fmt.Errorf(\"NAT64 prefixes need to be /96 prefixes\")\n\t\t}\n\t\tparsedNAT64Prefixes = append(parsedNAT64Prefixes, pPrefix)\n\t}\n\treturn &nat64Source{source: source, nat64Prefixes: parsedNAT64Prefixes}, nil\n}\n\n// Endpoints collects endpoints from its wrapped source and returns them without duplicates.\nfunc (s *nat64Source) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) {\n\tlog.Debug(\"nat64Source: collecting endpoints and processing NAT64 translation\")\n\n\tadditionalEndpoints := []*endpoint.Endpoint{}\n\n\tendpoints, err := s.source.Endpoints(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor _, ep := range endpoints {\n\t\tif ep.RecordType != endpoint.RecordTypeAAAA {\n\t\t\tcontinue\n\t\t}\n\n\t\tv4Targets := make([]string, 0)\n\n\t\tfor _, target := range ep.Targets {\n\t\t\tip, err := netip.ParseAddr(target)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\tvar sPrefix *netip.Prefix\n\n\t\t\tfor _, cPrefix := range s.nat64Prefixes {\n\t\t\t\tif cPrefix.Contains(ip) {\n\t\t\t\t\tsPrefix = &cPrefix\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// If we do not have a NAT64 prefix, we skip this record.\n\t\t\tif sPrefix == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tipBytes := ip.As16()\n\t\t\tv4AddrBytes := ipBytes[12:16]\n\n\t\t\tv4Addr, ok := addrFromSlice(v4AddrBytes)\n\t\t\tif !ok {\n\t\t\t\treturn nil, fmt.Errorf(\"could not parse %v to IPv4 address\", v4AddrBytes)\n\t\t\t}\n\n\t\t\tv4Targets = append(v4Targets, v4Addr.String())\n\t\t}\n\n\t\tif len(v4Targets) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tv4EP := ep.DeepCopy()\n\t\tv4EP.Targets = v4Targets\n\t\tv4EP.RecordType = endpoint.RecordTypeA\n\n\t\tadditionalEndpoints = append(additionalEndpoints, v4EP)\n\t}\n\treturn append(endpoints, additionalEndpoints...), nil\n}\n\nfunc (s *nat64Source) AddEventHandler(ctx context.Context, handler func()) {\n\tlog.Debug(\"nat64Source: adding event handler\")\n\ts.source.AddEventHandler(ctx, handler)\n}\n"
  },
  {
    "path": "source/wrappers/nat64source_test.go",
    "content": "/*\nCopyright 2024 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage wrappers\n\nimport (\n\t\"net/netip\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/internal/testutils\"\n\t\"sigs.k8s.io/external-dns/source\"\n)\n\n// Validates that dedupSource is a Source\nvar _ source.Source = &nat64Source{}\n\nfunc TestNAT64Source(t *testing.T) {\n\tt.Run(\"Endpoints\", testNat64Source)\n}\n\n// testDedupEndpoints tests that duplicates from the wrapped source are removed.\nfunc testNat64Source(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\ttitle     string\n\t\tendpoints []*endpoint.Endpoint\n\t\texpected  []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\t\"single non-nat64 ipv6 endpoint returns one ipv6 endpoint\",\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:db8:1::1\"}},\n\t\t\t},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:db8:1::1\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"single nat64 ipv6 endpoint returns one ipv4 endpoint and one ipv6 endpoint\",\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:db8::192.0.2.42\"}},\n\t\t\t},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:db8::192.0.2.42\"}},\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"192.0.2.42\"}},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"single nat64 ipv6 endpoint returns one ipv4 endpoint and one ipv6 endpoint\",\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:db8::c000:22a\"}},\n\t\t\t},\n\t\t\t[]*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:db8::c000:22a\"}},\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"192.0.2.42\"}},\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(tc.title, func(t *testing.T) {\n\t\t\tmockSource := new(testutils.MockSource)\n\t\t\tmockSource.On(\"Endpoints\").Return(tc.endpoints, nil)\n\n\t\t\t// Create our object under test and get the endpoints.\n\t\t\tsource, err := NewNAT64Source(mockSource, []string{\"2001:DB8::/96\"})\n\t\t\trequire.NoError(t, err)\n\n\t\t\tendpoints, err := source.Endpoints(t.Context())\n\t\t\trequire.NoError(t, err)\n\n\t\t\t// Validate returned endpoints against desired endpoints.\n\t\t\tvalidateEndpoints(t, endpoints, tc.expected)\n\n\t\t\t// Validate that the mock source was called.\n\t\t\tmockSource.AssertExpectations(t)\n\t\t})\n\t}\n}\n\nfunc TestNat64Source_AddEventHandler(t *testing.T) {\n\ttests := []struct {\n\t\ttitle string\n\t\tinput []string\n\t\ttimes int\n\t}{\n\t\t{\n\t\t\ttitle: \"should add event handler when prefixes are provided\",\n\t\t\tinput: []string{\"2001:DB8::/96\"},\n\t\t\ttimes: 1,\n\t\t},\n\t\t{\n\t\t\ttitle: \"should add event handler when prefixes not provided\",\n\t\t\tinput: []string{},\n\t\t\ttimes: 1,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.title, func(t *testing.T) {\n\t\t\tmockSource := testutils.NewMockSource()\n\n\t\t\tsrc, err := NewNAT64Source(mockSource, tt.input)\n\t\t\trequire.NoError(t, err)\n\n\t\t\tsrc.AddEventHandler(t.Context(), func() {})\n\n\t\t\tmockSource.AssertNumberOfCalls(t, \"AddEventHandler\", tt.times)\n\t\t})\n\t}\n}\n\nfunc TestNewNAT64Source(t *testing.T) {\n\ttype args struct {\n\t\tsource        source.Source\n\t\tnat64Prefixes []string\n\t}\n\ttests := []struct {\n\t\tname    string\n\t\targs    args\n\t\twant    source.Source\n\t\twantErr bool\n\t}{\n\t\t{\n\t\t\tname: \"empty NAT64 prefixes should succeed\",\n\t\t\targs: args{\n\t\t\t\tsource:        &testutils.MockSource{},\n\t\t\t\tnat64Prefixes: []string{},\n\t\t\t},\n\t\t\twant: &nat64Source{source: &testutils.MockSource{}, nat64Prefixes: []netip.Prefix{}},\n\t\t},\n\t\t{\n\t\t\tname: \"multiple valid NAT64 prefixes should succeed\",\n\t\t\targs: args{\n\t\t\t\tsource:        &testutils.MockSource{},\n\t\t\t\tnat64Prefixes: []string{\"2001:db8::/96\", \"64:ff9b::/96\"},\n\t\t\t},\n\t\t\twant: &nat64Source{source: &testutils.MockSource{}, nat64Prefixes: []netip.Prefix{netip.MustParsePrefix(\"2001:db8::/96\"), netip.MustParsePrefix(\"64:ff9b::/96\")}},\n\t\t},\n\t\t{\n\t\t\tname: \"invalid NAT64 prefix should fail\",\n\t\t\targs: args{\n\t\t\t\tsource:        &testutils.MockSource{},\n\t\t\t\tnat64Prefixes: []string{\"invalid-prefix\"},\n\t\t\t},\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"NAT64 prefix with wrong mask length should fail\",\n\t\t\targs: args{\n\t\t\t\tsource:        &testutils.MockSource{},\n\t\t\t\tnat64Prefixes: []string{\"2001:db8::/64\"},\n\t\t\t},\n\t\t\twantErr: true,\n\t\t},\n\t\t{\n\t\t\tname: \"IPv4 address as NAT64 prefix should fail\",\n\t\t\targs: args{\n\t\t\t\tsource:        &testutils.MockSource{},\n\t\t\t\tnat64Prefixes: []string{\"192.0.2.0/24\"},\n\t\t\t},\n\t\t\twantErr: true,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tsrc, err := NewNAT64Source(tt.args.source, tt.args.nat64Prefixes)\n\t\t\tif tt.wantErr {\n\t\t\t\tassert.Error(t, err)\n\t\t\t} else {\n\t\t\t\tassert.NoError(t, err)\n\t\t\t}\n\t\t\tassert.Equal(t, tt.want, src)\n\t\t})\n\t}\n}\n\nfunc TestNat64SourceEndpoints_VariousCases(t *testing.T) {\n\ttests := []struct {\n\t\tname       string\n\t\tmockReturn []*endpoint.Endpoint\n\t\tmockError  error\n\t\tsetup      func()\n\t\tasserts    func([]*endpoint.Endpoint, error)\n\t}{\n\t\t{\n\t\t\tname:      \"expect source error propagation\",\n\t\t\tmockError: assert.AnError,\n\t\t\tasserts: func(eps []*endpoint.Endpoint, err error) {\n\t\t\t\tassert.Nil(t, eps)\n\t\t\t\trequire.Error(t, err)\n\t\t\t\trequire.ErrorIs(t, err, assert.AnError)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"skip nat64 processing for non-AAAA records\",\n\t\t\tmockReturn: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{\"10.10.10.11\"}},\n\t\t\t},\n\t\t\tasserts: func(eps []*endpoint.Endpoint, err error) {\n\t\t\t\tassert.NotNil(t, eps)\n\t\t\t\tassert.Len(t, eps, 1)\n\t\t\t\trequire.NoError(t, err)\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"target is not a valid IPv6 address\",\n\t\t\tmockReturn: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"not-an-ip\"}},\n\t\t\t},\n\t\t\tasserts: func(eps []*endpoint.Endpoint, err error) {\n\t\t\t\tassert.Nil(t, eps)\n\t\t\t\trequire.Error(t, err)\n\t\t\t\tassert.EqualError(t, err, \"ParseAddr(\\\"not-an-ip\\\"): unable to parse IP\")\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"addr from slice fails\",\n\t\t\tmockReturn: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo.example.org\", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{\"2001:db8::192.0.2.42\"}},\n\t\t\t},\n\t\t\tsetup: func() {\n\t\t\t\toriginalAddrFromSlice := addrFromSlice\n\t\t\t\taddrFromSlice = func([]byte) (netip.Addr, bool) {\n\t\t\t\t\treturn netip.Addr{}, false\n\t\t\t\t}\n\t\t\t\tt.Cleanup(func() {\n\t\t\t\t\taddrFromSlice = originalAddrFromSlice\n\t\t\t\t})\n\t\t\t},\n\t\t\tasserts: func(eps []*endpoint.Endpoint, err error) {\n\t\t\t\tassert.Nil(t, eps)\n\t\t\t\trequire.Error(t, err)\n\t\t\t\tassert.EqualError(t, err, \"could not parse [192 0 2 42] to IPv4 address\")\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tc := range tests {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tif tc.setup != nil {\n\t\t\t\ttc.setup()\n\t\t\t}\n\t\t\tmockSource := new(testutils.MockSource)\n\t\t\tmockSource.On(\"Endpoints\").Return(tc.mockReturn, tc.mockError)\n\n\t\t\tsrc, err := NewNAT64Source(mockSource, []string{\"2001:db8::/96\"})\n\t\t\trequire.NoError(t, err)\n\n\t\t\teps, err := src.Endpoints(t.Context())\n\t\t\ttc.asserts(eps, err)\n\n\t\t\tmockSource.AssertExpectations(t)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "source/wrappers/post_processor.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage wrappers\n\nimport (\n\t\"context\"\n\t\"strings\"\n\t\"time\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source\"\n\t\"sigs.k8s.io/external-dns/source/annotations\"\n)\n\ntype postProcessor struct {\n\tsource source.Source\n\tcfg    PostProcessorConfig\n}\n\ntype PostProcessorConfig struct {\n\tttl          int64\n\tprovider     string\n\tpreferAlias  bool\n\tisConfigured bool\n}\n\ntype PostProcessorOption func(*PostProcessorConfig)\n\nfunc WithTTL(ttl time.Duration) PostProcessorOption {\n\treturn func(cfg *PostProcessorConfig) {\n\t\tif int64(ttl.Seconds()) > 0 {\n\t\t\tcfg.isConfigured = true\n\t\t\tcfg.ttl = int64(ttl.Seconds())\n\t\t}\n\t}\n}\n\n// WithPostProcessorProvider sets the provider used to retain provider-specific\n// properties on endpoints. Empty or whitespace-only values are ignored.\nfunc WithPostProcessorProvider(input string) PostProcessorOption {\n\treturn func(cfg *PostProcessorConfig) {\n\t\tif p := strings.TrimSpace(input); p != \"\" {\n\t\t\tcfg.isConfigured = true\n\t\t\tcfg.provider = p\n\t\t}\n\t}\n}\n\n// WithPostProcessorPreferAlias enables setting alias=true on CNAME endpoints.\n// This signals to providers that support ALIAS records (like PowerDNS, AWS)\n// to create ALIAS records instead of CNAMEs.\nfunc WithPostProcessorPreferAlias(enabled bool) PostProcessorOption {\n\treturn func(cfg *PostProcessorConfig) {\n\t\tcfg.preferAlias = enabled\n\t\tif enabled {\n\t\t\tcfg.isConfigured = true\n\t\t}\n\t}\n}\n\nfunc NewPostProcessor(source source.Source, opts ...PostProcessorOption) source.Source {\n\tcfg := PostProcessorConfig{}\n\tfor _, opt := range opts {\n\t\topt(&cfg)\n\t}\n\treturn &postProcessor{source: source, cfg: cfg}\n}\n\nfunc (pp *postProcessor) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) {\n\tendpoints, err := pp.source.Endpoints(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif !pp.cfg.isConfigured {\n\t\treturn endpoints, nil\n\t}\n\n\tfor _, ep := range endpoints {\n\t\tif ep == nil {\n\t\t\tcontinue\n\t\t}\n\t\tep.WithMinTTL(pp.cfg.ttl)\n\t\tep.RetainProviderProperties(pp.cfg.provider)\n\t\t// Set alias annotation for CNAME records when preferAlias is enabled\n\t\t// Only set if not already explicitly configured at the source level\n\t\tif pp.cfg.preferAlias && ep.RecordType == endpoint.RecordTypeCNAME {\n\t\t\tif _, exists := ep.GetProviderSpecificProperty(annotations.AliasKey); !exists {\n\t\t\t\tep.WithProviderSpecific(\"alias\", \"true\")\n\t\t\t}\n\t\t}\n\t}\n\n\treturn endpoints, nil\n}\n\nfunc (pp *postProcessor) AddEventHandler(ctx context.Context, handler func()) {\n\tlog.Debug(\"postProcessor: adding event handler\")\n\tpp.source.AddEventHandler(ctx, handler)\n}\n"
  },
  {
    "path": "source/wrappers/post_processor_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage wrappers\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/require\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/internal/testutils\"\n)\n\nfunc TestWithPostProcessorProvider(t *testing.T) {\n\ttests := []struct {\n\t\tname           string\n\t\tinput          string\n\t\texpectProvider string\n\t\tisConfigured   bool\n\t}{\n\t\t{\n\t\t\tname:           \"valid provider\",\n\t\t\tinput:          \"aws\",\n\t\t\texpectProvider: \"aws\",\n\t\t\tisConfigured:   true,\n\t\t},\n\t\t{\n\t\t\tname:         \"empty string\",\n\t\t\tinput:        \"\",\n\t\t\tisConfigured: false,\n\t\t},\n\t\t{\n\t\t\tname:         \"whitespace only\",\n\t\t\tinput:        \"   \",\n\t\t\tisConfigured: false,\n\t\t},\n\t\t{\n\t\t\tname:           \"provider with surrounding whitespace\",\n\t\t\tinput:          \"  aws  \",\n\t\t\texpectProvider: \"aws\",\n\t\t\tisConfigured:   true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tcfg := &PostProcessorConfig{}\n\t\t\topt := WithPostProcessorProvider(tt.input)\n\t\t\topt(cfg)\n\n\t\t\trequire.Equal(t, tt.isConfigured, cfg.isConfigured, \"isConfigured mismatch\")\n\t\t\trequire.Equal(t, tt.expectProvider, cfg.provider, \"provider mismatch\")\n\t\t})\n\t}\n}\n\nfunc TestWithTTL(t *testing.T) {\n\ttests := []struct {\n\t\tname         string\n\t\tttlStr       string\n\t\texpectErr    bool\n\t\texpectTTL    int64\n\t\tisConfigured bool\n\t}{\n\t\t{\n\t\t\tname:         \"valid 10m6s\",\n\t\t\tttlStr:       \"10m6s\",\n\t\t\texpectErr:    false,\n\t\t\texpectTTL:    606,\n\t\t\tisConfigured: true,\n\t\t},\n\t\t{\n\t\t\tname:         \"valid 5m\",\n\t\t\tttlStr:       \"5m\",\n\t\t\texpectTTL:    300,\n\t\t\tisConfigured: true,\n\t\t},\n\t\t{\n\t\t\tname:      \"zero duration\",\n\t\t\tttlStr:    \"0s\",\n\t\t\texpectTTL: 0,\n\t\t},\n\t\t{\n\t\t\tname:      \"empty duration\",\n\t\t\tttlStr:    \"0s\",\n\t\t\texpectTTL: 0,\n\t\t},\n\t\t{\n\t\t\tname:      \"invalid duration\",\n\t\t\tttlStr:    \"notaduration\",\n\t\t\texpectErr: true,\n\t\t\texpectTTL: 0,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tcfg := &PostProcessorConfig{}\n\t\t\tttl, err := time.ParseDuration(tt.ttlStr)\n\t\t\tif tt.expectErr {\n\t\t\t\trequire.Error(t, err, \"should fail to parse duration string\")\n\t\t\t\treturn\n\t\t\t}\n\t\t\trequire.NoError(t, err, \"should parse duration string\")\n\n\t\t\topt := WithTTL(ttl)\n\t\t\topt(cfg)\n\n\t\t\trequire.Equal(t, tt.isConfigured, cfg.isConfigured, \"isConfigured mismatch\")\n\t\t\trequire.Equal(t, tt.expectTTL, cfg.ttl, \"ttl mismatch\")\n\t\t})\n\t}\n}\n\nfunc TestPostProcessorEndpointsWithTTL(t *testing.T) {\n\ttests := []struct {\n\t\ttitle     string\n\t\tttl       string\n\t\tendpoints []*endpoint.Endpoint\n\t\texpected  []*endpoint.Endpoint\n\t\texpectErr bool\n\t}{\n\t\t{\n\t\t\ttitle: \"process endpoints with TTL set\",\n\t\t\tttl:   \"6s\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"foo-1\", \"A\", \"1.2.3.4\"),\n\t\t\t\tendpoint.NewEndpointWithTTL(\"foo-2\", \"A\", 60, \"1.2.3.5\"),\n\t\t\t\tendpoint.NewEndpointWithTTL(\"foo-3\", \"A\", 0, \"1.2.3.6\"),\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpointWithTTL(\"foo-1\", \"A\", 6, \"1.2.3.4\"),\n\t\t\t\tendpoint.NewEndpointWithTTL(\"foo-2\", \"A\", 60, \"1.2.3.5\"),\n\t\t\t\tendpoint.NewEndpointWithTTL(\"foo-3\", \"A\", 6, \"1.2.3.6\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"skip endpoints processing with TTL set to 0\",\n\t\t\tttl:   \"0s\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"foo-1\", \"A\", \"1.2.3.4\"),\n\t\t\t\tendpoint.NewEndpointWithTTL(\"foo-2\", \"A\", 60, \"1.2.3.5\"),\n\t\t\t\tendpoint.NewEndpointWithTTL(\"foo-3\", \"A\", 0, \"1.2.3.6\"),\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"foo-1\", \"A\", \"1.2.3.4\"),\n\t\t\t\tendpoint.NewEndpointWithTTL(\"foo-2\", \"A\", 60, \"1.2.3.5\"),\n\t\t\t\tendpoint.NewEndpointWithTTL(\"foo-3\", \"A\", 0, \"1.2.3.6\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"skip endpoints processing for nill endpoint\",\n\t\t\tttl:   \"0s\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tnil,\n\t\t\t\tendpoint.NewEndpointWithTTL(\"foo-2\", \"A\", 60, \"1.2.3.5\"),\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tnil,\n\t\t\t\tendpoint.NewEndpointWithTTL(\"foo-2\", \"A\", 60, \"1.2.3.5\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle: \"endpoint foo-2 with TTL configured while foo-1 without TTL configured\",\n\t\t\tttl:   \"1s\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo-1\", Targets: endpoint.Targets{\"1.2.3.5\"}},\n\t\t\t\t{DNSName: \"foo-2\", Targets: endpoint.Targets{\"1.2.3.6\"}, RecordTTL: endpoint.TTL(0)},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{DNSName: \"foo-1\", Targets: endpoint.Targets{\"1.2.3.5\"}, RecordTTL: endpoint.TTL(1)},\n\t\t\t\t{DNSName: \"foo-2\", Targets: endpoint.Targets{\"1.2.3.6\"}, RecordTTL: endpoint.TTL(1)},\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\n\t\tt.Run(tt.title, func(t *testing.T) {\n\n\t\t\tms := new(testutils.MockSource)\n\t\t\tms.On(\"Endpoints\").Return(tt.endpoints, nil)\n\t\t\tttl, _ := time.ParseDuration(tt.ttl)\n\t\t\tsrc := NewPostProcessor(ms, WithTTL(ttl))\n\n\t\t\tendpoints, err := src.Endpoints(t.Context())\n\t\t\trequire.NoError(t, err)\n\t\t\tvalidateEndpoints(t, endpoints, tt.expected)\n\t\t})\n\t}\n}\n\nfunc TestPostProcessorEndpointsWithPostProcessorProviderFilter(t *testing.T) {\n\ttests := []struct {\n\t\ttitle     string\n\t\tprovider  string\n\t\tendpoints []*endpoint.Endpoint\n\t\texpected  []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\ttitle:    \"no provider configured, properties untouched\",\n\t\t\tprovider: \"\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName: \"foo-1\",\n\t\t\t\t\tTargets: endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{Name: \"aws/evaluate-target-health\", Value: \"true\"},\n\t\t\t\t\t\t{Name: \"coredns/group\", Value: \"my-group\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName: \"foo-1\",\n\t\t\t\t\tTargets: endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{Name: \"aws/evaluate-target-health\", Value: \"true\"},\n\t\t\t\t\t\t{Name: \"coredns/group\", Value: \"my-group\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:    \"provider configured, all properties match\",\n\t\t\tprovider: \"aws\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName: \"foo-1\",\n\t\t\t\t\tTargets: endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{Name: \"aws/evaluate-target-health\", Value: \"true\"},\n\t\t\t\t\t\t{Name: \"aws/weight\", Value: \"10\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName: \"foo-1\",\n\t\t\t\t\tTargets: endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{Name: \"aws/evaluate-target-health\", Value: \"true\"},\n\t\t\t\t\t\t{Name: \"aws/weight\", Value: \"10\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:    \"provider configured, mixed properties, only provider retained\",\n\t\t\tprovider: \"aws\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName: \"foo-1\",\n\t\t\t\t\tTargets: endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{Name: \"aws/evaluate-target-health\", Value: \"true\"},\n\t\t\t\t\t\t{Name: \"coredns/group\", Value: \"my-group\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName: \"foo-1\",\n\t\t\t\t\tTargets: endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{Name: \"aws/evaluate-target-health\", Value: \"true\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:    \"provider configured, no matching properties, empty result\",\n\t\t\tprovider: \"aws\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName: \"foo-1\",\n\t\t\t\t\tTargets: endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{Name: \"coredns/group\", Value: \"my-group\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName: \"foo-1\",\n\t\t\t\t\tTargets: endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:    \"provider agnostic properties without prefix are retained\",\n\t\t\tprovider: \"aws\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName: \"foo-1\",\n\t\t\t\t\tTargets: endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{Name: \"alias\", Value: \"true\"},\n\t\t\t\t\t\t{Name: \"aws/evaluate-target-health\", Value: \"true\"},\n\t\t\t\t\t\t{Name: \"coredns/group\", Value: \"my-group\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName: \"foo-1\",\n\t\t\t\t\tTargets: endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{Name: \"alias\", Value: \"true\"},\n\t\t\t\t\t\t{Name: \"aws/evaluate-target-health\", Value: \"true\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:    \"cloudflare retains all properties regardless of prefix\",\n\t\t\tprovider: \"cloudflare\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName: \"foo-1\",\n\t\t\t\t\tTargets: endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{Name: \"external-dns.alpha.kubernetes.io/cloudflare-tags\", Value: \"tag1\"},\n\t\t\t\t\t\t{Name: \"aws/evaluate-target-health\", Value: \"true\"},\n\t\t\t\t\t\t{Name: \"alias\", Value: \"false\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName: \"foo-1\",\n\t\t\t\t\tTargets: endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{Name: \"alias\", Value: \"false\"},\n\t\t\t\t\t\t{Name: \"aws/evaluate-target-health\", Value: \"true\"},\n\t\t\t\t\t\t{Name: \"external-dns.alpha.kubernetes.io/cloudflare-tags\", Value: \"tag1\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:    \"cloudflare properties are sorted\",\n\t\t\tprovider: \"cloudflare\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName: \"foo-1\",\n\t\t\t\t\tTargets: endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{Name: \"external-dns.alpha.kubernetes.io/cloudflare-tags\", Value: \"tag1\"},\n\t\t\t\t\t\t{Name: \"external-dns.alpha.kubernetes.io/cloudflare-proxied\", Value: \"true\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\t{\n\t\t\t\t\tDNSName: \"foo-1\",\n\t\t\t\t\tTargets: endpoint.Targets{\"1.2.3.4\"},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{Name: \"external-dns.alpha.kubernetes.io/cloudflare-proxied\", Value: \"true\"},\n\t\t\t\t\t\t{Name: \"external-dns.alpha.kubernetes.io/cloudflare-tags\", Value: \"tag1\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:    \"nil endpoint is skipped\",\n\t\t\tprovider: \"aws\",\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tnil,\n\t\t\t\t{\n\t\t\t\t\tDNSName: \"foo-2\",\n\t\t\t\t\tTargets: endpoint.Targets{\"1.2.3.5\"},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{Name: \"aws/weight\", Value: \"10\"},\n\t\t\t\t\t\t{Name: \"coredns/group\", Value: \"my-group\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tnil,\n\t\t\t\t{\n\t\t\t\t\tDNSName: \"foo-2\",\n\t\t\t\t\tTargets: endpoint.Targets{\"1.2.3.5\"},\n\t\t\t\t\tProviderSpecific: endpoint.ProviderSpecific{\n\t\t\t\t\t\t{Name: \"aws/weight\", Value: \"10\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.title, func(t *testing.T) {\n\t\t\tms := new(testutils.MockSource)\n\t\t\tms.On(\"Endpoints\").Return(tt.endpoints, nil)\n\t\t\tsrc := NewPostProcessor(ms, WithPostProcessorProvider(tt.provider))\n\n\t\t\tendpoints, err := src.Endpoints(t.Context())\n\t\t\trequire.NoError(t, err)\n\t\t\tvalidateEndpoints(t, endpoints, tt.expected)\n\t\t})\n\t}\n}\n\nfunc TestPostProcessor_AddEventHandler(t *testing.T) {\n\ttests := []struct {\n\t\ttitle string\n\t\tinput []string\n\t\ttimes int\n\t}{\n\t\t{\n\t\t\ttitle: \"should add event handler\",\n\t\t\ttimes: 1,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.title, func(t *testing.T) {\n\t\t\tmockSource := testutils.NewMockSource()\n\n\t\t\tsrc := NewPostProcessor(mockSource)\n\t\t\tsrc.AddEventHandler(t.Context(), func() {})\n\n\t\t\tmockSource.AssertNumberOfCalls(t, \"AddEventHandler\", tt.times)\n\t\t})\n\t}\n}\n\nfunc TestPostProcessorEndpointsWithPreferAlias(t *testing.T) {\n\ttests := []struct {\n\t\ttitle       string\n\t\tpreferAlias bool\n\t\tendpoints   []*endpoint.Endpoint\n\t\texpected    []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\ttitle:       \"CNAME records get alias annotation when preferAlias is enabled\",\n\t\t\tpreferAlias: true,\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"foo.example.com\", endpoint.RecordTypeCNAME, \"target.example.com\"),\n\t\t\t\tendpoint.NewEndpoint(\"bar.example.com\", endpoint.RecordTypeA, \"1.2.3.4\"),\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"foo.example.com\", endpoint.RecordTypeCNAME, \"target.example.com\").WithProviderSpecific(\"alias\", \"true\"),\n\t\t\t\tendpoint.NewEndpoint(\"bar.example.com\", endpoint.RecordTypeA, \"1.2.3.4\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:       \"CNAME records remain unchanged when preferAlias is disabled\",\n\t\t\tpreferAlias: false,\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"foo.example.com\", endpoint.RecordTypeCNAME, \"target.example.com\"),\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"foo.example.com\", endpoint.RecordTypeCNAME, \"target.example.com\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:       \"only CNAME records are affected, A records are unchanged\",\n\t\t\tpreferAlias: true,\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"a.example.com\", endpoint.RecordTypeA, \"1.2.3.4\"),\n\t\t\t\tendpoint.NewEndpoint(\"aaaa.example.com\", endpoint.RecordTypeAAAA, \"::1\"),\n\t\t\t\tendpoint.NewEndpoint(\"cname.example.com\", endpoint.RecordTypeCNAME, \"target.example.com\"),\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"a.example.com\", endpoint.RecordTypeA, \"1.2.3.4\"),\n\t\t\t\tendpoint.NewEndpoint(\"aaaa.example.com\", endpoint.RecordTypeAAAA, \"::1\"),\n\t\t\t\tendpoint.NewEndpoint(\"cname.example.com\", endpoint.RecordTypeCNAME, \"target.example.com\").WithProviderSpecific(\"alias\", \"true\"),\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.title, func(t *testing.T) {\n\t\t\tms := new(testutils.MockSource)\n\t\t\tms.On(\"Endpoints\").Return(tt.endpoints, nil)\n\t\t\tsrc := NewPostProcessor(ms, WithPostProcessorPreferAlias(tt.preferAlias))\n\n\t\t\tendpoints, err := src.Endpoints(t.Context())\n\t\t\trequire.NoError(t, err)\n\t\t\tvalidateEndpoints(t, endpoints, tt.expected)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "source/wrappers/source_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage wrappers\n\nimport (\n\t\"reflect\"\n\t\"sort\"\n\t\"testing\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\nfunc sortEndpoints(endpoints []*endpoint.Endpoint) {\n\tfor _, ep := range endpoints {\n\t\tif ep != nil {\n\t\t\tep.Targets = endpoint.NewTargets(ep.Targets...)\n\t\t}\n\t}\n\tsort.Slice(endpoints, func(i, k int) bool {\n\t\t// Sort by DNSName, RecordType, and Targets\n\t\tei, ek := endpoints[i], endpoints[k]\n\t\tif ei == nil || ek == nil {\n\t\t\treturn true\n\t\t}\n\t\tif ei.DNSName != ek.DNSName {\n\t\t\treturn ei.DNSName < ek.DNSName\n\t\t}\n\t\tif ei.RecordType != ek.RecordType {\n\t\t\treturn ei.RecordType < ek.RecordType\n\t\t}\n\t\t// Targets are sorted ahead of time.\n\t\tfor j, ti := range ei.Targets {\n\t\t\tif j >= len(ek.Targets) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tif tk := ek.Targets[j]; ti != tk {\n\t\t\t\treturn ti < tk\n\t\t\t}\n\t\t}\n\t\treturn false\n\t})\n}\n\nfunc validateEndpoints(t *testing.T, endpoints, expected []*endpoint.Endpoint) {\n\tt.Helper()\n\n\tif len(endpoints) != len(expected) {\n\t\tt.Fatalf(\"expected %d endpoints, got %d\", len(expected), len(endpoints))\n\t}\n\n\t// Make sure endpoints are sorted - validateEndpoint() depends on it.\n\tsortEndpoints(endpoints)\n\tsortEndpoints(expected)\n\n\tfor i := range endpoints {\n\t\tvalidateEndpoint(t, endpoints[i], expected[i])\n\t}\n}\n\nfunc validateEndpoint(t *testing.T, endpoint, expected *endpoint.Endpoint) {\n\tt.Helper()\n\n\tif endpoint == nil && expected == nil {\n\t\treturn\n\t}\n\n\tif endpoint.DNSName != expected.DNSName {\n\t\tt.Errorf(\"DNSName expected %q, got %q\", expected.DNSName, endpoint.DNSName)\n\t}\n\n\tif !endpoint.Targets.Same(expected.Targets) {\n\t\tt.Errorf(\"Targets expected %q, got %q\", expected.Targets, endpoint.Targets)\n\t}\n\n\tif endpoint.RecordTTL != expected.RecordTTL {\n\t\tt.Errorf(\"RecordTTL expected %v, got %v\", expected.RecordTTL, endpoint.RecordTTL)\n\t}\n\n\t// if a non-empty record type is expected, check that it matches.\n\tif endpoint.RecordType != expected.RecordType {\n\t\tt.Errorf(\"RecordType expected %q, got %q\", expected.RecordType, endpoint.RecordType)\n\t}\n\n\t// if non-empty labels are expected, check that they match.\n\tif expected.Labels != nil && !reflect.DeepEqual(endpoint.Labels, expected.Labels) {\n\t\tt.Errorf(\"Labels expected %s, got %s\", expected.Labels, endpoint.Labels)\n\t}\n\n\tif (len(expected.ProviderSpecific) != 0 || len(endpoint.ProviderSpecific) != 0) &&\n\t\t!reflect.DeepEqual(endpoint.ProviderSpecific, expected.ProviderSpecific) {\n\t\tt.Errorf(\"ProviderSpecific expected %s, got %s\", expected.ProviderSpecific, endpoint.ProviderSpecific)\n\t}\n\n\tif endpoint.SetIdentifier != expected.SetIdentifier {\n\t\tt.Errorf(\"SetIdentifier expected %q, got %q\", expected.SetIdentifier, endpoint.SetIdentifier)\n\t}\n}\n"
  },
  {
    "path": "source/wrappers/targetfiltersource.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage wrappers\n\nimport (\n\t\"context\"\n\n\tlog \"github.com/sirupsen/logrus\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source\"\n)\n\n// targetFilterSource is a Source that removes endpoints matching the target filter from its wrapped source.\ntype targetFilterSource struct {\n\tsource       source.Source\n\ttargetFilter endpoint.TargetFilterInterface\n}\n\n// NewTargetFilterSource creates a new targetFilterSource wrapping the provided Source.\nfunc NewTargetFilterSource(source source.Source, targetFilter endpoint.TargetFilterInterface) source.Source {\n\treturn &targetFilterSource{source: source, targetFilter: targetFilter}\n}\n\n// Endpoints collects endpoints from its wrapped source and returns\n// them without targets matching the target filter.\nfunc (ms *targetFilterSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) {\n\tlog.Debug(\"targetFilterSource: collecting endpoints from wrapped source and applying target filter\")\n\tendpoints, err := ms.source.Endpoints(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif !ms.targetFilter.IsEnabled() {\n\t\treturn endpoints, nil\n\t}\n\n\tresult := make([]*endpoint.Endpoint, 0, len(endpoints))\n\n\tfor _, ep := range endpoints {\n\t\tfilteredTargets := make([]string, 0, len(ep.Targets))\n\n\t\tfor _, t := range ep.Targets {\n\t\t\tif ms.targetFilter.Match(t) {\n\t\t\t\tfilteredTargets = append(filteredTargets, t)\n\t\t\t}\n\t\t}\n\n\t\t// If all targets are filtered out, skip the endpoint.\n\t\tif len(filteredTargets) == 0 {\n\t\t\tlog.WithField(\"endpoint\", ep).Debugf(\"Skipping endpoint because all targets were filtered out\")\n\t\t\tcontinue\n\t\t}\n\n\t\tep.Targets = filteredTargets\n\n\t\tresult = append(result, ep)\n\t}\n\n\treturn result, nil\n}\n\nfunc (ms *targetFilterSource) AddEventHandler(ctx context.Context, handler func()) {\n\tlog.Debug(\"targetFilterSource: adding event handler\")\n\tms.source.AddEventHandler(ctx, handler)\n}\n"
  },
  {
    "path": "source/wrappers/targetfiltersource_test.go",
    "content": "/*\nCopyright 2017 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage wrappers\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n\n\t\"sigs.k8s.io/external-dns/internal/testutils\"\n\t\"sigs.k8s.io/external-dns/source\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\ntype mockTargetNetFilter struct {\n\ttargets map[string]bool\n}\n\nfunc NewMockTargetNetFilter(targets []string) endpoint.TargetFilterInterface {\n\ttargetMap := make(map[string]bool)\n\tfor _, target := range targets {\n\t\ttargetMap[target] = true\n\t}\n\treturn &mockTargetNetFilter{targets: targetMap}\n}\n\nfunc (m *mockTargetNetFilter) Match(target string) bool {\n\treturn m.targets[target]\n}\n\nfunc (m *mockTargetNetFilter) IsEnabled() bool {\n\treturn true\n}\n\nfunc TestEchoSourceReturnGivenSources(t *testing.T) {\n\tstartEndpoints := []*endpoint.Endpoint{{\n\t\tDNSName:    \"foo.bar.com\",\n\t\tRecordType: endpoint.RecordTypeA,\n\t\tTargets:    endpoint.Targets{\"1.2.3.4\"},\n\t\tRecordTTL:  endpoint.TTL(300),\n\t\tLabels:     endpoint.Labels{},\n\t}}\n\te := testutils.NewMockSource(startEndpoints...)\n\n\tendpoints, err := e.Endpoints(t.Context())\n\tif err != nil {\n\t\tt.Errorf(\"Expected no error but got %s\", err.Error())\n\t}\n\n\tfor i, ep := range endpoints {\n\t\tif ep != startEndpoints[i] {\n\t\t\tt.Errorf(\"Expected %s but got %s\", startEndpoints[i], ep)\n\t\t}\n\t}\n}\n\nfunc TestTargetFilterSource(t *testing.T) {\n\tt.Parallel()\n\n\tt.Run(\"Interface\", TestTargetFilterSourceImplementsSource)\n\tt.Run(\"Endpoints\", TestTargetFilterSourceEndpoints)\n}\n\n// TestTargetFilterSourceImplementsSource tests that targetFilterSource is a valid Source.\nfunc TestTargetFilterSourceImplementsSource(_ *testing.T) {\n\tvar _ source.Source = &targetFilterSource{}\n}\n\nfunc TestTargetFilterSourceEndpoints(t *testing.T) {\n\tt.Parallel()\n\n\ttests := []struct {\n\t\ttitle     string\n\t\tfilters   endpoint.TargetFilterInterface\n\t\tendpoints []*endpoint.Endpoint\n\t\texpected  []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\ttitle:   \"filter exclusion all\",\n\t\t\tfilters: NewMockTargetNetFilter([]string{}),\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"foo\", endpoint.RecordTypeA, \"1.2.3.4\"),\n\t\t\t\tendpoint.NewEndpoint(\"foo\", endpoint.RecordTypeA, \"1.2.3.5\"),\n\t\t\t\tendpoint.NewEndpoint(\"foo\", endpoint.RecordTypeA, \"1.2.3.6\"),\n\t\t\t\tendpoint.NewEndpoint(\"foo\", endpoint.RecordTypeA, \"1.3.4.5\"),\n\t\t\t\tendpoint.NewEndpoint(\"foo\", endpoint.RecordTypeA, \"1.4.4.5\")},\n\t\t\texpected: []*endpoint.Endpoint{},\n\t\t},\n\t\t{\n\t\t\ttitle:   \"filter exclude internal net\",\n\t\t\tfilters: NewMockTargetNetFilter([]string{\"8.8.8.8\"}),\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"foo\", endpoint.RecordTypeA, \"10.0.0.1\"),\n\t\t\t\tendpoint.NewEndpoint(\"foo\", endpoint.RecordTypeA, \"8.8.8.8\")},\n\t\t\texpected: []*endpoint.Endpoint{endpoint.NewEndpoint(\"foo\", endpoint.RecordTypeA, \"8.8.8.8\")},\n\t\t},\n\t\t{\n\t\t\ttitle:   \"filter only internal\",\n\t\t\tfilters: NewMockTargetNetFilter([]string{\"10.0.0.1\"}),\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"foo\", endpoint.RecordTypeA, \"10.0.0.1\"),\n\t\t\t\tendpoint.NewEndpoint(\"foo\", endpoint.RecordTypeA, \"8.8.8.8\")},\n\t\t\texpected: []*endpoint.Endpoint{endpoint.NewEndpoint(\"foo\", endpoint.RecordTypeA, \"10.0.0.1\")},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\n\t\tt.Run(tt.title, func(t *testing.T) {\n\t\t\tt.Parallel()\n\n\t\t\techo := testutils.NewMockSource(tt.endpoints...)\n\t\t\tsrc := NewTargetFilterSource(echo, tt.filters)\n\n\t\t\tendpoints, err := src.Endpoints(t.Context())\n\t\t\trequire.NoError(t, err, \"failed to get Endpoints\")\n\t\t\tvalidateEndpoints(t, endpoints, tt.expected)\n\t\t})\n\t}\n}\n\nfunc TestTargetFilterConcreteTargetFilter(t *testing.T) {\n\ttests := []struct {\n\t\ttitle     string\n\t\tfilters   endpoint.TargetFilterInterface\n\t\tendpoints []*endpoint.Endpoint\n\t\texpected  []*endpoint.Endpoint\n\t}{\n\t\t{\n\t\t\ttitle:   \"should skip filtering if no filters are set\",\n\t\t\tfilters: endpoint.NewTargetNetFilterWithExclusions([]string{}, []string{}),\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"foo\", endpoint.RecordTypeA, \"1.2.3.4\"),\n\t\t\t\tendpoint.NewEndpoint(\"foo\", endpoint.RecordTypeA, \"1.2.3.5\"),\n\t\t\t\tendpoint.NewEndpoint(\"foo\", endpoint.RecordTypeA, \"1.2.3.6\"),\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"foo\", endpoint.RecordTypeA, \"1.2.3.4\"),\n\t\t\t\tendpoint.NewEndpoint(\"foo\", endpoint.RecordTypeA, \"1.2.3.5\"),\n\t\t\t\tendpoint.NewEndpoint(\"foo\", endpoint.RecordTypeA, \"1.2.3.6\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:   \"should include all targets when filters are not correctly set\",\n\t\t\tfilters: endpoint.NewTargetNetFilterWithExclusions([]string{\"8.8.8.8\"}, []string{}),\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"foo\", endpoint.RecordTypeA, \"10.0.0.1\"),\n\t\t\t\tendpoint.NewEndpoint(\"foo\", endpoint.RecordTypeA, \"8.8.8.8\")},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"foo\", endpoint.RecordTypeA, \"10.0.0.1\"),\n\t\t\t\tendpoint.NewEndpoint(\"foo\", endpoint.RecordTypeA, \"8.8.8.8\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:   \"should include internal when include filter is set\",\n\t\t\tfilters: endpoint.NewTargetNetFilterWithExclusions([]string{\"10.0.0.0/8\"}, []string{}),\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"foo\", endpoint.RecordTypeA, \"10.0.0.1\"),\n\t\t\t\tendpoint.NewEndpoint(\"foo\", endpoint.RecordTypeA, \"49.13.41.161\")},\n\t\t\texpected: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"foo\", endpoint.RecordTypeA, \"10.0.0.1\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\ttitle:   \"exclude internal keep public ips\",\n\t\t\tfilters: endpoint.NewTargetNetFilterWithExclusions([]string{}, []string{\"10.0.0.0/8\"}),\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"foo\", endpoint.RecordTypeA, \"10.0.178.43\"),\n\t\t\t\tendpoint.NewEndpoint(\"foo\", endpoint.RecordTypeA, \"10.0.1.101\"),\n\t\t\t\tendpoint.NewEndpoint(\"foo\", endpoint.RecordTypeA, \"49.13.41.161\")},\n\t\t\texpected: []*endpoint.Endpoint{endpoint.NewEndpoint(\"foo\", endpoint.RecordTypeA, \"49.13.41.161\")},\n\t\t},\n\t\t{\n\t\t\ttitle:   \"should not exclude ipv6 when excluding ipv4\",\n\t\t\tfilters: endpoint.NewTargetNetFilterWithExclusions([]string{}, []string{\"10.0.0.0/8\"}),\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"foo\", endpoint.RecordTypeA, \"10.0.178.43\"),\n\t\t\t\tendpoint.NewEndpoint(\"foo\", endpoint.RecordTypeAAAA, \"2a01:asdf:asdf:asdf::1\"),\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{endpoint.NewEndpoint(\"foo\", endpoint.RecordTypeAAAA, \"2a01:asdf:asdf:asdf::1\")},\n\t\t},\n\t\t{\n\t\t\ttitle:   \"should not include ipv6 when including ipv4\",\n\t\t\tfilters: endpoint.NewTargetNetFilterWithExclusions([]string{\"10.0.0.0/8\"}, []string{}),\n\t\t\tendpoints: []*endpoint.Endpoint{\n\t\t\t\tendpoint.NewEndpoint(\"foo\", endpoint.RecordTypeA, \"10.0.178.43\"),\n\t\t\t\tendpoint.NewEndpoint(\"foo\", endpoint.RecordTypeAAAA, \"2a01:asdf:asdf:asdf::1\"),\n\t\t\t},\n\t\t\texpected: []*endpoint.Endpoint{endpoint.NewEndpoint(\"foo\", endpoint.RecordTypeA, \"10.0.178.43\")},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.title, func(t *testing.T) {\n\t\t\techo := testutils.NewMockSource(tt.endpoints...)\n\t\t\tsrc := NewTargetFilterSource(echo, tt.filters)\n\n\t\t\tendpoints, err := src.Endpoints(t.Context())\n\t\t\trequire.NoError(t, err, \"failed to get Endpoints\")\n\n\t\t\tvalidateEndpoints(t, endpoints, tt.expected)\n\t\t})\n\t}\n}\n\nfunc TestTargetFilterSource_AddEventHandler(t *testing.T) {\n\ttests := []struct {\n\t\ttitle   string\n\t\tfilters endpoint.TargetFilterInterface\n\t\ttimes   int\n\t}{\n\t\t{\n\t\t\ttitle:   \"should add event handler if target filter is enabled\",\n\t\t\tfilters: endpoint.NewTargetNetFilterWithExclusions([]string{\"10.0.0.0/8\"}, []string{}),\n\t\t\ttimes:   1,\n\t\t},\n\t\t{\n\t\t\ttitle:   \"should add event handler if target filter is disabled\",\n\t\t\tfilters: endpoint.NewTargetNetFilterWithExclusions([]string{}, []string{}),\n\t\t\ttimes:   1,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.title, func(t *testing.T) {\n\t\t\tm := testutils.NewMockSource()\n\t\t\tsrc := NewTargetFilterSource(m, tt.filters)\n\t\t\tsrc.AddEventHandler(t.Context(), func() {})\n\n\t\t\tm.AssertNumberOfCalls(t, \"AddEventHandler\", tt.times)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "source/wrappers/types.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage wrappers\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source\"\n)\n\ntype Config struct {\n\tdefaultTargets      []string\n\tforceDefaultTargets bool\n\tprovider            string\n\tnat64Networks       []string\n\ttargetNetFilter     []string\n\texcludeTargetNets   []string\n\tminTTL              time.Duration\n\tpreferAlias         bool\n\tsourceWrappers      map[string]bool // map of source wrappers, e.g. \"targetfilter\", \"nat64\"\n}\n\nfunc NewConfig(opts ...Option) *Config {\n\to := &Config{}\n\tfor _, opt := range opts {\n\t\topt(o)\n\t}\n\treturn o\n}\n\ntype Option func(config *Config)\n\nfunc WithDefaultTargets(input []string) Option {\n\treturn func(o *Config) {\n\t\to.defaultTargets = input\n\t}\n}\n\nfunc WithForceDefaultTargets(input bool) Option {\n\treturn func(o *Config) {\n\t\to.forceDefaultTargets = input\n\t}\n}\n\nfunc WithNAT64Networks(input []string) Option {\n\treturn func(o *Config) {\n\t\to.nat64Networks = input\n\t}\n}\n\nfunc WithTargetNetFilter(input []string) Option {\n\treturn func(o *Config) {\n\t\to.targetNetFilter = input\n\t}\n}\n\nfunc WithExcludeTargetNets(input []string) Option {\n\treturn func(o *Config) {\n\t\to.excludeTargetNets = input\n\t}\n}\n\nfunc WithMinTTL(ttl time.Duration) Option {\n\treturn func(o *Config) {\n\t\to.minTTL = ttl\n\t}\n}\n\n// WithProvider sets the DNS provider name, used to filter provider-specific\n// endpoint properties to only those belonging to the configured provider.\nfunc WithProvider(input string) Option {\n\treturn func(o *Config) {\n\t\to.provider = input\n\t}\n}\n\nfunc WithPreferAlias(enabled bool) Option {\n\treturn func(o *Config) {\n\t\to.preferAlias = enabled\n\t}\n}\n\n// addSourceWrapper registers a source wrapper by name in the Config.\n// It initializes the sourceWrappers map if it is nil.\nfunc (o *Config) addSourceWrapper(name string) {\n\tif o.sourceWrappers == nil {\n\t\to.sourceWrappers = make(map[string]bool)\n\t}\n\to.sourceWrappers[name] = true\n}\n\n// isSourceWrapperInstrumented returns whether a source wrapper is enabled or not.\nfunc (o *Config) isSourceWrapperInstrumented(name string) bool {\n\tif o.sourceWrappers == nil {\n\t\treturn false\n\t}\n\t_, ok := o.sourceWrappers[name]\n\treturn ok\n}\n\n// WrapSources combines multiple sources into a single source,\n// applies optional NAT64 and target network filtering wrappers, and sets a minimum TTL.\n// It registers each applied wrapper in the Config for instrumentation.\nfunc WrapSources(\n\tsources []source.Source,\n\topts *Config,\n) (source.Source, error) {\n\tcombinedSource := NewDedupSource(NewMultiSource(sources, opts.defaultTargets, opts.forceDefaultTargets))\n\topts.addSourceWrapper(\"dedup\")\n\tif len(opts.nat64Networks) > 0 {\n\t\tvar err error\n\t\tcombinedSource, err = NewNAT64Source(combinedSource, opts.nat64Networks)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to create NAT64 source wrapper: %w\", err)\n\t\t}\n\t\topts.addSourceWrapper(\"nat64\")\n\t}\n\ttargetFilter := endpoint.NewTargetNetFilterWithExclusions(opts.targetNetFilter, opts.excludeTargetNets)\n\tif targetFilter.IsEnabled() {\n\t\tcombinedSource = NewTargetFilterSource(combinedSource, targetFilter)\n\t\topts.addSourceWrapper(\"target-filter\")\n\t}\n\tcombinedSource = NewPostProcessor(combinedSource, WithTTL(opts.minTTL), WithPostProcessorPreferAlias(opts.preferAlias),\n\t\tWithPostProcessorProvider(opts.provider))\n\topts.addSourceWrapper(\"post-processor\")\n\treturn combinedSource, nil\n}\n"
  },
  {
    "path": "source/wrappers/types_test.go",
    "content": "/*\nCopyright 2025 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage wrappers\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestBuildSourceWithWrappers(t *testing.T) {\n\ttests := []struct {\n\t\tname    string\n\t\tcfg     *Config\n\t\tasserts func(*testing.T, *Config)\n\t}{\n\t\t{\n\t\t\tname: \"configuration with target filter wrapper\",\n\t\t\tcfg: NewConfig(\n\t\t\t\tWithTargetNetFilter([]string{\"10.0.0.0/8\"}),\n\t\t\t),\n\t\t\tasserts: func(t *testing.T, cfg *Config) {\n\t\t\t\tassert.True(t, cfg.isSourceWrapperInstrumented(\"target-filter\"))\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"configuration with nat64 networks\",\n\t\t\tcfg: NewConfig(\n\t\t\t\tWithNAT64Networks([]string{\"2001:db8::/96\"}),\n\t\t\t),\n\t\t\tasserts: func(t *testing.T, cfg *Config) {\n\t\t\t\tassert.True(t, cfg.isSourceWrapperInstrumented(\"nat64\"))\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"default configuration\",\n\t\t\tcfg:  NewConfig(),\n\t\t\tasserts: func(t *testing.T, cfg *Config) {\n\t\t\t\tassert.True(t, cfg.isSourceWrapperInstrumented(\"dedup\"))\n\t\t\t\tassert.False(t, cfg.isSourceWrapperInstrumented(\"nat64\"))\n\t\t\t\tassert.False(t, cfg.isSourceWrapperInstrumented(\"target-filter\"))\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"with TTL and NAT64\",\n\t\t\tcfg: NewConfig(\n\t\t\t\tWithMinTTL(300),\n\t\t\t\tWithNAT64Networks([]string{\"2001:db8::/96\"}),\n\t\t\t),\n\t\t\tasserts: func(t *testing.T, cfg *Config) {\n\t\t\t\tassert.True(t, cfg.isSourceWrapperInstrumented(\"dedup\"))\n\t\t\t\tassert.True(t, cfg.isSourceWrapperInstrumented(\"nat64\"))\n\t\t\t\tassert.True(t, cfg.isSourceWrapperInstrumented(\"post-processor\"))\n\t\t\t\tassert.False(t, cfg.isSourceWrapperInstrumented(\"target-filter\"))\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t_, err := WrapSources(nil, tt.cfg)\n\t\t\trequire.NoError(t, err)\n\t\t\ttt.asserts(t, tt.cfg)\n\t\t})\n\t}\n}\n\nfunc TestWrapSources_NAT64Error(t *testing.T) {\n\tcfg := NewConfig(WithNAT64Networks([]string{\"badnet\"}))\n\tsrc, err := WrapSources(nil, cfg)\n\tassert.Nil(t, src)\n\tassert.Error(t, err)\n\tassert.Contains(t, err.Error(), \"failed to create NAT64 source wrapper\")\n}\n\nfunc TestWithDefaultTargets(t *testing.T) {\n\tcfg := &Config{}\n\topt := WithDefaultTargets([]string{\"1.2.3.4\"})\n\topt(cfg)\n\tassert.Equal(t, []string{\"1.2.3.4\"}, cfg.defaultTargets)\n}\n\nfunc TestWithForceDefaultTargets(t *testing.T) {\n\tcfg := &Config{}\n\topt := WithForceDefaultTargets(true)\n\topt(cfg)\n\tassert.True(t, cfg.forceDefaultTargets)\n}\n\nfunc TestWithNAT64Networks(t *testing.T) {\n\tcfg := &Config{}\n\topt := WithNAT64Networks([]string{\"2001:db8::/96\"})\n\topt(cfg)\n\tassert.Equal(t, []string{\"2001:db8::/96\"}, cfg.nat64Networks)\n}\n\nfunc TestWithTargetNetFilter(t *testing.T) {\n\tcfg := &Config{}\n\topt := WithTargetNetFilter([]string{\"10.0.0.0/8\"})\n\topt(cfg)\n\tassert.Equal(t, []string{\"10.0.0.0/8\"}, cfg.targetNetFilter)\n}\n\nfunc TestWithExcludeTargetNets(t *testing.T) {\n\tcfg := &Config{}\n\topt := WithExcludeTargetNets([]string{\"192.168.0.0/16\"})\n\topt(cfg)\n\tassert.Equal(t, []string{\"192.168.0.0/16\"}, cfg.excludeTargetNets)\n}\n\nfunc TestWithMinTTL(t *testing.T) {\n\tcfg := &Config{}\n\topt := WithMinTTL(300 * time.Second)\n\topt(cfg)\n\tassert.Equal(t, 300*time.Second, cfg.minTTL)\n}\n\nfunc TestAddSourceWrapperAndIsSourceWrapperInstrumented(t *testing.T) {\n\tcfg := &Config{}\n\tassert.False(t, cfg.isSourceWrapperInstrumented(\"dedup\"))\n\tcfg.addSourceWrapper(\"dedup\")\n\tassert.True(t, cfg.isSourceWrapperInstrumented(\"dedup\"))\n\tcfg.addSourceWrapper(\"nat64\")\n\tassert.True(t, cfg.isSourceWrapperInstrumented(\"nat64\"))\n\tassert.False(t, cfg.isSourceWrapperInstrumented(\"target-filter\"))\n}\n"
  },
  {
    "path": "tests/integration/OWNERS",
    "content": "# See the OWNERS docs at https://go.k8s.io/owners\n\nlabels:\n- tests-integration\n"
  },
  {
    "path": "tests/integration/scenarios/tests.yaml",
    "content": "# Integration Test Scenarios\n#\n# Schema Summary:\n# | Field                                  | Type     | Description                              |\n# |----------------------------------------|----------|------------------------------------------|\n# | name                                   | string   | Test scenario name                       |\n# | config.sources                         | []string | Sources to create: ingress, service      |\n# | config.defaultTargets                  | []string | --default-targets flag values            |\n# | config.forceDefaultTargets             | bool     | --force-default-targets flag             |\n# | config.targetNetFilter                 | []string | --target-net-filter flag values          |\n# | config.serviceTypeFilter               | []string | --service-type-filter flag values        |\n# | resources                              | []object | K8s resources with optional dependencies |\n# | resources[].resource                   | object   | K8s resource (Ingress, Service, etc.)    |\n# | resources[].dependencies               | object   | Auto-generated dependent resources       |\n# | resources[].dependencies.pods.replicas | int      | Number of pods to generate               |\n# | expected                               | []object | Expected endpoints                       |\n\n# TODO:\n# 1. Support to Endpoint.ResourceLabelKey\n# 2. Support for Endpoint.RefObject\n# 3. Support for Endpoint.ProviderSpecific\n\nscenarios:\n  - name: headless-service-with-pods\n    description: >\n      Test that a headless Service with associated Pods\n      creates the correct DNS A records for each Pod IP.\n    config:\n      sources: [\"service\"]\n      targetNetFilter: [\"10.0.0.1/32\", \"10.0.0.2/32\"]\n      serviceTypeFilter: [\"ClusterIP\"]\n    resources:\n      - resource:\n          apiVersion: v1\n          kind: Service\n          metadata:\n            name: headless-svc\n            namespace: default\n            labels:\n              app: myapp\n            annotations:\n              external-dns.alpha.kubernetes.io/hostname: headless.example.com\n          spec:\n            type: ClusterIP\n            clusterIP: None\n            selector:\n              app: myapp\n        dependencies:\n          pods:\n            replicas: 3\n    expected:\n      - dnsName: headless.example.com\n        targets: [\"10.0.0.1\", \"10.0.0.2\"]\n        recordType: A\n      - dnsName: headless-svc-0.headless.example.com\n        targets: [\"10.0.0.1\"]\n        recordType: A\n      - dnsName: headless-svc-1.headless.example.com\n        targets: [\"10.0.0.2\"]\n        recordType: A\n\n  - name: service-loadbalancer-with-ip\n    description: >\n      Test that a Service of type LoadBalancer with an assigned IP\n      creates the correct DNS A record.\n    config:\n      sources: [\"service\"]\n    resources:\n      - resource:\n          apiVersion: v1\n          kind: Service\n          metadata:\n            name: test-service\n            namespace: default\n            annotations:\n              external-dns.alpha.kubernetes.io/hostname: svc.example.com\n          spec:\n            selector:\n              app.kubernetes.io/name: MyApp\n            type: LoadBalancer\n          status:\n            loadBalancer:\n              ingress:\n                - ip: 1.2.3.4\n    expected:\n      - dnsName: svc.example.com\n        targets: [\"1.2.3.4\"]\n        recordType: A\n\n  - name: known-limitation-cross-source-dedup-not-applied\n    description: >\n      Documents a known limitation: when multiple ingresses share the same hostname,\n      ingressSource.Endpoints() merges their targets via MergeEndpoints() before the\n      dedupSource ever sees them. The resulting combined ingress endpoint\n      ([\"1.2.3.4\",\"203.0.113.10\"]) has different Targets.String() than the service\n      endpoint ([\"1.2.3.4\"]), so the dedupSource keeps both — producing two A records\n      for the same hostname. Ideally the service endpoint would be absorbed into the\n      ingress one, but that would require cross-source target merging which does not\n      exist today. See the \"service-and-ingress-same-hostname-and-ip-dedup\" scenario\n      for a case where deduplication does work correctly.\n    config:\n      sources: [\"service\", \"ingress\"]\n    resources:\n      - resource:\n          apiVersion: v1\n          kind: Service\n          metadata:\n            name: test-service\n            namespace: default\n            annotations:\n              external-dns.alpha.kubernetes.io/hostname: example.local\n          spec:\n            selector:\n              app.kubernetes.io/name: MyApp\n            type: LoadBalancer\n          status:\n            loadBalancer:\n              ingress:\n                - ip: 1.2.3.4\n      - resource:\n          apiVersion: networking.k8s.io/v1\n          kind: Ingress\n          metadata:\n            name: test-ingress\n            namespace: default\n            annotations:\n              kubernetes.io/ingress.class: \"nginx\"\n          spec:\n            rules:\n              - host: example.local\n                http:\n                  paths:\n                    - path: /\n                      pathType: Prefix\n                      backend:\n                        service:\n                          name: my-service\n                          port:\n                            number: 80\n          status:\n            loadBalancer:\n              ingress:\n                - ip: 203.0.113.10\n      - resource:\n          apiVersion: networking.k8s.io/v1\n          kind: Ingress\n          metadata:\n            name: ingress-with-same-host-and-status-as-service\n            namespace: kube-system\n            annotations:\n              kubernetes.io/ingress.class: \"nginx\"\n          spec:\n            rules:\n              - host: example.local\n                http:\n                  paths:\n                    - path: /\n                      pathType: Prefix\n                      backend:\n                        service:\n                          name: my-service\n                          port:\n                            number: 80\n          status:\n            loadBalancer:\n              ingress:\n                - ip: 1.2.3.4\n    expected:\n      # The ingress source merges all ingresses with the same hostname via MergeEndpoints(),\n      # so both ingresses (203.0.113.10 and 1.2.3.4) produce one combined endpoint.\n      - dnsName: example.local\n        targets: [\"1.2.3.4\", \"203.0.113.10\"]\n        recordType: A\n      # The service source produces a separate endpoint for the same hostname.\n      - dnsName: example.local\n        targets: [\"1.2.3.4\"]\n        recordType: A\n\n  - name: service-and-ingress-same-hostname-and-ip-dedup\n    description: >\n      Test that dedupSource correctly removes an exact duplicate endpoint.\n      Both the Service and the Ingress resolve example.local to the same IP\n      (1.2.3.4), so each source emits an identical endpoint\n      (RecordType=A, DNSName=example.local, Targets=[\"1.2.3.4\"]).\n      The dedupSource key is RecordType+DNSName+SetIdentifier+Targets.String(),\n      which matches, so the second endpoint is dropped and only one A record survives.\n    config:\n      sources: [\"service\", \"ingress\"]\n    resources:\n      - resource:\n          apiVersion: v1\n          kind: Service\n          metadata:\n            name: test-service\n            namespace: default\n            annotations:\n              external-dns.alpha.kubernetes.io/hostname: example.local\n          spec:\n            selector:\n              app.kubernetes.io/name: MyApp\n            type: LoadBalancer\n          status:\n            loadBalancer:\n              ingress:\n                - ip: 1.2.3.4\n      - resource:\n          apiVersion: networking.k8s.io/v1\n          kind: Ingress\n          metadata:\n            name: test-ingress\n            namespace: default\n            annotations:\n              kubernetes.io/ingress.class: \"nginx\"\n          spec:\n            rules:\n              - host: example.local\n                http:\n                  paths:\n                    - path: /\n                      pathType: Prefix\n                      backend:\n                        service:\n                          name: my-service\n                          port:\n                            number: 80\n          status:\n            loadBalancer:\n              ingress:\n                - ip: 1.2.3.4\n    expected:\n      - dnsName: example.local\n        targets: [\"1.2.3.4\"]\n        recordType: A\n"
  },
  {
    "path": "tests/integration/source_test.go",
    "content": "/*\nCopyright 2026 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage integration\n\nimport (\n\t_ \"embed\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\n\t\"sigs.k8s.io/external-dns/tests/integration/toolkit\"\n)\n\nvar (\n\t//go:embed scenarios/tests.yaml\n\ttestsYAML []byte\n)\n\nfunc mustLoadScenarios(t *testing.T) *toolkit.TestScenarios {\n\tt.Helper()\n\ttestScenarios, err := toolkit.LoadScenarios(testsYAML)\n\trequire.NoError(t, err, \"failed to load scenarios\")\n\trequire.NotEmpty(t, testScenarios.Scenarios, \"no scenarios found\")\n\treturn testScenarios\n}\n\nfunc TestParseResources(t *testing.T) {\n\tscenarios := mustLoadScenarios(t)\n\tfor _, scenario := range scenarios.Scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tparsed, err := toolkit.ParseResources(scenario.Resources)\n\t\t\trequire.NoError(t, err, \"failed to parse resources\")\n\n\t\t\ttotalParsed := len(parsed.Services) + len(parsed.Ingresses) + len(parsed.Pods) + len(parsed.EndpointSlices)\n\t\t\t// Pods and EndpointSlices may be auto-generated from dependencies, so count\n\t\t\t// only the explicitly declared resources when checking nothing was silently dropped.\n\t\t\texplicitResources := 0\n\t\t\tfor _, r := range scenario.Resources {\n\t\t\t\texplicitResources++\n\t\t\t\tif r.Dependencies != nil && r.Dependencies.Pods != nil {\n\t\t\t\t\t// Each Service with pod dependencies generates Pods + one EndpointSlice.\n\t\t\t\t\texplicitResources += r.Dependencies.Pods.Replicas + 1\n\t\t\t\t}\n\t\t\t}\n\t\t\tassert.Equal(t, explicitResources, totalParsed, \"parsed resource count does not match declared resources\")\n\t\t})\n\t}\n}\n\nfunc TestSourceIntegration(t *testing.T) {\n\tscenarios := mustLoadScenarios(t)\n\tfor _, scenario := range scenarios.Scenarios {\n\t\tt.Run(scenario.Name, func(t *testing.T) {\n\t\t\tctx := t.Context()\n\n\t\t\tclient, err := toolkit.LoadResources(ctx, scenario)\n\t\t\trequire.NoError(t, err, \"failed to populate resources\")\n\n\t\t\t// Create wrapped source\n\t\t\twrappedSource, err := toolkit.CreateWrappedSource(ctx, client, scenario.Config)\n\t\t\trequire.NoError(t, err, \"failed to create wrapped source\")\n\n\t\t\t// Get endpoints\n\t\t\tendpoints, err := wrappedSource.Endpoints(ctx)\n\t\t\trequire.NoError(t, err)\n\t\t\ttoolkit.ValidateEndpoints(t, endpoints, scenario.Expected)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "tests/integration/toolkit/mocks.go",
    "content": "/*\nCopyright 2026 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage toolkit\n\nimport (\n\t\"fmt\"\n\n\topenshift \"github.com/openshift/client-go/route/clientset/versioned\"\n\t\"github.com/stretchr/testify/mock\"\n\tistioclient \"istio.io/client-go/pkg/clientset/versioned\"\n\t\"k8s.io/client-go/dynamic\"\n\t\"k8s.io/client-go/kubernetes\"\n\t\"k8s.io/client-go/kubernetes/fake\"\n\t\"k8s.io/client-go/rest\"\n\tgateway \"sigs.k8s.io/gateway-api/pkg/client/clientset/versioned\"\n)\n\n// MockClientGenerator implements source.ClientGenerator for testing.\ntype MockClientGenerator struct {\n\tmock.Mock\n}\n\nfunc (m *MockClientGenerator) RESTConfig() (*rest.Config, error) {\n\treturn nil, fmt.Errorf(\"RESTConfig: not implemented\")\n}\n\nfunc (m *MockClientGenerator) KubeClient() (kubernetes.Interface, error) {\n\targs := m.Called()\n\tif args.Error(1) != nil {\n\t\treturn nil, args.Error(1)\n\t}\n\treturn args.Get(0).(kubernetes.Interface), nil\n}\n\nfunc (m *MockClientGenerator) GatewayClient() (gateway.Interface, error) {\n\targs := m.Called()\n\tif args.Error(1) != nil {\n\t\treturn nil, args.Error(1)\n\t}\n\treturn args.Get(0).(gateway.Interface), nil\n}\n\nfunc (m *MockClientGenerator) IstioClient() (istioclient.Interface, error) {\n\targs := m.Called()\n\tif args.Error(1) != nil {\n\t\treturn nil, args.Error(1)\n\t}\n\treturn args.Get(0).(istioclient.Interface), nil\n}\n\nfunc (m *MockClientGenerator) DynamicKubernetesClient() (dynamic.Interface, error) {\n\targs := m.Called()\n\tif args.Error(1) != nil {\n\t\treturn nil, args.Error(1)\n\t}\n\treturn args.Get(0).(dynamic.Interface), nil\n}\n\nfunc (m *MockClientGenerator) OpenShiftClient() (openshift.Interface, error) {\n\targs := m.Called()\n\tif args.Error(1) != nil {\n\t\treturn nil, args.Error(1)\n\t}\n\treturn args.Get(0).(openshift.Interface), nil\n}\n\n// newMockClientGenerator creates a MockClientGenerator that returns the provided fake client.\nfunc newMockClientGenerator(client *fake.Clientset) *MockClientGenerator {\n\tm := new(MockClientGenerator)\n\tm.On(\"KubeClient\").Return(client, nil)\n\treturn m\n}\n"
  },
  {
    "path": "tests/integration/toolkit/models.go",
    "content": "/*\nCopyright 2026 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage toolkit\n\nimport (\n\tcorev1 \"k8s.io/api/core/v1\"\n\tdiscoveryv1 \"k8s.io/api/discovery/v1\"\n\tnetworkingv1 \"k8s.io/api/networking/v1\"\n\tk8sruntime \"k8s.io/apimachinery/pkg/runtime\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n)\n\n// TestScenarios represents the root structure of the YAML file.\ntype TestScenarios struct {\n\tScenarios []Scenario `json:\"scenarios\"`\n}\n\n// Scenario represents a single test scenario.\ntype Scenario struct {\n\tName        string                     `json:\"name\"`\n\tDescription string                     `json:\"description\"`\n\tConfig      ScenarioConfig             `json:\"config\"`\n\tResources   []ResourceWithDependencies `json:\"resources\"`\n\tExpected    []*endpoint.Endpoint       `json:\"expected\"`\n}\n\n// ResourceWithDependencies wraps a K8s resource with optional dependencies.\ntype ResourceWithDependencies struct {\n\tResource     k8sruntime.RawExtension `json:\"resource\"`\n\tDependencies *ResourceDependencies   `json:\"dependencies,omitempty\"`\n}\n\n// ResourceDependencies defines auto-generated dependent resources.\ntype ResourceDependencies struct {\n\tPods *PodDependencies `json:\"pods,omitempty\"`\n}\n\n// PodDependencies defines how to generate Pods and EndpointSlices for a Service.\ntype PodDependencies struct {\n\tReplicas int `json:\"replicas\"`\n}\n\n// ScenarioConfig holds the wrapper configuration for a scenario.\ntype ScenarioConfig struct {\n\tSources             []string `json:\"sources\"`\n\tDefaultTargets      []string `json:\"defaultTargets\"`\n\tForceDefaultTargets bool     `json:\"forceDefaultTargets\"`\n\tTargetNetFilter     []string `json:\"targetNetFilter\"`\n\tServiceTypeFilter   []string `json:\"serviceTypeFilter\"`\n}\n\n// ParsedResources holds the parsed Kubernetes resources from a scenario.\ntype ParsedResources struct {\n\tIngresses      []*networkingv1.Ingress\n\tServices       []*corev1.Service\n\tEndpointSlices []*discoveryv1.EndpointSlice\n\tPods           []*corev1.Pod\n}\n"
  },
  {
    "path": "tests/integration/toolkit/toolkit.go",
    "content": "/*\nCopyright 2026 The Kubernetes Authors.\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n*/\n\npackage toolkit\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"maps\"\n\t\"reflect\"\n\t\"sort\"\n\t\"testing\"\n\n\tcorev1 \"k8s.io/api/core/v1\"\n\tdiscoveryv1 \"k8s.io/api/discovery/v1\"\n\tnetworkingv1 \"k8s.io/api/networking/v1\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/runtime\"\n\t\"k8s.io/apimachinery/pkg/runtime/serializer\"\n\tutilruntime \"k8s.io/apimachinery/pkg/util/runtime\"\n\t\"k8s.io/client-go/kubernetes/fake\"\n\t\"sigs.k8s.io/yaml\"\n\n\t\"sigs.k8s.io/external-dns/pkg/apis/externaldns\"\n\n\t\"sigs.k8s.io/external-dns/internal/testutils\"\n\n\t\"sigs.k8s.io/external-dns/endpoint\"\n\t\"sigs.k8s.io/external-dns/source\"\n\t\"sigs.k8s.io/external-dns/source/wrappers\"\n)\n\n// Initialized at package load; safe for concurrent use after that.\nvar (\n\tscheme = func() *runtime.Scheme {\n\t\ts := runtime.NewScheme()\n\t\tutilruntime.Must(corev1.AddToScheme(s))\n\t\tutilruntime.Must(discoveryv1.AddToScheme(s))\n\t\tutilruntime.Must(networkingv1.AddToScheme(s))\n\t\treturn s\n\t}()\n\tdecoder = serializer.NewCodecFactory(scheme).UniversalDeserializer()\n)\n\n// LoadScenarios loads test scenarios from the embedded YAML data.\nfunc LoadScenarios(data []byte) (*TestScenarios, error) {\n\tvar scenarios TestScenarios\n\tif err := yaml.Unmarshal(data, &scenarios); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Validate scenarios\n\tfor i, s := range scenarios.Scenarios {\n\t\tif s.Name == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"scenario %d is missing required field: name\", i)\n\t\t}\n\t\tif s.Description == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"scenario %d (%q) is missing required field: description\", i, s.Name)\n\t\t}\n\t\tif len(s.Config.Sources) == 0 {\n\t\t\treturn nil, fmt.Errorf(\"scenario %d (%q) is missing required field: config.sources\", i, s.Name)\n\t\t}\n\t}\n\n\treturn &scenarios, nil\n}\n\n// ParseResources parses the raw resources from a scenario into typed objects.\nfunc ParseResources(resources []ResourceWithDependencies) (*ParsedResources, error) {\n\tparsed := &ParsedResources{}\n\n\tfor _, item := range resources {\n\t\tobj, _, err := decoder.Decode(item.Resource.Raw, nil, nil)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to decode resource: %w\", err)\n\t\t}\n\n\t\tswitch res := obj.(type) {\n\t\tcase *corev1.Pod:\n\t\t\tparsed.Pods = append(parsed.Pods, res)\n\t\tcase *corev1.Service:\n\t\t\tparsed.Services = append(parsed.Services, res)\n\t\t\t// Auto-generate Pods and EndpointSlice if dependencies are specified\n\t\t\tif item.Dependencies != nil && item.Dependencies.Pods != nil {\n\t\t\t\tpods, endpointSlice := generatePodsAndEndpointSlice(res, item.Dependencies.Pods)\n\t\t\t\tparsed.Pods = append(parsed.Pods, pods...)\n\t\t\t\tparsed.EndpointSlices = append(parsed.EndpointSlices, endpointSlice)\n\t\t\t}\n\t\tcase *networkingv1.Ingress:\n\t\t\tparsed.Ingresses = append(parsed.Ingresses, res)\n\t\tcase *discoveryv1.EndpointSlice:\n\t\t\tparsed.EndpointSlices = append(parsed.EndpointSlices, res)\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"unsupported resource type %T\", obj)\n\t\t}\n\t}\n\n\treturn parsed, nil\n}\n\n// generatePodsAndEndpointSlice creates Pods and an EndpointSlice for a headless service.\nfunc generatePodsAndEndpointSlice(svc *corev1.Service, deps *PodDependencies) ([]*corev1.Pod, *discoveryv1.EndpointSlice) {\n\tvar pods []*corev1.Pod\n\tvar endpoints []discoveryv1.Endpoint\n\n\tfor i := range deps.Replicas {\n\t\tpodName := fmt.Sprintf(\"%s-%d\", svc.Name, i)\n\t\tpodIP := fmt.Sprintf(\"10.0.0.%d\", i+1)\n\n\t\tpod := &corev1.Pod{\n\t\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\t\tName:      podName,\n\t\t\t\tNamespace: svc.Namespace,\n\t\t\t\tLabels:    svc.Spec.Selector,\n\t\t\t},\n\t\t\tSpec: corev1.PodSpec{\n\t\t\t\tHostname: podName,\n\t\t\t},\n\t\t\tStatus: corev1.PodStatus{\n\t\t\t\tPodIP: podIP,\n\t\t\t},\n\t\t}\n\t\tpods = append(pods, pod)\n\n\t\tendpoints = append(endpoints, discoveryv1.Endpoint{\n\t\t\tAddresses: []string{podIP},\n\t\t\tTargetRef: &corev1.ObjectReference{\n\t\t\t\tKind: \"Pod\",\n\t\t\t\tName: podName,\n\t\t\t},\n\t\t\tConditions: discoveryv1.EndpointConditions{\n\t\t\t\tReady: testutils.ToPtr(true),\n\t\t\t},\n\t\t})\n\t}\n\n\t// Create EndpointSlice with the service name label\n\tendpointSliceLabels := maps.Clone(svc.Spec.Selector)\n\tendpointSliceLabels[discoveryv1.LabelServiceName] = svc.Name\n\n\tendpointSlice := &discoveryv1.EndpointSlice{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:      fmt.Sprintf(\"%s-slice\", svc.Name),\n\t\t\tNamespace: svc.Namespace,\n\t\t\tLabels:    endpointSliceLabels,\n\t\t},\n\t\tAddressType: discoveryv1.AddressTypeIPv4,\n\t\tEndpoints:   endpoints,\n\t}\n\n\treturn pods, endpointSlice\n}\n\nfunc createIngressWithOptionalStatus(ctx context.Context, client *fake.Clientset, ing *networkingv1.Ingress) error {\n\tcreated, err := client.NetworkingV1().Ingresses(ing.Namespace).Create(ctx, ing, metav1.CreateOptions{})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Update status separately since Create doesn't set status in the fake client.\n\tif len(ing.Status.LoadBalancer.Ingress) > 0 {\n\t\tcreated.Status = ing.Status\n\t\t_, err = client.NetworkingV1().Ingresses(ing.Namespace).UpdateStatus(ctx, created, metav1.UpdateOptions{})\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc createServiceWithOptionalStatus(ctx context.Context, client *fake.Clientset, svc *corev1.Service) error {\n\tcreated, err := client.CoreV1().Services(svc.Namespace).Create(ctx, svc, metav1.CreateOptions{})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Update status separately since Create doesn't set status in the fake client.\n\tif len(svc.Status.LoadBalancer.Ingress) > 0 {\n\t\tcreated.Status = svc.Status\n\t\t_, err = client.CoreV1().Services(svc.Namespace).UpdateStatus(ctx, created, metav1.UpdateOptions{})\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// LoadResources creates the resources in the fake client using the API.\n// This must be called BEFORE creating sources so the informers can see the resources.\nfunc LoadResources(ctx context.Context, scenario Scenario) (*fake.Clientset, error) {\n\tclient := fake.NewClientset()\n\n\t// Parse resources from scenario\n\tresources, err := ParseResources(scenario.Resources)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tfor _, ing := range resources.Ingresses {\n\t\tif err := createIngressWithOptionalStatus(ctx, client, ing); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tfor _, svc := range resources.Services {\n\t\tif err := createServiceWithOptionalStatus(ctx, client, svc); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tfor _, pod := range resources.Pods {\n\t\t_, err := client.CoreV1().Pods(pod.Namespace).Create(ctx, pod, metav1.CreateOptions{})\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tfor _, eps := range resources.EndpointSlices {\n\t\t_, err := client.DiscoveryV1().EndpointSlices(eps.Namespace).Create(ctx, eps, metav1.CreateOptions{})\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\treturn client, nil\n}\n\n// scenarioToConfig creates a source.Config for testing with the scenario config.\nfunc scenarioToConfig(scenarioCfg ScenarioConfig) *source.Config {\n\treturn source.NewSourceConfig(&externaldns.Config{\n\t\tSources:             scenarioCfg.Sources,\n\t\tServiceTypeFilter:   scenarioCfg.ServiceTypeFilter,\n\t\tDefaultTargets:      scenarioCfg.DefaultTargets,\n\t\tForceDefaultTargets: scenarioCfg.ForceDefaultTargets,\n\t\tTargetNetFilter:     scenarioCfg.TargetNetFilter,\n\t})\n}\n\n// CreateWrappedSource creates sources using source.BuildWithConfig and wraps them with wrappers.WrapSources.\n// TODO: could we reuse the same source.BuildWithConfig() code as the controller instead of duplicating it here? It would require refactoring to allow passing in a custom client generator, but it would ensure we're testing the same code as the controller.\nfunc CreateWrappedSource(\n\tctx context.Context,\n\tclient *fake.Clientset,\n\tscenarioCfg ScenarioConfig) (source.Source, error) {\n\tclientGen := newMockClientGenerator(client)\n\tcfg := scenarioToConfig(scenarioCfg)\n\n\t// TODO: copied from controller/execute.go#buildSources\n\tsources, err := source.ByNames(ctx, cfg, clientGen)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\topts := wrappers.NewConfig(\n\t\twrappers.WithDefaultTargets(cfg.DefaultTargets),\n\t\twrappers.WithForceDefaultTargets(cfg.ForceDefaultTargets),\n\t\twrappers.WithNAT64Networks(cfg.NAT64Networks),\n\t\twrappers.WithTargetNetFilter(cfg.TargetNetFilter),\n\t\twrappers.WithExcludeTargetNets(cfg.ExcludeTargetNets),\n\t\twrappers.WithMinTTL(cfg.MinTTL))\n\n\treturn wrappers.WrapSources(sources, opts)\n}\n\n// TODO: copied from source/wrappers/source_test.go - unify in following PR\nfunc ValidateEndpoints(t *testing.T, endpoints, expected []*endpoint.Endpoint) {\n\tt.Helper()\n\n\tif len(endpoints) != len(expected) {\n\t\tt.Fatalf(\"expected %d endpoints, got %d\", len(expected), len(endpoints))\n\t}\n\n\t// Make sure endpoints are sorted - validateEndpoint() depends on it.\n\tsortEndpoints(endpoints)\n\tsortEndpoints(expected)\n\n\tfor i := range endpoints {\n\t\tvalidateEndpoint(t, endpoints[i], expected[i])\n\t}\n}\n\n// TODO: copied from source/wrappers/source_test.go - unify in following PR\nfunc validateEndpoint(t *testing.T, endpoint, expected *endpoint.Endpoint) {\n\tt.Helper()\n\n\tif endpoint.DNSName != expected.DNSName {\n\t\tt.Errorf(\"DNSName expected %q, got %q\", expected.DNSName, endpoint.DNSName)\n\t}\n\n\tif !endpoint.Targets.Same(expected.Targets) {\n\t\tt.Errorf(\"Targets expected %q, got %q\", expected.Targets, endpoint.Targets)\n\t}\n\n\tif endpoint.RecordTTL != expected.RecordTTL {\n\t\tt.Errorf(\"RecordTTL expected %v, got %v\", expected.RecordTTL, endpoint.RecordTTL)\n\t}\n\n\t// if a non-empty record type is expected, check that it matches.\n\tif endpoint.RecordType != expected.RecordType {\n\t\tt.Errorf(\"RecordType expected %q, got %q\", expected.RecordType, endpoint.RecordType)\n\t}\n\n\t// if non-empty labels are expected, check that they match.\n\tif expected.Labels != nil && !reflect.DeepEqual(endpoint.Labels, expected.Labels) {\n\t\tt.Errorf(\"Labels expected %s, got %s\", expected.Labels, endpoint.Labels)\n\t}\n\n\tif (len(expected.ProviderSpecific) != 0 || len(endpoint.ProviderSpecific) != 0) &&\n\t\t!reflect.DeepEqual(endpoint.ProviderSpecific, expected.ProviderSpecific) {\n\t\tt.Errorf(\"ProviderSpecific expected %s, got %s\", expected.ProviderSpecific, endpoint.ProviderSpecific)\n\t}\n\n\tif endpoint.SetIdentifier != expected.SetIdentifier {\n\t\tt.Errorf(\"SetIdentifier expected %q, got %q\", expected.SetIdentifier, endpoint.SetIdentifier)\n\t}\n}\n\n// TODO: copied from source/wrappers/source_test.go - unify in following PR\nfunc sortEndpoints(endpoints []*endpoint.Endpoint) {\n\tfor _, ep := range endpoints {\n\t\tsort.Strings(ep.Targets)\n\t}\n\tsort.Slice(endpoints, func(i, k int) bool {\n\t\t// Sort by DNSName, RecordType, and Targets\n\t\tei, ek := endpoints[i], endpoints[k]\n\t\tif ei.DNSName != ek.DNSName {\n\t\t\treturn ei.DNSName < ek.DNSName\n\t\t}\n\t\tif ei.RecordType != ek.RecordType {\n\t\t\treturn ei.RecordType < ek.RecordType\n\t\t}\n\t\t// Targets are sorted ahead of time.\n\t\tfor j, ti := range ei.Targets {\n\t\t\tif j >= len(ek.Targets) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tif tk := ek.Targets[j]; ti != tk {\n\t\t\t\treturn ti < tk\n\t\t\t}\n\t\t}\n\t\treturn false\n\t})\n}\n"
  }
]