Commit Graph
58 Commits
Author SHA1 Message Date
kevinveenbirkenbach 396c937033 Merge branch 'main' into feature/store-i18n
# Conflicts:
#	cli/build/docs/integration_matrix/__init__.py
#	tests/lint/ansible/roles/meta/__init__.py
#	utils/github/variant/axes.py
2026-10-21 00:09:53 +02:00
kevinveenbirkenbachandClaude Opus 5.5 6e776a5780 refactor(python): clear the open CodeQL code-quality alerts
- Declare `__all__` in every package `__init__.py` whose `PROJECT_ROOT`
  is imported by its modules (163 packages), so the constant counts as
  exported; list every public name where the package defines more.
- Empty the 15 package `__init__.py` files whose `PROJECT_ROOT` no module
  imports.
- Declare the public API of `utils.github.variant.axes` in `__all__`,
  re-exports included, instead of silencing them with `noqa: F401`; the
  list matches the one `feature/store-i18n` already carries.
- Use `import unittest.mock as mock` in `test_applications.py`, as the
  rest of the suite does.
- Spell `_NON_PRINTABLE` in `utils/yaml_bootstrap.py` as the equivalent
  BMP-only positive class; it matches exactly the same 2111 code points
  as the old negated class across U+0000..U+10FFFF.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 21:40:58 +02:00
kevinveenbirkenbach 15930945fe Merge branch 'main' into feature/store-i18n 2026-10-19 01:54:37 +02:00
evangelostsak 1343f77929 refactor: drop rationale and narration from tests/unit/python/cli/administration/inventory/mesh/test_write.py 2026-10-16 19:10:01 +02:00
evangelostsak 277a2aad76 refactor: drop rationale and narration from tests/unit/python/cli/administration/inventory/mesh/test_transport.py 2026-10-16 19:10:01 +02:00
evangelostsak 7c85cedc8e refactor: drop rationale and narration from tests/unit/python/cli/administration/inventory/mesh/test_plan.py 2026-10-16 19:10:01 +02:00
evangelostsak 814fc8b336 refactor: drop rationale and narration from tests/unit/python/cli/administration/inventory/mesh/test_inventory.py 2026-10-16 19:10:01 +02:00
evangelostsak a88f69bd35 refactor: drop rationale and narration from tests/unit/python/cli/administration/inventory/mesh/test_full_mesh.py 2026-10-16 19:10:01 +02:00
evangelostsak c95e5d06c4 refactor: drop rationale and narration from tests/unit/python/cli/administration/inventory/mesh/test_cli.py 2026-10-16 19:10:01 +02:00
evangelostsak 617879f680 test(mesh-cli): full meshes peer every member with every other
Covers the declarations read from every host, the refusal of differing ones,
every peer pinned to its /32, no hub anywhere, the host of one mesh holding no
peer of the other, and a second run leaving every file alone.
2026-10-16 03:40:15 +02:00
evangelostsak f534c973a7 test(mesh-cli): an inventory without a hub host has no hub 2026-10-16 03:40:15 +02:00
evangelostsak b1279525bf test(mesh): the transport switch under a baked layout 2026-10-14 15:03:41 +02:00
evangelostsak 56ce03d370 test(mesh): the CLI reads its meshes from host_vars
Mesh names are asserted structurally: the baked layout names every mesh in
every file, so a substring match would pass on a host holding no interface.
2026-10-14 15:03:41 +02:00
evangelostsak a0a7436d19 test(mesh): the writer under a layout baked into host_vars 2026-10-14 15:03:41 +02:00
evangelostsak 54e237bddb test(mesh): plan against a topology instead of raw groups 2026-10-14 15:03:41 +02:00
evangelostsak e30b17276c test(mesh): declarations, pool and membership read from host_vars
Covers per-role and per-host membership, a host two roles put into one mesh,
the hub never being a spoke, an undeclared mesh being refused, and the
example's members matching its table.
2026-10-14 15:03:41 +02:00
evangelostsak 31cf7362d5 test(mesh): remove the contract tests with the contract 2026-10-14 15:03:41 +02:00
evangelostsak e9dbab5835 test(mesh): the contract shape carries the primary flag 2026-10-14 11:23:23 +02:00
evangelostsak 05dc4af041 test(mesh): the controller joins the first declared mesh by default 2026-10-14 11:23:23 +02:00
evangelostsak a521937794 test(mesh): no two interfaces of a host route one prefix
Covers the swarm lab under the three-mesh example, where the NFS server is a
spoke of two meshes, and generated topologies of one to eight meshes.
2026-10-14 11:23:23 +02:00
evangelostsak 06cc872c1c test(mesh): pin how the primary mesh is chosen 2026-10-14 11:23:23 +02:00
evangelostsak e043ad0c00 test(mesh): zip the planned meshes strictly against their specs
The any-number-of-meshes test zipped the specs against the planned meshes
without strict=, which ruff reports as B905 and the lint-python job failed on.
Both sequences come from the same declarations, so strict=True also turns a
plan that drops a mesh into a failure instead of a silently shorter loop.
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
kevinveenbirkenbach de3127683f Merge branch 'feature/designer' into feature/store-i18n 2026-10-13 12:56:06 +02:00
kevinveenbirkenbachandClaude Opus 5.5 0327114c44 fix(inventory): drop disable= toggles once their key leaves the list
Provisioning merges into host_vars and never deletes, so the false
enabled/shared toggles that apply_services_disabled writes for a key stayed
in the round inventory after a later run dropped that key from disable=.
That covered the provider's own entry too: with seaweedfs once disabled,
web-app-seaweedfs kept shared: false, the service registry offered no
seaweedfs provider, container_networks attached no consumer, and BookWyrm
ran USE_S3=true against http://seaweedfs:8333 without a route.

reconcile_services_disabled_from_env records the keys of each disable run
in the host_vars key _INFINITO_SERVICES_DISABLED. On the next run it deletes,
in every application block, the enabled/shared toggles that are still
exactly false for a recorded key the current disable no longer lists, drops
a block left empty, and rewrites or removes the record. It runs right after
ensure_host_vars_file, before credentials and the variant bake, so a variant
that wants a toggle off writes it again. With an unchanged or empty disable
it does nothing. An inventory written before this change has no record, so
its older toggles are only reverted after one run with the old list.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 11:07:21 +02:00
kevinveenbirkenbachandClaude Opus 5 d3610af31f fix(disable): turn off a service key that only a variant bakes
`disable=lam` aborted the local deploy of web-app-openbao with "`disable`
conflicts with the current inventory state", and web-app-moodle carries the same
construction in two of its variants.

The writer and the conflict check disagreed on scope. apply_services_disabled
walked `find_roles_with_service`, the roles whose meta/services.yml declares the
key, so neither role was in that set for `lam` and nothing was written.
find_services_disabled_conflicts walks every deployed app's host_vars instead,
found the `lam` block the variant had baked, and refused the run. Dropping
`lam` from the list is not an answer either: the round then deploys web-app-lam
although ldap is off.

The writer now also flips a key it finds in host_vars under an app whose role
does not declare it, and only the flags that are already in that block. It never
creates a key or a flag, which is what `_declared_toggles` exists to prevent:
compare_application_keys rejects a host_vars key the role contract does not
carry. Overwriting a value the variant already wrote introduces nothing, so that
rule is untouched; test_skips_app_whose_role_does_not_define_service still
passes and pins it.

The alternative the TODO proposed, declaring `lam` in meta/services.yml with the
`'web-app-lam' in group_names` flags, is the worse trade: a consumer entry is
resolved through the service registry, so it would bind web-app-lam and its
whole closure into every round that carries openbao or moodle, for a key both
roles read only to gate a Playwright spec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-09 22:48:06 +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 4adfb6e3d8 test(mesh-cli): cover the addresses the transport dials
One test asserts ansible_host lands on the given address, the other
that a missing member raises. Either would have caught the UNREACHABLE run
before it was dispatched.
2026-10-08 10:58:23 +02:00
evangelostsak 25c7fdd1a5 test(mesh): pin the switched host to a tty-free transport
The carriage returns a pty adds are invisible until a role parses its own
output, so the absence of the tty is asserted where the transport is
written rather than left to the next role that reads stdout.
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
kevinveenbirkenbachandClaude Opus 5 d88fe1d83a test(lint): let the legacy-path guard see the trees that still named the old layout
`config/main.yml` exists in no role: f65f4d60fb migrated the last one. A guard
against the string already exists, `TestNoLegacyPathReferences`, and it names
`config/main.yml` among its LEGACY_TOKENS. It scanned plugins, utils, cli,
roles, scripts, filter_plugins and lookup_plugins, which is every tree except
the two the stale references actually lived in.

So nine of them survived in `tests/`, and the guard reported green the whole
time. The code underneath was already correct everywhere: `test_csp_consistency`
and `test_role_wiring` iterate ROLE_FILE_META_SERVICES, and only their docstrings
and one assertion message still said `config/main.yml`. Had any of them globbed
the old path the test would have gone vacuous rather than red, which is the
failure mode worth naming here.

`tests` and `tasks` are now scanned. The targets come from
`utils/roles/mapping.py`, not from guesswork: `config/main.yml` is
`meta/services.yml`, `schema/main.yml` is `meta/secrets.yml` (confirmed at
`cli/administration/inventory/provision/reset.py:67`, which reads exactly that
file), and `users/main.yml` is `meta/users.yml`.

Scanning `tasks` exposed a second dead pointer. The migration tool became a
package on 2026-09-14, but EXEMPT_FILES still named `migrate_meta_layout.py`, a
file that no longer exists, so the exemption was inert and only invisible
because `tasks` went unscanned. Two dead pointers covering for each other. The
exemption is now a directory, so a new module in that package cannot fall out of
it. `test_no_direct_calls.py` named the same dead path in prose; that lint
allows through nocheck markers at the call site, so nothing was broken there.

`docs/administration/configuration/email.md` was the worst of them, because it
instructed operators rather than describing tests: it told them to write
`compose.services.email` into `roles/<application_id>/config/main.yml`. The
consumer, `plugins/lookup/email.py:255`, reads `entry["services"]["email"]` and
says in its own comment that there is no `compose.services` envelope. The role
snippet, the inventory snippet and the precedence list now match the reader.
`group_vars/all/TODO.md` pointed at the same dead file.

Verified by watching the guard fail, twice. Re-introducing `config/main.yml` in
a test file reports it; pointing the new directory exemption at a wrong path
reports `tasks/utils/migrate_meta_layout/credentials.py`, which proves both that
`tasks` is scanned and that the exemption is the only thing letting the
migration tool through. Restored, 11 passed.

The changelogs under `docs/changelog/` keep their mentions, and `.md` stays out
of the scanned suffixes for that reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-30 21:27:40 +02:00
evangelostsak 94f2401fab test(mesh): pin one key per host across a rotation
Asserts the hub's two mesh entries carry the same key and that its spoke holds
it. A first attempt matched only the first entry and passed against the bug.
2026-09-29 08:59:23 +02:00
evangelostsak 7634c1ed32 test(mesh): pin the controller's key across a rerun
Without the fix its public half changes on every write, which is what made
the hub re-render and re-key a tunnel nothing had asked to rotate.
2026-09-28 15:44:30 +02:00
evangelostsak ba4b8cd6f5 test(credentials): cover the kept subtree
The destination keeps its own block and still takes the rotated values around
it; a destination without that block takes the source's.
2026-09-28 11:46:01 +02:00
evangelostsak b16a2fb511 revert(provision): restore the user password generator tests
Follows the generator back to its previous behaviour.
2026-09-27 23:55:21 +02:00
evangelostsak fbf17949d7 test(mesh): cover the endpoint argument
A stated address replaces the name peers dial, an unstated host keeps its
name, and a malformed pair aborts rather than being ignored.
2026-09-27 02:05:00 +02:00
evangelostsak 7a996cd0f5 test(provision): cover the declared password reference
Three cases: the value is pinned from host_vars, its vault node survives, and
a reference to something absent still falls back to a fresh password.
2026-09-27 01:30:59 +02:00
evangelostsak f4c336b5a1 test(provision): pin the declared-password behaviour
Covers both halves: the policy reaches the caller with no value, and a stated
password is carried through and draws no random pin.
2026-09-26 23:30:07 +02:00
evangelostsak a868045d69 test(mesh): cover the controller in the foreign-entry prune
Mirrors the hub's host_vars onto the controller the way the credential reset
does, then asserts the rerun leaves only the mesh the controller belongs to.
Without the fix the file keeps the hub's data entry, is_hub included.
2026-09-26 19:47:10 +02:00
evangelostsak 5ae72d3b88 test(mesh): cover the controller spoke and the transport switch
Pins the two failure modes the design turns on: a host with no mesh address is
left reachable rather than stranded, and an address is only read from the host
that owns it, because the swarm reset mirrors one node's host_vars over every
other.
2026-09-26 01:27:07 +02:00
evangelostsak 0a6700e378 Merge origin/main into feature/svc-net-wireguard
Brings in 225 commits, including the merged web-app-openbao work and the
LMStudio loopback fix.

Seven conflicts:

- The legacy svc-net-wireguard-core/plain/firewalled roles this branch deleted
  in e706170a0 were modified upstream. The deletion stands, which is the whole
  premise of the branch.
- The MCP audit takes upstream's list plus svc-net-firewall, with the three
  legacy wireguard entries collapsed into the single svc-net-wireguard: 23.
- web-app-openbao/README.md takes main's reviewed version wholesale.
- matrix.py takes both sides: upstream's app_id argument to _reset_credentials
  and this branch's _write_mesh call after it.

The merge pushed matrix.py to 511 lines against the 500 cap, so the step
runner moved to utils/tests/swarm/run.py, which is self-contained and leaves
matrix.py at 427. Its test moved with it.
2026-09-24 21:14:25 +02:00
evangelostsak 74bdfbae9c test(inventory): pin pruning, and assert mesh membership structurally
Adds the mirror case pruning has to survive and its inverse, that an owned mesh
is left untouched.

The existing assertions searched the file for 'swarm:' and 'data:', which the
credential keys mesh_private_key_swarm and mesh_private_key_data also match, so
they passed against a file whose mesh block was empty. They now read the mesh
names out of the parsed document.
2026-09-19 12:07:43 +02:00
evangelostsak 82c5d00c71 test(inventory): pin that a mirrored host_vars is reminted, not adopted
The failure this covers is silent in the inventory and total on the cluster:
every node adopts the hub's key and address, every interface claims 10.100.0.1,
and nothing handshakes. Asserts the mirrored hosts read as unkeyed and that a
rewrite gives the three swarm members three distinct addresses again.
2026-09-19 11:16:59 +02:00
kevinveenbirkenbachandClaude Opus 5 673de05a60 fix(inventory): write only the service toggles a role's contract declares
`disable=` wrote both `enabled: false` and `shared: false` for every role that
declares the service, but a role qualifies with any one of `enabled`, `shared`
or `provides`. compare_application_keys rejects any host_vars key the role
contract does not carry, so a role declaring only `enabled` came back as a
validation error and the deploy never reached Ansible:

    Missing default for web-svc-cdn: services.dashboard.shared
    Missing default for web-svc-mirror: services.dashboard.shared
    [ERROR] Inventory validation failed. Aborting deployment.

_declared_toggles reads the role's meta/services.yml and writes only what is
there; a role carrying neither toggle is skipped instead of gaining an empty
block. The module already held this principle: find_roles_with_service excludes
pure metadata entries so `disable` "doesn't add bogus enabled: false overrides",
and this applies the same rule per flag rather than per role.

Delta measured over the disable list the compose iteration procedure prescribes
(matomo, dashboard, prometheus, email, css): 407 of 409 role/service pairs are
untouched because they declare both toggles, exactly 2 stop receiving a
fabricated key, and 0 fall out of the disable entirely. The 2 are the pairs the
validator rejected. Across all 266 roles with a meta/services.yml, 1224 service
blocks declare `enabled`, 1190 declare `shared`, 34 declare `enabled` alone and
none declares `provides` alone, so nothing loses its disable.

Proven by the deploy sequence rather than by a test: the first local deploy
aborted at inventory validation, the second passed into the Ansible run with the
same disable list. Two regression tests cover both shapes, and the negative
control holds: hard-coding ["enabled", "shared"] fails the new test.

Without this, `disable=` is unusable in every local deploy, which the iteration
procedure requires on every run.

Residual: the generator was exercised through compose-deploy only, not through
roundtrip or swarm-zombie, which reach the same function by other call paths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 06:00:56 +02:00
evangelostsak 160b0eaff7 fix(inventory): route the whole pool through the hub from a spoke
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.
2026-09-18 16:37:18 +02:00
evangelostsak 0b8f8ac46f test(inventory): exercise the mesh CLI as the swarm routine invokes it
Covers the entry point rather than the library: one invocation writes every
member across both inventory files, a missing host_vars directory fails instead
of silently writing nothing, and a rerun is a no-op end to end.
2026-09-18 09:51:17 +02:00
evangelostsak 33dbd1ed8c test(inventory): pin what the mesh writer may put on disk
Asserted against real files in a tempdir, because the guarantees are about
files: which host_vars a secret may appear in, and whether a second run of an
unchanged mesh leaves them alone.

Two cases carry the security argument. A private key must reach exactly one
host_vars file, which is what keeps one host's inventory from impersonating
another, and it must never appear there in plaintext. A third covers the
inverse of the idempotence shortcut: a public key whose credential has gone
missing is worse than no key, because peers accept it while its owner cannot
use it, so that member is reported unkeyed and the pair is reminted together.

A worker is asserted NOT to receive the data mesh. Carrying it would peer the
worker directly with the NFS server and bypass the routing through the hub that
this topology depends on.
2026-09-18 09:29:35 +02:00
evangelostsak 10f0b94877 test(inventory): pin mesh planning, which fails silently when wrong
Every failure mode here is invisible at deploy time. A spoke missing from the
hub's peer list configures cleanly and never handshakes. A public key that does
not answer for its private half looks identical until traffic is attempted. A
partial rotation leaves the hub holding an old key while the spoke presents a
new one, and the handshake is refused with nothing logged on either side.

The load-bearing case is the hub filter: the manager is a member of
svc-swarm-node as well as the hub group, so left unfiltered it peers with
itself, which WireGuard accepts and which then routes the hub's own traffic
into its own tunnel.

The subnet guards are asserted against the shipped declaration too, so the
configuration proves its own collision checks rather than only the synthetic
ones.
2026-09-18 09:29:16 +02:00
evangelostsak c629fce2b7 test(inventory): add the mesh test package root
Resolves PROJECT_ROOT for the mesh suites, which read the shipped mesh
declaration rather than restating it.
2026-09-18 09:27:31 +02:00