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