Skip to content

Commit 8ecffa6

Browse files
committed
test: add integration test
This requires the secret-operator to be installed on a kubernetes cluster, and must be run after the ansible-playbook has been run.
1 parent defbd26 commit 8ecffa6

File tree

12 files changed

+616
-3
lines changed

12 files changed

+616
-3
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ tmp/
55
/target
66
roles/create-vm/files/guest-files/spice-guest-tools.exe
77
/.direnv
8+
_work/

nix/sources.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,16 @@
11
{
2+
"beku.py": {
3+
"branch": "main",
4+
"description": "Test suite expander for Stackable Kuttl tests.",
5+
"homepage": null,
6+
"owner": "stackabletech",
7+
"repo": "beku.py",
8+
"rev": "1ebc9e7b70fb8ee11dfb569ae45b3bcd63666d0e",
9+
"sha256": "1zg24h5wdis7cysa08r8vvbw2rpyx6fgv148i1lg54dwd3sa0h0d",
10+
"type": "tarball",
11+
"url": "https://github.com/stackabletech/beku.py/archive/1ebc9e7b70fb8ee11dfb569ae45b3bcd63666d0e.tar.gz",
12+
"url_template": "https://github.com/<owner>/<repo>/archive/<rev>.tar.gz"
13+
},
214
"nixpkgs": {
315
"branch": "nixos-unstable",
416
"description": "Nix Packages collection",

scripts/run-tests

Lines changed: 368 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,368 @@
1+
#!/usr/bin/env python
2+
# vim: filetype=python syntax=python tabstop=4 expandtab
3+
4+
import argparse
5+
import collections.abc
6+
import contextlib
7+
import logging
8+
import os
9+
import re
10+
import shutil
11+
import subprocess
12+
import sys
13+
import tempfile
14+
15+
__version__ = "0.0.1"
16+
17+
DESCRIPTION = """
18+
Run integration tests. Call this script from the root of the repository.
19+
20+
Exits with 0 on success, 1 on failure.
21+
22+
Requires the following commands to be installed:
23+
* beku
24+
* stackablectl
25+
* kubectl
26+
* kubectl-kuttl
27+
28+
Examples:
29+
30+
1. Install operators, run all tests and clean up test namespaces:
31+
32+
./scripts/run-tests --parallel 4
33+
34+
2. Install operators but for Airflow use version "0.0.0-pr123" instead of "0.0.0-dev" and run all tests as above:
35+
36+
./scripts/run-tests --operator airflow=0.0.0-pr123 --parallel 4
37+
38+
3. Do not install any operators, run the smoke test suite and keep namespace:
39+
40+
./scripts/run-tests --skip-release --skip-delete --test-suite smoke-latest
41+
42+
4. Run the ldap test(s) from the openshift test suite and keep namespace:
43+
44+
./scripts/run-tests --skip-release --skip-delete --test-suite openshift --test ldap
45+
46+
5. Run the smoke test suite in the namespace "smoke". The namespace will be
47+
created if it doesn't exist and will not be deleted when the tests end.
48+
49+
./scripts/run-tests --test-suite smoke-latest --namespace smoke
50+
"""
51+
52+
53+
class TestRunnerException(Exception):
54+
pass
55+
56+
57+
def parse_args(argv: list[str]) -> argparse.Namespace:
58+
"""Parse command line args."""
59+
parser = argparse.ArgumentParser(
60+
description=DESCRIPTION, formatter_class=argparse.RawDescriptionHelpFormatter
61+
)
62+
parser.add_argument(
63+
"--version",
64+
help="Display application version",
65+
action="version",
66+
version=f"%(prog)s {__version__}",
67+
)
68+
69+
parser.add_argument(
70+
"--skip-delete",
71+
help="Do not delete test namespaces.",
72+
action="store_true",
73+
)
74+
75+
parser.add_argument(
76+
"--skip-tests",
77+
help="Do not actually run the tests.",
78+
action="store_true",
79+
)
80+
81+
parser.add_argument(
82+
"--skip-release",
83+
help="Do not install operators.",
84+
action="store_true",
85+
)
86+
87+
parser.add_argument(
88+
"--parallel",
89+
help="How many tests to run in parallel. Default 2.",
90+
type=int,
91+
required=False,
92+
default=2,
93+
)
94+
95+
parser.add_argument(
96+
"--operator",
97+
help="Patch operator version in release.yaml. Format <operator>=<version>",
98+
nargs="*",
99+
type=cli_parse_operator_args,
100+
default=[],
101+
)
102+
103+
parser.add_argument(
104+
"--test",
105+
help="Kuttl test to run.",
106+
type=str,
107+
required=False,
108+
)
109+
110+
parser.add_argument(
111+
"--test-suite",
112+
help="Name of the test suite to expand. Default: default",
113+
type=str,
114+
required=False,
115+
)
116+
117+
parser.add_argument(
118+
"--log-level",
119+
help="Set log level.",
120+
type=cli_log_level,
121+
required=False,
122+
default=logging.INFO,
123+
)
124+
125+
parser.add_argument(
126+
"--namespace",
127+
help="Namespace to run the tests in. It will be created if it doesn't already exist.",
128+
type=str,
129+
required=False,
130+
)
131+
132+
return parser.parse_args(argv)
133+
134+
135+
def cli_parse_operator_args(args: str) -> tuple[str, str]:
136+
if "=" not in args:
137+
raise argparse.ArgumentTypeError(
138+
f"Invalid operator argument: {args}. Must be in format <operator>=<version>"
139+
)
140+
op, version = args.split("=", maxsplit=1)
141+
return (op, version)
142+
143+
144+
def cli_log_level(cli_arg: str) -> int:
145+
match cli_arg:
146+
case "debug":
147+
return logging.DEBUG
148+
case "info":
149+
return logging.INFO
150+
case "error":
151+
return logging.ERROR
152+
case "warning":
153+
return logging.WARNING
154+
case "critical":
155+
return logging.CRITICAL
156+
case _:
157+
raise argparse.ArgumentTypeError("Invalid log level")
158+
159+
160+
def have_requirements() -> None:
161+
commands = [
162+
("beku", "https://github.com/stackabletech/beku.py"),
163+
(
164+
"stackablectl",
165+
"https://github.com/stackabletech/stackable-cockpit/blob/main/rust/stackablectl/README.md",
166+
),
167+
("kubectl", "https://kubernetes.io/docs/tasks/tools/install-kubectl-linux/"),
168+
("kubectl-kuttl", "https://kuttl.dev/"),
169+
]
170+
171+
err = False
172+
for command, url in commands:
173+
if not shutil.which(command):
174+
logging.error(f'Command "{command}" not found, please install from {url}')
175+
err = True
176+
if err:
177+
raise TestRunnerException()
178+
179+
180+
@contextlib.contextmanager
181+
def release_file(
182+
operators: list[tuple[str, str]] = [],
183+
) -> collections.abc.Generator[str, None, None]:
184+
"""Patch release.yaml with operator versions if needed.
185+
186+
If no --operator is set, the default release file is used.
187+
188+
If an invalid operator name is provided (i.e. one that doesn't exist in the
189+
original release file), a TestRunnerException is raised.
190+
191+
Yields the name of the (potentially patched) release file. This is a temporary
192+
file that will be deleted when the context manager exits.
193+
"""
194+
195+
def _patch():
196+
release_file = os.path.join("tests", "release.yaml")
197+
# Make a copy so we can mutate it without affecting the original
198+
ops_copy = operators.copy()
199+
patched_release = []
200+
with open(release_file, "r") as f:
201+
patch_version = ""
202+
for line in f:
203+
if patch_version:
204+
line = re.sub(":.+$", f": {patch_version}", line)
205+
patch_version = ""
206+
else:
207+
for op, version in ops_copy:
208+
if op in line:
209+
patch_version = version
210+
ops_copy.remove((op, version)) # found an operator to patch
211+
break
212+
patched_release.append(line)
213+
if ops_copy:
214+
# Some --operator args were not found in the release file. This is
215+
# most likely a typo and CI pipelines should terminate early in such
216+
# cases.
217+
logging.error(
218+
f"Operators {', '.join([op for op, _ in ops_copy])} not found in {release_file}"
219+
)
220+
raise TestRunnerException()
221+
with tempfile.NamedTemporaryFile(
222+
mode="w",
223+
delete=False,
224+
prefix="patched",
225+
) as f:
226+
pcontents = "".join(patched_release)
227+
logging.debug(f"Writing patched release to {f.name}: {pcontents}\n")
228+
f.write(pcontents)
229+
return f.name
230+
231+
release_file = _patch()
232+
try:
233+
yield release_file
234+
except TestRunnerException as e:
235+
logging.error(f"Caught exception: {e}")
236+
raise
237+
finally:
238+
if "patched" in release_file:
239+
try:
240+
logging.debug(f"Removing patched release file : {release_file}")
241+
os.remove(release_file)
242+
except FileNotFoundError | OSError:
243+
logging.error(f"Failed to delete patched release file: {release_file}")
244+
245+
246+
def maybe_install_release(skip_release: bool, release_file: str) -> None:
247+
if skip_release:
248+
logging.debug("Skip release installation")
249+
return
250+
stackablectl_err = ""
251+
try:
252+
stackablectl_cmd = [
253+
"stackablectl",
254+
"release",
255+
"install",
256+
"--release-file",
257+
release_file,
258+
"tests",
259+
]
260+
logging.debug(f"Running : {stackablectl_cmd}")
261+
262+
completed_proc = subprocess.run(
263+
stackablectl_cmd,
264+
capture_output=True,
265+
check=True,
266+
)
267+
# stackablectl doesn't return a non-zero exit code on failure
268+
# so we need to check stderr for errors
269+
stackablectl_err = completed_proc.stderr.decode("utf-8")
270+
if "error" in stackablectl_err.lower():
271+
logging.error(stackablectl_err)
272+
logging.error("stackablectl failed")
273+
raise TestRunnerException()
274+
275+
except subprocess.CalledProcessError as e:
276+
# in case stackablectl starts returning non-zero exit codes
277+
logging.error(e.stderr.decode("utf-8"))
278+
logging.error("stackablectl failed")
279+
raise TestRunnerException()
280+
281+
282+
def gen_tests(test_suite: str) -> None:
283+
try:
284+
beku_cmd = [
285+
"beku",
286+
"--test_definition",
287+
os.path.join("tests", "test-definition.yaml"),
288+
"--kuttl_test",
289+
os.path.join("tests", "kuttl-test.yaml.jinja2"),
290+
"--template_dir",
291+
os.path.join("tests", "templates", "kuttl"),
292+
"--output_dir",
293+
os.path.join("tests", "_work"),
294+
]
295+
if test_suite:
296+
beku_cmd.extend(["--suite", test_suite])
297+
298+
logging.debug(f"Running : {beku_cmd}")
299+
subprocess.run(
300+
beku_cmd,
301+
check=True,
302+
)
303+
except subprocess.CalledProcessError:
304+
logging.error("beku failed")
305+
raise TestRunnerException()
306+
307+
308+
def run_tests(test: str, parallel: int, namespace: str, skip_delete: bool) -> None:
309+
try:
310+
kuttl_cmd = ["kubectl-kuttl", "test"]
311+
if test:
312+
kuttl_cmd.extend(["--test", test])
313+
if parallel:
314+
kuttl_cmd.extend(["--parallel", str(parallel)])
315+
if skip_delete:
316+
kuttl_cmd.extend(["--skip-delete"])
317+
if namespace:
318+
kuttl_cmd.extend(["--namespace", namespace])
319+
# kuttl doesn't create the namespace so we need to do it ourselves
320+
create_ns_cmd = ["kubectl", "create", "namespace", namespace]
321+
try:
322+
logging.debug(f"Running : {create_ns_cmd}")
323+
subprocess.run(
324+
create_ns_cmd,
325+
check=True,
326+
capture_output=True,
327+
)
328+
except subprocess.CalledProcessError as e:
329+
stderr = e.stderr.decode("utf-8")
330+
# If the namespace already exists, this will fail and we ignore the
331+
# error. If it fails for any other reason, we raise an exception.
332+
if "already exists" not in stderr:
333+
logging.error(stderr)
334+
logging.error("namespace creation failed")
335+
raise TestRunnerException()
336+
337+
logging.debug(f"Running : {kuttl_cmd}")
338+
339+
subprocess.run(
340+
kuttl_cmd,
341+
cwd="tests/_work",
342+
check=True,
343+
)
344+
except subprocess.CalledProcessError:
345+
logging.error("kuttl failed")
346+
raise TestRunnerException()
347+
348+
349+
def main(argv) -> int:
350+
ret = 0
351+
try:
352+
opts = parse_args(argv[1:])
353+
logging.basicConfig(encoding="utf-8", level=opts.log_level)
354+
have_requirements()
355+
gen_tests(opts.test_suite)
356+
with release_file(opts.operator) as f:
357+
maybe_install_release(opts.skip_release, f)
358+
if opts.skip_tests:
359+
logging.info("Skip running tests.")
360+
else:
361+
run_tests(opts.test, opts.parallel, opts.namespace, opts.skip_delete)
362+
except TestRunnerException:
363+
ret = 1
364+
return ret
365+
366+
367+
if __name__ == "__main__":
368+
sys.exit(main(sys.argv))

scripts/run_tests.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
#!/usr/bin/env bash
2+
3+
./scripts/run-tests "$@"

0 commit comments

Comments
 (0)