From 81a0ee3d53b8b1484b7821dac19020904afa9de3 Mon Sep 17 00:00:00 2001 From: Slavi Pantaleev Date: Thu, 27 Aug 2026 15:08:18 +0300 Subject: [PATCH] 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) --- molecule-shared/homeserver-stub.py | 137 ++++++++++++++++++ molecule-shared/homeserver-stub.py.license | 3 + molecule-shared/tasks/homeserver-stub.yml | 77 ++++++++++ .../molecule/default/prepare.yml | 65 ++------- 4 files changed, 226 insertions(+), 56 deletions(-) create mode 100644 molecule-shared/homeserver-stub.py create mode 100644 molecule-shared/homeserver-stub.py.license create mode 100644 molecule-shared/tasks/homeserver-stub.yml diff --git a/molecule-shared/homeserver-stub.py b/molecule-shared/homeserver-stub.py new file mode 100644 index 000000000..b0a334f58 --- /dev/null +++ b/molecule-shared/homeserver-stub.py @@ -0,0 +1,137 @@ +# SPDX-FileCopyrightText: 2026 Slavi Pantaleev +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""A stand-in homeserver for Molecule scenarios. + +Most components in this playbook talk to a homeserver while starting up and +exit if it is unreachable, so a scenario cannot get them running without one. +Standing up a real Synapse for every role would dominate the run time and drag +in Postgres, and the scenarios are not testing Synapse - they are testing that +the role's configuration reaches the component and that it starts. + +So this answers the handful of endpoints components touch during startup, with +the blandest plausible response in each case. It is deliberately permissive: an +unknown path returns `{}` with a 200 rather than a 404, because the goal is to +get the component past its startup checks, not to model the Matrix spec. + +What it is NOT: an authentication check, a room state machine, or anything a +scenario should assert *about*. Assert on what the role rendered and on what the +component reports about itself. If a scenario starts needing this stub to behave +like a real homeserver, that scenario has outgrown what these tests are for. +""" + +import json +import os +import re +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +SERVER_NAME = os.environ.get("STUB_SERVER_NAME", "molecule.local") +PORT = int(os.environ.get("STUB_PORT", "8008")) + +# Rooms reported as already joined. Components that resolve a room mapping at +# startup (matrix-alertmanager-receiver, for one) fail if the rooms they were +# configured with are missing, so a scenario passes its own room IDs in. +JOINED_ROOMS = [r for r in os.environ.get("STUB_JOINED_ROOMS", "").split(",") if r] + +USER_ID = os.environ.get("STUB_USER_ID", f"@stub:{SERVER_NAME}") + + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def _send(self, payload, status=200): + body = json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _route(self): + path = self.path.split("?", 1)[0] + + if path.endswith("/joined_rooms"): + return {"joined_rooms": JOINED_ROOMS} + + if path.endswith("/whoami"): + return {"user_id": USER_ID, "device_id": "STUBDEVICE"} + + if path.endswith("/versions"): + return { + "versions": ["v1.1", "v1.2", "v1.3", "v1.4", "v1.5", "v1.6"], + "unstable_features": {}, + } + + if path.endswith("/capabilities"): + return {"capabilities": {}} + + if path.endswith("/_matrix/client/v3/login"): + return { + "user_id": USER_ID, + "access_token": "stub_access_token", + "device_id": "STUBDEVICE", + "home_server": SERVER_NAME, + } + + if path.endswith("/createRoom"): + return {"room_id": f"!stub-room:{SERVER_NAME}"} + + if re.search(r"/rooms/[^/]+/join$", path) or path.endswith("/join"): + return {"room_id": f"!stub-room:{SERVER_NAME}"} + + if "/send/" in path or "/state/" in path: + return {"event_id": f"$stub-event:{SERVER_NAME}"} + + if path.endswith("/register"): + return { + "user_id": USER_ID, + "access_token": "stub_access_token", + "device_id": "STUBDEVICE", + "home_server": SERVER_NAME, + } + + if path.endswith("/profile") or "/profile/" in path: + return {"displayname": "stub"} + + if path.startswith("/_matrix/key/"): + return {"server_name": SERVER_NAME, "verify_keys": {}, "old_verify_keys": {}} + + if path.startswith("/.well-known/matrix/client"): + return {"m.homeserver": {"base_url": f"http://{SERVER_NAME}:{PORT}"}} + + if path.startswith("/.well-known/matrix/server"): + return {"m.server": f"{SERVER_NAME}:{PORT}"} + + if path.endswith("/health") or path.endswith("/_matrix/federation/v1/version"): + return {"server": {"name": "molecule-stub", "version": "0"}} + + # Anything unrecognised: an empty object, so a component doing a startup + # probe of an endpoint not listed here still gets past it. + return {} + + def do_GET(self): + self._send(self._route()) + + def do_POST(self): + length = int(self.headers.get("Content-Length") or 0) + if length: + self.rfile.read(length) + self._send(self._route()) + + def do_PUT(self): + self.do_POST() + + def do_DELETE(self): + self._send({}) + + def log_message(self, fmt, *args): + # Quiet by default; STUB_VERBOSE=1 when a scenario will not start and you + # need to see what the component is actually asking for. + if os.environ.get("STUB_VERBOSE"): + sys.stderr.write("stub: " + (fmt % args) + "\n") + + +if __name__ == "__main__": + ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever() diff --git a/molecule-shared/homeserver-stub.py.license b/molecule-shared/homeserver-stub.py.license new file mode 100644 index 000000000..dbb307901 --- /dev/null +++ b/molecule-shared/homeserver-stub.py.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2026 Slavi Pantaleev + +SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/molecule-shared/tasks/homeserver-stub.yml b/molecule-shared/tasks/homeserver-stub.yml new file mode 100644 index 000000000..5fbe375dd --- /dev/null +++ b/molecule-shared/tasks/homeserver-stub.yml @@ -0,0 +1,77 @@ +# SPDX-FileCopyrightText: 2026 Slavi Pantaleev +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +--- +# Stands up a stand-in homeserver on a container network, for scenarios whose +# component contacts a homeserver while starting up. +# +# Include from a scenario's prepare.yml: +# +# - 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: "{{ _container_network }}" +# molecule_shared_stub_joined_rooms: ["!some-room:molecule.local"] +# +# The component should then be pointed at http://matrix.molecule.local:8008. +# +# See molecule-shared/homeserver-stub.py for what it answers and, more +# importantly, for what it is not. + +- name: Ensure the homeserver stub script is present + ansible.builtin.copy: + src: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/../../../molecule-shared/homeserver-stub.py" + dest: /root/molecule-homeserver-stub.py + mode: "0755" + +- name: Ensure a previous homeserver stub is gone + ansible.builtin.command: + argv: + - docker + - rm + - --force + - "{{ molecule_shared_stub_name | default('matrix-homeserver-stub') }}" + register: molecule_shared_stub_removal + changed_when: molecule_shared_stub_removal.rc == 0 + failed_when: false + +# The alias is what the component resolves, so its configuration can name a +# hostname rather than a container name. +- name: Ensure the homeserver stub is running + ansible.builtin.command: + argv: + - docker + - run + - --detach + - --name={{ molecule_shared_stub_name | default('matrix-homeserver-stub') }} + - --network={{ molecule_shared_stub_network }} + - --network-alias={{ molecule_shared_stub_hostname | default('matrix.molecule.local') }} + - --env=STUB_SERVER_NAME={{ molecule_shared_stub_server_name | default('molecule.local') }} + - --env=STUB_JOINED_ROOMS={{ (molecule_shared_stub_joined_rooms | default([])) | join(',') }} + - --volume=/root/molecule-homeserver-stub.py:/stub.py:ro + - "{{ molecule_shared_image_python }}" + - python3 + - /stub.py + register: molecule_shared_stub_start + changed_when: molecule_shared_stub_start.rc == 0 + +- name: Wait for the homeserver stub to answer + ansible.builtin.command: + argv: + - docker + - run + - --rm + - --network={{ molecule_shared_stub_network }} + - "{{ molecule_shared_image_curl }}" + - --silent + - --fail + - --max-time + - "5" + - "http://{{ molecule_shared_stub_hostname | default('matrix.molecule.local') }}:8008/_matrix/client/versions" + register: molecule_shared_stub_ready + changed_when: false + until: molecule_shared_stub_ready.rc == 0 + retries: 12 + delay: 5 diff --git a/roles/custom/matrix-alertmanager-receiver/molecule/default/prepare.yml b/roles/custom/matrix-alertmanager-receiver/molecule/default/prepare.yml index 739a172ca..0066bb740 100644 --- a/roles/custom/matrix-alertmanager-receiver/molecule/default/prepare.yml +++ b/roles/custom/matrix-alertmanager-receiver/molecule/default/prepare.yml @@ -70,59 +70,12 @@ - "'already exists' not in matrix_alertmanager_receiver_molecule_network.stderr" # matrix-alertmanager-receiver 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 a homeserver has to exist for the service - # to come up at all. A stub is enough: the scenario is testing this role, - # not Synapse, and it keeps the run offline and fast. - - name: Ensure the Matrix homeserver stub script exists - ansible.builtin.copy: - dest: /root/matrix-homeserver-stub.py - mode: "0755" - content: | - import json - from http.server import BaseHTTPRequestHandler, HTTPServer - - ROOMS = {"joined_rooms": ["{{ matrix_alertmanager_receiver_config_matrix_room_mapping['molecule-room'] }}"]} - - class Handler(BaseHTTPRequestHandler): - def _send(self, payload): - body = json.dumps(payload).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - def do_GET(self): - if self.path.endswith("/joined_rooms"): - self._send(ROOMS) - else: - self._send({}) - - def do_POST(self): - self._send({"event_id": "$molecule-event-id"}) - - def log_message(self, *args): - pass - - HTTPServer(("0.0.0.0", 8008), Handler).serve_forever() - - - name: Ensure the Matrix homeserver stub is running on the role's network - ansible.builtin.command: - argv: - - docker - - run - - --detach - - --rm - - --name=matrix-homeserver-stub - - --network={{ matrix_alertmanager_receiver_container_network }} - - --network-alias=matrix.molecule.local - - --volume=/root/matrix-homeserver-stub.py:/stub.py:ro - - "{{ molecule_shared_image_python }}" - - python3 - - /stub.py - register: matrix_alertmanager_receiver_molecule_stub - changed_when: matrix_alertmanager_receiver_molecule_stub.rc == 0 - failed_when: - - matrix_alertmanager_receiver_molecule_stub.rc != 0 - - "'already in use' not in matrix_alertmanager_receiver_molecule_stub.stderr" + # it fetches /_matrix/client/v3/joined_rooms to resolve its room mapping and + # exits 1 if that fails - so a homeserver has to exist for it to come up at + # all. The shared stub is enough; see molecule-shared/homeserver-stub.py. + - name: Ensure the homeserver stub is running + ansible.builtin.include_tasks: + file: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/../../../molecule-shared/tasks/homeserver-stub.yml" + vars: + molecule_shared_stub_network: "{{ matrix_alertmanager_receiver_container_network }}" + molecule_shared_stub_joined_rooms: "{{ matrix_alertmanager_receiver_config_matrix_room_mapping.values() | list }}"