feat: apply node timezone before scheduled jobs
Read an optional configs/timezone declaration and refresh active cron after a timezone change. Report a failed cron restart instead of claiming success. Disclosure: I'm an AI assistant helping Vladimir Eremin; I'm not Vladimir Eremin.
This commit is contained in:
1 parent
99ff1f1433
commit
fe86e55f4a
3 files changed
+106
-1
No files matched your search
@@ -6,7 +6,7 @@ from engine import overlay
|
|||||||
|
|
||||||
METADATA = Meta(summary="Run every configure/* action in stable order.",
|
METADATA = Meta(summary="Run every configure/* action in stable order.",
|
||||||
args=[Flag("--dry-run", "Pass --dry-run to sub-actions that support it.")])
|
args=[Flag("--dry-run", "Pass --dry-run to sub-actions that support it.")])
|
||||||
ORDER = ["configure/base", "configure/ufw", "configure/crowdsec",
|
ORDER = ["configure/base", "configure/timezone", "configure/ufw", "configure/crowdsec",
|
||||||
"configure/cron", "configure/systemd", "configure/routing"]
|
"configure/cron", "configure/systemd", "configure/routing"]
|
||||||
|
|
||||||
def run(ctx, args):
|
def run(ctx, args):
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Apply the optional node timezone before installing scheduled jobs."""
|
||||||
|
from pathlib import Path
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
|
from engine.descriptor import Meta, Flag
|
||||||
|
|
||||||
|
METADATA = Meta(summary="Apply configs/timezone and refresh cron when the timezone changes.",
|
||||||
|
args=[Flag("--dry-run", "Validate and preview without changing the host.")])
|
||||||
|
|
||||||
|
|
||||||
|
def run(ctx, args):
|
||||||
|
source = Path(ctx.paths.configs) / "timezone"
|
||||||
|
if not source.is_file():
|
||||||
|
ctx.log.info("No configs/timezone declaration; preserving host timezone.")
|
||||||
|
return
|
||||||
|
zone = source.read_text().strip()
|
||||||
|
try:
|
||||||
|
ZoneInfo(zone)
|
||||||
|
except (ValueError, ZoneInfoNotFoundError):
|
||||||
|
ctx.log.error("configs/timezone must name an installed IANA timezone.")
|
||||||
|
raise SystemExit(1)
|
||||||
|
current = ctx.sys.run(["timedatectl", "show", "--property=Timezone", "--value"], capture=True).stdout.strip()
|
||||||
|
if current == zone:
|
||||||
|
ctx.log.info(f"Timezone already set to {zone}.")
|
||||||
|
return
|
||||||
|
if args.dry_run:
|
||||||
|
ctx.log.info(f"[DRY-RUN] would change timezone from {current} to {zone} and refresh active cron.")
|
||||||
|
return
|
||||||
|
ctx.sys.sudo(["timedatectl", "set-timezone", zone])
|
||||||
|
if ctx.sys.service_is_active("cron") and not ctx.sys.restart("cron"):
|
||||||
|
ctx.log.error("Timezone changed, but cron did not become active after restart.")
|
||||||
|
raise SystemExit(1)
|
||||||
|
ctx.log.success(f"Timezone set to {zone}.")
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
spec = importlib.util.spec_from_file_location('timezone_action', ROOT / 'actions/configure/timezone.py')
|
||||||
|
action = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(action)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTimezone(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
self.source = Path(self.tmp.name) / 'timezone'
|
||||||
|
self.zone = 'UTC'
|
||||||
|
self.cron_refreshes = []
|
||||||
|
def read(*args, **kwargs):
|
||||||
|
return SimpleNamespace(stdout=self.zone+'\n')
|
||||||
|
def apply(args):
|
||||||
|
self.zone = args[-1]
|
||||||
|
self.system = Mock()
|
||||||
|
self.system.run.side_effect = read
|
||||||
|
self.system.sudo.side_effect = apply
|
||||||
|
self.system.service_is_active.return_value = True
|
||||||
|
def restart(name):
|
||||||
|
self.cron_refreshes.append(name)
|
||||||
|
return True
|
||||||
|
self.system.restart.side_effect = restart
|
||||||
|
self.ctx = SimpleNamespace(paths=SimpleNamespace(configs=self.tmp.name), sys=self.system, log=Mock())
|
||||||
|
|
||||||
|
def test_timezone_transition_refreshes_cron_once_and_repeat_is_noop(self):
|
||||||
|
self.source.write_text('Europe/Moscow\n')
|
||||||
|
action.run(self.ctx, SimpleNamespace(dry_run=False))
|
||||||
|
self.assertEqual(self.zone, 'Europe/Moscow')
|
||||||
|
self.assertEqual(self.cron_refreshes, ['cron'])
|
||||||
|
action.run(self.ctx, SimpleNamespace(dry_run=False))
|
||||||
|
self.assertEqual(self.cron_refreshes, ['cron'])
|
||||||
|
|
||||||
|
def test_preview_and_absent_config_preserve_host(self):
|
||||||
|
action.run(self.ctx, SimpleNamespace(dry_run=False))
|
||||||
|
self.source.write_text('Europe/Moscow\n')
|
||||||
|
action.run(self.ctx, SimpleNamespace(dry_run=True))
|
||||||
|
self.assertEqual(self.zone, 'UTC')
|
||||||
|
self.assertEqual(self.cron_refreshes, [])
|
||||||
|
self.system.sudo.assert_not_called()
|
||||||
|
|
||||||
|
def test_failed_cron_restart_is_reported_as_failure(self):
|
||||||
|
self.source.write_text('Europe/Moscow\n')
|
||||||
|
self.system.restart.side_effect = None
|
||||||
|
self.system.restart.return_value = False
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
action.run(self.ctx, SimpleNamespace(dry_run=False))
|
||||||
|
self.ctx.log.success.assert_not_called()
|
||||||
|
self.assertEqual(self.zone, 'Europe/Moscow')
|
||||||
|
|
||||||
|
def test_inactive_cron_is_not_started(self):
|
||||||
|
self.source.write_text('Europe/Moscow\n')
|
||||||
|
self.system.service_is_active.return_value = False
|
||||||
|
action.run(self.ctx, SimpleNamespace(dry_run=False))
|
||||||
|
self.assertEqual(self.zone, 'Europe/Moscow')
|
||||||
|
self.assertEqual(self.cron_refreshes, [])
|
||||||
|
|
||||||
|
def test_invalid_zone_does_not_mutate_host(self):
|
||||||
|
self.source.write_text('../not-a-zone\n')
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
action.run(self.ctx, SimpleNamespace(dry_run=False))
|
||||||
|
self.system.sudo.assert_not_called()
|
||||||
|
self.assertEqual(self.cron_refreshes, [])
|
||||||
Reference in new issue
Block a user