diff --git a/roles/custom/matrix-bot-matrix-reminder-bot/molecule/default/converge.yml b/roles/custom/matrix-bot-matrix-reminder-bot/molecule/default/converge.yml new file mode 100644 index 000000000..554ff5a27 --- /dev/null +++ b/roles/custom/matrix-bot-matrix-reminder-bot/molecule/default/converge.yml @@ -0,0 +1,43 @@ +# SPDX-FileCopyrightText: 2026 Slavi Pantaleev +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +--- +# The devture base roles carry the variables this role reads +# (`devture_systemd_docker_base_*`, `devture_playbook_help_*`), the same way +# they do when the playbook runs. `matrix-base` is deliberately NOT included: +# it does far more than this role needs, and the variables it would supply come +# from molecule-shared/playbook-context.yml instead. +- name: Include roles for matrix-reminder-bot 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-reminder-bot 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 + +# The role installs the unit but does not start it - in the playbook that is +# `systemd_service_manager`'s job - so the scenario starts it here. +- name: Ensure matrix-reminder-bot 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-matrix-reminder-bot systemd service is started + ansible.builtin.systemd_service: + name: matrix-bot-matrix-reminder-bot.service + state: started diff --git a/roles/custom/matrix-bot-matrix-reminder-bot/molecule/default/molecule.yml b/roles/custom/matrix-bot-matrix-reminder-bot/molecule/default/molecule.yml new file mode 100644 index 000000000..d05e50785 --- /dev/null +++ b/roles/custom/matrix-bot-matrix-reminder-bot/molecule/default/molecule.yml @@ -0,0 +1,98 @@ +# 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-reminder-bot-${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_matrix_reminder_bot_container_network: matrix-reminder-bot-molecule + + # Unlike the bridges, this bot is not an appservice: it logs into the + # homeserver as an ordinary user with a password. The stub prepare.yml + # stands up answers /_matrix/client/v3/login with an access token, which + # is all the bot needs to get past its login and into its sync loop. + matrix_bot_matrix_reminder_bot_matrix_homeserver_url: http://matrix.molecule.local:8008 + + # Deliberately different from the role's default localpart + # (`bot.matrix-reminder-bot`), so verify.yml can tell what the role + # rendered apart from what it would have rendered anyway. + matrix_bot_matrix_reminder_bot_matrix_user_id_localpart: molecule.reminder-bot + matrix_bot_matrix_reminder_bot_matrix_user_password: molecule_bot_password_4f2a91 + + # The role has no default here and refuses to run without one. Also + # different from the bot's own fallback (`Etc/UTC`), and it reaches the + # container twice - through the config file and through TZ on the unit. + matrix_bot_matrix_reminder_bot_reminders_timezone: Europe/Sofia + + # The role and the bot both default to `!`. + matrix_bot_matrix_reminder_bot_command_prefix: "%%" + + # Both lists default to off with no entries, so turning them on with + # entries of our own exercises the `_auto + _custom` composition. + matrix_bot_matrix_reminder_bot_allowlist_enabled: true + matrix_bot_matrix_reminder_bot_allowlist_regexes_custom: + - "@molecule-allowed:molecule.local" + matrix_bot_matrix_reminder_bot_blocklist_enabled: true + matrix_bot_matrix_reminder_bot_blocklist_regexes_custom: + - ".*:blocked.molecule.local" + + # The device name is hardcoded in the role's config template, so + # overriding it is only possible through the extension mechanism. Doing + # it here means the merge of template + extension is tested too. + matrix_bot_matrix_reminder_bot_configuration_extension_yaml: | + matrix: + device_name: Molecule Reminder Bot + + # The SQLite database path is moved off the role's default (`bot.db`) so + # that verify.yml can assert the bot opened the path the role gave it, + # with the default name as a negative control. + matrix_bot_matrix_reminder_bot_sqlite_database_path_local: /matrix/matrix-reminder-bot/data/molecule-reminders.db + matrix_bot_matrix_reminder_bot_sqlite_database_path_in_container: /data/molecule-reminders.db + + # verify.yml runs as its own play, where the role's defaults are out of + # scope, so the paths it reads are pinned here as literals. They match + # what the role derives from `matrix_base_data_path`. + matrix_bot_matrix_reminder_bot_base_path: /matrix/matrix-reminder-bot + matrix_bot_matrix_reminder_bot_config_path: /matrix/matrix-reminder-bot/config + matrix_bot_matrix_reminder_bot_data_path: /matrix/matrix-reminder-bot/data + matrix_bot_matrix_reminder_bot_data_store_path: /matrix/matrix-reminder-bot/data/store + 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-bot-matrix-reminder-bot/molecule/default/prepare.yml b/roles/custom/matrix-bot-matrix-reminder-bot/molecule/default/prepare.yml new file mode 100644 index 000000000..737dec095 --- /dev/null +++ b/roles/custom/matrix-bot-matrix-reminder-bot/molecule/default/prepare.yml @@ -0,0 +1,85 @@ +# SPDX-FileCopyrightText: 2026 Slavi Pantaleev +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +--- +- name: Prepare matrix-reminder-bot 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 before it runs. In a + # real deployment `matrix-base` creates them. + - 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" + + # The role creates this network itself during converge, but the homeserver + # stub has to be on it before the bot starts, so it is created here first. + - name: Ensure the container network the role attaches to exists + ansible.builtin.command: + argv: + - docker + - network + - create + - "{{ matrix_bot_matrix_reminder_bot_container_network }}" + register: matrix_bot_matrix_reminder_bot_molecule_network + changed_when: matrix_bot_matrix_reminder_bot_molecule_network.rc == 0 + failed_when: + - matrix_bot_matrix_reminder_bot_molecule_network.rc != 0 + - "'already exists' not in matrix_bot_matrix_reminder_bot_molecule_network.stderr" + + # This bot is not an appservice - it logs in with the username and password + # the role rendered into its configuration, and retries every 15 seconds + # until that succeeds. The shared stub answers the login with an access + # token, which is enough to get it into its sync loop. Nothing is asserted + # about the stub itself; 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_bot_matrix_reminder_bot_container_network }}" + molecule_shared_stub_user_id: "@{{ matrix_bot_matrix_reminder_bot_matrix_user_id_localpart }}:{{ matrix_domain }}" diff --git a/roles/custom/matrix-bot-matrix-reminder-bot/molecule/default/requirements.yml b/roles/custom/matrix-bot-matrix-reminder-bot/molecule/default/requirements.yml new file mode 120000 index 000000000..1567a5392 --- /dev/null +++ b/roles/custom/matrix-bot-matrix-reminder-bot/molecule/default/requirements.yml @@ -0,0 +1 @@ +../../../../../molecule-shared/requirements.yml \ No newline at end of file diff --git a/roles/custom/matrix-bot-matrix-reminder-bot/molecule/default/verify.yml b/roles/custom/matrix-bot-matrix-reminder-bot/molecule/default/verify.yml new file mode 100644 index 000000000..56750b957 --- /dev/null +++ b/roles/custom/matrix-bot-matrix-reminder-bot/molecule/default/verify.yml @@ -0,0 +1,243 @@ +# SPDX-FileCopyrightText: 2026 Slavi Pantaleev +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +--- +# What this proves: matrix-reminder-bot starts on the configuration the role +# rendered, logs into a homeserver as the user the role gave it, opens the +# database at the path the role gave it, and is the version the role pins. +# +# The bot has no HTTP surface of its own to probe, so the evidence is what it +# says about itself in the journal plus what it left on disk. It does NOT set +# real reminders and never will. See docs/molecule-testing.md. +- name: Verify matrix-reminder-bot + 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: + matrix_bot_matrix_reminder_bot_molecule_user_id: "@{{ matrix_bot_matrix_reminder_bot_matrix_user_id_localpart }}:{{ matrix_domain }}" + matrix_bot_matrix_reminder_bot_molecule_container_user: "{{ matrix_user_uid }}:{{ matrix_user_gid }}" + + tasks: + # The version is read out of the role's own defaults rather than pinned in + # molecule.yml, so that the assertion further down compares the running + # image against what defaults/main.yml actually ships. Pinning it here + # would make that assertion compare the scenario with 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: matrix_bot_matrix_reminder_bot_role_defaults + + - name: Wait for the matrix-reminder-bot service to become active + ansible.builtin.systemd_service: + name: matrix-bot-matrix-reminder-bot.service + register: matrix_bot_matrix_reminder_bot_service + until: matrix_bot_matrix_reminder_bot_service.status.ActiveState == 'active' + retries: 30 + delay: 5 + failed_when: false + + # `Restart=always` means a bot crash-looping on a configuration it cannot + # read still reports `active`, so the restart counter is checked too. The + # config file is parsed before the bot's own catch-all retry loop starts, so + # anything wrong in what the role rendered shows up here as restarts. + # Asserted as `is defined` too, because `| int` turns a missing property + # into 0 and would pass vacuously on a systemd that does not expose it. + - name: Assert the service is active and has not been restarting + ansible.builtin.assert: + that: + - matrix_bot_matrix_reminder_bot_service.status.ActiveState == 'active' + - matrix_bot_matrix_reminder_bot_service.status.NRestarts is defined + - matrix_bot_matrix_reminder_bot_service.status.NRestarts | int == 0 + fail_msg: >- + matrix-bot-matrix-reminder-bot.service is + {{ matrix_bot_matrix_reminder_bot_service.status.ActiveState | default('unknown') }} + after {{ matrix_bot_matrix_reminder_bot_service.status.NRestarts | default('?') }} + automatic restart(s) + success_msg: "matrix-bot-matrix-reminder-bot.service is active and has not restarted" + + # The unit runs `docker start --attach`, so the container's output is in the + # journal despite `--log-driver=none`. That is the only thing this bot + # reports about itself - it serves nothing over HTTP. + # + # Filtered rather than tailed: the startup lines are the oldest ones in the + # journal, so a `--lines=N` tail would lose them behind anything the bot + # logs later, and reading the journal whole would pull an unbounded amount + # of text into a variable. The filter keeps the failure line too, so the + # "did not fail to log in" assertion below still has something to see. + - name: Wait for the bot to report that it finished starting up + ansible.builtin.shell: + cmd: >- + set -o pipefail; + journalctl --unit=matrix-bot-matrix-reminder-bot.service --no-pager --output=cat --lines=all + | grep -E 'Logged in as|Startup complete|Failed to login|Database initialization' | head -n 50 || true + executable: /bin/bash + register: matrix_bot_matrix_reminder_bot_journal + changed_when: false + until: "'Startup complete' in matrix_bot_matrix_reminder_bot_journal.stdout" + retries: 24 + delay: 5 + failed_when: false + + # "Logged in as ..." is only reached after the bot's login call came back as + # something other than a LoginError, so this is the whole chain at once: the + # homeserver URL, the user ID and the password the role rendered were good + # enough for a real login round-trip against the stub. + - name: Assert the bot logged in as the user the role configured + ansible.builtin.assert: + that: + - "'Startup complete' in matrix_bot_matrix_reminder_bot_journal.stdout" + - "'Logged in as ' + matrix_bot_matrix_reminder_bot_molecule_user_id in matrix_bot_matrix_reminder_bot_journal.stdout" + - "'Failed to login' not in matrix_bot_matrix_reminder_bot_journal.stdout" + fail_msg: >- + The bot did not log in as {{ matrix_bot_matrix_reminder_bot_molecule_user_id }} + and reach startup + success_msg: >- + The bot logged in as {{ matrix_bot_matrix_reminder_bot_molecule_user_id }} and finished starting up + + # The role picks the storage engine (SQLite here, Postgres otherwise) by + # building the connection string the bot parses, and the bot names the type + # it settled on once the database is open. + - name: Assert the bot opened the database engine the role selected + ansible.builtin.assert: + that: + - "\"Database initialization of type 'sqlite' complete\" in matrix_bot_matrix_reminder_bot_journal.stdout" + fail_msg: "The bot did not report a completed SQLite database initialization" + success_msg: "The bot initialized the SQLite database the role pointed it at" + + - name: Read the configuration file the role rendered + ansible.builtin.slurp: + src: "{{ matrix_bot_matrix_reminder_bot_config_path }}/config.yaml" + register: matrix_bot_matrix_reminder_bot_config_file + + # Every one of these differs from both the role's defaults and the bot's own + # fallbacks, so their presence means the role rendered this file rather than + # the values coinciding with what would have happened anyway. + - name: Assert the rendered configuration carries this scenario's values + ansible.builtin.assert: + that: + - matrix_bot_matrix_reminder_bot_molecule_user_id in matrix_bot_matrix_reminder_bot_config_rendered + - matrix_bot_matrix_reminder_bot_matrix_user_password in matrix_bot_matrix_reminder_bot_config_rendered + - matrix_bot_matrix_reminder_bot_matrix_homeserver_url in matrix_bot_matrix_reminder_bot_config_rendered + - matrix_bot_matrix_reminder_bot_reminders_timezone in matrix_bot_matrix_reminder_bot_config_rendered + - matrix_bot_matrix_reminder_bot_command_prefix in matrix_bot_matrix_reminder_bot_config_rendered + - "'sqlite://' + matrix_bot_matrix_reminder_bot_sqlite_database_path_in_container in matrix_bot_matrix_reminder_bot_config_rendered" + - "'@molecule-allowed:molecule.local' in matrix_bot_matrix_reminder_bot_config_rendered" + - "'.*:blocked.molecule.local' in matrix_bot_matrix_reminder_bot_config_rendered" + fail_msg: "The rendered configuration does not carry the scenario's settings" + success_msg: "The rendered configuration carries the scenario's settings" + vars: + matrix_bot_matrix_reminder_bot_config_rendered: "{{ matrix_bot_matrix_reminder_bot_config_file.content | b64decode }}" + + # `device_name` is hardcoded in the role's config template, so this value can + # only be there if `..._configuration_extension_yaml` was merged over the + # template rather than ignored. + - name: Assert the configuration extension was merged over the template + ansible.builtin.assert: + that: + - "'device_name: Molecule Reminder Bot' in matrix_bot_matrix_reminder_bot_config_rendered" + - "'device_name: Reminder Bot' not in matrix_bot_matrix_reminder_bot_config_rendered" + fail_msg: >- + The configuration extension did not override the device name the + role's template hardcodes + success_msg: "The configuration extension was merged over the role's template" + vars: + matrix_bot_matrix_reminder_bot_config_rendered: "{{ matrix_bot_matrix_reminder_bot_config_file.content | b64decode }}" + + # The bot has no HTTP surface, so where its database landed is the evidence + # that the storage configuration reached the running process rather than + # merely the file on disk. + - name: Stat the database at the path the scenario configured + ansible.builtin.stat: + path: "{{ matrix_bot_matrix_reminder_bot_sqlite_database_path_local }}" + register: matrix_bot_matrix_reminder_bot_database + + - name: Assert the database landed under the role's data path, owned by the role's user + ansible.builtin.assert: + that: + - matrix_bot_matrix_reminder_bot_database.stat.exists + - matrix_bot_matrix_reminder_bot_database.stat.uid == matrix_user_uid + - matrix_bot_matrix_reminder_bot_database.stat.gid == matrix_user_gid + fail_msg: >- + {{ matrix_bot_matrix_reminder_bot_sqlite_database_path_local }} is missing or is not + owned by {{ matrix_user_uid }}:{{ matrix_user_gid }} + success_msg: >- + The database is at the configured path, owned by {{ matrix_user_uid }}:{{ matrix_user_gid }} + + # A negative control for the assertion above: the role's own default + # database name must NOT appear, or a file at the configured path would not + # prove the configuration reached the process. + - name: Stat the database name the role would have used by default + ansible.builtin.stat: + path: "{{ matrix_bot_matrix_reminder_bot_data_path }}/bot.db" + register: matrix_bot_matrix_reminder_bot_default_database + + - name: Assert the role's default database name was not used + ansible.builtin.assert: + that: + - not matrix_bot_matrix_reminder_bot_default_database.stat.exists + fail_msg: >- + {{ matrix_bot_matrix_reminder_bot_data_path }}/bot.db exists as well, so the + database at the configured path does not prove the role's storage + configuration reached the bot + success_msg: "Only the configured database path was used" + + # matrix-nio writes its encryption store here once a login has succeeded, so + # a populated directory means the bot could use the store path the role + # created for it inside an otherwise read-only container. + - name: List the encryption store the role created + ansible.builtin.find: + paths: "{{ matrix_bot_matrix_reminder_bot_data_store_path }}" + file_type: file + register: matrix_bot_matrix_reminder_bot_store_files + + - name: Assert the bot wrote its encryption store where the role put it + ansible.builtin.assert: + that: + - matrix_bot_matrix_reminder_bot_store_files.matched | int > 0 + fail_msg: >- + {{ matrix_bot_matrix_reminder_bot_data_store_path }} is empty, so the bot never + got far enough to open its encryption store + success_msg: "The bot wrote its encryption store under the role's data path" + + - name: Read the running container's user and environment + ansible.builtin.command: + argv: + - docker + - container + - inspect + - matrix-bot-matrix-reminder-bot + - --format + - "{{ '{{' }} .Config.User {{ '}}' }} {{ '{{' }} json .Config.Env {{ '}}' }} {{ '{{' }} .Config.Image {{ '}}' }}" + register: matrix_bot_matrix_reminder_bot_container + changed_when: false + + # The timezone reaches the container twice - through the config file checked + # above and through TZ on the unit - and the uid/gid come from the playbook + # context rather than from anything the image would pick on its own. + - name: Assert the container runs as the role's user with the configured timezone + ansible.builtin.assert: + that: + - "'\"TZ=' + matrix_bot_matrix_reminder_bot_reminders_timezone + '\"' in matrix_bot_matrix_reminder_bot_container.stdout" + - matrix_bot_matrix_reminder_bot_molecule_container_user in matrix_bot_matrix_reminder_bot_container.stdout + fail_msg: >- + The container does not run as {{ matrix_user_uid }}:{{ matrix_user_gid }} with + TZ={{ matrix_bot_matrix_reminder_bot_reminders_timezone }} + ({{ matrix_bot_matrix_reminder_bot_container.stdout }}) + success_msg: >- + The container runs as {{ matrix_user_uid }}:{{ matrix_user_gid }} with + TZ={{ matrix_bot_matrix_reminder_bot_reminders_timezone }} + + - name: Assert the running container is the version defaults/main.yml pins + ansible.builtin.assert: + that: + - matrix_bot_matrix_reminder_bot_role_defaults.matrix_bot_matrix_reminder_bot_version | string in matrix_bot_matrix_reminder_bot_container.stdout + fail_msg: >- + The running container is {{ matrix_bot_matrix_reminder_bot_container.stdout }}, which + does not carry the pinned version + {{ matrix_bot_matrix_reminder_bot_role_defaults.matrix_bot_matrix_reminder_bot_version }} + success_msg: "The running container is the version defaults/main.yml pins"