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_enabled: true | ||||
| matrix_bridges_exposure_hostname: molecule.local | matrix_bridges_exposure_hostname: molecule.local | ||||
| matrix_bridges_exposure_path_prefix: /bridges | 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" | |||||