]> git.ipfire.org Git - thirdparty/knot-resolver.git/commitdiff
manager: config API: new query capability inspired by Caddy's HTTP API
authorVasek Sraier <git@vakabus.cz>
Sun, 7 Aug 2022 15:15:12 +0000 (17:15 +0200)
committerAleš Mrázek <ales.mrazek@nic.cz>
Thu, 25 Aug 2022 12:21:05 +0000 (14:21 +0200)
manager/knot_resolver_manager/server.py
manager/knot_resolver_manager/utils/modeling/__init__.py
manager/knot_resolver_manager/utils/modeling/parsing.py
manager/knot_resolver_manager/utils/modeling/query.py [new file with mode: 0644]
manager/tests/unit/utils/modeling/test_base_schema.py
manager/tests/unit/utils/modeling/test_parsing.py [new file with mode: 0644]
manager/tests/unit/utils/modeling/test_query.py [new file with mode: 0644]

index ff8da5b65eef435367e820f91845ab3ba28b445f..38851a456a711a16550182f9ef54a3d78c3b87f8 100644 (file)
@@ -7,13 +7,14 @@ import sys
 from http import HTTPStatus
 from pathlib import Path
 from time import time
-from typing import Any, Optional, Set, Union
+from typing import Any, Optional, Set, Union, cast
 
-from aiohttp import web
+from aiohttp import ETag, web
 from aiohttp.web import middleware
 from aiohttp.web_app import Application
 from aiohttp.web_response import json_response
 from aiohttp.web_runner import AppRunner, TCPSite, UnixSite
+from typing_extensions import Literal
 
 import knot_resolver_manager.utils.custom_atexit as atexit
 from knot_resolver_manager import log, statistics
@@ -160,25 +161,33 @@ class Server:
         )
 
     @statistics.async_timing_histogram(statistics.MANAGER_REQUEST_RECONFIGURE_LATENCY)
-    async def _handler_apply_config(self, request: web.Request) -> web.Response:
+    async def _handler_config_query(self, request: web.Request) -> web.Response:
         """
         Route handler for changing resolver configuration
         """
 
         # parse the incoming data
         document_path = request.match_info["path"]
+        etags = request.if_match
         last: ParsedTree = self.config_store.get().get_unparsed_data()
-        new_partial: ParsedTree = parse(await request.text(), request.content_type)
-        config = last.update(document_path, new_partial)
+        update_with: ParsedTree = parse(await request.text(), request.content_type)
+
+        if etags is not None and last.etag not in map(str, etags):
+            return web.Response(status=HTTPStatus.PRECONDITION_FAILED)
+
+        op = cast(Literal["get", "post", "delete", "patch", "put"], request.method.lower())
+        root, to_return = last.query(op, document_path, update_with)
 
         # validate config
-        config_validated = KresConfig(config)
+        config_validated = KresConfig(root)
 
         # apply config
         await self.config_store.update(config_validated)
 
         # return success
-        return web.Response()
+        res = web.Response(status=HTTPStatus.OK, text=str(to_return))
+        res.etag = ETag(config_validated.get_unparsed_data().etag)
+        return res
 
     async def _handler_metrics(self, _request: web.Request) -> web.Response:
         return web.Response(
@@ -232,7 +241,11 @@ class Server:
         self.app.add_routes(
             [
                 web.get("/", self._handler_index),
-                web.post(r"/config{path:.*}", self._handler_apply_config),
+                web.post(r"/config{path:.*}", self._handler_config_query),
+                web.put(r"/config{path:.*}", self._handler_config_query),
+                web.patch(r"/config{path:.*}", self._handler_config_query),
+                web.get(r"/config{path:.*}", self._handler_config_query),
+                web.delete(r"/config{path:.*}", self._handler_config_query),
                 web.post("/stop", self._handler_stop),
                 web.get("/schema", self._handler_schema),
                 web.get("/schema/ui", self._handle_view_schema),
index 24545f703992a97fa8f5d485ccf83f315fd7ecad..d174b7ce72d6d4941f601ea8e0a30f17d6217564 100644 (file)
@@ -1,6 +1,7 @@
 from .base_schema import BaseSchema
 from .base_value_type import BaseValueType
 from .parsing import ParsedTree, parse, parse_json, parse_yaml
+from .query import QueryTree
 
 __all__ = [
     "BaseValueType",
@@ -9,4 +10,5 @@ __all__ = [
     "parse",
     "parse_yaml",
     "parse_json",
+    "QueryTree",
 ]
index e3812456109d0d893b86fdc75a9ec132f7da4c23..ec14809ca98ca76e442a68bfeb758e854bbb483e 100644 (file)
@@ -1,15 +1,17 @@
-import copy
+import base64
 import json
-import re
 from enum import Enum, auto
+from hashlib import blake2b
 from typing import Any, Dict, List, Optional, Set, Tuple, Union
 
 import yaml
+from typing_extensions import Literal
 from yaml.constructor import ConstructorError
 from yaml.nodes import MappingNode
 
+from knot_resolver_manager.utils.modeling.query import QueryTree
+
 from .exceptions import DataParsingError
-from .types import is_internal_field_name
 
 
 class ParsedTree:
@@ -32,10 +34,10 @@ class ParsedTree:
             return name.replace("-", "_")
         return name
 
-    def __init__(self, data: Union[Dict[str, Any], str, int, bool]):
+    def __init__(self, data: Union[Dict[str, Any], str, int, bool, List[Any]]):
         self._data = data
 
-    def to_raw(self) -> Union[Dict[str, Any], str, int, bool]:
+    def to_raw(self) -> Union[Dict[str, Any], str, int, bool, List[Any]]:
         return self._data
 
     def __getitem__(self, key: str) -> Any:
@@ -53,54 +55,22 @@ class ParsedTree:
         assert isinstance(self._data, dict)
         return {ParsedTree._convert_external_field_name_to_internal(key) for key in self._data.keys()}
 
-    _SUBTREE_MUTATION_PATH_PATTERN = re.compile(r"^(/[^/]+)*/?$")
-
-    def update(self, path: str, data: "ParsedTree") -> "ParsedTree":
-
-        # prepare and validate the path object
-        path = path[:-1] if path.endswith("/") else path
-        if re.match(ParsedTree._SUBTREE_MUTATION_PATH_PATTERN, path) is None:
-            raise DataParsingError("Provided object path for mutation is invalid.")
-        if "_" in path:
-            raise DataParsingError("Provided object path contains character '_', which is illegal")
-        # Note: mutation happens on the internal dict only, therefore we are working with external
-        # naming only. That means, there are '-' in between words.
-        path = path[1:] if path.startswith("/") else path
-
-        # now, the path variable should contain '/' separated field names
-
-        # check if we should mutate whole object
-        if path == "":
-            return data
-
-        # find the subtree we will replace in a copy of the original object
-        to_mutate = copy.deepcopy(self.to_raw())
-        obj = to_mutate
-        parent = None
-
-        for segment in path.split("/"):
-            assert isinstance(obj, dict)
-
-            if segment == "":
-                raise DataParsingError(f"Unexpectedly empty segment in path '{path}'")
-            elif is_internal_field_name(segment):
-                raise DataParsingError(
-                    "No, changing internal fields (starting with _) is not allowed. Nice try though."
-                )
-            elif segment in obj:
-                parent = obj
-                obj = obj[segment]
-            elif segment not in obj:
-                parent = obj
-                obj = {}
-                parent[segment] = obj
-        assert parent is not None
-
-        # assign the subtree
-        last_name = path.split("/")[-1]
-        parent[last_name] = data.to_raw()
-
-        return ParsedTree(to_mutate)
+    def query(
+        self,
+        op: Literal["get", "post", "delete", "patch", "put"],
+        path: str,
+        update_with: Optional["ParsedTree"] = None,
+    ) -> "Tuple[ParsedTree, Optional[ParsedTree]]":
+        new_root, res = QueryTree(self._data).query(
+            op, path, update_with=QueryTree(update_with.to_raw()) if update_with is not None else None
+        )
+        return ParsedTree(new_root.to_raw()), ParsedTree(res.to_raw()) if res is not None else None
+
+    @property
+    def etag(self) -> str:
+        m = blake2b(digest_size=9)
+        m.update(json.dumps(self._data, sort_keys=True).encode("utf8"))
+        return base64.urlsafe_b64encode(m.digest()).decode("utf8")
 
 
 # custom hook for 'json.loads()' to detect duplicate keys in data
diff --git a/manager/knot_resolver_manager/utils/modeling/query.py b/manager/knot_resolver_manager/utils/modeling/query.py
new file mode 100644 (file)
index 0000000..25d2fba
--- /dev/null
@@ -0,0 +1,268 @@
+import copy
+import json
+import re
+from typing import Any, Dict, List, Optional, Set, Tuple, Union
+
+from typing_extensions import Literal
+
+from knot_resolver_manager.utils.modeling.exceptions import DataParsingError
+
+
+class QueryTree:
+    """
+    Simple wrapper for raw data which allows modification queries to be run on top.
+
+    IMMUTABLE, DO NOT MODIFY
+    """
+
+    def is_scalar(self) -> bool:
+        """
+        true if the object represents a primitive type
+        """
+        return isinstance(self._data, (str, int, bool))
+
+    def is_object(self) -> bool:
+        """
+        true if the object represents a list or dict
+        """
+        return isinstance(self._data, (list, dict))
+
+    def _is_list(self) -> bool:
+        return isinstance(self._data, list)
+
+    def _is_dict(self) -> bool:
+        return isinstance(self._data, dict)
+
+    def _upsert(self, key: str, value: "QueryTree") -> None:
+        """
+        WARNING!!! MUTATES THE TREE
+
+        update or insert
+        """
+        if isinstance(self._data, dict):
+            self._data[key] = value.to_raw()
+        elif isinstance(self._data, list):
+            if key in self:
+                self._data[int(key)] = value.to_raw()
+            else:
+                raise DataParsingError("query invalid: can't set a value of an item in a list at a non-existent index")
+        else:
+            assert False, "this should never happen"
+
+    def _append(self, value: "QueryTree") -> None:
+        """
+        WARNING!!! MUTATES THE TREE
+
+        append to a list
+        """
+        assert isinstance(self._data, list)
+        self._data.append(value.to_raw())
+
+    def _delete(self, key: str) -> None:
+        """
+        WARNING!!! MUTATES THE TREE
+
+        deletes a key
+        """
+        assert self.is_object()
+        if isinstance(self._data, list):
+            del self._data[int(key)]
+        elif isinstance(self._data, dict):
+            del self._data[key]
+        else:
+            assert False, "never happens"
+
+    def value(self) -> Union[str, int, bool]:
+        if self.is_object():
+            raise DataParsingError("attempted to access object as a scalar")
+
+        assert isinstance(self._data, (str, int, bool))  # make type checker happy
+        return self._data
+
+    def __init__(self, data: Union[Dict[str, Any], str, int, bool, List[Any]]):
+        self._data = data
+
+    def to_raw(self) -> Union[Dict[str, Any], str, int, bool, List[Any]]:
+        return self._data
+
+    def __getitem__(self, key: Union[str, int]) -> "QueryTree":
+        if self.is_scalar():
+            raise DataParsingError(f"attempted to access scalar value '{self._data}' as an object type")
+
+        if isinstance(self._data, list):
+            return QueryTree(self._data[int(key)])
+        elif isinstance(self._data, dict):
+            return QueryTree(self._data[str(key)])
+        else:
+            raise RuntimeError("unexpected type in self._data, this should never happen")
+
+    def __contains__(self, key: Union[str, int]) -> bool:
+        if self.is_scalar():
+            raise DataParsingError(f"attempted to access scalar value '{self._data}' as an object type")
+
+        if isinstance(self._data, list):
+            return int(key) < len(self._data)
+        elif isinstance(self._data, dict):
+            return key in self._data
+        else:
+            raise RuntimeError("unexpected type in self._data, this should never happen")
+
+    def __str__(self) -> str:
+        return json.dumps(self._data, sort_keys=False, indent=2)
+
+    def keys(self) -> Set[Any]:
+        if self.is_scalar():
+            raise DataParsingError(f"attempted to access scalar value '{self._data}' as an object type")
+
+        if isinstance(self._data, dict):
+            return set(self._data.keys())
+        elif isinstance(self._data, list):
+            return set(range(len(self._data)))
+        else:
+            raise RuntimeError("unexpected type in self._data, this should never happen")
+
+    _SUBTREE_MUTATION_PATH_PATTERN = re.compile(r"^(/[^/]+)*/?$")
+
+    def _preprocess_query_path(self, path: str) -> str:
+        # prepare and validate the path object
+        path = path[:-1] if path.endswith("/") else path
+        if re.match(QueryTree._SUBTREE_MUTATION_PATH_PATTERN, path) is None:
+            raise DataParsingError("Provided object path for mutation is invalid.")
+        if "_" in path:
+            raise DataParsingError("Provided object path contains character '_', which is illegal")
+
+        # now, the path variable should contain '/' separated field names
+        return path.strip("/")
+
+    def _copy_and_find(self, path: str) -> Tuple["QueryTree", "QueryTree", Optional["QueryTree"], str]:
+        """
+        Returns (fakeroot, parent, Optional[queryTarget])
+
+        - fakeroot has the real root in a field called 'root'
+        - queryTarget is None, when it refers to non-existent object
+        """
+
+        path = self._preprocess_query_path(path)
+
+        # `self` is considered immutable, do all operations on a copy
+        rwcopy = copy.deepcopy(self)
+        # make a fake root, so that we do not have to handle special cases for root node
+        rwcopy._data = {"root": rwcopy._data}  # pylint: disable=protected-access
+        segments = f"root/{path}".strip("/").split("/")
+
+        # walk the tree
+        obj: Optional[QueryTree] = rwcopy
+        parent: QueryTree = rwcopy
+        segment = ""  # just to make type checker happy
+        for segment in segments:
+            assert len(segment) > 0
+            if obj is None:
+                raise DataParsingError(
+                    f"query path does not point to any existing object in the configuration tree, first missing path segment is called '{segment}'"
+                )
+            elif segment in obj:
+                parent = obj
+                obj = obj[segment]
+            else:
+                parent = obj
+                obj = None
+
+        return rwcopy, parent, obj, segment
+
+    @staticmethod
+    def _post(
+        fakeroot: "QueryTree",
+        parent: "QueryTree",
+        obj: Optional["QueryTree"],
+        name: str,
+        update_with: Optional["QueryTree"] = None,
+    ) -> "Tuple[QueryTree, Optional[QueryTree]]":
+        # pylint: disable=protected-access
+        if update_with is None:
+            raise DataParsingError("query invalid: can't request a change via POST and not provide a value")
+        if parent._is_dict():
+            parent._upsert(name, update_with)
+            return fakeroot["root"], None
+        elif parent._is_list():
+            if obj is None:
+                parent._append(update_with)
+                return fakeroot["root"], None
+            else:
+                parent._upsert(name, update_with)
+                return fakeroot["root"], None
+        else:
+            assert False, "this should never happen"
+
+    @staticmethod
+    def _patch(
+        fakeroot: "QueryTree",
+        parent: "QueryTree",
+        obj: Optional["QueryTree"],
+        name: str,
+        update_with: Optional["QueryTree"] = None,
+    ) -> "Tuple[QueryTree, Optional[QueryTree]]":
+        # pylint: disable=protected-access
+        if update_with is None:
+            raise DataParsingError("query invalid: can't request a change via PATCH and not provide a value")
+        if obj is None:
+            raise DataParsingError("query error: can't update non-existent object")
+        else:
+            parent._upsert(name, update_with)
+            return fakeroot["root"], None
+
+    @staticmethod
+    def _put(
+        fakeroot: "QueryTree",
+        parent: "QueryTree",
+        obj: Optional["QueryTree"],
+        name: str,
+        update_with: Optional["QueryTree"] = None,
+    ) -> "Tuple[QueryTree, Optional[QueryTree]]":
+        # pylint: disable=protected-access
+        if update_with is None:
+            raise DataParsingError("query invalid: can't request an insert via PUT and not provide a value")
+        if obj is None:
+            if parent._is_list():
+                parent._append(update_with)
+                return fakeroot["root"], None
+            elif parent._is_dict():
+                parent._upsert(name, update_with)
+                return fakeroot["root"], None
+            else:
+                assert False, "never happens"
+        else:
+            raise DataParsingError("query invalid: can't insert when there is already a value there")
+
+    def query(
+        self, op: Literal["get", "post", "delete", "patch", "put"], path: str, update_with: Optional["QueryTree"] = None
+    ) -> "Tuple[QueryTree, Optional[QueryTree]]":
+        """
+        Implements a modification API in the style of Caddy:
+            https://caddyserver.com/docs/api
+        """
+        # pylint: disable=protected-access
+        fakeroot, parent, obj, name = self._copy_and_find(path)
+
+        # get = return what the path selector picks
+        if op == "get":
+            return fakeroot["root"], obj
+
+        # post = set value at a key, append to lists
+        elif op == "post":
+            return self._post(fakeroot, parent, obj, name, update_with)
+
+        # delete = remove the given key
+        elif op == "delete":
+            parent._delete(name)
+            return fakeroot["root"], None
+
+        # patch = update an existing object
+        elif op == "patch":
+            return self._patch(fakeroot, parent, obj, name, update_with)
+
+        # put = insert and never replace
+        elif op == "put":
+            return self._put(fakeroot, parent, obj, name, update_with)
+
+        else:
+            assert False, "invalid operation"
index f25de7f0fc2d7b3fc5e5e22d2dd9ed6ee90a0fd4..c68c8c6f1359e81bfca07692feff7a6914dad1c1 100644 (file)
@@ -118,66 +118,6 @@ o:
     assert o.o == {"key1": "str1", "key2": "str2"}
 
 
-def test_partial_mutations():
-    class InnerSchema(BaseSchema):
-        size: int = 5
-
-    class ConfPreviousSchema(BaseSchema):
-        workers: Union[Literal["auto"], int] = 1
-        lua_config: Optional[str] = None
-        inner: InnerSchema = InnerSchema()
-
-    class ConfSchema(BaseSchema):
-        _LAYER = ConfPreviousSchema
-
-        workers: int
-        lua_config: Optional[str]
-        inner: InnerSchema
-
-        def _workers(self, obj: Any) -> Any:
-            if "workers" in obj and obj["workers"] == "auto":
-                return 8
-            return obj["workers"]
-
-        def _validate(self) -> None:
-            if self.workers < 0:
-                raise ValueError("Number of workers must be non-negative")
-
-    yaml = """
-    workers: auto
-    lua-config: something
-    """
-
-    d = parse_yaml(yaml)
-    o = ConfSchema(d)
-    assert o.lua_config == "something"
-    assert o.inner.size == 5
-    assert o.workers == 8
-
-    # replacement of 'lua-config' attribute
-    upd = d.update("/lua-config", parse_json('"new_value"'))
-    o = ConfSchema(upd)
-    assert o.lua_config == "new_value"
-    assert o.inner.size == 5
-    assert o.workers == 8
-
-    # replacement of the whole tree
-    o = ConfSchema(d.update("/", parse_json('{"inner": {"size": 55}}')))
-    assert o.lua_config is None
-    assert o.workers == 1
-    assert o.inner.size == 55
-
-    # replacement of 'inner' subtree
-    o = ConfSchema(d.update("/inner", parse_json('{"size": 33}')))
-    assert o.lua_config == "something"
-    assert o.workers == 8
-    assert o.inner.size == 33
-
-    # raise validation DataValidationError
-    with raises(DataValidationError):
-        o = ConfSchema(d.update("/", parse_json('{"workers": -5}')))
-
-
 def test_dash_conversion():
     class TestSchema(BaseSchema):
         awesome_field: Dict[str, str]
diff --git a/manager/tests/unit/utils/modeling/test_parsing.py b/manager/tests/unit/utils/modeling/test_parsing.py
new file mode 100644 (file)
index 0000000..21cce7a
--- /dev/null
@@ -0,0 +1,15 @@
+from pyparsing import empty
+
+from knot_resolver_manager.utils.modeling import ParsedTree
+
+
+def test_etag():
+    empty1 = ParsedTree({})
+    empty2 = ParsedTree({})
+
+    assert empty1.etag == empty2.etag
+
+    something1 = ParsedTree({"something": 1})
+    something2 = ParsedTree({"something": 2})
+    assert empty1.etag != something1.etag
+    assert something1.etag != something2.etag
diff --git a/manager/tests/unit/utils/modeling/test_query.py b/manager/tests/unit/utils/modeling/test_query.py
new file mode 100644 (file)
index 0000000..691b83c
--- /dev/null
@@ -0,0 +1,77 @@
+from typing import List, Optional
+
+from pytest import raises
+
+from knot_resolver_manager.utils.modeling.base_schema import BaseSchema
+from knot_resolver_manager.utils.modeling.exceptions import DataValidationError
+from knot_resolver_manager.utils.modeling.parsing import parse_json, parse_yaml
+
+
+class InnerSchema(BaseSchema):
+    size: int = 5
+    lst: Optional[List[int]]
+
+
+class ConfSchema(BaseSchema):
+    workers: int
+    lua_config: Optional[str]
+    inner: InnerSchema = InnerSchema()
+
+    def _validate(self) -> None:
+        super()._validate()
+        if self.workers < 0:
+            raise DataValidationError("ee", "/workers")
+
+
+YAML = """
+workers: 1
+lua-config: something
+"""
+REF = parse_yaml(YAML)
+
+
+def test_patch():
+    o = ConfSchema(REF)
+    assert o.lua_config == "something"
+    assert o.workers == 1
+    assert o.inner.size == 5
+
+    # replacement of 'lua-config' attribute
+    upd, _resp = REF.query("patch", "/lua-config", parse_json('"new_value"'))
+    o = ConfSchema(upd)
+    assert o.lua_config == "new_value"
+    assert o.inner.size == 5
+    assert o.workers == 1
+
+    # replacement of the whole tree
+    upd, _resp = REF.query("patch", "/", parse_json('{"inner": {"size": 55}, "workers": 8}'))
+    o = ConfSchema(upd)
+    assert o.lua_config is None
+    assert o.workers == 8
+    assert o.inner.size == 55
+
+    # raise validation DataValidationError
+    with raises(DataValidationError):
+        upd, _resp = REF.query("patch", "/", parse_json('{"workers": -5}'))
+        o = ConfSchema(upd)
+
+
+def test_put_and_delete():
+    # insert 'inner' subtree
+    upd, _resp = REF.query("put", "/inner", parse_json('{"size": 33}'))
+    o = ConfSchema(upd)
+    assert o.lua_config == "something"
+    assert o.workers == 1
+    assert o.inner.size == 33
+
+    upd, _resp = upd.query("put", "/inner/lst", parse_json("[1,2,3]"))
+    o = ConfSchema(upd)
+    assert tuple(o.inner.lst or []) == tuple([1, 2, 3])
+
+    upd, _resp = upd.query("delete", "/inner/lst/1")
+    o = ConfSchema(upd)
+    assert tuple(o.inner.lst or []) == tuple([1, 3])
+
+    upd, _resp = upd.query("delete", "/inner/lst")
+    o = ConfSchema(upd)
+    assert o.inner.lst is None