Skip to content

Commit 85f8107

Browse files
agnerssairon
andauthored
Recreate aiohttp ClientSession after DNS plug-in load (#5862)
* Recreate aiohttp ClientSession after DNS plug-in load Create a temporary ClientSession early in case we need to load version information from the internet. This doesn't use the final DNS setup and hence might fail to load in certain situations since we don't have the fallback mechanims in place yet. But if the DNS container image is present, we'll continue the setup and load the DNS plug-in. We then can recreate the ClientSession such that it uses the DNS plug-in. This works around an issue with aiodns, which today doesn't reload `resolv.conf` automatically when it changes. This lead to Supervisor using the initial `resolv.conf` as created by Docker. It meant that we did not use the DNS plug-in (and its fallback capabilities) in Supervisor. Also it meant that changes to the DNS setup at runtime did not propagate to the aiohttp ClientSession (as observed in #5332). * Mock aiohttp.ClientSession for all tests Currently in several places pytest actually uses the aiohttp ClientSession and reaches out to the internet. This is not ideal for unit tests and should be avoided. This creates several new fixtures to aid this effort: The `websession` fixture simply returns a mocked aiohttp.ClientSession, which can be used whenever a function is tested which needs the global websession. A separate new fixture to mock the connectivity check named `supervisor_internet` since this is often used through the Job decorator which require INTERNET_SYSTEM. And the `mock_update_data` uses the already existing update json test data from the fixture directory instead of loading the data from the internet. * Log ClientSession nameserver information When recreating the aiohttp ClientSession, log information what nameservers exactly are going to be used. * Refuse ClientSession initialization when API is available Previous attempts to reinitialize the ClientSession have shown use of the ClientSession after it was closed due to API requets being handled in parallel to the reinitialization (see #5851). Make sure this is not possible by refusing to reinitialize the ClientSession when the API is available. * Fix pytests Also sure we don't create aiohttp ClientSession objects unnecessarily. * Apply suggestions from code review Co-authored-by: Jan Čermák <sairon@users.noreply.github.com> --------- Co-authored-by: Jan Čermák <sairon@users.noreply.github.com>
1 parent 2e44e64 commit 85f8107

24 files changed

+256
-102
lines changed

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,9 @@ filterwarnings = [
230230
"ignore:pkg_resources is deprecated as an API:DeprecationWarning:dirhash",
231231
"ignore::pytest.PytestUnraisableExceptionWarning",
232232
]
233+
markers = [
234+
"no_mock_init_websession: disable the autouse mock of init_websession for this test",
235+
]
233236

234237
[tool.ruff]
235238
lint.select = [

supervisor/api/middleware/security.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
ROLE_DEFAULT,
2121
ROLE_HOMEASSISTANT,
2222
ROLE_MANAGER,
23-
CoreState,
23+
VALID_API_STATES,
2424
)
2525
from ...coresys import CoreSys, CoreSysAttributes
2626
from ...utils import version_is_new_enough
@@ -200,11 +200,7 @@ async def block_bad_requests(self, request: Request, handler: Callable) -> Respo
200200
@middleware
201201
async def system_validation(self, request: Request, handler: Callable) -> Response:
202202
"""Check if core is ready to response."""
203-
if self.sys_core.state not in (
204-
CoreState.STARTUP,
205-
CoreState.RUNNING,
206-
CoreState.FREEZE,
207-
):
203+
if self.sys_core.state not in VALID_API_STATES:
208204
return api_return_error(
209205
message=f"System is not ready with state: {self.sys_core.state}"
210206
)

supervisor/const.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,3 +552,12 @@ def from_dict(cls, data: dict[str, dict[str, str | None]]) -> Self:
552552
CoreState.STARTUP,
553553
CoreState.SETUP,
554554
]
555+
556+
# States in which the API can be used (enforced by system_validation())
557+
VALID_API_STATES = frozenset(
558+
{
559+
CoreState.STARTUP,
560+
CoreState.RUNNING,
561+
CoreState.FREEZE,
562+
}
563+
)

supervisor/core.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,19 @@ async def setup(self):
124124
"""Start setting up supervisor orchestration."""
125125
await self.set_state(CoreState.SETUP)
126126

127+
# Initialize websession early. At this point we'll use the Docker DNS proxy
128+
# at 127.0.0.11, which does not have the fallback feature and hence might
129+
# fail in certain environments. But a websession is required to get the
130+
# initial version information after a device wipe or otherwise empty state
131+
# (e.g. CI environment, Supervised).
132+
#
133+
# An OS installation has the plug-in container images pre-installed, so we
134+
# setup can continue even if this early websession fails to connect to the
135+
# internet. We'll reinitialize the websession when the DNS plug-in is up to
136+
# make sure the DNS plug-in along with its fallback capabilities is used
137+
# (see #5857).
138+
await self.coresys.init_websession()
139+
127140
# Check internet on startup
128141
await self.sys_supervisor.check_connectivity()
129142

supervisor/coresys.py

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
ENV_SUPERVISOR_MACHINE,
2222
MACHINE_ID,
2323
SERVER_SOFTWARE,
24+
VALID_API_STATES,
2425
)
2526

2627
if TYPE_CHECKING:
@@ -68,7 +69,6 @@ def __init__(self):
6869

6970
# External objects
7071
self._loop: asyncio.BaseEventLoop = asyncio.get_running_loop()
71-
self._websession: aiohttp.ClientSession = aiohttp.ClientSession()
7272

7373
# Global objects
7474
self._config: CoreConfig = CoreConfig()
@@ -100,11 +100,7 @@ def __init__(self):
100100
self._security: Security | None = None
101101
self._bus: Bus | None = None
102102
self._mounts: MountManager | None = None
103-
104-
# Set default header for aiohttp
105-
self._websession._default_headers = MappingProxyType(
106-
{aiohttp.hdrs.USER_AGENT: SERVER_SOFTWARE}
107-
)
103+
self._websession: aiohttp.ClientSession | None = None
108104

109105
# Task factory attributes
110106
self._set_task_context: list[Callable[[Context], Context]] = []
@@ -114,6 +110,33 @@ async def load_config(self) -> Self:
114110
await self.config.read_data()
115111
return self
116112

113+
async def init_websession(self) -> None:
114+
"""Initialize global aiohttp ClientSession."""
115+
if self.core.state in VALID_API_STATES:
116+
# Make sure we don't reinitialize the session if the API is running (see #5851)
117+
raise RuntimeError(
118+
"Initializing ClientSession is not safe when API is running"
119+
)
120+
121+
if self._websession:
122+
await self._websession.close()
123+
124+
resolver = aiohttp.AsyncResolver()
125+
126+
# pylint: disable=protected-access
127+
_LOGGER.debug(
128+
"Initializing ClientSession with AsyncResolver. Using nameservers %s",
129+
resolver._resolver.nameservers,
130+
)
131+
connector = aiohttp.TCPConnector(loop=self.loop, resolver=resolver)
132+
133+
session = aiohttp.ClientSession(
134+
headers=MappingProxyType({aiohttp.hdrs.USER_AGENT: SERVER_SOFTWARE}),
135+
connector=connector,
136+
)
137+
138+
self._websession = session
139+
117140
async def init_machine(self):
118141
"""Initialize machine information."""
119142

@@ -165,6 +188,8 @@ def loop(self) -> asyncio.BaseEventLoop:
165188
@property
166189
def websession(self) -> aiohttp.ClientSession:
167190
"""Return websession object."""
191+
if self._websession is None:
192+
raise RuntimeError("WebSession not setup yet")
168193
return self._websession
169194

170195
@property

supervisor/plugins/dns.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,13 @@ async def load(self) -> None:
177177

178178
# Update supervisor
179179
await self._write_resolv(HOST_RESOLV)
180-
await self.sys_supervisor.check_connectivity()
180+
181+
# Reinitializing aiohttp.ClientSession after DNS setup makes sure that
182+
# aiodns is using the right DNS servers (see #5857).
183+
# At this point it should be fairly safe to replace the session since
184+
# we only use the session synchronously during setup and not thorugh the
185+
# API which previously caused issues (see #5851).
186+
await self.coresys.init_websession()
181187

182188
async def install(self) -> None:
183189
"""Install CoreDNS."""

tests/addons/test_manager.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ async def test_addon_shutdown_error(
190190

191191

192192
async def test_addon_uninstall_removes_discovery(
193-
coresys: CoreSys, install_addon_ssh: Addon
193+
coresys: CoreSys, install_addon_ssh: Addon, websession: MagicMock
194194
):
195195
"""Test discovery messages removed when addon uninstalled."""
196196
assert coresys.discovery.list_messages == []
@@ -203,7 +203,6 @@ async def test_addon_uninstall_removes_discovery(
203203
assert coresys.discovery.list_messages == [message]
204204

205205
coresys.homeassistant.api.ensure_access_token = AsyncMock()
206-
coresys.websession.delete = MagicMock()
207206

208207
await coresys.addons.uninstall(TEST_ADDON_SLUG)
209208
await asyncio.sleep(0)

tests/api/test_auth.py

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
"""Test auth API."""
22

33
from datetime import UTC, datetime, timedelta
4-
from unittest.mock import AsyncMock, patch
4+
from unittest.mock import AsyncMock, MagicMock, patch
55

66
from aiohttp.test_utils import TestClient
77
import pytest
88

99
from supervisor.addons.addon import Addon
1010
from supervisor.coresys import CoreSys
1111

12+
from tests.common import MockResponse
1213
from tests.const import TEST_ADDON_SLUG
1314

1415
LIST_USERS_RESPONSE = [
@@ -78,7 +79,10 @@ def fixture_mock_check_login(coresys: CoreSys):
7879

7980

8081
async def test_password_reset(
81-
api_client: TestClient, coresys: CoreSys, caplog: pytest.LogCaptureFixture
82+
api_client: TestClient,
83+
coresys: CoreSys,
84+
caplog: pytest.LogCaptureFixture,
85+
websession: MagicMock,
8286
):
8387
"""Test password reset api."""
8488
coresys.homeassistant.api.access_token = "abc123"
@@ -87,15 +91,12 @@ async def test_password_reset(
8791
days=1
8892
)
8993

90-
mock_websession = AsyncMock()
91-
mock_websession.post.return_value.__aenter__.return_value.status = 200
92-
with patch("supervisor.coresys.aiohttp.ClientSession.post") as post:
93-
post.return_value.__aenter__.return_value.status = 200
94-
resp = await api_client.post(
95-
"/auth/reset", json={"username": "john", "password": "doe"}
96-
)
97-
assert resp.status == 200
98-
assert "Successful password reset for 'john'" in caplog.text
94+
websession.post = MagicMock(return_value=MockResponse(status=200))
95+
resp = await api_client.post(
96+
"/auth/reset", json={"username": "john", "password": "doe"}
97+
)
98+
assert resp.status == 200
99+
assert "Successful password reset for 'john'" in caplog.text
99100

100101

101102
async def test_list_users(

tests/api/test_backups.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,7 @@ async def test_api_backup_restore_background(
276276
backup_type: str,
277277
options: dict[str, Any],
278278
tmp_supervisor_data: Path,
279+
supervisor_internet: AsyncMock,
279280
):
280281
"""Test background option on backup/restore APIs."""
281282
await coresys.core.set_state(CoreState.RUNNING)
@@ -472,6 +473,7 @@ async def test_restore_immediate_errors(
472473
api_client: TestClient,
473474
coresys: CoreSys,
474475
mock_partial_backup: Backup,
476+
supervisor_internet: AsyncMock,
475477
):
476478
"""Test restore errors that return immediately even in background mode."""
477479
await coresys.core.set_state(CoreState.RUNNING)
@@ -1010,6 +1012,7 @@ async def test_restore_backup_from_location(
10101012
coresys: CoreSys,
10111013
tmp_supervisor_data: Path,
10121014
local_location: str | None,
1015+
supervisor_internet: AsyncMock,
10131016
):
10141017
"""Test restoring a backup from a specific location."""
10151018
await coresys.core.set_state(CoreState.RUNNING)
@@ -1059,6 +1062,7 @@ async def test_restore_backup_from_location(
10591062
async def test_restore_backup_unencrypted_after_encrypted(
10601063
api_client: TestClient,
10611064
coresys: CoreSys,
1065+
supervisor_internet: AsyncMock,
10621066
):
10631067
"""Test restoring an unencrypted backup after an encrypted backup and vis-versa."""
10641068
enc_tar = copy(get_fixture_path("test_consolidate.tar"), coresys.config.path_backup)
@@ -1131,6 +1135,7 @@ async def test_restore_homeassistant_adds_env(
11311135
docker: DockerAPI,
11321136
backup_type: str,
11331137
postbody: dict[str, Any],
1138+
supervisor_internet: AsyncMock,
11341139
):
11351140
"""Test restoring home assistant from backup adds env to container."""
11361141
event = asyncio.Event()
@@ -1328,6 +1333,7 @@ async def test_missing_file_removes_location_from_cache(
13281333
url_path: str,
13291334
body: dict[str, Any] | None,
13301335
backup_file: str,
1336+
supervisor_internet: AsyncMock,
13311337
):
13321338
"""Test finding a missing file removes the location from cache."""
13331339
await coresys.core.set_state(CoreState.RUNNING)
@@ -1387,6 +1393,7 @@ async def test_missing_file_removes_backup_from_cache(
13871393
url_path: str,
13881394
body: dict[str, Any] | None,
13891395
backup_file: str,
1396+
supervisor_internet: AsyncMock,
13901397
):
13911398
"""Test finding a missing file removes the backup from cache if its the only one."""
13921399
await coresys.core.set_state(CoreState.RUNNING)
@@ -1412,7 +1419,9 @@ async def test_missing_file_removes_backup_from_cache(
14121419

14131420
@pytest.mark.usefixtures("tmp_supervisor_data")
14141421
async def test_immediate_list_after_missing_file_restore(
1415-
api_client: TestClient, coresys: CoreSys
1422+
api_client: TestClient,
1423+
coresys: CoreSys,
1424+
supervisor_internet: AsyncMock,
14161425
):
14171426
"""Test race with reload for missing file on restore does not error."""
14181427
await coresys.core.set_state(CoreState.RUNNING)

tests/api/test_discovery.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,12 +84,14 @@ async def test_api_list_discovery(
8484

8585
@pytest.mark.parametrize("api_client", [TEST_ADDON_SLUG], indirect=True)
8686
async def test_api_send_del_discovery(
87-
api_client: TestClient, coresys: CoreSys, install_addon_ssh: Addon
87+
api_client: TestClient,
88+
coresys: CoreSys,
89+
install_addon_ssh: Addon,
90+
websession: MagicMock,
8891
):
8992
"""Test adding and removing discovery."""
9093
install_addon_ssh.data["discovery"] = ["test"]
9194
coresys.homeassistant.api.ensure_access_token = AsyncMock()
92-
coresys.websession.post = MagicMock()
9395

9496
resp = await api_client.post("/discovery", json={"service": "test", "config": {}})
9597
assert resp.status == 200

tests/api/test_security.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Test Supervisor API."""
22

3+
from unittest.mock import AsyncMock
4+
35
import pytest
46

57
from supervisor.coresys import CoreSys
@@ -36,7 +38,9 @@ async def test_api_security_options_pwned(api_client, coresys: CoreSys):
3638

3739

3840
@pytest.mark.asyncio
39-
async def test_api_integrity_check(api_client, coresys: CoreSys):
41+
async def test_api_integrity_check(
42+
api_client, coresys: CoreSys, supervisor_internet: AsyncMock
43+
):
4044
"""Test security integrity check."""
4145
coresys.security.content_trust = False
4246

tests/api/test_store.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import asyncio
44
from pathlib import Path
5-
from unittest.mock import MagicMock, PropertyMock, patch
5+
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
66

77
from aiohttp import ClientResponse
88
from aiohttp.test_utils import TestClient
@@ -92,7 +92,9 @@ async def test_api_store_repositories_repository(
9292
assert result["data"]["slug"] == repository.slug
9393

9494

95-
async def test_api_store_add_repository(api_client: TestClient, coresys: CoreSys):
95+
async def test_api_store_add_repository(
96+
api_client: TestClient, coresys: CoreSys, supervisor_internet: AsyncMock
97+
) -> None:
9698
"""Test POST /store/repositories REST API."""
9799
with (
98100
patch("supervisor.store.repository.Repository.load", return_value=None),

tests/api/test_supervisor.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
# pylint: disable=protected-access
44
import time
5-
from unittest.mock import MagicMock, patch
5+
from unittest.mock import AsyncMock, MagicMock, patch
66

77
from aiohttp.test_utils import TestClient
88
from blockbuster import BlockingError
@@ -34,7 +34,7 @@ async def test_api_supervisor_options_debug(api_client: TestClient, coresys: Cor
3434

3535

3636
async def test_api_supervisor_options_add_repository(
37-
api_client: TestClient, coresys: CoreSys
37+
api_client: TestClient, coresys: CoreSys, supervisor_internet: AsyncMock
3838
):
3939
"""Test add a repository via POST /supervisor/options REST API."""
4040
assert REPO_URL not in coresys.store.repository_urls
@@ -231,7 +231,9 @@ async def test_api_supervisor_fallback_log_capture(
231231
capture_exception.assert_called_once()
232232

233233

234-
async def test_api_supervisor_reload(api_client: TestClient):
234+
async def test_api_supervisor_reload(
235+
api_client: TestClient, supervisor_internet: AsyncMock, websession: MagicMock
236+
):
235237
"""Test supervisor reload."""
236238
resp = await api_client.post("/supervisor/reload")
237239
assert resp.status == 200

0 commit comments

Comments
 (0)