Implement Exoscale onboarding endpoint
This commit is contained in:
parent
261cc5e750
commit
7cbd1162ff
6 changed files with 265 additions and 0 deletions
0
src/servala/api/__init__.py
Normal file
0
src/servala/api/__init__.py
Normal file
6
src/servala/api/apps.py
Normal file
6
src/servala/api/apps.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ApiConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "servala.api"
|
||||||
56
src/servala/api/authentication.py
Normal file
56
src/servala/api/authentication.py
Normal 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
13
src/servala/api/urls.py
Normal 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
179
src/servala/api/views.py
Normal 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,
|
||||||
|
)
|
||||||
11
src/servala/core/exoscale.py
Normal file
11
src/servala/core/exoscale.py
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue