Commit Graph
678 Commits
Author SHA1 Message Date
evangelostsak 921ba61df8 fix(swarm-test): leave the controller's transport alone
It holds a mesh address and routes the pool, but it is the machine running
the play. Pointed at itself over ssh, every task a role delegates to it dials
its own tunnel address instead of staying local -- which is where the second
pass died, on the controller-side NFS mount, after running 189 tasks on the
manager and 176 on each worker over the mesh without a failure.
2026-09-28 08:52:42 +02:00
evangelostsak 93052c4dec fix(swarm-test): escalate with the password the account was given
user-administrator creates the account from users.administrator.password,
which the development inventory states outright. The value ansible sends for
become is generated per host and never reaches the account, so every sudo
over the mesh was refused. Nothing noticed before because the container
connection escalates to root without a password.

The extras already carry that inventory's users, so the same value now rides
the deploy-facing twin as ansible_become_password.
2026-09-27 23:55:58 +02:00
evangelostsak a5826b455b fix(swarm-test): state the lab's underlay addresses to the mesh writer
The nodes are reached by container name over the docker connection, which
only the lab network resolves. The controller is not on it, so it is handed
the addresses default.env already pins for the five hosts.
2026-09-27 02:04:27 +02:00
evangelostsak 8f047444e0 fix(swarm-test): run the mesh converge inside the seam
Between the mesh write and the controller, so both later steps see one key
set.
2026-09-26 21:28:51 +02:00
evangelostsak 9b883498b0 fix(swarm-test): converge the nodes on the re-minted keys
The rotation gate mirrors one host's host_vars over every other, so the mesh
write that follows mints a fresh keypair for all but the mirror source. The
nodes kept running the keys of the first pass, so the hub turned the
controller away and its handshake gate failed the deploy.

Re-render every node before the controller joins, so the hub authorises the
set the inventory now holds.
2026-09-26 21:28:44 +02:00
evangelostsak 981a40785d fix(swarm-test): give the controller the lab name map
Nodes resolve each other through the extra_hosts compose hands them; the
controller is not a container and got no such map, so wg setconf could not
resolve the hub and the swarm interface failed to come up. Pass the same
name-to-address pairs the compose file builds from the topology.
2026-09-26 19:43:38 +02:00
evangelostsak b42b6f0018 fix(packages): declare the apt arguments a container install needs
apt installs recommends by default. wireguard-tools recommends the virtual
wireguard-modules, whose only providers are kernel images, so a plain install
pulls a realtime kernel into a container, fails there, and leaves the metapackage
broken -- after which every later apt call on that host exits 100, including the
retries of the task that caused it.

Every other apt call in the repository already passes --no-install-recommends;
the registry was the one path that did not.
2026-09-26 16:17:26 +02:00
evangelostsak 15c0458110 refactor(swarm-test): call the mesh steps from their own module 2026-09-26 11:14:12 +02:00
evangelostsak 935563f58e refactor(swarm-test): gather the round's mesh steps in one module
Minting the mesh, putting the controller on it and moving the inventory onto
it belong together, and they took matrix.py back over the 500-line cap.
2026-09-26 11:14:12 +02:00
evangelostsak 93e3331780 fix(swarm-test): keep the controller out of the deploy inventory
It joins the mesh through its own play instead.
2026-09-26 11:14:12 +02:00
evangelostsak c2400dc0d7 feat(ci): pass INFINITO_VPN through to the generated env 2026-09-26 08:31:32 +02:00
evangelostsak 89ab678a19 feat(ci): take the run's mesh mode when assigning rows
Also moves the title codec to label.py and re-exports it, which is what keeps
the file under the line cap.
2026-09-26 08:31:11 +02:00
evangelostsak 85e9112409 refactor(ci): split the job-title codec out of the assigner
Reading a title back into its axes is the inverse of building one, and it kept
axes.py over the 500-line cap once the mesh axis landed. The names stay
importable from axes so no consumer moves.
2026-09-26 08:31:01 +02:00
evangelostsak 4be2a381ab feat(ci): let a run hold the mesh axis on one side
auto rotates, enforced meshes every swarm row, disabled meshes none. No
exclusive: every swarm row can take the mesh, so there is nothing to drop.

A run that holds the axis outranks a per-token pin, because an operator who
asked for a whole sweep on one side means it.
2026-09-26 08:30:44 +02:00
evangelostsak 997b2f5091 fix(ci): count the mesh state as a narrowing axis
Without it two tokens differing only in the mesh state compare equal and
collapse into one, and a bare role&vpn reads as narrowing nothing.
2026-09-26 02:11:40 +02:00
evangelostsak 7be37c2369 feat(swarm-test): run the update pass over the mesh
The round already had two passes. The first brings the mesh up over the
container connection; the inventory is then switched to the mesh addresses and
the second pass runs over the tunnel, so no extra round and no extra CI time.
A full deploy of every role over the mesh is a stronger proof than a narrow
bootstrap round would have been.
2026-09-26 01:27:19 +02:00
evangelostsak db05ccc264 feat(swarm-test): put the controller on the mesh with a local connection
It runs the role like any member, but it drives the deploy rather than being
deployed to, so it keeps a local connection while the nodes keep the container
one.
2026-09-26 01:27:19 +02:00
evangelostsak e5de9675f3 feat(swarm-test): skip the mesh writer when the axis is off
The groups it resolves members from are absent, so writing is a no-op that
would otherwise fail on an empty mesh.
2026-09-25 22:34:37 +02:00
evangelostsak c400866fc2 feat(swarm-test): place the mesh role only when the axis asks for it
With the axis off the role is left out of both inventories entirely rather
than deployed disabled, so that arm proves swarm works with no mesh logic
anywhere in the path.
2026-09-25 22:34:37 +02:00
evangelostsak 4e4c68ac3f feat(ci): pass INFINITO_SWARM_VPN through to the generated env 2026-09-25 22:34:32 +02:00
evangelostsak 61d5206e3b feat(ci): pin the mesh state from a selection token
role&vpn / role&direct, and the same state read back off a pasted job title.
Own separator rather than more + words: + is the onion axis, and one
separator meaning two things could not be read back unambiguously.
2026-09-25 22:34:09 +02:00
evangelostsak 3128d74ba5 feat(ci): assign the mesh axis to every deploy row
Entries carry vpn, labels carry the glyph, and the artifact slug carries the
state so a meshed and a direct run of one row cannot collide on one artifact
name. Priority rows cover both states; compose never takes the axis.
2026-09-25 22:34:04 +02:00
evangelostsak edf28ac245 feat(ci): give the mesh axis its glyph pair
🔒 meshed, 🔓 direct. Neither carries a variation selector, which the token
parser strips before matching glyphs off a pasted job title.
2026-09-25 22:33:59 +02:00
evangelostsak 6391761c05 feat(ci): add the WireGuard mesh deploy axis
A swarm deploy runs either over the mesh or straight over the underlay, and
both have to keep working: the mesh joins physically separated clusters, so a
single-site swarm has no use for it and must not depend on it.

Quarters on sweep // 4 so it flips in step with neither the mode rotation nor
the onion, and a row walks every mode/tor/vpn combination over eight sweeps.
2026-09-25 22:33:55 +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
kevinveenbirkenbachandClaude Opus 5 5b8465260f feat(ci): deploy only the variants a change reaches
An image bump in a provider redeployed every variant of every consumer,
including the variants that switch that provider off and therefore never
deploy the bumped image at all. The diff-derived whitelist now narrows each
affected role to the variants whose own round closure still reaches a seed,
emitted as `role#0,2` selection tokens. That closure is walked on the same
variant-merged services maps the matrix planner builds its rounds from, so the
narrowing follows a parent's variant choice across every generation of the
chain. It covers every auto-derived whitelist: the weekly update PRs,
Dependabot, and any other diff-driven run.

The narrowing never shrinks the matrix on partial information. A seed role
keeps every variant, because its own files changed. A role reached only
through a run_after edge keeps every variant too, because the round closure
does not follow that edge and a role whose hits are empty is therefore
unnarrowed rather than dropped.

A pinned token naming a role the discovery query does not return now warns
instead of aborting the run. The diff-derived whitelist legitimately names
roles the lifecycle envelope filters out, and once it pins variants such a
token would abort every chunk; role names are still checked against roles/
before the run starts, so a typo is caught one layer earlier.

The round's variant merge moves out of the deploy inventory package into
utils/roles/applications/variants.py. The CI selection has to build the same
maps, and importing the deploy package for them pulls in a module that reads
INFINITO_SRC_DIR at import time, which the runner host does not set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 22:51:03 +02:00
evangelostsak 24d3cfdb07 fix(swarm): rewrite the meshes after the rotation gate
The gate mirrors the manager's host_vars onto every node, so the per-host mesh
each member needs is gone by the time the update pass deploys. Running the
writer again restores it, which is also what 'the tool runs before every
deploy' has to mean once anything rewrites the inventory mid-round.
2026-09-19 11:16:48 +02:00
kevinveenbirkenbachandClaude Opus 5 9b436a3162 fix(cache): key the project-tree walk on the root it walked
_all_project_files carried @lru_cache(maxsize=1) and read PROJECT_ROOT from the
module global inside its body, so the root was not part of the cache key. The
first call decided the answer for the rest of the process: point PROJECT_ROOT at
a fixture afterwards and the walk still reports the real repository, or, in the
other order, a sweep after the fixture is torn down enumerates a directory that
no longer exists.

Both failure modes are silent. iter_project_files, iter_project_files_with_content
and iter_non_ignored_files all sit on this one walk, 202 files in the tree use
them, and a lint whose corpus collapses to zero paths reports zero offenders and
passes. Nothing raises.

The root is now a parameter and therefore the key, with room for four of them.
_reset() still clears it through cache_clear(), and the only other caller is
iter_project_files, which passes str(PROJECT_ROOT) explicitly.

Live impact today is none, and that is worth stating: exactly one test patches
PROJECT_ROOT in this module, and it calls _reset() in both setUp and
addCleanup, so the defect was fully masked. Every other patch site in the repo
targets PROJECT_ROOT in a different module. This is about the next test that
forgets, not about a failure anyone has seen.

A regression test covers it rather than a comment. Negative control: with the
key removed the new test fails on the second root with

    Items in the first set but not the second: '/tmp/.../first.txt'

and passes once the key is back; the file is byte-identical to its pre-probe
state afterwards.

The lint docstring that quoted the old cache shape is deleted rather than
updated, because a cross-module implementation reference goes stale the moment
that module changes, which is exactly what happened here.

Same class as c4556fe72d, which keyed the applications SPOT by the groups its
payload reads. That one was a live defect in utils/cache/base.py; this is the
latent twin one module over.

Residual: maxsize=4 is a choice, not a measurement. More than four roots alive
at once evicts the oldest, which re-walks rather than answers wrongly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 07:44:02 +02:00
evangelostsak 6a0cbb916b fix(swarm): give the controller the lab address for its NFS mount
The cluster mounts the export over the mesh; the act runner is not a member
and mounts it over the lab network. Both addresses now travel in the extras
rather than one serving two topologies.
2026-09-19 00:29:25 +02:00
evangelostsak d3a990454f fix(swarm): address swarm and NFS on the mesh, not the underlay
The extras file pinned swarm.manager.advertise_addr and storage.nfs.server to
the lab addresses, and extras override the group_vars default. Every tunnel
came up and carried nothing: swarm kept advertising 192.168.244.10 and the
clients kept mounting 192.168.244.13, so the mesh existed beside the traffic it
was built for rather than under it.

Read from the host_vars the mesh writer produced earlier in the round rather
than recomputed, so the address allocation stays in one place, with the
underlay kept as the fallback for a round that has no mesh.

Caught in a drill whose gate had just gone green: tunnels healthy, handshakes
current, and swarm still on the underlay.
2026-09-18 20:19:09 +02:00
evangelostsak 0cbdf65636 fix(swarm): place svc-net-wireguard on every mesh member
The role only reached the three swarm nodes, which pull it as a dependency.
The NFS and backup hosts are spokes of the data mesh and got no placement at
all, so they never brought an interface up while the writer had already given
them keys and addresses.

That fails in a misleading place: the inventory describes the mesh correctly
and the hub holds peers for both, so the first symptom is the manager's gate
reporting peers unreachable rather than anything pointing at placement.

Observed in a live drill, where the role was included for swarm-mgr-01 and the
two workers only.
2026-09-18 17:05:06 +02:00
kevinveenbirkenbachandClaude Opus 5 2567c5ece1 feat(ci): let a manual run set the chunk size the formula would derive
chunk_size() multiplied the runner slots a chunk may assume by the waves the
queue window affords, and nothing could say otherwise. The derivation is the
right default -- too few rows leaves runners idle, too many push a chunk's tail
past the queue cancel -- but a run that wants smaller chunks had to edit
default.env to get them.

INFINITO_CI_CHUNK_SIZE now takes precedence when it is greater than zero, and
zero keeps the derivation. The input travels the same path offset already
travels: entry-manual-steer.yml offers the field and maps empty to 0,
call-orchestrator.yml hands it to all four chunk blocks, call-test-deploy.yml
exports it as the env var. It is a string rather than a number for the reason
resume_from_chunk already documents: a workflow_dispatch number input reaches
the caller as a string, and a number-typed workflow_call input then makes
GitHub refuse the job before it exists.

Two places in call-test-deploy.yml set it, not one. The plan summary resolves
the chunk size itself, so exporting it only for the app resolution would print
a plan that disagrees with the matrix the run then deploys.

Measured against the live helper: 0 gives 80 rows over 3 chunks, 25 gives 25
over 4, and returning to 0 restores 80 and 3.

The gate found three faults in the first attempt and all three are in this
commit: the key sorted after INFINITO_CI_CONCURRENCY rather than before it, the
dispatch description ran to 81 characters against a limit of 80, and the
retrigger fixture did not carry the new input. That last one matters beyond the
test: it is the check that would otherwise let a retrigger drop the chunk size
silently. carried_inputs() itself needed nothing, since it derives from the
declared inputs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 11:25:12 +02:00
evangelostsak 10d920e15b feat(swarm): write the meshes between inventory extension and deploy
Placed after extend_inventory, which is what creates the group memberships the
meshes resolve from and the sibling backup.yml the data mesh spans, and before
the deploy, which is the first step that needs a tunnel.

Both inventory files go in one invocation. A mesh written from only the cluster
inventory would silently omit the backup host, and nothing would notice until
the backup play could not reach the hub.
2026-09-18 10:36:14 +02:00
kevinveenbirkenbachandClaude Opus 5 86f7d8c4f5 fix(install): keep the collection check inside the slice that runs it
The image runs this module from a layer that holds three files of utils:
__init__.py, install/__init__.py and install/collections.py, the slice
ef45d69dc3 cut so the source tree cannot invalidate the collection install.
67f5398985 established that the module therefore stays on the standard
library, and d4ae5cf9f0 broke it 15 minutes later by routing the reads
through utils.cache.files to satisfy the cache-read lint. The import raises
ModuleNotFoundError: No module named 'utils.cache' in that layer, which no
import inside a full checkout can show, because this repository installs
utils into site-packages.

Nothing turned red. The caller reads only the exit code, and the module's
contract is exit 1 with the missing collections on stdout, so a crash - exit
1, nothing on stdout - is indistinguishable from work to do. Every image
build since has reinstalled every collection with --force-with-deps, which
is the cost the check exists to avoid. The deploy job the traceback was
found in finished green.

The reads go back to Path.read_text with a cache-read marker each. The cache
was never earning its place here: read_text is an lru_cache keyed on the
path, and one run reads the requirements file once and one MANIFEST.json per
collection, eight distinct paths, none of them twice. Copying utils/cache
into the slice instead would work - the closure is three stdlib-only files -
but it binds a package with 51 commits in the last twelve months to the
layer that installs ansible, in front of a cache with no possible hit.

The caller now exits 3 when a non-zero status arrives with an empty stdout,
a state the contract cannot produce, so the next crash stops the install
instead of quietly reinstalling everything. all.sh runs under set -e and
touches the stamp only after ansible.sh returns, so the failure also leaves
the stamp stale rather than recording an install that did not happen.

The probe that would have caught this builds the slice in a tempdir from the
Dockerfile's own COPY lines and runs the module inside it. It has to pass
python -S: without it the interpreter resolves every submodule from the
installed checkout however the slice is built, and the test passes on a
module that cannot import in the image. Verified by injecting the import
back into the tempdir copy, which reproduces the traceback verbatim.

Delta measured by loading the committed module and this one side by side:
identical on both requirements files, against the real collections directory
and an empty one, on a missing file, on non-UTF-8 input and on a corrupt
MANIFEST.json. The one behavioural difference is that a file changed within
a process is now read fresh rather than from the memo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 03:03:37 +02:00
kevinveenbirkenbachandClaude Opus 5 6e94e5b213 docs(cache): draw the SPOT lookup path and the three ways it lies
The applications SPOT is read through four places that each look
authoritative on their own: the process-global dict, the per-host carrier
fact, the templar render, and the key that decides between them. Which one
answers a given lookup is only visible when the order is written down, and
the order is what c4556fe72 turned out to depend on.

The diagram is scoped to that path and carries the three trip-wires on the
edges where they fire rather than in a list underneath: the process cache is
consulted before the host's own fact, the strategy's main process renders a
task name for the first host of a batch and every worker forked afterwards
inherits it, and carrier.py:68 stringifies only one side of its key
comparison, so a non-scalar key component silently stops matching.

Node labels avoid Jinja braces because GitHub's Mermaid renderer treats them
as node-shape syntax and drops the block.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 21:16:27 +02:00
kevinveenbirkenbachandClaude Opus 5 c4556fe72d fix(cache): key the applications SPOT by the groups its payload reads
A service flag may be written as `{{ '<role>' in group_names }}`; 114
meta/services.yml files do exactly that, and it is the only host-dependent
variable any of them uses. The rendered payload therefore differs per host
while the cache key did not: it was built from applications, users,
DOMAIN_PRIMARY and SYSTEM_EMAIL_DOMAIN alone, and in the failing job all
five host_vars files were byte-identical.

113113e553 moved the render out of every task on the premise that Ansible
forks a fresh worker per task, so the in-process cache is always cold. That
holds for workers and not for the strategy's main process, which renders a
task's name once for the first host of a batch. Anything cached there is
inherited by every worker forked afterwards, and applications.py consults
that process cache before the host's own carrier fact, so an inherited
entry outranks the correct answer.

Pretix#2 of run 35152776835 shows both sides in one task: `skipping:
[web-app-pretix-swarm-mgr-01]` against `included: dedicated.yml for
wrk-01, wrk-02`, eleven seconds before mgr-01 resolved its database to
`pretix_database`. The swarm was running the central `postgres_postgres`,
so the wait never converged and the job died after 600 attempts. Variants 0
and 1 pin the flag to a literal and both pass; only variant 2 inherits the
Jinja.

The groups join the key as a sorted comma-joined string rather than a
tuple, because carrier.py stringifies only one side of its key comparison.
A non-scalar component would make the carrier silently stop matching and
every lookup re-render.

domains derives from the applications view and inherits the same
dependency, so this closes a second instance that had not surfaced yet.
users reads group_names nowhere. No extra rendering results: group_names is
constant inside a worker, and the one process that sees several hosts is
the one the split is for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 21:12:42 +02:00
kevinveenbirkenbachandClaude Opus 5 e14e73fba0 refactor(docker): let a moving tag declare itself with the marker it already has
A pin outside semver was given its own 'docker-ref' marker on the theory
that every class needs one. That holds for a digest and not for a tag. A
digest pin declares something about the registry, that bytes move under a
standing tag, which no other choice says. A tag outside semver declares
that upstream publishes nothing orderable, which is exactly what
'docker-version' already means and what minio and the act runner already
carry on their version lines.

Keeping both would have put '# nocheck: docker-version, docker-ref' on
every such line: two keys, two checks, one sentence said twice. 'docker-ref'
is dropped instead. It lived in this one mapping, was never entered in the
suppression catalog, and no check ever read it.

The semver entry goes with it, because a semver pin never needs a
declaration. The test that asserted three distinct rules is replaced by two
that pin the separation where it is real: a digest marker differs from a
tag marker, and a semver pin has no marker at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 17:47:02 +02:00
kevinveenbirkenbachandClaude Opus 5 e66005ab07 fix(update): move a role's images as one release or not at all
Seven roles pin several images at one shared version, because those images
are one product: the four Jitsi components, six GitLab CNG images, eight
BigBlueButton services, three Penpot, plus Mastodon, Funkwhale and
Nextcloud Talk. The bump job walks entries one by one and takes each
image's own newest tag, so a run landing between upstream's first and last
push raises the components already there and leaves the rest behind. What
deploys afterwards is a mixed set, and for Jitsi a mixed set breaks video.

The window is real rather than theoretical. Measured on Docker Hub,
'stable-9457' exists for jitsi/prosody, jitsi/jicofo and jitsi/jvb but not
for jitsi/web, one build out of sixty.

Candidates are now grouped by role and current version, and the group is
held to the newest tag the intersection of all their tag lists still
offers. A group with nothing newer in common yields no update at all rather
than a partial one. A lone entry passes through untouched, so nothing
changes for the large majority.

The key is deliberately the role and the version, not the image namespace.
A namespace rule does not survive the data: prom/* carries four components
at four different versions, opentalk/* three, and minio, taigaio and
markoshust each two. Grouping by namespace would hold back pins that are
correctly independent.

Five of the seven roles have carried this exposure all along; the channel
grammar in the previous commit added the sixth by making Jitsi orderable at
all. This closes all of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 17:45:28 +02:00
kevinveenbirkenbachandClaude Opus 5 23fcad5ddf feat(update): read a release line written before the number as a family
Upstreams that ship several channels in parallel put the channel first:
LiteLLM publishes 'main-v1.77.7-stable' next to 'main-v1.77.7-nightly',
Jitsi ships 'stable-9646'. The grammar only allowed a leading 'v', so both
shapes parsed as nothing at all, sat at depth 0 next to 'latest', and the
bump job never looked at them.

The numeric core now takes an optional leading channel, and the channel
folds into the flavor the same way the patch letter already does.
Everything that is not the number is family; only the number is order. That
is what keeps '-stable' away from '-nightly' and 'stable-9646' away from a
bare '9646', which would leave the channel and change what the role
deploys.

The change is additive. Only a tag starting with letters followed by a dash
can match the new group, and every such tag failed the grammar outright
before, so no tag that parses today parses differently. The regression case
pins the four shapes that already worked, and the new cases pin the two
directions that must not happen along with the tags that must stay refused:
'act-latest', 'bookworm-slim', 'main-latest' and 'buildx-stable-1' carry a
channel but no version.

Two pins in this repository become orderable as a result: the four Jitsi
components and the LiteLLM gateway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 17:43:13 +02:00
kevinveenbirkenbachandClaude Opus 5 51dc06ad09 feat(update): order vendor patch counters and five-component versions
Two tag shapes in this repository name a fixed release yet failed the
semver gate, so the bump job skipped them and nothing could tell whether a
newer one existed: the Checkmk counter '2.4.0p32' and the Collabora
five-component '26.04.2.1.1'. Both returned depth 0 and fell into the
unorderable residue next to 'latest' and 'alpine'.

The numeric core now takes up to five components, and a counter written as
a letter plus a number is parsed into its own group. The letter joins the
flavor and the number joins the ordering key, which is what keeps
'2.4.0p32' in a different family from the four-component '2.4.0.32': a
bump between them would silently change what the role deploys. The number
in the key also makes p9 order below p10 instead of above it.

version_flavor reads the parse result positionally, so the wider tuple
leaves it untouched. Its contract does change for counter tags, which now
report the letter as their flavor; every shape the grammar already handled
keeps its classification, pinned by a regression case in the new test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:44:28 +02:00
kevinveenbirkenbachandClaude Opus 5 be6a25517c feat(docker): classify image pins and build pull references from the class
A digest pin was emitted as `name:sha256:...`, which no registry accepts:
image_source('ghcr.io/.../csp-checker', 'sha256:d559...') returned
'ghcr.io/.../csp-checker:3.0.0'. The separator depends on the pin class, so
the class has to be decided before the reference is built.

utils/docker/image/pin.py names the three classes in preference order
(semver, then digest, then a general ref that no registry can order) and
maps each to the nocheck marker that declares it. Both discovery entry
points now end in pull_reference(), so '@' versus ':' is decided in one
place instead of at every call site.

The semver pin lint points a digest at 'docker-digest' instead of
'docker-version', so the two fallbacks stay distinguishable in the
suppression catalog. It stays warn-only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:39:16 +02:00
kevinveenbirkenbachandClaude Opus 5 d4ae5cf9f0 fix(lint): put the cache-read markers where the rule reads them
suppressed_line_numbers matches the marker on the flagged line itself, and
ruff had split two of the calls so the comment landed behind the closing
paren, one line too low. The reads that can be cached now go through
utils.cache.files, which the bare interpreter imports fine; the two that
cannot keep a marker on a line short enough to stay whole.

The parser also gains the two guards utils/install/lint/pinned.py already
carries for the same bootstrap problem: its reading is cross-checked
against PyYAML on both requirements files, and a subprocess with yaml
made unimportable proves the module still works without it. That turns
the manual venv check into a standing test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 22:00:30 +02:00
kevinveenbirkenbachandClaude Opus 5 f0ecf3e337 style(update): apply the formatting the gate produced
The nocheck markers were written as long single lines; ruff splits the
call across three lines and leaves the marker behind the closing paren.
Committing the formatter's own output keeps the tree and the history in
step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:51:25 +02:00
kevinveenbirkenbachandClaude Opus 5 94baeb7502 fix(lint): meet the repository rules the collection work broke
The gate flagged six of my own changes. INFINITO_ANSIBLE_COLLECTIONS_SOURCE
reached default.env without a handler, so the generator dropped it
silently; ansible.sh then carried a shell-side default for the same key,
a second source that drifts. Both are now one declaration, read as
required, so a missing key fails loudly instead of falling back.

File reads route through the session cache except where the same run
rewrites the file, which is the documented opt-out and now says so per
call. The updater reads yaml through utils.cache.yaml rather than calling
safe_load itself.

restore_collection_history.sh was committed without its executable bit,
and the new workflow's name was 31 characters where the dispatch menu
truncates at 25; the explanatory wording lives in run-name instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:47:10 +02:00
kevinveenbirkenbachandClaude Opus 5 67f5398985 fix(install): keep the lint bootstrap off third-party imports
The lint installer imports this module, and it runs on the runner's bare
interpreter before a single dependency exists. Sharing the version check
with it therefore handed the bootstrap a PyYAML import, which killed it:
'ModuleNotFoundError: No module named yaml' raised through
utils/install/lint/__main__.py:35.

Routing the read through utils.cache.yaml would not have helped, that
imports yaml too. The requirements files are this repository's own and
hold one shape, so the pins are read with two regexes and the standard
library. A line that does not match the shape leaves the entry unpinned,
and unpinned already counts as unsatisfied, so the parser errs towards
installing rather than towards skipping.

Quoted versions are unwrapped: yaml accepts version: "13.4.0" and a
formatter may introduce it, and carrying the quotes into the comparison
would silently defeat the check instead of failing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:45:56 +02:00
kevinveenbirkenbachandClaude Opus 5 623ecc0248 feat(update): retune the preferred collection source from measured latency
Which of the two sources is faster is not a constant. galaxy.ansible.com
degraded from 207s to 687s over one day and then served 502s outright,
while the pinned git fallback carried the same install in 31s; a week
later the order may be the other way round. The daily job times both and
swaps the default only when the preferred one's median reaches three
times the other's over twelve samples.

A failed install is recorded as None and ranks worse than any duration,
so an outage counts instead of dropping out of the statistic. Below a
full window nothing changes, which keeps a single bad day from flipping
the default.

History lives in an artifact rather than the Actions cache: a schedule
run resolves to the default branch and has read-only cache access there,
so it could not carry the window forward.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:59:45 +02:00
kevinveenbirkenbachandClaude Opus 5 e992175fcc feat(update): keep the pinned Ansible collections from ageing unnoticed
Pinning the collections froze them, and nothing thaws a pin: dependabot
reads pyproject.toml, the docker updater reads image tags, and neither
sees a version inside requirements/. The new job resolves upstream
releases with git ls-remote --tags and opens a PR the same way the pip,
image and repository-ref updaters do.

Both requirement files are bumped together, because the git fallback has
to install what the Galaxy file declares; moving only one would break
that. A collection whose two files disagree, or whose version is not a
semver, is left alone -- that covers the branch ref and the commit pin,
which state an intent a bumper must not guess.

The job calls pr.sh rather than role_prs.sh: the latter splits per
sub-directory and would open no PR at all for flat paths like
requirements/*.yml.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:58:35 +02:00
kevinveenbirkenbachandClaude Opus 5 26e54b8f8e feat(install): pin the collections and stop refetching what is already there
The requirements named seven collections without versions, so every
install re-resolved them against galaxy.ansible.com and --force-with-deps
redownloaded all 37 MB even when nothing had changed. Exact pins make the
installed set checkable, and the check short-circuits the whole roundtrip:
make install-ansible drops from 8m24s to 0.077s, and a Galaxy outage stops
mattering once the pinned set is present.

The git fallback is pinned to the same versions so it installs what the
Galaxy file declares rather than whatever the branches currently hold.
kewlfft.aur pins a commit because its tags carry no galaxy.yml and master
has moved past the released version.

The lint installer kept a second, weaker truth about the same directory,
guessing from directory presence for three hardcoded collections; it now
shares the version-exact check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:38:50 +02:00
kevinveenbirkenbachandClaude Opus 5 8c60c5b6a6 style(swarm-matrix): mark the plan entry's include as unused
e1d513a3c0 replaced include=round_include with variant_scope in the payload resolve and left the loop variable without a reader, which ruff B007 rejects: 'Loop control variable round_include not used within loop body'. grep confirms the unpacking at matrix.py:388 is now its only occurrence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 14:03:14 +02:00
kevinveenbirkenbachandClaude Opus 5 e1d513a3c0 fix(swarm-matrix): bake a variant payload for every app the round deploys
The swarm matrix resolved variant payloads over the planner's round_include while provisioning the closure derive_includes returns. The two agree until a variant switches its couplings off: the planner then resolves no prerequisites, round_include collapses to the primary, and every deployed dependency silently keeps its base config. For svc-ai-litellm round 1 that meant web-app-keycloak ran with the base realm.totp_enabled true, so CONFIGURE_TOTP became a default required action and every SSO persona sat in a 300s predicate timeout until the job hit its wall clock at 20086s.

payload.py states the intent this restores: the role pulling a dependency in is what decides how lean the dependency runs. variant_scope keeps the decision next to the closure it filters and excludes the three lab infrastructure roles, whose payload would replace group-membership expressions such as {{ 'svc-swarm-node' in group_names }} with an unconditional true in every round.

Measured: round 1 goes from 1 to 29 payload apps and keycloak from absent to totp_enabled false, while the deployed closure stays 32. Repo-wide 62 of 280 rounds gain a real setting, dominated by postgres exposed in 48 and mariadb in 22; no round loses one, and no gained payload enables a service whose provider is not deployed. With disable set the scope shrinks instead, which is correct because a pruned provider is not deployed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 13:10:16 +02:00