Commit Graph
106 Commits
Author SHA1 Message Date
evangelostsak f169fd5995 refactor: drop rationale and narration from cli/administration/inventory/mesh/write.py 2026-10-16 19:10:00 +02:00
evangelostsak c8d5c4f056 refactor: drop rationale and narration from cli/administration/inventory/mesh/transport.py 2026-10-16 19:10:00 +02:00
evangelostsak cc38fe6232 refactor: drop rationale and narration from cli/administration/inventory/mesh/plan.py 2026-10-16 19:10:00 +02:00
evangelostsak 6c684a127e refactor: drop rationale and narration from cli/administration/inventory/mesh/model.py 2026-10-16 19:10:00 +02:00
evangelostsak 7c124be6a2 refactor: drop rationale and narration from cli/administration/inventory/mesh/keys.py 2026-10-16 19:10:00 +02:00
evangelostsak 59143ee3e2 refactor: drop rationale and narration from cli/administration/inventory/mesh/inventory.py 2026-10-16 19:09:59 +02:00
evangelostsak 868ca8117f refactor: drop rationale and narration from cli/administration/inventory/mesh/__main__.py 2026-10-16 19:09:59 +02:00
evangelostsak 4b0851dc46 refactor: drop rationale and narration from cli/administration/inventory/mesh/__init__.py 2026-10-16 19:09:59 +02:00
evangelostsak 6472ec6959 refactor: drop rationale and narration from cli/administration/inventory/credentials/reset/cli.py 2026-10-16 19:09:59 +02:00
evangelostsak 73d421f70c feat(mesh-cli): write full meshes for an inventory without a hub
The swarm output is byte-identical: the dev layout and the three-mesh example,
each with and without a controller, produced the same host_vars, wg.conf and
stdout before and after, with deterministic keys.
2026-10-16 03:40:15 +02:00
evangelostsak 28468b33d2 feat(mesh-cli): read the declarations of every host when there is no hub
Under a svc-swarm-manager host the declarations stay the hub's own. Without
one, every host may declare the meshes, and hosts declaring different ones are
refused rather than merged, because a member would otherwise render a mesh
its peers do not know.
2026-10-16 03:40:15 +02:00
evangelostsak acef74a150 feat(mesh-cli): plan meshes without a hub
With no hub every joined host is a member, addressed from the same offsets a
spoke takes, and each host is primary on the first declared mesh it joined.
2026-10-16 03:40:15 +02:00
evangelostsak 009f30241c feat(mesh-cli): a mesh without a hub is a full mesh
A topology may carry no hub. Its meshes then peer every member with every
other one, which is what a Compose deployment of several hosts needs: there
is no manager to route through.
2026-10-16 03:40:15 +02:00
evangelostsak 0fc5c3695d refactor(mesh): remove the contract writer
The per-host state is a list now, which the inventory validator does not
walk, so no role contract has to name the deployment's meshes.
2026-10-14 15:03:40 +02:00
evangelostsak 081260fc45 feat(mesh): drop --group-vars-file and the generated contract
The CLI reads the declarations and memberships from the host_vars it writes
to. --contract-file and --application-id go with them: there is no contract
left to write, and the application block is fixed by the mesh reader.
2026-10-14 15:03:40 +02:00
evangelostsak 91bcbe904c refactor(mesh): read the transport address through the mesh reader 2026-10-14 15:03:40 +02:00
evangelostsak 66608aa2d2 refactor(mesh): store a host's interfaces as a list
Each host's state moves from a map keyed by mesh name to a list under
svc-net-wireguard.networks.interfaces. The inventory validator does not walk
lists, so a deployment's own mesh names need no generated contract, and
every reader goes through utils.networks.mesh.
2026-10-14 15:03:40 +02:00
evangelostsak 4641a7f341 feat(mesh): read the meshes and their members from host_vars
The declarations are the hub's svc-net-wireguard.networks.meshes, and a host
joins every mesh its roles list in their own networks.meshes, so two hosts
of one role may sit in different meshes. The hub is the svc-swarm-manager
host. A deployment narrows the pool in its own group_vars, otherwise the
platform's NETWORK_MESH_POOL applies. A mesh a role names but nobody
declared is refused rather than dropped.
2026-10-14 15:03:40 +02:00
evangelostsak be55cebab8 refactor(mesh): plan against the resolved topology
members_of, primary_meshes and plan_mesh take the Topology instead of raw
inventory groups. The spokes come out deduplicated, sorted and without the
hub, as before.
2026-10-14 15:03:40 +02:00
evangelostsak d12c024e45 refactor(mesh): a spec is a mesh, a topology is who joins it
MeshSpec loses hub_group, spoke_groups and spoke_group_prefixes: membership
is no longer part of a declaration but resolved from the inventory into a
Topology of one hub and the spokes of every mesh.
2026-10-14 15:03:40 +02:00
evangelostsak ca69d865b2 fix(mesh): default the controller mesh to the first declared
--controller-mesh defaulted to 'swarm', a name only the development
declarations use; any other deployment left the controller off the mesh. It
now defaults to the first declared mesh, an undeclared name fails instead of
being ignored, and the primary of every host is resolved over all declared
meshes before any is written.
2026-10-14 11:23:22 +02:00
evangelostsak e0b46e0882 feat(mesh): declare the primary flag in the role contract
The writer now stores 'primary' in every mesh entry, and the inventory
validator refuses a key the role meta does not declare.
2026-10-14 11:23:22 +02:00
evangelostsak f126dd4aec fix(mesh): route the pool through one interface per host
Every spoke routed the whole pool through every mesh it belonged to. For a
spoke of two meshes that is two routes for one prefix: wg-quick refuses the
second with 'RTNETLINK answers: File exists' and deletes the interface. The
three-mesh example hits it on the swarm lab, where the NFS server is a spoke
of beta and of gamma.

Only the primary mesh routes the pool now; the others route their own subnet,
which the interface address already covers. Each entry records whether it is
the host's primary.
2026-10-14 11:23:22 +02:00
evangelostsak 4321d3a342 feat(mesh): resolve each host's primary mesh
A host's primary mesh is the first declared mesh it belongs to. It is the one
interface a spoke of several meshes may route the pool through, and the
address the other planes reach the host on.
2026-10-14 11:23:22 +02:00
evangelostsak e18939aea3 style(mesh): apply ruff formatting to the contract writer 2026-10-14 00:27:17 +02:00
evangelostsak b777b65e84 test(mesh): hold the planning guarantees at any mesh count
The guarantees were asserted against the declared swarm and data pair,
so they proved two meshes work rather than that the planner works. A
deployment declares its own, and the review asks for any number of them with
the nodes arranged however the client wants.

The same invariants now run over generated topologies: one to eight meshes,
three arrangements each, hosts dealt out so some belong to several meshes and
some to none. 2247 subtests, four seconds, and the planner needed no change to
pass them.
2026-10-13 19:36:43 +02:00
evangelostsak ff1db88753 revert(swarm-test): remove the SSH-on-underlay measurement arm
The arm existed to answer one question: how much of the meshed arm's
cost is the connection plugin and how much is the tunnel. Runs 37592061960
(docker), 37753506661 (SSH on the underlay) and 37621556682 (SSH over
WireGuard) answered it on web-app-gitea#0, debian/ext4:

  pass 1  5964s / 8511s / 8832s
  pass 2  5319s / 7472s / 7705s

Leaving ansible_connection: docker costs +4700s, the tunnel +554s - 89.5%
against 10.5%. The answer is recorded; the scaffolding does not need to ship.

Reverts d792519725, 1b28f87b37, 89b8a8c8d1, 0470491e1a, 529c5ace43,
dd3d10a9a2, c20dd0e5f5, 458d8956e1, 63ade1e03b, d281555945 and 4adfb6e3d2.
95cfc8b7b7 is already superseded by bbfd82f89, which sets the profiling limit
upstream.
2026-10-09 12:12:03 +02:00
evangelostsak 63ade1e03b fix(mesh-cli): dial given addresses on the underlay arm
keep_address left ansible_host alone, and the lab never sets it: the
docker connection uses inventory_hostname as the container name. Switching to
SSH without writing an address made ssh resolve 'web-app-gitea-swarm-mgr-01'
through DNS, and run 37617424998 died UNREACHABLE on all three nodes in 13
minutes.

switch_to_mesh now takes the addresses to dial. A member missing from the
mapping raises instead of falling back to its mesh address, because that
fallback would have produced the meshed arm under the other arm's name - a
wrong number rather than a failed run.
2026-10-08 10:58:22 +02:00
evangelostsak d792519725 feat(mesh-cli): move the transport to SSH without moving the address
keep_address switches the connection, the user, the key and the ssh
args exactly as the mesh switch does, and leaves ansible_host where it was.
Against the other two transports that isolates the cost of leaving the local
connection from the cost of the tunnel.
2026-10-07 09:54:32 +02:00
evangelostsak c70c8dac5e docs(mesh-cli): reduce the entrypoint docstrings to summary and Args
Each command's docstring now states what it does and what it takes.
2026-10-05 12:43:23 +02:00
evangelostsak b84f57799e docs(mesh-cli): reduce the transport docstrings to summary and Args
The ControlPersist value and the disabled tty are decisions, not
documentation; requirement 037 carries the reasoning.
2026-10-05 12:43:23 +02:00
evangelostsak 4a5845b54b docs(mesh-cli): reduce the writer docstrings to summary and Args
Why mirrored entries are skipped when reading existing addresses belongs
in the commit that introduced the skip.
2026-10-05 12:43:23 +02:00
evangelostsak 379dface8c docs(mesh-cli): reduce the planner docstrings to summary and Args
The prose explaining why offsets are reused and how the hub keeps index
zero is recorded in requirement 037. A reader of plan.py needs the signature,
not the history.
2026-10-05 12:43:23 +02:00
evangelostsak 9accaee134 fix(mesh): stop the mesh transport adding carriage returns
Ansible appends -tt to any task it cannot pipeline when the task escalates,
and ansible.builtin.script is one of those, so the deploy over the mesh ran
those tasks on a pty. A pty ends every line with a carriage return, while
the container connection this transport replaces returned bare newlines. A
role that reads its own stdout therefore started failing for no reason other
than the deploy having moved onto the mesh: the Matomo bootstrap parsed a
perfectly good token as empty, because its regex anchors each line and a
trailing carriage return defeats that anchor.

The host the tool switches now asks for no tty, so the mesh returns the same
bytes the connection it replaced did, and no role has to learn about the
transport underneath it.

Taken from the connection plugin rather than guessed: the tty is added when
the task cannot pipeline and escalates, and the option carrying it is
exposed per host as ansible_ssh_use_tty.
2026-10-03 23:20:26 +02:00
evangelostsak 152588b511 perf(mesh): retune the multiplexer for the hosts the mesh switches
The pass that runs over the mesh reaches every host by SSH where the
pass before it used the container connection, and it pays that difference
once per task. Measured in a container: an exec through a warm multiplexer
costs 7ms, a fresh connection 85ms, and 165ms once sudo asks for the
password every task demands. The pass runs thousands of tasks.

Two settings threw the multiplexer away. A persist window of 20s closes the
master whenever a host waits longer than that for its next task, which in a
linear play is most of them; and the named ControlPath points at a directory
nothing creates, so a master that cannot open its socket reconnects per task
without reporting it.

Neither is retuned for the repository. The tool that moves a host onto the
mesh now writes that host's own ssh_args, derived from the repository's
value so every other option is kept and only the window and the socket
differ. A host the mesh never touches keeps exactly what it had.

Confirmed against the built command: a switched host gets ControlPersist=600s
and a ControlPath under the directory Ansible creates, while a host beside it
keeps the repository's own.
2026-10-02 16:35:29 +02:00
evangelostsak dc14e8a3a8 fix(mesh): keep every member on the address it already holds
The offset came from the spoke's position in a sorted list, so a host that
sorts earlier renumbered every member behind it. `wg syncconf` carries keys
and peers onto a live interface and never its address, so a renumbered member
keeps answering on the old address while the peers and the inventory have
moved to the new one -- and the address it gave up can be handed to a new
member while the old owner still holds it.

The planner now reuses the addresses already on disk and allocates only an
unseen member, from the lowest free offset. A value outside the subnet, a
duplicate, and the hub's own offset are refused, so a mirrored host_vars
cannot hand a spoke the hub's address. Rotation keeps addresses: it replaces
identities, not addresses.

The routing assertions re-planned the mesh and compared the result with
itself, so a writer that stored the mesh subnet in place of the routed range
would have passed; they now read the entry off disk, which is what the role
reads.
2026-10-02 00:54:20 +02:00
evangelostsak 9feb2c3d2a fix(mesh): keep one key per host through a rotation
A host in two meshes has one private key, so the second mesh must reuse what
the first minted. The lookup prefers the entry of the mesh being written, which
after a rotation is the stale pre-rotation key, so the hub ended up advertising
one key on the swarm plane and another on the data plane while holding the
private half of only one. Its workers could never handshake again.

The run now carries what it decided: a host keyed while planning one mesh keeps
that key for every mesh after it.
2026-09-29 08:59:18 +02:00
evangelostsak 7dd8be1ab5 refactor(mesh): drop the writer's explanation comments 2026-09-29 00:50:45 +02:00
evangelostsak b8a96c2a6f refactor(mesh): drop the prune explanation comment 2026-09-29 00:50:45 +02:00
evangelostsak 10cb6d979c fix(mesh): keep the controller's key across a rerun
The existing-key lookup walked the inventory hosts, and the controller joins
by name rather than through a group, so it was the one member whose key was
never found and always minted fresh. Every write then changed the hub's peer
list, and the converge re-keyed the swarm tunnel for no reason.

all_hosts already accounts for the controller two lines above; the lookup now
uses it, as the prune already does.
2026-09-28 15:44:13 +02:00
evangelostsak 56cfdca4ff feat(credentials): let the mirror keep per-host material
The mirror copies one host's file over every other so a rotation reaches all
of them, which is right for a shared secret and wrong for anything a
generator writes per host: the copy hands every node the source's identity.

--mirror-keep names the subtrees that belong to the destination and are put
back after the copy. Absent, the mirror behaves exactly as before.
2026-09-28 11:45:34 +02:00
evangelostsak 68ae39a4c9 revert(provision): restore the user password generator
Out of scope for 037: the declared-reference pin was not what the swarm
transport needed, and the credential path should not change for it.
2026-09-27 23:55:10 +02:00
evangelostsak e98af2abc5 feat(mesh): take the underlay addresses on the command line
--endpoint HOST=ADDRESS, repeatable. A topology whose inventory names do not
resolve on every member states the address once, where the mesh is written,
rather than every member having to be taught the names.
2026-09-27 02:03:29 +02:00
evangelostsak fccd4d8382 feat(mesh): carry the endpoint into every peer entry
The rendered interface needs it; the member's own block does not, since a
host never dials itself.
2026-09-27 02:03:17 +02:00
evangelostsak b0697c08d6 feat(mesh): resolve each member's endpoint when planning
Defaults to the inventory name, which is what peers already dial wherever
those names resolve for every member.
2026-09-27 02:03:05 +02:00
evangelostsak 35572b8c06 feat(mesh): give a member the underlay address peers dial
The requirement's topology pins one per host and calls it the underlay
address; the mesh had no field for it and used the inventory name instead.
2026-09-27 02:02:54 +02:00
evangelostsak e154ad0b6f fix(provision): pin the value a declared password points at
meta/users.yml states 'password: {{ ansible_become_password }}' to say the
account shares the become secret. Provisioning pinned a fresh random over it,
so the account was created with one secret while become sent another; the
lookup returns the expression as a literal, so skipping the pin would hand
the account the text instead. Resolve the reference against host_vars and pin
that value, vault node included.

Any deploy that reaches a host as the administrator over ssh hits this, not
just the mesh: the container connection escalates to root without a password,
which is why nothing caught it.
2026-09-27 01:30:41 +02:00
evangelostsak 880de69687 fix(provision): keep the password a role states outright
A declared password is a dict when the role asks for a policy and a string
when it names the value. Only the dict case needs a generated secret, but
both took one, so user-administrator's '{{ ansible_become_password }}' was
overwritten by a random pin: the account was created with one secret while
become sent another.

Nothing caught it because the container connection escalates to root without
a password. The first transport that logs in as the administrator and sudos
for real gets 'Incorrect sudo password' on every host.
2026-09-26 23:29:54 +02:00
evangelostsak 81c32584e0 fix(mesh): prune the controller's foreign mesh entries too
The prune walks the hosts the inventory groups resolve to, but the controller
joins by name rather than through a group, so it was never visited. Its
mirrored host_vars kept the manager's data-mesh entry -- address 10.100.1.1,
is_hub true -- and the controller brought that interface up and enabled hub
forwarding for a mesh it is not a member of.
2026-09-26 19:28:13 +02:00
evangelostsak a170ccb4a3 feat(mesh): expose the controller through the writer CLI 2026-09-26 01:27:19 +02:00