From 412f2c0668e1bba04948ef48b759244aaf3a8697 Mon Sep 17 00:00:00 2001 From: tiga <68499559+SheepSheepChen@users.noreply.github.com> Date: Fri, 1 Nov 2024 14:58:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B2=99=E7=9B=92=E5=BC=80=E5=8F=91?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E7=8E=AF=E5=A2=83=20(#1657)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../paasng/paas_wl/bk_app/dev_sandbox/conf.py | 57 +++- .../paas_wl/bk_app/dev_sandbox/constants.py | 31 ++ .../paas_wl/bk_app/dev_sandbox/controller.py | 267 +++++++++++++++++- .../paas_wl/bk_app/dev_sandbox/entities.py | 50 ++++ .../dev_sandbox/kres_entities/__init__.py | 9 +- .../dev_sandbox/kres_entities/code_editor.py | 70 +++++ .../dev_sandbox/kres_entities/ingress.py | 17 +- .../dev_sandbox/kres_entities/sandbox.py | 38 ++- .../dev_sandbox/kres_entities/service.py | 36 ++- .../bk_app/dev_sandbox/kres_slzs/__init__.py | 15 +- .../dev_sandbox/kres_slzs/code_editor.py | 144 ++++++++++ .../bk_app/dev_sandbox/kres_slzs/sandbox.py | 66 ++++- .../bk_app/dev_sandbox/kres_slzs/service.py | 32 ++- .../paasng/accessories/dev_sandbox/apps.py | 22 ++ .../dev_sandbox/migrations/0001_initial.py | 51 ++++ .../dev_sandbox/migrations/__init__.py | 0 .../paasng/accessories/dev_sandbox/models.py | 75 +++++ .../accessories/dev_sandbox/serializers.py | 70 +++++ .../paasng/accessories/dev_sandbox/urls.py | 14 +- .../paasng/accessories/dev_sandbox/views.py | 221 ++++++++++++++- .../paasng/infras/accounts/constants.py | 2 + .../paasng/platform/engine/utils/source.py | 51 +++- apiserver/paasng/paasng/settings/__init__.py | 2 + apiserver/paasng/paasng/settings/workloads.py | 9 + .../paas_wl/bk_app/dev_sandbox/conftest.py | 72 ++++- .../bk_app/dev_sandbox/test_controller.py | 117 +++++++- .../bk_app/dev_sandbox/test_kres_slzs.py | 236 +++++++++++++++- 27 files changed, 1721 insertions(+), 53 deletions(-) create mode 100644 apiserver/paasng/paas_wl/bk_app/dev_sandbox/constants.py create mode 100644 apiserver/paasng/paas_wl/bk_app/dev_sandbox/kres_entities/code_editor.py create mode 100644 apiserver/paasng/paas_wl/bk_app/dev_sandbox/kres_slzs/code_editor.py create mode 100644 apiserver/paasng/paasng/accessories/dev_sandbox/apps.py create mode 100644 apiserver/paasng/paasng/accessories/dev_sandbox/migrations/0001_initial.py create mode 100644 apiserver/paasng/paasng/accessories/dev_sandbox/migrations/__init__.py create mode 100644 apiserver/paasng/paasng/accessories/dev_sandbox/models.py diff --git a/apiserver/paasng/paas_wl/bk_app/dev_sandbox/conf.py b/apiserver/paasng/paas_wl/bk_app/dev_sandbox/conf.py index 306fbfd8d1..1dd732df26 100644 --- a/apiserver/paasng/paas_wl/bk_app/dev_sandbox/conf.py +++ b/apiserver/paasng/paas_wl/bk_app/dev_sandbox/conf.py @@ -15,13 +15,13 @@ # 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. -from typing import List +from typing import Dict, List from django.conf import settings from .entities import IngressPathBackend, ServicePortPair -_ingress_service_conf = [ +_dev_sandbox_ingress_service_conf = [ # dev sandbox 中 devserver 的路径与端口映射 { "path_prefix": "/devserver/", @@ -30,23 +30,62 @@ "target_port": settings.DEV_SANDBOX_DEVSERVER_PORT, }, # dev sandbox 中 saas 应用的路径与端口映射 - {"path_prefix": "/", "service_port_name": "app", "port": 80, "target_port": settings.CONTAINER_PORT}, + {"path_prefix": "/app/", "service_port_name": "app", "port": 80, "target_port": settings.CONTAINER_PORT}, ] - DEV_SANDBOX_SVC_PORT_PAIRS: List[ServicePortPair] = [ ServicePortPair(name=conf["service_port_name"], port=conf["port"], target_port=conf["target_port"]) - for conf in _ingress_service_conf + for conf in _dev_sandbox_ingress_service_conf +] + +_code_editor_ingress_service_conf = [ + # code editor 的路径与端口映射 + { + "path_prefix": "/code-editor/", + "service_port_name": "code-editor", + "port": 10251, + "target_port": settings.CODE_EDITOR_PORT, + }, +] + +CODE_SVC_PORT_PAIRS: List[ServicePortPair] = [ + ServicePortPair(name=conf["service_port_name"], port=conf["port"], target_port=conf["target_port"]) + for conf in _code_editor_ingress_service_conf ] -def get_ingress_path_backends(service_name: str) -> List[IngressPathBackend]: - """get ingress path backends from _ingress_service_conf with service_name""" +def get_ingress_path_backends( + service_name: str, service_confs: List[Dict], dev_sandbox_code: str = "" +) -> List[IngressPathBackend]: + """get ingress path backends from service_confs with service_name and dev_sandbox_code + + :param service_name: Service name + :param dev_sandbox_code: dev sandbox code. + :param service_confs: service configs + """ + if not dev_sandbox_code: + return [ + IngressPathBackend( + path_prefix=conf["path_prefix"], + service_name=service_name, + service_port_name=conf["service_port_name"], + ) + for conf in service_confs + ] + return [ IngressPathBackend( - path_prefix=conf["path_prefix"], + path_prefix=f"/dev_sandbox/{dev_sandbox_code}{conf['path_prefix']}", service_name=service_name, service_port_name=conf["service_port_name"], ) - for conf in _ingress_service_conf + for conf in service_confs ] + + +def get_dev_sandbox_ingress_path_backends(service_name: str, dev_sandbox_code: str = "") -> List[IngressPathBackend]: + return get_ingress_path_backends(service_name, _dev_sandbox_ingress_service_conf, dev_sandbox_code) + + +def get_code_editor_ingress_path_backends(service_name: str, dev_sandbox_code: str = "") -> List[IngressPathBackend]: + return get_ingress_path_backends(service_name, _code_editor_ingress_service_conf, dev_sandbox_code) diff --git a/apiserver/paasng/paas_wl/bk_app/dev_sandbox/constants.py b/apiserver/paasng/paas_wl/bk_app/dev_sandbox/constants.py new file mode 100644 index 0000000000..be56a5190f --- /dev/null +++ b/apiserver/paasng/paas_wl/bk_app/dev_sandbox/constants.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# TencentBlueKing is pleased to support the open source community by making +# 蓝鲸智云 - PaaS 平台 (BlueKing - PaaS System) available. +# Copyright (C) 2017 THL A29 Limited, a Tencent company. All rights reserved. +# Licensed under the MIT License (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://opensource.org/licenses/MIT +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +# either express or implied. See the License for the specific language governing permissions and +# limitations under the License. +# +# We undertake not to change the open source license (MIT license) applicable +# to the current version of the project delivered to anyone in the future. + +from blue_krill.data_types.enum import EnumField, StrStructuredEnum + + +class SourceCodeFetchMethod(StrStructuredEnum): + HTTP = EnumField("HTTP") + GIT = EnumField("GIT") + BK_REPO = EnumField("BK_REPO") + + +class DevSandboxStatus(StrStructuredEnum): + """沙箱状态""" + + ACTIVE = EnumField("active", label="活跃") + ERROR = EnumField("error", label="错误") diff --git a/apiserver/paasng/paas_wl/bk_app/dev_sandbox/controller.py b/apiserver/paasng/paas_wl/bk_app/dev_sandbox/controller.py index db4dc2e621..e1fec55746 100644 --- a/apiserver/paasng/paas_wl/bk_app/dev_sandbox/controller.py +++ b/apiserver/paasng/paas_wl/bk_app/dev_sandbox/controller.py @@ -15,27 +15,48 @@ # 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. -from typing import Dict +import logging +from pathlib import Path +from typing import Dict, Optional from django.conf import settings from paas_wl.bk_app.applications.models import WlApp from paas_wl.bk_app.deploy.app_res.controllers import NamespacesHandler -from paas_wl.bk_app.dev_sandbox.entities import DevSandboxDetail, HealthPhase, Resources, ResourceSpec, Runtime +from paas_wl.bk_app.dev_sandbox.constants import SourceCodeFetchMethod +from paas_wl.bk_app.dev_sandbox.entities import ( + CodeEditorConfig, + DevSandboxDetail, + DevSandboxWithCodeEditorDetail, + DevSandboxWithCodeEditorUrls, + HealthPhase, + Resources, + ResourceSpec, + Runtime, + SourceCodeConfig, +) from paas_wl.bk_app.dev_sandbox.kres_entities import ( + CodeEditor, + CodeEditorService, DevSandbox, DevSandboxIngress, DevSandboxService, + get_code_editor_name, get_dev_sandbox_name, get_ingress_name, ) from paas_wl.infras.resources.kube_res.base import AppEntityManager from paas_wl.infras.resources.kube_res.exceptions import AppEntityNotFound +from paas_wl.workloads.volume.persistent_volume_claim.kres_entities import PersistentVolumeClaim, pvc_kmodel from paasng.platform.applications.models import Application +from paasng.platform.engine.utils.source import upload_source_code from paasng.platform.modules.constants import DEFAULT_ENGINE_APP_PREFIX, ModuleName +from paasng.platform.sourcectl.models import VersionInfo from .exceptions import DevSandboxAlreadyExists, DevSandboxResourceNotFound +logger = logging.getLogger(__name__) + class DevSandboxController: """DevSandbox Controller @@ -117,12 +138,229 @@ def _deploy(self, envs: Dict[str, str]): self.ingress_mgr.upsert(ingress_entity) +class DevSandboxWithCodeEditorController: + """DevSandboxWithCodeEditor Controller, + + 区别于 DevSandboxController 它提供了代码编辑器功能以及更方便的部署方式 + + :param app: Application 实例 + :param module_name: 模块名称 + :param dev_sandbox_code: 沙盒标识 + :param owner: 沙箱拥有者 + """ + + sandbox_mgr: AppEntityManager[DevSandbox] = AppEntityManager[DevSandbox](DevSandbox) + dev_sandbox_svc_mgr: AppEntityManager[DevSandboxService] = AppEntityManager[DevSandboxService](DevSandboxService) + ingress_mgr: AppEntityManager[DevSandboxIngress] = AppEntityManager[DevSandboxIngress](DevSandboxIngress) + code_editor_mgr: AppEntityManager[CodeEditor] = AppEntityManager[CodeEditor](CodeEditor) + code_editor_svc_mgr: AppEntityManager[CodeEditorService] = AppEntityManager[CodeEditorService](CodeEditorService) + + def __init__(self, app: Application, module_name: str, dev_sandbox_code: str, owner: str): + self.app = app + self.module_name = module_name + self.dev_wl_app: WlApp = _DevWlAppCreator(app, module_name, dev_sandbox_code).create() + self.dev_sandbox_code = dev_sandbox_code + self.owner = owner + + def deploy( + self, + dev_sandbox_env_vars: Dict[str, str], + code_editor_env_vars: Dict[str, str], + version_info: VersionInfo, + relative_source_dir: Path, + password: str, + ): + """部署 dev sandbox with code editor + + :param dev_sandbox_env_vars: 启动开发沙箱所需要的环境变量 + :param code_editor_env_vars: 启动代码编辑器所需要的环境变量 + :param version_info: 版本信息 + :param relative_source_dir: 代码目录相对路径 + :param password: 部署密码 + """ + sandbox_name = get_dev_sandbox_name(self.dev_wl_app) + code_editor_name = get_code_editor_name(self.dev_wl_app) + + sandbox_exists = True + code_editor_exists = True + + try: + self.sandbox_mgr.get(self.dev_wl_app, sandbox_name) + except AppEntityNotFound: + sandbox_exists = False + + try: + self.code_editor_mgr.get(self.dev_wl_app, code_editor_name) + except AppEntityNotFound: + code_editor_exists = False + + if sandbox_exists and code_editor_exists: + # 如果 sandbox 和 code editor 存在,则不重复创建 + raise DevSandboxAlreadyExists( + f"dev sandbox {sandbox_name} and code editor {code_editor_name} already exists" + ) + elif not sandbox_exists and not code_editor_exists: + # 如果 sandbox 和 code editor 都不存在,则直接部署 + self._deploy(dev_sandbox_env_vars, code_editor_env_vars, version_info, relative_source_dir, password) + elif sandbox_exists or code_editor_exists: + # 如果 sandbox, code editor 存在一个,则先删除全部资源再部署 + self.delete() + self._deploy(dev_sandbox_env_vars, code_editor_env_vars, version_info, relative_source_dir, password) + + def _deploy( + self, + dev_sandbox_env_vars: Dict[str, str], + code_editor_env_vars: Dict[str, str], + version_info: VersionInfo, + relative_source_dir: Path, + password: str, + ): + """部署 sandbox 服务""" + # step 1. ensure namespace + ns_handler = NamespacesHandler.new_by_app(self.dev_wl_app) + ns_handler.ensure_namespace(namespace=self.dev_wl_app.namespace) + + # step 2. create storage + pvc_kmodel.upsert( + PersistentVolumeClaim( + app=self.dev_wl_app, + name=get_pvc_name(self.dev_wl_app), + storage="1Gi", + storage_class_name=settings.DEFAULT_PERSISTENT_STORAGE_CLASS_NAME, + ) + ) + + # step 3. create dev sandbox + self._create_dev_sandbox(dev_sandbox_env_vars, version_info, relative_source_dir) + + # step 4. create code editor + self._create_code_editor(code_editor_env_vars, password) + + # step 5. upsert service + dev_sandbox_svc_entity = DevSandboxService.create(self.dev_wl_app) + self.dev_sandbox_svc_mgr.upsert(dev_sandbox_svc_entity) + code_editor_svc_entity = CodeEditorService.create(self.dev_wl_app) + self.code_editor_svc_mgr.upsert(code_editor_svc_entity) + + # step 6. upsert ingress + ingress_entity = DevSandboxIngress.create(self.dev_wl_app, self.app.code, self.dev_sandbox_code) + self.ingress_mgr.upsert(ingress_entity) + + def _create_dev_sandbox( + self, dev_sandbox_env_vars: Dict[str, str], version_info: VersionInfo, relative_source_dir: Path + ): + # upload source code + module = self.app.get_module(self.module_name) + source_fetch_url = upload_source_code( + module, version_info, relative_source_dir, self.owner, self.dev_wl_app.region + ) + + # create dev sandbox + default_sandbox_resources = Resources( + limits=ResourceSpec(cpu="4", memory="2Gi"), + requests=ResourceSpec(cpu="200m", memory="512Mi"), + ) + + source_code_config = SourceCodeConfig( + pvc_claim_name=get_pvc_name(self.dev_wl_app), + workspace=settings.DEV_SANDBOX_WORKSPACE, + source_fetch_url=source_fetch_url, + source_fetch_method=SourceCodeFetchMethod.BK_REPO, + ) + + sandbox_entity = DevSandbox.create( + self.dev_wl_app, + runtime=Runtime(envs=dev_sandbox_env_vars, image=settings.DEV_SANDBOX_IMAGE), + resources=default_sandbox_resources, + source_code_config=source_code_config, + ) + sandbox_entity.construct_envs() + self.sandbox_mgr.create(sandbox_entity) + + def _create_code_editor(self, code_editor_env_vars: Dict[str, str], password: str): + default_code_editor_resources = Resources( + limits=ResourceSpec(cpu="4", memory="2Gi"), + requests=ResourceSpec(cpu="500m", memory="1024Mi"), + ) + + code_editor_config = CodeEditorConfig( + pvc_claim_name=get_pvc_name(self.dev_wl_app), start_dir=settings.CODE_EDITOR_START_DIR, password=password + ) + + code_editor_entity = CodeEditor.create( + self.dev_wl_app, + runtime=Runtime(envs=code_editor_env_vars, image=settings.CODE_EDITOR_IMAGE), + resources=default_code_editor_resources, + config=code_editor_config, + ) + code_editor_entity.construct_envs() + self.code_editor_mgr.create(code_editor_entity) + + def delete(self): + """通过直接删除命名空间的方式, 销毁 dev sandbox 服务""" + ns_handler = NamespacesHandler.new_by_app(self.dev_wl_app) + ns_handler.delete(namespace=self.dev_wl_app.namespace) + + def _get_url(self) -> str: + """获取 dev sandbox url""" + try: + ingress_entity: DevSandboxIngress = self.ingress_mgr.get( + self.dev_wl_app, get_ingress_name(self.dev_wl_app) + ) + except AppEntityNotFound: + raise DevSandboxResourceNotFound("dev sandbox url not found") + + return ingress_entity.domains[0].host + + def get_detail(self) -> DevSandboxWithCodeEditorDetail: + """ + 获取详情 + raises: DevSandboxResourceNotFound: 如果开发沙箱资源(dev_sandbox 或者 code_editor)未找到。 + """ + try: + dev_sandbox_entity: DevSandbox = self.sandbox_mgr.get( + self.dev_wl_app, get_dev_sandbox_name(self.dev_wl_app) + ) + except AppEntityNotFound: + raise DevSandboxResourceNotFound("dev sandbox not found") + + try: + code_editor_entity: CodeEditor = self.code_editor_mgr.get( + self.dev_wl_app, get_code_editor_name(self.dev_wl_app) + ) + except AppEntityNotFound: + raise DevSandboxResourceNotFound("code editor not found") + + base_url = self._get_url() + urls = DevSandboxWithCodeEditorUrls(base_url=base_url, dev_sandbox_code=self.dev_sandbox_code) + + dev_sandbox_status = ( + dev_sandbox_entity.status.to_health_phase() if dev_sandbox_entity.status else HealthPhase.UNKNOWN + ) + code_editor_status = ( + code_editor_entity.status.to_health_phase() if code_editor_entity.status else HealthPhase.UNKNOWN + ) + return DevSandboxWithCodeEditorDetail( + dev_sandbox_env_vars=dev_sandbox_entity.runtime.envs, + code_editor_env_vars=code_editor_entity.runtime.envs, + dev_sandbox_status=dev_sandbox_status, + code_editor_status=code_editor_status, + urls=urls, + ) + + class _DevWlAppCreator: - """WlApp 实例构造器""" + """WlApp 实例构造器 - def __init__(self, app: Application, module_name: str): + :param app: 应用 + :param module_name: 模块名称 + :param dev_sandbox_code: 沙箱标识,在模块下沙箱不唯一时传入 + """ + + def __init__(self, app: Application, module_name: str, dev_sandbox_code: Optional[str] = None): self.app = app self.module_name = module_name + self.dev_sandbox_code = dev_sandbox_code def create(self) -> WlApp: """创建 WlApp 实例""" @@ -130,7 +368,8 @@ def create(self) -> WlApp: # 因为 dev_wl_app 不是查询集结果, 所以需要覆盖 namespace 和 module_name, 以保证 AppEntityManager 模式能够正常工作 # TODO 考虑更规范的方式处理这两个 cached_property 属性. 如考虑使用 WlAppProtocol 满足 AppEntityManager 模式 - setattr(dev_wl_app, "namespace", f"{DEFAULT_ENGINE_APP_PREFIX}-{self.app.code}-dev".replace("_", "0us0")) + namespace_name = self._make_namespace_name() + setattr(dev_wl_app, "namespace", namespace_name.replace("_", "0us0")) setattr(dev_wl_app, "module_name", self.module_name) return dev_wl_app @@ -138,6 +377,22 @@ def create(self) -> WlApp: def _make_dev_wl_app_name(self) -> str: """参考 make_engine_app_name 规则, 生成 dev 环境的 WlApp name""" if self.module_name == ModuleName.DEFAULT.value: - return f"{DEFAULT_ENGINE_APP_PREFIX}-{self.app.code}-dev" + if self.dev_sandbox_code: + return f"{DEFAULT_ENGINE_APP_PREFIX}-{self.app.code}-{self.dev_sandbox_code}-dev" + else: + return f"{DEFAULT_ENGINE_APP_PREFIX}-{self.app.code}-dev" + elif self.dev_sandbox_code: + return f"{DEFAULT_ENGINE_APP_PREFIX}-{self.app.code}-m-{self.module_name}-{self.dev_sandbox_code}-dev" else: return f"{DEFAULT_ENGINE_APP_PREFIX}-{self.app.code}-m-{self.module_name}-dev" + + def _make_namespace_name(self) -> str: + """生成 namespace_name""" + if self.dev_sandbox_code: + return f"{DEFAULT_ENGINE_APP_PREFIX}-{self.app.code}-{self.dev_sandbox_code}-dev" + else: + return f"{DEFAULT_ENGINE_APP_PREFIX}-{self.app.code}-dev" + + +def get_pvc_name(dev_wl_app: WlApp) -> str: + return f"{dev_wl_app.scheduler_safe_name}-pvc" diff --git a/apiserver/paasng/paas_wl/bk_app/dev_sandbox/entities.py b/apiserver/paasng/paas_wl/bk_app/dev_sandbox/entities.py index 88a52b4fee..00b0077181 100644 --- a/apiserver/paasng/paas_wl/bk_app/dev_sandbox/entities.py +++ b/apiserver/paasng/paas_wl/bk_app/dev_sandbox/entities.py @@ -19,7 +19,9 @@ from typing import Dict, List, Optional from blue_krill.data_types.enum import EnumField, StrStructuredEnum +from django.conf import settings +from paas_wl.bk_app.dev_sandbox.constants import SourceCodeFetchMethod from paas_wl.workloads.release_controller.constants import ImagePullPolicy @@ -115,3 +117,51 @@ class DevSandboxDetail: url: str envs: Dict[str, str] status: str + + +class DevSandboxWithCodeEditorUrls: + app_url: str + devserver_url: str + code_editor_url: str + + def __init__(self, base_url: str, dev_sandbox_code: str): + self.app_url = f"{base_url}/dev_sandbox/{dev_sandbox_code}/app/" + self.devserver_url = f"{base_url}/dev_sandbox/{dev_sandbox_code}/devserver/" + self.code_editor_url = ( + f"{base_url}/dev_sandbox/{dev_sandbox_code}/code-editor/?folder={settings.CODE_EDITOR_START_DIR}" + ) + + +@dataclass +class DevSandboxWithCodeEditorDetail: + dev_sandbox_env_vars: Dict[str, str] + code_editor_env_vars: Dict[str, str] + dev_sandbox_status: str + code_editor_status: str + urls: Optional[DevSandboxWithCodeEditorUrls] = None + + +@dataclass +class SourceCodeConfig: + """源码持久化相关配置""" + + # 源码持久化用的 pvc 名称 + pvc_claim_name: Optional[str] = None + # 工作空间,用于读取/存储源码 + workspace: Optional[str] = None + # 源码获取地址 + source_fetch_url: Optional[str] = None + # 源码获取方式 + source_fetch_method: SourceCodeFetchMethod = SourceCodeFetchMethod.HTTP + + +@dataclass +class CodeEditorConfig: + """代码编辑器相关配置""" + + # 源码持久化用的 pvc 名称 + pvc_claim_name: Optional[str] = None + # 项目目录, 读取项目源码的起始目录 + start_dir: Optional[str] = None + # 登陆密码 + password: Optional[str] = None diff --git a/apiserver/paasng/paas_wl/bk_app/dev_sandbox/kres_entities/__init__.py b/apiserver/paasng/paas_wl/bk_app/dev_sandbox/kres_entities/__init__.py index 11024951b9..ef44d88ea2 100644 --- a/apiserver/paasng/paas_wl/bk_app/dev_sandbox/kres_entities/__init__.py +++ b/apiserver/paasng/paas_wl/bk_app/dev_sandbox/kres_entities/__init__.py @@ -15,16 +15,21 @@ # 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. +from .code_editor import CodeEditor, get_code_editor_name from .ingress import DevSandboxIngress, get_ingress_name, get_sub_domain_host from .sandbox import DevSandbox, get_dev_sandbox_name -from .service import DevSandboxService, get_service_name +from .service import CodeEditorService, DevSandboxService, get_code_editor_service_name, get_dev_sandbox_service_name __all__ = [ + "CodeEditor", "DevSandboxIngress", "DevSandbox", "DevSandboxService", + "CodeEditorService", + "get_code_editor_name", "get_ingress_name", "get_sub_domain_host", "get_dev_sandbox_name", - "get_service_name", + "get_dev_sandbox_service_name", + "get_code_editor_service_name", ] diff --git a/apiserver/paasng/paas_wl/bk_app/dev_sandbox/kres_entities/code_editor.py b/apiserver/paasng/paas_wl/bk_app/dev_sandbox/kres_entities/code_editor.py new file mode 100644 index 0000000000..2851b29089 --- /dev/null +++ b/apiserver/paasng/paas_wl/bk_app/dev_sandbox/kres_entities/code_editor.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +# TencentBlueKing is pleased to support the open source community by making +# 蓝鲸智云 - PaaS 平台 (BlueKing - PaaS System) available. +# Copyright (C) 2017 THL A29 Limited, a Tencent company. All rights reserved. +# Licensed under the MIT License (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://opensource.org/licenses/MIT +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +# either express or implied. See the License for the specific language governing permissions and +# limitations under the License. +# +# We undertake not to change the open source license (MIT license) applicable +# to the current version of the project delivered to anyone in the future. + +from dataclasses import dataclass +from typing import Optional + +from paas_wl.bk_app.applications.models import WlApp +from paas_wl.bk_app.dev_sandbox.entities import CodeEditorConfig, Resources, Runtime, Status +from paas_wl.bk_app.dev_sandbox.kres_slzs import CodeEditorDeserializer, CodeEditorSerializer +from paas_wl.infras.resources.base import kres +from paas_wl.infras.resources.kube_res.base import AppEntity + + +@dataclass +class CodeEditor(AppEntity): + """CodeEditor entity""" + + runtime: Runtime + resources: Optional[Resources] = None + # 部署后, 从集群中获取状态 + status: Optional[Status] = None + # 编辑器相关配置 + config: Optional[CodeEditorConfig] = None + + class Meta: + kres_class = kres.KDeployment + serializer = CodeEditorSerializer + deserializer = CodeEditorDeserializer + + @classmethod + def create( + cls, dev_wl_app: WlApp, runtime: Runtime, config: CodeEditorConfig, resources: Optional[Resources] = None + ) -> "CodeEditor": + return cls( + app=dev_wl_app, name=get_code_editor_name(dev_wl_app), config=config, runtime=runtime, resources=resources + ) + + def construct_envs(self): + """该函数将 CodeEditor 对象的属性(需要通过环境变量生效的配置)注入环境变量""" + if not self.config: + return + + envs = self.runtime.envs + + def update_env_var(key, value): + if value: + envs.update({key: value}) + + # 注入登陆密码环境变量 + update_env_var("PASSWORD", self.config.password) + # 注入启动目录环境变量 + update_env_var("START_DIR", self.config.start_dir) + + +def get_code_editor_name(dev_wl_app: WlApp) -> str: + return f"{dev_wl_app.scheduler_safe_name}-code-editor" diff --git a/apiserver/paasng/paas_wl/bk_app/dev_sandbox/kres_entities/ingress.py b/apiserver/paasng/paas_wl/bk_app/dev_sandbox/kres_entities/ingress.py index cb2f4af5a8..0db81d08b9 100644 --- a/apiserver/paasng/paas_wl/bk_app/dev_sandbox/kres_entities/ingress.py +++ b/apiserver/paasng/paas_wl/bk_app/dev_sandbox/kres_entities/ingress.py @@ -19,7 +19,10 @@ from typing import List from paas_wl.bk_app.applications.models import WlApp -from paas_wl.bk_app.dev_sandbox.conf import get_ingress_path_backends +from paas_wl.bk_app.dev_sandbox.conf import ( + get_code_editor_ingress_path_backends, + get_dev_sandbox_ingress_path_backends, +) from paas_wl.bk_app.dev_sandbox.entities import IngressDomain from paas_wl.bk_app.dev_sandbox.kres_slzs import DevSandboxIngressDeserializer, DevSandboxIngressSerializer from paas_wl.infras.cluster.utils import get_dev_sandbox_cluster @@ -27,7 +30,7 @@ from paas_wl.infras.resources.kube_res.base import AppEntity from paas_wl.workloads.networking.entrance.utils import to_dns_safe -from .service import get_service_name +from .service import get_code_editor_service_name, get_dev_sandbox_service_name @dataclass @@ -46,11 +49,15 @@ def __post_init__(self): self.set_header_x_script_name = True @classmethod - def create(cls, dev_wl_app: WlApp, app_code: str) -> "DevSandboxIngress": - service_name = get_service_name(dev_wl_app) + def create(cls, dev_wl_app: WlApp, app_code: str, dev_sandbox_code: str = "") -> "DevSandboxIngress": + dev_sandbox_svc_name = get_dev_sandbox_service_name(dev_wl_app) + code_editor_svc_name = get_code_editor_service_name(dev_wl_app) + path_backends = get_dev_sandbox_ingress_path_backends( + dev_sandbox_svc_name, dev_sandbox_code + ) + get_code_editor_ingress_path_backends(code_editor_svc_name, dev_sandbox_code) sub_domain = IngressDomain( host=get_sub_domain_host(app_code, dev_wl_app, dev_wl_app.module_name), - path_backends=get_ingress_path_backends(service_name), + path_backends=path_backends, ) return cls(app=dev_wl_app, name=get_ingress_name(dev_wl_app), domains=[sub_domain]) diff --git a/apiserver/paasng/paas_wl/bk_app/dev_sandbox/kres_entities/sandbox.py b/apiserver/paasng/paas_wl/bk_app/dev_sandbox/kres_entities/sandbox.py index e27552a799..68f3a33f87 100644 --- a/apiserver/paasng/paas_wl/bk_app/dev_sandbox/kres_entities/sandbox.py +++ b/apiserver/paasng/paas_wl/bk_app/dev_sandbox/kres_entities/sandbox.py @@ -19,7 +19,7 @@ from typing import Optional from paas_wl.bk_app.applications.models import WlApp -from paas_wl.bk_app.dev_sandbox.entities import Resources, Runtime, Status +from paas_wl.bk_app.dev_sandbox.entities import Resources, Runtime, SourceCodeConfig, Status from paas_wl.bk_app.dev_sandbox.kres_slzs import DevSandboxDeserializer, DevSandboxSerializer from paas_wl.infras.resources.base import kres from paas_wl.infras.resources.kube_res.base import AppEntity @@ -33,6 +33,8 @@ class DevSandbox(AppEntity): resources: Optional[Resources] = None # 部署后, 从集群中获取状态 status: Optional[Status] = None + # 源码相关配置 + source_code_config: Optional[SourceCodeConfig] = None class Meta: kres_class = kres.KDeployment @@ -40,8 +42,38 @@ class Meta: deserializer = DevSandboxDeserializer @classmethod - def create(cls, dev_wl_app: WlApp, runtime: Runtime, resources: Optional[Resources] = None) -> "DevSandbox": - return cls(app=dev_wl_app, name=get_dev_sandbox_name(dev_wl_app), runtime=runtime, resources=resources) + def create( + cls, + dev_wl_app: WlApp, + runtime: Runtime, + resources: Optional[Resources] = None, + source_code_config: Optional[SourceCodeConfig] = None, + ) -> "DevSandbox": + return cls( + app=dev_wl_app, + name=get_dev_sandbox_name(dev_wl_app), + runtime=runtime, + resources=resources, + source_code_config=source_code_config, + ) + + def construct_envs(self): + """该函数将 DevSandbox 对象的属性(需要通过环境变量生效的配置)注入环境变量""" + if not self.source_code_config: + return + + envs = self.runtime.envs + + def update_env_var(key, value): + if value: + envs.update({key: value}) + + # 注入源码获取方式环境变量 + update_env_var("SOURCE_FETCH_METHOD", self.source_code_config.source_fetch_method.value) + # 注入源码获取地址 + update_env_var("SOURCE_FETCH_URL", self.source_code_config.source_fetch_url) + # 注入工作空间环境变量 + update_env_var("WORKSPACE", self.source_code_config.workspace) def get_dev_sandbox_name(dev_wl_app: WlApp) -> str: diff --git a/apiserver/paasng/paas_wl/bk_app/dev_sandbox/kres_entities/service.py b/apiserver/paasng/paas_wl/bk_app/dev_sandbox/kres_entities/service.py index 09df1d150b..1540e8d114 100644 --- a/apiserver/paasng/paas_wl/bk_app/dev_sandbox/kres_entities/service.py +++ b/apiserver/paasng/paas_wl/bk_app/dev_sandbox/kres_entities/service.py @@ -19,9 +19,14 @@ from typing import List from paas_wl.bk_app.applications.models import WlApp -from paas_wl.bk_app.dev_sandbox.conf import DEV_SANDBOX_SVC_PORT_PAIRS +from paas_wl.bk_app.dev_sandbox.conf import CODE_SVC_PORT_PAIRS, DEV_SANDBOX_SVC_PORT_PAIRS from paas_wl.bk_app.dev_sandbox.entities import ServicePortPair -from paas_wl.bk_app.dev_sandbox.kres_slzs import DevSandboxServiceDeserializer, DevSandboxServiceSerializer +from paas_wl.bk_app.dev_sandbox.kres_slzs import ( + CodeEditorServiceDeserializer, + CodeEditorServiceSerializer, + DevSandboxServiceDeserializer, + DevSandboxServiceSerializer, +) from paas_wl.infras.resources.base import kres from paas_wl.infras.resources.kube_res.base import AppEntity @@ -40,8 +45,29 @@ def __post_init__(self): @classmethod def create(cls, dev_wl_app: WlApp) -> "DevSandboxService": - return cls(app=dev_wl_app, name=get_service_name(dev_wl_app)) + return cls(app=dev_wl_app, name=get_dev_sandbox_service_name(dev_wl_app)) -def get_service_name(dev_wl_app: WlApp) -> str: - return dev_wl_app.scheduler_safe_name +@dataclass +class CodeEditorService(AppEntity): + """service entity to expose code editor network""" + + class Meta: + kres_class = kres.KService + serializer = CodeEditorServiceSerializer + deserializer = CodeEditorServiceDeserializer + + def __post_init__(self): + self.ports: List[ServicePortPair] = CODE_SVC_PORT_PAIRS + + @classmethod + def create(cls, dev_wl_app: WlApp) -> "CodeEditorService": + return cls(app=dev_wl_app, name=get_code_editor_service_name(dev_wl_app)) + + +def get_dev_sandbox_service_name(dev_wl_app: WlApp) -> str: + return f"{dev_wl_app.scheduler_safe_name}-dev-sandbox" + + +def get_code_editor_service_name(dev_wl_app: WlApp) -> str: + return f"{dev_wl_app.scheduler_safe_name}-code-editor" diff --git a/apiserver/paasng/paas_wl/bk_app/dev_sandbox/kres_slzs/__init__.py b/apiserver/paasng/paas_wl/bk_app/dev_sandbox/kres_slzs/__init__.py index ec870f713d..86e687f99f 100644 --- a/apiserver/paasng/paas_wl/bk_app/dev_sandbox/kres_slzs/__init__.py +++ b/apiserver/paasng/paas_wl/bk_app/dev_sandbox/kres_slzs/__init__.py @@ -15,16 +15,27 @@ # 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. +from .code_editor import CodeEditorDeserializer, CodeEditorSerializer from .ingress import DevSandboxIngressDeserializer, DevSandboxIngressSerializer -from .sandbox import DevSandboxDeserializer, DevSandboxSerializer, get_dev_sandbox_labels -from .service import DevSandboxServiceDeserializer, DevSandboxServiceSerializer +from .sandbox import DevSandboxDeserializer, DevSandboxSerializer, get_code_editor_labels, get_dev_sandbox_labels +from .service import ( + CodeEditorServiceDeserializer, + CodeEditorServiceSerializer, + DevSandboxServiceDeserializer, + DevSandboxServiceSerializer, +) __all__ = [ + "CodeEditorDeserializer", + "CodeEditorSerializer", "DevSandboxIngressDeserializer", "DevSandboxIngressSerializer", "DevSandboxDeserializer", "DevSandboxSerializer", "DevSandboxServiceDeserializer", "DevSandboxServiceSerializer", + "CodeEditorServiceSerializer", + "CodeEditorServiceDeserializer", "get_dev_sandbox_labels", + "get_code_editor_labels", ] diff --git a/apiserver/paasng/paas_wl/bk_app/dev_sandbox/kres_slzs/code_editor.py b/apiserver/paasng/paas_wl/bk_app/dev_sandbox/kres_slzs/code_editor.py new file mode 100644 index 0000000000..f017254e2f --- /dev/null +++ b/apiserver/paasng/paas_wl/bk_app/dev_sandbox/kres_slzs/code_editor.py @@ -0,0 +1,144 @@ +# -*- coding: utf-8 -*- +# TencentBlueKing is pleased to support the open source community by making +# 蓝鲸智云 - PaaS 平台 (BlueKing - PaaS System) available. +# Copyright (C) 2017 THL A29 Limited, a Tencent company. All rights reserved. +# Licensed under the MIT License (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://opensource.org/licenses/MIT +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +# either express or implied. See the License for the specific language governing permissions and +# limitations under the License. +# +# We undertake not to change the open source license (MIT license) applicable +# to the current version of the project delivered to anyone in the future. + +from typing import TYPE_CHECKING, Dict, Optional + +import cattr +from django.conf import settings +from kubernetes.dynamic import ResourceField, ResourceInstance + +from paas_wl.bk_app.applications.models import WlApp +from paas_wl.bk_app.dev_sandbox.conf import CODE_SVC_PORT_PAIRS +from paas_wl.bk_app.dev_sandbox.entities import CodeEditorConfig, Resources, Runtime, Status +from paas_wl.bk_app.dev_sandbox.kres_slzs.sandbox import get_code_editor_labels +from paas_wl.infras.resources.kube_res.base import AppEntityDeserializer, AppEntitySerializer +from paas_wl.workloads.release_controller.constants import ImagePullPolicy +from paasng.utils.dictx import get_items + +if TYPE_CHECKING: + from paas_wl.bk_app.dev_sandbox.kres_entities import CodeEditor + +_CONTAINER_NAME = "code-editor" + + +class CodeEditorSerializer(AppEntitySerializer["CodeEditor"]): + def serialize(self, obj: "CodeEditor", original_obj: Optional[ResourceInstance] = None, **kwargs): + labels = get_code_editor_labels(obj.app) + deployment_body = { + "apiVersion": self.get_apiversion(), + "kind": "Deployment", + "metadata": { + "name": obj.name, + "labels": labels, + }, + "spec": { + "replicas": 1, + "revisionHistoryLimit": settings.MAX_RS_RETAIN, + "selector": {"matchLabels": labels}, + "template": {"metadata": {"labels": labels}, "spec": self._construct_pod_spec(obj)}, + }, + } + return deployment_body + + def _construct_pod_spec(self, obj: "CodeEditor") -> Dict: + main_container = { + "name": _CONTAINER_NAME, + "image": obj.runtime.image, + "env": [{"name": str(key), "value": str(value)} for key, value in obj.runtime.envs.items()], + "imagePullPolicy": obj.runtime.image_pull_policy, + "ports": [{"containerPort": port_pair.target_port} for port_pair in CODE_SVC_PORT_PAIRS], + } + + if obj.resources: + main_container["resources"] = obj.resources.to_dict() + + spec = {"containers": [main_container]} + self._set_volume_mounts(obj, spec) + + return spec + + def _set_volume_mounts(self, obj: "CodeEditor", spec: Dict): + if not obj.config: + return + + if start_dir := obj.config.start_dir: + main_container = spec["containers"][0] + main_container["volumeMounts"] = [{"name": "start-dir", "mountPath": start_dir}] + + if pvc_claim_name := obj.config.pvc_claim_name: + spec["volumes"] = [ + { + "name": "start-dir", + "persistentVolumeClaim": {"claimName": pvc_claim_name}, + } + ] + + +class CodeEditorDeserializer(AppEntityDeserializer["CodeEditor"]): + def deserialize(self, app: WlApp, kube_data: ResourceInstance) -> "CodeEditor": + main_container = self._get_main_container(kube_data) + runtime = cattr.structure( + { + "envs": {env.name: env.value for env in main_container.env if getattr(env, "value", None)}, + "image": main_container.image, + "image_pull_policy": getattr(main_container, "imagePullPolicy", ImagePullPolicy.IF_NOT_PRESENT), + }, + Runtime, + ) + return self.entity_type( + app=app, + name=kube_data.metadata.name, + runtime=runtime, + resources=cattr.structure(getattr(main_container, "resources", None), Resources), + config=self._construct_config(kube_data), + status=Status(kube_data.status.get("replicas", 1), kube_data.status.get("readyReplicas", 0)), + ) + + def _get_main_container(self, deployment: ResourceInstance) -> ResourceField: + pod_template = deployment.spec.template + for c in pod_template.spec.containers: + if c.name == _CONTAINER_NAME: + return c + + raise RuntimeError(f"No {_CONTAINER_NAME} container found in resource") + + def _get_main_container_dict(self, deployment: ResourceInstance) -> Dict: + deployment_dict = deployment.to_dict() + containers = get_items(deployment_dict, "spec.template.spec.containers", []) + for c in containers: + if c["name"] == _CONTAINER_NAME: + return c + raise RuntimeError(f"No {_CONTAINER_NAME} container found in resource") + + def _construct_config(self, deployment: ResourceInstance) -> CodeEditorConfig: + deployment_dict = deployment.to_dict() + main_container_dict = self._get_main_container_dict(deployment) + + volume = get_items(deployment_dict, "spec.template.spec.volumes", [{}])[0] + volume_mounts = get_items(main_container_dict, "volumeMounts", [{}])[0] + env_list = get_items(main_container_dict, "env", [{}]) + envs = {env["name"]: env["value"] for env in env_list} + config = cattr.structure( + { + "pvc_claim_name": get_items(volume, "persistentVolumeClaim.claimName"), + "start_dir": get_items(volume_mounts, "mountPath"), + "password": envs.get("PASSWORD"), + }, + CodeEditorConfig, + ) + + return config diff --git a/apiserver/paasng/paas_wl/bk_app/dev_sandbox/kres_slzs/sandbox.py b/apiserver/paasng/paas_wl/bk_app/dev_sandbox/kres_slzs/sandbox.py index c8731a70e8..1751c564f0 100644 --- a/apiserver/paasng/paas_wl/bk_app/dev_sandbox/kres_slzs/sandbox.py +++ b/apiserver/paasng/paas_wl/bk_app/dev_sandbox/kres_slzs/sandbox.py @@ -23,14 +23,15 @@ from paas_wl.bk_app.applications.models import WlApp from paas_wl.bk_app.dev_sandbox.conf import DEV_SANDBOX_SVC_PORT_PAIRS -from paas_wl.bk_app.dev_sandbox.entities import Resources, Runtime, Status +from paas_wl.bk_app.dev_sandbox.constants import SourceCodeFetchMethod +from paas_wl.bk_app.dev_sandbox.entities import Resources, Runtime, SourceCodeConfig, Status from paas_wl.infras.resources.kube_res.base import AppEntityDeserializer, AppEntitySerializer from paas_wl.workloads.release_controller.constants import ImagePullPolicy +from paasng.utils.dictx import get_items if TYPE_CHECKING: from paas_wl.bk_app.dev_sandbox.kres_entities import DevSandbox - _CONTAINER_NAME = "dev-sandbox" @@ -65,7 +66,27 @@ def _construct_pod_spec(self, obj: "DevSandbox") -> Dict: if obj.resources: main_container["resources"] = obj.resources.to_dict() - return {"containers": [main_container]} + spec = {"containers": [main_container]} + self._set_volume_mounts(obj, spec) + + return spec + + def _set_volume_mounts(self, obj: "DevSandbox", spec: Dict): + """将 DevSandbox 下工作目录挂载到容器内""" + if not obj.source_code_config: + return + + if workspace := obj.source_code_config.workspace: + main_container = spec["containers"][0] + main_container["volumeMounts"] = [{"name": "workspace", "mountPath": workspace}] + + if pvc_claim_name := obj.source_code_config.pvc_claim_name: + spec["volumes"] = [ + { + "name": "workspace", + "persistentVolumeClaim": {"claimName": pvc_claim_name}, + } + ] class DevSandboxDeserializer(AppEntityDeserializer["DevSandbox"]): @@ -84,6 +105,7 @@ def deserialize(self, app: WlApp, kube_data: ResourceInstance) -> "DevSandbox": name=kube_data.metadata.name, runtime=runtime, resources=cattr.structure(getattr(main_container, "resources", None), Resources), + source_code_config=self._construct_source_code_config(kube_data), status=Status(kube_data.status.get("replicas", 1), kube_data.status.get("readyReplicas", 0)), ) @@ -95,7 +117,45 @@ def _get_main_container(self, deployment: ResourceInstance) -> ResourceField: raise RuntimeError(f"No {_CONTAINER_NAME} container found in resource") + def _get_main_container_dict(self, deployment: ResourceInstance) -> Dict: + deployment_dict = deployment.to_dict() + containers = get_items(deployment_dict, "spec.template.spec.containers", []) + for c in containers: + if c["name"] == _CONTAINER_NAME: + return c + raise RuntimeError(f"No {_CONTAINER_NAME} container found in resource") + + def _construct_source_code_config(self, deployment: ResourceInstance) -> SourceCodeConfig: + """通过挂载,环境变量等信息,构造 SourceCodeConfig""" + deployment_dict = deployment.to_dict() + main_container_dict = self._get_main_container_dict(deployment) + + # 获取挂载信息 + volume = get_items(deployment_dict, "spec.template.spec.volumes", [{}])[0] + volume_mounts = get_items(main_container_dict, "volumeMounts", [{}])[0] + # 获取环境变量 + env_list = get_items(main_container_dict, "env", [{}]) + envs = {env["name"]: env["value"] for env in env_list} + source_code_config = cattr.structure( + { + "pvc_claim_name": get_items(volume, "persistentVolumeClaim.claimName"), + "workspace": get_items(volume_mounts, "mountPath"), + "source_fetch_url": envs.get("SOURCE_FETCH_URL"), + "source_fetch_method": SourceCodeFetchMethod( + envs.get("SOURCE_FETCH_METHOD", SourceCodeFetchMethod.HTTP.value) + ), + }, + SourceCodeConfig, + ) + + return source_code_config + def get_dev_sandbox_labels(app: WlApp) -> Dict[str, str]: """get deployment labels for dev_sandbox by WlApp""" return {"env": "dev", "category": "bkapp", "app": app.scheduler_safe_name} + + +def get_code_editor_labels(app: WlApp) -> Dict[str, str]: + """get deployment labels for code_editor by WlApp""" + return {"env": "code_editor", "category": "bkapp", "app": app.scheduler_safe_name} diff --git a/apiserver/paasng/paas_wl/bk_app/dev_sandbox/kres_slzs/service.py b/apiserver/paasng/paas_wl/bk_app/dev_sandbox/kres_slzs/service.py index 60b1021819..6413d24fd0 100644 --- a/apiserver/paasng/paas_wl/bk_app/dev_sandbox/kres_slzs/service.py +++ b/apiserver/paasng/paas_wl/bk_app/dev_sandbox/kres_slzs/service.py @@ -22,10 +22,10 @@ from paas_wl.bk_app.applications.models import WlApp from paas_wl.infras.resources.kube_res.base import AppEntityDeserializer, AppEntitySerializer -from .sandbox import get_dev_sandbox_labels +from .sandbox import get_code_editor_labels, get_dev_sandbox_labels if TYPE_CHECKING: - from paas_wl.bk_app.dev_sandbox.kres_entities import DevSandboxService + from paas_wl.bk_app.dev_sandbox.kres_entities import CodeEditorService, DevSandboxService class DevSandboxServiceSerializer(AppEntitySerializer["DevSandboxService"]): @@ -54,3 +54,31 @@ def deserialize(self, app: WlApp, kube_data: ResourceInstance) -> "DevSandboxSer app=app, name=kube_data.metadata.name, ) + + +class CodeEditorServiceSerializer(AppEntitySerializer["CodeEditorService"]): + def serialize(self, obj: "CodeEditorService", original_obj: Optional[ResourceInstance] = None, **kwargs): + body: Dict[str, Any] = { + "metadata": {"name": obj.name, "labels": {"env": "dev"}}, + "spec": { + "ports": [ + {"name": p.name, "port": p.port, "targetPort": p.target_port, "protocol": p.protocol} + for p in obj.ports + ], + "selector": get_code_editor_labels(obj.app), + }, + "apiVersion": "v1", + "kind": "Service", + } + + if original_obj: + body["metadata"]["resourceVersion"] = original_obj.metadata.resourceVersion + return body + + +class CodeEditorServiceDeserializer(AppEntityDeserializer["CodeEditorService"]): + def deserialize(self, app: WlApp, kube_data: ResourceInstance) -> "CodeEditorService": + return self.entity_type( + app=app, + name=kube_data.metadata.name, + ) diff --git a/apiserver/paasng/paasng/accessories/dev_sandbox/apps.py b/apiserver/paasng/paasng/accessories/dev_sandbox/apps.py new file mode 100644 index 0000000000..2bfb91326b --- /dev/null +++ b/apiserver/paasng/paasng/accessories/dev_sandbox/apps.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# TencentBlueKing is pleased to support the open source community by making +# 蓝鲸智云 - PaaS 平台 (BlueKing - PaaS System) available. +# Copyright (C) 2017 THL A29 Limited, a Tencent company. All rights reserved. +# Licensed under the MIT License (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://opensource.org/licenses/MIT +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +# either express or implied. See the License for the specific language governing permissions and +# limitations under the License. +# +# We undertake not to change the open source license (MIT license) applicable +# to the current version of the project delivered to anyone in the future. + +from django.apps import AppConfig + + +class DevSandboxConfig(AppConfig): + name = "paasng.accessories.dev_sandbox" diff --git a/apiserver/paasng/paasng/accessories/dev_sandbox/migrations/0001_initial.py b/apiserver/paasng/paasng/accessories/dev_sandbox/migrations/0001_initial.py new file mode 100644 index 0000000000..c1a9e38085 --- /dev/null +++ b/apiserver/paasng/paasng/accessories/dev_sandbox/migrations/0001_initial.py @@ -0,0 +1,51 @@ +# Generated by Django 3.2.25 on 2024-10-30 13:30 + +import blue_krill.models.fields +from django.db import migrations, models +import django.db.models.deletion +import paasng.accessories.dev_sandbox.models +import paasng.utils.models +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('modules', '0016_auto_20240904_1439'), + ] + + operations = [ + migrations.CreateModel( + name='DevSandbox', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('region', models.CharField(help_text='部署区域', max_length=32)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('owner', paasng.utils.models.BkUserField(blank=True, db_index=True, max_length=64, null=True)), + ('code', models.CharField(help_text='沙箱标识', max_length=8, unique=True)), + ('status', models.CharField(choices=[('active', '活跃'), ('error', '错误')], max_length=32, verbose_name='沙箱状态')), + ('expire_at', models.DateTimeField(help_text='到期时间', null=True)), + ('version_info', paasng.accessories.dev_sandbox.models.VersionInfoField(default=None, help_text='代码版本信息', null=True)), + ('module', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.CASCADE, to='modules.module')), + ], + options={ + 'unique_together': {('module', 'owner')}, + }, + ), + migrations.CreateModel( + name='CodeEditor', + fields=[ + ('uuid', models.UUIDField(auto_created=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True, verbose_name='UUID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('password', blue_krill.models.fields.EncryptField(help_text='登陆密码', max_length=32, verbose_name='登陆密码')), + ('dev_sandbox', models.OneToOneField(db_constraint=False, on_delete=django.db.models.deletion.CASCADE, related_name='code_editor', to='dev_sandbox.devsandbox')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/apiserver/paasng/paasng/accessories/dev_sandbox/migrations/__init__.py b/apiserver/paasng/paasng/accessories/dev_sandbox/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apiserver/paasng/paasng/accessories/dev_sandbox/models.py b/apiserver/paasng/paasng/accessories/dev_sandbox/models.py new file mode 100644 index 0000000000..8c00a4860a --- /dev/null +++ b/apiserver/paasng/paasng/accessories/dev_sandbox/models.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +# TencentBlueKing is pleased to support the open source community by making +# 蓝鲸智云 - PaaS 平台 (BlueKing - PaaS System) available. +# Copyright (C) 2017 THL A29 Limited, a Tencent company. All rights reserved. +# Licensed under the MIT License (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://opensource.org/licenses/MIT +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +# either express or implied. See the License for the specific language governing permissions and +# limitations under the License. +# +# We undertake not to change the open source license (MIT license) applicable +# to the current version of the project delivered to anyone in the future. +import datetime +import string + +from blue_krill.models.fields import EncryptField +from django.db import models +from django.utils.crypto import get_random_string + +from paas_wl.bk_app.dev_sandbox.constants import DevSandboxStatus +from paas_wl.utils.models import make_json_field +from paasng.platform.modules.models import Module +from paasng.platform.sourcectl.models import VersionInfo +from paasng.utils.models import OwnerTimestampedModel, UuidAuditedModel + +VersionInfoField = make_json_field("VersionInfoField", VersionInfo) + + +def gen_dev_sandbox_code() -> str: + """生成随机的唯一的沙箱标识(只包含小写字母和数字)""" + characters = string.ascii_lowercase + string.digits + dev_sandbox_code = get_random_string(8, characters) + while DevSandbox.objects.filter(code=dev_sandbox_code).exists(): + dev_sandbox_code = get_random_string(8, characters) + return dev_sandbox_code + + +class DevSandbox(OwnerTimestampedModel): + """DevSandbox Model""" + + code = models.CharField(max_length=8, help_text="沙箱标识", unique=True) + module = models.ForeignKey(Module, on_delete=models.CASCADE, db_constraint=False) + status = models.CharField(max_length=32, verbose_name="沙箱状态", choices=DevSandboxStatus.get_choices()) + expire_at = models.DateTimeField(null=True, help_text="到期时间") + version_info = VersionInfoField(help_text="代码版本信息", default=None, null=True) + + def renew_expire_at(self): + # 如果状态不是ALIVE, 则设置两小时后过期 + if self.status != DevSandboxStatus.ACTIVE.value: + self.expire_at = datetime.datetime.now() + datetime.timedelta(hours=2) + else: + self.expire_at = None + self.save(update_fields=["expire_at"]) + + def should_recycle(self) -> bool: + """检查是否应该被回收""" + if self.expire_at: + return self.expire_at <= datetime.datetime.now() + return False + + class Meta: + unique_together = ("module", "owner") + + +class CodeEditor(UuidAuditedModel): + """CodeEditor Model""" + + dev_sandbox = models.OneToOneField( + DevSandbox, on_delete=models.CASCADE, db_constraint=False, related_name="code_editor" + ) + password = EncryptField(max_length=32, verbose_name="登陆密码", help_text="登陆密码") diff --git a/apiserver/paasng/paasng/accessories/dev_sandbox/serializers.py b/apiserver/paasng/paasng/accessories/dev_sandbox/serializers.py index 45d4926dce..cceafc18a3 100644 --- a/apiserver/paasng/paasng/accessories/dev_sandbox/serializers.py +++ b/apiserver/paasng/paasng/accessories/dev_sandbox/serializers.py @@ -15,9 +15,14 @@ # 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. +from dataclasses import asdict + from rest_framework import serializers +from rest_framework.fields import SerializerMethodField from paas_wl.bk_app.dev_sandbox.entities import HealthPhase +from paasng.accessories.dev_sandbox.models import DevSandbox +from paasng.platform.sourcectl.constants import VersionType class DevSandboxDetailSLZ(serializers.Serializer): @@ -26,3 +31,68 @@ class DevSandboxDetailSLZ(serializers.Serializer): url = serializers.CharField(help_text="dev sandbox 服务地址") token = serializers.CharField(help_text="访问 dev sandbox 中 devserver 服务的 token") status = serializers.ChoiceField(choices=HealthPhase.get_django_choices(), help_text="dev sandbox 的运行状态") + + +class CreateDevSandboxWithCodeEditorSLZ(serializers.Serializer): + """Serializer for create dev sandbox""" + + version_type = serializers.ChoiceField( + choices=VersionType.get_choices(), + required=True, + error_messages={"invalid_choice": f"Invalid choice. Valid choices are {VersionType.get_values()}"}, + help_text="版本类型, 如 branch/tag/trunk", + ) + version_name = serializers.CharField( + required=True, help_text="版本名称: 如 Tag Name/Branch Name/trunk/package_name" + ) + revision = serializers.CharField( + required=False, + help_text="版本信息, 如 hash(git版本)/version(源码包); 如果根据 smart_revision 能查询到 revision, 则不使用该值", + ) + + +class DevSandboxWithCodeEditorUrlsSLZ(serializers.Serializer): + """Serializer for dev sandbox with code editor urls""" + + app_url = serializers.CharField(help_text="访问 dev sandbox saas 的 url") + devserver_url = serializers.CharField(help_text="访问 dev sandbox devserver 的 url") + code_editor_url = serializers.CharField(help_text="访问 dev sandbox code editor 的 url") + + +class DevSandboxWithCodeEditorDetailSLZ(serializers.Serializer): + """Serializer for dev sandbox with code editor detail""" + + urls = DevSandboxWithCodeEditorUrlsSLZ() + token = serializers.CharField(help_text="访问 dev sandbox 中 devserver 服务的 token") + dev_sandbox_status = serializers.ChoiceField( + choices=HealthPhase.get_django_choices(), help_text="dev sandbox 的运行状态" + ) + code_editor_status = serializers.ChoiceField( + choices=HealthPhase.get_django_choices(), help_text="code editor 的运行状态" + ) + dev_sandbox_env_vars = serializers.JSONField(default={}, help_text="dev sandbox 环境变量") + + +class DevSandboxSLZ(serializers.ModelSerializer): + """Serializer for dev sandbox""" + + module_name = SerializerMethodField() + version_info_dict = SerializerMethodField() + + class Meta: + model = DevSandbox + fields = [ + "id", + "status", + "expire_at", + "version_info_dict", + "created", + "updated", + "module_name", + ] + + def get_module_name(self, obj: DevSandbox) -> str: + return obj.module.name + + def get_version_info_dict(self, obj: DevSandbox) -> dict: + return asdict(obj.version_info) diff --git a/apiserver/paasng/paasng/accessories/dev_sandbox/urls.py b/apiserver/paasng/paasng/accessories/dev_sandbox/urls.py index 56c546af22..9e8bb05864 100644 --- a/apiserver/paasng/paasng/accessories/dev_sandbox/urls.py +++ b/apiserver/paasng/paasng/accessories/dev_sandbox/urls.py @@ -17,11 +17,23 @@ from paasng.utils.basic import make_app_pattern, re_path -from .views import DevSandboxViewSet +from .views import DevSandboxViewSet, DevSandboxWithCodeEditorViewSet urlpatterns = [ re_path( make_app_pattern(r"/dev_sandbox/$", include_envs=False), DevSandboxViewSet.as_view({"post": "deploy", "delete": "delete", "get": "get_detail"}), ), + re_path( + make_app_pattern(r"/user/dev_sandbox_with_code_editor/$", include_envs=False), + DevSandboxWithCodeEditorViewSet.as_view({"post": "deploy", "delete": "delete", "get": "get_detail"}), + ), + re_path( + r"api/bkapps/applications/(?P[^/]+)/user/dev_sandbox_with_code_editors/lists/$", + DevSandboxWithCodeEditorViewSet.as_view({"get": "list_app_dev_sandbox"}), + ), + re_path( + make_app_pattern(r"/user/dev_sandbox_password/$", include_envs=False), + DevSandboxWithCodeEditorViewSet.as_view({"post": "get_password"}), + ), ] diff --git a/apiserver/paasng/paasng/accessories/dev_sandbox/views.py b/apiserver/paasng/paasng/accessories/dev_sandbox/views.py index 4d899f1819..83c7f2e238 100644 --- a/apiserver/paasng/paasng/accessories/dev_sandbox/views.py +++ b/apiserver/paasng/paasng/accessories/dev_sandbox/views.py @@ -14,22 +14,49 @@ # # We undertake not to change the open source license (MIT license) applicable # to the current version of the project delivered to anyone in the future. +import logging +from pathlib import Path +from typing import Dict +from bkpaas_auth.models import User +from django.conf import settings +from django.utils.translation import gettext_lazy as _ from drf_yasg.utils import swagger_auto_schema from rest_framework import status +from rest_framework.exceptions import ValidationError from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet -from paas_wl.bk_app.dev_sandbox.controller import DevSandboxController +from paas_wl.bk_app.dev_sandbox.constants import DevSandboxStatus +from paas_wl.bk_app.dev_sandbox.controller import DevSandboxController, DevSandboxWithCodeEditorController from paas_wl.bk_app.dev_sandbox.exceptions import DevSandboxAlreadyExists, DevSandboxResourceNotFound +from paasng.accessories.dev_sandbox.models import CodeEditor, DevSandbox, gen_dev_sandbox_code +from paasng.accessories.services.utils import generate_password +from paasng.infras.accounts.constants import FunctionType +from paasng.infras.accounts.models import make_verifier from paasng.infras.accounts.permissions.application import application_perm_class +from paasng.infras.accounts.serializers import VerificationCodeSLZ from paasng.infras.iam.permissions.resources.application import AppAction from paasng.platform.applications.mixins import ApplicationCodeInPathMixin +from paasng.platform.engine.configurations.config_var import get_env_variables +from paasng.platform.engine.utils.source import get_source_dir +from paasng.platform.modules.constants import SourceOrigin +from paasng.platform.modules.models.module import Module +from paasng.platform.sourcectl.exceptions import GitLabBranchNameBugError +from paasng.platform.sourcectl.models import VersionInfo +from paasng.platform.sourcectl.version_services import get_version_service from paasng.utils.error_codes import error_codes from .config_var import CONTAINER_TOKEN_ENV, generate_envs -from .serializers import DevSandboxDetailSLZ +from .serializers import ( + CreateDevSandboxWithCodeEditorSLZ, + DevSandboxDetailSLZ, + DevSandboxSLZ, + DevSandboxWithCodeEditorDetailSLZ, +) + +logger = logging.getLogger(__name__) class DevSandboxViewSet(GenericViewSet, ApplicationCodeInPathMixin): @@ -69,3 +96,193 @@ def get_detail(self, request, code, module_name): {"url": detail.url, "token": detail.envs[CONTAINER_TOKEN_ENV], "status": detail.status} ) return Response(data=serializer.data) + + +class DevSandboxWithCodeEditorViewSet(GenericViewSet, ApplicationCodeInPathMixin): + permission_classes = [IsAuthenticated, application_perm_class(AppAction.BASIC_DEVELOP)] + + @swagger_auto_schema(request_body=CreateDevSandboxWithCodeEditorSLZ, responses={"201": "没有返回数据"}) + def deploy(self, request, code, module_name): + """部署开发沙箱""" + app = self.get_application() + module = self.get_module_via_path() + + if DevSandbox.objects.filter(owner=request.user.pk, module=module).exists(): + raise error_codes.DEV_SANDBOX_ALREADY_EXISTS + + serializer = CreateDevSandboxWithCodeEditorSLZ(data=request.data) + serializer.is_valid(raise_exception=True) + params = serializer.data + + # 仅支持 vcs 类型的源码获取方式 + source_origin = module.get_source_origin() + if source_origin != SourceOrigin.AUTHORIZED_VCS: + raise error_codes.UNSUPPORTED_SOURCE_ORIGIN + + # 获取版本信息 + version_info = self._get_version_info(request.user, module, params) + + dev_sandbox_code = gen_dev_sandbox_code() + dev_sandbox = DevSandbox.objects.create( + region=app.region, + owner=request.user.pk, + module=module, + status=DevSandboxStatus.ACTIVE.value, + version_info=version_info, + code=dev_sandbox_code, + ) + + # 生成代码编辑器密码 + password = generate_password() + CodeEditor.objects.create( + dev_sandbox=dev_sandbox, + password=password, + ) + + controller = DevSandboxWithCodeEditorController( + app=app, + module_name=module.name, + dev_sandbox_code=dev_sandbox.code, + owner=request.user.pk, + ) + try: + # 获取构建目录相对路径 + source_dir = get_source_dir(module, request.user.pk, version_info) + relative_source_dir = Path(source_dir) + if relative_source_dir.is_absolute(): + logger.warning( + "Unsupported absolute path<%s>, force transform to relative_to path.", relative_source_dir + ) + relative_source_dir = relative_source_dir.relative_to("/") + + # 获取环境变量(复用 stag 环境) + envs = generate_envs(app, module) + stag_envs = get_env_variables(module.get_envs("stag")) + envs.update(stag_envs) + + controller.deploy( + dev_sandbox_env_vars=envs, + code_editor_env_vars={}, + version_info=version_info, + relative_source_dir=relative_source_dir, + password=password, + ) + except DevSandboxAlreadyExists: + dev_sandbox.delete() + raise error_codes.DEV_SANDBOX_ALREADY_EXISTS + except Exception: + dev_sandbox.delete() + raise + + return Response(status=status.HTTP_201_CREATED) + + @swagger_auto_schema(responses={"204": "没有返回数据"}) + def delete(self, request, code, module_name): + """清理开发沙箱""" + app = self.get_application() + module = self.get_module_via_path() + + try: + dev_sandbox = DevSandbox.objects.get(owner=request.user.pk, module=module) + except DevSandbox.DoesNotExist: + raise error_codes.DEV_SANDBOX_NOT_FOUND + + controller = DevSandboxWithCodeEditorController( + app=app, + module_name=module.name, + dev_sandbox_code=dev_sandbox.code, + owner=request.user.pk, + ) + controller.delete() + dev_sandbox.delete() + + return Response(status=status.HTTP_204_NO_CONTENT) + + @swagger_auto_schema(tags=["开发沙箱"], Response={200: DevSandboxWithCodeEditorDetailSLZ}) + def get_detail(self, request, code, module_name): + """获取开发沙箱的运行详情""" + app = self.get_application() + module = self.get_module_via_path() + try: + dev_sandbox = DevSandbox.objects.get(owner=request.user.pk, module=module) + except DevSandbox.DoesNotExist: + raise error_codes.DEV_SANDBOX_NOT_FOUND + + controller = DevSandboxWithCodeEditorController( + app=app, + module_name=module.name, + dev_sandbox_code=dev_sandbox.code, + owner=request.user.pk, + ) + try: + detail = controller.get_detail() + except DevSandboxResourceNotFound: + raise error_codes.DEV_SANDBOX_NOT_FOUND + + serializer = DevSandboxWithCodeEditorDetailSLZ( + { + "urls": detail.urls, + "token": detail.dev_sandbox_env_vars[CONTAINER_TOKEN_ENV], + "dev_sandbox_status": detail.dev_sandbox_status, + "code_editor_status": detail.code_editor_status, + "dev_sandbox_env_vars": detail.dev_sandbox_env_vars, + } + ) + return Response(data=serializer.data) + + @swagger_auto_schema(tags=["鉴权信息"], request_body=VerificationCodeSLZ) + def get_password(self, request, code, module_name): + """验证验证码查看代码编辑器密码""" + module = self.get_module_via_path() + + try: + dev_sandbox = DevSandbox.objects.get(owner=request.user.pk, module=module) + except DevSandbox.DoesNotExist: + raise error_codes.DEV_SANDBOX_NOT_FOUND + + # 部分版本没有发送通知的渠道可置:跳过验证码校验步骤 + if settings.ENABLE_VERIFICATION_CODE: + serializer = VerificationCodeSLZ(data=request.data) + serializer.is_valid(raise_exception=True) + + verifier = make_verifier(request.session, FunctionType.GET_CODE_EDITOR_PASSWORD.value) + is_valid = verifier.validate(serializer.data["verification_code"]) + if not is_valid: + raise ValidationError({"verification_code": [_("验证码错误")]}) + else: + logger.warning("Verification code is not currently supported, return app secret directly") + + return Response({"password": dev_sandbox.code_editor.password}) + + @swagger_auto_schema(tags=["开发沙箱"], Response={200: DevSandboxWithCodeEditorDetailSLZ}) + def list_app_dev_sandbox(self, request, code): + """获取该应用下用户的开发沙箱""" + app = self.get_application() + modules = app.modules.all() + dev_sandboxes = DevSandbox.objects.filter(owner=request.user.pk, module__in=modules) + + return Response(data=DevSandboxSLZ(dev_sandboxes, many=True).data) + + @staticmethod + def _get_version_info(user: User, module: Module, params: Dict) -> VersionInfo: + """Get VersionInfo from user inputted params""" + version_name = params["version_name"] + version_type = params["version_type"] + revision = params.get("revision") + try: + # 尝试根据获取最新的 revision + version_service = get_version_service(module, operator=user.pk) + revision = version_service.extract_smart_revision(f"{version_type}:{version_name}") + except GitLabBranchNameBugError as e: + raise error_codes.CANNOT_GET_REVISION.f(str(e)) + except NotImplementedError: + logger.debug( + "The current source code system does not support parsing the version unique ID from the version name" + ) + except Exception: + logger.exception("Failed to parse version information.") + + # 如果前端没有提供 revision 信息, 就报错 + if not revision: + raise error_codes.CANNOT_GET_REVISION + return VersionInfo(revision, version_name, version_type) diff --git a/apiserver/paasng/paasng/infras/accounts/constants.py b/apiserver/paasng/paasng/infras/accounts/constants.py index 6043e93ac0..65bfc8dca1 100644 --- a/apiserver/paasng/paasng/infras/accounts/constants.py +++ b/apiserver/paasng/paasng/infras/accounts/constants.py @@ -114,9 +114,11 @@ class FunctionType(ChoicesEnum): DEFAULT = "default" SVN = "SVN" GET_APP_SECRET = "GET_APP_SECRET" + GET_CODE_EDITOR_PASSWORD = "GET_CODE_EDITOR_PASSWORD" FUNCTION_TYPE_MAP = { FunctionType.SVN.value: "verification_code", FunctionType.GET_APP_SECRET.value: "app_secret_vcode_storage_key", + FunctionType.GET_CODE_EDITOR_PASSWORD.value: "code_editor_password_storage_key", } diff --git a/apiserver/paasng/paasng/platform/engine/utils/source.py b/apiserver/paasng/paasng/platform/engine/utils/source.py index ca8c7e120b..b9c813ff5f 100644 --- a/apiserver/paasng/paasng/platform/engine/utils/source.py +++ b/apiserver/paasng/paasng/platform/engine/utils/source.py @@ -20,6 +20,7 @@ from typing import Dict, Optional import cattr +from blue_krill.storages.blobstore.base import SignatureType from django.conf import settings from django.core.exceptions import ValidationError from django.utils.translation import gettext as _ @@ -49,7 +50,13 @@ ) from paasng.platform.sourcectl.models import VersionInfo from paasng.platform.sourcectl.repo_controller import get_repo_controller -from paasng.platform.sourcectl.utils import DockerIgnore +from paasng.platform.sourcectl.utils import ( + DockerIgnore, + compress_directory_ext, + generate_temp_dir, + generate_temp_file, +) +from paasng.utils.blobstore import make_blob_store from paasng.utils.validators import PROC_TYPE_MAX_LENGTH, PROC_TYPE_PATTERN logger = logging.getLogger(__name__) @@ -268,3 +275,45 @@ def tag_module_from_source_files(module, source_files_path): tag_module(module, tags, source="source_analyze") except Exception: logger.exception("Unable to tagging module") + + +def upload_source_code( + module: Module, version_info: VersionInfo, relative_source_dir: Path, operator: str, region: str +) -> str: + """上传应用模块源码到 blob 存储, 并且返回源码的下载链接, 参考方法 "BaseBuilder.compress_and_upload" + + return: source fetch url + """ + spec = ModuleSpecs(module) + with generate_temp_dir() as working_dir: + source_dir = working_dir.absolute() / relative_source_dir + # 下载源码到临时目录 + if spec.source_origin_specs.source_origin == SourceOrigin.AUTHORIZED_VCS: + get_repo_controller(module, operator=operator).export(working_dir, version_info) + else: + raise NotImplementedError + + # 上传源码 + with generate_temp_file(suffix=".tar.gz") as package_path: + source_destination_path = _get_source_package_path( + version_info, module.application.code, module.name, region + ) + compress_directory_ext(source_dir, package_path) + logger.info(f"Uploading source files to {source_destination_path}") + store = make_blob_store(bucket=settings.BLOBSTORE_BUCKET_APP_SOURCE) + store.upload_file(package_path, source_destination_path) + + source_fetch_url = store.generate_presigned_url( + key=source_destination_path, expires_in=60 * 60 * 24, signature_type=SignatureType.DOWNLOAD + ) + + return source_fetch_url + + +def _get_source_package_path(version_info: VersionInfo, app_code: str, module_name: str, region: str) -> str: + """Return the blobstore path for storing source files package""" + branch = version_info.version_name + revision = version_info.revision + + slug_name = f"{app_code}:{module_name}:{branch}:{revision}:dev" + return f"{region}/home/{slug_name}/tar" diff --git a/apiserver/paasng/paasng/settings/__init__.py b/apiserver/paasng/paasng/settings/__init__.py index 1fe3635954..2e6f100ae8 100644 --- a/apiserver/paasng/paasng/settings/__init__.py +++ b/apiserver/paasng/paasng/settings/__init__.py @@ -162,6 +162,8 @@ def minimum_database_version(self): "paasng.platform.sourcectl", "paasng.accessories.servicehub", "paasng.accessories.services", + # dev_sandbox + "paasng.accessories.dev_sandbox", "paasng.platform.templates", "paasng.plat_admin.api_doc", "paasng.plat_admin.admin42", diff --git a/apiserver/paasng/paasng/settings/workloads.py b/apiserver/paasng/paasng/settings/workloads.py index fe7827cc42..77e9b1dd22 100644 --- a/apiserver/paasng/paasng/settings/workloads.py +++ b/apiserver/paasng/paasng/settings/workloads.py @@ -38,6 +38,7 @@ - 环境变量比 YAML 配置的优先级更高 - 环境变量可修改字典内的嵌套值,参考文档:https://www.dynaconf.com/envvars/ """ + from pathlib import Path from dynaconf import LazySettings @@ -87,6 +88,14 @@ # dev sandbox 中 devserver 的监听地址 DEV_SANDBOX_DEVSERVER_PORT = settings.get("DEV_SANDBOX_DEVSERVER_PORT", 8000) DEV_SANDBOX_IMAGE = settings.get("DEV_SANDBOX_IMAGE", "bkpaas/dev-heroku-bionic:latest") +DEV_SANDBOX_WORKSPACE = settings.get("DEV_SANDBOX_WORKSPACE", "/cnb/devsandbox/src") + +# dev sandbox 中 code-editor 的监听地址 +CODE_EDITOR_PORT = settings.get("CODE_EDITOR_PORT", 8080) +CODE_EDITOR_IMAGE = settings.get("CODE_EDITOR_IMAGE", "codercom/code-server:4.9.0") +# code-editor 的项目启动目录 +CODE_EDITOR_START_DIR = settings.get("CODE_EDITOR_START_DIR", "/home/coder/project") + # 服务相关插件配置 SERVICES_PLUGINS = settings.get("SERVICES_PLUGINS", default={}) diff --git a/apiserver/paasng/tests/paas_wl/bk_app/dev_sandbox/conftest.py b/apiserver/paasng/tests/paas_wl/bk_app/dev_sandbox/conftest.py index 665cb93a34..ee2518fd24 100644 --- a/apiserver/paasng/tests/paas_wl/bk_app/dev_sandbox/conftest.py +++ b/apiserver/paasng/tests/paas_wl/bk_app/dev_sandbox/conftest.py @@ -17,9 +17,16 @@ import pytest +from paas_wl.bk_app.dev_sandbox.constants import SourceCodeFetchMethod from paas_wl.bk_app.dev_sandbox.controller import _DevWlAppCreator -from paas_wl.bk_app.dev_sandbox.entities import Resources, ResourceSpec, Runtime -from paas_wl.bk_app.dev_sandbox.kres_entities import DevSandbox, DevSandboxIngress, DevSandboxService +from paas_wl.bk_app.dev_sandbox.entities import CodeEditorConfig, Resources, ResourceSpec, Runtime, SourceCodeConfig +from paas_wl.bk_app.dev_sandbox.kres_entities import ( + CodeEditor, + CodeEditorService, + DevSandbox, + DevSandboxIngress, + DevSandboxService, +) from paas_wl.infras.cluster.models import Cluster from tests.conftest import CLUSTER_NAME_FOR_TESTING @@ -40,6 +47,11 @@ def module_name(): return "default" +@pytest.fixture() +def dev_sandbox_code(): + return "devsandbox" + + @pytest.fixture() def dev_wl_app(bk_app, module_name): return _DevWlAppCreator(bk_app, module_name).create() @@ -57,11 +69,67 @@ def dev_sandbox_entity(dev_wl_app, dev_runtime): ) +@pytest.fixture() +def user_dev_wl_app(bk_app, module_name, dev_sandbox_code): + return _DevWlAppCreator(bk_app, module_name, dev_sandbox_code).create() + + +@pytest.fixture() +def source_configured_dev_sandbox_entity(user_dev_wl_app, dev_runtime): + source_code_config = SourceCodeConfig( + pvc_claim_name="test-pvc", + workspace="/cnb/devsandbox/src", + source_fetch_url="http://example.com", + source_fetch_method=SourceCodeFetchMethod.BK_REPO, + ) + dev_sandbox_entity = DevSandbox.create( + user_dev_wl_app, + dev_runtime, + resources=Resources( + limits=ResourceSpec(cpu="4", memory="2Gi"), + requests=ResourceSpec(cpu="200m", memory="512Mi"), + ), + source_code_config=source_code_config, + ) + dev_sandbox_entity.construct_envs() + return dev_sandbox_entity + + +@pytest.fixture() +def code_editor_entity(user_dev_wl_app, dev_runtime): + config = CodeEditorConfig( + pvc_claim_name="test-pvc", + start_dir="/home/coder/project", + password="test-password", + ) + code_editor_entity = CodeEditor.create( + user_dev_wl_app, + dev_runtime, + config=config, + resources=Resources( + limits=ResourceSpec(cpu="4", memory="2Gi"), + requests=ResourceSpec(cpu="200m", memory="512Mi"), + ), + ) + code_editor_entity.construct_envs() + return code_editor_entity + + @pytest.fixture() def dev_sandbox_service_entity(dev_wl_app): return DevSandboxService.create(dev_wl_app) +@pytest.fixture() +def code_editor_service_entity(dev_wl_app): + return CodeEditorService.create(dev_wl_app) + + @pytest.fixture() def dev_sandbox_ingress_entity(bk_app, dev_wl_app, module_name): return DevSandboxIngress.create(dev_wl_app, bk_app.code) + + +@pytest.fixture() +def dev_sandbox_ingress_entity_with_dev_sandbox_code(bk_app, dev_wl_app, module_name, dev_sandbox_code): + return DevSandboxIngress.create(dev_wl_app, bk_app.code, dev_sandbox_code) diff --git a/apiserver/paasng/tests/paas_wl/bk_app/dev_sandbox/test_controller.py b/apiserver/paasng/tests/paas_wl/bk_app/dev_sandbox/test_controller.py index 8c082b7890..9d6ea5dbbf 100644 --- a/apiserver/paasng/tests/paas_wl/bk_app/dev_sandbox/test_controller.py +++ b/apiserver/paasng/tests/paas_wl/bk_app/dev_sandbox/test_controller.py @@ -15,13 +15,23 @@ # 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. +from pathlib import Path +from unittest import mock + import pytest +from django.conf import settings from kubernetes.client.apis import VersionApi -from paas_wl.bk_app.dev_sandbox.controller import DevSandboxController +from paas_wl.bk_app.dev_sandbox.controller import DevSandboxController, DevSandboxWithCodeEditorController from paas_wl.bk_app.dev_sandbox.exceptions import DevSandboxAlreadyExists -from paas_wl.bk_app.dev_sandbox.kres_entities import get_ingress_name, get_service_name, get_sub_domain_host +from paas_wl.bk_app.dev_sandbox.kres_entities import ( + get_dev_sandbox_service_name, + get_ingress_name, + get_sub_domain_host, +) +from paas_wl.bk_app.dev_sandbox.kres_entities.code_editor import get_code_editor_name from paas_wl.infras.resources.base.base import get_client_by_cluster_name +from paasng.platform.sourcectl.models import VersionInfo from tests.conftest import CLUSTER_NAME_FOR_TESTING pytestmark = pytest.mark.django_db(databases=["default", "workloads"]) @@ -56,8 +66,8 @@ def test_deploy_success(self, controller, bk_app, module_name, dev_wl_app): assert sandbox_entity_in_cluster.status.ready_replicas in [0, 1] assert sandbox_entity_in_cluster.status.to_health_phase() in ["Progressing", "Healthy"] - service_entity_in_cluster = controller.service_mgr.get(dev_wl_app, get_service_name(dev_wl_app)) - assert service_entity_in_cluster.name == get_service_name(dev_wl_app) + service_entity_in_cluster = controller.service_mgr.get(dev_wl_app, get_dev_sandbox_service_name(dev_wl_app)) + assert service_entity_in_cluster.name == get_dev_sandbox_service_name(dev_wl_app) ingress_entity_in_cluster = controller.ingress_mgr.get(dev_wl_app, get_ingress_name(dev_wl_app)) assert ingress_entity_in_cluster.name == get_ingress_name(dev_wl_app) @@ -74,3 +84,102 @@ def test_get_sandbox_detail(self, controller, bk_app, module_name, dev_wl_app): assert detail.status in ["Progressing", "Healthy"] assert detail.url == get_sub_domain_host(bk_app.code, dev_wl_app, module_name) assert detail.envs == {"FOO": "test"} + + +class TestDevSandboxWithCodeEditorController: + @pytest.fixture() + def controller(self, bk_app, bk_user, module_name, dev_sandbox_code): + ctrl = DevSandboxWithCodeEditorController(bk_app, module_name, dev_sandbox_code, bk_user.pk) + yield ctrl + # just test delete ok! + ctrl.delete() + + @pytest.fixture() + def _do_deploy(self, controller): + dev_sandbox_env_vars = {"FOO": "test"} + version_info = VersionInfo("1", "v1", "branches") + relative_source_dir = Path(".") + password = "123456" + with mock.patch( + "paas_wl.bk_app.dev_sandbox.controller.upload_source_code", + return_value="example.com", + ): + controller.deploy( + dev_sandbox_env_vars=dev_sandbox_env_vars, + code_editor_env_vars={}, + version_info=version_info, + relative_source_dir=relative_source_dir, + password=password, + ) + + @pytest.mark.usefixtures("_do_deploy") + def test_deploy_success(self, controller, bk_app, module_name, user_dev_wl_app): + sandbox_entity_in_cluster = controller.sandbox_mgr.get(user_dev_wl_app, user_dev_wl_app.scheduler_safe_name) + assert sandbox_entity_in_cluster.runtime.envs == { + "FOO": "test", + "SOURCE_FETCH_METHOD": "BK_REPO", + "SOURCE_FETCH_URL": "example.com", + "WORKSPACE": "/cnb/devsandbox/src", + } + assert sandbox_entity_in_cluster.status.replicas == 1 + assert sandbox_entity_in_cluster.status.ready_replicas in [0, 1] + assert sandbox_entity_in_cluster.status.to_health_phase() in ["Progressing", "Healthy"] + + code_editor_entity_in_cluster = controller.code_editor_mgr.get( + user_dev_wl_app, get_code_editor_name(user_dev_wl_app) + ) + assert code_editor_entity_in_cluster.runtime.envs == {"PASSWORD": "123456", "START_DIR": "/home/coder/project"} + assert code_editor_entity_in_cluster.status.replicas == 1 + assert code_editor_entity_in_cluster.status.ready_replicas in [0, 1] + assert code_editor_entity_in_cluster.status.to_health_phase() in ["Progressing", "Healthy"] + + service_entity_in_cluster = controller.dev_sandbox_svc_mgr.get( + user_dev_wl_app, get_dev_sandbox_service_name(user_dev_wl_app) + ) + assert service_entity_in_cluster.name == get_dev_sandbox_service_name(user_dev_wl_app) + + ingress_entity_in_cluster = controller.ingress_mgr.get(user_dev_wl_app, get_ingress_name(user_dev_wl_app)) + assert ingress_entity_in_cluster.name == get_ingress_name(user_dev_wl_app) + assert ingress_entity_in_cluster.domains[0].host == get_sub_domain_host( + bk_app.code, user_dev_wl_app, module_name + ) + + @pytest.mark.usefixtures("_do_deploy") + def test_deploy_when_already_exists(self, controller, bk_app, module_name, user_dev_wl_app): + dev_sandbox_env_vars = {"FOO": "test"} + version_info = VersionInfo("1", "v1", "branches") + relative_source_dir = Path(".") + password = "123456" + with pytest.raises(DevSandboxAlreadyExists), mock.patch( + "paas_wl.bk_app.dev_sandbox.controller.upload_source_code", + return_value="example.com", + ): + controller.deploy( + dev_sandbox_env_vars=dev_sandbox_env_vars, + code_editor_env_vars={}, + version_info=version_info, + relative_source_dir=relative_source_dir, + password=password, + ) + + @pytest.mark.usefixtures("_do_deploy") + def test_get_sandbox_detail(self, controller, bk_app, module_name, user_dev_wl_app): + detail = controller.get_detail() + base_url = get_sub_domain_host(bk_app.code, user_dev_wl_app, module_name) + dev_sandbox_code = controller.dev_sandbox_code + + assert detail.urls.app_url == f"{base_url}/dev_sandbox/{dev_sandbox_code}/app/" + assert detail.urls.devserver_url == f"{base_url}/dev_sandbox/{dev_sandbox_code}/devserver/" + assert ( + detail.urls.code_editor_url + == f"{base_url}/dev_sandbox/{dev_sandbox_code}/code-editor/?folder={settings.CODE_EDITOR_START_DIR}" + ) + assert detail.dev_sandbox_env_vars == { + "FOO": "test", + "SOURCE_FETCH_METHOD": "BK_REPO", + "SOURCE_FETCH_URL": "example.com", + "WORKSPACE": "/cnb/devsandbox/src", + } + assert detail.code_editor_env_vars == {"PASSWORD": "123456", "START_DIR": "/home/coder/project"} + assert detail.dev_sandbox_status in ["Progressing", "Healthy"] + assert detail.code_editor_status in ["Progressing", "Healthy"] diff --git a/apiserver/paasng/tests/paas_wl/bk_app/dev_sandbox/test_kres_slzs.py b/apiserver/paasng/tests/paas_wl/bk_app/dev_sandbox/test_kres_slzs.py index a5d955ce2a..aa4d1bbb8d 100644 --- a/apiserver/paasng/tests/paas_wl/bk_app/dev_sandbox/test_kres_slzs.py +++ b/apiserver/paasng/tests/paas_wl/bk_app/dev_sandbox/test_kres_slzs.py @@ -18,11 +18,22 @@ import pytest from django.conf import settings -from paas_wl.bk_app.dev_sandbox.kres_entities import DevSandbox, DevSandboxIngress, DevSandboxService, get_service_name +from paas_wl.bk_app.dev_sandbox.kres_entities import ( + CodeEditor, + CodeEditorService, + DevSandbox, + DevSandboxIngress, + DevSandboxService, + get_code_editor_service_name, + get_dev_sandbox_service_name, +) from paas_wl.bk_app.dev_sandbox.kres_slzs import ( + CodeEditorSerializer, + CodeEditorServiceSerializer, DevSandboxIngressSerializer, DevSandboxSerializer, DevSandboxServiceSerializer, + get_code_editor_labels, get_dev_sandbox_labels, ) from paas_wl.infras.resources.kube_res.base import GVKConfig @@ -80,6 +91,53 @@ def test_serialize(self, gvk_config, dev_sandbox_entity): }, } + def test_serialize_with_source_code_config(self, gvk_config, source_configured_dev_sandbox_entity): + slz = DevSandboxSerializer(DevSandbox, gvk_config) + manifest = slz.serialize(source_configured_dev_sandbox_entity) + + labels = get_dev_sandbox_labels(source_configured_dev_sandbox_entity.app) + assert manifest == { + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { + "labels": labels, + "name": source_configured_dev_sandbox_entity.name, + }, + "spec": { + "replicas": 1, + "revisionHistoryLimit": settings.MAX_RS_RETAIN, + "selector": {"matchLabels": labels}, + "template": { + "metadata": {"labels": labels}, + "spec": { + "containers": [ + { + "name": "dev-sandbox", + "image": source_configured_dev_sandbox_entity.runtime.image, + "imagePullPolicy": source_configured_dev_sandbox_entity.runtime.image_pull_policy, + "env": [ + {"name": "FOO", "value": "test"}, + {"name": "SOURCE_FETCH_METHOD", "value": "BK_REPO"}, + {"name": "SOURCE_FETCH_URL", "value": "http://example.com"}, + {"name": "WORKSPACE", "value": "/cnb/devsandbox/src"}, + ], + "ports": [ + {"containerPort": settings.DEV_SANDBOX_DEVSERVER_PORT}, + {"containerPort": settings.CONTAINER_PORT}, + ], + "resources": { + "requests": {"cpu": "200m", "memory": "512Mi"}, + "limits": {"cpu": "4", "memory": "2Gi"}, + }, + "volumeMounts": [{"mountPath": "/cnb/devsandbox/src", "name": "workspace"}], + } + ], + "volumes": [{"name": "workspace", "persistentVolumeClaim": {"claimName": "test-pvc"}}], + }, + }, + }, + } + class TestDevSandboxServiceSLZ: @pytest.fixture() @@ -99,7 +157,7 @@ def test_serialize(self, gvk_config, dev_sandbox_service_entity): "apiVersion": "v1", "kind": "Service", "metadata": { - "name": get_service_name(dev_sandbox_service_entity.app), + "name": get_dev_sandbox_service_name(dev_sandbox_service_entity.app), "labels": {"env": "dev"}, }, "spec": { @@ -131,7 +189,8 @@ def test_serialize(self, gvk_config, dev_sandbox_ingress_entity, bk_app, module_ slz = DevSandboxIngressSerializer(DevSandboxIngress, gvk_config) manifest = slz.serialize(dev_sandbox_ingress_entity) - service_name = get_service_name(dev_sandbox_ingress_entity.app) + dev_sandbox_svc_name = get_dev_sandbox_service_name(dev_sandbox_ingress_entity.app) + code_editor_svc_name = get_code_editor_service_name(dev_sandbox_ingress_entity.app) assert manifest["apiVersion"] == "networking.k8s.io/v1" assert manifest["metadata"] == { "name": dev_sandbox_ingress_entity.name, @@ -152,21 +211,186 @@ def test_serialize(self, gvk_config, dev_sandbox_ingress_entity, bk_app, module_ "pathType": "ImplementationSpecific", "backend": { "service": { - "name": service_name, + "name": dev_sandbox_svc_name, "port": {"name": "devserver"}, }, }, }, { - "path": "/()(.*)", + "path": "/(app)/(.*)()", "pathType": "ImplementationSpecific", "backend": { "service": { - "name": service_name, + "name": dev_sandbox_svc_name, "port": {"name": "app"}, }, }, }, + { + "path": "/(code-editor)/(.*)()", + "pathType": "ImplementationSpecific", + "backend": { + "service": { + "name": code_editor_svc_name, + "port": {"name": "code-editor"}, + }, + }, + }, ] }, } + + def test_serialize_with_user( + self, + gvk_config, + dev_sandbox_ingress_entity_with_dev_sandbox_code, + bk_app, + module_name, + default_dev_sandbox_cluster, + dev_sandbox_code, + ): + slz = DevSandboxIngressSerializer(DevSandboxIngress, gvk_config) + manifest = slz.serialize(dev_sandbox_ingress_entity_with_dev_sandbox_code) + + app = dev_sandbox_ingress_entity_with_dev_sandbox_code.app + dev_sandbox_svc_name = get_dev_sandbox_service_name(app) + code_editor_svc_name = get_code_editor_service_name(app) + assert manifest["apiVersion"] == "networking.k8s.io/v1" + assert manifest["metadata"] == { + "name": dev_sandbox_ingress_entity_with_dev_sandbox_code.name, + "annotations": { + "bkbcs.tencent.com/skip-filter-clb": "true", + "nginx.ingress.kubernetes.io/ssl-redirect": "false", + "nginx.ingress.kubernetes.io/rewrite-target": "/$2", + "nginx.ingress.kubernetes.io/configuration-snippet": "proxy_set_header X-Script-Name /$1$3;", + }, + "labels": {"env": "dev"}, + } + assert manifest["spec"]["rules"][0] == { + "host": f"dev-dot-{module_name}-dot-{bk_app.code}.{default_dev_sandbox_cluster.ingress_config.default_root_domain.name}", + "http": { + "paths": [ + { + "path": f"/(dev_sandbox/{dev_sandbox_code}/devserver)/(.*)()", + "pathType": "ImplementationSpecific", + "backend": { + "service": { + "name": dev_sandbox_svc_name, + "port": {"name": "devserver"}, + }, + }, + }, + { + "path": f"/(dev_sandbox/{dev_sandbox_code}/app)/(.*)()", + "pathType": "ImplementationSpecific", + "backend": { + "service": { + "name": dev_sandbox_svc_name, + "port": {"name": "app"}, + }, + }, + }, + { + "path": f"/(dev_sandbox/{dev_sandbox_code}/code-editor)/(.*)()", + "pathType": "ImplementationSpecific", + "backend": { + "service": { + "name": code_editor_svc_name, + "port": {"name": "code-editor"}, + }, + }, + }, + ] + }, + } + + +class TestCodeEditorSLZ: + @pytest.fixture() + def gvk_config(self): + return GVKConfig( + server_version="1.20.0", + kind="Deployment", + preferred_apiversion="apps/v1", + available_apiversions=["apps/v1"], + ) + + def test_serialize(self, gvk_config, code_editor_entity): + slz = CodeEditorSerializer(CodeEditor, gvk_config) + manifest = slz.serialize(code_editor_entity) + + labels = get_code_editor_labels(code_editor_entity.app) + assert manifest == { + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { + "labels": labels, + "name": code_editor_entity.name, + }, + "spec": { + "replicas": 1, + "revisionHistoryLimit": settings.MAX_RS_RETAIN, + "selector": {"matchLabels": labels}, + "template": { + "metadata": {"labels": labels}, + "spec": { + "containers": [ + { + "name": "code-editor", + "image": code_editor_entity.runtime.image, + "imagePullPolicy": code_editor_entity.runtime.image_pull_policy, + "env": [ + {"name": "FOO", "value": "test"}, + {"name": "PASSWORD", "value": "test-password"}, + {"name": "START_DIR", "value": "/home/coder/project"}, + ], + "ports": [ + {"containerPort": settings.CODE_EDITOR_PORT}, + ], + "resources": { + "requests": {"cpu": "200m", "memory": "512Mi"}, + "limits": {"cpu": "4", "memory": "2Gi"}, + }, + "volumeMounts": [{"mountPath": "/home/coder/project", "name": "start-dir"}], + } + ], + "volumes": [{"name": "start-dir", "persistentVolumeClaim": {"claimName": "test-pvc"}}], + }, + }, + }, + } + + +class TestCodeEditorServiceSLZ: + @pytest.fixture() + def gvk_config(self): + return GVKConfig( + server_version="1.20.0", + kind="Service", + preferred_apiversion="v1", + available_apiversions=["v1"], + ) + + def test_serialize(self, gvk_config, code_editor_service_entity): + slz = CodeEditorServiceSerializer(CodeEditorService, gvk_config) + manifest = slz.serialize(code_editor_service_entity) + + assert manifest == { + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "name": get_code_editor_service_name(code_editor_service_entity.app), + "labels": {"env": "dev"}, + }, + "spec": { + "selector": get_code_editor_labels(code_editor_service_entity.app), + "ports": [ + { + "name": "code-editor", + "port": 10251, + "targetPort": settings.CODE_EDITOR_PORT, + "protocol": "TCP", + }, + ], + }, + }