Archived
Compare commits
26
Commits
No files matched your search
@@ -23,3 +23,5 @@ host-keys/
|
||||
# Temporary Milestone 1 audit checklist (remove-sensetive-info-refactor.md)
|
||||
# - working notes only, never committed, deleted once every row is rotated.
|
||||
secrets-inventory.md
|
||||
.claude/worktrees/
|
||||
.claude/settings.local.json
|
||||
@@ -21,9 +21,7 @@ machines when deployed.
|
||||
`modules/installer/common.nix` (the auto-installer's own root/nixos login —
|
||||
a deliberate, documented choice, see `docs/auto-installer.md`, not
|
||||
accidental tech debt) and **SSH public keys** in `variables.nix`
|
||||
(`vars.adminSshKey`, `vars.remoteBuilderAuthorizedKeys`) plus a couple of
|
||||
per-host `KEY` values for beszel-agent auth (`hosts/server/host.nix`,
|
||||
`hosts/nix-cache/host.nix`). Don't use the installer's hardcoded hash as a
|
||||
(`vars.adminSshKey`, `vars.remoteBuilderAuthorizedKeys`, `vars.beszelHubKey`). Don't use the installer's hardcoded hash as a
|
||||
template for a *real* host — every other host uses sops-nix
|
||||
(`hashedPasswordFile`, see "Security Notes" in `README.md`). Flag any *new*
|
||||
secret-like string you encounter instead of committing it.
|
||||
@@ -375,9 +373,8 @@ removing a host.
|
||||
|
||||
- `hosts/<name>/host.nix` — per-machine identity **only**: hostname, hostId,
|
||||
per-machine secrets, `system.stateVersion`. These files carry no `imports`
|
||||
of their own beyond narrow parameterized helpers (see
|
||||
`modules/beszel/host-token.nix` below) — all shared behavior comes from the
|
||||
platform/build-type modules composed in `flake.nix`, not from the host file.
|
||||
of their own — all shared behavior comes from the platform/build-type modules
|
||||
composed in `flake.nix`, not from the host file.
|
||||
- `modules/platforms/{linode,proxmox,lxc,baremetal}.nix` — platform-specific
|
||||
config: boot method, guest tooling, and the hardware config, imported
|
||||
directly by the platform module itself — **not** wired in from
|
||||
@@ -434,10 +431,10 @@ removing a host.
|
||||
substituter + SSH remote-builder wiring; see `docs/nix-cache.md` for the
|
||||
full design (per-host local stores, no shared `/nix/store`, and how the
|
||||
`nixremote` signing/SSH keys fit together).
|
||||
- `modules/beszel/host-token.nix` — parameterized helper module
|
||||
(`{ name, sopsFile }`) that wires a host's beszel-agent sops secret/template
|
||||
and `environmentFile`; used by `hosts/server/host.nix` and
|
||||
`hosts/nix-cache/host.nix` to avoid duplicating that boilerplate.
|
||||
- `modules/beszel/enable-agent.nix` — enables beszel-agent, sets `HUB_URL`,
|
||||
fixes the upstream `StateDirectory` bug, and wires the universal
|
||||
`beszel-token` sops secret (from `secrets/common.yaml`) into the agent's
|
||||
`environmentFile`; see `docs/beszel.md` for the full setup guide.
|
||||
- `modules/tailscale/`, `modules/docker/`, `modules/networking/`,
|
||||
`modules/traefik/`, `modules/tor/`, `modules/services/*` — single-purpose,
|
||||
single-host
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
# Beszel agent
|
||||
|
||||
[Beszel](https://github.com/henrygd/beszel) is the monitoring dashboard used
|
||||
in this LAN. The hub runs as a Docker container on `docker.sweet.home` (port
|
||||
`vars.ports.beszelHub`, 8090). Each monitored NixOS host runs a
|
||||
`beszel-agent` that connects back to the hub.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
Everything is handled by a single module:
|
||||
|
||||
**`modules/beszel/enable-agent.nix`** — imported by a build type. It:
|
||||
- Enables `beszel-agent`
|
||||
- Sets `HUB_URL` to `docker.sweet.home:8090`
|
||||
- Sets `KEY` from `vars.beszelHubKey` (`variables.nix`) — the hub's SSH
|
||||
public key, shared by every agent. Update `beszelHubKey` if the docker
|
||||
host is ever rebuilt and the hub generates a new keypair.
|
||||
- Reads the universal `beszel-token` from `secrets/common.yaml` via sops
|
||||
and passes it to the agent as `TOKEN` in an env file
|
||||
- Fixes an upstream bug where the agent couldn't persist its hub-pairing
|
||||
fingerprint across restarts (adds a real `StateDirectory`)
|
||||
|
||||
A host file needs no beszel configuration at all — just import the module
|
||||
in the build type and add the system in the hub UI.
|
||||
|
||||
---
|
||||
|
||||
## Adding beszel to a new build type
|
||||
|
||||
Add `../beszel/enable-agent.nix` to the `imports` list in
|
||||
`modules/build-types/<type>.nix`:
|
||||
|
||||
```nix
|
||||
imports = [
|
||||
../beszel/enable-agent.nix
|
||||
# ... other imports
|
||||
];
|
||||
```
|
||||
|
||||
That's the only change required. The host file needs nothing.
|
||||
|
||||
---
|
||||
|
||||
## Adding a new system to the hub
|
||||
|
||||
1. Rebuild and deploy the host with its build type importing `enable-agent.nix`.
|
||||
2. Open the beszel hub (`http://docker.sweet.home:8090`).
|
||||
3. Go to **Systems → Add system**, enter the host's IP and the default port
|
||||
(45876). The agent will connect and the system will appear as active.
|
||||
|
||||
---
|
||||
|
||||
## One-time setup: add the token to `secrets/common.yaml`
|
||||
|
||||
The universal token is stored once in the common secrets file, shared by all
|
||||
agents. Only needed once, not per-host:
|
||||
|
||||
```sh
|
||||
sops secrets/common.yaml
|
||||
```
|
||||
|
||||
Add:
|
||||
```yaml
|
||||
beszel-token: <token from the beszel hub Settings → Keys>
|
||||
```
|
||||
|
||||
`secrets/common.yaml` is already a sops recipient for every host via their
|
||||
SSH host keys, so no additional sops recipient setup is needed.
|
||||
|
||||
---
|
||||
|
||||
## Optional: monitoring extra filesystems
|
||||
|
||||
To report disk usage for a mount beyond the root filesystem, add
|
||||
`EXTRA_FILESYSTEMS` in the host file:
|
||||
|
||||
```nix
|
||||
services.beszel.agent.environment = {
|
||||
EXTRA_FILESYSTEMS = "/mnt/data"; # colon-separated for multiple paths
|
||||
};
|
||||
```
|
||||
|
||||
The `server` host uses this to expose its ZFS data pool:
|
||||
|
||||
```nix
|
||||
services.beszel.agent.environment = {
|
||||
EXTRA_FILESYSTEMS = "${vars.storageRoot}/${vars.nfsShares.dockerVolumes.subpath}";
|
||||
LOG_LEVEL = "debug";
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Optional: monitoring Docker containers
|
||||
|
||||
`enable-agent.nix` has a commented-out line for Docker monitoring:
|
||||
|
||||
```nix
|
||||
#DOCKER_HOST = "tcp://docker-socket-proxy:2375";
|
||||
```
|
||||
|
||||
Uncomment it if the host runs docker-socket-proxy and you want per-container
|
||||
stats. Hosts without Docker should leave it commented out.
|
||||
|
||||
---
|
||||
|
||||
## If the hub key changes
|
||||
|
||||
If the docker host is ever rebuilt and beszel generates a new SSH keypair,
|
||||
update `beszelHubKey` in `variables.nix` and rebuild all beszel-enabled hosts.
|
||||
The new key is visible in the beszel hub under **Settings → Keys**.
|
||||
@@ -1,12 +1,5 @@
|
||||
{ vars, ... }:
|
||||
{
|
||||
imports = [
|
||||
(import ../../modules/beszel/host-token.nix {
|
||||
name = "ha-server-1";
|
||||
sopsFile = ../../secrets/ha-server-1.yaml;
|
||||
})
|
||||
];
|
||||
|
||||
networking = {
|
||||
hostName = vars.haServer1Host;
|
||||
hostId = "3a4b5c6d";
|
||||
@@ -23,8 +16,5 @@
|
||||
nameservers = [ vars.domainControllerIp ];
|
||||
};
|
||||
|
||||
# Set KEY after pairing this host with the beszel hub; the token is sops-managed.
|
||||
# services.beszel.agent.environment.KEY = "";
|
||||
|
||||
system.stateVersion = "26.05";
|
||||
}
|
||||
@@ -1,12 +1,5 @@
|
||||
{ vars, ... }:
|
||||
{
|
||||
imports = [
|
||||
(import ../../modules/beszel/host-token.nix {
|
||||
name = "ha-server-2";
|
||||
sopsFile = ../../secrets/ha-server-2.yaml;
|
||||
})
|
||||
];
|
||||
|
||||
networking = {
|
||||
hostName = vars.haServer2Host;
|
||||
hostId = "7e8f9a0b";
|
||||
@@ -23,8 +16,5 @@
|
||||
nameservers = [ vars.domainControllerIp ];
|
||||
};
|
||||
|
||||
# Set KEY after pairing this host with the beszel hub; the token is sops-managed.
|
||||
# services.beszel.agent.environment.KEY = "";
|
||||
|
||||
system.stateVersion = "26.05";
|
||||
}
|
||||
@@ -1,13 +1,6 @@
|
||||
{ vars, ... }:
|
||||
|
||||
{
|
||||
imports = [
|
||||
(import ../../modules/beszel/host-token.nix {
|
||||
name = "nix-cache";
|
||||
sopsFile = ../../secrets/nix-cache.yaml;
|
||||
})
|
||||
];
|
||||
|
||||
networking = {
|
||||
hostName = vars.nixCacheHost;
|
||||
useDHCP = false;
|
||||
@@ -19,10 +12,6 @@
|
||||
nameservers = [ vars.domainControllerIp ];
|
||||
};
|
||||
|
||||
services.beszel.agent.environment = {
|
||||
KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFPR9kwtC4TAeTRu46A7+opZsYpxqkRJ+x/ZyB2GWCeG";
|
||||
};
|
||||
|
||||
# Preserved from the pre-refactor `nix-cache` target — stateVersion must
|
||||
# never be bumped on an already-installed machine.
|
||||
system.stateVersion = "25.05";
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
# Optional: set environment vars
|
||||
sessionVariables = {
|
||||
EDITOR = "vim";
|
||||
EDITOR = "nano";
|
||||
SOPS_AGE_KEY_FILE = "${config.home.homeDirectory}/.config/sops/age/keys.txt";
|
||||
};
|
||||
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
{ vars, ... }:
|
||||
|
||||
{
|
||||
imports = [
|
||||
(import ../../modules/beszel/host-token.nix {
|
||||
name = "server";
|
||||
sopsFile = ../../secrets/server.yaml;
|
||||
})
|
||||
];
|
||||
|
||||
networking = {
|
||||
hostName = vars.nfsServerHost;
|
||||
hostId = "6689f93e";
|
||||
@@ -21,7 +14,6 @@
|
||||
};
|
||||
|
||||
services.beszel.agent.environment = {
|
||||
KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFPR9kwtC4TAeTRu46A7+opZsYpxqkRJ+x/ZyB2GWCeG";
|
||||
EXTRA_FILESYSTEMS = "${vars.storageRoot}/${vars.nfsShares.dockerVolumes.subpath}";
|
||||
LOG_LEVEL = "debug";
|
||||
};
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
{ vars, ... }:
|
||||
|
||||
{
|
||||
imports = [
|
||||
(import ../../modules/beszel/host-token.nix {
|
||||
name = "tailscale-router";
|
||||
sopsFile = ../../secrets/tailscale-router.yaml;
|
||||
})
|
||||
];
|
||||
|
||||
networking = {
|
||||
hostName = "tailscale-router";
|
||||
useDHCP = false;
|
||||
@@ -19,10 +12,6 @@
|
||||
nameservers = [ vars.domainControllerIp ];
|
||||
};
|
||||
|
||||
services.beszel.agent.environment = {
|
||||
KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFPR9kwtC4TAeTRu46A7+opZsYpxqkRJ+x/ZyB2GWCeG";
|
||||
};
|
||||
|
||||
# No networking.hostId: only ZFS-touching hosts (server, docker) need one
|
||||
# for pool-import safety, and this host does neither.
|
||||
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
{ vars, ... }:
|
||||
|
||||
{
|
||||
imports = [
|
||||
(import ../../modules/beszel/host-token.nix {
|
||||
name = "tor-relay";
|
||||
sopsFile = ../../secrets/tor-relay.yaml;
|
||||
})
|
||||
];
|
||||
|
||||
networking = {
|
||||
hostName = "tor-relay";
|
||||
useDHCP = false;
|
||||
@@ -22,10 +15,6 @@
|
||||
# No networking.hostId: only ZFS-touching hosts need one for pool-import
|
||||
# safety, and this host does neither.
|
||||
|
||||
services.beszel.agent.environment = {
|
||||
KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFPR9kwtC4TAeTRu46A7+opZsYpxqkRJ+x/ZyB2GWCeG";
|
||||
};
|
||||
|
||||
# A genuinely new host (not a pre-refactor carry-over), so it tracks the
|
||||
# flake's current nixpkgs release rather than being pinned to an older one.
|
||||
system.stateVersion = "26.05";
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
{ vars, ... }:
|
||||
{ config, vars, ... }:
|
||||
|
||||
{
|
||||
services.beszel.agent.enable = true;
|
||||
services.beszel.agent.environment = {
|
||||
#DOCKER_HOST = "tcp://docker-socket-proxy:2375";
|
||||
HUB_URL = "http://${vars.dockerHost}.${vars.homeDomain}:${toString vars.ports.beszelHub}";
|
||||
# Universal token shared by all beszel agents. Add to secrets/common.yaml:
|
||||
# sops secrets/common.yaml
|
||||
# beszel-token: <value from the beszel hub UI>
|
||||
sops.secrets."beszel-token" = { };
|
||||
|
||||
sops.templates."beszel.env".content = ''
|
||||
TOKEN=${config.sops.placeholder."beszel-token"}
|
||||
'';
|
||||
|
||||
services.beszel.agent = {
|
||||
enable = true;
|
||||
environmentFile = config.sops.templates."beszel.env".path;
|
||||
environment = {
|
||||
#DOCKER_HOST = "tcp://docker-socket-proxy:2375";
|
||||
HUB_URL = "http://${vars.dockerHost}.${vars.homeDomain}:${toString vars.ports.beszelHub}";
|
||||
KEY = vars.beszelHubKey;
|
||||
};
|
||||
};
|
||||
|
||||
# The upstream module runs beszel-agent under DynamicUser with
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
{ name, sopsFile }:
|
||||
|
||||
{ config, ... }:
|
||||
|
||||
{
|
||||
sops.secrets."beszel-token".sopsFile = sopsFile;
|
||||
sops.templates."${name}-beszel.env".content = ''
|
||||
TOKEN=${config.sops.placeholder."beszel-token"}
|
||||
'';
|
||||
services.beszel.agent.environmentFile = config.sops.templates."${name}-beszel.env".path;
|
||||
}
|
||||
@@ -45,4 +45,7 @@ in
|
||||
# Pacemaker controls nfs-server — prevent systemd from starting it at boot
|
||||
# on both nodes (only the Active node should be serving NFS).
|
||||
systemd.services.nfs-server.wantedBy = lib.mkForce [ ];
|
||||
|
||||
# Same reason as server.nix: exports use standard auth, not Kerberos.
|
||||
systemd.services.rpc-svcgssd.enable = false;
|
||||
}
|
||||
@@ -102,6 +102,12 @@ in
|
||||
requires = [ "zfs-mount.service" ];
|
||||
};
|
||||
|
||||
# rpc-svcgssd handles Kerberos/GSS-API for NFS. Not needed: exports use
|
||||
# standard auth, not sec=krb5. On IPA-joined hosts the keytab exists (host/
|
||||
# principal only) but has no nfs/ principal, causing spurious failure.
|
||||
# Mask it so nfs-server's Wants= can't pull it in.
|
||||
systemd.services.rpc-svcgssd.enable = false;
|
||||
|
||||
services.nfs.server = {
|
||||
enable = true;
|
||||
exports = mkNfsExports vars.storageRoot;
|
||||
|
||||
@@ -21,7 +21,26 @@
|
||||
# STONITH device during the testing phase. Switch to resource-and-stonith
|
||||
# once the fence_pve_ssh STONITH resource is active (see
|
||||
# scripts/ha/cluster-enable-stonith.sh).
|
||||
{ lib, vars, ... }:
|
||||
#
|
||||
# PATH wrapper: when the DRBD kernel module invokes the fence-peer handler
|
||||
# via the UMH (User Mode Helper) mechanism it provides a minimal PATH that
|
||||
# omits /run/current-system/sw/bin. crm-fence-peer.sh calls cibadmin,
|
||||
# crm_mon etc.; if those aren't found a pipeline in the script breaks with
|
||||
# SIGPIPE. A process killed by signal has WEXITSTATUS() == 0, so the kernel
|
||||
# sees exit code 0 and logs "fence-peer helper broken, returned 0", looping
|
||||
# forever. The writeShellScript wrappers below prepend the NixOS sw path
|
||||
# before exec-ing the real handler, giving it a working Pacemaker toolchain.
|
||||
{ lib, pkgs, vars, ... }:
|
||||
let
|
||||
fencePeerWrapper = pkgs.writeShellScript "drbd-fence-peer" ''
|
||||
export PATH="/run/current-system/sw/bin:/run/current-system/sw/sbin:$PATH"
|
||||
exec /run/current-system/sw/lib/drbd/crm-fence-peer.sh "$@"
|
||||
'';
|
||||
unfencePeerWrapper = pkgs.writeShellScript "drbd-unfence-peer" ''
|
||||
export PATH="/run/current-system/sw/bin:/run/current-system/sw/sbin:$PATH"
|
||||
exec /run/current-system/sw/lib/drbd/crm-unfence-peer.sh "$@"
|
||||
'';
|
||||
in
|
||||
{
|
||||
# Root SSH access — same key set as nixos user so all admin keys can reach root.
|
||||
users.users.root.openssh.authorizedKeys.keys = [
|
||||
@@ -62,28 +81,24 @@
|
||||
fencing resource-only;
|
||||
}
|
||||
handlers {
|
||||
fence-peer "/run/current-system/sw/lib/drbd/crm-fence-peer.sh";
|
||||
unfence-peer "/run/current-system/sw/lib/drbd/crm-unfence-peer.sh";
|
||||
fence-peer "${fencePeerWrapper}";
|
||||
unfence-peer "${unfencePeerWrapper}";
|
||||
}
|
||||
}
|
||||
|
||||
resource ha-data {
|
||||
volume 0 {
|
||||
device /dev/drbd0;
|
||||
disk ${vars.haServerDrbdDisk};
|
||||
meta-disk internal;
|
||||
}
|
||||
|
||||
on ${vars.haServer1Host} {
|
||||
address ${vars.haServer1StorageIp}:${toString vars.ports.haServerDrbd};
|
||||
volume 0 {
|
||||
device /dev/drbd0;
|
||||
disk ${vars.haServer1DrbdDisk};
|
||||
meta-disk internal;
|
||||
}
|
||||
}
|
||||
|
||||
on ${vars.haServer2Host} {
|
||||
address ${vars.haServer2StorageIp}:${toString vars.ports.haServerDrbd};
|
||||
volume 0 {
|
||||
device /dev/drbd0;
|
||||
disk ${vars.haServer2DrbdDisk};
|
||||
meta-disk internal;
|
||||
}
|
||||
}
|
||||
}
|
||||
'';
|
||||
|
||||
Binary file not shown.
@@ -34,18 +34,55 @@ echo "════════════════════════
|
||||
echo " HA Cluster Acceptance Tests — $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo "════════════════════════════════════════════════════"
|
||||
|
||||
# ── Detect Active/Standby nodes ────────────────────────────────────────────
|
||||
# Pacemaker can promote either node; determine which is currently Active
|
||||
# (DRBD Primary / holds ha-group resources) before running tests.
|
||||
# ── Pre-flight: DRBD sync must be complete ────────────────────────────────
|
||||
# Tests that check disk state, XFS mount, and iSCSI will fail or give false
|
||||
# results while the initial full sync is in progress. Block until done.
|
||||
echo ""
|
||||
echo "Detecting Active/Standby nodes..."
|
||||
if n1 "drbdadm role ha-data" 2>/dev/null | grep -q "^Primary"; then
|
||||
ACTIVE_NODE="$NODE1"; ACTIVE_IP="$NODE1_IP"
|
||||
echo "Pre-flight: verifying DRBD sync is complete..."
|
||||
DRBD_PREFLIGHT=$(n1 "drbdadm dstate ha-data 2>/dev/null" 2>/dev/null || echo "unknown")
|
||||
if ! echo "$DRBD_PREFLIGHT" | grep -q "^UpToDate/UpToDate$"; then
|
||||
echo ""
|
||||
echo " ERROR: DRBD initial sync not complete."
|
||||
echo " Current dstate on $NODE1: $DRBD_PREFLIGHT"
|
||||
echo ""
|
||||
echo " Monitor progress:"
|
||||
echo " ssh nixos@$NODE1_IP 'sudo watch -n3 cat /proc/drbd'"
|
||||
echo ""
|
||||
echo " Re-run this script once dstate shows UpToDate/UpToDate."
|
||||
exit 1
|
||||
fi
|
||||
echo " dstate: $DRBD_PREFLIGHT — ready."
|
||||
|
||||
# ── Detect Active/Standby nodes ────────────────────────────────────────────
|
||||
# Use crm_mon to detect which node holds the Promoted (Primary) DRBD resource.
|
||||
# Pacemaker is authoritative; DRBD role can briefly read as Secondary while
|
||||
# Pacemaker is mid-transition, giving a false Active/Standby swap.
|
||||
# Wait up to 90 s for Pacemaker to settle before giving up.
|
||||
echo ""
|
||||
echo "Detecting Active/Standby nodes (waiting for Pacemaker to settle)..."
|
||||
ACTIVE_NODE=""
|
||||
for i in $(seq 1 30); do
|
||||
# crm_mon -1 output contains "Promoted: [ <node> ]" for the DRBD master.
|
||||
CRM_OUT=$(n1 "crm_mon -1" 2>/dev/null || n2 "crm_mon -1" 2>/dev/null || true)
|
||||
# crm_mon 2.x formats Promoted lines as " * Promoted: [ node ]" — the * bullet
|
||||
# means ^\s*(Promoted|Masters): never matches; filter Unpromoted first instead.
|
||||
ACTIVE_NODE=$(echo "$CRM_OUT" | grep -v 'Unpromoted\|Unmanaged' | grep -E '(Promoted|Masters):' | grep -oE '\b(ha-server-[0-9]+)\b' | head -1 || true)
|
||||
[[ -n "$ACTIVE_NODE" ]] && break
|
||||
sleep 3
|
||||
done
|
||||
|
||||
if [[ -z "$ACTIVE_NODE" ]]; then
|
||||
echo " WARNING: could not determine Active node from crm_mon after 90 s — defaulting to $NODE1"
|
||||
ACTIVE_NODE="$NODE1"
|
||||
fi
|
||||
|
||||
if [[ "$ACTIVE_NODE" == "$NODE1" ]]; then
|
||||
ACTIVE_IP="$NODE1_IP"
|
||||
STANDBY_NODE="$NODE2"; STANDBY_IP="$NODE2_IP"
|
||||
na() { n1 "$@"; }
|
||||
ns() { n2 "$@"; }
|
||||
else
|
||||
ACTIVE_NODE="$NODE2"; ACTIVE_IP="$NODE2_IP"
|
||||
ACTIVE_IP="$NODE2_IP"
|
||||
STANDBY_NODE="$NODE1"; STANDBY_IP="$NODE1_IP"
|
||||
na() { n2 "$@"; }
|
||||
ns() { n1 "$@"; }
|
||||
|
||||
+91
-41
@@ -32,11 +32,11 @@ 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 per node — disk ordering can differ between Proxmox VMs.
|
||||
# Defaults match haServer{1,2}DrbdDisk in variables.nix. Override if your
|
||||
# VM was created with disks in a different order.
|
||||
NODE1_DRBD_DISK="${NODE1_DRBD_DISK:-/dev/sdb}"
|
||||
NODE2_DRBD_DISK="${NODE2_DRBD_DISK:-/dev/sda}"
|
||||
# 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}"
|
||||
@@ -152,11 +152,13 @@ for i in $(seq 1 30); do
|
||||
done
|
||||
|
||||
# ── 2. DRBD initialisation ────────────────────────────────────────────────
|
||||
# Put both nodes in Pacemaker standby before touching DRBD metadata.
|
||||
# Without this, the OCF DRBD agent races: it sees drbdadm-down as a failure
|
||||
# and immediately calls drbdadm-up again, leaving the backing disk busy when
|
||||
# create-md / write-dev-uuid runs. On a fresh cluster with no resources
|
||||
# configured this is a no-op; on a re-run it stops the race.
|
||||
# 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
|
||||
@@ -174,28 +176,41 @@ for i in $(seq 1 30); do
|
||||
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
|
||||
|
||||
# Verify the DRBD config on each node targets the correct backing disk.
|
||||
# Proxmox VM disk ordering is not guaranteed — the data disk may appear as
|
||||
# /dev/sda on one node and /dev/sdb on the other. If the deployed NixOS
|
||||
# config was generated before variables.nix had per-node disk assignments, the
|
||||
# config may point to the wrong device. Patch it in-place so drbdadm up
|
||||
# attaches to the right disk; rebuild+redeploy to make this permanent.
|
||||
n1_conf_disk=$(drbdadm sh-ll-dev ha-data 2>/dev/null || true)
|
||||
if [[ -n "$n1_conf_disk" && "$n1_conf_disk" != "$NODE1_DRBD_DISK" ]]; then
|
||||
warn "$NODE1 DRBD config says disk=$n1_conf_disk; patching to $NODE1_DRBD_DISK (redeploy to make permanent)"
|
||||
sed -i "s|${n1_conf_disk}|${NODE1_DRBD_DISK}|g" /etc/drbd.d/*.res 2>/dev/null || true
|
||||
# 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
|
||||
n2_conf_disk=$(n2_ssh "drbdadm sh-ll-dev ha-data 2>/dev/null" 2>/dev/null || true)
|
||||
if [[ -n "$n2_conf_disk" && "$n2_conf_disk" != "$NODE2_DRBD_DISK" ]]; then
|
||||
warn "$NODE2 DRBD config says disk=$n2_conf_disk; patching to $NODE2_DRBD_DISK (redeploy to make permanent)"
|
||||
n2_ssh "sed -i 's|${n2_conf_disk}|${NODE2_DRBD_DISK}|g' /etc/drbd.d/*.res" 2>/dev/null || true
|
||||
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.
|
||||
@@ -205,41 +220,73 @@ log "Initialising DRBD metadata on $NODE1..."
|
||||
# "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.
|
||||
if ! drbdadm dstate ha-data 2>/dev/null | grep -q "UpToDate"; then
|
||||
# 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 "${NODE1_DRBD_DISK}" internal create-md
|
||||
drbdmeta --force 0 v08 "${NODE1_DRBD_DISK}" internal write-dev-uuid "$UUID1"
|
||||
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 | grep -q UpToDate" 2>/dev/null; then
|
||||
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 ${NODE2_DRBD_DISK} internal create-md"
|
||||
n2_ssh "drbdmeta --force 0 v08 ${NODE2_DRBD_DISK} internal write-dev-uuid ${UUID2}"
|
||||
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 "Clearing Pacemaker standby — DRBD is up, letting Pacemaker resume..."
|
||||
crm_standby -N "$NODE1" -v off 2>/dev/null || true
|
||||
crm_standby -N "$NODE2" -v off 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 to finish initial sync (this may take several minutes)..."
|
||||
for i in $(seq 1 300); do
|
||||
state=$(drbdadm dstate ha-data 2>/dev/null || echo "unknown")
|
||||
if echo "$state" | grep -q "UpToDate/UpToDate"; then
|
||||
log "DRBD sync complete: $state"
|
||||
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
|
||||
[[ $i -eq 300 ]] && warn "DRBD not UpToDate after 300 s — continuing anyway (check drbdadm status)"
|
||||
sleep 1
|
||||
# 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
|
||||
@@ -389,6 +436,9 @@ cibadmin --replace --scope constraints --xml-text '<constraints>
|
||||
<rsc_colocation id="coloc-group-with-drbd" score="INFINITY" rsc="ha-group" with-rsc="ms-drbd0" with-rsc-role="Promoted"/>
|
||||
</constraints>'
|
||||
|
||||
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 --locate 2>/dev/null | grep -q "running on"; then
|
||||
|
||||
Executable
+256
@@ -0,0 +1,256 @@
|
||||
#!/usr/bin/env bash
|
||||
# failover.sh — graceful HA cluster failover
|
||||
#
|
||||
# Detects which node is active and moves all resources to the other node by
|
||||
# putting the active node into Pacemaker standby. Waits for the XFS mount to
|
||||
# appear on the target before returning.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/ha/failover.sh [--to node1|node2] [--force] [--timeout <s>] [--dry-run]
|
||||
#
|
||||
# --to node1|node2 target node (default: the node that is NOT currently active)
|
||||
# --force skip the interactive confirmation prompt
|
||||
# --timeout <s> seconds to wait for resources to move (default: 120)
|
||||
# --dry-run show what would be done without changing anything
|
||||
set -euo pipefail
|
||||
|
||||
# ── Configuration ─────────────────────────────────────────────────────────
|
||||
NODE1="${NODE1:-ha-server-1}"
|
||||
NODE2="${NODE2:-ha-server-2}"
|
||||
NODE1_IP="${NODE1_IP:-192.168.2.228}"
|
||||
NODE2_IP="${NODE2_IP:-192.168.2.227}"
|
||||
VIP="${VIP:-192.168.2.229}"
|
||||
XFS_MOUNT="${XFS_MOUNT:-/srv/ha-data}"
|
||||
HA_USER="${HA_USER:-nixos}"
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
n1() { ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 "${HA_USER}@${NODE1_IP}" sudo "$@" 2>/dev/null; }
|
||||
n2() { ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 "${HA_USER}@${NODE2_IP}" sudo "$@" 2>/dev/null; }
|
||||
|
||||
# ── Argument parsing ───────────────────────────────────────────────────────
|
||||
TARGET_NODE=""
|
||||
FORCE=false
|
||||
DRY_RUN=false
|
||||
TIMEOUT=120
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--to)
|
||||
shift
|
||||
case "${1:-}" in
|
||||
node1|ha-server-1) TARGET_NODE="$NODE1" ;;
|
||||
node2|ha-server-2) TARGET_NODE="$NODE2" ;;
|
||||
*) echo "ERROR: --to must be node1, node2, ha-server-1, or ha-server-2"; exit 1 ;;
|
||||
esac
|
||||
;;
|
||||
--force) FORCE=true ;;
|
||||
--dry-run) DRY_RUN=true ;;
|
||||
--timeout) shift; TIMEOUT="${1:?--timeout requires a value}" ;;
|
||||
*) echo "Unknown argument: $1"; echo "Usage: $0 [--to node1|node2] [--force] [--timeout <s>] [--dry-run]"; exit 1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
DRY_PREFIX=""
|
||||
$DRY_RUN && DRY_PREFIX="[dry-run] "
|
||||
|
||||
echo "════════════════════════════════════════════════════"
|
||||
echo " HA Cluster Failover — $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
$DRY_RUN && echo " MODE: dry-run — no changes will be made"
|
||||
echo "════════════════════════════════════════════════════"
|
||||
|
||||
# ── Detect active node ─────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Detecting active node..."
|
||||
|
||||
CRM_OUT=""
|
||||
if ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 "${HA_USER}@${NODE1_IP}" true 2>/dev/null; then
|
||||
CRM_OUT=$(n1 "crm_mon -1" 2>/dev/null || true)
|
||||
fi
|
||||
if [[ -z "$CRM_OUT" ]]; then
|
||||
if ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 "${HA_USER}@${NODE2_IP}" true 2>/dev/null; then
|
||||
CRM_OUT=$(n2 "crm_mon -1" 2>/dev/null || true)
|
||||
fi
|
||||
fi
|
||||
|
||||
# crm_mon 2.x formats Promoted lines as " * Promoted: [ node ]" — the * bullet
|
||||
# means ^\s*(Promoted|Masters): never matches; filter Unpromoted first instead.
|
||||
ACTIVE_NODE=$(echo "$CRM_OUT" | grep -v 'Unpromoted\|Unmanaged' | \
|
||||
grep -E '(Promoted|Masters):' | \
|
||||
grep -oE '\b(ha-server-[0-9]+)\b' | head -1 || true)
|
||||
|
||||
if [[ -z "$ACTIVE_NODE" ]]; then
|
||||
echo ""
|
||||
echo "ERROR: could not determine active node from crm_mon."
|
||||
echo " Is Pacemaker still settling? Try running scripts/ha/health.sh first."
|
||||
echo " If Pacemaker is down on both nodes, manual recovery is required."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$ACTIVE_NODE" == "$NODE1" ]]; then
|
||||
ACTIVE_IP="$NODE1_IP"
|
||||
STANDBY_NODE="$NODE2"
|
||||
STANDBY_IP="$NODE2_IP"
|
||||
na() { n1 "$@"; }
|
||||
ns() { n2 "$@"; }
|
||||
else
|
||||
ACTIVE_IP="$NODE2_IP"
|
||||
STANDBY_NODE="$NODE1"
|
||||
STANDBY_IP="$NODE1_IP"
|
||||
na() { n2 "$@"; }
|
||||
ns() { n1 "$@"; }
|
||||
fi
|
||||
|
||||
echo " Active: $ACTIVE_NODE ($ACTIVE_IP)"
|
||||
echo " Standby: $STANDBY_NODE ($STANDBY_IP)"
|
||||
|
||||
# ── Validate target ────────────────────────────────────────────────────────
|
||||
if [[ -n "$TARGET_NODE" ]]; then
|
||||
if [[ "$TARGET_NODE" == "$ACTIVE_NODE" ]]; then
|
||||
echo ""
|
||||
echo "ERROR: $TARGET_NODE is already the active node — nothing to do."
|
||||
exit 1
|
||||
fi
|
||||
echo " Target: $TARGET_NODE (as requested)"
|
||||
else
|
||||
echo " Target: $STANDBY_NODE (auto — the other node)"
|
||||
fi
|
||||
|
||||
# ── Pre-checks ─────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Pre-checks..."
|
||||
|
||||
DRBD_DSTATE=$(na "drbdadm dstate ha-data 2>/dev/null" 2>/dev/null || echo "unknown")
|
||||
if ! echo "$DRBD_DSTATE" | grep -q "UpToDate/UpToDate"; then
|
||||
echo ""
|
||||
echo " WARNING: DRBD dstate is '$DRBD_DSTATE' (not UpToDate/UpToDate)."
|
||||
echo " Failing over with a partially-synced disk risks split-brain."
|
||||
if ! $FORCE; then
|
||||
echo " Use --force to proceed anyway (not recommended)."
|
||||
exit 1
|
||||
fi
|
||||
echo " --force specified — proceeding despite non-ideal DRBD state."
|
||||
else
|
||||
echo " DRBD dstate: $DRBD_DSTATE — OK"
|
||||
fi
|
||||
|
||||
QUORUM_OK=$(na "corosync-quorumtool -s 2>/dev/null | grep -c 'Quorate:.*Yes'" 2>/dev/null || echo "0")
|
||||
if [[ "$QUORUM_OK" -lt 1 ]]; then
|
||||
echo " ERROR: cluster does not have quorum — failover would be unsafe."
|
||||
exit 1
|
||||
fi
|
||||
echo " Quorum: OK"
|
||||
|
||||
# ── Confirm ────────────────────────────────────────────────────────────────
|
||||
if ! $FORCE && ! $DRY_RUN; then
|
||||
echo ""
|
||||
echo " This will move all resources from $ACTIVE_NODE → $STANDBY_NODE."
|
||||
echo " VIP and services will be unreachable for ~10–30 seconds."
|
||||
printf " Proceed? [y/N] "
|
||||
read -r ANSWER
|
||||
[[ "${ANSWER,,}" == "y" || "${ANSWER,,}" == "yes" ]] || { echo "Aborted."; exit 0; }
|
||||
fi
|
||||
|
||||
# ── Capture active node's crm_node name ───────────────────────────────────
|
||||
# crm_node -n returns the node name as registered in Pacemaker (may differ
|
||||
# from hostname if Pacemaker was configured with explicit node names).
|
||||
ACTIVE_CRMD_NAME=$(na "crm_node -n 2>/dev/null" 2>/dev/null || echo "$ACTIVE_NODE")
|
||||
|
||||
# ── Perform failover ───────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "${DRY_PREFIX}Putting $ACTIVE_NODE into standby (resources will migrate to $STANDBY_NODE)..."
|
||||
if ! $DRY_RUN; then
|
||||
na "crm_standby -N '${ACTIVE_CRMD_NAME}' -v on" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── Wait for resources to move ─────────────────────────────────────────────
|
||||
echo "${DRY_PREFIX}Waiting up to ${TIMEOUT}s for XFS to mount on $STANDBY_NODE..."
|
||||
MOVED=false
|
||||
SPIN_CHARS=('|' '/' '-' '\')
|
||||
SPIN_I=0
|
||||
|
||||
if $DRY_RUN; then
|
||||
echo " [dry-run] would wait for mountpoint $XFS_MOUNT on $STANDBY_NODE"
|
||||
MOVED=true
|
||||
else
|
||||
for i in $(seq 1 "$TIMEOUT"); do
|
||||
if ns "mountpoint -q '${XFS_MOUNT}' 2>/dev/null" 2>/dev/null; then
|
||||
printf "\r%-80s\r" ""
|
||||
echo " Resources moved in ${i}s"
|
||||
MOVED=true
|
||||
break
|
||||
fi
|
||||
SPIN_I=$(( SPIN_I + 1 ))
|
||||
SC="${SPIN_CHARS[$((SPIN_I % 4))]}"
|
||||
printf "\r [%s] waiting... (%ds) " "$SC" "$i"
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
|
||||
if ! $MOVED; then
|
||||
echo ""
|
||||
echo "ERROR: XFS did not mount on $STANDBY_NODE within ${TIMEOUT}s."
|
||||
echo ""
|
||||
echo " Current resource state:"
|
||||
na "crm_mon -1 2>/dev/null" 2>/dev/null | grep -E 'Started|Stopped|Promoted|Unpromoted|FAILED' | sed 's/^/ /' || true
|
||||
echo ""
|
||||
echo " Clearing standby to restore $ACTIVE_NODE (undo the failover attempt)..."
|
||||
na "crm_standby -N '${ACTIVE_CRMD_NAME}' -v off" 2>/dev/null || true
|
||||
na "crm_resource --cleanup" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Clear failure history ──────────────────────────────────────────────────
|
||||
echo "${DRY_PREFIX}Clearing Pacemaker failure history..."
|
||||
if ! $DRY_RUN; then
|
||||
ns "crm_resource --cleanup 2>/dev/null" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── Re-enable original active node as standby ─────────────────────────────
|
||||
echo "${DRY_PREFIX}Re-enabling $ACTIVE_NODE (now standby — will not claim resources)..."
|
||||
if ! $DRY_RUN; then
|
||||
na "crm_standby -N '${ACTIVE_CRMD_NAME}' -v off" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── Wait briefly for DRBD resync to begin ─────────────────────────────────
|
||||
if ! $DRY_RUN; then
|
||||
sleep 5
|
||||
fi
|
||||
|
||||
# ── Final state ────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Failover complete. Final state:"
|
||||
echo ""
|
||||
|
||||
CRM_OUT_AFTER=""
|
||||
if ! $DRY_RUN; then
|
||||
CRM_OUT_AFTER=$(ns "crm_mon -1" 2>/dev/null || na "crm_mon -1" 2>/dev/null || true)
|
||||
else
|
||||
CRM_OUT_AFTER="$CRM_OUT"
|
||||
fi
|
||||
|
||||
NEW_ACTIVE=$(echo "$CRM_OUT_AFTER" | grep -v 'Unpromoted\|Unmanaged' | \
|
||||
grep -E '(Promoted|Masters):' | \
|
||||
grep -oE '\b(ha-server-[0-9]+)\b' | head -1 || true)
|
||||
|
||||
if [[ -n "$NEW_ACTIVE" ]]; then
|
||||
if [[ "$NEW_ACTIVE" == "$ACTIVE_NODE" ]]; then
|
||||
echo " WARNING: $ACTIVE_NODE is still showing as active in crm_mon."
|
||||
echo " Pacemaker may still be settling — check again in a few seconds."
|
||||
else
|
||||
echo " Active: $NEW_ACTIVE"
|
||||
echo " Standby: $ACTIVE_NODE"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
RESOURCES_AFTER=$(echo "$CRM_OUT_AFTER" | awk '/Full List of Resources/,0' | tail -n +2 || true)
|
||||
[[ -z "$RESOURCES_AFTER" ]] && RESOURCES_AFTER=$(echo "$CRM_OUT_AFTER" | \
|
||||
grep -E 'Started|Stopped|Promoted|Unpromoted|FAILED|Master|Slave' || true)
|
||||
[[ -n "$RESOURCES_AFTER" ]] && echo "$RESOURCES_AFTER" | sed 's/^/ /'
|
||||
|
||||
echo ""
|
||||
echo " (DRBD resync of $ACTIVE_NODE may take a moment; monitor with:"
|
||||
echo " ssh nixos@${ACTIVE_IP} 'sudo watch -n3 cat /proc/drbd')"
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════"
|
||||
Executable
+180
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env bash
|
||||
# health.sh — HA cluster health snapshot (read-only, non-destructive)
|
||||
#
|
||||
# Prints a compact status panel across both nodes: SSH reachability, quorum,
|
||||
# DRBD state, Pacemaker resources, and service ports via the VIP.
|
||||
# Run from any host with SSH access to the HA nodes.
|
||||
set -euo pipefail
|
||||
|
||||
# ── Configuration ─────────────────────────────────────────────────────────
|
||||
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.2.229}" # vars.haServerVip
|
||||
XFS_MOUNT="${XFS_MOUNT:-/srv/ha-data}" # vars.haStorageRoot
|
||||
HA_USER="${HA_USER:-nixos}"
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
REACHABLE_1=false
|
||||
REACHABLE_2=false
|
||||
|
||||
n1() { ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 "${HA_USER}@${NODE1_IP}" sudo "$@" 2>/dev/null; }
|
||||
n2() { ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 "${HA_USER}@${NODE2_IP}" sudo "$@" 2>/dev/null; }
|
||||
|
||||
probe_node() {
|
||||
local ip=$1
|
||||
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 "${HA_USER}@${ip}" true 2>/dev/null && echo "ONLINE" || echo "OFFLINE"
|
||||
}
|
||||
|
||||
section() { echo ""; echo "── $* ──"; }
|
||||
|
||||
echo "════════════════════════════════════════════════════"
|
||||
echo " HA Cluster Health — $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo "════════════════════════════════════════════════════"
|
||||
|
||||
# ── Node reachability ──────────────────────────────────────────────────────
|
||||
section "Nodes"
|
||||
N1_STATUS=$(probe_node "$NODE1_IP")
|
||||
N2_STATUS=$(probe_node "$NODE2_IP")
|
||||
[[ "$N1_STATUS" == "ONLINE" ]] && REACHABLE_1=true
|
||||
[[ "$N2_STATUS" == "ONLINE" ]] && REACHABLE_2=true
|
||||
|
||||
if ! $REACHABLE_1 && ! $REACHABLE_2; then
|
||||
echo " ERROR: both nodes unreachable — cannot continue."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Detect active node ─────────────────────────────────────────────────────
|
||||
# crm_mon 2.x formats the Promoted line as " * Promoted: [ node ]" — the
|
||||
# bullet * means ^\s*(Promoted|Masters): never matches. Filter out Unpromoted
|
||||
# first, then match anywhere on the line.
|
||||
ACTIVE_NODE=""
|
||||
CRM_OUT=""
|
||||
if $REACHABLE_1; then
|
||||
CRM_OUT=$(n1 "crm_mon -1" 2>/dev/null || true)
|
||||
elif $REACHABLE_2; then
|
||||
CRM_OUT=$(n2 "crm_mon -1" 2>/dev/null || true)
|
||||
fi
|
||||
ACTIVE_NODE=$(echo "${CRM_OUT:-}" | grep -v 'Unpromoted\|Unmanaged' | \
|
||||
grep -E '(Promoted|Masters):' | \
|
||||
grep -oE '\b(ha-server-[0-9]+)\b' | head -1 || true)
|
||||
|
||||
STANDBY_NODE=""
|
||||
if [[ "$ACTIVE_NODE" == "$NODE1" ]]; then
|
||||
STANDBY_NODE="$NODE2"
|
||||
elif [[ "$ACTIVE_NODE" == "$NODE2" ]]; then
|
||||
STANDBY_NODE="$NODE1"
|
||||
fi
|
||||
|
||||
n1_tag=""; n2_tag=""
|
||||
[[ "$ACTIVE_NODE" == "$NODE1" ]] && n1_tag=" [ACTIVE]" || n1_tag=" [STANDBY]"
|
||||
[[ "$ACTIVE_NODE" == "$NODE2" ]] && n2_tag=" [ACTIVE]" || n2_tag=" [STANDBY]"
|
||||
[[ -z "$ACTIVE_NODE" ]] && { n1_tag=""; n2_tag=""; }
|
||||
|
||||
printf " %-14s [%s]%s\n" "$NODE1" "$N1_STATUS" "$n1_tag"
|
||||
printf " %-14s [%s]%s\n" "$NODE2" "$N2_STATUS" "$n2_tag"
|
||||
|
||||
if [[ -z "$ACTIVE_NODE" ]]; then
|
||||
echo ""
|
||||
echo " WARNING: could not determine active node from crm_mon."
|
||||
echo " Pacemaker may still be settling, or both nodes may be in standby."
|
||||
fi
|
||||
|
||||
# ── Quorum ─────────────────────────────────────────────────────────────────
|
||||
section "Quorum"
|
||||
if $REACHABLE_1; then
|
||||
QUORUM=$(n1 "corosync-quorumtool -s 2>/dev/null" 2>/dev/null || echo "")
|
||||
elif $REACHABLE_2; then
|
||||
QUORUM=$(n2 "corosync-quorumtool -s 2>/dev/null" 2>/dev/null || echo "")
|
||||
fi
|
||||
|
||||
if [[ -z "${QUORUM:-}" ]]; then
|
||||
echo " corosync-quorumtool: unavailable"
|
||||
else
|
||||
QUORATE=$(echo "$QUORUM" | grep "Quorate:" | awk '{print $2}' || echo "?")
|
||||
VOTES=$(echo "$QUORUM" | grep "Total votes:" | awk '{print $3}' || echo "?")
|
||||
NEEDED=$(echo "$QUORUM" | grep "Quorum votes:" | awk '{print $3}' || echo "?")
|
||||
echo " Quorate: $QUORATE Votes: $VOTES / Expected: $NEEDED"
|
||||
fi
|
||||
|
||||
# ── DRBD ───────────────────────────────────────────────────────────────────
|
||||
section "DRBD (ha-data)"
|
||||
|
||||
drbd_info_from() {
|
||||
local node=$1 run=$2
|
||||
local role dstate cs pct
|
||||
role=$($run "drbdadm role ha-data 2>/dev/null" 2>/dev/null || echo "unknown")
|
||||
dstate=$($run "drbdadm dstate ha-data 2>/dev/null" 2>/dev/null || echo "unknown")
|
||||
cs=$($run "grep -oE 'cs:[A-Za-z]+' /proc/drbd 2>/dev/null | head -1 | sed 's/cs://'" 2>/dev/null || echo "unknown")
|
||||
pct=$($run "grep -oE \"sync'ed:[[:space:]]+[0-9.]+\" /proc/drbd 2>/dev/null | grep -oE '[0-9.]+$' | head -1" 2>/dev/null || echo "")
|
||||
printf " %-14s role: %-22s dstate: %-25s cs: %s" \
|
||||
"$node" "${role:-unknown}" "${dstate:-unknown}" "${cs:-unknown}"
|
||||
[[ -n "$pct" ]] && printf " syncing: %s%%" "$pct"
|
||||
echo ""
|
||||
}
|
||||
|
||||
$REACHABLE_1 && drbd_info_from "$NODE1" n1 || echo " $NODE1 [OFFLINE]"
|
||||
$REACHABLE_2 && drbd_info_from "$NODE2" n2 || echo " $NODE2 [OFFLINE]"
|
||||
|
||||
# ── Pacemaker (full crm_mon output) ───────────────────────────────────────
|
||||
section "Pacemaker"
|
||||
if [[ -n "${CRM_OUT:-}" ]]; then
|
||||
echo "$CRM_OUT" | sed 's/^/ /'
|
||||
else
|
||||
echo " crm_mon returned no output — trying again without suppression:"
|
||||
if $REACHABLE_1; then
|
||||
n1 "crm_mon -1" || true
|
||||
elif $REACHABLE_2; then
|
||||
n2 "crm_mon -1" || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── XFS mount ─────────────────────────────────────────────────────────────
|
||||
section "XFS Mount ($XFS_MOUNT)"
|
||||
check_mount() {
|
||||
local node=$1 run=$2
|
||||
local status
|
||||
if $run "mountpoint -q '$XFS_MOUNT' 2>/dev/null" 2>/dev/null; then
|
||||
local usage
|
||||
usage=$($run "df -h '$XFS_MOUNT' 2>/dev/null | tail -1 | awk '{print \$3\"/\"\$2\" used (\"\$5\")\";}'" 2>/dev/null || echo "")
|
||||
status="mounted"
|
||||
[[ -n "$usage" ]] && status="mounted $usage"
|
||||
else
|
||||
status="not mounted"
|
||||
fi
|
||||
printf " %-14s %s\n" "$node" "$status"
|
||||
}
|
||||
|
||||
$REACHABLE_1 && check_mount "$NODE1" n1 || echo " $NODE1 [OFFLINE]"
|
||||
$REACHABLE_2 && check_mount "$NODE2" n2 || echo " $NODE2 [OFFLINE]"
|
||||
|
||||
# ── Service ports via VIP ──────────────────────────────────────────────────
|
||||
section "Services via VIP ($VIP)"
|
||||
|
||||
check_port() {
|
||||
local name=$1 port=$2
|
||||
if bash -c "echo >/dev/tcp/${VIP}/${port}" 2>/dev/null; then
|
||||
printf " %-10s port %-5s OK\n" "$name" "$port"
|
||||
else
|
||||
printf " %-10s port %-5s UNREACHABLE\n" "$name" "$port"
|
||||
fi
|
||||
}
|
||||
|
||||
if ping -c1 -W2 "$VIP" >/dev/null 2>&1; then
|
||||
echo " Ping OK"
|
||||
else
|
||||
echo " Ping UNREACHABLE"
|
||||
fi
|
||||
check_port "NFS" 2049
|
||||
check_port "iSCSI" 3260
|
||||
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════"
|
||||
if [[ -n "$ACTIVE_NODE" ]]; then
|
||||
echo " Active: $ACTIVE_NODE Standby: $STANDBY_NODE"
|
||||
else
|
||||
echo " Active: unknown (Pacemaker not settled)"
|
||||
fi
|
||||
echo "════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
Executable
+276
@@ -0,0 +1,276 @@
|
||||
#!/usr/bin/env bash
|
||||
# resize-data-disk.sh — online resize of the HA cluster data disk
|
||||
#
|
||||
# Three-phase process (all online-safe, no downtime required):
|
||||
# 1. Proxmox: grow scsi1 on both HA VMs (qm resize)
|
||||
# 2. Guest: rescan block device on both nodes so the kernel sees the new size
|
||||
# 3. DRBD + XFS: drbdadm resize, then xfs_growfs — active node only
|
||||
#
|
||||
# Usage:
|
||||
# scripts/ha/resize-data-disk.sh --size +20G [--force] [--dry-run]
|
||||
#
|
||||
# --size +NNg amount to grow scsi1 by, e.g. +20G, +50G (required)
|
||||
# XFS and DRBD cannot shrink; only positive deltas accepted
|
||||
# --force skip the interactive confirmation prompt
|
||||
# --dry-run show what would be done without changing anything
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=../env.sh
|
||||
source "${SCRIPT_DIR}/../env.sh"
|
||||
|
||||
# ── Configuration ─────────────────────────────────────────────────────────
|
||||
NODE1="${NODE1:-ha-server-1}"
|
||||
NODE2="${NODE2:-ha-server-2}"
|
||||
NODE1_IP="${NODE1_IP:-192.168.2.228}"
|
||||
NODE2_IP="${NODE2_IP:-192.168.2.227}"
|
||||
XFS_MOUNT="${XFS_MOUNT:-/srv/ha-data}"
|
||||
DRBD_RESOURCE="${DRBD_RESOURCE:-ha-data}"
|
||||
DATA_DISK_SLOT="${DATA_DISK_SLOT:-scsi1}" # Proxmox disk name (scsi1 = data disk)
|
||||
HA_USER="${HA_USER:-nixos}"
|
||||
PVE_HOST="${PVE_HOST:-${PVE1_HOST}}"
|
||||
PVE_SSH_USER="${PVE_SSH_USER:-${PROXMOX_SSH_USER}}"
|
||||
# By-id symlink for the data disk; basename resolves to the raw block device.
|
||||
# matches variables.nix's haServerDrbdDisk.
|
||||
DATA_DISK_BYID="${DATA_DISK_BYID:-scsi-0QEMU_QEMU_HARDDISK_drive-${DATA_DISK_SLOT}}"
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
pve() { ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 \
|
||||
"${PVE_SSH_USER}@${PVE_HOST}" "$@"; }
|
||||
n1() { ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 \
|
||||
"${HA_USER}@${NODE1_IP}" sudo "$@" 2>/dev/null; }
|
||||
n2() { ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 \
|
||||
"${HA_USER}@${NODE2_IP}" sudo "$@" 2>/dev/null; }
|
||||
|
||||
# ── Argument parsing ───────────────────────────────────────────────────────
|
||||
SIZE=""
|
||||
FORCE=false
|
||||
DRY_RUN=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--size) shift; SIZE="${1:?--size requires a value (e.g. +20G)}" ;;
|
||||
--force) FORCE=true ;;
|
||||
--dry-run) DRY_RUN=true ;;
|
||||
*) echo "Unknown argument: $1"
|
||||
echo "Usage: $0 --size +NNg [--force] [--dry-run]"
|
||||
exit 1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [[ -z "$SIZE" ]]; then
|
||||
echo "ERROR: --size is required (e.g. --size +20G)"
|
||||
echo "Usage: $0 --size +NNg [--force] [--dry-run]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Only positive deltas — qm resize, DRBD, and XFS all refuse to shrink.
|
||||
if [[ ! "$SIZE" =~ ^\+[0-9]+(G|M|T)$ ]]; then
|
||||
echo "ERROR: --size must be a positive delta like +20G, +50G, +500M, +2T"
|
||||
echo " (qm resize, drbdadm resize, and xfs_growfs can only grow, not shrink)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DRY_PREFIX=""
|
||||
$DRY_RUN && DRY_PREFIX="[dry-run] "
|
||||
|
||||
echo "════════════════════════════════════════════════════"
|
||||
echo " HA Data Disk Resize — $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo " Size delta: $SIZE • Proxmox: $PVE_HOST"
|
||||
$DRY_RUN && echo " MODE: dry-run — no changes will be made"
|
||||
echo "════════════════════════════════════════════════════"
|
||||
|
||||
# ── Detect active node ─────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Detecting active node..."
|
||||
|
||||
CRM_OUT=""
|
||||
if ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 \
|
||||
"${HA_USER}@${NODE1_IP}" true 2>/dev/null; then
|
||||
CRM_OUT=$(n1 "crm_mon -1" 2>/dev/null || true)
|
||||
fi
|
||||
if [[ -z "$CRM_OUT" ]]; then
|
||||
if ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=5 \
|
||||
"${HA_USER}@${NODE2_IP}" true 2>/dev/null; then
|
||||
CRM_OUT=$(n2 "crm_mon -1" 2>/dev/null || true)
|
||||
fi
|
||||
fi
|
||||
|
||||
# crm_mon 2.x formats Promoted lines as " * Promoted: [ node ]" (bullet *),
|
||||
# so ^\s*(Promoted|Masters): never matches; filter Unpromoted first instead.
|
||||
ACTIVE_NODE=$(echo "$CRM_OUT" | grep -v 'Unpromoted\|Unmanaged' | \
|
||||
grep -E '(Promoted|Masters):' | \
|
||||
grep -oE '\b(ha-server-[0-9]+)\b' | head -1 || true)
|
||||
|
||||
if [[ -z "$ACTIVE_NODE" ]]; then
|
||||
echo "ERROR: could not determine active node from crm_mon."
|
||||
echo " Is Pacemaker still settling? Try running scripts/ha/health.sh first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$ACTIVE_NODE" == "$NODE1" ]]; then
|
||||
ACTIVE_IP="$NODE1_IP"
|
||||
na() { n1 "$@"; }
|
||||
else
|
||||
ACTIVE_IP="$NODE2_IP"
|
||||
na() { n2 "$@"; }
|
||||
fi
|
||||
|
||||
echo " Active node: $ACTIVE_NODE ($ACTIVE_IP)"
|
||||
|
||||
# ── Pre-check DRBD state ───────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Pre-checks..."
|
||||
|
||||
DRBD_DSTATE=$(na "drbdadm dstate ${DRBD_RESOURCE} 2>/dev/null" 2>/dev/null || echo "unknown")
|
||||
if ! echo "$DRBD_DSTATE" | grep -q "UpToDate/UpToDate"; then
|
||||
echo " WARNING: DRBD dstate is '$DRBD_DSTATE' (expected UpToDate/UpToDate)."
|
||||
echo " Resizing with a partially-synced disk may cause issues."
|
||||
if ! $FORCE; then
|
||||
echo " Use --force to proceed anyway."
|
||||
exit 1
|
||||
fi
|
||||
echo " --force specified — proceeding despite non-ideal DRBD state."
|
||||
else
|
||||
echo " DRBD dstate: $DRBD_DSTATE — OK"
|
||||
fi
|
||||
|
||||
# ── Find VMIDs on Proxmox ─────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Looking up VM IDs on ${PVE_HOST}..."
|
||||
|
||||
QM_LIST=$(pve "qm list 2>/dev/null" || true)
|
||||
VMID1=$(echo "$QM_LIST" | awk -v name="$NODE1" '$0 ~ name {print $1}' | head -1)
|
||||
VMID2=$(echo "$QM_LIST" | awk -v name="$NODE2" '$0 ~ name {print $1}' | head -1)
|
||||
|
||||
if [[ -z "$VMID1" ]]; then
|
||||
echo " ERROR: could not find VMID for $NODE1 on $PVE_HOST"
|
||||
echo " qm list output:"
|
||||
echo "$QM_LIST" | sed 's/^/ /'
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$VMID2" ]]; then
|
||||
echo " ERROR: could not find VMID for $NODE2 on $PVE_HOST"
|
||||
echo " qm list output:"
|
||||
echo "$QM_LIST" | sed 's/^/ /'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo " $NODE1: VMID $VMID1"
|
||||
echo " $NODE2: VMID $VMID2"
|
||||
|
||||
# ── Confirm ────────────────────────────────────────────────────────────────
|
||||
if ! $FORCE && ! $DRY_RUN; then
|
||||
echo ""
|
||||
echo " Plan:"
|
||||
echo " Phase 1 — qm resize $VMID1 ${DATA_DISK_SLOT} ${SIZE} (on $PVE_HOST)"
|
||||
echo " qm resize $VMID2 ${DATA_DISK_SLOT} ${SIZE} (on $PVE_HOST)"
|
||||
echo " Phase 2 — block device rescan on $NODE1 and $NODE2"
|
||||
echo " Phase 3 — drbdadm resize + xfs_growfs on $ACTIVE_NODE"
|
||||
echo " No downtime required (all operations are online-safe)."
|
||||
printf " Proceed? [y/N] "
|
||||
read -r ANSWER
|
||||
[[ "${ANSWER,,}" == "y" || "${ANSWER,,}" == "yes" ]] || { echo "Aborted."; exit 0; }
|
||||
fi
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Phase 1 — Resize both VM data disks in Proxmox
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
echo ""
|
||||
echo "── Phase 1 — Proxmox disk resize (${DATA_DISK_SLOT} ${SIZE} on both VMs) ──"
|
||||
|
||||
echo " ${DRY_PREFIX}qm resize $VMID1 ${DATA_DISK_SLOT} ${SIZE} ($NODE1 on ${PVE_HOST})"
|
||||
if ! $DRY_RUN; then
|
||||
pve "qm resize $VMID1 ${DATA_DISK_SLOT} ${SIZE}"
|
||||
fi
|
||||
|
||||
echo " ${DRY_PREFIX}qm resize $VMID2 ${DATA_DISK_SLOT} ${SIZE} ($NODE2 on ${PVE_HOST})"
|
||||
if ! $DRY_RUN; then
|
||||
pve "qm resize $VMID2 ${DATA_DISK_SLOT} ${SIZE}"
|
||||
fi
|
||||
|
||||
echo " Phase 1 done."
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Phase 2 — Rescan block device on both guest nodes
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
echo ""
|
||||
echo "── Phase 2 — Block device rescan (both nodes) ──"
|
||||
|
||||
rescan_node() {
|
||||
local node_name=$1 run_fn=$2
|
||||
|
||||
# Resolve block device name from the stable by-id symlink on the guest.
|
||||
local blk_dev=""
|
||||
if ! $DRY_RUN; then
|
||||
blk_dev=$($run_fn "bash -c 'basename \$(readlink -f /dev/disk/by-id/${DATA_DISK_BYID})'" 2>/dev/null || true)
|
||||
fi
|
||||
if [[ -z "$blk_dev" ]]; then
|
||||
# Fall back: scsi1 → index 1 → sdb, scsi2 → sdc, etc.
|
||||
local slot_idx
|
||||
slot_idx=$(echo "$DATA_DISK_SLOT" | grep -oE '[0-9]+$' || echo "1")
|
||||
blk_dev=$(printf "sd%s" "$(echo "abcdefghij" | cut -c$((slot_idx + 1)))")
|
||||
echo " WARNING: could not resolve /dev/disk/by-id/${DATA_DISK_BYID} on $node_name;" \
|
||||
"falling back to /dev/${blk_dev}" >&2
|
||||
fi
|
||||
|
||||
echo " ${DRY_PREFIX}Rescanning /dev/${blk_dev} on ${node_name}..."
|
||||
if ! $DRY_RUN; then
|
||||
$run_fn "bash -c 'echo 1 > /sys/block/${blk_dev}/device/rescan'" 2>/dev/null
|
||||
local new_size
|
||||
new_size=$($run_fn "lsblk -nd -o SIZE /dev/${blk_dev} 2>/dev/null" 2>/dev/null || echo "unknown")
|
||||
echo " /dev/${blk_dev} on $node_name now reports: $new_size"
|
||||
fi
|
||||
}
|
||||
|
||||
rescan_node "$NODE1" n1
|
||||
rescan_node "$NODE2" n2
|
||||
|
||||
echo " Phase 2 done."
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Phase 3 — Grow DRBD metadata, then XFS (active node only)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
echo ""
|
||||
echo "── Phase 3 — DRBD resize + XFS grow (on active node: $ACTIVE_NODE) ──"
|
||||
|
||||
echo " ${DRY_PREFIX}drbdadm resize ${DRBD_RESOURCE}"
|
||||
if ! $DRY_RUN; then
|
||||
na "drbdadm resize ${DRBD_RESOURCE}"
|
||||
fi
|
||||
|
||||
echo " ${DRY_PREFIX}xfs_growfs ${XFS_MOUNT}"
|
||||
if ! $DRY_RUN; then
|
||||
na "xfs_growfs ${XFS_MOUNT}"
|
||||
fi
|
||||
|
||||
echo " Phase 3 done."
|
||||
|
||||
# ── Verify ────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "── Verify ──"
|
||||
|
||||
if ! $DRY_RUN; then
|
||||
DF_OUT=$(na "df -h '${XFS_MOUNT}' 2>/dev/null" 2>/dev/null || echo "")
|
||||
if [[ -n "$DF_OUT" ]]; then
|
||||
echo " ${XFS_MOUNT}:"
|
||||
echo "$DF_OUT" | sed 's/^/ /'
|
||||
fi
|
||||
|
||||
DRBD_DSTATE_AFTER=$(na "drbdadm dstate ${DRBD_RESOURCE} 2>/dev/null" 2>/dev/null || echo "unknown")
|
||||
echo " DRBD dstate: $DRBD_DSTATE_AFTER"
|
||||
if ! echo "$DRBD_DSTATE_AFTER" | grep -q "UpToDate/UpToDate"; then
|
||||
echo " NOTE: DRBD is resyncing — normal immediately after resize."
|
||||
echo " Monitor: ssh nixos@${ACTIVE_IP} 'sudo watch -n3 cat /proc/drbd'"
|
||||
fi
|
||||
else
|
||||
echo " [dry-run] would verify df -h ${XFS_MOUNT} and drbdadm dstate on $ACTIVE_NODE"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════"
|
||||
echo " Resize complete."
|
||||
echo " Active node: $ACTIVE_NODE"
|
||||
echo "════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
@@ -1,22 +1,22 @@
|
||||
{
|
||||
"data": "ENC[AES256_GCM,data:Dqb0FfDXcSofPjHC6wuSjjHJ1VLtZ4RMqU71xBgxOuR+mV6fFP0LoNZcZp0an28XHyf3qLDwnGWvPMf8d9kYNpP0WC+nEqCDvtQjkraJ77j5y57+UNOqdM3tkJ85z5Cm0cQd2DoV4es/mtX6jzDotWW06ESCHtpcefz5SZ4PsYREIT09LmnHdJQ1sFYS0SQxtGa8oLSu7fcobgvV043GS35pq9BICHpCcUzr2LgpDFDpNGU1vSWN9QmwnmKpuf3kQKOYmCJLFsOn96cGnMbgsWc6U3zMOTwOHxgsracG169hlSS8fH7xLWWNgDDq+ILmREsWac5K680j98UXrSbn9g==,iv:iPlhl0GYlLFZOxdU+cHzCoVEkKYbg/X1XTP0lf0Mo0M=,tag:64U+dJ/vO3Q2BMkrtNI6Gw==,type:str]",
|
||||
"data": "ENC[AES256_GCM,data:JwjmYg0qzoer+8/jv7KOfK/BxmpObHOn04GNsUFQd6hSFBWbQlRbODfluL54hnWQ4gsS9MHgwWh8nrLciBhye05kRQCqADB7Crm0CZYubNb65WzM/devh14jmerc3MYIp1M3VmDisML3x5IqhJkFMSqKM/VaNAC3f0otcVR8TTgZw/fJyvPlfqpPTjikGmo5xg++Yk7Cy8A1Dj70kYk10+EQjQ78jf4k/agLoaS+YvMmKXFk4EljtHg8Fe5H6Rn9nfMSSKGTZeAH3Riix2e+fIH+nWEZiPHC0UlPjJrB3SeBqqGRSUUJshVKHFIdKI1Tq2Ea+GDWrAyIRI0qO8e7WQ==,iv:mr39NxbZuYUDHDw0e+fJDshbt9R1hLgVqkUxUVX/+l0=,tag:u31xZRPfvH5j3Sw5U8/nqg==,type:str]",
|
||||
"sops": {
|
||||
"age": [
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBpcjBTMVBHZE1MSWhuakVo\nLzJiYm0xYzdzL2RETllTSDBqSU5qZ05rR3g4CjJGV21aU0xSbzlJekg5RDFtUVdX\nSjRWRUdLRnZTVHk5U2pqVytQbXQySUUKLS0tIFVJZXp5Vll0aUczR1pRT3RSeitX\nckYvSFZoTmNrLzgrNTUyYk9vTkJ5UzQK3f5ShqRPTfdonFkHfXc7RWDgBZlhX1+u\npyWitr+t8O26hTPNBDy3jA1mascupr7r8VCVQkgU6rA8ujitAK7x9g==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBtVW5ENXlkUW83elc3ZnhE\nOEZ4MWIvQWZ4aXhuUmZGYXZNQnhJemsvOWlVCjIwNmFjS3pJOWEwRnhBOGNRamlM\ncXZid1RNYVBXOFZpMlpnU3ZnVE1scnMKLS0tIGFhWFZFd2JPTFNrV1VHSDVKVHRK\nbmhoeFh6YlUySU1ZRVhHbjZnM1IxMFkKo7aHkz2pEeV64m+OEkBZ2V1e+PUzoChu\nUc/Mnh37dNXSSJtg2KnocHdyzDGi1yQbA72xKTxx6QjYJWrAC/jurg==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age1njap586hc0q43kr03g6c8eqhdsmk8zcafkl3f83xwlc2gqhlmfgs4tmwad"
|
||||
},
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBxZUZXZkx1bVNvUnZMRFRQ\nd2N4QlBRTnoyaHpYWjZxN3JvUUJybW81Wm1BCm9oanJSM1RJemhtZUpIczIrYzFq\neVVjTnFkeUgvaVhDRGtzczJqdkdzL3MKLS0tIGVBRTZHWmw5aE9kcmpTMkkzbFg0\nT0dRVGRoYk1XWnBOWk84QTZzOFZ6UlUKpJieegDxYg5HmhH7HUptoff0UqYqUsIc\nBq3b5ChaZwSScbQzYz+18kBvdGgBEx4fB1q8vY+8SC+wJ5MVMh8lSQ==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBReWRLb09reXUxS0YyMUVV\nQlR2bEdYY08rK0RoR2pIelpLVEtYL0pqNUVNCklRaDNoSjBWT20xbk1UdmNqWHNt\nR3JoSlVwODVMUVBzMERUNktHMVViZm8KLS0tIHc0SXRIS1IreDF1VCthb0FBR0Na\ncGFRWTROUHF3clFtTmY1azEwNUpPNzQKsgCND/BZcMTgBTAcnHunQcT6LG1jNrtO\n+W7Yx7bFtFBajWnRYiNpUZPibQJlv5SE9os47WDH1gs86xftgV+uyA==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age1k73g8x47hs93wcv7qh92n3htz8pl295g49hyvlrf3570mts0hgys5g04d6"
|
||||
},
|
||||
{
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBNWUxqT3BxSXF5bkxsUTRy\nTWZVei9JTHFhcmNNUkRjcklzV0Y1VllXQ0hRCjVTRmQ4WHFmUnM0RzNKYnF1dE4y\nOU1mQ3ZDcjZoVDd4VnlodHR5c1Q2UTgKLS0tIGhhbU1kWmlrWHZPVVZMOVU3RWFV\nM0txajIwK01NNElITXZDWU9KMGJDOVkK8k5hLKWoFEvfNT6TctSHPuIAZu9xj4t8\n5unQOFkAShHx6VcTz0vM8xMGa7bvTfoDHce/HXN42vbuVhvYJUzJRg==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBGbllNTlhxNVVkd01GSUNi\nR0lxQWZoRXI5NWxoTExRUnBSb2FPR1BONnpzCkxCN3pNOXVrTW5GR2J0WFhZUE9r\ncm1QTThINDVVVlZEalJiS1RXcHJRS28KLS0tIHV6aGh5QjB0M1VPWDVMT1k3cTBh\nNTNGY25NV0tWSUl3UHlJSExFQVdpVDQK3ARj8xhFRYU6oqYxNQ5+Ryza86ALNUwX\n7yK/8ATnquYC8/ZIYUbgTPxzsocmXX9lVT0+ktIALqjtG8PgNTlOow==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||
"recipient": "age1fefy6dk8zn5c3edwmrs9vwx79quftnt784m628t9e34q3ft3cehqz8u72r"
|
||||
}
|
||||
],
|
||||
"lastmodified": "2026-07-28T13:48:23Z",
|
||||
"mac": "ENC[AES256_GCM,data:+2LDHPXVbrjBfIFHjo5Oh+enWy3j0zt85+MMxbMIKOtHOMadXuodlfJN7Igr6GKlMxsP8KvCemfTTL07xuS6T69mrWt8Lpsc/SmA8Xabl99BXtT9oAjD8PlZ85DjBelhuILZ2j1dMW9b1P3b5qfiLvRKnGXw5gqZvuyKmLI9q/k=,iv:FTCyYwi813j3UVMnHPCmMcJwqSkHO4NELsQ7oZlBZk0=,tag:C0luCrScx+23u8RJUeHZYg==,type:str]",
|
||||
"lastmodified": "2026-07-28T16:04:51Z",
|
||||
"mac": "ENC[AES256_GCM,data:9X0dkAEGJiug16LDy/8//QOkHITTzt4zwTKiIU13tRgSRXRpt/7b27+3eDauscXDMamg5wbYo1YF0+VwLl21Ip4icnnwrZBHd6HWleP30HTgef8rmo21SstshnclZz6RH8SEGVDO/vjrMDChaZ3A2WD+nbcomw47DpK3cG3kBIk=,iv:G7CScSD5Z1Z9WMEMydyeGK/RD4W43xA0PlcpmTCxLOc=,tag:DZmby/xkxpinoJztZZRWwA==,type:str]",
|
||||
"version": "3.13.3"
|
||||
}
|
||||
}
|
||||
+17
-10
@@ -40,17 +40,24 @@
|
||||
# from scratch.
|
||||
nixCacheHostKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICuHUxGNH6ei3BZD+EfZs3l4X8uJNcjQiOsM/G4yo4O/ lxc-nix-cache";
|
||||
|
||||
# Beszel hub's SSH public key — used by every agent to authenticate the
|
||||
# hub's incoming connection. Update if the docker host is ever rebuilt and
|
||||
# the hub generates a new keypair.
|
||||
beszelHubKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFPR9kwtC4TAeTRu46A7+opZsYpxqkRJ+x/ZyB2GWCeG";
|
||||
|
||||
# Public keys authorized to SSH in as remoteBuilderUser on the nix-cache
|
||||
# host (modules/nix-cache/server.nix) — one per client host that's allowed
|
||||
# to use it as a distributed builder.
|
||||
remoteBuilderAuthorizedKeys = [
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFDEA1S2ikpObREgbP5uVBWMxIOGbY8B+Wx7VTZK1m6t root@server"
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPAYIT9ormlmxZ0SziyDQaUntnKI8HK9/s3Qac1ZKjP2 root@docker"
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKKKzoEPl/ZW9KBRHBcp6/ThOngGpwMv5EhkTlgC4aDf root@nixos"
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIGtOWOCS+ImHc7NehguoyD7PbonGosKMZqc9+QR3v/h root@nixos"
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHxXTQxFnArK5HXG7czeoybZebCGfxpUdusJkPn+BCSp root@server"
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK+ioWPhHixlgCB9KIQ0QTHTz6A+Oo2F3uKiINLip5rO root@docker"
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICMJhrfFayLBG+gWtO6oAvgambw5nWWgztiTFEaaaVRH debian@surface"
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJEj26SL/emsVjW2YhRucJVp2kTz8WgcEQgjBEBLRikk root@claude"
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAII/rLceRhnDobVXQYiPceuhDHHvVjFQ1pc9A6un/eUlA root@server"
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHj11bLPpRzH2oslnwFzEvY9cSgfEFtSZbLQaDm4nZMK root@pxe-boot"
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH4/Sesm8NYpj73R0cbGhI0Ubvz73vIVWAnbEDTlBTdh root@tor-relay"
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIBCFUUtePndW7pqtlawft1QCdHmBVs3O/c8EJO+RcXV root@tailscale-router"
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBZ9WKKAlP9Z7GQdgaZ1Xgw9C+vja2lqEZO5rJFpVqYN root@ha-server-1"
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEGNKlaaMckd8nLWNGz4B2QokXjnnIvM+rEUv+R6h0sp root@ha-server-2"
|
||||
];
|
||||
|
||||
# Admin SSH public key, authorized on the primary user of every host and
|
||||
@@ -108,11 +115,11 @@
|
||||
haStoragePrefixLength = 29; # storage subnet prefix length (/29)
|
||||
haStorageRoot = "/srv/ha-data"; # XFS-over-DRBD mount point on the Active node
|
||||
haIscsiIqn = "iqn.2026-01.home.sweet:ha-storage";
|
||||
# DRBD backing device on each node. Disk ordering can differ between Proxmox
|
||||
# VMs depending on the order disks were added; these must match the actual
|
||||
# block device that is NOT the OS disk on each node (verify with lsblk).
|
||||
haServer1DrbdDisk = "/dev/sdb"; # data disk on ha-server-1 (OS disk is /dev/sda)
|
||||
haServer2DrbdDisk = "/dev/sda"; # data disk on ha-server-2 (OS disk is /dev/sdb)
|
||||
# DRBD backing disk — identified by SCSI controller path so it resolves to the
|
||||
# correct block device regardless of OS-level naming (sda vs sdb can differ
|
||||
# between Proxmox VMs depending on disk-add order). drive-scsi1 is always the
|
||||
# dedicated data disk on all HA nodes; drive-scsi0 is the OS disk.
|
||||
haServerDrbdDisk = "/dev/disk/by-id/scsi-0QEMU_QEMU_HARDDISK_drive-scsi1";
|
||||
|
||||
# Storage
|
||||
storageRoot = "/tank"; # ZFS pool root on `server`
|
||||
|
||||
Reference in new issue
Block a user