forked from MirrorHub/synapse
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.
This commit is contained in:
parent
36ba73f53d
commit
ab7a24cc6b
13 changed files with 159 additions and 36 deletions
1
changelog.d/8874.feature
Normal file
1
changelog.d/8874.feature
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Improve the error messages printed as a result of configuration problems for extension modules.
|
|
@ -19,7 +19,7 @@ import gc
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from typing import Iterable
|
from typing import Iterable, Iterator
|
||||||
|
|
||||||
from twisted.application import service
|
from twisted.application import service
|
||||||
from twisted.internet import defer, reactor
|
from twisted.internet import defer, reactor
|
||||||
|
@ -90,7 +90,7 @@ class SynapseHomeServer(HomeServer):
|
||||||
tls = listener_config.tls
|
tls = listener_config.tls
|
||||||
site_tag = listener_config.http_options.tag
|
site_tag = listener_config.http_options.tag
|
||||||
if site_tag is None:
|
if site_tag is None:
|
||||||
site_tag = port
|
site_tag = str(port)
|
||||||
|
|
||||||
# We always include a health resource.
|
# We always include a health resource.
|
||||||
resources = {"/health": HealthResource()}
|
resources = {"/health": HealthResource()}
|
||||||
|
@ -107,7 +107,10 @@ class SynapseHomeServer(HomeServer):
|
||||||
logger.debug("Configuring additional resources: %r", additional_resources)
|
logger.debug("Configuring additional resources: %r", additional_resources)
|
||||||
module_api = self.get_module_api()
|
module_api = self.get_module_api()
|
||||||
for path, resmodule in additional_resources.items():
|
for path, resmodule in additional_resources.items():
|
||||||
handler_cls, config = load_module(resmodule)
|
handler_cls, config = load_module(
|
||||||
|
resmodule,
|
||||||
|
("listeners", site_tag, "additional_resources", "<%s>" % (path,)),
|
||||||
|
)
|
||||||
handler = handler_cls(config, module_api)
|
handler = handler_cls(config, module_api)
|
||||||
if IResource.providedBy(handler):
|
if IResource.providedBy(handler):
|
||||||
resource = handler
|
resource = handler
|
||||||
|
@ -342,7 +345,10 @@ def setup(config_options):
|
||||||
"Synapse Homeserver", config_options
|
"Synapse Homeserver", config_options
|
||||||
)
|
)
|
||||||
except ConfigError as e:
|
except ConfigError as e:
|
||||||
sys.stderr.write("\nERROR: %s\n" % (e,))
|
sys.stderr.write("\n")
|
||||||
|
for f in format_config_error(e):
|
||||||
|
sys.stderr.write(f)
|
||||||
|
sys.stderr.write("\n")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if not config:
|
if not config:
|
||||||
|
@ -445,6 +451,38 @@ def setup(config_options):
|
||||||
return hs
|
return hs
|
||||||
|
|
||||||
|
|
||||||
|
def format_config_error(e: ConfigError) -> Iterator[str]:
|
||||||
|
"""
|
||||||
|
Formats a config error neatly
|
||||||
|
|
||||||
|
The idea is to format the immediate error, plus the "causes" of those errors,
|
||||||
|
hopefully in a way that makes sense to the user. For example:
|
||||||
|
|
||||||
|
Error in configuration at 'oidc_config.user_mapping_provider.config.display_name_template':
|
||||||
|
Failed to parse config for module 'JinjaOidcMappingProvider':
|
||||||
|
invalid jinja template:
|
||||||
|
unexpected end of template, expected 'end of print statement'.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
e: the error to be formatted
|
||||||
|
|
||||||
|
Returns: An iterator which yields string fragments to be formatted
|
||||||
|
"""
|
||||||
|
yield "Error in configuration"
|
||||||
|
|
||||||
|
if e.path:
|
||||||
|
yield " at '%s'" % (".".join(e.path),)
|
||||||
|
|
||||||
|
yield ":\n %s" % (e.msg,)
|
||||||
|
|
||||||
|
e = e.__cause__
|
||||||
|
indent = 1
|
||||||
|
while e:
|
||||||
|
indent += 1
|
||||||
|
yield ":\n%s%s" % (" " * indent, str(e))
|
||||||
|
e = e.__cause__
|
||||||
|
|
||||||
|
|
||||||
class SynapseService(service.Service):
|
class SynapseService(service.Service):
|
||||||
"""
|
"""
|
||||||
A twisted Service class that will start synapse. Used to run synapse
|
A twisted Service class that will start synapse. Used to run synapse
|
||||||
|
|
|
@ -23,7 +23,7 @@ import urllib.parse
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from textwrap import dedent
|
from textwrap import dedent
|
||||||
from typing import Any, Callable, List, MutableMapping, Optional
|
from typing import Any, Callable, Iterable, List, MutableMapping, Optional
|
||||||
|
|
||||||
import attr
|
import attr
|
||||||
import jinja2
|
import jinja2
|
||||||
|
@ -32,7 +32,17 @@ import yaml
|
||||||
|
|
||||||
|
|
||||||
class ConfigError(Exception):
|
class ConfigError(Exception):
|
||||||
pass
|
"""Represents a problem parsing the configuration
|
||||||
|
|
||||||
|
Args:
|
||||||
|
msg: A textual description of the error.
|
||||||
|
path: Where appropriate, an indication of where in the configuration
|
||||||
|
the problem lies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, msg: str, path: Optional[Iterable[str]] = None):
|
||||||
|
self.msg = msg
|
||||||
|
self.path = path
|
||||||
|
|
||||||
|
|
||||||
# We split these messages out to allow packages to override with package
|
# We split these messages out to allow packages to override with package
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
from typing import Any, List, Optional
|
from typing import Any, Iterable, List, Optional
|
||||||
|
|
||||||
from synapse.config import (
|
from synapse.config import (
|
||||||
api,
|
api,
|
||||||
|
@ -35,7 +35,10 @@ from synapse.config import (
|
||||||
workers,
|
workers,
|
||||||
)
|
)
|
||||||
|
|
||||||
class ConfigError(Exception): ...
|
class ConfigError(Exception):
|
||||||
|
def __init__(self, msg: str, path: Optional[Iterable[str]] = None):
|
||||||
|
self.msg = msg
|
||||||
|
self.path = path
|
||||||
|
|
||||||
MISSING_REPORT_STATS_CONFIG_INSTRUCTIONS: str
|
MISSING_REPORT_STATS_CONFIG_INSTRUCTIONS: str
|
||||||
MISSING_REPORT_STATS_SPIEL: str
|
MISSING_REPORT_STATS_SPIEL: str
|
||||||
|
|
|
@ -38,6 +38,22 @@ def validate_config(
|
||||||
try:
|
try:
|
||||||
jsonschema.validate(config, json_schema)
|
jsonschema.validate(config, json_schema)
|
||||||
except jsonschema.ValidationError as e:
|
except jsonschema.ValidationError as e:
|
||||||
|
raise json_error_to_config_error(e, config_path)
|
||||||
|
|
||||||
|
|
||||||
|
def json_error_to_config_error(
|
||||||
|
e: jsonschema.ValidationError, config_path: Iterable[str]
|
||||||
|
) -> ConfigError:
|
||||||
|
"""Converts a json validation error to a user-readable ConfigError
|
||||||
|
|
||||||
|
Args:
|
||||||
|
e: the exception to be converted
|
||||||
|
config_path: the path within the config file. This will be used as a basis
|
||||||
|
for the error message.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
a ConfigError
|
||||||
|
"""
|
||||||
# copy `config_path` before modifying it.
|
# copy `config_path` before modifying it.
|
||||||
path = list(config_path)
|
path = list(config_path)
|
||||||
for p in list(e.path):
|
for p in list(e.path):
|
||||||
|
@ -45,7 +61,4 @@ def validate_config(
|
||||||
path.append("<item %i>" % p)
|
path.append("<item %i>" % p)
|
||||||
else:
|
else:
|
||||||
path.append(str(p))
|
path.append(str(p))
|
||||||
|
return ConfigError(e.message, path)
|
||||||
raise ConfigError(
|
|
||||||
"Unable to parse configuration: %s at %s" % (e.message, ".".join(path))
|
|
||||||
)
|
|
||||||
|
|
|
@ -66,7 +66,7 @@ class OIDCConfig(Config):
|
||||||
(
|
(
|
||||||
self.oidc_user_mapping_provider_class,
|
self.oidc_user_mapping_provider_class,
|
||||||
self.oidc_user_mapping_provider_config,
|
self.oidc_user_mapping_provider_config,
|
||||||
) = load_module(ump_config)
|
) = load_module(ump_config, ("oidc_config", "user_mapping_provider"))
|
||||||
|
|
||||||
# Ensure loaded user mapping module has defined all necessary methods
|
# Ensure loaded user mapping module has defined all necessary methods
|
||||||
required_methods = [
|
required_methods = [
|
||||||
|
|
|
@ -36,7 +36,7 @@ class PasswordAuthProviderConfig(Config):
|
||||||
providers.append({"module": LDAP_PROVIDER, "config": ldap_config})
|
providers.append({"module": LDAP_PROVIDER, "config": ldap_config})
|
||||||
|
|
||||||
providers.extend(config.get("password_providers") or [])
|
providers.extend(config.get("password_providers") or [])
|
||||||
for provider in providers:
|
for i, provider in enumerate(providers):
|
||||||
mod_name = provider["module"]
|
mod_name = provider["module"]
|
||||||
|
|
||||||
# This is for backwards compat when the ldap auth provider resided
|
# This is for backwards compat when the ldap auth provider resided
|
||||||
|
@ -45,7 +45,8 @@ class PasswordAuthProviderConfig(Config):
|
||||||
mod_name = LDAP_PROVIDER
|
mod_name = LDAP_PROVIDER
|
||||||
|
|
||||||
(provider_class, provider_config) = load_module(
|
(provider_class, provider_config) = load_module(
|
||||||
{"module": mod_name, "config": provider["config"]}
|
{"module": mod_name, "config": provider["config"]},
|
||||||
|
("password_providers", "<item %i>" % i),
|
||||||
)
|
)
|
||||||
|
|
||||||
self.password_providers.append((provider_class, provider_config))
|
self.password_providers.append((provider_class, provider_config))
|
||||||
|
|
|
@ -142,7 +142,7 @@ class ContentRepositoryConfig(Config):
|
||||||
# them to be started.
|
# them to be started.
|
||||||
self.media_storage_providers = [] # type: List[tuple]
|
self.media_storage_providers = [] # type: List[tuple]
|
||||||
|
|
||||||
for provider_config in storage_providers:
|
for i, provider_config in enumerate(storage_providers):
|
||||||
# We special case the module "file_system" so as not to need to
|
# We special case the module "file_system" so as not to need to
|
||||||
# expose FileStorageProviderBackend
|
# expose FileStorageProviderBackend
|
||||||
if provider_config["module"] == "file_system":
|
if provider_config["module"] == "file_system":
|
||||||
|
@ -151,7 +151,9 @@ class ContentRepositoryConfig(Config):
|
||||||
".FileStorageProviderBackend"
|
".FileStorageProviderBackend"
|
||||||
)
|
)
|
||||||
|
|
||||||
provider_class, parsed_config = load_module(provider_config)
|
provider_class, parsed_config = load_module(
|
||||||
|
provider_config, ("media_storage_providers", "<item %i>" % i)
|
||||||
|
)
|
||||||
|
|
||||||
wrapper_config = MediaStorageProviderConfig(
|
wrapper_config = MediaStorageProviderConfig(
|
||||||
provider_config.get("store_local", False),
|
provider_config.get("store_local", False),
|
||||||
|
|
|
@ -180,7 +180,7 @@ class _RoomDirectoryRule:
|
||||||
self._alias_regex = glob_to_regex(alias)
|
self._alias_regex = glob_to_regex(alias)
|
||||||
self._room_id_regex = glob_to_regex(room_id)
|
self._room_id_regex = glob_to_regex(room_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ConfigError("Failed to parse glob into regex: %s", e)
|
raise ConfigError("Failed to parse glob into regex") from e
|
||||||
|
|
||||||
def matches(self, user_id, room_id, aliases):
|
def matches(self, user_id, room_id, aliases):
|
||||||
"""Tests if this rule matches the given user_id, room_id and aliases.
|
"""Tests if this rule matches the given user_id, room_id and aliases.
|
||||||
|
|
|
@ -125,7 +125,7 @@ class SAML2Config(Config):
|
||||||
(
|
(
|
||||||
self.saml2_user_mapping_provider_class,
|
self.saml2_user_mapping_provider_class,
|
||||||
self.saml2_user_mapping_provider_config,
|
self.saml2_user_mapping_provider_config,
|
||||||
) = load_module(ump_dict)
|
) = load_module(ump_dict, ("saml2_config", "user_mapping_provider"))
|
||||||
|
|
||||||
# Ensure loaded user mapping module has defined all necessary methods
|
# Ensure loaded user mapping module has defined all necessary methods
|
||||||
# Note parse_config() is already checked during the call to load_module
|
# Note parse_config() is already checked during the call to load_module
|
||||||
|
|
|
@ -33,13 +33,14 @@ class SpamCheckerConfig(Config):
|
||||||
# spam checker, and thus was simply a dictionary with module
|
# spam checker, and thus was simply a dictionary with module
|
||||||
# and config keys. Support this old behaviour by checking
|
# and config keys. Support this old behaviour by checking
|
||||||
# to see if the option resolves to a dictionary
|
# to see if the option resolves to a dictionary
|
||||||
self.spam_checkers.append(load_module(spam_checkers))
|
self.spam_checkers.append(load_module(spam_checkers, ("spam_checker",)))
|
||||||
elif isinstance(spam_checkers, list):
|
elif isinstance(spam_checkers, list):
|
||||||
for spam_checker in spam_checkers:
|
for i, spam_checker in enumerate(spam_checkers):
|
||||||
|
config_path = ("spam_checker", "<item %i>" % i)
|
||||||
if not isinstance(spam_checker, dict):
|
if not isinstance(spam_checker, dict):
|
||||||
raise ConfigError("spam_checker syntax is incorrect")
|
raise ConfigError("expected a mapping", config_path)
|
||||||
|
|
||||||
self.spam_checkers.append(load_module(spam_checker))
|
self.spam_checkers.append(load_module(spam_checker, config_path))
|
||||||
else:
|
else:
|
||||||
raise ConfigError("spam_checker syntax is incorrect")
|
raise ConfigError("spam_checker syntax is incorrect")
|
||||||
|
|
||||||
|
|
|
@ -26,7 +26,9 @@ class ThirdPartyRulesConfig(Config):
|
||||||
|
|
||||||
provider = config.get("third_party_event_rules", None)
|
provider = config.get("third_party_event_rules", None)
|
||||||
if provider is not None:
|
if provider is not None:
|
||||||
self.third_party_event_rules = load_module(provider)
|
self.third_party_event_rules = load_module(
|
||||||
|
provider, ("third_party_event_rules",)
|
||||||
|
)
|
||||||
|
|
||||||
def generate_config_section(self, **kwargs):
|
def generate_config_section(self, **kwargs):
|
||||||
return """\
|
return """\
|
||||||
|
|
|
@ -15,28 +15,56 @@
|
||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
import itertools
|
||||||
|
from typing import Any, Iterable, Tuple, Type
|
||||||
|
|
||||||
|
import jsonschema
|
||||||
|
|
||||||
from synapse.config._base import ConfigError
|
from synapse.config._base import ConfigError
|
||||||
|
from synapse.config._util import json_error_to_config_error
|
||||||
|
|
||||||
|
|
||||||
def load_module(provider):
|
def load_module(provider: dict, config_path: Iterable[str]) -> Tuple[Type, Any]:
|
||||||
""" Loads a synapse module with its config
|
""" Loads a synapse module with its config
|
||||||
Take a dict with keys 'module' (the module name) and 'config'
|
|
||||||
|
Args:
|
||||||
|
provider: a dict with keys 'module' (the module name) and 'config'
|
||||||
(the config dict).
|
(the config dict).
|
||||||
|
config_path: the path within the config file. This will be used as a basis
|
||||||
|
for any error message.
|
||||||
|
|
||||||
Returns
|
Returns
|
||||||
Tuple of (provider class, parsed config object)
|
Tuple of (provider class, parsed config object)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
modulename = provider.get("module")
|
||||||
|
if not isinstance(modulename, str):
|
||||||
|
raise ConfigError(
|
||||||
|
"expected a string", path=itertools.chain(config_path, ("module",))
|
||||||
|
)
|
||||||
|
|
||||||
# We need to import the module, and then pick the class out of
|
# We need to import the module, and then pick the class out of
|
||||||
# that, so we split based on the last dot.
|
# that, so we split based on the last dot.
|
||||||
module, clz = provider["module"].rsplit(".", 1)
|
module, clz = modulename.rsplit(".", 1)
|
||||||
module = importlib.import_module(module)
|
module = importlib.import_module(module)
|
||||||
provider_class = getattr(module, clz)
|
provider_class = getattr(module, clz)
|
||||||
|
|
||||||
|
module_config = provider.get("config")
|
||||||
try:
|
try:
|
||||||
provider_config = provider_class.parse_config(provider.get("config"))
|
provider_config = provider_class.parse_config(module_config)
|
||||||
|
except jsonschema.ValidationError as e:
|
||||||
|
raise json_error_to_config_error(e, itertools.chain(config_path, ("config",)))
|
||||||
|
except ConfigError as e:
|
||||||
|
raise _wrap_config_error(
|
||||||
|
"Failed to parse config for module %r" % (modulename,),
|
||||||
|
prefix=itertools.chain(config_path, ("config",)),
|
||||||
|
e=e,
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ConfigError("Failed to parse config for %r: %s" % (provider["module"], e))
|
raise ConfigError(
|
||||||
|
"Failed to parse config for module %r" % (modulename,),
|
||||||
|
path=itertools.chain(config_path, ("config",)),
|
||||||
|
) from e
|
||||||
|
|
||||||
return provider_class, provider_config
|
return provider_class, provider_config
|
||||||
|
|
||||||
|
@ -56,3 +84,27 @@ def load_python_module(location: str):
|
||||||
mod = importlib.util.module_from_spec(spec)
|
mod = importlib.util.module_from_spec(spec)
|
||||||
spec.loader.exec_module(mod) # type: ignore
|
spec.loader.exec_module(mod) # type: ignore
|
||||||
return mod
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap_config_error(
|
||||||
|
msg: str, prefix: Iterable[str], e: ConfigError
|
||||||
|
) -> "ConfigError":
|
||||||
|
"""Wrap a relative ConfigError with a new path
|
||||||
|
|
||||||
|
This is useful when we have a ConfigError with a relative path due to a problem
|
||||||
|
parsing part of the config, and we now need to set it in context.
|
||||||
|
"""
|
||||||
|
path = prefix
|
||||||
|
if e.path:
|
||||||
|
path = itertools.chain(prefix, e.path)
|
||||||
|
|
||||||
|
e1 = ConfigError(msg, path)
|
||||||
|
|
||||||
|
# ideally we would set the 'cause' of the new exception to the original exception;
|
||||||
|
# however now that we have merged the path into our own, the stringification of
|
||||||
|
# e will be incorrect, so instead we create a new exception with just the "msg"
|
||||||
|
# part.
|
||||||
|
|
||||||
|
e1.__cause__ = Exception(e.msg)
|
||||||
|
e1.__cause__.__cause__ = e.__cause__
|
||||||
|
return e1
|
||||||
|
|
Loading…
Reference in a new issue