# Per-Role Meta Layout ๐Ÿ—‚๏ธ This page describes the on-disk shape of every role's metadata. All role-owned metadata lives under `roles//meta/.yml`. ## File Layout ๐Ÿ“ | File | Contents | |--------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------| | `meta/main.yml` | Ansible Galaxy metadata + Ansible `dependencies:`. No project-internal `run_after:` / `lifecycle:` keys, no `logo:` / `homepage:` / `video:` / `display:`. | | `meta/services.yml` | Per-entity service config. **File root IS the services map** keyed by ``. No `compose:` and no `services:` wrapper. | | `meta/server.yml` | CSP, `domains`, `status_codes`, plus per-role `networks.local.{subnet,dns_resolver}`. File root IS `applications..server`. | | `meta/rbac.yml` | RBAC declarations. File root IS `applications..rbac`. | | `meta/volumes.yml` | Compose volumes. File root IS the volumes map keyed by volume name. No `compose:` and no `volumes:` wrapper. | | `meta/addons/.yml` | Optional. One file per addon (file root IS the addon spec; filename stem = addon id). See [Unified Addons](#unified-addons-metaaddons-) below. | | `meta/users.yml` | Role-local user definitions. File root IS the users map (no `users:` wrapper). | | `meta/secrets.yml` | Credential schema definitions and runtime credential values. | | `meta/info.yml` | Optional. Descriptive role-level metadata (`logo`, `homepage`, `video`, `display`). File root IS `applications..info` (no `info:` wrapper). | | `meta/variants.yml` | Optional. Variant overrides deep-merged over the assembled application payload (used by `svc-ai-ollama`, `web-app-phpmyadmin`). | Ansible only auto-loads `meta/main.yml`. Every other `meta/.yml` is read by the project's own loaders (`utils/cache/applications.py`, `utils/cache/users.py`, `utils/manager/inventory.py`). ## File-Root Convention ๐Ÿงท Every `meta/.yml` (except `meta/main.yml`, which keeps Galaxy semantics, and `meta/secrets.yml`, which is processed by `apply_schema()`) follows the rule: > **The file's content IS the value of `applications..`. There is NO wrapping key matching the filename.** So `meta/services.yml` MUST NOT have a top-level `services:` key wrapping its content. `meta/volumes.yml` MUST NOT have a top-level `volumes:` key, and the same rule applies to `meta/server.yml`, `meta/rbac.yml`, `meta/users.yml`, and `meta/info.yml`. The filename alone fixes the path prefix in the materialised application tree, which keeps consumer paths short and predictable (no redundant `compose.โ€ฆ` prefixes). ## Materialised Paths ๐Ÿ”— Consumers read the assembled application payload through `lookup('applications', '')` or `lookup('config', '', '')`. The paths are: | Source | Materialised path | |---------------------------------------|--------------------------------------------------| | `meta/services.yml..<โ€ฆ>` | `services..<โ€ฆ>` | | `meta/volumes.yml.` | `volumes.` | | `meta/services.yml...<โ€ฆ>` | `services...<โ€ฆ>` | | `meta/secrets.yml.credentials.` | `secrets.credentials.` | `secrets.credentials.*` paths are populated by `apply_schema()` at `applications..secrets.credentials.<โ€ฆ>`, so the credential file obeys the same file-root rule as every other `meta/.yml`. ## Services Inlining Rule ๐Ÿ“ฅ All non-compose top-level keys (everything except `compose:`, `server:`, `rbac:`, and `credentials:`) MUST be inlined into `meta/services.yml` under `.`, where `` is the value returned by `get_entity_name(role_name)`. Inlined keys observed today (non-exhaustive): `plugins`, `plugins_enabled`, `email`, `ldap`, `accounts`, `scopes`, `alerting`, `languages`, `company`, `default_quota`, `legacy_login_mask`, `site_name`, `token`, `modules`, `network`, `performance`, `preload_models`, `provision`, `features`. `addons` is **not inlined** under the primary entity: role-level extensions are a first-class, per-file topic under `meta/addons/` (see [Unified Addons](#unified-addons-metaaddons-)). The same applies to a role that spells the concept `plugins`/`extensions`/`modules`/`mu_plugins`: those declarations live under `meta/addons/`. `compose.volumes:` is **not** inlined into services. It lives in its own `meta/volumes.yml` (volumes are role-wide, not per-service). ### Worked Example: `web-app-matomo` `get_entity_name('web-app-matomo') == 'matomo'`, so every non-compose top-level key (`site_name`, `performance`, โ€ฆ) is inlined under `matomo.`: ```yaml # roles/web-app-matomo/meta/services.yml (file root IS the services map) matomo: image: matomo site_name: "{{ ... }}" performance: workers: 4 # roles/web-app-matomo/meta/volumes.yml (file root IS the volumes map) data: matomo_data ``` ## Schema Format: `meta/secrets.yml` ๐Ÿ—๏ธ `meta/secrets.yml` consolidates two structures under the `credentials:` top-level key: 1. The credential **schema definitions** (flat keys, e.g. `alerting_telegram_bot_token: { description, algorithm, validation }`). 2. The credential **runtime values** (nested keys, e.g. `recaptcha.key`, `recaptcha.secret`). The unified schema supports: - **Nested keys.** Both flat and nested credential keys are accepted, so e.g. `recaptcha.key` and `recaptcha.secret` remain nested. - **`algorithm:` defaults to `plain`** when the field is omitted. - **`type:` (optional)** is one of `string`, `integer`, `boolean`. A supplied value of another type aborts inventory creation. - **`regex:` (optional)** is a pattern the supplied value MUST match in full (`re.fullmatch`). Anchors are implicit; `sk-[A-Za-z0-9]{16,}` needs no `^` or `$`. - **`default:` (optional)** is a Jinja string used as the credential's value when the inventory does not provide one. - `default:` is **NOT rendered at inventory creation time.** The literal Jinja string is written verbatim into the inventory so that referenced variables (`CAPTCHA.RECAPTCHA.KEY`, `lookup(...)`, โ€ฆ) resolve only at deploy/runtime when those variables are actually defined. - `default:` values are **NOT validated.** `validation:` only applies to user-provided values, so the schema default is exempt. - When `default:` is present, the credential generator MUST NOT generate a new value via `algorithm:`. It writes the literal `default:` string verbatim. ### Worked Example: runtime credentials in `meta/secrets.yml` ```yaml # roles/web-app-keycloak/meta/secrets.yml credentials: recaptcha: key: description: "Google reCAPTCHA site key." algorithm: plain default: "{{ CAPTCHA.RECAPTCHA.KEY | default('') }}" secret: description: "Google reCAPTCHA secret key." algorithm: plain default: "{{ CAPTCHA.RECAPTCHA.SECRET | default('') }}" ``` ### What is checked, and when ๐Ÿ”Ž `type:` and `regex:` are enforced by `validate_supplied_value()` in [inventory.py](../../../../../utils/manager/inventory.py) at inventory-creation time, on every path where a value arrives from outside: | Source | Checked | |---|---| | `--set applications..secrets.credentials.=` | yes | | A value already present in the inventory | yes | | The literal written from `default:` | no | | A value produced from `algorithm:` | no | A generated value is exempt because the algorithm that produced it is named by the same schema, and `generate_value()` takes no schema argument. An empty string means "not configured" and passes every check, which is what makes an optional provider key expressible: declare the shape, leave the value empty, and the consumer gates on emptiness. `validation.min_length` is declared in many role schemas but enforced nowhere. Do not read it as a guarantee. ### Worked Example: an optional external key ```yaml # roles/svc-ai-litellm/meta/secrets.yml credentials: openai_api_key: description: "OpenAI API key; empty leaves the openai/* models unpublished" type: "string" regex: "\\S{20,}" default: "{{ API.openai.api_key }}" ``` The `default:` points at the central `API` block in [group_vars/all/18_api.yml](../../../../../group_vars/all/18_api.yml), so an operator fills the provider in one place while the role keeps the shape the value must have. ### Write a pattern for the paste, not for the vendor ๐Ÿšซ `regex:` MUST NOT pin a vendor's key prefix. OpenAI moved issuance from `sk-` to `sk-proj-` without notice, and Anthropic issues `sk-ant-api03-`; a schema that encoded either would reject a valid key and abort inventory creation, which is a hard failure for a value that was correct. Write the pattern against the mistakes a paste makes โ€” a trailing newline, an embedded space, a truncated copy, a `` โ€” and leave the provider's format alone. A key pasted into the wrong provider's slot is not caught here; it is caught at deploy time, where the route it publishes answers nothing. Flat schema entries keep the same shape: ```yaml # roles/web-app-prometheus/meta/secrets.yml credentials: alerting_telegram_bot_token: description: "Telegram bot token for Alertmanager notifications." algorithm: token validation: non_empty_string ``` If a single role defines the same credential key in both a schema definition and a runtime value, the loader MUST stop and surface the collision instead of silently merging. ## Per-Role Networks: `meta/server.yml.networks` ๐ŸŒ `networks:` is a top-level section of each role's `meta/server.yml`. The file-root convention applies: there is no wrapping `server:` key, and the file content IS `applications..server`. ```yaml # roles//meta/server.yml # ... existing csp / domains / status_codes ... networks: local: subnet: 192.168.101.112/28 # required; CIDR of the role's docker network dns_resolver: 192.168.102.29 # optional, only when a fixed DNS resolver IP is needed (today: mailu) ``` The role's name is implied by the path. There is NO `web-app-` key inside the file. The materialised path is `applications..networks.local.{subnet,dns_resolver}`. ## Per-Entity Ports: `meta/services.yml..ports` ๐Ÿšช Ports belong to the service entity that exposes them. All port data lives under `.ports` in `meta/services.yml` (no `ports:` section in `meta/server.yml`). ```yaml # roles//meta/services.yml : image: ... version: ... ports: internal: : # internal container port (category-keyed) local: : # localhost-bound host port public: : # public-facing port relay: # for port-ranges (coturn, BBB, nextcloud TURN) start: end: onion: : # forward this category over the node onion ``` ### `internal` / `local` / `public` Split ๐Ÿงญ | Slot | Meaning | |------------|---------------------------------------------------------------------------------------------------------------| | `internal` | **Internal container port.** Lives inside the container's network namespace, addressed by other containers on the same role-local network. NOT a host-bound port. Multiple roles MAY legitimately declare the same value (e.g. several nginx-based apps with `internal: { http: 80 }`). | | `local` | **Localhost-bound host port.** Bound on `127.0.0.1` and only reachable through the front-proxy / SSH tunnels. The OS-level binding namespace is shared across all roles, so `local` values MUST be unique across the whole repo. | | `public` | **Public-facing host port.** Bound on `0.0.0.0` and exposed to the public internet (or to whatever the operator's firewall allows). Same uniqueness rule as `local`. | | `onion` | **Onion opt-in, not a port.** A category-keyed map of booleans naming which of the entity's own categories get a `HiddenServicePort` on the node onion. It declares no numbers: the port is read from `local` when the category is declared there and from `public` otherwise, matching what `container_ports` publishes. Collision detection ignores it. | ### Always Category-Keyed Maps ๐Ÿ—‚๏ธ `ports.internal`, `ports.local` and `ports.public` are **always** category-keyed maps, even when the map has only one entry. Polymorphic int-or-map values are NOT supported. The category names are: `http`, `database`, `websocket`, `oauth2`, `ldap`, `ssh`, `ldaps`, `stun_turn`, `stun_turn_tls`, `federation`, plus the structured `relay` block under `public:`. ```yaml gitea: ports: internal: http: 3000 # category-keyed, even with one entry local: http: 8002 public: ssh: 2201 ``` ### `relay` Port Ranges ๐Ÿ“ก `ports.public.relay`, when present, is a map with two integer keys `start` and `end` directly under `relay` (no nested entity-or-key sub-level), with `start < end`. Only one relay range per entity is supported. ```yaml coturn: ports: public: stun_turn: 3481 stun_turn_tls: 5351 relay: start: 20000 end: 39999 ``` ### `onion` Forwarding Opt-In ๐Ÿง… `ports.onion` names the categories that answer on the node onion. Only the named categories are forwarded. The flag is mandatory, enforced by [test_onion_port_flag.py](../../../../../tests/lint/ansible/services/test_onion_port_flag.py): every category declared under `local` or `public` needs a `true` or a `false`, and every `false` needs `# nocheck: onion-flag` plus the reason it is false. Four groups are settled centrally instead and take no flag: the categories a hidden service cannot carry (`relay`, `media`, `stun_turn`, `stun_turn_tls`), the proxy-fronted `http`, `sso` and `websocket`, the implicit-TLS variants (`smtps`, `imaps`, `pop3s`, `ldaps`), and any entity that already answers the question through `exposed:`. ```yaml mailu: ports: public: smtp: 25 smtps: 465 imap: 143 onion: smtp: true # 25 gets a HiddenServicePort; 465 and 143 do not ``` The forward is per-variant: `meta/variants.yml` may drop the flag, and the role then loses the `HiddenServicePort` in that variant. ### Multi-Entity Roles ๐ŸŽ›๏ธ Each entity carries its own `ports` block: ```yaml # roles/web-app-bluesky/meta/services.yml api: ports: { local: { http: 8030 } } web: ports: { local: { http: 8031 } } view: ports: { local: { http: 8051 } } ``` ### Port Bands ๐Ÿ“Š The per-category port ranges that the suggester proposes from and that the lint check enforces live as a single `PORT_BANDS` map in [08_networks.yml](../../../../../group_vars/all/08_networks.yml). Suggesters and lint pick up new entries automatically, with no second registration step. See `cli contributing network ports suggest` in [port.md](../../../tools/network/port.md). ## `run_after` and `lifecycle` ๐ŸŒฑ For the semantic meaning of each `lifecycle` value (and the criteria a role MUST satisfy to claim a given value) see [lifecycle.md](lifecycle.md). This section only covers the on-disk shape of the two fields. Both fields live on the role's **primary entity** in `meta/services.yml`, where ` = get_entity_name(role_name)`: ```yaml # roles/web-app-gitea/meta/services.yml gitea: image: gitea/gitea ports: { ... } run_after: - svc-db-postgres lifecycle: stable ``` For multi-entity roles whose primary entity is not a real compose service (e.g. `web-app-bluesky` โ†’ `bluesky`), the layout uses a dedicated top-level metadata holder: ```yaml # roles/web-app-bluesky/meta/services.yml bluesky: # role-level metadata holder; no compose fields run_after: - web-app-keycloak lifecycle: alpha api: ports: { ... } image: ... web: ports: { ... } image: ... ``` ### Allowed `lifecycle` Values The stages listed in [lifecycle.md](lifecycle.md#stages-). Unknown values fail the lint. ### `run_after` Rules - `run_after:`, when present, is a non-empty list of role names. - Empty `run_after: []` is **forbidden**: omit the key when no constraint exists. - At most one entity per role carries `run_after` and `lifecycle`. Putting these fields on a non-primary entity fails the lint. ### Helper The helper module `utils/roles/meta_lookup.py` exposes `get_role_run_after(role) -> list[str]` and `get_role_lifecycle(role) -> str | None`. All consumers of these fields use the helper instead of hand-rolled derivations. The helper returns `[]` / `None` gracefully when `meta/services.yml` is absent or when the field is not set. ## Descriptive Role-Level Metadata: `meta/info.yml` ๐Ÿ“ Project-internal descriptive metadata (icon, upstream homepage, demo video, dashboard display flag) lives in an OPTIONAL `meta/info.yml`, not nested inside `galaxy_info:`. The file-root convention applies: there is no wrapping `info:` key, and the file content IS `applications..info`. ```yaml # roles/web-app-nextcloud/meta/info.yml logo: class: fa-solid fa-cloud homepage: https://nextcloud.com/ video: https://youtu.be/3jcYJGQgenI?si=FDmoMSrAb9_WvviC ``` ### Allowed Fields | Field | Type | Semantics | |------------|--------|--------------------------------------------------------------------------------------------------------------------------| | `logo` | map | UI icon descriptor. Today only `class:` (FontAwesome). Future fields (`source:`, `svg:`) require an explicit allowlist update in the lint. | | `homepage` | string | Upstream project URL: the canonical landing page of the software the role deploys. | | `video` | string | Upstream demo / overview video URL. | | `display` | bool | Default `true`. `false` opts the role out of dashboards / cards / apps grids. | The lint (`tests/lint/ansible/roles/meta/test_info.py`) rejects any other top-level key so the file does not become a dumping ground. ### Optionality `meta/info.yml` is OPTIONAL. A role with none of the four fields does not grow the file. Consumers MUST treat a missing file or missing field as absent / default, and `display` defaults to `true`. ### Materialised Path ``` applications..info.{logo,homepage,video,display} ``` The dashboard's `web-app-dashboard/lookup_plugins/docker_cards.py` reads `logo.class` and `display` from this location, while `description` and `galaxy_tags` continue to come from `galaxy_info` (Galaxy-spec fields). ## Unified Addons: `meta/addons/` ๐Ÿงฉ Role-level extensions, whatever a given app calls them natively (`addon`, `plugin`, `mu_plugin`, `extension`, `module`, or a network/appservice `bridge`), are declared through one unified contract under `meta/addons/` (requirement 026). The per-app spelling under the primary service entity (`addons` / `plugins` / `modules` / `mu_plugins`) maps onto this single contract. Each addon lives in its own file `meta/addons/.yml`: **the file root IS the addon spec, there is NO wrapping `:` key, and the filename stem supplies the addon id.** All files in the directory are collected into `applications..addons`, keyed by ``. The materialised path is `applications..addons.`, read via `lookup('config', application_id, 'addons.')`. ```yaml # roles/web-app-friendica/meta/addons/ldapauth.yml (file root IS the addon spec) enabled: "{{ lookup('config', application_id, 'services.ldap.enabled') | bool }}" required: false # true for core components that must always install mechanism: addon # addon | plugin | mu_plugin | extension | module | bridge source: upstream # upstream | bundled | vendored | built bridges: # optional; in-repo service keys declared in meta/services.yml - ldap version: "" # optional upstream pin; "" tracks the app default group: optional # optional grouping label (e.g. odoo core/optional) update: monitored: true # optional; external tests check latest versions catalog: friendica-addons # optional; supported upstream catalog adapter upstream_id: ldapauth # optional; defaults to the addon id config: {} # optional, opaque, role-interpreted runtime payload ``` ### Fields | Field | Required | Type | Default | Notes | |-------|----------|------|---------|-------| | `mechanism` | yes | enum | โ€” | `addon` / `plugin` / `mu_plugin` / `extension` / `module` / `bridge`. Selects the install path. `bridge` denotes a network/appservice bridge addon (distinct from the `bridges:` field). | | `source` | yes | enum | โ€” | `upstream` / `bundled` / `vendored` / `built`. | | `enabled` | no | bool \| Jinja | `false` (or `true` when `required: true`) | Normalised by the loader. When the addon bridges exactly one service, reference that service's `enabled` flag instead of re-deriving group membership. | | `required` | no | bool | `false` | `true` = baseline install contract: always installed, MAY omit `enabled`, MUST NOT set `enabled: false`. Also gates install-failure: a failed `required: true` addon hard-fails the play; a failed `required: false` addon warns, is skipped, and the play continues. | | `bridges` | no | list | โ€” | Non-empty list of in-repo service keys; each MUST resolve to a service block in the same role's `meta/services.yml`. Omit when there is no cross-role dependency. | | `version` | no | string | `""` | A pin MUST be a quoted string, never an unquoted number. `""` tracks the app default. | | `group` | no | string | โ€” | Free grouping label (e.g. Odoo `core`/`optional`). MUST NOT affect enablement. | | `update` | no | map | โ€” | `monitored` (bool, default `false`), `catalog` (a supported adapter), `upstream_id` (defaults to the addon id). A `version` pin MUST also name its version source here, or carry `# nocheck: unwatched-version` with a reason: either `type` with its fields, or `monitored: true` with `catalog: github-releases` and the `owner/repository` as `upstream_id`. See [Declared version sources](../../../../../cli/contributing/update/README.md#declared-version-sources). | | `config` | no | map | โ€” | Opaque, role-interpreted runtime payload. Lint does not constrain its inner shape beyond requiring a mapping. Secrets inside `config` MUST resolve through `lookup('config', application_id, 'secrets.credentials.')` and MUST NOT be inlined literally. | Any credential an addon needs is declared in `meta/secrets.yml` `credentials:` and read via `lookup('config', application_id, 'secrets.credentials.')`. There is no new secret store. ### Bridges: in-repo dependency vs network bridge Two distinct meanings of "bridge" MUST NOT be conflated: - the **`bridges:` field** names an in-repo cross-role service dependency (e.g. an addon that talks to `svc-db-openldap` via the role's `ldap` service block). Each listed key MUST be a service block in the same role's `meta/services.yml`; lint fails otherwise. - **`mechanism: bridge`** marks an addon that *is* a network/appservice bridge to an external system (e.g. a Matrix `mautrix` bridge to WhatsApp). A single addon MAY be both: a `mechanism: bridge` addon MAY also declare a `bridges:` dependency on an in-repo service it relies on. Front-door auth gates (e.g. an oauth2-proxy `sso` service in front of the vhost) are NOT addon bridges unless the addon itself talks to that service; they stay in `meta/services.yml` and MUST NOT appear under `bridges:`. ### Loader, lint, and the materialised path - The application loader (`utils/cache/applications.py`) reads every `meta/addons/.yml` like the other file-rooted meta topics and **normalises the enable state**: `required: true` defaults to enabled, an optional addon defaults to disabled, and an explicit `enabled` value is preserved verbatim. No new repo-wide dictionary is introduced. - Schema validation lives in [`tests/lint/ansible/services/test_addons_schema.py`](../../../../../tests/lint/ansible/services/test_addons_schema.py) and bridge resolution / parity in [`test_addons_bridges.py`](../../../../../tests/lint/ansible/services/test_addons_bridges.py). Suppress with `# nocheck: addon-schema` / `# nocheck: addon-bridge` (comment block above the addon key) or `# nocheck: addon-secret` (on a `config` leaf). - Optional (`required: false`) addons express their enabled/disabled split as a [`meta/variants.yml`](#) axis so the CI matrix exercises both states. > The Ansible-level `plugins/` directory (filters, lookups, modules) is a > different concept from the application-level `addons` topic. See the > [plugins README](../../../../../plugins/README.md) for the distinction. ## Related Pages ๐Ÿ“š - [base.md](base.md) covers the service registration, loading, and injection model. - [email.md](email.md) covers the email lookup contract. - [port.md](../../../tools/network/port.md) describes the `cli contributing network ports suggest` helper. - [address.md](../../../tools/network/address.md) describes the `cli contributing network address suggest` helper.