Runtime environments (RTE)
What an RTE is, how it runs a playbook, and what it may touch
For administrators: understand the RTE before you run one, size it, or move it to another network.
| Page | What it covers |
|---|---|
| Images | The three RTE images, and building your own with the collections your playbooks use |
| Modes | Outbound, inbound or database: who opens the connection, the spool, the job’s files |
| Run an RTE | The settings of each mode, and a docker run example of each |
| Several RTEs | Capacity, replicas behind one address, and what happens when an RTE stops or dies |
| Another datacenter | A runbook: add an RTE in a second datacenter, from a bare host to its first job |
What an RTE is
AnsibleForms 7 runs no playbook in the app container. The job of an ansible form is handed to a runner of type rte, a runtime environment: the AnsibleForms server started with AF_ROLE=rte, next to python and ansible, without a web interface. It talks to the app over one WebSocket and holds no database credentials (RTE_MODE outbound or inbound, see Modes): the app sends it the job, the RTE runs ansible-playbook, and the app stores the output and the final status it sends back, where the browser reads them.
The app resolves the job’s credentials and hands them to the RTE sealed for that RTE and that job only. The RTE never reads the credentials table nor a secret store, and a playbook does not inherit AnsibleForms’ own environment variables (ENCRYPTION_SECRET, DB_*, RTE_TOKEN and the others): pass such a value as an extravar or a credential. The RTE of 7.0 to 7.2, which read and wrote the database itself, still runs in 7.x as the deprecated database mode (see Database mode).
How the RTE runs
The RTE is a container you run yourself, next to the app or in another network: AnsibleForms never creates one. This walks through where it runs, what it shares with the app, and what happens inside it from the moment a form is launched until the playbook’s process is gone:
The diagram shows one socket per RTE whichever side opened it: the app dials an inbound RTE, an outbound RTE dials the app (see Modes). The same messages go both ways once it is open, and only the app reads and writes the database.
Who starts it. An RTE is a long-running container, started by whatever runs your other containers. It waits for jobs and keeps running between them:
| How you run AnsibleForms | Who starts the RTE |
|---|---|
docker run or podman run | You, with a command like the one in Run an RTE, on any host that reaches the app, or that the app reaches |
| Docker Compose | The rte service of docker-compose.yml, next to app and mysqldb. Its config seed declares it as the default runner rte, inbound, at http://rte:8000 |
| Helm chart | Kubernetes, as a second container named rte in the app’s pod (containers.rte). The app dials it on 127.0.0.1, inbound, and the chart’s seed declares it as the default runner |
| The installer | Docker Compose or the Helm chart as above, or a systemd service without containers; a Runner of its own, which asks to join and waits for an admin’s approval |
The container engine is yours: Docker or Podman on the host, or the container runtime of your Kubernetes cluster, installed and kept up to date by you. AnsibleForms never talks to it. Neither the app nor the RTE calls Docker or Podman, mounts their socket, or starts a container: the app only talks to the RTE over its socket.
What is inside. One container, with what it takes to run a playbook and nothing more:
- The server : the same Node.js server as the app, started with
AF_ROLE=rte(set in the image) undertini - Left out : the web interface, the login, the scheduler, the worker and every database module, which this role does not load
- What it listens on : its
PORT(8000):/liveand/readyfor probes and/metricsfor Prometheus, and inbound the socket the app dials,/rte/v1/socket. An outbound RTE takes no connection from the app - Python and ansible : brought by the image layer you pick (see The RTE images)
- The user : the server and every playbook run as
node(uid 1000), never as root
What it shares with the app. Its token, and with RTE_FILES=local the app’s files. With RTE_FILES=transfer, the default, the playbooks and inventories come with each job, and the RTE needs no shared disk:
| Shared with the app | What the RTE does with it |
|---|---|
RTE_TOKEN | Presents it when it dials the app (outbound), or checks it when the app dials it (inbound). The app holds the same value in the runner’s RTE credential, and the job’s secrets are sealed with it |
The persistent volume, /app/dist/persistent, with RTE_FILES=local only | The playbooks folder (ANSIBLE_PATH), or the playbooks repository the worker cloned under REPO_PATH, the inventories (INVENTORY_PATH and the inventory repositories) and the uploads. The RTE writes the job’s extravars files into the playbook folder, so mount it read-write |
The ssh folder, /home/node/.ssh | The private key and the known_hosts the playbook’s ssh connections use: mount the app’s, managed on its settings pages, or give the RTE its own |
The RTE clones nothing: a repository reaches it in a job’s files, or on the volume it shares with the app. Collections, roles and ansible.cfg come from the image, from the folders /etc/ansible/collections, /etc/ansible/roles and /etc/ansible/ansible.cfg when you mount them there (the Docker Compose project does), or from an ansible.cfg next to the playbooks.
The life of a job. The numbers below are the numbers in the diagram, for a playbook form launched from the browser or the API:
- Launch : the app checks the form and its values, builds the extravars and stores the job with status
running. A form with an approval stops here until an approver accepts it, and reaches no RTE before that - Choose the runner : the app takes the RTE the form names with
runner: <name>, else the default runner of type RTE, and stores its name on the job. It resolves the job’s credentials from the database or a secret store, its ansible login and its vault password, and seals them with AES-256-GCM under a key derived from that runner’s token and the job id - Hand-over : the app node that holds the RTE’s socket sends it the job: the extravars, the form’s options, the sealed secrets and the hashes of the job’s files. Any app node may launch the job; the one holding the socket sends it within two seconds. A full RTE, or one not connected, makes the job wait, up to
RTE_QUEUE_MINUTES - Claim : the RTE answers
claimed, orrefusedwhen it is full, stopping, cannot open the secrets, or the job comes with another contract than its own. WithRTE_FILES=transferit takes the playbooks and the inventories from its cache by their hash, asks the app for a hash it does not have, and unpacks them in a folder of the job’s own - Start : in the playbook folder (plus the form’s
playbookSubPath, which must exist) it writes two files:extravars_<id>.jsonwith the form’s values and the credentials, and a hidden one with the ansible login. Then it startsansible-playbookas its own child process, without a shell, in a process group of its own, with-e @those files, one-iper inventory, and the tags, limit, check, diff and verbosity of the form. The vault password goes in on stdin, never in a file or on the command line, and the process runs asnodewith a filtered environment (see below) - Run : the playbook connects to the managed hosts from the RTE container, over ssh with the key and the
known_hostsof/home/node/.ssh, withsshpassfor a password login, or over the APIs its modules call. The RTE, not the app, needs to reach them - Output and end : the RTE batches stdout and stderr every half second or 64 KB, with the credentials masked, and numbers each batch. It writes every item to the job’s spool on its disk before it sends it: the output, the events, the job log of
.joblogs/job_log_<id>.log(every 2 seconds), the start and the end with the final status (success,failedoraborted). The app stores each item once and acknowledges it, and the RTE drops the spool once the end is stored. PastPROCESS_MAX_BUFFERbytes on a stream (50 MB by default) it sends no more, and the playbook goes on. The app then sends the form’s notifications - Follow : the browser never talks to the RTE: the form page re-reads the job through the app every 2 seconds, and the Jobs page follows it live as the app stores each item
A dropped socket. The playbook goes on. The RTE redials (outbound), or an app node dials it again (inbound), and sends again what the app had not acknowledged: the app skips what it stored already, so no line is lost or stored twice. That holds while the socket is back within two minutes: the app writes the RTE’s heartbeat only while its socket is open, and once that heartbeat is two minutes old the worker ends the RTE’s running jobs as abandoned, even if their playbooks still run. Keep the spool (RTE_SPOOL_PATH) on a volume, so a restarted RTE still sends what it had kept. A playbook that was running when its RTE restarted cannot go on: the app ends its job as abandoned when the RTE comes back without it.
Abort. An abort sets the job’s abort flag, and the app node that holds the RTE’s socket sends cancel within two seconds. The RTE sends SIGTERM to the playbook’s process group, so ansible-playbook and every fork it started stop together, and the job ends aborted. A cancel that arrives before its job is kept for a minute, and the job then starts aborted.
Clean-up. As soon as the process exits, whatever the outcome, the RTE removes both extravars files (a form with keepExtravars keeps the visible one, never the hidden one), the artifacts file in /tmp, the job log file and the job’s own folder of files. The end is stored once: a second writer, such as the app failing a job the RTE already ended, changes nothing.
One process per job, not one container per job. AWX and AAP start a new pod or container from an execution environment image for every job. AnsibleForms starts nothing: the RTE container is already running, and each job is a child process inside it. A job starts without pulling an image or scheduling a pod, and AnsibleForms needs no rights on your container engine or cluster. In return, the playbooks running on one RTE share its CPU, memory, /tmp, home folder and python: give the container enough resources for RTE_MAX_JOBS playbooks with their forks. To change what a playbook can use, change the image, not the job.
The security boundary
The RTE runs code that form authors wrote, so it is built to hold as little as possible:
- Its token only : the RTE holds no database credentials and no
ENCRYPTION_SECRET. It takes no call but the app’s socket, with its token, and has no users, no login and no web pages. Over the socket it can only run the jobs the app sends it, report on them and ask for their files: it cannot create a job, read another job or hand out credentials - Sealed secrets : to run a job, the RTE never reads the credentials table nor a secret store. It gets the secrets of the jobs it runs, sealed for its token and that job id only, so a bundle meant for another RTE or another job does not open, and a changed byte is refused
- A checked connection : outbound, the RTE checks the app’s certificate. Inbound over HTTPS, the app pins the RTE’s own certificate when it joins, and dials it only when it presents that one (see Modes). An inbound RTE over plain HTTP belongs on a private network, such as the Docker Compose project’s
- No inherited environment :
ansible-playbookgets the RTE’s environment without every variable AnsibleForms reads itself (ENCRYPTION_SECRET,DB_PASSWORD,RTE_TOKEN,VAULT_TOKEN,ACCESS_TOKEN_SECRET, theMYSQL_*ones and the others), solookup('env', ...)cannot reach them. What you set for playbooks (PATH, proxies,ANSIBLE_*) passes as it is - No templates from users : a form value that holds
{{,{%or{#reaches the playbook as an unsafe string that ansible never templates, unless the form setsallowJinjaInExtravars, and no shell ever runs the command line - Not root : the RTE and its playbooks run as
node(uid 1000), so a playbook can do what that user can in the container, such as reading its files and the ssh key, and no more
Treat its container, its image and its environment with the same care as the app’s.
Database mode (deprecated)
The RTE of 7.0 to 7.2, without RTE_MODE, runs all through 7.x and is removed in 8.0.0. It differs from the steps above in four ways:
- It holds the database : its
DB_*settings, andENCRYPTION_SECRETwhen it registers itself (RTE_REGISTER) - The hand-over is an HTTP call : the app calls
POST <address>/rte/v1/jobswith the token and the sealed secrets, and the RTE claims the job by writing its name on it in the database (steps 3 and 4) - It writes the output itself : in the database, in batches, with no spool (step 7). An abort reaches it through the job’s abort flag, which it reads every 2 seconds, and a
/cancelcall - It reads the files from the app’s volume : it needs the same disk as the app, as with
RTE_FILES=local
Its runner page has none of the RTE settings: it reads its own variables. To move it to a socket mode, see Modes.