diff --git a/roles/custom/matrix-bridge-mautrix-discord/molecule/default/converge.yml b/roles/custom/matrix-bridge-mautrix-discord/molecule/default/converge.yml new file mode 100644 index 000000000..065ec95c2 --- /dev/null +++ b/roles/custom/matrix-bridge-mautrix-discord/molecule/default/converge.yml @@ -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 diff --git a/roles/custom/matrix-bridge-mautrix-discord/molecule/default/molecule.yml b/roles/custom/matrix-bridge-mautrix-discord/molecule/default/molecule.yml new file mode 100644 index 000000000..060d7ad26 --- /dev/null +++ b/roles/custom/matrix-bridge-mautrix-discord/molecule/default/molecule.yml @@ -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 diff --git a/roles/custom/matrix-bridge-mautrix-discord/molecule/default/prepare.yml b/roles/custom/matrix-bridge-mautrix-discord/molecule/default/prepare.yml new file mode 100644 index 000000000..1242940e0 --- /dev/null +++ b/roles/custom/matrix-bridge-mautrix-discord/molecule/default/prepare.yml @@ -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 }}" diff --git a/roles/custom/matrix-bridge-mautrix-discord/molecule/default/requirements.yml b/roles/custom/matrix-bridge-mautrix-discord/molecule/default/requirements.yml new file mode 120000 index 000000000..1567a5392 --- /dev/null +++ b/roles/custom/matrix-bridge-mautrix-discord/molecule/default/requirements.yml @@ -0,0 +1 @@ +../../../../../molecule-shared/requirements.yml \ No newline at end of file diff --git a/roles/custom/matrix-bridge-mautrix-discord/molecule/default/verify.yml b/roles/custom/matrix-bridge-mautrix-discord/molecule/default/verify.yml new file mode 100644 index 000000000..83b1779d8 --- /dev/null +++ b/roles/custom/matrix-bridge-mautrix-discord/molecule/default/verify.yml @@ -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"