Files
computer-playbook/docs/contributing/design/role/services/layout.md
kevinveenbirkenbachandClaude Opus 5.5 739c78bad1 feat(update): bump addon pins through their declared source
The version-source updater only read meta/services.yml. An addon pinned in
meta/addons/<id>.yml stayed where it was: the ONLYOFFICE connector for
Nextcloud sat on v10.1.2, which does not install on Nextcloud 35, until
someone bumped it by hand.

The updater now reads addon files too. An addon declares its source in the
`update:` block it already has, beside monitored, catalog and upstream_id.
A monitored addon of the github-releases catalog needs nothing more: its
upstream_id names the repository whose tags are its versions. The
update-version-sources job opens the PRs, one per role.

Every pinned addon is wired:

- nextcloud onlyoffice carries its own version pin now. Its archive URL
  stays a literal that carries the pin, because
  test_pinned_addon_archives_available reads it as written
- wordpress mcp-adapter, moodle auth_oidc and jellyfin sso-authentication
  follow the tags of their repository, moodle within its v4.5 series
- the three xwiki extensions read their Maven metadata
- pretix-oidc reads the PyPI simple index. The JSON API answers 404 behind
  the package cache the test stack routes pypi.org through
- joomla's plugin is built here and jellyfin's ldap-authentication is bound
  to the md5 of its archive; both are opted out with that reason

A bump rewrites the pin and the config.archive line together, and an
archive that does not carry the pin fails test_version_keys, so the two
cannot drift apart again. test_version_pins_are_watched covers addon pins
as well: a new one without a source or a reasoned opt-out fails the lint.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 02:50:48 +02:00

26 KiB

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
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 <entity_name>. 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.<app>.server.
meta/rbac.yml RBAC declarations. File root IS applications.<app>.rbac.
meta/volumes.yml Compose volumes. File root IS the volumes map keyed by volume name. No compose: and no volumes: wrapper.
meta/addons/<id>.yml Optional. One file per addon (file root IS the addon spec; filename stem = addon id). See Unified Addons 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.<app>.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/<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/services.yml.<entity>.<…> services.<entity>.<…>
meta/volumes.yml.<key> volumes.<key>
meta/services.yml.<primary_entity>.<top-level-key>.<…> services.<primary_entity>.<top-level-key>.<…>

| 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:

  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

# 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
--set applications.<app>.secrets.credentials.<path>=<value> 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

# 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 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:.

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_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.<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
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.<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
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.
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.<name>') 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.<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 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/<id>.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 and bridge resolution / parity in 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 for the distinction.

  • base.md covers the service registration, loading, and injection model.
  • email.md covers the email lookup contract.
  • port.md describes the cli contributing network ports suggest helper.
  • address.md describes the cli contributing network address suggest helper.