Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

* 资源导入时,支持旧版本 yaml 中的 upstreams/transform_headers #281

Merged
merged 12 commits into from
Oct 13, 2023
Merged
14 changes: 10 additions & 4 deletions src/dashboard/apigateway/apigateway/apis/open/stage/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,8 +238,11 @@ def create(self, validated_data):
# 4. create or update header rewrite plugin config
stage_transform_headers = proxy_http_config.get("transform_headers") or {}
stage_config = HeaderRewriteConvertor.transform_headers_to_plugin_config(stage_transform_headers)
HeaderRewriteConvertor.alter_plugin(
instance.gateway_id, PluginBindingScopeEnum.STAGE.value, instance.id, stage_config
HeaderRewriteConvertor.sync_plugins(
instance.gateway_id,
PluginBindingScopeEnum.STAGE.value,
{instance.id: stage_config},
self.context["request"].user.username,
)

return instance
Expand Down Expand Up @@ -292,8 +295,11 @@ def update(self, instance, validated_data):
# 3. create or update header rewrite plugin config
stage_transform_headers = proxy_http_config.get("transform_headers") or {}
stage_config = HeaderRewriteConvertor.transform_headers_to_plugin_config(stage_transform_headers)
HeaderRewriteConvertor.alter_plugin(
instance.gateway_id, PluginBindingScopeEnum.STAGE.value, instance.id, stage_config
HeaderRewriteConvertor.sync_plugins(
instance.gateway_id,
PluginBindingScopeEnum.STAGE.value,
{instance.id: stage_config},
self.context["request"].user.username,
)

return instance
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
#
# TencentBlueKing is pleased to support the open source community by making
# 蓝鲸智云 - API 网关(BlueKing - APIGateway) 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.
#
# 1.13 版本:兼容旧版 (api_version=0.1) 资源 yaml 通过 openapi 导入
import re

from django.utils.translation import gettext as _
from rest_framework import serializers

from apigateway.core.constants import DEFAULT_LB_HOST_WEIGHT, STAGE_VAR_REFERENCE_PATTERN, LoadBalanceTypeEnum

# 通过 openapi 导入时,只允许导入使用环境变量的后端地址
RESOURCE_DOMAIN_PATTERN = re.compile(r"^http(s)?:\/\/\{%s\}$" % (STAGE_VAR_REFERENCE_PATTERN.pattern))

HEADER_KEY_PATTERN = re.compile(r"^[a-zA-Z0-9-]{1,100}$")


class LegacyResourceHostSLZ(serializers.Serializer):
host = serializers.RegexField(RESOURCE_DOMAIN_PATTERN)
weight = serializers.IntegerField(min_value=1, default=DEFAULT_LB_HOST_WEIGHT)


class LegacyUpstreamsSLZ(serializers.Serializer):
loadbalance = serializers.ChoiceField(choices=LoadBalanceTypeEnum.get_choices(), required=False)
hosts = serializers.ListField(child=LegacyResourceHostSLZ(), allow_empty=False, required=False)

def validate(self, data):
if "hosts" in data and not data.get("loadbalance"):
raise serializers.ValidationError(_("hosts 存在时,需要指定 loadbalance 类型。"))

return data


class LegacyTransformHeadersSLZ(serializers.Serializer):
set = serializers.DictField(label="设置", child=serializers.CharField(), required=False, allow_empty=True)
delete = serializers.ListField(label="删除", child=serializers.CharField(), required=False, allow_empty=True)

def _validate_headers_key(self, value):
for key in value:
if not HEADER_KEY_PATTERN.match(key):
raise serializers.ValidationError(_("Header 键由字母、数字、连接符(-)组成,长度小于100个字符。"))
return value

def validate_set(self, value):
return self._validate_headers_key(value)

def validate_delete(self, value):
return self._validate_headers_key(value)
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
from apigateway.core.utils import get_path_display

from .constants import MAX_LABEL_COUNT_PER_RESOURCE, PATH_PATTERN, RESOURCE_NAME_PATTERN
from .legacy_serializers import LegacyTransformHeadersSLZ, LegacyUpstreamsSLZ


class ResourceQueryInputSLZ(serializers.Serializer):
Expand Down Expand Up @@ -143,6 +144,11 @@ class HttpBackendConfigSLZ(serializers.Serializer):
timeout = serializers.IntegerField(
max_value=MAX_BACKEND_TIMEOUT_IN_SECOND, min_value=0, required=False, help_text="超时时间"
)
# 1.13 版本: 兼容旧版 (api_version=0.1) 资源 yaml 通过 openapi 导入
legacy_upstreams = LegacyUpstreamsSLZ(allow_null=True, required=False, help_text="旧版 upstreams,管理端不需要处理")
zhu327 marked this conversation as resolved.
Show resolved Hide resolved
legacy_transform_headers = LegacyTransformHeadersSLZ(
allow_null=True, required=False, help_text="旧版 transform_headers,管理端不需要处理"
)


class ResourceInputSLZ(serializers.ModelSerializer):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
from apigateway.core.constants import DEFAULT_BACKEND_NAME, HTTP_METHOD_ANY
from apigateway.core.models import Backend, Gateway, Resource

from .legacy_synchronizers import LegacyTransformHeadersToPluginSynchronizer, LegacyUpstreamToBackendSynchronizer

logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -329,9 +331,15 @@ def import_resources(self):
# 3. 补全标签 ID 数据
self._complete_label_ids()

# 4. 创建或更新资源
# 4. [legacy upstreams] 创建或更新 backend,并替换资源对应的 backend
self._sync_legacy_upstreams_to_backend_and_replace_resource_backend()

# 5. 创建或更新资源
self._create_or_update_resources()

# 6. [legacy transform-headers] 将 transform-headers 转换为 bk-header-rewrite 插件,并绑定到资源
self._sync_legacy_transform_headers_to_plugins()

def get_selected_resource_data_list(self) -> List[ResourceData]:
return self.resource_data_list

Expand Down Expand Up @@ -387,3 +395,13 @@ def _create_or_update_resources(self) -> List[Resource]:
username=self.username,
)
return saver.save()

def _sync_legacy_upstreams_to_backend_and_replace_resource_backend(self):
"""根据 backend_config 中的 legacy_upstreams 创建 backend,并替换 resource_data_list 中资源关联的 backend"""
synchronizer = LegacyUpstreamToBackendSynchronizer(self.gateway, self.resource_data_list, self.username)
synchronizer.sync_backends_and_replace_resource_backend()

def _sync_legacy_transform_headers_to_plugins(self):
"""根据 backend_config 中的 legacy_transform_headers 创建 bk-header-rewrite 插件,并绑定到资源"""
synchronizer = LegacyTransformHeadersToPluginSynchronizer(self.gateway, self.resource_data_list, self.username)
synchronizer.sync_plugins()
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
#
# TencentBlueKing is pleased to support the open source community by making
# 蓝鲸智云 - API 网关(BlueKing - APIGateway) 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.
#
# 1.13 版本: 兼容旧版 (api_version=0.1) 资源 yaml 通过 openapi 导入
alex-smile marked this conversation as resolved.
Show resolved Hide resolved
import logging
import re
from collections import defaultdict
from typing import Any, Dict, List, Optional

from apigateway.apps.plugin.constants import PluginBindingScopeEnum
from apigateway.biz.resource.models import ResourceData
from apigateway.common.plugin.header_rewrite import HeaderRewriteConvertor
from apigateway.core.constants import DEFAULT_BACKEND_NAME, STAGE_VAR_PATTERN
from apigateway.core.models import Backend, BackendConfig, Gateway, Stage

logger = logging.getLogger(__name__)


LEGACY_BACKEND_NAME_PREFIX = "backend-"


class LegacyUpstream:
def __init__(self, upstreams: Dict[str, Any]):
self.upstreams = upstreams

def get_stage_id_to_backend_config(
self,
stages: List[Stage],
stage_id_to_timeout: Dict[int, int],
) -> Dict[int, Dict]:
"""获取此 upstream 对应的后端,在各个环境的后端配置"""
backend_configs = {}

for stage in stages:
stage_vars = stage.vars

hosts = []
for host in self.upstreams["hosts"]:
scheme, host_ = host["host"].rstrip("/").split("://")
hosts.append(
{
"scheme": scheme,
"host": self._render_host(stage_vars, host_),
"weight": host["weight"],
}
)

backend_configs[stage.id] = {
"type": "node",
# 新创建的后端,其超时时间,默认使用 default 后端在各环境配置的超时时间
"timeout": stage_id_to_timeout[stage.id],
"loadbalance": self.upstreams["loadbalance"],
"hosts": hosts,
}

return backend_configs

def _render_host(self, vars: Dict[str, Any], host: str) -> str:
def replace(matched):
return vars.get(matched.group(1), matched.group(0))

return re.sub(STAGE_VAR_PATTERN, replace, host)


alex-smile marked this conversation as resolved.
Show resolved Hide resolved
class LegacyBackendCreator:
def __init__(self, gateway: Gateway, username: str):
self.gateway = gateway
self.username = username

self._existing_backends = {backend.id: backend for backend in Backend.objects.filter(gateway=gateway)}
self._existing_backend_configs = self._get_existing_backend_configs()
self._max_legacy_backend_number = self._get_max_legacy_backend_number()

def match_or_create_backend(self, stage_id_to_backend_config: Dict[int, Dict]) -> Backend:
"""根据后端配置,匹配一个后端服务;如果未匹配,根据规则生成一个新的后端服务"""
# 排序 hosts,使其与 existing_backend_configs 中 hosts 顺序一致,便于对比数据
for backend_config in stage_id_to_backend_config.values():
backend_config["hosts"] = self._sort_hosts(backend_config["hosts"])

backend_id = self._match_existing_backend(stage_id_to_backend_config)
if backend_id:
return self._existing_backends[backend_id]

new_backend_name = self._generate_new_backend_name()
backend = self._create_backend_and_backend_configs(new_backend_name, stage_id_to_backend_config)

# 用新创建的 backend 更新辅助数据
self._existing_backends[backend.id] = backend
self._existing_backend_configs[backend.id] = stage_id_to_backend_config

return backend

def _match_existing_backend(self, stage_id_to_backend_config: Dict[int, Dict]) -> Optional[int]:
for backend_id, existing_backend_configs in self._existing_backend_configs.items():
if stage_id_to_backend_config == existing_backend_configs:
return backend_id

return None

def _get_existing_backend_configs(self) -> Dict[int, Dict[int, Dict]]:
# 对应关系:backend_id -> stage_id -> config
backend_configs: Dict[int, Dict[int, Dict]] = defaultdict(dict)

for backend_config in BackendConfig.objects.filter(gateway=self.gateway):
config = backend_config.config
config["hosts"] = self._sort_hosts(config["hosts"])

backend_configs[backend_config.backend_id][backend_config.stage_id] = config

return backend_configs

def _generate_new_backend_name(self) -> str:
self._max_legacy_backend_number += 1
return f"{LEGACY_BACKEND_NAME_PREFIX}{self._max_legacy_backend_number}"

def _create_backend_and_backend_configs(
self,
backend_name: str,
stage_id_to_backend_config: Dict[int, Dict],
) -> Backend:
backend = Backend.objects.create(
gateway=self.gateway, name=backend_name, created_by=self.username, updated_by=self.username
)

backend_configs = [
BackendConfig(
gateway=self.gateway,
stage_id=stage_id,
backend=backend,
config=config,
created_by=self.username,
updated_by=self.username,
)
for stage_id, config in stage_id_to_backend_config.items()
]
BackendConfig.objects.bulk_create(backend_configs)

return backend

def _sort_hosts(self, hosts: List[Dict[str, Dict]]) -> List[Dict[str, Dict]]:
# 排序 host,使用 "==" 对比配置时顺序一致
return sorted(hosts, key=lambda x: "{}://{}#{}".format(x["scheme"], x["host"], x["weight"]))

def _get_max_legacy_backend_number(self) -> int:
"""获取网关创建的后端中,后端名称中已使用的最大序号"""
names = Backend.objects.filter(gateway=self.gateway, name__startswith=LEGACY_BACKEND_NAME_PREFIX).values_list(
"name", flat=True
)

backend_numbers = [
int(name[len(LEGACY_BACKEND_NAME_PREFIX) :])
for name in names
if name[len(LEGACY_BACKEND_NAME_PREFIX) :].isdigit()
]
return max(backend_numbers, default=0)


class LegacyUpstreamToBackendSynchronizer:
def __init__(self, gateway: Gateway, resource_data_list: List[ResourceData], username: str):
self.gateway = gateway
self.resource_data_list = resource_data_list
self.username = username

def sync_backends_and_replace_resource_backend(self):
if not self._has_legacy_upstreams():
return

self._sync_backends_and_replace_resource_backend()

def _has_legacy_upstreams(self) -> bool:
return any(resource_data.backend_config.legacy_upstreams for resource_data in self.resource_data_list)

def _sync_backends_and_replace_resource_backend(self):
backend_creator = LegacyBackendCreator(self.gateway, self.username)
stages = list(Stage.objects.filter(gateway=self.gateway))
stage_id_to_timeout = self._get_stage_id_to_default_timeout()

for resource_data in self.resource_data_list:
if not resource_data.backend_config.legacy_upstreams:
continue

legacy_upstream = LegacyUpstream(resource_data.backend_config.legacy_upstreams)
stage_id_to_backend_config = legacy_upstream.get_stage_id_to_backend_config(stages, stage_id_to_timeout)
backend = backend_creator.match_or_create_backend(stage_id_to_backend_config)
resource_data.backend = backend

def _get_stage_id_to_default_timeout(self) -> Dict[int, int]:
return {
backend_config.stage_id: backend_config.config["timeout"]
for backend_config in BackendConfig.objects.filter(
gateway=self.gateway,
backend__name=DEFAULT_BACKEND_NAME,
)
}


class LegacyTransformHeadersToPluginSynchronizer:
def __init__(self, gateway: Gateway, resource_data_list: List[ResourceData], username: str):
self.gateway = gateway
self.resource_data_list = resource_data_list
self.username = username

def sync_plugins(self):
if not self._has_legacy_transform_headers():
return

alex-smile marked this conversation as resolved.
Show resolved Hide resolved
scope_id_to_plugin_config = {}
for resource_data in self.resource_data_list:
transform_headers = resource_data.backend_config.legacy_transform_headers
if transform_headers is None:
continue

assert resource_data.resource

plugin_config = HeaderRewriteConvertor.transform_headers_to_plugin_config(transform_headers)
scope_id_to_plugin_config[resource_data.resource.id] = plugin_config

HeaderRewriteConvertor.sync_plugins(
gateway_id=self.gateway.id,
scope_type=PluginBindingScopeEnum.RESOURCE.value,
scope_id_to_plugin_config=scope_id_to_plugin_config,
username=self.username,
)

def _has_legacy_transform_headers(self) -> bool:
return any(
resource_data.backend_config.legacy_transform_headers is not None
for resource_data in self.resource_data_list
)
Loading