docs: move PRIVATE_SERVER.md and NATS_REPORT.md to nats-docker submodule

This commit is contained in:
2026-08-23 15:11:04 +09:00
parent 629a67f09d
commit 12ba30bde1
4 changed files with 26 additions and 772 deletions
-176
View File
@@ -1,176 +0,0 @@
# 📊 MAM 메시징 백플레인 아키텍처 심층 분석 보고서: MQTT vs NATS
- **문서 버전**: Rev.2 Final Synthesis (`f1956d2e` / `5ac88ca0`)
- **작성/검토 주체**: MAM Multi-Agent Orchestration Team (`claude`, `agy`)
- **기준 커밋**: `ac82f9b` (`refactor`, 276/276 tests passing)
- **문서 목적**: MAM 프레임워크의 메시징 인프라(MQTT)를 NATS로 전면 전환할 것인지 여부에 대한 종합적인 기술·운영·보안 타당성 분석 및 실행 로드맵 확정.
---
## 0. 최종 판정 (Executive Verdict)
> ### 🎯 **[VERDICT: DO NOT MIGRATE CLIENT PROTOCOL — ADOPT `nats-server` AS DEDICATED BROKER]**
>
> **클라이언트 전송 프로토콜(MQTT)은 유지하고, 전용 브로커로서 `nats-server`의 내장 MQTT 3.1.1 어댑터를 채택합니다.**
### 0.1 3대 핵심 근거 요약
```
[MAM Control Plane] ────> run_loop.sh (wait_for_job: 3s Local Disk Polling) ──> 100% Broker-Independent
[Observability Plane] ────> publish_event.py ──(MQTT 3.1.1)──> nats-server (JetStream + nkeys)
```
1. **제어 평면과 관측 평면의 분리**:
MAM의 핵심 루프(`run_loop.sh`)는 MQTT 메시지를 구독하지 않으며, 로컬 파일시스템(`.mam/jobs/<id>.json`)을 3초 주기로 폴링(`wait_for_job`)하여 작업 완료를 판정합니다. 브로커는 **비동기 관측(observability) 사이드카**이며 제어 평면을 차단하지 않습니다.
2. **NATS의 실질적 이점은 '서버'에 존재**:
NATS의 핵심 강점(단일 무의존 Go 바이너리, JetStream 영속성, nkeys/JWT 계정·Subject별 ACL)은 서버 계층의 속성입니다. `nats-server`는 **MQTT 3.1.1 프로토콜을 네이티브로 수용**하므로, 클라이언트 코드를 한 줄도 바꾸지 않고 서버의 모든 운영·보안 이점을 100% 확보할 수 있습니다.
3. **네이티브 NATS(`nats-py`) 전환의 비용 대비 무익함**:
`nats-py`는 asyncio 전용 라이브러리로, bash 기반의 단명(short-lived) 동기 CLI 도구들(`publish_event.py` 등)과 심각한 구조적 마찰을 일으키며, 최소 46건의 테스트 재작성 및 276건 green 베이스라인 훼손 위험을 초래합니다. 반면 NATS 고유 기능(Req/Reply, 초당 수백만 처리량, 클러스터링)은 MAM 워크로드(단일 워크스페이스, 잡당 수 개 이벤트)에서 전혀 사용되지 않습니다.
---
## 1. 3대 전략적 선택지 비교 분석
| 평가 항목 | (A) 현행 유지 (공개 HiveMQ) | (B) 네이티브 NATS 전환 (`nats-py`) | (C) `nats-server` + MQTT 프로토콜 유지 (채택안) |
|---|---|---|---|
| **클라이언트 코드 변경** | 0줄 | 4개 호출부 전면 비동기 재작성 | **0줄** (환경변수만 구성) |
| **테스트 코드 재작성** | 0건 | 최소 46건 재작성 (276건 베이스라인 위험) | **0건** (기존 276건 100% 보존) |
| **A-2 보안 결함 해소** | ❌ 불가 (공개 브로커) | ✅ 완전 해소 | ✅ **완전 해소** (nkeys/JWT subject ACL) |
| **단일 정적 바이너리 배포** | ❌ 불가 | ✅ 지원 | ✅ **지원** (`nats-server` 바이너리 1개) |
| **이벤트 영속성 (JetStream)** | ❌ 미지원 | ✅ 지원 | ✅ **지원** (내장 JetStream 엔진) |
| **동기 CLI 호환성** | ✅ 우수 (paho-mqtt) | ❌ 심각 (asyncio 강제) | ✅ **우수** (기존 동기 핫패스 유지) |
| **되돌리기(Rollback) 비용** | — | 🔴 높음 (비가역 코드 재작성) | 🟢 **0 (가역적 환경변수 스위치)** |
| **최종 평가** | **기각 (보안 위험)** | **기각 (비용 대비 실익 전무)** | 🏆 **최종 채택** |
---
## 2. 현행 아키텍처 실측 및 기술적 진단 (Ground Truth)
### 2.1 제어 경로 상의 MQTT 의존도 실측
- `run_loop.sh` (899줄, 메인 오케스트레이터) 내 MQTT 직접 참조는 `:889`의 임시 구독자 로그 파일 삭제 1건뿐입니다.
- 작업 완료 감지는 11개 호출 지점(전체 12개 참조) 전체가 `wait_for_job()` 함수를 통해 `.mam/jobs/<id>.json` 파일의 `status` 필드를 3초 간격으로 검사합니다.
- 따라서 브로커가 다운되어도 제어 평면 자체는 독립적으로 완주할 수 있는 구조입니다.
### 2.2 paho-mqtt 결합도 (Blast Radius)
- `import paho``mqtt_common.py:32` 단 1곳에 캡슐화되어 있습니다.
- 그러나 `make_client()`가 raw `mqtt.Client` 인스턴스를 반환하여 다음 4개 지점에서 구동됩니다:
1. `mqtt_common.py:250-276` (`make_client`)
2. `publish_event.py:102-122` (발행 및 ACK 대기)
3. `job_subscriber.py:172-251` (이벤트 큐잉 및 구독)
4. `reconcile.sh:245-292` (내장 python 이벤트 수신)
---
## 3. 코드베이스 잠복 결함 분석 (F-1 ~ F-5)
브로커 제품 선택과 무관하게 현행 코드에 잠복해 있는 5가지 구조적 결함이 발굴되었습니다.
```
[발굴된 결함 체인]
F-1: publish 실패 시 return 2 ──> 레지스트리 상태 동기화 누락 ──> run_loop 3900초(65분) 정지
F-4: subscriber 미포착 예외 rc=1 ──> loop/discuss 경로에서 job_status="error" 오판정
F-2/F-3: 전역 토픽 + auth_token 조건부 ──> 워크스페이스 격리 및 HMAC 검증 사각지대 (A-2)
F-5: 매 실행 랜덤 client_id ──> 문서가 주장하는 durable session 구성 불가
```
### 3.1 F-1 (Critical): 발행 실패 시 레지스트리 갱신 누락 (65분 루프 정지)
- `publish_event.py:195-199`에서 브로커 네트워크 오류 발생 시 `return 2`로 조기 종료됩니다.
- 이로 인해 뒤따르는 `append_event`, `registry.append_event`, `update_job_status(status=completed)`가 실행되지 못합니다.
- `wait_for_job``status=running` 상태에서 `max_wait=3900s`를 소진할 때까지 **65분간 정지**합니다.
### 3.2 F-2 (High): 워크스페이스 지문 토픽 미발행 (A-2)
- `reconcile.sh:236`은 지문 토픽(`mam/<fp>/jobs/+/events`)을 구독하지만, `mqtt_common.py:119`는 전역 토픽(`python/mqtt/jobs`)으로만 발행합니다.
- 워크스페이스 간 메시지 격리가 실질적으로 비활성화되어 있습니다.
### 3.3 F-3 (High): HMAC 인증 조건부 공허화 (A-2)
- `registry.py:75-79`는 TLS나 사용자 인증이 켜진 보안 브로커 감지 시 `secrets.token_urlsafe(32)`를 자동 생성하나, 기본 공개 브로커(또는 평문 TCP 브로커) 환경에서는 토큰이 발급되지 않아 `auth_token=None`으로 남습니다.
- 이로 인해 `verify_hmac``if not auth_token: return True` 분기가 무조건 참이 되어 공개 브로커 환경에서 HMAC 검증이 무력화됩니다 (Track 2에서 전 브로커 대상 무조건 발급으로 심층 방어 적용 필요).
### 3.4 F-4 (Critical): `loop`/`discuss` 위임 경로의 오판정 결함
- `multi-agent-mux-delegate-job:331-341`에서 `wait "$sub_pid"``sub_rc`를 직접 `job_status`로 매핑(`rc=1` -> `job_status="error"`).
- 브로커 연결 실패 시 `job_subscriber.py`가 미포착 예외로 `rc=1`을 내므로, **브로커 접속 실패가 작업 에러로 둔갑**합니다.
### 3.5 F-5 (Medium): 영속 세션(Durable Session) 구성 불가
- `make_client()`가 매 실행마다 `uuid.uuid4().hex[:8]`로 랜덤 `client_id`를 생성하므로, 브로커가 재연결 세션을 식별할 수 없습니다.
---
## 4. 리뷰 및 챌린지 검증 (Challenge Resolution)
### 4.1 C1 챌린지 분석 및 판정
Reviewer (`agy`)가 제기한 `job_subscriber.py`의 제어 경로 블로킹 및 디스크 폴백 누락 지적을 실측 검증하였습니다.
1. **C1-a (위임 대기 경로 실재)**: `multi-agent-mux-delegate-job:227``wait "$sub_pid"`가 존재하며, `run_loop.sh`의 모든 호출부가 `--type direct`로 이 경로를 통과함을 확인 (수용).
2. **C1-b (디스크 폴백 부재)**: `job_subscriber.py`는 오직 `watcher.events.get()`만 대기하므로 브로커 단절 시 이벤트를 수신하지 못함 (수용).
3. **C1-c (메커니즘 선후관계)**: C1은 F-1이 해결되어 디스크에 완료 상태가 쓰여진 이후에 드러나는 연쇄 결함임 (정정 및 반영).
4. **C1-d (지연 시간 실측)**: 브로커 도달 불가 시 구독자는 15~40초 내 `rc=1`로 조기 종료되어 실제 추가 블로킹은 0초임 (지연 영향 기각, 그러나 감사 로그 오염 및 거짓 실패 판정의 심각성으로 채택).
5. **C1-e (해결책 채택)**: `job_subscriber.py`의 대기 루프에 로컬 디스크(`load_job` / `read_logged_status`) 폴백을 도입하여 브로커 단절 시에도 즉시 정상 종료하도록 보강.
---
## 5. 단계별 실행 계획 (Actionable Roadmap)
```
[Track 0: 결함 교정] ──> [Track 1: nats-server 스파이크] ──> [Track 2: A-2 보안/격리 해소] ──> [Track 3: 문서화]
(F-1, C1, F-4 해결) (S-1 ~ S-9 매트릭스 검증) (F-2, F-3, Token, ACL) (MESSAGING, VERSIONS)
```
### 5.1 Track 0 — 가용성 및 결함 교정 (최우선 과제, 브로커 무관)
#### Step 순서 의존성 (Strict Ordering)
1. **Step 1 (`publish_event.py`)**: 발행 실패 시에도 레지스트리 상태 동기화 및 감사 로그 작성을 완수하고 `return 2` 반환.
2. **Step 2 (`job_subscriber.py`)**: `queue.Empty` 시 3초 스로틀로 디스크 터미널 상태를 확인하여 `source: disk-fallback` 합성 이벤트 출력 후 `rc=0` 조기 종료.
3. **Step 3 (`multi-agent-mux-delegate-job`)**: 인프라 예외에 전용 `rc=3`을 부여하고 `job_status="broker_unavailable"` 분기 처리.
#### 회귀 가드 매트릭스 (11종 신설 — G-1 ~ G-11, 목표 287/287 PASS)
- **G-1**: 브로커 도달 불가 발행 시 `rc=2`이면서 레지스트리 `status=completed` 확인.
- **G-2**: 감사 로그에 `published: false``publish_error` 필드 기록 확인.
- **G-3**: 정상 브로커 발행 시 `rc=0``published: true` 무회귀 확인.
- **G-4**: 발행 실패 시 단조 `last_seq` 증가 및 후속 발행 seq 보존 확인.
- **G-5**: 디스크 `status=completed` 선작성 시 브로커 다운 상태에서도 `job_subscriber.py`가 3초 내 `rc=0` 종료.
- **G-6**: 디스크 폴백 종료 시 stdout에 `disk-fallback` 명시 확인.
- **G-7**: 디스크 `status=error` 시 폴백 `rc=1` 반환 확인.
- **G-8**: 다중 잡 감시 시 전체 완료 전까지 조기 종료 방지.
- **G-9**: 디스크 터미널 부재 + 브로커 실패 시 `rc=3` 반환 확인 (F-4 방어).
- **G-10**: `loop` 위임 경로에서 `rc=3` 수신 시 `job_status``"error"`로 오판되지 않음을 확인.
- **G-11**: `registry.register_job()` 호출 시 `auth_token`이 항상 비어있지 않게 생성됨을 단언 (`secrets.token_urlsafe(32)` 유지).
### 5.2 Track 1 — `nats-server` 스파이크 검증 매트릭스 (S-1 ~ S-9)
격리 클론(`$SCRATCH/nats-spike`)에서 검증 수행:
- **S-1**: `nats-server -js` MQTT 리스너 기본 구동 및 `started/progress/completed` 발행 수용 (`rc=0`).
- **S-2**: paho-mqtt 2.x `CallbackAPIVersion.VERSION2` CONNACK 호환성 검증.
- **S-3 (핵심 관문)**: **Retained terminal event 정상 전달 검증** (늦은 구독자의 즉시 최종 상태 수신). *실패 시 mosquitto로 회귀*.
- **S-4**: QoS 1 `wait_for_publish` ACK 동작 검증.
- **S-5**: 와일드카드 토픽(`mam/<fp>/jobs/+/events`) 구독 및 라우팅 검증.
- **S-6**: TLS 암호화 및 유저 인증 접근 제어 검증.
- **S-7**: Subject/Topic 레벨 권한 분리(Publisher write-only / Subscriber read-only) 검증.
- **S-8**: 전체 287건 회귀 테스트 100% PASS 검증.
- **S-9**: Track 0 디스크 폴백이 `nats-server` 장애 상황에서도 정상 동작함을 통합 검증.
### 5.3 Track 2 — A-2 보안 및 워크스페이스 격리 해소
1. **F-3 해소**: `registry.register_job()`에서 브로커 설정(TLS/인증 유무)과 무관하게 `secrets.token_urlsafe(32)` 기반 `auth_token`**무조건 항상 발급**.
2. **F-2 해소**: `DEFAULT_TOPIC_ROOT``mam/<sha256[:12]>/jobs`로 전환. 발행측 전환 후 `reconcile.sh`의 레거시 구독 단계적 제거.
3. 배포 설정에 nkeys 기반 계정 분리 적용.
### 5.4 Track 3 — 문서 및 설정 동기화
- `MESSAGING.md`: 브로커 사양을 `nats-server`로 갱신, F-1/C1 해소 기록, F-5 실측에 맞춘 영속 세션 설명 정정.
- `IMPROVEMENTS.md` & `VERSIONS.md`: A-2 완료 전환, F-1/F-4/F-5 백로그 이력 반영.
- `.mam.env`: `nats-server` 포트(1883/8883) 및 인증 템플릿 갱신.
---
## 6. 비-목표 (Explicit Non-Goals)
1.**`nats-py` 라이브러리 도입 및 클라이언트 비동기 재작성**: 불필요한 복잡도 및 장애 유발.
2.**`.mam/jobs/*.json`의 JetStream KV 대체**: 파일시스템 폴링 제어 계약을 훼손하므로 상태 계층 변경 제외.
3.**Durable Session 강제 도입을 위한 `client_id` 고정**: 동시성 충돌 위험이 크며, Track 0 디스크 폴백이 동일 복원력을 무비용으로 제공함.
4.**`requirements.txt``paho-mqtt>=2.0.0` 제거**: 현행 종속성 유지.
---
## 7. 결론
MAM 프레임워크의 메시징 백플레인은 **클라이언트 프로토콜(MQTT)을 100% 보존한 상태에서 `nats-server`를 전용 브로커로 채택(Option C)**하는 것이 기술적·운영적·보안적 최적해입니다.
선행 필수 과제인 **Track 0(F-1 + C1 + F-4 가용성 결함 교정)**을 우선 완수한 후, 스파이크 검증(Track 1) 및 A-2 보안 강화(Track 2)를 순차 전개합니다.
-584
View File
@@ -1,584 +0,0 @@
# 🔒 MAM 개인 전용 브로커(Private Broker) 구축 및 연동 가이드 (`PRIVATE_SERVER.md`)
- **작성일**: 2026-08-20 (Rev.2)
- **문서 목적**: MAM(Multi-Agent Mux)의 공개 브로커 의존성 및 보안 결함(A-2)을 해소하기 위한 개인 전용 브로커(NATS / Mosquitto) 구축, 운영, 다능성 활용 및 MAM 클라이언트 연동 가이드.
- **연계 문서**: [`NATS_REPORT.md`](NATS_REPORT.md), [`implementation_plan.md`](implementation_plan.md), [`IMPROVEMENTS.md`](IMPROVEMENTS.md)
---
## 1. 개요 및 도입 배경
현재 MAM 프레임워크의 기본 메시징 브로커는 공개 서버(`broker.hivemq.com:1883`)로 설정되어 있습니다. 개인 전용 브로커(Private Broker)를 구축하여 연결하면 **클라이언트 코드 변경 없이(0줄 변경)** 보안 위험을 원천 차단하고 네트워크 안정성을 대폭 향상시킬 수 있습니다.
```
[MAM Orchestrator / Agents]
▼ (MQTT 3.1.1 / TLS)
[Private Dedicated Broker] ───> 사설망/개인 서버 (NATS Server / Mosquitto)
• 외부 불법 트래픽 100% 차단 (A-2 보안 해소)
• JetStream 영속성 및 NKey/JWT ACL 지원
• 초저지연 (<1ms) 및 무제한 대역폭
```
MAM의 제어 평면(`run_loop.sh``wait_for_job` 파일시스템 폴링)은 브로커와 100% 독립적으로 작동하므로, 브로커는 **비동기 관측(observability) 사이드카** 역할을 수행합니다.
---
## 2. 해결 영역 매트릭스 (브로커 전환 vs 코드 패치)
전용 브로커 구축으로 즉시 해결되는 영역과, 로컬 코드 패치(Track 0)가 병행되어야 하는 영역의 명확한 구분입니다.
| 구분 | 당면 과제 | 개인 브로커 구축 시 | 로컬 코드 패치 필요 여부 (Track 0) |
|---|---|:---:|:---:|
| **보안 (A-2)** | 공개 브로커 노출 및 외부 악의적 이벤트 수신 위협 | 🟢 **100% 즉시 해소** (사설망/ACL 격리) | Track 2에서 토큰 발급 강제 |
| **안정성** | 공개 브로커의 예고 없는 순단 및 속도 제한(Rate-limit) | 🟢 **100% 즉시 해소** (전용 리소스) | — |
| **내결함성 (B-14)** | 브로커 일시 장애 시 65분 루프 정지(Hang) 결함 | ⚠️ 브로커 점검/순단 시 여전히 위험 | 🔴 **필수 (Track 0 Step 1 선행 패치)** |
| **지연/오판 (B-15)** | 브로커 다운 시 120초 지연 및 정상 작업의 에러 오판정 | ⚠️ 브로커 점검/순단 시 여전히 위험 | 🔴 **필수 (Track 0 Step 2 & 3 선행 패치)** |
> [!IMPORTANT]
> **방탄 아키텍처 원칙:**
> "Track 0(`B-14`, `B-15`) 패치를 통해 브로커가 다운되어도 루프가 100% 정상 완주하도록 로컬 디스크 내결함성을 먼저 확보하고, 개인 브로커를 연결하여 A-2 보안과 성능을 완결합니다."
---
## 3. 전용 브로커 추천 및 비교
MAM 클라이언트는 표준 `paho-mqtt`를 사용하므로, MQTT 3.1.1을 지원하는 모든 브로커와 100% 호환됩니다.
| 비교 항목 | 🏆 `nats-server` (강력 권장) | `eclipse-mosquitto` (대안) |
|---|---|---|
| **아키텍처** | Go 단일 정적 바이너리 (Zero Dependency) | C 기반 경량 오픈소스 브로커 |
| **주요 특징** | • 내장 MQTT 3.1.1 리스너 (`mqtt { port: 1883 }`)<br>• JetStream 엔진 내장 (이벤트 영속화 및 복구)<br>• NKey/JWT 기반 계정 및 Subject별 세분화된 ACL<br>• WebSocket 및 NATS 네이티브 프로토콜 동시 서빙 | • 가장 널리 쓰이는 표준 경량 MQTT 브로커<br>• 낮은 메모리 점유율 (~10MB) |
| **추천 용도** | 모던 인프라, 확장성, 감사 로그 영속화, 홈랩 통합 | 정통 초경량 임베디드/단일 목적 환경 |
| **배포 난이도** | 🟢 바이너리 1개 실행 또는 Docker 1줄 | 🟢 패키지 매니저 (`apt`, `brew`) 또는 Docker |
---
## 4. 개인 서버 브로커 배포 가이드
### 4.1 `nats-server` 배포 (권장)
`nats-server`에서 MQTT를 활성화하려면 설정 파일(`nats.conf`)에 `mqtt { port: 1883 }` 블록과 `jetstream { }` 블록이 반드시 포함되어야 합니다.
> [!NOTE]
> `nats-server`의 `-m` 플래그는 HTTP 모니터링 포트(`--http_port`)를 지정하는 옵션이며, MQTT를 켜는 플래그가 아닙니다. MQTT 활성화는 반드시 `-c nats.conf` 설정 파일을 통해 구성해야 합니다.
#### 1) 공통 설정 파일 (`nats.conf`)
```conf
# nats.conf
server_name: mam-hub
# JetStream 영속 스토리지 (MQTT QoS 1 및 Retained 메시지 처리에 필수)
jetstream {
store_dir: "/data" # Docker 환경 기본 스토리지 (네이티브 실행 시 $HOME 전개)
max_file: 10G # 홈랩 디스크 상한 설정
}
# HTTP 모니터링 엔드포인트 (/varz, /jsz 대시보드)
http_port: 8222
# 평면 A: MAM MQTT 3.1.1 프로토콜 리스너
mqtt {
port: 1883
}
# 평면 B: 홈랩/웹 브라우저 대시보드용 WebSocket 리스너 (선택 사항)
websocket {
port: 8080
no_tls: true # 내부 사설망 한정
}
```
#### 2) 배포 방법 A. Docker / Docker Compose (권장)
**단일 Docker 실행:**
```bash
# 호스트에 nats.conf 생성 후 실행 (D-2: alpine 고정 핀, D-3: 루프백/바인드 한정)
docker run -d \
--name mam-nats \
--restart unless-stopped \
-p "${MQTT_BIND:-127.0.0.1}:1883:1883" \
-p "${NATS_BIND:-127.0.0.1}:4222:4222" \
-p "127.0.0.1:8222:8222" \
-p "${WS_BIND:-127.0.0.1}:8080:8080" \
-v ./nats.conf:/etc/nats/nats.conf:ro \
-v nats-data:/data \
nats:2.12-alpine \
-c /etc/nats/nats.conf
```
**Docker Compose (`docker-compose.yml`):**
```yaml
version: '3.8'
services:
nats:
image: nats:2.12-alpine
container_name: mam-nats
restart: unless-stopped
command: ["-c", "/etc/nats/nats.conf"]
ports:
- "${MQTT_BIND:-127.0.0.1}:1883:1883" # MQTT 3.1.1 포트 (평면 A: MAM)
- "${NATS_BIND:-127.0.0.1}:4222:4222" # NATS 기본 포트 (평면 B)
- "127.0.0.1:8222:8222" # HTTP 모니터링 (/varz, /jsz, /healthz)
- "${WS_BIND:-127.0.0.1}:8080:8080" # WebSocket (평면 B)
volumes:
- ./nats.conf:/etc/nats/nats.conf:ro
- nats-data:/data
volumes:
nats-data:
```
#### 3) 배포 방법 B. 네이티브 바이너리 설치 (macOS / Linux — 비루트 사용자 공간)
macOS의 sealed APFS 루트 볼륨(`/data`) 권한 문제를 방지하기 위해 사용자 홈 디렉터리(`~/.config/nats/`, `~/.local/share/nats/data`)를 기본 스토리지로 사용합니다.
```bash
# 설정 및 데이터 디렉터리 생성 (sudo 불필요)
mkdir -p ~/.config/nats ~/.local/share/nats/data
# 설정 파일 작성 (D-1: 비인용 heredoc <<EOF 으로 $HOME 을 파일 생성 시점에 절대경로로 고정)
cat <<EOF > ~/.config/nats/nats.conf
server_name: mam-hub
jetstream {
store_dir: "$HOME/.local/share/nats/data"
max_file: 10G
}
http_port: 8222
mqtt {
port: 1883
}
websocket {
port: 8080
no_tls: true
}
EOF
# macOS (Homebrew 설치 및 실행)
brew install nats-server
nats-server -c ~/.config/nats/nats.conf &
# Linux (x86_64 단일 바이너리 설치 및 실행)
curl -L https://github.com/nats-io/nats-server/releases/download/v2.10.20/nats-server-v2.10.20-linux-amd64.tar.gz | tar xz
sudo mv nats-server-v2.10.20-linux-amd64/nats-server /usr/local/bin/
nats-server -c ~/.config/nats/nats.conf &
```
---
### 4.2 `mosquitto` 배포 (대안)
#### Docker 실행:
```bash
docker run -d \
--name mam-mosquitto \
--restart unless-stopped \
-p 1883:1883 \
-v ./mosquitto.conf:/mosquitto/config/mosquitto.conf \
eclipse-mosquitto:latest
```
**기본 `mosquitto.conf` 설정 파일 예시:**
```conf
listener 1883
allow_anonymous true
persistence true
persistence_location /mosquitto/data/
```
---
## 5. 하나의 서버로 여러 프로젝트 — `nats-server` 다능성 (Versatility)
`nats-server`의 다능성은 **MAM을 네이티브 NATS로 이관할 이유가 아니라, MAM 코드를 한 줄도 바꾸지 않고도 얻을 수 있는 부가적 이득**입니다.
### 5.1 두 개의 소비 평면 및 세 가지 접속 경로 (Consumption Planes & Transport Paths)
`nats-server`는 단일 프로세스 내에서 여러 프로토콜 리스너를 동시에 구동하므로, MAM의 단순성과 개인 홈랩의 확장성을 완벽히 양립시킵니다.
```
┌───────────────────────────────────────────────────────────┐
│ nats-server (단일 인스턴스) │
├─────────────────────────────┬─────────────────────────────┤
│ 평면 A: MAM 워크로드 │ 평면 B: 홈랩/개인 프로젝트 │
├─────────────────────────────┼─────────────────────────────┤
프로토콜 │ MQTT 3.1.1 (포트 1883) │ NATS(4222), WebSocket(8080) │
클라이언트 │ paho-mqtt (코드 변경 0줄) │ nats-py, nats.js, MQTT.js 등│
사용 기능 │ QoS 1, Retain, 와일드카드, TLS│ JetStream 리플레이, KV, Object│
설계 원칙 │ 초경량 동기 CLI 핫패스 보존 │ 고급 비동기 이벤트 스트리밍 │
공유 자원 │ └───── 단일 정적 바이너리 / JetStream 스토리지 / ACL ─────┘│
└───────────────────────────────────────────────────────────┘
```
특히 **WebSocket 포트(8080)**는 접속 경로(URL Path)에 따라 두 가지 프로토콜을 동시에 서빙합니다:
1. **MQTT 1883**: TCP 네이티브 MQTT 3.1.1 (MAM CLI 에이전트 표준 경로)
2. **WebSocket 8080 (`/mqtt` 경로)**: **MQTT-over-WebSocket** (`MQTT.js` 등으로 접속). 서버 내부에서 1883 리스너와 동일하게 취급되어 **retained 종료 이벤트를 완전하게 수신**합니다 (N-7).
3. **WebSocket 8080 (`/` 또는 경로 없음)**: **NATS 네이티브 WebSocket** (`nats.ws` 등으로 접속). 라이브 스트림만 수신하며 retained 메시지는 수신하지 않습니다 (N-1).
### 5.2 교차 프로토콜 브리징 (Cross-Protocol Bridging)
- `nats-server`는 내부적으로 MQTT 토픽(`/`)을 NATS Subject(`.`)로 실시간 자동 변환합니다.
- MAM 에이전트가 MQTT 토픽 `python/mqtt/jobs/<job_id>/events`로 이벤트를 발행하면, 웹 브라우저나 타 프로젝트의 NATS 구독자는 NATS Subject `python.mqtt.jobs.<job_id>.events` 또는 `python.mqtt.jobs.*.events`로 즉시 실시간 수신할 수 있습니다.
- **실용적 이점**: MAM 소스 코드를 단 1줄도 수정하지 않고도 React/Vue 웹 대시보드나 터미널 모니터링 툴을 즉시 부착할 수 있습니다.
- **경계 (필수 인지 — N-1 & N-7)**:
- NATS 네이티브(경로 없는 WebSocket 또는 4222)로 접속하는 경우: 교차 프로토콜 브리징은 **라이브 스트리밍에 한정**됩니다. 잡이 끝난 뒤 접속한 네이티브 NATS 대시보드는 그 잡의 **종료 이벤트를 수신하지 못합니다** (retained 메시지는 MQTT SUBSCRIBE 경로 전용).
- 웹 대시보드에서 사후 종료 이벤트까지 무상으로 수신하려면 **`ws://<host>:8080/mqtt` 경로로 `MQTT.js` 클라이언트를 연결**하십시오 (N-7). 리플레이 스트림 구축 및 디스크 관리 부담 없이 완전한 종료 이벤트를 즉시 수신할 수 있습니다.
- **계정 배치**: 관측 클라이언트는 §5.5 처방대로 `MAM` 계정 안의 읽기 전용 사용자(`mam_observer`)로 접속해야 합니다. 다른 계정에서는 subject가 보이지 않습니다.
- **주의 사항**: 토픽 레벨 내에 마침표(`.`)가 포함되면 NATS 계층에서 토큰이 분리될 수 있으나, MAM의 `job_id`는 8자리 hex, 워크스페이스 지문은 12자리 hex이므로 안전합니다.
### 5.3 JetStream 이벤트 리플레이 (Event Replay)
- `python.mqtt.jobs.>` Subject를 구독하는 JetStream 스트림을 생성하면, 지난 작업의 이벤트 스트림 전체를 시점 지정(Time-based) 또는 시퀀스 지정(Sequence-based)으로 사후 리플레이할 수 있습니다.
- **주의 사항**: 이 기능은 옵트인(Opt-in)이며, MQTT QoS 1 처리를 위한 내부 시스템 스트림(`$MQTT_*`)과 별개로 관리됩니다. 디스크 용량 관리를 위해 `max_age``max_bytes` 상한을 반드시 설정해야 합니다.
### 5.4 내장 Key-Value (KV) 및 Object Store
- 홈랩 및 개인 프로젝트에서 Redis나 MinIO 같은 별도 인프라를 띄우지 않고도 `nats-server` 내장 KV 및 Object Store를 즉시 사용할 수 있습니다.
- **금지 사항 (Non-Goal)**: MAM의 로컬 레지스트리(`.mam/jobs/*.json`)를 JetStream KV로 대체해서는 안 됩니다 (`wait_for_job`의 fcntl 및 파일시스템 폴링 계약 유지).
### 5.5 멀티테넌트 계정 분리 및 보안
- 단일 서버 내에서 `MAM` 전용 계정과 `HOME` 개인 계정을 분리하여 리소스 쿼터와 권한을 완벽히 격리할 수 있습니다.
- **권고 배치**: MAM과 이를 관측하는 대시보드는 동일한 계정(`MAM`)에 배치하고(관측자는 읽기 전용 `mam_observer` 역할 부여), 무관한 홈랩 서비스는 별도 계정(`HOME`)에 배치합니다.
---
## 6. MAM 클라이언트 연동 설정 (`.mam.env`)
개인 서버 브로커가 구동되면, MAM 저장소 루트의 [`.mam.env`](file:///.mam.env) 파일에 개인 서버 주소를 등록합니다.
> [!NOTE]
> MAM 코드(`mqtt_common.py`)는 `MQTT_*` 접두사의 환경변수를 읽습니다. 이전 비공식 문서의 `MAM_MQTT_*` 변수는 무효하므로 반드시 아래의 표준 변수명을 사용해야 합니다.
```bash
# ==============================================================================
# MAM Private MQTT Broker Configuration (.mam.env)
# ==============================================================================
# ── 모델 T (Tailscale 사설 오버레이망 권장) ───────────────────────
MQTT_BROKER="mam-hub.tailXXXX.ts.net" # 또는 100.x.y.z (평문이므로 IP 사용 가능)
MQTT_PORT=1883
MQTT_TLS=0
MQTT_USERNAME=mam_agent
MQTT_PASSWORD=replace_me_with_token
MQTT_KEEPALIVE=60 # WAN 구간 권장 연결 유지 시간
# ── 모델 P (공개 TLS / Let's Encrypt 모델) ───────────────────────
# MQTT_BROKER="mam-broker.example.com" # D-4: 반드시 인증서 SAN 의 DNS 이름 (IP 금지)
# MQTT_PORT=8883
# MQTT_TLS=1
# MQTT_USERNAME=mam_agent
# MQTT_PASSWORD=replace_me_with_token
# MQTT_CA_CERTS 는 Let's Encrypt 사용 시 '설정하지 않음' (시스템 신뢰 저장소 사용)
```
*참고: OS 환경변수에 동일한 이름이 이미 `export`되어 있는 경우 OS 환경변수가 `.mam.env` 파일 설정보다 우선합니다.*
---
## 7. 연동 및 동작 검증 테스트 (4-Step Verification)
개인 서버 브로커와의 연동 상태를 정확하게 검증하는 4단계 절차입니다.
### Step 1. 브로커 리스너 및 JetStream 상태 확인
```bash
# MQTT 리스너 활성화 확인
curl -s http://127.0.0.1:8222/varz | grep -i mqtt
# JetStream 엔진 정상 구동 확인
curl -s http://127.0.0.1:8222/jsz
```
### Step 2. 임시 잡 등록 및 연결 검증 이벤트 발행
`publish_event.py`는 레지스트리에 등록된 잡에 대해서만 발행을 수행하므로, 임시 잡을 등록하고 발행한 후 완료 처리합니다.
```bash
# 1) 임시 잡 등록 (자동 채번된 JID 캡처)
JID=$(.venv/bin/python .agents/skills/multi-agent-mux-delegate-job/scripts/registry.py \
--registry-dir .mam/jobs \
register \
--prompt "Private broker connectivity test" \
--agent-session "herdr:test")
echo "registered test job: $JID"
# 2) 이벤트 발행 (상세 로그 출력 및 rc=0 단언)
.venv/bin/python .agents/skills/multi-agent-mux-delegate-job/scripts/publish_event.py \
--registry-dir .mam/jobs \
--job "$JID" \
--event progress \
--detail "Private broker connection verified" -v
# 3) 테스트 잡 종결 처리 (미종결 시 --wait-any 유령 잡 잔존 방지)
.venv/bin/python .agents/skills/multi-agent-mux-delegate-job/scripts/registry.py \
--registry-dir .mam/jobs status --job "$JID" --set completed
```
### Step 3. 접속 대상 브로커 IP 단언
Step 2의 `-v` 출력 로그 또는 `.mam/delegate_job_logs/$JID/events.ndjson` 파일에서 실제 접속 호스트가 개인 브로커 IP로 나타나고 `broker.hivemq.com`이 포함되지 않았는지 확인합니다.
### Step 4. 단위 회귀 테스트 검증
```bash
.venv/bin/python -m pytest tests/ -q
```
*참고: MAM의 기본 단위/컴포넌트 테스트 스위트는 모의(Mock) 객체를 사용하므로 브로커 연결 여부와 무관하게 100% 통과합니다. 실제 네트워크 연동 검증은 Step 1~3이 담당합니다.*
---
## 8. 권장 실행 순서
```
[Phase 1: 내결함성 확보] ──> [Phase 2: 개인 브로커 가동] ──> [Phase 3: A-2 보안 완전 종결]
Track 0 (B-14, B-15) nats-server (nats.conf) 지문 토픽 및 인증 토큰 발급
로컬 디스크 폴백 패치 .mam.env 환경변수 연동 외부 간섭 100% 차단
```
1. **Phase 1 (Track 0 선행 패치)**: `publish_event.py``job_subscriber.py`의 로컬 디스크 폴백(`B-14`, `B-15`)을 먼저 적용하여 브로커 다운 시에도 루프가 멈추지 않는 방탄 구조를 확립합니다.
2. **Phase 2 (개인 브로커 가동)**: 개인 서버에 `nats-server -c nats.conf`를 구동하고 `.mam.env``MQTT_BROKER`를 연결합니다.
3. **Phase 3 (A-2 보안 완전 종결)**: 워크스페이스 지문 토픽(`mam/<sha256[:12]>/jobs/...`) 및 무조건 토큰 발급을 적용하여 공개 브로커 위험을 완전히 영구 폐기합니다.
---
## 9. 원격 서버 `nats-server` Docker 프로덕션 배포 가이드 (Track 1R)
원격 VPS 또는 상시 가동 홈랩 서버에 프로덕션 수준의 `nats-server`를 Docker 기반으로 구축하고 MAM과 연동하는 표준 절차입니다.
> [!NOTE]
> **정본 자산 안내**: 본 절의 설정은 [`docker/`](docker/) 디렉터리에 정본(canonical) 파일(`docker/docker-compose.yaml`, `docker/nats.conf`, `docker/.env.example`, `docker/README.md`)로 관리되며, 아래 코드 펜스는 그 사본입니다. 두 곳이 어긋나면 `tests/test_deploy_freshness.py`의 D-22 ~ D-30 회귀 가드가 실패합니다.
### 9.1 프로덕션 `nats.conf`
```conf
# ==============================================================================
# docker/nats.conf — MAM 원격 프로덕션 브로커 (Track 1R)
#
# 정본 문서: PRIVATE_SERVER.md §9.1
# 시크릿: 이 파일에는 없습니다. 모든 password 는 docker/.env → compose
# environment → 컨테이너 환경변수로 주입되는 $VAR 참조입니다.
#
# ⚠ 암호 문자 제약: NATS 는 환경변수 값을 자체 설정 렉서로 재파싱합니다.
# 암호에 [공백 ; , ] } # ' " $] 가 들어가면 설정이 깨지거나 다르게 해석됩니다.
# 반드시 `openssl rand -base64 32` (알파벳 A-Za-z0-9+/=) 를 사용하십시오.
# ==============================================================================
server_name: mam-hub
# ── JetStream: MQTT retained/QoS1 저장소. 종료 이벤트 재수신이 여기에 의존 ──
jetstream {
store_dir: "/data" # 절대경로 고정. '~' 도 인용된 "$HOME" 도 확장되지 않음
max_file: 10G # 접미사는 대문자만 유효 (K/M/G/T)
max_mem: 256M
}
http_port: 8222 # 무인증 모니터링 → 호스트 게시는 loopback 한정 (compose)
mqtt {
port: 1883
ack_wait: 60s # WAN RTT 흡수 (기본 30s)
max_ack_pending: 1024 # 다중 에이전트 동시 발행 여유 (상한 65535)
}
websocket {
port: 8080
no_tls: true # 사설망/tailnet 한정. 생략하면 TLS 설정 필수라 기동 실패
# ── 원점(Origin) 정책 ────────────────────────────────────────────────
# 기본값은 이미 '모든 출처 허용'입니다. same_origin 의 기본값은 false 이고
# allowed_origins 가 비어 있으면 checkOrigin() 이 Origin 헤더를 읽지도 않고
# 즉시 nil 을 반환합니다 (server/websocket.go:1039).
# → http://localhost:3000 의 브라우저 대시보드는 별도 설정 없이 접속됩니다.
# → `same_origin: false` 를 적는 것은 no-op 입니다.
#
# 8080 을 tailnet 밖으로 노출한다면 아래를 켜서 출처를 좁히십시오.
# 주의: allowed_origins 를 비우지 않는 순간 원점 검사가 '켜집니다'.
# "*" 는 절대 쓰지 마십시오 — 옵션 검증 실패로 서버가 기동하지 못합니다
# (websocket.go:1142 "must be absolute URLs with http or https scheme").
# allowed_origins: ["https://dashboard.example", "http://localhost:3000"]
# ── 이 포트는 MQTT-over-WebSocket 도 서빙합니다 ───────────────────────
# 경로 /mqtt 로 붙으면 완전한 MQTT 클라이언트가 됩니다 (mqtt.go:193,
# websocket.go:1335 → createMQTTClient, 1883 리스너와 동일 함수).
# ws://<host>:8080/mqtt → MQTT. retained 종료 이벤트를 받습니다.
# ws://<host>:8080/ → NATS 네이티브. retained 를 받지 못합니다 (N-1).
# 브라우저 대시보드는 MQTT.js 로 /mqtt 에 붙이는 것을 권장합니다.
}
# ── 인증 및 멀티테넌시 ────────────────────────────────────────────────────
accounts {
MAM: {
jetstream: enabled # MQTT 내부 스트림이 이 계정 안에 생성됨
users: [
# 발행자 겸 구독자 — MAM 에이전트 본체 (.mam.env 의 MQTT_USERNAME)
{ user: mam_agent, password: $MAM_BROKER_PASS }
# 관측자 — 대시보드/모니터링. 반드시 MAM 계정 안에 위치
{ user: mam_observer, password: $MAM_OBSERVER_PASS,
permissions: {
subscribe: { allow: ["python.mqtt.jobs.>"] } # M3 이후: "mam.<fp>.jobs.>"
publish: { deny: [">"] }
}
}
]
}
# MAM 과 무관한 홈랩 서비스 전용. MAM subject 는 보이지 않음(의도된 격리)
HOME: { jetstream: enabled, users: [ { user: home, password: $HOME_BROKER_PASS } ] }
SYS: { users: [ { user: sys, password: $SYS_BROKER_PASS } ] }
}
system_account: SYS
```
> [!IMPORTANT]
> **관측자는 반드시 `MAM` 계정 안에 둡니다.** NATS 계정은 하드 격리 경계이므로 `user: home`(계정 `HOME`)으로 접속한 클라이언트는 `python.mqtt.jobs.>`를 구독해도 **0건**을 받습니다. 서로 다른 신뢰 도메인이라 계정을 반드시 갈라야 하는 예외 상황은 **부록 X(Option A)** 를 따르십시오.
### 9.2 프로덕션 `docker/docker-compose.yaml`
```yaml
# ==============================================================================
# docker/docker-compose.yaml — MAM 원격 프로덕션 브로커 (Track 1R)
# 정본 문서: PRIVATE_SERVER.md §9.2
#
# 사용법: cd docker && cp .env.example .env && <시크릿 채우기> && docker compose up -d
# ==============================================================================
services:
nats:
image: nats:2.12-alpine # alpine 필수: healthcheck 의 wget 이 여기에만 있음
container_name: mam-nats
restart: unless-stopped
command: ["-c", "/etc/nats/nats.conf"]
environment:
# 미설정/빈 값이면 컨테이너 생성 전에 compose 가 중단 → fail-closed
MAM_BROKER_PASS: ${MAM_BROKER_PASS:?set MAM_BROKER_PASS in docker/.env}
MAM_OBSERVER_PASS: ${MAM_OBSERVER_PASS:?set MAM_OBSERVER_PASS in docker/.env}
HOME_BROKER_PASS: ${HOME_BROKER_PASS:?set HOME_BROKER_PASS in docker/.env}
SYS_BROKER_PASS: ${SYS_BROKER_PASS:?set SYS_BROKER_PASS in docker/.env}
ports:
# ⚠ Docker 의 published 포트는 UFW 를 우회합니다. 노출 통제는 방화벽이 아니라
# 여기의 바인드 주소가 담당합니다. 기본값은 전부 loopback.
- "${MQTT_BIND:-127.0.0.1}:1883:1883" # MQTT 3.1.1 (평면 A: MAM)
- "${NATS_BIND:-127.0.0.1}:4222:4222" # NATS 네이티브 (평면 B)
- "127.0.0.1:8222:8222" # 무인증 모니터링 — loopback 고정
- "${WS_BIND:-127.0.0.1}:8080:8080" # WebSocket (NATS + /mqtt 경로의 MQTT)
volumes:
- ./nats.conf:/etc/nats/nats.conf:ro
- nats-data:/data # nats.conf 의 store_dir 와 일치
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8222/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
logging:
driver: json-file
options: { max-size: "10m", max-file: "3" }
volumes:
nats-data:
```
> [!WARNING]
> **Docker의 published 포트는 UFW를 우회합니다.** Docker가 삽입하는 NAT/FORWARD 규칙이 `ufw`의 INPUT 체인보다 먼저 평가되므로, `ufw deny 1883`을 걸어도 `-p 1883:1883`으로 게시한 포트는 인터넷에 열립니다. 본 구성이 방화벽에만 의존하지 않고 **published 포트 자체에 바인드 주소를 명시**(`${MQTT_BIND}:1883:1883`)하는 이유입니다.
### 9.3 원격 네트워킹 & 보안 가이드
| 항목 | 🏆 **모델 T: Tailscale/WireGuard 오버레이 (권장)** | 모델 P: 공개 TLS (Let's Encrypt) |
|---|---|---|
| 인터넷 노출 면적 | **0** (공개 리스너 없음) | 8883 1개 |
| 인증서 필요 | 불필요 (`MQTT_TLS=0`) | 필수 + 90일 갱신 |
| **D-4 호스트명 제약** | **해당 없음** | 도메인 필수, IP 불가 |
| 도메인 필요 | 불필요 | **필수** |
| 이동성 | 자동 (Tailscale mesh) | 자동 |
**방화벽 (UFW) 설정 (모델 T):**
```bash
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
# tailnet 인터페이스만 허용
sudo ufw allow in on tailscale0 to any port 1883 proto tcp
sudo ufw allow in on tailscale0 to any port 4222 proto tcp
sudo ufw allow in on tailscale0 to any port 8080 proto tcp
sudo ufw enable && sudo ufw status verbose
```
**시크릿 생성 및 환경변수 주입 (`docker/.env`):**
```bash
# docker/.env.example 복사 및 권한 제한
cd docker && cp .env.example .env && chmod 600 .env
# 시크릿 암호 생성 (openssl rand -base64 32 사용)
# .env 파일을 열고 생성된 시크릿 및 바인드 주소 입력:
# MAM_BROKER_PASS=<생성된_토큰>
# MAM_OBSERVER_PASS=<생성된_토큰>
# HOME_BROKER_PASS=<생성된_토큰>
# SYS_BROKER_PASS=<생성된_토큰>
# MQTT_BIND=$(tailscale ip -4)
# WS_BIND=$(tailscale ip -4)
```
### 9.4 원격 검증 플레이북 (R-1 ~ R-10)
| ID | 검증 항목 | 방법 | 통과 기준 |
|---|---|---|---|
| **R-1** | 브로커 헬스 | SSH 터널 후 `curl -sf http://127.0.0.1:8222/healthz` | **HTTP 200** |
| **R-2** | 리스너 + TLS 신원 | `curl -s .../varz \| grep -i mqtt`; 모델 P는 `openssl s_client -connect H:8883 -servername H` | MQTT 리스너 노출, SAN에 `MQTT_BROKER` 포함 |
| **R-3** | 노출 면적 단언 | 외부 망에서 `nmap -Pn -p 1883,4222,8222,8080 <공개IP>` | 전부 closed/filtered |
| **R-4** | 왕복 pub/sub + 계정 JetStream | 임시 잡 등록 → 구독자 기동 → `progress` 발행 → 수신 → 종결 | 수신 성공, `JetStream not enabled for account` **미발생** |
| **R-5** | **retained 종료 이벤트 (MQTT)** | `--event completed` 발행 **후** 신규 `job_subscriber.py` 기동 | 즉시 최종 이벤트 수신 |
| **R-6** | 브로커 신원 단언 | `grep -c 'broker.hivemq.com' .mam/delegate_job_logs/$JID/events.ndjson` | **0**, 원격 호스트 등장 |
| **R-7** | freeze 회귀 (H-1/H-4) | freeze 경로 `publish_event.py``MAM_ENV_FILE` 없이 실행 / 오타 경로 실행 | 공개 브로커로 나가지 않음 |
| **R-8** | 전체 회귀 스위트 | `.venv/bin/python -m pytest tests/ -q` | **전건 통과** |
| **R-9** | **관측자 계정 경계** | `mam_observer`로 NATS 구독 → 수신 확인. 이어서 `home`으로 동일 구독 | `mam_observer` **수신**, `home` **0건** |
| **R-10** | **retained 경계 확인 (N-1)** | 잡 종료 **후** NATS 네이티브 구독자를 새로 붙임 | **0건 수신** (retained는 MQTT 전용) |
**WAN 지연 시간 측정 (Latency Probe):**
```bash
.venv/bin/python - <<'PY'
import sys, time, statistics
sys.path.insert(0, '.agents/skills/multi-agent-mux-delegate-job/scripts')
import mqtt_common as m
cfg = m.broker_config_from_env()
print(f"target: {cfg.host}:{cfg.port} tls={cfg.tls}")
conn, rtt = [], []
for _ in range(5):
c = m.make_client("latency", cfg)
t0 = time.perf_counter(); c.connect(cfg.host, cfg.port, 10); c.loop_start()
conn.append((time.perf_counter() - t0) * 1000)
t1 = time.perf_counter()
info = c.publish("mam/latency/probe", b"x", qos=1); info.wait_for_publish(10)
rtt.append((time.perf_counter() - t1) * 1000)
c.loop_stop(); c.disconnect()
print(f"connect p50={statistics.median(conn):.1f}ms max={max(conn):.1f}ms")
print(f"qos1 rtt p50={statistics.median(rtt):.1f}ms max={max(rtt):.1f}ms")
PY
```
### 9.5 전환(Cutover) 절차 (H-2 대응)
```
[1 드레인] ──> [2 잔여 스캔] ──> [3 .mam.env 교체] ──> [4 R-1~R-10] ──> [5 레거시 차단]
```
1. **드레인**: 신규 위임 중단, 진행 중 잡이 모두 terminal 될 때까지 대기.
2. **잔여 스캔**: 옛 브로커에 핀 고정된 레코드 스캔 후 정리.
3. **`.mam.env` 교체**: 원격 브로커 주소 및 토큰 적용.
4. **검증**: R-1 ~ R-10 전건 통과 확인.
5. **레거시 차단**: 공개 브로커 설정 완전 제거.
---
### 부록 X. Option A (계정 간 Export / Import) 예외 경로
관측자가 **서로 다른 신뢰 도메인**에 속해 계정을 엄격히 분리해야 하는 경우:
```conf
accounts {
MAM: {
jetstream: enabled
users: [ { user: mam_agent, password: $MAM_BROKER_PASS } ]
exports: [ { stream: "python.mqtt.jobs.>", accounts: [HOME] } ]
}
HOME: {
users: [ { user: home, password: $HOME_BROKER_PASS } ]
imports: [ { stream: { account: MAM, subject: "python.mqtt.jobs.>" } } ]
}
SYS: { users: [ { user: sys, password: $SYS_BROKER_PASS } ] }
}
system_account: SYS
```
+25 -11
View File
@@ -266,6 +266,20 @@ def test_d10_customization_survives_repeated_refresh(src_and_target):
"user gets no signal that their edit is diverging" % n)
def _resolve_private_server_doc() -> str:
for candidate in [
os.path.join(REPO_ROOT, "nats-docker", "PRIVATE_SERVER.md"),
os.path.join(REPO_ROOT, "nats-docker", "docs", "PRIVATE_SERVER.md"),
os.path.join(REPO_ROOT, "PRIVATE_SERVER.md"),
]:
if os.path.exists(candidate):
return candidate
return os.path.join(REPO_ROOT, "nats-docker", "PRIVATE_SERVER.md")
PRIVATE_SERVER_DOC_PATH = _resolve_private_server_doc()
# --------------------------------------------------------------------------
# D-11 — (G-D1) PRIVATE_SERVER.md must only document MQTT_* environment
# variables that broker_config_from_env() actually parses.
@@ -274,8 +288,8 @@ def test_d11_private_server_env_names_valid():
sys.path.insert(0, os.path.join(REPO_ROOT, ".agents", "skills", "multi-agent-mux-delegate-job", "scripts"))
import mqtt_common
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
assert os.path.exists(doc_path), "PRIVATE_SERVER.md missing"
doc_path = PRIVATE_SERVER_DOC_PATH
assert os.path.exists(doc_path), f"PRIVATE_SERVER.md missing at {doc_path}"
with open(doc_path, "r", encoding="utf-8") as f:
content = f.read()
@@ -301,7 +315,7 @@ def test_d11_private_server_env_names_valid():
# active configuration code blocks.
# --------------------------------------------------------------------------
def test_d12_private_server_no_mam_mqtt_in_code_fences():
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
doc_path = PRIVATE_SERVER_DOC_PATH
with open(doc_path, "r", encoding="utf-8") as f:
content = f.read()
@@ -317,7 +331,7 @@ def test_d12_private_server_no_mam_mqtt_in_code_fences():
# use valid config blocks (mqtt {) and not HTTP port flag (-m 1883).
# --------------------------------------------------------------------------
def test_d13_private_server_nats_config_valid():
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
doc_path = PRIVATE_SERVER_DOC_PATH
with open(doc_path, "r", encoding="utf-8") as f:
content = f.read()
@@ -333,7 +347,7 @@ def test_d13_private_server_nats_config_valid():
# CLI flags matching the actual scripts' argparse parsers.
# --------------------------------------------------------------------------
def test_d14_private_server_cli_args_valid():
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
doc_path = PRIVATE_SERVER_DOC_PATH
with open(doc_path, "r", encoding="utf-8") as f:
content = f.read()
@@ -353,7 +367,7 @@ def test_d14_private_server_cli_args_valid():
# and heredocs writing it must be unquoted (<<EOF).
# --------------------------------------------------------------------------
def test_d15_private_server_store_dir_valid():
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
doc_path = PRIVATE_SERVER_DOC_PATH
with open(doc_path, "r", encoding="utf-8") as f:
content = f.read()
@@ -374,7 +388,7 @@ def test_d15_private_server_store_dir_valid():
# D-16 — (G-D6) nats image references in code fences must use pinned alpine
# --------------------------------------------------------------------------
def test_d16_private_server_nats_image_alpine_pinned():
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
doc_path = PRIVATE_SERVER_DOC_PATH
with open(doc_path, "r", encoding="utf-8") as f:
content = f.read()
@@ -390,7 +404,7 @@ def test_d16_private_server_nats_image_alpine_pinned():
# D-17 — (G-D7) Port 8222 in docker examples must be bound to 127.0.0.1
# --------------------------------------------------------------------------
def test_d17_private_server_monitoring_port_localhost_bound():
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
doc_path = PRIVATE_SERVER_DOC_PATH
with open(doc_path, "r", encoding="utf-8") as f:
content = f.read()
@@ -407,7 +421,7 @@ def test_d17_private_server_monitoring_port_localhost_bound():
# D-18 — (G-D8) TLS examples must not use IP literals for MQTT_BROKER
# --------------------------------------------------------------------------
def test_d18_private_server_tls_examples_use_domain_names():
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
doc_path = PRIVATE_SERVER_DOC_PATH
with open(doc_path, "r", encoding="utf-8") as f:
content = f.read()
@@ -423,7 +437,7 @@ def test_d18_private_server_tls_examples_use_domain_names():
# D-19 — (G-D9) Subject literals in config examples match DEFAULT_TOPIC_ROOT
# --------------------------------------------------------------------------
def test_d19_private_server_subject_literals_match_default_topic_root():
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
doc_path = PRIVATE_SERVER_DOC_PATH
with open(doc_path, "r", encoding="utf-8") as f:
content = f.read()
@@ -526,7 +540,7 @@ def test_d23_compose_image_matches_doc_and_is_alpine():
assert compose_tag != "latest", "Compose image tag must not be 'latest'"
assert "alpine" in compose_tag, f"Compose image tag '{compose_tag}' must use alpine variant"
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
doc_path = PRIVATE_SERVER_DOC_PATH
with open(doc_path, "r", encoding="utf-8") as f:
doc_content = f.read()
doc_tags = re.findall(r'\bnats:([a-zA-Z0-9_.-]+)', doc_content)