Compare commits

...

2 commits

11 changed files with 317 additions and 180 deletions

View file

@ -11,7 +11,6 @@ charset = utf-8
trim_trailing_whitespace = true trim_trailing_whitespace = true
insert_final_newline = true insert_final_newline = true
[*.{yml,yaml,xml,hcl,tf}] [*.{yml,yaml,xml,hcl,tf}]
indent_size = 2 indent_size = 2

View file

@ -0,0 +1,24 @@
name: check/lint python code with hatch
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
check-python-hatch:
runs-on: python311
steps:
- name: checkout code
uses: actions/checkout@v3
- name: install hatch
run: pip install -U hatch
- name: test codestyle
run: hatch run lint:style
- name: test typing
run: hatch run lint:typing

153
.gitignore vendored
View file

@ -1,7 +1,152 @@
.idea/ .idea/
.vscode/ .vscode/
__pycache__ test.ipynb
.env test.py
.python-version test.sh
.ruff_cache/ downloads/
venv
.mypy_cache/ .mypy_cache/
.ruff_cache/
__pycache__/
.pytest_cache/
.pytest_cache&
### Python template
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/

View file

@ -0,0 +1,5 @@
#!/bin/env bash
set -euxo pipefail
just format

2
.tool-versions Normal file
View file

@ -0,0 +1,2 @@
just 1.16.0
lefthook 1.4.6

View file

@ -1,6 +1,6 @@
# netbox-plugin-dns source for octodns # netbox-plugin-dns source for octodns
> Works with https://github.com/peteeckel/netbox-plugin-dns > works with https://github.com/peteeckel/netbox-plugin-dns
## config ## config
@ -11,11 +11,12 @@ providers:
# Netbox url # Netbox url
# [mandatory, default=null] # [mandatory, default=null]
url: "https://some-url" url: "https://some-url"
# Netbox api token # Netbox API token
# [mandatory, default=null] # [mandatory, default=null]
token: env/NETBOX_API_KEY token: env/NETBOX_API_KEY
# View of the zone. Can be either a string (the view name) or "null" # View of the zone. Can be either a string -> the view name
# to only query zones without a view. Set to false to ignore views # "null" -> to only query zones without a view
# false -> to ignore views
# [optional, default=false] # [optional, default=false]
view: false view: false
# When records sourced from multiple providers, allows provider # When records sourced from multiple providers, allows provider
@ -30,7 +31,7 @@ providers:
## install ## install
### via pip ### via pip + git
```bash ```bash
pip install octodns-netbox-dns@git+https://github.com/olofvndrhr/octodns-netbox-dns.git@main pip install octodns-netbox-dns@git+https://github.com/olofvndrhr/octodns-netbox-dns.git@main

54
justfile Normal file
View file

@ -0,0 +1,54 @@
#!/usr/bin/env just --justfile
default: show_receipts
set shell := ["bash", "-uc"]
set dotenv-load
show_receipts:
@just --list
show_system_info:
@echo "=================================="
@echo "os : {{os()}}"
@echo "arch: {{arch()}}"
@echo "justfile dir: {{justfile_directory()}}"
@echo "invocation dir: {{invocation_directory()}}"
@echo "running dir: `pwd -P`"
@echo "=================================="
setup:
@asdf install
@lefthook install
create_venv:
@echo "creating venv"
@python3 -m pip install --upgrade pip setuptools wheel
@python3 -m venv venv
install_deps:
@echo "installing dependencies"
@python3 -m hatch dep show requirements --project-only > /tmp/requirements.txt
@pip3 install -r /tmp/requirements.txt
install_deps_dev:
@echo "installing dev dependencies"
@python3 -m hatch dep show requirements --project-only > /tmp/requirements.txt
@python3 -m hatch dep show requirements --env-only >> /tmp/requirements.txt
@pip3 install -r /tmp/requirements.txt
create_reqs:
@echo "creating requirements"
@pipreqs --force --savepath requirements.txt src/octodns_netbox_dns
lint:
just show_system_info
just test_shfmt
just test_shfmt
@hatch run lint:style
@hatch run lint:typing
format:
just show_system_info
just format_shfmt
@hatch run lint:fmt

8
lefthook.yml Normal file
View file

@ -0,0 +1,8 @@
colors: true
no_tty: false
pre-commit:
scripts:
"format.sh":
stage_fixed: true
runner: bash

View file

@ -44,17 +44,15 @@ packages = ["src/octodns_netbox_dns"]
### envs ### envs
[tool.hatch.envs.default] [tool.hatch.envs.default]
python = "3.11"
dependencies = [ dependencies = [
"pytest>=7.0.0", "pytest==7.4.3",
"coverage>=6.3.1", "coverage==7.3.2",
"mypy>=1.5.1",
"ruff>=0.1.3",
] ]
[tool.hatch.envs.default.scripts] [tool.hatch.envs.default.scripts]
python = "3.11" test = "pytest {args:tests}"
test = "pytest --verbose {args:tests}" test-cov = ["coverage erase", "coverage run -m pytest {args:tests}"]
test-cov = ["coverage erase", "coverage run -m pytest --verbose {args:tests}"]
cov-report = ["- coverage combine", "coverage report", "coverage xml"] cov-report = ["- coverage combine", "coverage report", "coverage xml"]
cov = ["test-cov", "cov-report"] cov = ["test-cov", "cov-report"]
@ -62,34 +60,16 @@ cov = ["test-cov", "cov-report"]
python = "3.11" python = "3.11"
detached = true detached = true
dependencies = [ dependencies = [
"mypy>=1.5.1", "mypy==1.7.1",
"ruff>=0.1.3", "ruff==0.1.7",
] ]
[tool.hatch.envs.lint.scripts] [tool.hatch.envs.lint.scripts]
typing = "mypy --non-interactive --install-types {args:src/octodns_netbox_dns}" typing = "mypy --non-interactive --install-types {args:src/octodns_netbox_dns}"
style = ["ruff check --diff {args:.}", "ruff format --check --diff {args:.}"] style = ["ruff check --diff {args:.}", "ruff format --check --diff {args:.}"]
fmt = ["ruff format {args:.}", "ruff --fix {args:.}", "style"] fmt = ["ruff format {args:.}", "ruff check --fix {args:.}", "style"]
all = ["style", "typing"] all = ["style", "typing"]
### black
[tool.black]
line-length = 100
target-version = ["py311"]
### pyright
[tool.pyright]
typeCheckingMode = "basic"
pythonVersion = "3.11"
reportUnnecessaryTypeIgnoreComment = true
reportShadowedImports = true
reportUnusedExpression = true
reportMatchNotExhaustive = true
# venvPath = "."
# venv = "venv"
### ruff ### ruff
[tool.ruff] [tool.ruff]
@ -146,7 +126,7 @@ select = [
"W", "W",
"YTT", "YTT",
] ]
ignore = ["E501", "D103", "D100", "D102", "PLR2004", "D403", "FBT003", "ISC001"] ignore = ["E501", "D103", "D100", "D102", "PLR2004", "D403", "ISC001", "FBT001", "FBT002", "FBT003"]
unfixable = ["F401"] unfixable = ["F401"]
[tool.ruff.format] [tool.ruff.format]
@ -189,15 +169,33 @@ max-doc-length = 100
### mypy ### mypy
[tool.mypy] [tool.mypy]
disallow_untyped_defs = false #plugins = ["pydantic.mypy"]
disallow_incomplete_defs = false follow_imports = "silent"
follow_imports = "normal" warn_redundant_casts = true
warn_unused_ignores = true
disallow_any_generics = true
check_untyped_defs = true
no_implicit_reexport = true
ignore_missing_imports = true ignore_missing_imports = true
warn_return_any = true
pretty = true pretty = true
show_column_numbers = true show_column_numbers = true
show_error_codes = true show_error_codes = true
warn_no_return = false show_error_context = true
warn_unused_ignores = true
#[tool.pydantic-mypy]
#init_forbid_extra = true
#init_typed = true
#warn_required_dynamic_aliases = true
### pytest
[tool.pytest.ini_options]
pythonpath = ["src"]
addopts = "--color=yes --exitfirst --verbose -ra"
#addopts = "--color=yes --exitfirst --verbose -ra --capture=tee-sys"
filterwarnings = [
'ignore:Jupyter is migrating its paths to use standard platformdirs:DeprecationWarning',
]
### coverage ### coverage
@ -208,8 +206,8 @@ parallel = true
omit = ["src/octodns_netbox_dns/__about__.py"] omit = ["src/octodns_netbox_dns/__about__.py"]
[tool.coverage.paths] [tool.coverage.paths]
testproj = ["src/octodns_netbox_dns", "*/src/octodns_netbox_dns"] testproj = ["src/octodns_netbox_dns", "*/octodns_netbox_dns/src/octodns_netbox_dns"]
tests = ["tests", "*/tests"] tests = ["tests", "*/octodns_netbox_dns/tests"]
[tool.coverage.report] [tool.coverage.report]
# Regexes for lines to exclude from consideration # Regexes for lines to exclude from consideration

4
renovate.json Normal file
View file

@ -0,0 +1,4 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["local>44net/renovate"]
}

View file

@ -1,20 +1,22 @@
"""OctoDNS provider for NetboxDNS."""
import logging import logging
from typing import Literal from typing import Literal
import dns.rdata import dns.rdata
import octodns.provider.base
import octodns.provider.plan
import octodns.record import octodns.record
import octodns.source.base
import octodns.zone import octodns.zone
import pynetbox.core.api import pynetbox.core.api
import pynetbox.core.response as pynb_resp import pynetbox.core.response
class NetBoxDNSSource(octodns.provider.base.BaseProvider): class NetBoxDNSSource(octodns.source.base.BaseSource):
SUPPORTS_GEO: bool = False """
SUPPORTS_DYNAMIC: bool = False OctoDNS provider for NetboxDNS
SUPPORTS: set[str] = { """
SUPPORTS_GEO = False
SUPPORTS_DYNAMIC = False
SUPPORTS: frozenset = {
"A", "A",
"AAAA", "AAAA",
"AFSDB", "AFSDB",
@ -45,10 +47,6 @@ class NetBoxDNSSource(octodns.provider.base.BaseProvider):
"TXT", "TXT",
} }
_api: pynetbox.core.api.Api
# log: logging.Logger
_ttl: int
def __init__( def __init__(
self, self,
id: int, id: int,
@ -56,15 +54,15 @@ class NetBoxDNSSource(octodns.provider.base.BaseProvider):
token: str, token: str,
view: str | None | Literal[False] = False, view: str | None | Literal[False] = False,
ttl=3600, ttl=3600,
replace_duplicates: bool = False, replace_duplicates=False,
make_absolute: bool = False, make_absolute=False,
): ):
"""Initialize the NetboxDNSSource.""" """
Initialize the NetboxDNSSource
"""
self.log = logging.getLogger(f"NetboxDNSSource[{id}]") self.log = logging.getLogger(f"NetboxDNSSource[{id}]")
self.log.debug( self.log.debug(f"__init__: {id=}, {url=}, {view=}, {replace_duplicates=}, {make_absolute=}")
f"__init__: id={id}, url={url}, view={view}, replace_duplicates={replace_duplicates}, make_absolute={make_absolute}" super().__init__(id)
)
super(NetBoxDNSSource, self).__init__(id)
self._api = pynetbox.core.api.Api(url, token) self._api = pynetbox.core.api.Api(url, token)
self._nb_view = {} if view is False else self._get_view(view) self._nb_view = {} if view is False else self._get_view(view)
self._ttl = ttl self._ttl = ttl
@ -76,27 +74,34 @@ class NetBoxDNSSource(octodns.provider.base.BaseProvider):
return value return value
return value + "." return value + "."
def _get_view(self, view: str | None) -> dict[str, int | str]: def _get_view(self, view: str | Literal[False]) -> dict[str, int | str]:
if view is None: if view is None:
return {"view": "null"} return {"view": "null"}
nb_view: pynb_resp.Record = self._api.plugins.netbox_dns.views.get(name=view) nb_view: pynetbox.core.response.Record = self._api.plugins.netbox_dns.views.get(name=view)
if nb_view is None: if nb_view is None:
raise ValueError(f"dns view: '{view}' has not been found") msg = f"dns view: '{view}' has not been found"
self.log.error(msg)
raise ValueError(msg)
self.log.debug(f"found {nb_view.name} {nb_view.id}") self.log.debug(f"found {nb_view.name} {nb_view.id}")
return {"view_id": nb_view.id} return {"view_id": nb_view.id}
def _get_nb_zone(self, name: str, view: dict[str, str | int]) -> pynb_resp.Record: def _get_nb_zone(self, name: str, view: dict[str, str | int]) -> pynetbox.core.response.Record:
"""Given a zone name and a view name, look it up in NetBox. """
Raises: pynetbox.RequestError if declared view is not existant""" Given a zone name and a view name, look it up in NetBox.
@raise pynetbox.RequestError: if declared view is not existent
"""
query_params = {"name": name[:-1], **view} query_params = {"name": name[:-1], **view}
nb_zone = self._api.plugins.netbox_dns.zones.get(**query_params) nb_zone = self._api.plugins.netbox_dns.zones.get(**query_params)
return nb_zone return nb_zone
def populate(self, zone: octodns.zone.Zone, target: bool = False, lenient: bool = False): def populate(self, zone: octodns.zone.Zone, target: bool = False, lenient: bool = False):
"""Get all the records of a zone from NetBox and add them to the OctoDNS zone.""" """
Get all the records of a zone from NetBox and add them to the OctoDNS zone.
"""
self.log.debug(f"populate: name={zone.name}, target={target}, lenient={lenient}") self.log.debug(f"populate: name={zone.name}, target={target}, lenient={lenient}")
records = {} records = {}
@ -218,111 +223,3 @@ class NetBoxDNSSource(octodns.provider.base.BaseProvider):
zone.add_record(record, lenient=lenient, replace=self.replace_duplicates) zone.add_record(record, lenient=lenient, replace=self.replace_duplicates)
self.log.info(f"populate: found {len(zone.records)} records for zone {zone.name}") self.log.info(f"populate: found {len(zone.records)} records for zone {zone.name}")
def _apply(self, plan: octodns.provider.plan.Plan):
"""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 = set(map(lambda v: repr(v)[1:-1], 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,
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 = set(map(lambda v: repr(v)[1:-1], 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 = set(map(lambda v: repr(v)[1:-1], 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 = set(map(lambda v: repr(v)[1:-1], 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,
disable_ptr=True,
)