A Platform CLI for Ansible, Written in Nushell

NushellAnsibleDevOpsCLIAutomationInfrastructure as Code

The Command Line Nobody Remembers

A real deployment in a hardened environment is not one command. It looks more like this:

export IAC_CUSTOMER=c_00000_acme
export IAC_ENV=prod
export IAC_DEPLOYMENT_ID=$(uuidgen)
ansible-playbook -i inventory/c_00000_acme.yml playbook.yml -l 101---app-01 --diff

Plus opening sudo for the automation account before the run, verifying the host against a compliance catalogue afterwards, closing sudo again, and writing all of it to an audit log. Every step has flags, every flag has a convention, and the conventions live in a wiki page that is out of date.

A platform CLI turns that into a handful of verbs:

./platform unseal  c_00000_acme 101
./platform deploy  c_00000_acme 101
./platform verify  bsi c_00000_acme 101 --summary
./platform seal    c_00000_acme 101

This article is about what such a wrapper should do, what it should not do, and why Nushell turned out to be a very good language to write it in.

What the Wrapper Is For

The wrapper is not a second automation engine. It never touches a host configuration itself; Ansible does that. Its job is the workflow around Ansible:

  • Resolve targets. Accept an environment (prod), a full host name, a partial name (101) or an IP, and turn it into the right --limit.
  • Set scope. Export customer, environment, deployment ID and controller identity, so that variable scoping and audit logging work without anyone typing environment variables.
  • Encode the lifecycle. preflight, unseal, deploy, verify, seal as explicit commands with validated inputs (see Privilege Windows for Automation Accounts).
  • Run checks Ansible is not built for. Compliance verification against declarative catalogues, quick reachability scans, secret management with no logging.
  • Write audit events for the steps that are not playbook runs.

And one rule it must keep: it is optional. Plain ansible-playbook against the inventory resolves the same scope from inventory variables. The wrapper adds safety and convenience; it does not become the only door.

Why Not Bash, Python or Make?

All three were tried at some point.

  • Bash is everywhere, but everything is a string. Parsing ansible-inventory --list, a YAML config or ssh output means jq, yq, awk and quoting rules that bite at the worst moment.
  • Python handles data well, but a CLI with subcommands, argument parsing, subprocess handling and pretty output becomes a small project with dependencies, a virtual environment and packaging.
  • Make is a fine entry point for build tasks and a poor one for anything with arguments, branching or data.

Nushell sits in the middle: a shell, so calling external tools is the natural thing to do, but one in which everything is structured data.

Configuration Is Just Data

The CLI’s configuration is a YAML file. In Nushell, reading it is one word:

let PLATFORM_CONFIG = (open platform.yaml)

open recognizes YAML, JSON, TOML, CSV and more by file extension and returns tables and records. No parser library, no schema code:

def validate-customer [customer: string] {
  let customers = ($PLATFORM_CONFIG | get customers | columns)
  if $customer not-in $customers {
    log-err $"Unknown customer '($customer)'. Available: ($customers | str join ', ')"
    exit 1
  }
}

The same applies to the Ansible inventory. Collecting all hosts from a nested YAML inventory, in file order, is a short recursive function:

# Walk a YAML inventory group (hosts + nested children) in file order.
def collect-yaml-hosts [group: record] {
  let own = ($group | get -o hosts | default {} | transpose name vars
    | where { |h| ($h.vars | default {} | get -o ansible_host) != null }
    | each { |h| { name: $h.name, ip: ($h.vars | get ansible_host | into string) } })
  let nested = ($group | get -o children | default {} | values
    | each { |c| collect-yaml-hosts ($c | default {}) } | flatten)
  $own | append $nested | uniq-by name
}

let hosts = (collect-yaml-hosts (open inventory/c_00000_acme.yml | get all))

The result is a table with name and ip columns. Target resolution is then a filter:

let matches = ($hosts | where { |h| ($h.name | str contains $pattern) or ($h.ip == $pattern) })

Any Tool’s Output Becomes Data

This is where Nushell earns its place. External commands produce text; Nushell turns that text into structured values with a single pipeline stage.

Tools that speak JSON are parsed directly:

let machine_id = (hostnamectl --json short | from json | get machineId)

Tools that print key–value text are parsed with parse:

# Top-level variable names in a vault file
open --raw $vault_file | lines | parse --regex '^(?<name>[A-Za-z_][A-Za-z0-9_]*):' | get name

Commands whose exit code matters are wrapped with complete, which returns a record with stdout, stderr and exit_code, instead of an error that aborts the script:

let ping = (do { ^ssh ...$ssh_args -o ConnectTimeout=5 $"($user)@($h.ip)" "echo ok" } | complete)
if $ping.exit_code != 0 {
  log-err "UNREACHABLE"
}

Once output is data, the rest of the language works on it: where, group-by, sort-by, length, merge. A reachability and inventory scan across a whole environment collects one record per host and prints either a readable view or JSON, from the same data:

let lines = ($info.stdout | lines | where { |l| $l | str contains ':' })
mut record = { name: $h.name, ip: $h.ip, status: "reachable" }
for line in $lines {
  let kv = ($line | split row ':' | { key: ($in | first), value: ($in | skip 1 | str join ':' | str trim) })
  $record = ($record | merge { ($kv.key): ($kv.value) })
}

Machine-Readable Output for Free

Because results are tables and records internally, offering JSON output is one line:

if $json { print ($results | to json) }

Every inspection command (scan, verify) has a --json flag. CI jobs and other tools consume it directly, and nobody writes a parser for the CLI’s human-readable output.

Writing an audit event is equally short. A record goes in, a line of JSONL comes out:

let entry = {
  timestamp: (date now | format date "%Y-%m-%dT%H:%M:%S.%fZ")
  host: $host
  role: $role
  phase: $phase
  outcome: $outcome
  run_id: $run_id
  controller_user: ($env.USER? | default "unknown")
  git_commit_short: (try { ^git rev-parse --short HEAD | str trim } catch { "unknown" })
  extra: $extra
}
$entry | to json --raw | $"($in)\n" | save --append $"($log_dir)/($host)--($role).jsonl"

Subcommands, Typed Flags and Help Without a Framework

A Nushell script becomes a multi-command CLI by naming functions main <subcommand>. Parameters are typed, flags are declared in the signature, and the comment next to each parameter becomes its help text:

#!/usr/bin/env nu

# Run Ansible provisioning for a customer target
def "main deploy" [
  customer: string              # Customer ID (e.g. c_00000_acme)
  target: string                # Environment, node name, or partial match
  --check                       # Dry-run mode (ansible --check)
  --diff                        # Show file diffs (ansible --diff)
  --forks: int                  # Number of parallel forks
  --verbosity(-v): int = 0      # Ansible verbosity level (0-4)
  --optx-ansible: string        # Pass extra flags verbatim to ansible-playbook
] {
  ...
}

def "main verify" [
  catalog: string               # Catalogue name or comma-separated chain
  customer: string
  target: string
  --json                        # Output results as JSON
  --summary                     # Show summary counts only
] {
  ...
}

./platform deploy --help prints a generated usage page with every parameter, type and description. Wrong types and unknown flags are rejected before any code runs. Typing --forks ten fails with a clear message instead of reaching ansible-playbook.

There is no argument parser to write, no help text to keep in sync, and no framework to install.

Wrapping Commands Without Quoting Pain

The core of the deploy command builds an argument list and calls Ansible with it:

let deployment_id = (random uuid | str upcase)

mut args = ["-i", $inv_path, $playbook, "-l", $deployment.host]
if $check { $args = ($args | append "--check") }
if $diff  { $args = ($args | append "--diff") }
if ($forks != null) { $args = ($args | append ["-f", ($forks | into string)]) }
if $verbosity > 0 {
  $args = ($args | append ("-" + (1..$verbosity | each { "v" } | str join "")))
}

let final_args = $args
with-env {
  IAC_CUSTOMER: $customer
  IAC_ENV: $deployment.environment
  IAC_DEPLOYMENT_ID: $deployment_id
  ANSIBLE_FORCE_COLOR: "true"
} {
  ^ansible-playbook ...$final_args
}

Three features make this robust:

  • Arguments are a list, not a string. ...$final_args spreads the list into separate arguments. A host pattern with spaces or a value with a quote cannot break the command line, because nothing is ever re-parsed by a shell.
  • ^ marks the external command. It is always clear whether a name refers to a Nushell command or a binary on PATH.
  • with-env scopes environment variables to the block. Nothing leaks into the calling shell, and the next command starts clean.

An escape hatch (--optx-ansible "--start-at-task 'Deploy audit rules'") passes anything else through verbatim, so the wrapper never blocks an Ansible feature it does not know about.

Generating Scripts for Remote Execution

Some commands need to run many checks on a host. The compliance verifier builds one bash script from a YAML catalogue and runs it over a single SSH connection:

def build-verify-probes [catalog: record] {
  mut script_lines = [($catalog | get catalog.helpers)]
  mut controls = []
  mut idx = 0
  for wp in ($catalog | get work_packages) {
    for ctrl in ($wp | get controls) {
      $script_lines = ($script_lines | append $"_r=$\(($ctrl.probe) 2>/dev/null\); echo \"V|($idx)|${_r:-__ERR__}\"")
      $controls = ($controls | append { idx: $idx, wp: $wp.id, name: $ctrl.name, expect: $ctrl.expect })
      $idx = $idx + 1
    }
  }
  { script: ($script_lines | str join "\n"), controls: $controls }
}

The output comes back as text, gets filtered to the marker lines, and is joined with the expectations as a table:

let measured = ($result.stdout | lines | where { |l| $l | str starts-with "V|" }
  | parse "V|{idx}|{value}" | update idx { into int })

let report = ($probes.controls | join $measured idx
  | insert status { |r| if $r.value == $r.expect { "PASS" } else { "FAIL" } })

$report | group-by status | transpose status rows | update rows { length }

From there, the readable table, the per-work-package summary and the JSON output are three different views of the same data (see Verifying Hardening Against Several Catalogues at Once).

Handling Secrets Carefully

A wrapper is also a good place to make the safe path the easy one. The vault subcommand manages per-variable secrets:

  • Values are read from a hidden prompt (entered twice) or from stdin, never from arguments, so they do not end up in shell history or process listings.
  • Decryption calls ansible-vault directly with logging disabled. No playbook runs, so no callback plugin can see a decrypted value.
  • Rekeying re-encrypts everything with a new key and verifies by decrypting with the new key before the old key is moved into a timestamped backup.

Nushell’s structured handling helps here too: a vault file is parsed into a table of entries (name, encrypted, data), and listing, filtering and re-assembling the file are table operations instead of fragile line editing.

Trade-offs

Nushell is not a free choice:

  • The language still moves. Releases occasionally rename commands or change flags. Pin the version in the development environment (a devbox or Nix shell) and upgrade deliberately, with the CLI’s commands as the test.
  • It is another runtime on the controller. It is a single static binary, which helps, but it has to be installed. Targets do not need it.
  • Team familiarity. Most engineers can read Nushell immediately; writing idiomatic pipelines takes a little practice. The payoff is that the result is readable by people who did not write it.
  • The wrapper grows. A CLI that covers preflight, privilege windows, deploy, verify, scan, copy and vault reaches a couple of thousand lines. Keep helpers small, keep each subcommand self-contained, and resist putting configuration logic in it that belongs in Ansible.

Conclusion

A platform CLI in front of Ansible is worth having as soon as a deployment is more than one command: it encodes the workflow, sets scope and audit context automatically, and adds checks that Ansible is not built for, while remaining optional.

Nushell makes that wrapper unusually small and robust. Configuration and inventories are opened as data, the output of any command-line tool becomes a table with one pipeline stage, typed subcommands come with generated help, and external commands are called with argument lists instead of strings. What would be a fragile Bash script or a small Python project becomes a readable, single-file tool.

A Platform CLI for Ansible, Written in Nushell - Patrick Paechnatz