fix(sync): 未设路径推送回退 /www/wwwroot,UI 判定不变

空或占位 /www/wwwroot 仍算「未设路径」;rsync 推送/预览/验证改用 resolve_push_target_path,并新增批量 detect-path 脚本。
This commit is contained in:
Nexus Agent
2026-06-11 23:42:58 +08:00
parent f11ad35708
commit 29b053b3c0
7 changed files with 265 additions and 14 deletions
@@ -0,0 +1,41 @@
# Changelog — 2026-06-11 推送默认目标 /www/wwwroot(未设路径 UI 不变)
## 摘要
未配置 `target_path` 的服务器,推送/预览/诊断回退目录由 `/tmp/sync` 改为 `/www/wwwroot`(宝塔站点根)。**「未设路径」**筛选与面板逻辑不变(仍为空或精确 `/www/wwwroot`)。
## 动机
- 非 root 子机普遍无 `/tmp/sync`,导致 44 台验证失败
- 运维确认:`/www/wwwroot` 可作为推送目标,与「未设路径」管理状态不冲突
## 变更
| 文件 | 说明 |
|------|------|
| `server/utils/posix_paths.py` | `DEFAULT_PUSH_TARGET_PATH``resolve_push_target_path()` |
| `server/application/services/sync_engine_v2.py` | 推送/预览使用 resolve |
| `scripts/verify_push_elevation.py` | 同上 |
| `scripts/batch_detect_target_path.py` | 批量 detect-path`--unset-only` |
| `tests/test_posix_paths.py` | 回退与 unset UI 分离单测 |
## 行为说明
| `target_path` | 未设路径 UI | 实际推送目标 |
|---------------|-------------|--------------|
| 空 | 是 | `/www/wwwroot` |
| `/www/wwwroot` | 是 | `/www/wwwroot` |
| `/www/wwwroot/site` | 否 | `/www/wwwroot/site` |
推送页填写目标路径仍覆盖全部选中机。
## 迁移 / 重启
需重启 API / 重建 Docker 镜像。无 DB 迁移。
## 验证
```bash
pytest tests/test_posix_paths.py -q
bash scripts/batch_detect_target_path.sh --unset-only --dry-run
```
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""Batch detect target_path via SSH (workerman.bat under /www/wwwroot).
Targets servers with「未设路径」(empty or ``/www/wwwroot`` placeholder) by default.
Usage:
python scripts/batch_detect_target_path.py --unset-only
python scripts/batch_detect_target_path.py --ids 245,246
python scripts/batch_detect_target_path.py --name 温胜夜 --dry-run
"""
from __future__ import annotations
import argparse
import asyncio
import os
import sys
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
def _load_dotenv() -> None:
env_path = ROOT / ".env"
if not env_path.is_file():
return
for line in env_path.read_text(encoding="utf-8", errors="replace").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key, value = key.strip(), value.strip().strip("\"'")
if key.startswith("NEXUS_") and key[6:] not in os.environ:
os.environ[key[6:]] = value
elif key and key not in os.environ:
os.environ[key] = value
async def _fetch_servers(
session: Any,
*,
ids: list[int] | None,
name_like: str | None,
unset_only: bool,
limit: int,
) -> list[Any]:
from sqlalchemy import or_, select
from server.domain.models import Server
from server.infrastructure.database.server_repo import _target_path_unset_filter
q = select(Server).order_by(Server.id)
if ids:
q = q.where(Server.id.in_(ids))
if name_like:
q = q.where(or_(Server.name.like(f"%{name_like}%"), Server.domain.like(f"%{name_like}%")))
if unset_only:
q = q.where(_target_path_unset_filter(True))
result = await session.execute(q)
servers = list(result.scalars().all())
if limit > 0:
servers = servers[:limit]
return servers
async def run_detect(args: argparse.Namespace) -> list[dict[str, Any]]:
from server.application.services.server_batch_service import _run_detect_path
from server.config import settings
from server.infrastructure.database.session import AsyncSessionLocal, init_db
from server.infrastructure.redis.client import close_redis, init_redis
await init_db()
await init_redis(settings.REDIS_URL)
ids: list[int] | None = None
if args.ids:
ids = [int(x.strip()) for x in args.ids.split(",") if x.strip()]
async with AsyncSessionLocal() as session:
servers = await _fetch_servers(
session,
ids=ids,
name_like=args.name,
unset_only=args.unset_only,
limit=args.limit,
)
if not servers:
print("No servers matched.", file=sys.stderr)
return []
if args.dry_run:
for s in servers:
print(f"[DRY] #{s.id} {s.name} target_path={s.target_path!r}")
print(f"\nWould detect: {len(servers)} servers")
await close_redis()
return [{"server_id": s.id, "dry_run": True} for s in servers]
live = {
"server_ids": [s.id for s in servers],
"results": [],
"done": 0,
"total": len(servers),
}
await _run_detect_path(live)
await close_redis()
results = live.get("results") or []
ok = sum(1 for r in results if r.get("success"))
fail = len(results) - ok
print(f"\nDetect: {ok} ok / {fail} fail / {len(results)} total")
for r in results:
status = "OK" if r.get("success") else "FAIL"
print(
f"[{status}] #{r.get('server_id')} {r.get('server_name')}: "
f"{r.get('stdout') or r.get('error') or ''}"
)
return results
def main() -> int:
_load_dotenv()
parser = argparse.ArgumentParser(description="Batch detect server target_path")
parser.add_argument("--ids", type=str, help="Comma-separated server ids")
parser.add_argument("--name", type=str, help="Filter name/domain LIKE")
parser.add_argument("--unset-only", action="store_true", help="Only「未设路径」servers")
parser.add_argument("--limit", type=int, default=0)
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
if not args.ids and not args.name and not args.unset_only:
parser.error("Specify --ids, --name, or --unset-only")
results = asyncio.run(run_detect(args))
if args.dry_run:
return 0 if results else 2
return 0 if results and all(r.get("success") for r in results) else 1
if __name__ == "__main__":
raise SystemExit(main())
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Batch detect target_path on「未设路径」servers (SSH find workerman.bat).
#
# Usage:
# bash scripts/batch_detect_target_path.sh --unset-only
# bash scripts/batch_detect_target_path.sh --unset-only --dry-run
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SECRETS="${ROOT}/deploy/nexus-1panel.secrets.sh"
if [[ -f "$SECRETS" ]]; then
# shellcheck disable=SC1090
source "$SECRETS"
fi
PYTHON="${ROOT}/.venv/bin/python"
[[ -x "$PYTHON" ]] || PYTHON="${ROOT}/venv/bin/python"
[[ -x "$PYTHON" ]] || PYTHON=python3
exec "$PYTHON" "${ROOT}/scripts/batch_detect_target_path.py" "$@"
+25 -9
View File
@@ -38,7 +38,13 @@ def _load_dotenv() -> None:
os.environ[key] = value
async def _fetch_servers(session: Any, *, ids: list[int] | None, name_like: str | None) -> list[Any]:
async def _fetch_servers(
session: Any,
*,
ids: list[int] | None,
name_like: str | None,
non_root_only: bool = False,
) -> list[Any]:
from sqlalchemy import or_, select
from server.domain.models import Server
@@ -48,6 +54,8 @@ async def _fetch_servers(session: Any, *, ids: list[int] | None, name_like: str
q = q.where(Server.id.in_(ids))
if name_like:
q = q.where(or_(Server.name.like(f"%{name_like}%"), Server.domain.like(f"%{name_like}%")))
if non_root_only:
q = q.where(Server.username.isnot(None)).where(Server.username != "root")
result = await session.execute(q)
return list(result.scalars().all())
@@ -74,7 +82,7 @@ async def run_verify(args: argparse.Namespace) -> list[dict[str, Any]]:
from server.config import settings
from server.infrastructure.database.session import AsyncSessionLocal, init_db
from server.infrastructure.redis.client import close_redis, init_redis
from server.utils.posix_paths import UPLOAD_STAGING_PREFIX, normalize_remote_abs_path
from server.utils.posix_paths import UPLOAD_STAGING_PREFIX, normalize_remote_abs_path, resolve_push_target_path
await init_db()
await init_redis(settings.REDIS_URL)
@@ -84,7 +92,9 @@ async def run_verify(args: argparse.Namespace) -> list[dict[str, Any]]:
ids = [int(x.strip()) for x in args.ids.split(",") if x.strip()]
async with AsyncSessionLocal() as session:
servers = await _fetch_servers(session, ids=ids, name_like=args.name)
servers = await _fetch_servers(
session, ids=ids, name_like=args.name, non_root_only=args.non_root_only
)
if not servers:
print("No servers matched.", file=sys.stderr)
@@ -98,10 +108,11 @@ async def run_verify(args: argparse.Namespace) -> list[dict[str, Any]]:
fh.write("nexus push elevation verify\n")
results: list[dict[str, Any]] = []
sem = asyncio.Semaphore(max(1, getattr(args, "concurrency", 8)))
try:
for server in servers:
dest = normalize_remote_abs_path(server.target_path or "/tmp/sync")
async def _verify_one(server: Any) -> None:
async with sem:
dest = resolve_push_target_path(server.target_path)
row: dict[str, Any] = {
"server_id": server.id,
"server_name": server.name,
@@ -111,7 +122,7 @@ async def run_verify(args: argparse.Namespace) -> list[dict[str, Any]]:
row["rsync_sudo"] = await probe_rsync_sudo(server)
write = await _diagnose_write(server, dest)
row.update(write_check=write)
row["write_check"] = write
preview = await _rsync_push(
server, staging, dest, sync_mode="incremental", dry_run=True
@@ -154,6 +165,9 @@ async def run_verify(args: argparse.Namespace) -> list[dict[str, Any]]:
print(f" dry_run_err: {row['dry_run_stderr']}", flush=True)
if row.get("push_stderr"):
print(f" push_err: {row['push_stderr']}", flush=True)
try:
await asyncio.gather(*[_verify_one(s) for s in servers])
finally:
import shutil
@@ -170,10 +184,12 @@ def main() -> int:
parser = argparse.ArgumentParser(description="Verify push elevation on servers")
parser.add_argument("--ids", type=str, help="Comma-separated server ids")
parser.add_argument("--name", type=str, help="Filter name/domain")
parser.add_argument("--non-root-only", action="store_true", help="All servers except username=root")
parser.add_argument("--concurrency", type=int, default=8, help="Parallel servers (default 8)")
parser.add_argument("--push", action="store_true", help="Run real probe file push after dry-run")
args = parser.parse_args()
if not args.ids and not args.name:
parser.error("Specify --ids or --name")
if not args.ids and not args.name and not args.non_root_only:
parser.error("Specify --ids, --name, or --non-root-only")
results = asyncio.run(run_verify(args))
return 0 if results and all(r.get("ok") for r in results) else 1
@@ -25,6 +25,7 @@ from server.utils.posix_paths import (
UPLOAD_STAGING_PREFIX,
normalize_remote_abs_path,
resolve_nexus_push_source_path,
resolve_push_target_path,
to_posix,
)
@@ -147,7 +148,7 @@ class SyncEngineV2:
sync_log = SyncLog(
server_id=server.id,
source_path=source_path,
target_path=target_path or server.target_path or "/tmp/sync",
target_path=resolve_push_target_path(server.target_path, target_path),
trigger_type=trigger_type,
operator=operator,
status="cancelled",
@@ -181,7 +182,7 @@ class SyncEngineV2:
except Exception as e:
logger.warning(f"Cancel check failed for batch {batch_id}, proceeding with push: {e}")
dest = target_path or server.target_path or "/tmp/sync"
dest = resolve_push_target_path(server.target_path, target_path)
sync_log = SyncLog(
server_id=server.id,
source_path=source_path,
@@ -383,7 +384,7 @@ class SyncEngineV2:
except PosixPathError as exc:
return {"error": str(exc), "exit_code": -1}
dest = target_path or server.target_path or "/tmp/sync"
dest = resolve_push_target_path(server.target_path, target_path)
result = await _rsync_push(server, source_path, dest, sync_mode, dry_run=True, verbose=verbose)
if result["exit_code"] not in (0, 23):
+21 -1
View File
@@ -15,6 +15,9 @@ import posixpath
UPLOAD_STAGING_PREFIX = "/tmp/nexus_upload_"
# Baota default web root — push/sync fallback when server.target_path is empty.
DEFAULT_PUSH_TARGET_PATH = "/www/wwwroot"
FORBIDDEN_NEXUS_SOURCE_PREFIXES = (
"/etc",
"/root",
@@ -143,12 +146,29 @@ def is_path_under_prefix(resolved: str, prefix: str) -> bool:
return r == p or r.startswith(p + "/")
def normalize_remote_dest(path: str | None, *, default: str = "/tmp/sync") -> str:
def normalize_remote_dest(path: str | None, *, default: str = DEFAULT_PUSH_TARGET_PATH) -> str:
"""Normalize remote target directory (push/rsync/diagnose); uses *default* when empty."""
raw = (path or "").strip() or default
return normalize_remote_abs_path(raw)
def resolve_push_target_path(
server_target_path: str | None,
override: str | None = None,
) -> str:
"""Resolve rsync push destination: override → server.target_path → ``/www/wwwroot``.
Independent of :func:`is_unset_target_path`: empty or placeholder ``/www/wwwroot`` in DB
still counts as「未设路径」in UI, but push may target ``/www/wwwroot`` (Baota web root).
"""
if override is not None and str(override).strip():
return normalize_remote_abs_path(str(override).strip())
raw = (server_target_path or "").strip()
if raw:
return normalize_remote_abs_path(raw)
return DEFAULT_PUSH_TARGET_PATH
def is_unset_target_path(path: str | None) -> bool:
"""True when push target path is empty or still the BT default placeholder.
+15 -1
View File
@@ -5,12 +5,14 @@ import pytest
from server.utils.posix_paths import (
PosixPathError,
assert_zip_member_safe,
is_unset_target_path,
normalize_remote_abs_path,
normalize_remote_dest,
posix_basename,
posix_dirname,
posix_join,
remote_join,
resolve_push_target_path,
to_posix,
)
@@ -51,10 +53,22 @@ def test_remote_join():
def test_normalize_remote_dest_default():
assert normalize_remote_dest(None) == "/tmp/sync"
assert normalize_remote_dest(None) == "/www/wwwroot"
assert normalize_remote_dest("/www/wwwroot/site/") == "/www/wwwroot/site"
def test_resolve_push_target_path_vs_unset_ui():
"""Push fallback to wwwroot does not change「未设路径」UI semantics."""
assert is_unset_target_path("") is True
assert is_unset_target_path("/www/wwwroot") is True
assert is_unset_target_path("/www/wwwroot/") is False
assert resolve_push_target_path(None) == "/www/wwwroot"
assert resolve_push_target_path("") == "/www/wwwroot"
assert resolve_push_target_path("/www/wwwroot") == "/www/wwwroot"
assert resolve_push_target_path("/www/wwwroot/site") == "/www/wwwroot/site"
assert resolve_push_target_path("/www/wwwroot/site", "/other") == "/other"
def test_assert_zip_member_safe():
base = "/tmp/nexus_upload_abc"
assert assert_zip_member_safe(base, "foo/bar.txt").startswith(base)