Per-Role Meta Layout 🗂️¶
This page describes the on-disk shape of every role’s metadata.
All role-owned metadata lives under roles/<role>/meta/<topic>.yml.
File Layout 📁¶
File |
Contents |
|---|---|
|
Ansible Galaxy metadata + Ansible |
|
Per-entity service config. File root IS the services map keyed by |
|
CSP, |
|
RBAC declarations. File root IS |
|
Compose volumes. File root IS the volumes map keyed by volume name. No |
|
Optional. One file per addon (file root IS the addon spec; filename stem = addon id). See Unified Addons below. |
|
Role-local user definitions. File root IS the users map (no |
|
Credential schema definitions and runtime credential values. |
|
Optional. Descriptive role-level metadata ( |
|
Optional. Variant overrides deep-merged over the assembled application payload (used by |
Ansible only auto-loads meta/main.yml.
Every other meta/<topic>.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/<topic>.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.<app>.<topic>. 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', '<role>') or lookup('config', '<role>', '<dotted.path>').
The paths are:
Source |
Materialised path |
|---|---|
|
|
|
|
|
|
| meta/secrets.yml.credentials.<key> | secrets.credentials.<key> |
secrets.credentials.* paths are populated by apply_schema() at
applications.<app>.secrets.credentials.<…>, so the credential file obeys the
same file-root rule as every other meta/<topic>.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 <primary_entity>.<key>, where <primary_entity> 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).
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.<key>:
# 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:
The credential schema definitions (flat keys, e.g.
alerting_telegram_bot_token: { description, algorithm, validation }).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.keyandrecaptcha.secretremain nested.algorithm:defaults toplainwhen the field is omitted.type:(optional) is one ofstring,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 viaalgorithm:. It writes the literaldefault:string verbatim.
Worked Example: runtime credentials in meta/secrets.yml¶
# 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 at inventory-creation
time, on every path where a value arrives from outside:
Source |
Checked |
|---|---|
|
yes |
A value already present in the inventory |
yes |
The literal written from |
no |
A value produced from |
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¶
# 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, 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 <placeholder> — 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:
# 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.<app>.server.
# roles/<role>/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-<role> key inside the file.
The materialised path is applications.<role>.networks.local.{subnet,dns_resolver}.
Per-Entity Ports: meta/services.yml.<entity>.ports 🚪¶
Ports belong to the service entity that exposes them.
All port data lives under <entity>.ports in meta/services.yml (no ports: section in meta/server.yml).
# roles/<role>/meta/services.yml
<entity>:
image: ...
version: ...
ports:
internal:
<category>: <int> # internal container port (category-keyed)
local:
<category>: <int> # localhost-bound host port
public:
<category>: <int> # public-facing port
relay: # for port-ranges (coturn, BBB, nextcloud TURN)
start: <int>
end: <int>
onion:
<category>: <bool> # forward this category over the node onion
internal / local / public Split 🧭¶
Slot |
Meaning |
|---|---|
|
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 |
|
Localhost-bound host port. Bound on |
|
Public-facing host port. Bound on |
|
Onion opt-in, not a port. A category-keyed map of booleans naming which of the entity’s own categories get a |
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:.
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.
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:
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:.
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:
# 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.
Suggesters and lint pick up new entries automatically, with no second registration step.
See cli contributing network ports suggest in 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.
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 <primary_entity> = get_entity_name(role_name):
# 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:
# 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. 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_afterandlifecycle. 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.<role>.info.
# 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 |
|---|---|---|
|
map |
UI icon descriptor. Today only |
|
string |
Upstream project URL: the canonical landing page of the software the role deploys. |
|
string |
Upstream demo / overview video URL. |
|
bool |
Default |
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.<role>.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/<addon_id>.yml: the file root IS the addon spec, there is NO wrapping <addon_id>: key, and the filename stem supplies the addon id.
All files in the directory are collected into applications.<role_id>.addons, keyed by <addon_id>.
The materialised path is applications.<role_id>.addons.<addon_id>, read via lookup('config', application_id, 'addons.<addon_id>').
# 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 |
|---|---|---|---|---|
|
yes |
enum |
— |
|
|
yes |
enum |
— |
|
|
no |
bool | Jinja |
|
Normalised by the loader. When the addon bridges exactly one service, reference that service’s |
|
no |
bool |
|
|
|
no |
list |
— |
Non-empty list of in-repo service keys; each MUST resolve to a service block in the same role’s |
|
no |
string |
|
A pin MUST be a quoted string, never an unquoted number. |
|
no |
string |
— |
Free grouping label (e.g. Odoo |
|
no |
map |
— |
|
|
no |
map |
— |
Opaque, role-interpreted runtime payload. Lint does not constrain its inner shape beyond requiring a mapping. Secrets inside |
Any credential an addon needs is declared in meta/secrets.yml credentials:
and read via lookup('config', application_id, 'secrets.credentials.<name>'). 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 tosvc-db-openldapvia the role’sldapservice block). Each listed key MUST be a service block in the same role’smeta/services.yml; lint fails otherwise.mechanism: bridgemarks an addon that is a network/appservice bridge to an external system (e.g. a Matrixmautrixbridge 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 everymeta/addons/<id>.ymllike the other file-rooted meta topics and normalises the enable state:required: truedefaults to enabled, an optional addon defaults to disabled, and an explicitenabledvalue is preserved verbatim. No new repo-wide dictionary is introduced.Schema validation lives in
tests/lint/ansible/services/test_addons_schema.pyand bridge resolution / parity intest_addons_bridges.py. Suppress with# nocheck: addon-schema/# nocheck: addon-bridge(comment block above the addon key) or# nocheck: addon-secret(on aconfigleaf).Optional (
required: false) addons express their enabled/disabled split as ameta/variants.ymlaxis so the CI matrix exercises both states.
The Ansible-level
plugins/directory (filters, lookups, modules) is a different concept from the application-leveladdonstopic. See the plugins README for the distinction.