From f61b8be6d3659f80eade946e47f0184be828098e Mon Sep 17 00:00:00 2001 From: chenc136 Date: Tue, 8 Oct 2024 20:01:02 +0800 Subject: [PATCH 1/2] feat: add configtrace module support --- docker/sysom_base_dockerfile | 2 +- docker/sysom_base_lite_dockerfile | 2 +- script/server/sysom_config_trace/clear.sh | 8 + script/server/sysom_config_trace/init.sh | 40 ++ script/server/sysom_config_trace/install.sh | 29 ++ .../sysom_config_trace/requirements.txt | 8 + script/server/sysom_config_trace/start.sh | 7 + script/server/sysom_config_trace/stop.sh | 7 + .../sysom_config_trace/sysom-configtrace.ini | 9 + .../sysom_config_trace/test-requirements.txt | 7 + script/server/sysom_config_trace/uninstall.sh | 9 + sysom_server/sysom_config_trace/README.md | 14 + sysom_server/sysom_config_trace/config.yml | 57 ++ .../sysom_config_trace/config_trace.conf | 4 + .../config_trace/__init__.py | 0 .../config_trace/__main__.py | 49 ++ .../config_trace/const/__init__.py | 0 .../config_trace/const/common.py | 66 +++ .../config_trace/const/conf_files.py | 27 + .../config_trace/const/conf_handler_const.py | 34 ++ .../config_trace/controllers/__init__.py | 0 .../controllers/authorization_controller.py | 6 + .../controllers/confs_controller.py | 356 +++++++++++++ .../controllers/domain_controller.py | 352 +++++++++++++ .../config_trace/controllers/format.py | 256 +++++++++ .../config_trace/jobs/confs_job.py | 135 +++++ .../config_trace/models/__init__.py | 10 + .../config_trace/models/base_model_.py | 69 +++ .../config_trace/models/configration.py | 148 ++++++ .../config_trace/models/confs.py | 89 ++++ .../config_trace/models/domain.py | 120 +++++ .../config_trace/models/domain_body.py | 92 ++++ .../config_trace/models/git_log_message.py | 194 +++++++ .../config_trace/models/host.py | 120 +++++ .../config_trace/models/v1_confs_body.py | 92 ++++ .../config_trace/models/v1_confs_body1.py | 63 +++ .../config_trace/swagger/swagger.yaml | 484 +++++++++++++++++ .../config_trace/test/__init__.py | 16 + .../test/test_confs_controller.py | 63 +++ .../test/test_default_controller.py | 29 ++ .../test/test_domain_controller.py | 96 ++++ .../config_trace/test/test_host_controller.py | 29 ++ .../config_trace/type_util.py | 32 ++ .../sysom_config_trace/config_trace/util.py | 142 +++++ .../config_trace/utils/__init__.py | 0 .../config_trace/utils/conf_tools.py | 492 ++++++++++++++++++ .../config_trace/utils/git_tools.py | 183 +++++++ .../config_trace/utils/host_tools.py | 96 ++++ .../config_trace/utils/prepare.py | 43 ++ ...L\346\227\266\345\272\217\345\233\276.png" | Bin 0 -> 124471 bytes .../sysom_config_trace/scripts/node_clear.sh | 10 + .../sysom_config_trace/scripts/node_init.sh | 46 ++ .../sysom_config_trace/scripts/node_update.sh | 1 + .../scripts/trace_file_change.py | 251 +++++++++ sysom_server/sysom_config_trace/setup.py | 36 ++ sysom_server/sysom_config_trace/tox.ini | 10 + sysom_web/config/routes.js | 29 ++ sysom_web/package.json | 4 + sysom_web/src/pages/configtrace/alerts.jsx | 54 ++ .../configtrace/components/AlertList.jsx | 66 +++ .../configtrace/components/ConfsDiffView.jsx | 41 ++ .../configtrace/components/ConfsList.jsx | 339 ++++++++++++ .../configtrace/components/DomainList.jsx | 219 ++++++++ .../components/DomainModalForm.jsx | 163 ++++++ .../pages/configtrace/components/HostList.jsx | 164 ++++++ sysom_web/src/pages/configtrace/confs.jsx | 57 ++ .../src/pages/configtrace/domain_hosts.jsx | 85 +++ sysom_web/src/pages/configtrace/domains.jsx | 60 +++ sysom_web/src/pages/configtrace/service.js | 311 +++++++++++ 69 files changed, 6130 insertions(+), 2 deletions(-) create mode 100644 script/server/sysom_config_trace/clear.sh create mode 100644 script/server/sysom_config_trace/init.sh create mode 100644 script/server/sysom_config_trace/install.sh create mode 100644 script/server/sysom_config_trace/requirements.txt create mode 100644 script/server/sysom_config_trace/start.sh create mode 100644 script/server/sysom_config_trace/stop.sh create mode 100644 script/server/sysom_config_trace/sysom-configtrace.ini create mode 100644 script/server/sysom_config_trace/test-requirements.txt create mode 100644 script/server/sysom_config_trace/uninstall.sh create mode 100644 sysom_server/sysom_config_trace/README.md create mode 100644 sysom_server/sysom_config_trace/config.yml create mode 100644 sysom_server/sysom_config_trace/config_trace.conf create mode 100644 sysom_server/sysom_config_trace/config_trace/__init__.py create mode 100644 sysom_server/sysom_config_trace/config_trace/__main__.py create mode 100644 sysom_server/sysom_config_trace/config_trace/const/__init__.py create mode 100644 sysom_server/sysom_config_trace/config_trace/const/common.py create mode 100644 sysom_server/sysom_config_trace/config_trace/const/conf_files.py create mode 100644 sysom_server/sysom_config_trace/config_trace/const/conf_handler_const.py create mode 100644 sysom_server/sysom_config_trace/config_trace/controllers/__init__.py create mode 100644 sysom_server/sysom_config_trace/config_trace/controllers/authorization_controller.py create mode 100644 sysom_server/sysom_config_trace/config_trace/controllers/confs_controller.py create mode 100644 sysom_server/sysom_config_trace/config_trace/controllers/domain_controller.py create mode 100644 sysom_server/sysom_config_trace/config_trace/controllers/format.py create mode 100644 sysom_server/sysom_config_trace/config_trace/jobs/confs_job.py create mode 100644 sysom_server/sysom_config_trace/config_trace/models/__init__.py create mode 100644 sysom_server/sysom_config_trace/config_trace/models/base_model_.py create mode 100644 sysom_server/sysom_config_trace/config_trace/models/configration.py create mode 100644 sysom_server/sysom_config_trace/config_trace/models/confs.py create mode 100644 sysom_server/sysom_config_trace/config_trace/models/domain.py create mode 100644 sysom_server/sysom_config_trace/config_trace/models/domain_body.py create mode 100644 sysom_server/sysom_config_trace/config_trace/models/git_log_message.py create mode 100644 sysom_server/sysom_config_trace/config_trace/models/host.py create mode 100644 sysom_server/sysom_config_trace/config_trace/models/v1_confs_body.py create mode 100644 sysom_server/sysom_config_trace/config_trace/models/v1_confs_body1.py create mode 100644 sysom_server/sysom_config_trace/config_trace/swagger/swagger.yaml create mode 100644 sysom_server/sysom_config_trace/config_trace/test/__init__.py create mode 100644 sysom_server/sysom_config_trace/config_trace/test/test_confs_controller.py create mode 100644 sysom_server/sysom_config_trace/config_trace/test/test_default_controller.py create mode 100644 sysom_server/sysom_config_trace/config_trace/test/test_domain_controller.py create mode 100644 sysom_server/sysom_config_trace/config_trace/test/test_host_controller.py create mode 100644 sysom_server/sysom_config_trace/config_trace/type_util.py create mode 100644 sysom_server/sysom_config_trace/config_trace/util.py create mode 100644 sysom_server/sysom_config_trace/config_trace/utils/__init__.py create mode 100644 sysom_server/sysom_config_trace/config_trace/utils/conf_tools.py create mode 100644 sysom_server/sysom_config_trace/config_trace/utils/git_tools.py create mode 100644 sysom_server/sysom_config_trace/config_trace/utils/host_tools.py create mode 100644 sysom_server/sysom_config_trace/config_trace/utils/prepare.py create mode 100644 "sysom_server/sysom_config_trace/docs/UML\346\227\266\345\272\217\345\233\276.png" create mode 100644 sysom_server/sysom_config_trace/scripts/node_clear.sh create mode 100644 sysom_server/sysom_config_trace/scripts/node_init.sh create mode 100644 sysom_server/sysom_config_trace/scripts/node_update.sh create mode 100644 sysom_server/sysom_config_trace/scripts/trace_file_change.py create mode 100644 sysom_server/sysom_config_trace/setup.py create mode 100644 sysom_server/sysom_config_trace/tox.ini create mode 100644 sysom_web/src/pages/configtrace/alerts.jsx create mode 100644 sysom_web/src/pages/configtrace/components/AlertList.jsx create mode 100644 sysom_web/src/pages/configtrace/components/ConfsDiffView.jsx create mode 100644 sysom_web/src/pages/configtrace/components/ConfsList.jsx create mode 100644 sysom_web/src/pages/configtrace/components/DomainList.jsx create mode 100644 sysom_web/src/pages/configtrace/components/DomainModalForm.jsx create mode 100644 sysom_web/src/pages/configtrace/components/HostList.jsx create mode 100644 sysom_web/src/pages/configtrace/confs.jsx create mode 100644 sysom_web/src/pages/configtrace/domain_hosts.jsx create mode 100644 sysom_web/src/pages/configtrace/domains.jsx create mode 100644 sysom_web/src/pages/configtrace/service.js diff --git a/docker/sysom_base_dockerfile b/docker/sysom_base_dockerfile index b75442ff..ec766ec0 100644 --- a/docker/sysom_base_dockerfile +++ b/docker/sysom_base_dockerfile @@ -42,7 +42,7 @@ COPY --from=web_builder /root/sysom_web/dist /usr/local/sysom/web RUN bash -x /root/sysom/script/sysom.sh install deps ALL RUN bash -x /root/sysom/script/sysom.sh install env ALL -RUN bash -x /root/sysom/script/sysom.sh install ms sysom_api,sysom_diagnosis,sysom_channel,sysom_monitor_server,sysom_log,sysom_alarm,sysom_cmg +RUN bash -x /root/sysom/script/sysom.sh install ms sysom_api,sysom_diagnosis,sysom_channel,sysom_monitor_server,sysom_log,sysom_alarm,sysom_cmg,sysom_configtrace RUN yum clean all diff --git a/docker/sysom_base_lite_dockerfile b/docker/sysom_base_lite_dockerfile index df9a713c..742ae16f 100644 --- a/docker/sysom_base_lite_dockerfile +++ b/docker/sysom_base_lite_dockerfile @@ -41,7 +41,7 @@ COPY --from=web_builder /root/sysom_web/dist /usr/local/sysom/web RUN bash -x /root/sysom/script/sysom.sh install deps nginx RUN bash -x /root/sysom/script/sysom.sh install env ALL -RUN bash -x /root/sysom/script/sysom.sh install ms sysom_api,sysom_diagnosis,sysom_channel,sysom_monitor_server,sysom_log,sysom_cmg +RUN bash -x /root/sysom/script/sysom.sh install ms sysom_api,sysom_diagnosis,sysom_channel,sysom_monitor_server,sysom_log,sysom_cmg,sysom_configtrace RUN yum clean all diff --git a/script/server/sysom_config_trace/clear.sh b/script/server/sysom_config_trace/clear.sh new file mode 100644 index 00000000..f80ffae2 --- /dev/null +++ b/script/server/sysom_config_trace/clear.sh @@ -0,0 +1,8 @@ +#!/bin/bash +SERVICE_NAME=sysom-configtrace +clear_app() { + rm -rf /etc/supervisord.d/${SERVICE_NAME}.ini + ###use supervisorctl update to stop and clear services### + supervisorctl update +} +clear_app diff --git a/script/server/sysom_config_trace/init.sh b/script/server/sysom_config_trace/init.sh new file mode 100644 index 00000000..8f6efc03 --- /dev/null +++ b/script/server/sysom_config_trace/init.sh @@ -0,0 +1,40 @@ +#!/bin/bash +VIRTUALENV_HOME=$GLOBAL_VIRTUALENV_HOME +SERVICE_NAME=sysom-configtrace + +if [ "$UID" -ne 0 ]; then + echo "Please run as root" + exit 1 +fi + +source_virtualenv() { + echo "INFO: activate virtualenv..." + source ${VIRTUALENV_HOME}/bin/activate || exit 1 +} + +init_conf() { + cp ${SERVICE_NAME}.ini /etc/supervisord.d/ + ###change the install dir base on param $1### + sed -i "s;/usr/local/sysom;${APP_HOME};g" /etc/supervisord.d/${SERVICE_NAME}.ini +} + +start_app() { + ###if supervisor service started, we need use "supervisorctl update" to start new conf#### + supervisorctl update + supervisorctl status ${SERVICE_NAME} + if [ $? -eq 0 ] + then + echo "supervisorctl start ${SERVICE_NAME} success..." + return 0 + fi + echo "${SERVICE_NAME} service start fail, please check log" + exit 1 +} + +deploy() { + source_virtualenv + init_conf + start_app +} + +deploy diff --git a/script/server/sysom_config_trace/install.sh b/script/server/sysom_config_trace/install.sh new file mode 100644 index 00000000..c6ca018a --- /dev/null +++ b/script/server/sysom_config_trace/install.sh @@ -0,0 +1,29 @@ +#!/bin/bash +SERVICE_SCRIPT_DIR=$(basename $(dirname $0)) +SERVICE_HOME=${MICROSERVICE_HOME}/${SERVICE_SCRIPT_DIR} +SERVICE_SCRIPT_HOME=${MICROSERVICE_SCRIPT_HOME}/${SERVICE_SCRIPT_DIR} +VIRTUALENV_HOME=$GLOBAL_VIRTUALENV_HOME +SERVICE_NAME=sysom-configtrace + +if [ "$UID" -ne 0 ]; then + echo "Please run as root" + exit 1 +fi + +install_requirement() { + pushd ${SERVICE_SCRIPT_HOME} + pip install -r requirements.txt + popd +} + +source_virtualenv() { + echo "INFO: activate virtualenv..." + source ${VIRTUALENV_HOME}/bin/activate || exit 1 +} + +install_app() { + source_virtualenv + install_requirement +} + +install_app diff --git a/script/server/sysom_config_trace/requirements.txt b/script/server/sysom_config_trace/requirements.txt new file mode 100644 index 00000000..2b8b4af8 --- /dev/null +++ b/script/server/sysom_config_trace/requirements.txt @@ -0,0 +1,8 @@ +connexion >= 2.6.0 +connexion[swagger-ui] >= 2.6.0 +python_dateutil == 2.6.0 +setuptools >= 21.0.0 +swagger-ui-bundle >= 0.0.2 +clogger>=0.0.1 +bcc==0.1.10 +pytz \ No newline at end of file diff --git a/script/server/sysom_config_trace/start.sh b/script/server/sysom_config_trace/start.sh new file mode 100644 index 00000000..553ed255 --- /dev/null +++ b/script/server/sysom_config_trace/start.sh @@ -0,0 +1,7 @@ +#!/bin/bash +SERVICE_NAME=sysom-configtrace +start_app() { + supervisorctl start $SERVICE_NAME +} + +start_app diff --git a/script/server/sysom_config_trace/stop.sh b/script/server/sysom_config_trace/stop.sh new file mode 100644 index 00000000..a12ba7c4 --- /dev/null +++ b/script/server/sysom_config_trace/stop.sh @@ -0,0 +1,7 @@ +#!/bin/bash +SERVICE_NAME=sysom-configtrace +stop_app() { + supervisorctl stop $SERVICE_NAME +} + +stop_app diff --git a/script/server/sysom_config_trace/sysom-configtrace.ini b/script/server/sysom_config_trace/sysom-configtrace.ini new file mode 100644 index 00000000..b8ad6755 --- /dev/null +++ b/script/server/sysom_config_trace/sysom-configtrace.ini @@ -0,0 +1,9 @@ +[program:sysom-configtrace] +directory = /usr/local/sysom/server/sysom_config_trace +command=/usr/local/sysom/environment/virtualenv/bin/uvicorn config_trace.__main__:main --port 7031 +startsecs=3 +autostart=true +autorestart=true +environment=PATH=/usr/local/sysom/virtualenv/bin:%(ENV_PATH)s +stderr_logfile=/var/log/sysom/sysom-configtrace-error.log +stdout_logfile=/var/log/sysom/sysom-configtrace.log diff --git a/script/server/sysom_config_trace/test-requirements.txt b/script/server/sysom_config_trace/test-requirements.txt new file mode 100644 index 00000000..2640639a --- /dev/null +++ b/script/server/sysom_config_trace/test-requirements.txt @@ -0,0 +1,7 @@ +flask_testing==0.8.0 +coverage>=4.0.3 +nose>=1.3.7 +pluggy>=0.3.1 +py>=1.4.31 +randomize>=0.13 +tox==3.20.1 diff --git a/script/server/sysom_config_trace/uninstall.sh b/script/server/sysom_config_trace/uninstall.sh new file mode 100644 index 00000000..0aac04c0 --- /dev/null +++ b/script/server/sysom_config_trace/uninstall.sh @@ -0,0 +1,9 @@ +#!/bin/bash +BaseDir=$(dirname $(readlink -f "$0")) + +uninstall_app() { + # do nothing + echo "" +} + +uninstall_app \ No newline at end of file diff --git a/sysom_server/sysom_config_trace/README.md b/sysom_server/sysom_config_trace/README.md new file mode 100644 index 00000000..263280eb --- /dev/null +++ b/sysom_server/sysom_config_trace/README.md @@ -0,0 +1,14 @@ +# configtrace设计及实现 + +## 设计 + +![UML时序图](docs/UML时序图.png) + +## 实现 + +### TODO + +1. 实现定时任务更新域配置 +2. 受控节点汇报ebpf文件变更信息到管控节点 +3. 写.changelog +4. 读.changelog \ No newline at end of file diff --git a/sysom_server/sysom_config_trace/config.yml b/sysom_server/sysom_config_trace/config.yml new file mode 100644 index 00000000..345cf116 --- /dev/null +++ b/sysom_server/sysom_config_trace/config.yml @@ -0,0 +1,57 @@ +vars: + SERVICE_NAME: &SERVICE_NAME sysom_config_trace + SERVICE_CONSUMER_GROUP: + !concat &SERVICE_CONSUMER_GROUP [*SERVICE_NAME, "_consumer_group"] + +sysom_server: + cec: + consumer_group: *SERVICE_CONSUMER_GROUP + channel_job: + target_topic: SYSOM_CEC_CHANNEL_TOPIC + listen_topic: SYSOM_CEC_CHANNEL_CONFIGTRACE_TOPIC + consumer_group: *SERVICE_CONSUMER_GROUP + +sysom_service: + service_name: *SERVICE_NAME + service_dir: *SERVICE_NAME + config_path: config_trace.conf + +# 节点测配置 +sysom_node: + version: 2.1 + # 节点分发配置 + delivery: + from_dir: scripts + to_dir: node + files: + comm: &code_delivery_files_comm + - local: node_init.sh + remote: + - local: node_clear.sh + remote: + - local: node_update.sh + remote: + - local: trace_file_change.py + remote: + el7: + amd64: *code_delivery_files_comm + x86_64: *code_delivery_files_comm + uelc20: + amd64: *code_delivery_files_comm + x86_64: *code_delivery_files_comm + arm64: *code_delivery_files_comm + aarch64: *code_delivery_files_comm + ky10sp1: + amd64: *code_delivery_files_comm + x86_64: *code_delivery_files_comm + arm64: *code_delivery_files_comm + aarch64: *code_delivery_files_comm + ky10sp2: + amd64: *code_delivery_files_comm + x86_64: *code_delivery_files_comm + arm64: *code_delivery_files_comm + aarch64: *code_delivery_files_comm + scripts: + init: node_init.sh + clear: node_clear.sh + update: node_update.sh diff --git a/sysom_server/sysom_config_trace/config_trace.conf b/sysom_server/sysom_config_trace/config_trace.conf new file mode 100644 index 00000000..122c961e --- /dev/null +++ b/sysom_server/sysom_config_trace/config_trace.conf @@ -0,0 +1,4 @@ +[git] +git_dir = "/home/confTraceTest" +user_name = "user_name" +user_email = "user_email" \ No newline at end of file diff --git a/sysom_server/sysom_config_trace/config_trace/__init__.py b/sysom_server/sysom_config_trace/config_trace/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sysom_server/sysom_config_trace/config_trace/__main__.py b/sysom_server/sysom_config_trace/config_trace/__main__.py new file mode 100644 index 00000000..92199b03 --- /dev/null +++ b/sysom_server/sysom_config_trace/config_trace/__main__.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 + +import connexion +import os +import configparser +import ast +import sys +from channel_job import default_channel_job_executor +from config_trace.const.common import CHANNEL_JOB_URL, YAML_CONFIG, GCLIENT_PROTO, GCLIENT_HOST, GCLIENT_PORT +from sysom_utils import PluginEventExecutor +from config_trace.const.conf_handler_const import CONFIG +from config_trace.utils.prepare import Prepare +from config_trace.jobs.confs_job import start_job_schedule, stop_job_schedule +from gclient_base import dispatch_g_client + +def main(): + default_channel_job_executor.init_config(CHANNEL_JOB_URL, + g_client = dispatch_g_client(f"{GCLIENT_PROTO}://{GCLIENT_HOST}:{GCLIENT_PORT}")) + default_channel_job_executor.start() + PluginEventExecutor(YAML_CONFIG, default_channel_job_executor).start() + # prepare to load config + load_prepare() + app = connexion.App(__name__, specification_dir='./swagger/') + app.add_api('swagger.yaml', arguments={'title': 'Configration Tracability'}, pythonic_params=True) + app.run(host="0.0.0.0", port=int(sys.argv[3]) if len(sys.argv) > 3 else 8080) + + +def load_prepare(): + git_dir, git_user_name, git_user_email = load_conf() + prepare = Prepare(git_dir) + prepare.mkdir_git_warehose(git_user_name, git_user_email) + start_job_schedule() + + +def load_conf(): + cf = configparser.ConfigParser() + if os.path.exists(CONFIG): + cf.read(CONFIG, encoding="utf-8") + else: + cf.read("../config_trace.conf", encoding="utf-8") + git_dir = ast.literal_eval(cf.get("git", "git_dir")) + git_user_name = ast.literal_eval(cf.get("git", "user_name")) + git_user_email = ast.literal_eval(cf.get("git", "user_email")) + return git_dir, git_user_name, git_user_email + + + +if __name__ == '__main__': + main() diff --git a/sysom_server/sysom_config_trace/config_trace/const/__init__.py b/sysom_server/sysom_config_trace/config_trace/const/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sysom_server/sysom_config_trace/config_trace/const/common.py b/sysom_server/sysom_config_trace/config_trace/const/common.py new file mode 100644 index 00000000..ad5bd524 --- /dev/null +++ b/sysom_server/sysom_config_trace/config_trace/const/common.py @@ -0,0 +1,66 @@ +from clogger import logger +import os +from pathlib import Path +from sysom_utils import ConfigParser, CecTarget, SysomFramework + +BASE_DIR = Path(__file__).resolve().parent.parent.parent + +################################################################## +# Load yaml config first +################################################################## +YAML_GLOBAL_CONFIG_PATH = f"{BASE_DIR.parent.parent}/conf/config.yml" + +if not os.path.exists(YAML_GLOBAL_CONFIG_PATH): + YAML_GLOBAL_CONFIG_PATH = f"/etc/sysom/config.yml" + +YAML_SERVICE_CONFIG_PATH = f"{BASE_DIR}/config.yml" + +YAML_CONFIG = ConfigParser(YAML_GLOBAL_CONFIG_PATH, YAML_SERVICE_CONFIG_PATH) +################################################################## +# Cec settings +################################################################## +SYSOM_CEC_PRODUCER_URL = YAML_CONFIG.get_cec_url(CecTarget.PRODUCER) +# 配置管理模块消费组 +SYSOM_CEC_CONFIGTRACE_CONSUMER_GROUP = "sysom_configtrace_consumer_group" +# 配置管理任务下发主题(由 View -> Executor) +SYSOM_CEC_CONFIGTRACE_TASK_DISPATCH_TOPIC = "SYSOM_CEC_CONFIGTRACE_TASK_DISPATCH_TOPIC" + +# channl_job SDK 需要的url +CHANNEL_JOB_URL = YAML_CONFIG.get_local_channel_job_url() + +################################################################## +# GClient Config +################################################################## +GCLIENT_PROTO = 'http' +GCLIENT_HOST = '127.0.0.1' +GCLIENT_PORT = '7003' + +########################################################################################## +# Web Config +########################################################################################## + +SysomFramework.init(YAML_CONFIG) + +DEBUG = True + +LANGUAGE_CODE = 'zh-hans' +TIME_ZONE = YAML_CONFIG.get_global_config().timezone +USE_TZ = True + + + +################################################################## +# Config settings +################################################################## +# Config log format +log_format = YAML_CONFIG.get_server_config().logger.format +log_level = YAML_CONFIG.get_server_config().logger.level +logger.set_format(log_format) +logger.set_level(log_level) + + +CONFIG_FILE_PATH = YAML_CONFIG.get_service_config().config_path +SERVICE_DIR = YAML_CONFIG.get_service_config().service_dir +SERVER_ROOT_PATH = YAML_CONFIG.get_server_config().path.root_path + +CONFIG_FILE_ABS_PATH = os.path.join(SERVER_ROOT_PATH, SERVICE_DIR, CONFIG_FILE_PATH) \ No newline at end of file diff --git a/sysom_server/sysom_config_trace/config_trace/const/conf_files.py b/sysom_server/sysom_config_trace/config_trace/const/conf_files.py new file mode 100644 index 00000000..ad89b76e --- /dev/null +++ b/sysom_server/sysom_config_trace/config_trace/const/conf_files.py @@ -0,0 +1,27 @@ +# Author: Lay +# Description: default +# Date: 2023/6/7 9:06 + + +yang_conf_list = [ + "/etc/yum.repos.d/openEuler.repo", + "/etc/coremail/coremail.conf", + "/etc/ssh/sshd_config", + "/etc/ssh/ssh_config", + "/etc/sysctl.conf", + "/etc/ntp.conf", + "/etc/passwd", + "/etc/sudoers", + "/etc/shadow", + "/etc/group", + "/etc/hostname", + "/etc/fstab", + "/etc/ld.so.conf", + "/etc/security/limits.conf", + "/etc/resolv.conf", + "/etc/rc.local", + "/etc/bashrc", + "/etc/profile", + "/etc/hosts", + "/etc/pam.d" +] diff --git a/sysom_server/sysom_config_trace/config_trace/const/conf_handler_const.py b/sysom_server/sysom_config_trace/config_trace/const/conf_handler_const.py new file mode 100644 index 00000000..efb1094e --- /dev/null +++ b/sysom_server/sysom_config_trace/config_trace/const/conf_handler_const.py @@ -0,0 +1,34 @@ +#!/usr/bin/python3 +# ****************************************************************************** +# Copyright (c) Huawei Technologies Co., Ltd. 2021-2022. All rights reserved. +# licensed under the Mulan PSL v2. +# You can use this software according to the terms and conditions of the Mulan PSL v2. +# You may obtain a copy of Mulan PSL v2 at: +# http://license.coscl.org.cn/MulanPSL2 +# THIS SOFTWARE IS PROVIDED ON AN 'AS IS' BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR +# PURPOSE. +# See the Mulan PSL v2 for more details. +# ******************************************************************************/ +""" +@FileName: conf_handler_const.py +@Time: 2023/7/26 15:24 +@Author: JiaoSiMao +Description: +""" +import re + +from config_trace.const.common import CONFIG_FILE_ABS_PATH + + +NOT_SYNCHRONIZE = "NOT SYNCHRONIZE" +SYNCHRONIZED = "SYNCHRONIZED" +LIMITS_DOMAIN_RE = re.compile('(^[*]$)|(^[@|0-9A-Za-z]+[0-9A-Za-z]$)') +LIMITS_TYPE_VALUE = "soft|hard|-|" +LIMITS_ITEM_VALUE = "core|data|fsize|memlock|nofile|rss|stack|cpu|nproc|as|maxlogins|maxsyslogins|priority|locks|sigpending|" \ + "msgqueue|nice|rtprio|" +RESOLV_KEY_VALUE = "nameserver|domain|search|sortlist|" +FSTAB_COLUMN_NUM = 6 +PAM_FILE_PATH = "/etc/pam.d" +DIRECTORY_FILE_PATH_LIST = ["/etc/pam.d"] +CONFIG = CONFIG_FILE_ABS_PATH \ No newline at end of file diff --git a/sysom_server/sysom_config_trace/config_trace/controllers/__init__.py b/sysom_server/sysom_config_trace/config_trace/controllers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sysom_server/sysom_config_trace/config_trace/controllers/authorization_controller.py b/sysom_server/sysom_config_trace/config_trace/controllers/authorization_controller.py new file mode 100644 index 00000000..2f7b0bb3 --- /dev/null +++ b/sysom_server/sysom_config_trace/config_trace/controllers/authorization_controller.py @@ -0,0 +1,6 @@ +from typing import List +""" +controller generated to handled auth operation described at: +https://connexion.readthedocs.io/en/latest/security.html +""" + diff --git a/sysom_server/sysom_config_trace/config_trace/controllers/confs_controller.py b/sysom_server/sysom_config_trace/config_trace/controllers/confs_controller.py new file mode 100644 index 00000000..084c7b33 --- /dev/null +++ b/sysom_server/sysom_config_trace/config_trace/controllers/confs_controller.py @@ -0,0 +1,356 @@ +import connexion +import six +import os +import datetime +from config_trace.models.confs import Confs # noqa: E501 +from config_trace.models.configration import Configration +from config_trace.utils.git_tools import GitTools +from config_trace.models.v1_confs_body import V1ConfsBody # noqa: E501 +from config_trace.models.v1_confs_body1 import V1ConfsBody1 # noqa: E501 +from config_trace import util +from clogger import logger +from config_trace.controllers.format import Format +from config_trace.const.conf_handler_const import DIRECTORY_FILE_PATH_LIST +from config_trace.utils.conf_tools import ConfTools +from config_trace.controllers.domain_controller import list_domain_hosts + +TARGETDIR = GitTools().target_dir + +def get_confs_by_domain(host_name=None, domain_name=None): # noqa: E501 + """list domain confs + + # noqa: E501 + + :param host_name: + :type host_name: str + :param domain_name: + :type domain_name: str + + :rtype: List[Confs] + """ + + if domain_name == None or domain_name == "": + return fetchAllConfs() + else: + return fetchConfsByDomain(domain_name, host_name) + +def fetchConfsByDomain(domain_name, host_name): + if not os.path.exists(os.path.join(TARGETDIR, domain_name)): + return "The current domain does not exist, please create the domain first.", 400 + domain_path = os.path.join(TARGETDIR, domain_name) + confs = Confs(domain_name=domain_name, conf_files=[]) + # Traverse all files in the source management repository + conf_tools = ConfTools() + for root, dirs, files in os.walk(domain_path): + # Domain also contains host cache files, so we need to add hierarchical judgment for root + if len(files) > 0: + for d_file in files: + if d_file == "hostRecord.txt" or d_file == "domaininfo.txt" or d_file == ".changelog": + continue + d_file_path = os.path.join(root, d_file) + git_message = GitTools(TARGETDIR).getLogMessageByPath(d_file_path) + with open(d_file_path, 'r') as f: + expect_content = f.read() + real_content = "" + conf_real_path = d_file_path.split(domain_path)[1] + if host_name != "" and host_name != None: + real_content_items = conf_tools.fetchRealConfs(host_name, conf_real_path) + if real_content_items == None or len(real_content_items) == 0: + continue + real_content = real_content_items[conf_real_path][0] + conf_base_info = Configration(file_path=conf_real_path,expect_content=expect_content,real_content=real_content,change_log=git_message) + confs.conf_files.append(conf_base_info) + + if not confs: + return "The current domain does not exist, please create the domain first.", 400 + + return confs.to_dict(), 200 + + +def fetchAllConfs(): + #conf_tools = ConfTools() + cmd = "/bin/ls {}".format(TARGETDIR) + git_tools = GitTools(TARGETDIR) + res_domain = git_tools.run_shell_return_output(cmd).decode().split() + + if len(res_domain) == 0: + return "The current domain does not exist, please create the domain first.", 400 + + confs = None + for d_domian in res_domain: + domain_path = os.path.join(TARGETDIR, d_domian) + confs = Confs(domain_name=d_domian, + conf_files=[]) + # Traverse all files in the source management repository + for root, dirs, files in os.walk(domain_path): + # Domain also contains host cache files, so we need to add hierarchical judgment for root + if len(files) > 0: + for d_file in files: + if d_file == "hostRecord.txt" or d_file == "domaininfo.txt" or d_file == ".changelog": + continue + d_file_path = os.path.join(root, d_file) + git_message = git_tools.getLogMessageByPath(d_file_path) + with open(d_file_path, 'r') as f: + expect_content = f.read() + # real_content = "" + conf_real_path = d_file_path.split(domain_path)[1] + # if host_name != "" and host_name != None: + # real_content_items = conf_tools.fetchRealConfs(host_name, conf_real_path) + # if real_content_items == None or len(real_content_items) == 0: + # continue + # real_content = real_content_items[conf_real_path][0] + conf_base_info = Configration(file_path=conf_real_path,expect_content=expect_content,change_log=git_message) + confs.conf_files.append(conf_base_info) + + if not confs: + return "The current domain does not exist, please create the domain first.", 400 + + return confs.to_dict(), 200 + + +def sync_confs(action, body=None): # noqa: E501 + """sync confs + + # noqa: E501 + + :param action: + :type action: str + :param body: + :type body: dict | bytes + + :rtype: None + """ + if action != "sync": + return "The current action is not supported.", 400 + + content = V1ConfsBody.from_dict(body) + + domain_name = content.domain_name + # check the input domain + checkRes = Format.domainCheck(domain_name) + if not checkRes: + return "Failed to verify the input parameter, please check the input parameters.", 400 + + # check whether the domain exists + isExist = Format.isDomainExist(domain_name) + if not isExist: + return "The current domain does not exist, please create the domain first.", 400 + + # TODO: 2023-03-07 + # 将期望配置更新到domain对应主机上 + hosts = content.hosts + if isEmpty(hosts): + # fetch domain managed hosts + listed_hosts, code = list_domain_hosts(domain_name) + if code / 100 > 2: + return listed_hosts, code + hosts = [h['host_id'] for h in listed_hosts] + conf_tools = ConfTools() + + # 获取当前domain的期望配置 + confs, code = get_confs_by_domain("", domain_name) + if code / 100 > 2: + return confs, code + # 向每台主机下发期望配置 + success = [] + failed = [] + parent_dir = '{}/{}'.format(TARGETDIR, domain_name) + for conf in confs['conf_files']: + ok, nok = conf_tools.sync_confs(hosts, conf, parent_dir) + if ok: + success.append(ok) + if nok: + failed.append(nok) + return { + "success": success, + "failed": failed + }, 202 + +def isEmpty(hosts): + if not hosts: + return True + if len(hosts) ==0: + return True + + for h in hosts: + if h is not "": + return False + return True + +def update_confs_by_domain(body=None, domain_name=None, host_name=None): # noqa: E501 + """update domain confs + + # noqa: E501 + + :param domain_name: + :type domain_name: str + :param host_name: + :type host_name: str + + :rtype: None + """ + content = V1ConfsBody1.from_dict(body) # noqa: E501 + + # check the input domain + checkRes = Format.domainCheck(domain_name) + if not checkRes: + return "Failed to verify the input parameter, please check the input parameters.", 400 + + # check whether the domain exists + isExist = Format.isDomainExist(domain_name) + if not isExist: + return "The current domain does not exist, please create the domain first.", 400 + + if len(content.confs) == 0: + return "The confs list is empty, please check the input parameters.", 400 + + # TODO: 如果用户没有传入host_name,需要从domain host中获取其中一个,然后执行后续操作 + return upload_or_add_confs(content, domain_name, host_name) + + +def upload_or_add_confs(body=None, domain_name=None, host_name=None): + confs = body.confs + success_confs = [] + for conf in confs: + if conf.expect_content is None: + # 使用add conf + real_conf = add_conf(conf, domain_name, host_name) + if real_conf is None: + continue + success_confs.append(real_conf) + else: + # 使用upload conf + real_conf = upload_conf(conf, domain_name, host_name) + success_confs.append(real_conf) + return V1ConfsBody1(confs=success_confs).to_dict(), 202 + + +def upload_conf(conf=None, domain_name=None, host_name=None): + conf_tools = ConfTools() + + # 获取真实配置并放入到git中管理 + real_config_file_content = conf.expect_content + + result_configuration = [] + + if len(real_config_file_content) == 0: + return None + # 放置到git管理中 + conf_tools.wirteFileInPath('{}/{}{}'.format(TARGETDIR, domain_name, conf.file_path), real_config_file_content) + result_configuration.append(Configration(conf.file_path, expect_content=real_config_file_content)) + # 返回confs + git_tools = GitTools(TARGETDIR) + commit_code = git_tools.gitCommit("Add the conf in {} domain, ".format(domain_name) + + "the path including : {}".format(conf.file_path)) + return Confs(domain_name=domain_name, conf_files=result_configuration) + + +def add_conf(conf=None, domain_name=None, host_name=None): + # 一种是从本地已经存储的文件里拉取,一种是去对比目标主机上拉取 + # 然后基于path返回expect和real,构成一个完整的 + + # TODO: 需要考虑用户指定输入配置与已经追踪配置的冲突处理问题 + + conf_tools = ConfTools() + + # 获取真实配置并放入到git中管理 + if host_name is not None: + real_config_file_content = conf_tools.fetchRealConfs(host_name, conf.file_path) + else: + return None + + result_configuration = [] + + if len(real_config_file_content) == 0: + return None + # 放置到git管理中 + succ_conf = "" + + for file_path, content in real_config_file_content.items(): + conf_tools.wirteFileInPath('{}/{}{}'.format(TARGETDIR, domain_name, file_path), content[0]) + result_configuration.append(Configration(file_path, expect_content=content[0])) + succ_conf = succ_conf + file_path + " " + # 返回confs + + git_tools = GitTools(TARGETDIR) + commit_code = git_tools.gitCommit("Add the conf in {} domain, ".format(domain_name) + + "the path including : {}".format(conf.file_path)) + + return Confs(domain_name=domain_name, conf_files=result_configuration) + + +def get_confs_alerts(action, domain_name=None): # noqa: E501 + """list confs alerts + + # noqa: E501 + + :param action: + :type action: str + :param domain_name: + :type domain_name: str + + :rtype: List[str] + """ + if action != 'alerts': + return 'Unknown Action', 400 + + # 读取domain下的.changelog文件内容,并返回 + if domain_name == None or domain_name == "": + return 'Unknown Domain', 400 + + cmd = "cat {}/.changelog".format(os.path.join(TARGETDIR, domain_name)) + git_tools = GitTools(TARGETDIR) + changelog_content = git_tools.run_shell_return_output(cmd).decode().strip() + result=[] + for line in changelog_content.split('\n'): + # format: parse bpftrace string to dict + res = parse_bpftrace_string(line) + if res is not None: + result.append(res) + return result, 200 + + +# format: 192.168.137.2,1715331497.896836,/etc/resolv.conf,vim,5,0 +def parse_bpftrace_string(input:str): + if input == None or input == "": + return None + content = input.split(",") + import pytz + system_timezone = pytz.timezone('Asia/Shanghai') + local_time = datetime.datetime.fromtimestamp(float(content[1]), tz=system_timezone) + return { + "ip": content[0], + "time": local_time.strftime('%Y-%m-%d %H:%M:%S.%f'), + "file": content[2], + "command": content[3] + } + +def delete_confs(domain_name=None, host_name=None, file_path=None): # noqa: E501 + """delete confs + + # noqa: E501 + + :param body: + :type body: dict | bytes + :param domain_name: + :type domain_name: str + :param host_name: + :type host_name: str + + :rtype: None + """ + + if domain_name == None or domain_name == "": + return 'Unknown Domain', 400 + + # 删除当前domain下的指定配置 + if not os.path.exists(os.path.join(TARGETDIR, domain_name)): + return "The current domain does not exist.", 400 + git_tools = GitTools(TARGETDIR) + domain_path = os.path.join(TARGETDIR, domain_name) + status = git_tools.run_shell_return_code("rm -r -f {}".format('{}{}'.format(domain_path, file_path))) + if status != 0: + return "Delete failed.", 400 + git_tools.gitCommit("Delete the conf in {} domain, ".format(domain_name) + + "the path including : {}".format(file_path)) + return 'Accepted', 202 \ No newline at end of file diff --git a/sysom_server/sysom_config_trace/config_trace/controllers/domain_controller.py b/sysom_server/sysom_config_trace/config_trace/controllers/domain_controller.py new file mode 100644 index 00000000..6822b6a2 --- /dev/null +++ b/sysom_server/sysom_config_trace/config_trace/controllers/domain_controller.py @@ -0,0 +1,352 @@ +import ast +import connexion +import six +import os +import json +import shutil +from config_trace.models.domain import Domain # noqa: E501 +from config_trace.models.host import Host # noqa: E501 +from config_trace import util +from clogger import logger +from config_trace.controllers.format import Format +from config_trace.utils.git_tools import GitTools +from config_trace.models.domain_body import DomainBody + +TARGETDIR = GitTools().target_dir + +def create_domain(domain_name, body=None): # noqa: E501 + """create domains + + # noqa: E501 + + :param domain_name: domain name + :type domain_name: str + :param body: + :type body: dict | bytes + + :rtype: None + """ + if body is not None: + body = DomainBody.from_dict(body) # noqa: E501 + + if domain_name == "": + return "Missing Param", 400 + isExist = Format.isDomainExist(domain_name) + if isExist: + return "Duplicated DomainName", 400 + else: + domainPath = os.path.join(TARGETDIR, domain_name) + os.umask(0o077) + os.mkdir(domainPath) + domain = Domain(domain_name=domain_name, priority=body.priority, enable_trace=body.enable_trace) + with open(os.path.join(domainPath, "domaininfo.txt"), 'w') as f: + f.write(json.dumps(domain.to_dict())) + + return "Create", 201 + +def create_domain_host(domain_name, host_name): # noqa: E501 + """create domains + + # noqa: E501 + + :param domain_name: domain name + :type domain_name: str + :param host_name: host name + :type host_name: str + + :rtype: None + """ + + checkRes = Format.domainCheck(domainName=domain_name) + if not checkRes: + return "Failed to verify the input parameter, please check the input parameters.", 400 + + # check whether the domain exists + isExist = Format.isDomainExist(domainName=domain_name) + if not isExist: + return "The current domain does not exist, please create the domain first.", 400 + + domainPath = os.path.join(TARGETDIR, domain_name) + + # Check whether the current host exists in the domain. + hostPath = os.path.join(domainPath, "hostRecord.txt") + if os.path.isfile(hostPath): + isContained = Format.isContainedHostIdInfile(hostPath, host_name) + if isContained: + return "The all host already exists in the administrative scope of the domain.", 400 + host = Host(host_id=host_name, ip=host_name, ipv6="") + Format.addHostToFile(hostPath, host) + + return "Add host successfully", 201 + +def delete_domain(domain_name): # noqa: E501 + """delete domain + + # noqa: E501 + + :param domain_name: domain name + :type domain_name: str + + :rtype: None + """ + if domain_name == "": + return "Missing Param", 400 + isExist = Format.isDomainExist(domain_name) + if not isExist: + return "Invalid DomainName", 400 + else: + domainPath = os.path.join(TARGETDIR, domain_name) + shutil.rmtree(domainPath) + return 'Accepted', 202 + +def delete_domain_host(domain_name, host_name): # noqa: E501 + """delete domain + + # noqa: E501 + + :param domain_name: domain name + :type domain_name: str + :param host_name: host name + :type host_name: str + + :rtype: None + """ + + # check the input domain + checkRes = Format.domainCheck(domainName=domain_name) + if not checkRes: + return "Failed to verify the input parameter, please check the input parameters.", 400 + + # check whether the domain exists + isExist = Format.isDomainExist(domainName=domain_name) + if not isExist: + return "The current domain does not exist, please create the domain first.", 400 + + # Whether the host information added within the current domain is empty while ain exists + domainPath = os.path.join(TARGETDIR, domain_name) + hostPath = os.path.join(domainPath, "hostRecord.txt") + if not os.path.isfile(hostPath) or (os.path.isfile(hostPath) and os.stat(hostPath).st_size == 0): + return "The host information is not set in the current domain. Please add the host information first", 400 + + # If the domain exists, check whether the current input parameter host belongs to the corresponding + # domain. If the host is in the domain, the host is deleted. If the host is no longer in the domain, + # the host is added to the failure range + os.umask(0o077) + + isContained = False + try: + with open(hostPath, 'r') as d_file: + lines = d_file.readlines() + with open(hostPath, 'w') as w_file: + for line in lines: + line_host_id = json.loads(str(ast.literal_eval(line)).replace("'", "\""))["host_id"] + if host_name != line_host_id: + w_file.write(line) + else: + isContained = True + except OSError as err: + logger.error("OS error: {0}".format(err)) + return "OS error: {0}".format(err), 500 + + if isContained: + git_tools = GitTools(TARGETDIR) + commit_code = git_tools.gitCommit("Delete the host in {} domian, ".format(domain_name) + + "the host including : {}".format(host_name)) + return "Delete the host in {} domian, ".format(domain_name), 202 + else: + return "The host is not in the domain", 400 + +def get_domain(domain_name): # noqa: E501 + """get specific domain + + # noqa: E501 + + :param domain_name: domain name + :type domain_name: str + + :rtype: Domain + """ + if domain_name == "": + return "Param Error", 400 + + cmd = "/bin/ls {}".format(TARGETDIR) + gitTools = GitTools(TARGETDIR) + ls_res = gitTools.run_shell_return_output(cmd).decode() + ll_list = ls_res.split('\n') + for d_ll in ll_list: + if d_ll == domain_name: + domainPath = os.path.join(TARGETDIR, d_ll) + domainInfo = os.path.join(domainPath, "domaininfo.txt") + if os.path.isfile(domainInfo): + content = '' + with open(domainInfo, 'r') as d_file: + content = json.load(d_file) + domain = Domain(domain_name=content["domain_name"], priority=content["priority"], enable_trace=content["enable_trace"]) + return domain.to_dict(), 200 + return "Not Existed", 400 + +def list_domain_hosts(domain_name): # noqa: E501 + """list domain hosts + + # noqa: E501 + + :param domain_name: domain name + :type domain_name: str + + :rtype: List[Host] + """ + # check the input domain + checkRes = Format.domainCheck(domainName=domain_name) + if not checkRes: + return "Failed to verify the input parameter, please check the input parameters.", 400 + + # check whether the domain exists + isExist = Format.isDomainExist(domainName=domain_name) + if not isExist: + return "The current domain does not exist, please create the domain first.", 400 + + # The domain exists, but the host information is empty + domainPath = os.path.join(TARGETDIR, domain_name) + hostPath = os.path.join(domainPath, "hostRecord.txt") + if not os.path.isfile(hostPath) or (os.path.isfile(hostPath) and os.stat(hostPath).st_size == 0): + return [], 200 + + # The domain exists, and the host information exists and is not empty + hostlist = [] + logger.debug("hostPath is : {}".format(hostPath)) + try: + with open(hostPath, 'r') as d_file: + for line in d_file.readlines(): + json_str = json.loads(line) + host_json = ast.literal_eval(json_str) + hostId = host_json["host_id"] + ip = host_json["ip"] + ipv6 = host_json["ipv6"] + host = Host(host_id=hostId, ip=ip, ipv6=ipv6) + hostlist.append(host.to_dict()) + except OSError as err: + logger.error("OS error: {0}".format(err)) + return "OS error: {0}".format(err), 500 + + # Joining together the returned codenum codeMessag + if len(hostlist) == 0: + return "Some unknown problems.", 500 + else: + logger.debug("hostlist is : {}".format(hostlist)) + return hostlist, 200 + +def list_domains(): # noqa: E501 + """list domains + + # noqa: E501 + + + :rtype: List[Domain] + """ + domain_list = [] + cmd = "/bin/ls {}".format(TARGETDIR) + gitTools = GitTools(TARGETDIR) + ls_res = gitTools.run_shell_return_output(cmd).decode() + ll_list = ls_res.split('\n') + for d_ll in ll_list: + if d_ll: + # fetch domian infos + domainPath = os.path.join(TARGETDIR, d_ll) + domainInfo = os.path.join(domainPath, "domaininfo.txt") + if os.path.isfile(domainInfo): + content = '' + with open(domainInfo, 'r') as d_file: + content = json.load(d_file) + domain = Domain(domain_name=content["domain_name"], priority=content["priority"], enable_trace=content["enable_trace"]) + domain_list.append(domain.to_dict()) + + return domain_list, 200 + +def get_domain_host(domain_name, host_name): # noqa: E501 + """get specific domain + + # noqa: E501 + + :param domain_name: domain name + :type domain_name: str + :param host_name: host name + :type host_name: str + + :rtype: Host + """ + # check the input domain + checkRes = Format.domainCheck(domainName=domain_name) + if not checkRes: + return "Failed to verify the input parameter, please check the input parameters.", 400 + + # check whether the domain exists + isExist = Format.isDomainExist(domainName=domain_name) + if not isExist: + return "The current domain does not exist, please create the domain first.", 400 + + # The domain exists, but the host information is empty + domainPath = os.path.join(TARGETDIR, domain_name) + hostPath = os.path.join(domainPath, "hostRecord.txt") + if not os.path.isfile(hostPath) or (os.path.isfile(hostPath) and os.stat(hostPath).st_size == 0): + return [], 200 + + # The domain exists, and the host information exists and is not empty + logger.debug("hostPath is : {}".format(hostPath)) + host = None + try: + with open(hostPath, 'r') as d_file: + for line in d_file.readlines(): + host_json = json.loads(str(ast.literal_eval(line)).replace("'", "\"")) + name = host_json["host_id"] + if name == host_name: + ip = host_json["ip"] + ipv6 = host_json["ipv6"] + host = Host(host_id=name, ip=ip, ipv6=ipv6) + except OSError as err: + logger.error("OS error: {0}".format(err)) + return "OS error: {0}".format(err), 500 + + # Joining together the returned codenum codeMessag + if not host: + return "Some unknown problems.", 500 + else: + logger.debug("host is : {}".format(host)) + return host.to_dict(), 200 + +def update_domain(domain_name, body=None): # noqa: E501 + """update domains + + # noqa: E501 + + :param domain_name: domain name + :type domain_name: str + :param body: + :type body: dict | bytes + + :rtype: None + """ + if body is not None: + body = DomainBody.from_dict(body) # noqa: E501 + + # check the input domain + checkRes = Format.domainCheck(domainName=domain_name) + if not checkRes: + return "Failed to verify the input parameter, please check the input parameters.", 400 + + # check whether the domain exists + isExist = Format.isDomainExist(domainName=domain_name) + if not isExist: + return "The current domain does not exist, please create the domain first.", 400 + + domainPath = os.path.join(TARGETDIR, domain_name) + + data = Domain(domain_name=domain_name, priority=body.priority, enable_trace=body.enable_trace) + with open(os.path.join(domainPath, "domaininfo.txt"), 'w') as f: + f.write(json.dumps(data.to_dict())) + + ## TODO: 根据enable_trace进行配置下发更新工作,对整个domain管理的所有主机生效 + if body.enable_trace: + from config_trace.jobs.confs_job import refresh_config_trace_conf_files + return refresh_config_trace_conf_files(domain_name) + + return 'Accepted', 202 \ No newline at end of file diff --git a/sysom_server/sysom_config_trace/config_trace/controllers/format.py b/sysom_server/sysom_config_trace/config_trace/controllers/format.py new file mode 100644 index 00000000..9fbbb81d --- /dev/null +++ b/sysom_server/sysom_config_trace/config_trace/controllers/format.py @@ -0,0 +1,256 @@ +import os +import re +import json +import configparser +import ast +import requests +from clogger import logger +from config_trace.const.conf_handler_const import NOT_SYNCHRONIZE, SYNCHRONIZED, CONFIG, \ + DIRECTORY_FILE_PATH_LIST +from config_trace.models.configration import Configration +from config_trace.models.host import Host # noqa: E501 +from config_trace.utils.host_tools import HostTools + + +class Format(object): + + @staticmethod + def domainCheck(domainName): + res = True + if not re.match(r"^[A-Za-z0-9_\.-]*$", domainName) or domainName == "" or len(domainName) > 255: + res = False + return res + + @staticmethod + def isDomainExist(domainName): + TARGETDIR = Format.get_git_dir() + domainPath = os.path.join(TARGETDIR, domainName) + if os.path.exists(domainPath): + return True + + return False + + @staticmethod + def spliceAllSuccString(obj, operation, succDomain): + """ + docstring + """ + codeString = "All {obj} {oper} successfully, {succ} {obj} in total.".format( \ + obj=obj, oper=operation, succ=len(succDomain)) + return codeString + + @staticmethod + def splicErrorString(obj, operation, succDomain, failDomain): + """ + docstring + """ + codeString = "{succ} {obj} {oper} successfully, {fail} {obj} {oper} failed.".format( \ + succ=len(succDomain), obj=obj, oper=operation, fail=len(failDomain)) + + succString = "\n" + if len(succDomain) > 0: + succString = "These are successful: " + for succName in succDomain: + succString += succName + " " + succString += "." + + if len(failDomain) > 0: + failString = "These are failed: " + for failName in failDomain: + failString += failName + " " + return codeString + succString + failString + + return codeString + succString + + @staticmethod + def two_abs_join(abs1, abs2): + """ + Absolute path Joins two absolute paths together + :param abs1: main path + :param abs2: the spliced path + :return: together the path + """ + # 1. Format path (change \\ in path to \) + abs2 = os.fspath(abs2) + + # 2. Split the path file + abs2 = os.path.splitdrive(abs2)[1] + # 3. Remove the beginning '/' + abs2 = abs2.strip('\\/') or abs2 + return os.path.abspath(os.path.join(abs1, abs2)) + + @staticmethod + def isContainedHostIdInfile(f_file, content): + isContained = False + with open(f_file, 'r') as d_file: + for line in d_file.readlines(): + line_dict = json.loads(str(ast.literal_eval(line)).replace("'", "\"")) + if content == line_dict["host_id"]: + isContained = True + break + return isContained + + @staticmethod + def addHostToFile(d_file, host): + info_json = json.dumps(str(host), sort_keys=False, indent=4, separators=(',', ': ')) + os.umask(0o077) + with open(d_file, 'a+') as host_file: + host_file.write(info_json) + host_file.write("\n") + + @staticmethod + def getSubDirFiles(path): + """ + desc: Subdirectory records and files need to be logged to the successConf + """ + fileRealPathList = [] + fileXPathlist = [] + for root, dirs, files in os.walk(path): + if len(files) > 0: + preXpath = root.split('/', 3)[3] + for d_file in files: + xpath = os.path.join(preXpath, d_file) + fileXPathlist.append(xpath) + realPath = os.path.join(root, d_file) + fileRealPathList.append(realPath) + + return fileRealPathList, fileXPathlist + + @staticmethod + def isHostInDomain(domainName): + """ + desc: Query domain Whether host information is configured in the domain + """ + isHostInDomain = False + TARGETDIR = Format.get_git_dir() + domainPath = os.path.join(TARGETDIR, domainName) + hostPath = os.path.join(domainPath, "hostRecord.txt") + if os.path.isfile(hostPath): + isHostInDomain = True + + return isHostInDomain + + @staticmethod + def isHostIdExist(hostPath, hostId): + """ + desc: Query hostId exists within the current host domain management + """ + isHostIdExist = False + if os.path.isfile(hostPath) and os.stat(hostPath).st_size > 0: + with open(hostPath) as h_file: + for line in h_file.readlines(): + if hostId in line: + isHostIdExist = True + break + + return isHostIdExist + + @staticmethod + def get_file_content_by_readlines(d_file): + """ + desc: remove empty lines and comments from d_file + """ + res = [] + try: + with open(d_file, 'r') as s_f: + lines = s_f.readlines() + for line in lines: + tmp = line.strip() + if not len(tmp) or tmp.startswith("#"): + continue + res.append(line) + except FileNotFoundError: + logger.error(f"File not found: {d_file}") + except IOError as e: + logger.error(f"IO error: {e}") + except Exception as e: + logger.error(f"An error occurred: {e}") + return res + + @staticmethod + def get_file_content_by_read(d_file): + """ + desc: return a string after read the d_file + """ + if not os.path.exists(d_file): + return "" + with open(d_file, 'r') as s_f: + lines = s_f.read() + return lines + + @staticmethod + def rsplit(_str, seps): + """ + Splits _str by the first sep in seps that is found from the right side. + Returns a tuple without the separator. + """ + for idx, ch in enumerate(reversed(_str)): + if ch in seps: + return _str[0:-idx - 1], _str[-idx:] + + @staticmethod + def arch_sep(package_string): + """ + Helper method for finding if arch separator is '.' or '-' + + Args: + package_string (str): dash separated package string such as 'bash-4.2.39-3.el7'. + + Returns: + str: arch separator + """ + return '.' if package_string.rfind('.') > package_string.rfind('-') else '-' + + @staticmethod + def set_file_content_by_path(content, path): + res = 0 + if os.path.exists(path): + with open(path, 'w+') as d_file: + for d_cont in content: + d_file.write(d_cont) + d_file.write("\n") + res = 1 + return res + + @staticmethod + def get_git_dir(): + cf = configparser.ConfigParser() + if os.path.exists(CONFIG): + cf.read(CONFIG, encoding="utf-8") + else: + parent = os.path.dirname(os.path.realpath(__file__)) + conf_path = os.path.join(parent, "../../config_trace.conf") + cf.read(conf_path, encoding="utf-8") + git_dir = ast.literal_eval(cf.get("git", "git_dir")) + return git_dir + + @staticmethod + def get_hostinfo_by_domain(domainName): + """ + desc: Query hostinfo by domainname + """ + logger.debug("Get hostinfo by domain : {}".format(domainName)) + TARGETDIR = Format.get_git_dir() + hostlist = [] + domainPath = os.path.join(TARGETDIR, domainName) + hostPath = os.path.join(domainPath, "hostRecord.txt") + if not os.path.isfile(hostPath) or os.stat(hostPath).st_size == 0: + return hostlist + try: + with open(hostPath, 'r') as d_file: + for line in d_file.readlines(): + json_str = json.loads(line) + host_json = ast.literal_eval(json_str) + hostId = host_json["host_id"] + ip = host_json["ip"] + ipv6 = host_json["ipv6"] + host = Host(host_id=hostId, ip=ip, ipv6=ipv6) + hostlist.append(host.to_dict()) + except OSError as err: + logger.error("OS error: {0}".format(err)) + return hostlist + if len(hostlist) == 0: + logger.debug("Hostlist is empty !") + else: + logger.debug("Hostlist is : {}".format(hostlist)) + return hostlist \ No newline at end of file diff --git a/sysom_server/sysom_config_trace/config_trace/jobs/confs_job.py b/sysom_server/sysom_config_trace/config_trace/jobs/confs_job.py new file mode 100644 index 00000000..e3359f01 --- /dev/null +++ b/sysom_server/sysom_config_trace/config_trace/jobs/confs_job.py @@ -0,0 +1,135 @@ +# coding=utf-8 +from config_trace.models.domain import Domain # noqa: E501 +from clogger import logger +import os +import schedule +import threading +import time +from config_trace.utils.git_tools import GitTools + +TARGETDIR = GitTools().target_dir +# TODO: execute scheduled jobs + +def refresh_config_trace_conf_files(domain_name): + # 根据domaain管理的配置列表刷新主机配置列表 + from config_trace.controllers.confs_controller import get_confs_by_domain + confs, code = get_confs_by_domain(domain_name=domain_name) + if code / 100 > 2: + return confs, code + + managedConfFileList = [] + if len(confs['conf_files']) > 0: + for conf in confs["conf_files"]: + managedConfFileList.append(conf["file_path"]) + + conflists = "[{}]\n".format(domain_name) + conflists += "\n".join(managedConfFileList) + + from config_trace.controllers.domain_controller import list_domain_hosts + managedHosts, code = list_domain_hosts(domain_name) + + from channel_job import default_channel_job_executor + for host_name in managedHosts: + job = default_channel_job_executor.dispatch_job( + channel_type="ssh", + channel_opt="cmd", + params={ + "instance": host_name['ip'], + # TODO: 优化写出路径问题 + "command": "echo '{}' > {}; if diff -q '{}' '{}' >/dev/null; then break; else systemctl restart sysom_configtrace_agent; mv {} {}; fi" + .format(conflists, "/var/run/configtrace/configlist.txt.tmp", "/var/run/configtrace/configlist.txt", "/var/run/configtrace/configlist.txt.tmp", "/var/run/configtrace/configlist.txt.tmp", "/var/run/configtrace/configlist.txt") + }, + timeout=10000, + auto_retry=False + ) + channel_result = job.execute() + if channel_result.code != 0: + return 'Failed', 400 + + return 'Accepted', 202 + + +def fetch_config_trace_changes(domain_name): + from config_trace.controllers.domain_controller import list_domain_hosts + managedHosts, code = list_domain_hosts(domain_name) + if code / 100 > 2: + return managedHosts, code + + alerts = [] + from channel_job import default_channel_job_executor + for host_name in managedHosts: + job = default_channel_job_executor.dispatch_job( + channel_type="ssh", + channel_opt="cmd", + params={ + "instance": host_name['ip'], + "command": "cat /var/run/configtrace/alert.log", + }, + timeout=10000, + auto_retry=False + ) + channel_result = job.execute() + if channel_result.code != 0: + return 'Failed', 400 + for line in channel_result.result.splitlines(): + alerts.append("{},{}".format(host_name['ip'], line)) + + # write to .changelog + + cmd = 'echo "{}" > {}/.changelog'.format('\n'.join(alerts), os.path.join(TARGETDIR, domain_name)) + gitTools = GitTools(TARGETDIR) + gitTools.run_shell_return_output(cmd) + return alerts, 202 + + +def refresh_config_trace_confs(): + logger.info("refresh confs") + from config_trace.controllers.domain_controller import list_domains + domains, code = list_domains() + logger.error("job: list domains failed") + if code / 100 > 2: + return + for d in domains: + domain = Domain(domain_name=d["domain_name"], priority=d["priority"], enable_trace=d["enable_trace"]) + if not domain.enable_trace: + continue + result, code = refresh_config_trace_conf_files(domain.domain_name) + if code / 100 > 2: + logger.error("refresh config trace conf files failed") + logger.info("run finished") + +def fetch_config_trace_changelists(): + logger.info("fetching changelist") + from config_trace.controllers.domain_controller import list_domains + domains, code = list_domains() + if code / 100 > 2: + logger.error("job: list domains failed") + return + + for d in domains: + domain = Domain(domain_name=d["domain_name"], priority=d["priority"], enable_trace=d["enable_trace"]) + if not domain.enable_trace: + continue + try: + result, code = fetch_config_trace_changes(domain.domain_name) + if code / 100 > 2: + logger.error("fetch config trace changes failed") + except Exception as e: + logger.error(e) + +def start_job_schedule(interval=5): + t = threading.Thread(target=start, args=[interval]) + t.start() + +def start(interval): + schedule.every(interval * 3).seconds.do(refresh_config_trace_confs).tag('confs') + schedule.every(interval * 3).seconds.do(fetch_config_trace_changelists).tag('changelists') + + while True: + schedule.run_pending() + time.sleep(1) + +def stop_job_schedule(): + logger.info("stop job schedule") + schedule.clear('confs') + schedule.clear('changelists') diff --git a/sysom_server/sysom_config_trace/config_trace/models/__init__.py b/sysom_server/sysom_config_trace/config_trace/models/__init__.py new file mode 100644 index 00000000..22d061ce --- /dev/null +++ b/sysom_server/sysom_config_trace/config_trace/models/__init__.py @@ -0,0 +1,10 @@ +# coding: utf-8 + +# flake8: noqa +from __future__ import absolute_import +# import models into model package +from config_trace.models.configration import Configration +from config_trace.models.confs import Confs +from config_trace.models.domain import Domain +from config_trace.models.host import Host +from config_trace.models.v1_confs_body import V1ConfsBody diff --git a/sysom_server/sysom_config_trace/config_trace/models/base_model_.py b/sysom_server/sysom_config_trace/config_trace/models/base_model_.py new file mode 100644 index 00000000..86204f1d --- /dev/null +++ b/sysom_server/sysom_config_trace/config_trace/models/base_model_.py @@ -0,0 +1,69 @@ +import pprint + +import six +import typing + +from config_trace import util + +T = typing.TypeVar('T') + + +class Model(object): + # swaggerTypes: The key is attribute name and the + # value is attribute type. + swagger_types = {} + + # attributeMap: The key is attribute name and the + # value is json key in definition. + attribute_map = {} + + @classmethod + def from_dict(cls: typing.Type[T], dikt) -> T: + """Returns the dict as a model""" + return util.deserialize_model(dikt, cls) + + def to_dict(self): + """Returns the model properties as a dict + + :rtype: dict + """ + result = {} + + for attr, _ in six.iteritems(self.swagger_types): + value = getattr(self, attr) + if isinstance(value, list): + result[attr] = list(map( + lambda x: x.to_dict() if hasattr(x, "to_dict") else x, + value + )) + elif hasattr(value, "to_dict"): + result[attr] = value.to_dict() + elif isinstance(value, dict): + result[attr] = dict(map( + lambda item: (item[0], item[1].to_dict()) + if hasattr(item[1], "to_dict") else item, + value.items() + )) + else: + result[attr] = value + + return result + + def to_str(self): + """Returns the string representation of the model + + :rtype: str + """ + return pprint.pformat(self.to_dict()) + + def __repr__(self): + """For `print` and `pprint`""" + return self.to_str() + + def __eq__(self, other): + """Returns true if both objects are equal""" + return self.__dict__ == other.__dict__ + + def __ne__(self, other): + """Returns true if both objects are not equal""" + return not self == other diff --git a/sysom_server/sysom_config_trace/config_trace/models/configration.py b/sysom_server/sysom_config_trace/config_trace/models/configration.py new file mode 100644 index 00000000..0a9fb4b2 --- /dev/null +++ b/sysom_server/sysom_config_trace/config_trace/models/configration.py @@ -0,0 +1,148 @@ +# coding: utf-8 + +from __future__ import absolute_import +from datetime import date, datetime # noqa: F401 + +from typing import List, Dict # noqa: F401 + +from config_trace.models.base_model_ import Model +from config_trace import util + + +class Configration(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + def __init__(self, file_path: str=None, real_content: str=None, expect_content: str=None, change_log: str=None): # noqa: E501 + """Configration - a model defined in Swagger + + :param file_path: The file_path of this Configration. # noqa: E501 + :type file_path: str + :param real_content: The real_content of this Configration. # noqa: E501 + :type real_content: str + :param expect_content: The expect_content of this Configration. # noqa: E501 + :type expect_content: str + :param change_log: The change_log of this Configration. # noqa: E501 + :type change_log: str + """ + self.swagger_types = { + 'file_path': str, + 'real_content': str, + 'expect_content': str, + 'change_log': str + } + + self.attribute_map = { + 'file_path': 'filePath', + 'real_content': 'realContent', + 'expect_content': 'expectContent', + 'change_log': 'changeLog' + } + self._file_path = file_path + self._real_content = real_content + self._expect_content = expect_content + self._change_log = change_log + + @classmethod + def from_dict(cls, dikt) -> 'Configration': + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The Configration of this Configration. # noqa: E501 + :rtype: Configration + """ + return util.deserialize_model(dikt, cls) + + @property + def file_path(self) -> str: + """Gets the file_path of this Configration. + + config file path # noqa: E501 + + :return: The file_path of this Configration. + :rtype: str + """ + return self._file_path + + @file_path.setter + def file_path(self, file_path: str): + """Sets the file_path of this Configration. + + config file path # noqa: E501 + + :param file_path: The file_path of this Configration. + :type file_path: str + """ + + self._file_path = file_path + + @property + def real_content(self) -> str: + """Gets the real_content of this Configration. + + the content of configuration file # noqa: E501 + + :return: The real_content of this Configration. + :rtype: str + """ + return self._real_content + + @real_content.setter + def real_content(self, real_content: str): + """Sets the real_content of this Configration. + + the content of configuration file # noqa: E501 + + :param real_content: The real_content of this Configration. + :type real_content: str + """ + + self._real_content = real_content + + @property + def expect_content(self) -> str: + """Gets the expect_content of this Configration. + + the expect content of configuration file # noqa: E501 + + :return: The expect_content of this Configration. + :rtype: str + """ + return self._expect_content + + @expect_content.setter + def expect_content(self, expect_content: str): + """Sets the change_log of this Configration. + + the expect content of configuration file # noqa: E501 + + :param change_log: The change_log of this Configration. + :type change_log: str + """ + + self._expect_content = expect_content + + @property + def change_log(self) -> str: + """Gets the change_log of this Configration. + + the expect content of configuration file # noqa: E501 + + :return: The change_log of this Configration. + :rtype: str + """ + return self._change_log + + @change_log.setter + def change_log(self, change_log: str): + """Sets the change_log of this Configration. + + the expect content of configuration file # noqa: E501 + + :param change_log: The change_log of this Configration. + :type change_log: str + """ + + self._change_log = change_log diff --git a/sysom_server/sysom_config_trace/config_trace/models/confs.py b/sysom_server/sysom_config_trace/config_trace/models/confs.py new file mode 100644 index 00000000..899903f8 --- /dev/null +++ b/sysom_server/sysom_config_trace/config_trace/models/confs.py @@ -0,0 +1,89 @@ +# coding: utf-8 + +from __future__ import absolute_import +from datetime import date, datetime # noqa: F401 + +from typing import List, Dict # noqa: F401 + +from config_trace.models.base_model_ import Model +from config_trace.models.configration import Configration # noqa: F401,E501 +from config_trace import util + + +class Confs(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + def __init__(self, domain_name: str=None, conf_files: List[Configration]=None): # noqa: E501 + """Confs - a model defined in Swagger + + :param domain_name: The domain_name of this Confs. # noqa: E501 + :type domain_name: str + :param conf_files: The conf_files of this Confs. # noqa: E501 + :type conf_files: List[Configration] + """ + self.swagger_types = { + 'domain_name': str, + 'conf_files': List[Configration] + } + + self.attribute_map = { + 'domain_name': 'domainName', + 'conf_files': 'confFiles' + } + self._domain_name = domain_name + self._conf_files = conf_files + + @classmethod + def from_dict(cls, dikt) -> 'Confs': + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The Confs of this Confs. # noqa: E501 + :rtype: Confs + """ + return util.deserialize_model(dikt, cls) + + @property + def domain_name(self) -> str: + """Gets the domain_name of this Confs. + + + :return: The domain_name of this Confs. + :rtype: str + """ + return self._domain_name + + @domain_name.setter + def domain_name(self, domain_name: str): + """Sets the domain_name of this Confs. + + + :param domain_name: The domain_name of this Confs. + :type domain_name: str + """ + + self._domain_name = domain_name + + @property + def conf_files(self) -> List[Configration]: + """Gets the conf_files of this Confs. + + + :return: The conf_files of this Confs. + :rtype: List[Configration] + """ + return self._conf_files + + @conf_files.setter + def conf_files(self, conf_files: List[Configration]): + """Sets the conf_files of this Confs. + + + :param conf_files: The conf_files of this Confs. + :type conf_files: List[Configration] + """ + + self._conf_files = conf_files diff --git a/sysom_server/sysom_config_trace/config_trace/models/domain.py b/sysom_server/sysom_config_trace/config_trace/models/domain.py new file mode 100644 index 00000000..c82b9455 --- /dev/null +++ b/sysom_server/sysom_config_trace/config_trace/models/domain.py @@ -0,0 +1,120 @@ +# coding: utf-8 + +from __future__ import absolute_import +from datetime import date, datetime # noqa: F401 + +from typing import List, Dict # noqa: F401 + +from config_trace.models.base_model_ import Model +from config_trace import util + + +class Domain(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + def __init__(self, domain_name: str=None, priority: int=None, enable_trace: bool=None): # noqa: E501 + """Domain - a model defined in Swagger + + :param domain_name: The domain_name of this Domain. # noqa: E501 + :type domain_name: str + :param priority: The priority of this Domain. # noqa: E501 + :type priority: int + :param enable_trace: The enable_trace of this Domain. # noqa: E501 + :type enable_trace: bool + """ + self.swagger_types = { + 'domain_name': str, + 'priority': int, + 'enable_trace': bool + } + + self.attribute_map = { + 'domain_name': 'domainName', + 'priority': 'priority', + 'enable_trace': 'enable_trace' + } + self._domain_name = domain_name + self._priority = priority + self._enable_trace = enable_trace + + @classmethod + def from_dict(cls, dikt) -> 'Domain': + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The Domain of this Domain. # noqa: E501 + :rtype: Domain + """ + return util.deserialize_model(dikt, cls) + + @property + def domain_name(self) -> str: + """Gets the domain_name of this Domain. + + domain name # noqa: E501 + + :return: The domain_name of this Domain. + :rtype: str + """ + return self._domain_name + + @domain_name.setter + def domain_name(self, domain_name: str): + """Sets the domain_name of this Domain. + + domain name # noqa: E501 + + :param domain_name: The domain_name of this Domain. + :type domain_name: str + """ + + self._domain_name = domain_name + + @property + def priority(self) -> int: + """Gets the priority of this Domain. + + Priority of the current domain # noqa: E501 + + :return: The priority of this Domain. + :rtype: int + """ + return self._priority + + @priority.setter + def priority(self, priority: int): + """Sets the priority of this Domain. + + Priority of the current domain # noqa: E501 + + :param priority: The priority of this Domain. + :type priority: int + """ + + self._priority = priority + + @property + def enable_trace(self) -> bool: + """Gets the enable_trace of this Domain. + + If enable object trace or not for current domain # noqa: E501 + + :return: The enable_trace of this Domain. + :rtype: bool + """ + return self._enable_trace + + @enable_trace.setter + def enable_trace(self, enable_trace: bool): + """Sets the enable_trace of this Domain. + + If enable object trace or not for current domain # noqa: E501 + + :param enable_trace: The enable_trace of this Domain. + :type enable_trace: bool + """ + + self._enable_trace = enable_trace \ No newline at end of file diff --git a/sysom_server/sysom_config_trace/config_trace/models/domain_body.py b/sysom_server/sysom_config_trace/config_trace/models/domain_body.py new file mode 100644 index 00000000..2546521f --- /dev/null +++ b/sysom_server/sysom_config_trace/config_trace/models/domain_body.py @@ -0,0 +1,92 @@ +# coding: utf-8 + +from __future__ import absolute_import +from datetime import date, datetime # noqa: F401 + +from typing import List, Dict # noqa: F401 + +from config_trace.models.base_model_ import Model +from config_trace import util + + +class DomainBody(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + def __init__(self, priority: int=None, enable_trace: bool=None): # noqa: E501 + """DomainBody - a model defined in Swagger + + :param priority: The priority of this DomainBody. # noqa: E501 + :type priority: int + :param enable_trace: The enable_trace of this DomainBody. # noqa: E501 + :type enable_trace: bool + """ + self.swagger_types = { + 'priority': int, + 'enable_trace': bool + } + + self.attribute_map = { + 'priority': 'priority', + 'enable_trace': 'enable_trace' + } + self._priority = priority + self._enable_trace = enable_trace + + @classmethod + def from_dict(cls, dikt) -> 'DomainBody': + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The domains_domainName_body of this DomainBody. # noqa: E501 + :rtype: DomainBody + """ + return util.deserialize_model(dikt, cls) + + @property + def priority(self) -> int: + """Gets the priority of this DomainBody. + + Priority of the current domain # noqa: E501 + + :return: The priority of this DomainBody. + :rtype: int + """ + return self._priority + + @priority.setter + def priority(self, priority: int): + """Sets the priority of this DomainBody. + + Priority of the current domain # noqa: E501 + + :param priority: The priority of this DomainBody. + :type priority: int + """ + + self._priority = priority + + @property + def enable_trace(self) -> bool: + """Gets the enable_trace of this DomainBody. + + If Enable objective trace by eBPF # noqa: E501 + + :return: The enable_trace of this DomainBody. + :rtype: bool + """ + return self._enable_trace + + @enable_trace.setter + def enable_trace(self, enable_trace: bool): + """Sets the enable_trace of this DomainBody. + + If Enable objective trace by eBPF # noqa: E501 + + :param enable_trace: The enable_trace of this DomainBody. + :type enable_trace: bool + """ + + self._enable_trace = enable_trace diff --git a/sysom_server/sysom_config_trace/config_trace/models/git_log_message.py b/sysom_server/sysom_config_trace/config_trace/models/git_log_message.py new file mode 100644 index 00000000..fc519621 --- /dev/null +++ b/sysom_server/sysom_config_trace/config_trace/models/git_log_message.py @@ -0,0 +1,194 @@ +# coding: utf-8 + +from __future__ import absolute_import +from datetime import date, datetime # noqa: F401 + +from typing import List, Dict # noqa: F401 + +from config_trace.models.base_model_ import Model +from config_trace import util + + +class GitLogMessage(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + + def __init__(self, _date: datetime=None, author: str=None, change_id: str=None, change_reason: str=None, pre_value: str=None, post_value: str=None): # noqa: E501 + """GitLogMessage - a model defined in Swagger + + :param _date: The _date of this GitLogMessage. # noqa: E501 + :type _date: datetime + :param author: The author of this GitLogMessage. # noqa: E501 + :type author: str + :param change_id: The change_id of this GitLogMessage. # noqa: E501 + :type change_id: str + :param change_reason: The change_reason of this GitLogMessage. # noqa: E501 + :type change_reason: str + :param pre_value: The pre_value of this GitLogMessage. # noqa: E501 + :type pre_value: str + :param post_value: The post_value of this GitLogMessage. # noqa: E501 + :type post_value: str + """ + self.swagger_types = { + '_date': datetime, + 'author': str, + 'change_id': str, + 'change_reason': str, + 'pre_value': str, + 'post_value': str + } + + self.attribute_map = { + '_date': 'date', + 'author': 'author', + 'change_id': 'changeId', + 'change_reason': 'changeReason', + 'pre_value': 'preValue', + 'post_value': 'postValue' + } + + self.__date = _date + self._author = author + self._change_id = change_id + self._change_reason = change_reason + self._pre_value = pre_value + self._post_value = post_value + + @classmethod + def from_dict(cls, dikt) -> 'GitLogMessage': + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The GitLogMessage of this GitLogMessage. # noqa: E501 + :rtype: GitLogMessage + """ + return util.deserialize_model(dikt, cls) + + @property + def _date(self) -> datetime: + """Gets the _date of this GitLogMessage. + + + :return: The _date of this GitLogMessage. + :rtype: datetime + """ + return self.__date + + @_date.setter + def _date(self, _date: datetime): + """Sets the _date of this GitLogMessage. + + + :param _date: The _date of this GitLogMessage. + :type _date: datetime + """ + + self.__date = _date + + @property + def author(self) -> str: + """Gets the author of this GitLogMessage. + + + :return: The author of this GitLogMessage. + :rtype: str + """ + return self._author + + @author.setter + def author(self, author: str): + """Sets the author of this GitLogMessage. + + + :param author: The author of this GitLogMessage. + :type author: str + """ + + self._author = author + + @property + def change_id(self) -> str: + """Gets the change_id of this GitLogMessage. + + + :return: The change_id of this GitLogMessage. + :rtype: str + """ + return self._change_id + + @change_id.setter + def change_id(self, change_id: str): + """Sets the change_id of this GitLogMessage. + + + :param change_id: The change_id of this GitLogMessage. + :type change_id: str + """ + + self._change_id = change_id + + @property + def change_reason(self) -> str: + """Gets the change_reason of this GitLogMessage. + + + :return: The change_reason of this GitLogMessage. + :rtype: str + """ + return self._change_reason + + @change_reason.setter + def change_reason(self, change_reason: str): + """Sets the change_reason of this GitLogMessage. + + + :param change_reason: The change_reason of this GitLogMessage. + :type change_reason: str + """ + + self._change_reason = change_reason + + @property + def pre_value(self) -> str: + """Gets the pre_value of this GitLogMessage. + + + :return: The pre_value of this GitLogMessage. + :rtype: str + """ + return self._pre_value + + @pre_value.setter + def pre_value(self, pre_value: str): + """Sets the pre_value of this GitLogMessage. + + + :param pre_value: The pre_value of this GitLogMessage. + :type pre_value: str + """ + + self._pre_value = pre_value + + @property + def post_value(self) -> str: + """Gets the post_value of this GitLogMessage. + + + :return: The post_value of this GitLogMessage. + :rtype: str + """ + return self._post_value + + @post_value.setter + def post_value(self, post_value: str): + """Sets the post_value of this GitLogMessage. + + + :param post_value: The post_value of this GitLogMessage. + :type post_value: str + """ + + self._post_value = post_value diff --git a/sysom_server/sysom_config_trace/config_trace/models/host.py b/sysom_server/sysom_config_trace/config_trace/models/host.py new file mode 100644 index 00000000..d090d6e0 --- /dev/null +++ b/sysom_server/sysom_config_trace/config_trace/models/host.py @@ -0,0 +1,120 @@ +# coding: utf-8 + +from __future__ import absolute_import +from datetime import date, datetime # noqa: F401 + +from typing import List, Dict # noqa: F401 + +from config_trace.models.base_model_ import Model +from config_trace import util + + +class Host(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + def __init__(self, host_id: int=None, ip: str=None, ipv6: str=None): # noqa: E501 + """Host - a model defined in Swagger + + :param host_id: The host_id of this Host. # noqa: E501 + :type host_id: int + :param ip: The ip of this Host. # noqa: E501 + :type ip: str + :param ipv6: The ipv6 of this Host. # noqa: E501 + :type ipv6: str + """ + self.swagger_types = { + 'host_id': int, + 'ip': str, + 'ipv6': str + } + + self.attribute_map = { + 'host_id': 'hostId', + 'ip': 'ip', + 'ipv6': 'ipv6' + } + self._host_id = host_id + self._ip = ip + self._ipv6 = ipv6 + + @classmethod + def from_dict(cls, dikt) -> 'Host': + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The Host of this Host. # noqa: E501 + :rtype: Host + """ + return util.deserialize_model(dikt, cls) + + @property + def host_id(self) -> int: + """Gets the host_id of this Host. + + the id of host # noqa: E501 + + :return: The host_id of this Host. + :rtype: int + """ + return self._host_id + + @host_id.setter + def host_id(self, host_id: int): + """Sets the host_id of this Host. + + the id of host # noqa: E501 + + :param host_id: The host_id of this Host. + :type host_id: int + """ + + self._host_id = host_id + + @property + def ip(self) -> str: + """Gets the ip of this Host. + + the ipv4 address of host # noqa: E501 + + :return: The ip of this Host. + :rtype: str + """ + return self._ip + + @ip.setter + def ip(self, ip: str): + """Sets the ip of this Host. + + the ipv4 address of host # noqa: E501 + + :param ip: The ip of this Host. + :type ip: str + """ + + self._ip = ip + + @property + def ipv6(self) -> str: + """Gets the ipv6 of this Host. + + the ipv6 address of host # noqa: E501 + + :return: The ipv6 of this Host. + :rtype: str + """ + return self._ipv6 + + @ipv6.setter + def ipv6(self, ipv6: str): + """Sets the ipv6 of this Host. + + the ipv6 address of host # noqa: E501 + + :param ipv6: The ipv6 of this Host. + :type ipv6: str + """ + + self._ipv6 = ipv6 diff --git a/sysom_server/sysom_config_trace/config_trace/models/v1_confs_body.py b/sysom_server/sysom_config_trace/config_trace/models/v1_confs_body.py new file mode 100644 index 00000000..71e9e25d --- /dev/null +++ b/sysom_server/sysom_config_trace/config_trace/models/v1_confs_body.py @@ -0,0 +1,92 @@ +# coding: utf-8 + +from __future__ import absolute_import +from datetime import date, datetime # noqa: F401 + +from typing import List, Dict # noqa: F401 + +from config_trace.models.base_model_ import Model +from config_trace import util + + +class V1ConfsBody(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + def __init__(self, domain_name: str=None, hosts: List[str]=None): # noqa: E501 + """V1ConfsBody - a model defined in Swagger + + :param domain_name: The domain_name of this V1ConfsBody. # noqa: E501 + :type domain_name: str + :param hosts: The hosts of this V1ConfsBody. # noqa: E501 + :type hosts: List[str] + """ + self.swagger_types = { + 'domain_name': str, + 'hosts': List[str] + } + + self.attribute_map = { + 'domain_name': 'domainName', + 'hosts': 'hosts' + } + self._domain_name = domain_name + self._hosts = hosts + + @classmethod + def from_dict(cls, dikt) -> 'V1ConfsBody': + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The v1_confs_body of this V1ConfsBody. # noqa: E501 + :rtype: V1ConfsBody + """ + return util.deserialize_model(dikt, cls) + + @property + def domain_name(self) -> str: + """Gets the domain_name of this V1ConfsBody. + + domain name # noqa: E501 + + :return: The domain_name of this V1ConfsBody. + :rtype: str + """ + return self._domain_name + + @domain_name.setter + def domain_name(self, domain_name: str): + """Sets the domain_name of this V1ConfsBody. + + domain name # noqa: E501 + + :param domain_name: The domain_name of this V1ConfsBody. + :type domain_name: str + """ + + self._domain_name = domain_name + + @property + def hosts(self) -> List[str]: + """Gets the hosts of this V1ConfsBody. + + host lists # noqa: E501 + + :return: The hosts of this V1ConfsBody. + :rtype: List[str] + """ + return self._hosts + + @hosts.setter + def hosts(self, hosts: List[str]): + """Sets the hosts of this V1ConfsBody. + + host lists # noqa: E501 + + :param hosts: The hosts of this V1ConfsBody. + :type hosts: List[str] + """ + + self._hosts = hosts diff --git a/sysom_server/sysom_config_trace/config_trace/models/v1_confs_body1.py b/sysom_server/sysom_config_trace/config_trace/models/v1_confs_body1.py new file mode 100644 index 00000000..7ade61db --- /dev/null +++ b/sysom_server/sysom_config_trace/config_trace/models/v1_confs_body1.py @@ -0,0 +1,63 @@ +# coding: utf-8 + +from __future__ import absolute_import +from datetime import date, datetime # noqa: F401 + +from typing import List, Dict # noqa: F401 + +from config_trace.models.base_model_ import Model +from config_trace.models.configration import Configration # noqa: F401,E501 +from config_trace import util + + +class V1ConfsBody1(Model): + """NOTE: This class is auto generated by the swagger code generator program. + + Do not edit the class manually. + """ + def __init__(self, confs: List[Configration]=None): # noqa: E501 + """V1ConfsBody1 - a model defined in Swagger + + :param confs: The confs of this V1ConfsBody1. # noqa: E501 + :type confs: List[Configration] + """ + self.swagger_types = { + 'confs': List[Configration] + } + + self.attribute_map = { + 'confs': 'confs' + } + self._confs = confs + + @classmethod + def from_dict(cls, dikt) -> 'V1ConfsBody1': + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The v1_confs_body_1 of this V1ConfsBody1. # noqa: E501 + :rtype: V1ConfsBody1 + """ + return util.deserialize_model(dikt, cls) + + @property + def confs(self) -> List[Configration]: + """Gets the confs of this V1ConfsBody1. + + + :return: The confs of this V1ConfsBody1. + :rtype: List[Configration] + """ + return self._confs + + @confs.setter + def confs(self, confs: List[Configration]): + """Sets the confs of this V1ConfsBody1. + + + :param confs: The confs of this V1ConfsBody1. + :type confs: List[Configration] + """ + + self._confs = confs diff --git a/sysom_server/sysom_config_trace/config_trace/swagger/swagger.yaml b/sysom_server/sysom_config_trace/config_trace/swagger/swagger.yaml new file mode 100644 index 00000000..d4ab582d --- /dev/null +++ b/sysom_server/sysom_config_trace/config_trace/swagger/swagger.yaml @@ -0,0 +1,484 @@ +openapi: 3.0.2 +info: + title: Configration Tracability + version: "1.0" +servers: + - url: http://localhost:8080 +tags: + - name: domain + description: configration domain + - name: host + description: host in domain + - name: confs + description: query the configration +paths: + /api/v1/configtrace/domains: + get: + tags: + - domain + summary: list domains + operationId: list_domains + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Domain" + x-content-type: application/json + x-openapi-router-controller: config_trace.controllers.domain_controller + /api/v1/configtrace/domains/{domainName}: + get: + tags: + - domain + summary: get specific domain + operationId: get_domain + parameters: + - name: domainName + in: path + description: domain name + required: true + style: simple + explode: false + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Domain" + x-openapi-router-controller: config_trace.controllers.domain_controller + post: + tags: + - domain + summary: create domains + operationId: create_domain + parameters: + - name: domainName + in: path + description: domain name + required: true + style: simple + explode: false + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + properties: + priority: + type: integer + description: Priority of the current domain + enable_trace: + type: boolean + description: If Enable objective trace by eBPF + responses: + "201": + description: Created + x-openapi-router-controller: config_trace.controllers.domain_controller + put: + tags: + - domain + summary: update domains + operationId: update_domain + parameters: + - name: domainName + in: path + description: domain name + required: true + style: simple + explode: false + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + properties: + priority: + type: integer + description: Priority of the current domain + enable_trace: + type: boolean + description: If Enable objective trace by eBPF + responses: + "202": + description: Accepted + x-openapi-router-controller: config_trace.controllers.domain_controller + delete: + tags: + - domain + summary: delete domain + operationId: delete_domain + parameters: + - name: domainName + in: path + description: domain name + required: true + style: simple + explode: false + schema: + type: string + responses: + "202": + description: Accepted + x-openapi-router-controller: config_trace.controllers.domain_controller + /api/v1/configtrace/domains/{domainName}/hosts: + get: + tags: + - domain + - host + summary: list domain hosts + operationId: list_domain_hosts + parameters: + - name: domainName + in: path + description: domain name + required: true + style: simple + explode: false + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Host" + x-content-type: application/json + x-openapi-router-controller: config_trace.controllers.domain_controller + /api/v1/configtrace/domains/{domainName}/hosts/{hostName}: + get: + summary: get specific domain + operationId: get_domain_host + parameters: + - name: domainName + in: path + description: domain name + required: true + style: simple + explode: false + schema: + type: string + - name: hostName + in: path + description: host name + required: true + style: simple + explode: false + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Host" + x-openapi-router-controller: config_trace.controllers.domain_controller + post: + tags: + - domain + summary: create domains + operationId: create_domain_host + parameters: + - name: domainName + in: path + description: domain name + required: true + style: simple + explode: false + schema: + type: string + - name: hostName + in: path + description: host name + required: true + style: simple + explode: false + schema: + type: string + responses: + "201": + description: Created + x-openapi-router-controller: config_trace.controllers.domain_controller + delete: + tags: + - domain + summary: delete domain + operationId: delete_domain_host + parameters: + - name: domainName + in: path + description: domain name + required: true + style: simple + explode: false + schema: + type: string + - name: hostName + in: path + description: host name + required: true + style: simple + explode: false + schema: + type: string + responses: + "202": + description: Accepted + x-openapi-router-controller: config_trace.controllers.domain_controller + /api/v1/configtrace/alerts: + get: + tags: + - confs + summary: list confs alerts + operationId: get_confs_alerts + parameters: + - name: domainName + in: query + required: false + style: form + explode: true + schema: + type: string + - name: action + in: query + required: true + style: form + explode: true + schema: + type: string + example: alerts + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + type: string + x-content-type: application/json + x-openapi-router-controller: config_trace.controllers.confs_controller + /api/v1/configtrace/confs: + get: + tags: + - confs + summary: list domain confs + operationId: get_confs_by_domain + parameters: + - name: domainName + in: query + required: false + style: form + explode: true + schema: + type: string + - name: hostName + in: query + required: false + style: form + explode: true + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Confs" + x-content-type: application/json + "400": + description: Client Error + content: + application/json: + schema: + type: string + x-content-type: application/json + x-openapi-router-controller: config_trace.controllers.confs_controller + put: + tags: + - confs + summary: update domain confs + operationId: update_confs_by_domain + parameters: + - name: domainName + in: query + required: false + style: form + explode: true + schema: + type: string + - name: hostName + in: query + required: false + style: form + explode: true + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + properties: + confs: + type: array + items: + $ref: "#/components/schemas/Configration" + responses: + "202": + description: Accepted + x-openapi-router-controller: config_trace.controllers.confs_controller + post: + tags: + - confs + summary: sync confs + operationId: sync_confs + parameters: + - name: action + in: query + required: true + style: form + explode: true + schema: + type: string + example: sync + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/v1_confs_body" + responses: + "202": + description: Accepted + x-openapi-router-controller: config_trace.controllers.confs_controller + delete: + tags: + - confs + summary: delete confs + operationId: delete_confs + parameters: + - name: domainName + in: query + required: false + style: form + explode: true + schema: + type: string + - name: hostName + in: query + required: false + style: form + explode: true + schema: + type: string + - name: filePath + in: query + required: false + style: form + explode: true + schema: + type: string + responses: + "202": + description: Accepted + x-openapi-router-controller: config_trace.controllers.confs_controller +components: + schemas: + Domain: + type: object + properties: + domainName: + type: string + description: domain name + priority: + type: integer + description: Priority of the current domain + format: int32 + enable_trace: + type: boolean + description: If enable object trace or not for current domain + format: boolean + example: + domainName: domainName + priority: 0 + enable_trace: false + Host: + type: object + properties: + hostId: + type: integer + description: the id of host + ip: + type: string + description: the ipv4 address of host + ipv6: + type: string + description: the ipv6 address of host + example: + ip: 1.2.3.4 + ipv6: ipv6 + hostId: hostId + Configration: + type: object + properties: + filePath: + type: string + description: config file path + realContent: + type: string + description: the content of configuration file + expectContent: + type: string + description: the expect content of configuration file + changeLog: + type: string + description: the configuration changelog + example: + realContent: realContent + filePath: filePath + expectContent: expectContent + Confs: + type: object + properties: + domainName: + type: string + confFiles: + type: array + items: + $ref: "#/components/schemas/Configration" + example: + confFiles: + - realContent: realContent + filePath: filePath + expectContent: expectContent + - realContent: realContent + filePath: filePath + expectContent: expectContent + domainName: domainName + v1_confs_body: + type: object + properties: + domainName: + type: string + description: domain name + hosts: + type: array + description: host lists + items: + type: string diff --git a/sysom_server/sysom_config_trace/config_trace/test/__init__.py b/sysom_server/sysom_config_trace/config_trace/test/__init__.py new file mode 100644 index 00000000..6d2543ec --- /dev/null +++ b/sysom_server/sysom_config_trace/config_trace/test/__init__.py @@ -0,0 +1,16 @@ +import logging + +import connexion +from flask_testing import TestCase + +from config_trace.encoder import JSONEncoder + + +class BaseTestCase(TestCase): + + def create_app(self): + logging.getLogger('connexion.operation').setLevel('ERROR') + app = connexion.App(__name__, specification_dir='../swagger/') + app.app.json_encoder = JSONEncoder + app.add_api('swagger.yaml') + return app.app diff --git a/sysom_server/sysom_config_trace/config_trace/test/test_confs_controller.py b/sysom_server/sysom_config_trace/config_trace/test/test_confs_controller.py new file mode 100644 index 00000000..33bcbf49 --- /dev/null +++ b/sysom_server/sysom_config_trace/config_trace/test/test_confs_controller.py @@ -0,0 +1,63 @@ +# coding: utf-8 + +from __future__ import absolute_import + +from flask import json +from six import BytesIO + +from config_trace.models.confs import Confs # noqa: E501 +from config_trace.models.v1_confs_body import V1ConfsBody # noqa: E501 +from config_trace.test import BaseTestCase + + +class TestConfsController(BaseTestCase): + """ConfsController integration test stubs""" + + def test_get_confs_by_domain(self): + """Test case for get_confs_by_domain + + list domain confs + """ + query_string = [('domain_name', 'domain_name_example'), + ('host_name', 'host_name_example')] + response = self.client.open( + '/api/v1/configtrace/confs', + method='GET', + query_string=query_string) + self.assert200(response, + 'Response body is : ' + response.data.decode('utf-8')) + + def test_sync_confs(self): + """Test case for sync_confs + + sync confs + """ + body = V1ConfsBody() + query_string = [('action', 'action_example')] + response = self.client.open( + '/api/v1/configtrace/confs', + method='POST', + data=json.dumps(body), + content_type='application/json', + query_string=query_string) + self.assert200(response, + 'Response body is : ' + response.data.decode('utf-8')) + + def test_update_confs_by_domain(self): + """Test case for update_confs_by_domain + + update domain confs + """ + query_string = [('domain_name', 'domain_name_example'), + ('host_name', 'host_name_example')] + response = self.client.open( + '/api/v1/configtrace/confs', + method='PUT', + query_string=query_string) + self.assert200(response, + 'Response body is : ' + response.data.decode('utf-8')) + + +if __name__ == '__main__': + import unittest + unittest.main() diff --git a/sysom_server/sysom_config_trace/config_trace/test/test_default_controller.py b/sysom_server/sysom_config_trace/config_trace/test/test_default_controller.py new file mode 100644 index 00000000..bc6a8f64 --- /dev/null +++ b/sysom_server/sysom_config_trace/config_trace/test/test_default_controller.py @@ -0,0 +1,29 @@ +# coding: utf-8 + +from __future__ import absolute_import + +from flask import json +from six import BytesIO + +from config_trace.models.host import Host # noqa: E501 +from config_trace.test import BaseTestCase + + +class TestDefaultController(BaseTestCase): + """DefaultController integration test stubs""" + + def test_get_domain_host(self): + """Test case for get_domain_host + + get specific domain + """ + response = self.client.open( + '/api/v1/configtrace/domains/{domainName}/hosts/{hostName}'.format(domain_name='domain_name_example', host_name='host_name_example'), + method='GET') + self.assert200(response, + 'Response body is : ' + response.data.decode('utf-8')) + + +if __name__ == '__main__': + import unittest + unittest.main() diff --git a/sysom_server/sysom_config_trace/config_trace/test/test_domain_controller.py b/sysom_server/sysom_config_trace/config_trace/test/test_domain_controller.py new file mode 100644 index 00000000..e46dcdf7 --- /dev/null +++ b/sysom_server/sysom_config_trace/config_trace/test/test_domain_controller.py @@ -0,0 +1,96 @@ +# coding: utf-8 + +from __future__ import absolute_import + +from flask import json +from six import BytesIO + +from config_trace.models.domain import Domain # noqa: E501 +from config_trace.models.host import Host # noqa: E501 +from config_trace.test import BaseTestCase + + +class TestDomainController(BaseTestCase): + """DomainController integration test stubs""" + + def test_create_domain(self): + """Test case for create_domain + + create domains + """ + response = self.client.open( + '/api/v1/configtrace/domains/{domainName}'.format(domain_name='domain_name_example'), + method='POST') + self.assert200(response, + 'Response body is : ' + response.data.decode('utf-8')) + + def test_create_domain_host(self): + """Test case for create_domain_host + + create domains + """ + response = self.client.open( + '/api/v1/configtrace/domains/{domainName}/hosts/{hostName}'.format(domain_name='domain_name_example', host_name='host_name_example'), + method='POST') + self.assert200(response, + 'Response body is : ' + response.data.decode('utf-8')) + + def test_delete_domain(self): + """Test case for delete_domain + + delete domain + """ + response = self.client.open( + '/api/v1/configtrace/domains/{domainName}'.format(domain_name='domain_name_example'), + method='DELETE') + self.assert200(response, + 'Response body is : ' + response.data.decode('utf-8')) + + def test_delete_domain_host(self): + """Test case for delete_domain_host + + delete domain + """ + response = self.client.open( + '/api/v1/configtrace/domains/{domainName}/hosts/{hostName}'.format(domain_name='domain_name_example', host_name='host_name_example'), + method='DELETE') + self.assert200(response, + 'Response body is : ' + response.data.decode('utf-8')) + + def test_get_domain(self): + """Test case for get_domain + + get specific domain + """ + response = self.client.open( + '/api/v1/configtrace/domains/{domainName}'.format(domain_name='domain_name_example'), + method='GET') + self.assert200(response, + 'Response body is : ' + response.data.decode('utf-8')) + + def test_list_domain_hosts(self): + """Test case for list_domain_hosts + + list domain hosts + """ + response = self.client.open( + '/api/v1/configtrace/domains/{domainName}/hosts'.format(domain_name='domain_name_example'), + method='GET') + self.assert200(response, + 'Response body is : ' + response.data.decode('utf-8')) + + def test_list_domains(self): + """Test case for list_domains + + list domains + """ + response = self.client.open( + '/api/v1/configtrace/domains', + method='GET') + self.assert200(response, + 'Response body is : ' + response.data.decode('utf-8')) + + +if __name__ == '__main__': + import unittest + unittest.main() diff --git a/sysom_server/sysom_config_trace/config_trace/test/test_host_controller.py b/sysom_server/sysom_config_trace/config_trace/test/test_host_controller.py new file mode 100644 index 00000000..e9cd63cc --- /dev/null +++ b/sysom_server/sysom_config_trace/config_trace/test/test_host_controller.py @@ -0,0 +1,29 @@ +# coding: utf-8 + +from __future__ import absolute_import + +from flask import json +from six import BytesIO + +from config_trace.models.host import Host # noqa: E501 +from config_trace.test import BaseTestCase + + +class TestHostController(BaseTestCase): + """HostController integration test stubs""" + + def test_list_domain_hosts(self): + """Test case for list_domain_hosts + + list domain hosts + """ + response = self.client.open( + '/api/v1/configtrace/domains/{domainName}/hosts'.format(domain_name='domain_name_example'), + method='GET') + self.assert200(response, + 'Response body is : ' + response.data.decode('utf-8')) + + +if __name__ == '__main__': + import unittest + unittest.main() diff --git a/sysom_server/sysom_config_trace/config_trace/type_util.py b/sysom_server/sysom_config_trace/config_trace/type_util.py new file mode 100644 index 00000000..0563f81f --- /dev/null +++ b/sysom_server/sysom_config_trace/config_trace/type_util.py @@ -0,0 +1,32 @@ +# coding: utf-8 + +import sys + +if sys.version_info < (3, 7): + import typing + + def is_generic(klass): + """ Determine whether klass is a generic class """ + return type(klass) == typing.GenericMeta + + def is_dict(klass): + """ Determine whether klass is a Dict """ + return klass.__extra__ == dict + + def is_list(klass): + """ Determine whether klass is a List """ + return klass.__extra__ == list + +else: + + def is_generic(klass): + """ Determine whether klass is a generic class """ + return hasattr(klass, '__origin__') + + def is_dict(klass): + """ Determine whether klass is a Dict """ + return klass.__origin__ == dict + + def is_list(klass): + """ Determine whether klass is a List """ + return klass.__origin__ == list diff --git a/sysom_server/sysom_config_trace/config_trace/util.py b/sysom_server/sysom_config_trace/config_trace/util.py new file mode 100644 index 00000000..ac61d394 --- /dev/null +++ b/sysom_server/sysom_config_trace/config_trace/util.py @@ -0,0 +1,142 @@ +import datetime + +import six +import typing +from config_trace import type_util + + +def _deserialize(data, klass): + """Deserializes dict, list, str into an object. + + :param data: dict, list or str. + :param klass: class literal, or string of class name. + + :return: object. + """ + if data is None: + return None + + if klass in six.integer_types or klass in (float, str, bool, bytearray): + return _deserialize_primitive(data, klass) + elif klass == object: + return _deserialize_object(data) + elif klass == datetime.date: + return deserialize_date(data) + elif klass == datetime.datetime: + return deserialize_datetime(data) + elif type_util.is_generic(klass): + if type_util.is_list(klass): + return _deserialize_list(data, klass.__args__[0]) + if type_util.is_dict(klass): + return _deserialize_dict(data, klass.__args__[1]) + else: + return deserialize_model(data, klass) + + +def _deserialize_primitive(data, klass): + """Deserializes to primitive type. + + :param data: data to deserialize. + :param klass: class literal. + + :return: int, long, float, str, bool. + :rtype: int | long | float | str | bool + """ + try: + value = klass(data) + except UnicodeEncodeError: + value = six.u(data) + except TypeError: + value = data + return value + + +def _deserialize_object(value): + """Return an original value. + + :return: object. + """ + return value + + +def deserialize_date(string): + """Deserializes string to date. + + :param string: str. + :type string: str + :return: date. + :rtype: date + """ + try: + from dateutil.parser import parse + return parse(string).date() + except ImportError: + return string + + +def deserialize_datetime(string): + """Deserializes string to datetime. + + The string should be in iso8601 datetime format. + + :param string: str. + :type string: str + :return: datetime. + :rtype: datetime + """ + try: + from dateutil.parser import parse + return parse(string) + except ImportError: + return string + + +def deserialize_model(data, klass): + """Deserializes list or dict to model. + + :param data: dict, list. + :type data: dict | list + :param klass: class literal. + :return: model object. + """ + instance = klass() + + if not instance.swagger_types: + return data + + for attr, attr_type in six.iteritems(instance.swagger_types): + if data is not None \ + and instance.attribute_map[attr] in data \ + and isinstance(data, (list, dict)): + value = data[instance.attribute_map[attr]] + setattr(instance, attr, _deserialize(value, attr_type)) + + return instance + + +def _deserialize_list(data, boxed_type): + """Deserializes a list and its elements. + + :param data: list to deserialize. + :type data: list + :param boxed_type: class literal. + + :return: deserialized list. + :rtype: list + """ + return [_deserialize(sub_data, boxed_type) + for sub_data in data] + + +def _deserialize_dict(data, boxed_type): + """Deserializes a dict and its elements. + + :param data: dict to deserialize. + :type data: dict + :param boxed_type: class literal. + + :return: deserialized dict. + :rtype: dict + """ + return {k: _deserialize(v, boxed_type) + for k, v in six.iteritems(data)} diff --git a/sysom_server/sysom_config_trace/config_trace/utils/__init__.py b/sysom_server/sysom_config_trace/config_trace/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sysom_server/sysom_config_trace/config_trace/utils/conf_tools.py b/sysom_server/sysom_config_trace/config_trace/utils/conf_tools.py new file mode 100644 index 00000000..566a5f8a --- /dev/null +++ b/sysom_server/sysom_config_trace/config_trace/utils/conf_tools.py @@ -0,0 +1,492 @@ +import os +import json +import configparser +import ast +from enum import Enum + +from config_trace.const.conf_handler_const import CONFIG +from config_trace.utils.git_tools import GitTools +from config_trace.controllers.format import Format +from clogger import logger + +PATH = "path" +EXCEPTED_VALUE = "expectedValue" +STRIKETHROUGH = '-' +KNOWN_ARCHITECTURES = [ + # Common architectures + "x86_64", + "i686", + "aarch64" +] +STAT = "/usr/bin/stat" +LS = "/usr/bin/ls" +LL = "-l" +ACCESS = "Access" +UID = "Uid" +GID = "Gid" +TWOSPACE = " " +SPACE = " " +Colon = ":" +FS = "/" +LeftParen = "(" +RightParen = ")" +STRIKE = "-" +PERMISSION = 3 +R = "r" +W = "w" +X = "x" +RPERM = 4 +WPERM = 2 +XPERM = 1 +SPERM = 0 + +NOTFOUND = "NOT FOUND" +NOTSYNCHRONIZE = "NOT SYNCHRONIZE" +SYNCHRONIZED = "SYNCHRONIZED" + + +class SyncRes(Enum): + SUCCESS = "SUCCESS" + FAILED = "FAILED" + + +class ConfTools(object): + """ + desc: convert the configuration items controlled in the domain into dict storage + """ + + def __init__(self): + self._managementConfs = [] + self._target_dir = "/home/confBak" + + @property + def managementConfs(self): + return self._managementConfs + + @managementConfs.setter + def managementConfs(self, value): + self._managementConfs = value + + @property + def target_dir(self): + return self._target_dir + + @target_dir.setter + def target_dir(self, target_dir): + self._target_dir = target_dir + + def listToDict(self, manaConfs): + res = {} + logger.debug("manaConfs is : {}".format(manaConfs)) + for d_conf in manaConfs: + path = d_conf.get(PATH) + value = d_conf.get(EXCEPTED_VALUE).strip() + level = path.split("/") + d_res0 = {} + d_res0[level[len(level) - 1]] = value + + returnObject = res + returnCount = 0 + for count in range(0, len(level)): + d_level = level[count] + if returnObject.get(d_level): + returnObject = returnObject.get(d_level) + else: + returnCount = count + break + # to dict + for count in range(len(level) - 2, returnCount, -1): + d_res = {} + key = level[count] + d_res[key] = d_res0 + d_res0 = d_res + + # level less than 2 + if d_res0.get(level[returnCount]): + returnObject[level[returnCount]] = d_res0.get(level[returnCount]) + else: + returnObject[level[returnCount]] = d_res0 + + return res + + def getRpmInfo(self, path): + """ + desc: return the rpm_name\rpm_release\rpm_version for the package of path + example: + input: '/etc/yum.repos.d/openEuler.repo' + output: openEuler-repos 1.0 3.0.oe1.aarch64 + """ + if not os.path.exists(path): + return None + cmd = "/bin/rpm -qf {}".format(path) + gitTools = GitTools() + package_string = gitTools.run_shell_return_output(cmd).decode() + logger.debug("package_string is : {}".format(package_string)) + if 'not owned by any package' in package_string: + return None, None, None + pkg, arch = Format.rsplit(package_string, Format.arch_sep(package_string)) + if arch not in KNOWN_ARCHITECTURES: + pkg, arch = (package_string, None) + pkg, release = Format.rsplit(pkg, '-') + name, version = Format.rsplit(pkg, '-') + # If the value of epoch needs to be returned separately, + return name, release, version + + def getFileAttr(self, path): + """ + desc: return the fileAtrr and fileOwner of path. + if the /usr/bin/stat exists, we can use the case1: + command: /usr/bin/stat filename + output: + [root@openeuler-development-2-pafcm demo]# stat /etc/tcsd.conf + File: /etc/tcsd.conf + Size: 7046 Blocks: 16 IO Block: 4096 regular file + Device: fd00h/64768d Inode: 262026 Links: 1 + Access: (0600/-rw-------) Uid: ( 59/ tss) Gid: ( 59/ tss) + Context: system_u:object_r:etc_t:s0 + Access: 2021-06-18 14:43:15.413173879 +0800 + Modify: 2020-12-21 23:16:08.000000000 +0800 + Change: 2021-01-13 16:50:31.257896622 +0800 + Birth: 2021-01-13 16:50:31.257896622 +0800 + else, we use the case2: + command: ls -l filename + output: + [root@openeuler-development-2-pafcm demo]# ls -l /etc/tcsd.conf + -rw-------. 1 tss tss 6.9K Dec 21 23:16 /etc/tcsd.conf + + example: + input: '/etc/yum.repos.d/openEuler.repo' + output: 0644 (root root) + """ + if not os.path.exists(STAT): + fileAttr, fileOwner = self.getFileAttrByLl(path) + return fileAttr, fileOwner + + cmd = STAT + SPACE + path + gitTools = GitTools() + stat_rest = gitTools.run_shell_return_output(cmd).decode() + logger.debug("the stat_rest is : {}".format(stat_rest)) + fileAttr = "" + fileOwner = "" + lines = stat_rest.splitlines() + for line in lines: + if ACCESS in line and UID in line and GID in line: + d_lines = line.split(RightParen + TWOSPACE) + for d_line in d_lines: + d_line = d_line.lstrip() + if d_line.startswith(ACCESS): + fileAttr = d_line.split(FS)[0].split(LeftParen)[1] + elif d_line.startswith(UID): + fileUid = d_line.split(LeftParen)[1].split(FS)[1].lstrip() + elif d_line.startswith(GID): + fileGid = d_line.split(LeftParen)[1].split(FS)[1].lstrip().split(RightParen)[0] + else: + continue + fileOwner = LeftParen + fileUid + SPACE + fileGid + RightParen + + if not fileAttr or not fileOwner: + fileAttr, fileOwner = self.getFileAttrByLL(path) + logger.debug("fileAttr is : {}".format(fileAttr)) + logger.debug("fileOwner is : {}".format(fileOwner)) + return fileAttr, fileOwner + + def getFileAttrByLL(self, path): + """ + desc: we can use the command 'ls -l filename' to get the Attribute information of the path. + example: + command: ls -l filename + commandOutput: + [root@openeuler-development-2-pafcm demo]# ls -l /etc/tcsd.conf + -rw-------. 1 tss tss 6.9K Dec 21 23:16 /etc/tcsd.conf + calculate score: + the first digit indicates the type: [d]->directory, [-]->files + then every 3 are grouped, indicates read/write/execute + score: r->4 w->2 x->1 + """ + if not os.path.exists(LS): + return None, None + cmd = LS + SPACE + LL + SPACE + path + logger.debug("cmd is : {}".format(cmd)) + gitTools = GitTools() + ll_res = gitTools.run_shell_return_output(cmd).decode() + logger.debug("ll_res is : {}".format(ll_res)) + ll_res_list = ll_res.split(SPACE) + + fileType = ll_res_list[0] + permssions = "0" + for perm in range(0, PERMISSION): + items = fileType[1 + perm * PERMISSION: (perm + 1) * PERMISSION + 1] + value = 0 + for d_item in items: + d_item_value = self.switch_perm(d_item) + value = value + d_item_value + permssions = permssions + str(value) + logger.debug("the perssion is : {}".format(permssions)) + + fileOwner = LeftParen + ll_res_list[2] + SPACE + ll_res_list[3] + RightParen + logger.debug("the fileOwner is : {}".format(fileOwner)) + + return permssions, fileOwner + + def switch_perm(self, permValue): + if permValue == R: + return RPERM + elif permValue == W: + return WPERM + elif permValue == X: + return XPERM + else: + return SPERM + + def getXpathInManagerConfs(self, manageConfs): + """ + desc: generate the xpath list of configuration items. + """ + confXpath = [] + for d_conf in manageConfs: + path = d_conf.get('path') + confXpath.append(path) + + return confXpath + + def writeBakFileInPath(self, path, content): + """ + desc: Create the Path file, and write the content content, return the write result + """ + res = False + cwd = os.getcwd() + os.umask(0o077) + if not os.path.exists(self._target_dir): + os.mkdir(self._target_dir) + + os.chdir(self._target_dir) + path_git = Format.two_abs_join(self.target_dir, path) + paths = path_git.split('/') + path_git_delete_last = "" + for d_index in range(0, len(paths) - 1): + path_git_delete_last = path_git_delete_last + '/' + paths[d_index] + if not os.path.exists(path_git): + cmd = "/bin/mkdir -p " + path_git_delete_last + logger.debug("cmd is : {}".format(cmd)) + gitTools = GitTools() + ll_res = gitTools.run_shell_return_output(cmd).decode() + + if not os.path.exists(path_git_delete_last): + return res + + with open(path_git, 'w') as d_file: + d_file.write(content) + res = True + os.chdir(cwd) + + return res + + def getRealConfByPath(self, real_conf, path): + """ + desc: Returns the index and true value corresponding to the PATH in real_conf + exmaple: + input: + real_conf: [ + { + 'path': 'OS/yum/openEuler.repo/OS/name', + 'real_value': 'OS' + }, + { + 'path': 'OS/yum/openEuler.repo/OS/baseurl', + 'real_value': 'http://repo.openeuler.org/openEuler-20.03-LTS-SP1/OS/$basearch/' + }] + path: 'OS/yum/openEuler.repo/OS/name' + output: + index: 0 + value: 'OS' + """ + index = 0 + value = "" + for count in range(0, len(real_conf)): + d_real = real_conf[count] + if d_real.path == path: + index = count + value = d_real.real_value.strip() + break + + return index, value + + def syncConf(self, contents, path): + """ + desc: Put the new configuration into the environment with the path. + return: the result of effective + example: + input: + contents: [ + '[OS]', + 'name=OS', + 'baseurl=https://repo.huaweicloud.com/openeuler/openEuler-20.03-LTS-SP1/everything/x86_64/', + 'enabled=1', + 'gpgcheck=0', + 'gpgkey=http://repo.openeuler.org/openEuler-20.03-LTS-SP1/OS/$basearch/RPM-GPG-KEY-openEuler' + ] + path: '/etc/yum.repos.d/openEuler.repo' + output: + res : true or false + """ + res = 0 + res = Format.set_file_content_by_path(contents, path) + return res + + def wirteFileInPath(self, path, content): + """ + desc: Create the Path file, and write the content content, return the write result + """ + res = False + path_delete_last = "" + os.umask(0o077) + if not os.path.exists(path): + paths = path.split('/') + for d_index in range(0, len(paths) - 1): + path_delete_last = path_delete_last + '/' + paths[d_index] + if not os.path.exists(path_delete_last): + cmd = "/bin/mkdir -p " + path_delete_last + logger.debug("cmd is : {}".format(cmd)) + gitTools = GitTools() + ll_res = gitTools.run_shell_return_output(cmd).decode() + logger.debug("path_delete_last IS :{}".format(path_delete_last)) + if not os.path.exists(path_delete_last): + return res + + with open(path, 'w') as d_file: + d_file.writelines(content) + res = True + + return res + + def load_url_by_conf(self): + """ + desc: get the url of collect conf + """ + cf = configparser.ConfigParser() + if os.path.exists(CONFIG): + cf.read(CONFIG, encoding="utf-8") + else: + parent = os.path.dirname(os.path.realpath(__file__)) + conf_path = os.path.join(parent, "../../config_trace.conf") + cf.read(conf_path, encoding="utf-8") + + collect_address = ast.literal_eval(cf.get("collect", "collect_address")) + collect_api = ast.literal_eval(cf.get("collect", "collect_api")) + collect_port = str(cf.get("collect", "collect_port")) + collect_url = "{address}:{port}{api}".format(address=collect_address, api=collect_api, port=collect_port) + + sync_address = ast.literal_eval(cf.get("sync", "sync_address")) + sync_api = ast.literal_eval(cf.get("sync", "sync_api")) + sync_port = str(cf.get("sync", "sync_port")) + sync_url = "{address}:{port}{api}".format(address=sync_address, api=sync_api, port=sync_port) + + object_file_address = ast.literal_eval(cf.get("objectFile", "object_file_address")) + object_file_api = ast.literal_eval(cf.get("objectFile", "object_file_api")) + object_file_port = str(cf.get("objectFile", "object_file_port")) + object_file_url = "{address}:{port}{api}".format(address=object_file_address, api=object_file_api, + port=object_file_port) + + url = {"collect_url": collect_url, "sync_url": sync_url, "object_file_url": object_file_url} + return url + + def load_port_by_conf(self): + """ + desc: get the password of collect conf + """ + cf = configparser.ConfigParser() + logger.debug("CONFIG is :{}".format(CONFIG)) + if os.path.exists(CONFIG): + cf.read(CONFIG, encoding="utf-8") + else: + parent = os.path.dirname(os.path.realpath(__file__)) + conf_path = os.path.join(parent, "../../config_trace.conf") + cf.read(conf_path, encoding="utf-8") + port = cf.get("config_trace", "port") + return port + + @staticmethod + def load_log_conf(): + """ + desc: get the log configuration + """ + cf = configparser.ConfigParser() + if os.path.exists(CONFIG): + cf.read(CONFIG, encoding="utf-8") + else: + parent = os.path.dirname(os.path.realpath(__file__)) + conf_path = os.path.join(parent, "../../config_trace.conf") + cf.read(conf_path, encoding="utf-8") + log_level = cf.get("log", "log_level") + log_dir = cf.get("log", "log_dir") + max_bytes = cf.get("log", "max_bytes") + backup_count = cf.get("log", "backup_count") + log_conf = {"log_level": log_level, "log_dir": log_dir, "max_bytes": int(max_bytes), + "backup_count": int(backup_count)} + return log_conf + + @staticmethod + def fetchRealConfs(host_name, file_path): + # 获取指定主机上的配置内容,需要考虑file_path为目录且存在子目录的情况 + # param: host_name: 主机名 + # param: file_path: 文件在主机上的路径,可以为目录 + # return: map[文件绝对路径][文件内容,文件元数据] + # 使用CEC框架实现交互访问 + from channel_job import default_channel_job_executor + job = default_channel_job_executor.dispatch_job( + channel_type="ssh", + channel_opt="cmd", + params={ + "instance": host_name, + "command": "cat " + file_path, + }, + auto_retry=False + ) + channel_result = job.execute() + + if channel_result.code != 0: + logger.warning("fetch config from host {} failed, err_msg: {} code: {}" + .format(host_name, channel_result.err_msg, channel_result.code)) + return {} + return { + # "/etc/hosts": ["127.0.0.1 localhost\n", "{\"mode\": \"0644\", \"owner\": \"root\", \"group\": \"root\"}"] + file_path: [channel_result.result] + } + + @staticmethod + def sync_confs(hosts, config, parent_dir): + # host_name: 入参主机名 + # config: 配置信息,包括: 路径及期望配置内容 + # Resp: 返回成功ip或失败ip, 例如: host_name, None + from channel_job import default_channel_job_executor + lpath = parent_dir + config["file_path"] + rpath = config["file_path"] + + job = default_channel_job_executor.dispatch_file_job( + params={ + "local_path": lpath, + "remote_path": rpath, + "instances": hosts + }, + opt="send-file" + ) + channel_result = job.execute() + logger.info(f'send file {lpath} to {rpath}') + logger.info(channel_result.__dict__) + if channel_result.code != 0: + return None, hosts + " " + channel_result.err_msg + return hosts, None + # job = default_channel_job_executor.dispatch_job( + # channel_type="ssh", + # channel_opt="cmd", + # params={ + # "instance": host_name, + # "command": "echo '" + config.expect_content + "' > " + config["path"], + # }, + # timeout=10000, + # auto_retry=False + # ) + # channel_result = job.execute() + # if channel_result.code != 0: + # return None, host_name + # return host_name, None diff --git a/sysom_server/sysom_config_trace/config_trace/utils/git_tools.py b/sysom_server/sysom_config_trace/config_trace/utils/git_tools.py new file mode 100644 index 00000000..03653f7e --- /dev/null +++ b/sysom_server/sysom_config_trace/config_trace/utils/git_tools.py @@ -0,0 +1,183 @@ +import os +import subprocess +import sys +import configparser +import ast + +from config_trace.const.conf_handler_const import CONFIG +from clogger import logger +from config_trace.models.git_log_message import GitLogMessage +from config_trace.controllers.format import Format + + +class GitTools(object): + def __init__(self, target_dir=None): + if target_dir: + self._target_dir = target_dir + else: + self._target_dir = self.load_git_dir() + + def load_git_dir(self): + cf = configparser.ConfigParser() + if os.path.exists(CONFIG): + cf.read(CONFIG, encoding="utf-8") + else: + parent = os.path.dirname(os.path.realpath(__file__)) + conf_path = os.path.join(parent, "../../config_trace.conf") + cf.read(conf_path, encoding="utf-8") + git_dir = ast.literal_eval(cf.get("git", "git_dir")) + return git_dir + + @property + def target_dir(self): + return self.load_git_dir() + + @target_dir.setter + def target_dir(self, target_dir): + self._target_dir = target_dir + + def gitInit(self): + cwdDir = os.getcwd() + os.chdir(self._target_dir) + shell = "/bin/git init" + returncode = self.run_shell_return_code(shell) + os.chdir(cwdDir) + return returncode + + def git_create_user(self, username, useremail): + """ + desc: Git initial configuration about add a user name and email + """ + returncode = 1 + cmd_add_user_name = "git config user.name {}".format(username) + cmd_name_code = self.run_shell_return_code(cmd_add_user_name) + cmd_add_user_email = "git config user.email {}".format(useremail) + cmd_email_code = self.run_shell_return_code(cmd_add_user_email) + if cmd_name_code and cmd_email_code: + returncode = 0 + return returncode + + def gitCommit(self, message): + cwdDir = os.getcwd() + os.chdir(self._target_dir) + cmd1 = "git add ." + returncode1 = self.run_shell_return_code(cmd1) + if returncode1 == 0: + cmd2 = "git commit -m '{}'".format(message) + returncode2 = self.run_shell_return_code(cmd2) + returncode = returncode2 + else: + returncode = returncode1 + os.chdir(cwdDir) + + return returncode + + def gitLog(self, path): + cwdDir = os.getcwd() + os.chdir(self._target_dir) + shell = ['git log {}'.format(path)] + output = self.run_shell_return_output(shell) + os.chdir(cwdDir) + return output + + # Execute the shell command and return the execution node + def run_shell_return_code(self, shell): + cmd = subprocess.Popen(shell, stdin=subprocess.PIPE, stderr=sys.stderr, close_fds=True, + stdout=sys.stdout, universal_newlines=True, shell=True, bufsize=1) + + output, err = cmd.communicate() + return cmd.returncode + + # Execute the shell command and return the execution node and output + def run_shell_return_output(self, shell): + cmd = subprocess.Popen(shell, stdout=subprocess.PIPE, shell=True) + logger.debug("subprocess.Popen({shell}, stdout=subprocess.PIPE, shell=True)".format(shell=shell)) + output, err = cmd.communicate() + return output + + def makeGitMessage(self, path, logMessage): + if len(logMessage) == 0: + return "the logMessage is null" + logger.debug("path is : {}".format(path)) + cwdDir = os.getcwd() + os.chdir(self._target_dir) + logger.debug(os.getcwd()) + logger.debug("logMessage is : {}".format(logMessage)) + gitLogMessageList = [] + singleLogLen = 6 + # the count is num of message + count = logMessage.count("commit") + lines = logMessage.split('\n') + + logger.debug("count is : {}".format(count)) + for index in range(0, count): + gitMessage = GitLogMessage() + for temp in range(0, singleLogLen): + line = lines[index * singleLogLen + temp] + value = line.split(" ", 1)[-1] + if "commit" in line: + gitMessage.change_id = value + if "Author" in line: + gitMessage.author = value + if "Date" in line: + gitMessage._date = value[2:] + gitMessage.change_reason = lines[index * singleLogLen + 4] + logger.debug("gitMessage is : {}".format(gitMessage)) + gitLogMessageList.append(gitMessage) + + logger.debug("################# gitMessage start ################") + if count == 1: + last_message = gitLogMessageList[0] + last_message.post_value = Format.get_file_content_by_read(path) + os.chdir(cwdDir) + return gitLogMessageList + + for index in range(0, count - 1): + logger.debug("index is : {}".format(index)) + message = gitLogMessageList[index] + next_message = gitLogMessageList[index + 1] + message.post_value = Format.get_file_content_by_read(path) + shell = ['/bin/git checkout {}'.format(next_message.change_id)] + output = self.run_shell_return_output(shell) + message.pre_value = Format.get_file_content_by_read(path) + # the last changlog + first_message = gitLogMessageList[count - 1] + first_message.post_value = Format.get_file_content_by_read(path) + + logger.debug("################# gitMessage end ################") + os.chdir(cwdDir) + return gitLogMessageList + + def gitCheckToHead(self): + """ + desc: git checkout to the HEAD in this git. + """ + cwdDir = os.getcwd() + os.chdir(self._target_dir) + cmd = ['/bin/git checkout master'] + output = self.run_shell_return_code(cmd) + os.chdir(cwdDir) + return output + + def getLogMessageByPath(self, confPath): + """ + :desc: Returns the Git change record under the current path. + :param : string + :rtype: confBaseInfo + """ + logMessage = self.gitLog(confPath) + gitMessage = self.makeGitMessage(confPath, logMessage.decode('utf-8')) + checoutResult = self.gitCheckToHead() + return gitMessage + + def gitDiff(self, confPath): + """ + :desc: 返回针对当前配置路径的期望配置和真实配置的差异 + """ + cwdDir = os.getcwd() + os.chdir(self._target_dir) + expected = self._target_dir + confPath + diff = self.run_shell_return_output(['/bin/git diff %s %s' % expected, confPath]) + os.chdir(cwdDir) + return diff + diff --git a/sysom_server/sysom_config_trace/config_trace/utils/host_tools.py b/sysom_server/sysom_config_trace/config_trace/utils/host_tools.py new file mode 100644 index 00000000..432ad8fd --- /dev/null +++ b/sysom_server/sysom_config_trace/config_trace/utils/host_tools.py @@ -0,0 +1,96 @@ +import os +import configparser +import ast + +from config_trace.const.conf_handler_const import CONFIG +from clogger import logger + + +class HostTools(object): + def __init__(self): + self._target_dir = self.load_git_dir() + self._host_file = "hostRecord.txt" + + @property + def target_dir(self): + return self._target_dir + + @target_dir.setter + def target_dir(self, target_dir): + self._target_dir = target_dir + + @property + def host_file(self): + return self._host_file + + @host_file.setter + def host_file(self, host_file): + self._host_file = host_file + + def isHostIdExist(self, hostPath, hostId): + """ + desc: 查询hostId是否存在当前的host域管理范围内 + """ + isHostIdExist = False + if os.path.isfile(hostPath) and os.stat(hostPath).st_size > 0: + with open(hostPath) as h_file: + for line in h_file.readlines(): + if str(hostId) in line: + isHostIdExist = True + break + + return isHostIdExist + + def getHostExistStatus(self, domain, hostList): + """ + desc: return two list about the status of the host exists + example: + input hostList: [{'host_id': '551d02da-7d8c-4357-b88d-15dc55ee22cc', + 'ip': '210.22.22.150', + 'ipv6': 'None'}] + output existHost:['551d02da-7d8c-4357-b88d-15dc55ee22cc'] + """ + if len(hostList) == 0: + return None, None + domainPath = os.path.join(self._target_dir, domain) + hostPath = os.path.join(domainPath, self._host_file) + existHost = [] + failedHost = [] + for d_host in hostList: + d_hostId = d_host.get('hostId') + isHostIdExist = self.isHostIdExist(hostPath, d_hostId) + if isHostIdExist: + existHost.append(d_hostId) + else: + failedHost.append(d_hostId) + return existHost, failedHost + + def getHostList(self, domainHost) -> []: + """ + desc:return a host list from the result of the /host/getHost + example: + domainHost is : [{'hostId': '551d02da-7d8c-4357-b88d-15dc55ee22cc', 'ip': '210.22.22.150', 'ipv6': 'None'}] + hostList is: [{ + "hostId" : '551d02da-7d8c-4357-b88d-15dc55ee22cc' + }] + """ + res = [] + logger.debug("The domainHost is : {}".format(domainHost)) + for d_host in domainHost: + hostId = int(d_host.get('host_id')) + d_host = {} + d_host["hostId"] = hostId + res.append(hostId) + + return res + + def load_git_dir(self): + cf = configparser.ConfigParser() + if os.path.exists(CONFIG): + cf.read(CONFIG, encoding="utf-8") + else: + parent = os.path.dirname(os.path.realpath(__file__)) + conf_path = os.path.join(parent, "../../config_trace.conf") + cf.read(conf_path, encoding="utf-8") + git_dir = ast.literal_eval(cf.get("git", "git_dir")) + return git_dir diff --git a/sysom_server/sysom_config_trace/config_trace/utils/prepare.py b/sysom_server/sysom_config_trace/config_trace/utils/prepare.py new file mode 100644 index 00000000..691befad --- /dev/null +++ b/sysom_server/sysom_config_trace/config_trace/utils/prepare.py @@ -0,0 +1,43 @@ +import os + +from clogger import logger +from config_trace.utils.git_tools import GitTools + +class Prepare(object): + def __init__(self, target_dir): + self._target_dir = target_dir + + @property + def target_dir(self): + return self._target_dir + + @target_dir.setter + def target_dir(self, target_dir): + self._target_dir = target_dir + + def mkdir_git_warehose(self, username, useremail): + res = True + logger.debug("self._target_dir is : {}".format(self._target_dir)) + if os.path.exists(self._target_dir): + rest = self.git_init(username, useremail) + return rest + os.umask(0o077) + cmd1 = "/bin/mkdir -p {}".format(self._target_dir) + git_tools = GitTools(self._target_dir) + mkdir_code = git_tools.run_shell_return_code(cmd1) + git_code = self.git_init(username, useremail) + if mkdir_code != 0 or not git_code: + res = False + return res + + def git_init(self, username, useremail): + res = False + cwd = os.getcwd() + os.chdir(self._target_dir) + git_tools = GitTools(self._target_dir) + res_init = git_tools.gitInit() + res_user = git_tools.git_create_user(username, useremail) + if res_init == 0 and res_user == 0: + res = True + os.chdir(cwd) + return res diff --git "a/sysom_server/sysom_config_trace/docs/UML\346\227\266\345\272\217\345\233\276.png" "b/sysom_server/sysom_config_trace/docs/UML\346\227\266\345\272\217\345\233\276.png" new file mode 100644 index 0000000000000000000000000000000000000000..08e19468b0e7636b34f0f638f9d94c968b0545ee GIT binary patch literal 124471 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 literal 0 HcmV?d00001 diff --git a/sysom_server/sysom_config_trace/scripts/node_clear.sh b/sysom_server/sysom_config_trace/scripts/node_clear.sh new file mode 100644 index 00000000..8ffce5ab --- /dev/null +++ b/sysom_server/sysom_config_trace/scripts/node_clear.sh @@ -0,0 +1,10 @@ +#!/bin/bash -x + +RESOURCE_DIR=${baseimage}/${SERVICE_NAME} + +systemctl stop sysom_configtrace_agent.service +systemctl disable sysom_configtrace_agent.service +rm -f /usr/lib/systemd/system/vmcore-collect.service +systemctl daemon-reload +rm -r -f ${RESOURCE_DIR} +exit 0 \ No newline at end of file diff --git a/sysom_server/sysom_config_trace/scripts/node_init.sh b/sysom_server/sysom_config_trace/scripts/node_init.sh new file mode 100644 index 00000000..54c1c28f --- /dev/null +++ b/sysom_server/sysom_config_trace/scripts/node_init.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +rpm -q --quiet bcc || yum install -y bcc +# TODO: 支持centos7,配置使用python3 +rpm -q --quiet python3-bcc || yum install -y python3-bcc + +RESOURCE_DIR=${baseimage}/${SERVICE_NAME} +mkdir -p ${RESOURCE_DIR} + +cp trace_file_change.py ${RESOURCE_DIR} + +# 写入service文件 + +cat << EOF > sysom_configtrace_agent.service +[Unit] +Description=SysOM ConfigTrace Agent +Documentation=SysOM ConfigTrace Agent +Wants=network-online.target +After=network-online.target + +[Service] +ExecStart=${RESOURCE_DIR}/trace_file_change.py --file /var/run/configtrace/configlist.txt +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +EOF + +if [ ! -d ${RESOURCE_DIR} ]; then + mkdir -p ${RESOURCE_DIR} +fi +if [ ! -d /var/run/configtrace ]; then + mkdir -p /var/run/configtrace + touch /var/run/configtrace/configlist.txt +fi +mv sysom_configtrace_agent.service /usr/lib/systemd/system +systemctl daemon-reload +systemctl enable sysom_configtrace_agent +systemctl start sysom_configtrace_agent +ps -elf | grep "${RESOURCE_DIR}/trace_file_change.py" | grep -v grep 1>/dev/null + +if [ $? -ne 0 ] +then + exit 1 +fi \ No newline at end of file diff --git a/sysom_server/sysom_config_trace/scripts/node_update.sh b/sysom_server/sysom_config_trace/scripts/node_update.sh new file mode 100644 index 00000000..bd888857 --- /dev/null +++ b/sysom_server/sysom_config_trace/scripts/node_update.sh @@ -0,0 +1 @@ +#!/bin/bash -x \ No newline at end of file diff --git a/sysom_server/sysom_config_trace/scripts/trace_file_change.py b/sysom_server/sysom_config_trace/scripts/trace_file_change.py new file mode 100644 index 00000000..0e065642 --- /dev/null +++ b/sysom_server/sysom_config_trace/scripts/trace_file_change.py @@ -0,0 +1,251 @@ +#!/bin/python3 + +# 主机在部署完成后,由控制面程序对主机需要追踪的文件进行注入和配置,并控制进程重启,初始状态下默认为configfile的默认内容 + +# 使用方式: ./trace_file_change.py --file filename +# 通过bcc实现常态化对主机文件的变更追踪 +# 需要安装bcc和python3-bcc + +from __future__ import print_function +from bcc import BPF +from bcc.containers import filter_by_containers +from bcc.utils import printb +from datetime import datetime +import argparse + +# 定义BPF程序 +bpf_text = """ +#include +#include +#include + +struct val_t { + u64 id; + char comm[TASK_COMM_LEN]; + const char *fname; + int flags; +}; + +struct data_t { + u64 id; + u64 ts; + u32 uid; + int ret; + char comm[TASK_COMM_LEN]; + char fname[NAME_MAX]; + int flags; +}; + +BPF_PERF_OUTPUT(events); + +BPF_HASH(infotmp, u64, struct val_t); + +int trace_open(struct pt_regs *ctx, const char __user *filename, int flags) +{ + struct val_t val = {}; + u64 id = bpf_get_current_pid_tgid(); + u32 pid = id >> 32; // PID is higher part + u32 tid = id; // Cast and get the lower part + u32 uid = bpf_get_current_uid_gid(); + + + + if (!(flags & 3)) { return 0; } + + if (container_should_be_filtered()) { + return 0; + } + + if (bpf_get_current_comm(&val.comm, sizeof(val.comm)) == 0) { + val.id = id; + val.fname = filename; + val.flags = flags; + infotmp.update(&id, &val); + } + + return 0; +}; + +int trace_openat(struct pt_regs *ctx, int dfd, const char __user *filename, int flags) +{ + struct val_t val = {}; + u64 id = bpf_get_current_pid_tgid(); + u32 pid = id >> 32; // PID is higher part + u32 tid = id; // Cast and get the lower part + u32 uid = bpf_get_current_uid_gid(); + + + + if (!(flags & 3)) { return 0; } + + if (container_should_be_filtered()) { + return 0; + } + + if (bpf_get_current_comm(&val.comm, sizeof(val.comm)) == 0) { + val.id = id; + val.fname = filename; + val.flags = flags; // EXTENDED_STRUCT_MEMBER + infotmp.update(&id, &val); + } + + return 0; +}; + +#include +int trace_openat2(struct pt_regs *ctx, int dfd, const char __user *filename, struct open_how *how) +{ + int flags = how->flags; + + struct val_t val = {}; + u64 id = bpf_get_current_pid_tgid(); + u32 pid = id >> 32; // PID is higher part + u32 tid = id; // Cast and get the lower part + u32 uid = bpf_get_current_uid_gid(); + + + + if (!(flags & 3)) { return 0; } + + if (container_should_be_filtered()) { + return 0; + } + + if (bpf_get_current_comm(&val.comm, sizeof(val.comm)) == 0) { + val.id = id; + val.fname = filename; + val.flags = flags; + infotmp.update(&id, &val); + } + + return 0; +}; + +int trace_return(struct pt_regs *ctx) +{ + u64 id = bpf_get_current_pid_tgid(); + struct val_t *valp; + struct data_t data = {}; + + u64 tsp = bpf_ktime_get_ns(); + + valp = infotmp.lookup(&id); + if (valp == 0) { + // missed entry + return 0; + } + bpf_probe_read_kernel(&data.comm, sizeof(data.comm), valp->comm); + bpf_probe_read_user(&data.fname, sizeof(data.fname), (void *)valp->fname); + data.id = valp->id; + data.ts = tsp / 1000; + data.uid = bpf_get_current_uid_gid(); + data.flags = valp->flags; + data.ret = PT_REGS_RC(ctx); + + events.perf_submit(ctx, &data, sizeof(data)); + infotmp.delete(&id); + + return 0; +} + +""" + +# 加载BPF程序 +# arguments +examples = """examples: + ./opensnoop --cgroupmap mappath # only trace cgroups in this BPF map + ./opensnoop --mntnsmap mappath # only trace mount namespaces in the map + ./opensnoop --config filepath # only trace out specified files change history +""" +parser = argparse.ArgumentParser( + description="Trace configfile syscalls", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=examples) +parser.add_argument("--cgroupmap", + help="trace cgroups in this BPF map only") +parser.add_argument("--mntnsmap", + help="trace mount namespaces in this BPF map only") +parser.add_argument("--file", + help="trace out specified files change history only") + +args = parser.parse_args() + + +bpf_text = filter_by_containers(args) + bpf_text + +b = BPF(text=bpf_text) + +b.attach_kprobe(event="do_sys_open", fn_name="trace_open") +b.attach_kretprobe(event="do_sys_open", fn_name="trace_return") + +# b.attach_kprobe(event="do_sys_openat", fn_name="trace_openat") +# b.attach_kretprobe(event="do_sys_openat", fn_name="trace_return") + +b.attach_kprobe(event="do_sys_openat2", fn_name="trace_openat2") +b.attach_kretprobe(event="do_sys_openat2", fn_name="trace_return") + +# fetch configfile list for host + +files = [] +confs = dict() +domain = '' +with open(args.file, 'r') as f: + for line in f.readlines(): + if line.startswith('#'): + continue + if line.startswith("["): + domain = line.strip().strip('[]') + confs[domain] = [] + continue + if line.strip() == '': + continue + confs[domain].append(line.strip()) + files.append(line.strip()) + +# process event +def print_event(cpu, data, size): + event = b["events"].event(data) + # split return value into FD and errno columns + if event.ret >= 0: + fd_s = event.ret + err = 0 + else: + fd_s = -1 + err = - event.ret + + if event.fname.decode("utf-8") not in files: + return + # byte to string + printb(b"%-6d %-16s %4d %3d " % + (boot_time_timestamp + int(event.ts / 1000000), + event.comm, fd_s, err), nl="") + + # printb(b'%s' % event.fname) + # TODO: 针对于在记录中的配置文件,进行告警提示 + # 根据主机取所有的待追踪的文件 + # 如果待追踪文件在列表中,则执行告警提示 + # 写出到文件 + with open('/var/run/configtrace/alert.log', 'a') as f: + f.write("%f,%s,%s,%d,%d\n" % + (boot_time_timestamp + float(event.ts / 1000000), + event.fname.decode("utf-8"), + event.comm.decode("utf-8"), fd_s, err)) + +def get_system_boot_time(): + with open('/proc/stat', 'r') as file: + for line in file: + if line.startswith('btime'): + boot_time = int(line.split()[1]) + return boot_time + +boot_time_timestamp = get_system_boot_time() +print(boot_time_timestamp) + + +# loop with callback to print_event +b["events"].open_perf_buffer(print_event, page_cnt=64) +while True: + try: + b.perf_buffer_poll() + except KeyboardInterrupt: + exit() diff --git a/sysom_server/sysom_config_trace/setup.py b/sysom_server/sysom_config_trace/setup.py new file mode 100644 index 00000000..b9bebe8f --- /dev/null +++ b/sysom_server/sysom_config_trace/setup.py @@ -0,0 +1,36 @@ +# coding: utf-8 + +import sys +from setuptools import setup, find_packages + +NAME = "config_trace" +VERSION = "1.0.0" +# To install the library, run the following +# +# python setup.py install +# +# prerequisite: setuptools +# http://pypi.python.org/pypi/setuptools + +REQUIRES = [ + "connexion", + "swagger-ui-bundle>=0.0.2" +] + +setup( + name=NAME, + version=VERSION, + description="Configration Tracability", + author_email="", + url="", + keywords=["Swagger", "Configration Tracability"], + install_requires=REQUIRES, + packages=find_packages(), + package_data={'': ['swagger/swagger.yaml']}, + include_package_data=True, + entry_points={ + 'console_scripts': ['config_trace=config_trace.__main__:main']}, + long_description="""\ + No description provided (generated by Swagger Codegen https://github.com/swagger-api/swagger-codegen) + """ +) diff --git a/sysom_server/sysom_config_trace/tox.ini b/sysom_server/sysom_config_trace/tox.ini new file mode 100644 index 00000000..2751b218 --- /dev/null +++ b/sysom_server/sysom_config_trace/tox.ini @@ -0,0 +1,10 @@ +[tox] +envlist = py38 + +[testenv] +deps=-r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt + +commands= + nosetests \ + [] \ No newline at end of file diff --git a/sysom_web/config/routes.js b/sysom_web/config/routes.js index 3232d8cc..9202e465 100644 --- a/sysom_web/config/routes.js +++ b/sysom_web/config/routes.js @@ -375,6 +375,35 @@ export default [ }, ] }, + { + path: "/configtrace", + name: "ConfigTrace", + routes: [ + { + path: '/configtrace', + redirect: '/configtrace/domains', + }, + { + path: '/configtrace/domains', + name: "Domains", + component: './configtrace/domains', + }, + { + path: '/configtrace/confs', + name: "Confs", + component: './configtrace/confs', + }, + { + path: '/configtrace/alerts', + name: "Alerts", + component: './configtrace/alerts', + }, + { + path: '/configtrace/confs/:host?', + component: './configtrace/confs', + }, + ] + }, { path: '/', redirect: '/welcome', diff --git a/sysom_web/package.json b/sysom_web/package.json index a361f2f0..1c9cf068 100644 --- a/sysom_web/package.json +++ b/sysom_web/package.json @@ -51,6 +51,7 @@ "@ant-design/pro-card": "^1.20.22", "@ant-design/pro-components": "^2.6.16", "@ant-design/pro-descriptions": "^1.12.6", + "@ant-design/pro-field": "^1.36.7", "@ant-design/pro-form": "^1.74.7", "@ant-design/pro-layout": "^6.5.0", "@ant-design/pro-table": "^2.80.8", @@ -61,12 +62,15 @@ "@uiw/codemirror-theme-tokyo-night": "^4.21.11", "@uiw/react-codemirror": "^4.21.11", "@umijs/route-utils": "^2.0.3", + "ahooks": "^2.0.0", "antd": "4.24.8", "browserslist": "^4.20.2", "caniuse-lite": "^1.0.30001320", "classnames": "^2.2.6", "js-export-excel": "^1.1.4", "moment": "^2.29.4", + "monaco-editor": "^0.36.0", + "monaco-editor-webpack-plugin": "^7.0.1", "node-fetch": "^3.3.1", "react": "17.x", "react-dev-inspector": "^1.8.4", diff --git a/sysom_web/src/pages/configtrace/alerts.jsx b/sysom_web/src/pages/configtrace/alerts.jsx new file mode 100644 index 00000000..1c807ecc --- /dev/null +++ b/sysom_web/src/pages/configtrace/alerts.jsx @@ -0,0 +1,54 @@ +import { useRequest, useParams } from 'umi'; +import { useState, useRef, useEffect } from 'react'; +import { Statistic } from 'antd'; +import { getDomainList } from "./service"; +import ProCard from '@ant-design/pro-card'; +import DomainList from './components/DomainList'; +import AlertList from './components/AlertList'; + +const AlertsList = () => { + const { data, error, loading } = useRequest(getDomainList) + // 获取data中的第一个元素的值 + const { domain } = data?.[0]?.domain_name || {} + const [domainName, setDomainName] = useState(domain || '') + const [collapsed, setCollapsed] = useState(false) + + const onCollapsed = () => { + setCollapsed(!collapsed); + } + + return ( + <> + + + + + + + + + { + setDomainName(domainName); + }} + onLoad={dataSource => { + if (dataSource.length > 0 && !!dataSource[0].domain_name) { + setDomainName(dataSource[0].domain_name) + } + } + } /> + + + {collapsed ? + >>





+ : <<





+ } +
+ +
+ + ); +}; + +export default AlertsList; diff --git a/sysom_web/src/pages/configtrace/components/AlertList.jsx b/sysom_web/src/pages/configtrace/components/AlertList.jsx new file mode 100644 index 00000000..846f671b --- /dev/null +++ b/sysom_web/src/pages/configtrace/components/AlertList.jsx @@ -0,0 +1,66 @@ +import ProTable from '@ant-design/pro-table'; +import { useRef, useState, useEffect } from 'react' +import { getConfsAlerts } from '../service'; + +/** + * A Table components display Confs list + * @param {*} props + * @returns + */ +const AlertList = (props) => { + const [alerts, setAlerts] = useState([]); + + useEffect(() => { + const fetchData = async () => { + try { + const response = await getConfsAlerts({ domainName: props.domainName }); + if (response.success) { + setAlerts(response.data); + } + } catch (error) { + console.error('Error fetching data:', error); + } + }; + if (props.domainName) { + fetchData(); + } + }, [props]); + + + // 表格包含四列分别是: ip、时间、涉及的文件和命令 + const columns = [ + { + title: 'IP', + dataIndex: 'ip', + key: 'ip', + }, + { + title: '时间', + dataIndex: 'time', + key: 'time', + }, + { + title: '变更的文件', + dataIndex: 'file', + key: 'file', + }, + { + title: '命令', + dataIndex: 'command', + key: 'command', + } + ] + + + return ( + + ); +} + +export default AlertList; \ No newline at end of file diff --git a/sysom_web/src/pages/configtrace/components/ConfsDiffView.jsx b/sysom_web/src/pages/configtrace/components/ConfsDiffView.jsx new file mode 100644 index 00000000..9ef51e5b --- /dev/null +++ b/sysom_web/src/pages/configtrace/components/ConfsDiffView.jsx @@ -0,0 +1,41 @@ +import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; +import { useEffect, forwardRef, useRef } from 'react'; + +let CodeDiffEditor = (props, ref) => { + const divRef = useRef(); + // 初始化编辑器 + useEffect(() => { + const editor = monaco.editor.createDiffEditor(divRef.current, { + readOnly: !props.editable, + renderSideBySide: true, + theme: 'vs-dark', + originalEditable: false, + }); + + const originalModel = monaco.editor.createModel(props.left, 'plaintext'); + const modifiedModel = monaco.editor.createModel(props.right, 'plaintext'); + + editor.setModel({ + original: originalModel, + modified: modifiedModel, + }); + + ref.current = modifiedModel; + + return () => { + originalModel.setValue(''); + modifiedModel.setValue(''); + originalModel.dispose(); + modifiedModel.dispose(); + editor.dispose(); + }; + }, []); + + return ( +
+ ); +}; + +CodeDiffEditor = forwardRef(CodeDiffEditor); + +export default CodeDiffEditor; \ No newline at end of file diff --git a/sysom_web/src/pages/configtrace/components/ConfsList.jsx b/sysom_web/src/pages/configtrace/components/ConfsList.jsx new file mode 100644 index 00000000..14f22a6b --- /dev/null +++ b/sysom_web/src/pages/configtrace/components/ConfsList.jsx @@ -0,0 +1,339 @@ +import ProTable from '@ant-design/pro-table'; +import { PlusOutlined } from '@ant-design/icons'; +import { useRef, useState, useEffect } from 'react' +import { getConfsByDomainAndHost, updateDomainConfs, deleteDomainConfs, syncDomainConfs } from '../service'; +import { Popconfirm, Button, message, Modal } from 'antd'; +import ProCard from '@ant-design/pro-card'; +import { DrawerForm, ProFormList, ProFormSelect, ProFormText, ProFormTextArea, ProFormDependency } from '@ant-design/pro-form'; +import { getHostIP } from '@/pages/host/service'; +import CodeDiffEditor from './ConfsDiffView'; +import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; +/** + * A Table components display Confs list + * @param {*} props + * @returns + */ +const ConfsList = (props) => { + const actionRef = useRef(); + const editRef = useRef(); + const [loading, setLoading] = useState(false); + const [domainConfs, setDomainConfs] = useState([]); + const [domainConfsListModal, setDomainConfsListModal] = useState(false); + const [detailDiffView, setDetailDiffView] = useState(false); + const [compareFiles, setComapareFiles] = useState([]); + const [isEdit, setIsEdit] = useState(false); + + const [hostIP, setHostIP] = useState(props.hostId) || ''; + const [domain, setDomain] = useState(props.domainName) || ''; + + const onSubmit = async (e) => { + // 调用添加配置接口 + let query = { + "domainName": props.domainName, + } + + let params = []; + for (let i = 0; i < e.confs.length; i++) { + let param = {}; + param["filePath"] = e.confs[i]['filePath'] + if (e.confs[i]['host']) { + query['hostName'] = e.confs[i]['host']; + } else { + param["expectContent"] = e.confs[i]['expectContent']; + } + params.push(param); + } + + + let res = await updateDomainConfs(query, params); + if (res.success) { + message.success("添加成功!"); + actionRef.current?.reload(); + } else { + message.error("添加失败"); + } + return true; + + } + + const fetchData = async () => { + try { + const response = await getConfsByDomainAndHost({ domainName: props.domainName, hostName: props.hostId }); + if (response.success) { + setDomainConfs(response.data['conf_files']); + } + } catch (error) { + console.error('Error fetching data:', error); + } + }; + useEffect(() => { + setDomain(props.domainName); + setHostIP(props.hostId); + if (props.domainName) { + fetchData(); + } + }, [props]); + + + const ConfsListColumns = [ + { + title: "配置文件", + dataIndex: "file_path", + valueType: "text", + width: "10%", + }, + { + title: "期望配置详情", + dataIndex: "expect_content", + valueType: "textarea", + width: "30%", + render: (text, record) => ( +
+ {text} +
+ ), + }, + { + title: "实际配置详情", + dataIndex: "real_content", + valueType: "textarea", + width: "30%", + render: (text, record) => ( +
+ {text} +
+ ), + }, + { + title: "修改日志", + dataIndex: "change_log", + valueType: "textarea", + width: "15%", + }, + { + title: "操作", + valueType: "option", + dataIndex: "option", + width: "10%", + render: (dom, record) => [ + + { + // 左右对比视图给出git compare的画面 + setDetailDiffView(true); + setComapareFiles([record.file_path, record.expect_content, record.real_content]); + setIsEdit(false); + }}> 详情 + , + , + + { + // 左右对比视图给出git compare的画面 + setDetailDiffView(true); + setComapareFiles([record.file_path, record.expect_content, record.real_content]); + setIsEdit(true); + }}> 修改 + , + , + + { + const response = await deleteDomainConfs( + { domainName: props.domainName, hostName: props.hostId, filePath: record.file_path } + ); + if (response.success) { + message.success("删除成功"); + } + actionRef.current?.reload(); + }}> + 删除 + + + ], + } + ]; + + + return ( + <> + record.filePath} + toolBarRender={() => [ + { + const response = await syncDomainConfs( + { domainName: props.domainName, hosts: [props.hostId] } + ); + if (response.success) { + message.success("同步成功"); + } else { + message.error("同步失败"); + } + actionRef.current?.reload(); + }}> + + , + ] + } + pagination={{ + showQuickJumper: true, + pageSize: 10, + }} + defaultSize="small" + search={false} + onRefresh={() => { + console.log("refresh...."); + fetchData(); + }} + /> + + < DrawerForm + onVisibleChange={setDomainConfsListModal} + visible={domainConfsListModal} + title="添加配置" + onFinish={onSubmit} + drawerProps={{ + destroyOnClose: true, + }} + > + + { + return ( + + {listDom} + + ); + }} + > + + + + + {({ mode }) => { + if (mode === 'manual') { + return + } else { + return + } + }} + + + + { + setLoading(true); + + // 更新domain配置 + let query = { + "domainName": props.domainName, + }; + let param = [{ + "filePath": compareFiles[0], + "expectContent": editRef?.current?.getValue() + }]; + + let res = await updateDomainConfs(query, param); + setLoading(false); + if (res.success) { + setDetailDiffView(false); + message.success("修改成功!"); + } else { + message.error("修改失败!" + res.msg); + } + }} + onCancel={() => { setDetailDiffView(false); setComapareFiles([]); }} + okButtonProps={{ + disabled: !isEdit, + }} + > + + + + ); +} + +export default ConfsList; \ No newline at end of file diff --git a/sysom_web/src/pages/configtrace/components/DomainList.jsx b/sysom_web/src/pages/configtrace/components/DomainList.jsx new file mode 100644 index 00000000..4ef28cbc --- /dev/null +++ b/sysom_web/src/pages/configtrace/components/DomainList.jsx @@ -0,0 +1,219 @@ +import ProTable from '@ant-design/pro-table'; +import { PlusOutlined } from '@ant-design/icons'; +import { useRef, useState } from 'react' +import { getDomainList, getHostListSrv, deleteDomain, updateDomain, addDomain } from '../service'; +import { message, Popconfirm, Button } from 'antd'; +import DomainModalForm from './DomainModalForm'; + +/** + * A Table components display host list + * @param {*} props + * @returns + */ +let DomainList = (props, ref) => { + const [domainModalFormVisible, setDomainModalFormVisible] = useState(false); + const [domainModalFormMode, setDomainModalFormMode] = useState(0); + const [domainModalFormTitle, setDomainModalFormTitle] = useState('New Domain'); + const domainModalFormRef = useRef(); + const actionRef = useRef(); + const showDomainModal = () => { + setDomainModalFormVisible(true); + } + + const handleAddDomain = async (fields) => { + setDomainModalFormTitle('添加域'); + setDomainModalFormMode(0); + const hide = message.loading('正在添加'); + const token = localStorage.getItem('token'); + try { + let res = await addDomain({ ...fields }, token); + hide(); + if (res.success) { + message.success("添加成功"); + setDomainModalFormVisible(false); + return true; + } else { + message.error(res.data); + setDomainModalFormVisible(false); + return false; + } + } catch (error) { + hide(); + setDomainModalFormVisible(false); + } + actionRef.current.reload(); + }; + + const handleUpdateDomain = async (fields) => { + const hide = message.loading('正在修改'); + const token = localStorage.getItem('token'); + try { + let res = await updateDomain({ ...fields }, token); + hide(); + if (res.success) { + message.success("修改成功"); + setDomainModalFormVisible(false); + return true; + } else { + message.error(res.data); + setDomainModalFormVisible(false); + return false; + } + } catch (error) { + hide(); + setDomainModalFormVisible(false); + } + }; + + + const handleDeleteDomain = async (fields) => { + const hide = message.loading('正在删除'); + const token = localStorage.getItem('token'); + try { + let res = await deleteDomain({ ...fields }, token); + if (res.success) { + message.success("删除成功"); + hide(); + return true; + } else { + message.error(res.data); + hide(); + return false; + } + } catch (error) { + hide(); + } + }; + + const DomainListColumns = [ + { + title: "域名称", + key: "domain_name", + dataIndex: 'domain_name', + render: (dom, entity) => { + return ( + { + props?.onClick?.(entity.domain_name) + }} + > + {dom} + + ); + }, + }, + { + title: '优先级', + key: "priority", + dataIndex: 'priority', + initialValue: 'all', + filters: true, + onFilter: true, + }, + { + title: '开启追踪', + dataIndex: 'enable_trace', + key: 'enable_trace', + valueEnum: { + true: { + text: '是' + }, + false: { + text: '否' + } + }, + filters: true, + onFilter: true, + }, + { + title: '操作', + dataIndex: "operate", + valueType: "operate", + render: (_, record) => [ + + { + // 触发编辑主机信息 + setDomainModalFormTitle('修改域'); + setDomainModalFormMode(1); // 设置 HostModalForm 的模式为 “编辑主机信息模式” + setDomainModalFormVisible(true); // 显示模态框 + domainModalFormRef.current.setFieldsValue({ // 将当前选中的主机信息填充到模态框表单中 + ...record, + }); + }}>编辑 + , + , + + { + await handleDeleteDomain(record); + actionRef.current?.reload(); + }}> + 删除 + + + ], + }, + ]; + + + return ( + <> + record?.domain_name} + pagination={{ + showQuickJumper: true, + pageSize: 10, + }} + toolBarRender={() => [ + ]} + defaultSize="small" + search={false} + {...props} + /> + + { + let success = false; + // 针对不同的模式,执行不同的网络请求 + if (value.mode == 0) { + // 添加domain + success = await handleAddDomain(value); + } else { + // 编辑domain + success = await handleUpdateDomain(value); + } + + if (success) { + setDomainModalFormVisible(false); + if (actionRef.current) { + actionRef.current.reload(); + } + } + }} + /> + + + ); +} + +// DomainList = forwardRef(DomainList); + +export default DomainList \ No newline at end of file diff --git a/sysom_web/src/pages/configtrace/components/DomainModalForm.jsx b/sysom_web/src/pages/configtrace/components/DomainModalForm.jsx new file mode 100644 index 00000000..a3c47fc6 --- /dev/null +++ b/sysom_web/src/pages/configtrace/components/DomainModalForm.jsx @@ -0,0 +1,163 @@ +import { ModalForm, ProFormText, ProFormTextArea, ProFormSelect, ProFormRadio } from '@ant-design/pro-form'; +import { useState } from 'react'; +import { useImperativeHandle, useRef, forwardRef } from 'react'; +import * as PropTypes from 'prop-types'; +import { useIntl, FormattedMessage } from 'umi'; +const MODE_ADD_DOMAIN = 0 +const MODE_EDIT_DOMAIN = 1 + +/** + * 主机信息模态表单 + * 1. 功能一:用于实现主机添加 + * 2. 功能二:用于实现主机信息编辑 + * @param {*} props + * props.mode => 模式: 0 => 添加主机 + * 1 => 修改主机信息 + * props.titl => 模态框顶部的标题 + * props.visible => 模态框是否可见 + * props.modalWidth => 模态框的宽度,默认为 440px + * props.onVisibleChange => 模态框可见性发生变动时触发 + * props.onFinish => 表单提交时触发 + */ +let DomainModalForm = (props, ref) => { + const { + mode, + title, + visible, + modalWidth, + onVisibleChange, + onFinish + } = props; + + const modalFormRef = useRef(); + const intl = useIntl(); + const [id, setId] = useState(-1); + + // https://zh-hans.reactjs.org/docs/hooks-reference.html#useimperativehandle + // 设置一些暴露给外部调用的函数,外部可以通过ref的方式调用 + useImperativeHandle(ref, () => ({ + // 填充表单的值 + setFieldsValue: (values) => { + if (!modalFormRef) { + modalFormRef = ref; + } + modalFormRef.current?.setFieldsValue(values); + }, + // getFieldValue: modalFormRef.getFieldValue, // 获取某个字段的值 + // getFieldsValue: modalFormRef.getFieldsValue, // 获取表单的当前值 + // getFieldsFormatValue: modalFormRef.getFieldsFormatValue, // 获取格式化之后所有数据 + // getFieldFormatValue: modalFormRef.getFieldFormatValue, // 获取格式化之后的单个数据 + // validateFieldsReturnFormatValue: modalFormRef.validateFieldsReturnFormatValue, // 校验字段后返回格式化之后的所有数据 + })); + + return ( + { + onFinish({ + mode: mode, + id: id, + ...value + }) + }} + > + + + ), + }, + ]} + width="md" + name="domain_name" + disabled={mode == MODE_EDIT_DOMAIN} + /> + + ), + }, + ]} + width="md" + name="priority" + /> + + ), + }, + ]} + options={[ + { + label: '是', + value: true, + }, + { + label: '否', + value: false, + }, + ]} + /> + + ) +} + +DomainModalForm = forwardRef(DomainModalForm); + +DomainModalForm.displayName = "DomainModalForm"; + +DomainModalForm.propTypes = { + mode: PropTypes.number, + title: PropTypes.string, + visible: PropTypes.bool, + modalWidth: PropTypes.string, + onVisibleChange: PropTypes.func, + onFinish: PropTypes.func +} + +// Props 参数默认值 +DomainModalForm.defaultProps = { + mode: 0, + title: "New Domain", + visible: false, + modalWidth: "440px", + onVisibleChange: () => { }, + onFinish: () => { }, +} + +export default DomainModalForm; \ No newline at end of file diff --git a/sysom_web/src/pages/configtrace/components/HostList.jsx b/sysom_web/src/pages/configtrace/components/HostList.jsx new file mode 100644 index 00000000..efc1aaa9 --- /dev/null +++ b/sysom_web/src/pages/configtrace/components/HostList.jsx @@ -0,0 +1,164 @@ +import ProTable from '@ant-design/pro-table'; +import { PlusOutlined } from '@ant-design/icons'; +import { useIntl, FormattedMessage, history } from 'umi'; +import { useRef, useState, useEffect } from 'react' +import { getHostListSrv, addDomainHosts, deleteDomainHost } from '../service'; +import { message, Popconfirm, Button, Transfer } from 'antd'; +import { ModalForm } from '@ant-design/pro-form'; +import { getHost } from '../../host/service'; +import { isToken } from 'typescript'; +/** + * A Table components display host list + * @param {*} props + * @returns + */ +const HostList = (props) => { + const [domain, setDomain] = useState(props?.domainName) || ''; + const actionRef = useRef(); + const [showDomainHostListModal, setShowDomainListModal] = useState(false); + + const [allHosts, setAllHosts] = useState([]); + const [toAddHosts, setToAddHosts] = useState([]); + const [domainHosts, setDomainHosts] = useState([]); + + const showDomainHostList = () => { + setShowDomainListModal(true); + getHost().then((res) => { + setAllHosts(res.data.map(item => ({ ...item, key: item.ip }))); + }); + } + + const onSubmit = async (params) => { + const hide = message.loading('正在添加'); + const token = localStorage.getItem('token'); + try { + await addDomainHosts({ domain_name: props.domainName, hosts: toAddHosts }, token); + hide(); + setShowDomainListModal(false); + return true; + } catch (error) { + hide(); + setShowDomainListModal(false); + } + }; + + const HostListColumns = [ + { + title: "主机名", + dataIndex: "host_id", + valueType: "textarea", + }, + { + title: "IP", + dataIndex: "ip", + valueType: "textarea", + }, + { + title: "IPv6", + dataIndex: "ipv6", + valueType: "textarea", + }, + { + title: "操作", + valueType: "option", + dataIndex: "option", + render: (dom, record) => [ + + { + history.push('/configtrace/confs/' + record.host_id); + }}> 详情 + , + , + + { + const token = localStorage.getItem('token'); + await deleteDomainHost({ domain_name: props.domainName, host: record.host_id }, token); + actionRef.current?.reload(); + }}> + 删除 + + + ], + } + ]; + + useEffect(() => { + const fetchData = async () => { + try { + const response = await getHostListSrv(props.domainName); + setDomainHosts(response.data); + } catch (error) { + console.error('Error fetching data:', error); + } + }; + + if (props.domainName) { + setDomain(props.domainName); + fetchData(); + } + }, [props]); + + return ( + <> + record.host_id} + toolBarRender={() => [ + ]} + pagination={{ + showQuickJumper: true, + pageSize: 10, + }} + defaultSize="small" + search={false} + {...props} + /> + + {/* 实现一个左右型的列表框,支持从左侧选择主机列表,放到右侧列表中,点击确认后,可以自动添加到主机列表中 */} + + item.ip} + /> + + + + ); +} + +export default HostList \ No newline at end of file diff --git a/sysom_web/src/pages/configtrace/confs.jsx b/sysom_web/src/pages/configtrace/confs.jsx new file mode 100644 index 00000000..75af637f --- /dev/null +++ b/sysom_web/src/pages/configtrace/confs.jsx @@ -0,0 +1,57 @@ +import { useRequest, useParams } from 'umi'; +import { useState, useRef } from 'react'; +import ProCard from '@ant-design/pro-card'; +import { Statistic } from 'antd'; +import { getDomainList } from './service'; +import DomainList from './components/DomainList'; +import ConfsList from './components/ConfsList'; + +const Confs = () => { + + const { data, error, loading } = useRequest(getDomainList) + const { domain } = data?.[0]?.domain_name || {} + const [domainName, setDomainName] = useState(domain || '') + const [collapsed, setCollapsed] = useState(false) + const param = useParams(); + const hostId = param.host || ''; + + const onCollapsed = () => { + setCollapsed(!collapsed); + } + + return ( + <> + {/* 左右分布,左侧给出所有的domain列表,右侧给出当前domain下的所有配置列表,配置列表中包括期望配置、真实配置*/} + + + + + + + + + { + setDomainName(domainName) + }} + onLoad={dataSource => { + if (dataSource.length > 0 && !!dataSource[0].domain_name) { + setDomainName(dataSource[0].domain_name) + } + } + } /> + + + {collapsed ? + >>





+ : <<





+ } +
+ +
+ + ); +}; + +export default Confs; diff --git a/sysom_web/src/pages/configtrace/domain_hosts.jsx b/sysom_web/src/pages/configtrace/domain_hosts.jsx new file mode 100644 index 00000000..1dc4dac2 --- /dev/null +++ b/sysom_web/src/pages/configtrace/domain_hosts.jsx @@ -0,0 +1,85 @@ +import React, { useState, useEffect } from 'react'; +import { GridContent, PageContainer } from '@ant-design/pro-layout'; +import ProTable from "@ant-design/pro-table"; +import { Button, Layout, Menu } from 'antd'; +import { useIntl, FormattedMessage, Link } from 'umi'; + +const { Sider, Content } = Layout; + +const DomainHostList = () => { + const [menuData, setMenuData] = useState([]); + const [selectedName, setSelectedName] = useState(''); + + useEffect(() => { + const fetchData = async () => { + try { + const response = await fetch('/api/v1/configtrace/domains'); + const data = await response.json(); + const sortedData = data.sort((a, b) => a.priority - b.priority); + setMenuData(sortedData); + } catch (error) { + console.error('Error fetching data:', error); + } + }; + + fetchData(); + }, []); + + const handleMenuClick = (e) => { + setSelectedName(e.domain_name); + }; + + const filteredData = menuData.find(item => item.domain_name === selectedName); + + const columns = [ + { + title: , + dataIndex: "HostName", + valueType: "textarea" + }, + { + title: , + dataIndex: "IPv4", + valueType: "textarea", + }, + { + title: , + dataIndex: "IPv6", + valueType: "textarea", + }, + { + title: , + dataIndex: "option", + valueType: "option", + render: (_, record) => [ + + {} + + ], + } + ]; + return ( + + + + { + menuData.map(item => ( + {item.domain_name} + Delete + + )) + } + + + + + + ); +}; + +export default DomainHostList; diff --git a/sysom_web/src/pages/configtrace/domains.jsx b/sysom_web/src/pages/configtrace/domains.jsx new file mode 100644 index 00000000..1991a23d --- /dev/null +++ b/sysom_web/src/pages/configtrace/domains.jsx @@ -0,0 +1,60 @@ +import { Statistic } from 'antd'; +import { useIntl, useRequest } from 'umi'; +import { useState, useRef } from 'react' +import { addDomain, updateDomain, deleteDomain, getDomainList } from './service'; +import ProCard from '@ant-design/pro-card'; +import DomainList from './components/DomainList'; +import HostList from './components/HostList'; + +const Dashboard = () => { + const intl = useIntl(); + const actionRef = useRef(); + const { data, error, loading } = useRequest(getDomainList) + // 获取data中的第一个元素的值 + const { domain } = data?.[0]?.domain_name || {} + const [domainName, setDomainName] = useState(domain || '') + const [collapsed, setCollapsed] = useState(false) + + const onCollapsed = () => { + setCollapsed(!collapsed); + } + + return ( + <> + + + + + + + {/* + + */} + + + { + setDomainName(domainName) + }} + onLoad={dataSource => { + if (dataSource.length > 0 && !!dataSource[0].domain_name) { + setDomainName(dataSource[0].domain_name) + } + } + } /> + + + {collapsed ? + >>





+ : <<





+ } +
+ +
+ + ); +}; + +export default Dashboard; diff --git a/sysom_web/src/pages/configtrace/service.js b/sysom_web/src/pages/configtrace/service.js new file mode 100644 index 00000000..fa274927 --- /dev/null +++ b/sysom_web/src/pages/configtrace/service.js @@ -0,0 +1,311 @@ +// @ts-ignore + +/* eslint-disable */ +import { request } from 'umi'; +import _ from "lodash"; +import { async } from '@antv/x6/lib/registry/marker/async'; +import { message } from 'antd'; +import { OmitProps } from 'antd/lib/transfer/ListBody'; + +const ACCOUNT_URL = '/api/v1/configtrace/'; + +export async function getDomainList(params, options) { + try { + const msg = await request(ACCOUNT_URL + 'domains', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Authorization': localStorage.getItem('token') + }, + // params: { ...params }, + ...(options || {}), + }); + return { + data: msg, + success: true, + total: msg?.length, + }; + } catch (e) { + console.error('Fetch domain list error. err:', e); + return { + success: false, + }; + } +} + +export async function getHostListSrv(domainName, params, options) { + try { + const msg = await request(ACCOUNT_URL + 'domains/' + domainName + '/hosts', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Authorization': localStorage.getItem('token') + }, + ...(options || {}), + }); + return { + data: msg, + success: true, + total: msg?.length, + }; + } catch (e) { + console.error('Fetch host list error. err:', e); + return { + data: [], + success: true, + total: 0, + }; + } +} + +export async function addDomain(params, token, options) { + try { + const msg = await request(ACCOUNT_URL + 'domains/' + params.domain_name, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': token, + }, + data: { + "priority": parseInt(params.priority), + "enable_trace": Boolean(params.enable_trace), + }, + ...(options || {}), + }); + return { + data: msg, + success: true, + total: msg?.length, + }; + } catch (e) { + return { + success: false, + data: msg, + }; + } +} + +export async function updateDomain(params, token, options) { + try { + const msg = await request(ACCOUNT_URL + 'domains/' + params.domain_name, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': token, + }, + data: { + "priority": parseInt(params.priority), + "enable_trace": Boolean(params.enable_trace), + }, + ...(options || {}), + }); + return { + data: msg, + success: true, + total: msg?.length, + }; + } catch (e) { + return { + success: false, + data: msg, + }; + } +} + +export async function deleteDomain(params, token, options) { + try { + const msg = await request(ACCOUNT_URL + 'domains/' + params.domain_name, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'Authorization': token, + }, + ...(options || {}), + }); + return { + data: msg, + success: true, + total: msg?.length, + }; + } catch (e) { + return { + success: false, + data: msg, + }; + } +} + +export async function addDomainHosts(params, token, options) { + for (let i = 0; i < params.hosts.length; i++) { + try { + const msg = await request(ACCOUNT_URL + 'domains/' + params.domain_name + "/hosts/" + params.hosts[i], { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': token, + }, + ...(options || {}), + }); + message.success('添加成功'); + } catch (error) { + message.error("添加失败"); + } + } + return { + success: true, + data: [], + total: 0, + } +} + +export async function deleteDomainHost(params, token, options) { + try { + const msg = await request(ACCOUNT_URL + 'domains/' + params.domain_name + "/hosts/" + params.host, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'Authorization': token, + }, + ...(options || {}), + }); + message.success('删除成功'); + } catch (error) { + message.error("删除失败"); + } + return { + success: true, + total: 0, + data: [], + } +} + +export async function getConfsByDomainAndHost(params, options) { + console.log(params); + try { + const msg = await request(ACCOUNT_URL + 'confs', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Authorization': localStorage.getItem('token') + }, + params: { + domainName: params.domainName, + hostName: params.hostName, + }, + ...(options || {}), + }); + return { + data: msg, + success: true, + total: msg?.length, + }; + } catch (e) { + console.error('Fetch confs list error. err:', e); + return { + success: true, + data: [], + total: 0, + }; + } +} + +export async function updateDomainConfs(params, data) { + try { + const msg = await request(ACCOUNT_URL + 'confs', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': localStorage.getItem('token'), + }, + params: params, + data: { + confs: data, + }, + }); + return { + data: msg, + success: true, + total: msg?.length, + }; + } catch (e) { + return { + success: false, + data: e, + }; + } +} + +export async function deleteDomainConfs(params, options) { + try { + const msg = await request(ACCOUNT_URL + 'confs', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'Authorization': localStorage.getItem('token'), + }, + params: params, + }); + return { + data: msg, + success: true, + total: msg?.length, + }; + } catch (e) { + return { + success: false, + data: msg, + }; + } +} + +export async function syncDomainConfs(data, options) { + try { + const msg = await request(ACCOUNT_URL + 'confs', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': localStorage.getItem('token'), + }, + params: { + action: "sync", + }, + data: data, + }); + return { + data: msg, + success: true, + total: msg?.length, + }; + } catch (e) { + return { + success: false, + data: msg, + }; + } +} + +export async function getConfsAlerts(params, options) { + try { + const msg = await request(ACCOUNT_URL + 'alerts', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Authorization': localStorage.getItem('token'), + }, + params: { + domainName: params.domainName, + action: "alerts", + }, + }); + return { + data: msg, + success: true, + total: msg?.length, + }; + } catch (e) { + return { + success: false, + data: e, + }; + } +} \ No newline at end of file -- Gitee From 521ca32830ba17389abd989400d193fe53743287 Mon Sep 17 00:00:00 2001 From: chenc136 Date: Tue, 8 Oct 2024 20:53:27 +0800 Subject: [PATCH 2/2] fix: add nginx route --- deps/2_nginx/sysom.conf | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/deps/2_nginx/sysom.conf b/deps/2_nginx/sysom.conf index 8f2d30eb..0af3f01c 100644 --- a/deps/2_nginx/sysom.conf +++ b/deps/2_nginx/sysom.conf @@ -180,6 +180,13 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } + location /api/v1/configtrace/ { + proxy_pass http://127.0.0.1:7031; + proxy_read_timeout 180; + proxy_redirect off; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + location /api/ { proxy_pass http://127.0.0.1:7001; proxy_read_timeout 180s; -- Gitee