-
Notifications
You must be signed in to change notification settings - Fork 0
Feat/prod server #123
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feat/prod server #123
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| name: Issueissyu Prod CI/CD | ||
|
|
||
| on: | ||
| pull_request: | ||
| types: | ||
| - closed | ||
| workflow_dispatch: | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| deploy: | ||
| runs-on: ubuntu-latest | ||
| if: | | ||
| github.event_name == 'workflow_dispatch' || | ||
| (github.event.pull_request.merged == true && (github.event.pull_request.base.ref == 'prod' || github.event.pull_request.base.ref == 'test/CICD')) | ||
|
|
||
| steps: | ||
| - name: Checkout | ||
| uses: actions/checkout@v4 | ||
|
|
||
| - name: Create .env file from secret | ||
| env: | ||
| DEV_ENV: ${{ secrets.PROD_ENV }} | ||
| run: | | ||
| printf '%s' "$DEV_ENV" > .env | ||
| chmod 600 .env | ||
|
|
||
| - name: Get current time | ||
| uses: josStorer/get-current-time@v2 | ||
| id: current-time | ||
| with: | ||
| format: YYYY-MM-DDTHH:mm:ss | ||
| utcOffset: "+09:00" | ||
|
|
||
| - name: Generate deployment package | ||
| run: | | ||
| mkdir -p deploy | ||
| rsync -a \ | ||
| --exclude='.git' \ | ||
| --exclude='deploy' \ | ||
| --exclude='__pycache__' \ | ||
| --exclude='.venv' \ | ||
| --exclude='venv' \ | ||
| --exclude='.idea' \ | ||
| --exclude='.cursor' \ | ||
| --exclude='.github' \ | ||
| --exclude='*.pyc' \ | ||
| ./ deploy/ | ||
| (cd deploy && zip -r deploy.zip .) | ||
|
|
||
| - name: Deploy to Elastic Beanstalk | ||
| uses: einaregilsson/beanstalk-deploy@v22 | ||
| with: | ||
| aws_access_key: ${{ secrets.AWS_PROD_ACCESS_KEY }} | ||
| aws_secret_key: ${{ secrets.AWS_PROD_SECRET_KEY }} | ||
| application_name: 'issueissyu-ai-prod' | ||
| environment_name: 'issueissyu-ai-env' | ||
| region: 'ap-northeast-2' | ||
| version_label: github-action-${{ steps.current-time.outputs.formattedTime }} | ||
| version_description: ${{ github.ref_name }}@${{ github.sha }} | ||
| deployment_package: deploy/deploy.zip | ||
| wait_for_deployment: false |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,12 +1,18 @@ | ||||||||||||||||||||||||||||||
| from fastapi import APIRouter, File, UploadFile | ||||||||||||||||||||||||||||||
| import io | ||||||||||||||||||||||||||||||
| from pathlib import Path | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| from fastapi import APIRouter, File, Query, UploadFile | ||||||||||||||||||||||||||||||
| from fastapi.responses import StreamingResponse | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| from app.core.deps import CurrentUserIdDep, S3UtilDep, UserServiceDep | ||||||||||||||||||||||||||||||
| from app.core.codes import ErrorCode, SuccessCode | ||||||||||||||||||||||||||||||
| from app.core.exceptions import raise_business_exception | ||||||||||||||||||||||||||||||
| from app.core.exceptions import raise_business_exception, raise_file_exception | ||||||||||||||||||||||||||||||
| from app.core.responses import success_response | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| router = APIRouter(prefix="/test", tags=["test"]) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| _DEFAULT_S3_TEST_PREFIX = "test-files" | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| @router.post("/s3/image-upload") | ||||||||||||||||||||||||||||||
| async def upload_test_image_to_s3( | ||||||||||||||||||||||||||||||
|
|
@@ -17,6 +23,61 @@ async def upload_test_image_to_s3( | |||||||||||||||||||||||||||||
| return success_response(result=uploaded, success_code=SuccessCode.CREATED) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| @router.post("/s3/upload") | ||||||||||||||||||||||||||||||
| async def upload_test_file_to_s3( | ||||||||||||||||||||||||||||||
| s3_util: S3UtilDep, | ||||||||||||||||||||||||||||||
| file: UploadFile = File(...), | ||||||||||||||||||||||||||||||
| prefix: str = Query(default=_DEFAULT_S3_TEST_PREFIX, description="S3 object key prefix"), | ||||||||||||||||||||||||||||||
| ): | ||||||||||||||||||||||||||||||
| """이미지·PDF 등 임의 파일 업로드 테스트. 응답의 key로 다운로드 API를 호출할 수 있습니다.""" | ||||||||||||||||||||||||||||||
| raw = await file.read() | ||||||||||||||||||||||||||||||
| if not raw: | ||||||||||||||||||||||||||||||
| raise_file_exception( | ||||||||||||||||||||||||||||||
| ErrorCode.FILE_UPLOAD_ERROR, | ||||||||||||||||||||||||||||||
| detail="업로드할 파일이 비어 있습니다.", | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| filename = (file.filename or "upload.bin").strip() or "upload.bin" | ||||||||||||||||||||||||||||||
| content_type = (file.content_type or "application/octet-stream").split(";")[0].strip() | ||||||||||||||||||||||||||||||
| normalized_prefix = prefix.strip("/") or _DEFAULT_S3_TEST_PREFIX | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| uploaded = await s3_util.upload_binary( | ||||||||||||||||||||||||||||||
| raw, | ||||||||||||||||||||||||||||||
| filename=filename, | ||||||||||||||||||||||||||||||
| content_type=content_type, | ||||||||||||||||||||||||||||||
| prefix=normalized_prefix, | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
| return success_response(result=uploaded, success_code=SuccessCode.CREATED) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| @router.get("/s3/download") | ||||||||||||||||||||||||||||||
| async def download_test_file_from_s3( | ||||||||||||||||||||||||||||||
| s3_util: S3UtilDep, | ||||||||||||||||||||||||||||||
| key: str = Query(..., min_length=1, description="S3 object key (업로드 응답의 key)"), | ||||||||||||||||||||||||||||||
| ): | ||||||||||||||||||||||||||||||
| """S3 object key로 파일 다운로드 테스트.""" | ||||||||||||||||||||||||||||||
| data, content_type = await s3_util.download_binary(key) | ||||||||||||||||||||||||||||||
| filename = Path(key).name or "download.bin" | ||||||||||||||||||||||||||||||
| return StreamingResponse( | ||||||||||||||||||||||||||||||
| io.BytesIO(data), | ||||||||||||||||||||||||||||||
| media_type=content_type or "application/octet-stream", | ||||||||||||||||||||||||||||||
| headers={"Content-Disposition": f'attachment; filename="{filename}"'}, | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
|
Comment on lines
+60
to
+65
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 다운로드할 파일명에 한글 등 비ASCII 문자가 포함되어 있을 경우, Content-Disposition 헤더의 filename="..." 포맷은 브라우저에 따라 파일명이 깨지거나 헤더 파싱 에러를 유발할 수 있습니다. RFC 5987 표준에 따라 filename*=utf-8''... 포맷을 사용하고 파일명을 퍼센트 인코딩(urllib.parse.quote)하는 것이 안전합니다.
Suggested change
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| @router.delete("/s3/object") | ||||||||||||||||||||||||||||||
| async def delete_test_file_from_s3( | ||||||||||||||||||||||||||||||
| s3_util: S3UtilDep, | ||||||||||||||||||||||||||||||
| key: str = Query(..., min_length=1, description="삭제할 S3 object key"), | ||||||||||||||||||||||||||||||
| ): | ||||||||||||||||||||||||||||||
| """업로드 테스트 후 S3 객체 삭제.""" | ||||||||||||||||||||||||||||||
| deleted = await s3_util.delete_object(key) | ||||||||||||||||||||||||||||||
| return success_response( | ||||||||||||||||||||||||||||||
| result={"key": key, "deleted": deleted}, | ||||||||||||||||||||||||||||||
| success_code=SuccessCode.OK, | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| @router.get("/user") | ||||||||||||||||||||||||||||||
| async def get_my_user(user_service: UserServiceDep, uid: CurrentUserIdDep): | ||||||||||||||||||||||||||||||
| user = await user_service.get_user(uid) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -19,6 +19,13 @@ | |||||||||
|
|
||||||||||
| logger = logging.getLogger(__name__) | ||||||||||
|
|
||||||||||
| DEFAULT_HNSW_KWARGS: dict[str, Any] = { | ||||||||||
| "hnsw_m": 16, | ||||||||||
| "hnsw_ef_construction": 64, | ||||||||||
| "hnsw_ef_search": 40, | ||||||||||
| "hnsw_dist_method": "vector_cosine_ops", | ||||||||||
| } | ||||||||||
|
|
||||||||||
| @dataclass(slots=True) | ||||||||||
| class _VectorIndexBundle: | ||||||||||
| table_name: str | ||||||||||
|
|
@@ -39,6 +46,7 @@ def __init__( | |||||||||
| hybrid_search: bool = True, | ||||||||||
| text_search_config: str = "simple", | ||||||||||
| embedding_batch_size_override: int | None = None, | ||||||||||
| hnsw_kwargs: dict[str, Any] | None = None, | ||||||||||
| ) -> None: | ||||||||||
| self._sync_database_url = sync_database_url | ||||||||||
| self._async_database_url = async_database_url | ||||||||||
|
|
@@ -52,6 +60,8 @@ def __init__( | |||||||||
| self._domain_configs = domain_configs or {} | ||||||||||
| self._embed_models: dict[tuple[str, int], BaseEmbedding] = {} | ||||||||||
| self._embedding_batch_size_override = embedding_batch_size_override | ||||||||||
| self._hnsw_kwargs = hnsw_kwargs or dict(DEFAULT_HNSW_KWARGS) | ||||||||||
| logger.info("Vector HNSW config: %s", self._hnsw_kwargs) | ||||||||||
|
Comment on lines
+63
to
+64
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. hnsw_kwargs가 제공될 때 DEFAULT_HNSW_KWARGS를 완전히 대체(override)하게 됩니다. 만약 호출부에서 일부 키(예: {"hnsw_m": 24})만 전달할 경우, 나머지 필수 설정 키들이 누락되어 에러가 발생하거나 기본값이 적용되지 않을 수 있습니다. 기본값 딕셔너리와 입력받은 딕셔너리를 병합(merge)하도록 수정하는 것이 안전합니다.
Suggested change
|
||||||||||
|
|
||||||||||
| @staticmethod | ||||||||||
| def _normalize_domain(domain: str) -> str: | ||||||||||
|
|
@@ -123,12 +133,7 @@ def _get_or_create_bundle( | |||||||||
| embed_dim=embed_dim, | ||||||||||
| hybrid_search=self._hybrid_search, | ||||||||||
| text_search_config=self._text_search_config, | ||||||||||
| hnsw_kwargs={ | ||||||||||
| "hnsw_m": 16, | ||||||||||
| "hnsw_ef_construction": 64, | ||||||||||
| "hnsw_ef_search": 40, | ||||||||||
| "hnsw_dist_method": "vector_cosine_ops", | ||||||||||
| }, | ||||||||||
| hnsw_kwargs=self._hnsw_kwargs, | ||||||||||
| ) | ||||||||||
| bundle = _VectorIndexBundle( | ||||||||||
| table_name=resolved_table_name, | ||||||||||
|
|
||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -105,6 +105,9 @@ def _build_public_file_url(self, object_key: str) -> str: | |||||||||||||||||
| bucket_name = self._ensure_bucket_name() | ||||||||||||||||||
| resolved_region = self.region_name or "us-east-1" | ||||||||||||||||||
| encoded_key = quote(object_key.lstrip("/"), safe="/") | ||||||||||||||||||
| cdn_base_url = (settings.cdn_base_url or "").strip() | ||||||||||||||||||
| if settings.cdn_enabled and cdn_base_url: | ||||||||||||||||||
| return f"{cdn_base_url.rstrip('/')}/{encoded_key}" | ||||||||||||||||||
|
Comment on lines
+108
to
+110
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. CDN_ENABLED가 True로 설정되어 있지만 CDN_BASE_URL이 비어 있는 경우, 의도치 않은 설정 오류일 가능성이 높습니다. 이 경우 S3 URL로 폴백하기 전에 경고 로그를 남겨두면 운영 환경에서 문제를 디버깅하는 데 큰 도움이 됩니다.
Suggested change
|
||||||||||||||||||
| if resolved_region == "us-east-1": | ||||||||||||||||||
| return f"https://{bucket_name}.s3.amazonaws.com/{encoded_key}" | ||||||||||||||||||
| return f"https://{bucket_name}.s3.{resolved_region}.amazonaws.com/{encoded_key}" | ||||||||||||||||||
|
|
||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import unittest | ||
|
|
||
| from app.routes import get_enabled_routers | ||
|
|
||
|
|
||
| def _router_tags(env: str) -> set[str]: | ||
| return {router.tags[0] for router in get_enabled_routers(env) if router.tags} | ||
|
|
||
|
|
||
| class RouterRegistryTest(unittest.TestCase): | ||
| def test_prod_excludes_pipeline_and_debug_routes(self) -> None: | ||
| tags = _router_tags("prod") | ||
|
|
||
| self.assertNotIn("complaint-email", tags) | ||
| self.assertNotIn("geo", tags) | ||
| self.assertNotIn("festival-pins", tags) | ||
| self.assertNotIn("contest-pins", tags) | ||
| self.assertNotIn("policy-pins", tags) | ||
| self.assertNotIn("test", tags) | ||
| self.assertNotIn("vector-test", tags) | ||
|
|
||
| def test_prod_includes_core_and_admin_routes(self) -> None: | ||
| tags = _router_tags("prod") | ||
|
|
||
| self.assertIn("issue", tags) | ||
| self.assertIn("complaint-apply", tags) | ||
| self.assertIn("festival-admin", tags) | ||
| self.assertIn("contest-admin", tags) | ||
| self.assertIn("policy-admin", tags) | ||
| self.assertIn("users", tags) | ||
|
|
||
| def test_dev_includes_complaint_email_and_geo(self) -> None: | ||
| tags = _router_tags("dev") | ||
|
|
||
| self.assertIn("complaint-email", tags) | ||
| self.assertIn("geo", tags) | ||
| self.assertIn("festival-pins", tags) | ||
| self.assertIn("contest-admin", tags) | ||
| self.assertIn("policy-admin", tags) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CDN_BASE_URL이 설정되었을 때, 올바른 URL 형식(예: http:// 또는 https://로 시작)인지 검증하는 로직을 추가하는 것이 안전합니다. 잘못된 형식으로 설정되면 서비스 전체에서 생성되는 퍼블릭 URL이 깨질 수 있습니다.