Skip to content
This repository was archived by the owner on Apr 26, 2024. It is now read-only.

Commit 98fd558

Browse files
reivilibreclokep
andauthored
Add a primitive helper script for listing worker endpoints. (#15243)
Co-authored-by: Patrick Cloke <patrickc@matrix.org>
1 parent 3b0083c commit 98fd558

31 files changed

+424
-12
lines changed

changelog.d/15243.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add a primitive helper script for listing worker endpoints.
Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
#!/usr/bin/env python
2+
# Copyright 2022-2023 The Matrix.org Foundation C.I.C.
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
import argparse
17+
import logging
18+
import re
19+
from collections import defaultdict
20+
from dataclasses import dataclass
21+
from typing import Dict, Iterable, Optional, Pattern, Set, Tuple
22+
23+
import yaml
24+
25+
from synapse.config.homeserver import HomeServerConfig
26+
from synapse.federation.transport.server import (
27+
TransportLayerServer,
28+
register_servlets as register_federation_servlets,
29+
)
30+
from synapse.http.server import HttpServer, ServletCallback
31+
from synapse.rest import ClientRestResource
32+
from synapse.rest.key.v2 import RemoteKey
33+
from synapse.server import HomeServer
34+
from synapse.storage import DataStore
35+
36+
logger = logging.getLogger("generate_workers_map")
37+
38+
39+
class MockHomeserver(HomeServer):
40+
DATASTORE_CLASS = DataStore # type: ignore
41+
42+
def __init__(self, config: HomeServerConfig, worker_app: Optional[str]) -> None:
43+
super().__init__(config.server.server_name, config=config)
44+
self.config.worker.worker_app = worker_app
45+
46+
47+
GROUP_PATTERN = re.compile(r"\(\?P<[^>]+?>(.+?)\)")
48+
49+
50+
@dataclass
51+
class EndpointDescription:
52+
"""
53+
Describes an endpoint and how it should be routed.
54+
"""
55+
56+
# The servlet class that handles this endpoint
57+
servlet_class: object
58+
59+
# The category of this endpoint. Is read from the `CATEGORY` constant in the servlet
60+
# class.
61+
category: Optional[str]
62+
63+
# TODO:
64+
# - does it need to be routed based on a stream writer config?
65+
# - does it benefit from any optimised, but optional, routing?
66+
# - what 'opinionated synapse worker class' (event_creator, synchrotron, etc) does
67+
# it go in?
68+
69+
70+
class EnumerationResource(HttpServer):
71+
"""
72+
Accepts servlet registrations for the purposes of building up a description of
73+
all endpoints.
74+
"""
75+
76+
def __init__(self, is_worker: bool) -> None:
77+
self.registrations: Dict[Tuple[str, str], EndpointDescription] = {}
78+
self._is_worker = is_worker
79+
80+
def register_paths(
81+
self,
82+
method: str,
83+
path_patterns: Iterable[Pattern],
84+
callback: ServletCallback,
85+
servlet_classname: str,
86+
) -> None:
87+
# federation servlet callbacks are wrapped, so unwrap them.
88+
callback = getattr(callback, "__wrapped__", callback)
89+
90+
# fish out the servlet class
91+
servlet_class = callback.__self__.__class__ # type: ignore
92+
93+
if self._is_worker and method in getattr(
94+
servlet_class, "WORKERS_DENIED_METHODS", ()
95+
):
96+
# This endpoint would cause an error if called on a worker, so pretend it
97+
# was never registered!
98+
return
99+
100+
sd = EndpointDescription(
101+
servlet_class=servlet_class,
102+
category=getattr(servlet_class, "CATEGORY", None),
103+
)
104+
105+
for pat in path_patterns:
106+
self.registrations[(method, pat.pattern)] = sd
107+
108+
109+
def get_registered_paths_for_hs(
110+
hs: HomeServer,
111+
) -> Dict[Tuple[str, str], EndpointDescription]:
112+
"""
113+
Given a homeserver, get all registered endpoints and their descriptions.
114+
"""
115+
116+
enumerator = EnumerationResource(is_worker=hs.config.worker.worker_app is not None)
117+
ClientRestResource.register_servlets(enumerator, hs)
118+
federation_server = TransportLayerServer(hs)
119+
120+
# we can't use `federation_server.register_servlets` but this line does the
121+
# same thing, only it uses this enumerator
122+
register_federation_servlets(
123+
federation_server.hs,
124+
resource=enumerator,
125+
ratelimiter=federation_server.ratelimiter,
126+
authenticator=federation_server.authenticator,
127+
servlet_groups=federation_server.servlet_groups,
128+
)
129+
130+
# the key server endpoints are separate again
131+
RemoteKey(hs).register(enumerator)
132+
133+
return enumerator.registrations
134+
135+
136+
def get_registered_paths_for_default(
137+
worker_app: Optional[str], base_config: HomeServerConfig
138+
) -> Dict[Tuple[str, str], EndpointDescription]:
139+
"""
140+
Given the name of a worker application and a base homeserver configuration,
141+
returns:
142+
143+
Dict from (method, path) to EndpointDescription
144+
145+
TODO Don't require passing in a config
146+
"""
147+
148+
hs = MockHomeserver(base_config, worker_app)
149+
# TODO We only do this to avoid an error, but don't need the database etc
150+
hs.setup()
151+
return get_registered_paths_for_hs(hs)
152+
153+
154+
def elide_http_methods_if_unconflicting(
155+
registrations: Dict[Tuple[str, str], EndpointDescription],
156+
all_possible_registrations: Dict[Tuple[str, str], EndpointDescription],
157+
) -> Dict[Tuple[str, str], EndpointDescription]:
158+
"""
159+
Elides HTTP methods (by replacing them with `*`) if all possible registered methods
160+
can be handled by the worker whose registration map is `registrations`.
161+
162+
i.e. the only endpoints left with methods (other than `*`) should be the ones where
163+
the worker can't handle all possible methods for that path.
164+
"""
165+
166+
def paths_to_methods_dict(
167+
methods_and_paths: Iterable[Tuple[str, str]]
168+
) -> Dict[str, Set[str]]:
169+
"""
170+
Given (method, path) pairs, produces a dict from path to set of methods
171+
available at that path.
172+
"""
173+
result: Dict[str, Set[str]] = {}
174+
for method, path in methods_and_paths:
175+
result.setdefault(path, set()).add(method)
176+
return result
177+
178+
all_possible_reg_methods = paths_to_methods_dict(all_possible_registrations)
179+
reg_methods = paths_to_methods_dict(registrations)
180+
181+
output = {}
182+
183+
for path, handleable_methods in reg_methods.items():
184+
if handleable_methods == all_possible_reg_methods[path]:
185+
any_method = next(iter(handleable_methods))
186+
# TODO This assumes that all methods have the same servlet.
187+
# I suppose that's possibly dubious?
188+
output[("*", path)] = registrations[(any_method, path)]
189+
else:
190+
for method in handleable_methods:
191+
output[(method, path)] = registrations[(method, path)]
192+
193+
return output
194+
195+
196+
def simplify_path_regexes(
197+
registrations: Dict[Tuple[str, str], EndpointDescription]
198+
) -> Dict[Tuple[str, str], EndpointDescription]:
199+
"""
200+
Simplify all the path regexes for the dict of endpoint descriptions,
201+
so that we don't use the Python-specific regex extensions
202+
(and also to remove needlessly specific detail).
203+
"""
204+
205+
def simplify_path_regex(path: str) -> str:
206+
"""
207+
Given a regex pattern, replaces all named capturing groups (e.g. `(?P<blah>xyz)`)
208+
with a simpler version available in more common regex dialects (e.g. `.*`).
209+
"""
210+
211+
# TODO it's hard to choose between these two;
212+
# `.*` is a vague simplification
213+
# return GROUP_PATTERN.sub(r"\1", path)
214+
return GROUP_PATTERN.sub(r".*", path)
215+
216+
return {(m, simplify_path_regex(p)): v for (m, p), v in registrations.items()}
217+
218+
219+
def main() -> None:
220+
parser = argparse.ArgumentParser(
221+
description=(
222+
"Updates a synapse database to the latest schema and optionally runs background updates"
223+
" on it."
224+
)
225+
)
226+
parser.add_argument("-v", action="store_true")
227+
parser.add_argument(
228+
"--config-path",
229+
type=argparse.FileType("r"),
230+
required=True,
231+
help="Synapse configuration file",
232+
)
233+
234+
args = parser.parse_args()
235+
236+
# TODO
237+
# logging.basicConfig(**logging_config)
238+
239+
# Load, process and sanity-check the config.
240+
hs_config = yaml.safe_load(args.config_path)
241+
242+
config = HomeServerConfig()
243+
config.parse_config_dict(hs_config, "", "")
244+
245+
master_paths = get_registered_paths_for_default(None, config)
246+
worker_paths = get_registered_paths_for_default(
247+
"synapse.app.generic_worker", config
248+
)
249+
250+
all_paths = {**master_paths, **worker_paths}
251+
252+
elided_worker_paths = elide_http_methods_if_unconflicting(worker_paths, all_paths)
253+
elide_http_methods_if_unconflicting(master_paths, all_paths)
254+
255+
# TODO SSO endpoints (pick_idp etc) NOT REGISTERED BY THIS SCRIPT
256+
257+
categories_to_methods_and_paths: Dict[
258+
Optional[str], Dict[Tuple[str, str], EndpointDescription]
259+
] = defaultdict(dict)
260+
261+
for (method, path), desc in elided_worker_paths.items():
262+
categories_to_methods_and_paths[desc.category][method, path] = desc
263+
264+
for category, contents in categories_to_methods_and_paths.items():
265+
print_category(category, contents)
266+
267+
268+
def print_category(
269+
category_name: Optional[str],
270+
elided_worker_paths: Dict[Tuple[str, str], EndpointDescription],
271+
) -> None:
272+
"""
273+
Prints out a category, in documentation page style.
274+
275+
Example:
276+
```
277+
# Category name
278+
/path/xyz
279+
280+
GET /path/abc
281+
```
282+
"""
283+
284+
if category_name:
285+
print(f"# {category_name}")
286+
else:
287+
print("# (Uncategorised requests)")
288+
289+
for ln in sorted(
290+
p for m, p in simplify_path_regexes(elided_worker_paths) if m == "*"
291+
):
292+
print(ln)
293+
print()
294+
for ln in sorted(
295+
f"{m:6} {p}" for m, p in simplify_path_regexes(elided_worker_paths) if m != "*"
296+
):
297+
print(ln)
298+
print()
299+
300+
301+
if __name__ == "__main__":
302+
main()

synapse/federation/transport/server/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ class PublicRoomList(BaseFederationServlet):
108108
"""
109109

110110
PATH = "/publicRooms"
111+
CATEGORY = "Federation requests"
111112

112113
def __init__(
113114
self,
@@ -212,6 +213,7 @@ class OpenIdUserInfo(BaseFederationServlet):
212213
"""
213214

214215
PATH = "/openid/userinfo"
216+
CATEGORY = "Federation requests"
215217

216218
REQUIRE_AUTH = False
217219

0 commit comments

Comments
 (0)