diff --git a/defaultenv b/defaultenv deleted file mode 100644 index 5996df5..0000000 --- a/defaultenv +++ /dev/null @@ -1,6 +0,0 @@ -DOCKER_INIPATH = docker.ini -NGINX_INIPATH = nginx.ini - -DOCKER_COMPOSE_OUTPATH = docker-compose.yml -INNER_NGINX_OUTPATH = inner-nginx.conf -OUTER_NGINX_OUTPATH = outer-nginx.conf \ No newline at end of file diff --git a/main.py b/main.py index eab8178..77e5045 100644 --- a/main.py +++ b/main.py @@ -1,116 +1,141 @@ """ -this is the main file +main """ -import argparse -import os import subprocess +import logging +import argparse from pathlib import Path -from dotenv import load_dotenv -from src.jinja2_render import render_dataclass -from src.config_reader import nginx_config, docker_config, docker2nginx +import yaml -load_dotenv() +from src.logger import get_logger +from src.nginx import NginxConfig, NginxConfigurator +logger = get_logger(__name__) -def run_command(command): - "run a command and use stdout" +# 配置常量 +FILE_PATH = Path(__file__).parent +DOCKER_COMPOSE_FILE = Path(FILE_PATH, "docker-compose.yml") +NETWORK_NAME = "nginx-net" + +def validate_docker_network(): + """检查Docker网络是否存在""" try: - result = subprocess.run(command, shell=True, check=True, - stdout=subprocess.PIPE, stderr=subprocess.PIPE) - print(result.stdout.decode()) - return True - except subprocess.CalledProcessError as e: - print(f"Command failed with error: {e.stderr.decode()}") + result = subprocess.run( + ["docker", "network", "inspect", NETWORK_NAME], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True + ) + return result.returncode == 0 + except subprocess.CalledProcessError: + logger.error("Docker网络 %s 不存在", NETWORK_NAME) + return False + except Exception as e: + logger.error("网络检查异常: %s", str(e), exc_info=True) return False -def docker_compose_action(config_path, output_path): - "读取并渲染配置文件" - (auto_config, other) = docker_config(config_path) - render_dataclass(auto_config, Path('./src/docker-compose.j2'), output_path, other=other) +def parse_compose_config(file_path: Path) -> list[NginxConfig]: + """解析Docker Compose文件""" + try: + with open(file_path, 'r', encoding='utf-8') as f: + config = yaml.safe_load(f) -def inner_nginx_action(config_path, output_path): - "读取并渲染配置文件" - (auto_config, other) = docker2nginx(config_path) - render_dataclass(auto_config, Path('./src/nginx-conf.j2'), output_path, other=other) + services:dict = config['services'] -def outer_nginx_action(config_path, output_path): - "读取并渲染配置文件" - (auto_config, other) = nginx_config(config_path) - render_dataclass(auto_config, Path('./src/nginx-conf.j2'), output_path, other=other) + # networks = config['networks'][NETWORK_NAME] + # if not networks['external']: + # raise ValueError("network 必须为external") -def nginx_reload(): - """reload nginx config""" - res = True - if res: - res = run_command("docker exec -it nginx nginx -t") - if res: - res = run_command("docker exec -it nginx nginx -s reload") + nginx_configs:list[NginxConfig] = [] -def docker_compose_restart(): - "docker compose restart" - run_command("docker compose restart") + for name, service in services.items(): + try: + labels = service.get('labels') + if labels is not None: + n = labels.get('nginx_prefix_name') + if n is not None: + name = n + conf = NginxConfig( + name = f"{name}.zzyxyz.com", + host = service['container_name'], + port = service['expose'][0] + ) + if len(service['expose']) != 1: + raise ValueError("expose 必须为一项") + networks = service.get('networks') + # TODO + if networks is None or networks[0] != NETWORK_NAME: + raise ValueError(f"networks 需要设置为{NETWORK_NAME}") + # required_fields = ['com.lingma.nginx.domain', 'com.lingma.nginx.port'] + # for field in required_fields: + # if field not in labels: + # raise ValueError(f"服务 {name} 缺少必要标签: {field}") -def docker_ps(): - "list running docker containers by my format" - cmd = "docker ps --format "\ - "\"table {{.Names}}\t{{.Image}}\t{{.Command}}\t{{.CreatedAt}}\t{{.Status}}\"" - run_command(cmd) + nginx_configs.append(conf) + except Exception as e: + logger.error("解析服务 %s 配置失败: %s{str(e)}", name, str(e)) + continue + + return nginx_configs + + except FileNotFoundError: + logger.error("文件不存在: %s", file_path) + return False + except PermissionError: + logger.error("无权限访问文件: %s", file_path) + return False + except yaml.YAMLError as e: + logger.error("YAML解析错误: %s", str(e)) + return False + except Exception as e: + logger.error("未知错误: %s", str(e), exc_info=True) + return False def main(): - "main function" - parser = argparse.ArgumentParser(description="Automate Docker and Nginx configuration.") - parser.add_argument('-a', '--all', action='store_true', default=True, - help='Perform all actions') - parser.add_argument('-c', '--docker-compose', action='store_true', - help='Generate Docker Compose') - parser.add_argument('-i', '--inner-nginx', action='store_true', - help='Generate Inner Nginx (docker network)') - parser.add_argument('-o', '--outer-nginx', action='store_true', - help='Generate Outer Nginx (host machine)') - parser.add_argument('-r', '--nginx-reload', action='store_true', - help='Reload Nginx configuration') - parser.add_argument('-s', '--docker-compose-restart', action='store_true', - help='Restart Docker Compose services') - parser.add_argument('-p', '--docker-ps', action='store_true', - help='List running Docker containers') - parser.add_argument('--docker-config-path', type=Path, - default=Path(os.getenv('DOCKER_INIPATH', 'docker.ini')), - help='Path to the Docker INI configuration file') - parser.add_argument('--docker-output-path', type=Path, - default=Path(os.getenv('DOCKER_COMPOSE_OUTPATH', 'docker-compose.conf')), - help='Path to the output Docker Compose configuration file') - parser.add_argument('--inner-config-path', type=Path, - default=Path(os.getenv('DOCKER_INIPATH', 'docker.ini')), - help='Path to the Docker INI configuration file for inner Nginx') - parser.add_argument('--inner-output-path', type=Path, - default=Path(os.getenv('INNER_NGINX_OUTPATH', 'inner-nginx.conf')), - help='Path to the output Inner Nginx configuration file') - parser.add_argument('--outer-config-path', type=Path, - default=Path(os.getenv('NGINX_INIPATH', 'nginx.ini')), - help='Path to the Nginx INI configuration file for outer Nginx') - parser.add_argument('--outer-output-path', type=Path, - default=Path(os.getenv('OUTER_NGINX_OUTPATH', 'outer-nginx.conf')), - help='Path to the output Outer Nginx configuration file') - + """main""" + parser = argparse.ArgumentParser(description="Nginx自动化配置工具") + parser.add_argument('--compose-file', type=str, default=DOCKER_COMPOSE_FILE, + help="Docker Compose文件路径") + parser.add_argument('--debug', action='store_true', + help="启用调试模式") args = parser.parse_args() - if args.all: - docker_compose_action(args.docker_config_path, args.docker_output_path) - inner_nginx_action(args.inner_config_path, args.inner_output_path) - outer_nginx_action(args.outer_config_path, args.outer_output_path) - nginx_reload() - else: - if args.docker_compose: - docker_compose_action(args.docker_config_path, args.docker_output_path) - if args.inner_nginx: - inner_nginx_action(args.inner_config_path, args.inner_output_path) - if args.outer_nginx: - outer_nginx_action(args.outer_config_path, args.outer_output_path) - if args.nginx_reload: - nginx_reload() - if args.docker_compose_restart: - docker_compose_restart() - if args.docker_ps: - docker_ps() + if args.debug: + logger.setLevel(logging.DEBUG) + logger.debug("调试模式已启用") + + # 前置检查 + if not validate_docker_network(): + logger.error("请先创建Docker网络: docker network create %s", NETWORK_NAME) + return + + # 配置生成 + try: + logger.info("开始解析Docker Compose配置...") + compose_files:str = args.compose_file + configs = [] + for file in compose_files.split(','): + logger.info("parse %s", file) + config = parse_compose_config(file) + configs.extend(config) + logger.info("发现 %d 个需要代理的服务", len(configs)) + + conf = NginxConfigurator() + + conf.backup_config() + + ret = conf.gen_and_save_config(configs) + if not ret: + return + + ret = conf.safe_reload() + if ret: + logger.info("全流程完成") + else: + logger.error("流程未完成,请检查错误日志") + + except Exception as e: + logger.critical("主流程异常终止: %s", str(e), exc_info=args.debug) + if __name__ == "__main__": main() diff --git a/requirements.txt b/requirements.txt index 539b7db..69c619e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ python-dotenv -jinja2 +pyyaml diff --git a/src/config_reader.py b/src/config_reader.py deleted file mode 100644 index 011ad8d..0000000 --- a/src/config_reader.py +++ /dev/null @@ -1,201 +0,0 @@ -""" -config_reader.py -""" -import configparser -from dataclasses import dataclass -from typing import Optional, List, Tuple -from pathlib import Path - -def read_config(file_path: Path) -> configparser.ConfigParser: - """ - 读取并解析指定路径的 INI 配置文件。 - - 参数: - file_path (Path): 配置文件的路径。 - - 返回: - configparser.ConfigParser: 解析后的配置对象。 - """ - config = configparser.ConfigParser() - config.read(file_path, encoding='utf-8') - return config - -def config_get(config: configparser.ConfigParser, section: str, key: str, - default: str = None) -> str: - """ - 从配置文件中获取指定键的值,支持默认值。 - - 如果在指定的 section 中找不到键,则尝试从默认 section 中获取。 - - 如果最终值为None则会抛出异常 - - 参数: - config (configparser.ConfigParser): 配置对象。 - section (str): 要查询的 section 名称。 - key (str): 要查询的键名称。 - default (str): 最终配置文件没有时硬编码的默认值。 - - 返回: - str: 查询到的值或默认值。 - """ - res = config.get(section, key, fallback= - config.get(config.default_section, key, fallback=default)) - # if is_raise and res is None: - # raise ValueError(f"Key '{key}' not found in section '{section}' or "\ - # "default section or default value.") - return res - -@dataclass -class NginxConfig: - """ - 表示一个 Nginx 配置项的数据类。 - - 属性: - name (str): 配置项名称,默认为 "unknown"。 - host (str): 主机地址,默认为 "localhost"。 - port (int): 端口号,默认为 80。 - output_path (Optional[str]): 输出路径,默认为 None。 - tcp_nopush (str): TCP 推送选项,默认为 "off"。 - sendfile (str): Sendfile 选项,默认为 "off"。 - client_max_body_size (str): 客户端最大请求体大小,默认为 "100m"。 - proxy_connect_timeout (str): 代理连接超时时间,默认为 "60s"。 - proxy_send_timeout (str): 代理发送超时时间,默认为 "60s"。 - proxy_read_timeout (str): 代理读取超时时间,默认为 "60s"。 - web_socket_proxy (bool): 是否启用 WebSocket 代理,默认为 False。 - """ - name: str = "unknown" - host: str = "localhost" - port: int = 80 - output_path: Optional[str] = None - tcp_nopush: str = "off" - sendfile: str = "off" - client_max_body_size: str = "100m" - proxy_connect_timeout: str = "60s" - proxy_send_timeout: str = "60s" - proxy_read_timeout: str = "60s" - web_socket_proxy: bool = False - -def nginx_config(file_path: Path) -> Tuple[List[NginxConfig], dict]: - """ - 读取 INI 配置文件并解析为 NginxConfig 对象列表。 - - 参数: - file_path (Path): 配置文件的路径。 - - 返回: - Tuple[List[NginxConfig], dict]: 包含 NginxConfig 对象的列表和其他相关信息的字典。 - """ - config = read_config(file_path) - configs = [] - - g_section = config.default_section - def _config_get(name, default = None): - return config_get(config, g_section, name, default) - - for section in config.sections(): - g_section = section - configs.append(NginxConfig( - name=section, - host=_config_get('host'), - port=int(_config_get('port')), # 将端口转换为整数类型 - web_socket_proxy=_config_get('web_socket_proxy') == 'True', - client_max_body_size=_config_get('client_max_body_size', - NginxConfig.client_max_body_size) - )) - return (configs, {}) - -@dataclass -class DockerComposeConfig: - """ - 表示一个 Docker Compose 配置项的数据类。 - - 属性: - name (str): 服务名称。 - container_name (str): 容器名称。 - image (str): 使用的镜像。 - ports (Optional[list[str]]): 映射的端口列表,默认为 None。 - volumes (Optional[list[str]]): 挂载的卷列表,默认为 None。 - environment (Optional[list[str]]): 环境变量列表,默认为 None。 - extra_hosts (Optional[list[str]]): 额外的主机映射列表,默认为 None。 - export (Optional[str]): 导出的端口,默认为 None。 - network (Optional[str]): 所属网络,默认为 None。 - """ - name: str - container_name: str - image: str - ports: Optional[List[str]] = None - volumes: Optional[List[str]] = None - environment: Optional[List[str]] = None - extra_hosts: Optional[List[str]] = None - export: Optional[str] = None - network: Optional[str] = None - -def docker_config(file_path: Path) -> Tuple[List[DockerComposeConfig], dict]: - """ - 读取 INI 配置文件并解析为 DockerComposeConfig 对象列表。 - - 参数: - file_path (Path): 配置文件的路径。 - - 返回: - Tuple[List[DockerComposeConfig], dict]: 包含 DockerComposeConfig 对象的列表和其他相关信息的字典。 - """ - config = read_config(file_path) - configs = [] - - g_section = '' - def _config_get(name, default = None): - return config_get(config, g_section, name, default) - - def _get_item(name) -> List[str]: - """将逗号分隔的字符串转换为列表,并去除空格和空字符串。""" - return [i.strip() for i in _config_get(name, '').split(',') if i.strip()] - - network = set() - for section in config.sections(): - g_section = section - configs.append(DockerComposeConfig( - name=section, - container_name=_config_get('container_name'), - image=_config_get('image'), - volumes=_get_item('volumes'), - ports=_get_item('ports'), - environment=_get_item('environment'), - extra_hosts=_get_item('extra_hosts'), - export=_config_get('export'), - network=_config_get('network'), - )) - network.add(_config_get('network')) - return (configs, {'networks': list(network)}) - -def docker2nginx(file_path: Path) -> Tuple[List[NginxConfig], dict]: - """ - 将 Docker Compose 服务配置转换为 Nginx 代理配置。 - - 参数: - file_path (Path): 配置文件的路径。 - - 返回: - Tuple[List[NginxConfig], dict]: 包含 NginxConfig 对象的列表和其他相关信息的字典。 - """ - config = read_config(file_path) - configs = [] - - g_section = '' - def _config_get(name): - return config_get(config, g_section, name) - - def _get_item(name) -> List[str]: - """将逗号分隔的字符串转换为列表,并去除空格和空字符串。""" - return [i.strip() for i in _config_get(name).split(',') if i.strip()] - - for section in config.sections(): - if section == 'nginx': - continue - g_section = section - configs.append(NginxConfig( - name=config.get(section, 'nginx_name', fallback=section), - host=_config_get('container_name'), - port=_config_get('export'), - )) - return (configs, {}) diff --git a/src/docker-compose.j2 b/src/docker-compose.j2 deleted file mode 100644 index f9104ba..0000000 --- a/src/docker-compose.j2 +++ /dev/null @@ -1,55 +0,0 @@ -# docker-compose.j2 -version: '3' - -services: - {%- for config in autoconfigs %} - {{ config.name }}: - container_name: {{ config.container_name }} - image: {{ config.image }} - deploy: - update_config: - parallelism: 1 - delay: 10s - restart_policy: - condition: on-failure - max_attempts: 3 - {%- if config.ports %} - ports: - {%- for port in config.ports %} - - {{ port }} - {%- endfor %} - {%- endif %} - {%- if config.volumes %} - volumes: - {%- for volume in config.volumes %} - - {{ volume }} - {%- endfor %} - {%- endif %} - {%- if config.environment %} - environment: - {%- for env in config.environment %} - - {{ env }} - {%- endfor %} - {%- endif %} - {%- if config.export %} - expose: - - {{ config.export }} - {%- endif %} - {%- if config.extra_hosts %} - extra_hosts: - {%- for host in config.extra_hosts %} - - {{ host }} - {%- endfor %} - {%- endif %} - {%- if config.network %} - networks: - - {{ config.network }} - {%- endif %} - {% endfor %} - -{%- if networks %} -networks: -{%- for network in networks %} - {{ network }}: -{%- endfor %} -{%- endif %} \ No newline at end of file diff --git a/src/jinja2_render.py b/src/jinja2_render.py deleted file mode 100644 index 5a6c655..0000000 --- a/src/jinja2_render.py +++ /dev/null @@ -1,19 +0,0 @@ -""" -jinja2_render.py -""" -from pathlib import Path -from dataclasses import asdict, dataclass -from jinja2 import Environment, FileSystemLoader, Template - -def render_dataclass(configs: list[dataclass], tmp_path: Path, out_path: Path, - verbose :bool = True, other = None) -> str: - "from dataclasses to jinja template" - env = Environment(loader=FileSystemLoader(str(tmp_path.parent), - encoding='utf-8')) - template: Template = env.get_template(str(tmp_path.name)) - data = [asdict(config) for config in configs] - res = template.render({'autoconfigs': data}, **other) - out_path.write_text(res, encoding='utf-8') - if verbose: - print(f"{out_path.absolute()} is rendered") - return res diff --git a/src/logger.py b/src/logger.py new file mode 100644 index 0000000..72db0ff --- /dev/null +++ b/src/logger.py @@ -0,0 +1,34 @@ +""" +统一日志工具 +""" +import logging + +class ColorFormatter(logging.Formatter): + """ + using terminal color to print colorful log + """ + COLORS = { + 'WARNING': '\033[93m', + 'ERROR': '\033[91m', + 'CRITICAL': '\033[91m', + 'INFO': '\033[94m', + 'DEBUG': '\033[92m', + 'ENDC': '\033[0m' + } + + def format(self, record): + color = self.COLORS.get(record.levelname, '') + message = super().format(record) + return f"{color}{message}{self.COLORS['ENDC']}" if color else message + +def get_logger(name: str | None = None): + """ + using ColorFormatter to print colorful log + """ + logger = logging.getLogger(name) + logger.setLevel(logging.INFO) + handler = logging.StreamHandler() + handler.setFormatter(ColorFormatter('[%(asctime)s] %(levelname)s: %(message)s', + datefmt='%Y-%m-%d %H:%M:%S')) + logger.addHandler(handler) + return logger diff --git a/src/nginx-conf.j2 b/src/nginx-conf.j2 deleted file mode 100644 index e85f35c..0000000 --- a/src/nginx-conf.j2 +++ /dev/null @@ -1,28 +0,0 @@ -{%- for config in autoconfigs %} -server { - listen 80; - listen 443 ssl; - server_name {{ config.name }}; - if ($scheme = http) { - return 301 https://$server_name$request_uri; - } - - tcp_nopush {{ config.tcp_nopush }}; - sendfile {{ config.sendfile }}; - client_max_body_size {{ config.client_max_body_size }}; - location / { - proxy_pass http://{{ config.host }}:{{ config.port }}; - proxy_connect_timeout {{ config.proxy_connect_timeout}}; - proxy_send_timeout {{ config.proxy_send_timeout }}; - proxy_read_timeout {{ config.proxy_read_timeout }}; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - {%- if config.web_socket_proxy %} - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - {%- endif %} - } -} -{% endfor %} \ No newline at end of file diff --git a/src/nginx.py b/src/nginx.py new file mode 100644 index 0000000..6d47092 --- /dev/null +++ b/src/nginx.py @@ -0,0 +1,202 @@ +""" +nginx 配置生成工具 +""" +from dataclasses import asdict, dataclass +from datetime import datetime +import logging +from pathlib import Path +import shutil +import subprocess + +from logger import get_logger +logger = get_logger(__name__) + +NGINX_CONF_DIR = Path("nginx/conf/conf.d") +BACKUP_DIR = Path("nginx/conf/backups") +CONTAINER_NAME = "nginx" + +@dataclass +class NginxConfig: + """ + 表示一个 Nginx 配置项的数据类。 + + 属性: + name (str): 配置项名称,默认为 "unknown"。 + host (str): 主机地址,默认为 "localhost"。 + port (int): 端口号,默认为 80。 + output_path (Optional[str]): 输出路径,默认为 None。 + tcp_nopush (str): TCP 推送选项,默认为 "off"。 + sendfile (str): Sendfile 选项,默认为 "off"。 + client_max_body_size (str): 客户端最大请求体大小,默认为 "100m"。 + proxy_connect_timeout (str): 代理连接超时时间,默认为 "60s"。 + proxy_send_timeout (str): 代理发送超时时间,默认为 "60s"。 + proxy_read_timeout (str): 代理读取超时时间,默认为 "60s"。 + web_socket_proxy (bool): 是否启用 WebSocket 代理,默认为 False。 + """ + name: str = "unknown" + host: str = "localhost" + port: int = 80 + output_path: str | None = None + tcp_nopush: str = "off" + sendfile: str = "off" + client_max_body_size: str = "100m" + proxy_connect_timeout: str = "60s" + proxy_send_timeout: str = "60s" + proxy_read_timeout: str = "60s" + web_socket_proxy: bool = False + +@dataclass +class NginxConfigurator: + """ + Nginx配置管理类,封装配置生成、备份、重载等操作 + + 属性: + compose_file (Path): Docker Compose文件路径 + output_dir (Path): 配置文件输出目录 + backup_dir (Path): 配置备份目录 + logger (logging.Logger): 日志记录器 + """ + + output_dir: Path = NGINX_CONF_DIR + backup_dir: Path = BACKUP_DIR + logger: logging.Logger = logger + + def gen_all_config(self, configs: list[NginxConfig]) -> str: + """生成Nginx配置内容""" + return "\n".join([self.gen_once_config(conf) for conf in configs]) + + def gen_once_config(self, conf: NginxConfig) -> str: + """构建单个server配置块""" + template = \ +""" +server {{ + # # 必须添加DNS解析器配置 + # resolver 127.0.0.11 valid=10s; # Docker内置DNS服务 + + # upstream {name}_upstream {{ + # zone upstream_{name} 64k; # 共享内存区 + # server {host}:{port} resolve max_fails=3 fail_timeout=30s; + # server 0.0.0.0:65535 backup; # 占位无效地址 + # }} + + listen 80; + listen 443 ssl; + server_name {name}; + if ($scheme = http) {{ + return 301 https://$server_name$request_uri; + }} + + tcp_nopush {tcp_nopush}; + sendfile {sendfile}; + client_max_body_size {client_max_body_size}; + location / {{ + proxy_pass http://{host}:{port}; + proxy_connect_timeout {proxy_connect_timeout}; + proxy_send_timeout {proxy_send_timeout}; + proxy_read_timeout {proxy_read_timeout}; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + {_web_socket_proxy} + }} +}} +""" + conf_dict = { + "_web_socket_proxy": + """ + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + """.strip() if conf.web_socket_proxy else "", + } + return template.format_map(asdict(conf) | conf_dict) + + def backup_config(self, filename: Path = Path("generated.conf")) -> Path | None: + """执行配置文件备份""" + try: + self.backup_dir.mkdir(exist_ok=True, parents=True) + backup_file = self.backup_dir / f"{filename}.bak.{datetime.now():%Y%m%d-%H%M%S}" + + if not (self.output_dir / filename).exists(): + self.logger.warning("未找到源文件,无法备份") + return None + shutil.copy(self.output_dir / filename, backup_file) + self.logger.info("配置已备份至 %s", backup_file) + return backup_file + except Exception as e: + self.logger.error("备份失败: %s", str(e)) + return None + + def gen_and_save_config(self, configs: list[NginxConfig], + filename: Path = Path("generated.conf")) -> bool: + """生成配置文件并保存到文件里""" + try: + output_file = self.output_dir / filename + output_file.parent.mkdir(exist_ok=True, parents=True) + + with open(output_file, 'w', encoding='utf-8') as f: + res = self.gen_all_config(configs) + f.write(res) + logger.info("配置已写入 %s", output_file) + return True + except Exception as e: + self.logger.error(f"生成文件失败: {str(e)}") + return False + + def safe_reload(self) -> bool: + """安全重载Nginx配置""" + if self.test_config() and self.reload(): + return True + return False + + def test_config(self) -> bool: + """测试Nginx配置""" + result = subprocess.run( + ["docker", "exec", CONTAINER_NAME, "nginx", "-t"], + capture_output=True, stderr=None, stdout=None, check=False + ) + if result.returncode != 0: + self.logger.error("配置测试失败: %s", result.stderr) + return False + else: + return True + + def reload(self) -> bool: + """执行配置重载""" + try: + result = subprocess.run( + ["docker", "exec", CONTAINER_NAME, "nginx", "-s", "reload"], + capture_output=True, stderr=None, stdout=None, check=False + ) + if result.returncode != 0: + self.logger.error("配置重载失败: %s", result.stderr) + return False + else: + self.logger.info("配置重载成功") + return True + except subprocess.CalledProcessError as e: + self.logger.error("重载失败: %s", str(e)) + return False + + # def rollback(self) -> bool: + # """回滚到最近的有效配置""" + # backups = sorted(self.backup_dir.glob("*.bak.*"), + # key=os.path.getmtime, reverse=True) + # if not backups: + # self.logger.error("无可用的备份配置") + # return False + # try: + # shutil.copy(backups[0], self.output_dir / "generated.conf") + # self.logger.warning(f"已回滚到备份 {backups[0].name}") + # return self.safe_reload() + # except Exception as e: + # self.logger.error(f"回滚失败: {str(e)}") + # return False + +if __name__ == '__main__': + config = NginxConfigurator() + config.safe_reload() + # conf = NginxConfig() + # conf.web_socket_proxy = True + # ret = config.gen_config([conf, conf]) + # config.backup_config()