128 lines
5.3 KiB
Python
128 lines
5.3 KiB
Python
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import json
|
|||
|
|
from pathlib import Path
|
|||
|
|
from types import SimpleNamespace
|
|||
|
|
|
|||
|
|
import pytest
|
|||
|
|
|
|||
|
|
from codexpi.docker_service import DockerError, _belongs_to_project, container_action, containers
|
|||
|
|
|
|||
|
|
|
|||
|
|
def inspected(root: Path, *, name: str = "project-web", working_dir: str | None = None, running: bool = False) -> dict:
|
|||
|
|
return {
|
|||
|
|
"Id": "a" * 64,
|
|||
|
|
"Name": f"/{name}",
|
|||
|
|
"Config": {"Labels": {
|
|||
|
|
"com.docker.compose.project": "example",
|
|||
|
|
"com.docker.compose.service": "web",
|
|||
|
|
"com.docker.compose.project.working_dir": working_dir or str(root),
|
|||
|
|
"com.docker.compose.project.config_files": str(root / "docker-compose.yml"),
|
|||
|
|
}},
|
|||
|
|
"Mounts": [],
|
|||
|
|
"State": {"Running": running},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_project_membership_uses_compose_directory_or_bind_mount(tmp_path: Path):
|
|||
|
|
project = tmp_path / "project"
|
|||
|
|
project.mkdir()
|
|||
|
|
assert _belongs_to_project(inspected(project), project)
|
|||
|
|
assert not _belongs_to_project(inspected(tmp_path / "other"), project)
|
|||
|
|
assert _belongs_to_project({"Config": {"Labels": {}}, "Mounts": [
|
|||
|
|
{"Type": "bind", "Source": str(project / "src")},
|
|||
|
|
]}, project)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_container_listing_only_returns_current_project(tmp_path: Path, monkeypatch):
|
|||
|
|
project = tmp_path / "project"
|
|||
|
|
project.mkdir()
|
|||
|
|
rows = [
|
|||
|
|
{"ID": "a" * 12, "Names": "project-web"},
|
|||
|
|
{"ID": "b" * 12, "Names": "someone-else"},
|
|||
|
|
]
|
|||
|
|
values = [inspected(project), {**inspected(tmp_path / "other", name="someone-else"), "Id": "b" * 64}]
|
|||
|
|
|
|||
|
|
async def fake(argv, cwd=None, timeout=60):
|
|||
|
|
stdout = "\n".join(json.dumps(row) for row in rows) if argv[1] == "ps" else json.dumps(values)
|
|||
|
|
return SimpleNamespace(returncode=0, stdout=stdout, stderr="")
|
|||
|
|
|
|||
|
|
monkeypatch.setattr("codexpi.docker_service.run_command", fake)
|
|||
|
|
result = await containers(project)
|
|||
|
|
assert [json.loads(line)["Names"] for line in result["stdout"].splitlines()] == ["project-web"]
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_rebuild_targets_only_the_scoped_compose_service(tmp_path: Path, monkeypatch):
|
|||
|
|
project = tmp_path / "project"
|
|||
|
|
project.mkdir()
|
|||
|
|
calls: list[tuple[list[str], Path | None]] = []
|
|||
|
|
|
|||
|
|
async def fake(argv, cwd=None, timeout=60):
|
|||
|
|
calls.append((argv, cwd))
|
|||
|
|
if len(calls) == 1:
|
|||
|
|
return SimpleNamespace(returncode=0, stdout=json.dumps({"ID": "a" * 12, "Names": "project-web"}), stderr="")
|
|||
|
|
if len(calls) == 2:
|
|||
|
|
return SimpleNamespace(returncode=0, stdout=json.dumps([inspected(project)]), stderr="")
|
|||
|
|
return SimpleNamespace(returncode=0, stdout="rebuilt", stderr="")
|
|||
|
|
|
|||
|
|
monkeypatch.setattr("codexpi.docker_service.run_command", fake)
|
|||
|
|
monkeypatch.setattr("codexpi.docker_service._compose_prefix", lambda _root: ["docker", "compose"])
|
|||
|
|
result = await container_action("project-web", "rebuild", project)
|
|||
|
|
assert result["stdout"] == "rebuilt"
|
|||
|
|
assert calls[-1] == ([
|
|||
|
|
"docker", "compose", "-f", str(project / "docker-compose.yml"),
|
|||
|
|
"--project-name", "example", "up", "-d", "--build", "web",
|
|||
|
|
], project)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_project_action_rejects_unrelated_container(tmp_path: Path, monkeypatch):
|
|||
|
|
project = tmp_path / "project"
|
|||
|
|
project.mkdir()
|
|||
|
|
|
|||
|
|
async def fake(argv, cwd=None, timeout=60):
|
|||
|
|
if argv[1] == "ps":
|
|||
|
|
return SimpleNamespace(returncode=0, stdout=json.dumps({"ID": "b" * 12, "Names": "other"}), stderr="")
|
|||
|
|
return SimpleNamespace(returncode=0, stdout=json.dumps([{**inspected(tmp_path / "other", name="other"), "Id": "b" * 64}]), stderr="")
|
|||
|
|
|
|||
|
|
monkeypatch.setattr("codexpi.docker_service.run_command", fake)
|
|||
|
|
with pytest.raises(DockerError, match="n’appartient pas"):
|
|||
|
|
await container_action("other", "restart", project)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_delete_only_removes_stopped_scoped_container(tmp_path: Path, monkeypatch):
|
|||
|
|
project = tmp_path / "project"
|
|||
|
|
project.mkdir()
|
|||
|
|
calls: list[list[str]] = []
|
|||
|
|
|
|||
|
|
async def fake(argv, cwd=None, timeout=60):
|
|||
|
|
calls.append(argv)
|
|||
|
|
if argv[1] == "ps":
|
|||
|
|
return SimpleNamespace(returncode=0, stdout=json.dumps({"ID": "a" * 12, "Names": "project-web"}), stderr="")
|
|||
|
|
if argv[1] == "inspect":
|
|||
|
|
return SimpleNamespace(returncode=0, stdout=json.dumps([inspected(project)]), stderr="")
|
|||
|
|
return SimpleNamespace(returncode=0, stdout="project-web", stderr="")
|
|||
|
|
|
|||
|
|
monkeypatch.setattr("codexpi.docker_service.run_command", fake)
|
|||
|
|
result = await container_action("project-web", "delete", project)
|
|||
|
|
assert result["stdout"] == "project-web"
|
|||
|
|
assert calls[-1] == ["docker", "rm", "project-web"]
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.mark.asyncio
|
|||
|
|
async def test_delete_refuses_running_container(tmp_path: Path, monkeypatch):
|
|||
|
|
project = tmp_path / "project"
|
|||
|
|
project.mkdir()
|
|||
|
|
|
|||
|
|
async def fake(argv, cwd=None, timeout=60):
|
|||
|
|
if argv[1] == "ps":
|
|||
|
|
return SimpleNamespace(returncode=0, stdout=json.dumps({"ID": "a" * 12, "Names": "project-web"}), stderr="")
|
|||
|
|
return SimpleNamespace(returncode=0, stdout=json.dumps([inspected(project, running=True)]), stderr="")
|
|||
|
|
|
|||
|
|
monkeypatch.setattr("codexpi.docker_service.run_command", fake)
|
|||
|
|
with pytest.raises(DockerError, match="Arrêtez"):
|
|||
|
|
await container_action("project-web", "delete", project)
|