From e5f6a11f941d57a7e3c9da148142244795638cac Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 17 Jun 2026 11:25:25 -0400 Subject: [PATCH] feat(ansible): playbooks self-describe via "# description:" comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Playbooks can ship a human description Steward reads and shows when one is selected. Convention: a `# description: ` magic comment (Ansible rejects unknown play keys, so a comment is the portable place — works for third-party playbooks too); falls back to the first play's name:. sources .discover_playbook_description(). Surfaced at the top of the shared _playbook_vars.html partial, which loads on playbook selection in the host run form, schedules form, and browse run form. All four bundled playbooks (provision/install/update/docker_prune) now carry a description line. Unit tests added. Scribe #900. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ansible/bundled/host_agent/install.yml | 1 + .../ansible/bundled/host_agent/provision.yml | 1 + steward/ansible/bundled/host_agent/update.yml | 1 + .../bundled/maintenance/docker_prune.yml | 1 + steward/ansible/routes.py | 8 +++-- steward/ansible/sources.py | 26 ++++++++++++++++ steward/templates/ansible/_playbook_vars.html | 12 +++++-- tests/test_playbook_variables.py | 31 ++++++++++++++++++- 8 files changed, 75 insertions(+), 6 deletions(-) diff --git a/steward/ansible/bundled/host_agent/install.yml b/steward/ansible/bundled/host_agent/install.yml index ce4acdd..9257e92 100644 --- a/steward/ansible/bundled/host_agent/install.yml +++ b/steward/ansible/bundled/host_agent/install.yml @@ -1,4 +1,5 @@ --- +# description: Install or re-enroll the Steward host agent on an already-provisioned host. # Install (or update) the Steward host agent on a target. Mirrors the # curl-based install.sh. Pass the per-host registration token as extra-vars: # steward_url=https://steward.example steward_token= diff --git a/steward/ansible/bundled/host_agent/provision.yml b/steward/ansible/bundled/host_agent/provision.yml index 49883eb..c09c0e3 100644 --- a/steward/ansible/bundled/host_agent/provision.yml +++ b/steward/ansible/bundled/host_agent/provision.yml @@ -1,4 +1,5 @@ --- +# description: Provision a fresh host — create the steward account + managed key + passwordless sudo, then install the agent. # First-contact provisioning: bring a fresh host under Steward management, then # install the host agent — all in one idempotent run. # diff --git a/steward/ansible/bundled/host_agent/update.yml b/steward/ansible/bundled/host_agent/update.yml index 8dff5ff..b5b6f8c 100644 --- a/steward/ansible/bundled/host_agent/update.yml +++ b/steward/ansible/bundled/host_agent/update.yml @@ -1,4 +1,5 @@ --- +# description: Update the Steward host agent (refresh agent.py + restart) on hosts already running it. # Update an already-installed Steward host agent: refresh agent.py and restart. # # Deliberately does NOT touch the registration token or /etc/steward-agent.conf diff --git a/steward/ansible/bundled/maintenance/docker_prune.yml b/steward/ansible/bundled/maintenance/docker_prune.yml index 1fcb8ec..d4e744e 100644 --- a/steward/ansible/bundled/maintenance/docker_prune.yml +++ b/steward/ansible/bundled/maintenance/docker_prune.yml @@ -1,4 +1,5 @@ --- +# description: Reclaim disk on Docker / Swarm nodes by pruning unused images, containers, networks and build cache. # Reclaim disk on Docker / Docker Swarm nodes by removing unused data. # Safe by default: prunes dangling images, stopped containers, unused networks # and build cache. Set extra-vars to widen scope: diff --git a/steward/ansible/routes.py b/steward/ansible/routes.py index d88dca8..9bcd5c3 100644 --- a/steward/ansible/routes.py +++ b/steward/ansible/routes.py @@ -165,11 +165,14 @@ async def playbook_vars(): playbook_path = (request.args.get("playbook_path", "") or "").strip() source = next((s for s in _get_sources() if s["name"] == source_name), None) variables: list = [] + description = "" if source and playbook_path: contents = src_module.read_playbook(source["path"], playbook_path) if contents is not None: variables = src_module.discover_playbook_variables(contents) - return await render_template("ansible/_playbook_vars.html", variables=variables) + description = src_module.discover_playbook_description(contents) + return await render_template( + "ansible/_playbook_vars.html", variables=variables, description=description) @ansible_bp.get("/run-form//") @@ -186,6 +189,7 @@ async def run_form(source_name: str, playbook_path: str): if contents is None: return "Playbook not found", 404 variables = src_module.discover_playbook_variables(contents) + description = src_module.discover_playbook_description(contents) inventories = src_module.discover_inventories(source["path"]) async with current_app.db_sessionmaker() as db: @@ -197,7 +201,7 @@ async def run_form(source_name: str, playbook_path: str): return await render_template( "ansible/_run_form.html", source_name=source_name, playbook_path=playbook_path, - variables=variables, targets=targets, groups=groups, + variables=variables, description=description, targets=targets, groups=groups, inventories=inventories, ) diff --git a/steward/ansible/sources.py b/steward/ansible/sources.py index 3e26a20..c495ce2 100644 --- a/steward/ansible/sources.py +++ b/steward/ansible/sources.py @@ -17,6 +17,10 @@ _SECRET_VAR_RE = re.compile( r"(password|passwd|secret|token|api[_-]?key|private[_-]?key|credential)", re.I ) +# A playbook self-describes via a `# description:` magic comment (Ansible rejects +# unknown play keys, so a comment is the portable place). First match wins. +_DESCRIPTION_RE = re.compile(r"^\s*#\s*description:\s*(.+?)\s*$", re.I | re.M) + # Name of the always-present, read-only source of first-party playbooks shipped # inside the app (maintenance tasks, host-agent install). Not operator-editable. BUILTIN_SOURCE_NAME = "steward-builtin" @@ -195,6 +199,28 @@ def delete_playbook(source_path: str, relative_path: str) -> tuple[bool, str | N return True, None +def discover_playbook_description(content: str) -> str: + """A human-readable description of what a playbook does. + + Reads a ``# description: ...`` magic comment (first match), so any playbook + can self-describe without touching its YAML structure. Falls back to the + first play's ``name:``. Returns "" if neither is present. + """ + m = _DESCRIPTION_RE.search(content) + if m: + return m.group(1).strip() + import yaml + try: + plays = yaml.safe_load(content) + except yaml.YAMLError: + return "" + if isinstance(plays, list): + for play in plays: + if isinstance(play, dict) and play.get("name"): + return str(play["name"]).strip() + return "" + + def discover_playbook_variables(content: str) -> list[dict]: """Parse a playbook and list the variables an operator can set at run time. diff --git a/steward/templates/ansible/_playbook_vars.html b/steward/templates/ansible/_playbook_vars.html index ecc611c..31a96fd 100644 --- a/steward/templates/ansible/_playbook_vars.html +++ b/steward/templates/ansible/_playbook_vars.html @@ -1,6 +1,12 @@ -{# Discovered playbook variables as fill-in fields. Shared by the browse run - form, the host run form, and the schedule form. Expects `variables` (from - discover_playbook_variables). Rendered standalone by /ansible/playbook-vars. #} +{# Description + discovered variable fields for a selected playbook. Shared by + the browse run form, host run form, and schedule form. Expects `variables` + and `description`. Rendered standalone by /ansible/playbook-vars. #} +{% if description %} +
+ {{ description }} +
+{% endif %} {% if variables %}
Playbook variables diff --git a/tests/test_playbook_variables.py b/tests/test_playbook_variables.py index 560cf03..c8d9d81 100644 --- a/tests/test_playbook_variables.py +++ b/tests/test_playbook_variables.py @@ -2,7 +2,10 @@ import json from steward.ansible.executor import build_extra_vars_file -from steward.ansible.sources import discover_playbook_variables +from steward.ansible.sources import ( + discover_playbook_description, + discover_playbook_variables, +) def _names(variables): @@ -108,3 +111,29 @@ def test_extra_vars_file_roundtrip_and_space_safe(): def test_extra_vars_file_empty_is_noop(): assert build_extra_vars_file({}, "/td") == ([], []) assert build_extra_vars_file(None, "/td") == ([], []) + + +# ── Playbook description discovery ──────────────────────────────────────────── + +def test_description_from_magic_comment(): + content = """--- +# description: Reclaim disk by pruning Docker. +- hosts: all + name: Docker prune + tasks: [] +""" + assert discover_playbook_description(content) == "Reclaim disk by pruning Docker." + + +def test_description_case_insensitive_key(): + assert discover_playbook_description("# DESCRIPTION: trimmed \n- hosts: all") == "trimmed" + + +def test_description_falls_back_to_play_name(): + content = "- hosts: all\n name: Configure web servers\n tasks: []\n" + assert discover_playbook_description(content) == "Configure web servers" + + +def test_description_empty_when_neither_present(): + assert discover_playbook_description("- hosts: all\n tasks: []\n") == "" + assert discover_playbook_description("not: : valid") == ""