Skip to content

Commit 182be9b

Browse files
committed
feat: Pull images before teardown containers on up command
Signed-off-by: Songmin Li <lisongmin@protonmail.com>
1 parent e8db551 commit 182be9b

File tree

3 files changed

+261
-0
lines changed

3 files changed

+261
-0
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Pull images before teardown containers on compose up, which reduce the downtime of services.

podman_compose.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,11 @@
2727
import tempfile
2828
import urllib.parse
2929
from asyncio import Task
30+
from dataclasses import dataclass
3031
from enum import Enum
3132
from typing import Any
3233
from typing import Callable
34+
from typing import ClassVar
3335
from typing import Iterable
3436
from typing import Union
3537
from typing import overload
@@ -3131,10 +3133,103 @@ def deps_from_container(args: argparse.Namespace, cnt: dict) -> set:
31313133
return cnt['_deps']
31323134

31333135

3136+
@dataclass
3137+
class PullImage:
3138+
POLICY_PRIORITY: ClassVar[dict[str, int]] = {
3139+
"always": 3,
3140+
"newer": 2,
3141+
"missing": 1,
3142+
"never": 0,
3143+
"build": 0,
3144+
}
3145+
3146+
image: str
3147+
policy: str = "missing"
3148+
quiet: bool = False
3149+
3150+
ignore_pull_error: bool = False
3151+
3152+
def __post_init__(self) -> None:
3153+
if self.policy not in self.POLICY_PRIORITY:
3154+
log.debug(f"Pull policy {self.policy} is not valid, using 'missing' instead")
3155+
self.policy = "missing"
3156+
3157+
def update_policy(self, new_policy: str) -> None:
3158+
if new_policy not in self.POLICY_PRIORITY:
3159+
log.debug(f"Pull policy {new_policy} is not valid, ignoring it")
3160+
return
3161+
3162+
if self.POLICY_PRIORITY[new_policy] > self.POLICY_PRIORITY[self.policy]:
3163+
self.policy = new_policy
3164+
3165+
@property
3166+
def pull_args(self) -> list[str]:
3167+
args = ["--policy", self.policy]
3168+
if self.quiet:
3169+
args.append("--quiet")
3170+
3171+
args.append(self.image)
3172+
return args
3173+
3174+
async def pull(self, podman: Podman) -> int | None:
3175+
if self.policy in ("never", "build"):
3176+
log.debug("Skipping pull of image %s due to policy %s", self.image, self.policy)
3177+
return 0
3178+
3179+
ret = await podman.run([], "pull", self.pull_args)
3180+
return ret if not self.ignore_pull_error else 0
3181+
3182+
@classmethod
3183+
async def pull_images(
3184+
cls,
3185+
podman: Podman,
3186+
args: argparse.Namespace,
3187+
services: list[dict[str, Any]],
3188+
) -> int | None:
3189+
pull_tasks = []
3190+
pull_images: dict[str, PullImage] = {}
3191+
for pull_service in services:
3192+
if not is_local(pull_service):
3193+
image = str(pull_service.get("image", ""))
3194+
policy = getattr(args, "pull", None) or pull_service.get("pull_policy", "missing")
3195+
3196+
if image in pull_images:
3197+
pull_images[image].update_policy(policy)
3198+
else:
3199+
pull_images[image] = PullImage(
3200+
image, policy, getattr(args, "quiet_pull", False)
3201+
)
3202+
3203+
if "build" in pull_service:
3204+
# From https://github.com/compose-spec/compose-spec/blob/main/build.md#using-build-and-image
3205+
# When both image and build are specified,
3206+
# we should try to pull the image first,
3207+
# and then build it if it does not exist.
3208+
# we should not stop here if pull fails.
3209+
pull_images[image].ignore_pull_error = True
3210+
3211+
for pull_image in pull_images.values():
3212+
pull_tasks.append(pull_image.pull(podman))
3213+
3214+
if pull_tasks:
3215+
ret = await asyncio.gather(*pull_tasks)
3216+
return next((r for r in ret if not r), 0)
3217+
3218+
return 0
3219+
3220+
31343221
@cmd_run(podman_compose, "up", "Create and start the entire stack or some of its services")
31353222
async def compose_up(compose: PodmanCompose, args: argparse.Namespace) -> int | None:
31363223
excluded = get_excluded(compose, args)
31373224

3225+
log.info("pulling images: ...")
3226+
pull_services = [v for k, v in compose.services.items() if k not in excluded]
3227+
err = await PullImage.pull_images(compose.podman, args, pull_services)
3228+
if err:
3229+
log.error("Pull image failed")
3230+
if not args.dry_run:
3231+
return err
3232+
31383233
log.info("building images: ...")
31393234

31403235
if not args.no_build:

tests/unit/test_pull_image.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
from argparse import Namespace
2+
from unittest import IsolatedAsyncioTestCase
3+
from unittest.mock import AsyncMock
4+
from unittest.mock import Mock
5+
from unittest.mock import call
6+
from unittest.mock import patch
7+
8+
from parameterized import parameterized
9+
10+
from podman_compose import PullImage
11+
12+
13+
class TestPullImage(IsolatedAsyncioTestCase):
14+
def test_unsupported_policy_fallback_to_missing(self) -> None:
15+
pull_image = PullImage("localhost/test:1", policy="unsupported")
16+
assert pull_image.policy == "missing"
17+
18+
def test_update_policy(self) -> None:
19+
pull_image = PullImage("localhost/test:1", policy="never")
20+
assert pull_image.policy == "never"
21+
22+
# not supported policy
23+
pull_image.update_policy("unsupported")
24+
assert pull_image.policy == "never"
25+
26+
pull_image.update_policy("missing")
27+
assert pull_image.policy == "missing"
28+
29+
pull_image.update_policy("newer")
30+
assert pull_image.policy == "newer"
31+
32+
pull_image.update_policy("always")
33+
assert pull_image.policy == "always"
34+
35+
# Ensure policy is not downgraded
36+
pull_image.update_policy("build")
37+
assert pull_image.policy == "always"
38+
39+
def test_pull_args(self) -> None:
40+
pull_image = PullImage("localhost/test:1", policy="always", quiet=True)
41+
assert pull_image.pull_args == ["--policy", "always", "--quiet", "localhost/test:1"]
42+
43+
pull_image.quiet = False
44+
assert pull_image.pull_args == ["--policy", "always", "localhost/test:1"]
45+
46+
@patch("podman_compose.Podman")
47+
async def test_pull_success(self, podman_mock: Mock) -> None:
48+
pull_image = PullImage("localhost/test:1", policy="always", quiet=True)
49+
50+
run_mock = AsyncMock()
51+
run_mock.return_value = 0
52+
podman_mock.run = run_mock
53+
54+
result = await pull_image.pull(podman_mock)
55+
assert result == 0
56+
run_mock.assert_called_once_with(
57+
[], "pull", ["--policy", "always", "--quiet", "localhost/test:1"]
58+
)
59+
60+
@patch("podman_compose.Podman")
61+
async def test_pull_failed(self, podman_mock: Mock) -> None:
62+
pull_image = PullImage(
63+
"localhost/test:1",
64+
policy="always",
65+
quiet=True,
66+
ignore_pull_error=True,
67+
)
68+
69+
run_mock = AsyncMock()
70+
run_mock.return_value = 1
71+
podman_mock.run = run_mock
72+
73+
# with ignore_pull_error=True, should return 0 even if pull fails
74+
result = await pull_image.pull(podman_mock)
75+
assert result == 0
76+
77+
# with ignore_pull_error=False, should return the actual error code
78+
pull_image.ignore_pull_error = False
79+
result = await pull_image.pull(podman_mock)
80+
assert result == 1
81+
82+
@patch("podman_compose.Podman")
83+
async def test_pull_with_never_policy(self, podman_mock: Mock) -> None:
84+
pull_image = PullImage(
85+
"localhost/test:1",
86+
policy="never",
87+
quiet=True,
88+
ignore_pull_error=True,
89+
)
90+
91+
run_mock = AsyncMock()
92+
run_mock.return_value = 1
93+
podman_mock.run = run_mock
94+
95+
result = await pull_image.pull(podman_mock)
96+
assert result == 0
97+
assert run_mock.call_count == 0
98+
99+
@parameterized.expand([
100+
(
101+
"Local image should not pull",
102+
Namespace(),
103+
[{"image": "localhost/a:latest"}],
104+
0,
105+
[],
106+
),
107+
(
108+
"Remote image should pull",
109+
Namespace(),
110+
[{"image": "ghcr.io/a:latest"}],
111+
1,
112+
[
113+
call([], "pull", ["--policy", "missing", "ghcr.io/a:latest"]),
114+
],
115+
),
116+
(
117+
"The same image in service should call once",
118+
Namespace(),
119+
[
120+
{"image": "ghcr.io/a:latest"},
121+
{"image": "ghcr.io/a:latest"},
122+
{"image": "ghcr.io/b:latest"},
123+
],
124+
2,
125+
[
126+
call([], "pull", ["--policy", "missing", "ghcr.io/a:latest"]),
127+
call([], "pull", ["--policy", "missing", "ghcr.io/b:latest"]),
128+
],
129+
),
130+
])
131+
@patch("podman_compose.Podman")
132+
async def test_pull_images(
133+
self,
134+
desc: str,
135+
args: Namespace,
136+
services: list[dict],
137+
call_count: int,
138+
calls: list,
139+
podman_mock: Mock,
140+
) -> None:
141+
run_mock = AsyncMock()
142+
run_mock.return_value = 0
143+
podman_mock.run = run_mock
144+
145+
assert await PullImage.pull_images(podman_mock, args, services) == 0
146+
assert run_mock.call_count == call_count
147+
if calls:
148+
run_mock.assert_has_calls(calls, any_order=True)
149+
150+
@patch("podman_compose.Podman")
151+
async def test_pull_images_with_build_section(
152+
self,
153+
podman_mock: Mock,
154+
) -> None:
155+
run_mock = AsyncMock()
156+
run_mock.return_value = 1
157+
podman_mock.run = run_mock
158+
159+
args: Namespace = Namespace()
160+
services: list[dict] = [
161+
{"image": "ghcr.io/a:latest", "build": {"context": "."}},
162+
]
163+
assert await PullImage.pull_images(podman_mock, args, services) == 0
164+
assert run_mock.call_count == 1
165+
run_mock.assert_called_with([], "pull", ["--policy", "missing", "ghcr.io/a:latest"])

0 commit comments

Comments
 (0)