diff --git a/molecule-shared/playbook-context.yml b/molecule-shared/playbook-context.yml index cd0982b5c..4e56480e6 100644 --- a/molecule-shared/playbook-context.yml +++ b/molecule-shared/playbook-context.yml @@ -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 diff --git a/roles/custom/matrix-bridge-heisenbridge/molecule/default/converge.yml b/roles/custom/matrix-bridge-heisenbridge/molecule/default/converge.yml new file mode 100644 index 000000000..989ed2bb6 --- /dev/null +++ b/roles/custom/matrix-bridge-heisenbridge/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 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 diff --git a/roles/custom/matrix-bridge-heisenbridge/molecule/default/molecule.yml b/roles/custom/matrix-bridge-heisenbridge/molecule/default/molecule.yml new file mode 100644 index 000000000..f465908cf --- /dev/null +++ b/roles/custom/matrix-bridge-heisenbridge/molecule/default/molecule.yml @@ -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 diff --git a/roles/custom/matrix-bridge-heisenbridge/molecule/default/prepare.yml b/roles/custom/matrix-bridge-heisenbridge/molecule/default/prepare.yml new file mode 100644 index 000000000..1058fec61 --- /dev/null +++ b/roles/custom/matrix-bridge-heisenbridge/molecule/default/prepare.yml @@ -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" diff --git a/roles/custom/matrix-bridge-heisenbridge/molecule/default/requirements.yml b/roles/custom/matrix-bridge-heisenbridge/molecule/default/requirements.yml new file mode 120000 index 000000000..1567a5392 --- /dev/null +++ b/roles/custom/matrix-bridge-heisenbridge/molecule/default/requirements.yml @@ -0,0 +1 @@ +../../../../../molecule-shared/requirements.yml \ No newline at end of file diff --git a/roles/custom/matrix-bridge-heisenbridge/molecule/default/verify.yml b/roles/custom/matrix-bridge-heisenbridge/molecule/default/verify.yml new file mode 100644 index 000000000..9c1d1e4c1 --- /dev/null +++ b/roles/custom/matrix-bridge-heisenbridge/molecule/default/verify.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"