userver: /data/code/userver/testsuite/pytest_plugins/pytest_userver/plugins/config.py Source File
Loading...
Searching...
No Matches
config.py
1"""
2Work with the configuration files of the service in testsuite.
3"""
4
5# pylint: disable=redefined-outer-name
6import copy
7import dataclasses
8import itertools
9import logging
10import os
11import pathlib
12import string
13import subprocess
14import types
15from typing import Any
16from collections.abc import Callable
17from collections.abc import Mapping
18from typing import TypeAlias
19
20import pytest
21import yaml
22
23# flake8: noqa E266
24
38USERVER_CONFIG_HOOKS = [
39 'userver_config_substitutions',
40 'userver_config_http_server',
41 'userver_config_http_client',
42 'userver_config_logging',
43 'userver_config_logging_otlp',
44 'userver_config_testsuite',
45 'userver_config_secdist',
46 'userver_config_testsuite_middleware',
47 'userver_config_deadlock_detector',
48]
49
50ServiceConfigPatch: TypeAlias = Callable[[dict, dict], None]
51
52
53# @cond
54
55
56logger = logging.getLogger(__name__)
57
58
59class _UserverConfigPlugin:
60 def __init__(self):
61 self._config_hooks = []
62
63 @property
64 def userver_config_hooks(self):
65 return self._config_hooks
66
67 def pytest_plugin_registered(self, plugin, manager):
68 if not isinstance(plugin, types.ModuleType):
69 return
70 uhooks = getattr(plugin, 'USERVER_CONFIG_HOOKS', None)
71 if uhooks is not None:
72 self._config_hooks.extend(uhooks)
73
74
75@dataclasses.dataclass(frozen=True)
76class _UserverConfig:
77 config_yaml: dict
78 config_vars: dict
79
80
81def pytest_configure(config):
82 config.pluginmanager.register(_UserverConfigPlugin(), 'userver_config')
83
84
85def pytest_addoption(parser) -> None:
86 group = parser.getgroup('userver-config')
87 group.addoption(
88 '--service-log-level',
89 type=str.lower,
90 choices=['trace', 'debug', 'info', 'warning', 'error', 'critical'],
91 )
92 group.addoption(
93 '--service-config',
94 type=pathlib.Path,
95 help='Path to service.yaml file.',
96 )
97 group.addoption(
98 '--service-config-vars',
99 type=pathlib.Path,
100 help='Path to config_vars.yaml file.',
101 )
102 group.addoption(
103 '--service-secdist',
104 type=pathlib.Path,
105 help='Path to secure_data.json file.',
106 )
107 group.addoption(
108 '--config-fallback',
109 type=pathlib.Path,
110 help='Path to dynamic config fallback file.',
111 )
112 group.addoption(
113 '--dump-config',
114 action='store_true',
115 help='Dump config from binary before running tests',
116 )
117
118
119# @endcond
120
121
122@pytest.fixture(scope='session')
123def service_config_path(pytestconfig, service_binary) -> pathlib.Path:
124 """
125 Returns the path to service.yaml file set by command line
126 `--service-config` option.
127
128 Override this fixture to change the way path to the static config is provided.
129
130 @ingroup userver_testsuite_fixtures
131 """
132 if pytestconfig.option.dump_config:
133 subprocess.run([
134 service_binary,
135 '--dump-config',
136 pytestconfig.option.service_config,
137 ])
138 return pytestconfig.option.service_config
139
140
141@pytest.fixture(scope='session')
142def db_dump_schema_path(service_binary, service_tmpdir) -> pathlib.Path:
143 """
144 Runs the service binary with `--dump-db-schema` argument, dumps the 0_db_schema.sql file with database schema and
145 returns path to it.
146
147 Override this fixture to change the way to dump the database schema.
148
149 @ingroup userver_testsuite_fixtures
150
151 """
152 path = service_tmpdir.joinpath('schemas')
153 os.mkdir(path)
154 subprocess.run([
155 service_binary,
156 '--dump-db-schema',
157 path / '0_db_schema.sql',
158 ])
159 return path
160
161
162@pytest.fixture(scope='session')
163def service_config_vars_path(pytestconfig) -> pathlib.Path | None:
164 """
165 Returns the path to config_vars.yaml file set by command line
166 `--service-config-vars` option.
167
168 Override this fixture to change the way path to config_vars.yaml is
169 provided.
170
171 @ingroup userver_testsuite_fixtures
172 """
173 return pytestconfig.option.service_config_vars
174
175
176@pytest.fixture(scope='session')
177def service_secdist_path(pytestconfig) -> pathlib.Path | None:
178 """
179 Returns the path to secure_data.json file set by command line
180 `--service-secdist` option.
181
182 Override this fixture to change the way path to secure_data.json is
183 provided.
184
185 @ingroup userver_testsuite_fixtures
186 """
187 return pytestconfig.option.service_secdist
188
189
190@pytest.fixture(scope='session')
191def config_fallback_path(pytestconfig) -> pathlib.Path:
192 """
193 Returns the path to dynamic config fallback file set by command line
194 `--config-fallback` option.
195
196 Override this fixture to change the way path to dynamic config fallback is
197 provided.
198
199 @ingroup userver_testsuite_fixtures
200 """
201 return pytestconfig.option.config_fallback
202
203
204@pytest.fixture(scope='session')
205def service_tmpdir(service_binary, tmp_path_factory):
206 """
207 Returns the path for temporary files. The path is the same for the whole
208 session and files are not removed (at least by this fixture) between
209 tests.
210
211 @ingroup userver_testsuite_fixtures
212 """
213 return tmp_path_factory.mktemp(
214 pathlib.Path(service_binary).name,
215 numbered=False,
216 )
217
218
219@pytest.fixture(scope='session')
221 service_tmpdir,
222 service_config,
223 service_config_yaml,
224 service_config_vars,
225) -> pathlib.Path:
226 """
227 Dumps the contents of the service_config_yaml and service_config_vars into a static config for
228 testsuite and returns the path to the config file.
229
230 @ingroup userver_testsuite_fixtures
231 """
232 dst_path = service_tmpdir / 'config.yaml'
233
234 service_config_yaml = dict(service_config_yaml)
235 if not service_config_vars:
236 service_config_yaml.pop('config_vars', None)
237 else:
238 config_vars_path = service_tmpdir / 'config_vars.yaml'
239 config_vars_text = yaml.dump(service_config_vars)
240 logger.debug(
241 'userver fixture "service_config_path_temp" writes the patched static config vars to "%s":\n%s',
242 config_vars_path,
243 config_vars_text,
244 )
245 config_vars_path.write_text(config_vars_text)
246 service_config_yaml['config_vars'] = str(config_vars_path)
247
248 logger.debug(
249 'userver fixture "service_config_path_temp" writes the patched static config to "%s" equivalent to:\n%s',
250 dst_path,
251 yaml.dump(service_config),
252 )
253 dst_path.write_text(yaml.dump(service_config_yaml))
254
255 return dst_path
256
257
258@pytest.fixture(scope='session')
259def service_config_yaml(_service_config_hooked) -> dict:
260 """
261 Returns the static config values after the USERVER_CONFIG_HOOKS were
262 applied (if any). Prefer using
263 pytest_userver.plugins.config.service_config
264
265 @ingroup userver_testsuite_fixtures
266 """
267 return _service_config_hooked.config_yaml
268
269
270@pytest.fixture(scope='session')
271def service_config_vars(_service_config_hooked) -> dict:
272 """
273 Returns the static config variables (config_vars.yaml) values after the
274 USERVER_CONFIG_HOOKS were applied (if any). Prefer using
275 pytest_userver.plugins.config.service_config
276
277 @ingroup userver_testsuite_fixtures
278 """
279 return _service_config_hooked.config_vars
280
281
282def _substitute_values(config, service_config_vars: dict, service_env) -> None:
283 if isinstance(config, dict):
284 for key, value in config.items():
285 if not isinstance(value, str):
286 _substitute_values(value, service_config_vars, service_env)
287 continue
288
289 if not value.startswith('$'):
290 continue
291
292 new_value = service_config_vars.get(value[1:])
293 if new_value is not None:
294 config[key] = new_value
295 continue
296
297 env = config.get(f'{key}#env')
298 if env:
299 if service_env:
300 new_value = service_env.get(env)
301 if not new_value:
302 new_value = os.environ.get(env)
303 if new_value:
304 config[key] = new_value
305 continue
306
307 fallback = config.get(f'{key}#fallback')
308 if fallback:
309 config[key] = fallback
310 continue
311
312 config[key] = None
313
314 if isinstance(config, list):
315 for i, value in enumerate(config):
316 if not isinstance(value, str):
317 _substitute_values(value, service_config_vars, service_env)
318 continue
319
320 if not value.startswith('$'):
321 continue
322
323 new_value = service_config_vars.get(value[1:])
324 if new_value is not None:
325 config[i] = new_value
326
327
328@pytest.fixture(scope='session')
329def substitute_config_vars(service_env) -> Callable[[Any, dict], Any]:
330 """
331 A function that takes `config_yaml`, `config_vars` and applies all
332 substitutions just like the service would.
333
334 Useful when patching the service config. It's a good idea to pass
335 a component's config instead of the whole `config_yaml` to avoid
336 unnecessary work.
337
338 @warning The returned YAML is a clone, mutating it will not modify
339 the actual config while in a config hook!
340
341 @ingroup userver_testsuite_fixtures
342 """
343
344 def substitute(config_yaml, config_vars, /):
345 if config_yaml is not None and not isinstance(config_yaml, dict) and not isinstance(config_yaml, list):
346 raise TypeError(
347 f'{substitute_config_vars.__name__} can only be meaningfully '
348 'called with dict and list nodes of config_yaml, while given: '
349 f'{config_yaml!r}. Pass a containing object instead.',
350 )
351
352 config = copy.deepcopy(config_yaml)
353 _substitute_values(config, config_vars, service_env)
354 return config
355
356 return substitute
357
358
359@pytest.fixture(scope='session')
361 service_config_yaml,
362 service_config_vars,
363 substitute_config_vars,
364) -> dict:
365 """
366 Returns the static config values after the USERVER_CONFIG_HOOKS were
367 applied (if any) and with all the '$', environment and fallback variables
368 substituted.
369
370 @ingroup userver_testsuite_fixtures
371 """
372 config = substitute_config_vars(service_config_yaml, service_config_vars)
373 config.pop('config_vars', None)
374 return config
375
376
377@pytest.fixture(scope='session')
378def _original_service_config(
379 service_config_path,
380 service_config_vars_path,
381) -> _UserverConfig:
382 config_vars: dict
383 config_yaml: dict
384
385 assert service_config_path is not None, 'Please specify proper path to the static config file, not None'
386
387 with open(service_config_path, mode='rt') as fp:
388 config_yaml = yaml.safe_load(fp)
389
390 if service_config_vars_path:
391 with open(service_config_vars_path, mode='rt') as fp:
392 config_vars = yaml.safe_load(fp)
393 else:
394 config_vars = {}
395
396 return _UserverConfig(config_yaml=config_yaml, config_vars=config_vars)
397
398
399@pytest.fixture(scope='session')
400def _service_config_hooked(
401 daemon_scoped_mark,
402 pytestconfig,
403 request,
404 _original_service_config,
405) -> _UserverConfig:
406 config_yaml = copy.deepcopy(_original_service_config.config_yaml)
407 config_vars = copy.deepcopy(_original_service_config.config_vars)
408
409 plugin = pytestconfig.pluginmanager.get_plugin('userver_config')
410 local_hooks = (daemon_scoped_mark or {}).get('config_hooks', ())
411
412 for hook in itertools.chain(plugin.userver_config_hooks, local_hooks):
413 if not callable(hook):
414 hook_func = request.getfixturevalue(hook)
415 else:
416 hook_func = hook
417 hook_func(config_yaml, config_vars)
418
419 return _UserverConfig(config_yaml=config_yaml, config_vars=config_vars)
420
421
422@pytest.fixture(scope='session')
423def _service_config_substitution_vars(request, mockserver_info) -> Mapping[str, str]:
424 substitution_vars = {
425 'mockserver': mockserver_info.base_url.removesuffix('/'),
426 }
427 if request.config.pluginmanager.hasplugin('pytest_userver.plugins.grpc.mockserver'):
428 grpc_mockserver_endpoint = request.getfixturevalue('grpc_mockserver_endpoint')
429 substitution_vars['grpc_mockserver'] = grpc_mockserver_endpoint
430 return substitution_vars
431
432
433@pytest.fixture(scope='session')
434def userver_config_substitutions(_service_config_substitution_vars) -> ServiceConfigPatch:
435 """
436 Replaces substitution vars in all strings within `config_vars` using
437 [string.Template.substitute](https://docs.python.org/3/library/string.html#string.Template.substitute).
438
439 Substitution vars can be used as a shorthand for writing a full-fledged @ref SERVICE_CONFIG_HOOKS "config hook"
440 in many common cases.
441
442 Unlike normal `config_vars`, substitution vars can also apply to a part of a string.
443 For example, for `config_vars` entry
444
445 @code{.yaml}
446 frobnicator-url: $mockserver/frobnicator
447 @endcode
448
449 a possible patching result is as follows:
450
451 @code{.yaml}
452 frobnicator-url: http://127.0.0.1:1234/frobnicator
453 @endcode
454
455 Currently, the following substitution vars are supported:
456
457 * `mockserver` - mockserver url
458 * `grpc_mockserver` - grpc mockserver endpoint
459
460 @ingroup userver_testsuite_fixtures
461 """
462
463 def _substitute(key, value, parent: list | dict) -> None:
464 if isinstance(value, str):
465 parent[key] = string.Template(value).safe_substitute(_service_config_substitution_vars)
466 elif isinstance(value, dict):
467 for child_key, child_value in value.items():
468 _substitute(child_key, child_value, value)
469 elif isinstance(value, list):
470 for child_key, child_value in enumerate(value):
471 _substitute(child_key, child_value, value)
472
473 def patch_config(config_yaml, config_vars):
474 for key, value in config_vars.items():
475 _substitute(key, value, config_vars)
476
477 return patch_config
478
479
480@pytest.fixture(scope='session')
481def userver_config_http_server(service_port, monitor_port) -> ServiceConfigPatch:
482 """
483 Returns a function that adjusts the static configuration file for testsuite.
484 Sets the `server.listener.port` to listen on
485 @ref pytest_userver.plugins.base.service_port "service_port" fixture value;
486 sets the `server.listener-monitor.port` to listen on
487 @ref pytest_userver.plugins.base.monitor_port "monitor_port"
488 fixture value.
489
490 @ingroup userver_testsuite_fixtures
491 """
492
493 def _patch_config(config_yaml, config_vars):
494 components = config_yaml['components_manager']['components']
495 if 'server' in components:
496 server = components['server']
497 if 'listener' in server:
498 server['listener']['port'] = service_port
499
500 if 'listener-monitor' in server:
501 server['listener-monitor']['port'] = monitor_port
502
503 return _patch_config
504
505
506@pytest.fixture(scope='session')
507def allowed_url_prefixes_extra() -> list[str]:
508 """
509 By default, userver HTTP client is only allowed to talk to mockserver
510 when running in testsuite. This makes tests repeatable and encapsulated.
511
512 Override this fixture to whitelist some additional URLs.
513 It is still strongly advised to only talk to localhost in tests.
514
515 @ingroup userver_testsuite_fixtures
516 """
517 return []
518
519
520@pytest.fixture(scope='session')
522 mockserver_info,
523 mockserver_ssl_info,
524 allowed_url_prefixes_extra,
525) -> ServiceConfigPatch:
526 """
527 Returns a function that adjusts the static configuration file for testsuite.
528 Sets increased timeout and limits allowed URLs for `http-client-core` component.
529
530 @ingroup userver_testsuite_fixtures
531 """
532
533 def patch_config(config, config_vars) -> None:
534 components: dict = config['components_manager']['components']
535 if not {'http-client-core', 'testsuite-support'}.issubset(
536 components.keys(),
537 ):
538 return
539 if components['http-client-core'] is None:
540 components['http-client-core'] = {}
541 http_client_core = components['http-client-core']
542 http_client_core['testsuite-enabled'] = True
543 http_client_core['testsuite-timeout'] = '10s'
544
545 allowed_urls = [mockserver_info.base_url]
546 if mockserver_ssl_info:
547 allowed_urls.append(mockserver_ssl_info.base_url)
548 allowed_urls += allowed_url_prefixes_extra
549 http_client_core['testsuite-allowed-url-prefixes'] = allowed_urls
550
551 return patch_config
552
553
554@pytest.fixture(scope='session')
556 """
557 Default log level to use in userver if no command line option was provided.
558
559 Returns 'debug'.
560
561 @ingroup userver_testsuite_fixtures
562 """
563 return 'debug'
564
565
566@pytest.fixture(scope='session')
567def userver_log_level(pytestconfig, userver_default_log_level) -> str:
568 """
569 Returns --service-log-level value if provided, otherwise returns
570 userver_default_log_level() value from fixture.
571
572 @ingroup userver_testsuite_fixtures
573 """
574 if pytestconfig.option.service_log_level:
575 return pytestconfig.option.service_log_level
576 return userver_default_log_level
577
578
579@pytest.fixture(scope='session')
580def userver_config_logging(userver_log_level, _service_logfile_path) -> ServiceConfigPatch:
581 """
582 Returns a function that adjusts the static configuration file for testsuite.
583 Sets the `logging.loggers.default` to log to `@stderr` with level set
584 from `--service-log-level` pytest configuration option.
585
586 @ingroup userver_testsuite_fixtures
587 """
588
589 if _service_logfile_path:
590 default_file_path = str(_service_logfile_path)
591 else:
592 default_file_path = '@stderr'
593
594 def _patch_config(config_yaml, config_vars):
595 components = config_yaml['components_manager']['components']
596 if 'logging' in components:
597 loggers = components['logging'].setdefault('loggers', {})
598 for logger in loggers.values():
599 logger['file_path'] = '@null'
600 loggers['default'] = {
601 'file_path': default_file_path,
602 'level': userver_log_level,
603 'overflow_behavior': 'discard',
604 }
605 config_vars['logger_level'] = userver_log_level
606
607 return _patch_config
608
609
610@pytest.fixture(scope='session')
611def userver_config_logging_otlp() -> ServiceConfigPatch:
612 """
613 Returns a function that adjusts the static configuration file for testsuite.
614 Sets the `otlp-logger.load-enabled` to `false` to disable OTLP logging and
615 leave the default file logger.
616
617 @ingroup userver_testsuite_fixtures
618 """
619
620 def _patch_config(config_yaml, config_vars):
621 components = config_yaml['components_manager']['components']
622 if 'otlp-logger' in components:
623 components['otlp-logger']['load-enabled'] = False
624
625 return _patch_config
626
627
628@pytest.fixture(scope='session')
629def userver_config_testsuite(pytestconfig, mockserver_info) -> ServiceConfigPatch:
630 """
631 Returns a function that adjusts the static configuration file for testsuite.
632
633 Sets up `testsuite-support` component, which:
634
635 - increases timeouts for userver drivers
636 - disables periodic cache updates
637 - enables testsuite tasks
638
639 Sets the `testsuite-enabled` in config_vars.yaml to `True`; sets the
640 `tests-control.testpoint-url` to mockserver URL.
641
642 @ingroup userver_testsuite_fixtures
643 """
644
645 def _set_postgresql_options(testsuite_support: dict) -> None:
646 testsuite_support['testsuite-pg-execute-timeout'] = '35s'
647 testsuite_support['testsuite-pg-statement-timeout'] = '30s'
648 testsuite_support['testsuite-pg-readonly-master-expected'] = True
649
650 def _set_redis_timeout(testsuite_support: dict) -> None:
651 testsuite_support['testsuite-redis-timeout-connect'] = '40s'
652 testsuite_support['testsuite-redis-timeout-single'] = '30s'
653 testsuite_support['testsuite-redis-timeout-all'] = '30s'
654
655 def _disable_cache_periodic_update(testsuite_support: dict) -> None:
656 testsuite_support['testsuite-periodic-update-enabled'] = False
657
658 def patch_config(config, config_vars) -> None:
659 # Don't delay tests teardown unnecessarily.
660 config['components_manager'].pop('graceful_shutdown_interval', None)
661 components: dict = config['components_manager']['components']
662 if 'testsuite-support' not in components:
663 return
664 if components['testsuite-support'] is None:
665 components['testsuite-support'] = {}
666 testsuite_support = components['testsuite-support']
667 testsuite_support['testsuite-increased-timeout'] = '30s'
668 testsuite_support['testsuite-grpc-is-tls-enabled'] = False
669 _set_postgresql_options(testsuite_support)
670 _set_redis_timeout(testsuite_support)
671 service_runner = pytestconfig.option.service_runner_mode
672 if not service_runner:
673 _disable_cache_periodic_update(testsuite_support)
674 testsuite_support['testsuite-tasks-enabled'] = not service_runner
675 testsuite_support['testsuite-periodic-dumps-enabled'] = '$userver-dumps-periodic'
676 components['testsuite-support'] = testsuite_support
677
678 config_vars['testsuite-enabled'] = True
679 if 'tests-control' in components:
680 components['tests-control']['testpoint-url'] = mockserver_info.url(
681 'testpoint',
682 )
683
684 return patch_config
685
686
687@pytest.fixture(scope='session')
688def userver_config_secdist(service_secdist_path) -> ServiceConfigPatch:
689 """
690 Returns a function that adjusts the static configuration file for testsuite.
691 Sets the `default-secdist-provider.config` to the value of
692 @ref pytest_userver.plugins.config.service_secdist_path "service_secdist_path"
693 fixture.
694
695 @ingroup userver_testsuite_fixtures
696 """
697
698 def _patch_config(config_yaml, config_vars):
699 if not service_secdist_path:
700 return
701
702 components = config_yaml['components_manager']['components']
703 if 'default-secdist-provider' not in components:
704 return
705
706 if not service_secdist_path.is_file():
707 raise ValueError(
708 f'"{service_secdist_path}" is not a file. Provide a '
709 f'"--service-secdist" pytest option or override the '
710 f'"service_secdist_path" fixture.',
711 )
712 components['default-secdist-provider']['config'] = str(
713 service_secdist_path,
714 )
715
716 return _patch_config
717
718
719@pytest.fixture(scope='session')
720def userver_config_testsuite_middleware(userver_testsuite_middleware_enabled: bool) -> ServiceConfigPatch:
721 def patch_config(config_yaml, config_vars):
722 if not userver_testsuite_middleware_enabled:
723 return
724
725 components = config_yaml['components_manager']['components']
726 if 'server' not in components:
727 return
728
729 pipeline_builder = components.setdefault(
730 'default-server-middleware-pipeline-builder',
731 {},
732 )
733 middlewares = pipeline_builder.setdefault('append', [])
734 middlewares.append('testsuite-exceptions-handling-middleware')
735
736 return patch_config
737
738
739@pytest.fixture(scope='session')
741 """Whether testsuite middleware is enabled."""
742 return True
743
744
745@pytest.fixture(scope='session')
746def userver_config_deadlock_detector(userver_deadlock_detector_mode: str) -> ServiceConfigPatch:
747 """
748 Returns a function that adjusts the static configuration file for testsuite.
749 Sets the `deadlock_detector` parameter of the `coro_pool` component to the value of
750 @ref pytest_userver.plugins.config.userver_deadlock_detector_mode "userver_deadlock_detector_mode"
751 fixture.
752
753 @ingroup userver_testsuite_fixtures
754 """
755
756 def patch_config(config_yaml, config_vars):
757 coro_pool = config_yaml['components_manager'].setdefault('coro_pool', {})
758 coro_pool['deadlock_detector'] = userver_deadlock_detector_mode
759
760 return patch_config
761
762
763@pytest.fixture(scope='session')
765 """
766 Returns Deadlock detector mode for testsuite.
767 Override this fixture to modify the deadlock detector settings.
768 By default, it operates in `detect-only` mode. For a full list of available options, refer to the
769 `coro_pool.deadlock_detector` parameter in the `components::ManagerControllerComponent`.
770
771 @ingroup userver_testsuite_fixtures
772 """
773 return 'detect-only'