Skip to content

Commit 1fd6569

Browse files
committed
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 '<template_dir>/shared' (overridable via --common_dir) and is skipped if absent, so this is a no-op for repositories that do not opt in. 'commons' is intentionally avoided since some operators already keep unrelated helper scripts there. On a name collision the test's own file wins: a shared file that would overwrite a file the test already provides is skipped (with a warning), so a shared step can never silently clobber a test-specific one.
1 parent f8256bb commit 1fd6569

5 files changed

Lines changed: 169 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased]
66

7+
### Added
8+
9+
- Common test steps: files in a `shared` directory next to the test templates (i.e.
10+
`<template_dir>/shared`), or an explicit `--common_dir`, are rendered into every generated test
11+
case in addition to the test's own steps. This lets shared steps such as a teardown live in a single
12+
place instead of being copied into each test. No-op unless the directory exists.
13+
714
### Changed
815

916
- Update registry references to oci ([#36]).

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,18 @@ rm -rf tests/_work && beku
6161
cd tests/_work && kubectl kuttl test
6262
```
6363

64+
### Common steps
65+
66+
Files placed in a `shared` directory next to the test templates (i.e.
67+
`tests/templates/kuttl/shared`) are rendered into *every* generated test case, in addition to that
68+
test's own steps. This lets shared steps — for example a teardown that deletes the product custom
69+
resources before the namespace is removed — live in a single place instead of being copied into each
70+
test. The directory is templated the same way as regular steps (`.j2`/`.jinja2` files are rendered,
71+
others copied; `NAMESPACE` and `lookup` are available). It is skipped if it does not exist, and its
72+
location can be overridden with `--common_dir`. On a name collision the test's own file wins — a
73+
shared file that would overwrite a file the test already provides is skipped (with a warning) — so a
74+
shared step never silently clobbers a test-specific one.
75+
6476
Also see the `examples` folder.
6577

6678
## Release a new version

src/beku/kuttl.py

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,16 @@ class TestFile:
4444
source_dir: str
4545
file_name: str
4646

47-
def build_destination(self) -> str:
47+
def build_destination(self, skip_existing: bool = False) -> Optional[str]:
4848
"""Copies the file name to the destination directory.
49-
Returns the destination file name.
49+
Returns the destination file name, or None if it was skipped because it already exists and
50+
`skip_existing` is set (used for common/shared files so a test's own file always wins).
5051
"""
5152
source = path.join(self.source_dir, self.file_name)
5253
dest = path.join(self.dest_dir, self.file_name)
54+
if skip_existing and path.exists(dest):
55+
logging.warning("Skipping common file %s: test case already provides %s", source, dest)
56+
return None
5357
logging.debug("Copy file %s to %s", source, dest)
5458
copy2(source, dest)
5559
logging.debug("Update file mode for %s", dest)
@@ -68,13 +72,17 @@ class TestTemplate:
6872
env: Environment
6973
values: Dict[str, str]
7074

71-
def build_destination(self) -> str:
75+
def build_destination(self, skip_existing: bool = False) -> Optional[str]:
7276
"""Renders the template to file in the destination directory. The resulting file has the same name as the
7377
template but with the .j2 or .jinja2 ending removed.
74-
Returns the rendered file name.
78+
Returns the rendered file name, or None if it was skipped because it already exists and
79+
`skip_existing` is set (used for common/shared files so a test's own file always wins).
7580
"""
7681
source = path.join(self.source_dir, self.file_name)
7782
dest = path.join(self.dest_dir, re.sub(PATTERN_EXTENSION_JINJA, "", self.file_name))
83+
if skip_existing and path.exists(dest):
84+
logging.warning("Skipping common template %s: test case already provides %s", source, dest)
85+
return None
7886
logging.debug("Render template %s to %s", source, dest)
7987
template = self.env.get_template(self.file_name)
8088
with open(dest, encoding="utf8", mode="w") as stream:
@@ -140,28 +148,50 @@ def tid(self) -> str:
140148
else:
141149
return name
142150

143-
def expand(self, template_dir: str, target_dir: str, namespace: str) -> None:
144-
"""Expand test case This will create the target folder, copy files and render render templates."""
151+
def expand(self, template_dir: str, target_dir: str, namespace: str, common_dir: Optional[str] = None) -> None:
152+
"""Expand test case. This will create the target folder, copy files and render templates.
153+
154+
The test's own steps (from ``template_dir/<name>``) are rendered first. If ``common_dir`` is
155+
given and exists, its files are then rendered into the SAME test folder, so shared steps (e.g.
156+
a teardown) can live in a single place instead of being copied into every test. On a name
157+
collision the test's OWN file wins: a common file that would overwrite a file the test already
158+
provides is skipped (with a warning), so a shared file can never silently clobber a
159+
test-specific one.
160+
"""
145161
logging.info("Expanding test case id [%s]", self.tid)
146-
td_root = path.join(template_dir, self.name)
147162
tc_root = path.join(target_dir, self.name, self.tid)
148163
_mkdir_ignore_exists(tc_root)
149-
test_env = Environment(loader=FileSystemLoader(path.join(template_dir, self.name)), trim_blocks=True)
150-
test_env.globals["lookup"] = ansible_lookup
151-
test_env.globals["NAMESPACE"] = determine_namespace(self.tid, namespace)
164+
namespace = determine_namespace(self.tid, namespace)
165+
self._render_dir(path.join(template_dir, self.name), tc_root, namespace)
166+
if common_dir:
167+
if path.isdir(common_dir):
168+
logging.debug("Rendering common steps from [%s] into [%s]", common_dir, tc_root)
169+
self._render_dir(common_dir, tc_root, namespace, skip_existing=True)
170+
else:
171+
logging.warning("Common steps directory [%s] does not exist, skipping", common_dir)
172+
173+
def _render_dir(self, source_root: str, tc_root: str, namespace: str, skip_existing: bool = False) -> None:
174+
"""Render/copy every file under ``source_root`` into ``tc_root``, preserving sub-directories.
175+
176+
When ``skip_existing`` is set, files whose destination already exists are skipped (used for
177+
common/shared files so a test's own file always takes precedence).
178+
"""
179+
env = Environment(loader=FileSystemLoader(source_root), trim_blocks=True)
180+
env.globals["lookup"] = ansible_lookup
181+
env.globals["NAMESPACE"] = namespace
152182
sub_level: int = 0
153-
for root, dirs, files in walk(td_root):
183+
for root, dirs, files in walk(source_root):
154184
sub_level += 1
155185
if sub_level == 8:
156186
# Sanity check
157187
raise ValueError("Maximum recursive level (8) reached.")
158188
for dir_name in dirs:
159-
_mkdir_ignore_exists(path.join(tc_root, root[len(td_root) + 1 :], dir_name))
189+
_mkdir_ignore_exists(path.join(tc_root, root[len(source_root) + 1 :], dir_name))
160190
for file_name in files:
161191
test_source = make_test_source_with_context(
162-
file_name, root, path.join(tc_root, root[len(td_root) + 1 :]), test_env, self.values
192+
file_name, root, path.join(tc_root, root[len(source_root) + 1 :]), env, self.values
163193
)
164-
test_source.build_destination()
194+
test_source.build_destination(skip_existing=skip_existing)
165195

166196

167197
@dataclass(frozen=True, eq=True)
@@ -336,6 +366,7 @@ def expand(
336366
output_dir: str,
337367
kuttl_tests: str,
338368
namespace: str,
369+
common_dir: Optional[str] = None,
339370
) -> int:
340371
"""Expand test suite."""
341372
try:
@@ -344,7 +375,7 @@ def expand(
344375
_mkdir_ignore_exists(output_dir)
345376
_expand_kuttl_tests(ets.test_cases, output_dir, kuttl_tests)
346377
for test_case in ets.test_cases:
347-
test_case.expand(template_dir, output_dir, namespace)
378+
test_case.expand(template_dir, output_dir, namespace, common_dir)
348379
except StopIteration as exc:
349380
raise ValueError(f"Cannot expand test suite [{suite}] because cannot find it in [{kuttl_tests}]") from exc
350381
return 0

src/beku/main.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,19 @@ def parse_cli_args() -> Namespace:
6060
default="tests/kuttl-test.yaml.jinja2",
6161
)
6262

63+
parser.add_argument(
64+
"-c",
65+
"--common_dir",
66+
help="Folder with common test step templates/files that are rendered into EVERY generated "
67+
"test case (in addition to the test's own steps). Lets shared steps such as a teardown live "
68+
"in a single place instead of being copied into each test. Defaults to a 'shared' folder "
69+
"next to the test templates (i.e. <template_dir>/shared); skipped if that folder does not "
70+
"exist, so this is a no-op unless you create it.",
71+
type=str,
72+
required=False,
73+
default=None,
74+
)
75+
6376
parser.add_argument(
6477
"-s",
6578
"--suite",
@@ -94,11 +107,17 @@ def main() -> int:
94107
rmtree(path=cli_args.output_dir, ignore_errors=True)
95108
# Compatibility warning: add 'tests' to output_dir
96109
output_dir = path.join(cli_args.output_dir, "tests")
110+
# Default the common steps directory to a 'shared' folder next to the test templates. It is
111+
# rendered into every test case if present, and silently skipped otherwise (so this stays a no-op
112+
# for repositories that don't opt in by creating it). Note: 'commons' is deliberately NOT used, as
113+
# some operators already keep unrelated helper scripts there.
114+
common_dir = cli_args.common_dir if cli_args.common_dir is not None else path.join(cli_args.template_dir, "shared")
97115
return expand(
98116
cli_args.suite,
99117
effective_test_suites,
100118
cli_args.template_dir,
101119
output_dir,
102120
cli_args.kuttl_test,
103121
cli_args.namespace,
122+
common_dir,
104123
)

src/beku/test/test_common_steps.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""Tests for the common-steps feature: files in a common directory are rendered into every test case."""
2+
3+
import tempfile
4+
import unittest
5+
from os import makedirs, path
6+
7+
from beku.kuttl import TestCase
8+
9+
10+
def _write(file_path: str, content: str) -> None:
11+
makedirs(path.dirname(file_path), exist_ok=True)
12+
with open(file_path, encoding="utf8", mode="w") as stream:
13+
stream.write(content)
14+
15+
16+
class TestCommonSteps(unittest.TestCase):
17+
def test_common_dir_rendered_into_test_case(self):
18+
with tempfile.TemporaryDirectory() as tmp:
19+
template_dir = path.join(tmp, "templates")
20+
common_dir = path.join(tmp, "commons")
21+
target_dir = path.join(tmp, "_work")
22+
23+
# A test with its own step, and a plain + templated common file.
24+
_write(path.join(template_dir, "mytest", "00-install.yaml"), "step\n")
25+
_write(path.join(common_dir, "99-teardown.yaml"), "teardown\n")
26+
_write(path.join(common_dir, "50-note.txt.j2"), "ns={{ NAMESPACE }}\n")
27+
28+
TestCase(name="mytest", values={}).expand(template_dir, target_dir, "kuttl-fixed", common_dir)
29+
30+
tc_dir = path.join(target_dir, "mytest", "mytest")
31+
# the test's own step is present
32+
self.assertTrue(path.isfile(path.join(tc_dir, "00-install.yaml")))
33+
# the common plain file is present
34+
self.assertTrue(path.isfile(path.join(tc_dir, "99-teardown.yaml")))
35+
# the common template is rendered (suffix stripped, NAMESPACE substituted)
36+
rendered = path.join(tc_dir, "50-note.txt")
37+
self.assertTrue(path.isfile(rendered))
38+
with open(rendered, encoding="utf8") as stream:
39+
self.assertIn("ns=kuttl-fixed", stream.read())
40+
41+
def test_test_own_file_wins_on_collision(self):
42+
with tempfile.TemporaryDirectory() as tmp:
43+
template_dir = path.join(tmp, "templates")
44+
common_dir = path.join(tmp, "shared")
45+
target_dir = path.join(tmp, "_work")
46+
47+
# Same file name in the test and in the shared dir, with different content.
48+
_write(path.join(template_dir, "mytest", "10-check.yaml"), "TEST-OWN\n")
49+
_write(path.join(common_dir, "10-check.yaml"), "SHARED\n")
50+
51+
TestCase(name="mytest", values={}).expand(template_dir, target_dir, "kuttl-fixed", common_dir)
52+
53+
# The test's own file must win; the shared file must not clobber it.
54+
with open(path.join(target_dir, "mytest", "mytest", "10-check.yaml"), encoding="utf8") as stream:
55+
self.assertEqual("TEST-OWN\n", stream.read())
56+
57+
def test_missing_common_dir_is_skipped(self):
58+
with tempfile.TemporaryDirectory() as tmp:
59+
template_dir = path.join(tmp, "templates")
60+
target_dir = path.join(tmp, "_work")
61+
_write(path.join(template_dir, "mytest", "00-install.yaml"), "step\n")
62+
63+
# Non-existent common dir must not raise and must not add anything.
64+
TestCase(name="mytest", values={}).expand(
65+
template_dir, target_dir, "kuttl-fixed", path.join(tmp, "does-not-exist")
66+
)
67+
68+
tc_dir = path.join(target_dir, "mytest", "mytest")
69+
self.assertTrue(path.isfile(path.join(tc_dir, "00-install.yaml")))
70+
self.assertFalse(path.isfile(path.join(tc_dir, "99-teardown.yaml")))
71+
72+
def test_no_common_dir_argument_is_backwards_compatible(self):
73+
with tempfile.TemporaryDirectory() as tmp:
74+
template_dir = path.join(tmp, "templates")
75+
target_dir = path.join(tmp, "_work")
76+
_write(path.join(template_dir, "mytest", "00-install.yaml"), "step\n")
77+
78+
# Old call signature (no common_dir) keeps working.
79+
TestCase(name="mytest", values={}).expand(template_dir, target_dir, "kuttl-fixed")
80+
81+
self.assertTrue(path.isfile(path.join(target_dir, "mytest", "mytest", "00-install.yaml")))
82+
83+
84+
if __name__ == "__main__":
85+
unittest.main()

0 commit comments

Comments
 (0)