From dd8721e38295989cbc0e38da20b4db5ea7f40c41 Mon Sep 17 00:00:00 2001 From: zhaojing1987 Date: Tue, 29 Aug 2023 15:53:54 +0800 Subject: [PATCH] add appmanage_new --- appmanage_new/.env | 0 appmanage_new/README.md | 0 appmanage_new/app/api/v1/__init__.py | 0 appmanage_new/app/api/v1/api.py | 16 + appmanage_new/app/core/api_key.py | 0 appmanage_new/app/core/code.py | 34 ++ appmanage_new/app/core/config.py | 4 + appmanage_new/app/core/exception.py | 10 + appmanage_new/app/core/log.py | 40 ++ appmanage_new/app/core/prerequisite.py | 327 +++++++++++ appmanage_new/app/core/rq.py | 6 + appmanage_new/app/core/settings.conf | 12 + .../app/external/nginx_proxy_manager.py | 84 +++ appmanage_new/app/schemas/applist.py | 20 + appmanage_new/app/services/app.py | 519 ++++++++++++++++++ appmanage_new/app/services/domain.py | 444 +++++++++++++++ appmanage_new/app/services/update.py | 177 ++++++ appmanage_new/app/utils/getIP.py | 41 ++ appmanage_new/app/utils/helper.py | 6 + appmanage_new/app/utils/runshell.py | 46 ++ appmanage_new/app/utils/settings_file.py | 68 +++ appmanage_new/docs/MAINTAINERS.md | 42 ++ appmanage_new/docs/architecture.md | 24 + appmanage_new/docs/developer.md | 24 + appmanage_new/docs/image/archi.png | Bin 0 -> 74546 bytes appmanage_new/docs/notes/PRD.md | 151 +++++ appmanage_new/docs/notes/research.md | 57 ++ appmanage_new/docs/notes/软件工厂.md | 37 ++ appmanage_new/docs/plugin-developer.md | 8 + appmanage_new/docs/recruit.md | 29 + appmanage_new/docs/user.md | 18 + appmanage_new/main.py | 7 + appmanage_new/requirements.txt | 10 + appmanage_new/tests/README.md | 0 34 files changed, 2261 insertions(+) create mode 100644 appmanage_new/.env create mode 100644 appmanage_new/README.md create mode 100644 appmanage_new/app/api/v1/__init__.py create mode 100644 appmanage_new/app/api/v1/api.py create mode 100644 appmanage_new/app/core/api_key.py create mode 100644 appmanage_new/app/core/code.py create mode 100644 appmanage_new/app/core/config.py create mode 100644 appmanage_new/app/core/exception.py create mode 100644 appmanage_new/app/core/log.py create mode 100644 appmanage_new/app/core/prerequisite.py create mode 100644 appmanage_new/app/core/rq.py create mode 100644 appmanage_new/app/core/settings.conf create mode 100644 appmanage_new/app/external/nginx_proxy_manager.py create mode 100644 appmanage_new/app/schemas/applist.py create mode 100644 appmanage_new/app/services/app.py create mode 100644 appmanage_new/app/services/domain.py create mode 100644 appmanage_new/app/services/update.py create mode 100644 appmanage_new/app/utils/getIP.py create mode 100644 appmanage_new/app/utils/helper.py create mode 100644 appmanage_new/app/utils/runshell.py create mode 100644 appmanage_new/app/utils/settings_file.py create mode 100644 appmanage_new/docs/MAINTAINERS.md create mode 100644 appmanage_new/docs/architecture.md create mode 100644 appmanage_new/docs/developer.md create mode 100644 appmanage_new/docs/image/archi.png create mode 100644 appmanage_new/docs/notes/PRD.md create mode 100644 appmanage_new/docs/notes/research.md create mode 100644 appmanage_new/docs/notes/软件工厂.md create mode 100644 appmanage_new/docs/plugin-developer.md create mode 100644 appmanage_new/docs/recruit.md create mode 100644 appmanage_new/docs/user.md create mode 100644 appmanage_new/main.py create mode 100644 appmanage_new/requirements.txt create mode 100644 appmanage_new/tests/README.md diff --git a/appmanage_new/.env b/appmanage_new/.env new file mode 100644 index 00000000..e69de29b diff --git a/appmanage_new/README.md b/appmanage_new/README.md new file mode 100644 index 00000000..e69de29b diff --git a/appmanage_new/app/api/v1/__init__.py b/appmanage_new/app/api/v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/appmanage_new/app/api/v1/api.py b/appmanage_new/app/api/v1/api.py new file mode 100644 index 00000000..87c04060 --- /dev/null +++ b/appmanage_new/app/api/v1/api.py @@ -0,0 +1,16 @@ +from fastapi import APIRouter +from typing import List +from schemas.user import UserCreate +from models.user import User as UserModel +from services.user_service import UserService +from db.database import SessionLocal + +router = APIRouter() +user_service = UserService() + +@router.get("/users/{user_type}", response_model=List[UserModel]) +async def get_users(user_type: str): + users = user_service.get_users_by_type(user_type) + if not users: + raise HTTPException(status_code=404, detail="Users not found") + return users diff --git a/appmanage_new/app/core/api_key.py b/appmanage_new/app/core/api_key.py new file mode 100644 index 00000000..e69de29b diff --git a/appmanage_new/app/core/code.py b/appmanage_new/app/core/code.py new file mode 100644 index 00000000..ad1bf7ac --- /dev/null +++ b/appmanage_new/app/core/code.py @@ -0,0 +1,34 @@ +# 所有常量统一定义区 + +# 错误代码定义 +ERROR_CLIENT_PARAM_BLANK = "Client.Parameter.Blank.Error" +ERROR_CLIENT_PARAM_Format = "Client.Parameter.Format.Error" +ERROR_CLIENT_PARAM_NOTEXIST = "Client.Parameter.Value.NotExist.Error" +ERROR_CLIENT_PARAM_REPEAT = "Client.Parameter.Value.Repeat.Error" +ERROR_CONFIG_NGINX = "Nginx.Configure.Error" +ERROR_SERVER_COMMAND = "Server.Container.Error" +ERROR_SERVER_SYSTEM = "Server.SystemError" +ERROR_SERVER_RESOURCE = "Server.ResourceError" +ERROR_SERVER_CONFIG_MISSING = "Server.Config.NotFound" + +# 错误信息定义 +ERRORMESSAGE_CLIENT_PARAM_BLANK = "Client.Parameter.Blank.Error" +ERRORMESSAGE_CLIENT_PARAM_Format = "Client.Parameter.Format.Error" +ERRORMESSAGE_CLIENT_PARAM_NOTEXIST = "Client.Parameter.Value.NotExist.Error" +ERRORMESSAGE_CLIENT_PARAM_REPEAT = "Client.Parameter.Value.Repeat.Error" +ERRORMESSAGE_SERVER_COMMAND = "Server.Container.Error" +ERRORMESSAGE_SERVER_SYSTEM = "Server.SystemError" +ERRORMESSAGE_SERVER_RESOURCE = "Server.ResourceError" +ERRORMESSAGE_SERVER_VERSION_NOTSUPPORT = "Server.Version.NotSupport" +ERRORMESSAGE_SERVER_VERSION_NEEDUPGRADE = "Server.Version.NeedUpgradeCore" + +# 应用启动中 installing +APP_STATUS_INSTALLING = "installing" +# 应用正在运行 running +APP_STATUS_RUNNING = "running" +# 应用已经停止 exited +APP_STATUS_EXITED = "exited" +# 应用不断重启 restarting +APP_STATUS_RESTARTING = "restarting" +# 应用错误 failed +APP_STATUS_FAILED = "failed" diff --git a/appmanage_new/app/core/config.py b/appmanage_new/app/core/config.py new file mode 100644 index 00000000..f4df0e62 --- /dev/null +++ b/appmanage_new/app/core/config.py @@ -0,0 +1,4 @@ +NGINX_URL = "http://websoft9-nginxproxymanager:81" +# ARTIFACT_URL="https://artifact.azureedge.net/release/websoft9" +ARTIFACT_URL = "https://w9artifact.blob.core.windows.net/release/websoft9" +ARTIFACT_URL_DEV = "https://w9artifact.blob.core.windows.net/dev/websoft9" \ No newline at end of file diff --git a/appmanage_new/app/core/exception.py b/appmanage_new/app/core/exception.py new file mode 100644 index 00000000..ab4ebef1 --- /dev/null +++ b/appmanage_new/app/core/exception.py @@ -0,0 +1,10 @@ +class CommandException(Exception): + def __init__(self, code, message, detail): + self.code = code + self.message = message + self.detail = detail + + +class MissingConfigException(CommandException): + + pass \ No newline at end of file diff --git a/appmanage_new/app/core/log.py b/appmanage_new/app/core/log.py new file mode 100644 index 00000000..099d14ec --- /dev/null +++ b/appmanage_new/app/core/log.py @@ -0,0 +1,40 @@ +import logging +import os +from logging import handlers + +class MyLogging(): + # init logging + def __init__(self): + # the file of log + logPath = 'logs/' + if not os.path.exists(logPath): + os.makedirs(logPath) + logName = 'app_manage.log' + logFile = logPath + logName + formatter = logging.Formatter('%(asctime)s %(levelname)s: %(message)s') + # handler + time_rotating_file_handler = handlers.TimedRotatingFileHandler(filename=logFile, when="MIDNIGHT", interval=1, encoding='utf-8') + time_rotating_file_handler.setLevel(logging.DEBUG) + time_rotating_file_handler.setFormatter(formatter) + # config + logging.basicConfig( + level= logging.DEBUG, + handlers= [time_rotating_file_handler], + datefmt='%Y-%m-%d %H:%M:%S', + format='%(asctime)s %(levelname)s: %(message)s' + ) + + def info_logger(self, content): + logging.info(content) + + def error_logger(self, content): + logging.error(content) + + def debug_logger(self, content): + logging.debug(content) + + def warning_logger(self, content): + logging.warning(content) + + +myLogger = MyLogging() \ No newline at end of file diff --git a/appmanage_new/app/core/prerequisite.py b/appmanage_new/app/core/prerequisite.py new file mode 100644 index 00000000..5510d14e --- /dev/null +++ b/appmanage_new/app/core/prerequisite.py @@ -0,0 +1,327 @@ +import json, psutil +import re + +from api.utils.log import myLogger +from api.utils import shell_execute, const +from api.exception.command_exception import CommandException +from api.service import manage + + +# 已经是running的app怎么知道它已经能够访问,如页面能进入,如mysql能被客户端连接 +def if_app_access(app_name): + return True + + +def if_app_exits(app_name): + cmd = "docker compose ls -a" + output = shell_execute.execute_command_output_all(cmd) + if int(output["code"]) == 0: + pattern = app_name + '$' + info_list = output['result'].split() + is_exist = False + for info in info_list: + if re.match(pattern, info) != None: + is_exist = True + break + return is_exist + else: + return True + + +def if_app_running(app_name): + cmd = "docker compose ls -a" + output = shell_execute.execute_command_output_all(cmd) + if int(output["code"]) == 0: + app_list = output['result'].split("\n") + pattern = app_name + '\s*' + if_running = False + for app in app_list: + if re.match(pattern, app) != None and re.match('running', app) != None: + if_running = True + break + return if_running + else: + return False + + +def check_appid_exist(app_id): + myLogger.info_logger("Checking check_appid_exist ...") + appList = manage.get_my_app() + find = False + for app in appList: + if app_id == app.app_id: + find = True + break + myLogger.info_logger("Check complete.") + return find + + +def check_appid_include_rq(app_id): + message = "" + code = None + if app_id == None or app_id == "undefine": + code = const.ERROR_CLIENT_PARAM_BLANK + message = "AppID is null" + elif re.match('^[a-z0-9]+_[a-z0-9]+$', app_id) == None: + code = const.ERROR_CLIENT_PARAM_Format + message = "App_id format error" + elif not check_appid_exist(app_id): + code = const.ERROR_CLIENT_PARAM_NOTEXIST + message = "AppID is not exist" + return code, message + + +def check_app_id(app_id): + message = "" + code = None + if app_id == None: + code = const.ERROR_CLIENT_PARAM_BLANK + message = "AppID is null" + elif re.match('^[a-z0-9]+_[a-z0-9]+$', app_id) == None: + code = const.ERROR_CLIENT_PARAM_Format + message = "APP name can only be composed of numbers and lowercase letters" + myLogger.info_logger(code) + return code, message + + +def check_vm_resource(app_name): + myLogger.info_logger("Checking virtual memory resource ...") + var_path = "/data/library/apps/" + app_name + "/variables.json" + requirements_var = read_var(var_path, 'requirements') + need_cpu_count = int(requirements_var['cpu']) + cpu_count = int(shell_execute.execute_command_output_all("cat /proc/cpuinfo | grep \'core id\'| wc -l")["result"]) + if cpu_count < need_cpu_count: + myLogger.info_logger("Check complete: The number of CPU cores is insufficient!") + return False + need_mem_total = int(requirements_var['memory']) + mem_free = float(psutil.virtual_memory().available) / 1024 / 1024 / 1024 + if mem_free < need_mem_total * 1.2: + myLogger.info_logger("Check complete: The total amount of memory is insufficient!") + return False + need_disk = int(requirements_var['disk']) + disk_free = float(psutil.disk_usage('/').free) / 1024 / 1024 / 1024 + if round(disk_free) < need_disk + 2: + myLogger.info_logger("Check complete: There are not enough disks left!") + return False + myLogger.info_logger("Check complete.") + return True + + +def check_app_websoft9(app_name): + # websoft9's support applist + myLogger.info_logger("Checking dir...") + path = "/data/library/apps/" + app_name + is_exists = check_directory(path) + return is_exists + + +def check_directory(path): + try: + shell_execute.execute_command_output_all("ls " + path) + return True + except CommandException as ce: + return False + + +def check_app_compose(app_name, customer_name): + myLogger.info_logger("Set port and random password ...") + library_path = "/data/library/apps/" + app_name + install_path = "/data/apps/" + customer_name + port_dic = read_env(library_path + '/.env', "APP_.*_PORT=") + # 1.判断/data/apps/app_name/.env中的port是否占用,没有被占用,方法结束(get_start_port方法) + cmd1 = "docker container inspect $(docker ps -aq) | grep HostPort | awk \'{print $2}\' | sort -u" + cmd2 = "netstat -tunlp | grep \"LISTEN\" | awk '{print $4}' | awk -F \":\" '{print $NF}' | sort -u" + cmd3 = "grep -r \"APP_.*_PORT=\" /data/apps/*/.env | awk -F \"=\" '{print $2}' | sort -u" + s1 = shell_execute.execute_command_output_all(cmd1)['result'].replace('\"', '') + s2 = shell_execute.execute_command_output_all(cmd2)['result'] + try: + s3 = '' + s3 = shell_execute.execute_command_output_all(cmd3)['result'] + except: + pass + s = s1 + '\n' + s2 + '\n' + s3 + + shell_execute.execute_command_output_all("cp -r " + library_path + " " + install_path) + env_path = install_path + "/.env" + get_map(env_path) + for port_name in port_dic: + port_value = get_start_port(s, port_dic[port_name]) + modify_env(install_path + '/.env', port_name, port_value) + + # set random password + power_password = shell_execute.execute_command_output_all("cat /data/apps/" + customer_name + "/.env")["result"] + if "POWER_PASSWORD" in power_password: + try: + shell_execute.execute_command_output_all("docker rm -f pwgen") + except Exception: + pass + new_password = shell_execute.execute_command_output_all("docker run --name pwgen backplane/pwgen 15")[ + "result"].rstrip('\n') + "!" + modify_env(install_path + '/.env', 'POWER_PASSWORD', new_password) + shell_execute.execute_command_output_all("docker rm -f pwgen") + env_path = install_path + "/.env" + get_map(env_path) + myLogger.info_logger("Port check complete") + return + + +def check_app_url(customer_app_name): + myLogger.info_logger("Checking app url...") + # 如果app的.env文件中含有HTTP_URL项目,需要如此设置 HTTP_URL=ip:port + env_path = "/data/apps/" + customer_app_name + "/.env" + env_map = get_map(env_path) + if env_map.get("APP_URL_REPLACE") == "true": + myLogger.info_logger(customer_app_name + "need to change app url...") + app_url = list(read_env(env_path, "APP_URL=").values())[0] + ip = "localhost" + url = "" + try: + ip_result = shell_execute.execute_command_output_all("cat /data/apps/w9services/w9appmanage/public_ip") + ip = ip_result["result"].rstrip('\n') + except Exception: + ip = "127.0.0.1" + http_port = list(read_env(env_path, "APP_HTTP_PORT").values())[0] + + if ":" in app_url: + url = ip + ":" + http_port + else: + url = ip + cmd = "sed -i 's/APP_URL=.*/APP_URL=" + url + "/g' /data/apps/" + customer_app_name + "/.env" + shell_execute.execute_command_output_all(cmd) + + myLogger.info_logger("App url check complete") + return + + +def get_map(path): + myLogger.info_logger("Read env_dic" + path) + output = shell_execute.execute_command_output_all("cat " + path) + code = output["code"] + env_dic = {} + if int(code) == 0: + ret = output["result"] + myLogger.info_logger(ret) + env_list = ret.split("\n") + for env in env_list: + if "=" in env: + env_dic[env.split("=")[0]] = env.split("=")[1] + myLogger.info_logger(env_dic) + return env_dic + + +def read_env(path, key): + myLogger.info_logger("Read " + path) + output = shell_execute.execute_command_output_all("cat " + path) + code = output["code"] + env_dic = {} + if int(code) == 0: + ret = output["result"] + env_list = ret.split("\n") + for env in env_list: + if re.match(key, env) != None: + env_dic[env.split("=")[0]] = env.split("=")[1] + myLogger.info_logger("Read " + path + ": " + str(env_dic)) + return env_dic + + +def modify_env(path, env_name, value): + myLogger.info_logger("Modify " + path + "...") + output = shell_execute.execute_command_output_all("sed -n \'/^" + env_name + "/=\' " + path) + if int(output["code"]) == 0 and output["result"] != "": + line_num = output["result"].split("\n")[0] + s = env_name + "=" + value + output = shell_execute.execute_command_output_all("sed -i \'" + line_num + "c " + s + "\' " + path) + if int(output["code"]) == 0: + myLogger.info_logger("Modify " + path + ": Change " + env_name + " to " + value) + + +def read_var(var_path, var_name): + value = "" + myLogger.info_logger("Read " + var_path) + output = shell_execute.execute_command_output_all("cat " + var_path) + if int(output["code"]) == 0: + var = json.loads(output["result"]) + try: + value = var[var_name] + except KeyError: + myLogger.warning_logger("Read " + var_path + ": No key " + var_name) + else: + myLogger.warning_logger(var_path + " not found") + return value + + +def get_start_port(s, port): + use_port = port + while True: + if s.find(use_port) == -1: + break + else: + use_port = str(int(use_port) + 1) + + return use_port + +def check_app(app_name, customer_name, app_version): + message = "" + code = None + app_id = app_name + "_" + customer_name + if app_name == None: + code = const.ERROR_CLIENT_PARAM_BLANK + message = "app_name is null" + elif customer_name == None: + code = const.ERROR_CLIENT_PARAM_BLANK + message = "customer_name is null" + elif len(customer_name) < 2: + code = const.ERROR_CLIENT_PARAM_BLANK + message = "customer_name must be longer than 2 chars" + elif app_version == None: + code = const.ERROR_CLIENT_PARAM_BLANK + message = "app_version is null" + elif app_version == "undefined" or app_version == "": + code = const.ERROR_CLIENT_PARAM_BLANK + message = "app_version is null" + elif not docker.check_app_websoft9(app_name): + code = const.ERROR_CLIENT_PARAM_NOTEXIST + message = "It is not support to install " + app_name + elif re.match('^[a-z0-9]+$', customer_name) == None: + code = const.ERROR_CLIENT_PARAM_Format + message = "APP name can only be composed of numbers and lowercase letters" + elif docker.check_directory("/data/apps/" + customer_name): + code = const.ERROR_CLIENT_PARAM_REPEAT + message = "Repeat installation: " + customer_name + elif not docker.check_vm_resource(app_name): + code = const.ERROR_SERVER_RESOURCE + message = "Insufficient system resources (cpu, memory, disk space)" + elif check_app_docker(app_id): + code = const.ERROR_CLIENT_PARAM_REPEAT + message = "Repeat installation: " + customer_name + elif check_app_rq(app_id): + code = const.ERROR_CLIENT_PARAM_REPEAT + message = "Repeat installation: " + customer_name + + return code, message + + + def app_exits_in_docker(app_id): + customer_name = app_id.split('_')[1] + app_name = app_id.split('_')[0] + flag = False + info = "" + cmd = "docker compose ls -a | grep \'/" + customer_name + "/\'" + try: + output = shell_execute.execute_command_output_all(cmd) + if int(output["code"]) == 0: + info = output["result"] + app_path = info.split()[-1].rsplit('/', 1)[0] + is_official = check_if_official_app(app_path + '/variables.json') + if is_official: + name = docker.read_var(app_path + '/variables.json', 'name') + if name == app_name: + flag = True + elif app_name == customer_name: + flag = True + myLogger.info_logger("APP in docker") + except CommandException as ce: + myLogger.info_logger("APP not in docker") + + return info, flag + diff --git a/appmanage_new/app/core/rq.py b/appmanage_new/app/core/rq.py new file mode 100644 index 00000000..5cd58dcf --- /dev/null +++ b/appmanage_new/app/core/rq.py @@ -0,0 +1,6 @@ +# 删除错误任务 +def delete_app_failedjob(job_id): + myLogger.info_logger("delete_app_failedjob") + failed = FailedJobRegistry(queue=q) + failed.remove(job_id, delete_job=True) + diff --git a/appmanage_new/app/core/settings.conf b/appmanage_new/app/core/settings.conf new file mode 100644 index 00000000..3bbfceb3 --- /dev/null +++ b/appmanage_new/app/core/settings.conf @@ -0,0 +1,12 @@ +#appstore_preview_update=false +#domain=test.websoft9.com + +#email=help@websoft9.com +#ip=127.0.0.1 +#smtp_port=743 +#smtp_server=smtp.websoft9.com +#smtp_tls/ssl=true +#smtp_user=admin +#smtp_password=password +#install_path=/data +#artifact_url=https://w9artifact.blob.core.windows.net/release/websoft9 \ No newline at end of file diff --git a/appmanage_new/app/external/nginx_proxy_manager.py b/appmanage_new/app/external/nginx_proxy_manager.py new file mode 100644 index 00000000..6f018ee7 --- /dev/null +++ b/appmanage_new/app/external/nginx_proxy_manager.py @@ -0,0 +1,84 @@ +import requests + +class NginxProxyManagerAPI: + """ + This class provides methods to interact with the Nginx Proxy Manager API. + + Args: + base_url (str): The base URL of the Nginx Proxy Manager API. + api_token (str): The API Token to use for authorization. + + Attributes: + base_url (str): The base URL of the Nginx Proxy Manager API. + api_token (str): The API Token to use for authorization. + + Methods: + get_token(identity, scope, secret): Request a new access token from Nginx Proxy Manager + refresh_token(): Refresh your access token + """ + + def __init__(self, base_url, api_token): + """ + Initialize the NginxProxyManagerAPI instance. + + Args: + base_url (str): The base URL of the Nginx Proxy Manager API. + api_token (str): The API token to use for authorization. + """ + self.base_url = base_url + self.api_token = api_token + + def get_token(self,identity,scope,secret): + """ + Request a new access token from Nginx Proxy Manager + + Args: + identity (string): user account with an email address + scope (user): "user" + secret (string): user password + + Returns: + dict or None: A dictionary containing token-related information if successful,otherwise None. The dictionary structure is as follows: + If successful: + { + "expires": str, # Expiry timestamp of the token + "token": str # The access token + } + + If unsuccessful: + None + """ + url = f"{self.base_url}/api/tokens" + data = { + "identity": identity, + "scope": scope, + "secret": secret + } + response = requests.post(url,json=data, headers=headers) + if response.status_code == 200: + return response.json() + else: + return None + + def refresh_token(self): + """ + Refresh your access token + + Returns: + dict or None: A dictionary containing token-related information if successful,otherwise None. The dictionary structure is as follows: + If successful: + { + "expires": str, # Expiry timestamp of the token + "token": str # The access token + } + + If unsuccessful: + None + """ + url = f"{self.base_url}/api/tokens" + headers = {"Authorization": f"Bearer {self.api_token}"} + response = requests.get(url, headers=headers) + if response.status_code == 200: + return response.json() + else: + return None \ No newline at end of file diff --git a/appmanage_new/app/schemas/applist.py b/appmanage_new/app/schemas/applist.py new file mode 100644 index 00000000..0978f649 --- /dev/null +++ b/appmanage_new/app/schemas/applist.py @@ -0,0 +1,20 @@ +from pydantic import BaseModel +from api.model.config import Config +from api.model.status_reason import StatusReason + +class App(BaseModel): + app_id: str + app_name: str + customer_name: str + trade_mark: str + status: str + status_reason: StatusReason = None + official_app: bool + app_version: str + create_time: str + volume_data : str + config_path : str + image_url: str + app_https: bool + app_replace_url: bool + config: Config = None \ No newline at end of file diff --git a/appmanage_new/app/services/app.py b/appmanage_new/app/services/app.py new file mode 100644 index 00000000..52ef3617 --- /dev/null +++ b/appmanage_new/app/services/app.py @@ -0,0 +1,519 @@ +# 合并applist +def conbine_list(installing_list, installed_list): + app_list = installing_list + installed_list + result_list = [] + appid_list = [] + for app in app_list: + app_id = app['app_id'] + if app_id in appid_list: + continue + else: + appid_list.append(app_id) + result_list.append(app) + return result_list + +# 获取所有app的信息 +def get_my_app(app_id): + installed_list = get_apps_from_compose() + installing_list = get_apps_from_queue() + + app_list = conbine_list(installing_list, installed_list) + find = False + ret = {} + if app_id != None: + for app in app_list: + if app_id == app['app_id']: + ret = app + find = True + break + if not find: + raise CommandException(const.ERROR_CLIENT_PARAM_NOTEXIST, "This App doesn't exist!", "") + else: + ret = app_list + myLogger.info_logger("app list result ok") + return ret + +def get_apps_from_compose(): + myLogger.info_logger("Search all of apps ...") + cmd = "docker compose ls -a --format json" + output = shell_execute.execute_command_output_all(cmd) + output_list = json.loads(output["result"]) + myLogger.info_logger(len(output_list)) + ip = "localhost" + try: + ip_result = shell_execute.execute_command_output_all("cat /data/apps/w9services/w9appmanage/public_ip") + ip = ip_result["result"].rstrip('\n') + except Exception: + ip = "127.0.0.1" + + app_list = [] + for app_info in output_list: + volume = app_info["ConfigFiles"] + app_path = volume.rsplit('/', 1)[0] + customer_name = volume.split('/')[-2] + app_id = "" + app_name = "" + trade_mark = "" + port = 0 + url = "" + admin_url = "" + image_url = "" + user_name = "" + password = "" + official_app = False + app_version = "" + create_time = "" + volume_data = "" + config_path = app_path + app_https = False + app_replace_url = False + default_domain = "" + admin_path = "" + admin_domain_url = "" + if customer_name in ['w9appmanage', 'w9nginxproxymanager', 'w9redis', 'w9kopia', + 'w9portainer'] or app_path == '/data/apps/w9services/' + customer_name: + continue + + var_path = app_path + "/variables.json" + official_app = check_if_official_app(var_path) + + status_show = app_info["Status"] + status = app_info["Status"].split("(")[0] + if status == "running" or status == "exited" or status == "restarting": + if "exited" in status_show and "running" in status_show: + if status == "exited": + cmd = "docker ps -a -f name=" + customer_name + " --format {{.Names}}#{{.Status}}|grep Exited" + result = shell_execute.execute_command_output_all(cmd)["result"].rstrip('\n') + container = result.split("#Exited")[0] + if container != customer_name: + status = "running" + if "restarting" in status_show: + about_time = get_createtime(official_app, app_path, customer_name) + if "seconds" in about_time: + status = "restarting" + else: + status = "failed" + elif status == "created": + status = "failed" + else: + continue + + if official_app: + app_name = docker.read_var(var_path, 'name') + app_id = app_name + "_" + customer_name # app_id + # get trade_mark + trade_mark = docker.read_var(var_path, 'trademark') + image_url = get_Image_url(app_name) + # get env info + path = app_path + "/.env" + env_map = docker.get_map(path) + + try: + myLogger.info_logger("get domain for APP_URL") + domain = env_map.get("APP_URL") + if "appname.example.com" in domain or ip in domain: + default_domain = "" + else: + default_domain = domain + except Exception: + myLogger.info_logger("domain exception") + try: + app_version = env_map.get("APP_VERSION") + volume_data = "/data/apps/" + customer_name + "/data" + user_name = env_map.get("APP_USER", "") + password = env_map.get("POWER_PASSWORD", "") + admin_path = env_map.get("APP_ADMIN_PATH") + if admin_path: + myLogger.info_logger(admin_path) + admin_path = admin_path.replace("\"", "") + else: + admin_path = "" + + if default_domain != "" and admin_path != "": + admin_domain_url = "http://" + default_domain + admin_path + except Exception: + myLogger.info_logger("APP_USER POWER_PASSWORD exception") + try: + replace = env_map.get("APP_URL_REPLACE", "false") + myLogger.info_logger("replace=" + replace) + if replace == "true": + app_replace_url = True + https = env_map.get("APP_HTTPS_ACCESS", "false") + if https == "true": + app_https = True + except Exception: + myLogger.info_logger("APP_HTTPS_ACCESS exception") + + try: + http_port = env_map.get("APP_HTTP_PORT", "0") + if http_port: + port = int(http_port) + except Exception: + pass + if port != 0: + try: + if app_https: + easy_url = "https://" + ip + ":" + str(port) + else: + easy_url = "http://" + ip + ":" + str(port) + url = easy_url + admin_url = get_admin_url(customer_name, url) + except Exception: + pass + else: + try: + db_port = list(docker.read_env(path, "APP_DB.*_PORT").values())[0] + port = int(db_port) + except Exception: + pass + else: + app_name = customer_name + app_id = customer_name + "_" + customer_name + create_time = get_createtime(official_app, app_path, customer_name) + if status in ['running', 'exited']: + config = Config(port=port, compose_file=volume, url=url, admin_url=admin_url, + admin_domain_url=admin_domain_url, + admin_path=admin_path, admin_username=user_name, admin_password=password, + default_domain=default_domain) + else: + config = None + if status == "failed": + status_reason = StatusReason(Code=const.ERROR_SERVER_SYSTEM, Message="system original error", + Detail="unknown error") + else: + status_reason = None + app = App(app_id=app_id, app_name=app_name, customer_name=customer_name, trade_mark=trade_mark, + app_version=app_version, create_time=create_time, volume_data=volume_data, config_path=config_path, + status=status, status_reason=status_reason, official_app=official_app, image_url=image_url, + app_https=app_https, app_replace_url=app_replace_url, config=config) + + app_list.append(app.dict()) + return app_list + +# 安装 +def install_app(app_name, customer_name, app_version): + myLogger.info_logger("Install app ...") + ret = {} + ret['ResponseData'] = {} + app_id = app_name + "_" + customer_name + ret['ResponseData']['app_id'] = app_id + + code, message = check_app(app_name, customer_name, app_version) + if code == None: + q.enqueue(install_app_delay, app_name, customer_name, app_version, job_id=app_id) + else: + ret['Error'] = get_error_info(code, message, "") + + return ret + + def start_app(app_id): + info, flag = app_exits_in_docker(app_id) + if flag: + app_path = info.split()[-1].rsplit('/', 1)[0] + cmd = "docker compose -f " + app_path + "/docker-compose.yml start" + shell_execute.execute_command_output_all(cmd) + else: + raise CommandException(const.ERROR_CLIENT_PARAM_NOTEXIST, "APP is not exist", "") + + +def stop_app(app_id): + info, flag = app_exits_in_docker(app_id) + if flag: + app_path = info.split()[-1].rsplit('/', 1)[0] + cmd = "docker compose -f " + app_path + "/docker-compose.yml stop" + shell_execute.execute_command_output_all(cmd) + else: + raise CommandException(const.ERROR_CLIENT_PARAM_NOTEXIST, "APP is not exist", "") + + +def restart_app(app_id): + code, message = docker.check_app_id(app_id) + if code == None: + info, flag = app_exits_in_docker(app_id) + if flag: + app_path = info.split()[-1].rsplit('/', 1)[0] + cmd = "docker compose -f " + app_path + "/docker-compose.yml restart" + shell_execute.execute_command_output_all(cmd) + else: + raise CommandException(const.ERROR_CLIENT_PARAM_NOTEXIST, "APP is not exist", "") + else: + raise CommandException(code, message, "") + +def uninstall_app(app_id): + app_name = app_id.split('_')[0] + customer_name = app_id.split('_')[1] + app_path = "" + info, code_exist = app_exits_in_docker(app_id) + if code_exist: + app_path = info.split()[-1].rsplit('/', 1)[0] + cmd = "docker compose -f " + app_path + "/docker-compose.yml down -v" + lib_path = '/data/library/apps/' + app_name + if app_path != lib_path: + cmd = cmd + " && sudo rm -rf " + app_path + shell_execute.execute_command_output_all(cmd) + else: + if check_app_rq(app_id): + delete_app_failedjob(app_id) + else: + raise CommandException(const.ERROR_CLIENT_PARAM_NOTEXIST, "AppID is not exist", "") + # Force to delete docker compose + try: + cmd = " sudo rm -rf /data/apps/" + customer_name + shell_execute.execute_command_output_all(cmd) + except CommandException as ce: + myLogger.info_logger("Delete app compose exception") + # Delete proxy config when uninstall app + app_proxy_delete(app_id) + + +# 安装失败后的处理 +def delete_app(app_id): + try: + app_name = app_id.split('_')[0] + customer_name = app_id.split('_')[1] + app_path = "" + info, code_exist = app_exits_in_docker(app_id) + if code_exist: + app_path = info.split()[-1].rsplit('/', 1)[0] + cmd = "docker compose -f " + app_path + "/docker-compose.yml down -v" + lib_path = '/data/library/apps/' + app_name + if app_path != lib_path: + cmd = cmd + " && sudo rm -rf " + app_path + try: + myLogger.info_logger("Intall fail, down app and delete files") + shell_execute.execute_command_output_all(cmd) + except Exception: + myLogger.info_logger("Delete app compose exception") + # 强制删除失败又无法通过docker compose down 删除的容器 + try: + myLogger.info_logger("IF delete fail, force to delete containers") + force_cmd = "docker rm -f $(docker ps -f name=^" + customer_name + " -aq)" + shell_execute.execute_command_output_all(force_cmd) + except Exception: + myLogger.info_logger("force delete app compose exception") + + else: + if check_app_rq(app_id): + delete_app_failedjob(app_id) + else: + raise CommandException(const.ERROR_CLIENT_PARAM_NOTEXIST, "AppID is not exist", "") + cmd = " sudo rm -rf /data/apps/" + customer_name + shell_execute.execute_command_output_all(cmd) + except CommandException as ce: + myLogger.info_logger("Delete app compose exception") + +#安装准备 +def prepare_app(app_name, customer_name): + library_path = "/data/library/apps/" + app_name + install_path = "/data/apps/" + customer_name + shell_execute.execute_command_output_all("cp -r " + library_path + " " + install_path) + + +def install_app_delay(app_name, customer_name, app_version): + myLogger.info_logger("-------RQ install start --------") + job_id = app_name + "_" + customer_name + + try: + # 因为这个时候还没有复制文件夹,是从/data/library里面文件读取json来检查的,应该是app_name,而不是customer_name + resource_flag = docker.check_vm_resource(app_name) + + if resource_flag == True: + + myLogger.info_logger("job check ok, continue to install app") + env_path = "/data/apps/" + customer_name + "/.env" + # prepare_app(app_name, customer_name) + docker.check_app_compose(app_name, customer_name) + myLogger.info_logger("start JobID=" + job_id) + docker.modify_env(env_path, 'APP_NAME', customer_name) + docker.modify_env(env_path, "APP_VERSION", app_version) + docker.check_app_url(customer_name) + cmd = "cd /data/apps/" + customer_name + " && sudo docker compose pull && sudo docker compose up -d" + output = shell_execute.execute_command_output_all(cmd) + myLogger.info_logger("-------Install result--------") + myLogger.info_logger(output["code"]) + myLogger.info_logger(output["result"]) + try: + shell_execute.execute_command_output_all("bash /data/apps/" + customer_name + "/src/after_up.sh") + except Exception as e: + myLogger.info_logger(str(e)) + else: + error_info = "##websoft9##" + const.ERROR_SERVER_RESOURCE + "##websoft9##" + "Insufficient system resources (cpu, memory, disk space)" + "##websoft9##" + "Insufficient system resources (cpu, memory, disk space)" + myLogger.info_logger(error_info) + raise Exception(error_info) + except CommandException as ce: + myLogger.info_logger(customer_name + " install failed(docker)!") + delete_app(job_id) + error_info = "##websoft9##" + ce.code + "##websoft9##" + ce.message + "##websoft9##" + ce.detail + myLogger.info_logger(error_info) + raise Exception(error_info) + except Exception as e: + myLogger.info_logger(customer_name + " install failed(system)!") + delete_app(job_id) + error_info = "##websoft9##" + const.ERROR_SERVER_SYSTEM + "##websoft9##" + 'system original error' + "##websoft9##" + str( + e) + myLogger.info_logger(error_info) + raise Exception(error_info) + +def get_createtime(official_app, app_path, customer_name): + data_time = "" + try: + if official_app: + cmd = "docker ps -f name=" + customer_name + " --format {{.RunningFor}} | head -n 1" + result = shell_execute.execute_command_output_all(cmd)["result"].rstrip('\n') + data_time = result + else: + cmd_all = "cd " + app_path + " && docker compose ps -a --format json" + output = shell_execute.execute_command_output_all(cmd_all) + container_name = json.loads(output["result"])[0]["Name"] + cmd = "docker ps -f name=" + container_name + " --format {{.RunningFor}} | head -n 1" + result = shell_execute.execute_command_output_all(cmd)["result"].rstrip('\n') + data_time = result + + except Exception as e: + myLogger.info_logger(str(e)) + myLogger.info_logger("get_createtime get success" + data_time) + return data_time + +def check_if_official_app(var_path): + if docker.check_directory(var_path): + if docker.read_var(var_path, 'name') != "" and docker.read_var(var_path, 'trademark') != "" and docker.read_var( + var_path, 'requirements') != "": + requirements = docker.read_var(var_path, 'requirements') + try: + cpu = requirements['cpu'] + mem = requirements['memory'] + disk = requirements['disk'] + return True + except KeyError: + return False + else: + return False + +# 应用是否已经安装 +def check_app_docker(app_id): + customer_name = app_id.split('_')[1] + app_name = app_id.split('_')[0] + flag = False + cmd = "docker compose ls -a | grep \'/" + customer_name + "/\'" + try: + shell_execute.execute_command_output_all(cmd) + flag = True + myLogger.info_logger("APP in docker") + except CommandException as ce: + myLogger.info_logger("APP not in docker") + + return flag + + +def check_app_rq(app_id): + myLogger.info_logger("check_app_rq") + + started = StartedJobRegistry(queue=q) + failed = FailedJobRegistry(queue=q) + run_job_ids = started.get_job_ids() + failed_job_ids = failed.get_job_ids() + queue_job_ids = q.job_ids + myLogger.info_logger(queue_job_ids) + myLogger.info_logger(run_job_ids) + myLogger.info_logger(failed_job_ids) + if queue_job_ids and app_id in queue_job_ids: + myLogger.info_logger("App in RQ") + return True + if failed_job_ids and app_id in failed_job_ids: + myLogger.info_logger("App in RQ") + return True + if run_job_ids and app_id in run_job_ids: + myLogger.info_logger("App in RQ") + return True + myLogger.info_logger("App not in RQ") + return False + + + def get_apps_from_queue(): + myLogger.info_logger("get queque apps...") + # 获取 StartedJobRegistry 实例 + started = StartedJobRegistry(queue=q) + finish = FinishedJobRegistry(queue=q) + deferred = DeferredJobRegistry(queue=q) + failed = FailedJobRegistry(queue=q) + scheduled = ScheduledJobRegistry(queue=q) + cancel = CanceledJobRegistry(queue=q) + + # 获取正在执行的作业 ID 列表 + run_job_ids = started.get_job_ids() + finish_job_ids = finish.get_job_ids() + wait_job_ids = deferred.get_job_ids() + failed_jobs = failed.get_job_ids() + scheduled_jobs = scheduled.get_job_ids() + cancel_jobs = cancel.get_job_ids() + + myLogger.info_logger(q.jobs) + myLogger.info_logger(run_job_ids) + myLogger.info_logger(failed_jobs) + myLogger.info_logger(cancel_jobs) + myLogger.info_logger(wait_job_ids) + myLogger.info_logger(finish_job_ids) + myLogger.info_logger(scheduled_jobs) + + installing_list = [] + for job_id in run_job_ids: + app = get_rq_app(job_id, 'installing', "", "", "") + installing_list.append(app) + for job in q.jobs: + app = get_rq_app(job.id, 'installing', "", "", "") + installing_list.append(app) + for job_id in failed_jobs: + job = q.fetch_job(job_id) + exc_info = job.exc_info + code = exc_info.split('##websoft9##')[1] + message = exc_info.split('##websoft9##')[2] + detail = exc_info.split('##websoft9##')[3] + app = get_rq_app(job_id, 'failed', code, message, detail) + installing_list.append(app) + + return installing_list + +#从rq获取app信息 +def get_rq_app(id, status, code, message, detail): + app_name = id.split('_')[0] + customer_name = id.split('_')[1] + # 当app还在RQ时,可能文件夹还没创建,无法获取trade_mark + trade_mark = "" + app_version = "" + create_time = "" + volume_data = "" + config_path = "" + image_url = get_Image_url(app_name) + config = None + if status == "installing": + status_reason = None + else: + status_reason = StatusReason(Code=code, Message=message, Detail=detail) + + app = App(app_id=id, app_name=app_name, customer_name=customer_name, trade_mark=trade_mark, + app_version=app_version, create_time=create_time, volume_data=volume_data, config_path=config_path, + status=status, status_reason=status_reason, official_app=True, image_url=image_url, + app_https=False, app_replace_url=False, config=config) + return app.dict() + + +def get_admin_url(customer_name, url): + admin_url = "" + path = "/data/apps/" + customer_name + "/.env" + try: + admin_path = list(docker.read_env(path, "APP_ADMIN_PATH").values())[0] + admin_path = admin_path.replace("\"", "") + admin_url = url + admin_path + except IndexError: + pass + return admin_url + +def get_container_port(container_name): + port = "80" + cmd = "docker port " + container_name + " |grep ::" + result = shell_execute.execute_command_output_all(cmd)["result"] + myLogger.info_logger(result) + port = result.split('/')[0] + myLogger.info_logger(port) + + return port \ No newline at end of file diff --git a/appmanage_new/app/services/domain.py b/appmanage_new/app/services/domain.py new file mode 100644 index 00000000..95c0d14d --- /dev/null +++ b/appmanage_new/app/services/domain.py @@ -0,0 +1,444 @@ +def app_domain_list(app_id): + code, message = docker.check_app_id(app_id) + if code == None: + info, flag = app_exits_in_docker(app_id) + if flag: + myLogger.info_logger("Check app_id ok[app_domain_list]") + else: + raise CommandException(const.ERROR_CLIENT_PARAM_NOTEXIST, "APP is not exist", "") + else: + raise CommandException(code, message, "") + + domains = get_all_domains(app_id) + + myLogger.info_logger(domains) + + ret = {} + ret['domains'] = domains + + default_domain = "" + if domains != None and len(domains) > 0: + customer_name = app_id.split('_')[1] + app_url = shell_execute.execute_command_output_all("cat /data/apps/" + customer_name + "/.env")["result"] + if "APP_URL" in app_url: + url = shell_execute.execute_command_output_all("cat /data/apps/" + customer_name + "/.env |grep APP_URL=")[ + "result"].rstrip('\n') + default_domain = url.split('=')[1] + ret['default_domain'] = default_domain + myLogger.info_logger(ret) + return ret + +def app_proxy_delete(app_id): + customer_name = app_id.split('_')[1] + proxy_host = None + token = get_token() + url = const.NGINX_URL+"/api/nginx/proxy-hosts" + headers = { + 'Authorization': token, + 'Content-Type': 'application/json' + } + response = requests.get(url, headers=headers) + + for proxy in response.json(): + portainer_name = proxy["forward_host"] + if customer_name == portainer_name: + proxy_id = proxy["id"] + token = get_token() + url = const.NGINX_URL+"/api/nginx/proxy-hosts/" + str(proxy_id) + headers = { + 'Authorization': token, + 'Content-Type': 'application/json' + } + response = requests.delete(url, headers=headers) + + +def app_domain_delete(app_id, domain): + code, message = docker.check_app_id(app_id) + if code == None: + info, flag = app_exits_in_docker(app_id) + if flag: + myLogger.info_logger("Check app_id ok[app_domain_delete]") + else: + raise CommandException(const.ERROR_CLIENT_PARAM_NOTEXIST, "APP is not exist", "") + else: + raise CommandException(code, message, "") + + if domain is None or domain == "undefined": + raise CommandException(const.ERROR_CLIENT_PARAM_BLANK, "Domains is blank", "") + + old_all_domains = get_all_domains(app_id) + if domain not in old_all_domains: + myLogger.info_logger("delete domain is not binded") + raise CommandException(const.ERROR_CLIENT_PARAM_NOTEXIST, "Domain is not bind.", "") + + myLogger.info_logger("Start to delete " + domain) + proxy = get_proxy_domain(app_id, domain) + if proxy != None: + myLogger.info_logger(proxy) + myLogger.info_logger("before update") + domains_old = proxy["domain_names"] + myLogger.info_logger(domains_old) + + domains_old.remove(domain) + myLogger.info_logger("after update") + myLogger.info_logger(domains_old) + if len(domains_old) == 0: + proxy_id = proxy["id"] + token = get_token() + url = const.NGINX_URL+"/api/nginx/proxy-hosts/" + str(proxy_id) + headers = { + 'Authorization': token, + 'Content-Type': 'application/json' + } + response = requests.delete(url, headers=headers) + try: + if response.json().get("error"): + raise CommandException(const.ERROR_CONFIG_NGINX, response.json().get("error").get("message"), "") + except Exception: + myLogger.info_logger(response.json()) + set_domain("", app_id) + else: + proxy_id = proxy["id"] + token = get_token() + url = const.NGINX_URL+"/api/nginx/proxy-hosts/" + str(proxy_id) + headers = { + 'Authorization': token, + 'Content-Type': 'application/json' + } + port = get_container_port(app_id.split('_')[1]) + host = app_id.split('_')[1] + data = { + "domain_names": domains_old, + "forward_scheme": "http", + "forward_host": host, + "forward_port": port, + "access_list_id": "0", + "certificate_id": 0, + "meta": { + "letsencrypt_agree": False, + "dns_challenge": False + }, + "advanced_config": "", + "locations": [], + "block_exploits": False, + "caching_enabled": False, + "allow_websocket_upgrade": False, + "http2_support": False, + "hsts_enabled": False, + "hsts_subdomains": False, + "ssl_forced": False + } + + response = requests.put(url, data=json.dumps(data), headers=headers) + if response.json().get("error"): + raise CommandException(const.ERROR_CONFIG_NGINX, response.json().get("error").get("message"), "") + domain_set = app_domain_list(app_id) + default_domain = domain_set['default_domain'] + # 如果被删除的域名是默认域名,删除后去剩下域名的第一个 + if default_domain == domain: + set_domain(domains_old[0], app_id) + + else: + raise CommandException(const.ERROR_CLIENT_PARAM_NOTEXIST, "Delete domain is not bind", "") + +def app_domain_update(app_id, domain_old, domain_new): + myLogger.info_logger("app_domain_update") + domain_list = [] + domain_list.append(domain_old) + domain_list.append(domain_new) + + check_domains(domain_list) + + code, message = docker.check_app_id(app_id) + if code == None: + info, flag = app_exits_in_docker(app_id) + if flag: + myLogger.info_logger("Check app_id ok") + else: + raise CommandException(const.ERROR_CLIENT_PARAM_NOTEXIST, "APP is not exist", "") + else: + raise CommandException(code, message, "") + proxy = get_proxy_domain(app_id, domain_old) + if proxy != None: + domains_old = proxy["domain_names"] + index = domains_old.index(domain_old) + domains_old[index] = domain_new + proxy_id = proxy["id"] + token = get_token() + url = const.NGINX_URL+"/api/nginx/proxy-hosts/" + str(proxy_id) + headers = { + 'Authorization': token, + 'Content-Type': 'application/json' + } + port = get_container_port(app_id.split('_')[1]) + host = app_id.split('_')[1] + data = { + "domain_names": domains_old, + "forward_scheme": "http", + "forward_host": host, + "forward_port": port, + "access_list_id": "0", + "certificate_id": 0, + "meta": { + "letsencrypt_agree": False, + "dns_challenge": False + }, + "advanced_config": "", + "locations": [], + "block_exploits": False, + "caching_enabled": False, + "allow_websocket_upgrade": False, + "http2_support": False, + "hsts_enabled": False, + "hsts_subdomains": False, + "ssl_forced": False + } + + response = requests.put(url, data=json.dumps(data), headers=headers) + if response.json().get("error"): + raise CommandException(const.ERROR_CONFIG_NGINX, response.json().get("error").get("message"), "") + domain_set = app_domain_list(app_id) + default_domain = domain_set['default_domain'] + myLogger.info_logger("default_domain=" + default_domain + ",domain_old=" + domain_old) + # 如果被修改的域名是默认域名,修改后也设置为默认域名 + if default_domain == domain_old: + set_domain(domain_new, app_id) + else: + raise CommandException(const.ERROR_CLIENT_PARAM_NOTEXIST, "edit domain is not exist", "") + +def app_domain_add(app_id, domain): + temp_domains = [] + temp_domains.append(domain) + check_domains(temp_domains) + + code, message = docker.check_app_id(app_id) + if code == None: + info, flag = app_exits_in_docker(app_id) + if flag: + myLogger.info_logger("Check app_id ok") + else: + raise CommandException(const.ERROR_CLIENT_PARAM_NOTEXIST, "APP is not exist", "") + else: + raise CommandException(code, message, "") + + old_domains = get_all_domains(app_id) + if domain in old_domains: + raise CommandException(const.ERROR_CLIENT_PARAM_NOTEXIST, "Domain is in use", "") + + proxy = get_proxy(app_id) + if proxy != None: + domains_old = proxy["domain_names"] + domain_list = domains_old + domain_list.append(domain) + + proxy_id = proxy["id"] + token = get_token() + url = const.NGINX_URL+"/api/nginx/proxy-hosts/" + str(proxy_id) + headers = { + 'Authorization': token, + 'Content-Type': 'application/json' + } + port = get_container_port(app_id.split('_')[1]) + host = app_id.split('_')[1] + data = { + "domain_names": domain_list, + "forward_scheme": "http", + "forward_host": host, + "forward_port": port, + "access_list_id": "0", + "certificate_id": 0, + "meta": { + "letsencrypt_agree": False, + "dns_challenge": False + }, + "advanced_config": "", + "locations": [], + "block_exploits": False, + "caching_enabled": False, + "allow_websocket_upgrade": False, + "http2_support": False, + "hsts_enabled": False, + "hsts_subdomains": False, + "ssl_forced": False + } + response = requests.put(url, data=json.dumps(data), headers=headers) + if response.json().get("error"): + raise CommandException(const.ERROR_CONFIG_NGINX, response.json().get("error").get("message"), "") + else: + # 追加 + token = get_token() + url = const.NGINX_URL+"/api/nginx/proxy-hosts" + headers = { + 'Authorization': token, + 'Content-Type': 'application/json' + } + port = get_container_port(app_id.split('_')[1]) + host = app_id.split('_')[1] + + data = { + "domain_names": temp_domains, + "forward_scheme": "http", + "forward_host": host, + "forward_port": port, + "access_list_id": "0", + "certificate_id": 0, + "meta": { + "letsencrypt_agree": False, + "dns_challenge": False + }, + "advanced_config": "", + "locations": [], + "block_exploits": False, + "caching_enabled": False, + "allow_websocket_upgrade": False, + "http2_support": False, + "hsts_enabled": False, + "hsts_subdomains": False, + "ssl_forced": False + } + + response = requests.post(url, data=json.dumps(data), headers=headers) + + if response.json().get("error"): + raise CommandException(const.ERROR_CONFIG_NGINX, response.json().get("error").get("message"), "") + set_domain(domain, app_id) + + return domain + +def check_domains(domains): + myLogger.info_logger(domains) + if domains is None or len(domains) == 0: + raise CommandException(const.ERROR_CLIENT_PARAM_BLANK, "Domains is blank", "") + else: + for domain in domains: + if is_valid_domain(domain): + if check_real_domain(domain) == False: + raise CommandException(const.ERROR_CLIENT_PARAM_NOTEXIST, "Domain and server not match", "") + else: + raise CommandException(const.ERROR_CLIENT_PARAM_Format, "Domains format error", "") + + +def is_valid_domain(domain): + if domain.startswith("http"): + return False + + return True + +def check_real_domain(domain): + domain_real = True + try: + cmd = "ping -c 1 " + domain + " | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | uniq" + domain_ip = shell_execute.execute_command_output_all(cmd)["result"].rstrip('\n') + + ip_result = shell_execute.execute_command_output_all("cat /data/apps/w9services/w9appmanage/public_ip") + ip_save = ip_result["result"].rstrip('\n') + + if domain_ip == ip_save: + myLogger.info_logger("Domain check ok!") + else: + domain_real = False + except CommandException as ce: + domain_real = False + + return domain_real + + +def get_proxy_domain(app_id, domain): + customer_name = app_id.split('_')[1] + proxy_host = None + token = get_token() + url = const.NGINX_URL+"/api/nginx/proxy-hosts" + headers = { + 'Authorization': token, + 'Content-Type': 'application/json' + } + response = requests.get(url, headers=headers) + + myLogger.info_logger(response.json()) + for proxy in response.json(): + portainer_name = proxy["forward_host"] + domain_list = proxy["domain_names"] + if customer_name == portainer_name: + myLogger.info_logger("-------------------") + if domain in domain_list: + myLogger.info_logger("find the domain proxy") + proxy_host = proxy + break + + return proxy_host + + +def get_all_domains(app_id): + customer_name = app_id.split('_')[1] + domains = [] + token = get_token() + url = const.NGINX_URL+"/api/nginx/proxy-hosts" + headers = { + 'Authorization': token, + 'Content-Type': 'application/json' + } + response = requests.get(url, headers=headers) + + for proxy in response.json(): + portainer_name = proxy["forward_host"] + if customer_name == portainer_name: + for domain in proxy["domain_names"]: + domains.append(domain) + return domains + + +def app_domain_set(domain, app_id): + temp_domains = [] + temp_domains.append(domain) + check_domains(temp_domains) + + code, message = docker.check_app_id(app_id) + if code == None: + info, flag = app_exits_in_docker(app_id) + if flag: + myLogger.info_logger("Check app_id ok") + else: + raise CommandException(const.ERROR_CLIENT_PARAM_NOTEXIST, "APP is not exist", "") + else: + raise CommandException(code, message, "") + + set_domain(domain, app_id) + + +def set_domain(domain, app_id): + myLogger.info_logger("set_domain start") + old_domains = get_all_domains(app_id) + if domain != "": + if domain not in old_domains: + message = domain + " is not in use" + raise CommandException(const.ERROR_CLIENT_PARAM_NOTEXIST, message, "") + + customer_name = app_id.split('_')[1] + app_url = shell_execute.execute_command_output_all("cat /data/apps/" + customer_name + "/.env")["result"] + + if "APP_URL" in app_url: + myLogger.info_logger("APP_URL is exist") + if domain == "": + ip_result = shell_execute.execute_command_output_all("cat /data/apps/w9services/w9appmanage/public_ip") + domain = ip_result["result"].rstrip('\n') + cmd = "sed -i 's/APP_URL=.*/APP_URL=" + domain + "/g' /data/apps/" + customer_name + "/.env" + shell_execute.execute_command_output_all(cmd) + if "APP_URL_REPLACE=true" in app_url: + myLogger.info_logger("need up") + shell_execute.execute_command_output_all("cd /data/apps/" + customer_name + " && docker compose up -d") + else: + cmd = "sed -i 's/APP_URL=.*/APP_URL=" + domain + "/g' /data/apps/" + customer_name + "/.env" + shell_execute.execute_command_output_all(cmd) + if "APP_URL_REPLACE=true" in app_url: + myLogger.info_logger("need up") + shell_execute.execute_command_output_all("cd /data/apps/" + customer_name + " && docker compose up -d") + else: + myLogger.info_logger("APP_URL is not exist") + if domain == "": + ip_result = shell_execute.execute_command_output_all("cat /data/apps/w9services/w9appmanage/public_ip") + domain = ip_result["result"].rstrip('\n') + + cmd = "sed -i '/APP_NETWORK/a APP_URL=" + domain + "' /data/apps/" + customer_name + "/.env" + shell_execute.execute_command_output_all(cmd) + myLogger.info_logger("set_domain success") + diff --git a/appmanage_new/app/services/update.py b/appmanage_new/app/services/update.py new file mode 100644 index 00000000..232cbfcf --- /dev/null +++ b/appmanage_new/app/services/update.py @@ -0,0 +1,177 @@ +def get_release_url(): + preview = db.AppSearchPreview().get("preview") + myLogger.info_logger(preview) + if preview == "false": + return const.ARTIFACT_URL + else: + return const.ARTIFACT_URL_DEV + +def appstore_update(): + myLogger.info_logger("appstore update start...") + # 当点击appstore升级时,是无条件升级,不需要做版本的判定 + release_url = get_release_url() + download_url = release_url + "/plugin/appstore/appstore-latest.zip" + cmd = "cd /opt && rm -rf /opt/appstore* && wget -q " + download_url + " && unzip -q appstore-latest.zip " + shell_execute.execute_command_output_all(cmd) + + shell_execute.execute_command_output_all("rm -rf /usr/share/cockpit/appstore && cp -r /opt/appstore /usr/share/cockpit") + shell_execute.execute_command_output_all("rm -rf /opt/appstore*") + + library_url = release_url + "/plugin/library/library-latest.zip" + library_cmd = "cd /opt && rm -rf /opt/library* && wget -q " + library_url + " && unzip -q library-latest.zip " + shell_execute.execute_command_output_all(library_cmd) + shell_execute.execute_command_output_all("rm -rf /data/library && cp -r /opt/library /data") + shell_execute.execute_command_output_all("rm -rf /opt/library*") + myLogger.info_logger("auto update success...") + +def AppStoreUpdate(): + core_support = AppStoreCore() + release_url = get_release_url() + if core_support == "-1": + raise CommandException(const.ERRORMESSAGE_SERVER_VERSION_NEEDUPGRADE, "You must upgrade websoft9 core", "You must upgrade websoft9 core") + elif core_support == "1": + raise CommandException(const.ERRORMESSAGE_SERVER_VERSION_NOTSUPPORT, "core not support,can not upgrade", "core not support,can not upgrade") + local_path = '/usr/share/cockpit/appstore/appstore.json' + local_version = "0" + try: + op = shell_execute.execute_command_output_all("cat " + local_path)['result'] + local_version = json.loads(op)['Version'] + except: + local_version = "0.0.0" + + version_cmd = "wget -O appstore.json " + release_url + "/plugin/appstore/appstore.json && cat appstore.json" + latest = shell_execute.execute_command_output_all(version_cmd)['result'] + version = json.loads(latest)['Version'] + if local_version < version: + appstore_update() + else: + myLogger.info_logger("You click update appstore, but not need to update") + + + +def AppPreviewUpdate(preview): + myLogger.info_logger("AppPreviewUpdate") + if preview == "true" or preview == "True": + db.AppUpdatePreview(preview) + return "true" + elif preview == "false" or preview == "False": + db.AppUpdatePreview(preview) + return "false" + elif preview == None or preview == "" or preview == "undefine": + return db.AppSearchPreview().get("preview") + else: + raise CommandException(const.ERROR_CLIENT_PARAM_NOTEXIST, "preview is true,false,blank", "preview is true,false,blank") + +#检查内核VERSION 是否支持Appstore的更新 +def AppStoreCore(): + release_url = get_release_url() + version_cmd = "wget -O appstore.json " + release_url + "/plugin/appstore/appstore.json && cat appstore.json" + latest = shell_execute.execute_command_output_all(version_cmd)['result'] + most_version = json.loads(latest)['Requires at most'] + least_version = json.loads(latest)['Requires at least'] + now = shell_execute.execute_command_output_all("cat /data/apps/websoft9/version.json")['result'] + now_version = json.loads(now)['VERSION'] + version_str = "now_version:" + now_version + " least_version:" + least_version + " most_version:" + most_version + myLogger.info_logger(version_str) + if now_version >= least_version and now_version <= most_version: + return "0" + elif now_version < least_version: + return "-1" + elif now_version > most_version: + return "1" + return "0" + +# 获取 核心更新日志 +def get_update_list(url: str=None): + local_path = '/data/apps/websoft9/version.json' + artifact_url = const.ARTIFACT_URL + if url: + artifact_url = url + + try: + op = shell_execute.execute_command_output_all("cat " + local_path)['result'] + local_version = json.loads(op)['VERSION'] + except: + local_version = "0.0.0" + version_cmd = f"wget -O version.json {artifact_url}/version.json && cat version.json" + latest = shell_execute.execute_command_output_all(version_cmd)['result'] + version = json.loads(latest)['VERSION'] + ret = {} + ret['local_version'] = local_version + ret['target_version'] = version + content = [] + date = "" + + if compared_version(local_version, version) == -1: + ret['update'] = True + cmd = f"wget -O CHANGELOG.md {artifact_url}/CHANGELOG.md && head -n 20 CHANGELOG.md" + change_log_contents = shell_execute.execute_command_output(cmd) + change_log = change_log_contents.split('## ')[1].split('\n') + date = change_log[0].split()[-1] + for change in change_log[1:]: + if change != '': + content.append(change) + else: + ret['update'] = False + ret['date'] = date + ret['content'] = content + return ret + +# 获取 appstore 更新日志 +def get_appstore_update_list(): + release_url = get_release_url() + local_path = '/usr/share/cockpit/appstore/appstore.json' + local_version = "0" + try: + op = shell_execute.execute_command_output_all("cat " + local_path)['result'] + local_version = json.loads(op)['Version'] + except: + local_version = "0.0.0" + + + version_cmd = "wget -O appstore.json -N " + release_url + "/plugin/appstore/appstore.json && cat appstore.json" + latest = shell_execute.execute_command_output_all(version_cmd)['result'] + version = json.loads(latest)['Version'] + ret = {} + ret['local_version'] = local_version + ret['target_version'] = version + content = [] + date = "" + core_compare = "" + + if compared_version(local_version, version) == -1: + ret['update'] = True + cmd = "wget -O CHANGELOG.md " + release_url + "/plugin/appstore/CHANGELOG.md && cat CHANGELOG.md" + change_log_contents = shell_execute.execute_command_output_all(cmd)['result'] + change_log = change_log_contents.split('## ')[1].split('\n') + date = change_log[0].split()[-1] + for change in change_log[1:]: + if change != '': + content.append(change) + core_compare = AppStoreCore() + else: + ret['update'] = False + ret['date'] = date + ret['content'] = content + ret['core_compare'] = core_compare + return ret + + +def compared_version(ver1, ver2): + list1 = str(ver1).split(".") + list2 = str(ver2).split(".") + # 循环次数为短的列表的len + for i in range(len(list1)) if len(list1) < len(list2) else range(len(list2)): + if int(list1[i]) == int(list2[i]): + pass + elif int(list1[i]) < int(list2[i]): + return -1 + else: + return 1 + # 循环结束,哪个列表长哪个版本号高 + if len(list1) == len(list2): + return 0 + elif len(list1) < len(list2): + return -1 + else: + return 1 \ No newline at end of file diff --git a/appmanage_new/app/utils/getIP.py b/appmanage_new/app/utils/getIP.py new file mode 100644 index 00000000..1a898ea8 --- /dev/null +++ b/appmanage_new/app/utils/getIP.py @@ -0,0 +1,41 @@ +#!/bin/bash +url_list=( + api.ipify.org + bot.whatismyipaddress.com + icanhazip.com + ifconfig.co + ident.me + ifconfig.me + icanhazip.com + ipecho.net/plain + ipinfo.io/ip + ip.sb + whatismyip.akamai.com + inet-ip.info +) + +curl_ip(){ + curl --connect-timeout 1 -m 2 $1 2>/dev/null + return $? +} + +debug(){ + for x in ${url_list[*]} + do + curl_ip $x + done +} + +print_ip(){ + for n in ${url_list[*]} + do + public_ip=`curl_ip $n` + check_ip=`echo $public_ip | awk -F"." '{print NF}'` + if [ ! -z "$public_ip" -a $check_ip -eq "4" ]; then + echo $public_ip + exit 0 + fi + done +} +#debug +print_ip \ No newline at end of file diff --git a/appmanage_new/app/utils/helper.py b/appmanage_new/app/utils/helper.py new file mode 100644 index 00000000..8a09d959 --- /dev/null +++ b/appmanage_new/app/utils/helper.py @@ -0,0 +1,6 @@ +class Singleton(type): + _instances = {} + def __call__(cls, *args, **kwargs): + if cls not in cls._instances: + cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) + return cls._instances[cls] \ No newline at end of file diff --git a/appmanage_new/app/utils/runshell.py b/appmanage_new/app/utils/runshell.py new file mode 100644 index 00000000..637f9b04 --- /dev/null +++ b/appmanage_new/app/utils/runshell.py @@ -0,0 +1,46 @@ +#!/usr/bin/python3 +import subprocess + +from api.utils.log import myLogger +from api.exception.command_exception import CommandException +from api.utils import const + + +# This fuction is for running shell commands on container +# cmd_str e.g: "ls -a" +# return string limit: 4000 chars? to do +def execute_command_output(cmd_str): + print(cmd_str) + out_str = subprocess.getoutput(cmd_str) + print(out_str) + return out_str + + +# This fuction is for running shell commands on host machine +# cmd_str e.g: "ls -a" +# return string limit: 4000 chars +def execute_command_output_all(cmd_str): + + myLogger.info_logger("Start to execute cmd: " + cmd_str) + + process = subprocess.run(f'nsenter -m -u -i -n -p -t 1 sh -c "{cmd_str}"', capture_output=True, bufsize=65536, check=False, text=True, shell=True) + + if process.returncode == 0 and 'Fail' not in process.stdout and 'fail' not in process.stdout and 'Error' not in process.stdout and 'error' not in process.stdout: + + return {"code": "0", "result": process.stdout} + else: + myLogger.info_logger("Failed to execute cmd, output failed result") + myLogger.info_logger(process) + raise CommandException(const.ERROR_SERVER_COMMAND, "Docker returns the original error", process.stderr) + + + +# This fuction is convert container commands to host machine commands +def convert_command(cmd_str): + convert_cmd = "" + if cmd_str == "": + convert_cmd=cmd_str + else: + convert_cmd="nsenter -m -u -i -n -p -t 1 sh -c " + "'"+cmd_str+"'" + + return convert_cmd \ No newline at end of file diff --git a/appmanage_new/app/utils/settings_file.py b/appmanage_new/app/utils/settings_file.py new file mode 100644 index 00000000..f5ab7955 --- /dev/null +++ b/appmanage_new/app/utils/settings_file.py @@ -0,0 +1,68 @@ +from api.utils.log import myLogger +from api.utils.helper import Singleton + + +# This class is add/modify/list/delete item to item=value(键值对) model settings file + +class SettingsFile(object): + + __metaclass__ = Singleton + + def __init__(self, path): + self._config = {} + self.config_file = path + + def build_config(self): + try: + with open(self.config_file, 'r') as f: + data = f.readlines() + except Exception as e: + data = [] + for i in data: + if i.startswith('#'): + continue + i = i.replace('\n', '').replace('\r\n', '') + if not i: + continue + tmp = i.split('=') + if len(tmp) != 2: + myLogger.error_logger(f'invalid format {i}') + continue + + key, value = i.split('=') + if self._config.get(key) != value: + self._config[key] = value + return self._config + + def init_config_from_file(self, config_file: str=None): + if config_file: + self.config_file = config_file + self.build_config() + + def update_setting(self, key: str, value: str): + self._config[key] = value + self.flush_config() + + def get_setting(self, key: str, default=None): + return self._config.get(key, default) + + def list_all_settings(self) -> dict: + self.build_config() + return self._config + + def delete_setting(self, key: str, value: str): + if self._config.get(key) == value: + del self._config[key] + self.flush_config() + + def flush_config(self): + try: + with open(self.config_file, 'w') as f: + for key, value in self._config.items(): + f.write(f'{key}={value}\n') + except Exception as e: + myLogger.error_logger(e) + + +# This class is add/modify/cat/delete content from file +# src: path | URL \ No newline at end of file diff --git a/appmanage_new/docs/MAINTAINERS.md b/appmanage_new/docs/MAINTAINERS.md new file mode 100644 index 00000000..83a13270 --- /dev/null +++ b/appmanage_new/docs/MAINTAINERS.md @@ -0,0 +1,42 @@ +# Documentation for core maintainers + +This documentaion is from [jenkins MAINTAINERS](https://github.com/jenkinsci/jenkins/blob/master/docs/MAINTAINERS.adoc) which have a paradigm of rigorous open source project maintenance + +## Scope + +This document applies to the following components: + +- Websoft9 core +- Websoft9 core plugins +- docker-library + +## Roles + +| Role/job | submit pr | review pr | assign pr | merge pr | close pr | create issue | manage issue | release | +| ------------ | --------- | --------- | --------- | -------- | -------- | ------------ | ------------ | ------- | +| Contributor | √ | | | | | √ | | | +| Issue Team | √ | | | | | √ | √ | | +| PR Reviewer | √ | √ | | | | √ | | | +| Release Team | √ | | | | | √ | | √ | +| Maintainer | √ | √ | √ | √ | √ | √ | | | +| PR Assignee | | | | √ | | √ | | | + + +* **Contributor**: submit pull requests to the Jenkins core and review changes submitted by others. There are no special preconditions to do so. Anyone is welcome to contribute. +* **Issue Triage Team Member**: review the incoming issues: bug reports, requests for enhancement, etc. Special permissions are not required to take this role or to contribute. +* **Core Pull Request Reviewer**: A team for contributors who are willing to regularly review pull requests and eventually become core maintainers. +* **Core Maintainer**: Get permissions in the repository, and hence they are able to merge pull requests.Their responsibility is to perform pull request reviews on a regular basis and to bring pull requests to closure, either by merging ready pull requests towards weekly releases ( branch) or by closing pull requests that are not ready for merge because of submitter inaction after an extended period of time. +* **Pull Request Assignee**: Core maintainers make a commitment to bringing a pull request to closure by becoming an Assignee. They are also responsible to monitor the weekly release status and to perform triage of critical issues. +* **Release Team Member**: Responsible for Websoft9 weekly and LTS releases + +## Pull request review process + +## Pull request Merge process + +## Issue triage + +## Release process + +## Tools + +## Communication diff --git a/appmanage_new/docs/architecture.md b/appmanage_new/docs/architecture.md new file mode 100644 index 00000000..8b8183a3 --- /dev/null +++ b/appmanage_new/docs/architecture.md @@ -0,0 +1,24 @@ +## Architecture + +Websoft9 is very simple [architecture](https://www.canva.cn/design/DAFpI9loqzQ/hI_2vrtfoK7zJwauhJzipQ/view?utm_content=DAFpI9loqzQ&utm_campaign=designshare&utm_medium=link&utm_source=publishsharelink) which used [Redhat Cockpit ](https://cockpit-project.org/) for web framework and [Docker](https://www.docker.com/) for running [application](https://github.com/Websoft9/docker-library). + +The benefits of this architecture means you don't have to learn new technology stacks or worry about the lack of maintenance this project. + +![Alt text](image/archi.png) + + +What we do is integrating below stacks's API or interfaces to Cockpit console by [Cockpit packages (Also known as plugin)](https://cockpit-project.org/guide/latest/packages.html) : + +- [Nginx Proxy Manager](https://nginxproxymanager.com/): A web-based Nginx management +- [Portainer](https://www.portainer.io/): Powerful container management for DevSecOps +- [Duplicati](https://www.duplicati.com/): Backup software to store encrypted backups online +- [Redis](https://redis.io/): The open source, in-memory data store +- [Appmanage](https://github.com/Websoft9/websoft9/tree/main/appmanage): API for create and manage docker compose based application powered by Websoft9 +- [websoft9-plugins](https://github.com/websoft9?q=plugin&type=all&language=&sort=): Cockpit packages powered by Websoft9 + +As Websoft9 is a complete product, we also offer: + +* API +* CLI + +And Websoft9 is more attractive to users is [200+ application templates](https://github.com/Websoft9/docker-library). \ No newline at end of file diff --git a/appmanage_new/docs/developer.md b/appmanage_new/docs/developer.md new file mode 100644 index 00000000..cc73f6bf --- /dev/null +++ b/appmanage_new/docs/developer.md @@ -0,0 +1,24 @@ +# Developer Guide + + + + +## Release + + + +#### 制品库自动化 + +- 插件制品管理:开发人员开发测试完成后,修改插件版本,触发 Action 构建 Github packages 制品 +- docker-libaray 库制品管理:开发人员测试完成后,修改 library 版本,触发 Action 构建 Github packages 制品 +- websoft9 制品管理:开发人员修改 appmanage 源码或微服务 docker-compose 测试完成后,修改 微服务 版本,触发 Action 构建 Dockerhub 镜像制品以及后台微服务 Github packages 制品 + +> Portainer,redis,nginxproxymanager 使用外部 dockerhub 镜像 + +### 自动化测试 + +当各个制品更新后,项目管理者修改 version_test.json 对应的组件的版本,构建 Action 触发自动化系统测试。 +自动化测试失败,通知各开发人员,删除制品,修改后重新生成制品。 +自动化测试成功,同步 version_test.json 到 version.json, 新制品正式发布。 + + diff --git a/appmanage_new/docs/image/archi.png b/appmanage_new/docs/image/archi.png new file mode 100644 index 0000000000000000000000000000000000000000..ae4a1c4d9d27a4216209d856e05024f25cd6b08b GIT binary patch literal 74546 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�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 U2CehiOgMtMvGvtDBlq~f0X7)L^#A|> literal 0 HcmV?d00001 diff --git a/appmanage_new/docs/notes/PRD.md b/appmanage_new/docs/notes/PRD.md new file mode 100644 index 00000000..31532f9b --- /dev/null +++ b/appmanage_new/docs/notes/PRD.md @@ -0,0 +1,151 @@ +# 需求 + +从两个主线理解 stackhub 的需求: + +- 应用生命周期管理:寻找、安装、发布、停止、卸载、升级等软件全生命周期。 +- 基础设施运维管理:安全、存储、文件、容器、监控等系统管理 + +## 应用生命周期 + +### 业务需求 + +#### 寻找 + +用户可以通过两个入口寻找应用: + +- 应用商店:采用一级分类的方式展现应用,并支持**筛选+搜索**的方式以便于用户检索 +- Docker 镜像仓库:检索 Docker 镜像仓库,找到对应的应用 + +#### 安装 + +- 用户自主安装应用,后端按顺序依次启动目标应用 +- 启动应用之前先进行资源约束判断,不符合条件的目标应用不予安装 +- 与安装有关的状态:安装中、运行中、安装失败、反复重启、已停止 + +#### 发布 + +- 以域名或端口的方式,将运行中的应用发布出去,供外部用户访问。 +- 自助设置 HTTPS,上传或更新证书 + +#### 停止 + +将应用服务停止 + +#### 卸载 + +卸载应用并删除数据 + +#### 升级 + +升级应用,如果升级失败会自动回滚到升级之前的状态 + +#### 恢复 + +在已有的完整备份的基础,恢复应用。 + +可能存在两种情况: + +- 覆盖现有应用 +- 恢复成一个新的应用 + +#### 克隆 + +克隆一个已经存在的应用,命名为新应用 + +### 技术需求 + +#### 模板编排 + +应用的底层编排 100% 以 Docker Compose 语法作为编排语言 + +#### 多语言 + +- 前端支持 i18n +- 后端接口支持英文 + +#### 用户管理 + +- 支持多个用户,用户角色分为普通用户和管理员用户 +- 普通用户可以创建和管理自己的应用,不可以删除他人的应用 + +#### UI 自适应 + +UI 自适应各种屏幕尺寸 + +#### 2FA + +引入一种双重登录策略 + +#### 商店基础设置 + +- 商店 Logo 可自定义 +- 语言、时区可选 +- 绑定域名 +- SMTP 信息填写 + +#### 通知 + +- SMTP 邮件通知 + +#### 商店更新 + +商店支持在线更新提示和在线更新 + +#### API + +支持生成 API Tokens + +#### CLI + +基于 API 的 CLI + +#### 仓库管理 + +默认以 DockerHub 作为镜像仓库,支持自建仓库并同步 DockerHub 镜像 + +#### 安装程序 + +一键自动化安装程序,类似: + +``` +curl https://websoft9.github.io/stackhub/install/install.sh | bash +``` + +主要步骤包括: + +1. Check 硬件、操作系统、cpu 架构 +2. 安装依赖包 +3. 安装 docker +4. 下载各源码包 +5. 启动个源码对应服务 + +## 基础设施运维 + +### SSH 终端 + +Web-Based SSH 终端 + +### 文件管理器 + +Web-Based 文件管理器 + +### 存储管理 + +- 支持接入第三方对象存储 + +### 备份 + +备份完整的应用数据: + +- 自定义备份时间区间 +- 自动备份可取消 +- 备份可以管理:删除、下载等 + +### 容器管理 + +可视化的容器管理,包括:拉镜像、创建/删除/停止容器、SSH 进入容器、向容器上传文件等 + +### 系统监控 + +- 监控容器的 CPU,内存和存储消耗情况 +- 监控系统的 CPU,内存和存储消耗情况 diff --git a/appmanage_new/docs/notes/research.md b/appmanage_new/docs/notes/research.md new file mode 100644 index 00000000..a8bf8db3 --- /dev/null +++ b/appmanage_new/docs/notes/research.md @@ -0,0 +1,57 @@ +# 概述 + +## 需求草稿 + +| | Cloudron | [casaos](https://www.casaos.io/) | umbrel | runtipi | +| -------------- | -------- | -------------------------------------------------------- | ------------ | ------- | +| 应用编排 | | 单一镜像 | | 多镜像,compose 编排 | +| 市场应用来源 | | 官方+社区 | 官方+社区 | | +| 一键安装程度 | | 不需任何配置 | 不需任何配置 | | +| 应用访问方式 | | 端口 | 端口 | | +| 自定义安装应用 | | Y | N | N | +| Web 管理容器 | | Y | N | | +| 默认镜像仓库 | | DockerHub | | | +| 自适应 | | Y | Y | | +| 多语言 | | Y | N | | +| 用户管理 | | 单一用户 | 单一用户 | | +| 自带应用 | | 文件,服务器终端,容器终端,监控,日志 | 监控,日志 | | +| 应用管理 | | 完整容器参数设置,克隆,绑定域名?备份?证书? | 无 | | +| 应用更新 | | N | | | +| 后端语言 | | Go | | | +| API | | HTTP API | | | +| 前端 | | vue.js | | | +| CLI | | Y | | | +| HTTP 服务器 | | 无,端口访问应用 | | traefik | +| 公共数据库 | | 无 | | | +| 开发文档 | | [wiki](https://wiki.casaos.io/en/contribute/development) | | | +| 2FA | | N | Y | | +| 安装方式 | | 服务器安装 | 容器安装 | | +| 商店更新 | | N | Y | Y | +| 商店绑定域名 | Y | N | N | | +| DNS服务 | Y | N | | | + +* 应用自动分配4级域名后,如何再 CNAME 二级域名? + +### casaos 架构分析 + +#### 安装脚本 + +1. Check硬件、操作系统、cpu架构 +2. 安装依赖包 +3. 安装docker +4. 下载各源码包 +5. 启动个源码对应服务 + +#### 源码解析 + +| 运行时项目 | 对应项目源码 | 说明 | +| -------------- | -------- | -------------------------------------------------------- | +| casaos | CasaOS | 每隔5秒通过websocekt推送内存/CPU/网络等系统信息;提供ssh登录操作的http接口;提供"sys", "port", "file", "folder", "batch", "image", "samba", "notify"这些http接口的访问| +| casaos-message-bus | CasaOS-MessageBus | 类似一个MQ提供消息的发布/订阅 | +| casaos-local-storage | CasaOS-LocalStorage | 每隔5S统计磁盘/USB信息,提供监控信息;提供http接口访问disk/usb/storage信息 | +| casaos-user-service | CasaOS-UserService | 通过http server提供用户管理的接口 | +| casaos-app-management | CasaOS-AppManagement | 使用CasaOS-AppStore中App的元数据;提供所有appList的分类/列表/详细信息;通过docker来管理app,提供安装/启动/关闭/重启/日志查看等相关接口;docker-compose管理(V2);| +| casaos-gateway | CasaOS-Gateway | 提供Gateway自身管理接口,比如切换Gateway的port的接口,查看所有路由的接口;提供CasaOS-UI的静态资源访问服务;根据请求的PATH将请求代理转发至其它模块 | +| casaos-cli | CasaOS-CLI | 通过命令行的方式来调用CasaOS-Gateway的接口,该模块未完全实现,实现了部分命令 | +| linux-all-casaos | CasaOS-UI | VUE2,CasaOS的Web源码,编译后的html/js/image/css等由CasaOS-Gateway提供访问入口,所有API接口指向CasaOS-Gateway | +| - | CasaOS-Common | Common structs and functions for CasaOS | diff --git a/appmanage_new/docs/notes/软件工厂.md b/appmanage_new/docs/notes/软件工厂.md new file mode 100644 index 00000000..3dcb17c3 --- /dev/null +++ b/appmanage_new/docs/notes/软件工厂.md @@ -0,0 +1,37 @@ +# 软件工厂 + +由 Websoft9 自主研发的面向高校的【软件工厂】解决方案,学生和老师可以自由使用镜像库用于教学。 + +## 商业需求 + +高校老师和学生在教学中需要使用大量的开源软件作为教学的载体,以及通过使用开源软件学习实战的经验,打开商业化软件领域的大门。 +目前,老师和学生受制于眼界以及技术原因,无法很方便的搭建和使用各种开源软件,大大的制约了教学的发展。 + +我们目前的方案只需要加以【盒子化】即可满足用户的需要。 + +## 业务模式 + +对我们既有的方案进行盒子化之后,通过如下方式盈利: + +- 售卖软件解决方案以及技术支持 +- 云资源分成 +- 镜像按小时付费 +- 知识库付费 +- 课程合作付费 + +## 功能需求 + +盒子化的解决方案包括: + +### 业务功能 + +- 可以一键使用的软件库(提供 300+场景方案) +- 可以在线使用的工具库(基于 Web 的工具库,学生在上课中无需安装大量的客户端工具即可完成任务) +- 可以管理教学过程的慕课系统 + +### 系统功能 + +- 账号管理 +- 日志管理 +- 安全管理 +- 资源消耗管理 diff --git a/appmanage_new/docs/plugin-developer.md b/appmanage_new/docs/plugin-developer.md new file mode 100644 index 00000000..6d3a3766 --- /dev/null +++ b/appmanage_new/docs/plugin-developer.md @@ -0,0 +1,8 @@ +# Developer Guide + +## Mulitiple language + +Below points you should know if you want to tranlate: + +- Every plugin's po.zh_CN.js can be used for other Cockpit plugin +- po.zh_CN.js.gz at base1 is the system language file diff --git a/appmanage_new/docs/recruit.md b/appmanage_new/docs/recruit.md new file mode 100644 index 00000000..9f808c9f --- /dev/null +++ b/appmanage_new/docs/recruit.md @@ -0,0 +1,29 @@ +# recruit + +In order to optimize the app management architecture and code specifications, and perform daily maintenance on new features and bugs, Websoft9 recruits a senior Python development expert. + +## Requirements + +1. Proficient in Python and have architectural experience in Python web projects + +2. Have experience in developing distributed (caching, message middleware) + +3. Familiar with Docker and other container technologies + +4. Love coding and willing to continuously optimize code at work + +5. Strong document reading and understanding skills as well as document writing experience + +## Job Description + +1. Complete additional features and modify bugs for existing projects + +2. Provide reasons and solutions for optimizing the project architecture and API methods + +## Work form + +Remote, must complete 40 hours of work per month + +## Remuneration and payment + +Pay 4000 yuan before the 10th of each month diff --git a/appmanage_new/docs/user.md b/appmanage_new/docs/user.md new file mode 100644 index 00000000..dbd2491c --- /dev/null +++ b/appmanage_new/docs/user.md @@ -0,0 +1,18 @@ +# User Guide + +## FAQ + +#### user can not sudo? + +``` +# add user to sudo/admin group (select one command) +usermod -aG wheel username +usermod -aG sudo username + +# sudo not need to input password + +``` + +#### Can not login when I reinstall my Instance? + +Need to clear all cookie at you browser \ No newline at end of file diff --git a/appmanage_new/main.py b/appmanage_new/main.py new file mode 100644 index 00000000..24789c67 --- /dev/null +++ b/appmanage_new/main.py @@ -0,0 +1,7 @@ +from fastapi import FastAPI +from fastapi.routing import APIRouter +from api.v1 import main as v1_router + +app = FastAPI() + +app.include_router(v1_router.router, prefix="/api/v1") diff --git a/appmanage_new/requirements.txt b/appmanage_new/requirements.txt new file mode 100644 index 00000000..82090dbd --- /dev/null +++ b/appmanage_new/requirements.txt @@ -0,0 +1,10 @@ +fastapi==0.98.0 +uvicorn +rq +apscheduler +docker +psutil +gunicorn +python-dotenv +sqlalchemy +databases[sqlite] \ No newline at end of file diff --git a/appmanage_new/tests/README.md b/appmanage_new/tests/README.md new file mode 100644 index 00000000..e69de29b