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 }}"