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
insert_final_newline = true
[*.{yml,yaml,xml,hcl,tf}]
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/
.vscode/
__pycache__
.env
.python-version
.ruff_cache/
test.ipynb
test.py
test.sh
downloads/
venv
.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
> Works with https://github.com/peteeckel/netbox-plugin-dns
> works with https://github.com/peteeckel/netbox-plugin-dns
## config
@ -11,11 +11,12 @@ providers:
# Netbox url
# [mandatory, default=null]
url: "https://some-url"
# Netbox api token
# Netbox API token
# [mandatory, default=null]
token: env/NETBOX_API_KEY
# View of the zone. Can be either a string (the view name) or "null"
# to only query zones without a view. Set to false to ignore views
# View of the zone. Can be either a string -> the view name
# "null" -> to only query zones without a view
# false -> to ignore views
# [optional, default=false]
view: false
# When records sourced from multiple providers, allows provider
@ -30,7 +31,7 @@ providers:
## install
### via pip
### via pip + git
```bash
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
[tool.hatch.envs.default]
python = "3.11"
dependencies = [
"pytest>=7.0.0",
"coverage>=6.3.1",
"mypy>=1.5.1",
"ruff>=0.1.3",
"pytest==7.4.3",
"coverage==7.3.2",
]
[tool.hatch.envs.default.scripts]
python = "3.11"
test = "pytest --verbose {args:tests}"
test-cov = ["coverage erase", "coverage run -m pytest --verbose {args:tests}"]
test = "pytest {args:tests}"
test-cov = ["coverage erase", "coverage run -m pytest {args:tests}"]
cov-report = ["- coverage combine", "coverage report", "coverage xml"]
cov = ["test-cov", "cov-report"]
@ -62,34 +60,16 @@ cov = ["test-cov", "cov-report"]
python = "3.11"
detached = true
dependencies = [
"mypy>=1.5.1",
"ruff>=0.1.3",
"mypy==1.7.1",
"ruff==0.1.7",
]
[tool.hatch.envs.lint.scripts]
typing = "mypy --non-interactive --install-types {args:src/octodns_netbox_dns}"
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"]
### 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
[tool.ruff]
@ -146,7 +126,7 @@ select = [
"W",
"YTT",
]
ignore = ["E501", "D103", "D100", "D102", "PLR2004", "D403", "FBT003", "ISC001"]
ignore = ["E501", "D103", "D100", "D102", "PLR2004", "D403", "ISC001", "FBT001", "FBT002", "FBT003"]
unfixable = ["F401"]
[tool.ruff.format]
@ -189,15 +169,33 @@ max-doc-length = 100
### mypy
[tool.mypy]
disallow_untyped_defs = false
disallow_incomplete_defs = false
follow_imports = "normal"
#plugins = ["pydantic.mypy"]
follow_imports = "silent"
warn_redundant_casts = true
warn_unused_ignores = true
disallow_any_generics = true
check_untyped_defs = true
no_implicit_reexport = true
ignore_missing_imports = true
warn_return_any = true
pretty = true
show_column_numbers = true
show_error_codes = true
warn_no_return = false
warn_unused_ignores = true
show_error_context = 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
@ -208,8 +206,8 @@ parallel = true
omit = ["src/octodns_netbox_dns/__about__.py"]
[tool.coverage.paths]
testproj = ["src/octodns_netbox_dns", "*/src/octodns_netbox_dns"]
tests = ["tests", "*/tests"]
testproj = ["src/octodns_netbox_dns", "*/octodns_netbox_dns/src/octodns_netbox_dns"]
tests = ["tests", "*/octodns_netbox_dns/tests"]
[tool.coverage.report]
# 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
from typing import 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 as pynb_resp
import pynetbox.core.response
class NetBoxDNSSource(octodns.provider.base.BaseProvider):
SUPPORTS_GEO: bool = False
SUPPORTS_DYNAMIC: bool = False
SUPPORTS: set[str] = {
class NetBoxDNSSource(octodns.source.base.BaseSource):
"""
OctoDNS provider for NetboxDNS
"""
SUPPORTS_GEO = False
SUPPORTS_DYNAMIC = False
SUPPORTS: frozenset = {
"A",
"AAAA",
"AFSDB",
@ -45,10 +47,6 @@ class NetBoxDNSSource(octodns.provider.base.BaseProvider):
"TXT",
}
_api: pynetbox.core.api.Api
# log: logging.Logger
_ttl: int
def __init__(
self,
id: int,
@ -56,15 +54,15 @@ class NetBoxDNSSource(octodns.provider.base.BaseProvider):
token: str,
view: str | None | Literal[False] = False,
ttl=3600,
replace_duplicates: bool = False,
make_absolute: bool = False,
replace_duplicates=False,
make_absolute=False,
):
"""Initialize the NetboxDNSSource."""
"""
Initialize the NetboxDNSSource
"""
self.log = logging.getLogger(f"NetboxDNSSource[{id}]")
self.log.debug(
f"__init__: id={id}, url={url}, view={view}, replace_duplicates={replace_duplicates}, make_absolute={make_absolute}"
)
super(NetBoxDNSSource, self).__init__(id)
self.log.debug(f"__init__: {id=}, {url=}, {view=}, {replace_duplicates=}, {make_absolute=}")
super().__init__(id)
self._api = pynetbox.core.api.Api(url, token)
self._nb_view = {} if view is False else self._get_view(view)
self._ttl = ttl
@ -76,27 +74,34 @@ class NetBoxDNSSource(octodns.provider.base.BaseProvider):
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:
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:
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}")
return {"view_id": nb_view.id}
def _get_nb_zone(self, name: str, view: dict[str, str | int]) -> pynb_resp.Record:
"""Given a zone name and a view name, look it up in NetBox.
Raises: pynetbox.RequestError if declared view is not existant"""
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.
@raise pynetbox.RequestError: if declared view is not existent
"""
query_params = {"name": name[:-1], **view}
nb_zone = self._api.plugins.netbox_dns.zones.get(**query_params)
return nb_zone
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}")
records = {}
@ -218,111 +223,3 @@ class NetBoxDNSSource(octodns.provider.base.BaseProvider):
zone.add_record(record, lenient=lenient, replace=self.replace_duplicates)
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,
)