Skip to content

Commit a38a34a

Browse files
authored
Merge pull request #1294 from lisongmin/feat-service-level-config-hash
Support service level config hash
2 parents e8db551 + f66edc7 commit a38a34a

9 files changed

+293
-38
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Support service level configuration change detection on up command

podman_compose.py

Lines changed: 107 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
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
@@ -1557,6 +1558,17 @@ async def wait_with_timeout(coro: Any, timeout: int | float) -> Any:
15571558
###################
15581559

15591560

1561+
@dataclass
1562+
class ExistingContainer:
1563+
name: str
1564+
id: str
1565+
service_name: str
1566+
config_hash: str
1567+
exited: bool
1568+
state: str
1569+
status: str
1570+
1571+
15601572
class Podman:
15611573
def __init__(
15621574
self,
@@ -1739,6 +1751,36 @@ async def volume_ls(self) -> list[str]:
17391751
volumes = output.splitlines()
17401752
return volumes
17411753

1754+
async def existing_containers(self, project_name: str) -> dict[str, ExistingContainer]:
1755+
output = await self.output(
1756+
[],
1757+
"ps",
1758+
[
1759+
"--filter",
1760+
f"label=io.podman.compose.project={project_name}",
1761+
"-a",
1762+
"--format",
1763+
"json",
1764+
],
1765+
)
1766+
1767+
containers = json.loads(output)
1768+
return {
1769+
c.get("Names")[0]: ExistingContainer(
1770+
name=c.get("Names")[0],
1771+
id=c.get("Id"),
1772+
service_name=(
1773+
c.get("Labels", {}).get("io.podman.compose.service", "")
1774+
or c.get("Labels", {}).get("com.docker.compose.service", "")
1775+
),
1776+
config_hash=c.get("Labels", {}).get("io.podman.compose.config-hash", ""),
1777+
exited=c.get("Exited", False),
1778+
state=c.get("State", ""),
1779+
status=c.get("Status", ""),
1780+
)
1781+
for c in containers
1782+
}
1783+
17421784

17431785
def normalize_service(service: dict[str, Any], sub_dir: str = "") -> dict[str, Any]:
17441786
if isinstance(service, ResetTag):
@@ -2091,6 +2133,27 @@ async def run(self, argv: list[str] | None = None) -> None:
20912133
if isinstance(retcode, int):
20922134
sys.exit(retcode)
20932135

2136+
def config_hash(self, service: dict[str, Any]) -> str:
2137+
"""
2138+
Returns a hash of the service configuration.
2139+
This is used to detect changes in the service configuration.
2140+
"""
2141+
if "_config_hash" in service:
2142+
return service["_config_hash"]
2143+
2144+
# Use a stable representation of the service configuration
2145+
jsonable_servcie = self.original_service(service)
2146+
config_str = json.dumps(jsonable_servcie, sort_keys=True)
2147+
service["_config_hash"] = hashlib.sha256(config_str.encode('utf-8')).hexdigest()
2148+
return service["_config_hash"]
2149+
2150+
def original_service(self, service: dict[str, Any]) -> dict[str, Any]:
2151+
"""
2152+
Returns the original service configuration without any overrides or resets.
2153+
This is used to compare the original service configuration with the current one.
2154+
"""
2155+
return {k: v for k, v in service.items() if isinstance(k, str) and not k.startswith("_")}
2156+
20942157
def resolve_pod_name(self) -> str | None:
20952158
# Priorities:
20962159
# - Command line --in-pod
@@ -2387,7 +2450,6 @@ def _parse_compose_file(self) -> None:
23872450
# volumes: [...]
23882451
self.vols = compose.get("volumes", {})
23892452
podman_compose_labels = [
2390-
"io.podman.compose.config-hash=" + self.yaml_hash,
23912453
"io.podman.compose.project=" + project_name,
23922454
"io.podman.compose.version=" + __version__,
23932455
f"PODMAN_SYSTEMD_UNIT=podman-compose@{project_name}.service",
@@ -2445,6 +2507,7 @@ def _parse_compose_file(self) -> None:
24452507
cnt["ports"] = norm_ports(cnt.get("ports"))
24462508
labels.extend(podman_compose_labels)
24472509
labels.extend([
2510+
f"io.podman.compose.config-hash={self.config_hash(service_desc)}",
24482511
f"com.docker.compose.container-number={num}",
24492512
f"io.podman.compose.service={service_name}",
24502513
f"com.docker.compose.service={service_name}",
@@ -3148,46 +3211,58 @@ async def compose_up(compose: PodmanCompose, args: argparse.Namespace) -> int |
31483211

31493212
# if needed, tear down existing containers
31503213

3151-
existing_containers: dict[str, str | None] = {
3152-
c['Names'][0]: c['Labels'].get('io.podman.compose.config-hash')
3153-
for c in json.loads(
3154-
await compose.podman.output(
3155-
[],
3156-
"ps",
3157-
[
3158-
"--filter",
3159-
f"label=io.podman.compose.project={compose.project_name}",
3160-
"-a",
3161-
"--format",
3162-
"json",
3163-
],
3164-
)
3165-
)
3166-
}
3214+
assert compose.project_name is not None, "Project name must be set before running up command"
3215+
existing_containers = await compose.podman.existing_containers(compose.project_name)
3216+
recreate_services: set[str] = set()
3217+
running_services = {c.service_name for c in existing_containers.values() if not c.exited}
31673218

3168-
if len(existing_containers) > 0:
3219+
if existing_containers:
31693220
if args.force_recreate and args.no_recreate:
31703221
log.error(
31713222
"Cannot use --force-recreate and --no-recreate at the same time, "
31723223
"please remove one of them"
31733224
)
31743225
return 1
31753226

3176-
if args.force_recreate:
3177-
teardown_needed = True
3178-
elif args.no_recreate:
3179-
teardown_needed = False
3180-
else:
3181-
# default is to tear down everything if any container is stale
3182-
teardown_needed = (
3183-
len([h for h in existing_containers.values() if h != compose.yaml_hash]) > 0
3184-
)
3227+
if not args.no_recreate:
3228+
for c in existing_containers.values():
3229+
if (
3230+
c.service_name in excluded
3231+
or c.service_name not in compose.services # orphaned container
3232+
):
3233+
continue
3234+
3235+
service = compose.services[c.service_name]
3236+
if args.force_recreate or c.config_hash != compose.config_hash(service):
3237+
recreate_services.add(c.service_name)
3238+
3239+
# Running dependents of service are removed by down command
3240+
# so we need to recreate and start them too
3241+
dependents = {
3242+
dep.name
3243+
for dep in service.get(DependField.DEPENDENTS, [])
3244+
if dep.name in running_services
3245+
}
3246+
if dependents:
3247+
log.debug(
3248+
"Service %s's dependents should be recreated and running again: %s",
3249+
c.service_name,
3250+
dependents,
3251+
)
3252+
recreate_services.update(dependents)
3253+
excluded = excluded - dependents
3254+
3255+
log.debug("** excluding update: %s", excluded)
3256+
log.debug("Prepare to recreate services: %s", recreate_services)
3257+
3258+
teardown_needed = bool(recreate_services)
31853259

31863260
if teardown_needed:
31873261
log.info("tearing down existing containers: ...")
3188-
down_args = argparse.Namespace(**dict(args.__dict__, volumes=False, rmi=None))
3262+
down_args = argparse.Namespace(
3263+
**dict(args.__dict__, volumes=False, rmi=None, services=recreate_services)
3264+
)
31893265
await compose.commands["down"](compose, down_args)
3190-
existing_containers = {}
31913266
log.info("tearing down existing containers: done\n\n")
31923267

31933268
await create_pods(compose)
@@ -3196,7 +3271,9 @@ async def compose_up(compose: PodmanCompose, args: argparse.Namespace) -> int |
31963271

31973272
create_error_codes: list[int | None] = []
31983273
for cnt in compose.containers:
3199-
if cnt["_service"] in excluded or cnt["name"] in existing_containers:
3274+
if cnt["_service"] in excluded or (
3275+
cnt["name"] in existing_containers and cnt["_service"] not in recreate_services
3276+
):
32003277
log.debug("** skipping create: %s", cnt["name"])
32013278
continue
32023279
podman_args = await container_to_args(compose, cnt, detached=False, no_deps=args.no_deps)

tests/integration/compose_up_behavior/__init__.py

Whitespace-only changes.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
services:
2+
app:
3+
image: nopush/podman-compose-test
4+
command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-p", "8080"]
5+
environment:
6+
- SERVICE_CHANGE=true
7+
depends_on:
8+
- db
9+
db:
10+
image: nopush/podman-compose-test
11+
command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-p", "8080"]
12+
no_deps:
13+
image: nopush/podman-compose-test
14+
command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-p", "8080"]
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
services:
2+
app:
3+
image: nopush/podman-compose-test
4+
command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-p", "8080"]
5+
depends_on:
6+
- db
7+
db:
8+
image: nopush/podman-compose-test
9+
command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-p", "8080"]
10+
no_deps:
11+
image: nopush/podman-compose-test
12+
command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-p", "8080"]
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
services:
2+
app:
3+
image: nopush/podman-compose-test
4+
command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-p", "8080"]
5+
depends_on:
6+
- db
7+
db:
8+
image: nopush/podman-compose-test
9+
command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-p", "8080"]
10+
environment:
11+
- SERVICE_CHANGE=true
12+
no_deps:
13+
image: nopush/podman-compose-test
14+
command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-p", "8080"]
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# SPDX-License-Identifier: GPL-2.0
2+
3+
import json
4+
import os
5+
import unittest
6+
from typing import Any
7+
8+
from parameterized import parameterized
9+
10+
from tests.integration.test_utils import RunSubprocessMixin
11+
from tests.integration.test_utils import podman_compose_path
12+
from tests.integration.test_utils import test_path
13+
14+
15+
def compose_yaml_path(scenario: str) -> str:
16+
return os.path.join(
17+
os.path.join(test_path(), "compose_up_behavior"), f"docker-compose_{scenario}.yaml"
18+
)
19+
20+
21+
class TestComposeDownBehavior(unittest.TestCase, RunSubprocessMixin):
22+
def get_existing_containers(self, scenario: str) -> dict[str, Any]:
23+
out, _ = self.run_subprocess_assert_returncode(
24+
[
25+
podman_compose_path(),
26+
"-f",
27+
compose_yaml_path(scenario),
28+
"ps",
29+
"--format",
30+
'json',
31+
],
32+
)
33+
containers = json.loads(out)
34+
return {
35+
c.get("Names")[0]: {
36+
"name": c.get("Names")[0],
37+
"id": c.get("Id"),
38+
"service_name": c.get("Labels", {}).get("io.podman.compose.service", ""),
39+
"config_hash": c.get("Labels", {}).get("io.podman.compose.config-hash", ""),
40+
"exited": c.get("Exited"),
41+
}
42+
for c in containers
43+
}
44+
45+
@parameterized.expand([
46+
(
47+
"service_change_app",
48+
"service_change_base",
49+
["up"],
50+
{"app"},
51+
),
52+
(
53+
"service_change_app",
54+
"service_change_base",
55+
["up", "app"],
56+
{"app"},
57+
),
58+
(
59+
"service_change_app",
60+
"service_change_base",
61+
["up", "db"],
62+
set(),
63+
),
64+
(
65+
"service_change_db",
66+
"service_change_base",
67+
["up"],
68+
{"db", "app"},
69+
),
70+
(
71+
"service_change_db",
72+
"service_change_base",
73+
["up", "app"],
74+
{"db", "app"},
75+
),
76+
(
77+
"service_change_db",
78+
"service_change_base",
79+
["up", "db"],
80+
{"db", "app"},
81+
),
82+
])
83+
def test_recreate_on_config_changed(
84+
self,
85+
change_to: str,
86+
running_scenario: str,
87+
command_args: list[str],
88+
expect_recreated_services: set[str],
89+
) -> None:
90+
try:
91+
self.run_subprocess_assert_returncode(
92+
[podman_compose_path(), "-f", compose_yaml_path(running_scenario), "up", "-d"],
93+
)
94+
95+
original_containers = self.get_existing_containers(running_scenario)
96+
97+
out, err = self.run_subprocess_assert_returncode(
98+
[
99+
podman_compose_path(),
100+
"--verbose",
101+
"-f",
102+
compose_yaml_path(change_to),
103+
*command_args,
104+
"-d",
105+
],
106+
)
107+
108+
new_containers = self.get_existing_containers(change_to)
109+
recreated_services = {
110+
c.get("service_name")
111+
for c in original_containers.values()
112+
if new_containers.get(c.get("name"), {}).get("id") != c.get("id")
113+
}
114+
115+
self.assertEqual(
116+
recreated_services,
117+
expect_recreated_services,
118+
msg=f"Expected services to be recreated: {expect_recreated_services}, "
119+
f"but got: {recreated_services}, containers: "
120+
f"[{original_containers}, {new_containers}]",
121+
)
122+
self.assertTrue(
123+
all([c.get("exited") is False for c in new_containers.values()]),
124+
msg="Not all containers are running after up command",
125+
)
126+
127+
finally:
128+
self.run_subprocess_assert_returncode([
129+
podman_compose_path(),
130+
"-f",
131+
compose_yaml_path(change_to),
132+
"down",
133+
"-t",
134+
"0",
135+
])

0 commit comments

Comments
 (0)