Browse Source

Merge branch 'main' into escaped-semi

pull/1253/head
Ross McFarland 5 months ago
committed by GitHub
parent
commit
6ef0e854e5
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
43 changed files with 956 additions and 69 deletions
  1. +4
    -0
      .changelog/80f748502d3e4258bcbf4a05d5f5bdb0.md
  2. +0
    -4
      .changelog/acc2596fb367494db070e6c06abf705a.md
  3. +4
    -0
      .changelog/c4f025d1c23c40dd98380e6d3496364d.md
  4. +1
    -1
      .git_hooks_pre-commit
  5. +51
    -0
      .github/workflows/geo-data.yml
  6. +6
    -3
      CHANGELOG.md
  7. +24
    -22
      README.md
  8. +59
    -0
      docs/processors.md
  9. +1
    -1
      octodns/__init__.py
  10. +113
    -0
      octodns/processor/templating.py
  11. +7
    -0
      octodns/record/caa.py
  12. +5
    -0
      octodns/record/chunked.py
  13. +7
    -0
      octodns/record/ds.py
  14. +1
    -1
      octodns/record/geo_data.py
  15. +3
    -0
      octodns/record/ip.py
  16. +3
    -0
      octodns/record/loc.py
  17. +7
    -0
      octodns/record/mx.py
  18. +13
    -0
      octodns/record/naptr.py
  19. +7
    -0
      octodns/record/srv.py
  20. +7
    -0
      octodns/record/sshfp.py
  21. +8
    -0
      octodns/record/svcb.py
  22. +10
    -0
      octodns/record/target.py
  23. +9
    -0
      octodns/record/tlsa.py
  24. +8
    -0
      octodns/record/urlfwd.py
  25. +30
    -28
      requirements-dev.txt
  26. +1
    -1
      requirements.txt
  27. +38
    -7
      script/changelog
  28. +4
    -0
      script/release
  29. +12
    -0
      script/update-requirements
  30. +227
    -0
      tests/test_octodns_processor_templating.py
  31. +17
    -0
      tests/test_octodns_record_caa.py
  32. +12
    -0
      tests/test_octodns_record_chunked.py
  33. +27
    -0
      tests/test_octodns_record_ds.py
  34. +8
    -0
      tests/test_octodns_record_ip.py
  35. +23
    -0
      tests/test_octodns_record_loc.py
  36. +13
    -0
      tests/test_octodns_record_mx.py
  37. +33
    -0
      tests/test_octodns_record_naptr.py
  38. +27
    -0
      tests/test_octodns_record_srv.py
  39. +21
    -0
      tests/test_octodns_record_sshfp.py
  40. +15
    -0
      tests/test_octodns_record_svcb.py
  41. +31
    -1
      tests/test_octodns_record_target.py
  42. +29
    -0
      tests/test_octodns_record_tlsa.py
  43. +30
    -0
      tests/test_octodns_record_urlfwd.py

+ 4
- 0
.changelog/80f748502d3e4258bcbf4a05d5f5bdb0.md View File

@ -0,0 +1,4 @@
---
type: none
---
Create dist directory in scripte/release if it doesn't exist

+ 0
- 4
.changelog/acc2596fb367494db070e6c06abf705a.md View File

@ -1,4 +0,0 @@
---
type: none
---
Adding changelog management infra and doc

+ 4
- 0
.changelog/c4f025d1c23c40dd98380e6d3496364d.md View File

@ -0,0 +1,4 @@
---
type: none
---
Documentation for processors

+ 1
- 1
.git_hooks_pre-commit View File

@ -7,7 +7,7 @@ GIT=$(dirname "$HOOKS")
ROOT=$(dirname "$GIT") ROOT=$(dirname "$GIT")
. "$ROOT/env/bin/activate" . "$ROOT/env/bin/activate"
"$ROOT/script/changelog" check
"$ROOT/script/lint" "$ROOT/script/lint"
"$ROOT/script/format" --check --quiet || (echo "Formatting check failed, run ./script/format" && exit 1) "$ROOT/script/format" --check --quiet || (echo "Formatting check failed, run ./script/format" && exit 1)
"$ROOT/script/coverage" "$ROOT/script/coverage"
"$ROOT/script/changelog" check

+ 51
- 0
.github/workflows/geo-data.yml View File

@ -0,0 +1,51 @@
name: Update geo_data.py
on:
workflow_dispatch: # option to run manually if/when needed
schedule:
- cron: "42 3 * * 6" # sat @ 3:42am
jobs:
config:
runs-on: ubuntu-latest
outputs:
json: ${{ steps.load.outputs.json }}
steps:
- uses: actions/checkout@v4
- id: load
# based on https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#multiline-strings
run: |
{
echo 'json<<EOF'
cat ./.ci-config.json
echo EOF
} >> $GITHUB_OUTPUT
update-geo-data:
needs: config
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup python
uses: actions/setup-python@v4
with:
python-version: ${{ fromJson(needs.config.outputs.json).python_version_current }}
architecture: x64
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install virtualenv
- name: Generate geo_data.py
run: |
./script/bootstrap
source env/bin/activate
./script/generate-geo-data > octodns/record/geo_data.py
git diff
[ `git status --porcelain=1 | wc -l` -ne 0 ] && ./script/changelog create -t minor Periodic updates to geo_data.py || true
- name: Create Pull Request
id: cpr
uses: peter-evans/create-pull-request@v7
with:
commit-message: Periodic updates to geo_data.py
branch: update-geo-data
title: Update geo_data.py to reflect recent changes
body: Auto-generated with https://github.com/peter-evans/create-pull-request

+ 6
- 3
CHANGELOG.md View File

@ -1,7 +1,10 @@
## v1.11.? - 2025-??-?? - ???
## 1.12.0 - 2025-06-25 - Automated changelogs
* Correct type-o in name of AcmeManagingProcessor, backwards compatible alias
in place
Minor:
* Templating processor added [#1259](https://github.com/octodns/octodns/pull/1259)
* Update geo-data, Türkiye [#1263](https://github.com/octodns/octodns/pull/1263)
* New provider: Bunny DNS [#1262](https://github.com/octodns/octodns/pull/1262)
* Correct type-o in name of AcmeManagingProcessor, backwards compatible alias in place [#1251](https://github.com/octodns/octodns/pull/1251)
## v1.11.0 - 2025-02-03 - Cleanup & deprecations with meta planning ## v1.11.0 - 2025-02-03 - Cleanup & deprecations with meta planning


+ 24
- 22
README.md View File

@ -153,7 +153,6 @@ zones:
- config - config
targets: targets:
- ns1 - ns1
``` ```
#### General Configuration Concepts #### General Configuration Concepts
@ -269,28 +268,37 @@ It is important to review any `WARNING` log lines printed out during an `octodns
The table below lists the providers octoDNS supports. They are maintained in their own repositories and released as independent modules. The table below lists the providers octoDNS supports. They are maintained in their own repositories and released as independent modules.
| Provider | Module | Notes | | Provider | Module | Notes |
|--|--|--|
| --------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------ |
| /etc/hosts | [octodns_etchosts](https://github.com/octodns/octodns-etchosts/) | |
| [Akamai Edge DNS](https://www.akamai.com/products/edge-dns) | [octodns_edgedns](https://github.com/octodns/octodns-edgedns/) | | | [Akamai Edge DNS](https://www.akamai.com/products/edge-dns) | [octodns_edgedns](https://github.com/octodns/octodns-edgedns/) | |
| [Amazon Route 53](https://aws.amazon.com/route53/) | [octodns_route53](https://github.com/octodns/octodns-route53) | | | [Amazon Route 53](https://aws.amazon.com/route53/) | [octodns_route53](https://github.com/octodns/octodns-route53) | |
| [AutoDNS](https://www.internetx.com/autodns/) | [octodns_autodns](https://github.com/octodns/octodns-autodns) | | | [AutoDNS](https://www.internetx.com/autodns/) | [octodns_autodns](https://github.com/octodns/octodns-autodns) | |
| [Azure DNS](https://azure.microsoft.com/en-us/services/dns/) | [octodns_azure](https://github.com/octodns/octodns-azure/) | | | [Azure DNS](https://azure.microsoft.com/en-us/services/dns/) | [octodns_azure](https://github.com/octodns/octodns-azure/) | |
| [BIND, AXFR, RFC-2136](https://www.isc.org/bind/) | [octodns_bind](https://github.com/octodns/octodns-bind/) | | | [BIND, AXFR, RFC-2136](https://www.isc.org/bind/) | [octodns_bind](https://github.com/octodns/octodns-bind/) | |
| [Bunny DNS](https://bunny.net/dns/) | [octodns_bunny](https://github.com/Relkian/octodns-bunny) | |
| [Cloudflare DNS](https://www.cloudflare.com/dns/) | [octodns_cloudflare](https://github.com/octodns/octodns-cloudflare/) | | | [Cloudflare DNS](https://www.cloudflare.com/dns/) | [octodns_cloudflare](https://github.com/octodns/octodns-cloudflare/) | |
| [ClouDNS](https://www.cloudns.net/) | [octodns_cloudns](https://github.com/ClouDNS/octodns_cloudns) | |
| [Constellix](https://constellix.com/) | [octodns_constellix](https://github.com/octodns/octodns-constellix/) | | | [Constellix](https://constellix.com/) | [octodns_constellix](https://github.com/octodns/octodns-constellix/) | |
| [deSEC](https://desec.io/) | [octodns_desec](https://github.com/rootshell-labs/octodns-desec) | |
| [DigitalOcean](https://docs.digitalocean.com/products/networking/dns/) | [octodns_digitalocean](https://github.com/octodns/octodns-digitalocean/) | | | [DigitalOcean](https://docs.digitalocean.com/products/networking/dns/) | [octodns_digitalocean](https://github.com/octodns/octodns-digitalocean/) | |
| [DNS Made Easy](https://dnsmadeeasy.com/) | [octodns_dnsmadeeasy](https://github.com/octodns/octodns-dnsmadeeasy/) | | | [DNS Made Easy](https://dnsmadeeasy.com/) | [octodns_dnsmadeeasy](https://github.com/octodns/octodns-dnsmadeeasy/) | |
| [DNSimple](https://dnsimple.com/) | [octodns_dnsimple](https://github.com/octodns/octodns-dnsimple/) | | | [DNSimple](https://dnsimple.com/) | [octodns_dnsimple](https://github.com/octodns/octodns-dnsimple/) | |
| [Dyn](https://www.oracle.com/cloud/networking/dns/) ([deprecated](https://www.oracle.com/corporate/acquisitions/dyn/technologies/migrate-your-services/)) | [octodns_dyn](https://github.com/octodns/octodns-dyn/) | | | [Dyn](https://www.oracle.com/cloud/networking/dns/) ([deprecated](https://www.oracle.com/corporate/acquisitions/dyn/technologies/migrate-your-services/)) | [octodns_dyn](https://github.com/octodns/octodns-dyn/) | |
| [easyDNS](https://easydns.com/) | [octodns_easydns](https://github.com/octodns/octodns-easydns/) | | | [easyDNS](https://easydns.com/) | [octodns_easydns](https://github.com/octodns/octodns-easydns/) | |
| [EdgeCenter DNS](https://edgecenter.ru/dns/) | [octodns_edgecenter](https://github.com/octodns/octodns-edgecenter/) | | | [EdgeCenter DNS](https://edgecenter.ru/dns/) | [octodns_edgecenter](https://github.com/octodns/octodns-edgecenter/) | |
| /etc/hosts | [octodns_etchosts](https://github.com/octodns/octodns-etchosts/) | |
| [Gandi](https://www.gandi.net/en-US/domain/dns) | [octodns_gandi](https://github.com/octodns/octodns-gandi/) | |
| [Fastly](https://www.fastly.com/de/) | [Financial-Times/octodns-fastly](https://github.com/Financial-Times/octodns-fastly) | |
| [G-Core Labs DNS](https://gcorelabs.com/dns/) | [octodns_gcore](https://github.com/octodns/octodns-gcore/) | | | [G-Core Labs DNS](https://gcorelabs.com/dns/) | [octodns_gcore](https://github.com/octodns/octodns-gcore/) | |
| [Gandi](https://www.gandi.net/en-US/domain/dns) | [octodns_gandi](https://github.com/octodns/octodns-gandi/) | |
| [Google Cloud DNS](https://cloud.google.com/dns) | [octodns_googlecloud](https://github.com/octodns/octodns-googlecloud/) | | | [Google Cloud DNS](https://cloud.google.com/dns) | [octodns_googlecloud](https://github.com/octodns/octodns-googlecloud/) | |
| [Hetzner DNS](https://www.hetzner.com/dns-console) | [octodns_hetzner](https://github.com/octodns/octodns-hetzner/) | | | [Hetzner DNS](https://www.hetzner.com/dns-console) | [octodns_hetzner](https://github.com/octodns/octodns-hetzner/) | |
| [Infoblox](https://www.infoblox.com/) | [asyncon/octoblox](https://github.com/asyncon/octoblox) | |
| [Infomaniak](https://www.infomaniak.com/) | [octodns_infomaniak](https://github.com/M0NsTeRRR/octodns-infomaniak) | |
| [Lexicon](https://dns-lexicon.github.io/dns-lexicon/#) | [dns-lexicon/dns-lexicon](https://github.com/dns-lexicon/dns-lexicon) | |
| [Mythic Beasts DNS](https://www.mythic-beasts.com/support/hosting/dns) | [octodns_mythicbeasts](https://github.com/octodns/octodns-mythicbeasts/) | | | [Mythic Beasts DNS](https://www.mythic-beasts.com/support/hosting/dns) | [octodns_mythicbeasts](https://github.com/octodns/octodns-mythicbeasts/) | |
| [NetBox-DNS Plugin](https://github.com/peteeckel/netbox-plugin-dns) | [olofvndrhr/octodns-netbox-dns](https://github.com/olofvndrhr/octodns-netbox-dns) | |
| [NS1](https://ns1.com/products/managed-dns) | [octodns_ns1](https://github.com/octodns/octodns-ns1/) | | | [NS1](https://ns1.com/products/managed-dns) | [octodns_ns1](https://github.com/octodns/octodns-ns1/) | |
| [OVHcloud DNS](https://www.ovhcloud.com/en/domains/dns-subdomain/) | [octodns_ovh](https://github.com/octodns/octodns-ovh/) | | | [OVHcloud DNS](https://www.ovhcloud.com/en/domains/dns-subdomain/) | [octodns_ovh](https://github.com/octodns/octodns-ovh/) | |
| [Pi-hole](https://pi-hole.net/) | [jvoss/octodns-pihole](https://github.com/jvoss/octodns-pihole) | |
| [PowerDNS](https://www.powerdns.com/) | [octodns_powerdns](https://github.com/octodns/octodns-powerdns/) | | | [PowerDNS](https://www.powerdns.com/) | [octodns_powerdns](https://github.com/octodns/octodns-powerdns/) | |
| [Rackspace](https://www.rackspace.com/library/what-is-dns) | [octodns_rackspace](https://github.com/octodns/octodns-rackspace/) | | | [Rackspace](https://www.rackspace.com/library/what-is-dns) | [octodns_rackspace](https://github.com/octodns/octodns-rackspace/) | |
| [Scaleway](https://www.scaleway.com/en/dns/) | [octodns_scaleway](https://github.com/scaleway/octodns-scaleway) | | | [Scaleway](https://www.scaleway.com/en/dns/) | [octodns_scaleway](https://github.com/scaleway/octodns-scaleway) | |
@ -299,7 +307,7 @@ The table below lists the providers octoDNS supports. They are maintained in the
| [TransIP](https://www.transip.eu/knowledgebase/entry/155-dns-and-nameservers/) | [octodns_transip](https://github.com/octodns/octodns-transip/) | | | [TransIP](https://www.transip.eu/knowledgebase/entry/155-dns-and-nameservers/) | [octodns_transip](https://github.com/octodns/octodns-transip/) | |
| [UltraDNS](https://vercara.com/authoritative-dns) | [octodns_ultra](https://github.com/octodns/octodns-ultra/) | | | [UltraDNS](https://vercara.com/authoritative-dns) | [octodns_ultra](https://github.com/octodns/octodns-ultra/) | |
| [YamlProvider](/octodns/provider/yaml.py) | built-in | Supports all record types and core functionality | | [YamlProvider](/octodns/provider/yaml.py) | built-in | Supports all record types and core functionality |
| [deSEC](https://desec.io/) | [octodns_desec](https://github.com/rootshell-labs/octodns-desec) | |
| Zonefile | [kompetenzbolzen/octodns-custom-provider](https://github.com/kompetenzbolzen/octodns-custom-provider) | |
### Updating to use extracted providers ### Updating to use extracted providers
@ -313,11 +321,15 @@ The module required and provider class path for extracted providers can be found
Similar to providers, but can only serve to populate records into a zone, cannot be synced to. Similar to providers, but can only serve to populate records into a zone, cannot be synced to.
| Source | Record Support | Dynamic | Notes | | Source | Record Support | Dynamic | Notes |
|--|--|--|--|
| ---------------------------------------------------------------------------- | ---------------------------------------------------- | ------- | ---------------------------------------- |
| [AxfrSource (BIND)](https://github.com/octodns/octodns-bind/) | A, AAAA, CAA, CNAME, LOC, MX, NS, PTR, SPF, SRV, TXT | No | read-only |
| [DDNS Source](https://github.com/octodns/octodns-ddns) | A, AAAA | No | read-only |
| [EnvVarSource](/octodns/source/envvar.py) | TXT | No | read-only environment variable injection | | [EnvVarSource](/octodns/source/envvar.py) | TXT | No | read-only environment variable injection |
| [AxfrSource](https://github.com/octodns/octodns-bind/) | A, AAAA, CAA, CNAME, LOC, MX, NS, PTR, SPF, SRV, TXT | No | read-only |
| [ZoneFileSource](https://github.com/octodns/octodns-bind/) | A, AAAA, CAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | read-only |
| [Lexicon Source](https://github.com/doddo/octodns-lexicon) | A, 'AAA, ALIAS, CAA, CNAME, MX, NS, SRV, TXT | No | read-only |
| [Netbox Source](https://github.com/sukiyaki/octodns-netbox) | A, AAAA, PTR | No | read-only |
| [PHPIPAM source](https://github.com/kompetenzbolzen/octodns-custom-provider) | A, AAAA | No | read-only |
| [TinyDnsFileSource](/octodns/source/tinydns.py) | A, CNAME, MX, NS, PTR | No | read-only | | [TinyDnsFileSource](/octodns/source/tinydns.py) | A, CNAME, MX, NS, PTR | No | read-only |
| [ZoneFileSource](https://github.com/octodns/octodns-bind/) | A, AAAA, CAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | read-only |
### Notes ### Notes
@ -329,7 +341,7 @@ Similar to providers, but can only serve to populate records into a zone, cannot
## Processors ## Processors
| Processor | Description | | Processor | Description |
|--|--|
| --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| [AcmeManagingProcessor](/octodns/processor/acme.py) | Useful when processes external to octoDNS are managing acme challenge DNS records, e.g. LetsEncrypt | | [AcmeManagingProcessor](/octodns/processor/acme.py) | Useful when processes external to octoDNS are managing acme challenge DNS records, e.g. LetsEncrypt |
| [AutoArpa](/octodns/processor/arpa.py) | See [Automatic PTR generation](#automatic-ptr-generation) below | | [AutoArpa](/octodns/processor/arpa.py) | See [Automatic PTR generation](#automatic-ptr-generation) below |
| [EnsureTrailingDots](/octodns/processor/trailing_dots.py) | Processor that ensures ALIAS, CNAME, DNAME, MX, NS, PTR, and SRVs have trailing dots | | [EnsureTrailingDots](/octodns/processor/trailing_dots.py) | Processor that ensures ALIAS, CNAME, DNAME, MX, NS, PTR, and SRVs have trailing dots |
@ -407,7 +419,6 @@ providers:
password: env/DYN_PASSWORD password: env/DYN_PASSWORD
zones: zones:
githubtest.net.: githubtest.net.:
sources: sources:
- route53 - route53
@ -428,11 +439,10 @@ providers:
token: env/GPANEL_SITE_TOKEN token: env/GPANEL_SITE_TOKEN
powerdns-site: powerdns-site:
class: octodns.provider.powerdns.PowerDnsProvider class: octodns.provider.powerdns.PowerDnsProvider
host: 'internal-dns.site.github.foo'
host: "internal-dns.site.github.foo"
api_key: env/POWERDNS_SITE_API_KEY api_key: env/POWERDNS_SITE_API_KEY
zones: zones:
hosts.site.github.foo.: hosts.site.github.foo.:
sources: sources:
- gpanel-site - gpanel-site
@ -453,20 +463,12 @@ If you have a problem or suggestion, please [open an issue](https://github.com/o
- **GitHub Action:** [octoDNS-Sync](https://github.com/marketplace/actions/octodns-sync) - **GitHub Action:** [octoDNS-Sync](https://github.com/marketplace/actions/octodns-sync)
- **NixOS Integration:** [NixOS-DNS](https://github.com/Janik-Haag/nixos-dns/) - **NixOS Integration:** [NixOS-DNS](https://github.com/Janik-Haag/nixos-dns/)
- **Sample Implementations.** See how others are using it - **Sample Implementations.** See how others are using it
- [`hackclub/dns`](https://github.com/hackclub/dns) - [`hackclub/dns`](https://github.com/hackclub/dns)
- [`kubernetes/k8s.io:/dns`](https://github.com/kubernetes/k8s.io/tree/main/dns) - [`kubernetes/k8s.io:/dns`](https://github.com/kubernetes/k8s.io/tree/main/dns)
- [`g0v-network/domains`](https://github.com/g0v-network/domains) - [`g0v-network/domains`](https://github.com/g0v-network/domains)
- [`jekyll/dns`](https://github.com/jekyll/dns) - [`jekyll/dns`](https://github.com/jekyll/dns)
- **Custom Sources & Providers.**
- [`octodns/octodns-ddns`](https://github.com/octodns/octodns-ddns): A simple Dynamic DNS source.
- [`doddo/octodns-lexicon`](https://github.com/doddo/octodns-lexicon): Use [Lexicon](https://github.com/AnalogJ/lexicon) providers as octoDNS providers.
- [`asyncon/octoblox`](https://github.com/asyncon/octoblox): [Infoblox](https://www.infoblox.com/) provider.
- [`sukiyaki/octodns-netbox`](https://github.com/sukiyaki/octodns-netbox): [NetBox](https://github.com/netbox-community/netbox) source.
- [`jcollie/octodns-netbox-dns`](https://github.com/jcollie/octodns-netbox-dns): [NetBox-DNS Plugin](https://github.com/auroraresearchlab/netbox-dns) provider.
- [`kompetenzbolzen/octodns-custom-provider`](https://github.com/kompetenzbolzen/octodns-custom-provider): zonefile provider & phpIPAM source.
- [`Financial-Times/octodns-fastly`](https://github.com/Financial-Times/octodns-fastly): An octoDNS source for Fastly.
- [`jvoss/octodns-pihole`](https://github.com/jvoss/octodns-pihole): [Pi-hole](https://pi-hole.net/) provider.
- [`M0NsTeRRR/octodns-infomaniak`](https://github.com/M0NsTeRRR/octodns-infomaniak): [Infomaniak](https://www.infomaniak.com/) provider.
- **Resources.** - **Resources.**
- Article: [Visualising DNS records with Neo4j](https://medium.com/@costask/querying-and-visualising-octodns-records-with-neo4j-f4f72ab2d474) + code - Article: [Visualising DNS records with Neo4j](https://medium.com/@costask/querying-and-visualising-octodns-records-with-neo4j-f4f72ab2d474) + code
- Video: [FOSDEM 2019 - DNS as code with octodns](https://archive.fosdem.org/2019/schedule/event/dns_octodns/) - Video: [FOSDEM 2019 - DNS as code with octodns](https://archive.fosdem.org/2019/schedule/event/dns_octodns/)


+ 59
- 0
docs/processors.md View File

@ -0,0 +1,59 @@
# octoDNS processors
## Available processors
These are listed in the main [`README`](../README.md#processors)
## Configuring processors
Configuring processors is done in the main config file.
### Defining a processor configuration
This is done under the top-level `processors` key in the octoDNS config file (for example `config.yaml`), as a sibling to the `manager` key.
The `processors` key contains YAML objects, where the key is the name of the processor, and the `class` value within that object refers to the processor name.
For example, to define a provider called `custom_meta` using the [`MetaProcessor`](../octodns/processor/meta.py) in order to extend the default `include_meta` behaviour:
```yaml
manager:
include_meta: false # disable default, basic `meta` records
processors:
custom_meta:
class: octodns.processor.meta.MetaProcessor
record_name: meta
include_time: true
include_uuid: true
include_provider: true
include_version: false
```
**NOTE:** the specific parameters for each processor are only documented within [the code](../octodns/processor/)
### Utilising the processor configuration
#### On **individual** domains
Each domain can utilise the processor independently by adding the name of the defined processor to a `processors` key beneath a `zone`:
```yaml
zones:
example.com.:
source:
- yaml_config
target:
- hetzner
processors:
- custom_meta
```
#### On **all** domains
To utilise the processor on **all** domains automatically, including new domains added to the `zones` config in future then you can add this to the `processors` key under the `manager` section of the configuration:
```yaml
manager:
processors:
- custom_meta
```

+ 1
- 1
octodns/__init__.py View File

@ -1,4 +1,4 @@
'OctoDNS: DNS as code - Tools for managing DNS across multiple providers' 'OctoDNS: DNS as code - Tools for managing DNS across multiple providers'
# TODO: remove __VERSION__ w/2.x # TODO: remove __VERSION__ w/2.x
__version__ = __VERSION__ = '1.11.0'
__version__ = __VERSION__ = '1.12.0'

+ 113
- 0
octodns/processor/templating.py View File

@ -0,0 +1,113 @@
#
#
#
from octodns.processor.base import BaseProcessor
class Templating(BaseProcessor):
'''
Record templating using python format. For simple records like TXT and CAA
that is the value itself. For multi-field records like MX or SRV it's the
text portions, exchange and target respectively.
Example Processor Config:
templating:
class: octodns.processor.templating.Templating
# Any k/v present in context will be passed into the .format method and
# thus be available as additional variables in the template. This is all
# optional.
context:
key: value
another: 42
Example Records:
foo:
type: TXT
value: The zone this record lives in is {zone_name}. There are {zone_num_records} record(s).
bar:
type: MX
values:
- preference: 1
exchange: mx1.{zone_name}.mail.mx.
- preference: 1
exchange: mx2.{zone_name}.mail.mx.
Note that validations for some types reject values with {}. When
encountered the best option is to use record level `lenient: true`
https://github.com/octodns/octodns/blob/main/docs/records.md#lenience
Note that if you need to add dynamic context you can create a custom
processor that inherits from Templating and passes them into the call to
super, e.g.
class MyTemplating(Templating):
def __init__(self, *args, context={}, **kwargs):
context['year'] = lambda desired, sources: datetime.now().strftime('%Y')
super().__init__(*args, context, **kwargs)
See https://docs.python.org/3/library/string.html#custom-string-formatting
for details on formatting options. Anything possible in an `f-string` or
`.format` should work here.
'''
def __init__(self, id, *args, context={}, **kwargs):
super().__init__(id, *args, **kwargs)
self.context = context
def process_source_zone(self, desired, sources):
sources = sources or []
zone_params = {
'zone_name': desired.decoded_name,
'zone_decoded_name': desired.decoded_name,
'zone_encoded_name': desired.name,
'zone_num_records': len(desired.records),
'zone_source_ids': ', '.join(s.id for s in sources),
# add any extra context provided to us, if the value is a callable
# object call it passing our params so that arbitrary dynamic
# context can be added for use in formatting
**{
k: (v(desired, sources) if callable(v) else v)
for k, v in self.context.items()
},
}
def params(record):
return {
'record_name': record.decoded_name,
'record_decoded_name': record.decoded_name,
'record_encoded_name': record.name,
'record_fqdn': record.decoded_fqdn,
'record_decoded_fqdn': record.decoded_fqdn,
'record_encoded_fqdn': record.fqdn,
'record_type': record._type,
'record_ttl': record.ttl,
'record_source_id': record.source.id if record.source else None,
**zone_params,
}
for record in desired.records:
if hasattr(record, 'values'):
if record.values and not hasattr(record.values[0], 'template'):
# the (custom) value type does not support templating
continue
new_values = [v.template(params(record)) for v in record.values]
if record.values != new_values:
new = record.copy()
new.values = new_values
desired.add_record(new, replace=True)
else:
if not hasattr(record.value, 'template'):
# the (custom) value type does not support templating
continue
new_value = record.value.template(params(record))
if record.value != new_value:
new = record.copy()
new.value = new_value
desired.add_record(new, replace=True)
return desired

+ 7
- 0
octodns/record/caa.py View File

@ -87,6 +87,13 @@ class CaaValue(EqualityTupleMixin, dict):
def rdata_text(self): def rdata_text(self):
return f'{self.flags} {self.tag} {self.value}' return f'{self.flags} {self.tag} {self.value}'
def template(self, params):
if '{' not in self.value:
return self
new = self.__class__(self)
new.value = new.value.format(**params)
return new
def _equality_tuple(self): def _equality_tuple(self):
return (self.flags, self.tag, self.value) return (self.flags, self.tag, self.value)


+ 5
- 0
octodns/record/chunked.py View File

@ -82,3 +82,8 @@ class _ChunkedValue(str):
@property @property
def rdata_text(self): def rdata_text(self):
return self return self
def template(self, params):
if '{' not in self:
return self
return self.__class__(self.format(**params))

+ 7
- 0
octodns/record/ds.py View File

@ -164,6 +164,13 @@ class DsValue(EqualityTupleMixin, dict):
f'{self.key_tag} {self.algorithm} {self.digest_type} {self.digest}' f'{self.key_tag} {self.algorithm} {self.digest_type} {self.digest}'
) )
def template(self, params):
if '{' not in self.digest:
return self
new = self.__class__(self)
new.digest = new.digest.format(**params)
return new
def _equality_tuple(self): def _equality_tuple(self):
return (self.key_tag, self.algorithm, self.digest_type, self.digest) return (self.key_tag, self.algorithm, self.digest_type, self.digest)


+ 1
- 1
octodns/record/geo_data.py View File

@ -123,7 +123,7 @@ geo_data = {
'TJ': {'name': 'Tajikistan'}, 'TJ': {'name': 'Tajikistan'},
'TL': {'name': 'Timor-Leste'}, 'TL': {'name': 'Timor-Leste'},
'TM': {'name': 'Turkmenistan'}, 'TM': {'name': 'Turkmenistan'},
'TR': {'name': 'Turkey'},
'TR': {'name': 'Türkiye'},
'TW': {'name': 'Taiwan, Province of China'}, 'TW': {'name': 'Taiwan, Province of China'},
'UZ': {'name': 'Uzbekistan'}, 'UZ': {'name': 'Uzbekistan'},
'VN': {'name': 'Viet Nam'}, 'VN': {'name': 'Viet Nam'},


+ 3
- 0
octodns/record/ip.py View File

@ -45,5 +45,8 @@ class _IpValue(str):
def rdata_text(self): def rdata_text(self):
return self return self
def template(self, params):
return self
_IpAddress = _IpValue _IpAddress = _IpValue

+ 3
- 0
octodns/record/loc.py View File

@ -305,6 +305,9 @@ class LocValue(EqualityTupleMixin, dict):
def rdata_text(self): def rdata_text(self):
return f'{self.lat_degrees} {self.lat_minutes} {self.lat_seconds} {self.lat_direction} {self.long_degrees} {self.long_minutes} {self.long_seconds} {self.long_direction} {self.altitude}m {self.size}m {self.precision_horz}m {self.precision_vert}m' return f'{self.lat_degrees} {self.lat_minutes} {self.lat_seconds} {self.lat_direction} {self.long_degrees} {self.long_minutes} {self.long_seconds} {self.long_direction} {self.altitude}m {self.size}m {self.precision_horz}m {self.precision_vert}m'
def template(self, params):
return self
def __hash__(self): def __hash__(self):
return hash( return hash(
( (


+ 7
- 0
octodns/record/mx.py View File

@ -101,6 +101,13 @@ class MxValue(EqualityTupleMixin, dict):
def rdata_text(self): def rdata_text(self):
return f'{self.preference} {self.exchange}' return f'{self.preference} {self.exchange}'
def template(self, params):
if '{' not in self.exchange:
return self
new = self.__class__(self)
new.exchange = new.exchange.format(**params)
return new
def __hash__(self): def __hash__(self):
return hash((self.preference, self.exchange)) return hash((self.preference, self.exchange))


+ 13
- 0
octodns/record/naptr.py View File

@ -138,6 +138,19 @@ class NaptrValue(EqualityTupleMixin, dict):
def rdata_text(self): def rdata_text(self):
return f'{self.order} {self.preference} {self.flags} {self.service} {self.regexp} {self.replacement}' return f'{self.order} {self.preference} {self.flags} {self.service} {self.regexp} {self.replacement}'
def template(self, params):
if (
'{' not in self.service
and '{' not in self.regexp
and '{' not in self.replacement
):
return self
new = self.__class__(self)
new.service = new.service.format(**params)
new.regexp = new.regexp.format(**params)
new.replacement = new.replacement.format(**params)
return new
def __hash__(self): def __hash__(self):
return hash(self.__repr__()) return hash(self.__repr__())


+ 7
- 0
octodns/record/srv.py View File

@ -135,6 +135,13 @@ class SrvValue(EqualityTupleMixin, dict):
def rdata_text(self): def rdata_text(self):
return f"{self.priority} {self.weight} {self.port} {self.target}" return f"{self.priority} {self.weight} {self.port} {self.target}"
def template(self, params):
if '{' not in self.target:
return self
new = self.__class__(self)
new.target = new.target.format(**params)
return new
def __hash__(self): def __hash__(self):
return hash(self.__repr__()) return hash(self.__repr__())


+ 7
- 0
octodns/record/sshfp.py View File

@ -105,6 +105,13 @@ class SshfpValue(EqualityTupleMixin, dict):
def rdata_text(self): def rdata_text(self):
return f'{self.algorithm} {self.fingerprint_type} {self.fingerprint}' return f'{self.algorithm} {self.fingerprint_type} {self.fingerprint}'
def template(self, params):
if '{' not in self.fingerprint:
return self
new = self.__class__(self)
new.fingerprint = new.fingerprint.format(**params)
return new
def __hash__(self): def __hash__(self):
return hash(self.__repr__()) return hash(self.__repr__())


+ 8
- 0
octodns/record/svcb.py View File

@ -287,6 +287,14 @@ class SvcbValue(EqualityTupleMixin, dict):
params += f'={svcparamvalue}' params += f'={svcparamvalue}'
return f'{self.svcpriority} {self.targetname}{params}' return f'{self.svcpriority} {self.targetname}{params}'
def template(self, params):
if '{' not in self.targetname:
return self
new = self.__class__(self)
new.targetname = new.targetname.format(**params)
# TODO: what, if any of the svcparams should be templated
return new
def __hash__(self): def __hash__(self):
return hash(self.__repr__()) return hash(self.__repr__())


+ 10
- 0
octodns/record/target.py View File

@ -41,6 +41,11 @@ class _TargetValue(str):
def rdata_text(self): def rdata_text(self):
return self return self
def template(self, params):
if '{' not in self:
return self
return self.__class__(self.format(**params))
# #
# much like _TargetValue, but geared towards multiple values # much like _TargetValue, but geared towards multiple values
@ -75,3 +80,8 @@ class _TargetsValue(str):
@property @property
def rdata_text(self): def rdata_text(self):
return self return self
def template(self, params):
if '{' not in self:
return self
return self.__class__(self.format(**params))

+ 9
- 0
octodns/record/tlsa.py View File

@ -136,6 +136,15 @@ class TlsaValue(EqualityTupleMixin, dict):
def rdata_text(self): def rdata_text(self):
return f'{self.certificate_usage} {self.selector} {self.matching_type} {self.certificate_association_data}' return f'{self.certificate_usage} {self.selector} {self.matching_type} {self.certificate_association_data}'
def template(self, params):
if '{' not in self.certificate_association_data:
return self
new = self.__class__(self)
new.certificate_association_data = (
new.certificate_association_data.format(**params)
)
return new
def _equality_tuple(self): def _equality_tuple(self):
return ( return (
self.certificate_usage, self.certificate_usage,


+ 8
- 0
octodns/record/urlfwd.py View File

@ -132,6 +132,14 @@ class UrlfwdValue(EqualityTupleMixin, dict):
def rdata_text(self): def rdata_text(self):
return f'"{self.path}" "{self.target}" {self.code} {self.masking} {self.query}' return f'"{self.path}" "{self.target}" {self.code} {self.masking} {self.query}'
def template(self, params):
if '{' not in self.path and '{' not in self.target:
return self
new = self.__class__(self)
new.path = new.path.format(**params)
new.target = new.target.format(**params)
return new
def _equality_tuple(self): def _equality_tuple(self):
return (self.path, self.target, self.code, self.masking, self.query) return (self.path, self.target, self.code, self.masking, self.query)


+ 30
- 28
requirements-dev.txt View File

@ -1,48 +1,50 @@
# DO NOT EDIT THIS FILE DIRECTLY - use ./script/update-requirements to update # DO NOT EDIT THIS FILE DIRECTLY - use ./script/update-requirements to update
Pygments==2.18.0
Pygments==2.19.2
SecretStorage==3.3.3
black==24.10.0 black==24.10.0
build==1.2.2.post1 build==1.2.2.post1
certifi==2024.8.30
certifi==2025.6.15
cffi==1.17.1 cffi==1.17.1
charset-normalizer==3.3.2
click==8.1.7
cmarkgfm==2024.1.14
coverage==7.6.1
charset-normalizer==3.4.2
click==8.1.8; python_version<'3.10'
click==8.2.1; python_version>='3.10'
cmarkgfm==2024.11.20
coverage==7.9.1
cryptography==45.0.4
docutils==0.21.2 docutils==0.21.2
importlib_metadata==8.5.0
iniconfig==2.0.0
isort==5.13.2
id==1.5.0
iniconfig==2.1.0
isort==6.0.1
jaraco.classes==3.4.0 jaraco.classes==3.4.0
jaraco.context==6.0.1 jaraco.context==6.0.1
jaraco.functools==4.1.0
keyring==25.4.1
jaraco.functools==4.2.1
jeepney==0.9.0
keyring==25.6.0
markdown-it-py==3.0.0 markdown-it-py==3.0.0
mdurl==0.1.2 mdurl==0.1.2
more-itertools==10.5.0
mypy-extensions==1.0.0
nh3==0.2.18
packaging==24.1
more-itertools==10.7.0
mypy_extensions==1.1.0
nh3==0.2.21
packaging==25.0
pathspec==0.12.1 pathspec==0.12.1
pkginfo==1.10.0
platformdirs==4.3.6
pluggy==1.5.0
platformdirs==4.3.8
pluggy==1.6.0
pprintpp==0.4.0 pprintpp==0.4.0
pycountry-convert==0.7.2 pycountry-convert==0.7.2
pycountry==24.6.1 pycountry==24.6.1
pycparser==2.22 pycparser==2.22
pyflakes==3.2.0
pyflakes==3.4.0
pyproject_hooks==1.2.0 pyproject_hooks==1.2.0
pytest-cov==5.0.0
pytest-mock==3.14.0
pytest==8.3.3
pytest-cov==6.2.1
pytest-mock==3.14.1
pytest==8.4.1
pytest_network==0.0.1 pytest_network==0.0.1
readme_renderer==44.0 readme_renderer==44.0
repoze.lru==0.7 repoze.lru==0.7
requests-toolbelt==1.0.0 requests-toolbelt==1.0.0
requests==2.32.3
requests==2.32.4
rfc3986==2.0.0 rfc3986==2.0.0
rich==13.9.2
twine==5.1.1
urllib3==2.2.3
wheel==0.44.0
zipp==3.20.2
rich==14.0.0
twine==6.1.0
urllib3==2.5.0
wheel==0.45.1

+ 1
- 1
requirements.txt View File

@ -5,4 +5,4 @@ fqdn==1.5.1
idna==3.10 idna==3.10
natsort==8.4.0 natsort==8.4.0
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0
six==1.16.0
six==1.17.0

+ 38
- 7
script/changelog View File

@ -11,7 +11,7 @@ from subprocess import PIPE, run
from sys import argv, exit, path, stderr from sys import argv, exit, path, stderr
from uuid import uuid4 from uuid import uuid4
from yaml import safe_load_all
from yaml import safe_load
def create(argv): def create(argv):
@ -39,6 +39,18 @@ def create(argv):
See https://semver.org/ for more info''', See https://semver.org/ for more info''',
) )
parser.add_argument(
'-p',
'--pr',
help='Manually override the PR number for the change, maintainer use only.',
)
parser.add_argument(
'-a',
'--add',
action='store_true',
default=False,
help='git add the newly created changelog entry',
)
parser.add_argument( parser.add_argument(
'md', 'md',
metavar='change-description-markdown', metavar='change-description-markdown',
@ -56,6 +68,9 @@ and links.''',
with open(filepath, 'w') as fh: with open(filepath, 'w') as fh:
fh.write('---\ntype: ') fh.write('---\ntype: ')
fh.write(args.type) fh.write(args.type)
if args.pr:
fh.write('\npr: ')
fh.write(args.pr)
fh.write('\n---\n') fh.write('\n---\n')
fh.write(' '.join(args.md)) fh.write(' '.join(args.md))
@ -63,6 +78,9 @@ and links.''',
f'Created {filepath}, it can be further edited and should be committed to your branch.' f'Created {filepath}, it can be further edited and should be committed to your branch.'
) )
if args.add:
run(['git', 'add', filepath])
def check(argv): def check(argv):
if isdir('.changelog'): if isdir('.changelog'):
@ -97,7 +115,9 @@ class _ChangeMeta:
_pr_cache = None _pr_cache = None
@classmethod @classmethod
def get(cls, filepath):
def get(cls, filepath, data):
if 'pr' in data:
return data['pr'], datetime(year=1970, month=1, day=1)
if cls._pr_cache is None: if cls._pr_cache is None:
result = run( result = run(
[ [
@ -130,6 +150,7 @@ class _ChangeMeta:
try: try:
return cls._pr_cache[filepath] return cls._pr_cache[filepath]
except KeyError: except KeyError:
# couldn't find a PR with the changelog file in it
return None, datetime(year=1970, month=1, day=1) return None, datetime(year=1970, month=1, day=1)
@ -141,8 +162,12 @@ def _get_changelogs():
continue continue
filepath = join(dirname, filename) filepath = join(dirname, filename)
with open(filepath) as fh: with open(filepath) as fh:
data, md = safe_load_all(fh)
pr, time = _ChangeMeta.get(filepath)
pieces = fh.read().split('---\n')
data = safe_load(pieces[1])
md = pieces[2]
if md[-1] == '\n':
md = md[:-1]
pr, time = _ChangeMeta.get(filepath, data)
if not pr: if not pr:
continue continue
ret.append( ret.append(
@ -155,8 +180,8 @@ def _get_changelogs():
} }
) )
ordering = {'major': 0, 'minor': 1, 'patch': 2, 'none': 3, '': 3}
ret.sort(key=lambda c: (ordering[c['type']], c['time']))
ordering = {'major': 3, 'minor': 2, 'patch': 1, 'none': 0, '': 0}
ret.sort(key=lambda c: (ordering[c['type']], c['time']), reverse=True)
return ret return ret
@ -195,6 +220,9 @@ def bump(argv):
action='store_true', action='store_true',
help='Write changelog update and bump version number', help='Write changelog update and bump version number',
) )
parser.add_argument(
'title', nargs='+', help='A short title/quip for the release title'
)
args = parser.parse_args(argv) args = parser.parse_args(argv)
@ -215,7 +243,7 @@ def bump(argv):
buf.write(' - ') buf.write(' - ')
buf.write(datetime.now().strftime('%Y-%m-%d')) buf.write(datetime.now().strftime('%Y-%m-%d'))
buf.write(' - ') buf.write(' - ')
buf.write(' '.join(argv[1:]))
buf.write(' '.join(args.title))
buf.write('\n') buf.write('\n')
current_type = None current_type = None
@ -225,6 +253,9 @@ def bump(argv):
continue continue
_type = changelog['type'] _type = changelog['type']
if _type == 'none':
# these aren't included in the listing
continue
if _type != current_type: if _type != current_type:
buf.write('\n') buf.write('\n')
buf.write(_type.capitalize()) buf.write(_type.capitalize())


+ 4
- 0
script/release View File

@ -44,6 +44,10 @@ echo "Created clean room $TMP_DIR and archived $VERSION into it"
(cd "$TMP_DIR" && python -m build --sdist --wheel) (cd "$TMP_DIR" && python -m build --sdist --wheel)
if [ ! -d dist ]; then
mkdir dist/
fi
cp $TMP_DIR/dist/*$VERSION.tar.gz $TMP_DIR/dist/*$VERSION*.whl dist/ cp $TMP_DIR/dist/*$VERSION.tar.gz $TMP_DIR/dist/*$VERSION*.whl dist/
echo "Copied $TMP_DIR/dists into ./dist" echo "Copied $TMP_DIR/dists into ./dist"


+ 12
- 0
script/update-requirements View File

@ -42,6 +42,18 @@ dev_frozen = sorted(
[p for p in dev_frozen if not p.startswith(our_package_name)] [p for p in dev_frozen if not p.startswith(our_package_name)]
) )
# special handling for click until python 3.9 is gone due to it dropping
# support for active versions early
i = [i for i, r in enumerate(dev_frozen) if r.startswith('click==')][0]
dev_frozen = (
dev_frozen[:i]
+ [
"click==8.1.8; python_version<'3.10'",
f"{dev_frozen[i]}; python_version>='3.10'",
]
+ dev_frozen[i + 1 :]
)
print_packages(frozen, 'frozen') print_packages(frozen, 'frozen')
print_packages(dev_frozen, 'dev_frozen') print_packages(dev_frozen, 'dev_frozen')


+ 227
- 0
tests/test_octodns_processor_templating.py View File

@ -0,0 +1,227 @@
#
#
#
from unittest import TestCase
from unittest.mock import call, patch
from octodns.processor.templating import Templating
from octodns.record import Record, ValueMixin, ValuesMixin
from octodns.zone import Zone
class DummySource:
def __init__(self, id):
self.id = str(id)
class CustomValue(str):
@classmethod
def validate(cls, *args, **kwargs):
return []
@classmethod
def process(cls, v):
if isinstance(v, (list, tuple)):
return (CustomValue(i) for i in v)
return CustomValue(v)
@classmethod
def parse_rdata_text(cls, *args, **kwargs):
pass
def __init__(self, *args, **kwargs):
self._asked_for = set()
def rdata_text(self):
pass
def __getattr__(self, item):
self._asked_for.add(item)
raise AttributeError('nope')
class Single(ValueMixin, Record):
_type = 'S'
_value_type = CustomValue
Record.register_type(Single, 'S')
class Multiple(ValuesMixin, Record):
_type = 'M'
_value_type = CustomValue
Record.register_type(Multiple, 'M')
def _find(zone, name):
return next(r for r in zone.records if r.name == name)
class TemplatingTest(TestCase):
def test_cname(self):
templ = Templating('test')
zone = Zone('unit.tests.', [])
cname = Record.new(
zone,
'cname',
{
'type': 'CNAME',
'ttl': 42,
'value': '_cname.{zone_name}something.else.',
},
lenient=True,
)
zone.add_record(cname)
noop = Record.new(
zone,
'noop',
{
'type': 'CNAME',
'ttl': 42,
'value': '_noop.nothing_to_do.something.else.',
},
lenient=True,
)
zone.add_record(noop)
got = templ.process_source_zone(zone, None)
cname = _find(got, 'cname')
self.assertEqual('_cname.unit.tests.something.else.', cname.value)
noop = _find(got, 'noop')
self.assertEqual('_noop.nothing_to_do.something.else.', noop.value)
def test_txt(self):
templ = Templating('test')
zone = Zone('unit.tests.', [])
txt = Record.new(
zone,
'txt',
{
'type': 'TXT',
'ttl': 42,
'value': 'There are {zone_num_records} record(s) in {zone_name}',
},
)
zone.add_record(txt)
noop = Record.new(
zone,
'noop',
{'type': 'TXT', 'ttl': 43, 'value': 'Nothing to template here.'},
)
zone.add_record(noop)
got = templ.process_source_zone(zone, None)
txt = _find(got, 'txt')
self.assertEqual('There are 2 record(s) in unit.tests.', txt.values[0])
noop = _find(got, 'noop')
self.assertEqual('Nothing to template here.', noop.values[0])
def test_no_template(self):
templ = Templating('test')
zone = Zone('unit.tests.', [])
s = Record.new(zone, 's', {'type': 'S', 'ttl': 42, 'value': 'string'})
zone.add_record(s)
m = Record.new(
zone, 'm', {'type': 'M', 'ttl': 43, 'values': ('string', 'another')}
)
zone.add_record(m)
# this should check for the template method on our values that don't
# have one
templ.process_source_zone(zone, None)
# and these should make sure that the value types were asked if they
# have a template method
self.assertEqual({'template'}, s.value._asked_for)
self.assertEqual({'template'}, m.values[0]._asked_for)
@patch('octodns.record.TxtValue.template')
def test_params(self, mock_template):
templ = Templating('test')
zone = Zone('unit.tests.', [])
record_source = DummySource('record')
txt = Record.new(
zone,
'txt',
{
'type': 'TXT',
'ttl': 42,
'value': 'There are {zone_num_records} record(s) in {zone_name}',
},
source=record_source,
)
zone.add_record(txt)
templ.process_source_zone(
zone, sources=[record_source, DummySource('other')]
)
mock_template.assert_called_once()
self.assertEqual(
call(
{
'record_name': 'txt',
'record_decoded_name': 'txt',
'record_encoded_name': 'txt',
'record_fqdn': 'txt.unit.tests.',
'record_decoded_fqdn': 'txt.unit.tests.',
'record_encoded_fqdn': 'txt.unit.tests.',
'record_type': 'TXT',
'record_ttl': 42,
'record_source_id': 'record',
'zone_name': 'unit.tests.',
'zone_decoded_name': 'unit.tests.',
'zone_encoded_name': 'unit.tests.',
'zone_num_records': 1,
'zone_source_ids': 'record, other',
}
),
mock_template.call_args,
)
def test_context(self):
templ = Templating(
'test',
context={
# static
'the_answer': 42,
# dynamic
'the_date': lambda _, __: 'today',
# uses a param
'num_sources': lambda z, ss: len(ss),
},
)
zone = Zone('unit.tests.', [])
txt = Record.new(
zone,
'txt',
{
'type': 'TXT',
'ttl': 42,
'values': (
'the_answer: {the_answer}',
'the_date: {the_date}',
'num_sources: {num_sources}',
),
},
)
zone.add_record(txt)
got = templ.process_source_zone(
zone, tuple(DummySource(i) for i in range(3))
)
txt = _find(got, 'txt')
self.assertEqual(3, len(txt.values))
self.assertEqual('num_sources: 3', txt.values[0])
self.assertEqual('the_answer: 42', txt.values[1])
self.assertEqual('the_date: today', txt.values[2])

+ 17
- 0
tests/test_octodns_record_caa.py View File

@ -285,3 +285,20 @@ class TestRecordCaa(TestCase):
{'type': 'CAA', 'ttl': 600, 'value': {'tag': 'iodef'}}, {'type': 'CAA', 'ttl': 600, 'value': {'tag': 'iodef'}},
) )
self.assertEqual(['missing value'], ctx.exception.reasons) self.assertEqual(['missing value'], ctx.exception.reasons)
class TestCaaValue(TestCase):
def test_template(self):
value = CaaValue(
{'flags': 0, 'tag': 'issue', 'value': 'ca.example.net'}
)
got = value.template({'needle': 42})
self.assertIs(value, got)
value = CaaValue(
{'flags': 0, 'tag': 'issue', 'value': 'ca.{needle}.net'}
)
got = value.template({'needle': 42})
self.assertIsNot(value, got)
self.assertEqual('ca.42.net', got.value)

+ 12
- 0
tests/test_octodns_record_chunked.py View File

@ -121,3 +121,15 @@ class TestChunkedValue(TestCase):
sc = self.SmallerChunkedMixin(['0123456789']) sc = self.SmallerChunkedMixin(['0123456789'])
self.assertEqual(['"01234567" "89"'], sc.chunked_values) self.assertEqual(['"01234567" "89"'], sc.chunked_values)
def test_template(self):
s = 'this.has.no.templating.'
value = _ChunkedValue(s)
got = value.template({'needle': 42})
self.assertIs(value, got)
s = 'this.does.{needle}.have.templating.'
value = _ChunkedValue(s)
got = value.template({'needle': 42})
self.assertIsNot(value, got)
self.assertEqual('this.does.42.have.templating.', got)

+ 27
- 0
tests/test_octodns_record_ds.py View File

@ -259,3 +259,30 @@ class TestRecordDs(TestCase):
self.assertEqual(DsValue(values[1]), a.values[1].data) self.assertEqual(DsValue(values[1]), a.values[1].data)
self.assertEqual('1 2 3 99148c44', a.values[1].rdata_text) self.assertEqual('1 2 3 99148c44', a.values[1].rdata_text)
self.assertEqual('1 2 3 99148c44', a.values[1].__repr__()) self.assertEqual('1 2 3 99148c44', a.values[1].__repr__())
class TestDsValue(TestCase):
def test_template(self):
value = DsValue(
{
'key_tag': 0,
'algorithm': 1,
'digest_type': 2,
'digest': 'abcdef0123456',
}
)
got = value.template({'needle': 42})
self.assertIs(value, got)
value = DsValue(
{
'key_tag': 0,
'algorithm': 1,
'digest_type': 2,
'digest': 'abcd{needle}ef0123456',
}
)
got = value.template({'needle': 42})
self.assertIsNot(value, got)
self.assertEqual('abcd42ef0123456', got.digest)

+ 8
- 0
tests/test_octodns_record_ip.py View File

@ -27,3 +27,11 @@ class TestRecordIp(TestCase):
zone = Zone('unit.tests.', []) zone = Zone('unit.tests.', [])
a = ARecord(zone, 'a', {'ttl': 42, 'value': '1.2.3.4'}) a = ARecord(zone, 'a', {'ttl': 42, 'value': '1.2.3.4'})
self.assertEqual('1.2.3.4', a.values[0].rdata_text) self.assertEqual('1.2.3.4', a.values[0].rdata_text)
class TestIpValue(TestCase):
def test_template(self):
value = Ipv4Value('1.2.3.4')
# template is a noop
self.assertIs(value, value.template({'needle': 42}))

+ 23
- 0
tests/test_octodns_record_loc.py View File

@ -715,3 +715,26 @@ class TestRecordLoc(TestCase):
self.assertEqual( self.assertEqual(
['invalid value for size "99999999.99"'], ctx.exception.reasons ['invalid value for size "99999999.99"'], ctx.exception.reasons
) )
class TestLocValue(TestCase):
def test_template(self):
value = LocValue(
{
'lat_degrees': 31,
'lat_minutes': 58,
'lat_seconds': 52.1,
'lat_direction': 'S',
'long_degrees': 115,
'long_minutes': 49,
'long_seconds': 11.7,
'long_direction': 'E',
'altitude': 20,
'size': 10,
'precision_horz': 10,
'precision_vert': 2,
}
)
# loc value template is a noop
self.assertIs(value, value.template({}))

+ 13
- 0
tests/test_octodns_record_mx.py View File

@ -268,3 +268,16 @@ class TestRecordMx(TestCase):
}, },
) )
self.assertEqual('.', record.values[0].exchange) self.assertEqual('.', record.values[0].exchange)
class TestMxValue(TestCase):
def test_template(self):
value = MxValue({'preference': 10, 'exchange': 'smtp1.'})
got = value.template({'needle': 42})
self.assertIs(value, got)
value = MxValue({'preference': 10, 'exchange': 'smtp1.{needle}.'})
got = value.template({'needle': 42})
self.assertIsNot(value, got)
self.assertEqual('smtp1.42.', got.exchange)

+ 33
- 0
tests/test_octodns_record_naptr.py View File

@ -449,3 +449,36 @@ class TestRecordNaptr(TestCase):
with self.assertRaises(ValidationError) as ctx: with self.assertRaises(ValidationError) as ctx:
Record.new(self.zone, '', {'type': 'NAPTR', 'ttl': 600, 'value': v}) Record.new(self.zone, '', {'type': 'NAPTR', 'ttl': 600, 'value': v})
self.assertEqual(['unrecognized flags "X"'], ctx.exception.reasons) self.assertEqual(['unrecognized flags "X"'], ctx.exception.reasons)
class TestNaptrValue(TestCase):
def test_template(self):
value = NaptrValue(
{
'order': 10,
'preference': 11,
'flags': 'X',
'service': 'Y',
'regexp': 'Z',
'replacement': '.',
}
)
got = value.template({'needle': 42})
self.assertIs(value, got)
value = NaptrValue(
{
'order': 10,
'preference': 11,
'flags': 'X',
'service': 'Y{needle}',
'regexp': 'Z{needle}',
'replacement': '.{needle}',
}
)
got = value.template({'needle': 42})
self.assertIsNot(value, got)
self.assertEqual('Y42', got.service)
self.assertEqual('Z42', got.regexp)
self.assertEqual('.42', got.replacement)

+ 27
- 0
tests/test_octodns_record_srv.py View File

@ -450,3 +450,30 @@ class TestRecordSrv(TestCase):
['Invalid SRV target "100 foo.bar.com." is not a valid FQDN.'], ['Invalid SRV target "100 foo.bar.com." is not a valid FQDN.'],
ctx.exception.reasons, ctx.exception.reasons,
) )
class TestSrvValue(TestCase):
def test_template(self):
value = SrvValue(
{
'priority': 10,
'weight': 11,
'port': 12,
'target': 'no_placeholders',
}
)
got = value.template({'needle': 42})
self.assertIs(value, got)
value = SrvValue(
{
'priority': 10,
'weight': 11,
'port': 12,
'target': 'has_{needle}_placeholder',
}
)
got = value.template({'needle': 42})
self.assertIsNot(value, got)
self.assertEqual('has_42_placeholder', got.target)

+ 21
- 0
tests/test_octodns_record_sshfp.py View File

@ -333,3 +333,24 @@ class TestRecordSshfp(TestCase):
}, },
) )
self.assertEqual(['missing fingerprint'], ctx.exception.reasons) self.assertEqual(['missing fingerprint'], ctx.exception.reasons)
class TestSshFpValue(TestCase):
def test_template(self):
value = SshfpValue(
{'algorithm': 10, 'fingerprint_type': 11, 'fingerprint': 'abc123'}
)
got = value.template({'needle': 42})
self.assertIs(value, got)
value = SshfpValue(
{
'algorithm': 10,
'fingerprint_type': 11,
'fingerprint': 'ab{needle}c123',
}
)
got = value.template({'needle': 42})
self.assertIsNot(value, got)
self.assertEqual('ab42c123', got.fingerprint)

+ 15
- 0
tests/test_octodns_record_svcb.py View File

@ -673,3 +673,18 @@ class TestRecordSvcb(TestCase):
], ],
ctx.exception.reasons, ctx.exception.reasons,
) )
class TestSrvValue(TestCase):
def test_template(self):
value = SvcbValue({'svcpriority': 0, 'targetname': 'foo.example.com.'})
got = value.template({'needle': 42})
self.assertIs(value, got)
value = SvcbValue(
{'svcpriority': 0, 'targetname': 'foo.{needle}.example.com.'}
)
got = value.template({'needle': 42})
self.assertIsNot(value, got)
self.assertEqual('foo.42.example.com.', got.targetname)

+ 31
- 1
tests/test_octodns_record_target.py View File

@ -5,7 +5,7 @@
from unittest import TestCase from unittest import TestCase
from octodns.record.alias import AliasRecord from octodns.record.alias import AliasRecord
from octodns.record.target import _TargetValue
from octodns.record.target import _TargetsValue, _TargetValue
from octodns.zone import Zone from octodns.zone import Zone
@ -28,3 +28,33 @@ class TestRecordTarget(TestCase):
zone = Zone('unit.tests.', []) zone = Zone('unit.tests.', [])
a = AliasRecord(zone, 'a', {'ttl': 42, 'value': 'some.target.'}) a = AliasRecord(zone, 'a', {'ttl': 42, 'value': 'some.target.'})
self.assertEqual('some.target.', a.value.rdata_text) self.assertEqual('some.target.', a.value.rdata_text)
class TestTargetValue(TestCase):
def test_template(self):
s = 'this.has.no.templating.'
value = _TargetValue(s)
got = value.template({'needle': 42})
self.assertIs(value, got)
s = 'this.does.{needle}.have.templating.'
value = _TargetValue(s)
got = value.template({'needle': 42})
self.assertIsNot(value, got)
self.assertEqual('this.does.42.have.templating.', got)
class TestTargetsValue(TestCase):
def test_template(self):
s = 'this.has.no.templating.'
value = _TargetsValue(s)
got = value.template({'needle': 42})
self.assertIs(value, got)
s = 'this.does.{needle}.have.templating.'
value = _TargetsValue(s)
got = value.template({'needle': 42})
self.assertIsNot(value, got)
self.assertEqual('this.does.42.have.templating.', got)

+ 29
- 0
tests/test_octodns_record_tlsa.py View File

@ -429,3 +429,32 @@ class TestRecordTlsa(TestCase):
'invalid matching_type "{value["matching_type"]}"', 'invalid matching_type "{value["matching_type"]}"',
ctx.exception.reasons, ctx.exception.reasons,
) )
class TestTlsaValue(TestCase):
def test_template(self):
value = TlsaValue(
{
'certificate_usage': 1,
'selector': 1,
'matching_type': 1,
'certificate_association_data': 'ABABABABABABABABAB',
}
)
got = value.template({'needle': 42})
self.assertIs(value, got)
value = TlsaValue(
{
'certificate_usage': 1,
'selector': 1,
'matching_type': 1,
'certificate_association_data': 'ABAB{needle}ABABABABABABAB',
}
)
got = value.template({'needle': 42})
self.assertIsNot(value, got)
self.assertEqual(
'ABAB42ABABABABABABAB', got.certificate_association_data
)

+ 30
- 0
tests/test_octodns_record_urlfwd.py View File

@ -483,3 +483,33 @@ class TestRecordUrlfwd(TestCase):
{'ttl': 32, 'value': UrlfwdValue.parse_rdata_text(rdata)}, {'ttl': 32, 'value': UrlfwdValue.parse_rdata_text(rdata)},
) )
self.assertEqual(rdata, record.values[0].rdata_text) self.assertEqual(rdata, record.values[0].rdata_text)
class TestUrlfwdValue(TestCase):
def test_template(self):
value = UrlfwdValue(
{
'path': '/',
'target': 'http://foo',
'code': 301,
'masking': 2,
'query': 0,
}
)
got = value.template({'needle': 42})
self.assertIs(value, got)
value = UrlfwdValue(
{
'path': '/{needle}',
'target': 'http://foo.{needle}',
'code': 301,
'masking': 2,
'query': 0,
}
)
got = value.template({'needle': 42})
self.assertIsNot(value, got)
self.assertEqual('/42', got.path)
self.assertEqual('http://foo.42', got.target)

Loading…
Cancel
Save