Files
computer-playbook/docs/contributing/code/services.md
T

252 lines
14 KiB
Markdown

# Service Management
This page documents how services are defined, mapped, loaded, and injected at deploy time.
## What Is a Service?
A *service* is a shared dependency that one or more applications can opt into via their `compose.services.<key>` configuration.
Each service has:
- a **key** — the short name used inside `compose.services` (e.g. `cdn`, `css`, `logout`, `ldap`)
- a **role** — the Ansible role that provisions it (e.g. `web-svc-cdn`, `svc-db-openldap`)
- a **type** — either `frontend` (loaded early in the server stage) or `backend` (loaded by `sys-stk-backend`)
- an optional **canonical** — required when multiple keys share the same role (see below)
An application declares it needs a service by setting `compose.services.<key>.enabled: true` in its config.
A service is considered *shared* (reusable across applications) when it also sets `shared: true`.
## SPOT: Service Registry
The canonical service key → role mapping lives in [`group_vars/all/20_services.yml`](../../../group_vars/all/20_services.yml).
This is the **single source of truth** for all service mappings — add a new service here to register it system-wide.
It is an Ansible `group_vars` file, so the `SERVICE_REGISTRY` variable is automatically available in all plays.
The Python CLI resolver reads the same file — no hardcoded mappings exist anywhere else.
```yaml
# group_vars/all/20_services.yml
SERVICE_REGISTRY:
matomo:
role: web-app-matomo
type: frontend
# Multiple keys can point to the same role.
# The primary key has no canonical field; aliases declare canonical: <primary-key>.
cdn:
role: web-svc-cdn
type: frontend
css:
role: web-svc-cdn
type: frontend
canonical: cdn # alias — role-based lookup returns id: cdn
javascript:
role: web-svc-cdn
type: frontend
canonical: cdn # alias — role-based lookup returns id: cdn
ldap:
role: svc-db-openldap
type: backend
database:
role_template: "svc-db-{type}" # {type} taken from compose.services.database.type
type: backend
```
### Shared-role entries and the `canonical` field
A role can be shared by multiple service keys (e.g. `cdn`, `css`, and `javascript` all provision `web-svc-cdn`).
Each service key represents a distinct *feature* that triggers CDN loading when an app enables it,
but role-based reverse lookup (`lookup('service', 'web-svc-cdn')`) must return a single, deterministic `id`.
Rules:
- The **primary key** has no `canonical` field — it is the canonical.
- Every **alias key** that shares the same role MUST declare `canonical: <primary-key>`.
- The canonical target must exist, share the same role, and must NOT itself be an alias (no chaining).
- A key whose role is unique (not shared) MUST NOT declare `canonical`.
These rules are enforced by the lint test [`tests/integration/test_services_canonical.py`](../../../tests/integration/test_services_canonical.py).
## Service Config per Role
Every role that provides a service defines its own compose.services block in:
```
roles/<role-name>/config/main.yml
compose:
services:
<key>:
enabled: true
shared: true
```
## Two Independent Mechanisms
**Loading a service** and **injecting a service into an app's nginx config** are two separate concerns driven by the same config flag `compose.services.<key>.enabled`.
### 1. Loading (deploy the container)
Decides whether the service container is deployed at all.
#### Frontend services — one early global SPOT
[`roles/sys-utils-service-loader/tasks/main.yml`](../../../roles/sys-utils-service-loader/tasks/main.yml) is the **SPOT** for shared frontend service loading.
It is called once from [`tasks/stages/02_server.yml`](../../../tasks/stages/02_server.yml) before `web-svc`
and `web-app` roles start. It iterates over every `type: frontend` entry from `SERVICE_REGISTRY`.
For each service it:
1. Checks `lookup('service', item.key).needed` — is the service needed by any deployed app?
2. Checks `run_once_*` — has the service already been loaded this run?
3. Performs a reachability check via [`load_frontend_service.yml`](../../../roles/sys-utils-service-loader/tasks/load_frontend_service.yml)
4. Loads the service via `utils/load_app.yml` if the endpoint is not reachable (status ≠ 200)
Because this happens once before any normal `web-svc` or `web-app` role starts, service order can be controlled
directly by the order of entries in [`group_vars/all/20_services.yml`](../../../group_vars/all/20_services.yml).
#### Backend services — `sys-stk-backend`
[`roles/sys-stk-backend/tasks/main.yml`](../../../roles/sys-stk-backend/tasks/main.yml) loops over a list of backend services and calls
[`_load_service.yml`](../../../roles/sys-stk-backend/tasks/_load_service.yml) for each, using the `service_should_load` lookup (checks `enabled AND shared`).
### 2. Injection (add service to the app's nginx config)
Decides whether a service is referenced inside an individual application's nginx vhost.
Controlled by `inj_enabled`, computed in [`roles/sys-front-inj-all/tasks/main.yml`](../../../roles/sys-front-inj-all/tasks/main.yml):
```yaml
inj_enabled: "{{ applications | inj_enabled(application_id, SRV_WEB_INJ_COMP_FEATURES_ALL) }}"
```
The `inj_enabled` filter ([`roles/sys-front-inj-all/filter_plugins/inj_enabled.py`](../../../roles/sys-front-inj-all/filter_plugins/inj_enabled.py))
reads `compose.services.<key>.enabled` for the **current app** and returns a dict of booleans.
Each injection role is then included conditionally:
| Condition | Role loaded |
|---|---|
| `inj_enabled.matomo` | `sys-front-inj-matomo` — adds Matomo tracking snippet |
| `inj_enabled.logout` | `sys-front-inj-logout` — adds logout proxy endpoint |
| `inj_enabled.dashboard` | `sys-front-inj-dashboard` — adds dashboard iframe notifier |
| `inj_enabled.css` | `sys-front-inj-css` — adds corporate CSS |
| `inj_enabled.javascript` | `sys-front-inj-javascript` — adds JS injection |
#### CSS injection (`inj_enabled.css`)
`sys-front-inj-css` injects a shared corporate stylesheet into the application's nginx vhost.
The stylesheet itself lives in each role under `files/style.css` and is served via the CDN service.
It applies a minimal token-based theme (using the `--color-01-*` palette) so all apps share a consistent look.
See the agent authoring guide for `style.css`: [`docs/agents/files/role/style.css.md`](../../../docs/agents/files/role/style.css.md)
#### JavaScript injection (`inj_enabled.javascript`)
`sys-front-inj-javascript` injects a shared JavaScript bundle into the application's nginx vhost.
The script lives in each role under `files/javascript.js` (or `templates/javascript.js.j2` for templated values).
It handles browser-side integration that cannot be solved via configuration or CSS alone — for example DOM mutations required to wire up dashboard or logout behaviour.
See the agent authoring guide for `javascript.js`: [`docs/agents/files/role/javascript.js.md`](../../../docs/agents/files/role/javascript.js.md)
**`lookup('service', ...)` does NOT add a service to the current domains.**
It only controls whether the service container is deployed.
The nginx domain injection is handled entirely by `inj_enabled` + the injection roles above.
## `load_app.yml` — run-once loader
File: [`tasks/utils/load_app.yml`](../../../tasks/utils/load_app.yml)
Loads a role exactly once by setting `application_id` to the target role name and calling `include_role`.
The `run_once_<role_underscored>` flag is set inside the loaded role itself (via `tasks/utils/once/flag.yml`)
to prevent duplicate loads across multiple app invocations.
## Lookup Plugins
### `service` — resolve a service and return its deployment flags
File: [`plugins/lookup/service.py`](../../../plugins/lookup/service.py)
```yaml
lookup('service', 'matomo') # look up by service key
lookup('service', 'web-app-matomo') # look up by role name → returns canonical key
lookup('service', 'css') # alias key → returns id: css
lookup('service', 'web-svc-cdn') # role lookup → returns id: cdn (canonical)
```
- Reads `applications`, `group_names`, and `SERVICE_REGISTRY` (from `group_vars/all/20_services.yml`) from Ansible variables
- Accepts either a service **key** (e.g. `matomo`, `css`) or a **role name** (e.g. `web-app-matomo`, `web-svc-cdn`) as the term
- When looking up by **key**, returns the entry for that exact key (`id` = the key you passed)
- When looking up by **role name**, returns the entry for the **canonical key** of that role
- Returns a dict per term:
| Field | Type | Meaning |
|-----------|--------|---------|
| `id` | string | Service key — equals the looked-up key, or the canonical key for role-based lookup |
| `role` | string | Provider role name (e.g. `web-app-matomo`) |
| `enabled` | bool | Any deployed app has `compose.services.<key>.enabled: true` |
| `shared` | bool | Any deployed app has `compose.services.<key>.shared: true` |
| `needed` | bool | Any deployed app has **both** `enabled` and `shared` (direct or transitively) |
- **Transitive:** `needed` follows chains of enabled services recursively, but the final target must have both `enabled: true` AND `shared: true`
- **Limitation:** transitive resolution only fires when the service key in `compose.services` equals a full application ID.
Short keys (e.g. `collabora`) do not chain to `web-svc-collabora` — only the key `web-svc-collabora` would.
- Does **not** control nginx injection — see `inj_enabled` above
- Used in: [`roles/sys-utils-service-loader/tasks/main.yml`](../../../roles/sys-utils-service-loader/tasks/main.yml)
- Tests: [`tests/unit/plugins/lookup/test_service.py`](../../../tests/unit/plugins/lookup/test_service.py), [`tests/integration/test_services_resolvable.py`](../../../tests/integration/test_services_resolvable.py)
### `service_should_load` — should this service be loaded for the current app?
File: [`roles/sys-stk-backend/lookup_plugins/service_should_load.py`](../../../roles/sys-stk-backend/lookup_plugins/service_should_load.py)
```yaml
query('service_should_load', service_id, application_id=application_id, service_name=service_name) | first
```
- Checks `enabled AND shared` for a single `application_id`
- Guards against `run_once_*` already set and self-loading (`application_id == service_id`)
- Used in: [`roles/sys-stk-backend/tasks/_load_service.yml`](../../../roles/sys-stk-backend/tasks/_load_service.yml)
- Tests: [`tests/unit/roles/sys-stk-backend/lookup_plugins/test_service_should_load.py`](../../../tests/unit/roles/sys-stk-backend/lookup_plugins/test_service_should_load.py)
### `inj_enabled` filter — which injections are active for this app?
File: [`roles/sys-front-inj-all/filter_plugins/inj_enabled.py`](../../../roles/sys-front-inj-all/filter_plugins/inj_enabled.py)
```yaml
applications | inj_enabled(application_id, feature_list)
```
- Reads `compose.services.<feature>.enabled` for the current `application_id`
- Returns `{feature: bool}` dict used to gate injection roles
- Used in: [`roles/sys-front-inj-all/tasks/main.yml`](../../../roles/sys-front-inj-all/tasks/main.yml)
## Python CLI Resolver
File: [`cli/meta/applications/resolution/services/resolver.py`](../../../cli/meta/applications/resolution/services/resolver.py)
Reads `group_vars/all/20_services.yml` and resolves which provider roles an application needs,
following service dependencies transitively (BFS). Used by the CLI to compute deploy order and
dependency graphs.
- `resolve_direct_service_roles_from_config(config)` — roles needed directly by one app config
- `ServicesResolver.resolve_transitively(role_name)` — full BFS over transitive dependencies
- Tests: [`tests/unit/cli/meta/applications/resolution/services/test_resolver.py`](../../../tests/unit/cli/meta/applications/resolution/services/test_resolver.py)
## Related Files
| File | Purpose |
|---|---|
| [`group_vars/all/20_services.yml`](../../../group_vars/all/20_services.yml) | **SPOT** — service key → role mapping with `type`, optional `canonical` |
| [`roles/sys-utils-service-loader/tasks/main.yml`](../../../roles/sys-utils-service-loader/tasks/main.yml) | **SPOT** — global frontend service loading loop |
| [`tasks/stages/02_server.yml`](../../../tasks/stages/02_server.yml) | Calls the global frontend service loader before `web-svc` and `web-app` roles |
| [`roles/sys-utils-service-loader/tasks/load_frontend_service.yml`](../../../roles/sys-utils-service-loader/tasks/load_frontend_service.yml) | Reachability check + conditional load per frontend service |
| [`roles/sys-front-inj-all/tasks/main.yml`](../../../roles/sys-front-inj-all/tasks/main.yml) | Orchestrates injection for every deployed app |
| [`roles/sys-front-inj-all/filter_plugins/inj_enabled.py`](../../../roles/sys-front-inj-all/filter_plugins/inj_enabled.py) | `inj_enabled` filter — per-app injection flags |
| [`tasks/utils/load_app.yml`](../../../tasks/utils/load_app.yml) | Run-once role loader |
| [`tasks/utils/once/flag.yml`](../../../tasks/utils/once/flag.yml) | Sets `run_once_<role>` fact to prevent duplicate loads |
| [`plugins/lookup/service.py`](../../../plugins/lookup/service.py) | Resolve service by key or role; returns `{id, role, enabled, shared, needed}` |
| [`tests/integration/test_services_resolvable.py`](../../../tests/integration/test_services_resolvable.py) | Integration: all keys/roles in `20_services.yml` resolve correctly |
| [`tests/integration/test_services_canonical.py`](../../../tests/integration/test_services_canonical.py) | Lint: canonical field consistency rules enforced |
| [`roles/sys-stk-backend/lookup_plugins/service_should_load.py`](../../../roles/sys-stk-backend/lookup_plugins/service_should_load.py) | "Should service load for this app?" lookup |
| [`cli/meta/applications/resolution/services/resolver.py`](../../../cli/meta/applications/resolution/services/resolver.py) | Python-side service resolver (CLI use) |
| [`utils/config_utils.py`](../../../utils/config_utils.py) | `get_app_conf()` — hierarchical config accessor |
| [`roles/sys-stk-backend/tasks/main.yml`](../../../roles/sys-stk-backend/tasks/main.yml) | Backend service loading (ldap, ollama, oauth2) |
| [`roles/sys-stk-backend/tasks/_load_service.yml`](../../../roles/sys-stk-backend/tasks/_load_service.yml) | Single-service load helper for backend stack |