From adcd54930345ea431c2c4e826e69c0acf84ae3d4 Mon Sep 17 00:00:00 2001 From: Soenke Liebau Date: Wed, 8 Jul 2026 10:06:53 +0000 Subject: [PATCH] feat: render common steps into every test case Add a common-steps directory whose files are rendered into every generated test case, in addition to the test's own steps. This lets shared steps - such as a teardown that removes the product CRs before the namespace is deleted - live in a single place instead of being copied into each test. The directory defaults to '/commons' (overridable via --common_dir) and is skipped if absent, so this is a no-op for repositories that do not opt in. Common files are templated identically to regular steps (.j2/.jinja2 rendered, others copied; NAMESPACE and lookup available) and are rendered after the test's own files. --- CHANGELOG.md | 7 +++ README.md | 11 +++++ src/beku/kuttl.py | 38 +++++++++++----- src/beku/main.py | 18 ++++++++ src/beku/test/test_common_steps.py | 69 ++++++++++++++++++++++++++++++ 5 files changed, 133 insertions(+), 10 deletions(-) create mode 100644 src/beku/test/test_common_steps.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c7ae5cb..5bbae5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- Common test steps: files in a `commons` directory next to the test templates (i.e. + `/commons`), or an explicit `--common_dir`, are rendered into every generated test + case in addition to the test's own steps. This lets shared steps such as a teardown live in a single + place instead of being copied into each test. No-op unless the directory exists. + ### Changed - Update registry references to oci ([#36]). diff --git a/README.md b/README.md index b97a570..784be4d 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,17 @@ rm -rf tests/_work && beku cd tests/_work && kubectl kuttl test ``` +### Common steps + +Files placed in a `commons` directory next to the test templates (i.e. +`tests/templates/kuttl/commons`) are rendered into *every* generated test case, in addition to that +test's own steps. This lets shared steps — for example a teardown that deletes the product custom +resources before the namespace is removed — live in a single place instead of being copied into each +test. The directory is templated the same way as regular steps (`.j2`/`.jinja2` files are rendered, +others copied; `NAMESPACE` and `lookup` are available). It is skipped if it does not exist, and its +location can be overridden with `--common_dir`. Use step names that don't collide with a test's own +steps (e.g. a high number like `99-teardown.yaml`). + Also see the `examples` folder. ## Release a new version diff --git a/src/beku/kuttl.py b/src/beku/kuttl.py index 0f0690c..14c6e73 100644 --- a/src/beku/kuttl.py +++ b/src/beku/kuttl.py @@ -140,26 +140,43 @@ def tid(self) -> str: else: return name - def expand(self, template_dir: str, target_dir: str, namespace: str) -> None: - """Expand test case This will create the target folder, copy files and render render templates.""" + def expand(self, template_dir: str, target_dir: str, namespace: str, common_dir: Optional[str] = None) -> None: + """Expand test case. This will create the target folder, copy files and render templates. + + The test's own steps (from ``template_dir/``) are rendered first. If ``common_dir`` is + given and exists, its files are then rendered into the SAME test folder, so shared steps (e.g. + a teardown) can live in a single place instead of being copied into every test. Common files + are rendered after the test's own files, so a common file wins on a name collision; use + non-colliding names (e.g. a high step number like ``99-teardown.yaml``). + """ logging.info("Expanding test case id [%s]", self.tid) - td_root = path.join(template_dir, self.name) tc_root = path.join(target_dir, self.name, self.tid) _mkdir_ignore_exists(tc_root) - test_env = Environment(loader=FileSystemLoader(path.join(template_dir, self.name)), trim_blocks=True) - test_env.globals["lookup"] = ansible_lookup - test_env.globals["NAMESPACE"] = determine_namespace(self.tid, namespace) + namespace = determine_namespace(self.tid, namespace) + self._render_dir(path.join(template_dir, self.name), tc_root, namespace) + if common_dir: + if path.isdir(common_dir): + logging.debug("Rendering common steps from [%s] into [%s]", common_dir, tc_root) + self._render_dir(common_dir, tc_root, namespace) + else: + logging.warning("Common steps directory [%s] does not exist, skipping", common_dir) + + def _render_dir(self, source_root: str, tc_root: str, namespace: str) -> None: + """Render/copy every file under ``source_root`` into ``tc_root``, preserving sub-directories.""" + env = Environment(loader=FileSystemLoader(source_root), trim_blocks=True) + env.globals["lookup"] = ansible_lookup + env.globals["NAMESPACE"] = namespace sub_level: int = 0 - for root, dirs, files in walk(td_root): + for root, dirs, files in walk(source_root): sub_level += 1 if sub_level == 8: # Sanity check raise ValueError("Maximum recursive level (8) reached.") for dir_name in dirs: - _mkdir_ignore_exists(path.join(tc_root, root[len(td_root) + 1 :], dir_name)) + _mkdir_ignore_exists(path.join(tc_root, root[len(source_root) + 1 :], dir_name)) for file_name in files: test_source = make_test_source_with_context( - file_name, root, path.join(tc_root, root[len(td_root) + 1 :]), test_env, self.values + file_name, root, path.join(tc_root, root[len(source_root) + 1 :]), env, self.values ) test_source.build_destination() @@ -336,6 +353,7 @@ def expand( output_dir: str, kuttl_tests: str, namespace: str, + common_dir: Optional[str] = None, ) -> int: """Expand test suite.""" try: @@ -344,7 +362,7 @@ def expand( _mkdir_ignore_exists(output_dir) _expand_kuttl_tests(ets.test_cases, output_dir, kuttl_tests) for test_case in ets.test_cases: - test_case.expand(template_dir, output_dir, namespace) + test_case.expand(template_dir, output_dir, namespace, common_dir) except StopIteration as exc: raise ValueError(f"Cannot expand test suite [{suite}] because cannot find it in [{kuttl_tests}]") from exc return 0 diff --git a/src/beku/main.py b/src/beku/main.py index 7d7959f..29dba55 100644 --- a/src/beku/main.py +++ b/src/beku/main.py @@ -60,6 +60,19 @@ def parse_cli_args() -> Namespace: default="tests/kuttl-test.yaml.jinja2", ) + parser.add_argument( + "-c", + "--common_dir", + help="Folder with common test step templates/files that are rendered into EVERY generated " + "test case (in addition to the test's own steps). Lets shared steps such as a teardown live " + "in a single place instead of being copied into each test. Defaults to a 'commons' folder " + "next to the test templates (i.e. /commons); skipped if that folder does not " + "exist, so this is a no-op unless you create it.", + type=str, + required=False, + default=None, + ) + parser.add_argument( "-s", "--suite", @@ -94,6 +107,10 @@ def main() -> int: rmtree(path=cli_args.output_dir, ignore_errors=True) # Compatibility warning: add 'tests' to output_dir output_dir = path.join(cli_args.output_dir, "tests") + # Default the common steps directory to a 'commons' folder next to the test templates. It is + # rendered into every test case if present, and silently skipped otherwise (so this stays a no-op + # for repositories that don't opt in by creating it). + common_dir = cli_args.common_dir if cli_args.common_dir is not None else path.join(cli_args.template_dir, "commons") return expand( cli_args.suite, effective_test_suites, @@ -101,4 +118,5 @@ def main() -> int: output_dir, cli_args.kuttl_test, cli_args.namespace, + common_dir, ) diff --git a/src/beku/test/test_common_steps.py b/src/beku/test/test_common_steps.py new file mode 100644 index 0000000..4a6f26b --- /dev/null +++ b/src/beku/test/test_common_steps.py @@ -0,0 +1,69 @@ +"""Tests for the common-steps feature: files in a common directory are rendered into every test case.""" + +import tempfile +import unittest +from os import makedirs, path + +from beku.kuttl import TestCase + + +def _write(file_path: str, content: str) -> None: + makedirs(path.dirname(file_path), exist_ok=True) + with open(file_path, encoding="utf8", mode="w") as stream: + stream.write(content) + + +class TestCommonSteps(unittest.TestCase): + def test_common_dir_rendered_into_test_case(self): + with tempfile.TemporaryDirectory() as tmp: + template_dir = path.join(tmp, "templates") + common_dir = path.join(tmp, "commons") + target_dir = path.join(tmp, "_work") + + # A test with its own step, and a plain + templated common file. + _write(path.join(template_dir, "mytest", "00-install.yaml"), "step\n") + _write(path.join(common_dir, "99-teardown.yaml"), "teardown\n") + _write(path.join(common_dir, "50-note.txt.j2"), "ns={{ NAMESPACE }}\n") + + TestCase(name="mytest", values={}).expand(template_dir, target_dir, "kuttl-fixed", common_dir) + + tc_dir = path.join(target_dir, "mytest", "mytest") + # the test's own step is present + self.assertTrue(path.isfile(path.join(tc_dir, "00-install.yaml"))) + # the common plain file is present + self.assertTrue(path.isfile(path.join(tc_dir, "99-teardown.yaml"))) + # the common template is rendered (suffix stripped, NAMESPACE substituted) + rendered = path.join(tc_dir, "50-note.txt") + self.assertTrue(path.isfile(rendered)) + with open(rendered, encoding="utf8") as stream: + self.assertIn("ns=kuttl-fixed", stream.read()) + + def test_missing_common_dir_is_skipped(self): + with tempfile.TemporaryDirectory() as tmp: + template_dir = path.join(tmp, "templates") + target_dir = path.join(tmp, "_work") + _write(path.join(template_dir, "mytest", "00-install.yaml"), "step\n") + + # Non-existent common dir must not raise and must not add anything. + TestCase(name="mytest", values={}).expand( + template_dir, target_dir, "kuttl-fixed", path.join(tmp, "does-not-exist") + ) + + tc_dir = path.join(target_dir, "mytest", "mytest") + self.assertTrue(path.isfile(path.join(tc_dir, "00-install.yaml"))) + self.assertFalse(path.isfile(path.join(tc_dir, "99-teardown.yaml"))) + + def test_no_common_dir_argument_is_backwards_compatible(self): + with tempfile.TemporaryDirectory() as tmp: + template_dir = path.join(tmp, "templates") + target_dir = path.join(tmp, "_work") + _write(path.join(template_dir, "mytest", "00-install.yaml"), "step\n") + + # Old call signature (no common_dir) keeps working. + TestCase(name="mytest", values={}).expand(template_dir, target_dir, "kuttl-fixed") + + self.assertTrue(path.isfile(path.join(target_dir, "mytest", "mytest", "00-install.yaml"))) + + +if __name__ == "__main__": + unittest.main()