Commit Graph
918 Commits
Author SHA1 Message Date
kevinveenbirkenbachandClaude Opus 5 e871df3da8 fix(ci): number the plan rows so the id column is unique
The id column printed the discovery id, so every mode/tor combination of a
role repeated the same number. Count the rows instead and remap 'covered by'
onto the row number of the covering row's first occurrence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 19:30:05 +02:00
kevinveenbirkenbachandClaude Opus 5 b7d4365f38 refactor(ci/plan): reorder the plan table and name why a row was cut
The id leads, the variant follows the name it belongs to, and covered_by,
weight and the new clone column close the row. ✂️ said only that a row was cut
while two different reasons hide behind it: 🔰 points at the earlier row that
already embeds this one, 🐑 says the row shares its service set with an earlier
role that stands in for it. 27 rows of a full plan carry 🐑 and were until now
indistinguishable from the rest.

_COLUMNS is the order and the headers again. tor and triggered used to sit
outside it as special cases, so a reordering had to be made in two places and
the tests indexed by hand; they now ask _COLUMNS for a column's position.

The chunk column loses its ⏭️ marker, which named the block the calling job was
running. Without it cells() has no use for `current` and --chunk no effect, so
both go, along with the workflow's argument.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 10:54:47 +02:00
kevinveenbirkenbachandClaude Opus 5 2aca0f62d0 fix(ci): make the failed-run retrigger reach its dispatch
i8cifail died twice on the same run, each failure hiding the next.

The retrigger recovers a source run's dispatch inputs from a job log, because the title truncates at 512 bytes and REST answers inputs: null for a finished workflow_dispatch. That reader picked the first depth-1 job outright. A skipped job never uploads a log blob, so its log endpoint answers BlobNotFound as a bare HTTP 404 -- and a force-cancelled run can leave every depth-1 job skipped, which is why deeper jobs are now read too rather than assumed inputs-free. Candidates are taken shallowest first, so the orchestrator's own inputs still win over what a caller forwarded. gh 2.97 also refuses to emit a log holding terminal escape sequences and exits 1; the log downloader read that as no log and silently wrote none. A missed fetch is now tolerated instead of aborting, so the guard in dispatched_priority reports an expired log in words rather than the command dying on gh: HTTP 404.

Behind that sat the resolver: it decoded whole priority entries, but an entry may pin axes (web-app-zammad#1@compose+tor), which the display-name codec does not parse. Every pinned entry aborted as corrupt. split_axes -- already the seam decode_list uses -- separates name from suffix, so only the name is resolved and the truncation guard stays sharp. The pin rides along into the retrigger: the entry never ran, so the run holds no evidence its axes were wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 05:21:52 +02:00
kevinveenbirkenbachandClaude Opus 5 15e26ac9e0 style: apply the project formatters
Output of 'make autoformat': ruff collapses expressions that were wrapped across lines without need, shfmt and the yaml formatters drop surplus blank lines. No semantic change - the whole diff is 10 insertions against 39 deletions, and every hunk joins lines that already said the same thing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 03:22:05 +02:00
kevinveenbirkenbachandClaude Opus 5 dd07275e82 fix(recover): read the generation's own layout instead of guessing it
A recent refactor imported baudolo into utils.recovery for three layout strings, and cli.administration.recover.__main__ imports full unconditionally - so the whole recovery CLI, --help included, died at import on every host without baudolo. That is most of them: recover device|nfs|secrets runs on swarm nodes, the NFS server and bare rescue hosts, while only the backup node carries svc-bkp-volume-2-local. utils/recovery had been deliberately stdlib-only for exactly that reason.

baudolo 5.0.0 writes a manifest into every generation stating the layout it used, so the names are now asked of the generation being read rather than of the installed package. A tree written by any version is walked with the names that version used, and utils.recovery.layout answers only for generations older than the manifest, which cannot describe themselves. That also retires two of core's guesses: the '<entity>_database' naming convention now yields to the engine the run actually recorded.

A repo whose manifest cannot be read is skipped rather than guessed at, but no longer takes the other repos with it - recovering two of three beats recovering none because one manifest is corrupt. The exit code reports the gap.

The pin lint compared two declared version strings and was therefore green while a different distribution was installed. It now checks the installed distribution and the layout literals against baudolo's own BackupPaths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 03:22:04 +02:00
kevinveenbirkenbachandClaude Opus 5 cb1ff29951 fix(ci): decide discovery's row selection on the set that actually deploys
clone and covered_by both name a stand-in row: another row carries this one's coverage, so the sweep may drop it. Both were decided over every row and then applied to a filtered set, so a run that selected a subset deferred to a stand-in the filter had already removed - the row was cut in favour of a deploy that never happens. --whitelist svc-db-mariadb yielded no row at all, and the swarm-zombie job passed green while drilling nothing.

The sort's last-resort tie-breaker was drawn per process, so discovery and the plan renderer could name different assignments for the same run. INFINITO_DISCOVERY_SEED derives it from the row's own identity instead; the workflow feeds github.run_id, so the rotation still happens, once per run rather than once per process.

Measured against the previous order: on the mode-only filter both cut sets are identical (swarm 220 rows, 44 cut; compose 243 rows, 54 cut), so the full sweep is unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 03:22:02 +02:00
kevinveenbirkenbachandClaude Opus 5 5137465c6f style: delete every comment the comments-valid policy rejects
3529 invalid comments in 528 files, grandfathered in while the linter
only saw the working-tree diff. Removal ran through the linter's own
collectors, so detection and deletion cannot drift: a comment whose
opener line is blank before it and whose closer line is blank after it
loses all its lines, anything else loses only the comment region, and a
line left holding a bare delimiter goes with it.

Five files keep their comments behind a nocheck marker instead. Four are
upstream configs whose inline docs are upstream's own (mautrix, systemd
journald, nextcloud-oidc-login, MediaWiki). In littlejs the
commented-out entries ARE the deactivation mechanism for an example
tile, so deleting them would delete configuration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 03:22:02 +02:00
Alejandro Roman 7332bd66ae Merge upstream/main into feature/role-stalwart-mail-swarm 2026-08-18 01:42:23 +04:00
kevinveenbirkenbachandClaude Opus 5 3aeced6260 refactor(recover): read baudolo's contract from baudolo, pinned at 4.0.0
utils/recovery restated three things baudolo owns: the generation layout (suffixes, sql/ and files/ directories), the \connect parser for cluster dumps, and the databases.csv reader. Each was a second implementation of a contract whose author is one pip install away - the connect parser had even received the same quoted-identifier fix twice, once per repo.

The layout constants are now derived from BackupPaths, the inventory comes from dump_inventory, and the CSV goes through read_rows. dump_inventory is stricter than the parser it replaces: it also reads CREATE DATABASE lines, so a database a stream creates but never reconnects to is found - a new test pins that. recover/full.py and the drill's token.py take their globs from the same constants instead of spelling them out.

The dependency this creates is declared: backup-docker-to-local==4.0.0 in the base dependencies, because the deploy CLI must import from the base set alone. The role installs the same version, and a lint holds the two pins together - they drifted apart is exactly the failure this whole commit removes, so it must not come back one level up.

The generation-layout tests now write their fixtures through BackupPaths itself; before, the fixture hand-built the paths with the same assumption the test claimed to verify.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 18:02:33 +02:00
kevinveenbirkenbachandClaude Opus 5 c55f508e05 feat(readme): draw mandatory and disabled service relations
The cosmos generator linked a consumer to its provider only through an
'<role>' in group_names reference or an explicit provides: name. A hard
dependency has neither -- pinning the provider on unconditionally is
exactly what removes the Jinja reference the generator was reading -- so
web-svc-asset's bond to web-svc-file vanished from the diagram the moment
it became binding. Match a service key against the provider's own primary
key from the registry, in both directions.

Services the role turns off drew a solid 1:1 edge, claiming a mandatory
relationship for something that carries nothing at deploy time. They now
render as a red 0..0 edge; edge merging gained a real precedence so a
provider consumed through two keys stays live when only one is off.

Named colour, not hex: a '#' opens an entity code in the mermaid lexer and
turns the whole diagram into a parse error on GitHub.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 18:02:31 +02:00
kevinveenbirkenbachandClaude Opus 5 37dc6b3406 feat(recover): replay a generation's database dumps
A backup generation stores databases as sql dumps, not as a file tree
(--dump-only-sql), and the recover chain only ever restored trees:
`git grep sql HEAD -- cli/administration/recover utils/recovery` was
empty. An operator running `recover full` after a real loss got their
files back, their databases untouched, and an exit code of 0.

Adds the missing leg as a fifth recoverer, planned right after the
volume steps so the dumps land once the volumes are back. Three
properties make it a restore rather than an attempt:

- It refuses while a consumer of that database runs. `--empty`
  pre-cleans the schema in one psql session and replays in the next; a
  booting consumer recreates it in between and the dump's own CREATE
  TABLE fails. That is what turned CI red with `relation
  "ar_internal_metadata" already exists`.
- The engine is read, not guessed: the service key in the consuming
  role's meta/services.yml, the same value that fed the backup seed.
  The two previous call sites each sniffed it from the container image
  and could disagree.
- The dump's own header states which server it came out of, and that is
  compared against the running engine before the pre-clean drops
  anything. Forward across a major version is the upgrade path and
  stays allowed; backward is refused. The version deliberately does not
  travel in databases.csv: that file is written at seed time and would
  state the version deployed back then, and a generation copied to
  another host carries its header along while a csv row stays behind.

Mind the header formats, they are not symmetric - postgres puts
`Dumped from database version` on line 7, while mariadb's line 2 opens
with mariadb-dump's own version and only the tab-separated `Server
version` line names the server. Both are pinned as test fixtures,
captured from postgres:17-alpine and mariadb:11 themselves.

baudolo is untouched: nothing beyond the credentials it already carries
is read from databases.csv, and no upstream format changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 18:02:31 +02:00
kevinveenbirkenbachandClaude Opus 5 e846063f5e feat(ci): cut redundant coverage from the deploy matrix
A clone and a row an earlier one already embeds prove nothing the sweep does
not already prove, but both still took a slot. Drop them when the regular line
is chunked, so the budget buys unique coverage only, and mark them in the plan
table with a status of their own instead of letting them read as 'beyond the
budget'.

A priority row is exempt: an operator who pins a clone asked for that deploy,
and dropping it would be the silent withdrawal 6605c7ef9 just removed.

The cut sits in chunks_of, not in entries_of, so the plan still lists every
candidate while no chunk can see one. 55 of 254 rows are cut on this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 18:02:26 +02:00
kevinveenbirkenbachandClaude Opus 5 32adf7010e refactor(ci): move clones-last into the sort spec
INFINITO_DISCOVERY_SORT is the single source of truth for the discovery
order, but 'asc clone' lived in sort_spec() and was concatenated in front of
it, so the declared value never showed the order a run actually takes.

Declare the key where the rest of the order is declared and let sort_spec
return the value verbatim. test_clones_sort_last_ahead_of_the_declared_spec
now guards the declaration instead of the concatenation, and covered rows
sort directly behind the clone key so they land at the tail of the ranking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 18:02:25 +02:00
kevinveenbirkenbachandClaude Opus 5 3b3ccbe9d4 fix(ci): withdraw pinned variants, not their whole role
The regular line was queried with selection.names(leading) as its blacklist,
and names() yields role ids only -- so pinning one variant in the priority
line withdrew every variant of that role from the sweep. Run 31927586060
deployed nextcloud only as #10 and #5; the other twelve variants existed
nowhere.

Blacklist only the tokens that name no variant, and drop the pinned
(role, variant) pairs from the regular rows afterwards. The variant-level cut
cannot go into the query filter: its tokenizer has no '#', so an axis token
aborts the parse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 18:02:24 +02:00
kevinveenbirkenbachandClaude Opus 5 b893d54ff3 refactor(ci): drop the unreachable sort fallback
env_setting returns a non-empty process value, else the default.env value,
else KeyError -- an empty process override falls back through the strip check
in parser.py. INFINITO_DISCOVERY_SORT is declared non-empty, so the spec the
ternary guarded against could never be falsy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 18:02:24 +02:00
kevinveenbirkenbachandClaude Opus 5 972b4ce7aa fix(ci): wire the offset key up and give the selections their own module
Four findings the quality gate raised against the offset and resume work, all
of them mine.

INFINITO_CI_OFFSET sat next to INFINITO_CI_CHUNK, where it reads well and
sorts wrong, and no handler claimed it -- so `make dotenv` would never have
materialised the default and every run outside CI would have resolved the
offset from an unset variable. It is in ASC order now and passes through the
static handler.

runs.py crossed 500 lines. The three helpers that translate job titles back
into selection tokens move to selections.py, which is a seam rather than a
quota: reading a finished run and dispatching a new one is one subject,
turning a job title into `role#variant@mode+tor` is another, and only the
latter is what lets a retrigger replay exactly what failed.

The sshd handler still told operators to look for a handler named
'sshd restart' after the topic became sshd-restart, and three lines from the
offset commit were past the format width.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 06:33:51 +02:00
kevinveenbirkenbachandClaude Opus 5 5d53c46c1f feat(ci): resume the regular line where the source run stopped
A retrigger started the regular line at the head again, redeploying the exact
window the source run had already judged. The red rows come back on the
priority line anyway, so that window was runner time spent on questions with
an answer.

The offset is computed now: the last row of the leading run of rows the source
run reached, as a selection token. Green or red does not matter -- what matters
is that the row ran. The scan stops at the first gap, because a hole inside the
window means that row was filtered out, not that the run got further, and
resuming past it would skip everything behind it.

The ranking is recomputed under the configuration the retrigger carries, since
that is the list the offset resolves against. The priority line is left out of
that computation on purpose: it only blacklists rows from the regular query,
and a token in it that no longer resolves would abort the very helper whose job
is to save time.

offset therefore joins priority as an input the retrigger decides rather than
carries. The integration test forced that: it asserts every carried input
arrives unchanged, and offset no longer did. The category was wrong, not the
test.

Costs a full local discovery per retrigger. There is no cheaper answer -- jobs
inside a chunk are sorted by name, so how far a run got cannot be read off the
job list without the ranking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 06:33:50 +02:00
kevinveenbirkenbachandClaude Opus 5 0f450722bc feat(ci): start chunk 0 at the head, or wherever the operator points
The regular rows began wherever (sweep * capacity) % count pointed. On run 210
that was offset 68: chunk 0 opened at candidate 69, the coverage-first head of
the list went undeployed, and no two runs could be compared because the window
moved with the run number. A run that looked wrong turned out to be merely
rotated, and finding that out meant recomputing the arithmetic by hand.

The rotation is gone. Chunk 0 reads the ranking from the top unless an offset
says otherwise, and the offset is an input now, threaded from the manual form
through the orchestrator and the deploy call down to the matrix builder. The
plan renderer takes the same flag, so the table shows the window the run will
take.

The offset is a row count or a selection token. A token is the honest form:
the ranking shifts whenever a variant appears, so 'offset 84' means something
different tomorrow while 'web-app-nextcloud#5' still means the same row. Axes
the token leaves open match anything, so a bare role name starts at its first
row and a pasted job label starts at exactly the job it names. A token that
matches no regular row aborts rather than silently starting at the head an
operator meant to skip.

The run title carries it as ⏩ followed by the value, and only when one was
asked for, so a rotated-looking run says so in its own name and the retrigger
reads it back from there when a job log has expired.

resume_from_chunk leaves the manual form with it. Skipping whole chunks was
the coarse way to say 'carry on further down'; the offset says it exactly and
keeps the rest of the chain intact. The orchestrator input stays for the other
entry points.

What this gives up is automatic tail coverage. Rows beyond the budget are
reached by moving the offset, widening the budget or narrowing the selection,
not by waiting for consecutive sweeps to walk the list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 06:33:50 +02:00
kevinveenbirkenbachandClaude Opus 5 4afb949223 feat(ci): one validation gate, and nothing starts before it is green
The merge assertion was a job nobody depended on. ci-orchestrator needed the
scope, the branch prefix and the affected roles -- not it -- so a PR whose base
was not merged in went on to spend five distros on a deploy sweep and reported
a single red job beside a chain that had already finished.

Validation is now a reusable workflow every entry calls, and what it checks
follows the event and the inputs rather than the caller: a pull request gets
its branch prefix and merge base asserted, a run carrying a selection gets that
selection checked, a fork PR waits here for the privileged run's images. The
wait and its gate move in with it, so the orchestrator no longer keeps a second
copy of that logic. Every downstream job depends on the gate.

The selection check is what the last two nights needed. cli.meta.ci.validate
resolves every token against the branch that would deploy it -- role, variant,
mode, onion state -- and reports all offenders at once. Until now the matrix
builder raised on the first bad token from inside a chunk job, so a stale
priority line cost one full CI round per entry and only ever showed the next
problem after the previous one was fixed. Measured on a real list: three
unusable tokens, one pass.

A bare role name that matches nothing is a warning, not an error: the
diff-derived whitelist legitimately names roles the lifecycle envelope filters
out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 06:33:49 +02:00
kevinveenbirkenbachandClaude Opus 5 15b3c5db03 refactor(ci): move the git and gh plumbing out of runs.py
runs.py stood at 568 lines against the 500-line cap from 4b37e123d. The git and gh helpers were the clearest seam in it: eight functions that know nothing about deploy runs and only drive subprocess and the gh CLI. They move to cli/administration/deploy/ci/gh.py, leaving 482 lines behind. The module is deliberately not named runs_gh.py, which would have formed a prefix group with runs.py and tripped the structure lint.

Re-exporting the moved names from runs.py does not survive the formatter: autoformat strips them as unused imports, and seven call sites in status/__main__.py and trigger/__main__.py would then raise AttributeError at runtime. The call sites therefore reach into gh directly, and the mock targets move with them -- patching a name in the module that no longer resolves it makes a test green without testing anything.

fetch_jobs stays reachable through runs, because find_last_deploy_run calls it internally and test_status.py covers both the URL and the branch path with a single patch. Splitting that name would have left one of the two paths unpatched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 03:38:47 +02:00
kevinveenbirkenbachandClaude Opus 5 f4c5a7cc36 feat(ci): retrigger the exact selection that failed, in every mode
A retrigger answered a red run with role names. Three things were lost on the
way, and each of them let the rerun test something other than what broke.

The mode was a scope: --failed swarm dropped whatever fell in compose, and two
aliases passed that scope permanently. Every mode is read now, and a leftover
scope argument is accepted and ignored rather than breaking the callers.

The selection was a role id, so the rerun deployed whichever variant, mode and
onion state the sweep rotation picked next -- not necessarily the combination
that failed. Each red job now contributes its own token
(web-app-x#0@swarm+tor); a role that failed in two modes comes back twice.

The carried configuration was a hand-written list of eight inputs against the
thirteen the form declares, so resume_from_chunk, sweep, workspace_track and
whitelist silently fell back to defaults. The list is read off
entry-manual-steer.yml now, and everything but the priority line travels
verbatim, values taken from the job log that records them all.

That rule is deliberate and total: it supersedes the suite-skip behaviour on
fork/feature/ai-agent-employees (b1ca39e9d), which turned workspace and
instructions off when the source run had passed them. A retrigger reproduces
its source; deciding which suites are worth re-running is not its job.

The integration test walks the workflow's own input declaration, so an input
added to the form without being carried fails here rather than in a run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 03:38:45 +02:00
kevinveenbirkenbachandClaude Opus 5 b058415eb3 feat(ci): name the row that already covers a planned deploy
The plan told you a row is being deployed, never that another row in the same
sweep already proves whatever it would prove. The complexity matrix has always
known -- covered_by holds the id of the earlier row that embeds this one -- but
the number stopped at the matrix, and 46 of 256 rows carry one.

The matrix entry now carries the row's discovery id and that covering id
through to the plan, which renders them as two columns and drops them again
before the matrix reaches the deploy jobs.

The id column had to change with it. It was a running count over the rendered
lines, so a covered_by of 1 pointed at a row that numbering does not have. It
is now the discovery id, the same number the complexity matrix prints, which is
what makes the reference resolvable inside the table it appears in.

Two tests indexed their columns by position and broke on the new one; they ask
_COLUMNS for the index now, so the next column does not break them again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 03:38:45 +02:00
kevinveenbirkenbachandClaude Opus 5 d42d139120 refactor(ci): render every matrix view through the discovery query
Two shell aliases rebuilt the discovery order by hand, and both got it wrong
in the same two ways: they passed INFINITO_DISCOVERY_SORT straight through,
dropping the clones-last prefix that keeps one representative per dna cluster
ahead of the budget cut, and they filtered on `compose` -- what a role can do
-- where discovery filters on `test_compose`, what CI actually tests. Measured
against the real query that is 13 rows of difference and a divergence from row
two onward.

The query module already owned both pieces. It now also renders the whole-role
view (--matrix --roles), which is the only thing the hand-rolled version could
do that this one could not, so nothing outside this module has a reason to
assemble a --sort or a --filter again.

A unit test pins the contract: the role view may drop the --variant flag and
nothing else, and the filter reads the tested column.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 03:38:44 +02:00
kevinveenbirkenbachandClaude Opus 5 111b1bbcdf feat(ci): pin deploy axes from a selection token
A whitelist or priority entry was a role name and nothing else, so a run
could not ask for "this variant, on swarm, behind the onion". It either
covered everything the role offers or waited for the rotation to come
around to the combination that broke.

An entry is now a token that may carry the axes, either as
web-app-x#0,2@swarm+tor or as the job label pasted straight back out of
the run that failed. What a token leaves open still belongs to the line
it stands in: priority covers every remaining combination in one sweep,
whitelist keeps the rotation and its position in the matrix. A pin the
row cannot take aborts the matrix rather than dropping the row, because
a dropped row reports a green run for a combination that never ran.

The onion state spells out as +clearnet, not as a -tor suffix: svc-net-tor
would otherwise read as role svc-net on the clearnet.

Chunk membership still follows the discovery ranking, which is what
decides who makes the budget cut, but the rows inside a chunk are sorted
by display name, variant, mode and onion state, so a chunk's job list
reads like the plan table instead of like the sweep's rotation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 03:38:42 +02:00
kevinveenbirkenbachandClaude Opus 5 810cb447bb fix(onion): hand the keys to the checkout owner
The node identity is minted during the first distro leg's deploy, as root inside the DiD on the bind-mounted workspace, mode 0600. The workspace survives between legs, so the second leg's 'pack the repo once on the controller' runs tar as the runner user and aborts on all three key files with EACCES -- 13 seconds into the play, before anything of the role under test has run.

Five swarm jobs of run 31864448027 die exactly there: svc-db-postgres, svc-db-openldap, svc-db-redis, web-app-matomo and web-svc-cdn. First leg 3313-5778s and green, second leg 125-140s and dead, whichever distro it happens to be. The remaining swarm failures are not immune, only shadowed: they die in their first leg for unrelated reasons and never reach a second.

Ownership follows the tree, not the process that happened to mint it. The repair runs on the reuse path too, where ensure_node_onion returns early and a root-owned key set from an earlier run would otherwise never be corrected. Modes stay 0600. lchown so a symlink cannot redirect the change, and a path that vanishes mid-walk is skipped instead of aborting the provisioner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 03:38:38 +02:00
kevinveenbirkenbachandClaude Opus 5 6e2b36c3d0 fix(inventory): resolve a system service id to no role
The applications: host_vars block is the config namespace keyed by role id, so it also carries system services (sys-ctl-hlth-csp, sys-svc-container). Those declare system_service_id and match no application role, and the resolver died on them instead of reporting their absence.

role_name now raises RoleNotFoundError and exits 3, distinct from argparse's usage error (2) and from a genuine failure (1). run_subprocess grew ok_returncodes so a caller can accept an expected non-zero status while every other failure still surfaces, and resolve_role_path returns None for that code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 13:42:32 +02:00
kevinveenbirkenbachandClaude Opus 5 ce2db79ee5 fix(ci-trigger): name the inputs a retrigger actually reproduces
CONFIG_INPUTS lost modes, sequencing and mode_fail_fast when the deploy
modes collapsed into one chunked sweep, but two mirrors of it did not
follow.

The --run help text enumerates what a retrigger carries over, so it was
telling the operator about three inputs the dispatch no longer has. The
job-log fixture described a run recording mode_fail_fast, a state the
dispatch can no longer produce, which left the log half of
config_from_run asserting on an input nobody writes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 06:26:44 +02:00
kevinveenbirkenbachandClaude Opus 5 6b3ed82aa0 fix(ci): resolve deploy job titles through the display-name codec
report_failures matched job names against a raw role id
((?:web-app|web-svc|svc|sys)-[a-z0-9-]+), but job titles have carried
display names for a while, so the regex matched nothing and every failed
deploy on main went unreported. runs.py had the same shape and stopped
at the first mode glyph, which swallowed a caller path like
'z / Host / sys-front-proxy' into the role name.

Both now go through axes.parse_label, kept next to the function that
builds the label so the two cannot drift, and resolve the middle through
the display-name codec. The parser anchors on the LAST mode glyph so a
reusable-workflow caller path carrying one of its own cannot capture the
role.

The onion state is part of what identifies a job, because a priority row
runs the same mode and variant twice; artifact_name takes it so the
reporter looks for the artifact the run actually uploaded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 06:09:05 +02:00
kevinveenbirkenbachandClaude Opus 5 5009ec81c9 feat(ci): deploy a sweep as serial chunks instead of three mode lines
One discovery query per run replaces one per mode, so the row basis, the
ranking and the budget cut all speak the same role#variant unit. The
result is deployed in serial chunk blocks rather than three parallel
mode matrices.

Chunk size is derived, not pinned: GitHub cancels a job queued for
INFINITO_CI_QUEUE_HOURS, so with INFINITO_CI_CONCURRENCY runners free a
chunk holds concurrency * floor(queue_hours / the deploy job's
timeout-minutes) rows. Assuming every job burns its full timeout is
deliberately pessimistic -- no job can outlast it -- so the drain
estimate can only overshoot.

The run job cap bounds the sweep as a whole; rows beyond it roll into
the next sweep, whose regular line starts at an offset so consecutive
sweeps walk the whole list instead of re-testing the same head. Priority
rows lead every sweep, never move with that offset, and force a chunk
boundary at the seam so a chunk is either all priority or all regular.

sequencing.py goes with the mode lines it ordered: chunking restarts the
queue clock at a controlled size, which is what serialising the modes
was approximating with three buckets of arbitrary size.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 06:09:04 +02:00
kevinveenbirkenbachandClaude Opus 5 dc6fbe24d9 feat(ci): give every variant its own row with rotating deploy axes
Variant bundling packed several variants onto one runner and made the
job count depend on storage sums. Every variant now gets its own matrix
row instead, and the two things that used to be fixed per workflow --
which deploy mode a row runs in, and whether it sits behind the node
onion -- become axes assigned per row.

Both axes are a deterministic rotation over the row's position and the
sweep number, never random, so a red job reproduces by re-running the
same sweep. Tor turns on sweep//2 so it does not flip in lockstep with
the mode: a row walks all four mode/tor combinations over four sweeps
instead of only two.

A role offers at most two modes in practice, since swarm requires its
own stack and host requires the absence of one.

Drops with the bundler: limits.py, the variant_bundle env handlers,
INFINITO_VARIANT_BUNDLE_SIZE/_MAX_STORAGE, the per-role
meta/tests.yml variant_bundle_size key and its only user, and the
ComplexityRow bundles/jobs fields with the --deploy-mode and --max-jobs
flags that consumed them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 06:09:04 +02:00
kevinveenbirkenbachandClaude Opus 5 a0cc3dbe40 feat(lint): make default.env the enforced source of the primary domain
default.env declares INFINITO_DOMAIN and everything is meant to derive from it.
Nothing checked that, so copies accumulated. A copy is a second source nobody
updates, and it fails in the least useful way: the code keeps running and
quietly talks about the wrong host. integration_mastodon is the worked example
-- it asserted the provisioned partner equalled a clearnet literal, so the spec
passed on a clearnet node and failed on an onion one while the product was
right in both.

tests/lint/repository/spot/test_no_hardcoded_primary_domain.py now fails on the
literal anywhere in tracked source. It reads the value it hunts for FROM
default.env, so the rule follows the SPOT instead of repeating it and would
otherwise be its own first violation.

Scope, and why each exclusion is not a loophole: default.env itself is the
source; .md is documentation; tests/ and scripts/tests/ hold fixtures that must
assert against a named host; inventories/ is where writing the real values is
the entire point; gitignored output carries the value because it correctly
derived it. Everything else is in.

Two real hardcodes are repaired rather than suppressed.
utils/domains/default_primary.py mirrors utils/roles/lifecycle.py -- one
function reading one key through parse_static_env -- and replaces the module
constant in utils/cleanup/nginx_vhosts.py and the argparse default in
cli/meta/domains/__main__.py. Both resolve to the same value as before.

Seven documentation sites become <primary-domain> placeholders instead of
carrying a nocheck: a Python docstring cannot host one without polluting the
docstring, and a placeholder does not rot when the default changes. The single
nocheck is scripts/system/tls/trust/wsl2.sh:61, where the marker is matched
verbatim against what an earlier run wrote into the Windows hosts file --
deriving it would orphan that block instead of replacing it.

Collateral, not intent: touching roles/sys-ctl-hlth-csp/files/script.py brought
it under the comment lint, which found five pre-existing narration comments.
Three deleted, `fn[:-5]  # strip ".conf"` replaced by fn.removesuffix(".conf"),
and the root-user comment rewritten in the sanctioned Exception form. Its 33
tests stay green.

tests/lint/repository/test_no_hardcoded_container_name.py (05f425ea5) is the
same family for INFINITO_CONTAINER and could move into spot/ later; this change
leaves it where it is.

make test-lint: 326 passed. The rule found 18 sites before the cleanup and none
after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 02:02:28 +02:00
kevinveenbirkenbachandClaude Opus 5 d86913225b fix(inventory): exempt credentials at their renamed root
compare_application_keys skips generated credentials because no default exists
for a secret, but it recognised them by the pre-rename prefix. Once the
generator started writing them again, every credential arrived as
secrets.credentials.<key> and the exemption stopped matching, so validation
rejected three keys per credential -- the leaf and both intermediate nodes,
which recursive_keys emits separately -- and aborted the deploy before the
playbook ran. The condition now admits the bare secrets node and the
secrets.credentials subtree, leaving a sibling such as secrets.other flagged.

The check in the inventory manager stays on the bare prefix on purpose: it
walks meta/secrets.yml, whose own root key is credentials.

test_credentials_keys_are_skipped fed the validator the old shape, so it kept
passing while real inventories were being rejected.

Evidence: run 31655559799, job 94311023723,
"Missing default for web-app-dashboard: secrets.credentials.sso_proxy_cookie_secret".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 03:13:26 +02:00
kevinveenbirkenbachandClaude Opus 5 32d9755763 style(credentials): import the emit helpers absolutely
provision and credentials are sibling packages, so reaching the emit helpers
with a parent-relative import tripped TID252. Same-package imports stay
relative, as everywhere else in the package. The blank line before the
role_resolver import keeps the first-party and relative groups apart, which
I001 requires.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:45:56 +02:00
kevinveenbirkenbachandClaude Opus 5 8edabb3246 fix(credentials): merge snippets at the renamed root
The host_vars merger still wrote applications.<app>.credentials and read
"credentials" out of the generated snippet. f713ba835 moved the emitter to
secrets.credentials, so the read resolved to {} on every app: nothing was ever
copied, and _ensure_ruamel_map left an empty credentials: {} shell behind as
the only trace. Every credential generated since that commit was discarded on
its way into the inventory.

Both sides now go through credentials_map() and declared_credentials(), the
accessors the emitter already uses, so the two ends cannot drift again.
_ensure_ruamel_map was a copy of emit.ensure_map and is gone.

The failure surfaced in two shapes from the one cause. Templates reading
through lookup('config', ...) raise, because get() defaults to strict:
"Key 'secrets' not found in dict at 'secrets'" for
web-app-mastodon.secrets.credentials.secret_key_base, with the dumped payload
showing 'credentials': {} and no secrets node. Templates reading through
lookup('database', ...) stay quiet, because that plugin passes strict=False
with default="": friendica's MariaDB received an empty MYSQL_ROOT_PASSWORD and
refused to initialise.

The generator's own tests pinned the pre-rename shape in both the snippet and
the assertion, which is why they stayed green through the regression.

Evidence: run 31649221474, job 94303863545.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:40:39 +02:00
kevinveenbirkenbachandClaude Opus 5 351b64e2c5 refactor(deploy): resolve role groups at runtime instead of generating task files
`cli build include` generated one tasks/groups/<group>-roles.yml per invokable category during setup, and the stage plays included those files. They were written by the in-container UID, which is why a dedicated clean-container-owned helper had to delete them from inside the container.

The stage plays now loop over lookup('group_roles', <group>) and include tasks/utils/setup/{group,role}.yml directly. ordered_roles() in utils/roles/order.py holds the run_after topological sort the generator used to perform at build time.

utils/roles/stage.py:_group_names() no longer globs the generated files; it reads get_invokable_paths(), the same categories.yml source those files were derived from.

Measured: handlers still flush after every role (ansible-core 2.20.2, three loop items, three separate handler runs), and resolving all 18 groups costs 0.60s cold against 1.4s for the generator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 23:21:34 +02:00
kevinveenbirkenbachandClaude Opus 5 905a5fb2d9 feat(deploy): rotate credentials between the two passes
PASS 2 read the exact values PASS 1 wrote, so no deploy ever had to propagate a secret that moved. Every role that only writes a credential at creation time therefore stayed green without the update path being exercised once.

Both orchestrators now call `inventory credentials reset` after the recover drill and before the async pass: compose through the container in the new .rotate module, swarm through the matrix orchestrator with --mirror, because its inventory keeps one host_vars copy per node. --backup keeps the pre-rotation file for the post-mortem, since after a rotation nothing records what PASS 1 deployed with. `administrator` stays exempt in both: its password is ansible_become_password, and rotating it would lock the deploy out of its own host.

Expect roles that never re-apply a changed secret to fail PASS 2 from here on; that is the gate working. Both database engines already converge their root password on every run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 23:17:06 +02:00
kevinveenbirkenbachandClaude Opus 5 7cd2664b06 feat(inventory): add a credentials reset command
Provisioning fills a missing credential and leaves an existing one alone, so every deploy pass reads the value the first pass wrote and the update path is never exercised. Rotating the generated values makes a redeploy carry a secret that moved.

`infinito administration inventory credentials reset` drops every vault-encrypted leaf under applications.*.credentials.* (--schema) and the pinned password of every declared user (--users), then lets the existing generators refill the gaps, so no schema logic is duplicated. Plain credentials stay unencrypted in host_vars and are therefore left alone, as are users that exist only in the inventory and would get nothing back. --except exempts application ids and user keys, --backup keeps the pre-rotation file as <name>.YYYYMMDDTHHMMSSZ.backup, and --mirror realigns the other hosts of a multi-host inventory.

Adds the command only; no deploy path calls it yet. Measured against web-svc-coturn and web-app-flowise: eleven generated secrets move between two runs while ansible_become_password and the administrator password stay pinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 23:06:55 +02:00
kevinveenbirkenbachandClaude Opus 5 f713ba8350 refactor(credentials): address them at secrets.credentials, like every other meta file
meta/schema.yml became meta/secrets.yml, but its content still landed at
applications.<app>.credentials — the one meta file whose payload did not sit
under a root named after the file. Every other topic obeys that rule:
meta/volumes.yml goes to volumes, meta/rbac.yml to rbac. Credentials now go to
secrets.credentials.

The path exists exactly once, in utils/manager/credential_key.py, and
OVERRIDE_SECTION is derived from its two segments rather than spelled beside
them, so the dotted form used by --set and the nested form used by the
inventory cannot drift. Six consumers read it: the loader, the generator, the
CLI override parser, the two emit helpers, and the four lookup validators.

--set now accepts exactly one key shape,
applications.<app>.secrets.credentials.<key>. The three short forms are
rejected instead of guessed at: the same short key names different credentials
on different apps. That also retired primary_app_id, which only existed to
disambiguate them.

Three defects surfaced while wiring this up. _apply_one_role_schema still wrote
to the old root, so an app's own credentials would have moved while its
providers' stayed behind. web-app-pgadmin reads through
lookup('applications', ...).credentials.<key>, an access path no regex over
lookup('config', ...) could see, and would have deployed with an empty postgres
password. And _META_NON_GENERIC_TOPICS still listed 'schema' — a bare topic
name, invisible to any path search — so the generic file-root loop assigned the
raw schema under secrets and the follow-up write landed inside the cached
document, truncating keycloak's four credentials to one for every later
reader.

tests/integration/ansible/lookups/config/test_credentials_roundtrip.py pins both
directions of the seam and the cache invariant that last defect broke.

Existing inventories still carry the pre-rename root; migrating them is
deliberately out of scope here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 16:51:56 +02:00
kevinveenbirkenbachandClaude Opus 5 6d8a741581 feat(users): let a user declare how its password must be generated
A user password is pinned once by inventory creation from a shell-safe
alphabet, because it travels through Ansible, a shell and container exec.
An application whose registration demands a special character then had no
way to say so, and roles worked around it with a second credential beside
the account they already owned. The account now carries algorithm and
validation itself, two roles disagreeing about one account abort instead of
one silently winning, and an unsatisfiable pair fails naming both halves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 15:10:15 +02:00
kevinveenbirkenbachandClaude Opus 5 e96c752549 Merge fix/playwright/env-template-gate into feature/svc-net-tor
75 commits: the socialhome role reimplemented against the project it actually
deploys, native-login specs for funkwhale and shopware, a window.open IdP
handoff and a settings-link fallback in the shared persona helpers, a swarm
converge budget that gives up on a terminal task, and a long tail of role
fixes.

Eleven conflicts. The shared persona helpers (keycloak.js, logout.js) take the
incoming structure — they gained real behaviour this branch never had — with
this branch's resolveTimeout convention re-applied over it and
confirmKeycloakLogoutIfPrompted re-attached at every return point; without it
the Keycloak SSO session outlives the logout. playwright.config.js keeps this
branch's version: both sides set actionTimeout, but ours routes through an env
var and 15s is too short behind an onion. socialhome's spec takes the incoming
content — 2dbd6eb10 found the role described the wrong upstream project
entirely — and keeps gotoOnion and expectHstsWhenTls, since demanding HSTS over
a plaintext onion is a false failure. Both READMEs were regenerated rather than
merged by hand.

Three collisions arrived with no conflict marker at all, because each side
changed a different place in a different file:

- group_vars/all/18_swarm.yml declared swarm_stack_converge_retries twice.
  Same value, so nothing breaks today; editing the documented one would have
  silently changed nothing.
- svc-net-tor and web-app-socialhome both claimed 192.168.74.0/24. Socialhome,
  the newer role, moves to 192.168.75.0/24.
- web-app-socialhome/meta/schema.yml arrived as a new file, so ef5ea7cb5's
  rename never saw it. Its credential would not have resolved and the role
  would have deployed with an unusable administrator account.

The incoming specs also needed this branch's conventions: raw timeouts wrapped,
four page.goto calls routed through gotoOnion, a request.get given an
onion-aware timeout, and 21 roles marked playwright-service-flag over the tor
block this branch added to them.

make quality green on the merge result.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 14:20:19 +02:00
kevinveenbirkenbachandClaude Opus 5 bc1d2d86f6 feat(ci): carry the tor mode into a retrigger, read from the job log
A retrigger reproduces its source run's configuration, and the tor axis was
missing from it: rerunning a run dispatched with 'exclusive' silently fell back
to 'auto' and tested a different matrix than the one whose failures it was meant
to chase.

The mode is read from the job log's Inputs block, not from the run title. The
title renders it as a glyph, so recovering it there would mean translating a
symbol back into a word on every retrigger; the log already holds what the
operator picked, and inputs_from_jobs reads that same block for the priority
list today. LOG_INPUTS names the inputs that take this route.

That also removes a trap the glyph created. The tor segment renders through
format('{0} ', ...) with no opening literal, and run_name locates a value by
searching for its head - str.find('') matches at position 0, so the parser would
have handed back the title's own prefix as the tor value, plausibly and without
raising. segments() now skips a headless segment; its glyphs stay openings, so
they still terminate the value before them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 12:22:20 +02:00
kevinveenbirkenbachandClaude Opus 5 b1ca39e9dd feat(cli): stop retriggering CI suites the source run already passed
A retrigger carried the workspace and instructions toggles over from the
run title, so a suite that had already gone green was dispatched again
and spent runners on work with a known answer.

Derive each toggle from the source run's job record instead: a suite is
turned off only when every one of its jobs reports success. A suite that
was skipped, cancelled or never dispatched has not passed, so it is still
asked for; absence of a failure is not evidence of a success. One failing
shard keeps the whole suite on.

Match against the job path rather than the run title by dropping the
workflow-name segment, and fail open when nothing matches, so an unknown
job layout re-runs the suite rather than silently skipping it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 19:28:35 +02:00
kevinveenbirkenbachandClaude Opus 5 a0494660c0 docs(services): say what is_explicit_truth actually matches
The docstring promised the narrow form "{{ '<role>' in group_names }}" and
pointed at tests/integration/roles/meta/test_services_dynamic_flags.py, a path
that does not exist. The helper substring-matches "in group_names", so a
negated predicate matches too -- and that is deliberate: such a flag is true on
some node shape, and the static closure is the co-deploy superset, so the
dependency must resolve at tooling time either way.
tests/integration/roles/meta/services/test_dynamic_flags.py:194 asserts the same
substring rule, which is the real contract.

Reported during triage as a latent defect (seaweedfs' frontend is gated on
"not in group_names" and reads as enabled on a tor node). It is not one; the
description was. Tightening the matcher would have changed behaviour against the
test that pins it, so that is filed as won't-fix and only the wording changes
here. The README table gains the row it was missing.

No executable line in this diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 18:55:22 +02:00
kevinveenbirkenbachandClaude Opus 5 ac7b98f342 refactor(workspace): split the environment suite into base, compose and swarm tracks
scripts/tests/environment/ becomes scripts/tests/workspace/ with one
subdirectory per track. base/ prepares the workspace and is a prerequisite;
compose/ and swarm/ are independent of each other, so CI can run them as
separate jobs with a budget each. 00_orchestrator.sh runs the tracks named
by INFINITO_WORKSPACE_TRACKS and aborts on an unknown one.

The job count stays at five: cli.meta.ci.dev_runtime matrix assigns exactly
one track per distro and flips the split with the run number, so two
consecutive runs cover both tracks on every distro without doubling the
matrix. The workspace_track input (rotate|compose|swarm) pins it from manual
steer through the orchestrator for a targeted re-run.

Job names carry the track glyph from utils/symbol_glossary and the image
shortened to its last two segments, so quay.io/centos/centos:latest reads
as centos:latest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 22:57:05 +02:00
kevinveenbirkenbach dc4e58ee09 fix(ci-trigger): resolve the run repo through remote.pushDefault
A branch with no tracking remote fell straight through to origin, so on a fork checkout every run lookup asked the upstream repository and answered HTTP 404. i8cifail could not reach a single run of this branch.

Consult remote.pushDefault between the tracking remote and origin. That is git's own answer to where the branch is pushed, hence where its runs live, and the i8puma alias already reads exactly this key — the two resolution paths now agree instead of contradicting each other.

The fallback chain had no test at all: every existing case mocks resolve_repo away. Four cases now pin it, including a pushDefault naming a remote that no longer exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 123794c7f4)
2026-08-10 20:50:24 +02:00
kevinveenbirkenbachandClaude Opus 5 123794c7f4 fix(ci-trigger): resolve the run repo through remote.pushDefault
A branch with no tracking remote fell straight through to origin, so on a fork checkout every run lookup asked the upstream repository and answered HTTP 404. i8cifail could not reach a single run of this branch.

Consult remote.pushDefault between the tracking remote and origin. That is git's own answer to where the branch is pushed, hence where its runs live, and the i8puma alias already reads exactly this key — the two resolution paths now agree instead of contradicting each other.

The fallback chain had no test at all: every existing case mocks resolve_repo away. Four cases now pin it, including a pushDefault naming a remote that no longer exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 20:17:07 +02:00
kevinveenbirkenbachandClaude Opus 5 2cf6a44a29 fix(ci): let the guide test honour the deploy mode declared in services.yml
The guide selector asked only meta/tests.yml whether a role opts out of its
deploy mode, so a role that switches the mode off in meta/services.yml stayed
in the random candidate pool and could be deployed by an orchestrator run that
passes no role, priority or whitelist.

Consulting get_role_mode_enabled covers both modes the selector can pick, host
included, which get_role_skip does not reach.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 15:52:34 +02:00
kevinveenbirkenbach efaa9b1c49 Merge remote-tracking branch 'origin/main' into feature/ai-agent-employees
# Conflicts:
#	roles/web-app-flowise/meta/services.yml
2026-08-10 00:14:48 +02:00
kevinveenbirkenbach abc5cd5754 fix(coredns): render the Corefile readable to the container user
mkstemp creates its file 0600 and Path.replace carries that mode into the target, so every freshly rendered Corefile was owner-only while coredns runs as nonroot. The container exits with 'open /Corefile: permission denied', name resolution dies for the whole stack, and deploys then fail at whatever they try to fetch first.

Long-running stacks hid it: coredns reads its config once at startup, so a container created before a re-render keeps working. Only new worktree stacks broke.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit efc281e327)
2026-08-09 13:30:57 +02:00
kevinveenbirkenbachandClaude Opus 5 efc281e327 fix(coredns): render the Corefile readable to the container user
mkstemp creates its file 0600 and Path.replace carries that mode into the target, so every freshly rendered Corefile was owner-only while coredns runs as nonroot. The container exits with 'open /Corefile: permission denied', name resolution dies for the whole stack, and deploys then fail at whatever they try to fetch first.

Long-running stacks hid it: coredns reads its config once at startup, so a container created before a re-render keeps working. Only new worktree stacks broke.

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