diff --git a/.env.example b/.env.example index ab361d7..998150c 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,7 @@ SERVALA_EMAIL_PASSWORD='' # At most one of the following settings may be set to True SERVALA_EMAIL_TLS='False' SERVALA_EMAIL_SSL='False' +SERVALA_EMAIL_DEFAULT_FROM='noreply@servala.com' # If the default OrganizationOrigin is **not** the one with the database ID 1, set it here. SERVALA_DEFAULT_ORIGIN='1' @@ -68,3 +69,7 @@ SERVALA_ODOO_USERNAME='' SERVALA_ODOO_PASSWORD='' # Helpdesk team ID for support tickets in Odoo. Defaults to 5. SERVALA_ODOO_HELPDESK_TEAM_ID='5' + +# OSB API authentication settings +SERVALA_OSB_USERNAME='' +SERVALA_OSB_PASSWORD='' diff --git a/README.md b/README.md index 89d0526..1192060 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,12 @@ uv run --env-file=.env src/manage.py runserver This will start the development server on http://localhost:8000. +For testing mail sending, `smtp4dev` can be used: + +``` +docker run --rm -it -p 5000:80 -p 2525:25 docker.io/rnwood/smtp4dev +``` + ## Configuration Configuration happens using environment variables. diff --git a/pyproject.toml b/pyproject.toml index a4061d2..273101a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ dev = [ "pytest>=8.4.2", "pytest-cov>=6.3.0", "pytest-django>=4.11.1", + "pytest-mock>=3.15.1", ] [tool.isort] @@ -54,7 +55,7 @@ ignore = "E203,W503" extend_exclude = "src/servala/static/mazer" [tool.pytest.ini_options] -DJANGO_SETTINGS_MODULE = "servala.settings" +DJANGO_SETTINGS_MODULE = "servala.settings_test" addopts = "-p no:doctest -p no:pastebin -p no:nose --cov=./ --cov-report=term-missing:skip-covered" testpaths = "src/tests" pythonpath = "src" 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/permissions.py b/src/servala/api/permissions.py new file mode 100644 index 0000000..25a015d --- /dev/null +++ b/src/servala/api/permissions.py @@ -0,0 +1,50 @@ +import base64 +from contextlib import suppress + +from django.conf import settings +from django.http import HttpResponseForbidden + + +def get_username_and_password(request): # pragma: no cover + # This method is vendored from assorted DRF bits, so we + # skip it in our test coverage report + auth = request.META.get("HTTP_AUTHORIZATION", b"") + if isinstance(auth, str): + # Work around django test client oddness + auth = auth.encode("iso-8859-1") + auth = auth.split() + if not auth or auth[0].lower() != b"basic": + return False, False + if len(auth) != 2: + return False, False + + with suppress(TypeError, ValueError): + try: + auth_decoded = base64.b64decode(auth[1]).decode("utf-8") + except UnicodeDecodeError: + auth_decoded = base64.b64decode(auth[1]).decode("latin-1") + + return auth_decoded.split(":", 1) + + return False, False + + +class OSBBasicAuthPermission: + """ + Basic auth for OSB is implemented as a permission class rather than as + an authentication class, because authentication is expected to associate + the request with a Django user. However, the OSB/Exoscale requests do not + relate to a user account, so we treat the auth result as a permission instead. + """ + + def dispatch(self, request, *args, **kwargs): + osb_username = getattr(settings, "OSB_USERNAME", None) + osb_password = getattr(settings, "OSB_PASSWORD", None) + + if not osb_username or not osb_password: + return False # pragma: no cover + + username, password = get_username_and_password(request) + if username == osb_username and password == osb_password: + return super().dispatch(request, *args, **kwargs) + return HttpResponseForbidden() diff --git a/src/servala/api/urls.py b/src/servala/api/urls.py new file mode 100644 index 0000000..244d1f4 --- /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( + "osb/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..48845d0 --- /dev/null +++ b/src/servala/api/views.py @@ -0,0 +1,193 @@ +import json +import logging +from contextlib import suppress + +from django.conf import settings +from django.contrib.auth.decorators import login_not_required +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.api.permissions import OSBBasicAuthPermission +from servala.core.exoscale import get_exoscale_origin +from servala.core.models import BillingEntity, Organization, User +from servala.core.models.service import Service, ServiceOffering + +logger = logging.getLogger(__name__) + + +@method_decorator(csrf_exempt, name="dispatch") +@method_decorator(login_not_required, name="dispatch") +class OSBServiceInstanceView(OSBBasicAuthPermission, 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() + if not email: + raise ValueError("Email address is required but missing or empty") + + 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", organization_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(osb_service_id=service_id) + service_offering = ServiceOffering.objects.get( + osb_plan_id=plan_id, service=service + ) + except Service.DoesNotExist: + return self._error(f"Unknown service_id: {service_id}") + except ServiceOffering.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, service_offering + ) + 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, service_offering + ) + + 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, service_offering + ): + service_path = f"{organization.urls.services}{service.slug}/offering/{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/admin.py b/src/servala/core/admin.py index 0b1b980..073d444 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -11,7 +11,6 @@ from servala.core.models import ( Organization, OrganizationMembership, OrganizationOrigin, - Plan, Service, ServiceCategory, ServiceDefinition, @@ -99,11 +98,6 @@ class ServiceCategoryAdmin(admin.ModelAdmin): autocomplete_fields = ("parent",) -class PlanInline(admin.TabularInline): - model = Plan - extra = 1 - - @admin.register(Service) class ServiceAdmin(admin.ModelAdmin): list_display = ("name", "category") @@ -230,14 +224,6 @@ class ControlPlaneAdmin(admin.ModelAdmin): test_kubernetes_connection.short_description = _("Test Kubernetes connection") -@admin.register(Plan) -class PlanAdmin(admin.ModelAdmin): - list_display = ("name", "service_offering", "term") - list_filter = ("service_offering", "term") - search_fields = ("name", "description") - autocomplete_fields = ("service_offering",) - - @admin.register(ServiceDefinition) class ServiceDefinitionAdmin(admin.ModelAdmin): form = ServiceDefinitionAdminForm @@ -317,7 +303,4 @@ class ServiceOfferingAdmin(admin.ModelAdmin): list_filter = ("service", "provider") search_fields = ("description",) autocomplete_fields = ("service", "provider") - inlines = ( - ControlPlaneCRDInline, - PlanInline, - ) + inlines = (ControlPlaneCRDInline,) diff --git a/src/servala/core/checks.py b/src/servala/core/checks.py index d1bb708..b8f5f74 100644 --- a/src/servala/core/checks.py +++ b/src/servala/core/checks.py @@ -84,3 +84,5 @@ def check_servala_production_settings(app_configs, **kwargs): id="servala.W001", ) ) + + return errors 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 diff --git a/src/servala/core/migrations/0007_controlplane_user_info_and_more.py b/src/servala/core/migrations/0007_controlplane_user_info_and_more.py index 5dda2ed..25c78a9 100644 --- a/src/servala/core/migrations/0007_controlplane_user_info_and_more.py +++ b/src/servala/core/migrations/0007_controlplane_user_info_and_more.py @@ -35,7 +35,10 @@ class Migration(migrations.Migration): name="external_links", field=models.JSONField( blank=True, - help_text='JSON array of link objects: {"url": "…", "title": "…", "featured": false}. Featured links will be shown on the service list page, all other links will only show on the service and offering detail pages.', + help_text=( + 'JSON array of link objects: {"url": "…", "title": "…", "featured": false}. ' + "Featured links will be shown on the service list page, all other links will only show on the service and offering detail pages." + ), null=True, verbose_name="External links", ), diff --git a/src/servala/core/migrations/0008_organization_osb_guid_service_osb_service_id_and_more.py b/src/servala/core/migrations/0008_organization_osb_guid_service_osb_service_id_and_more.py new file mode 100644 index 0000000..66d20a3 --- /dev/null +++ b/src/servala/core/migrations/0008_organization_osb_guid_service_osb_service_id_and_more.py @@ -0,0 +1,49 @@ +# Generated by Django 5.2.6 on 2025-10-02 07:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0007_controlplane_user_info_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="organization", + name="osb_guid", + field=models.CharField( + blank=True, + help_text="Open Service Broker GUID, used for organizations created via OSB API", + max_length=100, + null=True, + verbose_name="OSB GUID", + ), + ), + migrations.AddField( + model_name="service", + name="osb_service_id", + field=models.CharField( + blank=True, + help_text="Open Service Broker service ID for API matching", + max_length=100, + null=True, + verbose_name="OSB Service ID", + ), + ), + migrations.AddField( + model_name="serviceoffering", + name="osb_plan_id", + field=models.CharField( + blank=True, + help_text="Open Service Broker plan ID for API matching", + max_length=100, + null=True, + verbose_name="OSB Plan ID", + ), + ), + migrations.DeleteModel( + name="Plan", + ), + ] diff --git a/src/servala/core/models/__init__.py b/src/servala/core/models/__init__.py index 3fb0663..22e8e8a 100644 --- a/src/servala/core/models/__init__.py +++ b/src/servala/core/models/__init__.py @@ -9,7 +9,6 @@ from .service import ( CloudProvider, ControlPlane, ControlPlaneCRD, - Plan, Service, ServiceCategory, ServiceDefinition, @@ -27,7 +26,6 @@ __all__ = [ "OrganizationMembership", "OrganizationOrigin", "OrganizationRole", - "Plan", "Service", "ServiceCategory", "ServiceInstance", diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index 997b0a2..083bc50 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -53,6 +53,15 @@ class Organization(ServalaModelMixin, models.Model): odoo_sale_order_name = models.CharField( max_length=100, null=True, blank=True, verbose_name=_("Odoo Sale Order Name") ) + osb_guid = models.CharField( + max_length=100, + null=True, + blank=True, + verbose_name=_("OSB GUID"), + help_text=_( + "Open Service Broker GUID, used for organizations created via OSB API" + ), + ) class urls(urlman.Urls): base = "/org/{self.slug}/" diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 3677cca..43c9023 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -80,6 +80,13 @@ class Service(ServalaModelMixin, models.Model): "Featured links will be shown on the service list page, all other links will only show on the service and offering detail pages." ), ) + osb_service_id = models.CharField( + max_length=100, + null=True, + blank=True, + verbose_name=_("OSB Service ID"), + help_text=_("Open Service Broker service ID for API matching"), + ) class Meta: verbose_name = _("Service") @@ -297,35 +304,6 @@ class CloudProvider(ServalaModelMixin, models.Model): return self.name -class Plan(ServalaModelMixin, models.Model): - """ - Each service offering can have multiple plans, e.g. for different tiers. - """ - - name = models.CharField(max_length=100, verbose_name=_("Name")) - description = models.TextField(blank=True, verbose_name=_("Description")) - # TODO schema - features = models.JSONField(verbose_name=_("Features"), null=True, blank=True) - # TODO schema - pricing = models.JSONField(verbose_name=_("Pricing"), null=True, blank=True) - term = models.PositiveIntegerField( - verbose_name=_("Term"), help_text=_("Term in months") - ) - service_offering = models.ForeignKey( - to="ServiceOffering", - on_delete=models.PROTECT, - related_name="plans", - verbose_name=_("Service offering"), - ) - - class Meta: - verbose_name = _("Plan") - verbose_name_plural = _("Plans") - - def __str__(self): - return self.name - - def validate_api_definition(value): required_fields = ("group", "version", "kind") return validate_dict(value, required_fields) @@ -533,6 +511,13 @@ class ServiceOffering(ServalaModelMixin, models.Model): verbose_name=_("Provider"), ) description = models.TextField(blank=True, verbose_name=_("Description")) + osb_plan_id = models.CharField( + max_length=100, + null=True, + blank=True, + verbose_name=_("OSB Plan ID"), + help_text=_("Open Service Broker plan ID for API matching"), + ) class Meta: verbose_name = _("Service offering") diff --git a/src/servala/frontend/templates/frontend/organizations/services.html b/src/servala/frontend/templates/frontend/organizations/services.html index 766b5bc..3a48ff9 100644 --- a/src/servala/frontend/templates/frontend/organizations/services.html +++ b/src/servala/frontend/templates/frontend/organizations/services.html @@ -21,10 +21,7 @@