From 29b053b3c023ff7df594bec669ca8d2414e66568 Mon Sep 17 00:00:00 2001 From: Nexus Agent Date: Thu, 11 Jun 2026 23:42:58 +0800 Subject: [PATCH] =?UTF-8?q?fix(sync):=20=E6=9C=AA=E8=AE=BE=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E6=8E=A8=E9=80=81=E5=9B=9E=E9=80=80=20/www/wwwroot?= =?UTF-8?q?=EF=BC=8CUI=20=E5=88=A4=E5=AE=9A=E4=B8=8D=E5=8F=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 空或占位 /www/wwwroot 仍算「未设路径」;rsync 推送/预览/验证改用 resolve_push_target_path,并新增批量 detect-path 脚本。 --- .../2026-06-11-push-default-wwwroot.md | 41 +++++ scripts/batch_detect_target_path.py | 142 ++++++++++++++++++ scripts/batch_detect_target_path.sh | 17 +++ scripts/verify_push_elevation.py | 34 +++-- server/application/services/sync_engine_v2.py | 7 +- server/utils/posix_paths.py | 22 ++- tests/test_posix_paths.py | 16 +- 7 files changed, 265 insertions(+), 14 deletions(-) create mode 100644 docs/changelog/2026-06-11-push-default-wwwroot.md create mode 100644 scripts/batch_detect_target_path.py create mode 100644 scripts/batch_detect_target_path.sh diff --git a/docs/changelog/2026-06-11-push-default-wwwroot.md b/docs/changelog/2026-06-11-push-default-wwwroot.md new file mode 100644 index 00000000..d90e1013 --- /dev/null +++ b/docs/changelog/2026-06-11-push-default-wwwroot.md @@ -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 +``` diff --git a/scripts/batch_detect_target_path.py b/scripts/batch_detect_target_path.py new file mode 100644 index 00000000..8d8c840d --- /dev/null +++ b/scripts/batch_detect_target_path.py @@ -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()) diff --git a/scripts/batch_detect_target_path.sh b/scripts/batch_detect_target_path.sh new file mode 100644 index 00000000..d858fbb5 --- /dev/null +++ b/scripts/batch_detect_target_path.sh @@ -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" "$@" diff --git a/scripts/verify_push_elevation.py b/scripts/verify_push_elevation.py index 62656942..56d444de 100644 --- a/scripts/verify_push_elevation.py +++ b/scripts/verify_push_elevation.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 diff --git a/server/application/services/sync_engine_v2.py b/server/application/services/sync_engine_v2.py index 0f063a5d..126419a7 100644 --- a/server/application/services/sync_engine_v2.py +++ b/server/application/services/sync_engine_v2.py @@ -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): diff --git a/server/utils/posix_paths.py b/server/utils/posix_paths.py index 4931b8bf..98ec36e4 100644 --- a/server/utils/posix_paths.py +++ b/server/utils/posix_paths.py @@ -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. diff --git a/tests/test_posix_paths.py b/tests/test_posix_paths.py index 2cf1a6da..9b2dbbee 100644 --- a/tests/test_posix_paths.py +++ b/tests/test_posix_paths.py @@ -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)