fix(1panel): 安装锁持久化与升级后 1panel-network 校验

防止 nx update 重建容器后丢失安装锁、未接入 1panel-network 导致 MySQL/Redis 不可达。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nexus Deploy
2026-06-06 20:52:13 +08:00
parent 1f5daebeed
commit 107b089b16
5 changed files with 162 additions and 11 deletions
+60 -2
View File
@@ -273,6 +273,57 @@ nexus_publish_port() {
echo "${port:-8600}"
}
nexus_container_has_install_lock() {
local cname="$1"
docker exec "$cname" test -f /app/.install_locked 2>/dev/null \
|| docker exec "$cname" test -f /var/lib/nexus/.install_locked 2>/dev/null
}
restore_install_lock_in_container() {
local cname="$1"
[[ -n "$cname" ]] || return 0
docker exec "$cname" sh -c '
if [ ! -f /app/.install_locked ] && [ -f /var/lib/nexus/.install_locked ]; then
cp /var/lib/nexus/.install_locked /app/.install_locked
chmod 600 /app/.install_locked
fi
' 2>/dev/null || true
}
nexus_on_1panel_network() {
local cname="$1"
[[ -n "$cname" ]] || return 1
docker inspect "$cname" --format '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}' 2>/dev/null \
| grep -qw '1panel-network'
}
verify_nexus_1panel_network() {
local root="$1"
if ! has_1panel_network; then
return 0
fi
local cname
cname="$(nexus_container_name)"
[[ -n "$cname" ]] || return 0
restore_install_lock_in_container "$cname"
if nexus_on_1panel_network "$cname"; then
info "已验证 Nexus 接入 1panel-network"
return 0
fi
error "Nexus 容器 ${cname} 未接入 1panel-network,无法解析 1Panel MySQL/Redis 主机名"
warn "正在叠加 docker-compose.1panel.yml 重新创建容器..."
compose_cmd "$root" up -d --no-build --remove-orphans
sleep 2
cname="$(nexus_container_name)"
restore_install_lock_in_container "$cname"
if nexus_on_1panel_network "$cname"; then
info "已修复:Nexus 已接入 1panel-network"
return 0
fi
error "修复失败:Nexus 仍未接入 1panel-network"
return 1
}
set_install_complete() {
local root="$1"
local env_file="$root/$ENV_PROD"
@@ -280,7 +331,8 @@ set_install_complete() {
[[ -f "$env_file" ]] || return 0
cname="$(nexus_container_name)"
[[ -n "$cname" ]] || return 0
if docker exec "$cname" test -f /app/.install_locked 2>/dev/null; then
restore_install_lock_in_container "$cname"
if nexus_container_has_install_lock "$cname"; then
upsert_env_var "$env_file" "NEXUS_INSTALL_WIZARD_PENDING" "0"
info "安装已完成:NEXUS_INSTALL_WIZARD_PENDING=0"
fi
@@ -294,7 +346,8 @@ sync_env_prod_to_volume() {
[[ -n "$state_vol" && -f "$env_file" ]] || return 0
cname="$(nexus_container_name)"
[[ -n "$cname" ]] || return 0
docker exec "$cname" test -f /app/.install_locked 2>/dev/null || return 0
restore_install_lock_in_container "$cname"
nexus_container_has_install_lock "$cname" || return 0
python3 - "$env_file" "$state_vol" <<'PY'
import re
import subprocess
@@ -840,6 +893,7 @@ compose_up() {
info "档位: ${NEXUS_PROFILE}(仅 Nexus 容器;MySQL/Redis 请自行安装)"
info "构建并启动(首次约 1020 分钟)..."
compose_cmd "$root" up -d --build --remove-orphans
verify_nexus_1panel_network "$root" || return 1
}
verify_install_wizard() {
@@ -1101,6 +1155,10 @@ cmd_upgrade() {
fi
step "重建容器(--remove-orphans 清理旧 mysql/redis..."
compose_cmd "$NEXUS_ROOT" up -d --no-build --remove-orphans
if ! verify_nexus_1panel_network "$NEXUS_ROOT"; then
rollback_compose_upgrade "$NEXUS_ROOT" || true
exit 1
fi
local port wiz_code
port="$(nexus_publish_port "$NEXUS_ROOT")"
wiz_code="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${port}/app/install.html" 2>/dev/null || echo "000")"
+23 -7
View File
@@ -24,7 +24,8 @@ wait_tcp_optional() {
host="$1"
port="$2"
tries="${3:-15}"
echo "entrypoint: waiting for external Redis ${host}:${port} (max ${tries} tries)..."
label="${4:-service}"
echo "entrypoint: waiting for ${label} ${host}:${port} (max ${tries} tries)..."
n=0
while [ "$n" -lt "$tries" ]; do
if python3 -c "
@@ -34,16 +35,25 @@ s.settimeout(2)
s.connect(('${host}', ${port}))
s.close()
" 2>/dev/null; then
echo "entrypoint: Redis ready at ${host}:${port}"
echo "entrypoint: ${label} ready at ${host}:${port}"
return 0
fi
n=$((n + 1))
sleep 2
done
echo "entrypoint: warn — Redis not reachable at ${host}:${port}; install on host or set NEXUS_REDIS_URL"
echo "entrypoint: warn — ${label} not reachable at ${host}:${port}; install on host or set URL in .env"
return 0
}
restore_persisted_install_lock() {
PERSIST_LOCK="${PERSIST_DIR}/.install_locked"
if [ -f "${PERSIST_LOCK}" ] && [ ! -f /app/.install_locked ]; then
cp "${PERSIST_LOCK}" /app/.install_locked
chmod 600 /app/.install_locked
echo "entrypoint: restored .install_locked from ${PERSIST_LOCK}"
fi
}
restore_persisted_env() {
if [ ! -f "${PERSIST_ENV}" ]; then
return 0
@@ -97,29 +107,35 @@ write_env_from_environment() {
echo "entrypoint: wrote .env from container environment"
}
persist_env_on_exit() {
persist_state_on_exit() {
if [ -f /app/.env ]; then
mkdir -p "${PERSIST_DIR}"
cp /app/.env "${PERSIST_ENV}"
chmod 600 "${PERSIST_ENV}" 2>/dev/null || true
fi
if [ -f /app/.install_locked ]; then
mkdir -p "${PERSIST_DIR}"
cp /app/.install_locked "${PERSIST_DIR}/.install_locked"
chmod 600 "${PERSIST_DIR}/.install_locked" 2>/dev/null || true
fi
}
trap persist_env_on_exit EXIT INT TERM
trap persist_state_on_exit EXIT INT TERM
# MySQL / Redis 均由用户自行安装在宿主机;安装向导阶段不阻塞启动
if [ "${NEXUS_INSTALL_WIZARD_PENDING:-0}" != "1" ] && [ "${NEXUS_SKIP_MYSQL_WAIT:-0}" != "1" ]; then
MYSQL_HOST="${NEXUS_1PANEL_DB_HOST:-${MYSQL_HOST:-host.docker.internal}}"
MYSQL_PORT="${MYSQL_PORT:-3306}"
wait_tcp_optional "${MYSQL_HOST}" "${MYSQL_PORT}" 30
wait_tcp_optional "${MYSQL_HOST}" "${MYSQL_PORT}" 30 "MySQL"
fi
if [ "${NEXUS_INSTALL_WIZARD_PENDING:-0}" != "1" ] && [ "${NEXUS_SKIP_REDIS_WAIT:-0}" != "1" ]; then
REDIS_HOST="${NEXUS_1PANEL_REDIS_HOST:-${REDIS_HOST:-host.docker.internal}}"
REDIS_PORT="${REDIS_PORT:-6379}"
wait_tcp_optional "${REDIS_HOST}" "${REDIS_PORT}" 15
wait_tcp_optional "${REDIS_HOST}" "${REDIS_PORT}" 15 "Redis"
fi
restore_persisted_install_lock
restore_persisted_env
write_env_from_environment
export_app_env
@@ -0,0 +1,35 @@
# 安装锁持久化 + 1panel-network 升级校验
| 项 | 内容 |
|----|------|
| 日期 | 2026-06-06 |
| 动机 | 生产 `nx update` 重建容器后丢失 `.install_locked`;容器未接入 `1panel-network` 导致 MySQL/Redis DNS 失败与 502 |
## 变更摘要
1. **安装锁写入 nexus-state 卷**`install.py` 锁定后同步至 `/var/lib/nexus/.install_locked``entrypoint.sh` 启动时恢复至 `/app/.install_locked`,退出时双写。
2. **升级后 1panel-network 校验**`deploy/nexus-1panel.sh` 新增 `verify_nexus_1panel_network``compose up` 后自动检测;未接入则叠加 `docker-compose.1panel.yml` 重建,失败则回滚。
3. **entrypoint 日志**MySQL/Redis 等待日志区分服务名(不再误标为 Redis)。
4. **部署脚本**`set_install_complete` / `sync_env_prod_to_volume` 识别卷内锁文件并恢复至 `/app`
## 涉及文件
- `server/api/install.py`
- `docker/entrypoint.sh`
- `deploy/nexus-1panel.sh`
- `tests/test_security_unit.py`
## 迁移 / 重启
- 需重建 Nexus 镜像(entrypoint 变更)并 `compose up`
- 无 DB 迁移。
## 验证
```bash
pytest tests/test_security_unit.py -k "sync_lock or install_reject" -q
# 升级后容器应同时在 1panel-network 与 nexus-internal
docker inspect nexus-prod-nexus-1 --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}'
# 锁定后卷内应有锁文件
docker exec nexus-prod-nexus-1 test -f /var/lib/nexus/.install_locked
```
+27 -2
View File
@@ -578,9 +578,17 @@ def _write_env(req: InitDbRequest, secret_key: str, api_key: str, encryption_key
_sync_env_to_persist_volume()
def _persist_dir() -> Path:
raw = os.environ.get("NEXUS_PERSIST_DIR", "/var/lib/nexus").strip() or "/var/lib/nexus"
return Path(raw)
def _persist_env_path() -> Path:
persist_dir = os.environ.get("NEXUS_PERSIST_DIR", "/var/lib/nexus").strip() or "/var/lib/nexus"
return Path(persist_dir) / ".env"
return _persist_dir() / ".env"
def _persist_lock_path() -> Path:
return _persist_dir() / ".install_locked"
def _sync_env_to_persist_volume() -> None:
@@ -599,6 +607,22 @@ def _sync_env_to_persist_volume() -> None:
logger.warning("Failed to sync .env to persist volume: %s", e)
def _sync_lock_to_persist_volume() -> None:
"""Copy /app/.install_locked to nexus-state volume (survives container recreate)."""
if not INSTALL_LOCK.is_file():
return
dest = _persist_lock_path()
try:
dest.parent.mkdir(parents=True, exist_ok=True)
import shutil
shutil.copy2(INSTALL_LOCK, dest)
dest.chmod(0o600)
logger.info("Synced install lock to persist volume %s", dest)
except OSError as e:
logger.warning("Failed to sync install lock to persist volume: %s", e)
def _write_config_json(req: InitDbRequest, api_key: str, site_url: str) -> None:
"""Write shared configuration as JSON (replaces legacy config.php)."""
import json
@@ -837,6 +861,7 @@ def _finalize_install_lock() -> None:
INSTALL_LOCK,
f"Nexus installation locked at {datetime.now(timezone.utc).isoformat()}\n",
)
_sync_lock_to_persist_volume()
if STATE_FILE.exists():
try:
STATE_FILE.unlink()
+17
View File
@@ -403,6 +403,23 @@ def test_script_aggregate_empty_server_ids_no_pending():
assert _aggregate_execution_status([], {}) == "failed"
def test_sync_lock_to_persist_volume(monkeypatch, tmp_path):
from server.api import install as install_api
persist_dir = tmp_path / "state"
persist_dir.mkdir()
lock_file = tmp_path / ".install_locked"
lock_file.write_text("locked at test\n", encoding="utf-8")
monkeypatch.setattr(install_api, "INSTALL_LOCK", lock_file)
monkeypatch.setenv("NEXUS_PERSIST_DIR", str(persist_dir))
install_api._sync_lock_to_persist_volume()
dest = persist_dir / ".install_locked"
assert dest.is_file()
assert dest.read_text(encoding="utf-8") == "locked at test\n"
def test_install_reject_when_locked(monkeypatch, tmp_path):
from fastapi import HTTPException
from server.api import install as install_api