diff --git a/.github/renovate.json b/.github/renovate.json index 80655f876..eed19a958 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -127,6 +127,7 @@ "roles/custom/matrix-bot-draupnir/defaults/main.yml", "roles/custom/matrix-bot-maubot/defaults/main.yml", "roles/custom/matrix-bot-matrix-reminder-bot/defaults/main.yml", + "roles/custom/matrix-bot-meowlnir/defaults/main.yml", "roles/custom/matrix-bridge-heisenbridge/defaults/main.yml", "roles/custom/matrix-bridge-hookshot/defaults/main.yml", "roles/custom/matrix-bridge-mautrix-discord/defaults/main.yml", @@ -153,6 +154,7 @@ "description": "Automerge minor bumps of explicitly approved components with a weight-bearing Molecule scenario (via branch push - no PR). This list is intentionally narrower than the patch rule: the scenario must exercise enough real behavior to be a useful compatibility gate across a minor release. A failure surfaces as a PR instead. Keep every entry backed by a scenario and the patch rule - bin/check-molecule-automerge-list.py (minor rule) enforces that.", "matchFileNames": [ "roles/custom/matrix-authentication-service/defaults/main.yml", + "roles/custom/matrix-bot-meowlnir/defaults/main.yml", "roles/custom/matrix-element-admin/defaults/main.yml", "roles/custom/matrix-static-files/defaults/main.yml", "roles/custom/matrix-synapse-auto-compressor/defaults/main.yml" diff --git a/roles/custom/matrix-bot-meowlnir/molecule/default/converge.yml b/roles/custom/matrix-bot-meowlnir/molecule/default/converge.yml new file mode 100644 index 000000000..fcbbf1c32 --- /dev/null +++ b/roles/custom/matrix-bot-meowlnir/molecule/default/converge.yml @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: 2026 Slavi Pantaleev +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +--- +- name: Include roles for matrix-bot-meowlnir 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 matrix-bot-meowlnir 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 + +# Component roles install their unit; the complete playbook's service manager starts it. +- name: Ensure matrix-bot-meowlnir 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 matrix-bot-meowlnir systemd service is started + ansible.builtin.systemd_service: + name: matrix-bot-meowlnir.service + state: started diff --git a/roles/custom/matrix-bot-meowlnir/molecule/default/molecule.yml b/roles/custom/matrix-bot-meowlnir/molecule/default/molecule.yml new file mode 100644 index 000000000..0e5c355be --- /dev/null +++ b/roles/custom/matrix-bot-meowlnir/molecule/default/molecule.yml @@ -0,0 +1,155 @@ +# 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: matrix-bot-meowlnir-${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_bot_meowlnir_container_network: matrix-bot-meowlnir-molecule + + # verify.yml is a separate play where role defaults are out of scope. Pin only + # paths it reads; the component version comes from the role's defaults directly. + matrix_bot_meowlnir_base_path: /matrix/meowlnir + matrix_bot_meowlnir_config_path: /matrix/meowlnir/config + matrix_bot_meowlnir_data_path: /matrix/meowlnir/data + + # A local stub is available for bounded Matrix API calls. The empty-roster + # startup used here currently makes no outbound homeserver request, so the + # weight-bearing appservice gate is the authenticated inbound transaction in + # verify.yml. No real Matrix or third-party account is involved. + matrix_bot_meowlnir_config_homeserver_address: http://matrix.molecule.local:8008 + matrix_bot_meowlnir_config_homeserver_domain: molecule.local + matrix_bot_meowlnir_appservice_id: molecule-meowlnir + matrix_bot_meowlnir_appservice_sender_localpart: molecule_meowlnir_as + matrix_bot_meowlnir_user_prefix: molecule_meowlnir_bot_ + matrix_bot_meowlnir_appservice_token: molecule_as_token_2cf519 + matrix_bot_meowlnir_homeserver_token: molecule_hs_token_c20dc3 + + # Real Postgres is the normal playbook path. The non-default credentials make its + # migrated schema evidence that Meowlnir consumed the URI assembled by the role. + matrix_bot_meowlnir_database_engine: postgres + matrix_bot_meowlnir_database_hostname: matrix-postgres-molecule + matrix_bot_meowlnir_database_name: molecule_meowlnir + matrix_bot_meowlnir_database_username: molecule_meowlnir + matrix_bot_meowlnir_database_password: molecule_pg_password_da31bc + matrix_bot_meowlnir_database_sslmode: disable + matrix_bot_meowlnir_config_database_max_open_conns: 13 + matrix_bot_meowlnir_config_database_max_idle_conns: 3 + matrix_bot_meowlnir_config_database_max_conn_idle_time: 47s + matrix_bot_meowlnir_config_database_max_conn_lifetime: 11m + + # These differ from both role and upstream defaults. The authenticated management + # API below proves that the running process accepted the listener and secret. + matrix_bot_meowlnir_config_meowlnir_port: 29439 + matrix_bot_meowlnir_appservice_url: http://matrix-bot-meowlnir:29439 + matrix_bot_meowlnir_config_meowlnir_management_secret: molecule_management_secret_a64528 + matrix_bot_meowlnir_config_meowlnir_data_secret: molecule_data_secret_e8c7b4 + matrix_bot_meowlnir_config_meowlnir_federation_auth: true + matrix_bot_meowlnir_config_meowlnir_dry_run: true + matrix_bot_meowlnir_config_meowlnir_untrusted: true + matrix_bot_meowlnir_config_meowlnir_report_room: "!molecule-reports:molecule.local" + matrix_bot_meowlnir_config_meowlnir_room_ban_room: "!molecule-bans:molecule.local" + matrix_bot_meowlnir_config_meowlnir_hacky_rule_filter_custom: + - '@trusted:molecule.local' + - trusted.invalid + matrix_bot_meowlnir_config_meowlnir_hacky_redact_patterns_custom: + - molecule-abuse-* + matrix_bot_meowlnir_config_meowlnir_admin_tokens: + '@molecule-admin:molecule.local': molecule_admin_token_7e6ce0 + + matrix_bot_meowlnir_config_meowlnir4all_admin_room: "!molecule-provisioning:molecule.local" + matrix_bot_meowlnir_config_meowlnir4all_displayname: Molecule Provisioned Meowlnir + matrix_bot_meowlnir_config_meowlnir4all_avatar_url: mxc://molecule.local/meowlnir-avatar + matrix_bot_meowlnir_config_meowlnir4all_room_name: Molecule Meowlnir Control + matrix_bot_meowlnir_config_meowlnir4all_default_watched_lists_custom: + - name: Molecule policy list + room_id: "!molecule-policy:molecule.local" + shortcode: molecule + auto_unban: false + + matrix_bot_meowlnir_config_antispam_secret: molecule_antispam_secret_59d553 + matrix_bot_meowlnir_config_antispam_filter_local_invites: true + matrix_bot_meowlnir_config_antispam_auto_reject_invites_token: molecule_reject_token_a9812f + matrix_bot_meowlnir_config_antispam_notify_management_room: true + matrix_bot_meowlnir_config_antispam_block_invites_to_custom: + - '@blocked:molecule.local' + + # Both integrations render public routing labels. Their real handlers are not the + # readiness gate; the management API and database provide stronger process evidence. + matrix_bot_meowlnir_policy_server_enabled: true + matrix_bot_meowlnir_config_policy_server_always_redact: false + matrix_bot_meowlnir_config_policy_server_signing_key: >- + ed25519 policy_server MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY + matrix_bot_meowlnir_config_reporting_enabled: true + + matrix_bot_meowlnir_config_encryption_enable: false + matrix_bot_meowlnir_config_encryption_pickle_key: molecule_pickle_key_6f370e + matrix_bot_meowlnir_config_logging_min_level: debug + matrix_bot_meowlnir_config_logging_writers: + - type: stdout + format: json + + # No bot is declared: this avoids accepting provisioning behavior which is green only + # because a deliberately permissive homeserver stub papered over it. + matrix_bot_meowlnir_bots_custom: [] + matrix_bot_meowlnir_bots_pruning_enabled: false + + # Traefik is not deployed. Distinct non-default routes still prove the role's label + # contract both on disk and in Docker's accepted container labels. + matrix_bot_meowlnir_container_labels_traefik_enabled: true + matrix_bot_meowlnir_container_labels_traefik_docker_network: matrix-bot-meowlnir-molecule + matrix_bot_meowlnir_container_labels_reporting_traefik_hostname: reports.molecule.local + matrix_bot_meowlnir_container_labels_reporting_traefik_path_regexp: ^/molecule-report/(rooms|users)/[^/]+$$ + matrix_bot_meowlnir_container_labels_reporting_traefik_priority: 731 + matrix_bot_meowlnir_container_labels_reporting_traefik_entrypoints: web + matrix_bot_meowlnir_container_labels_reporting_traefik_tls: false + matrix_bot_meowlnir_container_labels_policy_server_traefik_hostname: federation.molecule.local + matrix_bot_meowlnir_container_labels_policy_server_traefik_path_prefix: /molecule-policy + matrix_bot_meowlnir_container_labels_policy_server_traefik_priority: 733 + matrix_bot_meowlnir_container_labels_policy_server_traefik_entrypoints: web + matrix_bot_meowlnir_container_labels_policy_server_traefik_tls: false + matrix_bot_meowlnir_container_labels_traefik_labels_additional_labels: | + molecule.meowlnir.coverage=enabled + + # Preserve the production-like no-host-port path; probes join its Docker network. + matrix_bot_meowlnir_container_http_host_bind_port: '' + 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_HOME:-~/.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-bot-meowlnir/molecule/default/prepare.yml b/roles/custom/matrix-bot-meowlnir/molecule/default/prepare.yml new file mode 100644 index 000000000..137b630b7 --- /dev/null +++ b/roles/custom/matrix-bot-meowlnir/molecule/default/prepare.yml @@ -0,0 +1,91 @@ +# SPDX-FileCopyrightText: 2026 Slavi Pantaleev +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +--- +- name: Prepare matrix-bot-meowlnir 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_facts['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 + + # matrix-base creates this identity in the complete playbook. Role file tasks resolve + # owner/group by name, so the role-scoped scenario supplies that side effect. + - 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" + + # Postgres and the stub must join this network before the role's converge creates it. + - name: Ensure the container network the role attaches to exists + ansible.builtin.command: + argv: + - docker + - network + - create + - "{{ matrix_bot_meowlnir_container_network }}" + register: matrix_bot_meowlnir_molecule_network + changed_when: matrix_bot_meowlnir_molecule_network.rc == 0 + failed_when: + - matrix_bot_meowlnir_molecule_network.rc != 0 + - "'already exists' not in matrix_bot_meowlnir_molecule_network.stderr" + + - name: Ensure Postgres is running + ansible.builtin.include_tasks: + file: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/../../../molecule-shared/tasks/postgres.yml" + vars: + molecule_shared_postgres_network: "{{ matrix_bot_meowlnir_container_network }}" + molecule_shared_postgres_database: "{{ matrix_bot_meowlnir_database_name }}" + molecule_shared_postgres_username: "{{ matrix_bot_meowlnir_database_username }}" + molecule_shared_postgres_password: "{{ matrix_bot_meowlnir_database_password }}" + + # Keep a local Matrix API available for bounded diagnostics and future provisioning + # coverage. Empty-roster startup currently makes no outbound homeserver request; the + # verifier therefore does not claim this stub as readiness evidence. + - 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_bot_meowlnir_container_network }}" + molecule_shared_stub_server_name: molecule.local + molecule_shared_stub_user_id: "@{{ matrix_bot_meowlnir_appservice_sender_localpart }}:{{ matrix_bot_meowlnir_config_homeserver_domain }}" diff --git a/roles/custom/matrix-bot-meowlnir/molecule/default/requirements.yml b/roles/custom/matrix-bot-meowlnir/molecule/default/requirements.yml new file mode 120000 index 000000000..1567a5392 --- /dev/null +++ b/roles/custom/matrix-bot-meowlnir/molecule/default/requirements.yml @@ -0,0 +1 @@ +../../../../../molecule-shared/requirements.yml \ No newline at end of file diff --git a/roles/custom/matrix-bot-meowlnir/molecule/default/verify.yml b/roles/custom/matrix-bot-meowlnir/molecule/default/verify.yml new file mode 100644 index 000000000..e4292903b --- /dev/null +++ b/roles/custom/matrix-bot-meowlnir/molecule/default/verify.yml @@ -0,0 +1,504 @@ +# SPDX-FileCopyrightText: 2026 Slavi Pantaleev +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +--- +# Meowlnir's authenticated management API and Postgres schema are the readiness evidence. +# The homeserver is a deliberately limited local stub, so no claim is made about moderation. +- name: Verify matrix-bot-meowlnir + 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" + vars: + matrix_bot_meowlnir_rendered_config: "{{ matrix_bot_meowlnir_config_file.content | b64decode | from_yaml }}" + matrix_bot_meowlnir_rendered_registration: "{{ matrix_bot_meowlnir_registration_file.content | b64decode | from_yaml }}" + matrix_bot_meowlnir_api_body: "{{ matrix_bot_meowlnir_api_http.stdout_lines[:-1] | join('\n') }}" + matrix_bot_meowlnir_api_response: "{{ matrix_bot_meowlnir_api_body | from_json }}" + matrix_bot_meowlnir_expected_database_uri: >- + postgresql://{{ matrix_bot_meowlnir_database_username }}:{{ matrix_bot_meowlnir_database_password }}@{{ matrix_bot_meowlnir_database_hostname }}:5432/{{ matrix_bot_meowlnir_database_name }}?sslmode={{ matrix_bot_meowlnir_database_sslmode }} + matrix_bot_meowlnir_labels_lines: "{{ (matrix_bot_meowlnir_labels_file.content | b64decode).splitlines() }}" + matrix_bot_meowlnir_runtime: "{{ (matrix_bot_meowlnir_container_inspect.stdout | from_json) | first }}" + matrix_bot_meowlnir_config_mounts: "{{ matrix_bot_meowlnir_runtime.Mounts | selectattr('Destination', 'equalto', '/data/config') | list }}" + matrix_bot_meowlnir_data_mounts: "{{ matrix_bot_meowlnir_runtime.Mounts | selectattr('Destination', 'equalto', '/data') | list }}" + 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: matrix_bot_meowlnir_role_defaults + + - name: Wait for the matrix-bot-meowlnir service to become active + ansible.builtin.systemd_service: + name: matrix-bot-meowlnir.service + register: matrix_bot_meowlnir_service + until: matrix_bot_meowlnir_service.status.ActiveState == 'active' + retries: 30 + delay: 5 + failed_when: false + + - name: Assert the service is active and has not restarted + ansible.builtin.assert: + that: + - matrix_bot_meowlnir_service.status.ActiveState == 'active' + - matrix_bot_meowlnir_service.status.NRestarts is defined + - matrix_bot_meowlnir_service.status.NRestarts | int == 0 + fail_msg: >- + matrix-bot-meowlnir.service is + {{ matrix_bot_meowlnir_service.status.ActiveState | default('unknown') }} after + {{ matrix_bot_meowlnir_service.status.NRestarts | default('?') }} restart(s) + success_msg: "matrix-bot-meowlnir.service is active and has not restarted" + + - name: Wait for Meowlnir's authenticated management API + ansible.builtin.command: + argv: + - docker + - run + - --rm + - --network={{ matrix_bot_meowlnir_container_network }} + - "{{ molecule_shared_image_curl }}" + - --silent + - --show-error + - --header + - 'Authorization: Bearer molecule_management_secret_a64528' + - --write-out + - "\nHTTP_STATUS=%{http_code}" + - http://matrix-bot-meowlnir:29439/_meowlnir/v1/bots + register: matrix_bot_meowlnir_api_http + changed_when: false + until: "'HTTP_STATUS=200' in matrix_bot_meowlnir_api_http.stdout" + retries: 24 + delay: 5 + failed_when: false + + - name: Assert the management API authenticated the configured secret + ansible.builtin.assert: + that: + - matrix_bot_meowlnir_api_http.rc == 0 + - matrix_bot_meowlnir_api_http.stdout_lines[-1] == 'HTTP_STATUS=200' + - matrix_bot_meowlnir_api_response is mapping + - matrix_bot_meowlnir_api_response.bots is sequence + - matrix_bot_meowlnir_api_response.bots | length == 0 + fail_msg: >- + Meowlnir's management API did not authenticate or return its empty bot roster + ({{ matrix_bot_meowlnir_api_http.stdout | default('no output') }}) + success_msg: "Meowlnir's live management API authenticated the configured secret" + + - name: Ask the management API with a wrong secret + ansible.builtin.command: + argv: + - docker + - run + - --rm + - --network={{ matrix_bot_meowlnir_container_network }} + - "{{ molecule_shared_image_curl }}" + - --silent + - --show-error + - --header + - 'Authorization: Bearer molecule_wrong_secret' + - --output + - /dev/null + - --write-out + - "HTTP_STATUS=%{http_code}" + - http://matrix-bot-meowlnir:29439/_meowlnir/v1/bots + register: matrix_bot_meowlnir_wrong_secret_http + changed_when: false + failed_when: false + + - name: Assert the management API rejects a wrong secret + ansible.builtin.assert: + that: + - matrix_bot_meowlnir_wrong_secret_http.rc == 0 + - matrix_bot_meowlnir_wrong_secret_http.stdout in ['HTTP_STATUS=401', 'HTTP_STATUS=403'] + fail_msg: "Meowlnir's management API accepted the wrong bearer secret" + success_msg: "Meowlnir's management API rejects a wrong bearer secret" + + # This is the appservice half of the live homeserver handshake. A valid empty + # transaction can only reach the dispatcher if the process loaded its hs_token. + - name: Send a homeserver transaction to Meowlnir's appservice API + ansible.builtin.command: + argv: + - docker + - run + - --rm + - --network={{ matrix_bot_meowlnir_container_network }} + - "{{ molecule_shared_image_curl }}" + - --silent + - --show-error + - --request + - PUT + - --header + - 'Authorization: Bearer molecule_hs_token_c20dc3' + - --header + - 'Content-Type: application/json' + - --data + - '{"events":[],"ephemeral":[],"to_device":[]}' + - --write-out + - "\nHTTP_STATUS=%{http_code}" + - http://matrix-bot-meowlnir:29439/_matrix/app/v1/transactions/molecule-transaction + register: matrix_bot_meowlnir_transaction_http + changed_when: false + failed_when: false + + - name: Assert Meowlnir accepts a transaction authenticated with the configured homeserver token + ansible.builtin.assert: + that: + - matrix_bot_meowlnir_transaction_http.rc == 0 + - matrix_bot_meowlnir_transaction_http.stdout_lines[-1] == 'HTTP_STATUS=200' + - matrix_bot_meowlnir_transaction_http.stdout_lines[:-1] | join('\n') | from_json == {} + fail_msg: >- + Meowlnir rejected an empty appservice transaction carrying the configured hs_token + ({{ matrix_bot_meowlnir_transaction_http.stdout | default('no output') }}) + success_msg: "Meowlnir accepts transactions carrying the configured homeserver token" + + - name: Send an appservice transaction with a wrong homeserver token + ansible.builtin.command: + argv: + - docker + - run + - --rm + - --network={{ matrix_bot_meowlnir_container_network }} + - "{{ molecule_shared_image_curl }}" + - --silent + - --show-error + - --request + - PUT + - --header + - 'Authorization: Bearer molecule_wrong_hs_token' + - --header + - 'Content-Type: application/json' + - --data + - '{"events":[]}' + - --output + - /dev/null + - --write-out + - "HTTP_STATUS=%{http_code}" + - http://matrix-bot-meowlnir:29439/_matrix/app/v1/transactions/molecule-wrong-token + register: matrix_bot_meowlnir_wrong_transaction_http + changed_when: false + failed_when: false + + - name: Assert Meowlnir rejects a transaction with the wrong homeserver token + ansible.builtin.assert: + that: + - matrix_bot_meowlnir_wrong_transaction_http.rc == 0 + - matrix_bot_meowlnir_wrong_transaction_http.stdout == 'HTTP_STATUS=401' + fail_msg: "Meowlnir accepted an appservice transaction carrying a wrong hs_token" + success_msg: "Meowlnir rejects transactions carrying a wrong homeserver token" + + - name: Probe the role's default Meowlnir port + ansible.builtin.command: + argv: + - docker + - run + - --rm + - --network={{ matrix_bot_meowlnir_container_network }} + - "{{ molecule_shared_image_curl }}" + - --silent + - --show-error + - --max-time + - '3' + - --output + - /dev/null + - --write-out + - "HTTP_STATUS=%{http_code}" + - http://matrix-bot-meowlnir:29339/_meowlnir/v1/bots + register: matrix_bot_meowlnir_default_port_http + changed_when: false + failed_when: false + + - name: Assert Meowlnir is not listening on the role's default port + ansible.builtin.assert: + that: + - matrix_bot_meowlnir_default_port_http.rc != 0 + - matrix_bot_meowlnir_default_port_http.stdout == 'HTTP_STATUS=000' + fail_msg: "Meowlnir still answers on 29339, so the configured port is not proven" + success_msg: "Meowlnir does not listen on the role's default port" + + - name: Read the configuration file the role rendered + ansible.builtin.slurp: + src: "{{ matrix_bot_meowlnir_config_path }}/config.yaml" + register: matrix_bot_meowlnir_config_file + + - name: Assert the parsed homeserver and appservice configuration + ansible.builtin.assert: + that: + - matrix_bot_meowlnir_rendered_config.homeserver.address == matrix_bot_meowlnir_config_homeserver_address + - matrix_bot_meowlnir_rendered_config.homeserver.domain == matrix_bot_meowlnir_config_homeserver_domain + - matrix_bot_meowlnir_rendered_config.meowlnir.id == matrix_bot_meowlnir_appservice_id + - matrix_bot_meowlnir_rendered_config.meowlnir.as_token == matrix_bot_meowlnir_appservice_token + - matrix_bot_meowlnir_rendered_config.meowlnir.hs_token == matrix_bot_meowlnir_homeserver_token + - matrix_bot_meowlnir_rendered_config.meowlnir.address == matrix_bot_meowlnir_appservice_url + - matrix_bot_meowlnir_rendered_config.meowlnir.hostname == '0.0.0.0' + - matrix_bot_meowlnir_rendered_config.meowlnir.port == matrix_bot_meowlnir_config_meowlnir_port + fail_msg: "The parsed configuration lost the scenario's Matrix/appservice wiring" + success_msg: "The parsed configuration carries the scenario's Matrix/appservice wiring" + + - name: Assert the parsed Meowlnir behavior and secrets + ansible.builtin.assert: + that: + - matrix_bot_meowlnir_rendered_config.meowlnir.management_secret == matrix_bot_meowlnir_config_meowlnir_management_secret + - matrix_bot_meowlnir_rendered_config.meowlnir.data_secret == matrix_bot_meowlnir_config_meowlnir_data_secret + - matrix_bot_meowlnir_rendered_config.meowlnir.federation_auth is sameas true + - matrix_bot_meowlnir_rendered_config.meowlnir.dry_run is sameas true + - matrix_bot_meowlnir_rendered_config.meowlnir.untrusted is sameas true + - matrix_bot_meowlnir_rendered_config.meowlnir.report_room == '!molecule-reports:molecule.local' + - matrix_bot_meowlnir_rendered_config.meowlnir.room_ban_room == '!molecule-bans:molecule.local' + - matrix_bot_meowlnir_rendered_config.meowlnir.hacky_rule_filter == ['@trusted:molecule.local', 'trusted.invalid'] + - matrix_bot_meowlnir_rendered_config.meowlnir.hacky_redact_patterns == ['spam', 'molecule-abuse-*'] + - matrix_bot_meowlnir_rendered_config.meowlnir.admin_tokens['@molecule-admin:molecule.local'] == 'molecule_admin_token_7e6ce0' + fail_msg: "The parsed configuration lost the non-default Meowlnir behavior" + success_msg: "The parsed configuration carries the non-default Meowlnir behavior" + + - name: Assert the parsed provisioning, antispam and policy configuration + ansible.builtin.assert: + that: + - matrix_bot_meowlnir_rendered_config.meowlnir4all.admin_room == '!molecule-provisioning:molecule.local' + - matrix_bot_meowlnir_rendered_config.meowlnir4all.localpart_template == matrix_bot_meowlnir_user_prefix + ('{' + '{ uuidgen }' + '}') + - matrix_bot_meowlnir_rendered_config.meowlnir4all.displayname == 'Molecule Provisioned Meowlnir' + - matrix_bot_meowlnir_rendered_config.meowlnir4all.avatar_url == 'mxc://molecule.local/meowlnir-avatar' + - matrix_bot_meowlnir_rendered_config.meowlnir4all.room_name == 'Molecule Meowlnir Control' + - matrix_bot_meowlnir_rendered_config.meowlnir4all.default_watched_lists | length == 1 + - matrix_bot_meowlnir_rendered_config.meowlnir4all.default_watched_lists[0].name == 'Molecule policy list' + - matrix_bot_meowlnir_rendered_config.meowlnir4all.default_watched_lists[0].room_id == '!molecule-policy:molecule.local' + - matrix_bot_meowlnir_rendered_config.meowlnir4all.default_watched_lists[0].shortcode == 'molecule' + - matrix_bot_meowlnir_rendered_config.meowlnir4all.default_watched_lists[0].auto_unban is sameas false + - matrix_bot_meowlnir_rendered_config.antispam.secret == matrix_bot_meowlnir_config_antispam_secret + - matrix_bot_meowlnir_rendered_config.antispam.filter_local_invites is sameas true + - matrix_bot_meowlnir_rendered_config.antispam.auto_reject_invites_token == 'molecule_reject_token_a9812f' + - matrix_bot_meowlnir_rendered_config.antispam.notify_management_room is sameas true + - matrix_bot_meowlnir_rendered_config.antispam.block_invites_to == ['@blocked:molecule.local'] + - matrix_bot_meowlnir_rendered_config.policy_server.always_redact is sameas false + - matrix_bot_meowlnir_rendered_config.policy_server.signing_key.startswith('ed25519 policy_server ') + fail_msg: "The parsed provisioning/antispam/policy blocks lost scenario values" + success_msg: "The parsed provisioning/antispam/policy blocks carry scenario values" + + - name: Assert the parsed Postgres and logging configuration + ansible.builtin.assert: + that: + - matrix_bot_meowlnir_rendered_config.database.type == 'postgres' + - matrix_bot_meowlnir_rendered_config.database.uri == matrix_bot_meowlnir_expected_database_uri + - matrix_bot_meowlnir_rendered_config.database.max_open_conns == 13 + - matrix_bot_meowlnir_rendered_config.database.max_idle_conns == 3 + - matrix_bot_meowlnir_rendered_config.database.max_conn_idle_time == '47s' + - matrix_bot_meowlnir_rendered_config.database.max_conn_lifetime == '11m' + - matrix_bot_meowlnir_rendered_config.synapse_db.type == 'postgres' + - matrix_bot_meowlnir_rendered_config.synapse_db.uri == '' + - matrix_bot_meowlnir_rendered_config.logging.min_level == 'debug' + - "matrix_bot_meowlnir_rendered_config.logging.writers == [{'type': 'stdout', 'format': 'json'}]" + fail_msg: "The parsed database/logging blocks lost scenario values" + success_msg: "The parsed database/logging blocks carry scenario values" + + - name: Read the appservice registration the role rendered + ansible.builtin.slurp: + src: "{{ matrix_bot_meowlnir_config_path }}/registration.yaml" + register: matrix_bot_meowlnir_registration_file + + - name: Assert the parsed appservice registration contract + ansible.builtin.assert: + that: + - matrix_bot_meowlnir_rendered_registration.id == matrix_bot_meowlnir_appservice_id + - matrix_bot_meowlnir_rendered_registration.as_token == matrix_bot_meowlnir_appservice_token + - matrix_bot_meowlnir_rendered_registration.hs_token == matrix_bot_meowlnir_homeserver_token + - matrix_bot_meowlnir_rendered_registration.url == matrix_bot_meowlnir_appservice_url + - matrix_bot_meowlnir_rendered_registration.sender_localpart == matrix_bot_meowlnir_appservice_sender_localpart + - matrix_bot_meowlnir_rendered_registration.rate_limited is sameas false + - matrix_bot_meowlnir_rendered_registration.namespaces.users | length == 2 + - matrix_bot_meowlnir_rendered_registration.namespaces.users[0].exclusive is sameas true + - matrix_bot_meowlnir_rendered_registration.namespaces.users[0].regex == '^@molecule_meowlnir_bot_[a-zA-Z0-9._=/+-]+:molecule\\.local$' + - matrix_bot_meowlnir_rendered_registration.namespaces.users[1].regex == '^@molecule_meowlnir_as:molecule\\.local$' + - matrix_bot_meowlnir_rendered_registration['de.sorunome.msc2409.push_ephemeral'] is not defined + fail_msg: "The appservice registration lost its tokens, address or namespaces" + success_msg: "The appservice registration carries its tokens, address and namespaces" + + - name: List the tables Meowlnir created in Postgres + ansible.builtin.command: + argv: + - docker + - exec + - matrix-postgres-molecule + - psql + - --username={{ matrix_bot_meowlnir_database_username }} + - --dbname={{ matrix_bot_meowlnir_database_name }} + - --tuples-only + - --no-align + - --command=SELECT tablename FROM pg_tables WHERE schemaname = 'public' + register: matrix_bot_meowlnir_tables + changed_when: false + + - name: Assert Meowlnir migrated its schema into the configured Postgres database + ansible.builtin.assert: + that: + - matrix_bot_meowlnir_tables.rc == 0 + - "'version' in matrix_bot_meowlnir_table_names" + - "'bot' in matrix_bot_meowlnir_table_names" + - "'management_room' in matrix_bot_meowlnir_table_names" + - "'mx_version' in matrix_bot_meowlnir_table_names" + - "'mx_room_state' in matrix_bot_meowlnir_table_names" + - "'policy_server_signature' in matrix_bot_meowlnir_table_names" + - matrix_bot_meowlnir_table_names | length == 10 + fail_msg: >- + Meowlnir did not create a substantial schema in {{ matrix_bot_meowlnir_database_name }} + (found {{ matrix_bot_meowlnir_table_names | length }} table(s)) + success_msg: "Meowlnir migrated its schema into the configured Postgres database" + vars: + matrix_bot_meowlnir_table_names: "{{ matrix_bot_meowlnir_tables.stdout_lines | select | list }}" + + - name: Read Meowlnir's two database migration markers + ansible.builtin.command: + argv: + - docker + - exec + - matrix-postgres-molecule + - psql + - --username={{ matrix_bot_meowlnir_database_username }} + - --dbname={{ matrix_bot_meowlnir_database_name }} + - --tuples-only + - --no-align + - --command=SELECT 'main:' || version || ':' || compat FROM version UNION ALL SELECT 'matrix_state:' || version || ':' || compat FROM mx_version ORDER BY 1 + register: matrix_bot_meowlnir_migration_versions + changed_when: false + + - name: Assert both Meowlnir schema sections reached their expected versions + ansible.builtin.assert: + that: + - matrix_bot_meowlnir_migration_versions.rc == 0 + - matrix_bot_meowlnir_migration_versions.stdout_lines | select | list == ['main:3:1', 'matrix_state:11:3'] + fail_msg: >- + Meowlnir's main and Matrix-state migrations did not reach versions 3 and 11 + ({{ matrix_bot_meowlnir_migration_versions.stdout_lines | select | list }}) + success_msg: "Both Meowlnir database schema sections reached their expected versions" + + - name: Read the labels the role rendered + ansible.builtin.slurp: + src: "{{ matrix_bot_meowlnir_base_path }}/labels" + register: matrix_bot_meowlnir_labels_file + + - name: Assert the labels carry the configured reporting and policy routes + ansible.builtin.assert: + that: + - "'traefik.enable=true' in matrix_bot_meowlnir_labels_lines" + - "'traefik.docker.network=matrix-bot-meowlnir-molecule' in matrix_bot_meowlnir_labels_lines" + - "'traefik.http.services.matrix-bot-meowlnir.loadbalancer.server.port=29439' in matrix_bot_meowlnir_labels_lines" + - "'traefik.http.routers.matrix-bot-meowlnir-reporting.rule=Host(`reports.molecule.local`) && PathRegexp(`^/molecule-report/(rooms|users)/[^/]+$`)' in matrix_bot_meowlnir_labels_lines" + - "'traefik.http.routers.matrix-bot-meowlnir-reporting.priority=731' in matrix_bot_meowlnir_labels_lines" + - "'traefik.http.routers.matrix-bot-meowlnir-reporting.entrypoints=web' in matrix_bot_meowlnir_labels_lines" + - "'traefik.http.routers.matrix-bot-meowlnir-reporting.tls=false' in matrix_bot_meowlnir_labels_lines" + - "'traefik.http.routers.matrix-bot-meowlnir-policy-server.rule=Host(`federation.molecule.local`) && PathPrefix(`/molecule-policy`)' in matrix_bot_meowlnir_labels_lines" + - "'traefik.http.routers.matrix-bot-meowlnir-policy-server.priority=733' in matrix_bot_meowlnir_labels_lines" + - "'traefik.http.routers.matrix-bot-meowlnir-policy-server.entrypoints=web' in matrix_bot_meowlnir_labels_lines" + - "'traefik.http.routers.matrix-bot-meowlnir-policy-server.tls=false' in matrix_bot_meowlnir_labels_lines" + - "'molecule.meowlnir.coverage=enabled' in matrix_bot_meowlnir_labels_lines" + fail_msg: "The role's labels lost the non-default public routing contract" + success_msg: "The role's labels carry the non-default public routing contract" + + - name: Inspect the running Meowlnir container + ansible.builtin.command: + argv: + - docker + - container + - inspect + - matrix-bot-meowlnir + register: matrix_bot_meowlnir_container_inspect + changed_when: false + + - name: Assert the running container uses the exact image pinned by the role + ansible.builtin.assert: + that: + - matrix_bot_meowlnir_runtime.Config.Image == matrix_bot_meowlnir_expected_image + fail_msg: "The running Meowlnir container does not use the role's exact image pin" + success_msg: "The running container uses the exact image pinned by the role" + vars: + matrix_bot_meowlnir_expected_image: >- + {{ matrix_bot_meowlnir_role_defaults.matrix_bot_meowlnir_container_image_registry_prefix_upstream_default }}{{ matrix_bot_meowlnir_role_defaults.matrix_bot_meowlnir_container_image_registry_namespace_identifier }}:{{ matrix_bot_meowlnir_role_defaults.matrix_bot_meowlnir_version }} + + - name: Assert the running container uses the playbook's matrix identity + ansible.builtin.assert: + that: + - matrix_bot_meowlnir_runtime.Config.User.split(':')[0] == matrix_user_uid | string + - matrix_bot_meowlnir_runtime.Config.User.split(':')[1] == matrix_user_gid | string + fail_msg: >- + Meowlnir runs as {{ matrix_bot_meowlnir_runtime.Config.User }} instead of + {{ matrix_user_uid }}:{{ matrix_user_gid }} + success_msg: "The running container uses the playbook's matrix UID and GID" + + - name: Assert the running container has the role's exact command + ansible.builtin.assert: + that: + - matrix_bot_meowlnir_runtime.Config.Cmd == ['/usr/bin/meowlnir', '--config=/data/config/config.yaml', '--no-update'] + fail_msg: "The running Meowlnir container command differs from the role contract" + success_msg: "The running container has the role's exact command" + + - name: Assert the container root filesystem is read-only + ansible.builtin.assert: + that: + - matrix_bot_meowlnir_runtime.HostConfig.ReadonlyRootfs is sameas true + fail_msg: "The Meowlnir container root filesystem is writable" + success_msg: "The Meowlnir container root filesystem is read-only" + + - name: Assert all Linux capabilities are dropped + ansible.builtin.assert: + that: + - matrix_bot_meowlnir_runtime.HostConfig.CapDrop == ['ALL'] + fail_msg: "The Meowlnir container does not drop all Linux capabilities" + success_msg: "The Meowlnir container drops all Linux capabilities" + + - name: Assert the configuration bind mount is read-only + ansible.builtin.assert: + that: + - matrix_bot_meowlnir_config_mounts | length == 1 + - matrix_bot_meowlnir_config_mounts[0].RW is sameas false + - matrix_bot_meowlnir_config_mounts[0].Source == matrix_bot_meowlnir_config_path + fail_msg: "The Meowlnir configuration bind mount is missing, misplaced or writable" + success_msg: "The configuration bind mount is present and read-only" + + - name: Assert the data bind mount is writable + ansible.builtin.assert: + that: + - matrix_bot_meowlnir_data_mounts | length == 1 + - matrix_bot_meowlnir_data_mounts[0].RW is sameas true + - matrix_bot_meowlnir_data_mounts[0].Source == matrix_bot_meowlnir_data_path + fail_msg: "The Meowlnir data bind mount is missing, misplaced or read-only" + success_msg: "The data bind mount is present and writable" + + - name: Assert the container is attached only to its dedicated network + ansible.builtin.assert: + that: + - matrix_bot_meowlnir_runtime.NetworkSettings.Networks is mapping + - matrix_bot_meowlnir_runtime.NetworkSettings.Networks | length == 1 + - matrix_bot_meowlnir_container_network in matrix_bot_meowlnir_runtime.NetworkSettings.Networks + fail_msg: >- + Meowlnir has unexpected networks: + {{ matrix_bot_meowlnir_runtime.NetworkSettings.Networks.keys() | list }} + success_msg: "The container is attached only to its dedicated network" + + - name: Ask Docker for Meowlnir's published ports + ansible.builtin.command: + argv: + - docker + - container + - port + - matrix-bot-meowlnir + register: matrix_bot_meowlnir_published_ports + changed_when: false + failed_when: false + + - name: Assert the role did not publish a host port + ansible.builtin.assert: + that: + - matrix_bot_meowlnir_runtime.HostConfig.PortBindings | default({}, true) | length == 0 + - matrix_bot_meowlnir_published_ports.rc == 0 + - matrix_bot_meowlnir_published_ports.stdout | trim | length == 0 + fail_msg: >- + Meowlnir unexpectedly publishes a host port: + {{ matrix_bot_meowlnir_published_ports.stdout | default('unknown') }} + success_msg: "The role leaves Meowlnir's HTTP port unpublished" + + - name: Assert Docker accepted the role's custom label + ansible.builtin.assert: + that: + - matrix_bot_meowlnir_runtime.Config.Labels is mapping + - matrix_bot_meowlnir_runtime.Config.Labels['molecule.meowlnir.coverage'] == 'enabled' + fail_msg: "Docker did not attach the custom label from the role's label file" + success_msg: "Docker accepted the role's custom label"