Pre-flight Guardrails: Refusing a Bad Run

Scenario: Someone ran the production deploy with -e app_version=latest. 'latest' was an untested nightly build. Nothing in the automation objected.

New words, in plain English

Automation applies your instructions faithfully, including the wrong ones. Guardrails are where you encode the things a human reviewer would have objected to.

assert takes a list of conditions under that: and fails unless all are true. Put assertions in pre_tasks so they run before any role touches anything - a run rejected before the first change is an inconvenience; a run rejected half-way through is an incident.

What is worth asserting is domain knowledge, not syntax: that a version looks like a real version rather than a moving tag; that a deploy user is set; that target_env matches the inventory you are actually pointed at; that a required credential is present; that you are not about to run a database migration against the wrong cluster.

fail_msg deserves real care. It is read by someone under pressure who did not write the playbook. "Assertion failed" is useless; "app_version must be x.y.z, got 'latest' - use a pinned release, not a moving tag" tells them exactly what to do next.

run_once: true on an assertion avoids repeating the same failure for every host in the inventory, which keeps the output readable.

Analogy: A guardrail is the height bar at the entrance to a ride. It does not make the ride safer for people who fit; it stops the one case that would have been a disaster, before the ride starts rather than at the first corner.

A worked example

- name: Deploy the application
  hosts: production

  pre_tasks:
    - name: Refuse to deploy without valid inputs
      ansible.builtin.assert:
        that:
          - app_version is defined
          - app_version is match('^\d+\.\d+\.\d+
  

)
          - deploy_user is defined
          - deploy_user | length > 0
          - target_env == 'production'
        fail_msg: >-
          Refusing to deploy.
          app_version must be an exact x.y.z release (got '{{ app_version | default("undefined") }}'),
          deploy_user must be set (got '{{ deploy_user | default("") }}'),
          and target_env must be 'production' (got '{{ target_env | default("undefined") }}').
        success_msg: "Pre-flight checks passed for {{ app_version }}"
      run_once: true

  roles:
    - webapp

Guardrails complement patterns rather than replacing them. --limit and a careful hosts: line control which machines a run reaches; assertions control whether the run should happen at all. Both matter, and the second is the one that catches the case where the pattern was right and the inputs were wrong.

A related guardrail worth knowing is vars_prompt, which asks interactively for a value and can be marked private: true. It is unsuitable for automation - it blocks in CI - but for a genuinely manual, genuinely dangerous playbook it forces a human to type the environment name, which is a meaningful speed bump.

Tip: Write fail_msg for the person who will read it at 3 AM without context. Include what was expected, what was received, and what to do next.
Goal: Put this to work in the ansible-preflight-assertions lab. Open /labs/ansible, pick ansible-preflight-assertions, and fix the real broken project - Ansible really does SSH into four managed hosts and converge them.