diff --git a/molecule-shared/homeserver-stub.py b/molecule-shared/homeserver-stub.py index b0a334f58..3331cd247 100644 --- a/molecule-shared/homeserver-stub.py +++ b/molecule-shared/homeserver-stub.py @@ -25,7 +25,9 @@ import json import os import re import sys +import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlparse SERVER_NAME = os.environ.get("STUB_SERVER_NAME", "molecule.local") PORT = int(os.environ.get("STUB_PORT", "8008")) @@ -37,6 +39,13 @@ JOINED_ROOMS = [r for r in os.environ.get("STUB_JOINED_ROOMS", "").split(",") if USER_ID = os.environ.get("STUB_USER_ID", f"@stub:{SERVER_NAME}") +# Longest a /sync call is held open. Long-polling clients (anything on +# matrix-sdk: baibot and the other bots) ask for a 30s timeout and immediately +# ask again when the call returns, so answering instantly would spin them into a +# hot loop that eats the test machine. Honouring the requested timeout, capped +# here, keeps an idle bot idle. +SYNC_MAX_HOLD_SECONDS = 30 + class Handler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" @@ -50,7 +59,40 @@ class Handler(BaseHTTPRequestHandler): self.wfile.write(body) def _route(self): - path = self.path.split("?", 1)[0] + parsed = urlparse(self.path) + path = parsed.path + + # A client that syncs (every bot here does) needs a `next_batch` back or + # the response will not deserialize, and it needs the call to block for + # the timeout it asked for or it will hammer this stub. Nothing is ever + # reported: an idle bot is what a scenario wants. + if path.endswith("/sync"): + requested_ms = parse_qs(parsed.query).get("timeout", ["0"])[0] + try: + hold = min(int(requested_ms) / 1000.0, SYNC_MAX_HOLD_SECONDS) + except ValueError: + hold = 0 + if hold > 0: + time.sleep(hold) + return {"next_batch": "molecule-stub-batch"} + + # Before the generic `/upload` below: this one is the end-to-end + # encryption key upload, and the client insists on the key counts. + if path.endswith("/keys/upload"): + return {"one_time_key_counts": {}} + + # Media. A bot that sets its own avatar asks for the upload limits first + # and refuses to proceed without them, then uploads and expects an MXC + # URI back. + if path.endswith("/media/config") or path.endswith("/media/v3/config"): + return {"m.upload.size": 10485760} + + if path.endswith("/upload"): + return {"content_uri": f"mxc://{SERVER_NAME}/molecule-stub-media"} + + # Sync filters are uploaded before the first sync and referenced by id. + if path.endswith("/filter"): + return {"filter_id": "molecule-stub-filter"} if path.endswith("/joined_rooms"): return {"joined_rooms": JOINED_ROOMS} @@ -67,7 +109,13 @@ class Handler(BaseHTTPRequestHandler): if path.endswith("/capabilities"): return {"capabilities": {}} - if path.endswith("/_matrix/client/v3/login"): + # Bots that authenticate with a username and password rather than as an + # appservice with a token log in here. Matched loosely on purpose: + # clients differ on the API version prefix (matrix-nio has shipped both + # /_matrix/client/r0/login and /_matrix/client/v3/login over time), and a + # login that falls through to the catch-all `{}` below looks to the + # client like bad credentials. + if path.endswith("/login"): return { "user_id": USER_ID, "access_token": "stub_access_token", diff --git a/molecule-shared/tasks/homeserver-stub.yml b/molecule-shared/tasks/homeserver-stub.yml index 12ed7dc31..939ff286e 100644 --- a/molecule-shared/tasks/homeserver-stub.yml +++ b/molecule-shared/tasks/homeserver-stub.yml @@ -54,6 +54,12 @@ # returned is not the bot user they were configured as, so a scenario # bridging anything has to tell the stub who it should claim to be. - --env=STUB_USER_ID={{ molecule_shared_stub_user_id | default('@stub:' + (molecule_shared_stub_server_name | default('molecule.local'))) }} + # Off by default. Set molecule_shared_stub_verbose to "1" to have the stub + # log every request it is asked for, which is both how you find out why a + # component will not start and - for a component that exposes no port of + # its own - the only place a scenario can observe it acting on what the + # role configured. + - --env=STUB_VERBOSE={{ molecule_shared_stub_verbose | default('') }} - --volume=/root/molecule-homeserver-stub.py:/stub.py:ro - "{{ molecule_shared_image_python }}" - python3 diff --git a/roles/custom/matrix-bot-baibot/molecule/default/converge.yml b/roles/custom/matrix-bot-baibot/molecule/default/converge.yml new file mode 100644 index 000000000..195105498 --- /dev/null +++ b/roles/custom/matrix-bot-baibot/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 what it would supply comes from +# molecule-shared/playbook-context.yml instead. +- name: Include roles for matrix-bot-baibot 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-baibot 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-bot-baibot 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-baibot systemd service is started + ansible.builtin.systemd_service: + name: matrix-bot-baibot.service + state: started diff --git a/roles/custom/matrix-bot-baibot/molecule/default/molecule.yml b/roles/custom/matrix-bot-baibot/molecule/default/molecule.yml new file mode 100644 index 000000000..81ab33bce --- /dev/null +++ b/roles/custom/matrix-bot-baibot/molecule/default/molecule.yml @@ -0,0 +1,93 @@ +# 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-baibot-${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_baibot_container_network: matrix-bot-baibot-molecule + + # 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_baibot_base_path: /matrix/baibot + matrix_bot_baibot_config_path: /matrix/baibot/config + matrix_bot_baibot_data_path: /matrix/baibot/data + + # baibot is a plain Matrix client, not an appservice: it logs in with a + # password and then syncs, so a homeserver has to answer for it to get + # anywhere. prepare.yml stands up the shared stub for that. + matrix_bot_baibot_config_homeserver_url: http://matrix.molecule.local:8008 + + # Deliberately different from the role's defaults (localpart `baibot`, + # name `baibot`, prefix `!bai`, self-introduction on) AND from baibot's + # own built-in defaults, so that a passing assertion cannot be explained + # by "it would have happened anyway". + matrix_bot_baibot_config_user_mxid_localpart: molecule-baibot + matrix_bot_baibot_config_user_name: Molecule baibot + matrix_bot_baibot_config_user_password: molecule_baibot_password_5b7c14 + matrix_bot_baibot_config_command_prefix: "!molecule-bai" + matrix_bot_baibot_config_room_post_join_self_introduction_enabled: false + matrix_bot_baibot_config_access_admin_patterns: + - "@molecule-admin:molecule.local" + + # `debug` rather than the role's `info`, so the journal carries what the + # bot loaded. verify.yml reads it. + matrix_bot_baibot_config_logging_level_baibot: debug + + # baibot talks to AI providers, and a scenario must not need a provider + # account. It does not have to: providers are contacted only when a + # message asks an agent to do something, never at startup. So a static + # agent is defined with a placeholder key and a base URL that resolves + # nowhere. Nothing is ever called, and the agent still has to survive + # baibot's startup parsing of `agents.static_definitions` - which is + # what proves the role's provider templating produced something the bot + # accepts. + matrix_bot_baibot_config_agents_static_definitions_anthropic_enabled: true + matrix_bot_baibot_config_agents_static_definitions_anthropic_id: molecule-anthropic + matrix_bot_baibot_config_agents_static_definitions_anthropic_config_base_url: http://molecule-no-such-provider.invalid/v1 + matrix_bot_baibot_config_agents_static_definitions_anthropic_config_api_key: molecule-placeholder-not-a-real-key + matrix_bot_baibot_config_agents_static_definitions_anthropic_config_text_generation_model_id: molecule-model-4-2 + matrix_bot_baibot_config_agents_static_definitions_anthropic_config_text_generation_temperature: 0.25 + matrix_bot_baibot_config_agents_static_definitions_anthropic_config_text_generation_max_response_tokens: 1234 + matrix_bot_baibot_config_agents_static_definitions_anthropic_config_text_generation_max_context_tokens: 56789 + 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-baibot/molecule/default/prepare.yml b/roles/custom/matrix-bot-baibot/molecule/default/prepare.yml new file mode 100644 index 000000000..f7731e2dc --- /dev/null +++ b/roles/custom/matrix-bot-baibot/molecule/default/prepare.yml @@ -0,0 +1,85 @@ +# SPDX-FileCopyrightText: 2026 Slavi Pantaleev +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +--- +- name: Prepare matrix-bot-baibot 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" + + - name: Ensure the container network the role attaches to exists + ansible.builtin.command: + argv: + - docker + - network + - create + - "{{ matrix_bot_baibot_container_network }}" + register: matrix_bot_baibot_molecule_network + changed_when: matrix_bot_baibot_molecule_network.rc == 0 + failed_when: + - matrix_bot_baibot_molecule_network.rc != 0 + - "'already exists' not in matrix_bot_baibot_molecule_network.stderr" + + # baibot logs in and then syncs forever; with no homeserver answering it + # never gets past login and the unit crash-loops. The shared stub is enough + # - see molecule-shared/homeserver-stub.py for what it is not. + # + # It is told to claim the bot's own MXID, because the bot resolves who it is + # from what the homeserver hands back at login, and everything it does + # afterwards (its profile, its own-message filtering) hangs off that. + - 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_baibot_container_network }}" + molecule_shared_stub_user_id: "@{{ matrix_bot_baibot_config_user_mxid_localpart }}:{{ matrix_domain }}" diff --git a/roles/custom/matrix-bot-baibot/molecule/default/requirements.yml b/roles/custom/matrix-bot-baibot/molecule/default/requirements.yml new file mode 120000 index 000000000..1567a5392 --- /dev/null +++ b/roles/custom/matrix-bot-baibot/molecule/default/requirements.yml @@ -0,0 +1 @@ +../../../../../molecule-shared/requirements.yml \ No newline at end of file diff --git a/roles/custom/matrix-bot-baibot/molecule/default/verify.yml b/roles/custom/matrix-bot-baibot/molecule/default/verify.yml new file mode 100644 index 000000000..846ff049a --- /dev/null +++ b/roles/custom/matrix-bot-baibot/molecule/default/verify.yml @@ -0,0 +1,248 @@ +# SPDX-FileCopyrightText: 2026 Slavi Pantaleev +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +--- +- name: Verify matrix-bot-baibot + 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: + # 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_baibot_role_defaults + + - name: Wait for the matrix-bot-baibot service to become active + ansible.builtin.systemd_service: + name: matrix-bot-baibot.service + register: matrix_bot_baibot_service + until: matrix_bot_baibot_service.status.ActiveState == 'active' + retries: 30 + delay: 5 + failed_when: false + + # `Restart=always` means a crash-looping container still reports `active`, + # so the restart counter is checked alongside it. 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_baibot_service.status.ActiveState == 'active' + - matrix_bot_baibot_service.status.NRestarts is defined + - matrix_bot_baibot_service.status.NRestarts | int == 0 + fail_msg: >- + matrix-bot-baibot.service is + {{ matrix_bot_baibot_service.status.ActiveState | default('unknown') }} + after {{ matrix_bot_baibot_service.status.NRestarts | default('?') }} + automatic restart(s) + success_msg: "matrix-bot-baibot.service is active and has not restarted" + + # baibot publishes no port of its own - it is a Matrix client, not a server - + # so what it says about itself has to come from its output. The unit runs + # `docker start --attach`, so `--log-driver=none` on the container does not + # stop the journal from carrying it. + # + # `Syncing..` is the line that matters, and it is what carries this scenario + # rather than the unit check above. baibot does not exit when its startup + # goes wrong: a profile step it cannot complete is retried forever with a + # growing delay, so the unit stays `active` with `NRestarts` at 0 while the + # bot never reaches its message loop. Point the avatar at a file that is not + # there and the assertion above still passes; this one does not. + - name: Wait for baibot to reach its sync loop + ansible.builtin.shell: + cmd: >- + set -o pipefail && journalctl -u matrix-bot-baibot.service --no-pager -o cat + | sed -e 's/\x1b\[[0-9;]*m//g' + executable: /bin/bash + register: matrix_bot_baibot_journal + changed_when: false + until: "'Syncing..' in matrix_bot_baibot_journal.stdout" + retries: 24 + delay: 5 + failed_when: false + + - name: Assert baibot got past startup and into its sync loop + ansible.builtin.assert: + that: + - "'Syncing..' in matrix_bot_baibot_journal.stdout" + - "'Failed to prepare profile' not in matrix_bot_baibot_journal.stdout" + fail_msg: >- + baibot never reached its sync loop; it is still in startup or stuck + retrying profile setup + success_msg: "baibot got past startup and is syncing" + + # `user.name` is the bot's display name. The scenario's value is neither the + # role's default (`baibot`) nor what the stub reports the account already has + # (`stub`), so the bot naming this as what it wants can only have come from + # the configuration the role rendered. + - name: Assert the display name the role configured reached the process + ansible.builtin.assert: + that: + - >- + 'desired_display_name="' ~ matrix_bot_baibot_config_user_name ~ '"' + in matrix_bot_baibot_journal.stdout + fail_msg: >- + baibot did not report {{ matrix_bot_baibot_config_user_name }} as the + display name it wants, so `user.name` did not reach the process + success_msg: "baibot acts on the display name the role configured" + + # The `logging` setting is one string carrying per-target levels + # (`warn,mxlink=info,baibot=debug`), so proving it arrived means proving that + # different targets ended up at different levels - a single global level + # would satisfy neither half of this. + # + # First clause: baibot's own records appear at DEBUG, which the role's + # default of `info` would not produce. + # + # Second clause is the control, and it is not vacuous: at DEBUG the crates + # underneath (matrix-sdk and its spans, hyper, eyeball) are extremely + # talkative - raising the catch-all level turns these two records into + # roughly a hundred. Their silence is the `warn` catch-all being enforced. + # + # A control on mxlink was tried first and is the trap here: mxlink happens to + # emit no DEBUG records at all on a first run, so asserting their absence + # passed just as happily with mxlink set to `debug`. + - name: Assert the per-target logging levels reached the process + ansible.builtin.assert: + that: + - matrix_bot_baibot_debug_lines | select('search', 'baibot::') | list | length > 0 + - matrix_bot_baibot_debug_lines | reject('search', 'baibot::') | list | length == 0 + fail_msg: >- + The rendered `logging` string did not take effect: + {{ matrix_bot_baibot_debug_lines | length }} DEBUG record(s), of which + {{ matrix_bot_baibot_debug_lines | select('search', 'baibot::') | list | length }} + from baibot itself + success_msg: >- + baibot logs at DEBUG while everything under it stays at the catch-all + level, as the rendered `logging` string asks + vars: + matrix_bot_baibot_debug_lines: >- + {{ matrix_bot_baibot_journal.stdout_lines | select('search', ' DEBUG ') | list }} + + - name: Read the configuration file the role rendered + ansible.builtin.slurp: + src: "{{ matrix_bot_baibot_config_path }}/config.yml" + register: matrix_bot_baibot_config_file + + - name: Assert the rendered configuration carries this scenario's Matrix settings + ansible.builtin.assert: + that: + - matrix_bot_baibot_config.homeserver.server_name == matrix_domain + - matrix_bot_baibot_config.homeserver.url == matrix_bot_baibot_config_homeserver_url + - matrix_bot_baibot_config.user.mxid_localpart == matrix_bot_baibot_config_user_mxid_localpart + - matrix_bot_baibot_config.user.name == matrix_bot_baibot_config_user_name + - matrix_bot_baibot_config.command_prefix == matrix_bot_baibot_config_command_prefix + - matrix_bot_baibot_config.room.post_join_self_introduction_enabled is false + - matrix_bot_baibot_config.access.admin_patterns == matrix_bot_baibot_config_access_admin_patterns + - matrix_bot_baibot_config.initial_global_config.user_patterns == ['@*:' ~ matrix_domain] + fail_msg: "The rendered configuration does not carry the scenario's Matrix settings" + success_msg: "The rendered configuration carries the scenario's Matrix settings" + vars: + matrix_bot_baibot_config: "{{ matrix_bot_baibot_config_file.content | b64decode | from_yaml }}" + + # The role supports two mutually-exclusive authentication modes and refuses + # a configuration that sets both. This scenario uses the password mode, so + # the access-token keys must be rendered as nulls rather than omitted or + # left with a value. + - name: Assert only the password authentication mode is rendered + ansible.builtin.assert: + that: + - matrix_bot_baibot_config.user.password == matrix_bot_baibot_config_user_password + - matrix_bot_baibot_config.user.access_token is none + - matrix_bot_baibot_config.user.device_id is none + fail_msg: "The rendered configuration does not use password authentication exclusively" + success_msg: "The rendered configuration uses password authentication exclusively" + vars: + matrix_bot_baibot_config: "{{ matrix_bot_baibot_config_file.content | b64decode | from_yaml }}" + + # The agent presets are the most involved templating in this role: a + # per-provider template is rendered to YAML, parsed, merged with an + # extension, and dropped into the list as a nested structure. This asserts + # the whole round trip, key by key. + # + # No provider is ever contacted. baibot calls one only when a message asks an + # agent to do something, and the base URL here resolves nowhere on purpose - + # a scenario must not need an account with an AI provider. + - name: Assert the statically-defined agent survived the provider templating + ansible.builtin.assert: + that: + - matrix_bot_baibot_agents | length == 1 + - matrix_bot_baibot_agent.id == matrix_bot_baibot_config_agents_static_definitions_anthropic_id + - matrix_bot_baibot_agent.provider == 'anthropic' + - matrix_bot_baibot_agent.config.base_url == matrix_bot_baibot_config_agents_static_definitions_anthropic_config_base_url + - matrix_bot_baibot_agent.config.api_key == matrix_bot_baibot_config_agents_static_definitions_anthropic_config_api_key + - matrix_bot_baibot_agent.config.text_generation.model_id == matrix_bot_baibot_config_agents_static_definitions_anthropic_config_text_generation_model_id + - matrix_bot_baibot_agent.config.text_generation.temperature == matrix_bot_baibot_config_agents_static_definitions_anthropic_config_text_generation_temperature + - matrix_bot_baibot_agent.config.text_generation.max_response_tokens == matrix_bot_baibot_config_agents_static_definitions_anthropic_config_text_generation_max_response_tokens + - matrix_bot_baibot_agent.config.text_generation.max_context_tokens == matrix_bot_baibot_config_agents_static_definitions_anthropic_config_text_generation_max_context_tokens + fail_msg: >- + The statically-defined agent is not what the role's preset variables ask + for: {{ matrix_bot_baibot_agents }} + success_msg: "The statically-defined agent carries the scenario's provider settings" + vars: + matrix_bot_baibot_agents: "{{ (matrix_bot_baibot_config_file.content | b64decode | from_yaml).agents.static_definitions }}" + matrix_bot_baibot_agent: "{{ matrix_bot_baibot_agents | first }}" + + - name: Read the container's runtime configuration + ansible.builtin.command: + argv: + - docker + - container + - inspect + - matrix-bot-baibot + - --format + - "{{ '{{' }} .Config.Image {{ '}}' }} {{ '{{' }} .Config.User {{ '}}' }}" + register: matrix_bot_baibot_container + changed_when: false + + - name: Assert the image carries the version defaults/main.yml pins + ansible.builtin.assert: + that: + - matrix_bot_baibot_role_defaults.matrix_bot_baibot_version in matrix_bot_baibot_container.stdout + fail_msg: >- + The running container is {{ matrix_bot_baibot_container.stdout }}, + which does not carry the pinned version + {{ matrix_bot_baibot_role_defaults.matrix_bot_baibot_version }} + success_msg: "The running container is the version defaults/main.yml pins" + + # The uid/gid come from outside the role (matrix-base supplies them in a real + # run, molecule-shared/playbook-context.yml here) and are deliberately not + # 1000, which the base image already uses - so this cannot pass by + # coincidence with whatever the image would have run as. + - name: Assert the container runs as the identity the playbook supplies + ansible.builtin.assert: + that: + - "matrix_user_uid ~ ':' ~ matrix_user_gid in matrix_bot_baibot_container.stdout" + fail_msg: >- + The container does not run as {{ matrix_user_uid }}:{{ matrix_user_gid }} + ({{ matrix_bot_baibot_container.stdout }}) + success_msg: "The container runs as the uid/gid the playbook supplies" + + # baibot keeps its session and crypto store here. The file existing proves + # the bind mount is writable by the user the container runs as - a + # read-only-root container whose data directory it could not write would + # never have got as far as logging in. + - name: Stat the session file baibot persists + ansible.builtin.stat: + path: "{{ matrix_bot_baibot_data_path }}/session.json" + register: matrix_bot_baibot_session_file + + - name: Assert baibot persisted its session as the matrix user + ansible.builtin.assert: + that: + - matrix_bot_baibot_session_file.stat.exists + - matrix_bot_baibot_session_file.stat.uid | int == matrix_user_uid | int + fail_msg: >- + {{ matrix_bot_baibot_data_path }}/session.json is missing or not owned + by uid {{ matrix_user_uid }} + success_msg: "baibot persisted its session into the data path as the matrix user"