#!/usr/bin/env bash # cluster-init.sh — one-time HA cluster initialisation script # # Run ONCE from ha-server-1 as root AFTER both VMs are booted and have SSH # access. It: # 1. Generates and distributes the corosync authkey # 2. Waits for corosync quorum and pacemaker # 3. Initialises DRBD metadata, promotes node1 to primary # 4. Creates XFS on /dev/drbd0 and mounts it # 5. Creates the directory tree and iSCSI LUN backing file # 6. Configures LIO iSCSI target (file-backed LUN) # 7. Configures Pacemaker resources: DRBD → XFS → iSCSI → NFS → VIP # # Prerequisites: # - Both VMs booted with the ha-server config (nixos-rebuild done) # - SSH key access from node1 to root@NODE2_IP # - VMID_NODE1 / VMID_NODE2 filled in below (needed for STONITH setup; # cluster starts without STONITH, which you enable separately via # scripts/ha/cluster-enable-stonith.sh) # - Run as root on ha-server-1 set -euo pipefail # ── Configuration ───────────────────────────────────────────────────────── # All values override-able via environment variables; defaults match variables.nix. NODE1="${NODE1:-ha-server-1}" NODE2="${NODE2:-ha-server-2}" NODE1_IP="${NODE1_IP:-192.168.2.228}" # vars.haServer1Ip NODE2_IP="${NODE2_IP:-192.168.2.227}" # vars.haServer2Ip VIP="${VIP:-192.168.20.229}" # vars.haServerVip (storage-client, vmbr2, VLAN 20) VIP_LAN="${VIP_LAN:-192.168.2.229}" # vars.haServerLanVip (LAN, vmbr0) XFS_MOUNT="${XFS_MOUNT:-/srv/ha-data}" # vars.haStorageRoot ISCSI_IQN="${ISCSI_IQN:-iqn.2026-01.home.sweet:ha-storage}" # vars.haIscsiIqn ISCSI_LUN_FILE="${XFS_MOUNT}/iscsi-lun.img" ISCSI_LUN_SIZE="10G" DRBD_DEVICE="/dev/drbd0" # DRBD backing disk — by-id path that resolves correctly on both nodes # regardless of whether the OS-level name is sda or sdb (Proxmox VM disk # ordering is not guaranteed). Matches haServerDrbdDisk in variables.nix. # Override DRBD_DISK if your hardware uses a different controller/slot path. DRBD_DISK="${DRBD_DISK:-/dev/disk/by-id/scsi-0QEMU_QEMU_HARDDISK_drive-scsi1}" VMID_NODE1="${VMID_NODE1:-}" # set by deploy.sh; needed for STONITH VMID_NODE2="${VMID_NODE2:-}" PVE_HOST="${PVE_HOST:-pve1.sweet.home}" PVE_USER="${PVE_USER:-wayne}" # Inter-node SSH: HA_USER is the user to SSH as on NODE2; HA_KEY is the private # key to use. Default is root-to-root (no key arg). deploy.sh sets HA_USER=nixos # and HA_KEY=/tmp/cluster-init-key so the script works even when root-to-root SSH # is not available. HA_USER="${HA_USER:-root}" HA_KEY="${HA_KEY:-}" # NFS dataset subdirectories to create under XFS_MOUNT. # Must mirror vars.nfsShares subpath values in variables.nix. NFS_SUBDIRS=( "docker/config" "docker/volumes" "docker/databases" "docker/nextcloud-data" "raspi/volumes" "proxmox/iso" "proxmox/lxc" "pxe-boot/images" ) # ────────────────────────────────────────────────────────────────────────── log() { echo "[cluster-init] $*"; } die() { echo "[cluster-init] ERROR: $*" >&2; exit 1; } warn() { echo "[cluster-init] WARNING: $*" >&2; } [[ $(id -u) -eq 0 ]] || die "must run as root" [[ "$(hostname)" == "$NODE1" ]] || die "must run on $NODE1" # NixOS may not include xfsprogs in root's PATH even when it's in the store. # If mkfs.xfs is missing, search the Nix store for it. if ! command -v mkfs.xfs &>/dev/null; then _xfs_bin=$(find /nix/store -maxdepth 3 -name mkfs.xfs 2>/dev/null | head -1 | xargs dirname 2>/dev/null || true) [[ -n "$_xfs_bin" ]] && export PATH="$_xfs_bin:$PATH" \ || die "mkfs.xfs not found — add xfsprogs to ha-server.nix environment.systemPackages and rebuild" fi # drbdmeta lives alongside drbdadm but may not be in PATH when run via sudo. if ! command -v drbdmeta &>/dev/null; then _drbd_bin=$(dirname "$(command -v drbdadm)" 2>/dev/null || true) [[ -n "$_drbd_bin" ]] && export PATH="$_drbd_bin:$PATH" \ || die "drbdmeta not found — is drbd-utils in ha-server environment.systemPackages?" fi # Portable 16-hex-char UUID generator (no openssl required). _rand_uuid() { cat /proc/sys/kernel/random/uuid 2>/dev/null | tr -d '-' | cut -c1-16 | tr '[:lower:]' '[:upper:]' } # Inter-node SSH/SCP helpers — abstract over root-to-root vs nixos+sudo. _SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=10" [[ -n "$HA_KEY" ]] && _SSH_OPTS="-i $HA_KEY $_SSH_OPTS" if [[ "$HA_USER" == "root" ]]; then n2_ssh() { ssh $_SSH_OPTS "root@${NODE2_IP}" "$@"; } n2_scp() { scp $_SSH_OPTS "$1" "root@${NODE2_IP}:$2"; } else # Non-root user with passwordless sudo; wrap each command with sudo. n2_ssh() { ssh $_SSH_OPTS "${HA_USER}@${NODE2_IP}" sudo "$@"; } n2_scp() { # SCP to a tmp path, then sudo-move to the real destination as the remote user. local src="$1" dst="$2" local tmp="/tmp/_cluster_init_scp_$$" scp $_SSH_OPTS "$src" "${HA_USER}@${NODE2_IP}:${tmp}" ssh $_SSH_OPTS "${HA_USER}@${NODE2_IP}" sudo mv "${tmp}" "${dst}" } fi # ── 0. Corosync authkey ─────────────────────────────────────────────────── AUTHKEY="/etc/corosync/authkey" mkdir -p /etc/corosync if [[ ! -f "$AUTHKEY" ]]; then log "Generating corosync authkey..." corosync-keygen -k "$AUTHKEY" chmod 0400 "$AUTHKEY" fi log "Distributing authkey to $NODE2..." n2_ssh "mkdir -p /etc/corosync" n2_scp "$AUTHKEY" "$AUTHKEY" n2_ssh "chmod 0400 '${AUTHKEY}'" log "Restarting corosync and pacemaker on both nodes..." systemctl restart corosync n2_ssh "systemctl restart corosync" sleep 3 log "Starting pacemaker on both nodes (may have failed at boot before authkey was placed)..." systemctl start pacemaker 2>/dev/null || systemctl restart pacemaker 2>/dev/null || true n2_ssh "systemctl start pacemaker 2>/dev/null || systemctl restart pacemaker 2>/dev/null || true" sleep 2 # ── 1. Corosync quorum ──────────────────────────────────────────────────── log "Waiting for corosync quorum..." for i in $(seq 1 30); do if corosync-quorumtool -s 2>/dev/null | grep -q 'Quorate:.*Yes'; then log "Quorum established" break fi [[ $i -eq 30 ]] && die "corosync quorum not established after 60 s" sleep 2 done log "Waiting for pacemaker..." for i in $(seq 1 30); do if crm_mon -1 &>/dev/null; then log "Pacemaker running" break fi [[ $i -eq 30 ]] && die "pacemaker not running after 60 s" sleep 2 done # ── 2. DRBD initialisation ──────────────────────────────────────────────── # Put both nodes in Pacemaker standby first so it stops managed resources # cleanly, then enable maintenance-mode so Pacemaker's monitor operations are # suspended. Without maintenance-mode, Pacemaker keeps monitoring: when it # sees DRBD Primary on a standby node (that it didn't start), it triggers a # stop action — killing the initial sync after ~10 s. Maintenance-mode # disables all start/stop/monitor actions for the duration of the sync; it is # cleared after UpToDate/UpToDate is confirmed. log "Setting both nodes to Pacemaker standby for DRBD metadata init..." crm_standby -N "$NODE1" -v on 2>/dev/null || true crm_standby -N "$NODE2" -v on 2>/dev/null || true # Wait for Pacemaker to actually stop DRBD (if it was managing it). log "Waiting for DRBD to stop under Pacemaker control..." for i in $(seq 1 30); do n1_role=$(drbdadm role ha-data 2>/dev/null || echo "Unconfigured") n2_role=$(n2_ssh "drbdadm role ha-data 2>/dev/null" 2>/dev/null || echo "Unconfigured") if [[ "$n1_role" == "Unconfigured" ]] && [[ "$n2_role" == "Unconfigured" ]]; then log "DRBD stopped on both nodes" break fi [[ $i -eq 30 ]] && warn "DRBD still active after 60s standby — forcing down anyway" sleep 2 done log "Enabling Pacemaker maintenance-mode (suspends monitor/start/stop during sync)..." crm_attribute -t crm_config -n maintenance-mode -v true 2>/dev/null || true log "Detaching DRBD on $NODE1 (belt-and-suspenders after standby)..." drbdadm down ha-data 2>/dev/null || true log "Detaching DRBD on $NODE2..." n2_ssh "drbdadm down ha-data 2>/dev/null || true" sleep 2 # Ensure /etc/drbd.conf on both nodes points to DRBD_DISK (the stable by-id # path). VMs built before this fix may have /dev/sda or /dev/sdb hardcoded. # NixOS makes /etc/drbd.conf a symlink into the read-only Nix store, so # sed -i on the symlink target would fail — we break the symlink first with # cp --remove-destination, creating a regular writable copy. # Rebuild+redeploy (--force-rebuild) to make this permanent. _PATCH_DRBD=$(mktemp) cat > "$_PATCH_DRBD" << 'PATCHEOF' #!/bin/bash WANT="$1" conf=/etc/drbd.conf if [[ -L "$conf" ]]; then cp --remove-destination "$(readlink -f "$conf")" "$conf" fi cur=$(drbdadm sh-ll-dev ha-data 2>/dev/null | head -1 || true) if [[ -n "$cur" && "$cur" != "$WANT" ]]; then echo "[cluster-init] WARNING: patching $conf: $cur → $WANT (rebuild to make permanent)" sed -i "s,${cur},${WANT},g" "$conf" fi PATCHEOF chmod +x "$_PATCH_DRBD" bash "$_PATCH_DRBD" "$DRBD_DISK" n2_scp "$_PATCH_DRBD" "/tmp/patch-drbd-disk.sh" n2_ssh "bash /tmp/patch-drbd-disk.sh ${DRBD_DISK}" n2_ssh "rm -f /tmp/patch-drbd-disk.sh" rm -f "$_PATCH_DRBD" log "Initialising DRBD metadata on $NODE1..." # Use drbdmeta --force directly for BOTH create-md and write-dev-uuid. # drbdadm create-md --force passes --force to drbdmeta create-md but NOT to # the write-dev-uuid sub-call it makes internally, so write-dev-uuid fails when # the backing disk is still busy and stdin is not a TTY: # "stdin not a TTY, not waiting for confirmation" → exit 20. # Calling drbdmeta --force directly bypasses the exclusive-open confirmation on # both steps without needing a TTY, regardless of whether the device is busy. # Skip metadata creation only if DRBD is UP and fully synced (UpToDate/UpToDate). # When the resource is down, drbdadm dstate reads metadata and returns just # "UpToDate" (no slash) — that must not be treated as "already synced". # Mismatched UUIDs from an interrupted sync cause instant WFConnection→StandAlone, # so we always recreate metadata unless the sync is genuinely complete. if [[ "$(drbdadm dstate ha-data 2>/dev/null)" != "UpToDate/UpToDate" ]]; then UUID1=$(_rand_uuid) drbdmeta --force 0 v08 "${DRBD_DISK}" internal create-md drbdmeta --force 0 v08 "${DRBD_DISK}" internal write-dev-uuid "$UUID1" fi log "Initialising DRBD metadata on $NODE2..." if [[ "$(n2_ssh "drbdadm dstate ha-data 2>/dev/null" 2>/dev/null)" != "UpToDate/UpToDate" ]]; then UUID2=$(n2_ssh "cat /proc/sys/kernel/random/uuid 2>/dev/null | tr -d '-' | cut -c1-16 | tr '[:lower:]' '[:upper:]'") n2_ssh "drbdmeta --force 0 v08 ${DRBD_DISK} internal create-md" n2_ssh "drbdmeta --force 0 v08 ${DRBD_DISK} internal write-dev-uuid ${UUID2}" fi log "Bringing up DRBD on both nodes..." drbdadm up ha-data 2>/dev/null || true n2_ssh "drbdadm up ha-data" 2>/dev/null || true log "Forcing $NODE1 to DRBD Primary for initial sync..." drbdadm primary ha-data --force # NOTE: Pacemaker standby is intentionally kept ON until after the sync # completes. Clearing it here races with the OCF DRBD agent: Pacemaker # sees DRBD in WFConnection/SyncSource and may call drbdadm-down thinking # something went wrong, killing the sync. Standby is cleared below, after # UpToDate/UpToDate is confirmed. log "Waiting for DRBD initial sync to complete (32 GB may take 10–20 min)..." log " (monitor with: watch -n3 cat /proc/drbd)" _sync_chars=('|' '/' '-' $'\\') _sync_iter=0 while true; do _dstate=$(drbdadm dstate ha-data 2>/dev/null || echo "unknown") if echo "$_dstate" | grep -q "UpToDate/UpToDate"; then printf "\r%-80s\r" "" log "DRBD initial sync complete (dstate: $_dstate)" break fi # Parse connection state from /proc/drbd (cs:SyncSource, cs:Connected, cs:StandAlone …) _cs=$(grep -oE 'cs:[A-Za-z]+' /proc/drbd 2>/dev/null | head -1 | sed 's/cs://' || echo "unknown") # /proc/drbd uses variable whitespace: "sync'ed: 5.2%" (two spaces). _pct=$(grep -oE "sync'ed:[[:space:]]+[0-9.]+" /proc/drbd 2>/dev/null | grep -oE "[0-9.]+" | head -1 || echo "") _eta=$(grep -oE "finish:[[:space:]]+[0-9:]+" /proc/drbd 2>/dev/null | grep -oE "[0-9:]+$" | head -1 || echo "") _spd=$(grep -oE "speed:[[:space:]]+[0-9,]+" /proc/drbd 2>/dev/null | grep -oE "[0-9,]+$" | head -1 || echo "") _sync_iter=$(( _sync_iter + 1 )) _sc="${_sync_chars[$_sync_iter % 4]}" if [[ "$_cs" == "StandAlone" && $_sync_iter -gt 5 ]]; then printf "\r%-80s\r" "" die "DRBD is StandAlone after 15 s — peer connection lost (dstate: $_dstate). " \ "Check corosync/network and re-run cluster-init." elif [[ -n "$_pct" ]]; then printf "\r [%s] syncing: %s%% done — ETA %s @ %s K/s " \ "$_sc" "$_pct" "${_eta:-??:??:??}" "${_spd:-?}" else printf "\r [%s] cs:%s dstate:%s — waiting for sync to start " "$_sc" "$_cs" "$_dstate" fi sleep 3 done log "Disabling Pacemaker maintenance-mode and clearing standby — handing DRBD back to Pacemaker..." crm_attribute -t crm_config -n maintenance-mode -v false 2>/dev/null || true crm_standby -N "$NODE1" -v off 2>/dev/null || true crm_standby -N "$NODE2" -v off 2>/dev/null || true # ── 3. XFS filesystem ───────────────────────────────────────────────────── log "Creating XFS on ${DRBD_DEVICE}..." if ! xfs_info "${DRBD_DEVICE}" &>/dev/null; then mkfs.xfs -f "${DRBD_DEVICE}" fi log "Mounting ${DRBD_DEVICE} at ${XFS_MOUNT}..." mkdir -p "${XFS_MOUNT}" mountpoint -q "${XFS_MOUNT}" || mount "${DRBD_DEVICE}" "${XFS_MOUNT}" # ── 4. NFS dataset directories ──────────────────────────────────────────── log "Creating NFS dataset directories..." for subdir in "${NFS_SUBDIRS[@]}"; do mkdir -p "${XFS_MOUNT}/${subdir}" done # ── 5. iSCSI LUN backing file ───────────────────────────────────────────── log "Creating iSCSI LUN backing file ${ISCSI_LUN_FILE} (${ISCSI_LUN_SIZE})..." if [[ ! -f "${ISCSI_LUN_FILE}" ]]; then fallocate -l "${ISCSI_LUN_SIZE}" "${ISCSI_LUN_FILE}" fi # ── 6. LIO iSCSI target ─────────────────────────────────────────────────── log "Configuring LIO iSCSI target via targetcli..." # Note: do NOT bind portal to ${VIP} here — the VIP isn't assigned yet (Pacemaker # creates it). The default portal (all IPs, port 3260) is correct; Pacemaker's # VIP resource will make the target reachable at the VIP address. # # Clear any existing LIO state first (idempotent: re-run after a partial failure). # Use specific delete commands — clearconfig does not reliably clear kernel state. if ls /sys/kernel/config/target/iscsi/ 2>/dev/null | grep -q "${ISCSI_IQN}"; then log "Clearing existing LIO target ${ISCSI_IQN} before reconfiguration..." targetcli "/iscsi delete ${ISCSI_IQN}" 2>/dev/null || true fi if ls /sys/kernel/config/target/core/ 2>/dev/null | grep -q "fileio"; then log "Clearing existing LIO backstore ha-lun0 before reconfiguration..." targetcli "/backstores/fileio delete ha-lun0" 2>/dev/null || true fi targetcli </dev/null || warn "LIO iscsi delete failed — umount may fail" targetcli "/backstores/fileio delete ha-lun0" 2>/dev/null || warn "LIO backstore delete failed" log "Distributing iSCSI saveconfig to $NODE2..." n2_scp /etc/target/saveconfig.json /etc/target/saveconfig.json log "Unmounting ${XFS_MOUNT} — Pacemaker manages it..." umount "${XFS_MOUNT}" || { sync; umount -l "${XFS_MOUNT}"; } log "Demoting DRBD to Secondary — Pacemaker manages primary role..." drbdadm role ha-data 2>/dev/null | grep -q "^Primary" && drbdadm secondary ha-data || true # ── 7. Pacemaker resources ──────────────────────────────────────────────── log "Configuring Pacemaker cluster properties..." crm_attribute -t crm_config -n stonith-enabled -v false crm_attribute -t crm_config -n no-quorum-policy -v ignore log "Creating Pacemaker resources via cibadmin..." # Use cibadmin --replace with pacemaker-4.0-compatible XML. # Key schema rules for pacemaker-4.0: # - globally-unique must be in , not a direct attribute # - promoted-max / promoted-node-max (not master-max / master-node-max) # - constraint with-rsc-role="Promoted" (not "Master") cibadmin --replace --scope resources --xml-text ' ' log "Adding Pacemaker ordering and colocation constraints..." cibadmin --replace --scope constraints --xml-text ' ' log "Clearing stale Pacemaker failure history..." crm_resource --cleanup 2>/dev/null || true log "Waiting for resources to start..." for i in $(seq 1 60); do if crm_resource -r vip-storage --locate 2>/dev/null | grep -q "running on"; then log "VIPs are up: $(crm_resource -r vip-storage --locate)" break fi [[ $i -eq 60 ]] && { warn "VIPs not up after 120 s — check: crm_mon -1"; break; } sleep 2 done log "" log "═══════════════════════════════════════════════════════════════" log " HA cluster initialised." log "" log " crm_mon -1 — cluster status" log " iscsiadm -m discovery -t st -p ${VIP} — verify iSCSI (storage net)" log " iscsiadm -m discovery -t st -p ${VIP_LAN} — verify iSCSI (LAN)" log " showmount -e ${VIP} — verify NFS exports (storage net)" log " showmount -e ${VIP_LAN} — verify NFS exports (LAN)" log "" log " To enable STONITH (after deploying fence SSH key):" log " 1. Fill in VMID_NODE1 / VMID_NODE2 in cluster-enable-stonith.sh" log " 2. Copy scripts/ha/fence-pve-ssh.py to /etc/pacemaker/fence_pve_ssh" log " on both nodes (chmod +x)" log " 3. Generate and distribute the fence SSH key" log " (see docs or cluster-enable-stonith.sh header)" log " 4. bash scripts/ha/cluster-enable-stonith.sh" log "═══════════════════════════════════════════════════════════════"