feat(ansible): playbooks self-describe via "# description:" comment
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:
@@ -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
|
# 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:
|
# curl-based install.sh. Pass the per-host registration token as extra-vars:
|
||||||
# steward_url=https://steward.example steward_token=<token from Host Agents>
|
# 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
|
# First-contact provisioning: bring a fresh host under Steward management, then
|
||||||
# install the host agent — all in one idempotent run.
|
# 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.
|
# Update an already-installed Steward host agent: refresh agent.py and restart.
|
||||||
#
|
#
|
||||||
# Deliberately does NOT touch the registration token or /etc/steward-agent.conf
|
# 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.
|
# Reclaim disk on Docker / Docker Swarm nodes by removing unused data.
|
||||||
# Safe by default: prunes dangling images, stopped containers, unused networks
|
# Safe by default: prunes dangling images, stopped containers, unused networks
|
||||||
# and build cache. Set extra-vars to widen scope:
|
# and build cache. Set extra-vars to widen scope:
|
||||||
|
|||||||
@@ -165,11 +165,14 @@ async def playbook_vars():
|
|||||||
playbook_path = (request.args.get("playbook_path", "") or "").strip()
|
playbook_path = (request.args.get("playbook_path", "") or "").strip()
|
||||||
source = next((s for s in _get_sources() if s["name"] == source_name), None)
|
source = next((s for s in _get_sources() if s["name"] == source_name), None)
|
||||||
variables: list = []
|
variables: list = []
|
||||||
|
description = ""
|
||||||
if source and playbook_path:
|
if source and playbook_path:
|
||||||
contents = src_module.read_playbook(source["path"], playbook_path)
|
contents = src_module.read_playbook(source["path"], playbook_path)
|
||||||
if contents is not None:
|
if contents is not None:
|
||||||
variables = src_module.discover_playbook_variables(contents)
|
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>")
|
@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:
|
if contents is None:
|
||||||
return "Playbook not found", 404
|
return "Playbook not found", 404
|
||||||
variables = src_module.discover_playbook_variables(contents)
|
variables = src_module.discover_playbook_variables(contents)
|
||||||
|
description = src_module.discover_playbook_description(contents)
|
||||||
inventories = src_module.discover_inventories(source["path"])
|
inventories = src_module.discover_inventories(source["path"])
|
||||||
|
|
||||||
async with current_app.db_sessionmaker() as db:
|
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(
|
return await render_template(
|
||||||
"ansible/_run_form.html",
|
"ansible/_run_form.html",
|
||||||
source_name=source_name, playbook_path=playbook_path,
|
source_name=source_name, playbook_path=playbook_path,
|
||||||
variables=variables, targets=targets, groups=groups,
|
variables=variables, description=description, targets=targets, groups=groups,
|
||||||
inventories=inventories,
|
inventories=inventories,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ _SECRET_VAR_RE = re.compile(
|
|||||||
r"(password|passwd|secret|token|api[_-]?key|private[_-]?key|credential)", re.I
|
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
|
# Name of the always-present, read-only source of first-party playbooks shipped
|
||||||
# inside the app (maintenance tasks, host-agent install). Not operator-editable.
|
# inside the app (maintenance tasks, host-agent install). Not operator-editable.
|
||||||
BUILTIN_SOURCE_NAME = "steward-builtin"
|
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
|
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]:
|
def discover_playbook_variables(content: str) -> list[dict]:
|
||||||
"""Parse a playbook and list the variables an operator can set at run time.
|
"""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
|
{# Description + discovered variable fields for a selected playbook. Shared by
|
||||||
form, the host run form, and the schedule form. Expects `variables` (from
|
the browse run form, host run form, and schedule form. Expects `variables`
|
||||||
discover_playbook_variables). Rendered standalone by /ansible/playbook-vars. #}
|
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 %}
|
{% if variables %}
|
||||||
<div style="margin:0.5rem 0 0.25rem;font-size:0.82rem;color:var(--text-muted);font-weight:600;">
|
<div style="margin:0.5rem 0 0.25rem;font-size:0.82rem;color:var(--text-muted);font-weight:600;">
|
||||||
Playbook variables
|
Playbook variables
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from steward.ansible.executor import build_extra_vars_file
|
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):
|
def _names(variables):
|
||||||
@@ -108,3 +111,29 @@ def test_extra_vars_file_roundtrip_and_space_safe():
|
|||||||
def test_extra_vars_file_empty_is_noop():
|
def test_extra_vars_file_empty_is_noop():
|
||||||
assert build_extra_vars_file({}, "/td") == ([], [])
|
assert build_extra_vars_file({}, "/td") == ([], [])
|
||||||
assert build_extra_vars_file(None, "/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") == ""
|
||||||
|
|||||||
Reference in New Issue
Block a user