diff --git a/steward/ansible/executor.py b/steward/ansible/executor.py index 22e62c9..92ee4ab 100644 --- a/steward/ansible/executor.py +++ b/steward/ansible/executor.py @@ -59,12 +59,37 @@ def _broadcast(run_id: str, item: str | _Done) -> None: _run_listeners.pop(run_id, None) +def build_ansible_command( + playbook_path: str, + inventory_path: str, + params: dict | None = None, +) -> list[str]: + """Build the ansible-playbook argv from a run's optional params. + + Pure (no IO). Everything is passed as argv — never shell-interpolated — so + extra-var values and limits can contain arbitrary characters safely. + params keys: extra_vars (list of "key=value"), limit, tags, check (bool). + """ + cmd = ["ansible-playbook", playbook_path, "-i", inventory_path] + params = params or {} + for ev in params.get("extra_vars") or []: + cmd += ["-e", ev] + if params.get("limit"): + cmd += ["--limit", params["limit"]] + if params.get("tags"): + cmd += ["--tags", params["tags"]] + if params.get("check"): + cmd += ["--check", "--diff"] + return cmd + + async def start_run( app: "Quart", run_id: str, playbook_path: str, inventory_path: str, source_path: str, + params: dict | None = None, ) -> None: """Execute ansible-playbook as a subprocess and update the DB run row.""" _run_lines[run_id] = [] @@ -92,7 +117,7 @@ async def start_run( lines_since_flush = 0 last_flush_at = time.monotonic() - cmd = ["ansible-playbook", playbook_path, "-i", inventory_path] + cmd = build_ansible_command(playbook_path, inventory_path, params) cwd = source_path if Path(source_path).exists() else None try: diff --git a/steward/ansible/routes.py b/steward/ansible/routes.py index 11022dc..b304bd5 100644 --- a/steward/ansible/routes.py +++ b/steward/ansible/routes.py @@ -85,6 +85,30 @@ async def create_run(): if source is None: return "Source not found", 404 + # Optional run parameters (all passed as argv by the executor — no shell). + extra_vars: list[str] = [] + for line in (form.get("extra_vars", "") or "").splitlines(): + line = line.strip() + if not line: + continue + if "=" not in line: + return f"Invalid extra var (expected key=value): {line!r}", 400 + extra_vars.append(line) + limit = (form.get("limit", "") or "").strip() + tags = (form.get("tags", "") or "").strip() + check = "check" in form + + params: dict = {} + if extra_vars: + params["extra_vars"] = extra_vars + if limit: + params["limit"] = limit + if tags: + params["tags"] = tags + if check: + params["check"] = True + params_or_none = params or None + run_id = str(uuid.uuid4()) now = datetime.now(timezone.utc) @@ -96,6 +120,7 @@ async def create_run(): triggered_by=session["user_id"], status=AnsibleRunStatus.running, started_at=now, + params=params_or_none, ) async with current_app.db_sessionmaker() as db: async with db.begin(): @@ -108,6 +133,7 @@ async def create_run(): playbook_path, inventory_path, source["path"], + params_or_none, ) ) task.add_done_callback( diff --git a/steward/migrations/versions/0013_ansible_run_params.py b/steward/migrations/versions/0013_ansible_run_params.py new file mode 100644 index 0000000..f70811b --- /dev/null +++ b/steward/migrations/versions/0013_ansible_run_params.py @@ -0,0 +1,22 @@ +"""Add ansible_runs.params (optional run parameters) + +Revision ID: 0013_ansible_run_params +Revises: 0012_ansible_run_nullable +Create Date: 2026-06-02 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = "0013_ansible_run_params" +down_revision: Union[str, None] = "0012_ansible_run_nullable" +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("params", sa.JSON(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("ansible_runs", "params") diff --git a/steward/models/ansible.py b/steward/models/ansible.py index 82d18bc..2adf06e 100644 --- a/steward/models/ansible.py +++ b/steward/models/ansible.py @@ -2,7 +2,7 @@ from __future__ import annotations import enum import uuid from datetime import datetime, timezone -from sqlalchemy import DateTime, Enum, ForeignKey, String, Text +from sqlalchemy import JSON, DateTime, Enum, ForeignKey, String, Text from sqlalchemy.orm import Mapped, mapped_column from .base import Base @@ -34,3 +34,5 @@ class AnsibleRun(Base): ) 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) diff --git a/steward/templates/ansible/browse.html b/steward/templates/ansible/browse.html index 93dae22..e0b95b8 100644 --- a/steward/templates/ansible/browse.html +++ b/steward/templates/ansible/browse.html @@ -83,6 +83,25 @@ +