diff --git a/sdks/paas-service/CHANGES.md b/sdks/paas-service/CHANGES.md index 7bd6cfd6..9a54a791 100644 --- a/sdks/paas-service/CHANGES.md +++ b/sdks/paas-service/CHANGES.md @@ -1,4 +1,10 @@ # 版本历史 + +## 2.0.4 +- 新增幂等分配实例接口 `POST services//instances/`,默认以 `engine_app_name` 作为幂等键,适用于分配耗时较长、可能超过五分钟的增强服务: + a. 同一幂等键仅对应一个增强服务实例,重复请求会复用已分配的实例 + b. 无需传递路径参数 ``,调用方应使用响应中的实例 UUID + ## 2.0.3 - service list 接口返回增加 `plan_schema` 字段,以指导服务方案配置 diff --git a/sdks/paas-service/paas_service/__init__.py b/sdks/paas-service/paas_service/__init__.py index 87199a5a..8a39e537 100644 --- a/sdks/paas-service/paas_service/__init__.py +++ b/sdks/paas-service/paas_service/__init__.py @@ -16,6 +16,6 @@ We undertake not to change the open source license (MIT license) applicable to the current version of the project delivered to anyone in the future. """ -__version__ = '2.0.3' +__version__ = '2.0.4' default_app_config = 'paas_service.apps.PaaSServiceConfig' diff --git a/sdks/paas-service/paas_service/base_vendor.py b/sdks/paas-service/paas_service/base_vendor.py index 5961e171..69efd97e 100644 --- a/sdks/paas-service/paas_service/base_vendor.py +++ b/sdks/paas-service/paas_service/base_vendor.py @@ -26,7 +26,7 @@ from django.utils.module_loading import import_string -def get_provider_cls(): +def get_provider_cls() -> 'type[BaseProvider]': try: SVC_PROVIDER_CLS = settings.PAAS_SERVICE_PROVIDER_CLS except AttributeError: diff --git a/sdks/paas-service/paas_service/constants.py b/sdks/paas-service/paas_service/constants.py index 265cd484..9ba5913e 100644 --- a/sdks/paas-service/paas_service/constants.py +++ b/sdks/paas-service/paas_service/constants.py @@ -18,6 +18,8 @@ """ from enum import Enum +from blue_krill.data_types.enum import StrStructuredEnum, EnumField + class Category(int, Enum): """Paas service categories""" @@ -26,6 +28,15 @@ class Category(int, Enum): MONITORING_HEALTHY = 2 +class ProvisionRecordStatus(StrStructuredEnum): + # 实例创建中 + PROVISIONING = EnumField("provisioning", label="分配资源中") + # 成功:物理资源已创建 + SUCCESS = EnumField("success", label="分配成功") + + # 异步删除触发,或分配发生错误,都会直接删除 ProvisionRecord,所以无需对应的状态 + + # Login 服务的重定向链接字段名 REDIRECT_FIELD_NAME = "c_url" diff --git a/sdks/paas-service/paas_service/idem_prov.py b/sdks/paas-service/paas_service/idem_prov.py new file mode 100644 index 00000000..416dd168 --- /dev/null +++ b/sdks/paas-service/paas_service/idem_prov.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸智云 - PaaS 平台 (BlueKing - PaaS System) available. +Copyright (C) 2017 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except +in compliance with the License. You may obtain a copy of the License at + + http://opensource.org/licenses/MIT + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +either express or implied. See the License for the specific language governing permissions and +limitations under the License. + +We undertake not to change the open source license (MIT license) applicable +to the current version of the project delivered to anyone in the future. +""" +import json +from typing import Callable + +from django.db import IntegrityError, transaction + +from paas_service.base_vendor import BaseProvider, get_provider_cls +from paas_service.models import Plan, Service + +from .constants import ProvisionRecordStatus +from .models import ProvisionRecord, ServiceInstance + + +def idempotent_provision_instance( + provision_key: str, + service: Service, + plan: Plan, + params: dict, + provider_cls_getter: Callable[[], type[BaseProvider]] = get_provider_cls, +) -> tuple[ServiceInstance | None, bool]: + """Create or reuse instance by provision key + + :returns: (service_instance, created) `service_instance=None` when provisioning + """ + + try: + with transaction.atomic(): + record = ProvisionRecord.objects.create( + provision_key=provision_key, + plan_id=plan.uuid, + status=ProvisionRecordStatus.PROVISIONING, + ) + acquired = True + except IntegrityError: + acquired = False + + if not acquired: + record = ProvisionRecord.objects.select_related('service_instance').get( + provision_key=provision_key, + ) + if record.status == ProvisionRecordStatus.SUCCESS: + return record.service_instance, False + if record.status == ProvisionRecordStatus.PROVISIONING: + return None, False + raise Exception(f"Provision record with key {provision_key} is in unexpected status {record.status}") + + provider_cls = provider_cls_getter() + plan_config = json.loads(plan.config) + try: + instance_data = provider_cls(**plan_config).create(params=params) + except Exception: + record.delete() + raise + + service_instance = ServiceInstance.objects.create( + service=service, + plan=plan, + config=instance_data.config, + credentials=json.dumps(instance_data.credentials), + tenant_id=plan.tenant_id, + ) + mark_record_success(record, service_instance) + return service_instance, True + + +def mark_record_success(record: ProvisionRecord, service_instance: ServiceInstance): + record.service_instance = service_instance + record.status = ProvisionRecordStatus.SUCCESS + record.save(update_fields=["service_instance", "status"]) diff --git a/sdks/paas-service/paas_service/migrations/0010_provisionrecord.py b/sdks/paas-service/paas_service/migrations/0010_provisionrecord.py new file mode 100644 index 00000000..7c7b2566 --- /dev/null +++ b/sdks/paas-service/paas_service/migrations/0010_provisionrecord.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.27 on 2026-04-22 02:53 + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('paas_service', '0009_alter_plan_unique_together_plan_tenant_id_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='ProvisionRecord', + fields=[ + ('uuid', models.UUIDField(auto_created=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True, verbose_name='UUID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('provision_key', models.CharField(max_length=64, unique=True, verbose_name='幂等分配键')), + ('plan_id', models.UUIDField(verbose_name='方案 ID')), + ('status', models.CharField(max_length=16, verbose_name='状态')), + ('service_instance', models.OneToOneField(db_constraint=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='record', to='paas_service.serviceinstance', verbose_name='实例')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/sdks/paas-service/paas_service/models.py b/sdks/paas-service/paas_service/models.py index cc325ab4..d91e2f97 100644 --- a/sdks/paas-service/paas_service/models.py +++ b/sdks/paas-service/paas_service/models.py @@ -20,6 +20,7 @@ import uuid from typing import Any, Dict, List +from blue_krill.models.fields import EncryptField from django.conf import settings from django.db import models from django.http import HttpRequest @@ -28,11 +29,9 @@ from django.utils.translation import gettext_lazy as _ from jsonfield import JSONField from translated_fields import TranslatedField -from blue_krill.models.fields import EncryptField from paas_service.fields import tenant_id_field_factory - # Base Models start @@ -182,6 +181,30 @@ def render(self): return self._prepared_fields_data +class ProvisionRecord(UuidAuditedModel): + """ + ProvisionRecord used to make instance creation idempotent + provision_key now is `engine_app_name`, which is unique in global(across tenant) + + Lifecycle (define in constants.ProvisionRecordStatus): + 1. `provisioning`: record exists, `service_instance` is empty + 2. `success`: instance has been created and bound to this record + 3. any error or delete/async_delete will directly delete the record + """ + provision_key = models.CharField(unique=True, verbose_name="幂等分配键", max_length=64) + + service_instance = models.OneToOneField( + ServiceInstance, + verbose_name="实例", + on_delete=models.CASCADE, + related_name="record", + db_constraint=False, + null=True, + ) + plan_id = models.UUIDField(verbose_name="方案 ID") + status = models.CharField(verbose_name="状态", max_length=16) + + class ServiceInstanceConfig(UuidAuditedModel): """Extra config for instance""" diff --git a/sdks/paas-service/paas_service/urls.py b/sdks/paas-service/paas_service/urls.py index 7cec4c22..923cf3e3 100644 --- a/sdks/paas-service/paas_service/urls.py +++ b/sdks/paas-service/paas_service/urls.py @@ -39,6 +39,11 @@ views.SvcInstanceViewSet.as_view({'post': 'provision'}), name='api.services.instances_creation', ), + re_path( + r'^services/(?P[0-9a-f-]{32,36})/instances/idem_prov/$', + views.SvcInstanceViewSet.as_view({'post': 'idem_prov'}), + name='idepotent_provision_instance', + ), re_path( r'^services/(?P[0-9a-f-]{32,36})/instances/$', views.SvcInstanceViewSet.as_view({'get': 'retrieve_by_fields'}), diff --git a/sdks/paas-service/paas_service/views.py b/sdks/paas-service/paas_service/views.py index c6d7e0b5..a9e91cb8 100644 --- a/sdks/paas-service/paas_service/views.py +++ b/sdks/paas-service/paas_service/views.py @@ -28,12 +28,14 @@ from paas_service.auth.backends import InstanceAuthBackend, InstanceAuthFailed from paas_service.auth.decorator import verified_client_required, verified_client_role_require from paas_service.base_vendor import ArgumentInvalidError, InstanceData, OperationFailed, get_provider_cls +from paas_service.idem_prov import idempotent_provision_instance from paas_service.mixins import LoginRequiredMixin -from paas_service.models import Plan, Service, ServiceInstance, ServiceInstanceConfig +from paas_service.models import Plan, ProvisionRecord, Service, ServiceInstance, ServiceInstanceConfig from paas_service.utils import parse_redirect_params from rest_framework import status, viewsets from rest_framework.response import Response + logger = logging.getLogger(__name__) m_verified_client_required = method_decorator(verified_client_required) @@ -170,6 +172,101 @@ def provision(self, request, service_id, instance_id): service_instance.prerender(request) return Response(serializers.ServiceInstanceSLZ(service_instance).data, status=201) + @verified_client_role_require('internal_platform') + def idem_prov(self, request, service_id): + """ + Provision a new instance with idempotency, use it when the provisioning process + is expected to be slow and caller want to avoid duplicated instance created by retrying + + request example: + { + "plan_id": "f8ad12f2-4dd5-4871-8e31-9a1f9f3795a2", + "params": { + "engine_app_name": "bkapp-myapp-stag", # required, default provision/idempotent key + } + } + + Response: + [http status code] [description] + 200 existing instance is reused + 201 instance provisioned successfully + 202 instance is still being provisioned, retry later + 400 bad request, e.g. missing required params or provision key conflict with plan_id + 500 provider operation failed or unknown error occurred + + Flow: + Create Req ---> [provision key] ----| + |---> Record exists && status=SUCCESS + | && instance.plan=plan + | ---> Reuse existing instance ---> 200 + | + |---> Input params error or conflict: + | ---> Reject due to plan conflict ---> 400 + | + |---> Record exists && status=PROVISIONING + | ---> Another request is still creating + | the instance ---> 202 + | ^ + | | + | if provider create takes too long, + | caller may retry Create Req and will + | hit this branch until the first request + | finishes or fails + | + |---> No record + ---> Create PROVISIONING record + ---> Start provider.create() + |---> Success + | ---> Create instance + | ---> Mark record SUCCESS + | ---> 201 + | + |---> Failed + ---> Delete record + ---> 500 + """ + plan_id = request.data.get('plan_id') + params = request.data.get('params', {}) + engine_app_name = params.get('engine_app_name') + if not (engine_app_name and plan_id): + return Response( + data={'detail': f"{'engine_app_name' if not engine_app_name else 'plan_id'} is required"}, + status=status.HTTP_400_BAD_REQUEST + ) + + service = get_object_or_404(Service, pk=service_id) + plan = get_object_or_404(Plan, service=service, pk=plan_id) + + with wrap_provider_action_exc('create instance') as ret: + service_instance, created = idempotent_provision_instance( + service=service, + plan=plan, + provision_key=engine_app_name, + params=params, + provider_cls_getter=get_provider_cls, + ) + + if ret.has_error: + return ret.response + + # 分配中... + if service_instance is None: + return Response( + data={'detail': 'instance is being provisioned, please retry later'}, + status=status.HTTP_202_ACCEPTED + ) + + if service_instance.plan.uuid != plan.uuid: + return Response( + data={'detail': 'an instance with the same provision key already exists but with different plan, unable to provision'}, + status=status.HTTP_400_BAD_REQUEST + ) + + service_instance.prerender(request) + status_code = status.HTTP_201_CREATED if created else status.HTTP_200_OK + return Response(serializers.ServiceInstanceSLZ(service_instance).data, status=status_code) + + @m_verified_client_required def retrieve(self, request, instance_id): """Retrieve an instance""" @@ -231,6 +328,13 @@ def async_destroy(self, request, instance_id): logger.exception("mark instance need to delete failed") return Response(data={'detail': f'unable to mark instance<{instance_id}> to delete: {e}'}) + try: + record = ProvisionRecord.objects.get(service_instance=instance) + record.delete() + except ProvisionRecord.DoesNotExist: + # 兼容线上历史数据无对应 ProvisionRecord 的情况 + pass + return Response(status=204) diff --git a/sdks/paas-service/pyproject.toml b/sdks/paas-service/pyproject.toml index f711830e..ec22cffb 100644 --- a/sdks/paas-service/pyproject.toml +++ b/sdks/paas-service/pyproject.toml @@ -8,7 +8,7 @@ classifiers = [ # PEP 621 project metadata # See https://www.python.org/dev/peps/pep-0621/ name = "paas_service" -version = "2.0.3" +version = "2.0.4" description = "A Django application for developing BK-PaaS add-on services." readme = "README.md" authors = [{ name = "blueking", email = "blueking@tencent.com" }] diff --git a/sdks/paas-service/tests/conftest.py b/sdks/paas-service/tests/conftest.py index de5fc2e9..4333532f 100644 --- a/sdks/paas-service/tests/conftest.py +++ b/sdks/paas-service/tests/conftest.py @@ -18,7 +18,8 @@ """ import pytest from paas_service.auth.backends import Client -from paas_service.models import Plan, Service, ServiceInstance, SpecDefinition +from paas_service.constants import ProvisionRecordStatus +from paas_service.models import Plan, ProvisionRecord, Service, ServiceInstance, SpecDefinition @pytest.fixture @@ -68,3 +69,22 @@ def spec_def(service): def platform_client(): """An authenticated client object""" return Client('test_client', role='internal_platform') + + +@pytest.fixture +def provisioning_record(plan): + return ProvisionRecord.objects.create( + provision_key='key1', + plan_id=plan.uuid, + status=ProvisionRecordStatus.PROVISIONING, + ) + + +@pytest.fixture +def success_record(plan, instance_with_credentials): + return ProvisionRecord.objects.create( + provision_key='key2', + plan_id=plan.uuid, + status=ProvisionRecordStatus.SUCCESS, + service_instance=instance_with_credentials + ) diff --git a/sdks/paas-service/tests/test_apis.py b/sdks/paas-service/tests/test_apis.py index 9b55c6c2..b60d0a95 100644 --- a/sdks/paas-service/tests/test_apis.py +++ b/sdks/paas-service/tests/test_apis.py @@ -22,7 +22,7 @@ from django.test.utils import override_settings from paas_service.constants import DEFAULT_TENANT_ID -from paas_service.models import Plan, Service, ServiceInstanceConfig, SpecDefinition, Specification +from paas_service.models import ProvisionRecord, Plan, Service, ServiceInstanceConfig, SpecDefinition, Specification from paas_service.views import PlanManageViewSet, ServiceManageViewSet, SvcInstanceConfigViewSet, SvcInstanceViewSet pytestmark = pytest.mark.django_db @@ -379,3 +379,60 @@ def test_retrieve_to_be_deleted_when_not_found(self, rf, service, instance_with_ response = view(request, instance_id=instance_with_credentials.pk) response.render() assert response.status_code == 404 + + def test_idem_prov(self, rf, service, plan, platform_client): + view = SvcInstanceViewSet.as_view({'post': 'idem_prov'}) + + request = rf.post(f"/services/{service.uuid}/instances/", data=json.dumps({ + "params": {"engine_app_name": "test-1"}, + "plan_id": str(plan.uuid), + }), content_type="application/json") + request.client = platform_client + + response = view(request, service_id=service.uuid) + # 基于 engine_app_name 幂等创建,第一次创建返回 201 + assert response.status_code == 201 + + response.render() + + request2 = rf.post(f"/services/{service.uuid}/insntaces/", data=json.dumps({ + "params": {"engine_app_name": "test-1"}, + "plan_id": str(plan.uuid), + }), content_type="application/json") + request2.client = platform_client + response2 = view(request2, service_id=service.uuid) + + # 第二次并未实际创建资源,返回 200表示复用了之前的资源 + assert response2.status_code == 200 + + assert response.data['uuid'] == response2.data['uuid'] + + def test_async_destroy_marks_instance_and_deletes_provision_record(self, rf, success_record, platform_client): + """异步删除接口会把 ProvisionRecord 删除掉, 避免错误的复用删除的实例""" + async_delete_view = SvcInstanceViewSet.as_view({'delete': 'async_destroy'}) + + record_pk, instance_pk = success_record.uuid, success_record.service_instance.uuid + + request = rf.delete(f"/instances/{success_record.service_instance.uuid}/async_delete") + request.client = platform_client + + response = async_delete_view(request, instance_id=instance_pk) + response.render() + assert response.status_code == 204 + + assert not ProvisionRecord.objects.filter(pk=record_pk).exists() + + def test_destroy_instance_and_record(self, rf, success_record, platform_client): + """删除 Instance 级联删除 ProvisionRecord""" + delete_view = SvcInstanceViewSet.as_view({'delete': 'destroy'}) + + record_pk, instance_pk = success_record.uuid, success_record.service_instance.uuid + + request = rf.delete(f"/instances/{success_record.service_instance.uuid}") + request.client = platform_client + + response = delete_view(request, instance_id=instance_pk) + response.render() + assert response.status_code == 204 + + assert not ProvisionRecord.objects.filter(pk=record_pk).exists() \ No newline at end of file diff --git a/sdks/paas-service/tests/test_idem_prov.py b/sdks/paas-service/tests/test_idem_prov.py new file mode 100644 index 00000000..348b7e4d --- /dev/null +++ b/sdks/paas-service/tests/test_idem_prov.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸智云 - PaaS 平台 (BlueKing - PaaS System) available. +Copyright (C) 2017 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except +in compliance with the License. You may obtain a copy of the License at + + http://opensource.org/licenses/MIT + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +either express or implied. See the License for the specific language governing permissions and +limitations under the License. + +We undertake not to change the open source license (MIT license) applicable +to the current version of the project delivered to anyone in the future. +""" +import pytest + +from paas_service.idem_prov import idempotent_provision_instance, mark_record_success +from paas_service.constants import ProvisionRecordStatus +from paas_service.models import ProvisionRecord +from paas_service.base_vendor import InstanceData + +pytestmark = pytest.mark.django_db + +class DummyProvider: + def __init__(self, **kwargs): + pass + + def create(self, params): + return InstanceData( + credentials={"host": "1.2.3.4", "password": "pass"}, + config={"endpoint": "http://example.com"}, + ) + +class FailingProvider: + def __init__(self, **kwargs): + pass + + def create(self, params): + raise Exception("Provisioning failed") + +class TestIdempotentProvision: + def test_idempotent_provision_instance(self, service, plan): + # 模拟 provider 创建实例成功 + # 第一次调用,应该创建实例 + instance1, created1 = idempotent_provision_instance( + provision_key='key1', + service=service, + plan=plan, + params={}, + provider_cls_getter=lambda: DummyProvider, + ) + assert created1 is True + assert instance1 is not None + assert ProvisionRecord.objects.filter(provision_key='key1', status=ProvisionRecordStatus.SUCCESS).exists() + + # 第二次调用,应该复用实例 + instance2, created2 = idempotent_provision_instance( + provision_key='key1', + service=service, + plan=plan, + params={}, + provider_cls_getter=lambda: DummyProvider + ) + assert created2 is False + assert instance2 == instance1 + + def test_idem_prov_failure(self, service, plan): + with pytest.raises(Exception, match="Provisioning failed"): + idempotent_provision_instance( + provision_key='key2', + service=service, + plan=plan, + params={}, + provider_cls_getter=lambda: FailingProvider, + ) + + assert not ProvisionRecord.objects.filter(provision_key='key2').exists() + + def test_mark_record_success(self, instance, provisioning_record): + assert provisioning_record.status == ProvisionRecordStatus.PROVISIONING + mark_record_success(provisioning_record, instance) + + provisioning_record.refresh_from_db() + assert provisioning_record.status == ProvisionRecordStatus.SUCCESS + assert provisioning_record.service_instance == instance