userver: /home/user/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
550 # Add WebSocket prefixes (ws:// and wss://) alongside HTTP prefixes
551 allowed_urls.append(mockserver_info.ws_url('/'))
552 if mockserver_ssl_info:
553 allowed_urls.append(mockserver_ssl_info.ws_url('/'))
554
555 http_client_core['testsuite-allowed-url-prefixes'] = allowed_urls
556
557 return patch_config
558
559
560@pytest.fixture(scope='session')
562 """
563 Default log level to use in userver if no command line option was provided.
564
565 Returns 'debug'.
566
567 @ingroup userver_testsuite_fixtures
568 """
569 return 'debug'
570
571
572@pytest.fixture(scope='session')
573def userver_log_level(pytestconfig, userver_default_log_level) -> str:
574 """
575 Returns --service-log-level value if provided, otherwise returns
576 userver_default_log_level() value from fixture.
577
578 @ingroup userver_testsuite_fixtures
579 """
580 if pytestconfig.option.service_log_level:
581 return pytestconfig.option.service_log_level
582 return userver_default_log_level
583
584
585@pytest.fixture(scope='session')
586def userver_config_logging(userver_log_level, _service_logfile_path) -> ServiceConfigPatch:
587 """
588 Returns a function that adjusts the static configuration file for testsuite.
589 Sets the `logging.loggers.default` to log to `@stderr` with level set
590 from `--service-log-level` pytest configuration option.
591
592 @ingroup userver_testsuite_fixtures
593 """
594
595 if _service_logfile_path:
596 default_file_path = str(_service_logfile_path)
597 else:
598 default_file_path = '@stderr'
599
600 def _patch_config(config_yaml, config_vars):
601 components = config_yaml['components_manager']['components']
602 if 'logging' in components:
603 loggers = components['logging'].setdefault('loggers', {})
604 for logger in loggers.values():
605 logger['file_path'] = '@null'
606 loggers['default'] = {
607 'file_path': default_file_path,
608 'level': userver_log_level,
609 'overflow_behavior': 'discard',
610 }
611 config_vars['logger_level'] = userver_log_level
612
613 return _patch_config
614
615
616@pytest.fixture(scope='session')
617def userver_config_logging_otlp() -> ServiceConfigPatch:
618 """
619 Returns a function that adjusts the static configuration file for testsuite.
620 Sets the `otlp-logger.load-enabled` to `false` to disable OTLP logging and
621 leave the default file logger.
622
623 @ingroup userver_testsuite_fixtures
624 """
625
626 def _patch_config(config_yaml, config_vars):
627 components = config_yaml['components_manager']['components']
628 if 'otlp-logger' in components:
629 components['otlp-logger']['load-enabled'] = False
630
631 return _patch_config
632
633
634@pytest.fixture(scope='session')
635def userver_config_testsuite(pytestconfig, mockserver_info) -> ServiceConfigPatch:
636 """
637 Returns a function that adjusts the static configuration file for testsuite.
638
639 Sets up `testsuite-support` component, which:
640
641 - increases timeouts for userver drivers
642 - disables periodic cache updates
643 - enables testsuite tasks
644
645 Sets the `testsuite-enabled` in config_vars.yaml to `True`; sets the
646 `tests-control.testpoint-url` to mockserver URL.
647
648 @ingroup userver_testsuite_fixtures
649 """
650
651 def _set_postgresql_options(testsuite_support: dict) -> None:
652 testsuite_support['testsuite-pg-execute-timeout'] = '35s'
653 testsuite_support['testsuite-pg-statement-timeout'] = '30s'
654 testsuite_support['testsuite-pg-readonly-master-expected'] = True
655
656 def _set_redis_timeout(testsuite_support: dict) -> None:
657 testsuite_support['testsuite-redis-timeout-connect'] = '40s'
658 testsuite_support['testsuite-redis-timeout-single'] = '30s'
659 testsuite_support['testsuite-redis-timeout-all'] = '30s'
660
661 def _disable_cache_periodic_update(testsuite_support: dict) -> None:
662 testsuite_support['testsuite-periodic-update-enabled'] = False
663
664 def patch_config(config, config_vars) -> None:
665 # Don't delay tests teardown unnecessarily.
666 config['components_manager'].pop('graceful_shutdown_interval', None)
667 components: dict = config['components_manager']['components']
668 if 'testsuite-support' not in components:
669 return
670 if components['testsuite-support'] is None:
671 components['testsuite-support'] = {}
672 testsuite_support = components['testsuite-support']
673 testsuite_support['testsuite-increased-timeout'] = '30s'
674 testsuite_support['testsuite-grpc-is-tls-enabled'] = False
675 _set_postgresql_options(testsuite_support)
676 _set_redis_timeout(testsuite_support)
677 service_runner = pytestconfig.option.service_runner_mode
678 if not service_runner:
679 _disable_cache_periodic_update(testsuite_support)
680 testsuite_support['testsuite-tasks-enabled'] = not service_runner
681 testsuite_support['testsuite-periodic-dumps-enabled'] = '$userver-dumps-periodic'
682 components['testsuite-support'] = testsuite_support
683
684 config_vars['testsuite-enabled'] = True
685 if 'tests-control' in components:
686 components['tests-control']['testpoint-url'] = mockserver_info.url(
687 'testpoint',
688 )
689
690 return patch_config
691
692
693@pytest.fixture(scope='session')
694def userver_config_secdist(service_secdist_path) -> ServiceConfigPatch:
695 """
696 Returns a function that adjusts the static configuration file for testsuite.
697 Sets the `default-secdist-provider.config` to the value of
698 @ref pytest_userver.plugins.config.service_secdist_path "service_secdist_path"
699 fixture.
700
701 @ingroup userver_testsuite_fixtures
702 """
703
704 def _patch_config(config_yaml, config_vars):
705 if not service_secdist_path:
706 return
707
708 components = config_yaml['components_manager']['components']
709 if 'default-secdist-provider' not in components:
710 return
711
712 if not service_secdist_path.is_file():
713 raise ValueError(
714 f'"{service_secdist_path}" is not a file. Provide a '
715 f'"--service-secdist" pytest option or override the '
716 f'"service_secdist_path" fixture.',
717 )
718 components['default-secdist-provider']['config'] = str(
719 service_secdist_path,
720 )
721
722 return _patch_config
723
724
725@pytest.fixture(scope='session')
726def userver_config_testsuite_middleware(userver_testsuite_middleware_enabled: bool) -> ServiceConfigPatch:
727 def patch_config(config_yaml, config_vars):
728 if not userver_testsuite_middleware_enabled:
729 return
730
731 components = config_yaml['components_manager']['components']
732 if 'server' not in components:
733 return
734
735 pipeline_builder = components.setdefault(
736 'default-server-middleware-pipeline-builder',
737 {},
738 )
739 middlewares = pipeline_builder.setdefault('append', [])
740 middlewares.append('testsuite-exceptions-handling-middleware')
741
742 return patch_config
743
744
745@pytest.fixture(scope='session')
747 """Whether testsuite middleware is enabled."""
748 return True
749
750
751@pytest.fixture(scope='session')
752def userver_config_deadlock_detector(userver_deadlock_detector_mode: str) -> ServiceConfigPatch:
753 """
754 Returns a function that adjusts the static configuration file for testsuite.
755 Sets the `deadlock_detector` parameter of the `coro_pool` component to the value of
756 @ref pytest_userver.plugins.config.userver_deadlock_detector_mode "userver_deadlock_detector_mode"
757 fixture.
758
759 @ingroup userver_testsuite_fixtures
760 """
761
762 def patch_config(config_yaml, config_vars):
763 coro_pool = config_yaml['components_manager'].setdefault('coro_pool', {})
764 coro_pool['deadlock_detector'] = userver_deadlock_detector_mode
765
766 return patch_config
767
768
769@pytest.fixture(scope='session')
771 """
772 Returns Deadlock detector mode for testsuite.
773 Override this fixture to modify the deadlock detector settings.
774 By default, it operates in `detect-only` mode. For a full list of available options, refer to the
775 `coro_pool.deadlock_detector` parameter in the `components::ManagerControllerComponent`.
776
777 @ingroup userver_testsuite_fixtures
778 """
779 return 'detect-only'