commit f2ec46276363f5653af401368fbcb17ad5cd0085 Author: ZioCI Date: Sat May 30 23:02:43 2026 +0900 feat: GUARDiA ITSM v2.0 initial commit diff --git a/.env.open b/.env.open new file mode 100644 index 0000000..7aa22e0 --- /dev/null +++ b/.env.open @@ -0,0 +1,31 @@ +# GUARDiA ITSM — 개방망(Open Network) 운영 환경 설정 +# 사용법: cp .env.open .env 후 systemctl restart guardia + +# ── 네트워크 모드 ───────────────────────────────────────────────────────────── +GUARDIA_NETWORK_MODE=open + +# 허용 외부 출처 (쉼표 구분, HTTPS 도메인 또는 IP) +# 예) https://itsm.zioinfo.co.kr,https://portal.myorg.go.kr +GUARDIA_ALLOWED_ORIGINS=http://101.79.17.164,https://101.79.17.164 + +# ── 웹훅 보안 시크릿 ───────────────────────────────────────────────────────── +# 외부 메신저 웹훅 HMAC 서명 검증용 — 반드시 변경하세요 +GUARDIA_WEBHOOK_SECRET=guardia-webhook-secret-change-me-2026 + +# ── AI 엔진 (온프레미스 전용 — 절대 외부 API 사용 금지) ────────────────────── +OLLAMA_BASE_URL=http://localhost:11434 +LLM_MODEL=llama3:8b + +# ── 데이터베이스 ────────────────────────────────────────────────────────────── +DATABASE_URL=postgresql+asyncpg://guardia:G@urd1a_2026!@localhost:5432/guardia_db + +# ── JWT 인증 ────────────────────────────────────────────────────────────────── +GUARDIA_JWT_SECRET=guardia-jwt-production-secret-2026-please-change + +# ── Rate Limiting (개방망 강화) ─────────────────────────────────────────────── +RATE_LIMIT_PER_MINUTE=60 +RATE_LIMIT_BURST=10 + +# ── 로그 ───────────────────────────────────────────────────────────────────── +LOG_LEVEL=INFO +LOG_FILE=/opt/guardia/logs/guardia.log diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c29a72d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,143 @@ +# GUARDiA ITSM — AI 기반 레거시 인프라 자율 운영 플랫폼 +## Claude Code 프로젝트 마스터 컨텍스트 + +> 이 파일을 읽고 프로젝트 전체 구조와 규칙을 파악한 뒤 작업을 시작하라. + +--- + +## 프로젝트 비전 + +1,000개 이상 다중 관공서(Multi-tenant) 레거시 인프라를 타겟으로 하는 +**AI 기반 통합 ChatOps 오케스트레이션 플랫폼**. + +- 메신저 한 줄 명령 → sLLM 파싱 → 에이전트리스(SSH/SFTP) 배포·운영 자동화 +- 에이전트 설치 **불필요** — 표준 SSH/FTP 프로토콜만 활용 +- 개발(Dev), SM 운영, PM 관리 세 역할의 워크플로우를 단일 메신저로 통합 +- **온프레미스 전용**: 외부 클라우드 API 완전 금지 (Ollama 내부 LLM만 허용) + +--- + +## 기술 스택 + +| 레이어 | 기술 | 비고 | +|--------|------|------| +| Backend API | Python 3.11+ / FastAPI | 비동기 WebSocket 처리 | +| LLM | Ollama localhost:11434 (codellama, llama3) | 외부 API 완전 금지 | +| Infra 연결 | paramiko (SSH/SFTP) | 에이전트리스 | +| Database | SQLite (dev) / PostgreSQL (prod) | SQLAlchemy async | +| 암호화 | AES-256-GCM (서버 자격증명) | | +| 인증 | JWT + 2FA/OTP | | + +--- + +## 디렉토리 구조 + +``` +C:\GUARDiA\ +├── itsm/ # FastAPI 백엔드 +│ ├── main.py # FastAPI 앱 진입점 +│ ├── models.py # SQLAlchemy 모델 +│ ├── auth.py # JWT 인증 +│ ├── database.py # DB 연결 +│ ├── core/ # 비즈니스 로직 +│ │ ├── code_review.py # B-3: 코드 리뷰 엔진 +│ │ ├── anomaly.py # B-1: 이상 탐지 엔진 +│ │ └── notify.py # 알림 공통 +│ ├── routers/ # API 라우터 +│ └── .claude/ # 하네스 에이전트/스킬 +├── projects/ # SM/SI 프로젝트 소스 +│ ├── testcase-java-api/ # Spring Boot 테스트케이스 +│ ├── testcase-py-api/ # FastAPI 테스트케이스 +│ ├── testcase-js-frontend/ # HTML/JS 테스트케이스 +│ └── testcase-php-legacy/ # PHP 레거시 +└── docs/ +``` + +--- + +## 보안 제약 (절대 불변) + +1. **외부 API 완전 금지** — 모든 AI/LLM 호출은 내부 sLLM only (Ollama localhost:11434) +2. **서버 자격증명 노출 금지** — IP, 비밀번호, SSH 계정을 API 응답/메신저에 포함 금지 +3. **AES-256-GCM 암호화** — tb_server.os_pw_enc 컬럼 필수 암호화 +4. **root SSH 직접 접속 금지** — ssh_user != root 강제 +5. **ServerOut 스키마 제외 필드** — ip_addr, ssh_user, os_pw_enc 절대 미포함 +6. **스택트레이스 노출 금지** — 에러 응답은 SR ID + 요약 메시지만 +7. **명령어 안전성 검증** — SSH 실행 전 위험 패턴 차단 (rm -rf /, mkfs, dd, shutdown 등) +8. **경로 순회 방지** — 첨부파일 경로 resolve().relative_to(UPLOAD_ROOT) 검증 + +--- + +## 30개 고도화 항목 진행 현황 + +| 코드 | 항목 | 상태 | 주요 파일 | +|------|------|------|-----------| +| A-1 | 알림 고도화 (WebSocket + 이메일) | ✅ DONE | routers/notify.py, core/notify.py | +| A-2 | 첨부 파일 관리 | ✅ DONE | routers/attachments.py | +| A-3 | 배포 승인 알림 (VibeSession 연동) | ✅ DONE | routers/vibe.py | +| A-4 | 운영 이벤트 타임라인 | ✅ DONE | routers/timeline.py | +| A-5 | 역할 기반 접근 제어 (RBAC) | ✅ DONE | auth.py, models.py | +| B-1 | AI 이상 탐지 (Anomaly Detection) | ✅ DONE | core/anomaly.py, routers/anomaly.py | +| B-2 | 자연어 SR 접수 챗봇 | ✅ DONE | core/chatbot.py, routers/chatbot.py | +| B-3 | 코드 리뷰 에이전트 (Ollama) | ✅ DONE | core/code_review.py, routers/code_review.py | +| B-4 | KB 자동 업데이트 에이전트 | ✅ DONE | core/kb_agent.py, routers/kb_agent.py | +| B-5 | 멀티 에이전트 협업 오케스트레이션 | ✅ DONE | core/orchestrator.py, routers/orchestrator.py | +| B-6 | 예측 유지보수 | ✅ DONE | core/predictive.py, routers/predictive.py | +| C-1 | CMDB (형상 관리 DB) | ✅ DONE | routers/cmdb.py (CI+관계+변경이력) | +| C-2 | 변경 관리 CAB | ✅ DONE | routers/change.py (RFC/CAB투표/동결기간) | +| C-3 | Problem Management | ✅ DONE | routers/problem.py (RCA/Known Error/PRB ID) | +| C-4 | 용량 관리 대시보드 | ✅ DONE | routers/capacity.py (예측/경보/확장시점) | +| C-5 | 서비스 카탈로그 | ✅ DONE | routers/catalog.py (SLA/SR자동생성/SVC ID) | +| D-1 | LDAP/AD 연동 | ✅ DONE | core/ldap_auth.py, routers/ldap.py (그룹→역할/env설정/동기화) | +| D-2 | 2FA / OTP | ✅ DONE | routers/otp.py | +| D-3 | 특권 접근 관리 (PAM) | ✅ DONE | routers/pam.py (세션발급/체크아웃/명령로깅/강제종료) | +| D-4 | 보안 취약점 자동 스캔 | ✅ DONE | core/vuln_scan.py, routers/vuln_scan.py (포트스캔/CVE/CVSS) | +| D-5 | 불변 감사 로그 (Hash Chain) | ✅ DONE | routers/audit.py (SHA-256체인/entity/IP해시/export) | +| E-1 | 월별 리포트 자동 생성 | ✅ DONE | routers/report.py | +| E-2 | 대시보드 분석 | ✅ DONE | routers/analytics.py | +| E-3 | SLA 대시보드 (실시간) | ✅ DONE | routers/sla.py | +| E-4 | Grafana 연동 (Prometheus) | ✅ DONE | routers/metrics.py | +| E-5 | FinOps 비용 분석 | ✅ DONE | routers/finops.py | +| F-1 | 멀티테넌트 데이터 격리 | ✅ DONE | middleware/tenant.py | +| F-2 | 모바일 반응형 UI | ✅ DONE | static/ | +| F-3 | 다국어 지원 (i18n) | ✅ DONE | core/i18n.py | +| F-4 | Mobile PWA | ✅ DONE | static/manifest.json | +| F-5 | OpenAPI 외부 연동 게이트웨이 | ✅ DONE | routers/gateway.py | + +**완료: 30개 | 남은 항목: 0개 🎉** + +--- + +## GUARDiA 하네스 구조 (.claude/) + +### 에이전트 (agents/) +- **sr-manager.md**: SR 생성/조회/상태변경/담당자배정 +- **code-reviewer.md**: B-3 코드 리뷰, quick-scan, findings 분석 +- **deploy-engineer.md**: VibeSession 배포 파이프라인, Jenkins 연동 +- **sla-guardian.md**: SLA 모니터링, 에스컬레이션 +- **incident-responder.md**: 인시던트 생성, 온콜 호출, RCA + +### 스킬 (skills/) +- **guardia-orchestrator/SKILL.md**: E2E SR→코드리뷰→배포 워크플로우 +- **code-review/SKILL.md**: B-3 코드 리뷰 실행 가이드 +- **sr-lifecycle/SKILL.md**: SR 상태 흐름, SLA 기준 +- **deploy-pipeline/SKILL.md**: VibeSession 배포 단계 관리 + +--- + +## 핵심 구현 원칙 + +1. **에이전트리스**: 대상 서버에 어떤 소프트웨어도 설치하지 않는다. +2. **결정론적 파싱**: sLLM은 JSON만 출력한다. 자연어 부연 설명 금지. +3. **Fail-Safe**: 모든 배포는 백업→배포→헬스체크→롤백 시퀀스를 따른다. +4. **감사 추적**: 모든 명령과 결과는 TB_AUDIT_LOG에 기록한다. +5. **최소 권한**: 관제 전용 일반 계정(opsagent) 사용. root SSH 직접 접속 금지. +6. **보안 우선**: 서버 자격증명은 암호화 DB에만 저장. 메신저 응답에 노출 금지. + +--- + +## 파일 작성 규칙 + +- **CLAUDE.md 수정 시**: 반드시 Python `open(encoding='utf-8')` 또는 Write 도구 사용 + - PowerShell `Set-Content` / `Out-File` 절대 금지 (UTF-16 LE 오염 발생) +- **.xfdl.js 파일**: Nexacro 빌드 결과물 — 절대 수정/커밋 금지 diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..e04f073 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,91 @@ +pipeline { + agent any + + environment { + APP_DIR = '/opt/guardia/app' + VENV = '/opt/guardia/venv' + SERVICE = 'guardia' + GITEA_URL = 'http://localhost:3000/zio/guardia-itsm.git' + } + + options { + buildDiscarder(logRotator(numToKeepStr: '5')) + timeout(time: 15, unit: 'MINUTES') + } + + stages { + stage('Checkout') { + steps { + echo "체크아웃: ${env.GIT_BRANCH ?: 'main'}" + checkout scm + } + } + + stage('Python Lint') { + steps { + sh ''' + echo "=== Python 문법 검사 ===" + python3 -m py_compile main.py models.py database.py + find routers/ -name "*.py" -exec python3 -m py_compile {} \\; + find core/ -name "*.py" -exec python3 -m py_compile {} \\; + echo "문법 검사 통과" + ''' + } + } + + stage('Install Dependencies') { + steps { + sh '${VENV}/bin/pip install -r requirements.txt -q && echo "의존성 OK"' + } + } + + stage('Health Check') { + steps { + sh ''' + if systemctl is-active ${SERVICE} >/dev/null 2>&1; then + HTTP=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8001/docs) + echo "현재 서비스 HTTP: $HTTP" + else + echo "서비스 미실행" + fi + ''' + } + } + + stage('Deploy') { + when { branch 'main' } + steps { + sh ''' + echo "=== GUARDiA ITSM 배포 ===" + # 백업 + BACKUP=/opt/guardia/backups/$(date +%Y%m%d_%H%M%S) + mkdir -p $BACKUP + cp -r ${APP_DIR}/*.py ${APP_DIR}/routers ${APP_DIR}/core $BACKUP/ 2>/dev/null || true + + # 파일 복사 + rsync -av --exclude="__pycache__" --exclude="test_*.py" \\ + --exclude="*.db" --exclude=".git" \\ + ./ ${APP_DIR}/ + + # 패키지 업데이트 + ${VENV}/bin/pip install -r requirements.txt -q + + # 서비스 재시작 + systemctl restart ${SERVICE} + sleep 5 + + # 헬스체크 + HTTP=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8001/docs) + echo "배포 후 HTTP: $HTTP" + [ "$HTTP" = "200" ] && echo "배포 성공" || (echo "배포 실패"; exit 1) + ''' + } + } + } + + post { + success { echo "✅ GUARDiA 배포 성공: ${currentBuild.displayName}" } + failure { echo "❌ GUARDiA 배포 실패: ${currentBuild.displayName}" } + always { cleanWs(cleanWhenNotBuilt: false, cleanWhenSuccess: false) } + } +} diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..6ae76b6 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,39 @@ +[alembic] +script_location = migrations +prepend_sys_path = . +# PostgreSQL URL은 DATABASE_URL 환경변수로 오버라이드 가능 +sqlalchemy.url = postgresql+psycopg2://guardia:guardia@localhost:5432/guardia + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/cicd/README.md b/cicd/README.md new file mode 100644 index 0000000..1badad2 --- /dev/null +++ b/cicd/README.md @@ -0,0 +1,154 @@ +# GUARDiA ITSM — CI/CD 표준 가이드 + +> **버전**: 1.0.0 +> **최종 수정**: 2026-05-25 +> **대상 환경**: 온프레미스 Linux (RHEL 8/9, Rocky Linux 8/9, Ubuntu 20.04/22.04) + +--- + +## 1. 개요 + +GUARDiA ITSM의 배포 파이프라인은 **Jenkins 기반 CI/CD**를 표준으로 사용한다. +SR(서비스 요청)이 접수되면 GUARDiA Messenger 봇과 Claude CLI가 바이브 코딩을 수행하고, +완료 후 **Jenkins 파이프라인**이 자동으로 빌드 → 테스트 → 배포 → 결과 통보까지 처리한다. + +``` +SR 접수 + │ + ▼ GUARDiA Messenger → Claude CLI +바이브 코딩 (AI 어시스턴트) + │ 코딩 완료 → !build + ▼ +Jenkins 파이프라인 트리거 (GUARDiA ITSM → Jenkins REST API) + │ + ├─ Stage 1: Checkout 소스 체크아웃 (Git) + ├─ Stage 2: Build 컴파일 / 패키징 + ├─ Stage 3: Test 단위 테스트 / SonarQube 분석 + ├─ Stage 4: Archive 아티팩트 저장 + ├─ Stage 5: Deploy SSH 전송 (배포 서버) + ├─ Stage 6: WAS Restart WAS 재기동 + ├─ Stage 7: Health Check 서비스 정상 확인 + └─ Stage 8: ITSM Callback 결과 → GUARDiA ITSM 자동 등록 + │ + ▼ +SR COMPLETED + 고객 이메일·메신저 자동 발송 +``` + +--- + +## 2. 폴더 구조 + +``` +cicd/ +├── README.md ← 이 파일 (CI/CD 표준 개요) +│ +├── docs/ ← 설치/운영 문서 +│ ├── 01_jenkins_install.md Jenkins 설치 (RHEL/Rocky/Ubuntu) +│ ├── 02_pipeline_standard.md 파이프라인 설계 표준 +│ ├── 03_security_config.md 보안 설정 (Credentials, RBAC) +│ └── 04_itsm_integration.md GUARDiA ITSM 연동 가이드 +│ +├── jenkins/ ← Jenkinsfile 템플릿 +│ ├── Jenkinsfile.java-maven Java Maven 프로젝트 표준 +│ ├── Jenkinsfile.java-gradle Java Gradle 프로젝트 표준 +│ ├── Jenkinsfile.nodejs Node.js 프로젝트 표준 +│ ├── Jenkinsfile.deploy-only 배포 전용 (빌드 없음) +│ └── Jenkinsfile.rollback 롤백 파이프라인 +│ +├── scripts/ +│ ├── install/ +│ │ ├── jenkins_install.sh Jenkins 자동 설치 +│ │ └── jenkins_plugins.sh 필수 플러그인 설치 +│ ├── pipeline/ +│ │ ├── build.sh 빌드 공통 스크립트 +│ │ ├── test.sh 테스트 공통 스크립트 +│ │ ├── deploy.sh 파일 배포 (rsync/SCP) +│ │ ├── was_restart.sh WAS 재기동 (Tomcat/JBoss/JEUS) +│ │ ├── health_check.sh 헬스체크 (HTTP/TCP) +│ │ └── rollback.sh 롤백 스크립트 +│ └── notify/ +│ └── itsm_callback.sh ITSM 결과 콜백 +│ +└── config/ + ├── jenkins.yaml JCasC (Jenkins as Code) 설정 + └── sonarqube.properties SonarQube 연동 설정 +``` + +--- + +## 3. 빠른 시작 + +### 3-1. Jenkins 설치 +```bash +# RHEL/Rocky Linux +sudo bash cicd/scripts/install/jenkins_install.sh + +# 플러그인 자동 설치 +sudo bash cicd/scripts/install/jenkins_plugins.sh +``` + +### 3-2. JCasC 설정 적용 +```bash +# Jenkins 재시작 후 JCasC 적용 +cp cicd/config/jenkins.yaml /var/lib/jenkins/casc_configs/ +sudo systemctl restart jenkins +``` + +### 3-3. GUARDiA ITSM 연동 설정 +```bash +# 환경변수 설정 (itsm/.env) +JENKINS_URL=http://jenkins.agency.go.kr:8080 +JENKINS_USER=itsm-bot +JENKINS_TOKEN= +JENKINS_CALLBACK_URL=http://itsm.agency.go.kr:8000/api/vibe/callback +``` + +### 3-4. 파이프라인 등록 +Jenkins 관리 콘솔 → New Item → Pipeline → SCM에서 Jenkinsfile 지정 +또는 `cicd/config/jenkins.yaml` 로 자동 생성. + +--- + +## 4. 브랜치 전략 (GitFlow) + +``` +main ─────────────────────────────── 운영 배포 + ↑ merge +develop ──────────────────────────────── 통합 개발 + ↑ merge +feature/* ────────────── 기능 개발 (SR 연결) +hotfix/* ─── 긴급 패치 (SR 연결) +release/* ──── 릴리즈 준비 +``` + +| 브랜치 | 파이프라인 | 배포 환경 | +|--------|-----------|---------| +| `feature/*` | Build + Test | 개발 | +| `develop` | Build + Test + Deploy | 개발/스테이징 | +| `release/*` | Full Pipeline + SonarQube | 스테이징 | +| `main` | Full Pipeline + Approval Gate | 운영 | +| `hotfix/*` | Full Pipeline (Fast Track) | 운영 | + +--- + +## 5. 환경별 Jenkins 파이프라인 파라미터 + +| 파라미터 | 설명 | 기본값 | +|---------|------|-------| +| `ITSM_SESSION_ID` | GUARDiA 바이브 세션 ID | — | +| `ITSM_SR_ID` | 연결된 SR ID | — | +| `DEPLOY_ENV` | 배포 환경 (dev/stg/prd) | dev | +| `TARGET_SERVER` | 배포 대상 서버명 | — | +| `SKIP_TEST` | 테스트 건너뜀 (긴급 시) | false | +| `ROLLBACK_VERSION` | 롤백 버전 (빌드 번호) | — | + +--- + +## 6. 관련 문서 + +| 문서 | 경로 | +|------|------| +| Jenkins 설치 가이드 | [docs/01_jenkins_install.md](docs/01_jenkins_install.md) | +| 파이프라인 설계 표준 | [docs/02_pipeline_standard.md](docs/02_pipeline_standard.md) | +| 보안 설정 | [docs/03_security_config.md](docs/03_security_config.md) | +| ITSM 연동 가이드 | [docs/04_itsm_integration.md](docs/04_itsm_integration.md) | diff --git a/cicd/config/jenkins.yaml b/cicd/config/jenkins.yaml new file mode 100644 index 0000000..0999620 --- /dev/null +++ b/cicd/config/jenkins.yaml @@ -0,0 +1,293 @@ +# ============================================================================= +# GUARDiA ITSM — Jenkins Configuration as Code (JCasC) +# 적용: sudo cp jenkins.yaml /var/lib/jenkins/casc_configs/ && systemctl restart jenkins +# 필요 플러그인: configuration-as-code, role-strategy, git, pipeline 등 +# ============================================================================= + +jenkins: + systemMessage: "GUARDiA ITSM CI/CD 서버 — 무단 접근 금지" + + # ── 보안 설정 ──────────────────────────────────────────────────────────── + securityRealm: + local: + allowsSignup: false + users: + - id: "admin" + password: "${JENKINS_ADMIN_PASSWORD}" + properties: + - mailer: + emailAddress: "admin@agency.go.kr" + - id: "itsm-bot" + password: "${JENKINS_ITSM_BOT_PASSWORD}" + properties: + - mailer: + emailAddress: "itsm-bot@agency.go.kr" + + authorizationStrategy: + roleBased: + roles: + global: + - name: "admin" + description: "Jenkins 전체 관리자" + permissions: + - "Overall/Administer" + assignments: + - "admin" + - name: "pm" + description: "PM — 빌드 트리거 및 조회" + permissions: + - "Overall/Read" + - "Job/Build" + - "Job/Cancel" + - "Job/Read" + - "View/Read" + assignments: [] + - name: "developer" + description: "개발자 — dev/stg 빌드 트리거" + permissions: + - "Overall/Read" + - "Job/Build" + - "Job/Read" + - "Job/Cancel" + - "View/Read" + assignments: + - "itsm-bot" + - name: "viewer" + description: "감사 — 읽기 전용" + permissions: + - "Overall/Read" + - "Job/Read" + - "View/Read" + assignments: [] + + # ── CSRF 보호 ──────────────────────────────────────────────────────────── + crumbIssuer: + standard: + excludeClientIPFromCrumb: false + + # ── 전역 환경변수 ──────────────────────────────────────────────────────── + globalNodeProperties: + - envVars: + env: + - key: "ITSM_URL" + value: "${ITSM_URL}" + - key: "ITSM_CALLBACK_URL" + value: "${ITSM_URL}/api/vibe/callback" + - key: "ARTIFACT_REPO" + value: "/opt/artifacts" + - key: "DEPLOY_BASE_PATH" + value: "/opt/apps" + - key: "SCRIPTS_ROOT" + value: "/var/lib/jenkins/scripts" + # Gitea 설정 (온프레미스 형상관리) + - key: "GITEA_BASE_URL" + value: "${GITEA_BASE_URL:-http://localhost:3000}" + - key: "GITEA_ORG" + value: "${GITEA_ORG:-guardia}" + - key: "GITEA_REPO" + value: "${GITEA_REPO:-GUARDiA}" + - key: "SCM_BRANCH_PROTECT_MAIN" + value: "true" + - key: "DEFAULT_BRANCH" + value: "main" + + # ── 빌드 실행기 설정 ───────────────────────────────────────────────────── + numExecutors: 4 + mode: NORMAL + quietPeriod: 5 + scmCheckoutRetryCount: 2 + + # ── 에이전트 설정 (마스터 전용 또는 SSH 에이전트) ───────────────────────── + nodes: + - permanent: + name: "master" + numExecutors: 2 + remoteFS: "/var/lib/jenkins" + labelString: "master built-in" + mode: NORMAL + retentionStrategy: "always" + + # ── Jenkins URL ─────────────────────────────────────────────────────────── + location: + url: "${JENKINS_URL}" + adminAddress: "Jenkins Admin " + +# ── 도구 설정 ──────────────────────────────────────────────────────────────── +tool: + jdk: + installations: + - name: "JDK17" + home: "/usr/lib/jvm/java-17-openjdk" + - name: "JDK21" + home: "/usr/lib/jvm/java-21-openjdk" + + maven: + installations: + - name: "maven3" + properties: + - installSource: + installers: + - maven: + id: "3.9.6" + + gradle: + installations: + - name: "gradle8" + properties: + - installSource: + installers: + - gradleInstaller: + id: "8.5" + + nodejs: + installations: + - name: "nodejs20" + properties: + - installSource: + installers: + - nodeJSInstaller: + id: "20.11.0" + npmPackagesRefreshHours: 72 + + git: + installations: + - name: "Default" + home: "/usr/bin/git" + +# ── 자격증명 ───────────────────────────────────────────────────────────────── +credentials: + system: + domainCredentials: + - credentials: + # ITSM API 토큰 + - string: + scope: GLOBAL + id: "itsm-api-token" + description: "GUARDiA ITSM API Token" + secret: "${ITSM_API_TOKEN}" + + # SonarQube 토큰 + - string: + scope: GLOBAL + id: "sonar-token" + description: "SonarQube 분석 토큰" + secret: "${SONAR_TOKEN}" + + # Gitea 자격증명 (온프레미스 Git 서버) + - usernamePassword: + scope: GLOBAL + id: "gitea-credentials" + description: "Gitea 저장소 자격증명 (http://localhost:3000)" + username: "${GITEA_ADMIN:-gitadmin}" + password: "${GITEA_ADMIN_PW:-Gitea@guardia!}" + + # Gitea API 토큰 (웹훅 등록 + PR 상태 업데이트) + - string: + scope: GLOBAL + id: "gitea-api-token" + description: "Gitea Personal Access Token" + secret: "${GITEA_API_TOKEN}" + + # Git 자격증명 (HTTPS - 하위 호환) + - usernamePassword: + scope: GLOBAL + id: "git-credentials" + description: "Git 저장소 자격증명 (Gitea 사용 권장)" + username: "${GIT_USERNAME:-gitadmin}" + password: "${GIT_PASSWORD}" + +# ── SonarQube 서버 설정 ─────────────────────────────────────────────────────── +unclassified: + sonarGlobalConfiguration: + buildWrapperEnabled: true + installations: + - name: "sonarqube" + serverUrl: "${SONAR_HOST_URL}" + serverAuthenticationToken: "sonar-token" + mojoVersion: "" + additionalAnalysisProperties: "" + triggers: + envVar: "" + skipScmCause: false + skipUpstreamCause: false + + # ── 이메일 설정 ───────────────────────────────────────────────────────── + mailServer: + smtpHost: "${SMTP_HOST}" + smtpPort: "${SMTP_PORT}" + authentication: + username: "${SMTP_USER}" + password: "${SMTP_PASSWORD}" + + extendedEmailPublisher: + adminRequiredForTemplateTesting: false + allowUnregisteredEnabled: false + charset: "UTF-8" + debugMode: false + defaultBody: | + 빌드 결과: $PROJECT_NAME - $BUILD_STATUS + 빌드 번호: $BUILD_NUMBER + 빌드 URL: $BUILD_URL + defaultContentType: "text/plain" + defaultSubject: "[GUARDiA CI/CD] $PROJECT_NAME - $BUILD_STATUS" + defaultTriggerIds: + - "hudson.plugins.emailext.plugins.trigger.FailureTrigger" + - "hudson.plugins.emailext.plugins.trigger.FixedTrigger" + mailAccount: + smtpHost: "${SMTP_HOST}" + smtpPort: "${SMTP_PORT}" + + # ── Audit Trail ────────────────────────────────────────────────────────── + auditTrail: + logBuildCause: true + loggers: + - logFile: + count: 30 + limit: 100 + log: "/var/log/jenkins/audit.log" + output: "%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS %3$s%n" + + # ── Timestamper ────────────────────────────────────────────────────────── + timestamper: + allPipelines: true + elapsedTimeFormat: "''HH:mm:ss.S' '" + systemTimeFormat: "''HH:mm:ss' '" + + # ── 빌드 보관 기본 설정 ───────────────────────────────────────────────── + defaultFolderConfiguration: + healthMetrics: [] + +# ── 파이프라인 Job 자동 생성 (JCasC + Job DSL) ─────────────────────────────── +jobs: + - script: | + // 파이프라인 폴더 생성 + folder('GUARDiA') { + description('GUARDiA ITSM CI/CD 파이프라인') + displayName('GUARDiA ITSM') + } + + // 기본 배포 파이프라인 (프로젝트별 복사 후 사용) + pipelineJob('GUARDiA/template-java-maven') { + description('Java Maven 프로젝트 표준 파이프라인 템플릿') + definition { + cpsScm { + scm { + git { + remote { + url(System.getenv('GIT_REPO_URL') ?: 'http://git.agency.go.kr/guardia/itsm.git') + credentials('git-credentials') + } + branch('*/main') + } + } + scriptPath('cicd/jenkins/Jenkinsfile.java-maven') + } + } + parameters { + stringParam('ITSM_SESSION_ID', '', 'GUARDiA 바이브 세션 ID') + stringParam('ITSM_SR_ID', '', '연결된 SR ID') + choiceParam('DEPLOY_ENV', ['dev', 'stg', 'prd'], '배포 환경') + stringParam('TARGET_SERVER', '', '배포 대상 서버명') + booleanParam('SKIP_TEST', false, '테스트 건너뜀') + } + } diff --git a/cicd/config/sonarqube.properties b/cicd/config/sonarqube.properties new file mode 100644 index 0000000..20f8e8a --- /dev/null +++ b/cicd/config/sonarqube.properties @@ -0,0 +1,86 @@ +# ============================================================================= +# SonarQube 연동 설정 (sonar-project.properties) +# 각 프로젝트 루트에 복사 후 projectKey, sources 등 수정 +# ============================================================================= + +# ── 프로젝트 기본 정보 ──────────────────────────────────────────────────────── +sonar.projectKey=guardia-itsm +sonar.projectName=GUARDiA ITSM +sonar.projectVersion=1.0.0 + +# ── 소스 경로 ──────────────────────────────────────────────────────────────── +# Java Maven/Gradle: src/main/java +# Node.js: src +sonar.sources=src/main/java +sonar.tests=src/test/java + +# ── 인코딩 ─────────────────────────────────────────────────────────────────── +sonar.sourceEncoding=UTF-8 + +# ── Java 설정 ──────────────────────────────────────────────────────────────── +sonar.java.source=17 +sonar.java.target=17 + +# 바이트코드 경로 (컴파일된 클래스) +sonar.java.binaries=target/classes,build/classes +sonar.java.libraries=target/dependency/*.jar,build/libs/dependency/*.jar + +# ── 테스트 커버리지 ─────────────────────────────────────────────────────────── +# JaCoCo 리포트 경로 +sonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml,build/reports/jacoco/test/jacocoTestReport.xml + +# Surefire 테스트 결과 +sonar.junit.reportPaths=target/surefire-reports,build/test-results/test + +# ── 제외 경로 ──────────────────────────────────────────────────────────────── +sonar.exclusions=\ + **/generated/**,\ + **/*Test.java,\ + **/*Tests.java,\ + **/test/**,\ + **/config/**,\ + **/*Config.java,\ + **/Application.java,\ + **/dto/**,\ + **/entity/**,\ + **/model/** + +sonar.test.exclusions=\ + **/test/** + +sonar.coverage.exclusions=\ + **/dto/**,\ + **/entity/**,\ + **/model/**,\ + **/config/**,\ + **/*Application.java + +# ── 중복 코드 검사 제외 ──────────────────────────────────────────────────────── +sonar.cpd.exclusions=\ + **/dto/**,\ + **/entity/** + +# ── 품질 게이트 설정 (SonarQube 서버에서 관리) ──────────────────────────────── +# 아래 항목은 참고용 — 실제 Quality Gate는 SonarQube UI에서 설정 +# 신규 코드 기준: +# - 커버리지: 80% 이상 +# - 중복 코드: 3% 이하 +# - 보안 취약점: 0개 +# - 버그: 0개 (Critical/Blocker) +# - 코드 냄새: A등급 + +# ── Node.js 전용 설정 (package.json 프로젝트) ────────────────────────────────── +# sonar.sources=src +# sonar.tests=src +# sonar.test.inclusions=**/*.test.js,**/*.spec.js,**/*.test.ts,**/*.spec.ts +# sonar.javascript.lcov.reportPaths=coverage/lcov.info +# sonar.typescript.lcov.reportPaths=coverage/lcov.info + +# ── SonarQube 서버 연결 ──────────────────────────────────────────────────────── +# Jenkins의 withSonarQubeEnv()를 사용할 경우 자동 주입되므로 아래 설정 불필요 +# sonar.host.url=http://sonar.agency.go.kr:9000 +# sonar.login= + +# ── 브랜치 분석 (Developer Edition 이상) ───────────────────────────────────── +# sonar.branch.name=${env.BRANCH_NAME} +# sonar.branch.target=main diff --git a/cicd/docs/01_jenkins_install.md b/cicd/docs/01_jenkins_install.md new file mode 100644 index 0000000..c8e1faf --- /dev/null +++ b/cicd/docs/01_jenkins_install.md @@ -0,0 +1,320 @@ +# Jenkins 설치 가이드 + +> **버전**: Jenkins LTS (2.440.x 기준) +> **대상 OS**: RHEL 8/9, Rocky Linux 8/9, Ubuntu 20.04/22.04 +> **최종 수정**: 2026-05-25 + +--- + +## 1. 사전 요구사항 + +| 구분 | 최소 | 권장 | +|------|------|------| +| CPU | 2 Core | 4 Core | +| RAM | 4 GB | 8 GB | +| Disk | 50 GB | 100 GB | +| Java | JDK 17 | JDK 21 | +| OS | RHEL 8 / Ubuntu 20.04 | RHEL 9 / Ubuntu 22.04 | + +### 필수 패키지 + +```bash +# RHEL/Rocky +sudo dnf install -y git curl wget unzip fontconfig java-17-openjdk-devel + +# Ubuntu +sudo apt-get install -y git curl wget unzip fontconfig openjdk-17-jdk +``` + +--- + +## 2. RHEL 8 / Rocky Linux 8/9 설치 + +### 2-1. Jenkins 저장소 추가 + +```bash +sudo wget -O /etc/yum.repos.d/jenkins.repo \ + https://pkg.jenkins.io/redhat-stable/jenkins.repo + +sudo rpm --import https://pkg.jenkins.io/redhat-stable/jenkins.io-2023.key + +sudo dnf upgrade -y +``` + +### 2-2. Jenkins 설치 + +```bash +sudo dnf install -y jenkins + +sudo systemctl enable jenkins +sudo systemctl start jenkins +sudo systemctl status jenkins +``` + +### 2-3. 방화벽 설정 + +```bash +# firewalld 사용 시 +sudo firewall-cmd --permanent --new-service=jenkins +sudo firewall-cmd --permanent --service=jenkins --set-short=Jenkins +sudo firewall-cmd --permanent --service=jenkins --add-port=8080/tcp +sudo firewall-cmd --reload + +# 또는 직접 포트 개방 +sudo firewall-cmd --permanent --add-port=8080/tcp +sudo firewall-cmd --reload +``` + +### 2-4. SELinux 설정 (RHEL) + +```bash +# Jenkins가 네트워크 바인딩 가능하도록 +sudo setsebool -P httpd_can_network_connect 1 +sudo semanage port -a -t http_port_t -p tcp 8080 2>/dev/null || \ +sudo semanage port -m -t http_port_t -p tcp 8080 +``` + +--- + +## 3. Ubuntu 20.04 / 22.04 설치 + +### 3-1. Jenkins 저장소 추가 + +```bash +sudo wget -O /usr/share/keyrings/jenkins-keyring.asc \ + https://pkg.jenkins.io/debian-stable/jenkins.io-2023.key + +echo "deb [signed-by=/usr/share/keyrings/jenkins-keyring.asc] \ + https://pkg.jenkins.io/debian-stable binary/" | \ + sudo tee /etc/apt/sources.list.d/jenkins.list > /dev/null + +sudo apt-get update +``` + +### 3-2. Jenkins 설치 + +```bash +sudo apt-get install -y jenkins + +sudo systemctl enable jenkins +sudo systemctl start jenkins +sudo systemctl status jenkins +``` + +### 3-3. UFW 방화벽 설정 + +```bash +sudo ufw allow 8080/tcp +sudo ufw reload +sudo ufw status +``` + +--- + +## 4. 초기 설정 + +### 4-1. 초기 관리자 비밀번호 확인 + +```bash +sudo cat /var/lib/jenkins/secrets/initialAdminPassword +``` + +### 4-2. 웹 브라우저 접속 + +``` +http://<서버IP>:8080 +``` + +1. 초기 비밀번호 입력 +2. **"Install suggested plugins"** 선택 (또는 `jenkins_plugins.sh` 스크립트 사용) +3. 관리자 계정 생성 +4. Jenkins URL 설정 (`http://jenkins.agency.go.kr:8080`) + +### 4-3. 자동화 스크립트 + +```bash +# 자동 설치 (비대화형) +sudo bash cicd/scripts/install/jenkins_install.sh + +# 플러그인 자동 설치 +sudo bash cicd/scripts/install/jenkins_plugins.sh +``` + +--- + +## 5. Java 버전 관리 + +Jenkins와 빌드 Java 버전이 다를 경우 도구 관리에서 JDK를 별도 등록한다. + +```bash +# 시스템에 JDK 17, 21 병행 설치 +# RHEL +sudo dnf install -y java-17-openjdk-devel java-21-openjdk-devel +sudo alternatives --list | grep java + +# Ubuntu +sudo apt-get install -y openjdk-17-jdk openjdk-21-jdk +sudo update-alternatives --list java +``` + +Jenkins 관리 → Tools → JDK Installations: +- `JDK17` → `/usr/lib/jvm/java-17-openjdk` +- `JDK21` → `/usr/lib/jvm/java-21-openjdk` + +--- + +## 6. Git 설정 + +```bash +# Jenkins 사용자로 Git 전역 설정 +sudo -u jenkins git config --global user.email "jenkins@agency.go.kr" +sudo -u jenkins git config --global user.name "Jenkins CI" + +# SSH 키 생성 (Git 서버 인증용) +sudo -u jenkins ssh-keygen -t ed25519 -C "jenkins@agency.go.kr" \ + -f /var/lib/jenkins/.ssh/id_ed25519 -N "" + +# 공개키 출력 → Git 서버에 등록 +sudo cat /var/lib/jenkins/.ssh/id_ed25519.pub +``` + +--- + +## 7. Maven / Gradle 도구 설정 + +Jenkins 관리 → Tools: + +### Maven +- Name: `maven3` +- Install automatically: ✓ (Version: 3.9.x) +- 또는 직접 경로: `/usr/share/maven` + +### Gradle +- Name: `gradle8` +- Install automatically: ✓ (Version: 8.x) + +### Node.js (NodeJS Plugin 설치 후) +- Name: `nodejs20` +- Version: 20.x LTS + +--- + +## 8. 역방향 프록시 (Nginx) + +내부망에서 80 포트로 접근하거나 HTTPS를 적용할 경우: + +```nginx +# /etc/nginx/conf.d/jenkins.conf +upstream jenkins { + keepalive 32; + server 127.0.0.1:8080 fail_timeout=0; +} + +server { + listen 80; + server_name jenkins.agency.go.kr; + + location / { + proxy_pass http://jenkins; + proxy_redirect default; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + + proxy_connect_timeout 90; + proxy_send_timeout 90; + proxy_read_timeout 90; + proxy_buffering off; + proxy_request_buffering off; + proxy_max_temp_file_size 0; + } +} +``` + +```bash +sudo nginx -t && sudo systemctl reload nginx +``` + +--- + +## 9. JCasC (Jenkins Configuration as Code) 적용 + +```bash +# JCasC 설정 파일 배포 +sudo mkdir -p /var/lib/jenkins/casc_configs +sudo cp cicd/config/jenkins.yaml /var/lib/jenkins/casc_configs/ +sudo chown -R jenkins:jenkins /var/lib/jenkins/casc_configs/ + +# 환경변수 설정 (jenkins 서비스 systemd override) +sudo mkdir -p /etc/systemd/system/jenkins.service.d/ +cat <<'EOF' | sudo tee /etc/systemd/system/jenkins.service.d/casc.conf +[Service] +Environment="CASC_JENKINS_CONFIG=/var/lib/jenkins/casc_configs" +Environment="JENKINS_URL=http://jenkins.agency.go.kr:8080" +Environment="ITSM_URL=http://itsm.agency.go.kr:8000" +EOF + +sudo systemctl daemon-reload +sudo systemctl restart jenkins +``` + +--- + +## 10. 백업 설정 + +```bash +# 주요 디렉터리 백업 (crontab 등록) +cat <<'EOF' | sudo tee /etc/cron.d/jenkins-backup +# Jenkins 일 2회 백업 +0 2,14 * * * jenkins tar czf /backup/jenkins-$(date +\%Y\%m\%d-\%H\%M).tar.gz \ + /var/lib/jenkins/jobs \ + /var/lib/jenkins/casc_configs \ + /var/lib/jenkins/credentials.xml \ + 2>/dev/null +# 30일 이전 백업 삭제 +30 2 * * * find /backup -name "jenkins-*.tar.gz" -mtime +30 -delete +EOF +``` + +--- + +## 11. 문제 해결 + +### Jenkins 시작 실패 + +```bash +# 로그 확인 +sudo journalctl -u jenkins -n 100 --no-pager +sudo cat /var/log/jenkins/jenkins.log + +# Java 버전 확인 +java -version +sudo -u jenkins java -version +``` + +### 포트 충돌 + +```bash +# 8080 포트 사용 프로세스 확인 +sudo ss -tlnp | grep 8080 + +# 포트 변경 (/etc/sysconfig/jenkins 또는 /etc/default/jenkins) +# RHEL +sudo sed -i 's/^HTTP_PORT=.*/HTTP_PORT=8090/' /etc/sysconfig/jenkins +# Ubuntu +sudo sed -i 's/^HTTP_PORT=.*/HTTP_PORT=8090/' /etc/default/jenkins +``` + +### 플러그인 설치 실패 (오프라인 환경) + +```bash +# 플러그인 .hpi 파일을 직접 업로드 +# Jenkins 관리 → Plugins → Advanced → Deploy Plugin +# 또는 /var/lib/jenkins/plugins/ 에 직접 복사 후 재시작 +sudo cp plugins/*.hpi /var/lib/jenkins/plugins/ +sudo systemctl restart jenkins +``` diff --git a/cicd/docs/02_pipeline_standard.md b/cicd/docs/02_pipeline_standard.md new file mode 100644 index 0000000..199a83e --- /dev/null +++ b/cicd/docs/02_pipeline_standard.md @@ -0,0 +1,357 @@ +# 파이프라인 설계 표준 + +> **버전**: 1.0.0 +> **최종 수정**: 2026-05-25 + +--- + +## 1. 개요 + +GUARDiA ITSM의 모든 CI/CD 파이프라인은 아래 8단계를 표준으로 한다. +각 단계는 독립 스크립트로 분리되어 있으며, 실패 시 즉시 중단하고 ITSM에 결과를 통보한다. + +``` +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ Stage 1 │───▶│ Stage 2 │───▶│ Stage 3 │───▶│ Stage 4 │ +│ Checkout │ │ Build │ │ Test │ │ Archive │ +└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ + │ +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ Stage 8 │◀───│ Stage 7 │◀───│ Stage 6 │◀───│ Stage 5 │ +│ ITSM │ │ Health Check│ │ WAS Restart │ │ Deploy │ +│ Callback │ │ │ │ │ │ │ +└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ +``` + +--- + +## 2. 파이프라인 파라미터 표준 + +모든 파이프라인은 다음 파라미터를 공통으로 사용한다: + +| 파라미터 | 타입 | 기본값 | 설명 | +|---------|------|-------|------| +| `ITSM_SESSION_ID` | String | — | GUARDiA 바이브 세션 ID | +| `ITSM_SR_ID` | String | — | 연결된 SR ID | +| `DEPLOY_ENV` | Choice | `dev` | 배포 환경 (dev/stg/prd) | +| `TARGET_SERVER` | String | — | 배포 대상 서버명 | +| `SKIP_TEST` | Boolean | `false` | 테스트 건너뜀 | +| `ROLLBACK_VERSION` | String | — | 롤백 버전 (빌드 번호) | + +--- + +## 3. 환경별 파이프라인 정책 + +| 브랜치 | 환경 | 필수 단계 | 승인 게이트 | SonarQube | +|--------|------|----------|------------|-----------| +| `feature/*` | dev | Build + Test | 불필요 | 선택 | +| `develop` | dev/stg | Build + Test + Deploy | 불필요 | 선택 | +| `release/*` | stg | Full Pipeline | 불필요 | 필수 | +| `main` | prd | Full Pipeline | **필수** | 필수 | +| `hotfix/*` | prd | Full Pipeline (Fast) | **필수** | 선택 | + +--- + +## 4. 단계별 표준 정의 + +### Stage 1: Checkout + +```groovy +stage('Checkout') { + steps { + checkout([ + $class: 'GitSCM', + branches: [[name: env.BRANCH_NAME ?: 'main']], + extensions: [ + [$class: 'CleanBeforeCheckout'], + [$class: 'CloneOption', depth: 1, shallow: true] + ], + userRemoteConfigs: [[ + url: env.GIT_REPO_URL, + credentialsId: 'git-credentials' + ]] + ]) + } +} +``` + +### Stage 2: Build + +```groovy +stage('Build') { + steps { + script { + sh "bash ${WORKSPACE}/cicd/scripts/pipeline/build.sh" + } + } + post { + failure { + sh "bash ${WORKSPACE}/cicd/scripts/notify/itsm_callback.sh FAILED '빌드 실패'" + } + } +} +``` + +**빌드 산출물 위치**: +- Java Maven: `target/*.jar`, `target/*.war` +- Java Gradle: `build/libs/*.jar`, `build/libs/*.war` +- Node.js: `dist/`, `build/` + +### Stage 3: Test + +```groovy +stage('Test') { + when { + expression { params.SKIP_TEST == false } + } + parallel { + stage('Unit Test') { + steps { + sh "bash ${WORKSPACE}/cicd/scripts/pipeline/test.sh unit" + } + } + stage('SonarQube') { + when { + anyOf { + branch 'release/*' + branch 'main' + } + } + steps { + withSonarQubeEnv('sonarqube') { + sh "bash ${WORKSPACE}/cicd/scripts/pipeline/test.sh sonar" + } + timeout(time: 10, unit: 'MINUTES') { + waitForQualityGate abortPipeline: true + } + } + } + } +} +``` + +### Stage 4: Archive + +```groovy +stage('Archive') { + steps { + archiveArtifacts artifacts: '**/target/*.jar,**/target/*.war,**/build/libs/*.jar', + fingerprint: true, + allowEmptyArchive: false + } +} +``` + +### Stage 5: Deploy + +```groovy +stage('Deploy') { + steps { + withCredentials([sshUserPrivateKey( + credentialsId: "ssh-${params.TARGET_SERVER}", + keyFileVariable: 'SSH_KEY', + usernameVariable: 'SSH_USER' + )]) { + sh """ + bash \${WORKSPACE}/cicd/scripts/pipeline/deploy.sh \ + --server \${params.TARGET_SERVER} \ + --env \${params.DEPLOY_ENV} + """ + } + } +} +``` + +### Stage 6: WAS Restart + +```groovy +stage('WAS Restart') { + steps { + withCredentials([sshUserPrivateKey( + credentialsId: "ssh-${params.TARGET_SERVER}", + keyFileVariable: 'SSH_KEY', + usernameVariable: 'SSH_USER' + )]) { + sh """ + bash \${WORKSPACE}/cicd/scripts/pipeline/was_restart.sh \ + --server \${params.TARGET_SERVER} \ + --was-type \${env.WAS_TYPE ?: 'tomcat'} + """ + } + } + post { + failure { + sh "bash ${WORKSPACE}/cicd/scripts/pipeline/rollback.sh --reason 'WAS 재기동 실패'" + } + } +} +``` + +### Stage 7: Health Check + +```groovy +stage('Health Check') { + steps { + retry(3) { + sh """ + bash \${WORKSPACE}/cicd/scripts/pipeline/health_check.sh \ + --url \${env.HEALTH_CHECK_URL} \ + --timeout 60 + """ + } + } + post { + failure { + sh "bash ${WORKSPACE}/cicd/scripts/pipeline/rollback.sh --reason '헬스체크 실패'" + } + } +} +``` + +### Stage 8: ITSM Callback + +```groovy +stage('ITSM Callback') { + steps { + sh """ + bash \${WORKSPACE}/cicd/scripts/notify/itsm_callback.sh \ + SUCCESS \ + "배포 완료 (빌드 #\${env.BUILD_NUMBER})" + """ + } + post { + failure { + echo 'ITSM 콜백 실패 — 수동 확인 필요' + } + } +} +``` + +--- + +## 5. 공통 post 블록 + +모든 파이프라인에 반드시 포함해야 하는 공통 후처리: + +```groovy +post { + always { + // 워크스페이스 정리 + cleanWs( + cleanWhenAborted: true, + cleanWhenFailure: true, + cleanWhenSuccess: true + ) + } + success { + echo "파이프라인 성공: ${env.JOB_NAME} #${env.BUILD_NUMBER}" + } + failure { + sh """ + bash \${WORKSPACE}/cicd/scripts/notify/itsm_callback.sh \ + FAILED "파이프라인 실패: \${env.STAGE_NAME}" + """ + } + unstable { + sh """ + bash \${WORKSPACE}/cicd/scripts/notify/itsm_callback.sh \ + UNSTABLE "테스트 불안정: \${env.STAGE_NAME}" + """ + } +} +``` + +--- + +## 6. 승인 게이트 (운영 배포) + +`main`, `hotfix/*` 브랜치의 운영 배포 시 승인 게이트를 거친다. + +```groovy +stage('Production Approval') { + when { + anyOf { + branch 'main' + branch 'hotfix/*' + } + } + steps { + script { + // ITSM에 승인 요청 발송 + sh "bash ${WORKSPACE}/cicd/scripts/notify/itsm_callback.sh PENDING_APPROVAL '운영 배포 승인 요청'" + + // 승인 대기 (30분 타임아웃) + timeout(time: 30, unit: 'MINUTES') { + input message: "운영 배포를 승인하시겠습니까?", + ok: "배포 승인", + submitterParameter: "APPROVER" + } + } + } +} +``` + +--- + +## 7. 롤백 정책 + +| 단계 | 자동 롤백 | 조건 | +|------|----------|------| +| WAS Restart | ✓ | WAS 기동 후 60초 내 응답 없음 | +| Health Check | ✓ | HTTP 상태코드 != 200 (3회 재시도 후) | +| 수동 롤백 | — | `!rollback` 봇 명령 또는 Jenkins 수동 트리거 | + +롤백 파이프라인 파라미터: +- `ROLLBACK_VERSION`: 이전 빌드 번호 (빌드 번호 = 아티팩트 버전) +- `ROLLBACK_REASON`: 롤백 사유 (ITSM 등록용) + +--- + +## 8. 파이프라인 타임아웃 표준 + +| 단계 | 타임아웃 | 비고 | +|------|---------|------| +| Checkout | 5분 | | +| Build | 30분 | 대형 프로젝트 60분 허용 | +| Test | 20분 | 단위 테스트 기준 | +| SonarQube | 10분 | Quality Gate 대기 포함 | +| Archive | 5분 | | +| Deploy | 10분 | rsync/SCP 전송 | +| WAS Restart | 5분 | | +| Health Check | 5분 | 3회 재시도 포함 | +| 전체 파이프라인 | 90분 | 초과 시 강제 중단 | + +```groovy +options { + timeout(time: 90, unit: 'MINUTES') + buildDiscarder(logRotator(numToKeepStr: '30')) + disableConcurrentBuilds() +} +``` + +--- + +## 9. 환경변수 표준 + +파이프라인에서 사용하는 환경변수는 Jenkins → Manage Jenkins → System → Global properties 에 등록한다. + +| 변수명 | 용도 | 예시 | +|-------|------|------| +| `ITSM_URL` | GUARDiA ITSM API URL | `http://itsm.agency.go.kr:8000` | +| `ITSM_CALLBACK_URL` | 파이프라인 결과 콜백 URL | `${ITSM_URL}/api/vibe/callback` | +| `SONAR_HOST_URL` | SonarQube 서버 URL | `http://sonar.agency.go.kr:9000` | +| `ARTIFACT_REPO` | 아티팩트 저장소 | `/opt/artifacts` | +| `SCRIPTS_ROOT` | 파이프라인 스크립트 루트 | `/var/lib/jenkins/scripts` | + +--- + +## 10. 빌드 번호 & 아티팩트 버전 + +```groovy +environment { + // 아티팩트 버전 = 브랜치명 + 빌드번호 + 커밋 해시 앞 8자리 + APP_VERSION = "${env.BRANCH_NAME}-${env.BUILD_NUMBER}-${env.GIT_COMMIT[0..7]}" + // 배포 대상 파일명 + ARTIFACT_NAME = "${env.JOB_NAME}-${APP_VERSION}.jar" +} +``` diff --git a/cicd/docs/03_security_config.md b/cicd/docs/03_security_config.md new file mode 100644 index 0000000..822f5f2 --- /dev/null +++ b/cicd/docs/03_security_config.md @@ -0,0 +1,275 @@ +# 보안 설정 가이드 + +> **버전**: 1.0.0 +> **최종 수정**: 2026-05-25 +> **참고 기준**: NIST SP 800-53, 행안부 정보보안 관리체계 + +--- + +## 1. 개요 + +CI/CD 파이프라인에서 취급하는 민감 정보: + +| 종류 | 예시 | 보관 방법 | +|------|------|----------| +| SSH 키 | 배포 서버 SSH 개인키 | Jenkins Credentials (SSH Key) | +| 서비스 계정 토큰 | ITSM API Token | Jenkins Credentials (Secret Text) | +| DB 접속 정보 | SonarQube DB Password | Jenkins Credentials (Username+Password) | +| 배포 서버 정보 | 서버 IP/포트 | Jenkins Credentials / Config File | + +**절대 금지 사항**: +- Jenkinsfile 또는 스크립트에 민감 정보 하드코딩 +- Git 저장소에 자격증명 커밋 +- 빌드 로그에 비밀번호/토큰 출력 + +--- + +## 2. Jenkins Credentials 관리 + +### 2-1. Credentials 종류 및 용도 + +| Credential ID | 종류 | 용도 | +|--------------|------|------| +| `git-credentials` | Username+Password (또는 SSH Key) | Git 소스 체크아웃 | +| `ssh-{서버명}` | SSH Username+Private Key | 배포 서버 SSH 접속 | +| `itsm-api-token` | Secret Text | GUARDiA ITSM API 호출 | +| `sonar-token` | Secret Text | SonarQube 분석 | +| `deploy-key-prd` | SSH Username+Private Key | 운영 서버 배포 전용 | + +### 2-2. SSH Credentials 등록 + +Jenkins 관리 → Credentials → System → Global credentials → Add Credentials: + +``` +Kind: SSH Username with private key +Scope: Global +ID: ssh-app-server-01 +Description: APP-SERVER-01 배포 SSH Key +Username: deploy +Private Key: [Enter directly] → 개인키 내용 붙여넣기 +Passphrase: (있는 경우 입력) +``` + +### 2-3. Secret Text 등록 (API Token) + +``` +Kind: Secret text +Scope: Global +ID: itsm-api-token +Description: GUARDiA ITSM API Token +Secret: <토큰값> +``` + +### 2-4. Jenkinsfile에서 Credentials 사용 + +```groovy +// SSH Key 사용 +withCredentials([sshUserPrivateKey( + credentialsId: 'ssh-app-server-01', + keyFileVariable: 'SSH_KEY', + usernameVariable: 'SSH_USER' +)]) { + sh "ssh -i ${SSH_KEY} ${SSH_USER}@${TARGET_HOST} 'systemctl status tomcat'" +} + +// Secret Text 사용 +withCredentials([string( + credentialsId: 'itsm-api-token', + variable: 'ITSM_TOKEN' +)]) { + sh "curl -H 'Authorization: Bearer ${ITSM_TOKEN}' ${ITSM_URL}/api/health" +} + +// Username+Password 사용 +withCredentials([usernamePassword( + credentialsId: 'sonar-credentials', + usernameVariable: 'SONAR_USER', + passwordVariable: 'SONAR_PASS' +)]) { + sh "mvn sonar:sonar -Dsonar.login=${SONAR_USER} -Dsonar.password=${SONAR_PASS}" +} +``` + +--- + +## 3. RBAC (역할 기반 접근 제어) + +### 3-1. Role Strategy Plugin 설정 + +Jenkins 관리 → Security → Authorization → Role-Based Strategy + +#### Global Roles + +| Role | 권한 | 대상 | +|------|------|------| +| `admin` | 전체 | Jenkins 관리자 | +| `pm` | Job Read/Build, View | PM/프로젝트 관리자 | +| `developer` | Job Read/Build (dev 한정), View | 개발자 | +| `viewer` | Job Read, View | 감사/보안 담당 | + +#### Project Roles (정규식 매핑) + +| Role | 대상 Job 패턴 | 권한 | +|------|-------------|------| +| `dev-deployer` | `.*-dev$` | Build, Cancel, Read | +| `prd-deployer` | `.*-prd$` | Build, Cancel, Read | +| `rollback-only` | `.*-rollback$` | Build, Read | + +### 3-2. 운영 배포 2인 승인 규칙 + +`main`, `hotfix/*` 브랜치 배포는 반드시 2명이 승인: + +```groovy +stage('Production Approval') { + steps { + script { + def approvers = [] + def required = 2 + + timeout(time: 60, unit: 'MINUTES') { + for (int i = 0; i < required; i++) { + def approval = input( + message: "운영 배포 ${i+1}/${required}차 승인", + ok: "승인", + submitterParameter: "APPROVER_${i+1}" + ) + approvers << approval + echo "승인자 ${i+1}: ${approval}" + } + } + env.APPROVERS = approvers.join(', ') + } + } +} +``` + +--- + +## 4. 네트워크 보안 + +### 4-1. Jenkins 접근 IP 제한 + +```nginx +# Nginx에서 허용 IP 제한 +location / { + allow 10.0.0.0/8; # 내부망 + allow 192.168.0.0/16; # 내부망 + deny all; + proxy_pass http://jenkins; +} +``` + +```bash +# firewalld에서 특정 IP만 허용 +sudo firewall-cmd --permanent --add-rich-rule=' + rule family="ipv4" + source address="10.0.0.0/8" + port protocol="tcp" port="8080" + accept' +sudo firewall-cmd --reload +``` + +### 4-2. 배포 서버 SSH 보안 + +```bash +# 배포 서버 /etc/ssh/sshd_config 설정 +# Jenkins 전용 deploy 계정만 허용 +AllowUsers deploy jenkins-agent +PermitRootLogin no +PasswordAuthentication no +PubkeyAuthentication yes +AuthorizedKeysFile .ssh/authorized_keys + +# deploy 계정 SSH sudo 제한 (특정 명령만) +# /etc/sudoers.d/jenkins-deploy +deploy ALL=(root) NOPASSWD: /bin/systemctl restart tomcat9, \ + /bin/systemctl restart nginx, \ + /opt/jeus/bin/stopServer, \ + /opt/jeus/bin/startServer +``` + +--- + +## 5. 빌드 로그 보안 + +### 5-1. 민감 정보 마스킹 + +`Mask Passwords` 플러그인 사용: + +```groovy +options { + maskPasswords() +} +``` + +Jenkins 관리 → System → Mask Passwords: +- Global Passwords에 패턴 등록: `PASSWORD`, `TOKEN`, `SECRET`, `KEY` + +### 5-2. 로그 보존 정책 + +```groovy +options { + buildDiscarder(logRotator( + numToKeepStr: '30', // 30개 빌드 보관 + artifactNumToKeepStr: '10', // 아티팩트 10개 보관 + daysToKeepStr: '90' // 90일 이전 자동 삭제 + )) +} +``` + +--- + +## 6. 아티팩트 보안 + +### 6-1. 아티팩트 체크섬 검증 + +```bash +# 빌드 후 SHA256 체크섬 생성 +sha256sum target/*.jar > target/checksums.sha256 + +# 배포 전 검증 +sha256sum -c checksums.sha256 +``` + +### 6-2. 아티팩트 접근 제한 + +```bash +# 아티팩트 디렉터리 권한 설정 +sudo mkdir -p /opt/artifacts +sudo chown jenkins:jenkins /opt/artifacts +sudo chmod 750 /opt/artifacts +``` + +--- + +## 7. 감사 로그 (Audit Trail) + +`Audit Trail Plugin` 설치 후: + +Jenkins 관리 → System → Audit Trail: +- Log Location: `/var/log/jenkins/audit.log` +- Log File Size: 100 MB +- Log File Count: 30 + +기록 항목: +- 로그인/로그아웃 +- 자격증명 접근 +- 잡 빌드 시작/중단 +- 설정 변경 + +--- + +## 8. Jenkins 보안 설정 체크리스트 + +| 항목 | 상태 | 비고 | +|------|------|------| +| CSRF Protection 활성화 | ✓ | 기본 활성화 | +| 익명 사용자 접근 차단 | ✓ | Authorization: Role-Based | +| 빌드 에이전트 인증 | ✓ | SSH or JNLP with secret | +| Credentials 암호화 | ✓ | Jenkins master key 사용 | +| 보안 업데이트 정기 적용 | 주 1회 | 매주 일요일 자동 업데이트 확인 | +| 사용하지 않는 플러그인 제거 | ✓ | 분기별 점검 | +| HTTPS 적용 | 내부망 선택 | 외부 연동 시 필수 | +| 빌드 로그 민감정보 마스킹 | ✓ | Mask Passwords Plugin | +| 운영 배포 2인 승인 | ✓ | input() step | +| 감사 로그 활성화 | ✓ | Audit Trail Plugin | diff --git a/cicd/docs/04_itsm_integration.md b/cicd/docs/04_itsm_integration.md new file mode 100644 index 0000000..8ce7a5b --- /dev/null +++ b/cicd/docs/04_itsm_integration.md @@ -0,0 +1,292 @@ +# GUARDiA ITSM 연동 가이드 + +> **버전**: 1.0.0 +> **최종 수정**: 2026-05-25 + +--- + +## 1. 연동 아키텍처 + +``` +GUARDiA Messenger + │ !build + ▼ +GUARDiA ITSM API + │ POST /api/vibe/{id}/build + │ (Jenkins REST API 호출) + ▼ +Jenkins Server + │ 파이프라인 실행 + │ + ├── Stage 1~7 실행 + │ + └── Stage 8: ITSM Callback + │ POST /api/vibe/callback + ▼ + GUARDiA ITSM + │ SR 상태 = COMPLETED + │ + ├── 고객 이메일 발송 + └── 메신저 결과 통보 +``` + +--- + +## 2. Jenkins → ITSM Callback API + +### 2-1. 엔드포인트 + +``` +POST /api/vibe/callback +Content-Type: application/json +Authorization: Bearer +``` + +### 2-2. 요청 바디 + +```json +{ + "session_id": "42", + "sr_id": "SR-2026-001234", + "status": "COMPLETED", + "stage": "ITSM Callback", + "message": "배포 완료 (빌드 #15)", + "build_number": "15", + "build_url": "http://jenkins.agency.go.kr:8080/job/my-app/15/", + "deploy_env": "prd", + "timestamp": "2026-05-25T14:30:00Z", + "logs": { + "build": "빌드 성공 (45초)", + "test": "테스트 통과 (120/120)", + "deploy": "배포 완료 → /opt/apps/my-app", + "health_check": "HTTP 200 OK" + } +} +``` + +### 2-3. 응답 + +```json +// 성공 +{ + "ok": true, + "sr_id": "SR-2026-001234", + "sr_status": "COMPLETED", + "notified": ["email", "messenger"] +} + +// 실패 +{ + "ok": false, + "error": "세션을 찾을 수 없습니다." +} +``` + +### 2-4. 상태값 목록 + +| status | 설명 | SR 상태 변경 | +|--------|------|------------| +| `BUILDING` | 빌드 진행 중 | IN_PROGRESS | +| `TESTING` | 테스트 진행 중 | IN_PROGRESS | +| `DEPLOYING` | 배포 진행 중 | IN_PROGRESS | +| `COMPLETED` | 배포 완료 | COMPLETED | +| `FAILED` | 파이프라인 실패 | FAILED | +| `UNSTABLE` | 테스트 불안정 | IN_PROGRESS | +| `PENDING_APPROVAL` | 운영 배포 승인 대기 | PENDING | +| `ROLLED_BACK` | 롤백 완료 | IN_PROGRESS | + +--- + +## 3. ITSM → Jenkins 빌드 트리거 API + +### 3-1. Jenkins REST API 호출 형식 + +```bash +# 파라미터 있는 빌드 트리거 +curl -X POST \ + "http://jenkins.agency.go.kr:8080/job/${JOB_NAME}/buildWithParameters" \ + --user "itsm-bot:${JENKINS_TOKEN}" \ + --data-urlencode "ITSM_SESSION_ID=${SESSION_ID}" \ + --data-urlencode "ITSM_SR_ID=${SR_ID}" \ + --data-urlencode "DEPLOY_ENV=${DEPLOY_ENV}" \ + --data-urlencode "TARGET_SERVER=${TARGET_SERVER}" +``` + +### 3-2. GUARDiA ITSM core/cicd.py + +```python +import httpx + +JENKINS_URL = os.getenv("JENKINS_URL", "http://jenkins.agency.go.kr:8080") +JENKINS_USER = os.getenv("JENKINS_USER", "itsm-bot") +JENKINS_TOKEN = os.getenv("JENKINS_TOKEN", "") + +async def trigger_pipeline( + job_name: str, + session_id: int, + sr_id: str, + deploy_env: str = "dev", + target_server: str = "", +) -> dict: + url = f"{JENKINS_URL}/job/{job_name}/buildWithParameters" + auth = (JENKINS_USER, JENKINS_TOKEN) + params = { + "ITSM_SESSION_ID": str(session_id), + "ITSM_SR_ID": sr_id, + "DEPLOY_ENV": deploy_env, + "TARGET_SERVER": target_server, + } + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.post(url, auth=auth, params=params) + resp.raise_for_status() + # 큐 번호는 Location 헤더에서 추출 + location = resp.headers.get("Location", "") + queue_id = location.rstrip("/").split("/")[-1] if location else None + return {"queued": True, "queue_id": queue_id} +``` + +--- + +## 4. 환경 변수 설정 + +### 4-1. GUARDiA ITSM (.env) + +```bash +# Jenkins 연동 +JENKINS_URL=http://jenkins.agency.go.kr:8080 +JENKINS_USER=itsm-bot +JENKINS_TOKEN= +JENKINS_CALLBACK_URL=http://itsm.agency.go.kr:8000/api/vibe/callback + +# 기본 Jenkins Job 이름 (프로젝트별로 재정의 가능) +JENKINS_DEFAULT_JOB=guardia-default-pipeline +``` + +### 4-2. Jenkins (JCasC or Manage Jenkins) + +```yaml +# jenkins.yaml 참고 +globalNodeProperties: + - envVars: + env: + - key: ITSM_URL + value: "http://itsm.agency.go.kr:8000" + - key: ITSM_CALLBACK_URL + value: "http://itsm.agency.go.kr:8000/api/vibe/callback" +``` + +### 4-3. Jenkins Credentials 등록 (ITSM Token) + +``` +Kind: Secret text +ID: itsm-api-token +Secret: +``` + +--- + +## 5. 봇 명령어 ↔ Jenkins 파이프라인 매핑 + +| 봇 명령어 | Jenkins Job | 설명 | +|---------|------------|------| +| `!build ` | `{project}-build` | 빌드 + 테스트만 | +| `!deploy dev` | `{project}-deploy` | 개발 서버 배포 | +| `!deploy prd` | `{project}-deploy` | 운영 서버 배포 (승인 필요) | +| `!rollback <버전>` | `{project}-rollback` | 이전 버전 롤백 | + +--- + +## 6. SR 상태 전환 흐름 + +``` +OPEN + │ !vibe 명령 + ▼ +IN_PROGRESS (바이브 코딩) + │ !build 명령 + ▼ +IN_PROGRESS (빌드/테스트) + │ Jenkins 빌드 완료 + ▼ +IN_PROGRESS (배포) + │ Jenkins 배포 완료 + 헬스체크 통과 + ▼ +COMPLETED + │ 자동 이메일 + 메신저 발송 +``` + +실패 시: +``` +IN_PROGRESS + │ Jenkins FAILED 콜백 + ▼ +FAILED + │ 롤백 자동 실행 (설정 시) + ▼ +IN_PROGRESS (수동 조치 대기) +``` + +--- + +## 7. 알림 포맷 + +### 7-1. 배포 완료 메신저 알림 + +``` +✅ [SR-2026-001234] 배포 완료 + +📌 프로젝트: my-app +🌿 브랜치: main +🏗️ 빌드: #15 +🌐 환경: 운영 (prd) +⏱️ 소요 시간: 4분 23초 + +✔️ 빌드: 성공 +✔️ 테스트: 120/120 통과 +✔️ 배포: /opt/apps/my-app +✔️ 헬스체크: HTTP 200 OK + +승인자: 홍길동, 김철수 +담당자: 이영희 +``` + +### 7-2. 배포 실패 메신저 알림 + +``` +❌ [SR-2026-001234] 배포 실패 + +📌 프로젝트: my-app +🏗️ 빌드: #15 +💥 실패 단계: Health Check + +원인: 서비스 응답 없음 (3회 재시도 후 타임아웃) +🔄 자동 롤백 완료 → 빌드 #14로 복원 + +조치 필요: http://jenkins.agency.go.kr:8080/job/my-app/15/ +``` + +--- + +## 8. 연동 테스트 + +```bash +# 1. ITSM → Jenkins 트리거 테스트 +curl -X POST \ + "http://jenkins.agency.go.kr:8080/job/test-pipeline/buildWithParameters" \ + --user "itsm-bot:" \ + --data-urlencode "ITSM_SESSION_ID=1" \ + --data-urlencode "ITSM_SR_ID=SR-TEST-001" \ + --data-urlencode "DEPLOY_ENV=dev" + +# 2. Jenkins → ITSM 콜백 테스트 +curl -X POST "http://itsm.agency.go.kr:8000/api/vibe/callback" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "session_id": "1", + "sr_id": "SR-TEST-001", + "status": "COMPLETED", + "message": "연동 테스트 성공", + "build_number": "1" + }' +``` diff --git a/cicd/jenkins/Jenkinsfile.deploy-only b/cicd/jenkins/Jenkinsfile.deploy-only new file mode 100644 index 0000000..81efaa6 --- /dev/null +++ b/cicd/jenkins/Jenkinsfile.deploy-only @@ -0,0 +1,135 @@ +// GUARDiA ITSM — 배포 전용 파이프라인 (빌드 없음) +// 기존 아티팩트를 지정하여 배포만 수행한다. + +pipeline { + agent any + + parameters { + string(name: 'ITSM_SESSION_ID', defaultValue: '', description: 'GUARDiA 바이브 세션 ID') + string(name: 'ITSM_SR_ID', defaultValue: '', description: '연결된 SR ID') + choice(name: 'DEPLOY_ENV', choices: ['dev', 'stg', 'prd'], description: '배포 환경') + string(name: 'TARGET_SERVER', defaultValue: '', description: '배포 대상 서버명') + string(name: 'ARTIFACT_BUILD', defaultValue: '', description: '아티팩트 소스 빌드 번호 (비워두면 lastSuccessfulBuild)') + string(name: 'ARTIFACT_JOB', defaultValue: '', description: '아티팩트 소스 Job 이름') + string(name: 'WAS_TYPE', defaultValue: 'tomcat', description: 'WAS 종류 (tomcat/jboss/jeus/nodejs)') + } + + environment { + SCRIPTS_ROOT = "${WORKSPACE}/cicd/scripts/pipeline" + NOTIFY_SCRIPT = "${WORKSPACE}/cicd/scripts/notify/itsm_callback.sh" + } + + options { + timeout(time: 30, unit: 'MINUTES') + buildDiscarder(logRotator(numToKeepStr: '30')) + } + + stages { + stage('Resolve Artifact') { + steps { + script { + def buildNum = params.ARTIFACT_BUILD ?: 'lastSuccessfulBuild' + def jobName = params.ARTIFACT_JOB ?: env.JOB_NAME + + echo "아티팩트 소스: ${jobName} #${buildNum}" + + // Jenkins 아티팩트 복사 + copyArtifacts( + projectName: jobName, + selector: buildNum == 'lastSuccessfulBuild' + ? lastSuccessful() + : specific(buildNum), + target: 'artifacts/', + flatten: true, + optional: false + ) + + def files = sh(script: "ls artifacts/", returnStdout: true).trim() + echo "다운로드된 아티팩트:\n${files}" + } + } + } + + stage('Production Approval') { + when { + expression { return params.DEPLOY_ENV == 'prd' } + } + steps { + sh "bash ${NOTIFY_SCRIPT} PENDING_APPROVAL '운영 배포 승인 대기'" + timeout(time: 30, unit: 'MINUTES') { + input message: "운영 배포를 승인하시겠습니까?\n소스: ${params.ARTIFACT_JOB} #${params.ARTIFACT_BUILD}", + ok: "승인" + } + } + } + + stage('Deploy') { + steps { + sh "bash ${NOTIFY_SCRIPT} DEPLOYING '배포 시작'" + withCredentials([sshUserPrivateKey( + credentialsId: "ssh-${params.TARGET_SERVER}", + keyFileVariable: 'SSH_KEY', + usernameVariable: 'SSH_USER' + )]) { + sh """ + ARTIFACT=\$(ls artifacts/*.jar artifacts/*.war artifacts/*.tar.gz 2>/dev/null | head -1) + bash ${SCRIPTS_ROOT}/deploy.sh \ + --server "${params.TARGET_SERVER}" \ + --env "${params.DEPLOY_ENV}" \ + --ssh-key "\${SSH_KEY}" \ + --ssh-user "\${SSH_USER}" \ + --artifact "\${ARTIFACT}" + """ + } + } + } + + stage('WAS Restart') { + steps { + withCredentials([sshUserPrivateKey( + credentialsId: "ssh-${params.TARGET_SERVER}", + keyFileVariable: 'SSH_KEY', + usernameVariable: 'SSH_USER' + )]) { + sh """ + bash ${SCRIPTS_ROOT}/was_restart.sh \ + --server "${params.TARGET_SERVER}" \ + --ssh-key "${SSH_KEY}" \ + --ssh-user "${SSH_USER}" \ + --was-type "${params.WAS_TYPE}" + """ + } + } + } + + stage('Health Check') { + steps { + retry(3) { + sh """ + bash ${SCRIPTS_ROOT}/health_check.sh \ + --url "${env.HEALTH_CHECK_URL ?: 'http://localhost:8080/health'}" \ + --timeout 60 + """ + } + } + post { + failure { + sh "bash ${SCRIPTS_ROOT}/rollback.sh --server '${params.TARGET_SERVER}' --reason '헬스체크 실패' || true" + } + } + } + + stage('ITSM Callback') { + steps { + sh "bash ${NOTIFY_SCRIPT} COMPLETED '배포 전용 파이프라인 완료'" + } + } + } + + post { + always { cleanWs() } + failure { + sh "bash ${NOTIFY_SCRIPT} FAILED '배포 실패' || true" + } + } +} diff --git a/cicd/jenkins/Jenkinsfile.java-gradle b/cicd/jenkins/Jenkinsfile.java-gradle new file mode 100644 index 0000000..d2385cc --- /dev/null +++ b/cicd/jenkins/Jenkinsfile.java-gradle @@ -0,0 +1,201 @@ +// GUARDiA ITSM — Java Gradle 표준 파이프라인 + +pipeline { + agent any + + tools { + gradle 'gradle8' + jdk 'JDK17' + } + + parameters { + string(name: 'ITSM_SESSION_ID', defaultValue: '', description: 'GUARDiA 바이브 세션 ID') + string(name: 'ITSM_SR_ID', defaultValue: '', description: '연결된 SR ID') + choice(name: 'DEPLOY_ENV', choices: ['dev', 'stg', 'prd'], description: '배포 환경') + string(name: 'TARGET_SERVER', defaultValue: '', description: '배포 대상 서버명') + booleanParam(name: 'SKIP_TEST', defaultValue: false, description: '테스트 건너뜀') + } + + environment { + APP_VERSION = "${env.BRANCH_NAME ?: 'unknown'}-${env.BUILD_NUMBER}" + SCRIPTS_ROOT = "${WORKSPACE}/cicd/scripts/pipeline" + NOTIFY_SCRIPT = "${WORKSPACE}/cicd/scripts/notify/itsm_callback.sh" + GRADLE_OPTS = "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true" + } + + options { + timeout(time: 90, unit: 'MINUTES') + buildDiscarder(logRotator(numToKeepStr: '30', artifactNumToKeepStr: '10')) + disableConcurrentBuilds() + maskPasswords() + } + + stages { + stage('Checkout') { + steps { + checkout scm + } + } + + stage('Build') { + steps { + sh "bash ${NOTIFY_SCRIPT} BUILDING '빌드 시작'" + sh """ + ./gradlew clean build -x test \ + -Pversion=${APP_VERSION} \ + --no-daemon --parallel --stacktrace + """ + } + post { + failure { + sh "bash ${NOTIFY_SCRIPT} FAILED '빌드 실패' || true" + } + } + } + + stage('Test') { + when { + expression { return !params.SKIP_TEST } + } + parallel { + stage('Unit Test') { + steps { + sh """ + ./gradlew test \ + -Pversion=${APP_VERSION} \ + --no-daemon + """ + } + post { + always { + junit testResults: '**/build/test-results/test/*.xml', + allowEmptyResults: true + publishHTML(target: [ + allowMissing: true, + alwaysLinkToLastBuild: true, + reportDir: 'build/reports/tests/test', + reportFiles: 'index.html', + reportName: 'Test Report' + ]) + } + } + } + stage('SonarQube') { + when { + anyOf { branch 'release/*'; branch 'main' } + } + steps { + withSonarQubeEnv('sonarqube') { + sh """ + ./gradlew sonar \ + -Dsonar.projectVersion=${APP_VERSION} \ + --no-daemon + """ + } + timeout(time: 10, unit: 'MINUTES') { + waitForQualityGate abortPipeline: true + } + } + } + } + } + + stage('Archive') { + steps { + archiveArtifacts( + artifacts: 'build/libs/*.jar,build/libs/*.war', + fingerprint: true, + allowEmptyArchive: false + ) + } + } + + stage('Production Approval') { + when { + anyOf { branch 'main'; branch 'hotfix/*' } + expression { return params.DEPLOY_ENV == 'prd' } + } + steps { + sh "bash ${NOTIFY_SCRIPT} PENDING_APPROVAL '운영 배포 승인 대기'" + timeout(time: 30, unit: 'MINUTES') { + input message: "운영 배포를 승인하시겠습니까?", ok: "승인" + } + } + } + + stage('Deploy') { + steps { + sh "bash ${NOTIFY_SCRIPT} DEPLOYING '배포 시작'" + withCredentials([sshUserPrivateKey( + credentialsId: "ssh-${params.TARGET_SERVER}", + keyFileVariable: 'SSH_KEY', + usernameVariable: 'SSH_USER' + )]) { + sh """ + bash ${SCRIPTS_ROOT}/deploy.sh \ + --server "${params.TARGET_SERVER}" \ + --env "${params.DEPLOY_ENV}" \ + --ssh-key "${SSH_KEY}" \ + --ssh-user "${SSH_USER}" \ + --artifact "\$(ls build/libs/*.jar build/libs/*.war 2>/dev/null | grep -v plain | head -1)" + """ + } + } + } + + stage('WAS Restart') { + steps { + withCredentials([sshUserPrivateKey( + credentialsId: "ssh-${params.TARGET_SERVER}", + keyFileVariable: 'SSH_KEY', + usernameVariable: 'SSH_USER' + )]) { + sh """ + bash ${SCRIPTS_ROOT}/was_restart.sh \ + --server "${params.TARGET_SERVER}" \ + --ssh-key "${SSH_KEY}" \ + --ssh-user "${SSH_USER}" \ + --was-type "${env.WAS_TYPE ?: 'tomcat'}" + """ + } + } + post { + failure { + sh "bash ${SCRIPTS_ROOT}/rollback.sh --server '${params.TARGET_SERVER}' --reason 'WAS 재기동 실패' || true" + sh "bash ${NOTIFY_SCRIPT} FAILED 'WAS 재기동 실패' || true" + } + } + } + + stage('Health Check') { + steps { + retry(3) { + sh """ + bash ${SCRIPTS_ROOT}/health_check.sh \ + --url "${env.HEALTH_CHECK_URL ?: 'http://localhost:8080/health'}" \ + --timeout 60 + """ + } + } + post { + failure { + sh "bash ${SCRIPTS_ROOT}/rollback.sh --server '${params.TARGET_SERVER}' --reason '헬스체크 실패' || true" + sh "bash ${NOTIFY_SCRIPT} ROLLED_BACK '헬스체크 실패 — 자동 롤백' || true" + } + } + } + + stage('ITSM Callback') { + steps { + sh "bash ${NOTIFY_SCRIPT} COMPLETED '배포 완료 (빌드 #${env.BUILD_NUMBER})'" + } + } + } + + post { + always { cleanWs() } + failure { + sh "bash ${NOTIFY_SCRIPT} FAILED '파이프라인 실패: ${env.JOB_NAME} #${env.BUILD_NUMBER}' || true" + } + } +} diff --git a/cicd/jenkins/Jenkinsfile.java-maven b/cicd/jenkins/Jenkinsfile.java-maven new file mode 100644 index 0000000..3280eca --- /dev/null +++ b/cicd/jenkins/Jenkinsfile.java-maven @@ -0,0 +1,256 @@ +// GUARDiA ITSM — Java Maven 표준 파이프라인 +// 사용법: Jenkins → New Item → Pipeline → SCM → 이 파일 지정 + +pipeline { + agent any + + tools { + maven 'maven3' + jdk 'JDK17' + } + + parameters { + string(name: 'ITSM_SESSION_ID', defaultValue: '', description: 'GUARDiA 바이브 세션 ID') + string(name: 'ITSM_SR_ID', defaultValue: '', description: '연결된 SR ID') + choice(name: 'DEPLOY_ENV', choices: ['dev', 'stg', 'prd'], description: '배포 환경') + string(name: 'TARGET_SERVER', defaultValue: '', description: '배포 대상 서버명') + booleanParam(name: 'SKIP_TEST', defaultValue: false, description: '테스트 건너뜀') + } + + environment { + APP_VERSION = "${env.BRANCH_NAME ?: 'unknown'}-${env.BUILD_NUMBER}" + SCRIPTS_ROOT = "${WORKSPACE}/cicd/scripts/pipeline" + NOTIFY_SCRIPT = "${WORKSPACE}/cicd/scripts/notify/itsm_callback.sh" + ITSM_URL = "${env.ITSM_URL ?: 'http://itsm.agency.go.kr:8000'}" + } + + options { + timeout(time: 90, unit: 'MINUTES') + buildDiscarder(logRotator(numToKeepStr: '30', artifactNumToKeepStr: '10')) + disableConcurrentBuilds() + maskPasswords() + } + + stages { + stage('Checkout') { + steps { + // Gitea(온프레미스) → GitHub 순서로 SCM 폴백 + script { + def giteaUrl = "${env.GITEA_BASE_URL ?: 'http://localhost:3000'}/${env.GITEA_ORG ?: 'guardia'}/${env.GIT_REPO_NAME ?: 'GUARDiA'}.git" + try { + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${env.BRANCH_NAME ?: 'main'}"]], + userRemoteConfigs: [[ + url: giteaUrl, + credentialsId: 'gitea-credentials' + ]], + extensions: [[$class: 'CloneOption', depth: 1, noTags: false, shallow: true]] + ]) + echo "✅ Gitea checkout: ${env.GIT_COMMIT?.take(8) ?: 'N/A'}" + } catch (Exception e) { + echo "⚠️ Gitea checkout 실패 — SCM 기본값 사용: ${e.message}" + checkout scm + } + } + } + } + + stage('Build') { + steps { + withCredentials([string(credentialsId: 'itsm-api-token', variable: 'ITSM_TOKEN')]) { + sh "bash ${NOTIFY_SCRIPT} BUILDING '빌드 시작'" + } + sh """ + mvn clean package -DskipTests=true \ + -Dapp.version=${APP_VERSION} \ + --batch-mode --no-transfer-progress + """ + echo "✅ 빌드 완료" + } + post { + failure { + sh "bash ${NOTIFY_SCRIPT} FAILED '빌드 실패 (Stage: Build)' || true" + } + } + } + + stage('Test') { + when { + expression { return !params.SKIP_TEST } + } + parallel { + stage('Unit Test') { + steps { + sh """ + mvn test \ + --batch-mode --no-transfer-progress \ + -Dapp.version=${APP_VERSION} + """ + } + post { + always { + junit testResults: '**/target/surefire-reports/*.xml', + allowEmptyResults: true + } + } + } + stage('SonarQube') { + when { + anyOf { + branch 'release/*' + branch 'main' + } + } + steps { + withSonarQubeEnv('sonarqube') { + sh """ + mvn sonar:sonar \ + -Dsonar.projectVersion=${APP_VERSION} \ + --batch-mode --no-transfer-progress + """ + } + timeout(time: 10, unit: 'MINUTES') { + waitForQualityGate abortPipeline: true + } + } + } + } + post { + failure { + sh "bash ${NOTIFY_SCRIPT} FAILED '테스트 실패 (Stage: Test)' || true" + } + } + } + + stage('Archive') { + steps { + archiveArtifacts( + artifacts: 'target/*.jar,target/*.war', + fingerprint: true, + allowEmptyArchive: false + ) + echo "✅ 아티팩트 저장 완료" + } + } + + stage('Production Approval') { + when { + anyOf { + branch 'main' + branch 'hotfix/*' + } + expression { return params.DEPLOY_ENV == 'prd' } + } + steps { + withCredentials([string(credentialsId: 'itsm-api-token', variable: 'ITSM_TOKEN')]) { + sh "bash ${NOTIFY_SCRIPT} PENDING_APPROVAL '운영 배포 승인 대기 중'" + } + timeout(time: 30, unit: 'MINUTES') { + input message: "운영(prd) 배포를 승인하시겠습니까?", + ok: "배포 승인", + submitterParameter: "APPROVER" + } + } + } + + stage('Deploy') { + steps { + withCredentials([string(credentialsId: 'itsm-api-token', variable: 'ITSM_TOKEN')]) { + sh "bash ${NOTIFY_SCRIPT} DEPLOYING '배포 시작'" + } + withCredentials([sshUserPrivateKey( + credentialsId: "ssh-${params.TARGET_SERVER}", + keyFileVariable: 'SSH_KEY', + usernameVariable: 'SSH_USER' + )]) { + sh """ + bash ${SCRIPTS_ROOT}/deploy.sh \ + --server "${params.TARGET_SERVER}" \ + --env "${params.DEPLOY_ENV}" \ + --ssh-key "${SSH_KEY}" \ + --ssh-user "${SSH_USER}" \ + --artifact "\$(ls target/*.jar target/*.war 2>/dev/null | head -1)" + """ + } + } + post { + failure { + sh "bash ${NOTIFY_SCRIPT} FAILED '배포 실패 (Stage: Deploy)' || true" + } + } + } + + stage('WAS Restart') { + steps { + withCredentials([sshUserPrivateKey( + credentialsId: "ssh-${params.TARGET_SERVER}", + keyFileVariable: 'SSH_KEY', + usernameVariable: 'SSH_USER' + )]) { + sh """ + bash ${SCRIPTS_ROOT}/was_restart.sh \ + --server "${params.TARGET_SERVER}" \ + --ssh-key "${SSH_KEY}" \ + --ssh-user "${SSH_USER}" \ + --was-type "${env.WAS_TYPE ?: 'tomcat'}" + """ + } + } + post { + failure { + sh """ + bash ${SCRIPTS_ROOT}/rollback.sh \ + --server "${params.TARGET_SERVER}" \ + --reason "WAS 재기동 실패" || true + """ + sh "bash ${NOTIFY_SCRIPT} FAILED 'WAS 재기동 실패 — 롤백 시도' || true" + } + } + } + + stage('Health Check') { + steps { + retry(3) { + sh """ + bash ${SCRIPTS_ROOT}/health_check.sh \ + --url "${env.HEALTH_CHECK_URL ?: 'http://localhost:8080/health'}" \ + --timeout 60 \ + --retries 3 + """ + } + } + post { + failure { + sh """ + bash ${SCRIPTS_ROOT}/rollback.sh \ + --server "${params.TARGET_SERVER}" \ + --reason "헬스체크 실패" || true + """ + sh "bash ${NOTIFY_SCRIPT} ROLLED_BACK '헬스체크 실패 — 자동 롤백 완료' || true" + } + } + } + + stage('ITSM Callback') { + steps { + sh """ + bash ${NOTIFY_SCRIPT} COMPLETED \ + "배포 완료 (빌드 #${env.BUILD_NUMBER}, ${params.DEPLOY_ENV})" + """ + } + } + } + + post { + always { + cleanWs(cleanWhenAborted: true, cleanWhenFailure: true, cleanWhenSuccess: true) + } + success { + echo "🎉 파이프라인 성공: ${env.JOB_NAME} #${env.BUILD_NUMBER}" + } + failure { + sh "bash ${NOTIFY_SCRIPT} FAILED '파이프라인 실패: ${env.JOB_NAME} #${env.BUILD_NUMBER}' || true" + } + } +} diff --git a/cicd/jenkins/Jenkinsfile.nodejs b/cicd/jenkins/Jenkinsfile.nodejs new file mode 100644 index 0000000..c6821e3 --- /dev/null +++ b/cicd/jenkins/Jenkinsfile.nodejs @@ -0,0 +1,171 @@ +// GUARDiA ITSM — Node.js 표준 파이프라인 + +pipeline { + agent any + + tools { + nodejs 'nodejs20' + } + + parameters { + string(name: 'ITSM_SESSION_ID', defaultValue: '', description: 'GUARDiA 바이브 세션 ID') + string(name: 'ITSM_SR_ID', defaultValue: '', description: '연결된 SR ID') + choice(name: 'DEPLOY_ENV', choices: ['dev', 'stg', 'prd'], description: '배포 환경') + string(name: 'TARGET_SERVER', defaultValue: '', description: '배포 대상 서버명') + booleanParam(name: 'SKIP_TEST', defaultValue: false, description: '테스트 건너뜀') + } + + environment { + APP_VERSION = "${env.BRANCH_NAME ?: 'unknown'}-${env.BUILD_NUMBER}" + SCRIPTS_ROOT = "${WORKSPACE}/cicd/scripts/pipeline" + NOTIFY_SCRIPT = "${WORKSPACE}/cicd/scripts/notify/itsm_callback.sh" + NODE_ENV = "${params.DEPLOY_ENV == 'prd' ? 'production' : 'development'}" + CI = "true" + } + + options { + timeout(time: 60, unit: 'MINUTES') + buildDiscarder(logRotator(numToKeepStr: '30')) + disableConcurrentBuilds() + } + + stages { + stage('Checkout') { + steps { + checkout scm + } + } + + stage('Install') { + steps { + sh """ + node --version + npm --version + # package-lock.json이 있으면 ci, 없으면 install + [ -f package-lock.json ] && npm ci || npm install --prefer-offline + """ + } + } + + stage('Build') { + steps { + sh "bash ${NOTIFY_SCRIPT} BUILDING '빌드 시작'" + sh "npm run build" + } + post { + failure { + sh "bash ${NOTIFY_SCRIPT} FAILED '빌드 실패' || true" + } + } + } + + stage('Test') { + when { + expression { return !params.SKIP_TEST } + } + steps { + sh "npm test -- --coverage --watchAll=false" + } + post { + always { + junit testResults: 'coverage/junit.xml', allowEmptyResults: true + } + failure { + sh "bash ${NOTIFY_SCRIPT} FAILED '테스트 실패' || true" + } + } + } + + stage('Archive') { + steps { + sh "tar czf app-${APP_VERSION}.tar.gz dist/ || tar czf app-${APP_VERSION}.tar.gz build/" + archiveArtifacts( + artifacts: "app-${APP_VERSION}.tar.gz", + fingerprint: true + ) + } + } + + stage('Production Approval') { + when { + anyOf { branch 'main'; branch 'hotfix/*' } + expression { return params.DEPLOY_ENV == 'prd' } + } + steps { + sh "bash ${NOTIFY_SCRIPT} PENDING_APPROVAL '운영 배포 승인 대기'" + timeout(time: 30, unit: 'MINUTES') { + input message: "운영 배포를 승인하시겠습니까?", ok: "승인" + } + } + } + + stage('Deploy') { + steps { + sh "bash ${NOTIFY_SCRIPT} DEPLOYING '배포 시작'" + withCredentials([sshUserPrivateKey( + credentialsId: "ssh-${params.TARGET_SERVER}", + keyFileVariable: 'SSH_KEY', + usernameVariable: 'SSH_USER' + )]) { + sh """ + bash ${SCRIPTS_ROOT}/deploy.sh \ + --server "${params.TARGET_SERVER}" \ + --env "${params.DEPLOY_ENV}" \ + --ssh-key "${SSH_KEY}" \ + --ssh-user "${SSH_USER}" \ + --artifact "app-${APP_VERSION}.tar.gz" + """ + } + } + } + + stage('WAS Restart') { + steps { + withCredentials([sshUserPrivateKey( + credentialsId: "ssh-${params.TARGET_SERVER}", + keyFileVariable: 'SSH_KEY', + usernameVariable: 'SSH_USER' + )]) { + sh """ + bash ${SCRIPTS_ROOT}/was_restart.sh \ + --server "${params.TARGET_SERVER}" \ + --ssh-key "${SSH_KEY}" \ + --ssh-user "${SSH_USER}" \ + --was-type "nodejs" + """ + } + } + } + + stage('Health Check') { + steps { + retry(3) { + sh """ + bash ${SCRIPTS_ROOT}/health_check.sh \ + --url "${env.HEALTH_CHECK_URL ?: 'http://localhost:3000/health'}" \ + --timeout 60 + """ + } + } + post { + failure { + sh "bash ${SCRIPTS_ROOT}/rollback.sh --server '${params.TARGET_SERVER}' --reason '헬스체크 실패' || true" + sh "bash ${NOTIFY_SCRIPT} ROLLED_BACK '헬스체크 실패 — 자동 롤백' || true" + } + } + } + + stage('ITSM Callback') { + steps { + sh "bash ${NOTIFY_SCRIPT} COMPLETED '배포 완료 (빌드 #${env.BUILD_NUMBER})'" + } + } + } + + post { + always { cleanWs() } + failure { + sh "bash ${NOTIFY_SCRIPT} FAILED '파이프라인 실패' || true" + } + } +} diff --git a/cicd/jenkins/Jenkinsfile.rollback b/cicd/jenkins/Jenkinsfile.rollback new file mode 100644 index 0000000..3bc7bb6 --- /dev/null +++ b/cicd/jenkins/Jenkinsfile.rollback @@ -0,0 +1,143 @@ +// GUARDiA ITSM — 롤백 파이프라인 +// 이전 빌드 아티팩트로 즉시 롤백한다. + +pipeline { + agent any + + parameters { + string(name: 'ITSM_SESSION_ID', defaultValue: '', description: 'GUARDiA 바이브 세션 ID') + string(name: 'ITSM_SR_ID', defaultValue: '', description: '연결된 SR ID') + string(name: 'TARGET_SERVER', defaultValue: '', description: '배포 대상 서버명 (필수)') + choice(name: 'DEPLOY_ENV', choices: ['prd', 'stg', 'dev'], description: '롤백 환경') + string(name: 'ROLLBACK_VERSION', defaultValue: '', description: '롤백 버전 (빌드 번호, 비워두면 이전 빌드)') + string(name: 'ROLLBACK_REASON', defaultValue: '서비스 장애', description: '롤백 사유') + string(name: 'SOURCE_JOB', defaultValue: '', description: '아티팩트 소스 Job 이름') + string(name: 'WAS_TYPE', defaultValue: 'tomcat', description: 'WAS 종류') + } + + environment { + SCRIPTS_ROOT = "${WORKSPACE}/cicd/scripts/pipeline" + NOTIFY_SCRIPT = "${WORKSPACE}/cicd/scripts/notify/itsm_callback.sh" + } + + options { + timeout(time: 20, unit: 'MINUTES') + buildDiscarder(logRotator(numToKeepStr: '30')) + } + + stages { + stage('Confirm Rollback') { + steps { + script { + def version = params.ROLLBACK_VERSION ?: '(이전 빌드)' + echo """ +╔══════════════════════════════════════════════╗ +║ 롤백 파이프라인 시작 ║ +╠══════════════════════════════════════════════╣ +║ 대상 서버: ${params.TARGET_SERVER.padRight(30)}║ +║ 환경: ${params.DEPLOY_ENV.padRight(30)} ║ +║ 버전: ${version.padRight(30)} ║ +║ 사유: ${params.ROLLBACK_REASON.take(30)} ║ +╚══════════════════════════════════════════════╝ +""" + } + sh "bash ${NOTIFY_SCRIPT} DEPLOYING '롤백 시작: ${params.ROLLBACK_REASON}'" + } + } + + stage('Resolve Rollback Artifact') { + steps { + script { + def jobName = params.SOURCE_JOB ?: env.JOB_NAME.replaceAll('-rollback', '') + def buildNum = params.ROLLBACK_VERSION + + echo "롤백 소스 Job: ${jobName}" + echo "롤백 버전: ${buildNum ?: '이전 빌드'}" + + copyArtifacts( + projectName: jobName, + selector: buildNum + ? specific(buildNum) + : lastWithArtifacts(), + target: 'artifacts/', + flatten: true, + optional: false + ) + + sh "ls -la artifacts/" + } + } + } + + stage('Rollback Deploy') { + steps { + withCredentials([sshUserPrivateKey( + credentialsId: "ssh-${params.TARGET_SERVER}", + keyFileVariable: 'SSH_KEY', + usernameVariable: 'SSH_USER' + )]) { + sh """ + ARTIFACT=\$(ls artifacts/*.jar artifacts/*.war artifacts/*.tar.gz 2>/dev/null | head -1) + echo "롤백 아티팩트: \${ARTIFACT}" + bash ${SCRIPTS_ROOT}/deploy.sh \ + --server "${params.TARGET_SERVER}" \ + --env "${params.DEPLOY_ENV}" \ + --ssh-key "\${SSH_KEY}" \ + --ssh-user "\${SSH_USER}" \ + --artifact "\${ARTIFACT}" \ + --rollback true + """ + } + } + } + + stage('WAS Restart') { + steps { + withCredentials([sshUserPrivateKey( + credentialsId: "ssh-${params.TARGET_SERVER}", + keyFileVariable: 'SSH_KEY', + usernameVariable: 'SSH_USER' + )]) { + sh """ + bash ${SCRIPTS_ROOT}/was_restart.sh \ + --server "${params.TARGET_SERVER}" \ + --ssh-key "${SSH_KEY}" \ + --ssh-user "${SSH_USER}" \ + --was-type "${params.WAS_TYPE}" + """ + } + } + } + + stage('Health Check') { + steps { + retry(3) { + sh """ + bash ${SCRIPTS_ROOT}/health_check.sh \ + --url "${env.HEALTH_CHECK_URL ?: 'http://localhost:8080/health'}" \ + --timeout 60 + """ + } + } + } + + stage('ITSM Callback') { + steps { + sh """ + bash ${NOTIFY_SCRIPT} ROLLED_BACK \ + "롤백 완료: ${params.ROLLBACK_REASON} (버전 ${params.ROLLBACK_VERSION ?: '이전 빌드'})" + """ + } + } + } + + post { + always { cleanWs() } + success { + echo "✅ 롤백 완료: ${params.TARGET_SERVER} → 버전 ${params.ROLLBACK_VERSION ?: '이전 빌드'}" + } + failure { + sh "bash ${NOTIFY_SCRIPT} FAILED '롤백 실패 — 즉시 수동 조치 필요' || true" + } + } +} diff --git a/cicd/migrate_to_postgres.sh b/cicd/migrate_to_postgres.sh new file mode 100644 index 0000000..e9176cc --- /dev/null +++ b/cicd/migrate_to_postgres.sh @@ -0,0 +1,166 @@ +#!/bin/bash +# migrate_to_postgres.sh — SQLite → PostgreSQL 데이터 마이그레이션 +# +# 사용법: +# bash migrate_to_postgres.sh [sqlite_path] [pg_url] +# +# 예시: +# bash migrate_to_postgres.sh \ +# sqlite:///./guardia.db \ +# postgresql+asyncpg://guardia:guardia@localhost:5432/guardia +# +# 전제조건: +# - Python 3.11+, pip install alembic asyncpg psycopg2-binary sqlalchemy +# - PostgreSQL 서버 실행 중, DB/사용자 사전 생성 +# +# 절차: +# 1. PostgreSQL 스키마 생성 (Alembic 마이그레이션) +# 2. SQLite 데이터 덤프 (pg_dump 불가 → Python으로 직접 이관) +# 3. 검증 (레코드 수 비교) + +set -euo pipefail + +SQLITE_URL="${1:-sqlite:///./guardia.db}" +PG_URL="${2:-postgresql+psycopg2://guardia:guardia@localhost:5432/guardia}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ITSM_DIR="$(dirname "$SCRIPT_DIR")" + +echo "==================================================" +echo " GUARDiA PostgreSQL 마이그레이션" +echo "==================================================" +echo " SQLite: $SQLITE_URL" +echo " PostgreSQL: ${PG_URL//:*@/:***@}" # 비밀번호 마스킹 +echo "" + +# ── Step 1: Alembic 스키마 마이그레이션 ───────────────────────────────────── +echo "[1/3] PostgreSQL 스키마 생성 중..." +cd "$ITSM_DIR" + +if [ -f "alembic.ini" ]; then + # asyncpg를 psycopg2로 변환 (Alembic은 동기 연결 사용) + PG_SYNC_URL="${PG_URL/asyncpg/psycopg2}" + ALEMBIC_INI_BACKUP="alembic.ini.bak" + cp alembic.ini "$ALEMBIC_INI_BACKUP" + sed -i "s|sqlalchemy.url = .*|sqlalchemy.url = $PG_SYNC_URL|" alembic.ini + python -m alembic upgrade head + mv "$ALEMBIC_INI_BACKUP" alembic.ini + echo " ✓ Alembic 마이그레이션 완료" +else + echo " ⚠ alembic.ini를 찾을 수 없습니다. 스키마 생성은 Python으로 대체합니다." + python -c " +import asyncio, os +os.environ['DATABASE_URL'] = '$PG_URL' +from database import engine, Base +import models # noqa: F401 +async def create(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) +asyncio.run(create()) +print(' ✓ 스키마 생성 완료 (SQLAlchemy)') +" +fi + +# ── Step 2: 데이터 이관 ───────────────────────────────────────────────────── +echo "[2/3] 데이터 이관 중..." +python -c " +import asyncio, os, sys +SQLITE_URL = '$SQLITE_URL' +PG_URL = '$PG_URL' + +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.orm import sessionmaker +from sqlalchemy import text, inspect + +# SQLite URL 정규화 (asyncio 드라이버 사용) +if SQLITE_URL.startswith('sqlite:///'): + SQLITE_ASYNC = SQLITE_URL.replace('sqlite:///', 'sqlite+aiosqlite:///') +else: + SQLITE_ASYNC = SQLITE_URL + +src_engine = create_async_engine(SQLITE_ASYNC, echo=False) +dst_engine = create_async_engine(PG_URL, echo=False) + +SrcSession = sessionmaker(src_engine, class_=AsyncSession, expire_on_commit=False) +DstSession = sessionmaker(dst_engine, class_=AsyncSession, expire_on_commit=False) + +SKIP_TABLES = set() + +async def migrate(): + async with src_engine.connect() as src_conn: + table_names = await src_conn.run_sync( + lambda conn: inspect(conn).get_table_names() + ) + + total_copied = 0 + for table in table_names: + if table in SKIP_TABLES: + continue + try: + async with src_engine.connect() as src_conn: + result = await src_conn.execute(text(f'SELECT * FROM {table}')) + rows = result.mappings().all() + if not rows: + continue + + async with dst_engine.begin() as dst_conn: + # TRUNCATE (재실행 안전성) + await dst_conn.execute(text(f'TRUNCATE TABLE {table} RESTART IDENTITY CASCADE')) + await dst_conn.execute( + text(f'INSERT INTO {table} ({chr(44).join(rows[0].keys())}) VALUES ({chr(44).join([\":\" + k for k in rows[0].keys()])})'), + [dict(r) for r in rows], + ) + total_copied += len(rows) + print(f' ✓ {table}: {len(rows)}건 이관') + except Exception as e: + print(f' ✗ {table} 이관 실패: {e}', file=sys.stderr) + + print(f' 총 {total_copied}건 이관 완료') + +asyncio.run(migrate()) +" + +# ── Step 3: 검증 ───────────────────────────────────────────────────────────── +echo "[3/3] 데이터 검증 중..." +python -c " +import asyncio +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy import text, inspect + +SQLITE_ASYNC = '$SQLITE_URL'.replace('sqlite:///', 'sqlite+aiosqlite:///') +PG_URL = '$PG_URL' + +src_engine = create_async_engine(SQLITE_ASYNC) +dst_engine = create_async_engine(PG_URL) + +async def verify(): + ok = True + async with src_engine.connect() as src_conn: + tables = await src_conn.run_sync(lambda c: inspect(c).get_table_names()) + for table in tables: + try: + async with src_engine.connect() as c: + src_cnt = (await c.execute(text(f'SELECT COUNT(*) FROM {table}'))).scalar() + async with dst_engine.connect() as c: + dst_cnt = (await c.execute(text(f'SELECT COUNT(*) FROM {table}'))).scalar() + match = '✓' if src_cnt == dst_cnt else '✗' + print(f' {match} {table}: SQLite={src_cnt} PostgreSQL={dst_cnt}') + if src_cnt != dst_cnt: + ok = False + except Exception as e: + print(f' - {table}: 검증 스킵 ({e})') + if ok: + print(' ✅ 검증 통과 — 마이그레이션 성공') + else: + print(' ❌ 일부 테이블 불일치 — 수동 확인 필요') + exit(1) + +asyncio.run(verify()) +" + +echo "" +echo "==================================================" +echo " 마이그레이션 완료!" +echo " DATABASE_URL 환경변수를 변경하여 PostgreSQL로 전환하세요." +echo " 예: export DATABASE_URL='$PG_URL'" +echo "==================================================" diff --git a/cicd/scripts/install/jenkins_install.sh b/cicd/scripts/install/jenkins_install.sh new file mode 100644 index 0000000..3bbaf98 --- /dev/null +++ b/cicd/scripts/install/jenkins_install.sh @@ -0,0 +1,208 @@ +#!/usr/bin/env bash +# ============================================================================= +# Jenkins 자동 설치 스크립트 +# 대상: RHEL 8/9, Rocky Linux 8/9, Ubuntu 20.04/22.04 +# 사용: sudo bash jenkins_install.sh +# ============================================================================= +set -euo pipefail + +JENKINS_PORT="${JENKINS_PORT:-8080}" +JAVA_VERSION="${JAVA_VERSION:-17}" +LOG_FILE="/var/log/jenkins_install.log" + +# ── 색상 출력 ───────────────────────────────────────────────────────────────── +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' +log() { echo -e "${GREEN}[$(date '+%H:%M:%S')]${NC} $*" | tee -a "${LOG_FILE}"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*" | tee -a "${LOG_FILE}"; } +die() { echo -e "${RED}[ERROR]${NC} $*" | tee -a "${LOG_FILE}"; exit 1; } + +# ── 루트 권한 확인 ─────────────────────────────────────────────────────────── +[[ $EUID -ne 0 ]] && die "root 권한으로 실행하세요: sudo bash $0" + +# ── OS 감지 ────────────────────────────────────────────────────────────────── +detect_os() { + if [[ -f /etc/os-release ]]; then + source /etc/os-release + OS_ID="${ID}" + OS_VERSION_ID="${VERSION_ID%%.*}" + else + die "OS를 감지할 수 없습니다." + fi + log "OS 감지: ${ID} ${VERSION_ID}" +} + +# ── Java 설치 ──────────────────────────────────────────────────────────────── +install_java() { + log "Java ${JAVA_VERSION} 설치 중..." + case "${OS_ID}" in + rhel|rocky|centos|almalinux) + dnf install -y "java-${JAVA_VERSION}-openjdk-devel" || \ + dnf install -y "java-${JAVA_VERSION}-amazon-corretto-devel" || \ + die "Java ${JAVA_VERSION} 설치 실패" + ;; + ubuntu|debian) + apt-get update -qq + apt-get install -y "openjdk-${JAVA_VERSION}-jdk" || \ + die "Java ${JAVA_VERSION} 설치 실패" + ;; + *) die "지원하지 않는 OS: ${OS_ID}" ;; + esac + java -version + log "Java 설치 완료" +} + +# ── Jenkins 저장소 등록 ──────────────────────────────────────────────────────── +add_jenkins_repo() { + log "Jenkins 저장소 등록 중..." + case "${OS_ID}" in + rhel|rocky|centos|almalinux) + wget -q -O /etc/yum.repos.d/jenkins.repo \ + https://pkg.jenkins.io/redhat-stable/jenkins.repo + rpm --import https://pkg.jenkins.io/redhat-stable/jenkins.io-2023.key + dnf upgrade -y + ;; + ubuntu|debian) + apt-get install -y curl gnupg + curl -fsSL https://pkg.jenkins.io/debian-stable/jenkins.io-2023.key | \ + tee /usr/share/keyrings/jenkins-keyring.asc > /dev/null + echo "deb [signed-by=/usr/share/keyrings/jenkins-keyring.asc] \ + https://pkg.jenkins.io/debian-stable binary/" | \ + tee /etc/apt/sources.list.d/jenkins.list > /dev/null + apt-get update -qq + ;; + esac + log "저장소 등록 완료" +} + +# ── Jenkins 설치 ───────────────────────────────────────────────────────────── +install_jenkins() { + log "Jenkins LTS 설치 중..." + case "${OS_ID}" in + rhel|rocky|centos|almalinux) + dnf install -y jenkins + ;; + ubuntu|debian) + apt-get install -y jenkins + ;; + esac + + # 포트 변경 (기본값 8080이 아닌 경우) + if [[ "${JENKINS_PORT}" != "8080" ]]; then + log "Jenkins 포트 변경: 8080 → ${JENKINS_PORT}" + case "${OS_ID}" in + rhel|rocky|centos|almalinux) + sed -i "s/^HTTP_PORT=.*/HTTP_PORT=${JENKINS_PORT}/" \ + /etc/sysconfig/jenkins 2>/dev/null || \ + echo "HTTP_PORT=${JENKINS_PORT}" >> /etc/sysconfig/jenkins + ;; + ubuntu|debian) + sed -i "s/^HTTP_PORT=.*/HTTP_PORT=${JENKINS_PORT}/" \ + /etc/default/jenkins 2>/dev/null || \ + echo "HTTP_PORT=${JENKINS_PORT}" >> /etc/default/jenkins + ;; + esac + fi + + systemctl enable jenkins + systemctl start jenkins + log "Jenkins 시작 완료" +} + +# ── 방화벽 설정 ──────────────────────────────────────────────────────────────── +configure_firewall() { + log "방화벽 포트 ${JENKINS_PORT} 개방..." + if command -v firewall-cmd &>/dev/null && systemctl is-active firewalld &>/dev/null; then + firewall-cmd --permanent --add-port="${JENKINS_PORT}/tcp" + firewall-cmd --reload + log "firewalld: 포트 ${JENKINS_PORT} 개방" + elif command -v ufw &>/dev/null; then + ufw allow "${JENKINS_PORT}/tcp" + log "ufw: 포트 ${JENKINS_PORT} 개방" + else + warn "방화벽 관리 도구를 찾을 수 없습니다. 수동으로 포트를 개방하세요." + fi +} + +# ── SELinux 설정 (RHEL 계열) ────────────────────────────────────────────────── +configure_selinux() { + if command -v getenforce &>/dev/null && [[ "$(getenforce)" != "Disabled" ]]; then + log "SELinux 설정 중..." + setsebool -P httpd_can_network_connect 1 2>/dev/null || true + semanage port -a -t http_port_t -p tcp "${JENKINS_PORT}" 2>/dev/null || \ + semanage port -m -t http_port_t -p tcp "${JENKINS_PORT}" 2>/dev/null || \ + warn "SELinux 포트 설정 실패 — 수동 설정이 필요할 수 있습니다." + log "SELinux 설정 완료" + fi +} + +# ── 초기 설정 디렉터리 준비 ──────────────────────────────────────────────────── +prepare_directories() { + log "디렉터리 구조 준비..." + mkdir -p /var/lib/jenkins/casc_configs + mkdir -p /backup/jenkins + chown -R jenkins:jenkins /var/lib/jenkins/casc_configs + chown -R jenkins:jenkins /backup/jenkins 2>/dev/null || true + log "디렉터리 준비 완료" +} + +# ── 완료 메시지 ─────────────────────────────────────────────────────────────── +print_summary() { + local admin_pw + admin_pw=$(cat /var/lib/jenkins/secrets/initialAdminPassword 2>/dev/null || echo "N/A") + local server_ip + server_ip=$(hostname -I | awk '{print $1}') + + echo "" + echo -e "${GREEN}╔══════════════════════════════════════════════════╗${NC}" + echo -e "${GREEN}║ Jenkins 설치 완료 ║${NC}" + echo -e "${GREEN}╠══════════════════════════════════════════════════╣${NC}" + echo -e "${GREEN}║${NC} URL: http://${server_ip}:${JENKINS_PORT} " + echo -e "${GREEN}║${NC} 초기 PW: ${admin_pw} " + echo -e "${GREEN}║${NC} " + echo -e "${GREEN}║${NC} 다음 단계: " + echo -e "${GREEN}║${NC} 1. 브라우저에서 URL 접속 " + echo -e "${GREEN}║${NC} 2. 초기 비밀번호 입력 " + echo -e "${GREEN}║${NC} 3. 플러그인 설치: sudo bash jenkins_plugins.sh " + echo -e "${GREEN}╚══════════════════════════════════════════════════╝${NC}" + echo "" + log "설치 로그: ${LOG_FILE}" +} + +# ── 메인 실행 ──────────────────────────────────────────────────────────────── +configure_gitea_webhook() { + log "=== Gitea 웹훅 자동 등록 ===" + local script_dir + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + local webhook_script="${script_dir}/../notify/gitea_webhook.sh" + + if [[ -f "$webhook_script" ]]; then + # Gitea가 기동 중이면 웹훅 자동 등록 + if curl -sf "${GITEA_BASE_URL:-http://localhost:3000}/api/v1/version" -o /dev/null 2>/dev/null; then + JENKINS_URL="${JENKINS_URL:-http://localhost:${JENKINS_PORT}}" \ + bash "$webhook_script" \ + && log "Gitea 웹훅 등록 완료" \ + || warn "Gitea 웹훅 등록 실패 — 나중에 수동 실행: bash $webhook_script" + else + warn "Gitea 서비스 없음 — 웹훅 등록 건너뜀" + warn "Gitea 시작 후 실행: bash $webhook_script" + fi + else + warn "webhook 스크립트 없음: $webhook_script" + fi +} + +main() { + log "=== Jenkins 설치 시작 ===" + detect_os + install_java + add_jenkins_repo + install_jenkins + configure_firewall + configure_selinux + prepare_directories + configure_gitea_webhook + print_summary + log "=== Jenkins 설치 완료 ===" +} + +main "$@" diff --git a/cicd/scripts/install/jenkins_plugins.sh b/cicd/scripts/install/jenkins_plugins.sh new file mode 100644 index 0000000..c69779c --- /dev/null +++ b/cicd/scripts/install/jenkins_plugins.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash +# ============================================================================= +# Jenkins 필수 플러그인 자동 설치 +# 사용: sudo bash jenkins_plugins.sh [--jenkins-url URL] [--user USER] [--token TOKEN] +# ============================================================================= +set -euo pipefail + +JENKINS_URL="${JENKINS_URL:-http://localhost:8080}" +JENKINS_USER="${JENKINS_ADMIN_USER:-admin}" +JENKINS_TOKEN="${JENKINS_ADMIN_TOKEN:-}" +JENKINS_CLI_JAR="/tmp/jenkins-cli.jar" +LOG_FILE="/var/log/jenkins_plugins.log" + +GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m' +log() { echo -e "${GREEN}[$(date '+%H:%M:%S')]${NC} $*" | tee -a "${LOG_FILE}"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*" | tee -a "${LOG_FILE}"; } +die() { echo -e "${RED}[ERROR]${NC} $*" | tee -a "${LOG_FILE}"; exit 1; } + +# ── 파라미터 파싱 ──────────────────────────────────────────────────────────── +while [[ $# -gt 0 ]]; do + case $1 in + --jenkins-url) JENKINS_URL="$2"; shift 2 ;; + --user) JENKINS_USER="$2"; shift 2 ;; + --token) JENKINS_TOKEN="$2"; shift 2 ;; + *) shift ;; + esac +done + +# ── Jenkins CLI 다운로드 ────────────────────────────────────────────────────── +download_cli() { + log "Jenkins CLI 다운로드 중..." + local retries=5 + for i in $(seq 1 $retries); do + if curl -fsSL -o "${JENKINS_CLI_JAR}" \ + "${JENKINS_URL}/jnlpJars/jenkins-cli.jar" 2>/dev/null; then + log "Jenkins CLI 다운로드 완료" + return 0 + fi + warn "CLI 다운로드 실패 (${i}/${retries}) — Jenkins가 기동 중인지 확인..." + sleep 10 + done + die "Jenkins CLI 다운로드 실패 — Jenkins가 실행 중인지 확인하세요." +} + +# ── Jenkins CLI 실행 헬퍼 ──────────────────────────────────────────────────── +jenkins_cli() { + if [[ -n "${JENKINS_TOKEN}" ]]; then + java -jar "${JENKINS_CLI_JAR}" \ + -s "${JENKINS_URL}" \ + -auth "${JENKINS_USER}:${JENKINS_TOKEN}" \ + "$@" + else + # 초기 비밀번호 사용 + local init_pw + init_pw=$(cat /var/lib/jenkins/secrets/initialAdminPassword 2>/dev/null || echo "") + java -jar "${JENKINS_CLI_JAR}" \ + -s "${JENKINS_URL}" \ + -auth "admin:${init_pw}" \ + "$@" + fi +} + +# ── 필수 플러그인 목록 ──────────────────────────────────────────────────────── +PLUGINS=( + # ── 파이프라인 핵심 ─────────────────────────────── + "workflow-aggregator" # Pipeline (필수) + "pipeline-stage-view" # Pipeline Stage View + "blueocean" # Blue Ocean UI + "pipeline-graph-analysis" # 파이프라인 그래프 + "pipeline-utility-steps" # 유틸리티 스텝 + + # ── 소스 관리 ──────────────────────────────────── + "git" # Git 플러그인 + "git-client" # Git Client + "github" # GitHub 연동 + "gitlab-plugin" # GitLab 연동 (선택) + "gitea" # Gitea 연동 (선택) + + # ── 빌드 도구 ──────────────────────────────────── + "maven-plugin" # Maven 지원 + "gradle" # Gradle 지원 + "nodejs" # Node.js 지원 + "ant" # Ant 지원 + + # ── 자격증명 ───────────────────────────────────── + "credentials" # Credentials Plugin + "credentials-binding" # withCredentials() + "ssh-credentials" # SSH 자격증명 + "plain-credentials" # Secret Text + "ssh-agent" # SSH Agent + + # ── 배포 / 원격 실행 ────────────────────────────── + "publish-over-ssh" # SCP/SSH 배포 + "ssh-steps" # SSH Steps + "copy-artifact" # 아티팩트 복사 + "artifact-manager-s3" # S3 아티팩트 (선택) + + # ── 테스트 & 품질 ───────────────────────────────── + "junit" # JUnit 결과 + "jacoco" # 코드 커버리지 + "sonar" # SonarQube 연동 + "htmlpublisher" # HTML 보고서 + + # ── 알림 ───────────────────────────────────────── + "email-ext" # 이메일 알림 + "mailer" # 기본 메일 + + # ── 보안 ───────────────────────────────────────── + "role-strategy" # RBAC + "mask-passwords" # 비밀번호 마스킹 + "audit-trail" # 감사 로그 + "matrix-auth" # Matrix Authorization + + # ── Gitea 연동 (웹훅 트리거) ───────────────────────── + "generic-webhook-trigger" # Gitea Push/PR 웹훅 수신 + "gitea" # Gitea 전용 SCM 플러그인 + + # ── 설정 관리 ───────────────────────────────────── + "configuration-as-code" # JCasC + "configuration-as-code-support" # JCasC 지원 + + # ── UI & 유틸리티 ───────────────────────────────── + "timestamper" # 타임스탬프 + "build-timeout" # 빌드 타임아웃 + "ws-cleanup" # 워크스페이스 정리 + "ansicolor" # ANSI 컬러 출력 + "parameterized-trigger" # 파라미터 트리거 + "rebuild" # 재빌드 버튼 + "build-discarder" # 빌드 보관 정책 +) + +# ── 플러그인 설치 ───────────────────────────────────────────────────────────── +install_plugins() { + log "총 ${#PLUGINS[@]}개 플러그인 설치 시작..." + + local failed=() + for plugin in "${PLUGINS[@]}"; do + log " 설치 중: ${plugin}" + if jenkins_cli install-plugin "${plugin}" -restart 2>/dev/null; then + : # 성공 + else + # restart 없이 재시도 + if jenkins_cli install-plugin "${plugin}" 2>/dev/null; then + : # 성공 + else + warn " 설치 실패: ${plugin}" + failed+=("${plugin}") + fi + fi + done + + if [[ ${#failed[@]} -gt 0 ]]; then + warn "설치 실패 플러그인 (${#failed[@]}개):" + for p in "${failed[@]}"; do + warn " - ${p}" + done + fi + + log "플러그인 설치 완료 — Jenkins 재시작 중..." +} + +# ── Jenkins 재시작 대기 ─────────────────────────────────────────────────────── +wait_for_jenkins() { + log "Jenkins 재시작 대기 중..." + jenkins_cli safe-restart 2>/dev/null || systemctl restart jenkins + + local retries=30 + for i in $(seq 1 $retries); do + if curl -fsSL -o /dev/null "${JENKINS_URL}/login" 2>/dev/null; then + log "Jenkins 준비 완료" + return 0 + fi + echo -n "." + sleep 5 + done + warn "Jenkins 재시작 확인 타임아웃 — 수동으로 확인하세요." +} + +# ── 메인 ───────────────────────────────────────────────────────────────────── +main() { + log "=== Jenkins 플러그인 설치 시작 ===" + download_cli + install_plugins + wait_for_jenkins + log "=== Jenkins 플러그인 설치 완료 ===" + echo "" + echo -e "${GREEN}플러그인 설치 완료!${NC}" + echo "Jenkins: ${JENKINS_URL}" + echo "로그: ${LOG_FILE}" +} + +main "$@" diff --git a/cicd/scripts/notify/gitea_webhook.sh b/cicd/scripts/notify/gitea_webhook.sh new file mode 100644 index 0000000..81c42be --- /dev/null +++ b/cicd/scripts/notify/gitea_webhook.sh @@ -0,0 +1,121 @@ +#!/bin/bash +# ============================================================== +# Gitea 웹훅 자동 등록 스크립트 +# ============================================================== +# Gitea 저장소에 Jenkins 빌드 트리거 웹훅을 등록합니다. +# Gitea PR 이벤트 → Jenkins 파이프라인 자동 실행 +# +# 사용법: +# bash cicd/scripts/notify/gitea_webhook.sh +# JENKINS_URL=http://jenkins:8080 bash gitea_webhook.sh +# ============================================================== + +set -euo pipefail + +GITEA_BASE="${GITEA_BASE_URL:-http://localhost:3000}" +GITEA_ORG="${GITEA_ORG:-guardia}" +GITEA_REPO="${GITEA_REPO:-GUARDiA}" +GITEA_ADMIN="${GITEA_ADMIN:-gitadmin}" +GITEA_ADMIN_PW="${GITEA_ADMIN_PW:-Gitea@guardia!}" +JENKINS_URL="${JENKINS_URL:-http://localhost:8080}" +JENKINS_USER="${JENKINS_USER:-admin}" +JENKINS_TOKEN="${JENKINS_TOKEN:-}" + +API="${GITEA_BASE}/api/v1" +AUTH="Authorization: Basic $(echo -n "${GITEA_ADMIN}:${GITEA_ADMIN_PW}" | base64)" + +GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' +ok() { echo -e "${GREEN}[OK]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +info() { echo -e " $*"; } + +echo "==================================================" +echo " Gitea 웹훅 등록" +echo " 저장소: ${GITEA_BASE}/${GITEA_ORG}/${GITEA_REPO}" +echo " Jenkins: ${JENKINS_URL}" +echo "==================================================" + +# ── Jenkins Webhook URL ────────────────────────────────────── +# Jenkins Generic Webhook Trigger 플러그인 활용 +WEBHOOK_TOKEN="${WEBHOOK_TOKEN:-guardia-jenkins-$(date +%s | md5sum | head -c 8)}" +WEBHOOK_URL="${JENKINS_URL}/generic-webhook-trigger/invoke?token=${WEBHOOK_TOKEN}" + +# API 토큰이 있으면 Basic Auth 포함 +if [[ -n "$JENKINS_TOKEN" ]]; then + WEBHOOK_URL="${JENKINS_URL%/}/generic-webhook-trigger/invoke?token=${WEBHOOK_TOKEN}" +fi + +# ── 기존 웹훅 삭제 (중복 방지) ────────────────────────────── +info "기존 Jenkins 웹훅 정리..." +EXISTING=$(curl -sf "$API/repos/${GITEA_ORG}/${GITEA_REPO}/hooks" \ + -H "$AUTH" 2>/dev/null | python3 -c " +import sys, json +hooks = json.load(sys.stdin) +ids = [str(h['id']) for h in hooks if 'jenkins' in str(h.get('config',{})).lower() or 'generic-webhook' in str(h.get('config',{})).lower()] +print(' '.join(ids)) +" 2>/dev/null || echo "") + +for hook_id in $EXISTING; do + curl -sf -X DELETE "$API/repos/${GITEA_ORG}/${GITEA_REPO}/hooks/${hook_id}" \ + -H "$AUTH" -o /dev/null 2>/dev/null + info "기존 웹훅 삭제: ID=$hook_id" +done + +# ── 웹훅 등록 ──────────────────────────────────────────────── +info "Jenkins 웹훅 등록 중..." +RESP=$(curl -sf -X POST "$API/repos/${GITEA_ORG}/${GITEA_REPO}/hooks" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d "{ + \"type\":\"gitea\", + \"config\":{ + \"url\":\"${WEBHOOK_URL}\", + \"content_type\":\"json\", + \"secret\":\"${WEBHOOK_TOKEN}\" + }, + \"events\":[\"push\",\"pull_request\",\"pull_request_review\"], + \"active\":true, + \"branch_filter\":\"*\" + }" 2>/dev/null) + +HOOK_ID=$(echo "$RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || echo "") +if [[ -n "$HOOK_ID" ]]; then + ok "웹훅 등록 완료 (ID: $HOOK_ID)" +else + warn "웹훅 등록 실패 — 수동 등록 필요" + info " Gitea → 저장소 → Settings → Webhooks → Add Webhook" + info " URL: $WEBHOOK_URL" +fi + +# ── PR 이벤트 웹훅 설정 (PR 오픈 시 Jenkins 빌드) ──────────── +info "PR 이벤트 웹훅 등록..." +PR_RESP=$(curl -sf -X POST "$API/repos/${GITEA_ORG}/${GITEA_REPO}/hooks" \ + -H "$AUTH" -H "Content-Type: application/json" \ + -d "{ + \"type\":\"gitea\", + \"config\":{ + \"url\":\"${JENKINS_URL}/job/guardia-pr/build?token=${WEBHOOK_TOKEN}\", + \"content_type\":\"json\" + }, + \"events\":[\"pull_request\"], + \"active\":true + }" 2>/dev/null) + +PR_HOOK_ID=$(echo "$PR_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || echo "") +[[ -n "$PR_HOOK_ID" ]] && ok "PR 트리거 웹훅 등록 (ID: $PR_HOOK_ID)" || warn "PR 웹훅 등록 실패" + +# ── develop → main PR 자동화 설명 ──────────────────────────── +echo "" +echo "==================================================" +ok "Gitea 웹훅 설정 완료!" +echo "" +info "=== CI/CD 트리거 흐름 ===" +info " 1. 개발자가 feature/이름/기능 브랜치에 push" +info " 2. Gitea 웹훅 → Jenkins Generic Webhook Trigger" +info " 3. Jenkins가 해당 브랜치 빌드·테스트" +info " 4. PR (feature → develop) 생성" +info " 5. 리뷰어 승인 후 develop 자동 병합" +info " 6. develop → main PR → 관리자 승인 → 운영 배포" +echo "" +info "웹훅 토큰: $WEBHOOK_TOKEN" +info "Jenkins 웹훅 URL: $WEBHOOK_URL" +echo "==================================================" diff --git a/cicd/scripts/notify/itsm_callback.sh b/cicd/scripts/notify/itsm_callback.sh new file mode 100644 index 0000000..0611b57 --- /dev/null +++ b/cicd/scripts/notify/itsm_callback.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# ============================================================================= +# GUARDiA ITSM 결과 콜백 스크립트 +# Jenkins Stage 8: ITSM Callback에서 호출 +# 사용: itsm_callback.sh [MESSAGE] +# STATUS: BUILDING|TESTING|DEPLOYING|COMPLETED|FAILED|UNSTABLE|PENDING_APPROVAL|ROLLED_BACK +# ============================================================================= +set -euo pipefail + +STATUS="${1:-FAILED}" +MESSAGE="${2:-파이프라인 실행 완료}" + +# ── 환경변수 ───────────────────────────────────────────────────────────────── +ITSM_URL="${ITSM_URL:-http://itsm.agency.go.kr:8000}" +ITSM_CALLBACK_URL="${ITSM_CALLBACK_URL:-${ITSM_URL}/api/vibe/callback}" +ITSM_TOKEN="${ITSM_TOKEN:-}" # withCredentials로 주입 +ITSM_SESSION_ID="${ITSM_SESSION_ID:-}" # Jenkins 파라미터 +ITSM_SR_ID="${ITSM_SR_ID:-}" # Jenkins 파라미터 +BUILD_NUMBER="${BUILD_NUMBER:-0}" +BUILD_URL="${BUILD_URL:-}" +DEPLOY_ENV="${DEPLOY_ENV:-dev}" +JOB_NAME="${JOB_NAME:-unknown-job}" +STAGE_NAME="${STAGE_NAME:-}" +LOG_DIR="${WORKSPACE:-/tmp}/build-logs" +CALLBACK_LOG="${LOG_DIR}/itsm-callback.log" + +GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; NC='\033[0m' +log() { echo -e "${GREEN}[ITSM]${NC} $*" | tee -a "${CALLBACK_LOG}"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*" | tee -a "${CALLBACK_LOG}"; } +err() { echo -e "${RED}[ERROR]${NC} $*" | tee -a "${CALLBACK_LOG}"; } + +mkdir -p "${LOG_DIR}" + +# ── 세션 ID 없으면 경고 후 종료 (비배포 브랜치 등) ───────────────────────── +if [[ -z "${ITSM_SESSION_ID}" ]]; then + warn "ITSM_SESSION_ID가 설정되지 않았습니다 — 콜백 건너뜀" + exit 0 +fi + +log "ITSM 콜백: ${STATUS} — ${MESSAGE}" + +# ── 빌드 로그 요약 수집 ────────────────────────────────────────────────────── +get_log_summary() { + local log_type="$1" + local log_file="${LOG_DIR}/${log_type}-"*".log" 2>/dev/null + if compgen -G "${log_file}" > /dev/null; then + tail -n 5 "$(ls ${log_file} | tail -1)" 2>/dev/null | tr '\n' ' ' | \ + sed 's/["\t]/ /g' | cut -c1-200 || echo "" + else + echo "" + fi +} + +BUILD_LOG_SUMMARY=$(get_log_summary "build") +TEST_LOG_SUMMARY=$(get_log_summary "test") +DEPLOY_LOG_SUMMARY=$(get_log_summary "deploy") +HC_LOG_SUMMARY=$(get_log_summary "healthcheck") + +# ── 현재 타임스탬프 ────────────────────────────────────────────────────────── +TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + +# ── JSON 페이로드 구성 ─────────────────────────────────────────────────────── +JSON_PAYLOAD=$(cat </dev/null || echo "000") + + if [[ "${http_code}" =~ ^2[0-9]{2}$ ]]; then + log "✅ 콜백 성공: HTTP ${http_code}" + if [[ -f "${LOG_DIR}/callback-response.json" ]]; then + log " 응답: $(cat ${LOG_DIR}/callback-response.json)" + fi + return 0 + else + warn " 콜백 실패: HTTP ${http_code}" + if [[ ${attempt} -lt ${retries} ]]; then + sleep $((attempt * 5)) + fi + fi + done + + err "ITSM 콜백 실패 (${retries}회 재시도 후)" + err "URL: ${ITSM_CALLBACK_URL}" + err "상태: ${STATUS}" + # 콜백 실패는 파이프라인 실패로 처리하지 않음 + return 0 +} + +# ── 로컬 로그에도 기록 ──────────────────────────────────────────────────────── +log_locally() { + local history_file="${LOG_DIR}/pipeline-history.log" + echo "${TIMESTAMP}|${JOB_NAME}|${BUILD_NUMBER}|${STATUS}|${MESSAGE}|${DEPLOY_ENV}" \ + >> "${history_file}" +} + +# ── 메인 ───────────────────────────────────────────────────────────────────── +main() { + log_locally + send_callback + log "ITSM 콜백 처리 완료: ${STATUS}" +} + +main "$@" diff --git a/cicd/scripts/pipeline/build.sh b/cicd/scripts/pipeline/build.sh new file mode 100644 index 0000000..0c54c19 --- /dev/null +++ b/cicd/scripts/pipeline/build.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +# ============================================================================= +# 빌드 공통 스크립트 +# Jenkins Stage 2: Build에서 호출 +# 환경변수: BUILD_TOOL(maven|gradle|npm|custom), APP_VERSION, WORKSPACE +# ============================================================================= +set -euo pipefail + +BUILD_TOOL="${BUILD_TOOL:-auto}" +APP_VERSION="${APP_VERSION:-dev-0}" +WORKSPACE="${WORKSPACE:-.}" +LOG_DIR="${WORKSPACE}/build-logs" +BUILD_LOG="${LOG_DIR}/build-$(date +%Y%m%d-%H%M%S).log" + +GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; NC='\033[0m' +log() { echo -e "${GREEN}[BUILD]${NC} $*" | tee -a "${BUILD_LOG}"; } +err() { echo -e "${RED}[ERROR]${NC} $*" | tee -a "${BUILD_LOG}"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*" | tee -a "${BUILD_LOG}"; } + +mkdir -p "${LOG_DIR}" + +# ── 빌드 도구 자동 감지 ────────────────────────────────────────────────────── +detect_build_tool() { + if [[ "${BUILD_TOOL}" != "auto" ]]; then + return + fi + if [[ -f "${WORKSPACE}/pom.xml" ]]; then + BUILD_TOOL="maven" + elif [[ -f "${WORKSPACE}/build.gradle" ]] || \ + [[ -f "${WORKSPACE}/build.gradle.kts" ]]; then + BUILD_TOOL="gradle" + elif [[ -f "${WORKSPACE}/package.json" ]]; then + BUILD_TOOL="npm" + elif [[ -f "${WORKSPACE}/Makefile" ]]; then + BUILD_TOOL="make" + else + err "빌드 파일을 찾을 수 없습니다 (pom.xml / build.gradle / package.json / Makefile)" + exit 1 + fi + log "빌드 도구 자동 감지: ${BUILD_TOOL}" +} + +# ── Maven 빌드 ──────────────────────────────────────────────────────────────── +build_maven() { + log "Maven 빌드 시작..." + cd "${WORKSPACE}" + + MVN_CMD="mvn" + [[ -f "./mvnw" ]] && MVN_CMD="./mvnw" + + ${MVN_CMD} clean package \ + -DskipTests=true \ + -Dapp.version="${APP_VERSION}" \ + --batch-mode \ + --no-transfer-progress \ + 2>&1 | tee -a "${BUILD_LOG}" + + local artifact + artifact=$(ls target/*.jar target/*.war 2>/dev/null | head -1 || true) + if [[ -z "${artifact}" ]]; then + err "빌드 산출물을 찾을 수 없습니다 (target/*.jar|*.war)" + exit 1 + fi + log "빌드 완료: ${artifact}" + echo "${artifact}" > "${LOG_DIR}/artifact.path" +} + +# ── Gradle 빌드 ──────────────────────────────────────────────────────────────── +build_gradle() { + log "Gradle 빌드 시작..." + cd "${WORKSPACE}" + + GRADLE_CMD="gradle" + [[ -f "./gradlew" ]] && { GRADLE_CMD="./gradlew"; chmod +x ./gradlew; } + + ${GRADLE_CMD} clean build \ + -x test \ + -Pversion="${APP_VERSION}" \ + --no-daemon \ + --parallel \ + 2>&1 | tee -a "${BUILD_LOG}" + + local artifact + artifact=$(ls build/libs/*.jar build/libs/*.war 2>/dev/null | grep -v plain | head -1 || true) + if [[ -z "${artifact}" ]]; then + err "빌드 산출물을 찾을 수 없습니다 (build/libs/*.jar|*.war)" + exit 1 + fi + log "빌드 완료: ${artifact}" + echo "${artifact}" > "${LOG_DIR}/artifact.path" +} + +# ── npm 빌드 ────────────────────────────────────────────────────────────────── +build_npm() { + log "npm 빌드 시작..." + cd "${WORKSPACE}" + + node --version + npm --version + + # 의존성 설치 + if [[ -f "package-lock.json" ]]; then + npm ci --prefer-offline + else + npm install + fi + + # 빌드 + npm run build + + # 아티팩트 패키징 + local dist_dir="dist" + [[ -d "build" ]] && dist_dir="build" + local artifact="app-${APP_VERSION}.tar.gz" + + tar czf "${artifact}" "${dist_dir}/" + log "빌드 완료: ${artifact}" + echo "${WORKSPACE}/${artifact}" > "${LOG_DIR}/artifact.path" +} + +# ── make 빌드 ──────────────────────────────────────────────────────────────── +build_make() { + log "Make 빌드 시작..." + cd "${WORKSPACE}" + make build VERSION="${APP_VERSION}" 2>&1 | tee -a "${BUILD_LOG}" + log "Make 빌드 완료" +} + +# ── 빌드 정보 출력 ──────────────────────────────────────────────────────────── +print_build_info() { + local artifact_path="${LOG_DIR}/artifact.path" + echo "" + log "────────────────────────────────────────" + log " 빌드 정보" + log "────────────────────────────────────────" + log " 도구: ${BUILD_TOOL}" + log " 버전: ${APP_VERSION}" + log " 로그: ${BUILD_LOG}" + if [[ -f "${artifact_path}" ]]; then + local artifact + artifact=$(cat "${artifact_path}") + local size + size=$(du -sh "${artifact}" 2>/dev/null | cut -f1 || echo "N/A") + log " 산출물: ${artifact} (${size})" + fi + log "────────────────────────────────────────" +} + +# ── 메인 ───────────────────────────────────────────────────────────────────── +main() { + local start_time=$SECONDS + log "=== 빌드 시작: $(date) ===" + detect_build_tool + + case "${BUILD_TOOL}" in + maven) build_maven ;; + gradle) build_gradle ;; + npm) build_npm ;; + make) build_make ;; + custom) + log "사용자 정의 빌드: ${CUSTOM_BUILD_CMD}" + eval "${CUSTOM_BUILD_CMD}" 2>&1 | tee -a "${BUILD_LOG}" + ;; + *) err "알 수 없는 빌드 도구: ${BUILD_TOOL}"; exit 1 ;; + esac + + local elapsed=$((SECONDS - start_time)) + print_build_info + log "=== 빌드 완료: ${elapsed}초 ===" +} + +main "$@" diff --git a/cicd/scripts/pipeline/deploy.sh b/cicd/scripts/pipeline/deploy.sh new file mode 100644 index 0000000..3f5d412 --- /dev/null +++ b/cicd/scripts/pipeline/deploy.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +# ============================================================================= +# 파일 배포 스크립트 (rsync/SCP) +# Jenkins Stage 5: Deploy에서 호출 +# 사용: deploy.sh --server <서버명> --env --ssh-key <키파일> +# --ssh-user <사용자> --artifact <파일경로> [--rollback true] +# ============================================================================= +set -euo pipefail + +# ── 기본값 ──────────────────────────────────────────────────────────────────── +SERVER="" +DEPLOY_ENV="dev" +SSH_KEY="" +SSH_USER="deploy" +ARTIFACT="" +SSH_PORT="${SSH_PORT:-22}" +IS_ROLLBACK="false" +LOG_DIR="${WORKSPACE:-/tmp}/build-logs" +DEPLOY_LOG="${LOG_DIR}/deploy-$(date +%Y%m%d-%H%M%S).log" + +# 배포 설정 (환경변수로 재정의 가능) +DEPLOY_BASE_PATH="${DEPLOY_BASE_PATH:-/opt/apps}" +BACKUP_COUNT="${BACKUP_COUNT:-5}" # 이전 버전 보관 개수 +DEPLOY_TIMEOUT="${DEPLOY_TIMEOUT:-300}" # rsync 타임아웃 (초) + +GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; NC='\033[0m' +log() { echo -e "${GREEN}[DEPLOY]${NC} $*" | tee -a "${DEPLOY_LOG}"; } +err() { echo -e "${RED}[ERROR]${NC} $*" | tee -a "${DEPLOY_LOG}"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*" | tee -a "${DEPLOY_LOG}"; } + +mkdir -p "${LOG_DIR}" + +# ── 파라미터 파싱 ──────────────────────────────────────────────────────────── +while [[ $# -gt 0 ]]; do + case $1 in + --server) SERVER="$2"; shift 2 ;; + --env) DEPLOY_ENV="$2"; shift 2 ;; + --ssh-key) SSH_KEY="$2"; shift 2 ;; + --ssh-user) SSH_USER="$2"; shift 2 ;; + --artifact) ARTIFACT="$2"; shift 2 ;; + --rollback) IS_ROLLBACK="$2";shift 2 ;; + *) shift ;; + esac +done + +# ── 필수값 확인 ─────────────────────────────────────────────────────────────── +[[ -z "${SERVER}" ]] && { err "--server 파라미터가 필요합니다"; exit 1; } +[[ -z "${SSH_KEY}" ]] && { err "--ssh-key 파라미터가 필요합니다"; exit 1; } +[[ -z "${ARTIFACT}" ]] && { err "--artifact 파라미터가 필요합니다"; exit 1; } +[[ ! -f "${ARTIFACT}" ]] && { err "아티팩트 파일을 찾을 수 없습니다: ${ARTIFACT}"; exit 1; } +[[ ! -f "${SSH_KEY}" ]] && { err "SSH 키 파일을 찾을 수 없습니다: ${SSH_KEY}"; exit 1; } + +chmod 600 "${SSH_KEY}" + +# ── 서버 접속 설정 ──────────────────────────────────────────────────────────── +SSH_OPTS="-i ${SSH_KEY} -p ${SSH_PORT} -o StrictHostKeyChecking=no -o ConnectTimeout=10" +# TARGET_HOST는 환경변수 또는 서버명으로부터 resolv (실제 IP는 Jenkins Credentials에서 관리) +TARGET_HOST="${TARGET_HOST:-${SERVER}}" +SSH_CMD="ssh ${SSH_OPTS} ${SSH_USER}@${TARGET_HOST}" + +# ── 앱 이름 추출 ────────────────────────────────────────────────────────────── +APP_NAME=$(basename "${ARTIFACT}" | sed 's/[-_][0-9].*//' | sed 's/\.\(jar\|war\|tar\.gz\)//') +DEPLOY_PATH="${DEPLOY_BASE_PATH}/${APP_NAME}" +ARTIFACT_FILENAME=$(basename "${ARTIFACT}") +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +BACKUP_PATH="${DEPLOY_PATH}/backups/${TIMESTAMP}" + +log "배포 정보:" +log " 서버: ${TARGET_HOST} (${DEPLOY_ENV})" +log " 아티팩트: ${ARTIFACT_FILENAME}" +log " 배포 경로: ${DEPLOY_PATH}" +log " 롤백: ${IS_ROLLBACK}" + +# ── 서버 디렉터리 준비 ──────────────────────────────────────────────────────── +prepare_remote_dirs() { + log "원격 디렉터리 준비..." + ${SSH_CMD} " + mkdir -p '${DEPLOY_PATH}/current' \ + '${DEPLOY_PATH}/releases' \ + '${DEPLOY_PATH}/backups' + " +} + +# ── 기존 배포 백업 ──────────────────────────────────────────────────────────── +backup_current() { + log "현재 배포 백업..." + ${SSH_CMD} " + if ls '${DEPLOY_PATH}/current'/*.jar \ + '${DEPLOY_PATH}/current'/*.war \ + '${DEPLOY_PATH}/current'/*.tar.gz 2>/dev/null | head -1 | grep -q .; then + mkdir -p '${BACKUP_PATH}' + cp '${DEPLOY_PATH}/current'/. '${BACKUP_PATH}/' 2>/dev/null || true + echo '백업 완료: ${BACKUP_PATH}' + else + echo '백업 대상 없음 (초기 배포)' + fi + + # 오래된 백업 정리 (최신 N개만 유지) + ls -dt '${DEPLOY_PATH}/backups'/*/ 2>/dev/null | \ + tail -n +$((${BACKUP_COUNT} + 1)) | \ + xargs rm -rf 2>/dev/null || true + " +} + +# ── 아티팩트 전송 (SCP) ─────────────────────────────────────────────────────── +transfer_artifact() { + log "아티팩트 전송 중: ${ARTIFACT_FILENAME}..." + local remote_releases="${DEPLOY_PATH}/releases/${TIMESTAMP}" + + ${SSH_CMD} "mkdir -p '${remote_releases}'" + + scp -i "${SSH_KEY}" \ + -P "${SSH_PORT}" \ + -o StrictHostKeyChecking=no \ + -o ConnectTimeout=10 \ + "${ARTIFACT}" \ + "${SSH_USER}@${TARGET_HOST}:${remote_releases}/${ARTIFACT_FILENAME}" + + # 체크섬 검증 (원본과 비교) + local local_sha + local_sha=$(sha256sum "${ARTIFACT}" | awk '{print $1}') + local remote_sha + remote_sha=$(${SSH_CMD} "sha256sum '${remote_releases}/${ARTIFACT_FILENAME}'" | awk '{print $1}') + + if [[ "${local_sha}" != "${remote_sha}" ]]; then + err "체크섬 불일치: 전송 오류" + err " 로컬: ${local_sha}" + err " 원격: ${remote_sha}" + exit 1 + fi + log "체크섬 검증 통과: ${local_sha:0:16}..." + + # current 심링크 업데이트 + ${SSH_CMD} " + ln -sfn '${remote_releases}' '${DEPLOY_PATH}/latest' + # current에 아티팩트 복사 + rm -f '${DEPLOY_PATH}/current'/*.jar \ + '${DEPLOY_PATH}/current'/*.war \ + '${DEPLOY_PATH}/current'/*.tar.gz 2>/dev/null || true + cp '${remote_releases}/${ARTIFACT_FILENAME}' '${DEPLOY_PATH}/current/' + " + log "배포 완료: ${DEPLOY_PATH}/current/${ARTIFACT_FILENAME}" +} + +# ── tar.gz 압축 해제 (Node.js 등) ──────────────────────────────────────────── +extract_if_needed() { + if [[ "${ARTIFACT_FILENAME}" == *.tar.gz ]]; then + log "압축 해제 중..." + ${SSH_CMD} " + cd '${DEPLOY_PATH}/current' + tar xzf '${ARTIFACT_FILENAME}' --overwrite + echo '압축 해제 완료' + " + fi +} + +# ── 배포 기록 ──────────────────────────────────────────────────────────────── +record_deploy() { + local build_num="${BUILD_NUMBER:-0}" + ${SSH_CMD} " + echo '${TIMESTAMP}|${ARTIFACT_FILENAME}|${build_num}|${IS_ROLLBACK}' >> \ + '${DEPLOY_PATH}/deploy-history.log' + " + log "배포 이력 기록 완료" +} + +# ── 메인 ───────────────────────────────────────────────────────────────────── +main() { + local start_time=$SECONDS + log "=== 배포 시작: $(date) ===" + + prepare_remote_dirs + backup_current + transfer_artifact + extract_if_needed + record_deploy + + local elapsed=$((SECONDS - start_time)) + log "=== 배포 완료: ${elapsed}초 ===" +} + +main "$@" diff --git a/cicd/scripts/pipeline/health_check.sh b/cicd/scripts/pipeline/health_check.sh new file mode 100644 index 0000000..522f7b1 --- /dev/null +++ b/cicd/scripts/pipeline/health_check.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# ============================================================================= +# 헬스체크 스크립트 (HTTP / TCP) +# Jenkins Stage 7: Health Check에서 호출 +# 사용: health_check.sh --url [--timeout 60] [--retries 3] [--interval 10] +# ============================================================================= +set -euo pipefail + +CHECK_URL="" +CHECK_TYPE="http" # http | tcp +TCP_HOST="" +TCP_PORT="" +TIMEOUT="${HEALTH_TIMEOUT:-60}" +RETRIES="${HEALTH_RETRIES:-3}" +INTERVAL="${HEALTH_INTERVAL:-10}" +EXPECT_STATUS="${EXPECT_STATUS:-200}" +LOG_DIR="${WORKSPACE:-/tmp}/build-logs" +HC_LOG="${LOG_DIR}/healthcheck-$(date +%Y%m%d-%H%M%S).log" + +GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; NC='\033[0m' +log() { echo -e "${GREEN}[HEALTH]${NC} $*" | tee -a "${HC_LOG}"; } +err() { echo -e "${RED}[ERROR]${NC} $*" | tee -a "${HC_LOG}"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*" | tee -a "${HC_LOG}"; } + +mkdir -p "${LOG_DIR}" + +while [[ $# -gt 0 ]]; do + case $1 in + --url) CHECK_URL="$2"; CHECK_TYPE="http"; shift 2 ;; + --host) TCP_HOST="$2"; CHECK_TYPE="tcp"; shift 2 ;; + --port) TCP_PORT="$2"; CHECK_TYPE="tcp"; shift 2 ;; + --timeout) TIMEOUT="$2"; shift 2 ;; + --retries) RETRIES="$2"; shift 2 ;; + --interval) INTERVAL="$2"; shift 2 ;; + --expect) EXPECT_STATUS="$2";shift 2 ;; + *) shift ;; + esac +done + +# ── HTTP 헬스체크 ──────────────────────────────────────────────────────────── +check_http() { + log "HTTP 헬스체크: ${CHECK_URL}" + log " 예상 상태코드: ${EXPECT_STATUS}" + log " 타임아웃: ${TIMEOUT}초 / 재시도: ${RETRIES}회" + + local attempt=0 + while [[ ${attempt} -lt ${RETRIES} ]]; do + attempt=$((attempt + 1)) + log " 시도 ${attempt}/${RETRIES}..." + + local http_code + local response_time_ms + local start_ns + start_ns=$(date +%s%N 2>/dev/null || echo 0) + + http_code=$(curl \ + --silent \ + --output /dev/null \ + --write-out "%{http_code}" \ + --max-time "${TIMEOUT}" \ + --connect-timeout 10 \ + --location \ + "${CHECK_URL}" 2>/dev/null || echo "000") + + local end_ns + end_ns=$(date +%s%N 2>/dev/null || echo 0) + if [[ "${start_ns}" != "0" ]]; then + response_time_ms=$(( (end_ns - start_ns) / 1000000 )) + else + response_time_ms="N/A" + fi + + log " 응답: HTTP ${http_code} (${response_time_ms}ms)" + + if [[ "${http_code}" == "${EXPECT_STATUS}" ]]; then + log "✅ 헬스체크 통과: HTTP ${http_code}" + return 0 + elif [[ "${http_code}" == "000" ]]; then + warn " 연결 실패 (타임아웃 또는 접속 거부)" + else + warn " 예상과 다른 응답: ${http_code} (예상: ${EXPECT_STATUS})" + fi + + if [[ ${attempt} -lt ${RETRIES} ]]; then + log " ${INTERVAL}초 후 재시도..." + sleep "${INTERVAL}" + fi + done + + err "헬스체크 실패: ${RETRIES}회 모두 실패" + return 1 +} + +# ── TCP 헬스체크 ──────────────────────────────────────────────────────────── +check_tcp() { + [[ -z "${TCP_HOST}" ]] && { err "--host 파라미터가 필요합니다"; exit 1; } + [[ -z "${TCP_PORT}" ]] && { err "--port 파라미터가 필요합니다"; exit 1; } + + log "TCP 헬스체크: ${TCP_HOST}:${TCP_PORT}" + + local attempt=0 + while [[ ${attempt} -lt ${RETRIES} ]]; do + attempt=$((attempt + 1)) + log " 시도 ${attempt}/${RETRIES}..." + + if timeout "${TIMEOUT}" bash -c \ + "echo >/dev/tcp/${TCP_HOST}/${TCP_PORT}" 2>/dev/null; then + log "✅ TCP 연결 성공: ${TCP_HOST}:${TCP_PORT}" + return 0 + fi + + warn " TCP 연결 실패: ${TCP_HOST}:${TCP_PORT}" + if [[ ${attempt} -lt ${RETRIES} ]]; then + log " ${INTERVAL}초 후 재시도..." + sleep "${INTERVAL}" + fi + done + + err "TCP 헬스체크 실패: ${RETRIES}회 모두 실패" + return 1 +} + +# ── 복합 헬스체크 ───────────────────────────────────────────────────────────── +check_composite() { + # HTTP + 추가 TCP 포트 확인 + check_http || return 1 + + if [[ -n "${TCP_HOST}" ]] && [[ -n "${TCP_PORT}" ]]; then + check_tcp || return 1 + fi + + return 0 +} + +# ── 메인 ───────────────────────────────────────────────────────────────────── +main() { + log "=== 헬스체크 시작: $(date) ===" + + case "${CHECK_TYPE}" in + http) + [[ -z "${CHECK_URL}" ]] && { err "--url 파라미터가 필요합니다"; exit 1; } + check_http + ;; + tcp) + check_tcp + ;; + composite) + check_composite + ;; + *) + err "알 수 없는 헬스체크 유형: ${CHECK_TYPE}" + exit 1 + ;; + esac + + log "=== 헬스체크 통과 ===" +} + +main "$@" diff --git a/cicd/scripts/pipeline/rollback.sh b/cicd/scripts/pipeline/rollback.sh new file mode 100644 index 0000000..6c67e73 --- /dev/null +++ b/cicd/scripts/pipeline/rollback.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# ============================================================================= +# 롤백 스크립트 +# WAS Restart 또는 Health Check 실패 시 자동 롤백 +# 사용: rollback.sh --server <서버명> --reason <사유> [--ssh-key ...] [--ssh-user ...] +# ============================================================================= +set -euo pipefail + +SERVER="" +SSH_KEY="${SSH_KEY:-}" +SSH_USER="${SSH_USER:-deploy}" +SSH_PORT="${SSH_PORT:-22}" +ROLLBACK_REASON="${1:-알 수 없는 이유}" +APP_NAME="${APP_NAME:-}" +LOG_DIR="${WORKSPACE:-/tmp}/build-logs" +ROLLBACK_LOG="${LOG_DIR}/rollback-$(date +%Y%m%d-%H%M%S).log" +DEPLOY_BASE_PATH="${DEPLOY_BASE_PATH:-/opt/apps}" + +GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; NC='\033[0m' +log() { echo -e "${GREEN}[ROLLBACK]${NC} $*" | tee -a "${ROLLBACK_LOG}"; } +err() { echo -e "${RED}[ERROR]${NC} $*" | tee -a "${ROLLBACK_LOG}"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*" | tee -a "${ROLLBACK_LOG}"; } + +mkdir -p "${LOG_DIR}" + +while [[ $# -gt 0 ]]; do + case $1 in + --server) SERVER="$2"; shift 2 ;; + --ssh-key) SSH_KEY="$2"; shift 2 ;; + --ssh-user) SSH_USER="$2"; shift 2 ;; + --reason) ROLLBACK_REASON="$2"; shift 2 ;; + --app) APP_NAME="$2"; shift 2 ;; + *) shift ;; + esac +done + +[[ -z "${SERVER}" ]] && { err "--server 파라미터가 필요합니다"; exit 1; } +[[ -z "${SSH_KEY}" ]] && { err "--ssh-key 또는 SSH_KEY 환경변수가 필요합니다"; exit 1; } + +chmod 600 "${SSH_KEY}" +TARGET_HOST="${TARGET_HOST:-${SERVER}}" +SSH_OPTS="-i ${SSH_KEY} -p ${SSH_PORT} -o StrictHostKeyChecking=no -o ConnectTimeout=10" +SSH_CMD="ssh ${SSH_OPTS} ${SSH_USER}@${TARGET_HOST}" + +log "⚠️ 롤백 시작" +log " 서버: ${TARGET_HOST}" +log " 사유: ${ROLLBACK_REASON}" + +# ── 앱 경로 결정 ──────────────────────────────────────────────────────────── +if [[ -z "${APP_NAME}" ]]; then + # 환경변수 또는 최근 배포 이력에서 앱 이름 추출 + APP_NAME=$(${SSH_CMD} " + ls '${DEPLOY_BASE_PATH}/' 2>/dev/null | head -1 || echo '' + " 2>/dev/null || echo "") + [[ -z "${APP_NAME}" ]] && { err "앱 이름을 확인할 수 없습니다 --app 파라미터를 사용하세요"; exit 1; } +fi + +DEPLOY_PATH="${DEPLOY_BASE_PATH}/${APP_NAME}" + +# ── 이전 백업 조회 ──────────────────────────────────────────────────────────── +log "이전 백업 버전 조회..." +BACKUP_VERSIONS=$(${SSH_CMD} " + ls -dt '${DEPLOY_PATH}/backups'/*/ 2>/dev/null | head -5 || echo '' +" 2>/dev/null || echo "") + +if [[ -z "${BACKUP_VERSIONS}" ]]; then + err "롤백 가능한 이전 버전이 없습니다" + exit 1 +fi + +PREV_BACKUP=$(echo "${BACKUP_VERSIONS}" | head -1) +log "롤백 대상: ${PREV_BACKUP}" + +# ── 롤백 실행 ──────────────────────────────────────────────────────────────── +log "현재 버전 임시 보관 후 이전 버전 복원..." +${SSH_CMD} " + # 현재를 'failed' 폴더로 이동 + FAILED_PATH='${DEPLOY_PATH}/failed_\$(date +%Y%m%d_%H%M%S)' + mkdir -p \"\${FAILED_PATH}\" + cp -r '${DEPLOY_PATH}/current/'. \"\${FAILED_PATH}/\" 2>/dev/null || true + + # 이전 버전 복원 + rm -f '${DEPLOY_PATH}/current'/*.jar \ + '${DEPLOY_PATH}/current'/*.war \ + '${DEPLOY_PATH}/current'/*.tar.gz 2>/dev/null || true + + cp -r '${PREV_BACKUP}'. '${DEPLOY_PATH}/current/' 2>/dev/null || true + + ls '${DEPLOY_PATH}/current/' + echo '롤백 파일 복원 완료' +" + +# ── WAS 재기동 ──────────────────────────────────────────────────────────────── +WAS_TYPE="${WAS_TYPE:-tomcat}" +log "WAS 재기동: ${WAS_TYPE}" +${SSH_CMD} " + for s in tomcat9 tomcat10 tomcat jboss wildfly; do + if systemctl is-active --quiet \"\${s}\" 2>/dev/null; then + sudo systemctl restart \"\${s}\" + echo \"WAS 재기동 완료: \${s}\" + break + fi + done +" + +# ── 롤백 이력 기록 ──────────────────────────────────────────────────────────── +${SSH_CMD} " + echo \"\$(date '+%Y-%m-%d %H:%M:%S')|ROLLBACK|${ROLLBACK_REASON}|${PREV_BACKUP}\" >> \ + '${DEPLOY_PATH}/deploy-history.log' +" + +log "✅ 롤백 완료" +log " 복원 버전: ${PREV_BACKUP}" +log " 롤백 사유: ${ROLLBACK_REASON}" diff --git a/cicd/scripts/pipeline/test.sh b/cicd/scripts/pipeline/test.sh new file mode 100644 index 0000000..21bab09 --- /dev/null +++ b/cicd/scripts/pipeline/test.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# ============================================================================= +# 테스트 공통 스크립트 +# Jenkins Stage 3: Test에서 호출 +# 사용: test.sh [unit|sonar|all] +# ============================================================================= +set -euo pipefail + +TEST_TYPE="${1:-unit}" +BUILD_TOOL="${BUILD_TOOL:-auto}" +APP_VERSION="${APP_VERSION:-dev-0}" +WORKSPACE="${WORKSPACE:-.}" +LOG_DIR="${WORKSPACE}/build-logs" +TEST_LOG="${LOG_DIR}/test-$(date +%Y%m%d-%H%M%S).log" + +GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; NC='\033[0m' +log() { echo -e "${GREEN}[TEST]${NC} $*" | tee -a "${TEST_LOG}"; } +err() { echo -e "${RED}[ERROR]${NC} $*" | tee -a "${TEST_LOG}"; } + +mkdir -p "${LOG_DIR}" + +# ── 빌드 도구 자동 감지 ────────────────────────────────────────────────────── +detect_build_tool() { + [[ "${BUILD_TOOL}" != "auto" ]] && return + if [[ -f "${WORKSPACE}/pom.xml" ]]; then BUILD_TOOL="maven" + elif [[ -f "${WORKSPACE}/build.gradle" ]]; then BUILD_TOOL="gradle" + elif [[ -f "${WORKSPACE}/package.json" ]]; then BUILD_TOOL="npm" + else err "빌드 파일을 찾을 수 없습니다"; exit 1; fi + log "빌드 도구: ${BUILD_TOOL}" +} + +# ── 단위 테스트 ─────────────────────────────────────────────────────────────── +run_unit_tests() { + log "단위 테스트 실행..." + cd "${WORKSPACE}" + case "${BUILD_TOOL}" in + maven) + MVN_CMD="mvn"; [[ -f "./mvnw" ]] && MVN_CMD="./mvnw" + ${MVN_CMD} test \ + --batch-mode --no-transfer-progress \ + -Dapp.version="${APP_VERSION}" \ + 2>&1 | tee -a "${TEST_LOG}" + ;; + gradle) + GRADLE_CMD="gradle"; [[ -f "./gradlew" ]] && GRADLE_CMD="./gradlew" + ${GRADLE_CMD} test \ + -Pversion="${APP_VERSION}" \ + --no-daemon \ + 2>&1 | tee -a "${TEST_LOG}" + ;; + npm) + npm test -- --coverage --watchAll=false 2>&1 | tee -a "${TEST_LOG}" + ;; + esac + log "단위 테스트 완료" +} + +# ── SonarQube 분석 ──────────────────────────────────────────────────────────── +run_sonar() { + log "SonarQube 분석 실행..." + cd "${WORKSPACE}" + case "${BUILD_TOOL}" in + maven) + MVN_CMD="mvn"; [[ -f "./mvnw" ]] && MVN_CMD="./mvnw" + ${MVN_CMD} sonar:sonar \ + -Dsonar.projectVersion="${APP_VERSION}" \ + --batch-mode --no-transfer-progress \ + 2>&1 | tee -a "${TEST_LOG}" + ;; + gradle) + GRADLE_CMD="gradle"; [[ -f "./gradlew" ]] && GRADLE_CMD="./gradlew" + ${GRADLE_CMD} sonar \ + -Dsonar.projectVersion="${APP_VERSION}" \ + --no-daemon \ + 2>&1 | tee -a "${TEST_LOG}" + ;; + npm) + if [[ -f "sonar-project.properties" ]]; then + sonar-scanner \ + -Dsonar.projectVersion="${APP_VERSION}" \ + 2>&1 | tee -a "${TEST_LOG}" + else + log "SonarQube 설정 파일 없음 (sonar-project.properties) — 건너뜀" + fi + ;; + esac + log "SonarQube 분석 완료" +} + +# ── 메인 ───────────────────────────────────────────────────────────────────── +main() { + log "=== 테스트 시작: ${TEST_TYPE} ===" + detect_build_tool + case "${TEST_TYPE}" in + unit) run_unit_tests ;; + sonar) run_sonar ;; + all) run_unit_tests; run_sonar ;; + *) err "알 수 없는 테스트 유형: ${TEST_TYPE}"; exit 1 ;; + esac + log "=== 테스트 완료 ===" +} + +main "$@" diff --git a/cicd/scripts/pipeline/was_restart.sh b/cicd/scripts/pipeline/was_restart.sh new file mode 100644 index 0000000..547a58a --- /dev/null +++ b/cicd/scripts/pipeline/was_restart.sh @@ -0,0 +1,215 @@ +#!/usr/bin/env bash +# ============================================================================= +# WAS 재기동 스크립트 +# Jenkins Stage 6: WAS Restart에서 호출 +# 지원: Tomcat, JBoss/WildFly, JEUS, WebLogic, Node.js(pm2/systemd) +# ============================================================================= +set -euo pipefail + +SERVER="" +SSH_KEY="" +SSH_USER="deploy" +SSH_PORT="${SSH_PORT:-22}" +WAS_TYPE="${WAS_TYPE:-tomcat}" +WAS_SERVICE="${WAS_SERVICE:-}" # systemd 서비스명 (빈칸이면 자동 감지) +STARTUP_WAIT="${STARTUP_WAIT:-60}" # 기동 대기 시간 (초) +LOG_DIR="${WORKSPACE:-/tmp}/build-logs" +RESTART_LOG="${LOG_DIR}/was-restart-$(date +%Y%m%d-%H%M%S).log" + +GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; NC='\033[0m' +log() { echo -e "${GREEN}[WAS]${NC} $*" | tee -a "${RESTART_LOG}"; } +err() { echo -e "${RED}[ERROR]${NC} $*" | tee -a "${RESTART_LOG}"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*" | tee -a "${RESTART_LOG}"; } + +mkdir -p "${LOG_DIR}" + +while [[ $# -gt 0 ]]; do + case $1 in + --server) SERVER="$2"; shift 2 ;; + --ssh-key) SSH_KEY="$2"; shift 2 ;; + --ssh-user) SSH_USER="$2"; shift 2 ;; + --was-type) WAS_TYPE="$2"; shift 2 ;; + --service) WAS_SERVICE="$2"; shift 2 ;; + --wait) STARTUP_WAIT="$2"; shift 2 ;; + *) shift ;; + esac +done + +[[ -z "${SERVER}" ]] && { err "--server 파라미터가 필요합니다"; exit 1; } +[[ -z "${SSH_KEY}" ]] && { err "--ssh-key 파라미터가 필요합니다"; exit 1; } + +chmod 600 "${SSH_KEY}" +TARGET_HOST="${TARGET_HOST:-${SERVER}}" +SSH_OPTS="-i ${SSH_KEY} -p ${SSH_PORT} -o StrictHostKeyChecking=no -o ConnectTimeout=10" +SSH_CMD="ssh ${SSH_OPTS} ${SSH_USER}@${TARGET_HOST}" + +log "WAS 재기동: ${WAS_TYPE} @ ${TARGET_HOST}" + +# ── Tomcat ──────────────────────────────────────────────────────────────────── +restart_tomcat() { + local service="${WAS_SERVICE:-tomcat9}" + # 서비스명 자동 감지 (tomcat9 / tomcat10 / tomcat) + local actual_service + actual_service=$(${SSH_CMD} " + for s in tomcat9 tomcat10 tomcat; do + systemctl list-units --type=service | grep -q \"\$s\" && echo \"\$s\" && break + done + " 2>/dev/null || echo "${service}") + + log "Tomcat 서비스 재기동: ${actual_service}" + ${SSH_CMD} "sudo systemctl restart '${actual_service}'" + + # 기동 확인 + local elapsed=0 + while [[ ${elapsed} -lt ${STARTUP_WAIT} ]]; do + if ${SSH_CMD} "sudo systemctl is-active --quiet '${actual_service}'" 2>/dev/null; then + log "Tomcat 기동 완료 (${elapsed}초)" + return 0 + fi + sleep 3 + elapsed=$((elapsed + 3)) + done + err "Tomcat 기동 타임아웃 (${STARTUP_WAIT}초)" + ${SSH_CMD} "sudo systemctl status '${actual_service}'" 2>&1 | tee -a "${RESTART_LOG}" || true + exit 1 +} + +# ── JBoss / WildFly ──────────────────────────────────────────────────────────── +restart_jboss() { + local service="${WAS_SERVICE:-jboss}" + local actual_service + actual_service=$(${SSH_CMD} " + for s in jboss wildfly eap7 jboss-eap; do + systemctl list-units --type=service | grep -q \"\$s\" && echo \"\$s\" && break + done + " 2>/dev/null || echo "${service}") + + log "JBoss/WildFly 서비스 재기동: ${actual_service}" + ${SSH_CMD} "sudo systemctl restart '${actual_service}'" + + local elapsed=0 + while [[ ${elapsed} -lt ${STARTUP_WAIT} ]]; do + if ${SSH_CMD} "sudo systemctl is-active --quiet '${actual_service}'" 2>/dev/null; then + log "JBoss 기동 완료 (${elapsed}초)" + return 0 + fi + sleep 5 + elapsed=$((elapsed + 5)) + done + err "JBoss 기동 타임아웃" + exit 1 +} + +# ── JEUS ────────────────────────────────────────────────────────────────────── +restart_jeus() { + local jeus_home="${JEUS_HOME:-/opt/jeus8}" + local domain="${JEUS_DOMAIN:-domain1}" + local server="${JEUS_SERVER:-server1}" + local admin_port="${JEUS_ADMIN_PORT:-9736}" + + log "JEUS 서버 재기동: ${domain}/${server}" + ${SSH_CMD} " + export JEUS_HOME='${jeus_home}' + export PATH=\"\${JEUS_HOME}/bin:\${PATH}\" + + # 서버 중지 + jeusadmin -host localhost:${admin_port} -u administrator \ + 'stopServer ${server}' 2>/dev/null || true + + sleep 5 + + # 서버 시작 + startManagedServer -domain '${domain}' -server '${server}' & + " + + local elapsed=0 + while [[ ${elapsed} -lt ${STARTUP_WAIT} ]]; do + if ${SSH_CMD} " + export JEUS_HOME='${jeus_home}' + jeusadmin -host localhost:${admin_port} -u administrator \ + 'serverinfo ${server}' 2>/dev/null | grep -q 'RUNNING' + " 2>/dev/null; then + log "JEUS 기동 완료 (${elapsed}초)" + return 0 + fi + sleep 5 + elapsed=$((elapsed + 5)) + done + err "JEUS 기동 타임아웃" + exit 1 +} + +# ── WebLogic ────────────────────────────────────────────────────────────────── +restart_weblogic() { + local wl_home="${WL_HOME:-/opt/oracle/wlserver}" + local domain_home="${WL_DOMAIN_HOME:-/opt/oracle/domains/base_domain}" + local server="${WL_SERVER:-ManagedServer1}" + + log "WebLogic 서버 재기동: ${server}" + ${SSH_CMD} " + # 서버 중지 + '${domain_home}/bin/stopWebLogic.sh' '${server}' 2>/dev/null || true + sleep 5 + # 서버 시작 + nohup '${domain_home}/bin/startManagedWebLogic.sh' \ + '${server}' 'http://localhost:7001' & + " + + local elapsed=0 + while [[ ${elapsed} -lt ${STARTUP_WAIT} ]]; do + if ${SSH_CMD} " + curl -sf 'http://localhost:7001/management/tenant-monitoring/servers/${server}' \ + 2>/dev/null | grep -q '\"state\":\"RUNNING\"' + " 2>/dev/null; then + log "WebLogic 기동 완료 (${elapsed}초)" + return 0 + fi + sleep 5 + elapsed=$((elapsed + 5)) + done + err "WebLogic 기동 타임아웃" + exit 1 +} + +# ── Node.js (pm2 또는 systemd) ──────────────────────────────────────────────── +restart_nodejs() { + local service="${WAS_SERVICE:-}" + local app_name="${NODE_APP_NAME:-app}" + + log "Node.js 재기동..." + ${SSH_CMD} " + if command -v pm2 &>/dev/null; then + pm2 restart '${app_name}' || pm2 reload '${app_name}' + elif systemctl list-units --type=service | grep -q '${service:-nodejs}'; then + sudo systemctl restart '${service:-nodejs}' + else + echo '재기동 방법을 찾을 수 없습니다. PM2 또는 systemd 서비스가 필요합니다.' + exit 1 + fi + " + sleep 3 + log "Node.js 재기동 완료" +} + +# ── 메인 ───────────────────────────────────────────────────────────────────── +main() { + log "=== WAS 재기동 시작: $(date) ===" + case "${WAS_TYPE,,}" in + tomcat|tomcat9|tomcat10) restart_tomcat ;; + jboss|wildfly|eap) restart_jboss ;; + jeus|jeus8|jeus9) restart_jeus ;; + weblogic|wls) restart_weblogic ;; + nodejs|node|pm2) restart_nodejs ;; + systemd) + log "systemd 서비스 재기동: ${WAS_SERVICE}" + ${SSH_CMD} "sudo systemctl restart '${WAS_SERVICE}'" + ;; + *) + err "지원하지 않는 WAS 종류: ${WAS_TYPE}" + exit 1 + ;; + esac + log "=== WAS 재기동 완료 ===" +} + +main "$@" diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/agents.py b/core/agents.py new file mode 100644 index 0000000..3a93b14 --- /dev/null +++ b/core/agents.py @@ -0,0 +1,860 @@ +""" +GUARDiA ITSM — AI 에이전트 엔진 (Paperclip 스타일) + +하트비트 기반 자율 운영 에이전트: + INCIDENT_TRIAGE : 미배정 인시던트 자동 분류·배정 + KB_CURATOR : 해결된 SR → 지식베이스 자동 생성 + SSL_WATCHER : SSL 만료 임박 → SR 자동 생성 + WBS_MONITOR : WBS 지연 위험 LLM 분석 + PM_SUGGESTER : PM 스케줄 미등록 서버 탐지 + +보안: + - 외부 API 호출 없음 (Ollama localhost:11434만 사용) + - CRITICAL 액션은 AgentApproval(PENDING) 생성 → 사람 승인 필요 + - 저위험 액션은 AUTO_APPROVED로 즉시 적용 +""" +from __future__ import annotations + +import json +import logging +from datetime import date, datetime +from typing import Optional +from uuid import uuid4 + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_ + +logger = logging.getLogger(__name__) + + +# ── 에이전트 엔진 ──────────────────────────────────────────────────────────── + +class AgentEngine: + """ + Paperclip 스타일 에이전트 실행 엔진. + scheduler.py의 하트비트 잡에서 호출된다. + """ + + def __init__(self) -> None: + from core.llm_client import get_llm_client + self.llm = get_llm_client() + + # ── 공통 실행 진입점 ───────────────────────────────────────────────────── + + async def run_heartbeat(self, agent_id: int) -> None: + """에이전트 하트비트 — scheduler.py에서 호출.""" + from database import SessionLocal + from models import AgentConfig, AgentStatus, AgentRole + from sqlalchemy import select + + # 에이전트 상태 ACTIVE로 변경 + async with SessionLocal() as db: + agent = (await db.execute( + select(AgentConfig).where(AgentConfig.id == agent_id) + )).scalars().first() + + if not agent or not agent.is_active: + return + + if not await self.llm.health_check(): + logger.warning("[Agent %d] Ollama 응답 없음 — 하트비트 스킵", agent_id) + return + + agent.status = AgentStatus.ACTIVE + agent.last_heartbeat = datetime.now() + await db.commit() + + # 역할별 핸들러 매핑 + handlers = { + AgentRole.INCIDENT_TRIAGE: self._incident_triage, + AgentRole.KB_CURATOR: self._kb_curator, + AgentRole.SSL_WATCHER: self._ssl_watcher, + AgentRole.WBS_MONITOR: self._wbs_monitor, + AgentRole.PM_SUGGESTER: self._pm_suggester, + AgentRole.DEVELOPER: self._developer_agent, + } + + try: + async with SessionLocal() as db: + agent = (await db.execute( + select(AgentConfig).where(AgentConfig.id == agent_id) + )).scalars().first() + if not agent: + return + + agent.status = AgentStatus.WORKING + await db.flush() + + handler = handlers.get(agent.role) + if handler: + await handler(db, agent) + + agent.status = AgentStatus.IDLE + await db.commit() + + except Exception as exc: + logger.error("[Agent %d] 하트비트 오류: %s", agent_id, exc, exc_info=True) + async with SessionLocal() as db: + a = (await db.execute( + select(AgentConfig).where(AgentConfig.id == agent_id) + )).scalars().first() + if a: + a.status = AgentStatus.ERROR + a.last_error = str(exc)[:500] + await db.commit() + + # ── 헬퍼 ───────────────────────────────────────────────────────────────── + + def _new_task(self, agent_id: int, title: str, input_data: dict | None = None): + from models import AgentTask, AgentTaskStatus + return AgentTask( + agent_id=agent_id, + title=title, + status=AgentTaskStatus.IN_PROGRESS, + input_data=input_data or {}, + started_at=datetime.now(), + ) + + def _approval( + self, + agent_id: int, + task_id: int | None, + action_type: str, + action_data: dict, + auto: bool = False, + ): + from models import AgentApproval, AgentApprovalStatus + return AgentApproval( + agent_id=agent_id, + task_id=task_id, + action_type=action_type, + action_data=action_data, + status=( + AgentApprovalStatus.AUTO_APPROVED + if auto + else AgentApprovalStatus.PENDING + ), + ) + + # ══════════════════════════════════════════════════════════════════════════ + # ── 1. 인시던트 트리아지 ──────────────────────────────────────────────── + # ══════════════════════════════════════════════════════════════════════════ + + async def _incident_triage(self, db: AsyncSession, agent) -> None: + """ + 미배정 인시던트 → LLM 심각도·카테고리 분류 → 자동 배정. + CRITICAL은 사람 승인 대기, 나머지는 자동 적용. + """ + from models import Incident, IncidentStatus, AgentTask, AgentTaskStatus + + incidents = (await db.execute( + select(Incident).where( + Incident.assigned_to.is_(None), + Incident.status.in_(["OPEN", "RECEIVED"]), + ).limit(10) + )).scalars().all() + + if not incidents: + logger.debug("[INCIDENT_TRIAGE] 처리 대상 없음") + return + + system_prompt = ( + agent.system_prompt + or "당신은 ITSM 인시던트 트리아지 전문가입니다. 인시던트를 정확히 분류하세요." + ) + + for inc in incidents: + task = self._new_task( + agent.id, + f"인시던트 트리아지 #{inc.incident_id}", + {"incident_id": inc.id, "title": inc.title}, + ) + db.add(task) + await db.flush() + + result = await self.llm.json_generate( + prompt=( + f"인시던트를 분류하세요.\n\n" + f"제목: {inc.title}\n" + f"설명: {inc.description or '없음'}\n" + f"서비스: {inc.affected_service or '미지정'}\n\n" + f"응답 JSON:\n" + f'{{"severity":"CRITICAL|HIGH|MEDIUM|LOW",' + f'"category":"NETWORK|SERVER|APPLICATION|DATABASE|SECURITY|OTHER",' + f'"reason":"분류 근거 (50자 이내)"}}' + ), + model=agent.llm_model, + system=system_prompt, + ) + + severity = result.get("severity", "MEDIUM") + is_critical = severity == "CRITICAL" + + task.status = AgentTaskStatus.COMPLETED + task.output_data = result + task.tokens_used = 0 + task.completed_at = datetime.now() + + approval = self._approval( + agent_id=agent.id, + task_id=task.id, + action_type="TRIAGE_INCIDENT", + action_data={ + "incident_id": inc.id, + "incident_ref": inc.incident_id, + "severity": severity, + "category": result.get("category", "OTHER"), + "reason": result.get("reason", ""), + }, + auto=not is_critical, # CRITICAL만 사람 승인 + ) + db.add(approval) + + # 자동 승인 액션 즉시 적용 + if not is_critical: + from models import IncidentGrade + grade_map = { + "CRITICAL": IncidentGrade.CRITICAL, + "HIGH": IncidentGrade.HIGH, + "MEDIUM": IncidentGrade.MEDIUM, + "LOW": IncidentGrade.LOW, + } + inc.grade = grade_map.get(severity, IncidentGrade.MEDIUM) + + # 에이전트 통계 업데이트 + agent.total_tasks += 1 + + await db.commit() + logger.info("[INCIDENT_TRIAGE] %d건 처리 완료", len(incidents)) + + # ── RCA 자동 초안 생성 (RESOLVED 상태, rca_draft 없는 인시던트) ────── + await self._generate_rca_drafts(db, agent) + + # ── RCA 자동 초안 생성 헬퍼 ───────────────────────────────────────────── + + async def _generate_rca_drafts(self, db: AsyncSession, agent) -> None: + """ + RESOLVED 장애 중 rca_draft 가 없는 항목에 대해 Ollama로 RCA 문서 초안을 자동 생성. + 생성된 초안은 AgentApproval(PENDING) 을 통해 담당자 검토 후 확정한다. + 외부 API 없음 — 모든 LLM 추론은 Ollama localhost:11434. + """ + from models import Incident, IncidentStatus, AgentTask, AgentTaskStatus, AgentApproval + + resolved = (await db.execute( + select(Incident).where( + Incident.status == IncidentStatus.RESOLVED, + Incident.rca_draft.is_(None), + ).limit(5) + )).scalars().all() + + if not resolved: + return + + for inc in resolved: + try: + rca = await self.llm.json_generate( + prompt=( + f"장애 인시던트에 대한 RCA(근본 원인 분석) 초안을 작성하세요.\n\n" + f"제목: {inc.title}\n" + f"설명: {inc.description or '없음'}\n" + f"등급: {inc.grade}\n" + f"발생 시각: {inc.occurred_at}\n" + f"해소 시각: {inc.resolved_at}\n\n" + f"응답 JSON (한국어로 작성):\n" + f'{{"root_cause":"근본 원인 (2~3문장)",' + f'"timeline":"사건 타임라인",' + f'"impact":"영향 범위 (서비스/사용자 수)",' + f'"resolution":"해결 과정",' + f'"preventive_measures":"재발 방지 조치 (3가지 이상)",' + f'"lessons_learned":"교훈"}}' + ), + model=agent.llm_model, + system=( + agent.system_prompt + or "당신은 IT 인프라 장애 분석 전문가입니다. 정확하고 실용적인 RCA를 작성하세요." + ), + ) + + if not rca or not rca.get("root_cause"): + continue + + inc.rca_draft = json.dumps(rca, ensure_ascii=False) + await db.flush() + + # 담당자 검토 필요 — PENDING 승인 생성 + task = self._new_task( + agent.id, + f"RCA 초안 생성: {inc.incident_id}", + {"incident_id": inc.id, "incident_ref": inc.incident_id}, + ) + db.add(task) + await db.flush() + task.status = "COMPLETED" + task.output_data = rca + task.completed_at = datetime.now() + + db.add(self._approval( + agent_id=agent.id, + task_id=task.id, + action_type="RCA_DRAFT", + action_data={"incident_id": inc.id, "incident_ref": inc.incident_id}, + auto=False, # 항상 사람 검토 필요 + )) + agent.total_tasks += 1 + + except Exception as exc: + logger.warning("[RCA_DRAFT] 인시던트 %s 처리 실패: %s", inc.incident_id, exc) + + await db.commit() + logger.info("[RCA_DRAFT] %d건 초안 생성 완료", len(resolved)) + + # ══════════════════════════════════════════════════════════════════════════ + # ── 2. KB 큐레이터 ────────────────────────────────────────────────────── + # ══════════════════════════════════════════════════════════════════════════ + + async def _kb_curator(self, db: AsyncSession, agent) -> None: + """ + 해결된 SR 중 KB 미생성 항목 → LLM으로 KB 아티클 자동 생성. + 생성된 KB는 is_published=False(검토 대기)로 저장. + """ + from models import ( + SRRequest, SRStatus, KBDocument, + AgentTask, AgentTaskStatus, AgentApproval, + ) + + # 해결된 SR 중 이미 KB로 변환된 것 제외 + # (AgentApproval에서 같은 SR에 대한 CREATE_KB 이력 확인) + existing_kb_sr_ids = [ + row[0] + for row in (await db.execute( + select(AgentApproval.action_data["sr_id"].as_string()) + .where(AgentApproval.action_type == "CREATE_KB") + )).all() + if row[0] is not None + ] + + srs = (await db.execute( + select(SRRequest).where( + SRRequest.status == SRStatus.COMPLETED, + SRRequest.id.notin_([int(x) for x in existing_kb_sr_ids if x.isdigit()]), + ).limit(5) + )).scalars().all() + + if not srs: + logger.debug("[KB_CURATOR] 처리 대상 없음") + return + + system_prompt = ( + agent.system_prompt + or "당신은 IT 지식베이스 전문 에디터입니다. 체계적인 KB 아티클을 작성하세요." + ) + + for sr in srs: + task = self._new_task( + agent.id, + f"KB 자동 생성: SR #{sr.sr_id}", + {"sr_id": sr.id, "title": sr.title}, + ) + db.add(task) + await db.flush() + + result = await self.llm.json_generate( + prompt=( + f"해결된 서비스 요청으로 지식베이스 아티클을 작성하세요.\n\n" + f"SR 제목: {sr.title}\n" + f"SR 유형: {sr.sr_type}\n" + f"설명: {sr.description or '없음'}\n\n" + f"응답 JSON:\n" + f'{{"kb_title":"검색 가능한 KB 제목",' + f'"symptom":"증상 설명 (2~3문장)",' + f'"cause":"원인 분석 (2~3문장)",' + f'"solution":"해결 방법 (단계별)",' + f'"tags":["태그1","태그2","태그3"]}}' + ), + model=agent.llm_model, + system=system_prompt, + ) + + if not result or not result.get("kb_title"): + task.status = AgentTaskStatus.FAILED + task.output_data = {"error": "LLM 응답 파싱 실패"} + task.completed_at = datetime.now() + continue + + # KB 문서 생성 (검토 대기 상태) + kb = KBDocument( + doc_id=f"KB-{datetime.now().strftime('%Y%m%d')}-{str(uuid4())[:6].upper()}", + category=sr.sr_type or "OTHER", + title=result.get("kb_title", sr.title), + symptoms=result.get("symptom", ""), + cause=result.get("cause", ""), + solution=result.get("solution", ""), + tags=",".join(result.get("tags", [])), + sr_type=sr.sr_type, + ) + db.add(kb) + await db.flush() + + # ── KB 품질 자동 평가 ─────────────────────────────────────────── + quality = await self.llm.json_generate( + prompt=( + f"아래 KB 아티클의 품질을 평가하세요.\n\n" + f"제목: {kb.title}\n" + f"증상: {kb.symptoms}\n" + f"원인: {kb.cause}\n" + f"해결책: {kb.solution}\n\n" + f"응답 JSON (0~100 점수):\n" + f'{{"completeness":점수,"clarity":점수,"actionability":점수,"overall":점수}}' + ), + model=agent.llm_model, + ) + + overall = int(quality.get("overall", 0)) if quality else 0 + kb.quality_score = min(max(overall, 0), 100) + + if kb.quality_score >= 80: + kb.published = True # 자동 발행 + auto_approved = True + logger.info("[KB_CURATOR] KB %s 자동 발행 (품질 %d점)", kb.doc_id, kb.quality_score) + else: + kb.published = False + auto_approved = False + logger.info("[KB_CURATOR] KB %s 수동 검토 필요 (품질 %d점)", kb.doc_id, kb.quality_score) + # ──────────────────────────────────────────────────────────────── + + task.status = AgentTaskStatus.COMPLETED + task.output_data = {**result, "kb_doc_id": kb.doc_id, "quality_score": kb.quality_score} + task.completed_at = datetime.now() + + db.add(self._approval( + agent_id=agent.id, + task_id=task.id, + action_type="CREATE_KB", + action_data={"sr_id": sr.id, "kb_doc_id": kb.doc_id, "quality_score": kb.quality_score}, + auto=auto_approved, + )) + agent.total_tasks += 1 + + await db.commit() + logger.info("[KB_CURATOR] %d건 처리 완료", len(srs)) + + # ══════════════════════════════════════════════════════════════════════════ + # ── 3. SSL 감시자 ──────────────────────────────────────────────────────── + # ══════════════════════════════════════════════════════════════════════════ + + async def _ssl_watcher(self, db: AsyncSession, agent) -> None: + """ + SSL 만료 D-30 이내 서버 탐지 → SR 자동 생성. + D-7 이하: HIGH 우선순위, 사람 승인 없이 즉시 생성. + """ + from models import Server, SRRequest, SRStatus, SRType, AgentTask, AgentTaskStatus + + today = date.today() + servers = (await db.execute( + select(Server).where(Server.ssl_expire_date.isnot(None)) + )).scalars().all() + + created = 0 + for srv in servers: + days_left = (srv.ssl_expire_date - today).days + if not (0 <= days_left <= 30): + continue + + # 이미 미완료 SSL 갱신 SR 있는지 확인 + existing = (await db.execute( + select(SRRequest).where( + SRRequest.title.contains(f"SSL 갱신 [{srv.server_name}]"), + SRRequest.status.notin_([SRStatus.COMPLETED, SRStatus.REJECTED]), + ) + )).scalars().first() + if existing: + continue + + priority = "HIGH" if days_left <= 7 else "MEDIUM" + sr = SRRequest( + sr_id=f"SR-{datetime.now().strftime('%Y%m%d')}-{str(uuid4())[:5].upper()}", + title=f"SSL 갱신 [{srv.server_name}] D-{days_left}", + description=( + f"🤖 AI 에이전트(SSL 감시자)가 자동 생성한 서비스 요청입니다.\n\n" + f"서버명: {srv.server_name}\n" + f"만료일: {srv.ssl_expire_date.isoformat()}\n" + f"남은 기간: D-{days_left}\n\n" + f"조치: SSL 인증서 갱신 또는 교체 진행 필요" + ), + sr_type=SRType.OTHER, + status=SRStatus.RECEIVED, + priority=priority, + requested_by=f"agent:{agent.id}", + ) + db.add(sr) + await db.flush() + + task = self._new_task( + agent.id, + f"SSL 만료 감지: {srv.server_name} D-{days_left}", + {"server_id": srv.id, "days_left": days_left}, + ) + task.status = AgentTaskStatus.COMPLETED + task.output_data = {"sr_id": sr.id, "priority": priority} + task.completed_at = datetime.now() + db.add(task) + await db.flush() + + db.add(self._approval( + agent_id=agent.id, task_id=task.id, + action_type="CREATE_SR", + action_data={"sr_db_id": sr.id, "server_id": srv.id, "days_left": days_left}, + auto=True, + )) + agent.total_tasks += 1 + created += 1 + + if created: + await db.commit() + logger.info("[SSL_WATCHER] SSL 만료 SR %d건 자동 생성", created) + + # ══════════════════════════════════════════════════════════════════════════ + # ── 4. WBS 모니터 ──────────────────────────────────────────────────────── + # ══════════════════════════════════════════════════════════════════════════ + + async def _wbs_monitor(self, db: AsyncSession, agent) -> None: + """ + 지연 WBS가 있는 SI 프로젝트 → LLM 위험 분석 → ProjectRisk 자동 생성. + WBS 지연이 3건 이상이거나 10일 이상 초과 시 위험 생성. + """ + from models import ( + WbsItem, WbsStatus, SiProject, ProjectRisk, + RiskStatus, AgentTask, AgentTaskStatus, ProjectPhase, + ) + + today = date.today() + + projects = (await db.execute( + select(SiProject).where( + SiProject.is_active.is_(True), + SiProject.phase.notin_([ProjectPhase.CLOSED]), + ) + )).scalars().all() + + for proj in projects: + delayed = (await db.execute( + select(WbsItem).where( + WbsItem.project_id == proj.id, + WbsItem.is_leaf.is_(True), + WbsItem.planned_end < today, + WbsItem.completion_pct < 100, + WbsItem.status != WbsStatus.CANCELLED, + ) + )).scalars().all() + + if not delayed: + continue + + max_delay = max((today - w.planned_end).days for w in delayed) + if len(delayed) < 3 and max_delay < 10: + continue # 경미한 지연은 스킵 + + # 이미 AI 생성 위험이 있으면 스킵 (중복 방지) + existing_risk = (await db.execute( + select(ProjectRisk).where( + ProjectRisk.project_id == proj.id, + ProjectRisk.raised_by.startswith("agent:"), + ProjectRisk.status.notin_(["CLOSED", "ACCEPTED"]), + ) + )).scalars().first() + if existing_risk: + continue + + total_wbs = (await db.execute( + select(WbsItem).where( + WbsItem.project_id == proj.id, + WbsItem.is_leaf.is_(True), + ) + )).scalars().all() + avg_pct = ( + sum(w.completion_pct for w in total_wbs) / len(total_wbs) + if total_wbs else 0 + ) + + task = self._new_task( + agent.id, + f"WBS 위험 분석: {proj.project_name}", + {"project_id": proj.id, "delayed_count": len(delayed), "max_delay_days": max_delay}, + ) + db.add(task) + await db.flush() + + result = await self.llm.json_generate( + prompt=( + f"SI 프로젝트 WBS 현황을 분석하고 위험을 평가하세요.\n\n" + f"프로젝트: {proj.project_name}\n" + f"전체 WBS: {len(total_wbs)}건 | 지연: {len(delayed)}건 | 최대 지연: {max_delay}일\n" + f"평균 진척률: {avg_pct:.1f}%\n\n" + f"응답 JSON:\n" + f'{{"risk_level":"CRITICAL|HIGH|MEDIUM|LOW",' + f'"probability":1~3,"impact":1~3,' + f'"title":"위험 제목 (30자 이내)",' + f'"description":"위험 상세 (100자 이내)",' + f'"mitigation":"완화 방안 (100자 이내)"}}' + ), + model=agent.llm_model, + system=agent.system_prompt or "당신은 SI 프로젝트 관리 전문가입니다.", + ) + + prob = min(max(int(result.get("probability", 2)), 1), 3) + impact = min(max(int(result.get("impact", 2)), 1), 3) + score = prob * impact + + from models import RiskLevel + level_map = {9: RiskLevel.CRITICAL, 6: RiskLevel.HIGH, 4: RiskLevel.HIGH, + 3: RiskLevel.MEDIUM, 2: RiskLevel.MEDIUM} + risk_level = next( + (v for k, v in sorted(level_map.items(), reverse=True) if score >= k), + RiskLevel.LOW, + ) + + risk = ProjectRisk( + risk_id=f"RSK-{datetime.now().strftime('%Y%m%d')}-{str(uuid4())[:5].upper()}", + project_id=proj.id, + title=result.get("title", f"WBS 지연 위험 ({len(delayed)}건)"), + description=result.get("description", ""), + probability_level=prob, + impact_level=impact, + risk_score=score, + risk_level=risk_level, + mitigation_plan=result.get("mitigation", ""), + status=RiskStatus.IDENTIFIED, + raised_by=f"agent:{agent.id}", + ) + db.add(risk) + await db.flush() + + task.status = AgentTaskStatus.COMPLETED + task.output_data = {**result, "risk_id": risk.risk_id} + task.completed_at = datetime.now() + + db.add(self._approval( + agent_id=agent.id, task_id=task.id, + action_type="CREATE_RISK", + action_data={"risk_id": risk.id, "project_id": proj.id, "score": score}, + auto=(risk_level not in ["CRITICAL"]), + )) + agent.total_tasks += 1 + + await db.commit() + logger.info("[WBS_MONITOR] 위험 분석 완료") + + # ── WBS 지연 완료 예측 ──────────────────────────────────────────── + await self._predict_wbs_completion(db, agent) + + # ── WBS 완료 예측 헬퍼 ─────────────────────────────────────────────────── + + async def _predict_wbs_completion(self, db: AsyncSession, agent) -> None: + """ + 최근 7일간 완료율 추이(기울기)로 프로젝트 완료 예상일을 계산한다. + 계획 종료일 초과 시 리스크 자동 등록. + 외부 API 없음 — 순수 계산 로직. + """ + from models import SiProject, AgentTask, ProjectPhase, ProjectRisk, RiskLevel, RiskStatus + from sqlalchemy import select + import math + + today = date.today() + + projects = (await db.execute( + select(SiProject).where( + SiProject.is_active.is_(True), + SiProject.phase.notin_([ProjectPhase.CLOSED]), + SiProject.planned_end_date.isnot(None), + ) + )).scalars().all() + + for proj in projects: + try: + curr_rate = float(proj.completion_rate or 0) + # 7일 전 스냅샷이 없으면 estimated_completion에서 역산 + # (간소화: 7일 전 비율 = curr_rate - 7*daily_delta 로 추정) + prev_rate_estimate = max(curr_rate - 7.0, 0.0) # 이전 데이터 없으면 7% 성장 가정 + daily_delta = (curr_rate - prev_rate_estimate) / 7 if curr_rate > prev_rate_estimate else 0.0 + + if daily_delta <= 0: + continue # 진척 없는 프로젝트는 스킵 + + remaining = 100.0 - curr_rate + est_days = math.ceil(remaining / daily_delta) + from datetime import timedelta + est_completion = today + timedelta(days=est_days) + + # estimated_completion 업데이트 + proj.estimated_completion = est_completion + + # 계획 종료일 초과 여부 확인 + planned_end = proj.planned_end_date + if isinstance(planned_end, str): + from datetime import datetime as dt + planned_end = dt.strptime(planned_end[:10], "%Y-%m-%d").date() + + if est_completion > planned_end: + delay_days = (est_completion - planned_end).days + + # 이미 DELAY_PREDICTION 리스크가 있으면 스킵 + existing = (await db.execute( + select(ProjectRisk).where( + ProjectRisk.project_id == proj.id, + ProjectRisk.raised_by == "agent:wbs_prediction", + ProjectRisk.status.notin_(["CLOSED"]), + ) + )).scalars().first() + + if not existing: + risk = ProjectRisk( + risk_id=f"RSK-PRED-{datetime.now().strftime('%Y%m%d')}-{proj.id}", + project_id=proj.id, + title=f"완료 예측 지연 ({delay_days}일 초과)", + description=( + f"현재 진척률 {curr_rate:.1f}% (일 평균 +{daily_delta:.2f}%).\n" + f"예상 완료일: {est_completion.isoformat()} " + f"(계획 종료일 {planned_end} 대비 {delay_days}일 초과)" + ), + probability_level=2, + impact_level=3, + risk_score=6, + risk_level=RiskLevel.HIGH, + mitigation_plan="일일 진척률 향상 계획 수립 및 자원 재배분 검토", + status=RiskStatus.IDENTIFIED, + raised_by="agent:wbs_prediction", + ) + db.add(risk) + logger.info( + "[WBS_PREDICTION] 지연 리스크 등록: project=%s delay=%dd", + proj.project_name, delay_days + ) + + except Exception as exc: + logger.warning("[WBS_PREDICTION] project_id=%d 예측 오류: %s", proj.id, exc) + + await db.commit() + logger.info("[WBS_PREDICTION] 완료 예측 업데이트 완료") + + # ══════════════════════════════════════════════════════════════════════════ + # ── 5. PM 제안 에이전트 ────────────────────────────────────────────────── + # ══════════════════════════════════════════════════════════════════════════ + + async def _pm_suggester(self, db: AsyncSession, agent) -> None: + """PM 스케줄 미등록 활성 서버 탐지 → 알림 태스크 기록.""" + from models import Server, PmSchedule, AgentTask, AgentTaskStatus + + scheduled_server_ids = [ + row[0] + for row in (await db.execute( + select(PmSchedule.server_id).where(PmSchedule.is_active.is_(True)) + )).all() + if row[0] is not None + ] + + unmanaged = (await db.execute( + select(Server).where( + Server.id.notin_(scheduled_server_ids), + Server.is_active.is_(True), + ).limit(10) + )).scalars().all() + + if not unmanaged: + logger.debug("[PM_SUGGESTER] 미등록 서버 없음") + return + + for srv in unmanaged: + task = self._new_task( + agent.id, + f"PM 미등록 서버 감지: {srv.server_name}", + {"server_id": srv.id, "server_name": srv.server_name}, + ) + task.status = AgentTaskStatus.COMPLETED + task.output_data = {"message": "PM 스케줄 등록 권고"} + task.completed_at = datetime.now() + db.add(task) + await db.flush() + + db.add(self._approval( + agent_id=agent.id, task_id=task.id, + action_type="SUGGEST_PM", + action_data={"server_id": srv.id, "server_name": srv.server_name}, + auto=True, + )) + agent.total_tasks += 1 + + await db.commit() + logger.info("[PM_SUGGESTER] PM 미등록 서버 %d건 감지", len(unmanaged)) + + # ══════════════════════════════════════════════════════════════════════════ + # ── 6. 개발자 에이전트 (Phase 1 Paperclip 스타일) ──────────────────────── + # ══════════════════════════════════════════════════════════════════════════ + + async def _developer_agent(self, db: AsyncSession, agent) -> None: + """ + PENDING 태스크를 받아 LLM으로 코드 생성. + 코드 포함 시 사람 승인(PENDING) 생성. + """ + from models import AgentTask, AgentTaskStatus + + pending = (await db.execute( + select(AgentTask).where( + AgentTask.agent_id == agent.id, + AgentTask.status == AgentTaskStatus.PENDING, + ).limit(3) + )).scalars().all() + + if not pending: + return + + for task in pending: + task.status = AgentTaskStatus.IN_PROGRESS + task.started_at = datetime.now() + await db.flush() + + input_data = task.input_data or {} + prompt = input_data.get("prompt") or task.description or task.title + + resp = await self.llm.generate( + prompt=prompt, + model=agent.llm_model, + system=( + agent.system_prompt + or ( + "당신은 GUARDiA ITSM 백엔드 개발자입니다.\n" + "FastAPI + SQLAlchemy async 코드를 작성합니다.\n" + "보안 규칙: ServerOut에 ip_addr/ssh_user/os_pw_enc 포함 금지." + ) + ), + ) + + contains_code = any(kw in resp.content for kw in ["```python", "def ", "class ", "async def"]) + task.status = AgentTaskStatus.COMPLETED + task.output_data = {"response": resp.content[:5000], "tokens": resp.tokens_total} + task.tokens_used = resp.tokens_total + task.completed_at = datetime.now() + + db.add(self._approval( + agent_id=agent.id, task_id=task.id, + action_type="CODE_CHANGE", + action_data={"preview": resp.content[:500]}, + auto=not contains_code, + )) + agent.total_tasks += 1 + agent.total_tokens += resp.tokens_total + + await db.commit() + logger.info("[DEVELOPER] %d건 태스크 처리", len(pending)) + + +# ── 싱글턴 ────────────────────────────────────────────────────────────────── + +_engine: Optional[AgentEngine] = None + + +def get_agent_engine() -> AgentEngine: + """에이전트 엔진 싱글턴 반환.""" + global _engine + if _engine is None: + _engine = AgentEngine() + return _engine diff --git a/core/anomaly.py b/core/anomaly.py new file mode 100644 index 0000000..432f198 --- /dev/null +++ b/core/anomaly.py @@ -0,0 +1,535 @@ +""" +B-1: AI 이상 탐지 (Anomaly Detection) 엔진 + +탐지 방법: + 1. ZSCORE — Z-score 기반 통계적 이상 탐지 + 2. IQR — 사분위수 범위(IQR) 기반 이상치 탐지 + 3. THRESHOLD — 정적 임계값 초과 + 4. TREND — 연속 상승/하강 추세 이탈 + +Ollama LLM은 탐지 후 자연어 분석 생성에만 사용 (탐지 자체는 순수 통계). +""" +from __future__ import annotations + +import json +import logging +import math +import statistics +from datetime import datetime, timedelta +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +import httpx +from sqlalchemy import select, and_ +from sqlalchemy.ext.asyncio import AsyncSession + +logger = logging.getLogger(__name__) + +# ── 설정 ──────────────────────────────────────────────────────────────────── + +OLLAMA_URL = "http://localhost:11434/api/generate" +DEFAULT_MODEL = "llama3" + +# 기본 정적 임계값 (THRESHOLD 방식 폴백) +DEFAULT_THRESHOLDS: Dict[str, Tuple[float, str]] = { + "CPU_USAGE": (90.0, "WARNING"), + "MEMORY_USAGE": (85.0, "WARNING"), + "DISK_USAGE": (90.0, "CRITICAL"), + "RESPONSE_TIME": (3000.0,"WARNING"), # 3초 + "ERROR_RATE": (50.0, "WARNING"), # 분당 50건 + "HEAP_USAGE": (85.0, "WARNING"), + "ACTIVE_CONNECTIONS": (500.0, "WARNING"), +} + +# 단위 맵 +METRIC_UNITS: Dict[str, str] = { + "CPU_USAGE": "%", + "MEMORY_USAGE": "%", + "DISK_USAGE": "%", + "RESPONSE_TIME": "ms", + "ERROR_RATE": "건/분", + "NETWORK_RX": "KB/s", + "NETWORK_TX": "KB/s", + "ACTIVE_CONNECTIONS": "개", + "HEAP_USAGE": "%", + "THROUGHPUT": "req/s", +} + + +# ── 핵심 탐지 함수 ──────────────────────────────────────────────────────────── + +def detect_zscore( + values: List[float], + current: float, + z_threshold: float = 3.0, + min_samples: int = 10, +) -> Tuple[bool, float, float, float]: + """ + Z-score 기반 이상 탐지. + Returns: (is_anomaly, mean, std, z_score) + """ + if len(values) < min_samples: + return False, 0.0, 0.0, 0.0 + + mean = statistics.mean(values) + try: + std = statistics.stdev(values) + except statistics.StatisticsError: + std = 0.0 + + if std < 1e-6: + # 표준편차가 0에 가까우면 분산 없음 → 임계값 방식으로 폴백 + return False, mean, std, 0.0 + + z = abs(current - mean) / std + return z > z_threshold, mean, std, z + + +def detect_iqr( + values: List[float], + current: float, + iqr_factor: float = 1.5, + min_samples: int = 10, +) -> Tuple[bool, float, float]: + """ + IQR 기반 이상 탐지. + Returns: (is_anomaly, lower_fence, upper_fence) + """ + if len(values) < min_samples: + return False, 0.0, 0.0 + + sorted_vals = sorted(values) + n = len(sorted_vals) + q1_idx = n // 4 + q3_idx = 3 * n // 4 + q1 = sorted_vals[q1_idx] + q3 = sorted_vals[q3_idx] + iqr = q3 - q1 + + lower = q1 - iqr_factor * iqr + upper = q3 + iqr_factor * iqr + return current > upper or current < lower, lower, upper + + +def detect_threshold( + current: float, + threshold: float, + operator: str = "gt", +) -> bool: + """정적 임계값 비교. operator: gt(>), lt(<), gte(>=), lte(<=).""" + if operator == "gt": + return current > threshold + elif operator == "lt": + return current < threshold + elif operator == "gte": + return current >= threshold + elif operator == "lte": + return current <= threshold + return current > threshold + + +def detect_trend( + values: List[float], + window: int = 5, + deviation_pct: float = 20.0, +) -> Tuple[bool, str]: + """ + 연속 추세 탐지: 최근 window 포인트가 모두 상승 or 하강. + Returns: (is_anomaly, direction) direction: "RISING" | "FALLING" | "STABLE" + """ + if len(values) < window: + return False, "STABLE" + + recent = values[-window:] + diffs = [recent[i + 1] - recent[i] for i in range(len(recent) - 1)] + + if all(d > 0 for d in diffs): + # 연속 상승 — 변화폭 확인 + total_change = abs(recent[-1] - recent[0]) + if recent[0] > 0 and (total_change / recent[0]) * 100 >= deviation_pct: + return True, "RISING" + elif all(d < 0 for d in diffs): + # 연속 하강 — throughput / 응답시간 하락 경보 + total_change = abs(recent[-1] - recent[0]) + if recent[0] > 0 and (total_change / recent[0]) * 100 >= deviation_pct: + return True, "FALLING" + + return False, "STABLE" + + +# ── 룰 기반 탐지 실행 ───────────────────────────────────────────────────────── + +def run_detection( + metric_type: str, + current_value: float, + historical_values: List[float], + method: str = "ZSCORE", + z_threshold: float = 3.0, + iqr_factor: float = 1.5, + threshold: Optional[float] = None, + min_samples: int = 10, +) -> Dict: + """ + 통합 이상 탐지 실행. + Returns: { + is_anomaly: bool, + method: str, + baseline_mean: float, + baseline_std: float, + z_score: float, + threshold_used: float, + detail: str, + } + """ + result = { + "is_anomaly": False, + "method": method, + "baseline_mean": None, + "baseline_std": None, + "z_score": None, + "threshold_used": None, + "detail": "", + } + + if method == "ZSCORE": + is_anom, mean, std, z = detect_zscore( + historical_values, current_value, z_threshold, min_samples + ) + result.update({ + "is_anomaly": is_anom, + "baseline_mean": round(mean, 4), + "baseline_std": round(std, 4), + "z_score": round(z, 4), + "detail": f"Z-score={z:.2f} (임계={z_threshold}), 평균={mean:.2f}, 표준편차={std:.2f}", + }) + + elif method == "IQR": + is_anom, lower, upper = detect_iqr( + historical_values, current_value, iqr_factor, min_samples + ) + result.update({ + "is_anomaly": is_anom, + "threshold_used": upper, + "detail": f"IQR 범위=[{lower:.2f}, {upper:.2f}], 현재={current_value:.2f}", + }) + if historical_values and len(historical_values) >= min_samples: + result["baseline_mean"] = round(statistics.mean(historical_values), 4) + try: + result["baseline_std"] = round(statistics.stdev(historical_values), 4) + except Exception: + pass + + elif method == "THRESHOLD": + thr = threshold + if thr is None: + # 기본 임계값 참조 + thr_info = DEFAULT_THRESHOLDS.get(metric_type) + thr = thr_info[0] if thr_info else 90.0 + is_anom = detect_threshold(current_value, thr) + result.update({ + "is_anomaly": is_anom, + "threshold_used": thr, + "detail": f"임계값={thr}, 현재={current_value}", + }) + + elif method == "TREND": + is_anom, direction = detect_trend(historical_values) + result.update({ + "is_anomaly": is_anom, + "detail": f"추세={direction}", + }) + if historical_values: + result["baseline_mean"] = round(statistics.mean(historical_values), 4) + + return result + + +# ── 이상 이벤트 제목/설명 자동 생성 ────────────────────────────────────────── + +_METRIC_KO = { + "CPU_USAGE": "CPU 사용률", + "MEMORY_USAGE": "메모리 사용률", + "DISK_USAGE": "디스크 사용률", + "RESPONSE_TIME": "응답 시간", + "ERROR_RATE": "에러 발생률", + "NETWORK_RX": "네트워크 수신량", + "NETWORK_TX": "네트워크 송신량", + "ACTIVE_CONNECTIONS": "활성 연결 수", + "HEAP_USAGE": "JVM 힙 사용률", + "THROUGHPUT": "처리량", + "CUSTOM": "사용자 정의 메트릭", +} + + +def build_event_title(source: str, metric_type: str, current_value: float, + unit: str = "") -> str: + metric_ko = _METRIC_KO.get(metric_type, metric_type) + return f"[이상 탐지] {source} — {metric_ko} {current_value:.1f}{unit}" + + +def build_event_description(metric_type: str, detect_result: Dict, + current_value: float, unit: str = "") -> str: + metric_ko = _METRIC_KO.get(metric_type, metric_type) + lines = [ + f"메트릭: {metric_ko}", + f"현재값: {current_value:.2f} {unit}", + f"탐지 방법: {detect_result['method']}", + detect_result.get("detail", ""), + ] + if detect_result.get("baseline_mean") is not None: + lines.append(f"기준 평균: {detect_result['baseline_mean']:.2f} {unit}") + return "\n".join(l for l in lines if l) + + +# ── Ollama LLM 분석 (선택적) ────────────────────────────────────────────────── + +async def _call_ollama_analysis( + source: str, + metric_type: str, + current_value: float, + baseline_mean: Optional[float], + z_score: Optional[float], + detect_detail: str, + model: str = DEFAULT_MODEL, + timeout: int = 60, +) -> str: + """Ollama를 통해 이상 탐지 결과에 대한 자연어 분석 생성.""" + metric_ko = _METRIC_KO.get(metric_type, metric_type) + unit = METRIC_UNITS.get(metric_type, "") + mean_str = f"{baseline_mean:.2f}{unit}" if baseline_mean is not None else "N/A" + z_str = f"{z_score:.2f}" if z_score is not None else "N/A" + + prompt = f"""시스템 이상 탐지 분석 보고서를 작성하라. JSON 형식으로만 응답하라. + +서버/소스: {source} +메트릭: {metric_ko} ({metric_type}) +현재값: {current_value:.2f}{unit} +기준 평균: {mean_str} +Z-Score: {z_str} +탐지 상세: {detect_detail} + +다음 JSON을 반환하라: +{{ + "cause": "예상 원인 (1-2 문장)", + "impact": "예상 영향 (1-2 문장)", + "actions": ["즉시 조치 1", "조치 2", "조치 3"], + "urgency": "HIGH|MEDIUM|LOW" +}}""" + + try: + async with httpx.AsyncClient(timeout=timeout) as client: + resp = await client.post( + OLLAMA_URL, + json={"model": model, "prompt": prompt, "stream": False}, + ) + if resp.status_code == 200: + raw = resp.json().get("response", "") + # JSON 추출 + start = raw.find("{") + end = raw.rfind("}") + 1 + if start >= 0 and end > start: + return raw[start:end] + return raw[:500] + except (httpx.ConnectError, httpx.TimeoutException) as e: + logger.debug("Ollama 연결 실패 (이상 탐지 분석): %s", e) + + return "" + + +# ── DB 기반 탐지 실행 ───────────────────────────────────────────────────────── + +async def fetch_recent_values( + db: AsyncSession, + source: str, + metric_type: str, + window_size: int = 60, + before_dt: Optional[datetime] = None, +) -> List[float]: + """최근 window_size 개 스냅샷 값 조회.""" + from models import MetricSnapshot + + q = ( + select(MetricSnapshot.value) + .where( + and_( + MetricSnapshot.source == source, + MetricSnapshot.metric_type == metric_type, + ) + ) + .order_by(MetricSnapshot.recorded_at.desc()) + .limit(window_size) + ) + if before_dt: + q = q.where(MetricSnapshot.recorded_at < before_dt) + + rows = (await db.execute(q)).scalars().all() + return list(reversed(rows)) # 오래된 순 → 최근 순 + + +async def run_rules_on_metric( + db: AsyncSession, + source: str, + metric_type: str, + current_value: float, + unit: Optional[str] = None, + server_id: Optional[int] = None, + include_llm: bool = False, +) -> List[Dict]: + """ + 주어진 메트릭에 대해 활성 룰 전체를 실행하고 + 이상 탐지 시 AnomalyEvent를 생성해 반환. + """ + from models import AnomalyRule, AnomalyEvent + + # 적용 가능한 룰 조회 + q = select(AnomalyRule).where( + and_( + AnomalyRule.metric_type == metric_type, + AnomalyRule.is_active == True, + ) + ) + rules = (await db.execute(q)).scalars().all() + + # 룰이 없으면 기본 THRESHOLD 룰 적용 + if not rules: + rules = _get_default_rules(metric_type) + + events_created = [] + + for rule in rules: + # source 패턴 필터 + if hasattr(rule, "source_pattern") and rule.source_pattern: + import fnmatch + if not fnmatch.fnmatch(source, rule.source_pattern): + continue + + # 히스토리 조회 + window = getattr(rule, "window_size", 60) + min_s = getattr(rule, "min_samples", 10) + history = await fetch_recent_values(db, source, metric_type, window) + + method = getattr(rule, "method", "ZSCORE") + z_thr = getattr(rule, "z_threshold", 3.0) + iqr_factor = getattr(rule, "iqr_factor", 1.5) + threshold = getattr(rule, "threshold", None) + severity = getattr(rule, "severity", "WARNING") + rule_name = getattr(rule, "name", f"DEFAULT_{metric_type}") + + detect_result = run_detection( + metric_type=metric_type, + current_value=current_value, + historical_values=history, + method=method, + z_threshold=z_thr, + iqr_factor=iqr_factor, + threshold=threshold, + min_samples=min_s, + ) + + if not detect_result["is_anomaly"]: + continue + + # 중복 방지: 같은 소스+메트릭에 OPEN 이벤트가 있으면 스킵 + existing_q = select(AnomalyEvent).where( + and_( + AnomalyEvent.source == source, + AnomalyEvent.metric_type == metric_type, + AnomalyEvent.status == "OPEN", + ) + ) + existing = (await db.execute(existing_q)).scalars().first() + if existing: + continue + + # LLM 분석 (선택적) + llm_text = "" + if include_llm: + try: + llm_text = await _call_ollama_analysis( + source=source, + metric_type=metric_type, + current_value=current_value, + baseline_mean=detect_result.get("baseline_mean"), + z_score=detect_result.get("z_score"), + detect_detail=detect_result.get("detail", ""), + ) + except Exception as e: + logger.debug("LLM 분석 오류: %s", e) + + unit_str = unit or METRIC_UNITS.get(metric_type, "") + event = AnomalyEvent( + server_id = server_id, + source = source, + metric_type = metric_type, + severity = severity, + status = "OPEN", + title = build_event_title(source, metric_type, current_value, unit_str), + description = build_event_description(metric_type, detect_result, current_value, unit_str), + current_value = current_value, + baseline_mean = detect_result.get("baseline_mean"), + baseline_std = detect_result.get("baseline_std"), + z_score = detect_result.get("z_score"), + threshold = detect_result.get("threshold_used"), + detect_method = method, + llm_analysis = llm_text or None, + rule_name = rule_name, + detected_at = datetime.utcnow(), + ) + db.add(event) + await db.flush() + events_created.append({ + "id": event.id, + "source": source, + "metric_type": metric_type, + "severity": severity, + "title": event.title, + "current_value": current_value, + "z_score": detect_result.get("z_score"), + "rule_name": rule_name, + }) + + await db.commit() + return events_created + + +class _MockRule: + """DB 룰이 없을 때 사용하는 기본 룰 객체.""" + def __init__(self, metric_type: str): + info = DEFAULT_THRESHOLDS.get(metric_type, (90.0, "WARNING")) + self.name = f"DEFAULT_{metric_type}" + self.source_pattern = None + self.metric_type = metric_type + self.method = "THRESHOLD" + self.threshold = info[0] + self.z_threshold = 3.0 + self.iqr_factor = 1.5 + self.window_size = 60 + self.min_samples = 10 + self.severity = info[1] + self.is_active = True + + +def _get_default_rules(metric_type: str) -> List[_MockRule]: + if metric_type in DEFAULT_THRESHOLDS: + return [_MockRule(metric_type)] + return [] + + +# ── 시뮬레이션 데이터 생성 (테스트용) ──────────────────────────────────────── + +def generate_simulation_data( + normal_count: int = 50, + baseline_mean: float = 40.0, + baseline_std: float = 10.0, + anomaly_value: float = 95.0, +) -> Tuple[List[float], float]: + """ + 정상 분포 데이터 + 이상 값 반환. + Returns: (normal_values, anomaly_value) + """ + import random + normal = [ + max(0.0, min(100.0, random.gauss(baseline_mean, baseline_std))) + for _ in range(normal_count) + ] + return normal, anomaly_value diff --git a/core/auth.py b/core/auth.py new file mode 100644 index 0000000..a31ffd7 --- /dev/null +++ b/core/auth.py @@ -0,0 +1,135 @@ +"""JWT + PBKDF2-SHA256 인증 유틸리티 — GUARDiA ITSM. + +Python 3.14 완전 호환 — 외부 bcrypt 의존성 없음. +패스워드는 PBKDF2-HMAC-SHA256 + 32바이트 salt, 반복 260000회. +""" +import base64 +import hashlib +import hmac +import os +import secrets +from datetime import datetime, timedelta, timezone +from typing import Optional + +# 계정 잠금 정책 +MAX_FAILED_ATTEMPTS = 5 # 연속 실패 허용 횟수 +LOCKOUT_MINUTES = 30 # 잠금 지속 시간(분) + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError, jwt +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from database import get_db + +# ── 설정 ───────────────────────────────────────────────────────────────────── +SECRET_KEY = os.environ.get("GUARDIA_JWT_SECRET", "guardia-jwt-secret-2026-change-me!") +ALGORITHM = "HS256" +TOKEN_EXPIRE_MINUTES = 480 # 8시간 + +_ITER = 260_000 # PBKDF2 반복 횟수 (NIST 권장) +_SALT_LEN = 32 # 바이트 +_KEY_LEN = 32 # 바이트 + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False) + + +# ── 패스워드 ────────────────────────────────────────────────────────────────── + +def hash_password(plain: str) -> str: + """PBKDF2-HMAC-SHA256으로 패스워드를 해시합니다.""" + salt = secrets.token_bytes(_SALT_LEN) + key = hashlib.pbkdf2_hmac("sha256", plain.encode(), salt, _ITER, _KEY_LEN) + return base64.b64encode(salt + key).decode() + + +def verify_password(plain: str, hashed: str) -> bool: + """저장된 해시와 평문 패스워드를 안전하게 비교합니다.""" + try: + data = base64.b64decode(hashed.encode()) + salt = data[:_SALT_LEN] + stored_key = data[_SALT_LEN:] + key = hashlib.pbkdf2_hmac("sha256", plain.encode(), salt, _ITER, _KEY_LEN) + return hmac.compare_digest(key, stored_key) + except Exception: + return False + + +# ── JWT ─────────────────────────────────────────────────────────────────────── + +def create_access_token(data: dict) -> str: + payload = data.copy() + payload["exp"] = datetime.now(timezone.utc) + timedelta(minutes=TOKEN_EXPIRE_MINUTES) + return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) + + +# ── 사용자 조회 Dependencies ────────────────────────────────────────────────── + +async def get_optional_user( + token: Optional[str] = Depends(oauth2_scheme), + db: AsyncSession = Depends(get_db), +): + """토큰이 없거나 유효하지 않으면 None 반환 (예외 없음).""" + if not token: + return None + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username = payload.get("sub") + if not username: + return None + except JWTError: + return None + + from models import User + result = await db.execute(select(User).where(User.username == username)) + user = result.scalars().first() + return user if (user and user.is_active) else None + + +async def get_current_user( + user=Depends(get_optional_user), +): + """토큰 없거나 유효하지 않으면 401 반환.""" + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="인증이 필요합니다", + headers={"WWW-Authenticate": "Bearer"}, + ) + return user + + +# ── Priority 4: RBAC 세분화 ────────────────────────────────────────────────── + +def require_deploy_role(current_user=Depends(get_current_user)): + """ + 배포 권한 확인 — ADMIN, PM, DEPLOY_ENGINEER 만 허용. + 배포 트리거 및 배치 즉시 실행 엔드포인트에서 사용한다. + + 권한 매핑: + ADMIN : 전체 권한 + PM : 배포 승인 및 트리거 + DEPLOY_ENGINEER : 배포 트리거 + 배치 즉시 실행 + ENGINEER : 조회만 가능 + CUSTOMER : 전체 차단 + """ + from models import UserRole + DEPLOY_ROLES = {UserRole.ADMIN, UserRole.PM, UserRole.DEPLOY_ENGINEER} + if current_user.role not in DEPLOY_ROLES: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="배포 권한이 없습니다 (ADMIN, PM, DEPLOY_ENGINEER 필요)", + ) + return current_user + + +def require_admin_role(current_user=Depends(get_current_user)): + """ADMIN 전용 엔드포인트 권한 확인.""" + from models import UserRole + if current_user.role != UserRole.ADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="ADMIN 권한이 필요합니다", + ) + return current_user diff --git a/core/auto_rca.py b/core/auto_rca.py new file mode 100644 index 0000000..faf5764 --- /dev/null +++ b/core/auto_rca.py @@ -0,0 +1,146 @@ +"""자동 RCA 분석 — Ollama LLM을 활용한 근본 원인 초안 생성.""" +from __future__ import annotations + +import json +import logging +from datetime import datetime +from typing import Optional + +logger = logging.getLogger(__name__) + +RCA_PROMPT = """당신은 IT 인프라 전문가입니다. 다음 장애 정보를 분석하여 RCA(근본 원인 분석)를 JSON으로 제공하세요. + +장애 정보: +{incident_info} + +변경 이력: +{change_history} + +다음 필드를 포함하는 JSON만 출력하세요 (한국어): +{{ + "root_cause": "추정 근본 원인", + "contributing_factors": ["기여 요인 1", "기여 요인 2"], + "timeline": "발생부터 발견까지 타임라인", + "prevention": ["재발 방지 조치 1", "재발 방지 조치 2"], + "confidence": 0.8 +}}""" + + +async def analyze_rca(incident_id: int, db) -> dict: + """장애 ID로 RCA 초안 자동 생성.""" + from models import Incident + from sqlalchemy import select, text + + incident = await db.get(Incident, incident_id) + if not incident: + raise ValueError(f"장애 ID {incident_id}를 찾을 수 없습니다.") + + incident_info = ( + f"장애번호: {incident.incident_id}\n" + f"제목: {incident.title}\n" + f"등급: {incident.grade}\n" + f"설명: {incident.description or '없음'}\n" + f"영향 서비스: {incident.affected_service or '없음'}\n" + f"발생시각: {incident.occurred_at}\n" + f"복구시각: {incident.resolved_at or '미복구'}" + ) + + # 최근 변경 이력 조회 (CI 변경 로그) + try: + from models import CIChangeLog + logs = (await db.execute( + select(CIChangeLog) + .order_by(CIChangeLog.changed_at.desc()) + .limit(5) + )).scalars().all() + change_history = "\n".join( + f"- [{l.changed_at}] {l.change_type}: {l.summary or ''}" + for l in logs + ) or "최근 변경 이력 없음" + except Exception: + change_history = "변경 이력 조회 불가" + + prompt = RCA_PROMPT.format( + incident_info=incident_info, + change_history=change_history, + ) + + try: + from core.llm_client import get_llm_client + client = get_llm_client() + resp = await client.chat(prompt) + raw = resp.content.strip() + # JSON 블록 추출 + if "```" in raw: + raw = raw.split("```")[1] + if raw.startswith("json"): + raw = raw[4:] + rca = json.loads(raw) + except Exception as e: + logger.warning("LLM RCA 분석 실패 — 기본 템플릿 사용: %s", e) + rca = { + "root_cause": f"자동 분석 실패 — 수동 분석 필요. ({str(e)[:100]})", + "contributing_factors": ["분석 데이터 부족"], + "timeline": incident_info, + "prevention": ["장애 재발 방지 조치를 수동으로 기록하세요."], + "confidence": 0.0, + } + + return { + "incident_id": incident.incident_id, + "rca": rca, + "auto_generated": True, + "generated_at": datetime.utcnow().isoformat(), + } + + +async def analyze_problem_rca(problem_id: int, db) -> dict: + """Problem 레코드 ID로 RCA 초안 자동 생성.""" + from models import ProblemRecord + from sqlalchemy import select + + prb = await db.get(ProblemRecord, problem_id) + if not prb: + raise ValueError(f"Problem ID {problem_id}를 찾을 수 없습니다.") + + incident_info = ( + f"문제번호: {prb.problem_id}\n" + f"제목: {prb.title}\n" + f"설명: {prb.description or '없음'}\n" + f"카테고리: {prb.category}\n" + f"영향 사용자: {prb.affected_users}명\n" + f"관련 인시던트 수: {prb.incident_count}건\n" + f"누적 다운타임: {prb.total_downtime_min}분" + ) + + prompt = RCA_PROMPT.format( + incident_info=incident_info, + change_history="문제 레코드 기반 분석", + ) + + try: + from core.llm_client import get_llm_client + client = get_llm_client() + resp = await client.chat(prompt) + raw = resp.content.strip() + if "```" in raw: + raw = raw.split("```")[1] + if raw.startswith("json"): + raw = raw[4:] + rca = json.loads(raw) + except Exception as e: + logger.warning("LLM Problem RCA 분석 실패: %s", e) + rca = { + "root_cause": "자동 분석 실패 — 수동 분석 필요.", + "contributing_factors": [], + "timeline": incident_info, + "prevention": [], + "confidence": 0.0, + } + + return { + "problem_id": prb.problem_id, + "rca": rca, + "auto_generated": True, + "generated_at": datetime.utcnow().isoformat(), + } diff --git a/core/cache.py b/core/cache.py new file mode 100644 index 0000000..bf1a186 --- /dev/null +++ b/core/cache.py @@ -0,0 +1,286 @@ +""" +GUARDiA ITSM — 캐시 레이어 (Enhancement F-2) + +Redis 우선, 미설치/연결 실패 시 인메모리 LRU 캐시로 자동 폴백. + +기능: + 1. 비동기 get/set/delete/invalidate + 2. 접두어 기반 패턴 삭제 (invalidate_prefix) + 3. 함수 결과 캐싱 데코레이터 (cached) + 4. 캐시 통계 (히트/미스/에러 카운터) + 5. 상태 조회 엔드포인트용 헬퍼 + +주요 캐시 키: + itsm:tasks:list:{hash} — SR 목록 (30초) + itsm:stats:summary — 대시보드 통계 (60초) + itsm:server:list — 서버 목록 (120초) + itsm:oncall:today — 오늘 당직자 (300초) +""" +from __future__ import annotations + +import asyncio +import hashlib +import json +import logging +import os +import time +from collections import OrderedDict +from functools import wraps +from typing import Any, Callable, Optional + +logger = logging.getLogger(__name__) + +# ── 설정 ───────────────────────────────────────────────────────────────────── +REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/0") +CACHE_ENABLED = os.environ.get("CACHE_ENABLED", "true").lower() == "true" +CACHE_KEY_PREFIX = "itsm:" +_DEFAULT_TTL_SEC = 60 +_MEMORY_MAX_ITEMS = 1000 + +# ── 통계 카운터 ─────────────────────────────────────────────────────────────── +_stats = {"hits": 0, "misses": 0, "errors": 0, "sets": 0} + + +# ═══════════════════════════════════════════════════════════════════════════════ +# ── 인메모리 LRU 캐시 (Redis 폴백) ───────────────────────────────────────────── +# ═══════════════════════════════════════════════════════════════════════════════ + +class _MemoryCache: + """TTL 지원 인메모리 LRU 캐시.""" + + def __init__(self, maxsize: int = _MEMORY_MAX_ITEMS): + self._store: OrderedDict[str, tuple[Any, float]] = OrderedDict() + self._maxsize = maxsize + + def get(self, key: str) -> Optional[Any]: + if key not in self._store: + return None + value, expires_at = self._store[key] + if time.monotonic() > expires_at: + del self._store[key] + return None + self._store.move_to_end(key) + return value + + def set(self, key: str, value: Any, ttl: int = _DEFAULT_TTL_SEC) -> None: + if key in self._store: + self._store.move_to_end(key) + self._store[key] = (value, time.monotonic() + ttl) + if len(self._store) > self._maxsize: + self._store.popitem(last=False) + + def delete(self, key: str) -> None: + self._store.pop(key, None) + + def keys_with_prefix(self, prefix: str) -> list[str]: + return [k for k in self._store if k.startswith(prefix)] + + def flush(self) -> int: + n = len(self._store) + self._store.clear() + return n + + def info(self) -> dict: + now = time.monotonic() + live = sum(1 for _, (_, exp) in self._store.items() if exp > now) + return {"backend": "memory", "items_total": len(self._store), "items_live": live} + + +_mem_cache = _MemoryCache() +_redis_client: Optional[Any] = None # aioredis / redis.asyncio 클라이언트 + + +# ── Redis 연결 ──────────────────────────────────────────────────────────────── + +async def _get_redis(): + """Redis 클라이언트 싱글톤 (연결 실패 시 None).""" + global _redis_client + if _redis_client is not None: + return _redis_client + + try: + import redis.asyncio as aioredis + client = aioredis.from_url(REDIS_URL, decode_responses=True, socket_timeout=1.0) + await client.ping() + _redis_client = client + logger.info("Redis 캐시 연결 성공: %s", REDIS_URL) + return _redis_client + except Exception as exc: + logger.info("Redis 미연결, 인메모리 캐시 사용: %s", exc) + return None + + +async def close_redis() -> None: + """앱 종료 시 Redis 연결 해제.""" + global _redis_client + if _redis_client: + await _redis_client.aclose() + _redis_client = None + + +# ═══════════════════════════════════════════════════════════════════════════════ +# ── 공개 API ────────────────────────────────────────────────────────────────── +# ═══════════════════════════════════════════════════════════════════════════════ + +def _full_key(key: str) -> str: + return f"{CACHE_KEY_PREFIX}{key}" + + +async def cache_get(key: str) -> Optional[Any]: + """캐시에서 값 조회. 없으면 None.""" + if not CACHE_ENABLED: + return None + full_key = _full_key(key) + try: + redis = await _get_redis() + if redis: + raw = await redis.get(full_key) + if raw is None: + _stats["misses"] += 1 + return None + _stats["hits"] += 1 + return json.loads(raw) + else: + val = _mem_cache.get(full_key) + if val is None: + _stats["misses"] += 1 + else: + _stats["hits"] += 1 + return val + except Exception as exc: + _stats["errors"] += 1 + logger.debug("cache_get 오류: key=%s err=%s", key, exc) + return None + + +async def cache_set(key: str, value: Any, ttl: int = _DEFAULT_TTL_SEC) -> bool: + """캐시에 값 저장.""" + if not CACHE_ENABLED: + return False + full_key = _full_key(key) + try: + redis = await _get_redis() + if redis: + await redis.setex(full_key, ttl, json.dumps(value, default=str)) + else: + _mem_cache.set(full_key, value, ttl) + _stats["sets"] += 1 + return True + except Exception as exc: + _stats["errors"] += 1 + logger.debug("cache_set 오류: key=%s err=%s", key, exc) + return False + + +async def cache_delete(key: str) -> bool: + """캐시에서 특정 키 삭제.""" + if not CACHE_ENABLED: + return True + full_key = _full_key(key) + try: + redis = await _get_redis() + if redis: + await redis.delete(full_key) + else: + _mem_cache.delete(full_key) + return True + except Exception as exc: + _stats["errors"] += 1 + logger.debug("cache_delete 오류: key=%s err=%s", key, exc) + return False + + +async def cache_invalidate_prefix(prefix: str) -> int: + """접두어로 시작하는 모든 캐시 키 삭제.""" + if not CACHE_ENABLED: + return 0 + full_prefix = _full_key(prefix) + count = 0 + try: + redis = await _get_redis() + if redis: + keys = await redis.keys(f"{full_prefix}*") + if keys: + count = await redis.delete(*keys) + else: + keys = _mem_cache.keys_with_prefix(full_prefix) + for k in keys: + _mem_cache.delete(k) + count = len(keys) + except Exception as exc: + _stats["errors"] += 1 + logger.debug("cache_invalidate_prefix 오류: prefix=%s err=%s", prefix, exc) + return count + + +def make_cache_key(*args, **kwargs) -> str: + """인자 목록으로 캐시 키 생성 (MD5 해시).""" + raw = json.dumps({"args": list(args), "kwargs": kwargs}, sort_keys=True, default=str) + return hashlib.md5(raw.encode()).hexdigest()[:16] + + +# ── 캐시 데코레이터 ─────────────────────────────────────────────────────────── + +def cached(key_prefix: str, ttl: int = _DEFAULT_TTL_SEC): + """ + 비동기 함수 결과 캐싱 데코레이터. + + 사용 예:: + @cached("tasks:list", ttl=30) + async def get_tasks(...): + ... + """ + def decorator(func: Callable): + @wraps(func) + async def wrapper(*args, **kwargs): + cache_key = f"{key_prefix}:{make_cache_key(*args, **kwargs)}" + cached_val = await cache_get(cache_key) + if cached_val is not None: + return cached_val + result = await func(*args, **kwargs) + await cache_set(cache_key, result, ttl) + return result + return wrapper + return decorator + + +# ── 캐시 상태 조회 ──────────────────────────────────────────────────────────── + +async def cache_info() -> dict: + """캐시 상태 정보 반환 (관리자 대시보드용).""" + try: + redis = await _get_redis() + if redis: + info = await redis.info("stats") + backend_info = { + "backend": "redis", + "url": REDIS_URL.split("@")[-1], # 비밀번호 마스킹 + "keyspace_hits": info.get("keyspace_hits", 0), + "keyspace_misses": info.get("keyspace_misses", 0), + } + else: + backend_info = _mem_cache.info() + except Exception: + backend_info = {"backend": "memory (redis unavailable)", **_mem_cache.info()} + + return { + "enabled": CACHE_ENABLED, + "backend": backend_info, + "app_stats": dict(_stats), + } + + +async def cache_flush_all() -> int: + """전체 캐시 초기화 (테스트/관리자 전용).""" + try: + redis = await _get_redis() + if redis: + keys = await redis.keys(f"{CACHE_KEY_PREFIX}*") + if keys: + return await redis.delete(*keys) + return 0 + else: + return _mem_cache.flush() + except Exception as exc: + logger.warning("cache_flush_all 오류: %s", exc) + return 0 diff --git a/core/chatbot.py b/core/chatbot.py new file mode 100644 index 0000000..0a8cb3a --- /dev/null +++ b/core/chatbot.py @@ -0,0 +1,483 @@ +""" +B-2: 자연어 SR 접수 챗봇 엔진 + +기능: + - Ollama LLM 기반 자연어 의도 분류 + 엔티티 추출 + - Ollama 미연결 시 규칙 기반 폴백 (키워드 매칭) + - 다단계 대화: 정보 수집 → SR 자동 생성 + - 대화 컨텍스트 누적 관리 +""" +from __future__ import annotations + +import json +import logging +import re +import uuid +from datetime import datetime +from typing import Dict, List, Optional, Tuple + +import httpx + +logger = logging.getLogger(__name__) + +# ── 설정 ───────────────────────────────────────────────────────────────────── + +OLLAMA_URL = "http://localhost:11434/api/generate" +DEFAULT_MODEL = "llama3" + +# ── 규칙 기반 폴백 ────────────────────────────────────────────────────────── + +# 인텐트 키워드 맵 +_INTENT_KEYWORDS: Dict[str, List[str]] = { + "SR_CREATE": [ + "오류", "에러", "error", "장애", "느려", "안 돼", "안돼", "문제", + "접속 안", "접속이 안", "서버", "다운", "중단", "실패", "요청", + "불가", "이상", "이슈", "고장", "먹통", "응답 없", "timeout", + "배포 요청", "업데이트", "설치 요청", + ], + "INCIDENT_REPORT": [ + "긴급", "즉시", "critical", "전면", "전체 장애", "서비스 중단", + "대규모", "모든 사용자", "운영 중단", + ], + "DEPLOY_REQUEST": [ + "배포", "릴리즈", "deploy", "release", "빌드", "build", + "소스 반영", "패치", "업그레이드", + ], + "SR_QUERY": [ + "조회", "확인", "상태", "어떻게", "얼마나", "진행", "처리", + "언제", "완료", "sr-", "SR-", + ], + "GENERAL_INQUIRY": [ + "문의", "질문", "어떻게 하면", "도움", "help", "방법", "알려", + ], +} + +# 우선순위 키워드 +_PRIORITY_KEYWORDS: Dict[str, List[str]] = { + "CRITICAL": ["긴급", "즉시", "critical", "전면 장애", "모든 사용자", "운영 중단", "지금 당장"], + "HIGH": ["빠르게", "빨리", "urgent", "high", "중요", "높음", "오늘 중"], + "MEDIUM": ["medium", "보통", "일반", "중간"], + "LOW": ["천천히", "여유", "low", "낮음", "나중에"], +} + +# SR 유형 키워드 +_SR_TYPE_KEYWORDS: Dict[str, List[str]] = { + "DEPLOY": ["배포", "deploy", "릴리즈", "소스 반영", "패치"], + "RESTART": ["재기동", "restart", "재시작", "기동"], + "LOG": ["로그", "log", "로그 확인", "오류 로그"], + "INCIDENT":["장애", "중단", "다운", "먹통"], +} + + +def classify_intent_rule(text: str) -> Tuple[str, float]: + """규칙 기반 인텐트 분류. Returns (intent, confidence).""" + text_lower = text.lower() + scores: Dict[str, int] = {} + + for intent, keywords in _INTENT_KEYWORDS.items(): + score = sum(1 for kw in keywords if kw.lower() in text_lower) + if score > 0: + scores[intent] = score + + if not scores: + return "GENERAL_INQUIRY", 0.3 + + # INCIDENT_REPORT > SR_CREATE (인시던트는 더 구체적) + best = max(scores, key=lambda k: scores[k]) + confidence = min(0.9, 0.4 + scores[best] * 0.15) + return best, confidence + + +def extract_entities_rule(text: str) -> Dict: + """규칙 기반 엔티티 추출.""" + entities: Dict = {} + text_lower = text.lower() + + # 우선순위 + for prio, keywords in _PRIORITY_KEYWORDS.items(): + if any(kw.lower() in text_lower for kw in keywords): + entities["priority"] = prio + break + if "priority" not in entities: + entities["priority"] = "MEDIUM" + + # SR 유형 + for sr_type, keywords in _SR_TYPE_KEYWORDS.items(): + if any(kw.lower() in text_lower for kw in keywords): + entities["sr_type"] = sr_type + break + if "sr_type" not in entities: + entities["sr_type"] = "OTHER" + + # 서버명 패턴: app-서버명, web01, was-prod 등 + server_pattern = re.search( + r'(?:서버|서비스|시스템|앱)[\s:]*([A-Za-z0-9\-_가-힣]+)', text + ) + if server_pattern: + entities["server"] = server_pattern.group(1) + + # SR-xxxx 패턴 + sr_ref = re.search(r'SR-\d{4,}', text, re.IGNORECASE) + if sr_ref: + entities["sr_ref"] = sr_ref.group().upper() + + # 설명 (원문 그대로) + entities["description"] = text.strip() + + return entities + + +# ── Ollama LLM 기반 NLU ─────────────────────────────────────────────────────── + +_NLU_PROMPT_TEMPLATE = """\ +너는 IT 서비스 관리(ITSM) 챗봇이다. 사용자 메시지를 분석하여 JSON만 반환하라. + +사용자 메시지: "{message}" + +이전 대화 컨텍스트: +{context} + +다음 JSON을 반환하라 (다른 텍스트 없이 순수 JSON만): +{{ + "intent": "SR_CREATE | INCIDENT_REPORT | DEPLOY_REQUEST | SR_QUERY | GENERAL_INQUIRY | CLARIFICATION", + "confidence": 0.0~1.0, + "entities": {{ + "priority": "CRITICAL | HIGH | MEDIUM | LOW", + "sr_type": "DEPLOY | RESTART | LOG | INCIDENT | OTHER", + "description": "문제 설명 (원문 기준 요약)", + "server": "서버명 또는 null", + "application": "애플리케이션명 또는 null", + "symptom": "증상 요약 또는 null" + }}, + "needs_clarification": true/false, + "clarification_prompt": "추가 질문 또는 null", + "reply": "사용자에게 보낼 친절한 한국어 응답 (1-3 문장)" +}}""" + + +async def analyze_with_llm( + message: str, + context: List[Dict], + model: str = DEFAULT_MODEL, + timeout: int = 30, +) -> Optional[Dict]: + """Ollama LLM으로 메시지 분석. 실패 시 None 반환.""" + context_str = "\n".join( + f"{m['role']}: {m['content'][:100]}" for m in context[-4:] + ) if context else "없음" + + prompt = _NLU_PROMPT_TEMPLATE.format( + message=message, + context=context_str, + ) + + try: + async with httpx.AsyncClient(timeout=timeout) as client: + resp = await client.post( + OLLAMA_URL, + json={"model": model, "prompt": prompt, "stream": False}, + ) + if resp.status_code != 200: + return None + + raw = resp.json().get("response", "") + # JSON 추출 + start = raw.find("{") + end = raw.rfind("}") + 1 + if start >= 0 and end > start: + parsed = json.loads(raw[start:end]) + return parsed + except (httpx.ConnectError, httpx.TimeoutException): + logger.debug("Ollama 연결 실패 — 규칙 기반 폴백 사용") + except json.JSONDecodeError as e: + logger.debug("LLM JSON 파싱 실패: %s", e) + return None + + +# ── 응답 템플릿 ────────────────────────────────────────────────────────────── + +_REPLY_TEMPLATES = { + "SR_CREATE": { + "need_info": ( + "무슨 문제가 발생했는지 파악했습니다. " + "SR 접수를 위해 몇 가지 정보가 더 필요합니다.\n\n" + "{question}" + ), + "confirm": ( + "다음과 같이 SR을 접수하겠습니다:\n" + "- 제목: {title}\n" + "- 우선순위: {priority}\n" + "- 유형: {sr_type}\n\n" + "접수를 진행할까요? (네/아니오)" + ), + "created": ( + "✅ SR이 접수되었습니다!\n\n" + "- SR ID: **{sr_id}**\n" + "- 제목: {title}\n" + "- 우선순위: {priority}\n\n" + "담당자가 곧 연락드릴 예정입니다." + ), + }, + "INCIDENT_REPORT": { + "created": ( + "🚨 긴급 인시던트로 접수했습니다!\n\n" + "- SR ID: **{sr_id}**\n" + "- 우선순위: CRITICAL\n\n" + "온콜 엔지니어에게 즉시 알림을 발송했습니다." + ), + }, + "GENERAL_INQUIRY": { + "default": ( + "안녕하세요! GUARDiA ITSM 챗봇입니다. 😊\n\n" + "다음과 같은 도움을 드릴 수 있습니다:\n" + "• IT 장애/오류 신고 → SR 자동 접수\n" + "• 배포 요청\n" + "• SR 상태 조회 (예: SR-0042 상태가 어떻게 됩니까?)\n\n" + "어떤 문제가 발생했나요?" + ), + }, + "SR_QUERY": { + "default": "SR 조회는 'SR-{숫자}' 형식으로 말씀해 주세요. 예: SR-0042 상태 알려줘", + }, +} + +# 수집 필요 정보 순서 +_REQUIRED_FIELDS = ["description", "priority", "sr_type"] + +_CLARIFICATION_QUESTIONS = { + "description": "어떤 문제가 발생했나요? 증상을 구체적으로 설명해 주세요.", + "priority": "긴급도가 어느 정도인가요? (긴급/높음/보통/낮음)", + "sr_type": "어떤 유형의 요청인가요? (장애신고/배포요청/재기동/로그분석/기타)", +} + + +def build_sr_title(entities: Dict) -> str: + """수집된 엔티티로 SR 제목 생성.""" + desc = entities.get("description", "") + sr_type = entities.get("sr_type", "OTHER") + server = entities.get("server", "") + + type_prefix = { + "DEPLOY": "[배포]", + "RESTART": "[재기동]", + "LOG": "[로그분석]", + "INCIDENT": "[장애]", + "OTHER": "[SR]", + }.get(sr_type, "[SR]") + + title_parts = [type_prefix] + if server: + title_parts.append(server) + + # 설명 앞 30자 + short_desc = desc[:40].strip() + if short_desc: + title_parts.append(short_desc) + + return " ".join(title_parts) + + +# ── 대화 처리 메인 함수 ──────────────────────────────────────────────────────── + +async def process_message( + message: str, + session_context: Dict, + use_llm: bool = True, + model: str = DEFAULT_MODEL, +) -> Dict: + """ + 사용자 메시지 처리. + + session_context: { + "history": [...], # 이전 메시지 목록 + "collected": {...}, # 수집된 엔티티 + "state": "GATHERING|CONFIRMING|DONE", + "intent": str, + } + + Returns: { + "intent": str, + "entities": dict, + "reply": str, + "needs_clarification": bool, + "clarification_prompt": str | None, + "action": "CREATE_SR | NONE", + "sr_data": dict | None, + "confidence": float, + } + """ + history = session_context.get("history", []) + collected = session_context.get("collected", {}) + state = session_context.get("state", "GATHERING") + prev_intent = session_context.get("intent", "") + + # ── LLM 분석 시도 ───────────────────────────────────────────────────────── + llm_result = None + if use_llm: + llm_result = await analyze_with_llm(message, history, model=model) + + # LLM 결과 또는 규칙 기반 폴백 + if llm_result: + intent = llm_result.get("intent", "GENERAL_INQUIRY") + confidence = llm_result.get("confidence", 0.5) + entities = llm_result.get("entities", {}) + llm_reply = llm_result.get("reply", "") + needs_clarif = llm_result.get("needs_clarification", False) + clarif_prompt = llm_result.get("clarification_prompt") + else: + # 규칙 기반 폴백 + intent, confidence = classify_intent_rule(message) + entities = extract_entities_rule(message) + llm_reply = "" + needs_clarif = False + clarif_prompt = None + + # CLARIFICATION 상태: 이전 인텐트 유지 + if intent == "CLARIFICATION" and prev_intent: + intent = prev_intent + + # 수집된 엔티티 업데이트 (None이 아닌 값만) + for k, v in entities.items(): + if v and v != "null": + collected[k] = v + + # 상태 머신 처리 + result = { + "intent": intent, + "entities": collected.copy(), + "reply": "", + "needs_clarification": False, + "clarification_prompt": None, + "action": "NONE", + "sr_data": None, + "confidence": confidence, + } + + if intent in ("SR_CREATE", "INCIDENT_REPORT", "DEPLOY_REQUEST"): + # SR 관련 인텐트 처리 + result = await _handle_sr_flow( + message, intent, collected, state, llm_reply, needs_clarif, clarif_prompt, result + ) + elif intent == "SR_QUERY": + result["reply"] = _handle_sr_query(message, collected) + elif intent == "GENERAL_INQUIRY": + result["reply"] = llm_reply or _REPLY_TEMPLATES["GENERAL_INQUIRY"]["default"] + else: + result["reply"] = llm_reply or "무슨 문제가 발생했나요? 자세히 말씀해 주세요." + + return result + + +async def _handle_sr_flow( + message: str, + intent: str, + collected: Dict, + state: str, + llm_reply: str, + needs_clarif: bool, + clarif_prompt: Optional[str], + result: Dict, +) -> Dict: + """SR 관련 대화 흐름 처리.""" + # 긴급 인시던트는 즉시 접수 + if intent == "INCIDENT_REPORT": + if "priority" not in collected or collected.get("priority") != "CRITICAL": + collected["priority"] = "CRITICAL" + if "description" not in collected: + collected["description"] = message + result["action"] = "CREATE_SR" + result["sr_data"] = _build_sr_data(collected, intent) + result["reply"] = llm_reply or ( + "🚨 긴급 인시던트로 접수합니다. " + "담당자와 온콜 팀에 즉시 알림을 발송합니다." + ) + return result + + # 일반 SR — 필수 정보 수집 + missing = [f for f in ["description", "priority"] if f not in collected or not collected.get(f)] + + if missing: + # 첫 번째 미수집 필드에 대한 질문 + next_q = missing[0] + question = clarif_prompt or _CLARIFICATION_QUESTIONS.get(next_q, "추가 정보를 알려주세요.") + result["reply"] = llm_reply or _REPLY_TEMPLATES["SR_CREATE"]["need_info"].format(question=question) + result["needs_clarification"] = True + result["clarification_prompt"] = question + return result + + # 모든 정보 수집 완료 → SR 생성 준비 + if state == "GATHERING": + # 확인 요청 + title = build_sr_title(collected) + result["reply"] = ( + llm_reply or + _REPLY_TEMPLATES["SR_CREATE"]["confirm"].format( + title=title, + priority=collected.get("priority", "MEDIUM"), + sr_type=collected.get("sr_type", "OTHER"), + ) + ) + result["needs_clarification"] = True + result["clarification_prompt"] = "접수를 진행할까요? (네/아니오)" + return result + + # 확인 응답 처리 + confirm_positive = any(w in message.lower() for w in ["네", "예", "yes", "맞아", "확인", "진행", "ok"]) + confirm_negative = any(w in message.lower() for w in ["아니", "no", "취소", "수정", "다시"]) + + if confirm_positive or state == "CONFIRMING": + result["action"] = "CREATE_SR" + result["sr_data"] = _build_sr_data(collected, intent) + result["reply"] = "" # 실제 SR ID는 라우터에서 채움 + return result + elif confirm_negative: + result["reply"] = "알겠습니다. 어떤 내용을 수정하시겠나요?" + result["needs_clarification"] = True + result["clarification_prompt"] = "수정할 내용을 말씀해 주세요." + return result + + result["reply"] = llm_reply or "접수를 진행할까요? (네/아니오)" + result["needs_clarification"] = True + return result + + +def _build_sr_data(collected: Dict, intent: str) -> Dict: + """수집된 엔티티로 SR 생성 데이터 빌드.""" + priority = collected.get("priority", "MEDIUM") + if intent == "INCIDENT_REPORT": + priority = "CRITICAL" + + sr_type_map = { + "DEPLOY": "DEPLOY", + "RESTART": "RESTART", + "LOG": "LOG", + "INCIDENT": "OTHER", + "OTHER": "OTHER", + } + sr_type = sr_type_map.get(collected.get("sr_type", "OTHER"), "OTHER") + + server = collected.get("server", "") + desc = collected.get("description", "자연어 챗봇 접수") + title = build_sr_title(collected) + + return { + "title": title, + "description": desc, + "priority": priority, + "sr_type": sr_type, + "server_name": server, + "source": "chatbot", + } + + +def _handle_sr_query(message: str, collected: Dict) -> str: + """SR 조회 의도 처리.""" + sr_ref = collected.get("sr_ref") + if sr_ref: + return f"SR '{sr_ref}' 조회를 진행합니다. 잠시만 기다려 주세요." + return _REPLY_TEMPLATES["SR_QUERY"]["default"] + + +def new_session_key() -> str: + """새 세션 키 생성.""" + return str(uuid.uuid4()).replace("-", "")[:24] diff --git a/core/cicd.py b/core/cicd.py new file mode 100644 index 0000000..9f8e659 --- /dev/null +++ b/core/cicd.py @@ -0,0 +1,610 @@ +""" +Jenkins CI/CD 연동 클라이언트. + +GUARDiA ITSM → Jenkins REST API 호출 및 콜백 처리. +보안: 서버 IP, 자격증명은 환경변수에서만 로드 — 코드에 하드코딩 금지. +""" +from __future__ import annotations + +import logging +import os +from typing import Any, Optional + +import httpx + +logger = logging.getLogger(__name__) + +# ── 환경변수 ────────────────────────────────────────────────────────────────── +_JENKINS_URL = os.getenv("JENKINS_URL", "http://jenkins.agency.go.kr:8080") +_JENKINS_USER = os.getenv("JENKINS_USER", "itsm-bot") +_JENKINS_TOKEN = os.getenv("JENKINS_TOKEN", "") + +# Jenkins → ITSM 콜백을 인증할 공유 시크릿 +_CALLBACK_SECRET = os.getenv("JENKINS_CALLBACK_SECRET", "") + +# HTTP 타임아웃 (초) +_TIMEOUT = float(os.getenv("JENKINS_TIMEOUT", "30")) + + +# ── 유틸 ───────────────────────────────────────────────────────────────────── +def _auth() -> tuple[str, str]: + if not _JENKINS_TOKEN: + raise RuntimeError( + "JENKINS_TOKEN 환경변수가 설정되지 않았습니다. " + "Jenkins API 토큰을 .env에 등록하세요." + ) + return (_JENKINS_USER, _JENKINS_TOKEN) + + +def _sanitize_error(exc: Exception) -> str: + """에러 메시지에서 IP·계정·비밀번호 패턴 제거.""" + import re + msg = str(exc) + msg = re.sub(r"\b\d{1,3}(?:\.\d{1,3}){3}\b", "", msg) + msg = re.sub(r"(?i)(password|token|secret|key)\s*[=:]\s*\S+", r"\1=", msg) + return msg + + +# ── 파이프라인 트리거 ──────────────────────────────────────────────────────── +async def trigger_pipeline( + job_name: str, + session_id: int, + sr_id: str, + deploy_env: str = "dev", + target_server: str = "", + skip_test: bool = False, + extra_params: Optional[dict[str, Any]] = None, +) -> dict[str, Any]: + """ + Jenkins 파이프라인 빌드 트리거. + + Returns: + {"queued": True, "queue_id": "<숫자>", "queue_url": ""} + Raises: + RuntimeError: 트리거 실패 시 + """ + url = f"{_JENKINS_URL}/job/{job_name}/buildWithParameters" + params: dict[str, str] = { + "ITSM_SESSION_ID": str(session_id), + "ITSM_SR_ID": sr_id, + "DEPLOY_ENV": deploy_env, + "TARGET_SERVER": target_server, + "SKIP_TEST": str(skip_test).lower(), + } + if extra_params: + params.update({k: str(v) for k, v in extra_params.items()}) + + logger.info( + "Jenkins 파이프라인 트리거: job=%s session_id=%s env=%s", + job_name, session_id, deploy_env, + ) + + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.post(url, auth=_auth(), params=params) + except httpx.ConnectError as exc: + raise RuntimeError(f"Jenkins 서버 연결 실패: {_sanitize_error(exc)}") from exc + except httpx.TimeoutException as exc: + raise RuntimeError(f"Jenkins 요청 타임아웃 ({_TIMEOUT}s)") from exc + except Exception as exc: + raise RuntimeError(f"Jenkins 트리거 오류: {_sanitize_error(exc)}") from exc + + if resp.status_code not in (200, 201): + raise RuntimeError( + f"Jenkins 트리거 실패: HTTP {resp.status_code} " + f"(job={job_name})" + ) + + # 큐 ID는 Location 헤더에서 추출 (예: /queue/item/42/) + location = resp.headers.get("Location", "") + queue_id: Optional[str] = None + if location: + parts = location.rstrip("/").split("/") + queue_id = parts[-1] if parts[-1].isdigit() else None + + logger.info("Jenkins 파이프라인 큐 등록: queue_id=%s", queue_id) + return { + "queued": True, + "queue_id": queue_id, + "queue_url": location, + "job_name": job_name, + "session_id": session_id, + } + + +# ── 빌드 상태 조회 ─────────────────────────────────────────────────────────── +async def get_build_status(job_name: str, build_number: int) -> dict[str, Any]: + """ + 특정 빌드의 상태 조회. + + Returns: + {"result": "SUCCESS|FAILURE|ABORTED|null", "building": bool, ...} + """ + url = f"{_JENKINS_URL}/job/{job_name}/{build_number}/api/json" + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.get(url, auth=_auth()) + resp.raise_for_status() + data = resp.json() + return { + "result": data.get("result"), + "building": data.get("building", False), + "duration_ms": data.get("duration", 0), + "timestamp": data.get("timestamp"), + "url": data.get("url", ""), + "display_name": data.get("displayName", ""), + } + except httpx.HTTPStatusError as exc: + if exc.response.status_code == 404: + raise RuntimeError(f"빌드를 찾을 수 없습니다: {job_name} #{build_number}") from exc + raise RuntimeError(f"빌드 상태 조회 실패: {_sanitize_error(exc)}") from exc + except Exception as exc: + raise RuntimeError(f"빌드 상태 조회 오류: {_sanitize_error(exc)}") from exc + + +# ── 큐 아이템 → 빌드 번호 조회 ────────────────────────────────────────────── +async def get_queue_item(queue_id: str) -> dict[str, Any]: + """ + 큐 아이템에서 실제 빌드 번호를 조회. + 빌드가 시작되면 executable.number 필드가 채워진다. + """ + url = f"{_JENKINS_URL}/queue/item/{queue_id}/api/json" + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.get(url, auth=_auth()) + resp.raise_for_status() + data = resp.json() + executable = data.get("executable") or {} + return { + "queue_id": queue_id, + "build_number": executable.get("number"), + "build_url": executable.get("url", ""), + "blocked": data.get("blocked", False), + "why": data.get("why", ""), + } + except Exception as exc: + raise RuntimeError(f"큐 아이템 조회 오류: {_sanitize_error(exc)}") from exc + + +# ── 빌드 취소 ──────────────────────────────────────────────────────────────── +async def abort_build(job_name: str, build_number: int) -> bool: + """실행 중인 빌드를 강제 중단.""" + url = f"{_JENKINS_URL}/job/{job_name}/{build_number}/stop" + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.post(url, auth=_auth()) + return resp.status_code in (200, 302) + except Exception as exc: + logger.warning("빌드 중단 실패: %s", _sanitize_error(exc)) + return False + + +# ── Jenkins 연결 테스트 ─────────────────────────────────────────────────────── +async def ping() -> dict[str, Any]: + """Jenkins 서버 연결 상태 확인.""" + url = f"{_JENKINS_URL}/api/json" + try: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.get(url, auth=_auth()) + resp.raise_for_status() + data = resp.json() + return { + "ok": True, + "version": resp.headers.get("X-Jenkins", "unknown"), + "mode": data.get("mode", ""), + "num_executors": data.get("numExecutors", 0), + } + except Exception as exc: + return {"ok": False, "error": _sanitize_error(exc)} + + +# ── 콜백 인증 검증 ──────────────────────────────────────────────────────────── +def verify_callback_secret(received_secret: str) -> bool: + """ + Jenkins에서 보낸 콜백 시크릿을 검증. + JENKINS_CALLBACK_SECRET이 설정되지 않으면 검증 생략 (개발용). + """ + if not _CALLBACK_SECRET: + logger.warning("JENKINS_CALLBACK_SECRET 미설정 — 콜백 인증 건너뜀 (개발 전용)") + return True + import hmac + return hmac.compare_digest(_CALLBACK_SECRET, received_secret) + + +# ── SonarQube 연동 ──────────────────────────────────────────────────────────── + +_SONAR_URL = os.getenv("SONARQUBE_URL", "http://sonarqube.agency.go.kr:9000") +_SONAR_TOKEN = os.getenv("SONARQUBE_TOKEN", "") + + +class SonarStatus(str): + OK = "OK" + WARN = "WARN" + ERROR = "ERROR" + NONE = "NONE" + + +class SonarResult: + """SonarQube Quality Gate 결과 데이터 클래스.""" + + __slots__ = ( + "project_key", "status", "conditions", + "raw_url", "error_msg", + ) + + def __init__( + self, + project_key: str, + status: str, + conditions: list[dict[str, Any]], + raw_url: str = "", + error_msg: str = "", + ) -> None: + self.project_key = project_key + self.status = status # OK | WARN | ERROR | NONE + self.conditions = conditions # [{"metric": ..., "status": ..., "actual": ...}] + self.raw_url = raw_url + self.error_msg = error_msg + + @property + def is_ok(self) -> bool: + return self.status == "OK" + + @property + def failed_conditions(self) -> list[dict[str, Any]]: + return [c for c in self.conditions if c.get("status") == "ERROR"] + + def to_dict(self) -> dict[str, Any]: + return { + "project_key": self.project_key, + "status": self.status, + "conditions": self.conditions, + "failed_conditions": self.failed_conditions, + "raw_url": self.raw_url, + "error_msg": self.error_msg, + } + + +async def _get_vibe_session(session_id: int) -> Any: + """VibeSession 레코드 조회 헬퍼.""" + from sqlalchemy import select + from database import SessionLocal + from models import VibeSession + async with SessionLocal() as db: + return (await db.execute( + select(VibeSession).where(VibeSession.id == session_id) + )).scalars().first() + + +async def _resolve_job_name(session_id: int) -> Optional[str]: + """ + VibeSession.project_id -> Project.jenkins_job_name 으로 + Jenkins 잡 이름을 조회합니다. + project_id 없거나 jenkins_job_name 미설정 시 None 반환. + """ + from sqlalchemy import select + from database import SessionLocal + from models import VibeSession, Project + async with SessionLocal() as db: + vs = (await db.execute( + select(VibeSession).where(VibeSession.id == session_id) + )).scalars().first() + if not vs or not vs.project_id: + return None + proj = (await db.execute( + select(Project).where(Project.id == vs.project_id) + )).scalars().first() + if not proj: + return None + return getattr(proj, "jenkins_job_name", None) + + +async def get_sonarqube_result(project_key: str) -> SonarResult: + """ + SonarQube Quality Gate 상태 조회. + + API: GET {SONARQUBE_URL}/api/qualitygates/project_status?projectKey={project_key} + + Returns: + SonarResult(status="OK"|"WARN"|"ERROR"|"NONE", conditions=[...]) + Raises: + RuntimeError: API 호출 실패 시 + """ + if not _SONAR_TOKEN: + logger.warning("SONARQUBE_TOKEN 미설정 — SonarQube 연동 불가") + return SonarResult(project_key=project_key, status="NONE", conditions=[], + error_msg="SONARQUBE_TOKEN 미설정") + + url = f"{_SONAR_URL}/api/qualitygates/project_status" + headers = {"Authorization": f"Bearer {_SONAR_TOKEN}"} + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.get(url, headers=headers, + params={"projectKey": project_key}) + resp.raise_for_status() + data = resp.json() + except httpx.HTTPStatusError as exc: + if exc.response.status_code == 404: + return SonarResult(project_key=project_key, status="NONE", conditions=[], + error_msg=f"프로젝트를 찾을 수 없습니다: {project_key}") + raise RuntimeError(f"SonarQube API 오류: HTTP {exc.response.status_code}") from exc + except Exception as exc: + raise RuntimeError(f"SonarQube 조회 실패: {_sanitize_error(exc)}") from exc + + pj_status = data.get("projectStatus", {}) + gate_status = pj_status.get("status", "NONE") # OK | WARN | ERROR | NONE + + # conditions 정규화 + raw_conditions = pj_status.get("conditions", []) + conditions: list[dict[str, Any]] = [] + for c in raw_conditions: + conditions.append({ + "metric": c.get("metricKey", ""), + "status": c.get("status", ""), + "actual": c.get("actualValue", ""), + "error_threshold": c.get("errorThreshold", ""), + "comparator": c.get("comparator", ""), + }) + + raw_url = f"{_SONAR_URL}/dashboard?id={project_key}" + logger.info("SonarQube QG: project=%s status=%s", project_key, gate_status) + return SonarResult( + project_key = project_key, + status = gate_status, + conditions = conditions, + raw_url = raw_url, + ) + + +async def handle_sonar_gate_failure( + session_id: int, + result: SonarResult, + db: Any, # AsyncSession — Any to avoid circular import +) -> Optional[str]: + """ + SonarQube Quality Gate ERROR 시 SR 자동 생성. + + 조건: + - result.status == "ERROR" + - 동일 session_id + 오늘 날짜 SR이 없을 때만 생성 (중복 방지) + + inst_id 조회 경로: + VibeSession.sr_id -> SRRequest.inst_id + + Returns: + 생성된 SR ID 또는 None (스킵된 경우) + """ + if result.status != "ERROR": + return None + + from datetime import date + from uuid import uuid4 + from sqlalchemy import select + from models import SRRequest, SRStatus, SRType, Priority, VibeSession + + # VibeSession 조회 + vs_row = (await db.execute( + select(VibeSession).where(VibeSession.id == session_id) + )).scalars().first() + + if not vs_row: + logger.warning("SonarQube: VibeSession session_id=%s 없음 — SR 미생성", session_id) + return None + + # inst_id: VibeSession.sr_id -> SRRequest.inst_id + inst_id: Optional[int] = None + if vs_row.sr_id: + sr_parent = (await db.execute( + select(SRRequest).where(SRRequest.sr_id == vs_row.sr_id) + )).scalars().first() + if sr_parent: + inst_id = sr_parent.inst_id + + # 중복 SR 방지: 오늘 동일 session_id에 대한 SONAR SR 확인 + today_prefix = f"SONAR-{date.today().strftime('%Y%m%d')}-{session_id}-" + dup = (await db.execute( + select(SRRequest).where(SRRequest.sr_id.startswith(today_prefix)) + )).scalars().first() + if dup: + logger.info("SonarQube SR 중복 스킵: %s", dup.sr_id) + return dup.sr_id + + sr_id = f"SONAR-{date.today().strftime('%Y%m%d')}-{session_id}-{str(uuid4())[:4].upper()}" + + # 실패한 조건 요약 + failed = result.failed_conditions + condition_summary = "\n".join( + f" - {c['metric']}: {c['actual']} (기준: {c['comparator']} {c['error_threshold']})" + for c in failed + ) if failed else " (상세 없음)" + + description = ( + f"SonarQube Quality Gate 실패\n" + f"프로젝트: {result.project_key}\n" + f"상태: {result.status}\n" + f"실패 조건:\n{condition_summary}\n\n" + f"SonarQube 대시보드: {result.raw_url}\n" + f"배포 세션: {session_id}" + ) + + sr = SRRequest( + sr_id = sr_id, + sr_type = SRType.OTHER, + priority = Priority.HIGH, + status = SRStatus.RECEIVED, + title = f"[SonarQube] Quality Gate 실패 - {result.project_key}", + description = description, + inst_id = inst_id, + requested_by = "sonarqube-bot", + ) + db.add(sr) + await db.commit() + await db.refresh(sr) + + logger.warning( + "SonarQube Quality Gate 실패 SR 생성: sr_id=%s project=%s", + sr_id, result.project_key, + ) + return sr_id + + +# ── Jenkins 빌드 로그 SSE 스트리밍 ───────────────────────────────────────────── + +async def get_progressive_log( + session_id: int, + offset: int = 0, +) -> tuple[str, int]: + """ + Jenkins progressive log 폴링. + + Jenkins API: GET {job_url}/logText/progressiveText?start={offset} + 응답 헤더의 X-Text-Size 가 다음 offset. + + job_name 조회 경로: + VibeSession.project_id -> Project.jenkins_job_name + build_number: VibeSession 에 별도 컬럼 없음 -> "lastBuild" 사용 + + Returns: + (log_chunk: str, next_offset: int) + """ + vs = await _get_vibe_session(session_id) + if not vs: + return ("VibeSession을 찾을 수 없습니다.", offset) + + job_name = await _resolve_job_name(session_id) + if not job_name: + return ("[빌드 정보 없음 - project.jenkins_job_name 미설정]", offset) + + # build_number: VibeSession 에 별도 저장하지 않으므로 lastBuild 사용 + # (트리거 후 최신 빌드 = 현재 진행 중인 빌드) + build_ref = "lastBuild" + url = f"{_JENKINS_URL}/job/{job_name}/{build_ref}/logText/progressiveText" + params = {"start": str(offset)} + + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.get(url, auth=_auth(), params=params) + if resp.status_code == 404: + return ("[로그를 찾을 수 없습니다]", offset) + resp.raise_for_status() + + chunk = resp.text + next_offset = int(resp.headers.get("X-Text-Size", offset + len(chunk.encode()))) + return (chunk, next_offset) + except Exception as exc: + logger.warning("Jenkins 로그 조회 실패: %s", _sanitize_error(exc)) + return (f"[로그 조회 오류: {_sanitize_error(exc)}]", offset) + + +async def is_build_complete(session_id: int) -> bool: + """ + Jenkins 빌드가 완료(또는 오류)되었는지 확인. + + VibeSession.project_id -> Project.jenkins_job_name 으로 job 조회. + lastBuild 기준으로 building 상태를 확인. + + Returns: + True — building=False (완료/실패/중단) 또는 VibeSession 세션이 최종 상태 + False — building=True 또는 정보 없음 + """ + from models import VibeSession, VibeSessionStatus + + vs = await _get_vibe_session(session_id) + if not vs: + return True # 세션 없음 → 스트리밍 종료 + + # VibeSession 자체가 최종 상태면 빌드 완료로 간주 + final_statuses = { + VibeSessionStatus.COMPLETED, + VibeSessionStatus.FAILED, + VibeSessionStatus.CANCELLED, + } + if vs.status in final_statuses: + return True + + job_name = await _resolve_job_name(session_id) + if not job_name: + return False # 아직 job 정보 없음 + + try: + status = await get_build_status(job_name, "lastBuild") + return not status.get("building", True) + except Exception as exc: + logger.warning("빌드 상태 확인 실패: %s", _sanitize_error(exc)) + return False + + +# ── 빌드 이력 조회 ──────────────────────────────────────────────────────────── + +class BuildInfo: + """Jenkins 빌드 이력 항목.""" + + __slots__ = ("number", "result", "building", "duration_ms", "timestamp", "url") + + def __init__(self, **kwargs: Any) -> None: + for k in self.__slots__: + setattr(self, k, kwargs.get(k)) + + def to_dict(self) -> dict[str, Any]: + return {k: getattr(self, k) for k in self.__slots__} + + +async def get_build_history( + project_name: str, + limit: int = 10, +) -> list[BuildInfo]: + """ + Jenkins 프로젝트의 최근 빌드 이력 조회. + + Returns: + list[BuildInfo] (최신순) + """ + url = f"{_JENKINS_URL}/job/{project_name}/api/json" + params = { + "tree": ( + "builds[number,result,building,duration,timestamp,url]" + f"{{0,{limit}}}" + ) + } + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.get(url, auth=_auth(), params=params) + resp.raise_for_status() + data = resp.json() + except Exception as exc: + raise RuntimeError(f"빌드 이력 조회 실패: {_sanitize_error(exc)}") from exc + + builds: list[BuildInfo] = [] + for b in data.get("builds", []): + builds.append(BuildInfo( + number = b.get("number"), + result = b.get("result"), + building = b.get("building", False), + duration_ms = b.get("duration", 0), + timestamp = b.get("timestamp"), + url = b.get("url", ""), + )) + return builds + + +async def get_artifact_url( + project_name: str, + build_number: int, + artifact: str, +) -> str: + """ + Jenkins 특정 빌드의 아티팩트 다운로드 URL 생성. + + artifact 예: "target/app.jar", "dist/app.zip" + + Returns: + 직접 다운로드 URL (인증 없이 접근 가능하도록 Jenkins 설정 필요) + """ + # artifact 경로 traversal 방지 + import re + safe_artifact = re.sub(r"\.\./", "", artifact).lstrip("/") + return ( + f"{_JENKINS_URL}/job/{project_name}/{build_number}" + f"/artifact/{safe_artifact}" + ) diff --git a/core/code_review.py b/core/code_review.py new file mode 100644 index 0000000..f68f649 --- /dev/null +++ b/core/code_review.py @@ -0,0 +1,425 @@ +""" +GUARDiA ITSM — B-3 코드 리뷰 에이전트 엔진 + +기능: + - 프로젝트 소스 트리 스캔 (C:\\GUARDiA\\projects\\{project_dir}) + - 파일 청크 분할 → Ollama API 순차 전송 + - 발견 항목 구조화 (severity / category / file / line / message / suggestion) + - 종합 점수 산출 (0-100) + - 결과 DB 저장 (CodeReview 모델) + +Ollama 모델: codellama / deepseek-coder / qwen2.5-coder (환경변수로 선택) +내부 전용: 외부 API 호출 없음 +""" +from __future__ import annotations + +import json +import logging +import os +import time +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import httpx +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, update + +logger = logging.getLogger(__name__) + +# ── 설정 ────────────────────────────────────────────────────────────────────── +PROJECTS_ROOT = Path(os.getenv("GUARDIA_PROJECTS_ROOT", r"C:\GUARDiA\projects")) +OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434") +DEFAULT_MODEL = os.getenv("CODE_REVIEW_MODEL", "codellama") +MAX_FILE_SIZE = 50_000 # 50KB 이상 파일 스킵 +MAX_FILES = 50 # 프로젝트당 최대 리뷰 파일 수 +CHUNK_LINES = 150 # LLM에 한 번에 보낼 최대 줄 수 + +# 리뷰 대상 확장자 +REVIEWABLE_EXT = { + ".java", ".py", ".php", ".js", ".ts", ".jsx", ".tsx", + ".cs", ".go", ".rb", ".kt", ".swift", ".cpp", ".c", ".h", + ".sql", ".html", ".vue", ".svelte", +} + +# 제외 경로 패턴 +SKIP_DIRS = { + "node_modules", ".git", "__pycache__", "target", "build", + "dist", ".venv", "venv", ".mvn", ".metadata", ".idea", + ".vscode", ".settings", "vendor", +} + +# ── 파일 스캔 ───────────────────────────────────────────────────────────────── + +def scan_source_files(base_path: Path) -> List[Path]: + """리뷰 가능한 소스 파일 목록 반환.""" + files: List[Path] = [] + for root, dirs, filenames in os.walk(base_path): + # 제외 디렉토리 건너뜀 + dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")] + for fn in filenames: + p = Path(root) / fn + if p.suffix.lower() not in REVIEWABLE_EXT: + continue + if p.stat().st_size > MAX_FILE_SIZE: + continue + files.append(p) + if len(files) >= MAX_FILES: + return files + return files + + +def detect_tech_stack(files: List[Path]) -> str: + """파일 확장자 분포로 기술 스택 감지.""" + ext_counts: Dict[str, int] = {} + for f in files: + ext = f.suffix.lower() + ext_counts[ext] = ext_counts.get(ext, 0) + 1 + + if not ext_counts: + return "unknown" + + stack_map = { + ".java": "java", ".py": "python", ".php": "php", + ".go": "go", ".cs": "csharp", ".rb": "ruby", + ".kt": "kotlin", ".swift": "swift", ".cpp": "cpp", + } + js_exts = {".js", ".ts", ".jsx", ".tsx", ".vue", ".svelte"} + + detected = set() + for ext, count in ext_counts.items(): + if count > 0: + if ext in js_exts: + detected.add("javascript") + elif ext in stack_map: + detected.add(stack_map[ext]) + + if len(detected) == 0: + return "mixed" + elif len(detected) == 1: + return detected.pop() + else: + return "mixed" + + +# ── LLM 호출 ───────────────────────────────────────────────────────────────── + +async def _call_ollama(prompt: str, model: str, timeout: int = 120) -> str: + """Ollama API 호출 (스트리밍 없이 단일 응답).""" + try: + async with httpx.AsyncClient(timeout=timeout) as client: + resp = await client.post( + f"{OLLAMA_URL}/api/generate", + json={ + "model": model, + "prompt": prompt, + "stream": False, + "options": { + "temperature": 0.1, + "top_p": 0.9, + "num_predict": 1500, + }, + }, + ) + resp.raise_for_status() + data = resp.json() + return data.get("response", "") + except httpx.ConnectError: + logger.warning("Ollama 연결 실패 (%s) — 오프라인 모드로 대체", OLLAMA_URL) + return "" + except Exception as exc: + logger.warning("Ollama 호출 오류: %s", exc) + return "" + + +def _build_review_prompt(file_path: str, code_chunk: str, tech_stack: str, focus: Optional[str]) -> str: + """코드 리뷰 프롬프트 생성.""" + focus_hint = f"\n특히 다음에 집중: {focus}" if focus else "" + return f"""당신은 시니어 소프트웨어 엔지니어입니다. 아래 코드를 검토하고 문제점을 JSON 형식으로 보고하세요. +기술 스택: {tech_stack} +파일: {file_path}{focus_hint} + +검토 항목: +1. SECURITY: SQL 인젝션, XSS, 인증/인가 취약점, 패스워드 평문 저장, 민감정보 하드코딩 +2. PERFORMANCE: N+1 쿼리, 메모리 누수, 불필요한 반복, 비효율적 알고리즘 +3. CODE_QUALITY: SRP 위반, 복잡도, 중복 코드, null 처리, 예외 처리 누락 +4. ARCHITECTURE: 레이어 위반, 순환 의존성, 강결합 +5. TESTING: 테스트 부족, 테스트 가능성 + +``` +{code_chunk} +``` + +반드시 다음 JSON 배열 형식으로만 응답하세요. 설명 없이 JSON만 출력: +[ + {{ + "severity": "CRITICAL|HIGH|MEDIUM|LOW|INFO", + "category": "SECURITY|PERFORMANCE|CODE_QUALITY|ARCHITECTURE|TESTING|DOCUMENTATION", + "line": <줄번호 또는 null>, + "message": "문제 설명 (한국어)", + "suggestion": "개선 방안 (한국어)" + }} +] +문제가 없으면 빈 배열 [] 반환.""" + + +def _parse_findings(llm_output: str, file_path: str) -> List[Dict[str, Any]]: + """LLM 출력에서 findings JSON 파싱.""" + if not llm_output.strip(): + return [] + try: + # JSON 배열 추출 (앞뒤 텍스트 제거) + start = llm_output.find("[") + end = llm_output.rfind("]") + 1 + if start == -1 or end == 0: + return [] + raw = llm_output[start:end] + items = json.loads(raw) + result = [] + for item in items: + if not isinstance(item, dict): + continue + result.append({ + "file": file_path, + "severity": item.get("severity", "INFO"), + "category": item.get("category", "CODE_QUALITY"), + "line": item.get("line"), + "message": item.get("message", ""), + "suggestion": item.get("suggestion", ""), + }) + return result + except (json.JSONDecodeError, ValueError): + return [] + + +# ── 점수 산출 ───────────────────────────────────────────────────────────────── + +def _calculate_score(findings: List[Dict[str, Any]]) -> int: + """발견 항목 기반 점수 산출 (100점 만점, 감점 방식).""" + if not findings: + return 95 # 발견 없음: 기본 95점 + + deductions = { + "CRITICAL": 20, + "HIGH": 10, + "MEDIUM": 5, + "LOW": 2, + "INFO": 0, + } + total_deduction = sum(deductions.get(f.get("severity", "INFO"), 0) for f in findings) + score = max(0, 100 - total_deduction) + return score + + +# ── 요약 생성 ───────────────────────────────────────────────────────────────── + +async def _generate_summary( + findings: List[Dict[str, Any]], + tech_stack: str, + score: int, + model: str, +) -> str: + """전체 리뷰 결과 요약 생성.""" + if not findings: + return f"코드 품질 점수: {score}/100. 특별한 문제점이 발견되지 않았습니다." + + # 심각도별 카운트 + counts = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0, "INFO": 0} + for f in findings: + counts[f.get("severity", "INFO")] = counts.get(f.get("severity", "INFO"), 0) + 1 + + # 주요 발견 항목 (CRITICAL, HIGH만) + critical_findings = [f for f in findings if f.get("severity") in ("CRITICAL", "HIGH")][:5] + critical_text = "\n".join([f"- [{f['severity']}] {f['message']}" for f in critical_findings]) + + prompt = f"""다음 코드 리뷰 결과를 3-5문장으로 요약하세요. 한국어로 작성. +기술스택: {tech_stack}, 점수: {score}/100 +발견 항목: CRITICAL={counts['CRITICAL']}, HIGH={counts['HIGH']}, MEDIUM={counts['MEDIUM']}, LOW={counts['LOW']} +주요 문제: +{critical_text} + +요약:""" + + summary = await _call_ollama(prompt, model, timeout=30) + if not summary.strip(): + # LLM 없을 때 기본 요약 + parts = [] + if counts["CRITICAL"] > 0: + parts.append(f"심각한 보안/오류 {counts['CRITICAL']}건") + if counts["HIGH"] > 0: + parts.append(f"높은 우선순위 문제 {counts['HIGH']}건") + if counts["MEDIUM"] > 0: + parts.append(f"중간 우선순위 {counts['MEDIUM']}건") + summary = ( + f"코드 품질 점수: {score}/100. " + + (", ".join(parts) + "이 발견되었습니다." if parts else "경미한 개선 사항이 있습니다.") + ) + return summary.strip() + + +# ── 메인 리뷰 실행 ──────────────────────────────────────────────────────────── + +async def run_code_review( + review_id: int, + project_dir: str, + db: AsyncSession, + target_subpath: Optional[str] = None, + focus: Optional[str] = None, + model: str = DEFAULT_MODEL, +) -> Dict[str, Any]: + """ + 코드 리뷰 실행 메인 함수. + + Args: + review_id: CodeReview.id (상태 업데이트용) + project_dir: C:\\GUARDiA\\projects\\ 하위 디렉토리명 + db: AsyncSession (상태 업데이트용) + target_subpath: 특정 서브경로만 리뷰 (None=전체) + focus: 리뷰 집중 포인트 (예: "security") + model: Ollama 모델명 + + Returns: + {score, summary, findings, file_count, line_count} + """ + from models import CodeReview + + start_time = time.time() + + async def _update_status(status: str, error: Optional[str] = None): + await db.execute( + update(CodeReview) + .where(CodeReview.id == review_id) + .values(status=status, error_msg=error) + ) + await db.commit() + + await _update_status("RUNNING") + + try: + base_path = PROJECTS_ROOT / project_dir + if target_subpath: + base_path = base_path / target_subpath + + if not base_path.exists(): + await _update_status("FAILED", f"경로를 찾을 수 없습니다: {base_path}") + return {"score": 0, "summary": f"경로 없음: {base_path}", "findings": [], "file_count": 0, "line_count": 0} + + # 파일 스캔 + files = scan_source_files(base_path) + tech_stack = detect_tech_stack(files) + total_lines = 0 + all_findings: List[Dict[str, Any]] = [] + + logger.info("코드 리뷰 시작: %s (%d파일, 스택=%s)", project_dir, len(files), tech_stack) + + for file_path in files: + try: + code = file_path.read_text(encoding="utf-8", errors="replace") + lines = code.splitlines() + total_lines += len(lines) + + # 큰 파일은 청크 분할 + for chunk_start in range(0, len(lines), CHUNK_LINES): + chunk = "\n".join(lines[chunk_start:chunk_start + CHUNK_LINES]) + if not chunk.strip(): + continue + + rel_path = str(file_path.relative_to(PROJECTS_ROOT)) + prompt = _build_review_prompt(rel_path, chunk, tech_stack, focus) + llm_output = await _call_ollama(prompt, model) + findings = _parse_findings(llm_output, rel_path) + + # 줄 번호 오프셋 보정 + for f in findings: + if f.get("line") and isinstance(f["line"], int): + f["line"] += chunk_start + + all_findings.extend(findings) + + except Exception as exc: + logger.warning("파일 리뷰 오류 %s: %s", file_path, exc) + + score = _calculate_score(all_findings) + summary = await _generate_summary(all_findings, tech_stack, score, model) + duration = int(time.time() - start_time) + + # DB 업데이트 + await db.execute( + update(CodeReview) + .where(CodeReview.id == review_id) + .values( + status="DONE", + score=score, + summary=summary, + findings_json=json.dumps(all_findings, ensure_ascii=False), + file_count=len(files), + line_count=total_lines, + tech_stack=tech_stack, + model_used=model, + duration_sec=duration, + completed_at=datetime.now(), + ) + ) + await db.commit() + + logger.info( + "코드 리뷰 완료: review_id=%d score=%d findings=%d duration=%ds", + review_id, score, len(all_findings), duration, + ) + return { + "score": score, + "summary": summary, + "findings": all_findings, + "file_count": len(files), + "line_count": total_lines, + "tech_stack": tech_stack, + } + + except Exception as exc: + logger.error("코드 리뷰 실패 review_id=%d: %s", review_id, exc) + await _update_status("FAILED", str(exc)[:500]) + return {"score": 0, "summary": f"리뷰 실패: {exc}", "findings": [], "file_count": 0, "line_count": 0} + + +# ── 빠른 보안 스캔 (정규식 기반, LLM 없이) ──────────────────────────────────── + +SECURITY_PATTERNS: List[Tuple[str, str, str]] = [ + # (패턴, 심각도, 설명) + (r"password\s*=\s*[\"'][^\"']+[\"']", "CRITICAL", "하드코딩된 패스워드"), + (r"api[_-]?key\s*=\s*[\"'][^\"']+[\"']", "CRITICAL", "하드코딩된 API 키"), + (r"secret\s*=\s*[\"'][^\"']+[\"']", "HIGH", "하드코딩된 시크릿"), + (r"execute\s*\(\s*[\"'].*?\+", "CRITICAL", "SQL 인젝션 가능성"), + (r"innerHTML\s*=", "HIGH", "XSS 취약점 가능성 (innerHTML)"), + (r"eval\s*\(", "HIGH", "eval() 사용 위험"), + (r"md5\s*\(", "MEDIUM", "취약한 해시 알고리즘 (MD5)"), + (r"sha1\s*\(", "MEDIUM", "취약한 해시 알고리즘 (SHA1)"), + (r"verify\s*=\s*False", "HIGH", "SSL 검증 비활성화"), + (r"DEBUG\s*=\s*True", "MEDIUM", "프로덕션 DEBUG 모드"), +] + + +def quick_security_scan(base_path: Path) -> List[Dict[str, Any]]: + """정규식 기반 빠른 보안 취약점 스캔 (LLM 불필요).""" + import re + findings = [] + files = scan_source_files(base_path) + + for file_path in files: + try: + code = file_path.read_text(encoding="utf-8", errors="replace") + for i, line in enumerate(code.splitlines(), 1): + for pattern, severity, description in SECURITY_PATTERNS: + if re.search(pattern, line, re.IGNORECASE): + rel_path = str(file_path.relative_to(PROJECTS_ROOT)) + findings.append({ + "file": rel_path, + "severity": severity, + "category": "SECURITY", + "line": i, + "message": f"{description}: {line.strip()[:80]}", + "suggestion": "코드에서 민감 정보를 제거하고 환경변수/비밀 저장소를 사용하세요.", + }) + except Exception: + pass + + return findings diff --git a/core/compliance_check.py b/core/compliance_check.py new file mode 100644 index 0000000..8ad0437 --- /dev/null +++ b/core/compliance_check.py @@ -0,0 +1,291 @@ +""" +GUARDiA 준수성 자동 점검 엔진 + +1. 시큐어코딩 (행안부 SW 보안약점 기준 + OWASP Top 10) +2. 웹 접근성 (WCAG 2.1 / KWCAG 2.1 — 한국형) +3. 개인정보보호법 (PIPA — 개인정보 식별자 탐지) +""" +from __future__ import annotations + +import ast +import re +import os +import logging +from datetime import datetime +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +# ── 1. 시큐어코딩 점검 ─────────────────────────────────────────────────────── + +# 행안부 SW 보안약점 주요 패턴 +SECURE_CODING_RULES = [ + # SQL 인젝션 + { + "id": "SC-001", + "category": "SQL 인젝션", + "severity": "CRITICAL", + "pattern": r'execute\s*\(\s*f["\'].*\{|execute\s*\(\s*".*%.*%|execute\s*\(\s*\'.*\'\.format\(', + "message": "SQL 문자열 직접 포맷팅 — 파라미터 바인딩 사용 필요", + }, + # XSS + { + "id": "SC-002", + "category": "XSS", + "severity": "HIGH", + "pattern": r'innerHTML\s*=\s*(?!"|\')|\.html\s*\(', + "message": "innerHTML 직접 삽입 — textContent 또는 escapeHtml 함수 사용 필요", + }, + # 하드코딩 비밀번호/키 + { + "id": "SC-003", + "category": "하드코딩 자격증명", + "severity": "CRITICAL", + "pattern": r'(?i)(password|passwd|secret|api_key|apikey|token)\s*=\s*["\'][^"\']{6,}["\']', + "message": "하드코딩된 자격증명 — 환경변수 또는 시크릿 관리자 사용 필요", + }, + # OS 명령어 인젝션 + { + "id": "SC-004", + "category": "OS 명령어 인젝션", + "severity": "CRITICAL", + "pattern": r'os\.system\s*\(|subprocess\.call\s*\(.*shell\s*=\s*True|eval\s*\(', + "message": "shell=True 또는 eval 사용 — 입력값 검증 필수", + }, + # 경로 조작 + { + "id": "SC-005", + "category": "경로 조작", + "severity": "HIGH", + "pattern": r'open\s*\(\s*.*\+|open\s*\(\s*f["\']', + "message": "사용자 입력 기반 파일 경로 — Path.resolve().relative_to() 검증 필수", + }, + # 정보 노출 + { + "id": "SC-006", + "category": "민감 정보 노출", + "severity": "MEDIUM", + "pattern": r'traceback\.print_exc\(\)|print\s*\(.*exception|logger\.(info|debug).*password', + "message": "예외 스택트레이스 직접 출력 — 상세 오류 메시지 은닉 필요", + }, + # CSRF 위험 (GET으로 상태 변경) + { + "id": "SC-007", + "category": "CSRF", + "severity": "MEDIUM", + "pattern": r'@router\.get\s*\([^\)]+\)\s*\nasync def.*(delete|remove|drop|truncate)', + "message": "GET 메서드로 데이터 변경 — POST/DELETE 사용 및 CSRF 토큰 적용 필요", + }, + # 취약한 암호화 + { + "id": "SC-008", + "category": "취약 암호화", + "severity": "HIGH", + "pattern": r'md5|sha1\s*\(|DES\.|RC4\.', + "message": "취약한 해시/암호화 알고리즘 — SHA-256 이상 또는 AES-256-GCM 사용 필요", + }, +] + +# ── 2. 웹 접근성 점검 (HTML 기반) ──────────────────────────────────────────── + +ACCESSIBILITY_RULES = [ + { + "id": "WA-001", + "category": "대체 텍스트", + "level": "A", + "pattern": r']*alt=)[^>]*>', + "message": "img 요소에 alt 속성 없음 — 시각 장애인 스크린리더 접근 불가", + }, + { + "id": "WA-002", + "category": "색상 대비", + "level": "AA", + "pattern": r'color:\s*#(?:aaa|999|bbb|ccc|ddd|eee|f{3,6})', + "message": "낮은 색상 대비 — 4.5:1 이상 비율 필요 (WCAG 2.1 AA)", + }, + { + "id": "WA-003", + "category": "키보드 접근성", + "level": "A", + "pattern": r'onclick="[^"]*"(?![^>]*tabindex)', + "message": "onclick만 있는 요소 — tabindex + onkeydown 추가 또는 + + + + + +
+
노드: 0 | 링크: 0
+
+
노드 유형
+
서버
+
WAS
+
DB
+
네트워크
+
스토리지
+
헬스 상태
+
정상
+
주의
+
위험
+
+ + + +""" + return HTMLResponse(html) diff --git a/routers/vibe.py b/routers/vibe.py new file mode 100644 index 0000000..92f614b --- /dev/null +++ b/routers/vibe.py @@ -0,0 +1,934 @@ +""" +바이브 코딩 세션 관리 API. + +워크플로우: + POST /api/vibe → 세션 생성 (PENDING) + PATCH /api/vibe/{id}/status → 상태 변경 (Claude CLI 세션 ID 업데이트 포함) + POST /api/vibe/{id}/build → Jenkins 파이프라인 빌드 트리거 + POST /api/vibe/{id}/deploy → Jenkins 파이프라인 배포 트리거 + POST /api/vibe/callback → Jenkins 파이프라인 완료 콜백 수신 + GET /api/vibe/{id} → 세션 상세 +""" +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime +from typing import Any, List, Optional + +from fastapi import APIRouter, Depends, Header, HTTPException +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user +from core.cicd import ( + abort_build, + ping as jenkins_ping, + trigger_pipeline, + verify_callback_secret, +) +from database import get_db +from models import ( + Project, SRRequest, SRStatus, + VibeSession, VibeSessionCreate, VibeSessionOut, VibeSessionUpdate, + VibeSessionStatus, User, UserRole, Server, WorkLog, WorkActionType, +) + +router = APIRouter(prefix="/api/vibe", tags=["vibe"]) +logger = logging.getLogger(__name__) + + +# ── 권한 헬퍼 ───────────────────────────────────────────────────────────────── + +def _require_ops(u: User = Depends(get_current_user)) -> User: + if u.role not in (UserRole.ADMIN, UserRole.PM, UserRole.ENGINEER): + raise HTTPException(403, "바이브 세션 권한이 없습니다.") + return u + + +# ── 요청/응답 스키마 ────────────────────────────────────────────────────────── + +class BuildRequest(BaseModel): + deploy_env: str = "dev" + skip_test: bool = False + job_name: Optional[str] = None # 명시 지정 시 사용, 없으면 프로젝트에서 자동 결정 + extra_params: Optional[dict[str, Any]] = None + + +class DeployRequest(BaseModel): + deploy_env: str = "dev" + skip_test: bool = False + job_name: Optional[str] = None + extra_params: Optional[dict[str, Any]] = None + + +class StatusChangeRequest(BaseModel): + status: VibeSessionStatus + note: Optional[str] = None + claude_session_id: Optional[str] = None + + +class PipelineCallbackRequest(BaseModel): + """Jenkins Stage 8: ITSM Callback에서 POST하는 페이로드.""" + session_id: str + sr_id: Optional[str] = None + status: str # COMPLETED | FAILED | UNSTABLE | ROLLED_BACK … + stage: Optional[str] = None + message: Optional[str] = None + build_number: Optional[str] = None + build_url: Optional[str] = None + deploy_env: Optional[str] = None + job_name: Optional[str] = None + timestamp: Optional[str] = None + logs: Optional[dict[str, str]] = None + + # 콜백 검증용 시크릿 (X-Jenkins-Secret 헤더로도 전달 가능) + secret: Optional[str] = None + + +# ── CRUD ───────────────────────────────────────────────────────────────────── + +@router.get("", response_model=List[VibeSessionOut]) +async def list_sessions( + sr_id: Optional[str] = None, + status: Optional[str] = None, + db: AsyncSession = Depends(get_db), + _u: User = Depends(_require_ops), +): + q = select(VibeSession).order_by(VibeSession.started_at.desc()) + if sr_id: + q = q.where(VibeSession.sr_id == sr_id) + if status: + q = q.where(VibeSession.status == status) + r = await db.execute(q.limit(50)) + return r.scalars().all() + + +@router.get("/{session_id}", response_model=VibeSessionOut) +async def get_session( + session_id: int, + db: AsyncSession = Depends(get_db), + _u: User = Depends(_require_ops), +): + r = await db.execute(select(VibeSession).where(VibeSession.id == session_id)) + vs = r.scalars().first() + if not vs: + raise HTTPException(404, "바이브 세션을 찾을 수 없습니다.") + return vs + + +@router.post("", response_model=VibeSessionOut, status_code=201) +async def create_session( + payload: VibeSessionCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(_require_ops), +): + """ + 바이브 코딩 세션 시작. + - sr_id: 연결할 SR + - project_id: 프로젝트 (소스 경로, 빌드 명령, 배포 서버 포함) + - claude_session_id: Claude CLI 세션 ID (이미 시작한 경우 전달) + """ + # SR 존재 확인 + if payload.sr_id: + r = await db.execute( + select(SRRequest).where(SRRequest.sr_id == payload.sr_id) + ) + if not r.scalars().first(): + raise HTTPException(404, f"SR을 찾을 수 없습니다: {payload.sr_id}") + + # 프로젝트 확인 + workspace = payload.workspace_path + if payload.project_id: + r = await db.execute( + select(Project).where(Project.id == payload.project_id) + ) + proj = r.scalars().first() + if not proj: + raise HTTPException(404, f"프로젝트를 찾을 수 없습니다: {payload.project_id}") + if not workspace and proj.source_path: + workspace = proj.source_path + + vs = VibeSession( + sr_id=payload.sr_id, + project_id=payload.project_id, + claude_session_id=payload.claude_session_id, + workspace_path=workspace, + started_by=payload.started_by or current_user.username, + status=VibeSessionStatus.PENDING, + ) + db.add(vs) + await db.commit() + await db.refresh(vs) + + asyncio.create_task(_notify_vibe_started(vs, current_user.username)) + return vs + + +@router.patch("/{session_id}/status", response_model=VibeSessionOut) +async def update_session_status( + session_id: int, + payload: StatusChangeRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(_require_ops), +): + """ + 세션 상태 수동 변경 (CODING → BUILDING 등). + Claude CLI 세션 ID도 이 엔드포인트로 업데이트. + """ + r = await db.execute(select(VibeSession).where(VibeSession.id == session_id)) + vs = r.scalars().first() + if not vs: + raise HTTPException(404, "바이브 세션을 찾을 수 없습니다.") + + vs.status = payload.status + if payload.claude_session_id: + vs.claude_session_id = payload.claude_session_id + + now = datetime.now() + if payload.status == VibeSessionStatus.CODING and not vs.started_at: + vs.started_at = now + elif payload.status == VibeSessionStatus.BUILDING: + vs.coded_at = now + elif payload.status == VibeSessionStatus.TESTING: + vs.built_at = now + elif payload.status == VibeSessionStatus.DEPLOYING: + vs.tested_at = now + elif payload.status == VibeSessionStatus.COMPLETED: + vs.deployed_at = now + + await db.commit() + await db.refresh(vs) + return vs + + +# ── Jenkins 파이프라인 트리거 ────────────────────────────────────────────────── + +@router.post("/{session_id}/build", response_model=VibeSessionOut) +async def trigger_build( + session_id: int, + payload: BuildRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(_require_ops), +): + """ + Jenkins 빌드 + 테스트 파이프라인 트리거. + tb_project.jenkins_job_name 또는 payload.job_name 사용. + """ + vs, proj = await _get_session_with_project(session_id, db) + + job_name = payload.job_name or getattr(proj, "jenkins_job_name", None) \ + or f"{proj.project_name}-build" + + # 배포 서버 이름 + target_server = await _resolve_target_server(proj, db) + + try: + result = await trigger_pipeline( + job_name=job_name, + session_id=vs.id, + sr_id=vs.sr_id or "", + deploy_env=payload.deploy_env, + target_server=target_server, + skip_test=payload.skip_test, + extra_params=payload.extra_params, + ) + except RuntimeError as exc: + raise HTTPException(502, f"Jenkins 연동 오류: {exc}") + + # 상태 업데이트 + vs.status = VibeSessionStatus.BUILDING + vs.coded_at = datetime.now() + # 큐 ID를 build_log에 임시 저장 + vs.build_log = ( + f"Jenkins 파이프라인 트리거 완료\n" + f"Job: {job_name}\n" + f"Queue ID: {result.get('queue_id', 'N/A')}\n" + f"Queue URL: {result.get('queue_url', 'N/A')}" + ) + await db.commit() + await db.refresh(vs) + + asyncio.create_task(_notify_build_triggered(vs, job_name, result)) + logger.info("빌드 트리거: session=%d job=%s queue=%s", vs.id, job_name, result.get("queue_id")) + return vs + + +@router.post("/{session_id}/deploy", response_model=VibeSessionOut) +async def trigger_deploy( + session_id: int, + payload: DeployRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(_require_ops), +): + """ + Jenkins Full Pipeline 배포 트리거. + """ + vs, proj = await _get_session_with_project(session_id, db) + + job_name = payload.job_name or getattr(proj, "jenkins_job_name", None) \ + or f"{proj.project_name}-deploy" + + target_server = await _resolve_target_server(proj, db) + + extra: dict[str, Any] = {} + if proj.health_check_url: + extra["HEALTH_CHECK_URL"] = proj.health_check_url + if payload.extra_params: + extra.update(payload.extra_params) + + try: + result = await trigger_pipeline( + job_name=job_name, + session_id=vs.id, + sr_id=vs.sr_id or "", + deploy_env=payload.deploy_env, + target_server=target_server, + skip_test=payload.skip_test, + extra_params=extra, + ) + except RuntimeError as exc: + raise HTTPException(502, f"Jenkins 연동 오류: {exc}") + + vs.status = VibeSessionStatus.DEPLOYING + vs.tested_at = datetime.now() + vs.deploy_log = ( + f"Jenkins 배포 파이프라인 트리거 완료\n" + f"Job: {job_name}\n" + f"Environment: {payload.deploy_env}\n" + f"Queue ID: {result.get('queue_id', 'N/A')}" + ) + await db.commit() + await db.refresh(vs) + + asyncio.create_task(_notify_deploy_triggered(vs, job_name, result)) + logger.info("배포 트리거: session=%d job=%s env=%s", vs.id, job_name, payload.deploy_env) + return vs + + +# ── Jenkins 콜백 수신 ───────────────────────────────────────────────────────── + +@router.post("/callback") +async def pipeline_callback( + body: PipelineCallbackRequest, + x_jenkins_secret: Optional[str] = Header(None, alias="X-Jenkins-Secret"), + db: AsyncSession = Depends(get_db), +): + """ + Jenkins Stage 8 (ITSM Callback) → 파이프라인 결과 수신. + 인증: X-Jenkins-Secret 헤더 또는 body.secret 으로 검증. + """ + # 시크릿 검증 + received_secret = x_jenkins_secret or body.secret or "" + if not verify_callback_secret(received_secret): + raise HTTPException(401, "콜백 인증 실패") + + # 세션 조회 + try: + vs_id = int(body.session_id) + except ValueError: + raise HTTPException(400, "유효하지 않은 session_id") + + r = await db.execute(select(VibeSession).where(VibeSession.id == vs_id)) + vs = r.scalars().first() + if not vs: + raise HTTPException(404, f"바이브 세션을 찾을 수 없습니다: {body.session_id}") + + # 상태 매핑 + status_map = { + "BUILDING": VibeSessionStatus.BUILDING, + "TESTING": VibeSessionStatus.TESTING, + "DEPLOYING": VibeSessionStatus.DEPLOYING, + "COMPLETED": VibeSessionStatus.COMPLETED, + "FAILED": VibeSessionStatus.FAILED, + "UNSTABLE": VibeSessionStatus.FAILED, + "PENDING_APPROVAL": VibeSessionStatus.BUILDING, + "ROLLED_BACK": VibeSessionStatus.FAILED, + "CANCELLED": VibeSessionStatus.CANCELLED, + } + new_status = status_map.get(body.status.upper(), VibeSessionStatus.FAILED) + vs.status = new_status + + now = datetime.now() + if body.logs: + if body.logs.get("build"): + vs.build_log = body.logs["build"][:2000] + vs.built_at = now + if body.logs.get("test"): + vs.test_result = body.logs["test"][:500] + vs.tested_at = now + if body.logs.get("deploy"): + vs.deploy_log = body.logs["deploy"][:2000] + vs.deployed_at = now + + if new_status == VibeSessionStatus.FAILED: + vs.error_msg = body.message or "파이프라인 실패" + + is_success = new_status == VibeSessionStatus.COMPLETED + + # ── SR COMPLETED 처리 ───────────────────────────────────── + sr_notifications = [] + if is_success and vs.sr_id: + rs = await db.execute( + select(SRRequest).where(SRRequest.sr_id == vs.sr_id) + ) + sr = rs.scalars().first() + if sr and sr.status not in (SRStatus.COMPLETED, SRStatus.FAILED_ROLLBACK): + sr.status = SRStatus.COMPLETED + sr.updated_at = now + final_msg = ( + body.message + or f"CI/CD 배포 완료 (빌드 #{body.build_number or 'N/A'})" + ) + db.add(WorkLog( + sr_id=vs.sr_id, + engineer="jenkins-bot", + action_type=WorkActionType.COMPLETE, + content="CI/CD 파이프라인 배포 완료", + result=final_msg, + is_success=True, + )) + sr_notifications.append((sr, final_msg)) + + await db.commit() + + # 알림 (비동기) + for sr_obj, msg in sr_notifications: + asyncio.create_task(_notify_sr_done(sr_obj, msg)) + + asyncio.create_task(_notify_pipeline_result(vs, body, is_success)) + # A-3: 배포 완료/실패 통합 알림 + asyncio.create_task(_a3_notify_deploy_completed( + vs, is_success, + summary=body.message or f"빌드 #{body.build_number or 'N/A'}", + )) + + logger.info( + "Jenkins 콜백 수신: session=%d status=%s build=#%s", + vs.id, body.status, body.build_number or "N/A", + ) + return { + "ok": True, + "sr_id": vs.sr_id, + "sr_status": SRStatus.COMPLETED if is_success else "UNCHANGED", + "notified": ["messenger"], + } + + +# ── Jenkins 연결 상태 확인 ────────────────────────────────────────────────────── + +@router.get("/jenkins/health") +async def jenkins_health(_u: User = Depends(_require_ops)): + """Jenkins 서버 연결 상태 확인.""" + result = await jenkins_ping() + if not result.get("ok"): + raise HTTPException(503, f"Jenkins 연결 실패: {result.get('error', '알 수 없는 오류')}") + return result + + +# ── 내부 헬퍼 ──────────────────────────────────────────────────────────────── + +async def _get_session_with_project( + session_id: int, + db: AsyncSession, +) -> tuple[VibeSession, Project]: + r = await db.execute(select(VibeSession).where(VibeSession.id == session_id)) + vs = r.scalars().first() + if not vs: + raise HTTPException(404, "바이브 세션을 찾을 수 없습니다.") + if not vs.project_id: + raise HTTPException(400, "프로젝트가 연결되지 않은 세션입니다.") + rp = await db.execute(select(Project).where(Project.id == vs.project_id)) + proj = rp.scalars().first() + if not proj: + raise HTTPException(400, "프로젝트를 찾을 수 없습니다.") + return vs, proj + + +async def _resolve_target_server(proj: Project, db: AsyncSession) -> str: + """프로젝트의 배포 서버 이름을 반환.""" + if not proj.deploy_server_id: + return "" + rs = await db.execute( + select(Server).where(Server.id == proj.deploy_server_id) + ) + srv = rs.scalars().first() + return srv.server_name if srv else "" + + +# ── 알림 헬퍼 ───────────────────────────────────────────────────────────────── + +async def _notify_vibe_started(vs: VibeSession, actor: str): + try: + import httpx, os + webhook = os.environ.get("MESSENGER_WEBHOOK", "") + if not webhook: + return + await _post_webhook(webhook, { + "event": "vibe_started", + "room": os.environ.get("NOTIFY_ROOM", "ops"), + "session_id": vs.id, + "sr_id": vs.sr_id or "—", + "actor": actor, + "workspace": vs.workspace_path or "—", + }) + except Exception: + pass + + +async def _notify_build_triggered(vs: VibeSession, job_name: str, result: dict): + try: + import os + webhook = os.environ.get("MESSENGER_WEBHOOK", "") + if not webhook: + return + await _post_webhook(webhook, { + "event": "build_triggered", + "room": os.environ.get("NOTIFY_ROOM", "ops"), + "session_id": vs.id, + "sr_id": vs.sr_id or "—", + "job_name": job_name, + "queue_id": result.get("queue_id"), + }) + except Exception: + pass + + +async def _notify_deploy_triggered(vs: VibeSession, job_name: str, result: dict): + try: + import os + webhook = os.environ.get("MESSENGER_WEBHOOK", "") + if not webhook: + return + await _post_webhook(webhook, { + "event": "deploy_triggered", + "room": os.environ.get("NOTIFY_ROOM", "ops"), + "session_id": vs.id, + "sr_id": vs.sr_id or "—", + "job_name": job_name, + "queue_id": result.get("queue_id"), + }) + except Exception: + pass + + +async def _notify_pipeline_result( + vs: VibeSession, + body: PipelineCallbackRequest, + success: bool, +): + try: + import os + webhook = os.environ.get("MESSENGER_WEBHOOK", "") + if not webhook: + return + await _post_webhook(webhook, { + "event": "pipeline_result", + "room": os.environ.get("NOTIFY_ROOM", "ops"), + "session_id": vs.id, + "sr_id": vs.sr_id or "—", + "success": success, + "status": body.status, + "message": body.message or "", + "build_number": body.build_number or "", + "build_url": body.build_url or "", + "deploy_env": body.deploy_env or "", + }) + except Exception: + pass + + +async def _notify_sr_done(sr: SRRequest, final_msg: str): + try: + from core.notify import notify_sr_status_changed + await notify_sr_status_changed(sr, SRStatus.COMPLETED, final_msg) + except Exception: + pass + + +async def _post_webhook(url: str, payload: dict): + import httpx + async with httpx.AsyncClient(timeout=5.0) as client: + await client.post(url, json=payload) + + +# ── Priority 5: Jenkins 운영 배포 승인 연동 ────────────────────────────────── + +class ApprovalRequestBody(BaseModel): + environment: str = "prd" + build_number: Optional[str] = None + + +@router.post("/{session_id}/request-approval") +async def request_approval( + session_id: int, + body: ApprovalRequestBody, + db: AsyncSession = Depends(get_db), +): + """ + Jenkins Pipeline → ITSM 승인 요청 생성. + PRD 배포 시 Jenkins가 이 엔드포인트를 호출하여 PM 승인을 요청한다. + 인증: ITSM_TOKEN (Jenkins에서 Bearer 토큰으로 전달) + """ + r = await db.execute(select(VibeSession).where(VibeSession.id == session_id)) + vs = r.scalars().first() + if not vs: + raise HTTPException(404, f"세션 없음: {session_id}") + + # 승인 요청 상태로 전환 + vs.status = VibeSessionStatus.BUILDING # 승인 대기 중 + vs.error_msg = None + # build_number를 deploy_log에 보관 + vs.deploy_log = ( + f"PRD 배포 승인 대기 중\n" + f"환경: {body.environment}\n" + f"빌드: #{body.build_number or 'N/A'}" + ) + await db.commit() + + # PM에게 메신저 알림 (기존) + asyncio.create_task(_notify_approval_requested(vs, body.environment, body.build_number)) + + # A-3: 승인 필요 통합 알림 (이메일 + 메신저) + asyncio.create_task(_a3_notify_approval_required(vs)) + + logger.info("PRD 배포 승인 요청: session=%d env=%s", session_id, body.environment) + return {"ok": True, "status": "PENDING", "session_id": session_id} + + +@router.get("/{session_id}/approval-status") +async def get_approval_status( + session_id: int, + db: AsyncSession = Depends(get_db), +): + """ + Jenkins Pipeline polling → 승인 상태 반환. + Jenkins `waitUntil` 블록이 이 엔드포인트를 폴링한다. + Returns: "PENDING" | "APPROVED" | "REJECTED" + """ + r = await db.execute(select(VibeSession).where(VibeSession.id == session_id)) + vs = r.scalars().first() + if not vs: + raise HTTPException(404, f"세션 없음: {session_id}") + + if vs.status == VibeSessionStatus.DEPLOYING: + return "APPROVED" + elif vs.status in (VibeSessionStatus.FAILED, VibeSessionStatus.CANCELLED): + return "REJECTED" + return "PENDING" + + +@router.patch("/{session_id}/approve") +async def approve_deploy( + session_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + PM이 ITSM UI에서 PRD 배포를 승인한다. + 승인되면 approval-status 폴링이 "APPROVED"를 반환하여 Jenkins 파이프라인이 재개된다. + """ + if current_user.role not in (UserRole.ADMIN, UserRole.PM): + raise HTTPException(403, "PM 또는 ADMIN 권한이 필요합니다.") + + r = await db.execute(select(VibeSession).where(VibeSession.id == session_id)) + vs = r.scalars().first() + if not vs: + raise HTTPException(404, f"세션 없음: {session_id}") + + vs.status = VibeSessionStatus.DEPLOYING + vs.tested_at = datetime.now() + await db.commit() + await db.refresh(vs) + + logger.info("PRD 배포 승인: session=%d by=%s", session_id, current_user.username) + asyncio.create_task(_notify_approval_result(vs, True, current_user.username)) + return {"ok": True, "status": "APPROVED", "session_id": session_id} + + +@router.patch("/{session_id}/reject") +async def reject_deploy( + session_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """PM이 PRD 배포를 반려한다.""" + if current_user.role not in (UserRole.ADMIN, UserRole.PM): + raise HTTPException(403, "PM 또는 ADMIN 권한이 필요합니다.") + + r = await db.execute(select(VibeSession).where(VibeSession.id == session_id)) + vs = r.scalars().first() + if not vs: + raise HTTPException(404, f"세션 없음: {session_id}") + + vs.status = VibeSessionStatus.FAILED + vs.error_msg = f"PRD 배포 반려 by {current_user.username}" + await db.commit() + await db.refresh(vs) + + logger.info("PRD 배포 반려: session=%d by=%s", session_id, current_user.username) + asyncio.create_task(_notify_approval_result(vs, False, current_user.username)) + return {"ok": True, "status": "REJECTED", "session_id": session_id} + + +# ── Priority 5: SonarQube Quality Gate 결과 수신 ───────────────────────────── + +class SonarResultBody(BaseModel): + session_id: int + project_key: str + + +@router.post("/sonar-result") +async def receive_sonar_result( + body: SonarResultBody, + db: AsyncSession = Depends(get_db), +): + """ + Jenkins Quality Gate 단계에서 SonarQube 분석 결과를 ITSM에 전달한다. + Quality Gate 실패(ERROR) 시 SR을 자동 생성한다. + """ + from core.cicd import get_sonarqube_result, handle_sonar_gate_failure + + try: + result = await get_sonarqube_result(body.project_key) + except Exception as exc: + logger.warning("SonarQube 결과 조회 실패: %s", exc) + return {"ok": False, "error": str(exc)} + + # 세션에 소나큐브 결과 기록 + r = await db.execute(select(VibeSession).where(VibeSession.id == body.session_id)) + vs = r.scalars().first() + if vs: + import json + vs.test_result = json.dumps({ + "sonar_status": result.status, + "coverage": result.coverage, + "bugs": result.bugs, + "vulnerabilities": result.vulnerabilities, + "code_smells": result.code_smells, + }, ensure_ascii=False) + vs.tested_at = datetime.now() + await db.commit() + + # Quality Gate 실패 → SR 자동 생성 + if result.status == "ERROR": + await handle_sonar_gate_failure(body.session_id, result, db) + + return { + "ok": True, + "status": result.status, + "coverage": result.coverage, + "bugs": result.bugs, + } + + +# ── Priority 5: 빌드 로그 SSE 스트리밍 ─────────────────────────────────────── + +@router.get("/{session_id}/build/stream") +async def stream_build_log( + session_id: int, + _u: User = Depends(_require_ops), +): + """ + Jenkins 빌드 로그를 실시간 SSE로 스트리밍한다. + Jenkins Progressive Log API를 폴링하여 청크 단위로 전달한다. + """ + from fastapi.responses import StreamingResponse + from core.cicd import get_progressive_log, is_build_complete + import json + + async def _generator(): + offset = 0 + max_iter = 300 # 최대 10분 (2초 간격) + for _ in range(max_iter): + try: + log_chunk, next_offset = await get_progressive_log(session_id, offset) + if log_chunk: + yield f"data: {json.dumps({'chunk': log_chunk, 'offset': next_offset})}\n\n" + offset = next_offset + done = await is_build_complete(session_id) + if done: + yield f"data: {json.dumps({'done': True})}\n\n" + break + except Exception as exc: + yield f"data: {json.dumps({'error': str(exc)})}\n\n" + break + await asyncio.sleep(2) + + return StreamingResponse(_generator(), media_type="text/event-stream") + + +# ── G-6: 배포 영향 분석 ─────────────────────────────────────────────────────── + +@router.post("/{session_id}/impact-analysis") +async def deploy_impact_analysis( + session_id: int, + db: AsyncSession = Depends(get_db), + _u: User = Depends(_require_ops), +): + """바이브 세션의 대상 서버 CI 의존성 분석으로 배포 영향 범위 파악.""" + from models import VibeSession, ConfigItem, Server + from sqlalchemy import select + + vs = await db.get(VibeSession, session_id) + if not vs: + raise HTTPException(404, "세션을 찾을 수 없습니다.") + + # 대상 서버를 통해 CI 목록 수집 + ci_id = None + if vs.target_server: + srv = (await db.execute( + select(Server).where(Server.server_name == vs.target_server) + )).scalars().first() + if srv: + ci = (await db.execute( + select(ConfigItem).where(ConfigItem.linked_server_id == srv.id) + )).scalars().first() + if ci: + ci_id = ci.id + + if not ci_id: + return { + "session_id": session_id, + "message": "연결된 CI 정보가 없습니다. CMDB에서 서버와 CI를 연결하세요.", + "risk_level": "UNKNOWN", + "affected_cis": [], + "affected_institutions": [], + } + + try: + from core.deploy_impact import analyze_deploy_impact + result = await analyze_deploy_impact(ci_id, db) + result["session_id"] = session_id + return result + except Exception as e: + raise HTTPException(500, f"영향 분석 오류: {str(e)[:200]}") + + +# ── 승인 알림 헬퍼 ──────────────────────────────────────────────────────────── + +async def _notify_approval_requested(vs: VibeSession, env: str, build_number: Optional[str]): + try: + import os + from core.notify import send_messenger + room = os.getenv("MESSENGER_PM_ROOM", "pm") + await send_messenger(room, { + "type": "text", + "text": ( + f"🚀 [PRD 배포 승인 요청]\n" + f"세션: {vs.id} | SR: {vs.sr_id or '—'}\n" + f"환경: {env} | 빌드: #{build_number or 'N/A'}\n" + f"ITSM에서 승인 또는 반려해주세요." + ), + }) + except Exception: + pass + + +async def _notify_approval_result(vs: VibeSession, approved: bool, approver: str): + try: + import os + from core.notify import send_messenger + icon = "✅" if approved else "❌" + room = os.getenv("MESSENGER_OPS_ROOM", "ops") + await send_messenger(room, { + "type": "text", + "text": ( + f"{icon} [PRD 배포 {'승인' if approved else '반려'}]\n" + f"세션: {vs.id} | SR: {vs.sr_id or '—'}\n" + f"처리자: {approver}" + ), + }) + except Exception: + pass + + +# ── A-3: 배포 승인/완료 통합 알림 헬퍼 ────────────────────────────────────── + +async def _a3_notify_approval_required(vs: VibeSession) -> None: + """ + A-3: 배포 승인 필요 시 이메일 + 메신저 통합 알림. + PM / ADMIN 역할 사용자에게 승인 요청 발송. + """ + try: + import os + from core.notify import notify_deploy_approval_required + from database import SessionLocal + from sqlalchemy import select + from models import User, UserRole + + # 승인 담당자: PM + ADMIN 역할 사용자 조회 + async with SessionLocal() as db: + result = await db.execute( + select(User.username).where( + User.role.in_([UserRole.PM, UserRole.ADMIN]), + User.is_active == True, + ) + ) + approvers = [row[0] for row in result.all()] + + if not approvers: + approvers = ["admin"] + + project_name = f"세션 #{vs.id}" + if vs.project_id: + try: + async with SessionLocal() as db: + from models import Project + pr = await db.execute( + select(Project.project_name).where(Project.id == vs.project_id) + ) + row = pr.first() + if row: + project_name = row[0] + except Exception: + pass + + base_url = os.getenv("ITSM_BASE_URL", "http://localhost:8000") + approve_url = f"{base_url}/vibe?session={vs.id}&action=approve" + + await notify_deploy_approval_required( + session_id=vs.id, + sr_id=vs.sr_id, + project_name=project_name, + approvers=approvers, + approve_url=approve_url, + ) + except Exception as exc: + logger.debug("A-3 승인 알림 오류: %s", exc) + + +async def _a3_notify_deploy_completed(vs: VibeSession, success: bool, summary: str = "") -> None: + """ + A-3: 배포 완료/실패 시 운영팀 통합 알림. + Jenkins 콜백 수신 후 호출. + """ + try: + from core.notify import notify_deploy_completed + + project_name = f"세션 #{vs.id}" + if vs.project_id: + try: + from database import SessionLocal + from sqlalchemy import select + from models import Project + async with SessionLocal() as db: + pr = await db.execute( + select(Project.project_name).where(Project.id == vs.project_id) + ) + row = pr.first() + if row: + project_name = row[0] + except Exception: + pass + + await notify_deploy_completed( + session_id=vs.id, + sr_id=vs.sr_id, + project_name=project_name, + success=success, + summary=summary or ("배포 성공" if success else "배포 실패"), + ) + except Exception as exc: + logger.debug("A-3 배포완료 알림 오류: %s", exc) diff --git a/routers/vuln_scan.py b/routers/vuln_scan.py new file mode 100644 index 0000000..eb98df3 --- /dev/null +++ b/routers/vuln_scan.py @@ -0,0 +1,482 @@ +""" +D-4: 보안 취약점 자동 스캔 API 라우터 + +엔드포인트: + POST /api/vuln/scan — 대상 서버 스캔 시작 (비동기) + GET /api/vuln/scans — 스캔 이력 조회 + GET /api/vuln/scans/{scan_id} — 스캔 결과 상세 + POST /api/vuln/quick-check — 빠른 단일 포트/서비스 점검 + GET /api/vuln/cve/{cve_id} — CVE 상세 정보 + POST /api/vuln/cvss — CVSS 점수 계산 + GET /api/vuln/stats — 취약점 통계 요약 + GET /api/vuln/policies — 스캔 정책 조회 +""" +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime +from typing import Dict, List, Optional + +from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user +from database import get_db +from models import User, UserRole + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/vuln", tags=["vuln_scan"]) + +# ── 스캔 결과 인메모리 스토어 ────────────────────────────────────────────────── +_scan_results: Dict[str, Dict] = {} +_scan_queue: List[str] = [] # 진행 중 scan_id + + +# ── Pydantic 스키마 ────────────────────────────────────────────────────────── + +class ScanRequestIn(BaseModel): + host: str + ports: Optional[List[int]] = None + include_llm: bool = False + timeout: float = 1.0 + sr_id: Optional[str] = None # 연관 SR + note: Optional[str] = None + +class QuickCheckIn(BaseModel): + host: str + port: int + service: Optional[str] = None + +class CVSSCalcIn(BaseModel): + attack_vector: str = "NETWORK" # NETWORK|ADJACENT|LOCAL|PHYSICAL + complexity: str = "LOW" # LOW|HIGH + privileges: str = "NONE" # NONE|LOW|HIGH + impact: str = "HIGH" # NONE|LOW|MEDIUM|HIGH + + +# ── 백그라운드 스캔 실행기 ───────────────────────────────────────────────────── + +async def _run_scan_bg(scan_id: str, host: str, ports, include_llm: bool, + timeout: float, requester: str): + """백그라운드에서 스캔을 실행하고 결과를 저장.""" + from core.vuln_scan import run_vulnerability_scan + + _scan_results[scan_id]["status"] = "RUNNING" + try: + result = await run_vulnerability_scan(host, ports, include_llm, timeout) + result["scan_id"] = scan_id + result["requester"] = requester + result["status"] = "COMPLETED" + _scan_results[scan_id].update(result) + logger.info("스캔 완료: %s → risk=%s score=%d", + scan_id, result["risk_level"], result["risk_score"]) + except Exception as e: + _scan_results[scan_id]["status"] = "FAILED" + _scan_results[scan_id]["error"] = str(e)[:100] + logger.error("스캔 실패: %s — %s", scan_id, str(e)[:80]) + finally: + if scan_id in _scan_queue: + _scan_queue.remove(scan_id) + + +# ── 엔드포인트 ─────────────────────────────────────────────────────────────── + +@router.post("/scan", status_code=202) +async def start_scan( + body: ScanRequestIn, + background_tasks: BackgroundTasks, + current_user: User = Depends(get_current_user), + _db: AsyncSession = Depends(get_db), +): + """ + 취약점 스캔 시작 (202 Accepted — 비동기 실행). + 보안: PM/ADMIN만 스캔 가능, 스캔 대상 기록 필수. + """ + if current_user.role not in (UserRole.ADMIN, UserRole.PM): + raise HTTPException(403, "PM/ADMIN 권한이 필요합니다.") + if not body.host: + raise HTTPException(400, "host는 필수입니다.") + if body.timeout > 5.0: + raise HTTPException(400, "timeout은 최대 5초입니다.") + + # scan_id 생성 + import hashlib + scan_id = hashlib.sha256( + f"{body.host}:{datetime.utcnow().isoformat()}:{current_user.username}".encode() + ).hexdigest()[:12] + + _scan_results[scan_id] = { + "scan_id": scan_id, + "host": body.host, + "status": "QUEUED", + "requester": current_user.username, + "requested_at": datetime.utcnow().isoformat(), + "sr_id": body.sr_id, + "note": body.note, + } + _scan_queue.append(scan_id) + + background_tasks.add_task( + _run_scan_bg, + scan_id, body.host, body.ports, + body.include_llm, body.timeout, + current_user.username, + ) + + logger.info("스캔 요청: %s → %s by %s", scan_id, body.host, current_user.username) + return { + "scan_id": scan_id, + "status": "QUEUED", + "message": "스캔이 시작되었습니다. GET /api/vuln/scans/{scan_id}로 결과를 확인하세요.", + "host": body.host, + } + + +@router.get("/scans") +async def list_scans( + status: Optional[str] = Query(None), + host: Optional[str] = Query(None), + limit: int = Query(20, ge=1, le=100), + offset: int = Query(0, ge=0), + current_user: User = Depends(get_current_user), + _db: AsyncSession = Depends(get_db), +): + """스캔 이력 조회.""" + results = list(_scan_results.values()) + + # ENGINEER는 본인 스캔만 + if current_user.role == UserRole.ENGINEER: + results = [r for r in results if r.get("requester") == current_user.username] + + if status: + results = [r for r in results if r.get("status") == status.upper()] + if host: + results = [r for r in results if host in r.get("host", "")] + + results_sorted = sorted( + results, + key=lambda x: x.get("requested_at", ""), + reverse=True, + ) + return { + "total": len(results_sorted), + "scans": results_sorted[offset: offset + limit], + "running": len(_scan_queue), + } + + +@router.get("/scans/{scan_id}") +async def get_scan_result( + scan_id: str, + current_user: User = Depends(get_current_user), + _db: AsyncSession = Depends(get_db), +): + """스캔 결과 상세 조회.""" + r = _scan_results.get(scan_id) + if not r: + raise HTTPException(404, f"스캔 {scan_id}를 찾을 수 없습니다.") + if current_user.role == UserRole.ENGINEER and r.get("requester") != current_user.username: + raise HTTPException(403, "본인 스캔만 조회할 수 있습니다.") + return r + + +@router.post("/quick-check") +async def quick_check( + body: QuickCheckIn, + current_user: User = Depends(get_current_user), + _db: AsyncSession = Depends(get_db), +): + """ + 단일 포트/서비스 빠른 점검. + """ + if current_user.role not in (UserRole.ADMIN, UserRole.PM, UserRole.ENGINEER): + raise HTTPException(403, "로그인 사용자만 접근 가능합니다.") + + from core.vuln_scan import _scan_port, _grab_banner, check_version_vulns, DANGER_PORTS + + is_open = _scan_port(body.host, body.port, timeout=1.0) + result = { + "host": body.host, + "port": body.port, + "service": body.service, + "is_open": is_open, + "banner": None, + "vulns": [], + "risk": "UNKNOWN", + "checked_at": datetime.utcnow().isoformat(), + } + + if is_open: + banner = _grab_banner(body.host, body.port, timeout=2.0) + result["banner"] = banner + if banner: + result["vulns"] = check_version_vulns(banner, body.service or "") + result["risk"] = "HIGH" if body.port in DANGER_PORTS else "LOW" + + return result + + +@router.get("/cve/{cve_id}") +async def get_cve_info( + cve_id: str, + current_user: User = Depends(get_current_user), + _db: AsyncSession = Depends(get_db), +): + """CVE 상세 정보 조회 (내부 DB).""" + from core.vuln_scan import VULN_VERSION_PATTERNS + + cve_upper = cve_id.upper() + matches = [ + { + "cve_id": cve, + "service": svc, + "pattern": pat, + "severity": sev, + "description": desc, + } + for svc, pat, cve, sev, desc in VULN_VERSION_PATTERNS + if cve.upper() == cve_upper + ] + + if not matches: + raise HTTPException(404, f"{cve_id}는 내부 DB에 없습니다.") + return matches[0] + + +@router.post("/cvss") +async def calculate_cvss( + body: CVSSCalcIn, + current_user: User = Depends(get_current_user), + _db: AsyncSession = Depends(get_db), +): + """CVSS v3.1 단순화 점수 계산.""" + from core.vuln_scan import calculate_cvss_simplified + + score = calculate_cvss_simplified( + body.attack_vector, body.complexity, + body.privileges, body.impact, + ) + severity = ( + "CRITICAL" if score >= 9.0 else + "HIGH" if score >= 7.0 else + "MEDIUM" if score >= 4.0 else + "LOW" if score > 0.0 else + "NONE" + ) + return { + "score": score, + "severity": severity, + "attack_vector": body.attack_vector, + "complexity": body.complexity, + "privileges": body.privileges, + "impact": body.impact, + "note": "단순화된 CVSS v3.1 근사 계산입니다.", + } + + +@router.get("/stats") +async def vuln_stats( + current_user: User = Depends(get_current_user), + _db: AsyncSession = Depends(get_db), +): + """취약점 스캔 통계.""" + if current_user.role not in (UserRole.ADMIN, UserRole.PM): + raise HTTPException(403, "PM/ADMIN 권한이 필요합니다.") + + completed = [r for r in _scan_results.values() if r.get("status") == "COMPLETED"] + total_vulns = sum(len(r.get("vulnerabilities", [])) for r in completed) + total_configs = sum(len(r.get("config_issues", [])) for r in completed) + + sev_totals: Dict[str, int] = {} + for r in completed: + for sev, cnt in (r.get("severity_summary") or {}).items(): + sev_totals[sev] = sev_totals.get(sev, 0) + cnt + + avg_risk = ( + sum(r.get("risk_score", 0) for r in completed) / len(completed) + if completed else 0 + ) + + return { + "total_scans": len(_scan_results), + "completed_scans": len(completed), + "running_scans": len(_scan_queue), + "total_vulns": total_vulns, + "total_config_issues": total_configs, + "severity_totals": sev_totals, + "avg_risk_score": round(avg_risk, 1), + } + + +@router.get("/policies") +async def get_scan_policies( + current_user: User = Depends(get_current_user), + _db: AsyncSession = Depends(get_db), +): + """스캔 정책 목록.""" + from core.vuln_scan import _DANGER_PATTERNS, DANGER_PORTS + return { + "scan_policies": [ + {"name": "스캔 권한", "value": "PM/ADMIN만 가능"}, + {"name": "위험 포트", "value": sorted(DANGER_PORTS)}, + {"name": "타임아웃 최대", "value": "5초"}, + {"name": "외부 DB 조회", "value": "금지 (내부망 전용)"}, + {"name": "root 계정 사용", "value": "금지"}, + {"name": "LLM 분석", "value": "내부 Ollama sLLM만 허용"}, + ], + "cve_db_count": len( + __import__("core.vuln_scan", fromlist=["VULN_VERSION_PATTERNS"]).VULN_VERSION_PATTERNS + ), + "danger_port_count": len(DANGER_PORTS), + } + + +# ── G-8: 보안 패치 추적 ────────────────────────────────────────────────────── + +class PatchUpdateIn(BaseModel): + cve_id: str + patch_note: Optional[str] = None + patched_at: Optional[str] = None + status: str = "PATCHED" # PATCHED|WONTFIX|MITIGATED + + +@router.get("/patches") +async def list_patches( + skip: int = 0, + limit: int = 100, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """패치 이력 목록 조회.""" + from models import VulnPatchRecord + from sqlalchemy import select, desc + rows = (await db.execute( + select(VulnPatchRecord).order_by(desc(VulnPatchRecord.created_at)).offset(skip).limit(limit) + )).scalars().all() + return [ + { + "id": r.id, + "scan_id": r.scan_id, + "cve_id": r.cve_id, + "cvss_score": r.cvss_score, + "severity": r.severity, + "status": r.status, + "patch_note": r.patch_note, + "patched_at": r.patched_at.isoformat() if r.patched_at else None, + "patched_by": r.patched_by, + "created_at": r.created_at.isoformat(), + } + for r in rows + ] + + +@router.post("/scans/{scan_id}/patch", status_code=201) +async def mark_patch( + scan_id: str, + body: PatchUpdateIn, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """취약점 패치 완료 기록.""" + from models import VulnPatchRecord + from sqlalchemy import select + + if current_user.role == UserRole.CUSTOMER: + raise HTTPException(403, "패치 기록은 ADMIN/PM/ENGINEER만 가능합니다.") + + patched_at = None + if body.patched_at: + try: + patched_at = datetime.fromisoformat(body.patched_at) + except ValueError: + patched_at = datetime.utcnow() + else: + patched_at = datetime.utcnow() + + # 기존 레코드 확인 + existing = (await db.execute( + select(VulnPatchRecord).where( + VulnPatchRecord.scan_id == scan_id, + VulnPatchRecord.cve_id == body.cve_id, + ) + )).scalars().first() + + if existing: + existing.status = body.status + existing.patch_note = body.patch_note + existing.patched_at = patched_at + existing.patched_by = current_user.username + existing.updated_at = datetime.utcnow() + else: + existing = VulnPatchRecord( + scan_id = scan_id, + cve_id = body.cve_id, + status = body.status, + patch_note = body.patch_note, + patched_at = patched_at, + patched_by = current_user.username, + ) + db.add(existing) + + await db.commit() + return {"message": f"CVE {body.cve_id} 패치 상태를 {body.status}로 기록했습니다."} + + +@router.get("/patch-stats") +async def patch_stats( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """패치 현황 통계.""" + from models import VulnPatchRecord + from sqlalchemy import select, func as sqlfunc, case + + rows = (await db.execute(select(VulnPatchRecord))).scalars().all() + total = len(rows) + patched = sum(1 for r in rows if r.status == "PATCHED") + open_ = sum(1 for r in rows if r.status == "OPEN") + by_sev = {} + for r in rows: + sev = r.severity or "UNKNOWN" + by_sev.setdefault(sev, {"total": 0, "patched": 0}) + by_sev[sev]["total"] += 1 + if r.status == "PATCHED": + by_sev[sev]["patched"] += 1 + + return { + "total_vulns": total, + "patched": patched, + "open": open_, + "patch_rate_pct": round(patched / total * 100, 1) if total else 0.0, + "by_severity": by_sev, + } + + +@router.get("/overdue-patches") +async def overdue_patches( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """30일 이상 미패치 취약점 목록.""" + from models import VulnPatchRecord + from sqlalchemy import select + cutoff = datetime.utcnow().replace(hour=0, minute=0, second=0) - __import__("datetime").timedelta(days=30) + rows = (await db.execute( + select(VulnPatchRecord).where( + VulnPatchRecord.status == "OPEN", + VulnPatchRecord.created_at <= cutoff, + ).order_by(VulnPatchRecord.created_at) + )).scalars().all() + return [ + { + "id": r.id, + "cve_id": r.cve_id, + "cvss_score": r.cvss_score, + "severity": r.severity, + "created_at": r.created_at.isoformat(), + "overdue_days": (datetime.utcnow() - r.created_at).days, + } + for r in rows + ] diff --git a/routers/work.py b/routers/work.py new file mode 100644 index 0000000..3865681 --- /dev/null +++ b/routers/work.py @@ -0,0 +1,257 @@ +""" +Work execution router — work-log CRUD, SSH simulation, completion + messenger notify. +""" +import asyncio +import json +from datetime import datetime +from typing import List, Optional + +try: + import httpx + _HTTPX = True +except ImportError: + _HTTPX = False + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user +from database import get_db +from models import ( + AuditLog, Institution, Rating, Server, SRRequest, SRStatus, + User, WorkActionType, WorkLog, WorkLogOut, WorkStepIn, compute_log_hash, +) + +router = APIRouter(prefix="/api/work", tags=["work"]) + +# ── Messenger webhook URL (same machine) ────────────────────────────────────── +MESSENGER_WEBHOOK = "http://localhost:8000/api/messenger/webhook" +NOTIFY_ROOM = "ops" # 알림 채널 + + +# ── SSH 시뮬레이션 템플릿 ───────────────────────────────────────────────────── +_SSH_SIM: dict[str, list[dict]] = { + "RESTART": [ + {"action": WorkActionType.SSH_CONNECT, + "content": "SSH 접속 시도 (opsagent@{server}:22)", + "result": "Connected to {server} — OpenSSH_8.7, RHEL 8.9"}, + {"action": WorkActionType.SSH_EXEC, + "content": "systemctl stop tomcat9 && sleep 2 && systemctl start tomcat9", + "result": "● tomcat9.service: active (running) since {ts}"}, + {"action": WorkActionType.HEALTH_CHECK, + "content": "curl -sf http://localhost:8080/health -o /dev/null -w '%{http_code}'", + "result": "200 OK — 응답 시간 42ms"}, + ], + "DEPLOY": [ + {"action": WorkActionType.SSH_CONNECT, + "content": "SFTP 접속 시도 (opsagent@{server}:22)", + "result": "sftp> Connected to {server}"}, + {"action": WorkActionType.SOURCE_MOD, + "content": "파일 전송: put -r ./classes /app/was/webapps/ROOT/WEB-INF/classes/", + "result": "전송 완료 — 23 files, 1.4 MB (0.8s)"}, + {"action": WorkActionType.SSH_EXEC, + "content": "systemctl reload tomcat9", + "result": "Reload OK — 클래스 파일 적용됨"}, + {"action": WorkActionType.HEALTH_CHECK, + "content": "curl -sf http://localhost:8080/actuator/health", + "result": '{"status":"UP","components":{"db":{"status":"UP"}}}'}, + ], + "LOG": [ + {"action": WorkActionType.SSH_CONNECT, + "content": "SSH 접속 ({server}:22) — 로그 분석 모드", + "result": "Connected"}, + {"action": WorkActionType.SSH_EXEC, + "content": "tail -n 200 /app/was/logs/catalina.out | grep -E 'ERROR|WARN'", + "result": ( + "[ERROR] 2026-05-24 17:32:11 ORA-01555: snapshot too old\n" + "[WARN] 2026-05-24 17:33:05 Connection pool exhausted (200/200)\n" + "[ERROR] 2026-05-24 17:34:22 java.lang.OutOfMemoryError: GC overhead" + )}, + ], + "DEFAULT": [ + {"action": WorkActionType.SSH_CONNECT, + "content": "SSH 접속 ({server}:22)", + "result": "Connected"}, + {"action": WorkActionType.SSH_EXEC, + "content": "작업 수행 중…", + "result": "명령 실행 완료"}, + ], +} + + +async def _write_audit(db: AsyncSession, sr_id: str, actor: str, + action: str, detail: str) -> None: + result = await db.execute( + select(AuditLog).where(AuditLog.sr_id == sr_id) + .order_by(AuditLog.id.desc()).limit(1) + ) + last = result.scalars().first() + prev_hash = last.log_hash if last else None + ts = datetime.now().isoformat() + log_hash = compute_log_hash(prev_hash, actor, action, detail, ts) + db.add(AuditLog(sr_id=sr_id, actor=actor, action=action, + detail=detail, prev_hash=prev_hash, log_hash=log_hash)) + + +async def _notify_messenger(sr: SRRequest, work_summary: str) -> None: + """완료 시 메신저 webhook 호출.""" + if not _HTTPX: + return + payload = { + "event": "itsm_complete", + "room": NOTIFY_ROOM, + "sr_id": sr.sr_id, + "title": sr.title, + "sr_type": sr.sr_type, + "requested_by": sr.requested_by, + "target_server": sr.target_server or "—", + "result_summary": work_summary, + } + try: + async with httpx.AsyncClient(timeout=5.0) as client: + await client.post(MESSENGER_WEBHOOK, json=payload) + except Exception: + pass # 메신저 미동작 시 무시 + + +# ── Endpoints ───────────────────────────────────────────────────────────────── + +@router.get("/{sr_id}", response_model=List[WorkLogOut]) +async def list_work_logs(sr_id: str, db: AsyncSession = Depends(get_db), + _u: User = Depends(get_current_user)): + result = await db.execute( + select(WorkLog).where(WorkLog.sr_id == sr_id).order_by(WorkLog.created_at) + ) + return result.scalars().all() + + +@router.post("/{sr_id}/step", response_model=WorkLogOut, status_code=201) +async def add_work_step(sr_id: str, payload: WorkStepIn, + db: AsyncSession = Depends(get_db), + _u: User = Depends(get_current_user)): + r = await db.execute(select(SRRequest).where(SRRequest.sr_id == sr_id)) + if not r.scalars().first(): + raise HTTPException(404, "SR을 찾을 수 없습니다.") + log = WorkLog(sr_id=sr_id, **payload.model_dump()) + db.add(log) + await db.commit() + await db.refresh(log) + return log + + +@router.post("/{sr_id}/simulate") +async def simulate_work(sr_id: str, engineer: str = "GUARDiA-AI", + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user)): + """ + 전체 작업 흐름 시뮬레이션: + CMDB 확인 → SSH 접속 → 작업 실행 → 헬스체크 → SR COMPLETED → 메신저 알림 + """ + r = await db.execute(select(SRRequest).where(SRRequest.sr_id == sr_id)) + sr = r.scalars().first() + if not sr: + raise HTTPException(404, "SR을 찾을 수 없습니다.") + + ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + server = sr.target_server or "UNKNOWN-SRV" + + # ── Step 1: CMDB 자산 확인 ────────────────────────────────────────────── + srv_info = "" + if sr.target_server: + sv = await db.execute( + select(Server).where(Server.server_name == sr.target_server) + ) + sv_obj = sv.scalars().first() + if sv_obj: + srv_info = f"{sv_obj.server_role} | {sv_obj.os_type} {sv_obj.os_version} | SSH:22" + + cmdb_log = WorkLog( + sr_id=sr_id, engineer=engineer, + action_type=WorkActionType.CMDB_CHECK, + content=f"CMDB 자산 조회: {server}", + result=srv_info or f"{server} — CMDB 등록 서버 확인됨", + is_success=True, + ) + db.add(cmdb_log) + + # ── SR 상태 자동 전이: RECEIVED/PARSED → PENDING_APPROVAL → APPROVED ──── + auto_approve = sr.status in ( + SRStatus.RECEIVED, SRStatus.PARSED, + SRStatus.PENDING_APPROVAL, SRStatus.APPROVED + ) + if sr.status in (SRStatus.RECEIVED, SRStatus.PARSED): + sr.status = SRStatus.PENDING_APPROVAL + await _write_audit(db, sr_id, engineer, "AUTO_PENDING", "시뮬레이션: 승인 대기 전이") + if sr.status == SRStatus.PENDING_APPROVAL: + sr.status = SRStatus.APPROVED + await _write_audit(db, sr_id, "AUTO_APPROVE", "SR_APPROVED", "시뮬레이션: 자동 승인") + + # IN_PROGRESS 전이 + sr.status = SRStatus.IN_PROGRESS + sr.updated_at = datetime.now() + await _write_audit(db, sr_id, engineer, "SR_STARTED", "작업 시작") + await db.flush() + + # ── Step 2~N: SR type별 SSH 시뮬레이션 ─────────────────────────────────── + steps = _SSH_SIM.get(sr.sr_type, _SSH_SIM["DEFAULT"]) + result_summary = "" + for step in steps: + wlog = WorkLog( + sr_id=sr_id, engineer=engineer, + action_type=step["action"], + content=step["content"].format(server=server, ts=ts), + result=step["result"].format(server=server, ts=ts), + is_success=True, + ) + db.add(wlog) + result_summary = wlog.result + + # ── Step Final: RESULT 기록 ─────────────────────────────────────────────── + final_msg = f"{sr.title} 처리 완료 — {result_summary[:80]}" + db.add(WorkLog( + sr_id=sr_id, engineer=engineer, + action_type=WorkActionType.RESULT, + content="작업 결과 기록", + result=final_msg, is_success=True, + )) + + # ── SR → COMPLETED ──────────────────────────────────────────────────────── + sr.status = SRStatus.COMPLETED + sr.updated_at = datetime.now() + await _write_audit(db, sr_id, engineer, "SR_COMPLETED", final_msg) + await db.commit() + + # ── 메신저 + 이메일 알림 (비동기, 실패 무시) ───────────────────────────── + asyncio.create_task(_notify_messenger(sr, final_msg)) + from core.notify import notify_sr_status_changed as _notify + asyncio.create_task(_notify(sr, SRStatus.COMPLETED, final_msg)) + + return {"status": "COMPLETED", "sr_id": sr_id, "summary": final_msg} + + +@router.post("/{sr_id}/complete") +async def manual_complete(sr_id: str, engineer: str = "엔지니어", + result_note: str = "수동 완료 처리", + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user)): + """수동 완료 처리 + 메신저 알림.""" + r = await db.execute(select(SRRequest).where(SRRequest.sr_id == sr_id)) + sr = r.scalars().first() + if not sr: + raise HTTPException(404, "SR을 찾을 수 없습니다.") + + sr.status = SRStatus.COMPLETED + sr.updated_at = datetime.now() + db.add(WorkLog( + sr_id=sr_id, engineer=engineer, + action_type=WorkActionType.COMPLETE, + content="수동 완료 처리", result=result_note, is_success=True, + )) + await _write_audit(db, sr_id, engineer, "SR_COMPLETED", result_note) + await db.commit() + + asyncio.create_task(_notify_messenger(sr, result_note)) + from core.notify import notify_sr_status_changed as _notify + asyncio.create_task(_notify(sr, SRStatus.COMPLETED, result_note)) + return {"status": "COMPLETED", "sr_id": sr_id} diff --git a/routers/ws.py b/routers/ws.py new file mode 100644 index 0000000..f9c399b --- /dev/null +++ b/routers/ws.py @@ -0,0 +1,346 @@ +""" +GUARDiA ITSM — WebSocket 실시간 이벤트 푸시 (Enhancement A-1) + +기능: + 1. WebSocket 연결 관리 (ConnectionManager) + 2. JWT 토큰 기반 인증 (query parameter ?token=...) + 3. 역할별 구독 채널 필터링 + 4. SSE broadcast() 와 동일한 이벤트 수신 + 5. 하트비트 (30초 간격 ping) + 6. 재연결 지원 (last_event_id 기반) + +엔드포인트: + WS /ws/events?token={jwt} — 전체 이벤트 스트림 + WS /ws/events/{channel}?token={jwt} — 채널별 (sr, deploy, sla, oncall, batch) + GET /api/ws/status — WebSocket 연결 상태 (ADMIN) + +프론트엔드 연결 예시: + const ws = new WebSocket("ws://localhost:8000/ws/events?token=" + accessToken); + ws.onmessage = (evt) => { + const msg = JSON.parse(evt.data); + if (msg.type === "sr_created") { ... } + }; +""" +from __future__ import annotations + +import asyncio +import json +import logging +from datetime import datetime +from typing import Dict, Optional, Set + +from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect, Query, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user +from database import get_db +from models import User, UserRole + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["websocket"]) + +# ── 채널 정의 ───────────────────────────────────────────────────────────────── +_CHANNELS = {"sr", "deploy", "sla", "oncall", "batch", "all"} +_CHANNEL_EVENT_MAP: Dict[str, Set[str]] = { + "sr": {"sr_created", "sr_updated", "sr_status_changed"}, + "deploy": {"deploy_started", "deploy_completed", "deploy_failed", "vibe_updated"}, + "sla": {"sla_violation", "sla_escalated", "sr_updated"}, + "oncall": {"oncall_assigned", "oncall_escalated"}, + "batch": {"batch_started", "batch_completed", "batch_failed"}, + "all": None, # None = 모든 이벤트 +} + +_HEARTBEAT_INTERVAL = 30 # 초 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# ── 연결 관리자 ──────────────────────────────────────────────────────────────── +# ═══════════════════════════════════════════════════════════════════════════════ + +class ConnectionManager: + """WebSocket 연결 목록 관리.""" + + def __init__(self): + # { websocket: { "username": str, "role": str, "channel": str, "connected_at": datetime } } + self._connections: Dict[WebSocket, dict] = {} + + async def connect( + self, + ws: WebSocket, + username: str, + role: str, + channel: str = "all", + ) -> None: + await ws.accept() + self._connections[ws] = { + "username": username, + "role": role, + "channel": channel, + "connected_at": datetime.now().isoformat(), + } + logger.info("WS 연결: user=%s channel=%s total=%d", username, channel, len(self._connections)) + + def disconnect(self, ws: WebSocket) -> None: + info = self._connections.pop(ws, {}) + logger.info( + "WS 연결 해제: user=%s total=%d", + info.get("username", "unknown"), + len(self._connections), + ) + + async def broadcast(self, event_type: str, data: dict) -> None: + """특정 이벤트 타입을 구독 중인 모든 WebSocket에 전송.""" + if not self._connections: + return + msg = json.dumps({"type": event_type, **data}, ensure_ascii=False) + dead: list[WebSocket] = [] + + for ws, info in list(self._connections.items()): + channel = info.get("channel", "all") + # 채널 필터링 + allowed = _CHANNEL_EVENT_MAP.get(channel) + if allowed is not None and event_type not in allowed: + continue + # CUSTOMER는 자신의 기관 이벤트만 수신 (향후 확장) + try: + await ws.send_text(msg) + except Exception: + dead.append(ws) + + for ws in dead: + self.disconnect(ws) + + def connection_count(self) -> int: + return len(self._connections) + + def connections_info(self) -> list: + return [ + { + "username": info["username"], + "role": info["role"], + "channel": info["channel"], + "connected_at": info["connected_at"], + } + for info in self._connections.values() + ] + + async def send_personal(self, ws: WebSocket, message: dict) -> bool: + """특정 WebSocket에만 메시지 전송.""" + try: + await ws.send_text(json.dumps(message, ensure_ascii=False)) + return True + except Exception: + self.disconnect(ws) + return False + + +# 전역 연결 관리자 (앱 수명 동안 유지) +manager = ConnectionManager() + + +# ── core/events.py 와 통합 ──────────────────────────────────────────────────── + +def _integrate_with_sse_bus() -> None: + """ + core/events.broadcast() 호출 시 WebSocket manager.broadcast() 도 함께 실행되도록 + 이벤트 버스를 패치합니다. + + main.py lifespan 에서 한 번 호출됩니다. + """ + import core.events as _events + + _original_broadcast = _events.broadcast + + async def _patched_broadcast(event_type: str, data=None): + # 기존 SSE 브로드캐스트 실행 + await _original_broadcast(event_type, data) + # WebSocket 브로드캐스트도 실행 + await manager.broadcast(event_type, data or {}) + + _events.broadcast = _patched_broadcast + logger.info("WebSocket 통합: core/events.broadcast() 패치 완료") + + +# ═══════════════════════════════════════════════════════════════════════════════ +# ── WebSocket 인증 헬퍼 ──────────────────────────────────────────────────────── +# ═══════════════════════════════════════════════════════════════════════════════ + +async def _authenticate_ws(token: str, db: AsyncSession) -> Optional[User]: + """WebSocket 연결 시 JWT 토큰으로 사용자 인증.""" + if not token: + return None + try: + from core.auth import SECRET_KEY, ALGORITHM + from jose import jwt, JWTError + from sqlalchemy import select + + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + # mfa_pending 토큰은 거부 + if payload.get("mfa_pending"): + return None + username = payload.get("sub") + if not username: + return None + + result = await db.execute(select(User).where(User.username == username)) + user = result.scalars().first() + return user if (user and user.is_active) else None + except Exception: + return None + + +# ═══════════════════════════════════════════════════════════════════════════════ +# ── WebSocket 엔드포인트 ────────────────────────────────────────────────────── +# ═══════════════════════════════════════════════════════════════════════════════ + +@router.websocket("/ws/events") +async def ws_all_events( + websocket: WebSocket, + token: str = Query(..., description="JWT access_token"), + db: AsyncSession = Depends(get_db), +): + """전체 이벤트 스트림 WebSocket.""" + await _handle_ws(websocket, token, "all", db) + + +@router.websocket("/ws/events/{channel}") +async def ws_channel_events( + websocket: WebSocket, + channel: str, + token: str = Query(..., description="JWT access_token"), + db: AsyncSession = Depends(get_db), +): + """채널별 이벤트 WebSocket. channel: sr|deploy|sla|oncall|batch|all""" + if channel not in _CHANNELS: + await websocket.close(code=4001, reason=f"Unknown channel: {channel}") + return + await _handle_ws(websocket, token, channel, db) + + +async def _handle_ws( + websocket: WebSocket, + token: str, + channel: str, + db: AsyncSession, +) -> None: + """WebSocket 연결 처리 공통 로직.""" + # 인증 + user = await _authenticate_ws(token, db) + if not user: + await websocket.close(code=4001, reason="인증 실패: 유효한 토큰이 필요합니다.") + return + + await manager.connect(websocket, user.username, user.role, channel) + + # 연결 성공 메시지 + await manager.send_personal(websocket, { + "type": "connected", + "username": user.username, + "channel": channel, + "server_time": datetime.now().isoformat(), + "message": f"GUARDiA ITSM WebSocket 연결됨 (채널: {channel})", + }) + + try: + # 하트비트 태스크 + heartbeat_task = asyncio.create_task(_heartbeat_loop(websocket)) + + # 클라이언트 메시지 수신 루프 (ping/pong, 구독 변경 등) + while True: + try: + raw = await asyncio.wait_for(websocket.receive_text(), timeout=_HEARTBEAT_INTERVAL + 5) + msg = json.loads(raw) + await _handle_client_message(websocket, msg, user) + except asyncio.TimeoutError: + # 클라이언트가 아무것도 안 보내면 그냥 계속 + pass + + except WebSocketDisconnect: + pass + except Exception as exc: + logger.debug("WS 오류: user=%s err=%s", user.username, exc) + finally: + heartbeat_task.cancel() + manager.disconnect(websocket) + + +async def _heartbeat_loop(ws: WebSocket) -> None: + """30초마다 서버 heartbeat 전송.""" + while True: + await asyncio.sleep(_HEARTBEAT_INTERVAL) + try: + await ws.send_text(json.dumps({ + "type": "heartbeat", + "server_time": datetime.now().isoformat(), + "connections": manager.connection_count(), + }, ensure_ascii=False)) + except Exception: + break + + +async def _handle_client_message(ws: WebSocket, msg: dict, user: User) -> None: + """클라이언트에서 보낸 메시지 처리.""" + msg_type = msg.get("type", "") + + if msg_type == "ping": + await manager.send_personal(ws, { + "type": "pong", + "server_time": datetime.now().isoformat(), + }) + elif msg_type == "subscribe": + new_channel = msg.get("channel", "all") + if new_channel in _CHANNELS: + info = manager._connections.get(ws) + if info: + info["channel"] = new_channel + await manager.send_personal(ws, { + "type": "subscribed", + "channel": new_channel, + }) + elif msg_type == "status": + await manager.send_personal(ws, { + "type": "status_reply", + "connections": manager.connection_count(), + "username": user.username, + "role": user.role, + }) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# ── REST API: WebSocket 연결 상태 조회 ─────────────────────────────────────── +# ═══════════════════════════════════════════════════════════════════════════════ + +@router.get("/api/ws/status") +async def ws_status( + current_user: User = Depends(get_current_user), +): + """WebSocket 연결 상태 조회 (ADMIN 전용).""" + if current_user.role != UserRole.ADMIN: + raise HTTPException(403, "ADMIN 권한이 필요합니다.") + + return { + "total_connections": manager.connection_count(), + "connections": manager.connections_info(), + "channels": list(_CHANNELS), + } + + +@router.post("/api/ws/broadcast") +async def ws_broadcast_manual( + event_type: str = Query(..., description="이벤트 타입"), + message: str = Query("", description="메시지"), + current_user: User = Depends(get_current_user), +): + """수동 WebSocket 브로드캐스트 (ADMIN 전용 — 테스트/공지용).""" + if current_user.role != UserRole.ADMIN: + raise HTTPException(403, "ADMIN 권한이 필요합니다.") + + await manager.broadcast(event_type, { + "message": message, + "sent_by": current_user.username, + "sent_at": datetime.now().isoformat(), + }) + return { + "sent_to": manager.connection_count(), + "event_type": event_type, + } diff --git a/scripts/sm/agent/agent_pinpoint_sm.sh b/scripts/sm/agent/agent_pinpoint_sm.sh new file mode 100644 index 0000000..6a6025f --- /dev/null +++ b/scripts/sm/agent/agent_pinpoint_sm.sh @@ -0,0 +1,128 @@ +#!/bin/bash +# ============================================================ +# GUARDiA SM | agent_pinpoint_sm.sh +# 대상: Pinpoint APM (Collector / Web / Flink / HBase) +# 파라미터: PINPOINT_HOME=/opt/pinpoint +# PP_COLLECTOR_PORT=9994 PP_WEB_PORT=8080 +# PP_FLINK_PORT=8081 PP_HBASE_PORT=16000 +# PP_WEB_URL=http://localhost:8080 +# ============================================================ +set -euo pipefail +PINPOINT_HOME=${PINPOINT_HOME:-/opt/pinpoint} +PP_COLLECTOR_PORT=${PP_COLLECTOR_PORT:-9994} +PP_WEB_PORT=${PP_WEB_PORT:-8080} +PP_FLINK_PORT=${PP_FLINK_PORT:-8081} +PP_HBASE_PORT=${PP_HBASE_PORT:-16000} +PP_WEB_URL=${PP_WEB_URL:-"http://localhost:${PP_WEB_PORT}"} +OK="[OK]"; WARN="[WARN]"; CRIT="[CRIT]" +SEP="─────────────────────────────────────────" +RESULT=0 + +echo "======================================================" +echo " GUARDiA SM 점검 | Pinpoint APM | $(hostname -s)" +echo " 점검 시각: $(date '+%Y-%m-%d %H:%M:%S %Z')" +echo "======================================================" + +# ── 1. Pinpoint Collector ───────────────────────────────── +echo; echo "[$SEP] 1. Pinpoint Collector" +COLL_PROC=$(pgrep -f "pinpoint-collector\|PinpointCollector" 2>/dev/null | wc -l || echo 0) +if [ "$COLL_PROC" -gt 0 ]; then + COLL_PID=$(pgrep -f "pinpoint-collector\|PinpointCollector" | head -1) + echo " ${OK} Collector 실행 중 (PID: ${COLL_PID})" + RSS_MB=$(awk '/VmRSS/{print $2}' /proc/${COLL_PID}/status 2>/dev/null | \ + awk '{printf "%d", $1/1024}' || echo "N/A") + echo " RSS 메모리: ${RSS_MB} MB" +else + echo " ${CRIT} Pinpoint Collector 프로세스 없음" + RESULT=2 +fi +ss -tlnp 2>/dev/null | grep -q ":${PP_COLLECTOR_PORT} " && \ + echo " ${OK} Collector 포트 ${PP_COLLECTOR_PORT} LISTEN" || \ + echo " ${WARN} Collector 포트 ${PP_COLLECTOR_PORT} LISTEN 없음" +# gRPC 포트 (9991~9993) +for GRPC_PORT in 9991 9992 9993; do + ss -tlnp 2>/dev/null | grep -q ":${GRPC_PORT} " && \ + echo " ${OK} gRPC 포트 ${GRPC_PORT} LISTEN" || true +done + +# ── 2. Pinpoint Web ─────────────────────────────────────── +echo; echo "[$SEP] 2. Pinpoint Web" +WEB_PROC=$(pgrep -f "pinpoint-web\|PinpointWeb" 2>/dev/null | wc -l || echo 0) +if [ "$WEB_PROC" -gt 0 ]; then + echo " ${OK} Pinpoint Web 실행 중" +else + echo " ${WARN} Pinpoint Web 프로세스 없음" + [ $RESULT -lt 1 ] && RESULT=1 +fi +if command -v curl &>/dev/null; then + HTTP_CODE=$(curl -sk -o /dev/null -w "%{http_code}" \ + --max-time 10 "${PP_WEB_URL}" 2>/dev/null || echo "ERR") + echo "$HTTP_CODE" | grep -qE "^[23]" && \ + echo " ${OK} Web UI 응답: ${HTTP_CODE}" || \ + echo " ${WARN} Web UI 응답: ${HTTP_CODE}" +fi + +# ── 3. HBase 연결 ───────────────────────────────────────── +echo; echo "[$SEP] 3. HBase 연결" +HBASE_PROC=$(pgrep -f "hbase\|HMaster\|HRegionServer" 2>/dev/null | wc -l || echo 0) +if [ "$HBASE_PROC" -gt 0 ]; then + echo " ${OK} HBase 프로세스 실행 중 (${HBASE_PROC}개)" +else + echo " ${WARN} HBase 프로세스 없음 (외부 HBase 연결 시 무시)" +fi +ss -tlnp 2>/dev/null | grep -q ":${PP_HBASE_PORT} " && \ + echo " ${OK} HBase Master 포트 ${PP_HBASE_PORT} LISTEN" || \ + echo " ${WARN} HBase 포트 ${PP_HBASE_PORT} 없음 (외부 HBase 사용 시 정상)" + +# ── 4. Flink (실시간 집계) ─────────────────────────────── +echo; echo "[$SEP] 4. Flink Job Manager" +FLINK_PROC=$(pgrep -f "flink\|StandaloneJobManager\|TaskManager" 2>/dev/null | wc -l || echo 0) +if [ "$FLINK_PROC" -gt 0 ]; then + echo " ${OK} Flink 실행 중 (${FLINK_PROC}개)" +else + echo " ${WARN} Flink 프로세스 없음 (Inspector 기능 비활성화)" +fi +if command -v curl &>/dev/null; then + FLINK_JOBS=$(curl -sk --max-time 5 \ + "http://localhost:${PP_FLINK_PORT}/jobs" 2>/dev/null | \ + python3 -c "import sys,json; d=json.load(sys.stdin); \ + [print(f' {j[\"id\"][:8]}... {j[\"status\"]}') for j in d.get('jobs',[])]" 2>/dev/null || echo "") + [ -n "$FLINK_JOBS" ] && echo " Flink 작업:" && echo "$FLINK_JOBS" || true +fi + +# ── 5. Agent 수집 통계 (Web API) ────────────────────────── +echo; echo "[$SEP] 5. 에이전트 수집 현황" +if command -v curl &>/dev/null && [ "$WEB_PROC" -gt 0 ]; then + APPS=$(curl -sk --max-time 5 \ + "${PP_WEB_URL}/getApplicationList.pinpoint" 2>/dev/null || echo "[]") + APP_COUNT=$(echo "$APPS" | python3 -c \ + "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null || echo 0) + echo " 모니터링 애플리케이션 수: ${APP_COUNT}" +fi + +# ── 6. 로그 오류 ───────────────────────────────────────── +echo; echo "[$SEP] 6. Pinpoint 로그 오류" +for LOGDIR in "${PINPOINT_HOME}/logs" "${PINPOINT_HOME}/collector/logs" \ + "${PINPOINT_HOME}/web/logs"; do + if [ -d "$LOGDIR" ]; then + LOGFILE=$(ls -t "${LOGDIR}"/*.log 2>/dev/null | head -1 || echo "") + if [ -n "$LOGFILE" ] && [ -r "$LOGFILE" ]; then + ERR=$(tail -500 "$LOGFILE" | grep -cE "ERROR|FATAL" || echo 0) + echo " 최근 오류: ${ERR}건 (${LOGFILE})" + [ "$ERR" -gt 0 ] && tail -500 "$LOGFILE" | grep -E "ERROR|FATAL" | tail -5 | sed 's/^/ /' + fi + break + fi +done + +# ── 요약 ───────────────────────────────────────────────── +echo +echo "======================================================" +case $RESULT in + 0) echo " 최종 결과: ${OK} Pinpoint APM 정상" ;; + 1) echo " 최종 결과: ${WARN} 주의 항목 있음" ;; + 2) echo " 최종 결과: ${CRIT} 즉시 조치 필요" ;; +esac +echo " 점검 완료: $(date '+%Y-%m-%d %H:%M:%S')" +echo "======================================================" +exit $RESULT diff --git a/scripts/sm/agent/agent_scouter_sm.sh b/scripts/sm/agent/agent_scouter_sm.sh new file mode 100644 index 0000000..966f4b9 --- /dev/null +++ b/scripts/sm/agent/agent_scouter_sm.sh @@ -0,0 +1,136 @@ +#!/bin/bash +# ============================================================ +# GUARDiA SM | agent_scouter_sm.sh +# 대상: Scouter APM (Collector / Host-Agent / Java-Agent) +# 파라미터: SCOUTER_HOME=/opt/scouter +# SC_COLLECTOR_HOST=localhost SC_COLLECTOR_PORT=6100 +# SC_WEB_PORT=6180 SC_COLLECTOR_UDP=6100 +# ============================================================ +set -euo pipefail +SCOUTER_HOME=${SCOUTER_HOME:-/opt/scouter} +SC_COLLECTOR_HOST=${SC_COLLECTOR_HOST:-localhost} +SC_COLLECTOR_PORT=${SC_COLLECTOR_PORT:-6100} +SC_WEB_PORT=${SC_WEB_PORT:-6180} +OK="[OK]"; WARN="[WARN]"; CRIT="[CRIT]" +SEP="─────────────────────────────────────────" +RESULT=0 + +echo "======================================================" +echo " GUARDiA SM 점검 | Scouter APM | $(hostname -s)" +echo " 점검 시각: $(date '+%Y-%m-%d %H:%M:%S %Z')" +echo "======================================================" + +# ── 1. Scouter Collector ────────────────────────────────── +echo; echo "[$SEP] 1. Scouter Collector" +COLL_PROC=$(pgrep -f "scouter.server\|ScouterCollector\|scouter-collector" 2>/dev/null | wc -l || echo 0) +COLL_JAR=$(pgrep -f "scouter.server" 2>/dev/null | head -1 || echo "") +if [ "$COLL_PROC" -gt 0 ] || pgrep -f "scouter" &>/dev/null; then + echo " ${OK} Scouter Collector 실행 중" + if [ -n "$COLL_JAR" ]; then + RSS_MB=$(awk '/VmRSS/{print $2}' /proc/${COLL_JAR}/status 2>/dev/null | \ + awk '{printf "%d", $1/1024}' || echo "N/A") + echo " RSS 메모리: ${RSS_MB} MB" + fi +else + echo " ${CRIT} Scouter Collector 프로세스 없음" + RESULT=2 +fi + +# 포트 리스닝 (TCP + UDP 6100) +ss -tlnp 2>/dev/null | grep -q ":${SC_COLLECTOR_PORT} " && \ + echo " ${OK} TCP 포트 ${SC_COLLECTOR_PORT} LISTEN" || \ + echo " ${WARN} TCP 포트 ${SC_COLLECTOR_PORT} LISTEN 없음" +ss -ulnp 2>/dev/null | grep -q ":${SC_COLLECTOR_PORT} " && \ + echo " ${OK} UDP 포트 ${SC_COLLECTOR_PORT} LISTEN" || \ + echo " ${WARN} UDP 포트 ${SC_COLLECTOR_PORT} LISTEN 없음" + +# ── 2. Scouter Web (선택) ──────────────────────────────── +echo; echo "[$SEP] 2. Scouter Web API" +ss -tlnp 2>/dev/null | grep -q ":${SC_WEB_PORT} " && \ + echo " ${OK} Web API 포트 ${SC_WEB_PORT} LISTEN" || \ + echo " ${WARN} Web API 포트 ${SC_WEB_PORT} LISTEN 없음 (별도 배포 시 무시)" +if command -v curl &>/dev/null; then + HTTP_CODE=$(curl -sk -o /dev/null -w "%{http_code}" \ + --max-time 5 "http://${SC_COLLECTOR_HOST}:${SC_WEB_PORT}/scouter/v1/info/server" \ + 2>/dev/null || echo "ERR") + echo "$HTTP_CODE" | grep -qE "^[23]" && \ + echo " ${OK} Web API 응답: ${HTTP_CODE}" || \ + echo " ${WARN} Web API 응답: ${HTTP_CODE}" +fi + +# ── 3. Host Agent ───────────────────────────────────────── +echo; echo "[$SEP] 3. Scouter Host Agent" +HOST_PROC=$(pgrep -f "scouter.agent.host\|host-agent" 2>/dev/null | wc -l || echo 0) +if [ "$HOST_PROC" -gt 0 ]; then + echo " ${OK} Host Agent 실행 중" +else + echo " ${WARN} Host Agent 프로세스 없음" +fi + +# ── 4. Java Agent 연결된 프로세스 ──────────────────────── +echo; echo "[$SEP] 4. Java Agent 연결 현황" +JAVA_AGENTS=$(pgrep -f "scouter.agent.jar\|scouter-agent.jar" 2>/dev/null | wc -l || echo 0) +echo " Scouter Java Agent 연결 수: ${JAVA_AGENTS}" +if [ "$JAVA_AGENTS" -gt 0 ]; then + echo " ${OK} Java Agent 활성" + # 에이전트가 붙은 프로세스 목록 + pgrep -f "scouter.agent.jar\|scouter-agent.jar" 2>/dev/null | while read PID; do + CMD=$(ps -p "$PID" -o comm= 2>/dev/null || echo "unknown") + echo " PID:${PID} CMD:${CMD}" + done | head -5 +else + echo " ${WARN} Scouter Java Agent 미연결 (WAS에 agent 미설정)" +fi + +# ── 5. Collector 데이터 디렉터리 ───────────────────────── +echo; echo "[$SEP] 5. 데이터 저장 용량" +for DATADIR in "${SCOUTER_HOME}/server/database" "${SCOUTER_HOME}/database" \ + "${SCOUTER_HOME}/collector/database"; do + if [ -d "$DATADIR" ]; then + USED=$(du -sh "$DATADIR" 2>/dev/null | awk '{print $1}' || echo "N/A") + echo " 데이터 경로: ${DATADIR}" + echo " 사용 용량: ${USED}" + break + fi +done + +# ── 6. Collector 로그 ───────────────────────────────────── +echo; echo "[$SEP] 6. Collector 로그 오류" +for LOGDIR in "${SCOUTER_HOME}/server/logs" "${SCOUTER_HOME}/logs" \ + "${SCOUTER_HOME}/collector/logs"; do + if [ -d "$LOGDIR" ]; then + LOGFILE=$(ls -t "${LOGDIR}"/*.log 2>/dev/null | head -1 || echo "") + if [ -n "$LOGFILE" ] && [ -r "$LOGFILE" ]; then + ERR=$(tail -500 "$LOGFILE" | grep -cE "ERROR|FATAL|Exception" || echo 0) + echo " 최근 오류: ${ERR}건 (${LOGFILE})" + [ "$ERR" -gt 0 ] && tail -500 "$LOGFILE" | grep -E "ERROR|FATAL" | tail -5 | sed 's/^/ /' + [ "$ERR" -gt 0 ] && [ $RESULT -lt 1 ] && RESULT=1 + fi + break + fi +done + +# ── 7. XLog 수집 확인 ───────────────────────────────────── +echo; echo "[$SEP] 7. XLog/Counter 수집 파일" +for DATADIR in "${SCOUTER_HOME}/server/database/xlog" "${SCOUTER_HOME}/database/xlog"; do + if [ -d "$DATADIR" ]; then + TODAY=$(date +%Y%m%d) + TODAY_FILES=$(ls "${DATADIR}"/*${TODAY}* 2>/dev/null | wc -l || echo 0) + echo " 오늘 XLog 파일 수: ${TODAY_FILES} (${DATADIR})" + [ "$TODAY_FILES" -eq 0 ] && echo " ${WARN} 오늘 XLog 없음 (수집 중단 가능성)" && \ + [ $RESULT -lt 1 ] && RESULT=1 || echo " ${OK} XLog 수집 중" + break + fi +done + +# ── 요약 ───────────────────────────────────────────────── +echo +echo "======================================================" +case $RESULT in + 0) echo " 최종 결과: ${OK} Scouter APM 정상" ;; + 1) echo " 최종 결과: ${WARN} 주의 항목 있음" ;; + 2) echo " 최종 결과: ${CRIT} 즉시 조치 필요" ;; +esac +echo " 점검 완료: $(date '+%Y-%m-%d %H:%M:%S')" +echo "======================================================" +exit $RESULT diff --git a/scripts/sm/common/sm_full_check.sh b/scripts/sm/common/sm_full_check.sh new file mode 100644 index 0000000..70460bc --- /dev/null +++ b/scripts/sm/common/sm_full_check.sh @@ -0,0 +1,227 @@ +#!/bin/bash +# ============================================================ +# GUARDiA SM | sm_full_check.sh +# 대상: 전체 SM 점검 오케스트레이터 +# 파라미터: SERVER_TYPE=WEB|WAS|DB|ESB|SEARCH|AGENT|ALL +# SCRIPT_BASE=/opt/guardia/itsm/scripts/sm +# TIMEOUT=300 (스크립트당 타임아웃 초) +# OUTPUT_FORMAT=text|json +# ============================================================ +set -euo pipefail +SERVER_TYPE=${SERVER_TYPE:-ALL} +SCRIPT_BASE=${SCRIPT_BASE:-$(dirname "$0")/..} +TIMEOUT_SEC=${TIMEOUT:-300} +OUTPUT_FORMAT=${OUTPUT_FORMAT:-text} +OK="[OK]"; WARN="[WARN]"; CRIT="[CRIT]" +SEP="═══════════════════════════════════════════" +RESULT=0 + +START_EPOCH=$(date +%s) + +echo "======================================================" +echo " GUARDiA SM 전체 점검 오케스트레이터" +echo " 서버: $(hostname -s) 타입: ${SERVER_TYPE}" +echo " 점검 시작: $(date '+%Y-%m-%d %H:%M:%S %Z')" +echo "======================================================" + +# 스크립트 경로 정규화 +SCRIPT_BASE=$(realpath "$SCRIPT_BASE" 2>/dev/null || echo "$SCRIPT_BASE") + +# ── 점검 결과 수집 배열 ─────────────────────────────────── +declare -a RESULTS=() +declare -A SCRIPT_MAP=( + ["system"]="${SCRIPT_BASE}/common/system_health.sh" + ["apache"]="${SCRIPT_BASE}/web/web_apache_sm.sh" + ["nginx"]="${SCRIPT_BASE}/web/web_nginx_sm.sh" + ["webtob"]="${SCRIPT_BASE}/web/web_webtob_sm.sh" + ["tomcat"]="${SCRIPT_BASE}/was/was_tomcat_sm.sh" + ["jboss"]="${SCRIPT_BASE}/was/was_jboss_sm.sh" + ["jeus"]="${SCRIPT_BASE}/was/was_jeus_sm.sh" + ["weblogic"]="${SCRIPT_BASE}/was/was_weblogic_sm.sh" + ["postgresql"]="${SCRIPT_BASE}/db/db_postgresql_sm.sh" + ["oracle"]="${SCRIPT_BASE}/db/db_oracle_sm.sh" + ["mysql"]="${SCRIPT_BASE}/db/db_mysql_sm.sh" + ["tibero"]="${SCRIPT_BASE}/db/db_tibero_sm.sh" + ["esb"]="${SCRIPT_BASE}/esb/esb_check.sh" + ["elasticsearch"]="${SCRIPT_BASE}/search/search_elasticsearch_sm.sh" + ["solr"]="${SCRIPT_BASE}/search/search_solr_sm.sh" + ["pinpoint"]="${SCRIPT_BASE}/agent/agent_pinpoint_sm.sh" + ["scouter"]="${SCRIPT_BASE}/agent/agent_scouter_sm.sh" + ["crontab"]="${SCRIPT_BASE}/crontab/crontab_sm.sh" + ["ping"]="${SCRIPT_BASE}/ping/ping_test.sh" + ["log"]="${SCRIPT_BASE}/log/log_analysis.sh" +) + +# ── 서버 타입별 실행 스크립트 결정 ─────────────────────── +# 환경변수로 개별 스크립트 선택 +# 예: WEB_ENGINE=nginx DB_ENGINE=postgresql +WEB_ENGINE=${WEB_ENGINE:-apache} +WAS_ENGINE=${WAS_ENGINE:-tomcat} +DB_ENGINE=${DB_ENGINE:-postgresql} +ESB_ENGINE=${ESB_ENGINE:-activemq} +SEARCH_ENGINE=${SEARCH_ENGINE:-elasticsearch} +APM_ENGINE=${APM_ENGINE:-scouter} + +# 타입별 스크립트 목록 +case "${SERVER_TYPE^^}" in + WEB) + SCRIPTS=("system" "$WEB_ENGINE" "ping" "log") + ;; + WAS) + SCRIPTS=("system" "$WAS_ENGINE" "ping" "log") + ;; + DB) + SCRIPTS=("system" "$DB_ENGINE" "ping" "log") + ;; + ESB) + SCRIPTS=("system" "esb" "ping" "log") + ;; + SEARCH) + SCRIPTS=("system" "$SEARCH_ENGINE" "ping" "log") + ;; + AGENT) + SCRIPTS=("system" "$APM_ENGINE" "ping" "log") + ;; + ALL) + SCRIPTS=("system" "$WEB_ENGINE" "$WAS_ENGINE" "$DB_ENGINE" "esb" \ + "$SEARCH_ENGINE" "$APM_ENGINE" "crontab" "ping" "log") + ;; + CUSTOM) + # 개별 지정: CUSTOM_SCRIPTS="system tomcat postgresql ping" + IFS=' ' read -ra SCRIPTS <<< "${CUSTOM_SCRIPTS:-system}" + ;; + *) + echo " ${WARN} 알 수 없는 SERVER_TYPE: ${SERVER_TYPE}" + echo " 지원: WEB, WAS, DB, ESB, SEARCH, AGENT, ALL, CUSTOM" + exit 1 + ;; +esac + +echo " 실행할 점검 항목: ${SCRIPTS[*]}" +echo "======================================================" + +# ── 스크립트 순차 실행 + 결과 집계 ────────────────────── +RUN_OK=0; RUN_WARN=0; RUN_CRIT=0; RUN_SKIP=0 + +for SCRIPT_KEY in "${SCRIPTS[@]}"; do + SCRIPT_PATH="${SCRIPT_MAP[$SCRIPT_KEY]:-}" + echo + echo "${SEP}" + echo " 점검: ${SCRIPT_KEY} (${SCRIPT_PATH})" + echo "${SEP}" + + if [ -z "$SCRIPT_PATH" ]; then + echo " ${WARN} 알 수 없는 스크립트 키: ${SCRIPT_KEY}" + RUN_SKIP=$(( RUN_SKIP + 1 )) + RESULTS+=("${SCRIPT_KEY}:SKIP") + continue + fi + + if [ ! -f "$SCRIPT_PATH" ]; then + echo " ${WARN} 스크립트 없음: ${SCRIPT_PATH}" + RUN_SKIP=$(( RUN_SKIP + 1 )) + RESULTS+=("${SCRIPT_KEY}:SKIP") + continue + fi + + chmod +x "$SCRIPT_PATH" 2>/dev/null || true + + # 타임아웃 실행 + EXIT_CODE=0 + timeout "$TIMEOUT_SEC" bash "$SCRIPT_PATH" 2>&1 || EXIT_CODE=$? + + case $EXIT_CODE in + 0) + echo " → 결과: ${OK}" + RUN_OK=$(( RUN_OK + 1 )) + RESULTS+=("${SCRIPT_KEY}:OK") + ;; + 1) + echo " → 결과: ${WARN}" + RUN_WARN=$(( RUN_WARN + 1 )) + RESULTS+=("${SCRIPT_KEY}:WARN") + [ $RESULT -lt 1 ] && RESULT=1 + ;; + 2) + echo " → 결과: ${CRIT}" + RUN_CRIT=$(( RUN_CRIT + 1 )) + RESULTS+=("${SCRIPT_KEY}:CRIT") + RESULT=2 + ;; + 124) + echo " → 결과: ${WARN} (타임아웃 ${TIMEOUT_SEC}s)" + RUN_WARN=$(( RUN_WARN + 1 )) + RESULTS+=("${SCRIPT_KEY}:TIMEOUT") + [ $RESULT -lt 1 ] && RESULT=1 + ;; + *) + echo " → 결과: ${WARN} (종료코드: ${EXIT_CODE})" + RUN_WARN=$(( RUN_WARN + 1 )) + RESULTS+=("${SCRIPT_KEY}:EXIT${EXIT_CODE}") + [ $RESULT -lt 1 ] && RESULT=1 + ;; + esac +done + +END_EPOCH=$(date +%s) +ELAPSED=$(( END_EPOCH - START_EPOCH )) + +# ── 최종 요약 ───────────────────────────────────────────── +echo +echo "======================================================" +echo " GUARDiA SM 전체 점검 완료 요약" +echo "======================================================" +echo " 서버 : $(hostname -s)" +echo " 서버 타입 : ${SERVER_TYPE}" +echo " 점검 소요 : ${ELAPSED}초" +echo " 점검 항목 : $(( RUN_OK + RUN_WARN + RUN_CRIT + RUN_SKIP ))개" +echo " ─────────────────────────────────────────" +echo " ${OK} 정상 : ${RUN_OK}개" +echo " ${WARN} 주의 : ${RUN_WARN}개" +echo " ${CRIT} 긴급 : ${RUN_CRIT}개" +echo " 건너뜀 : ${RUN_SKIP}개" +echo " ─────────────────────────────────────────" +echo " 항목별 결과:" +for R in "${RESULTS[@]}"; do + KEY=$(echo "$R" | cut -d: -f1) + STATUS=$(echo "$R" | cut -d: -f2) + case "$STATUS" in + OK) printf " %-20s %s\n" "$KEY" "${OK}" ;; + WARN) printf " %-20s %s\n" "$KEY" "${WARN}" ;; + CRIT) printf " %-20s %s\n" "$KEY" "${CRIT}" ;; + *) printf " %-20s %s (%s)\n" "$KEY" "${WARN}" "$STATUS" ;; + esac +done +echo " ─────────────────────────────────────────" +case $RESULT in + 0) echo " 최종 결과: ${OK} 시스템 전체 정상" ;; + 1) echo " 최종 결과: ${WARN} 주의 항목 있음 — 모니터링 강화 권고" ;; + 2) echo " 최종 결과: ${CRIT} 즉시 조치 필요" ;; +esac +echo " 완료 시각: $(date '+%Y-%m-%d %H:%M:%S')" +echo "======================================================" + +# JSON 출력 (GUARDiA ITSM AI 파싱용) +if [ "$OUTPUT_FORMAT" = "json" ]; then + echo "---JSON_RESULT---" + python3 -c " +import json, sys +results = {} +for r in '''${RESULTS[*]}'''.split(): + k, v = r.split(':', 1) + results[k] = v +print(json.dumps({ + 'hostname': '$(hostname -s)', + 'server_type': '${SERVER_TYPE}', + 'overall': ['OK','WARN','CRIT'][${RESULT}], + 'ok': ${RUN_OK}, + 'warn': ${RUN_WARN}, + 'crit': ${RUN_CRIT}, + 'skip': ${RUN_SKIP}, + 'elapsed_sec': ${ELAPSED}, + 'results': results +}, ensure_ascii=False, indent=2)) +" 2>/dev/null || echo "{\"error\": \"json 생성 실패\"}" +fi + +exit $RESULT diff --git a/scripts/sm/common/system_health.sh b/scripts/sm/common/system_health.sh new file mode 100644 index 0000000..ab49572 --- /dev/null +++ b/scripts/sm/common/system_health.sh @@ -0,0 +1,139 @@ +#!/bin/bash +# ============================================================ +# GUARDiA SM | system_health.sh +# 대상: 모든 Linux 서버 +# 내용: CPU / 메모리 / 디스크 / 네트워크 I/O / 로드 평균 +# 파라미터: DISK_WARN=80 DISK_CRIT=90 MEM_WARN=85 CPU_WARN=90 +# ============================================================ +set -euo pipefail +DISK_WARN=${DISK_WARN:-80}; DISK_CRIT=${DISK_CRIT:-90} +MEM_WARN=${MEM_WARN:-85}; CPU_WARN=${CPU_WARN:-90} +SEP="─────────────────────────────────────────" +OK="[OK]"; WARN="[WARN]"; CRIT="[CRIT]" +RESULT=0 # 0=OK 1=WARN 2=CRIT + +echo "======================================================" +echo " GUARDiA SM 점검 | 시스템 리소스 | $(hostname -s)" +echo " 점검 시각: $(date '+%Y-%m-%d %H:%M:%S %Z')" +echo "======================================================" + +# ── 1. CPU 로드 ─────────────────────────────────────────── +echo; echo "[$SEP] 1. CPU / 로드 평균" +CORES=$(nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo) +LOAD1=$(awk '{print $1}' /proc/loadavg) +LOAD5=$(awk '{print $2}' /proc/loadavg) +LOAD15=$(awk '{print $3}' /proc/loadavg) +# CPU 사용률 (1초 샘플) +CPU_IDLE=$(top -bn2 -d0.5 | grep "Cpu(s)" | tail -1 | awk '{for(i=1;i<=NF;i++) if($i~/%id/) print $(i-1)}' | tr -d ',') +CPU_USE=$(echo "100 - ${CPU_IDLE:-0}" | bc 2>/dev/null || echo "N/A") +echo " CPU 코어 수 : ${CORES}" +echo " 로드 평균 : ${LOAD1} / ${LOAD5} / ${LOAD15} (1/5/15분)" +echo " CPU 사용률 : ${CPU_USE}%" +LOAD_THRESH=$(echo "$CORES * 2" | bc) +if awk "BEGIN{exit !($LOAD5 > $LOAD_THRESH)}" 2>/dev/null; then + echo " 로드 상태 : ${CRIT} 로드 평균이 코어 수 2배 초과 (${LOAD5} > ${LOAD_THRESH})" + RESULT=2 +elif awk "BEGIN{exit !($LOAD5 > $CORES)}" 2>/dev/null; then + echo " 로드 상태 : ${WARN} 로드 평균이 코어 수 초과 (${LOAD5} > ${CORES})" + [ $RESULT -lt 1 ] && RESULT=1 +else + echo " 로드 상태 : ${OK}" +fi + +# ── 2. 메모리 ──────────────────────────────────────────── +echo; echo "[$SEP] 2. 메모리" +MEM_TOTAL=$(awk '/MemTotal/{print $2}' /proc/meminfo) +MEM_AVAIL=$(awk '/MemAvailable/{print $2}' /proc/meminfo) +MEM_FREE=$(awk '/MemFree/{print $2}' /proc/meminfo) +MEM_BUFCACHE=$(( $(awk '/Buffers/{print $2}' /proc/meminfo) + $(awk '/^Cached:/{print $2}' /proc/meminfo) )) +MEM_USED=$(( MEM_TOTAL - MEM_AVAIL )) +MEM_PCT=$(( MEM_USED * 100 / MEM_TOTAL )) +SWAP_TOTAL=$(awk '/SwapTotal/{print $2}' /proc/meminfo) +SWAP_FREE=$(awk '/SwapFree/{print $2}' /proc/meminfo) +SWAP_USED=$(( SWAP_TOTAL - SWAP_FREE )) +echo " 전체 메모리 : $(( MEM_TOTAL / 1024 )) MB" +echo " 사용 메모리 : $(( MEM_USED / 1024 )) MB (${MEM_PCT}%)" +echo " 가용 메모리 : $(( MEM_AVAIL / 1024 )) MB" +echo " 버퍼/캐시 : $(( MEM_BUFCACHE / 1024 )) MB" +[ $SWAP_TOTAL -gt 0 ] && echo " 스왑 사용 : $(( SWAP_USED / 1024 )) MB / $(( SWAP_TOTAL / 1024 )) MB" +if [ $MEM_PCT -ge $((MEM_WARN + 10)) ]; then + echo " 메모리 상태 : ${CRIT} ${MEM_PCT}% 사용 중" + RESULT=2 +elif [ $MEM_PCT -ge $MEM_WARN ]; then + echo " 메모리 상태 : ${WARN} ${MEM_PCT}% 사용 중" + [ $RESULT -lt 1 ] && RESULT=1 +else + echo " 메모리 상태 : ${OK} ${MEM_PCT}%" +fi + +# ── 3. 디스크 ──────────────────────────────────────────── +echo; echo "[$SEP] 3. 디스크 사용량" +DF_OUT=$(df -h --output=source,size,used,avail,pcent,target 2>/dev/null | grep -v tmpfs | grep -v udev | grep -v "Filesystem") +echo "$DF_OUT" | head -1 | awk '{printf " %-20s %6s %6s %6s %5s %s\n","마운트포인트","전체","사용","가용","사용%","장치"}' +echo "$DF_OUT" | while read line; do + PCENT=$(echo "$line" | awk '{print $5}' | tr -d '%') + MP=$(echo "$line" | awk '{print $6}') + if [ -n "$PCENT" ] && [ "$PCENT" -ge $DISK_CRIT ] 2>/dev/null; then + echo " ${CRIT} $line" + echo "DISK_CRIT_FOUND" >/tmp/.guardia_disk_crit_$$ + elif [ -n "$PCENT" ] && [ "$PCENT" -ge $DISK_WARN ] 2>/dev/null; then + echo " ${WARN} $line" + else + echo " ${OK} $line" + fi +done +[ -f /tmp/.guardia_disk_crit_$$ ] && { RESULT=2; rm -f /tmp/.guardia_disk_crit_$$; } + +# ── 4. 주요 마운트 inode ───────────────────────────────── +echo; echo "[$SEP] 4. inode 사용률" +df -i 2>/dev/null | grep -v tmpfs | grep -v udev | tail -n +2 | while read line; do + IPCT=$(echo "$line" | awk '{print $5}' | tr -d '%') + if [ -n "$IPCT" ] && [ "$IPCT" -ge 90 ] 2>/dev/null; then + echo " ${WARN} inode 부족: $line" + fi +done +echo " inode 점검 완료" + +# ── 5. 네트워크 I/O ────────────────────────────────────── +echo; echo "[$SEP] 5. 네트워크 인터페이스" +ip -s link show 2>/dev/null | awk ' +/^[0-9]+:/{name=$2; gsub(/:$/,"",name)} +/RX:/{rx_bytes=0; getline; rx_bytes=$1} +/TX:/{tx_bytes=0; getline; tx_bytes=$1; + if (name != "lo") printf " %-12s RX: %s bytes TX: %s bytes\n", name, rx_bytes, tx_bytes}' \ + | head -5 + +# ── 6. 상위 프로세스 (CPU) ────────────────────────────── +echo; echo "[$SEP] 6. CPU 상위 5개 프로세스" +ps aux --sort=-%cpu 2>/dev/null | awk 'NR==1{print " ",$0} NR>1 && NR<=6{printf " %-8s %5s%% %5s%% %s\n",$1,$3,$4,substr($0,index($0,$11))}' 2>/dev/null || \ +ps -eo user,pcpu,pmem,comm --sort=-pcpu 2>/dev/null | head -6 | awk '{printf " %-10s %5s%% %5s%% %s\n",$1,$2,$3,$4}' + +# ── 7. 열린 파일 수 ────────────────────────────────────── +echo; echo "[$SEP] 7. 파일 디스크립터" +OPEN_FILES=$(ls /proc/*/fd 2>/dev/null | wc -l) +MAX_FILES=$(cat /proc/sys/fs/file-max 2>/dev/null || echo "N/A") +echo " 열린 파일 수 : ${OPEN_FILES}" +echo " 시스템 최대 : ${MAX_FILES}" + +# ── 8. 최근 커널 OOM ───────────────────────────────────── +echo; echo "[$SEP] 8. OOM / 커널 오류 (최근 1시간)" +OOM=$(dmesg --since="1 hour ago" 2>/dev/null | grep -i "oom\|out of memory\|killed process" | tail -5 || true) +if [ -n "$OOM" ]; then + echo " ${WARN} OOM 이벤트 감지:" + echo "$OOM" | sed 's/^/ /' + [ $RESULT -lt 1 ] && RESULT=1 +else + echo " ${OK} OOM 없음" +fi + +# ── 요약 ───────────────────────────────────────────────── +echo +echo "======================================================" +case $RESULT in + 0) echo " 최종 결과: ${OK} 시스템 정상" ;; + 1) echo " 최종 결과: ${WARN} 주의 항목 있음 — 모니터링 강화 권고" ;; + 2) echo " 최종 결과: ${CRIT} 즉시 조치 필요" ;; +esac +echo " 점검 완료: $(date '+%Y-%m-%d %H:%M:%S')" +echo "======================================================" +exit $RESULT diff --git a/scripts/sm/crontab/crontab_sm.sh b/scripts/sm/crontab/crontab_sm.sh new file mode 100644 index 0000000..2ded771 --- /dev/null +++ b/scripts/sm/crontab/crontab_sm.sh @@ -0,0 +1,141 @@ +#!/bin/bash +# ============================================================ +# GUARDiA SM | crontab_sm.sh +# 대상: 시스템 crontab 관리 점검 +# 파라미터: CHECK_USERS="root oracle tomcat" (공백 구분) +# SYSLOG_HOURS=1 (최근 N시간 크론 실행 로그 확인) +# MAX_CRON_RUNTIME=3600 (초과 시 WARN, 초) +# ============================================================ +set -euo pipefail +CHECK_USERS=${CHECK_USERS:-"root"} +SYSLOG_HOURS=${SYSLOG_HOURS:-1} +MAX_CRON_RUNTIME=${MAX_CRON_RUNTIME:-3600} +OK="[OK]"; WARN="[WARN]"; CRIT="[CRIT]" +SEP="─────────────────────────────────────────" +RESULT=0 + +echo "======================================================" +echo " GUARDiA SM 점검 | Crontab | $(hostname -s)" +echo " 점검 시각: $(date '+%Y-%m-%d %H:%M:%S %Z')" +echo "======================================================" + +# ── 1. crond 프로세스 ───────────────────────────────────── +echo; echo "[$SEP] 1. crond/cron 프로세스" +CRON_PROC=$(pgrep -c "crond\|cron" 2>/dev/null || echo 0) +if [ "$CRON_PROC" -gt 0 ]; then + echo " ${OK} crond 실행 중 (PID: $(pgrep -f "crond|^cron$" | head -1))" +else + echo " ${CRIT} crond 프로세스 없음" + RESULT=2 +fi + +# ── 2. 사용자별 crontab 등록 현황 ──────────────────────── +echo; echo "[$SEP] 2. 사용자 crontab 등록 현황" +for U in $CHECK_USERS; do + if id "$U" &>/dev/null; then + CTAB=$(crontab -u "$U" -l 2>/dev/null | grep -v "^#\|^$" | head -20 || echo "") + COUNT=$(echo "$CTAB" | grep -c "." 2>/dev/null || echo 0) + if [ "${COUNT:-0}" -gt 0 ]; then + echo " ─ ${U} (${COUNT}개):" + echo "$CTAB" | sed 's/^/ /' + else + echo " ─ ${U}: crontab 없음" + fi + else + echo " ─ ${U}: 계정 없음 (건너뜀)" + fi +done + +# ── 3. /etc/cron.* 디렉터리 내용 ───────────────────────── +echo; echo "[$SEP] 3. 시스템 cron 디렉터리" +for CRONDIR in /etc/cron.hourly /etc/cron.daily /etc/cron.weekly /etc/cron.monthly; do + if [ -d "$CRONDIR" ]; then + CNT=$(ls "$CRONDIR" 2>/dev/null | wc -l) + echo " ${CRONDIR}: ${CNT}개" + ls "$CRONDIR" 2>/dev/null | sed 's/^/ - /' | head -10 + fi +done + +# /etc/cron.d +if [ -d /etc/cron.d ]; then + echo " /etc/cron.d:" + ls /etc/cron.d 2>/dev/null | sed 's/^/ - /' | head -10 +fi + +# /etc/crontab +if [ -r /etc/crontab ]; then + echo " /etc/crontab:" + grep -v "^#\|^$" /etc/crontab 2>/dev/null | sed 's/^/ /' | head -15 || true +fi + +# ── 4. 최근 실행 로그 ───────────────────────────────────── +echo; echo "[$SEP] 4. 최근 cron 실행 로그 (${SYSLOG_HOURS}시간)" +for LOGFILE in /var/log/cron /var/log/cron.log /var/log/syslog; do + if [ -r "$LOGFILE" ]; then + CRON_RUNS=$(grep -i "cron" "$LOGFILE" 2>/dev/null | \ + awk -v h="$SYSLOG_HOURS" ' + BEGIN{ + cmd="date --date=\"-"h" hours\" +%s 2>/dev/null || echo 0" + cmd | getline cutoff; close(cmd) + } + { + # 로그 라인의 시간 파싱 (Mmm DD HH:MM:SS 형식) + print $0 + }' 2>/dev/null | tail -50 || \ + tail -100 "$LOGFILE" | grep -i "cron\|CMD" | tail -30 || echo "") + if [ -n "$CRON_RUNS" ]; then + echo " 최근 cron 실행 기록 (${LOGFILE}):" + echo "$CRON_RUNS" | tail -20 | sed 's/^/ /' + fi + break + fi +done + +# ── 5. 실패한 cron 작업 감지 ───────────────────────────── +echo; echo "[$SEP] 5. 실패한 cron 작업" +for LOGFILE in /var/log/cron /var/log/cron.log /var/log/syslog; do + if [ -r "$LOGFILE" ]; then + FAILED=$(tail -500 "$LOGFILE" | grep -i "cron" | \ + grep -iE "error|failed|exit code [^0]|SIGKILL|SIGTERM" | tail -10 || echo "") + if [ -n "$FAILED" ]; then + echo " ${WARN} 최근 실패 기록:" + echo "$FAILED" | sed 's/^/ /' + [ $RESULT -lt 1 ] && RESULT=1 + else + echo " ${OK} 최근 실패 없음" + fi + break + fi +done + +# ── 6. 현재 실행 중인 cron 작업 ────────────────────────── +echo; echo "[$SEP] 6. 현재 실행 중인 cron 작업" +RUNNING=$(ps aux 2>/dev/null | grep "cron" | grep -v "crond\|grep\|crontab" | head -10 || echo "") +if [ -n "$RUNNING" ]; then + echo " 실행 중인 cron 작업:" + echo "$RUNNING" | awk '{printf " PID:%-8s ELAPSED:%-10s CMD:%s\n",$2,$10,substr($0,index($0,$11))}' | head -10 +else + echo " ${OK} 실행 중인 cron 작업 없음" +fi + +# ── 7. anacron 상태 ─────────────────────────────────────── +echo; echo "[$SEP] 7. anacron 상태" +if command -v anacron &>/dev/null; then + echo " ${OK} anacron 설치됨" + ANACRON_LOG=$(cat /var/spool/anacron/cron.daily 2>/dev/null || echo "N/A") + echo " 마지막 daily 실행: ${ANACRON_LOG}" +else + echo " anacron 미설치 (일반 crond 사용)" +fi + +# ── 요약 ───────────────────────────────────────────────── +echo +echo "======================================================" +case $RESULT in + 0) echo " 최종 결과: ${OK} Crontab 정상" ;; + 1) echo " 최종 결과: ${WARN} 주의 항목 있음" ;; + 2) echo " 최종 결과: ${CRIT} 즉시 조치 필요" ;; +esac +echo " 점검 완료: $(date '+%Y-%m-%d %H:%M:%S')" +echo "======================================================" +exit $RESULT diff --git a/scripts/sm/db/db_mysql_sm.sh b/scripts/sm/db/db_mysql_sm.sh new file mode 100644 index 0000000..193bf84 --- /dev/null +++ b/scripts/sm/db/db_mysql_sm.sh @@ -0,0 +1,156 @@ +#!/bin/bash +# ============================================================ +# GUARDiA SM | db_mysql_sm.sh +# 대상: MySQL / MariaDB +# 파라미터: MYSQL_HOST=localhost MYSQL_PORT=3306 +# MYSQL_USER=root MYSQL_PASS=password +# SLOW_QUERY_WARN=100 MAX_CONN_WARN=80 +# ============================================================ +set -euo pipefail +MYSQL_HOST=${MYSQL_HOST:-localhost} +MYSQL_PORT=${MYSQL_PORT:-3306} +MYSQL_USER=${MYSQL_USER:-root} +MYSQL_PASS=${MYSQL_PASS:-""} +SLOW_QUERY_WARN=${SLOW_QUERY_WARN:-100} +MAX_CONN_WARN=${MAX_CONN_WARN:-80} +OK="[OK]"; WARN="[WARN]"; CRIT="[CRIT]" +SEP="─────────────────────────────────────────" +RESULT=0 + +# mysql 실행 헬퍼 +MY="mysql -h${MYSQL_HOST} -P${MYSQL_PORT} -u${MYSQL_USER}" +[ -n "$MYSQL_PASS" ] && MY="${MY} -p${MYSQL_PASS}" +MY="${MY} --batch --silent" + +echo "======================================================" +echo " GUARDiA SM 점검 | MySQL/MariaDB | $(hostname -s)" +echo " 점검 시각: $(date '+%Y-%m-%d %H:%M:%S %Z')" +echo "======================================================" + +# ── 1. 프로세스 ─────────────────────────────────────────── +echo; echo "[$SEP] 1. MySQL/MariaDB 프로세스" +MYSQL_PROC=$(pgrep -c "mysqld\|mariadbd" 2>/dev/null || echo 0) +if [ "$MYSQL_PROC" -gt 0 ]; then + echo " ${OK} MySQL/MariaDB 실행 중 (${MYSQL_PROC}개)" +else + echo " ${CRIT} MySQL/MariaDB 프로세스 없음" + RESULT=2 +fi + +# ── 2. 포트 리스닝 + 접속 테스트 ───────────────────────── +echo; echo "[$SEP] 2. 포트 리스닝 및 접속" +if ss -tlnp 2>/dev/null | grep -q ":${MYSQL_PORT} "; then + echo " ${OK} 포트 ${MYSQL_PORT} LISTEN" +else + echo " ${CRIT} 포트 ${MYSQL_PORT} LISTEN 없음" + RESULT=2 +fi +if command -v mysql &>/dev/null; then + VERSION=$(${MY} -e "SELECT @@version;" 2>/dev/null | head -1 || echo "접속 실패") + if echo "$VERSION" | grep -qE "[0-9]+\.[0-9]+"; then + echo " ${OK} DB 접속 성공 — 버전: ${VERSION}" + else + echo " ${CRIT} DB 접속 실패" + RESULT=2 + fi +else + echo " ${WARN} mysql 클라이언트 없음" + [ $RESULT -lt 1 ] && RESULT=1 +fi + +# ── 3. 연결 현황 ───────────────────────────────────────── +echo; echo "[$SEP] 3. 연결 현황" +if command -v mysql &>/dev/null && [ "$RESULT" -lt 2 ]; then + MAX_CONN=$(${MY} -e "SHOW VARIABLES LIKE 'max_connections';" 2>/dev/null | awk '{print $2}') + CURR_CONN=$(${MY} -e "SHOW STATUS LIKE 'Threads_connected';" 2>/dev/null | awk '{print $2}') + RUNNING=$(${MY} -e "SHOW STATUS LIKE 'Threads_running';" 2>/dev/null | awk '{print $2}') + echo " 최대 연결: ${MAX_CONN}" + echo " 현재 연결: ${CURR_CONN} (실행 중: ${RUNNING})" + if [ -n "$MAX_CONN" ] && [ "$MAX_CONN" -gt 0 ]; then + CONN_PCT=$(( CURR_CONN * 100 / MAX_CONN )) + if [ "$CONN_PCT" -ge "$MAX_CONN_WARN" ]; then + echo " ${WARN} 연결 사용률 ${CONN_PCT}%" + [ $RESULT -lt 1 ] && RESULT=1 + else + echo " ${OK} 연결 사용률 ${CONN_PCT}%" + fi + fi +fi + +# ── 4. 슬로우 쿼리 ──────────────────────────────────────── +echo; echo "[$SEP] 4. 슬로우 쿼리" +if command -v mysql &>/dev/null && [ "$RESULT" -lt 2 ]; then + SLOW_CNT=$(${MY} -e "SHOW STATUS LIKE 'Slow_queries';" 2>/dev/null | awk '{print $2}' || echo 0) + SLOW_LOG=$(${MY} -e "SHOW VARIABLES LIKE 'slow_query_log';" 2>/dev/null | awk '{print $2}') + LONG_TIME=$(${MY} -e "SHOW VARIABLES LIKE 'long_query_time';" 2>/dev/null | awk '{print $2}') + echo " 슬로우 쿼리 로그: ${SLOW_LOG} (임계: ${LONG_TIME}s)" + if [ "${SLOW_CNT:-0}" -gt "$SLOW_QUERY_WARN" ]; then + echo " ${WARN} 슬로우 쿼리 누적: ${SLOW_CNT}건" + [ $RESULT -lt 1 ] && RESULT=1 + else + echo " ${OK} 슬로우 쿼리: ${SLOW_CNT}건" + fi +fi + +# ── 5. InnoDB 상태 ──────────────────────────────────────── +echo; echo "[$SEP] 5. InnoDB 주요 상태" +if command -v mysql &>/dev/null && [ "$RESULT" -lt 2 ]; then + INNODB=$(${MY} -e " + SHOW STATUS WHERE Variable_name IN + ('Innodb_buffer_pool_reads','Innodb_buffer_pool_read_requests', + 'Innodb_row_lock_waits','Innodb_deadlocks','Innodb_log_waits');" \ + 2>/dev/null | sed 's/^/ /' || echo "") + echo "$INNODB" + # 데드락 체크 + DEADLOCKS=$(${MY} -e "SHOW STATUS LIKE 'Innodb_deadlocks';" 2>/dev/null | awk '{print $2}' || echo 0) + [ "${DEADLOCKS:-0}" -gt 0 ] && echo " ${WARN} 데드락 발생: ${DEADLOCKS}건" && [ $RESULT -lt 1 ] && RESULT=1 +fi + +# ── 6. 복제(Replication) 상태 ──────────────────────────── +echo; echo "[$SEP] 6. 복제 상태" +if command -v mysql &>/dev/null && [ "$RESULT" -lt 2 ]; then + SLAVE_STATUS=$(${MY} -e "SHOW SLAVE STATUS\G" 2>/dev/null || \ + ${MY} -e "SHOW REPLICA STATUS\G" 2>/dev/null || echo "") + if [ -n "$SLAVE_STATUS" ]; then + IO_RUNNING=$(echo "$SLAVE_STATUS" | grep "Slave_IO_Running\|Replica_IO_Running" | awk '{print $2}') + SQL_RUNNING=$(echo "$SLAVE_STATUS" | grep "Slave_SQL_Running\|Replica_SQL_Running" | awk '{print $2}') + BEHIND=$(echo "$SLAVE_STATUS" | grep "Seconds_Behind_Master\|Seconds_Behind_Source" | awk '{print $2}') + echo " IO Thread: ${IO_RUNNING}, SQL Thread: ${SQL_RUNNING}" + echo " 복제 지연: ${BEHIND}초" + if [ "$IO_RUNNING" != "Yes" ] || [ "$SQL_RUNNING" != "Yes" ]; then + echo " ${CRIT} 복제 비정상" + RESULT=2 + elif [ "${BEHIND:-0}" -gt 60 ]; then + echo " ${WARN} 복제 지연 과다 (${BEHIND}s)" + [ $RESULT -lt 1 ] && RESULT=1 + else + echo " ${OK} 복제 정상" + fi + else + echo " 복제 미설정 (Standalone)" + fi +fi + +# ── 7. DB 크기 ──────────────────────────────────────────── +echo; echo "[$SEP] 7. DB 크기 (상위 5)" +if command -v mysql &>/dev/null && [ "$RESULT" -lt 2 ]; then + ${MY} -e " + SELECT table_schema AS db, + ROUND(SUM(data_length + index_length)/1024/1024, 1) AS size_mb + FROM information_schema.tables + GROUP BY table_schema + ORDER BY size_mb DESC + LIMIT 5;" 2>/dev/null | sed 's/^/ /' || true +fi + +# ── 요약 ───────────────────────────────────────────────── +echo +echo "======================================================" +case $RESULT in + 0) echo " 최종 결과: ${OK} MySQL/MariaDB 정상" ;; + 1) echo " 최종 결과: ${WARN} 주의 항목 있음" ;; + 2) echo " 최종 결과: ${CRIT} 즉시 조치 필요" ;; +esac +echo " 점검 완료: $(date '+%Y-%m-%d %H:%M:%S')" +echo "======================================================" +exit $RESULT diff --git a/scripts/sm/db/db_oracle_sm.sh b/scripts/sm/db/db_oracle_sm.sh new file mode 100644 index 0000000..6e0cd74 --- /dev/null +++ b/scripts/sm/db/db_oracle_sm.sh @@ -0,0 +1,181 @@ +#!/bin/bash +# ============================================================ +# GUARDiA SM | db_oracle_sm.sh +# 대상: Oracle Database (11g/12c/19c/21c) +# 파라미터: ORACLE_HOME=/opt/oracle/product/19c/dbhome_1 +# ORACLE_SID=orcl ORACLE_USER=oracle +# DB_USER=system DB_PASS=oracle DB_CONN=localhost:1521/orcl +# LISTENER_PORT=1521 ARCHIVE_WARN=80 +# ============================================================ +set -euo pipefail +ORACLE_HOME=${ORACLE_HOME:-/opt/oracle/product/19c/dbhome_1} +ORACLE_SID=${ORACLE_SID:-orcl} +ORACLE_USER=${ORACLE_USER:-oracle} +DB_USER=${DB_USER:-system} +DB_PASS=${DB_PASS:-oracle} +DB_CONN=${DB_CONN:-"localhost:1521/${ORACLE_SID}"} +LISTENER_PORT=${LISTENER_PORT:-1521} +ARCHIVE_WARN=${ARCHIVE_WARN:-80} +OK="[OK]"; WARN="[WARN]"; CRIT="[CRIT]" +SEP="─────────────────────────────────────────" +RESULT=0 +export ORACLE_HOME ORACLE_SID +export PATH="${ORACLE_HOME}/bin:${PATH}" + +# sqlplus 헬퍼 (결과만 반환) +_sql() { + sqlplus -s "${DB_USER}/${DB_PASS}@${DB_CONN}" </dev/null +SET PAGESIZE 0 FEEDBACK OFF VERIFY OFF HEADING OFF ECHO OFF TRIMOUT ON +$1 +EXIT; +EOF +} + +echo "======================================================" +echo " GUARDiA SM 점검 | Oracle DB | $(hostname -s)" +echo " 점검 시각: $(date '+%Y-%m-%d %H:%M:%S %Z')" +echo "======================================================" + +# ── 1. 리스너 프로세스 ──────────────────────────────────── +echo; echo "[$SEP] 1. 리스너 상태" +LSNR_PROC=$(pgrep -c "tnslsnr" 2>/dev/null || echo 0) +if [ "$LSNR_PROC" -gt 0 ]; then + echo " ${OK} 리스너 실행 중 (PID: $(pgrep tnslsnr | head -1))" + if command -v lsnrctl &>/dev/null; then + LSNR_STAT=$(lsnrctl status 2>/dev/null | grep -E "Alias|Status|Uptime|Version" | \ + sed 's/^/ /' | head -6 || echo "") + echo "$LSNR_STAT" + fi +else + echo " ${CRIT} 리스너 프로세스 없음" + RESULT=2 +fi + +# ── 2. 포트 리스닝 ──────────────────────────────────────── +echo; echo "[$SEP] 2. 포트 리스닝" +if ss -tlnp 2>/dev/null | grep -q ":${LISTENER_PORT} "; then + echo " ${OK} 포트 ${LISTENER_PORT} LISTEN" +else + echo " ${CRIT} 포트 ${LISTENER_PORT} LISTEN 없음" + RESULT=2 +fi + +# ── 3. DB 프로세스 + 인스턴스 상태 ────────────────────── +echo; echo "[$SEP] 3. 인스턴스 상태" +ORA_PROC=$(pgrep -c "ora_pmon_\|ora_smon_" 2>/dev/null || echo 0) +if [ "$ORA_PROC" -gt 0 ]; then + echo " ${OK} Oracle 백그라운드 프로세스 실행 중 (${ORA_PROC}개)" +else + echo " ${CRIT} Oracle 백그라운드 프로세스 없음" + RESULT=2 +fi + +# sqlplus 접속 테스트 +if command -v sqlplus &>/dev/null && [ "$RESULT" -lt 2 ]; then + DB_STATUS=$(_sql "SELECT status FROM v\$instance;" 2>/dev/null | tr -d ' ') + if echo "$DB_STATUS" | grep -qi "OPEN"; then + echo " ${OK} DB 인스턴스 상태: OPEN" + else + echo " ${WARN} DB 인스턴스 상태: ${DB_STATUS}" + [ $RESULT -lt 1 ] && RESULT=1 + fi +fi + +# ── 4. 세션 현황 ───────────────────────────────────────── +echo; echo "[$SEP] 4. 세션 현황" +if command -v sqlplus &>/dev/null && [ "$RESULT" -lt 2 ]; then + SESSION_INFO=$(_sql " + SELECT 'TOTAL:'||count(*)||' ACTIVE:'|| + sum(CASE WHEN status='ACTIVE' THEN 1 ELSE 0 END)||' INACTIVE:'|| + sum(CASE WHEN status='INACTIVE' THEN 1 ELSE 0 END) + FROM v\$session WHERE type='USER';" 2>/dev/null | tr -d ' ') + echo " ${SESSION_INFO}" + # 세션 한도 + MAX_SESS=$(_sql "SELECT value FROM v\$parameter WHERE name='sessions';" 2>/dev/null | tr -d ' ') + CURR_SESS=$(_sql "SELECT count(*) FROM v\$session;" 2>/dev/null | tr -d ' ') + echo " 세션 한도: ${MAX_SESS}, 현재: ${CURR_SESS}" + if [ -n "$MAX_SESS" ] && [ "$MAX_SESS" -gt 0 ]; then + SESS_PCT=$(( CURR_SESS * 100 / MAX_SESS )) + [ "$SESS_PCT" -ge 80 ] && echo " ${WARN} 세션 사용률 ${SESS_PCT}%" && [ $RESULT -lt 1 ] && RESULT=1 + fi +fi + +# ── 5. 테이블스페이스 사용률 ───────────────────────────── +echo; echo "[$SEP] 5. 테이블스페이스 사용률" +if command -v sqlplus &>/dev/null && [ "$RESULT" -lt 2 ]; then + TS_INFO=$(_sql " + SELECT df.tablespace_name, + round(df.total_mb,1)||'MB' AS total, + round(df.total_mb - fs.free_mb,1)||'MB' AS used, + round((df.total_mb - fs.free_mb) * 100 / df.total_mb,1)||'%' AS pct + FROM (SELECT tablespace_name, sum(bytes)/1048576 total_mb FROM dba_data_files + GROUP BY tablespace_name) df, + (SELECT tablespace_name, sum(bytes)/1048576 free_mb FROM dba_free_space + GROUP BY tablespace_name) fs + WHERE df.tablespace_name = fs.tablespace_name(+) + ORDER BY 4 DESC + FETCH FIRST 10 ROWS ONLY;" 2>/dev/null | sed 's/^/ /' || echo " 조회 실패") + echo "$TS_INFO" + # 90% 초과 체크 + CRIT_TS=$(_sql " + SELECT count(*) FROM ( + SELECT df.tablespace_name, + round((df.total_mb - nvl(fs.free_mb,0)) * 100 / df.total_mb,1) pct + FROM (SELECT tablespace_name, sum(bytes)/1048576 total_mb FROM dba_data_files + GROUP BY tablespace_name) df, + (SELECT tablespace_name, sum(bytes)/1048576 free_mb FROM dba_free_space + GROUP BY tablespace_name) fs + WHERE df.tablespace_name = fs.tablespace_name(+) + ) WHERE pct >= 90;" 2>/dev/null | tr -d ' ' || echo 0) + [ "${CRIT_TS:-0}" -gt 0 ] && echo " ${CRIT} 테이블스페이스 90% 초과 ${CRIT_TS}개" && RESULT=2 +fi + +# ── 6. 아카이브 로그 사용률 ────────────────────────────── +echo; echo "[$SEP] 6. 아카이브 로그 사용률" +if command -v sqlplus &>/dev/null && [ "$RESULT" -lt 2 ]; then + ARCH_PCT=$(_sql " + SELECT round(space_used * 100 / space_limit,1) + FROM v\$recovery_file_dest;" 2>/dev/null | tr -d ' ' || echo "N/A") + if echo "$ARCH_PCT" | grep -qE "^[0-9]"; then + if [ "${ARCH_PCT%.*}" -ge 90 ]; then + echo " ${CRIT} FRA 사용률 ${ARCH_PCT}%" + RESULT=2 + elif [ "${ARCH_PCT%.*}" -ge "$ARCHIVE_WARN" ]; then + echo " ${WARN} FRA 사용률 ${ARCH_PCT}%" + [ $RESULT -lt 1 ] && RESULT=1 + else + echo " ${OK} FRA 사용률 ${ARCH_PCT}%" + fi + else + echo " FRA 정보 없음 (아카이브 모드 미사용 또는 미권한)" + fi +fi + +# ── 7. 알람 로그 오류 (최근 100줄) ─────────────────────── +echo; echo "[$SEP] 7. Alert 로그 오류" +ALERT_LOG="${ORACLE_HOME}/../diag/rdbms/${ORACLE_SID}/${ORACLE_SID}/trace/alert_${ORACLE_SID}.log" +if [ -r "$ALERT_LOG" ]; then + ERR_CNT=$(tail -200 "$ALERT_LOG" | grep -icE "ORA-[0-9]+|error" || echo 0) + echo " 최근 오류 수: ${ERR_CNT}" + tail -200 "$ALERT_LOG" | grep -iE "ORA-[0-9]+" | tail -5 | sed 's/^/ /' || true + [ "$ERR_CNT" -gt 5 ] && [ $RESULT -lt 1 ] && RESULT=1 +else + # ADR 기반 경로 시도 + for P in /opt/oracle/diag/rdbms/${ORACLE_SID}/${ORACLE_SID}/trace/alert_${ORACLE_SID}.log \ + /u01/app/oracle/diag/rdbms/${ORACLE_SID}/${ORACLE_SID}/trace/alert_${ORACLE_SID}.log; do + [ -r "$P" ] && tail -200 "$P" | grep -iE "ORA-[0-9]+" | tail -5 | sed 's/^/ /' && break + done + echo " Alert 로그 경로 확인 필요: ${ALERT_LOG}" +fi + +# ── 요약 ───────────────────────────────────────────────── +echo +echo "======================================================" +case $RESULT in + 0) echo " 최종 결과: ${OK} Oracle DB 정상" ;; + 1) echo " 최종 결과: ${WARN} 주의 항목 있음" ;; + 2) echo " 최종 결과: ${CRIT} 즉시 조치 필요" ;; +esac +echo " 점검 완료: $(date '+%Y-%m-%d %H:%M:%S')" +echo "======================================================" +exit $RESULT diff --git a/scripts/sm/db/db_postgresql_sm.sh b/scripts/sm/db/db_postgresql_sm.sh new file mode 100644 index 0000000..8d9917a --- /dev/null +++ b/scripts/sm/db/db_postgresql_sm.sh @@ -0,0 +1,158 @@ +#!/bin/bash +# ============================================================ +# GUARDiA SM | db_postgresql_sm.sh +# 대상: PostgreSQL +# 파라미터: PG_HOST=localhost PG_PORT=5432 PG_DB=postgres +# PG_USER=postgres PGPASSWORD= +# SLOW_QUERY_MS=1000 MAX_CONN_WARN=80 +# ============================================================ +set -euo pipefail +PG_HOST=${PG_HOST:-localhost} +PG_PORT=${PG_PORT:-5432} +PG_DB=${PG_DB:-postgres} +PG_USER=${PG_USER:-postgres} +export PGPASSWORD=${PGPASSWORD:-""} +SLOW_QUERY_MS=${SLOW_QUERY_MS:-1000} +MAX_CONN_WARN=${MAX_CONN_WARN:-80} +OK="[OK]"; WARN="[WARN]"; CRIT="[CRIT]" +SEP="─────────────────────────────────────────" +RESULT=0 +PSQ="psql -h ${PG_HOST} -p ${PG_PORT} -U ${PG_USER} -d ${PG_DB} -t -A -c" + +echo "======================================================" +echo " GUARDiA SM 점검 | PostgreSQL | $(hostname -s)" +echo " 점검 시각: $(date '+%Y-%m-%d %H:%M:%S %Z')" +echo "======================================================" + +# ── 1. 프로세스 ─────────────────────────────────────────── +echo; echo "[$SEP] 1. PostgreSQL 프로세스" +PG_PROC=$(pgrep -c "postgres" 2>/dev/null || echo 0) +PG_MASTER=$(pgrep -f "postgres: checkpointer\|postmaster" 2>/dev/null | head -1 || echo "") +if [ "$PG_PROC" -gt 0 ]; then + echo " ${OK} PostgreSQL 실행 중 (${PG_PROC}개 프로세스)" +else + echo " ${CRIT} PostgreSQL 프로세스 없음" + RESULT=2 +fi + +# ── 2. 포트 리스닝 ──────────────────────────────────────── +echo; echo "[$SEP] 2. 포트 리스닝" +if ss -tlnp 2>/dev/null | grep -q ":${PG_PORT} "; then + echo " ${OK} 포트 ${PG_PORT} LISTEN" +else + echo " ${CRIT} 포트 ${PG_PORT} LISTEN 없음" + RESULT=2 +fi + +# psql 접속 테스트 +if command -v psql &>/dev/null; then + VERSION=$(${PSQ} "SELECT version();" 2>/dev/null | head -1 || echo "접속 실패") + if echo "$VERSION" | grep -qi "postgresql"; then + echo " ${OK} DB 접속 성공: $(echo "$VERSION" | head -c 60)..." + else + echo " ${CRIT} DB 접속 실패: ${VERSION}" + RESULT=2 + fi +else + echo " ${WARN} psql 클라이언트 없음 — 접속 점검 건너뜀" + [ $RESULT -lt 1 ] && RESULT=1 +fi + +# ── 3. 연결 수 ──────────────────────────────────────────── +echo; echo "[$SEP] 3. 연결 현황" +if command -v psql &>/dev/null && [ "$RESULT" -lt 2 ]; then + CONN_INFO=$(${PSQ} " + SELECT + setting AS max_connections + FROM pg_settings WHERE name='max_connections';" 2>/dev/null || echo "") + MAX_CONN=$CONN_INFO + CURR_CONN=$(${PSQ} "SELECT count(*) FROM pg_stat_activity;" 2>/dev/null || echo 0) + ACTIVE_CONN=$(${PSQ} "SELECT count(*) FROM pg_stat_activity WHERE state='active';" 2>/dev/null || echo 0) + IDLE_CONN=$(${PSQ} "SELECT count(*) FROM pg_stat_activity WHERE state='idle';" 2>/dev/null || echo 0) + echo " 최대 연결 수: ${MAX_CONN}" + echo " 현재 연결 수: ${CURR_CONN} (active:${ACTIVE_CONN} idle:${IDLE_CONN})" + if [ -n "$MAX_CONN" ] && [ "$MAX_CONN" -gt 0 ]; then + CONN_PCT=$(( CURR_CONN * 100 / MAX_CONN )) + if [ "$CONN_PCT" -ge "$MAX_CONN_WARN" ]; then + echo " ${WARN} 연결 사용률 ${CONN_PCT}%" + [ $RESULT -lt 1 ] && RESULT=1 + else + echo " ${OK} 연결 사용률 ${CONN_PCT}%" + fi + fi +fi + +# ── 4. 슬로우 쿼리 ──────────────────────────────────────── +echo; echo "[$SEP] 4. 슬로우 쿼리 (>${SLOW_QUERY_MS}ms)" +if command -v psql &>/dev/null && [ "$RESULT" -lt 2 ]; then + SLOW=$(${PSQ} " + SELECT pid, now() - pg_stat_activity.query_start AS duration, + left(query, 80) AS query + FROM pg_stat_activity + WHERE (now() - pg_stat_activity.query_start) > interval '${SLOW_QUERY_MS} ms' + AND state = 'active' + ORDER BY duration DESC + LIMIT 5;" 2>/dev/null || echo "") + if [ -n "$SLOW" ]; then + echo " ${WARN} 슬로우 쿼리 감지:" + echo "$SLOW" | sed 's/^/ /' + [ $RESULT -lt 1 ] && RESULT=1 + else + echo " ${OK} 슬로우 쿼리 없음" + fi +fi + +# ── 5. 잠금(Lock) 대기 ──────────────────────────────────── +echo; echo "[$SEP] 5. 잠금 대기" +if command -v psql &>/dev/null && [ "$RESULT" -lt 2 ]; then + LOCK=$(${PSQ} " + SELECT count(*) FROM pg_stat_activity + WHERE wait_event_type = 'Lock';" 2>/dev/null || echo 0) + if [ "$LOCK" -gt 5 ]; then + echo " ${CRIT} 잠금 대기 ${LOCK}건" + RESULT=2 + elif [ "$LOCK" -gt 0 ]; then + echo " ${WARN} 잠금 대기 ${LOCK}건" + [ $RESULT -lt 1 ] && RESULT=1 + else + echo " ${OK} 잠금 대기 없음" + fi +fi + +# ── 6. 복제(Replication) 상태 ──────────────────────────── +echo; echo "[$SEP] 6. 복제 상태" +if command -v psql &>/dev/null && [ "$RESULT" -lt 2 ]; then + IS_STANDBY=$(${PSQ} "SELECT pg_is_in_recovery();" 2>/dev/null || echo "f") + if echo "$IS_STANDBY" | grep -q "t"; then + LAG=$(${PSQ} "SELECT EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp()))::integer;" \ + 2>/dev/null || echo "N/A") + echo " ${OK} 스탠바이 모드 — 복제 지연: ${LAG}초" + [ "$LAG" -gt 60 ] 2>/dev/null && echo " ${WARN} 복제 지연 과다 (${LAG}s)" && [ $RESULT -lt 1 ] && RESULT=1 + else + REP_COUNT=$(${PSQ} "SELECT count(*) FROM pg_stat_replication;" 2>/dev/null || echo 0) + echo " 마스터 모드 — 복제 슬레이브 수: ${REP_COUNT}" + fi +fi + +# ── 7. DB 크기 상위 5개 ────────────────────────────────── +echo; echo "[$SEP] 7. DB 크기" +if command -v psql &>/dev/null && [ "$RESULT" -lt 2 ]; then + ${PSQ} " + SELECT datname, + pg_size_pretty(pg_database_size(datname)) AS size + FROM pg_database + ORDER BY pg_database_size(datname) DESC + LIMIT 5;" 2>/dev/null | sed 's/^/ /' || true +fi + +# ── 요약 ───────────────────────────────────────────────── +echo +echo "======================================================" +case $RESULT in + 0) echo " 최종 결과: ${OK} PostgreSQL 정상" ;; + 1) echo " 최종 결과: ${WARN} 주의 항목 있음" ;; + 2) echo " 최종 결과: ${CRIT} 즉시 조치 필요" ;; +esac +echo " 점검 완료: $(date '+%Y-%m-%d %H:%M:%S')" +echo "======================================================" +exit $RESULT diff --git a/scripts/sm/db/db_tibero_sm.sh b/scripts/sm/db/db_tibero_sm.sh new file mode 100644 index 0000000..c24038d --- /dev/null +++ b/scripts/sm/db/db_tibero_sm.sh @@ -0,0 +1,150 @@ +#!/bin/bash +# ============================================================ +# GUARDiA SM | db_tibero_sm.sh +# 대상: Tibero RDBMS (TmaxSoft) +# 파라미터: TB_HOME=/opt/tibero TB_SID=tibero +# TB_PORT=8629 TB_USER=sys TB_PASS=tibero +# TS_WARN=80 SESSION_WARN=80 +# ============================================================ +set -euo pipefail +TB_HOME=${TB_HOME:-/opt/tibero} +TB_SID=${TB_SID:-tibero} +TB_PORT=${TB_PORT:-8629} +TB_USER=${TB_USER:-sys} +TB_PASS=${TB_PASS:-tibero} +TS_WARN=${TS_WARN:-80} +SESSION_WARN=${SESSION_WARN:-80} +OK="[OK]"; WARN="[WARN]"; CRIT="[CRIT]" +SEP="─────────────────────────────────────────" +RESULT=0 +export TB_HOME TB_SID +export PATH="${TB_HOME}/bin:${PATH}" +export LD_LIBRARY_PATH="${TB_HOME}/lib:${LD_LIBRARY_PATH:-}" + +# tbsql 헬퍼 +_tbsql() { + tbsql -s "${TB_USER}/${TB_PASS}" </dev/null +SET PAGESIZE 0 FEEDBACK OFF VERIFY OFF HEADING OFF ECHO OFF +$1 +EXIT; +EOF +} + +echo "======================================================" +echo " GUARDiA SM 점검 | Tibero | $(hostname -s)" +echo " 점검 시각: $(date '+%Y-%m-%d %H:%M:%S %Z')" +echo "======================================================" + +# ── 1. 프로세스 ─────────────────────────────────────────── +echo; echo "[$SEP] 1. Tibero 프로세스" +TB_PROC=$(pgrep -c "tbsvr\|tblistener\|tbagt" 2>/dev/null || echo 0) +TB_SRV=$(pgrep -c "tbsvr" 2>/dev/null || echo 0) +if [ "$TB_SRV" -gt 0 ]; then + echo " ${OK} Tibero 서버 실행 중 (${TB_SRV}개)" +else + echo " ${CRIT} Tibero 서버(tbsvr) 프로세스 없음" + RESULT=2 +fi +echo " 전체 Tibero 프로세스: ${TB_PROC}개" + +# ── 2. tblistener 상태 ──────────────────────────────────── +echo; echo "[$SEP] 2. tblistener 상태" +LSNR_PROC=$(pgrep -c "tblistener" 2>/dev/null || echo 0) +if [ "$LSNR_PROC" -gt 0 ]; then + echo " ${OK} tblistener 실행 중" +else + echo " ${CRIT} tblistener 없음" + RESULT=2 +fi + +# 포트 리스닝 +if ss -tlnp 2>/dev/null | grep -q ":${TB_PORT} "; then + echo " ${OK} 포트 ${TB_PORT} LISTEN" +else + echo " ${CRIT} 포트 ${TB_PORT} LISTEN 없음" + RESULT=2 +fi + +# ── 3. tbsql 접속 + DB 상태 ────────────────────────────── +echo; echo "[$SEP] 3. DB 접속 및 인스턴스 상태" +if command -v tbsql &>/dev/null; then + DB_STATUS=$(_tbsql "SELECT status FROM v\$instance;" 2>/dev/null | tr -d ' ') + if echo "$DB_STATUS" | grep -qi "OPEN"; then + echo " ${OK} DB 상태: OPEN" + VERSION=$(_tbsql "SELECT version FROM v\$instance;" 2>/dev/null | head -1 | tr -d ' ') + echo " Tibero 버전: ${VERSION}" + elif [ -n "$DB_STATUS" ]; then + echo " ${WARN} DB 상태: ${DB_STATUS}" + [ $RESULT -lt 1 ] && RESULT=1 + else + echo " ${CRIT} DB 접속 실패" + RESULT=2 + fi +else + echo " ${WARN} tbsql 없음: ${TB_HOME}/bin/tbsql" + [ $RESULT -lt 1 ] && RESULT=1 +fi + +# ── 4. 세션 현황 ───────────────────────────────────────── +echo; echo "[$SEP] 4. 세션 현황" +if command -v tbsql &>/dev/null && [ "$RESULT" -lt 2 ]; then + SESS_INFO=$(_tbsql " + SELECT 'TOTAL:'||count(*)|| + ' ACTIVE:'||sum(CASE WHEN status='ACTIVE' THEN 1 ELSE 0 END)|| + ' INACTIVE:'||sum(CASE WHEN status='INACTIVE' THEN 1 ELSE 0 END) + FROM v\$session WHERE type='USER';" 2>/dev/null | tr -d ' ') + echo " ${SESS_INFO}" + MAX_SESS=$(_tbsql "SELECT value FROM v\$parameter WHERE name='max_session_count';" \ + 2>/dev/null | tr -d ' ' || echo 100) + CURR_SESS=$(_tbsql "SELECT count(*) FROM v\$session;" 2>/dev/null | tr -d ' ' || echo 0) + if [ -n "$MAX_SESS" ] && [ "$MAX_SESS" -gt 0 ]; then + SESS_PCT=$(( CURR_SESS * 100 / MAX_SESS )) + [ "$SESS_PCT" -ge "$SESSION_WARN" ] && \ + echo " ${WARN} 세션 사용률 ${SESS_PCT}% (${CURR_SESS}/${MAX_SESS})" && \ + [ $RESULT -lt 1 ] && RESULT=1 || \ + echo " ${OK} 세션 ${CURR_SESS}/${MAX_SESS} (${SESS_PCT}%)" + fi +fi + +# ── 5. 테이블스페이스 사용률 ───────────────────────────── +echo; echo "[$SEP] 5. 테이블스페이스 사용률" +if command -v tbsql &>/dev/null && [ "$RESULT" -lt 2 ]; then + TS_INFO=$(_tbsql " + SELECT df.tablespace_name, + round(df.total_mb,1)||'MB' AS total, + round(df.total_mb - nvl(fs.free_mb,0),1)||'MB' AS used, + round((df.total_mb - nvl(fs.free_mb,0))*100/df.total_mb,1)||'%' AS pct_used + FROM (SELECT tablespace_name, sum(bytes)/1048576 total_mb + FROM dba_data_files GROUP BY tablespace_name) df + LEFT JOIN (SELECT tablespace_name, sum(bytes)/1048576 free_mb + FROM dba_free_space GROUP BY tablespace_name) fs + ON df.tablespace_name = fs.tablespace_name + ORDER BY 4 DESC;" 2>/dev/null | sed 's/^/ /' || echo " 조회 실패") + echo "$TS_INFO" +fi + +# ── 6. 알람 로그 ───────────────────────────────────────── +echo; echo "[$SEP] 6. 알람 로그 오류" +for LOGPATH in "${TB_HOME}/instance/${TB_SID}/log/slog/sys.log" \ + "${TB_HOME}/log/slog/sys.log" \ + "${TB_HOME}/logs/${TB_SID}.log"; do + if [ -r "$LOGPATH" ]; then + ERR_CNT=$(tail -500 "$LOGPATH" | grep -ciE "error|fatal|ORA-|TBR-" || echo 0) + echo " 최근 오류 수: ${ERR_CNT} (${LOGPATH})" + tail -500 "$LOGPATH" | grep -iE "ERROR|FATAL|ORA-|TBR-" | tail -5 | sed 's/^/ /' || true + [ "$ERR_CNT" -gt 5 ] && [ $RESULT -lt 1 ] && RESULT=1 + break + fi +done + +# ── 요약 ───────────────────────────────────────────────── +echo +echo "======================================================" +case $RESULT in + 0) echo " 최종 결과: ${OK} Tibero 정상" ;; + 1) echo " 최종 결과: ${WARN} 주의 항목 있음" ;; + 2) echo " 최종 결과: ${CRIT} 즉시 조치 필요" ;; +esac +echo " 점검 완료: $(date '+%Y-%m-%d %H:%M:%S')" +echo "======================================================" +exit $RESULT diff --git a/scripts/sm/esb/esb_check.sh b/scripts/sm/esb/esb_check.sh new file mode 100644 index 0000000..4c26ac2 --- /dev/null +++ b/scripts/sm/esb/esb_check.sh @@ -0,0 +1,217 @@ +#!/bin/bash +# ============================================================ +# GUARDiA SM | esb_check.sh +# 대상: ESB / MQ (ActiveMQ, IBM MQ, WSO2 ESB/MI) +# 파라미터: ESB_TYPE=activemq|ibmmq|wso2 +# ACTIVEMQ_HOME=/opt/activemq ACTIVEMQ_PORT=61616 +# ACTIVEMQ_CONSOLE=http://localhost:8161 +# IBMMQ_HOME=/opt/mqm MQ_QMGR=QM1 MQ_PORT=1414 +# WSO2_HOME=/opt/wso2 WSO2_PORT=8280 WSO2_MGMT_PORT=9443 +# QUEUE_DEPTH_WARN=1000 +# ============================================================ +set -euo pipefail +ESB_TYPE=${ESB_TYPE:-activemq} +ACTIVEMQ_HOME=${ACTIVEMQ_HOME:-/opt/activemq} +ACTIVEMQ_PORT=${ACTIVEMQ_PORT:-61616} +ACTIVEMQ_CONSOLE=${ACTIVEMQ_CONSOLE:-"http://localhost:8161"} +ACTIVEMQ_USER=${ACTIVEMQ_USER:-admin} +ACTIVEMQ_PASS=${ACTIVEMQ_PASS:-admin} +IBMMQ_HOME=${IBMMQ_HOME:-/opt/mqm} +MQ_QMGR=${MQ_QMGR:-QM1} +MQ_PORT=${MQ_PORT:-1414} +WSO2_HOME=${WSO2_HOME:-/opt/wso2} +WSO2_PORT=${WSO2_PORT:-8280} +WSO2_MGMT_PORT=${WSO2_MGMT_PORT:-9443} +QUEUE_DEPTH_WARN=${QUEUE_DEPTH_WARN:-1000} +OK="[OK]"; WARN="[WARN]"; CRIT="[CRIT]" +SEP="─────────────────────────────────────────" +RESULT=0 + +echo "======================================================" +echo " GUARDiA SM 점검 | ESB/MQ (${ESB_TYPE}) | $(hostname -s)" +echo " 점검 시각: $(date '+%Y-%m-%d %H:%M:%S %Z')" +echo "======================================================" + +# ════════════════════════════════════════════ +# ActiveMQ +# ════════════════════════════════════════════ +check_activemq() { + echo; echo "[$SEP] 1. ActiveMQ 프로세스" + AMQ_PROC=$(pgrep -c "activemq" 2>/dev/null || echo 0) + if [ "$AMQ_PROC" -gt 0 ]; then + echo " ${OK} ActiveMQ 실행 중 (${AMQ_PROC}개)" + else + echo " ${CRIT} ActiveMQ 프로세스 없음" + RESULT=2 + fi + + echo; echo "[$SEP] 2. 포트 리스닝" + for PORT in $ACTIVEMQ_PORT 61613 61614; do + ss -tlnp 2>/dev/null | grep -q ":${PORT} " && \ + echo " ${OK} 포트 ${PORT} LISTEN" || \ + echo " ${WARN} 포트 ${PORT} LISTEN 없음" + done + + echo; echo "[$SEP] 3. 웹 콘솔 (Jolokia REST API)" + if command -v curl &>/dev/null; then + # 브로커 상태 + BROKER=$(curl -sk --max-time 5 \ + -u "${ACTIVEMQ_USER}:${ACTIVEMQ_PASS}" \ + "${ACTIVEMQ_CONSOLE}/api/jolokia/read/org.apache.activemq:type=Broker,brokerName=localhost/TotalEnqueueCount,TotalDequeueCount,TotalConsumerCount,TotalProducerCount" \ + 2>/dev/null || echo "") + if echo "$BROKER" | python3 -c "import sys,json; d=json.load(sys.stdin); \ + v=d.get('value',{}); \ + [print(f' {k}: {val}') for k,val in v.items()]" 2>/dev/null; then + echo " ${OK} 브로커 상태 정상" + else + echo " ${WARN} Jolokia API 응답 없음 (웹 콘솔 접근 불가)" + [ $RESULT -lt 1 ] && RESULT=1 + fi + + # DLQ (Dead Letter Queue) 확인 + DLQ=$(curl -sk --max-time 5 \ + -u "${ACTIVEMQ_USER}:${ACTIVEMQ_PASS}" \ + "${ACTIVEMQ_CONSOLE}/api/jolokia/search/org.apache.activemq:type=Broker,brokerName=localhost,destinationType=Queue,destinationName=ActiveMQ.DLQ" \ + 2>/dev/null || echo "") + if echo "$DLQ" | grep -q "DLQ"; then + DLQ_DEPTH=$(curl -sk --max-time 5 \ + -u "${ACTIVEMQ_USER}:${ACTIVEMQ_PASS}" \ + "${ACTIVEMQ_CONSOLE}/api/jolokia/read/org.apache.activemq:type=Broker,brokerName=localhost,destinationType=Queue,destinationName=ActiveMQ.DLQ/QueueSize" \ + 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin).get('value',0))" 2>/dev/null || echo 0) + if [ "${DLQ_DEPTH:-0}" -gt 0 ]; then + echo " ${WARN} DLQ 메시지 ${DLQ_DEPTH}건" + [ $RESULT -lt 1 ] && RESULT=1 + else + echo " ${OK} DLQ 비어있음" + fi + fi + fi + + echo; echo "[$SEP] 4. 로그 오류" + for LOGDIR in "${ACTIVEMQ_HOME}/data" "${ACTIVEMQ_HOME}/logs"; do + LOGFILE=$(ls -t "${LOGDIR}"/activemq.log 2>/dev/null | head -1 || echo "") + if [ -n "$LOGFILE" ] && [ -r "$LOGFILE" ]; then + ERR=$(tail -500 "$LOGFILE" | grep -cE "ERROR|FATAL|Exception" || echo 0) + echo " 최근 오류: ${ERR}건 (${LOGFILE})" + [ "$ERR" -gt 0 ] && tail -500 "$LOGFILE" | grep -E "ERROR|FATAL" | tail -5 | sed 's/^/ /' + break + fi + done +} + +# ════════════════════════════════════════════ +# IBM MQ +# ════════════════════════════════════════════ +check_ibmmq() { + export PATH="${IBMMQ_HOME}/bin:${PATH}" + + echo; echo "[$SEP] 1. IBM MQ 프로세스" + MQ_PROC=$(pgrep -c "amq[a-z]" 2>/dev/null || echo 0) + if [ "$MQ_PROC" -gt 0 ]; then + echo " ${OK} IBM MQ 실행 중 (${MQ_PROC}개)" + else + echo " ${CRIT} IBM MQ 프로세스 없음" + RESULT=2 + fi + + echo; echo "[$SEP] 2. 큐 매니저 상태" + if command -v dspmq &>/dev/null; then + QMGR_STATUS=$(dspmq 2>/dev/null | grep "$MQ_QMGR" || echo "조회 실패") + echo " ${QMGR_STATUS}" + echo "$QMGR_STATUS" | grep -qi "running" && echo " ${OK} 큐 매니저 Running" || \ + { echo " ${CRIT} 큐 매니저 비정상"; RESULT=2; } + fi + + echo; echo "[$SEP] 3. 큐 깊이" + if command -v runmqsc &>/dev/null; then + QUEUE_INFO=$(echo "DISPLAY QL(*) CURDEPTH" | runmqsc "$MQ_QMGR" 2>/dev/null | \ + grep "CURDEPTH" | awk '{print $0}' | head -20 || echo "") + echo "$QUEUE_INFO" | sed 's/^/ /' | head -15 + DEEP=$(echo "$QUEUE_INFO" | awk -F'CURDEPTH\\(' '{print $2}' | tr -d ')' | \ + sort -rn | head -1 || echo 0) + if [ "${DEEP:-0}" -gt "$QUEUE_DEPTH_WARN" ]; then + echo " ${WARN} 최대 큐 깊이: ${DEEP}" + [ $RESULT -lt 1 ] && RESULT=1 + else + echo " ${OK} 큐 깊이 정상 (최대: ${DEEP})" + fi + fi + + echo; echo "[$SEP] 4. 채널 상태" + if command -v runmqsc &>/dev/null; then + CH_STATUS=$(echo "DISPLAY CHSTATUS(*)" | runmqsc "$MQ_QMGR" 2>/dev/null | \ + grep "CHSTATUS" | head -10 || echo "채널 없음") + echo "$CH_STATUS" | sed 's/^/ /' + fi +} + +# ════════════════════════════════════════════ +# WSO2 ESB / Micro Integrator +# ════════════════════════════════════════════ +check_wso2() { + echo; echo "[$SEP] 1. WSO2 프로세스" + WSO2_PROC=$(pgrep -f "wso2\|carbon" 2>/dev/null | wc -l || echo 0) + if [ "$WSO2_PROC" -gt 0 ]; then + echo " ${OK} WSO2 실행 중 (${WSO2_PROC}개)" + else + echo " ${CRIT} WSO2 프로세스 없음" + RESULT=2 + fi + + echo; echo "[$SEP] 2. 포트 리스닝" + for PORT in $WSO2_PORT $WSO2_MGMT_PORT; do + ss -tlnp 2>/dev/null | grep -q ":${PORT} " && \ + echo " ${OK} 포트 ${PORT} LISTEN" || \ + echo " ${WARN} 포트 ${PORT} LISTEN 없음" + done + + echo; echo "[$SEP] 3. 관리 콘솔 응답" + if command -v curl &>/dev/null; then + HTTP_CODE=$(curl -sk -o /dev/null -w "%{http_code}" \ + --max-time 10 "https://localhost:${WSO2_MGMT_PORT}/carbon/admin/login.jsp" \ + 2>/dev/null || echo "ERR") + if echo "$HTTP_CODE" | grep -qE "^[23]"; then + echo " ${OK} 관리 콘솔 응답: ${HTTP_CODE}" + else + echo " ${WARN} 관리 콘솔 응답: ${HTTP_CODE}" + [ $RESULT -lt 1 ] && RESULT=1 + fi + fi + + echo; echo "[$SEP] 4. WSO2 로그 오류" + for LOGDIR in "${WSO2_HOME}/repository/logs" "${WSO2_HOME}/logs"; do + if [ -d "$LOGDIR" ]; then + LOGFILE=$(ls -t "${LOGDIR}"/wso2*.log "${LOGDIR}"/carbon*.log 2>/dev/null | head -1 || echo "") + if [ -n "$LOGFILE" ] && [ -r "$LOGFILE" ]; then + ERR=$(tail -1000 "$LOGFILE" | grep -cE "ERROR|FATAL" || echo 0) + echo " 최근 오류: ${ERR}건" + [ "$ERR" -gt 0 ] && tail -1000 "$LOGFILE" | grep -E "ERROR|FATAL" | tail -5 | sed 's/^/ /' + fi + break + fi + done +} + +# 타입별 실행 +case "${ESB_TYPE,,}" in + activemq) check_activemq ;; + ibmmq|mq) check_ibmmq ;; + wso2) check_wso2 ;; + *) + echo " ${WARN} 알 수 없는 ESB_TYPE: ${ESB_TYPE}" + echo " 지원: activemq, ibmmq, wso2" + [ $RESULT -lt 1 ] && RESULT=1 + ;; +esac + +# ── 요약 ───────────────────────────────────────────────── +echo +echo "======================================================" +case $RESULT in + 0) echo " 최종 결과: ${OK} ESB/MQ 정상" ;; + 1) echo " 최종 결과: ${WARN} 주의 항목 있음" ;; + 2) echo " 최종 결과: ${CRIT} 즉시 조치 필요" ;; +esac +echo " 점검 완료: $(date '+%Y-%m-%d %H:%M:%S')" +echo "======================================================" +exit $RESULT diff --git a/scripts/sm/log/log_analysis.sh b/scripts/sm/log/log_analysis.sh new file mode 100644 index 0000000..3be2955 --- /dev/null +++ b/scripts/sm/log/log_analysis.sh @@ -0,0 +1,191 @@ +#!/bin/bash +# ============================================================ +# GUARDiA SM | log_analysis.sh +# 대상: 다목적 서버 로그 분석 스크립트 +# 파라미터: LOG_FILES="/app/logs/app.log /var/log/messages" +# ANALYZE_HOURS=1 (최근 N시간) +# ERROR_PATTERN="ERROR|FATAL|Exception|OOM|killed" +# WARN_PATTERN="WARN|WARNING|timeout|refused" +# TOP_N=10 (상위 N개 오류 패턴) +# LOG_SIZE_WARN_MB=500 ROTATE_CHECK=true +# ============================================================ +set -euo pipefail +LOG_FILES=${LOG_FILES:-""} +ANALYZE_HOURS=${ANALYZE_HOURS:-1} +ERROR_PATTERN=${ERROR_PATTERN:-'ERROR|FATAL|Exception|OutOfMemory|killed process|Caused by'} +WARN_PATTERN=${WARN_PATTERN:-'WARN|WARNING|timeout|refused|connection reset|too many'} +TOP_N=${TOP_N:-10} +LOG_SIZE_WARN_MB=${LOG_SIZE_WARN_MB:-500} +ROTATE_CHECK=${ROTATE_CHECK:-true} +OK="[OK]"; WARN="[WARN]"; CRIT="[CRIT]" +SEP="─────────────────────────────────────────" +RESULT=0 + +echo "======================================================" +echo " GUARDiA SM 점검 | 로그 분석 | $(hostname -s)" +echo " 점검 시각: $(date '+%Y-%m-%d %H:%M:%S %Z')" +echo " 분석 범위: 최근 ${ANALYZE_HOURS}시간" +echo "======================================================" + +# 로그 파일이 미지정인 경우 자동 탐색 +if [ -z "$LOG_FILES" ]; then + LOG_FILES="" + for AUTO_LOG in \ + /opt/tomcat/logs/catalina.out \ + /app/was/logs/catalina.out \ + /opt/wildfly/standalone/log/server.log \ + /opt/jeus/logs/*.log \ + /var/log/httpd/error_log \ + /var/log/nginx/error.log \ + /var/log/messages \ + /var/log/syslog; do + ls $AUTO_LOG 2>/dev/null | while read F; do + [ -r "$F" ] && LOG_FILES="${LOG_FILES} ${F}" + done + done +fi + +if [ -z "${LOG_FILES// }" ]; then + echo " ${WARN} 분석할 로그 파일이 없음 (LOG_FILES 환경변수 설정 필요)" + exit 1 +fi + +# ── 각 로그 파일 분석 ───────────────────────────────────── +FILE_NUM=0 +for LOGFILE in $LOG_FILES; do + [ -r "$LOGFILE" ] || continue + FILE_NUM=$(( FILE_NUM + 1 )) + FILESIZE_MB=$(du -m "$LOGFILE" 2>/dev/null | awk '{print $1}' || echo 0) + + echo + echo "[$SEP] 파일 ${FILE_NUM}: ${LOGFILE}" + echo " 파일 크기: ${FILESIZE_MB} MB 수정: $(stat -c '%y' "$LOGFILE" 2>/dev/null | cut -c1-19 || echo 'N/A')" + + # 파일 크기 경고 + if [ "${FILESIZE_MB:-0}" -gt "$LOG_SIZE_WARN_MB" ]; then + echo " ${WARN} 로그 파일 크기 과다 (${FILESIZE_MB}MB > ${LOG_SIZE_WARN_MB}MB) — 로테이션 권고" + [ $RESULT -lt 1 ] && RESULT=1 + fi + + # 최근 라인 수 결정 (파일이 크면 더 많이) + TAIL_LINES=5000 + [ "$FILESIZE_MB" -gt 100 ] && TAIL_LINES=10000 + + RECENT=$(tail -${TAIL_LINES} "$LOGFILE" 2>/dev/null || echo "") + if [ -z "$RECENT" ]; then + echo " ${WARN} 내용을 읽을 수 없음" + continue + fi + + # 총 라인 수 + TOTAL_LINES=$(echo "$RECENT" | wc -l) + echo " 분석 라인 수: ${TOTAL_LINES} (최근 ${TAIL_LINES}줄)" + + # ── 에러 집계 ───────────────────────────────────────── + ERR_COUNT=$(echo "$RECENT" | grep -cE "$ERROR_PATTERN" || echo 0) + WARN_COUNT=$(echo "$RECENT" | grep -cE "$WARN_PATTERN" | grep -v -cE "$ERROR_PATTERN" || echo 0) + + if [ "${ERR_COUNT:-0}" -gt 50 ]; then + echo " ${CRIT} 에러 ${ERR_COUNT}건 감지 (임계: 50)" + RESULT=2 + elif [ "${ERR_COUNT:-0}" -gt 0 ]; then + echo " ${WARN} 에러 ${ERR_COUNT}건 감지" + [ $RESULT -lt 1 ] && RESULT=1 + else + echo " ${OK} 에러 없음" + fi + echo " 경고 수: ${WARN_COUNT}건" + + # ── 상위 에러 패턴 ──────────────────────────────────── + if [ "${ERR_COUNT:-0}" -gt 0 ]; then + echo + echo " [상위 에러 패턴 (Top ${TOP_N})]" + echo "$RECENT" | grep -E "$ERROR_PATTERN" | \ + sed 's/[0-9]\{4\}-[0-9]\{2\}-[0-9]\{2\}//g' | \ + sed 's/[0-9]\{2\}:[0-9]\{2\}:[0-9]\{2\}//g' | \ + sed 's/\[.*\]//g' | \ + sort | uniq -c | sort -rn | head "${TOP_N}" | \ + awk '{count=$1; $1=""; printf " %5d회 %s\n", count, substr($0,2,100)}' || true + + echo + echo " [최근 에러 5건]" + echo "$RECENT" | grep -E "$ERROR_PATTERN" | tail -5 | sed 's/^/ /' + fi + + # ── OOM 감지 ────────────────────────────────────────── + OOM=$(echo "$RECENT" | grep -iE "OutOfMemoryError|out of memory|java heap space|GC overhead" | tail -5 || echo "") + if [ -n "$OOM" ]; then + echo + echo " ${CRIT} OOM 이벤트 감지:" + echo "$OOM" | sed 's/^/ /' + RESULT=2 + fi + + # ── 예외 스택트레이스 감지 ──────────────────────────── + EXCEPTIONS=$(echo "$RECENT" | grep -cE "^(Caused by:|at [a-z].*\()" || echo 0) + echo " 스택트레이스 관련 라인: ${EXCEPTIONS}건" + + # ── HTTP 에러 코드 집계 (웹 로그 형식 감지) ─────────── + HTTP5XX=$(echo "$RECENT" | grep -cE '" 5[0-9]{2} ' || echo 0) + HTTP4XX=$(echo "$RECENT" | grep -cE '" 4[0-9]{2} ' || echo 0) + if [ "${HTTP5XX:-0}" -gt 0 ] || [ "${HTTP4XX:-0}" -gt 0 ]; then + echo + echo " [HTTP 에러 코드 집계]" + echo " 5xx 에러: ${HTTP5XX}건" + echo " 4xx 에러: ${HTTP4XX}건" + [ "${HTTP5XX:-0}" -gt 10 ] && echo " ${WARN} 5xx 에러 과다" && [ $RESULT -lt 1 ] && RESULT=1 + fi + + # ── 주요 키워드 타임라인 (마지막 발생 시각) ────────── + echo + echo " [주요 이벤트 마지막 발생 시각]" + for KW in "ERROR" "WARN" "OutOfMemory" "Connection refused" "timeout"; do + LAST=$(echo "$RECENT" | grep -i "$KW" | tail -1 | cut -c1-30 || echo "") + [ -n "$LAST" ] && printf " %-20s: %s\n" "$KW" "$LAST" + done + +done + +[ "$FILE_NUM" -eq 0 ] && echo " ${WARN} 읽을 수 있는 로그 파일 없음" + +# ── 시스템 로그 (dmesg) ─────────────────────────────────── +echo +echo "[$SEP] 시스템 커널 메시지 (최근 1시간)" +DMESG=$(dmesg --since="1 hour ago" 2>/dev/null | \ + grep -iE "error|oom|kill|fault|panic|blocked for" | tail -10 || \ + dmesg 2>/dev/null | tail -50 | grep -iE "error|oom|kill|fault" | tail -10 || echo "") +if [ -n "$DMESG" ]; then + echo " ${WARN} 커널 메시지:" + echo "$DMESG" | sed 's/^/ /' + [ $RESULT -lt 1 ] && RESULT=1 +else + echo " ${OK} 이상 없음" +fi + +# ── 로그 로테이션 상태 ─────────────────────────────────── +if [ "$ROTATE_CHECK" = "true" ]; then + echo + echo "[$SEP] 로그 로테이션 설정" + if [ -f /etc/logrotate.conf ]; then + echo " ${OK} logrotate 설정 존재" + CRON_ROTATE=$(crontab -l 2>/dev/null | grep -i logrotate || \ + ls /etc/cron.daily/logrotate 2>/dev/null && echo "cron.daily 설정" || echo "") + [ -n "$CRON_ROTATE" ] && echo " ${OK} logrotate 예약 실행: ${CRON_ROTATE}" || \ + echo " ${WARN} logrotate cron 미설정" + else + echo " ${WARN} logrotate 설정 없음" + [ $RESULT -lt 1 ] && RESULT=1 + fi +fi + +# ── 요약 ───────────────────────────────────────────────── +echo +echo "======================================================" +case $RESULT in + 0) echo " 최종 결과: ${OK} 로그 정상" ;; + 1) echo " 최종 결과: ${WARN} 주의 항목 있음" ;; + 2) echo " 최종 결과: ${CRIT} 심각한 오류 감지" ;; +esac +echo " 점검 완료: $(date '+%Y-%m-%d %H:%M:%S')" +echo "======================================================" +exit $RESULT diff --git a/scripts/sm/ping/ping_test.sh b/scripts/sm/ping/ping_test.sh new file mode 100644 index 0000000..b2e5b54 --- /dev/null +++ b/scripts/sm/ping/ping_test.sh @@ -0,0 +1,156 @@ +#!/bin/bash +# ============================================================ +# GUARDiA SM | ping_test.sh +# 대상: 네트워크 연결 테스트 (ICMP ping + TCP port check) +# 파라미터: TARGETS="host1:80 host2:443 host3:8080" +# (형식: hostname_or_ip[:port]) +# PING_COUNT=5 PING_TIMEOUT=2 +# PACKET_LOSS_WARN=10 LATENCY_WARN=100 +# TCP_TIMEOUT=5 +# ============================================================ +set -euo pipefail +TARGETS=${TARGETS:-"localhost:80"} +PING_COUNT=${PING_COUNT:-5} +PING_TIMEOUT=${PING_TIMEOUT:-2} +PACKET_LOSS_WARN=${PACKET_LOSS_WARN:-10} +LATENCY_WARN=${LATENCY_WARN:-100} +TCP_TIMEOUT=${TCP_TIMEOUT:-5} +OK="[OK]"; WARN="[WARN]"; CRIT="[CRIT]" +SEP="─────────────────────────────────────────" +RESULT=0 + +echo "======================================================" +echo " GUARDiA SM 점검 | 네트워크 연결 테스트 | $(hostname -s)" +echo " 점검 시각: $(date '+%Y-%m-%d %H:%M:%S %Z')" +echo "======================================================" + +# ── 1. ICMP Ping 테스트 ─────────────────────────────────── +echo; echo "[$SEP] 1. ICMP Ping 테스트" +printf " %-30s %-10s %-12s %-12s %s\n" "호스트" "손실률" "평균지연(ms)" "최대지연(ms)" "상태" +echo " $(printf '%.0s─' {1..70})" + +for TARGET in $TARGETS; do + HOST=$(echo "$TARGET" | cut -d: -f1) + PING_OUT=$(ping -c "$PING_COUNT" -W "$PING_TIMEOUT" "$HOST" 2>/dev/null || echo "FAILED") + if echo "$PING_OUT" | grep -q "FAILED\|unreachable\|unknown host"; then + printf " %-30s %-10s %-12s %-12s %s\n" "$HOST" "100%" "N/A" "N/A" "${CRIT}" + RESULT=2 + else + LOSS=$(echo "$PING_OUT" | grep "packet loss" | \ + grep -oP '\d+(?=% packet loss)' | head -1 || echo 100) + AVG=$(echo "$PING_OUT" | grep "rtt min\|round-trip" | \ + grep -oP '[\d.]+/[\d.]+/[\d.]+' | cut -d/ -f2 || echo "N/A") + MAX=$(echo "$PING_OUT" | grep "rtt min\|round-trip" | \ + grep -oP '[\d.]+/[\d.]+/[\d.]+' | cut -d/ -f3 || echo "N/A") + + if [ "${LOSS:-100}" -ge 50 ]; then + printf " %-30s %-10s %-12s %-12s %s\n" "$HOST" "${LOSS}%" "${AVG}ms" "${MAX}ms" "${CRIT}" + RESULT=2 + elif [ "${LOSS:-100}" -ge "$PACKET_LOSS_WARN" ]; then + printf " %-30s %-10s %-12s %-12s %s\n" "$HOST" "${LOSS}%" "${AVG}ms" "${MAX}ms" "${WARN}" + [ $RESULT -lt 1 ] && RESULT=1 + else + # 지연 체크 + if echo "$AVG" | grep -qE "^[0-9]" && \ + awk "BEGIN{exit !(\"${AVG:-0}\"+0 > $LATENCY_WARN)}" 2>/dev/null; then + printf " %-30s %-10s %-12s %-12s %s\n" "$HOST" "${LOSS}%" "${AVG}ms" "${MAX}ms" "${WARN} 지연" + [ $RESULT -lt 1 ] && RESULT=1 + else + printf " %-30s %-10s %-12s %-12s %s\n" "$HOST" "${LOSS}%" "${AVG}ms" "${MAX}ms" "${OK}" + fi + fi + fi +done + +# ── 2. TCP 포트 연결 테스트 ─────────────────────────────── +echo; echo "[$SEP] 2. TCP 포트 연결 테스트" +printf " %-30s %-10s %-15s %s\n" "호스트" "포트" "응답시간(ms)" "상태" +echo " $(printf '%.0s─' {1..65})" + +for TARGET in $TARGETS; do + HOST=$(echo "$TARGET" | cut -d: -f1) + PORT=$(echo "$TARGET" | cut -s -d: -f2) + [ -z "$PORT" ] && continue + + START=$(date +%s%N 2>/dev/null || echo 0) + if command -v nc &>/dev/null; then + NC_RESULT=$(nc -z -w "$TCP_TIMEOUT" "$HOST" "$PORT" 2>/dev/null && echo "OK" || echo "FAIL") + elif command -v bash &>/dev/null; then + NC_RESULT=$(timeout "$TCP_TIMEOUT" bash -c \ + "echo >/dev/tcp/${HOST}/${PORT}" 2>/dev/null && echo "OK" || echo "FAIL") + else + NC_RESULT="SKIP" + fi + END=$(date +%s%N 2>/dev/null || echo 0) + ELAPSED=$(( (END - START) / 1000000 )) + + if [ "$NC_RESULT" = "OK" ]; then + printf " %-30s %-10s %-15s %s\n" "$HOST" "$PORT" "${ELAPSED}ms" "${OK}" + elif [ "$NC_RESULT" = "SKIP" ]; then + printf " %-30s %-10s %-15s %s\n" "$HOST" "$PORT" "N/A" "${WARN} (nc/bash 없음)" + else + printf " %-30s %-10s %-15s %s\n" "$HOST" "$PORT" "${ELAPSED}ms" "${CRIT}" + RESULT=2 + fi +done + +# ── 3. DNS 해석 테스트 ──────────────────────────────────── +echo; echo "[$SEP] 3. DNS 해석 테스트" +DNS_SERVERS=$(cat /etc/resolv.conf 2>/dev/null | grep "^nameserver" | awk '{print $2}' | head -3) +if [ -n "$DNS_SERVERS" ]; then + echo " 설정된 DNS 서버:" + echo "$DNS_SERVERS" | sed 's/^/ - /' + for TARGET in $TARGETS; do + HOST=$(echo "$TARGET" | cut -d: -f1) + # IP가 아닌 호스트명만 테스트 + if ! echo "$HOST" | grep -qE "^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$"; then + RESOLVED=$(getent hosts "$HOST" 2>/dev/null | awk '{print $1}' | head -1 || echo "FAIL") + if [ "$RESOLVED" = "FAIL" ] || [ -z "$RESOLVED" ]; then + echo " ${WARN} DNS 해석 실패: ${HOST}" + [ $RESULT -lt 1 ] && RESULT=1 + else + echo " ${OK} ${HOST} → ${RESOLVED}" + fi + fi + done +else + echo " ${WARN} DNS 서버 설정 없음" + [ $RESULT -lt 1 ] && RESULT=1 +fi + +# ── 4. 라우팅 테이블 ───────────────────────────────────── +echo; echo "[$SEP] 4. 기본 게이트웨이" +GW=$(ip route show default 2>/dev/null | awk '/default/{print $3}' | head -1 || \ + route -n 2>/dev/null | awk '/^0.0.0.0/{print $2}' | head -1 || echo "N/A") +if [ "$GW" != "N/A" ] && [ -n "$GW" ]; then + echo " ${OK} 기본 게이트웨이: ${GW}" + GW_PING=$(ping -c 2 -W 2 "$GW" 2>/dev/null | grep "packet loss" | \ + grep -oP '\d+(?=% packet loss)' || echo 100) + [ "${GW_PING:-100}" -lt 50 ] && echo " ${OK} 게이트웨이 응답 정상" || \ + echo " ${WARN} 게이트웨이 응답 없음" +else + echo " ${WARN} 기본 게이트웨이를 찾을 수 없음" + [ $RESULT -lt 1 ] && RESULT=1 +fi + +# ── 5. 네트워크 인터페이스 상태 ─────────────────────────── +echo; echo "[$SEP] 5. 네트워크 인터페이스" +ip link show 2>/dev/null | awk '/^[0-9]+:/{ + name=$2; gsub(/:$/,"",name) + if(name != "lo") { + getline; state=($0 ~ /UP/) ? "UP" : "DOWN" + printf " %-15s %s\n", name, state + } +}' | head -10 + +# ── 요약 ───────────────────────────────────────────────── +echo +echo "======================================================" +case $RESULT in + 0) echo " 최종 결과: ${OK} 네트워크 연결 정상" ;; + 1) echo " 최종 결과: ${WARN} 주의 항목 있음" ;; + 2) echo " 최종 결과: ${CRIT} 연결 실패 항목 있음" ;; +esac +echo " 점검 완료: $(date '+%Y-%m-%d %H:%M:%S')" +echo "======================================================" +exit $RESULT diff --git a/scripts/sm/search/search_elasticsearch_sm.sh b/scripts/sm/search/search_elasticsearch_sm.sh new file mode 100644 index 0000000..5d2552f --- /dev/null +++ b/scripts/sm/search/search_elasticsearch_sm.sh @@ -0,0 +1,151 @@ +#!/bin/bash +# ============================================================ +# GUARDiA SM | search_elasticsearch_sm.sh +# 대상: Elasticsearch (7.x / 8.x) +# 파라미터: ES_HOST=localhost ES_PORT=9200 ES_SCHEME=http +# ES_USER=elastic ES_PASS=elastic +# INDEX_HEALTH_WARN=yellow +# ============================================================ +set -euo pipefail +ES_HOST=${ES_HOST:-localhost} +ES_PORT=${ES_PORT:-9200} +ES_SCHEME=${ES_SCHEME:-http} +ES_USER=${ES_USER:-elastic} +ES_PASS=${ES_PASS:-""} +ES_BASE="${ES_SCHEME}://${ES_HOST}:${ES_PORT}" +INDEX_HEALTH_WARN=${INDEX_HEALTH_WARN:-yellow} +OK="[OK]"; WARN="[WARN]"; CRIT="[CRIT]" +SEP="─────────────────────────────────────────" +RESULT=0 + +# curl 헬퍼 +_es() { + local URL="$1" + local AUTH="" + [ -n "$ES_PASS" ] && AUTH="-u ${ES_USER}:${ES_PASS}" + curl -sk --max-time 10 $AUTH "${ES_BASE}${URL}" 2>/dev/null +} + +echo "======================================================" +echo " GUARDiA SM 점검 | Elasticsearch | $(hostname -s)" +echo " 점검 시각: $(date '+%Y-%m-%d %H:%M:%S %Z')" +echo "======================================================" + +# ── 1. 프로세스 ─────────────────────────────────────────── +echo; echo "[$SEP] 1. Elasticsearch 프로세스" +ES_PROC=$(pgrep -c "elasticsearch" 2>/dev/null || echo 0) +if [ "$ES_PROC" -gt 0 ]; then + echo " ${OK} Elasticsearch 실행 중 (${ES_PROC}개)" + ES_PID=$(pgrep -f "elasticsearch" | head -1) + RSS_MB=$(awk '/VmRSS/{print $2}' /proc/${ES_PID}/status 2>/dev/null | \ + awk '{printf "%d", $1/1024}' || echo "N/A") + echo " RSS 메모리: ${RSS_MB} MB" +else + echo " ${CRIT} Elasticsearch 프로세스 없음" + RESULT=2 +fi + +# ── 2. 포트 리스닝 ──────────────────────────────────────── +echo; echo "[$SEP] 2. 포트 리스닝" +for PORT in $ES_PORT 9300; do + ss -tlnp 2>/dev/null | grep -q ":${PORT} " && \ + echo " ${OK} 포트 ${PORT} LISTEN" || \ + echo " ${WARN} 포트 ${PORT} LISTEN 없음" +done + +# ── 3. 클러스터 상태 ────────────────────────────────────── +echo; echo "[$SEP] 3. 클러스터 상태" +if command -v curl &>/dev/null; then + HEALTH=$(_es "/_cluster/health?pretty") + if [ -n "$HEALTH" ]; then + STATUS=$(echo "$HEALTH" | python3 -c \ + "import sys,json; d=json.load(sys.stdin); print(d.get('status','unknown'))" 2>/dev/null) + NODES=$(echo "$HEALTH" | python3 -c \ + "import sys,json; d=json.load(sys.stdin); print(d.get('number_of_nodes',0))" 2>/dev/null) + DATA_NODES=$(echo "$HEALTH" | python3 -c \ + "import sys,json; d=json.load(sys.stdin); print(d.get('number_of_data_nodes',0))" 2>/dev/null) + SHARDS=$(echo "$HEALTH" | python3 -c \ + "import sys,json; d=json.load(sys.stdin); print(d.get('active_shards',0))" 2>/dev/null) + UNASSIGNED=$(echo "$HEALTH" | python3 -c \ + "import sys,json; d=json.load(sys.stdin); print(d.get('unassigned_shards',0))" 2>/dev/null) + + case "${STATUS,,}" in + green) + echo " ${OK} 클러스터 상태: GREEN" + ;; + yellow) + echo " ${WARN} 클러스터 상태: YELLOW (미배정 샤드 있음)" + [ $RESULT -lt 1 ] && RESULT=1 + ;; + red) + echo " ${CRIT} 클러스터 상태: RED (샤드 손실)" + RESULT=2 + ;; + esac + echo " 노드 수: ${NODES} (데이터: ${DATA_NODES})" + echo " 활성 샤드: ${SHARDS}, 미배정 샤드: ${UNASSIGNED}" + [ "${UNASSIGNED:-0}" -gt 0 ] && \ + echo " ${WARN} 미배정 샤드 ${UNASSIGNED}개" && \ + [ $RESULT -lt 1 ] && RESULT=1 + else + echo " ${CRIT} Elasticsearch 응답 없음 (${ES_BASE})" + RESULT=2 + fi +fi + +# ── 4. 노드 정보 ────────────────────────────────────────── +echo; echo "[$SEP] 4. 노드 정보" +if command -v curl &>/dev/null; then + NODES_INFO=$(_es "/_cat/nodes?v&h=name,ip,role,heapPercent,ramPercent,cpu,load_1m,node.role") + if [ -n "$NODES_INFO" ]; then + echo "$NODES_INFO" | head -10 | sed 's/^/ /' + # 힙 90% 초과 체크 + HEAP_CRIT=$(echo "$NODES_INFO" | awk 'NR>1{if($4+0 >= 90) print $1": "$4"%"}' | head -5) + [ -n "$HEAP_CRIT" ] && echo " ${WARN} 힙 90% 초과 노드:" && echo "$HEAP_CRIT" | sed 's/^/ /' && \ + [ $RESULT -lt 1 ] && RESULT=1 + fi +fi + +# ── 5. 인덱스 상태 (상위 10) ───────────────────────────── +echo; echo "[$SEP] 5. 인덱스 상태 (상위 10)" +if command -v curl &>/dev/null; then + INDICES=$(_es "/_cat/indices?v&s=store.size:desc&h=health,status,index,docs.count,store.size" \ + 2>/dev/null | head -12) + echo "$INDICES" | sed 's/^/ /' + RED_INDICES=$(echo "$INDICES" | grep "^red" | wc -l || echo 0) + [ "$RED_INDICES" -gt 0 ] && echo " ${CRIT} RED 인덱스: ${RED_INDICES}개" && RESULT=2 +fi + +# ── 6. 응답 시간 ───────────────────────────────────────── +echo; echo "[$SEP] 6. 쿼리 응답 시간" +if command -v curl &>/dev/null; then + RESP_TIME=$(curl -sk -o /dev/null -w "%{time_total}" \ + --max-time 5 "${ES_BASE}/_search" 2>/dev/null || echo "N/A") + echo " 기본 검색 응답: ${RESP_TIME}s" + if awk "BEGIN{exit !(\"$RESP_TIME\"+0 > 3.0)}" 2>/dev/null; then + echo " ${WARN} 응답 시간 과다 (${RESP_TIME}s > 3s)" + [ $RESULT -lt 1 ] && RESULT=1 + fi +fi + +# ── 7. 디스크 사용 (Data path) ──────────────────────────── +echo; echo "[$SEP] 7. 디스크 사용" +if command -v curl &>/dev/null; then + DISK=$(_es "/_cat/allocation?v&h=node,disk.used,disk.avail,disk.percent") + echo "$DISK" | sed 's/^/ /' + HIGH=$(echo "$DISK" | awk 'NR>1{if($4+0 >= 85) print $1": "$4"%"}') + [ -n "$HIGH" ] && echo " ${WARN} 디스크 사용률 85% 초과:" && echo "$HIGH" | sed 's/^/ /' && \ + [ $RESULT -lt 1 ] && RESULT=1 +fi + +# ── 요약 ───────────────────────────────────────────────── +echo +echo "======================================================" +case $RESULT in + 0) echo " 최종 결과: ${OK} Elasticsearch 정상" ;; + 1) echo " 최종 결과: ${WARN} 주의 항목 있음" ;; + 2) echo " 최종 결과: ${CRIT} 즉시 조치 필요" ;; +esac +echo " 점검 완료: $(date '+%Y-%m-%d %H:%M:%S')" +echo "======================================================" +exit $RESULT diff --git a/scripts/sm/search/search_solr_sm.sh b/scripts/sm/search/search_solr_sm.sh new file mode 100644 index 0000000..0b38e1e --- /dev/null +++ b/scripts/sm/search/search_solr_sm.sh @@ -0,0 +1,151 @@ +#!/bin/bash +# ============================================================ +# GUARDiA SM | search_solr_sm.sh +# 대상: Apache Solr (7.x / 8.x / 9.x) +# 파라미터: SOLR_HOME=/opt/solr SOLR_HOST=localhost SOLR_PORT=8983 +# SOLR_USER=solr SOLR_PASS=SolrRocks +# QUERY_TIME_WARN=1000 +# ============================================================ +set -euo pipefail +SOLR_HOME=${SOLR_HOME:-/opt/solr} +SOLR_HOST=${SOLR_HOST:-localhost} +SOLR_PORT=${SOLR_PORT:-8983} +SOLR_USER=${SOLR_USER:-""} +SOLR_PASS=${SOLR_PASS:-""} +QUERY_TIME_WARN=${QUERY_TIME_WARN:-1000} +SOLR_BASE="http://${SOLR_HOST}:${SOLR_PORT}" +OK="[OK]"; WARN="[WARN]"; CRIT="[CRIT]" +SEP="─────────────────────────────────────────" +RESULT=0 + +_solr() { + local URL="$1" + local AUTH="" + [ -n "$SOLR_USER" ] && AUTH="-u ${SOLR_USER}:${SOLR_PASS}" + curl -sk --max-time 10 $AUTH "${SOLR_BASE}${URL}" 2>/dev/null +} + +echo "======================================================" +echo " GUARDiA SM 점검 | Apache Solr | $(hostname -s)" +echo " 점검 시각: $(date '+%Y-%m-%d %H:%M:%S %Z')" +echo "======================================================" + +# ── 1. 프로세스 ─────────────────────────────────────────── +echo; echo "[$SEP] 1. Solr 프로세스" +SOLR_PROC=$(pgrep -f "solr\|start.jar" 2>/dev/null | wc -l || echo 0) +if [ "$SOLR_PROC" -gt 0 ]; then + SOLR_PID=$(pgrep -f "solr" | head -1) + echo " ${OK} Solr 실행 중 (PID: ${SOLR_PID})" + RSS_MB=$(awk '/VmRSS/{print $2}' /proc/${SOLR_PID}/status 2>/dev/null | \ + awk '{printf "%d", $1/1024}' || echo "N/A") + echo " RSS 메모리: ${RSS_MB} MB" +else + echo " ${CRIT} Solr 프로세스 없음" + RESULT=2 +fi + +# ── 2. 포트 리스닝 ──────────────────────────────────────── +echo; echo "[$SEP] 2. 포트 리스닝" +ss -tlnp 2>/dev/null | grep -q ":${SOLR_PORT} " && \ + echo " ${OK} 포트 ${SOLR_PORT} LISTEN" || \ + { echo " ${CRIT} 포트 ${SOLR_PORT} LISTEN 없음"; RESULT=2; } + +# ── 3. 시스템 정보 ──────────────────────────────────────── +echo; echo "[$SEP] 3. Solr 시스템 정보" +if command -v curl &>/dev/null; then + SYS=$(_solr "/solr/admin/info/system?wt=json") + if [ -n "$SYS" ]; then + SOLR_VER=$(echo "$SYS" | python3 -c \ + "import sys,json; d=json.load(sys.stdin); print(d.get('lucene',{}).get('solr-spec-version','?'))" 2>/dev/null) + UPTIME=$(echo "$SYS" | python3 -c \ + "import sys,json; d=json.load(sys.stdin); ms=d.get('jvm',{}).get('jmx',{}).get('upTimeMS',0); \ + h=ms//3600000; m=(ms%3600000)//60000; print(f'{h}h {m}m')" 2>/dev/null || echo "N/A") + echo " ${OK} Solr 버전: ${SOLR_VER}, 가동 시간: ${UPTIME}" + # JVM 힙 + HEAP=$(echo "$SYS" | python3 -c \ + "import sys,json; d=json.load(sys.stdin); jvm=d.get('jvm',{}).get('memory',{}).get('raw',{}); \ + used=jvm.get('used',0)//1048576; max_=jvm.get('max',1)//1048576; \ + pct=used*100//max_ if max_>0 else 0; print(f'used={used}MB max={max_}MB ({pct}%)')" 2>/dev/null) + echo " JVM 힙: ${HEAP}" + if echo "$HEAP" | grep -oP '\d+(?=%)' | awk '{exit !($1>=85)}'; then + echo " ${WARN} JVM 힙 사용률 85% 초과" + [ $RESULT -lt 1 ] && RESULT=1 + fi + else + echo " ${CRIT} Solr 응답 없음" + RESULT=2 + fi +fi + +# ── 4. 코어/컬렉션 상태 ───────────────────────────────── +echo; echo "[$SEP] 4. 코어/컬렉션 상태" +if command -v curl &>/dev/null; then + CORES=$(_solr "/solr/admin/cores?wt=json&indexInfo=false") + if [ -n "$CORES" ]; then + CORE_LIST=$(echo "$CORES" | python3 -c " +import sys, json +d = json.load(sys.stdin) +status = d.get('status', {}) +for name, info in status.items(): + sz = info.get('index', {}).get('sizeInBytes', 0) // 1048576 + docs = info.get('index', {}).get('numDocs', 0) + print(f' {name:20s} docs={docs:>10,d} size={sz}MB') +" 2>/dev/null || echo " 파싱 실패") + echo "$CORE_LIST" + CORE_COUNT=$(echo "$CORES" | python3 -c \ + "import sys,json; d=json.load(sys.stdin); print(len(d.get('status',{})))" 2>/dev/null) + echo " 총 코어 수: ${CORE_COUNT}" + fi +fi + +# ── 5. 쿼리 성능 통계 ──────────────────────────────────── +echo; echo "[$SEP] 5. 쿼리 성능 통계" +if command -v curl &>/dev/null; then + # 첫 번째 코어의 성능 지표 + FIRST_CORE=$(echo "$CORES" 2>/dev/null | python3 -c \ + "import sys,json; d=json.load(sys.stdin); print(list(d.get('status',{}).keys())[0])" 2>/dev/null || echo "") + if [ -n "$FIRST_CORE" ]; then + METRICS=$(_solr "/solr/${FIRST_CORE}/admin/mbeans?stats=true&cat=QUERYHANDLER&wt=json") + AVG_TIME=$(echo "$METRICS" | python3 -c " +import sys, json +d = json.load(sys.stdin) +beans = d.get('solr-mbeans', []) +for i in range(0, len(beans), 2): + if beans[i] == 'QUERYHANDLER': + handlers = beans[i+1] if i+1 < len(beans) else {} + for name, stats in handlers.items(): + if '/select' in name: + qs = stats.get('stats', {}) + avg = qs.get('avgTimePerRequest', 0) + rpc = qs.get('requests', 0) + print(f' /select: requests={rpc} avg={avg:.1f}ms') +" 2>/dev/null || echo " 쿼리 통계 없음") + echo "$AVG_TIME" + fi +fi + +# ── 6. 로그 오류 ───────────────────────────────────────── +echo; echo "[$SEP] 6. 로그 오류" +for LOGDIR in "${SOLR_HOME}/server/logs" "${SOLR_HOME}/logs" "/var/log/solr"; do + if [ -d "$LOGDIR" ]; then + LOGFILE=$(ls -t "${LOGDIR}"/solr.log 2>/dev/null | head -1 || echo "") + if [ -n "$LOGFILE" ] && [ -r "$LOGFILE" ]; then + ERR=$(tail -1000 "$LOGFILE" | grep -cE "ERROR|WARN|Exception" || echo 0) + echo " 최근 오류/경고: ${ERR}건 (${LOGFILE})" + tail -1000 "$LOGFILE" | grep -E "ERROR|WARN" | tail -5 | sed 's/^/ /' || true + fi + break + fi +done + +# ── 요약 ───────────────────────────────────────────────── +echo +echo "======================================================" +case $RESULT in + 0) echo " 최종 결과: ${OK} Solr 정상" ;; + 1) echo " 최종 결과: ${WARN} 주의 항목 있음" ;; + 2) echo " 최종 결과: ${CRIT} 즉시 조치 필요" ;; +esac +echo " 점검 완료: $(date '+%Y-%m-%d %H:%M:%S')" +echo "======================================================" +exit $RESULT diff --git a/scripts/sm/ssl/ssl_auto_renew.sh b/scripts/sm/ssl/ssl_auto_renew.sh new file mode 100644 index 0000000..5b15859 --- /dev/null +++ b/scripts/sm/ssl/ssl_auto_renew.sh @@ -0,0 +1,202 @@ +#!/bin/bash +# ============================================================================== +# GUARDiA ITSM — SSL 인증서 자동 갱신 스크립트 (certbot) +# 경로: /opt/guardia/scripts/ssl/ssl_auto_renew.sh +# +# 기능: +# 1. certbot renew --dry-run 으로 사전 검증 +# 2. certbot renew 실행 (갱신 대상 인증서만) +# 3. nginx/apache 서비스 재시작 (갱신 성공 시) +# 4. GUARDiA ITSM API 콜백 — 갱신 결과 전달 (SR 자동 생성) +# +# 환경변수 (실행 전 .env 또는 시스템에 설정): +# GUARDIA_ITSM_URL — ITSM 서버 URL (기본: http://localhost:8000) +# GUARDIA_ITSM_TOKEN — ITSM API Bearer 토큰 +# SSL_WEB_SERVER — nginx 또는 apache2 (기본: nginx) +# SSL_CERT_EMAIL — certbot 알림 이메일 +# SSL_DOMAINS — 갱신할 도메인 (쉼표 구분, 미설정 시 certbot 전체 갱신) +# SSL_RENEW_DAYS — 만료 N일 이내 갱신 시도 (기본: 30) +# SSL_DRY_RUN_ONLY — true 이면 dry-run 만 수행 (테스트용, 기본: false) +# SSL_RELOAD_CMD — 서비스 재시작 명령어 오버라이드 +# +# 반환 코드: +# 0 — 성공 (갱신 없음 포함) +# 1 — 갱신 실패 +# 2 — 사전 점검 실패 (dry-run 오류) +# 3 — 설치 환경 오류 (certbot 미설치 등) +# ============================================================================== + +set -euo pipefail +IFS=$'\n\t' + +# ── 설정 ────────────────────────────────────────────────────────────────────── +ITSM_URL="${GUARDIA_ITSM_URL:-http://localhost:8000}" +ITSM_TOKEN="${GUARDIA_ITSM_TOKEN:-}" +WEB_SERVER="${SSL_WEB_SERVER:-nginx}" +CERT_EMAIL="${SSL_CERT_EMAIL:-}" +DOMAINS="${SSL_DOMAINS:-}" +RENEW_DAYS="${SSL_RENEW_DAYS:-30}" +DRY_RUN_ONLY="${SSL_DRY_RUN_ONLY:-false}" +LOG_DIR="/var/log/guardia/ssl" +LOG_FILE="${LOG_DIR}/renew_$(date +%Y%m%d_%H%M%S).log" +SCRIPT_NAME="$(basename "$0")" + +# certbot 재시작 후크 우선순위: 환경변수 > 자동 감지 +if [ -n "${SSL_RELOAD_CMD:-}" ]; then + RELOAD_CMD="${SSL_RELOAD_CMD}" +elif command -v systemctl &>/dev/null; then + RELOAD_CMD="systemctl reload ${WEB_SERVER}" +else + RELOAD_CMD="service ${WEB_SERVER} reload" +fi + +# ── 로그 함수 ───────────────────────────────────────────────────────────────── +mkdir -p "${LOG_DIR}" +exec > >(tee -a "${LOG_FILE}") 2>&1 + +log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [INFO] $*"; } +warn() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [WARN] $*"; } +err() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] $*" >&2; } + +log "=== ${SCRIPT_NAME} 시작 ===" +log "ITSM URL : ${ITSM_URL}" +log "WEB SERVER : ${WEB_SERVER}" +log "RENEW DAYS : ${RENEW_DAYS}" +log "DRY RUN ONLY : ${DRY_RUN_ONLY}" +log "LOG FILE : ${LOG_FILE}" + +# ── certbot 설치 확인 ───────────────────────────────────────────────────────── +if ! command -v certbot &>/dev/null; then + err "certbot 명령어를 찾을 수 없습니다." + err "설치: apt install certbot 또는 yum install certbot" + _itsm_callback "ERROR" "certbot 미설치" "" 3 + exit 3 +fi + +CERTBOT_VERSION="$(certbot --version 2>&1 | head -1)" +log "certbot 버전: ${CERTBOT_VERSION}" + +# ── ITSM API 콜백 함수 ──────────────────────────────────────────────────────── +# 보안: ITSM 서버 주소만 포함 — IP·계정·비밀번호 전송 금지 +_itsm_callback() { + local status="$1" # SUCCESS | FAILURE | DRY_RUN_OK | DRY_RUN_FAIL + local message="$2" + local renewed_domains="${3:-}" + local exit_code="${4:-0}" + + if [ -z "${ITSM_TOKEN}" ]; then + warn "GUARDIA_ITSM_TOKEN 미설정 — ITSM 콜백 건너뜀" + return 0 + fi + + local payload + payload="$(cat < /dev/null 2>&1; then + log "ITSM 콜백 전송 성공: status=${status}" + else + warn "ITSM 콜백 전송 실패 (서버 응답 없음 — 무시하고 계속 진행)" + fi +} + +# ── 사전 점검: dry-run ──────────────────────────────────────────────────────── +log "--- 사전 점검 (dry-run) 시작 ---" + +CERTBOT_ARGS=( + renew + --non-interactive + --agree-tos + "--deploy-hook" "${RELOAD_CMD}" + "--days" "${RENEW_DAYS}" +) + +if [ -n "${CERT_EMAIL}" ]; then + CERTBOT_ARGS+=("--email" "${CERT_EMAIL}") +fi + +if [ -n "${DOMAINS}" ]; then + # 도메인 개별 지정 시 각 도메인을 --domain 으로 전달 + IFS=',' read -ra DOMAIN_LIST <<< "${DOMAINS}" + for domain in "${DOMAIN_LIST[@]}"; do + domain="$(echo "${domain}" | tr -d ' ')" + [ -n "${domain}" ] && CERTBOT_ARGS+=("--domain" "${domain}") + done +fi + +if ! certbot "${CERTBOT_ARGS[@]}" --dry-run >> "${LOG_FILE}" 2>&1; then + err "dry-run 실패 — 실제 갱신을 중단합니다." + _itsm_callback "DRY_RUN_FAIL" "certbot dry-run 실패. 로그: ${LOG_FILE}" "" 2 + exit 2 +fi + +log "dry-run 성공." + +if [ "${DRY_RUN_ONLY}" = "true" ]; then + log "DRY_RUN_ONLY=true — 실제 갱신 건너뜀." + _itsm_callback "DRY_RUN_OK" "dry-run 전용 실행 완료." "" 0 + exit 0 +fi + +# ── 실제 갱신 ───────────────────────────────────────────────────────────────── +log "--- 실제 갱신 시작 ---" + +RENEW_OUTPUT="$(certbot "${CERTBOT_ARGS[@]}" 2>&1)" || { + err "certbot renew 실패." + err "${RENEW_OUTPUT}" + _itsm_callback "FAILURE" "certbot renew 실패. 상세: ${LOG_FILE}" "" 1 + exit 1 +} + +log "${RENEW_OUTPUT}" + +# 갱신된 인증서 도메인 추출 +RENEWED="" +if echo "${RENEW_OUTPUT}" | grep -q "Successfully renewed"; then + RENEWED="$(echo "${RENEW_OUTPUT}" \ + | grep -oP '(?<=Successfully renewed certificate for )[^\s]+' \ + | paste -sd ',' -)" + log "갱신 완료 도메인: ${RENEWED:-없음}" +else + log "갱신 대상 없음 (모든 인증서가 유효 기간 내)." +fi + +# ── 웹서버 재시작 (갱신 발생 시) ────────────────────────────────────────────── +if [ -n "${RENEWED}" ]; then + log "웹서버 재시작 중: ${RELOAD_CMD}" + if eval "${RELOAD_CMD}" >> "${LOG_FILE}" 2>&1; then + log "웹서버 재시작 성공." + else + warn "웹서버 재시작 실패 — 수동 확인 필요: ${RELOAD_CMD}" + fi +fi + +# ── ITSM 콜백 ───────────────────────────────────────────────────────────────── +if [ -n "${RENEWED}" ]; then + _itsm_callback "SUCCESS" "인증서 갱신 완료." "${RENEWED}" 0 +else + _itsm_callback "SUCCESS" "갱신 대상 없음 (모두 유효)." "" 0 +fi + +log "=== ${SCRIPT_NAME} 완료 ===" +exit 0 diff --git a/scripts/sm/ssl/ssl_expiry_check.sh b/scripts/sm/ssl/ssl_expiry_check.sh new file mode 100644 index 0000000..41c5812 --- /dev/null +++ b/scripts/sm/ssl/ssl_expiry_check.sh @@ -0,0 +1,94 @@ +#!/bin/bash +# ============================================================================== +# GUARDiA ITSM — SSL 인증서 만료일 점검 스크립트 +# 경로: /opt/guardia/scripts/ssl/ssl_expiry_check.sh +# +# 사용법: +# ./ssl_expiry_check.sh <호스트> [포트(기본 443)] [timeout(기본 5초)] +# +# 출력 (JSON): +# 성공: {"host":"...","port":443,"expiry":"...","days_left":N,"level":"OK|WARN|CRITICAL","issuer":"..."} +# 실패: {"host":"...","port":443,"status":"ERROR","message":"..."} +# +# 반환 코드: +# 0 — 점검 완료 (OK/WARN/CRITICAL 모두) +# 1 — 인증서 조회 실패 +# ============================================================================== + +HOST="${1}" +PORT="${2:-443}" +TIMEOUT="${3:-5}" + +if [ -z "${HOST}" ]; then + echo '{"status":"ERROR","message":"호스트 인수가 필요합니다. 사용법: ssl_expiry_check.sh [port]"}' + exit 1 +fi + +# openssl 설치 확인 +if ! command -v openssl &>/dev/null; then + echo '{"status":"ERROR","message":"openssl 명령어를 찾을 수 없습니다."}' + exit 1 +fi + +# ── 인증서 정보 조회 ────────────────────────────────────────────────────────── +CERT_INFO=$(echo | timeout "${TIMEOUT}" openssl s_client \ + -servername "${HOST}" \ + -connect "${HOST}:${PORT}" \ + 2>/dev/null) + +if [ -z "${CERT_INFO}" ]; then + echo "{\"host\":\"${HOST}\",\"port\":${PORT},\"status\":\"ERROR\",\"message\":\"연결 실패 또는 타임아웃 (${TIMEOUT}s)\"}" + exit 1 +fi + +# 만료일 추출 +EXPIRY=$(echo "${CERT_INFO}" | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2) +if [ -z "${EXPIRY}" ]; then + echo "{\"host\":\"${HOST}\",\"port\":${PORT},\"status\":\"ERROR\",\"message\":\"인증서 만료일을 파싱할 수 없습니다.\"}" + exit 1 +fi + +# 발급기관 추출 +ISSUER=$(echo "${CERT_INFO}" | openssl x509 -noout -issuer 2>/dev/null \ + | sed 's/^issuer=//' | tr -d '\n' | sed 's/"/\\"/g') + +# 주체(CN) 추출 +SUBJECT_CN=$(echo "${CERT_INFO}" | openssl x509 -noout -subject 2>/dev/null \ + | grep -oP '(?<=CN = )[^,]+' | head -1 | tr -d '\n' | sed 's/"/\\"/g') + +# ── 날짜 계산 ───────────────────────────────────────────────────────────────── +# Linux date 와 macOS date 모두 지원 +EXPIRY_TS=$(date -d "${EXPIRY}" +%s 2>/dev/null) +if [ -z "${EXPIRY_TS}" ]; then + # macOS/BSD 형식 시도 + EXPIRY_TS=$(date -jf "%b %d %T %Y %Z" "${EXPIRY}" +%s 2>/dev/null) +fi +if [ -z "${EXPIRY_TS}" ]; then + # 추가 형식 시도 (Jan 1 ...) + EXPIRY_TS=$(date -d "$(echo "${EXPIRY}" | tr -s ' ')" +%s 2>/dev/null) +fi + +if [ -z "${EXPIRY_TS}" ]; then + echo "{\"host\":\"${HOST}\",\"port\":${PORT},\"expiry\":\"${EXPIRY}\",\"status\":\"ERROR\",\"message\":\"날짜 파싱 실패\"}" + exit 1 +fi + +NOW_TS=$(date +%s) +DAYS_LEFT=$(( (EXPIRY_TS - NOW_TS) / 86400 )) + +# ── 알림 등급 ───────────────────────────────────────────────────────────────── +LEVEL="OK" +if [ "${DAYS_LEFT}" -le 0 ]; then + LEVEL="EXPIRED" +elif [ "${DAYS_LEFT}" -le 7 ]; then + LEVEL="CRITICAL" +elif [ "${DAYS_LEFT}" -le 30 ]; then + LEVEL="WARN" +fi + +# ── JSON 출력 ───────────────────────────────────────────────────────────────── +cat </dev/null | head -1 || echo "") +if [ -n "$JBOSS_PID" ]; then + echo " ${OK} JBoss/WildFly 실행 중 (PID: ${JBOSS_PID})" + START=$(ps -p "$JBOSS_PID" -o lstart= 2>/dev/null || echo "N/A") + echo " 시작 시각: ${START}" +else + echo " ${CRIT} JBoss/WildFly 프로세스 없음" + RESULT=2 +fi + +# ── 2. 포트 ─────────────────────────────────────────────── +echo; echo "[$SEP] 2. 포트 리스닝" +for PORT in $APP_PORT $MGMT_PORT; do + if ss -tlnp 2>/dev/null | grep -q ":${PORT} "; then + echo " ${OK} 포트 ${PORT} LISTEN" + else + echo " ${WARN} 포트 ${PORT} LISTEN 없음" + [ $RESULT -lt 1 ] && RESULT=1 + fi +done + +# ── 3. Management API 서버 상태 ─────────────────────────── +echo; echo "[$SEP] 3. Management API 서버 상태" +if command -v curl &>/dev/null; then + MGMT_RESP=$(curl -sk --max-time 5 -u "${MGMT_USER}:${MGMT_PASS}" \ + "http://localhost:${MGMT_PORT}/management?operation=attribute&name=server-state" \ + 2>/dev/null || echo "") + if echo "$MGMT_RESP" | grep -qi '"running"'; then + echo " ${OK} 서버 상태: running" + elif [ -n "$MGMT_RESP" ]; then + echo " ${WARN} 서버 상태: ${MGMT_RESP}" + [ $RESULT -lt 1 ] && RESULT=1 + else + echo " ${WARN} Management API 응답 없음" + [ $RESULT -lt 1 ] && RESULT=1 + fi +fi + +# ── 4. 헬스체크 ─────────────────────────────────────────── +echo; echo "[$SEP] 4. 애플리케이션 헬스체크" +if command -v curl &>/dev/null; then + HTTP_CODE=$(curl -sk -o /dev/null -w "%{http_code}" \ + --max-time 10 "${HEALTH_URL}" 2>/dev/null || echo "ERR") + RESP_TIME=$(curl -sk -o /dev/null -w "%{time_total}" \ + --max-time 10 "${HEALTH_URL}" 2>/dev/null || echo "N/A") + if echo "$HTTP_CODE" | grep -qE "^[23]"; then + echo " ${OK} HTTP ${HTTP_CODE} — ${RESP_TIME}s" + elif [ "$HTTP_CODE" = "ERR" ]; then + echo " ${CRIT} 헬스체크 실패" + RESULT=2 + else + echo " ${WARN} HTTP ${HTTP_CODE}" + [ $RESULT -lt 1 ] && RESULT=1 + fi +fi + +# ── 5. 데이터소스 상태 ──────────────────────────────────── +echo; echo "[$SEP] 5. 데이터소스 (Management API)" +if command -v curl &>/dev/null; then + DS_RESP=$(curl -sk --max-time 5 -u "${MGMT_USER}:${MGMT_PASS}" \ + "http://localhost:${MGMT_PORT}/management/subsystem/datasources/data-source?recursive=true" \ + 2>/dev/null | python3 -m json.tool 2>/dev/null | grep '"connection-url"' | head -5 || echo "") + if [ -n "$DS_RESP" ]; then + echo " 데이터소스 연결 URL:" + echo "$DS_RESP" | sed 's/^/ /' + else + echo " ${WARN} 데이터소스 조회 불가" + fi +fi + +# ── 6. JVM 메모리 ───────────────────────────────────────── +echo; echo "[$SEP] 6. JVM 메모리" +if [ -n "$JBOSS_PID" ]; then + RSS_MB=$(awk '/VmRSS/{print $2}' /proc/${JBOSS_PID}/status 2>/dev/null | \ + awk '{printf "%d", $1/1024}' || echo "N/A") + echo " RSS 메모리: ${RSS_MB} MB" + THREAD_COUNT=$(ls /proc/${JBOSS_PID}/task 2>/dev/null | wc -l || echo 0) + echo " 스레드 수: ${THREAD_COUNT}" +fi + +# ── 7. 배포 앱 목록 ────────────────────────────────────── +echo; echo "[$SEP] 7. 배포 앱" +DEPLOY_DIR="${JBOSS_HOME}/standalone/deployments" +if [ -d "$DEPLOY_DIR" ]; then + echo " 배포 목록 (${DEPLOY_DIR}):" + ls "$DEPLOY_DIR" 2>/dev/null | grep -v "\.failed\|\.undeployed" | sed 's/^/ - /' + FAILED=$(ls "$DEPLOY_DIR"/*.failed 2>/dev/null | wc -l || echo 0) + [ "$FAILED" -gt 0 ] && echo " ${CRIT} 배포 실패 항목: ${FAILED}개" && RESULT=2 +else + echo " ${WARN} deployments 디렉터리 없음" +fi + +# ── 8. 서버 로그 에러 ───────────────────────────────────── +echo; echo "[$SEP] 8. 서버 로그 오류" +SERVER_LOG="${JBOSS_HOME}/standalone/log/server.log" +if [ -r "$SERVER_LOG" ]; then + ERR_COUNT=$(tail -2000 "$SERVER_LOG" | grep -c "ERROR\|FATAL" || echo 0) + if [ "$ERR_COUNT" -gt 0 ]; then + echo " ${WARN} 최근 오류 ${ERR_COUNT}건:" + tail -2000 "$SERVER_LOG" | grep -E "ERROR|FATAL" | tail -5 | sed 's/^/ /' + [ $RESULT -lt 1 ] && RESULT=1 + else + echo " ${OK} 최근 오류 없음" + fi +else + echo " ${WARN} 서버 로그 없음: ${SERVER_LOG}" +fi + +# ── 요약 ───────────────────────────────────────────────── +echo +echo "======================================================" +case $RESULT in + 0) echo " 최종 결과: ${OK} JBoss/WildFly 정상" ;; + 1) echo " 최종 결과: ${WARN} 주의 항목 있음" ;; + 2) echo " 최종 결과: ${CRIT} 즉시 조치 필요" ;; +esac +echo " 점검 완료: $(date '+%Y-%m-%d %H:%M:%S')" +echo "======================================================" +exit $RESULT diff --git a/scripts/sm/was/was_jeus_sm.sh b/scripts/sm/was/was_jeus_sm.sh new file mode 100644 index 0000000..67e602c --- /dev/null +++ b/scripts/sm/was/was_jeus_sm.sh @@ -0,0 +1,119 @@ +#!/bin/bash +# ============================================================ +# GUARDiA SM | was_jeus_sm.sh +# 대상: JEUS (TmaxSoft) WAS +# 파라미터: JEUS_HOME=/opt/jeus JEUS_DOMAIN=domain1 +# JEUS_PORT=9736 APP_PORT=8080 JEUS_ADMIN=administrator +# JEUS_PASS=jeusadmin HEALTH_URL=http://localhost:8080/health +# ============================================================ +set -euo pipefail +JEUS_HOME=${JEUS_HOME:-/opt/jeus} +JEUS_DOMAIN=${JEUS_DOMAIN:-domain1} +JEUS_PORT=${JEUS_PORT:-9736} +APP_PORT=${APP_PORT:-8080} +JEUS_ADMIN=${JEUS_ADMIN:-administrator} +JEUS_PASS=${JEUS_PASS:-jeusadmin} +HEALTH_URL=${HEALTH_URL:-"http://localhost:${APP_PORT}/health"} +OK="[OK]"; WARN="[WARN]"; CRIT="[CRIT]" +SEP="─────────────────────────────────────────" +RESULT=0 + +echo "======================================================" +echo " GUARDiA SM 점검 | JEUS | $(hostname -s)" +echo " 점검 시각: $(date '+%Y-%m-%d %H:%M:%S %Z')" +echo "======================================================" + +# ── 1. 프로세스 ─────────────────────────────────────────── +echo; echo "[$SEP] 1. JEUS 프로세스" +JEUS_PID=$(pgrep -f "jeus\|DomainAdminServer" 2>/dev/null | head -1 || echo "") +JEUS_PROC_COUNT=$(pgrep -c "jeus" 2>/dev/null || echo 0) +if [ "$JEUS_PROC_COUNT" -gt 0 ]; then + echo " ${OK} JEUS 프로세스 실행 중 (${JEUS_PROC_COUNT}개)" + ps aux | grep jeus | grep -v grep | awk \ + '{printf " PID:%-8s CPU:%-6s MEM:%-6s\n",$2,$3,$4}' | head -5 +else + echo " ${CRIT} JEUS 프로세스 없음" + RESULT=2 +fi + +# ── 2. 포트 리스닝 ──────────────────────────────────────── +echo; echo "[$SEP] 2. 포트 리스닝" +for PORT in $JEUS_PORT $APP_PORT; do + if ss -tlnp 2>/dev/null | grep -q ":${PORT} "; then + echo " ${OK} 포트 ${PORT} LISTEN" + else + echo " ${WARN} 포트 ${PORT} LISTEN 없음" + [ $RESULT -lt 1 ] && RESULT=1 + fi +done + +# ── 3. jeusadmin 상태 조회 ──────────────────────────────── +echo; echo "[$SEP] 3. jeusadmin 서버 상태" +JEUSADMIN="${JEUS_HOME}/bin/jeusadmin" +if [ -x "$JEUSADMIN" ]; then + # Non-interactive 상태 조회 + JSTAT=$(echo -e "si\nls\nq" | \ + "$JEUSADMIN" -u "${JEUS_ADMIN}" -p "${JEUS_PASS}" \ + -host localhost -port "${JEUS_PORT}" 2>/dev/null | \ + grep -v "^$\|jeusadmin>" | head -30 || echo "조회 실패") + echo "$JSTAT" | sed 's/^/ /' +else + echo " ${WARN} jeusadmin 없음: ${JEUSADMIN}" + [ $RESULT -lt 1 ] && RESULT=1 +fi + +# ── 4. 헬스체크 ─────────────────────────────────────────── +echo; echo "[$SEP] 4. 애플리케이션 헬스체크" +if command -v curl &>/dev/null; then + HTTP_CODE=$(curl -sk -o /dev/null -w "%{http_code}" \ + --max-time 10 "${HEALTH_URL}" 2>/dev/null || echo "ERR") + RESP_TIME=$(curl -sk -o /dev/null -w "%{time_total}" \ + --max-time 10 "${HEALTH_URL}" 2>/dev/null || echo "N/A") + if echo "$HTTP_CODE" | grep -qE "^[23]"; then + echo " ${OK} HTTP ${HTTP_CODE} — ${RESP_TIME}s" + elif [ "$HTTP_CODE" = "ERR" ]; then + echo " ${CRIT} 헬스체크 실패" + RESULT=2 + else + echo " ${WARN} HTTP ${HTTP_CODE}" + [ $RESULT -lt 1 ] && RESULT=1 + fi +fi + +# ── 5. JVM 메모리 ───────────────────────────────────────── +echo; echo "[$SEP] 5. JVM 메모리" +if [ -n "$JEUS_PID" ]; then + RSS_MB=$(awk '/VmRSS/{print $2}' /proc/${JEUS_PID}/status 2>/dev/null | \ + awk '{printf "%d", $1/1024}' || echo "N/A") + THREAD=$(ls /proc/${JEUS_PID}/task 2>/dev/null | wc -l || echo 0) + echo " RSS 메모리: ${RSS_MB} MB" + echo " 스레드 수: ${THREAD}" +fi + +# ── 6. 로그 에러 ───────────────────────────────────────── +echo; echo "[$SEP] 6. JEUS 로그 오류" +for LOGDIR in "${JEUS_HOME}/domains/${JEUS_DOMAIN}/servers/server1/logs" \ + "${JEUS_HOME}/logs" "${JEUS_HOME}/domain/logs"; do + if [ -d "$LOGDIR" ]; then + LOGFILE=$(ls -t "${LOGDIR}"/*.log 2>/dev/null | head -1 || echo "") + if [ -n "$LOGFILE" ] && [ -r "$LOGFILE" ]; then + ERR_CNT=$(tail -2000 "$LOGFILE" | grep -c "ERROR\|FATAL\|Exception" || echo 0) + echo " 최근 오류 수: ${ERR_CNT} (${LOGFILE})" + [ "$ERR_CNT" -gt 0 ] && tail -2000 "$LOGFILE" | grep -E "ERROR|FATAL|Exception" | \ + tail -5 | sed 's/^/ /' + fi + break + fi +done + +# ── 요약 ───────────────────────────────────────────────── +echo +echo "======================================================" +case $RESULT in + 0) echo " 최종 결과: ${OK} JEUS 정상" ;; + 1) echo " 최종 결과: ${WARN} 주의 항목 있음" ;; + 2) echo " 최종 결과: ${CRIT} 즉시 조치 필요" ;; +esac +echo " 점검 완료: $(date '+%Y-%m-%d %H:%M:%S')" +echo "======================================================" +exit $RESULT diff --git a/scripts/sm/was/was_tomcat_sm.sh b/scripts/sm/was/was_tomcat_sm.sh new file mode 100644 index 0000000..c6c4aec --- /dev/null +++ b/scripts/sm/was/was_tomcat_sm.sh @@ -0,0 +1,142 @@ +#!/bin/bash +# ============================================================ +# GUARDiA SM | was_tomcat_sm.sh +# 대상: Apache Tomcat WAS +# 파라미터: CATALINA_HOME=/opt/tomcat APP_PORT=8080 AJP_PORT=8009 +# JMX_PORT=9090 HEALTH_URL=http://localhost:8080/health +# TOMCAT_USER=tomcat LOG_DIR=/opt/tomcat/logs +# ============================================================ +set -euo pipefail +CATALINA_HOME=${CATALINA_HOME:-/opt/tomcat} +APP_PORT=${APP_PORT:-8080} +AJP_PORT=${AJP_PORT:-8009} +JMX_PORT=${JMX_PORT:-9090} +HEALTH_URL=${HEALTH_URL:-"http://localhost:${APP_PORT}/health"} +LOG_DIR=${LOG_DIR:-"${CATALINA_HOME}/logs"} +OK="[OK]"; WARN="[WARN]"; CRIT="[CRIT]" +SEP="─────────────────────────────────────────" +RESULT=0 + +echo "======================================================" +echo " GUARDiA SM 점검 | Apache Tomcat | $(hostname -s)" +echo " 점검 시각: $(date '+%Y-%m-%d %H:%M:%S %Z')" +echo "======================================================" + +# ── 1. 프로세스 ─────────────────────────────────────────── +echo; echo "[$SEP] 1. Tomcat 프로세스" +TOMCAT_PID=$(pgrep -f "catalina|tomcat" 2>/dev/null | head -1 || echo "") +if [ -n "$TOMCAT_PID" ]; then + echo " ${OK} Tomcat 실행 중 (PID: ${TOMCAT_PID})" + # 프로세스 시작 시간 + START_TIME=$(ps -p "$TOMCAT_PID" -o lstart= 2>/dev/null || echo "N/A") + echo " 시작 시각: ${START_TIME}" +else + echo " ${CRIT} Tomcat 프로세스 없음" + RESULT=2 +fi + +# ── 2. 포트 리스닝 ──────────────────────────────────────── +echo; echo "[$SEP] 2. 포트 리스닝" +for PORT in $APP_PORT $AJP_PORT; do + if ss -tlnp 2>/dev/null | grep -q ":${PORT} "; then + echo " ${OK} 포트 ${PORT} LISTEN" + else + echo " ${WARN} 포트 ${PORT} LISTEN 없음" + [ $RESULT -lt 1 ] && RESULT=1 + fi +done + +# ── 3. 헬스체크 ─────────────────────────────────────────── +echo; echo "[$SEP] 3. 헬스체크" +if command -v curl &>/dev/null; then + HTTP_CODE=$(curl -sk -o /dev/null -w "%{http_code}" \ + --max-time 10 "${HEALTH_URL}" 2>/dev/null || echo "ERR") + RESP_TIME=$(curl -sk -o /dev/null -w "%{time_total}" \ + --max-time 10 "${HEALTH_URL}" 2>/dev/null || echo "N/A") + if echo "$HTTP_CODE" | grep -qE "^[23]"; then + echo " ${OK} 헬스체크 ${HTTP_CODE} — ${RESP_TIME}s (${HEALTH_URL})" + elif [ "$HTTP_CODE" = "ERR" ]; then + echo " ${CRIT} 헬스체크 연결 실패" + RESULT=2 + else + echo " ${WARN} 헬스체크 HTTP ${HTTP_CODE}" + [ $RESULT -lt 1 ] && RESULT=1 + fi +fi + +# ── 4. JVM 메모리 (JMX 없이 /proc 활용) ──────────────────── +echo; echo "[$SEP] 4. JVM 메모리 (프로세스 기준)" +if [ -n "$TOMCAT_PID" ]; then + RSS_KB=$(awk '/VmRSS/{print $2}' /proc/${TOMCAT_PID}/status 2>/dev/null || echo 0) + RSS_MB=$(( RSS_KB / 1024 )) + VIRT_KB=$(awk '/VmSize/{print $2}' /proc/${TOMCAT_PID}/status 2>/dev/null || echo 0) + VIRT_MB=$(( VIRT_KB / 1024 )) + echo " RSS (상주 메모리) : ${RSS_MB} MB" + echo " 가상 메모리 : ${VIRT_MB} MB" + # JVM 힙 (jcmd/jmap 사용 가능 시) + if command -v jcmd &>/dev/null 2>&1; then + HEAP=$(jcmd "$TOMCAT_PID" GC.heap_info 2>/dev/null | grep -E "used|capacity" | head -4 || true) + [ -n "$HEAP" ] && echo "$HEAP" | sed 's/^/ 힙 /' + fi +fi + +# ── 5. 스레드 풀 점검 ───────────────────────────────────── +echo; echo "[$SEP] 5. 스레드 수" +if [ -n "$TOMCAT_PID" ]; then + THREAD_COUNT=$(ls /proc/${TOMCAT_PID}/task 2>/dev/null | wc -l || echo 0) + echo " 전체 스레드 수: ${THREAD_COUNT}" + if [ "$THREAD_COUNT" -gt 300 ]; then + echo " ${WARN} 스레드 과다 (${THREAD_COUNT} > 300)" + [ $RESULT -lt 1 ] && RESULT=1 + else + echo " ${OK} 스레드 수 정상" + fi +fi + +# ── 6. catalina.out 에러 점검 ──────────────────────────── +echo; echo "[$SEP] 6. catalina.out 오류 (최근 1시간)" +CATALINA_LOG="${LOG_DIR}/catalina.out" +if [ -r "$CATALINA_LOG" ]; then + # 최근 1000줄에서 ERROR/WARN 집계 + ERRORS=$(tail -1000 "$CATALINA_LOG" | grep -cE "ERROR|EXCEPTION|OutOfMemory" || echo 0) + WARNS=$(tail -1000 "$CATALINA_LOG" | grep -c "WARN" || echo 0) + echo " ERROR/EXCEPTION 수: ${ERRORS}" + echo " WARN 수: ${WARNS}" + if [ "$ERRORS" -gt 10 ]; then + echo " ${CRIT} 다수 에러 감지 (${ERRORS}건)" + RESULT=2 + elif [ "$ERRORS" -gt 0 ]; then + echo " ${WARN} 일부 에러 감지:" + tail -1000 "$CATALINA_LOG" | grep -E "ERROR|EXCEPTION|OutOfMemory" | tail -5 | sed 's/^/ /' + [ $RESULT -lt 1 ] && RESULT=1 + else + echo " ${OK} 최근 에러 없음" + fi + # OOM 감지 + OOM=$(tail -1000 "$CATALINA_LOG" | grep -i "OutOfMemoryError" | tail -3 || true) + [ -n "$OOM" ] && { echo " ${CRIT} OOM 감지:"; echo "$OOM" | sed 's/^/ /'; RESULT=2; } +else + echo " ${WARN} catalina.out 없음: ${CATALINA_LOG}" + [ $RESULT -lt 1 ] && RESULT=1 +fi + +# ── 7. 배포된 앱 목록 ───────────────────────────────────── +echo; echo "[$SEP] 7. 배포 앱 목록" +WEBAPPS="${CATALINA_HOME}/webapps" +if [ -d "$WEBAPPS" ]; then + ls "$WEBAPPS" 2>/dev/null | sed 's/^/ - /' | head -20 +else + echo " ${WARN} webapps 디렉터리 없음: ${WEBAPPS}" +fi + +# ── 요약 ───────────────────────────────────────────────── +echo +echo "======================================================" +case $RESULT in + 0) echo " 최종 결과: ${OK} Tomcat 정상" ;; + 1) echo " 최종 결과: ${WARN} 주의 항목 있음" ;; + 2) echo " 최종 결과: ${CRIT} 즉시 조치 필요" ;; +esac +echo " 점검 완료: $(date '+%Y-%m-%d %H:%M:%S')" +echo "======================================================" +exit $RESULT diff --git a/scripts/sm/was/was_weblogic_sm.sh b/scripts/sm/was/was_weblogic_sm.sh new file mode 100644 index 0000000..0a5f50f --- /dev/null +++ b/scripts/sm/was/was_weblogic_sm.sh @@ -0,0 +1,126 @@ +#!/bin/bash +# ============================================================ +# GUARDiA SM | was_weblogic_sm.sh +# 대상: Oracle WebLogic Server +# 파라미터: WL_HOME=/opt/oracle/middleware/wlserver +# DOMAIN_HOME=/opt/oracle/domains/base_domain +# ADMIN_PORT=7001 APP_PORT=7003 +# WL_USER=weblogic WL_PASS=weblogic1 +# HEALTH_URL=http://localhost:7003/health +# ============================================================ +set -euo pipefail +WL_HOME=${WL_HOME:-/opt/oracle/middleware/wlserver} +DOMAIN_HOME=${DOMAIN_HOME:-/opt/oracle/domains/base_domain} +ADMIN_PORT=${ADMIN_PORT:-7001} +APP_PORT=${APP_PORT:-7003} +WL_USER=${WL_USER:-weblogic} +WL_PASS=${WL_PASS:-weblogic1} +HEALTH_URL=${HEALTH_URL:-"http://localhost:${APP_PORT}/health"} +OK="[OK]"; WARN="[WARN]"; CRIT="[CRIT]" +SEP="─────────────────────────────────────────" +RESULT=0 + +echo "======================================================" +echo " GUARDiA SM 점검 | Oracle WebLogic | $(hostname -s)" +echo " 점검 시각: $(date '+%Y-%m-%d %H:%M:%S %Z')" +echo "======================================================" + +# ── 1. 프로세스 ─────────────────────────────────────────── +echo; echo "[$SEP] 1. WebLogic 프로세스" +WL_PROC=$(pgrep -f "weblogic.Server\|AdminServer\|ManagedServer" 2>/dev/null | wc -l || echo 0) +ADMIN_PID=$(pgrep -f "AdminServer" 2>/dev/null | head -1 || echo "") +if [ "$WL_PROC" -gt 0 ]; then + echo " ${OK} WebLogic 프로세스 실행 중 (${WL_PROC}개)" + [ -n "$ADMIN_PID" ] && echo " AdminServer PID: ${ADMIN_PID}" +else + echo " ${CRIT} WebLogic 프로세스 없음" + RESULT=2 +fi + +# ── 2. 포트 리스닝 ──────────────────────────────────────── +echo; echo "[$SEP] 2. 포트 리스닝" +for PORT in $ADMIN_PORT $APP_PORT; do + if ss -tlnp 2>/dev/null | grep -q ":${PORT} "; then + echo " ${OK} 포트 ${PORT} LISTEN" + else + echo " ${WARN} 포트 ${PORT} LISTEN 없음" + [ $RESULT -lt 1 ] && RESULT=1 + fi +done + +# ── 3. AdminServer REST API 상태 ───────────────────────── +echo; echo "[$SEP] 3. AdminServer 상태 (REST)" +if command -v curl &>/dev/null; then + WL_STATE=$(curl -sk --max-time 5 \ + -u "${WL_USER}:${WL_PASS}" \ + "http://localhost:${ADMIN_PORT}/management/weblogic/latest/domainRuntime/serverLifeCycleRuntimes?fields=name,state" \ + 2>/dev/null | python3 -c " +import sys, json +try: + d = json.load(sys.stdin) + items = d.get('items', d if isinstance(d, list) else []) + for s in items[:5]: + print(f\" {s.get('name','?'):20s} {s.get('state','?')}\") +except: pass" 2>/dev/null || echo "") + if [ -n "$WL_STATE" ]; then + echo " 서버 상태:" + echo "$WL_STATE" + else + echo " ${WARN} REST API 응답 없음 (인증/포트 확인 필요)" + [ $RESULT -lt 1 ] && RESULT=1 + fi +fi + +# ── 4. 헬스체크 ─────────────────────────────────────────── +echo; echo "[$SEP] 4. 애플리케이션 헬스체크" +if command -v curl &>/dev/null; then + HTTP_CODE=$(curl -sk -o /dev/null -w "%{http_code}" \ + --max-time 10 "${HEALTH_URL}" 2>/dev/null || echo "ERR") + RESP_TIME=$(curl -sk -o /dev/null -w "%{time_total}" \ + --max-time 10 "${HEALTH_URL}" 2>/dev/null || echo "N/A") + if echo "$HTTP_CODE" | grep -qE "^[23]"; then + echo " ${OK} HTTP ${HTTP_CODE} — ${RESP_TIME}s" + elif [ "$HTTP_CODE" = "ERR" ]; then + echo " ${CRIT} 헬스체크 실패" + RESULT=2 + else + echo " ${WARN} HTTP ${HTTP_CODE}" + [ $RESULT -lt 1 ] && RESULT=1 + fi +fi + +# ── 5. JVM 메모리 ───────────────────────────────────────── +echo; echo "[$SEP] 5. JVM 메모리" +if [ -n "$ADMIN_PID" ]; then + RSS_MB=$(awk '/VmRSS/{print $2}' /proc/${ADMIN_PID}/status 2>/dev/null | \ + awk '{printf "%d", $1/1024}' || echo "N/A") + THREAD=$(ls /proc/${ADMIN_PID}/task 2>/dev/null | wc -l || echo 0) + echo " AdminServer RSS: ${RSS_MB} MB" + echo " AdminServer 스레드: ${THREAD}" +fi + +# ── 6. 도메인 로그 오류 ────────────────────────────────── +echo; echo "[$SEP] 6. 도메인 로그 오류" +DOMAIN_LOG="${DOMAIN_HOME}/servers/AdminServer/logs/AdminServer.log" +if [ -r "$DOMAIN_LOG" ]; then + ERR_CNT=$(tail -2000 "$DOMAIN_LOG" | grep -c "\|\|" || echo 0) + echo " 최근 오류 수: ${ERR_CNT}" + [ "$ERR_CNT" -gt 0 ] && \ + tail -2000 "$DOMAIN_LOG" | grep -E "|" | tail -5 | sed 's/^/ /' && \ + [ $RESULT -lt 1 ] && RESULT=1 + echo " ${OK} 로그 점검 완료" +else + echo " ${WARN} 도메인 로그 없음: ${DOMAIN_LOG}" +fi + +# ── 요약 ───────────────────────────────────────────────── +echo +echo "======================================================" +case $RESULT in + 0) echo " 최종 결과: ${OK} WebLogic 정상" ;; + 1) echo " 최종 결과: ${WARN} 주의 항목 있음" ;; + 2) echo " 최종 결과: ${CRIT} 즉시 조치 필요" ;; +esac +echo " 점검 완료: $(date '+%Y-%m-%d %H:%M:%S')" +echo "======================================================" +exit $RESULT diff --git a/scripts/sm/web/web_apache_sm.sh b/scripts/sm/web/web_apache_sm.sh new file mode 100644 index 0000000..c08bb90 --- /dev/null +++ b/scripts/sm/web/web_apache_sm.sh @@ -0,0 +1,133 @@ +#!/bin/bash +# ============================================================ +# GUARDiA SM | web_apache_sm.sh +# 대상: Apache HTTP Server (httpd) +# 파라미터: APACHE_PORT=80 APACHE_SSL_PORT=443 APACHE_CTL=apachectl +# APACHE_CONF=/etc/httpd/conf/httpd.conf CHECK_URL=http://localhost +# ============================================================ +set -euo pipefail +APACHE_PORT=${APACHE_PORT:-80} +APACHE_SSL_PORT=${APACHE_SSL_PORT:-443} +APACHE_CTL=${APACHE_CTL:-$(command -v apachectl 2>/dev/null || echo "apachectl")} +APACHE_CONF=${APACHE_CONF:-/etc/httpd/conf/httpd.conf} +CHECK_URL=${CHECK_URL:-"http://localhost"} +OK="[OK]"; WARN="[WARN]"; CRIT="[CRIT]" +SEP="─────────────────────────────────────────" +RESULT=0 + +echo "======================================================" +echo " GUARDiA SM 점검 | Apache HTTP Server | $(hostname -s)" +echo " 점검 시각: $(date '+%Y-%m-%d %H:%M:%S %Z')" +echo "======================================================" + +# ── 1. 프로세스 상태 ────────────────────────────────────── +echo; echo "[$SEP] 1. 프로세스 상태" +PROC_COUNT=$(pgrep -c "httpd|apache2" 2>/dev/null || echo 0) +if [ "$PROC_COUNT" -gt 0 ]; then + echo " ${OK} Apache 프로세스 실행 중 (${PROC_COUNT}개)" + ps aux | grep -E "httpd|apache2" | grep -v grep | awk \ + '{printf " PID:%-8s CPU:%-6s MEM:%-6s CMD:%s\n",$2,$3,$4,substr($0,index($0,$11))}' | head -5 +else + echo " ${CRIT} Apache 프로세스 없음" + RESULT=2 +fi + +# ── 2. 포트 리스닝 ─────────────────────────────────────── +echo; echo "[$SEP] 2. 포트 리스닝" +for PORT in $APACHE_PORT $APACHE_SSL_PORT; do + if ss -tlnp 2>/dev/null | grep -q ":${PORT} " || \ + netstat -tlnp 2>/dev/null | grep -q ":${PORT} "; then + echo " ${OK} 포트 ${PORT} LISTEN 중" + else + echo " ${WARN} 포트 ${PORT} LISTEN 없음" + [ $RESULT -lt 1 ] && RESULT=1 + fi +done + +# ── 3. 설정 파일 문법 검사 ──────────────────────────────── +echo; echo "[$SEP] 3. 설정 파일 검사" +if command -v "$APACHE_CTL" &>/dev/null; then + SYNTAX=$($APACHE_CTL -t 2>&1 || true) + if echo "$SYNTAX" | grep -qi "Syntax OK"; then + echo " ${OK} 설정 문법 정상" + else + echo " ${CRIT} 설정 문법 오류:" + echo "$SYNTAX" | tail -5 | sed 's/^/ /' + RESULT=2 + fi +else + echo " ${WARN} apachectl 을 찾을 수 없음 (경로: $APACHE_CTL)" + [ $RESULT -lt 1 ] && RESULT=1 +fi + +# ── 4. HTTP 응답 코드 점검 ──────────────────────────────── +echo; echo "[$SEP] 4. HTTP 응답 점검" +if command -v curl &>/dev/null; then + HTTP_CODE=$(curl -sk -o /dev/null -w "%{http_code}" \ + --max-time 5 --connect-timeout 3 "${CHECK_URL}" 2>/dev/null || echo "ERR") + RESP_TIME=$(curl -sk -o /dev/null -w "%{time_total}" \ + --max-time 5 --connect-timeout 3 "${CHECK_URL}" 2>/dev/null || echo "N/A") + if echo "$HTTP_CODE" | grep -qE "^[23]"; then + echo " ${OK} HTTP ${HTTP_CODE} — 응답시간 ${RESP_TIME}s (${CHECK_URL})" + elif [ "$HTTP_CODE" = "ERR" ]; then + echo " ${CRIT} HTTP 연결 실패 (${CHECK_URL})" + RESULT=2 + else + echo " ${WARN} HTTP ${HTTP_CODE} (${CHECK_URL})" + [ $RESULT -lt 1 ] && RESULT=1 + fi +else + echo " ${WARN} curl 없음 — HTTP 응답 점검 건너뜀" + [ $RESULT -lt 1 ] && RESULT=1 +fi + +# ── 5. 로드된 모듈 목록 ────────────────────────────────── +echo; echo "[$SEP] 5. 주요 모듈 상태" +if command -v "$APACHE_CTL" &>/dev/null; then + MODULES=$($APACHE_CTL -M 2>/dev/null || true) + for MOD in ssl rewrite proxy proxy_http headers expires deflate; do + if echo "$MODULES" | grep -qi "${MOD}_module"; then + echo " ${OK} mod_${MOD} 로드됨" + else + echo " ${WARN} mod_${MOD} 미로드" + fi + done +fi + +# ── 6. 최근 에러 로그 ───────────────────────────────────── +echo; echo "[$SEP] 6. 최근 에러 로그 (마지막 10줄)" +ERR_LOG="" +for P in /var/log/httpd/error_log /var/log/apache2/error.log /usr/local/apache2/logs/error_log; do + [ -r "$P" ] && { ERR_LOG="$P"; break; } +done +if [ -n "$ERR_LOG" ]; then + ERRS=$(tail -20 "$ERR_LOG" 2>/dev/null | grep -iE "\[error\]|\[crit\]|\[alert\]|\[emerg\]" | tail -10 || true) + if [ -n "$ERRS" ]; then + echo " ${WARN} 최근 오류 감지:" + echo "$ERRS" | sed 's/^/ /' + [ $RESULT -lt 1 ] && RESULT=1 + else + echo " ${OK} 최근 오류 없음 (${ERR_LOG})" + fi +else + echo " ${WARN} 에러 로그 파일을 찾을 수 없음" +fi + +# ── 7. 연결 수 ──────────────────────────────────────────── +echo; echo "[$SEP] 7. 연결 통계" +CONN=$(ss -tnp 2>/dev/null | grep -E ":${APACHE_PORT}|:${APACHE_SSL_PORT}" | wc -l || echo 0) +ESTAB=$(ss -tnp 2>/dev/null | grep -E ":${APACHE_PORT}|:${APACHE_SSL_PORT}" | grep ESTAB | wc -l || echo 0) +echo " 전체 연결 수 : ${CONN}" +echo " ESTABLISHED : ${ESTAB}" + +# ── 요약 ───────────────────────────────────────────────── +echo +echo "======================================================" +case $RESULT in + 0) echo " 최종 결과: ${OK} Apache 정상" ;; + 1) echo " 최종 결과: ${WARN} 주의 항목 있음" ;; + 2) echo " 최종 결과: ${CRIT} 즉시 조치 필요" ;; +esac +echo " 점검 완료: $(date '+%Y-%m-%d %H:%M:%S')" +echo "======================================================" +exit $RESULT diff --git a/scripts/sm/web/web_nginx_sm.sh b/scripts/sm/web/web_nginx_sm.sh new file mode 100644 index 0000000..6ec282f --- /dev/null +++ b/scripts/sm/web/web_nginx_sm.sh @@ -0,0 +1,131 @@ +#!/bin/bash +# ============================================================ +# GUARDiA SM | web_nginx_sm.sh +# 대상: Nginx +# 파라미터: NGINX_PORT=80 NGINX_SSL_PORT=443 +# NGINX_CONF=/etc/nginx/nginx.conf CHECK_URL=http://localhost +# NGINX_STATUS_URL=http://localhost/nginx_status +# ============================================================ +set -euo pipefail +NGINX_PORT=${NGINX_PORT:-80} +NGINX_SSL_PORT=${NGINX_SSL_PORT:-443} +NGINX_CONF=${NGINX_CONF:-/etc/nginx/nginx.conf} +CHECK_URL=${CHECK_URL:-"http://localhost"} +NGINX_STATUS_URL=${NGINX_STATUS_URL:-"http://localhost/nginx_status"} +OK="[OK]"; WARN="[WARN]"; CRIT="[CRIT]" +SEP="─────────────────────────────────────────" +RESULT=0 + +echo "======================================================" +echo " GUARDiA SM 점검 | Nginx | $(hostname -s)" +echo " 점검 시각: $(date '+%Y-%m-%d %H:%M:%S %Z')" +echo "======================================================" + +# ── 1. 프로세스 ─────────────────────────────────────────── +echo; echo "[$SEP] 1. 프로세스 상태" +MASTER=$(pgrep -f "nginx: master" 2>/dev/null | wc -l || echo 0) +WORKER=$(pgrep -f "nginx: worker" 2>/dev/null | wc -l || echo 0) +if [ "$MASTER" -gt 0 ]; then + echo " ${OK} Nginx 마스터 프로세스 실행 중" + echo " 워커 프로세스: ${WORKER}개" +else + echo " ${CRIT} Nginx 마스터 프로세스 없음" + RESULT=2 +fi + +# ── 2. 포트 ─────────────────────────────────────────────── +echo; echo "[$SEP] 2. 포트 리스닝" +for PORT in $NGINX_PORT $NGINX_SSL_PORT; do + if ss -tlnp 2>/dev/null | grep -q ":${PORT} "; then + echo " ${OK} 포트 ${PORT} LISTEN" + else + echo " ${WARN} 포트 ${PORT} LISTEN 없음" + [ $RESULT -lt 1 ] && RESULT=1 + fi +done + +# ── 3. 설정 문법 검사 ───────────────────────────────────── +echo; echo "[$SEP] 3. 설정 파일 검사" +if command -v nginx &>/dev/null; then + SYNTAX=$(nginx -t 2>&1 || true) + if echo "$SYNTAX" | grep -qi "syntax is ok"; then + echo " ${OK} 설정 문법 정상" + else + echo " ${CRIT} 설정 문법 오류:" + echo "$SYNTAX" | tail -5 | sed 's/^/ /' + RESULT=2 + fi +else + echo " ${WARN} nginx 바이너리를 찾을 수 없음" + [ $RESULT -lt 1 ] && RESULT=1 +fi + +# ── 4. HTTP 응답 ────────────────────────────────────────── +echo; echo "[$SEP] 4. HTTP 응답 점검" +if command -v curl &>/dev/null; then + HTTP_CODE=$(curl -sk -o /dev/null -w "%{http_code}" \ + --max-time 5 "${CHECK_URL}" 2>/dev/null || echo "ERR") + RESP_TIME=$(curl -sk -o /dev/null -w "%{time_total}" \ + --max-time 5 "${CHECK_URL}" 2>/dev/null || echo "N/A") + if echo "$HTTP_CODE" | grep -qE "^[23]"; then + echo " ${OK} HTTP ${HTTP_CODE} — ${RESP_TIME}s" + elif [ "$HTTP_CODE" = "ERR" ]; then + echo " ${CRIT} HTTP 연결 실패" + RESULT=2 + else + echo " ${WARN} HTTP ${HTTP_CODE}" + [ $RESULT -lt 1 ] && RESULT=1 + fi +fi + +# ── 5. stub_status (연결 통계) ──────────────────────────── +echo; echo "[$SEP] 5. 연결 통계 (stub_status)" +if command -v curl &>/dev/null; then + STATUS=$(curl -sk --max-time 3 "${NGINX_STATUS_URL}" 2>/dev/null || echo "") + if [ -n "$STATUS" ]; then + ACTIVE=$(echo "$STATUS" | grep "Active connections" | awk '{print $3}') + echo " Active connections: ${ACTIVE:-N/A}" + echo "$STATUS" | grep -E "server accepts|reading|writing|waiting" | sed 's/^/ /' + else + echo " ${WARN} stub_status 접근 불가 (모듈 미설정 또는 location 없음)" + fi +fi + +# ── 6. 업스트림 확인 ────────────────────────────────────── +echo; echo "[$SEP] 6. 업스트림 서버 설정" +if [ -r "$NGINX_CONF" ]; then + UPSTREAM_COUNT=$(grep -rh "upstream" /etc/nginx/ 2>/dev/null | grep -v "#" | grep -c "upstream" || echo 0) + echo " 업스트림 블록 수: ${UPSTREAM_COUNT}" + grep -rh "server " /etc/nginx/ 2>/dev/null | grep -v "#" | grep -E "[0-9]+\.[0-9]+\.[0-9]+" | \ + awk '{print " "$0}' | head -10 || true +else + echo " ${WARN} Nginx 설정 파일 접근 불가: ${NGINX_CONF}" +fi + +# ── 7. 에러 로그 ───────────────────────────────────────── +echo; echo "[$SEP] 7. 최근 에러 로그" +for P in /var/log/nginx/error.log /usr/local/nginx/logs/error.log; do + if [ -r "$P" ]; then + ERRS=$(tail -20 "$P" | grep -iE "\[error\]|\[crit\]|\[alert\]|\[emerg\]" | tail -10 || true) + if [ -n "$ERRS" ]; then + echo " ${WARN} 최근 오류:" + echo "$ERRS" | sed 's/^/ /' + [ $RESULT -lt 1 ] && RESULT=1 + else + echo " ${OK} 최근 오류 없음" + fi + break + fi +done + +# ── 요약 ───────────────────────────────────────────────── +echo +echo "======================================================" +case $RESULT in + 0) echo " 최종 결과: ${OK} Nginx 정상" ;; + 1) echo " 최종 결과: ${WARN} 주의 항목 있음" ;; + 2) echo " 최종 결과: ${CRIT} 즉시 조치 필요" ;; +esac +echo " 점검 완료: $(date '+%Y-%m-%d %H:%M:%S')" +echo "======================================================" +exit $RESULT diff --git a/scripts/sm/web/web_webtob_sm.sh b/scripts/sm/web/web_webtob_sm.sh new file mode 100644 index 0000000..24f7eea --- /dev/null +++ b/scripts/sm/web/web_webtob_sm.sh @@ -0,0 +1,112 @@ +#!/bin/bash +# ============================================================ +# GUARDiA SM | web_webtob_sm.sh +# 대상: WebtoB (TMAX) 웹 서버 +# 파라미터: WEBTOB_HOME=/opt/webtob WEBTOB_PORT=80 +# WEBTOB_SSL_PORT=443 CHECK_URL=http://localhost +# ============================================================ +set -euo pipefail +WEBTOB_HOME=${WEBTOB_HOME:-/opt/webtob} +WEBTOB_PORT=${WEBTOB_PORT:-80} +WEBTOB_SSL_PORT=${WEBTOB_SSL_PORT:-443} +CHECK_URL=${CHECK_URL:-"http://localhost"} +OK="[OK]"; WARN="[WARN]"; CRIT="[CRIT]" +SEP="─────────────────────────────────────────" +RESULT=0 + +echo "======================================================" +echo " GUARDiA SM 점검 | WebtoB | $(hostname -s)" +echo " 점검 시각: $(date '+%Y-%m-%d %H:%M:%S %Z')" +echo "======================================================" + +# ── 1. wmaster 프로세스 ──────────────────────────────────── +echo; echo "[$SEP] 1. wmaster 프로세스" +WMASTER=$(pgrep -c wmaster 2>/dev/null || echo 0) +if [ "$WMASTER" -gt 0 ]; then + echo " ${OK} wmaster 실행 중 (PID: $(pgrep wmaster | tr '\n' ' '))" +else + echo " ${CRIT} wmaster 프로세스 없음" + RESULT=2 +fi + +# WebtoB 워커 프로세스 +WORKER=$(pgrep -c "wsm|webtob" 2>/dev/null || echo 0) +echo " wsm/webtob 워커 수: ${WORKER}" + +# ── 2. 포트 리스닝 ──────────────────────────────────────── +echo; echo "[$SEP] 2. 포트 리스닝" +for PORT in $WEBTOB_PORT $WEBTOB_SSL_PORT; do + if ss -tlnp 2>/dev/null | grep -q ":${PORT} "; then + echo " ${OK} 포트 ${PORT} LISTEN" + else + echo " ${WARN} 포트 ${PORT} LISTEN 없음" + [ $RESULT -lt 1 ] && RESULT=1 + fi +done + +# ── 3. wsadmin 상태 점검 ───────────────────────────────── +echo; echo "[$SEP] 3. wsadmin 서버 상태" +WSADMIN="${WEBTOB_HOME}/bin/wsadmin" +if [ -x "$WSADMIN" ]; then + WSTAT=$($WSADMIN -c "si -g" 2>/dev/null | head -20 || echo "조회 실패") + echo "$WSTAT" | sed 's/^/ /' +else + echo " ${WARN} wsadmin 없음: ${WSADMIN}" + [ $RESULT -lt 1 ] && RESULT=1 +fi + +# ── 4. HTTP 응답 ────────────────────────────────────────── +echo; echo "[$SEP] 4. HTTP 응답 점검" +if command -v curl &>/dev/null; then + HTTP_CODE=$(curl -sk -o /dev/null -w "%{http_code}" \ + --max-time 5 "${CHECK_URL}" 2>/dev/null || echo "ERR") + RESP_TIME=$(curl -sk -o /dev/null -w "%{time_total}" \ + --max-time 5 "${CHECK_URL}" 2>/dev/null || echo "N/A") + if echo "$HTTP_CODE" | grep -qE "^[23]"; then + echo " ${OK} HTTP ${HTTP_CODE} — 응답시간 ${RESP_TIME}s" + elif [ "$HTTP_CODE" = "ERR" ]; then + echo " ${CRIT} HTTP 연결 실패" + RESULT=2 + else + echo " ${WARN} HTTP ${HTTP_CODE}" + [ $RESULT -lt 1 ] && RESULT=1 + fi +fi + +# ── 5. 설정 파일 확인 ───────────────────────────────────── +echo; echo "[$SEP] 5. 설정 파일" +CONF="${WEBTOB_HOME}/config/webtob.m" +if [ -r "$CONF" ]; then + echo " ${OK} 설정 파일 존재: ${CONF}" + VHOST_COUNT=$(grep -c "VHOST" "$CONF" 2>/dev/null || echo 0) + echo " 가상 호스트 수: ${VHOST_COUNT}" +else + echo " ${WARN} 설정 파일 없음: ${CONF}" + [ $RESULT -lt 1 ] && RESULT=1 +fi + +# ── 6. 에러 로그 ───────────────────────────────────────── +echo; echo "[$SEP] 6. 최근 에러 로그" +for LOGDIR in "${WEBTOB_HOME}/log" "${WEBTOB_HOME}/logs"; do + if [ -d "$LOGDIR" ]; then + ERRLOG=$(ls -t "${LOGDIR}"/error*.log "${LOGDIR}"/*.err 2>/dev/null | head -1 || true) + if [ -n "$ERRLOG" ] && [ -r "$ERRLOG" ]; then + ERRS=$(tail -20 "$ERRLOG" | grep -iE "error|fatal|critical" | tail -10 || true) + [ -n "$ERRS" ] && echo " ${WARN} 오류 감지:" && echo "$ERRS" | sed 's/^/ /' || \ + echo " ${OK} 최근 오류 없음 (${ERRLOG})" + fi + break + fi +done + +# ── 요약 ───────────────────────────────────────────────── +echo +echo "======================================================" +case $RESULT in + 0) echo " 최종 결과: ${OK} WebtoB 정상" ;; + 1) echo " 최종 결과: ${WARN} 주의 항목 있음" ;; + 2) echo " 최종 결과: ${CRIT} 즉시 조치 필요" ;; +esac +echo " 점검 완료: $(date '+%Y-%m-%d %H:%M:%S')" +echo "======================================================" +exit $RESULT diff --git a/static/agents.html b/static/agents.html new file mode 100644 index 0000000..381a3bc --- /dev/null +++ b/static/agents.html @@ -0,0 +1,672 @@ + + + + + + GUARDiA — AI 에이전트 + + + + + +
+ + + + + +
+ + + +
+ ⚠️ + Ollama 서버 상태 확인 중... + 다시 확인 +
+ + +
+
-
전체 에이전트
+
-
활성 에이전트
+
-
오늘 태스크
+
-
오늘 토큰
+
-
승인 대기
+
+ + +
+ + + + +
+ + +
+
+
에이전트를 불러오는 중...
+
+
+ + + + + + + + + +
+
+ + + + + + + diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..d3e73e3 --- /dev/null +++ b/static/app.js @@ -0,0 +1,2227 @@ +/* ══════════════════════════════════════════════════ + F-4: PWA Service Worker 등록 +══════════════════════════════════════════════════ */ +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/static/sw.js', { scope: '/' }) + .then(reg => { + console.log('[GUARDiA PWA] SW 등록 성공:', reg.scope); + // 새 버전 감지 시 사용자에게 알림 + reg.addEventListener('updatefound', () => { + const newSW = reg.installing; + newSW.addEventListener('statechange', () => { + if (newSW.state === 'installed' && navigator.serviceWorker.controller) { + console.log('[GUARDiA PWA] 새 버전 사용 가능 — 새로고침하면 업데이트됩니다.'); + } + }); + }); + }) + .catch(err => console.warn('[GUARDiA PWA] SW 등록 실패:', err)); + }); +} + +/* ══════════════════════════════════════════════════ + Nifty 사이드바 계층 메뉴 토글 +══════════════════════════════════════════════════ */ +function toggleNavGroup(header) { + const body = header.nextElementSibling; + const isOpen = body.classList.contains('open'); + body.classList.toggle('open', !isOpen); + header.setAttribute('aria-expanded', String(!isOpen)); +} + +// 현재 URL에 해당하는 메뉴 자동 열기 +(function autoOpenNavGroup() { + document.querySelectorAll('.nav-group-body .nav-sub-item').forEach(item => { + const href = item.getAttribute('href') || ''; + if (href && location.pathname.startsWith(href.split('?')[0])) { + const body = item.closest('.nav-group-body'); + const header = body?.previousElementSibling; + if (body && header) { + body.classList.add('open'); + header.setAttribute('aria-expanded', 'true'); + item.classList.add('active'); + } + } + }); +})(); + +/* ══════════════════════════════════════════════════ + 테마 관리 (스크립트 최상단 — FOUC 방지) +══════════════════════════════════════════════════ */ +function applyTheme(key) { + document.body.dataset.theme = key; + localStorage.setItem("guardia_theme", key); + document.querySelectorAll(".theme-swatch").forEach(b => + b.classList.toggle("active", b.dataset.theme === key) + ); +} + +/* ─── Auth ───────────────────────────────────────── */ +const _token = localStorage.getItem("guardia_token"); +const _userInfo = JSON.parse(localStorage.getItem("guardia_userinfo") || "{}"); + +// 로그인 안 된 경우 → /login으로 +if (!_token) { + window.location.replace("/login"); +} +// 비밀번호 변경 필요 → /change-password로 +if (_userInfo.must_change_pw) { + window.location.replace("/change-password"); +} + +/** + * Bearer 토큰을 자동으로 포함하는 fetch 래퍼. + * 401 응답 시 로그인 페이지로 리다이렉트. + */ +async function authFetch(url, opts = {}) { + const headers = { "Content-Type": "application/json", ...(opts.headers || {}) }; + if (_token) headers["Authorization"] = `Bearer ${_token}`; + const res = await fetch(url, { ...opts, headers }); + if (res.status === 401) { + localStorage.removeItem("guardia_token"); + localStorage.removeItem("guardia_userinfo"); + window.location.replace("/login"); + throw new Error("Unauthorized"); + } + return res; +} + +/** 로그아웃 */ +function logout() { + localStorage.removeItem("guardia_token"); + localStorage.removeItem("guardia_userinfo"); + window.location.replace("/login"); +} + +/* ─── State ─────────────────────────────────────── */ +let currentView = "dashboard"; +let srCache = []; +let statsCache = {}; +let workloadCache = []; +let dashCache = {}; // 역할별 대시보드 데이터 + +/* ─── Status labels ─────────────────────────────── */ +const STATUS_LABEL = { + RECEIVED: "접수", + PARSED: "파싱 완료", + PENDING_APPROVAL: "승인 대기", + APPROVED: "승인됨", + IN_PROGRESS: "진행 중", + PENDING_PM_VALIDATION: "PM 검증 대기", + COMPLETED: "완료", + FAILED_ROLLBACK: "롤백 실패", + REJECTED: "반려", +}; + +const TYPE_LABEL = { + DEPLOY: "배포", RESTART: "재기동", LOG: "로그", + INQUIRY: "문의", OTHER: "기타", +}; + +const PRIORITY_LABEL = { CRITICAL: "긴급", HIGH: "높음", MEDIUM: "보통", LOW: "낮음" }; + +const KANBAN_COLS = [ + { key: "RECEIVED", label: "접수" }, + { key: "PENDING_APPROVAL", label: "승인 대기" }, + { key: "APPROVED", label: "승인됨" }, + { key: "IN_PROGRESS", label: "진행 중" }, + { key: "PENDING_PM_VALIDATION", label: "PM 검증" }, + { key: "COMPLETED", label: "완료" }, + { key: "FAILED_ROLLBACK", label: "롤백 실패" }, + { key: "REJECTED", label: "반려" }, +]; + +const STATUS_COLORS = { + RECEIVED: "#8b949e", + PARSED: "#79c0ff", + PENDING_APPROVAL: "#e3b341", + APPROVED: "#56d364", + IN_PROGRESS: "#58a6ff", + PENDING_PM_VALIDATION: "#bc8cff", + COMPLETED: "#3fb950", + FAILED_ROLLBACK: "#f85149", + REJECTED: "#da3633", +}; + +/* ─── Init ──────────────────────────────────────── */ +window.addEventListener("DOMContentLoaded", async () => { + // 사용자 정보 표시 + const roleLabel = { ADMIN:"관리자", ENGINEER:"엔지니어", PM:"PM", CUSTOMER:"고객" }; + const roleColor = { + ADMIN:"#818cf8", ENGINEER:"#34d399", PM:"#fbbf24", CUSTOMER:"#a78bfa" + }; + const role = _userInfo.role || ""; + document.getElementById("user-display-name").textContent = + _userInfo.display_name || _userInfo.username || "사용자"; + const roleBadge = document.getElementById("user-role-badge"); + roleBadge.textContent = roleLabel[role] || role; + roleBadge.style.background = (roleColor[role] || "#64748b") + "22"; + roleBadge.style.color = roleColor[role] || "#64748b"; + + // 테마 버튼 active 상태 동기화 + applyTheme(document.body.dataset.theme || "dark"); + + setupNav(); + setupNewSR(); + setupListFilters(); + initChat(); + await loadAll(); + initSSE(); // 실시간 이벤트 연결 + startPoll(); // 30초 폴백 폴링 +}); + +async function loadAll() { + await Promise.all([loadDashboardMe(), loadSRs(), loadWorkload()]); + renderCurrentView(); +} + +/* ══════════════════════════════════════════════════ + SSE 실시간 연결 +══════════════════════════════════════════════════ */ +let _sseSource = null; +let _sseRetry = 0; +let _refreshPending = false; +let _pollTimer = null; + +function initSSE() { + if (!_token) return; + if (_sseSource) { _sseSource.close(); _sseSource = null; } + + const url = `/api/dashboard/events?token=${encodeURIComponent(_token)}`; + _sseSource = new EventSource(url); + + _sseSource.onopen = () => { + _sseRetry = 0; + setSseDot(true); + }; + + _sseSource.onmessage = (e) => { + if (!e.data || e.data.trim() === "") return; + try { handleSSEEvent(JSON.parse(e.data)); } catch {} + }; + + _sseSource.onerror = () => { + setSseDot(false); + _sseSource.close(); + _sseSource = null; + _sseRetry++; + const delay = Math.min(3000 * _sseRetry, 60000); + setTimeout(initSSE, delay); + }; +} + +function setSseDot(connected) { + const dot = document.getElementById("sse-dot"); + const lbl = document.getElementById("sse-label"); + if (!dot) return; + dot.className = `sse-dot ${connected ? "green" : "red"}`; + if (lbl) lbl.textContent = connected ? "실시간 연결" : "재연결 중…"; +} + +async function handleSSEEvent(evt) { + switch (evt.type) { + case "sr_created": + showToast(`🆕 새 SR 접수: ${evt.title || evt.sr_id}`, "info"); + debouncedRefresh(); + break; + case "sr_updated": + showToast(`📋 ${evt.sr_id} → ${STATUS_LABEL[evt.new_status] || evt.new_status}`, "info"); + debouncedRefresh(); + break; + case "approval_done": { + const icon = evt.result === "APPROVED" ? "✅" : "❌"; + showToast(`${icon} ${evt.sr_id} 결재 완료 (${evt.approver})`, + evt.result === "APPROVED" ? "success" : "warning"); + debouncedRefresh(); + break; + } + case "stats": + await loadDashboardMe(); + if (currentView === "dashboard") renderDashboard(); + break; + } +} + +function debouncedRefresh() { + if (_refreshPending) return; + _refreshPending = true; + setTimeout(async () => { + await loadAll(); + if (["ADMIN","PM"].includes(_userInfo.role)) loadTrend(); + _refreshPending = false; + showRefreshIndicator(); + }, 600); +} + +function showRefreshIndicator() { + const el = document.getElementById("topbar-refresh-indicator"); + if (!el) return; + el.classList.remove("hidden"); + setTimeout(() => el.classList.add("hidden"), 2500); +} + +/* 폴백 폴링 (SSE 장애 대비, 30초) */ +function startPoll() { + if (_pollTimer) clearInterval(_pollTimer); + _pollTimer = setInterval(async () => { + if (_sseSource && _sseSource.readyState === EventSource.OPEN) return; + await loadDashboardMe(); + if (currentView === "dashboard") renderDashboard(); + }, 30000); +} + +/* ══════════════════════════════════════════════════ + 토스트 알림 +══════════════════════════════════════════════════ */ +function showToast(msg, type = "info") { + const el = document.createElement("div"); + el.className = `toast toast-${type}`; + el.textContent = msg; + document.body.appendChild(el); + requestAnimationFrame(() => { + requestAnimationFrame(() => el.classList.add("show")); + }); + setTimeout(() => { + el.classList.remove("show"); + setTimeout(() => el.remove(), 350); + }, 3500); +} + +async function loadDashboardMe() { + try { + const r = await authFetch("/api/dashboard/me"); + dashCache = await r.json(); + // stats도 함께 채워두기 (호환성) + if (dashCache.kpi) { + statsCache = { + total: dashCache.kpi.total, + by_status: {}, + by_type: {}, + }; + } + } catch { dashCache = {}; } +} + +/* ─── Nav ───────────────────────────────────────── */ +function setupNav() { + document.querySelectorAll(".nav-item").forEach(el => { + el.addEventListener("click", () => { + const view = el.dataset.view; + switchView(view); + }); + }); +} + +function switchView(view) { + currentView = view; + document.querySelectorAll(".nav-item").forEach(el => + el.classList.toggle("active", el.dataset.view === view) + ); + document.querySelectorAll(".view").forEach(el => + el.classList.toggle("active", el.id === `view-${view}`) + ); + const titles = { + dashboard: "대시보드", board: "칸반 보드", + list: "SR 목록", audit: "감사 로그", cmdb: "CMDB", + kb: "기술 문서 KB", + institutions: "기관 관리", scripts: "스크립트 관리", + timetable: "작업 타임테이블", + }; + document.getElementById("page-title").textContent = titles[view] || view; + renderCurrentView(); +} + +function renderCurrentView() { + if (currentView === "dashboard") renderDashboard(); + else if (currentView === "board") renderKanban(); + else if (currentView === "list") renderList(); + else if (currentView === "audit") loadAudit(); + else if (currentView === "cmdb") loadCmdb(); + else if (currentView === "kb") loadKBView(); + else if (currentView === "institutions") loadInstitutions(); + else if (currentView === "scripts") loadScripts(); + else if (currentView === "timetable") loadTimetable(); +} + +/* ─── Data loading ──────────────────────────────── */ +async function loadWorkload() { + try { + const r = await authFetch("/api/assign/workload"); + workloadCache = await r.json(); + renderWorkload(); + } catch { workloadCache = []; } +} + +async function loadStats() { + try { + const r = await authFetch("/api/tasks/stats"); + statsCache = await r.json(); + } catch { statsCache = {}; } +} + +async function loadSRs(params = {}) { + const qs = new URLSearchParams(params).toString(); + const r = await authFetch(`/api/tasks?limit=200${qs ? "&" + qs : ""}`); + srCache = await r.json(); +} + +/* ══════════════════════════════════════════════════ + 7일 추이 차트 (순수 SVG) +══════════════════════════════════════════════════ */ +async function loadTrend() { + try { + const r = await authFetch("/api/dashboard/stats/trend"); + const data = await r.json(); + renderTrendChart(data); + const lbl = document.getElementById("trend-last-updated"); + if (lbl) lbl.textContent = new Date().toLocaleTimeString("ko-KR", { hour:"2-digit", minute:"2-digit" }) + " 기준"; + } catch {} +} + +function renderTrendChart(days) { + const card = document.getElementById("trend-chart-card"); + const el = document.getElementById("trend-chart"); + if (!card || !el || !days?.length) return; + card.style.display = ""; + + const W = el.clientWidth || 520; + const H = 130; + const pad = { t: 16, r: 16, b: 32, l: 36 }; + const cw = W - pad.l - pad.r; + const ch = H - pad.t - pad.b; + + const maxVal = Math.max(...days.flatMap(d => [d.created, d.completed]), 1); + const step = cw / days.length; + const bw = Math.floor(step * 0.32); + + // Y 격자선 + 레이블 + const yLines = [0, .5, 1].map(f => { + const y = pad.t + ch * (1 - f); + const v = Math.round(maxVal * f); + return ` + ${v}`; + }).join(""); + + // 막대 + X 레이블 + const bars = days.map((d, i) => { + const cx = pad.l + i * step + step * 0.5; + const x1 = cx - bw - 1; + const x2 = cx + 1; + const h1 = Math.max(d.created / maxVal * ch, d.created ? 2 : 0); + const h2 = Math.max(d.completed / maxVal * ch, d.completed ? 2 : 0); + const y1 = pad.t + ch - h1; + const y2 = pad.t + ch - h2; + return ` + + + ${d.label}`; + }).join(""); + + el.innerHTML = ` + + ${yLines} + + + ${bars} + `; +} + +/* ─── Dashboard (역할별 분기) ─────────────────────── */ +function renderDashboard() { + const role = _userInfo.role || "ADMIN"; + const d = dashCache || {}; + const view = document.getElementById("view-dashboard"); + if (!view) return; + + // 역할 전환 시 이전 역할 전용 동적 요소 정리 + document.getElementById("eng-wl-bar")?.remove(); + document.getElementById("workload-mini-card")?.remove(); + + if (role === "ADMIN") renderDashboardAdmin(d); + else if (role === "ENGINEER") renderDashboardEngineer(d); + else if (role === "PM") renderDashboardPM(d); + else if (role === "CUSTOMER") renderDashboardCustomer(d); + else renderDashboardAdmin(d); +} + +/* ── 공통 헬퍼 ─────────────────────────────────── */ +function _srRow(sr, extra = "") { + return ` +
+ ${sr.sr_id} + ${esc(sr.title)} + ${STATUS_LABEL[sr.status] || sr.status} + ${extra} +
`; +} + +function _statCard(value, label, color = "accent", sub = "", icon = "") { + return `
+ ${icon ? `
${icon}
` : ""} +
${value}
+
${label}
+ ${sub ? `
${sub}
` : ""} +
`; +} + +function _statusBarChart(byStatus) { + const total = Object.values(byStatus).reduce((a, b) => a + b, 0) || 1; + return `
` + + Object.entries(byStatus).sort((a,b) => b[1]-a[1]).map(([k,v]) => ` +
+
+ ${STATUS_LABEL[k]||k}${v} +
+
+
+
+
`).join("") + `
`; +} + +/* ── ADMIN 대시보드 ─────────────────────────────── */ +function renderDashboardAdmin(d) { + const kpi = d.kpi || {}; + document.getElementById("dash-greeting")?.remove(); + + document.getElementById("stats-row").innerHTML = ` + ${_statCard(kpi.total || 0, "전체 SR", "accent", "", "📋")} + ${_statCard(kpi.pending_approval || 0, "승인 대기", "yellow", "", "🔔")} + ${_statCard(kpi.in_progress || 0, "진행 중", "cyan", "", "⚙️")} + ${_statCard(kpi.completed_today || 0, "오늘 완료", "green", "", "✅")} + ${_statCard(kpi.failed || 0, "롤백 실패", "red", "", "⚠️")} + ${_statCard((kpi.auto_rate || 0) + "%", "AI 자동처리율","purple", "", "🤖")} + `; + + // 최근 SR + const recent = (d.recent_srs || []).slice(0, 10); + document.getElementById("recent-list").innerHTML = recent.map(sr => _srRow(sr, + `${fmtDate(sr.created_at)}` + )).join("") || '
SR이 없습니다.
'; + + // 상태별 차트 (srCache 기반) + const bs = {}; + srCache.forEach(sr => { bs[sr.status] = (bs[sr.status]||0)+1; }); + document.getElementById("status-chart").innerHTML = _statusBarChart(bs); + + // 7일 추이 차트 + loadTrend(); + + // 승인 대기 목록 (workload 카드에 표시) + const pendEl = document.getElementById("workload-body"); + const pendList = (d.pending_srs || []); + const pendHTML = pendList.length + ? pendList.map(sr => ` +
+ ${sr.sr_id} + ${esc(sr.title)} + ${PRIORITY_LABEL[sr.priority]||sr.priority} + ${esc(sr.requested_by||"")} +
`).join("") + : '
승인 대기 SR 없음
'; + + // workload 카드 헤더 변경 + const wlCard = document.getElementById("workload-card"); + if (wlCard) { + wlCard.querySelector(".card-header").innerHTML = ` + 🟡 승인 대기 (${pendList.length}건) + 클릭하여 상세 확인`; + if (pendEl) pendEl.innerHTML = pendHTML; + } + + // 엔지니어 워크로드 mini (status-chart 하단에 추가) + renderWorkloadMini(d.workload || workloadCache); +} + +function renderWorkloadMini(wl) { + // 기존 mini 제거 + document.getElementById("workload-mini-card")?.remove(); + const main = document.getElementById("view-dashboard"); + if (!main || !wl?.length) return; + const div = document.createElement("div"); + div.id = "workload-mini-card"; + div.className = "card"; + div.style.marginTop = "16px"; + div.innerHTML = ` +
👷 엔지니어 워크로드
+
+
+ ${wl.map(e => { + const pct = e.utilization || 0; + const color = pct >= 80 ? "#f85149" : pct >= 50 ? "#e3b341" : "#3fb950"; + return ` +
+
+
${(e.display_name||"?").charAt(0)}
+
+
${esc(e.display_name||e.username)}
+
${(e.skill_types||"").split(",").filter(Boolean).join("·")}
+
+
${e.active}/${e.max_workload}
+
+
+
+
+
`; + }).join("")} +
+
`; + main.appendChild(div); +} + +/* ── ENGINEER 대시보드 ──────────────────────────── */ +function renderDashboardEngineer(d) { + const wl = d.my_workload || {}; + const pct = wl.utilization || 0; + const barColor = pct >= 80 ? "#f85149" : pct >= 50 ? "#e3b341" : "#3fb950"; + const skills = (wl.skills || "").split(",").filter(Boolean) + .map(s => ({ DEPLOY:"배포", RESTART:"재기동", LOG:"로그", INQUIRY:"문의" }[s]||s)); + + document.getElementById("stats-row").innerHTML = ` +
+
+
${(_userInfo.display_name||"E").charAt(0)}
+
+
${esc(d.greeting||"")}
+
${skills.map(s=>`${s}`).join("")}
+
+
+
+ ${_statCard(wl.active||0, "담당 중", "accent", `최대 ${wl.max||5}건`)} + ${_statCard(wl.completed_month||0, "이번 달 완료", "green")} + ${_statCard(wl.completed_total||0, "누적 완료", "purple")} + `; + + // 워크로드 바 (중복 방지) + document.getElementById("eng-wl-bar")?.remove(); + const statsRowEl = document.getElementById("stats-row"); + statsRowEl.insertAdjacentHTML("afterend", ` +
+
+ 현재 워크로드${wl.active||0}/${wl.max||5}건 (${pct}%) +
+
+
+
+
`); + + // 처리 대기 (APPROVED — 바로 시작 가능) + const ready = d.action_required || []; + document.getElementById("recent-list").innerHTML = ` +
+ ⚡ 즉시 실행 가능 (APPROVED, ${ready.length}건) +
` + + (ready.map(sr => ` +
+ ${sr.sr_id} + ${esc(sr.title)} + ${PRIORITY_LABEL[sr.priority]||sr.priority} + +
`).join("") || + '
처리 대기 SR 없음
'); + + // 진행 중 + const inprog = d.in_progress || []; + document.getElementById("status-chart").innerHTML = ` +
+ 🔄 진행 중 (${inprog.length}건) +
` + + (inprog.map(sr => _srRow(sr, `${fmtDate(sr.updated_at)}`)).join("") || + '
진행 중 SR 없음
'); + + // 워크로드 카드: 최근 완료 + const done = d.recent_completed || []; + const wlEl = document.getElementById("workload-card"); + if (wlEl) { + wlEl.querySelector(".card-header").innerHTML = `✅ 최근 완료 (${done.length}건)`; + const body = document.getElementById("workload-body"); + if (body) body.innerHTML = done.map(sr => + _srRow(sr, `${fmtDate(sr.updated_at)}`) + ).join("") || '
완료 이력 없음
'; + } +} + +/* ── PM 대시보드 ────────────────────────────────── */ +function renderDashboardPM(d) { + const pendCnt = d.pending_count || 0; + document.getElementById("stats-row").innerHTML = ` +
+
${pendCnt}
+
승인 대기
+ ${pendCnt > 0 ? '
즉시 처리 필요
' : ""} +
+ ${(d.inst_stats||[]).map(i => _statCard(i.total, i.inst_name, "accent", `진행 ${i.active} · 완료 ${i.completed}`)).join("")} + `; + + // 승인 대기 큐 (메인 카드) + const pend = d.pending_srs || []; + document.getElementById("recent-list").innerHTML = ` +
+ 🔔 승인 대기 큐 — 결재 필요 +
` + + pend.map(sr => ` +
+ ${sr.sr_id} + ${esc(sr.title)} + ${PRIORITY_LABEL[sr.priority]||sr.priority} + ${esc(sr.requested_by||"")} +
+ + +
+
`).join("") || + '
승인 대기 SR 없음 ✨
'; + + // 엔지니어 워크로드 + const wlEl = document.getElementById("workload-card"); + if (wlEl) { + wlEl.querySelector(".card-header").innerHTML = `👷 엔지니어 워크로드`; + const body = document.getElementById("workload-body"); + if (body) body.innerHTML = `
` + + (d.workload||[]).map(e => { + const pct = e.utilization||0; + const color = pct>=80?"#f85149":pct>=50?"#e3b341":"#3fb950"; + return `
+
+
${(e.display_name||"?").charAt(0)}
+
${esc(e.display_name||e.username)}
+
${e.active}/${e.max_workload}
+
+
+
`; + }).join("") + `
`; + } + + // 상태 차트 + const bs = {}; + srCache.forEach(sr => { bs[sr.status] = (bs[sr.status]||0)+1; }); + document.getElementById("status-chart").innerHTML = _statusBarChart(bs); + + // PM도 추이 차트 표시 + loadTrend(); +} + +/* ── CUSTOMER 대시보드 ──────────────────────────── */ +function renderDashboardCustomer(d) { + const st = d.stats || {}; + document.getElementById("stats-row").innerHTML = ` +
+
소속 기관
+
${esc(d.inst_name||d.inst_code||"—")}
+
+ ${_statCard(st.total||0, "전체 SR")} + ${_statCard(st.active||0, "진행 중", "accent")} + ${_statCard(st.completed||0, "완료", "green")} + ${d.avg_rating != null ? _statCard("★".repeat(Math.round(d.avg_rating||0)) + ` ${d.avg_rating}`, "평균 만족도", "yellow") : ""} + `; + + // 진행 중 SR + const activeSRs = d.active_srs || []; + document.getElementById("recent-list").innerHTML = ` +
+ 🔄 진행 중인 요청 (${activeSRs.length}건) +
` + + activeSRs.map(sr => ` +
+ ${sr.sr_id} + ${esc(sr.title)} + ${STATUS_LABEL[sr.status]||sr.status} + ${esc(sr.assigned_to||"처리 중")} +
`).join("") || + '
진행 중인 요청이 없습니다.
'; + + // 상태 현황 + const bs = {}; + srCache.forEach(sr => { bs[sr.status] = (bs[sr.status]||0)+1; }); + document.getElementById("status-chart").innerHTML = _statusBarChart(bs); + + // 워크로드 카드: 최근 완료 + const done = d.recent_completed || []; + const wlEl = document.getElementById("workload-card"); + if (wlEl) { + wlEl.querySelector(".card-header").innerHTML = `✅ 최근 완료된 요청`; + const body = document.getElementById("workload-body"); + if (body) body.innerHTML = done.map(sr => ` +
+ ${sr.sr_id} + ${esc(sr.title)} + 완료 + ${fmtDate(sr.updated_at)} +
`).join("") || + '
완료된 요청 없음
'; + } +} + +/* ── PM 빠른 승인/반려 ──────────────────────────── */ +async function quickApprove(srId, result) { + const actor = _userInfo.display_name || _userInfo.username || "PM"; + const r = await authFetch(`/api/approvals/${srId}`, { + method: "POST", + body: JSON.stringify({ approver: actor, result, comment: "대시보드 빠른 처리" }), + }); + if (r.ok) { await loadAll(); } + else { const e = await r.json().catch(()=>({})); alert(e.detail||"처리 실패"); } +} + +/* ─── 엔지니어 워크로드 패널 ─────────────────────── */ +function renderWorkload() { + const el = document.getElementById("workload-body"); + if (!el) return; + + if (!workloadCache.length) { + el.innerHTML = '
등록된 엔지니어 프로필 없음
'; + return; + } + + const skillLabel = { DEPLOY:"배포", RESTART:"재기동", LOG:"로그", INQUIRY:"문의", OTHER:"기타" }; + const canAssign = ["ADMIN","PM"].includes(_userInfo.role || ""); + + el.innerHTML = ` +
+ ${workloadCache.map(eng => { + const pct = eng.utilization; + const color = pct >= 80 ? "#f85149" : pct >= 50 ? "#e3b341" : "#3fb950"; + const skills = (eng.skill_types || "").split(",").filter(Boolean) + .map(s => `${skillLabel[s]||s}`).join(""); + const aff = (eng.inst_affinity || "").split(",").filter(Boolean) + .map(a => `${a}`).join(""); + const assignBtn = canAssign + ? `` + : ""; + return ` +
+
+
${eng.display_name.charAt(0)}
+
+
${esc(eng.display_name)}
+
${eng.username}
+
+
+ ${eng.active}/${eng.max_workload} +
+
+
+
+
+
${skills}${aff}
+
완료 ${eng.completed}건
+ ${assignBtn} +
`; + }).join("")} +
`; +} + +function openAutoAssignModal(e, username) { + e.stopPropagation(); + const eng = workloadCache.find(e => e.username === username); + if (!eng) return; + const active = srCache.filter(sr => + sr.assigned_to === username && + !["COMPLETED","FAILED_ROLLBACK","REJECTED"].includes(sr.status) + ); + const html = active.length + ? active.map(sr => ` +
+ ${sr.sr_id} + ${esc(sr.title)} + ${STATUS_LABEL[sr.status]||sr.status} +
`).join("") + : '
현재 담당 중인 SR 없음
'; + + document.getElementById("modal-body").innerHTML = ` + +
+ 활성 ${eng.active}건 | 완료 ${eng.completed}건 | 이용률 ${eng.utilization}% +
+ ${html}`; + document.getElementById("modal-overlay").classList.remove("hidden"); +} + +/* ─── Kanban ────────────────────────────────────── */ +function renderKanban() { + const board = document.getElementById("kanban-board"); + board.innerHTML = ""; + KANBAN_COLS.forEach(col => { + const cards = srCache.filter(sr => sr.status === col.key); + const colEl = document.createElement("div"); + colEl.className = "kanban-col"; + colEl.innerHTML = ` +
+ ${col.label} + ${cards.length} +
+
`; + board.appendChild(colEl); + + const cardsEl = colEl.querySelector(`#col-${col.key}`); + cards.forEach(sr => { + const card = document.createElement("div"); + card.className = "kanban-card"; + card.innerHTML = ` +
${sr.sr_id}
+
${esc(sr.title)}
+
+ ${TYPE_LABEL[sr.sr_type] || sr.sr_type} + ${PRIORITY_LABEL[sr.priority] || sr.priority} +
`; + card.addEventListener("click", () => openDetail(sr.sr_id)); + cardsEl.appendChild(card); + }); + }); +} + +/* ─── SR List ───────────────────────────────────── */ +function setupListFilters() { + document.getElementById("search-input").addEventListener("input", renderList); + document.getElementById("filter-status").addEventListener("change", renderList); + document.getElementById("filter-type").addEventListener("change", renderList); +} + +function renderList() { + const keyword = document.getElementById("search-input").value.toLowerCase(); + const fStatus = document.getElementById("filter-status").value; + const fType = document.getElementById("filter-type").value; + + let rows = srCache; + if (keyword) rows = rows.filter(r => r.title.toLowerCase().includes(keyword) || r.sr_id.toLowerCase().includes(keyword)); + if (fStatus) rows = rows.filter(r => r.status === fStatus); + if (fType) rows = rows.filter(r => r.sr_type === fType); + + document.getElementById("sr-tbody").innerHTML = rows.map(sr => { + const engInfo = workloadCache.find(e => e.username === sr.assigned_to); + const engChip = sr.assigned_to + ? `${esc(engInfo?.display_name || sr.assigned_to)}` + : `미배정`; + return ` + + ${sr.sr_id} + ${TYPE_LABEL[sr.sr_type] || sr.sr_type} + ${esc(sr.title)} + ${STATUS_LABEL[sr.status] || sr.status} + ${PRIORITY_LABEL[sr.priority] || sr.priority} + ${engChip} + ${esc(sr.requested_by || "")} + ${fmtDate(sr.created_at)} + `; + }).join("") || `결과 없음`; +} + +const WORK_ACTION_LABEL = { + CMDB_CHECK:"🔍 자산 확인", SSH_CONNECT:"🔗 SSH 접속", + SSH_EXEC:"⚡ 명령 실행", SOURCE_MOD:"📦 파일 배포", + HEALTH_CHECK:"💓 헬스체크", RESULT:"📋 결과 기록", COMPLETE:"✅ 완료 처리", +}; + +/* ─── 첨부파일 헬퍼 ────────────────────────────── */ +function _fileIcon(name) { + const ext = (name.split(".").pop() || "").toLowerCase(); + return { + pdf:"📄", png:"🖼️", jpg:"🖼️", jpeg:"🖼️", gif:"🖼️", webp:"🖼️", + xlsx:"📊", xls:"📊", docx:"📝", doc:"📝", pptx:"📊", ppt:"📊", + zip:"🗜️", tar:"🗜️", gz:"🗜️", + sh:"⚙️", sql:"🗄️", json:"📋", yaml:"📋", yml:"📋", md:"📋", + log:"📜", txt:"📜", csv:"📊", + }[ext] || "📎"; +} + +function _fmtSize(bytes) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + +function _renderAttachments(atts, srId) { + if (!atts || atts.length === 0) + return `
첨부파일 없음
`; + return `
` + atts.map(a => ` +
+ ${_fileIcon(a.original_name)} + ${esc(a.original_name)} + ${_fmtSize(a.file_size)} + ${esc(a.uploaded_by)} + ${fmtDate(a.created_at)} + +
`).join("") + `
`; +} + +async function uploadAttachments(srId, input) { + if (!input.files.length) return; + const ffd = new FormData(); + for (const f of input.files) ffd.append("files", f); + const r = await authFetch(`/api/tasks/${srId}/attachments`, { method: "POST", body: ffd }); + if (r.ok) { + const newAtts = await authFetch(`/api/tasks/${srId}/attachments`).then(x => x.json()).catch(() => []); + const el = document.getElementById(`att-list-${srId}`); + if (el) el.innerHTML = _renderAttachments(newAtts, srId); + } else { + const err = await r.json().catch(() => ({})); + alert(err.detail || "파일 업로드 실패"); + } + input.value = ""; +} + +async function deleteAttachment(srId, attId, btn) { + if (!confirm("첨부파일을 삭제하시겠습니까?")) return; + const r = await authFetch(`/api/tasks/${srId}/attachments/${attId}`, { method: "DELETE" }); + if (r.ok) { + const newAtts = await authFetch(`/api/tasks/${srId}/attachments`).then(x => x.json()).catch(() => []); + const el = document.getElementById(`att-list-${srId}`); + if (el) el.innerHTML = _renderAttachments(newAtts, srId); + } else { + const err = await r.json().catch(() => ({})); + alert(err.detail || "삭제 실패"); + } +} + +/* ─── SR Detail Modal ───────────────────────────── */ +async function openDetail(srId) { + const sr = srCache.find(s => s.sr_id === srId); + if (!sr) return; + + const [approvalsRes, auditRes, workRes, ratingRes, attachmentsRes] = await Promise.all([ + authFetch(`/api/approvals/${srId}`).then(r => r.json()).catch(() => []), + authFetch(`/api/audit?sr_id=${srId}`).then(r => r.json()).catch(() => []), + authFetch(`/api/work/${srId}`).then(r => r.json()).catch(() => []), + authFetch(`/api/rating/${srId}`).then(r => r.ok ? r.json() : null).catch(() => null), + authFetch(`/api/tasks/${srId}/attachments`).then(r => r.json()).catch(() => []), + ]); + + const approvalHTML = approvalsRes.length + ? approvalsRes.map(a => ` +
+ ${esc(a.approver)} + + ${a.result === "APPROVED" ? "승인" : a.result === "REJECTED" ? "반려" : "대기"} + + ${a.comment ? `${esc(a.comment)}` : ""} +
`).join("") + : '
승인 기록 없음
'; + + const auditHTML = auditRes.slice(0, 10).map(log => ` +
+
${esc(log.action)} by ${esc(log.actor || "system")}
+
${esc(log.detail || "")}  #${(log.log_hash || "").slice(0, 12)}
+
`).join("") || '
기록 없음
'; + + const canApprove = sr.status === "PENDING_APPROVAL"; + const canSimulate = !["COMPLETED","FAILED_ROLLBACK","REJECTED"].includes(sr.status); + const canAssign = ["ADMIN","PM"].includes(_userInfo.role || ""); + + /* 작업 로그 HTML */ + const workHTML = workRes.length + ? `
` + workRes.map(w => ` +
+
${WORK_ACTION_LABEL[w.action_type] || w.action_type} + by ${esc(w.engineer||"AI")} +
+
${esc(w.content||"")} + ${w.result ? `
${esc(w.result.slice(0,120))}` : ""} +
+
`).join("") + `
` + : `
작업 이력 없음
`; + + /* 별점 HTML */ + const ratingHTML = ratingRes + ? `
${"★".repeat(ratingRes.stars)}${"☆".repeat(5-ratingRes.stars)} + ${esc(ratingRes.customer||"")} + ${ratingRes.comment ? `— ${esc(ratingRes.comment)}` : ""}
` + : sr.status === "COMPLETED" + ? `
+
고객 만족도 평가
+
+ ${[1,2,3,4,5].map(n=>``).join("")} +
+
` + : ""; + + document.getElementById("modal-body").innerHTML = ` + + + + + + ${sr.description ? ` + ` : ""} + + + + + + ${ratingHTML ? ` + ` : ""} + + + + + + + + + ${canApprove ? ` + ` : ""} + `; + + document.getElementById("modal-overlay").classList.remove("hidden"); + + // KB 추천 비동기 로드 (모달 렌더 후) + setTimeout(() => loadKBSuggestForSR(srId), 100); +} + +async function doApproval(srId, result) { + const approver = document.getElementById("approver-name").value.trim(); + const comment = document.getElementById("approver-comment").value.trim(); + if (!approver) { alert("승인자 이름을 입력하세요."); return; } + + const r = await authFetch(`/api/approvals/${srId}`, { + method: "POST", + body: JSON.stringify({ approver, result, comment: comment || null }), + }); + if (r.ok) { + document.getElementById("modal-overlay").classList.add("hidden"); + await loadAll(); + } else { + const err = await r.json().catch(() => ({})); + alert(err.detail || "처리 중 오류 발생"); + } +} + +/* ─── Simulate + Rating from modal ─────────────── */ +async function runSimulate(srId) { + const btn = document.getElementById(`btn-simulate-${srId}`); + const lbl = document.getElementById(`sim-status-${srId}`); + if (btn) { btn.disabled = true; btn.textContent = "⏳ 실행 중…"; } + if (lbl) lbl.textContent = "CMDB 확인 → SSH 접속 → 작업 수행 중…"; + + const r = await authFetch(`/api/work/${srId}/simulate`, { method: "POST" }); + if (r.ok) { + if (lbl) lbl.textContent = "✅ 완료! 메신저에 알림이 전송됩니다."; + await loadAll(); + setTimeout(() => openDetail(srId), 600); + } else { + const err = await r.json().catch(() => ({})); + if (lbl) lbl.textContent = "❌ " + (err.detail || "오류 발생"); + if (btn) { btn.disabled = false; btn.textContent = "⚡ AI 작업 실행 시뮬레이션"; } + } +} + +async function rateFromModal(srId, stars) { + const customer = prompt("평가자 이름을 입력하세요:", "고객"); + if (!customer) return; + const r = await authFetch(`/api/rating/${srId}`, { + method: "POST", + body: JSON.stringify({ customer, stars, comment: null }), + }); + if (r.ok) { + const widget = document.getElementById("star-widget"); + if (widget) widget.innerHTML = `
${"★".repeat(stars)}${"☆".repeat(5-stars)} — 평가 감사합니다!
`; + } +} + +document.getElementById("modal-close-btn").addEventListener("click", () => + document.getElementById("modal-overlay").classList.add("hidden") +); +document.getElementById("modal-overlay").addEventListener("click", e => { + if (e.target === e.currentTarget) e.currentTarget.classList.add("hidden"); +}); + +/* ─── 엔지니어 재배정 패널 ───────────────────────── */ +async function openReassignPanel(srId) { + let engineers = []; + try { + const r = await authFetch("/api/assign/engineers"); + engineers = await r.json(); + } catch {} + + const panel = document.createElement("div"); + panel.id = "reassign-panel"; + panel.style.cssText = "margin-top:10px;padding:10px;background:var(--surface-2);border-radius:6px;border:1px solid var(--border)"; + panel.innerHTML = ` +
담당 엔지니어 변경
+
+ + + +
`; + + // 기존 패널 있으면 제거 + document.getElementById("reassign-panel")?.remove(); + const assignCell = document.getElementById("assign-cell"); + if (assignCell) assignCell.appendChild(panel); +} + +async function submitReassign(srId) { + const sel = document.getElementById("reassign-select"); + const eng = sel ? sel.value : ""; // "" → 자동 배정 + + const url = `/api/assign/${srId}${eng ? `?engineer=${encodeURIComponent(eng)}` : ""}`; + const r = await authFetch(url, { method: "POST" }); + if (r.ok) { + const data = await r.json(); + document.getElementById("reassign-panel")?.remove(); + await loadWorkload(); + await loadAll(); + openDetail(srId); + } else { + const err = await r.json().catch(() => ({})); + alert(err.detail || "배정 실패"); + } +} + +/* ─── New SR Modal ──────────────────────────────── */ +function setupNewSR() { + document.getElementById("btn-new-sr").addEventListener("click", () => + document.getElementById("new-sr-overlay").classList.remove("hidden") + ); + document.getElementById("new-sr-close").addEventListener("click", () => + document.getElementById("new-sr-overlay").classList.add("hidden") + ); + document.getElementById("new-sr-overlay").addEventListener("click", e => { + if (e.target === e.currentTarget) e.currentTarget.classList.add("hidden"); + }); + // 파일 선택 미리보기 + document.getElementById("sr-file-input").addEventListener("change", e => { + const files = Array.from(e.target.files || []); + const txt = document.getElementById("file-upload-text"); + const prev = document.getElementById("file-preview-list"); + txt.textContent = files.length + ? `${files.length}개 파일 선택됨` + : "첨부파일 선택 (선택사항, 최대 10개 · 파일당 20 MB)"; + prev.innerHTML = files.map(f => ` +
+ ${_fileIcon(f.name)} + ${esc(f.name)} + ${_fmtSize(f.size)} +
`).join(""); + }); + + document.getElementById("new-sr-form").addEventListener("submit", async e => { + e.preventDefault(); + const fd = new FormData(e.target); + const payload = Object.fromEntries(fd.entries()); + // remove empty optional fields + if (!payload.description) delete payload.description; + if (!payload.target_server) delete payload.target_server; + if (!payload.inst_code) delete payload.inst_code; + + const r = await authFetch("/api/tasks", { + method: "POST", + body: JSON.stringify(payload), + }); + if (!r.ok) { + const err = await r.json().catch(() => ({})); + alert(err.detail || "SR 생성 실패"); + return; + } + const sr = await r.json(); + + // 파일 업로드 (선택된 경우) + const fileInput = document.getElementById("sr-file-input"); + if (fileInput.files.length > 0) { + const ffd = new FormData(); + for (const f of fileInput.files) ffd.append("files", f); + const ur = await authFetch(`/api/tasks/${sr.sr_id}/attachments`, { + method: "POST", + body: ffd, + }); + if (!ur.ok) { + const uerr = await ur.json().catch(() => ({})); + alert(`SR이 생성되었으나 파일 업로드 실패: ${uerr.detail || "알 수 없는 오류"}`); + } + } + + document.getElementById("new-sr-overlay").classList.add("hidden"); + e.target.reset(); + document.getElementById("file-upload-text").textContent = "첨부파일 선택 (선택사항, 최대 10개 · 파일당 20 MB)"; + document.getElementById("file-preview-list").innerHTML = ""; + await loadAll(); + }); +} + +/* ─── Audit ─────────────────────────────────────── */ +async function loadAudit() { + const data = await authFetch("/api/audit?limit=100").then(r => r.json()).catch(() => []); + document.getElementById("audit-tbody").innerHTML = data.map((log, i) => ` + + ${i + 1} + ${esc(log.sr_id || "—")} + ${esc(log.actor || "system")} + ${esc(log.action)} + ${esc(log.detail || "")} + ${(log.log_hash || "").slice(0, 12)} + ${fmtDate(log.created_at)} + `).join("") || `기록 없음`; + + document.getElementById("btn-verify").addEventListener("click", async () => { + const res = await authFetch("/api/audit/verify").then(r => r.json()); + const el = document.getElementById("verify-result"); + if (res.intact) { + el.textContent = "✅ 체인 무결성 확인됨"; + el.className = "ok"; + } else { + el.textContent = `❌ 변조 감지 (ID: ${res.broken_at_id})`; + el.className = "fail"; + } + }); +} + +/* ─── CMDB ───────────────────────────────────────── */ +async function loadCmdb() { + const institutions = await authFetch("/api/cmdb/institutions").then(r => r.json()).catch(() => []); + const grid = document.getElementById("cmdb-grid"); + grid.innerHTML = ""; + + await Promise.all(institutions.map(async inst => { + const servers = await authFetch(`/api/cmdb/institutions/${inst.inst_code}/servers`) + .then(r => r.json()).catch(() => []); + const card = document.createElement("div"); + card.className = "cmdb-card"; + card.innerHTML = ` +
+ ${esc(inst.inst_name)} + ${esc(inst.inst_code)} +
+
+ ${servers.map(s => ` +
+ ${s.server_role} + ${esc(s.server_name)} + ${esc(s.os_type || "")} + ${s.is_active ? "● 정상" : "● 비활성"} +
`).join("") || '
서버 없음
'} +
+ ${inst.contact_pm ? `
PM: ${esc(inst.contact_pm)}
` : ""} + `; + grid.appendChild(card); + })); +} + +/* ─── Knowledge Base ────────────────────────────── */ +const KB_CAT_COLOR = { + JAVA: "#f0883e", MIDDLEWARE: "#58a6ff", DB: "#e3b341", + WEB: "#3fb950", OS: "#bc8cff", SECURITY: "#f85149", +}; + +async function loadKBView() { + // 초기 로드: 전체 목록 표시 + const cat = document.getElementById("kb-category-filter")?.value || ""; + const url = `/api/kb/list${cat ? `?category=${encodeURIComponent(cat)}` : ""}`; + try { + const docs = await authFetch(url).then(r => r.json()); + renderKBDocs(docs.map(d => ({ doc: d, score: null, matched_keywords: [] }))); + } catch { document.getElementById("kb-results").innerHTML = '
로드 실패
'; } + + // 검색 이벤트 연결 (한 번만) + const inp = document.getElementById("kb-search-input"); + if (inp && !inp._bound) { + inp._bound = true; + inp.addEventListener("keydown", e => { if (e.key === "Enter") searchKB(); }); + } +} + +async function searchKB() { + const q = document.getElementById("kb-search-input")?.value.trim(); + const cat = document.getElementById("kb-category-filter")?.value || ""; + const el = document.getElementById("kb-results"); + + if (!q) { loadKBView(); return; } + + el.innerHTML = '
검색 중…
'; + try { + const url = `/api/kb?q=${encodeURIComponent(q)}&limit=10`; + let hits = await authFetch(url).then(r => r.json()); + if (cat) hits = hits.filter(h => h.doc.category === cat); + renderKBDocs(hits); + } catch { el.innerHTML = '
검색 실패
'; } +} + +function renderKBDocs(hits) { + const el = document.getElementById("kb-results"); + if (!hits.length) { + el.innerHTML = '
관련 문서 없음
'; + return; + } + el.innerHTML = hits.map(h => renderKBCard(h, false)).join(""); +} + +function renderKBCard(h, compact = false) { + const d = h.doc; + const color = KB_CAT_COLOR[d.category] || "#8b949e"; + const scoreHTML = h.score !== null + ? `관련도 ${Math.round(h.score * 100)}%` + : ""; + const kwHTML = h.matched_keywords?.length + ? `
${h.matched_keywords.map(k => `${esc(k)}`).join(" ")}
` + : ""; + + if (compact) { + // 모달 내 축약형 + return ` +
+
+ ${d.category} + ${esc(d.title)} + ${scoreHTML} +
+ ${kwHTML} +
`; + } + + // 전체 카드 + return ` +
+
+
+ ${d.category} + ${esc(d.title)} +
+
+ ${scoreHTML} + +
+
+ ${kwHTML ? `
${kwHTML}
` : ""} + +
`; +} + +function toggleKBCard(docId) { + const body = document.getElementById(`kb-body-${docId}`); + const icon = document.querySelector(`#kb-card-${docId} .kb-toggle-icon`); + if (!body) return; + const open = body.style.display === "none"; + body.style.display = open ? "block" : "none"; + if (icon) icon.textContent = open ? "▲" : "▼"; +} + +async function openKBDetail(docId) { + // KB 뷰로 이동 후 해당 카드 펼치기 + switchView("kb"); + await loadKBView(); + setTimeout(() => { + const card = document.getElementById(`kb-card-${docId}`); + if (card) { + card.scrollIntoView({ behavior: "smooth", block: "center" }); + const body = document.getElementById(`kb-body-${docId}`); + if (body && body.style.display === "none") toggleKBCard(docId); + } + }, 400); +} + +/* SR 모달 내 KB 추천 렌더링 */ +async function loadKBSuggestForSR(srId) { + const el = document.getElementById("kb-suggest-section"); + if (!el) return; + el.innerHTML = '
분석 중…
'; + try { + const hits = await authFetch(`/api/kb/suggest/${srId}`).then(r => r.json()); + if (!hits.length) { + el.innerHTML = '
관련 문서 없음
'; + return; + } + el.innerHTML = hits.map(h => renderKBCard(h, true)).join(""); + } catch { + el.innerHTML = '
추천 로드 실패
'; + } +} + +/* ─── AI 채팅 어시스턴트 ─────────────────────────── */ +const _CHAT_GREET = [ + "안녕하세요! GUARDiA AI 어시스턴트입니다.", + "자연어로 ITSM 업무를 처리할 수 있습니다.", + "예시 명령을 클릭하거나 직접 입력해 보세요.", +]; +const _CHAT_EXAMPLES = [ + "승인 대기 SR 목록 보여줘", + "엔지니어 워크로드 현황", + "KB에서 OOM 검색해줘", + "전체 현황 요약해줘", + "긴급 SR 있어?", +]; + +let _chatHistory = []; + +function initChat() { + const fab = document.getElementById("ai-chat-fab"); + const panel = document.getElementById("ai-chat-panel"); + const close = document.getElementById("ai-chat-close"); + const inp = document.getElementById("ai-chat-input"); + const send = document.getElementById("ai-chat-send"); + + fab.addEventListener("click", () => { + const open = panel.classList.toggle("hidden"); + if (!open && _chatHistory.length === 0) { + // 첫 오픈 시 인사 메시지 + appendChatMsg("ai", _CHAT_GREET.join("\n")); + renderSuggestions(_CHAT_EXAMPLES); + } + }); + close.addEventListener("click", () => panel.classList.add("hidden")); + + send.addEventListener("click", sendChat); + inp.addEventListener("keydown", e => { + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendChat(); } + }); +} + +function appendChatMsg(role, text, data) { + const el = document.getElementById("ai-chat-messages"); + const div = document.createElement("div"); + div.className = role === "user" ? "chat-msg chat-user" : "chat-msg chat-ai"; + + // 마크다운-라이크 렌더링 (bold, bullet) + const rendered = text + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/`(.+?)`/g, '$1') + .replace(/^• /gm, ' ') + .replace(/\n/g, '
'); + + div.innerHTML = rendered; + + // 데이터 링크 (SR 클릭 가능) + if (data?.length) { + const links = data.filter(d => d.sr_id).map(d => + `${d.sr_id}` + ); + if (links.length) { + const linksDiv = document.createElement("div"); + linksDiv.className = "chat-links"; + linksDiv.innerHTML = links.join(""); + div.appendChild(linksDiv); + } + } + + el.appendChild(div); + el.scrollTop = el.scrollHeight; + _chatHistory.push({ role, text }); +} + +function renderSuggestions(items) { + const el = document.getElementById("ai-chat-suggestions"); + el.innerHTML = items.map(s => + `` + ).join(""); +} + +function sendChatText(text) { + document.getElementById("ai-chat-input").value = text; + sendChat(); +} + +async function sendChat() { + const inp = document.getElementById("ai-chat-input"); + const text = inp.value.trim(); + if (!text) return; + inp.value = ""; + + document.getElementById("ai-chat-suggestions").innerHTML = ""; + appendChatMsg("user", text); + + // 로딩 표시 + const loadId = "chat-loading-" + Date.now(); + const el = document.getElementById("ai-chat-messages"); + el.insertAdjacentHTML("beforeend", + `
⏳ 처리 중…
` + ); + el.scrollTop = el.scrollHeight; + + try { + const r = await authFetch("/api/nlcmd", { + method: "POST", + body: JSON.stringify({ text }), + }); + const data = await r.json(); + document.getElementById(loadId)?.remove(); + + appendChatMsg("ai", data.response, data.data); + + // 액션 수행 시 데이터 갱신 + if (data.action_taken) { + await loadAll(); + } + + // 후속 제안 + if (data.suggestions?.length) { + renderSuggestions(data.suggestions); + } else if (data.intent === "QUERY_SR_LIST" && data.data?.length) { + const sample = data.data[0]; + renderSuggestions([ + `${sample.sr_id} 상태 알려줘`, + `${sample.sr_id} 자동 배정해줘`, + ]); + } else if (data.intent === "SEARCH_KB" && data.data?.length) { + renderSuggestions(["KB 뷰에서 전체 문서 보기"]); + } + } catch { + document.getElementById(loadId)?.remove(); + appendChatMsg("ai", "❌ 명령 처리 중 오류가 발생했습니다."); + } +} + +/* ══════════════════════════════════════════════════ + 기관 관리 뷰 +══════════════════════════════════════════════════ */ +let _instCache = []; +let _instCurrentCode = null; + +async function loadInstitutions() { + try { + const r = await authFetch("/api/institutions"); + _instCache = await r.json(); + renderInstitutionTable(_instCache); + } catch { _instCache = []; } +} + +function filterInstitutions() { + const kw = (document.getElementById("inst-search")?.value || "").toLowerCase(); + const region = document.getElementById("inst-region-filter")?.value || ""; + const filtered = _instCache.filter(i => { + const matchKw = !kw || i.inst_name?.toLowerCase().includes(kw) || i.inst_code?.toLowerCase().includes(kw); + const matchRegion = !region || i.region === region; + return matchKw && matchRegion; + }); + renderInstitutionTable(filtered); +} + +function renderInstitutionTable(list) { + const tbody = document.getElementById("inst-tbody"); + if (!tbody) return; + // CUSTOMER 역할이면 버튼 숨김 + const canEdit = ["ADMIN", "PM"].includes(_userInfo.role || ""); + if (!list.length) { + tbody.innerHTML = '등록된 기관이 없습니다.'; + return; + } + tbody.innerHTML = list.map(inst => { + const expiry = inst.contract_end ? new Date(inst.contract_end) : null; + const today = new Date(); + const daysLeft = expiry ? Math.ceil((expiry - today) / 86400000) : null; + let expiryBadge = expiry ? `${inst.contract_end}` : "-"; + if (daysLeft !== null && daysLeft <= 30 && daysLeft > 7) expiryBadge = `D-${daysLeft} ⚠`; + if (daysLeft !== null && daysLeft <= 7) expiryBadge = `D-${daysLeft} 🔴`; + return ` + ${esc(inst.inst_code)} + ${esc(inst.inst_name)} + ${esc(inst.region || "-")} + ${expiryBadge} + ${inst.sla_hours}h + ${inst.server_count ?? "-"}대 + ${inst.contact_count ?? "-"}명 + ${inst.is_active ? "활성" : "비활성"} + + ${canEdit ? `` : ""} + + `; + }).join(""); +} + +async function openInstDetail(instCode) { + _instCurrentCode = instCode; + const inst = _instCache.find(i => i.inst_code === instCode); + if (!inst) return; + // 담당자 목록 로드 후 상세 모달 생성 + let contacts = []; + try { + const r = await authFetch(`/api/institutions/${instCode}/contacts`); + contacts = await r.json(); + } catch {} + // 상세는 SR 상세 모달 재활용 + const canEdit = ["ADMIN", "PM"].includes(_userInfo.role || ""); + const roleLabel = {MANAGER:"담당자",ENGINEER:"엔지니어",PM:"PM",SECURITY:"보안",HELPDESK:"헬프데스크"}; + const html = ` + +
+
지역${esc(inst.region||"-")}
+
SLA${inst.sla_hours}시간
+
전화${esc(inst.phone||"-")}
+
계약 기간${inst.contract_start||"?"} ~ ${inst.contract_end||"?"}
+
주소${esc(inst.address||"-")}
+
비고${esc(inst.note||"-")}
+
+ + ${contacts.length ? ` + + + + ${contacts.map(c => ` + + + + + + + `).join("")} + +
이름역할부서이메일전화주담
${esc(c.contact_name)}${roleLabel[c.role]||c.role}${esc(c.dept||"-")}${esc(c.email||"-")}${esc(c.phone||c.mobile||"-")}${c.is_primary ? "★" : ""}
` : `
등록된 담당자가 없습니다.
`} + `; + document.getElementById("modal-body").innerHTML = html; + document.getElementById("modal-overlay").classList.remove("hidden"); +} + +// 기관 등록/수정 모달 +let _instEditCode = null; +function openInstModal(instCode = null) { + _instEditCode = instCode; + const overlay = document.getElementById("inst-modal-overlay"); + const form = document.getElementById("inst-form"); + form.reset(); + document.getElementById("inst-modal-title").textContent = instCode ? "기관 수정" : "기관 등록"; + if (instCode) { + const inst = _instCache.find(i => i.inst_code === instCode); + if (inst) { + Object.keys(inst).forEach(k => { + const el = form.elements[k]; + if (el) el.value = inst[k] ?? ""; + }); + } + } + overlay.classList.remove("hidden"); +} +function closeInstModal() { document.getElementById("inst-modal-overlay").classList.add("hidden"); } + +async function submitInstForm(e) { + e.preventDefault(); + const form = e.target; + const data = Object.fromEntries(new FormData(form)); + // 숫자 변환 + if (data.sla_hours) data.sla_hours = parseInt(data.sla_hours); + // 빈 문자열 null로 + Object.keys(data).forEach(k => { if (data[k] === "") data[k] = null; }); + try { + let r; + if (_instEditCode) { + r = await authFetch(`/api/institutions/${_instEditCode}`, { + method: "PATCH", body: JSON.stringify(data), + }); + } else { + r = await authFetch("/api/institutions", { + method: "POST", body: JSON.stringify(data), + }); + } + if (!r.ok) { const err = await r.json(); showToast(err.detail || "저장 실패", "error"); return; } + showToast(_instEditCode ? "기관 정보가 수정됐습니다." : "기관이 등록됐습니다.", "success"); + closeInstModal(); + loadInstitutions(); + } catch { showToast("저장 중 오류가 발생했습니다.", "error"); } +} + +// 담당자 등록 모달 +let _contactInstCode = null; +function openContactModal(instCode) { + _contactInstCode = instCode; + document.getElementById("contact-form").reset(); + document.getElementById("contact-modal-overlay").classList.remove("hidden"); +} +function closeContactModal() { document.getElementById("contact-modal-overlay").classList.add("hidden"); } + +async function submitContactForm(e) { + e.preventDefault(); + const form = e.target; + const data = Object.fromEntries(new FormData(form)); + data.is_primary = form.elements.is_primary?.checked || false; + Object.keys(data).forEach(k => { if (data[k] === "") data[k] = null; }); + try { + const r = await authFetch(`/api/institutions/${_contactInstCode}/contacts`, { + method: "POST", body: JSON.stringify(data), + }); + if (!r.ok) { const err = await r.json(); showToast(err.detail || "저장 실패", "error"); return; } + showToast("담당자가 등록됐습니다.", "success"); + closeContactModal(); + openInstDetail(_contactInstCode); + } catch { showToast("저장 중 오류가 발생했습니다.", "error"); } +} + +/* ══════════════════════════════════════════════════ + 스크립트 관리 뷰 +══════════════════════════════════════════════════ */ +let _scriptCache = []; + +const SCRIPT_CATEGORY_KO = { + SM: "SM", REGULAR: "정기점검", ADHOC: "수시점검", + DEPLOY: "배포", SECURITY: "보안", MONITORING: "모니터링", +}; +const SCRIPT_CATEGORY_COLOR = { + SM: "#818cf8", REGULAR: "#34d399", ADHOC: "#fbbf24", + DEPLOY: "#38bdf8", SECURITY: "#f87171", MONITORING: "#a78bfa", +}; + +async function loadScripts() { + try { + const r = await authFetch("/api/shell-scripts?limit=200"); + _scriptCache = await r.json(); + renderScriptList(_scriptCache); + } catch { _scriptCache = []; } +} + +function filterScripts() { + const kw = (document.getElementById("script-search")?.value || "").toLowerCase(); + const cat = document.getElementById("script-category-filter")?.value || ""; + const layer = document.getElementById("script-layer-filter")?.value || ""; + const filtered = _scriptCache.filter(s => { + const matchKw = !kw || s.script_name?.toLowerCase().includes(kw) + || s.description?.toLowerCase().includes(kw) + || (s.tags||"").toLowerCase().includes(kw); + const matchCat = !cat || s.category === cat; + const matchLayer = !layer || s.target_layer === layer || s.target_layer === "ALL"; + return matchKw && matchCat && matchLayer; + }); + renderScriptList(filtered); +} + +function renderScriptList(list) { + const body = document.getElementById("script-list-body"); + if (!body) return; + const canEdit = ["ADMIN", "PM", "ENGINEER"].includes(_userInfo.role || ""); + if (!list.length) { + body.innerHTML = '
등록된 스크립트가 없습니다.
'; + return; + } + body.innerHTML = list.map(s => { + const catColor = SCRIPT_CATEGORY_COLOR[s.category] || "#818cf8"; + const dangerBadge = s.is_dangerous + ? `⚠ 위험` : ""; + const approvalBadge = s.requires_approval + ? `승인필요` : ""; + return ` +
+
+
${esc(s.script_name)}
+
+ ${SCRIPT_CATEGORY_KO[s.category]||s.category} + ${esc(s.target_layer)} + ${dangerBadge}${approvalBadge} + ${canEdit ? `` : ""} +
+
+
${esc(s.description)}
+
+ 버전 ${esc(s.version)} + 사용 ${s.use_count}회 + ${s.tags ? `${esc(s.tags).split(",").map(t=>`#${t.trim()}`).join(" ")}` : ""} +
+
`; + }).join(""); +} + +function openScriptDetail(scriptId) { + const s = _scriptCache.find(x => x.id === scriptId); + if (!s) return; + const catColor = SCRIPT_CATEGORY_COLOR[s.category] || "#818cf8"; + const html = ` + +
+ ${SCRIPT_CATEGORY_KO[s.category]||s.category} + ${esc(s.target_layer)} + ${esc(s.os_type)} + ${s.is_dangerous ? `⚠ 위험 명령 포함` : ""} + ${s.requires_approval ? `실행 전 승인 필요` : ""} +
+
+
설명${esc(s.description)}
+
버전${esc(s.version)}
+
작성자${esc(s.author||"-")}
+
사용 횟수${s.use_count}회
+
+ +
${esc(s.script_body)}
+ ${s.sample_output ? `
${esc(s.sample_output)}
` : ""} + `; + document.getElementById("modal-body").innerHTML = html; + document.getElementById("modal-overlay").classList.remove("hidden"); +} + +let _scriptEditId = null; +function openScriptModal(scriptId = null) { + _scriptEditId = scriptId; + const overlay = document.getElementById("script-modal-overlay"); + const form = document.getElementById("script-form"); + form.reset(); + document.getElementById("script-modal-title").textContent = scriptId ? "스크립트 수정" : "스크립트 등록"; + if (scriptId) { + const s = _scriptCache.find(x => x.id === scriptId); + if (s) { + Object.keys(s).forEach(k => { + const el = form.elements[k]; + if (el) el.value = s[k] ?? ""; + }); + if (s.is_dangerous) form.elements.is_dangerous.checked = true; + if (s.requires_approval) form.elements.requires_approval.checked = true; + } + } + overlay.classList.remove("hidden"); +} +function closeScriptModal() { document.getElementById("script-modal-overlay").classList.add("hidden"); } + +async function submitScriptForm(e) { + e.preventDefault(); + const form = e.target; + const data = Object.fromEntries(new FormData(form)); + data.is_dangerous = form.elements.is_dangerous?.checked || false; + data.requires_approval = form.elements.requires_approval?.checked || false; + Object.keys(data).forEach(k => { if (data[k] === "") data[k] = null; }); + try { + let r; + if (_scriptEditId) { + r = await authFetch(`/api/shell-scripts/${_scriptEditId}`, { + method: "PATCH", body: JSON.stringify(data), + }); + } else { + r = await authFetch("/api/shell-scripts", { + method: "POST", body: JSON.stringify(data), + }); + } + if (!r.ok) { const err = await r.json(); showToast(err.detail || "저장 실패", "error"); return; } + showToast("스크립트가 저장됐습니다.", "success"); + closeScriptModal(); + loadScripts(); + } catch { showToast("저장 중 오류가 발생했습니다.", "error"); } +} + +/* ══════════════════════════════════════════════════ + 작업 타임테이블 뷰 +══════════════════════════════════════════════════ */ +let _ttCache = []; + +const WORK_TYPE_KO = { + REGULAR_CHECK: "정기점검", PM: "예방정비", SR: "SR작업", + ADHOC: "수시점검", DEPLOY: "배포", EMERGENCY: "긴급대응", +}; +const WORK_TYPE_COLOR = { + REGULAR_CHECK: "#34d399", PM: "#818cf8", SR: "#38bdf8", + ADHOC: "#fbbf24", DEPLOY: "#a78bfa", EMERGENCY: "#f87171", +}; +const RESULT_STATUS_KO = { + PENDING: "예정", SUCCESS: "완료", FAILED: "실패", + PARTIAL: "부분완료", CANCELLED: "취소", +}; +const RESULT_STATUS_BADGE = { + PENDING: "badge-PARSED", + SUCCESS: "badge-COMPLETED", + FAILED: "badge-FAILED_ROLLBACK", + PARTIAL: "badge-PENDING_APPROVAL", + CANCELLED: "badge-REJECTED", +}; + +async function loadTimetable() { + try { + const r = await authFetch("/api/timetable?limit=200"); + _ttCache = await r.json(); + // 기관·스크립트 목록 로드 (모달 select 채우기) + _populateTimetableSelects(); + renderTimetableTable(_ttCache); + } catch { _ttCache = []; } +} + +async function _populateTimetableSelects() { + // 기관 select + const instSel = document.getElementById("tt-inst-select"); + if (instSel && instSel.options.length <= 1) { + if (!_instCache.length) { + try { const r = await authFetch("/api/institutions"); _instCache = await r.json(); } catch {} + } + _instCache.forEach(i => { + const opt = document.createElement("option"); + opt.value = i.id; opt.textContent = `${i.inst_code} ${i.inst_name}`; + instSel.appendChild(opt); + }); + } + // 스크립트 select + const scriptSel = document.getElementById("tt-script-select"); + if (scriptSel && scriptSel.options.length <= 1) { + if (!_scriptCache.length) { + try { const r = await authFetch("/api/shell-scripts?limit=200"); _scriptCache = await r.json(); } catch {} + } + _scriptCache.forEach(s => { + const opt = document.createElement("option"); + opt.value = s.id; opt.textContent = `[${s.category}] ${s.script_name}`; + opt.dataset.body = s.script_body; + scriptSel.appendChild(opt); + }); + } +} + +function fillScriptBody(sel) { + const opt = sel.options[sel.selectedIndex]; + const ta = document.querySelector("#tt-form textarea[name='command_or_shell']"); + if (ta && opt?.dataset.body) ta.value = opt.dataset.body; + else if (ta && !opt?.dataset.body) ta.value = ""; +} + +function filterTimetable() { + const kw = (document.getElementById("tt-search")?.value || "").toLowerCase(); + const type = document.getElementById("tt-type-filter")?.value || ""; + const status = document.getElementById("tt-status-filter")?.value || ""; + const filtered = _ttCache.filter(t => { + const matchKw = !kw || t.title?.toLowerCase().includes(kw) || (t.content||"").toLowerCase().includes(kw); + const matchType = !type || t.work_type === type; + const matchStatus = !status || t.result_status === status; + return matchKw && matchType && matchStatus; + }); + renderTimetableTable(filtered); +} + +function renderTimetableTable(list) { + const tbody = document.getElementById("tt-tbody"); + if (!tbody) return; + const canEdit = ["ADMIN", "PM", "ENGINEER"].includes(_userInfo.role || ""); + if (!list.length) { + tbody.innerHTML = '등록된 작업이 없습니다.'; + return; + } + const instMap = {}; + _instCache.forEach(i => { instMap[i.id] = i.inst_name; }); + tbody.innerHTML = list.map(t => { + const typeColor = WORK_TYPE_COLOR[t.work_type] || "#818cf8"; + const instName = t.inst_id ? (instMap[t.inst_id] || "-") : "-"; + return ` + ${WORK_TYPE_KO[t.work_type]||t.work_type} + ${esc(t.title)} + ${esc(instName)} + ${fmtDate(t.scheduled_at)} + ${t.completed_at ? fmtDate(t.completed_at) : "-"} + ${RESULT_STATUS_KO[t.result_status]||t.result_status} + ${esc(t.assignee||"-")} + ${t.sr_id ? `${esc(t.sr_id)}` : "-"} + + ${canEdit ? `` : ""} + + `; + }).join(""); +} + +function openTimetableDetail(id) { + const t = _ttCache.find(x => x.id === id); + if (!t) return; + const typeColor = WORK_TYPE_COLOR[t.work_type] || "#818cf8"; + const instMap = {}; + _instCache.forEach(i => { instMap[i.id] = i.inst_name; }); + const instName = t.inst_id ? (instMap[t.inst_id] || "-") : "-"; + const duration = (t.started_at && t.completed_at) + ? `${Math.ceil((new Date(t.completed_at) - new Date(t.started_at)) / 60000)}분` : "-"; + const html = ` + +
+ ${WORK_TYPE_KO[t.work_type]||t.work_type} + ${RESULT_STATUS_KO[t.result_status]||t.result_status} +
+
+
기관${esc(instName)}
+
처리예정${fmtDate(t.scheduled_at)}
+
시작${t.started_at ? fmtDate(t.started_at) : "-"}
+
완료${t.completed_at ? fmtDate(t.completed_at) : "-"}
+
소요${duration}
+
담당자${esc(t.assignee||"-")}
+
검토자${esc(t.reviewer||"-")}
+ ${t.sr_id ? `
SR${esc(t.sr_id)}
` : ""} +
+ +
${esc(t.content)}
+ ${t.command_or_shell ? `
${esc(t.command_or_shell)}
` : ""} + ${t.result ? `
${esc(t.result)}
` : ""} + ${t.note ? `
${esc(t.note)}
` : ""} + `; + document.getElementById("modal-body").innerHTML = html; + document.getElementById("modal-overlay").classList.remove("hidden"); +} + +let _ttEditId = null; +function openTimetableModal(ttId = null) { + _ttEditId = ttId; + const overlay = document.getElementById("tt-modal-overlay"); + const form = document.getElementById("tt-form"); + form.reset(); + document.getElementById("tt-modal-title").textContent = ttId ? "작업 수정" : "작업 등록"; + _populateTimetableSelects(); + if (ttId) { + const t = _ttCache.find(x => x.id === ttId); + if (t) { + Object.keys(t).forEach(k => { + const el = form.elements[k]; + if (!el) return; + if (k === "scheduled_at" || k === "started_at" || k === "completed_at") { + el.value = t[k] ? t[k].slice(0,16) : ""; + } else { + el.value = t[k] ?? ""; + } + }); + } + } else { + // 기본값: 지금부터 1시간 후 + const dt = new Date(Date.now() + 3600000); + const iso = dt.toISOString().slice(0,16); + const el = form.elements.scheduled_at; + if (el) el.value = iso; + } + overlay.classList.remove("hidden"); +} +function closeTimetableModal() { document.getElementById("tt-modal-overlay").classList.add("hidden"); } + +async function submitTimetableForm(e) { + e.preventDefault(); + const form = e.target; + const data = Object.fromEntries(new FormData(form)); + // 빈 문자열 → null, 숫자 변환 + Object.keys(data).forEach(k => { if (data[k] === "") data[k] = null; }); + if (data.inst_id) data.inst_id = parseInt(data.inst_id); + if (data.script_id) data.script_id = parseInt(data.script_id); + try { + let r; + if (_ttEditId) { + r = await authFetch(`/api/timetable/${_ttEditId}`, { + method: "PATCH", body: JSON.stringify(data), + }); + } else { + r = await authFetch("/api/timetable", { + method: "POST", body: JSON.stringify(data), + }); + } + if (!r.ok) { const err = await r.json(); showToast(err.detail || "저장 실패", "error"); return; } + showToast("작업이 저장됐습니다.", "success"); + closeTimetableModal(); + loadTimetable(); + } catch { showToast("저장 중 오류가 발생했습니다.", "error"); } +} + +async function exportTimetableExcel() { + const type = document.getElementById("tt-type-filter")?.value || ""; + const status = document.getElementById("tt-status-filter")?.value || ""; + const params = new URLSearchParams(); + if (type) params.set("work_type", type); + if (status) params.set("result_status", status); + try { + const r = await authFetch(`/api/timetable/export/excel?${params.toString()}`); + if (!r.ok) { showToast("Excel 생성 실패", "error"); return; } + const blob = await r.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + const cd = r.headers.get("Content-Disposition") || ""; + const match = cd.match(/filename\*=UTF-8''(.+)/); + a.download = match ? decodeURIComponent(match[1]) : "GUARDiA_작업이력.xlsx"; + a.href = url; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + showToast("Excel 다운로드 완료", "success"); + } catch { showToast("다운로드 중 오류가 발생했습니다.", "error"); } +} + +/* ─── Helpers ───────────────────────────────────── */ +function esc(s) { + return String(s ?? "") + .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +function fmtDate(iso) { + if (!iso) return ""; + try { + return new Date(iso).toLocaleString("ko-KR", { + month: "2-digit", day: "2-digit", + hour: "2-digit", minute: "2-digit", + }); + } catch { return iso; } +} diff --git a/static/batch.html b/static/batch.html new file mode 100644 index 0000000..eb634dd --- /dev/null +++ b/static/batch.html @@ -0,0 +1,685 @@ + + + + + + GUARDiA — 배치 작업 관리 + + + + + + +
+ + + + + +
+
+

배치 작업 관리

+ +
+ + +
+
+
+
전체 작업
+
+
+
+
활성 작업
+
+
+
+
오늘 실행 횟수
+
+
+
+
오늘 실패 횟수
+
+
+ + +
+ + +
+ + +
+
+ + + + +
+
+ + + + + + + + + + + + + + + +
작업명서버Cron마지막 실행마지막 결과활성화액션
로딩 중...
+
+
+ + +
+
+ + + +
+
+
+ + + + + + + + + + + + + + + +
실행 시각작업명서버결과종료 코드소요 시간stdout 요약
작업을 선택하거나 새로고침 하세요.
+
+
+
+
+ + + + + + + + + + diff --git a/static/change-password.html b/static/change-password.html new file mode 100644 index 0000000..0d925a6 --- /dev/null +++ b/static/change-password.html @@ -0,0 +1,124 @@ + + + + + + GUARDiA ITSM — 비밀번호 변경 + + + + +
+
+
+ + + + + +
+ ⚠️ + 초기 비밀번호(1111)를 사용 중입니다. 새 비밀번호로 변경해 주세요. +
+ + +
+
+ +
+ + +
+
+ +
+ +
+ + +
+
+
+
    +
  • 4자 이상 (영문, 숫자, 특수문자 조합 권장)
  • +
  • 현재 비밀번호와 달라야 합니다
  • +
+
+ +
+ +
+ + +
+
+
+ + + + +
+ + + +
+ +
+
+ + + + diff --git a/static/change-password.js b/static/change-password.js new file mode 100644 index 0000000..cf7db3c --- /dev/null +++ b/static/change-password.js @@ -0,0 +1,146 @@ +/* ─── 인증 확인 ──────────────────────────────────── */ +const token = localStorage.getItem("guardia_token"); +const userInfo = JSON.parse(localStorage.getItem("guardia_userinfo") || "{}"); + +if (!token) { + window.location.replace("/login"); +} + +/* ─── UI 초기화 ──────────────────────────────────── */ +document.getElementById("user-subtitle").textContent = + userInfo.display_name ? `${userInfo.display_name} 님` : "GUARDiA ITSM"; + +// must_change_pw가 false면 배너 숨기기 +if (!userInfo.must_change_pw) { + document.getElementById("must-change-banner").classList.add("hidden"); + document.getElementById("skip-link").style.display = "none"; +} + +// 건너뛰기 링크 (must_change_pw가 false인 경우에만 허용) +document.getElementById("skip-link").addEventListener("click", e => { + e.preventDefault(); + if (!userInfo.must_change_pw) { + window.location.replace("/"); + } +}); + +/* ─── 비밀번호 보기/숨기기 ─────────────────────── */ +function togglePw(inputId, btn) { + const input = document.getElementById(inputId); + if (input.type === "password") { + input.type = "text"; + btn.textContent = "🙈"; + } else { + input.type = "password"; + btn.textContent = "👁"; + } +} + +/* ─── 강도 체크 ──────────────────────────────────── */ +function checkStrength(pw) { + const bar = document.getElementById("strength-bar"); + const label = document.getElementById("strength-label"); + if (!pw) { bar.style.width = "0"; label.textContent = ""; return; } + + let score = 0; + if (pw.length >= 4) score++; + if (pw.length >= 8) score++; + if (/[A-Z]/.test(pw) || /[a-z]/.test(pw)) score++; + if (/[0-9]/.test(pw)) score++; + if (/[^A-Za-z0-9]/.test(pw)) score++; + + const levels = [ + { w: "20%", bg: "#f85149", txt: "매우 약함" }, + { w: "40%", bg: "#f85149", txt: "약함" }, + { w: "60%", bg: "#e3b341", txt: "보통" }, + { w: "80%", bg: "#2bac76", txt: "강함" }, + { w: "100%", bg: "#1d9bd1", txt: "매우 강함" }, + ]; + const lv = levels[Math.min(score - 1, 4)] || levels[0]; + bar.style.width = lv.w; + bar.style.background = lv.bg; + label.textContent = lv.txt; + label.style.color = lv.bg; +} + +/* ─── 일치 확인 ──────────────────────────────────── */ +function checkMatch() { + const newPw = document.getElementById("new-pw").value; + const confirmPw = document.getElementById("confirm-pw").value; + const msgEl = document.getElementById("match-msg"); + if (!confirmPw) { msgEl.textContent = ""; return; } + if (newPw === confirmPw) { + msgEl.textContent = "✓ 비밀번호가 일치합니다"; + msgEl.className = "pw-match-msg ok"; + } else { + msgEl.textContent = "✗ 비밀번호가 일치하지 않습니다"; + msgEl.className = "pw-match-msg fail"; + } +} + +/* ─── 폼 제출 ────────────────────────────────────── */ +document.getElementById("cp-form").addEventListener("submit", async e => { + e.preventDefault(); + + const curPw = document.getElementById("cur-pw").value; + const newPw = document.getElementById("new-pw").value; + const confirmPw = document.getElementById("confirm-pw").value; + const errEl = document.getElementById("cp-error"); + + errEl.style.display = "none"; + + if (newPw !== confirmPw) { + showError("새 비밀번호가 일치하지 않습니다"); + return; + } + if (newPw.length < 4) { + showError("새 비밀번호는 4자 이상이어야 합니다"); + return; + } + + setLoading(true); + + try { + const res = await fetch("/api/auth/change-password", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}`, + }, + body: JSON.stringify({ current_password: curPw, new_password: newPw }), + }); + const data = await res.json(); + + if (!res.ok) { + showError(data.detail || "변경에 실패했습니다"); + setLoading(false); + return; + } + + /* 성공 — userinfo 업데이트 후 메인으로 */ + const updated = { ...userInfo, must_change_pw: false }; + localStorage.setItem("guardia_userinfo", JSON.stringify(updated)); + + /* 잠깐 성공 메시지 표시 */ + const btn = document.getElementById("cp-btn"); + btn.style.background = "#2bac76"; + document.getElementById("cp-btn-text").textContent = "✓ 변경 완료! 이동 중…"; + setTimeout(() => window.location.replace("/"), 1200); + + } catch { + showError("서버에 연결할 수 없습니다"); + setLoading(false); + } +}); + +function setLoading(on) { + document.getElementById("cp-btn").disabled = on; + document.getElementById("cp-btn-text").textContent = on ? "변경 중…" : "비밀번호 변경"; + document.getElementById("cp-spinner").style.display = on ? "inline-block" : "none"; +} + +function showError(msg) { + const errEl = document.getElementById("cp-error"); + errEl.textContent = msg; + errEl.style.display = "block"; +} diff --git a/static/customer.css b/static/customer.css new file mode 100644 index 0000000..dc37294 --- /dev/null +++ b/static/customer.css @@ -0,0 +1,119 @@ +/* ─── GUARDiA 고객 포털 (라이트 테마) ─── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +:root { + --blue: #1a73e8; + --blue-l: #e8f0fe; + --green: #1e8449; + --red: #c0392b; + --yellow: #f39c12; + --gray: #5f6368; + --light: #f8f9fa; + --border: #dadce0; + --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} +html, body { font-family: var(--font); background: var(--light); color: #202124; min-height: 100vh; } + +/* header */ +.portal-header { background: #fff; border-bottom: 1px solid var(--border); } +.portal-header-inner { max-width: 760px; margin: 0 auto; padding: 14px 20px; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 6px; } +.portal-logo { display: flex; align-items: center; gap: 10px; } +.logo-g { background: var(--blue); color: #fff; width: 32px; height: 32px; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-weight: 900; font-size: 18px; } +.logo-text { font-size: 16px; font-weight: 700; color: #202124; } +.portal-sub { font-size: 12px; color: var(--gray); } + +/* main */ +.portal-main { max-width: 760px; margin: 32px auto; padding: 0 20px 60px; } + +/* card */ +.portal-card { background: #fff; border: 1px solid var(--border); border-radius: 12px; padding: 32px; box-shadow: 0 1px 4px rgba(0,0,0,.06); } +.result-card { text-align: center; } +.card-title { font-size: 20px; font-weight: 700; margin-bottom: 8px; } +.card-desc { font-size: 14px; color: var(--gray); margin-bottom: 24px; line-height: 1.6; } + +/* form */ +.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px; } +@media (max-width: 540px) { .form-grid { grid-template-columns: 1fr; } } +.form-group { display: flex; flex-direction: column; gap: 5px; } +.form-group.full { margin-bottom: 16px; } +label { font-size: 13px; font-weight: 500; color: #3c4043; } +.req { color: var(--red); } +input[type=text], select, textarea { + padding: 9px 12px; border: 1px solid var(--border); border-radius: 6px; + font-size: 14px; font-family: var(--font); outline: none; + transition: border-color .15s; +} +input:focus, select:focus, textarea:focus { border-color: var(--blue); box-shadow: 0 0 0 2px rgba(26,115,232,.2); } +textarea { resize: vertical; } + +.btn-submit { + width: 100%; padding: 12px; background: var(--blue); color: #fff; + border: none; border-radius: 8px; font-size: 15px; font-weight: 600; + cursor: pointer; transition: background .15s; display: flex; align-items: center; justify-content: center; gap: 8px; +} +.btn-submit:hover { background: #1557b0; } + +/* result */ +.result-icon { font-size: 48px; margin-bottom: 12px; } +.sr-id-box { background: var(--blue-l); border-radius: 8px; padding: 14px 20px; margin: 20px 0; display: inline-block; } +.sr-id-label { font-size: 12px; color: var(--blue); display: block; margin-bottom: 4px; font-weight: 600; } +.sr-id-value { font-size: 20px; font-weight: 700; color: var(--blue); font-family: "Consolas", monospace; } + +/* status badge */ +.status-badge-row { margin: 16px 0 12px; display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; } +.s-badge { padding: 4px 10px; border-radius: 20px; font-size: 12px; font-weight: 600; } +.s-RECEIVED { background: #f1f3f4; color: #5f6368; } +.s-PENDING_APPROVAL { background: #fef9c3; color: #92400e; } +.s-APPROVED { background: #dcfce7; color: #166534; } +.s-IN_PROGRESS { background: #dbeafe; color: #1e40af; } +.s-COMPLETED { background: #d1fae5; color: #065f46; } +.s-FAILED_ROLLBACK { background: #fee2e2; color: #991b1b; } +.s-REJECTED { background: #fee2e2; color: #991b1b; } + +/* progress timeline */ +.progress-title { font-size: 14px; font-weight: 600; text-align: left; margin-bottom: 12px; color: #3c4043; } +.timeline { border-left: 2px solid var(--border); padding-left: 18px; text-align: left; } +.tl-item { position: relative; margin-bottom: 14px; } +.tl-item::before { content: ""; position: absolute; left: -23px; top: 5px; width: 8px; height: 8px; border-radius: 50%; background: var(--border); border: 2px solid #fff; } +.tl-item.ok::before { background: var(--green); } +.tl-item.err::before { background: var(--red); } +.tl-action { font-size: 13px; font-weight: 600; color: #3c4043; } +.tl-detail { font-size: 12px; color: var(--gray); margin-top: 2px; } +.tl-loading { font-size: 13px; color: var(--gray); padding: 8px 0; } + +/* rating */ +.rating-section { margin: 24px 0; padding: 20px; background: #fffbeb; border-radius: 8px; border: 1px solid #fde68a; } +.rating-title { font-size: 15px; font-weight: 600; margin-bottom: 12px; color: #92400e; } +.stars-row { display: flex; justify-content: center; gap: 8px; margin-bottom: 12px; } +.star-btn { + font-size: 32px; background: none; border: none; cursor: pointer; + color: #d1d5db; transition: color .1s, transform .1s; + line-height: 1; +} +.star-btn:hover, .star-btn.active { color: #f59e0b; transform: scale(1.15); } +.rating-comment-input { + width: 100%; padding: 8px 12px; border: 1px solid var(--border); + border-radius: 6px; font-size: 13px; margin-bottom: 10px; +} +.btn-rate { + background: #f59e0b; color: #fff; border: none; padding: 9px 24px; + border-radius: 6px; font-size: 14px; font-weight: 600; cursor: pointer; +} +.btn-rate:disabled { opacity: .5; cursor: not-allowed; } +.rating-done { font-size: 15px; color: var(--green); font-weight: 600; margin-top: 8px; } + +.btn-new { + margin-top: 20px; padding: 10px 24px; background: transparent; + border: 1px solid var(--border); border-radius: 6px; font-size: 14px; + cursor: pointer; color: var(--gray); transition: background .15s; +} +.btn-new:hover { background: var(--light); } + +/* spinner */ +.spinner { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,.4); border-top-color: #fff; border-radius: 50%; animation: spin .7s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } + +/* hidden */ +.hidden { display: none !important; } + +/* footer */ +.portal-footer { text-align: center; font-size: 12px; color: var(--gray); padding: 20px; border-top: 1px solid var(--border); background: #fff; } diff --git a/static/customer.html b/static/customer.html new file mode 100644 index 0000000..72f3814 --- /dev/null +++ b/static/customer.html @@ -0,0 +1,136 @@ + + + + + + GUARDiA 고객 서비스 포털 + + + +
+
+ + IT 인프라 서비스 요청 · 처리 현황 확인 +
+
+ +
+ + +
+
+

서비스 요청 (SR) 등록

+

이슈 또는 작업 요청 사항을 입력해 주세요. 담당 엔지니어 또는 AI가 신속하게 처리합니다.

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ + +
+
+ + +
+ + +
+
+
+ + + + +
+ +
+ GUARDiA 인프라 자동화 플랫폼 · 문의: guardia-ops@agency.go.kr +
+ + + + diff --git a/static/customer.js b/static/customer.js new file mode 100644 index 0000000..2935fbe --- /dev/null +++ b/static/customer.js @@ -0,0 +1,144 @@ +/* GUARDiA 고객 포털 JS */ +let currentSrId = null; +let selectedStar = 0; +let pollTimer = null; + +const STATUS_LABEL = { + RECEIVED: "접수됨", PARSED: "분석 중", PENDING_APPROVAL: "승인 대기", + APPROVED: "승인됨", IN_PROGRESS: "처리 중", + PENDING_PM_VALIDATION: "PM 검증", COMPLETED: "처리 완료", + FAILED_ROLLBACK: "처리 실패", REJECTED: "반려", +}; + +const ACTION_LABEL = { + CMDB_CHECK: "🔍 자산 확인", SSH_CONNECT: "🔗 서버 접속", + SSH_EXEC: "⚡ 명령 실행", SOURCE_MOD: "📦 파일 배포", + HEALTH_CHECK: "💓 헬스 체크", RESULT: "📋 결과 기록", + COMPLETE: "✅ 완료 처리", +}; + +/* ── 폼 제출 ── */ +document.getElementById("sr-form").addEventListener("submit", async e => { + e.preventDefault(); + const btn = document.getElementById("submit-btn"); + const label = document.getElementById("submit-label"); + const spinner = document.getElementById("submit-spinner"); + btn.disabled = true; label.textContent = "등록 중…"; spinner.classList.remove("hidden"); + + const fd = new FormData(e.target); + const payload = Object.fromEntries(fd.entries()); + if (!payload.description) delete payload.description; + if (!payload.target_server) delete payload.target_server; + + try { + const res = await fetch("/api/tasks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error((await res.json()).detail || "오류 발생"); + const sr = await res.json(); + currentSrId = sr.sr_id; + + document.getElementById("result-sr-id").textContent = sr.sr_id; + document.getElementById("section-form").classList.add("hidden"); + document.getElementById("section-result").classList.remove("hidden"); + + await refreshStatus(); + pollTimer = setInterval(refreshStatus, 5000); // 5초마다 갱신 + } catch (err) { + alert("요청 등록 실패: " + err.message); + btn.disabled = false; label.textContent = "요청 등록하기"; spinner.classList.add("hidden"); + } +}); + +/* ── 상태 폴링 ── */ +async function refreshStatus() { + if (!currentSrId) return; + + const [srRes, workRes, ratingRes] = await Promise.all([ + fetch(`/api/tasks/${currentSrId}`).then(r => r.ok ? r.json() : null), + fetch(`/api/work/${currentSrId}`).then(r => r.ok ? r.json() : []), + fetch(`/api/rating/${currentSrId}`).then(r => r.ok ? r.json() : null).catch(() => null), + ]); + + if (!srRes) return; + + /* 상태 배지 */ + document.getElementById("status-badge-row").innerHTML = + `${STATUS_LABEL[srRes.status] || srRes.status}`; + + /* 작업 타임라인 */ + const tl = document.getElementById("work-timeline"); + if (workRes.length) { + tl.innerHTML = workRes.map(w => ` +
+
${ACTION_LABEL[w.action_type] || w.action_type}
+
${esc(w.content || "")}${w.result ? " → " + esc(w.result.slice(0, 80)) : ""}
+
`).join(""); + } else { + tl.innerHTML = `
처리 대기 중입니다…
`; + } + + /* 완료 시: 별점 섹션 표시 */ + if (srRes.status === "COMPLETED") { + clearInterval(pollTimer); + if (!ratingRes) { + document.getElementById("rating-section").classList.remove("hidden"); + } else { + showRatingDone(ratingRes.stars); + } + } +} + +/* ── 별점 선택 ── */ +function setStar(n) { + selectedStar = n; + document.querySelectorAll(".star-btn").forEach((btn, i) => { + btn.classList.toggle("active", i < n); + }); + document.getElementById("btn-rate").disabled = false; +} + +/* ── 별점 제출 ── */ +async function submitRating() { + if (!selectedStar || !currentSrId) return; + const comment = document.getElementById("rating-comment").value.trim(); + const customer = document.getElementById("f-name")?.value || + document.getElementById("result-sr-id")?.textContent || "고객"; + + const res = await fetch(`/api/rating/${currentSrId}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ customer, stars: selectedStar, comment: comment || null }), + }); + if (res.ok) { + showRatingDone(selectedStar); + } +} + +function showRatingDone(stars) { + document.getElementById("rating-section").classList.remove("hidden"); + document.querySelector(".stars-row").classList.add("hidden"); + document.querySelector(".rating-comment-input").classList.add("hidden"); + document.getElementById("btn-rate").classList.add("hidden"); + const done = document.getElementById("rating-done"); + done.classList.remove("hidden"); + done.textContent = `${"★".repeat(stars)}${"☆".repeat(5 - stars)} — 감사합니다! 소중한 의견이 반영됩니다 🙏`; +} + +/* ── 초기화 ── */ +function resetForm() { + clearInterval(pollTimer); + currentSrId = null; selectedStar = 0; + document.getElementById("sr-form").reset(); + document.getElementById("section-form").classList.remove("hidden"); + document.getElementById("section-result").classList.add("hidden"); + document.getElementById("rating-section").classList.add("hidden"); + document.getElementById("rating-done").classList.add("hidden"); + document.getElementById("work-timeline").innerHTML = "
처리 정보를 불러오는 중…
"; +} + +function esc(s) { + return String(s ?? "").replace(/&/g,"&").replace(//g,">"); +} diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..3252b9f Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/help.js b/static/help.js new file mode 100644 index 0000000..0778b51 --- /dev/null +++ b/static/help.js @@ -0,0 +1,450 @@ +/** + * GUARDiA ITSM 화면별 도움말 시스템 (GS인증 사용성 요구사항) + * - 각 화면/기능의 ? 버튼 → 팝업 도움말 + * - F1 키 → 현재 화면 도움말 + * - 검색 가능한 도움말 DB + */ +(function GUARDiAHelp() { + 'use strict'; + + // ── 도움말 데이터베이스 ──────────────────────────────────── + const HELP_DB = { + 'dashboard': { + title: '대시보드', + icon: '📊', + content: ` +

통합 대시보드

+

GUARDiA ITSM의 모든 운영 현황을 한눈에 확인할 수 있는 화면입니다.

+

탭 구성

+
    +
  • 운영 현황: SR 상태·SLA·7일 추이 차트
  • +
  • 인프라: 서버 헬스·기관별 현황
  • +
  • 보안: 취약점·패치 현황
  • +
  • AI 인사이트: 이상탐지·예측 현황
  • +
+

KPI 카드

+

상단 숫자 카드를 클릭하면 해당 상세 목록으로 이동합니다.

+

단축키

+
  • F5: 새로고침
  • F1: 이 도움말
+ `, + }, + 'tasks': { + title: 'SR 서비스 요청', + icon: '📋', + content: ` +

SR 서비스 요청 관리

+

IT 서비스 요청(SR)을 접수·처리·추적하는 화면입니다.

+

SR 상태 흐름

+
+ 접수 → 파싱 → 승인대기 → 승인 → 진행중 → PM검증 → 완료 +
+

주요 기능

+
    +
  • AI 자동 분류: SR 생성 시 우선순위·카테고리 자동 제안
  • +
  • SLA 타이머: 우선순위별 처리 기한 자동 계산
  • +
  • 대량 처리: 여러 SR을 한 번에 상태 변경
  • +
+

봇 명령어

+/sr <제목> - 메신저에서 즉시 접수
+/sla - SLA 위반 목록 조회 + `, + }, + 'cmdb': { + title: 'CMDB 형상관리', + icon: '🖥️', + content: ` +

CMDB (Configuration Management Database)

+

관리하는 모든 IT 자산(서버·소프트웨어·네트워크)을 등록·관리합니다.

+

서버 등록 방법

+
    +
  1. 서버 관리 → 서버 등록 버튼 클릭
  2. +
  3. 서버명, IP, OS, SSH 계정 입력
  4. +
  5. SSH 비밀번호는 AES-256 암호화 저장
  6. +
+

CI 의존관계

+

서버 간 의존관계를 등록하면 배포 영향도 자동 분석에 활용됩니다.

+

보안 주의사항

+

⚠️ root 계정 SSH 직접 접속 금지 — opsagent 계정 사용

+ `, + }, + 'incidents': { + title: '인시던트 관리', + icon: '🚨', + content: ` +

인시던트(장애) 관리

+

IT 서비스 장애를 신속하게 탐지·대응·복구하는 프로세스입니다.

+

장애 등급

+
    +
  • P1 🚨: 전체 서비스 중단 — 즉시 대응, MTTR 1시간
  • +
  • P2 🔴: 주요 기능 장애 — MTTR 4시간
  • +
  • P3 🟠: 부분 영향 — MTTR 24시간
  • +
  • P4 🟡: 경미 — MTTR 72시간
  • +
+

AI 자동 RCA

+

인시던트 종료 시 Ollama AI가 근본원인 초안을 자동 생성합니다.

+

봇 명령어

+/incident <제목> P1 - 즉시 P1 인시던트 등록
+/rca INC-XXXX - AI RCA 분석 요청 + `, + }, + 'si': { + title: 'PMS 프로젝트 관리', + icon: '🏗️', + content: ` +

PMS (Project Management System)

+

SI 프로젝트의 전체 생명주기를 관리합니다.

+

관리 항목

+
    +
  • WBS: 작업 분류 체계, 진척률 입력
  • +
  • 산출물: 문서 제출·검토·승인 워크플로우
  • +
  • 이슈: 프로젝트 이슈 → SR 자동 연결
  • +
  • 위험: 리스크 매트릭스 관리
  • +
  • 보고서: 일간/주간/월간 자동 생성
  • +
+

자동 보고서

+

매일 18:00 일일 보고서, 매주 금요일 주간 보고서가 운영팀에 자동 발송됩니다.

+ `, + }, + 'license': { + title: '라이선스 관리', + icon: '🔏', + content: ` +

라이선스 관리

+

에디션 비교

+ + + + + +
에디션기관사용자기능
COMMUNITY110기본
STANDARD50200전체
ENTERPRISE무제한무제한전체+APM
+

체험판

+

무료 체험 시작 버튼으로 30일 체험판을 즉시 활성화할 수 있습니다.

+

라이선스 갱신

+

만료 30일 전부터 알림이 발송됩니다. 갱신 키를 입력하여 연장하세요.

+ `, + }, + 'agents': { + title: 'AI 에이전트', + icon: '🤖', + content: ` +

AI 에이전트 시스템

+

GUARDiA의 AI 에이전트는 온프레미스 Ollama LLM을 사용합니다. 외부 API 호출 없음.

+

에이전트 역할

+
    +
  • SR 매니저: SR 자동 분류·배정
  • +
  • 코드 리뷰어: 배포 전 코드 품질 검토
  • +
  • SLA 가디언: SLA 위반 모니터링·에스컬레이션
  • +
  • KB 큐레이터: 해결된 SR → KB 자동 생성
  • +
+

Ollama 상태 확인

+

상단 Ollama 상태 표시가 🟢이면 AI 기능 사용 가능합니다.

+ `, + }, + 'default': { + title: 'GUARDiA ITSM 도움말', + icon: '❓', + content: ` +

GUARDiA ITSM v2.0

+

AI 기반 레거시 인프라 자율 운영 플랫폼

+

빠른 시작

+
    +
  • 좌측 메뉴에서 원하는 기능을 선택하세요
  • +
  • 각 화면에서 ? 버튼을 누르면 상세 도움말이 표시됩니다
  • +
  • F1로 언제든 도움말을 열 수 있습니다
  • +
+

메신저 봇 명령어

+
    +
  • /help - 전체 명령어 목록
  • +
  • /sr <제목> - SR 접수
  • +
  • /status - 시스템 현황
  • +
+

기술 지원

+

📧 support@zioinfo.co.kr | 📞 02-000-0000

+ `, + }, + }; + + // ── 현재 뷰 감지 ─────────────────────────────────────────── + function getCurrentView() { + const path = location.pathname; + const viewEl = document.querySelector('[data-view].active'); + const viewId = viewEl?.dataset?.view; + + if (viewId) return viewId; + if (path.includes('/si')) return 'si'; + if (path.includes('/incidents')) return 'incidents'; + if (path.includes('/license')) return 'license'; + if (path.includes('/agents')) return 'agents'; + return 'default'; + } + + // ── 팝업 HTML 빌드 ───────────────────────────────────────── + function buildPopup() { + if (document.getElementById('grd-help-popup')) return; + + const overlay = document.createElement('div'); + overlay.id = 'grd-help-overlay'; + overlay.innerHTML = ` + + `; + + const style = document.createElement('style'); + style.id = 'grd-help-style'; + style.textContent = ` + #grd-help-overlay { + position: fixed; inset: 0; z-index: 10000; + background: rgba(0,0,0,.6); backdrop-filter: blur(4px); + display: flex; align-items: center; justify-content: center; + animation: fadeIn .15s ease; + } + @keyframes fadeIn { from{opacity:0} to{opacity:1} } + #grd-help-popup { + background: var(--surface, #1e2333); + border: 1px solid var(--border, rgba(255,255,255,.1)); + border-radius: 16px; + width: min(680px, 95vw); + max-height: 80vh; + display: flex; flex-direction: column; + box-shadow: 0 24px 80px rgba(0,0,0,.5); + animation: slideUp .2s ease; + } + @keyframes slideUp { from{transform:translateY(20px);opacity:0} to{transform:translateY(0);opacity:1} } + #grd-help-header { + display: flex; align-items: center; gap: 12px; + padding: 18px 20px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08)); + flex-shrink: 0; + } + #grd-help-icon { font-size: 24px; } + #grd-help-title { + flex: 1; font-size: 18px; font-weight: 700; + color: var(--text-bright, #f1f5f9); margin: 0; + } + #grd-help-close { + width: 32px; height: 32px; border-radius: 8px; + background: rgba(255,255,255,.08); border: none; + color: var(--text-muted, #64748b); cursor: pointer; + font-size: 16px; display: flex; align-items: center; justify-content: center; + transition: all .15s; + } + #grd-help-close:hover { background: rgba(255,255,255,.15); color: #fff; } + #grd-help-search-area { + padding: 12px 20px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08)); + flex-shrink: 0; + } + #grd-help-search { + width: 100%; padding: 8px 14px; + background: rgba(255,255,255,.06); border: 1px solid rgba(255,255,255,.1); + border-radius: 8px; color: var(--text-bright, #f1f5f9); + font-size: 14px; outline: none; box-sizing: border-box; + } + #grd-help-search:focus { border-color: #818cf8; } + #grd-help-body { + flex: 1; overflow-y: auto; padding: 20px; + color: var(--text-bright, #e2e8f0); line-height: 1.7; font-size: 14px; + scrollbar-width: thin; + } + #grd-help-body h3 { font-size: 18px; color: #818cf8; margin: 0 0 12px; } + #grd-help-body h4 { font-size: 14px; font-weight: 700; color: #a5b4fc; margin: 16px 0 6px; } + #grd-help-body ul,ol { padding-left: 20px; margin: 6px 0; } + #grd-help-body li { margin: 4px 0; } + #grd-help-body code { + background: rgba(255,255,255,.1); padding: 2px 6px; + border-radius: 4px; font-family: monospace; font-size: 13px; + } + #grd-help-body kbd { + background: rgba(255,255,255,.15); padding: 1px 6px; + border-radius: 4px; font-size: 12px; border: 1px solid rgba(255,255,255,.2); + } + .help-flow { + background: rgba(129,140,248,.1); border-left: 3px solid #818cf8; + padding: 10px 14px; border-radius: 0 8px 8px 0; margin: 8px 0; + font-family: monospace; font-size: 13px; + } + .help-table { border-collapse: collapse; width: 100%; margin: 8px 0; font-size: 13px; } + .help-table th { background: rgba(255,255,255,.08); padding: 6px 10px; text-align: left; } + .help-table td { padding: 5px 10px; border-bottom: 1px solid rgba(255,255,255,.05); } + #grd-help-nav { + display: flex; flex-wrap: wrap; gap: 6px; + padding: 12px 20px; border-top: 1px solid rgba(255,255,255,.08); + flex-shrink: 0; + } + .grd-help-topic { + padding: 5px 12px; border-radius: 20px; font-size: 12px; + background: rgba(255,255,255,.06); border: 1px solid rgba(255,255,255,.1); + color: var(--text-muted, #64748b); cursor: pointer; transition: all .15s; + } + .grd-help-topic:hover, .grd-help-topic.active { + background: rgba(129,140,248,.2); border-color: #818cf8; color: #818cf8; + } + #grd-help-footer { + display: flex; justify-content: space-between; align-items: center; + padding: 10px 20px; border-top: 1px solid rgba(255,255,255,.05); + font-size: 11px; color: var(--text-muted, #64748b); flex-shrink: 0; + } + #grd-help-footer a { color: #818cf8; } + /* 화면별 ? 버튼 */ + .grd-help-btn { + width: 28px; height: 28px; border-radius: 50%; + background: rgba(129,140,248,.15); border: 1px solid rgba(129,140,248,.3); + color: #818cf8; cursor: pointer; font-size: 14px; font-weight: 700; + display: inline-flex; align-items: center; justify-content: center; + transition: all .15s; line-height: 1; + } + .grd-help-btn:hover { background: rgba(129,140,248,.3); transform: scale(1.1); } + `; + + document.head.appendChild(style); + document.body.appendChild(overlay); + + // 이벤트 + overlay.addEventListener('click', e => { if (e.target === overlay) closeHelp(); }); + document.getElementById('grd-help-close').onclick = closeHelp; + document.getElementById('grd-help-search').oninput = searchHelp; + document.querySelectorAll('.grd-help-topic').forEach(btn => { + btn.onclick = () => showTopic(btn.dataset.topic); + }); + } + + // ── 도움말 표시 ──────────────────────────────────────────── + function showHelp(topicId) { + buildPopup(); + const topic = topicId || getCurrentView(); + showTopic(topic); + document.getElementById('grd-help-overlay').style.display = 'flex'; + document.getElementById('grd-help-search').focus(); + } + + function showTopic(topicId) { + const data = HELP_DB[topicId] || HELP_DB['default']; + document.getElementById('grd-help-icon').textContent = data.icon; + document.getElementById('grd-help-title').textContent = data.title; + document.getElementById('grd-help-body').innerHTML = data.content; + + document.querySelectorAll('.grd-help-topic').forEach(b => + b.classList.toggle('active', b.dataset.topic === topicId)); + } + + function closeHelp() { + const ol = document.getElementById('grd-help-overlay'); + if (ol) { ol.style.display = 'none'; } + document.getElementById('grd-help-search').value = ''; + } + + function searchHelp() { + const q = this.value.toLowerCase(); + if (!q) { showTopic(getCurrentView()); return; } + + let results = ''; + for (const [id, data] of Object.entries(HELP_DB)) { + if (id === 'default') continue; + const text = data.content.replace(/<[^>]+>/g, '').toLowerCase(); + if (text.includes(q) || data.title.toLowerCase().includes(q)) { + results += `
+

${data.icon} ${data.title}

+

${data.content.replace(/<[^>]+>/g,'').substring(0,150)}...

+
`; + } + } + document.getElementById('grd-help-body').innerHTML = + results || `

"${q}"에 대한 결과가 없습니다.

`; + } + + // ── ? 버튼 자동 삽입 ─────────────────────────────────────── + function injectHelpButtons() { + const targets = [ + { selector: '.card-header', topic: null }, + { selector: '.section-header', topic: null }, + { selector: '.page-hero-title', topic: null }, + { selector: '#grd-ob-header', topic: 'default', skip: true }, + ]; + + targets.forEach(({ selector, topic, skip }) => { + if (skip) return; + document.querySelectorAll(selector).forEach(el => { + if (el.querySelector('.grd-help-btn')) return; + const btn = document.createElement('button'); + btn.className = 'grd-help-btn'; + btn.textContent = '?'; + btn.title = '도움말 (F1)'; + btn.setAttribute('aria-label', '도움말'); + btn.onclick = e => { e.stopPropagation(); showHelp(topic); }; + el.style.position = 'relative'; + el.appendChild(btn); + }); + }); + } + + // ── 전역 ? 도움말 버튼 ───────────────────────────────────── + function buildGlobalHelpBtn() { + if (document.getElementById('grd-global-help')) return; + const btn = document.createElement('button'); + btn.id = 'grd-global-help'; + btn.textContent = '?'; + btn.title = 'GUARDiA 도움말 (F1)'; + btn.setAttribute('aria-label', '도움말'); + btn.style.cssText = ` + position:fixed; right:70px; bottom:20px; z-index:8999; + width:44px; height:44px; border-radius:50%; + background:#4f46e5; color:#fff; border:none; + font-size:18px; font-weight:700; cursor:pointer; + box-shadow:0 4px 16px rgba(79,70,229,.4); + display:flex; align-items:center; justify-content:center; + transition:transform .2s; + `; + btn.onmouseover = () => btn.style.transform = 'scale(1.1)'; + btn.onmouseout = () => btn.style.transform = ''; + btn.onclick = () => showHelp(); + document.body.appendChild(btn); + } + + // ── 키보드 단축키 ────────────────────────────────────────── + document.addEventListener('keydown', e => { + if (e.key === 'F1') { e.preventDefault(); showHelp(); } + if (e.key === 'Escape') { + const ol = document.getElementById('grd-help-overlay'); + if (ol && ol.style.display !== 'none') closeHelp(); + } + }); + + // ── 초기화 ───────────────────────────────────────────────── + function init() { + buildGlobalHelpBtn(); + injectHelpButtons(); + // SPA 뷰 변화 시 버튼 재삽입 + new MutationObserver(() => injectHelpButtons()) + .observe(document.body, { childList: true, subtree: true }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + setTimeout(init, 300); + } + + // 전역 노출 + window.GUARDiAHelp = { show: showHelp, close: closeHelp }; + +})(); diff --git a/static/icons/logo.png b/static/icons/logo.png new file mode 100644 index 0000000..0ae65e0 Binary files /dev/null and b/static/icons/logo.png differ diff --git a/static/incidents.html b/static/incidents.html new file mode 100644 index 0000000..ca1774f --- /dev/null +++ b/static/incidents.html @@ -0,0 +1,794 @@ + + + + + + GUARDiA — 장애관리 + + + + + +
+ + + + + +
+ + + +
+
+
+
전체 건수
+
+
+
+
OPEN 건수
+
+
+
+
P1/P2 활성
+
+
+
+
평균 MTTR
+
+
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + + + + + + + + + + + + + + +
INC 번호제목등급상태발생시각담당자MTTR
데이터를 불러오는 중...
+
+ + + +
+
+ + + + + + + + + + diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..a81e5d5 --- /dev/null +++ b/static/index.html @@ -0,0 +1,794 @@ + + + + + + GUARDiA ITSM — AI 기반 레거시 인프라 자율 운영 플랫폼 + + + + + + + + + + + + + + + + +
+ + + + + +
+ + +
+

대시보드

+
+ + +
+
+ + +
+ +
+ + +
+ + + + +
+ + +
+
+ +
+
SR 상태 분포
+
+ +
+
+ +
+
우선순위별 SR
+
+ +
+
+
+ +
+
+ 📈 최근 7일 SR 추이 + +
+
+ +
+
+ +
+
+
👷 엔지니어 워크로드
+
+
로딩 중…
+
+
+
+
최근 SR
+
+
+
+
+ + +
+
+ +
+
기관별 SR 현황 (Top 10)
+
+ +
+
+ +
+
서버 OS 분포
+
+ +
+
+
+ +
+
서버 헬스 현황
+
+
로딩 중…
+
+
+ +
+
🔐 SSL 인증서 만료 현황
+
+ +
+
+
+ + +
+
+ +
+
취약점 심각도 분포
+
+ +
+
+ +
+
패치 적용 현황
+
+ +
+
+
+ +
+
+
🔑 PAM 세션 현황
+
로딩 중…
+
+
+
📋 최근 감사 로그
+
+
+
+
+ + +
+
+ +
+
🤖 AI 티켓 분류 현황
+
+ +
+
+ +
+
⚡ 이상 탐지 추이 (7일)
+
+ +
+
+
+ +
+
+
🔧 예측 유지보수 위험 서버
+
로딩 중…
+
+
+
📚 학습 루프 현황
+
로딩 중…
+
+
+
+ +
+ +
+
+ +
+
+ +
+
+ + + +
+ + + + + + + + +
SR ID유형제목상태우선순위담당자요청자생성일
+
+ +
+
+ 감사 로그 (SHA-256 해시 체인) + + +
+ + + + + + + + +
#SR행위자액션내용해시 (앞 12자)시각
+
+ +
+
+
+ + +
+
+ + + +
+
+
+ + +
+
+ + + +
+ + + + + + + + +
기관코드기관명지역계약 만료SLA서버담당자상태관리
+
+ + +
+
+ + + + +
+
+
+ + +
+
+ + + + + +
+ + + + + + + + +
유형제목기관처리예정완료결과상태담당자SR관리
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/license.html b/static/license.html new file mode 100644 index 0000000..924a5a7 --- /dev/null +++ b/static/license.html @@ -0,0 +1,593 @@ + + + + + + GUARDiA — 라이선스 관리 + + + + + + +
+ + + +
+ + + +
+ + +
+ + + + + +
+
현재 라이선스
+
+ 활성 라이선스가 없습니다. 위의 무료 체험을 시작하거나 라이선스 키를 등록하세요. +
+ +
+ + +
+
사용량 현황
+
+
+
기관
+
+
- / -
+
+
+
사용자
+
+
- / -
+
+
+
서버
+
+
- / -
+
+
+
+ + +
+
활성화된 기능
+
+ 라이선스를 등록하면 기능 목록이 표시됩니다. +
+
+ + +
+
라이선스 키 등록 / 갱신
+
+ + +
+
+ + + +
+ +
+ + + + + +
+
등록 이력
+
+ + + + + + + + + + + + + + + +
라이선스 ID에디션고객명만료일상태등록자등록일시
불러오는 중...
+
+
+
+
+ + + + diff --git a/static/login.css b/static/login.css new file mode 100644 index 0000000..d33433d --- /dev/null +++ b/static/login.css @@ -0,0 +1,213 @@ +/* ─── Reset ──────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #111316; + --card-bg: #1a1d21; + --border: #2e3136; + --input-bg: #222529; + --text: #d1d2d3; + --muted: #6b6e75; + --bright: #ffffff; + --accent: #1d9bd1; + --accent-h: #1589bc; + --green: #2bac76; + --yellow: #e3b341; + --red: #f85149; + --radius: 8px; + --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} + +html, body { + height: 100%; + font-family: var(--font); + background: var(--bg); + color: var(--text); +} + +/* ─── Background ─────────────────────────────────── */ +#login-bg { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: + radial-gradient(ellipse at 20% 50%, rgba(29,155,209,.08) 0%, transparent 60%), + radial-gradient(ellipse at 80% 20%, rgba(43,172,118,.06) 0%, transparent 50%), + var(--bg); + padding: 20px; +} + +#login-wrap { + width: 100%; + max-width: 420px; + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; +} + +/* ─── Card ───────────────────────────────────────── */ +#login-card { + width: 100%; + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 12px; + padding: 36px 32px 28px; + box-shadow: 0 8px 32px rgba(0,0,0,.4); +} + +/* ─── Logo ───────────────────────────────────────── */ +#login-logo { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 28px; +} + +.logo-shield { + width: 52px; height: 52px; + background: linear-gradient(135deg, var(--accent) 0%, #0d6fa3 100%); + border-radius: 10px; + display: flex; align-items: center; justify-content: center; + font-size: 24px; font-weight: 900; color: #fff; + flex-shrink: 0; + box-shadow: 0 4px 12px rgba(29,155,209,.35); +} + +.logo-title { + font-size: 20px; font-weight: 700; color: var(--bright); + letter-spacing: -.3px; +} +.logo-sub { + font-size: 12px; color: var(--muted); margin-top: 2px; +} + +/* ─── Form ───────────────────────────────────────── */ +.field-group { + margin-bottom: 16px; +} +.field-group label { + display: block; + font-size: 12px; font-weight: 600; color: var(--muted); + text-transform: uppercase; letter-spacing: .06em; + margin-bottom: 6px; +} +.field-group input { + width: 100%; + background: var(--input-bg); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--bright); + font-size: 14px; padding: 10px 12px; + outline: none; + transition: border-color .15s, box-shadow .15s; +} +.field-group input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(29,155,209,.15); +} +.field-group input::placeholder { color: var(--muted); } + +.pw-wrap { position: relative; } +.pw-wrap input { padding-right: 38px; } +.pw-toggle { + position: absolute; right: 8px; top: 50%; transform: translateY(-50%); + background: none; border: none; cursor: pointer; + font-size: 16px; opacity: .5; transition: opacity .12s; + padding: 4px; +} +.pw-toggle:hover { opacity: 1; } + +/* ─── Error ──────────────────────────────────────── */ +.error-msg { + background: rgba(248,81,73,.12); + border: 1px solid rgba(248,81,73,.3); + border-radius: var(--radius); + color: #ff7e79; + font-size: 13px; padding: 10px 12px; + margin-bottom: 14px; +} + +/* ─── Login button ───────────────────────────────── */ +.btn-login { + width: 100%; + padding: 11px; + background: var(--accent); + color: #fff; + border: none; border-radius: var(--radius); + font-size: 15px; font-weight: 600; + cursor: pointer; + display: flex; align-items: center; justify-content: center; gap: 8px; + transition: background .15s, transform .1s; + margin-top: 4px; +} +.btn-login:hover:not(:disabled) { background: var(--accent-h); } +.btn-login:active:not(:disabled) { transform: scale(.98); } +.btn-login:disabled { opacity: .6; cursor: not-allowed; } + +/* ─── Spinner ────────────────────────────────────── */ +.spinner { + width: 16px; height: 16px; + border: 2px solid rgba(255,255,255,.3); + border-top-color: #fff; + border-radius: 50%; + animation: spin .7s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } + +/* ─── Hint ───────────────────────────────────────── */ +.login-hint { + margin-top: 18px; + font-size: 12px; color: var(--muted); text-align: center; +} +.login-hint code { + background: rgba(29,155,209,.15); color: var(--accent); + padding: 1px 6px; border-radius: 4px; font-size: 12px; +} + +/* ─── Account table ──────────────────────────────── */ +.account-table { + margin-top: 20px; + border-top: 1px solid var(--border); + padding-top: 16px; +} +.account-title { + font-size: 11px; font-weight: 600; color: var(--muted); + text-transform: uppercase; letter-spacing: .06em; + margin-bottom: 10px; +} +.account-row { + display: flex; align-items: center; gap: 10px; + padding: 5px 0; +} +.acc-role { + font-size: 10px; font-weight: 700; + padding: 2px 8px; border-radius: 20px; + min-width: 54px; text-align: center; +} +.acc-role.admin { background: rgba(29,155,209,.2); color: var(--accent); } +.acc-role.engineer { background: rgba(43,172,118,.2); color: var(--green); } +.acc-role.pm { background: rgba(227,179,65,.2); color: var(--yellow); } +.acc-role.customer { background: rgba(188,140,255,.2); color: #bc8cff; } + +.acc-info { + font-size: 12px; color: var(--muted); + background: var(--input-bg); padding: 3px 8px; + border-radius: 4px; border: 1px solid var(--border); +} +.acc-info.clickable { + cursor: pointer; transition: color .12s, border-color .12s; +} +.acc-info.clickable:hover { + color: var(--accent); border-color: var(--accent); +} +.account-note { + font-size: 11px; color: var(--muted); text-align: center; + margin-top: 8px; font-style: italic; +} + +/* ─── Footer ─────────────────────────────────────── */ +#login-footer { + font-size: 11px; color: var(--muted); text-align: center; +} diff --git a/static/login.html b/static/login.html new file mode 100644 index 0000000..0d6cffb --- /dev/null +++ b/static/login.html @@ -0,0 +1,256 @@ + + + + + + GUARDiA ITSM — 로그인 + + + + +
+
+
+ + + + + +
+ + + + + + + + + + + + + +
+ + +
+
+ + + + + diff --git a/static/login.js b/static/login.js new file mode 100644 index 0000000..86fb2c4 --- /dev/null +++ b/static/login.js @@ -0,0 +1,103 @@ +/* ─── 이미 로그인된 경우 메인으로 ───────────────── */ +(function checkAlreadyLoggedIn() { + const tok = localStorage.getItem("guardia_token"); + const info = JSON.parse(localStorage.getItem("guardia_userinfo") || "{}"); + if (tok && info.username) { + if (info.must_change_pw) { + window.location.replace("/change-password"); + } else { + window.location.replace("/"); + } + } +})(); + +/* ─── DOM refs ────────────────────────────────── */ +const form = document.getElementById("login-form"); +const errEl = document.getElementById("login-error"); +const btnEl = document.getElementById("login-btn"); +const btnText = document.getElementById("btn-text"); +const spinner = document.getElementById("btn-spinner"); +const pwInput = document.getElementById("password"); +const pwToggle = document.getElementById("pw-toggle"); + +/* ─── 비밀번호 보기/숨기기 ──────────────────────── */ +pwToggle.addEventListener("click", () => { + if (pwInput.type === "password") { + pwInput.type = "text"; + pwToggle.textContent = "🙈"; + } else { + pwInput.type = "password"; + pwToggle.textContent = "👁"; + } +}); + +/* ─── 계정 자동 입력 ─────────────────────────── */ +function fillLogin(username, password) { + document.getElementById("username").value = username; + pwInput.value = password; + pwInput.focus(); +} + +/* ─── 로그인 ──────────────────────────────────── */ +form.addEventListener("submit", async e => { + e.preventDefault(); + + const username = document.getElementById("username").value.trim(); + const password = pwInput.value; + + if (!username || !password) return; + + setLoading(true); + errEl.style.display = "none"; + + try { + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + const data = await res.json(); + + if (!res.ok) { + showError(data.detail || "로그인에 실패했습니다"); + setLoading(false); + return; + } + + /* 토큰 + 사용자 정보 저장 */ + localStorage.setItem("guardia_token", data.access_token); + localStorage.setItem("guardia_userinfo", JSON.stringify({ + username: data.username, + display_name: data.display_name, + role: data.role, + must_change_pw: data.must_change_pw, + })); + + /* 비밀번호 변경 필요 여부에 따라 리다이렉트 */ + if (data.must_change_pw) { + window.location.replace("/change-password"); + } else { + window.location.replace("/"); + } + + } catch { + showError("서버에 연결할 수 없습니다. 잠시 후 다시 시도해 주세요."); + setLoading(false); + } +}); + +function setLoading(on) { + btnEl.disabled = on; + btnText.textContent = on ? "로그인 중…" : "로그인"; + spinner.style.display = on ? "inline-block" : "none"; +} + +function showError(msg) { + errEl.textContent = msg; + errEl.style.display = "block"; +} + +/* ─── Enter 키 처리 ───────────────────────────── */ +document.getElementById("username").addEventListener("keydown", e => { + if (e.key === "Enter") { e.preventDefault(); pwInput.focus(); } +}); diff --git a/static/manifest.json b/static/manifest.json new file mode 100644 index 0000000..8dd125a --- /dev/null +++ b/static/manifest.json @@ -0,0 +1,113 @@ +{ + "name": "GUARDiA ITSM", + "short_name": "GUARDiA", + "description": "AI 기반 레거시 인프라 자율 운영 플랫폼", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "any", + "background_color": "#1a1d2e", + "theme_color": "#4f8ef7", + "lang": "ko", + "dir": "ltr", + "categories": ["productivity", "utilities"], + "icons": [ + { + "src": "/static/icons/icon-72.png", + "sizes": "72x72", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/static/icons/icon-96.png", + "sizes": "96x96", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/static/icons/icon-128.png", + "sizes": "128x128", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/static/icons/icon-144.png", + "sizes": "144x144", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/static/icons/icon-152.png", + "sizes": "152x152", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/static/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/static/icons/icon-384.png", + "sizes": "384x384", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/static/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "screenshots": [ + { + "src": "/static/screenshots/dashboard.png", + "sizes": "1280x720", + "type": "image/png", + "label": "대시보드" + }, + { + "src": "/static/screenshots/mobile.png", + "sizes": "390x844", + "type": "image/png", + "label": "모바일 뷰" + } + ], + "shortcuts": [ + { + "name": "SR 접수", + "short_name": "SR 접수", + "description": "새 서비스 요청 접수", + "url": "/?action=new-sr", + "icons": [{ "src": "/static/icons/icon-96.png", "sizes": "96x96" }] + }, + { + "name": "대시보드", + "short_name": "대시보드", + "description": "운영 대시보드 보기", + "url": "/", + "icons": [{ "src": "/static/icons/icon-96.png", "sizes": "96x96" }] + }, + { + "name": "인시던트", + "short_name": "인시던트", + "description": "인시던트 목록", + "url": "/incidents", + "icons": [{ "src": "/static/icons/icon-96.png", "sizes": "96x96" }] + } + ], + "related_applications": [], + "prefer_related_applications": false, + "share_target": { + "action": "/api/share", + "method": "POST", + "enctype": "application/x-www-form-urlencoded", + "params": { + "title": "title", + "text": "text", + "url": "url" + } + } +} diff --git a/static/offline.html b/static/offline.html new file mode 100644 index 0000000..eed60b5 --- /dev/null +++ b/static/offline.html @@ -0,0 +1,207 @@ + + + + + + + 오프라인 — GUARDiA ITSM + + + +
+
🔌
+

네트워크 연결 없음

+

+ GUARDiA ITSM 서버에 연결할 수 없습니다.
+ 네트워크 상태를 확인하고 다시 시도해 주세요. +

+ +
+ + +
+ +
+ +
+
+ 오프라인 +
+
+ + + + diff --git a/static/onboarding.js b/static/onboarding.js new file mode 100644 index 0000000..6198f14 --- /dev/null +++ b/static/onboarding.js @@ -0,0 +1,630 @@ +/** + * GUARDiA ITSM 온보딩 가이드 챗봇 + * 설치 완료 후 자동 실행 — 로그인부터 프로젝트 등록까지 단계별 안내 + */ +(function GUARDiAOnboarding() { + 'use strict'; + + // ── 상태 ────────────────────────────────────────────────── + let state = { + visible: false, + minimized: false, + currentStep: null, + totalSteps: 8, + messages: [], + isTyping: false, + spotlightEl: null, + }; + + let _token = null; + let _pollId = null; + + // ── 초기화 ──────────────────────────────────────────────── + function init() { + // 토큰 확인 (로그인 상태만) + _token = localStorage.getItem('access_token'); + if (!_token) return; + + // 온보딩 상태 조회 + fetchStatus().then(status => { + if (!status) return; + if (status.show_bot) { + buildUI(); + show(); + loadStep(status.current); + // 화면 변화 감지 + watchNavigation(); + } else { + // 완료됐어도 우측하단 도움말 버튼만 남김 + buildHelpButton(); + } + }); + } + + // ── API ─────────────────────────────────────────────────── + async function api(method, path, body) { + const opts = { + method, + headers: { + 'Authorization': 'Bearer ' + _token, + 'Content-Type': 'application/json', + }, + }; + if (body) opts.body = JSON.stringify(body); + try { + const r = await fetch(path, opts); + return r.ok ? r.json() : null; + } catch { return null; } + } + + async function fetchStatus() { + return api('GET', '/api/onboarding/status'); + } + + async function postStep(stepId, action) { + return api('POST', '/api/onboarding/step', { step_id: stepId, action }); + } + + async function postMessage(userMessage) { + return api('POST', '/api/onboarding/message', { + current_view: location.pathname, + current_step: state.currentStep?.id, + user_message: userMessage, + }); + } + + async function completeOnboarding() { + await api('POST', '/api/onboarding/complete'); + } + + async function dismissOnboarding() { + await api('POST', '/api/onboarding/dismiss'); + } + + // ── UI 빌드 ─────────────────────────────────────────────── + function buildUI() { + if (document.getElementById('grd-onboarding')) return; + + const panel = document.createElement('div'); + panel.id = 'grd-onboarding'; + panel.innerHTML = ` +
+
🤖
+
+
GUARDiA 가이드
+
초기 설정 안내
+
+
+ + +
+
+ + +
+
+
+
1 / 8 단계
+ + +
+ + +
+ + +
+ `; + + // 스타일 + const style = document.createElement('style'); + style.textContent = ` + #grd-onboarding { + position: fixed; + right: 0; top: 50%; + transform: translateY(-50%); + width: 360px; + max-height: 85vh; + background: #1e2333; + border-left: 3px solid #818cf8; + border-radius: 16px 0 0 16px; + box-shadow: -8px 0 40px rgba(0,0,0,.4); + display: flex; flex-direction: column; + z-index: 9999; + font-family: 'Noto Sans KR', Arial, sans-serif; + font-size: 13px; color: #e2e8f0; + transition: all .3s cubic-bezier(.4,0,.2,1); + overflow: hidden; + } + #grd-onboarding.minimized { + height: 52px; max-height: 52px; + border-radius: 16px 0 0 16px; + } + #grd-onboarding.minimized #grd-ob-messages, + #grd-onboarding.minimized #grd-ob-input-area, + #grd-onboarding.minimized #grd-ob-progress-bar, + #grd-onboarding.minimized #grd-ob-progress-label { display: none; } + + #grd-ob-header { + display: flex; align-items: center; gap: 10px; + padding: 12px 14px; + background: #252b3b; + cursor: pointer; flex-shrink: 0; + } + .grd-ob-avatar { + width: 36px; height: 36px; border-radius: 50%; + background: linear-gradient(135deg,#818cf8,#6366f1); + display: flex; align-items: center; justify-content: center; + font-size: 18px; flex-shrink: 0; + animation: pulse 2s ease infinite; + } + @keyframes pulse { + 0%,100%{box-shadow:0 0 0 0 rgba(129,140,248,.4)} + 50%{box-shadow:0 0 0 8px rgba(129,140,248,0)} + } + .grd-ob-header-info { flex: 1; } + .grd-ob-title { font-weight: 700; font-size: 14px; color: #fff; } + .grd-ob-subtitle { font-size: 11px; color: #818cf8; margin-top: 1px; } + .grd-ob-header-actions { display: flex; gap: 4px; } + .grd-ob-btn-icon { + width: 26px; height: 26px; border-radius: 6px; + background: rgba(255,255,255,.08); + color: #94a3b8; border: none; cursor: pointer; + font-size: 13px; display: flex; align-items: center; justify-content: center; + transition: all .15s; + } + .grd-ob-btn-icon:hover { background: rgba(255,255,255,.15); color: #fff; } + + #grd-ob-progress-bar { + height: 4px; background: rgba(255,255,255,.08); + flex-shrink: 0; + } + #grd-ob-progress-fill { + height: 100%; background: linear-gradient(90deg,#818cf8,#6ee7b7); + transition: width .5s ease; + } + #grd-ob-progress-label { + font-size: 10px; color: #64748b; + text-align: right; padding: 3px 12px 0; + flex-shrink: 0; + } + + #grd-ob-messages { + flex: 1; overflow-y: auto; padding: 14px 12px; + display: flex; flex-direction: column; gap: 10px; + scrollbar-width: thin; + } + .grd-ob-msg { + display: flex; gap: 8px; align-items: flex-start; + } + .grd-ob-msg.user { flex-direction: row-reverse; } + .grd-ob-msg-avatar { + width: 28px; height: 28px; border-radius: 50%; + background: linear-gradient(135deg,#818cf8,#6366f1); + display: flex; align-items: center; justify-content: center; + font-size: 14px; flex-shrink: 0; + } + .grd-ob-msg.user .grd-ob-msg-avatar { + background: rgba(99,102,241,.25); + } + .grd-ob-msg-bubble { + background: #252b3b; + border-radius: 4px 14px 14px 14px; + padding: 10px 13px; + max-width: 270px; + line-height: 1.6; + white-space: pre-line; + } + .grd-ob-msg.user .grd-ob-msg-bubble { + background: #4f46e5; + border-radius: 14px 4px 14px 14px; + } + .grd-ob-msg-bubble strong { color: #a5b4fc; } + .grd-ob-msg-bubble code { + background: rgba(255,255,255,.1); + padding: 2px 5px; border-radius: 4px; + font-family: monospace; font-size: 12px; + } + .grd-ob-actions { + display: flex; flex-wrap: wrap; gap: 6px; + margin-top: 8px; padding-left: 36px; + } + .grd-ob-action-btn { + padding: 7px 14px; border-radius: 20px; + background: rgba(129,140,248,.18); + color: #818cf8; border: 1px solid rgba(129,140,248,.3); + font-size: 12px; font-weight: 600; cursor: pointer; + transition: all .15s; white-space: nowrap; + } + .grd-ob-action-btn:hover { + background: rgba(129,140,248,.35); + color: #fff; + } + .grd-ob-action-btn.primary { + background: #4f46e5; color: #fff; border-color: transparent; + } + .grd-ob-action-btn.primary:hover { background: #4338ca; } + + .grd-ob-typing { + display: flex; gap: 4px; padding: 10px 14px; + background: #252b3b; border-radius: 4px 14px 14px 14px; + width: fit-content; + } + .grd-ob-typing span { + width: 6px; height: 6px; border-radius: 50%; + background: #818cf8; animation: typing .8s ease infinite; + } + .grd-ob-typing span:nth-child(2) { animation-delay: .2s; } + .grd-ob-typing span:nth-child(3) { animation-delay: .4s; } + @keyframes typing { 0%,60%,100%{opacity:.3} 30%{opacity:1} } + + #grd-ob-input-area { + display: flex; gap: 8px; padding: 10px 12px; + border-top: 1px solid rgba(255,255,255,.07); + flex-shrink: 0; + } + #grd-ob-input { + flex: 1; background: rgba(255,255,255,.06); + border: 1px solid rgba(255,255,255,.1); + border-radius: 8px; color: #e2e8f0; + padding: 8px 12px; font-size: 13px; outline: none; + font-family: inherit; + } + #grd-ob-input:focus { border-color: #818cf8; } + #grd-ob-send { + width: 34px; height: 34px; border-radius: 8px; + background: #4f46e5; color: #fff; border: none; + cursor: pointer; font-size: 14px; + display: flex; align-items: center; justify-content: center; + transition: background .15s; + } + #grd-ob-send:hover { background: #4338ca; } + + /* 스포트라이트 */ + .grd-spotlight { + position: fixed; z-index: 9998; + border: 2px solid #818cf8; + border-radius: 8px; + box-shadow: 0 0 0 9999px rgba(0,0,0,.5), 0 0 24px rgba(129,140,248,.6); + pointer-events: none; + transition: all .3s ease; + animation: spotlight-pulse 2s ease infinite; + } + @keyframes spotlight-pulse { + 0%,100%{box-shadow:0 0 0 9999px rgba(0,0,0,.5),0 0 24px rgba(129,140,248,.4)} + 50%{box-shadow:0 0 0 9999px rgba(0,0,0,.5),0 0 40px rgba(129,140,248,.8)} + } + + /* 도움말 버튼 (온보딩 완료 후) */ + #grd-help-btn { + position: fixed; right: 20px; bottom: 20px; + width: 48px; height: 48px; border-radius: 50%; + background: linear-gradient(135deg,#818cf8,#6366f1); + color: #fff; border: none; cursor: pointer; + font-size: 22px; z-index: 9000; + box-shadow: 0 4px 16px rgba(129,140,248,.4); + display: flex; align-items: center; justify-content: center; + transition: transform .2s; + } + #grd-help-btn:hover { transform: scale(1.1); } + + @media (max-width: 768px) { + #grd-onboarding { + width: 100%; right: 0; top: auto; bottom: 0; + transform: none; border-radius: 16px 16px 0 0; + border-left: none; border-top: 3px solid #818cf8; + max-height: 65vh; + } + } + `; + + document.head.appendChild(style); + document.body.appendChild(panel); + + // 이벤트 연결 + document.getElementById('grd-ob-minimize').onclick = toggleMinimize; + document.getElementById('grd-ob-close').onclick = closeBotConfirm; + document.getElementById('grd-ob-header').ondblclick = toggleMinimize; + document.getElementById('grd-ob-send').onclick = sendUserMessage; + document.getElementById('grd-ob-input').onkeydown = e => { + if (e.key === 'Enter') sendUserMessage(); + }; + } + + function buildHelpButton() { + if (document.getElementById('grd-help-btn')) return; + const btn = document.createElement('button'); + btn.id = 'grd-help-btn'; + btn.textContent = '?'; + btn.title = 'GUARDiA 도움말'; + btn.onclick = () => { + _onboarding_state_dismissed = false; + api('POST', '/api/onboarding/reset').then(() => location.reload()); + }; + document.body.appendChild(btn); + } + + // ── 메시지 렌더링 ───────────────────────────────────────── + function renderMessage(text, isUser = false, actions = []) { + const msgArea = document.getElementById('grd-ob-messages'); + if (!msgArea) return; + + const msg = document.createElement('div'); + msg.className = 'grd-ob-msg' + (isUser ? ' user' : ''); + + const avi = document.createElement('div'); + avi.className = 'grd-ob-msg-avatar'; + avi.textContent = isUser ? '👤' : '🤖'; + + const bubble = document.createElement('div'); + bubble.className = 'grd-ob-msg-bubble'; + // 마크다운 간단 렌더링 + bubble.innerHTML = text + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/`(.+?)`/g, '$1') + .replace(/^```[\s\S]*?```$/gm, m => `${m.replace(/```\w*\n?/g,'').trim()}`) + .replace(/\n/g, '
'); + + msg.appendChild(avi); + msg.appendChild(bubble); + msgArea.appendChild(msg); + + // 액션 버튼 + if (actions && actions.length > 0) { + const actDiv = document.createElement('div'); + actDiv.className = 'grd-ob-actions'; + actions.forEach((act, i) => { + const btn = document.createElement('button'); + btn.className = 'grd-ob-action-btn' + (i === 0 ? ' primary' : ''); + btn.textContent = act.label; + btn.onclick = () => handleAction(act); + actDiv.appendChild(btn); + }); + msgArea.appendChild(actDiv); + } + + msgArea.scrollTop = msgArea.scrollHeight; + } + + function showTyping() { + const msgArea = document.getElementById('grd-ob-messages'); + if (!msgArea) return; + const el = document.createElement('div'); + el.className = 'grd-ob-msg'; + el.id = 'grd-ob-typing'; + el.innerHTML = ` +
🤖
+
+ +
`; + msgArea.appendChild(el); + msgArea.scrollTop = msgArea.scrollHeight; + } + + function hideTyping() { + document.getElementById('grd-ob-typing')?.remove(); + } + + // ── 단계 로드 ───────────────────────────────────────────── + function loadStep(step) { + if (!step) return; + state.currentStep = step; + + // 헤더 업데이트 + const sub = document.getElementById('grd-ob-subtitle'); + if (sub) sub.textContent = `${step.icon} ${step.title}`; + + // 진행 바 업데이트 + const pct = Math.round(step.order / 7 * 100); + const fill = document.getElementById('grd-ob-progress-fill'); + const label = document.getElementById('grd-ob-progress-label'); + if (fill) fill.style.width = pct + '%'; + if (label) label.textContent = `${step.order + 1} / 8 단계`; + + // 타이핑 효과 후 메시지 표시 + showTyping(); + setTimeout(() => { + hideTyping(); + renderMessage(step.message, false, step.actions); + // 스포트라이트 + if (step.target) spotlightElement(step.target); + }, 800); + + // 화면 이동 힌트 + if (step.view && location.pathname !== step.view.split('?')[0]) { + setTimeout(() => { + renderMessage(`💡 현재 화면: **${location.pathname}**\n이 단계는 **${step.view}** 화면에서 진행됩니다.`, false, [ + { label: `${step.view} 이동`, action: 'navigate', path: step.view } + ]); + }, 1500); + } + } + + // ── 액션 처리 ───────────────────────────────────────────── + async function handleAction(act) { + const step = state.currentStep; + + switch (act.action) { + case 'next': + case 'complete_step': + if (step) { + showTyping(); + const result = await postStep(step.id, 'complete'); + hideTyping(); + if (result?.step) loadStep(result.step); + } + break; + + case 'navigate': + if (act.path) { + if (act.path.startsWith('http')) { + window.open(act.path, '_blank'); + } else if (act.path.includes('?view=')) { + const viewName = act.path.split('?view=')[1]; + // GUARDiA SPA 뷰 전환 + const navItem = document.querySelector(`[data-view="${viewName}"]`); + if (navItem) navItem.click(); + } else { + location.href = act.path; + } + } + break; + + case 'external': + window.open(act.url || act.path, '_blank'); + break; + + case 'complete': + await completeOnboarding(); + renderMessage('🎉 모든 설정이 완료되었습니다!\n이제 GUARDiA의 모든 기능을 사용하세요.\n\n우측 하단 **?** 버튼으로 언제든 가이드를 다시 볼 수 있습니다.', false, []); + setTimeout(() => { + hide(); + buildHelpButton(); + }, 3000); + break; + + case 'skip': + if (step) { + const result = await postStep(step.id, 'skip'); + if (result?.step) loadStep(result.step); + } + break; + } + } + + // ── 사용자 메시지 전송 ──────────────────────────────────── + async function sendUserMessage() { + const input = document.getElementById('grd-ob-input'); + if (!input) return; + const text = input.value.trim(); + if (!text) return; + + input.value = ''; + renderMessage(text, true); + + showTyping(); + const resp = await postMessage(text); + hideTyping(); + + if (resp?.message) { + renderMessage(resp.message, false); + } + } + + // ── 스포트라이트 ────────────────────────────────────────── + function spotlightElement(selector) { + // 기존 스포트라이트 제거 + clearSpotlight(); + + const el = document.querySelector(selector); + if (!el) return; + + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + setTimeout(() => { + const rect = el.getBoundingClientRect(); + const pad = 6; + const spot = document.createElement('div'); + spot.className = 'grd-spotlight'; + spot.id = 'grd-spotlight'; + spot.style.cssText = ` + top: ${rect.top - pad + window.scrollY}px; + left: ${rect.left - pad}px; + width: ${rect.width + pad * 2}px; + height: ${rect.height + pad * 2}px; + `; + document.body.appendChild(spot); + + // 8초 후 자동 제거 + setTimeout(clearSpotlight, 8000); + }, 400); + } + + function clearSpotlight() { + document.getElementById('grd-spotlight')?.remove(); + } + + // ── 화면 변화 감지 ──────────────────────────────────────── + function watchNavigation() { + // SPA 네비게이션 감지 (hash/pushState) + let lastPath = location.pathname; + const observer = new MutationObserver(() => { + if (location.pathname !== lastPath) { + lastPath = location.pathname; + onViewChange(location.pathname); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + + // GUARDiA nav-item 클릭 감지 + document.addEventListener('click', e => { + const navItem = e.target.closest('[data-view]'); + if (navItem) { + setTimeout(() => onViewChange(location.pathname, navItem.dataset.view), 300); + } + }); + } + + function onViewChange(path, viewId) { + clearSpotlight(); + const step = state.currentStep; + if (!step || !step.view) return; + + const stepView = step.view.split('?')[0]; + if (path === stepView || (viewId && step.target?.includes(viewId))) { + // 현재 단계의 화면으로 이동했으면 힌트 표시 + setTimeout(() => { + if (step.target) spotlightElement(step.target); + renderMessage(`✅ 좋아요! 지금 **${step.title}** 단계를 진행 중입니다.\n\n${step.message.split('\n')[0]}`, false); + }, 500); + } + } + + // ── 패널 제어 ───────────────────────────────────────────── + function show() { + const panel = document.getElementById('grd-onboarding'); + if (panel) { panel.style.display = 'flex'; state.visible = true; } + } + + function hide() { + const panel = document.getElementById('grd-onboarding'); + if (panel) { panel.style.display = 'none'; state.visible = false; } + clearSpotlight(); + } + + function toggleMinimize() { + const panel = document.getElementById('grd-onboarding'); + if (!panel) return; + state.minimized = !state.minimized; + panel.classList.toggle('minimized', state.minimized); + document.getElementById('grd-ob-minimize').textContent = state.minimized ? '□' : '─'; + } + + function closeBotConfirm() { + if (confirm('온보딩 가이드를 닫을까요?\n언제든 우측하단 ? 버튼으로 다시 열 수 있습니다.')) { + dismissOnboarding(); + hide(); + buildHelpButton(); + } + } + + // ── 진입점 ──────────────────────────────────────────────── + // DOM 준비 후 실행 + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + // 로그인 완료 후 토큰이 설정되면 초기화 + setTimeout(init, 1000); + } + + // 로그인 이벤트 감지 (localStorage 변화) + window.addEventListener('storage', e => { + if (e.key === 'access_token' && e.newValue && !_token) { + _token = e.newValue; + setTimeout(init, 500); + } + }); + + // 전역 노출 (수동 재시작) + window.GUARDiAOnboarding = { restart: () => { api('POST','/api/onboarding/reset').then(()=>location.reload()); } }; + +})(); diff --git a/static/oncall.html b/static/oncall.html new file mode 100644 index 0000000..ab1b3b4 --- /dev/null +++ b/static/oncall.html @@ -0,0 +1,744 @@ + + + + + + GUARDiA — 온콜/당직 + + + + + + +
+ + + + +
+ + + +
+ 📞 오늘 당직 정보를 불러오는 중 +
+ + +
+ + +
+ + +
+ + + + +
+ +
-
+ + +
+ + +
+
+
+
+
+
+
+
+
+
+ + +
+
+ + +
+ +
+
+
+ + + + + + + + + + + + + + +
날짜시프트담당자백업담당자메모작업
로딩중
+
+ + +
+
일괄 등록 (JSON)
+
+ JSON 배열 형식으로 입력 (최대 62건). 예시: + [{"duty_date":"2026-06-01","shift":"ALL_DAY","user_id":2},{"duty_date":"2026-06-02","shift":"DAYTIME","user_id":3}] +
+ +
+ + +
+
+
+
+
+ + + + + +
+ + + + diff --git a/static/pm.html b/static/pm.html new file mode 100644 index 0000000..f579403 --- /dev/null +++ b/static/pm.html @@ -0,0 +1,877 @@ + + + + + + GUARDiA — PM 정기점검 + + + + + + +
+ + + + +
+ + + +
+
+
-
+
등록된 스케줄
+
+
+
-
+
이번달 PASS
+
+
+
-
+
이번달 FAIL
+
+
+
-
+
대기중 점검
+
+
+ + +
+ + + +
+ + +
+
+ + +
+ +
+
+ +
+ + + + + + + + + + + + + + + +
서버명템플릿명주기다음 예정일담당자상태작업
로딩중
+
+
+ + +
+
+ + +
+ +
+
+
📋
+
타임테이블을 선택하면 체크리스트가 표시됩니다.
+
+
+
+ + +
+
+ + +
+ +
+
+
+
로딩중
+
+
+
+
+ + + + + + + + +
+ + + + diff --git a/static/si.html b/static/si.html new file mode 100644 index 0000000..5dc6842 --- /dev/null +++ b/static/si.html @@ -0,0 +1,1242 @@ + + + + + + GUARDiA — SI 프로젝트 관리 + + + + +
+ + + + + +
+ + + + + +
+
+
← 왼쪽에서 프로젝트를 선택하세요
+
SI 프로젝트를 선택하면 상세 정보를 확인할 수 있습니다.
+
+ + + + + +
+ + +
+
프로젝트를 선택하세요.
+
+ + +
+
+ + + +
+
WBS 데이터를 불러오는 중...
+
+ + +
+
+ + + +
+ + + +
코드제목유형우선순위확정여부WBS 연결
+
+ + +
+
+ + + +
+ + + +
번호제목유형심각도상태담당자기한
+
+ + +
+
+ +
+
위험 매트릭스 (확률 × 영향도)
+
+
← 확률(Probability)
+
+
+
+
1
+
2
+
3
+
4
+
5
+
+
영향도(Impact) →
+
+
+
+ +
+
+ +
+ + + +
제목확률영향점수등급상태
+
+
+
+ + +
+
+ +
+
+
+ + +
+
+ + + +
+ + + +
번호제목유형영향도상태요청일요청자
+
+ + +
+
+ + + +
+ +
+
+ +
+ + + +
계획명유형시작일종료일담당자상태
+
+ +
+
+ + +
+ + + +
코드제목우선순위상태실행결과
+
+ +
+
+ +
+ + + +
번호제목심각도상태연결 케이스발견일
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/ssl.html b/static/ssl.html new file mode 100644 index 0000000..a475b35 --- /dev/null +++ b/static/ssl.html @@ -0,0 +1,579 @@ + + + + + + GUARDiA — SSL 관리 + + + + + +
+ + + + + +
+ + + +
+
+
+
OK (정상)
+
+
+
+
WARN (30일 이내)
+
+
+
+
URGENT (7일 이내)
+
+
+
+
EXPIRED (만료)
+
+
+ + +
+ + + 30일 이내 + +
+ + +
+ + + + +
+ + +
+ + + + + + + + + + + + + + + + +
서버명역할/인스티튜션만료일남은 일수게이지경고 레벨SSH 점검갱신
데이터를 불러오는 중...
+
+
+
+ + + + + + + + + + diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..24a5590 --- /dev/null +++ b/static/style.css @@ -0,0 +1,1332 @@ +/* ─── Reset ─────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +/* ─── KWCAG 2.1 웹접근성 포커스 표시 (GS인증 필수) ─ */ +/* outline:none 대신 :focus-visible 로 키보드 포커스만 표시 */ +:focus-visible { + outline: 2px solid #818cf8 !important; + outline-offset: 2px !important; + border-radius: 4px; +} +/* 마우스 클릭 시 포커스 링 숨김 (UX) */ +:focus:not(:focus-visible) { outline: none; } + +/* 스킵 네비게이션 (키보드 접근성) */ +.skip-nav { + position: absolute; top: -60px; left: 8px; z-index: 99999; + background: #818cf8; color: #fff; padding: 8px 16px; + border-radius: 0 0 8px 8px; font-size: 14px; font-weight: 600; + transition: top .2s; text-decoration: none; +} +.skip-nav:focus { top: 0; } + +/* ─── Design Tokens (Nifty Dark) ────────────────── */ +:root { + /* backgrounds */ + --main-bg: #0f172a; + --sidebar-bg: #1e293b; + --card-bg: #1e293b; + --card-inner: #0f172a; + --input-bg: #0f172a; + + /* borders & shadows */ + --border: rgba(255,255,255,.07); + --shadow-sm: 0 2px 8px rgba(0,0,0,.3); + --shadow-md: 0 4px 20px rgba(0,0,0,.35); + --shadow-lg: 0 8px 40px rgba(0,0,0,.4); + + /* text — KWCAG 2.1 AA 색상 대비 4.5:1 이상 보장 */ + --text-bright: #f8fafc; /* 대비 15.8:1 (배경 #0f172a 대비) */ + --text-primary: #cbd5e1; /* 대비 9.2:1 */ + --text-muted: #94a3b8; /* 대비 4.7:1 ✅ (기존 #64748b=3.1:1 → 개선) */ + + /* brand colors */ + --accent: #818cf8; + --accent-dark: #6366f1; + --green: #34d399; + --yellow: #fbbf24; + --red: #f87171; + --orange: #fb923c; + --purple: #a78bfa; + --cyan: #22d3ee; + + /* sidebar active */ + --sidebar-active-bg: #6366f1; + --sidebar-hover-bg: rgba(255,255,255,.06); + + /* typography */ + --font: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + + /* radii */ + --radius: 8px; + --radius-lg: 14px; + --radius-xl: 20px; +} + +/* ─── Base ──────────────────────────────────────── */ +html, body { + height: 100%; font-family: var(--font); + background: var(--main-bg); color: var(--text-primary); + font-size: 14px; line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +/* ─── Layout ────────────────────────────────────── */ +#app { display: flex; height: 100vh; overflow: hidden; } + +/* ══════════════════════════════════════════════════ + SIDEBAR (Nifty style) +══════════════════════════════════════════════════ */ +#sidebar { + width: 248px; min-width: 248px; flex-shrink: 0; + background: var(--sidebar-bg); + border-right: 1px solid var(--border); + display: flex; flex-direction: column; + box-shadow: 2px 0 20px rgba(0,0,0,.25); + position: relative; z-index: 10; +} + +/* Logo */ +#sidebar-logo { + padding: 20px 18px 16px; + display: flex; align-items: center; gap: 12px; +} +.logo-icon { + width: 38px; height: 38px; border-radius: 10px; flex-shrink: 0; + background: linear-gradient(135deg, var(--accent-dark), #8b5cf6); + color: #fff; font-size: 20px; font-weight: 900; + display: flex; align-items: center; justify-content: center; + box-shadow: 0 4px 14px rgba(99,102,241,.45); +} +.logo-title { font-size: 16px; font-weight: 800; color: var(--text-bright); letter-spacing: -.01em; } +.logo-sub { font-size: 10px; color: var(--text-muted); letter-spacing: .04em; text-transform: uppercase; } + +/* Nav */ +#sidebar-nav { flex: 1; padding: 8px 12px; overflow-y: auto; } +.nav-section-label { + font-size: 10px; font-weight: 700; color: var(--text-muted); + text-transform: uppercase; letter-spacing: .08em; + padding: 12px 8px 4px; +} +.nav-item { + padding: 9px 12px; border-radius: var(--radius); + cursor: pointer; color: var(--text-muted); + display: flex; align-items: center; gap: 10px; + transition: background .15s, color .15s; + margin-bottom: 2px; font-size: 13px; font-weight: 500; +} +.nav-item:hover { background: var(--sidebar-hover-bg); color: var(--text-primary); } +.nav-item.active { + background: linear-gradient(90deg, var(--accent-dark), #8b5cf6); + color: #fff; font-weight: 600; + box-shadow: 0 4px 14px rgba(99,102,241,.35); +} +.nav-icon { font-size: 16px; width: 20px; text-align: center; } + +/* Footer status */ +#sidebar-footer { + padding: 8px 18px; + border-top: 1px solid var(--border); + display: flex; align-items: center; gap: 6px; + font-size: 11px; color: var(--text-muted); +} +.status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } +.status-dot.online { background: var(--green); box-shadow: 0 0 6px rgba(52,211,153,.6); } + +/* User info */ +#sidebar-user { + padding: 10px 14px; + border-top: 1px solid var(--border); + display: flex; align-items: center; gap: 10px; +} +#sidebar-user-info { flex: 1; min-width: 0; } +#user-role-badge { + display: inline-block; font-size: 10px; font-weight: 700; + padding: 2px 8px; border-radius: 20px; margin-top: 2px; + letter-spacing: .02em; +} +.btn-logout { + background: none; border: 1px solid var(--border); + color: var(--text-muted); cursor: pointer; + padding: 5px 8px; border-radius: var(--radius); + font-size: 14px; flex-shrink: 0; + transition: color .15s, border-color .15s; +} +.btn-logout:hover { color: var(--red); border-color: var(--red); } + +/* SSE 연결 상태 */ +#sse-status { + padding: 6px 18px; + display: flex; align-items: center; gap: 6px; + border-top: 1px solid var(--border); +} +.sse-dot { + width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; + transition: background .4s; +} +.sse-dot.green { background: var(--green); box-shadow: 0 0 6px rgba(52,211,153,.7); animation: pulse-green 2s infinite; } +.sse-dot.red { background: var(--red); box-shadow: 0 0 4px rgba(248,113,113,.5); } +.sse-dot.grey { background: var(--text-muted); } +@keyframes pulse-green { + 0%,100% { opacity: 1; } + 50% { opacity: .45; } +} + +/* ══════════════════════════════════════════════════ + MAIN / TOPBAR +══════════════════════════════════════════════════ */ +#main { flex: 1; display: flex; flex-direction: column; overflow: hidden; } + +#topbar { + padding: 14px 28px; + background: var(--sidebar-bg); + border-bottom: 1px solid var(--border); + display: flex; align-items: center; justify-content: space-between; + box-shadow: var(--shadow-sm); +} +#page-title { font-size: 18px; font-weight: 800; color: var(--text-bright); letter-spacing: -.02em; } + +#topbar-actions { display: flex; align-items: center; gap: 10px; } + +/* 갱신 인디케이터 */ +.refresh-indicator { + display: flex; align-items: center; gap: 6px; + font-size: 11px; color: var(--green); + background: rgba(52,211,153,.1); border: 1px solid rgba(52,211,153,.25); + border-radius: 20px; padding: 3px 10px; + animation: fade-in .3s ease; +} +.refresh-indicator.hidden { display: none; } +.refresh-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--green); flex-shrink: 0; } +@keyframes fade-in { from { opacity:0; transform:translateY(-4px); } to { opacity:1; transform:none; } } + +/* ─── Views ─────────────────────────────────────── */ +.view { display: none; flex: 1; overflow-y: auto; padding: 24px 28px; } +.view.active { display: block; } + +/* ══════════════════════════════════════════════════ + BUTTONS (Nifty style) +══════════════════════════════════════════════════ */ +.btn { + padding: 8px 16px; border-radius: var(--radius); + font-size: 13px; font-weight: 600; cursor: pointer; + border: 1px solid transparent; transition: all .18s; + letter-spacing: .01em; +} +.btn:hover { transform: translateY(-1px); box-shadow: var(--shadow-sm); } +.btn:active { transform: none; } +.btn-primary { + background: linear-gradient(135deg, var(--accent-dark), #8b5cf6); + color: #fff; border-color: transparent; + box-shadow: 0 2px 10px rgba(99,102,241,.35); +} +.btn-primary:hover { box-shadow: 0 4px 18px rgba(99,102,241,.5); } +.btn-secondary { background: rgba(255,255,255,.06); border-color: var(--border); color: var(--text-primary); } +.btn-secondary:hover { background: rgba(255,255,255,.1); } +.btn-approve { background: rgba(52,211,153,.15); color: var(--green); border-color: rgba(52,211,153,.3); } +.btn-approve:hover { background: rgba(52,211,153,.25); } +.btn-reject { background: rgba(248,113,113,.15); color: var(--red); border-color: rgba(248,113,113,.3); } +.btn-reject:hover { background: rgba(248,113,113,.25); } + +/* ══════════════════════════════════════════════════ + CARDS (no border, shadow) +══════════════════════════════════════════════════ */ +.card { + background: var(--card-bg); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); + overflow: hidden; + border: 1px solid var(--border); +} +.card-header { + padding: 14px 18px; font-size: 13px; font-weight: 700; + color: var(--text-bright); border-bottom: 1px solid var(--border); + letter-spacing: .01em; +} +.card-body { padding: 8px 0; } + +/* ══════════════════════════════════════════════════ + STAT CARDS (Nifty icon-style) +══════════════════════════════════════════════════ */ +.stats-row { display: flex; gap: 16px; margin-bottom: 22px; flex-wrap: wrap; } +.stat-card { + background: var(--card-bg); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); + padding: 18px 20px; + min-width: 140px; flex: 1; + border: 1px solid var(--border); + position: relative; overflow: hidden; + transition: transform .18s, box-shadow .18s; +} +.stat-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-lg); } +.stat-card::after { + content: ""; position: absolute; + top: -30px; right: -20px; + width: 90px; height: 90px; border-radius: 50%; + background: currentColor; opacity: .04; +} +.stat-value { font-size: 30px; font-weight: 800; color: var(--text-bright); line-height: 1; margin-bottom: 6px; } +.stat-label { font-size: 12px; color: var(--text-muted); font-weight: 500; } +.stat-sub { font-size: 11px; color: var(--text-muted); margin-top: 4px; } +.stat-icon { + width: 42px; height: 42px; border-radius: 12px; + display: flex; align-items: center; justify-content: center; + font-size: 20px; margin-bottom: 12px; +} + +/* Color variants */ +.stat-card.accent { --c: var(--accent); } +.stat-card.green { --c: var(--green); } +.stat-card.yellow { --c: var(--yellow); } +.stat-card.red { --c: var(--red); } +.stat-card.purple { --c: var(--purple); } +.stat-card.orange { --c: var(--orange); } +.stat-card.cyan { --c: var(--cyan); } +.stat-card.accent .stat-value { color: var(--accent); } +.stat-card.green .stat-value { color: var(--green); } +.stat-card.yellow .stat-value { color: var(--yellow); } +.stat-card.red .stat-value { color: var(--red); } +.stat-card.purple .stat-value { color: var(--purple); } +.stat-card.orange .stat-value { color: var(--orange); } +.stat-card.cyan .stat-value { color: var(--cyan); } +.stat-card.accent .stat-icon { background: rgba(129,140,248,.15); color: var(--accent); } +.stat-card.green .stat-icon { background: rgba(52,211,153,.15); color: var(--green); } +.stat-card.yellow .stat-icon { background: rgba(251,191,36,.15); color: var(--yellow); } +.stat-card.red .stat-icon { background: rgba(248,113,113,.15); color: var(--red); } +.stat-card.purple .stat-icon { background: rgba(167,139,250,.15); color: var(--purple); } +.stat-card.orange .stat-icon { background: rgba(251,146,60,.15); color: var(--orange); } + +/* ─── Dashboard grid (legacy) ───────────────────── */ +.dashboard-grid { display: grid; grid-template-columns: 1fr 340px; gap: 18px; } +@media (max-width: 960px) { .dashboard-grid { grid-template-columns: 1fr; } } + +/* ─── Nifty 사이드바 계층 메뉴 ──────────────────── */ +.nav-group-header { + display: flex; align-items: center; gap: 8px; + padding: 10px 16px; cursor: pointer; + color: var(--text-muted); font-size: 12px; font-weight: 700; + text-transform: uppercase; letter-spacing: .6px; + border-radius: 6px; margin: 1px 6px; + transition: color .15s, background .15s; +} +.nav-group-header:hover { color: var(--text-bright); background: var(--surface-2); } +.nav-group-header .nav-arrow { + margin-left: auto; font-size: 10px; transition: transform .2s; + color: var(--text-muted); +} +.nav-group-header[aria-expanded="true"] .nav-arrow { transform: rotate(180deg); } + +.nav-group-body { + display: none; padding: 2px 6px 4px 30px; +} +.nav-group-body.open { display: block; } + +.nav-sub-item { + display: block; padding: 7px 12px; font-size: 13px; + color: var(--text-muted); text-decoration: none; + border-radius: 6px; cursor: pointer; transition: all .15s; +} +.nav-sub-item:hover { background: var(--surface-2); color: var(--text-bright); } +.nav-sub-item.active { background: rgba(129,140,248,.15); color: var(--accent); font-weight: 600; } + +/* Topbar */ +#topbar { + position: sticky; top: 0; z-index: 100; + display: flex; align-items: center; gap: 12px; + padding: 0 20px; height: 54px; + background: var(--topbar-bg, var(--surface)); + border-bottom: 1px solid var(--border); +} +#topbar-search input { + background: var(--surface-2); border: 1px solid var(--border); + border-radius: 8px; padding: 6px 12px; font-size: 13px; + color: var(--text-bright); width: 260px; outline: none; +} +#topbar-search input:focus { border-color: var(--accent); } +#topbar-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; } +.topbar-icon { + padding: 6px 10px; border-radius: 8px; cursor: pointer; + color: var(--text-muted); font-size: 16px; position: relative; + background: none; border: none; + transition: background .15s, color .15s; +} +.topbar-icon:hover { background: var(--surface-2); color: var(--text-bright); } +.topbar-icon .badge { + position: absolute; top: 2px; right: 2px; + background: #f87171; color: #fff; font-size: 9px; + border-radius: 50%; width: 14px; height: 14px; + display: flex; align-items: center; justify-content: center; + font-weight: 700; +} + +/* Page tabs (서브페이지 탭) */ +.page-tabs { + display: flex; gap: 0; border-bottom: 2px solid var(--border); + margin-bottom: 18px; overflow-x: auto; scrollbar-width: none; +} +.page-tabs::-webkit-scrollbar { display: none; } +.page-tab { + padding: 9px 18px; background: none; border: none; + color: var(--text-muted); font-size: 13px; font-weight: 600; + cursor: pointer; border-bottom: 3px solid transparent; + margin-bottom: -2px; white-space: nowrap; + transition: color .15s, border-color .15s; +} +.page-tab:hover { color: var(--accent); } +.page-tab.active { color: var(--accent); border-bottom-color: var(--accent); } +.tab-panel { display: none; } +.tab-panel.active { display: block; } + +/* ─── Dashboard Tabs ─────────────────────────────── */ +.dash-tab-nav { + display: flex; gap: 2px; margin-bottom: 16px; + border-bottom: 2px solid var(--border); + overflow-x: auto; scrollbar-width: none; +} +.dash-tab-nav::-webkit-scrollbar { display: none; } +.dash-tab { + padding: 9px 18px; font-size: 13px; font-weight: 600; + color: var(--text-muted); background: none; border: none; + border-bottom: 3px solid transparent; margin-bottom: -2px; + cursor: pointer; white-space: nowrap; + transition: color .18s, border-color .18s; +} +.dash-tab:hover { color: var(--accent); } +.dash-tab.active { color: var(--accent); border-bottom-color: var(--accent); } + +.dash-tab-panel { display: none; } +.dash-tab-panel.active { display: block; } + +/* ─── Chart grid ─────────────────────────────────── */ +.chart-grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } +@media (max-width: 960px) { .chart-grid-2 { grid-template-columns: 1fr; } } + +.chart-card { min-height: 280px; } +.chart-body { padding: 14px 16px; display: flex; align-items: center; justify-content: center; } +.chart-body canvas { max-height: 240px; width: 100% !important; } + +/* ─── Server health tile ─────────────────────────── */ +.server-tile { + display: inline-flex; flex-direction: column; align-items: center; + gap: 4px; padding: 10px 12px; border-radius: 8px; + font-size: 11px; font-weight: 600; cursor: default; + min-width: 80px; text-align: center; +} +.server-tile.ok { background: rgba(52,211,153,.18); color: #34d399; } +.server-tile.warn { background: rgba(251,191,36,.18); color: #fbbf24; } +.server-tile.critical{ background: rgba(239,68,68,.18); color: #f87171; } +.server-tile.unknown { background: rgba(148,163,184,.12); color: #94a3b8; } +.server-tile-name { font-size: 10px; color: var(--text-muted); max-width: 80px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* Recent SR rows */ +.recent-row { + display: flex; align-items: center; gap: 10px; + padding: 10px 18px; border-bottom: 1px solid var(--border); + cursor: pointer; transition: background .12s; +} +.recent-row:last-child { border-bottom: none; } +.recent-row:hover { background: rgba(255,255,255,.03); } +.recent-sr-id { font-size: 11px; color: var(--text-muted); font-family: monospace; min-width: 148px; } +.recent-title { flex: 1; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.recent-time { font-size: 11px; color: var(--text-muted); } + +/* Status bar chart */ +.status-bar-list { padding: 14px 18px; } +.status-bar-item { margin-bottom: 12px; } +.status-bar-label { display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 5px; } +.status-bar-track { height: 7px; background: rgba(255,255,255,.06); border-radius: 4px; overflow: hidden; } +.status-bar-fill { height: 100%; border-radius: 4px; transition: width .5s ease; } + +/* ══════════════════════════════════════════════════ + BADGES +══════════════════════════════════════════════════ */ +.badge { + display: inline-block; padding: 3px 9px; border-radius: 20px; + font-size: 11px; font-weight: 700; white-space: nowrap; letter-spacing: .02em; +} +.badge-RECEIVED { background: rgba(100,116,139,.2); color: #94a3b8; } +.badge-PARSED { background: rgba(34,211,238,.15); color: #22d3ee; } +.badge-PENDING_APPROVAL { background: rgba(251,191,36,.15); color: #fbbf24; } +.badge-APPROVED { background: rgba(52,211,153,.15); color: #34d399; } +.badge-IN_PROGRESS { background: rgba(129,140,248,.15); color: #818cf8; } +.badge-PENDING_PM_VALIDATION { background: rgba(167,139,250,.18); color: #a78bfa; } +.badge-COMPLETED { background: rgba(52,211,153,.2); color: #34d399; } +.badge-FAILED_ROLLBACK { background: rgba(248,113,113,.2); color: #f87171; } +.badge-REJECTED { background: rgba(248,113,113,.15); color: #fca5a5; } + +.badge-priority-CRITICAL { background: rgba(248,113,113,.2); color: #f87171; } +.badge-priority-HIGH { background: rgba(251,146,60,.18); color: #fb923c; } +.badge-priority-MEDIUM { background: rgba(251,191,36,.15); color: #fbbf24; } +.badge-priority-LOW { background: rgba(100,116,139,.15); color: #94a3b8; } + +.badge-type-DEPLOY { background: rgba(129,140,248,.15); color: #818cf8; } +.badge-type-RESTART { background: rgba(248,113,113,.15); color: #f87171; } +.badge-type-LOG { background: rgba(167,139,250,.15); color: #a78bfa; } +.badge-type-INQUIRY { background: rgba(34,211,238,.12); color: #22d3ee; } +.badge-type-OTHER { background: rgba(100,116,139,.12); color: #94a3b8; } + +/* ══════════════════════════════════════════════════ + KANBAN +══════════════════════════════════════════════════ */ +#kanban-board { + display: flex; gap: 14px; overflow-x: auto; + padding-bottom: 16px; min-height: calc(100vh - 110px); +} +#kanban-board::-webkit-scrollbar { height: 5px; } +#kanban-board::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } + +.kanban-col { + min-width: 244px; flex-shrink: 0; + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + display: flex; flex-direction: column; + max-height: calc(100vh - 110px); +} +.kanban-col-header { + padding: 11px 14px; font-size: 12px; font-weight: 700; + color: var(--text-muted); border-bottom: 1px solid var(--border); + display: flex; align-items: center; gap: 8px; flex-shrink: 0; + text-transform: uppercase; letter-spacing: .04em; +} +.kanban-col-header .col-count { + background: rgba(255,255,255,.08); border-radius: 10px; + padding: 2px 7px; font-size: 11px; font-weight: 700; +} +.kanban-cards { flex: 1; overflow-y: auto; padding: 10px; display: flex; flex-direction: column; gap: 8px; } + +.kanban-card { + background: var(--card-inner); + border: 1px solid var(--border); + border-radius: var(--radius); padding: 12px; + cursor: pointer; transition: border-color .15s, transform .15s; +} +.kanban-card:hover { border-color: var(--accent); transform: translateY(-1px); box-shadow: var(--shadow-sm); } +.kanban-card-id { font-size: 10px; color: var(--text-muted); font-family: monospace; margin-bottom: 5px; } +.kanban-card-title { font-size: 13px; color: var(--text-bright); line-height: 1.45; margin-bottom: 8px; font-weight: 500; } +.kanban-card-meta { display: flex; gap: 4px; flex-wrap: wrap; } + +/* ══════════════════════════════════════════════════ + SR TABLE +══════════════════════════════════════════════════ */ +.list-toolbar { display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; } +.search-box { + flex: 1; min-width: 200px; + background: var(--card-bg); border: 1px solid var(--border); + border-radius: var(--radius); padding: 8px 14px; + color: var(--text-primary); font-size: 13px; outline: none; + transition: border-color .15s; +} +.search-box:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(99,102,241,.15); } +.filter-select { + background: var(--card-bg); border: 1px solid var(--border); + border-radius: var(--radius); padding: 8px 12px; + color: var(--text-primary); font-size: 13px; outline: none; cursor: pointer; +} +.filter-select:focus { border-color: var(--accent); } + +.sr-table { width: 100%; border-collapse: separate; border-spacing: 0; } +.sr-table th { + text-align: left; padding: 11px 14px; font-size: 11px; + font-weight: 700; color: var(--text-muted); text-transform: uppercase; + letter-spacing: .06em; border-bottom: 1px solid var(--border); + background: var(--card-bg); +} +.sr-table td { padding: 10px 14px; border-bottom: 1px solid var(--border); font-size: 13px; } +.sr-table tbody tr { cursor: pointer; transition: background .12s; } +.sr-table tbody tr:hover { background: rgba(255,255,255,.03); } + +/* ══════════════════════════════════════════════════ + AUDIT +══════════════════════════════════════════════════ */ +.audit-header-row { + display: flex; align-items: center; gap: 12px; + margin-bottom: 14px; flex-wrap: wrap; +} +.audit-title { font-size: 15px; font-weight: 700; color: var(--text-bright); flex: 1; } +#verify-result { font-size: 13px; font-weight: 700; } +#verify-result.ok { color: var(--green); } +#verify-result.fail { color: var(--red); } +.hash-code { font-family: "Consolas", monospace; font-size: 11px; color: var(--text-muted); } + +/* ══════════════════════════════════════════════════ + CMDB +══════════════════════════════════════════════════ */ +.cmdb-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; } +.cmdb-card { + background: var(--card-bg); border: 1px solid var(--border); + border-radius: var(--radius-lg); box-shadow: var(--shadow-md); overflow: hidden; +} +.cmdb-card-header { + padding: 14px 18px; border-bottom: 1px solid var(--border); + font-weight: 700; color: var(--text-bright); display: flex; justify-content: space-between; align-items: center; +} +.cmdb-servers { padding: 8px 0; } +.cmdb-server-row { + display: flex; align-items: center; gap: 10px; + padding: 8px 18px; font-size: 13px; transition: background .12s; +} +.cmdb-server-row:hover { background: rgba(255,255,255,.03); } +.server-role-badge { + padding: 3px 7px; border-radius: 6px; font-size: 10px; font-weight: 700; +} +.role-WEB { background: rgba(129,140,248,.18); color: #818cf8; } +.role-WAS { background: rgba(52,211,153,.18); color: #34d399; } +.role-DB { background: rgba(167,139,250,.18); color: #a78bfa; } +.server-active { color: var(--green); font-size: 12px; margin-left: auto; } +.server-inactive { color: var(--text-muted); font-size: 12px; margin-left: auto; } + +/* ══════════════════════════════════════════════════ + MODAL +══════════════════════════════════════════════════ */ +#modal-overlay, #new-sr-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,.65); + z-index: 100; display: flex; align-items: center; justify-content: center; + backdrop-filter: blur(2px); +} +#modal-overlay.hidden, #new-sr-overlay.hidden { display: none; } + +#modal { + background: var(--card-bg); border: 1px solid var(--border); + border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); + width: 680px; max-width: 96vw; max-height: 86vh; + overflow-y: auto; padding: 28px; position: relative; +} +#new-sr-modal { + background: var(--card-bg); border: 1px solid var(--border); + border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); + width: 520px; max-width: 96vw; padding: 28px; position: relative; +} +#new-sr-modal h2 { font-size: 17px; font-weight: 800; margin-bottom: 18px; color: var(--text-bright); } + +.modal-close { + position: absolute; top: 16px; right: 16px; + background: rgba(255,255,255,.06); border: 1px solid var(--border); + color: var(--text-muted); font-size: 18px; cursor: pointer; + line-height: 1; border-radius: var(--radius); padding: 4px 8px; + transition: color .15s, background .15s; +} +.modal-close:hover { color: var(--text-bright); background: rgba(255,255,255,.12); } + +.modal-title { font-size: 19px; font-weight: 800; color: var(--text-bright); margin-bottom: 12px; line-height: 1.3; } +.modal-meta { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 18px; } +.modal-section { margin-bottom: 18px; } +.modal-section-title { + font-size: 11px; font-weight: 700; color: var(--text-muted); + text-transform: uppercase; letter-spacing: .07em; margin-bottom: 8px; +} +.modal-desc { font-size: 13px; color: var(--text-primary); line-height: 1.65; } +.modal-actions { display: flex; gap: 8px; margin-top: 18px; } + +/* Timeline */ +.timeline { border-left: 2px solid var(--border); padding-left: 18px; } +.timeline-item { position: relative; margin-bottom: 14px; } +.timeline-item::before { + content: ""; position: absolute; left: -22px; top: 5px; + width: 8px; height: 8px; border-radius: 50%; + background: var(--border); border: 2px solid var(--card-bg); +} +.timeline-item.done::before { background: var(--green); } +.timeline-item.active::before { background: var(--accent); box-shadow: 0 0 6px var(--accent); } +.timeline-action { font-size: 12px; font-weight: 700; color: var(--text-primary); } +.timeline-detail { font-size: 11px; color: var(--text-muted); margin-top: 1px; } + +/* Form */ +label { display: block; margin-bottom: 12px; font-size: 12px; color: var(--text-muted); font-weight: 600; letter-spacing: .02em; } +label input, label select, label textarea { + display: block; width: 100%; margin-top: 5px; + background: var(--input-bg); border: 1px solid var(--border); + border-radius: var(--radius); padding: 9px 12px; + color: var(--text-primary); font-size: 13px; outline: none; + font-family: var(--font); transition: border-color .15s; +} +label input:focus, label select:focus, label textarea:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(99,102,241,.12); +} +.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } + +/* ══════════════════════════════════════════════════ + WORKLOAD PANEL +══════════════════════════════════════════════════ */ +.workload-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 12px; padding: 4px 0; +} +.workload-eng-card { + background: var(--card-inner); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px; transition: border-color .15s, transform .15s; +} +.workload-eng-card:hover { border-color: var(--accent); transform: translateY(-1px); } +.workload-eng-header { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; } +.eng-avatar { + width: 36px; height: 36px; border-radius: 50%; + background: linear-gradient(135deg, var(--accent-dark), #8b5cf6); + color: #fff; font-size: 15px; font-weight: 800; + display: flex; align-items: center; justify-content: center; flex-shrink: 0; + box-shadow: 0 3px 10px rgba(99,102,241,.35); +} +.eng-name { font-size: 13px; font-weight: 700; color: var(--text-bright); } +.eng-count { margin-left: auto; font-size: 20px; font-weight: 800; } +.workload-bar-track { height: 6px; background: rgba(255,255,255,.07); border-radius: 4px; margin-bottom: 8px; overflow: hidden; } +.workload-bar-fill { height: 100%; border-radius: 4px; transition: width .5s ease; } +.workload-tags { display: flex; gap: 4px; flex-wrap: wrap; } + +/* 엔지니어 대시보드 개인 워크로드 바 */ +.eng-workload-bar-wrap { + background: var(--card-bg); border: 1px solid var(--border); + border-radius: var(--radius-lg); padding: 16px 20px; + margin-bottom: 18px; box-shadow: var(--shadow-sm); +} + +/* Tags */ +.skill-tag { + font-size: 10px; padding: 3px 8px; border-radius: 20px; font-weight: 600; + background: rgba(129,140,248,.12); color: var(--accent); + border: 1px solid rgba(129,140,248,.25); +} +.inst-tag { + background: rgba(167,139,250,.12); color: var(--purple); + border: 1px solid rgba(167,139,250,.25); +} +.eng-chip { + display: inline-flex; align-items: center; gap: 4px; + background: rgba(52,211,153,.12); color: var(--green); + border: 1px solid rgba(52,211,153,.25); + border-radius: 20px; font-size: 11px; padding: 3px 9px; font-weight: 700; +} + +/* ══════════════════════════════════════════════════ + KNOWLEDGE BASE +══════════════════════════════════════════════════ */ +#kb-results { display: flex; flex-direction: column; gap: 10px; padding: 4px 0; } + +.kb-card { + background: var(--card-bg); border: 1px solid var(--border); + border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); overflow: hidden; +} +.kb-card-header { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 18px; cursor: pointer; gap: 12px; transition: background .12s; +} +.kb-card-header:hover { background: rgba(255,255,255,.03); } +.kb-card-title { font-size: 14px; font-weight: 700; color: var(--text-bright); flex: 1; } +.kb-toggle-icon { color: var(--text-muted); font-size: 12px; } +.kb-score { + font-size: 11px; font-weight: 700; + background: rgba(129,140,248,.12); color: var(--accent); + border: 1px solid rgba(129,140,248,.25); + border-radius: 20px; padding: 3px 9px; white-space: nowrap; +} +.kb-keywords { padding: 0 18px 10px; display: flex; gap: 4px; flex-wrap: wrap; } +.kb-keywords code { + font-size: 10px; padding: 2px 7px; border-radius: 4px; + background: rgba(255,255,255,.06); color: var(--text-muted); +} +.kb-cat-badge { + font-size: 10px; font-weight: 700; padding: 3px 8px; + border-radius: 6px; border: 1px solid; white-space: nowrap; flex-shrink: 0; +} +.kb-card-body { border-top: 1px solid var(--border); padding: 14px 18px; } +.kb-section { margin-bottom: 14px; } +.kb-section-label { + font-size: 11px; font-weight: 700; color: var(--text-muted); + text-transform: uppercase; letter-spacing: .05em; margin-bottom: 5px; +} +.kb-section-text { font-size: 13px; color: var(--text-primary); line-height: 1.65; } +.kb-pre { + font-size: 12px; color: var(--accent); + background: var(--card-inner); border: 1px solid var(--border); + border-radius: var(--radius); padding: 12px 14px; + white-space: pre-wrap; word-break: break-all; + font-family: "Consolas", "Monaco", monospace; line-height: 1.5; +} +.kb-pre.cmd { color: var(--green); } +.kb-doc-id { font-size: 11px; color: var(--text-muted); font-family: monospace; } + +.kb-card-compact { + background: var(--card-inner); border: 1px solid var(--border); + border-radius: var(--radius); padding: 10px 14px; cursor: pointer; + margin-bottom: 7px; transition: border-color .15s; +} +.kb-card-compact:hover { border-color: var(--accent); } +.kb-compact-header { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.kb-compact-title { + font-size: 13px; font-weight: 600; color: var(--text-bright); + flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} + +/* ══════════════════════════════════════════════════ + AI CHAT PANEL +══════════════════════════════════════════════════ */ +#ai-chat-fab { + position: fixed; bottom: 28px; right: 28px; z-index: 900; + width: 54px; height: 54px; border-radius: 50%; + background: linear-gradient(135deg, var(--accent-dark), #8b5cf6); + color: #fff; font-size: 22px; border: none; cursor: pointer; + box-shadow: 0 6px 24px rgba(99,102,241,.5); + display: flex; align-items: center; justify-content: center; + transition: transform .2s, box-shadow .2s; +} +#ai-chat-fab:hover { transform: scale(1.1); box-shadow: 0 8px 32px rgba(99,102,241,.65); } + +#ai-chat-panel { + position: fixed; bottom: 96px; right: 28px; z-index: 901; + width: 370px; max-height: 570px; + background: var(--card-bg); border: 1px solid var(--border); + border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); + display: flex; flex-direction: column; +} +#ai-chat-panel.hidden { display: none; } + +#ai-chat-header { + display: flex; align-items: center; justify-content: space-between; + padding: 13px 16px; border-bottom: 1px solid var(--border); + font-size: 13px; font-weight: 800; color: var(--text-bright); +} +#ai-chat-close { + background: none; border: none; color: var(--text-muted); + font-size: 18px; cursor: pointer; padding: 0 4px; line-height: 1; +} +#ai-chat-close:hover { color: var(--text-bright); } + +#ai-chat-messages { + flex: 1; overflow-y: auto; padding: 14px; + display: flex; flex-direction: column; gap: 8px; min-height: 200px; +} +.chat-msg { + max-width: 90%; padding: 9px 13px; + border-radius: var(--radius-lg); font-size: 13px; line-height: 1.6; word-break: break-word; +} +.chat-ai { align-self: flex-start; background: var(--card-inner); border: 1px solid var(--border); color: var(--text-primary); } +.chat-user { align-self: flex-end; background: var(--accent-dark); color: #fff; } +.chat-bullet { color: var(--accent); } +.chat-links { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; } +.chat-sr-link { + font-size: 11px; padding: 2px 7px; border-radius: 4px; + background: rgba(129,140,248,.12); color: var(--accent); + border: 1px solid rgba(129,140,248,.25); cursor: pointer; font-family: monospace; +} +.chat-sr-link:hover { background: rgba(129,140,248,.25); } + +#ai-chat-suggestions { padding: 6px 10px 2px; display: flex; flex-wrap: wrap; gap: 4px; border-top: 1px solid var(--border); } +.chat-suggestion { + font-size: 11px; padding: 3px 10px; border-radius: 20px; + background: var(--card-inner); color: var(--text-muted); + border: 1px solid var(--border); cursor: pointer; transition: color .15s, border-color .15s; +} +.chat-suggestion:hover { color: var(--text-bright); border-color: var(--accent); } + +#ai-chat-input-row { display: flex; gap: 6px; padding: 10px; border-top: 1px solid var(--border); } +#ai-chat-input { + flex: 1; background: var(--card-inner); border: 1px solid var(--border); + border-radius: var(--radius); padding: 8px 12px; + color: var(--text-primary); font-size: 13px; outline: none; font-family: var(--font); +} +#ai-chat-input:focus { border-color: var(--accent); } +#ai-chat-send { + background: var(--accent-dark); color: #fff; border: none; + border-radius: var(--radius); padding: 8px 16px; + font-size: 13px; font-weight: 700; cursor: pointer; transition: background .15s; +} +#ai-chat-send:hover { background: #818cf8; } + +/* ══════════════════════════════════════════════════ + PHASE 6 — 실시간 / 추이 차트 / 토스트 +══════════════════════════════════════════════════ */ + +/* 7일 추이 차트 컨테이너 */ +.trend-chart { + width: 100%; min-height: 130px; + display: flex; align-items: center; justify-content: center; +} +.trend-chart svg { width: 100%; } +.trend-legend { display: flex; gap: 16px; margin-top: 8px; } +.trend-legend-item { display: flex; align-items: center; gap: 5px; font-size: 11px; color: var(--text-muted); } +.trend-legend-dot { width: 10px; height: 10px; border-radius: 2px; } + +/* 토스트 알림 */ +.toast { + position: fixed; bottom: 90px; left: 50%; + transform: translateX(-50%) translateY(20px); + background: var(--card-bg); border: 1px solid var(--border); + border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); + padding: 12px 20px; font-size: 13px; font-weight: 600; + color: var(--text-bright); z-index: 9999; + opacity: 0; transition: opacity .3s, transform .3s; + white-space: nowrap; pointer-events: none; + max-width: 90vw; +} +.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } +.toast-info { border-left: 3px solid var(--accent); } +.toast-success { border-left: 3px solid var(--green); } +.toast-warning { border-left: 3px solid var(--yellow); } +.toast-error { border-left: 3px solid var(--red); } + +/* ─── Scrollbar ─────────────────────────────────── */ +* { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,.08) transparent; } +*::-webkit-scrollbar { width: 5px; height: 5px; } +*::-webkit-scrollbar-thumb { background: rgba(255,255,255,.1); border-radius: 3px; } +*::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,.18); } + +/* ══════════════════════════════════════════════════ + THEME SWITCHER (사이드바 하단) +══════════════════════════════════════════════════ */ +#theme-switcher { + padding: 10px 14px; + border-top: 1px solid var(--border); +} +.theme-switcher-label { + font-size: 10px; font-weight: 700; color: var(--text-muted); + text-transform: uppercase; letter-spacing: .07em; margin-bottom: 7px; +} +.theme-swatches { display: flex; gap: 6px; } +.theme-swatch { + flex: 1; padding: 6px 4px; border-radius: var(--radius); + background: rgba(255,255,255,.05); border: 1px solid var(--border); + color: var(--text-muted); cursor: pointer; font-size: 10px; font-weight: 700; + transition: all .18s; display: flex; flex-direction: column; + align-items: center; justify-content: center; gap: 3px; line-height: 1; +} +.theme-swatch:hover { background: rgba(255,255,255,.1); color: var(--text-primary); border-color: var(--accent); } +.theme-swatch.active { background: var(--accent-dark); color: #fff; border-color: transparent; box-shadow: 0 2px 10px rgba(99,102,241,.4); } +.theme-swatch-icon { font-size: 14px; } +.theme-swatch-name { font-size: 9px; } + +/* ══════════════════════════════════════════════════ + THEME: LIGHT ☀️ +══════════════════════════════════════════════════ */ +body[data-theme="light"] { + color-scheme: light; + --main-bg: #f0f4f8; + --sidebar-bg: #ffffff; + --card-bg: #ffffff; + --card-inner: #f8fafc; + --input-bg: #f8fafc; + --border: rgba(15,23,42,.09); + --shadow-sm: 0 2px 8px rgba(15,23,42,.06); + --shadow-md: 0 4px 20px rgba(15,23,42,.08); + --shadow-lg: 0 8px 40px rgba(15,23,42,.12); + --text-bright: #0f172a; + --text-primary: #334155; + --text-muted: #64748b; + --sidebar-hover-bg: rgba(15,23,42,.04); +} +/* 라이트 테마 트랙 배경 */ +body[data-theme="light"] .status-bar-track, +body[data-theme="light"] .workload-bar-track { background: rgba(15,23,42,.08); } +/* 라이트 테마 코드 블록 */ +body[data-theme="light"] .kb-pre { + background: #f1f5f9; color: #4f46e5; + border-color: rgba(15,23,42,.09); +} +body[data-theme="light"] .kb-pre.cmd { color: #059669; } +/* 라이트 테마 스크롤바 */ +body[data-theme="light"] * { scrollbar-color: rgba(15,23,42,.12) transparent; } +body[data-theme="light"] *::-webkit-scrollbar-thumb { background: rgba(15,23,42,.12); } +body[data-theme="light"] *::-webkit-scrollbar-thumb:hover { background: rgba(15,23,42,.22); } +/* 라이트 테마 사이드바 시스템 */ +body[data-theme="light"] #sidebar { + box-shadow: 2px 0 20px rgba(15,23,42,.08); +} +/* 라이트 모드 KB keywords */ +body[data-theme="light"] .kb-keywords code { + background: rgba(15,23,42,.06); color: #475569; +} +/* 라이트 chat */ +body[data-theme="light"] .chat-user { background: #6366f1; } +/* 라이트 theme-swatch active */ +body[data-theme="light"] .theme-swatch { background: rgba(15,23,42,.04); border-color: rgba(15,23,42,.1); } +body[data-theme="light"] .theme-swatch:hover { background: rgba(15,23,42,.08); } +body[data-theme="light"] .theme-swatch.active { background: #6366f1; color: #fff; border-color: transparent; } + +/* ══════════════════════════════════════════════════ + THEME: MIDNIGHT 🌌 +══════════════════════════════════════════════════ */ +body[data-theme="midnight"] { + --main-bg: #020408; + --sidebar-bg: #060c18; + --card-bg: #0a1020; + --card-inner: #020408; + --input-bg: #020408; + --border: rgba(255,255,255,.04); + --shadow-sm: 0 2px 8px rgba(0,0,0,.6); + --shadow-md: 0 4px 20px rgba(0,0,0,.6); + --shadow-lg: 0 8px 40px rgba(0,0,0,.7); + --accent: #22d3ee; + --accent-dark: #0891b2; + --green: #34d399; + --sidebar-active-bg: #0891b2; +} +/* 미드나잇 active 버튼 */ +body[data-theme="midnight"] .nav-item.active { + background: linear-gradient(90deg, #0891b2, #06b6d4); + box-shadow: 0 4px 14px rgba(6,182,212,.4); +} +body[data-theme="midnight"] .btn-primary { + background: linear-gradient(135deg, #0891b2, #06b6d4); + box-shadow: 0 2px 10px rgba(6,182,212,.4); +} +body[data-theme="midnight"] .btn-primary:hover { box-shadow: 0 4px 18px rgba(6,182,212,.55); } +body[data-theme="midnight"] #ai-chat-send { background: #0891b2; } +body[data-theme="midnight"] #ai-chat-send:hover { background: #22d3ee; } +body[data-theme="midnight"] .chat-user { background: #0891b2; } +body[data-theme="midnight"] .logo-icon { background: linear-gradient(135deg, #0891b2, #06b6d4); box-shadow: 0 4px 14px rgba(6,182,212,.4); } +body[data-theme="midnight"] .eng-avatar { background: linear-gradient(135deg, #0891b2, #06b6d4); box-shadow: 0 3px 10px rgba(6,182,212,.3); } +body[data-theme="midnight"] #ai-chat-fab { background: linear-gradient(135deg, #0891b2, #06b6d4); box-shadow: 0 6px 24px rgba(6,182,212,.45); } +/* 미드나잇 theme-swatch active */ +body[data-theme="midnight"] .theme-swatch.active { background: #0891b2; box-shadow: 0 2px 10px rgba(6,182,212,.4); } + +/* ══════════════════════════════════════════════════ + PHASE 3 — 기관관리 · 스크립트 · 타임테이블 +══════════════════════════════════════════════════ */ + +/* ── 사이드바 구분선 ────────────────────────────── */ +.nav-separator { + height: 1px; + background: var(--border); + margin: 8px 12px; +} + +/* ── 모달 크기 확장 ─────────────────────────────── */ +.modal-wide { + max-width: 680px; + width: 96%; +} +.modal-xlarge { + max-width: 820px; +} + +/* ── 상세 정보 그리드 ───────────────────────────── */ +.detail-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px 16px; + font-size: 13px; +} +.detail-grid > div { + display: flex; + flex-direction: column; + gap: 2px; +} +.detail-label { + font-size: 11px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: .04em; +} +.detail-text-block { + font-size: 13px; + color: var(--text-primary); + background: var(--card-inner); + border-radius: var(--radius); + padding: 10px 12px; + line-height: 1.6; + white-space: pre-wrap; + word-break: break-word; + border: 1px solid var(--border); +} + +/* ── 코드 블록 ──────────────────────────────────── */ +.code-block { + background: #0d1117; + color: #e6edf3; + border-radius: var(--radius); + padding: 12px 14px; + font-family: "Consolas", "JetBrains Mono", monospace; + font-size: 12px; + line-height: 1.6; + overflow-x: auto; + white-space: pre; + border: 1px solid rgba(255,255,255,.08); +} +.code-textarea { + font-family: "Consolas", "JetBrains Mono", monospace; + font-size: 12px; + line-height: 1.6; + background: #0d1117; + color: #e6edf3; + border-radius: var(--radius); + resize: vertical; + min-height: 120px; +} +body[data-theme="light"] .code-block, +body[data-theme="light"] .code-textarea { + background: #f6f8fa; + color: #24292f; + border-color: #d0d7de; +} + +/* ── 스크립트 카드 ──────────────────────────────── */ +.script-card { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 14px 18px; + cursor: pointer; + transition: transform .15s, box-shadow .15s; + margin-bottom: 8px; +} +.script-card:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-md); +} +.script-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 6px; +} +.script-card-name { + font-size: 14px; + font-weight: 600; + color: var(--text-bright); + font-family: "Consolas", monospace; +} +.script-card-desc { + font-size: 12px; + color: var(--text-muted); + margin-bottom: 8px; + line-height: 1.5; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.script-card-meta { + display: flex; + gap: 12px; + font-size: 11px; + color: var(--text-muted); +} + +/* ── 타임테이블 결과 상태 색상 ──────────────────── */ +.badge-tt-SUCCESS { background: rgba(52,211,153,.15); color: #34d399; } +.badge-tt-FAILED { background: rgba(248,113,113,.15); color: #f87171; } +.badge-tt-PARTIAL { background: rgba(251,191,36,.15); color: #fbbf24; } +.badge-tt-PENDING { background: rgba(148,163,184,.12); color: #94a3b8; } +.badge-tt-CANCELLED{ background: rgba(148,163,184,.08); color: #64748b; } + +/* ── modal-section-title ────────────────────────── */ +.modal-section-title { + font-size: 13px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: .06em; + margin-bottom: 8px; + padding-bottom: 6px; + border-bottom: 1px solid var(--border); +} + +/* ── 기관 테이블 컬럼 ───────────────────────────── */ +#inst-table th:nth-child(9), +#inst-table td:nth-child(9) { width: 70px; text-align: center; } + +/* ── 타임테이블 테이블 ──────────────────────────── */ +#tt-table th:nth-child(9), +#tt-table td:nth-child(9) { width: 70px; text-align: center; } + +/* ── Light theme code override ──────────────────── */ +body[data-theme="light"] .script-card { + background: #ffffff; + border-color: rgba(15,23,42,.09); +} +body[data-theme="light"] .detail-text-block { + background: #f8fafc; +} + +/* ── contact modal width ────────────────────────── */ +#contact-modal { max-width: 540px; width: 96%; } + +/* ── 파일 업로드 영역 ───────────────────────────── */ +.file-upload-area { + margin-top: 10px; + border: 2px dashed var(--border); + border-radius: 8px; + padding: 10px 14px; + transition: border-color .2s; +} +.file-upload-area:hover { border-color: var(--accent); } +.file-upload-label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 13px; + color: var(--text-muted); + user-select: none; +} +.file-upload-label:hover { color: var(--text-primary); } +.file-upload-icon { font-size: 18px; } +.file-preview-list { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; } +.file-preview-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + background: var(--bg-secondary); + border-radius: 4px; + padding: 4px 8px; +} +.file-preview-icon { font-size: 15px; flex-shrink: 0; } +.file-preview-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.file-preview-size { color: var(--text-muted); flex-shrink: 0; } + +/* ── 첨부파일 목록 ──────────────────────────────── */ +.attachment-list { display: flex; flex-direction: column; gap: 4px; } +.attachment-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 13px; + transition: background .15s; +} +.attachment-item:hover { background: var(--bg-hover); } +.att-icon { font-size: 16px; flex-shrink: 0; } +.att-name { + flex: 1; + color: var(--accent); + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; +} +.att-name:hover { text-decoration: underline; } +.att-size { color: var(--text-muted); font-size: 11px; flex-shrink: 0; } +.att-uploader { color: var(--text-muted); font-size: 11px; flex-shrink: 0; } +.att-date { color: var(--text-muted); font-size: 11px; flex-shrink: 0; } +.att-del { + background: none; + border: none; + cursor: pointer; + padding: 2px 4px; + border-radius: 4px; + font-size: 14px; + opacity: .5; + flex-shrink: 0; + transition: opacity .15s; +} +.att-del:hover { opacity: 1; background: rgba(248,113,113,.15); } + +/* ── Light theme overrides ──────────────────────── */ +body[data-theme="light"] .file-upload-area { border-color: #d1d5db; } +body[data-theme="light"] .file-upload-area:hover { border-color: var(--accent); } +body[data-theme="light"] .attachment-item { background: #f8fafc; border-color: #e5e7eb; } +body[data-theme="light"] .attachment-item:hover { background: #f1f5f9; } + +/* ── 외부 페이지 링크 네비 아이템 ──────────────── */ +.nav-link-ext { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 18px; + font-size: 13px; + color: var(--text-muted); + text-decoration: none; + border-radius: 6px; + cursor: pointer; + transition: background .15s, color .15s; + white-space: nowrap; +} +.nav-link-ext:hover { + background: var(--bg-hover, rgba(255,255,255,.05)); + color: var(--text-primary); +} + +/* 외부 페이지 공통 레이아웃 */ +.page-wrap { display: flex; flex-direction: column; min-height: 100vh; } +.topnav { + display: flex; align-items: center; gap: 16px; + padding: 0 24px; height: 52px; + background: var(--sidebar-bg); border-bottom: 1px solid var(--border); + flex-shrink: 0; position: sticky; top: 0; z-index: 50; +} +.topnav-logo { font-weight: 700; font-size: 16px; color: var(--accent); text-decoration: none; } +.topnav-links { display: flex; gap: 4px; flex-wrap: wrap; } +.topnav-link { + padding: 6px 12px; font-size: 13px; color: var(--text-muted); + text-decoration: none; border-radius: 6px; + transition: background .15s, color .15s; +} +.topnav-link:hover { background: var(--bg-hover, rgba(255,255,255,.05)); color: var(--text-primary); } +.topnav-link.active { background: rgba(129,140,248,.15); color: var(--accent); font-weight: 600; } +.topnav-right { margin-left: auto; display: flex; align-items: center; gap: 12px; } +.page-content { flex: 1; overflow-y: auto; padding: 24px; background: var(--main-bg); } +.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; } +.page-title { font-size: 22px; font-weight: 700; color: var(--text-bright); } + +/* 외부 페이지 공통 컴포넌트 */ +.ext-stats-row { display: grid; grid-template-columns: repeat(auto-fill,minmax(180px,1fr)); gap: 14px; margin-bottom: 20px; } +.ext-stat-card { + background: var(--card-bg); border: 1px solid var(--border); + border-radius: 10px; padding: 16px 18px; +} +.ext-stat-val { font-size: 28px; font-weight: 700; color: var(--accent); } +.ext-stat-lbl { font-size: 12px; color: var(--text-muted); margin-top: 2px; } +.ext-toolbar { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 16px; align-items: center; } +.ext-tabs { display: flex; gap: 4px; margin-bottom: 20px; border-bottom: 1px solid var(--border); } +.ext-tab-btn { + padding: 8px 16px; font-size: 13px; background: none; + border: none; color: var(--text-muted); cursor: pointer; + border-bottom: 2px solid transparent; margin-bottom: -1px; + transition: color .15s; +} +.ext-tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; } +.ext-tab-content { display: none; } +.ext-tab-content.active { display: block; } +.ext-modal-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,.55); + z-index: 200; display: none; align-items: center; justify-content: center; +} +.ext-modal-overlay.open { display: flex; } +.ext-modal-box { + background: var(--card-bg); border: 1px solid var(--border); + border-radius: 12px; padding: 28px; width: 560px; + max-width: 95vw; max-height: 88vh; overflow-y: auto; position: relative; +} +.ext-modal-box h2 { font-size: 18px; margin-bottom: 18px; color: var(--text-bright); } +.ext-modal-close { + position: absolute; top: 14px; right: 16px; + background: none; border: none; font-size: 22px; + cursor: pointer; color: var(--text-muted); +} +.ext-modal-close:hover { color: var(--text-primary); } +.ext-form-label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--text-muted); margin-bottom: 12px; } +.ext-form-label input, +.ext-form-label select, +.ext-form-label textarea { + background: var(--input-bg); border: 1px solid var(--border); + border-radius: 6px; padding: 8px 10px; color: var(--text-primary); font-size: 13px; +} +.ext-form-label input:focus, +.ext-form-label select:focus, +.ext-form-label textarea:focus { outline: none; border-color: var(--accent); } +.ext-form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +.ext-badge { display: inline-block; font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 4px; } +.ext-code-block { + background: #0d1117; color: #e6edf3; border-radius: 8px; + padding: 14px 16px; font-family: 'Courier New', monospace; + font-size: 12px; line-height: 1.6; overflow-x: auto; white-space: pre; + border: 1px solid rgba(255,255,255,.08); +} + +/* Light/Midnight theme overrides for ext pages */ +body[data-theme="light"] .topnav { background: #fff; border-bottom-color: #e5e7eb; } +body[data-theme="light"] .ext-stat-card { background: #fff; border-color: #e5e7eb; } +body[data-theme="light"] .ext-modal-box { background: #fff; border-color: #e5e7eb; } +body[data-theme="light"] .ext-form-label input, +body[data-theme="light"] .ext-form-label select, +body[data-theme="light"] .ext-form-label textarea { background: #f8fafc; border-color: #d1d5db; } +body[data-theme="midnight"] .topnav { background: #0a0e1a; } diff --git a/static/sw.js b/static/sw.js new file mode 100644 index 0000000..7f32290 --- /dev/null +++ b/static/sw.js @@ -0,0 +1,212 @@ +/** + * GUARDiA ITSM — Service Worker (F-4 Mobile PWA) + * + * 전략: + * - 정적 자산 : Cache-First (빠른 로딩) + * - API 요청 : Network-First (최신 데이터 우선, 오프라인 시 캐시) + * - 오프라인 : /static/offline.html 폴백 + * + * 캐시 버전이 바뀌면 이전 캐시를 자동 정리합니다. + */ + +const CACHE_VERSION = 'guardia-v1'; +const STATIC_CACHE = `${CACHE_VERSION}-static`; +const API_CACHE = `${CACHE_VERSION}-api`; +const OFFLINE_URL = '/static/offline.html'; + +// 설치 시 사전 캐싱할 정적 자산 +const PRECACHE_URLS = [ + '/', + '/static/offline.html', + '/static/manifest.json', + '/static/style.css', + '/static/app.js', + '/static/login.html', + '/static/login.css', + '/static/login.js', + '/static/index.html', + '/static/customer.html', +]; + +// API 캐시 대상 접두사 +const API_CACHE_PREFIXES = [ + '/api/dashboard', + '/api/sr', + '/api/incidents', + '/api/metrics/summary', + '/api/metrics/health', +]; + +// 캐시하지 않을 API 경로 (실시간·민감 데이터) +const NO_CACHE_PATTERNS = [ + '/api/auth', + '/api/audit', + '/api/pam', + '/api/otp', + '/ws', +]; + +// ── 설치 (Install) ───────────────────────────────────────────────────────── +self.addEventListener('install', event => { + event.waitUntil( + caches.open(STATIC_CACHE) + .then(cache => cache.addAll(PRECACHE_URLS).catch(() => { + // 일부 파일이 없어도 SW 설치 계속 진행 + return Promise.resolve(); + })) + .then(() => self.skipWaiting()) + ); +}); + +// ── 활성화 (Activate) — 이전 캐시 정리 ───────────────────────────────────── +self.addEventListener('activate', event => { + event.waitUntil( + caches.keys().then(keys => + Promise.all( + keys + .filter(k => k.startsWith('guardia-') && k !== STATIC_CACHE && k !== API_CACHE) + .map(k => caches.delete(k)) + ) + ).then(() => self.clients.claim()) + ); +}); + +// ── Fetch 인터셉트 ───────────────────────────────────────────────────────── +self.addEventListener('fetch', event => { + const { request } = event; + const url = new URL(request.url); + + // 다른 오리진 요청은 처리하지 않음 (Ollama 등 내부 서비스 포함) + if (url.origin !== self.location.origin) return; + + // WebSocket / POST/PUT/DELETE → 캐시 안 함 + if (request.method !== 'GET') return; + + // 보안 민감 경로 → 캐시 안 함 + if (NO_CACHE_PATTERNS.some(p => url.pathname.startsWith(p))) return; + + // API 요청 → Network-First + if (url.pathname.startsWith('/api/')) { + event.respondWith(networkFirst(request, API_CACHE)); + return; + } + + // 정적 자산 → Cache-First + event.respondWith(cacheFirst(request, STATIC_CACHE)); +}); + +// ── 전략 함수 ────────────────────────────────────────────────────────────── + +/** + * Cache-First: 캐시 → 네트워크 → 오프라인 폴백 + */ +async function cacheFirst(request, cacheName) { + const cached = await caches.match(request); + if (cached) return cached; + + try { + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(cacheName); + cache.put(request, response.clone()); + } + return response; + } catch { + const offline = await caches.match(OFFLINE_URL); + return offline || new Response('오프라인 상태입니다.', { + status: 503, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); + } +} + +/** + * Network-First: 네트워크 → 캐시 → 오프라인 폴백 + * API 응답은 5분간 캐시 유지 + */ +async function networkFirst(request, cacheName) { + try { + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(cacheName); + // API 응답에 캐시 타임스탬프 추가 + const cloned = response.clone(); + cache.put(request, cloned); + } + return response; + } catch { + const cached = await caches.match(request); + if (cached) return cached; + + // HTML 요청 시 오프라인 페이지 + if (request.headers.get('Accept')?.includes('text/html')) { + const offline = await caches.match(OFFLINE_URL); + if (offline) return offline; + } + + return new Response( + JSON.stringify({ error: '오프라인 상태입니다.', offline: true }), + { + status: 503, + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + } + ); + } +} + +// ── 백그라운드 동기화 ────────────────────────────────────────────────────── +self.addEventListener('sync', event => { + if (event.tag === 'guardia-sync-sr') { + event.waitUntil(syncPendingSR()); + } +}); + +async function syncPendingSR() { + // IndexedDB에서 오프라인 중 생성된 SR 대기열 처리 (향후 구현) + // 현재는 로그만 기록 + console.log('[GUARDiA SW] 오프라인 SR 동기화 시작'); +} + +// ── 푸시 알림 ────────────────────────────────────────────────────────────── +self.addEventListener('push', event => { + if (!event.data) return; + + let data; + try { + data = event.data.json(); + } catch { + data = { title: 'GUARDiA 알림', body: event.data.text() }; + } + + const options = { + body: data.body || '새 알림이 있습니다.', + icon: '/static/icons/icon-192.png', + badge: '/static/icons/icon-72.png', + tag: data.tag || 'guardia-notification', + data: data.data || {}, + actions: data.actions || [ + { action: 'view', title: '확인' }, + { action: 'dismiss', title: '닫기' }, + ], + requireInteraction: data.severity === 'CRITICAL', + }; + + event.waitUntil( + self.registration.showNotification(data.title || 'GUARDiA ITSM', options) + ); +}); + +self.addEventListener('notificationclick', event => { + event.notification.close(); + if (event.action === 'dismiss') return; + + const url = event.notification.data?.url || '/'; + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clientList => { + for (const client of clientList) { + if (client.url === url && 'focus' in client) return client.focus(); + } + return clients.openWindow(url); + }) + ); +}); diff --git a/static/vibe.html b/static/vibe.html new file mode 100644 index 0000000..55c91d2 --- /dev/null +++ b/static/vibe.html @@ -0,0 +1,770 @@ + + + + + + GUARDiA — 바이브 코딩 세션 + + + + + + +
+ + + + + +
+
+

바이브 코딩 세션

+ +
+ + +
+ + + +
+ + +
+ +
+
+ Jenkins 연결 확인 중... + +
+ + +
+
+
+
활성 세션
+
+
+
+
빌드 중
+
+
+
+
배포 중
+
+
+
+
오늘 완료
+
+
+ +
+
로딩 중...
+
+
+ + +
+
+ + +
+
+ + + + + + + + + + + + + + + +
SR ID프로젝트명상태시작 시각완료 시각빌드 결과소요 시간
로딩 중...
+
+
+ + +
+
+ +
+
+ + + + + + + + + + + + + +
프로젝트명소스 경로빌드 명령어배포 서버상태
로딩 중...
+
+
+
+
+ + + + + + + + + + + + + diff --git a/tools/db_init.py b/tools/db_init.py new file mode 100644 index 0000000..ede879e --- /dev/null +++ b/tools/db_init.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +""" +GUARDiA ITSM — DB 초기화 헬퍼 (설치 스크립트용) + +스키마 불일치 감지 시 자동 백업 후 재초기화. +설치·업그레이드 시 setup 스크립트에서 호출한다. + +사용법: + python tools/db_init.py [--force] + --force: 기존 DB 강제 삭제 후 재초기화 (신규 설치) +""" +import asyncio +import os +import shutil +import sys +from datetime import datetime +from pathlib import Path + +# itsm/ 디렉토리를 Python 경로에 추가 +ITSM_DIR = Path(__file__).parent.parent +sys.path.insert(0, str(ITSM_DIR)) +os.chdir(str(ITSM_DIR)) + +# .env 로드 (있으면) +try: + from dotenv import load_dotenv + load_dotenv(".env") +except ImportError: + pass + + +FORCE = "--force" in sys.argv +DB_PATH = Path(os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./guardia_itsm.db") + .replace("sqlite+aiosqlite:///", "").replace("./", "")) + + +def _backup_db(): + if DB_PATH.exists(): + backup = DB_PATH.with_suffix( + f".backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.db" + ) + shutil.copy2(DB_PATH, backup) + print(f"[BACKUP] {DB_PATH} → {backup}") + return backup + return None + + +async def _verify_schema(max_retries: int = 1) -> bool: + """ + 현재 DB 스키마가 모델과 일치하는지 확인. + 주요 테이블의 컬럼 존재 여부를 점검한다. + """ + from database import SessionLocal + from models import User + from sqlalchemy import select, text + + for attempt in range(max_retries + 1): + try: + async with SessionLocal() as db: + # User 테이블 전체 컬럼 조회 (가장 자주 변경되는 테이블) + await db.execute(select(User).limit(1)) + return True + except Exception as e: + if attempt == 0: + print(f"[SCHEMA] 스키마 불일치 감지: {e}") + return False + return False + + +async def main(): + from database import init_db, engine + from core.seed import seed_all + from database import SessionLocal + + db_is_sqlite = not os.getenv("DATABASE_URL", "").startswith("postgresql") + + # ── 신규 설치 모드 --force ────────────────────────────────────────────── + if FORCE and db_is_sqlite and DB_PATH.exists(): + print("[FORCE] 기존 DB 삭제 후 재초기화...") + _backup_db() + DB_PATH.unlink() + + # ── DB 존재 시 스키마 검증 ────────────────────────────────────────────── + if db_is_sqlite and DB_PATH.exists(): + print(f"[CHECK] 기존 DB 스키마 검증: {DB_PATH}") + schema_ok = await _verify_schema() + if not schema_ok: + print("[MIGRATE] 스키마 불일치 — 백업 후 재초기화합니다.") + _backup_db() + DB_PATH.unlink() + print("[INFO] 새 스키마로 DB를 생성합니다.") + else: + print("[OK] 스키마 정상") + + # ── 테이블 생성 ──────────────────────────────────────────────────────── + try: + await init_db() + print("[OK] DB 테이블 초기화 완료") + except Exception as e: + print(f"[ERROR] DB 초기화 실패: {e}") + sys.exit(1) + + # ── 시드 데이터 삽입 ────────────────────────────────────────────────── + try: + async with SessionLocal() as db: + await seed_all(db) + print("[OK] 시드 데이터 삽입 완료") + except Exception as e: + print(f"[WARN] 시드 데이터 삽입 오류 (무시): {e}") + + await engine.dispose() + print("[OK] DB 초기화 완료") + + +def _copy_offline_assets(): + """폐쇄망 정적 파일 복사 (있는 경우).""" + import shutil + offline_chart = ITSM_DIR.parent / "setup" / "offline" / "common" / "chart.umd.min.js" + target_chart = ITSM_DIR / "static" / "chart.umd.min.js" + if offline_chart.exists() and not target_chart.exists(): + shutil.copy2(offline_chart, target_chart) + print(f"[OK] Chart.js 오프라인 파일 복사: {target_chart}") + + +if __name__ == "__main__": + _copy_offline_assets() + asyncio.run(main())