mirror of
https://github.com/spantaleev/matrix-docker-ansible-deploy.git
synced 2026-08-30 04:23:14 +00:00
baibot is the first bot rather than a bridge, and the shape differs from the appservices: it is a plain Matrix client that logs in with a password, sets up its profile and then syncs. It publishes no port, so nothing can be probed over HTTP; what it says about itself in the journal is the only window into whether the role's configuration reached the process. What the scenario proves: - The unit is active with no automatic restarts, AND baibot got past startup into its sync loop. The second half is what carries the scenario. baibot never exits when startup goes wrong - it retries the failing step forever with a growing delay - so the unit sits there `active` with `NRestarts` at 0 while the bot is permanently half-started. Pointing `user.avatar` at a file that is not there reproduces exactly that: the unit assertion still passes, the sync assertion does not. - The display name the bot announces it wants is the role's `user.name`, which is neither the role's default nor what the stub reports the account already has. - The rendered `logging` string took effect per target: baibot's own records appear at DEBUG (the role ships `info`) while everything underneath stays at the `warn` catch-all. The second half is the control, and raising the catch-all turns 2 DEBUG records into 161. - The rendered config carries the scenario's homeserver, identity, command prefix, admin patterns and user patterns, and uses password authentication exclusively, with the access-token keys rendered as nulls. - The statically-defined agent survived the provider templating - the per-provider template rendered to YAML, parsed, merged and nested into the list - key by key. - The container runs as the uid/gid the playbook supplies (1234, not the 1000 the base image already has), on the image version defaults/main.yml pins, and could write its session into the data path. No AI provider is contacted and none is needed. baibot calls a provider only when a message asks an agent to do something, so a static agent with a placeholder key and a base URL that resolves nowhere still has to survive the bot's startup parsing - which is the part worth testing. The shared stub grew what a syncing Matrix client needs and an appservice did not: /sync (with a `next_batch`, and holding the call open for the timeout the client asked for, or the bot spins the stub in a hot loop), the media config and upload endpoints a bot setting its own avatar insists on, /keys/upload with its key counts, and filter creation. Without the media config in particular, baibot never gets past profile setup. The shared stub task gained a STUB_VERBOSE knob. The stub already advertised the environment variable but there was no way to set it from a scenario, and for a component with no port of its own its request log is the only place to see what the component is actually asking for. Note: molecule-shared/homeserver-stub.py also carries a loosened /login match from another scenario being written in this same tree at the same time; it was already in the working copy and is not mine. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SEH3vxYSQ5SV4N5z61eyGT
249 lines
13 KiB
YAML
249 lines
13 KiB
YAML
# 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"
|