#!/usr/bin/env python3 """Batch patch /etc/sudoers.d/nexus-files on sub-servers (add rsync for push elevation). Usage: python scripts/batch_patch_files_sudoers.py --name 温胜夜 python scripts/batch_patch_files_sudoers.py --non-root-only --limit 50 python scripts/batch_patch_files_sudoers.py --ids 12,34 --dry-run Requires .env with DATABASE_URL. SSH creds from servers table. Run from dev machine or on central host: bash scripts/batch_patch_files_sudoers.sh """ 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, *, server_id: int | None, server_ids: list[int] | None, name_like: str | None, non_root_only: bool, limit: int, ) -> list[Any]: from sqlalchemy import or_, select from server.domain.models import Server q = select(Server).order_by(Server.id) if server_id is not None: q = q.where(Server.id == server_id) elif server_ids: q = q.where(Server.id.in_(server_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) servers = list(result.scalars().all()) if limit > 0: servers = servers[:limit] return servers async def run_patch(args: argparse.Namespace) -> list[dict[str, Any]]: from server.application.services.files_sudoers_service import install_nexus_files_sudoers 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, server_id=args.id, server_ids=ids, name_like=args.name, non_root_only=args.non_root_only, limit=args.limit, ) if not servers: print("No servers matched.", file=sys.stderr) return [] sem = asyncio.Semaphore(max(1, args.concurrency)) results: list[dict[str, Any]] = [] async def _one(server: Any) -> None: async with sem: try: row = await install_nexus_files_sudoers(server, dry_run=args.dry_run) except Exception as exc: row = { "server_id": server.id, "server_name": server.name, "ssh_user": server.username, "ok": False, "action": "error", "detail": str(exc)[:500], } results.append(row) status = "OK" if row.get("ok") else "FAIL" print( f"[{status}] #{row.get('server_id')} {row.get('server_name')} " f"({row.get('ssh_user')}) → {row.get('action')}" + (f" — {row.get('detail')}" if row.get("detail") else ""), flush=True, ) await asyncio.gather(*[_one(s) for s in servers]) ok = sum(1 for r in results if r.get("ok")) fail = len(results) - ok print(f"\nDone: {ok} ok / {fail} fail / {len(results)} total") return results def main() -> int: _load_dotenv() parser = argparse.ArgumentParser(description="Batch patch nexus-files sudoers (rsync for push)") parser.add_argument("--id", type=int, help="Single server id") 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("--non-root-only", action="store_true", help="Skip username=root") parser.add_argument("--limit", type=int, default=0, help="Max servers (0=all)") parser.add_argument("--concurrency", type=int, default=5) parser.add_argument("--dry-run", action="store_true", help="Report only, no SSH writes") args = parser.parse_args() if not args.id and not args.ids and not args.name and not args.non_root_only: parser.error("Specify --id, --ids, --name, and/or --non-root-only") results = asyncio.run(run_patch(args)) return 0 if results and all(r.get("ok") for r in results) else (1 if results else 2) if __name__ == "__main__": raise SystemExit(main())