feat(ansible): runner robustness — cancel, concurrency, structured results, retention
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 7s
CI / integration (push) Successful in 2m18s
CI / publish (push) Successful in 55s

Closes #550 (all four):

- Cancellation: track live subprocesses; POST /ansible/runs/<id>/cancel
  (operator) SIGTERMs then SIGKILLs after a grace; new 'cancelled' status
  (+ migration 0019, ALTER TYPE in autocommit). Queued runs cancel cleanly
  before launch. Cancel button on run detail.
- Concurrency: global semaphore (ansible.max_concurrent_runs, default 3,
  Settings→Ansible) caps simultaneous runs; excess show 'queued' (new status)
  until a slot frees. Semaphore bound lazily per running loop.
- Structured results: parse PLAY RECAP into per-host ok/changed/unreachable/
  failed/skipped + capture failed-task lines, stored in new results JSON
  column (migration 0020); rendered as a host-summary table on run detail.
  Keeps live streaming (no json-callback swap).
- Retention: full output written to a persistent log artifact
  (/data/ansible/runs/<id>.log, env-overridable) beyond the 1 MB DB cap and
  across restarts; in-memory replay buffer bounded + GC'd after completion;
  Download-log route. Boot reconciliation now also sweeps stale 'queued'.

Unit tests for recap parsing + cancel flagging. Status colors updated across
run list / detail / schedules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 14:51:04 -04:00
parent 389002fc6f
commit 88857be24e
13 changed files with 406 additions and 61 deletions
+218 -54
View File
@@ -4,6 +4,7 @@ import asyncio
import json
import logging
import os
import re
import shutil
import tempfile
import time
@@ -17,9 +18,19 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
_OUTPUT_CAP_BYTES = 1024 * 1024 # 1 MB DB cap
_OUTPUT_CAP_BYTES = 1024 * 1024 # 1 MB DB cap (full output also goes to a log artifact)
_FLUSH_LINES = 50
_FLUSH_SECS = 5.0
_MEM_LINES_CAP = 5000 # bounded per-run in-memory replay buffer (SSE late-joiners)
_MEM_TTL_SECS = 300 # drop in-memory state this long after a run completes
_FAILURE_CAP = 50 # max failed-task lines captured into structured results
# Full run logs persist here (survive restart + the 1 MB DB cap). Override via env.
ARTIFACT_DIR = os.environ.get("STEWARD_ANSIBLE_LOG_DIR", "/data/ansible/runs")
def artifact_path(run_id: str) -> str:
return os.path.join(ARTIFACT_DIR, f"{run_id}.log")
@dataclass
@@ -27,11 +38,19 @@ class _Done:
status: str
# Per-run broadcast state (lives until process restarts)
_run_lines: dict[str, list[str]] = {} # all output lines received so far
class _Aborted(Exception):
"""Raised internally when a queued run is cancelled before it launches."""
# Per-run broadcast state (lives until shortly after completion, then GC'd)
_run_lines: dict[str, list[str]] = {} # bounded tail of output lines
_run_done: dict[str, _Done] = {} # set when run completes
_run_listeners: dict[str, list[asyncio.Queue]] = {} # active SSE client queues
# Running subprocesses by run_id (for cancellation) + run_ids an operator cancelled.
_run_procs: dict[str, asyncio.subprocess.Process] = {}
_cancelled: set[str] = set()
def register_listener(run_id: str) -> asyncio.Queue:
"""Create a listener queue for a run. Replays existing lines immediately."""
@@ -52,15 +71,31 @@ def deregister_listener(run_id: str, q: asyncio.Queue) -> None:
listeners.remove(q)
def _schedule_cleanup(run_id: str) -> None:
"""Drop a finished run's in-memory state after a grace window."""
async def _cleanup() -> None:
await asyncio.sleep(_MEM_TTL_SECS)
_run_lines.pop(run_id, None)
_run_done.pop(run_id, None)
try:
asyncio.create_task(_cleanup())
except RuntimeError:
pass # no running loop (e.g. tests) — fine to skip
def _broadcast(run_id: str, item: str | _Done) -> None:
if isinstance(item, str):
_run_lines.setdefault(run_id, []).append(item)
buf = _run_lines.setdefault(run_id, [])
buf.append(item)
if len(buf) > _MEM_LINES_CAP: # keep only the most recent lines in memory
del buf[: len(buf) - _MEM_LINES_CAP]
else:
_run_done[run_id] = item
for q in _run_listeners.get(run_id, []):
q.put_nowait(item)
if isinstance(item, _Done):
_run_listeners.pop(run_id, None)
_schedule_cleanup(run_id)
def build_ansible_command(
@@ -127,6 +162,86 @@ def ansible_env(creds: dict | None, base_env) -> dict:
return env
# ── Structured results (parsed from the streamed output) ──────────────────────
_RECAP_RE = re.compile(
r"^(?P<host>\S+)\s*:\s*ok=(?P<ok>\d+)\s+changed=(?P<changed>\d+)\s+"
r"unreachable=(?P<unreachable>\d+)\s+failed=(?P<failed>\d+)"
r"(?:\s+skipped=(?P<skipped>\d+))?"
)
def parse_recap(lines: list[str]) -> dict[str, dict[str, int]]:
"""Parse Ansible's PLAY RECAP into {host: {ok,changed,unreachable,failed,skipped}}.
Streaming-friendly: we keep the default stdout callback (live output) and
derive the per-host summary from the recap rather than swapping to the json
callback (which would buffer everything to the end and kill live streaming).
"""
hosts: dict[str, dict[str, int]] = {}
in_recap = False
for line in lines:
if "PLAY RECAP" in line:
in_recap = True
continue
if not in_recap:
continue
m = _RECAP_RE.match(line.strip())
if m:
hosts[m.group("host")] = {
"ok": int(m.group("ok")),
"changed": int(m.group("changed")),
"unreachable": int(m.group("unreachable")),
"failed": int(m.group("failed")),
"skipped": int(m.group("skipped") or 0),
}
return hosts
# ── Cancellation ──────────────────────────────────────────────────────────────
async def _set_status(app: "Quart", run_id: str, status) -> None:
from sqlalchemy import update
from steward.models.ansible import AnsibleRun
async with app.db_sessionmaker() as session:
async with session.begin():
await session.execute(
update(AnsibleRun).where(AnsibleRun.id == run_id).values(status=status)
)
async def _terminate_then_kill(proc: asyncio.subprocess.Process, grace: float = 10.0) -> None:
try:
proc.terminate()
except ProcessLookupError:
return
try:
await asyncio.wait_for(proc.wait(), timeout=grace)
except asyncio.TimeoutError:
try:
proc.kill()
except ProcessLookupError:
pass
def cancel_run(run_id: str) -> bool:
"""Request cancellation of a run. Terminates the process if it's running;
a queued run is flagged so start_run aborts before launch. Returns False
only when the run isn't tracked in this process (already finished)."""
if run_id not in _run_procs and run_id not in _cancelled:
# Not obviously in-flight. Still flag it (covers a just-queued run whose
# proc hasn't been registered yet); caller should gate on run status.
_cancelled.add(run_id)
return run_id in _run_procs or True
_cancelled.add(run_id)
proc = _run_procs.get(run_id)
if proc is not None:
asyncio.create_task(_terminate_then_kill(proc))
return True
# ── Run execution ─────────────────────────────────────────────────────────────
async def start_run(
app: "Quart",
run_id: str,
@@ -138,26 +253,30 @@ async def start_run(
) -> None:
"""Execute ansible-playbook as a subprocess and update the DB run row.
If inventory_content is given (host-targeted runs), it's written to a temp
file and used as the inventory instead of inventory_path.
Respects a global concurrency cap (app._ansible_semaphore): if no slot is
free the run shows as 'queued' until one frees. Supports cancellation, a
persistent full-log artifact, and a parsed per-host result summary.
"""
_run_lines[run_id] = []
from steward.models.ansible import AnsibleRunStatus
_run_lines[run_id] = []
db_output = ""
truncated = False
lines_since_flush = 0
last_flush_at = time.monotonic()
failures: list[str] = []
async def _flush(final_status: str | None = None) -> None:
async def _flush(final_status: str | None = None, results: dict | None = None) -> None:
nonlocal lines_since_flush, last_flush_at
from sqlalchemy import update
from steward.models.ansible import AnsibleRun, AnsibleRunStatus
from steward.models.ansible import AnsibleRun
values: dict = {"output": db_output}
if results is not None:
values["results"] = results
if final_status is not None:
values["status"] = AnsibleRunStatus(final_status)
values["finished_at"] = datetime.now(timezone.utc)
async with app.db_sessionmaker() as session:
async with session.begin():
await session.execute(
@@ -166,61 +285,106 @@ async def start_run(
lines_since_flush = 0
last_flush_at = time.monotonic()
cwd = source_path if Path(source_path).exists() else None
creds = app.config.get("ANSIBLE", {})
# ── Concurrency gate (semaphore lazily bound to the running loop so the
# cap is honoured in prod's single loop without breaking multi-loop tests).
loop = asyncio.get_running_loop()
sem = getattr(app, "_ansible_semaphore", None)
if sem is None or getattr(app, "_ansible_sem_loop", None) is not loop:
cap = int((app.config.get("ANSIBLE") or {}).get("max_concurrent_runs", 3) or 3)
sem = asyncio.Semaphore(max(1, cap))
app._ansible_semaphore = sem
app._ansible_sem_loop = loop
queued = sem.locked()
if queued:
await _set_status(app, run_id, AnsibleRunStatus.queued)
_broadcast(run_id, "[queued — waiting for an Ansible run slot]")
await sem.acquire()
# Temp dir holds the ephemeral inventory (if any) + credential files; removed in finally.
tmpdir = tempfile.mkdtemp(prefix="steward-ansible-")
os.chmod(tmpdir, 0o700)
final_status = "failed"
logf = None
try:
if inventory_content:
inv_target = os.path.join(tmpdir, "inventory")
with open(inv_target, "w", encoding="utf-8") as invf:
invf.write(inventory_content)
else:
inv_target = inventory_path
cmd = build_ansible_command(playbook_path, inv_target, params)
if run_id in _cancelled: # cancelled while queued — abort before launching
raise _Aborted
if queued:
await _set_status(app, run_id, AnsibleRunStatus.running)
cred_args, cred_files = build_credentials(creds, tmpdir)
for cred_path, content in cred_files:
with open(cred_path, "w", encoding="utf-8") as cf:
cf.write(content)
os.chmod(cred_path, 0o600)
cwd = source_path if Path(source_path).exists() else None
creds = app.config.get("ANSIBLE", {})
try:
os.makedirs(ARTIFACT_DIR, exist_ok=True)
logf = open(artifact_path(run_id), "w", encoding="utf-8")
except OSError:
logf = None # artifact is best-effort; the DB still holds capped output
proc = await asyncio.create_subprocess_exec(
*cmd, *cred_args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
cwd=cwd,
env=ansible_env(creds, os.environ),
)
assert proc.stdout is not None
# Temp dir holds the ephemeral inventory (if any) + credential files.
tmpdir = tempfile.mkdtemp(prefix="steward-ansible-")
os.chmod(tmpdir, 0o700)
try:
if inventory_content:
inv_target = os.path.join(tmpdir, "inventory")
with open(inv_target, "w", encoding="utf-8") as invf:
invf.write(inventory_content)
else:
inv_target = inventory_path
cmd = build_ansible_command(playbook_path, inv_target, params)
async for raw_line in proc.stdout:
line = raw_line.decode(errors="replace").rstrip("\n\r")
_broadcast(run_id, line)
cred_args, cred_files = build_credentials(creds, tmpdir)
for cred_path, content in cred_files:
with open(cred_path, "w", encoding="utf-8") as cf:
cf.write(content)
os.chmod(cred_path, 0o600)
if not truncated:
candidate = (db_output + "\n" + line) if db_output else line
if len(candidate.encode()) > _OUTPUT_CAP_BYTES:
db_output += "\n[output truncated]"
truncated = True
else:
db_output = candidate
proc = await asyncio.create_subprocess_exec(
*cmd, *cred_args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
cwd=cwd,
env=ansible_env(creds, os.environ),
)
_run_procs[run_id] = proc
assert proc.stdout is not None
lines_since_flush += 1
elapsed = time.monotonic() - last_flush_at
if lines_since_flush >= _FLUSH_LINES or elapsed >= _FLUSH_SECS:
await _flush()
async for raw_line in proc.stdout:
line = raw_line.decode(errors="replace").rstrip("\n\r")
_broadcast(run_id, line)
if logf is not None:
logf.write(line + "\n")
if (line.startswith("fatal:") or "FAILED!" in line) and len(failures) < _FAILURE_CAP:
failures.append(line[:500])
await proc.wait()
final_status = "success" if proc.returncode == 0 else "failed"
if not truncated:
candidate = (db_output + "\n" + line) if db_output else line
if len(candidate.encode()) > _OUTPUT_CAP_BYTES:
db_output += "\n[output truncated — download the full log]"
truncated = True
else:
db_output = candidate
lines_since_flush += 1
elapsed = time.monotonic() - last_flush_at
if lines_since_flush >= _FLUSH_LINES or elapsed >= _FLUSH_SECS:
await _flush()
await proc.wait()
if run_id in _cancelled:
final_status = "cancelled"
else:
final_status = "success" if proc.returncode == 0 else "failed"
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
except _Aborted:
final_status = "cancelled"
except Exception:
logger.exception("Ansible run %s failed with exception", run_id)
final_status = "failed"
final_status = "cancelled" if run_id in _cancelled else "failed"
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
if logf is not None:
logf.close()
_run_procs.pop(run_id, None)
_cancelled.discard(run_id)
sem.release()
await _flush(final_status)
results = {"hosts": parse_recap(_run_lines.get(run_id, [])), "failures": failures}
await _flush(final_status, results=results)
_broadcast(run_id, _Done(status=final_status))
+39 -1
View File
@@ -166,7 +166,7 @@ async def stream_run(run_id: str):
return "Not found", 404
async def generate():
if run.status != AnsibleRunStatus.running:
if run.status not in (AnsibleRunStatus.running, AnsibleRunStatus.queued):
yield f"event: done\ndata: {run.status.value}\n\n"
return
@@ -208,6 +208,44 @@ async def run_detail(run_id: str):
"ansible/run_detail.html", run=run, triggered_label=triggered_label)
@ansible_bp.post("/runs/<run_id>/cancel")
@require_role(UserRole.operator)
async def cancel_run(run_id: str):
async with current_app.db_sessionmaker() as db:
run = (await db.execute(
select(AnsibleRun).where(AnsibleRun.id == run_id))).scalar_one_or_none()
if run is None:
return "Not found", 404
if run.status in (AnsibleRunStatus.running, AnsibleRunStatus.queued):
executor.cancel_run(run_id)
return redirect(url_for("ansible.run_detail", run_id=run_id))
@ansible_bp.get("/runs/<run_id>/log")
@require_role(UserRole.viewer)
async def run_log(run_id: str):
"""Download the full run log (persistent artifact; falls back to DB output)."""
import os
from steward.ansible.executor import artifact_path
async with current_app.db_sessionmaker() as db:
run = (await db.execute(
select(AnsibleRun).where(AnsibleRun.id == run_id))).scalar_one_or_none()
if run is None:
return "Not found", 404
path = artifact_path(run_id)
if os.path.exists(path):
with open(path, encoding="utf-8", errors="replace") as f:
body = f.read()
else:
body = run.output or ""
return Response(
body, mimetype="text/plain",
headers={"Content-Disposition": f'attachment; filename="ansible-run-{run_id[:8]}.log"'},
)
# ── Scheduled recurring runs ──────────────────────────────────────────────────
@ansible_bp.get("/schedules")
+2 -1
View File
@@ -322,7 +322,8 @@ async def _mark_interrupted_runs(app: Quart) -> None:
async with session.begin():
await session.execute(
update(AnsibleRun)
.where(AnsibleRun.status == AnsibleRunStatus.running)
.where(AnsibleRun.status.in_(
[AnsibleRunStatus.running, AnsibleRunStatus.queued]))
.values(
status=AnsibleRunStatus.interrupted,
finished_at=datetime.now(timezone.utc),
+3
View File
@@ -57,6 +57,8 @@ DEFAULTS: dict[str, Any] = {
"ansible.become_password": "",
"ansible.vault_password": "",
"ansible.host_key_checking": False,
# Max simultaneous playbook runs; extra runs queue. Applied at app start.
"ansible.max_concurrent_runs": 3,
"ping.threshold.good_ms": 50,
"ping.threshold.warn_ms": 200,
"plugins.index_url": "https://git.fabledsword.com/bvandeusen/Steward-plugins/raw/branch/main/index.yaml",
@@ -175,6 +177,7 @@ def to_ansible_cfg(settings: dict[str, Any]) -> dict:
"become_password": settings.get("ansible.become_password", ""),
"vault_password": settings.get("ansible.vault_password", ""),
"host_key_checking": settings.get("ansible.host_key_checking", False),
"max_concurrent_runs": settings.get("ansible.max_concurrent_runs", 3),
}
@@ -0,0 +1,26 @@
"""Add 'queued' + 'cancelled' values to the ansiblerunstatus enum
Revision ID: 0019_ansible_run_statuses
Revises: 0018_ansible_schedules
Create Date: 2026-06-16
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0019_ansible_run_statuses"
down_revision: Union[str, None] = "0018_ansible_schedules"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ALTER TYPE ... ADD VALUE cannot run inside a transaction block; use an
# autocommit block. IF NOT EXISTS keeps it idempotent across re-runs.
with op.get_context().autocommit_block():
op.execute("ALTER TYPE ansiblerunstatus ADD VALUE IF NOT EXISTS 'queued'")
op.execute("ALTER TYPE ansiblerunstatus ADD VALUE IF NOT EXISTS 'cancelled'")
def downgrade() -> None:
# Postgres has no DROP VALUE for enums; leaving the values is harmless.
pass
@@ -0,0 +1,22 @@
"""Add results JSON column to ansible_runs (parsed per-host summary)
Revision ID: 0020_ansible_run_results
Revises: 0019_ansible_run_statuses
Create Date: 2026-06-16
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0020_ansible_run_results"
down_revision: Union[str, None] = "0019_ansible_run_statuses"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("ansible_runs", sa.Column("results", sa.JSON(), nullable=True))
def downgrade() -> None:
op.drop_column("ansible_runs", "results")
+6 -1
View File
@@ -8,10 +8,12 @@ from .base import Base
class AnsibleRunStatus(str, enum.Enum):
queued = "queued" # waiting for a concurrency slot
running = "running"
success = "success"
failed = "failed"
interrupted = "interrupted"
interrupted = "interrupted" # process/host died mid-run (e.g. app restart)
cancelled = "cancelled" # stopped by an operator
class AnsibleRun(Base):
@@ -39,3 +41,6 @@ class AnsibleRun(Base):
output: Mapped[str | None] = mapped_column(Text, nullable=True)
# Optional run parameters: {extra_vars: [..], limit: str, tags: str, check: bool}.
params: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# Parsed per-host summary + captured failures:
# {hosts: {name: {ok,changed,unreachable,failed,skipped}}, failures: [str]}.
results: Mapped[dict | None] = mapped_column(JSON, nullable=True)
+6
View File
@@ -179,6 +179,7 @@ async def ansible():
become_set=bool(settings.get("ansible.become_password")),
vault_set=bool(settings.get("ansible.vault_password")),
host_key_checking=settings.get("ansible.host_key_checking", False),
max_concurrent_runs=settings.get("ansible.max_concurrent_runs", 3),
)
@@ -203,6 +204,11 @@ async def ansible_save_credentials():
if val:
await set_setting(db, key, val)
await set_setting(db, "ansible.host_key_checking", "host_key_checking" in form)
try:
mcr = max(1, min(50, int(form.get("max_concurrent_runs", 3))))
except (TypeError, ValueError):
mcr = 3
await set_setting(db, "ansible.max_concurrent_runs", mcr)
await _reload_app_config()
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
"settings.saved", detail={"section": "ansible.credentials"})
+1 -1
View File
@@ -27,7 +27,7 @@
</thead>
<tbody>
{% for run in runs %}
{% set status_color = {"running": "var(--yellow)", "success": "var(--green)", "failed": "var(--red)", "interrupted": "var(--orange)"}.get(run.status.value, "var(--text-muted)") %}
{% set status_color = {"queued": "var(--text-dim)", "running": "var(--yellow)", "success": "var(--green)", "failed": "var(--red)", "interrupted": "var(--orange)", "cancelled": "var(--orange)"}.get(run.status.value, "var(--text-muted)") %}
<tr>
<td style="font-size:0.9rem;">{{ run.playbook_path }}</td>
<td style="color:var(--text-muted);font-size:0.9rem;">{{ run.source_name }}</td>
+40 -2
View File
@@ -6,9 +6,17 @@
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;">
<a href="/ansible/" class="btn btn-ghost btn-sm">← Runs</a>
<h1 class="page-title" style="margin-bottom:0;">Run Detail</h1>
<div style="margin-left:auto;display:flex;gap:0.5rem;">
{% if run.status.value in ("running", "queued") and session.user_role in ("operator", "admin") %}
<form method="post" action="/ansible/runs/{{ run.id }}/cancel" onsubmit="return confirm('Cancel this run?');">
<button type="submit" class="btn btn-sm btn-danger">Cancel run</button>
</form>
{% endif %}
<a href="/ansible/runs/{{ run.id }}/log" class="btn btn-sm btn-ghost">Download log</a>
</div>
</div>
{% set status_color = {"running": "var(--yellow)", "success": "var(--green)", "failed": "var(--red)", "interrupted": "var(--orange)"}.get(run.status.value, "var(--text-muted)") %}
{% set status_color = {"queued": "var(--text-dim)", "running": "var(--yellow)", "success": "var(--green)", "failed": "var(--red)", "interrupted": "var(--orange)", "cancelled": "var(--orange)"}.get(run.status.value, "var(--text-muted)") %}
<div class="card">
<div style="display:grid;grid-template-columns:auto 1fr;gap:0.4rem 1rem;font-size:0.9rem;margin-bottom:1rem;">
<span style="color:var(--text-muted);">ID</span><span style="color:var(--text-dim);font-family:monospace;">{{ run.id }}</span>
@@ -38,11 +46,41 @@
</div>
</div>
{% if run.results and run.results.hosts %}
<div class="card">
<div class="section-title" style="margin-bottom:0.6rem;">Host summary</div>
<table class="table">
<thead><tr>
<th>Host</th>
<th style="text-align:center;">ok</th><th style="text-align:center;">changed</th>
<th style="text-align:center;">unreachable</th><th style="text-align:center;">failed</th>
<th style="text-align:center;">skipped</th>
</tr></thead>
<tbody>
{% for host, c in run.results.hosts.items() %}
<tr>
<td style="font-family:ui-monospace,monospace;">{{ host }}</td>
<td style="text-align:center;">{{ c.ok }}</td>
<td style="text-align:center;color:{% if c.changed %}var(--yellow){% else %}var(--text-dim){% endif %};">{{ c.changed }}</td>
<td style="text-align:center;color:{% if c.unreachable %}var(--red){% else %}var(--text-dim){% endif %};">{{ c.unreachable }}</td>
<td style="text-align:center;color:{% if c.failed %}var(--red){% else %}var(--text-dim){% endif %};">{{ c.failed }}</td>
<td style="text-align:center;color:var(--text-dim);">{{ c.skipped }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if run.results.failures %}
<div class="section-title" style="margin:1rem 0 0.4rem;">Failures</div>
<pre style="margin:0;padding:0.75rem;background:var(--bg);border-radius:4px;font-size:0.75rem;color:#ff9090;max-height:240px;overflow:auto;white-space:pre-wrap;">{{ run.results.failures | join("\n") }}</pre>
{% endif %}
</div>
{% endif %}
<div class="card" style="padding:0;">
<div style="padding:0.75rem 1rem;background:var(--bg-elevated);border-bottom:1px solid var(--border);">
<span style="color:var(--text-muted);font-size:0.85rem;">Output</span>
</div>
{% if run.status.value == "running" %}
{% if run.status.value in ("running", "queued") %}
<pre id="live-output"
style="margin:0;padding:1rem;background:var(--bg);font-size:0.78rem;color:var(--green);max-height:600px;overflow-y:auto;white-space:pre-wrap;">{{ run.output or "" }}</pre>
<div id="run-done-status" style="padding:0.5rem 1rem;font-size:0.85rem;color:var(--text-muted);">
+1 -1
View File
@@ -42,7 +42,7 @@
{% if s.last_error %}
<span style="color:var(--red);" title="{{ s.last_error }}">launch error</span>
{% elif row.last_run %}
{% set sc = {"running":"var(--yellow)","success":"var(--green)","failed":"var(--red)","interrupted":"var(--orange)"}.get(row.last_run.status.value, "var(--text-muted)") %}
{% set sc = {"queued":"var(--text-dim)","running":"var(--yellow)","success":"var(--green)","failed":"var(--red)","interrupted":"var(--orange)","cancelled":"var(--orange)"}.get(row.last_run.status.value, "var(--text-muted)") %}
<a href="/ansible/runs/{{ row.last_run.id }}" style="color:{{ sc }};font-weight:600;">{{ row.last_run.status.value }}</a>
<span style="color:var(--text-dim);">· {{ s.last_run_at.strftime("%m-%d %H:%M") }}</span>
{% else %}
+7
View File
@@ -62,6 +62,13 @@
Enforce SSH host-key checking <span style="color:var(--text-muted);font-size:0.78rem;">(off by default for homelab convenience)</span>
</label>
</div>
<div class="form-group">
<label>Max concurrent runs
<span style="color:var(--text-muted);font-size:0.78rem;font-weight:normal;">(extra runs queue; applied on restart)</span>
</label>
<input type="number" name="max_concurrent_runs" min="1" max="50"
value="{{ max_concurrent_runs }}" style="width:7rem;">
</div>
<button type="submit" class="btn">Save credentials</button>
</form>
</div>
+35
View File
@@ -0,0 +1,35 @@
"""Unit tests for executor structured-results parsing + cancel flagging."""
from steward.ansible.executor import _cancelled, cancel_run, parse_recap
def test_parse_recap_extracts_per_host_counts():
lines = [
"PLAY RECAP *****************************************************************",
"web01 : ok=5 changed=2 unreachable=0 failed=0 skipped=1 rescued=0",
"db01 : ok=3 changed=0 unreachable=1 failed=0 skipped=0",
]
hosts = parse_recap(lines)
assert hosts["web01"] == {"ok": 5, "changed": 2, "unreachable": 0, "failed": 0, "skipped": 1}
assert hosts["db01"]["unreachable"] == 1
assert hosts["db01"]["ok"] == 3
def test_parse_recap_ignores_lines_before_recap():
lines = [
"TASK [whatever] ****",
"ok: [web01]",
"PLAY RECAP ****",
"web01 : ok=1 changed=0 unreachable=0 failed=0 skipped=0",
]
assert set(parse_recap(lines)) == {"web01"}
def test_parse_recap_empty_without_recap():
assert parse_recap(["TASK [x] ***", "ok: [h]"]) == {}
def test_cancel_run_flags_untracked_run():
rid = "nonexistent-run-id"
assert cancel_run(rid) is True
assert rid in _cancelled
_cancelled.discard(rid)