diff --git a/apiserver/paasng/paasng/accessories/servicehub/exceptions.py b/apiserver/paasng/paasng/accessories/servicehub/exceptions.py index 57e7603544..a0cd4176ee 100644 --- a/apiserver/paasng/paasng/accessories/servicehub/exceptions.py +++ b/apiserver/paasng/paasng/accessories/servicehub/exceptions.py @@ -44,6 +44,10 @@ class SvcAttachmentDoesNotExist(BaseServicesException): """remote or local service attachment does not exist""" +class UnboundSvcAttachmentDoesNotExist(BaseServicesException): + """unbound remote or local service attachment does not exist""" + + class CanNotModifyPlan(BaseServicesException): """remote or local service attachment already provided""" diff --git a/apiserver/paasng/paasng/accessories/servicehub/local/manager.py b/apiserver/paasng/paasng/accessories/servicehub/local/manager.py index f6e0efa6f1..137574e430 100644 --- a/apiserver/paasng/paasng/accessories/servicehub/local/manager.py +++ b/apiserver/paasng/paasng/accessories/servicehub/local/manager.py @@ -15,8 +15,8 @@ # 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. -"""Local services manager -""" +"""Local services manager""" + import json import logging import uuid @@ -37,8 +37,13 @@ ProvisionInstanceError, ServiceObjNotFound, SvcAttachmentDoesNotExist, + UnboundSvcAttachmentDoesNotExist, +) +from paasng.accessories.servicehub.models import ( + ServiceEngineAppAttachment, + ServiceModuleAttachment, + UnboundServiceEngineAppAttachment, ) -from paasng.accessories.servicehub.models import ServiceEngineAppAttachment, ServiceModuleAttachment from paasng.accessories.servicehub.services import ( NOTSET, BasePlanMgr, @@ -50,8 +55,9 @@ ServiceObj, ServiceSpecificationDefinition, ServiceSpecificationHelper, + UnboundEngineAppInstanceRel, ) -from paasng.accessories.services.models import Plan, Service +from paasng.accessories.services.models import Plan, Service, ServiceInstance from paasng.misc.metrics import SERVICE_PROVISION_COUNTER from paasng.platform.applications.models import ModuleEnvironment from paasng.platform.engine.constants import AppEnvName @@ -165,8 +171,19 @@ def provision(self): ).inc() def recycle_resource(self): + if self.is_provisioned() and self.db_obj.service.prefer_async_delete: + self.mark_unbound() self.db_obj.clean_service_instance() + def mark_unbound(self): + UnboundServiceEngineAppAttachment.objects.create( + engine_app=self.db_obj.engine_app, + service=self.db_obj.service, + plan=self.db_obj.plan, + service_instance=self.db_obj.service_instance, + credentials_enabled=self.db_obj.credentials_enabled, + ) + def get_instance(self) -> ServiceInstanceObj: """Get service instance object""" if not self.is_provisioned(): @@ -190,6 +207,47 @@ def get_plan(self) -> LocalPlanObj: return LocalPlanObj.from_db(self.db_obj.plan) +class UnboundLocalEngineAppInstanceRel(UnboundEngineAppInstanceRel): + """A unbound relationship between EngineApp and Provisioned instance""" + + def __init__(self, db_obj: UnboundServiceEngineAppAttachment): + self.db_obj = db_obj + + def get_service(self) -> LocalServiceObj: + return LocalServiceObj.from_db_object(self.db_obj.service) + + def is_recycled(self) -> bool: + if not ServiceInstance.objects.filter(uuid=self.db_obj.service_instance).exists(): + self.db_obj.delete() + return True + return False + + def recycle_resource(self) -> None: + if self.is_recycled(): + return + + self.db_obj.clean_service_instance() + + def get_instance(self) -> ServiceInstanceObj: + """Get service instance object""" + # All local service instance's credentials was prefixed with service name + service_name = self.db_obj.service.name + should_hidden_fields = constants.SERVICE_HIDDEN_FIELDS.get(service_name, []) + should_remove_fields = constants.SERVICE_SENSITIVE_FIELDS.get(service_name, []) + + return ServiceInstanceObj( + uuid=str(self.db_obj.service_instance), + credentials=json.loads(self.db_obj.service_instance.credentials), + config=self.db_obj.service_instance.config, + should_hidden_fields=should_hidden_fields, + should_remove_fields=should_remove_fields, + create_time=self.db_obj.created, + ) + + def get_plan(self) -> LocalPlanObj: + return LocalPlanObj.from_db(self.db_obj.plan) + + class LocalServiceMgr(BaseServiceMgr): """Local in-database services manager""" @@ -338,6 +396,16 @@ def list_provisioned_rels( for attachment in qs: yield self.transform_rel_db_obj(attachment) + def list_unbound_instance_rels( + self, engine_app: EngineApp, service: Optional[ServiceObj] = None + ) -> Generator[UnboundEngineAppInstanceRel, None, None]: + """Return all unbound engine_app <-> local service instances by specified service (None for all)""" + qs = engine_app.unbound_service_attachment.all() + if service: + qs = qs.filter(service_id=service.uuid) + for attachment in qs: + yield UnboundLocalEngineAppInstanceRel(attachment) + def get_attachment_by_instance_id(self, service: ServiceObj, service_instance_id: uuid.UUID): try: return ServiceEngineAppAttachment.objects.get( @@ -386,6 +454,16 @@ def get_attachment_by_engine_app(self, service: ServiceObj, engine_app: EngineAp except ServiceEngineAppAttachment.DoesNotExist as e: raise SvcAttachmentDoesNotExist from e + def get_unbound_instance_rel_by_instance_id(self, service: ServiceObj, service_instance_id: uuid.UUID): + try: + instance = UnboundServiceEngineAppAttachment.objects.get( + service_id=service.uuid, + service_instance__uuid=service_instance_id, + ) + except UnboundServiceEngineAppAttachment.DoesNotExist as e: + raise UnboundSvcAttachmentDoesNotExist from e + return UnboundLocalEngineAppInstanceRel(instance) + class LocalPlanMgr(BasePlanMgr): """Local in-database plans manager""" diff --git a/apiserver/paasng/paasng/accessories/servicehub/management/commands/check_unbound_remote_instance.py b/apiserver/paasng/paasng/accessories/servicehub/management/commands/check_unbound_remote_instance.py new file mode 100644 index 0000000000..5eff1dc4dc --- /dev/null +++ b/apiserver/paasng/paasng/accessories/servicehub/management/commands/check_unbound_remote_instance.py @@ -0,0 +1,33 @@ +# -*- 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 logging + +from django.core.management.base import BaseCommand + +from paasng.accessories.servicehub.tasks import check_is_unbound_remote_service_instance_recycled + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Check if unbound remote service instance is recycled, if it is recycled, delete object in database." + + def handle(self, *args, **options): + print("Start to check if remote unbound service instance recycled") + check_is_unbound_remote_service_instance_recycled() + print("Complete check if remote unbound service instance recycled") diff --git a/apiserver/paasng/paasng/accessories/servicehub/manager.py b/apiserver/paasng/paasng/accessories/servicehub/manager.py index 83b9475fdb..e2fb2c539a 100644 --- a/apiserver/paasng/paasng/accessories/servicehub/manager.py +++ b/apiserver/paasng/paasng/accessories/servicehub/manager.py @@ -17,6 +17,7 @@ import logging import operator +import uuid from typing import Callable, Dict, Generator, Iterable, Iterator, List, NamedTuple, Optional, TypeVar, cast from django.db.models import QuerySet @@ -27,6 +28,7 @@ DuplicatedServiceBoundError, ServiceObjNotFound, SvcAttachmentDoesNotExist, + UnboundSvcAttachmentDoesNotExist, ) from paasng.accessories.servicehub.local.manager import LocalPlanMgr, LocalServiceMgr, LocalServiceObj from paasng.accessories.servicehub.models import ( @@ -36,7 +38,12 @@ ) from paasng.accessories.servicehub.remote.manager import RemotePlanMgr, RemoteServiceMgr, RemoteServiceObj from paasng.accessories.servicehub.remote.store import get_remote_store -from paasng.accessories.servicehub.services import EngineAppInstanceRel, PlanObj, ServiceObj +from paasng.accessories.servicehub.services import ( + EngineAppInstanceRel, + PlanObj, + ServiceObj, + UnboundEngineAppInstanceRel, +) from paasng.accessories.services.models import ServiceCategory from paasng.core.region.models import get_all_regions, set_service_categories_loader from paasng.platform.engine.models import EngineApp @@ -218,6 +225,9 @@ def get_provisioned_queryset_by_services(self, services: List[ServiceObj], appli ) list_by_region: Callable[..., Iterable[ServiceObj]] = _proxied_chained_generator("list_by_region") list = cast(Callable[..., Iterable[ServiceObj]], _proxied_chained_generator("list")) + list_unbound_instance_rels = cast( + Callable[..., Iterable[UnboundEngineAppInstanceRel]], _proxied_chained_generator("list_unbound_instance_rels") + ) # Proxied generator methods end @@ -273,6 +283,16 @@ def get_attachment_by_engine_app(self, service: ServiceObj, engine_app: EngineAp continue raise SvcAttachmentDoesNotExist(f"engine_app<{engine_app}> has no attachment with service<{service.uuid}>") + def get_unbound_instance_rel_by_instance_id(self, service: ServiceObj, service_instance_id: uuid.UUID): + for mgr in self.mgr_instances: + try: + return mgr.get_unbound_instance_rel_by_instance_id(service, service_instance_id) + except UnboundSvcAttachmentDoesNotExist: + continue + raise UnboundSvcAttachmentDoesNotExist( + f"service<{ServiceObj}> has no attachment with service_instance_id<{service_instance_id}>" + ) + class MixedPlanMgr: """A hub for managing plans of mixed sources: database and remote REST plans""" diff --git a/apiserver/paasng/paasng/accessories/servicehub/migrations/0005_unboundserviceengineappattachment_and_more.py b/apiserver/paasng/paasng/accessories/servicehub/migrations/0005_unboundserviceengineappattachment_and_more.py new file mode 100644 index 0000000000..62cb45b98a --- /dev/null +++ b/apiserver/paasng/paasng/accessories/servicehub/migrations/0005_unboundserviceengineappattachment_and_more.py @@ -0,0 +1,55 @@ +# Generated by Django 4.2.16 on 2024-12-11 07:40 + +from django.db import migrations, models +import django.db.models.deletion +import paasng.utils.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('services', '0006_alter_servicecategory_name_en'), + ('engine', '0023_remove_deployment_hooks_remove_deployment_procfile'), + ('servicehub', '0004_auto_20240412_1723'), + ] + + operations = [ + migrations.CreateModel( + name='UnboundServiceEngineAppAttachment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('region', models.CharField(help_text='部署区域', max_length=32)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('owner', paasng.utils.models.BkUserField(blank=True, db_index=True, max_length=64, null=True)), + ('credentials_enabled', models.BooleanField(default=True, verbose_name='是否使用凭证')), + ('engine_app', models.ForeignKey(db_constraint=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='engine.engineapp', verbose_name='蓝鲸引擎应用')), + ('plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='services.plan')), + ('service', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='services.service', verbose_name='增强服务')), + ('service_instance', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='services.serviceinstance', verbose_name='增强服务实例')), + ], + options={ + 'verbose_name': '本地已解绑增强服务', + 'unique_together': {('service', 'engine_app')}, + }, + ), + migrations.CreateModel( + name='UnboundRemoteServiceEngineAppAttachment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('region', models.CharField(help_text='部署区域', max_length=32)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('owner', paasng.utils.models.BkUserField(blank=True, db_index=True, max_length=64, null=True)), + ('service_id', models.UUIDField(verbose_name='远程增强服务 ID')), + ('plan_id', models.UUIDField(verbose_name='远程增强服务 Plan ID')), + ('service_instance_id', models.UUIDField(null=True, verbose_name='远程增强服务实例 ID')), + ('credentials_enabled', models.BooleanField(default=True, verbose_name='是否使用凭证')), + ('engine_app', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.CASCADE, related_name='unbound_remote_service_attachment', to='engine.engineapp', verbose_name='蓝鲸引擎应用')), + ], + options={ + 'verbose_name': '远程已解绑增强服务', + 'unique_together': {('service_id', 'engine_app')}, + }, + ), + ] diff --git a/apiserver/paasng/paasng/accessories/servicehub/models.py b/apiserver/paasng/paasng/accessories/servicehub/models.py index cfd2a83282..64b9897b8b 100644 --- a/apiserver/paasng/paasng/accessories/servicehub/models.py +++ b/apiserver/paasng/paasng/accessories/servicehub/models.py @@ -123,6 +123,33 @@ def __str__(self): return "{prefix}-no-provision".format(prefix=prefix) +class UnboundServiceEngineAppAttachment(OwnerTimestampedModel): + """Local service instance which is unbound with engine app""" + + engine_app = models.ForeignKey( + "engine.EngineApp", + on_delete=models.CASCADE, + null=True, + db_constraint=False, + verbose_name="蓝鲸引擎应用", + related_name="unbound_service_attachment", + ) + service = models.ForeignKey(Service, on_delete=models.CASCADE, verbose_name="增强服务") + plan = models.ForeignKey(Plan, on_delete=models.CASCADE) + service_instance = models.ForeignKey( + ServiceInstance, on_delete=models.CASCADE, null=True, blank=True, verbose_name="增强服务实例" + ) + credentials_enabled = models.BooleanField(default=True, verbose_name="是否使用凭证") + + class Meta: + unique_together = ("service", "engine_app") + verbose_name = "本地已解绑增强服务" + + def clean_service_instance(self): + """回收增强服务资源""" + self.service.delete_service_instance(self.service_instance) + + class RemoteServiceModuleAttachment(OwnerTimestampedModel): """Binding relationship of module <-> remote service""" @@ -151,6 +178,26 @@ class Meta: unique_together = ("service_id", "engine_app") +class UnboundRemoteServiceEngineAppAttachment(OwnerTimestampedModel): + """Remote service instance which is unbound with engine app""" + + engine_app = models.ForeignKey( + "engine.EngineApp", + on_delete=models.CASCADE, + db_constraint=False, + verbose_name="蓝鲸引擎应用", + related_name="unbound_remote_service_attachment", + ) + service_id = models.UUIDField(verbose_name="远程增强服务 ID") + plan_id = models.UUIDField(verbose_name="远程增强服务 Plan ID") + service_instance_id = models.UUIDField(null=True, verbose_name="远程增强服务实例 ID") + credentials_enabled = models.BooleanField(default=True, verbose_name="是否使用凭证") + + class Meta: + unique_together = ("service_id", "engine_app") + verbose_name = "远程已解绑增强服务" + + class ServiceDBProperties: """Storing service related database properties""" diff --git a/apiserver/paasng/paasng/accessories/servicehub/remote/client.py b/apiserver/paasng/paasng/accessories/servicehub/remote/client.py index dfbaf44b35..112626801d 100644 --- a/apiserver/paasng/paasng/accessories/servicehub/remote/client.py +++ b/apiserver/paasng/paasng/accessories/servicehub/remote/client.py @@ -15,8 +15,8 @@ # 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. -"""Client for remote services -""" +"""Client for remote services""" + import json import logging from contextlib import contextmanager @@ -251,6 +251,18 @@ def delete_instance(self, instance_id: str): self.validate_resp(resp) return + def delete_instance_synchronously(self, instance_id: str): + """Delete a provisioned instance synchronously + + We assume the remote service is already able to recycle resources + """ + url = self.config.delete_instance_url.format(instance_id=instance_id) + + with wrap_request_exc(self): + resp = requests.delete(url, auth=self.auth, timeout=self.REQUEST_DELETE_TIMEOUT) + self.validate_resp(resp) + return + def update_instance_config(self, instance_id: str, config: Dict): """Update an provisioned instance's config diff --git a/apiserver/paasng/paasng/accessories/servicehub/remote/manager.py b/apiserver/paasng/paasng/accessories/servicehub/remote/manager.py index 4a94add879..d89c8c1672 100644 --- a/apiserver/paasng/paasng/accessories/servicehub/remote/manager.py +++ b/apiserver/paasng/paasng/accessories/servicehub/remote/manager.py @@ -32,12 +32,21 @@ from paas_wl.infras.cluster.shim import EnvClusterService from paas_wl.workloads.networking.egress.shim import get_cluster_egress_info from paasng.accessories.servicehub import constants, exceptions -from paasng.accessories.servicehub.exceptions import BindServiceNoPlansError -from paasng.accessories.servicehub.models import RemoteServiceEngineAppAttachment, RemoteServiceModuleAttachment +from paasng.accessories.servicehub.exceptions import ( + BindServiceNoPlansError, + SvcInstanceNotFound, + UnboundSvcAttachmentDoesNotExist, +) +from paasng.accessories.servicehub.models import ( + RemoteServiceEngineAppAttachment, + RemoteServiceModuleAttachment, + UnboundRemoteServiceEngineAppAttachment, +) from paasng.accessories.servicehub.remote.client import RemoteServiceClient from paasng.accessories.servicehub.remote.collector import RemoteSpecDefinitionUpdateSLZ, refresh_remote_service from paasng.accessories.servicehub.remote.exceptions import ( GetClusterEgressInfoError, + RClientResponseError, ServiceNotFound, UnsupportedOperationError, ) @@ -55,6 +64,7 @@ ServicePlansHelper, ServiceSpecificationDefinition, ServiceSpecificationHelper, + UnboundEngineAppInstanceRel, ) from paasng.accessories.services.models import ServiceCategory from paasng.core.region.models import get_all_regions @@ -284,9 +294,20 @@ def recycle_resource(self): except Exception as e: logger.exception("Error occurs during recycling") raise exceptions.SvcInstanceDeleteError("unable to delete instance") from e + if self.remote_client.config.prefer_async_delete: + self.mark_unbound() self.db_obj.service_instance_id = None self.db_obj.save() + def mark_unbound(self): + UnboundRemoteServiceEngineAppAttachment.objects.create( + engine_app=self.db_engine_app, + service_id=self.db_obj.service_id, + plan_id=self.db_obj.plan_id, + service_instance_id=self.db_obj.service_instance_id, + credentials_enabled=self.db_obj.credentials_enabled, + ) + def get_instance(self) -> ServiceInstanceObj: """Get service instance object""" if not self.is_provisioned(): @@ -356,6 +377,84 @@ def get_plan(self) -> RemotePlanObj: raise RuntimeError("Plan not found") +class UnboundRemoteEngineAppInstanceRel(UnboundEngineAppInstanceRel): + """A unbound relationship between EngineApp and Provisioned instance""" + + def __init__( + self, db_obj: UnboundRemoteServiceEngineAppAttachment, mgr: "RemoteServiceMgr", store: RemoteServiceStore + ): + self.store = store + self.mgr = mgr + + # Database objects + self.db_obj = db_obj + self.db_env = ModuleEnvironment.objects.get(engine_app=self.db_obj.engine_app) + self.db_application = self.db_env.application + + # Client components + self.remote_config = self.store.get_source_config(str(self.db_obj.service_id)) + self.remote_client = RemoteServiceClient(self.remote_config) + + def get_service(self) -> RemoteServiceObj: + return self.mgr.get(str(self.db_obj.service_id), region=self.db_application.region) + + def get_instance(self) -> ServiceInstanceObj: + """Get service instance object""" + try: + instance_data = self.remote_client.retrieve_instance(str(self.db_obj.service_instance_id)) + except RClientResponseError as e: + # if not find service instance with this id, remote response http status code 404 + if e.status_code == 404: + self.db_obj.delete() + raise SvcInstanceNotFound(f"service instance {self.db_obj.service_instance_id} not found") + raise + + svc_obj = self.get_service() + create_time = arrow.get(instance_data.get("created")) # type: ignore + return create_svc_instance_obj_from_remote( + uuid=str(self.db_obj.service_instance_id), + credentials=instance_data["credentials"], + config=instance_data["config"], + field_prefix=svc_obj.name, + create_time=create_time.datetime, + ) + + def is_recycled(self) -> bool: + try: + self.remote_client.retrieve_instance(str(self.db_obj.service_instance_id)) + except RClientResponseError as e: + # if not find service instance with this id, remote response http status code 404 + if e.status_code == 404: + self.db_obj.delete() + return True + raise + return False + + def recycle_resource(self) -> None: + if self.is_recycled(): + return + + try: + self.remote_client.delete_instance_synchronously(instance_id=str(self.db_obj.service_instance_id)) + self.db_obj.delete() + except Exception as e: + logger.exception("Error occurs during recycling") + raise exceptions.SvcInstanceDeleteError("unable to delete instance") from e + + def get_plan(self) -> RemotePlanObj: + plan_id = str(self.db_obj.plan_id) + # 兼容从v2迁移至v3的增强服务, 避免前端因此出现异常 + if plan_id == str(constants.LEGACY_PLAN_ID): + return RemotePlanObj.from_data(constants.LEGACY_PLAN_INSTANCE) + + svc_data = self.store.get(str(self.db_obj.service_id), region=self.db_application.region) + for d in svc_data["plans"]: + if d["uuid"] == plan_id: + return RemotePlanObj.from_data(d) + + raise RuntimeError("Plan not found") + + class RemotePlainInstanceMgr(PlainInstanceMgr): """纯粹的远程增强服务实例的管理器, 仅调用远程接口创建增强服务实例, 不涉及增强服务资源申请的流程""" @@ -611,6 +710,16 @@ def list_provisioned_rels( for attachment in qs: yield self.transform_rel_db_obj(attachment) + def list_unbound_instance_rels( + self, engine_app: EngineApp, service: Optional[ServiceObj] = None + ) -> Generator[UnboundRemoteEngineAppInstanceRel, None, None]: + """Return all unbound engine_app <-> remote service instances""" + qs = engine_app.unbound_remote_service_attachment.all() + if service: + qs = qs.filter(service_id=service.uuid) + for attachment in qs: + yield UnboundRemoteEngineAppInstanceRel(attachment, self, self.store) + def get_attachment_by_instance_id(self, service: ServiceObj, service_instance_id: uuid.UUID): try: return RemoteServiceEngineAppAttachment.objects.get( @@ -678,6 +787,16 @@ def get_attachment_by_engine_app(self, service: ServiceObj, engine_app: EngineAp except RemoteServiceEngineAppAttachment.DoesNotExist as e: raise exceptions.SvcAttachmentDoesNotExist from e + def get_unbound_instance_rel_by_instance_id(self, service: ServiceObj, service_instance_id: uuid.UUID): + try: + instance = UnboundRemoteServiceEngineAppAttachment.objects.get( + service_id=service.uuid, + service_instance_id=service_instance_id, + ) + except UnboundRemoteServiceEngineAppAttachment.DoesNotExist as e: + raise UnboundSvcAttachmentDoesNotExist from e + return UnboundRemoteEngineAppInstanceRel(instance, self, self.store) + class RemotePlanMgr(BasePlanMgr): """Remote REST plans manager""" diff --git a/apiserver/paasng/paasng/accessories/servicehub/serializers.py b/apiserver/paasng/paasng/accessories/servicehub/serializers.py index 890e3289fc..bb1c222450 100644 --- a/apiserver/paasng/paasng/accessories/servicehub/serializers.py +++ b/apiserver/paasng/paasng/accessories/servicehub/serializers.py @@ -258,3 +258,12 @@ class ServiceEngineAppAttachmentSLZ(serializers.Serializer): class UpdateServiceEngineAppAttachmentSLZ(serializers.Serializer): credentials_enabled = serializers.BooleanField(help_text="是否使用凭证") + + +class UnboundServiceEngineAppAttachmentSLZ(serializers.Serializer): + service = ServiceMinimalSLZ(help_text="增强服务信息") + unbound_instances = ServiceInstanceInfoSLZ(many=True, help_text="已解绑增强服务实例") + count = serializers.SerializerMethodField(help_text="数量") + + def get_count(self, obj): + return len(obj.get("unbound_instances") or []) diff --git a/apiserver/paasng/paasng/accessories/servicehub/services.py b/apiserver/paasng/paasng/accessories/servicehub/services.py index b09b6b4e32..cd74e56707 100644 --- a/apiserver/paasng/paasng/accessories/servicehub/services.py +++ b/apiserver/paasng/paasng/accessories/servicehub/services.py @@ -15,8 +15,8 @@ # 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. -"""The universal services module, handles both services from database and remote REST API -""" +"""The universal services module, handles both services from database and remote REST API""" + import logging import uuid import weakref @@ -200,7 +200,7 @@ def get_plan(self) -> PlanObj: raise NotImplementedError def delete(self): - """include delete rel & real resource recycle""" + """include delete rel, mark_unbound & real resource recycle""" if self.is_provisioned(): self.recycle_resource() logger.info("going to delete remote service attachment from db") @@ -212,6 +212,38 @@ def recycle_resource(self): """Recycle resources but do not unbind the service""" raise NotImplementedError + @abstractmethod + def mark_unbound(self): + """Add to unbound instances, which is unbound with engine app, could be recycled later""" + raise NotImplementedError + + +class UnboundEngineAppInstanceRel(metaclass=ABCMeta): + """A provinsioned instance which is unbound with engine app""" + + db_obj: Any + + @abstractmethod + def get_service(self) -> ServiceObj: + raise NotImplementedError + + @abstractmethod + def get_instance(self) -> ServiceInstanceObj: + raise NotImplementedError + + @abstractmethod + def get_plan(self) -> PlanObj: + raise NotImplementedError + + @abstractmethod + def is_recycled(self) -> bool: + raise NotImplementedError + + @abstractmethod + def recycle_resource(self): + """Recycle resources""" + raise NotImplementedError + class PlainInstanceMgr(metaclass=ABCMeta): """纯粹的增强服务实例的管理器, 不涉及增强服务资源申请的流程""" @@ -267,6 +299,12 @@ def list_provisioned_rels( ) -> Generator[EngineAppInstanceRel, None, None]: raise NotImplementedError + @abstractmethod + def list_unbound_instance_rels( + self, engine_app: EngineApp, service: Optional[ServiceObj] = None + ) -> Generator[UnboundEngineAppInstanceRel, None, None]: + raise NotImplementedError + @abstractmethod def get_provisioned_queryset(self, service: ServiceObj, application_ids: List[str]) -> QuerySet: raise NotImplementedError @@ -295,6 +333,10 @@ def find_by_name(self, name: str, region: str) -> ServiceObj: def get_attachment_by_engine_app(self, service: ServiceObj, engine_app: EngineApp): raise NotImplementedError + @abstractmethod + def get_unbound_instance_rel_by_instance_id(self, service: ServiceObj, service_instance_id: uuid.UUID): + raise NotImplementedError + class BasePlanMgr: """Base class for plan manager""" diff --git a/apiserver/paasng/paasng/accessories/servicehub/tasks.py b/apiserver/paasng/paasng/accessories/servicehub/tasks.py index 9dfd11b11e..4b33fb6027 100644 --- a/apiserver/paasng/paasng/accessories/servicehub/tasks.py +++ b/apiserver/paasng/paasng/accessories/servicehub/tasks.py @@ -16,8 +16,13 @@ # to the current version of the project delivered to anyone in the future. import logging +from collections import defaultdict -from .models import ServiceInstance +from paasng.accessories.servicehub.remote.client import RemoteServiceClient +from paasng.accessories.servicehub.remote.exceptions import RClientResponseError +from paasng.accessories.servicehub.remote.store import get_remote_store + +from .models import ServiceInstance, UnboundRemoteServiceEngineAppAttachment logger = logging.getLogger(__name__) @@ -44,3 +49,34 @@ def clean_instances(): continue else: logger.info(f"instance<{uuid}> cleaned. ") + + +def check_is_unbound_remote_service_instance_recycled(): + store = get_remote_store() + unbound_instances = UnboundRemoteServiceEngineAppAttachment.objects.all() + + if not unbound_instances: + logger.info("no unbound remote instances waiting for to be recycled") + return + + categorized_instances = defaultdict(list) + for instance in unbound_instances: + categorized_instances[str(instance.service_id)].append(instance) + + for service_id, instances in categorized_instances.items(): + remote_config = store.get_source_config(service_id) + remote_client = RemoteServiceClient(remote_config) + for instance in instances: + try: + remote_client.retrieve_instance(instance.service_instance_id) + except RClientResponseError as e: + # if not find service instance with this id, remote response http status code 404 + if e.status_code == 404: + instance.delete() + logger.info(f"unbound service instance<{instance.service_instance_id}> is recycled.") + continue + logger.warning(f"retrive unbound remote service instance<{instance.service_instance_id}> failed: {e}") + except Exception as e: + logger.warning(f"retrive unbound remote service instance<{instance.service_instance_id}> failed: {e}") + else: + logger.info(f"unbound service instance<{instance.service_instance_id}> is not recycled.") diff --git a/apiserver/paasng/paasng/accessories/servicehub/urls.py b/apiserver/paasng/paasng/accessories/servicehub/urls.py index b036898d8d..9ca83ce974 100644 --- a/apiserver/paasng/paasng/accessories/servicehub/urls.py +++ b/apiserver/paasng/paasng/accessories/servicehub/urls.py @@ -24,6 +24,7 @@ SERVICE_UUID = "(?P[0-9a-f-]{32,36})" APP_UUID = "(?P[0-9a-f-]{32,36})" CATEGORY_ID = r"(?P[\d]+)" +SERVICE_INTANCE_ID = "(?P[0-9a-f-]{32,36})" urlpatterns = [ # service APIs @@ -134,6 +135,24 @@ views.RelatedApplicationsInfoViewSet.as_view({"get": "retrieve_related_applications_info"}), name="api.services.mysql.retrieve_related_applications_info", ), + # retrieve unbound instances by module and service_id + re_path( + make_app_pattern(f"/services/{SERVICE_UUID}/attachments/unbound/$", include_envs=False), + views.UnboundServiceEngineAppAttachmentViewSet.as_view({"get": "list_by_service"}), + name="api.services.attachment.unbound.list_by_service", + ), + # List unbound instances by module + re_path( + make_app_pattern("/services/attachments/unbound/$", include_envs=False), + views.UnboundServiceEngineAppAttachmentViewSet.as_view({"get": "list_by_module"}), + name="api.services.attachment.unbound.list_by_module", + ), + # Recycle unbound instance + re_path( + make_app_pattern(f"/services/{SERVICE_UUID}/unbound/{SERVICE_INTANCE_ID}/$", include_envs=False), + views.UnboundServiceEngineAppAttachmentViewSet.as_view({"delete": "recycle"}), + name="api.services.attachment.unbound.recycle", + ), ] # Multi-editions specific start diff --git a/apiserver/paasng/paasng/accessories/servicehub/views.py b/apiserver/paasng/paasng/accessories/servicehub/views.py index eca117aab5..f46e92b013 100644 --- a/apiserver/paasng/paasng/accessories/servicehub/views.py +++ b/apiserver/paasng/paasng/accessories/servicehub/views.py @@ -16,6 +16,7 @@ # to the current version of the project delivered to anyone in the future. import logging +from collections import defaultdict from typing import Any, Dict, List from django.core.exceptions import ObjectDoesNotExist @@ -36,6 +37,7 @@ ReferencedAttachmentNotFound, ServiceObjNotFound, SharedAttachmentAlreadyExists, + SvcInstanceNotFound, ) from paasng.accessories.servicehub.manager import mixed_service_mgr from paasng.accessories.servicehub.models import ServiceSetGroupByName @@ -224,6 +226,7 @@ def retrieve_specs(self, request, code, module_name, service_id): return Response({"results": slz.data}) @app_action_required(AppAction.MANAGE_ADDONS_SERVICES) + @transaction.atomic def unbind(self, request, code, module_name, service_id): """删除一个服务绑定关系""" application = self._get_application_by_code(code) @@ -247,6 +250,7 @@ def unbind(self, request, code, module_name, service_id): raise error_codes.CANNOT_DESTROY_SERVICE.f(f"{e}") module_attachment.delete() + add_app_audit_record( app_code=code, user=request.user.pk, @@ -753,3 +757,83 @@ def update(self, request, code, module_name, service_id): results.append(attachment) return Response(slzs.ServiceEngineAppAttachmentSLZ(results, many=True).data) + + +class UnboundServiceEngineAppAttachmentViewSet(viewsets.ViewSet, ApplicationCodeInPathMixin): + """已解绑待回收增强服务实例相关API""" + + @staticmethod + def get_service(service_id, application): + return mixed_service_mgr.get_or_404(service_id, region=application.region) + + @app_action_required(AppAction.BASIC_DEVELOP) + def list_by_service(self, request, code, module_name, service_id): + """查看应用模块与增强服务已解绑实例详情""" + application = self.get_application() + module = self.get_module_via_path() + service = self.get_service(service_id, application) + + results = [] + for env in module.envs.all(): + for rel in mixed_service_mgr.list_unbound_instance_rels(env.engine_app, service=service): + try: + instance = rel.get_instance() + except SvcInstanceNotFound: + # 如果已经回收了,获取不到 instance,跳过 + continue + plan = rel.get_plan() + results.append( + { + "service_instance": instance, + "environment": env.environment, + "environment_name": AppEnvName.get_choice_label(env.environment), + "service_specs": plan.specifications, + "usage": "{}", + } + ) + serializer = slzs.ServiceInstanceInfoSLZ(results, many=True) + return Response({"count": len(results), "results": serializer.data}) + + @app_action_required(AppAction.BASIC_DEVELOP) + def list_by_module(self, request, code, module_name): + """查看模块所有已解绑增强服务实例,按增强服务归类""" + application = self.get_application() + module = self.get_module_via_path() + + categorized_rels = defaultdict(list) + for env in module.envs.all(): + for rel in mixed_service_mgr.list_unbound_instance_rels(env.engine_app): + try: + instance = rel.get_instance() + except SvcInstanceNotFound: + # 如果已经回收了,获取不到 instance,跳过 + continue + plan = rel.get_plan() + + categorized_rels[str(rel.db_obj.service_id)].append( + { + "service_instance": instance, + "environment": env.environment, + "environment_name": AppEnvName.get_choice_label(env.environment), + "service_specs": plan.specifications, + "usage": "{}", + } + ) + + results = [] + for service_id, rels in categorized_rels.items(): + results.append( + {"service": mixed_service_mgr.get_or_404(service_id, application.region), "unbound_instances": rels} + ) + + serializer = slzs.UnboundServiceEngineAppAttachmentSLZ(results, many=True) + return Response(serializer.data) + + @app_action_required(AppAction.MANAGE_ADDONS_SERVICES) + def recycle(self, request, code, module_name, service_id, service_instance_id): + """回收已解绑增强服务""" + service_obj = mixed_service_mgr.get_or_404(service_id, self.get_application().region) + unbound_instance = mixed_service_mgr.get_unbound_instance_rel_by_instance_id(service_obj, service_instance_id) + unbound_instance.recycle_instance() + + return Response() diff --git a/apiserver/paasng/tests/api/test_servicehub.py b/apiserver/paasng/tests/api/test_servicehub.py index 85ea2bf2c6..ea29b81a8f 100644 --- a/apiserver/paasng/tests/api/test_servicehub.py +++ b/apiserver/paasng/tests/api/test_servicehub.py @@ -16,10 +16,12 @@ # to the current version of the project delivered to anyone in the future. import datetime +import uuid from unittest import mock import pytest from django_dynamic_fixture import G +from rest_framework import status from paasng.accessories.servicehub.models import RemoteServiceEngineAppAttachment from paasng.accessories.servicehub.services import ServiceInstanceObj @@ -94,3 +96,40 @@ def test_config_vars(self, list_provisioned_rels, api_client, bk_app, bk_module) assert set(response.data[service.display_name]) == {"a", "b"} # 增强服务环境变量设置为不写入则不返回 assert credentials_disabled_service.display_name not in return_svc_names + + +class TestUnboundServiceEngineAppAttachmentViewSet: + def create_mock_rel(self, service, credentials_enabled, create_time, **credentials): + rel = mock.MagicMock() + rel.get_instance.return_value = ServiceInstanceObj( + uuid=str(uuid.uuid4()), credentials=credentials, config={}, create_time=create_time + ) + rel.get_plan.return_value = mock.MagicMock(spec=["specifications"]) + rel.get_plan.return_value.specifications = {"name": "version"} + rel.get_service.return_value = service + rel.db_obj.credentials_enabled = credentials_enabled + return rel + + @mock.patch("paasng.accessories.servicehub.views.mixed_service_mgr.list_unbound_instance_rels") + @mock.patch("paasng.accessories.servicehub.views.mixed_service_mgr.get_or_404") + def test_retrieve_unbound_service_instances( + self, mock_get_or_404, mock_list_unbound_instance_rels, api_client, bk_app, bk_module + ): + service = G(Service) + mock_get_or_404.return_value = service + + mock_rel1 = self.create_mock_rel(service, True, datetime.datetime(2020, 1, 1), a=1, b=2) + mock_rel2 = self.create_mock_rel(service, False, datetime.datetime(2020, 1, 1), c=3) + mock_list_unbound_instance_rels.return_value = [mock_rel1, mock_rel2] + + url = f"/api/bkapps/applications/{bk_app.code}/modules/{bk_module.name}/services/{str(service.uuid)}/attachments/unbound/" + + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + response_data = response.json() + print(response_data) + assert response_data["count"] == 4 + assert len(response_data["results"]) == 4 + assert response_data["results"][0]["service_instance"]["credentials"] == '{"a": 1, "b": 2}' + assert response_data["results"][3]["service_specs"] == {"name": "version"}