-
Notifications
You must be signed in to change notification settings - Fork 0
[deploy] prod 서버 배포 #124
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
[deploy] prod 서버 배포 #124
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. S3에서 다운로드할 파일의 이름( RFC 5987 및 RFC 6266 표준에 따라 파일명을 URL 인코딩( filename = Path(key).name or "download.bin"
import urllib.parse
encoded_filename = urllib.parse.quote(filename)
return StreamingResponse(
io.BytesIO(data),
media_type=content_type or "application/octet-stream",
headers={
"Content-Disposition": f"attachment; filename=\"{encoded_filename}\"; filename*=UTF-8''{encoded_filename}"
},
) |
||
|
|
||
|
|
||
| @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) | ||||||
|
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.
Suggested change
|
||||||
| logger.info("Vector HNSW config: %s", self._hnsw_kwargs) | ||||||
|
|
||||||
| @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 |
|---|---|---|
| @@ -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.
vector_hnsw_dist_method필드는str타입으로 정의되어 있으므로, 만약 환경 변수나 설정 값에서None이 명시적으로 전달될 경우isinstance(value, str)조건에 걸리지 않아None을 그대로 반환하게 됩니다. 이 경우 Pydantic 검증 단계에서ValidationError가 발생할 수 있습니다.안전한 방어적 프로그래밍을 위해
value가None인 경우에도 기본값인"vector_cosine_ops"를 반환하도록 처리하는 것이 좋습니다.