feat: add IaC layer with shared var/t1 and var/t3 data paths

Ansible provisions the k3d cluster and Flux controllers; Terraform
modules deploy the simulated fleet and ground warehouse. Compose and
k3d share var/t1 (live lake) and var/t3 (warehouse). The prototype
gains a live mode fed by the explorer read-only SQL API.
This commit is contained in:
2026-07-08 20:17:32 +01:00
parent d58569b25c
commit cb4664f5ee
44 changed files with 8067 additions and 35 deletions
+39
View File
@@ -0,0 +1,39 @@
# Ansible — host preparation
Ansible owns everything that happens **on a host before workloads run**.
Terraform ([`../terraform/`](../terraform/)) owns the workloads themselves.
The boundary is deliberate and documented in
[06 — Environments](../../docs/06-environments.md#iac-boundaries).
| Playbook | Target | What it does |
| --- | --- | --- |
| `drone-provision.yml` | Drone (on the bench, before a mission) | Identity keys, peer trust with forced commands, WireGuard, Compose bundle from the fleet release manifest |
| `sim-cluster.yml` | Local workstation / CI host | k3d cluster that miniatures the ground segment; shared data volume; simulator image import |
## Provision a drone
```bash
ansible-playbook -i inventory.example.yml drone-provision.yml
```
The playbook is idempotent and runs only over the bench network — drones
are never provisioned in flight (see [05 — Network & security](../../docs/05-network-security.md)):
1. **Identity**: generate the drone's ed25519 keypair if absent.
2. **Peer trust**: install every fleet member's public key into
`authorized_keys`, each pinned to the read-only SQL forced command —
the only thing a peer can execute.
3. **WireGuard**: render `wg0.conf` with the drone's address and peers.
4. **Data plane**: lay down the Compose bundle referenced by the fleet
release manifest and enable it as a systemd unit.
## Create the simulation cluster
```bash
ansible-playbook sim-cluster.yml # creates k3d cluster 'swarm-sim'
cd ../terraform/sim-env && terraform init && terraform apply
```
The cluster mounts a shared host directory as `/data` on the (single)
node, so drone pods, the exporter, and the explorer see one Parquet lake —
the same contract as the on-board NVMe layout.
+101
View File
@@ -0,0 +1,101 @@
# Provision a drone on the bench: identity, peer trust, WireGuard, data plane.
# Idempotent; never runs over the mission radio (bench network only).
---
- name: Provision drone data plane
hosts: drones
become: true
vars:
swarm_user: swarm
swarm_home: /opt/swarm
forced_command: "{{ swarm_home }}/bin/peer-query.sh"
tasks:
- name: Create swarm service user
ansible.builtin.user:
name: "{{ swarm_user }}"
home: "{{ swarm_home }}"
shell: /usr/sbin/nologin
system: true
# --- 1. Identity -----------------------------------------------------
- name: Generate drone ed25519 identity key
community.crypto.openssh_keypair:
path: "{{ swarm_home }}/.ssh/id_ed25519"
type: ed25519
comment: "{{ inventory_hostname }}"
owner: "{{ swarm_user }}"
mode: "0600"
register: identity
- name: Collect fleet public keys
ansible.builtin.slurp:
src: "{{ swarm_home }}/.ssh/id_ed25519.pub"
register: pubkey
# --- 2. Peer trust: forced command only ------------------------------
# Every peer may connect, but the only thing it can execute is the
# read-only SQL wrapper (docs/04). No shell, no forwarding, no pty.
- name: Authorize fleet peers with read-only forced command
ansible.posix.authorized_key:
user: "{{ swarm_user }}"
key: "{{ hostvars[item].pubkey.content | b64decode }}"
key_options: >-
command="{{ forced_command }}",no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-pty
loop: "{{ groups['drones'] | difference([inventory_hostname]) }}"
when: hostvars[item].pubkey is defined
- name: Install peer query wrapper
ansible.builtin.copy:
dest: "{{ forced_command }}"
mode: "0755"
content: |
#!/bin/sh
# Forced command: read-only DuckDB SQL over the sealed lake.
# SSH_ORIGINAL_COMMAND carries the statement; the gate rejects
# anything that is not a single read statement.
exec {{ swarm_home }}/bin/sql-gate --read-only --data /data "$SSH_ORIGINAL_COMMAND"
# --- 3. WireGuard swarm plane ----------------------------------------
- name: Render WireGuard configuration
ansible.builtin.template:
src: templates/wg0.conf.j2
dest: /etc/wireguard/wg0.conf
mode: "0600"
notify: Restart wireguard
- name: Enable WireGuard interface
ansible.builtin.systemd:
name: wg-quick@wg0
enabled: true
state: started
# --- 4. Data plane from the fleet release manifest --------------------
- name: Read fleet release manifest
ansible.builtin.include_vars:
file: "{{ fleet_manifest }}"
name: manifest
- name: Lay down Compose bundle for release {{ manifest.release }}
ansible.builtin.unarchive:
src: "{{ playbook_dir }}/../../.tmp/{{ manifest.artifacts[0].name }}"
dest: "{{ swarm_home }}/release"
owner: "{{ swarm_user }}"
- name: Enable data plane unit
ansible.builtin.template:
src: templates/swarm-data-plane.service.j2
dest: /etc/systemd/system/swarm-data-plane.service
mode: "0644"
notify: Restart data plane
handlers:
- name: Restart wireguard
ansible.builtin.systemd:
name: wg-quick@wg0
state: restarted
- name: Restart data plane
ansible.builtin.systemd:
name: swarm-data-plane
state: restarted
daemon_reload: true
+19
View File
@@ -0,0 +1,19 @@
# Example fleet inventory. Real inventories are generated from the fleet
# release manifest and live outside the repository.
all:
children:
drones:
hosts:
dr-01:
ansible_host: 10.44.0.11
wg_address: 10.44.0.11/24
dr-02:
ansible_host: 10.44.0.12
wg_address: 10.44.0.12/24
dr-03:
ansible_host: 10.44.0.13
wg_address: 10.44.0.13/24
vars:
ansible_user: ops
wg_port: 51820
fleet_manifest: ../../.tmp/manifest.yaml
+125
View File
@@ -0,0 +1,125 @@
# Create the local k3d simulation cluster — an executable miniature of the
# ground segment (k3s) from docs/06. Terraform then owns the workloads.
---
- name: Simulation cluster (k3d)
hosts: localhost
connection: local
gather_facts: false
vars:
cluster_name: swarm-sim
repo_root: "{{ playbook_dir }}/../.."
data_dir: "{{ lookup('env', 'SWARM_SIM_DATA') | default(repo_root + '/var/t1', true) }}"
warehouse_dir: "{{ lookup('env', 'SWARM_WAREHOUSE_DATA') | default(repo_root + '/var/t3', true) }}"
api_port: 6560
# Host ports for services exposed by the Terraform modules (NodePorts)
port_explorer: 30088
port_grafana: 30300
port_prometheus: 30990
port_warehouse_explorer: 30089
port_minio_console: 30901
simulator_image: swarm-house/simulator:dev
tasks:
- name: Check k3d is installed
ansible.builtin.command: k3d version
changed_when: false
- name: Create shared data directory (the pods' /data lake)
ansible.builtin.file:
path: "{{ data_dir }}"
state: directory
mode: "0777"
- name: Create warehouse directory (T3 host path inside the k3d node)
ansible.builtin.file:
path: "{{ warehouse_dir }}"
state: directory
mode: "0777"
- name: List existing clusters
ansible.builtin.command: k3d cluster list -o json
register: clusters
changed_when: false
- name: Create cluster {{ cluster_name }}
ansible.builtin.command: >-
k3d cluster create {{ cluster_name }}
--api-port {{ api_port }}
--servers 1 --agents 0
--volume {{ data_dir }}:/data@server:0
--volume {{ warehouse_dir }}:/warehouse@server:0
--port {{ port_explorer }}:{{ port_explorer }}@server:0
--port {{ port_grafana }}:{{ port_grafana }}@server:0
--port {{ port_prometheus }}:{{ port_prometheus }}@server:0
--port {{ port_warehouse_explorer }}:{{ port_warehouse_explorer }}@server:0
--port {{ port_minio_console }}:{{ port_minio_console }}@server:0
--k3s-arg "--disable=traefik@server:0"
--wait
when: clusters.stdout | from_json | selectattr('name', 'equalto', cluster_name) | list | length == 0
- name: Build simulator image
ansible.builtin.command:
cmd: docker build -t {{ simulator_image }} .
chdir: "{{ playbook_dir }}/../../simulator"
register: build
changed_when: "'Using cache' not in build.stderr"
- name: Import simulator image into the cluster
ansible.builtin.command: k3d image import {{ simulator_image }} -c {{ cluster_name }}
changed_when: true
- name: Merge k3d kubeconfig into the default context
ansible.builtin.command: k3d kubeconfig merge {{ cluster_name }} --kubeconfig-merge-default
changed_when: false
- name: Check whether Flux CLI is available
ansible.builtin.command: flux version --client
register: flux_cli
changed_when: false
failed_when: false
- name: Install Flux CLI (local user bin)
when: flux_cli.rc != 0
block:
- name: Download Flux release archive
ansible.builtin.get_url:
url: https://github.com/fluxcd/flux2/releases/download/v2.4.0/flux_2.4.0_linux_amd64.tar.gz
dest: /tmp/flux.tar.gz
mode: "0644"
- name: Extract flux binary
ansible.builtin.unarchive:
src: /tmp/flux.tar.gz
dest: "{{ lookup('env', 'HOME') }}/.local/bin"
remote_src: true
include:
- flux
extra_opts:
- --no-same-owner
ignore_errors: true
- name: Ensure flux is executable
ansible.builtin.file:
path: "{{ lookup('env', 'HOME') }}/.local/bin/flux"
mode: "0755"
state: file
- name: Install Flux controllers into the cluster
ansible.builtin.command: flux install --namespace=flux-system --components=source-controller,kustomize-controller
environment:
PATH: "{{ lookup('env', 'HOME') }}/.local/bin:{{ lookup('env', 'PATH') }}"
KUBECONFIG: "{{ lookup('env', 'HOME') }}/.kube/config"
register: flux_install
changed_when: "'installed' in (flux_install.stdout | default(''))"
- name: Apply Flux GitOps CRs (GitRepository + Kustomization)
ansible.builtin.command: kubectl apply -k {{ repo_root }}/infra/gitops/flux
changed_when: true
- name: Seed ground GitOps resources locally (works before first git push)
ansible.builtin.command: kubectl apply -k {{ repo_root }}/infra/gitops/ground
changed_when: true
- name: Show kubeconfig hint
ansible.builtin.debug:
msg: >-
Cluster ready. Workloads: cd ../terraform/sim-env && terraform init && terraform apply.
kubeconfig: k3d kubeconfig get {{ cluster_name }}
@@ -0,0 +1,15 @@
[Unit]
Description=Swarm data plane (Compose bundle, release {{ manifest.release }})
After=docker.service wg-quick@wg0.service
Requires=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory={{ swarm_home }}/release
ExecStart=/usr/bin/docker compose up -d
ExecStop=/usr/bin/docker compose down
TimeoutStartSec=300
[Install]
WantedBy=multi-user.target
+12
View File
@@ -0,0 +1,12 @@
# WireGuard swarm plane — rendered by drone-provision.yml
[Interface]
Address = {{ wg_address }}
ListenPort = {{ wg_port }}
PrivateKey = __REPLACED_FROM_HSM_AT_SEALING__
{% for peer in groups['drones'] | difference([inventory_hostname]) %}
[Peer]
# {{ peer }}
PublicKey = __PEER_{{ peer | upper | replace('-', '_') }}_PUBKEY__
AllowedIPs = {{ hostvars[peer].wg_address | ansible.utils.ipaddr('address') }}/32
{% endfor %}