guest@6admin.io:~$

Ansible before Ansible: ssh, a CSV and a template engine

In 2006-2007, I administered a small fleet with scripts like everyone else, except I believed (youth!) that my contraption was an architecture. It had three components: a host list, a CSV and bash. Ansible did not exist yet (it was born in 2012), but the shape of the problem was already there and mine modeled it at 80%.

The inventory: a list + a CSV

The list of machines: a flat file, one machine per line. On top of it, a CSV of inventory variables:

host,ip,user,role,env
web01,192.168.10.11,deploy,web,prod
web02,192.168.10.12,deploy,web,prod
db01,192.168.10.21,root,db,prod

The connection was ssh, directly: no agent, no daemon, no protocol. The CSV provided the IP, the user, the role; the script filtered on it (awk -F, '$4=="web"') and the ssh loop pushed whatever was needed. I must admit it: I was proud of that contraption, proud like a rooster on his CSV. It was the poor man’s ERP and I said so with a bravado I no longer allow myself. It was slow, sequential and enough for a fleet counted in tens.

The template engine, three iterations

The CSV variables fed a template engine that rendered per-host config files (nginx per web, my.cnf per db). And there, the memory came back in three layers, because I went through all three engines, in order.

First, plain sed. Naive substitution: sed -e "s|{{host}}|$host|g" conf.tpl. It worked, until the first value containing a delimiter: a | in a value breaks the s///, an & reinserts the matched string, a backslash leaks. The underlying problem: sed substitutes strings, it has no concept of a variable and every character of the value is syntax waiting to happen. That is exactly why the CSV leaked: sed does not see data, it sees text.

Then php-cli. Between 2005 and 2010 I did systems programming and even client-server, in php-cli, so the natural engine of the era was of this style:

<?php
$host = $argv[1];
echo str_replace('{{host}}', $host, file_get_contents('conf.tpl'));

Values become data again (a PHP variable can hold anything, commas and delimiters included), loops exist, multi-file rendering is trivial. The drawback: every template becomes another PHP file to maintain and escaping the {{var}} tokens themselves is a health walk. Better, not good.

Finally, m4. The real engine, the one that held:

define(`HOST', `web01')dnl
server_name HOST.domain.tld;

Recursive expansion (macros compose), dnl to discard comments, explicit backquote quoting that protects commas (sed’s exact bug) and it is installed everywhere: one m4, zero dependencies, where php-cli demanded a runtime on the generator. Its trap: every token in the template is a macro waiting to happen and one misplaced define turns your config into confetti. The lesson the three iterations left me: each engine existed to fix one precise bug of the previous one (delimiters, data vs text, composition) and that progression naive → language → real language is exactly what Jinja2 and Go templates followed. I lived it at the scale of a single fleet.

What worked, what was missing

What worked: the inventory as data (the CSV was the source of truth, not the machines), ssh as transport (zero client-side install, the only prerequisite was a key) and templates that rendered identical configs from one host to the next.

What was missing and what Ansible brought five years later:

  • idempotence: my scripts pushed files, they converged nothing. Re-running was not side-effect-free;
  • facts: no discovery of the real state, the CSV lied as soon as a disk changed;
  • parallelism: a sequential ssh loop, one slow machine slowed the whole fleet;
  • error handling: bash’s set -e and faith.

What I kept

The lesson that survived everything: the inventory is data, the transport is ssh, the rest is replaceable plumbing. Ansible industrialized exactly those three choices (YAML/INI inventory, ssh, idempotent modules) and later I applied the same philosophy in reverse with dynamic inventories, where the inventory is read from the network or the hypervisor instead of a CSV.

[edit: 2026] The contraption kept evolving for years before I really dropped it. m4 stayed the core for a while, then gave way to envsubst and mo for rendering; the inventory CSV became a key/value .env read straight by the shell; and the homemade ssh loop melted into a well-kept ~/.ssh/config. The same triptych as in 2007, inventory as data, transport over ssh, rendering by template, but this time with bricks finally clean and auditable. I had touched Ansible as early as 2012 without doing much with it and at work it was rather Puppet and Chef; I only really switched, including for my own needs, around 2016. Ansible brought what had been missing from the start, idempotence, parallelism and error handling. That minimal toolkit still keeps its place for simple needs, where handcraft still beats the heavy tool, which I develop from the cultural angle in Unix frugality . As for the inventory read from the network rather than a file, it ended up as a DNS-driven Ansible inventory .