Skip to content

Manage notifications

Nerthus.Core (until cutover). This page describes the frozen system that runs today and is deleted at cutover. Replaced by: not yet written.

Every fleet notice — provisioning outcomes, health transitions, and takeover steps — posts to the one IT tech channel kanal-wewnetrzny-it (the committed IT role's @discord, the name pair kanal-wewnetrzny-it, it). This page owns the wiring and the event catalog. The notices live in a Worker and the daemon rather than CI because health runs continuously; Fleet owns that budget doctrine.

The one delivery mechanism

There is no webhook. The daemon, the health Worker, and fleet CI all deliver the same way: a bot POST to the channel resolved by name, the mechanism the daemon has always used for lore. Each sender holds the fleet bot's token, resolves channel, category to a channel id with GET /guilds/{id}/channels, then POSTs to /channels/{id}/messages. All three send the required DiscordBot (…) User-Agent; Discord's edge rejects a guild request without it as a 403 carrying code 40333, while account-level calls still pass, so the failure looks misleadingly like an outage.

Sender Job or process Config it reads
daemon the primary host DISCORD_BOT_TOKEN (from secrets/<town>.discord.age, decrypted by the agent and rendered into the compose env on the primary only) + DISCORD_GUILD_ID (from discord_guild_id in fleet.settings.yml), integrations.discord.enabled
health Worker fleet-health DISCORD_BOT_TOKEN (wrangler secret), DISCORD_GUILD_ID + FLEET_ALERT_CHANNEL (wrangler vars)
fleet CI fleet:notify DISCORD_BOT_TOKEN + DISCORD_GUILD_ID (CI variables)

The bot must be in the guild with View Channel + Send Messages, invite permissions 3072. Without View Channel every resolve returns "no channel" and nothing posts. One secret value is shared by all three holders; rotating it touches each (Rotate secrets).

What provisioning reports

Fleet CI's fleet:notify posts one message per provision push, ending on what is still pending rather than on success:

  • Flota <env>: pipeline #<iid>, MR !<n> (autor: <user>) — the header.
  • Host <town>: Skonfigurowany - tunnel: utworzono, … — one line per host that changed.
  • Flota <env>: Oczekiwanie na uruchomienie hostów (<town>, <town>, ...) przez właścicieli (<owner>, <owner>, ...).
  • Flota <env>: Bez zmian — hosts.yaml zgodny z rejestrem — a no-op run.
  • Błąd: Brak fleet-changes.json - aktualizacja Floty (<env>) nie powiodła się — provision failed early.

The Zaktualizowany confirmation that a provisioned host is actually running comes later, from the sweep.

What a converge reports

Each daemon posts one line per repository each time its sync tick fast-forwards onto a commit it did not already hold — the lore checkout and every corpus. Host <town>: <repozytorium> (<key>) na <sha>, followed by the author or authors (%an/%ae, --no-merges) and a count of commits and files. It answers "is the fleet serving what I merged yet", which the provisioning notice cannot: that one fires when CI finishes, minutes to a quarter of an hour earlier.

Only a fast-forward posts. A tick that pushed is a host reporting its own write, a tick that found nothing has no news, and a sha already announced on that host is silent. The knobs — including the channel and the off switch — are integrations.discord.converge_announce.

Primary only, today. The agent renders DISCORD_BOT_TOKEN for ROLE = primary alone, so a replica's announce lands in its delivery log as SKIP/no_token. Giving replicas a token is what would make the per-host reading complete, and that is a secrets decision.

What the health sweep reports

The Worker speaks only when a host's state changes, never on an unchanged pass, so a fleet of sleeping laptops stays quiet.

Alert Fires when
Host <town>: Brak odpowiedzi - <error> a host stops answering /health
Host <town>: Znów odpowiada (OK) it answers again
Host <town>: Obraz Docker nieaktualny - <obs> (wymagany: <pin>) it runs a digest other than the pin
Host <town>: Obraz Docker aktualny (OK) back on the pinned image
Host <town>: Schemat nieaktualny - <s> (wymagany: <exp>) its on-disk schema differs from expected
Host <town>: Schemat aktualny (OK) schema matches again
Host <town>: Brak aktualizacji zmian od <n> minut sync went stale (over 60 min)
Host <town>: Pobiera aktualne zmiany (OK) sync is fresh again
Host <town>: Zaktualizowany - obraz <image> first sweep that sees a host on a newly-pinned image; closes provision's deferred confirmation
Flota <env>: Hosty nieaktualne od 24 godzin - <town>, <town>, ... a pinned image still not taken up after 24 h
Flota <env>: Raport dobowy - dostępność: <up>/<n> hostów, obraz Docker: <image> once a day; its absence is the signal the Worker itself stopped
Flota <env>: Monitor aktywny - Worker w trybie czuwania (0 operacji KV) the standby gate saw fresh monitor state and the Worker stood down
Flota <env>: Monitor nie odpowiada - Worker wznawia sweep the monitor went dark; the Worker's very next sweep took over

What automatic takeover reports

The takeover epoch — the fleet's promotion lineage count — is tracked internally and no longer appears in the message text. The procedure behind these is Run a failover.

Alert Step
Host APEX <primary>: Brak odpowiedzi od ~<mins> minut - szukanie nowego hosta APEX (kandydat: <town>) armed after about after_minutes of confirmed darkness (default 30)
Zmiana hosta APEX: <primary> → <town> (MR !<iid>, auto-merge) the role-flip MR is open
Nowy host APEX: <town> the MR merged and provision enacted it
Ręczna zmiana hosta APEX: <a> → <b> (automat wstrzymany na <h> h) a human flipped the roles instead
Błąd: Zmiana hosta APEX zablokowana - MR !<iid> nie został scalony, sprawdź pipeline the MR sat unmerged past 30 wall-clock minutes
Błąd: Brak kandydata na hosta APEX - <why> the primary is down and no replica qualifies
Nie udało się otworzyć MR przejęcia …: <error> the actuation token or API failed
Przejęto istniejący MR zmiany hosta APEX - MR !<iid> (drugi proponent) the pre-flight found another proposer's open takeover MR and adopted it instead of racing a second one
Zmiana hosta APEX już scalona - MR !<iid> (automat wstrzymany do końca cooldownu) the pre-flight found a takeover already merged inside the cooldown and honoured it

The daemons contribute their own mesh lines through the same channel (primary only — replicas hold no bot token): peer-dark edges and recoveries (… (peer watch)), a clock skew alert, the split-brain finding (report-only, never acted on), and the Discord-dispatch streak transitions (Dyspozycja Discord: …).

Split-brain posts on edges: one message per finding, not one per watch tick. The daemon posts Błąd: <finding> (split-brain - nic nie zmieniam, role zmienia tylko git) on the tick a finding first appears. It posts OK: rozbieżność primary ustąpiła, tylko jeden host twierdzi, że jest primary (split-brain) on the tick the last finding drops out. A finding that merely persists costs no further message. Who claims primary reads out of GET /fleet/peersself.role and each peers[].card.role, cached from the last probe rather than fetched live. The operational stream takes every finding on every tick. With no contributor store loaded, or no @discord on its IT role block, the sender falls back to the kanal-wewnetrzny-it, it name pair.

A Błąd: notice is the fleet asking for a human; everything else is informational.

Verifying delivery

The bot is write-only (no Read Message History), so it cannot read its own posts back. Confirm by eye in the channel, or read the last sweep at the Worker's plain GET / (KV fleet-state.json), which records every host's reachability, skew, and takeover state without posting. A fleet:notify run that cannot resolve the channel throws and fails the job; a Worker that cannot resolve silently no-ops, because a sweep must never fail on delivery.

See also

  • Fleet — why health and its notices live outside CI
  • Rotate secrets — rotating the one bot token every sender shares
  • Run a failover — the procedure the takeover notices track