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>
Ansible warns that the name is already in use and carries on. Carrying on is the problem: inside the loop the original value is gone, so every lookup, template and nested include reading that name sees the loop item instead, and the task still reports success. The damage lands in a log nobody greps.
The lint collects every key declared in group_vars and in each role's vars or defaults, then reports any loop_var matching one. A denylist of known-bad names would have found application_id and missed domain, which is the same class in four more places.
All seven existing sites are marked, each with the reason read at the source rather than inferred. The three application_id loops pass the role id as the item, and the include_vars that follows loads that role's own application_id with the same value, so both meanings coincide by construction. The four domain loops hand the vhost to sys-stk-front-proxy, whose tasks read front_proxy_domain from exactly that name, so the shadowing is the interface.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
At -vvv Ansible prints each module argument and result, so a password in
a command line, a request body, a header or a fact lands in the deploy
log. Nothing enforced masking outside the MCP task files, and no check
looked at parents at all.
A new lint, test_credential_tasks_are_masked, finds tasks whose printed
parts (module arguments, environment, loop) reference an identifier ending
in password, secret, token, api_key or private_key, or
secrets.credentials. A task passes when it or an enclosing block sets
no_log, when every file that pulls it in does so through include_tasks
with apply.no_log or import_tasks with no_log (transitively), when the
secret only reaches a parameter the module masks itself, or when the
module masks its values. no_log on an include_tasks task itself does not
count, because it does not reach the included tasks. The rule can be
suppressed per task with `# nocheck: credential-masking`, now listed in
the suppression catalog, and the lint tests its parent and suppression
logic on synthetic cases.
It found 39 unmasked tasks in 19 roles, all real: join tokens, client
secrets, API tokens in Cloudflare headers, database passwords in
command lines, the ChatGPT bot login body and more. Each now sets
no_log from MASK_CREDENTIALS_IN_LOGS. The sys-dns-hetzner-rdns alias
hetzner_no_log, which only restated that switch, is gone.
Not covered yet: template and copy tasks whose rendered file carries a
secret print it as a diff under --diff.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
container exec is node-local: sys-svc-container/files/python/container.py dispatches docker exec with no swarm routing. These roles passed *_CONTAINER_ADDRESS, which names the service cluster-wide, and ran from the stack host, so a call landed on the manager and answered "No such container" whenever the task sat on a worker.
Each branch now resolves the hosting node and its node-local id once through sys-svc-compose/tasks/utils/swarm/resolve_host_cid.yml, the way web-app-baserow already does, and every exec uses *_LOCAL_CID with delegate_to on *_HOST_NODE. Compose is untouched: resolve_host_cid sets *_HOST_NODE to inventory_hostname there, so the delegation is to self and resolve_compose_cid passes the old address through.
penpot resolves only when native login is enabled, because the resolver waits up to 600x2s for convergence and both of its exec tasks are already gated on that flag.
The *_LOCAL_CID names come from a runtime set_fact, which the resolver lint cannot approve, so each scanned call site carries the nocheck the migrated mastodon sites already use. Measured with the lint parser itself: 19 exec lines carry a target, 19 would be flagged, 19 are suppressed. No nocheck reaches a rendered command or a task name, and the one comment that does land inside a bash body passes bash -n.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Joomla keeps its DB credential in configuration.php on its own volume. CI rotates
credentials between the two passes, so in pass 2 the file holds a password MariaDB
no longer accepts. Job 99670614260 of run 33445831564:
mariadb_mariadb.log 1058 x Access denied for user 'joomla'@'192.168.201.7' (using password: YES)
negative control on the same file: 'wordpress' -> 0
joomla_joomla.1.*.log "resuming normal operations" 02:36:53, then the access log's
first line is "GET / HTTP/1.1" 500 216982, every ~5s
The role's own repair cannot reach that state. tasks/main.yml:21-26 flushes handlers
before including 02_manager_ops.yml, which is the only path to 05_patch.yml: the
converge gate wants a working configuration.php, and the repair wants the gate to
clear.
sys-svc-rdbms already runs a hook on the correct side of that gate, and it already
runs for joomla in both passes -- it skipped for exactly one reason (log line 103788):
false_condition: _app_persisted_credentials is exists
main.yml:29-33 opts a role in by the existence of
roles/<app>/tasks/utils/patch/db_credentials.yml. friendica, kix, matomo, moodle and
nextcloud have one. This adds the sixth.
The expression was checked against the real line read out of the running volume, not
against a sample -- `public $password = '...';` with a leading tab. sed -E with it
returns that one line and leaves dbprefix, secret and smtppass untouched; smtppass in
particular would have been caught without the `public $` anchor. The shared script
cannot silently no-op either: patch_persisted_password.sh:47 prints
"FAILED: no line in configuration.php matches the patch expression" and exits 1.
Verified by deploy, pass 2:
TASK [sys-svc-rdbms : 🔑 Realign the persisted database password wherever joomla_data lives]
changed: true
stdout: PATCHED
An earlier attempt moved the reconcile into the container entrypoint instead. It also
went green, and it was still the wrong mechanism: run against the image, that route
wipes the whole DB block when the credential resolves empty (database.py:104 returns
"password": "" for the no-database branch as a legitimate result) while php -l calls
the file clean; a password containing $0 or a trailing backslash writes a parse error
and still reports "changed" with rc 0; and a lost env var makes exec /entrypoint.sh
unreachable, so the container never starts again. Six files became one, and the path
now runs once per deploy instead of on every boot of every replica.
${PATCH_PASSWORD:?} in the shared script fires on empty as well as unset.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two installers write the same database and neither reconciles the admin
credential, so `/administrator` answered every login with "Username and
password do not match" — six POSTs across two orchestrator passes, all 303, in
the only CI row that exercises the local-login hatch. The green sibling row
proves nothing: its JUnit report lists that test as skipped.
The image entrypoint auto-installs Joomla on first boot. The role waited for
`plugins/actionlog/joomla/joomla.xml`, a file copied early in that boot, then
removed configuration.php and ran its own CLI install against the same
database. Whichever finished last owned the admin row, and the run's artifacts
show the entrypoint finishing one second after the play ended.
So the role now waits for the entrypoint to be done before deciding anything.
The marker it already writes, `.infinito-init.ready`, is that signal; the
`|| test -e configuration.php` arm covers re-deploys, where the entrypoint's
setup block never runs and the marker is therefore never touched.
With the state settled, the password reset no longer needs a gate. It was
skipped on fresh installs on the assumption that the CLI installer had set the
password — an assumption this run falsifies, since that installer exits 1 and
is swallowed as a soft success. The race it was written to avoid ("The user
does not exist!" right after install) is absorbed inside the reset itself,
which retries six times at five-second intervals and whose header documents
exactly that symptom.
The marker's name and path now live only in vars/main.yml and reach the
container through the env file. The entrypoint requires it without a default,
matching svc-runner and svc-db-openldap: a fallback would be the second
definition this change exists to remove.
Verified: the guarded expansion executed in both env states (127 with a legible
message, 0 with the path), the retry-exhaustion path executed against localhost
to confirm `failed_when: false` lets the play continue, plus shellcheck,
ansible-lint on the production profile, and 201 Jinja/ansible lint tests.
Not verified: no deploy was run. Whether the barrier holds on a real fresh
install is unmeasured.
Still open, both separate defects: 03_install.yml swallows the installer's
rc 1 as success, and test-oidc-login.js passes on the plugin's catch path
because it only asserts the URL prefix and a visible body.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>
Add tests/lint/repository/test_task_file_numbering.py: numbered task
files use exactly two leading digits, a numbered *_core.yml must be
00_core.yml, per directory the numbers are unique/consecutive and start
at 01 (00 with a core file), numbered includes appear in ascending
order, and shared helpers live in tasks/utils/ instead of the numbered
chain.
Apply it across every role: rename NN_core.yml to 00_core.yml with a
consecutive down-shift of the siblings, move letter-suffix children into
a subdirectory named after their parent task, relocate helpers included
from more than one place into tasks/utils/, and update every reference
(include_tasks, path_absolute, .ansible-lint-ignore, requirement docs).
Legacy narration comments in the touched files are deleted; the upstream
xwiki.properties reference block opts out via nocheck. The unused
MAILU_IP6_PUBLIC var (only ever named in now-removed comments) is dropped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The stock image copies /usr/src/joomla into /var/www/html and runs the
auto-installer on every boot. With more than one replica sharing the
NFS volume they corrupt the tree and race the install. Add a custom
entrypoint that takes an atomic mkdir lock on the shared volume so one
leader runs the setup while the others block on a ready marker, wired
through the same host-path build args as the odoo entrypoint. Verified
locally: 2 concurrent replicas on a shared volume copy and install
exactly once and both serve HTTP 200. Sessions are already stored in
the database and caching is off, so the replicas stay stateless.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
container cp needs its source on the node running the docker CLI, so
role_path sources break on swarm workers. The joomla plugin dir now
ships as a controller-side tarball through container exec -i. A new
repository lint forbids docker/container cp in tracked .yml and .sh
files; the six legitimate same-node uses carry justified nochecks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three folded 'when:/failed_when: >- {{ ... }}' conditionals (mediawiki, joomla, sys-front-tls-selfsigned) triggered the ansible-core 'Conditionals should not be surrounded by templating delimiters' deprecation, which fails the deploy deprecation gate. Drop the delimiters (a folded conditional is already a bare Jinja expression).
Extend two lints so this cannot regress and does not misfire: test_no_templated_conditionals now catches folded/block scalars (was inline-only); test_vars_usage_in_yaml now scans failed_when/until/changed_when for var usage (was when/loop/with_* only), so bare-expression conditionals no longer make their vars look unused.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The test-deploy-compose "Fail on deprecation warnings" gate failed ~10 apps
while the deploys themselves succeeded. Sources cleared:
- svc-db-postgres: community.postgresql `db` -> `login_db` (the `db` param
alias is deprecated, removed in collection 5.0.0)
- assert_no_deprecation_warnings.sh: ignore ansible-core's legacy filter/test
PluginLoader-aliases deprecation (needs a collection migration with
meta/runtime.yml, not fixable in role YAML)
- joomla/wordpress: drop the `{{ }}` delimiters from task conditionals
(when/until/changed_when), per ansible-core 2.23
Also drop `upstream`/`compat`/`security`/`bug` from the comment-policy markers
and delete those comments from the joomla install tasks.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The plugin copy used the bare `joomla` container name, which does not exist on
swarm (services are <stack>_<svc>.<slot>.<id>). Use JOOMLA_CONTAINER_ADDRESS via
shell so the swarm resolve-container-id subshell expands, matching the role's
other container steps.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add tests/lint/repository/test_name_emoji_and_length.py: every Ansible task
name and GitHub Actions workflow/job/step name must start with an emoji (first
char not 0-9/A-Za-z) and be <= 80 chars.
- emit emoji names at the source: cli/build/include generator now writes
"🔧 setup <app>" / "🚿 flush handlers after <app>" so generated
tasks/groups/*-roles.yml satisfy the convention too
- convert existing task/step/job/workflow names to emoji-prefixed, choosing a
task-fitting icon per action (📦 install, ✅ ensure, 🔍 probe, 🥾 bootstrap,
🚀 deploy, 🧩 service-module load, ⬇️ checkout, 📂 include-routines, ...)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolve 69 conflicts across ~70 files: union main's image-version bumps and new service fields (bond, resource limits) with the swarm branch's default_placement / NFS / IS_STACK_HOST config; retire the three per-deploy-type test workflows main still edited (consolidated into compose+swarm); adopt main's run-once 01_core dispatchers while re-applying each role's swarm guard. Fix the post-merge lint surfaced by main's stricter rules: bond + resource-budget keys, container-exec stack-host gating + resolver routing, leaky/dead proxy vars, suitecrm once-flag, xwiki variant nocheck, nextcloud oversized-block to gated include, matrix-mdad capped at 3g. make autoformat clean, make test green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ansible 2.19 parsed the task-level 'stdin:' written as a sibling of the free-form 'shell: >' as a second action module and aborted the play with '[ERROR]: conflicting action statements: shell, stdin'. Move stdin under 'args:' in 03_debug, 04_patch and 07_oidc_plugin so it is a shell module parameter, matching the canonical form already used by keycloak/mailu/friendica/decidim. This is a mode-independent parse error (failed compose and swarm).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
In swarm mode *_CONTAINER_ADDRESS resolves to "$(/usr/bin/resolve-container-id
<stack> <service>)", which must reach /bin/sh unquoted so the subshell runs.
- listmonk: drop the `| quote` filter; shlex.quote single-quoted the whole
"$(...)", suppressing the substitution, so docker received the literal token
("No such container: $(...)").
- joomla: rewrite the docker/container exec tasks from the shell argv-list form
(executed directly, never via /bin/sh, so the $() was never evaluated) to the
free-form string shell, and switch the raw `docker exec` calls to the
`container` wrapper (the argv form had hidden them from the no-raw-docker lint).
- joomla: move the three inline PHP scripts (configuration.php debug toggle, DB
settings patch, OIDC plugin ZIP packaging) out of inline `vars:` into
files/php/*.php, fed via `lookup('file', ...)` over stdin to
`container exec -i ... php --`, with their inputs passed as J_* env vars
(the files/php + stdin convention already used by friendica/espocrm/snipe-it).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A role's vars/main.yml is auto-loaded into play scope and persists, so proxy
override vars (webserver_*) leaked into the central front-proxy render of OTHER
roles (re-evaluated against the wrong application_id) — silently applying one
role's value elsewhere, or hard-failing when the lazy lookup hit a role lacking
the key (the seaweedfs<->matrix client_max_body_size crash).
- Templates (sys-svc-proxy html/upload/ws_generic, matrix synapse homeserver)
resolve client_max_body_size from the role's own server config
(lookup('config', application_id, 'server.client_max_body_size', '100m')).
- Move every role-level webserver_* out of vars/main.yml into task-scoped
include params (9 roles); they apply only to that role's proxy and can't leak.
- sys-stk-front-proxy resets webserver_* to safe defaults via task-scoped vars
on the render tasks, so a leaked value from any other source is neutralized;
a role's own include params (#20) still override the reset (precedence-tested,
direct + nested).
- Add test_no_leaky_proxy_role_vars lint forbidding webserver_* in vars/main.yml
(suppressable via '# nocheck: leaky-proxy-var').
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds tasks/addons hooks for the SSO/LDAP-coupled addons of the remaining web-app roles (exception-only; SPOT comments where the generic config + shared OIDC SPOT fully wire it). web-app-friendica gains the per-addon hook include in its 04_addons loader.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a mode-aware `container_address` lookup plugin and a
`resolve-container-id` helper script that resolve swarm task IDs at
exec time (compose mode returns bare names unchanged). Migrates ~25
roles + ~28 task files from `command:` to `shell:` so the lookup's
`$(...)` subshell evaluates correctly, and extends `database` lookup
with an `address` key for cross-role DB exec.
Three new lint tests enforce the contract: every container exec
target goes through the resolver, lives behind an IS_STACK_HOST gate
(walks the include graph one hop deep), and uses `shell:` rather than
`command:` when the target is a Jinja reference. Existing nocheck
suppressions on bare-name DB exec sites (nextcloud redis, listmonk,
moodle OIDC, mailu) are replaced with the lookup-based form.
Fixes the systemd Environment= injection in sys-ctl-cln-disc-space
(it never expanded $(...)) by passing STACK / SERVICE_KEY parts and
resolving inside the script. Extracts shopware admin and mediawiki
extension install bodies into dedicated .sh files invoked via env.
Rewrites password-bearing shell tasks to either `| quote` directly or
the `-e VAR=... | quote` + `sh -c '... ${VAR} ...'` env-bridge pattern.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds MODE_ASSERT_LOCAL to group_vars/all/01_modes.yml:
MODE_ASSERT_LOCAL: "{{ (MODE_ASSERT | bool) and (IS_STACK_HOST | bool) }}"
The mailu preload-assertion in sys-svc-mail/tasks/01_core.yml previously
fired on every host in `'web-app-mailu' in group_names` with no
MODE_ASSERT gate at all, so swarm workers ran into the assertion every
single CI run even though the preload state lives only on the stack
host. Same shape applies to a dozen other assertions: sys-svc-rdbms
(already double-gated, now collapsed), keycloak's kcadm-driven LDAP /
RBAC / reCAPTCHA / groups-path asserts, the sys-svc-compose handler
validators for compose/swarm config, svc-prx-openresty's 02_assert,
sys-svc-webserver-core's 05_assert, web-app-matomo's bootstrap token
check, web-app-joomla's 05_assert, and the sys-ctl-alm-compose notifier
self-start.
Each asserted state (run_once_<role> flags, kcadm output, openresty
container probes, compose-tree files) lives only on IS_STACK_HOST, so
the new SPOT keeps the assertion semantics intact while skipping the
spurious worker-side failures.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Implements docs/requirements/021-sso-flavor-migration.md atomically:
* Schema: every role's meta/services.yml replaces the parallel
`oauth2:` / `oidc:` top-level blocks with a single `sso:` block
carrying `enabled`, `shared`, and a `flavor` discriminator
(`oidc` | `oauth2` | `saml`; default `oidc`). Flavor-specific
sub-keys live under `sso.<flavor>.*` (`sso.oauth2.{origin,acl,
allowed_groups}`, `sso.oidc.plugin`).
* Provider role: roles/web-app-oauth2-proxy/ is deleted in its
entirety; its 5 sidecar templates move under
roles/web-app-keycloak/templates/sso_proxy/, and the per-consumer
rendering task lives at roles/web-app-keycloak/tasks/sso_proxy.yml.
web-app-keycloak now declares `provides: sso`.
* Runtime: new resolver utils/roles/applications/services/sso.py
centralises the SSO predicates (`is_enabled`, `is_proxy_gated`,
`is_oidc_native`) and the oauth2 sub-values
(`oauth2_origin_{host,port}`, `oauth2_acl`, `oauth2_allowed_groups`).
Three lookup plugins consume the resolver: `sso` (single-app
predicate access), `sso_proxy_consumers` (enumerates oauth2-flavored
consumers), `sso_oidc_plugin` (renamed from `oidc_flavor`, Nextcloud
plugin selector). sys-stk-backend, sys-svc-compose, sys-svc-proxy
and plugins/filter/compose_volumes.py all gate on
`lookup('sso', ..., 'is_proxy_gated')` instead of manually combining
`enabled` and `flavor`.
* Identifier renames repo-wide: `services.<entity>.ports.local.oauth2`
→ `ports.local.sso`; `PORT_BANDS.local.oauth2` → `PORT_BANDS.local.sso`;
`OAUTH2_PROXY_*` env/Jinja vars → `SSO_PROXY_*`;
`oauth2_proxy_cookie_secret` → `sso_proxy_cookie_secret`;
`OAUTH2_SERVICE_ENABLED` + `OIDC_SERVICE_ENABLED` collapse into
`SSO_SERVICE_ENABLED`; Playwright gating helpers
(isServiceEnabled/skipUnlessServiceEnabled/requireService/safeIsEnabled/
safeSkipUnlessEnabled) move from `oidc`/`oauth2` to `sso`.
* Dual-block resolution: per req's Background-Table, web-app-bookwyrm
is `flavor: oauth2`; web-app-gitea and web-app-friendica keep
`flavor: oauth2` with their existing `oauth2.acl.blacklist`
(/user/login) and `oauth2.acl.whitelist` (federation + discovery
endpoints) — preserving the operative oauth2-proxy gating.
* Tests: the two mutual-exclusion guards
(tests/integration/iam/oauth2_oidc/test_mutual_exclusive.py and
test_acl_mutual_exclusive.py) are deleted per Decision #10;
test_oauth2_contract.py is renamed and rewritten to
test_sso_contract.py, asserting that `flavor: oauth2` roles carry
`oauth2.origin.{host,port}` plus `ports.local.sso`; test_sso.py
(web-app integration) now requires the unified `sso:` block with an
explicit flavor or a `# nocheck: sso` opt-out; new
tests/lint/repository/no_legacy_sso_paths/ guards the source tree
against re-introducing the legacy strings.
* Docs: req 21 itself has every AC checked off, and reqs 006, 008,
009, 013, 015, 016, 019, 020 plus 10+ role READMEs and
docs/contributing/actions/testing/suppression.md are updated to the
unified SSO naming.
Empirically validated end-to-end against the validation app set
(web-app-keycloak, web-app-nextcloud, web-app-bookwyrm, web-app-gitea,
web-app-friendica, web-app-dashboard) in matrix variants 0 and 1; v2
also passes the 4 target roles' Playwright after a documented
pre-existing KC_HOSTNAME-drift workaround (separate concern, not
caused by this refactor).
BREAKING CHANGE: every consumer of `services.oauth2.*`,
`services.oidc.*`, `OAUTH2_PROXY_*`, `OAUTH2_SERVICE_ENABLED`,
`OIDC_SERVICE_ENABLED`, `oauth2_proxy_cookie_secret`,
`oauth2_proxy_application_id`, `ports.local.oauth2`, the
`web-app-oauth2-proxy` role, or the legacy gating helper service names
(`oidc`/`oauth2`) must migrate to the unified `sso` shape documented
in docs/requirements/021-sso-flavor-migration.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pure 1:1 rename (69 insertions / 69 deletions across 66 files) to
finish the ``docker_*`` → ``compose_*`` alignment started in
8373468e9. The flag configures whether the compose include flushes
handlers right after run; the ``docker_`` prefix was misleading
(handlers are Ansible-side, not docker-side) and the variable is part
of the same ``sys-svc-compose`` SPOT cluster as ``compose_repository_*``.
Touches the role default, the implementation
(``sys-svc-compose/tasks/utils/up.yml``), all 60+ consumer roles,
the network-utility wrapper + its README, and the
``test_network_create_via_util.py`` lint (both the docstring guidance
and the asserted diff).
Breaking change for any external override that still sets the old
name — no alias is provided.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds tests/lint/ansible/test_no_redundant_default_on_module_io.py which
flags `<reg>.{stdout,stderr,rc} | default(...)` inside `changed_when:`,
`failed_when:`, or `until:` lines. Those conditionals only fire after
the module has actually executed, so the fallback can never apply --
keeping it just hides bugs and noise.
Cleans up the 21 existing offenders across 14 role files. The taiga
upsert-admin task additionally gains a `not ASYNC_ENABLED and ...`
short-circuit so its async-poll-0 path no longer leans on a silent
empty fallback for stdout/stderr; with the short-circuit, the
`default('')` is genuinely dead code.
Cross-task references (a later task reading a registered result of an
earlier task that may have been skipped via `when:`) stay out of scope
and remain a legitimate use of `| default(...)`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Strip every `req <NNN>`, `req-<NNN>`, `Requirement <NNN>`,
`requirement-<NNN>`, and `docs/requirements/<NNN>-...md` mention from
non-Markdown source files. Comments anchored to requirement numbers
rot when requirements are renumbered or rewritten and conflate
present-state behaviour (which the comment should describe) with the
audit trail of how the code got there (which belongs in commit
messages, PR descriptions, and the requirement file itself).
* Document the rule in `docs/contributing/documentation.md` under
"Comments". Code MUST describe the *why* in present-state language;
contract enforcement lives in tests, not folklore.
* Clean 254 source files across `roles/`, `tests/`, `plugins/`,
`cli/`, `scripts/`, `utils/`, `group_vars/`, and the repo-root
`eslint.config.js`. Pure comment / docstring / log-message edits;
no behavioural change.
* Add `tests/lint/repository/test_no_req_references_in_code.py` to
enforce the rule going forward across `.js`, `.py`, `.yml`,
`.yaml`, `.j2`, and `.sh` files. The legacy
`test_no_stale_requirement_refs.py` is exempt as the only file
whose purpose is to detect these very tokens.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* scripts/lint/autoformat.sh:
- rename run_sort_claude_settings worker to run_claude (matches the
renamed scripts/lint/claude.sh).
- drop the EXIT trap that wiped status_dir; cleanup happens explicitly
after `wait` returns, so a parallel worker that interrupts the
parent never races a vanished status dir.
- each worker now captures its tool's exit code and writes one of
OK / SKIP / FAIL. Tolerated `expected violations remain`-style exits
(markdownlint-cli2 rc≤1, ansible-lint rc≤2) still count as OK; only
real failures count as FAIL.
- all workers run to completion regardless of individual tool failures
(so the user always sees the full picture); after the summary, the
script exits 1 if any worker reported FAIL.
* scripts/lint/sort_claude_settings.sh -> scripts/lint/claude.sh
(history preserved via git mv); update test_claude_settings_sorted.py
fix-hint accordingly.
Plus working-tree drift across roles/, group_vars/, inventories/,
requirements/, and .pre-commit-config.yaml accumulated from the
make-test setup phase regenerating generated artefacts during recent
test runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* scripts/lint/autoformat.sh: rename AUTOFORMAT_PARALLEL to PARALLEL,
declare it at file start with default `true`; still env-overridable.
Reword the worker comment so the literal `# shellcheck` token no
longer appears at start-of-line and trips SC1072/SC1073.
* scripts/lint/galaxy.sh: drop the GALAXY_FATAL / GALAXY_NAMESPACE /
GALAXY_PARALLEL env vars (over-engineered for a single repo). Failure
is always fatal. NAMESPACE is an internal constant. Add the same
PARALLEL env var (default `true`, env-overridable); falsy demotes the
xargs fan-out to one worker.
* docs/contributing/environment/variables.md: collapse the four
AUTOFORMAT_*/GALAXY_* rows into one `PARALLEL` row covering both
consumers.
* docs/contributing/tools/make.md: refer to `PARALLEL=0` everywhere;
add the lint-galaxy parallelism note.
Plus working-tree drift across roles/, group_vars/, inventories/, and
tasks/ that accumulated from the make-test setup phase regenerating
generated artefacts during recent test runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related fixes for the Deploy web-app-joomla CI job:
1. Wait for the plugin XML, not just `index.php`, before installing.
The upstream image (joomla:5.x-php8.3-apache) extracts plugin
manifests asynchronously after the front controller, so the CLI
install fired before the plugin tarball had finished extracting and
crashed with:
In Installer.php line 2120:
Warning: simplexml_load_file(): I/O warning: failed to load
external entity
"/var/www/html/plugins/actionlog/joomla/joomla.xml"
At that point the install had already created the schema (Creating
and populating the database...OK) but had NOT inserted the admin
user yet — so the retry loop saw existing tables, skipped admin
creation, and every Playwright login then failed with "Username and
password do not match". Waiting for that specific manifest is the
cheapest end-of-extract marker; switching the wait to it makes the
first install attempt clean and the admin user always lands.
2. Harden the spec so credentials problems fail fast and Joomla 5.x
template tweaks no longer false-fail. The body class on Joomla 5.x
admin uses `option-com_*`, not `com_cpanel`, so add `body[class*=
'option-com_']` to the cpanel-marker selector. Bump the wait timeout
60s → 120s for CI headroom. Race the success marker against
Joomla's "Username and password do not match" alert via
`Promise.race`, so a credentials problem now fails in seconds with
a clear `Joomla rejected the admin login: <text>` instead of timing
out 1.1 min × 3 retries on the cpanel locator.
Verified locally: `dashboard to joomla: admin login, verify control
panel, logout` passes in 5.1s.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The CLI install in roles/web-app-joomla/tasks/02_install.yml runs
through `bash -lc` with the admin password Jinja-substituted into a
double-quoted shell argument. The default admin password
(`'$tr0ng€xampl3PW\!`) contains `$tr0ng`, which bash expands as an
undefined parameter — the installed secret silently truncates to
`'€xampl3PW\!`. Joomla then rejects every subsequent login (admin
Playwright test, password reset, etc.).
Pass admin and DB passwords through the task's `environment:` and
reference them as bash env vars (`${J_ADMIN_PASSWORD}`,
`${J_DB_PASSWORD}`) so bash expands the env var (which holds the
literal secret) instead of re-parsing the secret as shell input.
Verified via `SERVICES_DISABLED='matomo,email' make deploy-fresh-purged-apps APPS=web-app-joomla FULL_CYCLE=true` — both sync and async passes report `failed=0`, Playwright `dashboard to joomla: admin login, verify control panel, logout` passes in 5.0s and 4.0s.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Promotes web-app-joomla's OIDC integration from "documented future
work" to a working native plugin shipped inside the role.
* New `plg_system_keycloak` Joomla 6 plugin under
`roles/web-app-joomla/files/joomla-oidc-plugin/`. The plugin's PHP
dependency `jumbojett/openid-connect-php` is vendored at deploy time
inside the Joomla container (Composer is baked into the role's
custom image), then packaged as a ZIP and installed via the Joomla
CLI. The repo working tree carries no `vendor/` tree.
* Modus 3 per requirement 013 (Force-Frontend, Local-Backup-Backend):
every visit to `/` and `/administrator` redirects to Keycloak
unless the request explicitly carries `?fallback=local` AND the
env-var `JOOMLA_OIDC_FALLBACK_ENABLED` is `true`. The fallback hatch
is env-toggleable so high-security deployments can flip to Modus 1
(no local form).
* RBAC group mapping: the plugin maps the Keycloak `groups` claim
onto Joomla's standard usergroup IDs:
/roles/web-app-joomla/administrator -> Super Users (id 8)
/roles/web-app-joomla/editor -> Editor (id 4)
/roles/web-app-joomla -> Registered (id 2)
Users whose Keycloak groups match none of these paths are refused.
* Matrix-deploy variants flip the auth-flow gate: variant 0 is the
canonical OIDC variant (this commit's primary path), variant 1
flips OIDC off and LDAP on so Joomla's core LDAP authentication
plugin against svc-db-openldap is the exercised auth path.
* Role wiring:
- `tasks/07_oidc_plugin.yml` builds + installs the plugin idempotently.
- `templates/env.j2` propagates JOOMLA_OIDC_* env into the container.
- `meta/services.yml` enables `oidc` with `fallback_enabled: true`.
- `meta/rbac.yml` declares the `editor` role.
- `templates/playwright.env.j2` keys `OIDC_SERVICE_ENABLED` off
`services.oidc.enabled` for the Playwright test gate.
* Playwright e2e covers three scenarios:
- native OIDC redirect from `/` to Keycloak and back to Joomla,
- `/administrator?fallback=local` hatch bypassing Keycloak,
- LDAP variant against the core Joomla LDAP plugin.
* Documentation:
- `roles/web-app-joomla/README.md` rewritten per documentation.md
and readme_md.md (software-focused description, no em-dashes,
canonical Credits format).
- `roles/web-app-joomla/files/joomla-oidc-plugin/README.md` covers
plugin build, env contract, claimed routes, and watch points.
- `docs/requirements/013-alpha-to-beta-promotion.md` joomla
per-role-notes updated to reference the in-role plugin and the
Modus 3 fallback contract.
* `.github/dependabot.yml` adds the composer ecosystem entry that
covers the plugin's `composer.json`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reintroduce the no_raw_docker integration test that was lost in the
flat-to-clustered tests/integration reorg, scoped to .yml/.yaml/.j2/.sh
and powered by utils.cache.files.iter_non_ignored_files plus read_text
so file enumeration respects .gitignore and content reads are
lru-cached. Catch two argv-/inline-form blind spots that the original
single-line regex missed (argv: - docker / shell: "docker exec ...")
and add a `no-raw-docker: skip` header marker for files where the
container wrapper is genuinely unavailable.
Apply the marker to the 6 GitHub-hosted-runner workflows that cannot
reach the wrapper, and rewrite the 7 role-task argv invocations
(BigBlueButton, Discourse, Matomo, Joomla 03/04/06) onto
'container' so the engine stays swappable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Single atomic landing for three back-to-back role-meta refactor requirements
(see docs/requirements/008-role-meta-layout.md, 009-per-role-networks-and-ports.md,
010-role-meta-runafter-lifecycle-migration.md).
req-008 — role meta layout
* Move every role's metadata under meta/<topic>.yml with file-root
convention (no wrapping key matching the basename):
- schema/main.yml + config/main.yml.credentials → meta/schema.yml
- users/main.yml → meta/users.yml
- config/main.yml.server → meta/server.yml
- config/main.yml.rbac → meta/rbac.yml
- config/main.yml.compose.services + every other top-level key
of config/main.yml inlined under <entity>.<key>
→ meta/services.yml
- config/main.yml.compose.volumes → meta/volumes.yml
* Drop the legacy schema/, users/, config/ directories from every role.
* meta/schema.yml: support nested credential keys, optional `default:`
field (Jinja literal, not rendered/validated/regenerated), implicit
`algorithm: plain`.
* Materialised paths drop the `compose.` prefix:
compose.services.<e>.<…> → services.<e>.<…>
compose.volumes.<k> → volumes.<k>
<former-top-level>.<…> → services.<entity_name>.<former-top-level>.<…>
* Update every Python consumer (utils/cache, utils/manager, utils/applications,
plugins/lookup, plugins/filter, cli/*) plus every Jinja template, vars/*.yml
and task file to the new paths.
* No legacy fallback. Repository-wide grep for `compose.services.` /
`compose.volumes.` / `schema/main.yml` / `users/main.yml` / `config/main.yml`
is empty outside docs/requirements/ and tasks/utils/migrate_meta_layout.py.
* Add tests/lint/repository/test_role_meta_layout.py guarding against the
old paths returning, and an explicit lint guard that meta/variants.yml
files do not carry a `compose:` wrapper.
req-009 — per-role networks/ports
* Move group_vars/all/08_networks.yml.defaults_networks.local.<role>.{subnet,
dns_resolver} into roles/<role>/meta/server.yml.networks.local.{subnet,
dns_resolver}.
* Delete group_vars/all/09_ports.yml entirely; ports now live on the
service entity that exposes them at
roles/<role>/meta/services.yml.<entity>.ports.{inter,local,public}.
* Rename + int-cast every former top-level `services.<entity>.port`
field to `services.<entity>.ports.inter` (single int).
* Always-category-keyed maps for ports.local / ports.public; relay
spans use {start, end} integers.
* Add PORT_BANDS map to group_vars/all/08_networks.yml — single source
of truth for the per-<scope>.<category> band ranges, consumed by both
the new CLI helpers and the lint test.
* New CLI helpers (cli meta ports suggest, cli meta networks suggest)
walk the live role tree gap-first then increment-fallback, with
deterministic output and non-zero exit on capacity overflow.
* cli create role consumes both helpers when scaffolding a new role.
* Lint extensions: bare-int port shape rejection, host-bound port
collision check (single ints + relay span integers, inter excluded),
band-membership check with documented allow-list for the legacy
web-app-bigbluebutton.bigbluebutton.local.http: 48087 entry.
req-010 — run_after / lifecycle migration
* Move galaxy_info.run_after and galaxy_info.lifecycle out of
meta/main.yml into meta/services.yml.<primary_entity>.{run_after,
lifecycle}; <primary_entity> is the value returned by
get_entity_name(role_name).
* For multi-entity roles whose primary entity has no compose service
(web-app-bluesky → bluesky, web-app-matrix → matrix, desk/dev/drv/
gen roles), create a dedicated metadata-only top-level entry under
<primary_entity>.
* Introduce utils/roles/meta_lookup.py with get_role_run_after /
get_role_lifecycle that resolve <primary_entity> once and read from
meta/services.yml; degrade gracefully when meta/services.yml is
absent OR the field is absent; raise MetaServicesShapeError when the
file exists but is malformed.
* Update every consumer (utils/service_registry, utils/invokable,
cli/meta/applications/resolution/run_after/__main__, cli/meta/roles/
lifecycle_filter/__main__, cli/meta/applications/resolution/combined,
cli/build/{tree,graph,role_include}, cli/deploy/development/{common,
init,deps,deploy}, plugins/filter/canonical_domains_map, etc.) to
use the helper.
* Lint guards: no run_after/lifecycle in meta/main.yml; lifecycle
values restricted to {planned,pre-alpha,alpha,beta,stable,deprecated};
run_after/lifecycle never on a non-primary entity; empty run_after
lists rejected.
Also folded into this change set
* services_disabler now writes `services.<svc>.{enabled,shared}` into
host_vars instead of the old `compose.services.<svc>.{...}` path; the
discover/find pair walks meta/services.yml and only treats entries
carrying enabled/shared/provides as provider declarations (so
metadata-only entries from req-010 do not create spurious provider
rows).
* service_registry skips `run_after` deps whose target was filtered
out via SERVICES_DISABLED instead of failing the whole load.
* cli/mirror/resolver and cli/create/inventory/mirror_overrides emit/
consume the new applications.<role>.services.<svc>.image shape
(mirror.md updated to match).
* utils/domains/list.build_applications_from_roles now reads
meta/server.yml directly (it had been silently returning {} since
config/main.yml stopped existing).
* utils/roles/resource_validation.filter_roles_by_min_storage reads
meta/services.yml.<entity>.min_storage instead of the legacy
compose.services path.
* web-app-bluesky pre-clones the social-app build context BEFORE
sys-svc-compose so the build context is on disk by the time
sys-stk-front-proxy/tasks/main.yml flushes the docker build handler.
* 4 meta/variants.yml files (svc-ai-ollama, web-app-phpmyadmin,
web-app-wordpress, web-app-keycloak) lose their `compose: services:`
wrapper so deep-merge over meta/services.yml lands on the new
file-root shape.
Validation
* Unit tests: 1571 passed.
* Lint tests: 50 passed.
* Integration tests: 161 passed (1 skipped).
* Full-cycle fresh-purged-apps deploy succeeded for the 8-app validation
set: web-app-{nextcloud,wordpress,yourls,bigbluebutton,bluesky,gitea,
discourse,mattermost}.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CLI installer already sets the admin password via --admin-password.
Running user:reset-password immediately after install races on Joomla's
per-invocation CLI session warm-up and intermittently fails with
"The user <name> does not exist\!" (observed in CI run 24915917415,
centos). Gate the reset on joomla_installed.rc == 0 so it only runs
on re-deploys, where it still enforces password rotation against an
existing user.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Joomla 5.x CLI installer's post-install cleanup step can crash
with rc=1 while the core install has already written configuration.php
successfully. Observed on Debian (run 24897749979) as:
In InstallCommand.php line 177:
Call to a member function deleteInstallationFolder() on null
Our existing retry loop treated rc=1 as a failure and kicked off a
destructive retry: the next attempt `rm -f`s the freshly written
configuration.php and then fails because the installer self-deleted
its own entry file (`installation/joomla.php`) during the crashed
cleanup.
Extend the tolerance to also match the new Joomla 5.x null-ref
symptom in stderr. The task-local `vars:` block is now the SPOT for
the outcome classification: `_joomla_install_soft_success` and
`_joomla_install_git_tree_refusal` encode the full soft-success /
hard-failure policy, and the three filter clauses (`until`,
`changed_when`, `failed_when`) reference those booleans instead of
duplicating `is search(...)` expressions.
Distinct from the earlier infinito-nexus/core#194 pipefail regression
(already fixed in 3fdbe8d9e) and from the "Unable to delete
installation folder" case already handled. Both pre-existing guards
stay in place.
The retry-loop install task introduced in f7d32109e used
`argv: [sh, -lc, …]` with `set -o pipefail` in the body. On Debian/Ubuntu
CI runners `/bin/sh` is dash, which aborts with
`sh: 1: set: Illegal option -o pipefail`, so all 5 retries die on the
very first line and the Joomla deploy fails.
Switch argv[0] to `bash` so the task works on every distro and document
the constraint inline. Regression is tracked in infinito-nexus/core#194;
the retry-loop itself was the fix for #186.
Add tests/lint/ansible/test_no_sh_pipefail_in_ansible_tasks.py guarding
the three ways this class of bug can come back:
* `command` with `argv: […, sh, -lc, body_with_pipefail]`
* `args.executable` set to any non-bash shell
* removal of the global `ansible_shell_executable: /bin/bash` default
from group_vars/all/00_general.yml
The joomla docker image populates /var/www/html asynchronously on first
container start: index.php appears early, but a stub configuration.php
can land a short time later. Between the install-check and the install
itself, the stub has been observed to appear in CI (~0.7s gap), which
then makes the CLI installer refuse with
[ERROR] configuration.php already present\! Nothing to install, exiting.
An earlier fix added a single "rm -f configuration.php" task between
the check and the install, but CI still caught the stub re-appearing
between rm and install when image init was slow enough. Bundle the
stub removal and the CLI install into a single shell step so every
retry re-runs BOTH: the install can lose the race at most for its own
attempt; the 5x15s retry window covers the image-init settling period.
Also rewrite the step to use the `container` / `compose` wrappers
instead of raw `docker exec`, matching the repo-wide convention
enforced by tests/integration/compose/test_no_raw_docker.py.
Validated via make deploy-fresh-purged-apps APPS="web-app-joomla
web-app-peertube" SERVICES_DISABLED="matomo,email" FULL_CYCLE=true:
Pass 1 ok=1576 failed=0, Pass 2 ok=1444 failed=0, joomla 1/1
Playwright scenario green in both passes.
Three orthogonal CI flakes addressed after autonomous iteration via the
Role Loop protocol (3 fresh-purged/reuse-kept cycles, all green on final).
web-app-matrix/files/playwright.spec.js
DM block retries=0 -> 1. rc_login exhaustion (the original reason for
retries=0) is obsolete: the 120s drain wait before admin's signin
already eliminates that class of failure. The dominant failure mode is
now CI infra pressure surfacing as "Target page, context or browser has
been closed" mid-navigation under the combined Synapse + Element +
bridges + Keycloak + Mailu + Matomo load on a standard runner. One
retry tolerates those transient crashes without burning 3x the budget.
web-app-joomla/tasks/02_install.yml
Delete stale stub configuration.php before install. The joomla
5.4.5-php8.3-apache image populates /var/www/html asynchronously: the
test -f configuration.php check races with a stub landing ~0.7s later.
CLI installer then refuses with "configuration.php already present\!".
Scoped to joomla_installed.rc \!= 0 so an existing install is never
touched.
web-app-joomla/tasks/06_reset_admin_password.yml
Add retries: 6 delay: 5 to reset-password. First post-install
invocation transiently reports "The user administrator does not exist\!"
even though user:list shows the user. Looks like a race between the
install's DB commit and the CLI's user lookup on a fresh Symfony boot.
A truly missing user still fails after all retries.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The rolling `joomla:latest` tag has shipped a raw git source tree,
which makes `installation/joomla.php install` refuse to run with
"It looks like you are trying to run Joomla\! from our git repository."
That refusal exits rc=0, silently masking the failure; the next task
then trips on a missing configuration.php.
Pin to a stable release tag (5.4.5-php8.3-apache) and treat the
git-repository stdout as a hard failure so future tag drift surfaces
the real cause instead of a misleading downstream symptom.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Joomla 5.x CLI installer occasionally fails to delete
/var/www/html/installation after a successful install (non-fatal
cleanup step). Treat "Unable to delete installation folder" as
success and force-remove the folder as root so subsequent runs see
a configured, non-stuck install state.
This commit adds dynamic upload size configuration (upload_max_filesize + post_max_size), introduces a dedicated php-upload.ini template, mounts it through Docker Compose, renumbers all task files consistently, updates main.yml flow, and overrides client_max_body_size inside the logout role.
Improves clarity, consistency, and brings Joomla in line with other IN roles.
See: https://chatgpt.com/share/6927075c-4de0-800f-bcee-b1f5193e4a99