Writing a member's own entry never removes another's. After the swarm reset
mirrors one node's host_vars onto every other, the NFS server keeps the hub's
swarm entry and brings up a second interface claiming 10.100.0.1. Its own data
mesh stays healthy, so nothing looks wrong on it -- but it now answers for the
hub's address and swallows the return path, and every worker's route to the
export dies there.
Observed as a worker with a live tunnel, a correct route and 100% packet loss
to the NFS server, while the hub reached the same address fine.
The swarm rotation gate mirrors one node's host_vars over every other, which is
correct for credentials that are identical per host and hands every node the
hub's identity here. Observed on a live cluster: a worker's swarm.conf was
byte-identical to the manager's, same private key, Address 10.100.0.1, a
ListenPort it should not have, and both spokes as peers. Nothing handshaked and
every NFS mount over the mesh failed.
Re-running the writer alone does not fix it, because a mirrored file presents
the hub's public key and credential and would be read as already keyed. The
entry now names its owner, so one that names a different host counts as unkeyed
and the pair is reminted.
It carries the same shebang as every sibling __main__.py and none of their
executable bit, which ruff rejects as EXE001.
Not reproducible from here: the sandbox masks the mode, so ruff reports the
file as executable whatever it actually is, and a local run passes with the bit
cleared and a cold cache. CI is the only place this rule can fail.
A spoke's AllowedIPs for the hub was its own mesh subnet, so a worker on
10.100.0.0/24 had no route to the NFS server on 10.100.1.11 and could not
reach the plane it is deliberately not a member of -- which is the entire
reason the hub sits in both.
The symptom would have been misleading: every tunnel reports a healthy
handshake, the gate passes because it only pings peers the host is configured
for, and the failure surfaces later as an NFS mount that hangs. It points at
storage rather than at routing.
Spokes now route the declared pool through the hub while the hub keeps each
spoke pinned to a single address; routing the pool in both directions would
blackhole every member behind whichever spoke came first.
Found by running the writer against the prefixed five-host topology before the
deploy rather than during it.
Every credential generator in this package is single-host scoped.
credentials_generator shells out once per (app_id, host_vars_file) pair, and
that subprocess sees one host's inventory with no knowledge of any other. A
mesh credential breaks the assumption: the value one host receives is derived
from a secret minted for a different host, because a peer holds the public half
of a key whose private half lives elsewhere.
This package is the missing writer that sees the whole host set at once, so a
mesh is written complete or not at all. It is specified as cross-host
credential generation rather than as a WireGuard utility -- the application id
is a parameter and a mesh is a named hub-and-spoke grouping of inventory hosts,
so a later consumer needing paired secrets reuses it unmodified.
Planning is a pure function of the inventory, the spec and the public keys that
already exist, so mesh completeness, key pairing and rotation are provable
without a deploy. Keys are minted in-process with Curve25519 in the encoding
WireGuard expects, so the controller needs no wg binary to plan a mesh it never
joins, and a public key is always derived from its private half rather than
stored, because a stored pair can drift into a mesh that looks configured and
never handshakes.
Group membership is read across every inventory file it is spread over: the
swarm lab keeps its backup host in a sibling backup.yml, so a mesh spanning
both planes is only complete when both files have been read.
Vault encryption is salted, so a member that already holds a key is left
untouched rather than re-encrypted; otherwise every run would rewrite the file
and no deploy could be a no-op.