Commit Graph
154 Commits
Author SHA1 Message Date
kevinveenbirkenbach 15930945fe Merge branch 'main' into feature/store-i18n 2026-10-19 01:54:37 +02:00
kevinveenbirkenbachandClaude Opus 5 a10c0db10e fix(svc-bkp-volume-2-local): exclude a volume by the name docker gives it
A meta/volumes.yml entry that declares backup: false without pinning a
name: had its semantic key emitted into --volumes-no-backup-required.
Docker names such a volume <project>_<key>, and baudolo compares the string
exactly, so the exclusion matched nothing and the volume was copied anyway.

web-app-docs hit it in CI: docs_sites was emitted, the live volume is
docs_docs_sites, and the 17 GB regenerable sphinx tree was backed up into
the 30 GB container root. That filled it and took four jobs of run
39137606155 down with three different symptoms: no space left during the
copy, rsync reading the tree while the builder still wrote to it, and
hlth-disc-space then refusing to start on a full filesystem.

deployed_volume_name() now derives the name the way compose does: the
pinned name: when there is one, otherwise the role's entity name, which is
the compose project, joined to the key. The docstring claimed the old
behaviour was correct and is fixed too.

Three roles were silently still backed up and are now excluded as declared:
web-app-docs, web-app-jellyfin and web-svc-api. The nine entries that pin a
name are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-18 23:08:45 +02:00
kevinveenbirkenbachandClaude Opus 5 bab08063f3 feat(plugins): ask for a container-network origin instead of assembling one
A sibling container is reached at http://<entity name>:<internal port>,
and that string was written out per call site: a literal protocol, an
entity_name interpolation for the host, and a lookup('config', ...,
'services.<key>.ports.internal.http') for the port. Nine of them across
seven roles, each needing its own `# nocheck: literal-protocol-lookup`,
because the sibling lint rightly objects to a protocol glued in front of
a lookup - the protocol is supposed to come from wherever the TLS
posture is decided.

A container network has no TLS posture. It speaks plaintext by
construction, which is a property of the network rather than of any
caller, so stating it nine times stated it eight times too often.
lookup('container_url', application_id, service_key) holds all three
facts, and because no literal protocol is left at the call site the
suppression markers go with them.

The port is deliberately the internal one. A sibling that goes out to
the published port leaves the container network and comes back through
the ingress, which the dns-search suffix turns into the public FQDN.
The lookup refuses a service that declares no internal http port rather
than falling back to anything.

test_container_url_lookup.py keeps the shape from coming back: a
protocol, an interpolation mentioning entity_name, a colon, a second
interpolation. Nothing else matches that, and `# nocheck:
container-url-lookup` covers an origin this lookup genuinely cannot
express - a port that is not ports.internal.http, or a host that is not
the application's entity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-18 02:13:05 +02:00
evangelostsak e62ed8c56f Merge remote-tracking branch 'upstream/main' into feature/svc-net-wireguard 2026-10-17 09:34:00 +02:00
kevinveenbirkenbachandClaude Opus 5 bf5aa1abd8 Merge branch 'feature/designer' into feature/store-i18n
Two conflicts, both resolved by combining rather than by picking a side.

`roles/web-app-socialhome/tasks/02_design.yml`: designer restructured the pass,
moving the token request and the theme write into the new
`02_design/converge.yml` and adding a stat of the name-state file. HEAD still
carried the converge block inline. Kept designer's stat, dropped HEAD's inline
block: the merged body below the conflict already includes `converge.yml` and
reads `socialhome_name_marker`, which only designer defines, so keeping HEAD's
side would have duplicated the token request and left that variable undefined.
HEAD's `retries: 0` on the token request is deliberately not carried over; the
default of 30 retries covers a service that is not yet accepting connections,
which matters more here than failing fast on bad credentials.

`roles/web-app-fider/files/playwright/test-design.js`: the import line takes
both sides, since the merged body uses designer's `apiGetOnion` and HEAD's
`requireDotenvValue`. The env block keeps HEAD's `requireDotenvValue` form,
because designer's `|| ""` defaults are what
`tests/lint/ansible/.../test_unguarded_env_default.py` exists to reject, and
drops HEAD's module-scope `title` and `logoEnabled`: the merged body defines
`title` locally and reads `DESIGN_LOGO_ENABLED` directly, so both were dead and
would fail `no-unused-vars`.

Checked after resolution: no conflict markers (`git diff --check`), no `nocheck`
line dropped by the merge, and no duplicated YAML key in the seven merged files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-17 00:13:02 +02:00
kevinveenbirkenbachandClaude Opus 5.5 d36e203171 fix(cache): render the applications carrier without the caller's application_id
`lookup('tls', ...)` aligns a cross-app domain to the consumer named by
`application_id` (utils/tls_common.py `align_domain_to_consumer`): a
clearnet-primary consumer gets the clearnet sibling of an onion-primary
target. The carrier key does not cover `application_id`, so a park that
renders inside a role hands that role's alignment to every later role
whose key matches.

The constructor and `tasks/utils/setup/role.yml` park at play scope,
where `application_id` is undefined. The re-park in
`roles/sys-token-store/tasks/write.yml` (67f6c8d7e8) and the nested-load
re-park render inside the caller: `svc-prx-openresty` at the preload
loader, `web-app-stalwart` or a provider role on the NFS host, the web
app itself under `sys-stk-front-base`. In a tor deploy that turned onion
targets into clearnet domains for onion-primary roles served from such a
park, and baked the caller's group into Nextcloud's sociallogin
`groupMapping`.

Drop `application_id` from the render's variables and from the
templar's for the duration of the render, so every park carries the
play-scope result the constructor parks have always carried. Rename the
nested re-park task so its log lines do not read as a role boundary.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 21:25:43 +02:00
evangelostsak 6f24461720 refactor: drop rationale and narration from tests/unit/python/plugins/lookup/test_mesh_ranges.py 2026-10-16 19:10:01 +02:00
evangelostsak d480c2785e refactor: drop rationale and narration from tests/unit/python/plugins/lookup/test_mesh_address.py 2026-10-16 19:10:01 +02:00
kevinveenbirkenbach 338a2ffa7f Merge branch 'feature/designer' into feature/store-i18n 2026-10-16 13:19:15 +02:00
kevinveenbirkenbachandClaude Opus 5 adacac7f29 fix(mcp): present the account's login in basic auth, not the users-dict key
The WordPress MCP endpoint answered 401 to every authenticated call, and
web-app-hermes and web-app-openclaw failed their CLI tests as cascades of the
same refusal. The WordPress container's own Apache log settles it: 121 POST,
100 GET and 21 HEAD on the MCP path, each with %u = mcp-web-app-wordpress and
status 401. A populated %u rules out a stripped Authorization header, and the
body is infinito_mcp_unauthorized, which only the mu-plugin's rest_pre_dispatch
guard emits - a wrong password for an existing user would surface WP core's
incorrect_password instead. So WordPress never resolved a user, because the
deploy sent the users-dict key while the login the deploy creates is
mcp-wordpress.

7916c0177e introduced the split when it capped usernames, deliberately keeping
the key because meta/mcp.yml's owner and the token store wire to it.
web-app-wordpress is the only role with auth: basic_auth - every other MCP
provider uses bearer, where the owner never travels - which is why only this
one broke.

resolve_credential now also returns the login, mcp_servers emits it on every
discovered entry, and the filter encodes login:token. It raises when no login
resolves rather than authenticating as the empty user, so the failure is loud
instead of another 401. The new mcp_client_login lookup shares
resolve_provider_credential with mcp_client_bearer so a second resolver cannot
drift from the first, and a test pins the two to the same function object.
test.env.j2 is the one place that hand-builds the server dict instead of
reading MCP_DISCOVERED_SERVERS, so it passes login as well.

The lint closes the class: auth: basic_auth requires credential.owner to be a
key some role's meta/users.yml declares. Without it a future basic-auth
provider sourced from credentials would hit the same fallback silently. It
imports BASIC_AUTHS from the filter so the rule and the renderer cannot drift
onto different spellings.

Tool names were verified against the adapter version the addon actually
installs, v0.7.0, not the v0.5.0 recorded in meta/mcp.yml: the sanitizer turns
the three registered abilities into infinito-get-post,
infinito-list-categories and infinito-search-posts, matching the rendered
allowlist exactly. Adding login to a discovered entry changes nothing for the
five consumers - three of them do not list basic_auth in supported_auths and
never see this server at all, and the other two read named keys only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-16 12:37:30 +02:00
kevinveenbirkenbachandClaude Opus 5.5 67f6c8d7e8 fix(cache): keep the applications carrier valid across token store writes
Since 778c18fb23 the carrier key holds the tokens file's mtime and size.
sys-token-store changed that stat on every inclusion (00_core.yml
touched the file) and on every persisted token, and nothing re-parked a
carrier afterwards. Every later task's fresh worker then rendered the
applications SPOT cold, 30 to 80 s each. On main fea6f31d9f (run
38812964308) seven heavy jobs logged 194 to 287 such tasks and ran out
of the 20400 s budget; the same jobs before 778c18fb23 logged 65 to 83,
all of them real waits.

- 00_core.yml touches the store with modification_time and access_time
  preserved, so it only creates a missing file. In swarm the controller
  sees the store over NFS with attribute caching, and the touch alone
  made the second pass go cold without any write.
- write.yml re-parks the carrier when the persist or the controller
  mirror changed the store. Both run once, so every host that ran the
  include gets the same verdict.
- tasks/utils/setup/role.yml re-parks after every role on every host,
  so hosts that did not run the writing role recover too. Where the
  key still matches this serves the carried payload; measured 0.1 s per
  role in a local compose deploy.
- The tokens file enters the key as the SHA-256 of its content,
  memoised per inode, mtime and size, so a touch no longer changes the
  key. A stat that still matches serves the memo (1.67 us against
  1.41 us for the old stat signature), and a stale stat keeps key and
  YAML cache on the same old content instead of pairing new content
  with an old key.
- applications_carrier opens the tokens file before it computes the
  key, so on the NFS-mounted controller the parked key reflects the
  revalidated attributes rather than a cached stat that changes a
  minute later.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 11:11:47 +02:00
kevinveenbirkenbachandClaude Opus 5 5b69b5c7c6 Merge branch 'feature/designer' into feature/store-i18n
Six conflicts, all from git pairing store-i18n's renamed roles with their designer originals.

web-app-meta/meta/services.yml and web-app-sphinx/vars/main.yml: store-i18n renamed web-app-sphinx to web-app-meta and dropped its custom build, so the role keeps the prebuilt ghcr.io/infinito-nexus/meta image and the old sphinx role stays deleted.

svc-ai-tika/templates/compose.yml.j2: a bogus rename pairing dropped sphinx build lines into the tika service. Rejected; the template is byte-identical to its pre-merge state again.

web-app-docs/templates/Dockerfile.j2: the designer file renders sphinx under the design title and reads SPHINX_IMAGE, SPHINX_OUTPUT_DIR and SPHINX_EXEC_DIR, none of which exist in web-app-docs, which builds from files/Dockerfile. Rejected.

web-app-docs/templates/playwright.env.j2 and files/playwright/test-design.js: both sides add independently, so both are kept. Of the designer's three new env reads only designTitle survives; iconUrl and faviconUrl are shadowed inside the test by stricter requireDotenvValue reads and were dead at module scope.

plugins/lookup/email.py merges clean toward designer, which is intended rather than a loss: 08ef66d8de (2026-10-08) replaces the submission_via_relay port-25 rule of 233f15f3e0 (2026-07-01) with per-bot app passwords under SSO, and carries the matching 04_manage_user/app_password tasks.

Checked after resolving: no duplicate YAML keys across the 17 merged YAML files, and no nocheck line dropped above a renamed key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-15 22:21:50 +02:00
kevinveenbirkenbachandClaude Opus 5 1e61debad2 fix(objstore): sign funkwhale's media URLs against the host the browser dials
funkwhale has two S3 surfaces sharing one private bucket. Audio goes out as X-Accel-Redirect and the front nginx fetches it server-side, but attachments (instance logo, avatars, covers) land in the API JSON as absolute URLs the BROWSER dials. AWS_S3_ENDPOINT_URL drives the boto3 client, so with the internal endpoint django-storages signed URLs for http://seaweedfs:8333, a host no browser can reach.

Point the client at the new bucket-less public_endpoint key. Measured against the live gateway: a URL signed for seaweedfs:8333 and sent to the public host answers 403 SignatureDoesNotMatch, while one signed for the public host reaches bucket lookup — query-string SigV4 binds the Host, so the signing host and the fetch host must be the same string. AWS_S3_CUSTOM_DOMAIN cannot serve this: storages/backends/s3.py:689 returns UNSIGNED without a CloudFront signer, and the bucket also holds the music library that music/views.py exists to gate.

AWS_CA_BUNDLE follows, because the public endpoint is TLS on the local CA and botocore reads its trust store only from that variable (configprovider.py:59) — sys-svc-compose-ca injects SSL_CERT_FILE, REQUESTS_CA_BUNDLE, CURL_CA_BUNDLE and NODE_EXTRA_CA_CERTS, never this one. It takes the bundle rather than the bare root cert, since AWS_CA_BUNDLE replaces the default store.

AWS_QUERYSTRING_AUTH=True fixes a pre-existing break: upstream defaults it to `not PROXY_MEDIA`, which produced unsigned URLs against a private bucket. The new front nginx.conf.j2 serves /_protected/media, which the stock template resolves with `alias ${MEDIA_ROOT}/$1;` and therefore 404s for every S3-backed listen.

web-svc-seaweedfs keeps no onion vhost. Its tor-contract reason now states the mechanical cause instead of the old claim that the role is never a Tor-browser surface, which decidim and funkwhale have made false: the node hidden service publishes http only while sys-front-tls keys listen/ssl on application_id, so an onion vhost would listen on 443 against a port-80 hidden service. Attachment fetches therefore still fail on an onion pick, and the spec is left to fail there rather than record the miss and pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-15 22:14:38 +02:00
evangelostsak 1c5ba164f2 Merge remote-tracking branch 'upstream/main' into feature/svc-net-wireguard
No conflicts across 121 incoming commits. The one file both sides changed is
tasks/stages/01_constructor.yml: upstream parks an applications carrier for
the swarm bootstrap roles after the add_host tasks, and keys the carrier on
the tokens file as well. This branch's mesh bootstrap opens and drops its own
block-scoped carrier before that point, so both keep working as written.

Upstream also brings ruff 0.17.0, distro-derived env keys, a scoped act outer
teardown, the stalwart relay rule, the onion apex redirect and image and
version updates; ruff 0.17 reformats nothing on this branch.
2026-10-15 21:27:11 +02:00
kevinveenbirkenbach 06a74e7658 Merge pull request #869 from infinito-nexus/fix/web-app-stalwart/relay-rule/793
fix(web-app-stalwart): authenticate bots with an app password under SSO
2026-10-15 15:42:19 +02:00
evangelostsak 01e8c1a75f test(lookup): mesh_address returns only the host's own primary address 2026-10-14 15:03:41 +02:00
kevinveenbirkenbachandClaude Opus 5.5 45732329b6 fix(lookup): keep the onion apex from redirecting to a clearnet homepage
On a Tor node current_play_redirect_domains schedules the bare node
onion as a redirect source targeting DOMAIN_HOMEPAGE. Without
web-app-dashboard, DOMAIN_HOMEPAGE falls back to SOFTWARE_DOMAIN
(infinito.nexus), so web-opt-rdr-domains rendered a 301 from
http://<node>.onion/ to http://infinito.nexus/: an onion visitor sent
to a clearnet host over plain HTTP.

The onion apex entry is now added only when DOMAIN_HOMEPAGE is an
onion host. Otherwise the onion apex gets no redirect and the default
server answers.

Closes #924

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 13:35:29 +02:00
evangelostsak 762513ec32 test(lookup): hold mesh_ranges to a rendered pool
The declared template must come back rendered, and one that stays a template
must fail rather than reach the export list.
2026-10-14 11:23:23 +02:00
kevinveenbirkenbachandClaude Opus 5 7c13e96c96 Merge branch 'main' into feature/store-i18n
Twelve files conflicted; most resolutions keep both sides rather than pick
one.

cli env.py and compose.py: both branches extend the same spots. The tools
override is appended before write_override, because that call filters the
mirror override to the compose files it is handed. compose.py keeps this
branch's CACHE_ROLE_SHELL_DIR, since the cache scripts moved into a role and
main's literal scripts/docker/cache path is their old home, and takes main's
mirrored init image alongside it.

utils/docker/image/discovery.py: this branch's version_variants expansion
plus main's whole runner-image block, which runner_mirror imports.

sys-svc-compose network create: main's side wins on substance and this
branch's on structure. main carries eda6249d7b, the fix for a probe that
matched an id prefix and let a sibling shadow the exact name; this branch had
refactored the task into create/overlay.yml and create/bridge.yml but brought
back the docker_network_info probe that fix replaced. The subfiles stay and
now probe through the exact-name lookup. main's half also read
SWARM_CLUSTER_AVAILABLE, which this branch removed in favour of
IS_SWARM_MODE, as the merge base confirms, so the gates follow this branch.
The lookup's import follows this branch's get_entity_name to entity_name
rename.

web-app-pixelfed: the custom entrypoints stay removed, since the new image
brings its own and the variables behind them no longer exist, and main's
host_alias=true comes along.

web-app-suitecrm: main's session.yaml and its config/packages path, because
only that version wires framework.session and only that directory is read by
Symfony.

web-app-odoo: main's onionOptions import is required by the test.use below
it; this branch's test-seaweedfs registration stays.

Four defects surfaced that no conflict marker shows.

The auto-merge of web-app-suitecrm/vars/main.yml duplicated
SUITECRM_SESSION_CONFIG_HOST, which ansible-lint rejects. Removing it exposed
the second: SUITECRM_SESSION_CONFIG_DOCKER still named config/session.yaml
while the mount had moved to config/packages, so the freshness comparison in
tasks/01_manager_ops.yml would have tested a path nothing writes.

svc-db-elasticsearch carried environment: on two include_tasks, which Ansible
accepts on include_role only. ELASTIC_PW now sits on the five tasks that read
it, which is also the tighter scope for a credential.

The openclaw templates render NETWORK_TRUSTED_PROXY_CIDRS while their test
renderer passes only its explicit kwargs, so seven tests raised Undefined.
That predates the merge: the template line is on this branch and not on main,
and both sides share the same test file.

test_runner_mirror neutralises four environment variables under clear=False
and missed three, so the gpu and tools overrides leaked in from the container
and the assertion on the layered list broke. It now neutralises all seven.

make quality-high passes both phases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-13 10:57:22 +02:00
evangelostsak 75fd3e9433 Merge remote-tracking branch 'upstream/main' into feature/svc-net-wireguard 2026-10-13 01:08:20 +02:00
kevinveenbirkenbachandClaude Opus 5.5 24d7872fde Merge remote-tracking branch 'origin/main' into HEAD
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 18:54:31 +02:00
kevinveenbirkenbachandClaude Opus 5.5 eda6249d7b fix(sys-svc-compose): probe provider networks by their exact name
The network probe used community.docker.docker_network_info, whose
get_network() scans GET /networks and returns the first entry whose Name
equals the requested name OR whose Id starts with it, in a list order that
changes between calls. In main run 38038541631 (Zammad#1, swarm) the
central redis overlay got the ID n8nwzb1q39m3u4k7awwwmt6yg, the probe for
the not yet created provider network "n8n" returned it as existing, the
create step was skipped, docker stack deploy attached hermes to the redis
overlay as "n8n", and the oauth2-proxy then reached the password-protected
central redis by its bare host name: "NOAUTH Authentication required".
The same scan can also return the ID-prefix twin of a network that does
exist, so comparing the returned name alone would turn that network into
"missing" and fail the create with "already exists".

Both probes now run `container network ls --filter name=<entity> --format
'{{json .}}'`, whose name filter never matches an ID. The new lookup
network_driver picks the entry named exactly after the entity and returns
its driver, '' when there is none; the drift checks, their messages and the
create steps all read that one entry. No docker module is left in the file,
so its two docker-module-uses-container-cli suppressions go too.

Verified: on the host daemon GET /networks/<id prefix> returns a network of
another name and docker network ls --filter name=<id prefix> returns
nothing. In ansible-playbook against real docker network ls output the
lookup returned the exact network's driver in 20 of 20 probes beside an
ID-prefix twin, an empty driver (create path) for a missing network and for
a name only contained in a sibling, and the drift path for a foreign
driver. Unit tests cover the same cases; with a substring match instead of
the exact name the two sibling cases fail.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 18:35:22 +02:00
kevinveenbirkenbachandClaude Opus 5 c7238adb0a fix(web-app-keycloak): absorb a rotated admin password instead of locking out
Keycloak stores the permanent admin's password in its own database. When the
inventory value rotates, the next deploy renders a credential that
authenticates against nothing, and the role cannot reach the instance it just
restarted. The bootstrap account is no way back either: Keycloak writes
KC_BOOTSTRAP_ADMIN_* once, on an empty database, and upstream states it is
meant to be removed once a permanent admin exists.

The role now keeps the credential its predecessor used. Before sys-stk-full
re-renders the environment file with force: true, utils/capture_previous_admin.yml
reads KEYCLOAK_PERMANENT_ADMIN_PASSWORD out of the copy still on disk. When the
login after the restart is refused, utils/converge_previous_admin.yml signs in
with that captured value and moves the account onto the newly rendered one,
which the restarted container already carries in its environment. The bootstrap
fallback stays what its name says: the path for an instance that has never been
deployed. A host with no previous file captures nothing and goes straight there.

Reading the file back needs the inverse of dotenv_quote, which did not exist.
dotenv_unquote switches on the same DEPLOYMENT_MODE as the forward direction, so
a value written in compose is unescaped and a value written in swarm is taken
verbatim; dotenv_value pulls one assignment's raw right-hand side out of the
rendered text. The password never reaches argv: the convergence login passes it
through container exec -e plus the task's environment:.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-12 16:56:51 +02:00
kevinveenbirkenbachandClaude Opus 5 9e7c62a119 fix(network): let every proxied role reach its object store directly
A role that exports an outbound proxy for one purpose applies it to every
HTTP call the app makes, so an internal dependency reached by name is proxied
too. web-app-pixelfed exported the Tor SOCKS proxy container-wide for its
onion OIDC issuer and its S3 client then failed with

    cURL error 97: cannot complete SOCKS5 connection to seaweedfs

for six minutes per attempt, until sixty retries had consumed the whole
suite budget and the job was killed. web-app-espocrm ships a byte-identical
proxy block and configures its S3 endpoint from the same lookup.
web-app-nextcloud sets the proxy globally in proxy.config.php and listed two
internal hosts in proxyexclude, neither of them the object store it reaches
by hostname.

9d91fbea83 had tried to retire the per-app proxies in favour of onion routing
over DNS, and 20ddda8c1c restored them for the OIDC issuer, so a broad proxy
is the current design and the exemption list is load-bearing.

lookup('proxy_excluded_hosts', application_id) is now that list: the loopback
forms plus the object store host when the role stores objects. It is a lookup
and not a group_vars value because no group_vars file resolves
application_id, so a global would have depended on which role happened to be
running. Nextcloud keeps its hand-written list of public hosts; only the
internal half is derived.

The lint gained a second rule for the class rather than the symptom: the
nextcloud list named no loopback literal at all, so the literal rule could
never have fired on it. A variable whose name marks it a proxy exclusion list
must render the lookup, unless it only composes other exclusion variables, and
a ..._PUBLIC_... list is exempt because it names no internal service.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-09 17:12:24 +02:00
evangelostsak 5a83785b53 Merge remote-tracking branch 'origin/main' into feature/svc-net-wireguard 2026-10-09 12:00:07 +02:00
kevinveenbirkenbach 799b835799 Merge remote-tracking branch 'origin/main' into fix/web-app-stalwart/relay-rule/793 2026-10-09 00:53:26 +02:00
kevinveenbirkenbachandClaude Opus 5.5 64677d4790 chore: regenerate role docs and apply autoformat after #881 and #873
`make quality-high` rewrote these files on every branch: the SuiteCRM
README and the root role table lacked the Redis service #881 added, the
session path test was not formatted, and an OpenTalk secret description
from #873 was not wrapped.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 22:27:12 +02:00
kevinveenbirkenbachandClaude Opus 5 fde90cd413 style(tests): wrap the redis session path call the merge brought in
Autoformat output on the test PR #881 added: the redis_session_path call
exceeded the line width, so its arguments move onto their own line. The
token sequence is unchanged, so the assertion still proves what it did
before, that a password carrying &, =, ?, # and a space survives the
connection URL intact instead of splitting it into extra parameters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-08 20:05:22 +02:00
kevinveenbirkenbach cb74bb73ec Merge branch 'main' into feature/store-i18n 2026-10-08 19:52:53 +02:00
kevinveenbirkenbach 3874f66ac3 Merge remote-tracking branch 'origin/main' into fix/web-app-stalwart/relay-rule/793 2026-10-08 19:38:56 +02:00
kevinveenbirkenbach 6e9b4e23dc Merge remote-tracking branch 'origin/main' into fix/web-app-stalwart/relay-rule/793 2026-10-08 19:27:27 +02:00
kevinveenbirkenbach badc2583eb Merge remote-tracking branch 'origin/main' into fix/web-app-suitecrm/redis-sessions/879 2026-10-08 19:25:24 +02:00
kevinveenbirkenbachandClaude Opus 5.5 3cefb01d71 fix(web-app-suitecrm): keep sessions in Redis so replicas share them
With three swarm replicas the PHP session files on the shared NFS volume
were read as empty under concurrent requests of one browser, so SuiteCRM
answered 403 on /api/graphql and dropped the session. Sessions now live in
Redis with session locking that waits like flock did; the phpredis version
is pinned in services.yml with its own update source, and the engine lookup
renders the save path.

Refs #879

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 18:31:19 +02:00
kevinveenbirkenbachandClaude Opus 5.5 08ef66d8de fix(web-app-stalwart): authenticate bots with an app password under SSO
Under SSO the role tried to let applications relay without SMTP AUTH by
widening the relay rule to the private networks. The rule compared the
peer address to the text of a CIDR, which never matches, so an
application could reach local mailboxes but got "550 5.1.2 Relay not
allowed" for every external recipient. Repairing the comparison would
have opened a relay, because published ports, Tor and the swarm mesh all
hand outside clients an address inside those networks.

Stalwart checks app passwords before it asks the external directory, so
a bot can authenticate while the directory is OIDC. With SSO enabled the
role now creates one app password per bot, acting for that account as
the recovery admin, and stores it in the token store the SMTP password
is already read from. A bot gets a new one when its entry is deleted in
Stalwart or its token is removed.

The relay rule is back to authenticated sessions only. The lookup loses
its SSO relay branch and the role its submission_via_relay flag, so an
SSO deployment submits on 465 with AUTH like every other one.

The role's CLI test now fails when an unauthenticated session is given
an external recipient, or when the bot credential is refused one.

Closes #793

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 08:57:22 +02:00
kevinveenbirkenbach b3563f4ea3 Merge remote-tracking branch 'origin/main' into fix/web-app-prometheus/blackbox-targets/792 2026-10-08 08:57:13 +02:00
kevinveenbirkenbachandClaude Opus 5 50b9f60c43 fix(prometheus): resolve the native-metrics scrape target per deployment mode
`native_metrics_target` was a Jinja filter, so it could not read
DEPLOYMENT_MODE: filters see no Ansible variables. It returned
`<services.<key>.name>:<port>` unconditionally, which is `matrix-synapse:9000`
for matrix. Compose serves that name; swarm does not, because `docker stack
deploy` ignores `container_name` and the alias never exists. This repository
says as much itself, through `lookup('compose_only', 'container_name', ...)`.
The target was therefore scraped and refused, and `playwright.spec.js:274`
reported `web-app-matrix: 1 matching target(s) reporting up != 1`, meaning one
matching target whose `up` value is not 1.

It becomes a lookup that delegates host resolution to `scrape_target`, which
was already mode-aware, so exactly one plugin knows the difference between the
two modes. The port has to be handed over explicitly: matrix exposes metrics on
9000 while `services.synapse.ports.internal.http` is 8008, so the existing
internal-port lookup cannot serve this case on its own.

`scrape_target` now refuses `port` together with an explicit `port_kind`.
Before, `port` won silently and the error text still named the
`ports.internal.<port_kind>` path that the call had never consulted.

`native_metrics_apps` drops an app that pins itself to compose through
`compose_mode_force` while the cluster runs swarm, because the scrape target
would then name the wrong object. Only the literal case: matrix declares that
override as a Jinja expression over its flavour, which this lookup cannot
resolve from the scraping role's variable scope, so variant 0 on swarm stays a
latent mismatch.

The two comment lines claiming `container_name:port` went with the change;
after it they state the opposite of what the code does.

Verified by mutation: with the swarm branch of `scrape_target` disabled, the
new test emits `matrix-synapse:9000`, the exact string CI reported.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-08 08:57:13 +02:00
kevinveenbirkenbachandClaude Opus 5 190368c478 test(sso): derive the whitelist test domain from default.env
default.env declares INFINITO_DOMAIN and every Python test is expected to
reach it through default_domain_primary(). e2beab9f1c converted nine sibling
tests to that shape and widened the hardcoded-primary-domain lint to the test
trees; this file was written after that pass and kept its own copy of the
value, which the widened lint will reject.

The literal and the SPOT agree today, so only the source of the value moves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-08 04:19:32 +02:00
kevinveenbirkenbachandClaude Opus 5 1771ba519c test(sso): name the whitelist test fixture outside the INFINITO namespace
Every INFINITO_* read from a .py or .sh file must be declared in default.env
and applied by a handler under utils/env/handlers/, so the registry lint
rejected the fixture this test exports to prove the templar rendered a raw
DOMAIN_PRIMARY. Registering it would be wrong twice over: it is not a project
default, and the companion lint would then demand a handler for it.

The template string, the export and its cleanup move together, because the
lookup resolves the name from the environment and a mismatch would render
empty and assert against ".".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-08 03:58:08 +02:00
kevinveenbirkenbach 7035169500 Merge branch 'main' into feature/store-i18n
# Conflicts:
#	roles/web-app-moodle/files/playwright/test-litellm-gateway.js
#	roles/web-app-moodle/tasks/04_oidc.yml
#	roles/web-app-moodle/templates/nginx-moodle.conf.j2
#	roles/web-app-moodle/vars/main.yml
#	roles/web-app-pixelfed/meta/services.yml
2026-10-08 02:34:58 +02:00
kevinveenbirkenbachandClaude Opus 5 b6e0ccb0b1 fix(sso): render DOMAIN_PRIMARY before it scopes an oauth2-proxy redirect
The lookup read DOMAIN_PRIMARY straight from the templar's available
variables, which hold it unrendered. The generated inventory defines it as
"{{ lookup('env', 'INFINITO_DOMAIN') }}", so whitelist_domains was written as
the literal ".{{ lookup(...) }}" and matched no host at all: oauth2-proxy
answered every absolute redirect with "domain / port not in whitelist", which
left pihole's sign_out unable to reach the Keycloak end-session endpoint.

utils/cache/domains.py already renders the raw value through
render_ansible_strict; do the same here, guarded on the value carrying a Jinja
marker so a literal domain is untouched. render_ansible_strict also refuses
output that still holds "{{", so an unresolvable primary domain now fails loud
instead of becoming a junk scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 17:26:48 +02:00
evangelostsak e9929d07c3 Merge remote-tracking branch 'origin/main' into feature/svc-net-wireguard
One conflict, in the generated role overview table of README.md.

Both sides had edited the same block. Upstream dropped Redis from the pihole
row; this branch still listed it. Upstream also listed svc-net-wireguard-core,
-plain and -firewalled, which this branch removed in e706170a0b as superseded
by the unified svc-net-wireguard role.

Resolved by taking upstream's block and then running `make readme-index`, so
the table is generated from the roles that actually exist rather than
hand-merged: Redis is gone from pihole, the three legacy WireGuard roles stay
gone, and svc-net-wireguard is listed once. `make readme-index check=true`
reports the table up to date.

Git kept the role deletions silently and correctly - upstream has not touched
those three directories since the merge base, so there was no modify/delete
conflict to resolve.
2026-10-07 10:04:31 +02:00
kevinveenbirkenbachandClaude Opus 5 89f8b6b761 Merge branch 'main' into feature/store-i18n
58 commits, five conflicts. Four of them are the same disagreement: main
stops deriving memory parameters from the host's total RAM and takes them
from the container's own limit through the service_memory_mb lookup, while
this branch had routed the same values through its resource lookup. Main's
direction wins each time, because the new
tests/lint/ansible/services/test_memory_parameters_use_lookup.py requires it
and because a value read from the host drifts from the limit its service
declares.

group_vars/all/17_resource.yml keeps this branch's lookup('resource', ...)
form for the parameters that stay, and loses POSTGRES_SHARED_BUFFERS,
POSTGRES_WORK_MEM and POSTGRES_MAINTENANCE_WORK_MEM, which svc-db-postgres
now derives per container in its own vars. Main's side of the hunk also
redefined RESOURCE_CPUS, RESOURCE_MEM_RESERVATION, RESOURCE_MEM_LIMIT and
RESOURCE_PIDS_LIMIT, which already stand above the conflict as lookups;
taking it whole would have duplicated four keys.

web-app-nextcloud drops the two php values it derived from host_mem_mb.
templates/env.j2 already sets PHP_MEMORY_LIMIT and
PHP_OPCACHE_MEMORY_CONSUMPTION from the container limit.

web-app-wordpress is adjacency only: both sides added one line in the same
place, WORDPRESS_MULTISITE_ENABLED here and WORDPRESS_PHP_MEMORY_LIMIT on
main. Both stay.

web-app-zammad takes the updater's elasticsearch bump to 9.5.5, which
svc-db-elasticsearch already carries, and resolves its compose template the
way 774ff8c2d2 did: the search keys live in templates/env.j2, because an
inline environment: block is what compose-environment-from-env-file rejects.
Main's new path.data joins them there.

Six keys of plugins/lookup/resource.py lose their last caller with the
postgres parameters above and are now unreferenced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 02:32:07 +02:00
kevinveenbirkenbach d287e55964 Merge remote-tracking branch 'origin/main' into fix/code-scanning-alerts/807 2026-10-07 01:51:19 +02:00
kevinveenbirkenbachandClaude Opus 5.5 45d956e5ea fix(sidecars): cap an engine's sidecar at its provider's size
A Redis sidecar whose consumer declares no services.redis.mem_limit
runs in a container that inherits the consumer's primary entity limit.
Since the lookup replaced the old filter, maxmemory followed that limit
and rose from 204 MiB to between 819 and 3276 MiB in 17 roles.

lookup('service_memory_mb', ..., provider=<role>) caps the result at
the same share of the provider's own limit when the application does
not declare one for that service. A declared limit is not capped, and a
container smaller than the provider keeps its own share. The Redis
sidecar passes provider='svc-db-redis': those 17 roles get 409 MiB, the
seven with a 256 MiB container stay at 204.

The lookup now rejects unknown options and a provider that is no role
or declares no limit for the service, so a misspelled option fails
instead of silently lifting the cap.

The memcached sidecar's literal -m 64 takes the same lookup and keeps
its value.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 01:30:01 +02:00
kevinveenbirkenbachandClaude Opus 5 774ff8c2d2 Merge branch 'main' into feature/store-i18n
Three conflicts, all from main sizing memory parameters off the container
limit while this branch moved container environments into one file per role.

plugins/filter/memory_filters.py and its test are deleted on main, replaced
by the service_memory_mb lookup. This branch only renamed the filter out of
its get_ prefix and fixed the docstrings' reStructuredText, so nothing of
substance is lost; the deletion is taken. Verified first that no consumer
survives: jvm_max_mb, jvm_min_mb and redis_maxmemory_mb appear nowhere in
roles/, group_vars/, tests/, plugins/, cli/ or utils/ once the merged tree
is in place.

web-app-zammad's search service is the only real conflict. 37e80b957e moved
discovery.type, xpack.security.enabled and ES_JAVA_OPTS from the compose
template into templates/env.j2, where an inline `environment:` block is what
the compose-environment-from-env-file lint rejects; a7db89ac0b changed the
heap from a literal 512m to half the container's limit. Both intents hold,
so the keys stay in env.j2 and the heap now reads ZAMMAD_SEARCH_HEAP_MB,
which the merged vars/main.yml already defines through the new lookup.

web-app-magento and web-app-shopware keep their inline environment blocks:
both carry an explicit nocheck naming the reason, that their single env.j2
is shared with containers the search daemon's keys must not reach.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 00:57:07 +02:00
kevinveenbirkenbachandClaude Opus 5.5 e203d6f7ab feat(plugins): size memory parameters from the container limit with one lookup
lookup('service_memory_mb', application_id, service_name, share) returns
a share of a service's mem_limit in whole MiB. It resolves the limit the
way sys-svc-container resolves the container's own: the named service,
then the role's primary entity, then RESOURCE_MEM_LIMIT.

It replaces the three filters of memory_filters, each with its own unit
parser and its own idea of the limit: Redis maxmemory at 0.8 of the
limit, and the Confluence and Jira heap at 0.7 and 0.35.

The central Redis (409 MiB), Confluence (5734/2867) and Jira (4300/2150)
keep their values. A Redis sidecar without a mem_limit of its own does
not: the filter fell back to a fixed 256m, which gave 204 MiB, while the
lookup takes the limit that container really runs with, the primary
entity's. That moves 25 roles to between 409 and 3276 MiB.

The heap clamps of the old filters are gone: the 1024 MiB floor, limit
minus 1024, the 12288 cap and Xms bounded by mem_reservation.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 23:05:26 +02:00
kevinveenbirkenbachandClaude Opus 5 db2a12566d fix(web-app-matrix): give Matrix user ids one definition and the admin MDAD needs
The variant-3 bootstrap aborts on an upstream guard:

    TASK [custom/matrix-bot-baibot : Fail if admin patterns list is empty]
    You need to define a required configuration setting
    (`matrix_bot_baibot_config_access_admin_patterns`)

which takes the whole playbook down and surfaces only as the generic
"matrix-mdad bootstrap failed inside the container". Nothing asks for that
setting directly: at the pinned MDAD ref 40179e6d4a it is derived from
matrix_admin, which matrix-base defaults to the empty string. The template
rendered every other baibot setting and never set the one they hang off.

matrix_admin could not simply be written out, because "@localpart:server" was
assembled by hand in nine places and the two flavors had already drifted apart
over it: the compose flavor built it from MATRIX_SERVER_NAME while the MDAD
template computed its own onion conditional off DOMAIN_PRIMARY, skipping the
services.matrix.server_name key the role declares. group_vars/all/14_about.yml
had drifted further still and used the raw clearnet name, so on an onion
deployment it published a contact handle that resolves nowhere.

The shape now lives in the mxid filter, which rejects an empty part or a
localpart that already carries a sigil and lowercases the result because
Synapse refuses anything else. The homeserver name lives in the
matrix_server_name lookup, which answers the Tor node when the role is
published under .onion and the declared name otherwise. mxid joins the two, so
every caller names only an account, needs no role variable in scope, and gets
an id that matches whichever network is published. MATRIX_SERVER_NAME now reads
that lookup rather than repeating the conditional, which leaves MATRIX_ONION_NODE
without a consumer, and it goes.

Rendering the replaced Jinja against the new resolver over eighteen input
combinations disagrees three times, each on input no inventory produces - a
whitespace-only Tor node, an uppercase .ONION domain, one with a trailing space -
and each time the new answer is the correct one.

Separately, Nextcloud#12 dies in the same role with

    /bin/sh: 1: sudo: not found

because matrix-synapse-usage-exporter pins self_build: true upstream and its
clone task becomes, which the runner image could not do. sudo joins its package
list; the container is already privileged, so this grants nothing new.

Static throughout: the baibot bootstrap had reached ok=196 of roughly 540 tasks
and the usage-exporter self-build never got to its docker build, so both fixes
clear the task that failed and nothing behind it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-06 13:52:36 +02:00
kevinveenbirkenbachandClaude Opus 5.5 3610e7b9ef fix(code-scanning): resolve the alerts that describe real code
Of the 76 open code scanning alerts, 39 point at code that can change.

svc-ai-mcp-adapter:
- A path argument that is empty or made only of dots is refused.
  Percent-encoding keeps a separator inside its segment, but "." and ".."
  need none, so an upstream that resolves dot segments could answer from a
  path the contract does not name.
- parse_duration splits on the units. The pattern it used before rescanned
  a long run of digits from every start position.

Tests and the README generators:
- Every Jinja environment built with autoescape=False and a "noqa: S701"
  uses select_autoescape now, as six test modules and
  utils/templating/jinja.py already did. It escapes templates named
  *.html, *.htm and *.xml, which none of these call sites loads.
- Dead code goes: a doubled "rel" block in the email lint, an unread
  _SKIP_DIRS, and a module that _resolve returned to no caller.
- Two "except ...: pass" blocks become contextlib.suppress and
  assertRaises.
- Nine test modules import unittest.mock one way instead of two, and one
  lookup test imports its module once.

Closes #807

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 09:18:00 +02:00
kevinveenbirkenbachandClaude Opus 5 47467f6cce style: settle the formatter and ruff rewrites of this change set
make autoformat reflowed a resource_filter call, a generator expression and five test files it touched along the way. The new architecture test also carried the two findings the first gate run stopped on: three RUF012, where a TestCase class attribute needs ClassVar, and two PERF401, where an append loop becomes list.extend over a generator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-06 00:23:15 +02:00
kevinveenbirkenbachandClaude Opus 5 819d710aaa fix(gpu): decide GPU acceleration in one lookup instead of at every call site
Whether a service gets a GPU needs two facts to agree: the declaration in
applications under services.<service>.gpu, and sys-svc-container's stat of
/dev/nvidia0 on the target host. The conjunction was restated at each call
site, and web-svc-libretranslate restated only the first half: it appended the
-cuda image suffix from the declaration alone. That tag has no arm64 manifest,
so the build died with `no match for platform in manifest`, which 10 of the 79
failing jobs of run 37252453141 carry, surfacing through
sys-svc-compose/handlers/{swarm,compose}.yml.

lookup('gpu', application_id[, service]) is now the single place that answers
it. Three call sites collapse onto it: both libretranslate roles' *_GPU var
and the runtime line of sys-svc-container's resource template. A lookup runs
on the controller and cannot stat the target, so it reads the register out of
the play's variables; the applications view comes through lookup_loader
because test_no_cache_imports_in_lookups forbids a lookup the direct
utils.cache import.

The coercion is ansible's boolean(), not python's bool(). The call sites this
replaces read the flag through Jinja's `| bool`, which answers False for the
strings "false", "False", "0" and "no" where bool() answers True, and a
templated declaration renders to such a string. Measured over 13 values the
two disagree on four of them; boolean(strict=False) matches `| bool` on all
13. Two tests pin it, and with bool() restored exactly the "false" string test
fails.

Measured rather than argued: over the merged applications, 1688
(application, service) pairs resolve the predicate identically under both
coercions, 0 disagreements. The predicate turns on for 21 services, not 5,
because resource_filter falls back to services.<entity_name>.gpu and every
service of a role whose entity declares gpu inherits it.

The assert_gpu include in both roles dropped its two trailing conditions: they
re-checked the register that *_GPU now already contains.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 17:45:00 +02:00