- git ships in the image; checkouts live under /data/ansible/sources (STEWARD_ANSIBLE_SOURCE_DIR), on the persistent volume. - One task, ansible_git_pulls, reads the sources every 30 s and pulls each as its interval passes: a source added in Settings is pulled without a restart, a removed one is forgotten. - git_pull: a 120 s timeout ending the process group, GIT_TERMINAL_PROMPT=0, pull --ff-only, a failed clone removed; it raises GitPullError with git's message, so the Sync button stops saying Synced after a failure (and the message is now escaped). - An alert action's vars and the firing's steward_alert_* vars reach ansible-playbook as one JSON extra-vars file, not -e key=value, which Ansible split on whitespace (a rule named 'High CPU on web'). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
103 lines
4.2 KiB
Python
103 lines
4.2 KiB
Python
"""Git sources: the checkout location, a pull cut at its timeout, a failure that
|
|
says why, and one pull task that picks up sources as they change (#5157)."""
|
|
import os
|
|
import time
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from steward.ansible import sources
|
|
|
|
|
|
def _fake_git(tmp_path, monkeypatch, body: str) -> None:
|
|
"""Put a `git` on PATH that runs `body` instead of the real one."""
|
|
bindir = tmp_path / "bin"
|
|
bindir.mkdir()
|
|
git = bindir / "git"
|
|
git.write_text("#!/bin/sh\n" + body + "\n")
|
|
git.chmod(0o755)
|
|
monkeypatch.setenv("PATH", f"{bindir}{os.pathsep}{os.environ.get('PATH', '')}")
|
|
|
|
|
|
def _source(path, **kw):
|
|
return {"name": "infra", "path": str(path), "url": "https://git.example/infra.git",
|
|
"branch": "main", "http_token": "", **kw}
|
|
|
|
|
|
def test_a_git_source_is_checked_out_under_data():
|
|
src = next(s for s in sources.get_sources(
|
|
{"sources": [{"name": "infra", "type": "git", "url": "https://x/infra.git"}]})
|
|
if s["name"] == "infra")
|
|
assert src["path"] == os.path.join(sources.SOURCE_CACHE_DIR, "infra")
|
|
# On the persistent volume, not the container's layer.
|
|
assert sources.SOURCE_CACHE_DIR == os.environ.get(
|
|
"STEWARD_ANSIBLE_SOURCE_DIR", "/data/ansible/sources")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_hung_clone_is_cut_at_its_timeout_and_removed(tmp_path, monkeypatch):
|
|
_fake_git(tmp_path, monkeypatch, "sleep 30")
|
|
checkout = tmp_path / "cache" / "infra"
|
|
started = time.monotonic()
|
|
with pytest.raises(sources.GitPullError, match="did not finish within 1 s"):
|
|
await sources.git_pull(_source(checkout), timeout=1)
|
|
assert time.monotonic() - started < 10
|
|
assert not checkout.exists() # the next attempt clones afresh
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_failed_pull_says_why_and_keeps_the_checkout(tmp_path, monkeypatch):
|
|
_fake_git(tmp_path, monkeypatch, "echo 'fatal: Not possible to fast-forward' >&2; exit 128")
|
|
checkout = tmp_path / "infra"
|
|
(checkout / ".git").mkdir(parents=True)
|
|
with pytest.raises(sources.GitPullError, match="Not possible to fast-forward"):
|
|
await sources.git_pull(_source(checkout))
|
|
assert (checkout / ".git").exists()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_git_never_prompts_and_only_fast_forwards(tmp_path):
|
|
checkout = tmp_path / "infra"
|
|
(checkout / ".git").mkdir(parents=True)
|
|
seen = {}
|
|
|
|
async def fake_exec(*args, **kwargs):
|
|
seen["argv"], seen["env"] = args, kwargs.get("env") or {}
|
|
proc = MagicMock()
|
|
proc.returncode = 0
|
|
proc.communicate = AsyncMock(return_value=(b"", b""))
|
|
return proc
|
|
|
|
with patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
|
|
await sources.git_pull(_source(checkout))
|
|
assert seen["env"]["GIT_TERMINAL_PROMPT"] == "0"
|
|
assert seen["argv"][-2:] == ("pull", "--ff-only")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_one_task_pulls_sources_as_they_fall_due(monkeypatch):
|
|
pulled: list[str] = []
|
|
|
|
async def fake_pull(src):
|
|
pulled.append(src["name"])
|
|
if src["name"] == "broken":
|
|
raise sources.GitPullError("git pull of 'broken' failed: nope")
|
|
|
|
monkeypatch.setattr(sources, "git_pull", fake_pull)
|
|
monkeypatch.setattr(sources, "_last_pull", {})
|
|
|
|
def cfg(*names):
|
|
intervals = {"fast": 60, "slow": 600, "broken": 60, "new": 60}
|
|
return {"cache_dir": "/tmp/x", "sources": [
|
|
{"name": n, "type": "git", "url": f"https://x/{n}.git",
|
|
"pull_interval_seconds": intervals[n]} for n in names]}
|
|
|
|
assert await sources.pull_due_sources(cfg("fast", "slow", "broken"), now=1000) == [
|
|
"fast", "slow", "broken"] # all due at start; a failure is logged
|
|
assert await sources.pull_due_sources(cfg("fast", "slow", "broken"), now=1030) == []
|
|
assert await sources.pull_due_sources(cfg("fast", "slow", "broken"), now=1061) == [
|
|
"fast", "broken"] # a failed one waits its interval too
|
|
# A source added in Settings is pulled on the next tick, no restart.
|
|
assert await sources.pull_due_sources(cfg("fast", "slow", "new"), now=1062) == ["new"]
|
|
assert "broken" not in sources._last_pull # a removed source is forgotten
|