diff --git a/.changelog/80f748502d3e4258bcbf4a05d5f5bdb0.md b/.changelog/80f748502d3e4258bcbf4a05d5f5bdb0.md new file mode 100644 index 0000000..fbf6c6d --- /dev/null +++ b/.changelog/80f748502d3e4258bcbf4a05d5f5bdb0.md @@ -0,0 +1,4 @@ +--- +type: none +--- +Create dist directory in scripte/release if it doesn't exist \ No newline at end of file diff --git a/.changelog/acc2596fb367494db070e6c06abf705a.md b/.changelog/acc2596fb367494db070e6c06abf705a.md deleted file mode 100644 index 2f3525b..0000000 --- a/.changelog/acc2596fb367494db070e6c06abf705a.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -type: none ---- -Adding changelog management infra and doc \ No newline at end of file diff --git a/.changelog/c4f025d1c23c40dd98380e6d3496364d.md b/.changelog/c4f025d1c23c40dd98380e6d3496364d.md new file mode 100644 index 0000000..e8a2d91 --- /dev/null +++ b/.changelog/c4f025d1c23c40dd98380e6d3496364d.md @@ -0,0 +1,4 @@ +--- +type: none +--- +Documentation for processors \ No newline at end of file diff --git a/.git_hooks_pre-commit b/.git_hooks_pre-commit index de3ba67..3b5f6ac 100755 --- a/.git_hooks_pre-commit +++ b/.git_hooks_pre-commit @@ -7,7 +7,7 @@ GIT=$(dirname "$HOOKS") ROOT=$(dirname "$GIT") . "$ROOT/env/bin/activate" +"$ROOT/script/changelog" check "$ROOT/script/lint" "$ROOT/script/format" --check --quiet || (echo "Formatting check failed, run ./script/format" && exit 1) "$ROOT/script/coverage" -"$ROOT/script/changelog" check diff --git a/.github/workflows/geo-data.yml b/.github/workflows/geo-data.yml new file mode 100644 index 0000000..a1249ec --- /dev/null +++ b/.github/workflows/geo-data.yml @@ -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<> $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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0df45b4..7eadfef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 50332aa..f561609 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,6 @@ zones: - config targets: - ns1 - ``` #### 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. | 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/) | | | [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) | | | [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/) | | +| [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/) | | +| [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/) | | +| [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/) | | | [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/) | | | [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/) | | | [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/) | | +| [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/) | | | [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/) | | +| [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/) | | | [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/) | | | [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) | | @@ -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/) | | | [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 | -| [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 @@ -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. | 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 | -| [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 | +| [ZoneFileSource](https://github.com/octodns/octodns-bind/) | A, AAAA, CAA, CNAME, MX, NS, PTR, SPF, SRV, TXT | No | read-only | ### Notes @@ -329,7 +341,7 @@ Similar to providers, but can only serve to populate records into a zone, cannot ## Processors | Processor | Description | -|--|--| +| --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | [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 | | [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 zones: - githubtest.net.: sources: - route53 @@ -428,11 +439,10 @@ providers: token: env/GPANEL_SITE_TOKEN powerdns-site: class: octodns.provider.powerdns.PowerDnsProvider - host: 'internal-dns.site.github.foo' + host: "internal-dns.site.github.foo" api_key: env/POWERDNS_SITE_API_KEY zones: - hosts.site.github.foo.: sources: - 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) - **NixOS Integration:** [NixOS-DNS](https://github.com/Janik-Haag/nixos-dns/) - **Sample Implementations.** See how others are using it + - [`hackclub/dns`](https://github.com/hackclub/dns) - [`kubernetes/k8s.io:/dns`](https://github.com/kubernetes/k8s.io/tree/main/dns) - [`g0v-network/domains`](https://github.com/g0v-network/domains) - [`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.** - 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/) diff --git a/docs/processors.md b/docs/processors.md new file mode 100644 index 0000000..70ee8f7 --- /dev/null +++ b/docs/processors.md @@ -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 +``` diff --git a/octodns/__init__.py b/octodns/__init__.py index d49222c..5340737 100644 --- a/octodns/__init__.py +++ b/octodns/__init__.py @@ -1,4 +1,4 @@ 'OctoDNS: DNS as code - Tools for managing DNS across multiple providers' # TODO: remove __VERSION__ w/2.x -__version__ = __VERSION__ = '1.11.0' +__version__ = __VERSION__ = '1.12.0' diff --git a/octodns/processor/templating.py b/octodns/processor/templating.py new file mode 100644 index 0000000..c514bec --- /dev/null +++ b/octodns/processor/templating.py @@ -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 diff --git a/octodns/record/caa.py b/octodns/record/caa.py index a9d048e..b02a1f0 100644 --- a/octodns/record/caa.py +++ b/octodns/record/caa.py @@ -87,6 +87,13 @@ class CaaValue(EqualityTupleMixin, dict): def rdata_text(self): 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): return (self.flags, self.tag, self.value) diff --git a/octodns/record/chunked.py b/octodns/record/chunked.py index ab68806..cbd1707 100644 --- a/octodns/record/chunked.py +++ b/octodns/record/chunked.py @@ -82,3 +82,8 @@ class _ChunkedValue(str): @property def rdata_text(self): return self + + def template(self, params): + if '{' not in self: + return self + return self.__class__(self.format(**params)) diff --git a/octodns/record/ds.py b/octodns/record/ds.py index 2e8bfe4..5cfc569 100644 --- a/octodns/record/ds.py +++ b/octodns/record/ds.py @@ -164,6 +164,13 @@ class DsValue(EqualityTupleMixin, dict): 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): return (self.key_tag, self.algorithm, self.digest_type, self.digest) diff --git a/octodns/record/geo_data.py b/octodns/record/geo_data.py index d78eabf..dfbe75f 100644 --- a/octodns/record/geo_data.py +++ b/octodns/record/geo_data.py @@ -123,7 +123,7 @@ geo_data = { 'TJ': {'name': 'Tajikistan'}, 'TL': {'name': 'Timor-Leste'}, 'TM': {'name': 'Turkmenistan'}, - 'TR': {'name': 'Turkey'}, + 'TR': {'name': 'Türkiye'}, 'TW': {'name': 'Taiwan, Province of China'}, 'UZ': {'name': 'Uzbekistan'}, 'VN': {'name': 'Viet Nam'}, diff --git a/octodns/record/ip.py b/octodns/record/ip.py index 6b3fe3c..ab838af 100644 --- a/octodns/record/ip.py +++ b/octodns/record/ip.py @@ -45,5 +45,8 @@ class _IpValue(str): def rdata_text(self): return self + def template(self, params): + return self + _IpAddress = _IpValue diff --git a/octodns/record/loc.py b/octodns/record/loc.py index babbb93..59d55c5 100644 --- a/octodns/record/loc.py +++ b/octodns/record/loc.py @@ -305,6 +305,9 @@ class LocValue(EqualityTupleMixin, dict): 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' + def template(self, params): + return self + def __hash__(self): return hash( ( diff --git a/octodns/record/mx.py b/octodns/record/mx.py index 36b48b5..5b11dc4 100644 --- a/octodns/record/mx.py +++ b/octodns/record/mx.py @@ -101,6 +101,13 @@ class MxValue(EqualityTupleMixin, dict): def rdata_text(self): 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): return hash((self.preference, self.exchange)) diff --git a/octodns/record/naptr.py b/octodns/record/naptr.py index 5dc9605..6469311 100644 --- a/octodns/record/naptr.py +++ b/octodns/record/naptr.py @@ -138,6 +138,19 @@ class NaptrValue(EqualityTupleMixin, dict): def rdata_text(self): 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): return hash(self.__repr__()) diff --git a/octodns/record/srv.py b/octodns/record/srv.py index e885735..9a49c47 100644 --- a/octodns/record/srv.py +++ b/octodns/record/srv.py @@ -135,6 +135,13 @@ class SrvValue(EqualityTupleMixin, dict): def rdata_text(self): 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): return hash(self.__repr__()) diff --git a/octodns/record/sshfp.py b/octodns/record/sshfp.py index e1c9de0..e186744 100644 --- a/octodns/record/sshfp.py +++ b/octodns/record/sshfp.py @@ -105,6 +105,13 @@ class SshfpValue(EqualityTupleMixin, dict): def rdata_text(self): 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): return hash(self.__repr__()) diff --git a/octodns/record/svcb.py b/octodns/record/svcb.py index 399414c..b286d1c 100644 --- a/octodns/record/svcb.py +++ b/octodns/record/svcb.py @@ -287,6 +287,14 @@ class SvcbValue(EqualityTupleMixin, dict): params += f'={svcparamvalue}' 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): return hash(self.__repr__()) diff --git a/octodns/record/target.py b/octodns/record/target.py index 3d6cea7..b1d954b 100644 --- a/octodns/record/target.py +++ b/octodns/record/target.py @@ -41,6 +41,11 @@ class _TargetValue(str): def rdata_text(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 @@ -75,3 +80,8 @@ class _TargetsValue(str): @property def rdata_text(self): return self + + def template(self, params): + if '{' not in self: + return self + return self.__class__(self.format(**params)) diff --git a/octodns/record/tlsa.py b/octodns/record/tlsa.py index ed7b267..c6e72e9 100644 --- a/octodns/record/tlsa.py +++ b/octodns/record/tlsa.py @@ -136,6 +136,15 @@ class TlsaValue(EqualityTupleMixin, dict): def rdata_text(self): 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): return ( self.certificate_usage, diff --git a/octodns/record/urlfwd.py b/octodns/record/urlfwd.py index e735725..2d7d0fe 100644 --- a/octodns/record/urlfwd.py +++ b/octodns/record/urlfwd.py @@ -132,6 +132,14 @@ class UrlfwdValue(EqualityTupleMixin, dict): def rdata_text(self): 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): return (self.path, self.target, self.code, self.masking, self.query) diff --git a/requirements-dev.txt b/requirements-dev.txt index b2e7d80..3f5ee4e 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,48 +1,50 @@ # 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 build==1.2.2.post1 -certifi==2024.8.30 +certifi==2025.6.15 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 -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.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 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 -pkginfo==1.10.0 -platformdirs==4.3.6 -pluggy==1.5.0 +platformdirs==4.3.8 +pluggy==1.6.0 pprintpp==0.4.0 pycountry-convert==0.7.2 pycountry==24.6.1 pycparser==2.22 -pyflakes==3.2.0 +pyflakes==3.4.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 readme_renderer==44.0 repoze.lru==0.7 requests-toolbelt==1.0.0 -requests==2.32.3 +requests==2.32.4 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 diff --git a/requirements.txt b/requirements.txt index dfef368..a40f955 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,4 @@ fqdn==1.5.1 idna==3.10 natsort==8.4.0 python-dateutil==2.9.0.post0 -six==1.16.0 +six==1.17.0 diff --git a/script/changelog b/script/changelog index 0c7dd42..a2a63bb 100755 --- a/script/changelog +++ b/script/changelog @@ -11,7 +11,7 @@ from subprocess import PIPE, run from sys import argv, exit, path, stderr from uuid import uuid4 -from yaml import safe_load_all +from yaml import safe_load def create(argv): @@ -39,6 +39,18 @@ def create(argv): 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( 'md', metavar='change-description-markdown', @@ -56,6 +68,9 @@ and links.''', with open(filepath, 'w') as fh: fh.write('---\ntype: ') fh.write(args.type) + if args.pr: + fh.write('\npr: ') + fh.write(args.pr) fh.write('\n---\n') 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.' ) + if args.add: + run(['git', 'add', filepath]) + def check(argv): if isdir('.changelog'): @@ -97,7 +115,9 @@ class _ChangeMeta: _pr_cache = None @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: result = run( [ @@ -130,6 +150,7 @@ class _ChangeMeta: try: return cls._pr_cache[filepath] except KeyError: + # couldn't find a PR with the changelog file in it return None, datetime(year=1970, month=1, day=1) @@ -141,8 +162,12 @@ def _get_changelogs(): continue filepath = join(dirname, filename) 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: continue 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 @@ -195,6 +220,9 @@ def bump(argv): action='store_true', 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) @@ -215,7 +243,7 @@ def bump(argv): buf.write(' - ') buf.write(datetime.now().strftime('%Y-%m-%d')) buf.write(' - ') - buf.write(' '.join(argv[1:])) + buf.write(' '.join(args.title)) buf.write('\n') current_type = None @@ -225,6 +253,9 @@ def bump(argv): continue _type = changelog['type'] + if _type == 'none': + # these aren't included in the listing + continue if _type != current_type: buf.write('\n') buf.write(_type.capitalize()) diff --git a/script/release b/script/release index da5d216..1fab0eb 100755 --- a/script/release +++ b/script/release @@ -44,6 +44,10 @@ echo "Created clean room $TMP_DIR and archived $VERSION into it" (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/ echo "Copied $TMP_DIR/dists into ./dist" diff --git a/script/update-requirements b/script/update-requirements index 7696f6d..c1adc47 100755 --- a/script/update-requirements +++ b/script/update-requirements @@ -42,6 +42,18 @@ dev_frozen = sorted( [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(dev_frozen, 'dev_frozen') diff --git a/tests/test_octodns_processor_templating.py b/tests/test_octodns_processor_templating.py new file mode 100644 index 0000000..94472d8 --- /dev/null +++ b/tests/test_octodns_processor_templating.py @@ -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]) diff --git a/tests/test_octodns_record_caa.py b/tests/test_octodns_record_caa.py index 8caecb9..224dd00 100644 --- a/tests/test_octodns_record_caa.py +++ b/tests/test_octodns_record_caa.py @@ -285,3 +285,20 @@ class TestRecordCaa(TestCase): {'type': 'CAA', 'ttl': 600, 'value': {'tag': 'iodef'}}, ) 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) diff --git a/tests/test_octodns_record_chunked.py b/tests/test_octodns_record_chunked.py index 6b40752..aa687b1 100644 --- a/tests/test_octodns_record_chunked.py +++ b/tests/test_octodns_record_chunked.py @@ -121,3 +121,15 @@ class TestChunkedValue(TestCase): sc = self.SmallerChunkedMixin(['0123456789']) 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) diff --git a/tests/test_octodns_record_ds.py b/tests/test_octodns_record_ds.py index f0429de..83fea42 100644 --- a/tests/test_octodns_record_ds.py +++ b/tests/test_octodns_record_ds.py @@ -259,3 +259,30 @@ class TestRecordDs(TestCase): 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].__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) diff --git a/tests/test_octodns_record_ip.py b/tests/test_octodns_record_ip.py index f9ba62d..818bca1 100644 --- a/tests/test_octodns_record_ip.py +++ b/tests/test_octodns_record_ip.py @@ -27,3 +27,11 @@ class TestRecordIp(TestCase): zone = Zone('unit.tests.', []) a = ARecord(zone, 'a', {'ttl': 42, 'value': '1.2.3.4'}) 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})) diff --git a/tests/test_octodns_record_loc.py b/tests/test_octodns_record_loc.py index 278b816..80c9505 100644 --- a/tests/test_octodns_record_loc.py +++ b/tests/test_octodns_record_loc.py @@ -715,3 +715,26 @@ class TestRecordLoc(TestCase): self.assertEqual( ['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({})) diff --git a/tests/test_octodns_record_mx.py b/tests/test_octodns_record_mx.py index a2fba19..57386c4 100644 --- a/tests/test_octodns_record_mx.py +++ b/tests/test_octodns_record_mx.py @@ -268,3 +268,16 @@ class TestRecordMx(TestCase): }, ) 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) diff --git a/tests/test_octodns_record_naptr.py b/tests/test_octodns_record_naptr.py index b099de4..3c05550 100644 --- a/tests/test_octodns_record_naptr.py +++ b/tests/test_octodns_record_naptr.py @@ -449,3 +449,36 @@ class TestRecordNaptr(TestCase): with self.assertRaises(ValidationError) as ctx: Record.new(self.zone, '', {'type': 'NAPTR', 'ttl': 600, 'value': v}) 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) diff --git a/tests/test_octodns_record_srv.py b/tests/test_octodns_record_srv.py index e525afd..8470a02 100644 --- a/tests/test_octodns_record_srv.py +++ b/tests/test_octodns_record_srv.py @@ -450,3 +450,30 @@ class TestRecordSrv(TestCase): ['Invalid SRV target "100 foo.bar.com." is not a valid FQDN.'], 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) diff --git a/tests/test_octodns_record_sshfp.py b/tests/test_octodns_record_sshfp.py index 4e66186..7364ec1 100644 --- a/tests/test_octodns_record_sshfp.py +++ b/tests/test_octodns_record_sshfp.py @@ -333,3 +333,24 @@ class TestRecordSshfp(TestCase): }, ) 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) diff --git a/tests/test_octodns_record_svcb.py b/tests/test_octodns_record_svcb.py index 2d8cecd..5345b70 100644 --- a/tests/test_octodns_record_svcb.py +++ b/tests/test_octodns_record_svcb.py @@ -673,3 +673,18 @@ class TestRecordSvcb(TestCase): ], 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) diff --git a/tests/test_octodns_record_target.py b/tests/test_octodns_record_target.py index 715cd4a..6700564 100644 --- a/tests/test_octodns_record_target.py +++ b/tests/test_octodns_record_target.py @@ -5,7 +5,7 @@ from unittest import TestCase from octodns.record.alias import AliasRecord -from octodns.record.target import _TargetValue +from octodns.record.target import _TargetsValue, _TargetValue from octodns.zone import Zone @@ -28,3 +28,33 @@ class TestRecordTarget(TestCase): zone = Zone('unit.tests.', []) a = AliasRecord(zone, 'a', {'ttl': 42, 'value': 'some.target.'}) 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) diff --git a/tests/test_octodns_record_tlsa.py b/tests/test_octodns_record_tlsa.py index 26132e8..ef29d5d 100644 --- a/tests/test_octodns_record_tlsa.py +++ b/tests/test_octodns_record_tlsa.py @@ -429,3 +429,32 @@ class TestRecordTlsa(TestCase): 'invalid matching_type "{value["matching_type"]}"', 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 + ) diff --git a/tests/test_octodns_record_urlfwd.py b/tests/test_octodns_record_urlfwd.py index 6c1f5e1..14f406f 100644 --- a/tests/test_octodns_record_urlfwd.py +++ b/tests/test_octodns_record_urlfwd.py @@ -483,3 +483,33 @@ class TestRecordUrlfwd(TestCase): {'ttl': 32, 'value': UrlfwdValue.parse_rdata_text(rdata)}, ) 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)