Skip to content

Fix alerting delivery, and deploy observability from CI - #183

Merged
ssavutu merged 5 commits into
mainfrom
feat/observability-autodeploy
Aug 5, 2026
Merged

Fix alerting delivery, and deploy observability from CI#183
ssavutu merged 5 commits into
mainfrom
feat/observability-autodeploy

Conversation

@ssavutu

@ssavutu ssavutu commented Aug 5, 2026

Copy link
Copy Markdown
Member

Follow-on to #182, which merged while this work was still in progress — these five commits landed on that branch after its merge point, so they never made it in.

This matters more than a normal follow-up: main currently documents and configures alerting for Slack, and production has been on Discord since. Everything here is already live on Delta and verified; merging reconciles the repo with the hosts.

What's in it

Commit
9eac385 Send alerts to Discord, not Slack
cfbb593 Move the watchdog to Prometheus + Alertmanager, redesign the messages
2437694 Drop Grafana from the observability stack
bdf949f Deploy the observability stack from CI
5c74568 Auto-deploy the observability Nginx sites

The through-line

The alerting built in #182 pointed at the wrong provider and wouldn't have delivered a single message — the webhook is Discord, not Slack. Fixing that exposed the rest: the watchdog lived in Grafana's provisioned alerting, which meant it would silently vanish when the local Grafana was retired in favour of the central Triangle Grafana. So it moved to Prometheus + Alertmanager, next to the data it reads, and then Grafana came out of the stack entirely.

At which point the remaining problem was that none of it deployed. The observability stack only matched the repo because someone had run docker compose by hand in a directory none of the tooling knew about — a bad property generally, and a worse one for the thing that pages you when the database dies.

Notable decisions, all documented in-tree

  • Discord's native payload, not its Slack-compat shim, which silently drops formatting it doesn't understand.
  • curl --fail in the alert script — without it curl exits 0 on 4xx/5xx, so a revoked webhook looks like success and never reaches the failure log.
  • The deploy copies rather than running in place: actions/checkout resets the runner workspace every deploy and would yank bind-mount sources from under a long-lived stack.
  • up -d does not restart a container when only a mounted file's contents changed — the script fingerprints the tree and restarts explicitly, and skips when nothing changed.
  • The sync is --inplace — plain rsync renames, giving new inodes a running container's bind mount never sees.
  • Nginx sites install into a runner-owned directory behind a root-owned wildcard include, so the runner's sudo stays at exactly nginx -t and nginx -s reload. Transactional: validate before reload, revert and fail otherwise, so no broken file is left for the CMS deploy's next reload.
  • triangle-cms.conf is deliberately excluded from auto-deploy — live site server block, much larger blast radius, changes about never.

Verification

All of it was exercised against production rather than reasoned about: a real failover test (promotion in <10s, CMS served and wrote against the promoted node, rejoin, switch back), Discord delivery confirmed on both paths, and the Nginx deploy tested through all three cases including a deliberately broken config that reverted and exited non-zero with the CMS still serving.

Current live state: DB1 Master / DB2 Slave at equal GTID, semi-sync clients=1, five observability services up, CMS 200 on both slots, both authenticated datasource endpoints 200.

Deploying this

Merging triggers Deploy Delta, which will now run deploy-observability.sh for the first time from CI. It should be a near no-op — the stack is already at these versions from the same script run by hand — but it's the first automated run, so worth watching.

Still open, unchanged: central Grafana wiring (mind the prometheus/loki datasource UIDs) and real backups.

🤖 Generated with Claude Code

ssavutu and others added 5 commits August 4, 2026 22:33
The webhook turned out to be a Discord one
(https://discord.com/api/webhooks/...), so both alert paths were built
against the wrong provider and neither would have delivered.

Uses Discord's native format rather than its Slack-compatibility shim.
Discord will accept Slack-shaped payloads on a /slack URL suffix, but it
discards formatting it does not understand, so the native field renders
correctly and fails honestly:

  - maxscale-alert.sh posts {"content": ...} instead of {"text": ...},
    and uses Discord markdown (**bold**; single asterisks are italics).
  - The Grafana contact point is type "discord", not "slack" pointed at
    a Discord URL.
  - SLACK_WEBHOOK_URL is now DISCORD_WEBHOOK_URL throughout.

Adds --fail to the script's curl. Without it curl exits 0 on a 4xx/5xx,
so a rejected payload or a revoked webhook would have looked like
success and never reached the failure log -- which for an alerting path
is the worst possible failure mode.

Adds deleteContactPoints for the orphaned slack-triangle receiver.
Removing a contact point from a provisioning file does NOT delete it
from Grafana; it would otherwise have sat there indefinitely looking
like a live destination.

Also gitignores .webhook, which was sitting untracked-but-committable in
the working tree.

Verified live on both paths: the MaxScale script posted a test event
with no discord_post_failed line (curl --fail makes that meaningful),
and the Grafana rule fired and delivered with no notifier error, where
the placeholder URL had previously logged a loud failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two changes, both prompted by seeing the alerts land in #alerts.

The MaxScale-down watchdog now lives in Prometheus and Alertmanager
instead of Grafana's provisioned alerting. Delta's Prometheus is a
DATASOURCE for the Triangle Grafana, not part of it, so a Grafana-owned
rule would have disappeared the moment the local Grafana was retired --
while blackbox kept probing, Prometheus kept scraping, and every
dashboard kept looking healthy. The only symptom would have been an
alert that never arrived, during exactly the window nobody was watching
for one. The rule now sits next to the data it reads and does not care
which Grafana is in front. Grafana's noDataState becomes an explicit
MaxScaleProbeMissing rule on absent().

The messages are now Discord embeds rather than walls of bold labels.
The first version dumped the whole annotation as prose and a teammate
replied "do I ignore this?", which is the only review that matters for
an alert. Each one now has a coloured bar (red act now, yellow degraded,
green over), a title that says what happened rather than naming the
event constant, one line of consequence, and the addresses as fields.
MaxScale's [10.248.40.155]:3306 bracket formatting is stripped -- it is
IPv6-safe quoting that means nothing for an IPv4 pair.

Grafana's alerting provisioning is kept as a deletion-only file, because
provisioning never deletes: removing a definition from disk leaves the
object live. Ordering there is load-bearing and I got it wrong first --
deleting a contact point a notification policy still references makes
Grafana exit with "contact-points.referenced" and crash-loop, and
dropping the policies block does not remove the policy. resetPolicies
has to hand routing back to the default first, in a separate pass.

Verified: rule fired -> Alertmanager -> Discord with no delivery error,
and the MaxScale script posted an embed with no discord_post_failed.
Grafana is back to zero alert rules and one built-in contact point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Everything routes to the central Triangle Grafana, which reaches this
stack through the Nginx endpoints in nginx/triangle-{prometheus,loki}
.conf. A second Grafana on Delta only duplicated that.

Safe to remove because the alerting no longer lives there. The
database-tier rules moved to Prometheus + Alertmanager in the previous
commit precisely so they would survive this; pulling Grafana now costs
no alerting at all, only dashboards, and those are the central
instance's job anyway.

Nothing was lost with it. The one dashboard it held, gisbxcj, was
verified byte-identical to the repo copy before removal -- no un-pulled
UI edits. Its datasources pointed at compose-internal DNS
(http://prometheus:9090), so they were local-only and never portable to
the central instance.

The dashboard JSON moves to observability/grafana/dashboards/ and stays
the source of truth to import centrally. The provisioning tree is gone:
datasources were local-only, and the alerting file existed solely to
delete what earlier commits had created, which it has now done.

Documents the trap that migration turns on: the dashboard hard-binds to
datasource UIDs "prometheus" and "loki" -- 16 panel references and 3 --
and Grafana assigns a random UID to anything created through the UI. Get
it wrong and the dashboard imports cleanly and renders empty, with no
error outside the panels themselves.

pull-dashboards.sh now points at whichever Grafana holds the dashboards
rather than a local one that no longer exists.

Verified after removal: Prometheus healthy with both rules loaded and
Alertmanager attached, blackbox probe returning 1, Loki still serving.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The stack was the last thing on Delta that only existed because someone
had run docker compose by hand, out of a directory nobody's tooling knew
about. That is a bad property for anything, and a worse one for the
thing that pages you when the database dies: it drifts from the repo
silently, and the first symptom is an alert that never arrives.

deploy-observability.sh now runs as a Deploy Delta step, after the CMS
deploy. It syncs observability/ and compose.observability.yml from the
runner's checkout into ~triangle-runner/triangle-observability and runs
Compose from there.

It copies rather than running in place because actions/checkout resets
the checkout on every deploy, which would yank the bind-mount sources
out from under a long-lived stack -- the reason the old README told
people to install it somewhere else by hand. The destination is owned by
the runner and outside _work/, so Actions never touches it.

Two things this has to handle that are easy to miss:

`docker compose up -d` does NOT restart a container when only the
CONTENTS of a mounted config file changed. The spec is identical, so it
reports "Running" and the new config never takes effect. The script
fingerprints the synced tree and restarts explicitly when it differs,
and skips restarting entirely when it does not, so an ordinary CMS
deploy costs nothing.

The sync is --inplace. Plain rsync writes a temp file and renames, so
every file gets a new inode while a running container's bind mount still
holds the old one -- stale config that looks correctly deployed.

It cannot disturb the CMS: separate Compose project, so up -d cannot
recreate or stop the slots. It runs after deploy.sh because Prometheus
joins the CMS network, declared external. It fails the job if Prometheus
comes up with no alerting rules or no attached Alertmanager, since a
stack that runs but silently does not alert is worse than one that is
plainly down.

Needing no env file is what made this simple, and that fell out of
removing Grafana: DISCORD_WEBHOOK_FILE is the only variable left and it
has a default, so there are no secrets to plumb through CI.

Migrated and verified on Delta: stack moved off /home/tadmin to the
runner-owned path with all four data volumes and Prometheus history
intact, re-running is a no-op, and a changed rule file does trigger the
restart. The stale /home/tadmin copy is gone; its one irreplaceable
file, the plaintext datasource password Nginx only stores hashed, was
moved to /etc/triangle-observability/ first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last hand-installed piece. The Loki and Prometheus datasource
endpoints the central Grafana connects to were host config that only
matched the repo because someone had copied it there.

Done without widening the runner's privileges. The files install into
/etc/nginx/triangle-observability/, a directory the runner OWNS, which a
root-owned conf.d file pulls in with a wildcard include. The runner's
sudo rights stay exactly what the CMS deploy already needs -- nginx -t
and nginx -s reload -- rather than gaining write access to /etc/nginx or
an "install this as root" rule. Same shape as /etc/nginx/triangle-cms/,
which it already owns for blue/green.

The install is transactional because this Nginx also serves the CMS: the
live files are snapshotted, the new ones installed, and nginx -t runs
BEFORE any reload. A config that fails validation is reverted and the
step fails. Nginx keeps serving the old config throughout, and -- the
part that actually matters -- no broken file is left on disk for the
next reload to trip over, which could be the CMS deploy's.

triangle-cms.conf is deliberately excluded. It is the live site's own
server block, a much larger blast radius than two loopback-proxying
endpoints, and it changes about never.

The compose helper is renamed obs_compose. It was shadowing the compose()
that common.sh binds to compose.cms.yml, which worked only by definition
order -- moving the source line would have silently pointed this script
at the CMS stack.

Verified on Delta by exercising all three paths: unchanged sites skip the
reload, a valid change installs and reloads, and a deliberately broken
config fails nginx -t, reverts, exits non-zero, and leaves Nginx, the CMS
and both authenticated endpoints healthy. Live files confirmed identical
to the repo afterwards, and the old sites-available/sites-enabled copies
removed so the two cannot collide on the same listen ports.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ssavutu
ssavutu merged commit b6c2583 into main Aug 5, 2026
6 checks passed
@ssavutu
ssavutu deleted the feat/observability-autodeploy branch August 5, 2026 03:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant