Procházet zdrojové kódy

Add a Molecule scenario for mautrix-discord

Proves the bridge starts on the configuration this role rendered: the
systemd unit is active and has not been restarting, the appservice port is
open, config.yaml and registration.yaml (parsed, not grepped) carry the
scenario's homeserver address, bot username, tokens, command prefix,
avatar-proxy key, public address and log level, the sqlite database was
created under the role's data path as the role's uid, and the running
container is the version defaults/main.yml pins.

It does not bridge anything. Discord is behind an account, which is where a
scenario stops being a test of this repository.

Two things are specific to this role rather than copied from the
mautrix-whatsapp scenario:

- mautrix-discord is the only bridge here whose validate_config.yml requires
  a public address. It is composed from hostname + scheme + path prefix, and
  those same three feed the Traefik avatar-proxy labels, so the scenario
  picks a non-`/` path prefix and a non-default scheme and asserts the
  composed router rule and strip-prefix middleware - both in the label file
  and, read back off the running container, as labels Docker accepted.
  Traefik labels are left enabled here for that reason, unlike in the
  mautrix-whatsapp scenario which asserts their absence.

- matrix_bridge_mautrix_discord_bridge_double_puppet_server_map_default in
  the role's defaults references matrix_bridge_beeper_linkedin_homeserver_domain
  and matrix_bridge_beeper_linkedin_homeserver_address - variables belonging
  to a different role, evidently copy-pasted from
  matrix-bridge-beeper-linkedin, and unique to this role among the bridges.
  A playbook run has every role's defaults in scope so it resolves silently;
  a role scenario has only this role loaded and the template fails on the
  undefined name. The scenario neutralises it in its own group_vars rather
  than touching the role.

Falsified the crash-loop assertion: pointing the in-container sqlite path at
a directory that does not exist makes the bridge exit on startup, and the run
then fails at "Assert the service is active and has not been restarting" with
"activating after 5 automatic restart(s)". Reverted, and green again since -
including idempotence.
pull/5575/head
Slavi Pantaleev před 17 hodinami
rodič
revize
6af80175dc
5 změnil soubory, kde provedl 463 přidání a 0 odebrání
  1. +36
    -0
      roles/custom/matrix-bridge-mautrix-discord/molecule/default/converge.yml
  2. +106
    -0
      roles/custom/matrix-bridge-mautrix-discord/molecule/default/molecule.yml
  3. +82
    -0
      roles/custom/matrix-bridge-mautrix-discord/molecule/default/prepare.yml
  4. +1
    -0
      roles/custom/matrix-bridge-mautrix-discord/molecule/default/requirements.yml
  5. +238
    -0
      roles/custom/matrix-bridge-mautrix-discord/molecule/default/verify.yml

+ 36
- 0
roles/custom/matrix-bridge-mautrix-discord/molecule/default/converge.yml Zobrazit soubor

@@ -0,0 +1,36 @@
# SPDX-FileCopyrightText: 2026 Slavi Pantaleev
#
# SPDX-License-Identifier: AGPL-3.0-or-later

---
- name: Include roles for mautrix-discord Molecule tests
hosts: all
become: true
vars_files:
- "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/../../../molecule-shared/playbook-context.yml"
gather_facts: true
tasks:
- name: Include roles for mautrix-discord Molecule tests
ansible.builtin.include_role:
name: "{{ role_name }}"
public: true
loop:
- com.devture.ansible.role.playbook_help
- com.devture.ansible.role.systemd_docker_base
- "custom/{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') | basename }}"
loop_control:
loop_var: role_name

- name: Ensure mautrix-discord is started
hosts: all
become: true
gather_facts: false
tasks:
- name: Ensure systemd daemon is reloaded
ansible.builtin.systemd_service:
daemon_reload: true

- name: Ensure the mautrix-discord systemd service is started
ansible.builtin.systemd_service:
name: matrix-mautrix-discord.service
state: started

+ 106
- 0
roles/custom/matrix-bridge-mautrix-discord/molecule/default/molecule.yml Zobrazit soubor

@@ -0,0 +1,106 @@
# SPDX-FileCopyrightText: 2026 Slavi Pantaleev
#
# SPDX-License-Identifier: AGPL-3.0-or-later

---
dependency:
name: galaxy
options:
requirements-file: requirements.yml
force: true
driver:
name: docker
platforms:
- name: mautrix-discord-${MOLECULE_DISTRO:-ubuntu2604}-default
image: "geerlingguy/docker-${MOLECULE_DISTRO:-ubuntu2604}-ansible:latest"
command: ${MOLECULE_DOCKER_COMMAND:-""}
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
cgroupns_mode: host
privileged: true
pre_build_image: true
provisioner:
name: ansible
config_options:
defaults:
callback_result_format: yaml
inventory:
group_vars:
all:
matrix_bridge_mautrix_discord_container_network: mautrix-discord-molecule

# The homeserver stub prepare.yml stands up. The bridge contacts it
# while starting; it is not a real homeserver and nothing is asserted
# about it. There is deliberately no Discord on the other side either -
# see docs/molecule-testing.md.
matrix_bridge_mautrix_discord_homeserver_address: http://matrix.molecule.local:8008

# sqlite keeps the scenario to one container. The role only requires a
# database hostname when the engine is postgres, and testing which
# database engine the bridge can talk to is not what this proves.
matrix_bridge_mautrix_discord_database_engine: sqlite

# Appservice tokens. These are what the bridge and homeserver would
# authenticate to each other with; here they only have to reach the
# rendered configuration and the registration file.
matrix_bridge_mautrix_discord_appservice_token: molecule_as_token_d15c07
matrix_bridge_mautrix_discord_homeserver_token: molecule_hs_token_a4e2b8

# Deliberately different from the role's defaults, so verify.yml can
# tell what the role rendered apart from what the bridge would have
# defaulted to on its own.
matrix_bridge_mautrix_discord_appservice_bot_username: molecule-discordbot
matrix_bridge_mautrix_discord_homeserver_domain: molecule.local
matrix_bridge_mautrix_discord_bridge_command_prefix: "!molecule-discord"
# The role defaults to `warn`; the bridge's own shipped configuration
# uses `debug`. `info` is neither.
matrix_bridge_mautrix_discord_logging_level: info

# Unlike most bridge roles here, mautrix-discord *requires* a public
# address: `validate_config.yml` fails without
# `matrix_bridge_mautrix_discord_bridge_public_address`, which is
# derived from these three. Discord fetches avatars over it in relay
# mode; nothing reaches it in this scenario, but it has to be set for
# the role to run at all.
#
# A non-`/` path prefix and a non-default scheme are chosen so the
# avatar-proxy labels verify.yml reads can only look the way they do if
# the role composed them from these values.
matrix_bridge_mautrix_discord_hostname: discord.molecule.local
matrix_bridge_mautrix_discord_path_prefix: /discord-bridge
matrix_bridge_mautrix_discord_scheme: http
matrix_bridge_mautrix_discord_bridge_avatar_proxy_key: molecule_avatar_proxy_key_7c1d

# The role's default for this references
# `matrix_bridge_beeper_linkedin_homeserver_domain` /
# `..._homeserver_address`, which belong to a *different* role. In a
# playbook run every role's defaults are in scope, so it renders; a role
# scenario has only this role loaded and the template fails on the
# undefined name. Neutralised here rather than worked around in the
# role, and reported as a defect of the role.
matrix_bridge_mautrix_discord_bridge_double_puppet_server_map_default: {}

# verify.yml runs as its own play, where role defaults are out of scope,
# so the paths it reads are pinned here as literals matching what the
# role derives from matrix_base_data_path.
matrix_bridge_mautrix_discord_base_path: /matrix/mautrix-discord
matrix_bridge_mautrix_discord_config_path: /matrix/mautrix-discord/config
matrix_bridge_mautrix_discord_data_path: /matrix/mautrix-discord/data
env:
# Workaround for https://github.com/ansible/molecule/issues/4391
ANSIBLE_ROLES_PATH: ${MOLECULE_PROJECT_DIRECTORY}/../..:/.ansible/roles:/usr/share/ansible/roles:/etc/ansible/roles:~/.ansible/roles
scenario:
test_sequence:
- dependency
- cleanup
- destroy
- syntax
- create
- prepare
- converge
- idempotence
- verify
- cleanup
- destroy
verifier:
name: ansible

+ 82
- 0
roles/custom/matrix-bridge-mautrix-discord/molecule/default/prepare.yml Zobrazit soubor

@@ -0,0 +1,82 @@
# SPDX-FileCopyrightText: 2026 Slavi Pantaleev
#
# SPDX-License-Identifier: AGPL-3.0-or-later

---
- name: Prepare mautrix-discord Molecule tests
hosts: all
become: true
vars_files:
- "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/../../../molecule-shared/vars.yml"
- "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/../../../molecule-shared/playbook-context.yml"
gather_facts: true
tasks:
- name: Ensure apt cache is updated
ansible.builtin.apt:
update_cache: true
cache_valid_time: 600
when: ansible_os_family == 'Debian'

- name: Ensure required packages are installed
ansible.builtin.package:
name:
- python3-requests
- fuse-overlayfs
state: present

- name: Ensure Docker is installed
ansible.builtin.include_role:
name: ansible-role-docker
vars:
docker_daemon_options:
storage-driver: fuse-overlayfs

# The role's file tasks set owner/group by name and Ansible resolves those
# through the passwd database, so they have to exist first. matrix-base
# creates them in a real deployment.
- name: Ensure the matrix group exists
ansible.builtin.group:
name: "{{ matrix_group_name }}"
gid: "{{ matrix_user_gid }}"
state: present

- name: Ensure the matrix user exists
ansible.builtin.user:
name: "{{ matrix_user_name }}"
uid: "{{ matrix_user_uid }}"
group: "{{ matrix_group_name }}"
create_home: false
system: true
state: present

- name: Ensure the base data path exists
ansible.builtin.file:
path: "{{ matrix_base_data_path }}"
state: directory
owner: "{{ matrix_user_name }}"
group: "{{ matrix_group_name }}"
mode: "0750"

- name: Ensure the container network the role attaches to exists
ansible.builtin.command:
argv:
- docker
- network
- create
- "{{ matrix_bridge_mautrix_discord_container_network }}"
register: mautrix_discord_molecule_network
changed_when: mautrix_discord_molecule_network.rc == 0
failed_when:
- mautrix_discord_molecule_network.rc != 0
- "'already exists' not in mautrix_discord_molecule_network.stderr"

# The bridge contacts the homeserver as it starts, and refuses to run if
# /whoami does not name the bot user it was configured as - see
# molecule-shared/homeserver-stub.py.
- name: Ensure the homeserver stub is running
ansible.builtin.include_tasks:
file: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/../../../molecule-shared/tasks/homeserver-stub.yml"
vars:
molecule_shared_stub_network: "{{ matrix_bridge_mautrix_discord_container_network }}"
molecule_shared_stub_server_name: molecule.local
molecule_shared_stub_user_id: "@{{ matrix_bridge_mautrix_discord_appservice_bot_username }}:{{ matrix_bridge_mautrix_discord_homeserver_domain }}"

+ 1
- 0
roles/custom/matrix-bridge-mautrix-discord/molecule/default/requirements.yml Zobrazit soubor

@@ -0,0 +1 @@
../../../../../molecule-shared/requirements.yml

+ 238
- 0
roles/custom/matrix-bridge-mautrix-discord/molecule/default/verify.yml Zobrazit soubor

@@ -0,0 +1,238 @@
# SPDX-FileCopyrightText: 2026 Slavi Pantaleev
#
# SPDX-License-Identifier: AGPL-3.0-or-later

---
# What this proves: the bridge starts, reads the configuration and registration
# the role rendered, opens its appservice port, and is the version the role
# pins. It also proves the role composed the avatar-proxy labels out of the
# hostname, scheme and path prefix it was given. It does NOT bridge anything -
# there is no Discord on the other side and there is deliberately never going to
# be one, because that would need a Discord account. See
# docs/molecule-testing.md.
- name: Verify mautrix-discord
hosts: all
become: true
vars_files:
- "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/../../../molecule-shared/vars.yml"
- "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/../../../molecule-shared/playbook-context.yml"
gather_facts: false

vars:
# The role derives this from scheme + hostname + path prefix, and role
# defaults are out of scope in this play, so it is recomposed here from the
# same three values the scenario pinned in molecule.yml.
mautrix_discord_expected_public_address: >-
{{ matrix_bridge_mautrix_discord_scheme }}://{{ matrix_bridge_mautrix_discord_hostname }}{{ matrix_bridge_mautrix_discord_path_prefix }}
mautrix_discord_expected_avatar_proxy_path_prefix: "{{ matrix_bridge_mautrix_discord_path_prefix }}/mautrix-discord/avatar"

tasks:
# Read from the role's own defaults rather than pinned in molecule.yml, so
# the version assertion below compares the running image against what the
# role ships instead of against the scenario itself.
- name: Load the role's defaults under a separate name
ansible.builtin.include_vars:
file: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults/main.yml"
name: mautrix_discord_role_defaults

- name: Wait for the mautrix-discord service to become active
ansible.builtin.systemd_service:
name: matrix-mautrix-discord.service
register: mautrix_discord_service
until: mautrix_discord_service.status.ActiveState == 'active'
retries: 30
delay: 5
failed_when: false

# `Restart=always` means a bridge crash-looping on a configuration it cannot
# read still reports `active`, so the restart counter is checked too. It is
# asserted `is defined` because `| int` turns a missing property into 0 and
# would pass vacuously.
- name: Assert the service is active and has not been restarting
ansible.builtin.assert:
that:
- mautrix_discord_service.status.ActiveState == 'active'
- mautrix_discord_service.status.NRestarts is defined
- mautrix_discord_service.status.NRestarts | int == 0
fail_msg: >-
matrix-mautrix-discord.service is
{{ mautrix_discord_service.status.ActiveState | default('unknown') }}
after {{ mautrix_discord_service.status.NRestarts | default('?') }}
automatic restart(s)
success_msg: "matrix-mautrix-discord.service is active and has not restarted"

# The appservice port is the bridge's own listener, the one a homeserver
# would push transactions to. It opening at all means the bridge got through
# reading its configuration and setting itself up.
- name: Wait for the bridge to open its appservice port
ansible.builtin.command:
argv:
- docker
- run
- --rm
- --network={{ matrix_bridge_mautrix_discord_container_network }}
- "{{ molecule_shared_image_curl }}"
- --silent
- --output
- /dev/null
- --write-out
- "HTTP_STATUS=%{http_code}"
- "http://matrix-mautrix-discord:8080/_matrix/mau/live"
register: mautrix_discord_live
changed_when: false
until: "'HTTP_STATUS=000' not in mautrix_discord_live.stdout"
retries: 24
delay: 5
failed_when: false

- name: Assert the bridge answers on its appservice port
ansible.builtin.assert:
that:
- "'HTTP_STATUS=000' not in mautrix_discord_live.stdout"
fail_msg: >-
The bridge did not answer on its appservice port
({{ mautrix_discord_live.stdout | default('no output') }})
success_msg: "The bridge answers on its appservice port"

- name: Read the configuration the role rendered
ansible.builtin.slurp:
src: "{{ matrix_bridge_mautrix_discord_config_path }}/config.yaml"
register: mautrix_discord_config_file

# Each of these differs from what the bridge would use on its own, so their
# presence means the role's configuration is what the bridge is running on
# rather than something that happened to agree with it. The public address
# in particular is composed by the role out of three separate variables.
#
# Asserted against the parsed document rather than by substring, so a value
# landing under the wrong key cannot pass.
- name: Assert the rendered configuration carries this scenario's values
ansible.builtin.assert:
that:
- mautrix_discord_config.homeserver.address == matrix_bridge_mautrix_discord_homeserver_address
- mautrix_discord_config.homeserver.domain == matrix_bridge_mautrix_discord_homeserver_domain
- mautrix_discord_config.appservice.bot.username == matrix_bridge_mautrix_discord_appservice_bot_username
- mautrix_discord_config.appservice.as_token == matrix_bridge_mautrix_discord_appservice_token
- mautrix_discord_config.appservice.hs_token == matrix_bridge_mautrix_discord_homeserver_token
- mautrix_discord_config.appservice.database.type == 'sqlite3'
- mautrix_discord_config.bridge.command_prefix == matrix_bridge_mautrix_discord_bridge_command_prefix
- mautrix_discord_config.bridge.avatar_proxy_key == matrix_bridge_mautrix_discord_bridge_avatar_proxy_key
- mautrix_discord_config.bridge.public_address == mautrix_discord_expected_public_address | trim
- mautrix_discord_config.logging.min_level == matrix_bridge_mautrix_discord_logging_level
fail_msg: "The rendered configuration does not carry the scenario's values"
success_msg: "The rendered configuration carries the scenario's values"
vars:
mautrix_discord_config: "{{ mautrix_discord_config_file.content | b64decode | from_yaml }}"

# The registration file is the half of the appservice handshake the
# homeserver reads, and it is generated by the role rather than by the
# bridge, so it is worth checking on its own. `sender_localpart` is the
# role's own `_bot_` prefixing, not something the bridge would produce.
- name: Read the appservice registration the role rendered
ansible.builtin.slurp:
src: "{{ matrix_bridge_mautrix_discord_config_path }}/registration.yaml"
register: mautrix_discord_registration_file

- name: Assert the registration carries the scenario's tokens and bot user
ansible.builtin.assert:
that:
- mautrix_discord_registration.as_token == matrix_bridge_mautrix_discord_appservice_token
- mautrix_discord_registration.hs_token == matrix_bridge_mautrix_discord_homeserver_token
- mautrix_discord_registration.sender_localpart == '_bot_' ~ matrix_bridge_mautrix_discord_appservice_bot_username
- mautrix_discord_registration.url == 'http://matrix-mautrix-discord:8080'
- mautrix_discord_bot_user_regex in (mautrix_discord_registration.namespaces.users | map(attribute='regex') | list)
fail_msg: "The appservice registration does not carry the scenario's tokens and bot user"
success_msg: "The appservice registration carries the scenario's tokens and bot user"
vars:
mautrix_discord_registration: "{{ mautrix_discord_registration_file.content | b64decode | from_yaml }}"
mautrix_discord_bot_user_regex: "^@{{ matrix_bridge_mautrix_discord_appservice_bot_username | regex_escape }}:{{ matrix_bridge_mautrix_discord_homeserver_domain | regex_escape }}$"

# sqlite was chosen in molecule.yml, so the bridge should have created its
# database under the role's data path. This is the cheap proof that the data
# path reached the process and is writable by the uid the role runs it as.
- name: Look for the bridge's sqlite database under the role's data path
ansible.builtin.stat:
path: "{{ matrix_bridge_mautrix_discord_data_path }}/mautrix-discord.db"
register: mautrix_discord_database

- name: Assert the bridge created its database where the role put its data path
ansible.builtin.assert:
that:
- mautrix_discord_database.stat.exists
- mautrix_discord_database.stat.uid | int == matrix_user_uid | int
fail_msg: >-
The bridge did not create its database under
{{ matrix_bridge_mautrix_discord_data_path }}, or it is not owned by
uid {{ matrix_user_uid }}
success_msg: "The bridge created its database under the role's data path, as the role's uid"

- name: Read the image of the running container
ansible.builtin.command:
argv:
- docker
- container
- inspect
- matrix-mautrix-discord
- --format
- "{{ '{{' }} .Config.Image {{ '}}' }}"
register: mautrix_discord_image
changed_when: false

- name: Assert the running container is the version defaults/main.yml pins
ansible.builtin.assert:
that:
- mautrix_discord_role_defaults.matrix_bridge_mautrix_discord_version in mautrix_discord_image.stdout
fail_msg: >-
The running container is {{ mautrix_discord_image.stdout }}, which does
not carry the pinned version
{{ mautrix_discord_role_defaults.matrix_bridge_mautrix_discord_version }}
success_msg: "The running container is the version defaults/main.yml pins"

# The avatar proxy is this role's own reverse-proxy wiring: the labels only
# appear because a public address was configured, and their hostname and
# path prefix are composed by the role rather than copied from a variable.
- name: Read the labels the role rendered
ansible.builtin.slurp:
src: "{{ matrix_bridge_mautrix_discord_base_path }}/labels"
register: mautrix_discord_labels

- name: Assert the avatar-proxy labels were composed from the scenario's hostname and path prefix
ansible.builtin.assert:
that:
- "'traefik.enable=true' in mautrix_discord_labels_rendered"
- "'traefik.docker.network=' ~ matrix_bridge_mautrix_discord_container_network in mautrix_discord_labels_rendered"
- "'traefik.http.routers.matrix-mautrix-discord-avatar-proxy.rule=Host(`' ~ matrix_bridge_mautrix_discord_hostname ~ '`) && PathPrefix(`' ~ mautrix_discord_expected_avatar_proxy_path_prefix ~ '`)' in mautrix_discord_labels_rendered"
- "'traefik.http.middlewares.matrix-mautrix-discord-strip-prefix.stripprefix.prefixes=' ~ matrix_bridge_mautrix_discord_path_prefix in mautrix_discord_labels_rendered"
fail_msg: >-
The avatar-proxy labels do not carry the scenario's hostname
({{ matrix_bridge_mautrix_discord_hostname }}) and path prefix
({{ mautrix_discord_expected_avatar_proxy_path_prefix }})
success_msg: "The avatar-proxy labels were composed from the scenario's hostname and path prefix"
vars:
mautrix_discord_labels_rendered: "{{ mautrix_discord_labels.content | b64decode }}"

# The label file is fed to `docker create --label-file`, so a label the role
# renders wrongly is not merely cosmetic - it would stop the container from
# being created at all. Reading them back off the running container proves
# Docker accepted them.
- name: Read the labels Docker attached to the running container
ansible.builtin.command:
argv:
- docker
- container
- inspect
- matrix-mautrix-discord
- --format
- "{{ '{{' }} index .Config.Labels \"traefik.http.routers.matrix-mautrix-discord-avatar-proxy.rule\" {{ '}}' }}"
register: mautrix_discord_container_label
changed_when: false

- name: Assert Docker carries the avatar-proxy router label the role rendered
ansible.builtin.assert:
that:
- matrix_bridge_mautrix_discord_hostname in mautrix_discord_container_label.stdout
- mautrix_discord_expected_avatar_proxy_path_prefix in mautrix_discord_container_label.stdout
fail_msg: >-
The running container's avatar-proxy router label is
"{{ mautrix_discord_container_label.stdout }}"
success_msg: "The running container carries the avatar-proxy router label the role rendered"

Načítá se…
Zrušit
Uložit