Commit Graph
297 Commits
Author SHA1 Message Date
kevinveenbirkenbachandClaude Opus 5.5 2348d48110 fix(web-app-shopware): move PHP base to docker-base 8.3.36-caddy
The bare ghcr.io/shopware/docker-base:8.3 tag is frozen. Its digest
sha256:14826eb9 is the same as 8.3.25, built 2025-09-05, and carries
PHP_VERSION=8.3.25. Upstream has published only flavoured tags since
8.3.26. 8.3.36-caddy (sha256:b1fdca6e, built 2026-10-30, also the
current 8.3-caddy) carries PHP_VERSION=8.3.36. It has the same
supervisord entrypoint, www-data user and exposed ports 8000/9000 as
the bare tag. That clears the 8.3.36 security floor that hlth-runtime
reports for shopware-web, -worker and -scheduler.

The flavoured tag needs two follow-ups:

- SHOPWARE_COMPOSER_PLATFORM_PHP drops the "-<flavor>" suffix before it
  pads major.minor. Without that, composer gets platform.php
  "8.3.36-caddy". It now renders 8.3.36, and 8.3 still renders 8.3.0.
- shopware/docker 6ae1717 ("feat: remove default env vars") removed
  SHOPWARE_ES_ENABLED=0, SHOPWARE_ES_INDEXING_ENABLED=0 and
  SHOPWARE_ES_INDEX_PREFIX= from the image. Shopware 6.7.13.0 reads
  them through %env()% with no container default, so env.j2 now sets
  the values the old image shipped. SHOPWARE_HTTP_DEFAULT_TTL keeps its
  7200 default from core services.xml. Nothing in Shopware reads
  DATABASE_PORT, and the role uses DATABASE_URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:45:19 +01:00
infinito-nexus-updater[bot] 1ba7903f31 update(images): update Docker image versions (web-app-shopware) 2026-10-30 04:58:18 +00:00
kevinveenbirkenbach 022abe155e Merge pull request #1259 from infinito-nexus/fix/sys-svc-compose-php/remove-interim-php-mitigations/1126
fix(sys-svc-compose-php): scope memory_limit -1 to unpatched images
2026-10-29 23:59:23 +01:00
kevinveenbirkenbachandClaude Opus 5.5 c2577560f8 fix(swarm): bound the in-container probes and retry only safe fider POSTs
Review follow-up on the in-container probes of gitea, shopware and fider.

- fider: the tenant POST over /dev/tcp waited on `head` without a bound,
  so a peer that accepts and stays silent blocked the task for good. It
  now runs under `timeout` (TIMEOUT_FACTOR-scaled 30 s, the old uri
  socket timeout) and reads the status line with the `read` builtin, so
  the bound kills the whole probe. Only outcomes Fider never committed
  are retried: a refused connect and a 5xx (Fider rolls the request
  transaction back on a handler error). A timeout or a closed socket
  after the request was sent fails the task instead of resending the
  POST; the next run's tenant count decides again.
- fider: both bootstrap tasks get `no_log` behind MASK_CREDENTIALS_IN_LOGS,
  as the body carries the administrator's name and email.
- shopware: each wget probe runs under `timeout`, as busybox wget waits
  900 s per read by default.
- shopware, gitea: the first readiness wait derives its retries from
  lookup('timeout', 300) again, the budget of the wait_for it replaced,
  so TIMEOUT_FACTOR scales it.

Refs #1089

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 01:41:55 +01:00
kevinveenbirkenbachandClaude Opus 5.5 c59e2838d6 fix(swarm): reach gitea, shopware and fider from inside their containers
In swarm, `docker stack deploy` drops the host IP of a short port mapping,
so a `127.0.0.1:<ports.local>` publish lands on the ingress routing mesh
on every node. The provisioning of these three roles only worked because
the mesh answered on the host:

- web-app-gitea waited for `DOCKER_REACH_HOST:<ports.local.http>` with
  wait_for and polled `/api/v1/version` there with uri_retry.
- web-app-shopware waited for `DOCKER_REACH_HOST:<ports.local.http>`.
- web-app-fider bootstrapped its first tenant in swarm by POSTing to
  `127.0.0.1:<ports.local.http>` on the hosting node.

As long as these calls depend on the mesh, container_ports cannot stop
publishing loopback ports in swarm. The probes now run inside the
service's own container through the resolved `<PREFIX>_LOCAL_CID` on
`<PREFIX>_HOST_NODE`, against the internal port:

- Gitea uses the curl it ships. The first wait accepts any HTTP answer,
  as wait_for did. The post-migration wait still requires 200.
- Shopware runs the wget probe its compose healthcheck already uses.
- Fider ships no HTTP client, so bash writes the tenant POST to
  /dev/tcp. The body comes in on stdin and the status line is checked
  for 200/201.

Fider's compose path is unchanged. The compose render of all three roles
is unchanged. The host-side ingress guard from svc-swarm-node stays in
place until the remaining host-side consumers are moved.

Refs #1089

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 00:01:20 +01:00
kevinveenbirkenbachandClaude Opus 5.5 e3d2a24bcd fix(sys-svc-compose-php): scope memory_limit -1 to unpatched images
The interim CVE-2026-109618 mitigation lived in the shared hardening.ini,
so every PHP role ran without a memory limit until the last image was
patched, and no role's service_memory_mb share applied.

Registry metadata of the images pinned on main shows that 16 of the 20
consumers already ship PHP 8.2.35, 8.3.36, 8.4.27 or 8.5.12 (or the
Alpine/Debian build of it). Four predate the fix:
markoshust/magento-php:8.4-fpm (8.4.21), ghcr.io/heviat/webmail:2024.06
(the Mailu OIDC flavor, built 2025-04-04), phpldapadmin/phpldapadmin:2.3.11
(built 2026-05-15) and ghcr.io/shopware/docker-base:8.3 (8.3.25).

- Move memory_limit = -1 into its own content-addressed ini
  (FILE_PHP_MEMORY_UNLIMITED_INI) and mount it only in magento, mailu,
  phpldapadmin and shopware.
- hardening.ini keeps expose_php, session.upload_progress.enabled and
  fatal_error_backtraces as permanent hardening.
- Joomla's php-upload.ini loads as conf.d/uploads.ini again: no -1 has
  to be outranked anymore.
- The root TODO names the four images that still need the override.

Refs #1126

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 22:39:07 +01:00
kevinveenbirkenbachandClaude Opus 5.5 d89c74feab feat(security): harden PHP services and proxy response headers
Proxy:
- server_tokens off, X-Powered-By stripped from upstream responses,
  HSTS max-age raised to one year.
- No global X-Content-Type-Options: under nosniff, ORB (step 3.4)
  blocks no-cors cross-origin responses typed text/html, e.g. tracking
  beacons answered with an empty 204.

PHP (CVE-2026-109618, CVE-2026-109619):
- New role sys-svc-compose-php installs a shared ini (expose_php Off,
  session.upload_progress.enabled 0, fatal_error_backtraces 0 for
  PHP 8.5, interim memory_limit -1) and a PHP-FPM [www] drop-in
  (pm.max_requests = PHP_FPM_MAX_REQUESTS) once per stack host,
  before 04_files renders any compose file.
- The host file names carry a sha1 prefix of the controller-side
  source, so a content change alters every PHP app's compose.yml and
  recreates it, also on remote deploys where compose_volumes cannot
  read the stack host file and hashes the path instead.
- 20 PHP roles mount the ini as a compose/swarm config into their
  image's scan dir; moodle, nextcloud and shopware also mount the FPM
  drop-in. Magento keeps its own pool: its php-fpm.conf has no
  include= and is rendered without notify.
- Joomla's php-upload.ini now loads as zzz-joomla-uploads.ini so its
  own memory_limit wins: the media API compares Content-Length with
  toBytes(memory_limit) and rejects every upload at -1.

TODOs record the memory_limit revert condition and accepted
trade-offs, Akaunting on PHP 8.1, FusionDirectory on PHP 7.4 and the
public Keycloak admin console.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-21 21:03:38 +02:00
kevinveenbirkenbachandClaude Opus 5.5 dd955a71a6 style(markdown): pin list markers to dash and convert every list
The 16.0.0 entry pkgmgr wrote on top of CHANGELOG.md uses "-" bullets
while every older entry uses "*". MD004's default "consistent" style takes
the marker of the first list in a file, so lint-markdown reported 400
MD004 findings in CHANGELOG.md and the pre-push gate refused the release
push.

MD004 is now pinned to "dash", the marker pkgmgr and the changelog skill
write, and make autoformat (markdownlint-cli2 --fix) converted the lists
of the 72 files that still used "*". Only list markers changed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 23:04:02 +02:00
evangelostsak 074892dcdc feat(web-app-shopware): let a deployment put its hosts into meshes 2026-10-16 03:40:20 +02:00
kevinveenbirkenbachandClaude Opus 5.5 462bc4ccf0 fix(web-app-shopware): keep the init memory limit in the role's env file
The init service received INIT_PHP_MEMORY_LIMIT through an inline
environment block in the compose template. It moves to templates/env.j2,
which the container base hands to every Shopware container as env_file.
Only files/shell/init.sh reads the variable, behind a `:?` guard, so the
other containers seeing it changes nothing.

The lookup names 'init' instead of service_name, which is bound in the
compose template but not in env.j2. The value stays 1024M.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 01:37:17 +02:00
kevinveenbirkenbachandClaude Opus 5.5 0f7fb92e12 refactor(roles): take seven roles' memory parameters from the container limit
Memcached, Joomla, Moodle, Odoo, Shopware, Snipe-IT and SuiteCRM set
their cache size, PHP memory_limit, OPcache, worker limits and search
heap as literals next to a mem_limit they never read. They now ask
lookup('service_memory_mb') for a share of that limit. The shares are
chosen so every rendered value stays what it was.

Where the parameter lives in a file that cannot hold a template, the
value is handed in: Shopware's init script reads INIT_PHP_MEMORY_LIMIT
and refuses to start without it, SuiteCRM's image takes it as a build
argument. Shopware's worker gets --memory-limit as a command appended
to its entrypoint, and Moodle's OPcache size moves from the static ini
into the rendered one.

The performance.php.memory_limit keys of Joomla and Moodle are gone
from services.yml; nothing else read them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 23:24:22 +02:00
kevinveenbirkenbachandClaude Opus 5.5 d3ca143343 docs(roles): regenerate READMEs for Stalwart as the mail provider
MAIL_PROVIDER moved to web-app-stalwart without a make docs run, so every generated README still drew Mailu as the email dependency. This is the generator output for the current tree: the root role table and each role cosmos now name Stalwart, Mailu loses its dependents, and Stalwart gains its swarm marker, the Tor dependencies and the 0..0 edges.

The hand-written cosmos of svc-net-tor-smtp gives way to the generated one, which no longer shows the SOCKS5 hop through svc-net-tor because the role does not declare that dependency.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 19:57:30 +02:00
kevinveenbirkenbach aa55c8c8bd Merge branch 'main' into feature/role-stalwart-mail-swarm 2026-10-02 17:40:09 +02:00
infinito-nexus-updater[bot] 2f47588040 update(images): update Docker image versions (web-app-shopware) 2026-09-30 14:59:27 +00:00
Alejandro Roman 4b9a834546 Merge remote-tracking branch 'upstream/main' into feature/role-stalwart-mail-swarm 2026-09-21 03:25:20 +04:00
kevinveenbirkenbachandClaude Opus 5 96408f23a0 perf(healthcheck): let the start window outlast a real boot
start_interval is the cadence while start_period runs, and only while it runs.
Once the window closes the probe falls back to interval, so a service that
becomes ready just after it pays a whole tick anyway and the cadence never
reached it. Six of the seven flavors were shaped that way, Curl worst: a thirty
second window against a one minute interval.

Measured across 151 converge waits in four job logs, 63 sit exactly on a tick
boundary, 36 in the thirty second step and 27 in the sixty second one.
Attributed by application, web-app-seaweedfs is the largest at 1501s over
sixteen waits with a median of 57.7s: it probes with curl and fell straight into
the gap. web-app-zammad probes with connect, whose window is fifteen minutes,
and its 154s median is real boot time with nothing to recover.

So the window moves to ten minutes and the steady state interval to two, and the
flavor overrides that sat below them go: Curl, Wget and Connect lose interval
1m, Http its twenty second window, Nc its ten. Connect keeps fifteen minutes.

Neither number costs anything while a container works. start_period delays
nothing, it only stops failures counting, and interval no longer touches
convergence at all since that hangs on the window alone; it is steady state
load, which drops from 120 probes an hour per service to 30.

Forty six role files follow. Twenty seven declarations go that repeat the new
default or undercut it, an undercut window being the defect itself. Eight
windows longer than ten minutes keep their value and now say why. The literal
blocks and the three Dockerfiles this repository builds spell every timing out,
because outside the lookup an omission takes docker's value and not the
platform's.

A fourth lint rule keeps the shape: start_period must outlast one interval,
checked against the effective values, so raising interval without the window
cannot quietly bring the cliff back.

Two faults in the lint surfaced while building it and are fixed here. It
compared timings as strings, so 600s and 10m read as different values, and it
matched the healthcheck key exactly, so a line carrying a trailing nocheck was
skipped whole, which is how the two swarm-init partials went unexamined.

One regression, bounded. A container that starts and never turns healthy is
neither rejected nor errored, so stack_ready.sh only calls it fatal after three
killed tasks, and each cycle now spans the longer window. That reaches twenty
minutes rather than six, capped there by swarm_stack_converge_retries at 600
with a two second delay, and it lands only on runs that were failing anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 21:54:26 +02:00
kevinveenbirkenbachandClaude Opus 5 16be22007b fix(healthcheck): spell the timings out where the SPOT cannot reach
A healthcheck written outside the container_healthcheck lookup inherits nothing
from utils/docker/healthcheck, and docker offers nowhere to put a replacement.
daemon.json carries storage, logging, DNS, registries and runtimes, and no
health key at all, so an omitted timing lands on docker's own value rather than
the platform's. The two disagree where it matters most: docker starts a
container with no start period, which leaves start_interval no window to act in.
That is why the hand-written blocks never gained the cadence.

The three images this repository builds itself now carry it. openldap and
postgres also drop to the platform interval, and bluesky gains the start window
it never had:

  svc-db-openldap/files/Dockerfile         --interval=30s ... --start-interval=5s
  svc-db-postgres/templates/Dockerfile.j2  --interval=30s ... --start-interval=5s
  web-app-bluesky/.../Dockerfile           --start-period=30s --start-interval=5s

Six literal blocks move to the platform interval as well: magento, redis,
elasticsearch, matrix and both sys-svc-rdbms partials. Since start_interval
exists, interval no longer touches convergence and is steady state load alone.

Sixteen values that stay where they are now name the mechanism that keeps them
there. Two are worth reading. web-app-kix overrides start_period and nothing
else on purpose, letting the image's own HEALTHCHECK supply test, interval,
timeout and retries; filling those in would have overwritten what upstream tuned.
The memcached and redis swarm-init partials probe every second and deliberately
run with no start window, which is why start_interval is absent rather than
forgotten.

The rest name their probe: occ boots the whole Nextcloud stack, tor re-parses
its torrc instead of asking a running service, opensearch is still forming a
cluster, matrix waits on a file another step writes to a share.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 21:15:49 +02:00
kevinveenbirkenbachandClaude Opus 5 19f4ac3390 refactor(healthcheck): drop timings that repeat their flavor default
The lookup writes overrides.get(key, effective_default), so a service that
repeats what it would have inherited changes nothing and only hides the day the
default moves. Forty seven such lines stood in the tree.

Almost all of them came from the migration that moved the literal blocks into
meta/services.yml. The values were carried over verbatim so the move could be
proven lossless, which was right for the proof and left the no-ops behind.

The defaults they are measured against are the effective ones, not the base
class. Curl probes every minute, Connect grants a fifteen minute start window,
and a prefix raises timeout, retries and start_period further. Comparing against
Probe alone would have called out every service that simply agrees with its
flavor.

Eight services lose interval: 10s as well and fall back to their flavor's
cadence, thirty seconds, or a minute for decidim's curl. Since start_interval
exists, interval no longer touches convergence at all: it is steady state load
for the life of the deployment. That takes the five databases, matomo, snipe-it
and decidim from 360 probes an hour each to 120. It buys that with slower
failure detection, 180s to 540s for the databases, which is acceptable because
an unhealthy container fails the deploy whichever number stands there.

Twelve deviations that do cost something now carry their reason. Where the
mechanism is known it is named: ejabberdctl and rabbitmq-diagnostics start an
Erlang CLI node, mmctl spawns a binary and talks to the local socket, php artisan
boots Laravel on every probe, keycloak's management endpoint answers from the
JVM.

Two say instead that the value was carried over and never measured. './fider
ping' and pgrep both fit the five second default and no reason for ten is
visible, so the comment records that there is no evidence rather than inventing
one. They mark where a measurement belongs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 21:02:02 +02:00
kevinveenbirkenbachandClaude Opus 5 d0a8608bfa perf(healthcheck): give every probe the start cadence 42ebd9ea71 added
42ebd9ea71 gave the healthcheck SPOT start_interval, the cadence docker uses
while start_period is still running. A block a template writes out itself
bypasses the SPOT and never gained it. Measured across the repository: 46 such
blocks, 40 of them already carrying start_period, not one carrying
start_interval.

Without it a probe fires only every interval, so a service ready after two
seconds is reported healthy at the next tick and a stack converges no faster
than its coarsest quantisation. That lands in sys-svc-compose's swarm converge
wait, 1551.7s per CI job over 38 stack deploys.

Twenty five of the blocks move into meta/services.yml and render through
container_healthcheck. Each keeps an explicit test rather than picking a flavor:
a flavor would replace the command instead of carrying it across.

The move is lossless by construction. probes.py block() writes
overrides.get(key, default) for interval, timeout, retries, start_period and
start_interval, so a declaration repeating the literal values renders the same
block plus the missing cadence. Checked per block against the old values: the
only difference is start_interval 5s, nothing changed, nothing lost.

Twenty one stay literal. Seventeen interpolate a template variable, whose value
resolves in a scope meta/services.yml does not share, so moving them would be a
rewrite rather than a transcription. Four interpolate nothing and still cannot
move: svc-db-memcached and svc-db-redis probe every second in their swarm
service partial, where a 5s start cadence would report healthy later than the
block already does, and the sys-svc-rdbms and keycloak sso_proxy partials render
for the consuming role's application_id, so their declaration has no single
meta/services.yml to live in. Each carries a nocheck naming its reason.

All twenty one gain start_interval. Three also gain start_period, without which
the key does nothing at all, since it only applies while the start period runs.
That widens their failure tolerance rather than narrowing it: bluesky from 90 to
120 seconds, magento from 50 to 80, the mariadb partial from 180 to 210.

pixelfed spelled its probe as a bare string and now spells it CMD-SHELL. Docker
reads both as a shell command; only the YAML shape changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 20:31:07 +02:00
kevinveenbirkenbachandClaude Opus 5 4986e80dd4 feat(readme): ask the reader for a domain the deployment can resolve
The Production block never set one. inventory provision writes only
ansible_connection for a local host, so DOMAIN_PRIMARY stayed at the
group_vars default of infinito.localhost and the deployment served a zone its
own resolver knows nothing about. CoreDNS templates answers for
INFINITO_DOMAIN_RE and nothing else, so every lookup of the deployment's own
name came back NXDOMAIN.

Mastodon is where that surfaced. tootctl accounts create validates the address
through EmailMxValidator, which resolves MX and then, for the domain itself,
A and AAAA. All six queries returned NXDOMAIN in run 33574056480, the
validator recorded email :unreachable, and the admin account was never created.
Only the guide rows were affected: the matrix rows write
DOMAIN_PRIMARY: "{{ lookup('env', 'INFINITO_DOMAIN') }}" into their host_vars
and resolve fine.

The template now carries DOMAIN=<your-domain> in both the host and the
container form and threads it into --vars, with the container form passing it
through the docker run boundary as well. The two guide runners substitute it
the way they already substitute the SSH key, so the replay deploys under
INFINITO_DOMAIN like every other CI deploy. compose_deploy.sh moves its env
load ahead of the substitutions to have the value; host_deploy.sh had no env
load at all.

A production reader is asked for a domain now instead of silently inheriting
infinito.localhost, which no real deployment wants either. The shipped default
keeps its value: sys-svc-nss documents that *.localhost resolves through the
myhostname NSS module without any zone, and host-side tasks lean on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 18:30:25 +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 bb7167458f fix(web-app-shopware): block the conductor, not the sweep endpoints
The negative control aborted every <host>/logout and then asserted the
admin bearerAuth cookie survives. It failed in run 32597195876, job
Shopware#0, in 5 of 6 executions: the cookie was gone anyway.

The premise was right and the target was wrong. The trace of the failing
run shows all nine sweep aborts landing - 584 ms too late:

  51.534  -1   shop/api/oauth/sso/config       logoutSso() cut off by the
                                               injected logout redirect
  51.538  200  auth/.../openid-connect/logout
  52.842  200  auth/.../logout/logout-confirm
  53.377  200  logout.<domain>/?sid=...        conductor, Clear-Site-Data
  53.961  -1   9x <host>/logout                sweep, correctly blocked
  54.256  200  shop/admin                      cookie already gone

What deletes the cookie is the Clear-Site-Data header on web-svc-logout's
conductor page - "the ONLY path-agnostic cookie deletion mechanism" per
universal-logout's app.py - and it clears the whole registrable domain,
which is how an origin on logout.<domain> reaches a cookie on
shop.<domain>. The per-app /logout requests are the second mechanism, not
the first. Shopware's own clearAuthState never runs either: the injected
redirect preempts it.

The route now blocks the conductor, which is the honest simulation of
"web-svc-logout never lands". It loads after Keycloak's logout-confirm,
so blocking it cannot disturb that flow.

Two anchors identify it, because one is not enough on every axis.
`lookup('tls', ...)` family-aligns a cross-app reference per consumer
(utils/tls_common.py align_domain_to_consumer), so where web-svc-logout
is onion-primary and this role and Keycloak sit in different families,
the origin resolved here is not the one Keycloak framed. The front-channel
parameters Keycloak appends catch that case. LOGOUT_URL stays optional for
the same reason - an empty lookup must fall through to the second anchor
rather than fail the test.

The sibling's poll message names the real mechanism and keeps the
rationale for its 30s wait, which 5b6d12f12 added for a reproduced
Tor-only failure.

Verified on clearnet evidence only: the failing job skipped every Tor
task, and no Tor run in either pipeline produced a trace, since traces are
retain-on-failure and those jobs were green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 04:31:56 +02:00
kevinveenbirkenbachandClaude Opus 5 575a5a87ac refactor(layout): sort shipped scripts by language and split the unit suite
Every script a role ships now lives under `files/<language>/`, and the unit
suite is split the same way: `tests/unit/python/` beside new sibling trees for
the other languages. `tests/lint/filesystem/test_role_files_layout.py` keeps it
that way; directories whose shape is dictated from outside carry a `.nocheck`
naming the constraint (Docker build contexts, WordPress mu-plugins, the Joomla
extension, Odoo addons, Playwright suites, the `files/test/test.sh` harness
that `test-e2e-cli` resolves generically).

This cannot be applied in halves. Moving a file and repointing what names it
are the same change: 14 references resolve only at deploy time, so the tree is
green either before or after, never between.

Those 14 were found by static resolution over every reference class, not by the
test suite -- a full `make quality-high` was green while all of them were
broken:

  * 7 `src:` values built from a role variable holding a bare filename
    (svc-bkp-remote-2-local, decidim, espocrm, friendica, joomla, pixelfed x2).
    The variable doubles as the destination name inside the container, so the
    language segment belongs at the call site, not in the variable.
  * `sys-service/tasks/04_base.yml` resolved every consumer's systemd script
    through `first_found(..., errors='strict')` over `files/script.{sh,py}`.
    18 roles ship exactly that and have no template fallback: the play would
    have died on each.
  * `sys-front-inj-javascript` fell back to `<role>/files/javascript.js`; the
    interface now reads `files/javascript/javascript.js`.
  * `sys-front-inj-logout/templates/head_sub.j2` built the cache-busting mtime
    path through `path_absolute(... 'files', ...)`.
  * 4 `ansible.builtin.script:` invocations naming a bare command
    (pull.py, prune.sh, set_postgres_superuser_password.sh, ganesha-serve.sh).
  * `dev-python/files/shell/install.sh` pointed at a sibling role's script.

Depth-dependent paths inside the moved trees were bumped accordingly: 59
`parents[N]` computations, 12 relative climbs, and the per-role recover CLI,
whose path `cli/administration/recover/recoverers.py` still assembled from the
pre-move location.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 01:31:09 +02:00
kevinveenbirkenbachandClaude Opus 5 6eff9d2ca1 test(web-app-shopware): prove the sweep is what clears the admin cookie
ca40eee68 made the admin test wait for bearerAuth to disappear before leaving
Keycloak's logout page. Nothing proves that wait is load-bearing. Delete it and
the suite stays green on clearnet, because there the sweep wins the race
anyway; it turns red only over Tor, where a red job reads as flake. The
property the wait protects is therefore untested exactly where it matters.

Racing it deliberately cannot fix that: a test that leaves early and asserts
the session survived would pass over Tor and fail on clearnet - the same
latency dependency, mirrored. 801923eb3 measured that asymmetry on Funkwhale:
"Only swarm+tor is slow enough to lose the race."

So this does not race. page.route aborts every request whose pathname is
/logout - each app host the conductor sweeps - and the test then asserts the
admin cookie SURVIVES. What is pinned is the causal link, not a timing: the
session outlives a logout exactly when the sweep does not land. That stays
true however fast the sweep becomes, and breaks only if logout stops depending
on it, which is a change worth failing on.

The pathname filter is deliberate. A glob on **/logout* would also swallow
Keycloak's own /realms/x/protocol/openid-connect/logout and break the flow
under test; url.pathname === "/logout" matches the sweep targets and neither
Keycloak's end-session endpoint nor the conductor page at /.

Two ways this test could pass while proving nothing, both closed. A parallel
page.on("request") counter separates them: no request to /logout at all means
the conductor never reached its sweep, the precondition is absent and the test
skips - making the sweep land is the sibling test's assertion. Requests seen
but nothing intercepted means the abort is a no-op, which fails loudly rather
than skipping forever if page.route ever stops matching.

Skipping rather than failing on the absent precondition also keeps the cost
bounded: playwright.config.js runs workers: 1 with retries: 2, so a failure
here would spend three full Tor login cycles proving something the sibling
already reports.

Interception was measured, not assumed: with three origins, a cross-origin
iframe and keepalive: true, the handler fires and the target server sees no
request. signIn/dismissFirstRunWizard/clickLogout are extracted so the second
test costs helpers rather than a copy of the first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 01:31:09 +02:00
kevinveenbirkenbachandClaude Opus 5 5b6d12f129 fix(web-app-shopware): let the logout sweep land before leaving the page
e59ea21c6 waited on document.cookie, which is origin-scoped. By the time
it runs the page sits on logout.<domain>, where Shopware's bearerAuth -
host-scoped to shop.<domain> - is not visible. The predicate was true on
the first poll, so the wait did nothing and three of the six jobs were
unchanged.

page.context().cookies() reads the browser context's jar regardless of
which document is displayed, so the same post-condition can be observed
from Keycloak's logout page.

The wait matters because the thing that clears that cookie is not
Shopware and not the injected patch - it is web-svc-logout. Keycloak
loads its conductor as the front-channel logout URL in an iframe; the
conductor fetches <host>/logout for every domain with
credentials: "include" (universal-logout templates/conductor.html.j2),
each /logout deletes the cookies visible on that host and sends
Clear-Site-Data (app.py), and roles/web-svc-logout/templates/logout-proxy.conf.j2
supplies the matching Access-Control-Allow-Origin plus
Allow-Credentials. Every link of that chain is in place.

What was missing was time. In run 32284541284 the trace shows the
logout-confirm click at 16543.805 and the navigation to /admin at
16902.227 - 358 ms, against 220-350 ms per Tor hop. The conductor's
document had not finished loading, so its sweep never ran:
grep -c -a "onion/logout" on the trace network log returns 0. On clearnet
the same chain fits inside those 358 ms, which is why variant 0 passes
there and fails over Tor.

A failure now reports the cookie that survived instead of the missing
password field two lines below, which said nothing about the cause.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 01:31:08 +02:00
kevinveenbirkenbachandClaude Opus 5 8bf29bd906 fix(web-app-shopware): wait for the logout to land, not for a load
4a543b3e1 moved the assertion back onto /admin so a surviving session
would fail where it means something. It does fail there - and the reason
is that nothing waits for the logout to finish before the page is torn
down.

waitForLoadState only observes a navigation that is already committed; it
never waits for one a click is about to start. Shopware's SPA logout
triggers no document load at all, so the call returned in 0.7ms and the
gotoOnion below fired 3.4ms after the click, killing the in-flight
teardown. Measured in run 32284541284 job 96178512465's trace: click at
10363.718, waitForLoadState returns 10420.992, goto starts 10422.365.
Proof the teardown never completed: POST /api/oauth/token with
grant_type=refresh_token returned 200 five minutes later, and
_action/user/logout appears zero times in the network log (positive
control _action: 6).

Waiting on the credential instead of on a load states the real
precondition, and it is faster on the happy path than any fixed settle
would be - it ends the moment the cookie goes. The catch keeps a timeout
from masking the assertion below it with a different error.

Six jobs across both runs, and the grid rules out the two obvious
culprits: variant 2 carries no injected logout script and still fails,
and variant 2 swarm+clearnet fails while variant 2 swarm+tor passes. It
is a race, not a tor defect and not an injector defect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 14:23:30 +02:00
kevinveenbirkenbachandClaude Opus 5 be13c44f63 fix(shopware): give the swarm gate room for the install to finish
Job 95883451154 failed the converge gate with stuck: shopware_scheduler exited 255 three times in 45s because 'Table shopware.scheduled_task does not exist' - init had not written the schema yet. Swarm accepts depends_on but ignores its ordering (plugins/lookup/depends_on.py), so scheduler and worker start alongside init instead of after it.

The gate ran 12:26:24 to 12:29:25, exactly 181s. At 180s churned_past_grace flipped, and with three attempts already burned has_fatal_task called it terminal - stack_ready.sh exit 2, and 'until rc in [0, 2]' stopped both callers. Nothing was broken; the install simply takes longer than the global grace.

b384f4825 introduced that grace and consolidated eleven role overrides into one SPOT, with the rule 'patience only grows where nothing is broken'. This override argues the other half of the same commit: its own lint docstring names the failure it was written against - 'the shorter one then killed a healthy-but-slow stack'. Shopware is that stack, and keeping the override role-local leaves detection at 180s for every other role.

Eight roles depend on a completed init (bookwyrm, mastodon, erpnext, openproject, funkwhale, gitlab, shopware, zammad) and share the exposure, but only Shopware is evidenced so far. Should a second one trip the same gate, the value belongs in group_vars rather than in a second role.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 16:41:10 +02:00
kevinveenbirkenbachandClaude Opus 5 4a543b3e15 fix(web-app-shopware): assert the login form on Shopware, not on the logout page
The native admin spec clicked in-app logout and then waited 60s for a password field on whatever page it had landed on. That page is never Shopware: with web-svc-logout attached, the flow ends on logout.<domain>, the universal-logout status page, which is terminal by design (README: "Front Channel Logout URL ... status page with live feedback").

Evidence from job 95883075100: the error-context snapshot shows heading "Logging out" plus paragraph "You are logging out from following apps" and a list - the web-svc-logout UI, not Keycloak's end-session page, which carries a Logout button instead (that one was already handled by a30d67a63). The openresty access log confirms the chain at 03:38:58: POST /realms/.../logout/logout-confirm 200, then GET logout.infinito.example/?sid=...&iss=... 200, 2555 bytes.

Returning to ${appBaseUrl}/admin before the expectation puts the claim where it can be true or false. This is the same move c7e5b0a27 made for web-app-funkwhale, and the shared persona flow has always done it via assertUnauthenticatedLanding (personas/utils/landing.js). The assertion itself is untouched, so the test gets stricter, not more lenient: a session that survived logout now fails here instead of at a spot that said nothing about it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 16:41:08 +02:00
kevinveenbirkenbachandClaude Opus 5 bc1901c5fc fix(web-app-shopware): fit the adminopenauth task name into the 80-char lint
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 23:28:16 +02:00
kevinveenbirkenbachandClaude Opus 5 acc9e25c0f refactor(volumes): one canonical mount surface, context-wired lookups
The mount pipeline was split across two layers: compose_volumes and
container_volumes lived as filter modules that ansible's filter loader
rejected on every start ("has no attribute 'FilterModule'", two warnings
per run), while the same-named lookups wrapped them thinly. The filter
bodies now live inside their lookups (the tor_socks precedent), the
filter files are gone, and the render tests moved next to the lookup
tests.

As lookups they see the templating context, so the application_id term
every call site passed back to its own role is gone too: both lookups
read application_id from context and fail loudly when it is missing.
107 templates and nfs_prep.yml drop the term; the calls collapse to
lookup('compose_volumes') and lookup('container_volumes', service_name).

The container_volumes call is now canonical - four leading spaces,
service_name in the service position, | indent(4) - enforced by a new
lint (test_container_volumes_call_shape). Normalizing the 85 literal
call sites surfaced that erpnext keyed its meta/volumes.yml mounts by
container name instead of compose service key, the only role to do so;
its meta now uses the service keys. wordpress's column-0 call, which
rendered its first line at top level, is indented like every other.

Template logic that only restated meta/volumes.yml is deleted: mailu fed
compose_volumes an extra_volumes dict whose eight names were looked up
from the very meta file compose_volumes reads itself; zammad's init and
railsserver extra mounts were the volumes.yml entries minus the two
services, which the meta now lists (the when-gated OIDC binds included).
Conditionality that meta cannot express stays in code, but in role-local
lookup plugins instead of template loops: matrix_bridge_mounts renders
the per-bridge registration mounts, nextcloud_extra_volumes the
feature-gated whiteboard/recording volumes (type: volume entries are
declared unconditionally by compose_volumes, so gating must happen
before it).

Parity is proven, not assumed: every mechanically rewritten template is
byte-identical to HEAD after the transformation regexes (95/95), and the
semantic roles render identically through the real functions against
HEAD's meta - zammad across six sso/tls polarities, all seven erpnext
services, mailu's top level. make test-lint passes complete.

Also: suitecrm's compose template pins container_extra_hosts with
host_alias (the SOCKS proxy URL host.docker.internal never resolved in
compose+tor), and the five disabled mautrix bridge addons use the
empty-default config lookup so templating the applications dict stops
emitting 125 errors per batch in every CI job.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 23:28:16 +02:00
kevinveenbirkenbachandClaude Opus 5 596b10c403 test(lint/csp): demand reason for unsafe-* grants
unsafe-inline re-admits the inline execution an XSS needs and unsafe-eval re-admits string-to-code, so a CSP relaxation must be verified against the running app rather than copied from another role. The new lint requires a nocheck marker plus a Reason on the flag's own line, so a marker cannot drift to the neighbouring directive.

The 197 pre-existing grants across 70 roles are checked out with a TODO: they were never verified and still have to be.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 23:28:14 +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 d8f980c9be style: clear the lint findings the full quality gate surfaced
Four files outside the healthcheck work, all flagged by make quality-high.

The shopware marker sat on the first line of a value the YAML formatter wraps, where the lint never looked; it now sits on the last line, beside the trailing comment the formatter keeps there. The two ci meta tests get ClassVar for a mutable class attribute and unpacking instead of list concatenation.

The keycloak realm-import test asked jinja2 for an Environment without naming autoescape. It stays False - the template renders JSON, and HTML escaping corrupts the values - and the rule is silenced the way 9a9c2f239 already silences it for the dotenv filter test.

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 dcb921c260 fix(web-app-shopware): address the OIDC provider by its service name
container_address resolves a container id for exec and emits a $(...) subshell in swarm; setup/oidc.yml passes the value through shlex.quote, so the substitution never ran and client.php wrote the literal text into token_endpoint and userinfo_endpoint. The deploy stayed green and every admin SSO login failed at the code-to-token exchange. container_service is the maintained lookup for the addressable form and yields keycloak in compose, keycloak_keycloak in swarm.

init.sh no longer waits on the S3 endpoint: c5a149b11 unmounted the config it guarded, while a hard fail there still took web, worker and scheduler with it. The admin-bundle check now runs unconditionally — it was skipped exactly when objstore was on, i.e. in the only variant with SSO, although the bundles are local either way. The two objstore env vars lost their last consumer with it.

no_log now sits on the two tasks that carry the admin password and the OIDC client secret; handing it through include_tasks apply: does not censor them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 18:02:30 +02:00
kevinveenbirkenbachandClaude Opus 5 15e73a84e6 fix(web-app-shopware): make the admin provisioning fail loudly
A command substitution inside an argument does not trip set -e, so a failing password generator would have left --password= and Shopware would have prompted for it interactively; on a non-TTY that kills init and with it every service gated on service_completed_successfully. The value is now generated in its own assignment.

privileges.php could not tell a missing user from an already-set flag, since both report zero affected rows. It now verifies the account exists, which is the only place a silently failed admin provisioning can surface: admin_user.sh swallows both of its commands with || true.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 18:02:29 +02:00
kevinveenbirkenbachandClaude Opus 5 29995093b0 revert(web-app-shopware): unmount the S3 filesystem config again
The mount restored in 574c8954f would have broken every asset URL. plugins/lookup/objstore.py:226 builds public_url as domain/bucket without the per-filesystem root, so public, theme, sitemap and asset all advertise the same URL while their objects live under different roots. shopware.filesystem.asset feeds the administration bundle's script URLs, so the admin would have 404'd on clearnet and over tor alike.

Re-mounting requires objstore to expose a root-aware URL and web-app-shopware to whitelist the S3 origin in meta/csp.yml.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 18:02:29 +02:00
kevinveenbirkenbachandClaude Opus 5 a30d67a63c fix(web-app-shopware): click through Keycloak's logout confirmation
Shopware's in-app logout leaves the browser on Keycloak's end-session page, which asks 'Do you want to log out?' because the request carries no id_token_hint. The spec waited 60s for a password field that page never renders. The shared persona helper already handles this for inAppLogout; it is now exported so the role-local spec can use it too.

Evidence: the error-context of job 95119330653 shows heading "Logging out" and button "Logout" where the login form was expected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 18:02:29 +02:00
kevinveenbirkenbachandClaude Opus 5 46686d72ad fix(web-app-shopware): keep OIDC admins in possession of their rights
AdminOpenAuth clears the admin flag and the ACL roles on every login that no role-assignment rule matched (UserResolver::getUserChangeSet). The Playwright login therefore demoted the provisioned administrator, the admin backend rendered empty, and the next provisioning run answered 403 FRAMEWORK__MISSING_PRIVILEGE_ERROR. The client now carries a rule with userBecomeAdmin, and a separate step re-asserts the flag for accounts an earlier login already demoted, which no console command can do.

discoveryDocumentUrl stays empty because a successful discovery assigns the document over the explicit endpoints and moves the back channel onto the .onion host cURL refuses to resolve.

system:install --basic-setup provisions admin/shopware with a published password; that account is locked on first install. shopware.yaml is mounted again after being rendered but unmounted since the mounts moved into meta/volumes.yml. files/ is split into php/ and shell/ trees.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 18:02:28 +02:00
kevinveenbirkenbachandClaude Opus 5 2580de62da fix(web-app-shopware): move the internal OIDC base into a variable
The previous commit glued a literal http:// onto a lookup inside the shell cmd, which test_literal_protocol_with_lookup rejects: the protocol must come from the tls lookup rather than be guessed. Here the exception is real -- this is a container-to-container hop that must bypass the canonical URL, because cURL refuses the .onion name the tls lookup returns -- so the value moves into SHOPWARE_OIDC_INTERNAL_BASE where the suppression sits on the offending line as an actual YAML comment.

The first attempt put the nocheck inside the cmd folded block, where # is not a comment: YAML folds it into the string, so the deploy would have run container exec with a dozen words of prose as arguments while the lint reported green. Reading the folded scalar back is what caught it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 18:02:25 +02:00
kevinveenbirkenbachandClaude Opus 5 47601d59d0 fix(web-app-shopware): split the OIDC front and back channel
The keycloak provider derives every endpoint from one auth-server-url, so both the browser redirect and the server-side calls pointed at the onion. cURL refuses .onion outright under RFC 7686 -- it never opens a socket -- so discovery failed, the authorization endpoint stayed empty and the login button redirected to itself.

The generic open_id_connect provider takes the endpoints individually. The authorization endpoint keeps the onion, which is the address the browser reaches through Tor; token and userinfo point at the internal service name, which cURL accepts. discoveryDocumentUrl still names the onion and therefore fails, which is what leaves the explicit endpoints in place -- discoverWellKnown overwrites them whenever it succeeds, and Keycloak advertises unusable values under every address it is asked through.

Verified in the running container before any redeploy: patching the live client row to this shape makes GET /admin/open-auth/<id>/remote answer 307 to the Keycloak authorization endpoint instead of to itself, and the browser now completes the Keycloak login -- the spec fails afterwards on the admin menu rather than on the login form. The payload was checked against a stub first and matches the live patch byte for byte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 18:02:25 +02:00
kevinveenbirkenbachandClaude Opus 5 0962037687 fix(web-app-shopware): drop the calls to a console command Shopware does not ship
bin/console user:update does not exist. The installed shopware/core ships exactly UserCreateCommand, UserChangePasswordCommand and UserListCommand. Both call sites hid the failure: one with || true, the other with || true and 2>/dev/null on top.

rename_admin_user.sh consisted of nothing else. It found the default admin user, printed Renaming: admin -> administrator and then did nothing, so the message was a false statement on every deploy. Script and task are removed. In ensure/admin_user.sh the user:update --email call goes too; user:create already passes --email and that works -- the administrator in the running stack carries administrator@infinito.example, read back through the admin API.

This is behaviour-identical: the removed calls never did anything. What it does change is visibility -- the Shopware default user admin with user@example.com survives every install, which is exactly what the rename was meant to prevent. Shopware offers no console command for that, so closing it needs the admin API and a decision about the account; that is deliberately not part of this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 18:02:23 +02:00
kevinveenbirkenbachandClaude Opus 5 cace98976b fix(web-app-shopware): let the OIDC client reach a plaintext identity provider
AdminOpenAuth ships heptacom.admin_open_auth.oidc.http_client with protocols: [https]. An onion carries no TLS -- Tor encrypts the circuit itself -- so guzzle aborted OIDC discovery inside CurlFactory before opening a socket. Discovery never ran, getAuthorizationEndpoint stayed empty, and getLoginUrl returned a bare query string that the browser resolved against the current URL: the admin login button redirected to itself until the test timed out on a blank page. No request ever reached the reverse proxy, and the only trace was a warning in the container-local var/log/dev.log.

A project-level config/packages template redefines that one service id. It grants http only when the resolved provider URL is itself plaintext, so a clearnet deployment keeps the https-only guarantee untouched; the condition keys on the actual scheme rather than on the tor flag, because the scheme is what guzzle rejects. Discovery, token, userinfo and jwks all flow through this single client, so one override suffices; base_http_client and saml.http_client are left alone. Deployment and mount mirror framework.yaml.

Four independent skeptics attacked the earlier claim that this was a hard third-party constraint and refuted it: the pin is an ordinary Symfony service id, Shopware loads project configuration after plugin bundles, and config/packages/http_discovery.yaml demonstrably compiles its services block into the running container. Both branches of the template condition were rendered through Ansible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 18:02:23 +02:00
kevinveenbirkenbachandClaude Opus 5 8d03cc71d3 fix(web-app-shopware): clear the cache after the OIDC client exists, not before
plugin:install carried --clearCache, so the cache was emptied while the AdminOpenAuth client row did not exist yet. Whatever the plugin had cached about having no configuration survived the only clear the deploy performed. The clear moves into its own task behind the provisioning step, which is the first moment there is anything worth caching.

This does NOT make variant 0 green, and the ordering was verified not to: after deploying it, GET /admin/open-auth/<id>/remote still answers 307 to itself and the reverse proxy still logs zero discovery requests. The blocker sits one layer down -- the plugin pins its Guzzle clients to protocols: [https] in its own DI definition while an onion service runs without TLS, so the discovery request is rejected before it reaches the network. Shopware records that as a warning in var/log/dev.log: the scheme http is not allowed by the protocols request option.

The ordering is still correct on its own terms: once that blocker is resolved, clearing before the write would be a no-op again and the failure would return as a follow-up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 18:02:22 +02:00
kevinveenbirkenbachandClaude Opus 5 d5b6e90a8c feat(web-app-shopware): authenticate the admin against Keycloak via AdminOpenAuth
meta/services.yml pinned sso.flavor: oauth2, so an oauth2-proxy fronted the whole role. That gated the storefront too and left /admin behind a proxy the administrator persona could not log out of, while tasks/setup/oidc.yml -- the only OIDC wiring -- installed a literal placeholder plugin name behind an || true and wrote InfxOidc.config.* keys that no plugin reads. tasks/03_setup.yml and tasks/04_cleanup.yml were included by nothing, so none of it ever ran.

The flavor moves to oidc, which drops the proxy on its own because sso_proxy.yml keys off is_proxy_gated: the storefront becomes public and /admin authenticates through heptacom/shopware-platform-admin-open-auth against Keycloak. The plugin is baked into files/Dockerfile beside the existing composer require, installed and activated by tasks/setup/oidc.yml, and its Keycloak client row is written idempotently by files/ensure/oidc_client.php over the Admin API -- GET by provider, then PATCH or POST -- because the plugin keeps its configuration in the heptacom_admin_open_auth_client DAL entity, not in system_config.

The web service gains container_extra_hosts. With the proxy gone the token exchange runs inside the shopware container, which under swarm resolves clearnet only and could not reach the keycloak onion.

The LDAP plugin tasks are deleted rather than wired. No admin-LDAP extension for 6.7 is installable unattended -- the only real one is a paid Store extension needing a licence token -- and arming tasks/03_setup.yml would have fired the INFX_LDAP_PLUGIN placeholder on every deploy that ships OpenLDAP, since meta/services.yml derives ldap.enabled from group membership. Directory identities reach the backend through the Keycloak LDAP federation instead.

PERSONA_BIBER_BLOCKED becomes unconditional: biber has an account in no variant. test-admin-native.js keeps the authenticated journey and now drives the AdminOpenAuth provider link when SSO is on.

Verified by execution: the provisioning payload and both idempotency branches against a stub; composer resolving 9.1.0 against shopware/core 6.7.13.0 without conflict; the install gate rejecting a no-plugins-found run and accepting both success shapes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 03:38:47 +02:00
kevinveenbirkenbachandClaude Opus 5 a46348b6cf refactor(handlers): bind notify to a listen topic, not to a name
A handler name was doing two jobs at once: the line an operator reads in the
play output, and the identifier a `notify:` binds to. The two pull in opposite
directions -- the log wants "restart the SSH service, with diagnostics", the
binding wants a token that survives quoting, YAML flow style and a shell
round-trip -- and a name that changes for readability silently changes which
handler runs.

Every handler that a notify reached by name now carries a `listen:` with that
exact wording, so the binding is explicit before anything is renamed. The
topics themselves become identifiers: `compose up` reads `compose-up`,
`Restart NFS ganesha` reads `restart-nfs-ganesha`, 22 of them across both
sides of every notify. Where the topic is assembled at runtime, both branches
moved together: the conditional in sys-service carries two literals, and the
LDAP renderer interpolates `import-{{ folder }}-ldif-files`, which is what
makes those handlers reachable at all.

Freed from the binding, the names take the emoji prefix every other task line
already has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 03:38:42 +02:00
kevinveenbirkenbachandClaude Opus 5 e16eb5728d test(lint): drop the minimal variant's exemption from the onion
72fd9c870 required the onion in every variant but exempted variant 1, on the
argument that the minimal round is the one place the stack runs without it. That
argument does not hold: the onion is part of the deployment under test, not a
feature bolted onto the full shape. A round that skips it is not a smaller
deployment, it is a different one.

So the exemption is gone and 95 variant-1 entries pin the onion on. Two
exclusions remain, both read from data rather than a comment: a role with no tor
block has no onion to pin, and a role whose meta/services.yml pins tor.enabled to
a literal false has already declared it has none.

That put the rule in conflict with test_coverage, which requires both polarities
of every dynamic flag: with tor on everywhere, the false side has no variant left
to live in. 94 roles take the exemption that contract already provides for
exactly this - "flags whose group-derived value is a hard invariant that variants
must never override", the same one 4e6ef2542 used for the seaweedfs frontend.
web-app-nextcloud needs none: its variant 8 pairs the clearnet-only PeerTube and
keeps the false polarity.

Measured, because the declaration is not what CI runs. assign_tor gives the onion
to every second capable entry with the run number as the parity switch, so more
capable variants do not mean proportionally more onion jobs: across five roles
and 21 entries the count goes 8 -> 10 on an even run and 9 on an odd one. The
clearnet path also survives - checkmk reports v1=false on run 0 and v1=true on
run 1, so both states are still covered across two consecutive runs. What the
94 markers give up is a declarative guarantee in variants.yml, not the coverage
itself.

The real cost is weight, not job count. web-app-nextcloud variant 1 goes from
3.09GB reservation / 7.27GB limit to 18.26GB / 43.01GB once svc-net-tor and its
closure join, so the cheap round is no longer cheap. Every variant stays under
the 32/64GB budget, and the same effect applies to the other 94 unmeasured.

Verified: tests/lint 336 passed / 307 subtests, tests/integration/roles/meta/
variants plus the role-meta lints 39 passed. The rule was checked in both
directions - it reported the variant-1 offenders before the flips and none after.

Not measured: what the heavier rounds do to wall-clock, against swarm jobs capped
at 355 minutes and one that has hit that ceiling before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 06:09:02 +02:00
kevinveenbirkenbachandClaude Opus 5 ab72cff929 test(lint): require the onion in every variant, not just SSO ones
7a70d27b7 made a variant that switches SSO on also switch the onion on, because
that pairing is where the platform breaks in ways clearnet never shows. The same
argument reaches further than SSO: a Secure cookie is dropped, an https identity
provider refuses to embed, and crypto.subtle is undefined for any feature on a
non-secure origin, not only for a login. A variant that never runs the onion is
not testing the deployment users get.

So the rule now covers every variant of a role that declares a tor block.
Variant 1 stays exempt - it is the minimal, partner-free round every role
declares and the one place the stack is exercised without the onion at all - and
a role whose meta/services.yml pins tor.enabled to a literal false is skipped
outright, because that declaration already says the role has no onion and needs
no comment repeating it.

That found 137 variants. 101 were variant 1. Of the rest, 28 are the LDAP-only
auth-isolation rounds, which gain real coverage from running LDAP over the onion
and are flipped on here. Four cannot: svc-net-tor is the provider itself, and
nextcloud's variant 8 pairs the clearnet-only PeerTube. Both carry a marker
naming the reason; opentalk and coturn need none, since their services.yml pins
the literal false the rule now reads.

Flipping them exposed a contract that contradicted itself. test_auth_isolation
and test_non_baseline_explicit_disables demanded `shared: false` beside
`enabled: false`, while variants-static-override flagged that same line as a
static override - which is why all 133 of them carried a nocheck marker. The
demand only makes sense where `shared` is dynamic: a literal base value cannot
leak through deep-merge, so restating it protects nothing. Both contracts now
ask for it only in that case, and the 133 lines are gone.

The result is a stricter rule with fewer lines: 189 insertions against 193
deletions.

Verified: tests/lint 336 passed / 307 subtests, tests/integration/roles 107
passed / 610 subtests. The rule is checked in both directions - it reported 137
offenders before the resolution and none after.

Not measured: 28 additional onion rounds cost matrix wall-clock, against swarm
jobs already capped at 355 minutes and one that has hit that ceiling before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 06:09:02 +02:00
kevinveenbirkenbachandClaude Opus 5 b2a0a317fa refactor(tor): loosen the onion bond to 0.5
Every variants.yml header states the rule the bond feeds: variant 0 carries each
tightly-coupled (bond>=1, same-host) service, while loosely-coupled (bond<1,
separate-host) partners are excluded from the closure and distributed across
later variants to keep every host under budget. tor was declared bond: 1 in all
101 consumers, so it had to sit in every variant-0 closure.

It does not belong there. The onion is a separate-host provider reached over the
node's hidden service, the same shape as the bridged partners that already carry
0.5, and f1dff50fb established both the reasoning and the precedent when it
dropped xwiki and bigbluebutton from 1 to 0.5: bridged partners with their own
role and their own spec, whose bond declaration was the outlier rather than
their placement.

This also unblocks the variant-level tor contract. Requiring a non-baseline
variant to pin the onion on while tor still counted as tightly coupled would
have pulled variant 0's closure requirements along with it.

Verified: tests/lint 336 passed / 307 subtests and tests/integration/roles 107
passed / 610 subtests, including test_resource_budget over every variant's
deduplicated footprint and the bond-aware test_order closure.

Not measured, and the honest cost: f1dff50fb's own caveat applies unchanged
here. Whether redistributing a service out of the baseline closure buys back
wall-clock is not established; role-runtimes has the per-role seconds and no run
has been made with this shape.

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