a-cloud-all/.devops/monitor.py

1471 lines
58 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Git 仓库监听器
监听多个 Git 仓库的指定分支检测到新提交时触发部署
"""
import os
import sys
import time
import yaml
import subprocess
2026-01-27 14:39:51 +08:00
import socket
2026-02-03 15:53:59 +08:00
import threading
import json
from datetime import datetime
from pathlib import Path
2026-02-03 15:53:59 +08:00
from flask import Flask, request, jsonify, render_template_string
# 添加当前目录到 Python 路径
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
2026-01-10 15:15:45 +08:00
# 导入自定义模块
from scripts.log import Logger
from scripts import docker, maven, npm
from scripts.init import mysql, redis, nacos
2026-01-27 14:17:44 +08:00
from scripts.dingtalk import DingTalkNotifier
class GitMonitor:
"""Git 仓库监听器"""
def __init__(self, config_path='.devops/config.yaml'):
"""初始化监听器"""
self.config_path = config_path
2026-01-10 15:15:45 +08:00
self.config = None
self.last_commits = {}
2026-01-27 18:14:28 +08:00
self.last_tags = {} # 记录每个仓库的最新 tag
2026-01-10 15:15:45 +08:00
self.global_branch = 'main'
self.project_root = None
self.runtime_path = None
2026-01-27 14:17:44 +08:00
self.dingtalk_notifier = None
2026-01-27 18:14:28 +08:00
self.watch_tags = False
self.tag_pattern = "v*"
2026-01-10 15:15:45 +08:00
# 初始化
self._print_startup_banner()
self._load_config()
self._init_paths()
2026-01-27 14:17:44 +08:00
self._init_dingtalk()
2026-01-10 15:15:45 +08:00
def _print_startup_banner(self):
"""打印启动横幅"""
print("\n")
Logger.separator()
print(" RuoYi Cloud DevOps 自动化部署系统")
Logger.separator()
print(f"启动时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
Logger.separator()
print("\n")
def _load_config(self):
"""加载配置文件"""
2026-01-10 15:15:45 +08:00
Logger.info(f"[步骤 1/3] 读取配置文件: {self.config_path}")
try:
with open(self.config_path, 'r', encoding='utf-8') as f:
self.config = yaml.safe_load(f)
self.global_branch = self.config.get('global_branch', 'main')
2026-01-27 18:14:28 +08:00
# 加载 tag 监听配置
monitor_config = self.config.get('monitor', {})
self.watch_tags = monitor_config.get('watch_tags', False)
self.tag_pattern = monitor_config.get('tag_pattern', 'v*')
2026-01-10 15:15:45 +08:00
# 初始化日志配置
log_config = self.config.get('logging', {})
log_file = log_config.get('file', '.devops/logs/devops.log')
max_size = log_config.get('max_size', 10485760)
Logger.init(log_file=log_file, max_size=max_size)
Logger.info(f"✓ 配置加载成功 - 全局分支: {self.global_branch}")
2026-01-27 18:14:28 +08:00
Logger.info(f"✓ Tag 监听: {'已启用' if self.watch_tags else '未启用'} (模式: {self.tag_pattern})")
2026-01-10 15:15:45 +08:00
Logger.info(f"✓ 日志配置 - 文件: {log_file}, 最大大小: {max_size} 字节")
except Exception as e:
Logger.error(f"配置加载失败: {e}")
sys.exit(1)
def _init_paths(self):
"""初始化路径"""
Logger.info("[步骤 2/3] 初始化路径")
try:
self.project_root = Path(__file__).parent.parent.resolve()
runtime_path = self.config['main_repository']['runtime_path']
if not Path(runtime_path).is_absolute():
self.runtime_path = self.project_root / runtime_path
else:
self.runtime_path = Path(runtime_path)
Logger.info(f"✓ 路径初始化成功")
Logger.info(f" 项目根目录: {self.project_root}")
Logger.info(f" Runtime 目录: {self.runtime_path}")
except Exception as e:
Logger.error(f"路径初始化失败: {e}")
sys.exit(1)
2026-01-27 14:17:44 +08:00
def _init_dingtalk(self):
"""初始化钉钉通知器"""
try:
dingtalk_config = self.config.get('dingtalk', {})
if dingtalk_config.get('enabled', False):
access_token = dingtalk_config.get('access_token')
secret = dingtalk_config.get('secret')
if access_token:
self.dingtalk_notifier = DingTalkNotifier(access_token, secret)
Logger.info("✓ 钉钉通知已启用")
else:
Logger.warning("钉钉通知已启用但未配置 access_token")
else:
Logger.info("钉钉通知未启用")
except Exception as e:
Logger.warning(f"钉钉通知初始化失败: {e}")
2026-01-27 14:39:51 +08:00
def get_server_ip(self):
"""获取服务器IP地址"""
try:
# 创建一个UDP socket连接到外部地址来获取本机IP
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
return ip
except Exception:
try:
# 备用方案获取主机名对应的IP
return socket.gethostbyname(socket.gethostname())
except Exception:
return "unknown"
def get_remote_commit(self, repo_url, branch):
"""获取远程仓库的最新提交 hash"""
try:
cmd = f"git ls-remote {repo_url} refs/heads/{branch}"
result = subprocess.run(
cmd, shell=True, capture_output=True, text=True, timeout=30
)
if result.returncode == 0 and result.stdout:
2026-01-10 15:15:45 +08:00
return result.stdout.split()[0]
return None
except Exception as e:
2026-01-10 15:15:45 +08:00
Logger.error(f"获取远程提交失败 {repo_url}: {e}")
return None
2026-01-27 14:39:51 +08:00
def get_commit_message(self, repo_url, commit_hash):
"""获取指定 commit 的提交消息"""
try:
cmd = f"git ls-remote --heads {repo_url} | grep {commit_hash[:8]}"
result = subprocess.run(
cmd, shell=True, capture_output=True, text=True, timeout=10
)
# 由于 ls-remote 无法获取 commit message我们返回简短的 hash
return f"提交 {commit_hash[:8]}"
except Exception as e:
Logger.error(f"获取提交消息失败: {e}")
return f"提交 {commit_hash[:8]}"
2026-01-27 18:14:28 +08:00
def get_remote_tags(self, repo_url):
"""获取远程仓库的所有 tags"""
try:
cmd = f"git ls-remote --tags {repo_url}"
result = subprocess.run(
cmd, shell=True, capture_output=True, text=True, timeout=30
)
if result.returncode == 0 and result.stdout:
tags = {}
for line in result.stdout.strip().split('\n'):
if line:
parts = line.split()
if len(parts) >= 2:
commit_hash = parts[0]
ref = parts[1]
# 提取 tag 名称,去掉 refs/tags/ 前缀和 ^{} 后缀
if ref.startswith('refs/tags/'):
tag_name = ref.replace('refs/tags/', '')
if not tag_name.endswith('^{}'):
tags[tag_name] = commit_hash
return tags
return {}
except Exception as e:
Logger.error(f"获取远程 tags 失败 {repo_url}: {e}")
return {}
def check_repository(self, repo_config):
"""检查单个仓库是否有新提交"""
repo_name = repo_config['name']
repo_url = repo_config['url']
2026-01-10 14:25:01 +08:00
current_commit = self.get_remote_commit(repo_url, self.global_branch)
if not current_commit:
return False
last_commit = self.last_commits.get(repo_name)
if last_commit is None:
self.last_commits[repo_name] = current_commit
2026-01-10 15:15:45 +08:00
Logger.info(f"初始化 {repo_name} 提交记录: {current_commit[:8]}")
return False
if current_commit != last_commit:
2026-01-10 15:15:45 +08:00
Logger.info(f"检测到 {repo_name} 新提交: {last_commit[:8]} -> {current_commit[:8]}")
self.last_commits[repo_name] = current_commit
return True
return False
2026-01-27 18:14:28 +08:00
def check_repository_tags(self, repo_config):
"""检查单个仓库是否有新 tag"""
repo_name = repo_config['name']
repo_url = repo_config['url']
# 获取远程所有 tags
current_tags = self.get_remote_tags(repo_url)
if current_tags is None or (not current_tags and repo_name not in self.last_tags):
# 首次获取失败或获取失败时发送通知
error_msg = f"获取 {repo_name} 远程 tags 失败"
Logger.error(error_msg)
if self.dingtalk_notifier and repo_name in self.last_tags:
# 只有在之前成功获取过 tags 的情况下才发送通知,避免首次初始化时发送
self.dingtalk_notifier.send_build_failure(
repo_name=repo_name,
branch=self.global_branch,
commit_hash='unknown',
error_msg=error_msg
)
return False, None
# 获取上次记录的 tags
last_tags = self.last_tags.get(repo_name, {})
# 找出新增的 tags
new_tags = []
for tag_name, commit_hash in current_tags.items():
# 检查 tag 是否匹配模式
import fnmatch
if fnmatch.fnmatch(tag_name, self.tag_pattern):
if tag_name not in last_tags:
new_tags.append((tag_name, commit_hash))
# 更新记录
self.last_tags[repo_name] = current_tags
if new_tags:
# 返回最新的 tag
new_tags.sort(reverse=True) # 按名称排序,最新的在前
latest_tag = new_tags[0]
Logger.info(f"检测到 {repo_name} 新 tag: {latest_tag[0]} ({latest_tag[1][:8]})")
return True, latest_tag
return False, None
2026-01-10 15:15:45 +08:00
def update_main_repo(self):
"""更新主仓库和所有子模块"""
repo_path = self.runtime_path / 'a-cloud-all'
main_repo_url = self.config['main_repository']['url']
Logger.separator()
Logger.info("更新主仓库和子模块")
Logger.separator()
# 检查主仓库是否存在
if not (repo_path / '.git').exists():
Logger.info("主仓库不存在,开始克隆...")
self.runtime_path.mkdir(parents=True, exist_ok=True)
cmd = f"git clone --recurse-submodules {main_repo_url} a-cloud-all"
result = subprocess.run(cmd, shell=True, cwd=self.runtime_path, capture_output=True, text=True)
if result.returncode != 0:
Logger.error("克隆主仓库失败")
return False
Logger.info("主仓库克隆成功")
else:
Logger.info("主仓库已存在,更新代码...")
# 切换到主分支
cmd = f"git checkout {self.global_branch}"
subprocess.run(cmd, shell=True, cwd=repo_path, capture_output=True)
# 拉取最新代码
cmd = "git pull"
result = subprocess.run(cmd, shell=True, cwd=repo_path, capture_output=True, text=True)
if result.returncode != 0:
Logger.error("拉取主仓库失败")
return False
2026-01-24 11:34:59 +08:00
# 初始化和更新所有子模块(包括新增的子模块)
cmd = "git submodule update --init --recursive"
result = subprocess.run(cmd, shell=True, cwd=repo_path, capture_output=True, text=True)
if result.returncode != 0:
Logger.error("初始化子模块失败")
return False
# 更新所有子模块到最新代码
2026-01-10 15:15:45 +08:00
cmd = f"git submodule foreach 'git checkout {self.global_branch} && git pull'"
result = subprocess.run(cmd, shell=True, cwd=repo_path, capture_output=True, text=True)
if result.returncode != 0:
Logger.error("更新子模块失败")
return False
Logger.info("主仓库和子模块更新成功")
return True
def init_infrastructure(self):
2026-01-26 14:01:22 +08:00
"""初始化基础设施服务(动态读取配置)"""
2026-01-10 15:15:45 +08:00
repo_path = self.runtime_path / 'a-cloud-all'
2026-01-26 14:01:22 +08:00
# 从配置文件读取基础设施列表
infra_config = self.config.get('infrastructure', [])
for infra in infra_config:
service_name = infra['name']
docker_service = infra['docker_service']
wait_time = infra.get('wait_time', 10)
# 检查是否已初始化
flag_file = repo_path / '.devops' / f'.deployed_{service_name}'
if not flag_file.exists():
Logger.info(f"初始化 {service_name}...")
# 执行预部署命令(如果有)
pre_deploy_commands = infra.get('pre_deploy_commands', [])
if pre_deploy_commands:
Logger.info(f"执行 {service_name} 预部署命令...")
for cmd in pre_deploy_commands:
Logger.info(f"执行命令: {cmd}")
result = subprocess.run(
cmd,
shell=True,
cwd=repo_path,
capture_output=True,
text=True
)
if result.returncode != 0:
Logger.error(f"预部署命令执行失败: {result.stderr}")
return False
# 构建并启动服务
docker_dir = repo_path / 'docker'
# 构建镜像
Logger.info(f"构建 {service_name} 镜像...")
build_cmd = f"docker-compose build --no-cache {docker_service}"
result = subprocess.run(
build_cmd,
shell=True,
cwd=docker_dir,
capture_output=True,
text=True
)
if result.returncode != 0:
Logger.error(f"{service_name} 镜像构建失败: {result.stderr}")
return False
Logger.info(f"{service_name} 镜像构建成功")
# 启动容器
Logger.info(f"启动 {service_name} 容器...")
up_cmd = f"docker-compose up -d {docker_service}"
result = subprocess.run(
up_cmd,
shell=True,
cwd=docker_dir,
capture_output=True,
text=True
)
if result.returncode != 0:
Logger.error(f"{service_name} 容器启动失败: {result.stderr}")
return False
Logger.info(f"{service_name} 容器启动成功")
# 创建标记文件
flag_file.parent.mkdir(parents=True, exist_ok=True)
flag_file.touch()
# 等待服务启动
Logger.info(f"等待 {service_name} 启动({wait_time}秒)...")
time.sleep(wait_time)
2026-01-10 15:15:45 +08:00
return True
2026-01-27 18:14:28 +08:00
def deploy(self, repo_config, tag_name=None):
"""执行部署流程
参数
repo_config: 仓库配置
tag_name: 可选的 tag 名称如果提供则表示这是由 tag 触发的部署
"""
2026-01-10 15:15:45 +08:00
repo_path = self.runtime_path / 'a-cloud-all'
2026-01-27 14:17:44 +08:00
repo_name = repo_config['name']
commit_hash = self.last_commits.get(repo_name, 'unknown')
start_time = time.time()
2026-01-10 15:15:45 +08:00
Logger.separator()
2026-01-27 14:17:44 +08:00
Logger.info(f"开始部署: {repo_name}")
2026-01-27 18:14:28 +08:00
if tag_name:
Logger.info(f"触发方式: Tag ({tag_name})")
else:
Logger.info(f"触发方式: 分支提交")
2026-01-10 15:15:45 +08:00
Logger.separator()
2026-01-10 15:15:45 +08:00
try:
# 1. 更新主仓库和子模块
if not self.update_main_repo():
2026-01-27 17:49:23 +08:00
# 发送 Git 更新失败通知
if self.dingtalk_notifier:
duration = time.time() - start_time
self.dingtalk_notifier.send_build_failure(
repo_name=repo_name,
branch=self.global_branch,
commit_hash=commit_hash,
error_msg="Git 仓库更新失败(主仓库或子模块)"
)
2026-01-10 15:15:45 +08:00
return False
2026-01-27 14:39:51 +08:00
# 获取子仓库的 commit message
commit_message = None
submodule_path = repo_path / repo_config['path']
if submodule_path.exists():
try:
cmd = f"git log -1 --pretty=format:'%s' {commit_hash}"
result = subprocess.run(
cmd, shell=True, cwd=submodule_path,
capture_output=True, text=True, timeout=10
)
if result.returncode == 0 and result.stdout:
commit_message = result.stdout.strip()
Logger.info(f"提交消息: {commit_message}")
except Exception as e:
Logger.warning(f"获取提交消息失败: {e}")
# 获取服务器 IP
server_ip = self.get_server_ip()
# 发送构建开始通知(包含 commit message 和服务器 IP
if self.dingtalk_notifier:
2026-01-27 18:14:28 +08:00
# 如果是 tag 触发,在 commit_message 中添加 tag 信息
display_message = commit_message
if tag_name:
display_message = f"Tag: {tag_name}" + (f" - {commit_message}" if commit_message else "")
2026-01-27 14:39:51 +08:00
self.dingtalk_notifier.send_build_start(
repo_name=repo_name,
2026-01-27 18:14:28 +08:00
branch=self.global_branch if not tag_name else f"tag/{tag_name}",
2026-01-27 14:39:51 +08:00
commit_hash=commit_hash,
2026-01-27 18:14:28 +08:00
commit_message=display_message,
2026-01-27 14:39:51 +08:00
server_ip=server_ip
)
2026-01-10 15:15:45 +08:00
# 2. 初始化基础设施
if not self.init_infrastructure():
2026-01-27 17:49:23 +08:00
# 发送基础设施初始化失败通知
if self.dingtalk_notifier:
duration = time.time() - start_time
self.dingtalk_notifier.send_build_failure(
repo_name=repo_name,
branch=self.global_branch,
commit_hash=commit_hash,
error_msg="基础设施初始化失败MySQL/Redis/Nacos等"
)
2026-01-10 15:15:45 +08:00
return False
# 3. 根据项目类型执行打包
if repo_config['type'] == 'java':
# Maven 打包
work_dir = repo_path
commands = ' && '.join(repo_config['build_commands'])
source_path = repo_config['path'] + '/' + repo_config['artifact_path']
target_dir = repo_path / repo_config['docker_path']
2026-01-27 18:14:28 +08:00
success, error_msg = maven.run_maven(work_dir, commands, source_path, target_dir)
if not success:
2026-01-27 17:42:27 +08:00
# 发送 Maven 构建失败通知
if self.dingtalk_notifier:
duration = time.time() - start_time
self.dingtalk_notifier.send_build_failure(
repo_name=repo_name,
branch=self.global_branch,
commit_hash=commit_hash,
2026-01-27 18:14:28 +08:00
error_msg=f"Maven 打包失败: {error_msg}"
2026-01-27 17:42:27 +08:00
)
2026-01-10 15:15:45 +08:00
return False
elif repo_config['type'] == 'nodejs':
# NPM 打包
work_dir = repo_path / repo_config['path']
commands = ' && '.join(repo_config['build_commands'])
source_dir = repo_config['artifact_path']
target_dir = repo_path / repo_config['docker_path']
2026-01-27 18:14:28 +08:00
success, error_msg = npm.run_npm(work_dir, commands, source_dir, target_dir)
if not success:
2026-01-27 17:42:27 +08:00
# 发送 NPM/PNPM 构建失败通知
if self.dingtalk_notifier:
duration = time.time() - start_time
self.dingtalk_notifier.send_build_failure(
repo_name=repo_name,
branch=self.global_branch,
commit_hash=commit_hash,
2026-01-27 18:14:28 +08:00
error_msg=f"NPM/PNPM 打包失败: {error_msg}"
2026-01-27 17:42:27 +08:00
)
2026-01-10 15:15:45 +08:00
return False
2026-01-31 14:55:24 +08:00
elif repo_config['type'] == 'python':
# Python 项目 - 直接复制源码到 docker 目录
Logger.separator()
Logger.info("开始 Python 项目部署")
Logger.separator()
source_path = repo_path / repo_config['path']
target_dir = repo_path / repo_config['docker_path']
Logger.info(f"源码目录: {source_path}")
Logger.info(f"目标目录: {target_dir}")
try:
# 清空目标目录(保留 .gitkeep 等隐藏文件)
if target_dir.exists():
import shutil
for item in target_dir.iterdir():
if not item.name.startswith('.'):
if item.is_dir():
shutil.rmtree(item)
else:
item.unlink()
Logger.info("清空目标目录完成")
else:
target_dir.mkdir(parents=True, exist_ok=True)
Logger.info("创建目标目录完成")
# 复制源码到目标目录
import shutil
for item in source_path.iterdir():
if item.name in ['.git', '__pycache__', '.pytest_cache', '.venv', 'venv']:
continue # 跳过不需要的目录
target_item = target_dir / item.name
if item.is_dir():
if target_item.exists():
shutil.rmtree(target_item)
shutil.copytree(item, target_item)
else:
shutil.copy2(item, target_item)
Logger.info("源码复制完成")
except Exception as e:
error_msg = f"Python 项目源码复制失败: {str(e)}"
Logger.error(error_msg)
if self.dingtalk_notifier:
duration = time.time() - start_time
self.dingtalk_notifier.send_build_failure(
repo_name=repo_name,
branch=self.global_branch,
commit_hash=commit_hash,
error_msg=error_msg
)
return False
2026-01-10 15:15:45 +08:00
# 4. Docker 部署
compose_dir = repo_path / 'docker'
service_name = repo_config['docker_service']
if not docker.run_docker_compose(compose_dir, service_name):
2026-01-27 14:17:44 +08:00
# 发送构建失败通知
if self.dingtalk_notifier:
duration = time.time() - start_time
self.dingtalk_notifier.send_build_failure(
repo_name=repo_name,
branch=self.global_branch,
commit_hash=commit_hash,
error_msg="Docker 部署失败"
)
2026-01-10 15:15:45 +08:00
return False
2026-01-27 14:17:44 +08:00
# 计算构建耗时
duration = time.time() - start_time
# 发送构建成功通知
if self.dingtalk_notifier:
self.dingtalk_notifier.send_build_success(
repo_name=repo_name,
branch=self.global_branch,
commit_hash=commit_hash,
duration=duration
)
2026-01-10 15:15:45 +08:00
Logger.info(f"部署完成: {repo_config['name']}")
return True
2026-01-10 15:15:45 +08:00
except Exception as e:
2026-01-27 14:17:44 +08:00
# 计算构建耗时
duration = time.time() - start_time
# 发送构建失败通知
if self.dingtalk_notifier:
self.dingtalk_notifier.send_build_failure(
repo_name=repo_name,
branch=self.global_branch,
commit_hash=commit_hash,
error_msg=str(e),
at_all=True
)
2026-01-10 15:15:45 +08:00
Logger.error(f"部署异常: {e}")
return False
def run_once(self):
"""执行一次检查"""
2026-01-10 15:15:45 +08:00
Logger.info("[步骤 3/3] 开始监听分支变化")
repos = self.config.get('repositories', [])
for repo_config in repos:
try:
2026-01-27 18:14:28 +08:00
# 检查分支提交
if self.check_repository(repo_config):
2026-01-27 18:14:28 +08:00
Logger.info(f"触发部署: {repo_config['name']} (分支提交)")
2026-01-10 15:15:45 +08:00
if self.deploy(repo_config):
Logger.info(f"✓ 部署成功: {repo_config['name']}")
else:
2026-01-10 15:15:45 +08:00
Logger.error(f"✗ 部署失败: {repo_config['name']}")
2026-01-27 18:14:28 +08:00
continue # 已经部署,跳过 tag 检查
# 检查 tag如果启用
if self.watch_tags:
has_new_tag, tag_info = self.check_repository_tags(repo_config)
if has_new_tag and tag_info:
tag_name, commit_hash = tag_info
Logger.info(f"触发部署: {repo_config['name']} (新 tag: {tag_name})")
# 更新 last_commits 以便 deploy 方法使用
self.last_commits[repo_config['name']] = commit_hash
if self.deploy(repo_config, tag_name=tag_name):
Logger.info(f"✓ 部署成功: {repo_config['name']}")
else:
Logger.error(f"✗ 部署失败: {repo_config['name']}")
except Exception as e:
2026-01-10 15:15:45 +08:00
Logger.error(f"处理仓库异常 {repo_config['name']}: {e}")
2026-01-27 17:49:23 +08:00
# 发送异常通知
if self.dingtalk_notifier:
commit_hash = self.last_commits.get(repo_config['name'], 'unknown')
self.dingtalk_notifier.send_build_failure(
repo_name=repo_config['name'],
branch=self.global_branch,
commit_hash=commit_hash,
error_msg=f"处理仓库时发生异常: {str(e)}",
at_all=True
)
2026-02-03 15:53:59 +08:00
def deploy_by_name(self, repo_name):
"""通过项目名称触发部署
参数
repo_name: 项目名称
返回
(success, message): 成功标志和消息
"""
repos = self.config.get('repositories', [])
# 查找匹配的仓库配置
repo_config = None
for repo in repos:
if repo['name'] == repo_name:
repo_config = repo
break
if not repo_config:
return False, f"未找到项目: {repo_name}"
Logger.info(f"HTTP触发部署: {repo_name}")
# 获取最新的commit hash
repo_url = repo_config['url']
current_commit = self.get_remote_commit(repo_url, self.global_branch)
if current_commit:
self.last_commits[repo_name] = current_commit
# 执行部署
success = self.deploy(repo_config)
if success:
return True, f"项目 {repo_name} 部署成功"
else:
return False, f"项目 {repo_name} 部署失败"
def get_all_projects(self):
"""获取所有可部署的项目列表
返回
项目列表每个项目包含 name, type, docker_service
"""
repos = self.config.get('repositories', [])
projects = []
for repo in repos:
projects.append({
'name': repo['name'],
'type': repo['type'],
'docker_service': repo['docker_service']
})
return projects
def run(self):
"""持续监听运行"""
poll_interval = self.config['monitor']['poll_interval']
2026-01-10 15:15:45 +08:00
Logger.info(f"开始持续监听,轮询间隔: {poll_interval}")
Logger.info("按 Ctrl+C 停止监听\n")
try:
while True:
self.run_once()
time.sleep(poll_interval)
except KeyboardInterrupt:
2026-01-10 15:15:45 +08:00
Logger.info("\n收到停止信号,退出监听")
except Exception as e:
2026-01-10 15:15:45 +08:00
Logger.error(f"监听异常: {e}")
2026-02-03 15:53:59 +08:00
class DeploymentServer:
"""HTTP部署服务器"""
def __init__(self, monitor, port=9999):
"""初始化HTTP服务器
参数
monitor: GitMonitor实例
port: HTTP服务器端口默认9999
"""
self.monitor = monitor
self.port = port
self.app = Flask(__name__)
self._setup_routes()
2026-02-03 16:18:08 +08:00
def get_all_containers(self):
"""获取所有Docker容器列表"""
try:
cmd = "docker ps --format '{{.ID}}|{{.Names}}|{{.Status}}|{{.Image}}'"
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=10)
if result.returncode != 0:
return []
containers = []
for line in result.stdout.strip().split('\n'):
if line:
parts = line.split('|')
if len(parts) >= 4:
containers.append({
'id': parts[0],
'name': parts[1],
'status': parts[2],
'image': parts[3]
})
return containers
except Exception as e:
Logger.error(f"获取容器列表失败: {e}")
return []
2026-02-03 15:53:59 +08:00
def _setup_routes(self):
"""设置路由"""
@self.app.route('/')
def index():
"""根路径 - 显示操作说明"""
projects = self.monitor.get_all_projects()
html = '''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>DevOps 部署系统</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 1200px;
margin: 50px auto;
padding: 20px;
background-color: #f5f5f5;
}
h1 {
color: #333;
text-align: center;
}
.info {
background-color: #e3f2fd;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
}
table {
width: 100%;
border-collapse: collapse;
background-color: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background-color: #1976d2;
color: white;
}
tr:hover {
background-color: #f5f5f5;
}
.deploy-link {
color: #1976d2;
text-decoration: none;
font-weight: bold;
}
.deploy-link:hover {
text-decoration: underline;
}
.type-badge {
display: inline-block;
padding: 3px 8px;
border-radius: 3px;
font-size: 12px;
font-weight: bold;
}
.type-java {
background-color: #ff9800;
color: white;
}
.type-nodejs {
background-color: #4caf50;
color: white;
}
.type-python {
background-color: #2196f3;
color: white;
}
</style>
</head>
<body>
<h1>🚀 RuoYi Cloud DevOps 部署系统</h1>
<div class="info">
<h3>📖 使用说明</h3>
<p><strong>触发部署</strong>点击下方表格中的"部署链接"或直接访问 <code>http://IP:9999/项目名称</code></p>
<p><strong>示例</strong><code>http://IP:9999/tuoheng-device</code> 触发 tuoheng-device 项目部署</p>
2026-02-03 16:18:08 +08:00
<p><strong>查看日志</strong><a href="/logs" style="color: #1976d2; font-weight: bold;">点击这里查看部署日志</a> | <a href="/containers" style="color: #1976d2; font-weight: bold;">查看容器日志</a></p>
2026-02-03 15:53:59 +08:00
</div>
<h2>📦 可部署项目列表</h2>
<table>
<thead>
<tr>
<th>项目名称</th>
<th>类型</th>
<th>Docker服务</th>
<th>部署链接</th>
</tr>
</thead>
<tbody>
'''
for project in projects:
type_class = f"type-{project['type']}"
html += f'''
<tr>
<td>{project['name']}</td>
<td><span class="{type_class} type-badge">{project['type'].upper()}</span></td>
<td>{project['docker_service']}</td>
<td><a href="/{project['name']}" class="deploy-link">/{project['name']}</a></td>
</tr>
'''
html += '''
</tbody>
</table>
</body>
</html>
'''
return html
@self.app.route('/logs')
def view_logs():
"""查看日志页面"""
html = '''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>DevOps 日志查看</title>
<style>
body {
font-family: 'Courier New', monospace;
margin: 0;
padding: 20px;
background-color: #1e1e1e;
color: #d4d4d4;
}
.header {
background-color: #2d2d30;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
h1 {
margin: 0;
color: #4ec9b0;
font-size: 24px;
}
.controls {
display: flex;
gap: 10px;
}
button {
background-color: #0e639c;
color: white;
border: none;
padding: 8px 16px;
border-radius: 3px;
cursor: pointer;
font-size: 14px;
}
button:hover {
background-color: #1177bb;
}
.log-container {
background-color: #1e1e1e;
border: 1px solid #3e3e42;
border-radius: 5px;
padding: 15px;
height: calc(100vh - 180px);
overflow-y: auto;
font-size: 13px;
line-height: 1.6;
}
.log-line {
margin: 2px 0;
white-space: pre-wrap;
word-wrap: break-word;
}
.log-error {
color: #f48771;
}
.log-warning {
color: #dcdcaa;
}
.log-info {
color: #4ec9b0;
}
.log-success {
color: #4ec9b0;
}
.loading {
text-align: center;
padding: 20px;
color: #858585;
}
.back-link {
color: #4ec9b0;
text-decoration: none;
font-size: 14px;
}
.back-link:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="header">
<div>
<h1>📋 DevOps 部署日志</h1>
<a href="/" class="back-link"> 返回首页</a>
</div>
<div class="controls">
<button onclick="refreshLogs()">🔄 刷新</button>
<button onclick="clearDisplay()">🗑 清空显示</button>
<button onclick="toggleAutoRefresh()"> <span id="autoRefreshText">开启自动刷新</span></button>
</div>
</div>
<div class="log-container" id="logContainer">
<div class="loading">正在加载日志...</div>
</div>
<script>
let autoRefreshInterval = null;
let isAutoRefresh = false;
function formatLogLine(line) {
if (line.includes('ERROR') || line.includes('失败') || line.includes('异常')) {
return '<div class="log-line log-error">' + escapeHtml(line) + '</div>';
} else if (line.includes('WARNING') || line.includes('警告')) {
return '<div class="log-line log-warning">' + escapeHtml(line) + '</div>';
} else if (line.includes('INFO') || line.includes('')) {
return '<div class="log-line log-info">' + escapeHtml(line) + '</div>';
} else if (line.includes('成功') || line.includes('完成')) {
return '<div class="log-line log-success">' + escapeHtml(line) + '</div>';
} else {
return '<div class="log-line">' + escapeHtml(line) + '</div>';
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function refreshLogs() {
fetch('/api/logs')
.then(response => response.json())
.then(data => {
const container = document.getElementById('logContainer');
if (data.logs && data.logs.length > 0) {
container.innerHTML = data.logs.map(formatLogLine).join('');
container.scrollTop = container.scrollHeight;
} else {
container.innerHTML = '<div class="loading">暂无日志</div>';
}
})
.catch(error => {
console.error('获取日志失败:', error);
document.getElementById('logContainer').innerHTML =
'<div class="log-line log-error">获取日志失败: ' + error.message + '</div>';
});
}
function clearDisplay() {
document.getElementById('logContainer').innerHTML = '<div class="loading">显示已清空,点击刷新重新加载</div>';
}
function toggleAutoRefresh() {
isAutoRefresh = !isAutoRefresh;
const text = document.getElementById('autoRefreshText');
if (isAutoRefresh) {
text.textContent = '关闭自动刷新';
autoRefreshInterval = setInterval(refreshLogs, 3000);
refreshLogs();
} else {
text.textContent = '开启自动刷新';
if (autoRefreshInterval) {
clearInterval(autoRefreshInterval);
autoRefreshInterval = null;
}
}
}
// 页面加载时获取日志
refreshLogs();
</script>
</body>
</html>
'''
return html
@self.app.route('/api/logs')
def get_logs():
"""获取日志内容API"""
try:
log_file = self.monitor.config.get('logging', {}).get('file', '.devops/logs/devops.log')
log_path = Path(log_file)
if not log_path.exists():
return jsonify({'logs': [], 'message': '日志文件不存在'})
# 读取最后1000行日志
with open(log_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
# 只返回最后1000行
recent_lines = lines[-1000:] if len(lines) > 1000 else lines
return jsonify({'logs': [line.rstrip() for line in recent_lines]})
except Exception as e:
return jsonify({'logs': [], 'error': str(e)})
2026-02-03 16:18:08 +08:00
@self.app.route('/containers')
def view_containers():
"""容器列表页面"""
containers = self.get_all_containers()
html = '''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Docker 容器管理</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 1400px;
margin: 50px auto;
padding: 20px;
background-color: #f5f5f5;
}
h1 {
color: #333;
text-align: center;
}
.info {
background-color: #e3f2fd;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
}
.back-link {
color: #1976d2;
text-decoration: none;
font-weight: bold;
}
.back-link:hover {
text-decoration: underline;
}
table {
width: 100%;
border-collapse: collapse;
background-color: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin-top: 20px;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background-color: #1976d2;
color: white;
}
tr:hover {
background-color: #f5f5f5;
}
.container-link {
color: #1976d2;
text-decoration: none;
font-weight: bold;
}
.container-link:hover {
text-decoration: underline;
}
.status-badge {
display: inline-block;
padding: 3px 8px;
border-radius: 3px;
font-size: 12px;
font-weight: bold;
background-color: #4caf50;
color: white;
}
</style>
</head>
<body>
<h1>🐳 Docker 容器管理</h1>
<div class="info">
<a href="/" class="back-link"> 返回首页</a>
<p><strong>说明</strong>点击容器名称查看该容器的实时日志</p>
</div>
<h2>📋 运行中的容器列表</h2>
<table>
<thead>
<tr>
<th>容器ID</th>
<th>容器名称</th>
<th>状态</th>
<th>镜像</th>
<th>操作</th>
</tr>
</thead>
<tbody>
'''
for container in containers:
html += f'''
<tr>
<td>{container['id'][:12]}</td>
<td>{container['name']}</td>
<td><span class="status-badge">{container['status']}</span></td>
<td>{container['image']}</td>
<td><a href="/container-logs/{container['name']}" class="container-link">查看日志</a></td>
</tr>
'''
if not containers:
html += '''
<tr>
<td colspan="5" style="text-align: center; padding: 20px; color: #999;">
暂无运行中的容器
</td>
</tr>
'''
html += '''
</tbody>
</table>
</body>
</html>
'''
return html
@self.app.route('/container-logs/<container_name>')
def view_container_logs(container_name):
"""容器日志查看页面"""
html = f'''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>容器日志 - {container_name}</title>
<style>
body {{
font-family: 'Courier New', monospace;
margin: 0;
padding: 20px;
background-color: #1e1e1e;
color: #d4d4d4;
}}
.header {{
background-color: #2d2d30;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}}
h1 {{
margin: 0;
color: #4ec9b0;
font-size: 24px;
}}
.controls {{
display: flex;
gap: 10px;
}}
button {{
background-color: #0e639c;
color: white;
border: none;
padding: 8px 16px;
border-radius: 3px;
cursor: pointer;
font-size: 14px;
}}
button:hover {{
background-color: #1177bb;
}}
.log-container {{
background-color: #1e1e1e;
border: 1px solid #3e3e42;
border-radius: 5px;
padding: 15px;
height: calc(100vh - 180px);
overflow-y: auto;
font-size: 13px;
line-height: 1.6;
}}
.log-line {{
margin: 2px 0;
white-space: pre-wrap;
word-wrap: break-word;
}}
.log-error {{
color: #f48771;
}}
.log-warning {{
color: #dcdcaa;
}}
.log-info {{
color: #4ec9b0;
}}
.loading {{
text-align: center;
padding: 20px;
color: #858585;
}}
.back-link {{
color: #4ec9b0;
text-decoration: none;
font-size: 14px;
}}
.back-link:hover {{
text-decoration: underline;
}}
</style>
</head>
<body>
<div class="header">
<div>
<h1>📋 容器日志: {container_name}</h1>
<a href="/containers" class="back-link"> 返回容器列表</a>
</div>
<div class="controls">
<button onclick="refreshLogs()">🔄 刷新</button>
<button onclick="clearDisplay()">🗑 清空显示</button>
<button onclick="toggleAutoRefresh()"> <span id="autoRefreshText">开启自动刷新</span></button>
</div>
</div>
<div class="log-container" id="logContainer">
<div class="loading">正在加载日志...</div>
</div>
<script>
let autoRefreshInterval = null;
let isAutoRefresh = false;
const containerName = '{container_name}';
function formatLogLine(line) {{
if (line.includes('ERROR') || line.includes('error') || line.includes('Exception') || line.includes('Failed')) {{
return '<div class="log-line log-error">' + escapeHtml(line) + '</div>';
}} else if (line.includes('WARN') || line.includes('warn') || line.includes('WARNING')) {{
return '<div class="log-line log-warning">' + escapeHtml(line) + '</div>';
}} else if (line.includes('INFO') || line.includes('info')) {{
return '<div class="log-line log-info">' + escapeHtml(line) + '</div>';
}} else {{
return '<div class="log-line">' + escapeHtml(line) + '</div>';
}}
}}
function escapeHtml(text) {{
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}}
function refreshLogs() {{
fetch('/api/container-logs/' + containerName)
.then(response => response.json())
.then(data => {{
const container = document.getElementById('logContainer');
if (data.logs && data.logs.length > 0) {{
container.innerHTML = data.logs.map(formatLogLine).join('');
container.scrollTop = container.scrollHeight;
}} else if (data.error) {{
container.innerHTML = '<div class="log-line log-error">获取日志失败: ' + data.error + '</div>';
}} else {{
container.innerHTML = '<div class="loading">暂无日志</div>';
}}
}})
.catch(error => {{
console.error('获取日志失败:', error);
document.getElementById('logContainer').innerHTML =
'<div class="log-line log-error">获取日志失败: ' + error.message + '</div>';
}});
}}
function clearDisplay() {{
document.getElementById('logContainer').innerHTML = '<div class="loading">显示已清空,点击刷新重新加载</div>';
}}
function toggleAutoRefresh() {{
isAutoRefresh = !isAutoRefresh;
const text = document.getElementById('autoRefreshText');
if (isAutoRefresh) {{
text.textContent = '关闭自动刷新';
autoRefreshInterval = setInterval(refreshLogs, 3000);
refreshLogs();
}} else {{
text.textContent = '开启自动刷新';
if (autoRefreshInterval) {{
clearInterval(autoRefreshInterval);
autoRefreshInterval = null;
}}
}}
}}
// 页面加载时获取日志
refreshLogs();
</script>
</body>
</html>
'''
return html
@self.app.route('/api/container-logs/<container_name>')
def get_container_logs(container_name):
"""获取容器日志API"""
try:
cmd = f"docker logs --tail 500 {container_name}"
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=10)
if result.returncode != 0:
return jsonify({'logs': [], 'error': f'获取容器日志失败: {result.stderr}'})
# 合并 stdout 和 stderr
logs = []
if result.stdout:
logs.extend(result.stdout.strip().split('\n'))
if result.stderr:
logs.extend(result.stderr.strip().split('\n'))
return jsonify({'logs': logs})
except Exception as e:
return jsonify({'logs': [], 'error': str(e)})
2026-02-03 15:53:59 +08:00
@self.app.route('/<project_name>')
def deploy_project(project_name):
"""触发项目部署"""
2026-02-03 16:18:08 +08:00
# 避免与其他路由冲突
if project_name in ['logs', 'api', 'containers', 'container-logs']:
2026-02-03 15:53:59 +08:00
return jsonify({'code': 404, 'message': '路径不存在'})
Logger.info(f"收到HTTP部署请求: {project_name}")
# 在后台线程中执行部署
def deploy_task():
success, message = self.monitor.deploy_by_name(project_name)
Logger.info(f"部署结果: {message}")
thread = threading.Thread(target=deploy_task)
thread.daemon = True
thread.start()
return jsonify({
'code': 200,
'message': f'已触发 {project_name} 部署,请查看日志了解部署进度',
'project': project_name
})
def run(self):
"""启动HTTP服务器"""
Logger.info(f"HTTP部署服务器启动在端口 {self.port}")
self.app.run(host='0.0.0.0', port=self.port, debug=False, use_reloader=False)
def main():
"""主函数"""
import argparse
parser = argparse.ArgumentParser(description='Git 仓库监听器')
2026-01-10 15:15:45 +08:00
parser.add_argument('--config', default='.devops/config.yaml', help='配置文件路径')
parser.add_argument('--once', action='store_true', help='只执行一次检查')
2026-02-03 15:53:59 +08:00
parser.add_argument('--http-port', type=int, default=9999, help='HTTP服务器端口默认9999')
parser.add_argument('--no-http', action='store_true', help='禁用HTTP服务器')
2026-01-10 15:15:45 +08:00
args = parser.parse_args()
monitor = GitMonitor(args.config)
2026-01-10 15:15:45 +08:00
if args.once:
monitor.run_once()
else:
2026-02-03 15:53:59 +08:00
# 启动HTTP服务器在单独的线程中
if not args.no_http:
server = DeploymentServer(monitor, port=args.http_port)
http_thread = threading.Thread(target=server.run)
http_thread.daemon = True
http_thread.start()
Logger.info(f"✓ HTTP部署服务器已启动: http://0.0.0.0:{args.http_port}")
# 启动Git监听
monitor.run()
if __name__ == '__main__':
main()
2026-01-10 15:15:45 +08:00