16 task, 5 phase. NAS insta-lab의 Playwright Chromium 100% Windows로 이전. Phase 1 (NAS 수신부): verify_internal_key + /api/internal/insta/update + main.py에 redis client + docker-compose env (Task 1-4) Phase 2 (Windows worker 신설): web-ai/services/insta-render Docker 컨테이너 (Dockerfile, requirements, card_renderer, worker, main, tests) (Task 5-10) Phase 3 (NAS cutover): _bg_render·_bg_create_slate를 Redis push로 + card_renderer.py stub + Dockerfile 슬림화 (Task 11-13) Phase 4 (nginx 3-layer 차단): /api/internal/* IP 화이트리스트 (Task 14) Phase 5 (end-to-end 검증): 폴링 + PNG 생성 확인 (Task 15-16) NAS Redis + WSL2 Docker + SMB mount (Plan-B-Base) prerequisite 완료. 다음 plan은 Plan-B-Music (Suno+MusicGen), Plan-B-Video (외부 API gateway). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1888 lines
62 KiB
Markdown
1888 lines
62 KiB
Markdown
# Plan-B-Insta — NAS insta-lab 분할 + Windows insta-render Worker Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** NAS insta-lab의 Playwright Chromium 렌더링을 Windows AI 머신(WSL2 Docker)으로 완전 이전. NAS는 게이트(메타·DB·Redis push)만 담당, Windows worker가 Chromium pool + 10장 PNG 렌더 + NAS SMB 볼륨에 저장 + webhook으로 NAS DB 업데이트.
|
|
|
|
**Architecture:** NAS gateway → Redis RPUSH `queue:insta-render` → Windows BLPOP → Browser pool reuse + Semaphore(1) → SMB direct write to `/mnt/nas/webpage/data/insta/{slate_id}/01.png~10.png` → HTTP POST `/api/internal/insta/update` (X-Internal-Key 인증, 3-layer 차단). 사용자는 폴링 (`GET /api/insta/tasks/{task_id}`).
|
|
|
|
**Tech Stack:** Python 3.12 / FastAPI / Playwright async / Jinja2 / `redis>=5.0` (async) / `httpx` (webhook) / Docker Engine in WSL2 Ubuntu 24.04 / cifs SMB to NAS.
|
|
|
|
**Spec:** `web-backend/docs/superpowers/specs/2026-05-18-nas-windows-distributed-architecture-design.md` §4 SP-3·SP-4, §5 Windows Render Worker 패턴, §6 Redis 큐, §8 internal webhook + auth, §10 SP-3/SP-4 상세
|
|
|
|
**Prerequisites (✅ 모두 완료):**
|
|
- Plan-A: web-ai/NAS 캐시 강화
|
|
- Plan-B-Base: NAS Redis 컨테이너 + Windows WSL2/Docker/SMB
|
|
- web-ai 리네임: signal_v2 → ai_trade + legacy/
|
|
|
|
---
|
|
|
|
## Phase 구조
|
|
|
|
| Phase | 내용 | Task |
|
|
|-------|------|------|
|
|
| **1. NAS gateway 측 준비** (수신부 먼저) | webhook endpoint + X-Internal-Key 인증 + Redis 통신 환경 | 1~4 |
|
|
| **2. Windows worker 신설** | services/insta-render 컨테이너 + Chromium pool + BLPOP worker | 5~10 |
|
|
| **3. NAS insta-lab 분할 (cutover)** | Playwright 제거 + Redis push로 전환 | 11~13 |
|
|
| **4. nginx 3-layer 차단** | /api/internal/* IP 화이트리스트 | 14 |
|
|
| **5. 통합 검증** | end-to-end 렌더 한 번 + 폴링 확인 | 15~16 |
|
|
|
|
**중요 순서:** Phase 1·2 먼저 (수신부 + worker 준비) → Phase 3 (전환) → Phase 4·5 (보안 + 검증). Phase 3을 먼저 하면 큐에 쌓이고 worker 미존재로 처리 안 됨.
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
### Phase 1·3·4 — NAS web-backend
|
|
|
|
| 파일 | 변경 | 책임 |
|
|
|------|------|------|
|
|
| `web-backend/insta-lab/app/auth.py` (Create) | `verify_internal_key` dependency | X-Internal-Key 검증 |
|
|
| `web-backend/insta-lab/app/internal_router.py` (Create) | `POST /api/internal/insta/update` | Windows webhook 수신 |
|
|
| `web-backend/insta-lab/app/main.py` | redis client 추가 + `_bg_create_slate`/`_bg_render` 분할 + router include | Redis push 전환 |
|
|
| `web-backend/insta-lab/app/card_renderer.py` | Playwright 코드 전체 제거 (1줄 stub만 유지) | 더 이상 NAS에서 렌더 안 함 |
|
|
| `web-backend/insta-lab/requirements.txt` | playwright 제거, `redis>=5.0` 추가 | 의존성 |
|
|
| `web-backend/insta-lab/Dockerfile` | Chromium runtime dep 라인 + `playwright install` 제거 | image 절반 이하 |
|
|
| `web-backend/insta-lab/.env` (예시) | `REDIS_URL`, `INTERNAL_API_KEY` 추가 | 환경 |
|
|
| `web-backend/docker-compose.yml` | insta-lab service에 REDIS_URL, INTERNAL_API_KEY env 추가 + depends_on redis | compose |
|
|
| `web-backend/nginx/default.conf` | `location /api/internal/` IP allow + deny all | 3-layer 차단 |
|
|
|
|
### Phase 2 — Windows web-ai/services/
|
|
|
|
| 파일 | 변경 | 책임 |
|
|
|------|------|------|
|
|
| `web-ai/services/docker-compose.yml` (Create) | insta-render service + network | compose |
|
|
| `web-ai/services/insta-render/Dockerfile` (Create) | python:3.12-slim + Chromium deps + playwright install | image |
|
|
| `web-ai/services/insta-render/requirements.txt` (Create) | fastapi, uvicorn, playwright, redis, httpx, jinja2, pillow | deps |
|
|
| `web-ai/services/insta-render/main.py` (Create) | FastAPI app + lifespan (Browser pool init + worker task) | entry |
|
|
| `web-ai/services/insta-render/worker.py` (Create) | Redis BLPOP 루프 + queue:paused 체크 + webhook 호출 | dispatcher |
|
|
| `web-ai/services/insta-render/card_renderer.py` (Create) | NAS card_renderer.py 이식 + Browser pool reuse | renderer |
|
|
| `web-ai/services/insta-render/templates/` (Copy) | NAS insta-lab/app/templates/ 복사 (`default/card.html.j2` + `hedgy75/...` 등) | Jinja |
|
|
| `web-ai/services/insta-render/tests/test_worker.py` (Create) | mocked Redis + 가짜 webhook 검증 | TDD |
|
|
| `web-ai/services/insta-render/.env.example` (Create) | `REDIS_URL`, `INTERNAL_API_KEY`, `NAS_BASE_URL` 플레이스홀더 | secrets |
|
|
|
|
---
|
|
|
|
## Task 1: NAS insta-lab — `verify_internal_key` dependency
|
|
|
|
**Files:**
|
|
- Create: `C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/app/auth.py`
|
|
- Test: `C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/tests/test_auth.py`
|
|
|
|
- [ ] **Step 1: 실패하는 테스트 작성**
|
|
|
|
`C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/tests/test_auth.py`:
|
|
|
|
```python
|
|
"""verify_internal_key dependency — Windows webhook 인증."""
|
|
import os
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from app.auth import verify_internal_key
|
|
|
|
|
|
def test_valid_key_passes(monkeypatch):
|
|
monkeypatch.setenv("INTERNAL_API_KEY", "secret123")
|
|
# dependency가 raise 안 하면 통과
|
|
verify_internal_key(x_internal_key="secret123")
|
|
|
|
|
|
def test_invalid_key_raises_401(monkeypatch):
|
|
monkeypatch.setenv("INTERNAL_API_KEY", "secret123")
|
|
with pytest.raises(HTTPException) as exc:
|
|
verify_internal_key(x_internal_key="wrong")
|
|
assert exc.value.status_code == 401
|
|
|
|
|
|
def test_missing_env_key_raises_401(monkeypatch):
|
|
monkeypatch.delenv("INTERNAL_API_KEY", raising=False)
|
|
with pytest.raises(HTTPException) as exc:
|
|
verify_internal_key(x_internal_key="any")
|
|
assert exc.value.status_code == 401
|
|
```
|
|
|
|
- [ ] **Step 2: 테스트 실패 확인**
|
|
|
|
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab && python -m pytest tests/test_auth.py -v`
|
|
Expected: FAIL — `app.auth` 모듈 미존재.
|
|
|
|
- [ ] **Step 3: `auth.py` 작성**
|
|
|
|
`C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/app/auth.py`:
|
|
|
|
```python
|
|
"""SP-4 — Windows worker → NAS internal webhook 인증.
|
|
|
|
X-Internal-Key 헤더를 .env의 INTERNAL_API_KEY와 비교.
|
|
서버 측 키 미설정 시 401 (안전한 기본값).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from fastapi import Header, HTTPException
|
|
|
|
|
|
def verify_internal_key(x_internal_key: str = Header(...)):
|
|
expected = os.getenv("INTERNAL_API_KEY")
|
|
if not expected:
|
|
raise HTTPException(401, "INTERNAL_API_KEY not configured on server")
|
|
if x_internal_key != expected:
|
|
raise HTTPException(401, "Invalid X-Internal-Key")
|
|
```
|
|
|
|
- [ ] **Step 4: 테스트 통과**
|
|
|
|
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab && python -m pytest tests/test_auth.py -v`
|
|
Expected: 3 PASS.
|
|
|
|
- [ ] **Step 5: 커밋**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
|
git add insta-lab/app/auth.py insta-lab/tests/test_auth.py
|
|
git commit -m "$(cat <<'EOF'
|
|
feat(insta-lab): verify_internal_key auth for Windows webhook (SP-4)
|
|
|
|
X-Internal-Key 헤더 검증 dependency. .env의 INTERNAL_API_KEY와 비교.
|
|
미설정 시 401 (fail-safe). Plan-B-Insta Phase 1.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: NAS insta-lab — `/api/internal/insta/update` endpoint
|
|
|
|
**Files:**
|
|
- Create: `C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/app/internal_router.py`
|
|
- Test: `C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/tests/test_internal_router.py`
|
|
|
|
- [ ] **Step 1: 실패하는 테스트 작성**
|
|
|
|
`C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/tests/test_internal_router.py`:
|
|
|
|
```python
|
|
"""POST /api/internal/insta/update — Windows worker webhook."""
|
|
import os
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
from app.internal_router import router
|
|
from app import db
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _set_key(monkeypatch):
|
|
monkeypatch.setenv("INTERNAL_API_KEY", "test-secret")
|
|
|
|
|
|
@pytest.fixture
|
|
def client(tmp_path, monkeypatch):
|
|
# SQLite in-memory test
|
|
monkeypatch.setenv("INSTA_DATA_PATH", str(tmp_path))
|
|
db.init_db()
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
return TestClient(app)
|
|
|
|
|
|
def _make_task():
|
|
return db.create_task("slate_render", {"slate_id": 42})
|
|
|
|
|
|
def test_update_with_valid_key_updates_db(client):
|
|
tid = _make_task()
|
|
r = client.post(
|
|
"/api/internal/insta/update",
|
|
headers={"X-Internal-Key": "test-secret"},
|
|
json={"task_id": tid, "status": "processing", "progress": 30},
|
|
)
|
|
assert r.status_code == 200
|
|
task = db.get_task(tid)
|
|
assert task["status"] == "processing"
|
|
assert task["progress"] == 30
|
|
|
|
|
|
def test_update_with_invalid_key_returns_401(client):
|
|
tid = _make_task()
|
|
r = client.post(
|
|
"/api/internal/insta/update",
|
|
headers={"X-Internal-Key": "wrong"},
|
|
json={"task_id": tid, "status": "processing", "progress": 30},
|
|
)
|
|
assert r.status_code == 401
|
|
|
|
|
|
def test_update_succeeded_sets_result_path(client):
|
|
tid = _make_task()
|
|
r = client.post(
|
|
"/api/internal/insta/update",
|
|
headers={"X-Internal-Key": "test-secret"},
|
|
json={
|
|
"task_id": tid,
|
|
"status": "succeeded",
|
|
"progress": 100,
|
|
"result_path": "/media/insta/42/01.png",
|
|
},
|
|
)
|
|
assert r.status_code == 200
|
|
task = db.get_task(tid)
|
|
assert task["status"] == "succeeded"
|
|
assert task["result_id"] is not None # slate_id from input_data
|
|
|
|
|
|
def test_update_failed_records_error(client):
|
|
tid = _make_task()
|
|
r = client.post(
|
|
"/api/internal/insta/update",
|
|
headers={"X-Internal-Key": "test-secret"},
|
|
json={"task_id": tid, "status": "failed", "progress": 0, "error": "Chromium crashed"},
|
|
)
|
|
assert r.status_code == 200
|
|
task = db.get_task(tid)
|
|
assert task["status"] == "failed"
|
|
assert "Chromium" in (task.get("error") or "")
|
|
```
|
|
|
|
- [ ] **Step 2: 테스트 실패 확인**
|
|
|
|
Run: `python -m pytest insta-lab/tests/test_internal_router.py -v`
|
|
Expected: FAIL — `app.internal_router` 미존재.
|
|
|
|
- [ ] **Step 3: `internal_router.py` 작성**
|
|
|
|
`C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/app/internal_router.py`:
|
|
|
|
```python
|
|
"""SP-4 — Windows insta-render → NAS internal webhook.
|
|
|
|
POST /api/internal/insta/update
|
|
- X-Internal-Key 인증 필수
|
|
- task DB row update (status, progress, result_path, error)
|
|
- result_path는 nginx 서빙 경로 (예: /media/insta/{slate_id}/01.png)
|
|
- succeeded 시 input_data에서 slate_id 추출 → result_id 세팅
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel, Field
|
|
|
|
from . import db
|
|
from .auth import verify_internal_key
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter()
|
|
|
|
|
|
class UpdatePayload(BaseModel):
|
|
task_id: str
|
|
status: str = Field(..., description="processing|succeeded|failed")
|
|
progress: int = Field(..., ge=0, le=100)
|
|
result_path: Optional[str] = None
|
|
error: Optional[str] = None
|
|
|
|
|
|
@router.post(
|
|
"/api/internal/insta/update",
|
|
dependencies=[Depends(verify_internal_key)],
|
|
)
|
|
def insta_update(payload: UpdatePayload):
|
|
task = db.get_task(payload.task_id)
|
|
if task is None:
|
|
raise HTTPException(404, f"task not found: {payload.task_id}")
|
|
|
|
result_id = None
|
|
if payload.status == "succeeded":
|
|
try:
|
|
input_data = json.loads(task.get("input_data") or "{}")
|
|
result_id = input_data.get("slate_id")
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
db.update_task(
|
|
payload.task_id,
|
|
payload.status,
|
|
payload.progress,
|
|
detail=payload.result_path or "",
|
|
result_id=result_id,
|
|
error=payload.error,
|
|
)
|
|
logger.info(
|
|
"internal/insta/update task=%s status=%s progress=%d",
|
|
payload.task_id, payload.status, payload.progress,
|
|
)
|
|
return {"ok": True}
|
|
```
|
|
|
|
- [ ] **Step 4: 테스트 통과**
|
|
|
|
Run: `python -m pytest insta-lab/tests/test_internal_router.py -v`
|
|
Expected: 4 PASS.
|
|
|
|
> **참고:** `db.update_task` signature는 기존 main.py 호출(`db.update_task(task_id, status, progress, detail, result_id=, error=)`)에서 확인 가능. `error` 파라미터를 키워드로 받아야 함. 만약 기존 시그니처가 `error`를 지원 안 하면 다음 Task에서 추가:
|
|
|
|
```python
|
|
# db.py 확장 (필요 시)
|
|
def update_task(task_id, status, progress, detail="", result_id=None, error=None):
|
|
...
|
|
```
|
|
|
|
- [ ] **Step 5: 커밋**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
|
git add insta-lab/app/internal_router.py insta-lab/tests/test_internal_router.py
|
|
git commit -m "$(cat <<'EOF'
|
|
feat(insta-lab): internal webhook /api/internal/insta/update (SP-4)
|
|
|
|
Windows insta-render worker가 작업 진행률·완료·실패를 보고할 수신부.
|
|
X-Internal-Key 인증 필수. 4건의 단위 테스트로 status·error·result_path 검증.
|
|
|
|
Plan-B-Insta Phase 1.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: NAS insta-lab — main.py에 internal_router include + Redis client 준비
|
|
|
|
**Files:**
|
|
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/app/main.py`
|
|
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/requirements.txt`
|
|
|
|
- [ ] **Step 1: requirements.txt에 redis 추가**
|
|
|
|
`C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/requirements.txt` — `playwright==1.48.0` 라인은 Task 11에서 제거. 지금은 redis만 추가:
|
|
|
|
기존 마지막 줄 다음에 추가:
|
|
```
|
|
redis>=5.0
|
|
```
|
|
|
|
- [ ] **Step 2: main.py 상단 import 추가**
|
|
|
|
`C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/app/main.py` 상단 import 블록에 추가 (다른 `from .` 들과 같은 위치):
|
|
|
|
```python
|
|
import redis.asyncio as aioredis
|
|
from .internal_router import router as internal_router
|
|
```
|
|
|
|
- [ ] **Step 3: app.include_router(internal_router)** 호출 추가
|
|
|
|
`app = FastAPI(...)` 정의 직후 (보통 line 25 근처) include 추가:
|
|
|
|
```python
|
|
app.include_router(internal_router)
|
|
```
|
|
|
|
- [ ] **Step 4: 모듈 레벨 redis client 생성**
|
|
|
|
`app = FastAPI(...)` 직전 또는 직후에:
|
|
|
|
```python
|
|
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379")
|
|
redis_client = aioredis.from_url(REDIS_URL, decode_responses=False)
|
|
```
|
|
|
|
- [ ] **Step 5: 빠른 import sanity 체크**
|
|
|
|
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab && python -c "from app import main; print('OK')"`
|
|
|
|
만약 `ModuleNotFoundError: No module named 'redis'`는 로컬에 redis 미설치 (정상). 운영 image에선 requirements.txt로 install. CI/로컬 검증 위해선 한 번:
|
|
```bash
|
|
pip install redis>=5.0
|
|
```
|
|
|
|
- [ ] **Step 6: 커밋**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
|
git add insta-lab/app/main.py insta-lab/requirements.txt
|
|
git commit -m "$(cat <<'EOF'
|
|
feat(insta-lab): wire internal_router + Redis client (SP-4 prep)
|
|
|
|
main.py에 internal_router include + 모듈 레벨 redis client.
|
|
requirements.txt에 redis>=5.0 추가 (playwright 제거는 Task 11에서).
|
|
|
|
Plan-B-Insta Phase 1 마무리. Task 11에서 _bg_render를 Redis push로 전환.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: NAS docker-compose에 REDIS_URL · INTERNAL_API_KEY env 주입
|
|
|
|
**Files:**
|
|
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/docker-compose.yml`
|
|
|
|
- [ ] **Step 1: insta-lab service의 environment에 추가**
|
|
|
|
`web-backend/docker-compose.yml`의 insta-lab service 블록에서 `environment:` 아래에 추가 (다른 env 변수들과 같은 위치):
|
|
|
|
```yaml
|
|
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
|
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
|
|
```
|
|
|
|
또 같은 service 블록에 `depends_on:` 추가 (없으면 신규, 있으면 redis 추가):
|
|
|
|
```yaml
|
|
depends_on:
|
|
- redis
|
|
```
|
|
|
|
(들여쓰기는 다른 옵션과 동일하게 4 space 또는 service 블록 시작 기준.)
|
|
|
|
- [ ] **Step 2: NAS .env에 INTERNAL_API_KEY 생성 안내**
|
|
|
|
박재오가 NAS의 `/volume1/docker/webpage/.env`에 다음 라인 추가:
|
|
```
|
|
INTERNAL_API_KEY=<32자 이상 random secret>
|
|
```
|
|
|
|
생성 명령 예시 (NAS bash 또는 WSL2):
|
|
```bash
|
|
openssl rand -hex 32
|
|
```
|
|
|
|
> 이 값은 Windows insta-render의 `.env`에도 동일 값으로 보관 필요 (Task 9에서).
|
|
|
|
- [ ] **Step 3: yaml 검증**
|
|
|
|
Run:
|
|
```bash
|
|
python -c "import yaml; d=yaml.safe_load(open('C:/Users/jaeoh/Desktop/workspace/web-backend/docker-compose.yml', encoding='utf-8')); env=d['services']['insta-lab'].get('environment', []); print([e for e in env if 'REDIS' in e or 'INTERNAL' in e])"
|
|
```
|
|
|
|
Expected: `['- REDIS_URL=...', '- INTERNAL_API_KEY=...']` 형태로 2 entries.
|
|
|
|
- [ ] **Step 4: 커밋**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
|
git add docker-compose.yml
|
|
git commit -m "$(cat <<'EOF'
|
|
chore(compose): insta-lab REDIS_URL + INTERNAL_API_KEY env + depends_on redis
|
|
|
|
박재오: NAS .env에 INTERNAL_API_KEY=$(openssl rand -hex 32) 추가 필요.
|
|
같은 값을 Windows insta-render .env에 보관 (대칭).
|
|
|
|
Plan-B-Insta Phase 1 완료.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Windows insta-render — 디렉토리 생성 + Dockerfile + requirements.txt
|
|
|
|
**Files:**
|
|
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/Dockerfile`
|
|
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/requirements.txt`
|
|
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/.env.example`
|
|
|
|
- [ ] **Step 1: 디렉토리 생성**
|
|
|
|
```bash
|
|
mkdir -p C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render
|
|
mkdir -p C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/templates
|
|
mkdir -p C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/tests
|
|
```
|
|
|
|
- [ ] **Step 2: Dockerfile 작성**
|
|
|
|
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/Dockerfile`:
|
|
|
|
```dockerfile
|
|
FROM python:3.12-slim-bookworm
|
|
ENV PYTHONUNBUFFERED=1
|
|
|
|
WORKDIR /app
|
|
|
|
# Korean fonts + Chromium runtime deps (Debian 12 / bookworm)
|
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
fonts-noto-cjk fonts-noto-cjk-extra \
|
|
libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 \
|
|
libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 \
|
|
libxfixes3 libxrandr2 libgbm1 libxshmfence1 libpango-1.0-0 \
|
|
libcairo2 libasound2 libatspi2.0-0 \
|
|
&& rm -rf /var/lib/apt/lists/*
|
|
|
|
COPY requirements.txt .
|
|
RUN pip install --no-cache-dir --timeout 600 --retries 5 -r requirements.txt
|
|
RUN playwright install chromium
|
|
|
|
COPY . .
|
|
|
|
EXPOSE 8000
|
|
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
|
```
|
|
|
|
- [ ] **Step 3: requirements.txt 작성**
|
|
|
|
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/requirements.txt`:
|
|
|
|
```
|
|
fastapi==0.115.6
|
|
uvicorn[standard]==0.34.0
|
|
playwright==1.48.0
|
|
jinja2>=3.1.4
|
|
Pillow>=10
|
|
redis>=5.0
|
|
httpx>=0.27
|
|
pytest>=8.0
|
|
pytest-asyncio>=0.24
|
|
```
|
|
|
|
- [ ] **Step 4: .env.example 작성**
|
|
|
|
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/.env.example`:
|
|
|
|
```
|
|
# Plan-B-Insta — Windows insta-render worker
|
|
|
|
# NAS Redis 큐
|
|
REDIS_URL=redis://192.168.45.54:6379
|
|
|
|
# NAS internal webhook
|
|
NAS_BASE_URL=http://192.168.45.54:18700
|
|
INTERNAL_API_KEY=__copy_from_nas_dotenv__
|
|
|
|
# NAS SMB mount 안의 미디어 디렉토리 (/mnt/nas/webpage/data/insta/)
|
|
INSTA_MEDIA_ROOT=/mnt/nas/webpage/data/insta
|
|
|
|
# nginx 서빙 prefix (NAS webhook payload에 보낼 result_path 만들 때)
|
|
INSTA_MEDIA_URL_PREFIX=/media/insta
|
|
|
|
# Jinja 템플릿 디렉토리 (이 컨테이너 안)
|
|
CARD_TEMPLATE_DIR=/app/templates
|
|
```
|
|
|
|
- [ ] **Step 5: 커밋**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-ai
|
|
git add services/insta-render/Dockerfile services/insta-render/requirements.txt services/insta-render/.env.example
|
|
git commit -m "$(cat <<'EOF'
|
|
feat(services/insta-render): Dockerfile + requirements + env.example (SP-3 scaffold)
|
|
|
|
Windows WSL2 Docker용 Chromium 워커 컨테이너 기본 골격.
|
|
다음 task에서 main.py, worker.py, card_renderer.py 작성.
|
|
|
|
Plan-B-Insta Phase 2 시작.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Windows insta-render — `card_renderer.py` 이식 + Browser pool
|
|
|
|
**Files:**
|
|
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/card_renderer.py`
|
|
|
|
- [ ] **Step 1: card_renderer.py 작성**
|
|
|
|
기존 NAS `web-backend/insta-lab/app/card_renderer.py`를 기반으로, 단 NAS DB 의존성(`from . import db`)을 제거하고 결과물을 `INSTA_MEDIA_ROOT`에 직접 저장 (SMB volume).
|
|
|
|
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/card_renderer.py`:
|
|
|
|
```python
|
|
"""Jinja → HTML → Playwright headless screenshot (Windows worker version).
|
|
|
|
NAS DB·db.py 의존성 제거. slate 데이터는 worker가 NAS HTTP API에서 fetch해서
|
|
인자로 전달. 결과 PNG는 INSTA_MEDIA_ROOT (/mnt/nas/webpage/data/insta/)에 직접 저장.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List
|
|
|
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|
from playwright.async_api import async_playwright
|
|
|
|
CARD_TEMPLATE_DIR = os.getenv("CARD_TEMPLATE_DIR", "/app/templates")
|
|
INSTA_MEDIA_ROOT = os.getenv("INSTA_MEDIA_ROOT", "/mnt/nas/webpage/data/insta")
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# Chromium 동시 1개 (CPU·GPU 보호)
|
|
_RENDER_SEMAPHORE: asyncio.Semaphore | None = None
|
|
|
|
|
|
def _render_semaphore() -> asyncio.Semaphore:
|
|
global _RENDER_SEMAPHORE
|
|
if _RENDER_SEMAPHORE is None:
|
|
_RENDER_SEMAPHORE = asyncio.Semaphore(1)
|
|
return _RENDER_SEMAPHORE
|
|
|
|
|
|
# Browser pool — 매 슬레이트마다 launch X. 모듈 레벨 lazy + reuse.
|
|
_PLAYWRIGHT = None
|
|
_BROWSER = None
|
|
|
|
|
|
async def init_browser() -> None:
|
|
global _PLAYWRIGHT, _BROWSER
|
|
if _BROWSER is not None and _BROWSER.is_connected():
|
|
return
|
|
_PLAYWRIGHT = await async_playwright().start()
|
|
_BROWSER = await _PLAYWRIGHT.chromium.launch()
|
|
logger.info("Chromium browser pool 초기화 완료")
|
|
|
|
|
|
async def shutdown_browser() -> None:
|
|
global _PLAYWRIGHT, _BROWSER
|
|
if _BROWSER is not None:
|
|
try:
|
|
await _BROWSER.close()
|
|
except Exception:
|
|
logger.exception("browser close 중 예외 (무시)")
|
|
_BROWSER = None
|
|
if _PLAYWRIGHT is not None:
|
|
try:
|
|
await _PLAYWRIGHT.stop()
|
|
except Exception:
|
|
logger.exception("playwright stop 중 예외 (무시)")
|
|
_PLAYWRIGHT = None
|
|
|
|
|
|
async def _get_browser():
|
|
global _BROWSER
|
|
if _BROWSER is None or not _BROWSER.is_connected():
|
|
await init_browser()
|
|
return _BROWSER
|
|
|
|
|
|
def _env() -> Environment:
|
|
return Environment(
|
|
loader=FileSystemLoader(CARD_TEMPLATE_DIR),
|
|
autoescape=select_autoescape(["html", "j2"]),
|
|
)
|
|
|
|
|
|
def _build_pages(slate: dict) -> List[dict]:
|
|
"""slate dict → 10 page specs."""
|
|
cover = json.loads(slate["cover_copy"] or "{}")
|
|
bodies = json.loads(slate["body_copies"] or "[]")
|
|
cta = json.loads(slate["cta_copy"] or "{}")
|
|
accent = cover.get("accent_color") or "#0F62FE"
|
|
pages: List[dict] = []
|
|
pages.append({
|
|
"page_type": "cover", "page_no": 1, "total_pages": 10,
|
|
"headline": cover.get("headline", ""), "body": cover.get("body", ""),
|
|
"accent_color": accent, "cta": "",
|
|
})
|
|
for i, b in enumerate(bodies[:8]):
|
|
pages.append({
|
|
"page_type": "body", "page_no": i + 2, "total_pages": 10,
|
|
"headline": b.get("headline", ""), "body": b.get("body", ""),
|
|
"accent_color": accent, "cta": "",
|
|
})
|
|
pages.append({
|
|
"page_type": "cta", "page_no": 10, "total_pages": 10,
|
|
"headline": cta.get("headline", ""), "body": cta.get("body", ""),
|
|
"accent_color": accent, "cta": cta.get("cta", ""),
|
|
})
|
|
return pages
|
|
|
|
|
|
def _slate_dir(slate_id: int) -> str:
|
|
out = os.path.join(INSTA_MEDIA_ROOT, str(slate_id))
|
|
os.makedirs(out, exist_ok=True)
|
|
return out
|
|
|
|
|
|
async def render_slate(slate: dict, slate_id: int, template: str = "default/card.html.j2") -> List[str]:
|
|
"""slate 데이터 + slate_id로 10장 PNG 렌더. 결과 path list 반환."""
|
|
async with _render_semaphore():
|
|
return await _render_slate_locked(slate, slate_id, template)
|
|
|
|
|
|
async def _render_slate_locked(slate: dict, slate_id: int, template: str) -> List[str]:
|
|
env = _env()
|
|
template_full = Path(CARD_TEMPLATE_DIR) / template
|
|
if not template_full.exists():
|
|
logger.warning("Template '%s' 없음 → default/card.html.j2 폴백", template)
|
|
template = "default/card.html.j2"
|
|
|
|
tmpl = env.get_template(template)
|
|
pages = _build_pages(slate)
|
|
out_dir = _slate_dir(slate_id)
|
|
paths: List[str] = []
|
|
|
|
browser = await _get_browser()
|
|
ctx = await browser.new_context(viewport={"width": 1080, "height": 1350})
|
|
try:
|
|
page = await ctx.new_page()
|
|
for spec in pages:
|
|
html_str = tmpl.render(**spec)
|
|
with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False, encoding="utf-8") as f:
|
|
f.write(html_str)
|
|
html_path = f.name
|
|
try:
|
|
await page.goto(f"file://{html_path}", wait_until="networkidle")
|
|
out_path = os.path.join(out_dir, f"{spec['page_no']:02d}.png")
|
|
await page.screenshot(path=out_path, full_page=False, omit_background=False)
|
|
paths.append(out_path)
|
|
finally:
|
|
try:
|
|
os.unlink(html_path)
|
|
except OSError:
|
|
pass
|
|
finally:
|
|
await ctx.close()
|
|
return paths
|
|
```
|
|
|
|
- [ ] **Step 2: templates 디렉토리 복사**
|
|
|
|
NAS의 templates를 Windows 측에 복사:
|
|
|
|
```bash
|
|
# NAS 측 path: web-backend/insta-lab/app/templates/
|
|
# Windows 측 path: web-ai/services/insta-render/templates/
|
|
cp -r C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/app/templates/* \
|
|
C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/templates/
|
|
ls C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/templates/
|
|
```
|
|
|
|
Expected: `default/`, `hedgy75/` 등 NAS의 모든 테마 디렉토리.
|
|
|
|
> **주의:** NAS의 templates 변경 시 Windows 측에도 같은 변경 필요 (수동 sync). 향후 별도 SMB share로 templates 노출하면 single source 가능 (out of scope).
|
|
|
|
- [ ] **Step 3: syntax check**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-ai
|
|
python -c "import ast; ast.parse(open('services/insta-render/card_renderer.py', encoding='utf-8').read()); print('OK')"
|
|
```
|
|
|
|
- [ ] **Step 4: 커밋**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-ai
|
|
git add services/insta-render/card_renderer.py services/insta-render/templates/
|
|
git commit -m "$(cat <<'EOF'
|
|
feat(services/insta-render): card_renderer.py + templates (SP-3)
|
|
|
|
NAS insta-lab/app/card_renderer.py 이식 + DB 의존성 제거.
|
|
slate 데이터는 worker가 NAS API에서 fetch해 인자로 전달.
|
|
결과 PNG는 INSTA_MEDIA_ROOT (/mnt/nas/webpage/data/insta/)에 직접 저장.
|
|
Browser pool + Semaphore(1) reuse (동시 Chromium 1개).
|
|
templates는 NAS와 동기화 (현재 default theme만).
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Windows insta-render — `worker.py` (Redis BLPOP + webhook)
|
|
|
|
**Files:**
|
|
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/worker.py`
|
|
|
|
- [ ] **Step 1: worker.py 작성**
|
|
|
|
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/worker.py`:
|
|
|
|
```python
|
|
"""Redis BLPOP worker — queue:insta-render → render_slate → NAS webhook.
|
|
|
|
queue:paused가 set이면 대기 (task-watcher가 박재오 활동 감지 시 set).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import redis.asyncio as aioredis
|
|
|
|
from card_renderer import render_slate
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
REDIS_URL = os.getenv("REDIS_URL", "redis://192.168.45.54:6379")
|
|
NAS_BASE_URL = os.getenv("NAS_BASE_URL", "http://192.168.45.54:18700")
|
|
INTERNAL_API_KEY = os.getenv("INTERNAL_API_KEY", "")
|
|
INSTA_MEDIA_URL_PREFIX = os.getenv("INSTA_MEDIA_URL_PREFIX", "/media/insta")
|
|
|
|
QUEUE_KEY = "queue:insta-render"
|
|
PAUSED_KEY = "queue:paused"
|
|
|
|
|
|
async def _post_update(client: httpx.AsyncClient, task_id: str, status: str, progress: int,
|
|
result_path: str | None = None, error: str | None = None) -> None:
|
|
"""NAS internal webhook 호출."""
|
|
url = f"{NAS_BASE_URL}/api/internal/insta/update"
|
|
payload: dict[str, Any] = {"task_id": task_id, "status": status, "progress": progress}
|
|
if result_path:
|
|
payload["result_path"] = result_path
|
|
if error:
|
|
payload["error"] = error
|
|
try:
|
|
r = await client.post(
|
|
url,
|
|
headers={"X-Internal-Key": INTERNAL_API_KEY},
|
|
json=payload,
|
|
timeout=10.0,
|
|
)
|
|
if r.status_code != 200:
|
|
logger.error("webhook %s returned %d: %s", task_id, r.status_code, r.text[:200])
|
|
except Exception:
|
|
logger.exception("webhook %s 호출 실패", task_id)
|
|
|
|
|
|
async def _fetch_slate(client: httpx.AsyncClient, slate_id: int) -> dict:
|
|
"""NAS /api/insta/slates/{id} GET. (인증 불필요 — 기존 endpoint)"""
|
|
r = await client.get(f"{NAS_BASE_URL}/api/insta/slates/{slate_id}", timeout=10.0)
|
|
r.raise_for_status()
|
|
return r.json()
|
|
|
|
|
|
async def _process_one(client: httpx.AsyncClient, payload: dict) -> None:
|
|
"""단일 작업 처리: fetch slate → render → webhook."""
|
|
task_id = payload["task_id"]
|
|
params = payload.get("params", {})
|
|
slate_id = params.get("slate_id")
|
|
theme = params.get("theme", "default")
|
|
template = f"{theme}/card.html.j2"
|
|
|
|
try:
|
|
await _post_update(client, task_id, "processing", 20)
|
|
slate = await _fetch_slate(client, slate_id)
|
|
await _post_update(client, task_id, "processing", 50)
|
|
paths = await render_slate(slate, slate_id, template=template)
|
|
# 결과 URL은 첫 페이지의 nginx 경로
|
|
first_url = f"{INSTA_MEDIA_URL_PREFIX}/{slate_id}/01.png"
|
|
await _post_update(
|
|
client, task_id, "succeeded", 100, result_path=first_url
|
|
)
|
|
logger.info("rendered task=%s slate=%s count=%d", task_id, slate_id, len(paths))
|
|
except Exception as e:
|
|
logger.exception("render task=%s 실패", task_id)
|
|
await _post_update(client, task_id, "failed", 0, error=str(e))
|
|
|
|
|
|
async def worker_loop():
|
|
"""무한 루프 — paused 체크 → BLPOP → process_one."""
|
|
redis = aioredis.from_url(REDIS_URL, decode_responses=False)
|
|
async with httpx.AsyncClient() as client:
|
|
logger.info("insta-render worker started (queue=%s)", QUEUE_KEY)
|
|
while True:
|
|
try:
|
|
paused = await redis.get(PAUSED_KEY)
|
|
if paused == b"1":
|
|
await asyncio.sleep(10)
|
|
continue
|
|
item = await redis.blpop(QUEUE_KEY, timeout=1)
|
|
if item is None:
|
|
continue
|
|
_, raw = item
|
|
try:
|
|
payload = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
logger.error("invalid queue payload: %r", raw[:200])
|
|
continue
|
|
await _process_one(client, payload)
|
|
except asyncio.CancelledError:
|
|
logger.info("worker_loop cancelled")
|
|
raise
|
|
except Exception:
|
|
logger.exception("worker_loop iteration 실패, 5초 후 재시도")
|
|
await asyncio.sleep(5)
|
|
```
|
|
|
|
- [ ] **Step 2: syntax check**
|
|
|
|
```bash
|
|
python -c "import ast; ast.parse(open('C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/worker.py', encoding='utf-8').read()); print('OK')"
|
|
```
|
|
|
|
- [ ] **Step 3: 커밋**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-ai
|
|
git add services/insta-render/worker.py
|
|
git commit -m "$(cat <<'EOF'
|
|
feat(services/insta-render): Redis BLPOP worker + NAS webhook (SP-3)
|
|
|
|
queue:insta-render에서 BLPOP → NAS API에서 slate 조회 → render →
|
|
internal webhook으로 NAS DB 업데이트. queue:paused 체크 (task-watcher 연동).
|
|
|
|
Plan-B-Insta Phase 2 진행 중.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Windows insta-render — `main.py` (FastAPI entry + lifespan)
|
|
|
|
**Files:**
|
|
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/main.py`
|
|
|
|
- [ ] **Step 1: main.py 작성**
|
|
|
|
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/main.py`:
|
|
|
|
```python
|
|
"""insta-render FastAPI entry — health + lifespan (Browser pool + worker loop)."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import FastAPI
|
|
|
|
import card_renderer
|
|
import worker
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
# Browser pool 초기화 (Chromium launch)
|
|
await card_renderer.init_browser()
|
|
# 큐 워커 백그라운드 시작
|
|
worker_task = asyncio.create_task(worker.worker_loop())
|
|
logger.info("insta-render lifespan 시작")
|
|
try:
|
|
yield
|
|
finally:
|
|
worker_task.cancel()
|
|
try:
|
|
await worker_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
await card_renderer.shutdown_browser()
|
|
logger.info("insta-render lifespan 종료")
|
|
|
|
|
|
app = FastAPI(lifespan=lifespan)
|
|
|
|
|
|
@app.get("/health")
|
|
def health():
|
|
return {"ok": True, "service": "insta-render"}
|
|
```
|
|
|
|
- [ ] **Step 2: syntax check**
|
|
|
|
```bash
|
|
python -c "import ast; ast.parse(open('C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/main.py', encoding='utf-8').read()); print('OK')"
|
|
```
|
|
|
|
- [ ] **Step 3: 커밋**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-ai
|
|
git add services/insta-render/main.py
|
|
git commit -m "$(cat <<'EOF'
|
|
feat(services/insta-render): FastAPI entry + lifespan (SP-3)
|
|
|
|
lifespan에서 Browser pool init + worker_loop spawn. shutdown 시 정상 cleanup.
|
|
GET /health (LivenessProbe용).
|
|
|
|
Plan-B-Insta Phase 2.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Windows services/docker-compose.yml + .env 운영 셋업
|
|
|
|
**Files:**
|
|
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/docker-compose.yml`
|
|
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/.env` (NOT committed — 박재오 머신 로컬만)
|
|
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-ai/.gitignore`
|
|
|
|
- [ ] **Step 1: docker-compose.yml 작성**
|
|
|
|
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/docker-compose.yml`:
|
|
|
|
```yaml
|
|
name: web-ai-services
|
|
|
|
services:
|
|
insta-render:
|
|
build:
|
|
context: ./insta-render
|
|
container_name: insta-render
|
|
restart: unless-stopped
|
|
ports:
|
|
- "18710:8000"
|
|
environment:
|
|
- TZ=Asia/Seoul
|
|
- REDIS_URL=${REDIS_URL:-redis://192.168.45.54:6379}
|
|
- NAS_BASE_URL=${NAS_BASE_URL:-http://192.168.45.54:18700}
|
|
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
|
|
- INSTA_MEDIA_ROOT=${INSTA_MEDIA_ROOT:-/mnt/nas/webpage/data/insta}
|
|
- INSTA_MEDIA_URL_PREFIX=${INSTA_MEDIA_URL_PREFIX:-/media/insta}
|
|
- CARD_TEMPLATE_DIR=/app/templates
|
|
volumes:
|
|
- /mnt/nas/webpage/data/insta:/mnt/nas/webpage/data/insta
|
|
healthcheck:
|
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
|
interval: 60s
|
|
timeout: 5s
|
|
retries: 3
|
|
```
|
|
|
|
- [ ] **Step 2: .gitignore에 .env 추가**
|
|
|
|
`C:/Users/jaeoh/Desktop/workspace/web-ai/.gitignore`에 다음 라인 (없으면) 추가:
|
|
|
|
```
|
|
services/.env
|
|
services/*/.env
|
|
```
|
|
|
|
- [ ] **Step 3: 박재오가 .env 실 작성**
|
|
|
|
Windows AI 머신의 WSL2 또는 PowerShell에서:
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-ai/services
|
|
cp insta-render/.env.example .env
|
|
nano .env
|
|
# REDIS_URL, NAS_BASE_URL는 기본값 OK
|
|
# INTERNAL_API_KEY는 NAS .env의 같은 값으로 채움
|
|
```
|
|
|
|
또는 한 줄로:
|
|
```bash
|
|
cd /mnt/c/Users/jaeoh/Desktop/workspace/web-ai/services
|
|
echo "INTERNAL_API_KEY=$(openssl rand -hex 32)" > .env.tmp
|
|
echo "REDIS_URL=redis://192.168.45.54:6379" >> .env.tmp
|
|
echo "NAS_BASE_URL=http://192.168.45.54:18700" >> .env.tmp
|
|
echo "INSTA_MEDIA_ROOT=/mnt/nas/webpage/data/insta" >> .env.tmp
|
|
echo "INSTA_MEDIA_URL_PREFIX=/media/insta" >> .env.tmp
|
|
mv .env.tmp .env
|
|
cat .env
|
|
```
|
|
|
|
> **이 INTERNAL_API_KEY 값을 NAS .env에도 같은 값으로 설정.** 위 명령은 새 random 키 생성이므로 NAS와 미일치. 박재오가 Task 4의 NAS .env 작성과 같은 키 보장해야 함.
|
|
|
|
- [ ] **Step 4: docker compose build + up (Windows WSL2에서)**
|
|
|
|
박재오 WSL2 bash:
|
|
|
|
```bash
|
|
cd /mnt/c/Users/jaeoh/Desktop/workspace/web-ai/services
|
|
docker compose build insta-render
|
|
docker compose up -d insta-render
|
|
docker compose ps
|
|
```
|
|
|
|
Expected:
|
|
- build: ~10분 (Chromium + Korean fonts install)
|
|
- ps: `insta-render` Up (healthy)
|
|
|
|
- [ ] **Step 5: 헬스 확인**
|
|
|
|
```bash
|
|
curl http://localhost:18710/health
|
|
```
|
|
|
|
Expected: `{"ok": true, "service": "insta-render"}`
|
|
|
|
- [ ] **Step 6: 커밋 (docker-compose만, .env 제외)**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-ai
|
|
git add services/docker-compose.yml .gitignore
|
|
git commit -m "$(cat <<'EOF'
|
|
feat(services): docker-compose for insta-render worker (SP-3)
|
|
|
|
Windows WSL2 Docker용. NAS Redis 6379 + NAS API 18700 호출.
|
|
/mnt/nas SMB 볼륨 마운트. INTERNAL_API_KEY는 NAS .env와 같은 값.
|
|
.env는 .gitignore (박재오 머신 로컬 보관).
|
|
|
|
Plan-B-Insta Phase 2 마무리.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Windows insta-render — 단위 테스트 (TDD)
|
|
|
|
**Files:**
|
|
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/tests/test_worker.py`
|
|
|
|
- [ ] **Step 1: 테스트 작성 (mock Redis + httpx)**
|
|
|
|
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/tests/test_worker.py`:
|
|
|
|
```python
|
|
"""worker.py — Redis BLPOP + webhook 단위 테스트."""
|
|
import json
|
|
import pytest
|
|
import httpx
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import worker
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_slate():
|
|
return {
|
|
"id": 42,
|
|
"cover_copy": json.dumps({"headline": "테스트 H", "body": "테스트 B", "accent_color": "#FF0000"}),
|
|
"body_copies": json.dumps([{"headline": "본문1", "body": "..."} for _ in range(8)]),
|
|
"cta_copy": json.dumps({"headline": "CTA", "body": "...", "cta": "Click"}),
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_update_sends_correct_payload(monkeypatch):
|
|
monkeypatch.setenv("INTERNAL_API_KEY", "test-secret")
|
|
monkeypatch.setenv("NAS_BASE_URL", "http://nas.test")
|
|
# worker 모듈 환경변수 재로딩
|
|
worker.NAS_BASE_URL = "http://nas.test"
|
|
worker.INTERNAL_API_KEY = "test-secret"
|
|
|
|
captured = {}
|
|
async def fake_post(self, url, headers=None, json=None, **kw):
|
|
captured["url"] = url
|
|
captured["headers"] = headers
|
|
captured["json"] = json
|
|
class R:
|
|
status_code = 200
|
|
text = "ok"
|
|
return R()
|
|
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
await worker._post_update(client, "t-1", "processing", 30)
|
|
|
|
assert captured["url"] == "http://nas.test/api/internal/insta/update"
|
|
assert captured["headers"]["X-Internal-Key"] == "test-secret"
|
|
assert captured["json"]["status"] == "processing"
|
|
assert captured["json"]["progress"] == 30
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_process_one_success_calls_webhook_twice(monkeypatch, fake_slate):
|
|
"""processing(50) → succeeded(100) 두 번 호출 + render 한 번."""
|
|
calls: list = []
|
|
|
|
async def fake_post(self, url, headers=None, json=None, **kw):
|
|
calls.append({"status": json["status"], "progress": json["progress"]})
|
|
class R:
|
|
status_code = 200
|
|
text = "ok"
|
|
return R()
|
|
|
|
async def fake_get(self, url, **kw):
|
|
class R:
|
|
status_code = 200
|
|
def json(self_inner): return fake_slate
|
|
def raise_for_status(self_inner): pass
|
|
return R()
|
|
|
|
async def fake_render(slate, slate_id, template="default/card.html.j2"):
|
|
return [f"/tmp/{slate_id}/{i:02d}.png" for i in range(1, 11)]
|
|
|
|
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
|
|
monkeypatch.setattr(httpx.AsyncClient, "get", fake_get)
|
|
monkeypatch.setattr(worker, "render_slate", fake_render)
|
|
worker.INTERNAL_API_KEY = "test"
|
|
worker.NAS_BASE_URL = "http://nas.test"
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
await worker._process_one(client, {
|
|
"task_id": "t-2",
|
|
"params": {"slate_id": 42, "theme": "default"},
|
|
})
|
|
|
|
statuses = [c["status"] for c in calls]
|
|
assert "processing" in statuses
|
|
assert "succeeded" in statuses
|
|
assert calls[-1]["progress"] == 100
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_process_one_render_failure_reports_failed(monkeypatch, fake_slate):
|
|
"""render 예외 시 failed webhook 호출."""
|
|
calls: list = []
|
|
|
|
async def fake_post(self, url, headers=None, json=None, **kw):
|
|
calls.append(json)
|
|
class R: status_code = 200; text = "ok"
|
|
return R()
|
|
|
|
async def fake_get(self, url, **kw):
|
|
class R:
|
|
status_code = 200
|
|
def json(self_inner): return fake_slate
|
|
def raise_for_status(self_inner): pass
|
|
return R()
|
|
|
|
async def fake_render(*a, **k):
|
|
raise RuntimeError("Chromium crashed")
|
|
|
|
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
|
|
monkeypatch.setattr(httpx.AsyncClient, "get", fake_get)
|
|
monkeypatch.setattr(worker, "render_slate", fake_render)
|
|
worker.INTERNAL_API_KEY = "test"
|
|
worker.NAS_BASE_URL = "http://nas.test"
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
await worker._process_one(client, {
|
|
"task_id": "t-3",
|
|
"params": {"slate_id": 99},
|
|
})
|
|
|
|
last = calls[-1]
|
|
assert last["status"] == "failed"
|
|
assert "Chromium" in last["error"]
|
|
```
|
|
|
|
- [ ] **Step 2: pytest.ini 작성 (asyncio mode)**
|
|
|
|
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/insta-render/pytest.ini`:
|
|
|
|
```ini
|
|
[pytest]
|
|
asyncio_mode = auto
|
|
```
|
|
|
|
- [ ] **Step 3: 테스트 실행**
|
|
|
|
WSL2 또는 박재오 PC에 pip install 필요. 빠른 검증을 위해 docker 내부 시도:
|
|
|
|
```bash
|
|
cd /mnt/c/Users/jaeoh/Desktop/workspace/web-ai/services
|
|
docker compose run --rm insta-render python -m pytest tests/ -v
|
|
```
|
|
|
|
Expected: 3 PASS.
|
|
|
|
> **alt**: 박재오 Windows 시스템 Python에 직접 — `pip install fastapi playwright redis httpx pytest pytest-asyncio jinja2` 후 services/insta-render/ 안에서 pytest.
|
|
|
|
- [ ] **Step 4: 커밋**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-ai
|
|
git add services/insta-render/tests/test_worker.py services/insta-render/pytest.ini
|
|
git commit -m "$(cat <<'EOF'
|
|
test(services/insta-render): worker unit tests (3 cases)
|
|
|
|
- _post_update payload·헤더 검증
|
|
- _process_one 정상 흐름 (processing + succeeded)
|
|
- _process_one 예외 시 failed webhook
|
|
|
|
Plan-B-Insta Phase 2 mature. Phase 3 cutover 준비 완료.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: NAS insta-lab cutover — `_bg_create_slate` + `_bg_render`를 Redis push로
|
|
|
|
**Files:**
|
|
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/app/main.py`
|
|
|
|
- [ ] **Step 1: 현재 두 render 호출 위치 확인**
|
|
|
|
```bash
|
|
grep -n "card_renderer\.render_slate" C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/app/main.py
|
|
```
|
|
|
|
Expected: 2 곳 (line 157 근처 `_bg_create_slate`, line 197 근처 `_bg_render`).
|
|
|
|
- [ ] **Step 2: 첫 번째 호출 (`_bg_create_slate`)을 Redis push로 변경**
|
|
|
|
`web-backend/insta-lab/app/main.py`의 `_bg_create_slate` 함수 본체에서 `await card_renderer.render_slate(sid, ...)` 라인을 다음으로 교체:
|
|
|
|
변경 전:
|
|
```python
|
|
db.update_task(task_id, "processing", 70, "카드 렌더 중")
|
|
await card_renderer.render_slate(sid, template=f"{INSTA_DEFAULT_THEME}/card.html.j2")
|
|
db.update_slate_status(sid, "rendered")
|
|
```
|
|
|
|
변경 후:
|
|
```python
|
|
db.update_task(task_id, "processing", 70, "Redis 큐 푸시 → Windows worker 대기 중")
|
|
from datetime import datetime, timezone, timedelta
|
|
kst = timezone(timedelta(hours=9))
|
|
payload = {
|
|
"task_id": task_id,
|
|
"kind": "insta",
|
|
"params": {"slate_id": sid, "theme": INSTA_DEFAULT_THEME},
|
|
"submitted_at": datetime.now(kst).isoformat(),
|
|
}
|
|
await redis_client.rpush("queue:insta-render", json.dumps(payload))
|
|
# 사용자는 GET /api/insta/tasks/{task_id}로 폴링 (worker가 webhook으로 status update)
|
|
```
|
|
|
|
- [ ] **Step 3: 두 번째 호출 (`_bg_render`)도 동일 변경**
|
|
|
|
`_bg_render(task_id, slate_id)` 함수 본체:
|
|
|
|
변경 전:
|
|
```python
|
|
async def _bg_render(task_id: str, slate_id: int):
|
|
try:
|
|
db.update_task(task_id, "processing", 30, "재렌더 중")
|
|
await card_renderer.render_slate(slate_id, template=f"{INSTA_DEFAULT_THEME}/card.html.j2")
|
|
db.update_slate_status(slate_id, "rendered")
|
|
db.update_task(task_id, "succeeded", 100, "완료", result_id=slate_id)
|
|
except Exception as e:
|
|
logger.exception("render failed")
|
|
db.update_task(task_id, "failed", 0, "", error=str(e))
|
|
```
|
|
|
|
변경 후:
|
|
```python
|
|
async def _bg_render(task_id: str, slate_id: int):
|
|
"""Redis 큐에 push. 실 렌더는 Windows insta-render worker."""
|
|
try:
|
|
from datetime import datetime, timezone, timedelta
|
|
kst = timezone(timedelta(hours=9))
|
|
payload = {
|
|
"task_id": task_id,
|
|
"kind": "insta",
|
|
"params": {"slate_id": slate_id, "theme": INSTA_DEFAULT_THEME},
|
|
"submitted_at": datetime.now(kst).isoformat(),
|
|
}
|
|
await redis_client.rpush("queue:insta-render", json.dumps(payload))
|
|
db.update_task(task_id, "processing", 30, "Redis 큐 푸시 → Windows worker 대기 중")
|
|
except Exception as e:
|
|
logger.exception("queue push failed")
|
|
db.update_task(task_id, "failed", 0, "", error=str(e))
|
|
```
|
|
|
|
- [ ] **Step 4: db.update_slate_status 호출 위치 정리**
|
|
|
|
기존 `db.update_slate_status(sid, "rendered")`는 Windows webhook이 succeeded 보고 시점에 NAS가 처리해야 함. 즉 `internal_router.py`에서 status="succeeded" 시 slate_status도 업데이트:
|
|
|
|
`web-backend/insta-lab/app/internal_router.py`의 `insta_update` 함수에 추가 (succeeded 분기):
|
|
|
|
```python
|
|
if payload.status == "succeeded":
|
|
# input_data에서 slate_id 추출
|
|
try:
|
|
input_data = json.loads(task.get("input_data") or "{}")
|
|
result_id = input_data.get("slate_id")
|
|
except (ValueError, TypeError):
|
|
pass
|
|
# slate status도 rendered로 갱신
|
|
if result_id is not None:
|
|
try:
|
|
db.update_slate_status(result_id, "rendered")
|
|
except Exception:
|
|
logger.exception("update_slate_status %s 실패 (무시)", result_id)
|
|
```
|
|
|
|
- [ ] **Step 5: 빠른 import sanity 체크**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab && python -c "from app import main, internal_router; print('OK')"
|
|
```
|
|
|
|
- [ ] **Step 6: 커밋**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
|
git add insta-lab/app/main.py insta-lab/app/internal_router.py
|
|
git commit -m "$(cat <<'EOF'
|
|
feat(insta-lab): cutover to Redis push, Playwright 렌더 호출 제거 (SP-4)
|
|
|
|
_bg_create_slate, _bg_render의 await card_renderer.render_slate(...)
|
|
호출을 Redis RPUSH queue:insta-render 로 전환.
|
|
|
|
NAS는 task_id 발급 + 큐 푸시 + 30% 진행률 보고만. Windows insta-render
|
|
워커가 BLPOP → 렌더 → webhook으로 succeeded 보고 시 NAS가
|
|
update_slate_status('rendered') 트리거.
|
|
|
|
Plan-B-Insta Phase 3 (cutover).
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 12: NAS insta-lab — card_renderer.py stub + Dockerfile 슬림화
|
|
|
|
**Files:**
|
|
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/app/card_renderer.py`
|
|
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/Dockerfile`
|
|
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/requirements.txt`
|
|
|
|
- [ ] **Step 1: card_renderer.py 전체 제거**
|
|
|
|
`web-backend/insta-lab/app/card_renderer.py`을 다음 한 줄로 교체 (NAS에서 더 이상 렌더 안 함):
|
|
|
|
```python
|
|
"""DEPRECATED 2026-05-19 — NAS에서 카드 렌더 안 함. Windows insta-render 워커로 이전됨.
|
|
|
|
기존 render_slate, init_browser, shutdown_browser는 모두 web-ai/services/insta-render/card_renderer.py로 이식.
|
|
NAS insta-lab은 Redis push (queue:insta-render)만 담당.
|
|
|
|
이 파일은 임포트 호환성 위해서만 존재. 새 코드는 이 모듈을 import하지 말 것.
|
|
"""
|
|
```
|
|
|
|
- [ ] **Step 2: main.py에서 `import card_renderer` 라인 제거**
|
|
|
|
```bash
|
|
grep -n "import card_renderer\|from .card_renderer\|from . import card_renderer" C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab/app/main.py
|
|
```
|
|
|
|
해당 라인 찾으면 그 라인 삭제. Task 11에서 모든 `card_renderer.render_slate` 호출이 Redis push로 바뀌어서 import 불필요.
|
|
|
|
- [ ] **Step 3: requirements.txt에서 playwright 라인 제거**
|
|
|
|
`web-backend/insta-lab/requirements.txt`에서 `playwright==1.48.0` 라인 삭제.
|
|
|
|
남는 내용:
|
|
```
|
|
fastapi==0.115.6
|
|
uvicorn[standard]==0.34.0
|
|
requests==2.32.3
|
|
httpx>=0.27
|
|
anthropic==0.52.0
|
|
jinja2>=3.1.4
|
|
Pillow>=10
|
|
pytest>=8.0
|
|
pytest-asyncio>=0.24
|
|
redis>=5.0
|
|
```
|
|
|
|
- [ ] **Step 4: Dockerfile 슬림화**
|
|
|
|
`web-backend/insta-lab/Dockerfile`을 다음으로 교체 (Chromium runtime 의존성 + `playwright install` 제거):
|
|
|
|
```dockerfile
|
|
FROM python:3.12-slim-bookworm
|
|
ENV PYTHONUNBUFFERED=1
|
|
|
|
WORKDIR /app
|
|
|
|
# Korean fonts (insta-lab가 자체 텍스트 처리는 안 하지만 향후 thumbnail 생성 등 위해 유지)
|
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
fonts-noto-cjk fonts-noto-cjk-extra \
|
|
&& rm -rf /var/lib/apt/lists/*
|
|
|
|
COPY requirements.txt .
|
|
RUN pip install --no-cache-dir --timeout 600 --retries 5 -r requirements.txt
|
|
|
|
COPY . .
|
|
|
|
EXPOSE 8000
|
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
|
```
|
|
|
|
이전 ~30개 Chromium dep 라인 + `playwright install chromium` 라인 모두 제거됨. image 크기 ~50% 감소 예상.
|
|
|
|
- [ ] **Step 5: pytest로 NAS insta-lab tests 회귀 확인**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-backend/insta-lab
|
|
python -m pytest tests/ -v 2>&1 | tail -10
|
|
```
|
|
|
|
Expected: 모든 tests PASS (test_card_renderer.py는 모듈 stub이 됐으니 일부 skip되거나 fail 가능 — 그 경우 test 파일을 deprecated 처리하거나 unit이 mock으로 변경되었는지 확인).
|
|
|
|
만약 `test_card_renderer.py`가 `from app.card_renderer import render_slate`로 import 시도해서 fail이면, 그 test 파일도 deprecated:
|
|
|
|
```bash
|
|
mv tests/test_card_renderer.py tests/test_card_renderer.py.deprecated
|
|
```
|
|
|
|
또는 git rm:
|
|
```bash
|
|
git rm tests/test_card_renderer.py
|
|
```
|
|
|
|
- [ ] **Step 6: 커밋**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
|
git add insta-lab/app/card_renderer.py insta-lab/app/main.py \
|
|
insta-lab/requirements.txt insta-lab/Dockerfile \
|
|
insta-lab/tests/
|
|
git commit -m "$(cat <<'EOF'
|
|
refactor(insta-lab): remove Playwright + slim Dockerfile (SP-4)
|
|
|
|
NAS에서 더 이상 카드 렌더 안 함 → Windows insta-render 워커로 완전 이전.
|
|
- card_renderer.py를 1줄 deprecation stub로 교체
|
|
- main.py의 import card_renderer 제거
|
|
- requirements.txt에서 playwright 삭제
|
|
- Dockerfile에서 Chromium 30+ dep 라인 + playwright install 제거 → image ~50% 감소
|
|
- test_card_renderer.py 폐기 (Windows 측 test_worker.py가 대체)
|
|
|
|
Plan-B-Insta Phase 3 완료.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 13: NAS push + deployer rebuild + 헬스 확인
|
|
|
|
**Files:** 없음 (git push + 운영 검증)
|
|
|
|
- [ ] **Step 1: web-backend push (Gitea webhook → NAS deployer)**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
|
git push origin main
|
|
```
|
|
|
|
(자격증명 prompt 1회. 1회 실패 시 재시도.)
|
|
|
|
- [ ] **Step 2: deployer rebuild 대기 (~3분, Chromium dep 제거로 빌드 더 빠름)**
|
|
|
|
```bash
|
|
for i in 1 2 3 4 5 6 7 8 9 10; do
|
|
code=$(curl -s -o /dev/null -w "%{http_code}" https://gahusb.synology.me/api/stock/news -m 5)
|
|
echo "[try $i, $(date +%H:%M:%S)] stock $code"
|
|
if [ "$code" = "200" ]; then break; fi
|
|
sleep 15
|
|
done
|
|
```
|
|
|
|
- [ ] **Step 3: insta-lab 헬스 + 신 endpoint 확인**
|
|
|
|
```bash
|
|
curl -s -o /dev/null -w "/api/insta/status: %{http_code}\n" https://gahusb.synology.me/api/insta/status
|
|
|
|
# Internal webhook은 X-Internal-Key 필요 — LAN에서 시도 (인증 X면 401)
|
|
curl -s -X POST https://gahusb.synology.me/api/internal/insta/update \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"task_id":"test","status":"processing","progress":1}' \
|
|
-o /dev/null -w "/api/internal/insta/update no key: %{http_code}\n"
|
|
# Expected: 401 또는 422 (key 없거나 task_id 없음)
|
|
```
|
|
|
|
- [ ] **Step 4: 커밋 없음, 검증만**
|
|
|
|
NAS deployer가 정상 작동했으면 다음 Phase 4 (nginx 차단) 진행.
|
|
|
|
---
|
|
|
|
## Task 14: nginx에 `/api/internal/*` LAN allow + deny all
|
|
|
|
**Files:**
|
|
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/nginx/default.conf`
|
|
|
|
- [ ] **Step 1: insta-lab routing 블록 근처에 internal 차단 추가**
|
|
|
|
`web-backend/nginx/default.conf`에서 `location /api/insta/` 블록 찾기. 그 위 또는 아래에 다음 새 블록 추가:
|
|
|
|
```nginx
|
|
# Plan-B-Insta — Windows worker → NAS internal webhook (3-layer 차단)
|
|
# Layer 1·2: nginx IP 화이트리스트 (LAN + Tailscale)
|
|
# Layer 3: X-Internal-Key (FastAPI dependency)
|
|
location /api/internal/insta/ {
|
|
allow 192.168.45.0/24; # LAN 화이트리스트
|
|
allow 100.64.0.0/10; # Tailscale CGNAT
|
|
allow 127.0.0.1; # NAS 내부
|
|
deny all;
|
|
|
|
resolver 127.0.0.11 valid=10s;
|
|
set $insta_backend insta-lab:8000;
|
|
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-Internal-Key $http_x_internal_key;
|
|
proxy_pass http://$insta_backend$request_uri;
|
|
}
|
|
```
|
|
|
|
> 들여쓰기는 기존 location 블록과 같은 수준 (보통 `server {}` 블록 안 2-space).
|
|
|
|
- [ ] **Step 2: nginx config syntax 검증 (NAS bash, 박재오 SSH)**
|
|
|
|
```bash
|
|
ssh -p 2300 bgg8988@gahusb.synology.me
|
|
sudo -i
|
|
docker exec frontend nginx -t
|
|
```
|
|
|
|
Expected: `nginx: configuration file /etc/nginx/nginx.conf test is successful`
|
|
|
|
- [ ] **Step 3: nginx reload (커밋 후 deployer가 자동 처리하지만 빠른 적용 위해 수동 가능)**
|
|
|
|
수동 (즉시):
|
|
```bash
|
|
docker exec frontend nginx -s reload
|
|
```
|
|
|
|
또는 다음 deployer cycle에서 자동 reload (deploy.sh의 `docker exec frontend nginx -s reload || true`).
|
|
|
|
- [ ] **Step 4: 외부에서 차단 확인**
|
|
|
|
박재오 머신 외부 connection (예: 휴대폰 LTE)에서:
|
|
```
|
|
curl -X POST https://gahusb.synology.me/api/internal/insta/update -d '{}'
|
|
```
|
|
|
|
Expected: **403 Forbidden** (nginx deny).
|
|
|
|
LAN에서는 (key 없이):
|
|
```bash
|
|
curl -X POST https://gahusb.synology.me/api/internal/insta/update -d '{}'
|
|
```
|
|
|
|
Expected: **401 또는 422** (nginx 통과, FastAPI에서 인증·validation).
|
|
|
|
- [ ] **Step 5: 커밋**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
|
git add nginx/default.conf
|
|
git commit -m "$(cat <<'EOF'
|
|
feat(nginx): 3-layer block for /api/internal/insta/ (SP-4)
|
|
|
|
Layer 1·2: IP 화이트리스트 (192.168.45.0/24 LAN + 100.64.0.0/10 Tailscale).
|
|
Layer 3: X-Internal-Key 헤더 (FastAPI dependency, 별도 검증).
|
|
|
|
외부에서 직접 호출 시 403 (nginx deny), LAN에서 키 없으면 401 (FastAPI).
|
|
Windows insta-render만 호출 가능.
|
|
|
|
Plan-B-Insta Phase 4.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
git push origin main
|
|
```
|
|
|
|
(push 후 deployer가 자동 reload.)
|
|
|
|
---
|
|
|
|
## Task 15: 통합 검증 — end-to-end 렌더 한 번
|
|
|
|
**Files:** 없음 (운영 검증)
|
|
|
|
- [ ] **Step 1: Windows insta-render 컨테이너 확인**
|
|
|
|
박재오 WSL2:
|
|
```bash
|
|
docker compose -f /mnt/c/Users/jaeoh/Desktop/workspace/web-ai/services/docker-compose.yml ps
|
|
docker compose -f .../services/docker-compose.yml logs insta-render --tail 30
|
|
```
|
|
|
|
Expected:
|
|
- ps: `insta-render` Up (healthy)
|
|
- logs: `insta-render worker started`, `Chromium browser pool 초기화 완료`
|
|
|
|
만약 안 떠 있으면:
|
|
```bash
|
|
docker compose up -d insta-render
|
|
```
|
|
|
|
- [ ] **Step 2: NAS에 카드 슬레이트 생성 + 렌더 트리거**
|
|
|
|
LAN에서 박재오 PC:
|
|
```bash
|
|
# 1) 슬레이트 ID 임의 (이전 작업한 slate가 있으면 그 ID 사용)
|
|
# 슬레이트 목록 조회
|
|
curl -s https://gahusb.synology.me/api/insta/slates | python -m json.tool | head -30
|
|
|
|
# 2) 임의 slate ID (예: 1)로 재렌더 트리거
|
|
SLATE_ID=1
|
|
curl -X POST "https://gahusb.synology.me/api/insta/slates/${SLATE_ID}/render" -o /tmp/render-resp.json
|
|
cat /tmp/render-resp.json
|
|
# Expected: {"task_id": "xxxxx-..."}
|
|
TASK_ID=$(cat /tmp/render-resp.json | python -c "import json,sys; print(json.load(sys.stdin)['task_id'])")
|
|
echo "task_id=$TASK_ID"
|
|
```
|
|
|
|
- [ ] **Step 3: 폴링으로 진행률 확인**
|
|
|
|
```bash
|
|
for i in 1 2 3 4 5 6 7 8 9 10; do
|
|
curl -s "https://gahusb.synology.me/api/insta/tasks/${TASK_ID}" | python -c "import json,sys; d=json.load(sys.stdin); print(f'[try $i] status={d[\"status\"]} progress={d[\"progress\"]}')"
|
|
sleep 5
|
|
done
|
|
```
|
|
|
|
Expected 진행:
|
|
- try 1~2: `status=processing progress=30` (NAS push 직후)
|
|
- try 3~4: `status=processing progress=50` (worker fetch + render 시작)
|
|
- try 5~6: `status=succeeded progress=100`
|
|
|
|
- [ ] **Step 4: 생성된 PNG 확인 (NAS 측 + 브라우저)**
|
|
|
|
NAS:
|
|
```bash
|
|
ls /volume1/docker/webpage/data/insta/${SLATE_ID}/
|
|
# Expected: 01.png ~ 10.png (각 ~50~150KB)
|
|
```
|
|
|
|
브라우저:
|
|
```
|
|
https://gahusb.synology.me/media/insta/${SLATE_ID}/01.png
|
|
```
|
|
|
|
→ 카드 이미지 보이면 end-to-end 성공.
|
|
|
|
- [ ] **Step 5: Windows worker 로그 확인**
|
|
|
|
```bash
|
|
docker compose -f .../services/docker-compose.yml logs insta-render --tail 50 | grep -E "task=|rendered"
|
|
```
|
|
|
|
Expected:
|
|
```
|
|
... task=xxxxx slate=1 count=10
|
|
```
|
|
|
|
---
|
|
|
|
## Task 16: 최종 정리 + Plan-B-Insta 마무리
|
|
|
|
**Files:** 없음
|
|
|
|
- [ ] **Step 1: Phase 2 services repo 진입 (cd web-ai)**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-ai
|
|
git status -sb
|
|
git log --oneline 26ef660..HEAD
|
|
```
|
|
|
|
이번 Plan-B-Insta web-ai 측 commits 확인. push 안 했으면:
|
|
|
|
```bash
|
|
git push origin main
|
|
```
|
|
|
|
- [ ] **Step 2: 모든 변경 점검 + commit이력**
|
|
|
|
```bash
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-backend
|
|
git log --oneline | head -10
|
|
|
|
cd C:/Users/jaeoh/Desktop/workspace/web-ai
|
|
git log --oneline | head -10
|
|
```
|
|
|
|
- [ ] **Step 3: 후속 trace — task-watcher (SP-10) 연동 확인 (옵션)**
|
|
|
|
queue:paused 메커니즘 검증. 박재오가 task-watcher 컨테이너 신설 전이면 manually 토글 가능:
|
|
|
|
NAS bash:
|
|
```bash
|
|
docker exec redis redis-cli SET queue:paused 1 EX 60
|
|
# 60초 안에 insta render 시도 → 큐 쌓이지만 worker idle
|
|
docker compose -f .../services/docker-compose.yml logs insta-render --tail 5
|
|
# Expected: 60초간 BLPOP 안 함
|
|
docker exec redis redis-cli DEL queue:paused
|
|
# worker가 즉시 처리 재개
|
|
```
|
|
|
|
> task-watcher는 별도 plan (Plan-B-Infra)에서 신설. 이번 plan은 paused 키만 인지.
|
|
|
|
---
|
|
|
|
## Self-Review
|
|
|
|
### Spec 커버리지
|
|
|
|
| Spec 요구사항 | 구현 Task |
|
|
|---------------|-----------|
|
|
| §4 SP-3: insta-render Windows 서비스 | Task 5~10 |
|
|
| §4 SP-4: NAS insta-lab 분할 (Playwright 제거 + Redis push) | Task 1~4, 11~12 |
|
|
| §5 Windows Render Worker 통합 패턴 (BLPOP + webhook + SMB) | worker.py 구조 (Task 7) |
|
|
| §6 Redis 키 `queue:insta-render`, `queue:paused` | Task 11 payload + Task 7 paused check |
|
|
| §7 NAS 볼륨 `/mnt/nas/webpage/data/insta/` | Task 6 INSTA_MEDIA_ROOT |
|
|
| §8 internal webhook + X-Internal-Key + 3-layer 차단 | Task 1·2·14 |
|
|
| §9 키 분리 (X-WebAI-Key vs X-Internal-Key) | Task 1 (auth.py 독립) |
|
|
| §11 데이터 플로우 검증 시퀀스 | Task 15 |
|
|
|
|
### Placeholder 스캔
|
|
|
|
- 모든 step에 구체 명령·코드 포함 ✓
|
|
- "박재오NAS비밀번호" 같은 placeholder 자리 — `INTERNAL_API_KEY=$(openssl rand -hex 32)` 자동 생성 명령 명시 ✓
|
|
- 한 가지 예외: Task 15 Step 2의 `SLATE_ID=1`은 박재오 환경에 존재하는 임의 slate. 박재오가 실 ID로 교체 — 환경 의존 placeholder, 정상
|
|
|
|
### Type consistency
|
|
|
|
- `verify_internal_key` dependency — Task 1 정의, Task 2·14에서 사용 일치 ✓
|
|
- `UpdatePayload` Pydantic 모델 — Task 2 정의 필드(status, progress, result_path, error) ↔ Task 7 worker `_post_update` payload 키 일치 ✓
|
|
- Redis key `queue:insta-render`, `queue:paused` 모든 task에서 동일 명명 ✓
|
|
- env 변수 `REDIS_URL`, `INTERNAL_API_KEY`, `NAS_BASE_URL`, `INSTA_MEDIA_ROOT`, `INSTA_MEDIA_URL_PREFIX` — Task 5·7·9에서 일관 ✓
|
|
- `card_renderer.render_slate(slate, slate_id, template=)` 시그니처 — Task 6 정의, Task 7·10에서 동일 호출 ✓
|
|
|
|
### 위험·주의
|
|
|
|
| 위험 | 완화 |
|
|
|------|------|
|
|
| NAS·Windows INTERNAL_API_KEY 불일치 | Task 4·9에서 같은 값 명시 (openssl rand 1회 + 양쪽 copy) |
|
|
| Windows worker SMB 마운트 끊김 | fstab `_netdev,nofail` (Plan-B-Base Task 7) + worker가 webhook에 result_path를 nginx 경로로 보고 |
|
|
| templates 비동기 (NAS vs Windows) | 현재 manual sync. 변경 시 양쪽 commit. SMB share로 통합은 별도 plan |
|
|
| Chromium image 크기 (~500MB) | 첫 build만 시간 소요. 이후 layer cache 활용 |
|
|
| Phase 3 cutover 시 큐 worker 미존재 | Phase 2를 먼저 완료 + 검증 후 Phase 3. Task 순서로 강제됨 |
|
|
| webhook 401 (키 불일치) | Task 4 단계에서 키 검증 + Task 14 직후 curl로 401 확인 |
|
|
| Plan-B-Base의 SMB 마운트 의존 | Plan-B-Base 완료 검증됨 (이미 prerequisite) |
|
|
|
|
---
|
|
|
|
## 완료 후 다음 단계
|
|
|
|
Plan-B-Insta 완료 후 spec §14 권장 순서대로:
|
|
|
|
1. **Plan-B-Music** — SP-5 + SP-6 (Suno API + MusicGen Windows worker)
|
|
2. **Plan-B-Video** — SP-7 + SP-8 (외부 영상 API gateway + 새 video-lab 컨테이너)
|
|
3. **Plan-B-Infra** — SP-9 + SP-10 (NSSM 자동 시작 + task-watcher)
|
|
|
|
각 후속 plan은 본 plan과 같은 패턴 반복 (worker pattern §5). 첫 번째인 인스타가 가장 비용 큼; 음악·영상은 복붙 + 외부 API 변경.
|