Initial commit
This commit is contained in:
BIN
backend/services/__pycache__/ip_processor.cpython-313.pyc
Normal file
BIN
backend/services/__pycache__/ip_processor.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/services/__pycache__/logger.cpython-313.pyc
Normal file
BIN
backend/services/__pycache__/logger.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/services/__pycache__/watchdog_handler.cpython-313.pyc
Normal file
BIN
backend/services/__pycache__/watchdog_handler.cpython-313.pyc
Normal file
Binary file not shown.
152
backend/services/ip_processor.py
Normal file
152
backend/services/ip_processor.py
Normal file
@@ -0,0 +1,152 @@
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
import logging
|
||||
import subprocess
|
||||
import platform
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from threading import Lock
|
||||
from watchdog.observers import Observer
|
||||
from backend.services.watchdog_handler import FileCreatedHandler
|
||||
from config import Config
|
||||
|
||||
# Job ID별 진행률 (스레드 안전)
|
||||
_progress: dict[str, int] = {}
|
||||
_progress_lock = Lock()
|
||||
|
||||
|
||||
def _set_progress(job_id: str, value: int) -> None:
|
||||
with _progress_lock:
|
||||
_progress[job_id] = max(0, min(100, int(value)))
|
||||
|
||||
|
||||
def get_progress(job_id: str) -> int:
|
||||
with _progress_lock:
|
||||
return int(_progress.get(job_id, 0))
|
||||
|
||||
|
||||
def on_complete(job_id: str) -> None:
|
||||
_set_progress(job_id, 100)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# IP 목록 저장
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
def save_ip_addresses(ips: str, folder: str | os.PathLike[str]) -> list[tuple[str, str]]:
|
||||
out_dir = Path(folder)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ip_files: list[tuple[str, str]] = []
|
||||
for i, raw in enumerate((ips or "").splitlines()):
|
||||
ip = raw.strip()
|
||||
if not ip:
|
||||
continue
|
||||
file_path = out_dir / f"ip_{i}.txt"
|
||||
file_path.write_text(ip + "\n", encoding="utf-8")
|
||||
ip_files.append((ip, str(file_path)))
|
||||
return ip_files
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 개별 IP 처리
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_command(script: str, ip_file: str, xml_file: str | None) -> list[str]:
|
||||
script_path = Path(Config.SCRIPT_FOLDER) / script
|
||||
if not script_path.exists():
|
||||
raise FileNotFoundError(f"스크립트를 찾을 수 없습니다: {script_path}")
|
||||
|
||||
if script_path.suffix == ".sh":
|
||||
# Windows에서 .sh 실행은 bash 필요 (Git Bash/WSL 등). 없으면 예외 처리.
|
||||
if platform.system() == "Windows":
|
||||
bash = shutil.which("bash") # type: ignore[name-defined]
|
||||
if not bash:
|
||||
raise RuntimeError("Windows에서 .sh 스크립트를 실행하려면 bash가 필요합니다.")
|
||||
cmd = [bash, str(script_path), ip_file]
|
||||
else:
|
||||
cmd = [str(script_path), ip_file]
|
||||
elif script_path.suffix == ".py":
|
||||
cmd = [sys.executable, str(script_path), ip_file]
|
||||
else:
|
||||
raise ValueError(f"지원되지 않는 스크립트 형식: {script_path.suffix}")
|
||||
|
||||
if xml_file:
|
||||
cmd.append(xml_file)
|
||||
return cmd
|
||||
|
||||
|
||||
def process_ip(ip_file: str, script: str, xml_file: str | None = None) -> None:
|
||||
ip = Path(ip_file).read_text(encoding="utf-8").strip()
|
||||
cmd = _build_command(script, ip_file, xml_file)
|
||||
|
||||
logging.info("🔧 실행 명령: %s", " ".join(cmd))
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
cwd=str(Path(Config.SCRIPT_FOLDER)),
|
||||
timeout=int(os.getenv("SCRIPT_TIMEOUT", "1800")), # 30분 기본
|
||||
)
|
||||
logging.info("[%s] ✅ stdout:\n%s", ip, result.stdout)
|
||||
if result.stderr:
|
||||
logging.warning("[%s] ⚠ stderr:\n%s", ip, result.stderr)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logging.error("[%s] ❌ 스크립트 실행 오류(code=%s): %s", ip, e.returncode, e.stderr or e)
|
||||
except subprocess.TimeoutExpired:
|
||||
logging.error("[%s] ⏰ 스크립트 실행 타임아웃", ip)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 병렬 처리 진입점
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
def process_ips_concurrently(ip_files, job_id, observer: Observer, script: str, xml_file: str | None):
|
||||
total = len(ip_files)
|
||||
completed = 0
|
||||
_set_progress(job_id, 0)
|
||||
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=Config.MAX_WORKERS) as pool:
|
||||
futures = {pool.submit(process_ip, ip_path, script, xml_file): ip for ip, ip_path in ip_files}
|
||||
for fut in as_completed(futures):
|
||||
ip = futures[fut]
|
||||
try:
|
||||
fut.result()
|
||||
except Exception as e:
|
||||
logging.error("%s 처리 중 오류 발생: %s", ip, e)
|
||||
finally:
|
||||
completed += 1
|
||||
if total:
|
||||
_set_progress(job_id, int(completed * 100 / total))
|
||||
finally:
|
||||
try:
|
||||
observer.stop()
|
||||
observer.join(timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 외부에서 한 번에 처리(동기)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
def handle_ip_processing(ip_text: str, script: str, xml_file: str | None = None) -> str:
|
||||
job_id = str(uuid.uuid4())
|
||||
|
||||
temp_dir = Path(Config.UPLOAD_FOLDER) / job_id
|
||||
ip_files = save_ip_addresses(ip_text, temp_dir)
|
||||
|
||||
xml_path = str(Path(Config.XML_FOLDER) / xml_file) if xml_file else None
|
||||
|
||||
handler = FileCreatedHandler(job_id, len(ip_files))
|
||||
observer = Observer()
|
||||
observer.schedule(handler, Config.IDRAC_INFO_FOLDER, recursive=False)
|
||||
observer.start()
|
||||
|
||||
process_ips_concurrently(ip_files, job_id, observer, script, xml_path)
|
||||
return job_id
|
||||
65
backend/services/logger.py
Normal file
65
backend/services/logger.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
import logging
|
||||
import os
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
from typing import Optional
|
||||
from config import Config
|
||||
|
||||
|
||||
_DEF_LEVEL = os.getenv("APP_LOG_LEVEL", "INFO").upper()
|
||||
_DEF_FMT = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
||||
|
||||
|
||||
def _ensure_log_dir() -> Path:
|
||||
p = Path(Config.LOG_FOLDER)
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
def setup_logging(app: Optional[object] = None) -> logging.Logger:
|
||||
"""앱 전역 로깅을 파일(일단위 회전) + 콘솔로 설정.
|
||||
- 회전 파일명: YYYY-MM-DD.log
|
||||
- 중복 핸들러 방지
|
||||
- Windows/Linux 공통 동작
|
||||
"""
|
||||
log_dir = _ensure_log_dir()
|
||||
log_path = log_dir / "app.log"
|
||||
|
||||
root = logging.getLogger()
|
||||
root.setLevel(_DEF_LEVEL)
|
||||
root.propagate = False
|
||||
|
||||
# 기존 핸들러 제거(중복 방지)
|
||||
for h in root.handlers[:]:
|
||||
root.removeHandler(h)
|
||||
|
||||
# 파일 로거
|
||||
file_handler = TimedRotatingFileHandler(
|
||||
filename=str(log_path), when="midnight", interval=1, backupCount=90, encoding="utf-8", utc=False
|
||||
)
|
||||
|
||||
# 회전 파일명: 2025-09-30.log 형태로
|
||||
def _namer(default_name: str) -> str:
|
||||
# default_name: app.log.YYYY-MM-DD
|
||||
base_dir = os.path.dirname(default_name)
|
||||
date_str = default_name.rsplit(".", 1)[-1]
|
||||
return os.path.join(base_dir, f"{date_str}.log")
|
||||
|
||||
file_handler.namer = _namer
|
||||
file_handler.setFormatter(logging.Formatter(_DEF_FMT))
|
||||
|
||||
# 콘솔 로거
|
||||
console = logging.StreamHandler()
|
||||
console.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
|
||||
|
||||
root.addHandler(file_handler)
|
||||
root.addHandler(console)
|
||||
|
||||
if app is not None:
|
||||
# Flask 앱 로거에도 동일 핸들러 바인딩
|
||||
app.logger.handlers = root.handlers
|
||||
app.logger.setLevel(root.level)
|
||||
|
||||
root.info("Logger initialized | level=%s | file=%s", _DEF_LEVEL, log_path)
|
||||
return root
|
||||
50
backend/services/watchdog_handler.py
Normal file
50
backend/services/watchdog_handler.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
from flask_socketio import SocketIO
|
||||
|
||||
# 외부에서 주입되는 socketio 인스턴스 (app.py에서 설정)
|
||||
socketio: Optional[SocketIO] = None
|
||||
|
||||
|
||||
class FileCreatedHandler(FileSystemEventHandler):
|
||||
"""파일 생성 감지를 처리하는 Watchdog 핸들러.
|
||||
- temp_ip 등 임시 파일은 무시
|
||||
- 감지 시 진행률/로그를 SocketIO로 실시간 브로드캐스트
|
||||
"""
|
||||
|
||||
def __init__(self, job_id: str, total_files: int):
|
||||
super().__init__()
|
||||
self.job_id = job_id
|
||||
self.total_files = max(int(total_files or 0), 0)
|
||||
self.completed_files = 0
|
||||
|
||||
def _broadcast(self, event_name: str, data: dict) -> None:
|
||||
if not socketio:
|
||||
return
|
||||
try:
|
||||
socketio.emit(event_name, data, namespace="/")
|
||||
except Exception as e:
|
||||
logging.warning("[Watchdog] SocketIO 전송 실패: %s", e)
|
||||
|
||||
def _should_ignore(self, src_path: str) -> bool:
|
||||
# 임시 업로드 디렉터리 하위 파일은 무시
|
||||
return "temp_ip" in src_path.replace("\\", "/")
|
||||
|
||||
def on_created(self, event):
|
||||
if event.is_directory:
|
||||
return
|
||||
if self._should_ignore(event.src_path):
|
||||
return
|
||||
|
||||
self.completed_files = min(self.completed_files + 1, self.total_files or 0)
|
||||
filename = os.path.basename(event.src_path)
|
||||
msg = f"[Watchdog] 생성된 파일: {filename} ({self.completed_files}/{self.total_files})"
|
||||
logging.info(msg)
|
||||
|
||||
self._broadcast("log_update", {"job_id": self.job_id, "log": msg})
|
||||
if self.total_files:
|
||||
progress = int((self.completed_files / self.total_files) * 100)
|
||||
self._broadcast("progress", {"job_id": self.job_id, "progress": progress})
|
||||
Reference in New Issue
Block a user