From c8d24dba25a53a2908d52b4b8210a48ddfab360c Mon Sep 17 00:00:00 2001 From: Tobias Persson Date: Mon, 14 Oct 2024 12:52:18 +0200 Subject: [PATCH] Add Kubernetes CRD client and schemas (#33) * Add Kubernetes CRD client and schemas --- pyproject.toml | 1 + src/etos_lib/kubernetes/__init__.py | 9 +- src/etos_lib/kubernetes/environment.py | 29 +++ .../kubernetes/environment_request.py | 29 +++ src/etos_lib/kubernetes/etos.py | 145 +++++++++++++++ src/etos_lib/kubernetes/provider.py | 26 +++ src/etos_lib/kubernetes/schemas/__init__.py | 21 +++ src/etos_lib/kubernetes/schemas/common.py | 55 ++++++ .../kubernetes/schemas/environment.py | 53 ++++++ .../kubernetes/schemas/environment_request.py | 79 ++++++++ src/etos_lib/kubernetes/schemas/provider.py | 142 ++++++++++++++ src/etos_lib/kubernetes/schemas/testrun.py | 174 ++++++++++++++++++ src/etos_lib/kubernetes/testrun.py | 26 +++ 13 files changed, 787 insertions(+), 2 deletions(-) create mode 100644 src/etos_lib/kubernetes/environment.py create mode 100644 src/etos_lib/kubernetes/environment_request.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/pyproject.toml b/pyproject.toml index 3bb2b6c..3cf442b 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", diff --git a/src/etos_lib/kubernetes/__init__.py b/src/etos_lib/kubernetes/__init__.py index 4f4dc59..3adcc54 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,10 @@ # 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 .environment_request import EnvironmentRequest +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..781643f --- /dev/null +++ b/src/etos_lib/kubernetes/environment.py @@ -0,0 +1,29 @@ +# 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): + """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 new file mode 100644 index 0000000..6a85327 --- /dev/null +++ b/src/etos_lib/kubernetes/environment_request.py @@ -0,0 +1,29 @@ +# 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): + """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 new file mode 100644 index 0000000..ad1d7cd --- /dev/null +++ b/src/etos_lib/kubernetes/etos.py @@ -0,0 +1,145 @@ +# 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 typing import Optional +from pydantic import BaseModel +from kubernetes import config +from kubernetes.client import api_client +from kubernetes.dynamic import DynamicClient +from kubernetes.dynamic.resource import Resource as DynamicResource, ResourceInstance +from kubernetes.dynamic.exceptions import 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. + + 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.""" + return self.client.get(name=name, namespace=self.namespace) # type: ignore + + def delete(self, name: str) -> bool: + """Delete a resource by name.""" + 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.""" + 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.""" + try: + return self.get(name) is not None + except NotFoundError: + return False + + +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..cbb351d --- /dev/null +++ b/src/etos_lib/kubernetes/provider.py @@ -0,0 +1,26 @@ +# 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): + """Set up Kubernetes client.""" + 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..2c87949 --- /dev/null +++ b/src/etos_lib/kubernetes/schemas/__init__.py @@ -0,0 +1,21 @@ +# 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 .environment_request 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..5735498 --- /dev/null +++ b/src/etos_lib/kubernetes/schemas/common.py @@ -0,0 +1,55 @@ +# 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] = [] + labels: Optional[dict[str, str]] = None + annotations: Optional[dict[str, str]] = None + + +class Image(BaseModel): + """Image is a container image representation.""" + + 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/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..22c610f --- /dev/null +++ b/src/etos_lib/kubernetes/schemas/environment_request.py @@ -0,0 +1,79 @@ +# 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 +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 + image: str + imagePullPolicy: str + artifact: str + identity: str + minimumAmount: int + maximumAmount: int + dataset: Optional[dict] = None + providers: EnvironmentProviders + splitter: Splitter + + +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..4bea78d --- /dev/null +++ b/src/etos_lib/kubernetes/schemas/provider.py @@ -0,0 +1,142 @@ +# 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.""" +from typing import Optional, Union +from pydantic import BaseModel +from .common import Metadata + + +class JSONTasList(BaseModel): + """JSONTasList describes the list part of a JSONTas provider.""" + + possible: dict + available: Union[dict, str] + + +class BaseProvider(BaseModel): + """BaseProvider describes the base parts of JSONTas providers.""" + + id: str + checkin: Optional[dict] = None + checkout: Optional[dict] = None + list: JSONTasList + + +class Stage(BaseModel): + """Stage is the IUT prepare stage for an IUT provider.""" + + steps: dict = {} + + +class JSONTasIutPrepareStages(BaseModel): + """JSONTasIUTPrepareStages describes the prepare stages for an IUT provider.""" + + environment_provider: Optional[Stage] = None + suite_runner: Optional[Stage] = None + test_runner: 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 + execution_space: Optional[JSONTasExecutionSpace] = None + log: 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: Optional[str] = None + healthCheck: Optional[Healthcheck] = None + 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.execution_space is not None + ), "Execution space must be a part of a Provider with type 'execution-space'." + ruleset = self.spec.jsontas.execution_space.model_dump() + elif self.spec.type == "log-area": + assert ( + self.spec.jsontas.log is not None + ), "Log area must be a part of a Provider with type 'log-area'." + ruleset = self.spec.jsontas.log.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..0e8adf2 --- /dev/null +++ b/src/etos_lib/kubernetes/schemas/testrun.py @@ -0,0 +1,174 @@ +# 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, Union +from pydantic import BaseModel +from .common import Metadata, Image, Retention + +__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] + dataset: dict + + @classmethod + 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 + 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 TestRunner(BaseModel): + """Test runner version.""" + + version: str + + +class TestRunSpec(BaseModel): + """TestRunSpec is the specification of a TestRun Kubernetes resource.""" + + cluster: Optional[str] = None + artifact: 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 + suites: List[Suite] + retention: Optional[Retention] = None + + @classmethod + 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. + """ + # 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): + """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..458d397 --- /dev/null +++ b/src/etos_lib/kubernetes/testrun.py @@ -0,0 +1,26 @@ +# 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): + """Set up Kubernetes client.""" + self.client = client.testruns + self.namespace = client.namespace