An RTE in another datacenter
From a bare host in a second datacenter to an RTE running its first playbook
- What you end with
- 0. Before you start
- 1. Prepare the host
- 2. Name and certificate
- 3. Network and firewall
- 4. Database access
- 5. Folders
- 6. Playbooks and ssh key
- 7. The image
- 8. Settings
- 9. Start it
- 10. Check it on the host
- 11. Check it in AnsibleForms
- 12. A first job
- 13. Send the forms to it
- When something fails
- Update it
- Roll back
- More capacity in datacenter 2
For administrators: when the managed hosts of a second datacenter should be reached from an RTE in that datacenter, not from the first one.
What you end with
The app, its worker and its database stay where they are, in datacenter 1. Datacenter 2 gets a host that runs one RTE container: it connects to the database of datacenter 1, adds itself as a runner, and runs the playbooks of the forms that name it, against the managed hosts of datacenter 2.
| Where | What runs there | Talks to |
|---|---|---|
| Datacenter 1 | The app nodes, the worker, the database (as today) | The RTE of datacenter 2, on its port 8000, to hand it jobs |
| Datacenter 2, the RTE host | Docker or Podman, and the RTE container | The database of datacenter 1 (3306), and the managed hosts of datacenter 2 |
Every command below runs in a terminal on the RTE host, as a user with sudo, unless it says otherwise. It takes about 30 minutes, most of it waiting for the image.
0. Before you start
Collect these, most of them from the .env of the app in datacenter 1:
| What | Where it comes from |
|---|---|
DB_HOST, DB_PORT | The database of datacenter 1, as datacenter 2 reaches it (an address over your link between the datacenters) |
DB_USER, DB_PASSWORD | The app’s own database user (step 4 checks that it may connect from this host) |
ENCRYPTION_SECRET | The app’s own value: the RTE stores its token encrypted when it registers itself |
| The app’s release | Status page, or the app’s image tag: the RTE runs the same one, such as 7.2.0 |
| The RTE’s name | The DNS name the app will call it on, such as rte-dc2.example.com |
| The playbooks | The git repository (or folder) the app’s playbooks come from |
| The ssh key | The private key the playbooks use to reach the managed hosts, or the right to create one |
The RTE writes the job output straight into the database, and the database connection has no TLS setting of its own: run it over your private link or VPN between the datacenters, never over the open internet. Every output line is a write, so a slow link slows the output down, not the playbook.
1. Prepare the host
The RTE is a container, so the host needs a container engine and nothing else: Node.js, python and ansible are in the image.
| Need | Why |
|---|---|
| Linux on x86_64 (amd64) | The RTE images are published for amd64 only |
| 2 CPU and 4 GB of memory, or more | One RTE runs up to RTE_MAX_JOBS playbooks at once (10), each with its forks: size it for them |
| 20 GB of free disk | The -rte-full image is about 3.3 GB, plus an image during updates, the playbooks and the jobs’ temporary files |
| Docker, or Podman with podman-compose | To pull and run the image |
| The time set by NTP | The jobs’ times and the heartbeats are compared with the other nodes’ |
Outbound HTTPS to ghcr.io | To pull the official image, unless it comes from your own registry (step 7) |
Docker, on Ubuntu or Debian (other distributions: docs.docker.com/engine/install):
sudo apt-get update && sudo apt-get install -y ca-certificates curl git chrony
curl -fsSL https://get.docker.com | sudo sh
sudo systemctl enable --now docker chrony
sudo usermod -aG docker $USER # log out and in again, so docker works without sudo
docker run --rm hello-world
docker compose version
Podman, on RHEL, Rocky or Alma Linux:
sudo dnf install -y podman podman-compose git chrony nmap-ncat
sudo systemctl enable --now chronyd
podman run --rm quay.io/podman/hello
With Podman, write podman for docker and podman-compose for docker compose in every command below.
2. Name and certificate
The app calls the RTE over HTTPS, at a name it can resolve and a certificate it trusts.
- DNS : add an
Arecord forrte-dc2.example.compointing to this host’s address, resolvable from the app nodes and the worker of datacenter 1. Check from there:getent hosts rte-dc2.example.com -
Certificate : a key and a certificate for that name. With your own CA, make the key and the request here and send the request to your CA:
sudo mkdir -p /opt/af-rte/data/certificates && cd /opt/af-rte/data/certificates sudo openssl req -new -newkey rsa:2048 -nodes -keyout key.pem -out rte-dc2.csr \ -subj "/CN=rte-dc2.example.com" -addext "subjectAltName=DNS:rte-dc2.example.com" # send rte-dc2.csr to your CA, and save what it returns here as cert.pem (with the intermediate CAs after it)For a test only, a self-signed certificate does: the runner then needs Ignore certificates, or this certificate as its CA (step 11):
sudo openssl req -x509 -newkey rsa:2048 -nodes -days 365 -keyout key.pem -out cert.pem \ -subj "/CN=rte-dc2.example.com" -addext "subjectAltName=DNS:rte-dc2.example.com"
The RTE reads key.pem and cert.pem from that folder by default (HTTPS_KEY, HTTPS_CERT).
3. Network and firewall
| From | To | Port | Why |
|---|---|---|---|
| The RTE host | The database of datacenter 1 | 3306 | The RTE claims its jobs and writes their output and status |
| The app nodes and the worker | The RTE host | 8000 | They hand jobs to the RTE and cancel them |
| The RTE host | The managed hosts of datacenter 2 | ssh (22), WinRM, their APIs | The playbooks run from here |
| The RTE host | ghcr.io | 443 | To pull the image (not with your own registry) |
Open 8000/tcp for the app nodes and the worker only: the RTE answers any caller that has its token, and no browser ever calls it.
# firewalld (RHEL family)
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="<app network>/24" port port="8000" protocol="tcp" accept'
sudo firewall-cmd --reload
# ufw (Ubuntu)
sudo ufw allow from <app network>/24 to any port 8000 proto tcp
Then check the database port from here:
nc -zv <DB_HOST> <DB_PORT>
4. Database access
A MySQL user is allowed from some addresses only. Check that the app’s user may connect from this host, with a throwaway client:
docker run --rm -it mysql:8.4 mysql -h <DB_HOST> -P <DB_PORT> -u <DB_USER> -p -e "SELECT COUNT(*) FROM AnsibleForms.jobs"
A count means it works. Access denied or Host is not allowed means the user exists for other hosts only: on the database, as an administrator, give it this host too, with the same rights on the AnsibleForms schema the app’s user has:
CREATE USER '<DB_USER>'@'<RTE host address>' IDENTIFIED BY '<DB_PASSWORD>';
GRANT ALL PRIVILEGES ON AnsibleForms.* TO '<DB_USER>'@'<RTE host address>';
5. Folders
Everything lives under /opt/af-rte. The RTE runs as the user node (uid 1000), so its data belongs to that uid:
sudo mkdir -p /opt/af-rte/data/{playbooks,ssh,certificates,ansible/roles,ansible/collections,git}
sudo touch /opt/af-rte/data/ansible/ansible.cfg /opt/af-rte/data/git/.gitconfig
| Folder | In the container | Holds |
|---|---|---|
data/ | /app/dist/persistent | The playbooks, the certificates, the python libraries a playbook installs |
data/ssh/ | /home/node/.ssh | The ssh key and known_hosts of the playbooks |
data/ansible/ | /etc/ansible/... | An ansible.cfg, roles and collections of your own, mounted instead of built in |
6. Playbooks and ssh key
The RTE runs the playbooks from its own disk and clones nothing, so they must be here, in the same layout as in datacenter 1.
Playbooks. Clone the repository the app’s playbooks come from, at the place the app expects them (ANSIBLE_PATH, /app/dist/persistent/playbooks by default):
sudo git clone https://git.example.com/automation/playbooks.git /opt/af-rte/data/playbooks
Keep it current like datacenter 1 does: a git pull from cron, or the same NFS share mounted at /opt/af-rte/data/playbooks. A form’s playbookSubPath must exist here too.
ssh key. Copy the key the playbooks use in datacenter 1, or make one for this datacenter and give its public half to the managed hosts:
sudo ssh-keygen -t ed25519 -N "" -C "ansibleforms-rte-dc2" -f /opt/af-rte/data/ssh/id_ed25519
sudo cat /opt/af-rte/data/ssh/id_ed25519.pub # add it to ~/.ssh/authorized_keys of the managed user on each host
sudo sh -c 'ssh-keyscan host1.dc2.example.com host2.dc2.example.com >> /opt/af-rte/data/ssh/known_hosts'
Then give everything to uid 1000, with the key readable by its owner only:
sudo chown -R 1000:1000 /opt/af-rte/data
sudo chmod 700 /opt/af-rte/data/ssh && sudo chmod 600 /opt/af-rte/data/ssh/id_*
7. The image
Pick one of the two.
Option A: the official image
The published images are on the GitHub Container Registry, public, no login needed (see Images for the three layers). Take the same layer as datacenter 1, and pin the app’s release so both run the same code:
docker pull ghcr.io/ansibleforms/ansibleforms-rte-full:7.2.0
:7 follows the latest 7 release instead, and :7.2 its patches; a pinned tag changes only when you change it.
A host without internet access gets the image from one that has it:
# on a host with internet
docker pull ghcr.io/ansibleforms/ansibleforms-rte-full:7.2.0
docker save ghcr.io/ansibleforms/ansibleforms-rte-full:7.2.0 | gzip > rte-full-7.2.0.tar.gz
# copy the file to the RTE host, then there
docker load < rte-full-7.2.0.tar.gz
Or push it once to your own registry (docker tag and docker push), and use that name in the next steps.
Option B: your own image
When the playbooks of datacenter 2 need collections or python libraries the official image lacks (the VMware ones for this datacenter’s vCenter, for example), build on ansibleforms-rte-core, at the app’s release:
sudo mkdir -p /opt/af-rte/build
sudo tee /opt/af-rte/build/Dockerfile >/dev/null <<'EOF'
FROM ghcr.io/ansibleforms/ansibleforms-rte-core:7.2.0
# the collections and python libraries these playbooks use : no root needed, both belong to node
RUN ansible-galaxy collection install community.vmware -p /usr/share/ansible/collections \
&& pip install --no-cache-dir pyvmomi
EOF
docker build -t af-rte-dc2:7.2.0 /opt/af-rte/build
Rebuild it on each new release of the app, with the new tag in FROM (see Build your own RTE). With several RTE hosts, build once and push to your own registry.
8. Settings
A new RTE_TOKEN, at least 16 characters:
openssl rand -base64 24
sudo tee /opt/af-rte/rte.env >/dev/null <<'EOF'
DB_HOST=<db host as datacenter 2 reaches it>
DB_PORT=3306
DB_USER=<the app's database user>
DB_PASSWORD=<its password>
ENCRYPTION_SECRET=<the app's ENCRYPTION_SECRET>
RTE_TOKEN=<the new token>
RTE_URL=https://rte-dc2.example.com:8000
PORT=8000
HTTPS=1
PYTHONPATH=/app/dist/persistent/python/lib
EOF
sudo chmod 600 /opt/af-rte/rte.env
RTE_URL is the address the app calls, and its host becomes the runner’s name, rte-dc2.example.com. The other settings an RTE reads are on Run an RTE.
9. Start it
A compose file keeps the whole definition in one place, next to the settings. Put the image of step 7 in image::
sudo tee /opt/af-rte/compose.yml >/dev/null <<'EOF'
services:
rte:
image: ghcr.io/ansibleforms/ansibleforms-rte-full:7.2.0 # or af-rte-dc2:7.2.0, the image you built
container_name: af_rte_dc2
restart: unless-stopped
# the running playbooks get RTE_DRAIN_SECONDS (25) to finish when the container stops
stop_grace_period: 40s
env_file: rte.env
ports:
- "8000:8000"
healthcheck:
test: ["CMD", "node", "./healthcheck.js"]
interval: 10s
timeout: 5s
retries: 30
start_period: 30s
volumes:
- ./data:/app/dist/persistent
- ./data/ssh:/home/node/.ssh
- ./data/git/.gitconfig:/home/node/.gitconfig
- ./data/ansible/ansible.cfg:/etc/ansible/ansible.cfg
- ./data/ansible/roles:/etc/ansible/roles
- ./data/ansible/collections:/etc/ansible/collections
EOF
cd /opt/af-rte && docker compose up -d
Docker starts it again after a reboot (unless-stopped). Podman has no daemon to do that: enable podman-restart.service (sudo systemctl enable --now podman-restart), or run the container as a systemd service with a Quadlet file. On a host with SELinux enforcing, add :Z to each volume (./data:/app/dist/persistent:Z).
Without compose, the same container in one command:
docker run -d --name af_rte_dc2 --restart unless-stopped --stop-timeout 40 \
--env-file /opt/af-rte/rte.env -p 8000:8000 \
-v /opt/af-rte/data:/app/dist/persistent \
-v /opt/af-rte/data/ssh:/home/node/.ssh \
-v /opt/af-rte/data/git/.gitconfig:/home/node/.gitconfig \
-v /opt/af-rte/data/ansible/ansible.cfg:/etc/ansible/ansible.cfg \
-v /opt/af-rte/data/ansible/roles:/etc/ansible/roles \
-v /opt/af-rte/data/ansible/collections:/etc/ansible/collections \
ghcr.io/ansibleforms/ansibleforms-rte-full:7.2.0
10. Check it on the host
The RTE connects to the database, then adds itself as a runner within 30 seconds:
docker ps --filter name=af_rte_dc2 # STATUS ends in (healthy)
docker logs -f af_rte_dc2 # Ctrl+C to stop following
curl -sk https://localhost:8000/ready
curl -sk -H "Authorization: Bearer <RTE_TOKEN>" https://localhost:8000/rte/v1/health
/ready answers 200 once the RTE takes jobs, and /rte/v1/health returns its version, its contract, its Ansible version and the jobs it runs. Then, on an app node in datacenter 1, check that the app reaches it with its certificate:
curl -s https://rte-dc2.example.com:8000/ready
11. Check it in AnsibleForms
- Connections > Runners lists a new runner of type RTE named
rte-dc2.example.com, registered automatic - Open it and click Test connection: it shows the RTE’s release and Ansible version
- Give it a description, such as Datacenter 2. With a certificate from a private CA, add that CA on the runner rather than ignoring certificates
- Leave Default on the runner of datacenter 1, unless every playbook form without a
runner:should run in datacenter 2
The Status page lists the new RTE with its version, next to the others.
12. A first job
Prove the whole path with a playbook that changes nothing. In the playbooks repository (so datacenter 1 has it too), add ping-dc2.yaml:
- name: Reach the hosts of datacenter 2
hosts: all
gather_facts: false
tasks:
- name: Ping
ansible.builtin.ping:
Pull it on the RTE host (git -C /opt/af-rte/data/playbooks pull), then add a form in the designer that names the new runner:
name: Ping datacenter 2
type: ansible
playbook: ping-dc2.yaml
runner: rte-dc2.example.com
inventory: <an inventory with the hosts of datacenter 2>
roles:
- admin
categories:
- Default
fields: []
Launch it. The output starts with Running on RTE rte-dc2.example.com, shows pong for every host, and the job ends in success.
13. Send the forms to it
A form runs on the RTE it names, so add the same line to every form for datacenter 2:
runner: rte-dc2.example.com
See Runners for the default runner and the other runner types.
When something fails
| What you see | Where | What to do |
|---|---|---|
RTE : set RTE_TOKEN (at least 16 characters) ; refusing to start | docker logs | RTE_TOKEN is missing or too short in rte.env |
You must set the database environment variables | docker logs | A DB_* value is missing in rte.env |
ECONNREFUSED or a timeout to the database | docker logs | The firewall or the address of step 3: nc -zv <DB_HOST> <DB_PORT> |
| Access denied from the database | docker logs | The user may not connect from this host: step 4 |
| No runner appears under Connections > Runners | The app | docker logs says why; RTE_REGISTER=0 turns registration off, and ENCRYPTION_SECRET must be the app’s |
| Test connection fails, or the RTE at … is unreachable on a job | The app | The app does not reach RTE_URL: the DNS name, port 8000 in the firewall, and the certificate (step 2) |
| The runner shows the RTE in red, with an update to make | Test connection, Status | The RTE speaks another contract than the app: run the app’s release (step 7) |
Permission denied on the playbooks or the ssh key | The job’s output | The files must belong to uid 1000 (step 6) |
The playbook folder or playbookSubPath is not found | The job’s output | The playbooks are not at the same place as in datacenter 1 (step 6) |
UNREACHABLE for the managed hosts | The job’s output | ssh from this host: the key, known_hosts, and the firewall towards the hosts |
Update it
When the app moves to a new release, follow it on this host too. An RTE of the same contract keeps working meanwhile, so there is no hurry within a release (see Build your own RTE). Put the new tag in compose.yml, or in the FROM of your Dockerfile, then:
cd /opt/af-rte
docker compose pull # option A; for option B: docker build -t af-rte-dc2:<new tag> build
docker compose up -d
docker image prune -f # removes the image it no longer uses
Roll back
cd /opt/af-rte && docker compose down # or: docker rm -f af_rte_dc2
The runner turns unresponsive two minutes later and the worker removes it after ten, or delete it under Connections > Runners at once. Remove the runner: line from the forms that named it, or they fail with the RTE at … is unreachable.
More capacity in datacenter 2
Repeat steps 1 to 10 on more hosts of datacenter 2, with the same RTE_URL and RTE_TOKEN, and put a load balancer on that address in front of them. They share the one runner rte-dc2.example.com, and each takes up to RTE_MAX_JOBS playbooks (see Several RTEs).