Skip to content

Commit 9b9dc17

Browse files
authored
feat: 新增幂等创建接口 (#262)
1 parent 171d0e5 commit 9b9dc17

13 files changed

Lines changed: 439 additions & 8 deletions

File tree

sdks/paas-service/CHANGES.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
# 版本历史
2+
3+
## 2.0.4
4+
- 新增幂等分配实例接口 `POST services/<service_id>/instances/`,默认以 `engine_app_name` 作为幂等键,适用于分配耗时较长、可能超过五分钟的增强服务:
5+
a. 同一幂等键仅对应一个增强服务实例,重复请求会复用已分配的实例
6+
b. 无需传递路径参数 `<instance_id>`,调用方应使用响应中的实例 UUID
7+
28
## 2.0.3
39
- service list 接口返回增加 `plan_schema` 字段,以指导服务方案配置
410

sdks/paas-service/paas_service/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,6 @@
1616
We undertake not to change the open source license (MIT license) applicable
1717
to the current version of the project delivered to anyone in the future.
1818
"""
19-
__version__ = '2.0.3'
19+
__version__ = '2.0.4'
2020

2121
default_app_config = 'paas_service.apps.PaaSServiceConfig'

sdks/paas-service/paas_service/base_vendor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from django.utils.module_loading import import_string
2727

2828

29-
def get_provider_cls():
29+
def get_provider_cls() -> 'type[BaseProvider]':
3030
try:
3131
SVC_PROVIDER_CLS = settings.PAAS_SERVICE_PROVIDER_CLS
3232
except AttributeError:

sdks/paas-service/paas_service/constants.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
"""
1919
from enum import Enum
2020

21+
from blue_krill.data_types.enum import StrStructuredEnum, EnumField
22+
2123

2224
class Category(int, Enum):
2325
"""Paas service categories"""
@@ -26,6 +28,15 @@ class Category(int, Enum):
2628
MONITORING_HEALTHY = 2
2729

2830

31+
class ProvisionRecordStatus(StrStructuredEnum):
32+
# 实例创建中
33+
PROVISIONING = EnumField("provisioning", label="分配资源中")
34+
# 成功:物理资源已创建
35+
SUCCESS = EnumField("success", label="分配成功")
36+
37+
# 异步删除触发,或分配发生错误,都会直接删除 ProvisionRecord,所以无需对应的状态
38+
39+
2940
# Login 服务的重定向链接字段名
3041
REDIRECT_FIELD_NAME = "c_url"
3142

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
TencentBlueKing is pleased to support the open source community by making
4+
蓝鲸智云 - PaaS 平台 (BlueKing - PaaS System) available.
5+
Copyright (C) 2017 THL A29 Limited, a Tencent company. All rights reserved.
6+
Licensed under the MIT License (the "License"); you may not use this file except
7+
in compliance with the License. You may obtain a copy of the License at
8+
9+
http://opensource.org/licenses/MIT
10+
11+
Unless required by applicable law or agreed to in writing, software distributed under
12+
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13+
either express or implied. See the License for the specific language governing permissions and
14+
limitations under the License.
15+
16+
We undertake not to change the open source license (MIT license) applicable
17+
to the current version of the project delivered to anyone in the future.
18+
"""
19+
import json
20+
from typing import Callable
21+
22+
from django.db import IntegrityError, transaction
23+
24+
from paas_service.base_vendor import BaseProvider, get_provider_cls
25+
from paas_service.models import Plan, Service
26+
27+
from .constants import ProvisionRecordStatus
28+
from .models import ProvisionRecord, ServiceInstance
29+
30+
31+
def idempotent_provision_instance(
32+
provision_key: str,
33+
service: Service,
34+
plan: Plan,
35+
params: dict,
36+
provider_cls_getter: Callable[[], type[BaseProvider]] = get_provider_cls,
37+
) -> tuple[ServiceInstance | None, bool]:
38+
"""Create or reuse instance by provision key
39+
40+
:returns: (service_instance, created) `service_instance=None` when provisioning
41+
"""
42+
43+
try:
44+
with transaction.atomic():
45+
record = ProvisionRecord.objects.create(
46+
provision_key=provision_key,
47+
plan_id=plan.uuid,
48+
status=ProvisionRecordStatus.PROVISIONING,
49+
)
50+
acquired = True
51+
except IntegrityError:
52+
acquired = False
53+
54+
if not acquired:
55+
record = ProvisionRecord.objects.select_related('service_instance').get(
56+
provision_key=provision_key,
57+
)
58+
if record.status == ProvisionRecordStatus.SUCCESS:
59+
return record.service_instance, False
60+
if record.status == ProvisionRecordStatus.PROVISIONING:
61+
return None, False
62+
raise Exception(f"Provision record with key {provision_key} is in unexpected status {record.status}")
63+
64+
provider_cls = provider_cls_getter()
65+
plan_config = json.loads(plan.config)
66+
try:
67+
instance_data = provider_cls(**plan_config).create(params=params)
68+
except Exception:
69+
record.delete()
70+
raise
71+
72+
service_instance = ServiceInstance.objects.create(
73+
service=service,
74+
plan=plan,
75+
config=instance_data.config,
76+
credentials=json.dumps(instance_data.credentials),
77+
tenant_id=plan.tenant_id,
78+
)
79+
mark_record_success(record, service_instance)
80+
return service_instance, True
81+
82+
83+
def mark_record_success(record: ProvisionRecord, service_instance: ServiceInstance):
84+
record.service_instance = service_instance
85+
record.status = ProvisionRecordStatus.SUCCESS
86+
record.save(update_fields=["service_instance", "status"])
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Generated by Django 4.2.27 on 2026-04-22 02:53
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
import uuid
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
('paas_service', '0009_alter_plan_unique_together_plan_tenant_id_and_more'),
12+
]
13+
14+
operations = [
15+
migrations.CreateModel(
16+
name='ProvisionRecord',
17+
fields=[
18+
('uuid', models.UUIDField(auto_created=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True, verbose_name='UUID')),
19+
('created', models.DateTimeField(auto_now_add=True)),
20+
('updated', models.DateTimeField(auto_now=True)),
21+
('provision_key', models.CharField(max_length=64, unique=True, verbose_name='幂等分配键')),
22+
('plan_id', models.UUIDField(verbose_name='方案 ID')),
23+
('status', models.CharField(max_length=16, verbose_name='状态')),
24+
('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='实例')),
25+
],
26+
options={
27+
'abstract': False,
28+
},
29+
),
30+
]

sdks/paas-service/paas_service/models.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import uuid
2121
from typing import Any, Dict, List
2222

23+
from blue_krill.models.fields import EncryptField
2324
from django.conf import settings
2425
from django.db import models
2526
from django.http import HttpRequest
@@ -28,11 +29,9 @@
2829
from django.utils.translation import gettext_lazy as _
2930
from jsonfield import JSONField
3031
from translated_fields import TranslatedField
31-
from blue_krill.models.fields import EncryptField
3232

3333
from paas_service.fields import tenant_id_field_factory
3434

35-
3635
# Base Models start
3736

3837

@@ -182,6 +181,30 @@ def render(self):
182181
return self._prepared_fields_data
183182

184183

184+
class ProvisionRecord(UuidAuditedModel):
185+
"""
186+
ProvisionRecord used to make instance creation idempotent
187+
provision_key now is `engine_app_name`, which is unique in global(across tenant)
188+
189+
Lifecycle (define in constants.ProvisionRecordStatus):
190+
1. `provisioning`: record exists, `service_instance` is empty
191+
2. `success`: instance has been created and bound to this record
192+
3. any error or delete/async_delete will directly delete the record
193+
"""
194+
provision_key = models.CharField(unique=True, verbose_name="幂等分配键", max_length=64)
195+
196+
service_instance = models.OneToOneField(
197+
ServiceInstance,
198+
verbose_name="实例",
199+
on_delete=models.CASCADE,
200+
related_name="record",
201+
db_constraint=False,
202+
null=True,
203+
)
204+
plan_id = models.UUIDField(verbose_name="方案 ID")
205+
status = models.CharField(verbose_name="状态", max_length=16)
206+
207+
185208
class ServiceInstanceConfig(UuidAuditedModel):
186209
"""Extra config for instance"""
187210

sdks/paas-service/paas_service/urls.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@
3939
views.SvcInstanceViewSet.as_view({'post': 'provision'}),
4040
name='api.services.instances_creation',
4141
),
42+
re_path(
43+
r'^services/(?P<service_id>[0-9a-f-]{32,36})/instances/idem_prov/$',
44+
views.SvcInstanceViewSet.as_view({'post': 'idem_prov'}),
45+
name='idepotent_provision_instance',
46+
),
4247
re_path(
4348
r'^services/(?P<service_id>[0-9a-f-]{32,36})/instances/$',
4449
views.SvcInstanceViewSet.as_view({'get': 'retrieve_by_fields'}),

sdks/paas-service/paas_service/views.py

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,14 @@
2828
from paas_service.auth.backends import InstanceAuthBackend, InstanceAuthFailed
2929
from paas_service.auth.decorator import verified_client_required, verified_client_role_require
3030
from paas_service.base_vendor import ArgumentInvalidError, InstanceData, OperationFailed, get_provider_cls
31+
from paas_service.idem_prov import idempotent_provision_instance
3132
from paas_service.mixins import LoginRequiredMixin
32-
from paas_service.models import Plan, Service, ServiceInstance, ServiceInstanceConfig
33+
from paas_service.models import Plan, ProvisionRecord, Service, ServiceInstance, ServiceInstanceConfig
3334
from paas_service.utils import parse_redirect_params
3435
from rest_framework import status, viewsets
3536
from rest_framework.response import Response
3637

38+
3739
logger = logging.getLogger(__name__)
3840

3941
m_verified_client_required = method_decorator(verified_client_required)
@@ -170,6 +172,101 @@ def provision(self, request, service_id, instance_id):
170172
service_instance.prerender(request)
171173
return Response(serializers.ServiceInstanceSLZ(service_instance).data, status=201)
172174

175+
@verified_client_role_require('internal_platform')
176+
def idem_prov(self, request, service_id):
177+
"""
178+
Provision a new instance with idempotency, use it when the provisioning process
179+
is expected to be slow and caller want to avoid duplicated instance created by retrying
180+
181+
request example:
182+
{
183+
"plan_id": "f8ad12f2-4dd5-4871-8e31-9a1f9f3795a2",
184+
"params": {
185+
"engine_app_name": "bkapp-myapp-stag", # required, default provision/idempotent key
186+
}
187+
}
188+
189+
Response:
190+
[http status code] [description]
191+
200 existing instance is reused
192+
201 instance provisioned successfully
193+
202 instance is still being provisioned, retry later
194+
400 bad request, e.g. missing required params or provision key conflict with plan_id
195+
500 provider operation failed or unknown error occurred
196+
197+
Flow:
198+
Create Req ---> [provision key] ----|
199+
|---> Record exists && status=SUCCESS
200+
| && instance.plan=plan
201+
| ---> Reuse existing instance ---> 200
202+
|
203+
|---> Input params error or conflict:
204+
| ---> Reject due to plan conflict ---> 400
205+
|
206+
|---> Record exists && status=PROVISIONING
207+
| ---> Another request is still creating
208+
| the instance ---> 202
209+
| ^
210+
| |
211+
| if provider create takes too long,
212+
| caller may retry Create Req and will
213+
| hit this branch until the first request
214+
| finishes or fails
215+
|
216+
|---> No record
217+
---> Create PROVISIONING record
218+
---> Start provider.create()
219+
|---> Success
220+
| ---> Create instance
221+
| ---> Mark record SUCCESS
222+
| ---> 201
223+
|
224+
|---> Failed
225+
---> Delete record
226+
---> 500
227+
"""
228+
plan_id = request.data.get('plan_id')
229+
params = request.data.get('params', {})
230+
engine_app_name = params.get('engine_app_name')
231+
if not (engine_app_name and plan_id):
232+
return Response(
233+
data={'detail': f"{'engine_app_name' if not engine_app_name else 'plan_id'} is required"},
234+
status=status.HTTP_400_BAD_REQUEST
235+
)
236+
237+
service = get_object_or_404(Service, pk=service_id)
238+
plan = get_object_or_404(Plan, service=service, pk=plan_id)
239+
240+
with wrap_provider_action_exc('create instance') as ret:
241+
service_instance, created = idempotent_provision_instance(
242+
service=service,
243+
plan=plan,
244+
provision_key=engine_app_name,
245+
params=params,
246+
provider_cls_getter=get_provider_cls,
247+
)
248+
249+
if ret.has_error:
250+
return ret.response
251+
252+
# 分配中...
253+
if service_instance is None:
254+
return Response(
255+
data={'detail': 'instance is being provisioned, please retry later'},
256+
status=status.HTTP_202_ACCEPTED
257+
)
258+
259+
if service_instance.plan.uuid != plan.uuid:
260+
return Response(
261+
data={'detail': 'an instance with the same provision key already exists but with different plan, unable to provision'},
262+
status=status.HTTP_400_BAD_REQUEST
263+
)
264+
265+
service_instance.prerender(request)
266+
status_code = status.HTTP_201_CREATED if created else status.HTTP_200_OK
267+
return Response(serializers.ServiceInstanceSLZ(service_instance).data, status=status_code)
268+
269+
173270
@m_verified_client_required
174271
def retrieve(self, request, instance_id):
175272
"""Retrieve an instance"""
@@ -231,6 +328,13 @@ def async_destroy(self, request, instance_id):
231328
logger.exception("mark instance need to delete failed")
232329
return Response(data={'detail': f'unable to mark instance<{instance_id}> to delete: {e}'})
233330

331+
try:
332+
record = ProvisionRecord.objects.get(service_instance=instance)
333+
record.delete()
334+
except ProvisionRecord.DoesNotExist:
335+
# 兼容线上历史数据无对应 ProvisionRecord 的情况
336+
pass
337+
234338
return Response(status=204)
235339

236340

sdks/paas-service/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ classifiers = [
88
# PEP 621 project metadata
99
# See https://www.python.org/dev/peps/pep-0621/
1010
name = "paas_service"
11-
version = "2.0.3"
11+
version = "2.0.4"
1212
description = "A Django application for developing BK-PaaS add-on services."
1313
readme = "README.md"
1414
authors = [{ name = "blueking", email = "blueking@tencent.com" }]

0 commit comments

Comments
 (0)