diff --git a/docker_refresh.py b/docker_refresh.py index a1f18d4..869dea2 100755 --- a/docker_refresh.py +++ b/docker_refresh.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python3 +#!/usr/bin/env -S uv run --script # /// script # dependencies = [] # /// @@ -22,6 +22,7 @@ class Project: files: tuple[Path, ...] environment_files: tuple[Path, ...] services: set[str] = field(default_factory=set) + running_images: dict[str, set[str]] = field(default_factory=dict) def command(self) -> list[str]: 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): raise ValueError(f"Project {name}: containers have inconsistent Compose file labels") existing.services.add(service) + existing.running_images.setdefault(service, set()).add(container.get("Image") or "") 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) if not services: return - for arguments in ( - ["pull", "--policy", "always", *services], - ["up", "--detach", "--no-deps", "--no-build", "--pull", "never", *services], - ): - logging.info("%s", shlex.join([*command, *arguments])) - if not dry_run: - subprocess.run(args=[*command, *arguments], cwd=project.directory, check=True) + pull_command = [*command, "pull", "--policy", "always", *services] + logging.info("%s", shlex.join(pull_command)) + if not dry_run: + 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: diff --git a/docker_refresh_test.py b/docker_refresh_test.py index cb5c993..dc83647 100755 --- a/docker_refresh_test.py +++ b/docker_refresh_test.py @@ -46,6 +46,7 @@ def test_discovery_filters_and_groups_replicas(selected: set[str], expected: lis ("app:latest", True, "False", "other"), ]: containers.append({ + "Image": "sha256:running", "State": {"Running": running}, "Config": {"Image": image, "Labels": { "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 for project in projects: 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 "--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"}, }, })) - 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) - assert run.call_count == (1 if dry_run else 3) + assert run.call_count == (1 if dry_run else 4) if not dry_run: 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": "."}])