Skip to content

Commit bbb9469

Browse files
authored
Write cidfiles of Docker containers and mount them individually to /run/cid (#6154)
* Write cidfiles of Docker containers and mount them individually to /run/cid There is no standard way to get the container ID in the container itself, which can be needed for instance for #6006. The usual pattern is to use the --cidfile argument of Docker CLI and mount the generated file to the container. However, this is feature of Docker CLI and we can't use it when creating the containers via API. To get container ID to implement native logging in e.g. Core as well, we need the help of the Supervisor. This change implements similar feature fully in Supervisor's DockerAPI class that orchestrates lifetime of all containers managed by Supervisor. The files are created in the SUPERVISOR_DATA directory, as it needs to be persisted between reboots, just as the instances of Docker containers are. Supervisor's cidfile must be created when starting the Supervisor itself, for that see home-assistant/operating-system#4276. * Address review comments, fix mounting of the cidfile
1 parent 859c32a commit bbb9469

File tree

7 files changed

+224
-1
lines changed

7 files changed

+224
-1
lines changed

supervisor/bootstrap.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,10 @@ def initialize_system(coresys: CoreSys) -> None:
226226
)
227227
config.path_addon_configs.mkdir()
228228

229+
if not config.path_cid_files.is_dir():
230+
_LOGGER.debug("Creating Docker cidfiles folder at '%s'", config.path_cid_files)
231+
config.path_cid_files.mkdir()
232+
229233

230234
def warning_handler(message, category, filename, lineno, file=None, line=None):
231235
"""Warning handler which logs warnings using the logging module."""

supervisor/config.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
EMERGENCY_DATA = PurePath("emergency")
5555
ADDON_CONFIGS = PurePath("addon_configs")
5656
CORE_BACKUP_DATA = PurePath("core/backup")
57+
CID_FILES = PurePath("cid_files")
5758

5859
DEFAULT_BOOT_TIME = datetime.fromtimestamp(0, UTC).isoformat()
5960

@@ -399,6 +400,16 @@ def path_extern_media(self) -> PurePath:
399400
"""Return root media data folder external for Docker."""
400401
return PurePath(self.path_extern_supervisor, MEDIA_DATA)
401402

403+
@property
404+
def path_cid_files(self) -> Path:
405+
"""Return CID files folder."""
406+
return self.path_supervisor / CID_FILES
407+
408+
@property
409+
def path_extern_cid_files(self) -> PurePath:
410+
"""Return CID files folder."""
411+
return PurePath(self.path_extern_supervisor, CID_FILES)
412+
402413
@property
403414
def addons_repositories(self) -> list[str]:
404415
"""Return list of custom Add-on repositories."""

supervisor/docker/manager.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,11 +321,36 @@ def run(
321321
if not network_mode:
322322
kwargs["network"] = None
323323

324+
# Setup cidfile and bind mount it
325+
cidfile_path = None
326+
if name:
327+
cidfile_path = self.coresys.config.path_cid_files / f"{name}.cid"
328+
329+
# Remove the file if it exists e.g. as a leftover from unclean shutdown
330+
if cidfile_path.is_file():
331+
with suppress(OSError):
332+
cidfile_path.unlink(missing_ok=True)
333+
334+
extern_cidfile_path = (
335+
self.coresys.config.path_extern_cid_files / f"{name}.cid"
336+
)
337+
338+
# Bind mount to /run/cid in container
339+
if "volumes" not in kwargs:
340+
kwargs["volumes"] = {}
341+
kwargs["volumes"][str(extern_cidfile_path)] = {
342+
"bind": "/run/cid",
343+
"mode": "ro",
344+
}
345+
324346
# Create container
325347
try:
326348
container = self.containers.create(
327349
f"{image}:{tag}", use_config_proxy=False, **kwargs
328350
)
351+
if cidfile_path:
352+
with cidfile_path.open("w", encoding="ascii") as cidfile:
353+
cidfile.write(str(container.id))
329354
except docker_errors.NotFound as err:
330355
raise DockerNotFound(
331356
f"Image {image}:{tag} does not exist for {name}", _LOGGER.error
@@ -563,6 +588,10 @@ def stop_container(
563588
_LOGGER.info("Cleaning %s application", name)
564589
docker_container.remove(force=True, v=True)
565590

591+
cidfile_path = self.coresys.config.path_cid_files / f"{name}.cid"
592+
with suppress(OSError):
593+
cidfile_path.unlink(missing_ok=True)
594+
566595
def start_container(self, name: str) -> None:
567596
"""Start Docker container."""
568597
try:

tests/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,7 @@ async def tmp_supervisor_data(coresys: CoreSys, tmp_path: Path) -> Path:
488488
coresys.config.path_addon_configs.mkdir(parents=True)
489489
coresys.config.path_ssl.mkdir()
490490
coresys.config.path_core_backup.mkdir(parents=True)
491+
coresys.config.path_cid_files.mkdir()
491492
yield tmp_path
492493

493494

tests/docker/test_addon.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,7 @@ async def test_addon_run_add_host_error(
347347
addonsdata_system: dict[str, Data],
348348
capture_exception: Mock,
349349
path_extern,
350+
tmp_supervisor_data: Path,
350351
):
351352
"""Test error adding host when addon is run."""
352353
await coresys.dbus.timedate.connect(coresys.dbus.bus)
@@ -433,6 +434,7 @@ async def test_addon_new_device(
433434
dev_path: str,
434435
cgroup: str,
435436
is_os: bool,
437+
tmp_supervisor_data: Path,
436438
):
437439
"""Test new device that is listed in static devices."""
438440
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
@@ -463,6 +465,7 @@ async def test_addon_new_device_no_haos(
463465
install_addon_ssh: Addon,
464466
docker: DockerAPI,
465467
dev_path: str,
468+
tmp_supervisor_data: Path,
466469
):
467470
"""Test new device that is listed in static devices on non HAOS system with CGroup V2."""
468471
coresys.hardware.disk.get_disk_free_space = lambda x: 5000

tests/docker/test_manager.py

Lines changed: 173 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
"""Test Docker manager."""
22

3-
from unittest.mock import MagicMock
3+
import asyncio
4+
from unittest.mock import MagicMock, patch
45

56
from docker.errors import DockerException
67
import pytest
78
from requests import RequestException
89

10+
from supervisor.coresys import CoreSys
911
from supervisor.docker.manager import CommandReturn, DockerAPI
1012
from supervisor.exceptions import DockerError
1113

@@ -134,3 +136,173 @@ async def test_run_command_custom_stdout_stderr(docker: DockerAPI):
134136
# Verify the result
135137
assert result.exit_code == 0
136138
assert result.output == b"output"
139+
140+
141+
async def test_run_container_with_cidfile(
142+
coresys: CoreSys, docker: DockerAPI, path_extern, tmp_supervisor_data
143+
):
144+
"""Test container creation with cidfile and bind mount."""
145+
# Mock container
146+
mock_container = MagicMock()
147+
mock_container.id = "test_container_id_12345"
148+
149+
container_name = "test_container"
150+
cidfile_path = coresys.config.path_cid_files / f"{container_name}.cid"
151+
extern_cidfile_path = coresys.config.path_extern_cid_files / f"{container_name}.cid"
152+
153+
docker.docker.containers.run.return_value = mock_container
154+
155+
# Mock container creation
156+
with patch.object(
157+
docker.containers, "create", return_value=mock_container
158+
) as create_mock:
159+
# Execute run with a container name
160+
loop = asyncio.get_event_loop()
161+
result = await loop.run_in_executor(
162+
None,
163+
lambda kwrgs: docker.run(**kwrgs),
164+
{"image": "test_image", "tag": "latest", "name": container_name},
165+
)
166+
167+
# Check the container creation parameters
168+
create_mock.assert_called_once()
169+
kwargs = create_mock.call_args[1]
170+
171+
assert "volumes" in kwargs
172+
assert str(extern_cidfile_path) in kwargs["volumes"]
173+
assert kwargs["volumes"][str(extern_cidfile_path)]["bind"] == "/run/cid"
174+
assert kwargs["volumes"][str(extern_cidfile_path)]["mode"] == "ro"
175+
176+
# Verify container start was called
177+
mock_container.start.assert_called_once()
178+
179+
# Verify cidfile was written with container ID
180+
assert cidfile_path.exists()
181+
assert cidfile_path.read_text() == mock_container.id
182+
183+
assert result == mock_container
184+
185+
186+
async def test_run_container_with_leftover_cidfile(
187+
coresys: CoreSys, docker: DockerAPI, path_extern, tmp_supervisor_data
188+
):
189+
"""Test container creation removes leftover cidfile before creating new one."""
190+
# Mock container
191+
mock_container = MagicMock()
192+
mock_container.id = "test_container_id_new"
193+
194+
container_name = "test_container"
195+
cidfile_path = coresys.config.path_cid_files / f"{container_name}.cid"
196+
197+
# Create a leftover cidfile
198+
cidfile_path.touch()
199+
200+
# Mock container creation
201+
with patch.object(
202+
docker.containers, "create", return_value=mock_container
203+
) as create_mock:
204+
# Execute run with a container name
205+
loop = asyncio.get_event_loop()
206+
result = await loop.run_in_executor(
207+
None,
208+
lambda kwrgs: docker.run(**kwrgs),
209+
{"image": "test_image", "tag": "latest", "name": container_name},
210+
)
211+
212+
# Verify container was created
213+
create_mock.assert_called_once()
214+
215+
# Verify new cidfile was written with container ID
216+
assert cidfile_path.exists()
217+
assert cidfile_path.read_text() == mock_container.id
218+
219+
assert result == mock_container
220+
221+
222+
async def test_stop_container_with_cidfile_cleanup(
223+
coresys: CoreSys, docker: DockerAPI, path_extern, tmp_supervisor_data
224+
):
225+
"""Test container stop with cidfile cleanup."""
226+
# Mock container
227+
mock_container = MagicMock()
228+
mock_container.status = "running"
229+
230+
container_name = "test_container"
231+
cidfile_path = coresys.config.path_cid_files / f"{container_name}.cid"
232+
233+
# Create a cidfile
234+
cidfile_path.touch()
235+
236+
# Mock the containers.get method and cidfile cleanup
237+
with (
238+
patch.object(docker.containers, "get", return_value=mock_container),
239+
):
240+
# Call stop_container with remove_container=True
241+
loop = asyncio.get_event_loop()
242+
await loop.run_in_executor(
243+
None,
244+
lambda kwrgs: docker.stop_container(**kwrgs),
245+
{"timeout": 10, "remove_container": True, "name": container_name},
246+
)
247+
248+
# Verify container operations
249+
mock_container.stop.assert_called_once_with(timeout=10)
250+
mock_container.remove.assert_called_once_with(force=True, v=True)
251+
252+
assert not cidfile_path.exists()
253+
254+
255+
async def test_stop_container_without_removal_no_cidfile_cleanup(docker: DockerAPI):
256+
"""Test container stop without removal doesn't clean up cidfile."""
257+
# Mock container
258+
mock_container = MagicMock()
259+
mock_container.status = "running"
260+
261+
container_name = "test_container"
262+
263+
# Mock the containers.get method and cidfile cleanup
264+
with (
265+
patch.object(docker.containers, "get", return_value=mock_container),
266+
patch("pathlib.Path.unlink") as mock_unlink,
267+
):
268+
# Call stop_container with remove_container=False
269+
docker.stop_container(container_name, timeout=10, remove_container=False)
270+
271+
# Verify container operations
272+
mock_container.stop.assert_called_once_with(timeout=10)
273+
mock_container.remove.assert_not_called()
274+
275+
# Verify cidfile cleanup was NOT called
276+
mock_unlink.assert_not_called()
277+
278+
279+
async def test_cidfile_cleanup_handles_oserror(
280+
coresys: CoreSys, docker: DockerAPI, path_extern, tmp_supervisor_data
281+
):
282+
"""Test that cidfile cleanup handles OSError gracefully."""
283+
# Mock container
284+
mock_container = MagicMock()
285+
mock_container.status = "running"
286+
287+
container_name = "test_container"
288+
cidfile_path = coresys.config.path_cid_files / f"{container_name}.cid"
289+
290+
# Create a cidfile
291+
cidfile_path.touch()
292+
293+
# Mock the containers.get method and cidfile cleanup to raise OSError
294+
with (
295+
patch.object(docker.containers, "get", return_value=mock_container),
296+
patch(
297+
"pathlib.Path.unlink", side_effect=OSError("File not found")
298+
) as mock_unlink,
299+
):
300+
# Call stop_container - should not raise exception
301+
docker.stop_container(container_name, timeout=10, remove_container=True)
302+
303+
# Verify container operations completed
304+
mock_container.stop.assert_called_once_with(timeout=10)
305+
mock_container.remove.assert_called_once_with(force=True, v=True)
306+
307+
# Verify cidfile cleanup was attempted
308+
mock_unlink.assert_called_once_with(missing_ok=True)

tests/plugins/test_plugin_base.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Test base plugin functionality."""
22

33
import asyncio
4+
from pathlib import Path
45
from unittest.mock import ANY, MagicMock, Mock, PropertyMock, patch
56

67
from awesomeversion import AwesomeVersion
@@ -165,6 +166,8 @@ async def test_plugin_watchdog_max_failed_attempts(
165166
error: PluginError,
166167
container: MagicMock,
167168
caplog: pytest.LogCaptureFixture,
169+
tmp_supervisor_data: Path,
170+
path_extern,
168171
) -> None:
169172
"""Test plugin watchdog gives up after max failed attempts."""
170173
with patch.object(type(plugin.instance), "attach"):

0 commit comments

Comments
 (0)