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
+29 -13
View File
@@ -34,21 +34,35 @@ def init_alerts(app: "Quart") -> None:
_app = app
def alert_extra_vars(rule: AlertRule, value: float) -> list[str]:
"""Ansible extra-vars describing the alert that fired, as `key=value` strings.
def alert_extra_vars(rule: AlertRule, value: float) -> dict:
"""Ansible extra-vars describing the alert that fired.
Injected into every alert-triggered playbook run so the playbook can target
the exact resource/metric that fired without templating. Pure function.
the exact resource/metric that fired without templating. They reach
ansible-playbook in its JSON extra-vars file, not as `-e key=value`, which
Ansible splits on whitespace: a rule named "High CPU on web" stays one
value (#5157). Pure function.
"""
return [
f"steward_alert_rule={rule.name}",
f"steward_alert_source={rule.source_module}",
f"steward_alert_resource={rule.resource_name}",
f"steward_alert_metric={rule.metric_name}",
f"steward_alert_value={value}",
f"steward_alert_threshold={rule.threshold}",
f"steward_alert_operator={rule.operator.value}",
]
return {
"steward_alert_rule": rule.name,
"steward_alert_source": rule.source_module,
"steward_alert_resource": rule.resource_name,
"steward_alert_metric": rule.metric_name,
"steward_alert_value": value,
"steward_alert_threshold": rule.threshold,
"steward_alert_operator": rule.operator.value,
}
def action_vars(lines) -> dict:
"""An alert action's stored `key=value` lines as a map, split at the first
`=` so a value keeps its spaces, quotes and further `=`."""
out: dict = {}
for line in lines or ():
key, sep, val = str(line).partition("=")
if sep and key.strip():
out[key.strip()] = val
return out
async def record_metric(
@@ -489,8 +503,10 @@ async def _run_ansible_action(
playbook = action.get("playbook") or ""
inventory = action.get("inventory") or ""
source_name = action.get("source") or ""
# One JSON extra-vars file; the firing's own vars win over the action's.
params: dict = {
"extra_vars": list(action.get("extra_vars") or []) + alert_extra_vars(rule, value),
"extra_vars_map": {**action_vars(action.get("extra_vars")),
**alert_extra_vars(rule, value)},
}
if action.get("limit"):
params["limit"] = action["limit"]