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>
7.6 KiB
Authoring Steward-friendly Ansible playbooks
This is the contract between a playbook and Steward's run UI. Follow it and a playbook drops into Steward with a description, fill-in variable fields, correct targeting, and credentials supplied automatically — no per-playbook wiring.
It applies to any playbook in a configured source (bundled, the writable local source, or a git source), including third-party ones.
1. Describe what it does — # description:
Steward shows a one-line description when a playbook is selected in the run form.
---
# description: Reclaim disk on Docker/Swarm nodes by pruning unused images and build cache.
- name: Docker system prune
hosts: all
...
- Format: a comment line
# description: <text>anywhere in the file. First match wins. Case-insensitive on thedescription:key. - Why a comment and not a key: Ansible rejects unknown play keys (you can't add
description:to a play), so a comment is the portable place. It survivesansible-playbookuntouched. - Fallback: if there's no
# description:comment, Steward uses the first play'sname:. So always give your play a meaningfulname:even without the comment. - Keep it to one readable line. Longer "how to use" notes can go in additional
normal comments — Steward only reads the
description:line.
2. Declare tunables in vars: — they become fill-in fields
Every scalar entry in a play's vars: block becomes an editable field in the
run form, with the default shown as the input's placeholder.
vars:
prune_all_images: false # → checkbox-ish text field, placeholder "default: false"
keep_last_days: 7 # → field, placeholder "default: 7"
registry_url: "" # → field, placeholder "no default"
- Blank field = use the default. Steward only sends fields the operator actually fills, so an untouched field falls through to the playbook/inventory default rather than overriding it.
- Only scalars (string/int/float/bool) surface as fields. Lists/dicts are skipped — set those in the playbook or via inventory group/host vars.
- Values are delivered as extra-vars (
-e), which are the highest precedence in Ansible — they override thevars:defaults. (This is why the default can be empty and still be safely overridden at run time.)
vars_prompt: also works
Steward reads vars_prompt too. Use it when you want an explicit prompt or a
required value:
vars_prompt:
- name: release_tag
prompt: "Which release to deploy?" # shown as the field label
# no default → Steward marks the field REQUIRED
- name: admin_password
prompt: "Admin password"
private: true # → masked field, never stored
3. Secrets — name them so they're masked and not persisted
A field is treated as secret (rendered masked, and its value is never written to the DB / run history) when either:
- the variable name contains
password,passwd,secret,token,api_key/apikey,private_key, orcredential(case-insensitive), or - it's a
vars_promptentry withprivate: true(Ansible's default for vars_prompt is private).
So name sensitive variables accordingly (db_password, api_token,
vault_secret) and they're handled safely with no extra config. Non-secret
run-time vars are persisted (so scheduled runs can reuse them); secret ones are
passed to the run only.
4. Target with hosts: all
Steward builds the inventory itself from the target / group the operator picks in the run form (or the single host on a host page). Your play should:
hosts: all # run against whatever Steward scoped to
- Don't hardcode hostnames or rely on a checked-in inventory for Steward runs (Steward generates a fresh inventory per run).
- Per-host connection vars (
ansible_host, plus anything you set on the target in Ansible → Inventory) arrive as inventory host vars. - The run form's Limit / Tags map to
--limit/--tags.
5. Privileges & connection — don't put credentials in the playbook
Steward supplies SSH and become for you:
- Steady-state runs connect as the managed
stewardaccount using Steward's managed key; that account has passwordless sudo. So just usebecome: truewhere you need root. - First-contact provisioning uses a one-time bootstrap user/password the operator enters (never stored).
- Never embed SSH keys, passwords, or
ansible_user/ansible_ssh_passin the playbook. Connection identity is global (Settings → Ansible) or per-target.
6. Be idempotent
Steward re-runs playbooks (updates, schedules, retries). Use modules that
converge (state-based) rather than ad-hoc command:/shell: where possible, so
re-runs are safe.
What Steward reads from a playbook (summary)
| Source in the playbook | What Steward does with it |
|---|---|
# description: <text> comment |
Description shown on selection (first match) |
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_prompt: entries |
Run-time fields (required if no default) |
secret-looking var name / private: true |
Field masked + value not persisted |
hosts: |
Expected to be all; Steward provides the inventory |
Everything else (SSH user/key, become password, the inventory, steward_token
etc. for the agent playbooks) is injected by Steward at run time.
Metadata comments
Steward reads metadata from magic comments (Ansible rejects unknown play keys, so comments are the portable place). Two forms:
# description: <text>— the description (see §1).# steward:<key>: <value>— 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). |
---
# 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
---
# description: Restart a systemd service and confirm it came back up.
- name: Restart a service
hosts: all
become: true
gather_facts: false
vars:
service_name: "" # required-ish: operator fills it in the run form
tasks:
- name: Validate input
ansible.builtin.assert:
that: service_name | default('') | length > 0
fail_msg: "Set service_name."
- name: Restart
ansible.builtin.systemd:
name: "{{ service_name }}"
state: restarted
- name: Confirm active
ansible.builtin.command: "systemctl is-active {{ service_name }}"
changed_when: false