feat(ansible): playbooks self-describe via "# description:" comment
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 2m15s
CI / publish (push) Successful in 52s

Playbooks can ship a human description Steward reads and shows when one is
selected. Convention: a `# description: <text>` 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) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 11:25:25 -04:00
parent a0d1c5f07c
commit e5f6a11f94
8 changed files with 75 additions and 6 deletions
@@ -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=<token from Host Agents>
@@ -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.
#
@@ -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
@@ -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:
+6 -2
View File
@@ -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/<source_name>/<path:playbook_path>")
@@ -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,
)
+26
View File
@@ -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.
@@ -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 %}
<div style="background:var(--bg);border-left:3px solid var(--accent);border-radius:3px;
padding:0.5rem 0.7rem;margin:0.25rem 0 0.6rem;font-size:0.82rem;color:var(--text-muted);">
{{ description }}
</div>
{% endif %}
{% if variables %}
<div style="margin:0.5rem 0 0.25rem;font-size:0.82rem;color:var(--text-muted);font-weight:600;">
Playbook variables
+30 -1
View File
@@ -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") == ""