From b6bc23312aad982a0f27d9e1425713c3dc636af1 Mon Sep 17 00:00:00 2001 From: Simone Tollardo Date: Thu, 16 Oct 2025 17:22:50 +0200 Subject: [PATCH] fix: podman.domain.containers.run pulls image if not found Signed-off-by: Simone Tollardo --- podman/domain/containers_create.py | 16 ++- podman/domain/containers_run.py | 14 +-- podman/tests/unit/test_containersmanager.py | 107 ++++++++++++++++++-- 3 files changed, 113 insertions(+), 24 deletions(-) diff --git a/podman/domain/containers_create.py b/podman/domain/containers_create.py index 9aa30f1c..6ce1c7e9 100644 --- a/podman/domain/containers_create.py +++ b/podman/domain/containers_create.py @@ -7,13 +7,13 @@ from contextlib import suppress from typing import Any, Union from collections.abc import MutableMapping +import requests from podman import api from podman.domain.containers import Container from podman.domain.images import Image from podman.domain.pods import Pod from podman.domain.secrets import Secret -from podman.errors import ImageNotFound logger = logging.getLogger("podman.containers") @@ -361,7 +361,6 @@ def create( A Container object. Raises: - ImageNotFound: when Image not found by Podman service APIError: when Podman service reports an error """ if isinstance(image, Image): @@ -379,7 +378,18 @@ def create( headers={"content-type": "application/json"}, data=payload, ) - response.raise_for_status(not_found=ImageNotFound) + if response.status_code == requests.codes.not_found: + self.podman_client.images.pull( + image, + auth_config=kwargs.get("auth_config"), + platform=kwargs.get("platform"), + policy=kwargs.get("policy", "missing"), + ) + response = self.client.post( + "/containers/create", + headers={"content-type": "application/json"}, + data=payload, + ) container_id = response.json()["Id"] diff --git a/podman/domain/containers_run.py b/podman/domain/containers_run.py index 7ab60f97..ead63ea9 100644 --- a/podman/domain/containers_run.py +++ b/podman/domain/containers_run.py @@ -8,7 +8,7 @@ from podman.domain.containers import Container from podman.domain.images import Image -from podman.errors import ContainerError, ImageNotFound +from podman.errors import ContainerError logger = logging.getLogger("podman.containers") @@ -62,7 +62,6 @@ def run( Raises: ContainerError: when Container exists with a non-zero code - ImageNotFound: when Image not found by Podman service APIError: when Podman service reports an error """ if isinstance(image, Image): @@ -72,16 +71,7 @@ def run( if isinstance(command, str): command = [command] - try: - container = self.create(image=image_id, command=command, **kwargs) # type: ignore[attr-defined] - except ImageNotFound: - self.podman_client.images.pull( # type: ignore[attr-defined] - image_id, - auth_config=kwargs.get("auth_config"), - platform=kwargs.get("platform"), - policy=kwargs.get("policy", "missing"), - ) - container = self.create(image=image_id, command=command, **kwargs) # type: ignore[attr-defined] + container = self.create(image=image_id, command=command, **kwargs) # type: ignore[attr-defined] container.start() container.reload() diff --git a/podman/tests/unit/test_containersmanager.py b/podman/tests/unit/test_containersmanager.py index 26140031..1362c1cf 100644 --- a/podman/tests/unit/test_containersmanager.py +++ b/podman/tests/unit/test_containersmanager.py @@ -16,7 +16,7 @@ from podman.domain.containers import Container from podman.domain.containers_create import CreateMixin from podman.domain.containers_manager import ContainersManager -from podman.errors import ImageNotFound, NotFound +from podman.errors import NotFound FIRST_CONTAINER = { "Id": "87e1325c82424e49a00abdd4de08009eb76c7de8d228426a9b8af9318ced5ecd", @@ -338,17 +338,45 @@ def test_create(self, mock): @requests_mock.Mocker() def test_create_404(self, mock): + # mock the first POST to return 404, + # then the second POST (after pulling the image) to return 201 mock.post( tests.LIBPOD_URL + "/containers/create", - status_code=404, - json={ - "cause": "Image not found", - "message": "Image not found", - "response": 404, - }, + [ + { + "status_code": 404, + "json": { + "cause": "Image not found", + "message": "Image not found", + "response": 404, + }, + }, + { + "status_code": 201, + "json": { + "Id": "87e1325c82424e49a00abdd4de08009eb76c7de8d228426a9b8af9318ced5ecd", + "Warnings": [], + }, + }, + ], + ) + self.client.images.pull = MagicMock() + mock.get( + tests.LIBPOD_URL + f"/containers/{FIRST_CONTAINER['Id']}/json", + json=FIRST_CONTAINER, + ) + actual = self.client.containers.create("fedora", "/usr/bin/ls", cpu_count=9999) + self.client.images.pull.assert_called_once_with( + "fedora", + auth_config=None, + platform=None, + policy="missing", ) - with self.assertRaises(ImageNotFound): - self.client.containers.create("fedora", "/usr/bin/ls", cpu_count=9999) + self.assertIsInstance(actual, Container) + self.assertEqual(actual.id, FIRST_CONTAINER['Id']) + # 2 POSTs for create + # 1 GET for container json + self.assertEqual(mock.call_count, 3) @requests_mock.Mocker() def test_create_parse_host_port(self, mock): @@ -644,6 +672,67 @@ def test_run(self, mock): self.assertEqual(next(actual), b"This is a unittest - line 1") self.assertEqual(next(actual), b"This is a unittest - line 2") + @requests_mock.Mocker() + def test_run_404(self, mock): + # mock the first POST to return 404, + # then the second POST (after pulling the image) to return 201 + mock.post( + tests.LIBPOD_URL + "/containers/create", + [ + { + "status_code": 404, + "json": { + "cause": "Image not found", + "message": "Image not found", + "response": 404, + }, + }, + { + "status_code": 201, + "json": { + "Id": "87e1325c82424e49a00abdd4de08009eb76c7de8d228426a9b8af9318ced5ecd", + "Warnings": [], + }, + }, + ], + ) + self.client.images.pull = MagicMock() + mock.post( + tests.LIBPOD_URL + + "/containers/87e1325c82424e49a00abdd4de08009eb76c7de8d228426a9b8af9318ced5ecd/start", + status_code=204, + ) + mock.get( + tests.LIBPOD_URL + f"/containers/{FIRST_CONTAINER['Id']}/json", + json=FIRST_CONTAINER, + ) + + mock_logs = ( + b"This is a unittest - line 1", + b"This is a unittest - line 2", + ) + + with patch.multiple(Container, logs=DEFAULT, wait=DEFAULT, autospec=True) as mock_container: + mock_container["wait"].return_value = 0 + mock_container["logs"].return_value = iter(mock_logs) + + actual = self.client.containers.run("fedora", "/usr/bin/ls") + self.client.images.pull.assert_called_once_with( + "fedora", + auth_config=None, + platform=None, + policy="missing", + ) + self.assertIsInstance(actual, bytes) + self.assertEqual(actual, b"This is a unittest - line 1This is a unittest - line 2") + # 2 POSTs for create + # 1 POST for start + # 1 GET for container json + # 1 GET for reload + for r in mock.request_history: + print(r) + self.assertEqual(mock.call_count, 5) + if __name__ == "__main__": unittest.main()