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

Commit ab7a24c

Browse files
authored
Better formatting for config errors from modules (#8874)
The idea is that the parse_config method of extension modules can raise either a ConfigError or a JsonValidationError, and it will be magically turned into a legible error message. There's a few components to it: * Separating the "path" and the "message" parts of a ConfigError, so that we can fiddle with the path bit to turn it into an absolute path. * Generally improving the way ConfigErrors get printed. * Passing in the config path to load_module so that it can wrap any exceptions that get caught appropriately.
1 parent 36ba73f commit ab7a24c

13 files changed

+160
-37
lines changed

changelog.d/8874.feature

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Improve the error messages printed as a result of configuration problems for extension modules.

synapse/app/homeserver.py

+42-4
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import logging
2020
import os
2121
import sys
22-
from typing import Iterable
22+
from typing import Iterable, Iterator
2323

2424
from twisted.application import service
2525
from twisted.internet import defer, reactor
@@ -90,7 +90,7 @@ def _listener_http(self, config: HomeServerConfig, listener_config: ListenerConf
9090
tls = listener_config.tls
9191
site_tag = listener_config.http_options.tag
9292
if site_tag is None:
93-
site_tag = port
93+
site_tag = str(port)
9494

9595
# We always include a health resource.
9696
resources = {"/health": HealthResource()}
@@ -107,7 +107,10 @@ def _listener_http(self, config: HomeServerConfig, listener_config: ListenerConf
107107
logger.debug("Configuring additional resources: %r", additional_resources)
108108
module_api = self.get_module_api()
109109
for path, resmodule in additional_resources.items():
110-
handler_cls, config = load_module(resmodule)
110+
handler_cls, config = load_module(
111+
resmodule,
112+
("listeners", site_tag, "additional_resources", "<%s>" % (path,)),
113+
)
111114
handler = handler_cls(config, module_api)
112115
if IResource.providedBy(handler):
113116
resource = handler
@@ -342,7 +345,10 @@ def setup(config_options):
342345
"Synapse Homeserver", config_options
343346
)
344347
except ConfigError as e:
345-
sys.stderr.write("\nERROR: %s\n" % (e,))
348+
sys.stderr.write("\n")
349+
for f in format_config_error(e):
350+
sys.stderr.write(f)
351+
sys.stderr.write("\n")
346352
sys.exit(1)
347353

348354
if not config:
@@ -445,6 +451,38 @@ async def start():
445451
return hs
446452

447453

454+
def format_config_error(e: ConfigError) -> Iterator[str]:
455+
"""
456+
Formats a config error neatly
457+
458+
The idea is to format the immediate error, plus the "causes" of those errors,
459+
hopefully in a way that makes sense to the user. For example:
460+
461+
Error in configuration at 'oidc_config.user_mapping_provider.config.display_name_template':
462+
Failed to parse config for module 'JinjaOidcMappingProvider':
463+
invalid jinja template:
464+
unexpected end of template, expected 'end of print statement'.
465+
466+
Args:
467+
e: the error to be formatted
468+
469+
Returns: An iterator which yields string fragments to be formatted
470+
"""
471+
yield "Error in configuration"
472+
473+
if e.path:
474+
yield " at '%s'" % (".".join(e.path),)
475+
476+
yield ":\n %s" % (e.msg,)
477+
478+
e = e.__cause__
479+
indent = 1
480+
while e:
481+
indent += 1
482+
yield ":\n%s%s" % (" " * indent, str(e))
483+
e = e.__cause__
484+
485+
448486
class SynapseService(service.Service):
449487
"""
450488
A twisted Service class that will start synapse. Used to run synapse

synapse/config/_base.py

+12-2
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from collections import OrderedDict
2424
from hashlib import sha256
2525
from textwrap import dedent
26-
from typing import Any, Callable, List, MutableMapping, Optional
26+
from typing import Any, Callable, Iterable, List, MutableMapping, Optional
2727

2828
import attr
2929
import jinja2
@@ -32,7 +32,17 @@
3232

3333

3434
class ConfigError(Exception):
35-
pass
35+
"""Represents a problem parsing the configuration
36+
37+
Args:
38+
msg: A textual description of the error.
39+
path: Where appropriate, an indication of where in the configuration
40+
the problem lies.
41+
"""
42+
43+
def __init__(self, msg: str, path: Optional[Iterable[str]] = None):
44+
self.msg = msg
45+
self.path = path
3646

3747

3848
# We split these messages out to allow packages to override with package

synapse/config/_base.pyi

+5-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any, List, Optional
1+
from typing import Any, Iterable, List, Optional
22

33
from synapse.config import (
44
api,
@@ -35,7 +35,10 @@ from synapse.config import (
3535
workers,
3636
)
3737

38-
class ConfigError(Exception): ...
38+
class ConfigError(Exception):
39+
def __init__(self, msg: str, path: Optional[Iterable[str]] = None):
40+
self.msg = msg
41+
self.path = path
3942

4043
MISSING_REPORT_STATS_CONFIG_INSTRUCTIONS: str
4144
MISSING_REPORT_STATS_SPIEL: str

synapse/config/_util.py

+24-11
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,27 @@ def validate_config(
3838
try:
3939
jsonschema.validate(config, json_schema)
4040
except jsonschema.ValidationError as e:
41-
# copy `config_path` before modifying it.
42-
path = list(config_path)
43-
for p in list(e.path):
44-
if isinstance(p, int):
45-
path.append("<item %i>" % p)
46-
else:
47-
path.append(str(p))
48-
49-
raise ConfigError(
50-
"Unable to parse configuration: %s at %s" % (e.message, ".".join(path))
51-
)
41+
raise json_error_to_config_error(e, config_path)
42+
43+
44+
def json_error_to_config_error(
45+
e: jsonschema.ValidationError, config_path: Iterable[str]
46+
) -> ConfigError:
47+
"""Converts a json validation error to a user-readable ConfigError
48+
49+
Args:
50+
e: the exception to be converted
51+
config_path: the path within the config file. This will be used as a basis
52+
for the error message.
53+
54+
Returns:
55+
a ConfigError
56+
"""
57+
# copy `config_path` before modifying it.
58+
path = list(config_path)
59+
for p in list(e.path):
60+
if isinstance(p, int):
61+
path.append("<item %i>" % p)
62+
else:
63+
path.append(str(p))
64+
return ConfigError(e.message, path)

synapse/config/oidc_config.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def read_config(self, config, **kwargs):
6666
(
6767
self.oidc_user_mapping_provider_class,
6868
self.oidc_user_mapping_provider_config,
69-
) = load_module(ump_config)
69+
) = load_module(ump_config, ("oidc_config", "user_mapping_provider"))
7070

7171
# Ensure loaded user mapping module has defined all necessary methods
7272
required_methods = [

synapse/config/password_auth_providers.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def read_config(self, config, **kwargs):
3636
providers.append({"module": LDAP_PROVIDER, "config": ldap_config})
3737

3838
providers.extend(config.get("password_providers") or [])
39-
for provider in providers:
39+
for i, provider in enumerate(providers):
4040
mod_name = provider["module"]
4141

4242
# This is for backwards compat when the ldap auth provider resided
@@ -45,7 +45,8 @@ def read_config(self, config, **kwargs):
4545
mod_name = LDAP_PROVIDER
4646

4747
(provider_class, provider_config) = load_module(
48-
{"module": mod_name, "config": provider["config"]}
48+
{"module": mod_name, "config": provider["config"]},
49+
("password_providers", "<item %i>" % i),
4950
)
5051

5152
self.password_providers.append((provider_class, provider_config))

synapse/config/repository.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ def read_config(self, config, **kwargs):
142142
# them to be started.
143143
self.media_storage_providers = [] # type: List[tuple]
144144

145-
for provider_config in storage_providers:
145+
for i, provider_config in enumerate(storage_providers):
146146
# We special case the module "file_system" so as not to need to
147147
# expose FileStorageProviderBackend
148148
if provider_config["module"] == "file_system":
@@ -151,7 +151,9 @@ def read_config(self, config, **kwargs):
151151
".FileStorageProviderBackend"
152152
)
153153

154-
provider_class, parsed_config = load_module(provider_config)
154+
provider_class, parsed_config = load_module(
155+
provider_config, ("media_storage_providers", "<item %i>" % i)
156+
)
155157

156158
wrapper_config = MediaStorageProviderConfig(
157159
provider_config.get("store_local", False),

synapse/config/room_directory.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ def __init__(self, option_name, rule):
180180
self._alias_regex = glob_to_regex(alias)
181181
self._room_id_regex = glob_to_regex(room_id)
182182
except Exception as e:
183-
raise ConfigError("Failed to parse glob into regex: %s", e)
183+
raise ConfigError("Failed to parse glob into regex") from e
184184

185185
def matches(self, user_id, room_id, aliases):
186186
"""Tests if this rule matches the given user_id, room_id and aliases.

synapse/config/saml2_config.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ def read_config(self, config, **kwargs):
125125
(
126126
self.saml2_user_mapping_provider_class,
127127
self.saml2_user_mapping_provider_config,
128-
) = load_module(ump_dict)
128+
) = load_module(ump_dict, ("saml2_config", "user_mapping_provider"))
129129

130130
# Ensure loaded user mapping module has defined all necessary methods
131131
# Note parse_config() is already checked during the call to load_module

synapse/config/spam_checker.py

+5-4
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,14 @@ def read_config(self, config, **kwargs):
3333
# spam checker, and thus was simply a dictionary with module
3434
# and config keys. Support this old behaviour by checking
3535
# to see if the option resolves to a dictionary
36-
self.spam_checkers.append(load_module(spam_checkers))
36+
self.spam_checkers.append(load_module(spam_checkers, ("spam_checker",)))
3737
elif isinstance(spam_checkers, list):
38-
for spam_checker in spam_checkers:
38+
for i, spam_checker in enumerate(spam_checkers):
39+
config_path = ("spam_checker", "<item %i>" % i)
3940
if not isinstance(spam_checker, dict):
40-
raise ConfigError("spam_checker syntax is incorrect")
41+
raise ConfigError("expected a mapping", config_path)
4142

42-
self.spam_checkers.append(load_module(spam_checker))
43+
self.spam_checkers.append(load_module(spam_checker, config_path))
4344
else:
4445
raise ConfigError("spam_checker syntax is incorrect")
4546

synapse/config/third_party_event_rules.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ def read_config(self, config, **kwargs):
2626

2727
provider = config.get("third_party_event_rules", None)
2828
if provider is not None:
29-
self.third_party_event_rules = load_module(provider)
29+
self.third_party_event_rules = load_module(
30+
provider, ("third_party_event_rules",)
31+
)
3032

3133
def generate_config_section(self, **kwargs):
3234
return """\

synapse/util/module_loader.py

+58-6
Original file line numberDiff line numberDiff line change
@@ -15,28 +15,56 @@
1515

1616
import importlib
1717
import importlib.util
18+
import itertools
19+
from typing import Any, Iterable, Tuple, Type
20+
21+
import jsonschema
1822

1923
from synapse.config._base import ConfigError
24+
from synapse.config._util import json_error_to_config_error
2025

2126

22-
def load_module(provider):
27+
def load_module(provider: dict, config_path: Iterable[str]) -> Tuple[Type, Any]:
2328
""" Loads a synapse module with its config
24-
Take a dict with keys 'module' (the module name) and 'config'
25-
(the config dict).
29+
30+
Args:
31+
provider: a dict with keys 'module' (the module name) and 'config'
32+
(the config dict).
33+
config_path: the path within the config file. This will be used as a basis
34+
for any error message.
2635
2736
Returns
2837
Tuple of (provider class, parsed config object)
2938
"""
39+
40+
modulename = provider.get("module")
41+
if not isinstance(modulename, str):
42+
raise ConfigError(
43+
"expected a string", path=itertools.chain(config_path, ("module",))
44+
)
45+
3046
# We need to import the module, and then pick the class out of
3147
# that, so we split based on the last dot.
32-
module, clz = provider["module"].rsplit(".", 1)
48+
module, clz = modulename.rsplit(".", 1)
3349
module = importlib.import_module(module)
3450
provider_class = getattr(module, clz)
3551

52+
module_config = provider.get("config")
3653
try:
37-
provider_config = provider_class.parse_config(provider.get("config"))
54+
provider_config = provider_class.parse_config(module_config)
55+
except jsonschema.ValidationError as e:
56+
raise json_error_to_config_error(e, itertools.chain(config_path, ("config",)))
57+
except ConfigError as e:
58+
raise _wrap_config_error(
59+
"Failed to parse config for module %r" % (modulename,),
60+
prefix=itertools.chain(config_path, ("config",)),
61+
e=e,
62+
)
3863
except Exception as e:
39-
raise ConfigError("Failed to parse config for %r: %s" % (provider["module"], e))
64+
raise ConfigError(
65+
"Failed to parse config for module %r" % (modulename,),
66+
path=itertools.chain(config_path, ("config",)),
67+
) from e
4068

4169
return provider_class, provider_config
4270

@@ -56,3 +84,27 @@ def load_python_module(location: str):
5684
mod = importlib.util.module_from_spec(spec)
5785
spec.loader.exec_module(mod) # type: ignore
5886
return mod
87+
88+
89+
def _wrap_config_error(
90+
msg: str, prefix: Iterable[str], e: ConfigError
91+
) -> "ConfigError":
92+
"""Wrap a relative ConfigError with a new path
93+
94+
This is useful when we have a ConfigError with a relative path due to a problem
95+
parsing part of the config, and we now need to set it in context.
96+
"""
97+
path = prefix
98+
if e.path:
99+
path = itertools.chain(prefix, e.path)
100+
101+
e1 = ConfigError(msg, path)
102+
103+
# ideally we would set the 'cause' of the new exception to the original exception;
104+
# however now that we have merged the path into our own, the stringification of
105+
# e will be incorrect, so instead we create a new exception with just the "msg"
106+
# part.
107+
108+
e1.__cause__ = Exception(e.msg)
109+
e1.__cause__.__cause__ = e.__cause__
110+
return e1

0 commit comments

Comments
 (0)