diff --git a/deckhouse/tests.py b/deckhouse/tests.py index 97b37a2..2e0f453 100644 --- a/deckhouse/tests.py +++ b/deckhouse/tests.py @@ -3,9 +3,16 @@ # Copyright 2024 Flant JSC Licensed under Apache License 2.0 # +import copy +import sys +import types import unittest import typing +from pathlib import Path +from dotmap import DotMap + +from . import hook, utils, validations from .hook import Output @@ -121,3 +128,88 @@ def assert_conversion(t: unittest.TestCase, o: Output, res: typing.Union[dict, t t.assertEqual(d[0]["convertedObjects"], expected) + + +def load_validation_webhook(*paths: typing.Union[str, Path]) -> types.ModuleType: + """ + Returns the hook webhook-operator renders for a ValidationWebhook as a module, so + hook.testrun can run its main. + + Args: + paths: the files of handler.python, in the order the ValidationWebhook joins them + """ + paths = [Path(p) for p in paths] + # The names the template has in scope for handler.python. + module = _new_module(paths, {"Optional": typing.Optional, "DotMap": DotMap, "hook": hook, "validations": validations}) + + # The template defines main before handler.python, so a handler could override it. + module.main = lambda ctx: validations.handle(ctx, module.validate) + for path in paths: + exec(compile(path.read_text(), str(path), "exec"), module.__dict__) + template_main = module.main + + def main(ctx: hook.Context): + # The template sets keepFullObjectsInMemory: false on every context binding, so the hook + # gets the jqFilter result only. A snapshot item a test hands over with the full object + # must not let a handler that reads `object` pass here and fail in the cluster. + ctx.binding_context = _without_full_objects(ctx.binding_context) + ctx.snapshots = ctx.binding_context.get("snapshots", {}) + return template_main(ctx) + + module.main = main + return module + + +def load_conversion_webhook(crd: str, conversions: typing.Iterable[typing.Tuple[str, str]], *paths: typing.Union[str, Path]) -> types.ModuleType: + """ + Returns the hook webhook-operator renders for a ConversionWebhook as a module, so + hook.testrun can run its main with module.config. + + Args: + crd: the name of the CRD, nodegroups.deckhouse.io + conversions: the (from, to) version pairs of the resource, ("v1alpha1", "v1") + paths: the files of handler.python, in the order of the resource + """ + paths = [Path(p) for p in paths] + # The names the template has in scope for handler.python. + module = _new_module(paths, {"typing": typing, "DotMap": DotMap, "hook": hook, "utils": utils}) + + # The template pastes the handlers into a utils.BaseConversionHook subclass, indented by four + # spaces, every line including blank ones and lines inside multi-line strings. + handlers = "\n".join("\n".join(" " + line for line in p.read_text().split("\n")) for p in paths) + code = ( + "class Conversion(utils.BaseConversionHook):\n" + " def __init__(self, ctx: hook.Context):\n" + " super().__init__(ctx)\n" + + handlers + + "\n\ndef main(ctx: hook.Context):\n Conversion(ctx).run()\n" + ) + exec(compile(code, str(paths[0]), "exec"), module.__dict__) + + group = crd.split(".", 1)[1] + module.config = "configVersion: v1\nkubernetesCustomResourceConversion:\n" + "".join( + f" - name: {source}_to_{target}\n" + f" crdName: {crd}\n" + f" conversions:\n" + f" - fromVersion: {group}/{source}\n" + f" toVersion: {group}/{target}\n" + for source, target in conversions + ) + return module + + +def _new_module(paths: typing.List[Path], names: dict) -> types.ModuleType: + name = "_".join(p.stem for p in paths) + "_hook" + module = types.ModuleType(name) + module.__dict__.update(names) + sys.modules[name] = module + return module + + +def _without_full_objects(binding_context): + plain = copy.deepcopy(binding_context.toDict() if isinstance(binding_context, DotMap) else binding_context) + for snapshot in (plain.get("snapshots") or {}).values(): + for item in snapshot or []: + if isinstance(item, dict): + item.pop("object", None) + return plain diff --git a/deckhouse/validations.py b/deckhouse/validations.py index b1a604f..49e4042 100644 --- a/deckhouse/validations.py +++ b/deckhouse/validations.py @@ -6,6 +6,22 @@ import typing +from dotmap import DotMap + +# What a webhook hands over as warnings or a message: a string, a list of them, or nothing. +Messages = typing.Union[str, typing.Iterable[str], None] + + +def _strings(values) -> tuple: + """The non-empty strings of `values`, with lists and tuples among them unpacked.""" + result = [] + for value in values: + if isinstance(value, (list, tuple)): + result.extend(_strings(value)) + elif value: + result.append(value) + return tuple(result) + class ValidationsCollector: """ @@ -30,17 +46,39 @@ def data(self): """ return self._data - def allow(self, *warnings: str): + def allow(self, *warnings: Messages): + """Allows the request. Each warning is a string or a list of them; empty ones are dropped.""" + warnings = _strings(warnings) response = {"allowed": True} - if len(warnings) > 0: + if warnings: response["warnings"] = warnings self.collect(response) - def deny(self, message: typing.Union[str, None] = None): + def deny(self, message: Messages = None): + """Denies the request. A list of messages becomes one message, joined with "; ".""" + messages = _strings([message]) response = {"allowed": False} - if message is not None: - response["message"] = message + if messages: + response["message"] = "; ".join(messages) self.collect(response) def error(self, message: str): self.collect({"allowed": False, "message": f"Internal webhook error: {message}"}) + + +def handle(ctx, validate: typing.Callable[[DotMap], typing.Tuple[Messages, bool]]): + """Answers the admission request of the hook context `ctx` with the result of `validate`. + + This is the main of the hook webhook-operator renders for a ValidationWebhook. + validate(binding_context) gets the binding context as a DotMap and returns (message, allowed). + The message is a string, a list of them or None: warnings for an allowed request, the reason + for a denied one. An exception becomes an internal webhook error. + """ + try: + message, allowed = validate(DotMap(ctx.binding_context)) + if allowed: + ctx.output.validations.allow(message) + else: + ctx.output.validations.deny(message) + except Exception as e: + ctx.output.validations.error(str(e)) diff --git a/poetry.lock b/poetry.lock index 0c8e3cb..eadc427 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,10 +1,9 @@ -# This file is automatically @generated by Poetry and should not be changed by hand. +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. [[package]] name = "astroid" version = "2.15.6" description = "An abstract syntax tree for Python with inference support." -category = "dev" optional = false python-versions = ">=3.7.2" files = [ @@ -24,7 +23,6 @@ wrapt = [ name = "black" version = "22.12.0" description = "The uncompromising code formatter." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -60,7 +58,6 @@ uvloop = ["uvloop (>=0.15.2)"] name = "click" version = "8.1.7" description = "Composable command line interface toolkit" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -75,7 +72,6 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""} name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." -category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ @@ -87,7 +83,6 @@ files = [ name = "dictdiffer" version = "0.9.0" description = "Dictdiffer is a library that helps you to diff and patch dictionaries." -category = "main" optional = false python-versions = "*" files = [ @@ -105,7 +100,6 @@ tests = ["check-manifest (>=0.42)", "mock (>=1.3.0)", "pytest (==5.4.3)", "pytes name = "dill" version = "0.3.7" description = "serialize all of Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -120,7 +114,6 @@ graph = ["objgraph (>=1.7.2)"] name = "dotmap" version = "1.3.30" description = "ordered, dynamically-expandable dot-access dictionary" -category = "dev" optional = false python-versions = "*" files = [ @@ -132,7 +125,6 @@ files = [ name = "exceptiongroup" version = "1.1.3" description = "Backport of PEP 654 (exception groups)" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -147,7 +139,6 @@ test = ["pytest (>=6)"] name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -159,7 +150,6 @@ files = [ name = "isort" version = "5.12.0" description = "A Python utility / library to sort Python imports." -category = "dev" optional = false python-versions = ">=3.8.0" files = [ @@ -177,7 +167,6 @@ requirements-deprecated-finder = ["pip-api", "pipreqs"] name = "lazy-object-proxy" version = "1.9.0" description = "A fast and thorough lazy object proxy." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -223,7 +212,6 @@ files = [ name = "mccabe" version = "0.7.0" description = "McCabe checker, plugin for flake8" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -235,7 +223,6 @@ files = [ name = "mypy-extensions" version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -247,7 +234,6 @@ files = [ name = "packaging" version = "23.1" description = "Core utilities for Python packages" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -259,7 +245,6 @@ files = [ name = "pathspec" version = "0.11.2" description = "Utility library for gitignore style pattern matching of file paths." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -271,7 +256,6 @@ files = [ name = "platformdirs" version = "3.10.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -287,7 +271,6 @@ test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-co name = "pluggy" version = "1.3.0" description = "plugin and hook calling mechanisms for python" -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -303,7 +286,6 @@ testing = ["pytest", "pytest-benchmark"] name = "pylint" version = "2.17.5" description = "python code static checker" -category = "dev" optional = false python-versions = ">=3.7.2" files = [ @@ -333,7 +315,6 @@ testutils = ["gitpython (>3)"] name = "pytest" version = "7.4.1" description = "pytest: simple powerful testing with Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -356,7 +337,6 @@ testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "no name = "tomli" version = "2.0.1" description = "A lil' TOML parser" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -368,7 +348,6 @@ files = [ name = "tomlkit" version = "0.12.1" description = "Style preserving TOML library" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -380,7 +359,6 @@ files = [ name = "typing-extensions" version = "4.7.1" description = "Backported and Experimental Type Hints for Python 3.7+" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -392,7 +370,6 @@ files = [ name = "wrapt" version = "1.15.0" description = "Module for decorators, wrappers and monkey patching." -category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" files = [ @@ -476,4 +453,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "c151130e2dda14ce97521106c30d64fbd57f5d1b5e40503885acb8c93f00370c" +content-hash = "832a49a3ee5d17e788e4ee2586617c0e4f675aa262696c139e036bfc17160e47" diff --git a/pyproject.toml b/pyproject.toml index 0c38434..6f0bc0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,12 +11,12 @@ packages = [{ include = "deckhouse" }] [tool.poetry.dependencies] python = "^3.8" dictdiffer = "^0.9.0" +dotmap = "^1.3.30" [tool.poetry.group.dev.dependencies] black = "^22.12.0" pylint = "^2.15.10" pytest = "^7.2.0" -dotmap = "^1.3.30" [build-system] requires = ["poetry-core"] diff --git a/tests/validations_test.py b/tests/validations_test.py index 30ac90c..bb0c4d8 100644 --- a/tests/validations_test.py +++ b/tests/validations_test.py @@ -1,4 +1,4 @@ -from deckhouse import hook +from deckhouse import hook, validations def run(handler): @@ -14,13 +14,41 @@ def test_allow_with_warnings(): assert data == [{"allowed": True, "warnings": ("first", "second")}] +def test_allow_with_a_list_of_warnings(): + data = run(lambda ctx: ctx.output.validations.allow(["first", "second"], "third")) + assert data == [{"allowed": True, "warnings": ("first", "second", "third")}] + + +def test_allow_drops_empty_warnings(): + for warnings in [(None,), ("",), ([],), ([None, ""],)]: + assert run(lambda ctx: ctx.output.validations.allow(*warnings)) == [{"allowed": True}] + + def test_deny(): data = run(lambda ctx: ctx.output.validations.deny("not allowed")) assert data == [{"allowed": False, "message": "not allowed"}] +def test_deny_joins_a_list_of_messages(): + data = run(lambda ctx: ctx.output.validations.deny(["first", "", "second"])) + assert data == [{"allowed": False, "message": "first; second"}] + + +def test_deny_without_a_message(): + for message in [None, "", []]: + assert run(lambda ctx: ctx.output.validations.deny(message)) == [{"allowed": False}] + + def test_error_is_a_denial_with_the_message(): # shell-operator reads the reason from "message"; any other key is dropped and the # requester sees a denial without a reason. data = run(lambda ctx: ctx.output.validations.error("snapshot is missing")) assert data == [{"allowed": False, "message": "Internal webhook error: snapshot is missing"}] + + +def test_handle_answers_with_the_result_of_validate(): + def main(ctx): + validations.handle(ctx, lambda bc: (bc.warnings, True)) + + data = hook.testrun(main, [{"warnings": ["first", "second"]}]).validations.data + assert data == [{"allowed": True, "warnings": ("first", "second")}] diff --git a/tests/webhooks_test.py b/tests/webhooks_test.py new file mode 100644 index 0000000..d4c2ef2 --- /dev/null +++ b/tests/webhooks_test.py @@ -0,0 +1,79 @@ +import textwrap + +from deckhouse import hook, tests + + +def write(tmp_path, name, source): + path = tmp_path / name + path.write_text(textwrap.dedent(source)) + return path + + +def validations(module, binding_context): + return hook.testrun(module.main, [binding_context]).validations.data + + +def test_validation_webhook_passes_the_binding_context_as_dotmap(tmp_path): + path = write(tmp_path, "check.py", ''' + import typing + + def validate(ctx: DotMap) -> typing.Tuple[Optional[str], bool]: + if ctx.review.request.name == "bad": + return "bad name", False + return None, True + ''') + module = tests.load_validation_webhook(path) + assert validations(module, {"review": {"request": {"name": "good"}}}) == [{"allowed": True}] + assert validations(module, {"review": {"request": {"name": "bad"}}}) == [{"allowed": False, "message": "bad name"}] + + +def test_validation_webhook_joins_files_in_order(tmp_path): + first = write(tmp_path, "common.py", ''' + WARNINGS = ["first", "second"] + ''') + second = write(tmp_path, "check.py", ''' + def validate(ctx): + return WARNINGS, True + ''') + module = tests.load_validation_webhook(first, second) + assert validations(module, {}) == [{"allowed": True, "warnings": ("first", "second")}] + + +def test_validation_webhook_turns_an_exception_into_an_error(tmp_path): + path = write(tmp_path, "check.py", ''' + def validate(ctx): + raise KeyError("spec") + ''') + data = validations(tests.load_validation_webhook(path), {}) + assert data == [{"allowed": False, "message": "Internal webhook error: 'spec'"}] + + +def test_validation_webhook_drops_full_objects_from_snapshots(tmp_path): + # webhook-operator sets keepFullObjectsInMemory: false, so the hook sees filterResult only. + path = write(tmp_path, "check.py", ''' + def validate(ctx): + item = ctx.snapshots.pods[0] + return None, "object" not in item.toDict() and item.filterResult == 1 + ''') + binding_context = {"snapshots": {"pods": [{"object": {"kind": "Pod"}, "filterResult": 1}]}} + assert validations(tests.load_validation_webhook(path), binding_context) == [{"allowed": True}] + + +def test_conversion_webhook(tmp_path): + path = write(tmp_path, "conversion.py", ''' + def v1alpha1_to_v1(self, o: dict) -> typing.Tuple[typing.Optional[str], dict]: + """A docstring + on two lines.""" + o["apiVersion"] = "deckhouse.io/v1" + return None, o + ''') + module = tests.load_conversion_webhook("things.deckhouse.io", [("v1alpha1", "v1")], path) + assert "fromVersion: deckhouse.io/v1alpha1" in module.config + + out = hook.testrun(module.main, [{ + "binding": "v1alpha1_to_v1", + "fromVersion": "deckhouse.io/v1alpha1", + "toVersion": "deckhouse.io/v1", + "review": {"request": {"objects": [{"apiVersion": "deckhouse.io/v1alpha1", "kind": "Thing"}]}}, + }]) + assert out.conversions.data == [{"convertedObjects": [{"apiVersion": "deckhouse.io/v1", "kind": "Thing"}]}]