Skip to content

Commit d4338e1

Browse files
committed
fix hot-reloading
1 parent 267ec96 commit d4338e1

4 files changed

Lines changed: 107 additions & 138 deletions

File tree

backend/src/module/api/program.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ async def check_for_update(include_prerelease: bool = False):
161161
)
162162

163163

164-
@router.post("program/update", response_model=APIResponse, dependencies=[Depends(get_current_user)])
164+
@router.post("/program/update", response_model=APIResponse, dependencies=[Depends(get_current_user)])
165165
async def update_program(download_url: str):
166166
"""Docker 环境更新接口
167167
@@ -194,19 +194,19 @@ async def perform_update():
194194
# 等待一小段时间让 API 响应返回
195195
await asyncio.sleep(1)
196196

197-
logger.info(f"[Program] Starting update from URL: {download_url}")
197+
logger.info(f"[Program] Starting update preparation from URL: {download_url}")
198198

199199
# 停止应用核心
200200
await program.stop()
201201

202-
# 执行更新
203-
await docker_updater.update(download_url)
202+
# 准备更新:下载、解压、创建标志文件
203+
await docker_updater.prepare_update(download_url)
204204

205-
# 强制重启容器
206-
docker_updater.force_restart()
205+
# 触发优雅重启,shell脚本会检测更新标志并执行替换
206+
docker_updater.trigger_graceful_restart()
207207

208208
except Exception as e:
209-
logger.error(f"[Program] Update failed: {e}")
209+
logger.error(f"[Program] Update preparation failed: {e}")
210210
# 尝试重启应用核心
211211
try:
212212
await program.start()

backend/src/module/update/docker_updater.py

Lines changed: 46 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
"""
77

88
import asyncio
9+
import json
910
import logging
1011
import os
1112
import shutil
13+
import sys
1214
import tempfile
1315
import zipfile
1416
from pathlib import Path
@@ -18,8 +20,9 @@
1820

1921
logger = logging.getLogger(__name__)
2022

21-
# 更新锁文件路径
23+
# 更新相关文件路径
2224
UPDATE_LOCK_FILE = "/tmp/auto_bangumi_update.lock"
25+
UPDATE_FLAG_FILE = "/tmp/auto_bangumi_update_ready.flag"
2326

2427
# 允许的下载域名白名单
2528
ALLOWED_DOMAINS = ["github.com", "codeload.github.com"] # GitHub 的文件下载域名
@@ -33,8 +36,7 @@ class DockerUpdater:
3336

3437
def __init__(self):
3538
self.temp_dir: Path | None = None
36-
self.backup_dir: Path = Path("/app/backup")
37-
self.app_dir: Path = Path("/app")
39+
self.app_dir = Path("/app")
3840

3941
def _is_url_allowed(self, url: str) -> bool:
4042
"""检查 URL 是否在允许的白名单中
@@ -137,6 +139,29 @@ async def _download_file(self, url: str) -> Path:
137139
self._cleanup_temp_files()
138140
raise
139141

142+
def _create_update_flag(self, download_url: str, extract_path: Path) -> None:
143+
"""创建更新标志文件,供shell脚本检测
144+
145+
Args:
146+
download_url: 原始下载URL
147+
extract_path: 解压后的目录路径
148+
"""
149+
try:
150+
flag_data = {
151+
"download_url": download_url,
152+
"extract_path": str(extract_path),
153+
"timestamp": asyncio.get_event_loop().time()
154+
}
155+
156+
with open(UPDATE_FLAG_FILE, "w") as f:
157+
json.dump(flag_data, f, indent=2)
158+
159+
logger.info(f"[DockerUpdater] Created update flag file: {UPDATE_FLAG_FILE}")
160+
161+
except Exception as e:
162+
logger.error(f"[DockerUpdater] Failed to create update flag: {e}")
163+
raise
164+
140165
def _extract_zip(self, zip_path: Path) -> Path:
141166
"""解压 ZIP 文件
142167
@@ -172,102 +197,8 @@ def _extract_zip(self, zip_path: Path) -> Path:
172197
logger.error(f"[DockerUpdater] Extract failed: {e}")
173198
raise
174199

175-
def _backup_current_app(self):
176-
"""备份当前应用的 src 和 dist 目录"""
177-
try:
178-
# 删除旧的备份
179-
if self.backup_dir.exists():
180-
shutil.rmtree(self.backup_dir)
181-
182-
# 创建备份目录
183-
self.backup_dir.mkdir(exist_ok=True)
184200

185-
# 只备份 src 和 dist 目录
186-
src_dir = self.app_dir / "src"
187-
dist_dir = self.app_dir / "dist"
188201

189-
if src_dir.exists():
190-
shutil.copytree(src_dir, self.backup_dir / "src")
191-
logger.info(f"[DockerUpdater] Backed up src directory to {self.backup_dir / 'src'}")
192-
193-
if dist_dir.exists():
194-
shutil.copytree(dist_dir, self.backup_dir / "dist")
195-
logger.info(f"[DockerUpdater] Backed up dist directory to {self.backup_dir / 'dist'}")
196-
197-
except Exception as e:
198-
logger.error(f"[DockerUpdater] Backup failed: {e}")
199-
raise
200-
201-
def _install_new_app(self, source_dir: Path):
202-
"""安装新应用的 src 和 dist 目录
203-
204-
Args:
205-
source_dir: 新应用源目录
206-
"""
207-
try:
208-
# 更新 src 目录
209-
new_src = source_dir / "src"
210-
if new_src.exists():
211-
target_src = self.app_dir / "src"
212-
if target_src.exists():
213-
shutil.rmtree(target_src)
214-
shutil.copytree(new_src, target_src)
215-
logger.info(f"[DockerUpdater] Installed new src directory from {new_src}")
216-
217-
# 更新 dist 目录
218-
new_dist = source_dir / "dist"
219-
if new_dist.exists():
220-
target_dist = self.app_dir / "dist"
221-
if target_dist.exists():
222-
shutil.rmtree(target_dist)
223-
shutil.copytree(new_dist, target_dist)
224-
logger.info(f"[DockerUpdater] Installed new dist directory from {new_dist}")
225-
226-
except Exception as e:
227-
logger.error(f"[DockerUpdater] Installation failed: {e}")
228-
raise
229-
230-
231-
232-
def _fix_permissions(self):
233-
"""修复文件权限"""
234-
try:
235-
# 在 Docker 环境中,使用 chown 命令修复权限
236-
os.system(f"chown -R ab:ab {self.app_dir}")
237-
logger.info("[DockerUpdater] Fixed file permissions")
238-
239-
except Exception as e:
240-
logger.error(f"[DockerUpdater] Failed to fix permissions: {e}")
241-
242-
def _rollback(self):
243-
"""回滚到备份版本"""
244-
try:
245-
if not self.backup_dir.exists():
246-
logger.error("[DockerUpdater] No backup found for rollback")
247-
return
248-
249-
# 回滚 src 目录
250-
backup_src = self.backup_dir / "src"
251-
if backup_src.exists():
252-
target_src = self.app_dir / "src"
253-
if target_src.exists():
254-
shutil.rmtree(target_src)
255-
shutil.copytree(backup_src, target_src)
256-
logger.info("[DockerUpdater] Rolled back src directory")
257-
258-
# 回滚 dist 目录
259-
backup_dist = self.backup_dir / "dist"
260-
if backup_dist.exists():
261-
target_dist = self.app_dir / "dist"
262-
if target_dist.exists():
263-
shutil.rmtree(target_dist)
264-
shutil.copytree(backup_dist, target_dist)
265-
logger.info("[DockerUpdater] Rolled back dist directory")
266-
267-
logger.info("[DockerUpdater] Rollback completed")
268-
269-
except Exception as e:
270-
logger.error(f"[DockerUpdater] Rollback failed: {e}")
271202

272203
def _cleanup_temp_files(self):
273204
"""清理临时文件"""
@@ -278,14 +209,14 @@ def _cleanup_temp_files(self):
278209
except Exception as e:
279210
logger.error(f"[DockerUpdater] Failed to cleanup temp files: {e}")
280211

281-
async def update(self, download_url: str) -> dict:
282-
"""执行更新
212+
async def prepare_update(self, download_url: str) -> dict:
213+
"""准备更新:下载、解压、创建标志文件
283214
284215
Args:
285216
download_url: 更新包下载 URL
286217
287218
Returns:
288-
dict: 更新结果
219+
dict: 准备结果
289220
"""
290221
# 检查 URL
291222
if not self._is_url_allowed(download_url):
@@ -294,50 +225,37 @@ async def update(self, download_url: str) -> dict:
294225
# 创建更新锁
295226
if not self._create_update_lock():
296227
raise Exception("Another update is already in progress")
228+
297229
try:
298-
logger.info("[DockerUpdater] Starting Docker update process")
230+
logger.info("[DockerUpdater] Preparing update package")
299231

300232
# 1. 下载更新包
301233
zip_path = await self._download_file(download_url)
302234

303235
# 2. 解压更新包
304236
source_dir = self._extract_zip(zip_path)
305237

306-
# 3. 备份当前应用
307-
self._backup_current_app()
308-
309-
# 4. 安装新应用
310-
self._install_new_app(source_dir)
311-
312-
# 6. 修复权限
313-
self._fix_permissions()
238+
# 3. 验证解压结果
239+
if not (source_dir / "src").exists():
240+
raise Exception("Invalid update package: missing src directory")
314241

315-
logger.info("[DockerUpdater] Update completed successfully")
242+
# 4. 创建更新标志文件
243+
self._create_update_flag(download_url, source_dir)
316244

317-
return {"status": "success", "message": "Update completed, container will restart"}
245+
logger.info("[DockerUpdater] Update preparation completed")
246+
return {"status": "success", "message": "Update prepared, process will restart for file replacement"}
318247

319248
except Exception as e:
320-
logger.error(f"[DockerUpdater] Update failed: {e}")
321-
322-
# 尝试回滚
323-
try:
324-
self._rollback()
325-
except Exception as rollback_error:
326-
logger.error(f"[DockerUpdater] Rollback also failed: {rollback_error}")
327-
328-
raise
329-
330-
finally:
249+
logger.error(f"[DockerUpdater] Update preparation failed: {e}")
331250
# 清理资源
332251
self._cleanup_temp_files()
333252
self._remove_update_lock()
253+
raise
334254

335-
def force_restart(self):
336-
"""强制重启容器(退出进程让 Docker 重启)"""
337-
logger.info("[DockerUpdater] Forcing container restart")
338-
# 在 Docker 环境中,.sh 有监控进程会自动重启
339-
import sys
340-
255+
def trigger_graceful_restart(self):
256+
"""触发优雅重启:让 Python 进程退出,shell 脚本会检测更新标志并处理"""
257+
logger.info("[DockerUpdater] Triggering graceful restart for update")
258+
# 使用退出码 0 让 entrypoint.sh 知道这是正常的重启(热更新)
341259
sys.exit(0)
342260

343261

entrypoint.sh

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,53 @@ while [ "$SHOULD_STOP" != "true" ]; do
5858
elif [ $exit_code -eq 0 ]; then
5959
echo "应用正常退出,2秒后重启(热更新)..."
6060
sleep 2
61+
62+
# 在Python进程完全退出后检查更新标志
63+
if [ -f "/tmp/auto_bangumi_update_ready.flag" ]; then
64+
echo "检测到更新标志,执行文件替换..."
65+
66+
# 读取更新信息
67+
extract_path=$(python3 -c "
68+
import json
69+
try:
70+
with open('/tmp/auto_bangumi_update_ready.flag', 'r') as f:
71+
data = json.load(f)
72+
print(data.get('extract_path', ''))
73+
except:
74+
print('')
75+
" 2>/dev/null || echo "")
76+
77+
if [ -n "$extract_path" ] && [ -d "$extract_path" ]; then
78+
echo "$extract_path 更新应用文件..."
79+
80+
# 更新 src 目录
81+
if [ -d "$extract_path/src" ]; then
82+
echo "更新 src 目录..."
83+
rm -rf /app/src.old 2>/dev/null || true
84+
[ -d "/app/src" ] && mv /app/src /app/src.old
85+
cp -r "$extract_path/src" /app/src && echo "src 目录更新成功"
86+
fi
87+
88+
# 更新 dist 目录
89+
if [ -d "$extract_path/dist" ]; then
90+
echo "更新 dist 目录..."
91+
rm -rf /app/dist.old 2>/dev/null || true
92+
[ -d "/app/dist" ] && mv /app/dist /app/dist.old
93+
cp -r "$extract_path/dist" /app/dist && echo "dist 目录更新成功"
94+
fi
95+
96+
# 修复权限并清理
97+
chown -R ab:ab /app/src /app/dist 2>/dev/null || true
98+
rm -rf /app/src.old /app/dist.old "$extract_path" 2>/dev/null || true
99+
echo "文件替换完成!"
100+
else
101+
echo "无效的更新路径: $extract_path"
102+
fi
103+
104+
# 删除更新标志文件
105+
rm -f "/tmp/auto_bangumi_update_ready.flag"
106+
echo "更新处理完成,启动新版本..."
107+
fi
61108
else
62109
echo "应用异常退出 (退出码: $exit_code),停止容器"
63110
exit $exit_code

webui/src/api/program.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,13 @@ export const apiProgram = {
6767
* 执行更新
6868
*/
6969
async update(downloadUrl: string) {
70-
const { data } = await axios.post<ApiSuccess>('api/v1/program/update', {
71-
download_url: downloadUrl,
72-
});
70+
const { data } = await axios.post<ApiSuccess>(
71+
'api/v1/program/update',
72+
null,
73+
{
74+
params: { download_url: downloadUrl }
75+
}
76+
);
7377
return data;
7478
},
7579
};

0 commit comments

Comments
 (0)