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
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
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.
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>
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>
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>
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>
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>
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>
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>
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>
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>
Every dependency bump in the housekeeping list was already automerging,
but as a pull request - which meant an email and an open PR for every
bump even when it merged itself. Branch-push automerge makes green bumps
invisible; a failing one still surfaces as a pull request. The new
Matrix i18n workflow gates the i18n/requirements.txt ones with a real
exercise of the translation toolchain.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BhJbmhyUpt4qSrKD9cbJ5A
Nothing used to test an i18n/requirements.txt bump - the first real
exercise of a bumped Sphinx/Babel stack was the next scheduled
translations run, well after merging. Template extraction runs the same
toolchain the scheduled workflow uses, in a minute or two, so a broken
bump now fails on its branch instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BhJbmhyUpt4qSrKD9cbJ5A
The seven pinned com.devture.ansible.role.* roles are maintained by the
same people who review these PRs; their releases are already the review.
Same branch-push automerge (and the same lint gate) as MASH role bumps.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BhJbmhyUpt4qSrKD9cbJ5A
Same rule mash-playbook has been running in production: role bumps from
mother-of-all-self-hosting merge via branch push (no PR) once the branch's
CI checks pass. The trust basis is the role repositories' own gates - each
release is cut by Molecule-verified, autotagged merges over there.
Bumps of devture roles and everything else keep their PRs. Extending the
same treatment to devture roles (here and in mash-playbook) is a discussed
follow-up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BhJbmhyUpt4qSrKD9cbJ5A
The previous image versions set ANSIBLE_WORKER_SESSION_ISOLATION=False in an
attempt to restore SSH prompts on Ansible 2.21. That setting does not bring
the prompts back — it makes Ansible hang silently at the first SSH connection
whenever a prompt would have been needed. See
https://github.com/devture/docker-ansible/issues/6
The new image auto-accepts the SSH host keys of previously unknown hosts
instead. The docs stop recommending ANSIBLE_WORKER_SESSION_ISOLATION=False
for direct (non-Docker) runs for the same reason, and now explain how to use
an ssh-agent for passphrase-protected SSH keys.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Protected rooms, watched policy lists, protection settings and the set of
users allowed to command a bot all belong to a management room rather than
to the bot, and a room can be protected from only one of them. That is what
makes it possible to delegate a single room to another moderator without
handing over power in every other protected room, so it is worth spelling
out, along with what such a split does not cover: the variables naming one
management room, and policy lists shared through subscriptions.
Adding a second management room also means declaring the rooms of a bot
which had been auto-creating its own, which is a step with a sharp edge:
once the bot is no longer auto-creating, any of its rooms missing from the
declaration is unregistered. Document that path and warn about it.
Also drop a note describing "!lists subscribe --insert-before" as a future
release. The role has shipped a version carrying it since v0.2608.0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This reverts commit 421b592690.
The org.matrix.msc4143.rtc_foci property is indeed gone from MSC4143 and
Element Call v0.24.0 no longer reads it, but Element Web does not ship that
version of Element Call yet. v1.12.26 embeds Element Call v0.22.0, which
discovers transports through the well-known property.
That embedded copy cannot consult the homeserver's RTC transports API
instead: it runs as a widget, a widget holds no access token and the API
requires authentication, so its own discovery code skips the API branch
outright. Newer Element Call versions ask their host client over the widget
API (MSC4515) and Element Web already implements that side of it, but the
widget has to speak it too.
Dropping the property therefore left Element Web and Element Desktop users
with MISSING_MATRIX_RTC_TRANSPORT when starting a call.
The restored variables carry a comment about why the property stays, and the
changelog entry is rewritten to announce the revert.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lk-jwt-service v0.6.0 ships a healthcheck which builds its URL as
http://localhost:$LIVEKIT_JWT_BIND/healthz, interpolating the bind
address into the port slot. We set LIVEKIT_JWT_BIND to ":8080", so the
check requests http://localhost::8080/healthz, which does not parse and
can never succeed. The container therefore sits permanently unhealthy,
Traefik skips unhealthy containers, and the service stops being routed,
so Element Call fails to obtain an SFU token.
No value satisfies both sides: the service passes LIVEKIT_JWT_BIND to
ListenAndServe, which needs a full bind address, while the healthcheck
needs a bare port. The check only works when the variable is unset and
its own "8080" fallback applies, which would mean ignoring a configured
port. Correcting the check from here is not possible either, as the
image is built FROM scratch and has no shell for a --health-cmd
override to use.
Turn the healthcheck off, behind a variable so it can be turned back on
once upstream fixes it. This restores the pre-v0.6.0 behavior, where the
image carried no healthcheck at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Failing to update the apt cache now reports why, instead of reporting that
the result of the apt module has no cache_updated attribute.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>