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