diff --git a/dev/sync.yml b/dev/sync.yml index 84e0ff1..4a4b6bf 100644 --- a/dev/sync.yml +++ b/dev/sync.yml @@ -1,5 +1,5 @@ manager: - max_workers: 2 + max_workers: 1 plan_outputs: html: class: octodns.provider.plan.PlanMarkdown diff --git a/dev/zones/example.com.yaml b/dev/zones/example.com.yaml index e5b1b40..84d0e86 100644 --- a/dev/zones/example.com.yaml +++ b/dev/zones/example.com.yaml @@ -6,6 +6,12 @@ values: - ns1.example.com. - ns2.example.com. +ns1: + type: A + value: 192.168.1.1 +ns2: + type: A + value: 192.168.1.2 record1: type: CNAME value: record11.example.com. diff --git a/justfile b/justfile index 1e72f69..9263d3e 100644 --- a/justfile +++ b/justfile @@ -46,9 +46,9 @@ lint: hatch run lint:style hatch run lint:typing -format: +format *args: just show_system_info - hatch run lint:fmt + hatch run lint:fmt {{ args }} check: just lint @@ -71,11 +71,11 @@ clean: rm -rf dev/redis-data/* rm -rf dev/netbox-data/* -sync *flags: - cd dev && octodns-sync --debug --config-file sync.yml --force {{ flags }} +sync *args: + hatch -v run default:sync {{ args }} -dump *flags: - cd dev && octodns-dump --debug --config-file sync.yml --output-dir output {{ flags }} '*' netbox +dump *args: + hatch -v run default:dump {{ args }} -validate *flags: - cd dev && octodns-validate --debug --config-file sync.yml {{ flags }} +validate *args: + hatch -v run default:validate {{ args }} diff --git a/pyproject.toml b/pyproject.toml index 6c51b5c..c742958 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,10 @@ test-cov = ["coverage erase", "coverage run -m pytest {args:tests}"] cov-report = ["- coverage combine", "coverage report", "coverage xml"] cov = ["test-cov", "cov-report"] +sync = ["cd dev && octodns-sync --debug --config-file sync.yml --force {args}"] +dump = ["cd dev && octodns-dump --debug --config-file sync.yml --output-dir output {args} '*' netbox"] +validate = ["cd dev && octodns-validate --debug --config-file sync.yml {args}"] + [tool.hatch.envs.lint] python = "3.11" detached = true @@ -108,6 +112,7 @@ exclude = [ "dist", "node_modules", "venv", + "dev" ] [tool.ruff.lint] diff --git a/src/octodns_netbox_dns/__init__.py b/src/octodns_netbox_dns/__init__.py index 4873f02..9f99416 100644 --- a/src/octodns_netbox_dns/__init__.py +++ b/src/octodns_netbox_dns/__init__.py @@ -2,20 +2,23 @@ import logging from typing import Any, Literal import dns.rdata +import octodns.provider.base +import octodns.provider.plan import octodns.record -import octodns.source.base import octodns.zone import pynetbox.core.api import pynetbox.core.response -class NetBoxDNSSource(octodns.source.base.BaseSource): +class NetBoxDNSSource(octodns.provider.base.BaseProvider): """ OctoDNS provider for NetboxDNS """ SUPPORTS_GEO = False SUPPORTS_DYNAMIC = False + SUPPORTS_ROOT_NS = True + SUPPORTS_MULTIVALUE_PTR = True SUPPORTS: set[str] = { # noqa "A", "AAAA", @@ -56,13 +59,15 @@ class NetBoxDNSSource(octodns.source.base.BaseSource): ttl=3600, replace_duplicates=False, make_absolute=False, + *args, + **kwargs, ): """ Initialize the NetboxDNSSource """ self.log = logging.getLogger(f"NetboxDNSSource[{id}]") self.log.debug(f"__init__: {id=}, {url=}, {view=}, {replace_duplicates=}, {make_absolute=}") - super().__init__(id) + super().__init__(id, *args, **kwargs) self.api = pynetbox.core.api.Api(url, token) self.nb_view = self._get_nb_view(view) @@ -193,7 +198,7 @@ class NetBoxDNSSource(octodns.source.base.BaseSource): } case "SPF" | "TXT": - value = raw_value.replace(";", r"\;") + value = raw_value.replace(";", "\\;") case "SRV": value = { @@ -267,7 +272,7 @@ class NetBoxDNSSource(octodns.source.base.BaseSource): def populate( self, zone: octodns.zone.Zone, target: bool = False, lenient: bool = False - ) -> None: + ) -> bool: """ Get all the records of a zone from NetBox and add them to the OctoDNS zone @@ -277,10 +282,17 @@ class NetBoxDNSSource(octodns.source.base.BaseSource): """ self.log.info(f"populate -> '{zone.name}', target={target}, lenient={lenient}") - records = self._format_nb_records(zone) + try: + records = self._format_nb_records(zone) + except LookupError: + return False + for data in records: if len(data["values"]) == 1: data["value"] = data.pop("values")[0] + if target and data["type"] in ["NS", "SOA", "PTR"]: + self.log.debug(f"{data['type']} type not supported in target mode") + continue record = octodns.record.Record.new( zone=zone, name=data["name"], @@ -291,3 +303,121 @@ class NetBoxDNSSource(octodns.source.base.BaseSource): zone.add_record(record, lenient=lenient, replace=self.replace_duplicates) self.log.info(f"populate -> found {len(zone.records)} records for zone '{zone.name}'") + + return True + + def _include_change(self, change: octodns.record.change.Change) -> bool: + """Filter out record types which the provider can't create in netbox""" + if change.new._type in ["SOA", "PTR", "NS"]: + return False + + return True + + def _apply(self, plan: octodns.provider.plan.Plan) -> None: + """Apply the changes to the NetBox DNS zone.""" + self.log.debug(f"_apply: zone={plan.desired.name}, len(changes)={len(plan.changes)}") + + nb_zone = self._get_nb_zone(plan.desired.name, view=self.nb_view) + + for change in plan.changes: + match change: + case octodns.record.Create(): + name = change.new.name + if name == "": + name = "@" + + match change.new: + case octodns.record.ValueMixin(): + new = {repr(change.new.value)[1:-1]} + case octodns.record.ValuesMixin(): + new = {repr(v)[1:-1] for v in change.new.values} + case _: + raise ValueError + + for value in new: + nb_record = self.api.plugins.netbox_dns.records.create( + zone=nb_zone.id, + name=name, + type=change.new._type, + ttl=change.new.ttl, + value=value.replace("\\\\", "\\").replace("\\;", ";"), + disable_ptr=True, + ) + self.log.debug(f"{nb_record!r}") + + case octodns.record.Delete(): + name = change.existing.name + if name == "": + name = "@" + + nb_records = self.api.plugins.netbox_dns.records.filter( + zone_id=nb_zone.id, + name=change.existing.name, + type=change.existing._type, + ) + + match change.existing: + case octodns.record.ValueMixin(): + existing = {repr(change.existing.value)[1:-1]} + case octodns.record.ValuesMixin(): + existing = {repr(v)[1:-1] for v in change.existing.values} + case _: + raise ValueError + + for nb_record in nb_records: + for value in existing: + if nb_record.value == value: + self.log.debug( + f"{nb_record.id} {nb_record.name} {nb_record.type} {nb_record.value} {value}" + ) + self.log.debug(f"{nb_record.url} {nb_record.endpoint.url}") + nb_record.delete() + + case octodns.record.Update(): + name = change.existing.name + if name == "": + name = "@" + + nb_records = self.api.plugins.netbox_dns.records.filter( + zone_id=nb_zone.id, + name=name, + type=change.existing._type, + ) + + match change.existing: + case octodns.record.ValueMixin(): + existing = {repr(change.existing.value)[1:-1]} + case octodns.record.ValuesMixin(): + existing = {repr(v)[1:-1] for v in change.existing.values} + case _: + raise ValueError + + match change.new: + case octodns.record.ValueMixin(): + new = {repr(change.new.value)[1:-1]} + case octodns.record.ValuesMixin(): + new = {repr(v)[1:-1] for v in change.new.values} + case _: + raise ValueError + + delete = existing.difference(new) + update = existing.intersection(new) + create = new.difference(existing) + + for nb_record in nb_records: + if nb_record.value in delete: + nb_record.delete() + if nb_record.value in update: + nb_record.ttl = change.new.ttl + nb_record.save() + + for value in create: + nb_record = self.api.plugins.netbox_dns.records.create( + zone=nb_zone.id, + name=name, + type=change.new._type, + ttl=change.new.ttl, + value=value.replace("\\\\", "\\").replace("\\;", ";"), + disable_ptr=True, + ) + self.log.debug(f"{nb_record!r}")