]> git.ipfire.org Git - thirdparty/knot-resolver.git/commitdiff
python: manager: allow multiple configuration file inputs
authorAleš Mrázek <ales.mrazek@nic.cz>
Wed, 18 Jun 2025 14:05:31 +0000 (16:05 +0200)
committerVladimír Čunát <vladimir.cunat@nic.cz>
Fri, 4 Jul 2025 17:12:45 +0000 (19:12 +0200)
python/knot_resolver/manager/main.py
python/knot_resolver/manager/server.py

index dac47bed4846b8155b1e36417885e0261ef9ba14..1db2af975111420b91f1eb9e2983c3939a883f75 100644 (file)
@@ -5,7 +5,6 @@ file to allow us to exclude the __main__.py file from black's autoformatting
 
 import argparse
 import sys
-from pathlib import Path
 from typing import NoReturn
 
 from knot_resolver.constants import CONFIG_FILE, VERSION
@@ -26,11 +25,16 @@ def parse_args() -> argparse.Namespace:
     parser.add_argument(
         "-c",
         "--config",
-        help="Config file to load. Overrides default config location at '" + str(CONFIG_FILE) + "'",
+        help="One or more configuration files to load."
+        f" Overrides default configuration file location at '{str(CONFIG_FILE)}'"
+        " Files must not contain the same options."
+        " However, they may extend individual subsections."
+        " The location of the first configuration file determines"
+        "the prefix for every relative path in the configuration.",
         type=str,
-        nargs=1,
+        nargs="+",
         required=False,
-        default=None,
+        default=[str(CONFIG_FILE)],
     )
     return parser.parse_args()
 
@@ -42,11 +46,5 @@ def main() -> NoReturn:
     # parse arguments
     args = parse_args()
 
-    # where to look for config
-    if args.config is not None:
-        config_path = Path(args.config[0])
-    else:
-        config_path = CONFIG_FILE
-
-    exit_code = compat.asyncio.run(start_server(config=config_path))
+    exit_code = compat.asyncio.run(start_server(config=args.config))
     sys.exit(exit_code)
index 41078a7ad3c33b5c662d5707daf7a56fed3ea499..0f5ea74dd60083fbf8f6623bfd4405fdb5d61557 100644 (file)
@@ -18,7 +18,7 @@ from aiohttp.web_app import Application
 from aiohttp.web_response import json_response
 from aiohttp.web_runner import AppRunner, TCPSite, UnixSite
 
-from knot_resolver.constants import CONFIG_FILE, USER
+from knot_resolver.constants import USER
 from knot_resolver.controller import get_best_controller_implementation
 from knot_resolver.controller.exceptions import SubprocessControllerError, SubprocessControllerExecError
 from knot_resolver.controller.interface import SubprocessType
@@ -88,7 +88,7 @@ class Server:
     # This is top-level class containing pretty much everything. Instead of global
     # variables, we use instance attributes. That's why there are so many and it's
     # ok.
-    def __init__(self, store: ConfigStore, config_path: Optional[Path], manager: KresManager):
+    def __init__(self, store: ConfigStore, config_path: Optional[List[Path]], manager: KresManager):
         # config store & server dynamic reconfiguration
         self.config_store = store
 
@@ -98,7 +98,7 @@ class Server:
         self.listen: Optional[ManagementSchema] = None
         self.site: Union[NoneType, TCPSite, UnixSite] = None
         self.listen_lock = asyncio.Lock()
-        self._config_path: Optional[Path] = config_path
+        self._config_path: Optional[List[Path]] = config_path
         self._exit_code: int = 0
         self._shutdown_event = asyncio.Event()
         self._manager = manager
@@ -121,13 +121,17 @@ class Server:
             logger.warning("The manager was started with inlined configuration - can't reload")
         else:
             try:
-                data = await readfile(self._config_path)
-                config = KresConfig(try_to_parse(data))
+                data = {}
+                for file in self._config_path:
+                    file_data = try_to_parse(await readfile(file))
+                    data.update(file_data)
+
+                config = KresConfig(data)
                 await self.config_store.update(config)
                 logger.info("Configuration file successfully reloaded")
             except FileNotFoundError:
                 logger.error(
-                    f"Configuration file was not found at '{self._config_path}'."
+                    f"Configuration file was not found at '{file}'."
                     " Something must have happened to it while we were running."
                 )
                 logger.error("Configuration has NOT been changed.")
@@ -537,7 +541,7 @@ async def _sigterm_while_shutting_down():
     sys.exit(128 + signal.SIGTERM)
 
 
-async def start_server(config: Path = CONFIG_FILE) -> int:  # noqa: PLR0915
+async def start_server(config: List[str]) -> int:  # noqa: PLR0915
     # This function is quite long, but it describes how manager runs. So let's silence pylint
     # pylint: disable=too-many-statements
 
@@ -559,23 +563,26 @@ async def start_server(config: Path = CONFIG_FILE) -> int:  # noqa: PLR0915
     # before starting server, initialize the subprocess controller, config store, etc. Any errors during inicialization
     # are fatal
     try:
-        # Make sure that the config path does not change meaning when we change working directory
-        config = config.absolute()
+        # Make sure that the config paths does not change meaning when we change working directory
+        config_absolute = [Path(path).absolute() for path in config]
 
-        # Preprocess config - load from file or in general take it to the last step before validation.
-        config_raw = await _load_raw_config(config)
+        config_data = {}
+        for file in config_absolute:
+            # Preprocess config - load from file or in general take it to the last step before validation.
+            config_raw = await _load_raw_config(file)
+            config_data.update(config_raw)
 
         # before processing any configuration, set validation context
         #  - resolve_root: root against which all relative paths will be resolved
         #  - strict_validation: check for path existence during configuration validation
         #  - permissions_default: validate dirs/files rwx permissions against default user:group in constants
-        set_global_validation_context(Context(config.parent, True, False))
+        set_global_validation_context(Context(config_absolute[0].parent, True, False))
 
         # We want to change cwd as soon as possible. Some parts of the codebase are using os.getcwd() to get the
         # working directory.
         #
         # If we fail to read rundir from unparsed config, the first config validation error comes from here
-        _set_working_directory(config_raw)
+        _set_working_directory(config_data)
 
         # We don't want more than one manager in a single working directory. So we lock it with a PID file.
         # Warning - this does not prevent multiple managers with the same naming of kresd service.
@@ -584,7 +591,7 @@ async def start_server(config: Path = CONFIG_FILE) -> int:  # noqa: PLR0915
         # set_global_validation_context(Context(config.parent))
 
         # After the working directory is set, we can initialize proper config store with a newly parsed configuration.
-        config_store = await _init_config_store(config_raw)
+        config_store = await _init_config_store(config_data)
 
         # Some "constants" need to be loaded from the initial config, some need to be stored from the initial run conditions
         await init_user_constants(config_store, working_directory_on_startup)
@@ -608,7 +615,7 @@ async def start_server(config: Path = CONFIG_FILE) -> int:  # noqa: PLR0915
         manager = await _init_manager(config_store)
 
         # prepare instance of the server (no side effects)
-        server = Server(config_store, config, manager)
+        server = Server(config_store, config_absolute, manager)
 
         # add Server's shutdown trigger to the manager
         manager.add_shutdown_trigger(server.trigger_shutdown)