feat(metablog-webhook): git_pull helper + deploy ring buffer (ref #113)

- git_pull(site_dir, branch) -> (old_sha, new_sha) via 4 git ops
  (rev-parse, fetch, reset, rev-parse) with timeouts (60s fetch,
  10s local ops).
- Ring buffer holds the last 50 deploy records; oldest evicted
  on overflow. list_deploys() returns newest-first.

5 new pytest cases cover the buffer and git ops (with subprocess
stubbed via monkeypatch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-05-13 07:04:07 +02:00
parent f1946fb178
commit da0b4e537d
2 changed files with 116 additions and 0 deletions

View File

@ -74,3 +74,78 @@ def test_load_secret_caches(tmp_path, monkeypatch):
p.write_bytes(b"changed\n")
s2 = webhook.load_secret(p)
assert s1 == s2 == b"first" # cache wins
def test_record_deploy_appends():
import webhook
webhook._deploys.clear()
webhook._record_deploy({"site": "a", "from": "x", "to": "y"})
assert len(webhook._deploys) == 1
assert webhook._deploys[0]["site"] == "a"
def test_record_deploy_evicts_oldest_at_50():
import webhook
webhook._deploys.clear()
for i in range(55):
webhook._record_deploy({"site": f"s{i}"})
assert len(webhook._deploys) == 50
# oldest 5 are gone; first remaining is s5
assert webhook._deploys[0]["site"] == "s5"
assert webhook._deploys[-1]["site"] == "s54"
def test_list_deploys_returns_reversed():
import webhook
webhook._deploys.clear()
webhook._record_deploy({"site": "a"})
webhook._record_deploy({"site": "b"})
out = webhook.list_deploys()
assert out["count"] == 2
assert out["deploys"][0]["site"] == "b" # newest first
assert out["deploys"][1]["site"] == "a"
def test_git_pull_returns_old_and_new_sha(tmp_path, monkeypatch):
import webhook
site = tmp_path / "site"
site.mkdir()
(site / ".git").mkdir()
calls = []
sha_iter = iter(["aaa1111", "bbb2222"])
def fake_run(cmd, **kwargs):
calls.append(cmd)
class R:
stdout = next(sha_iter) + "\n" if "rev-parse" in cmd else ""
stderr = ""
returncode = 0
return R()
monkeypatch.setattr(webhook.subprocess, "run", fake_run)
old, new = webhook.git_pull(site, "main")
assert old == "aaa1111"
assert new == "bbb2222"
# ensure the 4 expected git commands happened in order
op_names = [c[3] for c in calls]
assert op_names == ["rev-parse", "fetch", "reset", "rev-parse"]
def test_git_pull_timeout_propagates(tmp_path, monkeypatch):
import webhook
import subprocess
site = tmp_path / "site"
site.mkdir()
(site / ".git").mkdir()
def fake_run(cmd, **kwargs):
if "fetch" in cmd:
raise subprocess.TimeoutExpired(cmd, kwargs.get("timeout", 60))
class R:
stdout = "abc\n"; stderr = ""; returncode = 0
return R()
monkeypatch.setattr(webhook.subprocess, "run", fake_run)
with pytest.raises(subprocess.TimeoutExpired):
webhook.git_pull(site, "main")

View File

@ -11,6 +11,7 @@ reload nginx if site.json:domain changed, and log to a ring buffer.
import hmac
import hashlib
import logging
import subprocess
from pathlib import Path
from typing import Optional
@ -45,3 +46,43 @@ def verify_signature(secret: bytes, body: bytes, signature_hex: str) -> bool:
return hmac.compare_digest(expected, signature_hex)
except (TypeError, ValueError):
return False
GIT_FETCH_TIMEOUT = 60
GIT_OP_TIMEOUT = 10
def git_pull(site_dir: Path, branch: str) -> tuple[str, str]:
"""Pull site_dir to origin/<branch>. Returns (old_sha, new_sha).
Raises subprocess.TimeoutExpired or CalledProcessError on git failure.
"""
def _git(*args: str, timeout: int = GIT_OP_TIMEOUT) -> str:
result = subprocess.run(
["git", "-C", str(site_dir), *args],
capture_output=True, text=True, timeout=timeout, check=True,
)
return result.stdout.strip()
old = _git("rev-parse", "HEAD")
_git("fetch", "--quiet", "origin", branch, timeout=GIT_FETCH_TIMEOUT)
_git("reset", "--hard", f"origin/{branch}")
new = _git("rev-parse", "HEAD")
return old, new
# ─── Deploy ring buffer ──────────────────────────────────────────
_deploys: list[dict] = []
_DEPLOYS_MAX = 50
def _record_deploy(entry: dict) -> None:
"""Append a deploy record; evict oldest beyond cap."""
_deploys.append(entry)
if len(_deploys) > _DEPLOYS_MAX:
_deploys.pop(0)
def list_deploys() -> dict:
"""Return deploys newest-first."""
return {"deploys": list(reversed(_deploys)), "count": len(_deploys)}