fix(docker_refresh): Skip recreation for unchanged images
This commit is contained in:
1 parent
48fe3f025e
commit
e12feae399
2 files changed
+64
-10
No files matched your search
+26
-7
@@ -1,4 +1,4 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env -S uv run --script
|
||||||
# /// script
|
# /// script
|
||||||
# dependencies = []
|
# dependencies = []
|
||||||
# ///
|
# ///
|
||||||
@@ -22,6 +22,7 @@ class Project:
|
|||||||
files: tuple[Path, ...]
|
files: tuple[Path, ...]
|
||||||
environment_files: tuple[Path, ...]
|
environment_files: tuple[Path, ...]
|
||||||
services: set[str] = field(default_factory=set)
|
services: set[str] = field(default_factory=set)
|
||||||
|
running_images: dict[str, set[str]] = field(default_factory=dict)
|
||||||
|
|
||||||
def command(self) -> list[str]:
|
def command(self) -> list[str]:
|
||||||
command = [
|
command = [
|
||||||
@@ -116,6 +117,7 @@ def discover_projects(containers: list[dict[str, Any]], selected_projects: set[s
|
|||||||
if (existing.directory, existing.files, existing.environment_files) != (project.directory, project.files, project.environment_files):
|
if (existing.directory, existing.files, existing.environment_files) != (project.directory, project.files, project.environment_files):
|
||||||
raise ValueError(f"Project {name}: containers have inconsistent Compose file labels")
|
raise ValueError(f"Project {name}: containers have inconsistent Compose file labels")
|
||||||
existing.services.add(service)
|
existing.services.add(service)
|
||||||
|
existing.running_images.setdefault(service, set()).add(container.get("Image") or "")
|
||||||
return sorted(projects.values(), key=lambda project: project.name)
|
return sorted(projects.values(), key=lambda project: project.name)
|
||||||
|
|
||||||
|
|
||||||
@@ -148,13 +150,30 @@ def refresh_project(project: Project, dry_run: bool) -> None:
|
|||||||
services.append(name)
|
services.append(name)
|
||||||
if not services:
|
if not services:
|
||||||
return
|
return
|
||||||
for arguments in (
|
pull_command = [*command, "pull", "--policy", "always", *services]
|
||||||
["pull", "--policy", "always", *services],
|
logging.info("%s", shlex.join(pull_command))
|
||||||
["up", "--detach", "--no-deps", "--no-build", "--pull", "never", *services],
|
|
||||||
):
|
|
||||||
logging.info("%s", shlex.join([*command, *arguments]))
|
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
subprocess.run(args=[*command, *arguments], cwd=project.directory, check=True)
|
subprocess.run(args=pull_command, cwd=project.directory, check=True)
|
||||||
|
changed_services = []
|
||||||
|
for name in services:
|
||||||
|
result = subprocess.run(
|
||||||
|
args=["docker", "image", "inspect", "--format", "{{.Id}}", configured_services[name]["image"]],
|
||||||
|
check=True, capture_output=True, text=True, timeout=60,
|
||||||
|
)
|
||||||
|
image_id = result.stdout.strip()
|
||||||
|
if not image_id:
|
||||||
|
raise ValueError(f"Project {project.name}: image ID is missing for service {name}")
|
||||||
|
if project.running_images.get(name) == {image_id}:
|
||||||
|
logging.info("Skipping %s/%s: running image is up to date", project.name, name)
|
||||||
|
else:
|
||||||
|
changed_services.append(name)
|
||||||
|
services = changed_services
|
||||||
|
if not services:
|
||||||
|
return
|
||||||
|
up_command = [*command, "up", "--detach", "--no-deps", "--no-build", "--pull", "never", *services]
|
||||||
|
logging.info("%s", shlex.join(up_command))
|
||||||
|
if not dry_run:
|
||||||
|
subprocess.run(args=up_command, cwd=project.directory, check=True)
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
|
|||||||
+38
-3
@@ -46,6 +46,7 @@ def test_discovery_filters_and_groups_replicas(selected: set[str], expected: lis
|
|||||||
("app:latest", True, "False", "other"),
|
("app:latest", True, "False", "other"),
|
||||||
]:
|
]:
|
||||||
containers.append({
|
containers.append({
|
||||||
|
"Image": "sha256:running",
|
||||||
"State": {"Running": running},
|
"State": {"Running": running},
|
||||||
"Config": {"Image": image, "Labels": {
|
"Config": {"Image": image, "Labels": {
|
||||||
"com.docker.compose.project": project,
|
"com.docker.compose.project": project,
|
||||||
@@ -60,6 +61,7 @@ def test_discovery_filters_and_groups_replicas(selected: set[str], expected: lis
|
|||||||
assert [project.name for project in projects] == expected
|
assert [project.name for project in projects] == expected
|
||||||
for project in projects:
|
for project in projects:
|
||||||
assert project.services == {"app"}
|
assert project.services == {"app"}
|
||||||
|
assert project.running_images == {"app": {"sha256:running"}}
|
||||||
assert project.files == (Path("/srv/web/compose.yml"), Path("/srv/web/override.yml"))
|
assert project.files == (Path("/srv/web/compose.yml"), Path("/srv/web/override.yml"))
|
||||||
assert "--env-file" in project.command()
|
assert "--env-file" in project.command()
|
||||||
|
|
||||||
@@ -104,12 +106,45 @@ def test_refresh_commands_and_current_config_filtering(tmp_path: Path, dry_run:
|
|||||||
"db": {"image": "db:16"},
|
"db": {"image": "db:16"},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
with patch.object(script.subprocess, "run", return_value=response) as run:
|
image_response = subprocess.CompletedProcess(args=[], returncode=0, stdout="sha256:new\n")
|
||||||
|
with patch.object(script.subprocess, "run", side_effect=[response, response, image_response, response]) as run:
|
||||||
script.refresh_project(project=project, dry_run=dry_run)
|
script.refresh_project(project=project, dry_run=dry_run)
|
||||||
assert run.call_count == (1 if dry_run else 3)
|
assert run.call_count == (1 if dry_run else 4)
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
assert run.call_args_list[1].kwargs["args"] == [*project.command(), "pull", "--policy", "always", "app"]
|
assert run.call_args_list[1].kwargs["args"] == [*project.command(), "pull", "--policy", "always", "app"]
|
||||||
assert run.call_args_list[2].kwargs["args"] == [*project.command(), "up", "--detach", "--no-deps", "--no-build", "--pull", "never", "app"]
|
assert run.call_args_list[2].kwargs["args"] == ["docker", "image", "inspect", "--format", "{{.Id}}", "app:latest"]
|
||||||
|
assert run.call_args_list[3].kwargs["args"] == [*project.command(), "up", "--detach", "--no-deps", "--no-build", "--pull", "never", "app"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("running_images, expected_services", [
|
||||||
|
({"app": {"sha256:current"}, "worker": {"sha256:current"}}, []),
|
||||||
|
({"app": {"sha256:old"}, "worker": {"sha256:current"}}, ["app"]),
|
||||||
|
({"app": {"sha256:current", "sha256:old"}, "worker": {"sha256:current"}}, ["app"]),
|
||||||
|
({"app": {"sha256:old"}, "worker": {"sha256:old"}}, ["app", "worker"]),
|
||||||
|
])
|
||||||
|
def test_only_changed_services_are_redeployed(
|
||||||
|
tmp_path: Path, running_images: dict[str, set[str]], expected_services: list[str],
|
||||||
|
) -> None:
|
||||||
|
compose = tmp_path / "compose.yml"
|
||||||
|
compose.touch()
|
||||||
|
project = script.Project(
|
||||||
|
name="web", directory=tmp_path, files=(compose,), environment_files=(),
|
||||||
|
services={"app", "worker"}, running_images=running_images,
|
||||||
|
)
|
||||||
|
configuration = subprocess.CompletedProcess(args=[], returncode=0, stdout=json.dumps({
|
||||||
|
"services": {"app": {"image": "app:latest"}, "worker": {"image": "worker:latest"}},
|
||||||
|
}))
|
||||||
|
image_response = subprocess.CompletedProcess(args=[], returncode=0, stdout="sha256:current\n")
|
||||||
|
with patch.object(script.subprocess, "run", side_effect=[
|
||||||
|
configuration, configuration, image_response, image_response, configuration,
|
||||||
|
]) as run:
|
||||||
|
script.refresh_project(project=project, dry_run=False)
|
||||||
|
assert run.call_args_list[1].kwargs["args"] == [*project.command(), "pull", "--policy", "always", "app", "worker"]
|
||||||
|
assert run.call_count == (5 if expected_services else 4)
|
||||||
|
if expected_services:
|
||||||
|
assert run.call_args.kwargs["args"] == [
|
||||||
|
*project.command(), "up", "--detach", "--no-deps", "--no-build", "--pull", "never", *expected_services,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("build", [".", {"context": "."}])
|
@pytest.mark.parametrize("build", [".", {"context": "."}])
|
||||||
|
|||||||
Reference in new issue
Block a user