88857be24e
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>
47 lines
2.1 KiB
Python
47 lines
2.1 KiB
Python
from __future__ import annotations
|
|
import enum
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from sqlalchemy import JSON, DateTime, Enum, ForeignKey, String, Text
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
from .base import Base
|
|
|
|
|
|
class AnsibleRunStatus(str, enum.Enum):
|
|
queued = "queued" # waiting for a concurrency slot
|
|
running = "running"
|
|
success = "success"
|
|
failed = "failed"
|
|
interrupted = "interrupted" # process/host died mid-run (e.g. app restart)
|
|
cancelled = "cancelled" # stopped by an operator
|
|
|
|
|
|
class AnsibleRun(Base):
|
|
__tablename__ = "ansible_runs"
|
|
|
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
|
playbook_path: Mapped[str] = mapped_column(String(512), nullable=False)
|
|
inventory_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
|
inventory_scope: Mapped[str] = mapped_column(
|
|
String(255), nullable=False, default="steward:all"
|
|
)
|
|
source_name: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
# Nullable: automated runs (alert actions, schedules) have no human actor —
|
|
# NULL renders as "system". Manual runs still record the triggering user.
|
|
triggered_by: Mapped[str | None] = mapped_column(
|
|
String(36), ForeignKey("users.id", ondelete="RESTRICT"), nullable=True
|
|
)
|
|
status: Mapped[AnsibleRunStatus] = mapped_column(
|
|
Enum(AnsibleRunStatus), nullable=False, default=AnsibleRunStatus.running
|
|
)
|
|
started_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
|
|
)
|
|
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
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)
|