Files
CapacityReport/build/build.py
T

991 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
CapacityReport 统一构建脚本
支持完整部署包和更新包的构建
"""
import os
import sys
import json
import platform
import shutil
import subprocess
import re
import tarfile
import traceback
from pathlib import Path
# ============================================================================
# 配置常量 - 可根据需要修改
# ============================================================================
# 默认镜像版本
DEFAULT_MYSQL_VERSION = "8.0.44"
DEFAULT_PYTHON_VERSION = "3.13.11-slim"
# 版本要求
MYSQL_MIN_VERSION = (8, 0, 0) # MySQL >= 8.0
MYSQL_MAX_VERSION = (9, 0, 0) # MySQL < 9.0
PYTHON_MIN_VERSION = (3, 10, 0) # Python >= 3.10
PYTHON_MAX_VERSION = (3, 14, 0) # Python < 3.14
# 数据库配置
MYSQL_ROOT_USER = "root"
MYSQL_ROOT_PASSWORD = "gmcc123"
MYSQL_DATABASE = "CapacityReport"
MYSQL_HOST = "capacity-mysql"
MYSQL_PORT = 3306
# 应用端口映射
APP_PORT_HOST = 19081
MYSQL_PORT_HOST = 13306
# ============================================================================
# 脚本目录配置
# ============================================================================
SCRIPT_DIR = Path(__file__).parent.absolute()
PROJECT_ROOT = SCRIPT_DIR.parent
DIST_DIR = PROJECT_ROOT / "dist"
BUILD_DIR = PROJECT_ROOT / "build"
TEMP_BUILD_DIR = BUILD_DIR / "temp"
if platform.system() == 'Windows':
try:
import ctypes
kernel32 = ctypes.windll.kernel32
kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7)
except OSError:
pass
class Colors:
GREEN = '\033[92m'
YELLOW = '\033[93m'
RED = '\033[91m'
BLUE = '\033[94m'
RESET = '\033[0m'
def print_step(msg):
print(f"\n{Colors.GREEN}{msg}{Colors.RESET}")
def print_info(msg):
print(f" {msg}")
def print_warning(msg):
print(f"{Colors.YELLOW}警告: {msg}{Colors.RESET}")
def print_error(msg):
print(f"{Colors.RED}错误: {msg}{Colors.RESET}")
def print_choice(msg):
print(f"{Colors.BLUE}{msg}{Colors.RESET}")
def run_cmd(cmd, check=True, capture_output=False):
"""执行命令"""
if isinstance(cmd, str):
cmd = cmd.split()
try:
result = subprocess.run(
cmd,
check=check,
capture_output=capture_output,
text=True
)
return result
except subprocess.CalledProcessError:
if check:
print_error(f"命令执行失败: {' '.join(cmd)}")
sys.exit(1)
return None
except FileNotFoundError:
print_error(f"命令未找到: {cmd[0]}")
sys.exit(1)
def check_command(cmd):
"""检查命令是否存在"""
try:
if cmd == "docker":
subprocess.run([cmd, "--version"], capture_output=True, check=True)
elif cmd == "docker-compose":
subprocess.run([cmd, "version"], capture_output=True, check=True)
return True
except (subprocess.CalledProcessError, FileNotFoundError):
return False
def parse_version(version_str):
"""解析版本字符串为元组,例如 '8.0.44' -> (8, 0, 44)"""
parts = version_str.split('.')
try:
# 处理带后缀的版本,如 '3.13.11-slim' -> (3, 13, 11)
base_version = parts[0] if len(parts) > 0 else '0'
major = int(base_version)
minor = int(parts[1]) if len(parts) > 1 else 0
patch_str = parts[2] if len(parts) > 2 else '0'
# 移除后缀,如 '11-slim' -> '11'
patch = int(patch_str.split('-')[0])
return (major, minor, patch)
except (ValueError, IndexError):
return None
def check_version_in_range(version_tuple, min_version, max_version):
"""检查版本是否在指定范围内 [min, max)"""
if version_tuple is None:
return False
return min_version <= version_tuple < max_version
def get_python_tag_from_dockerfile():
"""从 Dockerfile 中提取 Python 镜像标签"""
# 从 build 目录读取 Dockerfile
dockerfile_path = BUILD_DIR / "Dockerfile"
if not dockerfile_path.exists():
print_error(f"Dockerfile 不存在: {dockerfile_path}")
print_error("请确保 build/Dockerfile 文件存在")
sys.exit(1)
with open(dockerfile_path, 'r', encoding='utf-8') as f:
for line in f:
match = re.match(r'^FROM\s+python:(.+?)(?:\s|$)', line, re.IGNORECASE)
if match:
tag = match.group(1).strip()
return tag
print_error("无法从 Dockerfile 中找到 Python 镜像标签")
print_error("请确保 Dockerfile 中包含 'FROM python:xxx' 行")
sys.exit(1)
def check_and_get_python_image():
"""检查并获取符合要求的 Python 镜像"""
dockerfile_tag = get_python_tag_from_dockerfile()
dockerfile_version = parse_version(dockerfile_tag)
# 检查 Dockerfile 中的版本是否符合要求
if dockerfile_version and check_version_in_range(
dockerfile_version, PYTHON_MIN_VERSION, PYTHON_MAX_VERSION
):
print_info(f"Dockerfile 中的 Python 版本符合要求: {dockerfile_tag}")
return f"python:{dockerfile_tag}"
else:
print_warning(f"Dockerfile 中的 Python 版本 {dockerfile_tag} 不符合要求")
print_warning(f"要求: Python >= {'.'.join(map(str, PYTHON_MIN_VERSION))} < {'.'.join(map(str, PYTHON_MAX_VERSION))}")
print_info(f"将使用默认版本: {DEFAULT_PYTHON_VERSION}")
print_info(f"如果镜像不存在,将自动拉取: python:{DEFAULT_PYTHON_VERSION}")
return f"python:{DEFAULT_PYTHON_VERSION}"
def check_and_get_mysql_image():
"""检查并获取符合要求的 MySQL 镜像"""
# 检查默认版本是否符合要求
default_version = parse_version(DEFAULT_MYSQL_VERSION)
if default_version and check_version_in_range(
default_version, MYSQL_MIN_VERSION, MYSQL_MAX_VERSION
):
print_info(f"默认 MySQL 版本符合要求: {DEFAULT_MYSQL_VERSION}")
return f"mysql:{DEFAULT_MYSQL_VERSION}"
else:
print_error(f"默认 MySQL 版本 {DEFAULT_MYSQL_VERSION} 不符合要求")
print_error(f"要求: MySQL >= {'.'.join(map(str, MYSQL_MIN_VERSION))} < {'.'.join(map(str, MYSQL_MAX_VERSION))}")
sys.exit(1)
def image_exists(image_name):
"""检查镜像是否已存在"""
try:
result = subprocess.run(
["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"],
capture_output=True,
text=True,
check=True
)
return image_name in result.stdout
except (subprocess.CalledProcessError, FileNotFoundError):
return False
def format_size(size_bytes):
"""格式化文件大小"""
for unit in ['B', 'KB', 'MB', 'GB']:
if size_bytes < 1024.0:
return f"{size_bytes:.1f} {unit}"
size_bytes /= 1024.0
return f"{size_bytes:.1f} TB"
def write_sh_script(file_path, content):
"""写入 shell 脚本,确保使用 LF 换行和 UTF-8 编码"""
# 确保内容使用 LF 换行
content = content.replace('\r\n', '\n').replace('\r', '\n')
# 写入文件(二进制模式确保换行符不被转换)
with open(file_path, 'wb') as f:
f.write(content.encode('utf-8'))
# 设置执行权限(在 Linux 上)
if platform.system() != 'Windows':
os.chmod(file_path, 0o755)
def get_deploy_sh():
"""生成 deploy.sh 脚本内容"""
mysql_version = DEFAULT_MYSQL_VERSION
return f'''#!/bin/bash
# CapacityReport Deployment Script
# Run this script on offline machine to import images and start services
set -e
SCRIPT_DIR="$(cd "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)"
cd "$SCRIPT_DIR"
# Set permissions automatically
chmod -R 0777 "$SCRIPT_DIR" 2>/dev/null || true
find "$SCRIPT_DIR" -type f \\( -name "*.sh" -o -name "*.py" \\) -exec chmod +x {{}} \\; 2>/dev/null || true
echo "=========================================="
echo "CapacityReport Deployment"
echo "=========================================="
echo ""
# Check Docker
if ! command -v docker &> /dev/null; then
echo "Error: Docker not found, please install Docker"
exit 1
fi
# Check Docker Compose
DOCKER_COMPOSE_CMD=""
if docker compose version &> /dev/null; then
DOCKER_COMPOSE_CMD="docker compose"
elif command -v docker-compose &> /dev/null; then
DOCKER_COMPOSE_CMD="docker-compose"
else
echo "Error: Docker Compose not found, please install Docker Compose"
exit 1
fi
# Check image file
if [ ! -f "images/capacity-images.tar" ]; then
echo "Error: Image file not found: images/capacity-images.tar"
exit 1
fi
echo "Step 1: Checking ports..."
check_port() {{
local port=${{1}}
local name=${{2}}
local in_use=false
if command -v lsof &> /dev/null; then
if lsof -Pi :$port -sTCP:LISTEN -t >/dev/null 2>&1; then
in_use=true
fi
elif command -v netstat &> /dev/null; then
if netstat -tuln 2>/dev/null | grep -q ":$port "; then
in_use=true
fi
elif command -v ss &> /dev/null; then
if ss -tuln 2>/dev/null | grep -q ":$port "; then
in_use=true
fi
fi
if [ "$in_use" = true ]; then
echo "Warning: Port $port ($name) is in use"
read -p "Continue? (y/n) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
exit 1
fi
fi
{{}}
check_port {APP_PORT_HOST} "Frontend"
check_port {MYSQL_PORT_HOST} "MySQL"
echo ""
echo "Step 2: Loading images..."
docker load -i images/capacity-images.tar >/dev/null 2>&1
echo ""
echo "Step 3: Verifying images..."
if docker images | grep -q "capacity-report-app.*latest"; then
echo " App image: OK"
else
echo " App image: FAILED"
exit 1
fi
if docker images | grep -q "capacity-mysql.*{mysql_version}"; then
echo " MySQL image: OK"
else
echo " MySQL image: FAILED"
exit 1
fi
echo ""
echo "Step 4: Creating directories..."
mkdir -p cache logs
chmod 777 cache logs 2>/dev/null || true
echo ""
echo "Step 5: Checking config..."
if [ ! -f "Configure.json" ]; then
echo " Creating default Configure.json..."
cat > Configure.json << 'EOF'
{{
"MySQL_DBInfo": {{
"host": "{MYSQL_HOST}",
"port": {MYSQL_PORT},
"user": "{MYSQL_ROOT_USER}",
"passwd": "{MYSQL_ROOT_PASSWORD}",
"dbname": "{MYSQL_DATABASE}"
}},
"ExtractField": []
}}
EOF
else
if command -v python3 &> /dev/null || command -v python &> /dev/null; then
(command -v python3 >/dev/null && python3 || python) << 'PYEOF'
import json
import sys
try:
with open('Configure.json', 'r', encoding='utf-8') as f:
config = json.load(f)
db_info = config.get('MySQL_DBInfo', {{}})
needs_update = False
if db_info.get('host') != '{MYSQL_HOST}':
db_info['host'] = '{MYSQL_HOST}'
needs_update = True
if db_info.get('port') != {MYSQL_PORT}:
db_info['port'] = {MYSQL_PORT}
needs_update = True
if db_info.get('user') != '{MYSQL_ROOT_USER}':
db_info['user'] = '{MYSQL_ROOT_USER}'
needs_update = True
if db_info.get('passwd') != '{MYSQL_ROOT_PASSWORD}':
db_info['passwd'] = '{MYSQL_ROOT_PASSWORD}'
needs_update = True
if db_info.get('dbname') != '{MYSQL_DATABASE}':
db_info['dbname'] = '{MYSQL_DATABASE}'
needs_update = True
if needs_update:
with open('Configure.json', 'w', encoding='utf-8') as f:
json.dump(config, f, ensure_ascii=False, indent=2)
print(" Updated Configure.json")
except:
pass
PYEOF
fi
fi
if [ ! -f "ReportScript.sql" ]; then
touch ReportScript.sql
fi
echo ""
echo "Step 6: Starting services..."
$DOCKER_COMPOSE_CMD down 2>/dev/null || true
$DOCKER_COMPOSE_CMD up -d >/dev/null 2>&1
echo ""
echo "Step 7: Waiting for services..."
for i in {{1..30}}; do
if docker exec capacity-mysql mysqladmin ping -h localhost -u {MYSQL_ROOT_USER} -p{MYSQL_ROOT_PASSWORD} --silent 2>/dev/null; then
echo " MySQL: Ready"
break
fi
if [ $i -eq 30 ]; then
echo " MySQL: Timeout"
exit 1
fi
sleep 2
done
if command -v curl &> /dev/null; then
for i in {{1..30}}; do
if curl -f http://localhost:{APP_PORT_HOST}/health >/dev/null 2>&1; then
echo " App: Ready"
break
fi
if [ $i -eq 30 ]; then
echo " App: Timeout"
echo " Check logs: $DOCKER_COMPOSE_CMD logs capacity-app"
exit 1
fi
sleep 2
done
else
for i in {{1..30}}; do
if docker ps --filter "name=capacity-report-app" --filter "status=running" --format "{{.Names}}" | grep -q "capacity-report-app"; then
if docker inspect capacity-report-app --format='{{.State.Health.Status}}' 2>/dev/null | grep -q "healthy"; then
echo " App: Ready"
break
elif [ $i -gt 10 ]; then
echo " App: Running"
break
fi
fi
if [ $i -eq 30 ]; then
echo " App: Timeout"
exit 1
fi
sleep 2
done
fi
echo ""
echo "=========================================="
echo "Deployment completed!"
echo "=========================================="
echo ""
echo "Frontend: http://localhost:{APP_PORT_HOST}"
echo ""
echo "Commands:"
echo " Logs: $DOCKER_COMPOSE_CMD logs -f"
echo " Stop: $DOCKER_COMPOSE_CMD down"
echo " Restart: $DOCKER_COMPOSE_CMD restart"
echo " Status: $DOCKER_COMPOSE_CMD ps"
echo ""
echo "Database:"
echo " Host: localhost"
echo " Port: {MYSQL_PORT_HOST}"
echo " User: {MYSQL_ROOT_USER}"
echo " Password: {MYSQL_ROOT_PASSWORD}"
echo " Database: {MYSQL_DATABASE}"
echo ""
'''
def get_update_sh():
"""生成 update.sh 脚本内容"""
return f'''#!/bin/bash
# CapacityReport Update Script
# Update application container without affecting database
# Run this script when you need to update the application
set -e
SCRIPT_DIR="$(cd "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)"
cd "$SCRIPT_DIR"
# Set permissions automatically
chmod -R 0777 "$SCRIPT_DIR" 2>/dev/null || true
find "$SCRIPT_DIR" -type f \\( -name "*.sh" -o -name "*.py" \\) -exec chmod +x {{}} \\; 2>/dev/null || true
echo "=========================================="
echo "CapacityReport Application Update"
echo "=========================================="
echo ""
# Check Docker
if ! command -v docker &> /dev/null; then
echo "Error: Docker not found, please install Docker"
exit 1
fi
# Check Docker Compose
DOCKER_COMPOSE_CMD=""
if docker compose version &> /dev/null; then
DOCKER_COMPOSE_CMD="docker compose"
elif command -v docker-compose &> /dev/null; then
DOCKER_COMPOSE_CMD="docker-compose"
else
echo "Error: Docker Compose not found, please install Docker Compose"
exit 1
fi
# Check if services are running
if ! docker ps --format "{{.Names}}" | grep -q "capacity-report-app"; then
echo "Error: Application container is not running"
echo "Please use deploy.sh for initial deployment"
exit 1
fi
echo "Step 1: Checking current status..."
if docker ps --format "{{.Names}}" | grep -q "capacity-mysql"; then
echo " MySQL: Running"
else
echo " Warning: MySQL container is not running"
read -p "Continue? (y/n) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
exit 1
fi
fi
echo ""
echo "Step 2: Loading new application image..."
if [ -f "images/capacity-app-update.tar" ]; then
echo " Loading images from capacity-app-update.tar..."
docker load -i images/capacity-app-update.tar >/dev/null 2>&1
if docker images | grep -q "capacity-report-app.*latest"; then
echo " App image: Loaded"
else
echo " App image: Not found in tar file"
exit 1
fi
else
echo " Error: Image file not found: images/capacity-app-update.tar"
exit 1
fi
echo ""
echo "Step 3: Stopping application container..."
$DOCKER_COMPOSE_CMD stop capacity-app 2>/dev/null || true
$DOCKER_COMPOSE_CMD rm -f capacity-app 2>/dev/null || true
echo " Application stopped"
echo ""
echo "Step 4: Updating configuration files..."
if [ -f "Configure.json" ]; then
if command -v python3 &> /dev/null || command -v python &> /dev/null; then
(command -v python3 >/dev/null && python3 || python) << 'PYEOF'
import json
import sys
try:
with open('Configure.json', 'r', encoding='utf-8') as f:
config = json.load(f)
db_info = config.get('MySQL_DBInfo', {{}})
needs_update = False
if db_info.get('host') != '{MYSQL_HOST}':
db_info['host'] = '{MYSQL_HOST}'
needs_update = True
if db_info.get('port') != {MYSQL_PORT}:
db_info['port'] = {MYSQL_PORT}
needs_update = True
if needs_update:
with open('Configure.json', 'w', encoding='utf-8') as f:
json.dump(config, f, ensure_ascii=False, indent=2)
print(" Updated Configure.json")
except:
pass
PYEOF
fi
else
echo " Warning: Configure.json not found, creating default..."
cat > Configure.json << 'EOF'
{{
"MySQL_DBInfo": {{
"host": "{MYSQL_HOST}",
"port": {MYSQL_PORT},
"user": "{MYSQL_ROOT_USER}",
"passwd": "{MYSQL_ROOT_PASSWORD}",
"dbname": "{MYSQL_DATABASE}"
}},
"ExtractField": []
}}
EOF
fi
if [ ! -f "ReportScript.sql" ]; then
touch ReportScript.sql
fi
mkdir -p cache logs
chmod 777 cache logs 2>/dev/null || true
echo ""
echo "Step 5: Starting application container..."
$DOCKER_COMPOSE_CMD up -d capacity-app >/dev/null 2>&1
echo " Application started"
echo ""
echo "Step 6: Waiting for application to be ready..."
if command -v curl &> /dev/null; then
for i in {{1..30}}; do
if curl -f http://localhost:{APP_PORT_HOST}/health >/dev/null 2>&1; then
echo " App: Ready"
break
fi
if [ $i -eq 30 ]; then
echo " App: Timeout (may still be starting)"
echo " Check logs: $DOCKER_COMPOSE_CMD logs capacity-app"
exit 1
fi
sleep 2
done
else
for i in {{1..30}}; do
if docker ps --filter "name=capacity-report-app" --filter "status=running" --format "{{.Names}}" | grep -q "capacity-report-app"; then
if docker inspect capacity-report-app --format='{{.State.Health.Status}}' 2>/dev/null | grep -q "healthy"; then
echo " App: Ready"
break
elif [ $i -gt 10 ]; then
echo " App: Running"
break
fi
fi
if [ $i -eq 30 ]; then
echo " App: Timeout"
echo " Check logs: $DOCKER_COMPOSE_CMD logs capacity-app"
exit 1
fi
sleep 2
done
fi
echo ""
echo "=========================================="
echo "Update completed!"
echo "=========================================="
echo ""
echo "Frontend: http://localhost:{APP_PORT_HOST}"
echo ""
echo "Database status:"
if docker ps --format "{{.Names}}" | grep -q "capacity-mysql"; then
echo " MySQL: Running (unchanged)"
else
echo " MySQL: Not running"
fi
echo ""
echo "Useful commands:"
echo " View logs: $DOCKER_COMPOSE_CMD logs -f capacity-app"
echo " Restart app: $DOCKER_COMPOSE_CMD restart capacity-app"
echo " Status: $DOCKER_COMPOSE_CMD ps"
echo ""
'''
def build_full_package():
"""构建完整部署包"""
print_step("构建完整部署包...")
# 清理临时目录
if TEMP_BUILD_DIR.exists():
shutil.rmtree(TEMP_BUILD_DIR)
TEMP_BUILD_DIR.mkdir(parents=True, exist_ok=True)
# 检查 Docker
if not check_command("docker"):
print_error("未检测到 Docker,请先安装 Docker Desktop")
sys.exit(1)
# 检查 Docker Compose
try:
subprocess.run(["docker", "compose", "version"], capture_output=True, check=True)
except (subprocess.CalledProcessError, FileNotFoundError):
if not check_command("docker-compose"):
print_error("未检测到 Docker Compose")
sys.exit(1)
# 检查并获取符合要求的镜像
print_step("检查镜像版本...")
python_image = check_and_get_python_image()
mysql_image = check_and_get_mysql_image()
# 拉取基础镜像(如果不存在则自动拉取)
print_step("拉取基础镜像...")
if not image_exists(python_image):
print_info(f"Python 镜像不存在,正在拉取: {python_image}")
run_cmd(["docker", "pull", python_image])
print_info("Python 镜像拉取完成")
else:
print_info(f"Python 镜像已存在: {python_image}")
if not image_exists(mysql_image):
print_info(f"MySQL 镜像不存在,正在拉取: {mysql_image}")
run_cmd(["docker", "pull", mysql_image])
print_info("MySQL 镜像拉取完成")
else:
print_info(f"MySQL 镜像已存在: {mysql_image}")
# 构建应用镜像
print_step("构建应用镜像...")
# 使用 build 目录的 Dockerfile
dockerfile_path = BUILD_DIR / "Dockerfile"
if not dockerfile_path.exists():
print_error(f"Dockerfile 不存在: {dockerfile_path}")
sys.exit(1)
print_info(f"使用 build 目录的 Dockerfile: {dockerfile_path}")
# 构建时使用项目根目录作为上下文(因为需要复制应用代码)
os.chdir(PROJECT_ROOT)
# 但使用 build 目录的 Dockerfile
dockerfile_arg = str(dockerfile_path)
# .dockerignore 会自动使用构建上下文根目录的(即 PROJECT_ROOT/.dockerignore)
# 从 build 目录复制 .dockerignore 到根目录(临时,构建时使用)
dockerignore_build = BUILD_DIR / ".dockerignore"
dockerignore_root = PROJECT_ROOT / ".dockerignore"
if dockerignore_build.exists():
shutil.copy2(dockerignore_build, dockerignore_root)
print_info("已使用 build 目录的 .dockerignore")
run_cmd(["docker", "build", "-t", "capacity-report-app:latest", "-f", dockerfile_arg, "."])
# 清理临时复制的 .dockerignore(如果根目录原本没有)
if dockerignore_build.exists() and dockerignore_root.exists():
try:
# 如果内容相同,说明是从 build 目录复制的,可以删除
if dockerignore_build.read_text() == dockerignore_root.read_text():
dockerignore_root.unlink()
print_info("已清理临时 .dockerignore")
except OSError:
pass
# 标记 MySQL 镜像(提取版本号)
mysql_tag = mysql_image.split(':')[1]
mysql_tagged = f"capacity-mysql:{mysql_tag}"
run_cmd(["docker", "tag", mysql_image, mysql_tagged])
# 导出镜像
print_step("导出镜像...")
images_dir = TEMP_BUILD_DIR / "images"
images_dir.mkdir(parents=True, exist_ok=True)
tar_file = images_dir / "capacity-images.tar"
# 提取 MySQL 标签
mysql_tag = mysql_image.split(':')[1]
mysql_tagged = f"capacity-mysql:{mysql_tag}"
run_cmd([
"docker", "save",
"capacity-report-app:latest",
mysql_tagged,
"-o", str(tar_file)
])
print_info(f"镜像导出完成: {format_size(tar_file.stat().st_size)}")
# 复制配置文件
print_step("复制配置文件...")
# 优先从 build 目录读取,如果不存在则从根目录读取
def get_source_path(filename, subdir=None):
"""获取源文件路径,优先 build 目录,其次根目录"""
if subdir:
build_path = BUILD_DIR / subdir / filename
root_path = PROJECT_ROOT / subdir / filename
else:
build_path = BUILD_DIR / filename
root_path = PROJECT_ROOT / filename
return build_path if build_path.exists() else root_path
# Configure.json 和 ReportScript.sql 从项目根目录读取(应用配置文件)
files_to_copy = [
(PROJECT_ROOT / "Configure.json", TEMP_BUILD_DIR / "Configure.json"),
(PROJECT_ROOT / "ReportScript.sql", TEMP_BUILD_DIR / "ReportScript.sql"),
(get_source_path("docker-compose.yml"), TEMP_BUILD_DIR / "docker-compose.yml"),
(get_source_path("custom.cnf", "mysql/conf.d"), TEMP_BUILD_DIR / "mysql" / "conf.d" / "custom.cnf"),
(get_source_path("01-init-db.sql", "mysql/init"), TEMP_BUILD_DIR / "mysql" / "init" / "01-init-db.sql"),
]
for src, dst in files_to_copy:
if src.exists():
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
else:
print_warning(f"{src.name} 不存在: {src}")
# 更新 Configure.json
config_file = TEMP_BUILD_DIR / "Configure.json"
if config_file.exists():
with open(config_file, 'r', encoding='utf-8') as f:
config = json.load(f)
config['MySQL_DBInfo'] = {
'host': MYSQL_HOST,
'port': MYSQL_PORT,
'user': MYSQL_ROOT_USER,
'passwd': MYSQL_ROOT_PASSWORD,
'dbname': MYSQL_DATABASE
}
with open(config_file, 'w', encoding='utf-8') as f:
json.dump(config, f, ensure_ascii=False, indent=2)
# 生成 deploy.sh
print_step("生成部署脚本...")
write_sh_script(TEMP_BUILD_DIR / "deploy.sh", get_deploy_sh())
# 打包
print_step("打包部署包...")
tar_output = DIST_DIR / "capacity-report-full.tar.gz"
DIST_DIR.mkdir(exist_ok=True)
with tarfile.open(tar_output, 'w:gz') as tar:
tar.add(TEMP_BUILD_DIR, arcname='.')
print_info(f"部署包已生成: {tar_output}")
print_info(f"文件大小: {format_size(tar_output.stat().st_size)}")
# 清理临时文件和缓存
print_step("清理临时文件...")
if TEMP_BUILD_DIR.exists():
shutil.rmtree(TEMP_BUILD_DIR, ignore_errors=True)
print_info("已清理临时构建目录")
return tar_output
def build_update_package():
"""构建更新包"""
print_step("构建更新包...")
# 清理临时目录
if TEMP_BUILD_DIR.exists():
shutil.rmtree(TEMP_BUILD_DIR)
TEMP_BUILD_DIR.mkdir(parents=True, exist_ok=True)
# 检查 Docker
if not check_command("docker"):
print_error("未检测到 Docker")
sys.exit(1)
# 检查并获取符合要求的镜像
print_step("检查镜像版本...")
python_image = check_and_get_python_image()
# 拉取基础镜像(如果不存在则自动拉取)
print_step("拉取基础镜像...")
if not image_exists(python_image):
print_info(f"Python 镜像不存在,正在拉取: {python_image}")
run_cmd(["docker", "pull", python_image])
print_info("Python 镜像拉取完成")
else:
print_info(f"Python 镜像已存在: {python_image}")
# 构建应用镜像
print_step("构建应用镜像...")
# 使用 build 目录的 Dockerfile
dockerfile_path = BUILD_DIR / "Dockerfile"
if not dockerfile_path.exists():
print_error(f"Dockerfile 不存在: {dockerfile_path}")
sys.exit(1)
print_info(f"使用 build 目录的 Dockerfile: {dockerfile_path}")
# 构建时使用项目根目录作为上下文(因为需要复制应用代码)
os.chdir(PROJECT_ROOT)
# 但使用 build 目录的 Dockerfile
dockerfile_arg = str(dockerfile_path)
# .dockerignore 会自动使用构建上下文根目录的(即 PROJECT_ROOT/.dockerignore)
# 从 build 目录复制 .dockerignore 到根目录(临时,构建时使用)
dockerignore_build = BUILD_DIR / ".dockerignore"
dockerignore_root = PROJECT_ROOT / ".dockerignore"
if dockerignore_build.exists():
shutil.copy2(dockerignore_build, dockerignore_root)
print_info("已使用 build 目录的 .dockerignore")
run_cmd(["docker", "build", "-t", "capacity-report-app:latest", "-f", dockerfile_arg, "."])
# 清理临时复制的 .dockerignore(如果根目录原本没有)
if dockerignore_build.exists() and dockerignore_root.exists():
try:
# 如果内容相同,说明是从 build 目录复制的,可以删除
if dockerignore_build.read_text() == dockerignore_root.read_text():
dockerignore_root.unlink()
print_info("已清理临时 .dockerignore")
except OSError:
pass
# 导出镜像
print_step("导出镜像...")
images_dir = TEMP_BUILD_DIR / "images"
images_dir.mkdir(parents=True, exist_ok=True)
tar_file = images_dir / "capacity-app-update.tar"
run_cmd([
"docker", "save",
"capacity-report-app:latest",
"-o", str(tar_file)
])
print_info(f"镜像导出完成: {format_size(tar_file.stat().st_size)}")
# 复制配置文件
print_step("复制配置文件...")
# 从 build 目录读取配置文件
def get_source_path(filename, subdir=None):
"""获取源文件路径,从 build 目录读取"""
if subdir:
return BUILD_DIR / subdir / filename
else:
return BUILD_DIR / filename
files_to_copy = [
(get_source_path("docker-compose.yml"), TEMP_BUILD_DIR / "docker-compose.yml"),
]
for src, dst in files_to_copy:
if src.exists():
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
# 生成 update.sh
print_step("生成更新脚本...")
write_sh_script(TEMP_BUILD_DIR / "update.sh", get_update_sh())
# 打包
print_step("打包更新包...")
tar_output = DIST_DIR / "capacity-report-update.tar.gz"
DIST_DIR.mkdir(exist_ok=True)
with tarfile.open(tar_output, 'w:gz') as tar:
tar.add(TEMP_BUILD_DIR, arcname='.')
print_info(f"更新包已生成: {tar_output}")
print_info(f"文件大小: {format_size(tar_output.stat().st_size)}")
# 清理临时文件和缓存
print_step("清理临时文件...")
if TEMP_BUILD_DIR.exists():
shutil.rmtree(TEMP_BUILD_DIR, ignore_errors=True)
print_info("已清理临时构建目录")
return tar_output
def main():
print("=" * 60)
print("CapacityReport 统一构建脚本")
print("=" * 60)
print()
print_choice("请选择构建类型:")
print(" 1. 完整部署包 (包含 MySQL + 应用)")
print(" 2. 更新包 (仅应用)")
print()
while True:
choice = input("请输入选项 (1/2): ").strip()
if choice in ['1', '2']:
break
print_error("无效选项,请输入 1 或 2")
print()
if choice == '1':
tar_file = build_full_package()
print()
print("=" * 60)
print("构建完成!")
print("=" * 60)
print()
print(f"部署包位置: {tar_file}")
print()
print("使用方法:")
print("1. 将 tar.gz 文件传输到目标 Linux 机器")
print("2. 解压: tar -xzf capacity-report-full.tar.gz")
print("3. 进入解压目录,执行: sh deploy.sh")
print()
else:
tar_file = build_update_package()
print()
print("=" * 60)
print("构建完成!")
print("=" * 60)
print()
print(f"更新包位置: {tar_file}")
print()
print("使用方法:")
print("1. 将 tar.gz 文件传输到目标 Linux 机器")
print("2. 解压: tar -xzf capacity-report-update.tar.gz")
print("3. 进入解压目录,执行: sh update.sh")
print()
print("注意: 更新包仅用于更新已部署的应用,不包含 MySQL")
print()
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\n\n构建已取消")
sys.exit(1)
except Exception as e:
print_error(f"构建失败: {e}")
traceback.print_exc()
sys.exit(1)