Skip to content

Commit 1ef1b56

Browse files
committed
cluster support
1 parent 91f9809 commit 1ef1b56

53 files changed

Lines changed: 3899 additions & 134 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ Terrarium provisions the host with:
5252
## Supported Host
5353

5454
- Ubuntu Server 24.04 LTS
55-
- Single-host install only
55+
- Single-host install by default
56+
- Experimental multi-node LXD clustering through `terrariumctl cluster ...`
5657
- LXC containers only
5758

5859
## Install Modes
@@ -185,6 +186,39 @@ What gets updated on change:
185186
- Self-hosted ZITADEL is enabled, disabled, restarted, and reconciled when its rendered config changes.
186187
- Terrarium then re-runs `terrariumctl proxy sync`, and when IDP mode is `local`, also re-runs `terrariumctl idp sync`.
187188

189+
## Clustering
190+
191+
Terrarium clustering uses LXD's native cluster membership and dqlite state. The
192+
Terrarium config is stored in LXD project metadata, so joined nodes can export
193+
the same config and reconfigure locally.
194+
195+
Typical first member:
196+
197+
```bash
198+
terrariumctl cluster init \
199+
--member node1 \
200+
--address 10.0.0.11:8443 \
201+
--central-addresses 10.0.0.11,10.0.0.12,10.0.0.13 \
202+
--peer-cidr 10.0.0.0/24
203+
```
204+
205+
Typical additional member:
206+
207+
```bash
208+
terrariumctl cluster token node2
209+
terrariumctl cluster join --token '<token>' --address 10.0.0.12:8443 --peer-cidr 10.0.0.0/24 --yes
210+
```
211+
212+
Terrarium creates an OVN workload network named `terrarium-ovn` and points the
213+
default LXD profiles at it. Use at least three cluster members for a real
214+
quorum-tolerant setup; clustering does not make local ZFS storage magically
215+
shared.
216+
217+
`lxdbr0` stays as the managed parent/uplink network. Host-side Traefik reaches
218+
published OVN workloads through Terrarium-managed LXD `proxy` devices bound to
219+
loopback, so it does not depend on direct host routing to private OVN instance
220+
addresses.
221+
188222
## terrariumctl Reference
189223

190224
Top-level commands:
@@ -199,6 +233,11 @@ Top-level commands:
199233
| `terrariumctl reconfigure` | none | n/a | Re-runs the local Ansible reconciliation using the saved config. |
200234
| `terrariumctl config import` | none | n/a | Imports `/etc/terrarium/config.yaml` into the LXD dqlite-backed config store. |
201235
| `terrariumctl config export` | none | n/a | Recreates `/etc/terrarium/config.yaml` from the LXD dqlite-backed config store. |
236+
| `terrariumctl cluster status` | none | n/a | Shows LXD cluster state and the Terrarium OVN network. |
237+
| `terrariumctl cluster init` | required: `--member`, `--address`; optional: `--central-addresses`, `--peer-cidr` | network `terrarium-ovn`, parent `lxdbr0` | Enables LXD clustering on the first member and reconciles Terrarium OVN networking. |
238+
| `terrariumctl cluster token` | required: member name | n/a | Mints a single-use LXD cluster join token. |
239+
| `terrariumctl cluster join` | required: `--token`, `--address`; optional: `--peer-cidr`, `--yes` | storage pool `terrarium` | Joins the local node to an existing LXD cluster, exports shared Terrarium config, and reconfigures. |
240+
| `terrariumctl cluster ovn configure` | optional: `--central-addresses`, `--peer-cidr` | network `terrarium-ovn`, parent `lxdbr0` | Updates Terrarium OVN central member and peer firewall settings. |
202241
| `terrariumctl proxy sync` | none | n/a | Rebuilds Traefik dynamic config and Terrarium-managed UFW rules from LXC `user.proxy` labels. |
203242
| `terrariumctl mount add` | required: `protocol`, `hostPath`, `address`, `username`; optional: `-p/--password`, `--password-file`, `--seal` | password prompt, `uid=0`, `gid=0`, `file_mode=0660`, `dir_mode=0770`, `--seal=true` | Creates a managed host SMB/CIFS mount, stores credentials under `/etc/terrarium/mounts`, writes a managed `/etc/fstab` block, and mounts it immediately. |
204243
| `terrariumctl mount remove` | required: `hostPath` | n/a | Unmounts a Terrarium-managed host mount, removes its managed `/etc/fstab` block, and deletes its managed credentials file. |
@@ -410,7 +449,8 @@ Rules:
410449
- `udp://hostport:containerport` exposes a raw UDP port through Traefik.
411450
- Dynamic TCP/UDP host ports are also opened and closed in UFW automatically by the sync job.
412451
- Auth-protected published routes are backed by host-side `oauth2-proxy` instances managed automatically by `terrariumctl proxy sync`.
413-
- If the container does not have a global IPv4 address yet, the route is skipped until it does.
452+
- `terrariumctl proxy sync` also reconciles host-loopback LXD `proxy` devices for published container backends and points Traefik at those localhost targets.
453+
- If a backend proxy device cannot be reconciled, the sync fails without publishing a partially broken Traefik config.
414454

415455
## Development
416456

ansible/roles/base/tasks/main.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,22 @@
9999
- "80"
100100
- "443"
101101

102+
- name: Allow LXD cluster API from configured peer networks
103+
community.general.ufw:
104+
rule: allow
105+
from_ip: "{{ item }}"
106+
port: "8443"
107+
proto: tcp
108+
loop: "{{ terrarium_cluster_peer_cidrs | default([]) }}"
109+
110+
- name: Allow OVN cluster database and overlay traffic from configured peer networks
111+
community.general.ufw:
112+
rule: allow
113+
from_ip: "{{ item.0 }}"
114+
port: "{{ item.1.port }}"
115+
proto: "{{ item.1.proto }}"
116+
loop: "{{ (terrarium_cluster_peer_cidrs | default([])) | product([{'port': '6081', 'proto': 'udp'}, {'port': '6641', 'proto': 'tcp'}, {'port': '6642', 'proto': 'tcp'}]) | list }}"
117+
102118
- name: Enable UFW
103119
community.general.ufw:
104120
state: enabled

ansible/roles/lxd/defaults/main.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,11 @@ terrarium_lxd_snap_channel: latest/stable
22
terrarium_lxd_auth_group: terrarium-management
33
terrarium_lxd_oidc_groups_claim: groups
44
terrarium_lxd_idp_group_state_path: "{{ terrarium_state_dir }}/lxd-idp-admin-group"
5+
terrarium_cluster_enabled: false
6+
terrarium_lxd_network_parent: lxdbr0
7+
terrarium_lxd_network_name: terrarium-ovn
8+
terrarium_lxd_parent_ipv4: 10.154.0.1/24
9+
terrarium_lxd_parent_dhcp_range: 10.154.0.2-10.154.0.199
10+
terrarium_lxd_parent_ovn_range: 10.154.0.200-10.154.0.254
11+
terrarium_ovn_central_addresses: []
12+
terrarium_ovn_local_address: "{{ ansible_default_ipv4.address }}"

ansible/roles/lxd/tasks/main.yml

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@
55
changed_when: false
66

77
- name: Install LXD snap when missing
8-
ansible.builtin.command: snap install lxd --channel={{ terrarium_lxd_snap_channel }}
8+
ansible.builtin.command: /usr/bin/timeout --kill-after=15s 180s snap install lxd --channel={{ terrarium_lxd_snap_channel }}
99
register: terrarium_lxd_snap_install
1010
until:
1111
- terrarium_lxd_snap_install.rc == 0
1212
or 'already installed' in (terrarium_lxd_snap_install.stdout | lower)
1313
or 'already installed' in (terrarium_lxd_snap_install.stderr | lower)
14-
retries: 30
14+
retries: 10
1515
delay: 10
1616
changed_when:
1717
- terrarium_lxd_snap_install.rc == 0
@@ -147,15 +147,103 @@
147147
delay: 10
148148
changed_when: false
149149

150+
- name: Install OVN networking packages
151+
ansible.builtin.apt:
152+
name:
153+
- openvswitch-switch
154+
- ovn-central
155+
- ovn-host
156+
state: present
157+
158+
- name: Resolve OVN database connection strings
159+
ansible.builtin.set_fact:
160+
terrarium_ovn_northbound_connection: "{{ terrarium_ovn_central_addresses | map('regex_replace', '^(.*)$', 'tcp:\\1:6641') | join(',') }}"
161+
terrarium_ovn_southbound_connection: "{{ terrarium_ovn_central_addresses | map('regex_replace', '^(.*)$', 'tcp:\\1:6642') | join(',') }}"
162+
163+
- name: Configure clustered OVN central service
164+
ansible.builtin.template:
165+
src: ovn-central.j2
166+
dest: /etc/default/ovn-central
167+
mode: "0644"
168+
when:
169+
- terrarium_ovn_central_addresses | length > 0
170+
- terrarium_ovn_local_address in terrarium_ovn_central_addresses
171+
register: terrarium_ovn_central_config
172+
173+
- name: Enable OVN central on standalone or central members
174+
ansible.builtin.systemd:
175+
name: ovn-central
176+
state: "{{ 'restarted' if terrarium_ovn_central_config.changed | default(false) else 'started' }}"
177+
enabled: true
178+
when: terrarium_ovn_central_addresses | length == 0 or terrarium_ovn_local_address in terrarium_ovn_central_addresses
179+
180+
- name: Disable OVN central on non-central cluster members
181+
ansible.builtin.systemd:
182+
name: ovn-central
183+
state: stopped
184+
enabled: false
185+
when:
186+
- terrarium_ovn_central_addresses | length > 0
187+
- terrarium_ovn_local_address not in terrarium_ovn_central_addresses
188+
189+
- name: Enable OVN host service
190+
ansible.builtin.systemd:
191+
name: ovn-host
192+
state: started
193+
enabled: true
194+
195+
- name: Configure Open vSwitch for Terrarium OVN
196+
ansible.builtin.command:
197+
argv:
198+
- ovs-vsctl
199+
- set
200+
- open_vswitch
201+
- "."
202+
- "external_ids:ovn-remote={{ (terrarium_ovn_central_addresses | length > 0) | ternary(terrarium_ovn_southbound_connection, 'unix:/var/run/ovn/ovnsb_db.sock') }}"
203+
- "external_ids:ovn-encap-type=geneve"
204+
- "external_ids:ovn-encap-ip={{ (terrarium_ovn_central_addresses | length > 0) | ternary(terrarium_ovn_local_address, '127.0.0.1') }}"
205+
register: terrarium_ovs_configure
206+
until: terrarium_ovs_configure.rc == 0
207+
retries: 12
208+
delay: 5
209+
changed_when: false
210+
211+
- name: Configure LXD OVN northbound database connection
212+
ansible.builtin.command: "/snap/bin/lxc config set network.ovn.northbound_connection {{ terrarium_ovn_northbound_connection }}"
213+
when: terrarium_ovn_central_addresses | length > 0
214+
changed_when: false
215+
216+
- name: Configure LXD OVN parent network ranges
217+
ansible.builtin.command: >-
218+
/snap/bin/lxc network set {{ terrarium_lxd_network_parent }}
219+
ipv4.dhcp.ranges={{ terrarium_lxd_parent_dhcp_range }}
220+
ipv4.ovn.ranges={{ terrarium_lxd_parent_ovn_range }}
221+
changed_when: false
222+
223+
- name: Check whether Terrarium OVN workload network exists
224+
ansible.builtin.command: "/snap/bin/lxc network show {{ terrarium_lxd_network_name }}"
225+
register: terrarium_lxd_ovn_network
226+
failed_when: false
227+
changed_when: false
228+
229+
- name: Create Terrarium OVN workload network
230+
ansible.builtin.command: "/snap/bin/lxc network create {{ terrarium_lxd_network_name }} --type=ovn network={{ terrarium_lxd_network_parent }}"
231+
register: terrarium_lxd_ovn_network_create
232+
until: terrarium_lxd_ovn_network_create.rc == 0
233+
retries: 12
234+
delay: 5
235+
when: terrarium_lxd_ovn_network.rc != 0
236+
150237
- name: Pin LXD API to loopback
151238
ansible.builtin.command: /snap/bin/lxc config set core.https_address 127.0.0.1:8443
152239
changed_when: false
240+
when: not (terrarium_cluster_enabled | bool)
153241

154242
- name: Allow LXD bridge DHCP and DNS through UFW
155243
community.general.ufw:
156244
rule: allow
157245
direction: in
158-
interface: lxdbr0
246+
interface: "{{ terrarium_lxd_network_parent }}"
159247
proto: "{{ item.proto }}"
160248
port: "{{ item.port }}"
161249
loop:
@@ -167,7 +255,7 @@
167255
community.general.ufw:
168256
rule: allow
169257
route: true
170-
interface_in: lxdbr0
258+
interface_in: "{{ terrarium_lxd_network_parent }}"
171259
interface_out: "{{ ansible_default_ipv4.interface }}"
172260

173261
- name: Disable LXD ACME certificate management

ansible/roles/lxd/templates/default-profile.yml.j2

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ description: Terrarium default container profile
77
devices:
88
eth0:
99
name: eth0
10-
network: lxdbr0
10+
network: {{ terrarium_lxd_network_name }}
1111
type: nic
1212
root:
1313
path: /

ansible/roles/lxd/templates/lxd-preseed.yml.j2

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
config:
22
core.https_address: 127.0.0.1:8443
33
networks:
4-
- name: lxdbr0
4+
- name: {{ terrarium_lxd_network_parent }}
55
type: bridge
66
config:
7-
ipv4.address: auto
7+
ipv4.address: {{ terrarium_lxd_parent_ipv4 }}
8+
ipv4.dhcp.ranges: {{ terrarium_lxd_parent_dhcp_range }}
9+
ipv4.ovn.ranges: {{ terrarium_lxd_parent_ovn_range }}
810
ipv4.nat: "true"
911
ipv6.address: none
1012
storage_pools:
@@ -24,7 +26,7 @@ profiles:
2426
eth0:
2527
type: nic
2628
name: eth0
27-
network: lxdbr0
29+
network: {{ terrarium_lxd_network_parent }}
2830
root:
2931
type: disk
3032
path: /
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
OVN_CTL_OPTS="--db-nb-addr={{ terrarium_ovn_local_address }} --db-nb-create-insecure-remote=yes --db-sb-addr={{ terrarium_ovn_local_address }} --db-sb-create-insecure-remote=yes{% if terrarium_ovn_local_address != terrarium_ovn_central_addresses[0] %} --db-nb-cluster-remote-addr={{ terrarium_ovn_central_addresses[0] }} --db-sb-cluster-remote-addr={{ terrarium_ovn_central_addresses[0] }}{% endif %} --db-nb-cluster-local-addr={{ terrarium_ovn_local_address }} --db-sb-cluster-local-addr={{ terrarium_ovn_local_address }} --ovn-northd-nb-db={{ terrarium_ovn_northbound_connection }} --ovn-northd-sb-db={{ terrarium_ovn_southbound_connection }}"

ansible/roles/lxd/templates/strict-profile.yml.j2

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ description: Terrarium stricter container profile without Docker-friendly nestin
44
devices:
55
eth0:
66
name: eth0
7-
network: lxdbr0
7+
network: {{ terrarium_lxd_network_name }}
88
type: nic
99
root:
1010
path: /

ansible/roles/lxd/templates/terrarium-profile.yml.j2

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ description: Terrarium baseline container profile, kept as an explicit alias for
77
devices:
88
eth0:
99
name: eth0
10-
network: lxdbr0
10+
network: {{ terrarium_lxd_network_name }}
1111
type: nic
1212
root:
1313
path: /

ansible/site.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@
77
terrarium_config_path: /etc/terrarium/config.yaml
88
terrarium_state_dir: /var/lib/terrarium
99
terrarium_lxd_pool_name: terrarium
10+
terrarium_cluster_enabled: false
11+
terrarium_cluster_peer_cidrs: []
12+
terrarium_lxd_network_parent: lxdbr0
13+
terrarium_lxd_network_name: terrarium-ovn
14+
terrarium_lxd_parent_ipv4: 10.154.0.1/24
15+
terrarium_lxd_parent_dhcp_range: 10.154.0.2-10.154.0.199
16+
terrarium_lxd_parent_ovn_range: 10.154.0.200-10.154.0.254
17+
terrarium_ovn_central_addresses: []
18+
terrarium_ovn_local_address: "{{ ansible_default_ipv4.address }}"
1019
terrarium_storage_mode: file
1120
terrarium_storage_source: ""
1221
terrarium_storage_size: ""
@@ -61,6 +70,14 @@
6170
terrarium_admin_group: "{{ terrarium_admin_group }}"
6271
terrarium_zitadel_admin_email: "{{ terrarium_zitadel_admin_email }}"
6372
terrarium_lxd_pool_name: "{{ terrarium_lxd_pool_name }}"
73+
terrarium_cluster_enabled: "{{ terrarium_cluster_enabled }}"
74+
terrarium_cluster_peer_cidrs: "{{ terrarium_cluster_peer_cidrs }}"
75+
terrarium_lxd_network_parent: "{{ terrarium_lxd_network_parent }}"
76+
terrarium_lxd_network_name: "{{ terrarium_lxd_network_name }}"
77+
terrarium_lxd_parent_ipv4: "{{ terrarium_lxd_parent_ipv4 }}"
78+
terrarium_lxd_parent_dhcp_range: "{{ terrarium_lxd_parent_dhcp_range }}"
79+
terrarium_lxd_parent_ovn_range: "{{ terrarium_lxd_parent_ovn_range }}"
80+
terrarium_ovn_central_addresses: "{{ terrarium_ovn_central_addresses }}"
6481
terrarium_storage_mode: "{{ terrarium_storage_mode }}"
6582
terrarium_storage_source: "{{ terrarium_storage_source }}"
6683
terrarium_storage_size: "{{ terrarium_storage_size }}"

0 commit comments

Comments
 (0)