Commit Graph
2780 Commits
Author SHA1 Message Date
kevinveenbirkenbachandClaude Opus 5 f8e79cf17f chore(tests): drop assertion-restating comments from two lookup suites
40 comments in test_database.py and test_sso_oidc_plugin.py restated the
assertion directly below them ("id should be present", "enabled/shared
surfaced") or narrated a step the test name already carries. The repo's
comment lint flagged all of them once the files entered its scope.

The sys.modules eviction rationale above _load_module moves into that
function's docstring, where behaviour documentation belongs; everything
else is deleted outright. Trailing comments go too: Python is linted via
tokenize, so unlike the line-based languages its inline comments are in
scope.

Verified: pytest over both files => 20 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 17:11:22 +02:00
kevinveenbirkenbachandClaude Opus 5 65c72fc374 fix(tests): one import form per module in the unittest suites
59 test files imported `unittest` and then `from unittest import mock`,
giving the same names two spellings so a call site no longer says which
one it follows. CodeQL flags this as py/import-and-import-from; at 59
occurrences it is the single largest item in the repo's code-scanning
backlog.

Keep `import unittest` and reach the submodule as `import unittest.mock
as mock`. Both are ast.Import, which is what the rule asks for, and
`mock.<attr>` call sites stay untouched — the diff is exactly one line
per file and nothing else.

The opposite direction would have been wrong here: PLR0402 rewrites
`import a.b as c` into `from a import b`, i.e. straight back into the
pattern CodeQL flags. It is deliberately absent from the ruff select
list in pyproject.toml, so nothing in the toolchain will undo this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 17:07:44 +02:00
kevinveenbirkenbachandClaude Opus 5 cc9599dde2 fix(ci-trigger): read the priority input from the job log
GitHub cuts display_title at 512 UTF-8 bytes and closes it with a
literal '...'. The run-name template renders priority second-to-last
and whitelist last, and on the --failed path the whitelist is empty, so
the cut always lands inside the priority list: 21 of 69 roles survived
the source run's title, the 21st a half-token.

untriggered_priority parsed that title and passed undecodable names
through verbatim, so the fragment reached the dispatched priority
input, became the orchestrator's whitelist, and hit the complexity
filter's lexer, whose word class has no rule for CJK. Sequencing died
with argparse exit 2 and took the run with it. The retrigger that is
supposed to repair a red run was breaking its successor.

Read the priority from the ##[group] Inputs block a called job's log
records instead: it carries all 69 roles. Depth-1 jobs only -- a deeper
one echoes its own workflow's inputs, which have no priority.

Drop the decode passthrough now that the source is lossless. The loud
failure is only affordable once a whole value exists; before, it would
have aborted every large retrigger.

dispatched_priority cross-checks against the title: it still proves a
priority line existed even when truncation mangled it, so an empty log
read is a broken reader -- expired logs, a moved block -- and not a run
without priority. Without that check the carry-over would vanish
silently, which is what the rest of this change exists to prevent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 14:56:46 +02:00
kevinveenbirkenbach 60c4f582cf Merge branch 'main' into fix/playwright/env-template-gate 2026-08-08 10:42:51 +02:00
kevinveenbirkenbach 4dcd820898 Merge branch 'main' into feature/svc-net-tor 2026-08-08 10:30:39 +02:00
kevinveenbirkenbachandClaude Opus 5 7cc61cff02 test(storage): mock disk_usage instead of sampling it live
Both docker_root_free_bytes tests compared the result against a second
live disk_usage() call, so any concurrent write between the two samples
failed them — reliably under the parallel `make test` targets, never in
isolation. Mock the call and assert which path was measured, which is
what the tests are about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 10:29:27 +02:00
kevinveenbirkenbachandClaude Opus 5 c634368957 fix(swarm-test): keep force-shared off disabled services
The swarm drill provisions a fresh inventory each round: _provision runs
the services disabler, which writes enabled: false, shared: false for
every service in `disable=`. _force_shared_db then ran four seconds
later and flipped shared back to true, because it only ever looked at
shared and never at enabled.

Its key set comes from every flagged top-level key of the svc-db-* roles,
so it covers svc-db-mariadb's own consumer entries tor and
container_backup, both of which sit on the disable list. The result was
enabled=False, shared=True, and the deploy aborted on the consistency
guard in services_disabler.

Guard the flip on enabled instead of narrowing the key set: the key set
would only drop today's two keys, while postgres, redis, memcached,
qdrant, typesense and ldap are all equally on the live disable list.
`is not False` keeps flipping entries that carry no enabled key at all.

derive_includes mirrors force_shared_db by contract, so it gets the same
condition; otherwise the dependency walk sees a different topology than
the deploy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 08:33:04 +02:00
kevinveenbirkenbach eb1da235ba Merge branch 'feature/swarm-nfs' into fix/playwright/env-template-gate 2026-08-07 17:58:40 +02:00
kevinveenbirkenbach d235dd7d7e Merge branch 'feature/swarm-nfs' into feature/svc-net-tor 2026-08-07 17:28:13 +02:00
kevinveenbirkenbachandClaude Opus 5 a5ca02d6d9 fix(ci): build the reference list with extend
ruff PERF401 rejected the append-in-loop and failed make quality at autoformat, before any test ran. The generator keeps order, filter and output identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 17:19:46 +02:00
kevinveenbirkenbachandClaude Opus 5 71888d86af refactor(ci): prefix workflows by trigger
The workflow directory mixed entry points, reusable stages and scheduled jobs into one flat list sorted by topic, so the first question about any file - what starts it - was the one the name did not answer. Every workflow now carries one of three prefixes, derived from its on: block: cron- for a schedule trigger, call- for one only reachable through workflow_call, entry- for everything a repository event or a human starts. Scheduled workflows additionally carry an alarm clock as the first character of their name.

Two lints keep it that way. One derives the expected prefix from the triggers and compares it against the file name; it flagged all 17 offenders before the rename and none after. The other resolves every .github/workflows/<file> string in the repository against the directory, which is what a rename silently breaks in Markdown links, Python constants and shell API URLs alike.

Renaming pulled 69 pre-existing comments into the comment lint's changed-file scope. The vendored CodeQL template got the nocheck marker its scorecard sibling already carried, two workflow headers moved above name: where they count as file headers, and the remaining banners were deleted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 17:05:51 +02:00
kevinveenbirkenbachandClaude Opus 5 4d245a9180 fix(tests): keep the distro SPOT lint out of .claude
The walk excluded only .git and node_modules and ignores .gitignore, so it read agent session artifacts such as .claude/loop.md, which the sandbox denies and which exist only while a run is in flight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 13:35:51 +02:00
kevinveenbirkenbachandClaude Opus 5 00c02251c0 Merge branch 'feature/swarm-nfs' into feature/svc-net-tor
Every conflicted hunk resolves onto the swarm-nfs side, because on all six
files it is the strict superset: it carries the display-name codec
(utils/roles/display.py) the CI role selections now encode through, and the
sequencing glyph move from 910748ec4. svc-net-tor holds no unique content on
any of them -- runs.py even had non-conflicted hunks already calling `codec`,
so keeping its side would have left a NameError.

The lint fallout is settled in the same commit, as 4afc9b770 did for the
previous merge: the shell-or-true lint lives on svc-net-tor (c505ce746) and
swarm-nfs brings two unmarked `|| true` sites with it. Both get a nocheck
reason rather than a rewrite, because neither swallows anything -- the
slaptest call is the measured exception of c4bbbba93 and has_cn_config decides
right below it, and the dockerd restart is followed by an is-active poll that
fatals. In both the exit code was never the authority.

make quality green end to end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 07:47:15 +02:00
kevinveenbirkenbach 448eb3ae92 Merge branch 'feature/swarm-nfs' of github.com:kevinveenbirkenbach/infinito-nexus-core into fix/playwright/env-template-gate 2026-08-06 23:23:58 +02:00
kevinveenbirkenbach eccb502197 Merge branch 'feature/swarm-nfs' into feature/ai-agent-employees
One conflict, in the category catalogue: this branch added a virt category for
the sandboxed container runtimes, the other did not.

Keeping our side alone would have closed the conflict and broken the build. The
same merge introduces a test requiring every category to declare a hanzi label,
unique among its siblings, and virt predates that rule and carries none. It now
declares 沙箱, checked against its ten siblings for a collision.
2026-08-06 21:49:56 +02:00
kevinveenbirkenbach f6b361f42c feat(agents): add the worktree skills and the path they write to
A second branch needs its own checkout to be worked on in parallel, with its own
slot, subnet, ports and container names so two deploys do not take each other's.
The two skills route to the make targets that create and release one.

The sandbox gains the worktree directory as a writable path, without which the
checkout cannot be created and the skills are unusable. It is the only change
to the permission file: one write path, no deny or ask rule touched.
2026-08-06 21:34:05 +02:00
kevinveenbirkenbach 3b22cd4073 feat(web-app-n8n): provision the bidirectional MCP surface
n8n is the only role that speaks MCP in both directions, consuming other
servers and serving one of its own.

The provisioner carries the auth cookie by hand rather than through a
CookieJar. n8n sets it with Secure, which is right for the browser-facing
origin, so a jar drops it over the plain-http internal address and every call
after the login answers 401. Setting N8N_SECURE_COOKIE=false would have fixed
the provisioning at the cost of the cookie the browser actually relies on, so
the session is carried explicitly instead.

The mcp role is declared in the role's rbac metadata, without which the group
path lookup hard-fails, and a Playwright spec proves the surface serves a guest
nothing. That check needs no credential, which is why it belongs in Playwright
while the contract probe with the real bearer stays at deploy time and out of
the trace.
2026-08-06 21:17:17 +02:00
kevinveenbirkenbach 37e74ddd63 fix(inventory): generate user passwords the exec chain does not mangle
A user password reaches its application through Ansible, a shell, a container
exec -e and finally the runtime's own environment reader. The generator seeded
it from the same alphabet as the become password, punctuation included, and
that does not survive the chain intact.

Three roles had already worked around it by declaring a separate alphanumeric
credential for an identity that their meta/users.yml already declares, and
saying so in the description. Removing those without fixing the generator would
have reintroduced exactly the bug they were built against, so the generator is
fixed first and all three now read the user's own password. At length 64 the
smaller alphabet costs nothing worth measuring, and the become password keeps
the old one because it travels a different path.

Zammad additionally used the bot's password for its wizard-bypass admin, one
secret for two users. Each has its own declaration and now its own value.

A test asserts the generated password is alphanumeric, so a future change to
the alphabet fails there rather than inside a Ruby ENV.fetch in a container.
2026-08-06 21:08:21 +02:00
kevinveenbirkenbach 23ce4355e6 feat(web-app-flowise): build our own image and wire it as an MCP client
The published image lies about its version: flowiseai/flowise 3.1.3, 3.1.4 and
latest all ship package 3.1.2, measured in the running container rather than
inferred from the tag. An unpinned global npm install layer is reused between
releases. We build from the declared version instead.

The build installs locally rather than globally because flowise declares node
^24 from 3.1.3 on while resolving better-sqlite3 to 9.6.0, whose binding.gyp
pins -std=c++17 against node 24 headers demanding C++20. That version cannot
build there at all, and an npm override is only honoured for a local install.

Three upstream behaviours were read at the pinned source before being worked
around: cookie authentication is routed only behind an x-request-from header,
the key controller rejects an empty permissions list, and key creation answers
with the whole key list instead of the new key.

The identity password is written straight into the user row because flowise
accepts a password at registration only, and registration works once; a
redeploy carrying a rotated credential otherwise meets an account whose hash
still encodes the previous one.

Server registration refuses anything that does not speak SSE. This flowise
authorizes with an SSE toolkit, so registering another transport would claim a
connection that cannot be made.
2026-08-06 20:59:58 +02:00
kevinveenbirkenbach 7030bf60cf test(docker): forbid ARG defaults in every Dockerfile
A defaulted build argument answers a question the caller failed to answer. The
build then succeeds against a base image, version or path nobody chose, and the
mistake surfaces as wrong behaviour at runtime instead of a failed build.

web-app-bridgy-fed shows the cost: its metadata declared python 3.14 while the
Dockerfile default silently built against 3.12. Both look plausible, so nothing
pointed at the drift. It and web-app-postmarks now take the base image from the
same services declaration the rest of the role reads.

The lint walks every tracked Dockerfile, template variants and paths outside
roles/ included, which the existing ARG-scope check does not. A second
assertion fails when the scan finds no Dockerfile at all, so the rule cannot
pass vacuously if the walk ever breaks.

Touching the two Dockerfiles brought their pre-existing narration under the
comment lint; what carried substance moved into the file header.
2026-08-06 20:58:44 +02:00
kevinveenbirkenbachandClaude Opus 5 910748ec41 refactor(ci): mark deploy-mode sequencing with a many-legged crawler
The traffic light said "gate" everywhere else in the pipeline, on the PR
scope check and on the dependency gate, so reusing it for the deploy-mode
order made an ordering knob look like another pass/fail barrier. A crawler
reads as what sequencing is: segments following each other in a line.

Both failure-report entries in the orchestrator move to the ladybug in the
same commit, because they already held the caterpillar and one workflow
cannot spend the same glyph on ordering and on failures. The ladybug is what
the bug-report issue template already uses.

Only the test fixture spells the glyph out; the parser derives its segment
heads from the workflow's own run-name, so it follows the change on its own.
The traffic light stays where it means a gate, and as the release-candidate
symbol in the glossary.

A run title created before this commit still carries the traffic light, so
recovering its sequencing input from the title yields nothing and a retrigger
leaves that one input on the workflow default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 20:58:36 +02:00
kevinveenbirkenbachandClaude Opus 5 ed0f5af9db feat(playwright): make a blocked persona state why
Blocking a persona removes the only end-to-end proof that a role's auth chain
works. docs/contributing/artefact/files/role/playwright.specs.js.md:146 has
always required each flag to carry a one-line nocheck stating the role contract
that justifies it, plus a matching paragraph in the role's README or TODO.
Nothing enforced it, so 89 flags across 46 roles carried neither.

The lint mirrors test_has_spec's shape and uses is_suppressed_at with
mode="line-above", so the marker must sit directly above its flag -- a blank
line between them breaks the association, which is what the persona helper's
reader will look at. A literal =false blocks nothing and is exempt; the
sso-conditional ternary is not, because it blocks in every variant that pins
services.sso.enabled false.

Writing 89 reasons turned out to be the point. Five roles audited in one pass
blocked for five entirely different mechanisms, all of which read as the same
shrug behind a bare =true: pgAdmin's login id is the account e-mail rather than
the Keycloak username; phpMyAdmin authenticates as the MariaDB root account;
GitLab's seeded password belongs to the built-in root, not the administrator;
Mastodon's tootctl accounts create never sets a password; phpLDAPadmin binds
with an LDAP DN and a different secret. Further out: MediaWiki removes the form
that would accept the Keycloak secret ($wgPluggableAuth_EnableLocalLogin=false)
and bounces anonymous requests back into the IdP, so the unauthenticated
landing the contract asserts can never be observed; Mattermost Team Edition has
no native OIDC, so Keycloak rides the GitLab OAuth slot behind a relabelled
link; MinIO's console returns redirectRules: null and renders no SSO button at
all; XWiki's administrator is an XWikiUsers object with no password property.

Two flags turned out to be unjustifiable and are gone rather than annotated.
web-app-mailu gated PERSONA_GUEST_BLOCKED on services.dashboard.enabled, but
runGuestFlow does one anonymous goto and asserts no signed-in marker -- it
never touches the dashboard. A leftover from a helper version that entered
through the dashboard account menu. And web-app-mig's rationale claimed the
role is an in-app module of web-app-dashboard; it has its own compose stack,
healthcheck and canonical domain, and dashboard is merely a tile consumer.

Nine of the reasons say plainly that the block is not a role property but a gap
in the shared persona helpers -- decidim's sign-out is a data-method link the
generic click cannot activate, fider's SSO entry is a two-step modal, taiga,
wordpress, xwiki, odoo and penpot bind OIDC on a path the personas never visit,
jenkins blocks unconditionally although its OIDC variant looks runnable, and
opentalk's own spec proves both personas work. That list did not exist before;
it is the actual yield here, not the 89 comment lines.

45 pre-existing comments came into the comment lint's scope because this sweep
touched their files -- it only inspects files that differ from HEAD. 28 of them
were the banner "Service gate flags rendered for closure.", every one sitting
above a line that is not a service gate flag at all, orphaned by the
alphabetical re-sort these templates once got.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 20:36:39 +02:00
kevinveenbirkenbach 06bc856c32 refactor(mcp): make MCP a role topic instead of a service entry
The block sat under services but never behaved like a service. A service has
exactly one providing role, declared with provides, so the registry can map the
key to that role and consumers bind their flag to its presence. No role declares
provides: mcp. There are seventeen providers and the pairing is done by
allowed_consumers inside the block itself.

Every rule that enforces the service model therefore needed an exemption for it:
a shared-provider suppression in fourteen roles, a playwright-service-flag
suppression, and two freshly added exemptions in the dynamic-flag and
variants-static-override checks. Four independent rules, four escape hatches,
one cause. They are all gone now, together with a bond key that only the
resource budgeter reads and only for entries under services, and a wordpress
addon bridge that pointed at a service block that no longer exists.

The block moves to meta/mcp.yml. The applications loader maps meta/<name>.yml to
applications.<app>.<name> generically, so the move needs no wiring; the file
takes the same shape as meta/rbac.yml. Every config path follows, the variant
overrides are lifted out of their services map, and the vocabulary module leaves
the services package because it no longer describes one. roles_with_service
gains an explicit topic parameter rather than an implicit fallback, so nothing
silently resolves against the wrong container.

Files that are otherwise unrelated to this move are included where the committed
tree would otherwise keep pointing at a path that no longer resolves: thirteen
vars files, eleven playwright env templates and one task file.
2026-08-06 20:36:00 +02:00
kevinveenbirkenbachandClaude Opus 5 58e18872ce refactor(ci): tighten the display name to one separator
The category labels now run together into a single block instead of being
separated from each other, the glyph sits flush against that block, and the
only `·` left is the one between the categories and the role's own fragment:
`🐳网络应用·Nextcloud` where it used to read `🐳 网络·应用·Nextcloud`.

The deploy-job parser drops the whitespace requirement behind the glyph in
the same move. Without it no job name from a fresh run matches any more, and
the retrigger would find zero roles without reporting an error.

The name still never holds a space, so the heading keeps collapsing its own
whitespace to `·` and the space-separated whitelist and priority inputs stay
parseable with more than one role. Empty parts are dropped when joining, so a
role with no category at all (pkgmgr, test-e2e-*) does not gain a leading
separator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 20:09:53 +02:00
kevinveenbirkenbachandClaude Opus 5 05d85bcc40 test(sys-ctl-hlth-volumes): spell out that a non-zero exit is expected
The script returns its anomaly count as the exit code and three of the five
cases assert on it, so the run must not raise. check=False is the default,
but stating it keeps someone from adding check=True and turning those three
assertions into a CalledProcessError.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 20:09:53 +02:00
kevinveenbirkenbachandClaude Opus 5 4cd4eb8504 fix(sys-svc-container): select orphaned scopes by age, not by dockerd
The first version asked dockerd which containers it still knew and treated
the rest as orphaned. That predicate can never fire in time. dockerd keeps
reporting an orphan as running for minutes, then synthesises an exit it never
verified, so the container is either still listed as running or already listed
as exited, and `docker ps -a` covers both. On one captured manager 17 of 18
orphaned scopes carried an Exited container that was still listed, so they
were skipped; the reap found nothing and the unconditional dockerd restart
that followed took the node's tasks down for no gain.

A restarted containerd re-adopts none of the shims it left behind: across 13
observed restarts, 96 of 96 were leaked and the counts match the orphaned
scopes node by node. Age is therefore the exact criterion, and it holds the
moment the damage happens rather than minutes later. It also drops the docker
CLI from the script entirely, so a wedged daemon can no longer hang the play.

Around that: the stop tolerates systemctl's "no such unit" because the scope
vanishing is the wanted outcome, both calls are bounded so a hung unit cannot
block forever, each stopped scope is verified gone, and dockerd is polled
until it is active instead of trusting the restart's return code. A unit that
systemd no longer knows answers 0 for its start timestamp, which would sort
before every real one, so that answer is treated as unknown rather than old.

The tests drive the real script against a stubbed systemctl and cover the
selection, both filters, and every failure branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 20:09:53 +02:00
kevinveenbirkenbachandClaude Opus 5 add00234c9 feat(ci): name deploy jobs after the role, not its id
CI deploy jobs were titled with the raw role id, so a run page read as a
column of dash-joined prefixes. They now carry a display name: the category
path as hanzi labels, then the role's README heading or the part of the id
the categories do not already say, whichever is shorter.

utils/roles/display.py owns both directions. Encoding happens once, at
discovery time, as a `label` key on every matrix entry; the workflows render
`<glyph> ${{ matrix.label }}` and keep the glyph as a literal because the
retrigger reads the deploy mode off it.

Decoding is what makes the retrigger keep working. It accepts a display name
with or without its variant shard and a bare role id unchanged, so job names
from runs that predate this still parse and a hand-typed dispatch never has
to spell hanzi. The parser therefore stopped matching an id-shaped tail and
asks the codec instead; a job the codec does not recognise is skipped rather
than counted as a role.

The role selections go out as display names too, so the run title reads like
the jobs it spawns. Four consumers decode them back, each the single place
the value is actually used: the whitelist resolver before it checks the role
directory, the discovery query on its env, and the sequencing and plan CLIs
on their arguments.

Every fragment is joined by `·`, including before the title, and the heading
loses its whitespace and any emoji variation selector. A display name holding
a space would make the space-separated whitelist and priority inputs
unparseable with more than one role.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 20:09:53 +02:00
kevinveenbirkenbachandClaude Opus 5 851795f794 feat(categories): give every category a hanzi label
Adds a `hanzi` field to every node in meta/categories.yml plus a lint that
keeps it there. The labels are compact two-character fragments that compose
into a display path (sys-ctl-cln reads as 系统·控制·清理), so a category tree
that is otherwise only navigable by dash-joined role prefixes gains a short
human-facing form.

The lint asserts presence on every node and uniqueness among siblings, not
across the whole tree: the labels are path fragments and repeat across
branches on purpose (web-svc and svc both carry 服务), so sibling scope is
what actually guarantees a unique display path.

test_paths.py gained `hanzi` in its attribute skip list; without it the new
key reads as a sub-category and the test demands roles/web-hanzi,
roles/svc-hanzi and friends.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 20:09:53 +02:00
kevinveenbirkenbachandClaude Opus 5 e442e059f0 fix(sys-ctl-hlth-volumes): resolve volume holders through one lookup
The bootstrap check and the report disagreed about which containers hold an
anonymous volume. The check enumerated running containers and filtered them
in the Go template, the report asked the engine for all containers including
stopped ones, and both carried their own copy of the mount-path template. A
container reaped mid-init therefore kept its anonymous bootstrap volume
attached, escaped the whitelist, and was counted as an anomaly, so the health
unit exited non-zero on every retry while the cleanup it triggers could not
prune a volume that is still attached.

The check also concatenated the destinations of every holder into one string
before comparing it against the bootstrap path, so two containers holding the
same volume yielded a doubled path and never matched.

Both lookups now go through one helper each and the bootstrap decision is per
container, which removes the divergence by construction rather than by
keeping two call sites in sync. Asking the engine to filter also drops the
inspect call per container that the scan performed for every volume.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 20:09:52 +02:00
kevinveenbirkenbachandClaude Opus 5 567cadd4b7 fix(sys-svc-container): resync dockerd after a containerd restart
A containerd restart under a live dockerd orphans the container shims. The
new containerd does not re-adopt them, dockerd keeps stale task handles and
force-deletes the container records while the processes stay alive and keep
their file locks, so every replacement task is blocked by its own
predecessor: mariadb aborts on the Aria and InnoDB locks, slapd returns
LDAP_OTHER from the LMDB reader lock, and an exec into a healthcheck-less
service fails with "task not found".

Package versions do not reveal that state, because containerd restarts into
the version it was upgraded to and dockerd is not upgraded at all. The
signal is the unit start order: containerd entering active later than
dockerd means it came up underneath it.

Reaping is done on the systemd scopes rather than through the engine,
because dockerd has already dropped the records and its own force-kill
cannot reach the surviving processes.

Runs one node at a time: a package upgrade restarts containerd on every node
of the cluster at once, and simultaneous dockerd restarts can cost the swarm
managers their quorum.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 20:09:52 +02:00
kevinveenbirkenbach f4398e1c58 feat(inventory): pin a password on every user the roles declare
A user definition that declares no password falls back to
{{ 42 | strong_password }}. That is a template, not a value: it is re-rendered
at every use site, so the task that creates an account and the task that later
authenticates as it read two different secrets. The account exists and the
login fails. Inventory creation now decides the value once, after the roles are
resolved and next to the role credentials that were already generated there.
The scope follows the deployment: only roles resolved into this inventory
contribute users, an existing password is never overwritten, and a second run
writes nothing.

Building it duplicated what the package already carried three to seven times
over: an ensure-map helper defined three times, the same fifteen-line
round-trip load repeated seven times, and the vault encrypt-and-reparse dance
twice. They now live in one module, the round-trip counterpart to the existing
read-only yaml_io. host_vars drops from 213 to 130 lines, and its two override
entry points share the merge they used to copy. Behaviour is unchanged, and
that is measured rather than asserted: the committed versions and the new ones
were driven against the same host_vars and produced byte-identical output for
all six writers, comment placement and vault block layout included.

The adapter probes move into their own folder because a shared file-name prefix
reads as a folder in disguise, and the lint that enforces it scans the tracked
file list, so the repository was failing on it. A test that was defined at
module level instead of inside its class, and therefore never ran, is indented
back in.
2026-08-06 19:54:19 +02:00
kevinveenbirkenbachandClaude Opus 5 3fb2d57e4a fix(playwright): make the env-template lint's opt-out usable and its scope match the runner
The lint shipped in the previous commit had three defects, all reproduced in a
throwaway copy of the tree rather than argued.

Its documented opt-out could not be used. The docstring said to put
`# nocheck: playwright-env-template` on the first line of the role's spec.
Every spec is a CommonJS .js file, where a leading `#` is a hard parse error --
`node --check` refuses it. Following the instruction broke the file the lint
was protecting. It is `//` now, and the marker may sit anywhere in the head
rather than on line 1, which the mandatory `require` already occupies.

Its suppression check was a bare substring match: `if _RULE in
read_text(...).split("\n", 1)[0]`. Any prose containing the rule name disarmed
it -- proven by deleting a template and writing
`// TODO: someone still owes this role a playwright-env-template`, which the
lint then passed. A typo in the rule name disarmed it just as well. It now uses
`is_suppressed_in_head` from utils/annotations/suppress.py, the shared grammar
every other lint here consumes; this was the only one with a hand-rolled parse.

Its scope followed test_has_spec's `web-app-` prefix, but the mechanism it
guards does not: discover_playwright_roles.py globs
`rglob("templates/playwright.env.j2")` with no prefix filter. Six web-svc-*
roles ship specs. All six carry a template today, so the hole was latent, but
consistency with a narrower sibling is not correctness.

Verified in the copy: missing template -> fails; bare mention of the rule name
-> still fails; a real `// nocheck` in the head -> passes, and node --check
accepts the file. In the tree: tests/lint + tests/integration 532 passed,
1548 subtests, and the lint itself green at 82 specs against 82 templates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 18:02:08 +02:00
kevinveenbirkenbach a53d17e2a8 feat(mcp): scope the mcp role per application and prove the boundary
The mcp role sat in the administrator's unscoped roles list. That list carries
no application, and the RBAC builders admit any user whose roles contain the
name, so the single entry granted mcp in every deployed application at once.
It is replaced by application_roles, a mapping of application to roles, which
the user cache now reads and passes through. Both directory builders resolve
membership through one shared function, so the Keycloak and LDAP paths cannot
drift.

The member list never reached its consumer. mcp_group_members yielded username
strings while the Open WebUI reconciler calls member.get("email"), so the first
real grant would have crashed the deploy. It went unnoticed because the
reconciler returns early on an empty list and nobody held the role. The filter
now yields username and email, which is what the reconciler needs to match
accounts Open WebUI created from an OIDC claim, whose display name need not be
the username. A test drives the real filter output through the real script so
the two sides cannot diverge again.

The Playwright scenario walks the boundary end to end: biber is served no MCP
tools, the administrator adds him to the provider's LDAP mcp group in LAM, and
after signing in again the tools are served. LAM is reached only when it is
present in the inventory, so no service edge or variant is added for it.

The administrator spec inverts its last assertion. With the role scoped, an
administrator holds no application's mcp group, and BYPASS_ADMIN_ACCESS_CONTROL
is false, so the tools must not be served to them either.
2026-08-06 15:01:12 +02:00
kevinveenbirkenbach 10de64ff78 fix(networks): give MCP overlay membership a direction
A provider overlay keyed on services.mcp.enabled and .shared admitted every
MCP-capable role, servers included, because those flags carry no direction. Each
provider's network therefore collected every other provider and the result was a
mesh in which any sidecar could reach any application. The new mcp_client
consumer kind additionally requires services.mcp.direction to be client or both,
and the four provider overlays switch to it.

A sidecar rendered its networks with the same lookup as its provider and
inherited that provider's consumer attachments, which it neither needs nor
should have. own_network_only keeps it on the provider network alone. A provider
whose own topology is default_net contributes no attachment line at all, and a
bare networks: key is not a list, so the flag returns nothing in that case and
lets the sidecar fall back to the project default, which is that provider's own
network.
2026-08-06 14:27:07 +02:00
kevinveenbirkenbachandClaude Opus 5 92faba5d2d feat(playwright): make the env template mandatory so specs actually run
test_has_spec.py forces every roles/web-app-* to ship a Playwright spec and
calls a missing one a hard error, because the persona contract must never be
silently absent from the deploy capstone. Nothing forced the other half. The
runner discovers roles by rglob('templates/playwright.env.j2')
(discover_playwright_roles.py:53), so a spec without that file is staged
nowhere and executed never.

21 of 76 web-app roles were in exactly that state. Verified rather than
inferred: running the discovery filter directly returns 61 roles and none of
the 21 is among them, `make compose-playwright` refuses without a rendered
.env from a prior deploy, and `git log --all` on those paths shows the file
never existed on any branch -- the single hit is web-app-gitlab on a fork
branch. Their specs have never run once. 15 of the 21 deploy in CI today,
reporting green while their only functional verification never fires.

The new lint mirrors test_has_spec and carries a per-role opt-out that is
used nowhere: uniform beats exceptions.

Every one of the 21 now has a template written against its own
meta/services.yml rather than a shared skeleton -- espocrm splits
ADMIN_PASSWORD from ADMIN_NATIVE_PASSWORD because OIDC_ALLOW_ADMIN_USER
leaves both login paths open, the four permanently auth-less roles declare
their personas blocked instead of shipping a knowingly-red spec, and
web-app-mastodon gained a real test-seaweedfs.js because no honest
suppression existed for a role that does route Paperclip to S3.

web-app-magento moves pre-alpha -> alpha here: it now has a spec that runs,
which is what the lifecycle gate is about.

Six meta/services.yml files change only where adding a template falsified an
existing suppression -- gitlab and shopware both claimed the role 'ships no
templates/playwright.env.j2', which stopped being true in this commit.

Not resolved here, and the adversarial review says so plainly: the specs are
enabled, not proven. None has executed against a live stack. The review found
three classes of defect that land in follow-up commits rather than being
smuggled in silently -- persona blocks that are missing or should be tied to
sso.enabled, eleven suppressions naming a sys-svc-mail test suite that does
not exist, and eight claiming a CSP assertion that cannot fire because no
consumer role renders CSS_BASE_URL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:17:55 +02:00
kevinveenbirkenbach 7c1d733a85 feat(mcp): add the service vocabulary, discovery and rendering layer
Defines what a services.mcp block may declare and enforces it: classification,
transport, auth, implementation and the provenance fields that make an upstream
claim checkable. The lint refuses an unknown key anywhere in the block and
refuses a provider whose allowed consumers cannot speak its transport or its
auth scheme.

Discovery resolves each provider's credential and endpoint per consumer and
per deployment, rejecting a server with a named reason. Transport, auth and
endpoint faults are fatal; a missing credential is not, because it also occurs
when the provider is simply not provisioned yet in the same play.

The filters render the Authorization header from the declared scheme, build
Open WebUI's tool-server connections with their group grants, and derive the
members of those groups from the RBAC assignments. Role scoping gains
application-scoped roles, so an mcp role only counts when it is granted on a
specific application.

roles_with_service gains a scope and a direction filter, and drops the canonical
domain requirement for direction-filtered entries: an MCP URL is built from the
service key and the internal port, so a provider without a public vhost stays
valid.
2026-08-06 14:13:26 +02:00
kevinveenbirkenbachandClaude Opus 5 683de0ab9a feat(svc-ai-mcp-adapter): add the reusable per-provider MCP adapter
One audited implementation, instantiated once per provider application rather
than deployed as a single process holding every application's credential. Each
instance joins only its provider's network, carries only that provider's
least-privileged credential and serves a distinct endpoint.

Every decision lives in the policy layer, which validates the contract at
startup rather than at first call, so an instance missing a limit, an
allowlist or the schema hash never reaches a client. The server itself only
moves bytes over three JSON-RPC methods, with no stream state to get wrong.
Its upstream header is configurable because only half the providers accept an
RFC 6750 bearer.

The contract probe runs at deploy time rather than in Playwright, because it
needs the provider's real credential and a Playwright trace would capture it.
It proves, over both transports, that an unauthenticated caller is refused,
that a wrongly authenticated one is refused, that the handshake completes,
that the served tool list is exactly the contracted one, and that one
deterministic read answers without an error. Steps one and two are what
separate a guarded endpoint from one that merely happens to be unreachable.

The isolation probe counts the sidecar's network interfaces from inside it.
Reachability cannot be used here: an unknown name does not fail to resolve in
this environment, it falls through to a wildcard resolver that answers every
name with one reachable address.

The role is inert until a provider includes it; those inclusions follow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:04:08 +02:00
kevinveenbirkenbachandClaude Opus 5 9d7fc70900 perf(sys-service-loader): scope the preload to the whitelist closure
A whitelist-scoped update preloaded and fully executed every provider role
the host is a member of, because the gate only asked whether the role was in
group_names. On this inventory that meant a single-app update built compose
contexts, rendered configs and vhosts for 17 unrelated applications.

reachable_roles() is lifted out of applications_if_group_and_all_deps() so the
same traversal can start from an arbitrary seed set instead of group_names.
The old function now calls it and filters as before, so every existing caller
keeps its result. A new application_closure lookup exposes it to Ansible, and
sys-service-loader gates each preload on the whitelist's closure. An empty
whitelist yields an empty closure, which the gate reads as the full inventory
scope, so a no-apps deploy is unchanged.

Measured on mode=update apps=web-app-snipe-it: 42:49 before, 17:18 after, with
ok tasks dropping from 4903 to 2030. Nothing outside the closure ran, and
snipe-it's real dependencies (mariadb, redis, openldap, postgres, keycloak,
mailu, prometheus, seaweedfs, cdn, logout) all still did.

The closure also gates the required branch, deliberately. `required` reads as
a global property but plugins/lookup/service.py derives it from group_names,
so it means "some provisioned app needs this" and admits nearly every service.
Exempting it was measured at 42:39 against 17:18 for the same deploy, which is
the entire saving. A service whose provider lies outside the closure is by
definition required by an application outside the deploy scope.

Out of scope: seeding applications_current_play from the closure. That shrinks
CURRENT_PLAY_DOMAINS_ALL, which cert SANs, openresty vhosts, DNS records and
the webserver health check all consume at full inventory scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 11:31:12 +02:00
kevinveenbirkenbachandClaude Opus 5 4ee5802149 style(tests): clear the ruff findings in the CI sequencing helpers
make quality failed on three ruff errors, all in the CI deploy-sequencing
work: RUF007 twice in test_ci_deploy_sequencing.py and TID252 in
tests/utils/ci/job_names.py. autoformat is chained ahead of test, so the whole
test stage never ran while they stood.

zip(CHAIN, CHAIN[1:], strict=False) becomes pairwise(CHAIN). Same two pairs
over the three-element chain, but strict=False was switching off a length
check that this call genuinely needs -- CHAIN[1:] is shorter by construction,
so strict=True would raise. pairwise has no second sequence and cannot have
the problem, so the suppression goes away with its reason. Both assertIn calls
fit on one line again as a result; that reflow is ruff format's, not a
hand-made edit.

job_names.py was the only file reaching PROJECT_ROOT through a relative parent
import; test_ci_deploy_sequencing.py:20 already spells it absolutely.

Verified rather than assumed: make quality now green end to end (ruff in the
ran list, all four targets green, 4030 + 299 passed). The sequencing file on
its own reports the same 24 subtests as before the change, so pairwise yields
the identical pair set and not merely a passing one. All four importers of
job_names run green (60 passed), which is what rules out the absolute import
resolving differently under another sys.path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 10:47:42 +02:00
kevinveenbirkenbachandClaude Opus 5 38675a4073 fix(packages): retry distro-repo installs like every other fetch
Six of thirteen failures in run 30937913632 landed on the one package
acquisition path that carries no retry: package_call() built every ordinary
distro-repo install with retry=None, and the enable_existing branch did the
same. EXTERNAL_FETCH was attached to AUR, COPR, PPA and source builds only,
and its docstring said so outright.

The dichotomy that justified it - external endpoints are flaky, distro repos
are not - is one the repo already rejects elsewhere: the image build wraps
every dnf call in RETRIES=3, and sys-svc-ssh hand-rolls retries: 5 for apt
with the comment 'nested CI egress is flaky'. Line 14 of that same file then
hands the RedHat branch to the unarmoured path, which is where lam 2 and
prometheus 0 died.

The measurement that settles it, same task across the run:
2.98s / 5.34s / 5.63s / 6.78s / 18.09s / 20.46s / 68.41s / 367.71s (FAIL) /
769.29s (FAIL) / 815.18s (SUCCESS). The slowest run is a success - failure
and success sit on one continuous tail, so the split is not mirror-up versus
mirror-down. xwiki 2 hit the identical epel metadata error, same checksum,
404 from ten mirrors including dl.fedoraproject.org, and is green only
because the Docker build retried it five seconds later.

The three upstreams that failed - EPEL, updates-testing and www.centos.org -
are exactly the three the package cache does not proxy, so the cache cannot
substitute for this.

test_a_plain_repository_install_is_not_retried was the defect's own tripwire
and inverts accordingly.

Also here because it is the same class: roles/update pinned dnf to retries=5,
below dnf's own default of 10 - a hardening task that halved what it meant to
raise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 10:47:41 +02:00
kevinveenbirkenbachandClaude Opus 5 3883d73c43 feat(ci): retrigger reproduces the source run
The REST API answers inputs: null for a finished workflow_dispatch, so
the run name is the only record of what a manual run was dispatched
with. It carried one value, distros; everything else silently fell back
to the workflow defaults, and a failure that only reproduces on swarm
with btrfs came back green from a run that deployed all three modes on
whatever filesystem it drew.

Render every input as its own <emoji><value> segment instead, omitted
when it holds its default, so the title carries exactly what deviates
and an absent input means 'leave it on the default'. run_name derives
those segments from the workflow rather than knowing them, so the format
cannot drift away from the parser without a failing test.

distros no longer defaults to debian -- empty draws one at random, the
way untagged pushes already do. That leaves nothing in the title, so the
effective list is recovered from the discover job names, matched by
content against the meta/distros.yml SPOT rather than by job-name shape.

Priority roles that fell behind the budget cut in every mode hold no job
at all, so failed_roles cannot see them: they are neither green nor red,
they never ran. Carry them along or they never deploy.

--run now resolves before the selection, so it reproduces a run whatever
picks the roles, not only with --failed.

tests/utils/ci_*.py move into tests/utils/ci/ -- two files sharing a
delimiter-bounded prefix are a folder in disguise, and the structure
lint says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 10:47:40 +02:00
kevinveenbirkenbachandClaude Opus 5 636701ac26 feat(ci): serialise a big deploy line's modes
GitHub cancels a job queued for 24h. A line starting swarm, compose and
host at once queues its whole matrix in one go, so with more roles than
runner slots the tail waits past that cut and dies unrun -- 54 of 241
jobs in run 30937913632.

Deploy the modes one after the other instead, heaviest first, which
restarts the queue clock per mode. cli.meta.ci.sequencing counts a
line's matrix jobs on the row basis the discover steps use and
serialises above INFINITO_CI_SEQUENTIAL_THRESHOLD; below it the modes
still start together, so a small diff pays no wall-clock for this.

A needs: edge is static, so both layouts exist as jobs and the decision
skips one. Their conditions are structurally asserted, not evaluated:
ten near-identical if expressions invite a copied sentinel or a missing
chain edge, and both failures are silent at runtime.

mode_fail_fast stops a serial line at its first failed mode, on by
default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 10:47:40 +02:00
kevinveenbirkenbachandClaude Opus 5 6b5388c091 fix(nfs-server): bootstrap centos ganesha repo
The inline repo definition pinned gpgkey to www.centos.org, a single
non-CDN origin. In run 30937913632 it was unreachable for at least
17m25s and killed 2 of 23 centos ganesha installs; dnf's own retries=5
was already active on the host and did not help.

centos-release-nfs-ganesha11 ships the same repo with metalink failover
and gpgkey=file://, planting the key through Requires:
centos-release-storage-common. Verified end to end against
quay.io/centos/centos:latest and ghcr.io/kevinveenbirkenbach/pkgmgr-centos:stable.

The BOOTSTRAP_BASEURL entry keeps the availability probe verifying both
names instead of degrading them to an inconclusive warning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 10:47:38 +02:00
kevinveenbirkenbachandClaude Opus 5 2540d37a62 fix(bkp): stop rsync from keeping superseded files as ~ twins
rsync -b turns a deletion into a rename, so a file the source dropped between
two writes of the same generation survives as name~ beside the real one and is
restored into live data. baudolo 3.4.1 fixes the copy that caused the
elasticsearch restore failure; this is the sweep over the sibling call sites
here.

svc-bkp-remote-2-local is the live one: the rsync command is assembled once and
re-executed up to twelve times against the same destination, which is created
with exist_ok before the loop starts. A retry after a partial transfer is
structurally the same second write as baudolo's hot/cold pair, only triggered by
--timeout instead of a container stop. That path pulls other hosts' backups, and
retries are its normal case rather than an exception.

svc-bkp-local-2-device cannot fire today - it writes one pass into a directory
named after the current second - but mkdir(exist_ok=True) makes a re-entry the
same two-write case, so the flag goes rather than the argument for keeping it.
The integration test mirrors the production command and follows.

The lint is what makes this complete: it scans every tracked .py/.sh/.j2/.yml
for a backup flag near an rsync token, and it found the integration test that
the manual sweep had missed. It excludes exactly one file, its own, because the
rule has to name the token it forbids; the sibling guard for || true avoids the
same problem only by scanning .sh alone.

Verified: make quality green across all four targets, the ssh-wrapper
integration test passes with real rsync, and test-lint goes 307 -> 308 with the
new guard collected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 10:47:37 +02:00
kevinveenbirkenbach eb921895ff Merge branch 'feature/swarm-nfs' into feature/svc-net-tor 2026-08-06 10:43:55 +02:00
kevinveenbirkenbachandClaude Opus 5 b99c2c405d style(tests): clear the ruff findings in the CI sequencing helpers
make quality failed on three ruff errors, all in the CI deploy-sequencing
work: RUF007 twice in test_ci_deploy_sequencing.py and TID252 in
tests/utils/ci/job_names.py. autoformat is chained ahead of test, so the whole
test stage never ran while they stood.

zip(CHAIN, CHAIN[1:], strict=False) becomes pairwise(CHAIN). Same two pairs
over the three-element chain, but strict=False was switching off a length
check that this call genuinely needs -- CHAIN[1:] is shorter by construction,
so strict=True would raise. pairwise has no second sequence and cannot have
the problem, so the suppression goes away with its reason. Both assertIn calls
fit on one line again as a result; that reflow is ruff format's, not a
hand-made edit.

job_names.py was the only file reaching PROJECT_ROOT through a relative parent
import; test_ci_deploy_sequencing.py:20 already spells it absolutely.

Verified rather than assumed: make quality now green end to end (ruff in the
ran list, all four targets green, 4030 + 299 passed). The sequencing file on
its own reports the same 24 subtests as before the change, so pairwise yields
the identical pair set and not merely a passing one. All four importers of
job_names run green (60 passed), which is what rules out the absolute import
resolving differently under another sys.path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 10:41:10 +02:00
kevinveenbirkenbachandClaude Opus 5 064f2fc4c7 fix(cache): drop unsound id-keyed fingerprint memo
_fingerprint_mapping memoised digests under id(obj) while holding no
reference to obj, so CPython reused the address of a freed mapping and
served its digest for an unrelated live one. Measured 398/400 wrong
digests for short-lived mappings; in-place mutation was invisible and
the domain-index collision guard could be bypassed entirely.

Two unit tests pinned that behaviour as the contract —
test_id_cache_short_circuits_recompute asserted that mutating a dict in
place leaves its fingerprint unchanged — so the defect shipped green.
Both now assert the opposite.

build_domain_index rebuilt the whole reverse index on every lookup,
scanning every application's domains and aliases to answer one key. It
is now memoised on that fingerprint, which is only sound because of the
fix above. 4x on a 100-app payload.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 10:27:04 +02:00
kevinveenbirkenbachandClaude Opus 5 5996287d25 fix(packages): retry distro-repo installs like every other fetch
Six of thirteen failures in run 30937913632 landed on the one package
acquisition path that carries no retry: package_call() built every ordinary
distro-repo install with retry=None, and the enable_existing branch did the
same. EXTERNAL_FETCH was attached to AUR, COPR, PPA and source builds only,
and its docstring said so outright.

The dichotomy that justified it - external endpoints are flaky, distro repos
are not - is one the repo already rejects elsewhere: the image build wraps
every dnf call in RETRIES=3, and sys-svc-ssh hand-rolls retries: 5 for apt
with the comment 'nested CI egress is flaky'. Line 14 of that same file then
hands the RedHat branch to the unarmoured path, which is where lam 2 and
prometheus 0 died.

The measurement that settles it, same task across the run:
2.98s / 5.34s / 5.63s / 6.78s / 18.09s / 20.46s / 68.41s / 367.71s (FAIL) /
769.29s (FAIL) / 815.18s (SUCCESS). The slowest run is a success - failure
and success sit on one continuous tail, so the split is not mirror-up versus
mirror-down. xwiki 2 hit the identical epel metadata error, same checksum,
404 from ten mirrors including dl.fedoraproject.org, and is green only
because the Docker build retried it five seconds later.

The three upstreams that failed - EPEL, updates-testing and www.centos.org -
are exactly the three the package cache does not proxy, so the cache cannot
substitute for this.

test_a_plain_repository_install_is_not_retried was the defect's own tripwire
and inverts accordingly.

Also here because it is the same class: roles/update pinned dnf to retries=5,
below dnf's own default of 10 - a hardening task that halved what it meant to
raise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 10:04:35 +02:00
kevinveenbirkenbachandClaude Opus 5 afe20c0c76 feat(ci): retrigger reproduces the source run
The REST API answers inputs: null for a finished workflow_dispatch, so
the run name is the only record of what a manual run was dispatched
with. It carried one value, distros; everything else silently fell back
to the workflow defaults, and a failure that only reproduces on swarm
with btrfs came back green from a run that deployed all three modes on
whatever filesystem it drew.

Render every input as its own <emoji><value> segment instead, omitted
when it holds its default, so the title carries exactly what deviates
and an absent input means 'leave it on the default'. run_name derives
those segments from the workflow rather than knowing them, so the format
cannot drift away from the parser without a failing test.

distros no longer defaults to debian -- empty draws one at random, the
way untagged pushes already do. That leaves nothing in the title, so the
effective list is recovered from the discover job names, matched by
content against the meta/distros.yml SPOT rather than by job-name shape.

Priority roles that fell behind the budget cut in every mode hold no job
at all, so failed_roles cannot see them: they are neither green nor red,
they never ran. Carry them along or they never deploy.

--run now resolves before the selection, so it reproduces a run whatever
picks the roles, not only with --failed.

tests/utils/ci_*.py move into tests/utils/ci/ -- two files sharing a
delimiter-bounded prefix are a folder in disguise, and the structure
lint says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 10:01:42 +02:00
kevinveenbirkenbachandClaude Opus 5 60dbf57445 feat(ci): serialise a big deploy line's modes
GitHub cancels a job queued for 24h. A line starting swarm, compose and
host at once queues its whole matrix in one go, so with more roles than
runner slots the tail waits past that cut and dies unrun -- 54 of 241
jobs in run 30937913632.

Deploy the modes one after the other instead, heaviest first, which
restarts the queue clock per mode. cli.meta.ci.sequencing counts a
line's matrix jobs on the row basis the discover steps use and
serialises above INFINITO_CI_SEQUENTIAL_THRESHOLD; below it the modes
still start together, so a small diff pays no wall-clock for this.

A needs: edge is static, so both layouts exist as jobs and the decision
skips one. Their conditions are structurally asserted, not evaluated:
ten near-identical if expressions invite a copied sentinel or a missing
chain edge, and both failures are silent at runtime.

mode_fail_fast stops a serial line at its first failed mode, on by
default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 09:55:18 +02:00