From 7a6cb3c77c2ac82148d7960555170f5a083fa990 Mon Sep 17 00:00:00 2001 From: Tobias Persson Date: Fri, 26 Jul 2024 08:44:23 +0200 Subject: [PATCH 01/19] Add Kubernetes CRD client and schemas --- src/etos_lib/kubernetes/__init__.py | 8 +- src/etos_lib/kubernetes/environment.py | 36 ++++ src/etos_lib/kubernetes/etos.py | 191 ++++++++++++++++++ src/etos_lib/kubernetes/provider.py | 33 +++ src/etos_lib/kubernetes/schemas/__init__.py | 20 ++ src/etos_lib/kubernetes/schemas/common.py | 46 +++++ .../kubernetes/schemas/environment.py | 53 +++++ .../kubernetes/schemas/environment_request.py | 37 ++++ src/etos_lib/kubernetes/schemas/provider.py | 157 ++++++++++++++ src/etos_lib/kubernetes/schemas/testrun.py | 150 ++++++++++++++ src/etos_lib/kubernetes/testrun.py | 33 +++ 11 files changed, 762 insertions(+), 2 deletions(-) create mode 100644 src/etos_lib/kubernetes/environment.py create mode 100644 src/etos_lib/kubernetes/etos.py create mode 100644 src/etos_lib/kubernetes/provider.py create mode 100644 src/etos_lib/kubernetes/schemas/__init__.py create mode 100644 src/etos_lib/kubernetes/schemas/common.py create mode 100644 src/etos_lib/kubernetes/schemas/environment.py create mode 100644 src/etos_lib/kubernetes/schemas/environment_request.py create mode 100644 src/etos_lib/kubernetes/schemas/provider.py create mode 100644 src/etos_lib/kubernetes/schemas/testrun.py create mode 100644 src/etos_lib/kubernetes/testrun.py diff --git a/src/etos_lib/kubernetes/__init__.py b/src/etos_lib/kubernetes/__init__.py index 4f4dc59..2a45e1b 100644 --- a/src/etos_lib/kubernetes/__init__.py +++ b/src/etos_lib/kubernetes/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2020 Axis Communications AB. +# Copyright Axis Communications AB. # # For a full list of individual contributors, please see the commit history. # @@ -13,5 +13,9 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -"""ETOS library kubernetes helpers.""" +"""ETOS Environment Provider Kubernetes module.""" from .jobs import Job +from .etos import Kubernetes, Resource +from .environment import Environment +from .testrun import TestRun +from .provider import Provider diff --git a/src/etos_lib/kubernetes/environment.py b/src/etos_lib/kubernetes/environment.py new file mode 100644 index 0000000..8405062 --- /dev/null +++ b/src/etos_lib/kubernetes/environment.py @@ -0,0 +1,36 @@ +# Copyright Axis Communications AB. +# +# For a full list of individual contributors, please see the commit history. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Environment custom resource manager ETOS.""" +import logging +from .etos import Kubernetes, Resource + + +class Environment(Resource): + """Environment handles the Environment custom Kubernetes resources.""" + + logger = logging.getLogger(__name__) + + def __init__(self, client: Kubernetes, strict: bool = False): + """Set up Kubernetes client. + + :param strict: If True, the client will raise exceptions when Kubernetes could not + be reached as expected such as the ETOS namespace not being able to be determined. + The default (False) will just ignore any problems. + """ + self.strict = strict + with self._catch_errors_if_not_strict(): + self.client = client.environments + self.namespace = client.namespace diff --git a/src/etos_lib/kubernetes/etos.py b/src/etos_lib/kubernetes/etos.py new file mode 100644 index 0000000..1c21d52 --- /dev/null +++ b/src/etos_lib/kubernetes/etos.py @@ -0,0 +1,191 @@ +# Copyright Axis Communications AB. +# +# For a full list of individual contributors, please see the commit history. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Kubernetes client for ETOS custom resources.""" +import os +import logging +from pathlib import Path +from contextlib import contextmanager +from typing import Optional +from pydantic import BaseModel +from kubernetes import config +from kubernetes.client import api_client +from kubernetes.client.exceptions import ApiException +from kubernetes.dynamic import DynamicClient +from kubernetes.dynamic.resource import Resource as DynamicResource, ResourceInstance +from kubernetes.dynamic.exceptions import ( + ResourceNotFoundError, + ResourceNotUniqueError, + NotFoundError, +) + +config.load_config() +NAMESPACE_FILE = Path("/var/run/secrets/kubernetes.io/serviceaccount/namespace") + + +class NoNamespace(Exception): + """NoNamespace exception is raised when ETOS could not determine the current namespace.""" + + +class Resource: + """Resource is the base resource client for ETOS custom resources.""" + + client: DynamicResource + namespace: str = "default" + strict: bool = False + __cache = {} + + @contextmanager + def _catch_errors_if_not_strict(self): + """Catch errors if the strict flag is False, else raise.""" + try: + yield + # Internal exceptions + except NoNamespace: + if self.strict: + raise + # Client exceptions + except ApiException: + if self.strict: + raise + # Dynamic client exceptions + except (ResourceNotFoundError, ResourceNotUniqueError): + if self.strict: + raise + # Built-in exceptions + except AttributeError: + # AttributeError happens if ResourceNotFoundError was raised when setting up + # clients. + if self.strict: + raise + + def __full_resource_name(self, name: str): + """Full resource name will return the group, version, namespace and kind.""" + return ( + f"{self.client.group}/{self.client.api_version}/{self.client.kind} " + f"{self.namespace}/{name}" + ) + + def get(self, name: str, cache=True) -> Optional[ResourceInstance]: + """Get a resource from Kubernetes by name. + + if Cache is set to False, then make sure to get the resource from kubernetes, + if Cache is True, then the cache will be used every time. + There is no cache invalidation! + """ + resource: Optional[ResourceInstance] = None + if cache: + resource = self.__cache.get(self.__full_resource_name(name)) + if resource is not None: + return resource + try: + with self._catch_errors_if_not_strict(): + resource = self.client.get(name=name, namespace=self.namespace) # type: ignore + if resource: + self.__cache[self.__full_resource_name(name)] = resource + except NotFoundError: + resource = None + return resource + + def delete(self, name: str) -> bool: + """Delete a resource by name.""" + with self._catch_errors_if_not_strict(): + if self.client.delete(name=name, namespace=self.namespace): # type: ignore + return True + return False + + def create(self, model: BaseModel) -> bool: + """Create a resource from a pydantic model.""" + with self._catch_errors_if_not_strict(): + if self.client.create( + body=model.model_dump(), namespace=self.namespace + ): # type: ignore + return True + return False + + def exists(self, name: str) -> bool: + """Test if a resource with name exists.""" + return self.get(name) is not None + + +class Kubernetes: + """Kubernetes is a client for fetching ETOS custom resources from Kubernetes.""" + + __providers = None + __requests = None + __testruns = None + __environments = None + __namespace = None + logger = logging.getLogger(__name__) + + def __init__(self, version="v1alpha1"): + """Initialize a dynamic client with version.""" + self.version = f"etos.eiffel-community.github.io/{version}" + self.__client = DynamicClient(api_client.ApiClient()) + + @property + def namespace(self) -> str: + """Namespace returns the current namespace of the machine this code is running on.""" + if self.__namespace is None: + if not NAMESPACE_FILE.exists(): + self.logger.warning( + "Not running in Kubernetes? Namespace file not found: %s", NAMESPACE_FILE + ) + etos_ns = os.getenv("ETOS_NAMESPACE") + if etos_ns: + self.logger.warning( + "Defauling to environment variable 'ETOS_NAMESPACE': %s", etos_ns + ) + else: + self.logger.warning("ETOS_NAMESPACE environment variable not set!") + raise NoNamespace("Failed to determine Kubernetes namespace!") + self.__namespace = etos_ns + else: + self.__namespace = NAMESPACE_FILE.read_text(encoding="utf-8") + return self.__namespace + + @property + def providers(self) -> DynamicResource: + """Providers request returns a client for Provider resources.""" + if self.__providers is None: + self.__providers = self.__client.resources.get( + api_version=self.version, kind="Provider" + ) + return self.__providers + + @property + def environment_requests(self) -> DynamicResource: + """Environment requests returns a client for EnvironmentRequest resources.""" + if self.__requests is None: + self.__requests = self.__client.resources.get( + api_version=self.version, kind="EnvironmentRequest" + ) + return self.__requests + + @property + def environments(self) -> DynamicResource: + """Environments returns a client for Environment resources.""" + if self.__environments is None: + self.__environments = self.__client.resources.get( + api_version=self.version, kind="Environment" + ) + return self.__environments + + @property + def testruns(self) -> DynamicResource: + """Testruns returns a client for TestRun resources.""" + if self.__testruns is None: + self.__testruns = self.__client.resources.get(api_version=self.version, kind="TestRun") + return self.__testruns diff --git a/src/etos_lib/kubernetes/provider.py b/src/etos_lib/kubernetes/provider.py new file mode 100644 index 0000000..b4d22ae --- /dev/null +++ b/src/etos_lib/kubernetes/provider.py @@ -0,0 +1,33 @@ +# Copyright Axis Communications AB. +# +# For a full list of individual contributors, please see the commit history. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Provider custom resource manager ETOS.""" +from .etos import Kubernetes, Resource + + +class Provider(Resource): + """Provider handles the Provider custom Kubernetes resources.""" + + def __init__(self, client: Kubernetes, strict: bool = False): + """Set up Kubernetes client. + + :param strict: If True, the client will raise exceptions when Kubernetes could not + be reached as expected such as the ETOS namespace not being able to be determined. + The default (False) will just ignore any problems. + """ + self.strict = strict + with self._catch_errors_if_not_strict(): + self.client = client.providers + self.namespace = client.namespace diff --git a/src/etos_lib/kubernetes/schemas/__init__.py b/src/etos_lib/kubernetes/schemas/__init__.py new file mode 100644 index 0000000..6186a68 --- /dev/null +++ b/src/etos_lib/kubernetes/schemas/__init__.py @@ -0,0 +1,20 @@ +# Copyright Axis Communications AB. +# +# For a full list of individual contributors, please see the commit history. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""ETOS Kubernetes schemas.""" +from .common import Metadata +from .environment import * +from .testrun import * +from .provider import * diff --git a/src/etos_lib/kubernetes/schemas/common.py b/src/etos_lib/kubernetes/schemas/common.py new file mode 100644 index 0000000..7376091 --- /dev/null +++ b/src/etos_lib/kubernetes/schemas/common.py @@ -0,0 +1,46 @@ +# Copyright Axis Communications AB. +# +# For a full list of individual contributors, please see the commit history. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Common schemas that are used between most kubernetes resources.""" +from typing import Optional +from pydantic import BaseModel + + +class OwnerReference(BaseModel): + """Owner reference describes the owner of a kubernetes resource.""" + + apiVersion: str + kind: str + name: str + uid: str + controller: Optional[bool] + blockOwnerDeletion: bool + + +class Metadata(BaseModel): + """Metadata describes the metadata of a kubernetes resource.""" + + name: Optional[str] = None + generateName: Optional[str] = None + namespace: str = "default" + uid: Optional[str] = None + ownerReferences: list[OwnerReference] = [] + + +class Image(BaseModel): + """Image is a container image representation.""" + + image: str + imagePullPolicy: str = "IfNotPresent" diff --git a/src/etos_lib/kubernetes/schemas/environment.py b/src/etos_lib/kubernetes/schemas/environment.py new file mode 100644 index 0000000..9e86b30 --- /dev/null +++ b/src/etos_lib/kubernetes/schemas/environment.py @@ -0,0 +1,53 @@ +# Copyright Axis Communications AB. +# +# For a full list of individual contributors, please see the commit history. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Models for the Environment resource.""" +from typing import Optional +from pydantic import BaseModel +from .common import Metadata +from .testrun import Test, Suite + + +class EnvironmentSpec(BaseModel): + """EnvironmentSpec is the specification of a Environment Kubernetes resource.""" + + name: str + suite_id: str + sub_suite_id: str + test_suite_started_id: str + artifact: str + context: str + priority: int = 1 + test_runner: str + recipes: list[Test] + iut: dict + executor: dict + log_area: dict + + @classmethod + def from_subsuite(cls, sub_suite: dict) -> "EnvironmentSpec": + """Create environment spec from sub suite definition.""" + sub_suite["recipes"] = Suite.tests_from_recipes(sub_suite.pop("recipes")) + spec = EnvironmentSpec(**sub_suite) + return spec + + +class Environment(BaseModel): + """Environment Kubernetes resource.""" + + apiVersion: Optional[str] = "etos.eiffel-community.github.io/v1alpha1" + kind: Optional[str] = "Environment" + metadata: Metadata + spec: EnvironmentSpec diff --git a/src/etos_lib/kubernetes/schemas/environment_request.py b/src/etos_lib/kubernetes/schemas/environment_request.py new file mode 100644 index 0000000..86da2a3 --- /dev/null +++ b/src/etos_lib/kubernetes/schemas/environment_request.py @@ -0,0 +1,37 @@ +# Copyright Axis Communications AB. +# +# For a full list of individual contributors, please see the commit history. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Models for the EnvironmentRequest resource.""" +from typing import Optional +from pydantic import BaseModel +from .common import Metadata + + +class EnvironmentRequestSpec(BaseModel): + """EnvironmentRequstSpec is the specification of an EnvironmentRequest Kubernetes resource.""" + + iut: str + logArea: str + executionSpace: str + testrun: str + + +class EnvironmentRequest(BaseModel): + """EnvironmentRequest Kubernetes resource.""" + + apiVersion: Optional[str] = "etos.eiffel-community.github.io/v1alpha1" + kind: Optional[str] = "EnvironmentRequest" + metadata: Metadata + spec: EnvironmentRequestSpec diff --git a/src/etos_lib/kubernetes/schemas/provider.py b/src/etos_lib/kubernetes/schemas/provider.py new file mode 100644 index 0000000..bb118c4 --- /dev/null +++ b/src/etos_lib/kubernetes/schemas/provider.py @@ -0,0 +1,157 @@ +# Copyright Axis Communications AB. +# +# For a full list of individual contributors, please see the commit history. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Models for the Provider resource.""" +import json +from json.decoder import JSONDecodeError +from typing import Optional, Union +from typing_extensions import Annotated +from pydantic import BaseModel +from pydantic.functional_validators import AfterValidator +from .common import Metadata + + +def json_load_str_if_possible(data: str) -> Union[dict, str]: + """Attempt to load a str as JSON or just return the str.""" + try: + return json.loads(data) + except JSONDecodeError: + return data + + +JSON = Annotated[str, AfterValidator(json_load_str_if_possible)] + + +class JSONTasList(BaseModel): + """JSONTasList describes the list part of a JSONTas provider.""" + + possible: JSON + available: JSON + + +class BaseProvider(BaseModel): + """BaseProvider describes the base parts of JSONTas providers.""" + + id: str + checkin: Optional[JSON] = None + checkout: Optional[JSON] = None + list: JSONTasList + + +class Stage(BaseModel): + """Stage is the IUT prepare stage for an IUT provider.""" + + steps: str + + +class JSONTasIutPrepareStages(BaseModel): + """JSONTasIUTPrepareStages describes the prepare stages for an IUT provider.""" + + environmentProvider: Optional[Stage] = None + suiteRunner: Optional[Stage] = None + testRunner: Optional[Stage] = None + + +class JSONTasIutPrepare(BaseModel): + """JSONTasIUTPrepare describes the prepare for an IUT provider.""" + + stages: JSONTasIutPrepareStages + + +class JSONTasIut(BaseProvider): + """JSONTasIUT describes the JSONTas specification of an IUT provider.""" + + prepare: Optional[JSONTasIutPrepare] = None + + +class JSONTasExecutionSpace(BaseProvider): + """JSONTasExecutionSpace describes the JSONTas specification of an execution space provider.""" + + +class JSONTasLogArea(BaseProvider): + """JSONTasLogArea describes the JSONTas specification of a log area provider.""" + + +class JSONTas(BaseModel): + """JSONTas describes the JSONTas specification of a provider.""" + + iut: Optional[JSONTasIut] = None + executionSpace: Optional[JSONTasExecutionSpace] = None + logArea: Optional[JSONTasLogArea] = None + + +class Healthcheck(BaseModel): + """Healthcheck describes the healthcheck rules of a provider.""" + + endpoint: str + intervalSeconds: int + + +class ProviderSpec(BaseModel): + """ProviderSpec is the specification of a Provider Kubernetes resource.""" + + type: str + host: str + healthCheck: Healthcheck + jsontas: Optional[JSONTas] = None + + +class Provider(BaseModel): + """Provider Kubernetes resource.""" + + apiVersion: Optional[str] = "etos.eiffel-community.github.io/v1alpha1" + kind: Optional[str] = "Provider" + spec: ProviderSpec + metadata: Metadata + + def to_jsontas(self) -> dict: + """To JSONTas will convert a provider to a JSONTas ruleset. + + This method is for the transition period between the current ETOS and + the kubernetes controller based ETOS. + """ + ruleset = {} + if self.spec.jsontas is not None: + if self.spec.type == "iut": + assert ( + self.spec.jsontas.iut is not None + ), "IUT must be a part of a Provider with type 'iut'." + ruleset = self.spec.jsontas.iut.model_dump() + elif self.spec.type == "execution-space": + assert ( + self.spec.jsontas.executionSpace is not None + ), "Execution space must be a part of a Provider with type 'execution-space'." + ruleset = self.spec.jsontas.executionSpace.model_dump() + elif self.spec.type == "log-area": + assert ( + self.spec.jsontas.logArea is not None + ), "Log area must be a part of a Provider with type 'log-area'." + ruleset = self.spec.jsontas.logArea.model_dump() + ruleset["id"] = self.metadata.name + return ruleset + + def to_external(self) -> dict: + """To external will convert a provider to an external provider ruleset. + + This method is for the transition period between the current ETOS and + the kubernetes controller based ETOS. + """ + return { + "id": self.metadata.name, + "type": "external", + "status": {"host": f"{self.spec.host}/status"}, + "start": {"host": f"{self.spec.host}/start"}, + "stop": {"host": f"{self.spec.host}/stop"}, + } diff --git a/src/etos_lib/kubernetes/schemas/testrun.py b/src/etos_lib/kubernetes/schemas/testrun.py new file mode 100644 index 0000000..346f81d --- /dev/null +++ b/src/etos_lib/kubernetes/schemas/testrun.py @@ -0,0 +1,150 @@ +# Copyright Axis Communications AB. +# +# For a full list of individual contributors, please see the commit history. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Models for the TestRun resource.""" +from typing import Optional, List, Any +from pydantic import BaseModel +from .common import Metadata, Image + +__all__ = ["TestRun", "TestRunSpec"] + + +class Environment(BaseModel): + """Environment describes the environment in which a test shall run. + + This is different from the `Execution.environment` field which is + used to describe the environment variables to set for the testrunner. + """ + + +class TestCase(BaseModel): + """TestCase holds meta information about a testcase to run.""" + + id: str + tracker: Optional[str] = None + uri: Optional[str] = None + version: Optional[str] = "master" + + +class Execution(BaseModel): + """Execution describes how to execute a single test case.""" + + checkout: List[str] + command: str + testRunner: str + environment: dict[str, Any] = {} + execute: List[str] = [] + parameters: dict[str, str] = {} + + +class Test(BaseModel): + """Test describes the environment and execution of a test case.""" + + id: str + environment: Environment + execution: Execution + testCase: TestCase + + +class Suite(BaseModel): + """Suite is a single test suite to execute in an ETOS testrun.""" + + name: str + priority: Optional[int] = 1 + tests: List[Test] + + @classmethod + def from_tercc(cls, suite: dict) -> "Suite": + """From tercc will create a Suite from an Eiffel TERCC event. + + A TERCC is a list of suites, this method takes a single one of those + suites. For loading multiple suites, see :method:`TestRunSpec.from_tercc` + """ + return Suite( + name=suite.get("name", "NoName"), + priority=suite.get("priority", 1), + tests=cls.tests_from_recipes(suite.get("recipes", [])), + ) + + @classmethod + def tests_from_recipes(cls, recipes: list[dict]) -> list[Test]: + """Load tests from Eiffel TERCC recipes. + + Tests from recipes will read the recipes field of an Eiffel TERCC + and create a list of Test. + """ + tests: list[Test] = [] + for recipe in recipes: + execution = {} + for constraint in recipe.get("constraints", []): + if constraint.get("key") == "ENVIRONMENT": + execution["environment"] = constraint.get("value", {}) + elif constraint.get("key") == "PARAMETERS": + execution["parameters"] = constraint.get("value", {}) + elif constraint.get("key") == "COMMAND": + execution["command"] = constraint.get("value", "") + elif constraint.get("key") == "EXECUTE": + execution["execute"] = constraint.get("value", []) + elif constraint.get("key") == "CHECKOUT": + execution["checkout"] = constraint.get("value", []) + elif constraint.get("key") == "TEST_RUNNER": + execution["testRunner"] = constraint.get("value", "") + testcase = recipe.get("testCase", {}) + if testcase.get("url") is not None: + testcase["uri"] = testcase.pop("url") + tests.append( + Test( + id=recipe.get("id", ""), + environment=Environment(), + testCase=TestCase(**testcase), + execution=Execution(**execution), + ) + ) + return tests + + +class Providers(BaseModel): + """Providers describes the providers to use for a testrun.""" + + executionSpace: Optional[str] = "default" + logArea: Optional[str] = "default" + iut: Optional[str] = "default" + + +class TestRunSpec(BaseModel): + """TestRunSpec is the specification of a TestRun Kubernetes resource.""" + + cluster: str + artifact: str + suiteRunner: Image + environmentProvider: Image + id: str + identity: str + providers: Providers + suites: List[Suite] + + @classmethod + def from_tercc(cls, tercc: list[dict]) -> list[Suite]: + """From tercc loads a list of suites from an eiffel TERCC.""" + return [Suite.from_tercc(suite) for suite in tercc] + + +class TestRun(BaseModel): + """TestRun Kubernetes resource.""" + + apiVersion: Optional[str] = "etos.eiffel-community.github.io/v1alpha1" + kind: Optional[str] = "TestRun" + metadata: Metadata + spec: TestRunSpec diff --git a/src/etos_lib/kubernetes/testrun.py b/src/etos_lib/kubernetes/testrun.py new file mode 100644 index 0000000..9621b57 --- /dev/null +++ b/src/etos_lib/kubernetes/testrun.py @@ -0,0 +1,33 @@ +# Copyright Axis Communications AB. +# +# For a full list of individual contributors, please see the commit history. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""TestRun custom resource manager ETOS.""" +from .etos import Kubernetes, Resource + + +class TestRun(Resource): + """TestRun handles the TestRun custom Kubernetes resources.""" + + def __init__(self, client: Kubernetes, strict: bool = False): + """Set up Kubernetes client. + + :param strict: If True, the client will raise exceptions when Kubernetes could not + be reached as expected such as the ETOS namespace not being able to be determined. + The default (False) will just ignore any problems. + """ + self.strict = strict + with self._catch_errors_if_not_strict(): + self.client = client.testruns + self.namespace = client.namespace From 6d3ddbdedbee489d4316f550fd3f6df8e69ee790 Mon Sep 17 00:00:00 2001 From: Tobias Persson Date: Fri, 26 Jul 2024 12:51:47 +0200 Subject: [PATCH 02/19] Add dataset as a required parameter for a suite --- src/etos_lib/kubernetes/schemas/testrun.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/etos_lib/kubernetes/schemas/testrun.py b/src/etos_lib/kubernetes/schemas/testrun.py index 346f81d..9e9a178 100644 --- a/src/etos_lib/kubernetes/schemas/testrun.py +++ b/src/etos_lib/kubernetes/schemas/testrun.py @@ -64,18 +64,22 @@ class Suite(BaseModel): name: str priority: Optional[int] = 1 tests: List[Test] + dataset: dict @classmethod - def from_tercc(cls, suite: dict) -> "Suite": + def from_tercc(cls, suite: dict, dataset: dict) -> "Suite": """From tercc will create a Suite from an Eiffel TERCC event. A TERCC is a list of suites, this method takes a single one of those suites. For loading multiple suites, see :method:`TestRunSpec.from_tercc` + Dataset is a required parameter as it is not part of the Eiffel TERCC + event. """ return Suite( name=suite.get("name", "NoName"), priority=suite.get("priority", 1), tests=cls.tests_from_recipes(suite.get("recipes", [])), + dataset=dataset, ) @classmethod @@ -136,9 +140,13 @@ class TestRunSpec(BaseModel): suites: List[Suite] @classmethod - def from_tercc(cls, tercc: list[dict]) -> list[Suite]: - """From tercc loads a list of suites from an eiffel TERCC.""" - return [Suite.from_tercc(suite) for suite in tercc] + def from_tercc(cls, tercc: list[dict], dataset: dict) -> list[Suite]: + """From tercc loads a list of suites from an eiffel TERCC. + + Dataset is a required parameter as it is not part of the Eiffel TERCC + event. + """ + return [Suite.from_tercc(suite, dataset) for suite in tercc] class TestRun(BaseModel): From 835ab10b9eb86e419fc3a99aab1ef5854b9d7fd6 Mon Sep 17 00:00:00 2001 From: Tobias Persson Date: Fri, 26 Jul 2024 12:59:59 +0200 Subject: [PATCH 03/19] Handle multiple datasets --- src/etos_lib/kubernetes/schemas/testrun.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/etos_lib/kubernetes/schemas/testrun.py b/src/etos_lib/kubernetes/schemas/testrun.py index 9e9a178..84aab28 100644 --- a/src/etos_lib/kubernetes/schemas/testrun.py +++ b/src/etos_lib/kubernetes/schemas/testrun.py @@ -14,7 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. """Models for the TestRun resource.""" -from typing import Optional, List, Any +from typing import Optional, List, Any, Union from pydantic import BaseModel from .common import Metadata, Image @@ -140,13 +140,20 @@ class TestRunSpec(BaseModel): suites: List[Suite] @classmethod - def from_tercc(cls, tercc: list[dict], dataset: dict) -> list[Suite]: + def from_tercc(cls, tercc: list[dict], datasets: Union[list[dict], dict]) -> list[Suite]: """From tercc loads a list of suites from an eiffel TERCC. Dataset is a required parameter as it is not part of the Eiffel TERCC event. """ - return [Suite.from_tercc(suite, dataset) for suite in tercc] + # This code mimics what the environment provider did before. + if isinstance(datasets, list): + assert len(datasets) == len( + tercc + ), "If multiple datasets are provided it must correspond with number of test suites" + else: + datasets = [datasets] * len(tercc) + return [Suite.from_tercc(suite, datasets.pop(0)) for suite in tercc] class TestRun(BaseModel): From eb3a2933e7f953c572026251fd41c357c719dfa9 Mon Sep 17 00:00:00 2001 From: Tobias Persson Date: Mon, 29 Jul 2024 13:46:50 +0200 Subject: [PATCH 04/19] Add log listener to schema --- src/etos_lib/kubernetes/schemas/testrun.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/etos_lib/kubernetes/schemas/testrun.py b/src/etos_lib/kubernetes/schemas/testrun.py index 84aab28..e8b1e7a 100644 --- a/src/etos_lib/kubernetes/schemas/testrun.py +++ b/src/etos_lib/kubernetes/schemas/testrun.py @@ -133,6 +133,7 @@ class TestRunSpec(BaseModel): cluster: str artifact: str suiteRunner: Image + logListener: Image environmentProvider: Image id: str identity: str From 216ece19fcff522bffdb8a48541d4edb308b720e Mon Sep 17 00:00:00 2001 From: Tobias Persson Date: Wed, 31 Jul 2024 11:52:53 +0200 Subject: [PATCH 05/19] Update provider schema --- src/etos_lib/kubernetes/schemas/provider.py | 47 +++++++-------------- 1 file changed, 16 insertions(+), 31 deletions(-) diff --git a/src/etos_lib/kubernetes/schemas/provider.py b/src/etos_lib/kubernetes/schemas/provider.py index bb118c4..c6291b3 100644 --- a/src/etos_lib/kubernetes/schemas/provider.py +++ b/src/etos_lib/kubernetes/schemas/provider.py @@ -14,54 +14,39 @@ # See the License for the specific language governing permissions and # limitations under the License. """Models for the Provider resource.""" -import json -from json.decoder import JSONDecodeError from typing import Optional, Union -from typing_extensions import Annotated from pydantic import BaseModel -from pydantic.functional_validators import AfterValidator from .common import Metadata -def json_load_str_if_possible(data: str) -> Union[dict, str]: - """Attempt to load a str as JSON or just return the str.""" - try: - return json.loads(data) - except JSONDecodeError: - return data - - -JSON = Annotated[str, AfterValidator(json_load_str_if_possible)] - - class JSONTasList(BaseModel): """JSONTasList describes the list part of a JSONTas provider.""" - possible: JSON - available: JSON + possible: dict + available: Union[dict, str] class BaseProvider(BaseModel): """BaseProvider describes the base parts of JSONTas providers.""" id: str - checkin: Optional[JSON] = None - checkout: Optional[JSON] = None + checkin: Optional[dict] = None + checkout: Optional[dict] = None list: JSONTasList class Stage(BaseModel): """Stage is the IUT prepare stage for an IUT provider.""" - steps: str + steps: dict class JSONTasIutPrepareStages(BaseModel): """JSONTasIUTPrepareStages describes the prepare stages for an IUT provider.""" - environmentProvider: Optional[Stage] = None - suiteRunner: Optional[Stage] = None - testRunner: Optional[Stage] = None + environment_provider: Optional[Stage] = None + suite_runner: Optional[Stage] = None + test_runner: Optional[Stage] = None class JSONTasIutPrepare(BaseModel): @@ -88,8 +73,8 @@ class JSONTas(BaseModel): """JSONTas describes the JSONTas specification of a provider.""" iut: Optional[JSONTasIut] = None - executionSpace: Optional[JSONTasExecutionSpace] = None - logArea: Optional[JSONTasLogArea] = None + execution_space: Optional[JSONTasExecutionSpace] = None + log: Optional[JSONTasLogArea] = None class Healthcheck(BaseModel): @@ -103,8 +88,8 @@ class ProviderSpec(BaseModel): """ProviderSpec is the specification of a Provider Kubernetes resource.""" type: str - host: str - healthCheck: Healthcheck + host: Optional[str] = None + healthCheck: Optional[Healthcheck] = None jsontas: Optional[JSONTas] = None @@ -131,14 +116,14 @@ def to_jsontas(self) -> dict: ruleset = self.spec.jsontas.iut.model_dump() elif self.spec.type == "execution-space": assert ( - self.spec.jsontas.executionSpace is not None + self.spec.jsontas.execution_space is not None ), "Execution space must be a part of a Provider with type 'execution-space'." - ruleset = self.spec.jsontas.executionSpace.model_dump() + ruleset = self.spec.jsontas.execution_space.model_dump() elif self.spec.type == "log-area": assert ( - self.spec.jsontas.logArea is not None + self.spec.jsontas.log is not None ), "Log area must be a part of a Provider with type 'log-area'." - ruleset = self.spec.jsontas.logArea.model_dump() + ruleset = self.spec.jsontas.log.model_dump() ruleset["id"] = self.metadata.name return ruleset From 9de160d397dd8637dff9b2c8d9450dfc21fcad80 Mon Sep 17 00:00:00 2001 From: Tobias Persson Date: Wed, 31 Jul 2024 12:05:40 +0200 Subject: [PATCH 06/19] Add pydantic --- pyproject.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 3bb2b6c..d57900f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "eiffellib[rabbitmq]~=2.4", "requests~=2.31", "kubernetes~=26.1", + "pydantic~=2.1", "pyyaml~=6.0", "opentelemetry-api~=1.21", "opentelemetry-semantic-conventions~=0.42b0", @@ -50,3 +51,7 @@ build_dir = "build/sphinx" [tool.devpi.upload] no-vcs = 1 formats = "bdist_wheel" + +[tool.setuptools_scm] +version_scheme = "setup:version_scheme" +local_scheme = "setup:local_scheme" From 830de0487ed21edc2e22357a77f7171fa22767d8 Mon Sep 17 00:00:00 2001 From: Tobias Persson Date: Thu, 1 Aug 2024 08:07:35 +0200 Subject: [PATCH 07/19] Steps can be an empty dict --- src/etos_lib/kubernetes/schemas/provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/etos_lib/kubernetes/schemas/provider.py b/src/etos_lib/kubernetes/schemas/provider.py index c6291b3..4bea78d 100644 --- a/src/etos_lib/kubernetes/schemas/provider.py +++ b/src/etos_lib/kubernetes/schemas/provider.py @@ -38,7 +38,7 @@ class BaseProvider(BaseModel): class Stage(BaseModel): """Stage is the IUT prepare stage for an IUT provider.""" - steps: dict + steps: dict = {} class JSONTasIutPrepareStages(BaseModel): From 8f524b192d03cc5fbe64f6f848605d6071f2da6f Mon Sep 17 00:00:00 2001 From: Tobias Persson Date: Thu, 1 Aug 2024 11:32:17 +0200 Subject: [PATCH 08/19] Add suite source to testrun schema --- src/etos_lib/kubernetes/schemas/testrun.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/etos_lib/kubernetes/schemas/testrun.py b/src/etos_lib/kubernetes/schemas/testrun.py index e8b1e7a..612a74b 100644 --- a/src/etos_lib/kubernetes/schemas/testrun.py +++ b/src/etos_lib/kubernetes/schemas/testrun.py @@ -138,6 +138,7 @@ class TestRunSpec(BaseModel): id: str identity: str providers: Providers + suiteSource: str suites: List[Suite] @classmethod From 9bce8a7192e69aebbbee244f1a4925be61a1b4b9 Mon Sep 17 00:00:00 2001 From: Tobias Persson Date: Thu, 1 Aug 2024 13:22:54 +0200 Subject: [PATCH 09/19] Add labels and annotations to schemas --- src/etos_lib/kubernetes/schemas/common.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/etos_lib/kubernetes/schemas/common.py b/src/etos_lib/kubernetes/schemas/common.py index 7376091..0bf2c62 100644 --- a/src/etos_lib/kubernetes/schemas/common.py +++ b/src/etos_lib/kubernetes/schemas/common.py @@ -37,6 +37,8 @@ class Metadata(BaseModel): namespace: str = "default" uid: Optional[str] = None ownerReferences: list[OwnerReference] = [] + labels: Optional[dict[str, str]] = None + annotations: Optional[dict[str, str]] = None class Image(BaseModel): From 578b535c39f81491383665ffba49c48236473310 Mon Sep 17 00:00:00 2001 From: Tobias Persson Date: Wed, 14 Aug 2024 12:58:15 +0200 Subject: [PATCH 10/19] Add environment requests --- src/etos_lib/kubernetes/__init__.py | 1 + .../kubernetes/environment_request.py | 36 ++++++++++++++++++ src/etos_lib/kubernetes/schemas/__init__.py | 1 + .../kubernetes/schemas/environment_request.py | 38 ++++++++++++++++--- 4 files changed, 71 insertions(+), 5 deletions(-) create mode 100644 src/etos_lib/kubernetes/environment_request.py diff --git a/src/etos_lib/kubernetes/__init__.py b/src/etos_lib/kubernetes/__init__.py index 2a45e1b..3adcc54 100644 --- a/src/etos_lib/kubernetes/__init__.py +++ b/src/etos_lib/kubernetes/__init__.py @@ -17,5 +17,6 @@ from .jobs import Job from .etos import Kubernetes, Resource from .environment import Environment +from .environment_request import EnvironmentRequest from .testrun import TestRun from .provider import Provider diff --git a/src/etos_lib/kubernetes/environment_request.py b/src/etos_lib/kubernetes/environment_request.py new file mode 100644 index 0000000..04b8bc4 --- /dev/null +++ b/src/etos_lib/kubernetes/environment_request.py @@ -0,0 +1,36 @@ +# Copyright Axis Communications AB. +# +# For a full list of individual contributors, please see the commit history. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Environment request custom resource manager ETOS.""" +import logging +from .etos import Kubernetes, Resource + + +class EnvironmentRequest(Resource): + """EnvironmentRequest handles the EnvironmentRequest custom Kubernetes resources.""" + + logger = logging.getLogger(__name__) + + def __init__(self, client: Kubernetes, strict: bool = False): + """Set up Kubernetes client. + + :param strict: If True, the client will raise exceptions when Kubernetes could not + be reached as expected such as the ETOS namespace not being able to be determined. + The default (False) will just ignore any problems. + """ + self.strict = strict + with self._catch_errors_if_not_strict(): + self.client = client.environment_requests + self.namespace = client.namespace diff --git a/src/etos_lib/kubernetes/schemas/__init__.py b/src/etos_lib/kubernetes/schemas/__init__.py index 6186a68..2c87949 100644 --- a/src/etos_lib/kubernetes/schemas/__init__.py +++ b/src/etos_lib/kubernetes/schemas/__init__.py @@ -16,5 +16,6 @@ """ETOS Kubernetes schemas.""" from .common import Metadata from .environment import * +from .environment_request import * from .testrun import * from .provider import * diff --git a/src/etos_lib/kubernetes/schemas/environment_request.py b/src/etos_lib/kubernetes/schemas/environment_request.py index 86da2a3..333b22c 100644 --- a/src/etos_lib/kubernetes/schemas/environment_request.py +++ b/src/etos_lib/kubernetes/schemas/environment_request.py @@ -17,15 +17,43 @@ from typing import Optional from pydantic import BaseModel from .common import Metadata +from .testrun import Test + +class Iut(BaseModel): + id: str + +class ExecutionSpace(BaseModel): + id: str + testRunner: str + +class LogArea(BaseModel): + id: str + + +class EnvironmentProviders(BaseModel): + iut: Optional[Iut] = None + executionSpace: Optional[ExecutionSpace] = None + logArea: Optional[LogArea] = None + + +class Splitter(BaseModel): + tests: list[Test] class EnvironmentRequestSpec(BaseModel): """EnvironmentRequstSpec is the specification of an EnvironmentRequest Kubernetes resource.""" - - iut: str - logArea: str - executionSpace: str - testrun: str + id: str + name: Optional[str] = None + identifier: str + image: str + imagePullPolicy: str + artifact: str + identity: str + minimumAmount: int + maximumAmount: int + dataset: Optional[dict] = None + providers: EnvironmentProviders + splitter: Splitter class EnvironmentRequest(BaseModel): From 637e5e98a6c2c2c5299667724ca4cfe5e5551c67 Mon Sep 17 00:00:00 2001 From: Tobias Persson Date: Thu, 15 Aug 2024 12:40:21 +0200 Subject: [PATCH 11/19] Add testrunner version to testrun --- src/etos_lib/kubernetes/schemas/testrun.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/etos_lib/kubernetes/schemas/testrun.py b/src/etos_lib/kubernetes/schemas/testrun.py index 612a74b..3206cb2 100644 --- a/src/etos_lib/kubernetes/schemas/testrun.py +++ b/src/etos_lib/kubernetes/schemas/testrun.py @@ -127,12 +127,18 @@ class Providers(BaseModel): iut: Optional[str] = "default" +class TestRunner(BaseModel): + """Test runner version.""" + version: str + + class TestRunSpec(BaseModel): """TestRunSpec is the specification of a TestRun Kubernetes resource.""" cluster: str artifact: str suiteRunner: Image + testRunner: TestRunner logListener: Image environmentProvider: Image id: str From 456d4f058af9b09f607a06a20e4c0ea1da42ffd1 Mon Sep 17 00:00:00 2001 From: Tobias Persson Date: Mon, 19 Aug 2024 08:10:02 +0200 Subject: [PATCH 12/19] Fix tox --- .../kubernetes/schemas/environment_request.py | 14 ++++++++++++++ src/etos_lib/kubernetes/schemas/testrun.py | 1 + 2 files changed, 15 insertions(+) diff --git a/src/etos_lib/kubernetes/schemas/environment_request.py b/src/etos_lib/kubernetes/schemas/environment_request.py index 333b22c..22c610f 100644 --- a/src/etos_lib/kubernetes/schemas/environment_request.py +++ b/src/etos_lib/kubernetes/schemas/environment_request.py @@ -19,29 +19,43 @@ from .common import Metadata from .testrun import Test + class Iut(BaseModel): + """Iut describes the IUT provider to use for a request.""" + id: str + class ExecutionSpace(BaseModel): + """ExecutionSpace describes the execution space provider to use for a request.""" + id: str testRunner: str + class LogArea(BaseModel): + """LogArea describes the log area provider to use for a request.""" + id: str class EnvironmentProviders(BaseModel): + """EnvironmentProvider describes the providers to use for a request.""" + iut: Optional[Iut] = None executionSpace: Optional[ExecutionSpace] = None logArea: Optional[LogArea] = None class Splitter(BaseModel): + """Splitter describes the configuration for the environment splitter.""" + tests: list[Test] class EnvironmentRequestSpec(BaseModel): """EnvironmentRequstSpec is the specification of an EnvironmentRequest Kubernetes resource.""" + id: str name: Optional[str] = None identifier: str diff --git a/src/etos_lib/kubernetes/schemas/testrun.py b/src/etos_lib/kubernetes/schemas/testrun.py index 3206cb2..1c36a6f 100644 --- a/src/etos_lib/kubernetes/schemas/testrun.py +++ b/src/etos_lib/kubernetes/schemas/testrun.py @@ -129,6 +129,7 @@ class Providers(BaseModel): class TestRunner(BaseModel): """Test runner version.""" + version: str From e2062827a03a1f58d96b69047ad877aec80f8477 Mon Sep 17 00:00:00 2001 From: Tobias Persson Date: Mon, 19 Aug 2024 14:23:06 +0200 Subject: [PATCH 13/19] Remove cache --- src/etos_lib/kubernetes/etos.py | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/src/etos_lib/kubernetes/etos.py b/src/etos_lib/kubernetes/etos.py index 1c21d52..a41b2fc 100644 --- a/src/etos_lib/kubernetes/etos.py +++ b/src/etos_lib/kubernetes/etos.py @@ -45,7 +45,6 @@ class Resource: client: DynamicResource namespace: str = "default" strict: bool = False - __cache = {} @contextmanager def _catch_errors_if_not_strict(self): @@ -78,23 +77,11 @@ def __full_resource_name(self, name: str): f"{self.namespace}/{name}" ) - def get(self, name: str, cache=True) -> Optional[ResourceInstance]: - """Get a resource from Kubernetes by name. - - if Cache is set to False, then make sure to get the resource from kubernetes, - if Cache is True, then the cache will be used every time. - There is no cache invalidation! - """ - resource: Optional[ResourceInstance] = None - if cache: - resource = self.__cache.get(self.__full_resource_name(name)) - if resource is not None: - return resource + def get(self, name: str) -> Optional[ResourceInstance]: + """Get a resource from Kubernetes by name.""" try: with self._catch_errors_if_not_strict(): resource = self.client.get(name=name, namespace=self.namespace) # type: ignore - if resource: - self.__cache[self.__full_resource_name(name)] = resource except NotFoundError: resource = None return resource From c0485a36735f27e6ef5776f3ea815caba93bc905 Mon Sep 17 00:00:00 2001 From: Tobias Persson Date: Mon, 19 Aug 2024 14:37:38 +0200 Subject: [PATCH 14/19] Remove unused method --- src/etos_lib/kubernetes/etos.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/etos_lib/kubernetes/etos.py b/src/etos_lib/kubernetes/etos.py index a41b2fc..356cbbc 100644 --- a/src/etos_lib/kubernetes/etos.py +++ b/src/etos_lib/kubernetes/etos.py @@ -70,13 +70,6 @@ def _catch_errors_if_not_strict(self): if self.strict: raise - def __full_resource_name(self, name: str): - """Full resource name will return the group, version, namespace and kind.""" - return ( - f"{self.client.group}/{self.client.api_version}/{self.client.kind} " - f"{self.namespace}/{name}" - ) - def get(self, name: str) -> Optional[ResourceInstance]: """Get a resource from Kubernetes by name.""" try: From 91e6ef93206e49ff53e0ac214f8a216fa085f427 Mon Sep 17 00:00:00 2001 From: Tobias Persson Date: Mon, 26 Aug 2024 09:33:28 +0200 Subject: [PATCH 15/19] Add Retention to testrun schema --- src/etos_lib/kubernetes/schemas/common.py | 7 +++++++ src/etos_lib/kubernetes/schemas/testrun.py | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/etos_lib/kubernetes/schemas/common.py b/src/etos_lib/kubernetes/schemas/common.py index 0bf2c62..5735498 100644 --- a/src/etos_lib/kubernetes/schemas/common.py +++ b/src/etos_lib/kubernetes/schemas/common.py @@ -46,3 +46,10 @@ class Image(BaseModel): image: str imagePullPolicy: str = "IfNotPresent" + + +class Retention(BaseModel): + """Retention for ETOS testruns.""" + + failure: Optional[str] = None + success: Optional[str] = None diff --git a/src/etos_lib/kubernetes/schemas/testrun.py b/src/etos_lib/kubernetes/schemas/testrun.py index 1c36a6f..2920ed8 100644 --- a/src/etos_lib/kubernetes/schemas/testrun.py +++ b/src/etos_lib/kubernetes/schemas/testrun.py @@ -16,7 +16,7 @@ """Models for the TestRun resource.""" from typing import Optional, List, Any, Union from pydantic import BaseModel -from .common import Metadata, Image +from .common import Metadata, Image, Retention __all__ = ["TestRun", "TestRunSpec"] @@ -147,6 +147,7 @@ class TestRunSpec(BaseModel): providers: Providers suiteSource: str suites: List[Suite] + retention: Optional[Retention] = None @classmethod def from_tercc(cls, tercc: list[dict], datasets: Union[list[dict], dict]) -> list[Suite]: From 05b1b770b4143244e1f610a2e287abea31525e2b Mon Sep 17 00:00:00 2001 From: Tobias Persson Date: Tue, 27 Aug 2024 09:25:28 +0200 Subject: [PATCH 16/19] Set optional on optional fields --- src/etos_lib/kubernetes/schemas/testrun.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/etos_lib/kubernetes/schemas/testrun.py b/src/etos_lib/kubernetes/schemas/testrun.py index 2920ed8..0e8adf2 100644 --- a/src/etos_lib/kubernetes/schemas/testrun.py +++ b/src/etos_lib/kubernetes/schemas/testrun.py @@ -136,16 +136,15 @@ class TestRunner(BaseModel): class TestRunSpec(BaseModel): """TestRunSpec is the specification of a TestRun Kubernetes resource.""" - cluster: str + cluster: Optional[str] = None artifact: str - suiteRunner: Image - testRunner: TestRunner - logListener: Image - environmentProvider: Image - id: str + suiteRunner: Optional[Image] = None + testRunner: Optional[TestRunner] = None + logListener: Optional[Image] = None + environmentProvider: Optional[Image] = None + id: Optional[str] = None identity: str providers: Providers - suiteSource: str suites: List[Suite] retention: Optional[Retention] = None From 89bf475d56bf0cbe2c5c062086f6e4eedf365494 Mon Sep 17 00:00:00 2001 From: Tobias Persson Date: Wed, 9 Oct 2024 08:17:15 +0200 Subject: [PATCH 17/19] Remove strict --- src/etos_lib/kubernetes/environment.py | 15 ++---- .../kubernetes/environment_request.py | 15 ++---- src/etos_lib/kubernetes/etos.py | 52 +++---------------- src/etos_lib/kubernetes/provider.py | 15 ++---- src/etos_lib/kubernetes/testrun.py | 15 ++---- 5 files changed, 24 insertions(+), 88 deletions(-) diff --git a/src/etos_lib/kubernetes/environment.py b/src/etos_lib/kubernetes/environment.py index 8405062..781643f 100644 --- a/src/etos_lib/kubernetes/environment.py +++ b/src/etos_lib/kubernetes/environment.py @@ -23,14 +23,7 @@ class Environment(Resource): logger = logging.getLogger(__name__) - def __init__(self, client: Kubernetes, strict: bool = False): - """Set up Kubernetes client. - - :param strict: If True, the client will raise exceptions when Kubernetes could not - be reached as expected such as the ETOS namespace not being able to be determined. - The default (False) will just ignore any problems. - """ - self.strict = strict - with self._catch_errors_if_not_strict(): - self.client = client.environments - self.namespace = client.namespace + def __init__(self, client: Kubernetes): + """Set up Kubernetes client.""" + self.client = client.environments + self.namespace = client.namespace diff --git a/src/etos_lib/kubernetes/environment_request.py b/src/etos_lib/kubernetes/environment_request.py index 04b8bc4..6a85327 100644 --- a/src/etos_lib/kubernetes/environment_request.py +++ b/src/etos_lib/kubernetes/environment_request.py @@ -23,14 +23,7 @@ class EnvironmentRequest(Resource): logger = logging.getLogger(__name__) - def __init__(self, client: Kubernetes, strict: bool = False): - """Set up Kubernetes client. - - :param strict: If True, the client will raise exceptions when Kubernetes could not - be reached as expected such as the ETOS namespace not being able to be determined. - The default (False) will just ignore any problems. - """ - self.strict = strict - with self._catch_errors_if_not_strict(): - self.client = client.environment_requests - self.namespace = client.namespace + def __init__(self, client: Kubernetes): + """Set up Kubernetes client.""" + self.client = client.environment_requests + self.namespace = client.namespace diff --git a/src/etos_lib/kubernetes/etos.py b/src/etos_lib/kubernetes/etos.py index 356cbbc..055274e 100644 --- a/src/etos_lib/kubernetes/etos.py +++ b/src/etos_lib/kubernetes/etos.py @@ -17,19 +17,13 @@ import os import logging from pathlib import Path -from contextlib import contextmanager from typing import Optional from pydantic import BaseModel from kubernetes import config from kubernetes.client import api_client -from kubernetes.client.exceptions import ApiException from kubernetes.dynamic import DynamicClient from kubernetes.dynamic.resource import Resource as DynamicResource, ResourceInstance -from kubernetes.dynamic.exceptions import ( - ResourceNotFoundError, - ResourceNotUniqueError, - NotFoundError, -) +from kubernetes.dynamic.exceptions import NotFoundError config.load_config() NAMESPACE_FILE = Path("/var/run/secrets/kubernetes.io/serviceaccount/namespace") @@ -44,56 +38,26 @@ class Resource: client: DynamicResource namespace: str = "default" - strict: bool = False - - @contextmanager - def _catch_errors_if_not_strict(self): - """Catch errors if the strict flag is False, else raise.""" - try: - yield - # Internal exceptions - except NoNamespace: - if self.strict: - raise - # Client exceptions - except ApiException: - if self.strict: - raise - # Dynamic client exceptions - except (ResourceNotFoundError, ResourceNotUniqueError): - if self.strict: - raise - # Built-in exceptions - except AttributeError: - # AttributeError happens if ResourceNotFoundError was raised when setting up - # clients. - if self.strict: - raise def get(self, name: str) -> Optional[ResourceInstance]: """Get a resource from Kubernetes by name.""" try: - with self._catch_errors_if_not_strict(): - resource = self.client.get(name=name, namespace=self.namespace) # type: ignore + resource = self.client.get(name=name, namespace=self.namespace) # type: ignore except NotFoundError: resource = None return resource def delete(self, name: str) -> bool: """Delete a resource by name.""" - with self._catch_errors_if_not_strict(): - if self.client.delete(name=name, namespace=self.namespace): # type: ignore - return True - return False + if self.client.delete(name=name, namespace=self.namespace): # type: ignore + return True + return False def create(self, model: BaseModel) -> bool: """Create a resource from a pydantic model.""" - with self._catch_errors_if_not_strict(): - if self.client.create( - body=model.model_dump(), namespace=self.namespace - ): # type: ignore - return True - return False + if self.client.create(body=model.model_dump(), namespace=self.namespace): # type: ignore + return True + return False def exists(self, name: str) -> bool: """Test if a resource with name exists.""" diff --git a/src/etos_lib/kubernetes/provider.py b/src/etos_lib/kubernetes/provider.py index b4d22ae..cbb351d 100644 --- a/src/etos_lib/kubernetes/provider.py +++ b/src/etos_lib/kubernetes/provider.py @@ -20,14 +20,7 @@ class Provider(Resource): """Provider handles the Provider custom Kubernetes resources.""" - def __init__(self, client: Kubernetes, strict: bool = False): - """Set up Kubernetes client. - - :param strict: If True, the client will raise exceptions when Kubernetes could not - be reached as expected such as the ETOS namespace not being able to be determined. - The default (False) will just ignore any problems. - """ - self.strict = strict - with self._catch_errors_if_not_strict(): - self.client = client.providers - self.namespace = client.namespace + def __init__(self, client: Kubernetes): + """Set up Kubernetes client.""" + self.client = client.providers + self.namespace = client.namespace diff --git a/src/etos_lib/kubernetes/testrun.py b/src/etos_lib/kubernetes/testrun.py index 9621b57..458d397 100644 --- a/src/etos_lib/kubernetes/testrun.py +++ b/src/etos_lib/kubernetes/testrun.py @@ -20,14 +20,7 @@ class TestRun(Resource): """TestRun handles the TestRun custom Kubernetes resources.""" - def __init__(self, client: Kubernetes, strict: bool = False): - """Set up Kubernetes client. - - :param strict: If True, the client will raise exceptions when Kubernetes could not - be reached as expected such as the ETOS namespace not being able to be determined. - The default (False) will just ignore any problems. - """ - self.strict = strict - with self._catch_errors_if_not_strict(): - self.client = client.testruns - self.namespace = client.namespace + def __init__(self, client: Kubernetes): + """Set up Kubernetes client.""" + self.client = client.testruns + self.namespace = client.namespace From 45db628bbe3aaa7a45888547fdee89c5a0f9009e Mon Sep 17 00:00:00 2001 From: Tobias Persson Date: Wed, 9 Oct 2024 10:48:12 +0200 Subject: [PATCH 18/19] Remove rebase problem --- pyproject.toml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d57900f..3cf442b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,3 @@ build_dir = "build/sphinx" [tool.devpi.upload] no-vcs = 1 formats = "bdist_wheel" - -[tool.setuptools_scm] -version_scheme = "setup:version_scheme" -local_scheme = "setup:local_scheme" From 8b47fffea1275092cf64222d7386fdda7480a219 Mon Sep 17 00:00:00 2001 From: Tobias Persson Date: Mon, 14 Oct 2024 09:10:52 +0200 Subject: [PATCH 19/19] Add documentation about the client behavior Also moved the NotFound check to the 'exists' method. --- src/etos_lib/kubernetes/etos.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/etos_lib/kubernetes/etos.py b/src/etos_lib/kubernetes/etos.py index 055274e..ad1d7cd 100644 --- a/src/etos_lib/kubernetes/etos.py +++ b/src/etos_lib/kubernetes/etos.py @@ -34,18 +34,25 @@ class NoNamespace(Exception): class Resource: - """Resource is the base resource client for ETOS custom resources.""" + """Resource is the base resource client for ETOS custom resources. + + This resource base class is used by our custom resources to, somewhat, mimic + the behavior of a built-in resource from Kubernetes. This means that we don't + do any error handling as that is not done in the built-in Kubernetes client. + + While we do somewhat mimic the behavior we don't necessarily mimic the "API" + of the built-in resources. For example, we return boolean where the built-in + would return the Kubernetes API response. We do this because of how we typically + use Kubernetes in our services. If the Kubernetes API response is preferred + we can still use the client :obj:`DynamicResource` directly. + """ client: DynamicResource namespace: str = "default" def get(self, name: str) -> Optional[ResourceInstance]: """Get a resource from Kubernetes by name.""" - try: - resource = self.client.get(name=name, namespace=self.namespace) # type: ignore - except NotFoundError: - resource = None - return resource + return self.client.get(name=name, namespace=self.namespace) # type: ignore def delete(self, name: str) -> bool: """Delete a resource by name.""" @@ -61,7 +68,10 @@ def create(self, model: BaseModel) -> bool: def exists(self, name: str) -> bool: """Test if a resource with name exists.""" - return self.get(name) is not None + try: + return self.get(name) is not None + except NotFoundError: + return False class Kubernetes: