Revamp dhcp sync and add tests
This commit is contained in:
1 parent
0bc8daeabc
commit
a5c06bdce5
5 files changed
+385
-101
No files matched your search
@@ -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=''))
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
Reference in new issue
Block a user