Start work on implementing Exoscale offboarding

rel #262
This commit is contained in:
Tobias Kunze 2025-11-12 11:42:10 +01:00 committed by Tobias Brunner
parent 3d62595663
commit 61f1065bc6
Signed by: tobru
SSH key fingerprint: SHA256:kOXg1R6c11XW3/Pt9dbLdQvOJGFAy+B2K6v6PtRWBGQ
2 changed files with 445 additions and 4 deletions

View file

@ -6,6 +6,7 @@ from django.contrib.auth.decorators import login_not_required
from django.core.mail import send_mail from django.core.mail import send_mail
from django.db import transaction from django.db import transaction
from django.http import JsonResponse from django.http import JsonResponse
from django.urls import reverse
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views import View from django.views import View
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
@ -19,7 +20,8 @@ from servala.core.models import (
OrganizationRole, OrganizationRole,
User, User,
) )
from servala.core.models.service import Service, ServiceOffering from servala.core.models.service import Service, ServiceInstance, ServiceOffering
from servala.core.odoo import CLIENT
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -28,9 +30,7 @@ logger = logging.getLogger(__name__)
@method_decorator(login_not_required, name="dispatch") @method_decorator(login_not_required, name="dispatch")
class OSBServiceInstanceView(OSBBasicAuthPermission, View): class OSBServiceInstanceView(OSBBasicAuthPermission, View):
""" """
OSB API endpoint for service instance provisioning (onboarding). OSB API endpoint for service instance management via Exoscale.
Implements the PUT /v2/service_instances/:instance_id endpoint.
https://docs.servala.com/exoscale-osb.html#_onboarding
""" """
def _error(self, error): def _error(self, error):
@ -177,3 +177,168 @@ The Servala Team"""
recipient_list=[user.email], recipient_list=[user.email],
fail_silently=False, fail_silently=False,
) )
def delete(self, request, instance_id):
"""
This implements the Exoscale offboarding flow MVP.
https://docs.servala.com/exoscale-osb.html#_offboarding
"""
service_id = request.GET.get("service_id")
plan_id = request.GET.get("plan_id")
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:
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}"
)
self._create_action_helpdesk_ticket(
request=request,
action="Offboard",
instance_id=instance_id,
service=service,
service_offering=service_offering,
)
return JsonResponse({}, status=200)
def patch(self, request, instance_id):
"""
This implements the Exoscale suspension flow MVP.
https://docs.servala.com/exoscale-osb.html#_suspension
"""
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({"error": "Invalid JSON in request body"}, status=400)
service_id = data.get("service_id")
plan_id = data.get("plan_id")
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:
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}"
)
self._create_action_helpdesk_ticket(
request=request,
action="Suspend",
instance_id=instance_id,
service=service,
service_offering=service_offering,
users=data.get("parameters", {}).get("users"),
)
return JsonResponse({}, status=200)
def _get_admin_url(self, model_name, pk):
admin_path = reverse(f"admin:{model_name}", args=[pk])
return self.request.build_absolute_uri(admin_path)
def _create_action_helpdesk_ticket(
self, request, action, instance_id, service, service_offering, users=None
):
"""
Create an Odoo helpdesk ticket for offboarding or suspension actions.
This is an MVP implementation that creates a ticket for manual handling.
"""
try:
service_instance = None
organization = None
try:
# Look for instances with this name in the service offering's context
# TODO: we do not currently match instance IDs from exoscale yet, this
# will likely not work at all yet
instances = (
ServiceInstance.objects.filter(
name=instance_id,
context__service_offering=service_offering,
)
.select_related("organization")
.first()
)
if instances:
organization = service_instance.organization
except Exception:
pass
description_parts = [f"Action: {action}", f"Service: {service.name}"]
if organization:
org_url = self._get_admin_url(
"core_organization_change", organization.pk
)
description_parts.append(
f"Organization: {organization.name} - {org_url}"
)
if service_instance:
instance_url = self._get_admin_url(
"core_serviceinstance_change", service_instance.pk
)
description_parts.append(
f"Instance: {service_instance.name} - {instance_url}"
)
else:
description_parts.append(f"Instance: {instance_id}")
offering_url = self._get_admin_url(
"core_serviceoffering_change", service_offering.pk
)
description_parts.append(f"Service Offering: {offering_url}")
if users:
description_parts.append("\nUsers:")
for user_data in users:
email = user_data.get("email", "N/A")
full_name = user_data.get("full_name", "N/A")
role = user_data.get("role", "N/A")
user_link = email
if email and email != "N/A":
try:
user = User.objects.get(email=email.strip().lower())
user_link = self._get_admin_url("core_user_change", user.pk)
except User.DoesNotExist:
pass
description_parts.append(f" - {full_name} ({user_link}) - {role}")
description = "\n".join(description_parts)
ticket_data = {
"name": f"Exoscale OSB {action} - {service.name} - {instance_id}",
"team_id": settings.ODOO["HELPDESK_TEAM_ID"],
"description": description,
}
CLIENT.execute("helpdesk.ticket", "create", [ticket_data])
logger.info(
f"Created {action} helpdesk ticket for instance {instance_id}, service {service.name}"
)
except Exception as e:
logger.error(
f"Error creating Exoscale {action} helpdesk ticket for instance {instance_id}: {e}"
)

View file

@ -5,6 +5,12 @@ from django.core import mail
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from servala.core.models import Organization, OrganizationOrigin, User from servala.core.models import Organization, OrganizationOrigin, User
from servala.core.models.service import (
ControlPlane,
ControlPlaneCRD,
ServiceDefinition,
ServiceInstance,
)
@pytest.fixture @pytest.fixture
@ -451,3 +457,273 @@ def test_organization_creation_with_context_only(
assert response.status_code == 201 assert response.status_code == 201
org = Organization.objects.get(osb_guid="fallback-org-guid") org = Organization.objects.get(osb_guid="fallback-org-guid")
assert org is not None assert org is not None
@pytest.mark.django_db
def test_delete_offboarding_success(
mock_odoo_success,
osb_client,
test_service,
test_service_offering,
instance_id,
):
response = osb_client.delete(
f"/api/osb/v2/service_instances/{instance_id}"
f"?service_id={test_service.osb_service_id}&plan_id={test_service_offering.osb_plan_id}"
)
assert response.status_code == 200
assert response.content == b"{}"
@pytest.mark.django_db
def test_delete_missing_service_id(osb_client, test_service_offering, instance_id):
response = osb_client.delete(
f"/api/osb/v2/service_instances/{instance_id}?plan_id={test_service_offering.osb_plan_id}"
)
assert response.status_code == 400
response_data = json.loads(response.content)
assert "service_id is required but missing" in response_data["error"]
@pytest.mark.django_db
def test_delete_missing_plan_id(osb_client, test_service, instance_id):
response = osb_client.delete(
f"/api/osb/v2/service_instances/{instance_id}?service_id={test_service.osb_service_id}"
)
assert response.status_code == 400
response_data = json.loads(response.content)
assert "plan_id is required but missing" in response_data["error"]
@pytest.mark.django_db
def test_delete_invalid_service_id(osb_client, instance_id):
response = osb_client.delete(
f"/api/osb/v2/service_instances/{instance_id}?service_id=invalid&plan_id=invalid"
)
assert response.status_code == 400
response_data = json.loads(response.content)
assert "Unknown service_id: invalid" in response_data["error"]
@pytest.mark.django_db
def test_delete_invalid_plan_id(osb_client, test_service, instance_id):
response = osb_client.delete(
f"/api/osb/v2/service_instances/{instance_id}"
f"?service_id={test_service.osb_service_id}&plan_id=invalid"
)
assert response.status_code == 400
response_data = json.loads(response.content)
assert (
f"Unknown plan_id: invalid for service_id: {test_service.osb_service_id}"
in response_data["error"]
)
@pytest.mark.django_db
def test_patch_suspension_success(
mock_odoo_success,
osb_client,
test_service,
test_service_offering,
instance_id,
):
payload = {
"service_id": test_service.osb_service_id,
"plan_id": test_service_offering.osb_plan_id,
"parameters": {
"users": [
{
"email": "user@example.com",
"full_name": "Test User",
"role": "owner",
}
]
},
}
response = osb_client.patch(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(payload),
content_type="application/json",
)
assert response.status_code == 200
assert response.content == b"{}"
@pytest.mark.django_db
def test_patch_missing_service_id(osb_client, test_service_offering, instance_id):
payload = {
"plan_id": test_service_offering.osb_plan_id,
"parameters": {"users": []},
}
response = osb_client.patch(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(payload),
content_type="application/json",
)
assert response.status_code == 400
response_data = json.loads(response.content)
assert "service_id is required but missing" in response_data["error"]
@pytest.mark.django_db
def test_patch_missing_plan_id(osb_client, test_service, instance_id):
payload = {
"service_id": test_service.osb_service_id,
"parameters": {"users": []},
}
response = osb_client.patch(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(payload),
content_type="application/json",
)
assert response.status_code == 400
response_data = json.loads(response.content)
assert "plan_id is required but missing" in response_data["error"]
@pytest.mark.django_db
def test_patch_invalid_json(osb_client, instance_id):
response = osb_client.patch(
f"/api/osb/v2/service_instances/{instance_id}",
data="invalid json{",
content_type="application/json",
)
assert response.status_code == 400
response_data = json.loads(response.content)
assert "Invalid JSON in request body" in response_data["error"]
@pytest.mark.django_db
def test_delete_creates_ticket_with_admin_links(
mocker,
mock_odoo_success,
osb_client,
test_service,
test_service_offering,
instance_id,
):
mock_api_client = mocker.patch("servala.api.views.CLIENT")
response = osb_client.delete(
f"/api/osb/v2/service_instances/{instance_id}"
f"?service_id={test_service.osb_service_id}&plan_id={test_service_offering.osb_plan_id}"
)
assert response.status_code == 200
ticket_call = mock_api_client.execute.call_args_list[-1]
ticket_data = ticket_call[0][2][0]
assert "admin/core/serviceoffering" in ticket_data["description"]
assert f"/{test_service_offering.pk}/" in ticket_data["description"]
assert (
ticket_data["name"]
== f"Exoscale OSB Offboard - {test_service.name} - {instance_id}"
)
@pytest.mark.django_db
def test_patch_creates_ticket_with_user_admin_links(
mocker,
mock_odoo_success,
osb_client,
test_service,
test_service_offering,
instance_id,
org_owner,
):
mock_api_client = mocker.patch("servala.api.views.CLIENT")
payload = {
"service_id": test_service.osb_service_id,
"plan_id": test_service_offering.osb_plan_id,
"parameters": {
"users": [
{
"email": org_owner.email,
"full_name": "Test User",
"role": "owner",
}
]
},
}
response = osb_client.patch(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(payload),
content_type="application/json",
)
assert response.status_code == 200
ticket_call = mock_api_client.execute.call_args_list[-1]
ticket_data = ticket_call[0][2][0]
assert "admin/core/serviceoffering" in ticket_data["description"]
assert "admin/core/user" in ticket_data["description"]
assert f"/{org_owner.pk}/" in ticket_data["description"]
assert (
ticket_data["name"]
== f"Exoscale OSB Suspend - {test_service.name} - {instance_id}"
)
@pytest.mark.django_db
def test_ticket_includes_organization_and_instance_when_found(
mocker,
mock_odoo_success,
osb_client,
test_service,
test_service_offering,
organization,
):
mock_api_client = mocker.patch("servala.api.views.CLIENT")
service_definition = ServiceDefinition.objects.create(
name="Test Definition",
service=test_service,
api_definition={"group": "test.example.com", "version": "v1", "kind": "Test"},
)
control_plane = ControlPlane.objects.create(
name="Test Control Plane",
cloud_provider=test_service_offering.provider,
api_credentials={
"certificate-authority-data": "test",
"server": "https://test",
"token": "test",
},
)
crd = ControlPlaneCRD.objects.create(
service_offering=test_service_offering,
control_plane=control_plane,
service_definition=service_definition,
)
instance_name = "test-instance-123"
service_instance = ServiceInstance.objects.create(
name=instance_name,
organization=organization,
context=crd,
)
response = osb_client.delete(
f"/api/osb/v2/service_instances/{instance_name}"
f"?service_id={test_service.osb_service_id}&plan_id={test_service_offering.osb_plan_id}"
)
assert response.status_code == 200
ticket_call = mock_api_client.execute.call_args_list[-1]
ticket_data = ticket_call[0][2][0]
assert f"Organization: {organization.name}" in ticket_data["description"]
assert "admin/core/organization" in ticket_data["description"]
assert f"/{organization.pk}/" in ticket_data["description"]
assert f"Instance: {service_instance.name}" in ticket_data["description"]
assert "admin/core/serviceinstance" in ticket_data["description"]
assert f"/{service_instance.pk}/" in ticket_data["description"]