Skip to content

Commit ef9785a

Browse files
authored
Merge pull request #1298 from lisongmin/feat-pull-before-teardown
Feat pull images before teardown containers on up command
2 parents 742fbdd + bf3f1e4 commit ef9785a

File tree

4 files changed

+276
-10
lines changed

4 files changed

+276
-10
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Images that have names starting with `localhost/` will no longer be
2+
pulled when both image and build sections exist.
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: 112 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from enum import Enum
3131
from typing import Any
3232
from typing import Callable
33+
from typing import ClassVar
3334
from typing import Iterable
3435
from typing import Sequence
3536
from typing import overload
@@ -2758,11 +2759,8 @@ def is_local(container: dict) -> bool:
27582759
* prefixed with localhost/
27592760
* has a build section and is not prefixed
27602761
"""
2761-
return (
2762-
"/" not in container["image"]
2763-
if "build" in container
2764-
else container["image"].startswith("localhost/")
2765-
)
2762+
image = container.get("image", "")
2763+
return image.startswith("localhost/") or ("build" in container and "/" not in image)
27662764

27672765

27682766
@cmd_run(podman_compose, "wait", "wait running containers to stop")
@@ -3206,9 +3204,101 @@ def deps_from_container(args: argparse.Namespace, cnt: dict) -> set:
32063204
return cnt['_deps']
32073205

32083206

3209-
@cmd_run(podman_compose, "up", "Create and start the entire stack or some of its services")
3210-
async def compose_up(compose: PodmanCompose, args: argparse.Namespace) -> int | None:
3211-
excluded = get_excluded(compose, args)
3207+
@dataclass
3208+
class PullImageSettings:
3209+
POLICY_PRIORITY: ClassVar[dict[str, int]] = {
3210+
"always": 3,
3211+
"newer": 2,
3212+
"missing": 1,
3213+
"never": 0,
3214+
"build": 0,
3215+
}
3216+
3217+
image: str
3218+
policy: str = "missing"
3219+
quiet: bool = False
3220+
3221+
ignore_pull_error: bool = False
3222+
3223+
def __post_init__(self) -> None:
3224+
if self.policy not in self.POLICY_PRIORITY:
3225+
log.debug("Pull policy %s is not valid, using 'missing' instead", self.policy)
3226+
self.policy = "missing"
3227+
3228+
def update_policy(self, new_policy: str) -> None:
3229+
if new_policy not in self.POLICY_PRIORITY:
3230+
log.debug("Pull policy %s is not valid, ignoring it", new_policy)
3231+
return
3232+
3233+
if self.POLICY_PRIORITY[new_policy] > self.POLICY_PRIORITY[self.policy]:
3234+
self.policy = new_policy
3235+
3236+
3237+
def settings_to_pull_args(settings: PullImageSettings) -> list[str]:
3238+
args = ["--policy", settings.policy]
3239+
if settings.quiet:
3240+
args.append("--quiet")
3241+
3242+
args.append(settings.image)
3243+
return args
3244+
3245+
3246+
async def pull_image(podman: Podman, settings: PullImageSettings) -> int | None:
3247+
if settings.policy in ("never", "build"):
3248+
log.debug("Skipping pull of image %s due to policy %s", settings.image, settings.policy)
3249+
return 0
3250+
3251+
ret = await podman.run([], "pull", settings_to_pull_args(settings))
3252+
return ret if not settings.ignore_pull_error else 0
3253+
3254+
3255+
async def pull_images(
3256+
podman: Podman,
3257+
args: argparse.Namespace,
3258+
services: list[dict[str, Any]],
3259+
) -> int | None:
3260+
pull_tasks = []
3261+
settingettings: dict[str, PullImageSettings] = {}
3262+
for pull_service in services:
3263+
if not is_local(pull_service):
3264+
image = str(pull_service.get("image", ""))
3265+
policy = getattr(args, "pull", None) or pull_service.get("pull_policy", "missing")
3266+
3267+
if image in settingettings:
3268+
settingettings[image].update_policy(policy)
3269+
else:
3270+
settingettings[image] = PullImageSettings(
3271+
image, policy, getattr(args, "quiet_pull", False)
3272+
)
3273+
3274+
if "build" in pull_service:
3275+
# From https://github.com/compose-spec/compose-spec/blob/main/build.md#using-build-and-image
3276+
# When both image and build are specified,
3277+
# we should try to pull the image first,
3278+
# and then build it if it does not exist.
3279+
# we should not stop here if pull fails.
3280+
settingettings[image].ignore_pull_error = True
3281+
3282+
for s in settingettings.values():
3283+
pull_tasks.append(pull_image(podman, s))
3284+
3285+
if pull_tasks:
3286+
ret = await asyncio.gather(*pull_tasks)
3287+
return next((r for r in ret if not r), 0)
3288+
3289+
return 0
3290+
3291+
3292+
async def prepare_images(
3293+
compose: PodmanCompose, args: argparse.Namespace, excluded: set[str]
3294+
) -> int | None:
3295+
log.info("pulling images: ...")
3296+
3297+
pull_services = [v for k, v in compose.services.items() if k not in excluded]
3298+
err = await pull_images(compose.podman, args, pull_services)
3299+
if err:
3300+
log.error("Pull image failed")
3301+
return err
32123302

32133303
log.info("building images: ...")
32143304

@@ -3218,8 +3308,20 @@ async def compose_up(compose: PodmanCompose, args: argparse.Namespace) -> int |
32183308
build_exit_code = await compose.commands["build"](compose, build_args)
32193309
if build_exit_code != 0:
32203310
log.error("Build command failed")
3221-
if not args.dry_run:
3222-
return build_exit_code
3311+
return build_exit_code
3312+
3313+
return 0
3314+
3315+
3316+
@cmd_run(podman_compose, "up", "Create and start the entire stack or some of its services")
3317+
async def compose_up(compose: PodmanCompose, args: argparse.Namespace) -> int | None:
3318+
excluded = get_excluded(compose, args)
3319+
3320+
exit_code = await prepare_images(compose, args, excluded)
3321+
if exit_code != 0:
3322+
log.error("Prepare images failed")
3323+
if not args.dry_run:
3324+
return exit_code
32233325

32243326
# if needed, tear down existing containers
32253327

tests/unit/test_pull_image.py

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

0 commit comments

Comments
 (0)