mirror of
https://github.com/home-assistant/supervisor.git
synced 2026-09-30 02:35:20 +01:00
* Start the Supervisor auto update on every version reload A pending Supervisor update blocks Core, OS and app updates through the SUPERVISOR_UPDATED job condition. Only the startup and the daily scheduled reload started the auto update. Every other reload, such as the one Core requests when the user opens Settings, made the new version known without installing it. The user then saw "supervisor needs to be updated first" until the daily task ran. The updater now starts the auto update task after every reload while the system is running. The scheduled task calls the updater reload directly. The Supervisor update job rejects a concurrent update request, so a user requested update during the auto update fails cleanly instead of creating an update failed issue. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAWqYzDiFEvYjZwfBWoxkC * Report success for an update request during a running Supervisor update The auto update can already run when Core or a user requests the update. The requested outcome is underway, so the API reports success and the caller sees the result through the Supervisor restart. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAWqYzDiFEvYjZwfBWoxkC * Trim comments around the Supervisor auto update Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAWqYzDiFEvYjZwfBWoxkC * Drop comment on the updater auto update trigger Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAWqYzDiFEvYjZwfBWoxkC * Raise specific errors when restoring a backup requires a Supervisor update Move the supervisor version check in do_restore_full/do_restore_partial into a shared _check_supervisor_version helper. If the backup requires a newer Supervisor version: - Raise the new translatable BackupSupervisorVersionError (400) with the existing message when auto update is disabled. - Otherwise kick off the Supervisor auto update in the background via sys_create_task(auto_update_supervisor()) and raise the new translatable BackupSupervisorUpdateInProgressError (503), telling the caller to retry once the update completes. * Recheck for Supervisor update before failing a version-mismatch restore When a restore requires a newer Supervisor version and auto update is enabled, refresh version info first in case a newer Supervisor was just released. If an update is already known to be needed, make sure it gets kicked off directly instead of waiting on a reload. Only fall back to the translatable version-mismatch error if no update is available after the recheck. Also move auto_update_supervisor from Tasks to the Supervisor class so it can be reused outside of the task scheduler. * Track auto update and version fetch tasks to close restore-check races Supervisor.auto_update_supervisor() now stores and returns its update task (created with eager_start so a synchronous failure is reflected immediately) instead of firing-and-forgetting it. This lets callers check the task's actual done state rather than guessing based on need_update, which could be wrong if the update job rejected the call for an unrelated reason. HomeAssistantCore's install retry loop now also catches SupervisorJobError when triggering the Supervisor update it depends on, so it keeps retrying while an update is already in progress instead of giving up. Add Updater.start_fetch_data(), which starts (or reuses) a version fetch and returns its task. fetch_data() sets its throttle timestamp before running, even on failure, so a caller relying on fetch_data() directly can't tell "someone else just refreshed" from "someone else's fetch just failed" - it would silently skip either way. Callers that need to know the true outcome should await the shared task instead. reload() and BackupManager._check_supervisor_version() are updated to use it: the latter awaits the fetch task directly (skipping it entirely if there's no connectivity to fetch with) before checking auto_update_supervisor()'s task, closing a race where a stale/failed throttle window could cause a 400 (version mismatch) response instead of a 503 (update in progress). * Use Job decorator's detach option for update and version fetch tasks #7211 added a `detach` option to the Job decorator, making the manual task-tracking previously added here redundant. Replace it with the decorator-native option: - `Supervisor.update()` is now `detach=True` with `concurrency=JobConcurrency.REJECT`. Calling it returns the task performing the update - which may already be in progress from any previous caller, manual or automatic - instead of raising `SupervisorJobError` or blocking the caller until it (and the Supervisor restart it triggers) completes. Errors are raised on the returned task, not to the immediate caller. - `Supervisor.auto_update_supervisor()` is now a thin, non-detached method that simply returns `update()`'s own task directly instead of tracking a separate task itself. This ensures a concurrent manual update and an auto update share the exact same underlying task regardless of which caller started it. It never awaits the task to completion, since update() restarts Supervisor and awaiting that here could drop the caller's connection before a response is sent. - `Updater.fetch_data()` is now `detach=True` with `concurrency=JobConcurrency.REJECT`, replacing the bespoke `start_fetch_data()`/`_fetch_task` tracking. `reload()` and `BackupManager._check_supervisor_version()` await the returned task directly to get the real outcome of a fetch instead of relying on the throttle window. - The Supervisor update API endpoint and the Home Assistant Core install retry loop now use `if task := await ...: await task` instead of catching `SupervisorJobError`, since a concurrent call no longer raises that error - it returns the shared in-progress task instead. Updated tests across supervisor.py, updater.py, the API and Home Assistant Core install retry loop to match the new return values and removed now-obsolete `SupervisorJobError`-based concurrency tests. * Fix too-many-nested-blocks pylint warning in HomeAssistantCore.install() Flatten the nested need_update/auto_update if/else into an if/elif so the Supervisor update retry try/except isn't nested one level deeper, which pylint 4.0.8 (unlike the previously installed version in this container) now flags as too-many-nested-blocks (6/5). Cache need_update in a local variable since it's now referenced twice in the same iteration and must be consistent between both checks. * Update supervisor/api/supervisor.py --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Mike Degatano <michael.degatano@gmail.com> Co-authored-by: Stefan Agner <stefan@agner.ch>
512 lines
18 KiB
Python
512 lines
18 KiB
Python
"""Test Home Assistant OS functionality."""
|
|
|
|
import asyncio
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, PropertyMock, call, patch
|
|
|
|
from awesomeversion import AwesomeVersion
|
|
from dbus_fast import Variant
|
|
import pytest
|
|
|
|
from supervisor.const import CoreState
|
|
from supervisor.coresys import CoreSys
|
|
from supervisor.dbus.const import RaucState
|
|
from supervisor.exceptions import HassOSJobError, HassOSUpdateError
|
|
from supervisor.resolution.const import (
|
|
ContextType,
|
|
IssueType,
|
|
SuggestionType,
|
|
UnhealthyReason,
|
|
)
|
|
|
|
from tests.common import MockResponse
|
|
from tests.dbus_service_mocks.base import DBusServiceMock
|
|
from tests.dbus_service_mocks.rauc import Rauc as RaucService
|
|
|
|
# pylint: disable=protected-access
|
|
|
|
|
|
@pytest.mark.usefixtures("no_job_throttle")
|
|
async def test_ota_url_generic_x86_64_rename(
|
|
coresys: CoreSys, mock_update_data: MockResponse, supervisor_internet: AsyncMock
|
|
) -> None:
|
|
"""Test download URL generated."""
|
|
coresys.os._board = "intel-nuc"
|
|
coresys.os._version = AwesomeVersion("5.13")
|
|
task = await coresys.updater.fetch_data()
|
|
assert task
|
|
await task
|
|
|
|
version6 = AwesomeVersion("6.0")
|
|
url = coresys.updater.ota_url.format(
|
|
version=str(version6), board="generic-x86-64", os_name="haos"
|
|
)
|
|
|
|
assert coresys.os._get_download_url(version6) == url
|
|
|
|
|
|
def test_ota_url_os_name(coresys: CoreSys) -> None:
|
|
"""Test download URL generated with os_name."""
|
|
board = "generic-x86-64"
|
|
os_name = "haos"
|
|
versionstr = "6.0"
|
|
|
|
url = "https://github.com/home-assistant/operating-system/releases/download/{version}/{os_name}_{board}-{version}.raucb"
|
|
url_formatted = url.format(version=versionstr, board=board, os_name=os_name)
|
|
|
|
coresys.os._board = board
|
|
coresys.os._os_name = os_name
|
|
coresys.updater._data = {"ota": url}
|
|
|
|
url = coresys.os._get_download_url(AwesomeVersion(versionstr))
|
|
assert url == url_formatted
|
|
|
|
|
|
def test_ota_url_os_name_rel_5_downgrade(coresys: CoreSys) -> None:
|
|
"""Test download URL generated with os_name."""
|
|
board = "generic-x86-64"
|
|
versionstr = "5.9"
|
|
|
|
# On downgrade below 6.0 we need to use hassos as os_name.
|
|
url = "https://github.com/home-assistant/operating-system/releases/download/{version}/{os_name}_{board}-{version}.raucb"
|
|
url_formatted = url.format(version=versionstr, board=board, os_name="hassos")
|
|
|
|
coresys.os._board = board
|
|
coresys.os._os_name = "haos"
|
|
coresys.updater._data = {"ota": url}
|
|
|
|
url = coresys.os._get_download_url(AwesomeVersion(versionstr))
|
|
assert url == url_formatted
|
|
|
|
|
|
async def test_update_fails_if_out_of_date(
|
|
coresys: CoreSys, supervisor_internet: AsyncMock
|
|
) -> None:
|
|
"""Test update of OS fails if Supervisor is out of date."""
|
|
await coresys.core.set_state(CoreState.RUNNING)
|
|
with (
|
|
patch.object(
|
|
type(coresys.supervisor), "need_update", new=PropertyMock(return_value=True)
|
|
),
|
|
patch.object(
|
|
type(coresys.os), "available", new=PropertyMock(return_value=True)
|
|
),
|
|
pytest.raises(HassOSJobError),
|
|
):
|
|
await coresys.os.update()
|
|
|
|
|
|
async def test_update_fails_if_unhealthy(
|
|
coresys: CoreSys,
|
|
) -> None:
|
|
"""Test update of OS fails if Supervisor is unhealthy."""
|
|
await coresys.core.set_state(CoreState.RUNNING)
|
|
coresys.resolution.add_unhealthy_reason(UnhealthyReason.DUPLICATE_OS_INSTALLATION)
|
|
with (
|
|
patch.object(
|
|
type(coresys.os), "available", new=PropertyMock(return_value=True)
|
|
),
|
|
pytest.raises(HassOSJobError),
|
|
):
|
|
await coresys.os.update()
|
|
|
|
|
|
async def test_update_success_cleans_up_bundle(
|
|
coresys: CoreSys,
|
|
tmp_supervisor_data: Path,
|
|
path_extern: None,
|
|
supervisor_internet: AsyncMock,
|
|
) -> None:
|
|
"""Test successful OS update installs via RAUC and removes the downloaded bundle."""
|
|
await coresys.core.set_state(CoreState.RUNNING)
|
|
|
|
coresys.os._available = True
|
|
coresys.os._board = "generic-x86-64"
|
|
coresys.os._os_name = "haos"
|
|
coresys.os._version = AwesomeVersion("12.0")
|
|
coresys.updater._data = {
|
|
"ota": (
|
|
"https://github.com/home-assistant/operating-system/releases/download/"
|
|
"{version}/{os_name}_{board}-{version}.raucb"
|
|
),
|
|
"hassos_unrestricted": AwesomeVersion("13.0"),
|
|
}
|
|
|
|
async def fake_download(url: str, raucb: Path) -> None:
|
|
raucb.touch()
|
|
|
|
reboot_mock = AsyncMock()
|
|
|
|
with (
|
|
patch.object(coresys.os, "_download_raucb", side_effect=fake_download),
|
|
patch.object(coresys.host.control, "reboot", reboot_mock),
|
|
):
|
|
await coresys.os.update()
|
|
|
|
bundle = coresys.config.path_tmp / "hassos-13.0.raucb"
|
|
assert not bundle.exists()
|
|
reboot_mock.assert_not_called()
|
|
assert (
|
|
IssueType.REBOOT_REQUIRED,
|
|
ContextType.SYSTEM,
|
|
) in {(issue.type, issue.context) for issue in coresys.resolution.issues}
|
|
assert (
|
|
SuggestionType.EXECUTE_REBOOT,
|
|
ContextType.SYSTEM,
|
|
) in {
|
|
(suggestion.type, suggestion.context)
|
|
for suggestion in coresys.resolution.suggestions
|
|
}
|
|
assert coresys.os.version_pending == AwesomeVersion("13.0")
|
|
assert coresys.os.need_update is False
|
|
|
|
|
|
async def test_update_pending_version_blocked(
|
|
coresys: CoreSys,
|
|
tmp_supervisor_data: Path,
|
|
path_extern: None,
|
|
supervisor_internet: AsyncMock,
|
|
) -> None:
|
|
"""Test updating to an already installed version pending reboot is rejected."""
|
|
await coresys.core.set_state(CoreState.RUNNING)
|
|
|
|
coresys.os._available = True
|
|
coresys.os._board = "generic-x86-64"
|
|
coresys.os._os_name = "haos"
|
|
coresys.os._version = AwesomeVersion("12.0")
|
|
coresys.updater._data = {
|
|
"ota": (
|
|
"https://github.com/home-assistant/operating-system/releases/download/"
|
|
"{version}/{os_name}_{board}-{version}.raucb"
|
|
),
|
|
"hassos_unrestricted": AwesomeVersion("13.0"),
|
|
}
|
|
|
|
async def fake_download(url: str, raucb: Path) -> None:
|
|
raucb.touch()
|
|
|
|
with patch.object(
|
|
coresys.os, "_download_raucb", side_effect=fake_download
|
|
) as download:
|
|
await coresys.os.update()
|
|
|
|
with pytest.raises(HassOSUpdateError):
|
|
await coresys.os.update()
|
|
|
|
download.assert_called_once()
|
|
|
|
|
|
async def test_mark_healthy_keeps_pending_update(coresys: CoreSys) -> None:
|
|
"""Test mark_healthy does not mark booted slot active with pending update."""
|
|
coresys.os._available = True
|
|
coresys.os._version_pending = AwesomeVersion("13.0")
|
|
|
|
with patch.object(
|
|
coresys.dbus.rauc,
|
|
"mark",
|
|
AsyncMock(return_value=["kernel.1", "marked slot kernel.1 as good"]),
|
|
) as mark:
|
|
await coresys.os.mark_healthy()
|
|
|
|
mark.assert_called_once_with(RaucState.GOOD, "booted")
|
|
|
|
|
|
async def test_mark_healthy_marks_active_without_pending(coresys: CoreSys) -> None:
|
|
"""Test mark_healthy marks booted slot good and active without pending update."""
|
|
coresys.os._available = True
|
|
|
|
with patch.object(
|
|
coresys.dbus.rauc,
|
|
"mark",
|
|
AsyncMock(return_value=["kernel.1", "marked slot kernel.1 as good"]),
|
|
) as mark:
|
|
await coresys.os.mark_healthy()
|
|
|
|
assert mark.call_args_list == [
|
|
call(RaucState.GOOD, "booted"),
|
|
call(RaucState.ACTIVE, "booted"),
|
|
]
|
|
|
|
|
|
async def test_board_name_supervised(coresys: CoreSys) -> None:
|
|
"""Test board name is supervised when not on haos."""
|
|
with patch("supervisor.os.manager.CPE.get_product", return_value=["not-hassos"]):
|
|
await coresys.dbus.hostname.connect(coresys.dbus.bus)
|
|
await coresys.os.load()
|
|
assert coresys.os.board == "supervised"
|
|
|
|
|
|
async def test_load_slot_status_fresh_install(
|
|
coresys: CoreSys,
|
|
all_dbus_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]],
|
|
) -> None:
|
|
"""Test load works when slot status returns minimal fresh install response."""
|
|
rauc_service: RaucService = all_dbus_services["rauc"]
|
|
rauc_service.response_get_slot_status = [
|
|
(
|
|
"kernel.0",
|
|
{
|
|
"class": Variant("s", "kernel"),
|
|
"boot-status": Variant("s", "good"),
|
|
"type": Variant("s", "raw"),
|
|
"bootname": Variant("s", "A"),
|
|
"device": Variant("s", "/dev/disk/by-partlabel/hassos-kernel0"),
|
|
"state": Variant("s", "inactive"),
|
|
},
|
|
),
|
|
(
|
|
"boot.0",
|
|
{
|
|
"bundle.compatible": Variant("s", "haos-green"),
|
|
"sha256": Variant(
|
|
"s",
|
|
"f0b8a08d9bc49acbb230cf709beb0aa214cbee09969566755dff52fb8b3cc29b",
|
|
),
|
|
"state": Variant("s", "inactive"),
|
|
"size": Variant("t", 16777216),
|
|
"installed.count": Variant("u", 1),
|
|
"class": Variant("s", "boot"),
|
|
"device": Variant("s", "/dev/disk/by-partlabel/hassos-boot"),
|
|
"type": Variant("s", "vfat"),
|
|
"status": Variant("s", "ok"),
|
|
"bundle.version": Variant("s", "12.2.dev20240313"),
|
|
"installed.timestamp": Variant("s", "2024-03-15T17:27:38Z"),
|
|
},
|
|
),
|
|
(
|
|
"rootfs.0",
|
|
{
|
|
"class": Variant("s", "rootfs"),
|
|
"parent": Variant("s", "kernel.0"),
|
|
"type": Variant("s", "raw"),
|
|
"state": Variant("s", "inactive"),
|
|
"device": Variant("s", "/dev/disk/by-partlabel/hassos-system0"),
|
|
},
|
|
),
|
|
(
|
|
"spl.0",
|
|
{
|
|
"bundle.compatible": Variant("s", "haos-green"),
|
|
"sha256": Variant(
|
|
"s",
|
|
"97e4f1616250e7f9d2b20d98a972cf3aab03849a8cf50a8630f96a183b64384f",
|
|
),
|
|
"state": Variant("s", "inactive"),
|
|
"size": Variant("t", 16777216),
|
|
"installed.count": Variant("u", 1),
|
|
"class": Variant("s", "spl"),
|
|
"device": Variant("s", "/dev/disk/by-partlabel/hassos-boot"),
|
|
"type": Variant("s", "raw"),
|
|
"status": Variant("s", "ok"),
|
|
"bundle.version": Variant("s", "12.2.dev20240313"),
|
|
"installed.timestamp": Variant("s", "2024-03-15T17:27:47Z"),
|
|
},
|
|
),
|
|
(
|
|
"kernel.1",
|
|
{
|
|
"activated.count": Variant("u", 1),
|
|
"activated.timestamp": Variant("s", "2024-03-15T17:27:47Z"),
|
|
"boot-status": Variant("s", "good"),
|
|
"bundle.compatible": Variant("s", "haos-green"),
|
|
"sha256": Variant(
|
|
"s",
|
|
"c327b3c2ac4f56926d0d7c4693fe79c67dc05ed49c4abd020da981bf4faf977f",
|
|
),
|
|
"state": Variant("s", "booted"),
|
|
"size": Variant("t", 13410304),
|
|
"installed.count": Variant("u", 1),
|
|
"class": Variant("s", "kernel"),
|
|
"device": Variant("s", "/dev/disk/by-partlabel/hassos-kernel1"),
|
|
"type": Variant("s", "raw"),
|
|
"bootname": Variant("s", "B"),
|
|
"bundle.version": Variant("s", "12.2.dev20240313"),
|
|
"installed.timestamp": Variant("s", "2024-03-15T17:27:39Z"),
|
|
"status": Variant("s", "ok"),
|
|
},
|
|
),
|
|
(
|
|
"rootfs.1",
|
|
{
|
|
"bundle.compatible": Variant("s", "haos-green"),
|
|
"parent": Variant("s", "kernel.1"),
|
|
"state": Variant("s", "active"),
|
|
"size": Variant("t", 194560000),
|
|
"sha256": Variant(
|
|
"s",
|
|
"151dbfff469a7f1252cb8482e7a9439c5164f52c53ed141e377c10e6858208cb",
|
|
),
|
|
"class": Variant("s", "rootfs"),
|
|
"device": Variant("s", "/dev/disk/by-partlabel/hassos-system1"),
|
|
"type": Variant("s", "raw"),
|
|
"status": Variant("s", "ok"),
|
|
"bundle.version": Variant("s", "12.2.dev20240313"),
|
|
"installed.timestamp": Variant("s", "2024-03-15T17:27:45Z"),
|
|
"installed.count": Variant("u", 1),
|
|
},
|
|
),
|
|
]
|
|
|
|
await coresys.os.load()
|
|
assert len(coresys.os.slots) == 6
|
|
assert coresys.os.get_slot_name("A") == "kernel.0"
|
|
assert coresys.os.get_slot_name("B") == "kernel.1"
|
|
|
|
|
|
async def test_mark_healthy_detects_pending_update(
|
|
coresys: CoreSys,
|
|
all_dbus_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]],
|
|
) -> None:
|
|
"""Test mark_healthy recovers a pending update and raises a reboot issue."""
|
|
rauc_service: RaucService = all_dbus_services["rauc"]
|
|
rauc_service.response_get_primary = "kernel.0"
|
|
|
|
await coresys.os.load()
|
|
with patch.object(
|
|
coresys.dbus.rauc,
|
|
"mark",
|
|
AsyncMock(return_value=["kernel.1", "marked slot kernel.1 as good"]),
|
|
) as mark:
|
|
await coresys.os.mark_healthy()
|
|
|
|
assert coresys.os.version_pending == AwesomeVersion("9.0.dev20220818")
|
|
assert (
|
|
IssueType.REBOOT_REQUIRED,
|
|
ContextType.SYSTEM,
|
|
) in {(issue.type, issue.context) for issue in coresys.resolution.issues}
|
|
assert (
|
|
SuggestionType.EXECUTE_REBOOT,
|
|
ContextType.SYSTEM,
|
|
) in {
|
|
(suggestion.type, suggestion.context)
|
|
for suggestion in coresys.resolution.suggestions
|
|
}
|
|
# The pending update must not be cancelled by marking the booted slot active
|
|
mark.assert_called_once_with(RaucState.GOOD, "booted")
|
|
|
|
|
|
async def test_mark_healthy_no_pending_update(coresys: CoreSys) -> None:
|
|
"""Test mark_healthy finds no pending update when the primary slot is booted."""
|
|
await coresys.os.load()
|
|
with patch.object(
|
|
coresys.dbus.rauc,
|
|
"mark",
|
|
AsyncMock(return_value=["kernel.1", "marked slot kernel.1 as good"]),
|
|
):
|
|
await coresys.os.mark_healthy()
|
|
|
|
assert coresys.os.version_pending is None
|
|
assert (
|
|
IssueType.REBOOT_REQUIRED,
|
|
ContextType.SYSTEM,
|
|
) not in {(issue.type, issue.context) for issue in coresys.resolution.issues}
|
|
|
|
|
|
async def test_mark_healthy_stale_primary_before_mark_good(
|
|
coresys: CoreSys,
|
|
all_dbus_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]],
|
|
) -> None:
|
|
"""Test pending update detection runs after the booted slot is marked good.
|
|
|
|
rauc's GRUB backend does not treat a slot with boot attempts pending as
|
|
primary. The booted slot has a boot attempt recorded until it is marked
|
|
good, so on every boot GetPrimary reports the previous slot as primary
|
|
until then. Detecting the pending update from that stale response raised
|
|
a bogus reboot issue on every boot on GRUB systems.
|
|
"""
|
|
rauc_service: RaucService = all_dbus_services["rauc"]
|
|
rauc_service.response_get_primary = "kernel.0"
|
|
|
|
async def mark(state: RaucState, slot_identifier: str) -> list[str]:
|
|
# Marking the booted slot good resets its boot attempt counter,
|
|
# making it eligible as primary again
|
|
rauc_service.response_get_primary = "kernel.1"
|
|
return ["kernel.1", f"marked slot kernel.1 as {state}"]
|
|
|
|
await coresys.os.load()
|
|
assert coresys.os.version_pending is None
|
|
|
|
with patch.object(coresys.dbus.rauc, "mark", side_effect=mark) as mark_mock:
|
|
await coresys.os.mark_healthy()
|
|
|
|
assert coresys.os.version_pending is None
|
|
assert (
|
|
IssueType.REBOOT_REQUIRED,
|
|
ContextType.SYSTEM,
|
|
) not in {(issue.type, issue.context) for issue in coresys.resolution.issues}
|
|
assert mark_mock.call_args_list == [
|
|
call(RaucState.GOOD, "booted"),
|
|
call(RaucState.ACTIVE, "booted"),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("os_available", "expected_service"),
|
|
[
|
|
("17.3", "hassos-config.service"),
|
|
("18.0.rc1", "haos-config.service"),
|
|
("18.0", "haos-config.service"),
|
|
],
|
|
indirect=["os_available"],
|
|
)
|
|
async def test_config_sync_service_name(
|
|
coresys: CoreSys, os_available: None, expected_service: str
|
|
) -> None:
|
|
"""Test config_sync uses the correct service name per OS version."""
|
|
with patch.object(coresys.host.services, "restart", new=AsyncMock()) as restart:
|
|
await coresys.os.config_sync()
|
|
|
|
restart.assert_called_once_with(expected_service)
|
|
|
|
|
|
@pytest.mark.usefixtures("os_available")
|
|
async def test_ssh_authorized_keys_jobs_serialized(coresys: CoreSys):
|
|
"""Test concurrent SSH key add and clear do not interleave.
|
|
|
|
Interleaved file and service operations could end with the service
|
|
state not matching the key state (e.g. a key configured but dropbear
|
|
stopped).
|
|
"""
|
|
events: list[str] = []
|
|
|
|
def record(name: str):
|
|
async def _record(*args):
|
|
events.append(f"{name}-begin")
|
|
await asyncio.sleep(0.01)
|
|
events.append(f"{name}-end")
|
|
|
|
return _record
|
|
|
|
with (
|
|
patch.object(
|
|
type(coresys.dbus.agent.system), "add_ssh_auth_key", new=record("add-key")
|
|
),
|
|
patch.object(
|
|
type(coresys.dbus.agent.system),
|
|
"clear_ssh_auth_keys",
|
|
new=record("clear-keys"),
|
|
),
|
|
patch.object(
|
|
coresys.host.services, "start", new=AsyncMock(side_effect=record("start"))
|
|
),
|
|
patch.object(
|
|
coresys.host.services, "stop", new=AsyncMock(side_effect=record("stop"))
|
|
),
|
|
):
|
|
await asyncio.gather(
|
|
coresys.os.add_ssh_authorized_key("ssh-ed25519 AAAA test@example.com"),
|
|
coresys.os.clear_ssh_authorized_keys(),
|
|
)
|
|
|
|
# Each operation's file change and service action must be contiguous
|
|
assert events == [
|
|
"add-key-begin",
|
|
"add-key-end",
|
|
"start-begin",
|
|
"start-end",
|
|
"clear-keys-begin",
|
|
"clear-keys-end",
|
|
"stop-begin",
|
|
"stop-end",
|
|
]
|