diff --git a/cmdeploy/src/cmdeploy/cmdeploy.py b/cmdeploy/src/cmdeploy/cmdeploy.py index d1b90c01..27854b7c 100644 --- a/cmdeploy/src/cmdeploy/cmdeploy.py +++ b/cmdeploy/src/cmdeploy/cmdeploy.py @@ -34,6 +34,7 @@ from .sshexec import ( resolve_host_from_ssh_config, resolve_key_from_ssh_config, ) +from .util import build_chatmaild_sdist from .www import main as webdev_main # @@ -120,6 +121,9 @@ def run_cmd(args, out): env["CHATMAIL_WEBSITE_ONLY"] = "True" if args.website_only else "" env["CHATMAIL_DISABLE_MAIL"] = "True" if args.disable_mail else "" env["CHATMAIL_REQUIRE_IROH"] = "True" if require_iroh else "" + + if not args.website_only: + build_chatmaild_sdist() if not args.dns_check_disabled: env["CHATMAIL_ADDR_V4"] = remote_data.get("A") or "" env["CHATMAIL_ADDR_V6"] = remote_data.get("AAAA") or "" diff --git a/cmdeploy/src/cmdeploy/deployers.py b/cmdeploy/src/cmdeploy/deployers.py index de7e61af..e16272be 100644 --- a/cmdeploy/src/cmdeploy/deployers.py +++ b/cmdeploy/src/cmdeploy/deployers.py @@ -3,9 +3,6 @@ Chat Mail pyinfra deploy. """ import os -import shutil -import subprocess -import sys from io import StringIO from pathlib import Path @@ -18,7 +15,7 @@ from pyinfra.facts.systemd import SystemdEnabled from pyinfra.operations import apt, files, pip, server, systemd from cmdeploy.cmdeploy import Out -from cmdeploy.util import get_version_string +from cmdeploy.util import get_chatmaild_sdist, get_version_string from .acmetool import AcmetoolDeployer from .basedeploy import ( @@ -55,20 +52,6 @@ class Port(FactBase): return output[0] -def _build_chatmaild(dist_dir) -> None: - dist_dir = Path(dist_dir).resolve() - if dist_dir.exists(): - shutil.rmtree(dist_dir) - dist_dir.mkdir() - subprocess.check_output( - [sys.executable, "-m", "build", "-n"] - + ["--sdist", "chatmaild", "--outdir", str(dist_dir)] - ) - entries = list(dist_dir.iterdir()) - assert len(entries) == 1 - return entries[0] - - def remove_legacy_artifacts(): if not has_systemd(): return @@ -84,7 +67,7 @@ def remove_legacy_artifacts(): def _install_remote_venv_with_chatmaild() -> None: remove_legacy_artifacts() - dist_file = _build_chatmaild(dist_dir=Path("chatmaild/dist")) + dist_file = get_chatmaild_sdist() remote_base_dir = "/usr/local/lib/chatmaild" remote_dist_file = f"{remote_base_dir}/dist/{dist_file.name}" remote_venv_dir = f"{remote_base_dir}/venv" diff --git a/cmdeploy/src/cmdeploy/tests/test_util.py b/cmdeploy/src/cmdeploy/tests/test_util.py index e2fa45d9..7d609da5 100644 --- a/cmdeploy/src/cmdeploy/tests/test_util.py +++ b/cmdeploy/src/cmdeploy/tests/test_util.py @@ -1,4 +1,13 @@ -from cmdeploy.util import collapse, get_git_hash, get_version_string, shell +import pytest + +from cmdeploy.util import ( + build_chatmaild_sdist, + collapse, + get_chatmaild_sdist, + get_git_hash, + get_version_string, + shell, +) def test_collapse(): @@ -51,3 +60,28 @@ def test_git_helpers_with_commits_and_diffs(tmp_path): new_hash = get_git_hash(root=tmp_path) assert new_hash != git_hash assert get_version_string(root=tmp_path) == new_hash + + +def test_build_chatmaild_sdist(tmp_path): + dist_dir = tmp_path / "dist" + + # First call builds the sdist + result = build_chatmaild_sdist(dist_dir) + assert result.name.endswith(".tar.gz") + assert result.stat().st_size > 0 + + # Second call is idempotent — returns the same file, no rebuild + mtime = result.stat().st_mtime + result2 = build_chatmaild_sdist(dist_dir) + assert result2 == result + assert result2.stat().st_mtime == mtime + + +def test_get_chatmaild_sdist_errors(tmp_path): + with pytest.raises(FileNotFoundError): + get_chatmaild_sdist(tmp_path / "nonexistent") + + empty = tmp_path / "empty" + empty.mkdir() + with pytest.raises(FileNotFoundError): + get_chatmaild_sdist(empty) diff --git a/cmdeploy/src/cmdeploy/util.py b/cmdeploy/src/cmdeploy/util.py index ed70236d..4a75ff4b 100644 --- a/cmdeploy/src/cmdeploy/util.py +++ b/cmdeploy/src/cmdeploy/util.py @@ -1,6 +1,8 @@ """Shared utility functions for cmdeploy.""" +import fcntl import subprocess +import sys import textwrap from pathlib import Path @@ -15,11 +17,11 @@ def collapse(text): Handy for writing shell commands across multiple lines:: - cmd = collapse(f\""" + cmd = collapse(f\"\"\" cmdeploy run --config {ct.ini} --ssh-host {ct.domain} - \""") + \"\"\") """ return textwrap.dedent(text).replace("\n", " ").strip() @@ -67,3 +69,42 @@ def get_version_string(root=None): if git_diff: return f"{git_hash}\n{git_diff}" return git_hash + + +def _chatmaild_default_dist_dir(): + return _project_root() / "chatmaild" / "dist" + + +def build_chatmaild_sdist(dist_dir=None): + """Build the chatmaild sdist if not already present (idempotent, process-safe).""" + + if dist_dir is None: + dist_dir = _chatmaild_default_dist_dir() + dist_dir = Path(dist_dir).resolve() + dist_dir.mkdir(parents=True, exist_ok=True) + + lockfile = dist_dir.parent / ".dist.lock" + with open(lockfile, "w") as fh: + fcntl.flock(fh, fcntl.LOCK_EX) + existing = [p for p in dist_dir.iterdir() if p.suffix == ".gz"] + if existing: + return existing[0] + subprocess.check_output( + [sys.executable, "-m", "build", "-n"] + + ["--sdist", "chatmaild", "--outdir", str(dist_dir)], + cwd=str(_project_root()), + ) + return get_chatmaild_sdist(dist_dir) + + +def get_chatmaild_sdist(dist_dir=None): + """Return the path to the pre-built chatmaild sdist.""" + if dist_dir is None: + dist_dir = _chatmaild_default_dist_dir() + + entries = list(Path(dist_dir).iterdir()) + if len(entries) == 0: + raise FileNotFoundError(f"dist directory is empty: {dist_dir}") + if len(entries) > 1: + raise ValueError(f"expected one file in {dist_dir}, found {len(entries)}") + return entries[0]