# Fleet Hook - 웹에서 Python 로직 편집 가이드

엣지 Data Collector의 동작을 웹에서 Python 스크립트로 커스터마이징하는 기능입니다.

## 개념

```
┌─ Pipeline 웹 UI ─────────────┐
│  사용자가 Python 함수 편집   │
│  (Monaco 에디터)             │
│         ↓                    │
│  [테스트] 버튼으로 미리 검증 │
│         ↓                    │
│  [저장] → fleet_edge_scripts │
└──────────────┬───────────────┘
               │
               │ /api/fleet/v1/edges/{id}/config
               │ (ETag 캐싱)
               ▼
┌─ 엣지 Data Collector (Python) ┐
│  scripts = config["scripts"]  │
│  for script in scripts:       │
│    load_hook(script)          │
│                               │
│  수집 사이클마다:             │
│  ├ raw_value = read_plc()     │
│  ├ value = transform(...)     │  ← Hook 1
│  ├ tags.update(derived(...))  │  ← Hook 2
│  ├ if not filter_data(...):   │  ← Hook 3
│  │     skip                   │
│  ├ alarm_info = alarm(...)    │  ← Hook 4
│  ├ payload = pre_send(...)    │  ← Hook 5
│  └ publish_mqtt(payload)      │
└───────────────────────────────┘
```

## 5가지 Hook 종류

| Hook | 시점 | 입력 | 출력 | 용도 |
|---|---|---|---|---|
| **transform** | 원시값 변환 | tag_name, raw_value, context | 변환된 값 | 센서 스케일링, 단위 변환 |
| **derived_tags** | 파생 태그 계산 | tags 딕셔너리, context | 새 태그 딕셔너리 | 여러 태그 조합 (전력 = V×I) |
| **filter** | 발행 여부 판단 | tags, context | bool | 조건부 수집 (가동 중만) |
| **alarm** | 알람 판정 | tag_name, value, context | dict 또는 None | 임계값 초과 알람 |
| **pre_send** | MQTT 발행 전 | payload, context | 가공된 payload | 최종 메타데이터 추가 |

## 적용 범위 (scope)

- **global**: 모든 엣지에 적용
- **equipment**: 특정 장비만 (pipeline_equipment)
- **connection**: 특정 통신 연결만 (pipeline_device_connections)
- **device**: 특정 엣지 디바이스만 (fleet_devices)

## Python 엣지 쪽 hook loader 샘플

기존 Data Collector 프로젝트(`/Users/chpark/workspace/data-collector/src/data_collector/`)에 추가할 파일:

### `hooks/hook_loader.py`

```python
"""
Hook Loader - Fleet API에서 받은 Python 스크립트를 로드/실행
"""
import logging
from typing import Any, Callable, Dict, List, Optional
import structlog

logger = structlog.get_logger(__name__)

# 허용된 내장 함수/모듈 (보안)
ALLOWED_BUILTINS = {
    'abs', 'all', 'any', 'bool', 'bytes', 'dict', 'enumerate', 'filter',
    'float', 'int', 'len', 'list', 'map', 'max', 'min', 'print', 'range',
    'round', 'set', 'sorted', 'str', 'sum', 'tuple', 'type', 'zip',
    'isinstance', 'hasattr', 'getattr', 'True', 'False', 'None',
}


class HookRegistry:
    """Hook 스크립트 등록 및 실행"""

    # hook_type → [(script_id, priority, scope, callable, meta)]
    hooks: Dict[str, List[Dict[str, Any]]] = {}

    # 스크립트 ID → 컴파일된 함수 캐시
    compiled: Dict[int, Dict[str, Callable]] = {}

    @classmethod
    def load_from_config(cls, scripts: List[Dict[str, Any]]) -> None:
        """
        Fleet API에서 받은 스크립트 목록을 로드
        각 hook별로 priority 순으로 정렬
        """
        cls.hooks = {}
        cls.compiled = {}

        func_name_map = {
            "transform": "transform",
            "derived_tags": "derived_tags",
            "filter": "filter_data",
            "alarm": "alarm",
            "pre_send": "pre_send",
        }

        for script in scripts:
            try:
                hook_type = script["hook_type"]
                func_name = func_name_map.get(hook_type)
                if not func_name:
                    continue

                # 제한된 네임스페이스에서 컴파일
                import math
                from datetime import datetime, date
                allowed_globals = {
                    "__builtins__": {k: __builtins__[k] for k in ALLOWED_BUILTINS if k in dir(__builtins__)},
                    "math": math,
                    "datetime": datetime,
                    "date": date,
                }
                exec(script["code"], allowed_globals)
                func = allowed_globals.get(func_name)
                if not callable(func):
                    logger.warning(f"함수 {func_name}가 정의되지 않음: script id={script['id']}")
                    continue

                cls.hooks.setdefault(hook_type, []).append({
                    "script_id": script["id"],
                    "script_name": script.get("script_name", ""),
                    "scope": script.get("scope", "global"),
                    "equipment_id": script.get("equipment_id"),
                    "connection_id": script.get("connection_id"),
                    "priority": script.get("priority", 100),
                    "timeout_ms": script.get("timeout_ms", 1000),
                    "func": func,
                })
                logger.info(f"Hook 로드: {hook_type} / script_id={script['id']} v{script.get('version', 1)}")
            except Exception as e:
                logger.error(f"Hook 컴파일 실패 (id={script.get('id')}): {e}")

        # 우선순위 정렬
        for hooks in cls.hooks.values():
            hooks.sort(key=lambda h: h["priority"])

    @classmethod
    def _match_scope(cls, hook: Dict[str, Any], equipment_id: Optional[int], connection_id: Optional[int]) -> bool:
        """스코프 매칭"""
        scope = hook.get("scope", "global")
        if scope == "global":
            return True
        if scope == "equipment" and hook.get("equipment_id") == equipment_id:
            return True
        if scope == "connection" and hook.get("connection_id") == connection_id:
            return True
        return False

    @classmethod
    def run_transform(cls, tag_name: str, raw_value: Any, context: dict) -> Any:
        """transform hook 실행 (파이프라인 - 순차 적용)"""
        value = raw_value
        for hook in cls.hooks.get("transform", []):
            if not cls._match_scope(hook, context.get("equipment_id"), context.get("connection_id")):
                continue
            try:
                value = hook["func"](tag_name, value, context)
            except Exception as e:
                logger.warning(f"transform 실패 (script_id={hook['script_id']}): {e}")
        return value

    @classmethod
    def run_derived_tags(cls, tags: dict, context: dict) -> dict:
        """derived_tags hook 실행 (모든 hook 결과 병합)"""
        result = {}
        for hook in cls.hooks.get("derived_tags", []):
            if not cls._match_scope(hook, context.get("equipment_id"), context.get("connection_id")):
                continue
            try:
                new_tags = hook["func"](tags, context) or {}
                if isinstance(new_tags, dict):
                    result.update(new_tags)
            except Exception as e:
                logger.warning(f"derived_tags 실패: {e}")
        return result

    @classmethod
    def run_filter(cls, tags: dict, context: dict) -> bool:
        """filter hook 실행 (AND - 모두 True여야 발행)"""
        for hook in cls.hooks.get("filter", []):
            if not cls._match_scope(hook, context.get("equipment_id"), context.get("connection_id")):
                continue
            try:
                if not hook["func"](tags, context):
                    return False
            except Exception as e:
                logger.warning(f"filter 실패: {e}")
        return True

    @classmethod
    def run_alarm(cls, tag_name: str, value: Any, context: dict) -> List[dict]:
        """alarm hook 실행 (모든 알람 수집)"""
        alarms = []
        for hook in cls.hooks.get("alarm", []):
            if not cls._match_scope(hook, context.get("equipment_id"), context.get("connection_id")):
                continue
            try:
                alarm_info = hook["func"](tag_name, value, context)
                if alarm_info:
                    alarm_info["script_id"] = hook["script_id"]
                    alarms.append(alarm_info)
            except Exception as e:
                logger.warning(f"alarm 실패: {e}")
        return alarms

    @classmethod
    def run_pre_send(cls, payload: dict, context: dict) -> dict:
        """pre_send hook 실행 (순차 적용)"""
        result = payload
        for hook in cls.hooks.get("pre_send", []):
            if not cls._match_scope(hook, context.get("equipment_id"), context.get("connection_id")):
                continue
            try:
                result = hook["func"](result, context) or result
            except Exception as e:
                logger.warning(f"pre_send 실패: {e}")
        return result
```

### 수집 파이프라인 통합 (`collectors/manager.py`)

```python
# 수집 루프 안에서...
from data_collector.hooks.hook_loader import HookRegistry

async def collect_and_publish(self, device):
    raw_data = await self.collector.collect()
    context = {
        "device_id": self.device_id,
        "equipment_id": device.equipment_id,
        "connection_id": device.id,
        "company_code": self.company_code,
    }

    # 1. transform 각 태그에 적용
    tags = {}
    for tag_name, raw_value in raw_data.items():
        tags[tag_name] = HookRegistry.run_transform(tag_name, raw_value, context)

    # 2. derived_tags 병합
    tags.update(HookRegistry.run_derived_tags(tags, context))

    # 3. filter 체크
    if not HookRegistry.run_filter(tags, context):
        logger.debug("filter로 스킵")
        return

    # 4. alarm 판정
    alarms = []
    for tag_name, value in tags.items():
        alarms.extend(HookRegistry.run_alarm(tag_name, value, context))
    if alarms:
        # 알람 발행 (MQTT vexplor/devices/{id}/alarms 등)
        self.publish_alarms(alarms)

    # 5. 최종 payload 가공
    payload = {
        "timestamp": datetime.now().isoformat(),
        "equipment_id": device.equipment_id,
        "connection_id": device.id,
        "tags": tags,
    }
    payload = HookRegistry.run_pre_send(payload, context)

    # 6. MQTT 발행
    self.mqtt.publish(f"vexplor/devices/{self.device_id}/data", payload)
```

### config_syncer에 hook 로드 추가

```python
async def fetch_config(self):
    # ... 기존 설정 조회 ...

    # Hook 스크립트 로드
    if config.get("scripts"):
        from data_collector.hooks.hook_loader import HookRegistry
        HookRegistry.load_from_config(config["scripts"])
        logger.info(f"Hook 스크립트 로드: {len(config['scripts'])}개")
```

## 로컬 테스트

Pipeline 웹에서:
1. **시스템 관리 > Python Hook** 메뉴 접근
2. **새 스크립트** → Hook 타입 선택 → 예제 코드 자동 로드
3. 우측 Monaco 에디터에서 편집
4. 좌측 하단 **테스트 입력 JSON** 작성 → **실행** 버튼
5. 결과 확인 후 **저장**

## API 엔드포인트

| 메서드 | 경로 | 용도 |
|---|---|---|
| GET | `/api/fleet/scripts/hook-types` | Hook 타입 5종 + 예제 코드 |
| GET | `/api/fleet/scripts` | 스크립트 목록 |
| POST | `/api/fleet/scripts` | 생성 |
| PUT | `/api/fleet/scripts/:id` | 수정 (자동 버전 증가) |
| DELETE | `/api/fleet/scripts/:id` | 삭제 |
| POST | `/api/fleet/scripts/dry-run` | 저장 전 테스트 실행 |
| GET | `/api/fleet/scripts/:id/versions` | 버전 이력 |
| POST | `/api/fleet/scripts/:id/rollback/:version` | 롤백 |
| GET | `/api/fleet/v1/edges/:id/config` | 엣지용 전체 설정 (scripts 포함) |

## 보안 사항

- Python `exec()` 실행 시 제한된 네임스페이스 (ALLOWED_BUILTINS만)
- `import` 제한 (math, datetime, json만 허용)
- 파일 시스템 / 네트워크 접근 차단
- 각 hook 실행 타임아웃 (기본 1초)
- Dry-run 시 Python 서브프로세스 격리

## 실시간 반영

1. 웹에서 수정 → PUT API 호출
2. DB UPDATE 트리거 → version 증가 + 이력 저장
3. Python이 다음 config sync 주기(기본 30초) 시 새 버전 감지
4. `HookRegistry.load_from_config()` 재실행 → 즉시 적용
5. **Python 재시작 불필요**
