The third shape: a non-mautrix bridge. It confirms the scaffolding is not mautrix-specific, and it is laid out differently enough to be worth having - everything sits directly under the base path rather than in config/ and data/ subdirectories, the owner and homeserver URL are passed on the command line rather than through a config file, so the unit is where they can be checked, and identd optionally binds host port 113. matrix-base's matrix_server_fqn_* family moved into the shared context; 18 of the roles here read one of them. Three roles now, three shapes - HTTP receiver, mautrix bridge, non-mautrix bridge - and the scaffolding took no per-shape special-casing beyond each role's own variables. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>pull/5575/head
| @@ -54,3 +54,27 @@ matrix_bridges_relay_enabled: false | |||
| matrix_bridges_exposure_enabled: true | |||
| matrix_bridges_exposure_hostname: molecule.local | |||
| matrix_bridges_exposure_path_prefix: /bridges | |||
| # --- Public hostnames (matrix-base) ---------------------------------------- | |||
| # | |||
| # 18 of the roles here read one of these. Rendered against the scenario's | |||
| # matrix_domain rather than left as Jinja, so a scenario can read them in | |||
| # verify.yml without the role's defaults being in scope. | |||
| matrix_server_fqn_matrix: matrix.molecule.local | |||
| matrix_server_fqn_matrix_federation: matrix.molecule.local | |||
| matrix_server_fqn_element: element.molecule.local | |||
| matrix_server_fqn_hydrogen: hydrogen.molecule.local | |||
| matrix_server_fqn_cinny: cinny.molecule.local | |||
| matrix_server_fqn_sable: sable.molecule.local | |||
| matrix_server_fqn_schildichat: schildichat.molecule.local | |||
| matrix_server_fqn_fluffychat: fluffychat.molecule.local | |||
| matrix_server_fqn_buscarron: buscarron.molecule.local | |||
| matrix_server_fqn_etherpad: etherpad.molecule.local | |||
| matrix_server_fqn_jitsi: jitsi.molecule.local | |||
| matrix_server_fqn_grafana: stats.molecule.local | |||
| matrix_server_fqn_sygnal: sygnal.molecule.local | |||
| matrix_server_fqn_mautrix_wsproxy: wsproxy.molecule.local | |||
| matrix_server_fqn_ntfy: ntfy.molecule.local | |||
| matrix_server_fqn_rageshake: rageshake.molecule.local | |||
| matrix_server_fqn_matrixto: mt.molecule.local | |||
| @@ -0,0 +1,36 @@ | |||
| # SPDX-FileCopyrightText: 2026 Slavi Pantaleev | |||
| # | |||
| # SPDX-License-Identifier: AGPL-3.0-or-later | |||
| --- | |||
| - name: Include roles for heisenbridge 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 heisenbridge 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 heisenbridge 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 heisenbridge systemd service is started | |||
| ansible.builtin.systemd_service: | |||
| name: matrix-heisenbridge.service | |||
| state: started | |||
| @@ -0,0 +1,75 @@ | |||
| # 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: heisenbridge-${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_heisenbridge_container_network: heisenbridge-molecule | |||
| # The stub prepare.yml stands up. Heisenbridge talks to it while | |||
| # starting; it is not a real homeserver and nothing is asserted about it. | |||
| matrix_bridge_heisenbridge_homeserver_url: http://matrix.molecule.local:8008 | |||
| matrix_bridge_heisenbridge_appservice_token: molecule_as_token_4f2a91 | |||
| # Heisenbridge refuses to start without an owner - it is the Matrix user | |||
| # allowed to administer the bridge. | |||
| matrix_bridge_heisenbridge_owner: "@molecule-admin:molecule.local" | |||
| # Deliberately different from the role's defaults, so verify.yml can tell | |||
| # what the role rendered apart from what heisenbridge would have chosen. | |||
| matrix_bridge_heisenbridge_path_prefix: /molecule-heisenbridge | |||
| # identd binds host port 113, which would collide with anything else on | |||
| # the machine and is not what this scenario is proving. | |||
| matrix_bridge_heisenbridge_identd_enabled: false | |||
| # Traefik is not deployed here, so the labels the role would render for | |||
| # it are switched off and their absence is asserted instead. | |||
| matrix_bridge_heisenbridge_container_labels_traefik_enabled: false | |||
| # verify.yml runs as its own play, where role defaults are out of scope. | |||
| # Unlike the mautrix bridges, heisenbridge keeps everything directly | |||
| # under its base path rather than in config/ and data/ subdirectories. | |||
| matrix_bridge_heisenbridge_base_path: /matrix/heisenbridge | |||
| 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 | |||
| @@ -0,0 +1,81 @@ | |||
| # SPDX-FileCopyrightText: 2026 Slavi Pantaleev | |||
| # | |||
| # SPDX-License-Identifier: AGPL-3.0-or-later | |||
| --- | |||
| - name: Prepare heisenbridge 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_heisenbridge_container_network }}" | |||
| register: heisenbridge_molecule_network | |||
| changed_when: heisenbridge_molecule_network.rc == 0 | |||
| failed_when: | |||
| - heisenbridge_molecule_network.rc != 0 | |||
| - "'already exists' not in heisenbridge_molecule_network.stderr" | |||
| # The bridge contacts the homeserver as it starts. It is not being asked to | |||
| # bridge anything - 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_heisenbridge_container_network }}" | |||
| molecule_shared_stub_server_name: molecule.local | |||
| molecule_shared_stub_user_id: "@heisenbridge:molecule.local" | |||
| @@ -0,0 +1 @@ | |||
| ../../../../../molecule-shared/requirements.yml | |||
| @@ -0,0 +1,130 @@ | |||
| # SPDX-FileCopyrightText: 2026 Slavi Pantaleev | |||
| # | |||
| # SPDX-License-Identifier: AGPL-3.0-or-later | |||
| --- | |||
| # What this proves: heisenbridge starts, reads the registration the role | |||
| # rendered, and is the version the role pins. It does NOT connect to IRC and | |||
| # never will. See docs/molecule-testing.md. | |||
| - name: Verify heisenbridge | |||
| 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 | |||
| tasks: | |||
| - name: Load the role's defaults under a separate name | |||
| ansible.builtin.include_vars: | |||
| file: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults/main.yml" | |||
| name: heisenbridge_role_defaults | |||
| - name: Wait for the heisenbridge service to become active | |||
| ansible.builtin.systemd_service: | |||
| name: matrix-heisenbridge.service | |||
| register: heisenbridge_service | |||
| until: heisenbridge_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. | |||
| - name: Assert the service is active and has not been restarting | |||
| ansible.builtin.assert: | |||
| that: | |||
| - heisenbridge_service.status.ActiveState == 'active' | |||
| - heisenbridge_service.status.NRestarts is defined | |||
| - heisenbridge_service.status.NRestarts | int == 0 | |||
| fail_msg: >- | |||
| matrix-heisenbridge.service is | |||
| {{ heisenbridge_service.status.ActiveState | default('unknown') }} | |||
| after {{ heisenbridge_service.status.NRestarts | default('?') }} | |||
| automatic restart(s) | |||
| success_msg: "matrix-heisenbridge.service is active and has not restarted" | |||
| # Heisenbridge keeps everything directly under its base path rather than in | |||
| # config/ and data/ subdirectories the way the mautrix bridges do. | |||
| - name: Read the appservice registration the role rendered | |||
| ansible.builtin.slurp: | |||
| src: "{{ matrix_bridge_heisenbridge_base_path }}/registration.yaml" | |||
| register: heisenbridge_registration_file | |||
| # The token and the URL both differ from anything heisenbridge would pick on | |||
| # its own, so their presence means the role rendered this file rather than | |||
| # the bridge generating one. | |||
| - name: Assert the registration carries the scenario's token and namespace | |||
| ansible.builtin.assert: | |||
| that: | |||
| - matrix_bridge_heisenbridge_appservice_token in heisenbridge_registration_rendered | |||
| - "'heisenbridge' in heisenbridge_registration_rendered" | |||
| fail_msg: "The appservice registration does not carry the scenario's token" | |||
| success_msg: "The appservice registration carries the scenario's token" | |||
| vars: | |||
| heisenbridge_registration_rendered: "{{ heisenbridge_registration_file.content | b64decode }}" | |||
| # The owner is what heisenbridge is told to accept administration from, and | |||
| # the role passes it on the command line rather than through a config file, | |||
| # so the unit is where it can be checked. | |||
| - name: Read the systemd unit the role rendered | |||
| ansible.builtin.slurp: | |||
| src: /etc/systemd/system/matrix-heisenbridge.service | |||
| register: heisenbridge_unit_file | |||
| - name: Assert the unit carries the owner and homeserver URL the scenario set | |||
| ansible.builtin.assert: | |||
| that: | |||
| - matrix_bridge_heisenbridge_owner in heisenbridge_unit_rendered | |||
| - matrix_bridge_heisenbridge_homeserver_url in heisenbridge_unit_rendered | |||
| fail_msg: "The unit does not carry the scenario's owner and homeserver URL" | |||
| success_msg: "The unit carries the scenario's owner and homeserver URL" | |||
| vars: | |||
| heisenbridge_unit_rendered: "{{ heisenbridge_unit_file.content | b64decode }}" | |||
| # identd binds host port 113 when enabled, and this scenario turns it off. | |||
| # Asserting its absence keeps the default from silently becoming "on". | |||
| - name: Assert identd is not published while it is disabled | |||
| ansible.builtin.assert: | |||
| that: | |||
| - "'-p 113:' not in (heisenbridge_unit_file.content | b64decode)" | |||
| fail_msg: >- | |||
| The unit publishes identd on port 113 even though | |||
| matrix_bridge_heisenbridge_identd_enabled is false | |||
| success_msg: "identd is not published while it is disabled" | |||
| - name: Read the image of the running container | |||
| ansible.builtin.command: | |||
| argv: | |||
| - docker | |||
| - container | |||
| - inspect | |||
| - matrix-heisenbridge | |||
| - --format | |||
| - "{{ '{{' }} .Config.Image {{ '}}' }}" | |||
| register: heisenbridge_image | |||
| changed_when: false | |||
| - name: Assert the running container is the version defaults/main.yml pins | |||
| ansible.builtin.assert: | |||
| that: | |||
| - heisenbridge_role_defaults.matrix_bridge_heisenbridge_version | string in heisenbridge_image.stdout | |||
| fail_msg: >- | |||
| The running container is {{ heisenbridge_image.stdout }}, which does not | |||
| carry the pinned version | |||
| {{ heisenbridge_role_defaults.matrix_bridge_heisenbridge_version }} | |||
| success_msg: "The running container is the version defaults/main.yml pins" | |||
| - name: Read the labels the role rendered | |||
| ansible.builtin.slurp: | |||
| src: "{{ matrix_bridge_heisenbridge_base_path }}/labels" | |||
| register: heisenbridge_labels | |||
| - name: Assert no Traefik labels are emitted while Traefik support is disabled | |||
| ansible.builtin.assert: | |||
| that: | |||
| - "'traefik.' not in (heisenbridge_labels.content | b64decode)" | |||
| fail_msg: >- | |||
| Traefik labels were emitted even though | |||
| matrix_bridge_heisenbridge_container_labels_traefik_enabled is false | |||
| success_msg: "No Traefik labels are emitted while Traefik support is disabled" | |||