diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 251d2bd..ab6dea8 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -85,8 +85,8 @@ jobs: sleep 2 done if command -v uv >/dev/null 2>&1; then - uv pip install --system -e '.[dev]' + uv pip install --system -e '.[dev,ansible]' else - pip install -e '.[dev]' + pip install -e '.[dev,ansible]' fi pytest tests/integration -v -m integration --durations=10 diff --git a/Dockerfile b/Dockerfile index 4172e1d..c74d42d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,8 @@ RUN DEBIAN_FRONTEND=noninteractive apt-get update \ iputils-ping \ # gosu — minimal privilege-drop tool used by entrypoint.sh gosu \ + # openssh-client — ansible-playbook reaches remote hosts over ssh + openssh-client \ && rm -rf /var/lib/apt/lists/* # Upgrade pip to suppress the version notice @@ -14,7 +16,8 @@ WORKDIR /app COPY pyproject.toml . COPY steward/ steward/ -RUN pip install --no-cache-dir . +# .[ansible] pulls the full Ansible package so the playbook runner works in-image. +RUN pip install --no-cache-dir '.[ansible]' COPY alembic.ini . # First-party plugins ship inside the image (bundled root at /app/plugins). diff --git a/pyproject.toml b/pyproject.toml index fe4179c..1a4aecc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,11 @@ ldap = [ snmp = [ "pysnmp-lextudio>=6.2", ] +# Batteries-included Ansible (bundles the common community collections) for the +# playbook runner. Installed into the image via Dockerfile `pip install .[ansible]`. +ansible = [ + "ansible>=10,<13", +] dev = [ "pytest>=8.0", "pytest-asyncio>=0.23", diff --git a/steward/ansible/routes.py b/steward/ansible/routes.py index 1dceb26..11022dc 100644 --- a/steward/ansible/routes.py +++ b/steward/ansible/routes.py @@ -13,7 +13,7 @@ from sqlalchemy import select from steward.ansible import executor, sources as src_module from steward.auth.middleware import require_role from steward.models.ansible import AnsibleRun, AnsibleRunStatus -from steward.models.users import UserRole +from steward.models.users import User, UserRole ansible_bp = Blueprint("ansible", __name__, url_prefix="/ansible") @@ -163,6 +163,13 @@ async def run_detail(run_id: str): async with current_app.db_sessionmaker() as db: result = await db.execute(select(AnsibleRun).where(AnsibleRun.id == run_id)) run = result.scalar_one_or_none() - if run is None: - return "Not found", 404 - return await render_template("ansible/run_detail.html", run=run) + if run is None: + return "Not found", 404 + # NULL triggered_by = an automated (system) run; otherwise resolve the user. + triggered_label = "system" + if run.triggered_by: + res = await db.execute( + select(User.username).where(User.id == run.triggered_by)) + triggered_label = res.scalar_one_or_none() or "(deleted user)" + return await render_template( + "ansible/run_detail.html", run=run, triggered_label=triggered_label) diff --git a/steward/migrations/versions/0012_ansible_run_triggered_by_nullable.py b/steward/migrations/versions/0012_ansible_run_triggered_by_nullable.py new file mode 100644 index 0000000..26ddc9d --- /dev/null +++ b/steward/migrations/versions/0012_ansible_run_triggered_by_nullable.py @@ -0,0 +1,31 @@ +"""Make ansible_runs.triggered_by nullable (automated runs have no user) + +Revision ID: 0012_ansible_run_triggered_by_nullable +Revises: 0011_audit_log +Create Date: 2026-06-02 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = "0012_ansible_run_triggered_by_nullable" +down_revision: Union[str, None] = "0011_audit_log" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.alter_column( + "ansible_runs", "triggered_by", + existing_type=sa.String(36), + nullable=True, + ) + + +def downgrade() -> None: + # Re-imposes NOT NULL; will fail if any system-triggered (NULL) rows exist. + op.alter_column( + "ansible_runs", "triggered_by", + existing_type=sa.String(36), + nullable=False, + ) diff --git a/steward/models/ansible.py b/steward/models/ansible.py index 8524fca..82d18bc 100644 --- a/steward/models/ansible.py +++ b/steward/models/ansible.py @@ -21,8 +21,10 @@ class AnsibleRun(Base): playbook_path: Mapped[str] = mapped_column(String(512), nullable=False) inventory_path: Mapped[str] = mapped_column(String(512), nullable=False) source_name: Mapped[str] = mapped_column(String(128), nullable=False) - triggered_by: Mapped[str] = mapped_column( - String(36), ForeignKey("users.id", ondelete="RESTRICT"), 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 diff --git a/steward/templates/ansible/run_detail.html b/steward/templates/ansible/run_detail.html index 236ad81..d6fdf9f 100644 --- a/steward/templates/ansible/run_detail.html +++ b/steward/templates/ansible/run_detail.html @@ -13,6 +13,7 @@ Playbook{{ run.playbook_path }} Inventory{{ run.inventory_path }} Source{{ run.source_name }} + Triggered by{{ triggered_label }} Status{{ run.status.value.upper() }} Started{{ run.started_at.strftime("%Y-%m-%d %H:%M:%S UTC") }} {% if run.finished_at %} diff --git a/tests/integration/test_ansible_foundation.py b/tests/integration/test_ansible_foundation.py new file mode 100644 index 0000000..341755d --- /dev/null +++ b/tests/integration/test_ansible_foundation.py @@ -0,0 +1,101 @@ +"""Integration: the Ansible foundation (task 545). + +Proves the two foundation pieces against a live Postgres + a real ansible install: + 1. ansible-playbook is on PATH (ships in the image / [ansible] extra). + 2. executor.start_run actually runs a trivial connection:local playbook end to + end and records success + output on the AnsibleRun row. + 3. An AnsibleRun with triggered_by=NULL inserts and round-trips (migration 0012). + +Requires STEWARD_DATABASE_URL (the integration CI lane provides it). A fresh app +is built per test so each test's async work runs on its own event loop with its +own engine (create_app's internal asyncio.run can't be nested under a running +loop, so these tests are sync and drive async work via asyncio.run). +""" +from __future__ import annotations + +import asyncio +import os +import shutil +import textwrap +import uuid + +import pytest + +pytestmark = pytest.mark.integration + +_NEEDS_DB = pytest.mark.skipif( + not os.environ.get("STEWARD_DATABASE_URL"), + reason="integration test needs a live Postgres (STEWARD_DATABASE_URL)", +) + + +@pytest.fixture +def app(): + if not os.environ.get("STEWARD_DATABASE_URL"): + pytest.skip("needs Postgres") + from steward.app import create_app + # testing=False → runs all migrations (incl. 0012) and gives a real engine. + return create_app(testing=False) + + +def test_ansible_playbook_on_path(): + assert shutil.which("ansible-playbook") is not None + + +@_NEEDS_DB +def test_null_triggered_run_inserts(app): + from sqlalchemy import select + from steward.models.ansible import AnsibleRun, AnsibleRunStatus + + run_id = str(uuid.uuid4()) + + async def _go(): + async with app.db_sessionmaker() as s: + async with s.begin(): + s.add(AnsibleRun( + id=run_id, playbook_path="p.yml", inventory_path="i.ini", + source_name="t", triggered_by=None, + status=AnsibleRunStatus.success, + )) + async with app.db_sessionmaker() as s: + row = (await s.execute( + select(AnsibleRun).where(AnsibleRun.id == run_id))).scalar_one() + return row.triggered_by + + assert asyncio.run(_go()) is None + + +@_NEEDS_DB +def test_executor_runs_local_playbook(app, tmp_path): + from sqlalchemy import select + from steward.ansible import executor + from steward.models.ansible import AnsibleRun, AnsibleRunStatus + + (tmp_path / "test.yml").write_text(textwrap.dedent("""\ + - hosts: localhost + connection: local + gather_facts: false + tasks: + - debug: + msg: STEWARD_ANSIBLE_OK + """)) + (tmp_path / "inv.ini").write_text("localhost ansible_connection=local\n") + + run_id = str(uuid.uuid4()) + + async def _go(): + async with app.db_sessionmaker() as s: + async with s.begin(): + s.add(AnsibleRun( + id=run_id, playbook_path="test.yml", inventory_path="inv.ini", + source_name="t", triggered_by=None, + status=AnsibleRunStatus.running, + )) + await executor.start_run(app, run_id, "test.yml", "inv.ini", str(tmp_path)) + async with app.db_sessionmaker() as s: + return (await s.execute( + select(AnsibleRun).where(AnsibleRun.id == run_id))).scalar_one() + + run = asyncio.run(_go()) + assert run.status == AnsibleRunStatus.success, run.output + assert "STEWARD_ANSIBLE_OK" in (run.output or "")