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