├── .changelog ├── 0176ad7731a9428a92b794db746f0568.md ├── dc4409765e0c4ffe824fc4d6208ec76d.md ├── 6dc54cc66333401b927beb7ab136939e.md ├── 41bc3f8da3c84ca9a734d0be96c589c5.md ├── 7f722301e34140f7bbf0b4126709342d.md ├── 822e7123fe7c40b7b8edc4a406445101.md ├── c11eef73fe934a6681499fb3f6f61e00.md ├── ea6268b4c5b542ebb35a618bc66032fe.md ├── 0c766cb99c8a419aa8af6396b7ee56aa.md ├── 220d9ce126f842cf9c61515f8d035bba.md ├── 8d5ac9f8740a4cdd9dde916a17e8ffef.md └── 05f3b5dc965240e7a07f71522e390fd9.md ├── .git-blame-ignore-revs ├── .gitignore ├── script ├── changelog ├── test ├── update-requirements ├── lint ├── format ├── common.sh ├── release ├── coverage.orig ├── coverage ├── cibuild ├── cibuild-setup-py └── bootstrap ├── pyproject.toml ├── .git_hooks_pre-commit ├── .github └── workflows │ ├── stale.yml │ ├── changelog.yml │ └── main.yml ├── docker-compose.yml ├── LICENSE ├── requirements.txt ├── setup.py ├── octodns_powerdns ├── record.py └── __init__.py ├── CHANGELOG.md ├── README.md └── tests ├── config └── unit.tests.yaml ├── fixtures └── powerdns-full-data.json └── test_octodns_provider_powerdns.py /.changelog/0176ad7731a9428a92b794db746f0568.md: -------------------------------------------------------------------------------- 1 | --- 2 | type: none 3 | --- 4 | Correct readme typo -------------------------------------------------------------------------------- /.changelog/dc4409765e0c4ffe824fc4d6208ec76d.md: -------------------------------------------------------------------------------- 1 | --- 2 | type: none 3 | --- 4 | Update requirements -------------------------------------------------------------------------------- /.changelog/6dc54cc66333401b927beb7ab136939e.md: -------------------------------------------------------------------------------- 1 | --- 2 | type: none 3 | --- 4 | Update requirements*.txt -------------------------------------------------------------------------------- /.changelog/41bc3f8da3c84ca9a734d0be96c589c5.md: -------------------------------------------------------------------------------- 1 | --- 2 | type: none 3 | --- 4 | Update scripts to use common.sh -------------------------------------------------------------------------------- /.changelog/7f722301e34140f7bbf0b4126709342d.md: -------------------------------------------------------------------------------- 1 | --- 2 | type: none 3 | --- 4 | Add JSON coverage report format -------------------------------------------------------------------------------- /.changelog/822e7123fe7c40b7b8edc4a406445101.md: -------------------------------------------------------------------------------- 1 | --- 2 | type: none 3 | --- 4 | Fix ns1 type-o in script/format -------------------------------------------------------------------------------- /.changelog/c11eef73fe934a6681499fb3f6f61e00.md: -------------------------------------------------------------------------------- 1 | --- 2 | type: minor 3 | --- 4 | Add support for URI record type -------------------------------------------------------------------------------- /.changelog/ea6268b4c5b542ebb35a618bc66032fe.md: -------------------------------------------------------------------------------- 1 | --- 2 | type: none 3 | --- 4 | Pull in the latest template changes -------------------------------------------------------------------------------- /.changelog/0c766cb99c8a419aa8af6396b7ee56aa.md: -------------------------------------------------------------------------------- 1 | --- 2 | type: none 3 | --- 4 | Run changelog check during pre-commit -------------------------------------------------------------------------------- /.changelog/220d9ce126f842cf9c61515f8d035bba.md: -------------------------------------------------------------------------------- 1 | --- 2 | type: none 3 | --- 4 | Use script/common.sh in script/update-requirements -------------------------------------------------------------------------------- /.changelog/8d5ac9f8740a4cdd9dde916a17e8ffef.md: -------------------------------------------------------------------------------- 1 | --- 2 | type: none 3 | --- 4 | Switch to proviso for requirements.txt management -------------------------------------------------------------------------------- /.changelog/05f3b5dc965240e7a07f71522e390fd9.md: -------------------------------------------------------------------------------- 1 | --- 2 | type: patch 3 | --- 4 | Use new [changelet](https://github.com/octodns/changelet) tooling -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Commit that added in black formatting support 2 | d299cefe07cf4ff03e88bc74e371bcb7df90328d 3 | # Commit for isort formatting changes 4 | 1c93a5de86f6339029ddbe6cfa55a4a223e19d15 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .coverage 3 | .eggs/ 4 | .env 5 | /build/ 6 | /config/ 7 | coverage.json 8 | coverage.xml 9 | dist/ 10 | env/ 11 | htmlcov/ 12 | nosetests.xml 13 | octodns_powerdns*.egg-info/ 14 | -------------------------------------------------------------------------------- /script/changelog: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Get current script path 4 | SCRIPT_PATH="$( dirname -- "$( readlink -f -- "${0}"; )"; )" 5 | # Activate OctoDNS Python venv 6 | source "${SCRIPT_PATH}/common.sh" 7 | 8 | changelet "$@" 9 | -------------------------------------------------------------------------------- /script/test: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Get current script path 4 | SCRIPT_PATH="$(dirname -- "$(readlink -f -- "${0}")")" 5 | # Activate OctoDNS Python venv 6 | source "${SCRIPT_PATH}/common.sh" 7 | 8 | export POWERDNS_API_KEY= 9 | 10 | pytest --disable-network "$@" 11 | -------------------------------------------------------------------------------- /script/update-requirements: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Get current script path 4 | SCRIPT_PATH="$(dirname -- "$(readlink -f -- "${0}")")" 5 | # Activate OctoDNS Python venv 6 | source "${SCRIPT_PATH}/common.sh" 7 | 8 | proviso --header="# DO NOT EDIT THIS FILE DIRECTLY - use ./script/update-requirements" "$@" 9 | -------------------------------------------------------------------------------- /script/lint: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Get current script path 4 | SCRIPT_PATH="$( dirname -- "$( readlink -f -- "${0}"; )"; )" 5 | # Activate OctoDNS Python venv 6 | source "${SCRIPT_PATH}/common.sh" 7 | 8 | SOURCES="$(find *.py octodns_powerdns tests -name "*.py") $(grep --files-with-matches '^#!.*python' script/* || true)" 9 | 10 | pyflakes $SOURCES 11 | -------------------------------------------------------------------------------- /script/format: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Get current script path 4 | SCRIPT_PATH="$( dirname -- "$( readlink -f -- "${0}"; )"; )" 5 | # Activate OctoDNS Python venv 6 | source "${SCRIPT_PATH}/common.sh" 7 | 8 | SOURCES="$(find *.py octodns_powerdns tests -name "*.py") $(grep --files-with-matches '^#!.*python' script/* || true)" 9 | 10 | isort "$@" $SOURCES 11 | black "$@" $SOURCES 12 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length=80 3 | skip-string-normalization=true 4 | skip-magic-trailing-comma=true 5 | 6 | [tool.isort] 7 | profile = "black" 8 | known_first_party="octodns_powerdns" 9 | known_octodns="octodns" 10 | line_length=80 11 | sections="FUTURE,STDLIB,THIRDPARTY,OCTODNS,FIRSTPARTY,LOCALFOLDER" 12 | 13 | [tool.pytest.ini_options] 14 | filterwarnings = [ 15 | 'error', 16 | ] 17 | pythonpath = "." 18 | -------------------------------------------------------------------------------- /.git_hooks_pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Scripts path 4 | SCRIPT_PATH="$( dirname -- "$( readlink -f -- "${0}"; )"; )/script" 5 | # Activate OctoDNS Python venv 6 | source "${SCRIPT_PATH}/common.sh" 7 | 8 | "${SCRIPT_PATH}/changelog" check 9 | "${SCRIPT_PATH}/lint" 10 | "${SCRIPT_PATH}/format" --check --quiet || ( 11 | echo "Formatting check failed, run ./script/format" && 12 | exit 1 13 | ) 14 | "${SCRIPT_PATH}/coverage" 15 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: 'Close stale issues and PRs' 2 | on: 3 | schedule: 4 | - cron: '42 4 * * *' 5 | workflow_dispatch: 6 | 7 | jobs: 8 | stale: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/stale@v8 12 | with: 13 | stale-issue-message: 'This issue is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 7 days.' 14 | days-before-stale: 90 15 | days-before-close: 7 16 | -------------------------------------------------------------------------------- /.github/workflows/changelog.yml: -------------------------------------------------------------------------------- 1 | name: Changelog 2 | on: 3 | pull_request: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | changelog: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - name: Fetch main 12 | run: git fetch origin main --depth 1 13 | - name: Setup python 14 | uses: actions/setup-python@v4 15 | with: 16 | python-version: ${{ matrix.python-version }} 17 | architecture: x64 18 | - name: Setup Environment 19 | run: | 20 | python -m pip install --upgrade pip 21 | ./script/bootstrap 22 | - name: Changelog Check 23 | run: | 24 | ./script/changelog check 25 | -------------------------------------------------------------------------------- /script/common.sh: -------------------------------------------------------------------------------- 1 | # This script contains Python's venv management features common to all shell 2 | # scripts located in this directory and to the repository pre-commit hook. 3 | # This script is *not* meant to be run directly. 4 | 5 | # Exit on any error 6 | set -e 7 | 8 | # Path to OctoDNS base directory 9 | OCTODNS_PATH="$( dirname -- "${SCRIPT_PATH}"; )" 10 | 11 | # Change to path OctoDNS base directory 12 | cd "${OCTODNS_PATH}" 13 | 14 | # If no venv name is set, set it to "env" 15 | if [ -z "${VENV_NAME}" ]; then 16 | VENV_NAME="${OCTODNS_PATH}/env" 17 | fi 18 | 19 | ACTIVATE="${VENV_NAME}/bin/activate" 20 | # Check that [venv_directory]/bin/activate exists. 21 | if [ ! -f "${ACTIVATE}" ]; then 22 | echo "${ACTIVATE} does not exist. Run ./script/bootstrap" >&2 23 | exit 1 24 | fi 25 | 26 | # Activate OctoDNS venv. 27 | source "${ACTIVATE}" 28 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" # optional since v1.27.0 2 | 3 | volumes: 4 | powerdns-db-data: 5 | name: powerdns-db-data 6 | 7 | services: 8 | db: 9 | image: mariadb:10.1 10 | ports: 11 | - "3306:3306" 12 | environment: 13 | MYSQL_ROOT_PASSWORD: l3tmein 14 | volumes: 15 | - powerdns-db-data:/var/lib/mysql 16 | powerdns: 17 | image: psitrax/powerdns 18 | ports: 19 | - "8053:53/tcp" 20 | - "8053:53/udp" 21 | - "8081:8081" 22 | environment: 23 | MYSQL_HOST: db 24 | MYSQL_PORT: 3306 25 | MYSQL_USER: root 26 | MYSQL_PASS: l3tmein 27 | depends_on: 28 | - db 29 | command: --api=yes --api-key=its@secret --loglevel=99 --webserver=yes --webserver-address=0.0.0.0 --webserver-allow-from=0.0.0.0/0 --webserver-password=its@secret --webserver-port=8081 --enable-lua-records=shared --edns-subnet-processing=yes 30 | -------------------------------------------------------------------------------- /script/release: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Get current script path 4 | SCRIPT_PATH="$( dirname -- "$( readlink -f -- "${0}"; )"; )" 5 | # Activate OctoDNS Python venv 6 | source "${SCRIPT_PATH}/common.sh" 7 | 8 | set -o pipefail 9 | 10 | PYPYRC="$HOME/.pypirc" 11 | if [ ! -e "$PYPYRC" ]; then 12 | cat << EndOfMessage >&2 13 | $PYPYRC does not exist, please create it with the following contents 14 | 15 | [pypi] 16 | username = __token__ 17 | password = [secret-token-goes-here] 18 | 19 | EndOfMessage 20 | exit 1 21 | fi 22 | 23 | VERSION="$(grep "^__version__" "octodns_powerdns/__init__.py" | sed -e "s/.* = '//" -e "s/'$//")" 24 | 25 | git tag -s "v$VERSION" -m "Release $VERSION" 26 | git push origin "v$VERSION" 27 | echo "Tagged and pushed v$VERSION" 28 | python -m build --sdist --wheel 29 | twine check dist/*$VERSION.tar.gz dist/*$VERSION*.whl 30 | twine upload dist/*$VERSION.tar.gz dist/*$VERSION*.whl 31 | echo "Uploaded $VERSION" 32 | -------------------------------------------------------------------------------- /script/coverage.orig: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Get current script path 4 | SCRIPT_PATH="$(dirname -- "$(readlink -f -- "${0}")")" 5 | # Activate OctoDNS Python venv 6 | source "${SCRIPT_PATH}/common.sh" 7 | 8 | SOURCE_DIR="octodns_powerdns/" 9 | 10 | # Don't allow disabling coverage 11 | PRAGMA_OUTPUT=$(grep -r -I --line-number "# pragma: \+no.*cover" "$SOURCE_DIR" || echo) 12 | PRAGMA_COUNT=$(echo "$PRAGMA_OUTPUT" | grep -c . || true) 13 | PRAGMA_ALLOWED=6 14 | if [ "$PRAGMA_COUNT" -gt "$PRAGMA_ALLOWED" ]; then 15 | echo "Found $PRAGMA_COUNT instances of 'pragma: no cover' (no more than $PRAGMA_ALLOWED allowed):" 16 | echo "$PRAGMA_OUTPUT" 17 | echo "Code coverage should not be disabled, except for version handling blocks" 18 | exit 1 19 | fi 20 | 21 | export POWERDNS_API_KEY= 22 | 23 | pytest \ 24 | --disable-network \ 25 | --cov-reset \ 26 | --cov=$SOURCE_DIR \ 27 | --cov-fail-under=100 \ 28 | --cov-report=html \ 29 | --cov-report=xml \ 30 | --cov-report=term \ 31 | --cov-branch \ 32 | "$@" 33 | -------------------------------------------------------------------------------- /script/coverage: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Get current script path 4 | SCRIPT_PATH="$(dirname -- "$(readlink -f -- "${0}")")" 5 | # Activate OctoDNS Python venv 6 | source "${SCRIPT_PATH}/common.sh" 7 | 8 | SOURCE_DIR="octodns_powerdns/" 9 | 10 | # Don't allow disabling coverage 11 | PRAGMA_OUTPUT=$(grep -r -I --line-number "# pragma: \+no.*cover" "$SOURCE_DIR" || echo) 12 | PRAGMA_COUNT=$(echo "$PRAGMA_OUTPUT" | grep -c . || true) 13 | PRAGMA_ALLOWED=6 14 | if [ "$PRAGMA_COUNT" -gt "$PRAGMA_ALLOWED" ]; then 15 | echo "Found $PRAGMA_COUNT instances of 'pragma: no cover' (no more than $PRAGMA_ALLOWED allowed):" 16 | echo "$PRAGMA_OUTPUT" 17 | echo "Code coverage should not be disabled, except for version handling blocks" 18 | exit 1 19 | fi 20 | 21 | export POWERDNS_API_KEY= 22 | 23 | pytest \ 24 | --disable-network \ 25 | --cov-reset \ 26 | --cov=$SOURCE_DIR \ 27 | --cov-fail-under=100 \ 28 | --cov-report=html \ 29 | --cov-report=json \ 30 | --cov-report=xml \ 31 | --cov-report=term \ 32 | --cov-branch \ 33 | "$@" 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Ross McFarland & the octoDNS Maintainers 4 | Copyright (c) 2017 GitHub, Inc. 5 | 6 | Permission is hereby granted, free of charge, to any person 7 | obtaining a copy of this software and associated documentation 8 | files (the "Software"), to deal in the Software without 9 | restriction, including without limitation the rights to use, 10 | copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the 12 | Software is furnished to do so, subject to the following 13 | conditions: 14 | 15 | The above copyright notice and this permission notice shall be 16 | included in all copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 19 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 20 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 21 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 22 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 23 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 24 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 25 | OTHER DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /script/cibuild: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "## bootstrap ###################################################################" 4 | script/bootstrap 5 | 6 | # Get current script path 7 | SCRIPT_PATH="$( dirname -- "$( readlink -f -- "${0}"; )"; )" 8 | # Activate OctoDNS Python venv 9 | source "${SCRIPT_PATH}/common.sh" 10 | 11 | echo "## environment & versions ######################################################" 12 | python --version 13 | pip --version 14 | echo "## modules: " 15 | pip freeze 16 | echo "## clean up ####################################################################" 17 | find octodns_powerdns tests* -name "*.pyc" -exec rm {} \; 18 | rm -f *.pyc 19 | echo "## begin #######################################################################" 20 | # For now it's just lint... 21 | echo "## lint ########################################################################" 22 | script/lint 23 | echo "## formatting ##################################################################" 24 | script/format --check || (echo "Formatting check failed, run ./script/format" && exit 1) 25 | echo "## tests/coverage ##############################################################" 26 | script/coverage 27 | echo "## complete ####################################################################" 28 | -------------------------------------------------------------------------------- /script/cibuild-setup-py: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | cd "$(dirname "$0")/.." 5 | 6 | VERSION="$(grep "^__version__" "./octodns_powerdns/__init__.py" | sed -e "s/.* = '//" -e "s/'$//")" 7 | 8 | echo "## create test venv ############################################################" 9 | TMP_DIR=$(mktemp -d -t ci-XXXXXXXXXX) 10 | python3 -m venv $TMP_DIR 11 | . "$TMP_DIR/bin/activate" 12 | pip install build setuptools 13 | echo "## environment & versions ######################################################" 14 | python --version 15 | pip --version 16 | echo "## validate setup.py build #####################################################" 17 | python -m build --sdist --wheel 18 | echo "## validate wheel install ###################################################" 19 | pip install dist/*$VERSION*.whl 20 | echo "## validate tests can run against installed code ###############################" 21 | # filename needs to resolved independently as pip requires quoting and doesn't support 22 | # wildcards when installing extra requirements 23 | # (see: https://pip.pypa.io/en/stable/user_guide/#installing-from-wheels) 24 | wheel_file=$(ls dist/*$VERSION*.whl) 25 | pip install "${wheel_file}[test]" 26 | pytest --disable-network 27 | echo "## complete ####################################################################" 28 | -------------------------------------------------------------------------------- /script/bootstrap: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Usage: script/bootstrap 3 | # Ensures all dependencies are installed locally. 4 | 5 | set -e 6 | 7 | cd "$(dirname "$0")"/.. 8 | ROOT=$(pwd) 9 | 10 | if [ -z "$VENV_NAME" ]; then 11 | VENV_NAME="env" 12 | fi 13 | 14 | if [ ! -d "$VENV_NAME" ]; then 15 | if [ -z "$VENV_PYTHON" ]; then 16 | VENV_PYTHON=$(command -v python3) 17 | fi 18 | "$VENV_PYTHON" -m venv "$VENV_NAME" 19 | fi 20 | . "$VENV_NAME/bin/activate" 21 | 22 | # We're in the venv now, so use the first Python in $PATH. In particular, don't 23 | # use $VENV_PYTHON - that's the Python that *created* the venv, not the python 24 | # *inside* the venv 25 | python -m pip install -U 'pip>=10.0.1' 26 | python -m pip install -r requirements.txt 27 | 28 | if [ -d ".git" ]; then 29 | if [ -f ".git-blame-ignore-revs" ]; then 30 | echo "" 31 | echo "Setting blame.ignoreRevsFile to .git-blame-ingore-revs" 32 | git config --local blame.ignoreRevsFile .git-blame-ignore-revs 33 | fi 34 | if [ ! -L ".git/hooks/pre-commit" ]; then 35 | echo "" 36 | echo "Installing pre-commit hook" 37 | ln -s "$ROOT/.git_hooks_pre-commit" ".git/hooks/pre-commit" 38 | fi 39 | fi 40 | 41 | echo "" 42 | echo "Run source env/bin/activate to get your shell in to the virtualenv" 43 | echo "See README.md for more information." 44 | echo "" 45 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: octoDNS PowerDnsProvider 2 | on: 3 | pull_request: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | config: 8 | runs-on: ubuntu-latest 9 | outputs: 10 | json: ${{ steps.load.outputs.json }} 11 | steps: 12 | - id: load 13 | run: | 14 | { 15 | echo 'json<> $GITHUB_OUTPUT 19 | ci: 20 | needs: config 21 | runs-on: ubuntu-latest 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | python-version: ${{ fromJson(needs.config.outputs.json).python_versions_active }} 26 | steps: 27 | - uses: actions/checkout@v4 28 | - name: Setup python 29 | uses: actions/setup-python@v4 30 | with: 31 | python-version: ${{ matrix.python-version }} 32 | architecture: x64 33 | - name: CI Build 34 | run: | 35 | ./script/cibuild 36 | setup-py: 37 | needs: config 38 | runs-on: ubuntu-latest 39 | steps: 40 | - uses: actions/checkout@v4 41 | - name: Setup python 42 | uses: actions/setup-python@v4 43 | with: 44 | python-version: ${{ fromJson(needs.config.outputs.json).python_version_current }} 45 | architecture: x64 46 | - name: CI setup.py 47 | run: | 48 | ./script/cibuild-setup-py 49 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT THIS FILE DIRECTLY - use ./script/update-requirements 2 | anyio==4.12.0 3 | backports-tarfile==1.2.0; python_version=='3.10' or python_version=='3.11' 4 | black==24.10.0 5 | build==1.3.0 6 | certifi==2025.11.12 7 | cffi==2.0.0 8 | changelet==0.4.0 9 | charset-normalizer==3.4.4 10 | click==8.3.1 11 | coverage==7.13.0 12 | cryptography==46.0.3 13 | dnspython==2.8.0 14 | docutils==0.22.3 15 | exceptiongroup==1.3.1; python_version=='3.10' 16 | fqdn==1.5.1 17 | h11==0.16.0 18 | hishel==1.1.7 19 | httpcore==1.0.9 20 | httpx==0.28.1 21 | id==1.5.0 22 | idna==3.11 23 | importlib-metadata==8.7.0; python_version=='3.10' or python_version=='3.11' 24 | iniconfig==2.3.0 25 | isort==7.0.0 26 | jaraco-classes==3.4.0 27 | jaraco-context==6.0.1 28 | jaraco-functools==4.3.0 29 | jeepney==0.9.0 30 | keyring==25.7.0 31 | markdown-it-py==4.0.0 32 | mdurl==0.1.2 33 | more-itertools==10.8.0 34 | msgpack==1.1.2 35 | mypy-extensions==1.1.0 36 | natsort==8.4.0 37 | nh3==0.3.2 38 | octodns==1.15.0 39 | packaging==25.0 40 | pathspec==0.12.1 41 | platformdirs==4.5.1 42 | pluggy==1.6.0 43 | proviso==0.2.0 44 | pycparser==2.23 45 | pyflakes==3.4.0 46 | pygments==2.19.2 47 | pyproject-hooks==1.2.0 48 | pytest==9.0.2 49 | pytest-cov==7.0.0 50 | pytest-network==0.0.1 51 | python-dateutil==2.9.0.post0 52 | pyyaml==6.0.3 53 | readme-renderer==44.0 54 | requests==2.32.5 55 | requests-mock==1.12.1 56 | requests-toolbelt==1.0.0 57 | resolvelib==1.2.1 58 | rfc3986==2.0.0 59 | rich==14.2.0 60 | secretstorage==3.5.0 61 | semver==3.0.4 62 | setuptools==80.9.0 63 | six==1.17.0 64 | tomli==2.3.0; python_version=='3.10' 65 | twine==6.2.0 66 | typing-extensions==4.15.0 67 | unearth==0.18.1 68 | urllib3==2.6.2 69 | zipp==3.23.0; python_version=='3.10' or python_version=='3.11' 70 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import find_packages, setup 4 | 5 | 6 | def descriptions(): 7 | with open('README.md') as fh: 8 | ret = fh.read() 9 | first = ret.split('\n', 1)[0].replace('#', '') 10 | return first, ret 11 | 12 | 13 | def version(): 14 | with open('octodns_powerdns/__init__.py') as fh: 15 | for line in fh: 16 | if line.startswith('__version__'): 17 | return line.split("'")[1] 18 | return 'unknown' 19 | 20 | 21 | description, long_description = descriptions() 22 | 23 | tests_require = ('pytest', 'pytest-cov', 'pytest-network', 'requests_mock') 24 | 25 | setup( 26 | author='Ross McFaland', 27 | author_email='rwmcfa1@gmail.com', 28 | description=description, 29 | extras_require={ 30 | 'dev': tests_require 31 | + ( 32 | # we need to manually/explicitely bump major versions as they're 33 | # likely to result in formatting changes that should happen in their 34 | # own PR. This will basically happen yearly 35 | # https://black.readthedocs.io/en/stable/the_black_code_style/index.html#stability-policy 36 | 'black>=24.3.0,<25.0.0', 37 | 'build>=0.7.0', 38 | 'changelet', 39 | 'isort>=5.11.5', 40 | 'proviso', 41 | 'pyflakes>=2.2.0', 42 | 'readme_renderer[md]>=26.0', 43 | 'twine>=3.4.2', 44 | ), 45 | 'test': tests_require, 46 | }, 47 | install_requires=('octodns>=1.5.0', 'requests>=2.26.0'), 48 | license='MIT', 49 | long_description=long_description, 50 | long_description_content_type='text/markdown', 51 | name='octodns-powerdns', 52 | packages=find_packages(), 53 | python_requires='>=3.9', 54 | tests_require=tests_require, 55 | url='https://github.com/octodns/octodns-powerdns', 56 | version=version(), 57 | ) 58 | -------------------------------------------------------------------------------- /octodns_powerdns/record.py: -------------------------------------------------------------------------------- 1 | from octodns.equality import EqualityTupleMixin 2 | from octodns.record import Record, ValuesMixin 3 | 4 | 5 | class _PowerDnsLuaValue(EqualityTupleMixin, dict): 6 | # See https://doc.powerdns.com/authoritative/lua-records/index.html for the 7 | # LUA record docs and 8 | # https://gist.github.com/ahupowerdns/1e8bfbba95a277a4fac09cb3654eb2ac 9 | # has some good example scripts 10 | 11 | @classmethod 12 | def validate(cls, data, _type): 13 | if not isinstance(data, (list, tuple)): 14 | data = (data,) 15 | reasons = [] 16 | if len(data) == 0: 17 | reasons.append('at least one value required') 18 | for value in data: 19 | if 'type' not in value: 20 | reasons.append('missing type') 21 | if 'script' not in value: 22 | reasons.append('missing script') 23 | return reasons 24 | 25 | @classmethod 26 | def process(cls, values): 27 | return [_PowerDnsLuaValue(v) for v in values] 28 | 29 | def __init__(self, value): 30 | self._type = value['type'] 31 | self.script = value['script'] 32 | 33 | @property 34 | def _type(self): 35 | return self['type'] 36 | 37 | @_type.setter 38 | def _type(self, value): 39 | self['type'] = value 40 | 41 | @property 42 | def script(self): 43 | return self['script'] 44 | 45 | @script.setter 46 | def script(self, value): 47 | self['script'] = value 48 | 49 | @property 50 | def data(self): 51 | return self 52 | 53 | def __hash__(self): 54 | return hash((self._type,)) 55 | 56 | def _equality_tuple(self): 57 | return (self._type, self.script) 58 | 59 | def __repr__(self): 60 | return f'{self._type} (script)' 61 | 62 | 63 | class PowerDnsLuaRecord(ValuesMixin, Record): 64 | _type = 'PowerDnsProvider/LUA' 65 | _value_type = _PowerDnsLuaValue 66 | 67 | 68 | Record.register_type(PowerDnsLuaRecord) 69 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v1.0.0 - 2025-05-04 - Long overdue 1.0 2 | 3 | ### Notedworthy Changes: 4 | 5 | * `SPF` record support removed, records should be migrated to `TXT` before 6 | upgrading. 7 | * DS properties "algorithm", "flags", "public_key", and "protocol" support 8 | removed 9 | * Requires octoDNS >= 1.5.0 10 | 11 | ## v0.0.7 - 2024-08-28 - Less picky about names 12 | 13 | * Support for fully managing zones with special characters in their names, e.g. 14 | 128/26.2.0.192.in-addr.arpa. added. 15 | 16 | ## v0.0.6 - 2024-03-08 - Get port type straight 17 | 18 | * DS Record support added 19 | * Fix for url formatting of port when it's of type float 20 | 21 | ## v0.0.5 - 2023-09-12 - Known your zones 22 | 23 | * Adds Provider.list_zones to enable new dynamic zone config functionality 24 | * Support disabling SSL verification 25 | 26 | ## v0.0.4 - 2023-08-03 - Stay off the network unless you really need it 27 | 28 | * Rework mode_of_operation to be fetched on-demand rather than during __init__ 29 | so that the provider can be created w/o access to or credentials for the 30 | server. This should allow things like octodns-validate w/o connectivity. 31 | 32 | ## v0.0.3 - 2022-12-22 - TLSA 33 | 34 | * Add support for TLSA records 35 | 36 | ## v0.0.2 - 2022-11-09 - Root NS Records and (beta) LUA record support 37 | 38 | #### Nothworthy Changes 39 | 40 | * Root NS record management support added, requires octodns>=0.9.16, 41 | `nameserver_values` and `nameserver_ttl` support removed. managing PowerDNS 42 | root NS records should migrate to sources (usually YamlProvider, but could 43 | utilize dynamic source/provider if necessary) 44 | * Support for `_get_nameserver_record` removed. For static values it should be 45 | replaced with configuration in yaml files. For dynamic values where 46 | information is sourced from an API or otherwise calculated a custom Source is 47 | recommended. 48 | * Beta-level support for PowerDnsProvider/LUA scripted records, see 49 | https://doc.powerdns.com/authoritative/lua-records/index.html for their doc 50 | and https://gist.github.com/ahupowerdns/1e8bfbba95a277a4fac09cb3654eb2ac for 51 | some of what's possible. 52 | * Allow configuring mode_of_operation and soa_edit_api via provider parameters 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## PowerDNS API provider for octoDNS 2 | 3 | An [octoDNS](https://github.com/octodns/octodns/) provider that targets [PowerDNS's API](https://doc.powerdns.com/authoritative/http-api/index.html). 4 | 5 | ### Installation 6 | 7 | #### Command line 8 | 9 | ``` 10 | pip install octodns-powerdns 11 | ``` 12 | 13 | #### requirements.txt/setup.py 14 | 15 | Pinning specific versions or SHAs is recommended to avoid unplanned upgrades. 16 | 17 | ##### Versions 18 | 19 | ``` 20 | # Start with the latest versions and don't just copy what's here 21 | octodns==0.9.21 22 | octodns-powerdns==0.0.3 23 | requests==2.31.0 24 | ``` 25 | 26 | ##### SHAs 27 | 28 | ``` 29 | # Start with the latest/specific versions and don't just copy what's here 30 | -e git+https://git@github.com/octodns/octodns.git@67ea0b0ea7961e37b028cfe21c463fa3e5090c8f#egg=octodns 31 | -e git+https://git@github.com/octodns/octodns-powerdns.git@e33349e5edfe4e12a1d179a32a5f70a8ec4c2aad#egg=octodns_powerdns 32 | requests==2.31.0 33 | ``` 34 | 35 | ### Configuration 36 | 37 | ```yaml 38 | providers: 39 | powerdns: 40 | class: octodns_powerdns.PowerDnsProvider 41 | # The host on which PowerDNS api is listening (required) 42 | host: fqdn 43 | # The port on which PowerDNS api is listening (optional, default 8081) 44 | port: 8081 45 | # The api key that grants access (required, example is using an env var) 46 | api_key: env/POWERDNS_API_KEY 47 | # The URL scheme (optional, default http) 48 | # scheme: https 49 | # Check SSL certificate (optional, default True) 50 | # ssl_verify: true 51 | # Send DNS NOTIFY to secondary servers after change (optional, default false) 52 | # notify: false 53 | ``` 54 | 55 | ### Support Information 56 | 57 | #### Records 58 | 59 | All octoDNS record types are supported. 60 | 61 | #### Root NS Records 62 | 63 | PowerDnsProvider supports full root NS record management. 64 | 65 | #### Dynamic 66 | 67 | PowerDnsProvider does not support dynamic records. 68 | 69 | ### Development 70 | 71 | See the [/script/](/script/) directory for some tools to help with the development process. They generally follow the [Script to rule them all](https://github.com/github/scripts-to-rule-them-all) pattern. Most useful is `./script/bootstrap` which will create a venv and install both the runtime and development related requirements. It will also hook up a pre-commit hook that covers most of what's run by CI. 72 | 73 | There is a [docker-compose.yml](/docker-compose.yml) file included in the repo that will set up a PowerDNS server with the API enabled for use in development. The api-key for it is `its@secret`. 74 | -------------------------------------------------------------------------------- /tests/config/unit.tests.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | ? '' 3 | : - ttl: 300 4 | type: A 5 | values: 6 | - 1.2.3.4 7 | - 1.2.3.5 8 | - ttl: 3600 9 | type: SSHFP 10 | values: 11 | - algorithm: 1 12 | fingerprint: bf6b6825d2977c511a475bbefb88aad54a92ac73 13 | fingerprint_type: 1 14 | - algorithm: 1 15 | fingerprint: 7491973e5f8b39d5327cd4e08bc81b05f7710b49 16 | fingerprint_type: 1 17 | - ttl: 600 18 | type: NS 19 | values: 20 | - 1.1.1.1. 21 | - 4.4.4.4. 22 | - type: CAA 23 | values: 24 | - flags: 0 25 | tag: issue 26 | value: ca.unit.tests 27 | - type: 'DS' 28 | values: 29 | - algorithm: 13 30 | digest: 0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF 31 | digest_type: 2 32 | key_tag: 2371 33 | _853._tcp: 34 | type: TLSA 35 | values: 36 | - certificate_association_data: 0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF 37 | certificate_usage: 3 38 | matching_type: 1 39 | selector: 1 40 | _http._tcp: 41 | ttl: 601 42 | type: URI 43 | values: 44 | - priority: 1 45 | target: https://www.unit.tests./foo 46 | weight: 2 47 | - priority: 2 48 | target: https://backups.unit.tests./ 49 | weight: 3 50 | _imap._tcp: 51 | ttl: 600 52 | type: SRV 53 | values: 54 | - port: 0 55 | priority: 0 56 | target: . 57 | weight: 0 58 | _pop3._tcp: 59 | ttl: 600 60 | type: SRV 61 | values: 62 | - port: 0 63 | priority: 0 64 | target: . 65 | weight: 0 66 | _srv._tcp: 67 | ttl: 600 68 | type: SRV 69 | values: 70 | - port: 30 71 | priority: 12 72 | target: foo-2.unit.tests. 73 | weight: 20 74 | - port: 30 75 | priority: 10 76 | target: foo-1.unit.tests. 77 | weight: 20 78 | aaaa: 79 | ttl: 600 80 | type: AAAA 81 | value: 2601:644:500:e210:62f8:1dff:feb8:947a 82 | cname: 83 | ttl: 300 84 | type: CNAME 85 | value: unit.tests. 86 | dname: 87 | ttl: 300 88 | type: DNAME 89 | value: unit.tests. 90 | excluded: 91 | octodns: 92 | excluded: 93 | - test 94 | type: CNAME 95 | value: unit.tests. 96 | https: 97 | type: HTTPS 98 | value: 99 | svcpriority: 1 100 | targetname: www.unit.tests. 101 | ignored: 102 | octodns: 103 | ignored: true 104 | type: A 105 | value: 9.9.9.9 106 | included: 107 | octodns: 108 | included: 109 | - test 110 | type: CNAME 111 | value: unit.tests. 112 | loc: 113 | ttl: 300 114 | type: LOC 115 | values: 116 | - altitude: 20 117 | lat_degrees: 31 118 | lat_direction: S 119 | lat_minutes: 58 120 | lat_seconds: 52.1 121 | long_degrees: 115 122 | long_direction: E 123 | long_minutes: 49 124 | long_seconds: 11.7 125 | precision_horz: 10 126 | precision_vert: 2 127 | size: 10 128 | - altitude: 20 129 | lat_degrees: 53 130 | lat_direction: N 131 | lat_minutes: 13 132 | lat_seconds: 10 133 | long_degrees: 2 134 | long_direction: W 135 | long_minutes: 18 136 | long_seconds: 26 137 | precision_horz: 1000 138 | precision_vert: 2 139 | size: 10 140 | lua: 141 | ttl: 42 142 | type: PowerDnsProvider/LUA 143 | values: 144 | - script: "'1.2.3.42'" 145 | type: A 146 | - script: "'fc00::42'" 147 | type: AAAA 148 | mx: 149 | ttl: 300 150 | type: MX 151 | values: 152 | - exchange: smtp-1.unit.tests. 153 | preference: 40 154 | - exchange: smtp-2.unit.tests. 155 | preference: 20 156 | - exchange: smtp-3.unit.tests. 157 | preference: 30 158 | - priority: 10 159 | value: smtp-4.unit.tests. 160 | naptr: 161 | ttl: 600 162 | type: NAPTR 163 | values: 164 | - flags: U 165 | order: 100 166 | preference: 100 167 | regexp: '!^.*$!sip:info@bar.example.com!' 168 | replacement: . 169 | service: SIP+D2U 170 | - flags: S 171 | order: 10 172 | preference: 100 173 | regexp: '!^.*$!sip:info@bar.example.com!' 174 | replacement: . 175 | service: SIP+D2U 176 | ptr: 177 | ttl: 300 178 | type: PTR 179 | values: [foo.bar.com.] 180 | sub: 181 | - type: 'NS' 182 | values: 183 | - 6.2.3.4. 184 | - 7.2.3.4. 185 | - type: 'DS' 186 | values: 187 | - algorithm: 13 188 | digest: 0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF 189 | digest_type: 2 190 | key_tag: 12345 191 | svcb: 192 | type: SVCB 193 | values: 194 | - svcpriority: 1 195 | targetname: www.unit.tests. 196 | - svcpriority: 2 197 | targetname: backups.unit.tests. 198 | txt: 199 | ttl: 600 200 | type: TXT 201 | values: 202 | - Bah bah black sheep 203 | - have you any wool. 204 | - 'v=DKIM1\;k=rsa\;s=email\;h=sha256\;p=A/kinda+of/long/string+with+numb3rs' 205 | urlfwd: 206 | ttl: 300 207 | type: URLFWD 208 | values: 209 | - code: 302 210 | masking: 2 211 | path: '/' 212 | query: 0 213 | target: 'http://www.unit.tests' 214 | - code: 301 215 | masking: 2 216 | path: '/target' 217 | query: 0 218 | target: 'http://target.unit.tests' 219 | www: 220 | ttl: 300 221 | type: A 222 | value: 2.2.3.6 223 | www.sub: 224 | ttl: 300 225 | type: A 226 | value: 2.2.3.6 227 | -------------------------------------------------------------------------------- /tests/fixtures/powerdns-full-data.json: -------------------------------------------------------------------------------- 1 | { 2 | "account": "", 3 | "dnssec": false, 4 | "id": "unit.tests.", 5 | "kind": "Master", 6 | "last_check": 0, 7 | "masters": [], 8 | "name": "unit.tests.", 9 | "notified_serial": 2017012803, 10 | "rrsets": [ 11 | { 12 | "comments": [], 13 | "name": "mx.unit.tests.", 14 | "records": [ 15 | { 16 | "content": "40 smtp-1.unit.tests.", 17 | "disabled": false 18 | }, 19 | { 20 | "content": "20 smtp-2.unit.tests.", 21 | "disabled": false 22 | }, 23 | { 24 | "content": "30 smtp-3.unit.tests.", 25 | "disabled": false 26 | }, 27 | { 28 | "content": "10 smtp-4.unit.tests.", 29 | "disabled": false 30 | } 31 | ], 32 | "ttl": 300, 33 | "type": "MX" 34 | }, 35 | { 36 | "comments": [], 37 | "name": "loc.unit.tests.", 38 | "records": [ 39 | { 40 | "content": "31 58 52.100 S 115 49 11.700 E 20.00m 10.00m 10.00m 2.00m", 41 | "disabled": false 42 | }, 43 | { 44 | "content": "53 13 10.000 N 2 18 26.000 W 20.00m 10.00m 1000.00m 2.00m", 45 | "disabled": false 46 | } 47 | ], 48 | "ttl": 300, 49 | "type": "LOC" 50 | }, 51 | { 52 | "comments": [], 53 | "name": "sub.unit.tests.", 54 | "records": [ 55 | { 56 | "content": "6.2.3.4.", 57 | "disabled": false 58 | }, { 59 | "content": "7.2.3.4.", 60 | "disabled": false 61 | } 62 | ], 63 | "ttl": 3600, 64 | "type": "NS" 65 | }, 66 | { 67 | "comments": [], 68 | "name": "www.unit.tests.", 69 | "records": [ 70 | { 71 | "content": "2.2.3.6", 72 | "disabled": false 73 | } 74 | ], 75 | "ttl": 300, 76 | "type": "A" 77 | }, 78 | { 79 | "comments": [], 80 | "name": "_imap._tcp.unit.tests.", 81 | "records": [ 82 | { 83 | "content": "0 0 0 .", 84 | "disabled": false 85 | } 86 | ], 87 | "ttl": 600, 88 | "type": "SRV" 89 | }, 90 | { 91 | "comments": [], 92 | "name": "_pop3._tcp.unit.tests.", 93 | "records": [ 94 | { 95 | "content": "0 0 0 .", 96 | "disabled": false 97 | } 98 | ], 99 | "ttl": 600, 100 | "type": "SRV" 101 | }, 102 | { 103 | "comments": [], 104 | "name": "_srv._tcp.unit.tests.", 105 | "records": [ 106 | { 107 | "content": "10 20 30 foo-1.unit.tests.", 108 | "disabled": false 109 | }, 110 | { 111 | "content": "12 20 30 foo-2.unit.tests.", 112 | "disabled": false 113 | } 114 | ], 115 | "ttl": 600, 116 | "type": "SRV" 117 | }, 118 | { 119 | "comments": [], 120 | "name": "txt.unit.tests.", 121 | "records": [ 122 | { 123 | "content": "\"Bah bah black sheep\"", 124 | "disabled": false 125 | }, 126 | { 127 | "content": "\"have you any wool.\"", 128 | "disabled": false 129 | }, 130 | { 131 | "content": "\"v=DKIM1\\;k=rsa;s=email\\;h=sha256;p=A/kinda+of/long/string+with+numb3rs\"", 132 | "disabled": false 133 | } 134 | ], 135 | "ttl": 600, 136 | "type": "TXT" 137 | }, 138 | { 139 | "comments": [], 140 | "name": "naptr.unit.tests.", 141 | "records": [ 142 | { 143 | "content": "10 100 \"S\" \"SIP+D2U\" \"!^.*$!sip:info@bar.example.com!\" .", 144 | "disabled": false 145 | }, 146 | { 147 | "content": "100 100 \"U\" \"SIP+D2U\" \"!^.*$!sip:info@bar.example.com!\" .", 148 | "disabled": false 149 | } 150 | ], 151 | "ttl": 600, 152 | "type": "NAPTR" 153 | }, 154 | { 155 | "comments": [], 156 | "name": "ptr.unit.tests.", 157 | "records": [ 158 | { 159 | "content": "foo.bar.com.", 160 | "disabled": false 161 | } 162 | ], 163 | "ttl": 300, 164 | "type": "PTR" 165 | }, 166 | { 167 | "comments": [], 168 | "name": "cname.unit.tests.", 169 | "records": [ 170 | { 171 | "content": "unit.tests.", 172 | "disabled": false 173 | } 174 | ], 175 | "ttl": 300, 176 | "type": "CNAME" 177 | }, 178 | { 179 | "comments": [], 180 | "name": "www.sub.unit.tests.", 181 | "records": [ 182 | { 183 | "content": "2.2.3.6", 184 | "disabled": false 185 | } 186 | ], 187 | "ttl": 300, 188 | "type": "A" 189 | }, 190 | { 191 | "comments": [], 192 | "name": "aaaa.unit.tests.", 193 | "records": [ 194 | { 195 | "content": "2601:644:500:e210:62f8:1dff:feb8:947a", 196 | "disabled": false 197 | } 198 | ], 199 | "ttl": 600, 200 | "type": "AAAA" 201 | }, 202 | { 203 | "comments": [], 204 | "name": "unit.tests.", 205 | "records": [ 206 | { 207 | "content": "1 1 7491973e5f8b39d5327cd4e08bc81b05f7710b49", 208 | "disabled": false 209 | }, 210 | { 211 | "content": "1 1 bf6b6825d2977c511a475bbefb88aad54a92ac73", 212 | "disabled": false 213 | } 214 | ], 215 | "ttl": 3600, 216 | "type": "SSHFP" 217 | }, 218 | { 219 | "comments": [], 220 | "name": "unit.tests.", 221 | "records": [ 222 | { 223 | "content": "ns1.ext.unit.tests. hostmaster.unit.tests. 2017012803 3600 600 604800 60", 224 | "disabled": false 225 | } 226 | ], 227 | "ttl": 3600, 228 | "type": "SOA" 229 | }, 230 | { 231 | "comments": [], 232 | "name": "unit.tests.", 233 | "records": [ 234 | { 235 | "content": "2371 13 2 0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF", 236 | "disabled": false 237 | } 238 | ], 239 | "ttl": 3600, 240 | "type": "DS" 241 | }, 242 | { 243 | "comments": [], 244 | "name": "unit.tests.", 245 | "records": [ 246 | { 247 | "content": "1.1.1.1.", 248 | "disabled": false 249 | }, 250 | { 251 | "content": "4.4.4.4.", 252 | "disabled": false 253 | } 254 | ], 255 | "ttl": 600, 256 | "type": "NS" 257 | }, 258 | { 259 | "comments": [], 260 | "name": "unit.tests.", 261 | "records": [ 262 | { 263 | "content": "1.2.3.5", 264 | "disabled": false 265 | }, 266 | { 267 | "content": "1.2.3.4", 268 | "disabled": false 269 | } 270 | ], 271 | "ttl": 300, 272 | "type": "A" 273 | }, 274 | { 275 | "comments": [], 276 | "name": "unit.tests.", 277 | "records": [ 278 | { 279 | "content": "0 issue \"ca.unit.tests\"", 280 | "disabled": false 281 | } 282 | ], 283 | "ttl": 3600, 284 | "type": "CAA" 285 | }, 286 | { 287 | "comments": [], 288 | "name": "_853._tcp.unit.tests.", 289 | "records": [ 290 | { 291 | "content": "3 1 1 0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF", 292 | "disabled": false 293 | } 294 | ], 295 | "ttl": 3600, 296 | "type": "TLSA" 297 | }, 298 | { 299 | "comments": [], 300 | "name": "included.unit.tests.", 301 | "records": [ 302 | { 303 | "content": "unit.tests.", 304 | "disabled": false 305 | } 306 | ], 307 | "ttl": 3600, 308 | "type": "CNAME" 309 | }, 310 | { 311 | "comments": [], 312 | "name": "lua.unit.tests.", 313 | "records": [ 314 | { 315 | "content": "A \"'1.2.3.42'\"", 316 | "disabled": false 317 | }, 318 | { 319 | "content": "AAAA \"'fc00::42'\"", 320 | "disabled": false 321 | } 322 | ], 323 | "ttl": 42, 324 | "type": "LUA" 325 | 326 | }, 327 | { 328 | "comments": [], 329 | "name": "sub.unit.tests.", 330 | "records": [ 331 | { 332 | "content": "12345 13 2 0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF", 333 | "disabled": false 334 | } 335 | ], 336 | "ttl": 3600, 337 | "type": "DS" 338 | }, 339 | { 340 | "comments": [], 341 | "name": "https.unit.tests.", 342 | "records": [ 343 | { 344 | "content": "1 www.unit.tests.", 345 | "disabled": false 346 | } 347 | ], 348 | "ttl": 3600, 349 | "type": "HTTPS" 350 | }, 351 | { 352 | "comments": [], 353 | "name": "svcb.unit.tests.", 354 | "records": [ 355 | { 356 | "content": "1 www.unit.tests.", 357 | "disabled": false 358 | }, 359 | { 360 | "content": "2 backups.unit.tests.", 361 | "disabled": false 362 | } 363 | ], 364 | "ttl": 3600, 365 | "type": "SVCB" 366 | }, 367 | { 368 | "comments": [], 369 | "name": "_http._tcp.unit.tests.", 370 | "records": [ 371 | { 372 | "content": "1 2 \"https://www.unit.tests./foo\"", 373 | "disabled": false 374 | }, 375 | { 376 | "content": "2 3 \"https://backups.unit.tests./\"", 377 | "disabled": false 378 | } 379 | ], 380 | "ttl": 601, 381 | "type": "URI" 382 | } 383 | ], 384 | "serial": 2017012803, 385 | "soa_edit": "", 386 | "soa_edit_api": "INCEPTION-INCREMENT", 387 | "url": "api/v1/servers/localhost/zones/unit.tests." 388 | } 389 | -------------------------------------------------------------------------------- /octodns_powerdns/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # 3 | # 4 | 5 | import logging 6 | from operator import itemgetter 7 | from urllib.parse import quote_plus 8 | 9 | from requests import HTTPError, Session 10 | 11 | from octodns import __VERSION__ as octodns_version 12 | from octodns.provider import ProviderException 13 | from octodns.provider.base import BaseProvider 14 | from octodns.record import Record 15 | 16 | try: # pragma: no cover 17 | from octodns.record.https import HttpsValue 18 | from octodns.record.svcb import SvcbValue 19 | 20 | SUPPORTS_SVCB = True 21 | except ImportError: # pragma: no cover 22 | SUPPORTS_SVCB = False 23 | 24 | try: # pragma: no cover 25 | from octodns.record.uri import UriValue 26 | 27 | SUPPORTS_URI = True 28 | except ImportError: # pragma: no cover 29 | SUPPORTS_URI = False 30 | 31 | 32 | from .record import PowerDnsLuaRecord 33 | 34 | # TODO: remove __VERSION__ with the next major version release 35 | __version__ = __VERSION__ = '1.0.0' 36 | 37 | 38 | def _encode_zone_name(name): 39 | # Powerdns uses a special encoding for URLs. Instead of "%2F" for a slash, 40 | # the slash must be encoded with "=2F". (This must be done in version 4.7.3 41 | # from Debian, from version >= 4.8 Powerdns accepts “%2F” and “=2F” as path 42 | # argument. The output of "/api/v1/servers/localhost/zones" still shows the 43 | # zone URL with "=2F") 44 | return quote_plus(name).replace('%', '=') 45 | 46 | 47 | def _escape_unescaped_semicolons(value): 48 | pieces = value.split(';') 49 | if len(pieces) == 1: 50 | return value 51 | last = pieces.pop() 52 | joined = ';'.join([p if p and p[-1] == '\\' else f'{p}\\' for p in pieces]) 53 | ret = f'{joined};{last}' 54 | return ret 55 | 56 | 57 | class PowerDnsBaseProvider(BaseProvider): 58 | SUPPORTS_GEO = False 59 | SUPPORTS_DYNAMIC = False 60 | SUPPORTS_ROOT_NS = True 61 | SUPPORTS_MULTIVALUE_PTR = True 62 | SUPPORTS = set( 63 | ( 64 | 'A', 65 | 'AAAA', 66 | 'ALIAS', 67 | 'CAA', 68 | 'CNAME', 69 | 'DS', 70 | 'LOC', 71 | 'MX', 72 | 'NAPTR', 73 | 'NS', 74 | 'PTR', 75 | 'SSHFP', 76 | 'SRV', 77 | 'TLSA', 78 | 'TXT', 79 | PowerDnsLuaRecord._type, 80 | ) 81 | ) 82 | # These are only supported if we have a new enough octoDNS core 83 | if SUPPORTS_SVCB: # pragma: no cover 84 | SUPPORTS.add('HTTPS') 85 | SUPPORTS.add('SVCB') 86 | 87 | if SUPPORTS_URI: # pragma: no cover 88 | SUPPORTS.add('URI') 89 | 90 | TIMEOUT = 5 91 | 92 | POWERDNS_MODES_OF_OPERATION = { 93 | 'native', 94 | 'primary', 95 | 'secondary', 96 | 'master', 97 | 'slave', 98 | } 99 | POWERDNS_LEGACY_MODES_OF_OPERATION = {'native', 'master', 'slave'} 100 | 101 | def __init__( 102 | self, 103 | id, 104 | host, 105 | api_key, 106 | port=8081, 107 | scheme="http", 108 | ssl_verify=True, 109 | timeout=TIMEOUT, 110 | soa_edit_api='default', 111 | mode_of_operation='master', 112 | notify=False, 113 | *args, 114 | **kwargs, 115 | ): 116 | super().__init__(id, *args, **kwargs) 117 | 118 | if getattr(self, '_get_nameserver_record', False): 119 | raise ProviderException( 120 | '_get_nameserver_record no longer ' 121 | 'supported; instead migrate to using a ' 122 | 'dynamic source for zones; see ' 123 | 'CHANGELOG.md' 124 | ) 125 | 126 | self.host = host 127 | self.port = int(port) 128 | self.scheme = scheme 129 | self.timeout = timeout 130 | self.notify = notify 131 | 132 | self._powerdns_version = None 133 | 134 | sess = Session() 135 | sess.headers.update( 136 | { 137 | 'X-API-Key': api_key, 138 | 'User-Agent': f'octodns/{octodns_version} octodns-powerdns/{__VERSION__}', 139 | } 140 | ) 141 | sess.verify = ssl_verify 142 | self._sess = sess 143 | 144 | self.soa_edit_api = soa_edit_api 145 | # to avoid making an API call to get the pdns version during the 146 | # constructor we'll check the value against the larger set of possible 147 | # values. the first time we do something that requires the mode of 148 | # operation we'll do the work of fully vetting it based on version 149 | if mode_of_operation not in self.POWERDNS_MODES_OF_OPERATION: 150 | raise ValueError( 151 | f'invalid mode_of_operation "{mode_of_operation}" - available values: {self.POWERDNS_MODES_OF_OPERATION}' 152 | ) 153 | # start out with an unset valid 154 | self._mode_of_operation = None 155 | # store what we were passed so that we can check it when the time comes 156 | self._mode_of_operation_arg = mode_of_operation 157 | 158 | def _request(self, method, path, data=None): 159 | self.log.debug('_request: method=%s, path=%s', method, path) 160 | 161 | url = ( 162 | f'{self.scheme}://{self.host}:{self.port:d}/api/v1/servers/' 163 | f'localhost/{path}'.rstrip('/') 164 | ) 165 | # Strip trailing / from url. 166 | resp = self._sess.request(method, url, json=data, timeout=self.timeout) 167 | self.log.debug('_request: status=%d', resp.status_code) 168 | resp.raise_for_status() 169 | return resp 170 | 171 | def _get(self, path, data=None): 172 | return self._request('GET', path, data=data) 173 | 174 | def _post(self, path, data=None): 175 | return self._request('POST', path, data=data) 176 | 177 | def _put(self, path, data=None): 178 | return self._request('PUT', path, data=data) 179 | 180 | def _patch(self, path, data=None): 181 | return self._request('PATCH', path, data=data) 182 | 183 | def _data_for_multiple(self, rrset): 184 | # TODO: geo not supported 185 | return { 186 | 'type': rrset['type'], 187 | 'values': [r['content'] for r in rrset['records']], 188 | 'ttl': rrset['ttl'], 189 | } 190 | 191 | _data_for_A = _data_for_multiple 192 | _data_for_AAAA = _data_for_multiple 193 | _data_for_NS = _data_for_multiple 194 | _data_for_PTR = _data_for_multiple 195 | 196 | def _data_for_TLSA(self, rrset): 197 | values = [] 198 | for record in rrset['records']: 199 | ( 200 | certificate_usage, 201 | selector, 202 | matching_type, 203 | certificate_association_data, 204 | ) = record['content'].split(' ', 3) 205 | values.append( 206 | { 207 | 'certificate_usage': certificate_usage, 208 | 'selector': selector, 209 | 'matching_type': matching_type, 210 | 'certificate_association_data': certificate_association_data, 211 | } 212 | ) 213 | return {'type': rrset['type'], 'values': values, 'ttl': rrset['ttl']} 214 | 215 | def _data_for_DS(self, rrset): 216 | values = [] 217 | for record in rrset['records']: 218 | (key_tag, algorithm, digest_type, digest) = record['content'].split( 219 | ' ', 3 220 | ) 221 | value = { 222 | 'key_tag': key_tag, 223 | 'algorithm': algorithm, 224 | 'digest_type': digest_type, 225 | 'digest': digest, 226 | } 227 | values.append(value) 228 | 229 | return {'type': rrset['type'], 'values': values, 'ttl': rrset['ttl']} 230 | 231 | def _data_for_CAA(self, rrset): 232 | values = [] 233 | for record in rrset['records']: 234 | flags, tag, value = record['content'].split(' ', 2) 235 | values.append({'flags': flags, 'tag': tag, 'value': value[1:-1]}) 236 | return {'type': rrset['type'], 'values': values, 'ttl': rrset['ttl']} 237 | 238 | def _data_for_single(self, rrset): 239 | return { 240 | 'type': rrset['type'], 241 | 'value': rrset['records'][0]['content'], 242 | 'ttl': rrset['ttl'], 243 | } 244 | 245 | _data_for_ALIAS = _data_for_single 246 | _data_for_CNAME = _data_for_single 247 | 248 | def _data_for_quoted(self, rrset): 249 | return { 250 | 'type': rrset['type'], 251 | 'values': [ 252 | _escape_unescaped_semicolons(r['content'][1:-1]) 253 | for r in rrset['records'] 254 | ], 255 | 'ttl': rrset['ttl'], 256 | } 257 | 258 | _data_for_TXT = _data_for_quoted 259 | 260 | def _data_for_LOC(self, rrset): 261 | values = [] 262 | for record in rrset['records']: 263 | ( 264 | lat_degrees, 265 | lat_minutes, 266 | lat_seconds, 267 | lat_direction, 268 | long_degrees, 269 | long_minutes, 270 | long_seconds, 271 | long_direction, 272 | altitude, 273 | size, 274 | precision_horz, 275 | precision_vert, 276 | ) = (record['content'].replace('m', '').split(' ', 11)) 277 | values.append( 278 | { 279 | 'lat_degrees': int(lat_degrees), 280 | 'lat_minutes': int(lat_minutes), 281 | 'lat_seconds': float(lat_seconds), 282 | 'lat_direction': lat_direction, 283 | 'long_degrees': int(long_degrees), 284 | 'long_minutes': int(long_minutes), 285 | 'long_seconds': float(long_seconds), 286 | 'long_direction': long_direction, 287 | 'altitude': float(altitude), 288 | 'size': float(size), 289 | 'precision_horz': float(precision_horz), 290 | 'precision_vert': float(precision_vert), 291 | } 292 | ) 293 | return {'ttl': rrset['ttl'], 'type': rrset['type'], 'values': values} 294 | 295 | def _data_for_MX(self, rrset): 296 | values = [] 297 | for record in rrset['records']: 298 | preference, exchange = record['content'].split(' ', 1) 299 | values.append({'preference': preference, 'exchange': exchange}) 300 | return {'type': rrset['type'], 'values': values, 'ttl': rrset['ttl']} 301 | 302 | def _data_for_NAPTR(self, rrset): 303 | values = [] 304 | for record in rrset['records']: 305 | order, preference, flags, service, regexp, replacement = record[ 306 | 'content' 307 | ].split(' ', 5) 308 | values.append( 309 | { 310 | 'order': order, 311 | 'preference': preference, 312 | 'flags': flags[1:-1], 313 | 'service': service[1:-1], 314 | 'regexp': regexp[1:-1], 315 | 'replacement': replacement, 316 | } 317 | ) 318 | return {'type': rrset['type'], 'values': values, 'ttl': rrset['ttl']} 319 | 320 | def _data_for_SSHFP(self, rrset): 321 | values = [] 322 | for record in rrset['records']: 323 | algorithm, fingerprint_type, fingerprint = record['content'].split( 324 | ' ', 2 325 | ) 326 | values.append( 327 | { 328 | 'algorithm': algorithm, 329 | 'fingerprint_type': fingerprint_type, 330 | 'fingerprint': fingerprint, 331 | } 332 | ) 333 | return {'type': rrset['type'], 'values': values, 'ttl': rrset['ttl']} 334 | 335 | def _data_for_SRV(self, rrset): 336 | values = [] 337 | for record in rrset['records']: 338 | priority, weight, port, target = record['content'].split(' ', 3) 339 | values.append( 340 | { 341 | 'priority': priority, 342 | 'weight': weight, 343 | 'port': port, 344 | 'target': target, 345 | } 346 | ) 347 | return {'type': rrset['type'], 'values': values, 'ttl': rrset['ttl']} 348 | 349 | def _data_for_HTTPS(self, rrset): 350 | values = [] 351 | for record in rrset['records']: 352 | value = HttpsValue.parse_rdata_text(record['content']) 353 | values.append(value) 354 | return {'type': rrset['type'], 'values': values, 'ttl': rrset['ttl']} 355 | 356 | def _data_for_SVCB(self, rrset): 357 | values = [] 358 | for record in rrset['records']: 359 | value = SvcbValue.parse_rdata_text(record['content']) 360 | values.append(value) 361 | return {'type': rrset['type'], 'values': values, 'ttl': rrset['ttl']} 362 | 363 | def _data_for_LUA(self, rrset): 364 | values = [] 365 | for record in rrset['records']: 366 | _type, script = record['content'].split(' ', 1) 367 | values.append({'type': _type, 'script': script[1:-1]}) 368 | return { 369 | 'ttl': rrset['ttl'], 370 | 'type': PowerDnsLuaRecord._type, 371 | 'values': values, 372 | } 373 | 374 | def _data_for_URI(self, rrset): 375 | values = [] 376 | for record in rrset['records']: 377 | value = UriValue.parse_rdata_text(record['content']) 378 | values.append(value) 379 | return {'type': rrset['type'], 'values': values, 'ttl': rrset['ttl']} 380 | 381 | @property 382 | def powerdns_version(self): 383 | if self._powerdns_version is None: 384 | try: 385 | resp = self._get('') 386 | except HTTPError as e: 387 | if e.response.status_code == 401: 388 | # Nicer error message for auth problems 389 | raise Exception(f'PowerDNS unauthorized host={self.host}') 390 | raise 391 | 392 | version = resp.json()['version'] 393 | self.log.debug( 394 | 'powerdns_version: got version %s from server', version 395 | ) 396 | # The extra `-` split is to handle pre-release and source built 397 | # versions like 4.5.0-alpha0.435.master.gcb114252b 398 | self._powerdns_version = [ 399 | int(p.split('-')[0]) for p in version.split('.')[:3] 400 | ] 401 | 402 | return self._powerdns_version 403 | 404 | @property 405 | def soa_edit_api(self): 406 | # >>> [4, 4, 3] >= [4, 3] 407 | # True 408 | # >>> [4, 3, 3] >= [4, 3] 409 | # True 410 | # >>> [4, 1, 3] >= [4, 3] 411 | # False 412 | return self._soa_edit_api 413 | 414 | @soa_edit_api.setter 415 | def soa_edit_api(self, value): 416 | settings = { 417 | 'default', 418 | 'increase', 419 | 'epoch', 420 | 'soa-edit', 421 | 'soa-edit-increase', 422 | } 423 | 424 | if value in settings: 425 | self._soa_edit_api = value 426 | else: 427 | raise ValueError( 428 | f'invalid soa_edit_api, "{value}" - available values: {settings}' 429 | ) 430 | 431 | @property 432 | def mode_of_operation(self): 433 | if self._mode_of_operation is None: 434 | # start with what we were passed as a provider arg 435 | value = self._mode_of_operation_arg 436 | # we previously validated things against 437 | # POWERDNS_MODES_OF_OPERATION, the newer/larger set. If we're 438 | # running an (much) older version we need to check against the 439 | # reduced set of options now that we can get the version 440 | if ( 441 | self.powerdns_version < [4, 5] 442 | and value not in self.POWERDNS_LEGACY_MODES_OF_OPERATION 443 | ): 444 | raise ValueError( 445 | f'invalid mode_of_operation "{value}" - available values: {self.POWERDNS_LEGACY_MODES_OF_OPERATION}' 446 | ) 447 | # we have a value we can now confidentily use 448 | self._mode_of_operation = value 449 | 450 | return self._mode_of_operation 451 | 452 | @property 453 | def check_status_not_found(self): 454 | # >=4.2.x returns 404 when not found 455 | return self.powerdns_version >= [4, 2] 456 | 457 | def list_zones(self): 458 | self.log.debug('list_zones:') 459 | resp = self._get('zones') 460 | return sorted([z['name'] for z in resp.json()]) 461 | 462 | def populate(self, zone, target=False, lenient=False): 463 | self.log.debug( 464 | 'populate: name=%s, target=%s, lenient=%s', 465 | zone.name, 466 | target, 467 | lenient, 468 | ) 469 | encoded_name = _encode_zone_name(zone.name) 470 | resp = None 471 | try: 472 | resp = self._get(f'zones/{encoded_name}') 473 | self.log.debug('populate: loaded') 474 | except HTTPError as e: 475 | error = self._get_error(e) 476 | if e.response.status_code == 401: 477 | # Nicer error message for auth problems 478 | raise Exception(f'PowerDNS unauthorized host={self.host}') 479 | elif e.response.status_code == 404 and self.check_status_not_found: 480 | # 404 means powerdns doesn't know anything about the requested 481 | # domain. We'll just ignore it here and leave the zone 482 | # untouched. 483 | pass 484 | elif ( 485 | e.response.status_code == 422 486 | and error.startswith('Could not find domain ') 487 | and not self.check_status_not_found 488 | ): 489 | # 422 means powerdns doesn't know anything about the requested 490 | # domain. We'll just ignore it here and leave the zone 491 | # untouched. 492 | pass 493 | else: 494 | # just re-throw 495 | raise 496 | 497 | before = len(zone.records) 498 | exists = False 499 | 500 | if resp: 501 | exists = True 502 | for rrset in resp.json()['rrsets']: 503 | _type = rrset['type'] 504 | _provider_specific_type = f'PowerDnsProvider/{_type}' 505 | if ( 506 | _type not in self.SUPPORTS 507 | and _provider_specific_type not in self.SUPPORTS 508 | ): 509 | continue 510 | data_for = getattr(self, f'_data_for_{_type}') 511 | record_name = zone.hostname_from_fqdn(rrset['name']) 512 | record = Record.new( 513 | zone, 514 | record_name, 515 | data_for(rrset), 516 | source=self, 517 | lenient=lenient, 518 | ) 519 | zone.add_record(record, lenient=lenient) 520 | 521 | self.log.info( 522 | 'populate: found %s records, exists=%s', 523 | len(zone.records) - before, 524 | exists, 525 | ) 526 | return exists 527 | 528 | def _records_for_multiple(self, record): 529 | return [ 530 | {'content': v, 'disabled': False} for v in record.values 531 | ], record._type 532 | 533 | _records_for_A = _records_for_multiple 534 | _records_for_AAAA = _records_for_multiple 535 | _records_for_NS = _records_for_multiple 536 | _records_for_PTR = _records_for_multiple 537 | 538 | def _records_for_TLSA(self, record): 539 | return [ 540 | { 541 | 'content': f'{v.certificate_usage} {v.selector} {v.matching_type} {v.certificate_association_data}', 542 | 'disabled': False, 543 | } 544 | for v in record.values 545 | ], record._type 546 | 547 | def _records_for_DS(self, record): 548 | data = [] 549 | for v in record.values: 550 | content = f'{v.key_tag} {v.algorithm} {v.digest_type} {v.digest}' 551 | data.append({'content': content, 'disabled': False}) 552 | return data, record._type 553 | 554 | def _records_for_CAA(self, record): 555 | return [ 556 | {'content': f'{v.flags} {v.tag} "{v.value}"', 'disabled': False} 557 | for v in record.values 558 | ], record._type 559 | 560 | def _records_for_single(self, record): 561 | return [{'content': record.value, 'disabled': False}], record._type 562 | 563 | _records_for_ALIAS = _records_for_single 564 | _records_for_CNAME = _records_for_single 565 | 566 | def _records_for_quoted(self, record): 567 | return [ 568 | {'content': f'"{v}"', 'disabled': False} for v in record.values 569 | ], record._type 570 | 571 | _records_for_TXT = _records_for_quoted 572 | 573 | def _records_for_LOC(self, record): 574 | return [ 575 | { 576 | 'content': '%d %d %0.3f %s %d %d %.3f %s %0.2fm %0.2fm %0.2fm %0.2fm' 577 | % ( 578 | int(v.lat_degrees), 579 | int(v.lat_minutes), 580 | float(v.lat_seconds), 581 | v.lat_direction, 582 | int(v.long_degrees), 583 | int(v.long_minutes), 584 | float(v.long_seconds), 585 | v.long_direction, 586 | float(v.altitude), 587 | float(v.size), 588 | float(v.precision_horz), 589 | float(v.precision_vert), 590 | ), 591 | 'disabled': False, 592 | } 593 | for v in record.values 594 | ], record._type 595 | 596 | def _records_for_MX(self, record): 597 | return [ 598 | {'content': f'{v.preference} {v.exchange}', 'disabled': False} 599 | for v in record.values 600 | ], record._type 601 | 602 | def _records_for_NAPTR(self, record): 603 | return [ 604 | { 605 | 'content': f'{v.order} {v.preference} "{v.flags}" "{v.service}" ' 606 | f'"{v.regexp}" {v.replacement}', 607 | 'disabled': False, 608 | } 609 | for v in record.values 610 | ], record._type 611 | 612 | def _records_for_SSHFP(self, record): 613 | return [ 614 | { 615 | 'content': f'{v.algorithm} {v.fingerprint_type} {v.fingerprint}', 616 | 'disabled': False, 617 | } 618 | for v in record.values 619 | ], record._type 620 | 621 | def _records_for_SRV(self, record): 622 | return [ 623 | { 624 | 'content': f'{v.priority} {v.weight} {v.port} {v.target}', 625 | 'disabled': False, 626 | } 627 | for v in record.values 628 | ], record._type 629 | 630 | def _records_for_SVCB(self, record): 631 | return [ 632 | {'content': v.rdata_text, 'disabled': False} for v in record.values 633 | ], record._type 634 | 635 | _records_for_HTTPS = _records_for_SVCB 636 | _records_for_URI = _records_for_SVCB 637 | 638 | def _records_for_PowerDnsProvider_LUA(self, record): 639 | return [ 640 | {'content': f'{v._type} "{v.script}"', 'disabled': False} 641 | for v in record.values 642 | ], 'LUA' 643 | 644 | def _mod_Create(self, change): 645 | new = change.new 646 | records_for = f'_records_for_{new._type}'.replace('/', '_') 647 | records_for = getattr(self, records_for) 648 | records = records_for(new) 649 | 650 | records, _type = records_for(new) 651 | return { 652 | 'name': new.fqdn, 653 | 'type': _type, 654 | 'ttl': new.ttl, 655 | 'changetype': 'REPLACE', 656 | 'records': records, 657 | } 658 | 659 | _mod_Update = _mod_Create 660 | 661 | def _mod_Delete(self, change): 662 | existing = change.existing 663 | records_for = f'_records_for_{existing._type}'.replace('/', '_') 664 | records_for = getattr(self, records_for) 665 | records = records_for(existing) 666 | 667 | records, _type = records_for(existing) 668 | return { 669 | 'name': existing.fqdn, 670 | 'type': _type, 671 | 'ttl': existing.ttl, 672 | 'changetype': 'DELETE', 673 | 'records': records, 674 | } 675 | 676 | def _get_error(self, http_error): 677 | try: 678 | return http_error.response.json()['error'] 679 | except Exception: 680 | return '' 681 | 682 | def _apply(self, plan): 683 | desired = plan.desired 684 | changes = plan.changes 685 | encoded_name = _encode_zone_name(desired.name) 686 | self.log.debug( 687 | '_apply: zone=%s, len(changes)=%d', desired.name, len(changes) 688 | ) 689 | 690 | mods = [] 691 | for change in changes: 692 | class_name = change.__class__.__name__ 693 | mods.append(getattr(self, f'_mod_{class_name}')(change)) 694 | 695 | # Ensure that any DELETE modifications always occur before any REPLACE 696 | # modifications. This ensures that an A record can be replaced by a 697 | # CNAME record and vice-versa. 698 | mods.sort(key=itemgetter('changetype')) 699 | 700 | self.log.debug('_apply: sending change request') 701 | 702 | try: 703 | self._patch(f'zones/{encoded_name}', data={'rrsets': mods}) 704 | self.log.debug('_apply: patched') 705 | except HTTPError as e: 706 | error = self._get_error(e) 707 | if not ( 708 | (e.response.status_code == 404 and self.check_status_not_found) 709 | or ( 710 | e.response.status_code == 422 711 | and error.startswith('Could not find domain ') 712 | and not self.check_status_not_found 713 | ) 714 | ): 715 | self.log.error( 716 | '_apply: status=%d, text=%s', 717 | e.response.status_code, 718 | e.response.text, 719 | ) 720 | raise 721 | 722 | self.log.info('_apply: creating zone=%s', desired.name) 723 | # 404 or 422 means powerdns doesn't know anything about the 724 | # requested domain. We'll try to create it with the correct 725 | # records instead of update. Hopefully all the mods are 726 | # creates :-) 727 | data = { 728 | 'name': desired.name, 729 | 'kind': self.mode_of_operation, 730 | 'masters': [], 731 | 'nameservers': [], 732 | 'rrsets': mods, 733 | 'soa_edit_api': self.soa_edit_api, 734 | 'serial': 0, 735 | } 736 | try: 737 | self._post('zones', data) 738 | except HTTPError as e: 739 | self.log.error( 740 | '_apply: status=%d, text=%s', 741 | e.response.status_code, 742 | e.response.text, 743 | ) 744 | raise 745 | self.log.debug('_apply: created') 746 | 747 | if self.notify: 748 | self._request_notify(encoded_name) 749 | 750 | self.log.debug('_apply: complete') 751 | 752 | def _request_notify(self, zoneid): 753 | self.log.debug('_request_notify: requesting notification: %s', zoneid) 754 | self._put(f'zones/{zoneid}/notify') 755 | 756 | 757 | class PowerDnsProvider(PowerDnsBaseProvider): 758 | def __init__( 759 | self, 760 | id, 761 | host, 762 | api_key, 763 | port=8081, 764 | nameserver_values=None, 765 | nameserver_ttl=None, 766 | *args, 767 | **kwargs, 768 | ): 769 | self.log = logging.getLogger(f'PowerDnsProvider[{id}]') 770 | self.log.debug( 771 | '__init__: id=%s, host=%s, port=%d, ' 772 | 'nameserver_values=%s, nameserver_ttl=%s', 773 | id, 774 | host, 775 | port, 776 | nameserver_values, 777 | nameserver_ttl, 778 | ) 779 | super().__init__( 780 | id, host=host, api_key=api_key, port=port, *args, **kwargs 781 | ) 782 | 783 | if nameserver_values or nameserver_ttl: 784 | raise ProviderException( 785 | 'nameserver_values parameter no longer ' 786 | 'supported; migrate root NS records to ' 787 | 'sources; see CHANGELOG.md' 788 | ) 789 | -------------------------------------------------------------------------------- /tests/test_octodns_provider_powerdns.py: -------------------------------------------------------------------------------- 1 | # 2 | # 3 | # 4 | 5 | from json import dumps, loads 6 | from logging import getLogger 7 | from os.path import dirname, join 8 | from unittest import TestCase 9 | 10 | from requests import HTTPError 11 | from requests_mock import ANY 12 | from requests_mock import mock as requests_mock 13 | 14 | from octodns.provider import ProviderException 15 | from octodns.provider.yaml import YamlProvider 16 | from octodns.record import Record, ValidationError 17 | from octodns.zone import Zone 18 | 19 | from octodns_powerdns import ( 20 | PowerDnsBaseProvider, 21 | PowerDnsProvider, 22 | _encode_zone_name, 23 | _escape_unescaped_semicolons, 24 | ) 25 | from octodns_powerdns.record import PowerDnsLuaRecord, _PowerDnsLuaValue 26 | 27 | EMPTY_TEXT = ''' 28 | { 29 | "account": "", 30 | "dnssec": false, 31 | "id": "xunit.tests.", 32 | "kind": "Master", 33 | "last_check": 0, 34 | "masters": [], 35 | "mode_of_operation": "master", 36 | "name": "xunit.tests.", 37 | "notified_serial": 0, 38 | "rrsets": [], 39 | "serial": 2017012801, 40 | "soa_edit": "", 41 | "soa_edit_api": "default", 42 | "url": "api/v1/servers/localhost/zones/xunit.tests." 43 | } 44 | ''' 45 | 46 | with open('./tests/fixtures/powerdns-full-data.json') as fh: 47 | FULL_TEXT = fh.read() 48 | 49 | 50 | class TestPowerDnsProvider(TestCase): 51 | def test_provider_version_detection(self): 52 | # Bad auth 53 | with requests_mock() as mock: 54 | mock.get(ANY, status_code=401, text='Unauthorized') 55 | 56 | with self.assertRaises(Exception) as ctx: 57 | provider = PowerDnsProvider('test', 'non.existent', 'api-key') 58 | provider.powerdns_version 59 | self.assertTrue('unauthorized' in str(ctx.exception)) 60 | 61 | # Api not found 62 | with requests_mock() as mock: 63 | mock.get(ANY, status_code=404, text='Not Found') 64 | 65 | with self.assertRaises(Exception) as ctx: 66 | provider = PowerDnsProvider('test', 'non.existent', 'api-key') 67 | provider.powerdns_version 68 | self.assertTrue('404' in str(ctx.exception)) 69 | 70 | # Test version detection 71 | with requests_mock() as mock: 72 | mock.get( 73 | 'http://non.existent:8081/api/v1/servers/localhost', 74 | status_code=200, 75 | json={'version': "4.1.10"}, 76 | ) 77 | provider = PowerDnsProvider('test', 'non.existent', 'api-key') 78 | self.assertEqual(provider.powerdns_version, [4, 1, 10]) 79 | 80 | # Test version detection for second time (should stay at 4.1.10) 81 | with requests_mock() as mock: 82 | mock.get( 83 | 'http://non.existent:8081/api/v1/servers/localhost', 84 | status_code=200, 85 | json={'version': "4.2.0"}, 86 | ) 87 | self.assertEqual(provider.powerdns_version, [4, 1, 10]) 88 | 89 | # Test version detection 90 | with requests_mock() as mock: 91 | mock.get( 92 | 'http://non.existent:8081/api/v1/servers/localhost', 93 | status_code=200, 94 | json={'version': "4.2.0"}, 95 | ) 96 | 97 | # Reset version, so detection will try again 98 | provider._powerdns_version = None 99 | self.assertNotEqual(provider.powerdns_version, [4, 1, 10]) 100 | 101 | # Test version detection with pre-releases 102 | with requests_mock() as mock: 103 | # Reset version, so detection will try again 104 | provider._powerdns_version = None 105 | mock.get( 106 | 'http://non.existent:8081/api/v1/servers/localhost', 107 | status_code=200, 108 | json={'version': "4.4.0-alpha1"}, 109 | ) 110 | self.assertEqual(provider.powerdns_version, [4, 4, 0]) 111 | 112 | provider._powerdns_version = None 113 | mock.get( 114 | 'http://non.existent:8081/api/v1/servers/localhost', 115 | status_code=200, 116 | json={'version': "4.5.0-alpha0.435.master.gcb114252b"}, 117 | ) 118 | self.assertEqual(provider.powerdns_version, [4, 5, 0]) 119 | 120 | def test_provider_version_config(self): 121 | # Test version 4.1.0 122 | with requests_mock() as mock: 123 | mock.get( 124 | 'http://non.existent:8081/api/v1/servers/localhost', 125 | status_code=200, 126 | json={'version': "4.1.10"}, 127 | ) 128 | provider = PowerDnsProvider('test', 'non.existent', 'api-key') 129 | self.assertEqual(provider.soa_edit_api, 'default') 130 | self.assertEqual(provider.mode_of_operation, 'master') 131 | self.assertFalse( 132 | provider.check_status_not_found, 133 | 'check_status_not_found should be false ' 134 | 'for version 4.1.x and below', 135 | ) 136 | 137 | # Test version 4.2.0 138 | provider._powerdns_version = None 139 | with requests_mock() as mock: 140 | mock.get( 141 | 'http://non.existent:8081/api/v1/servers/localhost', 142 | status_code=200, 143 | json={'version': "4.2.0"}, 144 | ) 145 | self.assertEqual(provider.soa_edit_api, 'default') 146 | self.assertEqual(provider.mode_of_operation, 'master') 147 | self.assertTrue( 148 | provider.check_status_not_found, 149 | 'check_status_not_found should be true for version 4.2.x', 150 | ) 151 | 152 | # Test version 4.3.0 153 | provider._powerdns_version = None 154 | with requests_mock() as mock: 155 | mock.get( 156 | 'http://non.existent:8081/api/v1/servers/localhost', 157 | status_code=200, 158 | json={'version': "4.3.0"}, 159 | ) 160 | provider = PowerDnsProvider( 161 | 'test', 162 | 'non.existent', 163 | 'api-key', 164 | soa_edit_api="soa-edit", 165 | mode_of_operation="slave", 166 | ) 167 | self.assertEqual(provider.soa_edit_api, 'soa-edit') 168 | self.assertEqual(provider.mode_of_operation, 'slave') 169 | self.assertTrue( 170 | provider.check_status_not_found, 171 | 'check_status_not_found should be true for version 4.3.x', 172 | ) 173 | 174 | # Test version 4.5.0 175 | # mode_of_operation primary preffered over master and secondary prefered over slave. 176 | with requests_mock() as mock: 177 | mock.get( 178 | 'http://non.existent:8081/api/v1/servers/localhost', 179 | status_code=200, 180 | json={'version': "4.5.0"}, 181 | ) 182 | provider = PowerDnsProvider( 183 | 'test', 184 | 'non.existent', 185 | 'api-key', 186 | soa_edit_api="epoch", 187 | mode_of_operation="primary", 188 | ) 189 | self.assertEqual(provider.soa_edit_api, 'epoch') 190 | self.assertEqual(provider.mode_of_operation, 'primary') 191 | self.assertTrue( 192 | provider.check_status_not_found, 193 | 'check_status_not_found should be true for version 4.5.x', 194 | ) 195 | 196 | def test_managed_attribute_validation(self): 197 | with requests_mock() as mock: 198 | mock.get( 199 | 'http://non.existent:8081/api/v1/servers/localhost', 200 | status_code=200, 201 | json={'version': "4.2.0"}, 202 | ) 203 | 204 | with self.assertRaises(ValueError) as ctx: 205 | PowerDnsProvider( 206 | 'test', 207 | 'non.existent', 208 | 'api-key', 209 | soa_edit_api='inception-increment', 210 | ) 211 | self.assertTrue('invalid soa_edit_api', str(ctx.exception)) 212 | 213 | # "Primary" is available since pdns v4.5 214 | with self.assertRaises(ValueError) as ctx: 215 | provider = PowerDnsProvider( 216 | 'test', 217 | 'non.existent', 218 | 'api-key', 219 | mode_of_operation='primary', 220 | ) 221 | provider.mode_of_operation() 222 | self.assertTrue('invalid mode_of_operation' in str(ctx.exception)) 223 | 224 | # "foo" is never a valid option 225 | with self.assertRaises(ValueError) as ctx: 226 | provider = PowerDnsProvider( 227 | 'test', 'non.existent', 'api-key', mode_of_operation='foo' 228 | ) 229 | self.assertTrue('invalid mode_of_operation' in str(ctx.exception)) 230 | 231 | def test_provider(self): 232 | # Test version detection 233 | with requests_mock() as mock: 234 | mock.get( 235 | 'http://non.existent:8082/api/v1/servers/localhost', 236 | status_code=200, 237 | json={'version': "4.1.10"}, 238 | ) 239 | provider = PowerDnsProvider( 240 | 'test', 241 | 'non.existent', 242 | 'api-key', 243 | strict_supports=False, 244 | # specifically testing a float here to make sure it doesn't 245 | # include the .1 when applied to the url 246 | port=8082.1, 247 | ) 248 | self.assertEqual(provider.powerdns_version, [4, 1, 10]) 249 | 250 | # Bad auth 251 | with requests_mock() as mock: 252 | mock.get(ANY, status_code=401, text='Unauthorized') 253 | 254 | with self.assertRaises(Exception) as ctx: 255 | zone = Zone('unit.tests.', []) 256 | provider.populate(zone) 257 | self.assertTrue('unauthorized' in str(ctx.exception)) 258 | 259 | # General error 260 | with requests_mock() as mock: 261 | mock.get(ANY, status_code=502, text='Things caught fire') 262 | 263 | with self.assertRaises(HTTPError) as ctx: 264 | zone = Zone('unit.tests.', []) 265 | provider.populate(zone) 266 | self.assertEqual(502, ctx.exception.response.status_code) 267 | 268 | # Non-existent zone in PowerDNS <4.3.0 doesn't populate anything 269 | with requests_mock() as mock: 270 | mock.get( 271 | ANY, 272 | status_code=422, 273 | json={'error': "Could not find domain 'unit.tests.'"}, 274 | ) 275 | zone = Zone('unit.tests.', []) 276 | provider.populate(zone) 277 | self.assertEqual(set(), zone.records) 278 | 279 | # Non-existent zone in PowerDNS >=4.2.0 doesn't populate anything 280 | 281 | provider._powerdns_version = [4, 2, 0] 282 | with requests_mock() as mock: 283 | mock.get(ANY, status_code=404, text='Not Found') 284 | zone = Zone('unit.tests.', []) 285 | provider.populate(zone) 286 | self.assertEqual(set(), zone.records) 287 | 288 | provider._powerdns_version = [4, 1, 0] 289 | 290 | # The rest of this is messy/complicated b/c it's dealing with mocking 291 | 292 | expected = Zone('unit.tests.', []) 293 | source = YamlProvider( 294 | 'test', join(dirname(__file__), 'config'), supports_root_ns=False 295 | ) 296 | source.populate(expected) 297 | expected_n = len(expected.records) - 4 298 | self.assertEqual(25, expected_n) 299 | 300 | # No diffs == no changes 301 | with requests_mock() as mock: 302 | mock.get(ANY, status_code=200, text=FULL_TEXT) 303 | 304 | zone = Zone('unit.tests.', []) 305 | provider.populate(zone) 306 | self.assertEqual(25, len(zone.records)) 307 | changes = expected.changes(zone, provider) 308 | self.assertEqual(0, len(changes)) 309 | 310 | # Used in a minute 311 | def assert_rrsets_callback(request, context): 312 | data = loads(request.body) 313 | self.assertEqual(expected_n, len(data['rrsets'])) 314 | return '' 315 | 316 | # No existing records -> creates for every record in expected 317 | with requests_mock() as mock: 318 | mock.get(ANY, status_code=200, text=EMPTY_TEXT) 319 | # post 201, is response to the create with data 320 | mock.patch(ANY, status_code=201, text=assert_rrsets_callback) 321 | 322 | plan = provider.plan(expected) 323 | self.assertEqual(expected_n, len(plan.changes)) 324 | self.assertEqual(expected_n, provider.apply(plan)) 325 | self.assertTrue(plan.exists) 326 | 327 | # Non-existent zone -> creates for every record in expected 328 | # OMG this is fucking ugly, probably better to ditch requests_mocks and 329 | # just mock things for real as it doesn't seem to provide a way to get 330 | # at the request params or verify that things were called from what I 331 | # can tell 332 | not_found = {'error': "Could not find domain 'unit.tests.'"} 333 | with requests_mock() as mock: 334 | # get 422's, unknown zone 335 | mock.get(ANY, status_code=422, text=dumps(not_found)) 336 | # patch 422's, unknown zone 337 | mock.patch(ANY, status_code=422, text=dumps(not_found)) 338 | # post 201, is response to the create with data 339 | mock.post(ANY, status_code=201, text=assert_rrsets_callback) 340 | 341 | plan = provider.plan(expected) 342 | self.assertEqual(expected_n, len(plan.changes)) 343 | self.assertEqual(expected_n, provider.apply(plan)) 344 | self.assertFalse(plan.exists) 345 | 346 | provider._powerdns_version = [4, 2, 0] 347 | with requests_mock() as mock: 348 | # get 404's, unknown zone 349 | mock.get(ANY, status_code=404, text='') 350 | # patch 404's, unknown zone 351 | mock.patch(ANY, status_code=404, text=dumps(not_found)) 352 | # post 201, is response to the create with data 353 | mock.post(ANY, status_code=201, text=assert_rrsets_callback) 354 | 355 | plan = provider.plan(expected) 356 | self.assertEqual(expected_n, len(plan.changes)) 357 | self.assertEqual(expected_n, provider.apply(plan)) 358 | self.assertFalse(plan.exists) 359 | 360 | provider._powerdns_version = [4, 1, 0] 361 | with requests_mock() as mock: 362 | # get 422's, unknown zone 363 | mock.get(ANY, status_code=422, text=dumps(not_found)) 364 | # patch 422's, 365 | data = {'error': "Key 'name' not present or not a String"} 366 | mock.patch(ANY, status_code=422, text=dumps(data)) 367 | 368 | with self.assertRaises(HTTPError) as ctx: 369 | plan = provider.plan(expected) 370 | provider.apply(plan) 371 | response = ctx.exception.response 372 | self.assertEqual(422, response.status_code) 373 | self.assertTrue('error' in response.json()) 374 | 375 | with requests_mock() as mock: 376 | # get 422's, unknown zone 377 | mock.get(ANY, status_code=422, text=dumps(not_found)) 378 | # patch 500's, things just blew up 379 | mock.patch(ANY, status_code=500, text='') 380 | 381 | with self.assertRaises(HTTPError): 382 | plan = provider.plan(expected) 383 | provider.apply(plan) 384 | 385 | with requests_mock() as mock: 386 | # get 422's, unknown zone 387 | mock.get(ANY, status_code=422, text=dumps(not_found)) 388 | # patch 500's, things just blew up 389 | mock.patch(ANY, status_code=422, text=dumps(not_found)) 390 | # post 422's, something wrong with create 391 | mock.post(ANY, status_code=422, text='Hello Word!') 392 | 393 | with self.assertRaises(HTTPError): 394 | plan = provider.plan(expected) 395 | provider.apply(plan) 396 | 397 | def test_small_change(self): 398 | expected = Zone('unit.tests.', []) 399 | source = YamlProvider( 400 | 'test', join(dirname(__file__), 'config'), supports_root_ns=False 401 | ) 402 | source.populate(expected) 403 | self.assertEqual(29, len(expected.records)) 404 | 405 | # A small change to a single record 406 | with requests_mock() as mock: 407 | mock.get(ANY, status_code=200, text=FULL_TEXT) 408 | mock.get( 409 | 'http://non.existent:8081/api/v1/servers/localhost', 410 | status_code=200, 411 | json={'version': '4.1.0'}, 412 | ) 413 | provider = PowerDnsProvider( 414 | 'test', 'non.existent', 'api-key', strict_supports=False 415 | ) 416 | 417 | missing = Zone(expected.name, []) 418 | # Find and delete the SPF record 419 | for record in expected.records: 420 | if record._type != 'SVCB': 421 | missing.add_record(record) 422 | 423 | def assert_delete_callback(request, context): 424 | self.assertEqual( 425 | { 426 | 'rrsets': [ 427 | { 428 | 'records': [ 429 | { 430 | 'content': '1 www.unit.tests.', 431 | 'disabled': False, 432 | }, 433 | { 434 | 'content': '2 backups.unit.tests.', 435 | 'disabled': False, 436 | }, 437 | ], 438 | 'changetype': 'DELETE', 439 | 'type': 'SVCB', 440 | 'name': 'svcb.unit.tests.', 441 | 'ttl': 3600, 442 | } 443 | ] 444 | }, 445 | loads(request.body), 446 | ) 447 | return '' 448 | 449 | mock.patch(ANY, status_code=201, text=assert_delete_callback) 450 | 451 | plan = provider.plan(missing) 452 | self.assertEqual(1, len(plan.changes)) 453 | self.assertEqual(1, provider.apply(plan)) 454 | 455 | def test_notify(self): 456 | expected = Zone('unit.tests.', []) 457 | source = YamlProvider( 458 | 'test', join(dirname(__file__), 'config'), supports_root_ns=False 459 | ) 460 | source.populate(expected) 461 | 462 | # PUT /servers/{server_id}/zones/{zone_id}/notify should be invoked in apply() 463 | with requests_mock() as mock: 464 | mock.get( 465 | 'http://non.existent:8081/api/v1/servers/localhost/zones/unit.tests.', 466 | status_code=200, 467 | text=FULL_TEXT, 468 | ) 469 | mock.get( 470 | 'http://non.existent:8081/api/v1/servers/localhost', 471 | status_code=200, 472 | json={'version': '4.1.0'}, 473 | ) 474 | provider = PowerDnsProvider( 475 | 'test', 476 | 'non.existent', 477 | 'api-key', 478 | strict_supports=False, 479 | notify=True, 480 | ) 481 | 482 | missing = Zone(expected.name, []) 483 | # Find and delete the SPF record 484 | for record in expected.records: 485 | if record._type != 'SVCB': 486 | missing.add_record(record) 487 | 488 | plan = provider.plan(missing) 489 | self.assertEqual(1, len(plan.changes)) 490 | 491 | def mock_notify(request, context): 492 | mock.put( 493 | 'http://non.existent:8081/api/v1/servers/localhost/zones/unit.tests./notify', 494 | status_code=200, 495 | text='', 496 | ) 497 | return '' 498 | 499 | mock.patch( 500 | 'http://non.existent:8081/api/v1/servers/localhost/zones/unit.tests.', 501 | status_code=204, 502 | text=mock_notify, # PUT /notify is invoked after PATCHing the zone 503 | ) 504 | 505 | self.assertEqual(1, provider.apply(plan)) 506 | 507 | def test_nameservers_params(self): 508 | with requests_mock() as mock: 509 | mock.get( 510 | 'http://non.existent:8081/api/v1/servers/localhost', 511 | status_code=200, 512 | json={'version': "4.1.10"}, 513 | ) 514 | with self.assertRaises(ProviderException) as ctx: 515 | PowerDnsProvider( 516 | 'test', 517 | 'non.existent', 518 | 'api-key', 519 | nameserver_values=['8.8.8.8.', '9.9.9.9.'], 520 | nameserver_ttl=600, 521 | ) 522 | self.assertTrue( 523 | str(ctx.exception).startswith( 524 | 'nameserver_values parameter no longer supported' 525 | ) 526 | ) 527 | 528 | class ChildProvider(PowerDnsBaseProvider): 529 | log = getLogger('ChildProvider') 530 | 531 | def _get_nameserver_record(self, *args, **kwargs): 532 | pass 533 | 534 | with self.assertRaises(ProviderException) as ctx: 535 | with requests_mock() as mock: 536 | mock.get( 537 | 'http://non.existent:8081/api/v1/servers/localhost', 538 | status_code=200, 539 | json={'version': "4.1.10"}, 540 | ) 541 | ChildProvider('text', 'non.existent', 'api-key') 542 | self.assertTrue( 543 | str(ctx.exception).startswith( 544 | '_get_nameserver_record no longer supported;' 545 | ) 546 | ) 547 | 548 | def test_unescaped_semicolon(self): 549 | # no escapes 550 | self.assertEqual('', _escape_unescaped_semicolons('')) 551 | self.assertEqual('hello', _escape_unescaped_semicolons('hello')) 552 | self.assertEqual( 553 | 'hello world!', _escape_unescaped_semicolons('hello world!') 554 | ) 555 | 556 | # good 557 | self.assertEqual('\\;', _escape_unescaped_semicolons('\\;')) 558 | self.assertEqual('foo\\;', _escape_unescaped_semicolons('foo\\;')) 559 | self.assertEqual( 560 | 'foo\\; bar\\;', _escape_unescaped_semicolons('foo\\; bar\\;') 561 | ) 562 | self.assertEqual( 563 | 'foo\\; bar\\; baz\\;', 564 | _escape_unescaped_semicolons('foo\\; bar\\; baz\\;'), 565 | ) 566 | 567 | # missing 568 | self.assertEqual('\\;', _escape_unescaped_semicolons(';')) 569 | self.assertEqual('foo\\;', _escape_unescaped_semicolons('foo;')) 570 | self.assertEqual( 571 | 'foo\\; bar\\;', _escape_unescaped_semicolons('foo; bar;') 572 | ) 573 | self.assertEqual( 574 | 'foo\\; bar\\; baz\\;', 575 | _escape_unescaped_semicolons('foo; bar; baz;'), 576 | ) 577 | 578 | # partial 579 | self.assertEqual( 580 | 'foo\\; bar\\; baz\\;', 581 | _escape_unescaped_semicolons('foo; bar\\; baz;'), 582 | ) 583 | 584 | # double escaped, left alone 585 | self.assertEqual('foo\\\\;', _escape_unescaped_semicolons('foo\\\\;')) 586 | 587 | # double ;; 588 | self.assertEqual('foo\\;\\;', _escape_unescaped_semicolons('foo\\;\\;')) 589 | self.assertEqual('foo\\;\\;', _escape_unescaped_semicolons('foo;\\;')) 590 | self.assertEqual('foo\\;\\;', _escape_unescaped_semicolons('foo\\;;')) 591 | self.assertEqual('foo\\;\\;', _escape_unescaped_semicolons('foo;;')) 592 | 593 | def test_list_zones(self): 594 | with requests_mock() as mock: 595 | mock.get( 596 | ANY, 597 | status_code=200, 598 | json=[ 599 | {'other': 'stuff', 'name': 'zeta.net.'}, 600 | {'some': 42, 'name': 'alpha.com.'}, 601 | ], 602 | ) 603 | provider = PowerDnsProvider( 604 | 'test', 'non.existent', 'api-key', strict_supports=False 605 | ) 606 | self.assertEqual(['alpha.com.', 'zeta.net.'], provider.list_zones()) 607 | 608 | def test_data_for_DS_compat(self): 609 | provider = PowerDnsProvider('test', 'non.existent', 'api-key') 610 | 611 | rrset = { 612 | 'records': [{'content': 'one two three four'}], 613 | 'ttl': 42, 614 | 'type': 'DS', 615 | } 616 | 617 | # new 618 | value = provider._data_for_DS(rrset)['values'][0] 619 | self.assertEqual( 620 | { 621 | 'algorithm': 'two', 622 | 'digest': 'four', 623 | 'digest_type': 'three', 624 | 'key_tag': 'one', 625 | }, 626 | value, 627 | ) 628 | 629 | def test_records_for_DS_compat(self): 630 | provider = PowerDnsProvider('test', 'non.existent', 'api-key') 631 | 632 | class DummyRecord: 633 | _type = 'DS' 634 | 635 | def __init__(self, value): 636 | self.values = [value] 637 | 638 | class NewFields: 639 | key_tag = 'key_tag' 640 | algorithm = 'algorithm' 641 | digest_type = 'digest_type' 642 | digest = 'digest' 643 | 644 | new_fields = NewFields() 645 | 646 | # new 647 | data = provider._records_for_DS(DummyRecord(new_fields))[0] 648 | self.assertEqual( 649 | [ 650 | { 651 | 'content': 'key_tag algorithm digest_type digest', 652 | 'disabled': False, 653 | } 654 | ], 655 | data, 656 | ) 657 | 658 | 659 | class TestPowerDnsLuaRecord(TestCase): 660 | def test_basics(self): 661 | zone = Zone('unit.tests.', []) 662 | 663 | # no value(s) 664 | with self.assertRaises(ValidationError) as ctx: 665 | Record.new( 666 | zone, 667 | 'lua', 668 | {'type': PowerDnsLuaRecord._type, 'ttl': 42, 'values': []}, 669 | ) 670 | self.assertEqual( 671 | 'at least one value required', ctx.exception.reasons[0] 672 | ) 673 | 674 | # value missing type 675 | with self.assertRaises(ValidationError) as ctx: 676 | Record.new( 677 | zone, 678 | 'lua', 679 | { 680 | 'type': PowerDnsLuaRecord._type, 681 | 'ttl': 42, 682 | 'value': {'script': ''}, 683 | }, 684 | ) 685 | self.assertEqual('missing type', ctx.exception.reasons[0]) 686 | 687 | # value missing script 688 | with self.assertRaises(ValidationError) as ctx: 689 | Record.new( 690 | zone, 691 | 'lua', 692 | { 693 | 'type': PowerDnsLuaRecord._type, 694 | 'ttl': 42, 695 | 'value': {'type': 'A'}, 696 | }, 697 | ) 698 | self.assertEqual('missing script', ctx.exception.reasons[0]) 699 | 700 | # valid record with a single value 701 | lua = Record.new( 702 | zone, 703 | 'lua', 704 | { 705 | 'type': PowerDnsLuaRecord._type, 706 | 'ttl': 42, 707 | 'value': {'script': '1.2.3.4', 'type': 'A'}, 708 | }, 709 | ) 710 | self.assertEqual( 711 | { 712 | 'ttl': 42, 713 | 'value': _PowerDnsLuaValue({'script': '1.2.3.4', 'type': 'A'}), 714 | }, 715 | lua.data, 716 | ) 717 | 718 | # valid record with a multiple values 719 | luas = Record.new( 720 | zone, 721 | 'lua', 722 | { 723 | 'type': PowerDnsLuaRecord._type, 724 | 'ttl': 42, 725 | 'values': [ 726 | _PowerDnsLuaValue({'script': '1.2.3.4', 'type': 'A'}), 727 | _PowerDnsLuaValue({'script': 'fc00::42', 'type': 'AAAA'}), 728 | ], 729 | }, 730 | ) 731 | self.assertEqual( 732 | { 733 | 'ttl': 42, 734 | 'values': [ 735 | _PowerDnsLuaValue({'script': '1.2.3.4', 'type': 'A'}), 736 | _PowerDnsLuaValue({'script': 'fc00::42', 'type': 'AAAA'}), 737 | ], 738 | }, 739 | luas.data, 740 | ) 741 | 742 | # smoke tests 743 | lua.__repr__() 744 | hash(lua.values[0]) 745 | 746 | def test_lua_validate(self): 747 | val = {'type': 'A', 'script': ''} 748 | # single value 749 | self.assertFalse( 750 | _PowerDnsLuaValue.validate(val, PowerDnsLuaRecord._type) 751 | ) 752 | # tuple of values 753 | self.assertFalse( 754 | _PowerDnsLuaValue.validate((val), PowerDnsLuaRecord._type) 755 | ) 756 | # list of values 757 | self.assertFalse( 758 | _PowerDnsLuaValue.validate([val, val], PowerDnsLuaRecord._type) 759 | ) 760 | 761 | # list w/a bad value 762 | got = _PowerDnsLuaValue.validate([val, {}], PowerDnsLuaRecord._type) 763 | self.assertEqual(['missing type', 'missing script'], got) 764 | 765 | def test_encode_zone_name(self): 766 | for expected, value in ( 767 | ('unit.tests.', 'unit.tests.'), 768 | ('another_one.unit.tests.', 'another_one.unit.tests.'), 769 | ('128=2F26.2.0.192.in-addr.arpa.', '128/26.2.0.192.in-addr.arpa.'), 770 | ): 771 | self.assertEqual(expected, _encode_zone_name(value)) 772 | --------------------------------------------------------------------------------