diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bdb9670 --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +docker diff --git a/.flake8 b/.flake8 index 317d19b..ee90db2 100644 --- a/.flake8 +++ b/.flake8 @@ -10,4 +10,5 @@ per-file-ignores = ./tests/cloudevent_receiver_server.py: E501 # remove when implemented: ./src/actinia_cloudevent_plugin/api/cloudevent.py: E501 - ./src/actinia_cloudevent_plugin/core/processing.py: F841, E501 + ./src/actinia_cloudevent_plugin/api/hooks.py: F841 + ./src/actinia_cloudevent_plugin/core/cloudevents.py: F841, E501 diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..cab65f7 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,24 @@ +{ + "configurations": [ + { + "name": "Docker: Python - Flask", + "type": "docker", + "request": "launch", + "preLaunchTask": "docker-run: debug", + "python": { + "pathMappings": [ + { + "localRoot": "${workspaceFolder}", + "remoteRoot": "/src/actinia-cloudevent-plugin" + } + ], + "projectType": "flask" + }, + "dockerServerReadyAction": { + "action": "openExternally", + "pattern": "Running on (https?://\\S+|[0-9]+)", + "uriFormat": "%s://localhost:%s/" + } + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..ae7ae86 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,58 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "docker-build", + "label": "docker-build", + "platform": "python", + "dockerBuild": { + "tag": "actinia-cloudevent-plugin:latest", + "dockerfile": "${workspaceFolder}/docker/Dockerfile", + "context": "${workspaceFolder}" + } + }, + { + "type": "docker-run", + "label": "docker-run: debug", + "dependsOn": [ + "docker-build" + ], + "python": { + "module": "flask", + "args": [ + "run", + "--no-debugger", + "--host", + "0.0.0.0", + "--port", + "3003" + ] + }, + "dockerRun": { + "remove": true, + "network": "actinia-docker_actinia-dev", + "ports": [ + { + "containerPort": 3003, + "hostPort": 3003 + } + ], + "env": { + "PYTHONUNBUFFERED": "1", + "PYTHONDONWRITEBYTECODE": "1", + "FLASK_APP": "actinia_cloudevent_plugin.main", + "FLASK_DEBUG": "1", + "FLASK_ENV": "development" + }, + "customOptions": "--ip 172.18.0.12", + "volumes": [ + { + "localPath": "${workspaceFolder}", + "containerPath": "/src/actinia-cloudevent-plugin", + "permissions": "rw" + } + ] + } + } + ] +} diff --git a/docker/Dockerfile b/docker/Dockerfile index d53eca4..d119526 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,4 +1,4 @@ -FROM alpine:3.21 +FROM alpine:3.22 # python3 + pip3 # hadolint ignore=DL3018 @@ -9,23 +9,17 @@ RUN /usr/bin/python -m venv --system-site-packages --without-pip /opt/venv # hadolint ignore=DL3013 RUN python -m ensurepip && pip3 install --no-cache-dir --upgrade pip pep517 wheel -# gunicorn # hadolint ignore=DL3013 -RUN pip3 install --no-cache-dir gunicorn - -# needed for tests -# hadolint ignore=DL3013 -RUN pip3 install --no-cache-dir setuptools pwgen==0.8.2.post0 pytest==8.3.5 pytest-cov==6.0.0 +RUN pip3 install --no-cache-dir gunicorn && \ + # needed for tests + pip3 install --no-cache-dir setuptools pwgen==0.8.2.post0 pytest==8.3.5 pytest-cov==6.0.0 COPY . /src/actinia-cloudevent-plugin/ - -# SETUPTOOLS_SCM_PRETEND_VERSION is only needed if in the plugin folder is no -# .git folder -ENV SETUPTOOLS_SCM_PRETEND_VERSION=0.0 +RUN pip3 install --no-cache-dir -e /src/actinia-cloudevent-plugin/ && \ + # For tests: + chmod a+x /src/actinia-cloudevent-plugin/tests_with_cloudevent_receiver.sh WORKDIR /src/actinia-cloudevent-plugin -RUN pip3 install --no-cache-dir -e . - -# For tests: -RUN chmod a+x tests_with_cloudevent_receiver.sh && make install # RUN make test + +CMD ["gunicorn", "-b", "0.0.0.0:8088", "-w", "8", "--access-logfile=-", "-k", "gthread", "actinia_cloudevent_plugin.main:flask_app"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 39b7c70..54e6607 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -11,4 +11,4 @@ services: - SYS_PTRACE ports: - "5000:5000" - network_mode: "host" + # network_mode: "host" diff --git a/src/actinia_cloudevent_plugin/api/cloudevent.py b/src/actinia_cloudevent_plugin/api/cloudevent.py index cfcf4cf..e428398 100644 --- a/src/actinia_cloudevent_plugin/api/cloudevent.py +++ b/src/actinia_cloudevent_plugin/api/cloudevent.py @@ -27,7 +27,7 @@ from requests.exceptions import ConnectionError # noqa: A004 from actinia_cloudevent_plugin.apidocs import cloudevent -from actinia_cloudevent_plugin.core.processing import ( +from actinia_cloudevent_plugin.core.cloudevents import ( cloud_event_to_process_chain, receive_cloud_event, send_binary_cloud_event, diff --git a/src/actinia_cloudevent_plugin/api/hooks.py b/src/actinia_cloudevent_plugin/api/hooks.py new file mode 100644 index 0000000..b1ee73c --- /dev/null +++ b/src/actinia_cloudevent_plugin/api/hooks.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python +"""Copyright (c) 2025 mundialis GmbH & Co. KG. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +Hello World class +""" + +__license__ = "GPLv3" +__author__ = "Carmen Tawalika" +__copyright__ = "Copyright 2025 mundialis GmbH & Co. KG" +__maintainer__ = "mundialis GmbH & Co. KG" + +import json + +import requests +from cloudevents.conversion import to_binary +from cloudevents.http import CloudEvent +from flask import jsonify, make_response, request +from flask_restful_swagger_2 import Resource, swagger + +from actinia_cloudevent_plugin.apidocs import hooks +from actinia_cloudevent_plugin.model.response_models import ( + SimpleStatusCodeResponseModel, +) +from actinia_cloudevent_plugin.resources.config import EVENTRECEIVER + + +class Hooks(Resource): + """Webhook handling.""" + + def get(self, source_name): + """Cloudevent get method: not allowed response.""" + _source_name = source_name + res = jsonify( + SimpleStatusCodeResponseModel( + status=405, + message="Method Not Allowed", + ), + ) + return make_response(res, 405) + + def head(self, source_name): + """Cloudevent head method: return empty response.""" + _source_name = source_name + return make_response("", 200) + + @swagger.doc(hooks.describe_hooks_post_docs) + def post(self, source_name) -> SimpleStatusCodeResponseModel: + """Translate actinia webhook call to cloudevent. + + This method is called by HTTP POST actinia-core webhook + """ + # only actinia as source supported so far + if source_name != "actinia": + return make_response( + jsonify( + SimpleStatusCodeResponseModel( + status=400, + message="Bad Request: Source name not 'actinia'", + ), + ), + 400, + ) + + postbody = request.get_json(force=True) + + if type(postbody) is dict: + postbody = json.dumps(postbody) + elif not isinstance(postbody, str): + postbody = str(postbody) + + resp = json.loads(postbody) + if "resource_id" not in resp: + return make_response( + jsonify( + SimpleStatusCodeResponseModel( + status=400, + message="Bad Request: No resource_id found in request", + ), + ), + 400, + ) + + # TODO: define when to send cloudevent + status = resp["status"] + if status == "finished": + # TODO send cloudevent + pass + terminate_status = ["finished", "error", "terminated"] + if status in terminate_status: + # TODO send cloudevent + pass + + # TODO: move to common function from core.cloudevents + url = EVENTRECEIVER.url + try: + attributes = { + "specversion": "1.0", + "source": "/actinia-cloudevent-plugin", + "type": "com.mundialis.actinia.process.status", + "subject": "nc_spm_08/PERMANENT", + "datacontenttype": "application/json", + } + data = {"actinia_job": resp} + event = CloudEvent(attributes, data) + headers, body = to_binary(event) + requests.post(url, headers=headers, data=body) + except ConnectionError as e: + return f"Connection ERROR when returning cloudevent: {e}" + except Exception() as e: + return f"ERROR when returning cloudevent: {e}" + + res = jsonify( + SimpleStatusCodeResponseModel( + status=200, + message="Thank you for your update", + ), + ) + return make_response(res, 200) diff --git a/src/actinia_cloudevent_plugin/apidocs/hooks.py b/src/actinia_cloudevent_plugin/apidocs/hooks.py new file mode 100644 index 0000000..cbd7b2b --- /dev/null +++ b/src/actinia_cloudevent_plugin/apidocs/hooks.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python +"""Copyright (c) 2025 mundialis GmbH & Co. KG. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +Apidocs for webhook endpoint +""" + +__license__ = "GPLv3" +__author__ = "Carmen Tawalika" +__copyright__ = "Copyright 2025 mundialis GmbH & Co. KG" +__maintainer__ = "mundialis GmbH & Co. KG" + + +from actinia_cloudevent_plugin.model.response_models import ( + SimpleStatusCodeResponseModel, +) + +describe_hooks_post_docs = { + # "summary" is taken from the description of the get method + "tags": ["cloudevent"], + "description": ( + "Receives webhook with status update e.g. from actinia-core," + " transforms to cloudevent and sends it to configurable endpoint." + ), + "responses": { + "200": { + "description": ( + "This response returns a cloud event, " + "generated from actinia-core status" + ), + "schema": SimpleStatusCodeResponseModel, + }, + "400": { + "description": "This response returns an error message", + "schema": SimpleStatusCodeResponseModel, + }, + }, +} diff --git a/src/actinia_cloudevent_plugin/core/processing.py b/src/actinia_cloudevent_plugin/core/cloudevents.py similarity index 100% rename from src/actinia_cloudevent_plugin/core/processing.py rename to src/actinia_cloudevent_plugin/core/cloudevents.py diff --git a/src/actinia_cloudevent_plugin/endpoints.py b/src/actinia_cloudevent_plugin/endpoints.py index 8292420..f56eeac 100644 --- a/src/actinia_cloudevent_plugin/endpoints.py +++ b/src/actinia_cloudevent_plugin/endpoints.py @@ -23,41 +23,15 @@ __maintainer__ = "mundialis GmbH & Co. KG" -import sys - -import werkzeug -from flask import current_app, send_from_directory from flask_restful_swagger_2 import Api from actinia_cloudevent_plugin.api.cloudevent import Cloudevent -from actinia_cloudevent_plugin.resources.logging import log +from actinia_cloudevent_plugin.api.hooks import Hooks # endpoints loaded if run as actinia-core plugin as well as standalone app def create_endpoints(flask_api: Api) -> None: """Create plugin endpoints.""" - app = flask_api.app apidoc = flask_api - - package = sys._getframe().f_back.f_globals["__package__"] # noqa: SLF001 - if package != "actinia_core": - - @app.route("/") - def index(): - try: - return current_app.send_static_file("index.html") - except werkzeug.exceptions.NotFound: - log.debug("No index.html found. Serving backup.") - # when actinia-cloudevent-plugin is installed in single mode, - # the swagger endpoint would be "latest/api/swagger.json". - # As api docs exist in single mode, - # use this fallback for plugin mode. - return """

actinia-metadata-plugin

- API docs""" - - @app.route("/") - def static_content(filename): - # WARNING: all content from folder "static" will be accessible! - return send_from_directory(app.static_folder, filename) - apidoc.add_resource(Cloudevent, "/") + apidoc.add_resource(Hooks, "/hooks/") diff --git a/src/actinia_cloudevent_plugin/main.py b/src/actinia_cloudevent_plugin/main.py index 031ae70..510e64c 100644 --- a/src/actinia_cloudevent_plugin/main.py +++ b/src/actinia_cloudevent_plugin/main.py @@ -42,7 +42,7 @@ apidoc = Api( flask_app, title="actinia-cloudevent-plugin", - prefix=URL_PREFIX, + # prefix=URL_PREFIX, api_version=API_VERSION, api_spec_url=f"{URL_PREFIX}/swagger", schemes=["https", "http"], @@ -53,8 +53,7 @@ """, ) -create_endpoints(apidoc) - +create_endpoints(flask_api=apidoc) if __name__ == "__main__": # call this for development only with: diff --git a/tests/integrationtests/test_cloudevent.py b/tests/integrationtests/test_cloudevent.py index 6d86412..33d24b1 100644 --- a/tests/integrationtests/test_cloudevent.py +++ b/tests/integrationtests/test_cloudevent.py @@ -69,7 +69,7 @@ def test_post_cloudevent(self) -> None: # Test post method resp = self.app.post( - f"{self.URL_PREFIX}/", + "/", data=json.dumps(cloudevent_json), content_type="application/json", ) @@ -100,7 +100,7 @@ def test_post_cloudevent(self) -> None: @pytest.mark.integrationtest def test_get_cloudevent(self) -> None: """Test the get method of the / endpoint.""" - resp = self.app.get(f"{self.URL_PREFIX}/") + resp = self.app.get("/") assert isinstance( resp, Response,