Implement Exoscale onboarding API endpoint #199

Merged
rixx merged 14 commits from 37-exoscale-onboarding into main 2025-10-03 07:04:44 +00:00
6 changed files with 265 additions and 0 deletions
Showing only changes of commit 7cbd1162ff - Show all commits

View file

6
src/servala/api/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ApiConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "servala.api"

View file

@ -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

13
src/servala/api/urls.py Normal file
View file

@ -0,0 +1,13 @@
from django.urls import path
from . import views
app_name = "api"
urlpatterns = [
path(
"v2/service_instances/<str:instance_id>",
views.OSBServiceInstanceView.as_view(),
name="osb_service_instance",
),
]

179
src/servala/api/views.py Normal file
View file

@ -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,
)

View file

@ -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