feat: Add script to pull and redeploy Docker containers
This commit is contained in:
1 parent
4e61ace0df
commit
f3526b60e5
2 files changed
+339
No files matched your search
Executable
+185
@@ -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())
|
||||||
Executable
+154
@@ -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:]]))
|
||||||
Reference in new issue
Block a user