Revamp dhcp sync and add tests

This commit is contained in:
ponas committed 2026-03-19 01:24:54 +01:00
1 parent 0bc8daeabc
commit a5c06bdce5
5 files changed
+385 -101

No files matched your search

-101
View File
@@ -1,101 +0,0 @@
import os
import sys
from difflib import context_diff
from django.core.management.base import BaseCommand
from django.core.mail import mail_admins
from subprocess import getstatusoutput
from mdb.models import DhcpConfig
class Command(BaseCommand):
args = '--output <path> [--force] [--restart]'
help = 'Generates the DHCP configuration'
def add_arguments(self, parser):
parser.add_argument('--output',
action='store',
dest='output',
default=None,
help='Path to dhcpd.conf, defaults to stdout'),
parser.add_argument('--force',
action='store_true',
dest='force',
default=False,
help='Write configuration even if serial is unchanged.'),
parser.add_argument('--restart',
action='store_true',
dest='restart',
default=False,
help='Restart dhcp3-server if serial is changed'),
def handle(self, *args, **options):
output = options['output'] if options['output'] else sys.stdout
force = options['force']
restart = options['restart']
restart_command = "/usr/sbin/service isc-dhcp-server restart"
config = DhcpConfig.objects.filter(name="default").get()
# do we need to do anything?
if config.serial == config.active_serial and not force:
print("Nothing to do, serial is the same: %s" % config.serial)
sys.exit(0)
# write to stdout
if output == sys.stdout:
print(config.dhcpd_configuration())
print('Not writing to file, serial not updated')
return
# read current file contents
try:
with open(output, 'r') as f:
old = f.read()
except IOError:
old = ''
# write to file
new = config.dhcpd_configuration()
with open(output, 'w') as f:
f.write(new)
diff = self.calculate_diff(config, new, old)
print(diff)
# update serial
old_serial = config.active_serial
config.active_serial = config.serial
config.save()
# restart the dhcp server?
if restart:
status, output = getstatusoutput(restart_command)
if status != 0:
message = (
"Failed to restart dhcp3-server, please inspect...\n\n" +
"%s@%s# %s" % (os.getlogin(), os.uname()[1], restart_command) +
"\n%s\n\n%s" % (output, diff))
mail_admins(
subject="FAIL: dhcpd %s -> %s" % (old_serial, config.active_serial),
message=message,
html_message='<pre>%s</pre>' % message)
sys.exit(1)
else:
message = (
"Updated dhcpd configuration to serial %d\n\n" % config.active_serial +
"%s" % diff)
mail_admins(
subject="Success: dhcpd %s -> %s" % (old_serial, config.active_serial),
message=message,
html_message='<pre>%s</pre>' % message)
sys.exit(0)
def calculate_diff(self, config, new, old):
return "\n".join(context_diff(
a=old.splitlines(),
b=new.splitlines(),
fromfile=str(config.active_serial),
tofile=str(config.serial),
lineterm=''))
+82
View File
@@ -0,0 +1,82 @@
import os
from django.core.management.base import BaseCommand
from mdb.services import discord
from mdb.services.dhcp_sync import DhcpSyncService
class Command(BaseCommand):
args = "--output <path> [--force] [--restart]"
help = "Generates the DHCP configuration"
def add_arguments(self, parser):
parser.add_argument(
"--output",
action="store",
dest="output",
default=None,
help="Path to dhcpd.conf, defaults to stdout",
)
parser.add_argument(
"--force",
action="store_true",
dest="force",
default=False,
help="Write configuration even if serial is unchanged.",
)
parser.add_argument(
"--restart",
action="store_true",
dest="restart",
default=False,
help="Restart isc-dhcp-server if serial is changed",
)
def handle(self, *args, **options):
output = options["output"]
force = options["force"]
restart = options["restart"]
service = DhcpSyncService(force=force)
config = service.get_config()
if not service.needs_update(config):
self.stdout.write("Nothing to do, serial is the same: %s\n" % config.serial)
return
if output is None:
self.stdout.write(config.dhcpd_configuration())
self.stdout.write("Not writing to file, serial not updated\n")
return
old_serial, diff = service.write_config(config, output)
self.stdout.write(diff + "\n")
if restart:
self.restart_and_notify(service, config, old_serial, diff)
def restart_and_notify(self, service, config, old_serial, diff):
self.stdout.write("restarting isc-dhcp-server... ")
success, output = service.restart_service()
if not success:
self.stdout.write("fail\n")
message = (
"Failed to restart isc-dhcp-server, please inspect...\n\n"
"%s@%s$ %s\n%s" % (os.getlogin(), os.uname()[1], service.restart_command, output)
)
discord.notify(
subject="⚠️ FAIL: dhcpd %s -> %s" % (old_serial, config.active_serial),
message=message,
preformatted=diff or None,
)
raise SystemExit(1)
self.stdout.write("ok\n")
message = "Updated dhcpd configuration to serial %d" % config.active_serial
discord.notify(
subject="✅ Success: dhcpd %s -> %s" % (old_serial, config.active_serial),
message=message,
preformatted=diff or None,
)
+59
View File
@@ -0,0 +1,59 @@
from difflib import context_diff
from subprocess import getstatusoutput
from mdb.models import DhcpConfig
class DhcpSyncService:
restart_command = "/usr/sbin/service isc-dhcp-server restart"
def __init__(self, force=False):
self.force = force
def get_config(self):
return DhcpConfig.objects.filter(name="default").get()
def needs_update(self, config):
return config.serial != config.active_serial or self.force
def calculate_diff(self, config, new, old):
return "\n".join(
context_diff(
a=old.splitlines(),
b=new.splitlines(),
fromfile=str(config.active_serial),
tofile=str(config.serial),
lineterm="",
)
)
def write_config(self, config, output_path):
"""Write new DHCP config to file and update active_serial.
Returns (old_serial, diff).
"""
try:
with open(output_path, "r") as f:
old = f.read()
except IOError:
old = ""
new = config.dhcpd_configuration()
with open(output_path, "w") as f:
f.write(new)
diff = self.calculate_diff(config, new, old)
old_serial = config.active_serial
config.active_serial = config.serial
config.save()
return old_serial, diff
def restart_service(self):
"""Run the DHCP server restart command.
Returns (success, output).
"""
status, output = getstatusoutput(self.restart_command)
return status == 0, output
+113
View File
@@ -0,0 +1,113 @@
from unittest.mock import MagicMock, patch
import pytest
from mdb.services.dhcp_sync import DhcpSyncService
def make_config(serial=2, active_serial=1):
config = MagicMock()
config.serial = serial
config.active_serial = active_serial
config.dhcpd_configuration.return_value = "# dhcpd config\nsubnet 10.0.0.0 netmask 255.255.255.0 {}\n"
return config
@pytest.fixture
def service():
return DhcpSyncService()
# --- needs_update ---
def test_needs_update_true_when_serials_differ(service):
config = make_config(serial=2, active_serial=1)
assert service.needs_update(config) is True
def test_needs_update_false_when_serials_match(service):
config = make_config(serial=1, active_serial=1)
assert service.needs_update(config) is False
def test_needs_update_true_when_force_even_if_serials_match():
svc = DhcpSyncService(force=True)
config = make_config(serial=1, active_serial=1)
assert svc.needs_update(config) is True
# --- calculate_diff ---
def test_calculate_diff_identical_returns_empty(service):
config = make_config()
assert service.calculate_diff(config, "line\n", "line\n") == ""
def test_calculate_diff_format(service):
config = make_config(serial=2, active_serial=1)
diff = service.calculate_diff(config, "new line\n", "old line\n")
assert "*** 1" in diff
assert "--- 2" in diff
assert "! old line" in diff
assert "! new line" in diff
# --- write_config ---
def test_write_config_creates_file_with_config_contents(service, tmp_path):
config = make_config()
output_path = str(tmp_path / "dhcpd.conf")
service.write_config(config, output_path)
with open(output_path) as f:
assert f.read() == config.dhcpd_configuration.return_value
def test_write_config_updates_active_serial(service, tmp_path):
config = make_config(serial=5, active_serial=3)
output_path = str(tmp_path / "dhcpd.conf")
service.write_config(config, output_path)
assert config.active_serial == 5
config.save.assert_called_once()
def test_write_config_returns_old_serial(service, tmp_path):
config = make_config(serial=2, active_serial=1)
output_path = str(tmp_path / "dhcpd.conf")
old_serial, diff = service.write_config(config, output_path)
assert old_serial == 1
def test_write_config_diff_reflects_changes(service, tmp_path):
output_path = str(tmp_path / "dhcpd.conf")
with open(output_path, "w") as f:
f.write("old content\n")
config = make_config(serial=2, active_serial=1)
config.dhcpd_configuration.return_value = "new content\n"
old_serial, diff = service.write_config(config, output_path)
assert "old content" in diff
assert "new content" in diff
# --- restart_service ---
def test_restart_service_returns_true_on_success(service):
with patch("mdb.services.dhcp_sync.getstatusoutput", return_value=(0, "")):
success, output = service.restart_service()
assert success is True
assert output == ""
def test_restart_service_returns_false_on_failure(service):
with patch("mdb.services.dhcp_sync.getstatusoutput", return_value=(1, "Job failed")):
success, output = service.restart_service()
assert success is False
assert output == "Job failed"
+131
View File
@@ -0,0 +1,131 @@
from io import StringIO
from unittest.mock import MagicMock, patch
import pytest
from mdb.management.commands.dhcp_sync import Command as CommandClass
@pytest.fixture
def cmd():
c = CommandClass()
c.stdout = StringIO()
c.stderr = StringIO()
return c
def make_service(serial=2, active_serial=1, restart_success=True, restart_output=""):
svc = MagicMock()
svc.force = False
svc.restart_command = "/usr/sbin/service isc-dhcp-server restart"
config = MagicMock()
config.serial = serial
config.active_serial = active_serial
config.dhcpd_configuration.return_value = "# dhcpd config\n"
svc.get_config.return_value = config
svc.needs_update.return_value = serial != active_serial
svc.write_config.return_value = (active_serial, "- old\n+ new")
svc.restart_service.return_value = (restart_success, restart_output)
return svc, config
# --- handle: nothing to do ---
def test_handle_prints_nothing_to_do_when_no_update_needed(cmd):
svc, config = make_service(serial=1, active_serial=1)
svc.needs_update.return_value = False
with patch("mdb.management.commands.dhcp_sync.DhcpSyncService", return_value=svc):
cmd.handle(output=None, force=False, restart=False)
assert "Nothing to do" in cmd.stdout.getvalue()
# --- handle: stdout mode ---
def test_handle_prints_config_to_stdout_when_no_output_path(cmd):
svc, config = make_service()
with patch("mdb.management.commands.dhcp_sync.DhcpSyncService", return_value=svc):
cmd.handle(output=None, force=False, restart=False)
assert "# dhcpd config" in cmd.stdout.getvalue()
assert "Not writing to file" in cmd.stdout.getvalue()
def test_handle_does_not_write_file_when_no_output_path(cmd):
svc, config = make_service()
with patch("mdb.management.commands.dhcp_sync.DhcpSyncService", return_value=svc):
cmd.handle(output=None, force=False, restart=False)
svc.write_config.assert_not_called()
# --- handle: file mode ---
def test_handle_calls_write_config_when_output_given(cmd, tmp_path):
svc, config = make_service()
output_path = str(tmp_path / "dhcpd.conf")
with patch("mdb.management.commands.dhcp_sync.DhcpSyncService", return_value=svc):
cmd.handle(output=output_path, force=False, restart=False)
svc.write_config.assert_called_once_with(config, output_path)
def test_handle_does_not_restart_when_restart_false(cmd, tmp_path):
svc, config = make_service()
output_path = str(tmp_path / "dhcpd.conf")
with patch("mdb.management.commands.dhcp_sync.DhcpSyncService", return_value=svc):
with patch.object(cmd, "restart_and_notify") as mock_restart:
cmd.handle(output=output_path, force=False, restart=False)
mock_restart.assert_not_called()
def test_handle_calls_restart_and_notify_when_restart_true(cmd, tmp_path):
svc, config = make_service()
output_path = str(tmp_path / "dhcpd.conf")
with patch("mdb.management.commands.dhcp_sync.DhcpSyncService", return_value=svc):
with patch.object(cmd, "restart_and_notify") as mock_restart:
cmd.handle(output=output_path, force=False, restart=True)
mock_restart.assert_called_once()
# --- restart_and_notify ---
def test_restart_and_notify_success_writes_ok(cmd):
svc, config = make_service(restart_success=True)
with patch("mdb.management.commands.dhcp_sync.discord"):
cmd.restart_and_notify(svc, config, old_serial=1, diff="- old\n+ new")
assert "ok" in cmd.stdout.getvalue()
def test_restart_and_notify_success_calls_discord(cmd):
svc, config = make_service(restart_success=True)
with patch("mdb.management.commands.dhcp_sync.discord") as mock_discord:
cmd.restart_and_notify(svc, config, old_serial=1, diff="- old\n+ new")
mock_discord.notify.assert_called_once()
assert "✅" in mock_discord.notify.call_args.kwargs["subject"]
def test_restart_and_notify_success_passes_diff_as_preformatted(cmd):
svc, config = make_service(restart_success=True)
with patch("mdb.management.commands.dhcp_sync.discord") as mock_discord:
cmd.restart_and_notify(svc, config, old_serial=1, diff="- old\n+ new")
assert mock_discord.notify.call_args.kwargs["preformatted"] == "- old\n+ new"
def test_restart_and_notify_failure_writes_fail_and_exits(cmd):
svc, config = make_service(restart_success=False, restart_output="Job failed")
with patch("mdb.management.commands.dhcp_sync.discord"):
with patch("os.getlogin", return_value="testuser"):
with pytest.raises(SystemExit) as exc:
cmd.restart_and_notify(svc, config, old_serial=1, diff="")
assert exc.value.code == 1
assert "fail" in cmd.stdout.getvalue()
def test_restart_and_notify_failure_discord_includes_command_and_output(cmd):
svc, config = make_service(restart_success=False, restart_output="Job failed")
with patch("mdb.management.commands.dhcp_sync.discord") as mock_discord:
with patch("os.getlogin", return_value="testuser"):
with pytest.raises(SystemExit):
cmd.restart_and_notify(svc, config, old_serial=1, diff="")
subject = mock_discord.notify.call_args.kwargs["subject"]
message = mock_discord.notify.call_args.kwargs["message"]
assert "⚠️" in subject
assert "isc-dhcp-server" in message
assert "Job failed" in message