diff --git a/supervisor/core.py b/supervisor/core.py index e95fb26a2..0f77bd3b9 100644 --- a/supervisor/core.py +++ b/supervisor/core.py @@ -16,6 +16,7 @@ from .const import ( CoreState, ) from .coresys import CoreSys, CoreSysAttributes +from .dbus.const import StopUnitMode from .exceptions import ( HassioError, HomeAssistantCrashError, @@ -423,11 +424,27 @@ class Core(CoreSysAttributes): await self.sys_host.control.set_timezone(timezone) # Calculate if system time is out of sync - delta = data.dt_utc - utcnow() - if delta <= timedelta(days=3) or self.sys_host.info.dt_synchronized: + delta = abs(data.dt_utc - utcnow()) + if delta <= timedelta(hours=1) or self.sys_host.info.dt_synchronized: return - _LOGGER.warning("System time/date shift over more than 3 days found!") + _LOGGER.warning("System time/date shift over more than 1 hour detected!") + + if self.sys_host.info.use_ntp: + # Stop timesyncd if NTP is enabled, as set_time is blocked while it runs. + _LOGGER.info("Stopping systemd-timesyncd to allow manual time adjustment") + await self.sys_dbus.systemd.stop_unit( + "systemd-timesyncd.service", StopUnitMode.REPLACE + ) + # Keep service disabled and create a repair issue + self.sys_resolution.create_issue( + IssueType.NTP_SYNC_FAILED, + ContextType.SYSTEM, + suggestions=[SuggestionType.ENABLE_NTP], + ) + # We need to wait a bit for the service to stop. + await asyncio.sleep(1) + await self.sys_host.control.set_datetime(data.dt_utc) await self.sys_supervisor.check_connectivity() diff --git a/supervisor/resolution/const.py b/supervisor/resolution/const.py index 6142743ba..dd362a2e7 100644 --- a/supervisor/resolution/const.py +++ b/supervisor/resolution/const.py @@ -100,6 +100,7 @@ class IssueType(StrEnum): MOUNT_FAILED = "mount_failed" MULTIPLE_DATA_DISKS = "multiple_data_disks" NO_CURRENT_BACKUP = "no_current_backup" + NTP_SYNC_FAILED = "ntp_sync_failed" PWNED = "pwned" REBOOT_REQUIRED = "reboot_required" SECURITY = "security" @@ -114,6 +115,7 @@ class SuggestionType(StrEnum): CLEAR_FULL_BACKUP = "clear_full_backup" CREATE_FULL_BACKUP = "create_full_backup" DISABLE_BOOT = "disable_boot" + ENABLE_NTP = "enable_ntp" EXECUTE_REBOOT = "execute_reboot" EXECUTE_REBUILD = "execute_rebuild" EXECUTE_RELOAD = "execute_reload" diff --git a/supervisor/resolution/fixups/system_enable_ntp.py b/supervisor/resolution/fixups/system_enable_ntp.py new file mode 100644 index 000000000..c5780e568 --- /dev/null +++ b/supervisor/resolution/fixups/system_enable_ntp.py @@ -0,0 +1,41 @@ +"""Enable NTP fixup.""" + +import logging + +from ...coresys import CoreSys +from ...dbus.const import StartUnitMode +from ..const import ContextType, IssueType, SuggestionType +from .base import FixupBase + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +def setup(coresys: CoreSys) -> FixupBase: + """Check setup function.""" + return FixupSystemEnableNTP(coresys) + + +class FixupSystemEnableNTP(FixupBase): + """Storage class for fixup.""" + + async def process_fixup(self, reference: str | None = None) -> None: + """Initialize the fixup class.""" + _LOGGER.info("Starting systemd-timesyncd service") + await self.sys_dbus.systemd.start_unit( + "systemd-timesyncd.service", StartUnitMode.REPLACE + ) + + @property + def suggestion(self) -> SuggestionType: + """Return a SuggestionType enum.""" + return SuggestionType.ENABLE_NTP + + @property + def context(self) -> ContextType: + """Return a ContextType enum.""" + return ContextType.SYSTEM + + @property + def issues(self) -> list[IssueType]: + """Return a IssueType enum list.""" + return [IssueType.NTP_SYNC_FAILED] diff --git a/tests/resolution/fixup/test_system_enable_ntp.py b/tests/resolution/fixup/test_system_enable_ntp.py new file mode 100644 index 000000000..adc191adb --- /dev/null +++ b/tests/resolution/fixup/test_system_enable_ntp.py @@ -0,0 +1,32 @@ +"""Test fixup system enable NTP.""" + +from supervisor.coresys import CoreSys +from supervisor.resolution.const import ContextType, IssueType, SuggestionType +from supervisor.resolution.data import Issue, Suggestion +from supervisor.resolution.fixups.system_enable_ntp import FixupSystemEnableNTP + +from tests.dbus_service_mocks.base import DBusServiceMock +from tests.dbus_service_mocks.systemd import Systemd as SystemdService + + +async def test_fixup( + coresys: CoreSys, + all_dbus_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]], +): + """Test fixup.""" + systemd_service: SystemdService = all_dbus_services["systemd"] + systemd_service.StartUnit.calls.clear() + + system_enable_ntp = FixupSystemEnableNTP(coresys) + assert system_enable_ntp.auto is False + + coresys.resolution.add_suggestion( + Suggestion(SuggestionType.ENABLE_NTP, ContextType.SYSTEM) + ) + coresys.resolution.add_issue(Issue(IssueType.NTP_SYNC_FAILED, ContextType.SYSTEM)) + + await system_enable_ntp() + + assert systemd_service.StartUnit.calls == [("systemd-timesyncd.service", "replace")] + assert len(coresys.resolution.suggestions) == 0 + assert len(coresys.resolution.issues) == 0 diff --git a/tests/test_core.py b/tests/test_core.py index 200d9ff26..a393fe875 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -12,9 +12,13 @@ from supervisor.coresys import CoreSys from supervisor.exceptions import WhoamiSSLError from supervisor.host.control import SystemControl from supervisor.host.info import InfoCenter +from supervisor.resolution.const import IssueType, SuggestionType from supervisor.supervisor import Supervisor from supervisor.utils.whoami import WhoamiData +from tests.dbus_service_mocks.base import DBusServiceMock +from tests.dbus_service_mocks.systemd import Systemd as SystemdService + @pytest.mark.parametrize("run_supervisor_state", ["test_file"], indirect=True) async def test_write_state(run_supervisor_state: MagicMock, coresys: CoreSys): @@ -70,11 +74,16 @@ async def test_adjust_system_datetime_without_ssl( async def test_adjust_system_datetime_if_time_behind( - coresys: CoreSys, websession: MagicMock + coresys: CoreSys, + websession: MagicMock, + all_dbus_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]], ): - """Test _adjust_system_datetime method when current time is ahead more than 3 days.""" + """Test _adjust_system_datetime method when current time is ahead more than 1 hour.""" + systemd_service: SystemdService = all_dbus_services["systemd"] + systemd_service.StopUnit.calls.clear() + utc_ts = datetime.datetime.now().replace(tzinfo=datetime.UTC) + datetime.timedelta( - days=4 + hours=1, minutes=1 ) with ( patch( @@ -87,6 +96,7 @@ async def test_adjust_system_datetime_if_time_behind( patch.object( InfoCenter, "dt_synchronized", new=PropertyMock(return_value=False) ), + patch.object(InfoCenter, "use_ntp", new=PropertyMock(return_value=True)), patch.object(Supervisor, "check_connectivity") as mock_check_connectivity, ): await coresys.core._adjust_system_datetime() @@ -95,6 +105,21 @@ async def test_adjust_system_datetime_if_time_behind( mock_check_connectivity.assert_called_once() mock_set_timezone.assert_called_once_with("Europe/Zurich") + # Verify timesyncd was stopped before setting time + assert systemd_service.StopUnit.calls == [ + ("systemd-timesyncd.service", "replace") + ] + + # Verify issue was created + assert any( + issue.type == IssueType.NTP_SYNC_FAILED + for issue in coresys.resolution.issues + ) + assert any( + suggestion.type == SuggestionType.ENABLE_NTP + for suggestion in coresys.resolution.suggestions + ) + async def test_adjust_system_datetime_sync_timezone_to_host( coresys: CoreSys, websession: MagicMock