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>
7.8 KiB
Service Management 🧱
This page describes how shared services are declared, discovered, ordered, loaded, and injected.
What Is a Service? 🧩
A service is a reusable dependency that an application enables via services.<service_key>.
Examples:
web-svc-cdnprovides the primary service keycdnweb-app-keycloakprovidesoidcsvc-db-mariadbprovidesmariadb
Each service entry lives in the provider role's own meta/services.yml, where the file root IS the services map keyed by <entity_name> (no compose: and no services: wrapper).
See layout.md for the full per-role meta layout.
Role-Local Service Metadata 🏷️
Service providers are self-describing. The provider role owns:
enabledshared- optional
provides - optional
canonical
Example (file root of meta/services.yml):
keycloak:
enabled: false
shared: true
provides: oidc
Canonical aliases are also role-local:
cdn:
enabled: false
shared: true
css:
enabled: true
shared: true
canonical: cdn
javascript:
enabled: true
shared: true
canonical: cdn
Rules:
- The primary service entry is the role entity name returned by
get_entity_name. provides:is only used when the public service name differs from the entity name.canonical:is only used on alias entries that resolve back to the primary service key.frontendvs.backendis derived from the role name prefix, not stored in config.
Service Discovery 🔍
Service discovery is built from role configs, not from a central registry file.
Primary implementation files:
The discovery layer:
- scans role configs from roles/
- discovers provider entries from
services - derives deploy type and loader bucket from the role name
- resolves
provides:andcanonical: - validates and applies
run_after:frommeta/services.yml.<primary_entity>
Load Order 🧮
main.yml is the single loader entry point for all shared services.
It runs from 01_constructor.yml before the normal application stage.
Global bucket order:
universalworkstationserverweb-svcweb-app
Within the same bucket, ordering is refined by run_after: declared on the provider role's primary entity in meta/services.yml (i.e. services.<primary_entity>.run_after).
Rules:
run_after:entries are role names, not service keys.- Cross-type
run_after:is invalid and fails hard. - Later-bucket dependencies are invalid and fail hard.
- Cross-type service dependencies do not need
run_after:because constructor-stage loading already brings backend services up before normal app deployment.
Loading vs Injection 🔀
Service loading and frontend injection are separate mechanisms.
Loading 📥
Loading decides whether the provider role is deployed at all.
- queries the ordered discovered service list
- checks
lookup('service', service_key).required - skips roles already protected by
run_once_* - loads services through load/app.yml
Frontend service probe/load helper:
Injection 🔌
Injection decides whether a deployed app gets extra nginx integration such as dashboard, logout, CSS, or JavaScript hooks.
This stays in:
Injection still reads the current app's services.<feature>.enabled flags.
It does not load provider roles.
Lookup Plugins 🔎
service 🧷
File: service.py
Examples:
lookup('service', 'matomo')
lookup('service', 'sso')
lookup('service', 'web-svc-cdn')
Returns:
idroleenabledsharedrequiredis True when some deployed app has the service with bothenabled: trueANDshared: true, directly or transitively through its own enabled service dependencies. This is the flag consumers should gate on when deciding whether to load, integrate with, or configure the service. A plainenabledflag is not enough on its own, because a service that is enabled but never shared with another app is not actually contractually required by anyone.
Behavior:
- accepts either a service key or a provider role name
- resolves aliases through
canonical - resolves provider roles through discovered primary service keys
- computes
requiredtransitively from enabled shared services
service_registry 📚
File: service_registry.py
Examples:
query('service_registry') | first
query('service_registry', 'ordered') | first
Modes:
- default: full discovered registry mapping
ordered: ordered primary service entries for the service loader
applications_current_play 🧭
File: applications_current_play.py
Builds the current-play application set including:
- group-selected roles
- transitive shared service dependencies
- meta dependencies
Database Services 🗄️
Relational databases are regular services:
svc-db-mariadbprovidesmariadbsvc-db-postgresprovidespostgres
Applications express database choice directly via the file root of meta/services.yml:
mariadb:
enabled: true
shared: true
or:
postgres:
enabled: true
shared: false
The lookup('database', ...) API is the convenience accessor for database connection values; it resolves the active direct database service from the role-local keys above.
Related Files 📁
| File | Purpose |
|---|---|
| service_registry.py | Service discovery, provides, canonical, bucket detection, run_after ordering |
| service_registry.py lookup | Exposes the discovered registry and ordered provider list to Ansible |
| service.py | Resolves service flags and transitive need |
| applications_current_play.py | Builds the current play app graph with shared service deps |
| main.yml | Single shared-service loader entry point |
| load_service.yml | Per-service load helper used by the central service loader |
| 01_constructor.yml | Calls the service loader during constructor |
| load/app.yml | Run-once role loader |
| test_service_registry.py | Unit tests for discovery, buckets, and run_after ordering |
| test_service.py | Unit tests for lookup('service', ...) |
| test_resolvable.py | Integration checks for discovered service resolution |
| test_canonical.py | Canonical alias consistency checks |
| test_transitive_dependencies.py | Integration coverage for transitive dependency resolution |