From f838048588d3d725df8ccd7d317e01b8bbffc553 Mon Sep 17 00:00:00 2001 From: wuw-Mercury Date: Mon, 17 Aug 2026 02:41:45 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E6=8F=90=E4=BE=9B=20APM=20?= =?UTF-8?q?=E5=85=B3=E8=81=94=E6=95=B0=E6=8D=AE=E7=BB=91=E5=AE=9A=20API=20?= =?UTF-8?q?#1010158081137160935?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../packages/apm_web/service/resources.py | 106 +++++- .../packages/apm_web/service/serializers.py | 28 ++ .../apm_web/tests/service/__init__.py | 0 .../tests/service/test_service_config.py | 318 ++++++++++++++++++ .../apigw/docs/zh/update_service_config.md | 98 ++++++ .../apigw/resources/internal/app/apm.yaml | 3 +- .../apigw/scripts/test_merge_resources.py | 21 ++ 7 files changed, 560 insertions(+), 14 deletions(-) create mode 100644 bkmonitor/packages/apm_web/tests/service/__init__.py create mode 100644 bkmonitor/packages/apm_web/tests/service/test_service_config.py create mode 100644 bkmonitor/support-files/apigw/docs/zh/update_service_config.md diff --git a/bkmonitor/packages/apm_web/service/resources.py b/bkmonitor/packages/apm_web/service/resources.py index 62d47eb9ac1..8b1ebcf6c0b 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,85 @@ 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 _prepare_incremental_event_relations( + cls, + bk_biz_id: int, + app_name: str, + service_name: str, + relation_type: str, + new_relations: list[dict[str, Any]], + ) -> list[dict[str, Any]]: + table, unique_fields = cls.INCREMENTAL_EVENT_RELATION_CONFIG[relation_type] + existing_relation: dict[str, Any] = ( + EventServiceRelation.get_relation_qs( + bk_biz_id, + app_name, + [service_name], + table=table, + ) + .order_by("id") + .values("relations", "options") + .first() + or {} + ) + existing_relations: list[dict[str, Any]] = existing_relation.get("relations") or [] + options: dict[str, Any] = existing_relation.get("options") or {} + + deduped_relations: dict[tuple[Any, ...], dict[str, Any]] = {} + # 存量排在新增数据之前,身份相同时由 setdefault 保留原配置。 + for relation in [*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 - ) - prepare_datas: list[dict[str, Any]] = prepare_handler(relation_data) + if relation_type in cls.INCREMENTAL_EVENT_RELATION_CONFIG: + if not relation_data: + return + prepare_datas: list[dict[str, Any]] = cls._prepare_incremental_event_relations( + bk_biz_id, + app_name, + service_name, + relation_type, + 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 relation_type in cls.INCREMENTAL_EVENT_RELATION_CONFIG: + table = cls.INCREMENTAL_EVENT_RELATION_CONFIG[relation_type][0] + 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 +618,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 +637,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..09547b95c3b 100644 --- a/bkmonitor/packages/apm_web/service/serializers.py +++ b/bkmonitor/packages/apm_web/service/serializers.py @@ -77,6 +77,19 @@ class ServiceApdexConfigSerializer(serializers.Serializer): apdex_value = serializers.CharField() +class IncrementalK8sRelationSerializer(serializers.Serializer): + bcs_cluster_id = serializers.CharField() + namespace = serializers.CharField() + kind = serializers.CharField() + name = serializers.CharField() + + +class IncrementalCICDRelationSerializer(serializers.Serializer): + project_id = serializers.CharField() + pipeline_id = serializers.CharField() + pipeline_name = serializers.CharField() + + class ServiceConfigSerializer(serializers.Serializer): bk_biz_id = serializers.IntegerField(label=_("业务 ID")) app_name = serializers.CharField(label=_("应用名")) @@ -88,9 +101,19 @@ 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", + } + if incremental_fields and "event_relation" in request_fields: + raise serializers.ValidationError(_("event_relation 不能与增量事件关联字段同时提交")) + uri_relations: list[str] = attrs["uri_relation"] if len(set(uri_relations)) != len(uri_relations): raise serializers.ValidationError(_("uri 含有重复配置项")) @@ -100,6 +123,11 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: attrs["bk_biz_id"], attrs["app_name"], attrs["service_name"] ) + if incremental_fields: + # 增量请求只处理显式字段,避免完整保存协议的默认值清空其他配置。 + for field in set(attrs) - request_fields: + attrs.pop(field) + return super().validate(attrs) 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..8cd031d3b9b --- /dev/null +++ b/bkmonitor/packages/apm_web/tests/service/test_service_config.py @@ -0,0 +1,318 @@ +"""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 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) + + 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_incremental_serializer_rejects_mixed_event_save_modes() -> None: + serializer = ServiceConfigSerializer( + data={ + **BASE_REQUEST, + "event_relation": [ + { + "table": EventCategory.K8S_EVENT.value, + "relations": [EXISTING_K8S_RELATION], + "options": {}, + } + ], + "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_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 == {} + 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": False}, + ) + 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_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_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/update_service_config.md b/bkmonitor/support-files/apigw/docs/zh/update_service_config.md new file mode 100644 index 00000000000..06d6770f378 --- /dev/null +++ b/bkmonitor/support-files/apigw/docs/zh/update_service_config.md @@ -0,0 +1,98 @@ +### 功能描述 + +向指定 APM 服务增量绑定容器负载或蓝盾流水线,用于容器管理平台、蓝盾等内部平台向 APM 注册事件关联数据。 + +该接口只追加请求中显式提交的增量关系,不提供解绑能力,也不会删除服务已有的事件关系、CMDB、日志、APDEX、URI 或标签配置。相同请求按关系身份去重,串行重复调用不会产生重复数据;并发追加不承诺强一致性。 + +### 请求方法与路径 + +```text +POST /app/apm/service/update_service_config/ +``` + +该资源是内部应用态接口,由 APIGW 校验调用应用身份并授权,无需用户登录态。 + +### 请求参数 + +| 字段 | 类型 | 必选 | 描述 | +| --- | --- | --- | --- | +| bk_biz_id | int | 是 | APM 应用所属业务 ID | +| app_name | string | 是 | APM 应用名 | +| service_name | string | 是 | 需要绑定关系的 APM 服务名 | +| incremental_k8s_relations | array[object] | 否 | 需要追加的容器负载关系 | +| incremental_cicd_relations | array[object] | 否 | 需要追加的蓝盾流水线关系 | + +两个增量字段可以同时提交。字段未提交表示不处理该类别;显式提交空数组表示本次没有新增关系,不会清空存量数据。 + +#### incremental_k8s_relations 元素 + +| 字段 | 类型 | 必选 | 描述 | +| --- | --- | --- | --- | +| bcs_cluster_id | string | 是 | BCS 集群 ID | +| namespace | string | 是 | 命名空间 | +| kind | string | 是 | Workload 类型,如 `Deployment`、`StatefulSet` | +| name | string | 是 | Workload 名称 | + +K8S 关系按 `(bcs_cluster_id, namespace, kind, name)` 去重。 + +#### incremental_cicd_relations 元素 + +| 字段 | 类型 | 必选 | 描述 | +| --- | --- | --- | --- | +| project_id | string | 是 | 蓝盾项目 ID | +| pipeline_id | string | 是 | 流水线 ID | +| pipeline_name | string | 是 | 流水线展示名称,不参与事件查询条件 | + +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 发布流水线" + } + ] +} +``` + +### 调用约束 + +- 不要在同一请求中混用 `event_relation` 与任一增量字段。`event_relation` 属于 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..c5061c3acc7 100644 --- a/bkmonitor/support-files/apigw/resources/internal/app/apm.yaml +++ b/bkmonitor/support-files/apigw/resources/internal/app/apm.yaml @@ -1116,7 +1116,7 @@ paths: resourcePermissionRequired: true descriptionEn: "query apm event statistics graph" /app/apm/service/update_service_config/: - get: + post: operationId: update_service_config description: 【APM】应用下服务配置修改 x-bk-apigateway-resource: @@ -1277,4 +1277,3 @@ paths: userVerifiedRequired: false resourcePermissionRequired: true descriptionEn: "export profile query" - diff --git a/bkmonitor/support-files/apigw/scripts/test_merge_resources.py b/bkmonitor/support-files/apigw/scripts/test_merge_resources.py index cad1edda7b2..f10221c5582 100644 --- a/bkmonitor/support-files/apigw/scripts/test_merge_resources.py +++ b/bkmonitor/support-files/apigw/scripts/test_merge_resources.py @@ -25,6 +25,7 @@ _RESOURCES_DIR = _SCRIPT.parent.parent / "resources" _DOCS_DIR = _SCRIPT.parent.parent / "docs/zh" _METADATA_FILE = _RESOURCES_DIR / "internal/app/metadata.yaml" +_APM_FILE = _RESOURCES_DIR / "internal/app/apm.yaml" _ALERT_MCP_FILE = _RESOURCES_DIR / "internal/user/alert_mcp.yaml" _ALERT_HANDLING_MCP_FILE = _RESOURCES_DIR / "internal/user/alert_handling_mcp.yaml" @@ -130,6 +131,26 @@ def test_result_table_storage_status_apigw_contract(): assert (_DOCS_DIR / f"{method_data['operationId']}.md").is_file() +def test_update_service_config_apigw_contract(): + """APM 服务配置接口必须使用应用态 POST 并提供中文文档。""" + path_data = _load_paths(_APM_FILE)["/app/apm/service/update_service_config/"] + + assert set(path_data) == {"post"} + method_data = path_data["post"] + gateway_resource = method_data["x-bk-apigateway-resource"] + assert method_data["operationId"] == "update_service_config" + assert gateway_resource["isPublic"] is False + assert gateway_resource["allowApplyPermission"] is False + assert gateway_resource["backend"]["method"] == "post" + assert gateway_resource["backend"]["path"] == "/api/v4/service_web/service_config/" + assert gateway_resource["authConfig"] == { + "appVerifiedRequired": True, + "userVerifiedRequired": False, + "resourcePermissionRequired": True, + } + assert (_DOCS_DIR / f"{method_data['operationId']}.md").is_file() + + def test_repository_resources_have_unique_operation_ids(): """仓库内现有 apigw 资源定义必须无重复 operationId(回归基线)。""" public_dirs = ["internal", "external"] From c0f0801a67485114aa5c655e8a0dc2a784888cbb Mon Sep 17 00:00:00 2001 From: wuw-Mercury Date: Mon, 17 Aug 2026 06:50:55 +0000 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20operationId=20?= =?UTF-8?q?=E5=91=BD=E5=90=8D=E8=A2=AB=E5=8D=A0=E7=94=A8=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bkmonitor/support-files/apigw/resources/internal/app/apm.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bkmonitor/support-files/apigw/resources/internal/app/apm.yaml b/bkmonitor/support-files/apigw/resources/internal/app/apm.yaml index c5061c3acc7..5907bfdf18a 100644 --- a/bkmonitor/support-files/apigw/resources/internal/app/apm.yaml +++ b/bkmonitor/support-files/apigw/resources/internal/app/apm.yaml @@ -1117,7 +1117,7 @@ paths: descriptionEn: "query apm event statistics graph" /app/apm/service/update_service_config/: post: - operationId: update_service_config + operationId: apm_update_service_config description: 【APM】应用下服务配置修改 x-bk-apigateway-resource: isPublic: false From 5a783e89e16fb093765d3b46e9abc9bd590017bb Mon Sep 17 00:00:00 2001 From: wuw-Mercury Date: Mon, 17 Aug 2026 11:09:49 +0000 Subject: [PATCH 3/5] =?UTF-8?q?refactor:=20=E6=9B=B4=E6=96=B0=20APIGW=20?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{update_service_config.md => apm_update_service_config.md} | 0 bkmonitor/support-files/apigw/scripts/test_merge_resources.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename bkmonitor/support-files/apigw/docs/zh/{update_service_config.md => apm_update_service_config.md} (100%) diff --git a/bkmonitor/support-files/apigw/docs/zh/update_service_config.md b/bkmonitor/support-files/apigw/docs/zh/apm_update_service_config.md similarity index 100% rename from bkmonitor/support-files/apigw/docs/zh/update_service_config.md rename to bkmonitor/support-files/apigw/docs/zh/apm_update_service_config.md diff --git a/bkmonitor/support-files/apigw/scripts/test_merge_resources.py b/bkmonitor/support-files/apigw/scripts/test_merge_resources.py index f10221c5582..6da9a29f15f 100644 --- a/bkmonitor/support-files/apigw/scripts/test_merge_resources.py +++ b/bkmonitor/support-files/apigw/scripts/test_merge_resources.py @@ -138,7 +138,7 @@ def test_update_service_config_apigw_contract(): assert set(path_data) == {"post"} method_data = path_data["post"] gateway_resource = method_data["x-bk-apigateway-resource"] - assert method_data["operationId"] == "update_service_config" + assert method_data["operationId"] == "apm_update_service_config" assert gateway_resource["isPublic"] is False assert gateway_resource["allowApplyPermission"] is False assert gateway_resource["backend"]["method"] == "post" From 3f7ec8785a62d1077591e3e47f4b6b58ea42d28a Mon Sep 17 00:00:00 2001 From: wuw-Mercury Date: Mon, 17 Aug 2026 11:51:35 +0000 Subject: [PATCH 4/5] =?UTF-8?q?refactor:=20=E4=BB=A3=E7=A0=81=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../packages/apm_web/service/resources.py | 18 +++-- .../packages/apm_web/service/serializers.py | 16 +++-- bkmonitor/packages/apm_web/service/views.py | 10 ++- .../tests/service/test_service_config.py | 69 ++++++++++++++++--- .../docs/zh/apm_update_service_config.md | 2 +- .../apigw/scripts/test_merge_resources.py | 21 ------ 6 files changed, 91 insertions(+), 45 deletions(-) diff --git a/bkmonitor/packages/apm_web/service/resources.py b/bkmonitor/packages/apm_web/service/resources.py index 8b1ebcf6c0b..727cc22e7de 100644 --- a/bkmonitor/packages/apm_web/service/resources.py +++ b/bkmonitor/packages/apm_web/service/resources.py @@ -530,10 +530,10 @@ def _prepare_incremental_event_relations( bk_biz_id: int, app_name: str, service_name: str, - relation_type: str, + table: str, + unique_fields: tuple[str, ...], new_relations: list[dict[str, Any]], ) -> list[dict[str, Any]]: - table, unique_fields = cls.INCREMENTAL_EVENT_RELATION_CONFIG[relation_type] existing_relation: dict[str, Any] = ( EventServiceRelation.get_relation_qs( bk_biz_id, @@ -551,7 +551,7 @@ def _prepare_incremental_event_relations( deduped_relations: dict[tuple[Any, ...], dict[str, Any]] = {} # 存量排在新增数据之前,身份相同时由 setdefault 保留原配置。 - for relation in [*existing_relations, *new_relations]: + 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) @@ -571,14 +571,19 @@ def update_relation( # 预处理数据 model_cls: type[ServiceBase] = cls.RELATION_MODEL_MAP[relation_type] - if relation_type in cls.INCREMENTAL_EVENT_RELATION_CONFIG: + incremental_config: tuple[str, tuple[str, ...]] | None = cls.INCREMENTAL_EVENT_RELATION_CONFIG.get( + relation_type + ) + 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, - relation_type, + table, + unique_fields, relation_data, ) else: @@ -593,8 +598,7 @@ def update_relation( for data in prepare_datas ] # 执行同步 - if relation_type in cls.INCREMENTAL_EVENT_RELATION_CONFIG: - table = cls.INCREMENTAL_EVENT_RELATION_CONFIG[relation_type][0] + if incremental_config is not None: model_cls.sync_relations( bk_biz_id, app_name, diff --git a/bkmonitor/packages/apm_web/service/serializers.py b/bkmonitor/packages/apm_web/service/serializers.py index 09547b95c3b..7bddefb8a01 100644 --- a/bkmonitor/packages/apm_web/service/serializers.py +++ b/bkmonitor/packages/apm_web/service/serializers.py @@ -111,8 +111,16 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: "incremental_cicd_relations", "incremental_k8s_relations", } - if incremental_fields and "event_relation" in request_fields: - raise serializers.ValidationError(_("event_relation 不能与增量事件关联字段同时提交")) + 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): @@ -123,8 +131,8 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: attrs["bk_biz_id"], attrs["app_name"], attrs["service_name"] ) - if incremental_fields: - # 增量请求只处理显式字段,避免完整保存协议的默认值清空其他配置。 + if incremental_fields or not full_relation_fields: + # 增量、空请求和仅标签请求只处理显式字段,避免完整保存协议的默认值清空其他配置。 for field in set(attrs) - request_fields: attrs.pop(field) diff --git a/bkmonitor/packages/apm_web/service/views.py b/bkmonitor/packages/apm_web/service/views.py index 5c3a06c5488..5f594b3147f 100644 --- a/bkmonitor/packages/apm_web/service/views.py +++ b/bkmonitor/packages/apm_web/service/views.py @@ -29,6 +29,7 @@ ) from bkmonitor.iam import ActionEnum, ResourceEnum +from bkmonitor.iam.action import ActionMeta from bkmonitor.iam.drf import InstanceActionForDataPermission, ViewBusinessPermission, insert_permission_field from core.drf_resource.viewsets import ResourceRoute, ResourceViewSet @@ -36,14 +37,17 @@ 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 [] + required_action: ActionMeta = ( + ActionEnum.MANAGE_APM_APPLICATION if self.action == "service_config" else ActionEnum.VIEW_APM_APPLICATION + ) return [ InstanceActionForDataPermission( self.INSTANCE_ID, - [ActionEnum.VIEW_APM_APPLICATION], + [required_action], ResourceEnum.APM_APPLICATION, get_instance_id=Application.get_application_id_by_app_name, ) diff --git a/bkmonitor/packages/apm_web/tests/service/test_service_config.py b/bkmonitor/packages/apm_web/tests/service/test_service_config.py index 8cd031d3b9b..8a83631557a 100644 --- a/bkmonitor/packages/apm_web/tests/service/test_service_config.py +++ b/bkmonitor/packages/apm_web/tests/service/test_service_config.py @@ -17,6 +17,8 @@ ) 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 @@ -88,7 +90,7 @@ def test_incremental_serializer_only_keeps_explicit_config_fields() -> None: def test_full_save_serializer_keeps_existing_default_semantics() -> None: - serializer = ServiceConfigSerializer(data=BASE_REQUEST) + serializer = ServiceConfigSerializer(data={**BASE_REQUEST, "uri_relation": []}) assert serializer.is_valid(), serializer.errors assert serializer.validated_data["app_relation"] is None @@ -99,17 +101,37 @@ def test_full_save_serializer_keeps_existing_default_semantics() -> None: assert serializer.validated_data["event_relation"] == [] -def test_incremental_serializer_rejects_mixed_event_save_modes() -> None: +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, - "event_relation": [ - { - "table": EventCategory.K8S_EVENT.value, - "relations": [EXISTING_K8S_RELATION], - "options": {}, - } - ], + **full_config, "incremental_k8s_relations": [NEW_K8S_RELATION], } ) @@ -132,6 +154,16 @@ def test_incremental_serializer_rejects_incomplete_relations(field: str, relatio assert field in serializer.errors +def test_service_config_requires_manage_permission() -> None: + view = ServiceViewSet() + view.action = "service_config" + + permissions = view.get_permissions() + + assert len(permissions) == 1 + assert permissions[0].actions == [ActionEnum.MANAGE_APM_APPLICATION] + + def test_incremental_relations_create_event_records( application: Application, disable_config_delivery: None, @@ -273,6 +305,25 @@ def test_empty_incremental_relations_do_not_change_existing_record( 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, 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 index 06d6770f378..f5ed5e3de13 100644 --- a/bkmonitor/support-files/apigw/docs/zh/apm_update_service_config.md +++ b/bkmonitor/support-files/apigw/docs/zh/apm_update_service_config.md @@ -72,7 +72,7 @@ CICD 关系按 `(project_id, pipeline_id)` 去重。已有流水线与新增数 ### 调用约束 -- 不要在同一请求中混用 `event_relation` 与任一增量字段。`event_relation` 属于 APM 内部完整保存协议,混用时接口会拒绝请求。 +- 不要在同一请求中混用增量字段与 APM 内部的其他服务配置字段,混用时接口会拒绝请求。 - 调用前需确保 `bk_biz_id + app_name` 对应的 APM 应用已存在;接口允许在服务被拓扑发现前预先绑定 `service_name`。 - 关系写入成功后,事件查询侧的进程内缓存最多可能延迟约 60 秒更新。 - 如需解绑或覆盖完整事件配置,请使用 APM 自身的服务配置能力,不要用空数组表达删除。 diff --git a/bkmonitor/support-files/apigw/scripts/test_merge_resources.py b/bkmonitor/support-files/apigw/scripts/test_merge_resources.py index 6da9a29f15f..cad1edda7b2 100644 --- a/bkmonitor/support-files/apigw/scripts/test_merge_resources.py +++ b/bkmonitor/support-files/apigw/scripts/test_merge_resources.py @@ -25,7 +25,6 @@ _RESOURCES_DIR = _SCRIPT.parent.parent / "resources" _DOCS_DIR = _SCRIPT.parent.parent / "docs/zh" _METADATA_FILE = _RESOURCES_DIR / "internal/app/metadata.yaml" -_APM_FILE = _RESOURCES_DIR / "internal/app/apm.yaml" _ALERT_MCP_FILE = _RESOURCES_DIR / "internal/user/alert_mcp.yaml" _ALERT_HANDLING_MCP_FILE = _RESOURCES_DIR / "internal/user/alert_handling_mcp.yaml" @@ -131,26 +130,6 @@ def test_result_table_storage_status_apigw_contract(): assert (_DOCS_DIR / f"{method_data['operationId']}.md").is_file() -def test_update_service_config_apigw_contract(): - """APM 服务配置接口必须使用应用态 POST 并提供中文文档。""" - path_data = _load_paths(_APM_FILE)["/app/apm/service/update_service_config/"] - - assert set(path_data) == {"post"} - method_data = path_data["post"] - gateway_resource = method_data["x-bk-apigateway-resource"] - assert method_data["operationId"] == "apm_update_service_config" - assert gateway_resource["isPublic"] is False - assert gateway_resource["allowApplyPermission"] is False - assert gateway_resource["backend"]["method"] == "post" - assert gateway_resource["backend"]["path"] == "/api/v4/service_web/service_config/" - assert gateway_resource["authConfig"] == { - "appVerifiedRequired": True, - "userVerifiedRequired": False, - "resourcePermissionRequired": True, - } - assert (_DOCS_DIR / f"{method_data['operationId']}.md").is_file() - - def test_repository_resources_have_unique_operation_ids(): """仓库内现有 apigw 资源定义必须无重复 operationId(回归基线)。""" public_dirs = ["internal", "external"] From 0d84c90a8a30486fbc646ec6d134f07a4c0b905a Mon Sep 17 00:00:00 2001 From: wuw-Mercury Date: Wed, 19 Aug 2026 03:38:26 +0000 Subject: [PATCH 5/5] =?UTF-8?q?refactor:=20CR=20=E6=84=8F=E8=A7=81?= =?UTF-8?q?=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../packages/apm_web/service/resources.py | 76 ++++++++++++++++-- .../packages/apm_web/service/serializers.py | 18 ++--- bkmonitor/packages/apm_web/service/views.py | 6 +- .../tests/service/test_service_config.py | 79 ++++++++++++++++++- .../docs/zh/apm_update_service_config.md | 19 ++--- 5 files changed, 165 insertions(+), 33 deletions(-) diff --git a/bkmonitor/packages/apm_web/service/resources.py b/bkmonitor/packages/apm_web/service/resources.py index 727cc22e7de..6902a4f3cb7 100644 --- a/bkmonitor/packages/apm_web/service/resources.py +++ b/bkmonitor/packages/apm_web/service/resources.py @@ -525,29 +525,86 @@ def _prepare_log_relation_list(cls, data: list[dict[str, Any]]) -> list[dict[str return list(unique_relations.values()) @classmethod - def _prepare_incremental_event_relations( + def _get_event_relation_for_update( 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] = ( + ) -> 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] = existing_relation.get("options") 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 保留原配置。 @@ -586,6 +643,13 @@ def update_relation( 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 diff --git a/bkmonitor/packages/apm_web/service/serializers.py b/bkmonitor/packages/apm_web/service/serializers.py index 7bddefb8a01..502976c63cf 100644 --- a/bkmonitor/packages/apm_web/service/serializers.py +++ b/bkmonitor/packages/apm_web/service/serializers.py @@ -78,22 +78,22 @@ class ServiceApdexConfigSerializer(serializers.Serializer): class IncrementalK8sRelationSerializer(serializers.Serializer): - bcs_cluster_id = serializers.CharField() - namespace = serializers.CharField() - kind = serializers.CharField() - name = serializers.CharField() + 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() - pipeline_id = serializers.CharField() - pipeline_name = serializers.CharField() + 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) diff --git a/bkmonitor/packages/apm_web/service/views.py b/bkmonitor/packages/apm_web/service/views.py index 5f594b3147f..6c57badeade 100644 --- a/bkmonitor/packages/apm_web/service/views.py +++ b/bkmonitor/packages/apm_web/service/views.py @@ -29,7 +29,6 @@ ) from bkmonitor.iam import ActionEnum, ResourceEnum -from bkmonitor.iam.action import ActionMeta from bkmonitor.iam.drf import InstanceActionForDataPermission, ViewBusinessPermission, insert_permission_field from core.drf_resource.viewsets import ResourceRoute, ResourceViewSet @@ -41,13 +40,10 @@ def get_permissions(self) -> list[InstanceActionForDataPermission]: if self.action == "app_query_by_index_set": return [] - required_action: ActionMeta = ( - ActionEnum.MANAGE_APM_APPLICATION if self.action == "service_config" else ActionEnum.VIEW_APM_APPLICATION - ) return [ InstanceActionForDataPermission( self.INSTANCE_ID, - [required_action], + [ActionEnum.VIEW_APM_APPLICATION], ResourceEnum.APM_APPLICATION, get_instance_id=Application.get_application_id_by_app_name, ) diff --git a/bkmonitor/packages/apm_web/tests/service/test_service_config.py b/bkmonitor/packages/apm_web/tests/service/test_service_config.py index 8a83631557a..c9f9747c175 100644 --- a/bkmonitor/packages/apm_web/tests/service/test_service_config.py +++ b/bkmonitor/packages/apm_web/tests/service/test_service_config.py @@ -154,14 +154,59 @@ def test_incremental_serializer_rejects_incomplete_relations(field: str, relatio assert field in serializer.errors -def test_service_config_requires_manage_permission() -> None: +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.MANAGE_APM_APPLICATION] + 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( @@ -178,7 +223,7 @@ def test_incremental_relations_create_event_records( 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 == {} + 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 == {} @@ -212,7 +257,7 @@ def test_incremental_relations_append_deduplicate_and_preserve_existing_configs( **BASE_REQUEST, table=EventCategory.K8S_EVENT.value, relations=[EXISTING_K8S_RELATION], - options={"is_auto": False}, + options={"is_auto": True}, ) cicd_relation = EventServiceRelation.objects.create( **BASE_REQUEST, @@ -285,6 +330,32 @@ def test_incremental_relations_append_deduplicate_and_preserve_existing_configs( ) +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, 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 index f5ed5e3de13..821cb6c6fa6 100644 --- a/bkmonitor/support-files/apigw/docs/zh/apm_update_service_config.md +++ b/bkmonitor/support-files/apigw/docs/zh/apm_update_service_config.md @@ -17,8 +17,8 @@ POST /app/apm/service/update_service_config/ | 字段 | 类型 | 必选 | 描述 | | --- | --- | --- | --- | | bk_biz_id | int | 是 | APM 应用所属业务 ID | -| app_name | string | 是 | APM 应用名 | -| service_name | string | 是 | 需要绑定关系的 APM 服务名 | +| app_name | string | 是 | APM 应用名,最长 50 个字符 | +| service_name | string | 是 | 需要绑定关系的 APM 服务名,最长 512 个字符 | | incremental_k8s_relations | array[object] | 否 | 需要追加的容器负载关系 | | incremental_cicd_relations | array[object] | 否 | 需要追加的蓝盾流水线关系 | @@ -28,20 +28,21 @@ POST /app/apm/service/update_service_config/ | 字段 | 类型 | 必选 | 描述 | | --- | --- | --- | --- | -| bcs_cluster_id | string | 是 | BCS 集群 ID | -| namespace | string | 是 | 命名空间 | -| kind | string | 是 | Workload 类型,如 `Deployment`、`StatefulSet` | -| name | string | 是 | Workload 名称 | +| 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 | -| pipeline_id | string | 是 | 流水线 ID | -| pipeline_name | string | 是 | 流水线展示名称,不参与事件查询条件 | +| project_id | string | 是 | 蓝盾项目 ID,最长 128 个字符 | +| pipeline_id | string | 是 | 流水线 ID,最长 128 个字符 | +| pipeline_name | string | 是 | 流水线展示名称,最长 255 个字符,不参与事件查询条件 | CICD 关系按 `(project_id, pipeline_id)` 去重。已有流水线与新增数据身份相同时保留已有记录,`pipeline_name` 不会被增量请求覆盖。