Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions sdks/paas-service/CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
# 版本历史

## 2.0.4
Comment thread
xiaoj655 marked this conversation as resolved.
- 新增幂等分配实例接口 `POST services/<service_id>/instances/`,默认以 `engine_app_name` 作为幂等键,适用于分配耗时较长、可能超过五分钟的增强服务:
Comment thread
xiaoj655 marked this conversation as resolved.
a. 同一幂等键仅对应一个增强服务实例,重复请求会复用已分配的实例
b. 无需传递路径参数 `<instance_id>`,调用方应使用响应中的实例 UUID

## 2.0.3
- service list 接口返回增加 `plan_schema` 字段,以指导服务方案配置

Expand Down
2 changes: 1 addition & 1 deletion sdks/paas-service/paas_service/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
2 changes: 1 addition & 1 deletion sdks/paas-service/paas_service/base_vendor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 11 additions & 0 deletions sdks/paas-service/paas_service/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
"""
from enum import Enum

from blue_krill.data_types.enum import StrStructuredEnum, EnumField


class Category(int, Enum):
"""Paas service categories"""
Expand All @@ -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"

Expand Down
86 changes: 86 additions & 0 deletions sdks/paas-service/paas_service/idem_prov.py
Original file line number Diff line number Diff line change
@@ -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
Comment thread
xiaoj655 marked this conversation as resolved.
raise Exception(f"Provision record with key {provision_key} is in unexpected status {record.status}")
Comment thread
xiaoj655 marked this conversation as resolved.

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)
Comment thread
xiaoj655 marked this conversation as resolved.
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"])
30 changes: 30 additions & 0 deletions sdks/paas-service/paas_service/migrations/0010_provisionrecord.py
Original file line number Diff line number Diff line change
@@ -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,
},
),
]
27 changes: 25 additions & 2 deletions sdks/paas-service/paas_service/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import uuid
from typing import Any, Dict, List

from blue_krill.models.fields import EncryptField
Comment thread
xiaoj655 marked this conversation as resolved.
from django.conf import settings
from django.db import models
from django.http import HttpRequest
Expand All @@ -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


Expand Down Expand Up @@ -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"""

Expand Down
5 changes: 5 additions & 0 deletions sdks/paas-service/paas_service/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@
views.SvcInstanceViewSet.as_view({'post': 'provision'}),
name='api.services.instances_creation',
),
re_path(
r'^services/(?P<service_id>[0-9a-f-]{32,36})/instances/idem_prov/$',
views.SvcInstanceViewSet.as_view({'post': 'idem_prov'}),
name='idepotent_provision_instance',
),
re_path(
r'^services/(?P<service_id>[0-9a-f-]{32,36})/instances/$',
views.SvcInstanceViewSet.as_view({'get': 'retrieve_by_fields'}),
Expand Down
106 changes: 105 additions & 1 deletion sdks/paas-service/paas_service/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
}
Comment thread
xiaoj655 marked this conversation as resolved.

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"""
Expand Down Expand Up @@ -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
Comment thread
xiaoj655 marked this conversation as resolved.

return Response(status=204)


Expand Down
2 changes: 1 addition & 1 deletion sdks/paas-service/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "[email protected]" }]
Expand Down
Loading