fix(sync): 未设路径推送回退 /www/wwwroot,UI 判定不变
空或占位 /www/wwwroot 仍算「未设路径」;rsync 推送/预览/验证改用 resolve_push_target_path,并新增批量 detect-path 脚本。
This commit is contained in:
@@ -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
|
||||
```
|
||||
@@ -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())
|
||||
@@ -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" "$@"
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user