From fe86e55f4a53a5a1d9593d038ddbcd9c2060f5a3 Mon Sep 17 00:00:00 2001 From: Vladimir Eremin Date: Sun, 11 Oct 2026 02:07:13 +0300 Subject: [PATCH] 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. --- actions/configure.py | 2 +- actions/configure/timezone.py | 33 ++++++++++++++++ tests/test_timezone.py | 72 +++++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 actions/configure/timezone.py create mode 100644 tests/test_timezone.py diff --git a/actions/configure.py b/actions/configure.py index 768fc5f..2e5965f 100644 --- a/actions/configure.py +++ b/actions/configure.py @@ -6,7 +6,7 @@ from engine import overlay METADATA = Meta(summary="Run every configure/* action in stable order.", 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"] def run(ctx, args): diff --git a/actions/configure/timezone.py b/actions/configure/timezone.py new file mode 100644 index 0000000..30a75e1 --- /dev/null +++ b/actions/configure/timezone.py @@ -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}.") diff --git a/tests/test_timezone.py b/tests/test_timezone.py new file mode 100644 index 0000000..12de561 --- /dev/null +++ b/tests/test_timezone.py @@ -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, [])