From 7cbd1162ff6f53a35629c4949f535194e43681d3 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 26 Sep 2025 12:26:45 +0200 Subject: [PATCH] Implement Exoscale onboarding endpoint --- src/servala/api/__init__.py | 0 src/servala/api/apps.py | 6 + src/servala/api/authentication.py | 56 ++++++++++ src/servala/api/urls.py | 13 +++ src/servala/api/views.py | 179 ++++++++++++++++++++++++++++++ src/servala/core/exoscale.py | 11 ++ 6 files changed, 265 insertions(+) create mode 100644 src/servala/api/__init__.py create mode 100644 src/servala/api/apps.py create mode 100644 src/servala/api/authentication.py create mode 100644 src/servala/api/urls.py create mode 100644 src/servala/api/views.py create mode 100644 src/servala/core/exoscale.py diff --git a/src/servala/api/__init__.py b/src/servala/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/servala/api/apps.py b/src/servala/api/apps.py new file mode 100644 index 0000000..33c94f2 --- /dev/null +++ b/src/servala/api/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "servala.api" diff --git a/src/servala/api/authentication.py b/src/servala/api/authentication.py new file mode 100644 index 0000000..f7d7479 --- /dev/null +++ b/src/servala/api/authentication.py @@ -0,0 +1,56 @@ +import base64 + +from django.conf import settings +from django.http import HttpResponse +from django.utils.translation import gettext as _ + + +class OSBBasicAuthentication: + """ + HTTP Basic Authentication for OSB API endpoints. + Uses environment variables for username/password configuration. + """ + + def __init__(self, get_response): + self.get_response = get_response + self.osb_username = getattr(settings, "OSB_USERNAME", None) + self.osb_password = getattr(settings, "OSB_PASSWORD", None) + + def __call__(self, request): + # Only apply authentication to OSB API endpoints + if request.path.startswith("/v2/"): + if not self._authenticate_request(request): + return self._authentication_required() + + return self.get_response(request) + + def _authenticate_request(self, request): + """ + Authenticate the request using HTTP Basic Authentication. + """ + if not self.osb_username or not self.osb_password: + return False + + auth_header = request.META.get("HTTP_AUTHORIZATION", "") + + if not auth_header.startswith("Basic "): + return False + + try: + encoded_credentials = auth_header[6:] # Remove 'Basic ' prefix + decoded_credentials = base64.b64decode(encoded_credentials).decode("utf-8") + username, password = decoded_credentials.split(":", 1) + + return username == self.osb_username and password == self.osb_password + except ValueError: + return False + + def _authentication_required(self): + """ + Return an HTTP 401 Unauthorized response. + """ + response = HttpResponse( + _("Authentication required"), status=401, content_type="text/plain" + ) + response["WWW-Authenticate"] = 'Basic realm="OSB API"' + return response diff --git a/src/servala/api/urls.py b/src/servala/api/urls.py new file mode 100644 index 0000000..ff6e70b --- /dev/null +++ b/src/servala/api/urls.py @@ -0,0 +1,13 @@ +from django.urls import path + +from . import views + +app_name = "api" + +urlpatterns = [ + path( + "v2/service_instances/", + views.OSBServiceInstanceView.as_view(), + name="osb_service_instance", + ), +] diff --git a/src/servala/api/views.py b/src/servala/api/views.py new file mode 100644 index 0000000..b00c25e --- /dev/null +++ b/src/servala/api/views.py @@ -0,0 +1,179 @@ +import json +import logging +from contextlib import suppress + +from django.conf import settings +from django.core.mail import send_mail +from django.db import transaction +from django.http import JsonResponse +from django.utils.decorators import method_decorator +from django.views import View +from django.views.decorators.csrf import csrf_exempt + +from servala.core.exoscale import get_exoscale_origin +from servala.core.models import BillingEntity, Organization, User +from servala.core.models.service import Plan, Service + +logger = logging.getLogger(__name__) + + +@method_decorator(csrf_exempt, name="dispatch") +class OSBServiceInstanceView(View): + """ + OSB API endpoint for service instance provisioning (onboarding). + Implements the PUT /v2/service_instances/:instance_id endpoint. + https://docs.servala.com/exoscale-osb.html#_onboarding + """ + + def _error(self, error): + return JsonResponse({"error": error}, status=400) + + def _get_user(self, data): + email = data.get("email").strip().lower() + full_name = data.get("full_name") or "" + name_parts = full_name.split(" ", 1) + first_name = name_parts[0] if name_parts else "" + last_name = name_parts[1] if len(name_parts) > 1 else "" + user, _ = User.objects.get_or_create( + email=email, + defaults={"first_name": first_name, "last_name": last_name}, + ) + return user + + def put(self, request, instance_id): + """ + This implements the Exoscale onboarding flow. + https://docs.servala.com/exoscale-osb.html#_onboarding + https://community.exoscale.com/vendor/marketplace-managed-svc-prov/#provisioning + """ + try: + data = json.loads(request.body) + except json.JSONDecodeError: + return JsonResponse({"error": "Invalid JSON in request body"}, status=400) + + context = data.get("context", {}) + parameters = data.get("parameters", {}) + + organization_guid = context.get("organization_guid") or data.get( + "organization_guid" + ) + organization_name = context.get("organization_name") + organization_display_name = context.get("organization_display_name") + users = parameters.get("users", []) + service_id = data.get("service_id") + plan_id = data.get("plan_id") + + if not organization_guid: + return self._error("organization_guid is required but missing.") + if not organization_name: + return self._error("organization_name is required but missing.") + if not users: + return self._error("users array is required but missing.") + if len(users) != 1: + return self._error("users array is expected to contain a single user.") + if not service_id: + return self._error("service_id is required but missing.") + if not plan_id: + return self._error("plan_id is required but missing.") + + try: + user = self._get_user(users[0]) + except Exception as e: + return self._error(f"Unable to create user: {e}") + + try: + service = Service.objects.get(id=service_id) + plan = Plan.objects.get(id=plan_id, service=service) + except Service.DoesNotExist: + return self._error(f"Unknown service_id: {service_id}") + except Plan.DoesNotExist: + return self._error( + f"Unknown plan_id: {plan_id} for service_id: {service_id}" + ) + + exoscale_origin = get_exoscale_origin() + with suppress(Organization.DoesNotExist): + organization = Organization.objects.get( + osb_guid=organization_guid, origin=exoscale_origin + ) + self._send_service_welcome_email(request, organization, user, service, plan) + return JsonResponse({"message": "Service already enabled"}, status=200) + + odoo_data = { + "company_name": organization_display_name, + "invoice_email": user.email, + } + try: + with transaction.atomic(): + billing_entity = BillingEntity.create_from_data( + name=f"{organization_display_name} (Exoscale)", odoo_data=odoo_data + ) + organization = Organization( + name=organization_display_name, + billing_entity=billing_entity, + origin=exoscale_origin, + osb_guid=organization_guid, + ) + organization = Organization.create_organization(organization, user) + + self._send_invitation_email(request, organization, user) + self._send_service_welcome_email( + request, organization, user, service, plan + ) + + return JsonResponse( + {"message": "Successfully enabled service"}, status=201 + ) + + except Exception as e: + logger.error(f"Error creating organization for Exoscale: {str(e)}") + return JsonResponse({"error": "Internal server error"}, status=500) + + def _send_invitation_email(self, request, organization, user): + subject = f"Welcome to Servala - {organization.name}" + url = request.build_absolute_uri(organization.urls.base) + message = f"""Hello {user.first_name or user.email}, + +You have been invited to join the organization "{organization.name}" on Servala Portal. + +You can access your organization at: {url} + +Please use this email address ({user.email}) when prompted to log in. + +Best regards, +The Servala Team""" + + send_mail( + subject=subject, + message=message, + from_email=settings.EMAIL_DEFAULT_FROM, + recipient_list=[user.email], + fail_silently=False, + ) + + def _send_service_welcome_email(self, request, organization, user, service, plan): + service_path = f"{organization.urls.services}{service.slug}/offering/{plan.service_offering_id}/" + service_url = request.build_absolute_uri(service_path) + + subject = f"Get started with {service.name} - {organization.name}" + message = f"""Hello {user.first_name or user.email}, + +Your organization "{organization.name}" is now ready on Servala Portal! + +You can create your {service.name} service directly here: +{service_url} + +Or browse all available services at: {request.build_absolute_uri(organization.urls.services)} + +Need help? Contact our support team through the portal. + +Best regards, +The Servala Team""" + + send_mail( + subject=subject, + message=message, + from_email=settings.EMAIL_DEFAULT_FROM, + recipient_list=[user.email], + fail_silently=False, + ) diff --git a/src/servala/core/exoscale.py b/src/servala/core/exoscale.py new file mode 100644 index 0000000..49b3f60 --- /dev/null +++ b/src/servala/core/exoscale.py @@ -0,0 +1,11 @@ +from servala.core.models import OrganizationOrigin + + +def get_exoscale_origin(): + origin, _ = OrganizationOrigin.objects.get_or_create( + name="exoscale-marketplace", + defaults={ + "description": "Organizations created via Exoscale marketplace onboarding" + }, + ) + return origin