325 lines
14 KiB
Python
325 lines
14 KiB
Python
"""
|
|
GUARDiA ITSM 관리자 기능 API (GS인증 필수 요구사항)
|
|
|
|
엔드포인트:
|
|
GET /api/admin/about — 시스템 버전/빌드 정보 (GS인증 유지보수성)
|
|
POST /api/admin/backup — DB 백업 (GS인증 신뢰성 > 복구성)
|
|
GET /api/admin/backups — 백업 목록
|
|
POST /api/admin/restore/{filename} — 백업 복원
|
|
GET /api/admin/health — 시스템 상태 종합
|
|
GET /api/admin/errors/codes — 에러 코드 목록 (GS인증 기능적절성)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import shutil
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from fastapi.responses import FileResponse
|
|
from sqlalchemy import text
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from core.auth import get_current_user, require_admin_role
|
|
from database import get_db
|
|
from models import User
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
|
|
|
# 백업 저장 경로
|
|
BACKUP_DIR = Path(os.getenv("BACKUP_DIR", "./backups"))
|
|
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
# ── About / 버전 정보 (GS인증 유지보수성) ──────────────────────────────────
|
|
|
|
@router.get("/about")
|
|
async def get_about(_u: User = Depends(get_current_user)):
|
|
"""시스템 버전, 빌드 정보, 라이선스, 오픈소스 정보 (GS인증 유지보수성 > 분석성)."""
|
|
import sys, platform
|
|
|
|
return {
|
|
"product": "GUARDiA ITSM",
|
|
"version": "2.0.0",
|
|
"build_date": "2026-05-30",
|
|
"description": "AI 기반 레거시 인프라 자율 운영 플랫폼",
|
|
"vendor": "(주)지오정보기술",
|
|
"copyright": "Copyright © 2026 (주)지오정보기술 All Rights Reserved.",
|
|
"support": "support@zioinfo.co.kr",
|
|
"website": "https://www.zioinfo.co.kr",
|
|
"gs_cert": "GS 1등급 취득 예정 (2026년 12월)",
|
|
"environment": {
|
|
"python": sys.version.split()[0],
|
|
"os": platform.system() + " " + platform.release(),
|
|
"arch": platform.machine(),
|
|
},
|
|
"open_source": [
|
|
{"name": "FastAPI", "version": "0.115+", "license": "MIT"},
|
|
{"name": "SQLAlchemy", "version": "2.0+", "license": "MIT"},
|
|
{"name": "Pydantic", "version": "2.0+", "license": "MIT"},
|
|
{"name": "React.js", "version": "18.3+", "license": "MIT"},
|
|
{"name": "Chart.js", "version": "4.4+", "license": "MIT"},
|
|
{"name": "D3.js", "version": "7.0+", "license": "BSD-3"},
|
|
{"name": "Ollama", "version": "최신", "license": "MIT"},
|
|
{"name": "APScheduler", "version": "3.x", "license": "MIT"},
|
|
{"name": "paramiko", "version": "최신", "license": "LGPL"},
|
|
],
|
|
"api_count": 595,
|
|
"total_routes": 595,
|
|
}
|
|
|
|
|
|
# ── 에러 코드 목록 (GS인증 기능 적절성) ─────────────────────────────────────
|
|
|
|
@router.get("/errors/codes")
|
|
async def get_error_codes(_u: User = Depends(get_current_user)):
|
|
"""GUARDiA 에러 코드 및 해결 방법 목록 (GS인증 기능 적절성 > 오류 메시지)."""
|
|
return {
|
|
"error_codes": [
|
|
# 인증/권한
|
|
{"code": "AUTH_001", "http": 401, "message": "로그인이 필요합니다.", "solution": "다시 로그인해 주세요."},
|
|
{"code": "AUTH_002", "http": 401, "message": "아이디 또는 비밀번호가 틀렸습니다.", "solution": "입력 정보를 확인하고 다시 시도하세요."},
|
|
{"code": "AUTH_003", "http": 403, "message": "계정이 잠겼습니다.", "solution": "30분 후 다시 시도하거나 관리자에게 문의하세요."},
|
|
{"code": "AUTH_004", "http": 403, "message": "이 기능을 사용할 권한이 없습니다.", "solution": "관리자에게 권한 부여를 요청하세요."},
|
|
# SR
|
|
{"code": "SR_001", "http": 404, "message": "SR을 찾을 수 없습니다.", "solution": "SR ID를 확인하거나 목록에서 다시 검색하세요."},
|
|
{"code": "SR_002", "http": 400, "message": "이 상태에서는 해당 전이를 할 수 없습니다.", "solution": "SR 상태 흐름을 확인하세요 (접수→파싱→승인→진행→완료)."},
|
|
{"code": "SR_003", "http": 400, "message": "SR ID 목록이 비어 있습니다.", "solution": "처리할 SR을 하나 이상 선택하세요."},
|
|
{"code": "SR_004", "http": 400, "message": "한 번에 최대 100건까지 처리할 수 있습니다.", "solution": "SR을 100건 이하로 나누어 처리하세요."},
|
|
# 라이선스
|
|
{"code": "LIC_001", "http": 402, "message": "라이선스가 만료되었습니다.", "solution": "/license 페이지에서 라이선스를 갱신하세요."},
|
|
{"code": "LIC_002", "http": 409, "message": "무료 체험은 설치당 1회만 가능합니다.", "solution": "정식 라이선스를 구매하거나 지원팀에 문의하세요."},
|
|
{"code": "LIC_003", "http": 400, "message": "라이선스 한도를 초과했습니다.", "solution": "상위 에디션으로 업그레이드하세요."},
|
|
# CMDB
|
|
{"code": "CMDB_001", "http": 400, "message": "root SSH 계정은 등록할 수 없습니다.", "solution": "opsagent 등 일반 계정을 사용하세요 (보안 정책)."},
|
|
{"code": "CMDB_002", "http": 404, "message": "서버를 찾을 수 없습니다.", "solution": "CMDB에서 서버가 등록되어 있는지 확인하세요."},
|
|
# AI/LLM
|
|
{"code": "AI_001", "http": 503, "message": "AI 엔진에 연결할 수 없습니다.", "solution": "Ollama 서비스 상태를 확인하세요 (http://localhost:11434)."},
|
|
{"code": "AI_002", "http": 400, "message": "외부 AI API는 사용할 수 없습니다.", "solution": "온프레미스 Ollama만 허용됩니다 (보안 정책)."},
|
|
# 일반
|
|
{"code": "SYS_001", "http": 500, "message": "서버 오류가 발생했습니다.", "solution": "잠시 후 다시 시도하거나 관리자에게 문의하세요."},
|
|
{"code": "SYS_002", "http": 503, "message": "서비스가 일시적으로 사용할 수 없습니다.", "solution": "잠시 후 다시 시도하세요."},
|
|
{"code": "VAL_001", "http": 422, "message": "입력 값이 올바르지 않습니다.", "solution": "입력 필드의 형식과 범위를 확인하세요."},
|
|
]
|
|
}
|
|
|
|
|
|
# ── 백업 (GS인증 신뢰성 > 복구성) ──────────────────────────────────────────
|
|
|
|
@router.post("/backup")
|
|
async def create_backup(
|
|
db: AsyncSession = Depends(get_db),
|
|
cu: User = Depends(require_admin_role),
|
|
):
|
|
"""DB 및 설정 파일 백업 생성 (ADMIN 전용)."""
|
|
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
|
backup_name = f"guardia_backup_{timestamp}"
|
|
backup_path = BACKUP_DIR / backup_name
|
|
backup_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
backed_up = []
|
|
|
|
# 1. SQLite DB 백업
|
|
from database import engine
|
|
db_url = str(engine.url)
|
|
if "sqlite" in db_url:
|
|
db_file = db_url.replace("sqlite+aiosqlite:///", "").replace("sqlite:///", "")
|
|
db_file = Path(db_file)
|
|
if db_file.exists():
|
|
shutil.copy2(db_file, backup_path / "guardia_itsm.db")
|
|
backed_up.append("guardia_itsm.db")
|
|
|
|
# 2. .env 파일 백업
|
|
env_paths = [
|
|
Path("./itsm/.env"),
|
|
Path("./.env"),
|
|
Path("../.env"),
|
|
]
|
|
for env_p in env_paths:
|
|
if env_p.exists():
|
|
shutil.copy2(env_p, backup_path / ".env")
|
|
backed_up.append(".env")
|
|
break
|
|
|
|
# 3. 업로드 파일 백업
|
|
upload_dir = Path("./uploads")
|
|
if upload_dir.exists():
|
|
shutil.copytree(upload_dir, backup_path / "uploads", dirs_exist_ok=True)
|
|
backed_up.append("uploads/")
|
|
|
|
# 4. 백업 메타데이터
|
|
meta = {
|
|
"backup_name": backup_name,
|
|
"created_at": datetime.utcnow().isoformat(),
|
|
"created_by": cu.username,
|
|
"files": backed_up,
|
|
"version": "2.0.0",
|
|
}
|
|
import json
|
|
(backup_path / "backup_meta.json").write_text(
|
|
json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
)
|
|
|
|
# 5. ZIP 압축
|
|
zip_path = BACKUP_DIR / f"{backup_name}.zip"
|
|
shutil.make_archive(str(BACKUP_DIR / backup_name), "zip", str(BACKUP_DIR), backup_name)
|
|
shutil.rmtree(backup_path)
|
|
|
|
size_mb = round(zip_path.stat().st_size / 1024 / 1024, 2)
|
|
|
|
logger.info("백업 생성: %s (%.2fMB) by %s", zip_path.name, size_mb, cu.username)
|
|
|
|
return {
|
|
"message": f"백업 완료: {zip_path.name}",
|
|
"backup_name": zip_path.name,
|
|
"size_mb": size_mb,
|
|
"files": backed_up,
|
|
"created_at": datetime.utcnow().isoformat(),
|
|
}
|
|
|
|
|
|
@router.get("/backups")
|
|
async def list_backups(cu: User = Depends(require_admin_role)):
|
|
"""백업 파일 목록 조회 (ADMIN 전용)."""
|
|
backups = []
|
|
for f in sorted(BACKUP_DIR.glob("guardia_backup_*.zip"), reverse=True):
|
|
stat = f.stat()
|
|
backups.append({
|
|
"filename": f.name,
|
|
"size_mb": round(stat.st_size / 1024 / 1024, 2),
|
|
"created_at": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
|
})
|
|
return {"backups": backups, "total": len(backups)}
|
|
|
|
|
|
@router.get("/backups/{filename}/download")
|
|
async def download_backup(
|
|
filename: str,
|
|
cu: User = Depends(require_admin_role),
|
|
):
|
|
"""백업 파일 다운로드 (ADMIN 전용)."""
|
|
# 경로 조작 방지
|
|
safe_name = Path(filename).name
|
|
if not safe_name.startswith("guardia_backup_") or not safe_name.endswith(".zip"):
|
|
raise HTTPException(400, "올바르지 않은 백업 파일명입니다.")
|
|
file_path = BACKUP_DIR / safe_name
|
|
if not file_path.exists():
|
|
raise HTTPException(404, "백업 파일을 찾을 수 없습니다.")
|
|
return FileResponse(
|
|
path=str(file_path),
|
|
filename=safe_name,
|
|
media_type="application/zip",
|
|
)
|
|
|
|
|
|
@router.post("/restore/{filename}")
|
|
async def restore_backup(
|
|
filename: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
cu: User = Depends(require_admin_role),
|
|
):
|
|
"""백업에서 복원 (ADMIN 전용 — 주의: 현재 데이터 덮어씀)."""
|
|
safe_name = Path(filename).name
|
|
if not safe_name.startswith("guardia_backup_") or not safe_name.endswith(".zip"):
|
|
raise HTTPException(400, "올바르지 않은 백업 파일명입니다.")
|
|
file_path = BACKUP_DIR / safe_name
|
|
if not file_path.exists():
|
|
raise HTTPException(404, "백업 파일을 찾을 수 없습니다.")
|
|
|
|
# 복원 전 현재 상태 임시 백업
|
|
pre_backup = BACKUP_DIR / f"pre_restore_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.zip"
|
|
|
|
# SQLite 복원
|
|
import zipfile
|
|
extract_dir = BACKUP_DIR / "restore_temp"
|
|
extract_dir.mkdir(exist_ok=True)
|
|
|
|
try:
|
|
with zipfile.ZipFile(file_path, "r") as zf:
|
|
zf.extractall(extract_dir)
|
|
|
|
# DB 파일 복원
|
|
from database import engine
|
|
db_url = str(engine.url)
|
|
if "sqlite" in db_url:
|
|
db_file = db_url.replace("sqlite+aiosqlite:///", "").replace("sqlite:///", "")
|
|
db_file = Path(db_file)
|
|
# 복원할 DB 파일 탐색
|
|
for f in extract_dir.rglob("guardia_itsm.db"):
|
|
shutil.copy2(f, db_file)
|
|
break
|
|
|
|
return {
|
|
"message": f"복원 완료: {filename}",
|
|
"warning": "서비스를 재시작해야 변경사항이 적용됩니다.",
|
|
"pre_backup": pre_backup.name if pre_backup.exists() else None,
|
|
}
|
|
finally:
|
|
shutil.rmtree(extract_dir, ignore_errors=True)
|
|
|
|
|
|
# ── 시스템 건강 상태 ──────────────────────────────────────────────────────────
|
|
|
|
@router.get("/health")
|
|
async def system_health(
|
|
db: AsyncSession = Depends(get_db),
|
|
_u: User = Depends(get_current_user),
|
|
):
|
|
"""시스템 종합 건강 상태 (GS인증 신뢰성 > 가용성)."""
|
|
import httpx
|
|
|
|
checks = {}
|
|
|
|
# DB 연결
|
|
try:
|
|
await db.execute(text("SELECT 1"))
|
|
checks["database"] = {"status": "ok", "message": "DB 연결 정상"}
|
|
except Exception as e:
|
|
checks["database"] = {"status": "error", "message": str(e)[:100]}
|
|
|
|
# Ollama
|
|
try:
|
|
async with httpx.AsyncClient(timeout=3.0) as c:
|
|
r = await c.get(os.getenv("OLLAMA_BASE_URL", "http://localhost:11434") + "/api/version")
|
|
checks["ollama"] = {"status": "ok" if r.status_code == 200 else "warn",
|
|
"message": r.json().get("version", "연결됨")}
|
|
except Exception:
|
|
checks["ollama"] = {"status": "warn", "message": "Ollama 미연결 (AI 기능 제한)"}
|
|
|
|
# 디스크
|
|
import shutil as sh
|
|
disk = sh.disk_usage(".")
|
|
free_gb = round(disk.free / 1024**3, 1)
|
|
checks["disk"] = {
|
|
"status": "ok" if free_gb > 1 else "warn",
|
|
"message": f"여유 공간: {free_gb}GB",
|
|
"free_gb": free_gb,
|
|
}
|
|
|
|
# 라이선스
|
|
try:
|
|
from routers.license import get_license_status
|
|
lic = await get_license_status(db)
|
|
checks["license"] = {
|
|
"status": "ok" if lic.get("valid") else ("warn" if lic.get("expired") else "info"),
|
|
"message": lic.get("message", ""),
|
|
"edition": lic.get("edition"),
|
|
"days": lic.get("days_remaining"),
|
|
}
|
|
except Exception:
|
|
checks["license"] = {"status": "warn", "message": "라이선스 조회 실패"}
|
|
|
|
overall = "ok" if all(c["status"] == "ok" for c in checks.values()) else \
|
|
"error" if any(c["status"] == "error" for c in checks.values()) else "warn"
|
|
|
|
return {
|
|
"overall": overall,
|
|
"version": "2.0.0",
|
|
"uptime": "서버 시작 후 경과 시간",
|
|
"checks": checks,
|
|
"checked_at": datetime.utcnow().isoformat(),
|
|
}
|