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_host_from_ssh_config,
resolve_key_from_ssh_config, resolve_key_from_ssh_config,
) )
from .util import build_chatmaild_sdist
from .www import main as webdev_main 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_WEBSITE_ONLY"] = "True" if args.website_only else ""
env["CHATMAIL_DISABLE_MAIL"] = "True" if args.disable_mail else "" env["CHATMAIL_DISABLE_MAIL"] = "True" if args.disable_mail else ""
env["CHATMAIL_REQUIRE_IROH"] = "True" if require_iroh else "" env["CHATMAIL_REQUIRE_IROH"] = "True" if require_iroh else ""
if not args.website_only:
build_chatmaild_sdist()
if not args.dns_check_disabled: if not args.dns_check_disabled:
env["CHATMAIL_ADDR_V4"] = remote_data.get("A") or "" env["CHATMAIL_ADDR_V4"] = remote_data.get("A") or ""
env["CHATMAIL_ADDR_V6"] = remote_data.get("AAAA") or "" env["CHATMAIL_ADDR_V6"] = remote_data.get("AAAA") or ""

View File

@@ -3,9 +3,6 @@ Chat Mail pyinfra deploy.
""" """
import os import os
import shutil
import subprocess
import sys
from io import StringIO from io import StringIO
from pathlib import Path from pathlib import Path
@@ -18,7 +15,7 @@ from pyinfra.facts.systemd import SystemdEnabled
from pyinfra.operations import apt, files, pip, server, systemd from pyinfra.operations import apt, files, pip, server, systemd
from cmdeploy.cmdeploy import Out 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 .acmetool import AcmetoolDeployer
from .basedeploy import ( from .basedeploy import (
@@ -55,20 +52,6 @@ class Port(FactBase):
return output[0] 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(): def remove_legacy_artifacts():
if not has_systemd(): if not has_systemd():
return return
@@ -84,7 +67,7 @@ def remove_legacy_artifacts():
def _install_remote_venv_with_chatmaild() -> None: def _install_remote_venv_with_chatmaild() -> None:
remove_legacy_artifacts() 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_base_dir = "/usr/local/lib/chatmaild"
remote_dist_file = f"{remote_base_dir}/dist/{dist_file.name}" remote_dist_file = f"{remote_base_dir}/dist/{dist_file.name}"
remote_venv_dir = f"{remote_base_dir}/venv" 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(): 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) new_hash = get_git_hash(root=tmp_path)
assert new_hash != git_hash assert new_hash != git_hash
assert get_version_string(root=tmp_path) == new_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.""" """Shared utility functions for cmdeploy."""
import fcntl
import subprocess import subprocess
import sys
import textwrap import textwrap
from pathlib import Path from pathlib import Path
@@ -15,11 +17,11 @@ def collapse(text):
Handy for writing shell commands across multiple lines:: Handy for writing shell commands across multiple lines::
cmd = collapse(f\""" cmd = collapse(f\"\"\"
cmdeploy run cmdeploy run
--config {ct.ini} --config {ct.ini}
--ssh-host {ct.domain} --ssh-host {ct.domain}
\""") \"\"\")
""" """
return textwrap.dedent(text).replace("\n", " ").strip() return textwrap.dedent(text).replace("\n", " ").strip()
@@ -67,3 +69,42 @@ def get_version_string(root=None):
if git_diff: if git_diff:
return f"{git_hash}\n{git_diff}" return f"{git_hash}\n{git_diff}"
return git_hash 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]