From f31b35a246bdcdc773d4c6fdecabcac8286380be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Ca=C3=B1uelo?= Date: Fri, 9 Feb 2024 16:40:07 +0100 Subject: [PATCH 1/6] api/models.py: add an utility function (is_test_suite) for test nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Ricardo Cañuelo --- kernelci/api/models.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/kernelci/api/models.py b/kernelci/api/models.py index 589d73ee42..164d6930a6 100644 --- a/kernelci/api/models.py +++ b/kernelci/api/models.py @@ -426,6 +426,12 @@ class Test(Node): description="Test details" ) + def is_test_suite(self): + """Returns True if the node represents a test suite, false + otherwise (test case) + """ + return self.name == self.group + class RegressionData(BaseModel): """Model for the data field of a Regression node""" From 985d0048669e2a11a81b2ca20216c21585eb744b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Ca=C3=B1uelo?= Date: Tue, 20 Feb 2024 12:57:27 +0100 Subject: [PATCH 2/6] kernel.api.models: make parse_node_obj more flexible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow it to take the node parameter either as a Node object, as a concrete Node submodel object or as a dict. Signed-off-by: Ricardo Cañuelo --- kernelci/api/models.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/kernelci/api/models.py b/kernelci/api/models.py index 164d6930a6..2b1086264e 100644 --- a/kernelci/api/models.py +++ b/kernelci/api/models.py @@ -572,11 +572,20 @@ class PublishEvent(BaseModel): ) -def parse_node_obj(node: Node): +def parse_node_obj(node: Node | dict): """Parses a generic Node object using the appropriate Node submodel depending on its 'kind'. + + If the node is passed as a Node object, it returns the appropriate + submodel object. + If it's passed as a dict, it's converted to the appropriate Node + submodel object. + If it's passed as a concrete Node submodel object, it's returned as + is. """ - for submodel in type(node).__subclasses__(): + if isinstance(node, dict): + node = Node.parse_obj(node) + for submodel in Node.__subclasses__(): if node.kind == submodel.class_kind: return submodel.parse_obj(node) raise ValueError(f"Unsupported node kind: {node.kind}") From f92c639b99de42452ea5c7bad79e51cd8dd52330 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Ca=C3=B1uelo?= Date: Fri, 9 Feb 2024 16:44:20 +0100 Subject: [PATCH 3/6] kernelci.api.helper: add get_node_obj function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This function is meant to be used by code issuing API requests (such as a pipeline service) to turn node dicts into objects with resolved references to linked nodes. Signed-off-by: Ricardo Cañuelo --- kernelci/api/helper.py | 42 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/kernelci/api/helper.py b/kernelci/api/helper.py index f5778412b0..2d77c40f14 100644 --- a/kernelci/api/helper.py +++ b/kernelci/api/helper.py @@ -10,7 +10,7 @@ import json import requests -from . import API +from . import API, models def merge(primary: dict, secondary: dict): @@ -246,6 +246,46 @@ def submit_results(self, results, root): except requests.exceptions.HTTPError as error: raise RuntimeError(json.loads(error.response.text)) from error + def get_node_obj(self, node_dict, get_linked=False): + """Takes a dict defining a Node and returns it as a concrete + Node object (or Node subtype object). If get_linked is set to + True, linked nodes are vivified (not recursively). + + It will also accept a Node (or subclass) object instead of a + dict. This can be used to fetch and vivify the linked objects if + get_linked is set to True. + """ + # pylint: disable=protected-access + def get_attr(obj, attr): + """Similar behavior to the builtin getattr, but it can be + used with nested attributes in.dot.notation + """ + fields = attr.split('.') + if len(fields) == 1: + return getattr(obj, fields[0]) + return get_attr(getattr(obj, fields[0]), '.'.join(fields[1:])) + + def set_attr(obj, attr, value): + """Similar behavior to the builtin setattr, but it can be + used with nested attributes in.dot.notation + """ + fields = attr.split('.') + if len(fields) == 1: + setattr(obj, fields[0], value) + return + obj = get_attr(obj, '.'.join(fields[:-1])) + setattr(obj, fields[-1], value) + + node_obj = models.parse_node_obj(node_dict) + if get_linked: + for linked_node_attr in node_obj._OBJECT_ID_FIELDS: + node_id = get_attr(node_obj, linked_node_attr) + if node_id: + resp = self.api.node.get(node_id) + linked_obj = models.parse_node_obj(resp) + set_attr(node_obj, linked_node_attr, linked_obj) + return node_obj + @classmethod def load_json(cls, json_path, encoding='utf-8'): """Read content from JSON file""" From e8cc6b9306b22546733b245256e3d4f00f6957b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Ca=C3=B1uelo?= Date: Wed, 21 Feb 2024 10:27:21 +0100 Subject: [PATCH 4/6] Fix mypy-pydantic incompatibilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since I don't think we can use use the mypy pydantic plugin (https://docs.pydantic.dev/latest/integrations/mypy/) unless we're using the latest version of pydantic, I set the recommended mypy flags from that page. The purpose is to fix certain wrong mypy assumptions over pydantic models that were causing the checks to fail for kernelci.api.helper. Signed-off-by: Ricardo Cañuelo --- Makefile | 4 +++- requirements.txt | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 2c7cf6b60c..c91a5e5705 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,9 @@ test: \ validate-yaml mypy: - mypy \ + mypy --ignore-missing-imports \ + --follow-imports=skip \ + --strict-optional \ -m kernelci.api \ -m kernelci.api.latest \ -m kernelci.api.helper diff --git a/requirements.txt b/requirements.txt index 01acc59bab..e053862e72 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,3 +13,4 @@ pyyaml==6.0 requests==2.31.0 scp==0.14.5 toml==0.10.2 +pymongo-stubs==0.2.0 From f7c2e7f791be2216d40483c0a6702d506cb73ff3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Ca=C3=B1uelo?= Date: Thu, 22 Feb 2024 10:22:30 +0100 Subject: [PATCH 5/6] kernelci.api.helper: enhance submit_regression() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make this function create the regression from the two nodes that define the breaking point (the fail node and the previous pass node) and then submit it. This abstracts all the model details and the logic behind the regression generation away from the user code. Signed-off-by: Ricardo Cañuelo --- kernelci/api/helper.py | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/kernelci/api/helper.py b/kernelci/api/helper.py index 2d77c40f14..e5f5075c70 100644 --- a/kernelci/api/helper.py +++ b/kernelci/api/helper.py @@ -157,17 +157,32 @@ def create_job_node(self, job_config, input_node, except requests.exceptions.HTTPError as error: raise RuntimeError(json.loads(error.response.text)) from error - def submit_regression(self, regression): - """Post a regression object - [TODO] Leave this function in place in case we'll need any other - processing or formatting before submitting the regression node + def submit_regression(self, fail_node: dict, pass_node: dict): + """Creates and submits a regression object from two existing + nodes (test or kbuilds) passed as parameters: + + Arguments: + fail_node: dict describing the node that failed and triggered + the regression creation. + pass_node: dict describing the previous passing run of the same + test/build + + General use case: failure detected, the fail_node and pass_node + are retrieved from the API and passed to this function to define + and submit a regression. + + Returns the API request response. """ - # pylint: disable=protected-access + fail_node_obj = self.get_node_obj(fail_node) + pass_node_obj = self.get_node_obj(pass_node) + regression_dict = models.Regression.create_regression( + fail_node_obj, pass_node_obj, as_dict=True) try: - return self.api._post('node', regression) + return self.api.node.add(regression_dict) except requests.exceptions.HTTPError as error: - raise RuntimeError(error.response.text) from error + raise RuntimeError(json.loads(error.response.text)) from error + def _prepare_results(self, results, parent, base): node = results['node'].copy() From fb2556e7604e844e4e1f417f5bcf75994df57077 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Ca=C3=B1uelo?= Date: Fri, 23 Feb 2024 10:17:28 +0100 Subject: [PATCH 6/6] tests: rewrite the submit_regression test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapt the test and related mock to test the new submit_regression helper logic (regression generation and submission) against an expected result. Signed-off-by: Ricardo Cañuelo --- kernelci/api/helper.py | 2 - tests/api/conftest.py | 151 +++++++++++++++++++++++++++++++++-------- tests/api/test_api.py | 28 +++----- 3 files changed, 130 insertions(+), 51 deletions(-) diff --git a/kernelci/api/helper.py b/kernelci/api/helper.py index e5f5075c70..c58181349b 100644 --- a/kernelci/api/helper.py +++ b/kernelci/api/helper.py @@ -157,7 +157,6 @@ def create_job_node(self, job_config, input_node, except requests.exceptions.HTTPError as error: raise RuntimeError(json.loads(error.response.text)) from error - def submit_regression(self, fail_node: dict, pass_node: dict): """Creates and submits a regression object from two existing nodes (test or kbuilds) passed as parameters: @@ -183,7 +182,6 @@ def submit_regression(self, fail_node: dict, pass_node: dict): except requests.exceptions.HTTPError as error: raise RuntimeError(json.loads(error.response.text)) from error - def _prepare_results(self, results, parent, base): node = results['node'].copy() # Merge `Node.data` instead of overwriting it diff --git a/tests/api/conftest.py b/tests/api/conftest.py index bc9111bb40..54b2c5718e 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -54,30 +54,115 @@ def __init__(self): "timeout": "2022-09-28T11:05:25.814000", "holdoff": None } - self._regression_node = { - "kind": "regression", - "name": "kver", + self._pass_node = { + "kind": "test", + "name": "login", "path": [ "checkout", - "kver" + "kbuild-gcc-10-x86", + "baseline-x86", + "login" ], - "group": "kver", - "data": { - "fail_node": "636143c38f94e20c6826b0b6", - "pass_node": "636143c38f94e20c6826b0b5" - }, - "parent": "6361440f8f94e20c6826b0b7", + "group": "baseline-x86", "state": "done", "result": "pass", + "data": { + "kernel_revision": { + "tree": "kernelci", + "url": "https://github.com/kernelci/linux.git", + "branch": "staging-mainline", + "commit": "7f036eb8d7a5ff2f655c5d949343bac6a2928bce", + "describe": "staging-mainline-20220927.0", + "version": { + "version": 6, + "patchlevel": 0, + "sublevel": None, + "extra": "-rc7-36-g7f036eb8d7a5", + "name": None + } + }, + "arch": "x86_64", + "defconfig": "x86_64_defconfig", + "platform": "gcc-10", + }, "artifacts": { - "tarball": "http://staging.kernelci.org:9080/linux-kernelci\ - -staging-mainline-staging-mainline-20221101.1.tar.gz" + "tarball": ("http://staging.kernelci.org:9080/linux-kernelci" + "-staging-mainline-staging-mainline-20221101.1.tar.gz") }, "created": "2022-11-01T16:07:09.770000", "updated": "2022-11-01T16:07:09.770000", "timeout": "2022-11-02T16:07:09.770000", "holdoff": None } + self._fail_node = { + "kind": "test", + "name": "login", + "path": [ + "checkout", + "kbuild-gcc-10-x86", + "baseline-x86", + "login" + ], + "group": "baseline-x86", + "state": "done", + "result": "fail", + "data": { + "kernel_revision": { + "tree": "kernelci", + "url": "https://github.com/kernelci/linux.git", + "branch": "staging-mainline", + "commit": "7f036eb8d7a5ff2f655c5d949343bac6a2928bce", + "describe": "staging-mainline-20220927.0", + "version": { + "version": 6, + "patchlevel": 0, + "sublevel": None, + "extra": "-rc7-36-g7f036eb8d7a5", + "name": None + } + }, + "arch": "x86_64", + "defconfig": "x86_64_defconfig", + "platform": "gcc-10", + }, + "artifacts": { + "tarball": ("http://staging.kernelci.org:9080/linux-kernelci" + "-staging-mainline-staging-mainline-20221101.1.tar.gz") + }, + "created": "2022-11-02T16:07:09.770000", + "updated": "2022-11-02T16:07:09.770000", + "timeout": "2022-11-03T16:07:09.770000", + "holdoff": None + } + self._expected_regression_node = { + 'kind': 'regression', + 'name': 'login', + 'path': [ + 'checkout', + 'kbuild-gcc-10-x86', + 'baseline-x86', + 'login' + ], + 'group': 'baseline-x86', + 'state': 'done', + 'data': { + 'failed_kernel_revision': { + 'tree': 'kernelci', + 'url': 'https://github.com/kernelci/linux.git', + 'branch': 'staging-mainline', + 'commit': '7f036eb8d7a5ff2f655c5d949343bac6a2928bce', + 'describe': 'staging-mainline-20220927.0', + 'version': { + 'version': 6, + 'patchlevel': 0, + 'extra': '-rc7-36-g7f036eb8d7a5' + } + }, + 'arch': 'x86_64', + 'defconfig': 'x86_64_defconfig', + 'platform': 'gcc-10' + } + } self._kunit_node = { "id": "6332d92f1a45d41c279e7a06", "kind": "node", @@ -129,9 +214,19 @@ def checkout_node(self): return self._checkout_node @property - def regression_node(self): - """Get the regression node""" - return self._regression_node + def fail_node(self): + """Get the input fail node for a regression""" + return self._fail_node + + @property + def pass_node(self): + """Get the input pass node for a regression""" + return self._pass_node + + @property + def expected_regression_node(self): + """Get the expected regression node""" + return self._expected_regression_node @property def kunit_node(self): @@ -143,13 +238,6 @@ def kunit_child_node(self): """Get the kunit sample child node""" return self._kunit_child_node - def get_regression_node_with_id(self): - """Get regression node with node ID""" - self._regression_node.update({ - "id": "6361442d8f94e20c6826b0b9" - }) - return self._regression_node - def update_kunit_node(self): """Update kunit node with timestamp fields""" self._kunit_node.update({ @@ -227,16 +315,19 @@ def mock_api_get_node_from_id(mocker): @pytest.fixture -def mock_api_post_regression(mocker): - """Mocks call to LatestAPI class method used to submit regression node""" - resp = Response() - resp.status_code = 200 - resp._content = json.dumps( # pylint: disable=protected-access - APIHelperTestData().get_regression_node_with_id()).encode('utf-8') +def mock_api_node_add(mocker): + """Mocks call to LatestAPI Node add so that it returns the sent node + as a response (echo) + """ + def return_node_response(input_node): + resp = Response() + resp.status_code = 200 + resp._content = json.dumps(input_node).encode('utf-8') # pylint: disable=protected-access + return resp mocker.patch( - 'kernelci.api.API._post', - return_value=resp, + 'kernelci.api.latest.LatestAPI.Node.add', + side_effect=return_node_response ) diff --git a/tests/api/test_api.py b/tests/api/test_api.py index 24cd0a0007..ad781e0c07 100644 --- a/tests/api/test_api.py +++ b/tests/api/test_api.py @@ -81,31 +81,21 @@ def test_get_node_from_event(get_api_config, mock_api_get_node_from_id): } -def test_submit_regression(get_api_config, mock_api_post_regression): - """Test method to submit regression object to API""" +def test_submit_regression(get_api_config, mock_api_node_add): + """Tests the regression generation and submission done by + helper.submit_regression() + """ for _, api_config in get_api_config.items(): api = kernelci.api.get_api(api_config) helper = kernelci.api.helper.APIHelper(api) resp = helper.submit_regression( - regression=APIHelperTestData().regression_node + APIHelperTestData().fail_node, + APIHelperTestData().pass_node ) assert resp.status_code == 200 - assert resp.json().keys() == { - 'id', - 'artifacts', - 'created', - 'data', - 'group', - 'holdoff', - 'kind', - 'name', - 'path', - 'parent', - 'result', - 'state', - 'timeout', - 'updated', - } + created_regression = resp.json() + for field, expected_val in APIHelperTestData().expected_regression_node.items(): + assert created_regression[field] == expected_val def test_pubsub_event_filter_positive(get_api_config, mock_api_subscribe):