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