fix(ansible): git sources work in the image and survive a redeploy; pulls time out; alert vars go in a JSON file (#5157)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 49s
CI / unit (push) Successful in 1m11s
CI / publish (push) Successful in 1m3s

- 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>
This commit is contained in:
2026-10-06 21:13:14 -04:00
co-authored by Claude Opus 5.5
parent d2ade9c1c7
commit edcfa4d3db
8 changed files with 265 additions and 89 deletions
+102
View File
@@ -0,0 +1,102 @@
"""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
+30 -19
View File
@@ -1,5 +1,5 @@
"""Unit tests for alert -> Ansible action context vars (task 250)."""
from steward.core.alerts import alert_extra_vars
"""Unit tests for alert -> Ansible action context vars (task 250, #5157)."""
from steward.core.alerts import action_vars, alert_extra_vars
from steward.models.alerts import AlertOperator, AlertRule
@@ -9,22 +9,33 @@ def test_alert_extra_vars_describes_the_firing():
metric_name="battery_status", operator=AlertOperator.lt, threshold=3.0,
created_by="u",
)
ev = alert_extra_vars(rule, 2.0)
assert "steward_alert_rule=UPS low battery" in ev
assert "steward_alert_source=snmp" in ev
assert "steward_alert_resource=ups" in ev
assert "steward_alert_metric=battery_status" in ev
assert "steward_alert_value=2.0" in ev
assert "steward_alert_threshold=3.0" in ev
assert "steward_alert_operator=<" in ev
assert alert_extra_vars(rule, 2.0) == {
"steward_alert_rule": "UPS low battery", # one value, spaces and all
"steward_alert_source": "snmp",
"steward_alert_resource": "ups",
"steward_alert_metric": "battery_status",
"steward_alert_value": 2.0,
"steward_alert_threshold": 3.0,
"steward_alert_operator": "<",
}
def test_alert_extra_vars_are_key_value_strings():
rule = AlertRule(
name="r", source_module="host_agent", resource_name="srv1",
metric_name="cpu_pct", operator=AlertOperator.gt, threshold=90.0,
created_by="u",
)
ev = alert_extra_vars(rule, 95.5)
assert all("=" in item for item in ev)
assert all(item.startswith("steward_alert_") for item in ev)
def test_action_vars_split_at_the_first_equals():
assert action_vars(["greeting=hello world", "q=a=b", " k = v", "bare", "=x"]) == {
"greeting": "hello world", "q": "a=b", "k": " v"}
assert action_vars(None) == {}
def test_an_alert_run_passes_its_vars_as_a_json_file_not_on_the_command_line():
import json
from steward.ansible.executor import build_ansible_command, build_extra_vars_file
rule = AlertRule(name="High CPU on web", source_module="host_agent", resource_name="web",
metric_name="cpu_pct", operator=AlertOperator.gt, threshold=90.0,
created_by="u")
merged = {**action_vars(["note=from the action"]), **alert_extra_vars(rule, 95.5)}
cmd = build_ansible_command("p.yml", "inv", {"extra_vars_map": merged})
assert "-e" not in cmd # nothing interpolated into argv
argv, files = build_extra_vars_file(merged, "/tmp/x")
assert argv == ["-e", "@/tmp/x/extravars.json"]
assert json.loads(files[0][1])["steward_alert_rule"] == "High CPU on web"
assert json.loads(files[0][1])["note"] == "from the action"