Records the operator-confirmed design for replacing the sys-stk-full,
sys-stk-backend and sys-stk-front-proxy includes (115 call sites in 94
roles) with one shared `stack` service that sys-service-loader runs once
before the application roles and that processes every application of
the play in one batch: per-role literal `stack` and `openresty` keys,
front proxy and database derived from enabled services, per-domain vhost
data in meta without exceptions, all roles in one change.
The document carries the verified codebase facts with file:line on
origin/main 6997dd3dbb, the ordering and binding constraints, the cost
measured on the echoserver full-6 and full-7 deploys, a target design,
grouped acceptance criteria, validation commands and an implementation
order, so another agent can implement it without this conversation.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
22 KiB
045 - Stack Service: One Shared Service Replaces the Stack Role Includes
User Story
As an operator who deploys many applications per host, I want the application stack (front proxy, backend, database) to be one shared stack service that the service loader runs once, before the application roles, and that processes every application of the play in that one invocation, so that a deploy stops repeating host-wide stack work per application and the stack has a single point of truth instead of 115 include sites.
Decisions (operator-confirmed 2026-10-24; do not re-litigate)
- One service key,
stack. There is no separate key for front proxy, backend or full. What the stack does for an application is derived automatically from the files the role ships and the services the role enables. - Declared per application, literally. Every consumer role declares
stack: {enabled: true, shared: true}in its ownmeta/services.yml. The values are fixed literals, not'<role>' in group_namesexpressions. A central per-category declaration and theadd_hostinjection used for OpenResty were rejected. - Front proxy is triggered by OpenResty. An application gets a vhost exactly when its own
meta/services.ymlenables and sharesopenresty. Today no application declares that key (onlysvc-prx-openrestydoes), so every role that needs a vhost gets the new key. - Database is triggered by the database service. An application gets its database exactly when it enables and shares
postgresormariadb(55 roles already declare one of them). Object storage and engines (sys-svc-rdbms, objstore, engine includes ofsys-stk-backend) follow their own existing service keys the same way. - Completely without includes. No role includes, imports or calls
sys-stk-full,sys-stk-backendorsys-stk-front-proxyanymore. The three roles are removed. The stack drives the per-application work itself. - Runs before the application role, in the dependency/service phase, once for all applications. The
stackservice is loaded bysys-service-loaderlike the other shared services and processes every stack consumer of the play in one batch invocation. The application roles run afterwards and contain only application-specific configuration. - Per-vhost parameters move into meta, per domain, without exceptions. Every value an include site passes today moves into the role's meta, keyed by domain. This includes the multi-vhost roles (Matrix, Stalwart, Bluesky and the others listed below).
- All roles in one change. All ~94 consumer roles are migrated in this requirement. There is no pilot subset.
- Definition of done: code complete,
make testgreen, and a local compose measurement againstorigin/mainthat shows the saving.
Codebase facts (verified on origin/main 6997dd3dbb, 2026-10-24; build on these)
How the stack is entered today
- Mechanism: dynamic
include_rolefrom the application's own tasks only. Nometa/main.ymldependency, noimport_role, no central loader. - Call sites:
sys-stk-full78 (in 78 roles),sys-stk-front-proxy24 external plus 1 insidesys-stk-full(18 roles),sys-stk-backend13 external plus 1 insidesys-stk-full(12 roles). 94 distinct consumer roles in total, allweb-app-*orweb-svc-*. compose_handlers_flush: of the counted full/backend call sites, 29 passtrueand 42 passfalse.falsedefers the compose flush into the application role.sys-stk-full/tasks/main.yml::3-16freezes the application context;:18-28includes front-proxy withapply: delegate_to: groups['svc-prx-openresty'][0], run_once: true, only when that group is non-empty;:30-34then includes backend.sys-stk-front-proxy/tasks/main.yml(one vhost per call)::4-52freezes domain, service key, ports, conf path (lookup('nginx','files.domain')), probe URL and accepted status codes;:54-57sys-svc-nss;:59-61sys-stk-front-base;:63-69sys-front-tls(letsencrypt or selfsigned, thensys-svc-certs);:71-76sys-front-inj-all(webserver core, cdn, inj-logout/dashboard/css/matomo/javascript);:78-93template choice (the role's owntemplates/proxy.conf.j2, else the basic vhost);:95-109vhost render withnotify: reload-openresty;:111-154probe, restart decision and twometa: flush_handlers.sys-stk-backend/tasks/main.yml::2comment# run_once_sys_stk_backend: disabled;:4-10database consumer resolution;:21-27sys-svc-rdbms;:29-38objstore;:40-45engine;:47-52sys-svc-composewithcompose_handlers_flush: false;:54-60Keycloak SSO proxy render;:62-66re-owns the compose handler topics;:68-70flush;:72-77dedicated-DB realign onSTACK_HOSTwithrun_once.- Per-vhost parameters passed at call sites:
domain,http_port,proxy_internal_port,webserver_proxy_service_key,webserver_vhost_flavour,webserver_websocket_location,webserver_client_max_body_size,webserver_extra_configuration. - Multi-vhost call patterns: loops with
loop_var: domainin mastodon00_core.yml:6, peertube:6, wordpress:10, joomla:4; list loops in minio:21, seaweedfs:40; several explicit calls in bluesky:58/67/88, matrix compose04_webserver.yml:8,17and ansible flavor05_proxy_wiring.yml:132,140,161;web-svc-html :8andweb-svc-file :8call it inside aname: "{{ item }}"loop together with dev-git; stalwart:90andweb-svc-cdn :19call front-proxy in addition to full; confluence, jira and xwiki wrap full in a block guarded byrun_once_<role>.
How a shared service is loaded today (example svc-db-postgres)
playbook.yml:19-20includestasks/stages/01_constructor.yml.01_constructor.yml:45-48setsCURRENT_PLAY_APPLICATIONS;:159-176adds the host to thesvc-prx-openrestygroup when anyweb-*group is non-empty;:187-191opens the preload context;:211-213runssys-service-loader;:215-218closes it.roles/sys-service-loader/tasks/main.yml::18-27ordered list (topologicalrun_aftersort, bucket orderuniversal < workstation < server < web-svc < web-app, seeutils/roles/applications/services/registry.py:373-456);:29-41required services viaquery('service', …) | selectattr('required');:43-45_preload_closure = application_closure(whitelist);:47-56loopsload_service.yml;:58-63runsservice_loader_post_run.load_service.yml:2-7loads a service whenrun_once_<role>is undefined AND (its key is required OR the role is ingroup_names) AND (no whitelist OR the role is in the closure).preload_service.yml:2-21flushes handlers, includestasks/utils/load/app.yml(include_rolewithpublic: false,application_id=<role>,domain=lookup('domain', <role>)), flushes again.svc-db-postgres/tasks/main.yml:2-3runs00_coreonce (utils/once/flag.ymlsetsrun_once_<role>); the per-consumer part re-enters later: application →sys-stk-full→sys-stk-backend:21-27→sys-svc-rdbms:37-49asserts the preload,:51-59re-includessvc-db-postgreswithdatabase_init: true, which runs01_init.ymloutside the run-once guard.- App stage:
tasks/stages/02_server.yml:25-30→tasks/utils/setup/group.yml→tasks/utils/setup/role.yml:2-5includes each role and flushes handlers after each application (:7-8).
Service resolution facts
- A role is discovered as a provider only if its
vars/main.ymldeclaresapplication_id(registry.py:102-122) and its primary entity has a truthyshared, aprovidesor alias entries (:142-148). - Static resolution (
is_explicit_truth,registry.py:41-65) accepts literalTrueor strings containingin group_names; runtime resolution (plugins/lookup/service.py:20-38) takesboolof the rendered value;requiredmeans some deployed application has the key enabled and shared (:77-116). - No
sys-*role is a provider today; thesysandstkcategories areinvokable: false(meta/categories.yml:34-39,99-104), so asys-*role is never ingroup_names. - The three stack roles already have a
meta/services.yml(backend,full,front-proxy), but none is a provider.required_byonly feeds the coverage lint and the post-deploy verifier (cli/meta/roles/services/called/__init__.py), it loads nothing. - Entity-name collisions to avoid:
backendexists inweb-app-kix:90,web-app-erpnext:89,web-app-penpot:88;proxyinkix:84,nextcloud:118,seaweedfs:87,openproject:81. - Literal true/true consumer keys need
# nocheck: dynamic-flagand abond(tests/integration/roles/meta/services/test_dynamic_flags.py,tests/lint/ansible/roles/test_service_bond.py). roles/sys-service-loader/TODO.md:3-4already lists "Implement loading of backend roles" and "Transform to general loader role for full stack".
Ordering and binding constraints
- C1 Stage timing: the loader runs in the constructor stage, before any application stage. The batch must therefore not depend on work an application role performs in its own tasks before today's stack include.
- C2 Handler binding:
sys-svc-compose/handlers/compose.ymlevaluateslookup('container', application_id, …)at flush time (:12-14,30,43-50,66,113) andguard_instance_dirfails on drift (utils/guard_instance_dir.yml:9-18). Every compose flush must happen whileapplication_idnames the application whose files changed. - C3 Context switching: handler loading sets
application_idto the handler role (tasks/utils/load/handlers.yml:8) and rdbms switches it to the provider (sys-svc-rdbms:51-59); the stack freezes its context first (front-proxy:4-46,full:3-16). - C4 Injection before render: the
sys-front-inj-*roles accumulateinj_enabledandwebserver_csp_hashes_extra_by_appthat the vhost render consumes (inj-all:13-51,front-proxy:95); Matomo injection needs Matomo deployed and its token present (sys-front-inj-matomo/tasks/main.yml:10-27). - C5 Probe after reload: front-proxy probes each vhost right after the reload (
:111-154); application tasks that run after the stack need their vhost live (BigBlueButton Greenlight wait, Matomo API calls). - C6 Front proxy flush: front-proxy's
meta: flush_handlers(:129-130,152-154) flushes every pending handler of the play; this is whysvc-ai-mcp-adapter/tasks/instance.yml:10-16notifies nothing during staging. - C7 Placement: front-proxy runs on the OpenResty host via delegate plus
run_once(full:21-23); compose prep and up only whereIS_STACK_HOST(sys-svc-compose/tasks/main.yml:25-34,group_vars/all/18_swarm.yml:11-19); NFS subdirs on every swarm node (main.yml:29-30); swarm deploys through a notify (utils/up.yml:44-49); dedicated-DB realign onSTACK_HOSTwithrun_once; the Postgres container is re-resolved per node (svc-db-postgres/tasks/01_init.yml:16). - C8 Preload assertion:
sys-svc-rdbms:37-49asserts the provider was preloaded;list_or_shoot.yml:23-35behaves differently inside and outside the preload context. - C9 Serial seeding:
baudolo-seedruns withthrottle: 1(svc-bkp-volume-2-local/tasks/03_seed-database-to-backup.yml:57-68). - Known bug to fix on the way:
sys-svc-compose-ca/tasks/main.yml:2-9prints "mark done" but never sets its run-once flag, so it runs 21-25 times per deploy.
Measured cost (echoserver full-6 complete and full-7 partial, feature/updater code, 2026-10-24)
- Time inside stack windows: 2454 s of 5917 s parsed (40 %) in full-6, 1969 s of 6320 s (31 %) in full-7. ROLES RECAP of stack-family roles: 3066 s of 6154 s (49.8 %, includes non-stack users of
sys-svc-compose). - Per application, inside stack windows, excluding compose handlers: 71.7 s (full-6), 73.4 s (full-7). Steady-state means: full 68 to 69 s, front-proxy 28 to 32 s, backend 27 to 51 s.
- Split of stack window time without handlers: per-application work 61.5 to 69.4 %; host-wide work repeated per application 13.0 to 14.5 % (about 9.3 to 10.7 s per application); guard-skip overhead 3.4 to 4.1 %; one-time initialisation billed to the first application 5.4 to 9.4 %; include and dispatch overhead 8.8 to 10.5 %.
- Top repeated host-wide tasks (full-7, count x average): Reload OpenResty 24 x 0.86 s; force-recreate OpenResty 2 x 10.15 s; "Shoot post-load roles" (always skipped) 81 x 0.24 s; Validate OpenResty config 12 x 1.57 s;
sys-svc-cdnhandler overwrite 28 x 0.54 s; Wait for Postgres TCP auth 12 x 1.16 s;resolve_host_cid12 x 1.07 s; "List post-load roles" 81 x 0.14 s; front-base re-import of OpenResty handlers 28 x 0.33 s; Cloudflare zone id checks 28 x 0.32 s plus cache dict 28 x 0.19 s; webserver health routing 31 x 0.23 s; re-own compose handler topics 24 x 0.29 s. (pip install and/etc/baudoloper seed are already fixed on the updater line.) - Expected saving if repeated host-wide work runs once per host: about 3 to 4.5 minutes per full echoserver deploy; ceiling about 6.5 to 9 minutes if guard checks and include dispatch also disappear. Batching vhost rendering and OpenResty reloads (decision 6) is what moves beyond the lower figure.
Target Design (binding where it restates a decision; the rest is the starting proposal)
Provider role
- One provider role owns the
stackservice. It declaresapplication_idinvars/main.ymlso the registry discovers it, and itsmeta/services.ymlprimary entity isstackwithenabled: trueandshared: true. Choose a role name and category thatsys-service-loaderloads in the dependency phase and that does not collide with the entity names listed above. - Host-wide work runs once per host inside the provider (the run-once parts of
sys-svc-compose,sys-stk-front-base,sys-svc-nss,sys-svc-webserver-*,sys-svc-cdn, handler loading, Cloudflare zone cache, service-loader routing).
Consumer declaration
stack:
enabled: true # nocheck: dynamic-flag
shared: true
bond: 1
openresty:
enabled: true # nocheck: dynamic-flag
shared: true
bond: 1
The exact nocheck placement and bond value follow test_dynamic_flags.py and test_service_bond.py. openresty appears only in roles that serve a vhost; postgres/mariadb keys keep their current form.
Per-domain vhost meta
- Every value listed under "Per-vhost parameters passed at call sites" moves into the role's meta keyed by domain (proposal:
meta/server.yml, one entry per domain key the role'slookup('domains', role)returns). Each entry carries the internal port, the proxy service key, the vhost flavour, the websocket location, the client body size and a reference to the role's extra-configuration template, so the provider can render every vhost without call-site variables. - A role's own
templates/proxy.conf.j2stays the template override, resolved by the provider as front-proxy does today (:78-93).
Batch algorithm (one invocation, all applications)
- Collect the stack consumers of the play: applications in
lookup('deployment').running(respecting the whitelist closure) whosestackkey is enabled and shared. - Per consumer and with its
application_idfrozen: stage the compose project (directories,.env, override, compose files, Dockerfiles, build contexts) from the role's templates and files; provision its database through the existing rdbms per-consumer path; provision objstore and engines by their keys. - Per consumer domain: TLS, injection accumulation, vhost render. Then one OpenResty config validation and one reload for the whole batch, followed by the per-vhost probes and the existing stale-config restart decision.
- Per consumer, again with its
application_idfrozen: compose pull, build and up with the handler semantics of today (C2), on the stack host only (C7). - Only then do the application roles run.
Work that preceded the include
Everything an application role does today before its stack include must be available to the provider through templates or meta, never through role tasks that run later. Known cases: svc-ai-mcp-adapter/tasks/instance.yml (MCP adapter build context, 16 providers), roles that render their own Dockerfiles or build contexts before calling the stack, and roles that set facts the stack reads. The implementation must list every such case from the 115 call sites and move it.
Acceptance Criteria
Provider and loading
- One role provides the
stackservice: itsvars/main.ymldeclaresapplication_idand itsmeta/services.ymldeclares the primary entitystackwithshared: trueandenabled: true. sys-service-loaderruns thestackservice in the dependency phase, and the deploy log shows the provider's tasks ahead of everyweb-app-*andweb-svc-*role task.- The
stackprovider is invoked once per play and host and processes every stack consumer of the play in that invocation; its log shows one task header per stage, not one stack block per application. - Host-wide work listed under "Measured cost" runs at most once per host per play.
sys-svc-compose-casets its run-once flag and runs once per host.
Consumers
- Every role that included
sys-stk-full,sys-stk-backendorsys-stk-front-proxyonorigin/maindeclaresstack: {enabled: true, shared: true}in its ownmeta/services.yml. - Every role that serves a vhost declares
openresty: {enabled: true, shared: true}; a role without that key gets no vhost. - A role gets its database exactly when it enables and shares
postgresormariadb; objstore and engines follow their own keys. - No file under
roles/,tasks/,playbooks/orplugins/includes, imports or namessys-stk-full,sys-stk-backendorsys-stk-front-proxy, and the three role directories are removed.
Per-domain data
- Every per-vhost parameter from the call-site list is declared per domain in the role's meta, and the provider reads it only from there.
web-app-matrix(both flavours),web-app-stalwart,web-app-bluesky,web-app-mastodon,web-app-peertube,web-app-wordpress,web-app-joomla,web-app-minio,web-svc-seaweedfs,web-svc-cdn,web-svc-htmlandweb-svc-fileget every vhost from their meta, with no loop or front proxy call in the role.- Every pre-include step found in the 115 call sites (MCP adapter build contexts, role Dockerfiles, facts the stack reads) is provided through templates or meta and is in place before the provider needs it.
Behaviour
- Within one invocation OpenResty validates and reloads once per batch, and a full deploy shows fewer
Reload OpenRestyhandler runs thanorigin/mainfor the same application set. - Compose handlers run with the
application_idof the application whose files changed, andguard_instance_dirnever fails on drift. - Every vhost answers its accepted status codes before the first application role that depends on it runs.
- Swarm deploys keep today's placement: the front proxy on the OpenResty host, compose on the stack host, NFS preparation on every node, dedicated-DB realign on
STACK_HOST.
Tests and lints
- A lint fails when any file names a removed stack role, when a role with a compose project lacks the
stackkey, or when a role with a vhost lacks theopenrestykey. - A lint fails when a role's meta lacks the per-domain vhost data for a domain the role serves.
- Every test that pinned the include shape is updated to the new contract:
tests/unit/python/roles/sys-stk-backend/test_handler_registration_order.py,tests/lint/ansible/roles/test_docker_calls_follow_the_stack.py,tests/lint/ansible/roles/test_stack_vars_resolve_in_their_own_role.py,test_redundant_compose_handlers_flush.py,test_no_leaky_proxy_role_vars.py,test_vhost_cross_cutting_includes.py,meta/test_sso_contract.py,modules/test_compose_repository_url_ref.py,tests/integration/infrastructure/services/test_frontend_spot.py,tests/integration/roles/run_once/test_shared_primary_entity_run_once.py,tests/lint/ansible/roles/test_service_core_first_task_run_once.py,tests/unit/python/roles/web-app-openbao/test_credential_rotation.py. - The post-deploy
required_byverifier (cli/meta/roles/services/called/__init__.py) checks the new provider's task headers instead of the removed roles. make testpasses.
Measurement
- A local compose deploy of the same application set with
full_cycle=trueon this branch and onorigin/mainshows a lower total wall clock on this branch; both durations, the application set and the per-target table are recorded in the pull request. - Every role that is green end to end in compose on
origin/mainstays green end to end in compose on this branch.
Out of scope
- Changing what an application role configures after its stack has run.
- New service keys besides
stackand the per-applicationopenrestykey. - Swarm performance measurement (swarm must keep working; only compose is measured).
Validation
make test
INFINITO_PLAYWRIGHT_KEEP=true make compose-deploy mode=reinstall apps="web-app-gitea web-app-nextcloud web-app-mastodon web-app-matrix web-svc-cdn web-app-bigbluebutton" full_cycle=true variant=0 2>&1 | tee /tmp/compose-deploy-stack-service.log
git grep -nE "sys-stk-(full|backend|front-proxy)" -- roles tasks playbooks plugins
grep -c "RUNNING HANDLER \[.*Reload OpenResty" /tmp/compose-deploy-stack-service.log
Run the same deploy command on origin/main into a second log and compare the PLAYBOOK RECAP wall clock and the reload count.
Implementation order
- Provider role, loader integration and the batch skeleton, with the host-wide work moved out of the three stack roles.
- Per-domain meta schema plus its lint; generate the meta entries for all 94 roles from today's call-site variables.
- Consumer migration in groups (single-vhost web-svc, single-vhost web-app, multi-vhost roles, backend-only roles), removing every include in the same change as its meta.
- Remove the three stack roles; update the pinned tests and the
required_byverifier. make test, then the compose measurement againstorigin/main.
Commit Policy
One concern per commit: provider and loader, meta schema and lint, each migration group, removal and test updates, measurement evidence in the pull request. No commit leaves make test red for the files it touches.