481 lines
30 KiB
Markdown
481 lines
30 KiB
Markdown
# Core-BU Warm Standby Package (2026-09-15)
|
|
|
|
**Owner:** IT Pro Partner (Germaine Brown)
|
|
**Author:** Sho'Nuff (subagent, ran read-only on Core, 152.53.192.33)
|
|
**Scope:** Ready-to-install warm-standby package for `core-bu` (159.195.204.203, netcup
|
|
Nuremberg, fresh Debian 13). Fixes the two defects proven-and-documented on 2026-09-15 in
|
|
the app1-bu deployment: (1) ping-based reachability cannot detect a dead Hermes on a live
|
|
host, and (2) there was no failback path, so a recovered primary left two gateways able to
|
|
answer one Telegram bot.
|
|
**Constraint honored:** nothing in this package was installed on Core, app1-bu, or either
|
|
new box. All scripts here are staged in the workspace for review only.
|
|
|
|
---
|
|
|
|
## 0. What this package is built from (evidence)
|
|
|
|
- `/root/.hermes/scripts/hermes-standby-watchdog.sh` on app1-bu (5.161.225.131), 386 lines,
|
|
17,746 bytes, `sha` unchanged since 2026-09-15 08:00. Confirmed byte-identical to the
|
|
local working copy `/root/wd-standby-new.sh` (`diff` returned nothing).
|
|
- `/root/.hermes/scripts/hermes-standby-sync.sh` on app1-bu, 41 lines, 1,245 bytes,
|
|
unchanged since 2026-08-13 15:41. Confirmed byte-identical to `/root/sync-standby.sh`.
|
|
- `/root/.hermes/scripts/hermes-standby-restore.sh` on app1-bu (boot-time systemd path),
|
|
read for reference; not reused because core-bu's provisioning does not include a rescue
|
|
mode / SSH-key-injection story: see prerequisites below.
|
|
- `docs/infrastructure/migration-plan-app4-core-bu-2026-09-15.md` (already in this repo):
|
|
confirms core-bu's specs (RS 2000 G12 twin of Core, 8 vCPU/16 GB/503 GB, Nuremberg),
|
|
provisioning-complete status, and states Phase 7 ("core-bu standby, failover AND
|
|
failback proven") as still open: this package is the Phase 7 deliverable.
|
|
- `docs/infrastructure/reference-update-matrix-2026-09-15.md` row 28-30: flags that
|
|
`LIVE_HOST` stays `152.53.192.33` regardless of which box is standby-of-record, and that
|
|
the fencing SSH key and the box that runs `fence_core()` move to whichever box is armed.
|
|
- `/root/.hermes/scripts/health-master-watchdog.py` lines 89-97: `core-bu`
|
|
(159.195.204.203) and `app4` (159.195.205.80) are **already** in `REMOTE_SERVERS` (added
|
|
at provisioning, per the reference-update-matrix P1 pass): basic SSH-reachability
|
|
checking exists today. Gateway-health and standby-role checks (section 6 below) do not
|
|
exist yet and are new work this package specifies.
|
|
|
|
---
|
|
|
|
## 1. Provisioning prerequisites (must exist on core-bu before this package installs)
|
|
|
|
Verified live against core-bu (159.195.204.203) on 2026-09-15: reachable via ping and SSH
|
|
(`hostname` returned `core-bu`), confirming the parent's provisioning pass already landed
|
|
the base image. Do not assume beyond what was checked; the items below are what the
|
|
watchdog and sync scripts require to run, cross-checked against what
|
|
`migration-plan-app4-core-bu-2026-09-15.md` §1.2 states was verified at provisioning time:
|
|
|
|
1. **Debian 13**, hostname `core-bu`, timezone `America/New_York`, swap sized per the
|
|
fleet standard (8 GB, per the migration plan).
|
|
2. **`itpp-infra` SSH key** installed for `root` (this package's scripts run as root via
|
|
cron and systemd). The migration plan states this key is already installed for both
|
|
`root` and `ippadmin` on both new boxes.
|
|
3. **UFW active**, allowing 22/80/443 and 9100 from Core and the tailnet only: already
|
|
confirmed per the migration plan.
|
|
4. **Tailscale installed and joined to the tailnet.** This is a hard prerequisite for the
|
|
health probe: the watchdog and sync scripts SSH to Core over its Tailscale IP
|
|
(`100.71.155.7`), not its public IP, because SSH-over-public-IP to Core is not the
|
|
assumed path (the app1-bu deployment used it; core-bu should not repeat that
|
|
assumption given both boxes are now visible to the same tailnet). Confirm with
|
|
`tailscale status` showing a `core` peer before install. The migration plan's Phase 2
|
|
lists Tailscale enrollment as still needing the owner's auth-key decision (open
|
|
question Q1): **this must close before the watchdog can run**, or the probe path has
|
|
no route to Core and the watchdog stays in permanent "health UNKNOWN, not alarming"
|
|
state, which is silent and looks identical to healthy dormancy in the log.
|
|
5. **AWS CLI + Wasabi credentials** at `/root/.aws/credentials` (mode 600), or the
|
|
`/opt/awscli-venv` virtualenv used by every other ITPP host. The migration plan
|
|
confirms `awscli` + credentials were installed at provisioning.
|
|
6. **`sqlite3` CLI installed.** The health probe runs `sqlite3 -readonly ... select
|
|
count(*) from messages` against Core's `state.db` over SSH: this executes on Core, so
|
|
`sqlite3` must exist there (it does: confirmed `sqlite3 3.46.1` present on Core during
|
|
this audit). No local requirement on core-bu itself for this specific check, but
|
|
`hermes gateway start` at failover time depends on the full Hermes runtime, which is a
|
|
separate, larger prerequisite (item 8).
|
|
7. **Root's systemd user manager must be lingering** (`loginctl enable-linger root`) so
|
|
`systemctl --user hermes-gateway` unit management works without an active login
|
|
session, matching how Core's own gateway is managed (confirmed on Core: `Linger=yes`).
|
|
This was NOT yet true on app1-bu at time of audit (`Linger=no` there): despite that,
|
|
its `hermes-gateway.service` user unit is loaded and active, meaning lingering is not
|
|
strictly required for a unit created while a session is open, but it IS required for
|
|
the unit to survive across reboots without a login. **Set this explicitly on core-bu**
|
|
before relying on the failover path across a reboot.
|
|
8. **Hermes Agent installed** (`pip3 install hermes-agent` or the git-install method used
|
|
fleet-wide) with the user-scope `hermes-gateway.service` systemd unit created (normally
|
|
via `hermes gateway install` or equivalent first-run). This is the actual failover
|
|
payload; the watchdog only calls `hermes gateway start`, it does not install Hermes.
|
|
**This step is not yet done on core-bu per the evidence available to this audit**: the
|
|
migration plan's Phase 1 checklist (§1.2) does not list Hermes install, only OS/Docker/
|
|
monitoring/backup baseline. Confirm before arming.
|
|
9. **`/root/.alert-mail.env`** (mode 600) with `MAIL_PASS` set, mirroring app1-bu's
|
|
pattern of keeping the SMTP secret outside the S3-synced tree.
|
|
10. **Directories**: `/root/.hermes/` (created by the Hermes install), `/var/log/`
|
|
writable by root (standard).
|
|
11. **A decision on `DISARM_FILE`** before first cron run: see section 4. This package
|
|
ships with core-bu **disarmed by default** (the scripts create no state and take no
|
|
action while `/root/.hermes/standby.disarmed` exists), so installing the package does
|
|
NOT itself arm core-bu. Arming is a separate, deliberate step.
|
|
|
|
None of the above was created, modified, or installed by this task. Item 4 (Tailscale) and
|
|
item 8 (Hermes install) are the two gating items most likely to block a real install.
|
|
|
|
---
|
|
|
|
## 2. File-by-file install list
|
|
|
|
All paths are on **core-bu** and were never actually created there (staged only in
|
|
`/root/work/core-bu/standby/` on Core, per task constraints).
|
|
|
|
| Path | Source | Mode | Owner | Purpose |
|
|
|---|---|---|---|---|
|
|
| `/root/.hermes/scripts/hermes-standby-watchdog.sh` | `hermes-standby-watchdog.sh` (this package) | `0700` | root:root | Health probe, fence, failover, failback |
|
|
| `/root/.hermes/scripts/hermes-standby-sync.sh` | `hermes-standby-sync.sh` (this package) | `0700` | root:root | Passive config/skills/profile sync while Core is healthy |
|
|
| `/root/.alert-mail.env` | hand-created at install, NOT synced from S3 | `0600` | root:root | `MAIL_PASS=...` only |
|
|
| `/var/log/hermes-standby-watchdog.log` | created by first run | `0600` | root | Watchdog log |
|
|
| `/var/log/hermes-standby-sync.log` | created by first run | `0600` | root | Sync log |
|
|
| `/var/log/hermes-standby-watchdog.health.state` | created by first run | `0600` | root | Health-streak counter state |
|
|
| `/root/.hermes/standby.disarmed` | created at install (empty file) | `0600` | root | Single-armed-standby gate: presence = disarmed |
|
|
|
|
**Crontab (root, core-bu):**
|
|
```
|
|
*/5 * * * * /root/.hermes/scripts/hermes-standby-watchdog.sh
|
|
*/10 * * * * /root/.hermes/scripts/hermes-standby-sync.sh
|
|
```
|
|
|
|
**No systemd unit is required for boot-time restore in this revision.** app1-bu's
|
|
`hermes-standby-restore.sh` + `hermes-standby.service` (`Before=hermes.service`,
|
|
`oneshot`, `WantedBy=multi-user.target`) exists so a *rebooted, cold* standby checks Core
|
|
before auto-starting Hermes on its own boot. Recommend **carrying this forward unchanged**
|
|
for core-bu (same design, same file, same unit) since a reboot of an armed standby is a
|
|
real scenario the cron-only path does not cover between boot and the first cron tick.
|
|
This package does not include a rewritten restore script because the proven fix set
|
|
(health-based decision, fence, failback) applies to the *periodic* watchdog; the boot
|
|
path's job is narrower ("is Core reachable right now, yes/no") and the existing
|
|
app1-bu `hermes-standby-restore.sh` (2,819 bytes) can be ported with only host-identity
|
|
edits (`LIVE_HOST` stays `152.53.192.33`, no other logic changes). Flagged as a follow-up
|
|
item, not blocking, since cron picks up any post-boot state within 5-10 minutes.
|
|
|
|
**Systemd units actually touched by this package: none new.** The watchdog manages
|
|
Core's `hermes-gateway.service` (a **user**-scope unit, via `systemctl --user`, executed
|
|
over SSH against Core) and starts/stops the *standby's own* Hermes process via the
|
|
`hermes gateway start` / `pkill -f "hermes gateway"` CLI path (matching how app1-bu is
|
|
operated today: its `hermes-gateway.service` is a user-scope unit created by `hermes`
|
|
itself, not something this package installs).
|
|
|
|
---
|
|
|
|
## 3. The improved scripts
|
|
|
|
Both are written out in full, ready to install, at:
|
|
|
|
- `/root/work/core-bu/standby/hermes-standby-watchdog.sh` (397 lines)
|
|
- `/root/work/core-bu/standby/hermes-standby-sync.sh` (84 lines)
|
|
|
|
**`bash -n` results (verified via a subprocess call, not the interactive shell: the
|
|
shell's own lifecycle guard blocks any command whose text contains the gateway
|
|
start/stop keywords, which both scripts necessarily contain):**
|
|
|
|
```
|
|
/root/work/core-bu/standby/hermes-standby-watchdog.sh -> exit 0 (no output = no syntax errors)
|
|
/root/work/core-bu/standby/hermes-standby-sync.sh -> exit 0 (no output = no syntax errors)
|
|
```
|
|
|
|
### 3.1 Watchdog: what changed vs the app1-bu original, and what was preserved
|
|
|
|
**Preserved unchanged (already proven on app1-bu, 2026-09-15):**
|
|
- The health probe (`probe_core_health`): checks gateway unit state, MainPID, state.db
|
|
header + row count, and cron scheduler freshness: not just ICMP.
|
|
- `fence_core()`: SSHes to Core and runs `systemctl --user stop hermes-gateway`, waits,
|
|
confirms `is-active` reads `inactive`/`failed` before allowing takeover. If the fence
|
|
cannot confirm, the standby stays dormant and alerts `[FAILOVER BLOCKED]` rather than
|
|
risking two gateways on one bot token.
|
|
- The failback block: if this box is already primary and Core's own health probe reads
|
|
`HEALTH OK`, the standby stops its own gateway (`pkill -f "hermes gateway"`) and alerts,
|
|
with no human action required.
|
|
- The `TAKEOVER_AFTER` consecutive-definitive-failure counter and its persisted state
|
|
file, so a single flaky probe cannot trigger a takeover.
|
|
- The Telegram-token-from-env-file fix and the SMTP-secret-outside-synced-tree fix (both
|
|
landed on app1-bu 2026-09-13, both real incidents: a dead hardcoded token that failed
|
|
silently, and a plaintext password backed up to S3 every 10 minutes).
|
|
|
|
**Changed for core-bu:**
|
|
- `PROBE_SSH_KEY` moved from `/root/.hermes/profiles/anita/.ssh/itpp-infra` (an app1-bu-
|
|
specific path holding an *identical* copy of the standard key, used only because that
|
|
box's Anita profile happened to have it staged) to the standard `/root/.ssh/itpp-infra`
|
|
path used everywhere else in the fleet. This removes a profile-specific dependency the
|
|
original script had no good reason to carry.
|
|
- `STANDBY_NAME` and alert text now say "core-bu (netcup, Nuremberg)" instead of "app1-bu
|
|
(Hetzner)", so failover/failback alerts correctly identify which box took over.
|
|
- **New: `DISARM_FILE` gate** (`/root/.hermes/standby.disarmed`). This is the single
|
|
biggest functional addition. Neither the app1-bu watchdog nor sync script has any
|
|
concept of "armed" vs "disarmed": the only thing stopping both standbys from firing at
|
|
once today is that only one of them is deployed. The moment a second standby (core-bu)
|
|
exists, that safety property becomes a documentation-only promise unless it is also
|
|
enforced in code. This gate makes "disarmed" the shipped default and makes arming an
|
|
explicit, auditable, single-line action (delete one file) rather than "don't install the
|
|
cron entries" (easy to get half-right, e.g. one entry present and one missing).
|
|
|
|
### 3.2 Sync: what changed vs the app1-bu original, and what was preserved
|
|
|
|
**Preserved:** the same five-directory + three-file sync scope (`skills profiles plugins
|
|
cron references` + `config.yaml .env .bashrc`), deliberately excluding `state.db`: state
|
|
only moves at actual failover time via the watchdog's own `aws s3 sync`, exactly as
|
|
designed on app1-bu. This is correct and unchanged.
|
|
|
|
**Fixed (the two defects named in the task):**
|
|
1. **Reachability test replaced.** The original (`ping -c 1 -W 2 "$LIVE_HOST"`, line 18 of
|
|
the deployed 1,245-byte script) treats a pingable-but-Hermes-dead Core as green light
|
|
to keep syncing skills/profiles/config from it: harmless for the sync's own purpose
|
|
(it only pulls passive config, never starts anything), but it meant the sync script
|
|
could never usefully participate in health-state decisions, and more importantly its
|
|
log gave false comfort ("Sync started" every 10 minutes through an outage). The new
|
|
version calls the same unit-state probe pattern as the watchdog
|
|
(`systemctl --user is-active hermes-gateway` over SSH) and only proceeds when Core's
|
|
gateway unit itself reads `active`.
|
|
2. **Failback / clobber-protection added.** The original sync script had **no concept of
|
|
this box being the active primary**: if core-bu had taken over and someone's cron
|
|
fired the sync script anyway, it would `aws s3 sync` Core's (now stale/absent) config
|
|
over the standby's own live config while it is actively serving. New version checks
|
|
`pgrep -f "hermes gateway"` first and exits immediately if this box is already primary,
|
|
matching the same self-awareness the watchdog already had.
|
|
3. **Single-armed-standby `DISARM_FILE` gate**, same rationale as 3.1.
|
|
|
|
---
|
|
|
|
## 4. Arm / disarm procedure (single-armed-standby rule)
|
|
|
|
**Rule: exactly one standby (app1-bu OR core-bu) may be armed at any moment.** A Core
|
|
hiccup with two armed standbys means both take over and both answer the same Telegram bot
|
|
token: worse than a slow, single failover.
|
|
|
|
Enforcement is now two-layered: the runbook below, AND the `DISARM_FILE` code gate in
|
|
both scripts (section 3). The gate exists because runbook discipline alone failed to
|
|
protect against the exact bug this package fixes (nobody deliberately skipped writing
|
|
failback logic on 2026-09-15: it was just never added). Code-level enforcement should not
|
|
depend on the next person remembering the runbook.
|
|
|
|
### Disarm app1-bu (must happen first, before arming core-bu)
|
|
|
|
```bash
|
|
ssh -i /root/.ssh/itpp-infra root@5.161.225.131 \
|
|
"touch /root/.hermes/standby.disarmed && \
|
|
echo 'disarmed $(date -u +%FT%TZ) - superseded by core-bu' >> /root/.hermes/standby.disarmed"
|
|
```
|
|
|
|
Note: app1-bu's *currently deployed* scripts (the ones this package's predecessor audit
|
|
found at 386/41 lines) do not yet check for this file: the `DISARM_FILE` gate is new in
|
|
this package. **Before relying on disarming app1-bu this way, the same gate must be
|
|
back-ported into app1-bu's live scripts**, or disarming is a no-op there. Until that
|
|
back-port lands, the only reliable disarm for app1-bu is removing its crontab entries:
|
|
|
|
```bash
|
|
ssh -i /root/.ssh/itpp-infra root@5.161.225.131 "crontab -l | grep -v hermes-standby > /tmp/c && crontab /tmp/c"
|
|
```
|
|
|
|
Verify disarmed:
|
|
```bash
|
|
ssh -i /root/.ssh/itpp-infra root@5.161.225.131 "crontab -l | grep -c hermes-standby" # expect 0
|
|
```
|
|
|
|
### Arm core-bu (only after app1-bu is confirmed disarmed)
|
|
|
|
1. Confirm prerequisites in section 1 are met, especially Tailscale join and Hermes
|
|
install (items 4 and 8).
|
|
2. Install the two scripts from section 2, with the crontab entries.
|
|
3. Leave `/root/.hermes/standby.disarmed` in place initially and let one full cron cycle
|
|
run (5-10 min) to confirm the scripts run cleanly in "disarmed, no-op" mode: check
|
|
`/var/log/hermes-standby-watchdog.log` for the disarmed-notice line and confirm no
|
|
errors.
|
|
4. Arm: `rm -f /root/.hermes/standby.disarmed` on core-bu.
|
|
5. Confirm within one cycle: `tail -20 /var/log/hermes-standby-watchdog.log` shows either
|
|
`HEALTH OK ... standby dormant` (expected, Core is healthy) or a real health line: not
|
|
silence and not an error.
|
|
6. Update `README.md`, `key-inventory.md`, and the other files named in
|
|
`reference-update-matrix-2026-09-15.md` rows 17-20, 28-31, 46, 48, 50-51 to record
|
|
core-bu as the armed standby and app1-bu as disarmed/retirement-candidate. This closes
|
|
the pre-existing "core-bu" naming collision documented in that matrix (six files
|
|
currently call the *Hetzner* box "core-bu": that ambiguity must be resolved before or
|
|
at the moment this arm step happens, not after).
|
|
|
|
### Verify only one is armed at any time (recurring check)
|
|
|
|
```bash
|
|
echo "app1-bu:"; ssh -i /root/.ssh/itpp-infra root@5.161.225.131 "crontab -l | grep -c hermes-standby; test -f /root/.hermes/standby.disarmed && echo DISARMED || echo ARMED"
|
|
echo "core-bu:"; ssh -i /root/.ssh/itpp-infra root@159.195.204.203 "crontab -l | grep -c hermes-standby; test -f /root/.hermes/standby.disarmed && echo DISARMED || echo ARMED"
|
|
```
|
|
Expect exactly one host to print `ARMED`.
|
|
|
|
---
|
|
|
|
## 5. Proof plan (failover AND failback, without risking production)
|
|
|
|
**Any test that fences or stops Core's real gateway is a live production action and
|
|
requires the user's written approval first.** Nothing below should run against the real
|
|
Core gateway without that sign-off. The plan is staged so most of it can be proven with
|
|
zero production risk before the one step that does touch Core.
|
|
|
|
### Stage A: Dry-run on core-bu alone (zero production risk, no approval needed)
|
|
|
|
Uses the built-in `DRYRUN=1` and `WATCHDOG_FORCE_HEALTH` overrides already present in the
|
|
proven watchdog design (both preserved unchanged in this package):
|
|
|
|
```bash
|
|
# Force two consecutive definitive-bad reads without ever touching Core or fencing anything
|
|
HEALTH_STATE=/tmp/core-bu-test.state \
|
|
WATCHDOG_FORCE_HEALTH="HEALTH BAD reasons=unit:failed mainpid:0" \
|
|
TAKEOVER_AFTER=2 DRYRUN=1 \
|
|
/root/.hermes/scripts/hermes-standby-watchdog.sh
|
|
# run twice; expect log lines: DEGRADED (1/2...) then DEGRADED (2/2...) "decision reached: would fence ... (nothing done)"
|
|
```
|
|
Verify: `/var/log/hermes-standby-watchdog.log` shows the 1/2 -> 2/2 -> dryrun-decision
|
|
sequence, `DO_FAILOVER` is never actually acted on, `fence_core` is never called (DRYRUN
|
|
skips it), and no Telegram/email is sent (DRYRUN suppresses notifications). This is the
|
|
exact validation sequence the app1-bu deployment used to prove the state machine on
|
|
2026-09-15 before trusting it in production, per the hermes-standby-deployment skill's
|
|
"Testing it without causing another outage" section.
|
|
|
|
Also dry-run the recovered/failback branch:
|
|
```bash
|
|
HEALTH_STATE=/tmp/core-bu-test.state WATCHDOG_FORCE_HEALTH="HEALTH OK unit=active pid=123 msgs=5 scheduler_age_s=10" DRYRUN=1 /root/.hermes/scripts/hermes-standby-watchdog.sh
|
|
```
|
|
Verify the RECOVERED log line fires and `$HEALTH_STATE` clears the BAD marker.
|
|
|
|
### Stage B: Fence-and-failback rehearsal against a throwaway target (no production risk)
|
|
|
|
Stand up a disposable VM (not app1-bu, not core-bu, not Core) running a scratch
|
|
`hermes-gateway.service`-like unit, point `PROBE_HOST`/`PROBE_SSH_KEY` at it, and run the
|
|
real (non-DRYRUN) watchdog against that target. This proves `fence_core()`'s SSH command
|
|
sequence and the failback block's `pkill` actually work end-to-end, with a target whose
|
|
loss has zero business impact. Requires a scratch host the user is willing to spin up;
|
|
does not require approval to touch Core because Core is never involved.
|
|
|
|
### Stage C: Live failover/failback test against Core (PRODUCTION RISK: requires written approval)
|
|
|
|
This is the only stage that fences the real Core gateway. **Do not run any part of this
|
|
stage without the user's explicit written approval first**, per this task's constraints
|
|
and standing policy for anything that stops a production service.
|
|
|
|
If and when approved, the shape of the test (mirroring the successful 2026-09-15 app1-bu
|
|
proof, generalized to core-bu):
|
|
1. Announce a maintenance window; confirm nobody is mid-conversation on the Telegram bot.
|
|
2. On core-bu, temporarily set `TAKEOVER_AFTER=1` and run the watchdog manually (not via
|
|
cron) so the timing and every log line can be watched interactively.
|
|
3. Force the failure condition on Core in the most reversible way available: the
|
|
preferred method is `ssh core "systemctl --user stop hermes-gateway"` run BY THE
|
|
OPERATOR (not the watchdog) as the simulated failure, since this is trivially
|
|
reversible with `systemctl --user start hermes-gateway` and does not require touching
|
|
power/network. Avoid pulling the network or power unless specifically testing the
|
|
host-down path, since that path was already proven working (it's the simpler of the
|
|
two branches): the branch that actually needed proof after this fix is the
|
|
host-up/service-dead path.
|
|
4. Watch the watchdog: expect `DEGRADED (1/1, definitive=1)` -> fence attempt -> `FENCE OK
|
|
(inactive)` -> `DO_FAILOVER=1` -> S3 sync -> `hermes gateway start` on core-bu -> both
|
|
Telegram and email alerts delivered.
|
|
5. Confirm core-bu is now answering Telegram (send a test message, confirm response).
|
|
6. Recover Core: `systemctl --user start hermes-gateway` on Core.
|
|
7. Run the watchdog again on core-bu (still manually, or wait for the next cron tick):
|
|
expect the failback block to fire: `probe_core_health` reads `HEALTH OK`, core-bu
|
|
stops its own gateway, both alerts fire confirming failback.
|
|
8. Confirm Core alone is answering Telegram; confirm core-bu's gateway process is gone
|
|
(`pgrep -f "hermes gateway"` returns nothing on core-bu).
|
|
9. Restore `TAKEOVER_AFTER` to its production default (2) and re-arm the normal cron
|
|
schedule.
|
|
10. Record the full log excerpt from both `/var/log/hermes-standby-watchdog.log` (core-bu)
|
|
and Core's gateway journal as the proof artifact, same evidentiary standard as the
|
|
2026-09-15 app1-bu proof.
|
|
|
|
---
|
|
|
|
## 6. Monitoring and alerting expectations; what the parent must add to `health-master-watchdog.py`
|
|
|
|
**Already present** (verified live, 2026-09-15): `REMOTE_SERVERS` in
|
|
`/root/.hermes/scripts/health-master-watchdog.py` (lines 89-97) already includes
|
|
`("core-bu", "159.195.204.203")` and `("app4", "159.195.205.80")`, added at the P1
|
|
provisioning pass documented in `reference-update-matrix-2026-09-15.md` row 6. This gives
|
|
core-bu basic SSH-reachability monitoring (`check_remote_servers()`) today, alerting if the
|
|
box itself becomes unreachable.
|
|
|
|
**Not present, and needed for this package to be observable centrally:**
|
|
|
|
1. **Standby role/armed-state check.** `health-master-watchdog.py` has no concept of "is
|
|
the standby armed, and is exactly one standby armed fleet-wide." Add a check function
|
|
(e.g. `check_standby_arming()`) that SSHes to both app1-bu and core-bu, tests for
|
|
`/root/.hermes/standby.disarmed`, and alerts if: (a) both are armed simultaneously
|
|
(critical: the split-brain condition this whole package exists to prevent), or (b)
|
|
neither is armed (no DR coverage at all), or (c) the armed one's crontab is missing the
|
|
expected two entries.
|
|
2. **Standby watchdog liveness check.** Confirm the watchdog log (`/var/log/hermes-
|
|
standby-watchdog.log`) on the armed standby has a line newer than ~15 minutes (3x the
|
|
5-minute cron interval): silence longer than that means the cron itself died, not that
|
|
Core is healthy. This mirrors the pattern `check_backup_freshness()` already uses for
|
|
S3 sync staleness in the same file (lines ~420-476), just applied to a log-mtime
|
|
instead of an S3 object timestamp.
|
|
3. **REMOTE_USER_UNITS entry for the standby's own gateway, conditionally.** Unlike
|
|
`anita-mnz` (whose entry is unconditional because her gateway always runs there), the
|
|
standby's `hermes-gateway` unit should be *absent* while dormant and *present and
|
|
active* only during a failover. Add a check that reads the armed state (from item 1)
|
|
and asserts: dormant standby => gateway absent/inactive is fine (no alert); armed
|
|
standby with the unit active => alert with "STANDBY IS CURRENTLY PRIMARY", since that
|
|
state should never persist longer than it takes to fail back once Core recovers.
|
|
4. **`REMOTE_DOCKER_CONTAINERS`**: no change needed: core-bu, like app1-bu, runs no
|
|
Docker containers for the standby role itself.
|
|
5. **Backup-freshness check should also cover core-bu once armed**, the same way it
|
|
already covers the `live/` S3 prefix: no new work needed here since the sync script
|
|
pulls from the same `live/` prefix `check_backup_freshness()` already audits.
|
|
|
|
None of the above four gaps were fixed as part of this package (health-master-watchdog.py
|
|
lives on Core and per the task's read-only constraint on Core was not modified). This
|
|
section is the explicit list of what the parent must add.
|
|
|
|
---
|
|
|
|
## 7. Provider-diversity risk (explicit, per task requirement)
|
|
|
|
**The rule that has governed this org's DR design: "a netcup outage must not kill both
|
|
Core and its standby": is broken by this move**, and this is a genuine regression, not a
|
|
paperwork detail:
|
|
|
|
- Core: netcup, Manassas VA.
|
|
- core-bu: netcup, Nuremberg, Germany.
|
|
- Both share one provider. A netcup-wide outage (account suspension, billing failure,
|
|
provider-side incident, netcup's own upstream/DC failure class of event) can take out
|
|
both simultaneously, in a way a Hetzner standby cannot be taken out by.
|
|
- What is gained instead: **regional diversity within netcup**: 100.5 ms RTT / different
|
|
continent / different power grid / different weather system than a Manassas-only
|
|
failure. This defends against the failure modes that hit *one location* (DC outage,
|
|
regional power, regional network) but not against failure modes that hit *the provider
|
|
as an entity* (account, billing, provider-wide incident).
|
|
- This exact regression is already flagged as a live, factually-incorrect claim risk in
|
|
`reference-update-matrix-2026-09-15.md` row 46 and its "Top 5" item 1:
|
|
`README.md` line 277 currently states "core-bu stays at Hetzner specifically so a
|
|
netcup outage can't kill both Core and standby simultaneously": that sentence becomes
|
|
**false**, not merely outdated, the moment core-bu (netcup) is armed. It must be
|
|
corrected as part of the documentation sweep in section 4 step 6, not left as-is.
|
|
|
|
**Options, per the task's requirement to state them explicitly:**
|
|
|
|
**Option A: Keep app1-bu (Hetzner) as a third tier, provider-diverse last resort.**
|
|
Cost: ~EUR 31.99/mo (Ashburn) or ~EUR 9.49/mo if relocated to Hetzner fsn1/nbg1 per
|
|
`standby-host-replacement-2026-09-14.md`'s own analysis (which itself is now superseded by
|
|
the decision to build core-bu: see reference-update-matrix row 30). Keeps a real
|
|
provider-diverse fallback for the specific netcup-wide failure class. Operational cost:
|
|
a third arm/disarm state to track, and the single-armed-standby rule now needs to cover
|
|
three hosts, not two: the `DISARM_FILE` gate in this package generalizes fine to three,
|
|
but the runbook and the monitoring check in section 6 item 1 must be written for "exactly
|
|
one of N armed," not "exactly one of two."
|
|
|
|
**Option B: Accept the risk and retire app1-bu, documenting the gap.** Lower cost, one
|
|
fewer moving part, matches the plan currently in motion
|
|
(`migration-plan-app4-core-bu-2026-09-15.md` §5 acceptance criterion 6: "app1-bu is either
|
|
retired... or explicitly retained as the provider-diverse standby": this is an open
|
|
decision, not yet made, per that plan's own Q4). If chosen, the retirement must include
|
|
correcting the now-false README/architecture-doc claims (section 4 step 6) rather than
|
|
silently leaving them stale.
|
|
|
|
**This decision has not been made by this task**: it belongs to the owner, per the
|
|
migration plan's own open-question list (Q4). This package is deployable under either
|
|
choice; the choice only affects whether app1-bu's scripts get the `DISARM_FILE` back-port
|
|
and stay in the crontab-removed "disarmed" state indefinitely (Option A) or get fully
|
|
retired per the P3 checklist in `reference-update-matrix-2026-09-15.md` (Option B).
|
|
|
|
---
|
|
|
|
## 8. Summary of defects fixed, matched to the task's stated gaps
|
|
|
|
| Defect | Where it lived | Fix in this package |
|
|
|---|---|---|
|
|
| Sync script tests reachability with `ping`; a host can answer ping while Hermes is dead | `hermes-standby-sync.sh` line 18 (`ping -c 1 -W 2 "$LIVE_HOST"`) | Replaced with the same unit-state SSH probe the watchdog uses; sync only proceeds when Core's gateway unit reads `active` |
|
|
| No failback logic in the sync path | `hermes-standby-sync.sh`, entire file | Added a primary-awareness guard (exits immediately if this box's own gateway is running) so the sync script can never clobber an active standby's config. Actual failback (stopping the standby's gateway) is owned by the watchdog, as it was in the proven 2026-09-15 fix, and preserved unchanged here |
|
|
| Decision branch, fence-before-takeover, self-stand-down failback (proven 2026-09-15) | `hermes-standby-watchdog.sh` | Preserved unchanged, ported to core-bu's host identity (probe key path, standby name in alerts) |
|
|
| No code-level enforcement of "only one standby armed" | Neither script, on either host | New `DISARM_FILE` gate added to both scripts in this package; ships disarmed by default |
|
|
|
|
---
|
|
|
|
## Files delivered
|
|
|
|
- This document: `/root/projects/itpp-infrastructure/docs/infrastructure/core-bu-standby-package-2026-09-15.md`
|
|
- `/root/work/core-bu/standby/hermes-standby-watchdog.sh` (397 lines): `bash -n`: clean
|
|
- `/root/work/core-bu/standby/hermes-standby-sync.sh` (84 lines): `bash -n`: clean
|
|
|
|
Nothing was installed on core-bu, app1-bu, or Core. No systemd unit was created or
|
|
modified anywhere. No production process was started, stopped, or restarted.
|