Files
bvandeusen 42f7840c26
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 2m14s
CI / publish (push) Successful in 59s
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>
2026-06-17 11:35:35 -04:00

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 the description: 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 survives ansible-playbook untouched.
  • Fallback: if there's no # description: comment, Steward uses the first play's name:. So always give your play a meaningful name: 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 the vars: 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, or credential (case-insensitive), or
  • it's a vars_prompt entry with private: 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 steward account using Steward's managed key; that account has passwordless sudo. So just use become: true where 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_pass in 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