Files
supervisor/tests/api/test_host.py
T
Mike Degatano 54a430f55e Hide new ntp host feature from v1 /host/info features field (#7207)
The python-supervisor-client library's HostInfo model types
`features` as the strict `list[HostFeature]` instead of
`list[HostFeature | str]`:
https://github.com/home-assistant-libs/python-supervisor-client/blob/3459d25b7b223dfd2a49e5aa3f794ea822faab5c/aiohasupervisor/models/host.py#L49

#7181 added HostFeature.NTP, which now breaks the hassio integration
on nightly since the client library raises on parsing it:

    Failed setup, will retry: Field "features" of type list[HostFeature] in HostInfo has invalid value ['reboot', 'shutdown', 'services', 'network', 'hostname', 'timedate', 'os_agent', 'haos', 'resolved', 'journal', 'disk', 'mount', 'ntp']

Work around this on the v1 /host/info endpoint by hiding "ntp" from
the features field and exposing the full list via a new all_features
field instead. The v2 API is unaffected since it has not shipped yet
and its features field is already typed as list[HostFeature | str].
2026-09-10 11:20:35 +02:00

1619 lines
57 KiB
Python

"""Test Host API."""
import asyncio
from collections.abc import AsyncGenerator
from contextlib import suppress
from datetime import UTC, datetime
import errno
import gc
import time
from typing import Any
from unittest.mock import ANY, MagicMock, patch
from aiohttp import ClientPayloadError
from aiohttp.test_utils import TestClient
from dbus_fast import DBusError, ErrorType
import pytest
import time_machine
from supervisor.api.host import APIHost
from supervisor.coresys import CoreSys
from supervisor.dbus.const import UnitActiveState
from supervisor.dbus.resolved import Resolved
from supervisor.exceptions import (
HostJournalGatewaydConnectionError,
MountUsageTimeoutError,
)
from supervisor.homeassistant.api import APIState
from supervisor.host.const import HostFeature, LogFormat, LogFormatter
from supervisor.host.control import SystemControl
from supervisor.mounts.mount import Mount
from tests.dbus_service_mocks.base import DBusServiceMock
from tests.dbus_service_mocks.hostname import Hostname as HostnameService
from tests.dbus_service_mocks.systemd import Systemd as SystemdService
DEFAULT_RANGE = "entries=:-99:100"
DEFAULT_RANGE_FOLLOW = "entries=:-99:18446744073709551615"
# pylint: disable=protected-access
@pytest.fixture(name="coresys_disk_info")
async def fixture_coresys_disk_info(coresys: CoreSys) -> AsyncGenerator[CoreSys]:
"""Mock basic disk information for host APIs."""
async def mock_disk_lifetime(_):
return 0
coresys.hardware.disk.get_disk_life_time = mock_disk_lifetime
coresys.hardware.disk.get_disk_free_space = lambda _: 5000
coresys.hardware.disk.get_disk_total_space = lambda _: 50000
coresys.hardware.disk.get_disk_used_space = lambda _: 45000
return coresys
async def test_api_host_info(
api_client_with_prefix: tuple[TestClient, str], coresys_disk_info: CoreSys
):
"""Test host info api."""
api_client, prefix = api_client_with_prefix
coresys = coresys_disk_info
dt_utc = datetime(2026, 2, 17, 1, 23, 45, 678901, tzinfo=UTC)
await coresys.dbus.agent.connect(coresys.dbus.bus)
await coresys.dbus.agent.update()
with time_machine.travel(dt_utc, tick=False):
resp = await api_client.get(f"{prefix}/host/info")
result = await resp.json()
assert result["data"]["apparmor_version"] == "2.13.2"
assert result["data"]["dt_utc"] == "2026-02-17T01:23:45.678901+00:00"
async def test_api_host_features(
api_client_with_prefix: tuple[TestClient, str],
coresys_disk_info: CoreSys,
dbus_is_connected,
):
"""Test host info features."""
api_client, prefix = api_client_with_prefix
coresys = coresys_disk_info
coresys.host.sys_dbus.systemd.is_connected = False
coresys.host.sys_dbus.network.is_connected = False
coresys.host.sys_dbus.hostname.is_connected = False
coresys.host.sys_dbus.timedate.is_connected = False
coresys.host.sys_dbus.agent.is_connected = False
coresys.host.sys_dbus.resolved.is_connected = False
coresys.host.sys_dbus.udisks2.is_connected = False
coresys.host.sys_dbus.agent.timesyncd.is_connected = False
resp = await api_client.get(f"{prefix}/host/info")
result = await resp.json()
assert "reboot" not in result["data"]["features"]
assert "services" not in result["data"]["features"]
assert "shutdown" not in result["data"]["features"]
assert "network" not in result["data"]["features"]
assert "hostname" not in result["data"]["features"]
assert "timedate" not in result["data"]["features"]
assert "os_agent" not in result["data"]["features"]
assert "ntp" not in result["data"]["features"]
assert "resolved" not in result["data"]["features"]
assert "disk" not in result["data"]["features"]
coresys.host.sys_dbus.systemd.is_connected = True
coresys.host.supported_features.cache_clear()
resp = await api_client.get(f"{prefix}/host/info")
result = await resp.json()
assert "reboot" in result["data"]["features"]
assert "services" in result["data"]["features"]
assert "shutdown" in result["data"]["features"]
coresys.host.sys_dbus.network.is_connected = True
coresys.host.supported_features.cache_clear()
resp = await api_client.get(f"{prefix}/host/info")
result = await resp.json()
assert "network" in result["data"]["features"]
coresys.host.sys_dbus.hostname.is_connected = True
coresys.host.supported_features.cache_clear()
resp = await api_client.get(f"{prefix}/host/info")
result = await resp.json()
assert "hostname" in result["data"]["features"]
coresys.host.sys_dbus.timedate.is_connected = True
coresys.host.supported_features.cache_clear()
resp = await api_client.get(f"{prefix}/host/info")
result = await resp.json()
assert "timedate" in result["data"]["features"]
coresys.host.sys_dbus.agent.is_connected = True
coresys.host.supported_features.cache_clear()
resp = await api_client.get(f"{prefix}/host/info")
result = await resp.json()
assert "os_agent" in result["data"]["features"]
coresys.host.sys_dbus.resolved.is_connected = True
coresys.host.supported_features.cache_clear()
resp = await api_client.get(f"{prefix}/host/info")
result = await resp.json()
assert "resolved" in result["data"]["features"]
coresys.host.sys_dbus.udisks2.is_connected = True
coresys.host.supported_features.cache_clear()
resp = await api_client.get(f"{prefix}/host/info")
result = await resp.json()
assert "disk" in result["data"]["features"]
@pytest.mark.parametrize("os_agent_version", ["1.13.0"], indirect=True)
@pytest.mark.usefixtures("os_available", "os_agent_version")
async def test_api_host_features_os(
api_client_with_prefix: tuple[TestClient, str],
coresys_disk_info: CoreSys,
dbus_is_connected,
):
"""Test host info features with OS available."""
api_client, prefix = api_client_with_prefix
coresys = coresys_disk_info
coresys.host.sys_dbus.agent.timesyncd.is_connected = False
resp = await api_client.get(f"{prefix}/host/info")
result = await resp.json()
assert "ntp" not in result["data"]["features"]
coresys.host.sys_dbus.agent.timesyncd.is_connected = True
coresys.host.supported_features.cache_clear()
resp = await api_client.get(f"{prefix}/host/info")
result = await resp.json()
if prefix == "/v2":
assert "ntp" in result["data"]["features"]
else:
# V1's features field is parsed by a strict enum in older clients, so
# ntp is hidden there and only exposed via all_features instead
assert "ntp" not in result["data"]["features"]
assert "ntp" in result["data"]["all_features"]
coresys.host.sys_dbus.systemd.is_connected = False
coresys.host.supported_features.cache_clear()
resp = await api_client.get(f"{prefix}/host/info")
result = await resp.json()
assert "ntp" not in result["data"]["features"]
# Host features known to the v1 python-supervisor-client HostInfo model's
# strict `features: list[HostFeature]` typing at the time this test was
# written. If this test fails after adding a new HostFeature value, that new
# value needs the same v1 compatibility treatment as ntp in
# APIHost.info_v1 (supervisor/api/host.py) until the client library relaxes
# its typing.
_KNOWN_V1_HOST_FEATURES = {
HostFeature.DISK,
HostFeature.HAOS,
HostFeature.HOSTNAME,
HostFeature.JOURNAL,
HostFeature.MOUNT,
HostFeature.NETWORK,
HostFeature.OS_AGENT,
HostFeature.REBOOT,
HostFeature.RESOLVED,
HostFeature.SERVICES,
HostFeature.SHUTDOWN,
HostFeature.TIMEDATE,
}
async def test_api_host_info_v1_features_no_unknown_values(
api_client: TestClient,
coresys_disk_info: CoreSys,
):
"""Test v1 host info features has no values unknown to older clients."""
coresys = coresys_disk_info
with patch.object(
coresys.host, "supported_features", return_value=list(HostFeature)
):
resp = await api_client.get("/host/info")
result = await resp.json()
assert not set(result["data"]["features"]) - _KNOWN_V1_HOST_FEATURES
assert "ntp" not in result["data"]["features"]
assert "ntp" in result["data"]["all_features"]
async def test_api_llmnr_mdns_info(
api_client_with_prefix: tuple[TestClient, str], coresys_disk_info: CoreSys
):
"""Test llmnr and mdns details in info."""
api_client, prefix = api_client_with_prefix
coresys = coresys_disk_info
# pylint: disable=protected-access
coresys.host.sys_dbus._resolved = Resolved()
# pylint: enable=protected-access
resp = await api_client.get(f"{prefix}/host/info")
result = await resp.json()
assert result["data"]["broadcast_llmnr"] is None
assert result["data"]["broadcast_mdns"] is None
assert result["data"]["llmnr_hostname"] is None
await coresys.dbus.resolved.connect(coresys.dbus.bus)
resp = await api_client.get(f"{prefix}/host/info")
result = await resp.json()
assert result["data"]["broadcast_llmnr"] is True
assert result["data"]["broadcast_mdns"] is False
assert result["data"]["llmnr_hostname"] == "homeassistant"
async def test_api_boot_ids_info(
api_client_with_prefix: tuple[TestClient, str], journald_logs: MagicMock
):
"""Test getting boot IDs."""
api_client, prefix = api_client_with_prefix
resp = await api_client.get(f"{prefix}/host/logs/boots")
result = await resp.json()
assert result["data"] == {"boots": {"0": "ccc", "-1": "bbb", "-2": "aaa"}}
async def test_api_identifiers_info(
api_client_with_prefix: tuple[TestClient, str], journald_logs: MagicMock
):
"""Test getting syslog identifiers."""
api_client, prefix = api_client_with_prefix
resp = await api_client.get(f"{prefix}/host/logs/identifiers")
result = await resp.json()
assert result["data"] == {
"identifiers": ["hassio_supervisor", "hassos-config", "kernel"]
}
async def test_api_virtualization_info(
api_client_with_prefix: tuple[TestClient, str],
all_dbus_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]],
coresys_disk_info: CoreSys,
):
"""Test getting virtualization info."""
api_client, prefix = api_client_with_prefix
systemd_service: SystemdService = all_dbus_services["systemd"]
resp = await api_client.get(f"{prefix}/host/info")
result = await resp.json()
assert result["data"]["virtualization"] == ""
systemd_service.virtualization = "vmware"
await coresys_disk_info.dbus.systemd.update()
resp = await api_client.get(f"{prefix}/host/info")
result = await resp.json()
assert result["data"]["virtualization"] == "vmware"
async def test_advanced_logs(
api_client_with_prefix: tuple[TestClient, str],
coresys: CoreSys,
journald_logs: MagicMock,
):
"""Test advanced logging API entries with identifier and custom boot."""
api_client, prefix = api_client_with_prefix
await api_client.get(f"{prefix}/host/logs")
journald_logs.assert_called_once_with(
params={"SYSLOG_IDENTIFIER": coresys.host.logs.default_identifiers},
range_header=DEFAULT_RANGE,
accept=LogFormat.JOURNAL,
)
journald_logs.reset_mock()
identifier = "dropbear"
await api_client.get(f"{prefix}/host/logs/identifiers/{identifier}")
journald_logs.assert_called_once_with(
params={"SYSLOG_IDENTIFIER": identifier},
range_header=DEFAULT_RANGE,
accept=LogFormat.JOURNAL,
)
journald_logs.reset_mock()
bootid = "798cc03bcd77465482b6a1c43dc6a5fc"
await api_client.get(f"{prefix}/host/logs/boots/{bootid}")
journald_logs.assert_called_once_with(
params={
"_BOOT_ID": bootid,
"SYSLOG_IDENTIFIER": coresys.host.logs.default_identifiers,
},
range_header=DEFAULT_RANGE,
accept=LogFormat.JOURNAL,
)
journald_logs.reset_mock()
await api_client.get(f"{prefix}/host/logs/boots/{bootid}/identifiers/{identifier}")
journald_logs.assert_called_once_with(
params={"_BOOT_ID": bootid, "SYSLOG_IDENTIFIER": identifier},
range_header=DEFAULT_RANGE,
accept=LogFormat.JOURNAL,
)
journald_logs.reset_mock()
headers = {"Range": "entries=:-19:10"}
await api_client.get(f"{prefix}/host/logs", headers=headers)
journald_logs.assert_called_once_with(
params={"SYSLOG_IDENTIFIER": coresys.host.logs.default_identifiers},
range_header=headers["Range"],
accept=LogFormat.JOURNAL,
)
journald_logs.reset_mock()
await api_client.get(f"{prefix}/host/logs/follow")
journald_logs.assert_called_once_with(
params={
"SYSLOG_IDENTIFIER": coresys.host.logs.default_identifiers,
"follow": "",
},
range_header=DEFAULT_RANGE_FOLLOW,
accept=LogFormat.JOURNAL,
)
# Host logs don't have a /latest endpoint
resp = await api_client.get(f"{prefix}/host/logs/latest")
assert resp.status == 404
async def test_advanced_logs_query_parameters(
api_client_with_prefix: tuple[TestClient, str],
coresys: CoreSys,
journald_logs: MagicMock,
journal_logs_reader: MagicMock,
):
"""Test advanced logging API entries controlled by query parameters."""
api_client, prefix = api_client_with_prefix
# Check lines query parameter
await api_client.get(f"{prefix}/host/logs?lines=53")
journald_logs.assert_called_once_with(
params={"SYSLOG_IDENTIFIER": coresys.host.logs.default_identifiers},
range_header="entries=:-52:53",
accept=LogFormat.JOURNAL,
)
journald_logs.reset_mock()
# Check verbose logs formatter via query parameter
await api_client.get(f"{prefix}/host/logs?verbose")
journald_logs.assert_called_once_with(
params={"SYSLOG_IDENTIFIER": coresys.host.logs.default_identifiers},
range_header=DEFAULT_RANGE,
accept=LogFormat.JOURNAL,
)
journal_logs_reader.assert_called_with(ANY, LogFormatter.VERBOSE, False)
journal_logs_reader.reset_mock()
journald_logs.reset_mock()
# Query parameters should take precedence over headers
await api_client.get(
f"{prefix}/host/logs?lines=53&verbose",
headers={
"Range": "entries=:-19:10",
"Accept": "text/plain",
},
)
journald_logs.assert_called_once_with(
params={"SYSLOG_IDENTIFIER": coresys.host.logs.default_identifiers},
range_header="entries=:-52:53",
accept=LogFormat.JOURNAL,
)
journal_logs_reader.assert_called_with(ANY, LogFormatter.VERBOSE, False)
journal_logs_reader.reset_mock()
journald_logs.reset_mock()
# Check no_colors query parameter
await api_client.get(f"{prefix}/host/logs?no_colors")
journald_logs.assert_called_once_with(
params={"SYSLOG_IDENTIFIER": coresys.host.logs.default_identifiers},
range_header=DEFAULT_RANGE,
accept=LogFormat.JOURNAL,
)
journal_logs_reader.assert_called_with(ANY, LogFormatter.VERBOSE, True)
async def test_advanced_logs_boot_id_offset(
api_client_with_prefix: tuple[TestClient, str],
coresys: CoreSys,
journald_logs: MagicMock,
):
"""Test advanced logging API when using an offset as boot ID."""
api_client, prefix = api_client_with_prefix
await api_client.get(f"{prefix}/host/logs/boots/0")
journald_logs.assert_called_once_with(
params={
"_BOOT_ID": "ccc",
"SYSLOG_IDENTIFIER": coresys.host.logs.default_identifiers,
},
range_header=DEFAULT_RANGE,
accept=LogFormat.JOURNAL,
)
journald_logs.reset_mock()
await api_client.get(f"{prefix}/host/logs/boots/-2")
journald_logs.assert_called_once_with(
params={
"_BOOT_ID": "aaa",
"SYSLOG_IDENTIFIER": coresys.host.logs.default_identifiers,
},
range_header=DEFAULT_RANGE,
accept=LogFormat.JOURNAL,
)
journald_logs.reset_mock()
await api_client.get(f"{prefix}/host/logs/boots/2")
journald_logs.assert_called_once_with(
params={
"_BOOT_ID": "bbb",
"SYSLOG_IDENTIFIER": coresys.host.logs.default_identifiers,
},
range_header=DEFAULT_RANGE,
accept=LogFormat.JOURNAL,
)
journald_logs.reset_mock()
async def test_advanced_logs_formatters(
journald_gateway: MagicMock,
api_client_with_prefix: tuple[TestClient, str],
coresys: CoreSys,
journal_logs_reader: MagicMock,
):
"""Test advanced logs formatters varying on Accept header."""
api_client, prefix = api_client_with_prefix
await api_client.get(f"{prefix}/host/logs")
journal_logs_reader.assert_called_once_with(ANY, LogFormatter.VERBOSE, False)
journal_logs_reader.reset_mock()
headers = {"Accept": "text/x-log"}
await api_client.get(f"{prefix}/host/logs", headers=headers)
journal_logs_reader.assert_called_once_with(ANY, LogFormatter.VERBOSE, False)
journal_logs_reader.reset_mock()
await api_client.get(f"{prefix}/host/logs/identifiers/test")
journal_logs_reader.assert_called_once_with(ANY, LogFormatter.PLAIN, False)
journal_logs_reader.reset_mock()
headers = {"Accept": "text/x-log"}
await api_client.get(f"{prefix}/host/logs/identifiers/test", headers=headers)
journal_logs_reader.assert_called_once_with(ANY, LogFormatter.VERBOSE, False)
journal_logs_reader.reset_mock()
await api_client.get(
f"{prefix}/host/logs/identifiers/test", skip_auto_headers={"Accept"}
)
journal_logs_reader.assert_called_once_with(ANY, LogFormatter.PLAIN, False)
async def test_advanced_logs_errors(
coresys: CoreSys, api_client_with_prefix: tuple[TestClient, str]
):
"""Test advanced logging API errors."""
api_client, prefix = api_client_with_prefix
with patch("supervisor.host.logs.SYSTEMD_JOURNAL_GATEWAYD_SOCKET") as socket:
socket.is_socket.return_value = False
await coresys.host.logs.post_init()
resp = await api_client.get(f"{prefix}/host/logs")
assert resp.content_type == "text/plain"
assert resp.status == 400
content = await resp.text()
assert content == "No systemd-journal-gatewayd Unix socket available"
headers = {"Accept": "application/json"}
resp = await api_client.get(f"{prefix}/host/logs", headers=headers)
assert resp.content_type == "text/plain"
assert resp.status == 400
content = await resp.text()
assert (
content
== "Invalid content type requested. Only text/plain and text/x-log supported for now."
)
async def test_advanced_logs_gateway_closed_mid_stream(
journald_gateway: MagicMock,
api_client_with_prefix: tuple[TestClient, str],
):
"""Test connection to journal gateway closed mid-stream ends the stream gracefully."""
api_client, prefix = api_client_with_prefix
journald_gateway.content.feed_data(b"__CURSOR=cursor1\nMESSAGE=Hello, world!\n\n")
resp = await api_client.get(f"{prefix}/host/logs/identifiers/test")
assert resp.status == 200
# Simulate connection to systemd-journal-gatewayd being closed mid-stream,
# e.g. because it was stopped on host shutdown.
journald_gateway.content.set_exception(
ClientPayloadError("Response payload is not completed")
)
assert await resp.text() == "Hello, world!\n"
async def test_advanced_logs_gateway_reset_before_stream(
journald_gateway: MagicMock,
api_client_with_prefix: tuple[TestClient, str],
):
"""Test connection reset before the log stream started returns an API error."""
api_client, prefix = api_client_with_prefix
journald_gateway.content.set_exception(
ClientPayloadError("Response payload is not completed")
)
resp = await api_client.get(f"{prefix}/host/logs/identifiers/test")
assert resp.status == 400
assert (
await resp.text()
== "Connection reset when trying to fetch data from systemd-journald."
)
async def test_advanced_logs_gateway_unavailable(
api_client_with_prefix: tuple[TestClient, str],
journald_logs: MagicMock,
caplog: pytest.LogCaptureFixture,
):
"""Test connection failure to journal gateway returns a plain API error."""
api_client, prefix = api_client_with_prefix
journald_logs.side_effect = HostJournalGatewaydConnectionError(
"Unable to connect to systemd-journal-gatewayd"
)
with patch("supervisor.api.utils.async_capture_exception") as capture_exception:
resp = await api_client.get(f"{prefix}/host/logs")
assert resp.status == 400
assert await resp.text() == "Unable to connect to systemd-journal-gatewayd"
capture_exception.assert_not_called()
assert "Unexpected error during API call" not in caplog.text
async def test_disk_usage_api(
api_client_with_prefix: tuple[TestClient, str], coresys: CoreSys
):
"""Test disk usage API endpoint."""
api_client, prefix = api_client_with_prefix
# Mock the disk usage methods
with (
patch.object(coresys.hardware.disk, "disk_usage") as mock_disk_usage,
patch.object(coresys.hardware.disk, "get_dir_sizes") as mock_dir_sizes,
):
# Mock the main disk usage call
mock_disk_usage.return_value = (
1000000000,
500000000,
500000000,
) # 1GB total, 500MB used, 500MB free
# Mock the directory structure sizes for each path
mock_dir_sizes.return_value = [
{
"id": "apps_data",
"label": "Apps Data",
"used_bytes": 100000000,
"children": [
{"id": "addon1", "label": "addon1", "used_bytes": 50000000}
],
},
{
"id": "apps_config",
"label": "Apps Config",
"used_bytes": 200000000,
"children": [
{"id": "media1", "label": "media1", "used_bytes": 100000000}
],
},
{
"id": "media",
"label": "Media",
"used_bytes": 50000000,
"children": [
{"id": "share1", "label": "share1", "used_bytes": 25000000}
],
},
{
"id": "share",
"label": "Share",
"used_bytes": 300000000,
"children": [
{"id": "backup1", "label": "backup1", "used_bytes": 150000000}
],
},
{
"id": "backup",
"label": "Backup",
"used_bytes": 10000000,
"children": [{"id": "ssl1", "label": "ssl1", "used_bytes": 5000000}],
},
{
"id": "ssl",
"label": "SSL",
"used_bytes": 40000000,
"children": [
{
"id": "homeassistant1",
"label": "homeassistant1",
"used_bytes": 20000000,
}
],
},
{
"id": "homeassistant",
"label": "Home Assistant",
"used_bytes": 40000000,
"children": [
{
"id": "homeassistant1",
"label": "homeassistant1",
"used_bytes": 20000000,
}
],
},
]
# Test default max_depth=1
resp = await api_client.get(f"{prefix}/host/disks/default/usage")
assert resp.status == 200
result = await resp.json()
assert result["data"]["id"] == "root"
assert result["data"]["label"] == "Root"
assert result["data"]["total_bytes"] == 1000000000
assert result["data"]["used_bytes"] == 500000000
assert "children" in result["data"]
children = result["data"]["children"]
# First child should be system
assert children[0]["id"] == "system"
assert children[0]["label"] == "System"
# Verify all expected directories are present in the remaining children
expected_data_id = "addons_data" if prefix == "" else "apps_data"
expected_config_id = "addons_config" if prefix == "" else "apps_config"
assert children[1]["id"] == expected_data_id
assert children[2]["id"] == expected_config_id
assert children[3]["id"] == "media"
assert children[4]["id"] == "share"
assert children[5]["id"] == "backup"
assert children[6]["id"] == "ssl"
assert children[7]["id"] == "homeassistant"
# Verify the sizes are correct
assert children[1]["used_bytes"] == 100000000
assert children[2]["used_bytes"] == 200000000
assert children[3]["used_bytes"] == 50000000
assert children[4]["used_bytes"] == 300000000
assert children[5]["used_bytes"] == 10000000
assert children[6]["used_bytes"] == 40000000
assert children[7]["used_bytes"] == 40000000
# Verify system space calculation (total used - sum of known paths)
total_known_space = (
100000000
+ 200000000
+ 50000000
+ 300000000
+ 10000000
+ 40000000
+ 40000000
)
expected_system_space = 500000000 - total_known_space
assert children[0]["used_bytes"] == expected_system_space
# Verify disk_usage was called with supervisor path
mock_disk_usage.assert_called_once_with(coresys.config.path_supervisor)
# Verify get_dir_sizes was called once with all paths
assert mock_dir_sizes.call_count == 1
call_args = mock_dir_sizes.call_args
assert call_args[0][1] == 1 # max_depth parameter
paths_dict = call_args[0][0] # paths dictionary
assert paths_dict["apps_data"] == coresys.config.path_apps_data
assert paths_dict["apps_config"] == coresys.config.path_app_configs
assert paths_dict["media"] == coresys.config.path_media
assert paths_dict["share"] == coresys.config.path_share
assert paths_dict["backup"] == coresys.config.path_backup
assert paths_dict["ssl"] == coresys.config.path_ssl
assert paths_dict["homeassistant"] == coresys.config.path_homeassistant
async def test_disk_usage_api_with_custom_depth(
api_client_with_prefix: tuple[TestClient, str], coresys: CoreSys
):
"""Test disk usage API endpoint with custom max_depth parameter."""
api_client, prefix = api_client_with_prefix
with (
patch.object(coresys.hardware.disk, "disk_usage") as mock_disk_usage,
patch.object(coresys.hardware.disk, "get_dir_sizes") as mock_dir_sizes,
):
mock_disk_usage.return_value = (1000000000, 500000000, 500000000)
# Mock deeper directory structure
mock_dir_sizes.return_value = [
{
"id": "apps_data",
"label": "Apps Data",
"used_bytes": 100000000,
"children": [
{
"id": "addon1",
"label": "addon1",
"used_bytes": 50000000,
"children": [
{
"id": "subdir1",
"label": "subdir1",
"used_bytes": 25000000,
},
],
},
],
},
{
"id": "apps_config",
"label": "Apps Config",
"used_bytes": 100000000,
"children": [
{
"id": "addon1",
"label": "addon1",
"used_bytes": 50000000,
"children": [
{
"id": "subdir1",
"label": "subdir1",
"used_bytes": 25000000,
},
],
},
],
},
{
"id": "media",
"label": "Media",
"used_bytes": 100000000,
"children": [
{
"id": "addon1",
"label": "addon1",
"used_bytes": 50000000,
"children": [
{
"id": "subdir1",
"label": "subdir1",
"used_bytes": 25000000,
},
],
},
],
},
{
"id": "share",
"label": "Share",
"used_bytes": 100000000,
"children": [
{
"id": "addon1",
"label": "addon1",
"used_bytes": 50000000,
"children": [
{
"id": "subdir1",
"label": "subdir1",
"used_bytes": 25000000,
},
],
},
],
},
{
"id": "backup",
"label": "Backup",
"used_bytes": 100000000,
"children": [
{
"id": "addon1",
"label": "addon1",
"used_bytes": 50000000,
"children": [
{
"id": "subdir1",
"label": "subdir1",
"used_bytes": 25000000,
},
],
},
],
},
{
"id": "ssl",
"label": "SSL",
"used_bytes": 100000000,
"children": [
{
"id": "addon1",
"label": "addon1",
"used_bytes": 50000000,
"children": [
{
"id": "subdir1",
"label": "subdir1",
"used_bytes": 25000000,
},
],
},
],
},
{
"id": "homeassistant",
"label": "Home Assistant",
"used_bytes": 100000000,
"children": [
{
"id": "addon1",
"label": "addon1",
"used_bytes": 50000000,
"children": [
{
"id": "subdir1",
"label": "subdir1",
"used_bytes": 25000000,
},
],
},
],
},
]
# Test with custom max_depth=2
resp = await api_client.get(f"{prefix}/host/disks/default/usage?max_depth=2")
assert resp.status == 200
result = await resp.json()
assert result["data"]["used_bytes"] == 500000000
assert result["data"]["children"]
# Verify max_depth=2 was passed to get_dir_sizes
assert mock_dir_sizes.call_count == 1
call_args = mock_dir_sizes.call_args
assert call_args[0][1] == 2 # max_depth parameter
async def test_disk_usage_api_invalid_depth(
api_client_with_prefix: tuple[TestClient, str], coresys: CoreSys
):
"""Test disk usage API endpoint with invalid max_depth parameter."""
api_client, prefix = api_client_with_prefix
with (
patch.object(coresys.hardware.disk, "disk_usage") as mock_disk_usage,
patch.object(coresys.hardware.disk, "get_dir_sizes") as mock_dir_sizes,
):
mock_disk_usage.return_value = (1000000000, 500000000, 500000000)
mock_dir_sizes.return_value = [
{
"id": "apps_data",
"label": "Apps Data",
"used_bytes": 100000000,
},
{
"id": "apps_config",
"label": "Apps Config",
"used_bytes": 100000000,
},
{
"id": "media",
"label": "Media",
"used_bytes": 100000000,
},
{
"id": "share",
"label": "Share",
"used_bytes": 100000000,
},
{
"id": "backup",
"label": "Backup",
"used_bytes": 100000000,
},
{
"id": "ssl",
"label": "SSL",
"used_bytes": 100000000,
},
{
"id": "homeassistant",
"label": "Home Assistant",
"used_bytes": 100000000,
},
]
# Test with invalid max_depth (non-integer)
resp = await api_client.get(
f"{prefix}/host/disks/default/usage?max_depth=invalid"
)
assert resp.status == 200
result = await resp.json()
assert result["data"]["used_bytes"] == 500000000
assert result["data"]["children"]
# Should default to max_depth=1 when invalid value is provided
assert mock_dir_sizes.call_count == 1
call_args = mock_dir_sizes.call_args
assert call_args[0][1] == 1 # Should default to 1
async def test_disk_usage_api_empty_directories(
api_client_with_prefix: tuple[TestClient, str], coresys: CoreSys
):
"""Test disk usage API endpoint with empty directories."""
api_client, prefix = api_client_with_prefix
with (
patch.object(coresys.hardware.disk, "disk_usage") as mock_disk_usage,
patch.object(coresys.hardware.disk, "get_dir_sizes") as mock_dir_sizes,
):
mock_disk_usage.return_value = (1000000000, 500000000, 500000000)
# Mock empty directory structures (no children)
mock_dir_sizes.return_value = [
{
"id": "apps_data",
"label": "Apps Data",
"used_bytes": 0,
},
{
"id": "apps_config",
"label": "Apps Config",
"used_bytes": 0,
},
{
"id": "media",
"label": "Media",
"used_bytes": 0,
},
{
"id": "share",
"label": "Share",
"used_bytes": 0,
},
{
"id": "backup",
"label": "Backup",
"used_bytes": 0,
},
{
"id": "ssl",
"label": "SSL",
"used_bytes": 0,
},
{
"id": "homeassistant",
"label": "Home Assistant",
"used_bytes": 0,
},
]
resp = await api_client.get(f"{prefix}/host/disks/default/usage")
assert resp.status == 200
result = await resp.json()
assert result["data"]["used_bytes"] == 500000000
children = result["data"]["children"]
# First child should be system with all the space
assert children[0]["id"] == "system"
assert children[0]["used_bytes"] == 500000000
# All other directories should have size 0
for i in range(1, len(children)):
assert children[i]["used_bytes"] == 0
async def test_disk_usage_api_v1_uses_legacy_addon_ids(
api_client: TestClient, coresys: CoreSys
):
"""Test v1 disk usage response uses legacy addon IDs."""
with (
patch.object(coresys.hardware.disk, "disk_usage") as mock_disk_usage,
patch.object(coresys.hardware.disk, "get_dir_sizes") as mock_dir_sizes,
):
mock_disk_usage.return_value = (1000000000, 500000000, 500000000)
mock_dir_sizes.return_value = [
{"id": "apps_data", "label": "Apps Data", "used_bytes": 100000000},
{
"id": "apps_config",
"label": "Apps Config",
"used_bytes": 200000000,
},
{"id": "media", "label": "Media", "used_bytes": 50000000},
{"id": "share", "label": "Share", "used_bytes": 300000000},
{"id": "backup", "label": "Backup", "used_bytes": 10000000},
{"id": "ssl", "label": "SSL", "used_bytes": 40000000},
{
"id": "homeassistant",
"label": "Home Assistant",
"used_bytes": 40000000,
},
]
resp = await api_client.get("/host/disks/default/usage")
assert resp.status == 200
result = await resp.json()
child_ids = [child["id"] for child in result["data"]["children"]]
assert "addons_data" in child_ids
assert "addons_config" in child_ids
assert "apps_data" not in child_ids
assert "apps_config" not in child_ids
@pytest.mark.parametrize("action", ["reboot", "shutdown"])
async def test_migration_blocks_shutdown(
api_client_with_prefix: tuple[TestClient, str],
coresys: CoreSys,
action: str,
):
"""Test that an offline db migration in progress stops users from shutting down or rebooting system."""
api_client, prefix = api_client_with_prefix
coresys.homeassistant.api.get_api_state.return_value = APIState("NOT_RUNNING", True)
resp = await api_client.post(f"{prefix}/host/{action}")
assert resp.status == 503
result = await resp.json()
assert (
result["message"]
== "Home Assistant offline database migration in progress, please wait until complete before shutting down host"
)
async def test_force_reboot_during_migration(
api_client_with_prefix: tuple[TestClient, str], coresys: CoreSys
):
"""Test force option reboots even during a migration."""
api_client, prefix = api_client_with_prefix
coresys.homeassistant.api.get_api_state.return_value = APIState("NOT_RUNNING", True)
with patch.object(SystemControl, "reboot") as reboot:
await api_client.post(f"{prefix}/host/reboot", json={"force": True})
reboot.assert_called_once()
async def test_force_shutdown_during_migration(
api_client_with_prefix: tuple[TestClient, str], coresys: CoreSys
):
"""Test force option shutdown even during a migration."""
api_client, prefix = api_client_with_prefix
coresys.homeassistant.api.get_api_state.return_value = APIState("NOT_RUNNING", True)
with patch.object(SystemControl, "shutdown") as shutdown:
await api_client.post(f"{prefix}/host/shutdown", json={"force": True})
shutdown.assert_called_once()
async def test_set_hostname_invalid_returns_400(
api_client: TestClient,
all_dbus_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]],
):
"""An INVALID_ARGS rejection from hostnamed becomes a 400 with a structured body."""
hostname_service: HostnameService = all_dbus_services["hostname"]
hostname_service.response_set_static_hostname = DBusError(
ErrorType.INVALID_ARGS, "Invalid static hostname 'bad name'"
)
resp = await api_client.post("/host/options", json={"hostname": "bad name"})
assert resp.status == 400
body = await resp.json()
assert body["result"] == "error"
assert body["message"] == "Invalid hostname 'bad name'"
assert body["error_key"] == "host_invalid_hostname"
assert body["extra_fields"] == {"hostname": "bad name"}
def _register_mount(
coresys: CoreSys, name: str, state: UnitActiveState | None
) -> Mount:
"""Register a CIFS mount with the manager in the given systemd state."""
mount = Mount.from_dict(
coresys,
{
"name": name,
"type": "cifs",
"usage": "media",
"server": "media.local",
"share": "media",
},
)
mount._state = state
coresys.mounts._mounts = {mount.name: mount}
return mount
@pytest.fixture(name="active_mount")
async def fixture_active_mount(
coresys: CoreSys, tmp_supervisor_data, path_extern
) -> Mount:
"""Return an active CIFS mount registered with the mount manager."""
return _register_mount(coresys, "media_test", UnitActiveState.ACTIVE)
@pytest.mark.parametrize(
"query",
["", "?max_depth=0", "?max_depth=1"],
ids=["depth-default", "depth-explicit-0", "depth-1-emits-nothing"],
)
async def test_disk_usage_api_mount_totals_only(
api_client_with_prefix: tuple[TestClient, str],
coresys: CoreSys,
active_mount: Mount,
query: str,
):
"""Test a mount reports totals only without ever walking the tree.
The directory walker recurses regardless of max_depth and only emits
children beyond depth 1, so whenever it could not produce output — depth 0
and depth 1 alike — it has to be skipped outright. Calling it would walk
the whole mount on every request just to discard the result.
"""
api_client, prefix = api_client_with_prefix
with (
patch.object(coresys.hardware.disk, "disk_usage_for_mount") as mock_disk_usage,
patch.object(
coresys.hardware.disk, "get_dir_structure_sizes"
) as mock_structure,
):
# Middle value is deliberately wrong: used must come from total - free so
# that reserved space counts as used, as it does for the system disk.
mock_disk_usage.return_value = (2000000000, 999, 800000000)
resp = await api_client.get(f"{prefix}/host/disks/media_test/usage{query}")
assert resp.status == 200
result = await resp.json()
assert result["data"] == {
"id": "media_test",
"label": "media_test",
"total_bytes": 2000000000,
"used_bytes": 1200000000,
}
# Omitted entirely rather than empty, like every other node in the tree
assert "children" not in result["data"]
mock_structure.assert_not_called()
mock_disk_usage.assert_called_once_with(active_mount.local_where)
async def test_disk_usage_api_mount_breakdown(
api_client_with_prefix: tuple[TestClient, str],
coresys: CoreSys,
active_mount: Mount,
):
"""Test a mount breakdown at depth 2.
Depth means the same thing as it does for the system disk: level 1 is the
labeled known paths, which a mount does not have, so a mount's own
subdirectories start at level 2.
"""
api_client, prefix = api_client_with_prefix
with (
patch.object(coresys.hardware.disk, "disk_usage_for_mount") as mock_disk_usage,
patch.object(
coresys.hardware.disk, "get_dir_structure_sizes"
) as mock_structure,
):
mock_disk_usage.return_value = (2000000000, 999, 800000000)
mock_structure.return_value = {
"used_bytes": 1100000000,
"children": [
{"id": "movies", "label": "movies", "used_bytes": 900000000},
{"id": "music", "label": "music", "used_bytes": 200000000},
],
}
resp = await api_client.get(f"{prefix}/host/disks/media_test/usage?max_depth=2")
assert resp.status == 200
result = await resp.json()
children = result["data"]["children"]
# What the walk could not attribute is reported as "other", so a mount's
# children add up to its used_bytes like every other node in the tree.
assert children == [
{"id": "movies", "label": "movies", "used_bytes": 900000000},
{"id": "music", "label": "music", "used_bytes": 200000000},
{"id": "other", "label": "Other", "used_bytes": 100000000},
]
assert (
sum(child["used_bytes"] for child in children) == (result["data"]["used_bytes"])
)
# check_oserror off: read errors from a mount walk must stay the mount's
# problem instead of marking the whole system unhealthy
mock_structure.assert_called_once_with(
active_mount.local_where, 2, check_oserror=False
)
@pytest.mark.parametrize(
("walked_bytes", "expect_children"),
[(1200000000, True), (1300000000, False)],
ids=["accounts-for-everything", "overshoots-the-filesystem"],
)
@pytest.mark.usefixtures("active_mount")
async def test_disk_usage_api_mount_breakdown_without_remainder(
api_client_with_prefix: tuple[TestClient, str],
coresys: CoreSys,
walked_bytes: int,
expect_children: bool,
caplog: pytest.LogCaptureFixture,
):
"""Test an exact walk keeps its children and an overshooting walk drops them."""
api_client, prefix = api_client_with_prefix
with (
patch.object(coresys.hardware.disk, "disk_usage_for_mount") as mock_disk_usage,
patch.object(
coresys.hardware.disk, "get_dir_structure_sizes"
) as mock_structure,
):
# used = total - free = 1200000000
mock_disk_usage.return_value = (2000000000, 999, 800000000)
mock_structure.return_value = {
"used_bytes": walked_bytes,
"children": [
{"id": "movies", "label": "movies", "used_bytes": walked_bytes},
],
}
resp = await api_client.get(f"{prefix}/host/disks/media_test/usage?max_depth=2")
assert resp.status == 200
result = await resp.json()
if expect_children:
assert result["data"]["children"] == [
{"id": "movies", "label": "movies", "used_bytes": walked_bytes},
]
assert "Omitting the breakdown" not in caplog.text
else:
assert "children" not in result["data"]
# Dropped data is not silent: leave a trace for user reports
assert "Directory sizes of mount media_test" in caplog.text
assert "Omitting the breakdown" in caplog.text
# Either way the children never sum past used_bytes
assert (
sum(child["used_bytes"] for child in result["data"].get("children", []))
<= result["data"]["used_bytes"]
)
@pytest.mark.usefixtures("tmp_supervisor_data", "path_extern")
async def test_disk_usage_api_unknown_mount(
api_client_with_prefix: tuple[TestClient, str],
):
"""Test requesting usage for a mount that does not exist."""
api_client, prefix = api_client_with_prefix
resp = await api_client.get(f"{prefix}/host/disks/nope/usage")
assert resp.status == 404
result = await resp.json()
assert result["message"] == "No mount exists with name nope"
assert result["error_key"] == "mount_not_found_error"
assert result["extra_fields"] == {"name": "nope"}
@pytest.mark.usefixtures("tmp_supervisor_data", "path_extern")
@pytest.mark.parametrize(
"stale_state",
[UnitActiveState.INACTIVE, UnitActiveState.FAILED, None],
ids=["dormant-trigger", "last-probe-failed", "state-unknown"],
)
async def test_disk_usage_api_probes_regardless_of_cached_state(
api_client_with_prefix: tuple[TestClient, str],
coresys: CoreSys,
stale_state: UnitActiveState | None,
):
"""Test usage comes from the probe, not cached mount state."""
api_client, prefix = api_client_with_prefix
_register_mount(coresys, "media_test", stale_state)
with patch.object(coresys.hardware.disk, "disk_usage_for_mount") as mock_disk_usage:
mock_disk_usage.return_value = (2000000000, 999, 800000000)
resp = await api_client.get(f"{prefix}/host/disks/media_test/usage")
assert resp.status == 200
result = await resp.json()
assert result["data"]["used_bytes"] == 1200000000
mock_disk_usage.assert_called_once()
@pytest.mark.usefixtures("active_mount")
async def test_disk_usage_api_mount_timeout(
api_client_with_prefix: tuple[TestClient, str], coresys: CoreSys
):
"""Test a probe that never returns fails cleanly for that mount."""
api_client, prefix = api_client_with_prefix
def _never_returns(_):
time.sleep(0.5)
return (1, 1, 1)
with (
patch("supervisor.api.host.MOUNT_USAGE_TIMEOUT", 0.05),
patch.object(
coresys.hardware.disk, "disk_usage_for_mount", side_effect=_never_returns
),
):
resp = await api_client.get(f"{prefix}/host/disks/media_test/usage")
assert resp.status == 400
result = await resp.json()
assert result["error_key"] == "mount_usage_timeout_error"
assert result["extra_fields"] == {"name": "media_test"}
@pytest.mark.usefixtures("active_mount")
async def test_disk_usage_api_mount_unreachable(
api_client_with_prefix: tuple[TestClient, str], coresys: CoreSys
):
"""Test an unreachable mount reports an error rather than failing the API."""
api_client, prefix = api_client_with_prefix
with patch.object(
coresys.hardware.disk,
"disk_usage_for_mount",
side_effect=OSError(errno.EHOSTDOWN, "Host is down"),
):
resp = await api_client.get(f"{prefix}/host/disks/media_test/usage")
assert resp.status == 400
result = await resp.json()
assert result["error_key"] == "mount_usage_read_error"
assert result["extra_fields"]["name"] == "media_test"
assert "Host is down" in result["extra_fields"]["reason"]
@pytest.mark.usefixtures("active_mount")
async def test_disk_usage_api_ghost_mount(
api_client_with_prefix: tuple[TestClient, str], coresys: CoreSys
):
"""Test a vanished mount is an error, not host-disk numbers."""
api_client, prefix = api_client_with_prefix
with (
patch.object(coresys.hardware.disk, "disk_usage_for_mount", return_value=None),
patch.object(
coresys.hardware.disk, "get_dir_structure_sizes"
) as mock_structure,
):
resp = await api_client.get(f"{prefix}/host/disks/media_test/usage?max_depth=2")
assert resp.status == 400
result = await resp.json()
assert result["error_key"] == "mount_usage_not_mounted_error"
assert result["extra_fields"] == {"name": "media_test"}
# A ghost is never walked either
mock_structure.assert_not_called()
@pytest.mark.usefixtures("tmp_supervisor_data", "path_extern")
async def test_mount_usage_caller_timeout_joins_same_probe(coresys: CoreSys):
"""Test a caller's timeout neither cancels nor orphans the shared probe.
A probe killed by its own timeout would be dropped from the registry while
its executor thread stays parked in the kernel, so the next request would
stack a fresh thread against the same mount. The timeout therefore bounds
only the caller's wait: the probe and its registry entry outlive slow
callers, and later callers join the same probe.
"""
api_host = APIHost()
api_host.coresys = coresys
mount = _register_mount(coresys, "media_test", UnitActiveState.ACTIVE)
def _slow(_):
time.sleep(0.4)
return (2000000000, 999, 800000000)
with patch.object(
coresys.hardware.disk, "disk_usage_for_mount", side_effect=_slow
) as mock_usage:
with (
patch("supervisor.api.host.MOUNT_USAGE_TIMEOUT", 0.05),
pytest.raises(
MountUsageTimeoutError, match="Timed out reading storage usage"
),
):
await api_host._mount_usage(mount, 0)
# The probe survived its caller: still registered, still running
assert ("media_test", 0) in api_host._mount_usage_probes
# A later caller (with the real, generous timeout) joins that same
# probe rather than starting another executor thread
result = await api_host._mount_usage(mount, 0)
assert result["used_bytes"] == 1200000000
mock_usage.assert_called_once()
# Completion popped the entry, so the next request starts fresh
assert not api_host._mount_usage_probes
@pytest.mark.usefixtures("active_mount")
async def test_disk_usage_api_mount_shares_in_flight_probe(
api_client_with_prefix: tuple[TestClient, str], coresys: CoreSys
):
"""Test concurrent requests for the same mount share one probe.
A probe of an unreachable mount blocks until the kernel gives up, so
stacking one executor thread per caller is what this guards against.
"""
api_client, prefix = api_client_with_prefix
def _slow(_):
time.sleep(0.3)
return (2000000000, 999, 800000000)
with patch.object(
coresys.hardware.disk, "disk_usage_for_mount", side_effect=_slow
) as mock_disk_usage:
first, second = await asyncio.gather(
api_client.get(f"{prefix}/host/disks/media_test/usage"),
api_client.get(f"{prefix}/host/disks/media_test/usage"),
)
assert first.status == 200
assert second.status == 200
assert (await first.json())["data"] == (await second.json())["data"]
mock_disk_usage.assert_called_once()
@pytest.mark.usefixtures("tmp_supervisor_data", "path_extern")
async def test_mount_usage_abandoned_probe_retrieves_its_exception(coresys: CoreSys):
"""Test an abandoned failing probe is not reported as an unretrieved exception.
The shield deliberately keeps a probe alive past the caller that started it,
so a browser giving up on an unreachable mount leaves a probe that still
fails later with nobody waiting on it. Left unretrieved, asyncio reports it
with a traceback that reads like a supervisor fault rather than a mount that
was never going to answer.
"""
api_host = APIHost()
api_host.coresys = coresys
mount = _register_mount(coresys, "media_test", UnitActiveState.ACTIVE)
reported: list[dict[str, Any]] = []
loop = asyncio.get_running_loop()
previous_handler = loop.get_exception_handler()
loop.set_exception_handler(lambda _loop, context: reported.append(context))
def _fails_slowly(_):
time.sleep(0.2)
raise OSError(errno.EHOSTDOWN, "Host is down")
try:
with patch.object(
coresys.hardware.disk, "disk_usage_for_mount", side_effect=_fails_slowly
):
waiter = asyncio.create_task(api_host._mount_usage(mount, 0))
# Let the probe get going, then abandon it the way a disconnecting
# client does
await asyncio.sleep(0.05)
waiter.cancel()
with suppress(asyncio.CancelledError):
await waiter
# Give the orphaned probe time to fail, then force collection so an
# unretrieved exception would be surfaced
await asyncio.sleep(0.5)
gc.collect()
await asyncio.sleep(0)
finally:
loop.set_exception_handler(previous_handler)
# Nothing at all should reach the loop exception handler: an unreachable
# mount is an expected outcome, not a supervisor fault
assert not reported
# The entry is gone either way, so a later request starts a fresh probe
assert not api_host._mount_usage_probes
@pytest.mark.usefixtures("active_mount")
async def test_disk_usage_api_mount_depths_below_two_share_one_probe(
api_client_with_prefix: tuple[TestClient, str], coresys: CoreSys
):
"""Test concurrent callers at depths 0 and 1 share a single probe."""
api_client, prefix = api_client_with_prefix
def _slow(_):
time.sleep(0.3)
return (2000000000, 999, 800000000)
with patch.object(
coresys.hardware.disk, "disk_usage_for_mount", side_effect=_slow
) as mock_disk_usage:
first, second = await asyncio.gather(
api_client.get(f"{prefix}/host/disks/media_test/usage?max_depth=0"),
api_client.get(f"{prefix}/host/disks/media_test/usage?max_depth=1"),
)
assert first.status == 200
assert second.status == 200
assert (await first.json())["data"] == (await second.json())["data"]
mock_disk_usage.assert_called_once()
@pytest.mark.usefixtures("active_mount")
async def test_disk_usage_api_v1_mount_children_are_not_remapped(
api_client: TestClient, coresys: CoreSys
):
"""Test a mount directory named apps_data keeps its name in v1 responses."""
with (
patch.object(coresys.hardware.disk, "disk_usage_for_mount") as mock_disk_usage,
patch.object(
coresys.hardware.disk, "get_dir_structure_sizes"
) as mock_structure,
):
mock_disk_usage.return_value = (2000000000, 999, 800000000)
mock_structure.return_value = {
"used_bytes": 1200000000,
"children": [
{"id": "apps_data", "label": "apps_data", "used_bytes": 1200000000},
],
}
resp = await api_client.get("/host/disks/media_test/usage?max_depth=2")
assert resp.status == 200
result = await resp.json()
assert result["data"]["children"] == [
{"id": "apps_data", "label": "apps_data", "used_bytes": 1200000000},
]
@pytest.mark.usefixtures("tmp_supervisor_data", "path_extern")
async def test_disk_usage_api_default_wins_over_mount_of_that_name(
api_client_with_prefix: tuple[TestClient, str], coresys: CoreSys
):
"""Test "default" always means the system disk.
The mount name pattern permits a mount called "default", so the resolution
order has to be documented and pinned: the reserved target wins.
"""
api_client, prefix = api_client_with_prefix
_register_mount(coresys, "default", UnitActiveState.ACTIVE)
with (
patch.object(coresys.hardware.disk, "disk_usage") as mock_disk_usage,
patch.object(coresys.hardware.disk, "get_dir_sizes") as mock_dir_sizes,
):
mock_disk_usage.return_value = (1000000000, 500000000, 500000000)
mock_dir_sizes.return_value = []
resp = await api_client.get(f"{prefix}/host/disks/default/usage")
assert resp.status == 200
result = await resp.json()
# The system disk, not the mount
assert result["data"]["id"] == "root"
assert result["data"]["label"] == "Root"