refactor: extract sdist build into util with lock-based idempotency

Move _build_chatmaild from deployers.py into util.py as
build_chatmaild_sdist() with fcntl-based file locking so
parallel deploys do not race on the sdist.  The build is
called once from run_cmd() before pyinfra starts; deployers.py
now only calls get_chatmaild_sdist() to locate the pre-built
archive.

Add test_build_chatmaild_sdist and test_get_chatmaild_sdist_errors.
This commit is contained in:
holger krekel
2026-03-07 14:38:47 +01:00
parent 95c76aa2b0
commit 6e52bfe8c4
4 changed files with 84 additions and 22 deletions

View File

@@ -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 ""

View File

@@ -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"

View File

@@ -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)

View File

@@ -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]