From 17d7f834f313b7918d52799efc2666e336a07fc2 Mon Sep 17 00:00:00 2001 From: harlee-x Date: Fri, 29 Mar 2024 18:22:24 +0800 Subject: [PATCH 1/2] Feat: init project (#1) * Feat: init project * Fix: add test env CI: change build ci * CI: add cache by pip * Docs: update readme * Docs: update readme --- .github/ISSUE_TEMPLATE/config.yml | 2 +- .github/workflows/ci-build.yml | 68 ++--- .github/workflows/unit-test.yml | 44 ++- Dockerfile | 52 ++++ Makefile | 2 + README.md | 97 ++++++- docker/bin/entrypoint.sh | 25 ++ docker/python/requirements.txt | 8 + kdp-catalog-manager.png | Bin 0 -> 48912 bytes kdp_catalog_manager/__init__.py | 2 + kdp_catalog_manager/api/__init__.py | 2 + kdp_catalog_manager/api/api_v1/__init__.py | 2 + kdp_catalog_manager/api/api_v1/api.py | 18 ++ .../api/api_v1/endpoints/__init__.py | 2 + .../api/api_v1/endpoints/catalog.py | 100 +++++++ .../api/api_v1/endpoints/catalog_app_form.py | 167 +++++++++++ .../api_v1/endpoints/catalog_app_runtime.py | 48 ++++ .../api/api_v1/endpoints/tests/__init__.py | 2 + .../api_v1/endpoints/tests/test_catalog.py | 144 ++++++++++ .../endpoints/tests/test_catalog_form.py | 166 +++++++++++ kdp_catalog_manager/common/__init__.py | 2 + kdp_catalog_manager/common/constants.py | 45 +++ kdp_catalog_manager/config/__init__.py | 2 + kdp_catalog_manager/config/base_config.py | 43 +++ kdp_catalog_manager/config/gunicorn.py | 22 ++ kdp_catalog_manager/config/log_config.py | 54 ++++ kdp_catalog_manager/domain/__init__.py | 2 + kdp_catalog_manager/domain/format/__init__.py | 2 + kdp_catalog_manager/domain/format/base.py | 7 + .../domain/format/format_catalog.py | 27 ++ .../domain/format/format_catalog_form.py | 86 ++++++ .../format_catalog_runtime_application.py | 71 +++++ .../domain/format/test/__init__.py | 2 + .../domain/format/test/test_format_catalog.py | 61 +++++ .../format/test/test_format_catalog_form.py | 92 +++++++ ...test_format_catalog_runtime_application.py | 147 ++++++++++ kdp_catalog_manager/domain/model/__init__.py | 2 + kdp_catalog_manager/domain/model/base.py | 10 + kdp_catalog_manager/domain/model/catalog.py | 21 ++ .../domain/model/catalog_app_form.py | 47 ++++ .../domain/model/catalog_app_runtime.py | 29 ++ .../domain/service/__init__.py | 2 + .../domain/service/application.py | 37 +++ kdp_catalog_manager/domain/service/catalog.py | 62 +++++ .../domain/service/catalog_form.py | 170 ++++++++++++ .../domain/service/markdown_transform_html.py | 55 ++++ .../domain/service/save_data_cache.py | 91 ++++++ .../domain/service/test/__init__.py | 2 + .../domain/service/test/test_application.py | 0 .../domain/service/test/test_catalog.py | 50 ++++ .../domain/service/test/test_catalog_form.py | 148 ++++++++++ kdp_catalog_manager/exceptions/__init__.py | 2 + kdp_catalog_manager/exceptions/err_map.json | 50 ++++ kdp_catalog_manager/exceptions/exception.py | 196 +++++++++++++ kdp_catalog_manager/main.py | 108 ++++++++ kdp_catalog_manager/modules/__init__.py | 2 + kdp_catalog_manager/modules/cache/__init__.py | 2 + kdp_catalog_manager/modules/cache/cache.py | 6 + .../modules/http_requests/__init__.py | 2 + .../http_requests/application/__init__.py | 2 + .../application/oam_application.py | 19 ++ .../application/test/__init__.py | 2 + .../application/test/test_oam_application.py | 25 ++ .../modules/http_requests/base_requests.py | 54 ++++ .../modules/http_requests/test/__init__.py | 2 + .../http_requests/test/test_base_requests.py | 55 ++++ kdp_catalog_manager/setup.py | 49 ++++ kdp_catalog_manager/test_main.py | 12 + kdp_catalog_manager/utils/__init__.py | 2 + kdp_catalog_manager/utils/dictutils.py | 22 ++ kdp_catalog_manager/utils/fileutils.py | 41 +++ kdp_catalog_manager/utils/format_return.py | 46 ++++ kdp_catalog_manager/utils/log.py | 40 +++ .../utils/markdown_to_html/__init__.py | 2 + .../utils/markdown_to_html/markdownToHtml.py | 60 ++++ .../utils/markdown_to_html/md_html.css | 259 ++++++++++++++++++ .../markdown_to_html/test_markdownToHtml.py | 40 +++ kdp_catalog_manager/utils/test_dictutils.py | 37 +++ kdp_catalog_manager/utils/test_fileutils.py | 54 ++++ kdp_catalog_manager/utils/test_yamlutils.py | 91 ++++++ kdp_catalog_manager/utils/yamlutils.py | 69 +++++ makefiles/build-image.mk | 23 ++ makefiles/const.mk | 25 ++ 83 files changed, 3678 insertions(+), 63 deletions(-) create mode 100644 Dockerfile create mode 100644 Makefile create mode 100755 docker/bin/entrypoint.sh create mode 100644 docker/python/requirements.txt create mode 100644 kdp-catalog-manager.png create mode 100644 kdp_catalog_manager/__init__.py create mode 100644 kdp_catalog_manager/api/__init__.py create mode 100644 kdp_catalog_manager/api/api_v1/__init__.py create mode 100644 kdp_catalog_manager/api/api_v1/api.py create mode 100644 kdp_catalog_manager/api/api_v1/endpoints/__init__.py create mode 100644 kdp_catalog_manager/api/api_v1/endpoints/catalog.py create mode 100644 kdp_catalog_manager/api/api_v1/endpoints/catalog_app_form.py create mode 100644 kdp_catalog_manager/api/api_v1/endpoints/catalog_app_runtime.py create mode 100644 kdp_catalog_manager/api/api_v1/endpoints/tests/__init__.py create mode 100644 kdp_catalog_manager/api/api_v1/endpoints/tests/test_catalog.py create mode 100644 kdp_catalog_manager/api/api_v1/endpoints/tests/test_catalog_form.py create mode 100644 kdp_catalog_manager/common/__init__.py create mode 100644 kdp_catalog_manager/common/constants.py create mode 100644 kdp_catalog_manager/config/__init__.py create mode 100644 kdp_catalog_manager/config/base_config.py create mode 100644 kdp_catalog_manager/config/gunicorn.py create mode 100644 kdp_catalog_manager/config/log_config.py create mode 100644 kdp_catalog_manager/domain/__init__.py create mode 100644 kdp_catalog_manager/domain/format/__init__.py create mode 100644 kdp_catalog_manager/domain/format/base.py create mode 100644 kdp_catalog_manager/domain/format/format_catalog.py create mode 100644 kdp_catalog_manager/domain/format/format_catalog_form.py create mode 100644 kdp_catalog_manager/domain/format/format_catalog_runtime_application.py create mode 100644 kdp_catalog_manager/domain/format/test/__init__.py create mode 100644 kdp_catalog_manager/domain/format/test/test_format_catalog.py create mode 100644 kdp_catalog_manager/domain/format/test/test_format_catalog_form.py create mode 100644 kdp_catalog_manager/domain/format/test/test_format_catalog_runtime_application.py create mode 100644 kdp_catalog_manager/domain/model/__init__.py create mode 100644 kdp_catalog_manager/domain/model/base.py create mode 100644 kdp_catalog_manager/domain/model/catalog.py create mode 100644 kdp_catalog_manager/domain/model/catalog_app_form.py create mode 100644 kdp_catalog_manager/domain/model/catalog_app_runtime.py create mode 100644 kdp_catalog_manager/domain/service/__init__.py create mode 100644 kdp_catalog_manager/domain/service/application.py create mode 100644 kdp_catalog_manager/domain/service/catalog.py create mode 100644 kdp_catalog_manager/domain/service/catalog_form.py create mode 100644 kdp_catalog_manager/domain/service/markdown_transform_html.py create mode 100644 kdp_catalog_manager/domain/service/save_data_cache.py create mode 100644 kdp_catalog_manager/domain/service/test/__init__.py create mode 100644 kdp_catalog_manager/domain/service/test/test_application.py create mode 100644 kdp_catalog_manager/domain/service/test/test_catalog.py create mode 100644 kdp_catalog_manager/domain/service/test/test_catalog_form.py create mode 100644 kdp_catalog_manager/exceptions/__init__.py create mode 100644 kdp_catalog_manager/exceptions/err_map.json create mode 100644 kdp_catalog_manager/exceptions/exception.py create mode 100644 kdp_catalog_manager/main.py create mode 100644 kdp_catalog_manager/modules/__init__.py create mode 100644 kdp_catalog_manager/modules/cache/__init__.py create mode 100644 kdp_catalog_manager/modules/cache/cache.py create mode 100644 kdp_catalog_manager/modules/http_requests/__init__.py create mode 100644 kdp_catalog_manager/modules/http_requests/application/__init__.py create mode 100644 kdp_catalog_manager/modules/http_requests/application/oam_application.py create mode 100644 kdp_catalog_manager/modules/http_requests/application/test/__init__.py create mode 100644 kdp_catalog_manager/modules/http_requests/application/test/test_oam_application.py create mode 100644 kdp_catalog_manager/modules/http_requests/base_requests.py create mode 100644 kdp_catalog_manager/modules/http_requests/test/__init__.py create mode 100644 kdp_catalog_manager/modules/http_requests/test/test_base_requests.py create mode 100644 kdp_catalog_manager/setup.py create mode 100644 kdp_catalog_manager/test_main.py create mode 100644 kdp_catalog_manager/utils/__init__.py create mode 100644 kdp_catalog_manager/utils/dictutils.py create mode 100644 kdp_catalog_manager/utils/fileutils.py create mode 100644 kdp_catalog_manager/utils/format_return.py create mode 100644 kdp_catalog_manager/utils/log.py create mode 100644 kdp_catalog_manager/utils/markdown_to_html/__init__.py create mode 100644 kdp_catalog_manager/utils/markdown_to_html/markdownToHtml.py create mode 100644 kdp_catalog_manager/utils/markdown_to_html/md_html.css create mode 100644 kdp_catalog_manager/utils/markdown_to_html/test_markdownToHtml.py create mode 100644 kdp_catalog_manager/utils/test_dictutils.py create mode 100644 kdp_catalog_manager/utils/test_fileutils.py create mode 100644 kdp_catalog_manager/utils/test_yamlutils.py create mode 100644 kdp_catalog_manager/utils/yamlutils.py create mode 100644 makefiles/build-image.mk create mode 100644 makefiles/const.mk diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 9e259dd..edd76e2 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -2,4 +2,4 @@ blank_issues_enabled: true contact_links: - name: Questions about: Please use one of the forums for questions or general discussions - url: https://github.com/linktimecloud/template-project/pulls \ No newline at end of file + url: https://github.com/linktimecloud/kdp-catalog-manager/issues \ No newline at end of file diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 530a4e3..294ae91 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -4,43 +4,45 @@ name: CI-Build on: - push: - branches: - - main - - release-* - pull_request: - branches: - - main - - release-* + release: + types: + - published jobs: build-docker-images: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - - name: Get the version - id: get_version - run: | - VERSION=${GITHUB_REF#refs/tags/} - echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT - - - name: Get image registry - id: get_image_registry + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + cache: 'pip' # caching pip dependencies + - run: | + python -m pip install --upgrade pip + pip install pytest pytest-cov + if [ -f docker/python/requirements.txt ]; then pip install -r docker/python/requirements.txt; fi + + - name: Docker Login + uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 + with: + username: ${{ secrets.REG_USER }} + password: ${{ secrets.REG_PASSWD }} + + - name: Get Version + id: pversion run: | - echo "IMG_REGISTRY=${{ secrets.NX_ALIYUN_REGISTRY }}" >> $GITHUB_OUTPUT - -# - name: Docker Login -# uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 -# with: -# registry: ${{ secrets.NX_ALIYUN_REGISTRY }} -# username: ${{ secrets.NX_ALIYUN_USERNAME }} -# password: ${{ secrets.NX_ALIYUN_PASSWORD }} - -# - name: Build Images -# run: make docker-build-apiserver IMG_REGISTRY=${{ steps.get_image_registry.outputs.IMG_REGISTRY }} VERSION=${{ steps.get_version.outputs.VERSION }} -# -# - name: Push Images -# run: make docker-push-apiserver IMG_REGISTRY=${{ steps.get_image_registry.outputs.IMG_REGISTRY }} VERSION=${{ steps.get_version.outputs.VERSION }} - - + if [[ "${{ github.ref_name }}" =~ ^[.\|v][0-9]{1,}.[0-9]{1,}[.\|-][0-9]{1,} ]];then + VERSION=${{ github.ref_name }} + else + VERSION="latest" + fi + echo "git_revision=$VERSION" >> $GITHUB_OUTPUT + + - name: Build Images + id: build + run: make docker-build IMG_REGISTRY=${{ secrets.CONTAINER_REGISTRY }} VERSION=${{ steps.pversion.outputs.git_revision }} + + - name: Push Images + run: make docker-push IMG_REGISTRY=${{ secrets.CONTAINER_REGISTRY }} VERSION=${{ steps.pversion.outputs.git_revision }} diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 7c1a7c5..174573b 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -3,8 +3,7 @@ name: Unit-Test on: push: branches: - - main - - release-* + - "*" workflow_dispatch: {} pull_request: branches: @@ -14,15 +13,11 @@ on: permissions: contents: read -env: - # Common versions - GO_VERSION: "1.21" - jobs: detect-noop: permissions: actions: write # for fkirc/skip-duplicate-actions to skip or stop workflow runs - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest outputs: noop: ${{ steps.noop.outputs.should_skip }} steps: @@ -36,27 +31,22 @@ jobs: continue-on-error: true unit-tests: - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest needs: detect-noop if: needs.detect-noop.outputs.noop != 'true' - steps: - - name: Set up Go - uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 + - uses: actions/checkout@v3 + - name: Set up Python 3.10 + uses: actions/setup-python@v3 with: - go-version: ${{ env.GO_VERSION }} - - - name: Check out code into the Go module directory - uses: actions/checkout@cd7d8d697e10461458bc61a30d094dc601a8b017 - with: - submodules: true - - - name: Cache Go Dependencies - uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 - with: - path: .work/pkg - key: ${{ runner.os }}-pkg-${{ hashFiles('**/go.sum') }} - restore-keys: ${{ runner.os }}-pkg- - - # - name: Run Make test - # run: make test + python-version: "3.10" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest pytest-cov + if [ -f docker/python/requirements.txt ]; then pip install -r docker/python/requirements.txt; fi + + - name: Test with pytest + run: | + export Test=true + pytest --cov --cov-report term --cov-report xml:coverage.xml \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7aa4648 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,52 @@ +FROM python:3.10.13-bullseye AS builder + +WORKDIR /workspace + +ENV VIRTUAL_ENV=/opt/venv +RUN python3 -m venv ${VIRTUAL_ENV} +ENV PATH="${VIRTUAL_ENV}/bin:$PATH" + +# Install Python dependencies +COPY docker/python/requirements.txt . +COPY docker/bin/entrypoint.sh . +#COPY version.py . +COPY kdp_catalog_manager kdp_catalog_manager/ +RUN ${VIRTUAL_ENV}/bin/pip3 install --no-cache-dir --upgrade pip setuptools Cython==3.0.8 \ + && ${VIRTUAL_ENV}/bin/pip3 install --no-cache-dir --upgrade --force-reinstall -r requirements.txt \ + && rm -f requirements.txt \ + && cd kdp_catalog_manager && python setup.py build_ext --inplace \ + && ${VIRTUAL_ENV}/bin/python setup.py build_ext clean -a && rm -f setup.py && rm -rf build + + +FROM python:3.10.13-bullseye + +ENV TZ=${TZ:-Asia/Shanghai} +ARG RUNTIME_HOME +ENV RUNTIME_HOME=${RUNTIME_HOME:-/opt/bdos/kdp/bdos-core} +ENV BDOS_USER=${BDOS_USER:-bdos} +ENV BDOS_USER_HOME=${BDOS_USER_HOME:-/home/${BDOS_USER}} + + +WORKDIR ${RUNTIME_HOME} + +RUN apt-get update \ + && apt-get -y install sudo \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* \ + && cp /usr/share/zoneinfo/${TZ} /etc/localtime \ + && echo ${TZ} > /etc/timezone \ + && adduser -q --disabled-password --shell /bin/bash ${BDOS_USER} \ + && echo "${BDOS_USER} ALL=(root) NOPASSWD:ALL" > /etc/sudoers.d/${BDOS_USER} \ + && chmod 0440 /etc/sudoers.d/${BDOS_USER} \ + && chown -R ${BDOS_USER}:${BDOS_USER} ${BDOS_USER_HOME} \ + && mkdir ${RUNTIME_HOME}/logs \ + && chown -R ${BDOS_USER}:${BDOS_USER} ${RUNTIME_HOME} + +ENV PYTHONPATH=${RUNTIME_HOME} +ENV VIRTUAL_ENV=/opt/venv +ENV PATH="${VIRTUAL_ENV}/bin:$PATH" +COPY --chown=${BDOS_USER}:${BDOS_USER} --from=builder /workspace . +COPY --chown=${BDOS_USER}:${BDOS_USER} --from=builder /opt/venv /opt/venv + +USER ${BDOS_USER} +CMD ["/bin/sh", "-c", "${RUNTIME_HOME}/entrypoint.sh"] \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8b156a6 --- /dev/null +++ b/Makefile @@ -0,0 +1,2 @@ +include makefiles/const.mk +include makefiles/build-image.mk diff --git a/README.md b/README.md index 00fafa3..dab4336 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,95 @@ -# template-project -This is the template project for initializing other LTC projects. +[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) +![Tests](https://github.com/linktimecloud/kdp-catalog-manager/actions/workflows/unit-test.yml/badge.svg) +![Build](https://github.com/linktimecloud/kdp-catalog-manager/actions/workflows/ci-build.yml/badge.svg) +![](https://img.shields.io/badge/python-3.10.13-green) +![](https://img.shields.io/badge/fastapi-0.110.0-green) +![image version](https://img.shields.io/docker/v/linktimecloud/kdp-catalog-manager) +![image size](https://img.shields.io/docker/image-size/linktimecloud/kdp-catalog-manager) + + +# KDP Catalog Manager + +## 项目描述 + +### 项目概述 +KDP Catalog Manager是一套大数据应用管理平台。基于应用功能进行分类查看、管理,降低应用管理的复杂度,从而使大数据管理人员更专注于数据的处理 + +### 核心技术架构 +![kdp-catalog-manager](kdp-catalog-manager.png) + + + +### 功能模块描述 + +#### api +* view +定义 Restful API 并对用户输入参数执行基本验证及结果输出 + +#### Domain +* service +业务逻辑 +* format +数据转换层,用于缓存数据与业务数据转换 +* model +数据模型实体 + + +##### Modules +* cache +数据存储层,静态数据存储于缓存中 +* requests +外部数据调用,调用外部服务获取数据 + + +## 目录结构 +```shell +├── CODEOWNERS +├── README.md +└── kdp_catalog_manager +   ├── api +   ├── common +   ├── config +   ├── main.py # 服务启动程序 +   ├── requirements.txt +   ├── test_main.py +   ├── domain +   ├── modules +   └── utils +``` + +## 启动方式 +### 开发环境搭建 +* 使用python3.10+ + +1. 克隆代码至本地 +```shell +git clone xxx && cd kdp-catalog-manager +``` + +2. 使用虚拟环境 +```shell +#安装virtualenv +pip install virtualenv +virtualenv -p /usr/local/bin/python3 venv +# 激活虚拟环境 +source ./venv/bin/activate + +# 关闭虚拟环境 +deactivate +``` + +3. 安装依赖 +```shell +pip install -r docker/python/requirements.txt +``` + +4. 服务启动 +```shell +cd ~/kdp-catalog-manager \ +&& export PYTHONPATH=$PYTHONPATH:$(pwd) +python kdp_catalog_manager/main.py + +``` + +### API 手册 +* 启动服务后通过 http://127.0.0.1:8000/docs 查看提供的接口列表 \ No newline at end of file diff --git a/docker/bin/entrypoint.sh b/docker/bin/entrypoint.sh new file mode 100755 index 0000000..0ad362d --- /dev/null +++ b/docker/bin/entrypoint.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +declare RUNTIME_HOME=${RUNTIME_HOME:-"/opt/bdos/kdp/bdos-core"} +declare BDOS_USER=${BDOS_USER:-bdos} +declare CHOWN_HOST_PATH=${CHOWN_HOST_PATH:-"False"} +declare LOG_LEVEL=${LOG_LEVEL:-"info"} +declare WORKER_NUM=${WORKER_NUM:-4} +declare PARAMS_LIMIT=${PARAMS_LIMIT:-0} +declare MAX_REQUESTS=${MAX_REQUESTS:-100} +declare MAX_REQUESTS_JITTER=${MAX_REQUESTS:-100} + + +sudo chown ${BDOS_USER}:${BDOS_USER} ${RUNTIME_HOME} >/dev/null 2>&1 + +if [[ "${CHOWN_HOST_PATH}" == "True" ]];then + echo -e "### Change owner of $RUNTIME_HOME ###" + sudo chown -R ${BDOS_USER}:${BDOS_USER} ${RUNTIME_HOME}/kdp_catalog_manager ${RUNTIME_HOME}/logs ${RUNTIME_HOME} >/dev/null 2>&1 + echo -e "### Change owner return code: $? ###" +fi + + +echo -e "### Starting Server... ###" +cd ${RUNTIME_HOME} + +gunicorn -c kdp_catalog_manager/config/gunicorn.py kdp_catalog_manager.main:app --workers ${WORKER_NUM} --log-level ${LOG_LEVEL} --limit-request-line=${PARAMS_LIMIT} --max-requests ${MAX_REQUESTS} --max-requests-jitter ${MAX_REQUESTS_JITTER} --preload diff --git a/docker/python/requirements.txt b/docker/python/requirements.txt new file mode 100644 index 0000000..76ca52f --- /dev/null +++ b/docker/python/requirements.txt @@ -0,0 +1,8 @@ +fastapi[all]==0.110.0 +uvicorn[standard]==0.27.1 +cachelib==0.12.0 +concurrent-log-handler==0.9.25 +cachelib==0.12.0 +markdown==3.5.2 +prometheus-fastapi-instrumentator==6.1.0 +gunicorn==21.2.0 \ No newline at end of file diff --git a/kdp-catalog-manager.png b/kdp-catalog-manager.png new file mode 100644 index 0000000000000000000000000000000000000000..17b487409627642d987d67695a1f0cec54d345d2 GIT binary patch literal 48912 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 literal 0 HcmV?d00001 diff --git a/kdp_catalog_manager/__init__.py b/kdp_catalog_manager/__init__.py new file mode 100644 index 0000000..ff6ea1e --- /dev/null +++ b/kdp_catalog_manager/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- diff --git a/kdp_catalog_manager/api/__init__.py b/kdp_catalog_manager/api/__init__.py new file mode 100644 index 0000000..ff6ea1e --- /dev/null +++ b/kdp_catalog_manager/api/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- diff --git a/kdp_catalog_manager/api/api_v1/__init__.py b/kdp_catalog_manager/api/api_v1/__init__.py new file mode 100644 index 0000000..ff6ea1e --- /dev/null +++ b/kdp_catalog_manager/api/api_v1/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- diff --git a/kdp_catalog_manager/api/api_v1/api.py b/kdp_catalog_manager/api/api_v1/api.py new file mode 100644 index 0000000..601fd85 --- /dev/null +++ b/kdp_catalog_manager/api/api_v1/api.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +from fastapi import APIRouter + +from kdp_catalog_manager.api.api_v1.endpoints import catalog +from kdp_catalog_manager.api.api_v1.endpoints import catalog_app_form +from kdp_catalog_manager.api.api_v1.endpoints import catalog_app_runtime + +api_router = APIRouter() + +# 分组路由 +api_router.include_router(catalog.router, tags=["Catalog"]) +api_router.include_router( + catalog_app_form.router, tags=["CatalogAppForm"] +) +api_router.include_router( + catalog_app_runtime.router, tags=["CatalogRuntime"] +) diff --git a/kdp_catalog_manager/api/api_v1/endpoints/__init__.py b/kdp_catalog_manager/api/api_v1/endpoints/__init__.py new file mode 100644 index 0000000..ff6ea1e --- /dev/null +++ b/kdp_catalog_manager/api/api_v1/endpoints/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- diff --git a/kdp_catalog_manager/api/api_v1/endpoints/catalog.py b/kdp_catalog_manager/api/api_v1/endpoints/catalog.py new file mode 100644 index 0000000..c3197f8 --- /dev/null +++ b/kdp_catalog_manager/api/api_v1/endpoints/catalog.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +from typing import Annotated + +from fastapi import APIRouter, Path, Response, status, Header +from fastapi.responses import HTMLResponse + +from kdp_catalog_manager.common.constants import CATALOG_DESC, \ + NOT_FOUND_README_HTML +from kdp_catalog_manager.config.base_config import DEFAULT_LANG, SUPPORT_LANG +from kdp_catalog_manager.domain.model.catalog import CatalogList, \ + CatalogDataOut +from kdp_catalog_manager.domain.service.catalog import CatalogController +from kdp_catalog_manager.exceptions.exception import KdpCatalogManagerError, \ + FileNotExistsError, LangNotSupport +from kdp_catalog_manager.utils.format_return import FormatReturn + +router = APIRouter() + + +@router.get("/catalogs", + response_model=CatalogList, + summary="获取应用目录列表", + description="获取应用目录列表操作") +async def read_catalogs( + accept_language: Annotated[str, Header(description="请求语言类型")] = DEFAULT_LANG +): + """获取catalog 列表""" + try: + if accept_language not in SUPPORT_LANG: + raise LangNotSupport( + f"Header Accept-Language is:{accept_language}, " + f"not in {SUPPORT_LANG}") + data = CatalogController(lang=accept_language).get_catalogs() + rtn = FormatReturn().format_return_json( + data, msg="get catalogs data success") + except KdpCatalogManagerError as error: + error_info = FormatReturn().format_error_info( + error.error_name, + error.error_details, + error_msg=error.error_cname + ) + rtn = FormatReturn().format_return_json( + [], status=1, msg=error.error_cname, error_info=error_info) + return rtn + + +@router.get("/catalogs/{catalog}", + response_model=CatalogDataOut, + summary="获取应用目录信息", + description="获取某个应用目录信息操作") +async def read_catalog( + catalog: Annotated[str, Path(description=CATALOG_DESC)], + accept_language: Annotated[str, Header(description="请求语言类型")] = DEFAULT_LANG +): + """获取catalog 信息""" + try: + if accept_language not in SUPPORT_LANG: + raise LangNotSupport( + f"Header Accept-Language is:{accept_language}, " + f"not in {SUPPORT_LANG}") + data = CatalogController( + catalog=catalog, lang=accept_language).get_catalog() + rtn = FormatReturn().format_return_json( + data, msg="get catalogs data success") + except KdpCatalogManagerError as error: + error_info = FormatReturn().format_error_info( + error.error_name, + error.error_details, + error_msg=error.error_cname + ) + rtn = FormatReturn().format_return_json( + {}, status=1, msg=error.error_cname, error_info=error_info) + return rtn + + +@router.get("/catalogs/{catalog}/readme", + response_class=HTMLResponse, + summary="获取应用目录说明", + description="获取某个应用目录说明操作") +async def read_catalog_readme( + response: Response, + catalog: Annotated[str, Path(description=CATALOG_DESC)], + accept_language: Annotated[str, Header(description="请求语言类型")] = DEFAULT_LANG +): + """获取catalog 说明""" + try: + if accept_language not in SUPPORT_LANG: + raise LangNotSupport( + f"Header Accept-Language is:{accept_language}, " + f"not in {SUPPORT_LANG}") + rtn = CatalogController( + catalog, lang=accept_language).get_catalog_readme() + except FileNotExistsError: + response.status_code = status.HTTP_404_NOT_FOUND + rtn = NOT_FOUND_README_HTML + except LangNotSupport: + response.status_code = status.HTTP_400_BAD_REQUEST + rtn = "语言不支持:Header Accept-Language is:ens, not in ['zh', 'en']" + return rtn diff --git a/kdp_catalog_manager/api/api_v1/endpoints/catalog_app_form.py b/kdp_catalog_manager/api/api_v1/endpoints/catalog_app_form.py new file mode 100644 index 0000000..83d67da --- /dev/null +++ b/kdp_catalog_manager/api/api_v1/endpoints/catalog_app_form.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +from typing import Annotated + +from fastapi import APIRouter, Path, Query, Header, status, Response +from fastapi.responses import HTMLResponse + +from kdp_catalog_manager.common.constants import CATALOG_DESC, FORM_DESC, \ + ORG_DESC, BDC_DESC, NOT_FOUND_README_HTML +from kdp_catalog_manager.config.base_config import DEFAULT_LANG, SUPPORT_LANG +from kdp_catalog_manager.domain.model.catalog_app_form import CatalogFormList, \ + CatalogForm, CatalogFormInstall +from kdp_catalog_manager.domain.model.catalog_app_form import \ + Response as CatalogFormData +from kdp_catalog_manager.domain.service.catalog_form import \ + CatalogFormController +from kdp_catalog_manager.exceptions.exception import LangNotSupport, \ + FileNotExistsError, KdpCatalogManagerError +from kdp_catalog_manager.utils.format_return import FormatReturn + +router = APIRouter() + + +# +@router.get("/catalogs/{catalog}/app_forms", + response_model=CatalogFormList, + summary="获取应用模板列表", + description="获取应用模板列表操作") +async def read_catalog_forms( + catalog: Annotated[str, Path(description=CATALOG_DESC)], + accept_language: + Annotated[str, Header(description="请求语言类型")] = DEFAULT_LANG +): + """获取catalog 应用模板列表""" + try: + if accept_language not in SUPPORT_LANG: + raise LangNotSupport( + f"Header Accept-Language is:{accept_language}, " + f"not in {SUPPORT_LANG}") + data = CatalogFormController( + catalog=catalog, lang=accept_language + ).get_catalogs_forms() + rtn = FormatReturn().format_return_json( + data, msg="get catalog form templates success") + except KdpCatalogManagerError as error: + error_info = FormatReturn().format_error_info( + error.error_name, + error.error_details, + error_msg=error.error_cname + ) + rtn = FormatReturn().format_return_json( + [], status=1, msg=error.error_cname, error_info=error_info) + return rtn + + +@router.get("/catalogs/{catalog}/app_forms/{form}", + response_model=CatalogForm, + summary="获取应用模板信息", + description="获取某个应用模板信息操作") +async def read_catalog_form( + catalog: Annotated[str, Path(description=CATALOG_DESC)], + form: Annotated[str, Path(description=FORM_DESC)], + accept_language: + Annotated[str, Header(description="请求语言类型")] = DEFAULT_LANG +): + """获取catalog 应用模板信息""" + try: + if accept_language not in SUPPORT_LANG: + raise LangNotSupport( + f"Header Accept-Language is:{accept_language}, " + f"not in {SUPPORT_LANG}") + data = CatalogFormController( + catalog=catalog, app_form=form, lang=accept_language + ).get_catalogs_form() + rtn = FormatReturn().format_return_json( + data, msg="get catalog form template success") + except KdpCatalogManagerError as error: + error_info = FormatReturn().format_error_info( + error.error_name, + error.error_details, + error_msg=error.error_cname + ) + rtn = FormatReturn().format_return_json( + {}, status=1, msg=error.error_cname, error_info=error_info) + return rtn + + +@router.get("/catalogs/{catalog}/app_forms/{form}/data", + response_model=CatalogFormData, + summary="获取应用模板信息", + description="获取某个应用模板信息操作") +async def read_catalog_form_data( + catalog: Annotated[str, Path(description=CATALOG_DESC)], + form: Annotated[str, Path(description=FORM_DESC)], +): + """获取catalog 应用模板数据""" + try: + data = CatalogFormController( + catalog=catalog, app_form=form + ).get_catalog_form_data() + rtn = FormatReturn().format_return_json( + data, msg="get catalog form data success") + except KdpCatalogManagerError as error: + error_info = FormatReturn().format_error_info( + error.error_name, + error.error_details, + error_msg=error.error_cname + ) + rtn = FormatReturn().format_return_json( + {}, status=1, msg=error.error_cname, error_info=error_info) + return rtn + + +@router.get("/catalogs/{catalog}/app_forms/{form}/install", + response_model=CatalogFormInstall, + summary="获取应用模板应用安装信息", + description="获取某个应用模板应用安装信息操作") +async def read_catalog_form_install( + catalog: Annotated[str, Path(description=CATALOG_DESC)], + form: Annotated[str, Path(description=FORM_DESC)], + org: Annotated[str, Query(description=ORG_DESC)] = None, + bdc: Annotated[str, Query(description=BDC_DESC)] = None +): + """获取catalog 应用模板安装情况""" + try: + data = CatalogFormController( + catalog=catalog, app_form=form + ).get_catalog_forms_install(org, bdc) + rtn = FormatReturn().format_return_json( + data, msg="get catalog form install data success") + except KdpCatalogManagerError as error: + error_info = FormatReturn().format_error_info( + error.error_name, + error.error_details, + error_msg=error.error_cname + ) + rtn = FormatReturn().format_return_json( + [], status=1, msg=error.error_cname, error_info=error_info) + return rtn + + +@router.get("/catalogs/{catalog}/app_forms/{form}/readme", + response_class=HTMLResponse, + summary="获取应用模板使用说明信息", + description="获取某个应用模板使用说明信息操作") +async def read_catalog_form_readme( + response: Response, + catalog: Annotated[str, Path(description=CATALOG_DESC)], + form: Annotated[str, Path(description=FORM_DESC)], + accept_language: + Annotated[str, Header(description="请求语言类型")] = DEFAULT_LANG +): + """获取应用说明文档""" + try: + if accept_language not in SUPPORT_LANG: + raise LangNotSupport( + f"Header Accept-Language is:{accept_language}, " + f"not in {SUPPORT_LANG}") + rtn = CatalogFormController( + catalog, form, lang=accept_language).get_catalog_form_readme() + except FileNotExistsError: + response.status_code = status.HTTP_404_NOT_FOUND + rtn = NOT_FOUND_README_HTML + except LangNotSupport: + response.status_code = status.HTTP_400_BAD_REQUEST + rtn = "语言不支持:Header Accept-Language is:ens, not in ['zh', 'en']" + return rtn diff --git a/kdp_catalog_manager/api/api_v1/endpoints/catalog_app_runtime.py b/kdp_catalog_manager/api/api_v1/endpoints/catalog_app_runtime.py new file mode 100644 index 0000000..14fd18c --- /dev/null +++ b/kdp_catalog_manager/api/api_v1/endpoints/catalog_app_runtime.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +from fastapi import APIRouter, Query +from typing import Annotated + +from kdp_catalog_manager.common.constants import (CATALOG_DESC, + FORM_DESC, BDC_DESC) +from kdp_catalog_manager.domain.model.catalog_app_runtime import CatalogAppList +from kdp_catalog_manager.domain.service.application import ApplicationController +from kdp_catalog_manager.utils.format_return import FormatReturn +from kdp_catalog_manager.exceptions.exception import KdpCatalogManagerError + + +router = APIRouter() + + +@router.get("/catalogs/app_forms/apps", + response_model=CatalogAppList, + summary="获取应用列表信息", + description="获取应用列表信息操作") +async def read_catalog_runtimes( + catalog: Annotated[str, Query(description=CATALOG_DESC)] = None, + form: Annotated[str, Query(description=FORM_DESC)] = None, + bdc: Annotated[str, Query(description=BDC_DESC)] = None, + labelSelector: Annotated[ + str, Query( + description="A selector to restrict the list of " + "returned objects by their labels. " + "Defaults to everything." + ) + ] = None, +): + """获取catalog 运行态应用列表""" + try: + data = ApplicationController( + bdc=bdc, catalog=catalog, app_form=form + ).get_applications(labelSelector) + rtn = FormatReturn().format_return_json( + data, msg="get catalog form data success") + except KdpCatalogManagerError as error: + error_info = FormatReturn().format_error_info( + error.error_name, + error.error_details, + error_msg=error.error_cname + ) + rtn = FormatReturn().format_return_json( + [], status=1, msg=error.error_cname, error_info=error_info) + return rtn diff --git a/kdp_catalog_manager/api/api_v1/endpoints/tests/__init__.py b/kdp_catalog_manager/api/api_v1/endpoints/tests/__init__.py new file mode 100644 index 0000000..ff6ea1e --- /dev/null +++ b/kdp_catalog_manager/api/api_v1/endpoints/tests/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- diff --git a/kdp_catalog_manager/api/api_v1/endpoints/tests/test_catalog.py b/kdp_catalog_manager/api/api_v1/endpoints/tests/test_catalog.py new file mode 100644 index 0000000..ad6ddc4 --- /dev/null +++ b/kdp_catalog_manager/api/api_v1/endpoints/tests/test_catalog.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +import os.path + +from fastapi.testclient import TestClient +from unittest import TestCase +from kdp_catalog_manager.main import app +from kdp_catalog_manager.modules.cache.cache import cache_instance +from kdp_catalog_manager.common.constants import CATALOG_KEY +from kdp_catalog_manager.exceptions.exception import LangNotSupport + + +class TestCatalogApi(TestCase): + def setUp(self): + self.client = TestClient(app=app) + self.catalog_data = { + "mysql": { + "name": "mysql", + "category": "系统/大数据开发工具", + "description": "mysql", + "i18n": { + "en": { + "category": "system.dataManagement", + "description": "mysql" + } + } + } + } + self.catalog_readme_file = "readme/catalog/mysql/i18n/zh/README.html" + cache_instance.set(CATALOG_KEY, self.catalog_data) + self.get_catalogs_url = "/api/v1/catalogs" + self.get_catalogs_mysql_url = "/api/v1/catalogs/mysql" + + def test_get_catalogs(self): + response = self.client.get(self.get_catalogs_url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json().get("status"), 0) + catalogs_data = response.json().get("data") + catalogs_data_keys = catalogs_data[0].keys() + self.assertEqual( + list(catalogs_data_keys), + ["name", "description", "category"] + ) + + def test_get_catalogs_en(self): + response = self.client.get( + self.get_catalogs_url, headers={"Accept-Language": "en"}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json().get("status"), 0) + catalogs_data = response.json().get("data") + catalogs_data_keys = catalogs_data[0].keys() + self.assertEqual( + list(catalogs_data_keys), + ["name", "description", "category"] + ) + + def test_get_catalogs_not_found_lang(self): + response = self.client.get(self.get_catalogs_url, + headers={"Accept-Language": "zhs"}) + self.assertEqual(response.json().get("status"), 1) + self.assertEqual(response.status_code, 200) + + def test_get_catalogs_data_is_None(self): + self.catalog_data = None + cache_instance.set(CATALOG_KEY, self.catalog_data) + response = self.client.get(self.get_catalogs_url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json().get("status"), 1) + + def test_get_catalog(self): + response = self.client.get(self.get_catalogs_mysql_url) + self.assertEqual(response.status_code, 200) + catalogs_data = response.json().get("data") + catalogs_data_keys = catalogs_data.keys() + self.assertEqual( + list(catalogs_data_keys), + ["name", "description", "category"] + ) + + def test_get_catalog_data_is_None(self): + self.catalog_data = None + cache_instance.set(CATALOG_KEY, self.catalog_data) + response = self.client.get(self.get_catalogs_mysql_url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json().get("status"), 1) + + def test_get_catalog_en(self): + response = self.client.get(self.get_catalogs_mysql_url, + headers={"Accept-Language": "en"}) + self.assertEqual(response.status_code, 200) + catalogs_data = response.json().get("data") + catalogs_data_keys = catalogs_data.keys() + self.assertEqual( + list(catalogs_data_keys), + ["name", "description", "category"] + ) + + def test_get_catalog_not_found_lang(self): + response = self.client.get(self.get_catalogs_mysql_url, + headers={"Accept-Language": "ens"}) + self.assertEqual(response.status_code, 200) + + def test_catalog_readme(self): + response = self.client.get("/api/v1/catalogs/mysql/readme") + self.assertEqual(response.status_code, 404) + self.assertEqual( + response.headers.get("content-type"), + "text/html; charset=utf-8" + ) + + def test_catalog_readme_not_found(self): + response = self.client.get("/api/v1/catalogs/mysql1/readme") + self.assertEqual(response.status_code, 404) + self.assertEqual( + response.headers.get("content-type"), + "text/html; charset=utf-8" + ) + + def test_catalog_readme_not_found_lang(self): + response = self.client.get("/api/v1/catalogs/mysql1/readme", + headers={"Accept-Language": "ens"}) + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.text, + "语言不支持:Header Accept-Language is:ens, not in ['zh', 'en']" + ) + + def test_catalog_readme_catalog(self): + response = self.client.get("/api/v1/catalogs//readme") + self.assertEqual(response.status_code, 404) + self.assertEqual( + response.headers.get("content-type"), + "application/json" + ) + + def test_catalog_readme_catalog_unsupport_lanf(self): + try: + self.client.get("/api/v1/catalogs/mysql/readme", + headers={"Accept-Language": "ens"}) + except LangNotSupport: + self.assertEqual(True, True) + + def tearDown(self): + cache_instance.clear() diff --git a/kdp_catalog_manager/api/api_v1/endpoints/tests/test_catalog_form.py b/kdp_catalog_manager/api/api_v1/endpoints/tests/test_catalog_form.py new file mode 100644 index 0000000..57c454c --- /dev/null +++ b/kdp_catalog_manager/api/api_v1/endpoints/tests/test_catalog_form.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +import os.path + +from fastapi.testclient import TestClient +from unittest import TestCase +from kdp_catalog_manager.main import app +from kdp_catalog_manager.modules.cache.cache import cache_instance +from kdp_catalog_manager.common.constants import CATALOG_FORM_KEY +from kdp_catalog_manager.exceptions.exception import KdpCatalogManagerError + + +class TestCatalogFormApi(TestCase): + def setUp(self): + self.client = TestClient(app=app) + self.catalog_form_metadata = { + "mysql": { + "mysql": { + "category": "系统/大数据开发工具", + "description": "mysql", + "i18n": { + "en": { + "category": "system.dataManagement", + "description": "mysql" + } + } + } + } + } + self.catalog_form_data = { + "apiVersion": "bdc.bdos.io/v1alpha1", + "kind": "Application" + } + self.catalog_from_readme_file = "readme/form/mysql/i18n/zh/README.html" + cache_instance.set(CATALOG_FORM_KEY, self.catalog_form_metadata) + self.get_catalogs_forms_url = "/api/v1/catalogs/mysql/app_forms" + self.get_catalogs_forms_mysql_url = "/api/v1/catalogs/mysql/app_forms/mysql" + self.get_catalog_form_mysql_data = "/api/v1/catalogs/mysql/app_forms/mysql/data" + self.rt_key_list = ["name", "version", "alias", "invisible", + "isGlobal", "description", "catalog", "dashboardUrl"] + + def test_get_catalogs_froms(self): + response = self.client.get(self.get_catalogs_forms_url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json().get("status"), 0) + catalogs_data = response.json().get("data") + catalogs_data_keys = catalogs_data[0].keys() + self.assertEqual( + list(catalogs_data_keys), + self.rt_key_list + ) + + def test_get_catalogs_forms_en(self): + response = self.client.get( + self.get_catalogs_forms_url, headers={"Accept-Language": "en"}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json().get("status"), 0) + catalogs_data = response.json().get("data") + catalogs_data_keys = catalogs_data[0].keys() + self.assertEqual( + list(catalogs_data_keys), + self.rt_key_list + ) + + def test_get_catalogs_forms_not_found_lang(self): + response = self.client.get(self.get_catalogs_forms_url, + headers={"Accept-Language": "zhs"}) + self.assertEqual(response.json().get("status"), 1) + self.assertEqual(response.status_code, 200) + + def test_get_catalogs_forms_data_is_None(self): + self.catalog_data = None + cache_instance.set(CATALOG_FORM_KEY, self.catalog_data) + response = self.client.get(self.get_catalogs_forms_url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json().get("status"), 1) + + def test_get_catalog_form(self): + response = self.client.get(self.get_catalogs_forms_mysql_url) + self.assertEqual(response.status_code, 200) + catalogs_data = response.json().get("data") + catalogs_data_keys = catalogs_data.keys() + self.assertEqual( + list(catalogs_data_keys), + self.rt_key_list + ) + + def test_get_catalog_form_data_is_None(self): + self.catalog_data = None + cache_instance.set(CATALOG_FORM_KEY, self.catalog_data) + response = self.client.get(self.get_catalogs_forms_mysql_url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json().get("status"), 1) + + def test_get_catalog_form_en(self): + response = self.client.get(self.get_catalogs_forms_mysql_url, + headers={"Accept-Language": "en"}) + self.assertEqual(response.status_code, 200) + catalogs_data = response.json().get("data") + catalogs_data_keys = catalogs_data.keys() + self.assertEqual( + list(catalogs_data_keys), + self.rt_key_list + ) + + def test_get_catalog_form_not_found_lang(self): + response = self.client.get(self.get_catalogs_forms_mysql_url, + headers={"Accept-Language": "ens"}) + self.assertEqual(response.status_code, 200) + + def test_get_catalog_form_data(self): + cache_instance.set("mysql-mysql-data", self.catalog_form_data) + response = self.client.get(self.get_catalog_form_mysql_data) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json().get("data"), + self.catalog_form_data + ) + + # 缓存数据不存在 + cache_instance.delete("mysql-mysql-data") + response = self.client.get(self.get_catalog_form_mysql_data) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json().get("status"), 0) + + # url 异常 + response = self.client.get("/api/v1/catalogs//app_forms//data") + self.assertEqual(response.status_code, 404) + + def test_catalog_form_readme(self): + response = self.client.get("/api/v1/catalogs/mysql/app_forms/mysql/readme") + self.assertEqual(response.status_code, 404) + self.assertEqual(response.headers.get("content-type"), + "text/html; charset=utf-8" + ) + + def test_catalog_form_readme_not_found(self): + response = self.client.get( + "/api/v1/catalogs/mysql/app_forms/mysql1/readme", + headers={"Accept-Language": "zh"}) + self.assertEqual(response.status_code, 404) + self.assertEqual( + response.headers.get("content-type"), + "text/html; charset=utf-8" + ) + + def test_catalog_form_readme_not_found_lang(self): + response = self.client.get( + "/api/v1/catalogs/mysql/app_forms/mysql/readme", + headers={"Accept-Language": "ens"}) + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.text, + "语言不支持:Header Accept-Language is:ens, not in ['zh', 'en']" + ) + + def test_catalog_form_readme_catalog(self): + response = self.client.get("/api/v1/catalogs/mysql/app_forms//readme") + self.assertEqual(response.status_code, 404) + self.assertEqual( + response.headers.get("content-type"), + "application/json" + ) + + def tearDown(self): + cache_instance.clear() diff --git a/kdp_catalog_manager/common/__init__.py b/kdp_catalog_manager/common/__init__.py new file mode 100644 index 0000000..ff6ea1e --- /dev/null +++ b/kdp_catalog_manager/common/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- diff --git a/kdp_catalog_manager/common/constants.py b/kdp_catalog_manager/common/constants.py new file mode 100644 index 0000000..81cc469 --- /dev/null +++ b/kdp_catalog_manager/common/constants.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- + +CATALOG_DESC = "应用目录名称" +FORM_DESC = "应用模板名称" +ORG_DESC = "机构名称" +BDC_DESC = "大数据集群名称" +LANG_DESC = "语言类型" + +METADATA_YAML = "metadata.yaml" +APP_YAML = "app.yaml" +README = "README.md" +I18N = "i18n" +README_HTML = "README.html" + +# cache key +CATALOG_KEY = "catalog_info" +CATALOG_FORM_KEY = "catalog_form" +# example: catalog-form +CATALOG_FROM_DATA_KEY = "{}-{}-data" + + +NOT_FOUND_README_HTML = """ + + + + 404 Not Found + + + +

404 Not Found

+

The resource could not be found.

+ + + + """ + + +# HTTP +HTTP_HEADER = { + "Accept": "application/json; charset=utf-8" +} +RESPONSE_NORMAL_CODE = 200 +RESPONSE_NOT_FOUND_CODE = 404 +RESPONSE_DATA = "data" diff --git a/kdp_catalog_manager/config/__init__.py b/kdp_catalog_manager/config/__init__.py new file mode 100644 index 0000000..ff6ea1e --- /dev/null +++ b/kdp_catalog_manager/config/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- diff --git a/kdp_catalog_manager/config/base_config.py b/kdp_catalog_manager/config/base_config.py new file mode 100644 index 0000000..1395f87 --- /dev/null +++ b/kdp_catalog_manager/config/base_config.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +import os + + +CACHE_EXPIRE = 60*60*24*365*10 + + +CATALOG_DIR = "catalog" +APPS_DIR = "apps" + +CATALOG_README_DIR = "readme/catalog" +CATALOG_FROM_README_DIR = "readme/form" + + +DEFAULT_LANG = "zh" +SUPPORT_LANG = (os.environ.get("SUPPORT_LANG") or "zh,en").split(",") + +# httpx connect other server setting +HTTP_TIME_OUT = int(os.environ.get("HTTP_TIME_OUT") or 10) +HTTP_MAX_RETRIES = int(os.environ.get("HTTP_MAX_RETRIES") or 3) +HTTP = "http" + + +# OAM config +OAM_BASE_URL = os.environ.get( + "OAM_BASE_URL", + default=f"{HTTP}://kdp-oam-apiserver:8000" +) + + +# markdown to html +EXTEND_EXTENSION = os.environ.get("EXTENSION", default=None) + + +# worker +WORKER_NUM = int(os.environ.get("WORKER_NUM") or 4) +LIMIT_MAX_REQUESTS = os.environ.get("LIMIT_MAX_REQUESTS", None) +TIMEOUT_KEEP_ALIVE = int(os.environ.get("TIMEOUT_KEEP_ALIVE") or 5) + + +# monitor +DASHBOARD_URL = os.environ.get("DASHBOARD_URL", default="https://grafana.com") diff --git a/kdp_catalog_manager/config/gunicorn.py b/kdp_catalog_manager/config/gunicorn.py new file mode 100644 index 0000000..2fcf9b5 --- /dev/null +++ b/kdp_catalog_manager/config/gunicorn.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- + +# 监听内网端口8000 +bind = "0.0.0.0:8888" +# 工作模式协程。 +worker_class = "uvicorn.workers.UvicornWorker" +# 不设置守护进程 +daemon = 'false' +# 日志输出到stdout、stderr +errorlog = '-' +accesslog = '-' +# 日志格式 +logconfig_dict = { + 'formatters': { + "generic": { + # 打日志的格式 + "format": "[%(asctime)s] [%(levelname)s] [PID:%(process)d][ThreadID:%(thread)d-%(threadName)s] %(message)s", + "class": "logging.Formatter" + } + } +} \ No newline at end of file diff --git a/kdp_catalog_manager/config/log_config.py b/kdp_catalog_manager/config/log_config.py new file mode 100644 index 0000000..7db9567 --- /dev/null +++ b/kdp_catalog_manager/config/log_config.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +import os + + +LOG_DIR = os.environ.get("LOG_DIR", "logs") +LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO') +LOG_FILE = os.environ.get("LOG_FILE", "kdp-catalog-manager.log") +LOG_FILE_MAX_BYTES = os.environ.get('LOG_FILE_MAX_BYTES', 1024 * 1024 * 5) +LOG_FILE_BACKUP_COUNT = os.environ.get('LOG_FILE_BACKUP_COUNT', 3) + + +LOGGING_CONFIG = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "default": { + "()": "uvicorn.logging.DefaultFormatter", + "fmt": "[%(asctime)s] [%(levelname)s]: %(message)s", + "use_colors": None + }, + "access": { + "()": "uvicorn.logging.AccessFormatter", + "fmt": "[%(asctime)s] [%(levelname)s] [PID:%(process)d][ThreadID:%(thread)d-%(threadName)s] - [%(status_code)s]: %(message)s" + } + }, + "handlers": { + "default": { + "formatter": "default", + "class": "logging.StreamHandler", + "stream": "ext://sys.stderr" + }, + "access": { + "formatter": "access", + "class": "logging.StreamHandler", + "stream": "ext://sys.stderr" + } + }, + "loggers": { + "uvicorn": { + "handlers": ["default"], + "level": "ERROR", + "propagate": False + }, + "uvicorn.error": { + "level": "INFO" + }, + "uvicorn.access": { + "handlers": ["access"], + "level": "INFO", + "propagate": False + } + } +} \ No newline at end of file diff --git a/kdp_catalog_manager/domain/__init__.py b/kdp_catalog_manager/domain/__init__.py new file mode 100644 index 0000000..ff6ea1e --- /dev/null +++ b/kdp_catalog_manager/domain/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- diff --git a/kdp_catalog_manager/domain/format/__init__.py b/kdp_catalog_manager/domain/format/__init__.py new file mode 100644 index 0000000..ff6ea1e --- /dev/null +++ b/kdp_catalog_manager/domain/format/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- diff --git a/kdp_catalog_manager/domain/format/base.py b/kdp_catalog_manager/domain/format/base.py new file mode 100644 index 0000000..0e2b3b4 --- /dev/null +++ b/kdp_catalog_manager/domain/format/base.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- + + +class Format(object): + def __init__(self, raw): + self.raw = raw diff --git a/kdp_catalog_manager/domain/format/format_catalog.py b/kdp_catalog_manager/domain/format/format_catalog.py new file mode 100644 index 0000000..0905f13 --- /dev/null +++ b/kdp_catalog_manager/domain/format/format_catalog.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +from kdp_catalog_manager.domain.format.base import Format +from kdp_catalog_manager.utils.dictutils import DictUtils +from kdp_catalog_manager.config.base_config import DEFAULT_LANG +from kdp_catalog_manager.common.constants import I18N + + +class FormatCatalog(Format): + def __init__(self, raw): + super().__init__(raw) + + def get_name(self): + return DictUtils().get_items(self.raw, ["name"]) + + def get_type(self): + return DictUtils().get_items(self.raw, ["isGlobal"], False) + + def get_category(self, lang=DEFAULT_LANG): + if lang == DEFAULT_LANG: + return DictUtils().get_items(self.raw, ["category"]) + return DictUtils().get_items(self.raw, [I18N, lang, "category"]) + + def get_description(self, lang=DEFAULT_LANG): + if lang == DEFAULT_LANG: + return DictUtils().get_items(self.raw, ["description"]) + return DictUtils().get_items(self.raw, [I18N, lang, "description"]) diff --git a/kdp_catalog_manager/domain/format/format_catalog_form.py b/kdp_catalog_manager/domain/format/format_catalog_form.py new file mode 100644 index 0000000..5d4dfb4 --- /dev/null +++ b/kdp_catalog_manager/domain/format/format_catalog_form.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +from kdp_catalog_manager.common.constants import I18N +from kdp_catalog_manager.config.base_config import DEFAULT_LANG +from kdp_catalog_manager.domain.format.base import Format +from kdp_catalog_manager.utils.dictutils import DictUtils +from kdp_catalog_manager.config.base_config import DASHBOARD_URL + + +class FormatCatalogForm(Format): + def __init__(self, raw): + super().__init__(raw) + + def get_version(self): + return DictUtils().get_items(self.raw, ["version"]) + + def get_type(self): + return DictUtils().get_items(self.raw, ["isGlobal"], False) + + def get_alias(self): + return DictUtils().get_items(self.raw, ["alias"]) + + def get_invisible(self): + return DictUtils().get_items(self.raw, ["invisible"], False) + + def get_description(self, lang=DEFAULT_LANG): + if lang == DEFAULT_LANG: + return DictUtils().get_items(self.raw, ["description"]) + return DictUtils().get_items( + self.raw, [I18N, lang, "description"]) + + def get_dashboard(self, lang=DEFAULT_LANG): + """ + get catalog from dashboard info + :param lang: + :return: [{"name": "xx", "id": "xxx"}] + """ + dashboard_list = [] + dashboards = DictUtils().get_items(self.raw, ["dashboard"]) + if not dashboards: + return dashboard_list + for dashboard in dashboards: + dashboard_id = DictUtils().get_items(dashboard, ["id"]) + dashboard_name = None + if lang == DEFAULT_LANG: + dashboard_name = DictUtils().get_items( + dashboard, ["name"]) + if lang != DEFAULT_LANG: + dashboard_name = DictUtils().get_items( + dashboard, [I18N, lang]) + dashboard_list.append({ + "name": dashboard_name, + "id": dashboard_id, + "link": f"{DASHBOARD_URL}/d/{dashboard_id}" + }) + return dashboard_list + + +def format_catalog_form_metadata(catalog_form_metadatas, filter_lang, filter_invisible): + catalog_form_metadata_info = {} + catalog_form_metadata_all = catalog_form_metadatas + for catalog, catalog_form_metadatas in catalog_form_metadata_all.items(): + + catalog_catalog_form_metadatas_info = {} + for catalog_form, catalog_form_metadata in catalog_form_metadatas.items(): + + catalog_form_metadata_format_obj = FormatCatalogForm( + catalog_form_metadata) + invisible = catalog_form_metadata_format_obj.get_invisible() + if filter_invisible != invisible: + continue + catalog_catalog_form_metadatas_info[catalog_form] = { + "name": catalog_form, + "version": catalog_form_metadata_format_obj.get_version(), + "alias": catalog_form_metadata_format_obj.get_alias(), + "invisible": catalog_form_metadata_format_obj.get_invisible(), + "isGlobal": catalog_form_metadata_format_obj.get_type(), + "description": catalog_form_metadata_format_obj.get_description( + filter_lang), + "catalog": catalog, + "dashboardUrl": catalog_form_metadata_format_obj.get_dashboard( + filter_lang) + } + catalog_form_metadata_info[ + catalog] = catalog_catalog_form_metadatas_info + return catalog_form_metadata_info diff --git a/kdp_catalog_manager/domain/format/format_catalog_runtime_application.py b/kdp_catalog_manager/domain/format/format_catalog_runtime_application.py new file mode 100644 index 0000000..2d09a69 --- /dev/null +++ b/kdp_catalog_manager/domain/format/format_catalog_runtime_application.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +from kdp_catalog_manager.domain.format.base import Format +from kdp_catalog_manager.utils.dictutils import DictUtils +from kdp_catalog_manager.utils.log import log + + +class FormatCatalogRuntimeApplication(Format): + def __init__(self, raw): + super().__init__(raw) + + def get_bdc_name(self): + return DictUtils().get_items(self.raw, ["bdc", "name"]) + + def get_bdc_org(self): + return DictUtils().get_items(self.raw, ["bdc", "orgName"]) + + def get_bdc_status(self): + return DictUtils().get_items(self.raw, ["bdc", "status"]) + + def get_app_name(self): + return DictUtils().get_items(self.raw, ["name"]) + + def get_app_form(self): + return DictUtils().get_items(self.raw, ["appFormName"]) + + def get_app_runtime_name(self): + return DictUtils().get_items(self.raw, ["appRuntimeName"]) + + def get_app_createtime(self): + return DictUtils().get_items(self.raw, ["createTime"]) + + def get_app_updatetime(self): + return DictUtils().get_items(self.raw, ["updateTime"]) + + def get_app_status(self): + return DictUtils().get_items(self.raw, ["status"]) + + +def format_application( + all_application, + form_info, + filter_catalog, + filter_form +): + applications = [] + # deal with get application data is empty list or None + if not all_application: + return applications + for application_data in all_application: + application = application_data + application_format_obj = ( + FormatCatalogRuntimeApplication(application)) + application_form = application_format_obj.get_app_form() + application_catalog = DictUtils().get_items( + form_info, [application_form]) + + if not application_catalog: + log.warning(f"{application_form} not match catalog, " + f"get data is {application_catalog}") + continue + + # filter application + if filter_catalog and filter_catalog != application_catalog: + continue + if filter_form and filter_form != application_form: + continue + + application["catalog"] = application_catalog + applications.append(application) + return applications diff --git a/kdp_catalog_manager/domain/format/test/__init__.py b/kdp_catalog_manager/domain/format/test/__init__.py new file mode 100644 index 0000000..ff6ea1e --- /dev/null +++ b/kdp_catalog_manager/domain/format/test/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- diff --git a/kdp_catalog_manager/domain/format/test/test_format_catalog.py b/kdp_catalog_manager/domain/format/test/test_format_catalog.py new file mode 100644 index 0000000..b39ebcf --- /dev/null +++ b/kdp_catalog_manager/domain/format/test/test_format_catalog.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- + +from unittest import TestCase + +from ..format_catalog import FormatCatalog + +TEST_SYSTEM_CATEGORY = "系统/大数据开发工具" + + +class TestFormCatalog(TestCase): + def setUp(self): + self.catalog_data = { + "name": "mysql", + "category": "系统/大数据开发工具", + "description": "mysql", + "i18n": { + "en": { + "category": "system.dataManagement", + "description": "Mysql M" + } + } + } + self.catalog_format_obj = FormatCatalog(self.catalog_data) + + def test_get_name(self): + rt = self.catalog_format_obj.get_name() + self.assertEqual(rt, "mysql") + + def test_get_type(self): + rt = self.catalog_format_obj.get_type() + self.assertEqual(rt, False) + + def test_get_type_exists(self): + self.catalog_format_obj.raw["isGlobal"] = True + rt = self.catalog_format_obj.get_type() + self.assertEqual(rt, True) + + def test_get_category_default(self): + rt = self.catalog_format_obj.get_category("zh") + self.assertEqual(rt, TEST_SYSTEM_CATEGORY) + + def test_get_category_en(self): + rt = self.catalog_format_obj.get_category("en") + self.assertEqual(rt, "system.dataManagement") + + def test_get_category(self): + rt = self.catalog_format_obj.get_category() + self.assertEqual(rt, TEST_SYSTEM_CATEGORY) + + def test_get_description(self): + rt = self.catalog_format_obj.get_description() + self.assertEqual(rt, "mysql") + + def test_get_description_zh(self): + rt = self.catalog_format_obj.get_description("zh") + self.assertEqual(rt, "mysql") + + def test_get_description_en(self): + rt = self.catalog_format_obj.get_description("en") + self.assertEqual(rt, "Mysql M") diff --git a/kdp_catalog_manager/domain/format/test/test_format_catalog_form.py b/kdp_catalog_manager/domain/format/test/test_format_catalog_form.py new file mode 100644 index 0000000..d2a3eba --- /dev/null +++ b/kdp_catalog_manager/domain/format/test/test_format_catalog_form.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- + +from unittest import TestCase + +from ..format_catalog_form import FormatCatalogForm + + +TEST_DESC = "mysql test" + + +class TestFormatCatalogForm(TestCase): + def setUp(self): + self.catalog_form_metadata = { + "version": "1.0.0", + "alias": "mysql", + "description": TEST_DESC, + "isGlobal": False, + "i18n": { + "en": { + "category": "system.dataManagement", + "description": "Mysql M" + } + } + } + self.dashboard = [{ + "id": "xxx", + "name": "x1", + "i18n": { + "en": "x11" + } + }] + self.catalog_form_format_obj = FormatCatalogForm(self.catalog_form_metadata) + + def test_get_version(self): + rt = self.catalog_form_format_obj.get_version() + self.assertEqual(rt, "1.0.0") + + def test_get_version_not_exists(self): + del self.catalog_form_format_obj.raw["version"] + rt = self.catalog_form_format_obj.get_version() + self.assertEqual(rt, None) + + def test_get_type(self): + rt = self.catalog_form_format_obj.get_type() + self.assertEqual(rt, False) + + def test_get_type_exists(self): + self.catalog_form_format_obj.raw["isGlobal"] = True + rt = self.catalog_form_format_obj.get_type() + self.assertEqual(rt, True) + + def test_get_alias(self): + rt = self.catalog_form_format_obj.get_alias() + self.assertEqual(rt, "mysql") + + def test_get_invisible(self): + rt = self.catalog_form_format_obj.get_invisible() + self.assertEqual(rt, False) + + def test_get_invisible_exists(self): + self.catalog_form_format_obj.raw["invisible"] = True + rt = self.catalog_form_format_obj.get_invisible() + self.assertEqual(rt, True) + + def test_get_description(self): + rt = self.catalog_form_format_obj.get_description() + self.assertEqual(rt, TEST_DESC) + + def test_get_description_zh(self): + rt = self.catalog_form_format_obj.get_description("zh") + self.assertEqual(rt, TEST_DESC) + + def test_get_description_en(self): + rt = self.catalog_form_format_obj.get_description("en") + self.assertEqual(rt, "Mysql M") + + def test_dashboard(self): + rt = self.catalog_form_format_obj.get_dashboard() + self.assertEqual(rt, []) + + def test_dashboard_exists(self): + self.catalog_form_format_obj.raw["dashboard"] = self.dashboard + rt = self.catalog_form_format_obj.get_dashboard() + self.assertEqual(rt, [{ + "id": "xxx", "name": "x1", 'link': 'https://grafana.com/d/xxx'}]) + + def test_dashboard_exists_en(self): + self.catalog_form_format_obj.raw["dashboard"] = self.dashboard + rt = self.catalog_form_format_obj.get_dashboard(lang="en") + self.assertEqual(rt, [{ + "id": "xxx", "name": "x11", 'link': 'https://grafana.com/d/xxx'}]) diff --git a/kdp_catalog_manager/domain/format/test/test_format_catalog_runtime_application.py b/kdp_catalog_manager/domain/format/test/test_format_catalog_runtime_application.py new file mode 100644 index 0000000..32e4ad2 --- /dev/null +++ b/kdp_catalog_manager/domain/format/test/test_format_catalog_runtime_application.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- + +from unittest import TestCase + +from ..format_catalog_runtime_application import FormatCatalogRuntimeApplication, format_application + + +TEST_TIME = "0001-01-01T00:00:00Z" + + +CATALOG_RUNTIME_APPLICATION = { + "name": "admin-admin-bdos-file-registry", + "appFormName": "mysql", + "appTemplateType": "file-registry", + "appRuntimeName": "", + "appRuntimeNs": "admin", + "bdc": { + "name": "admin-admin", + "alias": "", + "description": "", + "orgName": "admin", + "status": "" + }, + "createTime": TEST_TIME, + "updateTime": TEST_TIME, + "labels": { + "app": "bdos-file-registry", + "app.core.bdos/type": "system", + "bdc.bdos.io/name": "admin-admin", + "bdc.bdos.io/org": "admin" + }, + "status": "running" +} + + +class TestFormatCatalogRuntimeApplication(TestCase): + def setUp(self): + self.maxDiff = None + self.catalog_runtime_application_data = CATALOG_RUNTIME_APPLICATION + self.catalog_runtime_application_format_obj = ( + FormatCatalogRuntimeApplication( + self.catalog_runtime_application_data + ) + ) + + def test_get_bdc_name(self): + rt = self.catalog_runtime_application_format_obj.get_bdc_name() + self.assertEqual(rt, "admin-admin") + + def test_get_bdc_org(self): + rt = self.catalog_runtime_application_format_obj.get_bdc_org() + self.assertEqual(rt, "admin") + + def test_get_bdc_status(self): + rt = self.catalog_runtime_application_format_obj.get_bdc_status() + self.assertEqual(rt, "") + + def test_get_app_name(self): + rt = self.catalog_runtime_application_format_obj.get_app_name() + self.assertEqual(rt, "admin-admin-bdos-file-registry") + + def test_get_app_form(self): + rt = self.catalog_runtime_application_format_obj.get_app_form() + self.assertEqual(rt, "mysql") + + def test_get_app_runtime_name(self): + rt = self.catalog_runtime_application_format_obj.get_app_runtime_name() + self.assertEqual(rt, "") + + def test_get_app_createtime(self): + rt = self.catalog_runtime_application_format_obj.get_app_createtime() + self.assertEqual(rt, TEST_TIME) + + def test_get_app_updatetime(self): + rt = self.catalog_runtime_application_format_obj.get_app_updatetime() + self.assertEqual(rt, TEST_TIME) + + def test_get_app_status(self): + rt = self.catalog_runtime_application_format_obj.get_app_status() + self.assertEqual(rt, "running") + + +class TestFormatApplication(TestCase): + def setUp(self): + self.maxDiff = None + self.all_application = CATALOG_RUNTIME_APPLICATION + self.form_info = {"mysql": "mysql", "spark": "spark"} + self.filter_catalog = None + self.filter_form = None + + self.rt = CATALOG_RUNTIME_APPLICATION + self.add_application = { + "appFormName": "test", + "bdc": { + "name": "", + "orgName": "admin" + } + } + + def test_format_application(self): + rt = format_application( + [self.all_application], + self.form_info, + self.filter_catalog, + self.filter_form + ) + self.assertEqual(rt, [self.rt]) + + def test_format_application_not_match_catalog(self): + rt = format_application( + [self.all_application, self.add_application], + self.form_info, + self.filter_catalog, + self.filter_form + ) + self.assertEqual(rt, [self.rt]) + + def test_format_application_filter_catalog(self): + self.add_application["appFormName"] = "spark" + rt = format_application( + [self.all_application, self.add_application], + self.form_info, + "mysql", + self.filter_form + ) + self.assertEqual(rt, [self.rt]) + + def test_format_application_filter_catalog1(self): + self.add_application["appFormName"] = "spark" + rt = format_application( + [self.all_application, self.add_application], + self.form_info, + "spark", + self.filter_form + ) + self.assertEqual(rt, [self.add_application]) + + def test_format_application_filter_catalog_form(self): + self.add_application["appFormName"] = "spark" + rt = format_application( + [self.all_application, self.add_application], + self.form_info, + self.filter_catalog, + "mysql" + ) + self.assertEqual(rt, [self.rt]) diff --git a/kdp_catalog_manager/domain/model/__init__.py b/kdp_catalog_manager/domain/model/__init__.py new file mode 100644 index 0000000..ff6ea1e --- /dev/null +++ b/kdp_catalog_manager/domain/model/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- diff --git a/kdp_catalog_manager/domain/model/base.py b/kdp_catalog_manager/domain/model/base.py new file mode 100644 index 0000000..3f6ef3b --- /dev/null +++ b/kdp_catalog_manager/domain/model/base.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +from pydantic import BaseModel, Field + + +class Response(BaseModel): + status: int = Field(default=0, description="状态码, 1:失败, 0:成功") + message: str = Field(description="描述信息") + data: dict = Field(description="返回数据") + error: dict = Field(description="错误信息") diff --git a/kdp_catalog_manager/domain/model/catalog.py b/kdp_catalog_manager/domain/model/catalog.py new file mode 100644 index 0000000..793f1b7 --- /dev/null +++ b/kdp_catalog_manager/domain/model/catalog.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +from typing import List, Dict + +from pydantic import BaseModel, Field + +from kdp_catalog_manager.domain.model.base import Response + + +class CatalogData(BaseModel): + name: str = Field(description="应用目录") + description: str = Field(description="应用目录说明") + category: str = Field(description="应用目录分类") + + +class CatalogDataOut(Response): + data: CatalogData | Dict + + +class CatalogList(Response): + data: List[CatalogData] | List diff --git a/kdp_catalog_manager/domain/model/catalog_app_form.py b/kdp_catalog_manager/domain/model/catalog_app_form.py new file mode 100644 index 0000000..55ada91 --- /dev/null +++ b/kdp_catalog_manager/domain/model/catalog_app_form.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +from typing import List, Dict + +from pydantic import BaseModel, Field + +from kdp_catalog_manager.domain.model.base import Response + + +class CatalogFormDataDashboardUrl(BaseModel): + name: str = Field(description="面板名称") + id: str = Field(description="面板id") + link: str = Field(description="面板地址") + + +class CatalogFormData(BaseModel): + name: str | None = Field(description="应用模板名称") + version: str | None = Field(description="应用模板版本") + alias: str | None = Field(description="应用模板别名") + invisible: bool = Field( + default=False, description="应用模板是否隐藏") + isGlobal: bool = Field(default=False, description="是否为全局级别") + description: str | None = Field(description="应用模板说明") + catalog: str = Field(description="应用目录") + dashboardUrl: List[CatalogFormDataDashboardUrl] | List + + +class CatalogFormList(Response): + data: List[CatalogFormData] | List + + +class CatalogForm(Response): + data: CatalogFormData | Dict + + +class CatalogFormInstallDataList(BaseModel): + org: str = Field(description="机构名称") + bdc: list = [] + + +class CatalogFormInstallData(BaseModel): + name: str = Field(description="form名称") + installtion: List[CatalogFormInstallDataList] + + +class CatalogFormInstall(Response): + data: CatalogFormInstallData diff --git a/kdp_catalog_manager/domain/model/catalog_app_runtime.py b/kdp_catalog_manager/domain/model/catalog_app_runtime.py new file mode 100644 index 0000000..d6212b7 --- /dev/null +++ b/kdp_catalog_manager/domain/model/catalog_app_runtime.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +from typing import List + +from pydantic import BaseModel, Field + +from kdp_catalog_manager.domain.model.base import Response + + +class CatalogAppListDataMetadata(BaseModel): + catalog: str = Field(description="应用目录名称") + category: str = Field(description="应用目录分类") + appForm: str = Field(description="应用模板名称") + appName: str = Field(description="应用名称") + appRuntime: str = Field(description="应用实例名称") + org: str = Field(description="机构名称") + bdc: str = Field(description="大数据集群名称") + + +class CatalogAppListData(BaseModel): + name: str = Field(description="应用名称") + isGlobal: bool = Field(default=False, description="是否为全局级别") + status: str = Field(description="应用状态") + updataTime: str = Field(description="应用更新时间") + metadata: CatalogAppListDataMetadata + + +class CatalogAppList(Response): + data: List diff --git a/kdp_catalog_manager/domain/service/__init__.py b/kdp_catalog_manager/domain/service/__init__.py new file mode 100644 index 0000000..ff6ea1e --- /dev/null +++ b/kdp_catalog_manager/domain/service/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- diff --git a/kdp_catalog_manager/domain/service/application.py b/kdp_catalog_manager/domain/service/application.py new file mode 100644 index 0000000..159216a --- /dev/null +++ b/kdp_catalog_manager/domain/service/application.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +from kdp_catalog_manager.domain.format.format_catalog_runtime_application import \ + format_application +from kdp_catalog_manager.domain.service.catalog_form import \ + CatalogFormController +from kdp_catalog_manager.exceptions.exception import GetDataError, \ + get_exception +from kdp_catalog_manager.modules.http_requests.application.oam_application import \ + OamApplication +from kdp_catalog_manager.utils.log import log + + +class ApplicationController(object): + def __init__(self, bdc=None, catalog=None, app_form=None): + self.bdc = bdc + self.catalog = catalog + self.app_form = app_form + + def get_applications(self, label_selector): + try: + form_info = CatalogFormController().get_form_info() + except Exception: + log.error(get_exception()) + raise GetDataError("get form catalog error") + + params = {} + if self.bdc: + params["bdcName"] = self.bdc + if label_selector: + params["labelSelector"] = label_selector + if not params: + params = None + all_application = OamApplication().list_all(params) + applications = format_application( + all_application, form_info, self.catalog, self.app_form) + return applications diff --git a/kdp_catalog_manager/domain/service/catalog.py b/kdp_catalog_manager/domain/service/catalog.py new file mode 100644 index 0000000..60630b1 --- /dev/null +++ b/kdp_catalog_manager/domain/service/catalog.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +import os.path + +from kdp_catalog_manager.common.constants import CATALOG_KEY, I18N, README_HTML +from kdp_catalog_manager.config.base_config import DEFAULT_LANG, \ + CATALOG_README_DIR +from kdp_catalog_manager.domain.format.format_catalog import FormatCatalog +from kdp_catalog_manager.exceptions.exception import DataTypeError, \ + APIRequestedInvalidParamsError +from kdp_catalog_manager.modules.cache.cache import cache_instance +from kdp_catalog_manager.utils.dictutils import DictUtils +from kdp_catalog_manager.utils.fileutils import FileUtils + + +class CatalogController(object): + def __init__(self, catalog=None, lang=DEFAULT_LANG): + self.catalog = catalog + self.lang = lang + + def get_catalogs_info(self): + catalogs_info = {} + catalogs_data = cache_instance.get(CATALOG_KEY) + if not isinstance(catalogs_data, dict): + raise DataTypeError(f"data type is {type(catalogs_data)}") + for catalog, catalog_info in catalogs_data.items(): + if not catalog_info: + catalogs_info[catalog] = {} + continue + catalog_format_obj = FormatCatalog(catalog_info) + catalogs_info[catalog] = { + "name": catalog_format_obj.get_name(), + "description": catalog_format_obj.get_description(self.lang), + "category": catalog_format_obj.get_category(self.lang) + } + return catalogs_info + + def get_catalogs(self): + catalogs = [] + catalogs_info = self.get_catalogs_info() + if not catalogs_info: + return catalogs + for catalog_info in catalogs_info.values(): + catalogs.append(catalog_info) + return catalogs + + def get_catalog(self): + catalogs_info = self.get_catalogs_info() + catalog_data = DictUtils().get_items(catalogs_info, [self.catalog], {}) + return catalog_data + + def get_catalog_readme(self): + if self.catalog is None: + raise APIRequestedInvalidParamsError("catalog is None") + catalog_file = os.path.join( + CATALOG_README_DIR, + self.catalog, + I18N, + self.lang, + README_HTML + ) + return FileUtils().read_file(catalog_file) diff --git a/kdp_catalog_manager/domain/service/catalog_form.py b/kdp_catalog_manager/domain/service/catalog_form.py new file mode 100644 index 0000000..cb21a7a --- /dev/null +++ b/kdp_catalog_manager/domain/service/catalog_form.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +import os + +from kdp_catalog_manager.common.constants import CATALOG_FORM_KEY, \ + CATALOG_FROM_DATA_KEY, I18N, README_HTML +from kdp_catalog_manager.config.base_config import DEFAULT_LANG, \ + CATALOG_FROM_README_DIR +from kdp_catalog_manager.domain.format.format_catalog_form import \ + FormatCatalogForm, format_catalog_form_metadata +from kdp_catalog_manager.exceptions.exception import CacheNotExistsError, \ + CatalogFormMatchError, APIRequestedInvalidParamsError +from kdp_catalog_manager.modules.cache.cache import cache_instance +from kdp_catalog_manager.utils.dictutils import DictUtils +from kdp_catalog_manager.utils.fileutils import FileUtils +from kdp_catalog_manager.modules.http_requests.application.oam_application import OamApplication +from kdp_catalog_manager.domain.format.format_catalog_runtime_application import FormatCatalogRuntimeApplication +from kdp_catalog_manager.utils.log import log + + +class CatalogFormController(object): + def __init__( + self, catalog=None, app_form=None, + lang=DEFAULT_LANG, invisible=False + ): + self.catalog = catalog + self.app_form = app_form + self.lang = lang + self.invisible = invisible + + def get_catalog_form_metadata_for_cache(self): + """ + get catalog from metadata from cache + :return: + """ + catalog_form_metadatas = cache_instance.get(CATALOG_FORM_KEY) + if not catalog_form_metadatas: + raise CacheNotExistsError( + "catalog from cache not exists, please re-initialize") + return catalog_form_metadatas + + def get_format_catalog_metadata(self): + """ + get format catalog + :return: + { + "catalog": + { + "catalog_form": + { + "name": "xxxx", + "version": "", + "alias": "Mysql", + "isGlobal": false, + "description": "xxx", + "invisible": false + } + } + } + """ + catalog_form_metadata_cache = self.get_catalog_form_metadata_for_cache() + catalog_form_metadata = format_catalog_form_metadata( + catalog_form_metadata_cache, + self.lang, + self.invisible + ) + return catalog_form_metadata + + def get_catalogs_forms(self): + catalog_forms = [] + catalog_form_metadata = self.get_format_catalog_metadata() + catalog_forms_metadta = DictUtils().get_items( + catalog_form_metadata, self.catalog) + if not catalog_forms_metadta: + return catalog_forms + for catalog_form_info in catalog_forms_metadta.values(): + catalog_forms.append(catalog_form_info) + return catalog_forms + + def get_catalogs_form(self): + catalog_form = {} + catalog_form_metadata = self.get_format_catalog_metadata() + catalog_forms_metadta = DictUtils().get_items( + catalog_form_metadata, self.catalog) + if not catalog_forms_metadta: + return catalog_form + + catalog_form = DictUtils().get_items( + catalog_forms_metadta, [self.app_form]) + if not catalog_form: + raise CatalogFormMatchError( + f"{self.catalog} not found {self.app_form}") + + return catalog_form + + def get_catalog_form_data(self): + catalog_form_data = cache_instance.get( + CATALOG_FROM_DATA_KEY.format(self.catalog, self.app_form) + ) + if not catalog_form_data: + return {} + return catalog_form_data + + def get_catalog_forms_install(self, org=None, bdc=None): + params = None + if bdc: + params = { + "bdcName": bdc + } + form_application_install = {} + all_application_data = OamApplication().list_all(params) + # deal with get application data is empty list or None + if all_application_data: + for application_data in all_application_data: + if not application_data: + continue + application_format_obj = ( + FormatCatalogRuntimeApplication(application_data)) + + application_org = application_format_obj.get_bdc_org() + application_bdc = application_format_obj.get_bdc_name() + application_form = application_format_obj.get_app_form() + if self.app_form != application_form: + continue + if org and org != application_org: + continue + + if application_org not in form_application_install: + form_application_install[application_org] = [application_bdc] + continue + if application_bdc not in form_application_install[application_org]: + form_application_install[application_org].append(application_bdc) + + install_data = [] + if form_application_install: + for org, bdc in form_application_install.items(): + install_data.append({ + "org": org, + "bdc": bdc + }) + + catalog_forms_install = { + "name": self.app_form, + "installtion": install_data + } + return catalog_forms_install + + def get_catalog_form_readme(self): + if self.catalog is None or self.app_form is None: + raise APIRequestedInvalidParamsError("catalog or app form is None") + catalog_file = os.path.join( + CATALOG_FROM_README_DIR, + self.app_form, + I18N, + self.lang, + README_HTML + ) + return FileUtils().read_file(catalog_file) + + def get_form_info(self): + """ + get form info + :return: {"form_name": "catalog_name"} + """ + form_info = {} + catalog_form_metadata = self.get_format_catalog_metadata() + for catalog, catalog_forms in catalog_form_metadata.items(): + for catalog_form in catalog_forms.keys(): + form_info[catalog_form] = catalog + return form_info diff --git a/kdp_catalog_manager/domain/service/markdown_transform_html.py b/kdp_catalog_manager/domain/service/markdown_transform_html.py new file mode 100644 index 0000000..8de02b0 --- /dev/null +++ b/kdp_catalog_manager/domain/service/markdown_transform_html.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +import os + +from kdp_catalog_manager.common.constants import README, I18N +from kdp_catalog_manager.config.base_config import CATALOG_DIR, APPS_DIR, \ + CATALOG_README_DIR, CATALOG_FROM_README_DIR, DEFAULT_LANG +from kdp_catalog_manager.utils.log import log +from kdp_catalog_manager.utils.markdown_to_html.markdownToHtml import \ + MarkdownToHtlm + + +def deal_readme(base_dir, output_file, output_base_dir=CATALOG_README_DIR): + for base_dir_name in os.listdir(base_dir): + form_name = base_dir_name + if base_dir_name.endswith(".app"): + form_name = base_dir_name.split(".")[0] + + # 处理默认语言readme + default_reademe_file = os.path.join(base_dir, base_dir_name, README) + output_dir = os.path.join( + output_base_dir, form_name, I18N, DEFAULT_LANG) + MarkdownToHtlm().markdown_to_html( + default_reademe_file, + output_file, + output_dir + ) + + # 处理其他语言readme + readme_lang_dir = os.path.join(base_dir, base_dir_name, I18N) + for readme_lang in os.listdir(readme_lang_dir): + readme_lang_file = os.path.join(readme_lang_dir, readme_lang, README) + output_dir = os.path.join( + output_base_dir, form_name, I18N, readme_lang + ) + MarkdownToHtlm().markdown_to_html( + readme_lang_file, + output_file, + output_dir + ) + + +def catalog_readme(): + output_file = README.replace("md", "html") + deal_readme(CATALOG_DIR, output_file, CATALOG_README_DIR) + log.info("transform catalog readme to html success") + + +def catalog_form_readme(): + output_file = README.replace("md", "html") + for catalog in os.listdir(CATALOG_DIR): + catalog_form_dir = os.path.join(CATALOG_DIR, catalog, APPS_DIR) + deal_readme(catalog_form_dir, output_file, CATALOG_FROM_README_DIR) + + log.info("transform catalog app form readme to html success") diff --git a/kdp_catalog_manager/domain/service/save_data_cache.py b/kdp_catalog_manager/domain/service/save_data_cache.py new file mode 100644 index 0000000..f6f34c9 --- /dev/null +++ b/kdp_catalog_manager/domain/service/save_data_cache.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +import os + +from kdp_catalog_manager.common.constants import METADATA_YAML, CATALOG_KEY, \ + CATALOG_FORM_KEY, APP_YAML, CATALOG_FROM_DATA_KEY +from kdp_catalog_manager.config.base_config import CACHE_EXPIRE +from kdp_catalog_manager.config.base_config import CATALOG_DIR, APPS_DIR +from kdp_catalog_manager.exceptions.exception import CacheOperatorError +from kdp_catalog_manager.modules.cache.cache import cache_instance +from kdp_catalog_manager.utils.log import log +from kdp_catalog_manager.utils.yamlutils import YAMLUtils + + +class SaveDataToCache(object): + def __init__(self): + self.cache = cache_instance + + +class SaveCatalogDataCache(SaveDataToCache): + def __init__(self): + super().__init__() + self.catalog_dir = CATALOG_DIR + + def get_catalog_data(self): + catalog_data = {} + for catalog in os.listdir(self.catalog_dir): + catalog_metadata_file = os.path.join( + CATALOG_DIR, catalog, METADATA_YAML) + if not os.path.exists(catalog_metadata_file): + continue + + catalog_metadata = YAMLUtils().load_all_yaml(catalog_metadata_file) + catalog_data[catalog] = catalog_metadata + rt = self.cache.set(CATALOG_KEY, catalog_data, CACHE_EXPIRE) + if rt: + log.info("initialization catalog data to cache successful") + else: + raise CacheOperatorError() + + +class SaveCatalogFormDataCache(SaveDataToCache): + def __init__(self): + super().__init__() + self.catalog_dir = CATALOG_DIR + self.apps = APPS_DIR + + def get_catalog_form_data(self): + catalog_form = {} + for catalog in os.listdir(self.catalog_dir): + catalog_form_dir = os.path.join( + self.catalog_dir, catalog, self.apps) + catalog_form_metadata_info = {} + for catalog_from_app in os.listdir(catalog_form_dir): + if not catalog_from_app.endswith(".app"): + continue + + # 存储元数据至缓存中 + catalog_form_metadata_file = os.path.join( + catalog_form_dir, catalog_from_app, METADATA_YAML) + if not os.path.exists(catalog_form_metadata_file): + continue + + catalog_form_metadata = YAMLUtils().load_all_yaml( + catalog_form_metadata_file) + + catalog_form_app_name = catalog_from_app.split(".")[0] + catalog_form_metadata_info[catalog_form_app_name] = ( + catalog_form_metadata) + + # 存储应用模板信息至缓存中 + catalog_form_data_file = os.path.join( + catalog_form_dir, catalog_from_app, APP_YAML + ) + if not os.path.exists(catalog_form_data_file): + continue + catalog_form_data = YAMLUtils().load_all_yaml( + catalog_form_data_file) + + self.cache.set( + CATALOG_FROM_DATA_KEY.format(catalog, catalog_form_app_name), + catalog_form_data, + CACHE_EXPIRE + ) + + catalog_form[catalog] = catalog_form_metadata_info + rt = self.cache.set(CATALOG_FORM_KEY, catalog_form, CACHE_EXPIRE) + if rt: + log.info("initialization catalog from data to cache successful") + else: + raise CacheOperatorError("save catalog form data to cache failed") diff --git a/kdp_catalog_manager/domain/service/test/__init__.py b/kdp_catalog_manager/domain/service/test/__init__.py new file mode 100644 index 0000000..ff6ea1e --- /dev/null +++ b/kdp_catalog_manager/domain/service/test/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- diff --git a/kdp_catalog_manager/domain/service/test/test_application.py b/kdp_catalog_manager/domain/service/test/test_application.py new file mode 100644 index 0000000..e69de29 diff --git a/kdp_catalog_manager/domain/service/test/test_catalog.py b/kdp_catalog_manager/domain/service/test/test_catalog.py new file mode 100644 index 0000000..445b6b5 --- /dev/null +++ b/kdp_catalog_manager/domain/service/test/test_catalog.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +from unittest import TestCase + +from kdp_catalog_manager.common.constants import CATALOG_KEY +from kdp_catalog_manager.exceptions.exception import FileNotExistsError, \ + APIRequestedInvalidParamsError +from kdp_catalog_manager.modules.cache.cache import cache_instance +from ..catalog import CatalogController + + +class TestCatalogController(TestCase): + def setUp(self): + self.catalog_data = { + "mysql": { + "category": "系统/大数据开发工具", + "description": "mysql", + "i18n": { + "en": { + "category": "system.dataManagement", + "description": "mysql" + } + } + } + } + cache_instance.set(CATALOG_KEY, self.catalog_data) + + def test_get_catalog_data_is_None(self): + self.catalog_data = {"mysql": None} + cache_instance.set(CATALOG_KEY, self.catalog_data) + rt = CatalogController(catalog="mysql").get_catalog() + self.assertEqual(rt, {}) + + def test_get_catalog(self): + self.catalog_data = {"mysql": {}} + cache_instance.set(CATALOG_KEY, self.catalog_data) + rt = CatalogController(catalog="mysql").get_catalog() + self.assertEqual(rt, {}) + + def test_get_catalog_readme(self): + try: + CatalogController(catalog="mysql", lang="zhs").get_catalog_readme() + except FileNotExistsError: + self.assertEqual(True, True) + + def test_get_catalog_readme_catalog_is_None(self): + try: + CatalogController(lang="zhs").get_catalog_readme() + except APIRequestedInvalidParamsError: + self.assertEqual(True, True) diff --git a/kdp_catalog_manager/domain/service/test/test_catalog_form.py b/kdp_catalog_manager/domain/service/test/test_catalog_form.py new file mode 100644 index 0000000..8a7300b --- /dev/null +++ b/kdp_catalog_manager/domain/service/test/test_catalog_form.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +from unittest import TestCase + +from kdp_catalog_manager.common.constants import CATALOG_FORM_KEY +from kdp_catalog_manager.exceptions.exception import FileNotExistsError, \ + APIRequestedInvalidParamsError +from kdp_catalog_manager.modules.cache.cache import cache_instance +from ..catalog_form import CatalogFormController, CacheNotExistsError, \ + CatalogFormMatchError + +TEST_DESC = "Mysql T" + + +class TestCatalogFormController(TestCase): + def setUp(self): + self.catalog_form_metadata = { + "mysql": { + "mysql": { + "version": "1.0.0", + "alias": "mysql", + "description": TEST_DESC, + "isGlobal": False, + "i18n": { + "en": { + "category": "system.dataManagement", + "description": "Mysql M" + } + } + } + } + } + + self.catalog_form_data = { + "apiVersion": "bdc.bdos.io/v1alpha1", + "kind": "Application" + } + + self.rt = { + "mysql": { + "mysql": { + "name": "mysql", + "version": "1.0.0", + "alias": "mysql", + "isGlobal": False, + "description": TEST_DESC, + "invisible": False, + "catalog": "mysql", + "dashboardUrl": [] + } + } + } + + def set_cache(self): + cache_instance.set(CATALOG_FORM_KEY, self.catalog_form_metadata) + + def test_get_catalog_form_metadata_from_cache_not_exists(self): + self.catalog_form_metadata = {} + self.set_cache() + try: + CatalogFormController().get_catalog_form_metadata_for_cache() + except CacheNotExistsError: + self.assertEqual(True, True) + + def test_get_catalog_form_metadata_from_cache(self): + self.set_cache() + rt = CatalogFormController().get_catalog_form_metadata_for_cache() + self.assertEqual(rt, self.catalog_form_metadata) + + def test_get_catalog_form_metadata(self): + self.set_cache() + rt = CatalogFormController().get_format_catalog_metadata() + self.assertEqual(rt, self.rt) + + def test_get_catalogs_forms(self): + self.set_cache() + rt = CatalogFormController( + catalog="mysql", app_form="mysql").get_catalogs_forms() + self.assertEqual(rt, [{ + "name": "mysql", "version": "1.0.0", "alias": 'mysql', + 'invisible': False, 'isGlobal': False, + 'description': TEST_DESC, "catalog": "mysql", 'dashboardUrl': []}]) + + def test_get_catalogs_forms_invisible(self): + self.set_cache() + rt = CatalogFormController( + catalog="mysql", app_form="mysql", invisible=True + ).get_catalogs_forms() + self.assertEqual(rt, []) + + def test_get_catalogs_form_catalog_not_found_from(self): + self.set_cache() + try: + CatalogFormController( + catalog="mysql", app_form="spark").get_catalogs_form() + except CatalogFormMatchError: + self.assertEqual(True, True) + + def test_get_catalogs_form_invisible(self): + self.set_cache() + cache_instance.set("mysql", self.catalog_form_metadata) + rt = CatalogFormController( + catalog="mysql", app_form="mysql", invisible=True + ).get_catalogs_form() + self.assertEqual(rt, {}) + cache_instance.delete("mysql") + + def test_get_catalogs_form(self): + self.set_cache() + cache_instance.set("mysql", self.catalog_form_metadata) + rt = CatalogFormController( + catalog="mysql", app_form="mysql").get_catalogs_form() + self.assertEqual(rt, { + 'name': 'mysql', 'version': '1.0.0', 'alias': 'mysql', + 'isGlobal': False, 'description': 'Mysql T', + 'invisible': False, "catalog": "mysql", 'dashboardUrl': []}) + cache_instance.delete("mysql") + + def test_get_catalog_form_data(self): + self.set_cache() + cache_instance.set("mysql-mysql-data", self.catalog_form_data) + rt = CatalogFormController( + catalog="mysql", app_form="mysql").get_catalog_form_data() + self.assertEqual(rt, self.catalog_form_data) + + def test_get_catalog_form_data_not_exists(self): + self.set_cache() + cache_instance.set("mysql-mysql-data", self.catalog_form_data) + rt = CatalogFormController( + catalog="mysql", app_form="mysql1").get_catalog_form_data() + self.assertEqual(rt, {}) + + def test_get_catalog_form_readme_not_exists(self): + try: + CatalogFormController( + catalog="mysql", app_form="mysql1").get_catalog_form_readme() + except FileNotExistsError: + self.assertEqual(True, True) + + def test_get_catalog_form_readme_not_catalog(self): + try: + CatalogFormController().get_catalog_form_readme() + except APIRequestedInvalidParamsError: + self.assertEqual(True, True) + + def test_get_form_info(self): + rt = CatalogFormController().get_form_info() + self.assertEqual(rt, {"mysql": "mysql"}) diff --git a/kdp_catalog_manager/exceptions/__init__.py b/kdp_catalog_manager/exceptions/__init__.py new file mode 100644 index 0000000..ff6ea1e --- /dev/null +++ b/kdp_catalog_manager/exceptions/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- diff --git a/kdp_catalog_manager/exceptions/err_map.json b/kdp_catalog_manager/exceptions/err_map.json new file mode 100644 index 0000000..51909f8 --- /dev/null +++ b/kdp_catalog_manager/exceptions/err_map.json @@ -0,0 +1,50 @@ +{ + "KdpCatalogManagerError": { + "code": 100000, + "error-name": "KdpCatalogManagerError", + "description": "", + "solution": "请查看日志获取更多错误详情", + "manual": "", + "app": "KDP-Catalog-Manager" + }, + "LoadJSONFileError": { + "code": 100001, + "error-name": "LoadJSONFileError", + "description": "读取的{name} 配置文件内容不是合法的JSON格式", + "solution": "检查文件内容", + "manual": "", + "app": "" + }, + "LangNotSupport":{ + "code": 300001, + "error-name": "LangNotSupport", + "description": "语言不支持", + "solution": "请传输正确语言参数", + "manual": "", + "app": "" + }, + "CatalogFormMatchError": { + "code": 300001, + "error-name": "CatalogFormMatchError", + "description": "catalog 与from 不匹配", + "solution": "检查查询参数是否正确", + "manual": "", + "app": "" + }, + "CacheOperatorError": { + "code": 100101, + "error-name": "CacheOperatorError", + "description": "缓存操作失败: {name}", + "solution": "检查应用缓存数据或重启应用", + "manual": "", + "app": "" + }, + "DataTypeError": { + "code": 100201, + "error-name": "DataTypeError", + "description": "缓存操作失败: {name}", + "solution": "检查应用缓存数据或重启应用", + "manual": "", + "app": "" + } +} \ No newline at end of file diff --git a/kdp_catalog_manager/exceptions/exception.py b/kdp_catalog_manager/exceptions/exception.py new file mode 100644 index 0000000..87747e7 --- /dev/null +++ b/kdp_catalog_manager/exceptions/exception.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +import sys +import traceback +import os +from kdp_catalog_manager.utils.log import logger + + +def get_exception(): + # 获取异常的跟踪信息 + exc_traceback = traceback.format_exc() + + # 提取文件名部分并打印跟踪信息 + basename_traceback = os.path.basename(exc_traceback) + logger.error(basename_traceback) + + +class KdpCatalogManagerError(Exception): + def __init__(self, error_cname=None): + self.error_name = self.__class__.__name__ + self.error_details = get_exception() + if not error_cname: + self.error_cname = "Kdp Catalog Manager Error" + else: + self.error_cname = "{}".format(error_cname) + super().__init__(self.error_cname) + + def __repr__(self): + return "'{}({})'".format(self.error_name, self.error_cname) + + +class LangNotSupport(KdpCatalogManagerError): + def __init__(self, name): + self.error_cname = "语言不支持:{}".format(name) + KdpCatalogManagerError.__init__(self, self.error_cname) + + +class DirectoryNotExistsError(KdpCatalogManagerError): + def __init__(self, file_path): + self.error_cname = "路径 {} 不存在".format(file_path) + KdpCatalogManagerError.__init__(self, self.error_cname) + + +class FileNotExistsError(KdpCatalogManagerError): + def __init__(self, file_path): + self.error_cname = "文件 {} 不存在".format(file_path) + KdpCatalogManagerError.__init__(self, self.error_cname) + + +class ReadFileError(KdpCatalogManagerError): + def __init__(self, file_path): + self.error_cname = "读取文件 {} 失败".format(file_path) + KdpCatalogManagerError.__init__(self, self.error_cname) + + +class HTTPRequestError(KdpCatalogManagerError): + def __init__(self, method, url, params=None, payload=None, msg=None): + __basic_error_cname = "请求method={} url={}失败 {},请检查该服务是否运行正常".format(method, url, msg) + self.error_cname = __basic_error_cname + if params: + self.error_cname = "{}(请求参数Params={})".format(__basic_error_cname, params) + if payload: + self.error_cname = "{}(请求参数Body={})".format(__basic_error_cname, payload) + if params and payload: + self.error_cname = "{}(请求参数Params={},Body={})".format(__basic_error_cname, params, payload) + KdpCatalogManagerError.__init__(self, self.error_cname) + + +class APIRequestedURLNotFoundError(KdpCatalogManagerError): + def __init__(self): + self.error_cname = "API 请求URL不存在" + KdpCatalogManagerError.__init__(self, self.error_cname) + + +class APIRequestedInvalidParamsError(KdpCatalogManagerError): + def __init__(self, error_msg=""): + self.error_cname = "API 请求参数不正确:{}".format(error_msg) + KdpCatalogManagerError.__init__(self, self.error_cname) + + +class WriteFileError(KdpCatalogManagerError): + def __init__(self, file_path): + self.error_cname = "保存(写)文件 {} 失败".format(file_path) + KdpCatalogManagerError.__init__(self, self.error_cname) + + +class FileOpsForbidden(KdpCatalogManagerError): + def __init__(self, file_path): + self.error_cname = "文件路径 {} 不合法".format(file_path) + KdpCatalogManagerError.__init__(self, self.error_cname) + + +class CreateSoftLinkError(KdpCatalogManagerError): + def __init__(self, src, dst): + self.error_cname = "创建软链 {}->{} 失败".format(src, dst) + KdpCatalogManagerError.__init__(self, self.error_cname) + + +class MaxTryError(KdpCatalogManagerError): + """超过最大重试次数""" + + +class LoadYamlError(KdpCatalogManagerError): + def __init__(self, name=""): + self.error_cname = "获取YAML失败:{}".format(name) + KdpCatalogManagerError.__init__(self, self.error_cname) + + +class CopyFileError(KdpCatalogManagerError): + def __init__(self, error_msg): + self.error_cname = "拷贝文件失败 {}".format(error_msg) + KdpCatalogManagerError.__init__(self, self.error_cname) + + +class DirNotExistsError(KdpCatalogManagerError): + def __init__(self, dir_path): + self.error_cname = "目录 {} 不存在".format(dir_path) + KdpCatalogManagerError.__init__(self, self.error_cname) + + +class CopyDirError(KdpCatalogManagerError): + def __init__(self, error_msg): + self.error_cname = "拷贝目录失败 {}".format(error_msg) + KdpCatalogManagerError.__init__(self, self.error_cname) + + +class RemoveDirError(KdpCatalogManagerError): + def __init__(self, error_msg): + self.error_cname = "删除目录失败 {}".format(error_msg) + KdpCatalogManagerError.__init__(self, self.error_cname) + + +class GetAppInfoError(KdpCatalogManagerError): + def __init__(self, error_msg=""): + self.error_cname = "获取应用描述信息失败 {}".format(error_msg) + KdpCatalogManagerError.__init__(self, self.error_cname) + + +class ConvertEngineDataError(KdpCatalogManagerError): + def __init__(self, name=""): + self.error_cname = "[实例化应用]转换配置失败 {}".format(name) + KdpCatalogManagerError.__init__(self, self.error_cname) + + +class JSONSchemaError(KdpCatalogManagerError): + def __init__(self, name=""): + self.error_cname = "JSONSchemaError:{}".format(name) + KdpCatalogManagerError.__init__(self, self.error_cname) + + +class CacheNotSupportError(KdpCatalogManagerError): + def __init__(self, name=""): + self.error_cname = f"缓存类型{name}不支持" + KdpCatalogManagerError.__init__(self, self.error_cname) + + +class CacheOperatorError(KdpCatalogManagerError): + def __init__(self, name=""): + self.error_cname = f"缓存操作失败: {name}" + KdpCatalogManagerError.__init__(self, self.error_cname) + + +class CacheNotExistsError(KdpCatalogManagerError): + def __init__(self, name=""): + self.error_cname = f"缓存不存在: {name}" + KdpCatalogManagerError.__init__(self, self.error_cname) + + +class LoadJSONFileError(KdpCatalogManagerError): + def __init__(self, file_path): + self.error_cname = f"读取的{file_path} 配置文件内容不是合法的JSON格式" + KdpCatalogManagerError.__init__(self, self.error_cname) + + +class DataNoneError(KdpCatalogManagerError): + def __init__(self, file_path): + self.error_cname = f"内容为空:{file_path}" + KdpCatalogManagerError.__init__(self, self.error_cname) + + +class CatalogFormMatchError(KdpCatalogManagerError): + def __init__(self, error_msg): + self.error_cname = f"应用目录与应用模板不匹配: {error_msg}" + KdpCatalogManagerError.__init__(self, self.error_cname) + + +class DataTypeError(KdpCatalogManagerError): + def __init__(self, error_msg): + self.error_cname = f"数据类型异常: {error_msg}" + KdpCatalogManagerError.__init__(self, self.error_cname) + + +class GetDataError(KdpCatalogManagerError): + def __init__(self, error_msg): + self.error_cname = f"获取数据类型: {error_msg}" + KdpCatalogManagerError.__init__(self, self.error_cname) \ No newline at end of file diff --git a/kdp_catalog_manager/main.py b/kdp_catalog_manager/main.py new file mode 100644 index 0000000..1b31910 --- /dev/null +++ b/kdp_catalog_manager/main.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +import os +from contextlib import asynccontextmanager + +import fastapi.openapi.utils as fu +from fastapi import FastAPI, status, Request, HTTPException +from fastapi.encoders import jsonable_encoder +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse + +from kdp_catalog_manager.api.api_v1.api import api_router +from kdp_catalog_manager.domain.service.markdown_transform_html import \ + catalog_readme, catalog_form_readme +from kdp_catalog_manager.domain.service.save_data_cache import \ + SaveCatalogDataCache, SaveCatalogFormDataCache +from kdp_catalog_manager.utils.format_return import FormatReturn +from kdp_catalog_manager.utils.log import log +from prometheus_fastapi_instrumentator import Instrumentator + + +@asynccontextmanager +async def lifespan(app: FastAPI): + print("prestart runing ......") + instrumentator.expose(app) + yield + print("running before closing ......") + + +app = FastAPI( + title="KDP Catalog Manager", + version="1.0.0", + description="应用目录管理", + license_info={ + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html", + }, + openapi_url="/api/v1/openapi.json", + lifespan=lifespan +) + +# 添加监控指标 +instrumentator = Instrumentator().instrument(app) + +# 添加路由前缀 +app.include_router(api_router, prefix="/api/v1") + + +@app.get("/") +async def index(): + return {"ping": "pong"} + + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request: Request, exc: RequestValidationError): + error_info = FormatReturn().format_error_info( + "RequestValidationError", + exc.errors(), + error_msg=exc.errors() + ) + rtn = FormatReturn().format_return_json( + {}, status=1, msg=exc.errors(), error_info=error_info) + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content=jsonable_encoder( + rtn + ) + ) + + +@app.exception_handler(404) +def not_found_exception_handler(request: Request, exc: HTTPException): + return JSONResponse( + status_code=404, + content=FormatReturn().format_return_json( + data=exc.detail, + status=1, + msg=f"{request.query_params} {exc.detail}" + ) + ) + + +fu.validation_error_response_definition = { + "title": "HTTPValidationError", + "type": "object", + "properties": { + "status": {"title": "err status", "type": "int", "default": 1}, + "message": {"title": "Message", "type": "string"}, + "data": {"title": "data Message", "type": "object"}, + "error": {"title": "err Message", "type": "object"} + } +} + +if not os.environ.get("Test"): + SaveCatalogDataCache().get_catalog_data() + SaveCatalogFormDataCache().get_catalog_form_data() + + catalog_readme() + log.info("initialization catalog readme transform to html successful") + catalog_form_readme() + log.info("initialization catalog form readme transform to html successful") + + +if __name__ == '__main__': + import uvicorn + uvicorn.run( + "main:app", host="0.0.0.0", port=8888, + ) diff --git a/kdp_catalog_manager/modules/__init__.py b/kdp_catalog_manager/modules/__init__.py new file mode 100644 index 0000000..ff6ea1e --- /dev/null +++ b/kdp_catalog_manager/modules/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- diff --git a/kdp_catalog_manager/modules/cache/__init__.py b/kdp_catalog_manager/modules/cache/__init__.py new file mode 100644 index 0000000..ff6ea1e --- /dev/null +++ b/kdp_catalog_manager/modules/cache/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- diff --git a/kdp_catalog_manager/modules/cache/cache.py b/kdp_catalog_manager/modules/cache/cache.py new file mode 100644 index 0000000..9ffb635 --- /dev/null +++ b/kdp_catalog_manager/modules/cache/cache.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +from cachelib import SimpleCache + + +cache_instance = SimpleCache() diff --git a/kdp_catalog_manager/modules/http_requests/__init__.py b/kdp_catalog_manager/modules/http_requests/__init__.py new file mode 100644 index 0000000..ff6ea1e --- /dev/null +++ b/kdp_catalog_manager/modules/http_requests/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- diff --git a/kdp_catalog_manager/modules/http_requests/application/__init__.py b/kdp_catalog_manager/modules/http_requests/application/__init__.py new file mode 100644 index 0000000..ff6ea1e --- /dev/null +++ b/kdp_catalog_manager/modules/http_requests/application/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- diff --git a/kdp_catalog_manager/modules/http_requests/application/oam_application.py b/kdp_catalog_manager/modules/http_requests/application/oam_application.py new file mode 100644 index 0000000..46a9c26 --- /dev/null +++ b/kdp_catalog_manager/modules/http_requests/application/oam_application.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +from kdp_catalog_manager.modules.http_requests.base_requests import BaseRequests +from kdp_catalog_manager.common.constants import HTTP_HEADER, RESPONSE_DATA +from kdp_catalog_manager.config.base_config import OAM_BASE_URL, HTTP_TIME_OUT, \ + HTTP_MAX_RETRIES + + +class OamApplication(BaseRequests): + def __init__(self, timeout=HTTP_TIME_OUT, max_retries=HTTP_MAX_RETRIES): + super().__init__(timeout=timeout, max_retries=max_retries) + self.headers = HTTP_HEADER + + def list_all(self, params=None): + _url = "{}/api/v1/applications".format(OAM_BASE_URL) + _response = self.http_request( + "GET", _url, self.headers, params=params + ) + return _response.get(RESPONSE_DATA) diff --git a/kdp_catalog_manager/modules/http_requests/application/test/__init__.py b/kdp_catalog_manager/modules/http_requests/application/test/__init__.py new file mode 100644 index 0000000..ff6ea1e --- /dev/null +++ b/kdp_catalog_manager/modules/http_requests/application/test/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- diff --git a/kdp_catalog_manager/modules/http_requests/application/test/test_oam_application.py b/kdp_catalog_manager/modules/http_requests/application/test/test_oam_application.py new file mode 100644 index 0000000..3db9219 --- /dev/null +++ b/kdp_catalog_manager/modules/http_requests/application/test/test_oam_application.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +from unittest import TestCase, mock +from ..oam_application import OamApplication +from kdp_catalog_manager.exceptions.exception import HTTPRequestError + + +class TestOamApplication(TestCase): + def setUp(self): + self.application = OamApplication(1, 2) + + def test_list_all_normal(self): + with self.assertRaises(HTTPRequestError): + self.application.list_all() + + def test_list_all_success(self): + self.rt = { + "status": 0, + "message": "ok", + "data": [ + ] + } + self.application.http_request = mock.Mock(return_value=self.rt) + response = self.application.list_all() + self.assertEqual(response, []) diff --git a/kdp_catalog_manager/modules/http_requests/base_requests.py b/kdp_catalog_manager/modules/http_requests/base_requests.py new file mode 100644 index 0000000..d84c20a --- /dev/null +++ b/kdp_catalog_manager/modules/http_requests/base_requests.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +import traceback + +import httpx + +from kdp_catalog_manager.common.constants import RESPONSE_NORMAL_CODE, \ + RESPONSE_NOT_FOUND_CODE +from kdp_catalog_manager.config.base_config import HTTP_TIME_OUT, \ + HTTP_MAX_RETRIES +from kdp_catalog_manager.exceptions.exception import HTTPRequestError, \ + APIRequestedURLNotFoundError +from kdp_catalog_manager.utils.log import log + + +class BaseRequests(object): + def __init__(self, timeout=HTTP_TIME_OUT, max_retries=HTTP_MAX_RETRIES): + self.timeout = timeout + self.max_retries = max_retries + + def http_request( + self, + method, + url, + headers, + data=None, + params=None + ): + try: + log.info("request url: [{}]{}".format(method, url)) + log.info("request body: {}".format(data)) + log.info("request params: {}".format(params)) + transport = httpx.HTTPTransport(retries=self.max_retries) + with httpx.Client(transport=transport) as client: + response = client.request( + method, + url, + headers=headers, + params=params, + timeout=self.timeout, + json=data + ) + if response.status_code == RESPONSE_NORMAL_CODE: + return response.json() + if response.status_code == RESPONSE_NOT_FOUND_CODE and \ + response.text == "404: Page Not Found": + raise APIRequestedURLNotFoundError() + else: + raise HTTPRequestError( + method, url, + msg=f"{response.status_code}, response.json()") + except Exception as e: + print(traceback.format_exc()) + raise HTTPRequestError(method, url, msg=str(e)) diff --git a/kdp_catalog_manager/modules/http_requests/test/__init__.py b/kdp_catalog_manager/modules/http_requests/test/__init__.py new file mode 100644 index 0000000..ff6ea1e --- /dev/null +++ b/kdp_catalog_manager/modules/http_requests/test/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- diff --git a/kdp_catalog_manager/modules/http_requests/test/test_base_requests.py b/kdp_catalog_manager/modules/http_requests/test/test_base_requests.py new file mode 100644 index 0000000..56de120 --- /dev/null +++ b/kdp_catalog_manager/modules/http_requests/test/test_base_requests.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +from unittest import TestCase +from unittest import mock + +from kdp_catalog_manager.exceptions.exception import HTTPRequestError +from kdp_catalog_manager.modules.http_requests.base_requests import BaseRequests, RESPONSE_NOT_FOUND_CODE, APIRequestedURLNotFoundError + + +class TestHttpRequest(TestCase): + def setUp(self): + self.maxDiff = None + self.header = {"Content-Type": "application/json; charset=UTF-8"} + self.base_requests = BaseRequests(timeout=1, max_retries=1) + + def test_http_request(self): + rt = True + try: + self.base_requests.http_request( + "GET", "http://127.0.0.1", + self.header + ) + except HTTPRequestError: + self.assertEqual(rt, True) + + def test_http_request1(self): + rt = self.base_requests.http_request( + "GET", "https://wenku.baidu.com/message/getnotice", + self.header) + self.assertEqual(rt.get("status").get("code"), 0) + self.assertEqual(rt, { + "status": {"code": 0, "msg": "get notice success"}, + "data": {"system": [], "diy": [], "advert": [], + "errstr": "get notice success"} + }) + + @mock.patch('httpx.Client') + def test_http_request_404(self, mock_client): + mock_response = mock.MagicMock() + mock_response.status_code = 404 + mock_response.text = "404: Page Not Found" + mock_client.return_value.__enter__.return_value.request.return_value = mock_response + with self.assertRaises(HTTPRequestError): + self.base_requests.http_request( + "GET", "http://127.0.0.1:8002/api/v1/applicationss", + self.header) + + @mock.patch('httpx.Client') + def test_http_request_500(self, mock_client): + mock_response = mock.MagicMock() + mock_response.status_code = 500 + with self.assertRaises(HTTPRequestError): + self.base_requests.http_request( + "GET", "http://127.0.0.1:8002/api/v1/applicationss", + self.header) diff --git a/kdp_catalog_manager/setup.py b/kdp_catalog_manager/setup.py new file mode 100644 index 0000000..e47f412 --- /dev/null +++ b/kdp_catalog_manager/setup.py @@ -0,0 +1,49 @@ +import os +import re +import sys +from Cython.Build import cythonize +from Cython.Compiler import Options +from distutils.core import Extension, setup + +__ABS_DIR_PATH = os.path.abspath('') +exclude_so = ["__init__.py", "setup.py", "main.py", "gunicorn.py"] +sources = ['./'] + +extensions = [] +sources_files = [] +for source in sources: + for dir_path, folder_names, filenames in os.walk(source): + for filename in filter(lambda x: re.match(r'.*[.]py$', x), filenames): + print(filename) + file_path = os.path.join(dir_path, filename) + print(file_path) + if filename not in exclude_so: + sources_files.append(file_path) + if filename not in exclude_so: + print("debug point ", file_path[:-3].replace('/', '.')[2:]) + extensions.append( + Extension(file_path[:-3].replace('/', '.')[2:], [file_path], extra_compile_args=["-Os", "-g0"], + extra_link_args=["-Wl,--strip-all"])) + +print(sources_files) +Options.docstrings = False +compiler_directives = {'optimize.unpack_method_calls': False, 'always_allow_keywords': True} + +try: + setup( + ext_modules=cythonize( + extensions, + exclude=None, + nthreads=20, + quiet=True, + language_level=3, + compiler_directives=compiler_directives + ) + ) + print("Start remove source files...") + for sf in sources_files: + os.remove(sf) + os.remove('{}.c'.format(sf[:-3])) +except Exception as ex: + print("error! ", str(ex)) + sys.exit(1) diff --git a/kdp_catalog_manager/test_main.py b/kdp_catalog_manager/test_main.py new file mode 100644 index 0000000..96f3a47 --- /dev/null +++ b/kdp_catalog_manager/test_main.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +from .main import app +from fastapi.testclient import TestClient + +client = TestClient(app) + + +def test_read_main(): + response = client.get("/") + assert response.status_code == 200 + assert response.json() == {"ping": "pong"} diff --git a/kdp_catalog_manager/utils/__init__.py b/kdp_catalog_manager/utils/__init__.py new file mode 100644 index 0000000..ff6ea1e --- /dev/null +++ b/kdp_catalog_manager/utils/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- diff --git a/kdp_catalog_manager/utils/dictutils.py b/kdp_catalog_manager/utils/dictutils.py new file mode 100644 index 0000000..c934c48 --- /dev/null +++ b/kdp_catalog_manager/utils/dictutils.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +from functools import reduce + + +class DictUtils(object): + + @staticmethod + def get_items(obj, items, default=None): + """递归获取数据 + + :param obj: + :param default: + :param items: 键列表:['foo', 'bar'],或者用 "." 连接的键路径: ".foo.bar" 或 "foo.bar" + """ + if isinstance(items, str): + items = items.strip(".").split(".") + try: + return reduce(lambda x, i: x[i], items, obj) + except (IndexError, KeyError, TypeError): + return default diff --git a/kdp_catalog_manager/utils/fileutils.py b/kdp_catalog_manager/utils/fileutils.py new file mode 100644 index 0000000..a19bfe0 --- /dev/null +++ b/kdp_catalog_manager/utils/fileutils.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +import os + +from kdp_catalog_manager.exceptions.exception import FileNotExistsError, \ + ReadFileError, WriteFileError +from kdp_catalog_manager.utils.log import log + + +class FileUtils(object): + + @staticmethod + def check_file_exists(file, log_err=True): + if not os.path.lexists(file): + if log_err: + log.debug("File {} not exists!".format(file)) + return False + else: + return True + + def read_file(self, file): + if self.check_file_exists(file): + try: + with open(file) as f: + rtn = f.read() + return rtn + except IOError as e: + log.exception(e) + raise ReadFileError(file) + else: + log.warning("File {} not exists".format(file)) + raise FileNotExistsError(file) + + @staticmethod + def write_file(content, target_file): + try: + with open(target_file, 'w') as f: + f.write(content) + except Exception as err: + log.error(str(err)) + raise WriteFileError(target_file) diff --git a/kdp_catalog_manager/utils/format_return.py b/kdp_catalog_manager/utils/format_return.py new file mode 100644 index 0000000..4d39c5d --- /dev/null +++ b/kdp_catalog_manager/utils/format_return.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +import json +from kdp_catalog_manager.utils.log import log + + +class FormatReturn(object): + + @staticmethod + def format_return_json(data, status=0, msg="", error_info={}): + if error_info: + status = 1 + msg = "BdosException occurred" if msg == "" else msg + data = None if data is None else data + + rtn = {'status': status, 'data': data, + 'message': msg, "error": error_info} + return rtn + + @staticmethod + def format_error_info( + error_name, raw_exception, app_name=None, func_args=None, + error_msg=None, exception_type="error" + ): + log.error(f"{error_name}: {error_msg}") + with open("kdp_catalog_manager/exceptions/err_map.json") as fp: + errors_map_list = json.load(fp) + error_info = { + "type": exception_type, + "exception": raw_exception, + "args": [] if func_args is None else func_args + } + error_data_info = errors_map_list.get( + error_name, + errors_map_list.get("KdpCatalogManagerError") + ) + error_info["info"] = error_data_info + if error_msg: + error_info["info"]['description'] = error_msg + if app_name: + error_info["app"] = app_name + else: + error_info["app"] = error_data_info["app"] + del error_data_info['app'] + + return error_info diff --git a/kdp_catalog_manager/utils/log.py b/kdp_catalog_manager/utils/log.py new file mode 100644 index 0000000..a8db13b --- /dev/null +++ b/kdp_catalog_manager/utils/log.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +import os +import logging +from concurrent_log_handler import ConcurrentRotatingFileHandler +from kdp_catalog_manager.config import log_config + + +# 日志记录器 +logger = logging.getLogger() +# 设置日志级别 +logger.setLevel(log_config.LOG_LEVEL) + +# 设置日志格式 +formatter = logging.Formatter( + "[%(asctime)s] [%(levelname)s] [PID:%(process)d][ThreadID:%(thread)d-%(threadName)s] %(module)s %(funcName)s line:%(lineno)d %(message)s" +) + +to_console = logging.StreamHandler() +to_console.setFormatter(formatter) + + +to_file = ConcurrentRotatingFileHandler( + os.path.join(log_config.LOG_DIR, log_config.LOG_FILE), + mode="a", + maxBytes=log_config.LOG_FILE_MAX_BYTES, + backupCount=log_config.LOG_FILE_BACKUP_COUNT +) +to_file.setFormatter(formatter) + +# 将日志输出至屏幕 +logger.addHandler(to_console) +# 将日志输出至文件 +logger.addHandler(to_file) + +log = logger + + +if not os.path.exists(log_config.LOG_DIR): + os.makedirs(log_config.LOG_DIR) diff --git a/kdp_catalog_manager/utils/markdown_to_html/__init__.py b/kdp_catalog_manager/utils/markdown_to_html/__init__.py new file mode 100644 index 0000000..ff6ea1e --- /dev/null +++ b/kdp_catalog_manager/utils/markdown_to_html/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- diff --git a/kdp_catalog_manager/utils/markdown_to_html/markdownToHtml.py b/kdp_catalog_manager/utils/markdown_to_html/markdownToHtml.py new file mode 100644 index 0000000..b4f86a4 --- /dev/null +++ b/kdp_catalog_manager/utils/markdown_to_html/markdownToHtml.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- + +import os + +import markdown + +from kdp_catalog_manager.config.base_config import EXTEND_EXTENSION +from kdp_catalog_manager.utils.fileutils import FileUtils + +MD_HTML_CSS = "kdp_catalog_manager/utils/markdown_to_html/md_html.css" + + +class HtmlController(object): + def __init__(self, css_file=MD_HTML_CSS): + self.css_file = css_file + self.html_css = os.path.join(os.getcwd(), self.css_file) + + def get_css(self): + return FileUtils().read_file(self.html_css) + + def create(self, data, file_name: str): + css_data = self.get_css() + html_data = css_data + data + FileUtils().write_file(html_data, file_name) + + +class MarkdownToHtlm(HtmlController): + def __init__(self): + super().__init__() + self.extensions = [ + 'extra', + 'tables', + 'codehilite' + ] + self.extend_extensions() + + def extend_extensions(self): + if EXTEND_EXTENSION: + custom_extensions = EXTEND_EXTENSION.split(",") + self.extensions.extend(custom_extensions) + + def mutate_markdown_data(self, input_file): + try: + markdown_data = FileUtils().read_file(input_file) + html_data = markdown.markdown( + markdown_data, + extensions=self.extensions + ) + return html_data + except Exception: + return None + + def markdown_to_html(self, input_file, output_file, output_dir=None): + markdown_html_data = self.mutate_markdown_data(input_file) + if output_dir is not None: + if not os.path.exists(output_dir): + os.makedirs(output_dir) + output_file = os.path.join(output_dir, output_file) + self.create(markdown_html_data, output_file) diff --git a/kdp_catalog_manager/utils/markdown_to_html/md_html.css b/kdp_catalog_manager/utils/markdown_to_html/md_html.css new file mode 100644 index 0000000..1a66764 --- /dev/null +++ b/kdp_catalog_manager/utils/markdown_to_html/md_html.css @@ -0,0 +1,259 @@ + + \ No newline at end of file diff --git a/kdp_catalog_manager/utils/markdown_to_html/test_markdownToHtml.py b/kdp_catalog_manager/utils/markdown_to_html/test_markdownToHtml.py new file mode 100644 index 0000000..278d517 --- /dev/null +++ b/kdp_catalog_manager/utils/markdown_to_html/test_markdownToHtml.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +import os.path +from unittest import TestCase + +from .markdownToHtml import HtmlController, MarkdownToHtlm + + +class TestHtmlController(TestCase): + def setUp(self): + + self.css_file = HtmlController().css_file + with open(self.css_file, "r") as f: + self.css_data = f.read() + + def test_get_css(self): + rt = HtmlController().get_css() + self.assertEqual(rt, self.css_data) + + def test_create(self): + output_file = "test.html" + HtmlController().create("test", output_file) + if os.path.exists(output_file): + self.assertEqual(True, True) + os.remove("test.html") + + +class TestMarkdownToHtlm(TestCase): + def setUp(self): + self.markdown_data = "# desc" + self.markdown_file = "test.md" + with open(self.markdown_file, "w") as f: + f.write(self.markdown_data) + + def test_mutate_markdown_data(self): + rt = MarkdownToHtlm().mutate_markdown_data(self.markdown_file) + self.assertEqual(rt, '

desc

') + + def tearDown(self): + os.remove(self.markdown_file) diff --git a/kdp_catalog_manager/utils/test_dictutils.py b/kdp_catalog_manager/utils/test_dictutils.py new file mode 100644 index 0000000..2dc3870 --- /dev/null +++ b/kdp_catalog_manager/utils/test_dictutils.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +from unittest import TestCase +from .dictutils import DictUtils + + +class TestDicuUtils(TestCase): + def setUp(self): + self.raw = {"aa": 123, "bb": "123"} + + def test_get_items(self): + rt = DictUtils().get_items(self.raw, ["aa"]) + self.assertEqual(rt, 123) + + def test_get_items_not_found(self): + rt = DictUtils().get_items(self.raw, ["ab"]) + self.assertEqual(rt, None) + + def test_get_items1(self): + rt = DictUtils().get_items(self.raw, ".aa") + self.assertEqual(rt, 123) + + def test_get_items1_not_found(self): + rt = DictUtils().get_items(self.raw, ".ab") + self.assertEqual(rt, None) + + def test_get_items2(self): + rt = DictUtils().get_items(self.raw, "aa") + self.assertEqual(rt, 123) + + def test_get_items2_not_found(self): + rt = DictUtils().get_items(self.raw, "ab") + self.assertEqual(rt, None) + + def test_get_items3(self): + rt = DictUtils().get_items(self.raw, "bb") + self.assertEqual(rt, "123") diff --git a/kdp_catalog_manager/utils/test_fileutils.py b/kdp_catalog_manager/utils/test_fileutils.py new file mode 100644 index 0000000..ede2356 --- /dev/null +++ b/kdp_catalog_manager/utils/test_fileutils.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +import os +from unittest import TestCase +from unittest.mock import patch, mock_open + +from kdp_catalog_manager.exceptions.exception import FileNotExistsError, \ + ReadFileError, WriteFileError +from .fileutils import FileUtils + + +class TestFileUtils(TestCase): + def setUp(self): + self.file = "test.log" + with open(self.file, "a") as f: + f.write("test") + + def test_check_file_exists(self): + # 测试文件是否存在的情况 + with patch('os.path.lexists') as mock_lexists: + mock_lexists.return_value = True + self.assertTrue(FileUtils.check_file_exists(self.file)) + + # 测试文件不存在的情况 + with patch('os.path.lexists') as mock_lexists: + mock_lexists.return_value = False + self.assertFalse(FileUtils.check_file_exists('a.log')) + + @patch('builtins.open', new_callable=mock_open, read_data='test') + def test_read_file(self, mock_open): + # 测试文件已存在 + rt = FileUtils().read_file(self.file) + self.assertEqual(rt, "test") + + # 测试读取不存在的文件的情况 + with self.assertRaises(FileNotExistsError): + FileUtils().read_file("a.log") + + # 测试读取文件时发生IOError的情况 + with patch('builtins.open', side_effect=IOError): + with self.assertRaises(ReadFileError): + FileUtils().read_file(self.file) + + @patch('builtins.open', new_callable=mock_open) + def test_write_file(self, mock_open): + FileUtils().write_file("test1", self.file) + + # 测试写文件时发生IOError的情况 + with patch('builtins.open', side_effect=IOError): + with self.assertRaises(WriteFileError): + FileUtils().write_file("test1", self.file) + + def tearDown(self): + os.remove(self.file) diff --git a/kdp_catalog_manager/utils/test_yamlutils.py b/kdp_catalog_manager/utils/test_yamlutils.py new file mode 100644 index 0000000..28e2c8c --- /dev/null +++ b/kdp_catalog_manager/utils/test_yamlutils.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +import os +from unittest import TestCase +from unittest.mock import patch, mock_open + +import yaml +from yaml import SafeLoader + +from .yamlutils import YAMLUtils, FileNotExistsError, LoadYamlError + + +class TestYamlUtils(TestCase): + def setUp(self): + self.data = { + "name": "test", + "city": { + "en": "china" + } + } + self.yaml_file = "test.yaml" + self.yaml_data = yaml.dump(self.data) + with open(self.yaml_file, "w") as f: + f.write(yaml.dump(self.data)) + + def test_load_all_yaml_many(self): + self.yaml_data = """--- +name: test + +--- +name: test1""" + with open(self.yaml_file, "w") as f: + f.write(self.yaml_data) + rt = YAMLUtils().load_all_yaml(self.yaml_file) + self.assertEqual(rt, [{"name": "test"}, {"name": "test1"}]) + + def test_load_all_yaml(self): + with open(self.yaml_file, "w") as f: + f.write(self.yaml_data) + rt = YAMLUtils().load_all_yaml(self.yaml_file) + self.assertEqual(rt, self.data) + + @patch('os.path.exists', return_value=False) + def test_load_all_yaml_non_existing_file(self, mock_exists): + with self.assertRaises(FileNotExistsError): + YAMLUtils.load_all_yaml('t.yaml') + + @patch('builtins.open', side_effect=Exception) + @patch('os.path.exists', return_value=True) + def test_load_all_yaml_exception_handling(self, mock_exists, mock_open): + with self.assertRaises(LoadYamlError): + YAMLUtils.load_all_yaml(self.yaml_file) + + def test_load_yaml(self): + rt = YAMLUtils().load_yaml(self.yaml_file) + self.assertEqual(rt, self.data) + + def test_load_yaml_non_existing_file(self): + with self.assertRaises(FileNotExistsError): + YAMLUtils().load_yaml("tt.yaml") + + def test_load_yaml_err(self): + err_log_file = "err.log" + self.yaml_data = """name: -""" + with open(err_log_file, "w") as f: + f.write(self.yaml_data) + with self.assertRaises(LoadYamlError): + YAMLUtils().load_yaml(err_log_file) + os.remove(err_log_file) + + @patch('yaml.dump_all') + def test_json_to_yaml(self, mock_dump_all): + YAMLUtils().json_to_yaml(self.data) + mock_dump_all.assert_called_once_with( + self.data, default_flow_style=False) + + @patch('yaml.dump') + def test_json_to_yaml_single(self, mock_dump): + YAMLUtils().json_to_yaml(self.data, dump_all=False) + mock_dump.assert_called_once_with(self.data, default_flow_style=False) + + @patch('yaml.load') + def test_yaml_to_json_single(self, mock_load_all): + YAMLUtils().single_yaml_to_json(self.yaml_data) + mock_load_all.assert_called_once_with( + self.yaml_data, + SafeLoader + ) + + def tearDown(self): + os.remove(self.yaml_file) diff --git a/kdp_catalog_manager/utils/yamlutils.py b/kdp_catalog_manager/utils/yamlutils.py new file mode 100644 index 0000000..2e80792 --- /dev/null +++ b/kdp_catalog_manager/utils/yamlutils.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# @Time : 2021/1/19 10:07 上午 +import os + +import yaml + +from kdp_catalog_manager.exceptions.exception import LoadYamlError, \ + FileNotExistsError +from kdp_catalog_manager.utils.log import log + + +class YAMLUtils(object): + @staticmethod + def load_all_yaml(yaml_file_path): + if not os.path.exists(yaml_file_path): + raise FileNotExistsError(f"The source yaml file {yaml_file_path} not exists") + try: + data = [] + with open(yaml_file_path, 'r') as configfile: + yaml_file_content = yaml.safe_load_all(configfile) + for value in yaml_file_content: + data.append(value) + if len(data) == 1: + return data[0] + # yaml_file_content's type is dict + return data + except Exception as e: + log.exception(str(e), exc_info=True) + raise LoadYamlError(e) + + @staticmethod + def load_yaml(yaml_file_path): + if not os.path.exists(yaml_file_path): + raise FileNotExistsError(f"The source yaml file {yaml_file_path} not exists") + try: + with open(yaml_file_path, 'r') as configfile: + yaml_file_content = yaml.safe_load(configfile) + # yaml_file_content's type is dict + data = yaml_file_content + return data + except Exception as e: + log.exception(str(e), exc_info=True) + raise LoadYamlError(e) + + @staticmethod + def json_to_yaml(json_data, dump_all=True): + try: + if not dump_all: + return yaml.dump( + json_data, + default_flow_style=False + ) + return yaml.dump_all( + json_data, + default_flow_style=False + ) + except Exception as e: + log.exception(str(e), exc_info=True) + raise + + @staticmethod + def single_yaml_to_json(yaml_data): + try: + _py_object_data = yaml.safe_load(yaml_data) + return _py_object_data + except Exception as e: + log.exception(str(e), exc_info=True) + raise diff --git a/makefiles/build-image.mk b/makefiles/build-image.mk new file mode 100644 index 0000000..0920745 --- /dev/null +++ b/makefiles/build-image.mk @@ -0,0 +1,23 @@ +# Docker image info +IMG ?= linktimecloud/kdp-catalog-manager:$(VERSION) +IMG_REGISTRY ?= "" + +.PHONY: set-build-info +set-build-info: + git log -1 --pretty=format:"{ \"GitVersion\": {%n \"branch\": \"$(GIT_BRANCH)\",%n \"commit\": \"%H\",%n \"author\": \"%an <%ae>\",%n \"date\": \"%ai\",%n \"message\": \"$(GIT_COMMIT_MESSAGE)\",%n \"tag\": \"$(GIT_TAG)\"%n},\"Version\": \"$(VERSION)\"%n,\"BuildDate\": \"$(BUILD_DATE)\"%n}" > kdp_catalog_manager/git.json + +.PHONY: docker-build +docker-build: docker-build-image + @echo "Docker build complete." + +.PHONY: docker-build-image +docker-build-image: + docker build -t $(IMG_REGISTRY)/$(IMG) -f Dockerfile . + +.PHONY: docker-push +docker-push: docker-push-image + @echo "Docker push complete." + +.PHONY: docker-push-image +docker-push-image: + docker push $(IMG_REGISTRY)/$(IMG) diff --git a/makefiles/const.mk b/makefiles/const.mk new file mode 100644 index 0000000..3a63895 --- /dev/null +++ b/makefiles/const.mk @@ -0,0 +1,25 @@ +# Setting SHELL to bash allows bash commands to be executed by recipes. +SHELL = /usr/bin/env bash -o pipefail +.SHELL_FLAGS = -ec + + +# Git Repo info +BUILD_DATE := $(shell date -u +'%Y-%m-%dT%H:%M:%SZ') + +GIT_COMMIT ?= git-$(shell git rev-parse --short HEAD) +GIT_COMMIT_LONG ?= $(shell git rev-parse HEAD) +GIT_COMMIT_MESSAGE := $(shell git log -1 --pretty=format:"%s" | sed 's/"/\\"/g') +GIT_REMOTE := origin +GIT_BRANCH := $(shell git rev-parse --symbolic-full-name --verify --quiet --abbrev-ref HEAD) +GIT_TAG := $(shell git describe --exact-match --tags --abbrev=0 2> /dev/null || echo untagged) +GIT_TREE_STATE := $(shell if [[ -z "`git status --porcelain`" ]]; then echo "clean" ; else echo "dirty"; fi) +RELEASE_TAG := $(shell if [[ "$(GIT_TAG)" =~ ^[.\|v][0-9]+\.[0-9]+\.[0-9]+.*$$ ]]; then echo "true"; else echo "false"; fi) + +VERSION := latest +ifeq ($(RELEASE_TAG),true) +VERSION := $(GIT_TAG) +endif + +$(info GIT_COMMIT=$(GIT_COMMIT) GIT_COMMIT_MESSAGE=$(GIT_COMMIT_MESSAGE) GIT_BRANCH=$(GIT_BRANCH) GIT_TAG=$(GIT_TAG) GIT_TREE_STATE=$(GIT_TREE_STATE) RELEASE_TAG=$(RELEASE_TAG) VERSION=$(VERSION)) + +ARTIFACTS_SERVER ?= "" \ No newline at end of file From 4da61cdcd2bf05f0f1f77cd38bd7b46a7edf321d Mon Sep 17 00:00:00 2001 From: harlee-x Date: Mon, 1 Apr 2024 11:35:06 +0800 Subject: [PATCH 2/2] Fix: update --- .github/dependabot.yml | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6b9490e..3fef70f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,19 +6,23 @@ version: 2 updates: - - package-ecosystem: "gomod" # See documentation for possible values - directory: "/" # Location of package manifests + - package-ecosystem: "github-actions" + directory: "/" schedule: interval: "weekly" + time: "1" commit-message: prefix: "Chore: " include: "scope" - ignore: - - dependency-name: k8s.io/* - - package-ecosystem: "github-actions" - directory: "/" + - package-ecosystem: "pip" # See documentation for possible values + directory: "/docker/python" # Location of package manifests schedule: interval: "weekly" + groups: + python-packages: + patterns: + - "*" commit-message: prefix: "Chore: " - include: "scope" + include: "scope" +