diff --git a/bkmonitor/packages/apm_web/service/resources.py b/bkmonitor/packages/apm_web/service/resources.py index 62d47eb9ac1..6902a4f3cb7 100644 --- a/bkmonitor/packages/apm_web/service/resources.py +++ b/bkmonitor/packages/apm_web/service/resources.py @@ -19,6 +19,7 @@ from datetime import timedelta import arrow +from django.db import transaction from django.utils.translation import gettext_lazy as _ from rest_framework import serializers @@ -50,6 +51,7 @@ ) from apm_web.profile.doris.querier import QueryTemplate from apm_web.serializers import ApplicationListSerializer, ServiceApdexConfigSerializer +from apm_web.tasks import update_application_config from apm_web.service.serializers import ( AppServiceRelationSerializer, LogServiceRelationOutputSerializer, @@ -72,6 +74,7 @@ from bkmonitor.utils.time_tools import get_datetime_range from bkmonitor.utils.common_utils import count_md5 from core.drf_resource import Resource, api +from monitor_web.data_explorer.event.constants import EventCategory class ApplicationListResource(Resource): @@ -473,13 +476,25 @@ def perform_request(self, validated_request_data): class ServiceConfigResource(Resource): RequestSerializer = ServiceConfigSerializer - RELATION_MODEL_MAP = { + RELATION_MODEL_MAP: dict[str, type[ServiceBase]] = { "app_relation": AppServiceRelation, "cmdb_relation": CMDBServiceRelation, "log_relation_list": LogServiceRelation, "apdex_relation": ApdexServiceRelation, "uri_relation": UriServiceRelation, "event_relation": EventServiceRelation, + "incremental_cicd_relations": EventServiceRelation, + "incremental_k8s_relations": EventServiceRelation, + } + INCREMENTAL_EVENT_RELATION_CONFIG: dict[str, tuple[str, tuple[str, ...]]] = { + "incremental_cicd_relations": ( + EventCategory.CICD_EVENT.value, + ("project_id", "pipeline_id"), + ), + "incremental_k8s_relations": ( + EventCategory.K8S_EVENT.value, + ("bcs_cluster_id", "namespace", "kind", "name"), + ), } @classmethod @@ -510,23 +525,153 @@ def _prepare_log_relation_list(cls, data: list[dict[str, Any]]) -> list[dict[str return list(unique_relations.values()) @classmethod - def update_relation(cls, bk_biz_id: int, app_name: str, service_name: str, relation_type: str, relation_data: Any): + def _get_event_relation_for_update( + cls, + bk_biz_id: int, + app_name: str, + service_name: str, + table: str, + ) -> dict[str, Any]: + # 当前表暂无作用域唯一约束:若存在历史重复记录,这里固定读取最小 ID,sync_relations 则由 QuerySet 的遍历顺序决定最终更新对象。 + # 唯一约束与重复数据治理需另行收敛。 + return ( + EventServiceRelation.get_relation_qs( + bk_biz_id, + app_name, + [service_name], + table=table, + ) + .select_for_update() + .order_by("id") + .values("relations", "options") + .first() + or {} + ) + + @classmethod + def _prepare_event_relations( + cls, + bk_biz_id: int, + app_name: str, + service_name: str, + relations: list[dict[str, Any]], + ) -> list[dict[str, Any]]: + prepared_relations: list[dict[str, Any]] = [dict(relation) for relation in relations] + for relation in prepared_relations: + if ( + relation.get("table") != EventCategory.K8S_EVENT.value + or relation.get("relations") + or (relation.get("options") or {}).get("is_auto") is not True + ): + continue + + # SaaS 自动关联模式固定提交空 relations,不能用该空值覆盖外部平台已绑定的 Workload。 + existing_relation: dict[str, Any] = cls._get_event_relation_for_update( + bk_biz_id, + app_name, + service_name, + EventCategory.K8S_EVENT.value, + ) + existing_relations: list[dict[str, Any]] = existing_relation.get("relations") or [] + if not existing_relations: + continue + + options: dict[str, Any] = dict(existing_relation.get("options") or {}) + options.update(relation.get("options") or {}) + options["is_auto"] = False + relation["relations"] = existing_relations + relation["options"] = options + + return prepared_relations + + @classmethod + def _prepare_incremental_event_relations( + cls, + bk_biz_id: int, + app_name: str, + service_name: str, + table: str, + unique_fields: tuple[str, ...], + new_relations: list[dict[str, Any]], + ) -> list[dict[str, Any]]: + existing_relation: dict[str, Any] = cls._get_event_relation_for_update( + bk_biz_id, + app_name, + service_name, + table, + ) + existing_relations: list[dict[str, Any]] = existing_relation.get("relations") or [] + options: dict[str, Any] = dict(existing_relation.get("options") or {}) + if table == EventCategory.K8S_EVENT.value: + # 外部注册的 Workload 必须落到手动关联模式,否则配置页不渲染 relations,下次保存会被空列表覆盖。 + options["is_auto"] = False + + deduped_relations: dict[tuple[Any, ...], dict[str, Any]] = {} + # 存量排在新增数据之前,身份相同时由 setdefault 保留原配置。 + for relation in itertools.chain(existing_relations, new_relations): + unique_key: tuple[Any, ...] = tuple(relation.get(field) for field in unique_fields) + deduped_relations.setdefault(unique_key, relation) + + return [{"table": table, "relations": list(deduped_relations.values()), "options": options}] + + @classmethod + def update_relation( + cls, + bk_biz_id: int, + app_name: str, + service_name: str, + relation_type: str, + relation_data: Any, + ) -> None: if relation_type not in cls.RELATION_MODEL_MAP: return # 预处理数据 model_cls: type[ServiceBase] = cls.RELATION_MODEL_MAP[relation_type] - prepare_handler: Callable[[Any], list[dict[str, Any]]] = getattr( - cls, f"_prepare_{relation_type}", cls._prepare_default + incremental_config: tuple[str, tuple[str, ...]] | None = cls.INCREMENTAL_EVENT_RELATION_CONFIG.get( + relation_type ) - prepare_datas: list[dict[str, Any]] = prepare_handler(relation_data) + if incremental_config is not None: + if not relation_data: + return + table, unique_fields = incremental_config + prepare_datas: list[dict[str, Any]] = cls._prepare_incremental_event_relations( + bk_biz_id, + app_name, + service_name, + table, + unique_fields, + relation_data, + ) + elif relation_type == "event_relation": + prepare_datas = cls._prepare_event_relations( + bk_biz_id, + app_name, + service_name, + relation_data, + ) + else: + prepare_handler: Callable[[Any], list[dict[str, Any]]] = getattr( + cls, f"_prepare_{relation_type}", cls._prepare_default + ) + prepare_datas = prepare_handler(relation_data) + # 构建模型记录数据 records: list[dict[str, Any]] = [ {"bk_biz_id": bk_biz_id, "app_name": app_name, "service_name": service_name, "is_global": False, **data} for data in prepare_datas ] # 执行同步 - if relation_type == "event_relation": + if incremental_config is not None: + model_cls.sync_relations( + bk_biz_id, + app_name, + service_name, + records, + is_delete=False, + table=table, + ) + elif relation_type == "event_relation": model_cls.sync_relations(bk_biz_id, app_name, service_name, records, is_delete=False) else: model_cls.sync_relations(bk_biz_id, app_name, service_name, records) @@ -541,10 +686,12 @@ def update_labels(cls, bk_biz_id: int, app_name: str, service_name: str, labels: json.dumps(labels), ) + @transaction.atomic def perform_request(self, validated_request_data: dict[str, Any]) -> None: bk_biz_id: int = validated_request_data["bk_biz_id"] app_name: str = validated_request_data["app_name"] service_name: str = validated_request_data["service_name"] + application: Application = Application.objects.get(bk_biz_id=bk_biz_id, app_name=app_name) # 对 labels 单独作处理 if "labels" in validated_request_data: @@ -558,11 +705,14 @@ def perform_request(self, validated_request_data: dict[str, Any]) -> None: update_relation(relation_type, relation_data) # 下发修改后的配置 - application = Application.objects.filter(bk_biz_id=bk_biz_id, app_name=app_name).get() - from apm_web.tasks import update_application_config - - update_application_config.delay( - application.bk_biz_id, application.app_name, {"service_configs": application.get_service_transfer_config()} + transfer_config: dict[str, Any] = {"service_configs": application.get_service_transfer_config()} + transaction.on_commit( + functools.partial( + update_application_config.delay, + application.bk_biz_id, + application.app_name, + transfer_config, + ) ) diff --git a/bkmonitor/packages/apm_web/service/serializers.py b/bkmonitor/packages/apm_web/service/serializers.py index d8b2c8a120d..502976c63cf 100644 --- a/bkmonitor/packages/apm_web/service/serializers.py +++ b/bkmonitor/packages/apm_web/service/serializers.py @@ -77,10 +77,23 @@ class ServiceApdexConfigSerializer(serializers.Serializer): apdex_value = serializers.CharField() +class IncrementalK8sRelationSerializer(serializers.Serializer): + bcs_cluster_id = serializers.CharField(label=_("BCS 集群 ID"), max_length=64) + namespace = serializers.CharField(label=_("命名空间"), max_length=63) + kind = serializers.CharField(label=_("Workload 类型"), max_length=64) + name = serializers.CharField(label=_("Workload 名称"), max_length=253) + + +class IncrementalCICDRelationSerializer(serializers.Serializer): + project_id = serializers.CharField(label=_("项目 ID"), max_length=128) + pipeline_id = serializers.CharField(label=_("流水线 ID"), max_length=128) + pipeline_name = serializers.CharField(label=_("流水线名称"), max_length=255) + + class ServiceConfigSerializer(serializers.Serializer): bk_biz_id = serializers.IntegerField(label=_("业务 ID")) - app_name = serializers.CharField(label=_("应用名")) - service_name = serializers.CharField(label=_("服务名")) + app_name = serializers.CharField(label=_("应用名"), max_length=50) + service_name = serializers.CharField(label=_("服务名"), max_length=512) app_relation = AppServiceRelationSerializer(allow_null=True, default=None) cmdb_relation = CMDBServiceRelationSerializer(allow_null=True, default=None) @@ -88,9 +101,27 @@ class ServiceConfigSerializer(serializers.Serializer): apdex_relation = ServiceApdexConfigSerializer(allow_null=True, default=None) uri_relation = serializers.ListSerializer(default=[], child=serializers.CharField()) event_relation = serializers.ListSerializer(default=[], child=EventServiceRelationSerializer()) + incremental_cicd_relations = serializers.ListSerializer(required=False, child=IncrementalCICDRelationSerializer()) + incremental_k8s_relations = serializers.ListSerializer(required=False, child=IncrementalK8sRelationSerializer()) labels = serializers.ListSerializer(required=False, allow_null=True, child=serializers.CharField()) def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: + request_fields: set[str] = set(self.initial_data) + incremental_fields: set[str] = request_fields & { + "incremental_cicd_relations", + "incremental_k8s_relations", + } + full_relation_fields: set[str] = request_fields & { + "app_relation", + "cmdb_relation", + "log_relation_list", + "apdex_relation", + "uri_relation", + "event_relation", + } + if incremental_fields and (full_relation_fields or "labels" in request_fields): + raise serializers.ValidationError(_("增量事件关联字段不能与其他服务配置字段同时提交")) + uri_relations: list[str] = attrs["uri_relation"] if len(set(uri_relations)) != len(uri_relations): raise serializers.ValidationError(_("uri 含有重复配置项")) @@ -100,6 +131,11 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: attrs["bk_biz_id"], attrs["app_name"], attrs["service_name"] ) + if incremental_fields or not full_relation_fields: + # 增量、空请求和仅标签请求只处理显式字段,避免完整保存协议的默认值清空其他配置。 + for field in set(attrs) - request_fields: + attrs.pop(field) + return super().validate(attrs) diff --git a/bkmonitor/packages/apm_web/service/views.py b/bkmonitor/packages/apm_web/service/views.py index 5c3a06c5488..6c57badeade 100644 --- a/bkmonitor/packages/apm_web/service/views.py +++ b/bkmonitor/packages/apm_web/service/views.py @@ -36,8 +36,8 @@ class ServiceViewSet(ResourceViewSet): INSTANCE_ID = "app_name" - def get_permissions(self): - if self.action in ["app_query_by_index_set"]: + def get_permissions(self) -> list[InstanceActionForDataPermission]: + if self.action == "app_query_by_index_set": return [] return [ diff --git a/bkmonitor/packages/apm_web/tests/service/__init__.py b/bkmonitor/packages/apm_web/tests/service/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/bkmonitor/packages/apm_web/tests/service/test_service_config.py b/bkmonitor/packages/apm_web/tests/service/test_service_config.py new file mode 100644 index 00000000000..c9f9747c175 --- /dev/null +++ b/bkmonitor/packages/apm_web/tests/service/test_service_config.py @@ -0,0 +1,440 @@ +"""APM 服务配置增量关联测试。""" + +from typing import Any + +import pytest + +from apm_web.constants import ServiceRelationLogTypeChoices +from apm_web.models import ( + ApdexServiceRelation, + ApmMetaConfig, + Application, + AppServiceRelation, + CMDBServiceRelation, + EventServiceRelation, + LogServiceRelation, + UriServiceRelation, +) +from apm_web.service.resources import ServiceConfigResource +from apm_web.service.serializers import ServiceConfigSerializer +from apm_web.service.views import ServiceViewSet +from bkmonitor.iam import ActionEnum +from monitor_web.data_explorer.event.constants import EventCategory + +pytestmark = pytest.mark.django_db + + +BK_BIZ_ID = 2 +APP_NAME = "checkout" +SERVICE_NAME = "checkout-api" +BASE_REQUEST = { + "bk_biz_id": BK_BIZ_ID, + "app_name": APP_NAME, + "service_name": SERVICE_NAME, +} +EXISTING_K8S_RELATION = { + "bcs_cluster_id": "BCS-K8S-00000", + "namespace": "prod", + "kind": "Deployment", + "name": "checkout-api", +} +NEW_K8S_RELATION = { + "bcs_cluster_id": "BCS-K8S-00000", + "namespace": "prod", + "kind": "StatefulSet", + "name": "checkout-worker", +} +EXISTING_CICD_RELATION = { + "project_id": "demo-project", + "pipeline_id": "p-checkout-api", + "pipeline_name": "checkout-api 发布流水线", +} +NEW_CICD_RELATION = { + "project_id": "demo-project", + "pipeline_id": "p-checkout-worker", + "pipeline_name": "checkout-worker 发布流水线", +} + + +@pytest.fixture +def application() -> Application: + return Application.objects.create( + application_id=1, + bk_biz_id=BK_BIZ_ID, + app_name=APP_NAME, + app_alias=APP_NAME, + description="test application", + ) + + +@pytest.fixture +def disable_config_delivery(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("apm_web.tasks.update_application_config.delay", lambda *_args, **_kwargs: None) + + +def test_incremental_serializer_only_keeps_explicit_config_fields() -> None: + serializer = ServiceConfigSerializer( + data={ + **BASE_REQUEST, + "incremental_k8s_relations": [NEW_K8S_RELATION], + } + ) + + assert serializer.is_valid(), serializer.errors + assert set(serializer.validated_data) == { + "bk_biz_id", + "app_name", + "service_name", + "incremental_k8s_relations", + } + + +def test_full_save_serializer_keeps_existing_default_semantics() -> None: + serializer = ServiceConfigSerializer(data={**BASE_REQUEST, "uri_relation": []}) + + assert serializer.is_valid(), serializer.errors + assert serializer.validated_data["app_relation"] is None + assert serializer.validated_data["cmdb_relation"] is None + assert serializer.validated_data["log_relation_list"] == [] + assert serializer.validated_data["apdex_relation"] is None + assert serializer.validated_data["uri_relation"] == [] + assert serializer.validated_data["event_relation"] == [] + + +def test_base_only_serializer_does_not_apply_full_save_defaults() -> None: + serializer = ServiceConfigSerializer(data=BASE_REQUEST) + + assert serializer.is_valid(), serializer.errors + assert set(serializer.validated_data) == set(BASE_REQUEST) + + +def test_labels_only_serializer_does_not_apply_relation_defaults() -> None: + serializer = ServiceConfigSerializer(data={**BASE_REQUEST, "labels": ["critical"]}) + + assert serializer.is_valid(), serializer.errors + assert set(serializer.validated_data) == {*BASE_REQUEST, "labels"} + + +@pytest.mark.parametrize( + "full_config", + [ + {"app_relation": None}, + {"cmdb_relation": None}, + {"log_relation_list": []}, + {"apdex_relation": None}, + {"uri_relation": []}, + {"event_relation": []}, + {"labels": []}, + ], +) +def test_incremental_serializer_rejects_mixed_save_modes(full_config: dict[str, Any]) -> None: + serializer = ServiceConfigSerializer( + data={ + **BASE_REQUEST, + **full_config, + "incremental_k8s_relations": [NEW_K8S_RELATION], + } + ) + + assert not serializer.is_valid() + assert "non_field_errors" in serializer.errors + + +@pytest.mark.parametrize( + ("field", "relation"), + [ + ("incremental_k8s_relations", {"bcs_cluster_id": "BCS-K8S-00000"}), + ("incremental_cicd_relations", {"project_id": "demo-project", "pipeline_id": "pipeline-id"}), + ], +) +def test_incremental_serializer_rejects_incomplete_relations(field: str, relation: dict[str, str]) -> None: + serializer = ServiceConfigSerializer(data={**BASE_REQUEST, field: [relation]}) + + assert not serializer.is_valid() + assert field in serializer.errors + + +def test_service_config_uses_view_permission() -> None: + view = ServiceViewSet() + view.action = "service_config" + + permissions = view.get_permissions() + + assert len(permissions) == 1 + assert permissions[0].actions == [ActionEnum.VIEW_APM_APPLICATION] + + +@pytest.mark.parametrize( + ("request_data", "error_field"), + [ + ({**BASE_REQUEST, "app_name": "a" * 51}, "app_name"), + ({**BASE_REQUEST, "service_name": "s" * 513}, "service_name"), + ( + { + **BASE_REQUEST, + "incremental_k8s_relations": [{**NEW_K8S_RELATION, "bcs_cluster_id": "c" * 65}], + }, + "incremental_k8s_relations", + ), + ( + {**BASE_REQUEST, "incremental_k8s_relations": [{**NEW_K8S_RELATION, "namespace": "n" * 64}]}, + "incremental_k8s_relations", + ), + ( + {**BASE_REQUEST, "incremental_k8s_relations": [{**NEW_K8S_RELATION, "kind": "k" * 65}]}, + "incremental_k8s_relations", + ), + ( + {**BASE_REQUEST, "incremental_k8s_relations": [{**NEW_K8S_RELATION, "name": "n" * 254}]}, + "incremental_k8s_relations", + ), + ( + {**BASE_REQUEST, "incremental_cicd_relations": [{**NEW_CICD_RELATION, "project_id": "p" * 129}]}, + "incremental_cicd_relations", + ), + ( + {**BASE_REQUEST, "incremental_cicd_relations": [{**NEW_CICD_RELATION, "pipeline_id": "p" * 129}]}, + "incremental_cicd_relations", + ), + ( + {**BASE_REQUEST, "incremental_cicd_relations": [{**NEW_CICD_RELATION, "pipeline_name": "p" * 256}]}, + "incremental_cicd_relations", + ), + ], +) +def test_service_config_serializer_rejects_overlong_fields(request_data: dict[str, Any], error_field: str) -> None: + serializer = ServiceConfigSerializer(data=request_data) + + assert not serializer.is_valid() + assert error_field in serializer.errors + + +def test_incremental_relations_create_event_records( + application: Application, + disable_config_delivery: None, +) -> None: + ServiceConfigResource().request( + { + **BASE_REQUEST, + "incremental_k8s_relations": [NEW_K8S_RELATION], + "incremental_cicd_relations": [NEW_CICD_RELATION], + } + ) + + relations = {relation.table: relation for relation in EventServiceRelation.objects.all()} + assert relations[EventCategory.K8S_EVENT.value].relations == [NEW_K8S_RELATION] + assert relations[EventCategory.K8S_EVENT.value].options == {"is_auto": False} + assert relations[EventCategory.CICD_EVENT.value].relations == [NEW_CICD_RELATION] + assert relations[EventCategory.CICD_EVENT.value].options == {} + + +def test_incremental_relations_append_deduplicate_and_preserve_existing_configs( + application: Application, + disable_config_delivery: None, +) -> None: + AppServiceRelation.objects.create( + **BASE_REQUEST, + relate_bk_biz_id=3, + relate_app_name="payment", + ) + CMDBServiceRelation.objects.create(**BASE_REQUEST, template_id=100) + LogServiceRelation.objects.create( + **BASE_REQUEST, + log_type=ServiceRelationLogTypeChoices.BK_LOG, + related_bk_biz_id=BK_BIZ_ID, + value="", + value_list=[1001], + ) + ApdexServiceRelation.objects.create( + **BASE_REQUEST, + apdex_key=Application.ApdexConfig.APDEX_DEFAULT, + apdex_value=500, + ) + UriServiceRelation.objects.create(**BASE_REQUEST, uri="/checkout", rank=0) + ApmMetaConfig.service_config_setup(BK_BIZ_ID, APP_NAME, SERVICE_NAME, "labels", '["critical"]') + + k8s_relation = EventServiceRelation.objects.create( + **BASE_REQUEST, + table=EventCategory.K8S_EVENT.value, + relations=[EXISTING_K8S_RELATION], + options={"is_auto": True}, + ) + cicd_relation = EventServiceRelation.objects.create( + **BASE_REQUEST, + table=EventCategory.CICD_EVENT.value, + relations=[EXISTING_CICD_RELATION], + options={"source": "bkci"}, + ) + system_relation = EventServiceRelation.objects.create( + **BASE_REQUEST, + table=EventCategory.SYSTEM_EVENT.value, + relations=[{"bk_biz_id": BK_BIZ_ID}], + options={"level": ["warning"]}, + ) + other_service_relation = EventServiceRelation.objects.create( + bk_biz_id=BK_BIZ_ID, + app_name=APP_NAME, + service_name="payment-api", + table=EventCategory.K8S_EVENT.value, + relations=[{**EXISTING_K8S_RELATION, "name": "payment-api"}], + options={"owner": "payment"}, + ) + global_relation = EventServiceRelation.objects.create( + bk_biz_id=BK_BIZ_ID, + app_name=APP_NAME, + service_name="", + is_global=True, + table=EventCategory.K8S_EVENT.value, + relations=[{"bcs_cluster_id": "BCS-K8S-GLOBAL"}], + options={"scope": "application"}, + ) + request_data: dict[str, Any] = { + **BASE_REQUEST, + "incremental_k8s_relations": [EXISTING_K8S_RELATION, NEW_K8S_RELATION, NEW_K8S_RELATION], + "incremental_cicd_relations": [ + {**EXISTING_CICD_RELATION, "pipeline_name": "不应覆盖已有名称"}, + NEW_CICD_RELATION, + NEW_CICD_RELATION, + ], + } + + resource = ServiceConfigResource() + resource.request(request_data) + resource.request(request_data) + + k8s_relation.refresh_from_db() + cicd_relation.refresh_from_db() + system_relation.refresh_from_db() + other_service_relation.refresh_from_db() + global_relation.refresh_from_db() + assert k8s_relation.relations == [EXISTING_K8S_RELATION, NEW_K8S_RELATION] + assert k8s_relation.options == {"is_auto": False} + assert cicd_relation.relations == [EXISTING_CICD_RELATION, NEW_CICD_RELATION] + assert cicd_relation.options == {"source": "bkci"} + assert system_relation.relations == [{"bk_biz_id": BK_BIZ_ID}] + assert system_relation.options == {"level": ["warning"]} + assert other_service_relation.relations == [{**EXISTING_K8S_RELATION, "name": "payment-api"}] + assert other_service_relation.options == {"owner": "payment"} + assert global_relation.relations == [{"bcs_cluster_id": "BCS-K8S-GLOBAL"}] + assert global_relation.options == {"scope": "application"} + assert EventServiceRelation.objects.count() == 5 + + assert AppServiceRelation.objects.filter(**BASE_REQUEST).count() == 1 + assert CMDBServiceRelation.objects.filter(**BASE_REQUEST).count() == 1 + assert LogServiceRelation.objects.filter(**BASE_REQUEST).count() == 1 + assert ApdexServiceRelation.objects.filter(**BASE_REQUEST).count() == 1 + assert UriServiceRelation.objects.filter(**BASE_REQUEST).count() == 1 + assert ( + ApmMetaConfig.get_service_config_value(BK_BIZ_ID, APP_NAME, SERVICE_NAME, "labels").config_value + == '["critical"]' + ) + + +def test_incremental_k8s_relation_survives_follow_up_full_save( + application: Application, + disable_config_delivery: None, +) -> None: + resource = ServiceConfigResource() + resource.request({**BASE_REQUEST, "incremental_k8s_relations": [NEW_K8S_RELATION]}) + k8s_relation = EventServiceRelation.objects.get(table=EventCategory.K8S_EVENT.value) + + resource.request( + { + **BASE_REQUEST, + "event_relation": [ + { + "table": EventCategory.K8S_EVENT.value, + "relations": [], + "options": {"is_auto": True}, + } + ], + } + ) + + k8s_relation.refresh_from_db() + assert k8s_relation.relations == [NEW_K8S_RELATION] + assert k8s_relation.options == {"is_auto": False} + + +def test_empty_incremental_relations_do_not_change_existing_record( + application: Application, + disable_config_delivery: None, +) -> None: + relation = EventServiceRelation.objects.create( + **BASE_REQUEST, + table=EventCategory.K8S_EVENT.value, + relations=[EXISTING_K8S_RELATION], + options={"is_auto": False}, + ) + updated_at = relation.updated_at + + ServiceConfigResource().request({**BASE_REQUEST, "incremental_k8s_relations": []}) + + relation.refresh_from_db() + assert relation.relations == [EXISTING_K8S_RELATION] + assert relation.options == {"is_auto": False} + assert relation.updated_at == updated_at + + +def test_base_only_request_does_not_delete_existing_relations( + application: Application, + disable_config_delivery: None, +) -> None: + app_relation = AppServiceRelation.objects.create( + **BASE_REQUEST, + relate_bk_biz_id=3, + relate_app_name="payment", + ) + uri_relation = UriServiceRelation.objects.create(**BASE_REQUEST, uri="/checkout", rank=0) + + ServiceConfigResource().request(BASE_REQUEST) + + app_relation.refresh_from_db() + uri_relation.refresh_from_db() + assert app_relation.relate_app_name == "payment" + assert uri_relation.uri == "/checkout" + + +def test_incremental_request_rolls_back_all_relations_on_failure( + application: Application, + disable_config_delivery: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + original_sync_relations = EventServiceRelation.sync_relations + sync_count = 0 + + def fail_second_sync( + _model_cls: type[EventServiceRelation], + *args: Any, + **kwargs: Any, + ) -> dict[str, Any]: + nonlocal sync_count + sync_count += 1 + if sync_count == 2: + raise RuntimeError("second relation failed") + return original_sync_relations(*args, **kwargs) + + monkeypatch.setattr(EventServiceRelation, "sync_relations", classmethod(fail_second_sync)) + + with pytest.raises(RuntimeError, match="second relation failed"): + ServiceConfigResource().request( + { + **BASE_REQUEST, + "incremental_k8s_relations": [NEW_K8S_RELATION], + "incremental_cicd_relations": [NEW_CICD_RELATION], + } + ) + + assert not EventServiceRelation.objects.exists() + + +def test_missing_application_does_not_create_incremental_relation() -> None: + with pytest.raises(Application.DoesNotExist): + ServiceConfigResource().request( + { + **BASE_REQUEST, + "incremental_k8s_relations": [NEW_K8S_RELATION], + } + ) + + assert not EventServiceRelation.objects.exists() diff --git a/bkmonitor/support-files/apigw/docs/zh/apm_update_service_config.md b/bkmonitor/support-files/apigw/docs/zh/apm_update_service_config.md new file mode 100644 index 00000000000..821cb6c6fa6 --- /dev/null +++ b/bkmonitor/support-files/apigw/docs/zh/apm_update_service_config.md @@ -0,0 +1,99 @@ +### 功能描述 + +向指定 APM 服务增量绑定容器负载或蓝盾流水线,用于容器管理平台、蓝盾等内部平台向 APM 注册事件关联数据。 + +该接口只追加请求中显式提交的增量关系,不提供解绑能力,也不会删除服务已有的事件关系、CMDB、日志、APDEX、URI 或标签配置。相同请求按关系身份去重,串行重复调用不会产生重复数据;并发追加不承诺强一致性。 + +### 请求方法与路径 + +```text +POST /app/apm/service/update_service_config/ +``` + +该资源是内部应用态接口,由 APIGW 校验调用应用身份并授权,无需用户登录态。 + +### 请求参数 + +| 字段 | 类型 | 必选 | 描述 | +| --- | --- | --- | --- | +| bk_biz_id | int | 是 | APM 应用所属业务 ID | +| app_name | string | 是 | APM 应用名,最长 50 个字符 | +| service_name | string | 是 | 需要绑定关系的 APM 服务名,最长 512 个字符 | +| incremental_k8s_relations | array[object] | 否 | 需要追加的容器负载关系 | +| incremental_cicd_relations | array[object] | 否 | 需要追加的蓝盾流水线关系 | + +两个增量字段可以同时提交。字段未提交表示不处理该类别;显式提交空数组表示本次没有新增关系,不会清空存量数据。 + +#### incremental_k8s_relations 元素 + +| 字段 | 类型 | 必选 | 描述 | +| --- | --- | --- | --- | +| bcs_cluster_id | string | 是 | BCS 集群 ID,最长 64 个字符 | +| namespace | string | 是 | 命名空间,最长 63 个字符 | +| kind | string | 是 | Workload 类型,如 `Deployment`、`StatefulSet`,最长 64 个字符 | +| name | string | 是 | Workload 名称,最长 253 个字符 | + +K8S 关系按 `(bcs_cluster_id, namespace, kind, name)` 去重。 +增量写入 K8S 关系后,容器事件关联会转为手动模式;APM 配置页后续以自动模式提交空关系时,不会清除外部平台已绑定的 Workload。 + +#### incremental_cicd_relations 元素 + +| 字段 | 类型 | 必选 | 描述 | +| --- | --- | --- | --- | +| project_id | string | 是 | 蓝盾项目 ID,最长 128 个字符 | +| pipeline_id | string | 是 | 流水线 ID,最长 128 个字符 | +| pipeline_name | string | 是 | 流水线展示名称,最长 255 个字符,不参与事件查询条件 | + +CICD 关系按 `(project_id, pipeline_id)` 去重。已有流水线与新增数据身份相同时保留已有记录,`pipeline_name` 不会被增量请求覆盖。 + +### 请求参数示例 + +```json +{ + "bk_biz_id": 2, + "app_name": "checkout", + "service_name": "checkout-api", + "incremental_k8s_relations": [ + { + "bcs_cluster_id": "BCS-K8S-00000", + "namespace": "prod", + "kind": "Deployment", + "name": "checkout-api" + } + ], + "incremental_cicd_relations": [ + { + "project_id": "demo-project", + "pipeline_id": "p-checkout-api", + "pipeline_name": "checkout-api 发布流水线" + } + ] +} +``` + +### 调用约束 + +- 不要在同一请求中混用增量字段与 APM 内部的其他服务配置字段,混用时接口会拒绝请求。 +- 调用前需确保 `bk_biz_id + app_name` 对应的 APM 应用已存在;接口允许在服务被拓扑发现前预先绑定 `service_name`。 +- 关系写入成功后,事件查询侧的进程内缓存最多可能延迟约 60 秒更新。 +- 如需解绑或覆盖完整事件配置,请使用 APM 自身的服务配置能力,不要用空数组表达删除。 + +### 响应参数 + +| 字段 | 类型 | 描述 | +| --- | --- | --- | +| result | bool | 请求是否成功 | +| code | int | 返回状态码 | +| message | string | 返回信息 | +| data | null | 成功时固定为空 | + +### 响应参数示例 + +```json +{ + "result": true, + "code": 200, + "message": "OK", + "data": null +} +``` diff --git a/bkmonitor/support-files/apigw/resources/internal/app/apm.yaml b/bkmonitor/support-files/apigw/resources/internal/app/apm.yaml index 4e22c79b589..5907bfdf18a 100644 --- a/bkmonitor/support-files/apigw/resources/internal/app/apm.yaml +++ b/bkmonitor/support-files/apigw/resources/internal/app/apm.yaml @@ -1116,8 +1116,8 @@ paths: resourcePermissionRequired: true descriptionEn: "query apm event statistics graph" /app/apm/service/update_service_config/: - get: - operationId: update_service_config + post: + operationId: apm_update_service_config description: 【APM】应用下服务配置修改 x-bk-apigateway-resource: isPublic: false @@ -1277,4 +1277,3 @@ paths: userVerifiedRequired: false resourcePermissionRequired: true descriptionEn: "export profile query" -