fix(update): host-side watchdog replaces in-container rebuild

Root cause: docker compose down killed the nohup script inside the
container before rebuild could complete — only git pull was visible
(updated VERSION file).

Changes:
- update_host.sh: new host-side watchdog (poll .update_trigger every 3s)
  runs: git pull → docker compose down → build --no-cache app → up -d
  PROJECT_DIR and User are dynamic (set at install time, not hardcoded)
- update_service.py: perform_update() now writes .update_trigger instead
  of running git/docker commands inside the container
- deploy.sh: added install_update_watchdog() + --watchdog CLI option;
  generates /etc/systemd/system/pvemanager-update.service dynamically
  with correct User= and WorkingDirectory= for any install path/user
- .gitignore: ignore .update_trigger and generated systemd/ files
- CHANGELOG, VERSION, WIKI: bump to v1.1.1, document new update flow
This commit is contained in:
markmorado
2026-03-08 19:02:40 +01:00
parent b3db346cfb
commit b8c6e52c9a
7 changed files with 321 additions and 161 deletions

View File

@@ -355,175 +355,58 @@ def get_update_status() -> Dict[str, Any]:
async def perform_update() -> Dict[str, Any]:
"""
Выполнить обновление системы
1. Git pull
2. Docker compose build
3. Docker compose up
Выполнить обновление системы.
Записывает файл-триггер .update_trigger в PROJECT_ROOT.
Watchdog-сервис на хосте (pvemanager-update.service / update_host.sh)
обнаруживает триггер и выполняет:
git pull → docker compose down → docker compose build --no-cache app → docker compose up -d
Такой подход необходим, потому что «docker compose down» убивает сам контейнер
(и любой nohup-процесс внутри него) прежде, чем успевает завершиться rebuild.
"""
global update_status
if update_status["is_updating"]:
return {"success": False, "error": "Update already in progress"}
# Проверяем доступность
if not is_git_available():
return {"success": False, "error": "Git not available"}
if not is_project_mounted():
return {"success": False, "error": "Project not mounted"}
# Добавляем safe.directory для git
ensure_safe_directory()
# Настраиваем git для публичного доступа
configure_git_for_public_access()
# Получаем URL репозитория из настроек и устанавливаем remote
return {
"success": False,
"error": (
"Project directory not mounted. "
"Make sure the compose volume '.:/project:rw' is present "
"and pvemanager-update.service is running on the host."
),
}
trigger_path = os.path.join(PROJECT_ROOT, ".update_trigger")
try:
repo_url = get_repository_url_from_settings()
subprocess.run(
["git", "remote", "set-url", "origin", repo_url],
cwd=PROJECT_ROOT,
capture_output=True,
timeout=10
)
logger.info(f"Set git remote to: {repo_url}")
except Exception as e:
logger.warning(f"Could not set git remote: {e}")
# Сбросить статус
with open(trigger_path, "w") as f:
f.write(datetime.now().isoformat())
logger.info(f"Update trigger written to {trigger_path}")
except OSError as e:
logger.error(f"Failed to write update trigger: {e}")
return {"success": False, "error": f"Cannot write update trigger: {e}"}
# Обновляем in-memory статус — UI-баннер будет показан сразу
update_status = {
"is_updating": True,
"started_at": datetime.now().isoformat(),
"stage": "initializing",
"progress": 0,
"stage": "restarting",
"progress": 80,
"error": None,
"completed": False
"completed": False,
}
# Persistent log file (survives via volume mount ./logs:/app/logs)
UPDATE_LOG = "/app/logs/update.log"
def _detect_branch() -> str:
"""Detect current git branch, falling back to 'main'"""
try:
r = subprocess.run(
["git", "symbolic-ref", "--short", "HEAD"],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
timeout=5,
)
if r.returncode == 0 and r.stdout.strip():
return r.stdout.strip()
except Exception:
pass
return "main"
try:
# Stage 1: Git pull
update_status["stage"] = "pulling"
update_status["progress"] = 10
git_env = {**os.environ, "GIT_TERMINAL_PROMPT": "0"}
branch = _detect_branch()
pull_result = subprocess.run(
["git", "pull", "origin", branch],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
timeout=120,
env=git_env,
)
logger.info(f"git pull stdout: {pull_result.stdout}")
logger.info(f"git pull stderr: {pull_result.stderr}")
if pull_result.returncode != 0:
# Fallback: fetch + reset --hard
fetch_result = subprocess.run(
["git", "fetch", "origin", branch],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
timeout=120,
env=git_env,
)
if fetch_result.returncode != 0:
raise Exception(f"Git fetch failed: {fetch_result.stderr}")
reset_result = subprocess.run(
["git", "reset", "--hard", f"origin/{branch}"],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
timeout=30,
)
if reset_result.returncode != 0:
raise Exception(f"Git reset failed: {reset_result.stderr}")
# Stage 2: Docker rebuild
update_status["stage"] = "building"
update_status["progress"] = 30
# Detect which compose command is available
compose_cmd = "docker compose"
try:
check = subprocess.run(
["docker", "compose", "version"],
capture_output=True,
timeout=5,
)
if check.returncode != 0:
compose_cmd = "docker-compose"
except Exception:
compose_cmd = "docker-compose"
# Script runs after this process exits — writes logs to persistent volume
build_script = f"""#!/bin/sh
set -e
cd {PROJECT_ROOT}
echo "[update] Starting docker rebuild at $(date)" >> {UPDATE_LOG}
{compose_cmd} up -d --build app >> {UPDATE_LOG} 2>&1
echo "[update] Docker rebuild finished at $(date)" >> {UPDATE_LOG}
"""
script_path = "/tmp/update_panel.sh"
with open(script_path, "w") as f:
f.write(build_script)
os.chmod(script_path, 0o755)
with open(UPDATE_LOG, "a") as f:
f.write(f"[update] git pull done (branch={branch}), launching rebuild...\n")
# Launch detached so it survives container restart
subprocess.Popen(
["nohup", "sh", script_path],
cwd=PROJECT_ROOT,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
update_status["stage"] = "restarting"
update_status["progress"] = 80
return {
"success": True,
"message": "Update started. The panel will restart shortly.",
}
except subprocess.TimeoutExpired:
update_status["error"] = "Update timeout"
update_status["is_updating"] = False
return {"success": False, "error": "Update timeout"}
except Exception as e:
update_status["error"] = str(e)
update_status["is_updating"] = False
logger.error(f"Update failed: {e}")
return {"success": False, "error": str(e)}
return {
"success": True,
"message": (
"Update triggered. The host watchdog (pvemanager-update.service) "
"will now run git pull, rebuild and restart the panel."
),
}
def reset_update_status():