From 42f7840c2601edfbab418187a661b495e4ac40eb Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 17 Jun 2026 11:35:35 -0400 Subject: [PATCH] feat(ansible): steward:category + steward:confirm playbook metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the playbook metadata convention with a namespaced `# steward::` comment block: - steward:category — free-text grouping label, shown as a badge in the browse list and on the run form. - steward:confirm — true/yes/1/on marks a playbook destructive; the run form then requires a confirmation tick (required checkbox in the shared vars fragment) before it can launch. sources.discover_playbook_meta() parses description + category + confirm (first match per key; `# description:` still primary, `# steward:description:` alias). discover_playbook_description() now delegates to it. The browse list reads per-playbook meta to show category badges + descriptions; the run-form and playbook-vars fragments render the badge + confirm gate. Bundled playbooks tagged: docker_prune → category maintenance + confirm true; provision/install/update → category host-agent. Docs: docs/reference/playbook-authoring.md updated (keys now implemented) and a quick reference added next to the code at steward/ansible/PLAYBOOK_CONVENTIONS.md. Tests added for category/confirm/alias parsing. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/reference/playbook-authoring.md | 40 ++++++++--- steward/ansible/PLAYBOOK_CONVENTIONS.md | 39 +++++++++++ .../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 | 2 + steward/ansible/routes.py | 23 +++++-- steward/ansible/sources.py | 68 +++++++++++++------ steward/templates/ansible/_playbook_vars.html | 13 +++- steward/templates/ansible/browse.html | 14 ++-- tests/test_playbook_variables.py | 29 ++++++++ 11 files changed, 192 insertions(+), 39 deletions(-) create mode 100644 steward/ansible/PLAYBOOK_CONVENTIONS.md diff --git a/docs/reference/playbook-authoring.md b/docs/reference/playbook-authoring.md index 02a6bf9..8b6c19e 100644 --- a/docs/reference/playbook-authoring.md +++ b/docs/reference/playbook-authoring.md @@ -124,6 +124,8 @@ re-runs are safe. |---|---| | `# description: ` comment | Description shown on selection (first match) | | first play `name:` | Description fallback | +| `# steward:category: ` | Grouping badge in the browse list + run form | +| `# steward:confirm: true` | Requires a confirmation tick before the run launches | | `vars:` scalar entries | Run-time fill-in fields (placeholder = default) | | `vars_prompt:` entries | Run-time fields (required if no default) | | secret-looking var name / `private: true` | Field masked + value not persisted | @@ -132,16 +134,36 @@ re-runs are safe. Everything else (SSH user/key, become password, the inventory, `steward_token` etc. for the agent playbooks) is injected by Steward at run time. -## Metadata convention & extensibility +## Metadata comments -Today the **only comment-based metadata** Steward parses is `# description:`. -Everything else is *structural* (`vars`, `vars_prompt`, `hosts`, the play -`name`). If we add more comment metadata later, it will use a reserved -**`# steward:: `** namespace (e.g. `# steward:category: maintenance`, -`# steward:confirm: true` for a run-confirmation gate) so it can't collide with -ordinary comments. **These extra keys are not implemented yet** — only -`# description:` is. Don't author playbooks that depend on `# steward:*` keys -until this doc says they're live. +Steward reads metadata from magic comments (Ansible rejects unknown play keys, +so comments are the portable place). Two forms: + +- `# description: ` — the description (see §1). +- `# steward:: ` — the namespaced metadata block. First match per + key wins; keys are case-insensitive. + +**Implemented `# steward:` keys:** + +| Key | Example | Effect | +|---|---|---| +| `category` | `# steward:category: maintenance` | Free-text grouping label. Shown as a badge in the browse list and on the run form. Purely organizational. | +| `confirm` | `# steward:confirm: true` | Marks the playbook as significant/destructive. The run form then requires an explicit confirmation tick before it can be launched. Use for data-loss-capable plays (prune with volumes, resets, etc.). Truthy values: `true`/`yes`/`1`/`on`. | +| `description` | `# steward:description: ...` | Alias for `# description:` (the unprefixed form takes precedence if both exist). | + +```yaml +--- +# description: Reclaim disk on Docker/Swarm nodes by pruning unused data. +# steward:category: maintenance +# steward:confirm: true +- name: Docker system prune + hosts: all + ... +``` + +Other `# steward::` keys are simply ignored today — the namespace is +reserved, so it's safe to add ones Steward doesn't yet understand without +breaking anything, but only `category`, `confirm`, and `description` do something. ## Minimal annotated example diff --git a/steward/ansible/PLAYBOOK_CONVENTIONS.md b/steward/ansible/PLAYBOOK_CONVENTIONS.md new file mode 100644 index 0000000..a44c3e3 --- /dev/null +++ b/steward/ansible/PLAYBOOK_CONVENTIONS.md @@ -0,0 +1,39 @@ +# Steward playbook conventions (quick reference) + +What Steward reads from a playbook in this subsystem. Full guide: +`docs/reference/playbook-authoring.md`. Parser: +`steward/ansible/sources.py` — `discover_playbook_meta()` and +`discover_playbook_variables()`. + +## Magic comments (metadata) + +```yaml +--- +# description: One line on what this playbook does. # shown on selection +# steward:category: maintenance # grouping badge +# steward:confirm: true # require confirm to run +- name: … # used as the description if no `# description:` comment + hosts: all # Steward generates the inventory from the chosen target/group +``` + +- `# description:` — first match wins; case-insensitive; falls back to first + play `name:`. +- `# steward:: ` — namespaced metadata. Implemented keys: + - `category` — free-text grouping label (badge in browse + run form). + - `confirm` — `true`/`yes`/`1`/`on` → run form requires a confirmation tick. + - `description` — alias for `# description:` (unprefixed wins if both present). + - Unknown `# steward:*` keys are ignored (namespace reserved for future use). + +## Structural cues (no comments needed) + +- **`vars:` scalars** → editable run-time fields (default shown as placeholder; + blank = keep default; values sent as highest-precedence extra-vars). +- **`vars_prompt:`** → run-time fields (required when no default; `private: true` + → masked). +- **Secret naming** — a var whose name contains `password`/`passwd`/`secret`/ + `token`/`api_key`/`private_key`/`credential` is masked **and not persisted**. +- **`hosts: all`** — target via the run form's scope; don't hardcode hosts. +- **Credentials** — never embed them. Steward connects as the managed `steward` + account (passwordless sudo; use `become: true`) or one-time bootstrap creds. + +Be idempotent — Steward re-runs playbooks (updates, schedules, retries). diff --git a/steward/ansible/bundled/host_agent/install.yml b/steward/ansible/bundled/host_agent/install.yml index 9257e92..a1074d2 100644 --- a/steward/ansible/bundled/host_agent/install.yml +++ b/steward/ansible/bundled/host_agent/install.yml @@ -1,5 +1,6 @@ --- # description: Install or re-enroll the Steward host agent on an already-provisioned host. +# steward:category: host-agent # 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 c09c0e3..09b50ec 100644 --- a/steward/ansible/bundled/host_agent/provision.yml +++ b/steward/ansible/bundled/host_agent/provision.yml @@ -1,5 +1,6 @@ --- # description: Provision a fresh host — create the steward account + managed key + passwordless sudo, then install the agent. +# steward:category: host-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 b5b6f8c..82f60f9 100644 --- a/steward/ansible/bundled/host_agent/update.yml +++ b/steward/ansible/bundled/host_agent/update.yml @@ -1,5 +1,6 @@ --- # description: Update the Steward host agent (refresh agent.py + restart) on hosts already running it. +# steward:category: host-agent # 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 d4e744e..51c5bcf 100644 --- a/steward/ansible/bundled/maintenance/docker_prune.yml +++ b/steward/ansible/bundled/maintenance/docker_prune.yml @@ -1,5 +1,7 @@ --- # description: Reclaim disk on Docker / Swarm nodes by pruning unused images, containers, networks and build cache. +# steward:category: maintenance +# steward:confirm: true # 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 9bcd5c3..af09e19 100644 --- a/steward/ansible/routes.py +++ b/steward/ansible/routes.py @@ -50,7 +50,16 @@ async def browse(): all_sources = _get_sources() source_data = [] for source in all_sources: - playbooks = src_module.discover_playbooks(source["path"]) + playbooks = [] + for path in src_module.discover_playbooks(source["path"]): + contents = src_module.read_playbook(source["path"], path) + meta = (src_module.discover_playbook_meta(contents) if contents is not None + else {"description": "", "category": ""}) + playbooks.append({ + "path": path, + "category": meta["category"], + "description": meta["description"], + }) inventories = src_module.discover_inventories(source["path"]) source_data.append({ "source": source, @@ -165,14 +174,15 @@ 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 = "" + meta: dict = {"description": "", "category": "", "confirm": False} 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) - description = src_module.discover_playbook_description(contents) + meta = src_module.discover_playbook_meta(contents) return await render_template( - "ansible/_playbook_vars.html", variables=variables, description=description) + "ansible/_playbook_vars.html", variables=variables, + description=meta["description"], category=meta["category"], confirm=meta["confirm"]) @ansible_bp.get("/run-form//") @@ -189,7 +199,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) + meta = src_module.discover_playbook_meta(contents) inventories = src_module.discover_inventories(source["path"]) async with current_app.db_sessionmaker() as db: @@ -201,7 +211,8 @@ 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, description=description, targets=targets, groups=groups, + variables=variables, description=meta["description"], category=meta["category"], + confirm=meta["confirm"], targets=targets, groups=groups, inventories=inventories, ) diff --git a/steward/ansible/sources.py b/steward/ansible/sources.py index c495ce2..9c0cc6e 100644 --- a/steward/ansible/sources.py +++ b/steward/ansible/sources.py @@ -20,6 +20,52 @@ _SECRET_VAR_RE = re.compile( # 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) +# Namespaced metadata: `# steward:: ` (category, confirm, …). +_STEWARD_META_RE = re.compile(r"^\s*#\s*steward:(\w+):\s*(.+?)\s*$", re.I | re.M) +_TRUTHY = {"1", "true", "yes", "on"} + + +def _first_play_name(content: str) -> str: + 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_meta(content: str) -> dict: + """Parse Steward's playbook metadata from magic comments. + + Returns {description, category, confirm}. Sources: + - description: ``# description:`` (primary) or ``# steward:description:``; + falls back to the first play's ``name:``. + - category: ``# steward:category: `` (free-text grouping label). + - confirm: ``# steward:confirm: true`` → require an explicit confirmation + in the run form before launching (for destructive playbooks). + First match wins for each key. + """ + steward: dict[str, str] = {} + for km in _STEWARD_META_RE.finditer(content): + steward.setdefault(km.group(1).lower(), km.group(2).strip()) + + m = _DESCRIPTION_RE.search(content) + if m: + description = m.group(1).strip() + elif steward.get("description"): + description = steward["description"] + else: + description = _first_play_name(content) + + return { + "description": description, + "category": steward.get("category", ""), + "confirm": str(steward.get("confirm", "")).strip().lower() in _TRUTHY, + } # Name of the always-present, read-only source of first-party playbooks shipped # inside the app (maintenance tasks, host-agent install). Not operator-editable. @@ -200,25 +246,9 @@ def delete_playbook(source_path: str, relative_path: str) -> tuple[bool, str | N 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 "" + """A human-readable description of what a playbook does (see + discover_playbook_meta). Returns "" if none can be determined.""" + return discover_playbook_meta(content)["description"] def discover_playbook_variables(content: str) -> list[dict]: diff --git a/steward/templates/ansible/_playbook_vars.html b/steward/templates/ansible/_playbook_vars.html index 31a96fd..38d2149 100644 --- a/steward/templates/ansible/_playbook_vars.html +++ b/steward/templates/ansible/_playbook_vars.html @@ -1,12 +1,23 @@ {# 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 %} +{% if description or category %}
+ {% if category %}{{ category }}{% endif %} {{ description }}
{% endif %} +{% if confirm %} + +{% endif %} {% if variables %}
Playbook variables diff --git a/steward/templates/ansible/browse.html b/steward/templates/ansible/browse.html index 4832eff..ba30058 100644 --- a/steward/templates/ansible/browse.html +++ b/steward/templates/ansible/browse.html @@ -70,13 +70,19 @@ {% for pb in sd.playbooks %} - {{ pb }} + +
+ {% if pb.category %}{{ pb.category }}{% endif %} + {{ pb.path }} +
+ {% if pb.description %}
{{ pb.description }}
{% endif %} + - View + View {% if sd.editable and session.user_role == 'admin' %} - Edit + Edit {% endif %} - Run diff --git a/tests/test_playbook_variables.py b/tests/test_playbook_variables.py index c8d9d81..a5ffa08 100644 --- a/tests/test_playbook_variables.py +++ b/tests/test_playbook_variables.py @@ -4,6 +4,7 @@ import json from steward.ansible.executor import build_extra_vars_file from steward.ansible.sources import ( discover_playbook_description, + discover_playbook_meta, discover_playbook_variables, ) @@ -137,3 +138,31 @@ def test_description_falls_back_to_play_name(): def test_description_empty_when_neither_present(): assert discover_playbook_description("- hosts: all\n tasks: []\n") == "" assert discover_playbook_description("not: : valid") == "" + + +# ── steward:category / steward:confirm metadata ─────────────────────────────── + +def test_meta_category_and_confirm(): + content = """--- +# description: Prune docker. +# steward:category: maintenance +# steward:confirm: true +- hosts: all + tasks: [] +""" + meta = discover_playbook_meta(content) + assert meta["description"] == "Prune docker." + assert meta["category"] == "maintenance" + assert meta["confirm"] is True + + +def test_meta_confirm_falsey_and_absent(): + assert discover_playbook_meta("# steward:confirm: false\n- hosts: all")["confirm"] is False + assert discover_playbook_meta("- hosts: all\n name: x")["confirm"] is False + assert discover_playbook_meta("- hosts: all\n name: x")["category"] == "" + + +def test_meta_steward_description_alias(): + # `# steward:description:` works when there's no plain `# description:`. + meta = discover_playbook_meta("# steward:description: via namespace\n- hosts: all") + assert meta["description"] == "via namespace"