From 573b9d3c2fd7cedad8fcb5b57d542c3b9858b2e3 Mon Sep 17 00:00:00 2001 From: Andrei Matveyeu Date: Tue, 20 Feb 2024 13:47:04 +0100 Subject: [PATCH] Abort running suites using DELETE request --- manifests/base/role.yaml | 26 +++++++---- python/src/etos_api/routers/etos/router.py | 43 ++++++++++++++++- python/src/etos_api/routers/etos/schemas.py | 22 +++++++-- python/src/etos_api/routers/lib/__init__.py | 16 +++++++ python/src/etos_api/routers/lib/kubernetes.py | 46 +++++++++++++++++++ python/src/etos_api/routers/logs/router.py | 25 ++-------- 6 files changed, 143 insertions(+), 35 deletions(-) create mode 100644 python/src/etos_api/routers/lib/__init__.py create mode 100644 python/src/etos_api/routers/lib/kubernetes.py diff --git a/manifests/base/role.yaml b/manifests/base/role.yaml index 71f4058..4f886db 100644 --- a/manifests/base/role.yaml +++ b/manifests/base/role.yaml @@ -9,11 +9,21 @@ metadata: rbac.authorization.kubernetes.io/autoupdate: "true" name: etos-api:sa:pod-reader rules: -- apiGroups: - - "" - resources: - - pods - verbs: - - get - - list - - watch + # apiGroups batch shall be defined before apiGroups "" + - apiGroups: + - "batch" + resources: + - jobs + verbs: + - get + - delete + - list + - watch + - apiGroups: + - "" + resources: + - pods + verbs: + - get + - list + - watch diff --git a/python/src/etos_api/routers/etos/router.py b/python/src/etos_api/routers/etos/router.py index d9ef055..3f30b0a 100644 --- a/python/src/etos_api/routers/etos/router.py +++ b/python/src/etos_api/routers/etos/router.py @@ -21,14 +21,16 @@ from eiffellib.events import EiffelTestExecutionRecipeCollectionCreatedEvent from etos_lib import ETOS from fastapi import APIRouter, HTTPException +from kubernetes import client from opentelemetry import trace from etos_api.library.utilities import sync_to_async from etos_api.library.validator import SuiteValidator from etos_api.routers.environment_provider.router import configure_environment_provider from etos_api.routers.environment_provider.schemas import ConfigureEnvironmentProviderRequest +from etos_api.routers.lib.kubernetes import namespace -from .schemas import StartEtosRequest, StartEtosResponse +from .schemas import AbortEtosResponse, StartEtosRequest, StartEtosResponse from .utilities import wait_for_artifact_created ROUTER = APIRouter() @@ -142,6 +144,32 @@ async def _start(etos: StartEtosRequest, span: "Span") -> dict: } +async def _abort(suite_id: str) -> dict: + ns = namespace() + + batch_api = client.BatchV1Api() + jobs = batch_api.list_namespaced_job(namespace=ns) + + delete_options = client.V1DeleteOptions( + propagation_policy="Background" # asynchronous cascading deletion + ) + + for job in jobs.items: + if ( + job.metadata.labels.get("app") == "suite-runner" + and job.metadata.labels.get("id") == suite_id + ): + batch_api.delete_namespaced_job( + name=job.metadata.name, namespace=ns, body=delete_options + ) + LOGGER.info("Deleted suite-runner job: %s", job.metadata.name) + break + else: + raise HTTPException(status_code=404, detail="Suite ID not found.") + + return {"message": f"Abort triggered for suite id: {suite_id}."} + + @ROUTER.post("/etos", tags=["etos"], response_model=StartEtosResponse) async def start_etos(etos: StartEtosRequest): """Start ETOS execution on post. @@ -153,3 +181,16 @@ async def start_etos(etos: StartEtosRequest): """ with TRACER.start_as_current_span("start-etos") as span: return await _start(etos, span) + + +@ROUTER.delete("/etos/{suite_id}", tags=["etos"], response_model=AbortEtosResponse) +async def abort_etos(suite_id: str): + """Abort ETOS execution 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) diff --git a/python/src/etos_api/routers/etos/schemas.py b/python/src/etos_api/routers/etos/schemas.py index 0098968..8b2d60a 100644 --- a/python/src/etos_api/routers/etos/schemas.py +++ b/python/src/etos_api/routers/etos/schemas.py @@ -25,8 +25,16 @@ # pylint: disable=no-self-argument -class StartEtosRequest(BaseModel): - """Request model for the ETOS start API.""" +class EtosRequest(BaseModel): + """Base class for ETOS request models.""" + + +class EtosResponse(BaseModel): + """Base class for ETOS response models.""" + + +class StartEtosRequest(EtosRequest): + """Request model for the start endpoint of the ETOS API.""" artifact_identity: Optional[str] artifact_id: Optional[UUID] @@ -56,10 +64,16 @@ def validate_id_or_identity(cls, artifact_id, values): return artifact_id -class StartEtosResponse(BaseModel): - """Response model for the ETOS start API.""" +class StartEtosResponse(EtosResponse): + """Response model for the start endpoint of the ETOS API.""" event_repository: str tercc: UUID artifact_id: UUID artifact_identity: str + + +class AbortEtosResponse(EtosResponse): + """Response model for the abort endpoint of the ETOS API.""" + + message: str diff --git a/python/src/etos_api/routers/lib/__init__.py b/python/src/etos_api/routers/lib/__init__.py new file mode 100644 index 0000000..226cd9c --- /dev/null +++ b/python/src/etos_api/routers/lib/__init__.py @@ -0,0 +1,16 @@ +# 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. +"""Generic helpers for all submodules.""" diff --git a/python/src/etos_api/routers/lib/kubernetes.py b/python/src/etos_api/routers/lib/kubernetes.py new file mode 100644 index 0000000..ab60d26 --- /dev/null +++ b/python/src/etos_api/routers/lib/kubernetes.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. +"""Generic Kubernetes helpers for all submodules.""" +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.""" + if not os.path.isfile(NAMESPACE_FILE): + LOGGER.warning("Not running in Kubernetes? Namespace file not found: %s", NAMESPACE_FILE) + etos_ns = os.getenv("ETOS_NAMESPACE") + if etos_ns: + LOGGER.warning("Defauling to environment variable 'ETOS_NAMESPACE': %s", etos_ns) + else: + LOGGER.warning("ETOS_NAMESPACE environment variable not set!") + LOGGER.warning("Failed to determine Kubernetes namespace!") + return etos_ns + with open(NAMESPACE_FILE, encoding="utf-8") as namespace_file: + return namespace_file.read() diff --git a/python/src/etos_api/routers/logs/router.py b/python/src/etos_api/routers/logs/router.py index 13487f0..3cd39f9 100644 --- a/python/src/etos_api/routers/logs/router.py +++ b/python/src/etos_api/routers/logs/router.py @@ -16,39 +16,20 @@ """ETOS API log handler.""" import asyncio import logging -import os from uuid import UUID import httpx from fastapi import APIRouter, HTTPException -from kubernetes import client, config +from kubernetes import client from sse_starlette.sse import EventSourceResponse from starlette.requests import Request +from etos_api.routers.lib.kubernetes import namespace + NAMESPACE_FILE = "/var/run/secrets/kubernetes.io/serviceaccount/namespace" LOGGER = logging.getLogger(__name__) ROUTER = APIRouter() -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.""" - if not os.path.isfile(NAMESPACE_FILE): - LOGGER.warning( - "Not running in Kubernetes. Cannot figure out namespace. " - "Defaulting to environment variable 'ETOS_NAMESPACE'." - ) - return os.getenv("ETOS_NAMESPACE") - with open(NAMESPACE_FILE, encoding="utf-8") as namespace_file: - return namespace_file.read() - @ROUTER.get("/logs/{uuid}", tags=["logs"]) async def get_logs(uuid: UUID, request: Request):