diff --git a/docker_refresh.py b/docker_refresh.py new file mode 100755 index 0000000..a1f18d4 --- /dev/null +++ b/docker_refresh.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +# /// script +# dependencies = [] +# /// + +import argparse +from dataclasses import dataclass, field +import json +import logging +from pathlib import Path +import re +import shlex +import subprocess +import sys +from typing import Any + + +@dataclass +class Project: + name: str + directory: Path + files: tuple[Path, ...] + environment_files: tuple[Path, ...] + services: set[str] = field(default_factory=set) + + def command(self) -> list[str]: + command = [ + "docker", "compose", "--project-name", self.name, + "--project-directory", str(self.directory), "--profile", "*", + ] + for path in self.files: + command.extend(["--file", str(path)]) + for path in self.environment_files: + command.extend(["--env-file", str(path)]) + return command + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Pull and redeploy running Compose services with unpinned images.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + epilog="Digests and tags starting with a version number (e.g. v1.2.3 or 16-alpine) " + "are skipped. Run on the Compose host with the original files and environment available.", + ) + parser.add_argument("projects", nargs="*", help="Only refresh these Compose projects; omit to refresh all eligible projects") + parser.add_argument("--dry-run", action="store_true", help="Discover services and log commands without changing deployments") + return parser.parse_args() + + +def is_pinned(image: str) -> bool: + if "@" in image: + return True + final_component = image.rsplit("/", 1)[-1] + if ":" not in final_component: + return False + tag = final_component.rsplit(":", 1)[-1] + return re.match(r"^v?\d+(?:[._-]|$)", tag) is not None + + +def inspect_containers() -> list[dict[str, Any]]: + result = subprocess.run( + args=["docker", "container", "ls", "--quiet"], + check=True, capture_output=True, text=True, timeout=60, + ) + identifiers = result.stdout.split() + containers = [] + for offset in range(0, len(identifiers), 100): + result = subprocess.run( + args=["docker", "container", "inspect", *identifiers[offset:offset + 100]], + check=True, capture_output=True, text=True, timeout=60, + ) + value = json.loads(result.stdout) + if not isinstance(value, list): + raise ValueError("Docker returned an unexpected response") + containers.extend(value) + return containers + + +def discover_projects(containers: list[dict[str, Any]], selected_projects: set[str]) -> list[Project]: + projects: dict[str, Project] = {} + for container in containers: + if not container.get("State", {}).get("Running"): + continue + config = container.get("Config") or {} + labels = config.get("Labels") or {} + name = labels.get("com.docker.compose.project") + service = labels.get("com.docker.compose.service") + if selected_projects and name not in selected_projects: + continue + if labels.get("com.docker.compose.oneoff", "").lower() == "true": + continue + image = config.get("Image") or "" + if not image: + logging.info("Skipping %s/%s: container image reference is missing", name, service) + continue + if is_pinned(image=image): + logging.warning("Skipping %s/%s: deployed image %s is pinned", name, service, image) + continue + if not name or not service: + logging.warning("Skipping %s: unpinned image %s is not a Compose service", container.get("Name"), image) + continue + directory = labels.get("com.docker.compose.project.working_dir") + files = labels.get("com.docker.compose.project.config_files") + if not directory or not files: + raise ValueError(f"Project {name}: missing Compose working directory or config file labels") + working_directory = Path(directory) + config_files = tuple(working_directory / path for path in files.split(",") if path) + environment_files = tuple( + working_directory / path + for path in labels.get("com.docker.compose.project.environment_file", "").split(",") if path + ) + if not config_files: + raise ValueError(f"Project {name}: no Compose config files") + project = Project(name=name, directory=working_directory, files=config_files, environment_files=environment_files) + existing = projects.setdefault(name, project) + 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) + return sorted(projects.values(), key=lambda project: project.name) + + +def refresh_project(project: Project, dry_run: bool) -> None: + if not project.directory.is_dir(): + raise ValueError(f"Project {project.name}: directory is unavailable: {project.directory}; run on the Compose host") + for path in (*project.files, *project.environment_files): + if not path.is_file(): + raise ValueError(f"Project {project.name}: file is unavailable: {path}") + command = project.command() + result = subprocess.run( + args=[*command, "config", "--format", "json"], cwd=project.directory, + check=True, capture_output=True, text=True, timeout=60, + ) + configuration = json.loads(result.stdout) + configured_services = configuration.get("services", {}) + if any(service.get("build") is not None for service in configured_services.values()): + logging.info("Skipping project %s: contains locally built services", project.name) + return + services = [] + for name in sorted(project.services): + service = configured_services.get(name) + if service is None: + raise ValueError(f"Project {project.name}: service {name} is absent from current Compose config") + image = service.get("image") or "" + if not image or is_pinned(image=image): + logging.warning("Skipping %s/%s: current Compose image %s is missing or pinned", project.name, name, image) + else: + logging.info("Refreshing %s/%s (%s)", project.name, name, image) + 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) + + +def main() -> int: + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s") + arguments = parse_args() + try: + selected_projects = set(arguments.projects) + projects = discover_projects(containers=inspect_containers(), selected_projects=selected_projects) + if not projects: + if selected_projects: + logging.info("No eligible services to refresh in selected projects: %s", ", ".join(sorted(selected_projects))) + else: + logging.info("No eligible services to refresh") + failed = False + for project in projects: + try: + refresh_project(project=project, dry_run=arguments.dry_run) + except (OSError, ValueError, subprocess.SubprocessError) as error: + logging.error("Project %s: %s", project.name, getattr(error, "stderr", None) or error) + failed = True + return int(failed) + except (OSError, ValueError, subprocess.SubprocessError) as error: + logging.error("%s", getattr(error, "stderr", None) or error) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docker_refresh_test.py b/docker_refresh_test.py new file mode 100755 index 0000000..cb5c993 --- /dev/null +++ b/docker_refresh_test.py @@ -0,0 +1,154 @@ +#!/usr/bin/env -S uv run --script +# /// script +# dependencies = ["pytest"] +# /// + +import json +import logging +from pathlib import Path +import subprocess +import sys +from unittest.mock import patch + +import pytest + +import docker_refresh as script + + +@pytest.mark.parametrize("image, expected", [ + ("nginx", False), ("nginx:latest", False), ("app:stable", False), + ("app:main", False), ("app:nightly", False), ("app:alpine", False), + ("localhost:5000/team/app", False), + ("localhost:5000/team/app:latest", False), + ("nginx:1.27.3", True), ("postgres:16-alpine", True), + ("app:v2.0.1", True), ("app:2026-10-03", True), + ("app@sha256:abc", True), ("app:latest@sha256:abc", True), +]) +def test_image_pinning(image: str, expected: bool) -> None: + assert script.is_pinned(image=image) == expected + + +@pytest.mark.parametrize("selected, expected", [ + (set(), ["other", "web"]), + ({"web"}, ["web"]), + ({"web", "other"}, ["other", "web"]), + ({"other", "missing"}, ["other"]), + ({"missing"}, []), +]) +def test_discovery_filters_and_groups_replicas(selected: set[str], expected: list[str]) -> None: + containers = [] + for image, running, oneoff, project in [ + ("app:latest", True, "False", "web"), + ("app:latest", True, "False", "web"), + ("db:16", True, "False", "web"), + ("stopped:latest", False, "False", "web"), + ("oneoff:latest", True, "True", "web"), + ("app:latest", True, "False", "other"), + ]: + containers.append({ + "State": {"Running": running}, + "Config": {"Image": image, "Labels": { + "com.docker.compose.project": project, + "com.docker.compose.service": image.split(":")[0], + "com.docker.compose.oneoff": oneoff, + "com.docker.compose.project.working_dir": "/srv/web", + "com.docker.compose.project.config_files": "compose.yml,override.yml", + "com.docker.compose.project.environment_file": "/srv/web/custom.env", + }}, + }) + projects = script.discover_projects(containers=containers, selected_projects=selected) + assert [project.name for project in projects] == expected + for project in projects: + assert project.services == {"app"} + assert project.files == (Path("/srv/web/compose.yml"), Path("/srv/web/override.yml")) + assert "--env-file" in project.command() + + +@pytest.mark.parametrize("names", [[], ["web"], ["web", "api"]]) +def test_positional_project_arguments(names: list[str]) -> None: + with patch("sys.argv", ["docker_refresh.py", "--dry-run", *names]): + arguments = script.parse_args() + assert arguments.projects == names + assert arguments.dry_run + + +def test_selected_pinned_project_reports_skip_reason(caplog: pytest.LogCaptureFixture) -> None: + container = { + "State": {"Running": True}, + "Config": { + "Image": "ghcr.io/abdusco/uno:v1.0.0", + "Labels": { + "com.docker.compose.project": "uno", + "com.docker.compose.service": "uno", + }, + }, + } + with patch("sys.argv", ["docker_refresh.py", "uno"]): + with patch.object(script, "inspect_containers", return_value=[container]): + with caplog.at_level(logging.INFO): + assert script.main() == 0 + assert caplog.record_tuples == [ + ("root", logging.WARNING, "Skipping uno/uno: deployed image ghcr.io/abdusco/uno:v1.0.0 is pinned"), + ("root", logging.INFO, "No eligible services to refresh in selected projects: uno"), + ] + + +@pytest.mark.parametrize("dry_run", [True, False]) +def test_refresh_commands_and_current_config_filtering(tmp_path: Path, dry_run: bool) -> None: + compose = tmp_path / "compose.yml" + compose.touch() + project = script.Project(name="web", directory=tmp_path, files=(compose,), environment_files=(), services={"app", "db"}) + response = subprocess.CompletedProcess(args=[], returncode=0, stdout=json.dumps({ + "services": { + "app": {"image": "app:latest"}, + "db": {"image": "db:16"}, + }, + })) + with patch.object(script.subprocess, "run", return_value=response) as run: + script.refresh_project(project=project, dry_run=dry_run) + assert run.call_count == (1 if dry_run else 3) + 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"] + + +@pytest.mark.parametrize("build", [".", {"context": "."}]) +def test_project_with_any_built_service_is_skipped(tmp_path: Path, build: str | dict[str, str]) -> None: + compose = tmp_path / "compose.yml" + compose.touch() + project = script.Project(name="web", directory=tmp_path, files=(compose,), environment_files=(), services={"app"}) + response = subprocess.CompletedProcess(args=[], returncode=0, stdout=json.dumps({ + "services": { + "app": {"image": "app:latest"}, + "local": {"build": build}, + }, + })) + with patch.object(script.subprocess, "run", return_value=response) as run: + script.refresh_project(project=project, dry_run=False) + assert run.call_count == 1 + assert run.call_args.kwargs["args"] == [*project.command(), "config", "--format", "json"] + + +def test_failed_pull_does_not_redeploy(tmp_path: Path) -> None: + compose = tmp_path / "compose.yml" + compose.touch() + project = script.Project(name="web", directory=tmp_path, files=(compose,), environment_files=(), services={"app"}) + response = subprocess.CompletedProcess(args=[], returncode=0, stdout='{"services":{"app":{"image":"app:latest"}}}') + with patch.object(script.subprocess, "run", side_effect=[ + response, subprocess.CalledProcessError(returncode=1, cmd="pull"), + ]) as run: + with pytest.raises(subprocess.CalledProcessError): + script.refresh_project(project=project, dry_run=False) + assert run.call_count == 2 + + +def test_missing_files_fail_before_docker_commands(tmp_path: Path) -> None: + project = script.Project(name="web", directory=tmp_path, files=(tmp_path / "missing.yml",), environment_files=(), services={"app"}) + with patch.object(script.subprocess, "run") as run: + with pytest.raises(ValueError): + script.refresh_project(project=project, dry_run=False) + run.assert_not_called() + + +if __name__ == "__main__": + sys.exit(pytest.main(args=[str(Path(__file__).resolve()), *sys.argv[1:]]))