diff --git a/gateway/conf.py b/gateway/conf.py index 500007e..e40f065 100644 --- a/gateway/conf.py +++ b/gateway/conf.py @@ -25,7 +25,7 @@ class Settings(BaseModel): # Service URLs RESULTS_SERVICE_URL: str = os.getenv("RESULTS_SERVICE_URL", "http://localhost:8000") PODORC_SERVICE_URL: str = os.getenv("PODORC_SERVICE_URL") - KONG_SERVICE_URL: str = os.getenv("RESULTS_SERVICE_URL", "http://localhost:8000") + KONG_ADMIN_SERVICE_URL: str = os.getenv("RESULTS_SERVICE_URL", "http://localhost:8001") # UI ID and secret UI_CLIENT_ID: str = os.getenv("UI_CLIENT_ID", "test-client") diff --git a/gateway/models/k8s.py b/gateway/models/k8s.py index 7401456..b8d800f 100644 --- a/gateway/models/k8s.py +++ b/gateway/models/k8s.py @@ -1,3 +1,4 @@ +"""Endpoints for the pod orchestrator.""" from typing import Optional from uuid import UUID diff --git a/gateway/models/kong.py b/gateway/models/kong.py index e69de29..7183bbc 100644 --- a/gateway/models/kong.py +++ b/gateway/models/kong.py @@ -0,0 +1,51 @@ +"""Models for the Kong microservice.""" +from enum import Enum + +from kong_admin_client import CreateServiceRequest, CreateServiceRequestClientCertificate, Plugin, Consumer, KeyAuth, \ + ACL +from kong_admin_client.models.service import Service +from pydantic import BaseModel, constr + + +class DataStoreType(Enum): + """Data store types.""" + S3: str = "s3" + FHIR: str = "fhir" + + +class Services(BaseModel): + """Data store list response model.""" + data: list[Service] + offset: int | None = None + + +class ServiceRequest(CreateServiceRequest): + """Improved version of the CreateServiceRequest with better defaults.""" + protocol: str | None = "http" + port: int | None = 80 + path: str | None = "/somewhere" + client_certificate: CreateServiceRequestClientCertificate | None = None + tls_verify: bool | None = None + ca_certificates: list[str] | None = None + + +class LinkDataStoreProject(BaseModel): + route: Service + keyauth: Plugin + acl: Plugin + + +class LinkProjectAnalysis(BaseModel): + consumer: Consumer + keyauth: KeyAuth + acl: ACL + + +class Disconnect(BaseModel): + """Response from disconnecting a project from a datastore.""" + removed_routes: list[str] | None + status: str | None + + +HttpMethodCode = constr(pattern=r"(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD|CONNECT|TRACE|CUSTOM)") +ProtocolCode = constr(pattern=r"(http|grpc|grpcs|tls|tcp)") diff --git a/gateway/routers/hub.py b/gateway/routers/hub.py index f833b6b..53c6b2a 100644 --- a/gateway/routers/hub.py +++ b/gateway/routers/hub.py @@ -10,8 +10,9 @@ from gateway.auth import hub_oauth2_scheme from gateway.conf import gateway_settings from gateway.core import route -from gateway.models import ImageDataResponse, ContainerResponse, Project, AllProjects, \ - ApprovalStatus, AnalysisOrProjectNode, ListAnalysisNodes, ListAnalysisOrProjectNodes, AnalysisNode +from gateway.models.hub import Project, AllProjects, ApprovalStatus, AnalysisOrProjectNode, ListAnalysisNodes, \ + ListAnalysisOrProjectNodes, AnalysisNode +from gateway.models.k8s import ImageDataResponse, ContainerResponse hub_router = APIRouter( dependencies=[Security(hub_oauth2_scheme)], diff --git a/gateway/routers/k8s.py b/gateway/routers/k8s.py index 087093d..85fe180 100644 --- a/gateway/routers/k8s.py +++ b/gateway/routers/k8s.py @@ -1,3 +1,4 @@ +"""EPs for the pod orchestrator.""" import logging from typing import Annotated diff --git a/gateway/routers/kong.py b/gateway/routers/kong.py new file mode 100644 index 0000000..7621ab8 --- /dev/null +++ b/gateway/routers/kong.py @@ -0,0 +1,402 @@ +"""EPs for the kong service.""" +import logging +from typing import Annotated + +import kong_admin_client +from fastapi import APIRouter, HTTPException, Body, Path +from kong_admin_client import CreateServiceRequest, Service, CreateRouteRequest, CreatePluginForConsumerRequest, \ + ListRoute200Response, CreateConsumerRequest, CreateAclForConsumerRequest, CreateKeyAuthForConsumerRequest +from kong_admin_client.rest import ApiException +from starlette import status + +# from gateway.auth import idp_oauth2_scheme +from gateway.conf import gateway_settings +from gateway.models.kong import Services, ServiceRequest, HttpMethodCode, ProtocolCode, LinkDataStoreProject, \ + Disconnect, LinkProjectAnalysis + +kong_router = APIRouter( + # dependencies=[Security(idp_oauth2_scheme)], + tags=["Kong"], + responses={404: {"description": "Not found"}}, +) + +logger = logging.getLogger(__name__) +kong_admin_url = gateway_settings.KONG_ADMIN_SERVICE_URL + + +@kong_router.get("/datastore", response_model=Services) +async def list_data_stores(): + """List all available data stores.""" + configuration = kong_admin_client.Configuration(host=kong_admin_url) + + try: + with kong_admin_client.ApiClient(configuration) as api_client: + api_instance = kong_admin_client.ServicesApi(api_client) + return api_instance.list_service() + + except ApiException as e: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(e), + headers={"WWW-Authenticate": "Bearer"}, + ) + + except Exception: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Service error", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +@kong_router.get("/datastore/{project_name}", status_code=status.HTTP_200_OK, response_model=ListRoute200Response) +async def list_data_stores_by_project( + project_name: Annotated[str, Path(description="Unique name of project.")] +): + """List all the data stores connected to this project.""" + configuration = kong_admin_client.Configuration(host=kong_admin_url) + + try: + with kong_admin_client.ApiClient(configuration) as api_client: + api_instance = kong_admin_client.RoutesApi(api_client) + api_response = api_instance.list_route(tags=project_name) + + for route in api_response.data: + logger.info(f"Project {project_name} connected to data store id: {route.service.id}") + + if len(api_response.data) == 0: + logger.info("No data stores connected to project.") + + return api_response + + except ApiException as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e), + headers={"WWW-Authenticate": "Bearer"}, + ) + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Service error: {e}", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +@kong_router.put("/datastore", response_model=Service, status_code=status.HTTP_201_CREATED) +async def create_data_store(data: Annotated[ServiceRequest, Body( + description="Required information for creating a new data store.", + title="Data store metadata." +)]): + """Create a datastore by providing necessary metadata.""" + configuration = kong_admin_client.Configuration(host=kong_admin_url) + + try: + with kong_admin_client.ApiClient(configuration) as api_client: + api_instance = kong_admin_client.ServicesApi(api_client) + create_service_request = CreateServiceRequest( + host=data.host, + path=data.path, + port=data.port, + protocol=data.protocol, + name=data.name, + enabled=data.enabled, + tls_verify=data.tls_verify, + tags=data.tags, + ) + api_response = api_instance.create_service(create_service_request) + return api_response + + except ApiException as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e), + headers={"WWW-Authenticate": "Bearer"}, + ) + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Service error: {e}", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +@kong_router.put("/datestore/project", response_model=LinkDataStoreProject, status_code=status.HTTP_202_ACCEPTED) +async def connect_project_to_datastore( + data_store_id: Annotated[str, Body(description="UUID of the data store or 'gateway'")], + project_id: Annotated[str, Body(description="UUID of the project")], + methods: Annotated[ + list[HttpMethodCode], + Body(description="List of acceptable HTTP methods") + ] = ["GET", "POST", "PUT", "DELETE"], + protocols: Annotated[ + list[ProtocolCode], + Body(description="List of acceptable transfer protocols. A combo of 'http', 'grpc', 'grpcs', 'tls', 'tcp'") + ] = ["http"], + ds_type: Annotated[str, Body(description="Data store type. Either 's3' or 'fhir'")] = "fhir", +): + """Create a new project and link it to a data store.""" + configuration = kong_admin_client.Configuration(host=kong_admin_url) + response = {} + + # Construct path from project_id and type + path = f"/{project_id}/{ds_type}" + + # Add route + try: + with kong_admin_client.ApiClient(configuration) as api_client: + api_instance = kong_admin_client.RoutesApi(api_client) + create_route_request = CreateRouteRequest( + name=project_id, + protocols=protocols, + methods=methods, + paths=[path], + https_redirect_status_code=426, + preserve_host=False, + request_buffering=True, + response_buffering=True, + tags=[project_id, ds_type], + ) + api_response = api_instance.create_route_for_service( + data_store_id, create_route_request + ) + route_id = api_response.id + response["route"] = api_response + + except ApiException as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e), + headers={"WWW-Authenticate": "Bearer"}, + ) + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Service error: {e}", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Add key-auth plugin + try: + with kong_admin_client.ApiClient(configuration) as api_client: + api_instance = kong_admin_client.PluginsApi(api_client) + create_route_request = CreatePluginForConsumerRequest( + name="key-auth", + instance_name=f"{project_id}-keyauth", + config={ + "hide_credentials": True, + "key_in_body": False, + "key_in_header": True, + "key_in_query": False, + "key_names": ["apikey"], + "run_on_preflight": True, + }, + enabled=True, + protocols=protocols, + ) + api_response = api_instance.create_plugin_for_route( + route_id, create_route_request + ) + response["keyauth"] = api_response + + except ApiException as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e), + headers={"WWW-Authenticate": "Bearer"}, + ) + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Service error: {e}", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Add acl plugin + try: + with kong_admin_client.ApiClient(configuration) as api_client: + api_instance = kong_admin_client.PluginsApi(api_client) + create_route_request = CreatePluginForConsumerRequest( + name="acl", + instance_name=f"{project_id}-acl", + config={"allow": [project_id], "hide_groups_header": True}, + enabled=True, + protocols=protocols, + ) + api_response = api_instance.create_plugin_for_route( + route_id, create_route_request + ) + response["acl"] = api_response + + except ApiException as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e), + headers={"WWW-Authenticate": "Bearer"}, + ) + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Service error: {e}", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return response + + +@kong_router.put("/disconnect/{project_name}", status_code=status.HTTP_200_OK, response_model=Disconnect) +async def disconnect_project( + project_name: Annotated[str, Path(description="Unique name of project to be disconnected")] +): + """Disconnect a project from all connected data stores.""" + configuration = kong_admin_client.Configuration(host=kong_admin_url) + + try: + with kong_admin_client.ApiClient(configuration) as api_client: + api_instance = kong_admin_client.RoutesApi(api_client) + api_response = api_instance.list_route(tags=project_name) + removed_routes = [] + for route in api_response.data: + # Delete route + try: + api_instance = kong_admin_client.RoutesApi(api_client) + api_instance.delete_route(route.id) + logger.info( + f"Project {project_name} disconnected from data store {route.service.id}" + ) + removed_routes.append(route.id) + + except ApiException as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e), + headers={"WWW-Authenticate": "Bearer"}, + ) + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Service error: {e}", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return {"removed_routes": removed_routes, "status": status.HTTP_200_OK} + + except ApiException as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e), + headers={"WWW-Authenticate": "Bearer"}, + ) + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Service error: {e}", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +@kong_router.put("/project/analysis", response_model=LinkProjectAnalysis, status_code=status.HTTP_202_ACCEPTED) +async def connect_analysis_to_project( + project_id: Annotated[str, Body(description="UUID of the project")], + analysis_id: Annotated[str, Body(description="UUID of the data store or 'gateway'")], +): + """Create a new analysis and link it to a project.""" + configuration = kong_admin_client.Configuration(host=kong_admin_url) + response = {} + + try: + with kong_admin_client.ApiClient(configuration) as api_client: + api_instance = kong_admin_client.ConsumersApi(api_client) + api_response = api_instance.create_consumer( + CreateConsumerRequest( + username=analysis_id, + custom_id=analysis_id, + tags=[project_id], + ) + ) + logger.info(f"Consumer added, id: {api_response.id}") + + consumer_id = api_response.id + response["consumer"] = api_response + + except ApiException as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e), + headers={"WWW-Authenticate": "Bearer"}, + ) + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Service error: {e}", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Configure acl plugin for consumer + try: + with kong_admin_client.ApiClient(configuration) as api_client: + api_instance = kong_admin_client.ACLsApi(api_client) + api_response = api_instance.create_acl_for_consumer( + consumer_id, + CreateAclForConsumerRequest( + group=project_id, + tags=[project_id], + ), + ) + logger.info( + f"ACL plugin configured for consumer, group: {api_response.group}" + ) + response["acl"] = api_response + + except ApiException as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e), + headers={"WWW-Authenticate": "Bearer"}, + ) + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Service error: {e}", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Configure key-auth plugin for consumer + try: + with kong_admin_client.ApiClient(configuration) as api_client: + api_instance = kong_admin_client.KeyAuthsApi(api_client) + api_response = api_instance.create_key_auth_for_consumer( + consumer_id, + CreateKeyAuthForConsumerRequest( + tags=[project_id], + ), + ) + logger.info( + f"Key authentication plugin configured for consumer, api_key: {api_response.key}" + ) + response["keyauth"] = api_response + + except ApiException as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e), + headers={"WWW-Authenticate": "Bearer"}, + ) + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Service error: {e}", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return response diff --git a/gateway/routers/metadata.py b/gateway/routers/metadata.py index ccf6f8d..9ae2865 100644 --- a/gateway/routers/metadata.py +++ b/gateway/routers/metadata.py @@ -3,7 +3,7 @@ from fastapi import APIRouter from gateway.conf import gateway_settings -from gateway.models import KeycloakConfig +from gateway.models.conf import KeycloakConfig metadata_router = APIRouter( # dependencies=[Security(oauth2_scheme)], diff --git a/gateway/server.py b/gateway/server.py index 080253c..3391555 100644 --- a/gateway/server.py +++ b/gateway/server.py @@ -6,10 +6,7 @@ from starlette.middleware.cors import CORSMiddleware from gateway.models import HealthCheck -from gateway.routers.hub import hub_router -from gateway.routers.k8s import k8s_router -from gateway.routers.metadata import metadata_router -from gateway.routers.results import results_router +from gateway.routers.kong import kong_router # API metadata tags_metadata = [ @@ -62,20 +59,24 @@ def get_health() -> HealthCheck: return HealthCheck(status="OK") -app.include_router( - k8s_router, -) - -app.include_router( - results_router, -) - -app.include_router( - metadata_router, -) +# app.include_router( +# k8s_router, +# ) +# +# app.include_router( +# results_router, +# ) +# +# app.include_router( +# metadata_router, +# ) +# +# app.include_router( +# hub_router, +# ) app.include_router( - hub_router, + kong_router, ) if __name__ == "__main__":