Commit Graph
11867 Commits
Author SHA1 Message Date
Slavi PantaleevandClaude Opus 5 5e9a20a3d2 Correct what the baibot OpenAI API key comment claims
It read "The API key is intentionally not required. Some OpenAI-compatible APIs
do not require a key", while validate_config.yml fails when the key is empty and
the provider is enabled. Enabling the provider against a keyless endpoint stopped
with "You need to define a required configuration setting".

baibot is the authority here, and it agrees with the validation rather than the
comment: `openai::Config` takes `api_key: String`, whereas the provider whose key
really is optional is a different one, `openai_compat`, with
`api_key: Option<String>`. So the comment described a real baibot feature, just
not this provider's.

The validation stays as it is; the comment now says what the code does, and
points at the provider the keyless case belongs to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SEH3vxYSQ5SV4N5z61eyGT
2026-08-27 21:39:26 +03:00
Slavi PantaleevandClaude Opus 5 928f5d9525 Drop the unreachable Instagram branch from the mautrix-meta-messenger image tag
The image tag prefixed `ig-` when `..._meta_mode` was `instagram`, but this role
does not accept that mode any more: `..._database_suffix` and
`..._bridge_username_prefix` map only `facebook`, `facebook-tor` and `messenger`,
so `instagram` raises an undefined-key error long before the tag is rendered.

Instagram has been a separate bridge since v26.07 and is handled by
matrix-bridge-mautrix-meta-instagram, which hardcodes the prefix. Left over from
before that split.

The comment stays, reworded: both bridges still share a container image
repository, so an `ig-` prefix on a tag here would mean this role had pulled the
other bridge's image - which is what the Molecule scenario asserts against.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SEH3vxYSQ5SV4N5z61eyGT
2026-08-27 18:40:35 +03:00
Slavi PantaleevandClaude Opus 5 31a9d21ca7 Write down what the first wave of Molecule scenarios taught us
Eight scenarios in, the same few things keep deciding whether a scenario is
worth having, and none of them were obvious beforehand:

- Falsify every assertion, not just enough to see red. One control asserted that
  a component emitted no DEBUG records from a module and passed just as happily
  with that module set to `debug`, because it emits none on a first run either
  way. Green for the wrong reason, and only breaking it deliberately showed that.
- `ActiveState == active` with `NRestarts == 0` can both hold while a component
  is completely broken, when it catches its errors and retries rather than
  exiting. Two of the eight behave that way.
- Startup lines are the oldest in the journal, so tailing loses them. Grep, and
  strip ANSI first.
- Parse rendered configuration and assert on structure, so a value under the
  wrong key cannot pass.
- `molecule converge` on a running instance does not restart the container, so a
  falsification can pass for that reason alone.

Also documents that a role's scenario obliges it to join the automerge list, and
that prek enforces the two staying in step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SEH3vxYSQ5SV4N5z61eyGT
2026-08-27 18:25:26 +03:00
Slavi PantaleevandClaude Opus 5 15429f4a90 Fix the Draupnir Renovate annotations so updates are found again
The annotation read `depName=depName=ghcr.io/...`, so Renovate looked up a
package literally named `depName=ghcr.io/the-draupnir-project/draupnir` and found
nothing. Both Draupnir roles have been invisible to Renovate since v3.0.0 landed
in April, and the dependency dashboard has been reporting it as a repository
problem the whole time:

  Failed to look up docker package
  depName=ghcr.io/the-draupnir-project/draupnir: no-result

Checked every other annotation under roles/custom/ for the same shape; these two
were the only ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SEH3vxYSQ5SV4N5z61eyGT
2026-08-27 18:19:02 +03:00
Slavi PantaleevandClaude Opus 5 2d258cf0e0 Automerge patch bumps of components whose role has a Molecule scenario
A component bump touches that role's defaults/main.yml, which is on the Molecule
workflow's path filter, so the change-detection job runs that role's scenario.
The bump therefore only merges once the new version has actually been started on
the configuration the role renders. That is a real gate, and a stronger one than
a human reading a version number in a diff.

Patch only. A minor carries behaviour changes that no static rule can judge - the
MASH fleet tried a minor-automerge preset across 21 stateless roles and reverted
all of them, because reading a given release's notes is what decides it, and that
is a job for a human or an agent rather than a config file.

Branch push rather than a PR, as with the other automerge rules here.

The list has to stay in step with the roles that actually have a scenario, so
bin/check-molecule-automerge-list.py enforces it from prek. The direction that
matters is a role keeping automerge after losing its scenario: bumps would then
merge with nothing exercising them. It reports the harmless direction too, since
a role gaining a scenario without being listed is usually an oversight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SEH3vxYSQ5SV4N5z61eyGT
2026-08-27 18:18:13 +03:00
Slavi PantaleevandClaude Opus 5 8e53dbf940 Automerge Molecule's own dependency updates
Anything under molecule-shared/ - the helper container images, Postgres, and the
Python pins - is on the Molecule workflow's path filter, and a change to a shared
file makes the detect job run every scenario rather than a subset. So an update
there is already gated on the whole suite passing on its own branch, which is a
stronger check than a human reading the diff.

Branch push rather than a PR, matching how the housekeeping updates in this file
already work: nothing to review and no email on success, and a failure still
surfaces as a PR.

This is also how a new Postgres major reaches us. The bump to the pin runs every
scenario against it before it can merge, so if a component does not cope with the
new major we find out from a failed Renovate branch rather than from a user.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SEH3vxYSQ5SV4N5z61eyGT
2026-08-27 18:02:53 +03:00
Slavi PantaleevandClaude Opus 5 db4eb94058 Set PGDATA explicitly in the Molecule Postgres container, like the role does
The scenarios worked around Postgres 18's data layout change by mounting the
tmpfs at /var/lib/postgresql. The postgres role solved the same problem
differently and better, when it bit us there: it sets PGDATA explicitly, and to
/data rather than anywhere beneath /var/lib/postgresql, because that path is a
VOLUME in the official image and nothing can be mounted under it.

Doing the same here means the scenarios exercise the layout the role actually
deploys, and that a future image changing its own default cannot move the data
directory out from under them.

Ref: https://github.com/docker-library/postgres/pull/1259

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SEH3vxYSQ5SV4N5z61eyGT
2026-08-27 18:02:53 +03:00
Slavi PantaleevandClaude Opus 5 85f80a3c7e Test the Molecule scenarios against Postgres rather than sqlite
`group_vars/matrix_servers` selects postgres whenever postgres is enabled, which
is the default, so postgres is what essentially every deployment runs. The
scenarios were testing sqlite - a path almost nobody is on.

How little that path is used is not a guess: the mautrix-meta bridges could not
start at all under sqlite, and nobody reported it. Testing the engine users are
actually on is worth more than keeping coverage of the one they are not, so no
scenario is left behind on sqlite.

Four of the eight scenarios have a database and are converted; the other four
have none and are untouched.

molecule-shared/tasks/postgres.yml stands Postgres up on the scenario's network,
with the data directory on a tmpfs since it is thrown away with the container.
The image is pinned at the major the postgres role deploys to new installations
and left to Renovate: when a new major lands, the PR bumping that pin runs every
scenario against it, which is the earliest warning we get that a component does
not cope.

Each scenario gives its database and user names that differ from the role's
defaults, so the component reaching the database proves the role built its
connection string out of them. The assertions moved from "a file appeared at the
path we configured" to "these tables exist", which is strictly stronger: tables
can only appear once the component has resolved the hostname, authenticated with
the credentials the role rendered, and run its migrations to completion.

Costs about 10 seconds per affected scenario (115s to 125s locally for
mautrix-whatsapp), on jobs that run in parallel.

Gotcha worth recording: since Postgres 18 the image puts PGDATA in a versioned
subdirectory and refuses to start if it finds a mount at the old
/var/lib/postgresql/data, so the tmpfs is mounted at /var/lib/postgresql.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SEH3vxYSQ5SV4N5z61eyGT
2026-08-27 18:02:53 +03:00
Slavi PantaleevandClaude Opus 5 c447e1528b Reword the Molecule scenario comments
They were hard-wrapped at 80 characters, broke mid-parenthesis, and spent lines
restating what the code below them does.

Rewrapped at natural boundaries instead, with the narration dropped and only the
reasons, gotchas and surprises kept. Section dividers stay - they delineate long
plays rather than narrate them.

Comments only; no scenario behaviour changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SEH3vxYSQ5SV4N5z61eyGT
2026-08-27 18:02:53 +03:00
Slavi PantaleevandClaude Opus 5 e2d3be504e Add just molecule-clean
`just molecule` leaves a shared virtualenv of over 500 MB under var/, plus a
~7 MB Ansible home per role that has a scenario. Neither is reclaimed by
anything today.

`--idle-days N` limits it to what has not been touched recently, so it can be
run unattended without taking the cache out from under a scenario being worked
on right now.

The two directories are named explicitly rather than globbed: var/ holds other
things and must never be removed wholesale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SEH3vxYSQ5SV4N5z61eyGT
2026-08-27 18:02:53 +03:00
Slavi PantaleevandClaude Opus 5 6ad1c9b031 Stop calling hookshot a mautrix bridge in a task name
Hookshot is not one; the name was copied from a mautrix role.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SEH3vxYSQ5SV4N5z61eyGT
2026-08-27 18:02:53 +03:00
Slavi PantaleevandClaude Opus 5 1751246cd1 Point the hookshot jira_oauth_uri deprecation at the right variable
The renamed-variable table mapped `matrix_bridge_hookshot_jira_oauth_uri` to
`..._jira_oauth_client_secret`, the entry above it. Anyone hitting the
deprecation was told to rename their setting to a variable that means something
else entirely; the URI's replacement is `..._jira_oauth_redirect_uri`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SEH3vxYSQ5SV4N5z61eyGT
2026-08-27 18:02:53 +03:00
Slavi PantaleevandClaude Opus 5 0500127456 Fix the sqlite database URI for the mautrix-meta bridges
Both roles derived `..._appservice_database_uri` as `'sqlite:///' + <path>`.
mautrix-go hands that string to go-sqlite3 as a filename rather than parsing it
as a URL, so the bridge cannot open its database and dies at startup:

  FTL Failed to initialize database
      error="... unable to open database file: no such file or directory"

Every other mautrix bridge role here passes the bare in-container path.

This has stayed hidden because group_vars/matrix_servers selects postgres
whenever postgres is enabled, which is the default - so almost nobody reaches
the sqlite branch. Anyone who does gets a bridge that never starts.

Found by the mautrix-meta-messenger Molecule scenario, which runs sqlite
deliberately. The scenario's override is dropped and its assertion now compares
the rendered URI against the path the role defines, so the derived value is
what is under test rather than the scenario's own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SEH3vxYSQ5SV4N5z61eyGT
2026-08-27 18:02:53 +03:00
Slavi PantaleevandClaude Opus 5 dc092d3545 Build mautrix-discord's double puppet server map from its own variables
`matrix_bridge_mautrix_discord_bridge_double_puppet_server_map_default` was
built from `matrix_bridge_beeper_linkedin_homeserver_domain` and
`..._homeserver_address` - a different role's variables, copy-pasted along with
the expression.

No user is affected today: group_vars/matrix_servers points both roles'
`homeserver_address` at `matrix_addons_homeserver_client_api_url` and both
domains at `matrix_domain`, so the rendered value is identical either way. It
only diverges for someone who overrides beeper-linkedin's homeserver settings,
where mautrix-discord would silently follow them.

It does not survive mautrix-discord being used without beeper-linkedin's
defaults in scope, which is how the Molecule scenario found it: with only the
one role loaded, the template fails on the undefined name. The scenario's
neutralising override is dropped in the same commit, so the expression is now
exercised rather than bypassed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SEH3vxYSQ5SV4N5z61eyGT
2026-08-27 18:02:53 +03:00
Slavi PantaleevandClaude Opus 5 fe2fa6eaca Don't reset Hookshot's crypto store on a run that selects no tags
`tasks/main.yml` opens with a block tagged `reset-hookshot-encryption`, and
Ansible runs tagged tasks unless tags are actually selected. A run that selects
no tags therefore reaches `tasks/reset_encryption.yml`: on a host where the
service does not exist yet it fails outright, and on one where it does it stops
the bridge and resets its crypto store without being asked to.

Users of the playbook are shielded from this only because the documented way to
run it always passes `--tags=setup-all,start`.

Adding `never` makes the block reachable only when its tag is explicitly
selected, which is what `--tags=reset-hookshot-encryption` in
docs/configuring-playbook-bridge-hookshot.md already does - that keeps working
unchanged, as selecting a tag by name overrides `never`.

The Molecule scenario no longer needs its `skip-tags` workaround, which is what
makes this verifiable: the scenario runs with no tags selected, and is green
with the workaround gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SEH3vxYSQ5SV4N5z61eyGT
2026-08-27 18:02:53 +03:00
Slavi PantaleevandClaude Opus 5 705f561777 Give each Molecule scenario its own Ansible home
Scenarios install their Galaxy dependencies with `force: true`, so two roles
running at once re-extract the same collections and roles into ~/.ansible and
pull them out from under each other mid-play. It surfaces as a collection that
was working moments earlier going missing:

  the connection plugin 'community.docker.docker' was not found

Found while running five scenarios in parallel, where it cost a run.

ANSIBLE_HOME relocates both `collections/` and `roles/`, so one variable covers
both halves; the scenarios' ANSIBLE_ROLES_PATH workaround now follows it rather
than hardcoding ~/.ansible/roles. Left alone if already set, and unset in CI,
where each role runs in its own job and has nothing to collide with.

Verified by removing var/molecule-ansible-home entirely and running
matrix-alertmanager-receiver from cold: green through idempotence, with the
collections and roles landing under the per-role directory - which also shows
nothing was quietly relying on the shared ~/.ansible being populated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SEH3vxYSQ5SV4N5z61eyGT
2026-08-27 18:02:53 +03:00
Slavi PantaleevandClaude Opus 5 6bdcddb79a Add a Molecule scenario for baibot, and teach the stub to answer a syncing client
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
2026-08-27 18:02:53 +03:00
Slavi PantaleevandClaude Opus 5 d534b00e04 Add a Molecule scenario for mautrix-meta-messenger
Stands the bridge up against the shared homeserver stub and checks that it
starts on the configuration the role rendered: the unit is active with no
automatic restarts, the appservice listener on 29319 answers, and the bridge
created its sqlite database under the role's data path as the role's uid.
Nothing is bridged - there is no Facebook or Messenger account involved and
there is deliberately never going to be one.

The thing worth proving for this role in particular is the Meta mode. One
upstream codebase serves several Meta networks, and
`matrix_bridge_mautrix_meta_messenger_meta_mode` is what picks which one; the
role expands it into an appservice id, a ghost username prefix, a bot
displayname and the bridge's `tor` switch. The scenario therefore runs in
`facebook-tor` mode rather than the role's default `messenger`, and asserts on
all four - against the parsed configuration, not substrings. The registration
namespaces are checked by what they match rather than by comparing regexes:
this mode's ghosts are covered, the default mode's are not.

Since v26.07 the Instagram bridge is published to the same image repository
with an `ig-` tag prefix, so the running image is compared against the whole
tag `defaults/main.yml` pins rather than by substring - an `ig-` prefix would
mean this role pulled the other bridge's image.

Surprising: the role's derived sqlite URI does not work. For `sqlite3-fk-wal`
it builds `sqlite:///` + the in-container path, and the bridge hands that to
go-sqlite3 as a plain filename rather than parsing it as a URL, so it dies at
startup with `unable to open database file: no such file or directory`. Every
other mautrix role here uses a bare path. The playbook selects postgres
whenever postgres is enabled, which is the default, so nothing normally reaches
that code path. The scenario overrides the URI rather than changing the role's
default, which is consumer-visible; matrix-bridge-mautrix-meta-instagram has
the same expression.

Falsified by setting `matrix_bridge_mautrix_meta_messenger_meta_mode` back to
`messenger` and re-running: the run failed at "Assert the configuration
reflects the Meta mode the scenario selected", on the
`appservice.id == 'facebook-tor'` clause.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SEH3vxYSQ5SV4N5z61eyGT
2026-08-27 18:02:53 +03:00
Slavi PantaleevandClaude Opus 5 7e8b3466b6 Add a Molecule scenario for hookshot, and the openssl host command to the shared context
Hookshot is the widest HTTP surface of the bridges here - an appservice port
plus a `listeners` list whose entries appear or vanish with independent
switches - so the scenario asserts on that surface rather than on "it started".

The scenario configures NO third-party service. GitHub, GitLab, Jira and Figma
each need an account and a credential somewhere else, which is where a scenario
stops testing this repository (docs/molecule-testing.md). GitLab, which the role
turns on by default, is switched off and its absence from both config.yml and
the registration's namespaces is asserted. The generic webhooks listener needs
no credential from anyone, and is what gets exercised live: a POST to an unknown
hook id comes back as `{"ok":false,"error":"Webhook not found"}`, which only
Hookshot's own generic-webhook handler produces - an Express "Cannot POST" page
there would mean the port is Hookshot's but the service was never mounted on it.

Every port the scenario sets differs from both the role's default and Hookshot's
own, and the run asserts both halves: the webhooks, metrics and appservice ports
it chose all answer, and 9000/9001/9003/9993 - what the role would have used
instead - all refuse. Metrics are off in the role's defaults, so that listener
exists only because the scenario asked for it, and /metrics answers with
`hookshot_*` series that nothing else could have produced.

config.yml and registration.yml are parsed as YAML and asserted structurally, so
a value landing under the wrong key cannot pass as a substring match.

Two things the role made necessary:

- `tasks/main.yml` opens with a block tagged `reset-hookshot-encryption`, and
  Ansible runs tagged tasks unless tags are actually selected - so any tagless
  play hits it, and it stops `matrix-hookshot.service` before that service
  exists. The scenario skips the tag, which is what the playbook effectively
  does by always running with `--tags=setup-all,start`.

- The role reads `matrix_host_command_openssl`, which matrix-base defines.
  matrix-bridge-appservice-irc reads it too, so it goes in the shared playbook
  context rather than into this scenario.

Falsified by pointing the webhooks listener at 127.0.0.1 in the role's config
template - a change no file-level assertion can see. Every config and
registration assertion still passed, and the run failed at "Assert the generic
webhooks service is mounted on that listener" with "Port 9741 did not answer as
Hookshot's generic webhooks service". Reverted afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SEH3vxYSQ5SV4N5z61eyGT
2026-08-27 18:02:53 +03:00
Slavi PantaleevandClaude Opus 5 0ae18e7a16 Add a Molecule scenario for matrix-reminder-bot
A bot rather than a bridge, and not an appservice: it logs into the
homeserver as an ordinary user with a password, and keeps its reminders in
a local SQLite database. That makes it a cheap second data point for the
bot shape, and it is closer to matrix-alertmanager-receiver than to the
bridges - except that it has no HTTP surface at all, so there is nothing to
probe.

What the scenario proves instead:

- The unit is active and has not restarted. The bot parses its config file
  before its own catch-all retry loop starts, so anything wrong in what the
  role rendered surfaces as a crash loop rather than as a running process.
- The bot reached "Logged in as @molecule.reminder-bot:molecule.local" in
  the journal. That line is only reached once the login call came back as
  something other than an error, so it covers the homeserver URL, the user
  ID and the password the role rendered in one go - a real login round-trip
  against the shared stub, which already answers /_matrix/client/v3/login
  with an access token. No stub changes were needed.
- The SQLite database landed at the path the role configured, owned by the
  role's uid, with the role's own default name (bot.db) absent as a negative
  control - so the storage configuration reached the running process and not
  just the file on disk.
- matrix-nio populated its encryption store under the role's data path,
  inside an otherwise read-only container.
- The container runs as the playbook context's uid:gid with the configured
  timezone on TZ, and carries the version defaults/main.yml pins.
- `..._configuration_extension_yaml` was merged over the role's template:
  device_name is hardcoded in the template, so overriding it is only
  possible through the extension.

Every value the scenario sets differs from both the role's defaults and the
bot's own fallbacks - localpart, command prefix, timezone, database
filename, both the allowlist and the blocklist.

Falsified by pointing the homeserver URL at a dead port. The service stayed
`active` with NRestarts == 0 and that assertion passed, because the bot
catches every exception and retries every 15s rather than exiting - a good
illustration of why `active` on its own proves nothing here. The run failed
at "Assert the bot logged in as the user the role configured", which is the
assertion carrying the weight.

Surprise worth recording: the journal is read through a grep rather than a
`--lines=N` tail. The startup lines are the oldest in the journal, and if
the stub ever answers /sync instantly the bot's sync loop spins fast enough
to bury them under thousands of lines within a minute.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SEH3vxYSQ5SV4N5z61eyGT
2026-08-27 18:02:53 +03:00
Slavi Pantaleev 6af80175dc Add a Molecule scenario for mautrix-discord
Proves the bridge starts on the configuration this role rendered: the
systemd unit is active and has not been restarting, the appservice port is
open, config.yaml and registration.yaml (parsed, not grepped) carry the
scenario's homeserver address, bot username, tokens, command prefix,
avatar-proxy key, public address and log level, the sqlite database was
created under the role's data path as the role's uid, and the running
container is the version defaults/main.yml pins.

It does not bridge anything. Discord is behind an account, which is where a
scenario stops being a test of this repository.

Two things are specific to this role rather than copied from the
mautrix-whatsapp scenario:

- mautrix-discord is the only bridge here whose validate_config.yml requires
  a public address. It is composed from hostname + scheme + path prefix, and
  those same three feed the Traefik avatar-proxy labels, so the scenario
  picks a non-`/` path prefix and a non-default scheme and asserts the
  composed router rule and strip-prefix middleware - both in the label file
  and, read back off the running container, as labels Docker accepted.
  Traefik labels are left enabled here for that reason, unlike in the
  mautrix-whatsapp scenario which asserts their absence.

- matrix_bridge_mautrix_discord_bridge_double_puppet_server_map_default in
  the role's defaults references matrix_bridge_beeper_linkedin_homeserver_domain
  and matrix_bridge_beeper_linkedin_homeserver_address - variables belonging
  to a different role, evidently copy-pasted from
  matrix-bridge-beeper-linkedin, and unique to this role among the bridges.
  A playbook run has every role's defaults in scope so it resolves silently;
  a role scenario has only this role loaded and the template fails on the
  undefined name. The scenario neutralises it in its own group_vars rather
  than touching the role.

Falsified the crash-loop assertion: pointing the in-container sqlite path at
a directory that does not exist makes the bridge exit on startup, and the run
then fails at "Assert the service is active and has not been restarting" with
"activating after 5 automatic restart(s)". Reverted, and green again since -
including idempotence.
2026-08-27 18:02:53 +03:00
Slavi PantaleevandClaude Opus 5 8cd6ea1eef Add a Molecule scenario for heisenbridge, and the hostname family to the shared context
The third shape: a non-mautrix bridge. It confirms the scaffolding is not
mautrix-specific, and it is laid out differently enough to be worth having -
everything sits directly under the base path rather than in config/ and
data/ subdirectories, the owner and homeserver URL are passed on the command
line rather than through a config file, so the unit is where they can be
checked, and identd optionally binds host port 113.

matrix-base's matrix_server_fqn_* family moved into the shared context;
18 of the roles here read one of them.

Three roles now, three shapes - HTTP receiver, mautrix bridge, non-mautrix
bridge - and the scaffolding took no per-shape special-casing beyond each
role's own variables.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 18:02:53 +03:00
Slavi PantaleevandClaude Opus 5 f320bfc93b Add a Molecule scenario for mautrix-whatsapp, and a shared playbook context
The second scenario, chosen to be a bridge rather than another HTTP
component: bridges are roughly 40 of the 70 roles here, and validating the
shape on role two is cheaper than discovering it on role forty. It found
two things the first role could not.

Bridges read a whole family of variables from outside themselves -
matrix_bridges_relay_enabled, _encryption_enabled, _encryption_default,
_msc4190_enabled, _self_sign_enabled, plus matrix_admin - all defined in
matrix-base. Since every bridge reads them, they belong in shared context
rather than in each bridge's scenario: molecule-shared/playbook-context.yml
now carries them along with the identity and path variables the first
scenario had inline. Note it is loaded through vars_files, which outranks
inventory group_vars, so it is authoritative; a scenario that wants to prove
one of these reaches the rendered configuration should say so explicitly.

Appservices call /whoami while starting and refuse to run if the id returned
is not the bot user they were configured as - mautrix-whatsapp exits 17 with
"Unexpected user ID in whoami call". The shared stub now takes the id it
should claim to be. Expect every bridge and bot to need this.

What the scenario proves: the bridge starts and stays up, opens its
appservice port, its rendered config and appservice registration carry the
scenario's tokens and bot user, it created its sqlite database under the
role's data path as the role's uid, and it runs the version the role pins.
It does not bridge anything and never will.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 18:02:53 +03:00
Slavi PantaleevandClaude Opus 5 81a0ee3d53 Share the Molecule homeserver stub between scenarios
Components here contact a homeserver while starting up and exit if it is
unreachable, so nearly every scenario will need one. Standing up a real
Synapse per role would dominate the run and drag in Postgres, and these
scenarios are not testing Synapse.

The stub answers the handful of endpoints components touch during startup
with the blandest plausible response, and is deliberately permissive: an
unrecognised path returns {} rather than 404, because the goal is to get the
component past its startup checks. It is not an authentication check or a
room state machine, and a scenario should not assert *about* it - if one
starts needing it to behave like a real homeserver, that scenario has
outgrown what these tests are for.

matrix-alertmanager-receiver now includes it instead of carrying its own
inline copy. Verified green afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 18:02:53 +03:00
Slavi PantaleevandClaude Opus 5 efe204829f Symlink the shared requirements into each Molecule scenario
Molecule checks for a requirements file at the scenario's default path
before it installs anything, and warns "Missing roles requirements file"
when it is absent - so `requirements-file` pointing elsewhere was silently
ignored and the galaxy roles were never installed. CI failed on a missing
ansible-role-docker; the local run passed only because those roles were
already in ~/.ansible/roles from earlier work, which is exactly the kind of
difference a clean CI run exists to expose.

A symlink satisfies Molecule's check while keeping one copy of the pins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 18:02:53 +03:00
Slavi PantaleevandClaude Opus 5 8095bfda8b Run every Molecule scenario when the shared files change
molecule-shared/ and the workflow itself are used by every scenario, so a
change to either has to run all of them rather than only the roles whose own
files moved. They were also missing from the paths filter, so such a change
would not have triggered the workflow at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 18:02:53 +03:00
Slavi PantaleevandClaude Opus 5 51e5d5f482 Install the shared Molecule requirements in CI too
The previous commit moved requirements.txt into molecule-shared/ but left
the workflow installing from the old per-role path, so CI failed on a file
that no longer exists while the local run - which reads the shared file
through bin/molecule.sh - passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 18:02:53 +03:00
Slavi PantaleevandClaude Opus 5 9370fdf4b4 Share the Molecule scaffolding and add just molecule
Three things that would not have scaled to 70 roles:

- The Python and Ansible dependency pins were about to be copied into every
  role. They now live once in molecule-shared/, which scenarios reference
  relatively, so they cannot drift apart.
- The helper container images used for probing were hardcoded inline. They
  are pinned once in molecule-shared/vars.yml, carry `# renovate:`
  annotations, and a custom manager in .github/renovate.json keeps them
  current - verified with a local Renovate dry run, which offers
  curl 8.11.1 -> 8.21.0 and python 3.13 -> 3.14-alpine. Seventy invisible
  hardcodes is the blindness class we have been removing elsewhere.
- Running a scenario meant knowing the venv and cd incantation. `just
  molecule <role>` does it, and with no argument lists the roles that have
  a scenario.

Molecule is deliberately not wired into prek: a run takes minutes, pulls
images and needs Docker, which is fine on request and not fine per commit.

docs/molecule-testing.md covers how to run and write these, including the
four things a role here needs that a standalone role does not. AGENTS.md
points at it rather than carrying the detail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 18:02:53 +03:00
Slavi PantaleevandClaude Opus 5 ebe13bc360 Add a Molecule scenario for matrix-alertmanager-receiver
Not for merging as-is. One role's scenario plus the workflow that would
make per-role testing affordable here, so we can see how it behaves before
deciding whether to do the other 69.

Unlike the MASH repositories, one repository holds every role, so running
everything on every push is not an option. The workflow's first job works
out which roles a push touched and builds the matrix from that; a change to
docs, or to a role with no scenario yet, runs nothing.

Three things this role needed that a MASH role does not:

- The variables matrix-base would supply (matrix_base_data_path,
  matrix_domain, matrix_user_name, matrix_group_name and the uid/gid) have
  to be provided by the scenario, and the user and group have to exist
  before the role's file tasks run.
- The service contacts the homeserver while starting up - it fetches
  /_matrix/client/v3/joined_rooms to resolve its room mapping and exits 1
  if that fails - so prepare.yml stands up a stub homeserver. Most roles
  here are bridges and bots, so this is likely the rule rather than the
  exception.
- verify.yml runs as its own play, where role defaults are out of scope, so
  the paths it reads are pinned in molecule.yml. The version is deliberately
  NOT pinned: it is read from defaults/main.yml so the assertion compares
  the running image against what the role ships.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 18:02:53 +03:00
renovate[bot]andSlavi Pantaleev 03f6e4305f Update docker.io/metio/matrix-alertmanager-receiver Docker tag to v2026.8.26 2026-08-27 18:00:34 +03:00
renovate[bot]andGitHub 7a125124c3 Update dependency prometheus_node_exporter to v1.12.1-1 2026-08-27 14:44:21 +00:00
renovate[bot]andGitHub 050b74a3f5 Update dependency postgres to v18.6-1 2026-08-27 13:59:53 +00:00
renovate[bot]andGitHub b9a72f2d41 Update dependency ntfy to v2.27.0-2 2026-08-27 13:37:11 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
64a2b7e657 Update dependency traefik to v3.7.12-0 (#5561)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-27 13:35:54 +00:00
renovate[bot]andGitHub c029028607 Update dependency jitsi to v11146-2-1 2026-08-27 12:33:13 +00:00
renovate[bot]andGitHub 0619489e63 Update dependency grafana to v13.0.2-2 2026-08-27 12:09:46 +00:00
renovate[bot]andGitHub a14f1adf4f Update dependency ddclient to v4.0.0-5 2026-08-27 10:24:06 +00:00
renovate[bot]andGitHub 92cacbb79b Update dependency livekit_server to v1.13.6-0 2026-08-27 07:53:13 +00:00
renovate[bot]andGitHub cbb70e858f Update dependency prek to v0.5.0 2026-08-27 04:46:51 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
45448b0a66 Update dependency backup_borg to v1.4.5-2.1.7-2 (#5570)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-27 04:46:25 +00:00
renovate[bot]andGitHub ce6e2baabf Update dependency click to v8.5.0 2026-08-26 14:43:44 +00:00
renovate[bot]andSlavi Pantaleev 18735b5b5c Update matrixconduit/matrix-conduit Docker tag to v0.10.13 2026-08-26 07:30:12 +03:00
renovate[bot]andGitHub 45905f5367 Update dependency etherpad to v3 2026-08-26 00:27:24 +00:00
renovate[bot]andGitHub 9dd01d88e4 Update dependency prometheus_nginxlog_exporter to v1.11.0-0 2026-08-25 19:45:35 +00:00
Slavi PantaleevandClaude Opus 5 bcca8dba18 justfile: git hooks resolve prek through the mise shim
prek bakes the full path of the currently installed version into the hooks it
generates (var/mise/installs/prek/<version>/...). That path stops existing as
soon as the pinned version changes or old versions are pruned, and the hook's
PATH fallback finds no prek either, so every commit fails until the hook is
regenerated by hand. It also means a hook keeps running the version it was
generated with, long after mise.toml has moved on.

Rewriting PREK to mise's shim makes the hook resolve whatever mise.toml pins at
the time it runs. The accompanying MISE_DATA_DIR / MISE_TRUSTED_CONFIG_PATHS
exports keep that resolution inside this project - without them mise falls back
to the global data directory and silently installs a second copy of the tool.

The patch loop now covers every hook file prek generated rather than just
pre-commit, since default_install_hook_types decides which ones exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 16:39:23 +03:00
renovate[bot]andSlavi Pantaleev 40cc9843be Update dependency postgres_backup to v18-4 2026-08-25 02:59:38 +03:00
renovate[bot]andSlavi Pantaleev 5d1a58e34d Update dependency hydrogen to v0.5.1-7 2026-08-25 02:57:29 +03:00
renovate[bot]andSlavi Pantaleev bbf2c18b41 Update dependency cinny to v4.12.6-2 2026-08-25 02:57:25 +03:00
renovate[bot]andSlavi Pantaleev 4548e502a3 Update dependency exim_relay to v4.99.1-r0-2-3 2026-08-25 02:57:22 +03:00
renovate[bot]andSlavi Pantaleev f357406371 Update dependency etherpad to v2.7.2-3 2026-08-25 02:57:19 +03:00