diff --git a/5_django_backend/.gitignore b/5_django_backend/.gitignore index b37b3b1..09216a1 100644 --- a/5_django_backend/.gitignore +++ b/5_django_backend/.gitignore @@ -13,3 +13,5 @@ deploy/langfuse/docker-compose.override.yml # render_opencode.py 가 환경마다 만드는 파일 — 서버마다 다름 opencode/opencode.json +deploy/docker/.env +deploy/phoenix/.env diff --git a/5_django_backend/deploy.sh b/5_django_backend/deploy.sh index 7f9634c..0cd01cb 100644 --- a/5_django_backend/deploy.sh +++ b/5_django_backend/deploy.sh @@ -4,6 +4,8 @@ set -euo pipefail cd "$(dirname "$0")" export PYTHONUTF8=1 +# git 안이면 최신으로(고객사: /www/abap-ito). 로컬 수정이 있어 못 당기면 멈춤 — 렌더 산출물은 git 밖이라 안 걸림 +if git rev-parse --show-toplevel >/dev/null 2>&1; then git -C "$(git rev-parse --show-toplevel)" pull --ff-only; fi # 필수 키 비어 있으면 여기서 멈춤 — 비어 있는 채로 띄우면 채팅 때 "누락" 에러가 나서 원인 찾기 어려움 for k in AAF_FABRIX_BASE_URL AAF_FABRIX_CLIENT_KEY AAF_FABRIX_OPENAPI_TOKEN DB_HOST; do grep -qE "^$k=.+" .env || { echo "!! .env 에 $k 가 비어 있음 — 채우고 다시"; exit 1; } diff --git a/5_django_backend/deploy/docker/.env.example b/5_django_backend/deploy/docker/.env.example new file mode 100644 index 0000000..3f130fd --- /dev/null +++ b/5_django_backend/deploy/docker/.env.example @@ -0,0 +1,37 @@ +# CodeAssist 도커 배포 .env — 이 폴더에 .env 로 복사해서 채움. be 컨테이너 환경변수로 그대로 들어감. + +# 바깥 포트 (앱이 http://<서버>:/api/v1 로 붙음) +BE_PORT=8080 + +# LLM — 우리 환경은 OpenRouter 직접(고객사 FabriX 키 AAF_* 는 비움 → 렌더러가 OpenRouter 템플릿을 고름) +OPENROUTER_API_KEY= +OPENCODE_VISION_MODEL=openrouter/google/gemma-4-31b-it + +# 토큰 서명 키 (비우면 data/.auth_secret 자동 생성) +AUTH_SECRET= + +# DB — 비우면 SQLite(be_data 볼륨). 팀 PostgreSQL 쓰려면 채움 +DB_HOST= +DB_PORT=5432 +DB_USER= +DB_PASSWORD= +DB_NAME= +DB_SCHEMA=codeassist +DB_SSLMODE=prefer + +# 앱(Tauri)에서 오는 요청 허용 origin +CORS_ORIGINS=http://tauri.localhost,tauri://localhost,http://localhost:15173 + +# 채팅 타임아웃(초) +STREAM_FIRST_EVENT_TIMEOUT_S=300 +STREAM_TURN_TIMEOUT_S=600 + +# SAP 조회 MCP — 같은 서버의 abap-opencode compose 에 mcp 컨테이너가 있으면 그 주소. 비우면 끔 +MCP_HOST=mcp +MCP_PORT=3200 +MCP_API_KEY= + +# 관측(Phoenix) — deploy/phoenix 로 띄운 주소. 비우면 안 보냄 +PHOENIX_HOST=http://20.200.170.37:6006 +TRACE_PROJECT=codeassist +PHOENIX_UI_PASSWORD= diff --git a/5_django_backend/deploy/docker/Dockerfile.be b/5_django_backend/deploy/docker/Dockerfile.be new file mode 100644 index 0000000..69770ff --- /dev/null +++ b/5_django_backend/deploy/docker/Dockerfile.be @@ -0,0 +1,12 @@ +# CodeAssist 백엔드 — Django(ASGI/uvicorn). 기동 때 migrate → OpenCode 설정 렌더 → 서버. +FROM python:3.12-slim +WORKDIR /app +ENV PYTHONUTF8=1 PYTHONUNBUFFERED=1 DJANGO_SETTINGS_MODULE=config.settings +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY manage.py ./ +COPY config ./config +COPY apps ./apps +COPY common ./common +# opencode/ 는 compose 가 bind 로 끼움(/app/opencode) — 렌더 결과 opencode.json 을 opencode 컨테이너도 같은 폴더로 봄 +CMD ["sh", "-c", "python manage.py migrate --noinput && ([ -f seed/snippets.db ] && python manage.py import_snippets seed/snippets.db || true) && python opencode/render_opencode.py && uvicorn config.asgi:application --host 0.0.0.0 --port 8080"] diff --git a/5_django_backend/deploy/docker/Dockerfile.opencode b/5_django_backend/deploy/docker/Dockerfile.opencode new file mode 100644 index 0000000..5919d53 --- /dev/null +++ b/5_django_backend/deploy/docker/Dockerfile.opencode @@ -0,0 +1,9 @@ +# OpenCode 헤드리스 서버 — 고객사 opencode-bin 과 같은 1.18.6 으로 고정 +FROM node:22-bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git ripgrep \ + && rm -rf /var/lib/apt/lists/* +RUN npm install -g --no-audit --no-fund opencode-ai@1.18.6 +WORKDIR /workspace +EXPOSE 4096 +# .opencode/tools 의 커스텀 도구가 @opencode-ai/plugin 을 씀 — bind 된 workspace 라 기동 때 설치(있으면 건너뜀) +CMD ["sh", "-c", "cd /workspace/.opencode && npm install --no-audit --no-fund --silent >/dev/null 2>&1; cd /workspace && opencode serve --hostname 0.0.0.0 --port 4096"] diff --git a/5_django_backend/deploy/docker/README.md b/5_django_backend/deploy/docker/README.md new file mode 100644 index 0000000..393e19b --- /dev/null +++ b/5_django_backend/deploy/docker/README.md @@ -0,0 +1,35 @@ +# CodeAssist 백엔드 — 도커 배포 (우리 환경용) + +고객사(도커 없음)는 `5_django_backend/deploy.sh`(bare) 를 씀. 여긴 도커 되는 서버(20.200.170.37 등)용. +둘 다 같은 저장소 구조를 쓰고, 배포 스크립트만 다름: + +| 환경 | 스크립트 | 하는 일 | +|---|---|---| +| 우리(도커) | `deploy/docker/deploy.sh [up\|update\|stop\|logs\|status]` | (update 면 git pull) → 이미지 빌드 → be·opencode 기동 → 헬스 확인 | +| 고객사(bare) | `deploy.sh` | git pull → .env 키 보정 → migrate → 렌더 → opencode-bin·uvicorn 재기동 → 헬스 | + +## 처음 한 번 + +```bash +cd <저장소>/code-assistant/5_django_backend/deploy/docker # 고객사 repo 구조 기준. 우리 git 이면 5_django_backend/deploy/docker +cp .env.example .env && vi .env # OPENROUTER_API_KEY, PHOENIX_HOST 정도만 채우면 됨 +./deploy.sh +``` +컨테이너 2개: `be`(Django, 바깥 `BE_PORT`=8080) + `opencode`(OpenCode 1.18.6). 저장소의 `opencode/` 폴더를 둘 다 bind 로 봐서 +AGENTS.md·위키·커스텀 도구가 그대로 먹고, be 가 렌더한 `opencode.json` 을 opencode 가 바로 읽음. + +## 이후 + +```bash +./deploy.sh update # 최신 코드로 갱신 +./deploy.sh status # 상태 + 헬스 +./deploy.sh logs +``` + +## 알아둘 것 + +- LLM: `.env` 에 `AAF_FABRIX_*` 가 없으면 렌더러가 OpenRouter 템플릿(`opencode/opencode.openrouter.json.tmpl`)을 고름. 고객사는 FabriX 템플릿. +- DB: `DB_HOST` 비우면 SQLite(`be_data` 볼륨). 팀 PostgreSQL 쓰려면 DB_* 채움(스키마 `codeassist`). +- SAP 조회(MCP): 같은 서버에 abap-opencode compose 가 떠 있으면 그 `mcp` 컨테이너를 쓸 수 있음 — 두 compose 를 같은 네트워크로 묶어야 함(아직 안 함). 비우면 꺼짐. +- 관측: `PHOENIX_HOST=http://<서버>:6006` (`deploy/phoenix/docker-compose.yml` 로 띄운 것). 프로젝트 `codeassist`. +- 앱(Tauri) 쪽은 `2_frontend/.env.production` 의 `VITE_API_BASE_URL` 을 이 서버로 바꿔 빌드해야 붙음. diff --git a/5_django_backend/deploy/docker/deploy.sh b/5_django_backend/deploy/docker/deploy.sh new file mode 100644 index 0000000..5400398 --- /dev/null +++ b/5_django_backend/deploy/docker/deploy.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# CodeAssist 도커 배포 — 우리 환경용. 이거 하나면 됨. (ABAP_OPENCODE web/deploy/deploy.sh 와 같은 명령 모양) +# +# ./deploy.sh 빌드 + 기동 (떠 있으면 갱신) +# ./deploy.sh update git pull 까지 하고 갱신 +# ./deploy.sh stop 종료 (데이터 볼륨은 남음) +# ./deploy.sh logs 로그 +# ./deploy.sh status 상태 +# +# 전제: 이 폴더에 .env (cp .env.example .env 후 채움) +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)"; cd "$HERE" +CMD="${1:-up}" + +command -v docker >/dev/null || { echo "docker 없음"; exit 1; } +docker compose version >/dev/null 2>&1 || { echo "docker compose v2 없음"; exit 1; } +[ -f .env ] || { echo ".env 없음 — cp .env.example .env 후 채워"; exit 1; } +set -a; . ./.env; set +a +PORT="${BE_PORT:-8080}"; BASE="http://localhost:${PORT}/api/v1" + +case "$CMD" in + stop) docker compose down; echo "종료(볼륨 유지)"; exit 0 ;; + logs) docker compose logs -f --tail=100; exit 0 ;; + status) docker compose ps; curl -fsS -m 5 "$BASE/health" && echo || echo "헬스 응답 없음"; exit 0 ;; + update) git -C "$HERE/../../.." pull --ff-only || { echo "git pull 실패 — 로컬 변경 확인"; exit 1; } ;; + up) ;; + *) echo "모르는 명령: $CMD (up|update|stop|logs|status)"; exit 1 ;; +esac + +[ -n "${OPENROUTER_API_KEY:-}${AAF_FABRIX_BASE_URL:-}" ] || { echo ".env 에 OPENROUTER_API_KEY(또는 AAF_FABRIX_*) 가 비어 있음"; exit 1; } + +echo "-- 이미지 빌드 + 기동" +docker compose up -d --build +echo "-- 기동 확인(최대 2분)" +for _ in $(seq 1 60); do curl -fsS -m 3 "$BASE/health" >/dev/null 2>&1 && break; sleep 2; done +curl -fsS -m 5 "$BASE/health" || { echo "안 떴음 → ./deploy.sh logs"; docker compose ps; exit 1; } +echo +echo "-- OpenCode 설정(렌더 결과) 모델:"; grep -oE '"model": *"[^"]+"' ../../opencode/opencode.json || true +echo "-- 앱 주소: http://$(hostname -I 2>/dev/null | awk '{print $1}'):${PORT}/api/v1" diff --git a/5_django_backend/deploy/docker/docker-compose.yml b/5_django_backend/deploy/docker/docker-compose.yml new file mode 100644 index 0000000..3632650 --- /dev/null +++ b/5_django_backend/deploy/docker/docker-compose.yml @@ -0,0 +1,35 @@ +# CodeAssist 백엔드 — 우리 환경(도커)용. 고객사(도커 없음)는 5_django_backend/deploy.sh. +# 사용: 이 폴더에서 cp .env.example .env 채우고 ./deploy.sh +# 밖으로 여는 건 be(:8080 → BE_PORT) 하나. 앱(Tauri)이 http://<서버>:/api/v1 로 붙음. +name: codeassist + +services: + be: + build: { context: ../.., dockerfile: deploy/docker/Dockerfile.be } + ports: + - "${BE_PORT:-8080}:8080" + env_file: .env + environment: + OPENCODE_BASE_URL: http://opencode:4096 + OPENCODE_DIRECTORY: /workspace # OpenCode 컨테이너가 보는 workspace 경로 — 세션이 여기 생김 + BACKEND_HOST: be # 렌더된 opencode.json 의 게이트웨이 주소(고객사 모드일 때) + BACKEND_PORT: "8080" + MCP_HOST: ${MCP_HOST:-mcp} + volumes: + - ../../opencode:/app/opencode # AGENTS.md·.opencode·wiki·렌더 결과(opencode.json) + - be_data:/app/data # SQLite(DB_HOST 비울 때)·auth secret + depends_on: [opencode] + restart: unless-stopped + + opencode: + build: { context: ../.., dockerfile: deploy/docker/Dockerfile.opencode } + environment: + OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-} + volumes: + - ../../opencode:/workspace + - oc_data:/root/.local/share/opencode # 세션·메시지 저장 + restart: unless-stopped + +volumes: + be_data: + oc_data: diff --git a/5_django_backend/deploy/phoenix/docker-compose.yml b/5_django_backend/deploy/phoenix/docker-compose.yml new file mode 100644 index 0000000..bbbdbfd --- /dev/null +++ b/5_django_backend/deploy/phoenix/docker-compose.yml @@ -0,0 +1,22 @@ +# Phoenix — 우리 환경(도커)용. 한 대에 하나 띄우고 CodeAssist·ABAP_OPENCODE 둘 다 여기로 보냄(프로젝트로 나뉨). +# 고객사(도커 없음)는 같은 폴더의 run.sh(pip 방식). +# docker compose up -d → http://<서버>:6006 +name: phoenix + +services: + phoenix: + image: arizephoenix/phoenix:latest + ports: + - "${PHOENIX_PORT:-6006}:6006" + environment: + PHOENIX_WORKING_DIR: /data + PHOENIX_TELEMETRY_ENABLED: "false" + # 로그인 켜려면 둘 다 채움(첫 관리자 admin@localhost / PHOENIX_DEFAULT_ADMIN_INITIAL_PASSWORD). 켜면 수신에도 API 키 필요 — 모듈 헤더 한 줄 추가해야 함 + # PHOENIX_ENABLE_AUTH: "true" + # PHOENIX_SECRET: ${PHOENIX_SECRET} + volumes: + - phoenix_data:/data + restart: unless-stopped + +volumes: + phoenix_data: diff --git a/5_django_backend/opencode/opencode.fabrix.json.tmpl b/5_django_backend/opencode/opencode.fabrix.json.tmpl index 335b5d0..11401c9 100644 --- a/5_django_backend/opencode/opencode.fabrix.json.tmpl +++ b/5_django_backend/opencode/opencode.fabrix.json.tmpl @@ -6,7 +6,7 @@ "npm": "@ai-sdk/openai-compatible", "name": "FabriX", "options": { - "baseURL": "http://127.0.0.1:${BACKEND_PORT}/api/ito", + "baseURL": "http://${BACKEND_HOST}:${BACKEND_PORT}/api/ito", "apiKey": "${AAF_GATEWAY_KEY}" }, "models": ${AAF_FABRIX_MODELS_JSON} @@ -18,7 +18,7 @@ "mcp": { "sap-icf": { "type": "remote", - "url": "http://127.0.0.1:${MCP_PORT}/mcp", + "url": "http://${MCP_HOST}:${MCP_PORT}/mcp", "headers": { "Authorization": "Bearer ${MCP_API_KEY}" }, "enabled": ${SAP_MCP_ENABLED} } diff --git a/5_django_backend/opencode/opencode.openrouter.json.tmpl b/5_django_backend/opencode/opencode.openrouter.json.tmpl new file mode 100644 index 0000000..b9adcf3 --- /dev/null +++ b/5_django_backend/opencode/opencode.openrouter.json.tmpl @@ -0,0 +1,23 @@ +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "openrouter": { + "options": { "apiKey": "{env:OPENROUTER_API_KEY}" }, + "models": { + "z-ai/glm-5.2": { "name": "GLM 5.2" }, + "google/gemma-4-31b-it": { "name": "Gemma4", "attachment": true, "modalities": { "input": ["text", "image"], "output": ["text"] } } + } + } + }, + "model": "openrouter/z-ai/glm-5.2", + "tools": { "question": false }, + "permission": { "external_directory": "allow" }, + "mcp": { + "sap-icf": { + "type": "remote", + "url": "http://${MCP_HOST}:${MCP_PORT}/mcp", + "headers": { "Authorization": "Bearer ${MCP_API_KEY}" }, + "enabled": ${SAP_MCP_ENABLED} + } + } +} diff --git a/5_django_backend/opencode/render_opencode.py b/5_django_backend/opencode/render_opencode.py index a0b3c83..1e1f1eb 100644 --- a/5_django_backend/opencode/render_opencode.py +++ b/5_django_backend/opencode/render_opencode.py @@ -1,10 +1,11 @@ # -*- coding: utf-8 -*- -"""고객사(FabriX)용 opencode.json 만들기 — 템플릿의 ${이름} 을 .env 값으로 채움. +"""opencode.json 만들기 — 템플릿의 ${이름} 을 .env 값으로 채움. python opencode/render_opencode.py # ../.env 읽어서 opencode/opencode.json 덮어씀 python opencode/render_opencode.py --check # 쓰진 않고 결과만 출력 -개발(OpenRouter)로 돌아가려면 `git checkout opencode/opencode.json`. +템플릿 고르기: AAF_FABRIX_BASE_URL 이 있으면 고객사(FabriX 게이트웨이), 없으면 OpenRouter(우리 환경·로컬). +호스트: BACKEND_HOST/MCP_HOST 기본 127.0.0.1, 도커 compose 에선 be / mcp 컨테이너 이름. ABAP_OPENCODE web/deploy/bare/render-opencode.py 와 같은 규칙: AAF_FABRIX_MODELS="605:Gemma4,339:GaussO Flash,581:GaussO Think" → 모델 목록. 키가 곧 x-llm-model-id. 비어 있으면 AAF_FABRIX_MODEL_ID 하나(이름 FabriX). 기본 모델은 목록의 첫 번째. @@ -34,7 +35,7 @@ def load_env(path: Path) -> dict[str, str]: k, v = line.split("=", 1) v = v.split(" #", 1)[0].strip().strip("\"'") # 줄 끝 주석은 두 칸 띄고 # out[k.strip()] = v - out.update({k: v for k, v in os.environ.items() if k.startswith(("AAF_", "BACKEND_PORT", "MCP_", "OPENCODE_"))}) + out.update({k: v for k, v in os.environ.items() if k.startswith(("AAF_", "BACKEND_", "MCP_", "OPENCODE_", "OPENROUTER_"))}) return out @@ -53,7 +54,9 @@ def render(env: dict[str, str]) -> str: return m values["AAF_FABRIX_MODELS_JSON"] = json.dumps({mid: _model(mid, name) for mid, name in models.items()}, ensure_ascii=False) values["AAF_FABRIX_DEFAULT_MODEL"] = next(iter(models)) + values.setdefault("BACKEND_HOST", "127.0.0.1") values.setdefault("BACKEND_PORT", "8001") + values.setdefault("MCP_HOST", "127.0.0.1") values.setdefault("MCP_PORT", "8888") # SAP MCP: MCP_API_KEY 가 있으면 켬. 없으면 끔(로컬 개발) values["SAP_MCP_ENABLED"] = "true" if values.get("MCP_API_KEY") else "false" @@ -67,7 +70,8 @@ def render(env: dict[str, str]) -> str: missing.append(m.group(1)) return v - text = re.sub(r"\$\{(\w+)\}", fill, (HERE / "opencode.fabrix.json.tmpl").read_text(encoding="utf-8")) + tmpl = "opencode.fabrix.json.tmpl" if env.get("AAF_FABRIX_BASE_URL") else "opencode.openrouter.json.tmpl" + text = re.sub(r"\$\{(\w+)\}", fill, (HERE / tmpl).read_text(encoding="utf-8")) if missing: print("경고: 비어 있는 값 —", ", ".join(sorted(set(missing))), file=sys.stderr) return text diff --git a/docs/tech/customer-repo-layout.md b/docs/tech/customer-repo-layout.md index 3cb86a3..009d6d1 100644 --- a/docs/tech/customer-repo-layout.md +++ b/docs/tech/customer-repo-layout.md @@ -23,12 +23,13 @@ - `.env` 는 폴더마다 따로. 둘 다 gitignore. **커밋 전 `git status --short | grep '\.env$'` 가 비어야 함.** - `-12`(code-assistant) 와 `-13`(abap-specgen) 은 서로 통신 안 함. 각자 GHE 만 봄. -## 배포 루틴 +## 배포 루틴 — 환경별 스크립트 하나씩 -| VM | 명령 | -|---|---| -| -12 (code-assistant, 포트 8914→8080) | `cd /www/abap-ito && git pull && cd code-assistant/5_django_backend && bash deploy.sh` | -| -13 (abap-specgen, 포트 8918) | `cd /www/abap-ito && git pull && bash abap-specgen/web/deploy/bare/run.sh restart` | +| | 고객사(도커 없음, bare) | 우리 환경 20.200.170.37 (도커) | +|---|---|---| +| code-assistant | `cd /www/abap-ito/code-assistant/5_django_backend && bash deploy.sh` (안에서 git pull) | `cd /code-assistant/5_django_backend/deploy/docker && ./deploy.sh update` | +| abap-specgen | `bash /www/abap-ito/abap-specgen/web/deploy/bare/deploy.sh` (git pull → pip → restart) | `cd /abap-specgen/web/deploy && ./deploy.sh update` | +| Phoenix | `deploy/phoenix/run.sh` (pip, -12 안 8915) | `deploy/phoenix/docker-compose.yml` (`:6006`, 둘이 공유) | -13 에서 `git pull` 이 "local changes would be overwritten" 로 막히면 서버에서 직접 고친 파일이 있는 것: `git diff > /tmp/p.patch && git checkout -- . && git pull && git apply /tmp/p.patch`.