diff --git a/mdb/management/commands/dhcp-sync.py b/mdb/management/commands/dhcp-sync.py deleted file mode 100644 index 3b60a5c..0000000 --- a/mdb/management/commands/dhcp-sync.py +++ /dev/null @@ -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 [--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='
%s
' % 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='
%s
' % 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='')) diff --git a/mdb/management/commands/dhcp_sync.py b/mdb/management/commands/dhcp_sync.py new file mode 100644 index 0000000..517c1b3 --- /dev/null +++ b/mdb/management/commands/dhcp_sync.py @@ -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 [--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, + ) diff --git a/mdb/services/dhcp_sync.py b/mdb/services/dhcp_sync.py new file mode 100644 index 0000000..78e7303 --- /dev/null +++ b/mdb/services/dhcp_sync.py @@ -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 diff --git a/tests/test_dhcp_sync.py b/tests/test_dhcp_sync.py new file mode 100644 index 0000000..4b8c382 --- /dev/null +++ b/tests/test_dhcp_sync.py @@ -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" diff --git a/tests/test_dhcp_sync_command.py b/tests/test_dhcp_sync_command.py new file mode 100644 index 0000000..89c5443 --- /dev/null +++ b/tests/test_dhcp_sync_command.py @@ -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