diff --git a/internal/configs/base/config.go b/internal/configs/base/config.go index 36c5f67..316f5da 100644 --- a/internal/configs/base/config.go +++ b/internal/configs/base/config.go @@ -25,6 +25,7 @@ import ( type Config interface { ServiceHost() string ServicePort() string + StripPrefix() string LogLevel() string LogFilePath() string ETOSNamespace() string @@ -35,6 +36,7 @@ type Config interface { type cfg struct { serviceHost string servicePort string + stripPrefix string logLevel string logFilePath string etosNamespace string @@ -48,6 +50,7 @@ func Get() Config { flag.StringVar(&conf.serviceHost, "address", EnvOrDefault("SERVICE_HOST", "127.0.0.1"), "Address to serve API on") flag.StringVar(&conf.servicePort, "port", EnvOrDefault("SERVICE_PORT", "8080"), "Port to serve API on") + flag.StringVar(&conf.stripPrefix, "stripprefix", EnvOrDefault("STRIP_PREFIX", ""), "Strip a URL prefix. Useful when a reverse proxy sets a subpath. I.e. reverse proxy sets /stream as prefix, making the etos API available at /stream/v1/events. In that case we want to set stripprefix to /stream") flag.StringVar(&conf.logLevel, "loglevel", EnvOrDefault("LOGLEVEL", "INFO"), "Log level (TRACE, DEBUG, INFO, WARNING, ERROR, FATAL, PANIC).") flag.StringVar(&conf.logFilePath, "logfilepath", os.Getenv("LOG_FILE_PATH"), "Path, including filename, for the log files to create.") flag.StringVar(&conf.etosNamespace, "etosnamespace", ReadNamespaceOrEnv("ETOS_NAMESPACE"), "Path, including filename, for the log files to create.") @@ -68,6 +71,11 @@ func (c *cfg) ServicePort() string { return c.servicePort } +// StripPrefix returns the prefix to strip. Empty string if no prefix. +func (c *cfg) StripPrefix() string { + return c.stripPrefix +} + // LogLevel returns the log level. func (c *cfg) LogLevel() string { return c.logLevel diff --git a/internal/configs/iut/config.go b/internal/configs/iut/config.go index 4b8fb0b..b945115 100644 --- a/internal/configs/iut/config.go +++ b/internal/configs/iut/config.go @@ -25,6 +25,7 @@ import ( type Config interface { ServiceHost() string ServicePort() string + StripPrefix() string LogLevel() string LogFilePath() string ETOSNamespace() string @@ -35,6 +36,7 @@ type Config interface { type cfg struct { serviceHost string servicePort string + stripPrefix string logLevel string logFilePath string etosNamespace string @@ -48,6 +50,7 @@ func Get() Config { flag.StringVar(&conf.serviceHost, "address", EnvOrDefault("SERVICE_HOST", "127.0.0.1"), "Address to serve API on") flag.StringVar(&conf.servicePort, "port", EnvOrDefault("SERVICE_PORT", "8080"), "Port to serve API on") + flag.StringVar(&conf.stripPrefix, "stripprefix", EnvOrDefault("STRIP_PREFIX", ""), "Strip a URL prefix. Useful when a reverse proxy sets a subpath. I.e. reverse proxy sets /stream as prefix, making the etos API available at /stream/v1/events. In that case we want to set stripprefix to /stream") flag.StringVar(&conf.logLevel, "loglevel", EnvOrDefault("LOGLEVEL", "INFO"), "Log level (TRACE, DEBUG, INFO, WARNING, ERROR, FATAL, PANIC).") flag.StringVar(&conf.logFilePath, "logfilepath", os.Getenv("LOG_FILE_PATH"), "Path, including filename, for the log files to create.") flag.StringVar(&conf.databaseHost, "database_host", EnvOrDefault("ETOS_ETCD_HOST", "etcd-client"), "Host to ETOS database") @@ -67,6 +70,11 @@ func (c *cfg) ServicePort() string { return c.servicePort } +// StripPrefix returns the prefix to strip. Empty string if no prefix. +func (c *cfg) StripPrefix() string { + return c.stripPrefix +} + // LogLevel returns the log level. func (c *cfg) LogLevel() string { return c.logLevel diff --git a/internal/configs/logarea/config.go b/internal/configs/logarea/config.go index 36c5f67..316f5da 100644 --- a/internal/configs/logarea/config.go +++ b/internal/configs/logarea/config.go @@ -25,6 +25,7 @@ import ( type Config interface { ServiceHost() string ServicePort() string + StripPrefix() string LogLevel() string LogFilePath() string ETOSNamespace() string @@ -35,6 +36,7 @@ type Config interface { type cfg struct { serviceHost string servicePort string + stripPrefix string logLevel string logFilePath string etosNamespace string @@ -48,6 +50,7 @@ func Get() Config { flag.StringVar(&conf.serviceHost, "address", EnvOrDefault("SERVICE_HOST", "127.0.0.1"), "Address to serve API on") flag.StringVar(&conf.servicePort, "port", EnvOrDefault("SERVICE_PORT", "8080"), "Port to serve API on") + flag.StringVar(&conf.stripPrefix, "stripprefix", EnvOrDefault("STRIP_PREFIX", ""), "Strip a URL prefix. Useful when a reverse proxy sets a subpath. I.e. reverse proxy sets /stream as prefix, making the etos API available at /stream/v1/events. In that case we want to set stripprefix to /stream") flag.StringVar(&conf.logLevel, "loglevel", EnvOrDefault("LOGLEVEL", "INFO"), "Log level (TRACE, DEBUG, INFO, WARNING, ERROR, FATAL, PANIC).") flag.StringVar(&conf.logFilePath, "logfilepath", os.Getenv("LOG_FILE_PATH"), "Path, including filename, for the log files to create.") flag.StringVar(&conf.etosNamespace, "etosnamespace", ReadNamespaceOrEnv("ETOS_NAMESPACE"), "Path, including filename, for the log files to create.") @@ -68,6 +71,11 @@ func (c *cfg) ServicePort() string { return c.servicePort } +// StripPrefix returns the prefix to strip. Empty string if no prefix. +func (c *cfg) StripPrefix() string { + return c.stripPrefix +} + // LogLevel returns the log level. func (c *cfg) LogLevel() string { return c.logLevel diff --git a/internal/configs/sse/config.go b/internal/configs/sse/config.go index 36c5f67..316f5da 100644 --- a/internal/configs/sse/config.go +++ b/internal/configs/sse/config.go @@ -25,6 +25,7 @@ import ( type Config interface { ServiceHost() string ServicePort() string + StripPrefix() string LogLevel() string LogFilePath() string ETOSNamespace() string @@ -35,6 +36,7 @@ type Config interface { type cfg struct { serviceHost string servicePort string + stripPrefix string logLevel string logFilePath string etosNamespace string @@ -48,6 +50,7 @@ func Get() Config { flag.StringVar(&conf.serviceHost, "address", EnvOrDefault("SERVICE_HOST", "127.0.0.1"), "Address to serve API on") flag.StringVar(&conf.servicePort, "port", EnvOrDefault("SERVICE_PORT", "8080"), "Port to serve API on") + flag.StringVar(&conf.stripPrefix, "stripprefix", EnvOrDefault("STRIP_PREFIX", ""), "Strip a URL prefix. Useful when a reverse proxy sets a subpath. I.e. reverse proxy sets /stream as prefix, making the etos API available at /stream/v1/events. In that case we want to set stripprefix to /stream") flag.StringVar(&conf.logLevel, "loglevel", EnvOrDefault("LOGLEVEL", "INFO"), "Log level (TRACE, DEBUG, INFO, WARNING, ERROR, FATAL, PANIC).") flag.StringVar(&conf.logFilePath, "logfilepath", os.Getenv("LOG_FILE_PATH"), "Path, including filename, for the log files to create.") flag.StringVar(&conf.etosNamespace, "etosnamespace", ReadNamespaceOrEnv("ETOS_NAMESPACE"), "Path, including filename, for the log files to create.") @@ -68,6 +71,11 @@ func (c *cfg) ServicePort() string { return c.servicePort } +// StripPrefix returns the prefix to strip. Empty string if no prefix. +func (c *cfg) StripPrefix() string { + return c.stripPrefix +} + // LogLevel returns the log level. func (c *cfg) LogLevel() string { return c.logLevel diff --git a/internal/server/server.go b/internal/server/server.go index 79fd9f3..47c5698 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -39,6 +39,9 @@ type WebService struct { // NewWebService creates a new Server of the webservice type. func NewWebService(cfg config.Config, log *logrus.Entry, handler http.Handler) Server { + if cfg.StripPrefix() != "" { + handler = http.StripPrefix(cfg.StripPrefix(), handler) + } webservice := &WebService{ server: &http.Server{ Addr: fmt.Sprintf("%s:%s", cfg.ServiceHost(), cfg.ServicePort()), @@ -52,7 +55,7 @@ func NewWebService(cfg config.Config, log *logrus.Entry, handler http.Handler) S // Start a webservice and block until closed or crashed. func (s *WebService) Start() error { - s.logger.Infof("Starting webservice listening on %s:%s", s.cfg.ServiceHost(), s.cfg.ServicePort()) + s.logger.Infof("Starting webservice listening on %s:%s%s", s.cfg.ServiceHost(), s.cfg.ServicePort(), s.cfg.StripPrefix()) return s.server.ListenAndServe() } diff --git a/manifests/base/role.yaml b/manifests/base/role.yaml index 4f886db..036db12 100644 --- a/manifests/base/role.yaml +++ b/manifests/base/role.yaml @@ -19,6 +19,16 @@ rules: - delete - list - watch + - apiGroups: + - "etos.eiffel-community.github.io" + resources: + - testruns + verbs: + - create + - get + - delete + - list + - watch - apiGroups: - "" resources: diff --git a/pkg/application/application.go b/pkg/application/application.go index c5f83b5..1f40c2f 100644 --- a/pkg/application/application.go +++ b/pkg/application/application.go @@ -15,7 +15,9 @@ // limitations under the License. package application -import "github.com/julienschmidt/httprouter" +import ( + "github.com/julienschmidt/httprouter" +) type Application interface { LoadRoutes(*httprouter.Router) diff --git a/python/pyproject.toml b/python/pyproject.toml index 394aea6..a437276 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -16,7 +16,7 @@ classifiers = [ ] requires-python = ">=3.9" dependencies = [ - "etos_lib==4.3.1", + "etos_lib==4.4.1", "etcd3gw~=2.3", "uvicorn~=0.22", "fastapi~=0.109.1", @@ -62,4 +62,4 @@ testpaths = ["tests"] root = ".." [tool.setuptools.packages] -find = { where = ["src"], exclude = ["tests"] } \ No newline at end of file +find = { where = ["src"], exclude = ["tests"] } diff --git a/python/src/etos_api/library/validator.py b/python/src/etos_api/library/validator.py index 5e63bec..7672299 100644 --- a/python/src/etos_api/library/validator.py +++ b/python/src/etos_api/library/validator.py @@ -18,8 +18,6 @@ from typing import List, Union from uuid import UUID -import requests - # Pylint refrains from linting C extensions due to arbitrary code execution. from pydantic import BaseModel # pylint:disable=no-name-in-module from pydantic import ValidationError, conlist, constr, field_validator @@ -157,33 +155,14 @@ class SuiteValidator: logger = logging.getLogger(__name__) - async def _download_suite(self, test_suite_url): - """Attempt to download suite. - - :param test_suite_url: URL to test suite to download. - :type test_suite_url: str - :return: Downloaded test suite as JSON. - :rtype: list - """ - try: - suite = requests.get(test_suite_url, timeout=60) - suite.raise_for_status() - except Exception as exception: # pylint:disable=broad-except - raise AssertionError(f"Unable to download suite from {test_suite_url}") from exception - return suite.json() - - async def validate(self, test_suite_url): + async def validate(self, test_suite): """Validate the ETOS suite definition. - :param test_suite_url: URL to test suite that is being executed. - :type test_suite_url: str + :param test_suite: Test suite that is being executed. + :type test_suite: list :raises ValidationError: If the suite did not validate. """ - downloaded_suite = await self._download_suite(test_suite_url) - assert ( - len(downloaded_suite) > 0 - ), "Suite definition validation unsuccessful - Reason: Empty Test suite definition list" - for suite_json in downloaded_suite: + for suite_json in test_suite: test_runners = set() suite = Suite(**suite_json) assert suite diff --git a/python/src/etos_api/main.py b/python/src/etos_api/main.py index b441211..ed40442 100644 --- a/python/src/etos_api/main.py +++ b/python/src/etos_api/main.py @@ -18,12 +18,12 @@ from fastapi import FastAPI -# from opentelemetry.sdk.trace import TracerProvider from starlette.responses import RedirectResponse from etos_api import routers -APP = FastAPI() +# This allows the path to start either at '/api' or '/'. +APP = FastAPI(root_path="/api") LOGGER = logging.getLogger(__name__) @@ -52,5 +52,6 @@ async def redirect_head_to_root(): APP.include_router(routers.etos.ROUTER) +APP.include_router(routers.testrun.ROUTER) APP.include_router(routers.selftest.ROUTER) APP.include_router(routers.logs.ROUTER) diff --git a/python/src/etos_api/routers/__init__.py b/python/src/etos_api/routers/__init__.py index 9e3bb14..ae620b2 100644 --- a/python/src/etos_api/routers/__init__.py +++ b/python/src/etos_api/routers/__init__.py @@ -14,4 +14,12 @@ # See the License for the specific language governing permissions and # limitations under the License. """ETOS API routers module.""" -from . import etos, logs, selftest +import os +from kubernetes import config +from . import etos, testrun, logs, selftest + +if os.getenv("RUNNING_TESTS") is None: + try: + config.load_incluster_config() + except config.ConfigException: + config.load_config() diff --git a/python/src/etos_api/routers/etos/router.py b/python/src/etos_api/routers/etos/router.py index 3e83d00..0fd6550 100644 --- a/python/src/etos_api/routers/etos/router.py +++ b/python/src/etos_api/routers/etos/router.py @@ -24,6 +24,7 @@ from kubernetes import client from opentelemetry import trace from opentelemetry.trace import Span +import requests from etos_api.library.environment import Configuration, configure_testrun from etos_api.library.utilities import sync_to_async @@ -39,6 +40,20 @@ logging.getLogger("pika").setLevel(logging.WARNING) +async def download_suite(test_suite_url: str) -> dict: + """Attempt to download suite. + + :param test_suite_url: URL to test suite to download. + :return: Downloaded test suite as JSON. + """ + try: + suite = requests.get(test_suite_url, timeout=60) + suite.raise_for_status() + except Exception as exception: # pylint:disable=broad-except + raise AssertionError(f"Unable to download suite from {test_suite_url}") from exception + return suite.json() + + async def validate_suite(test_suite_url: str) -> None: """Validate the ETOS test suite through the SuiteValidator. @@ -47,7 +62,8 @@ async def validate_suite(test_suite_url: str) -> None: span = trace.get_current_span() try: - await SuiteValidator().validate(test_suite_url) + test_suite = await download_suite(test_suite_url) + await SuiteValidator().validate(test_suite) except AssertionError as exception: LOGGER.error("Test suite validation failed!") LOGGER.error(exception) diff --git a/python/src/etos_api/routers/lib/kubernetes.py b/python/src/etos_api/routers/lib/kubernetes.py index 6942dc6..622a9db 100644 --- a/python/src/etos_api/routers/lib/kubernetes.py +++ b/python/src/etos_api/routers/lib/kubernetes.py @@ -17,19 +17,9 @@ import logging import os -from kubernetes import config - NAMESPACE_FILE = "/var/run/secrets/kubernetes.io/serviceaccount/namespace" LOGGER = logging.getLogger(__name__) -try: - config.load_incluster_config() -except config.ConfigException: - try: - config.load_config() - except config.ConfigException: - LOGGER.warning("Could not load a Kubernetes config") - def namespace() -> str: """Get current namespace if available.""" diff --git a/python/src/etos_api/routers/testrun/__init__.py b/python/src/etos_api/routers/testrun/__init__.py new file mode 100644 index 0000000..e1127d3 --- /dev/null +++ b/python/src/etos_api/routers/testrun/__init__.py @@ -0,0 +1,18 @@ +# 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 API testrun module.""" +from .router import ROUTER +from . import schemas diff --git a/python/src/etos_api/routers/testrun/router.py b/python/src/etos_api/routers/testrun/router.py new file mode 100644 index 0000000..216d2c2 --- /dev/null +++ b/python/src/etos_api/routers/testrun/router.py @@ -0,0 +1,337 @@ +# 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 testrun router.""" +import logging +import os +import re +from uuid import uuid4 +from typing import Any + +import requests +from etos_lib import ETOS +from etos_lib.kubernetes.schemas.testrun import ( + TestRun as TestRunSchema, + TestRunSpec, + Providers, + Image, + Metadata, + Retention, + TestRunner, +) +from etos_lib.kubernetes import TestRun, Environment, Kubernetes +from fastapi import APIRouter, HTTPException +from opentelemetry import trace +from opentelemetry.trace import Span + +from etos_api.library.validator import SuiteValidator +from etos_api.routers.lib.kubernetes import namespace + +from .schemas import AbortTestrunResponse, StartTestrunRequest, StartTestrunResponse +from .utilities import wait_for_artifact_created + +ROUTER = APIRouter() +TRACER = trace.get_tracer("etos_api.routers.testrun.router") +LOGGER = logging.getLogger(__name__) +logging.getLogger("pika").setLevel(logging.WARNING) + + +async def download_suite(test_suite_url): + """Attempt to download suite. + + :param test_suite_url: URL to test suite to download. + :type test_suite_url: str + :return: Downloaded test suite as JSON. + :rtype: list + """ + try: + suite = requests.get(test_suite_url, timeout=60) + suite.raise_for_status() + except Exception as exception: # pylint:disable=broad-except + raise AssertionError(f"Unable to download suite from {test_suite_url}") from exception + return suite.json() + + +async def validate_suite(test_suite: list[dict[str, Any]]) -> None: + """Validate the ETOS test suite through the SuiteValidator. + + :param test_suite_url: The URL to the test suite to validate. + """ + span = trace.get_current_span() + + try: + await SuiteValidator().validate(test_suite) + except AssertionError as exception: + LOGGER.error("Test suite validation failed!") + LOGGER.error(exception) + span.add_event("Test suite validation failed") + raise HTTPException( + status_code=400, detail=f"Test suite validation failed. {exception}" + ) from exception + + +def convert_to_rfc1123(value: str) -> str: + """Convert string to RFC-1123 accepted string. + + https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-label-names + + Some resource types require their names to follow the DNS label standard as defined in RFC 1123. + This means the name must: + + contain at most 63 characters + contain only lowercase alphanumeric characters or '-' + start with an alphanumeric character + end with an alphanumeric character + + This method does not care about the length of the string since ETOS uses generateName for + creating Kubernetes resources and that function will truncate the string down to 63-5 and + then add 5 random characters. + """ + # Replace all characters that are not alphanumeric (A-Z, a-z, 0-9) with a hyphen + result = re.sub(r"[^A-Z\d]", "-", value, flags=re.IGNORECASE) + # Remove leading hyphens + result = re.sub(r"^-+", "", result) + # Remove trailing hyphens + result = re.sub(r"-+$", "", result) + # Replace multiple consecutive hyphens with a single hyphen + result = re.sub(r"-+", "-", result) + return result.lower() + + +async def _create_testrun(etos: StartTestrunRequest, span: Span) -> dict: + """Create a testrun for ETOS to execute. + + :param etos: Testrun pydantic model. + :param span: An opentelemetry span for tracing. + :return: JSON dictionary with response. + """ + testrun_id = str(uuid4()) + LOGGER.identifier.set(testrun_id) + span.set_attribute("etos.id", testrun_id) + + LOGGER.info("Download test suite.") + test_suite = await download_suite(etos.test_suite_url) + LOGGER.info("Test suite downloaded.") + + LOGGER.info("Validating test suite.") + await validate_suite(test_suite) + LOGGER.info("Test suite validated.") + + etos_library = ETOS("ETOS API", os.getenv("HOSTNAME", "localhost"), "ETOS API") + + LOGGER.info("Get artifact created %r", (etos.artifact_identity or str(etos.artifact_id))) + try: + artifact = await wait_for_artifact_created( + etos_library, etos.artifact_identity, etos.artifact_id + ) + except Exception as exception: # pylint:disable=broad-except + LOGGER.critical(exception) + raise HTTPException( + status_code=400, detail=f"Could not connect to GraphQL. {exception}" + ) from exception + if artifact is None: + identity = etos.artifact_identity or str(etos.artifact_id) + raise HTTPException( + status_code=400, + detail=f"Unable to find artifact with identity '{identity}'", + ) + LOGGER.info("Found artifact created %r", artifact) + # There are assumptions here. Since "edges" list is already tested + # and we know that the return from GraphQL must be 'node'.'meta'.'id' + # if there are "edges", this is fine. + # Same goes for 'data'.'identity'. + artifact_id = artifact[0]["node"]["meta"]["id"] + identity = artifact[0]["node"]["data"]["identity"] + span.set_attribute("etos.artifact.id", artifact_id) + span.set_attribute("etos.artifact.identity", identity) + + try: + # Since the TERCC that we use can have multiple names, it's quite difficult to get a + # single name that describes the entire TERCC. However ETOS mostly only gets a single + # test suite or gets a suite that has a similar name for all suites in the TERCC and + # for this reason we get the name of the first suite and that should be okay. + name = test_suite[0].get("name") + # Convert to kubernetes accepted name + name = convert_to_rfc1123(name) + # Truncate and Add a hyphen at the end, if possible since it makes the generated name + # easier to read. This truncation does not need to be validated since the generateName we + # use when creating a TestRun will truncate the string if necessary. + if not name.endswith("-"): + # 63 is the max length, 5 is the random characters added by generateName and + # 1 is to be able to fit a hyphen at the end so we truncate to 57 to fit everything. + name = f"{name[:57]}-" + except (IndexError, TypeError, ValueError): + name = f"testrun-{testrun_id}-" + LOGGER.error("Could not get name from test suite, defaulting to %s", name) + + retention = Retention( + failure=os.getenv("TESTRUN_FAILURE_RETENTION"), + success=os.getenv("TESTRUN_SUCCESS_RETENTION"), + ) + + testrun_spec = TestRunSchema( + metadata=Metadata( + generateName=name, + namespace=namespace(), + labels={ + "etos.eiffel-community.github.io/id": testrun_id, + "etos.eiffel-community.github.io/cluster": os.getenv("ETOS_CLUSTER", "Unknown"), + }, + ), + spec=TestRunSpec( + cluster=os.getenv("ETOS_CLUSTER", "Unknown"), + id=testrun_id, + retention=retention, + suiteRunner=Image( + image=os.getenv( + "SUITE_RUNNER_IMAGE", "registry.nordix.org/eiffel/etos-suite-runner:latest" + ), + imagePullPolicy=os.getenv("SUITE_RUNNER_IMAGE_PULL_POLICY", "IfNotPresent"), + ), + logListener=Image( + image=os.getenv( + "LOG_LISTENER_IMAGE", "registry.nordix.org/eiffel/etos-log-listener:latest" + ), + imagePullPolicy=os.getenv("LOG_LISTENER_IMAGE_PULL_POLICY", "IfNotPresent"), + ), + environmentProvider=Image( + image=os.getenv( + "ENVIRONMENT_PROVIDER_IMAGE", + "registry.nordix.org/eiffel/etos-environment-provider:latest", + ), + imagePullPolicy=os.getenv("ENVIRONMENT_PROVIDER_IMAGE_PULL_POLICY", "IfNotPresent"), + ), + artifact=artifact_id, + identity=identity, + testRunner=TestRunner(version=os.getenv("ETR_VERSION", "Unknown")), + providers=Providers( + iut=etos.iut_provider, + executionSpace=etos.execution_space_provider, + logArea=etos.log_area_provider, + ), + suites=TestRunSpec.from_tercc(test_suite, etos.dataset), + ), + ) + + testrun_client = TestRun(Kubernetes()) + if not testrun_client.create(testrun_spec): + raise HTTPException("Failed to create testrun") + + LOGGER.info("ETOS triggered successfully.") + return { + "tercc": testrun_id, + "artifact_id": artifact_id, + "artifact_identity": identity, + "event_repository": etos_library.debug.graphql_server, + } + + +async def _abort(suite_id: str) -> dict: + """Abort a testrun by deleting the testrun resource.""" + testrun_client = TestRun(Kubernetes()) + response = testrun_client.client.delete( + type="TestRun", + namespace=testrun_client.namespace, + label_selector=f"etos.eiffel-community.github.io/id={suite_id}", + ) # type:ignore + if not response.items: + raise HTTPException(status_code=404, detail="Suite ID not found.") + return {"message": f"Abort triggered for suite id: {suite_id}."} + + +@ROUTER.post("/v1alpha/testrun", tags=["etos", "testrun"], response_model=StartTestrunResponse) +async def start_testrun(etos: StartTestrunRequest): + """Start ETOS testrun on post. + + :param etos: ETOS pydantic model. + :type etos: :obj:`etos_api.routers.etos.schemas.StartTestrunRequest` + :return: JSON dictionary with response. + :rtype: dict + """ + with TRACER.start_as_current_span("start-etos") as span: + return await _create_testrun(etos, span) + + +@ROUTER.delete("/v1alpha/testrun/{suite_id}", tags=["etos"], response_model=AbortTestrunResponse) +async def abort_testrun(suite_id: str): + """Abort ETOS testrun on delete. + + :param suite_id: ETOS suite id + :type suite_id: str + :return: JSON dictionary with response. + :rtype: dict + """ + with TRACER.start_as_current_span("abort-etos"): + return await _abort(suite_id) + + +@ROUTER.get("/v1alpha/testrun/{sub_suite_id}", tags=["etos"]) +async def get_subsuite(sub_suite_id: str) -> dict: + """Get sub suite returns the sub suite definition for the ETOS test runner. + + :param sub_suite_id: The name of the Environment kubernetes resource. + :return: JSON dictionary with the Environment spec. Formatted to TERCC format. + """ + environment_client = Environment(Kubernetes()) + environment_resource = environment_client.get(sub_suite_id) + if not environment_resource: + raise HTTPException(404, "Failed to get environment") + environment_spec = environment_resource.to_dict().get("spec", {}) + recipes = await recipes_from_tests(environment_spec["recipes"]) + environment_spec["recipes"] = recipes + return environment_spec + + +async def recipes_from_tests(tests: list[dict]) -> list[dict]: + """Load Eiffel TERCC recipes from test. + + :param tests: The tests defined in a Test model. + :return: A list of Eiffel TERCC recipes. + """ + recipes: list[dict] = [] + for test in tests: + recipes.append( + { + "id": test["id"], + "testCase": test["testCase"], + "constraints": [ + { + "key": "ENVIRONMENT", + "value": test["execution"]["environment"], + }, + { + "key": "COMMAND", + "value": test["execution"]["command"], + }, + { + "key": "EXECUTE", + "value": test["execution"]["execute"], + }, + { + "key": "CHECKOUT", + "value": test["execution"]["checkout"], + }, + { + "key": "PARAMETERS", + "value": test["execution"]["parameters"], + }, + { + "key": "TEST_RUNNER", + "value": test["execution"]["testRunner"], + }, + ], + } + ) + return recipes diff --git a/python/src/etos_api/routers/testrun/schemas.py b/python/src/etos_api/routers/testrun/schemas.py new file mode 100644 index 0000000..9741603 --- /dev/null +++ b/python/src/etos_api/routers/testrun/schemas.py @@ -0,0 +1,80 @@ +# 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. +"""Schemas for the testrun endpoint.""" +import os +from typing import Optional, Union +from uuid import UUID + +# Pylint refrains from linting C extensions due to arbitrary code execution. +from pydantic import BaseModel, Field, field_validator # pylint:disable=no-name-in-module + +# pylint: disable=too-few-public-methods +# pylint: disable=no-self-argument + + +class TestrunRequest(BaseModel): + """Base class for testrun request models.""" + + +class TestrunResponse(BaseModel): + """Base class for testrun response models.""" + + +class StartTestrunRequest(TestrunRequest): + """Request model for the start endpoint of the ETOS testrun API.""" + + artifact_identity: Optional[str] + artifact_id: Optional[UUID] = Field(default=None, validate_default=True) + test_suite_url: str + dataset: Union[dict, list[dict]] = {} + execution_space_provider: Optional[str] = os.getenv( + "DEFAULT_EXECUTION_SPACE_PROVIDER", "default" + ) + iut_provider: Optional[str] = os.getenv("DEFAULT_IUT_PROVIDER", "default") + log_area_provider: Optional[str] = os.getenv("DEFAULT_LOG_AREA_PROVIDER", "default") + + @field_validator("artifact_id") + def validate_id_or_identity(cls, artifact_id, info): + """Validate that at least one and only one of id and identity are set. + + :param artifact_id: The value of 'artifact_id' to validate. + :value artifact_id: str or None + :param info: The information about the model. + :type info: FieldValidationInfo + :return: The value of artifact_id. + :rtype: str or None + """ + values = info.data + if values.get("artifact_identity") is None and not artifact_id: + raise ValueError("At least one of 'artifact_identity' or 'artifact_id' is required.") + if values.get("artifact_identity") is not None and artifact_id: + raise ValueError("Only one of 'artifact_identity' or 'artifact_id' is required.") + return artifact_id + + +class StartTestrunResponse(TestrunResponse): + """Response model for the start endpoint of the ETOS testrun API.""" + + event_repository: str + tercc: UUID + artifact_id: UUID + artifact_identity: str + + +class AbortTestrunResponse(TestrunResponse): + """Response model for the abort endpoint of the ETOS testrun API.""" + + message: str diff --git a/python/src/etos_api/routers/testrun/utilities.py b/python/src/etos_api/routers/testrun/utilities.py new file mode 100644 index 0000000..ebbc003 --- /dev/null +++ b/python/src/etos_api/routers/testrun/utilities.py @@ -0,0 +1,69 @@ +# 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. +"""Utilities specific for the testrun endpoint.""" +import logging +import asyncio +import time +from etos_api.library.graphql import GraphqlQueryHandler +from etos_api.library.graphql_queries import ( + ARTIFACT_IDENTITY_QUERY, + VERIFY_ARTIFACT_ID_EXISTS, +) + +LOGGER = logging.getLogger(__name__) + + +async def wait_for_artifact_created(etos_library, artifact_identity, artifact_id, timeout=30): + """Execute graphql query and wait for an artifact created. + + :param etos_library: ETOS library instande. + :type etos_library: :obj:`etos_lib.etos.ETOS` + :param artifact_identity: Identity of the artifact to get. + :type artifact_identity: str + :param artifact_id: ID of the artifact to get. + :type artifact_id: UUID + :param timeout: Maximum time to wait for a response (seconds). + :type timeout: int + :return: ArtifactCreated edges from GraphQL. + :rtype: list + """ + timeout = time.time() + timeout + query_handler = GraphqlQueryHandler(etos_library) + if artifact_id is not None: + LOGGER.info("Verify that artifact ID %r exists.", artifact_id) + query = VERIFY_ARTIFACT_ID_EXISTS + elif artifact_identity is not None: + LOGGER.info("Getting artifact from packageURL %r", artifact_identity) + query = ARTIFACT_IDENTITY_QUERY + if artifact_identity.startswith("pkg:"): + # This makes the '$regex' query to the event repository more efficient. + artifact_identity = f"^{artifact_identity}" + else: + raise ValueError("'artifact_id' and 'artifact_identity' are both None!") + artifact_identifier = artifact_identity or str(artifact_id) + + LOGGER.debug("Wait for artifact created event.") + while time.time() < timeout: + try: + artifacts = await query_handler.execute(query % artifact_identifier) + assert artifacts is not None + assert artifacts["artifactCreated"]["edges"] + return artifacts["artifactCreated"]["edges"] + except (AssertionError, KeyError): + LOGGER.warning("Artifact created not ready yet") + await asyncio.sleep(2) + LOGGER.error("Artifact %r not found.", artifact_identifier) + return None diff --git a/python/tests/library/test_validator.py b/python/tests/library/test_validator.py index 61b7763..50961d8 100644 --- a/python/tests/library/test_validator.py +++ b/python/tests/library/test_validator.py @@ -33,8 +33,7 @@ class TestValidator: pytestmark = pytest.mark.asyncio @patch("etos_api.library.validator.Docker.digest") - @patch("etos_api.library.validator.SuiteValidator._download_suite") - async def test_validate_proper_suite(self, download_suite_mock, digest_mock): + async def test_validate_proper_suite(self, digest_mock): """Test that the validator validates a proper suite correctly. Approval criteria: @@ -47,7 +46,7 @@ async def test_validate_proper_suite(self, download_suite_mock, digest_mock): digest_mock.return_value = ( "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" ) - download_suite_mock.return_value = [ + test_suite = [ { "name": "TestValidator", "priority": 1, @@ -74,15 +73,14 @@ async def test_validate_proper_suite(self, download_suite_mock, digest_mock): self.logger.info("STEP: Validate a proper suite.") validator = SuiteValidator() try: - await validator.validate("url") + await validator.validate(test_suite) exception = False except (AssertionError, ValidationError): exception = True self.logger.info("STEP: Verify that no exceptions were raised.") assert exception is False - @patch("etos_api.library.validator.SuiteValidator._download_suite") - async def test_validate_missing_constraints(self, download_suite_mock): + async def test_validate_missing_constraints(self): """Test that the validator fails when missing required constraints. Approval criteria: @@ -92,7 +90,7 @@ async def test_validate_missing_constraints(self, download_suite_mock): 1. Validate a suite with a missing constraint. 2. Verify that the validator raises ValidationError. """ - download_suite_mock.return_value = [ + test_suite = [ { "name": "TestValidator", "priority": 1, @@ -118,15 +116,14 @@ async def test_validate_missing_constraints(self, download_suite_mock): self.logger.info("STEP: Validate a suite with a missing constraint.") validator = SuiteValidator() try: - await validator.validate("url") + await validator.validate(test_suite) exception = False except ValidationError: exception = True self.logger.info("STEP: Verify that the validator raises ValidationError.") assert exception is True - @patch("etos_api.library.validator.SuiteValidator._download_suite") - async def test_validate_wrong_types(self, download_suite_mock): + async def test_validate_wrong_types(self): """Test that the validator fails when constraints have the wrong types. Approval criteria: @@ -207,13 +204,11 @@ async def test_validate_wrong_types(self, download_suite_mock): for constraint in constraints: self.logger.info("STEP: Validate constraint with wrong type.") base_suite["recipes"][0]["constraints"] = constraint - download_suite_mock.return_value = [base_suite] self.logger.info("STEP: Verify that the validator raises ValidationError.") with pytest.raises(ValidationError): - await validator.validate("url") + await validator.validate([base_suite]) - @patch("etos_api.library.validator.SuiteValidator._download_suite") - async def test_validate_too_many_constraints(self, download_suite_mock): + async def test_validate_too_many_constraints(self): """Test that the validator fails when a constraint is defined multiple times. Approval criteria: @@ -223,7 +218,7 @@ async def test_validate_too_many_constraints(self, download_suite_mock): 1. Validate a suite with a constraint defined multiple times. 2. Verify that the validator raises ValidationError. """ - download_suite_mock.return_value = [ + test_suite = [ { "name": "TestValidator", "priority": 1, @@ -251,15 +246,14 @@ async def test_validate_too_many_constraints(self, download_suite_mock): self.logger.info("STEP: Validate a suite with a constraint defined multiple times.") validator = SuiteValidator() try: - await validator.validate("url") + await validator.validate(test_suite) exception = False except ValidationError: exception = True self.logger.info("STEP: Verify that the validator raises ValidationError.") assert exception is True - @patch("etos_api.library.validator.SuiteValidator._download_suite") - async def test_validate_unknown_constraint(self, download_suite_mock): + async def test_validate_unknown_constraint(self): """Test that the validator fails when an unknown constraint is defined. Approval criteria: @@ -269,7 +263,7 @@ async def test_validate_unknown_constraint(self, download_suite_mock): 1. Validate a suite with an unknown constraint. 2. Verify that the validator raises ValidationError. """ - download_suite_mock.return_value = [ + test_suite = [ { "name": "TestValidator", "priority": 1, @@ -297,15 +291,14 @@ async def test_validate_unknown_constraint(self, download_suite_mock): self.logger.info("STEP: Validate a suite with an unknown constraint.") validator = SuiteValidator() try: - await validator.validate("url") + await validator.validate(test_suite) exception = False except TypeError: exception = True self.logger.info("STEP: Verify that the validator raises ValidationError.") assert exception is True - @patch("etos_api.library.validator.SuiteValidator._download_suite") - async def test_validate_empty_constraints(self, download_suite_mock): + async def test_validate_empty_constraints(self): """Test that required constraints are not empty. Approval criteria: @@ -360,7 +353,6 @@ async def test_validate_empty_constraints(self, download_suite_mock): validator = SuiteValidator() for constraint in constraints: base_suite["recipes"][0]["constraints"] = constraint - download_suite_mock.return_value = [base_suite] self.logger.info("STEP: Validate a suite without the required key.") with pytest.raises(ValidationError): - await validator.validate("url") + await validator.validate([base_suite]) diff --git a/python/tests/test_routers.py b/python/tests/test_routers.py index 14143a8..f856266 100644 --- a/python/tests/test_routers.py +++ b/python/tests/test_routers.py @@ -25,6 +25,7 @@ from fastapi.testclient import TestClient from etos_api.main import APP +from etos_api.routers.testrun.router import convert_to_rfc1123 from tests.fake_database import FakeDatabase logging.basicConfig(level=logging.DEBUG, stream=sys.stdout) @@ -140,7 +141,7 @@ def test_post_on_root_without_redirect(self): assert response.status_code == 308 @patch("etos_api.library.validator.Docker.digest") - @patch("etos_api.library.validator.SuiteValidator._download_suite") + @patch("etos_api.routers.etos.router.download_suite") @patch("etos_api.library.graphql.GraphqlQueryHandler.execute") def test_post_on_root_with_redirect( self, graphql_execute_mock, download_suite_mock, digest_mock @@ -215,7 +216,7 @@ def test_post_on_root_with_redirect( assert response.status_code == 200 @patch("etos_api.library.validator.Docker.digest") - @patch("etos_api.library.validator.SuiteValidator._download_suite") + @patch("etos_api.routers.etos.router.download_suite") @patch("etos_api.library.graphql.GraphqlQueryHandler.execute") def test_start_etos(self, graphql_execute_mock, download_suite_mock, digest_mock): """Test that POST requests to /etos attempts to start ETOS tests. @@ -311,7 +312,7 @@ def test_start_etos(self, graphql_execute_mock, download_suite_mock, digest_mock self.assertDictEqual(execution_space, EXECUTION_SPACE_PROVIDER) @patch("etos_api.library.validator.Docker.digest") - @patch("etos_api.library.validator.SuiteValidator._download_suite") + @patch("etos_api.routers.etos.router.download_suite") def test_start_etos_empty_suite(self, download_suite_mock, digest_mock): """Test that POST requests to /etos with an empty suite definition list fails validation and does not start ETOS tests. @@ -380,3 +381,35 @@ def test_selftest_head_ping(self): response = self.client.head("/selftest/ping") self.logger.info("STEP: Verify that the status code is 204.") assert response.status_code == 204 + + def test_convert_to_rfc1123(self): + """Test that the testrun router can convert a string to an RFC-1123 accepted string. + + Approval criteria: + - A string passed to the convert_to_rfc1123 method shall be returned as valid rfc-1123. + + Test steps:: + 1. For a set of invalid strings. + 1.1. Pass the string to the conversion method. + 1.2. Verify that the string returned is valid rfc-1123. + """ + # invalid strings contains an invalid string coupled with what it is + # expected to convert to. + invalid_strings = ( + ("Hello World!", "hello-world"), + ("123_ABC", "123-abc"), + ("No-Change", "no-change"), + ("Special@#%&*()Characters", "special-characters"), + ("Multiple Spaces", "multiple-spaces"), + ("EndWithSpecialCharacter!", "endwithspecialcharacter"), + ("@StartWithSpecialCharacter", "startwithspecialcharacter"), + ("Mixed$#Case123", "mixed-case123"), + ("singleword", "singleword"), + ("1234567890", "1234567890"), + ) + self.logger.info("STEP: For a set of invalid strings.") + for invalid, expected in invalid_strings: + self.logger.info("STEP: Pass the string to the conversion method.") + value = convert_to_rfc1123(invalid) + self.logger.info("STEP: Verify that th string returned is valid rfc-1123.") + assert value == expected diff --git a/python/tox.ini b/python/tox.ini index 15f38ab..2bc873b 100644 --- a/python/tox.ini +++ b/python/tox.ini @@ -14,6 +14,7 @@ setenv = ETOS_DISABLE_RECEIVING_EVENTS=1 ETOS_GRAPHQL_SERVER=http://localhost:8005/graphql ETOS_ENVIRONMENT_PROVIDER=http://localhost:8005/environment_provider + RUNNING_TESTS=true commands = pytest -s --log-cli-level="DEBUG" --log-format="%(levelname)s: %(message)s" {posargs} diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index abc56c5..a556f26 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -5,6 +5,5 @@ set -e # Setup requirements export GOBIN=$(pwd)/bin export PATH=$GOBIN:$PATH -make gen sleep 1 CompileDaemon --build="go build -o bin/etos-sse ./cmd/sse" --exclude-dir=".git" --exclude-dir="**/**/test" --command=./bin/etos-sse -verbose diff --git a/test/testconfig/testconfig.go b/test/testconfig/testconfig.go index d1b9d3a..3c8b004 100644 --- a/test/testconfig/testconfig.go +++ b/test/testconfig/testconfig.go @@ -55,6 +55,11 @@ func (c *cfg) ServicePort() string { return c.servicePort } +// StripPrefix returns an empty string. +func (c *cfg) StripPrefix() string { + return "" +} + // LogLevel returns the Log level testconfig parameter func (c *cfg) LogLevel() string { return c.logLevel