feat(ansible): idempotent role-based host config for both production VMs

common/firewall/docker-tls/platform/lab-host/monitoring-agent/backup roles,
orchestrated by site.yml with the ordering that matters (firewall/TLS land
before the services that depend on them). Ansible Vault for secrets, no new
tool introduced for this project's modest secret surface. Explicit
dev/staging/production environment separation, no secrets committed
(.gitignore covers vault plaintext, TLS keys, Terraform state, *.env).

Validated: ansible-playbook --syntax-check clean.
This commit is contained in:
2026-08-18 17:13:01 -05:00
parent cbf81b09e7
commit 507ac4c5d3
50 changed files with 1174 additions and 0 deletions
@@ -0,0 +1,6 @@
---
- name: restart rootnode-platform
ansible.builtin.systemd:
name: rootnode-platform
state: restarted
daemon_reload: true
@@ -0,0 +1,85 @@
---
# rn-platform-01: Docker Engine, directory structure, rendered compose file + env files,
# Postgres tuning overlay, reverse proxy config, and the systemd unit that runs it all.
# Docker TLS material and the host firewall are separate roles (docker-tls, firewall) run
# before this one in site.yml — this role assumes both are already in place.
- name: Install Docker Engine
ansible.builtin.import_tasks: "{{ role_path }}/../common/tasks/docker-engine.yml"
- name: Create data/config directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: docker
mode: "0750"
loop:
- "{{ rootnode_data_root }}/postgres"
- "{{ rootnode_data_root }}/backups"
- /opt/rootnode/platform
- /opt/rootnode/platform/postgresql.conf.d
- /etc/rootnode/provisioner-config
- name: Deploy Postgres tuning overlay
ansible.builtin.copy:
src: "{{ role_path }}/../../../docker/postgres/postgresql.conf.d/00-rootnode-baseline.conf"
dest: /opt/rootnode/platform/postgresql.conf.d/00-rootnode-baseline.conf
mode: "0644"
notify: restart rootnode-platform
- name: Deploy provisioner-config overlay ({{ env }})
ansible.builtin.copy:
src: "{{ role_path }}/../../../../environments/{{ env }}/provisioner-config/"
dest: /etc/rootnode/provisioner-config/
mode: "0644"
notify: restart rootnode-platform
- name: Deploy per-service env files (from Ansible Vault-backed variables — see group_vars)
ansible.builtin.template:
src: "{{ item }}.env.j2"
dest: "/etc/rootnode/{{ item }}.env"
owner: root
group: root
mode: "0600"
loop:
- platform
- provisioner
- gateway
- postgres
notify: restart rootnode-platform
- name: Render production docker-compose.yml
ansible.builtin.template:
src: "{{ role_path }}/../../../docker/platform/docker-compose.production.yml"
dest: /opt/rootnode/platform/docker-compose.yml
owner: root
group: root
mode: "0640"
notify: restart rootnode-platform
- name: Render Caddyfile
ansible.builtin.template:
src: "{{ role_path }}/../../../docker/platform/Caddyfile.j2"
dest: /opt/rootnode/platform/Caddyfile
owner: root
group: root
mode: "0644"
notify: restart rootnode-platform
- name: Deploy systemd unit
ansible.builtin.template:
src: rootnode-platform.service.j2
dest: /etc/systemd/system/rootnode-platform.service
mode: "0644"
notify: restart rootnode-platform
# The `backup` role (applied after this one in site.yml) deploys the backup script and cron —
# kept separate since it also has to run correctly on a host that ISN'T freshly deployed.
- name: Enable and start rootnode-platform
ansible.builtin.systemd:
name: rootnode-platform
enabled: true
state: started
daemon_reload: true
@@ -0,0 +1,5 @@
# Rendered by Ansible — see platform.env.j2 for the pattern.
PLATFORM_API_URL=http://api:4300
PROVISIONER_URL=http://provisioner:4100
PROVISIONER_SHARED_SECRET={{ vault_provisioner_shared_secret }}
GATEWAY_PORT=4200
@@ -0,0 +1,18 @@
# Rendered by Ansible from infra/environments/{{ env }}/platform.env.template's shape —
# real values come from group_vars/{{ env }}.yml + the encrypted vault file. Do not hand-edit
# on the host; re-run the platform role instead.
# service names, not localhost — production runs these on a named Docker bridge network
# (rn-platform-internal), not network_mode: host like the dev compose file does.
PLATFORM_DB_URL=postgres://rootnode_platform:{{ vault_postgres_password }}@postgres:5432/rootnode_platform
PROVISIONER_URL=http://provisioner:4100
PROVISIONER_SHARED_SECRET={{ vault_provisioner_shared_secret }}
PLATFORM_API_PORT=4300
STUDENT_SESSION_TTL_HOURS=8
STUDENT_SESSION_IDLE_HOURS=2
INSTRUCTOR_SESSION_TTL_HOURS=12
INSTRUCTOR_SESSION_IDLE_HOURS=2
COOKIE_SECURE=1
TRUST_PROXY=1
@@ -0,0 +1,4 @@
# Rendered by Ansible — see platform.env.j2 for the pattern.
POSTGRES_USER=rootnode_platform
POSTGRES_PASSWORD={{ vault_postgres_password }}
POSTGRES_DB=rootnode_platform
@@ -0,0 +1,11 @@
# Rendered by Ansible — see platform.env.j2 for the pattern.
PLATFORM_DB_URL=postgres://rootnode_platform:{{ vault_postgres_password }}@postgres:5432/rootnode_platform
PROVISIONER_SHARED_SECRET={{ vault_provisioner_shared_secret }}
PROVISIONER_PORT=4100
PROVISIONER_CONFIG_DIR=/app/config
LAB_IMAGE_REF={{ registry }}/rootnode/lab:{{ image_tag }}
EGRESS_PROXY_PORT=3128
DNS_RESOLVER=127.0.0.11
NO_PROXY_CIDRS=10.42.0.0/16,10.43.0.0/16
@@ -0,0 +1,17 @@
[Unit]
Description=RootNode Academy Platform (docker compose)
Requires=docker.service
After=docker.service network-online.target
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/rootnode/platform
ExecStart=/usr/bin/docker compose -f docker-compose.yml up -d --remove-orphans
ExecStop=/usr/bin/docker compose -f docker-compose.yml down
ExecReload=/usr/bin/docker compose -f docker-compose.yml up -d --remove-orphans
TimeoutStartSec=300
[Install]
WantedBy=multi-user.target