feat(ansible): steward:category + steward:confirm playbook metadata
Extend the playbook metadata convention with a namespaced `# steward:<key>:` 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) <noreply@anthropic.com>
This commit is contained in:
@@ -124,6 +124,8 @@ re-runs are safe.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `# description: <text>` comment | Description shown on selection (first match) |
|
| `# description: <text>` comment | Description shown on selection (first match) |
|
||||||
| first play `name:` | Description fallback |
|
| first play `name:` | Description fallback |
|
||||||
|
| `# steward:category: <text>` | 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:` scalar entries | Run-time fill-in fields (placeholder = default) |
|
||||||
| `vars_prompt:` entries | Run-time fields (required if no default) |
|
| `vars_prompt:` entries | Run-time fields (required if no default) |
|
||||||
| secret-looking var name / `private: true` | Field masked + value not persisted |
|
| 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`
|
Everything else (SSH user/key, become password, the inventory, `steward_token`
|
||||||
etc. for the agent playbooks) is injected by Steward at run time.
|
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:`.
|
Steward reads metadata from magic comments (Ansible rejects unknown play keys,
|
||||||
Everything else is *structural* (`vars`, `vars_prompt`, `hosts`, the play
|
so comments are the portable place). Two forms:
|
||||||
`name`). If we add more comment metadata later, it will use a reserved
|
|
||||||
**`# steward:<key>: <value>`** namespace (e.g. `# steward:category: maintenance`,
|
- `# description: <text>` — the description (see §1).
|
||||||
`# steward:confirm: true` for a run-confirmation gate) so it can't collide with
|
- `# steward:<key>: <value>` — the namespaced metadata block. First match per
|
||||||
ordinary comments. **These extra keys are not implemented yet** — only
|
key wins; keys are case-insensitive.
|
||||||
`# description:` is. Don't author playbooks that depend on `# steward:*` keys
|
|
||||||
until this doc says they're live.
|
**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:<key>:` 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
|
## Minimal annotated example
|
||||||
|
|
||||||
|
|||||||
@@ -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:<key>: <value>` — 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).
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
# description: Install or re-enroll the Steward host agent on an already-provisioned host.
|
# 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
|
# 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,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
# description: Provision a fresh host — create the steward account + managed key + passwordless sudo, then install the agent.
|
# 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
|
# 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,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
# description: Update the Steward host agent (refresh agent.py + restart) on hosts already running it.
|
# 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.
|
# 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,5 +1,7 @@
|
|||||||
---
|
---
|
||||||
# description: Reclaim disk on Docker / Swarm nodes by pruning unused images, containers, networks and build cache.
|
# 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.
|
# 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:
|
||||||
|
|||||||
@@ -50,7 +50,16 @@ async def browse():
|
|||||||
all_sources = _get_sources()
|
all_sources = _get_sources()
|
||||||
source_data = []
|
source_data = []
|
||||||
for source in all_sources:
|
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"])
|
inventories = src_module.discover_inventories(source["path"])
|
||||||
source_data.append({
|
source_data.append({
|
||||||
"source": source,
|
"source": source,
|
||||||
@@ -165,14 +174,15 @@ 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 = ""
|
meta: dict = {"description": "", "category": "", "confirm": False}
|
||||||
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)
|
||||||
description = src_module.discover_playbook_description(contents)
|
meta = src_module.discover_playbook_meta(contents)
|
||||||
return await render_template(
|
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/<source_name>/<path:playbook_path>")
|
@ansible_bp.get("/run-form/<source_name>/<path:playbook_path>")
|
||||||
@@ -189,7 +199,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)
|
meta = src_module.discover_playbook_meta(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:
|
||||||
@@ -201,7 +211,8 @@ 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, description=description, targets=targets, groups=groups,
|
variables=variables, description=meta["description"], category=meta["category"],
|
||||||
|
confirm=meta["confirm"], targets=targets, groups=groups,
|
||||||
inventories=inventories,
|
inventories=inventories,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+49
-19
@@ -20,6 +20,52 @@ _SECRET_VAR_RE = re.compile(
|
|||||||
# A playbook self-describes via a `# description:` magic comment (Ansible rejects
|
# A playbook self-describes via a `# description:` magic comment (Ansible rejects
|
||||||
# unknown play keys, so a comment is the portable place). First match wins.
|
# 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)
|
_DESCRIPTION_RE = re.compile(r"^\s*#\s*description:\s*(.+?)\s*$", re.I | re.M)
|
||||||
|
# Namespaced metadata: `# steward:<key>: <value>` (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: <text>`` (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
|
# 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.
|
||||||
@@ -200,25 +246,9 @@ def delete_playbook(source_path: str, relative_path: str) -> tuple[bool, str | N
|
|||||||
|
|
||||||
|
|
||||||
def discover_playbook_description(content: str) -> str:
|
def discover_playbook_description(content: str) -> str:
|
||||||
"""A human-readable description of what a playbook does.
|
"""A human-readable description of what a playbook does (see
|
||||||
|
discover_playbook_meta). Returns "" if none can be determined."""
|
||||||
Reads a ``# description: ...`` magic comment (first match), so any playbook
|
return discover_playbook_meta(content)["description"]
|
||||||
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]:
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
{# Description + discovered variable fields for a selected playbook. Shared by
|
{# Description + discovered variable fields for a selected playbook. Shared by
|
||||||
the browse run form, host run form, and schedule form. Expects `variables`
|
the browse run form, host run form, and schedule form. Expects `variables`
|
||||||
and `description`. Rendered standalone by /ansible/playbook-vars. #}
|
and `description`. Rendered standalone by /ansible/playbook-vars. #}
|
||||||
{% if description %}
|
{% if description or category %}
|
||||||
<div style="background:var(--bg);border-left:3px solid var(--accent);border-radius:3px;
|
<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);">
|
padding:0.5rem 0.7rem;margin:0.25rem 0 0.6rem;font-size:0.82rem;color:var(--text-muted);">
|
||||||
|
{% if category %}<span style="display:inline-block;font-size:0.68rem;text-transform:uppercase;letter-spacing:0.04em;
|
||||||
|
background:var(--bg-elevated);color:var(--text-dim);border-radius:3px;padding:0.05em 0.45em;margin-right:0.4rem;">{{ category }}</span>{% endif %}
|
||||||
{{ description }}
|
{{ description }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if confirm %}
|
||||||
|
<label style="display:flex;align-items:flex-start;gap:0.5rem;background:color-mix(in srgb,var(--red) 10%,var(--bg-elevated));
|
||||||
|
border:1px solid color-mix(in srgb,var(--red) 35%,var(--border));border-radius:6px;
|
||||||
|
padding:0.6rem 0.8rem;margin:0 0 0.6rem;font-size:0.82rem;font-weight:normal;cursor:pointer;">
|
||||||
|
<input type="checkbox" name="confirmed" required style="margin-top:0.15rem;">
|
||||||
|
<span><strong style="color:var(--red);">Confirm:</strong> this playbook is marked as making
|
||||||
|
significant or destructive changes. Tick to enable running it.</span>
|
||||||
|
</label>
|
||||||
|
{% 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
|
||||||
|
|||||||
@@ -70,13 +70,19 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for pb in sd.playbooks %}
|
{% for pb in sd.playbooks %}
|
||||||
<tr>
|
<tr>
|
||||||
<td style="font-family:ui-monospace,monospace;font-size:0.88rem;">{{ pb }}</td>
|
<td>
|
||||||
|
<div style="display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;">
|
||||||
|
{% if pb.category %}<span style="font-size:0.68rem;text-transform:uppercase;letter-spacing:0.04em;background:var(--bg-elevated);color:var(--text-dim);border-radius:3px;padding:0.05em 0.45em;">{{ pb.category }}</span>{% endif %}
|
||||||
|
<span style="font-family:ui-monospace,monospace;font-size:0.88rem;">{{ pb.path }}</span>
|
||||||
|
</div>
|
||||||
|
{% if pb.description %}<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.15rem;">{{ pb.description }}</div>{% endif %}
|
||||||
|
</td>
|
||||||
<td class="td-actions">
|
<td class="td-actions">
|
||||||
<a href="/ansible/browse/{{ sd.source.name }}/{{ pb }}" class="btn btn-sm btn-ghost" style="margin-right:0.5rem;">View</a>
|
<a href="/ansible/browse/{{ sd.source.name }}/{{ pb.path }}" class="btn btn-sm btn-ghost" style="margin-right:0.5rem;">View</a>
|
||||||
{% if sd.editable and session.user_role == 'admin' %}
|
{% if sd.editable and session.user_role == 'admin' %}
|
||||||
<a href="/ansible/playbooks/edit/{{ sd.source.name }}/{{ pb }}" class="btn btn-sm btn-ghost" style="margin-right:0.5rem;">Edit</a>
|
<a href="/ansible/playbooks/edit/{{ sd.source.name }}/{{ pb.path }}" class="btn btn-sm btn-ghost" style="margin-right:0.5rem;">Edit</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a hx-get="/ansible/run-form/{{ sd.source.name }}/{{ pb }}"
|
<a hx-get="/ansible/run-form/{{ sd.source.name }}/{{ pb.path }}"
|
||||||
hx-target="#run-form-{{ sd.source.name }}" hx-swap="innerHTML"
|
hx-target="#run-form-{{ sd.source.name }}" hx-swap="innerHTML"
|
||||||
style="cursor:pointer;" class="btn btn-sm btn-ghost">Run</a>
|
style="cursor:pointer;" class="btn btn-sm btn-ghost">Run</a>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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 (
|
from steward.ansible.sources import (
|
||||||
discover_playbook_description,
|
discover_playbook_description,
|
||||||
|
discover_playbook_meta,
|
||||||
discover_playbook_variables,
|
discover_playbook_variables,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -137,3 +138,31 @@ def test_description_falls_back_to_play_name():
|
|||||||
def test_description_empty_when_neither_present():
|
def test_description_empty_when_neither_present():
|
||||||
assert discover_playbook_description("- hosts: all\n tasks: []\n") == ""
|
assert discover_playbook_description("- hosts: all\n tasks: []\n") == ""
|
||||||
assert discover_playbook_description("not: : valid") == ""
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user