diff --git a/apps/node_man/management/commands/generate_swagger_apigw.py b/apps/node_man/management/commands/generate_swagger_apigw.py new file mode 100644 index 000000000..7bd701a5c --- /dev/null +++ b/apps/node_man/management/commands/generate_swagger_apigw.py @@ -0,0 +1,352 @@ +# coding: utf-8 +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-节点管理(BlueKing-BK-NODEMAN) available. +Copyright (C) 2017-2022 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 https://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. +""" +import copy +import logging +import os + +from coreapi.compat import force_bytes, urlparse +from django.contrib.auth import get_user_model +from django.core.exceptions import ImproperlyConfigured +from django.core.management.base import BaseCommand +from django.urls import get_script_prefix +from drf_yasg import openapi +from drf_yasg.app_settings import swagger_settings +from drf_yasg.codecs import VALIDATORS, OpenAPICodecJson, OpenAPICodecYaml +from drf_yasg.errors import SwaggerValidationError +from drf_yasg.generators import OpenAPISchemaGenerator +from drf_yasg.utils import get_consumes, get_produces +from rest_framework.settings import api_settings +from rest_framework.test import APIRequestFactory, force_authenticate +from rest_framework.views import APIView + + +class ApigwOpenAPICodecYaml(OpenAPICodecYaml): + def encode(self, document): + + spec = self.generate_swagger_object(document) + errors = {} + for validator in self.validators: + try: + VALIDATORS[validator](copy.deepcopy(spec)) + except SwaggerValidationError as e: + errors[validator] = str(e) + + if errors: + raise SwaggerValidationError("spec validation failed: {}".format(errors), errors, spec, self) + + return force_bytes(self._dump_dict(spec)) + + +class PathItem(openapi.PathItem): + def __init__(self, get=None, put=None, post=None, delete=None, options=None, head=None, patch=None, **extra): + super().__init__(**extra) + self.get = get + self.head = head + self.post = post + self.put = put + self.patch = patch + self.delete = delete + self.options = options + self._insert_extras__() + + +class ApigwSwagger(openapi.SwaggerDict): + def __init__( + self, + info=None, + _url=None, + _prefix=None, + _version=None, + consumes=None, + produces=None, + security_definitions=None, + security=None, + paths=None, + definitions=None, + **extra + ): + """Root Swagger object.""" + super(ApigwSwagger, self).__init__(**extra) + self.swagger = "2.0" + self.info = info + self.info.version = _version or info._default_version + + if _url: + url = urlparse.urlparse(_url) + assert url.netloc and url.scheme, "if given, url must have both schema and netloc" + self.host = url.netloc + self.schemes = [url.scheme] + + self.base_path = self.get_base_path(get_script_prefix(), _prefix) + self.paths = paths + self._insert_extras__() + + @classmethod + def get_base_path(cls, script_prefix, api_prefix): + # avoid double slash when joining script_name with api_prefix + if script_prefix and script_prefix.endswith("/"): + script_prefix = script_prefix[:-1] + if not api_prefix.startswith("/"): + api_prefix = "/" + api_prefix + + base_path = script_prefix + api_prefix + + # ensure that the base path has a leading slash and no trailing slash + if base_path and base_path.endswith("/"): + base_path = base_path[:-1] + if not base_path.startswith("/"): + base_path = "/" + base_path + + return base_path + + +class ApigwOpenAPISchemaGenerator(OpenAPISchemaGenerator): + def get_schema(self, request=None, public=False): + """Generate a :class:`.Swagger` object representing the API schema.""" + endpoints = self.get_endpoints(request) + components = self.reference_resolver_class(openapi.SCHEMA_DEFINITIONS, force_init=True) + self.consumes = get_consumes(api_settings.DEFAULT_PARSER_CLASSES) + self.produces = get_produces(api_settings.DEFAULT_RENDERER_CLASSES) + paths, prefix = self.get_paths(endpoints, components, request, public) + + security_definitions = self.get_security_definitions() + if security_definitions: + security_requirements = self.get_security_requirements(security_definitions) + else: + security_requirements = None + + url = self.url + if url is None and request is not None: + url = request.build_absolute_uri() + + return ApigwSwagger( + info=self.info, + paths=paths, + consumes=self.consumes or None, + produces=self.produces or None, + security_definitions=security_definitions, + security=security_requirements, + _url=url, + _prefix=prefix, + _version=self.version, + **dict(components) + ) + + def get_path_item(self, path, view_cls, operations): + return PathItem(**operations) + + def get_operation(self, view, path, prefix, method, components, request): + """Get an :class:`.Operation` for the given API endpoint (path, method). This method delegates to""" + overrides = self.get_overrides(view, method) + if not overrides.get("extra_overrides", {}).get("is_register_apigw", False): + return None + + operation = super().get_operation(view, path, prefix, method, components, request) + + apigw_operation = { + "operationId": operation["operationId"], + "summary": operation["summary"], + "description": operation["description"], + "tags": operation["tags"], + "x-bk-apigateway-resource": { + "isPublic": True, + "allowApplyPermission": True, + "matchSubpath": False, + "backend": { + "name": "default", + "method": method.lower(), + "path": path.lstrip("/"), + "matchSubpath": False, + "timeout": 0, + }, + "authConfig": { + "userVerifiedRequired": True, + "appVerifiedRequired": True, + "resourcePermissionRequired": True, + }, + }, + } + + return apigw_operation + + +class Command(BaseCommand): + help = "Write the Swagger schema to disk in JSON or YAML format." + + def add_arguments(self, parser): + parser.add_argument( + "output_file", + metavar="output-file", + nargs="?", + default="-", + type=str, + help='Output path for generated swagger document, or "-" for stdout.', + ) + parser.add_argument( + "-o", + "--overwrite", + default=False, + action="store_true", + help="Overwrite the output file if it already exists. " + "Default behavior is to stop if the output file exists.", + ) + parser.add_argument( + "-f", + "--format", + dest="format", + default="", + choices=["json", "yaml"], + type=str, + help="Output format. If not given, it is guessed from the output file extension and defaults to json.", + ) + parser.add_argument( + "-u", + "--url", + dest="api_url", + default="", + type=str, + help="Base API URL - sets the host and scheme attributes of the generated document.", + ) + parser.add_argument( + "-m", + "--mock-request", + dest="mock", + default=False, + action="store_true", + help="Use a mock request when generating the swagger schema. This is useful if your views or serializers " + "depend on context from a request in order to function.", + ) + parser.add_argument( + "--api-version", + dest="api_version", + type=str, + help="Version to use to generate schema. This option implies --mock-request.", + ) + parser.add_argument( + "--user", + dest="user", + help="Username of an existing user to use for mocked authentication. This option implies --mock-request.", + ) + parser.add_argument( + "-p", + "--private", + default=False, + action="store_true", + help="Hides endpoints not accesible to the target user. If --user is not given, only shows endpoints that " + "are accesible to unauthenticated users.\n" + "This has the same effect as passing public=False to get_schema_view() or " + "OpenAPISchemaGenerator.get_schema().\n" + "This option implies --mock-request.", + ) + parser.add_argument( + "-g", + "--generator-class", + dest="generator_class_name", + default="", + help="Import string pointing to an OpenAPISchemaGenerator subclass to use for schema generation.", + ) + + def write_schema(self, schema, stream, format): + if format == "json": + codec = OpenAPICodecJson(validators=[], pretty=True) + swagger_json = codec.encode(schema).decode("utf-8") + stream.write(swagger_json) + elif format == "yaml": + codec = ApigwOpenAPICodecYaml(validators=[]) + swagger_yaml = codec.encode(schema).decode("utf-8") + # YAML is already pretty! + stream.write(swagger_yaml) + else: # pragma: no cover + raise ValueError("unknown format %s" % format) + + def get_mock_request(self, url, format, user=None): + factory = APIRequestFactory() + + request = factory.get(url + "/swagger." + format) + if user is not None: + force_authenticate(request, user=user) + request = APIView().initialize_request(request) + return request + + def get_schema_generator(self, generator_class_name, api_info, api_version, api_url): + generator_class = ApigwOpenAPISchemaGenerator + + return generator_class( + info=api_info, + version=api_version, + url=api_url, + ) + + def get_schema(self, generator, request, public): + return generator.get_schema(request=request, public=public) + + def handle( + self, + output_file, + overwrite, + format, + api_url, + mock, + api_version, + user, + private, + generator_class_name, + *args, + **kwargs + ): + # disable logs of WARNING and below + logging.disable(logging.WARNING) + + info = getattr(swagger_settings, "DEFAULT_INFO", None) + if not isinstance(info, openapi.Info): + raise ImproperlyConfigured( + 'settings.SWAGGER_SETTINGS["DEFAULT_INFO"] should be an ' + "import string pointing to an openapi.Info object" + ) + + if not format: + if os.path.splitext(output_file)[1] in (".yml", ".yaml"): + format = "yaml" + format = format or "json" + + api_url = api_url or swagger_settings.DEFAULT_API_URL + + if user: + # Only call get_user_model if --user was passed in order to + # avoid crashing if auth is not configured in the project + user = get_user_model().objects.get(**{get_user_model().USERNAME_FIELD: user}) + + mock = mock or private or (user is not None) or (api_version is not None) + if mock and not api_url: + raise ImproperlyConfigured( + "--mock-request requires an API url; either provide " + "the --url argument or set the DEFAULT_API_URL setting" + ) + + request = None + if mock: + request = self.get_mock_request(api_url, format, user) + + api_version = api_version or api_settings.DEFAULT_VERSION + if request and api_version: + request.version = api_version + + generator = self.get_schema_generator(generator_class_name, info, api_version, api_url) + schema = self.get_schema(generator, request, not private) + + print(output_file) + + if output_file == "-": + self.write_schema(schema, self.stdout, format) + else: + flags = "w" if overwrite else "x" + with open(output_file, flags) as stream: + self.write_schema(schema, stream, format) diff --git a/apps/node_man/management/commands/sync_apigw.py b/apps/node_man/management/commands/sync_apigw.py new file mode 100644 index 000000000..43c2951a5 --- /dev/null +++ b/apps/node_man/management/commands/sync_apigw.py @@ -0,0 +1,39 @@ +# coding: utf-8 +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-节点管理(BlueKing-BK-NODEMAN) available. +Copyright (C) 2017-2022 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 https://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. +""" +from django.conf import settings +from django.core.management import call_command +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + def handle(self, *args, **kwargs): + if not settings.SYNC_APIGATEWAY_ENABLED: + return + + # 待同步网关名,需修改为实际网关名;直接指定网关名,则不需要配置 Django settings BK_APIGW_NAME + gateway_name = settings.BK_APIGW_NAME + + # 待同步网关、资源定义文件,需调整为实际的配置文件地址 + definition_path = "support-files/apigw/definition.yaml" + resources_path = "support-files/apigw/resources.yaml" + + call_command("sync_apigw_config", f"--api-name={gateway_name}", f"--file={definition_path}") + call_command("sync_apigw_stage", f"--api-name={gateway_name}", f"--file={definition_path}") + call_command("sync_apigw_resources", f"--api-name={gateway_name}", "--delete", f"--file={resources_path}") + # call_command("sync_resource_docs_by_archive", f"--api-name={gateway_name}", f"--file={definition_path}") + call_command( + "create_version_and_release_apigw", + f"--api-name={gateway_name}", + f"--file={definition_path}", + f"-s {settings.ENVIRONMENT}", + ) + call_command("grant_apigw_permissions", f"--api-name={gateway_name}", f"--file={definition_path}") + call_command("fetch_apigw_public_key", f"--api-name={gateway_name}") diff --git a/config/default.py b/config/default.py index 9634e0e0b..feca132a6 100644 --- a/config/default.py +++ b/config/default.py @@ -32,6 +32,8 @@ from .patchers import logging from .patchers.monitor_reporter import monitor_report_config +ENVIRONMENT = env.ENVIRONMENT + # =============================================================================== # 运行时,用于区分环境差异 # =============================================================================== @@ -320,6 +322,8 @@ BK_API_URL_TMPL = env.BK_API_URL_TMPL BK_DOMAIN = env.BK_DOMAIN +SYNC_APIGATEWAY_ENABLED = env.SYNC_APIGATEWAY_ENABLED + BK_NODEMAN_HOST = env.BK_NODEMAN_HOST # 节点管理后台外网域名,用于构造文件导入导出的API URL BK_NODEMAN_BACKEND_HOST = env.BK_NODEMAN_BACKEND_HOST @@ -368,6 +372,10 @@ # 敏感参数 SENSITIVE_PARAMS = ["app_code", "app_secret", "bk_app_code", "bk_app_secret", "auth_info"] +SWAGGER_SETTINGS = { + "DEFAULT_INFO": "urls.openapi_info", +} + # rest_framework REST_FRAMEWORK = { "DATETIME_FORMAT": "%Y-%m-%d %H:%M:%S", diff --git a/env/__init__.py b/env/__init__.py index f0cd3b853..fd8a44682 100644 --- a/env/__init__.py +++ b/env/__init__.py @@ -70,6 +70,7 @@ # 自动选择安装通道相关配置 "BKAPP_DEFAULT_INSTALL_CHANNEL_ID", "BKAPP_AUTOMATIC_CHOICE_CLOUD_ID", + "SYNC_APIGATEWAY_ENABLED", ] # =============================================================================== @@ -200,3 +201,5 @@ BKPAAS_SHARED_RES_URL = get_type_env(key="BKPAAS_SHARED_RES_URL", default="", _type=str) BKAPP_LEGACY_AUTH = get_type_env(key="BKAPP_LEGACY_AUTH", default=False, _type=bool) BK_NOTICE_ENABLED = get_type_env(key="BK_NOTICE_ENABLED", default=False, _type=bool) + +SYNC_APIGATEWAY_ENABLED = get_type_env(key="SYNC_APIGATEWAY_ENABLED", default=True, _type=bool) diff --git a/support-files/apigw/definition.yaml b/support-files/apigw/definition.yaml new file mode 100644 index 000000000..1af216c21 --- /dev/null +++ b/support-files/apigw/definition.yaml @@ -0,0 +1,50 @@ +spec_version: 2 + +release: + # 发布版本号 + version: 1.0.0 + title: "API 初始化" + comment: "API 初始化" + +# 定义网关基本信息,用于命令 `sync_apigw_config` +apigateway: + description: "节点管理 API" + # 网关的英文描述,蓝鲸官方网关需提供英文描述,以支持国际化 + description_en: "NodeMan API" + is_public: true + api_type: 1 + maintainers: + - "admin" + +# 定义环境信息,用于命令 `sync_apigw_stage` +stage: + name: "{{ settings.ENVIRONMENT }}" +{% if settings.ENVIRONMENT == "prod" %} + description: "正式环境" + description_en: "Prod" +{% elif settings.ENVIRONMENT == "stag" %} + description: "预发布环境" + description_en: "Test" +{% else %} + description: "开发测试环境" + description_en: "Development" +{% endif %} + backends: + - name: "default" + config: + timeout: 180 + loadbalance: "roundrobin" + hosts: + # 网关调用后端服务的默认域名或IP,不包含Path,比如: http://api.example.com + - host: "{{ settings.BK_NODEMAN_BACKEND_HOST }}" + weight: 100 +grant_permissions: + - bk_app_code: "{{ settings.APP_CODE }}" + # grant_dimension: "gateway" + +resource_docs: + # 资源文档的归档文件,可为 tar.gz,zip 格式文件;创建归档文件可使用指令 `tar czvf xxx.tgz en zh` + # archivefile: "{{ settings.BK_APIGW_RESOURCE_DOCS_ARCHIVE_FILE }}" + # 资源文档目录,basedir 与 archivefile 二者至少一个有效,若同时存在,则 archivefile 优先 + # basedir: "{{ settings.BK_APIGW_RESOURCE_DOCS_BASE_DIR }}" + basedir: "support-files/apigw/apidocs" \ No newline at end of file diff --git a/support-files/kubernetes/helm/bk-nodeman/templates/configmaps/env-configmap.yaml b/support-files/kubernetes/helm/bk-nodeman/templates/configmaps/env-configmap.yaml index 648a257b7..dcd2d1e17 100644 --- a/support-files/kubernetes/helm/bk-nodeman/templates/configmaps/env-configmap.yaml +++ b/support-files/kubernetes/helm/bk-nodeman/templates/configmaps/env-configmap.yaml @@ -134,3 +134,4 @@ data: BKAPP_IEOD_ACTIVE_FIREWALL_POLICY_SCRIPT_INFO: '{{ .Values.config.bkAppIEODActiveFirewallPolicyScriptInfo }}' BKAPP_DEFAULT_INSTALL_CHANNEL_ID: "{{ .Values.config.bkAppDefaultInstallChannelId}}" BKAPP_AUTOMATIC_CHOICE_CLOUD_ID: "{{ .Values.config.bkAppAutomaticChoiceCloudId}}" + SYNC_APIGATEWAY_ENABLED: '{{ .Values.config.bkAppSyncApiGatewayEnabled }}' diff --git a/urls.py b/urls.py index 4c370edc3..6ec62995a 100644 --- a/urls.py +++ b/urls.py @@ -20,19 +20,6 @@ from version_log import config -schema_view = get_schema_view( - openapi.Info( - title="Bk Nodeman API", - default_version="v1", - description="节点管理", - terms_of_service="https://bk.tencent.com/info/#laws", - contact=openapi.Contact(email="contactus_bk@tencent.com"), - license=openapi.License(name="MIT License"), - ), - public=True, - permission_classes=(permissions.IsAdminUser,), -) - urlpatterns = [ url(r"^admin_nodeman/", admin.site.urls), url(r"^account/", include("blueapps.account.urls")), @@ -44,6 +31,21 @@ ] if settings.ENVIRONMENT not in ["production", "prod"]: + openapi_info = openapi.Info( + title="Bk Nodeman API", + default_version="v1", + description="节点管理", + terms_of_service="https://bk.tencent.com/info/#laws", + contact=openapi.Contact(email="contactus_bk@tencent.com"), + license=openapi.License(name="MIT License"), + ) + + schema_view = get_schema_view( + openapi_info, + public=True, + permission_classes=(permissions.IsAdminUser,), + ) + urlpatterns += [ re_path(r"^swagger(?P\.json|\.yaml)$", schema_view.without_ui(cache_timeout=0), name="schema-json"), re_path(r"^swagger/$", schema_view.with_ui("swagger", cache_timeout=0), name="schema-swagger-ui"),