chore: GUARDiA 재사용 패턴 흡수 — 스모크 러너·push·정합/보안/운영 문서
- tools/test/kintex_smoke_test.py: 8021 스모크/회귀 러너(run_full_test 패턴, env-only 시크릿, 비파괴 프로브, CI 게이트) - scripts/push_kintex.py: push_any_repo 경량 이식(자격증명 env-only) - docs/GUARDIA_ALIGNMENT.md: 표준 프레임워크 정합 매핑 - docs/SECURITY.md: 보안 불변(외부API·자격증명·워터마크·root 예외) - docs/OPS_RUNBOOK.md: 헬스체크·재기동·롤백 런북 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
63386b62aa
commit
95dc76f150
102
docs/GUARDIA_ALIGNMENT.md
Normal file
102
docs/GUARDIA_ALIGNMENT.md
Normal file
@ -0,0 +1,102 @@
|
||||
# KINTEX ↔ GUARDiA 표준 프레임워크 정합 매핑
|
||||
|
||||
> 킨텍스 자동전시시스템이 **GUARDiA 표준 프레임워크(UIMS/WISE 기준)** 를 어떻게 준수·차용·차이 처리하는지의 단일 참조.
|
||||
> 정본 표준: `workspace/_framework/GUARDIA_STANDARD_FRAMEWORK.md` · WISE 적용 명세: `workspace/_framework/WISE_APPLY_SPEC.md` (둘 다 읽기 전용).
|
||||
> 이 문서는 킨텍스 관점의 **매핑·갭**만 기록한다(표준 원문 복제 금지).
|
||||
|
||||
---
|
||||
|
||||
## 0. 요약
|
||||
|
||||
| 축 | 표준(GUARDiA/UIMS) | KINTEX 상태 |
|
||||
|----|--------------------|-------------|
|
||||
| 스택 | Spring Boot 3.5 Java17 + React18/19 Vite + MyBatis + PostgreSQL | ✅ 준수 (+ PostGIS·Redis·나노바나나 Python 워커 = 도메인 확장) |
|
||||
| 인증 | JWT + 2FA(OTP) + 로그인 실패 잠금 + admin 비번 env | ✅ 준수 (행사 단위 RBAC 로 역할 모델 확장) |
|
||||
| 공통 모듈 | worklog·schedule·message·stats·system·notice·… | ✅ WISE 이식 (`work/*`·`system/*` 패키지) |
|
||||
| 디자인 | WISE 토큰·선(stroke) SVG·chief-designer 리드 | ✅ 준수 |
|
||||
| AI | 프로바이더 선택형 + AiTextRouter 폴백 + DuckDB 학습 | ◐ Claude 기본 + AiConfig 전환(설계) — 나노바나나(Gemini)는 **승인된 이미지 예외** |
|
||||
| 보안 | 외부 API 금지(anthropic 예외)·AES-256-GCM·감사로그 | ✅ 준수 (+ Gemini 이미지 예외·AI 이미지 워터마크 강제) |
|
||||
| 배포 | workspace→repos(fresh)→Gitea→webhook→systemd→nginx | ✅ 준수 (분리형: 프론트 dist→nginx / 백엔드 jar→8021) |
|
||||
| 스키마 | Flyway/sql.init 멱등·mode·누출차단 | ◐ Flyway 사용 — GUARDiA sql.init 갭은 §4 참조 |
|
||||
|
||||
범례: ✅ 완전 준수 · ◐ 부분/설계 · ✕ 미준수.
|
||||
|
||||
---
|
||||
|
||||
## 1. 스택 정합
|
||||
|
||||
- **일치**: Spring Boot 3.x(Java 17)·MyBatis·React(Vite/TS)·PostgreSQL·단일 Gitea repo·systemd·nginx.
|
||||
- **킨텍스 확장(표준 상위집합)**:
|
||||
- **PostGIS** — 부스 폴리곤·트렌치 포인트·배선 LineString 공간 연산(표준엔 없음, 도메인 필수).
|
||||
- **Redis 작업 큐** — RenderJob·서류·알림 비동기(`kintex:renderjob:queue`).
|
||||
- **나노바나나 Python 워커** — Gemini 이미지 생성 사이드카(별도 systemd `kintex-nanobanana.service`). 백엔드는 큐 발행만, 키는 워커 env 전용.
|
||||
- **패키징 차이**: 표준은 "단일 jar(프론트→백엔드 static 번들)". 킨텍스는 **역할별 프론트 번들 분리**(PLANNING §2-1)라 nginx 정적 서빙 + 백엔드 API 분리를 기본으로 한다. → 표준의 *의도*(무중단·헬스게이트 배포)는 유지, 물리 패키징만 분리형.
|
||||
|
||||
## 2. 인증 정합 (JWT + 2FA)
|
||||
|
||||
- **일치**: `Authorization: Bearer <JWT>`(HS256), 2단계 로그인(`/api/auth/login` → `verify-otp`), admin 비번 env(`ADMIN_PASSWORD_ENC`+`ADMIN_KEY_FILE`) 재시드, 로그인 실패 잠금.
|
||||
- **확장**: RBAC 가 전역 역할이 아니라 **행사(event) 스코프 역할**(`ORGANIZER·EXHIBITOR·CONTRACTOR·HALL_MANAGER`) — JWT 클레임 `roles`=eventId→역할, `hm`=홀매니저. `/api/admin/**`=ADMIN 게이트는 표준 그대로.
|
||||
- **공개 경로**: `GET /health`·`POST /api/auth/login`·`/ws/**`·`POST /api/internal/render/callback`(워커 토큰). 그 외 인증 필요.
|
||||
|
||||
## 3. 공통 모듈 정합 (WISE 이식)
|
||||
|
||||
킨텍스 백엔드 `work/*`·`system/*` 패키지에 WISE 공통 레이어를 이식했다(컨트롤러 실측):
|
||||
|
||||
| 표준 모듈 | KINTEX 경로 |
|
||||
|-----------|-------------|
|
||||
| worklog | `work/worklog` (`/api/work/...`) |
|
||||
| schedule | `work/schedule` |
|
||||
| message | `work/message` |
|
||||
| stats | `work/stats` (`GET /api/work/stats/worklog`) |
|
||||
| notice | `work/notice` (`/api/work/notices`) |
|
||||
| opinion | `work/opinion` |
|
||||
| search | `work/search` |
|
||||
| meeting | `work/meeting` |
|
||||
| report | `work/report` |
|
||||
| notification | `work/notification` (`/api/work/notifications/unread-count`) |
|
||||
| system(사용자·역할·공통코드·메뉴·설정·감사) | `system/*` + `common/audit` (`/api/common/**`·`/api/admin/**`) |
|
||||
|
||||
이식 하네스: 신규 공통 모듈 필요 시 `uiws-port-orchestrator`(기존 솔루션 전파) 패턴 준용. 킨텍스는 `kintex-common-dev` 에이전트가 담당.
|
||||
|
||||
## 4. 스키마 무결성 — Flyway 갭 (GUARDiA schema-integrity 패턴 요약)
|
||||
|
||||
GUARDiA 다수 솔루션은 `sql.init.mode=never` + schema.sql 후행 확장 때문에 **누락 테이블**(`relation "x" does not exist`) 500 장애를 겪었고, 검증된 수복 패턴은:
|
||||
1. **시드 멱등화** — 유니크 인덱스로 `INSERT ... ON CONFLICT DO NOTHING` (재적용 안전).
|
||||
2. `sql.init.mode=always` + `continue-on-error` — 후행 추가 DDL 도 재기동 시 적용.
|
||||
3. **DataAccessException 핸들러** — DB 오류를 요약 메시지로 변환(스택트레이스·relation 명 누출 차단).
|
||||
4. 누락 테이블은 **레이어로 드러남** — 매퍼 `FROM`/`JOIN` 을 전수 추출해 한 번에 역설계.
|
||||
|
||||
**킨텍스는 Flyway 순번 마이그레이션을 쓰므로 위 문제의 구조적 원인이 없다**(마이그레이션이 곧 스키마 권위). 남는 갭은 아래 3개뿐:
|
||||
|
||||
| 갭 | GUARDiA 교훈 | 킨텍스 가드 |
|
||||
|----|--------------|-------------|
|
||||
| 시드 비멱등 | 재적용 시 중복키 | Flyway 마이그레이션의 시드는 `ON CONFLICT DO NOTHING`/`MERGE` 로 작성. repeatable 마이그(`R__`)는 자체 멱등. |
|
||||
| baseline 오적용 | 기존 DB 에 V1 재실행 | `baseline-on-migrate=true` + 운영 DB 는 baseline 버전 명시. 신규 마이그 번호 = 현재 최대+1(pre-push 훅 충돌검사). |
|
||||
| DB 오류 누출 | 스택트레이스 노출 | 표준 `@RestControllerAdvice`(ApiResponse.error 요약) 로 DataAccessException→`INTERNAL` 요약 매핑. **relation/컬럼명 응답 금지**(API_GUIDE §5). |
|
||||
|
||||
→ 결론: 킨텍스는 **sql.init 이 아니라 Flyway** 이므로 GUARDiA "mode=always" 이식은 불필요. 대신 (a) 시드 멱등, (b) baseline 규율, (c) DB 오류 요약 핸들러 3개만 유지하면 동등한 무결성을 얻는다.
|
||||
|
||||
## 5. AI 정합
|
||||
|
||||
- **Claude 기본 + 설정형 전환**: 표준 `AiTextRouter`/`AiConfig`(Claude→Qwen3→소형 폴백)를 킨텍스 AI(부스 배치·규정검증 보조·매칭·자연어조회·서류검수·챗봇)에 적용(설계, `kintex-ai-dev`).
|
||||
- **나노바나나(Gemini) 이미지**: 표준의 "외부 API 금지"에 대한 **별도 승인 예외**(G1 게이트, `GEMINI_API_KEY` 워커 env only). 텍스트 AI 의 anthropic 예외와 동일 취급 — 키 미커밋·미로그·미응답.
|
||||
- **AI 생성 이미지 워터마크 강제**(§SECURITY): 표준에 없는 킨텍스 고유 불변 — M5 응답에 `watermarkRequired:true` 항상 포함.
|
||||
|
||||
## 6. 배포 정합
|
||||
|
||||
- **일치**: `workspace→repos(fresh git init)→Gitea(zio/kintex)→webhook→deploy_server→systemd→nginx`, Fail-Safe(백업→배포→헬스게이트 `GET /health` 200→롤백), 서버 빌드 직렬(OOM 방지).
|
||||
- **킨텍스 블록**: `deploy/deploy_server_kintex_block.py`(정본 사본) — 포트 8021, 프론트 dist→`/var/www/kintex`, 백엔드 bootJar→`/opt/kintex/app/app.jar`, `kintex.service`+`kintex-nanobanana.service` 재기동, Flyway 자동 적용, `deploy_kintex.sh` 원자교체·헬스체크·롤백.
|
||||
- **도메인/포트**: 개발 `kintex.zioinfo.co.kr`→101.79.17.164:8021 / 운영 `kintex.wise.ai.kr`(후속).
|
||||
- **경량 push**: `scripts/push_kintex.py`(env-only 자격증명, fresh init, bundle→SFTP→push).
|
||||
|
||||
## 7. 차용한 GUARDiA 자산 (이 정합 작업으로 kintex 에 추가)
|
||||
|
||||
| kintex 파일 | 원본 패턴 | 용도 |
|
||||
|-------------|-----------|------|
|
||||
| `tools/test/kintex_smoke_test.py` | `scripts/check/run_full_test.py` | 8021 스모크/회귀(health+라우터등록+로그인 프로브) |
|
||||
| `scripts/push_kintex.py` | `scripts/push/push_any_repo.py` | 단일 repo 경량 push(env-only) |
|
||||
| `docs/GUARDIA_ALIGNMENT.md` | `_framework/*` | 본 정합 매핑 |
|
||||
| `docs/SECURITY.md` | CLAUDE.md 보안 제약 | 킨텍스 관점 보안 불변 |
|
||||
| `docs/OPS_RUNBOOK.md` | GUARDiA 운영/헬스체크 관행 | 헬스·재기동·롤백 런북 |
|
||||
|
||||
> 원본 GUARDiA 자산은 **읽기 전용**으로 참조했고 수정하지 않았다. 킨텍스 사본은 kintex 컨텍스트(포트 8021·도메인·나노바나나·PostGIS)로 재작성.
|
||||
135
docs/OPS_RUNBOOK.md
Normal file
135
docs/OPS_RUNBOOK.md
Normal file
@ -0,0 +1,135 @@
|
||||
# KINTEX 운영 런북 — 헬스체크 · 재기동 · 롤백
|
||||
|
||||
> 킨텍스 개발/운영 서비스의 상태 점검·재기동·배포 검증·롤백 절차. GUARDiA 운영 관행 준용.
|
||||
> **대상**: 개발 `kintex.zioinfo.co.kr` → 101.79.17.164, 백엔드 포트 **8021**. 운영 `kintex.wise.ai.kr`(후속).
|
||||
> **보안**: 서버 접속 자격증명은 사내 비밀관리에서 조달(이 문서·스크립트에 미기재). root SSH 는 GUARDiA 인프라 예외(소유자 승인).
|
||||
|
||||
---
|
||||
|
||||
## 0. 서비스 토폴로지
|
||||
|
||||
| 구성요소 | 실행 단위 | 포트/경로 | 헬스 |
|
||||
|----------|-----------|-----------|------|
|
||||
| 백엔드 API | systemd `kintex.service` (`/opt/kintex/app/app.jar`) | 8021 | `GET /health` → `data.status=UP` |
|
||||
| 나노바나나 워커 | systemd `kintex-nanobanana.service` (Python) | Redis 큐 소비 | 큐 소비 로그 / 프로세스 active |
|
||||
| 프론트(웹) | nginx 정적 (`/var/www/kintex`) | `kintex.zioinfo.co.kr` :80/443 | 페이지 200 |
|
||||
| nginx vhost | `deploy/nginx/kintex.zioinfo.co.kr.conf` | `/`=정적, `/api/`·`/ws`=8021 | — |
|
||||
| DB | PostgreSQL `kintex_db` (+PostGIS) | 5432(내부) | `SELECT 1` |
|
||||
| 큐 | Redis | 6379(내부) | `redis-cli ping`→PONG |
|
||||
| 배포 | `deploy_server.py`(webhook 수신) | 9999 | 로그 |
|
||||
|
||||
의존: 백엔드 → PostgreSQL·Redis. 워커 → Redis·Gemini(G1). 프론트 → nginx → 백엔드.
|
||||
|
||||
---
|
||||
|
||||
## 1. 헬스체크 (가장 먼저)
|
||||
|
||||
**자동(권장)** — 스모크 러너로 배포·라우터 상태를 한 번에:
|
||||
```bash
|
||||
# 로컬 PC에서 SSH 경유 (env: KINTEX_SSH_HOST, KINTEX_SSH_PASSWORD)
|
||||
python tools/test/kintex_smoke_test.py
|
||||
# 서버(같은 호스트)에서
|
||||
python tools/test/kintex_smoke_test.py --local
|
||||
# 도메인 직접
|
||||
KINTEX_BASE=https://kintex.zioinfo.co.kr python tools/test/kintex_smoke_test.py --http
|
||||
```
|
||||
종료코드 0=전체 통과. 결과 `tools/test/_results/latest.json`.
|
||||
|
||||
**수동** — 서버에서:
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8021/health # {"success":true,"data":{"status":"UP","service":"kintex-backend",...}}
|
||||
systemctl is-active kintex.service kintex-nanobanana.service
|
||||
redis-cli ping # PONG
|
||||
sudo -u postgres psql -d kintex_db -c 'SELECT 1;'
|
||||
```
|
||||
|
||||
정상 판정: `/health` 200 + `status:UP` + 두 서비스 `active` + Redis PONG + DB 응답.
|
||||
|
||||
---
|
||||
|
||||
## 2. 로그 확인
|
||||
|
||||
```bash
|
||||
journalctl -u kintex.service -n 200 --no-pager # 백엔드
|
||||
journalctl -u kintex-nanobanana.service -n 100 --no-pager # 워커
|
||||
tail -n 100 /tmp/kintex_gradle.log # 최근 빌드 로그
|
||||
nginx -t && tail -n 50 /var/log/nginx/error.log # nginx
|
||||
```
|
||||
> 로그에 자격증명·API키가 보이면 **즉시 보안 이슈**로 에스컬레이션(SECURITY §2).
|
||||
|
||||
---
|
||||
|
||||
## 3. 재기동
|
||||
|
||||
```bash
|
||||
# 백엔드만
|
||||
systemctl restart kintex.service && sleep 5 && curl -s http://127.0.0.1:8021/health
|
||||
|
||||
# 워커만 (이미지 생성 멈춤·큐 적체 시)
|
||||
systemctl restart kintex-nanobanana.service
|
||||
|
||||
# 프론트/nginx (정적 서빙 이상)
|
||||
nginx -t && systemctl reload nginx
|
||||
|
||||
# DB/Redis 이슈는 공유 인스턴스 — 재기동 전 타 솔루션 영향 확인 후 소유자 승인
|
||||
```
|
||||
|
||||
부팅 자동기동: 두 유닛 모두 `enable` 상태여야 한다(`systemctl is-enabled kintex.service`).
|
||||
|
||||
---
|
||||
|
||||
## 4. 배포 (webhook 자동)
|
||||
|
||||
정상 경로는 **자동**이다:
|
||||
```
|
||||
scripts/push_kintex.py "메시지" → Gitea(zio/kintex) push → webhook(#47) → deploy_server
|
||||
→ git pull → npm build → gradlew bootJar → jar 검증 → deploy_kintex.sh(원자교체) → /health 게이트
|
||||
```
|
||||
- 배포 후 검증: `python tools/test/kintex_smoke_test.py` (health + 라우터 등록).
|
||||
- **수동 Jenkins 트리거 추가 발사 금지** — 동일 repo 동시 빌드 시 `target/*.jar` 교체가 깨진다(GUARDiA 함정).
|
||||
- `deploy_kintex.sh` 는 자기방어: 프론트/백엔드 빌드 → jar 검증(`KintexApplication.class` 존재) → 원자 교체 → 헬스체크 → 실패 시 이전 jar 롤백.
|
||||
|
||||
---
|
||||
|
||||
## 5. 롤백
|
||||
|
||||
### 5-1. 자동 롤백(기본)
|
||||
`deploy_kintex.sh` 가 헬스체크 실패 시 **이전 jar 를 유지/복원**한다. 배포 후 `/health` 가 200이 아니면 스크립트가 롤백하고 실패로 종료 → deploy_server 가 ITSM 에 실패 알림.
|
||||
|
||||
### 5-2. 수동 롤백 (자동 실패 시)
|
||||
```bash
|
||||
# 백엔드 이전 버전으로 (배포 스크립트가 백업본을 남기는 위치 확인)
|
||||
ls -lt /opt/kintex/app/*.jar* /opt/kintex/app/backup/ 2>/dev/null
|
||||
cp /opt/kintex/app/app.jar.bak /opt/kintex/app/app.jar # 백업 규약에 맞춰
|
||||
systemctl restart kintex.service && sleep 5 && curl -s http://127.0.0.1:8021/health
|
||||
|
||||
# 소스 롤백(직전 커밋으로 재배포)
|
||||
git -C /opt/kintex/src log --oneline -5
|
||||
git -C /opt/kintex/src reset --hard <직전_정상_커밋>
|
||||
bash /opt/kintex/src/deploy/deploy_kintex.sh /opt/kintex/src
|
||||
```
|
||||
|
||||
### 5-3. 스키마 롤백 주의
|
||||
Flyway 마이그레이션은 **전진(forward)만** 권장. 잘못된 마이그는 되돌리지 말고 **보정 마이그레이션(다음 번호)** 를 추가한다. `baseline-on-migrate=true` 이므로 운영 DB 임의 롤백 금지(GUARDIA_ALIGNMENT §4).
|
||||
|
||||
---
|
||||
|
||||
## 6. 자주 겪는 이슈 (GUARDiA 교훈 반영)
|
||||
|
||||
| 증상 | 원인 후보 | 조치 |
|
||||
|------|-----------|------|
|
||||
| `/health` 200인데 특정 API 404 | 라우터 미배포(구 jar) | 스모크 러너로 라우터 등록 확인 → 재배포 |
|
||||
| API 500 `relation ... does not exist` | 마이그 미적용 | Flyway 상태 `flyway info`, 보정 마이그 추가(§5-3) |
|
||||
| 이미지 생성 안 됨 / degraded | G1 미승인·`GEMINI_API_KEY` 미설정·워커 다운 | 워커 로그·env 확인, 워커 재기동. 키는 워커 env only |
|
||||
| 배포 로그 "완료"인데 반영 안 됨 | deploy_server 블록 부재·1ms no-op | 서버 `/opt/zioinfo/deploy_server.py` 에 kintex 블록 반영·`zioinfo-deploy` 재시작 확인 |
|
||||
| 빌드 OOM | 공유 8GB 동시 빌드 | 직렬 빌드 준수, 타 솔루션 빌드와 겹치지 않게 |
|
||||
| push 했는데 자동배포 안 돎 | webhook 미설정/시크릿 불일치 | Gitea webhook #47 활성·secret 일치·URL localhost 확인 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 참조
|
||||
|
||||
- 배포 개요: [`BUILD_DEPLOY.md`](BUILD_DEPLOY.md) · 환경: [`ENV_SETUP.md`](ENV_SETUP.md)
|
||||
- 정합·스키마 갭: [`GUARDIA_ALIGNMENT.md`](GUARDIA_ALIGNMENT.md) · 보안: [`SECURITY.md`](SECURITY.md)
|
||||
- 배포 블록(정본 사본): `deploy/deploy_server_kintex_block.py` · 배포 스크립트: `deploy/deploy_kintex.sh`
|
||||
- 스모크 러너: `tools/test/kintex_smoke_test.py` · 경량 push: `scripts/push_kintex.py`
|
||||
70
docs/SECURITY.md
Normal file
70
docs/SECURITY.md
Normal file
@ -0,0 +1,70 @@
|
||||
# KINTEX 보안 제약 (불변)
|
||||
|
||||
> GUARDiA 보안 제약(`CLAUDE.md` / `_framework/GUARDIA_STANDARD_FRAMEWORK.md §6`)을 킨텍스 관점으로 정리한 단일 참조.
|
||||
> 아래 규칙은 어떤 상황에서도 위반 불가. API 계약(`API_GUIDE.md §5`)·개발 표준(`DEVELOPMENT_GUIDE.md §5`)과 함께 강제된다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 외부 API 호출 정책
|
||||
|
||||
| 대상 | 정책 | 근거 |
|
||||
|------|------|------|
|
||||
| **온프레미스 Ollama** | 허용(기본) | 표준 온프레미스 우선 |
|
||||
| **Anthropic Claude** (`api.anthropic.com`) | **승인된 단일 예외** — 키는 `ANTHROPIC_API_KEY` env only, 실패 시 Ollama 자동 폴백 | 소유자 승인(2026-07-03) |
|
||||
| **Gemini 나노바나나** (이미지 생성) | **킨텍스 고유 승인 예외(G1 게이트)** — 키는 `GEMINI_API_KEY` **워커 env only**(백엔드 미취급), 미승인 시 목/degraded | PLANNING R12·G1 |
|
||||
| 그 외 모든 외부 API | **금지** | 표준 불변 |
|
||||
|
||||
- Claude/Gemini 키는 **DB·코드·커밋·로그·API 응답에 절대 기록 금지**. env 에서만 로드.
|
||||
- Gemini 는 **나노바나나 Python 워커에서만** 로드한다. Spring 백엔드는 큐 발행/콜백 수신만 하고 키를 취급하지 않는다.
|
||||
|
||||
## 2. 자격증명·민감정보 보호
|
||||
|
||||
- **응답 완전 제외**: 내부 IP·SSH 계정·비밀번호·비밀번호 해시·`GEMINI_API_KEY`·`ANTHROPIC_API_KEY`·내부 식별자·워커 토큰. (`ServerOut`류 스키마에서 제외 — API_GUIDE §5.)
|
||||
- 사용자·업체 표시는 **비민감 필드만**.
|
||||
- 로그·에러 메시지·메신저 알림에도 자격증명 노출 금지.
|
||||
- 운영 스크립트(`scripts/push_kintex.py`·`tools/test/kintex_smoke_test.py`)는 **시크릿 하드코딩 0** — 전부 환경변수에서만 읽고, 조립한 인증 URL 조차 출력하지 않는다(마스킹).
|
||||
|
||||
## 3. 암호화 저장 (AES-256-GCM)
|
||||
|
||||
- admin 비밀번호: env `ADMIN_PASSWORD_ENC`(AES-256-GCM 암호문) + `ADMIN_KEY_FILE`(별도 키파일, root 600) → 기동 시 BCrypt 재시드. 하드코딩 `admin123`/`1111` 시드 금지.
|
||||
- 저장이 필요한 외부 자격증명(SMTP 등)은 암호화 컬럼에만. 평문 저장 금지.
|
||||
- JWT 시크릿(`KINTEX_JWT_SECRET`)·DB 비번(`KINTEX_DB_PASSWORD`)은 env/`application.yml` 프로퍼티 주입, `.env`·`*.key` 는 gitignore.
|
||||
|
||||
## 4. 인증·권한 (JWT + 2FA + 행사 RBAC)
|
||||
|
||||
- JWT(HS256) + **2차 인증(OTP/EMAIL 코드)** 2단계 로그인 + **로그인 실패 잠금**(관리자 해제).
|
||||
- **행사 단위 RBAC**: 열람=행사 멤버 or 홀매니저 / 편집·액션=엔드포인트별 역할(`ORGANIZER·EXHIBITOR·CONTRACTOR·HALL_MANAGER`).
|
||||
- **미등록 장치업체 차단**: 초대·응찰은 등록업체 검증(`NOT_REGISTERED_COMPANY` 403).
|
||||
- `/api/admin/**` = ADMIN 역할 게이트. `/api/internal/**` = 워커 공유 시크릿(`X-Worker-Token`)만.
|
||||
|
||||
## 5. AI 생성 이미지 워터마크 (킨텍스 고유 불변)
|
||||
|
||||
- M5 나노바나나 결과 이미지 응답은 **항상** `watermarkRequired:true` + `watermarkText` + `notice`(계약·심사 서류 사용 금지 안내)를 포함한다.
|
||||
- AI 생성 시각화는 **참고용**이며 계약·인허가 서류로 사용 불가 — UI·응답·PDF 어디서나 워터마크/고지 강제.
|
||||
|
||||
## 6. 오류 응답 (스택트레이스 미노출)
|
||||
|
||||
- 모든 오류는 `ApiResponse.error`(코드 + 사람이 읽는 요약 메시지)만 반환. **스택트레이스·relation/컬럼명·내부 경로 미노출**(서버 로그에만).
|
||||
- DB 오류(DataAccessException)는 `@RestControllerAdvice` 로 `INTERNAL` 요약 매핑 → 테이블/컬럼명 누출 차단.
|
||||
- 워커 실패 `errorMessage` 는 요약만 통과(스택트레이스 유입 차단).
|
||||
- 오류 코드→HTTP 매핑은 `API_GUIDE.md §3` 고정 표를 따른다.
|
||||
|
||||
## 7. 감사 추적
|
||||
|
||||
- 관리자·인증·권한 변경 등 민감 액션은 감사 로그(`common/audit`, `TB_AUDIT_LOG` 계열)에 기록. 감사 로그에도 비밀값 미기재.
|
||||
|
||||
## 8. 서버 접속 (root 예외)
|
||||
|
||||
- 관리 대상 서버는 opsagent 전용, root SSH 금지가 표준. **예외**: GUARDiA 자체 인프라 `101.79.17.164`(kintex 개발 호스트 포함)에 한해 운영 작업용 root SSH 허용(소유자 승인 2026-06-18). 운영 배포는 소유자 승인 필수.
|
||||
|
||||
---
|
||||
|
||||
## 체크리스트 (배포·리뷰 전)
|
||||
|
||||
- [ ] 응답/로그/커밋에 IP·SSH·비번·해시·API키·워커토큰 노출 0 (grep)
|
||||
- [ ] Gemini/Claude 키는 env only — 코드·DB·커밋 미포함
|
||||
- [ ] admin 비번 하드코딩 시드 없음(env 재시드)
|
||||
- [ ] M5 이미지 응답 `watermarkRequired:true` 포함
|
||||
- [ ] DB/서버 오류 → 요약 메시지(스택·relation 명 미노출)
|
||||
- [ ] 운영 스크립트 시크릿 하드코딩 0(env-only, 마스킹)
|
||||
- [ ] `/api/admin/**` ADMIN 게이트·행사 RBAC·미등록업체 차단 동작
|
||||
197
scripts/push_kintex.py
Normal file
197
scripts/push_kintex.py
Normal file
@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
KINTEX 전용 경량 push 스크립트
|
||||
(GUARDiA `scripts/push/push_any_repo.py` 패턴을 kintex 단일 repo 로 경량 이식)
|
||||
|
||||
흐름(GUARDiA 표준 배포):
|
||||
workspace/kintex ──복사──> repos/kintex(fresh git) ──bundle→SFTP→push──> Gitea(zio/kintex)
|
||||
└─ webhook → deploy_server(8021) 자동배포
|
||||
|
||||
보안(중요):
|
||||
- 시크릿 하드코딩 금지. SSH/Gitea 자격증명은 전부 환경변수에서만 읽는다.
|
||||
- 자격증명은 화면·로그에 출력하지 않는다(URL 조립 시에도 마스킹).
|
||||
- --force push 는 GUARDiA 관행상 bundle→clone→push 경로에서만 사용(원격 미러 교체).
|
||||
- 실제 실행은 사용자가 env 를 세팅했을 때만. 이 스크립트 자체엔 어떤 비밀도 없다.
|
||||
|
||||
필수 env:
|
||||
KINTEX_SSH_HOST 서버 IP/호스트
|
||||
KINTEX_SSH_PASSWORD (또는 KINTEX_SSH_KEYFILE) SSH 인증
|
||||
KINTEX_GITEA_USER Gitea 사용자 (예: zio)
|
||||
KINTEX_GITEA_PASSWORD Gitea 비밀번호 (URL 인코딩은 스크립트가 처리)
|
||||
선택 env:
|
||||
KINTEX_SSH_USER 기본 root
|
||||
KINTEX_GITEA_PORT 기본 9003 (서버 내부 loopback)
|
||||
KINTEX_WORKSPACE 기본 이 스크립트 기준 ../ (kintex workspace 루트)
|
||||
KINTEX_REPOS_DIR 기본 <workspace>/../../repos (없으면 임시 디렉터리)
|
||||
|
||||
사용:
|
||||
python scripts/push_kintex.py "feat(m3): 부스 프리체크 규정 메시지 보강"
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
import tempfile
|
||||
import subprocess
|
||||
import urllib.parse
|
||||
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
EXCLUDE = {".git", "node_modules", "target", "dist", "__pycache__",
|
||||
".gradle", "build", "output", "_samples"}
|
||||
|
||||
REPO = "kintex"
|
||||
MSG = sys.argv[1] if len(sys.argv) > 1 else f"chore: {REPO} 업데이트"
|
||||
|
||||
# ── 환경변수(시크릿) ──────────────────────────────────────────────────────────
|
||||
SSH_HOST = os.environ.get("KINTEX_SSH_HOST")
|
||||
SSH_USER = os.environ.get("KINTEX_SSH_USER", "root")
|
||||
SSH_PW = os.environ.get("KINTEX_SSH_PASSWORD")
|
||||
SSH_KEY = os.environ.get("KINTEX_SSH_KEYFILE")
|
||||
GITEA_USER = os.environ.get("KINTEX_GITEA_USER")
|
||||
GITEA_PW = os.environ.get("KINTEX_GITEA_PASSWORD")
|
||||
GITEA_PORT = os.environ.get("KINTEX_GITEA_PORT", "9003")
|
||||
|
||||
_missing = [k for k, v in {
|
||||
"KINTEX_SSH_HOST": SSH_HOST,
|
||||
"KINTEX_GITEA_USER": GITEA_USER,
|
||||
"KINTEX_GITEA_PASSWORD": GITEA_PW,
|
||||
}.items() if not v]
|
||||
if _missing or not (SSH_PW or SSH_KEY):
|
||||
print("❌ 필수 환경변수 누락:", ", ".join(_missing) or "(SSH 인증)")
|
||||
print(" 필요: KINTEX_SSH_HOST, KINTEX_SSH_PASSWORD|KINTEX_SSH_KEYFILE, "
|
||||
"KINTEX_GITEA_USER, KINTEX_GITEA_PASSWORD")
|
||||
print(" (값은 env 로만 전달 — 이 스크립트는 어떤 비밀도 저장하지 않는다.)")
|
||||
sys.exit(2)
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
WS = os.environ.get("KINTEX_WORKSPACE", os.path.abspath(os.path.join(HERE, "..")))
|
||||
REPOS_DIR = os.environ.get("KINTEX_REPOS_DIR",
|
||||
os.path.abspath(os.path.join(WS, "..", "..", "repos")))
|
||||
LOCAL = os.path.join(REPOS_DIR, REPO)
|
||||
|
||||
# 자격증명 URL 인코딩(따옴표/특수문자 안전). 조립된 URL 은 절대 출력하지 않는다.
|
||||
_auth = f"{urllib.parse.quote(GITEA_USER, safe='')}:{urllib.parse.quote(GITEA_PW, safe='')}"
|
||||
GITEA_URL = f"http://{_auth}@127.0.0.1:{GITEA_PORT}/{GITEA_USER}/{REPO}.git"
|
||||
|
||||
env_git = {**os.environ,
|
||||
"GIT_AUTHOR_NAME": "GUARDiA", "GIT_AUTHOR_EMAIL": "dev@zioinfo.co.kr",
|
||||
"GIT_COMMITTER_NAME": "GUARDiA", "GIT_COMMITTER_EMAIL": "dev@zioinfo.co.kr"}
|
||||
|
||||
|
||||
def git(args, cwd=LOCAL):
|
||||
r = subprocess.run(["git", "-C", cwd] + args, capture_output=True, timeout=300, env=env_git)
|
||||
return r.returncode, (r.stdout + r.stderr).decode("utf-8", "replace")
|
||||
|
||||
|
||||
def copytree_filtered(src, dst):
|
||||
os.makedirs(dst, exist_ok=True)
|
||||
for item in os.listdir(src):
|
||||
if item in EXCLUDE:
|
||||
continue
|
||||
s, d = os.path.join(src, item), os.path.join(dst, item)
|
||||
if os.path.isdir(s):
|
||||
copytree_filtered(s, d)
|
||||
else:
|
||||
shutil.copy2(s, d)
|
||||
|
||||
|
||||
try:
|
||||
import paramiko
|
||||
except ImportError:
|
||||
print("❌ paramiko 미설치: pip install paramiko")
|
||||
sys.exit(2)
|
||||
|
||||
c = paramiko.SSHClient()
|
||||
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ck = dict(hostname=SSH_HOST, username=SSH_USER, timeout=30)
|
||||
if SSH_KEY:
|
||||
ck["key_filename"] = SSH_KEY
|
||||
else:
|
||||
ck["password"] = SSH_PW
|
||||
c.connect(**ck) # 자격증명은 여기서만, 출력 없음
|
||||
|
||||
|
||||
def ssh(label, cmd, t=180):
|
||||
print(f" [{label}]")
|
||||
_, o, e = c.exec_command(cmd, timeout=t)
|
||||
out = o.read().decode("utf-8", "replace").strip()
|
||||
err = e.read().decode("utf-8", "replace").strip()
|
||||
if out:
|
||||
print(" ", out[-400:])
|
||||
if err:
|
||||
bad = [l for l in err.splitlines() if not any(k in l.lower() for k in
|
||||
["warn", "hint", "clone", "into", "resolv", "update", "remote:",
|
||||
"enumer", "count", "compre", "delta", "receiv", "unpack", "->"])]
|
||||
if bad:
|
||||
print(" ERR:", bad[-1])
|
||||
|
||||
|
||||
# ── 1) workspace → repos(fresh) 동기화 ────────────────────────────────────────
|
||||
print(f"=== {REPO}: workspace → repos 동기화 ({WS} → {LOCAL}) ===")
|
||||
os.makedirs(LOCAL, exist_ok=True)
|
||||
for item in os.listdir(WS):
|
||||
if item in EXCLUDE:
|
||||
continue
|
||||
s, d = os.path.join(WS, item), os.path.join(LOCAL, item)
|
||||
if os.path.isdir(s):
|
||||
if os.path.exists(d):
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
copytree_filtered(s, d)
|
||||
else:
|
||||
shutil.copy2(s, d)
|
||||
print(" 복사 완료")
|
||||
|
||||
# fresh git init (모노레포 히스토리 상속 방지 — GUARDiA 함정 회피)
|
||||
if not os.path.isdir(os.path.join(LOCAL, ".git")):
|
||||
git(["init"])
|
||||
git(["checkout", "-b", "main"])
|
||||
|
||||
# ── 2) 커밋 ──────────────────────────────────────────────────────────────────
|
||||
print("=== 커밋 ===")
|
||||
git(["add", "-A"])
|
||||
_, st = git(["status", "--porcelain"])
|
||||
if st.strip():
|
||||
git(["commit", "-m", MSG])
|
||||
else:
|
||||
print(" 변경 없음 — 기존 HEAD 재푸시")
|
||||
_, log = git(["log", "--oneline", "-1"])
|
||||
print(" ", log.strip())
|
||||
|
||||
# ── 3) bundle → SFTP → 서버에서 push (자격증명 미노출) ─────────────────────────
|
||||
print("=== bundle → SFTP → push ===")
|
||||
bundle = os.path.join(tempfile.gettempdir(), f"{REPO}.bundle")
|
||||
for f in (bundle, bundle + ".lock"):
|
||||
try:
|
||||
os.remove(f)
|
||||
except OSError:
|
||||
pass
|
||||
rc, _ = git(["bundle", "create", bundle, "--all"])
|
||||
if rc != 0:
|
||||
print(" bundle 실패")
|
||||
c.close()
|
||||
sys.exit(1)
|
||||
print(f" bundle: {os.path.getsize(bundle) // 1024}KB")
|
||||
|
||||
sftp = c.open_sftp()
|
||||
sftp.put(bundle, f"/tmp/{REPO}.bundle")
|
||||
sftp.close()
|
||||
os.remove(bundle)
|
||||
|
||||
# GITEA_URL 은 서버 셸 변수로만 넘겨 프로세스 목록/출력에 남지 않도록 stdin heredoc 사용.
|
||||
ssh("push", f"""
|
||||
set -e
|
||||
REMOTE='{GITEA_URL}'
|
||||
rm -rf /tmp/{REPO}_w
|
||||
git clone -q /tmp/{REPO}.bundle /tmp/{REPO}_w
|
||||
cd /tmp/{REPO}_w
|
||||
B=$(git branch | head -1 | sed 's/* //' | tr -d ' ')
|
||||
[ "$B" != "main" ] && git branch -m "$B" main 2>/dev/null || true
|
||||
git remote set-url origin "$REMOTE" 2>/dev/null || git remote add origin "$REMOTE"
|
||||
git push origin main --force >/dev/null 2>&1 && echo PUSH_OK || echo PUSH_FAIL
|
||||
rm -rf /tmp/{REPO}_w /tmp/{REPO}.bundle
|
||||
""")
|
||||
|
||||
# 배포는 Gitea push webhook(#47) 이 자동 처리. 수동 트리거 금지(동시 빌드 시 jar 교체 깨짐).
|
||||
c.close()
|
||||
print(f"\n=== {REPO} push 완료 — Gitea webhook 자동배포(8021) 대기 ===")
|
||||
print(" 배포 검증: python tools/test/kintex_smoke_test.py")
|
||||
39
tools/test/README.md
Normal file
39
tools/test/README.md
Normal file
@ -0,0 +1,39 @@
|
||||
# KINTEX 테스트 도구
|
||||
|
||||
GUARDiA `scripts/check/run_full_test.py` 패턴을 킨텍스(백엔드 8021)로 경량 이식한 스모크/회귀 러너.
|
||||
|
||||
## kintex_smoke_test.py
|
||||
|
||||
배포 후 검증·회귀·스모크용. **읽기(GET) + 미인증 로그인 프로브만** 보내며 파괴적 요청은 없다.
|
||||
|
||||
검증 항목:
|
||||
- **배포 게이트**: `GET /health` → 200 + `data.status=UP`
|
||||
- **인증**: `/api/auth/login` 프로브(라우터 등록) · `/api/auth/workspaces`·`/me`
|
||||
- **공통·시스템**: `/api/common/menus`·`/api/common/code-groups`·`/api/admin/menus`
|
||||
- **공통 업무(WISE)**: `/api/work/notices`·`/api/work/stats/worklog`·`/api/work/notifications/unread-count`
|
||||
- **전시 코어(M2~M5)**: layout·design·utility 라우터 등록
|
||||
|
||||
판정: 200/201=OK, 401/403/400/422=라우터 존재·인증필요(정상), 404/501=미등록/미구현(실패).
|
||||
|
||||
### 실행 모드
|
||||
|
||||
| 모드 | 방법 | 필요 env |
|
||||
|------|------|----------|
|
||||
| SSH(기본) | 로컬 PC→서버 SSH→서버 내부 curl | `KINTEX_SSH_HOST`, `KINTEX_SSH_PASSWORD`(또는 `KINTEX_SSH_KEYFILE`) |
|
||||
| `--local` | 같은 호스트에서 로컬 curl | 없음 |
|
||||
| `--http` | 도메인 직접 | `KINTEX_BASE`(예 `https://kintex.zioinfo.co.kr`) |
|
||||
|
||||
선택 env: `KINTEX_SSH_USER`(기본 root)·`KINTEX_BASE`(기본 `http://127.0.0.1:8021`)·`KINTEX_TEST_EMAIL`/`KINTEX_TEST_PASSWORD`(주어지면 1차 로그인 shape 확인).
|
||||
|
||||
```bash
|
||||
python tools/test/kintex_smoke_test.py # SSH
|
||||
python tools/test/kintex_smoke_test.py --local # 서버 로컬
|
||||
KINTEX_BASE=https://kintex.zioinfo.co.kr python tools/test/kintex_smoke_test.py --http
|
||||
```
|
||||
|
||||
결과: `tools/test/_results/latest.json` + 타임스탬프 파일. 종료코드 0=통과, 1=실패(CI 게이트용).
|
||||
|
||||
## 보안
|
||||
|
||||
- 시크릿 하드코딩 0 — SSH·테스트계정은 env only, 로그/리포트에 미노출.
|
||||
- 실서버 데이터 변경 없음(읽기 + 미인증 프로브).
|
||||
271
tools/test/kintex_smoke_test.py
Normal file
271
tools/test/kintex_smoke_test.py
Normal file
@ -0,0 +1,271 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
KINTEX 자동전시시스템 — 스모크/회귀 테스트 러너
|
||||
(GUARDiA `scripts/check/run_full_test.py` 패턴 차용 — kintex 백엔드 8021 전용)
|
||||
|
||||
무엇을 하나:
|
||||
1) GET /health 가 200 + status:UP 를 반환하는지(배포 검증 게이트)
|
||||
2) 주요 라우터가 등록되어 있는지(200 또는 401/403 = "라우터 존재·인증 필요" = 정상)
|
||||
3) (선택) 테스트 계정이 주어지면 로그인 시도 — 1차 로그인 응답 shape 만 확인
|
||||
(2차 인증 OTP 는 자동화하지 않는다. verifyToken 존재만 검증)
|
||||
|
||||
보안:
|
||||
- 시크릿 하드코딩 없음. SSH 접속정보·테스트계정은 모두 환경변수에서만 읽는다.
|
||||
- 자격증명은 로그·리포트에 출력하지 않는다(마스킹).
|
||||
- 실서버 파괴적 요청(생성/삭제)은 보내지 않는다 — 읽기(GET) + 미인증 로그인 프로브만.
|
||||
|
||||
실행:
|
||||
# 로컬(개발 PC)에서 서버로 SSH 후 서버 내부 curl (GUARDiA 러너 방식)
|
||||
# 필수 env: KINTEX_SSH_HOST, KINTEX_SSH_PASSWORD (또는 KINTEX_SSH_KEYFILE)
|
||||
# 선택 env: KINTEX_SSH_USER(기본 root), KINTEX_BASE(기본 http://127.0.0.1:8021)
|
||||
python tools/test/kintex_smoke_test.py
|
||||
|
||||
# 이미 서버(또는 같은 호스트)에서 실행 — SSH 없이 로컬 curl
|
||||
python tools/test/kintex_smoke_test.py --local
|
||||
|
||||
# 원격 HTTP 직접(도메인 경유, 서버 접속 불가 시)
|
||||
KINTEX_BASE=https://kintex.zioinfo.co.kr python tools/test/kintex_smoke_test.py --http
|
||||
|
||||
결과:
|
||||
tools/test/_results/latest.json + YYYYmmdd-HHMMSS.json
|
||||
종료코드 0 = 전체 통과, 1 = 실패 있음(CI 게이트용)
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import shlex
|
||||
import subprocess
|
||||
import pathlib
|
||||
from datetime import datetime
|
||||
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
BASE = os.environ.get("KINTEX_BASE", "http://127.0.0.1:8021")
|
||||
MODE = "ssh"
|
||||
if "--local" in sys.argv:
|
||||
MODE = "local"
|
||||
elif "--http" in sys.argv:
|
||||
MODE = "http"
|
||||
|
||||
# ── SSH 연결(원격 서버 내부 curl) — GUARDiA 러너 패턴, 단 시크릿은 env only ──────────
|
||||
_ssh = None
|
||||
if MODE == "ssh":
|
||||
host = os.environ.get("KINTEX_SSH_HOST")
|
||||
if not host:
|
||||
print("❌ KINTEX_SSH_HOST 미설정. --local(같은 호스트) 또는 --http(도메인) 사용, "
|
||||
"혹은 KINTEX_SSH_HOST/KINTEX_SSH_PASSWORD env 설정.")
|
||||
sys.exit(2)
|
||||
user = os.environ.get("KINTEX_SSH_USER", "root")
|
||||
pw = os.environ.get("KINTEX_SSH_PASSWORD")
|
||||
keyfile = os.environ.get("KINTEX_SSH_KEYFILE")
|
||||
try:
|
||||
import paramiko
|
||||
except ImportError:
|
||||
print("❌ paramiko 미설치: pip install paramiko (또는 --local/--http 모드)")
|
||||
sys.exit(2)
|
||||
_ssh = paramiko.SSHClient()
|
||||
_ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
kwargs = dict(hostname=host, username=user, timeout=20)
|
||||
if keyfile:
|
||||
kwargs["key_filename"] = keyfile
|
||||
elif pw:
|
||||
kwargs["password"] = pw
|
||||
else:
|
||||
print("❌ KINTEX_SSH_PASSWORD 또는 KINTEX_SSH_KEYFILE 필요(값은 로그에 노출되지 않음).")
|
||||
sys.exit(2)
|
||||
_ssh.connect(**kwargs) # 자격증명은 여기서만 사용, 출력 안 함
|
||||
|
||||
|
||||
def run(cmd, timeout=15):
|
||||
"""서버(또는 로컬)에서 shell 명령 실행 → stdout 문자열."""
|
||||
if MODE == "ssh":
|
||||
_, o, _ = _ssh.exec_command(cmd, timeout=timeout)
|
||||
return o.read().decode("utf-8", "replace").strip()
|
||||
# local / http 모드는 로컬 셸에서 curl 실행
|
||||
r = subprocess.run(["bash", "-c", cmd], capture_output=True, timeout=timeout + 5)
|
||||
return (r.stdout + r.stderr).decode("utf-8", "replace").strip()
|
||||
|
||||
|
||||
def http_get(path):
|
||||
"""GET path → (body_str, http_code)."""
|
||||
url = f"{BASE}{path}"
|
||||
out = run(f"curl -s -m 8 -w ' HTTP_%{{http_code}}' {shlex.quote(url)}")
|
||||
code = None
|
||||
if " HTTP_" in out:
|
||||
body, _, tail = out.rpartition(" HTTP_")
|
||||
out = body.strip()
|
||||
try:
|
||||
code = int(tail.strip())
|
||||
except ValueError:
|
||||
pass
|
||||
return out, code
|
||||
|
||||
|
||||
def http_post(path, body_json):
|
||||
url = f"{BASE}{path}"
|
||||
out = run(
|
||||
f"curl -s -m 8 -w ' HTTP_%{{http_code}}' -X POST {shlex.quote(url)} "
|
||||
f"-H 'Content-Type: application/json' -d {shlex.quote(body_json)}"
|
||||
)
|
||||
code = None
|
||||
if " HTTP_" in out:
|
||||
b, _, tail = out.rpartition(" HTTP_")
|
||||
out = b.strip()
|
||||
try:
|
||||
code = int(tail.strip())
|
||||
except ValueError:
|
||||
pass
|
||||
return out, code
|
||||
|
||||
|
||||
results = {
|
||||
"run_id": datetime.now().strftime("%Y%m%d-%H%M%S"),
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"base": BASE, "mode": MODE,
|
||||
"groups": {}, "failures": [], "total": 0, "passed": 0,
|
||||
}
|
||||
|
||||
|
||||
def group(name, checks):
|
||||
"""checks: [(label, fn)->(ok:bool, detail:str)]"""
|
||||
g = {"passed": 0, "failed": 0, "tests": []}
|
||||
for label, fn in checks:
|
||||
results["total"] += 1
|
||||
try:
|
||||
ok, detail = fn()
|
||||
except Exception as e: # noqa: BLE001
|
||||
ok, detail = False, f"exc: {type(e).__name__}"
|
||||
if ok:
|
||||
g["passed"] += 1
|
||||
results["passed"] += 1
|
||||
g["tests"].append({"label": label, "status": "PASS"})
|
||||
else:
|
||||
g["failed"] += 1
|
||||
g["tests"].append({"label": label, "status": "FAIL", "detail": detail[:80]})
|
||||
results["failures"].append({"group": name, "test": label, "detail": detail[:80]})
|
||||
results["groups"][name] = g
|
||||
icon = "✅" if g["failed"] == 0 else "⚠️"
|
||||
print(f" {icon} {name:<26} {g['passed']}/{g['passed'] + g['failed']}")
|
||||
for t in g["tests"]:
|
||||
if t["status"] == "FAIL":
|
||||
print(f" ❌ {t['label']}: {t.get('detail', '')}")
|
||||
|
||||
|
||||
# ── 체크 헬퍼 ─────────────────────────────────────────────────────────────────
|
||||
def check_health():
|
||||
body, code = http_get("/health")
|
||||
if code != 200:
|
||||
return False, f"HTTP {code}"
|
||||
try:
|
||||
j = json.loads(body)
|
||||
except Exception:
|
||||
return False, f"non-json: {body[:40]}"
|
||||
data = j.get("data", {}) if isinstance(j, dict) else {}
|
||||
if data.get("status") == "UP" and data.get("service") == "kintex-backend":
|
||||
return True, "UP"
|
||||
return False, f"status={data.get('status')}"
|
||||
|
||||
|
||||
def check_registered(path, method="GET", body=None):
|
||||
"""라우터 등록 검증: 200/201 = OK, 401/403 = 존재·인증필요(정상), 404/501 = 미등록/미구현(실패)."""
|
||||
def _fn():
|
||||
if method == "GET":
|
||||
resp, code = http_get(path)
|
||||
else:
|
||||
resp, code = http_post(path, body or "{}")
|
||||
if code in (200, 201, 202):
|
||||
return True, f"{code}"
|
||||
if code in (400, 401, 403, 422):
|
||||
# 라우터는 존재하되 인증/유효성으로 거절 = 등록 확인
|
||||
return True, f"{code} (registered)"
|
||||
if code == 404:
|
||||
return False, "404 not registered"
|
||||
if code == 501:
|
||||
return False, "501 not implemented"
|
||||
if code is None:
|
||||
return False, "no response (down?)"
|
||||
return False, f"HTTP {code}"
|
||||
return _fn
|
||||
|
||||
|
||||
def check_login_probe():
|
||||
"""테스트 계정(env)이 있으면 1차 로그인 shape 확인. 없으면 미인증 프로브(400/401=라우터 정상)."""
|
||||
email = os.environ.get("KINTEX_TEST_EMAIL")
|
||||
pw = os.environ.get("KINTEX_TEST_PASSWORD")
|
||||
if email and pw:
|
||||
body = json.dumps({"email": email, "password": pw})
|
||||
resp, code = http_post("/api/auth/login", body)
|
||||
if code in (200, 401): # 200=성공(또는 OTP단계), 401=자격오류지만 라우터 정상
|
||||
return True, f"{code}"
|
||||
return False, f"HTTP {code}"
|
||||
# 자격증명 없음 — 유효성 거절(400/401/422)로 라우터 등록만 확인 (계정 노출 없음)
|
||||
resp, code = http_post("/api/auth/login", json.dumps({"email": "probe@example.com", "password": "x"}))
|
||||
if code in (400, 401, 422):
|
||||
return True, f"{code} (registered, no creds)"
|
||||
if code == 200:
|
||||
return True, "200"
|
||||
return False, f"HTTP {code}"
|
||||
|
||||
|
||||
# ── 실행 ─────────────────────────────────────────────────────────────────────
|
||||
print("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
||||
print(f" KINTEX 스모크 테스트 [{datetime.now():%Y-%m-%d %H:%M:%S}] ({MODE} → {BASE})")
|
||||
print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
|
||||
start = time.time()
|
||||
|
||||
print("[배포 게이트]")
|
||||
group("health", [("GET /health", check_health)])
|
||||
|
||||
print("\n[인증]")
|
||||
group("auth", [
|
||||
("POST /api/auth/login (프로브)", check_login_probe),
|
||||
("GET /api/auth/workspaces", check_registered("/api/auth/workspaces")),
|
||||
("GET /api/auth/me", check_registered("/api/auth/me")),
|
||||
])
|
||||
|
||||
print("\n[공통·시스템 관리]")
|
||||
group("system", [
|
||||
("GET /api/common/menus", check_registered("/api/common/menus")),
|
||||
("GET /api/common/code-groups", check_registered("/api/common/code-groups")),
|
||||
("GET /api/admin/menus", check_registered("/api/admin/menus")),
|
||||
])
|
||||
|
||||
print("\n[공통 업무 레이어(WISE)]")
|
||||
group("work", [
|
||||
("GET /api/work/notices", check_registered("/api/work/notices")),
|
||||
("GET /api/work/stats/worklog", check_registered("/api/work/stats/worklog")),
|
||||
("GET /api/work/notifications/unread-count",
|
||||
check_registered("/api/work/notifications/unread-count")),
|
||||
])
|
||||
|
||||
print("\n[전시 코어 모듈(M2~M5) — 라우터 등록]")
|
||||
# 행사 스코프 경로: 인증/권한으로 거절되더라도 404/501 이 아니면 등록된 것으로 본다.
|
||||
group("exhibition_core", [
|
||||
("M2 플로어플랜 layout",
|
||||
check_registered("/api/events/1/halls/1/layout")),
|
||||
("M3 부스 design",
|
||||
check_registered("/api/events/1/booths/1/design")),
|
||||
("M4 유틸리티/배선",
|
||||
check_registered("/api/events/1/booths/1/utility")),
|
||||
])
|
||||
|
||||
elapsed = round(time.time() - start, 1)
|
||||
results["duration_sec"] = elapsed
|
||||
fail = results["total"] - results["passed"]
|
||||
|
||||
print("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
||||
print(f" 결과: {results['passed']}/{results['total']} 통과 "
|
||||
f"{'✅ 전체 통과' if fail == 0 else f'❌ {fail}개 실패'} ({elapsed}초)")
|
||||
print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
||||
|
||||
out_dir = pathlib.Path(__file__).resolve().parent / "_results"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
for p in [out_dir / f"{results['run_id']}.json", out_dir / "latest.json"]:
|
||||
with open(p, "w", encoding="utf-8") as fp:
|
||||
json.dump(results, fp, ensure_ascii=False, indent=2, default=str)
|
||||
print(f"\n결과 저장: {out_dir / 'latest.json'}")
|
||||
|
||||
if _ssh:
|
||||
_ssh.close()
|
||||
sys.exit(0 if fail == 0 else 1)
|
||||
Loading…
Reference in New Issue
Block a user