Commit Graph
261 Commits
Author SHA1 Message Date
evangelostsak 56cfdca4ff feat(credentials): let the mirror keep per-host material
The mirror copies one host's file over every other so a rotation reaches all
of them, which is right for a shared secret and wrong for anything a
generator writes per host: the copy hands every node the source's identity.

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

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

Nothing caught it because the container connection escalates to root without
a password. The first transport that logs in as the administrator and sudos
for real gets 'Incorrect sudo password' on every host.
2026-09-26 23:29:54 +02:00
evangelostsak 81c32584e0 fix(mesh): prune the controller's foreign mesh entries too
The prune walks the hosts the inventory groups resolve to, but the controller
joins by name rather than through a group, so it was never visited. Its
mirrored host_vars kept the manager's data-mesh entry -- address 10.100.1.1,
is_hub true -- and the controller brought that interface up and enabled hub
forwarding for a mesh it is not a member of.
2026-09-26 19:28:13 +02:00
evangelostsak 189c91122c fix(ci): feed the mesh axis to the retrigger
entries_of requires it, so a retrigger would have aborted the same way the
plan did.
2026-09-26 09:48:20 +02:00
evangelostsak a170ccb4a3 feat(mesh): expose the controller through the writer CLI 2026-09-26 01:27:19 +02:00
evangelostsak 435d839ac5 feat(mesh): admit the Ansible controller as a spoke
Named rather than resolved from a group: the controller is not a swarm member
and has no role group, and inventing one would put a non-role name into
group_names where every service and placement lookup would special-case it.
2026-09-26 01:27:07 +02:00
evangelostsak c471940798 feat(mesh): move the Ansible transport onto the mesh
The first pass of a deploy reaches the hosts however it can, because no tunnel
exists yet and that pass is what creates one. Every pass after it connects
over the mesh, which is what makes the mesh load-bearing rather than merely
present: a broken tunnel then fails the deploy at the connection, and no role
can quietly fall back to the underlay it was supposed to stop using.

A separate step rather than a fact set during the play, because a play cannot
change the transport it is already running over. A host holding no address on
the mesh is left alone -- rewriting it would strand a host that was
reachable.
2026-09-26 01:26:53 +02:00
evangelostsak 978c0eeb28 refactor(mesh): one key per host, not one per mesh
A host presents the same identity on every plane it belongs to, so the hub no
longer carries two identities and the writer has one credential to keep in
step. A member already keyed by an earlier mesh keeps that key instead of
minting a second one.
2026-09-25 22:35:22 +02:00
evangelostsak e337e2f51f feat(mesh): follow the pool reference into its SPOT
The pool is declared once under NETWORK_* and referenced from the mesh file.
Ansible resolves that at play time; this writer runs before the play, so it
follows the one hop rather than duplicating the value.
2026-09-25 22:35:17 +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
Alejandro Roman 9df3287adf Merge remote-tracking branch 'upstream/main' into feature/role-stalwart-mail-swarm 2026-09-22 12:16:44 +04: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
Alejandro Roman 4b9a834546 Merge remote-tracking branch 'upstream/main' into feature/role-stalwart-mail-swarm 2026-09-21 03:25:20 +04:00
evangelostsak ae26d38247 fix(inventory): prune foreign meshes before planning
Runs first so a mirrored entry cannot be mistaken for this host's own, and
reports what it dropped, because a silently removed mesh is exactly the kind of
change that should be visible in a deploy log.
2026-09-19 12:07:33 +02:00
evangelostsak c4fd05ba82 fix(inventory): drop mesh entries a host does not own
Writing a member's own entry never removes another's. After the swarm reset
mirrors one node's host_vars onto every other, the NFS server keeps the hub's
swarm entry and brings up a second interface claiming 10.100.0.1. Its own data
mesh stays healthy, so nothing looks wrong on it -- but it now answers for the
hub's address and swallows the return path, and every worker's route to the
export dies there.

Observed as a worker with a live tunnel, a correct route and 100% packet loss
to the NFS server, while the hub reached the same address fine.
2026-09-19 12:07:24 +02:00
evangelostsak df23a74ffc fix(inventory): record which host a mesh entry belongs to
The swarm rotation gate mirrors one node's host_vars over every other, which is
correct for credentials that are identical per host and hands every node the
hub's identity here. Observed on a live cluster: a worker's swarm.conf was
byte-identical to the manager's, same private key, Address 10.100.0.1, a
ListenPort it should not have, and both spokes as peers. Nothing handshaked and
every NFS mount over the mesh failed.

Re-running the writer alone does not fix it, because a mirrored file presents
the hub's public key and credential and would be read as already keyed. The
entry now names its owner, so one that names a different host counts as unkeyed
and the pair is reminted.
2026-09-19 11:16:28 +02:00
evangelostsak c9d856b645 fix(inventory): make the mesh entry point executable
It carries the same shebang as every sibling __main__.py and none of their
executable bit, which ruff rejects as EXE001.

Not reproducible from here: the sandbox masks the mode, so ruff reports the
file as executable whatever it actually is, and a local run passes with the bit
cleared and a cold cache. CI is the only place this rule can fail.
2026-09-19 09:08:57 +02:00
kevinveenbirkenbachandClaude Opus 5 673de05a60 fix(inventory): write only the service toggles a role's contract declares
`disable=` wrote both `enabled: false` and `shared: false` for every role that
declares the service, but a role qualifies with any one of `enabled`, `shared`
or `provides`. compare_application_keys rejects any host_vars key the role
contract does not carry, so a role declaring only `enabled` came back as a
validation error and the deploy never reached Ansible:

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 06:00:56 +02:00
evangelostsak 160b0eaff7 fix(inventory): route the whole pool through the hub from a spoke
A spoke's AllowedIPs for the hub was its own mesh subnet, so a worker on
10.100.0.0/24 had no route to the NFS server on 10.100.1.11 and could not
reach the plane it is deliberately not a member of -- which is the entire
reason the hub sits in both.

The symptom would have been misleading: every tunnel reports a healthy
handshake, the gate passes because it only pings peers the host is configured
for, and the failure surfaces later as an NFS mount that hangs. It points at
storage rather than at routing.

Spokes now route the declared pool through the hub while the hub keeps each
spoke pinned to a single address; routing the pool in both directions would
blackhole every member behind whichever spoke came first.

Found by running the writer against the prefixed five-host topology before the
deploy rather than during it.
2026-09-18 16:37:18 +02:00
evangelostsak dae11dd57e feat(inventory): add cross-host credential generation
Every credential generator in this package is single-host scoped.
credentials_generator shells out once per (app_id, host_vars_file) pair, and
that subprocess sees one host's inventory with no knowledge of any other. A
mesh credential breaks the assumption: the value one host receives is derived
from a secret minted for a different host, because a peer holds the public half
of a key whose private half lives elsewhere.

This package is the missing writer that sees the whole host set at once, so a
mesh is written complete or not at all. It is specified as cross-host
credential generation rather than as a WireGuard utility -- the application id
is a parameter and a mesh is a named hub-and-spoke grouping of inventory hosts,
so a later consumer needing paired secrets reuses it unmodified.

Planning is a pure function of the inventory, the spec and the public keys that
already exist, so mesh completeness, key pairing and rotation are provable
without a deploy. Keys are minted in-process with Curve25519 in the encoding
WireGuard expects, so the controller needs no wg binary to plan a mesh it never
joins, and a public key is always derived from its private half rather than
stored, because a stored pair can drift into a mesh that looks configured and
never handshakes.

Group membership is read across every inventory file it is spread over: the
swarm lab keeps its backup host in a sibling backup.yml, so a mesh spanning
both planes is only complete when both files have been read.

Vault encryption is salted, so a member that already holds a key is left
untouched rather than re-encrypted; otherwise every run would rewrite the file
and no deploy could be a no-op.
2026-09-18 09:06:52 +02:00
kevinveenbirkenbachandClaude Opus 5 05fcec52e7 fix(planner): give every variant-carrying app the round's index, not only the include
round_variants was built by walking round_include. When a primary's variant switches its couplings off the planner resolves no prerequisites, round_include collapses to the primary, and no dependency gets an index at all. derive_includes then evaluates them at base config, where an unrendered Jinja coupling counts as enabled, so the closure inflates to everything reachable instead of what the variant declares.

Measured: svc-ai-litellm round 1 deploys 10 apps instead of 32, web-app-bigbluebutton round 1 and web-app-peertube round 1 drop from 34 to 10 and 11. Every other round of every role measured is unchanged, including all fourteen nextcloud rounds. The apps that leave are not dependencies of the round: svc-ai-litellm declares litellm, lmstudio, ollama and postgres and no sso at all, while bigbluebutton and peertube declare sso but switch it off in variant 1 together with dashboard, matomo, css, logout and prometheus.

complexity/cli.py:300 already documents the rule this makes true: the planner hands each pulled-in dependency round_index if round_index is below its variant count else 0, and pulls_at_zero implements exactly that. The coverage model was describing behaviour the planner only delivered for apps inside the include.

init.py filters the printed variant map to the apps actually shown, because round_variants now carries 266 entries and round 1 would otherwise print 112 of them on one line while ten apps deploy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 13:29:43 +02:00
kevinveenbirkenbachandClaude Opus 5 46110f4311 perf(swarm-test): rotate the round's credentials, not every block in the file
The matrix gate rotates credentials between the two deploy passes so PASS 2
has to carry fresh values. It rotated every application block in the host_vars
file, and on a matrix inventory that is not the round. Provision applies its
--include as a strict intersection (filters.py:57-62), so it generates
credentials for at most derive_includes, while mirror_overrides.py adds a block
per mirror artefact carrying only image and version. Rotating those costs one
credential subprocess each for values provision never wrote.

matrix.py now passes --include derive_includes(app_id, variants=round_variants),
the same call 02_provision_inventory.sh:11 feeds provision, from the same
round_variants the provision step got one call earlier. The rotation set is
therefore a superset of what provision generated: every database_password still
rotates, because utils/manager/inventory.py:236-242 writes it under an id from
that same filtered list. Measured for web-app-wordpress: 35 ids.

The scope stops at the credentials. reset_credentials takes credential_ids
beside application_ids because the two halves shared one list, and narrowing it
narrowed _drop_user_passwords too: that resolves through
required_user_policies(roles_dir, application_ids), so 60 of 66 declared users
would have stopped rotating, root, backup and every mcp-* bot among them. The
user half keeps the full document list and is byte-identical to before.

The test fixture declared the same users in both roles, which made the
regression test pass against the regression. web-app-keycloak now declares a
user web-app-a does not, so the assertion has something to bite on.

The CLI aborts when --include matches no block instead of rotating nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 20:32:02 +02:00
kevinveenbirkenbachandClaude Opus 5 c7d162fa88 fix(coredns): derive the domain regex from INFINITO_DOMAIN when rendering
INFINITO_DOMAIN_RE carried an already escaped regex through the env
chain: EnvBuilder.setdefault prefers a value already in the process env,
quote_for_dotenv escapes every backslash, and load.sh unescapes it again.
One inherited value with a doubled backslash was enough to write
"infinito\\\\.test" into .env, the rendered Corefile then matched a
literal backslash, and getent hosts test.infinito.test failed with rc 2.

The renderer now substitutes DOMAIN_RE = re.escape(INFINITO_DOMAIN) at
render time, so no backslash travels through the env or .env at all. The
template placeholder is renamed because it is a derived render value, not
an env key, and INFINITO_DOMAIN_RE is removed from default.env and the
passthrough keys, leaving INFINITO_DOMAIN as the single source.

A renderer test renders the real template and requires a single-escaped
match with no doubled backslash and no leftover placeholder. Verified
live: the Corefile renders ^infinito\.test\.$, test.infinito.test
resolves to 172.30.0.10 and test.infinitoXtest does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 20:53:19 +02:00
Alejandro Roman e6596d51f2 fix(stalwart): add the fix PR into main PR 2026-09-09 00:21:58 +04:00
kevinveenbirkenbachandClaude Opus 5 f1e86eef58 fix(ci-trigger): retire a green suite on any input
The retrigger turned a suite off only when the source run had been
dispatched with that suite explicitly on. b1ca39e9dd introduced that
guard together with the feature itself, and it contradicts the same
commit's message and docstring, both of which state that the job record
is the only evidence.

The guard made the feature a no-op in its most common case. An input
left on its default renders no title segment and resolves to empty, so
config.get('workspace') was never 'true' for a run that reached the
suite on the 'auto' default. call-orchestrator.yml reads 'auto' as
global scope off the whitelist alone, and a --failed retrigger puts its
selection on the priority line while leaving the whitelist empty, so
every retrigger in the chain redeployed a suite that had already gone
green. Measured on run 33785198875: six workspace jobs green, and the
dispatch still went out carrying workspace=auto.

Drop the guard. suite_passed already refuses to retire anything that
failed, was cancelled, timed out, was skipped or never ran at all, so
the job record decides on its own.

Three tests cover the paths 8600f1d9aa left open, all of which pinned
the explicit 'true': green on 'auto', green with no recorded input, and
red on 'auto' staying on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 04:06:04 +02:00
Alejandro Roman f9d3a1345f fix(stalwart): fix TOr and testing properly, local pass 2026-09-04 02:43:39 +04:00
kevinveenbirkenbach 8600f1d9aa test(ci-trigger): pin the workspace suite rule
The rule lived only in suite_passed's body, and it inverts easily on a
casual read: ask for the suite again when it failed. Under that reading
the retrigger aliases would drop the suite in their most common case,
because they force-cancel the source run before reading it, so a shard
that was still running comes back cancelled rather than red.

Only an all-green suite is retired. Failed, cancelled, skipped, timed
out, still running and never dispatched all keep it on the retrigger,
and each of those now has a test. The two docstrings enumerate them, and
so does the i8ciall description, which is where an operator reads it.
2026-09-03 17:37:33 +02:00
kevinveenbirkenbachandClaude Opus 5 fe7a9d53ea fix(cli,utils): settle three CodeQL error findings
CodeQL reports cmd and sort_spec as possibly uninitialised after the except branch that calls parser.error, because it does not model the argparse error method as NoReturn. It is: usage is printed and the process exits with status 2. An explicit return 2 on that branch states the exit status the call already produces, so the analyser and a reader see the same path.

The retry loop in the registry client counted two attempts in a variable the body never read; the underscore prefix was the convention for that, but CodeQL only honours the bare name.

No path changes behaviour; the three alerts stop pointing at correct code so the scanner stays readable for the ones that are not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 16:40:13 +02:00
kevinveenbirkenbach 1e78a02c34 fix(ci-trigger): resume where the last run left off
The regular line restarted at the head on every retrigger. resume_offset
compared the source run's deploy tokens against a freshly computed
ranking, but every axis rotates with the sweep number and a retrigger is
a new run with a new one: measured against the current 277-row ranking,
2 of 277 tokens matched. The walk stopped on its first row every time,
and the 66 rows past the job budget never deployed at all.

Membership now compares role#variant, the identity the rotation cannot
move, and a row counts as proven only when every job it had was green.
A priority row deploys its whole cross-product, so one role#variant can
carry several verdicts, and a green compose job must not carry a red
swarm one past the line. The offset also inherits the source run's own
offset as its floor rather than falling back to the head.

The priority line drops --include-unrun with it: only the hard failures
and the priority entries the run never deployed belong there. The rows
it never reached are the regular line's turn now that the offset walks.
2026-09-03 13:38:07 +02:00
kevinveenbirkenbachandClaude Fable 5.1 eab8f5fc90 fix(inventory): give pinned user passwords the mark the realm policy demands
The shell-safe default drew from letters and digits only, and every user without declared accounts is an identity account, so kcadm set-password rejected hermes and openclaw with invalidPasswordMinSpecialCharsMessage as soon as keycloak entered the GitLab#1 round without LDAP (4164f37981) and the extras file stopped replacing the users map (03e42bae6d; run 33689701895). The generator now draws from letters, digits and -_. and guarantees upper case, lower case, a digit and one mark, never in the leading position.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 02:15:53 +02:00
kevinveenbirkenbachandClaude Fable 5.1 1a4ee72249 feat(ci): let a retrigger override chunk_gate, with i8ciallon as the non-stop i8ciall
A retrigger copies every dispatch input from its source run, chunk_gate
included, so a sweep that stopped at its first red chunk came back stopping
there again, and nothing short of a manual dispatch could ask the chain to
deploy every chunk and report all of it at once.

`infinito administration deploy ci trigger --chunk-gate {true,false}` now
overrides the carried value after the source run's configuration is read; it
is the one deliberate departure from "reproduce the source run exactly", and
the module docstring says so. Omitted, the carried value or the workflow
default applies as before.

`i8ciallon` is `i8ciall` with `--chunk-gate false`: force-cancel the run, put
everything without a verdict back on the priority line, and keep deploying
past a failed chunk. The alias lint caps a name at two adjacent consonants,
which is why it is not `i8ciallnonstop`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 00:12:56 +02:00
kevinveenbirkenbachandClaude Opus 5 f19d224dd0 fix(ci-trigger): never resume at a row the priority line takes
A retrigger by role hands the priority line a bare role name so the rotation
can pick fresh axes, and the matrix then drops that role from the regular
query entirely. The resume offset was computed against a ranking discovered
without the priority line, so it could name one of exactly those rows, and
the run it was handed to had already guaranteed the row would not be there.

Chunk 0 of sweep 77 died on it before deploying anything:

    offset 'web-app-nextcloud#11' names no row of the regular line

and with it every other chunk of the run. The priority line of that run
promoted 27 roles whole, nextcloud among them; its regular line held no
nextcloud row at all.

Give resume_offset the priority pins and let it skip what they claim, by the
same two rules the matrix applies: a variant-less pin takes its whole role, a
pinned one takes those variants. The walk continues past a claimed row rather
than stopping, so a promoted role in the middle of the green window does not
truncate the resume point, and a window that is claimed end to end yields no
offset and starts at the head.

The ranking itself still discovers without the priority line, so an
unresolvable token there still cannot abort the helper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 02:07:41 +02:00
kevinveenbirkenbachandClaude Opus 5 f1635e6a59 feat(variants): let a variant dictate the config of the providers it pulls in
A round deploys a provider once, so the role that pulls it in is the only place that can say how lean it has to be. A variant entry may now carry any application config topic under a service it depends on: a mapping deep-merges, so naming one key changes that key alone, and null empties the topic. Empty was the case that needed a marker of its own, because merging an empty mapping is a no-op and would leave exactly the services the round was trying to shed.

The topic vocabulary and the merge semantics live in utils.roles.applications.topics, which is also where resources.py now gets them rather than spelling the same null-or-merge out a second time. Collecting who demands what stays in the inventory tree, refuses two roles demanding different values for one provider in the same round, and skips a role's own entity entry so the existing services.<own>.users declarations are not read as overrides.

The CI set-cover learns the same fact. Its greedy cover retires a role's variant-0 row when an earlier row of another role embeds it, on the stated premise that embedding pulls the provider's variant 0. A variant that dictates that provider's config breaks the premise from the other side: whatever ran, it was not the provider as the provider declares it. Such a pair now covers nothing, so the retired row keeps its job, its artefact and its verdict.

Measured on the current tree: 20 override pairs, 41 covered rows before and after, so nothing is revoked today. The rule bites the moment an override lands in a row that covers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 21:08:08 +02:00
kevinveenbirkenbachandClaude Opus 5 e3d5ab287b fix(gitlab): pin only the two keys that cannot be rotated, and say what is skipped
An earlier change in this branch marked six GitLab key bases rotatable: false after
one of them, db_key_base, was proven to break a deploy. Six was the shape of
"everything in secrets.yml.j2 that could plausibly be blamed", not the shape of
"what is lost". Measured in the shipped image (gitlab-rails-ce:v19.3.1), four of
them rotate fine:

    A_new_key_only                       => FAIL Decryption   (control: reproduces the rotation)
    B_old_then_new_list                  => s3cret-written-before-rotation
    C_new_only_reads_post_rotation_write => written-after-rotation
    D_salt_rotated                       => FAIL Decryption

B and C prove active_record_encryption_primary_key is list-rotatable and that the
old key can later be dropped; D proves the salt is not. Note the new key goes LAST
-- key_provider.rb:21 is `@keys.last` -- so a prepend would have shipped a broken
rotation.

Unpinned, with the reason each:
  otp_key_base                                gitlab:two_factor:rotate_key:apply exists, with rollback!
  encrypted_settings_key_base                 protects zero bytes here: grep -c encrypted on
                                              gitlab.yml.j2 is 0, ldap and incoming_email are off,
                                              and upstream has rotated_encrypted_settings_key_base
  active_record_encryption_primary_key        list rotation measured above
  active_record_encryption_deterministic_key  one column, a push device token the device re-registers

Still pinned, and the descriptions now name why rather than leaving it to a commit
message: db_key_base (settings.rb raises MultipleDbKeyBaseError on a second key and
upstream ships no re-encrypt task) and active_record_encryption_key_derivation_salt
(scalar PBKDF2 input, no previous-salt slot in Rails). What db_key_base protects is
also worse than first recorded -- grep -rl returns 36 files: CI/CD variable values at
every scope, terraform state, Pages TLS private keys, webhook and integration
credentials. The two tokens that actually crashed were only the ones custom-instance-setup
happened to touch.

The second half matters more than the first. _drop_generated skipped a pinned path
with a bare `continue`, and reset/cli.py printed "Rotated 66 value(s)" with no mention
that six were refused. An operator rotating a disclosed key was told it worked. A tool
that silently declines to rotate a leaked secret and reports success inverts the
property it exists to provide, so every skip now names itself on stderr.

Not addressed here, and it is the real repair: gitlab:doctor:reset_encrypted_tokens in
the db-migrate path would let any instance with undecryptable tokens heal itself,
including after a restore under a different secrets file, which no pin can help. It
changes the migrations container's command and is not measurable without a live GitLab.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 09:40:49 +02:00
kevinveenbirkenbachandClaude Opus 5 00478b8ead fix(coredns): substitute the Corefile in-process instead of shelling to envsubst
Two workspace jobs of run 33414875521 died the same way, on debian and on fedora:

    RuntimeError: envsubst not found. Install gettext-base (Ubuntu/Debian) or gettext (Arch).
    make: *** [Makefile:252: compose-up] Error 1
    make: *** [Makefile:778: swarm-zombie] Error 1

swarm-zombie runs the deploy with INFINITO_APP_DISCOVERY_RUNNER=host, so the README
replay step calls compose-up on the act runner, and that image ships no gettext. A
two-variable substitution took the whole deploy down with it, and the failure reads
like a broken host rather than a missing package.

string.Template.safe_substitute handles ${VAR} exactly as envsubst does for this
template, including the four bare `$` that end the match lines -- both leave them
alone. The rendered Corefile is byte-identical, checked over three re-renders, and
getent resolves inside the deploy container afterwards.

The concurrency property from f3826589f2 is untouched: the render still writes into
the descriptor from tempfile.mkstemp and lands through an atomic replace. The read
goes through utils.cache.files.read_text, as the uncached-read lint requires.

Nothing else in the replay step failed -- one traceback, one raise, one message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 21:52:40 +02:00
kevinveenbirkenbachandClaude Opus 5 7fd495cdfc feat(ci): replay a role's README instructions on its smallest deploy row
The instructions test held a job of its own that picked one random role per
run, so a README's Production block was proven roughly once per 265 roles.
The replay now rides on a deploy row instead: no extra runner, and every role
the sweep touches gets its documented one-command deploy exercised.

The row is the role's smallest variant -- fewest services enabled -- because
that is the shape the README describes, and it is chosen among the variants
the sweep actually deploys, so a row cut as redundant coverage cannot silently
drop the role from the test. A role qualifies only when its README carries a
Production block and the guide's own mode is one the role runs and tests.

The marked entry carries the guide mode in `instructions` and a 📖 in its job
title, between the variant number and a priority ⭐. The guide mode is
independent of the mode the rotation gave the row: a swarm row still replays
in compose. The step runs last in the deploy job, after the swarm teardown,
because the replay deploys the role a second time and needs the runner's
ports.

call-test-instructions.yml, cli.meta.ci.guide_select and the `instructions`
dispatch input go with it. `redundant()` moves next to the marker so the
chunker, the plan table and the marker share one definition.

The plan table trades its 📊 Weight column for 📖 Instructions; nothing read
the weight any more, so it leaves the matrix entries too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 16:57:04 +02:00
kevinveenbirkenbachandClaude Opus 5 bfde5876ba fix(ci-trigger): resume the regular line behind the green window, not the deployed one
The offset a retrigger carries skipped every row the source run merely
started. A row that failed or was cancelled therefore retired its place in
the ranking: the regular line resumed past it, and the row came back only on
the priority line, pinned to the one combination it was recorded in. Every
other combination of that row was silently dropped from the sweep.

resume_offset now stops at the first row that is not green, so only proven
coverage is skipped. passed_selections supplies it; the three token
comprehensions that only differed in their state filter collapse into one
helper. deployed_selections and settled_selections keep feeding
--include-unrun unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 16:25:51 +02:00
Alejandro Roman 333079cc2a feat(UI): fix migration, errors happening in local deploy and swarm integraton + merged main 2026-08-30 21:39:52 +04:00
kevinveenbirkenbachandClaude Opus 5 6d191555c3 feat(ci-trigger): retrigger by role, and by what never got a verdict
Two switches and an alias for the case f4c5a7cc36 was not written for: a run
where most of the ranking is red and much of it never ran.

--roles-only collapses the failed selections to their role ids. f4c5a7cc36
replaced exactly that form with pinned tokens, and its reason holds: the
rotation may not draw the combination that broke. It holds for a handful of red
rows. At thirty roles the question stops being which combination broke and
becomes whether the role comes up at all, and a token per combination spends
the priority budget proving the same role red twice. The default is unchanged;
this is the trade, named, behind a flag.

--include-unrun puts the rows the source run holds no job for on the priority
line, with their axes. resume_offset covers a run that walked a contiguous
prefix; it stops at the first gap, and a run whose priority line consumed the
budget before the head of the ranking leaves a hole at row one. Then the offset
is empty, the retrigger starts over, and the rows nobody judged queue behind
the ones already settled.

With --strict the two agree on what settles a row. Only a success or a failure
does. A cancelled row, or one still running when the run was read, was never
given the chance to be either, so it comes back as itself rather than as a role
name that would let the rotation draw some other combination instead. On the
run this was written against that moves 24 rows: 123 started, 99 judged.

Membership is decided on role#variant. Mode, onion state and distro are
assigned by the sweep rotation, so comparing full tokens between a source run
and a freshly computed ranking reports almost every row as unrun -- measured,
266 of 269, against 177 once the identity is right. Each ranking row is a
distinct role#variant, which is what makes it the identity to compare on.

i8ciall carries the combination, next to i8cifail which reproduces the exact
red rows and i8cifo which takes only the hard failures.

resume_offset and the new code now build their tokens through one helper
rather than two copies of the same five lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 16:14:25 +02:00
kevinveenbirkenbachandClaude Opus 5 a3dcf9b869 fix(inventory): drop the credentials the inventory actually holds
_drop_app_credentials walked applications[app].credentials, which
f713ba8350 moved to secrets.credentials. Nothing lives at the old address
any more - the run's inventory has twelve applications under
secrets.credentials and none above it - so the isinstance check never
matched and the function returned 0 every time. The rotation ran, reported
success and rotated no application secret at all.

It stayed hidden because the sibling half worked: _drop_user_passwords
reads document["users"], which did not move, so the total was never zero
and the call looked healthy.

That matters more than a dead branch usually would. Rotating between two
deploy passes is what turns the second pass into a test of the update
path, where a role has to push a new secret into the application and the
database it already created. With the drop silently skipped, every such
run has been re-deploying the values the first pass wrote.

The fixture pinned the pre-rename shape, so the test confirmed the bug
rather than catching it. Migrated to the real layout, with the address in
one helper instead of four assertions; against the old code it now fails
1 != 4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 20:58:42 +02:00
kevinveenbirkenbachandClaude Opus 5 481666bdb0 refactor(roles): finish renaming meta/schema.yml to meta/secrets.yml
Eleven roles still declared their credentials in meta/schema.yml while the
rest of the repository had moved to meta/secrets.yml. The name is not
cosmetic: the inventory resolves credentials at that path, as
d86913225b and c541e7c22f record, so a role left on the old name drops out
of that resolution.

All eleven are pure renames with no content change.

Also correct the four remaining places that still named the old path in
prose: the credentials reset CLI help, two test docstrings and the espocrm
README. None of them resolved a file, but they misdescribe where credentials
live, which is exactly what sent an earlier reader to the wrong location.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 12:13:48 +02:00
kevinveenbirkenbachandClaude Opus 5 7162b7bf4c Merge fork/feature/svc-net-tor into feature/ai-agent-employees
834 commits from the Tor onion-service branch against 299 of ours, from merge
base 7b646bdece: 127 conflicted files, 3506 files changed.

The two sides are largely orthogonal. Theirs adds svc-net-tor and a `tor` service
across the role tree, plus two sweeping refactors: shipped scripts sorted by
language (files/ruby/, files/python/, files/shell/, scripts/lint/*.sh) and the
unit suite moved under tests/unit/python/. Ours is requirement 036, the native AI
gateway, plus the MCP role work. Most conflicts were both sides appending to the
same service map, resolved as unions.

Resolutions that were not unions:

Credentials moved namespace. Theirs renamed meta/schema.yml to meta/secrets.yml
and re-addressed credentials as secrets.credentials.<name>. Git carried our
litellm_api_key blocks through the rename, but every consumer we added still read
'credentials.<name>', which no longer resolves. All 45 call sites are migrated.

plugins/filter/container_volumes.py is deleted by theirs and relocated to
plugins/lookup/container_volumes.py, where it carries the same tmpfs defect our
f85996b43 had just fixed: a hard-coded {"size": ...} that silently drops a per
mount `mode`. The fix is re-applied in the relocated file, as that commit's merge
note anticipated.

compose.yml keeps only INFINITO_PLAYWRIGHT_REPORTS_BASE_DIR. Theirs also forwards
INFINITO_PLAYWRIGHT_STAGE_BASE_DIR; that line is deliberately dropped, per the
analysis recorded in 45804e9ad -- STAGE has a drift-proof role-vars fallback,
while forwarding it promotes a setdefault-pinnable .env value above that SPOT.

tasks/stages/01_constructor.yml takes their include, ./tasks/utils/setup/group.yml,
because their refactor removed tasks/groups/*-roles.yml, leaving our include
target nonexistent. Our task name is kept, since svc-virt is in the loop.

Makefile swarm-zombie is a union: their validate plus teardown, then our
lab_subnet cleanup, which their side dropped while leaving lab_subnet.sh as the
only orphaned reference in the file.

01_bootstrap.sh keeps our deterministic --ip pinning, which distinguishes an
already-attached node from a real failure, over their `|| true`.

Their language-sorted layout orphaned references our side still held: zammad's
two scripts/apply/*.rb, jenkins' java-with-ca-trust.sh, and the notify topics
their handlers renamed to hyphenated form. All corrected.

roles/sys-svc-compose/files/shell/swarm/stack_ready.sh gains our latched-update
block: paused, rollback_paused, rollback_started and rollback_completed exit 2
rather than polling a state that cannot resolve without another deploy.

config_from_run is unioned to (title, logged, jobs); the production caller in
trigger/__main__.py now passes jobs, without which our suite suppression would
have been inert.

READMEs are regenerated rather than hand-merged: 15 LiteLLM rows and 91 Tor rows
now derive from the merged metadata, which is itself evidence the meta merge is
coherent.

Verified: 5161 unit tests pass. The single failure, test_query.py TestSortSpec,
also fails on fork/feature/svc-net-tor at b51d2f9919, measured in a baseline
worktree -- so this merge introduces no new unit-test failure. Two modules are
excluded from that run because `baudolo`, a dependency their branch introduces,
is not installed on this host.
Not verified: no deploy has run against the merged tree, and make quality has not
been run end to end here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 16:32:30 +02:00
kevinveenbirkenbachandClaude Opus 5 3781c30235 fix(env): write the cache-stack declaration from a handler, not the registry
48845db97 made three capability decisions declarable and named them INFINITO_*, which put them under setdefault: the live environment wins and the value is written back. Makefile exports BASH_ENV, so .env is re-exported into every make recipe, and scripts/tests/code/run.sh reaches dotenv through 'make setup' on every test run. A declaration made for one run therefore became a machine fact on first regeneration and stayed one - that is what made the workspace failure intermittent rather than reproducible, and why fedora died on 11 assertions where debian passed the same code.

A dedicated handler writing through eb.set recomputes the value every time and discards whatever the environment held, so the key stays registered in default.env and visible to the lints while remaining structurally unpinnable. running_on/github.py already worked this way; the distinction now has a name in utils/env/README.md. The exporting shell still steers its own run, because load.sh preserves non-empty caller values.

The other two declarations are deleted rather than moved. image_mirror_enabled and docker_root_ephemeral both derive from runs_on_github(), and the orchestrator does not unset INFINITO_RUNNING_ON_GITHUB, so inside the workspace they were already true and the declarations restated a value that held anyway. Measured across all four runtime contexts: only the workspace row changes, and only for the cache.

Verified by reversing the probe: 'INFINITO_CACHE_STACK=false make dotenv' writes .env=true, where the registry path wrote the exported value back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:24:15 +02:00
kevinveenbirkenbachandClaude Opus 5 48845db979 refactor(profile): split runtime capabilities from runtime identity
One boolean answered five unrelated questions: whether the pull-through cache stack runs, whether image pulls take the GHCR mirror, whether the docker data root may be wiped, plus runtime naming and inventory resolution. The workspace is the environment where those answers diverge — it simulates a developer machine on a real runner — so getting one of them meant misreporting where the suite runs and silently paying for the other four.

is_ci() and runs_on_github() stay facts about the machine. cache_stack_enabled, image_mirror_enabled and docker_root_ephemeral become decisions that default off those facts and can be declared explicitly, so an environment states what it wants. Every default reproduces the previous condition exactly, including the distinction the old mirror and wipe gates drew between a real runner and act.

73ae40585 got the cache stack by flipping the identity flag and lost the docker-root wipe with it; the workspace now declares all three capabilities instead. Pairs with 4c068d1cf, which makes the resulting stray bind sources harmless rather than fatal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 19:52:03 +02:00