xolorg.opnsense- used to make API calls to OPNSense to register new host overrides. This is because with Kea DHCP, the automatic registration of static reservations into Unbound DNS is currently missing in OPNSense 25.7+ and so it does not work reliably as in ISC DHCP, from which OPNSense is moving away.awx.awx - in order to interact with the inventory in AWXcommunity.mysql - to set up new Galera nodes (to modify config files).community.proxmox - to spin up new LXCs.galera5.site2.mydomain.com. In my case, the sites are as follows:
uvody.bakalar.priv - an example host: galera-A1.uvody.bakalar.privtusarka.bakalar.priv - an example host: web3.tusarka.bakalar.privroot@galera--template:/opt/scripts# ufw status numbered
Status: active
To Action From
-- ------ ----
[ 1] 80/tcp DENY IN Anywhere
[ 2] 443 DENY IN Anywhere
[ 3] 21/tcp DENY IN Anywhere
[ 4] 4567/tcp ALLOW IN 10.10.10.0/24 # Cluster Replication / Multicast (heartbeat, actual writes)
[ 5] 4567/udp ALLOW IN 10.10.10.0/24 # Cluster Replication / Multicast (heartbeat, actual writes)
[ 6] 3306/tcp ALLOW IN 10.10.10.0/24 # MariaDB SQL client connection
[ 7] 3306/tcp ALLOW IN 192.168.0.0/16 # MariaDB SQL client connection
[ 8] 4567/tcp ALLOW IN 192.168.0.0/16 # Cluster Replication / Multicast (heartbeat, actual writes)
[ 9] 4567/udp ALLOW IN 192.168.0.0/16 # Cluster Replication / Multicast (heartbeat, actual writes)
[10] 2222/tcp ALLOW IN 192.168.0.0/16 # SSH in
[11] 4444/tcp ALLOW IN 192.168.0.0/16 # Galera full data copy from donor
[12] 4568/tcp ALLOW IN 192.168.0.0/16 # IST - Incremental State Transfer - last 10s
[13] 4444/tcp ALLOW IN 10.10.10.0/24 # Galera full data copy from donor
[14] 4568/tcp ALLOW IN 10.10.10.0/24 # IST - Incremental State Transfer - last 10s
[15] 80/tcp (v6) DENY IN Anywhere (v6)
[16] 443 (v6) DENY IN Anywhere (v6)
[17] 21/tcp (v6) DENY IN Anywhere (v6)
sudo ufw allow from 192.168.0.0/16 to any port 4444 proto tcp comment "Galera full data copy from donor"
sudo ufw allow from 10.10.10.0/24 to any port 4444 proto tcp comment "Galera full data copy from donor"
sudo ufw allow from 10.10.10.0/24 to any port 3306 proto tcp comment "MariaDB SQL client connection"
sudo ufw allow from 192.168.0.0/16 to any port 3306 proto tcp comment "MariaDB SQL client connection"
sudo ufw allow from 10.10.10.0/24 to any port 4568 proto tcp comment "IST - Incremental State Transfer - last 10s"
sudo ufw allow from 192.168.0.0/16 to any port 4568 proto tcp comment "IST - Incremental State Transfer - last 10s"
| Port | Protocol | Status | Scope | Comment |
|---|---|---|---|---|
| 2222 | TCP | ALLOW | LAN (192.168.0.0/16) | SSH Management |
| 3306 | TCP | ALLOW | LAN + VPN | Client Connections (Apps + Uptime Kuma) |
| 4567 | TCP/UDP | ALLOW | LAN + VPN | Replication Traffic (Heartbeats & Write Sets) |
| 4568 | TCP | ALLOW | LAN + VPN | IST (Incremental State Transfer - "The catch-up") |
| 4444 | TCP | ALLOW | LAN + VPN | SST (Snapshot Transfer - "The full backup") |
| 80/443 | TCP | DENY | Anywhere | Explicitly block Web traffic (Good practice) |
To explain in terms of bandwidth/latency terms:
mariadb-backup options in the config file later.Before converting the LXC into a template again, ensure that by default, incoming traffic is blocked and outgoing is allowed:
root@galera--template:/opt/scripts# ufw status verbose
Status: active
Logging: on (low)
Default: deny (**incoming**), allow (**outgoing**), disabled (routed)
root@galera--template:/# cat /opt/scripts/check_galera.sh
#!/bin/bash
# --- CONFIGURATION ---
# Argument 1: The Kuma Push URL (including the unique code)
PUSH_URL="$1"
# Credentials (Local check doesn't necessarily need network IP, can use localhost)
# Recommendation: Use a .my.cnf file for the user running the script to avoid passwords in plain text in this file.
# If hardcoding is necessary:
DB_USER="uptimekuma"
DB_PASS="YourStrongPasswordHere"
if [ -z "$PUSH_URL" ]; then
echo "Usage: $0 <uptime_kuma_push_full_url>"
exit 1
fi
# --- CHECK 1: Is Process Running? ---
if ! systemctl is-active --quiet mariadb; then
curl -fsS --retry 3 "${PUSH_URL}?status=down&msg=Service_Stopped&ping=" > /dev/null
exit 0
fi
# --- CHECK 2: Galera Status ---
# We check 'wsrep_local_state_comment'.
# Valid states for serving traffic: 'Synced'.
# 'Donor/Desynced' means it's up but taking a backup or catching up (technically up, but maybe not ready for writes).
STATUS=$(mysql -u"$DB_USER" -p"$DB_PASS" -N -B -e "SHOW STATUS LIKE 'wsrep_local_state_comment';" 2>/dev/null | awk '{print $2}')
if [ "$STATUS" == "Synced" ]; then
curl -fsS --retry 3 "${PUSH_URL}?status=up&msg=Synced&ping=" > /dev/null
elif [ "$STATUS" == "Donor/Desynced" ]; then
# Optional: Mark as UP but with a warning message, or DOWN depending on your preference.
curl -fsS --retry 3 "${PUSH_URL}?status=up&msg=Donor_Mode&ping=" > /dev/null
else
# Joined, Connected, etc., but not Synced usually means issues.
curl -fsS --retry 3 "${PUSH_URL}?status=down&msg=State_${STATUS}&ping=" > /dev/null
fi
Galera Credentials**# Input configuration (YAML):**
fields:
- id: wsrep_sst_password
label: Galera SST Password
type: string
secret: true
required:
- wsrep_sst_password
**# Injector configuration (YAML)**
extra_vars:
galera_sst_pass: '{{ wsrep_sst_password }}'
galera_sst_pass with the replication password.60-galera.cnf file that will be saved on the node once deployed from the template.# 60-galera.cnf.j2
[mysqld]
# MySQL-related settings
bind-address = 0.0.0.0
binlog_format = ROW
default_storage_engine = InnoDB
innodb_autoinc_lock_mode = 2
# Durability vs Performance
innodb_flush_log_at_trx_commit = 2
innodb_log_file_size = 256M
innodb_log_buffer_size = 64M
innodb_lock_wait_timeout = 60
innodb_buffer_pool_size = 800M
# Network & Safety
skip-name-resolve
max_connect_errors = 1000
log_warnings = 1
wait_timeout = 1800
interactive_timeout = 1800
max_allowed_packet = 1G
[galera]
# Galera Provider Configuration
wsrep_on=ON
wsrep_provider=/usr/lib/galera/libgalera_smm.so
# Provider Options
# gmcast.segment is injected via Ansible Inventory/Group Vars
wsrep_provider_options="gcache.size=512M;gcs.fc_limit=128;gcs.fc_factor=0.8;gmcast.segment={{ galera_segment }};pc.weight={{ galera_weight }}"
# Cluster Configuration - clusterA, clusterB, etc.
wsrep_cluster_name="{{ galera_cluster_name }}"
# This variable should be defined in your Inventory "all" group vars
wsrep_cluster_address="{{ galera_cluster_address_string }}"
# SST Configuration
wsrep_sst_method = mariabackup
# The user is hardcoded (usually safe), the password comes from AWX Secret
wsrep_sst_auth = sst_user:{{ galera_sst_pass }}
wsrep_node_address = "{{ ansible_ip | default(ansible_host) }}"
wsrep_sst_receive_address = "{{ ansible_ip | default(ansible_host) }}"
wsrep_node_name = "{{ inventory_hostname }}"
# 1-provision_one_galera_node.yml
---
- name: Provision Galera Container (Clone Method)
hosts: localhost
gather_facts: no
connection: local
vars:
template_id: 150
target_disk_storage: "local-lvm"
storage_id_site1: "pbs-rpi"
storage_id_site2: "rpi_backups"
tasks:
# ---------------------------------------------------------
# 1. DYNAMIC INVENTORY LOOKUP
# ---------------------------------------------------------
- name: Identify Site Group Name
set_fact:
target_site_group: "{{ 'uvody' if galera_segment|int == 1 else 'tusarka' }}"
- name: Find Target Proxmox Host in Inventory
set_fact:
pve_inventory_name: >-
{{
groups.get(target_site_group, [])
| intersect(groups.get('proxmox_nodes', []))
| first
| default('')
}}
- name: Find Target OPNsense Host in Inventory
set_fact:
# Use .get() to return empty list [] if group is missing, preventing crash
opnsense_inventory_name: >-
{{
groups.get(target_site_group, [])
| intersect(groups.get('opnsense_hosts', []))
| first
| default('')
}}
- name: Validate Inventory Lookups
fail:
msg: >
Could not find required infrastructure hosts in the selected inventory.
Target Site: {{ target_site_group }}
Found Hypervisor: '{{ pve_inventory_name }}' (Expected a host in 'proxmox_nodes')
Found Firewall: '{{ opnsense_inventory_name }}' (Expected a host in 'opnsense_hosts')
when: pve_inventory_name == '' or opnsense_inventory_name == ''
- name: Extract Variables
set_fact:
opnsense_ip: "{{ hostvars[opnsense_inventory_name]['ansible_ip'] | default(hostvars[opnsense_inventory_name]['ansible_host']) }}"
pbs_storage_id: "{{ storage_id_site1 if galera_segment|int == 1 else storage_id_site2 }}"
short_hostname: "{{ target_host.split('.')[0] }}"
dns_domain: "{{ target_host.split('.')[1:] | join('.') }}"
- name: DEBUG - Verify Target
debug:
msg: "Delegating to Inventory Host: {{ pve_inventory_name }}"
# ---------------------------------------------------------
# 1.5 PRE-FLIGHT CHECK (Prevent Clashes)
# ---------------------------------------------------------
- name: "Check if host '{{ short_hostname }}' already exists"
command: "/usr/bin/pvesh get /cluster/resources --type vm --output-format json"
register: cluster_resources
delegate_to: "{{ pve_inventory_name }}"
become: true
vars:
ansible_connection: ssh
- name: Parse Existing Hosts
set_fact:
existing_host_conflict: >-
{{
cluster_resources.stdout
| from_json
| selectattr('name', 'equalto', short_hostname)
| list
}}
- name: "FAIL: Host '{{ short_hostname }}' already exists!"
fail:
msg: >
A container with the name '{{ short_hostname }}' already exists!
VMID: {{ existing_host_conflict[0].vmid }}
Status: {{ existing_host_conflict[0].status }}
Node: {{ existing_host_conflict[0].node }}
Please delete it manually or choose a different hostname.
when: existing_host_conflict | length > 0
# ---------------------------------------------------------
# 2. GET TEMP VMID (For the Intermediate Restore)
# ---------------------------------------------------------
- name: Find First Free VMID (For Template Restore)
command: "/usr/bin/pvesh get /cluster/nextid"
register: temp_vmid_out
delegate_to: "{{ pve_inventory_name }}"
vars:
ansible_connection: ssh
become: true
- name: Set Temp VMID
set_fact:
temp_vmid: "{{ temp_vmid_out.stdout | trim }}"
# ---------------------------------------------------------
# 3. FIND BACKUP
# ---------------------------------------------------------
- name: "Fetch raw backup list for {{ template_id }}"
command: "/usr/sbin/pvesm list {{ pbs_storage_id }} --content backup"
register: backup_list_raw
delegate_to: "{{ pve_inventory_name }}"
vars:
ansible_connection: ssh
become: true
- name: Parse and Select Latest Backup
set_fact:
latest_backup_volid: >-
{{
backup_list_raw.stdout_lines
| select('search', 'ct/' ~ template_id)
| sort(reverse=True)
| first
| split(' ')
| first
}}
- name: Fail if backup not found
fail:
msg: "Backup for ID {{ template_id }} not found on storage '{{ pbs_storage_id }}'"
when: latest_backup_volid is undefined or latest_backup_volid == "" or latest_backup_volid == "Volid"
# ---------------------------------------------------------
# 4. RESTORE (INTERMEDIATE STEP)
# ---------------------------------------------------------
- name: "Restore Backup to Temp ID {{ temp_vmid }}"
# We restore with the final network settings here so the Clone inherits them
command: >
/usr/sbin/pct restore {{ temp_vmid }} {{ latest_backup_volid }}
--hostname "temp-restore-galera-template"
--storage {{ target_disk_storage }}
--net0 name=eth0,bridge=vmbr0,firewall=1,ip={{ target_ip }}/24,gw={{ opnsense_ip }}
--searchdomain {{ dns_domain }}
delegate_to: "{{ pve_inventory_name }}"
vars:
ansible_connection: ssh
become: true
# ---------------------------------------------------------
# 5. GET FINAL VMID (For the Production Container)
# ---------------------------------------------------------
- name: Find Second Free VMID (For Final Container)
command: "/usr/bin/pvesh get /cluster/nextid"
register: final_vmid_out
delegate_to: "{{ pve_inventory_name }}"
vars:
ansible_connection: ssh
become: true
- name: Set Final VMID
set_fact:
final_vmid: "{{ final_vmid_out.stdout | trim }}"
# ---------------------------------------------------------
# 6. CLONE & CONFIGURE
# ---------------------------------------------------------
- name: "Clone Temp ID {{ temp_vmid }} to Final ID {{ final_vmid }}"
# We use --full 1 to ensure it is a standalone container (not dependent on the temp one)
# We verify the hostname is set correctly on the final unit
command: >
/usr/sbin/pct clone {{ temp_vmid }} {{ final_vmid }}
--hostname {{ short_hostname }}
--full 1
delegate_to: "{{ pve_inventory_name }}"
vars:
ansible_connection: ssh
become: true
- name: "Set Proxmox Tags on Final ID"
command: "pct set {{ final_vmid }} --tags {{ target_ip }},2222,{{ target_site_group }},galera,database,{{ galera_cluster_name | lower }}"
delegate_to: "{{ pve_inventory_name }}"
vars:
ansible_connection: ssh
become: true
# ---------------------------------------------------------
# 7. CLEANUP & START
# ---------------------------------------------------------
- name: "Destroy Intermediate Template {{ temp_vmid }}"
command: "/usr/sbin/pct destroy {{ temp_vmid }}"
delegate_to: "{{ pve_inventory_name }}"
vars:
ansible_connection: ssh
become: true
- name: "Start Final Container {{ final_vmid }}"
command: "/usr/sbin/pct start {{ final_vmid }}"
delegate_to: "{{ pve_inventory_name }}"
vars:
ansible_connection: ssh
become: true
# ---------------------------------------------------------
# 8. REGISTER DNS
# ---------------------------------------------------------
- name: Register DNS in OPNsense
oxlorg.opnsense.unbound_host:
firewall: "{{ opnsense_ip }}"
api_key: "{{ lookup('env', 'OPNSENSE_SITE' + galera_segment|string + '_API_KEY') }}"
api_secret: "{{ lookup('env', 'OPNSENSE_SITE' + galera_segment|string + '_API_SECRET') }}"
ssl_verify: false
hostname: "{{ short_hostname }}"
domain: "{{ dns_domain }}"
value: "{{ target_ip }}"
description: "Auto-provisioned via Ansible"
record_type: A
enabled: true
state: present
match_fields: [hostname, domain]
delegate_to: localhost
Galera Cluster-A Production.groups__name__icontains=witness or groups__name__icontains=cluster_A.G1 - Deploy a Single Galera NodeCopy & configure a node from a PBS-stored templateGalera Cluster-A Production (tick prompt on launch)Ansible Playbooks - Jan's Giteabachelor-2.18.11-eedeploy-galera-node/1-deploy_single_galera_node.ymlAnsible SSH keygalera_cluster_nametarget_hosttarget_hostgalera_weighttarget_ip60-galera.cnf.# 2-configure_one_galera_node.yml
---
- name: Configure Single Galera Node
# Wildcard allows matching "galera-a5" even if inventory has "galera-a5.domain.com"
hosts: "{{ target_host.split('.')[0] }}*"
become: yes
gather_facts: no # Do not SSH in as the LXC will not be ready straight after it is switched on
vars:
# Default fallbacks
galera_segment: 2
galera_weight: 1
galera_sst_user: "sst_user"
galera_cluster_name: "{{ galera_cluster_name | default('clusterA') }}"
# ROBUST CLUSTER STRING LOGIC
# 1. We define the candidate groups: The Galera Cluster + The Witness Group.
# (We accept both 'cluster_a' and 'clustera' to handle tag variations).
# 2. We Loop through only these hosts.
# 3. We use .get() to safely retrieve IPs, defaulting to the hostname if IP is missing.
galera_cluster_address_string: >-
gcomm://{% for host in (
groups.get(galera_cluster_name | lower, [])
+ groups.get((galera_cluster_name | lower) | replace('a', '_a'), [])
+ groups.get('witness_group', [])
) | unique -%}
{{ hostvars[host].get('ansible_ip', hostvars[host].get('ansible_host', host)) }}
{%- if not loop.last -%},{%- endif -%}
{%- endfor %}
pre_tasks:
- name: Wait for SSH Connection to be available
wait_for_connection:
delay: 5
timeout: 300 # Wait up to 5 minutes
connect_timeout: 5
sleep: 5
- name: Gather Facts (Manual)
setup:
tasks:
- name: Fail if target_host is not defined
fail:
msg: "You must specify a target_host variable!"
when: target_host is not defined
- name: Stop MariaDB (to apply new config)
systemd:
name: mariadb
state: stopped
- name: "Deploy Galera Config (Segment: {{ galera_segment }}, Weight: {{ galera_weight }})"
template:
src: 60-galera.cnf.j2
dest: /etc/mysql/mariadb.conf.d/60-galera.cnf
owner: root
group: root
mode: '0644'
register: config_pushed
- name: Start MariaDB Service
systemd:
name: mariadb
state: started
enabled: yes
- name: Check Sync Status
command: mysql -e "SHOW STATUS LIKE 'wsrep_local_state_comment';"
register: node_state
changed_when: false
failed_when: "'Synced' not in node_state.stdout"
retries: 20
delay: 5
# ---------------------------------------------------------
# STATUS CHECKS
# ---------------------------------------------------------
- name: Query Cluster Size and Weight
command: mysql -e "SHOW STATUS LIKE '{{ item }}';"
loop:
- wsrep_cluster_size
- wsrep_cluster_weight
register: cluster_status
changed_when: false
- name: Display Cluster Status
debug:
msg: "{{ item.stdout_lines }}"
loop: "{{ cluster_status.results }}"
loop_control:
label: "{{ item.item }}"
# 3-update_other_nodes_config_after_change.yml
---
- name: Update Galera Cluster Configuration (Rolling)
hosts: "{{ galera_cluster_name }}"
become: yes
serial: 1
vars:
galera_cluster_address_string: >-
gcomm://{% for host in (
groups.get(galera_cluster_name | lower, [])
+ groups.get((galera_cluster_name | lower) | replace('a', '_a'), [])
+ groups.get('witness_group', [])
) | unique -%}
{{ hostvars[host].get('ansible_ip', hostvars[host].get('ansible_host')) }}
galera_segment: "{{ hostvars[inventory_hostname]['galera_segment'] | default(10) }}"
tasks:
# -----------------------------------------------------------
# BLOCK: DATA NODES ONLY (Segment != 3)
# -----------------------------------------------------------
- name: MariaDB Configuration Tasks
block:
- name: Check current Cluster Address in config
command: grep "wsrep_cluster_address" /etc/mysql/mariadb.conf.d/60-galera.cnf
register: current_config
changed_when: false
ignore_errors: yes
- name: Update Config and Restart
block:
- name: Stop MariaDB
systemd:
name: mariadb
state: stopped
- name: Deploy Updated Config
template:
src: ../templates/60-galera.cnf.j2
dest: /etc/mysql/mariadb.conf.d/60-galera.cnf
owner: root
group: root
mode: '0644'
- name: Start MariaDB
systemd:
name: mariadb
state: started
- name: Wait for Node to Sync
command: mysql -e "SHOW STATUS LIKE 'wsrep_local_state_comment';"
register: node_state
until: node_state.stdout.find("Synced") != -1
retries: 30
delay: 10
# Only run restart block if the string is missing
when: galera_cluster_address_string not in current_config.stdout
# Only run this block on Data Nodes (Not the Witness on Site 3)
when: galera_segment | int != 3
# -----------------------------------------------------------
# NEW TASKS: Final Next Step Reminder
# -----------------------------------------------------------
- name: Determine OPNsense Site Name
set_fact:
opnsense_site_name: "{{ 'Site 1 (U vody)' if galera_segment|int == 1 else 'Site 2 (Tusarka)' }}"
when: galera_segment | int != 3
- name: Next Steps Reminder
debug:
msg: "Remember to verify that you have the host {{ inventory_hostname }} added on {{ opnsense_site_name }}'s HAProxy as a Real Server and in the back-end pool to start serving traffic."
when: galera_segment | int != 3