mirror of
https://github.com/opsre/LiteOps.git
synced 2026-03-17 03:20:46 +08:00
first commit
This commit is contained in:
56
backend/apps/utils/auth.py
Normal file
56
backend/apps/utils/auth.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import jwt
|
||||
import logging
|
||||
from functools import wraps
|
||||
from django.http import JsonResponse
|
||||
from django.conf import settings
|
||||
from ..models import UserToken
|
||||
|
||||
logger = logging.getLogger('apps')
|
||||
|
||||
def jwt_auth_required(view_func):
|
||||
@wraps(view_func)
|
||||
def wrapper(request, *args, **kwargs):
|
||||
token = request.headers.get('Authorization')
|
||||
if not token:
|
||||
logger.info('认证失败: 未提供Token')
|
||||
return JsonResponse({
|
||||
'code': 401,
|
||||
'message': '未提供Token'
|
||||
}, status=401)
|
||||
|
||||
try:
|
||||
# 验证token是否存在于数据库
|
||||
user_token = UserToken.objects.filter(token=token).first()
|
||||
if not user_token:
|
||||
logger.info('认证失败: Token无效')
|
||||
return JsonResponse({
|
||||
'code': 401,
|
||||
'message': 'Token无效'
|
||||
}, status=401)
|
||||
|
||||
# 解析token
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256'])
|
||||
request.user_id = payload.get('user_id')
|
||||
|
||||
return view_func(request, *args, **kwargs)
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
logger.info('认证失败: Token已过期')
|
||||
return JsonResponse({
|
||||
'code': 401,
|
||||
'message': 'Token已过期'
|
||||
}, status=401)
|
||||
except jwt.InvalidTokenError:
|
||||
logger.info('认证失败: Token格式无效')
|
||||
return JsonResponse({
|
||||
'code': 401,
|
||||
'message': '无效的Token'
|
||||
}, status=401)
|
||||
except Exception as e:
|
||||
logger.error('认证过程发生错误', exc_info=True)
|
||||
return JsonResponse({
|
||||
'code': 500,
|
||||
'message': '服务器错误'
|
||||
}, status=500)
|
||||
|
||||
return wrapper
|
||||
341
backend/apps/utils/build_stages.py
Normal file
341
backend/apps/utils/build_stages.py
Normal file
@@ -0,0 +1,341 @@
|
||||
import os
|
||||
import subprocess
|
||||
import logging
|
||||
import time
|
||||
import tempfile
|
||||
from typing import List, Dict, Any, Callable
|
||||
|
||||
logger = logging.getLogger('apps')
|
||||
|
||||
class BuildStageExecutor:
|
||||
"""构建阶段执行器"""
|
||||
|
||||
def __init__(self, build_path: str, send_log: Callable, record_time: Callable):
|
||||
"""
|
||||
初始化构建阶段执行器
|
||||
Args:
|
||||
build_path: 构建目录路径
|
||||
send_log: 发送日志的回调函数
|
||||
record_time: 记录时间的回调函数
|
||||
"""
|
||||
self.build_path = build_path
|
||||
self.send_log = send_log
|
||||
self.record_time = record_time
|
||||
self.env = {} # 初始化为空字典,将由 Builder 设置
|
||||
|
||||
# 用于存储临时变量文件的路径
|
||||
self.vars_file = os.path.join(self.build_path, '.build_vars')
|
||||
self._init_vars_file()
|
||||
|
||||
def _init_vars_file(self):
|
||||
"""初始化变量文件"""
|
||||
try:
|
||||
with open(self.vars_file, 'w') as f:
|
||||
f.write('#!/bin/bash\n# 构建变量\n')
|
||||
# 设置执行权限
|
||||
os.chmod(self.vars_file, 0o755)
|
||||
except Exception as e:
|
||||
logger.error(f"初始化变量文件失败: {str(e)}", exc_info=True)
|
||||
|
||||
def _save_variables_to_file(self, variables):
|
||||
"""
|
||||
将变量保存到文件
|
||||
Args:
|
||||
variables: 变量字典
|
||||
"""
|
||||
try:
|
||||
if not variables:
|
||||
return
|
||||
|
||||
# 追加变量到变量文件
|
||||
with open(self.vars_file, 'a') as f:
|
||||
for name, value in variables.items():
|
||||
if name.startswith(('_', 'BASH_', 'SHELL', 'HOME', 'PATH', 'PWD', 'OLDPWD')):
|
||||
continue
|
||||
safe_value = self._escape_shell_value(str(value))
|
||||
f.write(f'export {name}={safe_value}\n')
|
||||
except Exception as e:
|
||||
logger.error(f"保存变量到文件失败: {str(e)}", exc_info=True)
|
||||
|
||||
def _escape_shell_value(self, value):
|
||||
"""
|
||||
转义shell变量值
|
||||
Args:
|
||||
value: 要转义的值
|
||||
Returns:
|
||||
str: 转义后的值
|
||||
"""
|
||||
try:
|
||||
# 如果值为空,返回空字符串
|
||||
if not value:
|
||||
return '""'
|
||||
|
||||
# 如果值只包含安全字符(字母、数字、下划线、点、斜杠、冒号),不需要引号
|
||||
import re
|
||||
if re.match(r'^[a-zA-Z0-9_./:-]+$', value):
|
||||
return value
|
||||
|
||||
# 用单引号包围,并转义其中的单引号
|
||||
escaped_value = value.replace("'", "'\"'\"'")
|
||||
return f"'{escaped_value}'"
|
||||
except Exception as e:
|
||||
logger.error(f"转义shell值失败: {str(e)}", exc_info=True)
|
||||
return '""'
|
||||
|
||||
def _is_variable_assignment(self, line):
|
||||
"""
|
||||
检查是否是变量赋值语句
|
||||
Args:
|
||||
line: 要检查的行
|
||||
Returns:
|
||||
bool: 是否是变量赋值语句
|
||||
"""
|
||||
try:
|
||||
import re
|
||||
# 去除前导空格
|
||||
line = line.strip()
|
||||
|
||||
# 检查是否是 export VAR=value 格式
|
||||
if line.startswith('export '):
|
||||
line = line[7:].strip() # 移除 'export ' 前缀
|
||||
|
||||
# 检查是否符合变量赋值格式:VAR=value
|
||||
# 变量名必须以字母或下划线开头,后面可以跟字母、数字、下划线
|
||||
pattern = r'^[a-zA-Z_][a-zA-Z0-9_]*='
|
||||
return bool(re.match(pattern, line))
|
||||
except Exception as e:
|
||||
logger.error(f"检查变量赋值语句失败: {str(e)}", exc_info=True)
|
||||
return False
|
||||
|
||||
def _create_temp_script_file(self, script_content: str, stage_name: str) -> str:
|
||||
"""
|
||||
创建临时脚本文件,支持Jenkins风格的命令显示
|
||||
Args:
|
||||
script_content: 内联脚本内容
|
||||
stage_name: 阶段名称
|
||||
Returns:
|
||||
str: 临时脚本文件路径
|
||||
"""
|
||||
try:
|
||||
# 创建临时脚本文件
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode='w',
|
||||
suffix='.sh',
|
||||
prefix=f'build_stage_{stage_name}_',
|
||||
dir=self.build_path,
|
||||
delete=False
|
||||
) as temp_file:
|
||||
# 写入脚本头部
|
||||
temp_file.write('#!/bin/bash\n')
|
||||
temp_file.write('set -e # 遇到错误立即退出\n')
|
||||
temp_file.write('set -o pipefail # 管道命令中任何一个失败都视为失败\n')
|
||||
temp_file.write('\n')
|
||||
|
||||
# 加载变量文件
|
||||
temp_file.write(f'# 加载构建变量\n')
|
||||
temp_file.write(f'source "{self.vars_file}" 2>/dev/null || true\n')
|
||||
temp_file.write('\n')
|
||||
|
||||
# 添加命令显示函数
|
||||
temp_file.write('''# 命令显示函数
|
||||
execute_with_display() {
|
||||
echo "+ $*"
|
||||
"$@"
|
||||
}
|
||||
|
||||
''')
|
||||
|
||||
temp_file.write('# 用户脚本开始\n')
|
||||
|
||||
# 逐行处理脚本内容
|
||||
for line in script_content.splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#'):
|
||||
# 检查是否是变量赋值语句(如:VAR=value 或 export VAR=value)
|
||||
if self._is_variable_assignment(line):
|
||||
# 变量赋值:直接显示并执行
|
||||
temp_file.write(f'echo "+ {line}"\n')
|
||||
temp_file.write(f'{line}\n')
|
||||
elif any(op in line for op in ['|', '>', '<', '&&', '||', ';']):
|
||||
# 复杂命令:先显示,再在子shell中执行
|
||||
temp_file.write(f'echo "+ {line}"\n')
|
||||
temp_file.write(f'bash -c {repr(line)}\n')
|
||||
else:
|
||||
# 简单命令:使用函数显示并执行
|
||||
temp_file.write(f'execute_with_display {line}\n')
|
||||
elif line.startswith('#'):
|
||||
# 保留注释
|
||||
temp_file.write(f'{line}\n')
|
||||
|
||||
temp_file.write('\n')
|
||||
|
||||
script_path = temp_file.name
|
||||
|
||||
# 设置执行权限
|
||||
os.chmod(script_path, 0o755)
|
||||
return script_path
|
||||
|
||||
except Exception as e:
|
||||
self.send_log(f"创建临时脚本文件失败: {str(e)}", stage_name)
|
||||
return None
|
||||
|
||||
def _execute_script_unified(self, script_path: str, stage_name: str, check_termination: Callable = None) -> bool:
|
||||
try:
|
||||
# 检查是否应该终止
|
||||
if check_termination and check_termination():
|
||||
self.send_log("构建已被终止,跳过脚本执行", stage_name)
|
||||
return False
|
||||
|
||||
# 执行脚本,合并stdout和stderr到同一个流,保持输出顺序
|
||||
process = subprocess.Popen(
|
||||
['/bin/bash', script_path],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, # 将stderr重定向到stdout,保持输出顺序
|
||||
cwd=self.build_path,
|
||||
env=self.env,
|
||||
universal_newlines=True,
|
||||
bufsize=1 # 行缓冲,确保输出能够实时获取
|
||||
)
|
||||
|
||||
# 实时读取并发送输出
|
||||
import fcntl
|
||||
import os
|
||||
|
||||
# 设置非阻塞模式
|
||||
fd = process.stdout.fileno()
|
||||
fl = fcntl.fcntl(fd, fcntl.F_GETFL)
|
||||
fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
|
||||
|
||||
# 持续读取直到进程结束
|
||||
while process.poll() is None:
|
||||
# 检查是否终止
|
||||
if check_termination and check_termination():
|
||||
process.terminate()
|
||||
self.send_log("构建已被终止,停止当前脚本", stage_name)
|
||||
return False
|
||||
|
||||
try:
|
||||
line = process.stdout.readline()
|
||||
if line:
|
||||
line = line.rstrip()
|
||||
self.send_log(line, stage_name, raw_output=True)
|
||||
except BlockingIOError:
|
||||
time.sleep(0.01)
|
||||
continue
|
||||
|
||||
remaining_output = process.stdout.read()
|
||||
if remaining_output:
|
||||
for line in remaining_output.splitlines():
|
||||
if line.strip():
|
||||
self.send_log(line.rstrip(), stage_name, raw_output=True)
|
||||
|
||||
# 检查执行结果
|
||||
success = process.returncode == 0
|
||||
|
||||
if not success:
|
||||
self.send_log(f"脚本执行失败,返回码: {process.returncode}", stage_name)
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
self.send_log(f"执行脚本时发生错误: {str(e)}", stage_name)
|
||||
return False
|
||||
finally:
|
||||
if script_path.startswith(tempfile.gettempdir()) or '/build_stage_' in script_path:
|
||||
try:
|
||||
os.unlink(script_path)
|
||||
except Exception as e:
|
||||
logger.debug(f"清理临时脚本文件失败: {str(e)}")
|
||||
|
||||
def execute_stage(self, stage: Dict[str, Any], check_termination: Callable = None) -> bool:
|
||||
"""
|
||||
执行单个构建阶段
|
||||
Args:
|
||||
stage: 阶段配置信息
|
||||
check_termination: 检查是否终止的回调函数
|
||||
Returns:
|
||||
bool: 执行是否成功
|
||||
"""
|
||||
try:
|
||||
stage_name = stage.get('name', '未命名阶段')
|
||||
|
||||
if check_termination and check_termination():
|
||||
self.send_log("构建已被终止,跳过此阶段", stage_name)
|
||||
return False
|
||||
|
||||
# 记录阶段开始时间
|
||||
stage_start_time = time.time()
|
||||
|
||||
# 执行脚本
|
||||
success = self._execute_inline_script(stage, check_termination)
|
||||
|
||||
# 记录阶段耗时
|
||||
stage_duration = time.time() - stage_start_time
|
||||
self.record_time(stage_name, stage_start_time, stage_duration)
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
self.send_log(f"执行阶段时发生错误: {str(e)}", stage_name)
|
||||
return False
|
||||
|
||||
def _execute_inline_script(self, stage: Dict[str, Any], check_termination: Callable = None) -> bool:
|
||||
"""
|
||||
执行内联脚本
|
||||
Args:
|
||||
stage: 阶段配置信息
|
||||
check_termination: 检查是否终止的回调函数
|
||||
Returns:
|
||||
bool: 执行是否成功
|
||||
"""
|
||||
stage_name = stage.get('name', '未命名阶段')
|
||||
try:
|
||||
script_content = stage.get('script', '').strip()
|
||||
if not script_content:
|
||||
self.send_log("脚本内容为空", stage_name)
|
||||
return False
|
||||
|
||||
# 创建临时脚本文件
|
||||
script_path = self._create_temp_script_file(script_content, stage_name)
|
||||
if not script_path:
|
||||
return False
|
||||
|
||||
# 脚本执行方法
|
||||
success = self._execute_script_unified(script_path, stage_name, check_termination)
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
self.send_log(f"执行内联脚本时发生错误: {str(e)}", stage_name)
|
||||
return False
|
||||
|
||||
def execute_stages(self, stages: List[Dict[str, Any]], check_termination: Callable = None) -> bool:
|
||||
"""
|
||||
执行所有构建阶段
|
||||
Args:
|
||||
stages: 阶段配置列表
|
||||
check_termination: 检查是否终止的回调函数
|
||||
Returns:
|
||||
bool: 所有阶段是否都执行成功
|
||||
"""
|
||||
if not stages:
|
||||
self.send_log("没有配置构建阶段")
|
||||
return False
|
||||
|
||||
for stage in stages:
|
||||
if check_termination and check_termination():
|
||||
self.send_log("构建已被终止,跳过后续阶段", "Build Stages")
|
||||
return False
|
||||
|
||||
stage_name = stage.get('name', '未命名阶段')
|
||||
self.send_log(f"开始执行阶段: {stage_name}", "Build Stages")
|
||||
|
||||
# 执行当前阶段
|
||||
if not self.execute_stage(stage, check_termination):
|
||||
self.send_log(f"阶段 {stage_name} 执行失败", "Build Stages")
|
||||
return False
|
||||
|
||||
self.send_log(f"阶段 {stage_name} 执行完成", "Build Stages")
|
||||
|
||||
self.send_log("所有阶段执行完成", "Build Stages")
|
||||
return True
|
||||
578
backend/apps/utils/builder.py
Normal file
578
backend/apps/utils/builder.py
Normal file
@@ -0,0 +1,578 @@
|
||||
import os
|
||||
import logging
|
||||
import time
|
||||
import subprocess
|
||||
import tempfile
|
||||
import re
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from django.conf import settings
|
||||
from git import Repo
|
||||
from git.exc import GitCommandError
|
||||
from .build_stages import BuildStageExecutor
|
||||
from .notifier import BuildNotifier
|
||||
from .log_stream import log_stream_manager
|
||||
from django.db.models import F
|
||||
from ..models import BuildTask, BuildHistory
|
||||
# from ..utils.builder import Builder
|
||||
# from ..utils.crypto import decrypt_sensitive_data
|
||||
|
||||
logger = logging.getLogger('apps')
|
||||
|
||||
class Builder:
|
||||
def __init__(self, task, build_number, commit_id, history):
|
||||
self.task = task
|
||||
self.build_number = build_number
|
||||
self.commit_id = commit_id
|
||||
self.history = history # 构建历史记录
|
||||
self.log_buffer = [] # 缓存日志
|
||||
|
||||
# 检查是否已有指定的版本号
|
||||
if self.history.version:
|
||||
self.version = self.history.version
|
||||
self.send_log(f"使用指定版本: {self.version}", "Version")
|
||||
else:
|
||||
# 为开发和测试环境生成新的版本号
|
||||
self.version = f"{datetime.now().strftime('%Y%m%d%H%M%S')}_{commit_id[:8]}"
|
||||
|
||||
# 初始化构建时间信息
|
||||
self.build_time = {
|
||||
'start_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'stages_time': []
|
||||
}
|
||||
|
||||
# 更新构建历史记录的状态和版本
|
||||
self.history.status = 'running'
|
||||
if not self.history.version: # 只有当没有版本号时才更新
|
||||
self.history.version = self.version
|
||||
self.history.build_time = self.build_time
|
||||
self.history.save(update_fields=['status', 'version', 'build_time'])
|
||||
|
||||
# 设置构建目录
|
||||
self.build_path = Path(settings.BUILD_ROOT) / task.name / self.version / task.project.name
|
||||
|
||||
# 创建实时日志流
|
||||
log_stream_manager.create_build_stream(self.task.task_id, self.build_number)
|
||||
|
||||
def check_if_terminated(self):
|
||||
"""检查构建是否已被终止"""
|
||||
# 从数据库重新加载构建历史记录,以获取最新状态
|
||||
try:
|
||||
history_record = BuildHistory.objects.get(history_id=self.history.history_id)
|
||||
if history_record.status == 'terminated':
|
||||
# 如果状态为terminated,构建已被手动终止
|
||||
self.send_log("检测到构建已被手动终止,停止后续步骤", "System")
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"检查构建状态时出错: {str(e)}", exc_info=True)
|
||||
return False
|
||||
|
||||
def _filter_maven_progress(self, message):
|
||||
# 只过滤Maven下载进度信息中的Progress()部分
|
||||
if 'Progress (' in message and ('KB' in message or 'MB' in message or 'B/s' in message):
|
||||
return None
|
||||
|
||||
# 过滤Maven下载进度条
|
||||
if re.match(r'^Progress \(\d+\): .+', message.strip()):
|
||||
return None
|
||||
|
||||
# 过滤空的进度行
|
||||
if re.match(r'^\s*Progress\s*$', message.strip()):
|
||||
return None
|
||||
|
||||
# 过滤下载进度百分比
|
||||
if re.match(r'^\s*\d+%\s*$', message.strip()):
|
||||
return None
|
||||
|
||||
return message
|
||||
|
||||
def send_log(self, message, stage=None, console_only=False, raw_output=False):
|
||||
"""发送日志到缓存、实时流和控制台
|
||||
Args:
|
||||
message: 日志消息
|
||||
stage: 阶段名称
|
||||
console_only: 是否只输出到控制台(保留参数兼容性)
|
||||
raw_output: 是否为原始输出(不添加阶段标记)
|
||||
"""
|
||||
# 过滤Maven Progress信息
|
||||
filtered_message = self._filter_maven_progress(message)
|
||||
if filtered_message is None:
|
||||
return # 跳过被过滤的消息
|
||||
|
||||
# 格式化消息
|
||||
if raw_output:
|
||||
formatted_message = filtered_message
|
||||
else:
|
||||
formatted_message = filtered_message
|
||||
|
||||
# 如果有阶段名称,添加阶段标记
|
||||
if stage:
|
||||
formatted_message = f"[{stage}] {filtered_message}"
|
||||
|
||||
# 缓存日志
|
||||
self.log_buffer.append(formatted_message)
|
||||
|
||||
# 推送到实时日志流
|
||||
try:
|
||||
log_stream_manager.push_log(
|
||||
task_id=self.task.task_id,
|
||||
build_number=self.build_number,
|
||||
message=formatted_message + '\n',
|
||||
stage=stage
|
||||
)
|
||||
except Exception as e:
|
||||
# 降低日志级别,避免在清理阶段产生过多错误日志
|
||||
if "日志队列不存在" in str(e):
|
||||
logger.debug(f"日志队列已清理,跳过推送: {str(e)}")
|
||||
else:
|
||||
logger.error(f"推送实时日志失败: {str(e)}", exc_info=True)
|
||||
|
||||
# 批量更新数据库中的构建日志
|
||||
try:
|
||||
should_update_db = (
|
||||
len(self.log_buffer) % 10 == 0 or # 每10条日志更新一次
|
||||
not hasattr(self, '_last_db_update') or
|
||||
time.time() - getattr(self, '_last_db_update', 0) >= 5 # 每5秒更新一次
|
||||
)
|
||||
|
||||
if should_update_db:
|
||||
current_log = '\n'.join(self.log_buffer)
|
||||
self.history.build_log = current_log
|
||||
self.history.save(update_fields=['build_log'])
|
||||
self._last_db_update = time.time()
|
||||
except Exception as e:
|
||||
logger.error(f"批量更新构建日志失败: {str(e)}", exc_info=True)
|
||||
|
||||
# 输出到控制台 - 确保构建日志在控制台显示
|
||||
logger.info(formatted_message, extra={
|
||||
'from_builder': True, # 添加标记以区分构建日志
|
||||
'task_id': self.task.task_id,
|
||||
'build_number': self.build_number
|
||||
})
|
||||
|
||||
def _save_build_log(self):
|
||||
"""保存构建日志到历史记录"""
|
||||
try:
|
||||
self.history.build_log = '\n'.join(self.log_buffer)
|
||||
self.history.save(update_fields=['build_log'])
|
||||
except Exception as e:
|
||||
logger.error(f"保存构建日志失败: {str(e)}", exc_info=True)
|
||||
|
||||
def clone_repository(self):
|
||||
"""克隆Git仓库"""
|
||||
try:
|
||||
# 检查构建是否已被终止
|
||||
if self.check_if_terminated():
|
||||
return False
|
||||
|
||||
self.send_log("开始克隆代码...", "Git Clone")
|
||||
self.send_log(f"构建目录: {self.build_path}", "Git Clone")
|
||||
|
||||
# 确保目录存在
|
||||
self.build_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 获取Git凭证
|
||||
repository = self.task.project.repository
|
||||
self.send_log(f"仓库地址: {repository}", "Git Clone")
|
||||
git_token = self.task.git_token.token if self.task.git_token else None
|
||||
|
||||
# 处理带有token的仓库URL
|
||||
if git_token and repository.startswith('http'):
|
||||
if '@' in repository:
|
||||
repository = repository.split('@')[1]
|
||||
repository = f'https://oauth2:{git_token}@{repository}'
|
||||
else:
|
||||
repository = repository.replace('://', f'://oauth2:{git_token}@')
|
||||
|
||||
# 使用构建历史记录中的分支
|
||||
branch = self.history.branch
|
||||
self.send_log(f"克隆分支: {branch}", "Git Clone")
|
||||
self.send_log("正在克隆代码,请稍候...", "Git Clone")
|
||||
|
||||
# 克隆指定分支的代码
|
||||
Repo.clone_from(
|
||||
repository,
|
||||
str(self.build_path),
|
||||
branch=branch,
|
||||
progress=self.git_progress
|
||||
)
|
||||
|
||||
# 检查构建是否已被终止
|
||||
if self.check_if_terminated():
|
||||
return False
|
||||
|
||||
# 验证克隆是否成功
|
||||
if not os.path.exists(self.build_path) or not os.listdir(self.build_path):
|
||||
self.send_log("代码克隆失败:目录为空", "Git Clone")
|
||||
return False
|
||||
|
||||
self.send_log("代码克隆完成", "Git Clone")
|
||||
self.send_log(f"克隆目录验证成功: {self.build_path}", "Git Clone")
|
||||
return True
|
||||
|
||||
except GitCommandError as e:
|
||||
self.send_log(f"克隆代码失败: {str(e)}", "Git Clone")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.send_log(f"发生错误: {str(e)}", "Git Clone")
|
||||
return False
|
||||
|
||||
def git_progress(self, op_code, cur_count, max_count=None, message=''):
|
||||
"""Git进度回调"""
|
||||
# 每秒检查一次构建是否已被终止
|
||||
if int(time.time()) % 5 == 0: # 每5秒检查一次
|
||||
if self.check_if_terminated():
|
||||
# 如果构建已被终止,尝试引发异常停止Git克隆
|
||||
raise Exception("Build terminated")
|
||||
pass
|
||||
|
||||
def clone_external_scripts(self):
|
||||
"""克隆外部脚本库"""
|
||||
try:
|
||||
if not self.task.use_external_script:
|
||||
return True
|
||||
|
||||
# 检查外部脚本库配置
|
||||
config = self.task.external_script_config
|
||||
if not config or not config.get('repo_url') or not config.get('directory') or not config.get('branch'):
|
||||
self.send_log("外部脚本库配置不完整,跳过克隆", "External Scripts")
|
||||
return True
|
||||
|
||||
# 检查构建是否已被终止
|
||||
if self.check_if_terminated():
|
||||
return False
|
||||
|
||||
repo_url = config.get('repo_url')
|
||||
base_directory = config.get('directory')
|
||||
branch = config.get('branch') # 分支为必填项
|
||||
token_id = config.get('token_id')
|
||||
|
||||
# 从仓库URL中提取项目名称
|
||||
import re
|
||||
repo_name_match = re.search(r'/([^/]+?)(?:\.git)?/?$', repo_url)
|
||||
if repo_name_match:
|
||||
repo_name = repo_name_match.group(1)
|
||||
if repo_name.endswith('.git'):
|
||||
repo_name = repo_name[:-4]
|
||||
else:
|
||||
repo_name = 'external-scripts'
|
||||
|
||||
# 完整的克隆目录路径
|
||||
directory = os.path.join(base_directory, repo_name)
|
||||
|
||||
self.send_log("开始克隆外部脚本库...", "External Scripts")
|
||||
self.send_log(f"仓库地址: {repo_url}", "External Scripts")
|
||||
self.send_log(f"基础目录: {base_directory}", "External Scripts")
|
||||
self.send_log(f"项目名称: {repo_name}", "External Scripts")
|
||||
self.send_log(f"完整目录: {directory}", "External Scripts")
|
||||
self.send_log(f"分支: {branch}", "External Scripts")
|
||||
|
||||
# 获取Git Token(如果配置了)
|
||||
git_token = None
|
||||
if token_id:
|
||||
try:
|
||||
from ..models import GitlabTokenCredential
|
||||
credential = GitlabTokenCredential.objects.get(credential_id=token_id)
|
||||
git_token = credential.token
|
||||
except:
|
||||
self.send_log("获取Git Token失败,尝试使用公开仓库方式克隆", "External Scripts")
|
||||
|
||||
# 处理带有token的仓库URL
|
||||
if git_token and repo_url.startswith('http'):
|
||||
if '@' in repo_url:
|
||||
repo_url = repo_url.split('@')[1]
|
||||
repo_url = f'https://oauth2:{git_token}@{repo_url}'
|
||||
else:
|
||||
repo_url = repo_url.replace('://', f'://oauth2:{git_token}@')
|
||||
|
||||
# 确保基础目录存在
|
||||
os.makedirs(base_directory, exist_ok=True)
|
||||
|
||||
# 如果目标目录已存在且不为空,先清空
|
||||
if os.path.exists(directory) and os.listdir(directory):
|
||||
self.send_log(f"清空现有目录: {directory}", "External Scripts")
|
||||
shutil.rmtree(directory)
|
||||
|
||||
# 克隆外部脚本库
|
||||
self.send_log("正在克隆外部脚本库,请稍候...", "External Scripts")
|
||||
|
||||
from git import Repo
|
||||
# 使用指定分支克隆
|
||||
Repo.clone_from(
|
||||
repo_url,
|
||||
directory,
|
||||
branch=branch
|
||||
)
|
||||
|
||||
# 再次检查构建是否已被终止
|
||||
if self.check_if_terminated():
|
||||
return False
|
||||
|
||||
# 验证克隆是否成功
|
||||
if not os.path.exists(directory) or not os.listdir(directory):
|
||||
self.send_log("外部脚本库克隆失败:目录为空", "External Scripts")
|
||||
return False
|
||||
|
||||
self.send_log("外部脚本库克隆完成", "External Scripts")
|
||||
self.send_log(f"克隆目录验证成功: {directory}", "External Scripts")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.send_log(f"克隆外部脚本库失败: {str(e)}", "External Scripts")
|
||||
# 如果用户配置了外部脚本库,克隆失败应该终止构建
|
||||
self.send_log("外部脚本库克隆失败,终止构建", "External Scripts")
|
||||
return False
|
||||
|
||||
def execute_stages(self, stage_executor):
|
||||
"""执行构建阶段"""
|
||||
try:
|
||||
if not self.task.stages:
|
||||
self.send_log("没有配置构建阶段", "Build Stages")
|
||||
return False
|
||||
|
||||
# 检查构建是否已被终止
|
||||
if self.check_if_terminated():
|
||||
return False
|
||||
|
||||
# 执行所有阶段
|
||||
success = stage_executor.execute_stages(self.task.stages, check_termination=self.check_if_terminated)
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
self.send_log(f"执行构建阶段时发生错误: {str(e)}", "Build Stages")
|
||||
return False
|
||||
|
||||
def execute(self):
|
||||
"""执行构建"""
|
||||
build_start_time = time.time()
|
||||
success = False # 初始化成功状态
|
||||
try:
|
||||
# 在开始构建前检查构建是否已被终止
|
||||
if self.check_if_terminated():
|
||||
self._update_build_stats(False)
|
||||
self._save_build_log()
|
||||
return False
|
||||
|
||||
# 获取环境类型
|
||||
environment_type = self.task.environment.type if self.task.environment else None
|
||||
|
||||
# 根据环境类型决定是否需要克隆代码
|
||||
if environment_type in ['development', 'testing']:
|
||||
# 只在开发和测试环境克隆代码
|
||||
clone_start_time = time.time()
|
||||
if not self.clone_repository():
|
||||
self._update_build_stats(False) # 更新失败统计
|
||||
self._update_build_time(build_start_time, False)
|
||||
# 发送构建失败通知
|
||||
notifier = BuildNotifier(self.history)
|
||||
notifier.send_notifications()
|
||||
return False
|
||||
|
||||
# 记录代码克隆阶段的时间
|
||||
self.build_time['stages_time'].append({
|
||||
'name': 'Git Clone',
|
||||
'start_time': datetime.fromtimestamp(clone_start_time).strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'duration': str(int(time.time() - clone_start_time))
|
||||
})
|
||||
else:
|
||||
self.send_log(f"预发布/生产环境构建,跳过代码克隆,直接使用版本: {self.version}", "Environment")
|
||||
|
||||
# 创建构建目录
|
||||
os.makedirs(self.build_path, exist_ok=True)
|
||||
|
||||
# 再次检查构建是否已被终止
|
||||
if self.check_if_terminated():
|
||||
self._update_build_stats(False)
|
||||
self._update_build_time(build_start_time, False)
|
||||
return False
|
||||
|
||||
# 克隆外部脚本库(如果配置了)
|
||||
external_script_start_time = time.time()
|
||||
if not self.clone_external_scripts():
|
||||
self._update_build_stats(False)
|
||||
self._update_build_time(build_start_time, False)
|
||||
# 发送构建失败通知
|
||||
notifier = BuildNotifier(self.history)
|
||||
notifier.send_notifications()
|
||||
return False
|
||||
|
||||
# 记录外部脚本库克隆阶段的时间(如果启用了外部脚本库)
|
||||
if self.task.use_external_script:
|
||||
self.build_time['stages_time'].append({
|
||||
'name': 'External Scripts Clone',
|
||||
'start_time': datetime.fromtimestamp(external_script_start_time).strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'duration': str(int(time.time() - external_script_start_time))
|
||||
})
|
||||
|
||||
# 检查构建是否已被终止
|
||||
if self.check_if_terminated():
|
||||
self._update_build_stats(False)
|
||||
self._update_build_time(build_start_time, False)
|
||||
return False
|
||||
|
||||
# 创建阶段执行器,传递send_log方法和构建时间记录回调
|
||||
stage_executor = BuildStageExecutor(
|
||||
str(self.build_path),
|
||||
lambda msg, stage=None, raw_output=False: self.send_log(msg, stage, raw_output=raw_output),
|
||||
self._record_stage_time
|
||||
)
|
||||
|
||||
# 设置系统内置环境变量
|
||||
system_variables = {
|
||||
# 编号相关变量
|
||||
'BUILD_NUMBER': str(self.build_number),
|
||||
'VERSION': self.version,
|
||||
|
||||
# Git相关变量
|
||||
'COMMIT_ID': self.commit_id,
|
||||
'BRANCH': self.history.branch,
|
||||
|
||||
# 项目相关变量
|
||||
'PROJECT_NAME': self.task.project.name,
|
||||
'PROJECT_ID': self.task.project.project_id,
|
||||
'PROJECT_REPO': self.task.project.repository,
|
||||
|
||||
# 任务相关变量
|
||||
'TASK_NAME': self.task.name,
|
||||
'TASK_ID': self.task.task_id,
|
||||
|
||||
# 环境相关变量
|
||||
'ENVIRONMENT': self.task.environment.name,
|
||||
'ENVIRONMENT_TYPE': self.task.environment.type,
|
||||
'ENVIRONMENT_ID': self.task.environment.environment_id,
|
||||
|
||||
# 别名(便于使用)
|
||||
'service_name': self.task.name,
|
||||
'build_env': self.task.environment.name,
|
||||
'branch': self.history.branch,
|
||||
'version': self.version,
|
||||
|
||||
# 构建路径
|
||||
'BUILD_PATH': str(self.build_path),
|
||||
'BUILD_WORKSPACE': str(self.build_path),
|
||||
|
||||
# Docker配置
|
||||
'DOCKER_BUILDKIT': '0',
|
||||
'BUILDKIT_PROGRESS': 'plain',
|
||||
|
||||
# Locale配置 - 使用稳定的POSIX locale避免SSH连接时的警告
|
||||
'LC_ALL': 'POSIX',
|
||||
'LANG': 'POSIX',
|
||||
}
|
||||
|
||||
combined_env = {**os.environ, **system_variables}
|
||||
stage_executor.env = combined_env
|
||||
|
||||
# 保存系统变量到文件
|
||||
stage_executor._save_variables_to_file(system_variables)
|
||||
|
||||
# 执行构建阶段
|
||||
success = self.execute_stages(stage_executor)
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
self.send_log(f"构建过程中发生未捕获的异常: {str(e)}", "Error")
|
||||
success = False
|
||||
return False
|
||||
finally:
|
||||
# 更新构建统计和时间信息
|
||||
self._update_build_stats(success)
|
||||
self._update_build_time(build_start_time, success)
|
||||
|
||||
# 确保最终日志保存到数据库
|
||||
self._save_build_log()
|
||||
|
||||
# 输出构建完成状态日志
|
||||
self.history.refresh_from_db()
|
||||
final_status = self.history.status
|
||||
self.send_log(f"构建完成,状态: {final_status}", "Build")
|
||||
|
||||
# 确保构建完成状态日志也保存到数据库
|
||||
self._save_build_log()
|
||||
|
||||
# 通知日志流管理器构建完成
|
||||
try:
|
||||
log_stream_manager.complete_build(
|
||||
task_id=self.task.task_id,
|
||||
build_number=self.build_number,
|
||||
status=final_status
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"通知日志流管理器构建完成失败: {str(e)}", exc_info=True)
|
||||
|
||||
# 发送构建通知
|
||||
notifier = BuildNotifier(self.history)
|
||||
notifier.send_notifications()
|
||||
|
||||
def _record_stage_time(self, stage_name: str, start_time: float, duration: float):
|
||||
"""记录阶段执行时间
|
||||
Args:
|
||||
stage_name: 阶段名称
|
||||
start_time: 开始时间戳
|
||||
duration: 耗时(秒)
|
||||
"""
|
||||
stage_time = {
|
||||
'name': stage_name,
|
||||
'start_time': datetime.fromtimestamp(start_time).strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'duration': str(int(duration))
|
||||
}
|
||||
self.build_time['stages_time'].append(stage_time)
|
||||
|
||||
# 更新构建历史记录的阶段信息
|
||||
self.history.stages = self.task.stages
|
||||
self.history.save(update_fields=['stages'])
|
||||
|
||||
def _update_build_time(self, build_start_time: float, success: bool):
|
||||
"""更新构建时间信息
|
||||
Args:
|
||||
build_start_time: 构建开始时间戳
|
||||
success: 构建是否成功
|
||||
"""
|
||||
try:
|
||||
# 计算总耗时
|
||||
total_duration = int(time.time() - build_start_time)
|
||||
|
||||
# 更新构建时间信息
|
||||
self.build_time['total_duration'] = str(total_duration)
|
||||
self.build_time['end_time'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# 检查当前构建状态,如果已经是terminated则不覆盖状态
|
||||
self.history.refresh_from_db()
|
||||
if self.history.status != 'terminated':
|
||||
# 只有在状态不是terminated时才更新状态
|
||||
self.history.status = 'success' if success else 'failed'
|
||||
|
||||
self.history.build_time = self.build_time
|
||||
self.history.save(update_fields=['status', 'build_time'])
|
||||
except Exception as e:
|
||||
logger.error(f"更新构建时间信息失败: {str(e)}", exc_info=True)
|
||||
|
||||
def _update_build_stats(self, success: bool):
|
||||
"""更新构建统计信息
|
||||
Args:
|
||||
success: 构建是否成功
|
||||
"""
|
||||
try:
|
||||
# 检查当前构建状态,如果是terminated则不更新统计
|
||||
self.history.refresh_from_db()
|
||||
if self.history.status == 'terminated':
|
||||
return
|
||||
|
||||
# 更新任务的构建统计信息
|
||||
if success:
|
||||
BuildTask.objects.filter(task_id=self.task.task_id).update(
|
||||
success_builds=F('success_builds') + 1
|
||||
)
|
||||
# 只有成功的构建才更新版本号
|
||||
BuildTask.objects.filter(task_id=self.task.task_id).update(
|
||||
version=self.version
|
||||
)
|
||||
else:
|
||||
BuildTask.objects.filter(task_id=self.task.task_id).update(
|
||||
failure_builds=F('failure_builds') + 1
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"更新构建统计信息失败: {str(e)}", exc_info=True)
|
||||
|
||||
194
backend/apps/utils/log_stream.py
Normal file
194
backend/apps/utils/log_stream.py
Normal file
@@ -0,0 +1,194 @@
|
||||
import threading
|
||||
import queue
|
||||
import time
|
||||
import logging
|
||||
from typing import Dict, Set, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger('apps')
|
||||
|
||||
@dataclass
|
||||
class LogMessage:
|
||||
"""日志消息数据类"""
|
||||
task_id: str
|
||||
build_number: int
|
||||
message: str
|
||||
stage: Optional[str] = None
|
||||
timestamp: float = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.timestamp is None:
|
||||
self.timestamp = time.time()
|
||||
|
||||
class LogStreamManager:
|
||||
|
||||
_instance = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if hasattr(self, '_initialized'):
|
||||
return
|
||||
|
||||
self._initialized = True
|
||||
# 存储每个构建的日志队列 {(task_id, build_number): queue.Queue}
|
||||
self._log_queues: Dict[tuple, queue.Queue] = {}
|
||||
# 存储每个构建的SSE客户端集合 {(task_id, build_number): set}
|
||||
self._sse_clients: Dict[tuple, Set] = {}
|
||||
# 线程锁
|
||||
self._queues_lock = threading.Lock()
|
||||
self._clients_lock = threading.Lock()
|
||||
|
||||
logger.info("LogStreamManager initialized")
|
||||
|
||||
def get_build_key(self, task_id: str, build_number: int) -> tuple:
|
||||
"""获取构建的唯一键"""
|
||||
return (task_id, build_number)
|
||||
|
||||
def create_build_stream(self, task_id: str, build_number: int):
|
||||
"""为新构建创建日志流"""
|
||||
build_key = self.get_build_key(task_id, build_number)
|
||||
|
||||
with self._queues_lock:
|
||||
if build_key not in self._log_queues:
|
||||
self._log_queues[build_key] = queue.Queue(maxsize=20000) # 最大缓存20000条日志
|
||||
logger.info(f"Created log stream for build {task_id}#{build_number}")
|
||||
|
||||
with self._clients_lock:
|
||||
if build_key not in self._sse_clients:
|
||||
self._sse_clients[build_key] = set()
|
||||
|
||||
def add_sse_client(self, task_id: str, build_number: int, client_id: str):
|
||||
"""添加SSE客户端"""
|
||||
build_key = self.get_build_key(task_id, build_number)
|
||||
|
||||
with self._clients_lock:
|
||||
if build_key not in self._sse_clients:
|
||||
self._sse_clients[build_key] = set()
|
||||
self._sse_clients[build_key].add(client_id)
|
||||
logger.info(f"Added SSE client {client_id} for build {task_id}#{build_number}")
|
||||
|
||||
def remove_sse_client(self, task_id: str, build_number: int, client_id: str):
|
||||
"""移除SSE客户端"""
|
||||
build_key = self.get_build_key(task_id, build_number)
|
||||
|
||||
with self._clients_lock:
|
||||
if build_key in self._sse_clients:
|
||||
self._sse_clients[build_key].discard(client_id)
|
||||
logger.info(f"Removed SSE client {client_id} for build {task_id}#{build_number}")
|
||||
|
||||
if not self._sse_clients[build_key]:
|
||||
del self._sse_clients[build_key]
|
||||
self._cleanup_build_stream(build_key)
|
||||
|
||||
def _cleanup_build_stream(self, build_key: tuple):
|
||||
"""清理构建流资源"""
|
||||
with self._queues_lock:
|
||||
if build_key in self._log_queues:
|
||||
del self._log_queues[build_key]
|
||||
logger.info(f"Cleaned up log stream for build {build_key[0]}#{build_key[1]}")
|
||||
|
||||
def push_log(self, task_id: str, build_number: int, message: str, stage: Optional[str] = None):
|
||||
"""推送日志消息到流"""
|
||||
build_key = self.get_build_key(task_id, build_number)
|
||||
|
||||
# 创建日志消息
|
||||
log_msg = LogMessage(
|
||||
task_id=task_id,
|
||||
build_number=build_number,
|
||||
message=message,
|
||||
stage=stage
|
||||
)
|
||||
|
||||
# 推送到队列
|
||||
with self._queues_lock:
|
||||
if build_key not in self._log_queues:
|
||||
# 如果队列不存在,先创建
|
||||
self._log_queues[build_key] = queue.Queue(maxsize=20000)
|
||||
logger.debug(f"Created log queue for build {task_id}#{build_number} during push")
|
||||
|
||||
try:
|
||||
# 非阻塞推送,如果队列满了就丢弃最老的消息
|
||||
if self._log_queues[build_key].full():
|
||||
try:
|
||||
self._log_queues[build_key].get_nowait() # 移除最老的消息
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
self._log_queues[build_key].put_nowait(log_msg)
|
||||
except queue.Full:
|
||||
logger.warning(f"Log queue full for build {task_id}#{build_number}, dropping message")
|
||||
|
||||
def get_log_stream(self, task_id: str, build_number: int, client_id: str):
|
||||
"""获取日志流生成器"""
|
||||
build_key = self.get_build_key(task_id, build_number)
|
||||
|
||||
# 确保流存在
|
||||
self.create_build_stream(task_id, build_number)
|
||||
self.add_sse_client(task_id, build_number, client_id)
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
# 检查客户端是否还在连接
|
||||
with self._clients_lock:
|
||||
if build_key not in self._sse_clients or client_id not in self._sse_clients[build_key]:
|
||||
logger.info(f"Client {client_id} disconnected from build {task_id}#{build_number}")
|
||||
break
|
||||
|
||||
# 获取日志队列
|
||||
with self._queues_lock:
|
||||
log_queue = self._log_queues.get(build_key)
|
||||
|
||||
if log_queue is None:
|
||||
break
|
||||
|
||||
try:
|
||||
# 等待新日志消息,超时时间为1秒
|
||||
log_msg = log_queue.get(timeout=1.0)
|
||||
yield log_msg
|
||||
except queue.Empty:
|
||||
# 超时,发送心跳
|
||||
yield None # None表示心跳
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in log stream for {task_id}#{build_number}: {str(e)}")
|
||||
break
|
||||
finally:
|
||||
# 清理客户端
|
||||
self.remove_sse_client(task_id, build_number, client_id)
|
||||
|
||||
def complete_build(self, task_id: str, build_number: int, status: str):
|
||||
"""标记构建完成"""
|
||||
build_key = self.get_build_key(task_id, build_number)
|
||||
|
||||
# 推送完成消息
|
||||
completion_msg = LogMessage(
|
||||
task_id=task_id,
|
||||
build_number=build_number,
|
||||
message=f"BUILD_COMPLETE:{status}",
|
||||
stage="SYSTEM"
|
||||
)
|
||||
|
||||
with self._queues_lock:
|
||||
if build_key in self._log_queues:
|
||||
try:
|
||||
self._log_queues[build_key].put_nowait(completion_msg)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
def has_active_clients(self, task_id: str, build_number: int) -> bool:
|
||||
"""检查是否有活跃的SSE客户端"""
|
||||
build_key = self.get_build_key(task_id, build_number)
|
||||
|
||||
with self._clients_lock:
|
||||
return build_key in self._sse_clients and len(self._sse_clients[build_key]) > 0
|
||||
|
||||
# 全局单例实例
|
||||
log_stream_manager = LogStreamManager()
|
||||
252
backend/apps/utils/notifier.py
Normal file
252
backend/apps/utils/notifier.py
Normal file
@@ -0,0 +1,252 @@
|
||||
import json
|
||||
import time
|
||||
import hmac
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import requests
|
||||
from urllib.parse import quote_plus
|
||||
from django.conf import settings
|
||||
from ..models import NotificationRobot, BuildHistory
|
||||
|
||||
logger = logging.getLogger('apps')
|
||||
|
||||
class BuildNotifier:
|
||||
"""构建通知工具类"""
|
||||
|
||||
def __init__(self, history: BuildHistory):
|
||||
self.history = history
|
||||
self.task = history.task
|
||||
self.project = history.task.project
|
||||
self.environment = history.task.environment
|
||||
|
||||
def _sign_dingtalk(self, secret: str, timestamp: str) -> str:
|
||||
"""钉钉机器人签名"""
|
||||
string_to_sign = f'{timestamp}\n{secret}'
|
||||
hmac_code = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
string_to_sign.encode('utf-8'),
|
||||
digestmod=hashlib.sha256
|
||||
).digest()
|
||||
return base64.b64encode(hmac_code).decode('utf-8')
|
||||
|
||||
def _sign_feishu(self, secret: str, timestamp: str) -> str:
|
||||
"""飞书机器人签名"""
|
||||
string_to_sign = f'{timestamp}\n{secret}'
|
||||
hmac_code = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
string_to_sign.encode('utf-8'),
|
||||
digestmod=hashlib.sha256
|
||||
).digest()
|
||||
return base64.b64encode(hmac_code).decode('utf-8')
|
||||
|
||||
def _get_build_status_emoji(self) -> str:
|
||||
"""获取构建状态对应的emoji"""
|
||||
status_emoji = {
|
||||
'success': '✅',
|
||||
'failed': '❌',
|
||||
'running': '🔄',
|
||||
'pending': '⏳',
|
||||
'terminated': '🛑'
|
||||
}
|
||||
return status_emoji.get(self.history.status, '❓')
|
||||
|
||||
def _get_duration_text(self) -> str:
|
||||
"""获取构建耗时文本"""
|
||||
if not self.history.build_time or 'total_duration' not in self.history.build_time:
|
||||
return '未完成'
|
||||
|
||||
duration = int(self.history.build_time['total_duration'])
|
||||
if duration < 60:
|
||||
return f'{duration}秒'
|
||||
minutes = duration // 60
|
||||
seconds = duration % 60
|
||||
return f'{minutes}分{seconds}秒'
|
||||
|
||||
def _get_status_text(self) -> str:
|
||||
"""获取状态文本"""
|
||||
status_texts = {
|
||||
'success': '构建成功',
|
||||
'failed': '构建失败',
|
||||
'running': '构建中',
|
||||
'pending': '等待中',
|
||||
'terminated': '构建已终止'
|
||||
}
|
||||
return status_texts.get(self.history.status, self.history.status)
|
||||
|
||||
def _get_build_url(self) -> str:
|
||||
"""获取构建历史页面URL"""
|
||||
base_url = getattr(settings, 'WEB_URL', 'http://localhost:5173') # 如果未配置,使用默认值
|
||||
return f"{base_url}/build/history?history_id={self.history.history_id}"
|
||||
|
||||
def _format_dingtalk_message(self) -> dict:
|
||||
"""格式化钉钉通知消息"""
|
||||
status_text = self._get_status_text()
|
||||
build_url = self._get_build_url()
|
||||
|
||||
content = [
|
||||
f"## 🔔 构建通知:{status_text}",
|
||||
"---",
|
||||
"**构建详情:**",
|
||||
f"- **任务名称**:{self.task.name}",
|
||||
f"- **构建编号**:#{self.history.build_number}",
|
||||
f"- **构建版本**:{self.history.version}",
|
||||
f"- **构建分支**:{self.history.branch}",
|
||||
f"- **提交ID**:{self.history.commit_id[:8] if self.history.commit_id else '无'}",
|
||||
f"- **构建环境**:{self.environment.name}",
|
||||
f"- **构建人员**:{self.history.operator.name if self.history.operator else '系统'}",
|
||||
f"- **构建耗时**:{self._get_duration_text()}",
|
||||
"",
|
||||
"**构建需求:**",
|
||||
f"> {self.history.requirement or '无'}",
|
||||
"",
|
||||
f"**查看详情:**[点击查看构建日志]({build_url})",
|
||||
"",
|
||||
"---",
|
||||
"**注意事项:**",
|
||||
"1. 此为自动通知,请勿回复",
|
||||
"2. 如遇构建失败,请先查看构建日志进行排查",
|
||||
"3. 如需帮助,请联系运维同学"
|
||||
]
|
||||
|
||||
return {
|
||||
"msgtype": "markdown",
|
||||
"markdown": {
|
||||
"title": f"{status_text}: {self.task.name} #{self.history.build_number}",
|
||||
"text": "\n".join(content)
|
||||
},
|
||||
"at": {
|
||||
"isAtAll": True
|
||||
}
|
||||
}
|
||||
|
||||
def _format_wecom_message(self) -> dict:
|
||||
"""格式化企业微信通知消息"""
|
||||
status_text = self._get_status_text()
|
||||
build_url = self._get_build_url()
|
||||
|
||||
content = [
|
||||
f"## 🔔 构建通知:{status_text}",
|
||||
"---",
|
||||
"@all", # 企业微信使用 @all 来@所有人
|
||||
"",
|
||||
"**构建详情:**",
|
||||
f"- **任务名称**:{self.task.name}",
|
||||
f"- **构建编号**:#{self.history.build_number}",
|
||||
f"- **构建版本**:{self.history.version}",
|
||||
f"- **构建分支**:{self.history.branch}",
|
||||
f"- **提交ID**:{self.history.commit_id[:8] if self.history.commit_id else '无'}",
|
||||
f"- **构建环境**:{self.environment.name}",
|
||||
f"- **构建人员**:{self.history.operator.name if self.history.operator else '系统'}",
|
||||
f"- **构建耗时**:{self._get_duration_text()}",
|
||||
"",
|
||||
"**构建需求:**",
|
||||
f"> {self.history.requirement or '无'}",
|
||||
"",
|
||||
f"**查看详情:**[点击查看构建日志]({build_url})",
|
||||
"",
|
||||
"---",
|
||||
"**注意事项:**",
|
||||
"1. 此为自动通知,请勿回复",
|
||||
"2. 如遇构建失败,请先查看构建日志进行排查",
|
||||
"3. 如需帮助,请联系运维同学"
|
||||
]
|
||||
|
||||
return {
|
||||
"msgtype": "markdown",
|
||||
"markdown": {
|
||||
"content": "\n".join(content)
|
||||
}
|
||||
}
|
||||
|
||||
def _format_feishu_message(self) -> dict:
|
||||
"""格式化飞书通知消息"""
|
||||
status_text = self._get_status_text()
|
||||
build_url = self._get_build_url()
|
||||
|
||||
content = [
|
||||
f"🔔 构建通知:{status_text}",
|
||||
"---",
|
||||
"<at user_id=\"all\">所有人</at>", # 飞书使用这种格式@所有人
|
||||
"",
|
||||
"**构建详情:**",
|
||||
f"- **任务名称**:{self.task.name}",
|
||||
f"- **构建编号**:#{self.history.build_number}",
|
||||
f"- **构建版本**:{self.history.version}",
|
||||
f"- **构建分支**:{self.history.branch}",
|
||||
f"- **提交ID**:{self.history.commit_id[:8] if self.history.commit_id else '无'}",
|
||||
f"- **构建环境**:{self.environment.name}",
|
||||
f"- **构建人员**:{self.history.operator.name if self.history.operator else '系统'}",
|
||||
f"- **构建耗时**:{self._get_duration_text()}",
|
||||
"",
|
||||
"**构建需求:**",
|
||||
f"> {self.history.requirement or '无'}",
|
||||
"",
|
||||
f"**查看详情:**[点击查看构建日志]({build_url})",
|
||||
"",
|
||||
"---",
|
||||
"**注意事项:**",
|
||||
"1. 此为自动通知,请勿回复",
|
||||
"2. 如遇构建失败,请先查看构建日志进行排查",
|
||||
"3. 如需帮助,请联系运维同学"
|
||||
]
|
||||
|
||||
return {
|
||||
"msg_type": "text",
|
||||
"content": {
|
||||
"text": "\n".join(content)
|
||||
}
|
||||
}
|
||||
|
||||
def send_notifications(self):
|
||||
"""发送构建通知"""
|
||||
if not self.task.notification_channels:
|
||||
logger.info(f"任务 {self.task.name} 未配置通知方式")
|
||||
return
|
||||
|
||||
# 获取需要通知的机器人
|
||||
robots = NotificationRobot.objects.filter(robot_id__in=self.task.notification_channels)
|
||||
|
||||
for robot in robots:
|
||||
try:
|
||||
webhook = robot.webhook
|
||||
timestamp = str(int(time.time() * 1000))
|
||||
headers = {}
|
||||
|
||||
# 根据机器人类型处理安全设置
|
||||
if robot.security_type == 'secret' and robot.secret:
|
||||
if robot.type == 'dingtalk':
|
||||
sign = self._sign_dingtalk(robot.secret, timestamp)
|
||||
webhook = f"{webhook}×tamp={timestamp}&sign={quote_plus(sign)}"
|
||||
elif robot.type == 'feishu':
|
||||
sign = self._sign_feishu(robot.secret, timestamp)
|
||||
headers.update({
|
||||
"X-Timestamp": timestamp,
|
||||
"X-Sign": sign
|
||||
})
|
||||
|
||||
# 根据机器人类型获取消息内容
|
||||
if robot.type == 'dingtalk':
|
||||
message = self._format_dingtalk_message()
|
||||
elif robot.type == 'wecom':
|
||||
message = self._format_wecom_message()
|
||||
elif robot.type == 'feishu':
|
||||
message = self._format_feishu_message()
|
||||
else:
|
||||
logger.error(f"不支持的机器人类型: {robot.type}")
|
||||
continue
|
||||
|
||||
# 发送通知
|
||||
response = requests.post(webhook, json=message, headers=headers)
|
||||
|
||||
if response.status_code == 200:
|
||||
resp_json = response.json()
|
||||
if resp_json.get('errcode') == 0 or resp_json.get('StatusCode') == 0 or resp_json.get('code') == 0:
|
||||
logger.info(f"发送 {robot.type} 通知成功: {robot.name}")
|
||||
else:
|
||||
logger.error(f"发送 {robot.type} 通知失败: {response.text}")
|
||||
else:
|
||||
logger.error(f"发送 {robot.type} 通知失败: {response.text}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"发送 {robot.type} 通知出错: {str(e)}", exc_info=True)
|
||||
128
backend/apps/utils/permissions.py
Normal file
128
backend/apps/utils/permissions.py
Normal file
@@ -0,0 +1,128 @@
|
||||
import json
|
||||
import logging
|
||||
from ..models import User, UserRole
|
||||
|
||||
logger = logging.getLogger('apps')
|
||||
|
||||
def get_user_permissions(user_id):
|
||||
"""
|
||||
获取用户的权限信息
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
|
||||
Returns:
|
||||
dict: 用户权限信息,包含菜单权限、功能权限和数据权限
|
||||
"""
|
||||
try:
|
||||
# 获取用户信息
|
||||
try:
|
||||
user = User.objects.get(user_id=user_id)
|
||||
except User.DoesNotExist:
|
||||
logger.error(f'获取用户权限时用户不存在: {user_id}')
|
||||
return {
|
||||
'menu': [],
|
||||
'function': {},
|
||||
'data': {
|
||||
'project_scope': 'all',
|
||||
'project_ids': [],
|
||||
'environment_scope': 'all',
|
||||
'environment_types': []
|
||||
}
|
||||
}
|
||||
|
||||
# 获取用户的所有角色
|
||||
user_roles = UserRole.objects.filter(user=user).select_related('role')
|
||||
|
||||
# 合并所有角色的权限
|
||||
menu_permissions = set()
|
||||
function_permissions = {}
|
||||
data_permissions = {
|
||||
'project_scope': 'all',
|
||||
'project_ids': [],
|
||||
'environment_scope': 'all',
|
||||
'environment_types': []
|
||||
}
|
||||
|
||||
has_custom_project_scope = False
|
||||
has_custom_environment_scope = False
|
||||
|
||||
for user_role in user_roles:
|
||||
role = user_role.role
|
||||
permissions = role.permissions
|
||||
|
||||
# 如果permissions是字符串,解析为JSON
|
||||
if isinstance(permissions, str):
|
||||
try:
|
||||
permissions = json.loads(permissions)
|
||||
except:
|
||||
permissions = {}
|
||||
|
||||
# 合并菜单权限
|
||||
if permissions.get('menu'):
|
||||
menu_permissions.update(permissions['menu'])
|
||||
|
||||
# 合并功能权限
|
||||
if permissions.get('function'):
|
||||
for module, actions in permissions['function'].items():
|
||||
if module not in function_permissions:
|
||||
function_permissions[module] = []
|
||||
function_permissions[module].extend(actions)
|
||||
# 确保不重复
|
||||
function_permissions[module] = list(set(function_permissions[module]))
|
||||
|
||||
# 合并数据权限
|
||||
if permissions.get('data'):
|
||||
data_perms = permissions['data']
|
||||
|
||||
# 项目权限
|
||||
if data_perms.get('project_scope') == 'custom':
|
||||
has_custom_project_scope = True
|
||||
if data_permissions['project_scope'] == 'all':
|
||||
data_permissions['project_scope'] = 'custom'
|
||||
data_permissions['project_ids'] = data_perms.get('project_ids', [])
|
||||
else:
|
||||
# 合并项目ID列表
|
||||
data_permissions['project_ids'].extend(data_perms.get('project_ids', []))
|
||||
# 确保不重复
|
||||
data_permissions['project_ids'] = list(set(data_permissions['project_ids']))
|
||||
|
||||
# 环境权限
|
||||
if data_perms.get('environment_scope') == 'custom':
|
||||
has_custom_environment_scope = True
|
||||
if data_permissions['environment_scope'] == 'all':
|
||||
data_permissions['environment_scope'] = 'custom'
|
||||
data_permissions['environment_types'] = data_perms.get('environment_types', [])
|
||||
else:
|
||||
# 合并环境类型列表
|
||||
data_permissions['environment_types'].extend(data_perms.get('environment_types', []))
|
||||
# 确保不重复
|
||||
data_permissions['environment_types'] = list(set(data_permissions['environment_types']))
|
||||
|
||||
# 如果没有任何角色有自定义项目/环境范围,保持为'all'
|
||||
if not has_custom_project_scope:
|
||||
data_permissions['project_scope'] = 'all'
|
||||
data_permissions['project_ids'] = []
|
||||
|
||||
if not has_custom_environment_scope:
|
||||
data_permissions['environment_scope'] = 'all'
|
||||
data_permissions['environment_types'] = []
|
||||
|
||||
return {
|
||||
'menu': list(menu_permissions),
|
||||
'function': function_permissions,
|
||||
'data': data_permissions
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f'获取用户权限失败: {str(e)}', exc_info=True)
|
||||
# 返回默认的空权限
|
||||
return {
|
||||
'menu': [],
|
||||
'function': {},
|
||||
'data': {
|
||||
'project_scope': 'all',
|
||||
'project_ids': [],
|
||||
'environment_scope': 'all',
|
||||
'environment_types': []
|
||||
}
|
||||
}
|
||||
214
backend/apps/utils/security.py
Normal file
214
backend/apps/utils/security.py
Normal file
@@ -0,0 +1,214 @@
|
||||
import re
|
||||
import hashlib
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from django.utils import timezone
|
||||
from ..models import SecurityConfig, LoginAttempt, User
|
||||
|
||||
logger = logging.getLogger('apps')
|
||||
|
||||
class SecurityValidator:
|
||||
"""安全验证工具类"""
|
||||
|
||||
@staticmethod
|
||||
def get_security_config():
|
||||
"""获取安全配置"""
|
||||
try:
|
||||
config, created = SecurityConfig.objects.get_or_create(
|
||||
id=1,
|
||||
defaults={
|
||||
'min_password_length': 8,
|
||||
'password_complexity': ['lowercase', 'number'],
|
||||
'session_timeout': 120,
|
||||
'max_login_attempts': 5,
|
||||
'lockout_duration': 30,
|
||||
'enable_2fa': False
|
||||
}
|
||||
)
|
||||
return config
|
||||
except Exception as e:
|
||||
logger.error(f'获取安全配置失败: {str(e)}')
|
||||
# 返回默认配置
|
||||
return type('SecurityConfig', (), {
|
||||
'min_password_length': 8,
|
||||
'password_complexity': ['lowercase', 'number'],
|
||||
'session_timeout': 120,
|
||||
'max_login_attempts': 5,
|
||||
'lockout_duration': 30,
|
||||
'enable_2fa': False
|
||||
})()
|
||||
|
||||
@staticmethod
|
||||
def validate_password(password):
|
||||
"""验证密码是否符合安全策略"""
|
||||
config = SecurityValidator.get_security_config()
|
||||
|
||||
# 检查密码长度
|
||||
if len(password) < config.min_password_length:
|
||||
return False, f'密码长度不能少于{config.min_password_length}位'
|
||||
|
||||
# 检查密码复杂度
|
||||
complexity_checks = {
|
||||
'uppercase': (r'[A-Z]', '大写字母'),
|
||||
'lowercase': (r'[a-z]', '小写字母'),
|
||||
'number': (r'[0-9]', '数字'),
|
||||
'special': (r'[!@#$%^&*(),.?":{}|<>]', '特殊字符')
|
||||
}
|
||||
|
||||
missing_requirements = []
|
||||
for requirement in config.password_complexity:
|
||||
if requirement in complexity_checks:
|
||||
pattern, description = complexity_checks[requirement]
|
||||
if not re.search(pattern, password):
|
||||
missing_requirements.append(description)
|
||||
|
||||
if missing_requirements:
|
||||
return False, f'密码必须包含: {", ".join(missing_requirements)}'
|
||||
|
||||
return True, '密码验证通过'
|
||||
|
||||
@staticmethod
|
||||
def check_account_lockout(user, ip_address):
|
||||
"""检查账户是否被锁定"""
|
||||
try:
|
||||
# 首先检查用户表中的status字段
|
||||
if user.status == 0:
|
||||
return False, '账户已被锁定,请联系管理员解锁'
|
||||
|
||||
config = SecurityValidator.get_security_config()
|
||||
|
||||
# 获取登录尝试记录
|
||||
try:
|
||||
attempt = LoginAttempt.objects.get(user=user, ip_address=ip_address)
|
||||
except LoginAttempt.DoesNotExist:
|
||||
return True, '账户未被锁定'
|
||||
|
||||
# 如果账户被锁定,检查是否已过期
|
||||
if attempt.locked_until and attempt.locked_until > timezone.now():
|
||||
remaining_time = attempt.locked_until - timezone.now()
|
||||
minutes = int(remaining_time.total_seconds() / 60)
|
||||
return False, f'账户因登录失败次数过多被临时锁定,请在{minutes}分钟后重试'
|
||||
|
||||
# 如果临时锁定已过期,重置失败次数并解锁
|
||||
if attempt.locked_until and attempt.locked_until <= timezone.now():
|
||||
attempt.failed_attempts = 0
|
||||
attempt.locked_until = None
|
||||
attempt.save()
|
||||
|
||||
# 如果用户被系统锁定(status=0),检查是否需要自动解锁
|
||||
if user.status == 0:
|
||||
# 暂时保持锁定状态,需要管理员手动解锁
|
||||
return False, '账户已被锁定,请联系管理员解锁'
|
||||
|
||||
return True, '账户未被锁定'
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'检查账户锁定状态失败: {str(e)}')
|
||||
return True, '锁定检查跳过'
|
||||
|
||||
@staticmethod
|
||||
def record_failed_login(user, ip_address):
|
||||
"""记录登录失败"""
|
||||
try:
|
||||
config = SecurityValidator.get_security_config()
|
||||
|
||||
# 获取或创建登录尝试记录
|
||||
attempt, created = LoginAttempt.objects.get_or_create(
|
||||
user=user,
|
||||
ip_address=ip_address,
|
||||
defaults={'failed_attempts': 0}
|
||||
)
|
||||
|
||||
# 增加失败次数
|
||||
attempt.failed_attempts += 1
|
||||
attempt.last_attempt_time = timezone.now()
|
||||
|
||||
# 如果达到最大失败次数,锁定账户
|
||||
if attempt.failed_attempts >= config.max_login_attempts:
|
||||
attempt.locked_until = timezone.now() + timedelta(minutes=config.lockout_duration)
|
||||
|
||||
# 同时将用户状态设置为锁定
|
||||
user.status = 0
|
||||
user.save()
|
||||
|
||||
logger.warning(f'用户[{user.username}]账户因多次登录失败被锁定,IP: {ip_address}')
|
||||
|
||||
attempt.save()
|
||||
|
||||
return attempt.failed_attempts, config.max_login_attempts
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'记录登录失败失败: {str(e)}')
|
||||
return 0, 5
|
||||
|
||||
@staticmethod
|
||||
def record_successful_login(user, ip_address):
|
||||
"""记录登录成功,重置失败次数"""
|
||||
try:
|
||||
# 清除登录失败记录
|
||||
LoginAttempt.objects.filter(
|
||||
user=user,
|
||||
ip_address=ip_address
|
||||
).delete()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'重置登录失败记录失败: {str(e)}')
|
||||
|
||||
@staticmethod
|
||||
def unlock_user_account(user):
|
||||
"""解锁用户账户(管理员操作)"""
|
||||
try:
|
||||
# 清除所有登录尝试记录
|
||||
LoginAttempt.objects.filter(user=user).delete()
|
||||
|
||||
# 解锁用户状态
|
||||
if user.status == 0:
|
||||
user.status = 1
|
||||
user.save()
|
||||
logger.info(f'管理员解锁了用户[{user.username}]的账户')
|
||||
return True, '账户解锁成功'
|
||||
|
||||
return True, '账户状态正常'
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'解锁用户账户失败: {str(e)}')
|
||||
return False, '解锁失败'
|
||||
|
||||
@staticmethod
|
||||
def lock_user_account(user):
|
||||
"""锁定用户账户(管理员操作)"""
|
||||
try:
|
||||
# 锁定用户状态
|
||||
if user.status == 1:
|
||||
user.status = 0
|
||||
user.save()
|
||||
logger.info(f'管理员锁定了用户[{user.username}]的账户')
|
||||
return True, '账户锁定成功'
|
||||
|
||||
return True, '账户已处于锁定状态'
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'锁定用户账户失败: {str(e)}')
|
||||
return False, '锁定失败'
|
||||
|
||||
@staticmethod
|
||||
def is_session_expired(login_time):
|
||||
"""检查会话是否过期"""
|
||||
try:
|
||||
config = SecurityValidator.get_security_config()
|
||||
|
||||
if not login_time:
|
||||
return True
|
||||
|
||||
# 计算会话过期时间
|
||||
session_expire_time = login_time + timedelta(minutes=config.session_timeout)
|
||||
|
||||
return timezone.now() > session_expire_time
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'检查会话过期失败: {str(e)}')
|
||||
return False
|
||||
|
||||
def validate_password_strength(password):
|
||||
"""独立的密码强度验证函数,供其他模块使用"""
|
||||
return SecurityValidator.validate_password(password)
|
||||
Reference in New Issue
Block a user