fix(zone_sync): thaw and warn when a sync is stopped or killed
CI / prek (push) Successful in 15s
CI / tests (push) Successful in 41s
CI / deploy-sega (push) Successful in 15s

This commit is contained in:
ponas committed 2026-11-02 01:47:37 +01:00
1 parent 571523354e
commit ff27b75101
3 files changed
+289 -13

No files matched your search

+91 -8
View File
@@ -1,11 +1,30 @@
import getpass import getpass
import os import os
import signal
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from mdb.services import discord from mdb.services import discord
from mdb.services.zone_sync import ZoneSyncService from mdb.services.zone_sync import ZoneSyncService
# What stops a sync run by hand: a dropped ssh session, a kill, Ctrl-C.
STOPPING_SIGNALS = (signal.SIGHUP, signal.SIGTERM, signal.SIGINT)
class Interrupted(Exception):
def __init__(self, signum):
self.signum = signum
super().__init__(signal.Signals(signum).name)
def raise_interrupted(signum, frame):
raise Interrupted(signum)
def set_handlers(handler):
for signum in STOPPING_SIGNALS:
signal.signal(signum, handler)
class Command(BaseCommand): class Command(BaseCommand):
args = "[--force] [--debug]" args = "[--force] [--debug]"
@@ -46,6 +65,9 @@ class Command(BaseCommand):
os.path.isdir(service.checkzone_dir) or os.mkdir(service.checkzone_dir) os.path.isdir(service.checkzone_dir) or os.mkdir(service.checkzone_dir)
debug and (os.path.isdir(service.debug_dir) or os.mkdir(service.debug_dir)) debug and (os.path.isdir(service.debug_dir) or os.mkdir(service.debug_dir))
if not debug:
self.thaw_after_killed_sync(service)
if zone_name: if zone_name:
zone = service.find_zone(zone_name) zone = service.find_zone(zone_name)
if zone is None: if zone is None:
@@ -63,14 +85,75 @@ class Command(BaseCommand):
service.sync_zone(zone) service.sync_zone(zone)
return return
self.freeze(service, zones) previous = {signum: signal.getsignal(signum) for signum in STOPPING_SIGNALS}
try: try:
for zone in zones: # From the freeze to the thaw, a signal must not stop the sync in between:
service.sync_zone(zone) # ignored while rndc runs, it stops the writes for a reload and thaw.
except BaseException: set_handlers(signal.SIG_IGN)
service.reload() self.freeze(service, zones)
raise set_handlers(raise_interrupted)
self.reload_and_notify(service, service.changes) try:
for zone in zones:
service.sync_zone(zone)
except BaseException as e:
set_handlers(signal.SIG_IGN)
self.reload_after_interruption(service, zones, e)
if isinstance(e, Interrupted):
raise SystemExit(128 + e.signum) from e
raise
set_handlers(signal.SIG_IGN)
self.reload_and_notify(service, service.changes)
finally:
for signum, handler in previous.items():
signal.signal(signum, handler)
def thaw_after_killed_sync(self, service):
since = service.frozen_since()
if since is None:
return
self.stdout.write("a previous sync left the zones frozen, thawing... ")
summary = (
f"A zone sync killed after {since:%Y-%m-%d %H:%M} left every zone frozen"
)
success, failed_command, output = service.reload()
if not success:
self.stdout.write("fail\n")
self.notify_failure(
f"{summary}, and thawing them failed", failed_command, output, []
)
self.stdout.write("ok\n")
discord.notify(
subject="⚠️ Zones thawed after a killed zone sync",
message=f"{summary}, refusing dynamic updates until now. "
"They are reloaded and thawed.",
)
def reload_after_interruption(self, service, zones, error):
reason = str(error) if isinstance(error, Interrupted) else repr(error)
written = [z for z, c in service.changes.items() if c["success"]]
not_written = [z.domain_name for z in zones if z.domain_name not in written]
self.stdout.write(f"stopped by {reason}, reloading zones... ")
success, failed_command, output = service.reload()
if not success:
self.stdout.write("fail\n")
self.notify_failure(
f"Zone sync stopped by {reason}, and reloading the zones failed; "
"they may still be frozen",
failed_command,
output,
not_written,
)
self.stdout.write("ok\n")
message = f"Zone sync stopped by {reason}. The zones are reloaded and thawed."
if written:
message += "\n\nWritten:\n" + "\n".join("- " + z for z in written)
if not_written:
message += "\n\nNot written, run mdb-sync again:\n" + "\n".join(
"- " + z for z in not_written
)
discord.notify(subject="⚠️ Zone sync interrupted, zones thawed", message=message)
def freeze(self, service, zones): def freeze(self, service, zones):
self.stdout.write("freezing zones... ") self.stdout.write("freezing zones... ")
@@ -78,7 +161,7 @@ class Command(BaseCommand):
if not success: if not success:
self.stdout.write("fail\n") self.stdout.write("fail\n")
# A failed freeze may have frozen some zones before stopping. # A failed freeze may have frozen some zones before stopping.
service.run_commands([service.thaw_command]) service.thaw()
self.notify_failure( self.notify_failure(
"Failed to freeze zones, nothing was written", "Failed to freeze zones, nothing was written",
failed_command, failed_command,
+36 -2
View File
@@ -4,6 +4,7 @@ import shutil
import stat import stat
import sys import sys
import tempfile import tempfile
from datetime import datetime
from difflib import context_diff from difflib import context_diff
from itertools import chain from itertools import chain
from subprocess import getstatusoutput from subprocess import getstatusoutput
@@ -44,17 +45,50 @@ class ZoneSyncService:
return False, command, output return False, command, output
return True, "", "" return True, "", ""
@property
def frozen_marker(self):
"""Exists from just before rndc freeze until the zones are thawed.
A sync killed in between (SIGKILL, the OOM killer) leaves it behind, and the
next sync thaws the zones it finds frozen.
"""
return os.path.join(self.checkzone_dir, "zones-frozen")
def frozen_since(self):
"""When a sync that never thawed froze the zones, or None."""
try:
return datetime.fromtimestamp(os.path.getmtime(self.frozen_marker))
except FileNotFoundError:
return None
def freeze(self): def freeze(self):
"""Stop dynamic updates and flush each dynamic zone's journal to its file. """Stop dynamic updates and flush each dynamic zone's journal to its file.
Must run before zone files are written: freeze dumps the zone BIND Must run before zone files are written: freeze dumps the zone BIND
holds in memory, which would overwrite a file written earlier. holds in memory, which would overwrite a file written earlier.
""" """
with open(self.frozen_marker, "w") as f:
f.write(f"{os.getpid()}\n")
return self.run_commands([self.freeze_command]) return self.run_commands([self.freeze_command])
def thaw(self):
"""Resume dynamic updates, which a frozen zone refuses (ACME dns-01 among them)."""
result = self.run_commands([self.thaw_command])
if result[0]:
try:
os.unlink(self.frozen_marker)
except FileNotFoundError:
pass
return result
def reload(self): def reload(self):
"""Load the written zone files and resume dynamic updates.""" """Load the written zone files and resume dynamic updates.
return self.run_commands([self.reload_command, self.thaw_command])
Thaws even when the reload fails, and reports the first failure.
"""
reloaded = self.run_commands([self.reload_command])
thawed = self.thaw()
return reloaded if not reloaded[0] else thawed
def find_zone(self, name): def find_zone(self, name):
for model in (Domain, Ip4Subnet, Ip6Subnet): for model in (Domain, Ip4Subnet, Ip6Subnet):
+162 -3
View File
@@ -1,4 +1,5 @@
import os import os
import signal
from io import StringIO from io import StringIO
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -8,6 +9,13 @@ from mdb.management.commands.zone_sync import Command as CommandClass
from mdb.services.zone_sync import ZoneSyncService from mdb.services.zone_sync import ZoneSyncService
@pytest.fixture(autouse=True)
def checkzone_dir(tmp_path, monkeypatch):
"""Where the frozen marker lives; not the real /tmp/mdb-checkzone."""
monkeypatch.setattr(ZoneSyncService, "checkzone_dir", str(tmp_path))
return tmp_path
@pytest.fixture @pytest.fixture
def cmd(): def cmd():
c = CommandClass() c = CommandClass()
@@ -185,7 +193,7 @@ def test_handle_writes_nothing_when_freeze_fails(cmd, db_domain):
patch.object( patch.object(
ZoneSyncService, "freeze", return_value=(False, "rndc freeze", "no") ZoneSyncService, "freeze", return_value=(False, "rndc freeze", "no")
), ),
patch.object(ZoneSyncService, "run_commands") as mock_run, patch.object(ZoneSyncService, "thaw", return_value=(True, "", "")) as thaw,
patch("mdb.management.commands.zone_sync.discord") as mock_discord, patch("mdb.management.commands.zone_sync.discord") as mock_discord,
patch("getpass.getuser", return_value="testuser"), patch("getpass.getuser", return_value="testuser"),
pytest.raises(SystemExit), pytest.raises(SystemExit),
@@ -193,7 +201,7 @@ def test_handle_writes_nothing_when_freeze_fails(cmd, db_domain):
cmd.handle(debug=False, force=False, zone=None) cmd.handle(debug=False, force=False, zone=None)
assert not os.path.exists(db_domain.domain_filename) assert not os.path.exists(db_domain.domain_filename)
mock_run.assert_called_once_with([ZoneSyncService.thaw_command]) thaw.assert_called_once()
assert "- example.org" in mock_discord.notify.call_args.kwargs["message"] assert "- example.org" in mock_discord.notify.call_args.kwargs["message"]
@@ -201,10 +209,161 @@ def test_handle_writes_nothing_when_freeze_fails(cmd, db_domain):
def test_handle_thaws_when_sync_raises(cmd, db_domain): def test_handle_thaws_when_sync_raises(cmd, db_domain):
with ( with (
patch.object(ZoneSyncService, "freeze", return_value=(True, "", "")), patch.object(ZoneSyncService, "freeze", return_value=(True, "", "")),
patch.object(ZoneSyncService, "reload") as mock_reload, patch.object(
ZoneSyncService, "reload", return_value=(True, "", "")
) as mock_reload,
patch.object(ZoneSyncService, "sync_zone", side_effect=RuntimeError), patch.object(ZoneSyncService, "sync_zone", side_effect=RuntimeError),
patch("mdb.management.commands.zone_sync.discord"),
pytest.raises(RuntimeError), pytest.raises(RuntimeError),
): ):
cmd.handle(debug=False, force=False, zone=None) cmd.handle(debug=False, force=False, zone=None)
mock_reload.assert_called_once() mock_reload.assert_called_once()
# --- stopped and killed syncs ---
def rndc(results=None):
"""Patch rndc: every command succeeds unless results maps it to (status, output)."""
results = results or {}
calls = []
def run(command):
calls.append(command.rsplit(" ", 1)[-1])
return results.get(command, (0, ""))
return calls, patch("mdb.services.zone_sync.getstatusoutput", side_effect=run)
def test_reload_thaws_even_when_reload_fails(service):
calls, rndc_patch = rndc({ZoneSyncService.reload_command: (1, "timed out")})
with rndc_patch:
success, failed_command, output = service.reload()
assert calls == ["reload", "thaw"]
assert (success, failed_command, output) == (
False,
ZoneSyncService.reload_command,
"timed out",
)
def test_marker_exists_only_while_frozen(service):
calls, rndc_patch = rndc()
with rndc_patch:
service.freeze()
assert service.frozen_since() is not None
service.reload()
assert service.frozen_since() is None
def test_marker_stays_when_thaw_fails(service):
calls, rndc_patch = rndc({ZoneSyncService.thaw_command: (1, "no")})
with rndc_patch:
service.freeze()
service.reload()
assert service.frozen_since() is not None
@pytest.mark.django_db
@pytest.mark.parametrize("signum", [signal.SIGHUP, signal.SIGTERM, signal.SIGINT])
def test_handle_thaws_and_warns_when_stopped_by_signal(cmd, db_domain, signum):
def killed(zone):
os.kill(os.getpid(), signum)
calls, rndc_patch = rndc()
previous = signal.getsignal(signum)
with (
rndc_patch,
patch.object(ZoneSyncService, "sync_zone", side_effect=killed),
patch("mdb.management.commands.zone_sync.discord") as mock_discord,
pytest.raises(SystemExit) as exc,
):
cmd.handle(debug=False, force=False, zone=None)
assert exc.value.code == 128 + signum
assert calls == ["freeze", "reload", "thaw"]
assert ZoneSyncService().frozen_since() is None
kwargs = mock_discord.notify.call_args.kwargs
assert kwargs["subject"] == "⚠️ Zone sync interrupted, zones thawed"
assert signal.Signals(signum).name in kwargs["message"]
assert "Not written, run mdb-sync again:\n- example.org" in kwargs["message"]
assert signal.getsignal(signum) == previous
@pytest.mark.django_db
def test_handle_ignores_signals_while_rndc_runs(cmd, db_domain):
seen = []
def run(commands):
seen.append((commands, signal.getsignal(signal.SIGHUP)))
return True, "", ""
with (
patch.object(ZoneSyncService, "run_commands", side_effect=run),
patch("mdb.management.commands.zone_sync.discord"),
):
cmd.handle(debug=False, force=False, zone=None)
# Signals still stop named-checkzone and the writes, between freeze and reload.
assert seen == [
([ZoneSyncService.freeze_command], signal.SIG_IGN),
([ZoneSyncService.reload_command], signal.SIG_IGN),
([ZoneSyncService.thaw_command], signal.SIG_IGN),
]
@pytest.mark.django_db
def test_handle_warns_when_reload_after_signal_fails(cmd, db_domain):
calls, rndc_patch = rndc({ZoneSyncService.thaw_command: (1, "no")})
with (
rndc_patch,
patch.object(
ZoneSyncService,
"sync_zone",
side_effect=lambda zone: os.kill(os.getpid(), signal.SIGHUP),
),
patch("mdb.management.commands.zone_sync.discord") as mock_discord,
patch("getpass.getuser", return_value="testuser"),
pytest.raises(SystemExit),
):
cmd.handle(debug=False, force=False, zone=None)
kwargs = mock_discord.notify.call_args.kwargs
assert kwargs["subject"] == "⚠️ FAIL: bind"
assert "may still be frozen" in kwargs["message"]
assert ZoneSyncService().frozen_since() is not None
@pytest.mark.django_db
def test_handle_thaws_zones_a_killed_sync_left_frozen(cmd, checkzone_dir):
(checkzone_dir / "zones-frozen").write_text("1234\n")
calls, rndc_patch = rndc()
with (
rndc_patch,
patch("mdb.management.commands.zone_sync.discord") as mock_discord,
):
cmd.handle(debug=False, force=False, zone=None)
assert calls == ["reload", "thaw"]
assert ZoneSyncService().frozen_since() is None
kwargs = mock_discord.notify.call_args.kwargs
assert kwargs["subject"] == "⚠️ Zones thawed after a killed zone sync"
assert "left every zone frozen" in kwargs["message"]
@pytest.mark.django_db
def test_handle_stops_when_thawing_a_killed_sync_fails(cmd, db_domain, checkzone_dir):
(checkzone_dir / "zones-frozen").write_text("1234\n")
calls, rndc_patch = rndc({ZoneSyncService.reload_command: (1, "no")})
with (
rndc_patch,
patch("mdb.management.commands.zone_sync.discord") as mock_discord,
patch("getpass.getuser", return_value="testuser"),
pytest.raises(SystemExit),
):
cmd.handle(debug=False, force=False, zone=None)
assert calls == ["reload", "thaw"] # nothing frozen again, nothing written
assert not os.path.exists(db_domain.domain_filename)
assert "thawing them failed" in mock_discord.notify.call_args.kwargs["message"]