diff --git a/src/baudolo/backup/compose.py b/src/baudolo/backup/compose.py index 6fe9ce9..487209d 100644 --- a/src/baudolo/backup/compose.py +++ b/src/baudolo/backup/compose.py @@ -1,13 +1,104 @@ from __future__ import annotations import os +import shutil import subprocess +from pathlib import Path +from typing import List, Optional + + +def _detect_env_file(project_dir: Path) -> Optional[Path]: + """ + Detect Compose env file in a directory. + Preference (same as Infinito.Nexus wrapper): + 1) /.env (file) + 2) /.env/env (file) (legacy layout) + """ + c1 = project_dir / ".env" + if c1.is_file(): + return c1 + + c2 = project_dir / ".env" / "env" + if c2.is_file(): + return c2 + + return None + + +def _detect_compose_files(project_dir: Path) -> List[Path]: + """ + Detect Compose file stack in a directory (same as Infinito.Nexus wrapper). + Always requires docker-compose.yml. + Optionals: + - docker-compose.override.yml + - docker-compose.ca.override.yml + """ + base = project_dir / "docker-compose.yml" + if not base.is_file(): + raise FileNotFoundError(f"Missing docker-compose.yml in: {project_dir}") + + files = [base] + + override = project_dir / "docker-compose.override.yml" + if override.is_file(): + files.append(override) + + ca_override = project_dir / "docker-compose.ca.override.yml" + if ca_override.is_file(): + files.append(ca_override) + + return files + + +def _compose_wrapper_path() -> Optional[str]: + """ + Prefer the Infinito.Nexus compose wrapper if present. + Equivalent to: `which compose` + """ + return shutil.which("compose") + + +def _build_compose_cmd(project_dir: str, passthrough: List[str]) -> List[str]: + """ + Build the compose command for this project directory. + + Behavior: + - If `compose` wrapper exists: use it with --chdir (so it resolves -f/--env-file itself) + - Else: use `docker compose` and replicate wrapper's file/env detection. + """ + pdir = Path(project_dir).resolve() + + wrapper = _compose_wrapper_path() + if wrapper: + # Wrapper defaults project name to basename of --chdir. + # "--" ensures wrapper stops parsing its own args. + return [wrapper, "--chdir", str(pdir), "--", *passthrough] + + # Fallback: pure docker compose, but mirror wrapper behavior. + files = _detect_compose_files(pdir) + env_file = _detect_env_file(pdir) + + cmd: List[str] = ["docker", "compose"] + for f in files: + cmd += ["-f", str(f)] + if env_file: + cmd += ["--env-file", str(env_file)] + + cmd += passthrough + return cmd def hard_restart_docker_services(dir_path: str) -> None: - print(f"Hard restart docker-compose services in: {dir_path}", flush=True) - subprocess.run(["docker-compose", "down"], cwd=dir_path, check=True) - subprocess.run(["docker-compose", "up", "-d"], cwd=dir_path, check=True) + print(f"Hard restart compose services in: {dir_path}", flush=True) + + down_cmd = _build_compose_cmd(dir_path, ["down"]) + up_cmd = _build_compose_cmd(dir_path, ["up", "-d"]) + + print(">>> " + " ".join(down_cmd), flush=True) + subprocess.run(down_cmd, check=True) + + print(">>> " + " ".join(up_cmd), flush=True) + subprocess.run(up_cmd, check=True) def handle_docker_compose_services( diff --git a/tests/unit/src/baudolo/backup/test_compose.py b/tests/unit/src/baudolo/backup/test_compose.py new file mode 100644 index 0000000..31e1ba7 --- /dev/null +++ b/tests/unit/src/baudolo/backup/test_compose.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +from pathlib import Path +from typing import List + +import pytest + + +@pytest.fixture +def compose_mod(): + """ + Import the module under test. + Adjust the import path if your package layout differs. + """ + from baudolo.backup import compose as mod + + return mod + + +def _touch(p: Path) -> None: + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text("x", encoding="utf-8") + + +def _setup_compose_dir( + tmp_path: Path, + name: str = "mailu", + *, + with_override: bool = False, + with_ca_override: bool = False, + env_layout: str | None = None, # None | ".env" | ".env/env" +) -> Path: + d = tmp_path / name + d.mkdir(parents=True, exist_ok=True) + + _touch(d / "docker-compose.yml") + + if with_override: + _touch(d / "docker-compose.override.yml") + + if with_ca_override: + _touch(d / "docker-compose.ca.override.yml") + + if env_layout == ".env": + _touch(d / ".env") + elif env_layout == ".env/env": + _touch(d / ".env" / "env") + + return d + + +def test_detect_env_file_prefers_dotenv_over_legacy(tmp_path: Path, compose_mod): + d = _setup_compose_dir(tmp_path, env_layout=".env/env") + # Also create .env file -> should be preferred + _touch(d / ".env") + + env_file = compose_mod._detect_env_file(d) + assert env_file == d / ".env" + + +def test_detect_env_file_uses_legacy_if_no_dotenv(tmp_path: Path, compose_mod): + d = _setup_compose_dir(tmp_path, env_layout=".env/env") + env_file = compose_mod._detect_env_file(d) + assert env_file == d / ".env" / "env" + + +def test_detect_compose_files_requires_base(tmp_path: Path, compose_mod): + d = tmp_path / "stack" + d.mkdir() + + with pytest.raises(FileNotFoundError): + compose_mod._detect_compose_files(d) + + +def test_detect_compose_files_includes_optional_overrides(tmp_path: Path, compose_mod): + d = _setup_compose_dir( + tmp_path, + with_override=True, + with_ca_override=True, + ) + + files = compose_mod._detect_compose_files(d) + assert files == [ + d / "docker-compose.yml", + d / "docker-compose.override.yml", + d / "docker-compose.ca.override.yml", + ] + + +def test_build_cmd_uses_wrapper_when_present(monkeypatch, tmp_path: Path, compose_mod): + d = _setup_compose_dir( + tmp_path, with_override=True, with_ca_override=True, env_layout=".env" + ) + + # Pretend "which compose" finds a wrapper. + monkeypatch.setattr( + compose_mod.shutil, "which", lambda name: "/usr/local/bin/compose" + ) + + cmd = compose_mod._build_compose_cmd(str(d), ["up", "-d"]) + + # Wrapper should be used, and wrapper itself resolves -f / --env-file. + assert cmd == [ + "/usr/local/bin/compose", + "--chdir", + str(d.resolve()), + "--", + "up", + "-d", + ] + + +def test_build_cmd_fallback_docker_compose_with_all_files_and_env( + monkeypatch, tmp_path: Path, compose_mod +): + d = _setup_compose_dir( + tmp_path, + with_override=True, + with_ca_override=True, + env_layout=".env", + ) + + # No wrapper found. + monkeypatch.setattr(compose_mod.shutil, "which", lambda name: None) + + cmd = compose_mod._build_compose_cmd(str(d), ["up", "-d", "--force-recreate"]) + + # Fallback should replicate the wrapper resolution logic. + expected: List[str] = [ + "docker", + "compose", + "-f", + str((d / "docker-compose.yml").resolve()), + "-f", + str((d / "docker-compose.override.yml").resolve()), + "-f", + str((d / "docker-compose.ca.override.yml").resolve()), + "--env-file", + str((d / ".env").resolve()), + "up", + "-d", + "--force-recreate", + ] + assert cmd == expected + + +def test_hard_restart_calls_run_twice_with_correct_cmds_wrapper( + monkeypatch, tmp_path: Path, compose_mod +): + d = _setup_compose_dir(tmp_path, name="mailu", env_layout=".env") + + # Wrapper exists + monkeypatch.setattr( + compose_mod.shutil, "which", lambda name: "/usr/local/bin/compose" + ) + + calls = [] + + def fake_run(cmd, check: bool): + calls.append((cmd, check)) + return 0 + + monkeypatch.setattr(compose_mod.subprocess, "run", fake_run) + + compose_mod.hard_restart_docker_services(str(d)) + + assert calls == [ + (["/usr/local/bin/compose", "--chdir", str(d.resolve()), "--", "down"], True), + ( + ["/usr/local/bin/compose", "--chdir", str(d.resolve()), "--", "up", "-d"], + True, + ), + ] + + +def test_hard_restart_calls_run_twice_with_correct_cmds_fallback( + monkeypatch, tmp_path: Path, compose_mod +): + d = _setup_compose_dir( + tmp_path, + name="mailu", + with_override=True, + with_ca_override=True, + env_layout=".env/env", + ) + + # No wrapper exists + monkeypatch.setattr(compose_mod.shutil, "which", lambda name: None) + + calls = [] + + def fake_run(cmd, check: bool): + calls.append((cmd, check)) + return 0 + + monkeypatch.setattr(compose_mod.subprocess, "run", fake_run) + + compose_mod.hard_restart_docker_services(str(d)) + + # We assert only key structure + ordering to keep it robust. + down_cmd = calls[0][0] + up_cmd = calls[1][0] + + assert calls[0][1] is True + assert calls[1][1] is True + + # down: docker compose -f ... --env-file ... down + assert down_cmd[0:2] == ["docker", "compose"] + assert down_cmd[-1] == "down" + assert "--env-file" in down_cmd + + # up: docker compose ... up -d + assert up_cmd[0:2] == ["docker", "compose"] + assert up_cmd[-2:] == ["up", "-d"] or up_cmd[-3:] == ["up", "-d"] # tolerance + assert "--env-file" in up_cmd