commit 31556f7b13decc33fac78d611614ec672f03c0c9 Author: Ondřej Caletka Date: Fri Jul 6 21:53:03 2018 +0200 initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..894a44c --- /dev/null +++ b/.gitignore @@ -0,0 +1,104 @@ +# 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/ +*.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/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# 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/ diff --git a/dzonegit.py b/dzonegit.py new file mode 100644 index 0000000..4b679ea --- /dev/null +++ b/dzonegit.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 + +import subprocess +import re +from collections import namedtuple +from hashlib import sha256 +from pathlib import Path + + +def get_head(): + r = subprocess.run( + ["git", "rev-parse", "--verify", "HEAD"], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + encoding="utf-8" + ) + if r.returncode == 0: + return r.stdout.strip() + else: + # Initial commit: diff against an empty tree object + return "4b825dc642cb6eb9a060e54bf8d69288fbee4904" + + +def check_whitespace_errors(against): + r = subprocess.run( + ["git", "diff-index", "--check", "--cached", against], + ) + if r.returncode != 0: + raise ValueError("Whitespace errors") + + +def get_file_contents(path, revision=""): + """ Return contents of a file in staged env or in some revision. """ + r = subprocess.run( + ["git", "show", f"{revision}:{path}"], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + encoding="utf-8", + check=True + ) + return r.stdout + + +def compile_zone(zonename, zonedata): + """ Compile the zone. Return tuple with results.""" + CompileResults = namedtuple("CompileResults", "success, serial, " + "zonehash, stderr") + r = subprocess.run( + ["/usr/sbin/named-compilezone", "-o", "-", zonename, "/dev/stdin"], + input=zonedata, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + m = re.search(r"^zone.*loaded serial ([0-9]*)$", r.stderr, re.MULTILINE) + if r.returncode == 0 and m: + serial = m.group(1) + zonehash = sha256(r.stdout.encode("utf-8")).hexdigest() + return CompileResults(True, serial, zonehash, r.stderr) + else: + return CompileResults(False, None, None, r.stderr) + + +def is_serial_increased(old, new): + """ Return true if serial number was increased using RFC 1982 logic. """ + old, new = (int(n) for n in [old, new]) + diff = (new - old) % 2**32 + return 0 < diff < (2**31 - 1) + + +def get_altered_files(against, diff_filter=None): + """ Return list of changed files. """ + cmd = ["git", "diff", "--cached", "--name-only", "-z"] + if diff_filter: + cmd.append(f"--diff-filter={diff_filter}") + cmd.append(against) + r = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + encoding="utf-8", + check=True + ) + return (Path(p) for p in r.stdout.rstrip("\0").split("\0")) + + +def get_zone_origin(zonedata, maxlines=10): + """ + Parse $ORIGIN directive in first maxlines lines of zone file. + Return zone name without the trailing dot. + """ + for i, line in enumerate(zonedata.splitlines()): + if i >= maxlines: + break + m = re.match(r"^\$ORIGIN\s+([^ ]+)\.\s*(;.*)?$", line) + if m: + return m.group(1).lower() + + +def get_zone_name(path, zonedata): + """ + Try to guess zone name from either filename or the first $ORIGIN. + Throw a ValueError if filename and zone ORIGIN differ more than + in slashes. + """ + stemname = Path(path).stem.lower() + originname = get_zone_origin(zonedata) + if originname: + tt = str.maketrans("", "", "/_,:-+*%^&#$") + sn, on = [s.translate(tt) for s in [stemname, originname]] + if sn != on: + raise ValueError('Zone origin and zone file name differ.', + originname, stemname) + return originname + else: + return stemname + + +def check_updated_zones(against): + """ Check whether all updated zone files compile. """ + for f in get_altered_files(against, "AM"): + if not f.suffix == ".zone": + continue + print(f"Checking file {f}") + zonedata = get_file_contents(f) + zname = get_zone_name(f, zonedata) + rnew = compile_zone(zname, zonedata) + if not rnew.success: + raise ValueError("New zone version does not compile", str(f), + rnew.stderr) + try: + zonedata = get_file_contents(f, against) + zname = get_zone_name(f, zonedata) + rold = compile_zone(zname, zonedata) + + if (rold.success and rold.zonehash != rnew.zonehash and not + is_serial_increased(rold.serial, rnew.serial)): + raise ValueError("Zone contents changed without " + "increasing serial", f) + except subprocess.CalledProcessError: + pass # Old version of zone did not exist + + +def main(): + against = get_head() + try: + check_whitespace_errors(against) + check_updated_zones(against) + except ValueError as e: + print("\n".join(e.args)) + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/tests/test_dzonegit.py b/tests/test_dzonegit.py new file mode 100644 index 0000000..da4dcf0 --- /dev/null +++ b/tests/test_dzonegit.py @@ -0,0 +1,130 @@ + +import pytest +import contextlib +import os +import subprocess +from pathlib import Path + +from dzonegit import * + +@contextlib.contextmanager +def cwd(directory): + curdir = os.getcwd() + try: + os.chdir(Path(__file__).parent / directory) + yield + finally: + os.chdir(curdir) + +def test_get_head_empty(): + with cwd("emptyrepo"): + assert get_head() == "4b825dc642cb6eb9a060e54bf8d69288fbee4904" + with cwd("testrepo"): + assert get_head() == "ca6f091201985bfb3e047b3bba8632235e1c0486" + +def test_check_whitespace_errors(): + with cwd("emptyrepo"): + with pytest.raises(ValueError): + check_whitespace_errors(get_head()) + with cwd("testrepo"): + check_whitespace_errors(get_head()) + +def test_get_file_contents(): + with cwd("testrepo"): + assert get_file_contents('dummy') == "dummy\n" + with pytest.raises(subprocess.CalledProcessError): + get_file_contents('nonexistent') + +def test_compile_zone(): + testzone = """ +$ORIGIN example.com. +@ 60 IN SOA ns hostmaster ( + 1234567890 ; serial + 3600 ; refresh (1 hour) + 900 ; retry (15 minutes) + 1814400 ; expire (3 weeks) + 60 ; minimum (1 minute) + ) + 60 IN NS ns +ns.example.com. 60 IN A 192.0.2.1 +""" + r = compile_zone("example.org", testzone) + assert not r.success + assert r.zonehash is None + assert r.stderr + r = compile_zone("example.com", testzone) + assert r.success + assert r.serial == "1234567890" + assert r.zonehash + r2 = compile_zone("example.com", testzone + "\n\n; some comment") + assert r.zonehash == r2.zonehash + + +def test_is_serial_increased(): + assert is_serial_increased(1234567890, "2018010100") + assert is_serial_increased("2018010100", "4018010100") + assert is_serial_increased("4018010100", "1234567890") + assert not is_serial_increased(2018010100, "1234567890") + + +def test_get_altered_files(): + with cwd("testrepo"): + files = set(get_altered_files("HEAD", "A")) + assert files == set([ + Path("zones/example.org.zone") + ]) + +def test_get_zone_origin(): + testzone = """ +$ORIGIN examPle.com. ;coment +@ 60 IN SOA ns hostmaster 1 60 60 60 60 + 60 IN NS ns +ns.example.com. 60 IN A 192.0.2.1 +$ORIGIN sub +$ORIGIN subsub.example.com. +$ORIGIN example.com. +""" + assert "example.com" == get_zone_origin(testzone) + testzone = """ +@ 60 IN SOA ns hostmaster 1 60 60 60 60 + 60 IN NS ns +ns.example.com. 60 IN A 192.0.2.1 +""" + assert get_zone_origin(testzone) is None + testzone = """ +@ 60 IN SOA ns hostmaster 1 60 60 60 60 + 60 IN NS ns +ns.example.com. 60 IN A 192.0.2.1 +$ORIGIN sub.example.com. +""" + assert get_zone_origin(testzone, 4) is None + + +def test_get_zone_name(): + testzone = """ +$ORIGIN eXample.com. ;coment +@ 60 IN SOA ns hostmaster 1 60 60 60 60 + 60 IN NS ns +ns.example.com. 60 IN A 192.0.2.1 +""" + assert "example.com" == get_zone_name("zones/example.com.zone", "") + assert "example.com" == get_zone_name("zones/example.com.zone", testzone) + with pytest.raises(ValueError): + get_zone_name("zones/example.org.zone", testzone) + testzone = """ +$ORIGIN 240/28.2.0.192.in-addr.arpa. +@ 60 IN SOA ns hostmaster 1 60 60 60 60 + 60 IN NS ns +ns 60 IN A 192.0.2.1 +""" + assert "240/28.2.0.192.in-addr.arpa" == get_zone_name( + "zones/240-28.2.0.192.in-addr.arpa.zone", + testzone + ) + +def test_check_updated_zones(): + with cwd("emptyrepo"): + with pytest.raises(ValueError): + check_updated_zones(get_head()) + with cwd("testrepo"): + check_updated_zones(get_head())