diff --git a/docs/superpowers/plans/2026-05-19-plan-b-insta-render.md b/docs/superpowers/plans/2026-05-19-plan-b-insta-render.md new file mode 100644 index 0000000..3ab2f46 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-plan-b-insta-render.md @@ -0,0 +1,1887 @@ +# 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) +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) +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) +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) +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) +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) +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) +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) +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) +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) +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) +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) +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) +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 변경.