From 89866d148a39f679ea5697bc16bad21143740c08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicki=20K=C5=99=C3=AD=C5=BEek?= Date: Tue, 1 Oct 2024 14:45:36 +0200 Subject: [PATCH] Support jinja2 templates in pytest runner Configuration files in system tests which require some variables (e.g. port numbers) filled in during test setup, can now use jinja2 templates when `jinja2` python package is available. Any `*.j2` file found within the system test directory will be automatically rendered with the environment variables into a file without the `.j2` extension by the pytest runner. E.g. `ns1/named.conf.j2` will become `ns1/named.conf` during test setup. To avoid automatic rendering, use `.j2.manual` extension and render the files manually at test time. New `templates` pytest fixture has been added. Its `render()` function can be used to render a template with custom test variables. This can be useful to fill in different config options during the test. With advanced jinja2 template syntax, it can also be used to include/omit entire sections of the config file rather than using `named1.conf.in`, `named2.conf.in` etc. (cherry picked from commit 60e118c4fb7085030f47ef69e136fc6ca710b009) --- bin/tests/system/README | 10 +++ bin/tests/system/conftest.py | 7 ++ bin/tests/system/isctest/__init__.py | 1 + bin/tests/system/isctest/template.py | 100 +++++++++++++++++++++++++++ 4 files changed, 118 insertions(+) create mode 100644 bin/tests/system/isctest/template.py diff --git a/bin/tests/system/README b/bin/tests/system/README index c9a57189f6..14f99088dc 100644 --- a/bin/tests/system/README +++ b/bin/tests/system/README @@ -319,6 +319,16 @@ prereq.sh Run at the beginning to determine whether the test can be run at if not present, the test is assumed to have all its prerequisites met. +*.j2 These jinja2 templates can be used for configuration files or any + other files which require certain variables filled in, e.g. ports from the + environment variables. During test setup, the pytest runner will automatically + fill those in and strip the filename extension .j2, e.g. `ns1/named.conf.j2` + becomes `ns1/named.conf`. When using advanced templating to conditionally + include/omit entire sections or when filling in custom variables used for the + test, ensure the templates always include the defaults. If you don't need the + file to be auto-templated during test setup, use `.j2.manual` instead and then + no defaults are needed. + setup.sh Run after prereq.sh, this sets up the preconditions for the tests. Although optional, virtually all tests will require such a file to set up the ports they should use for the test. diff --git a/bin/tests/system/conftest.py b/bin/tests/system/conftest.py index d0f4168dc7..42b1a11f33 100644 --- a/bin/tests/system/conftest.py +++ b/bin/tests/system/conftest.py @@ -453,6 +453,11 @@ def system_test_dir(request, env, system_test_name): unlink(symlink_dst) +@pytest.fixture(scope="module") +def templates(system_test_dir: Path): + return isctest.template.TemplateEngine(system_test_dir) + + def _run_script( env, system_test_dir: Path, @@ -531,6 +536,7 @@ def system_test( request, env: Dict[str, str], system_test_dir, + templates, shell, perl, ): @@ -572,6 +578,7 @@ def system_test( pytest.skip("Prerequisites missing.") def setup_test(): + templates.render_auto() try: shell(f"{system_test_dir}/setup.sh") except FileNotFoundError: diff --git a/bin/tests/system/isctest/__init__.py b/bin/tests/system/isctest/__init__.py index 3c04d73a49..9c066ec8a1 100644 --- a/bin/tests/system/isctest/__init__.py +++ b/bin/tests/system/isctest/__init__.py @@ -15,6 +15,7 @@ from . import query from . import name from . import rndc from . import run +from . import template from . import log from . import hypothesis diff --git a/bin/tests/system/isctest/template.py b/bin/tests/system/isctest/template.py new file mode 100644 index 0000000000..123d758ab0 --- /dev/null +++ b/bin/tests/system/isctest/template.py @@ -0,0 +1,100 @@ +#!/usr/bin/python3 + +# Copyright (C) Internet Systems Consortium, Inc. ("ISC") +# +# SPDX-License-Identifier: MPL-2.0 +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, you can obtain one at https://mozilla.org/MPL/2.0/. +# +# See the COPYRIGHT file distributed with this work for additional +# information regarding copyright ownership. + +import os +from pathlib import Path +from typing import Any, Dict, Optional, Union + +import pytest + +from .log import debug + + +class TemplateEngine: + """ + Engine for rendering jinja2 templates in system test directories. + """ + + def __init__(self, directory: Union[str, Path], env_vars=None): + """ + Initialize the template engine for `directory`, optionally overriding + the `env_vars` that will be used when rendering the templates (defaults + to the environment variables set by the pytest runner). + """ + self.directory = Path(directory) + self._j2env = None + if env_vars is None: + self.env_vars = dict(os.environ) + else: + self.env_vars = dict(env_vars) + + @property + def j2env(self): + """ + Jinja2 engine that is initialized when first requested. In case the + jinja2 package in unavailable, the current test will be skipped. + """ + if self._j2env is None: + try: + import jinja2 # pylint: disable=import-outside-toplevel + except ImportError: + pytest.skip("jinja2 not found") + + loader = jinja2.FileSystemLoader(str(self.directory)) + return jinja2.Environment( + loader=loader, + undefined=jinja2.StrictUndefined, + variable_start_string="@", + variable_end_string="@", + ) + return self._j2env + + def render( + self, + output: str, + data: Optional[Dict[str, Any]] = None, + template: Optional[str] = None, + ) -> None: + """ + Render `output` file from jinja `template` and fill in the `data`. The + `template` defaults to *.j2.manual or *.j2 file. The environment + variables which the engine was initialized with are also filled in. In + case of a variable name clash, `data` has precedence. + """ + if template is None: + template = f"{output}.j2.manual" + if not Path(template).is_file(): + template = f"{output}.j2" + if not Path(template).is_file(): + raise RuntimeError('No jinja2 template found for "{output}"') + + if data is None: + data = self.env_vars + else: + data = {**self.env_vars, **data} + + debug("rendering template `%s` to file `%s`", template, output) + stream = self.j2env.get_template(template).stream(data) + stream.dump(output, encoding="utf-8") + + def render_auto(self): + """ + Render all *.j2 templates with default values and write the output to + files without the .j2 extensions. + """ + templates = [ + str(filepath.relative_to(self.directory)) + for filepath in self.directory.rglob("*.j2") + ] + for template in templates: + self.render(template[:-3])