190 lines
8.4 KiB
Python
Executable File
190 lines
8.4 KiB
Python
Executable File
#!/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({
|
|
"Image": "sha256:running",
|
|
"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.running_images == {"app": {"sha256:running"}}
|
|
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"},
|
|
},
|
|
}))
|
|
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 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"] == ["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": "."}])
|
|
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:]]))
|