diff --git a/.github/workflows/deploy-dev.yaml.yml b/.github/workflows/deploy-dev.yaml.yml new file mode 100644 index 0000000..b7786db --- /dev/null +++ b/.github/workflows/deploy-dev.yaml.yml @@ -0,0 +1,20 @@ +name: Deploy to Dev + +on: + push: + branches: + - 'dev-*' # dev-0.1.2 등 버전 브랜치 푸시 시 동작 + +jobs: + call-dev: + permissions: + contents: write + packages: write + pull-requests: write + uses: M-ADP/M-ADP-GITHUB-ACTIONS/.github/workflows/dev.yaml@master + with: + deploy_repo: "M-ADP/M-ADP-ARGOCD" + secrets: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + PERSONAL_TOKEN: ${{ secrets.PERSONAL_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/rollback-dev.yaml.yml b/.github/workflows/rollback-dev.yaml.yml new file mode 100644 index 0000000..7ad1446 --- /dev/null +++ b/.github/workflows/rollback-dev.yaml.yml @@ -0,0 +1,23 @@ +name: Rollback Dev Environment + +on: + workflow_dispatch: + inputs: + version: + description: 'Deploy version to restore (e.g. v1.0.0, hotfix-1)' + required: true + type: string + +jobs: + call-rollback: + permissions: + contents: write + packages: write + pull-requests: write + uses: M-ADP/M-ADP-GITHUB-ACTIONS/.github/workflows/rollback.yaml@master + with: + target_env: "dev" + target_version: ${{ inputs.version }} + deploy_repo: "M-ADP/M-ADP-ARGOCD" + secrets: + PERSONAL_TOKEN: ${{ secrets.PERSONAL_TOKEN }} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..05dad6d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application source +COPY . . + +ENV TZ=Asia/Seoul + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/docs/api.md b/docs/api.md index c24af6f..8adbbca 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,8 +1,8 @@ # MADP Project Service API 문서 ## 요약 -- Base Path는 `/v1`이며 Health Check는 `/health`입니다. -- `/v1` 이하 모든 엔드포인트는 헤더 `user-id`, `role`이 필요합니다. +- Base Path는 `/`이며 Health Check는 `/health`입니다. +- `/` 이하 모든 엔드포인트는 헤더 `user-id`, `role`이 필요합니다. - 주요 리소스는 Projects, Ports, DNS이며 공통 응답 형식과 페이지네이션 규칙을 공유합니다. - 공통 에러는 422(Validation)와 500(Internal Server Error)입니다. @@ -15,33 +15,35 @@ - [Health (상태 확인)](#health) - [GET /health (서비스 상태)](#get-health) - [Projects (프로젝트)](#projects) - - [POST /v1/projects (프로젝트 생성)](#post-v1projects) - - [GET /v1/projects (프로젝트 목록 조회)](#get-v1projects) - - [GET /v1/projects/{project_id} (프로젝트 상세 조회)](#get-v1projectsproject_id) - - [PATCH /v1/projects/{project_id}/name (프로젝트 이름 변경)](#patch-v1projectsproject_idname) - - [PATCH /v1/projects/{project_id}/resource (프로젝트 리소스 변경)](#patch-v1projectsproject_idresource) - - [DELETE /v1/projects/{project_id} (프로젝트 삭제)](#delete-v1projectsproject_id) + - [POST /projects (프로젝트 생성)](#post-projects) + - [GET /projects (프로젝트 목록 조회)](#get-projects) + - [GET /projects/owner (프로젝트 소유자 여부 조회)](#get-projectsowner) + - [GET /projects/resource-limit (프로젝트 최대 리소스 조회)](#get-projectsresource-limit) + - [GET /projects/{project_id} (프로젝트 상세 조회)](#get-projectsproject_id) + - [PATCH /projects/{project_id}/name (프로젝트 이름 변경)](#patch-projectsproject_idname) + - [PATCH /projects/{project_id}/resource (프로젝트 리소스 변경)](#patch-projectsproject_idresource) + - [DELETE /projects/{project_id} (프로젝트 삭제)](#delete-projectsproject_id) - [Project Members (프로젝트 멤버)](#project-members) - - [GET /v1/projects/{project_id}/members (멤버 목록 조회)](#get-v1projectsproject_idmembers) - - [POST /v1/projects/{project_id}/members (멤버 추가)](#post-v1projectsproject_idmembers) - - [DELETE /v1/projects/{project_id}/members/{target_user_id} (멤버 제거)](#delete-v1projectsproject_idmemberstarget_user_id) - - [PATCH /v1/projects/{project_id}/owner (소유권 이전)](#patch-v1projectsproject_idowner) + - [GET /projects/{project_id}/members (멤버 목록 조회)](#get-projectsproject_idmembers) + - [POST /projects/{project_id}/members (멤버 추가)](#post-projectsproject_idmembers) + - [DELETE /projects/{project_id}/members/{target_user_id} (멤버 제거)](#delete-projectsproject_idmemberstarget_user_id) + - [PATCH /projects/{project_id}/owner (소유권 이전)](#patch-projectsproject_idowner) - [Ports (포트)](#ports) - - [POST /v1/projects/{project_id}/ports (포트 공개)](#post-v1projectsproject_idports) - - [GET /v1/projects/{project_id}/ports (포트 목록 조회)](#get-v1projectsproject_idports) - - [PUT /v1/projects/{project_id}/ports/{port_id} (포트 수정)](#put-v1projectsproject_idportsport_id) - - [DELETE /v1/projects/{project_id}/ports/{port_id} (포트 삭제)](#delete-v1projectsproject_idportsport_id) + - [POST /projects/{project_id}/ports (포트 공개)](#post-projectsproject_idports) + - [GET /projects/{project_id}/ports (포트 목록 조회)](#get-projectsproject_idports) + - [PUT /projects/{project_id}/ports/{port_id} (포트 수정)](#put-projectsproject_idportsport_id) + - [DELETE /projects/{project_id}/ports/{port_id} (포트 삭제)](#delete-projectsproject_idportsport_id) - [DNS (도메인)](#dns) - - [POST /v1/projects/{project_id}/dns (DNS 생성)](#post-v1projectsproject_iddns) - - [GET /v1/projects/{project_id}/dns-records (DNS 조회)](#get-v1projectsproject_iddns-records) - - [DELETE /v1/projects/{project_id}/dns-records/{dns_id} (DNS 삭제)](#delete-v1projectsproject_iddns-recordsdns_id) - - [PATCH /v1/projects/{project_id}/dns-records/{dns_id} (DNS 서브도메인 변경)](#patch-v1projectsproject_iddns-recordsdns_id) - - [PATCH /v1/projects/{project_id}/dns-records/{dns_id}/port (DNS에 포트 바인딩)](#patch-v1projectsproject_iddns-recordsdns_idport) + - [POST /projects/{project_id}/dns (DNS 생성)](#post-projectsproject_iddns) + - [GET /projects/{project_id}/dns-records (DNS 조회)](#get-projectsproject_iddns-records) + - [DELETE /projects/{project_id}/dns-records/{dns_id} (DNS 삭제)](#delete-projectsproject_iddns-recordsdns_id) + - [PATCH /projects/{project_id}/dns-records/{dns_id} (DNS 서브도메인 변경)](#patch-projectsproject_iddns-recordsdns_id) + - [PATCH /projects/{project_id}/dns-records/{dns_id}/port (DNS에 포트 바인딩)](#patch-projectsproject_iddns-recordsdns_idport) ## 개요 -- Base Path: `/v1` +- Base Path: `/` - Health Check: `/health` -- 인증/인가: `/v1` 이하 모든 엔드포인트는 헤더 `user-id`, `role` 필요 +- 인증/인가: `/` 이하 모든 엔드포인트는 헤더 `user-id`, `role` 필요 ## 공통 응답 형식 @@ -99,7 +101,7 @@ ## Projects -### POST /v1/projects +### POST /projects 프로젝트 생성 요청 바디: @@ -139,7 +141,7 @@ --- -### GET /v1/projects +### GET /projects 프로젝트 목록 조회 쿼리 파라미터: `cursor`, `limit` @@ -169,7 +171,7 @@ --- -### GET /v1/projects/{project_id} +### GET /projects/{project_id} 프로젝트 상세 조회 응답 데이터: `ProjectDetailResponse` @@ -220,7 +222,56 @@ --- -### PATCH /v1/projects/{project_id}/name +### GET /projects/owner +프로젝트 소유자 여부 조회 + +쿼리 파라미터: +- `project_id` (integer, 필수): 확인할 프로젝트 ID + +권한: +- 요청 사용자(`X-User-Id`) 기준으로 소유자 여부 확인 + +응답 데이터: +```json +{ + "message": "프로젝트 소유자 여부를 조회했습니다.", + "data": { + "status": true + } +} +``` + +--- + +### GET /projects/resource-limit +프로젝트 최대 리소스 한도 조회 (App SVC 내부 연동용) + +쿼리 파라미터: +- `project_id` (integer, 필수): 조회할 프로젝트 ID + +권한: +- 프로젝트 OWNER만 조회 가능 + +응답 데이터: +```json +{ + "message": "프로젝트 최대 리소스 한도를 조회했습니다.", + "data": { + "project_id": 1234567890123, + "max_cpu": 2.0, + "max_memory": 1024.0, + "max_disk": 2048.0 + } +} +``` + +에러: +- 404: "프로젝트를 찾을 수 없습니다." +- 403: "프로젝트 소유자만 리소스 한도를 조회할 수 있습니다." + +--- + +### PATCH /projects/{project_id}/name 프로젝트 이름 변경 요청 바디: @@ -241,7 +292,7 @@ --- -### PATCH /v1/projects/{project_id}/resource +### PATCH /projects/{project_id}/resource 프로젝트 리소스 변경 요청 바디 (모두 선택): @@ -269,7 +320,7 @@ --- -### DELETE /v1/projects/{project_id} +### DELETE /projects/{project_id} 프로젝트 삭제 응답 데이터: `ProjectResponse` @@ -288,7 +339,7 @@ - 소유권은 기존 OWNER가 같은 프로젝트의 MEMBER에게만 이전할 수 있습니다. - 프로젝트 내 OWNER는 항상 1명입니다. -### GET /v1/projects/{project_id}/members +### GET /projects/{project_id}/members 프로젝트 멤버 목록 조회 쿼리 파라미터: `cursor`, `limit` @@ -321,7 +372,7 @@ --- -### POST /v1/projects/{project_id}/members +### POST /projects/{project_id}/members 프로젝트에 멤버 추가 (사용자 초대) 요청 바디: @@ -356,7 +407,7 @@ --- -### DELETE /v1/projects/{project_id}/members/{target_user_id} +### DELETE /projects/{project_id}/members/{target_user_id} 프로젝트에서 멤버 제거 응답 데이터: `ProjectMemberResponse` @@ -369,7 +420,7 @@ --- -### PATCH /v1/projects/{project_id}/owner +### PATCH /projects/{project_id}/owner 프로젝트 소유권 이전 요청 바디: @@ -395,7 +446,7 @@ ## Ports -### POST /v1/projects/{project_id}/ports +### POST /projects/{project_id}/ports 포트 공개 요청 바디: @@ -425,7 +476,7 @@ --- -### GET /v1/projects/{project_id}/ports +### GET /projects/{project_id}/ports 포트 목록 조회 쿼리 파라미터: `cursor`, `limit` @@ -437,7 +488,7 @@ --- -### PUT /v1/projects/{project_id}/ports/{port_id} +### PUT /projects/{project_id}/ports/{port_id} 포트 수정 요청 바디 (`PortCreate`와 동일): @@ -468,7 +519,7 @@ --- -### DELETE /v1/projects/{project_id}/ports/{port_id} +### DELETE /projects/{project_id}/ports/{port_id} 포트 삭제 응답 데이터: `PortResponse` @@ -484,7 +535,7 @@ ## DNS -### POST /v1/projects/{project_id}/dns +### POST /projects/{project_id}/dns DNS 생성 요청 바디: @@ -518,7 +569,7 @@ DNS 생성 --- -### GET /v1/projects/{project_id}/dns-records +### GET /projects/{project_id}/dns-records DNS 조회 응답 데이터: `DNSResponse | null` @@ -528,7 +579,7 @@ DNS 조회 --- -### DELETE /v1/projects/{project_id}/dns-records/{dns_id} +### DELETE /projects/{project_id}/dns-records/{dns_id} DNS 삭제 응답 데이터: `DNSResponse` @@ -542,7 +593,7 @@ DNS 삭제 --- -### PATCH /v1/projects/{project_id}/dns-records/{dns_id} +### PATCH /projects/{project_id}/dns-records/{dns_id} DNS 서브도메인 변경 요청 바디: @@ -567,7 +618,7 @@ DNS 서브도메인 변경 --- -### PATCH /v1/projects/{project_id}/dns-records/{dns_id}/port +### PATCH /projects/{project_id}/dns-records/{dns_id}/port DNS에 포트 바인딩 요청 바디: diff --git a/main.py b/main.py index 530a12f..24e04af 100644 --- a/main.py +++ b/main.py @@ -1,3 +1,10 @@ +import logging + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s - %(message)s", +) + from src.api import create_app app = create_app() diff --git a/src/api/__init__.py b/src/api/__init__.py index b502713..e1043dd 100644 --- a/src/api/__init__.py +++ b/src/api/__init__.py @@ -1,7 +1,7 @@ from fastapi import FastAPI from src.api.routers import api_router -from src.common.config.settings import get_app_config +from src.common.config.settings import get_app_config, load_all_configs from src.core.db import create_all_tables from src.core.exceptions import register_exception_handlers @@ -14,6 +14,7 @@ def create_app() -> FastAPI: @application.on_event("startup") async def startup_event() -> None: + load_all_configs() await create_all_tables() return application diff --git a/src/api/routers/__init__.py b/src/api/routers/__init__.py index 044df3e..2ee47ef 100644 --- a/src/api/routers/__init__.py +++ b/src/api/routers/__init__.py @@ -1,8 +1,8 @@ from fastapi import APIRouter from src.api.routers import health -from src.api.routers.v1 import v1_api_router +from src.api.routers.routes import routes_api_router api_router = APIRouter() api_router.include_router(health.router, tags=["health"]) -api_router.include_router(v1_api_router) +api_router.include_router(routes_api_router) diff --git a/src/api/routers/routes/__init__.py b/src/api/routers/routes/__init__.py new file mode 100644 index 0000000..680ebdb --- /dev/null +++ b/src/api/routers/routes/__init__.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +from src.api.routers.routes import dns, ports, projects + +routes_api_router = APIRouter() +routes_api_router.include_router(projects.router) +routes_api_router.include_router(ports.router) +routes_api_router.include_router(dns.router) diff --git a/src/api/routers/v1/dns.py b/src/api/routers/routes/dns.py similarity index 100% rename from src/api/routers/v1/dns.py rename to src/api/routers/routes/dns.py diff --git a/src/api/routers/v1/ports.py b/src/api/routers/routes/ports.py similarity index 100% rename from src/api/routers/v1/ports.py rename to src/api/routers/routes/ports.py diff --git a/src/api/routers/v1/projects.py b/src/api/routers/routes/projects.py similarity index 85% rename from src/api/routers/v1/projects.py rename to src/api/routers/routes/projects.py index 4496708..09cc194 100644 --- a/src/api/routers/v1/projects.py +++ b/src/api/routers/routes/projects.py @@ -1,5 +1,4 @@ -from fastapi import APIRouter, Depends, Query, status -from fastapi.responses import JSONResponse +from fastapi import APIRouter, Depends, Query from src.app.project.schemas import ( ProjectAvailableResponse, @@ -11,6 +10,7 @@ ProjectNameUpdate, ProjectOwnerResponse, ProjectOwnerTransfer, + ProjectResourceLimitResponse, ProjectResourceUpdate, ProjectResponse, ) @@ -27,6 +27,7 @@ TransferProjectOwnershipUseCase, CheckProjectAvailableUseCase, CheckProjectOwnerUseCase, + GetProjectResourceLimitUseCase, ) from src.dependencies.auth import UserInfo, get_user_info from src.common.schemas import CursorPage, SuccessResponse @@ -74,40 +75,52 @@ async def list_projects_endpoint( @router.get( "/available", - status_code=status.HTTP_200_OK, - response_model=ProjectAvailableResponse, + status_code=200, + response_model=SuccessResponse[ProjectAvailableResponse], ) async def check_project_available_endpoint( project_id: int = Query(..., description="확인할 프로젝트 ID"), user: UserInfo = Depends(get_user_info), usecase: CheckProjectAvailableUseCase = Depends(CheckProjectAvailableUseCase), -) -> JSONResponse: +) -> SuccessResponse[ProjectAvailableResponse]: is_member = await usecase(project_id=project_id, user_id=user.user_id) - if is_member: - return JSONResponse( - status_code=status.HTTP_200_OK, - content=ProjectAvailableResponse(status=True).model_dump(), - ) - return JSONResponse( - status_code=status.HTTP_403_FORBIDDEN, - content=ProjectAvailableResponse(status=False).model_dump(), + return SuccessResponse( + message="프로젝트 접근 가능 여부를 조회했습니다.", + data=ProjectAvailableResponse(status=is_member), ) @router.get( "/owner", - status_code=status.HTTP_200_OK, - response_model=ProjectOwnerResponse, + status_code=200, + response_model=SuccessResponse[ProjectOwnerResponse], ) async def check_project_owner_endpoint( project_id: int = Query(..., description="확인할 프로젝트 ID"), - user_id: int = Query(..., description="소유자 여부를 확인할 사용자 ID"), + user: UserInfo = Depends(get_user_info), usecase: CheckProjectOwnerUseCase = Depends(CheckProjectOwnerUseCase), -) -> JSONResponse: - is_owner = await usecase(project_id=project_id, user_id=user_id) - return JSONResponse( - status_code=status.HTTP_200_OK, - content=ProjectOwnerResponse(status=is_owner).model_dump(), +) -> SuccessResponse[ProjectOwnerResponse]: + is_owner = await usecase(project_id=project_id, user_id=user.user_id) + return SuccessResponse( + message="프로젝트 소유자 여부를 조회했습니다.", + data=ProjectOwnerResponse(status=is_owner), + ) + + +@router.get( + "/resource-limit", + status_code=200, + response_model=SuccessResponse[ProjectResourceLimitResponse], +) +async def get_project_resource_limit_endpoint( + project_id: int = Query(..., description="확인할 프로젝트 ID"), + user: UserInfo = Depends(get_user_info), + usecase: GetProjectResourceLimitUseCase = Depends(GetProjectResourceLimitUseCase), +) -> SuccessResponse[ProjectResourceLimitResponse]: + resource_limit = await usecase(project_id=project_id, user_id=user.user_id) + return SuccessResponse( + message="프로젝트 최대 리소스 한도를 조회했습니다.", + data=resource_limit, ) diff --git a/src/api/routers/v1/__init__.py b/src/api/routers/v1/__init__.py deleted file mode 100644 index c1f2f41..0000000 --- a/src/api/routers/v1/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from fastapi import APIRouter - -from src.api.routers.v1 import dns, ports, projects - -v1_api_router = APIRouter() -v1_api_router.include_router(projects.router) -v1_api_router.include_router(ports.router) -v1_api_router.include_router(dns.router) diff --git a/src/app/port/exceptions.py b/src/app/port/exceptions.py index c005a58..347488d 100644 --- a/src/app/port/exceptions.py +++ b/src/app/port/exceptions.py @@ -16,3 +16,33 @@ def __init__(self) -> None: code="PORT_NOT_FOUND", status_code=404, ) + + +# ===== Resource Server Exceptions ===== + + +class PortCreationFailed(AppException): + def __init__(self) -> None: + super().__init__( + "리소스 서버 오류로 포트를 생성할 수 없습니다.", + code="PORT_CREATION_FAILED", + status_code=502, + ) + + +class PortDeletionFailed(AppException): + def __init__(self) -> None: + super().__init__( + "리소스 서버 오류로 포트를 삭제할 수 없습니다.", + code="PORT_DELETION_FAILED", + status_code=502, + ) + + +class PortUpdateFailed(AppException): + def __init__(self) -> None: + super().__init__( + "리소스 서버 오류로 포트를 변경할 수 없습니다.", + code="PORT_UPDATE_FAILED", + status_code=502, + ) diff --git a/src/app/port/usecase/create_port.py b/src/app/port/usecase/create_port.py index 2c61d2f..eba5229 100644 --- a/src/app/port/usecase/create_port.py +++ b/src/app/port/usecase/create_port.py @@ -1,6 +1,7 @@ from fastapi import Depends -from src.app.port.exceptions import PortAlreadyExists +from src.app.port.exceptions import PortAlreadyExists, PortCreationFailed +from src.core.exceptions import ResourceServerException from src.core.domain.port import Port from src.app.port.schemas import PortCreate from src.app.project.exceptions import ProjectNotFound, OnlyOwnerCanManagePorts @@ -53,10 +54,13 @@ async def __call__( protocol=request.protocol, ) port = await self.uow.port.insert(port_row) - await self.project_resource_client.open_port( - user_id=user_id, - role=role, - project=project, - port=port, - ) + try: + await self.project_resource_client.open_port( + user_id=user_id, + role=role, + project=project, + port=port, + ) + except ResourceServerException as e: + raise PortCreationFailed() from e return port diff --git a/src/app/port/usecase/delete_port.py b/src/app/port/usecase/delete_port.py index b5a2de2..f141f2d 100644 --- a/src/app/port/usecase/delete_port.py +++ b/src/app/port/usecase/delete_port.py @@ -1,6 +1,7 @@ from fastapi import Depends -from src.app.port.exceptions import PortNotFound +from src.app.port.exceptions import PortNotFound, PortDeletionFailed +from src.core.exceptions import ResourceServerException from src.core.domain.port import Port from src.app.project.exceptions import ProjectNotFound, OnlyOwnerCanManagePorts from src.app.base_usecase import BaseUseCase @@ -44,11 +45,14 @@ async def __call__( if port is None: raise PortNotFound() - await self.project_resource_client.close_port( - user_id=user_id, - role=role, - project=project, - port=port, - ) + try: + await self.project_resource_client.close_port( + user_id=user_id, + role=role, + project=project, + port=port, + ) + except ResourceServerException as e: + raise PortDeletionFailed() from e await self.uow.port.delete(port) return port diff --git a/src/app/port/usecase/update_port.py b/src/app/port/usecase/update_port.py index 6736888..91568bf 100644 --- a/src/app/port/usecase/update_port.py +++ b/src/app/port/usecase/update_port.py @@ -1,6 +1,7 @@ from fastapi import Depends -from src.app.port.exceptions import PortAlreadyExists, PortNotFound +from src.app.port.exceptions import PortAlreadyExists, PortNotFound, PortUpdateFailed +from src.core.exceptions import ResourceServerException from src.core.domain.port import Port from src.app.port.schemas import PortUpdate from src.app.project.exceptions import ProjectNotFound, OnlyOwnerCanManagePorts @@ -62,11 +63,14 @@ async def __call__( port_number=request.port_number, protocol=request.protocol, ) - await self.project_resource_client.update_port( - user_id=user_id, - role=role, - project=project, - original_port=original_port, - updated_port=port, - ) + try: + await self.project_resource_client.update_port( + user_id=user_id, + role=role, + project=project, + original_port=original_port, + updated_port=port, + ) + except ResourceServerException as e: + raise PortUpdateFailed() from e return port diff --git a/src/app/project/exceptions.py b/src/app/project/exceptions.py index 908f337..dfd3f07 100644 --- a/src/app/project/exceptions.py +++ b/src/app/project/exceptions.py @@ -53,6 +53,15 @@ def __init__(self) -> None: ) +class OnlyOwnerCanGetResourceLimit(AppException): + def __init__(self) -> None: + super().__init__( + "프로젝트 소유자만 리소스 한도를 조회할 수 있습니다.", + code="ONLY_OWNER_CAN_GET_RESOURCE_LIMIT", + status_code=403, + ) + + class OnlyOwnerCanUpdateProjectName(AppException): def __init__(self) -> None: super().__init__( @@ -162,3 +171,33 @@ def __init__(self) -> None: code="CANNOT_TRANSFER_OWNERSHIP_TO_SELF", status_code=400, ) + + +# ===== Resource Server Exceptions ===== + + +class ProjectCreationFailed(AppException): + def __init__(self) -> None: + super().__init__( + "리소스 서버 오류로 프로젝트를 생성할 수 없습니다.", + code="PROJECT_CREATION_FAILED", + status_code=502, + ) + + +class ProjectDeletionFailed(AppException): + def __init__(self) -> None: + super().__init__( + "리소스 서버 오류로 프로젝트를 삭제할 수 없습니다.", + code="PROJECT_DELETION_FAILED", + status_code=502, + ) + + +class ProjectResourceUpdateFailed(AppException): + def __init__(self) -> None: + super().__init__( + "리소스 서버 오류로 프로젝트 리소스를 변경할 수 없습니다.", + code="PROJECT_RESOURCE_UPDATE_FAILED", + status_code=502, + ) diff --git a/src/app/project/schemas.py b/src/app/project/schemas.py index 491ffab..f4b5e6a 100644 --- a/src/app/project/schemas.py +++ b/src/app/project/schemas.py @@ -139,6 +139,25 @@ class ProjectOwnerResponse(BaseModel): ) +class ProjectResourceLimitResponse(BaseModel): + project_id: int = Field( + ..., + description="조회한 프로젝트 ID", + ) + max_cpu: float = Field( + ..., + description="프로젝트 최대 vCPU", + ) + max_memory: float = Field( + ..., + description="프로젝트 최대 메모리(MB)", + ) + max_disk: float = Field( + ..., + description="프로젝트 최대 디스크(MB)", + ) + + class MetricPoint(BaseModel): timestamp: str value: float diff --git a/src/app/project/usecase/__init__.py b/src/app/project/usecase/__init__.py index c8e7403..bef5c0a 100644 --- a/src/app/project/usecase/__init__.py +++ b/src/app/project/usecase/__init__.py @@ -10,6 +10,7 @@ from src.app.project.usecase.transfer_project_ownership import TransferProjectOwnershipUseCase from src.app.project.usecase.check_project_available import CheckProjectAvailableUseCase from src.app.project.usecase.check_project_owner import CheckProjectOwnerUseCase +from src.app.project.usecase.get_project_resource_limit import GetProjectResourceLimitUseCase __all__ = [ "CreateProjectUseCase", @@ -24,4 +25,5 @@ "TransferProjectOwnershipUseCase", "CheckProjectAvailableUseCase", "CheckProjectOwnerUseCase", + "GetProjectResourceLimitUseCase", ] diff --git a/src/app/project/usecase/create_project.py b/src/app/project/usecase/create_project.py index 495e7d0..7582878 100644 --- a/src/app/project/usecase/create_project.py +++ b/src/app/project/usecase/create_project.py @@ -4,16 +4,16 @@ from src.app.project.exceptions import ( ProjectLimitExceeded, ProjectNameAlreadyExists, + ProjectCreationFailed, ) +from src.core.exceptions import ResourceServerException from src.core.domain.project import Project, ProjectMember from src.app.base_usecase import BaseUseCase from src.core.uow import UnitOfWork from src.core.client.project_resource import ProjectResourceClient from src.dependencies.uow import get_uow from src.dependencies.client.project_resource import get_project_resource_client - -PROJECT_LIMIT = 3 -# 이거 config로 뭉쳐서 환경변수로 하는게 나을 듯 +from src.common.config.settings import ProjectConfig class CreateProjectUseCase(BaseUseCase): @@ -35,7 +35,7 @@ async def __call__( ) -> Project: async with self.uow: project_count = await self.uow.project.count_by_user(user_id) - if project_count >= PROJECT_LIMIT: + if project_count >= ProjectConfig.LIMIT: raise ProjectLimitExceeded() if await self.uow.project.exists_by_name(user_id, request.name): @@ -58,9 +58,12 @@ async def __call__( ) await self.uow.project_member.insert(owner_member) - await self.project_resource_client.create( - user_id=user_id, - role=role, - project=project, - ) + try: + await self.project_resource_client.create( + user_id=user_id, + role=role, + project=project, + ) + except ResourceServerException as e: + raise ProjectCreationFailed() from e return project diff --git a/src/app/project/usecase/delete_project.py b/src/app/project/usecase/delete_project.py index 400c802..3dc777d 100644 --- a/src/app/project/usecase/delete_project.py +++ b/src/app/project/usecase/delete_project.py @@ -3,7 +3,9 @@ from src.app.project.exceptions import ( ProjectNotFound, OnlyOwnerCanDeleteProject, + ProjectDeletionFailed, ) +from src.core.exceptions import ResourceServerException from src.core.domain.project import Project from src.app.base_usecase import BaseUseCase from src.core.uow import UnitOfWork @@ -36,10 +38,13 @@ async def __call__( if not is_owner: raise OnlyOwnerCanDeleteProject() - await self.project_resource_client.delete( - user_id=user_id, - role=role, - project=project, - ) + try: + await self.project_resource_client.delete( + user_id=user_id, + role=role, + project=project, + ) + except ResourceServerException as e: + raise ProjectDeletionFailed() from e await self.uow.project.delete(project) return project diff --git a/src/app/project/usecase/get_project_resource_limit.py b/src/app/project/usecase/get_project_resource_limit.py new file mode 100644 index 0000000..1ae7ad2 --- /dev/null +++ b/src/app/project/usecase/get_project_resource_limit.py @@ -0,0 +1,34 @@ +from fastapi import Depends + +from src.app.base_usecase import BaseUseCase +from src.app.project.exceptions import OnlyOwnerCanGetResourceLimit, ProjectNotFound +from src.app.project.schemas import ProjectResourceLimitResponse +from src.core.uow import UnitOfWork +from src.dependencies.uow import get_uow + + +class GetProjectResourceLimitUseCase(BaseUseCase): + """프로젝트 최대 리소스 한도를 조회합니다.""" + + def __init__( + self, + uow: UnitOfWork = Depends(get_uow), + ): + self.uow = uow + + async def __call__(self, project_id: int, user_id: int) -> ProjectResourceLimitResponse: + async with self.uow: + project = await self.uow.project.get_by_id(project_id) + if project is None: + raise ProjectNotFound() + + is_owner = await self.uow.project_member.is_owner(project_id, user_id) + if not is_owner: + raise OnlyOwnerCanGetResourceLimit() + + return ProjectResourceLimitResponse( + project_id=project.id, + max_cpu=project.max_cpu, + max_memory=project.max_memory, + max_disk=project.max_disk, + ) diff --git a/src/app/project/usecase/update_project_resource.py b/src/app/project/usecase/update_project_resource.py index ba3d497..78d2969 100644 --- a/src/app/project/usecase/update_project_resource.py +++ b/src/app/project/usecase/update_project_resource.py @@ -4,7 +4,9 @@ ProjectNotFound, DiskCannotBeReduced, OnlyOwnerCanUpdateResource, + ProjectResourceUpdateFailed, ) +from src.core.exceptions import ResourceServerException from src.core.domain.project import Project from src.app.project.schemas import ProjectResourceUpdate from src.app.base_usecase import BaseUseCase @@ -48,9 +50,12 @@ async def __call__( max_memory=request.max_memory, max_disk=request.max_disk ) - await self.project_resource_client.allocate( - user_id=user_id, - role=role, - project=project, - ) + try: + await self.project_resource_client.allocate( + user_id=user_id, + role=role, + project=project, + ) + except ResourceServerException as e: + raise ProjectResourceUpdateFailed() from e return project diff --git a/src/common/config/application_server.py b/src/common/config/application_server.py index e2ca379..ad756cd 100644 --- a/src/common/config/application_server.py +++ b/src/common/config/application_server.py @@ -1,19 +1,23 @@ from functools import lru_cache -from pydantic_settings import BaseSettings, SettingsConfigDict +from src.common.config.settings import LoggedSettings, register_config +from pydantic_settings import SettingsConfigDict +from src.common.const.vault import VAULT_ENV_FILE -class ApplicationServerConfig(BaseSettings): + +class ApplicationServerConfig(LoggedSettings): model_config = SettingsConfigDict( env_prefix="APPLICATION_", extra="ignore", - env_file=".env", + env_file=VAULT_ENV_FILE, env_file_encoding="utf-8", ) SERVER_BASE_URL: str = "http://localhost:8003" +@register_config @lru_cache def get_application_server_config() -> ApplicationServerConfig: return ApplicationServerConfig() diff --git a/src/common/config/resource_server.py b/src/common/config/resource_server.py index d627eec..f06f0e6 100644 --- a/src/common/config/resource_server.py +++ b/src/common/config/resource_server.py @@ -1,12 +1,16 @@ -from anyio.functools import lru_cache -from pydantic_settings import BaseSettings, SettingsConfigDict +from functools import lru_cache +from src.common.config.settings import LoggedSettings, register_config +from pydantic_settings import SettingsConfigDict -class ResourceServerConfig(BaseSettings): +from src.common.const.vault import VAULT_ENV_FILE + + +class ResourceServerConfig(LoggedSettings): model_config = SettingsConfigDict( env_prefix="RESOURCE_", extra="ignore", - env_file="/vault/secrets/.env", + env_file=VAULT_ENV_FILE, env_file_encoding="utf-8", ) @@ -14,6 +18,7 @@ class ResourceServerConfig(BaseSettings): # env에 이거 추가하면 됨 # ex) SERVER_BASE_URL=https:// ··· +@register_config @lru_cache def get_resource_config() -> ResourceServerConfig: return ResourceServerConfig() \ No newline at end of file diff --git a/src/common/config/settings.py b/src/common/config/settings.py index 0a18c00..9cda97e 100644 --- a/src/common/config/settings.py +++ b/src/common/config/settings.py @@ -1,9 +1,37 @@ +import logging from functools import lru_cache -from typing import List +from typing import Any, Callable, List +from urllib.parse import quote_plus from pydantic import BaseModel from pydantic_settings import BaseSettings, SettingsConfigDict +from src.common.const.vault import VAULT_ENV_FILE + +logger = logging.getLogger(__name__) + +_CONFIG_GETTERS: list[Callable] = [] + + +def register_config(fn: Callable) -> Callable: + _CONFIG_GETTERS.append(fn) + return fn + + +def load_all_configs() -> None: + for getter in _CONFIG_GETTERS: + getter() + + +class LoggedSettings(BaseSettings): + def model_post_init(self, __context: Any) -> None: + prefix = self.model_config.get("env_prefix", "").upper() + fields_info = { + f"{prefix}{name.upper()}": getattr(self, name) + for name in self.model_fields + } + logger.info("[Config Loaded] %s → %s", self.__class__.__name__, fields_info) + class CorsSettings(BaseModel): allow_origins: List[str] = [ @@ -15,24 +43,24 @@ class CorsSettings(BaseModel): allow_headers: List[str] = ["*"] -class AppConfig(BaseSettings): +class AppConfig(LoggedSettings): model_config = SettingsConfigDict( env_prefix="MADP_", - env_file="/vault/secrets/.env", + env_file=VAULT_ENV_FILE, env_file_encoding="utf-8", env_nested_delimiter="__", extra="ignore", ) app_name: str = "MADP Project Service" - app_version: str = "v1" + app_version: str = "0.0.1" cors: CorsSettings = CorsSettings() -class DatabaseConfig(BaseSettings): +class DatabaseConfig(LoggedSettings): model_config = SettingsConfigDict( env_prefix="DB_", - env_file="/vault/secrets/.env", + env_file=VAULT_ENV_FILE, env_file_encoding="utf-8", extra="ignore", ) @@ -45,13 +73,13 @@ class DatabaseConfig(BaseSettings): @property def url(self) -> str: - return f"mysql+aiomysql://{self.user}:{self.password}@{self.host}:{self.port}/{self.name}" + return f"mysql+aiomysql://{quote_plus(self.user)}:{quote_plus(self.password)}@{self.host}:{self.port}/{self.name}" -class SonyflakeConfig(BaseSettings): +class SonyflakeConfig(LoggedSettings): model_config = SettingsConfigDict( env_prefix="SONYFLAKE_", - env_file="/vault/secrets/.env", + env_file=VAULT_ENV_FILE, env_file_encoding="utf-8", extra="ignore", ) @@ -64,16 +92,19 @@ class ProjectConfig: DNS_DOMAIN: str = "mdeveloper.platform" +@register_config @lru_cache def get_app_config() -> AppConfig: return AppConfig() +@register_config @lru_cache def get_db_config() -> DatabaseConfig: return DatabaseConfig() +@register_config @lru_cache def get_sonyflake_config() -> SonyflakeConfig: return SonyflakeConfig() diff --git a/src/common/config/user_server.py b/src/common/config/user_server.py index d0504d5..3a84667 100644 --- a/src/common/config/user_server.py +++ b/src/common/config/user_server.py @@ -1,18 +1,23 @@ from functools import lru_cache -from pydantic_settings import BaseSettings, SettingsConfigDict +from src.common.config.settings import LoggedSettings, register_config +from pydantic_settings import SettingsConfigDict -class UserServerConfig(BaseSettings): +from src.common.const.vault import VAULT_ENV_FILE + + +class UserServerConfig(LoggedSettings): model_config = SettingsConfigDict( env_prefix="USER_", extra="ignore", - env_file="/vault/secrets/.env", + env_file=VAULT_ENV_FILE, env_file_encoding="utf-8", ) SERVER_BASE_URL: str = "http://localhost:8002" +@register_config @lru_cache def get_user_server_config() -> UserServerConfig: return UserServerConfig() diff --git a/src/common/const/__init__.py b/src/common/const/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/common/const/vault.py b/src/common/const/vault.py new file mode 100644 index 0000000..ab768d0 --- /dev/null +++ b/src/common/const/vault.py @@ -0,0 +1,6 @@ +import os + +_VAULT_PATH = "/vault/secrets/.env" +_LOCAL_PATH = ".env" + +VAULT_ENV_FILE = _VAULT_PATH if os.path.exists(_VAULT_PATH) else _LOCAL_PATH diff --git a/src/core/db.py b/src/core/db.py index e74cc0b..6f1d184 100644 --- a/src/core/db.py +++ b/src/core/db.py @@ -22,28 +22,30 @@ async def get_session() -> AsyncIterator[AsyncSession]: yield session -_MIGRATIONS: list[tuple[str, str]] = [ +_MIGRATIONS: list[tuple[str, list[str]]] = [ ( "001_init_role", - """ - UPDATE project_member - SET role = 'MEMBER' - WHERE role NOT IN ('OWNER', 'MEMBER') - """, + [ + """ + UPDATE project_member + SET role = 'MEMBER' + WHERE role NOT IN ('OWNER', 'MEMBER') + """, + ], ), ( "002_user_id_to_bigint", - """ - ALTER TABLE project - MODIFY COLUMN user_id BIGINT NOT NULL - """, + [ + "DELETE FROM project WHERE user_id REGEXP '[^0-9]'", + "ALTER TABLE project MODIFY COLUMN user_id BIGINT NOT NULL", + ], ), ( "003_member_user_id_to_bigint", - """ - ALTER TABLE project_member - MODIFY COLUMN user_id BIGINT NOT NULL - """, + [ + "DELETE FROM project_member WHERE user_id REGEXP '[^0-9]'", + "ALTER TABLE project_member MODIFY COLUMN user_id BIGINT NOT NULL", + ], ), ] @@ -59,13 +61,14 @@ async def create_all_tables() -> None: ) """ )) - for version, sql in _MIGRATIONS: + for version, sqls in _MIGRATIONS: result = await conn.execute( text("SELECT 1 FROM schema_migrations WHERE version = :v"), {"v": version}, ) if result.fetchone() is None: - await conn.execute(text(sql)) + for sql in sqls: + await conn.execute(text(sql)) await conn.execute( text("INSERT INTO schema_migrations (version) VALUES (:v)"), {"v": version}, diff --git a/src/core/exceptions.py b/src/core/exceptions.py index 009a926..a931edd 100644 --- a/src/core/exceptions.py +++ b/src/core/exceptions.py @@ -7,6 +7,12 @@ from src.common.schemas import ErrorResponse +class ResourceServerException(Exception): + def __init__(self, message: str = "리소스 서버와 통신 중 오류가 발생했습니다.") -> None: + super().__init__(message) + self.message = message + + class AppException(Exception): def __init__( self, diff --git a/src/infra/client/asyncio_http.py b/src/infra/client/asyncio_http.py index 256469b..629b2ec 100644 --- a/src/infra/client/asyncio_http.py +++ b/src/infra/client/asyncio_http.py @@ -37,6 +37,7 @@ async def get( headers=self._merge_headers(headers), ) as session: response = await session.get(url, params=params) + await response.read() return response async def post( @@ -52,6 +53,7 @@ async def post( headers=self._merge_headers(headers), ) as session: response = await session.post(url, data=data, json=json) + await response.read() return response async def delete( @@ -66,6 +68,7 @@ async def delete( headers=self._merge_headers(headers), ) as session: response = await session.delete(url, params=params) + await response.read() return response async def put( @@ -81,6 +84,7 @@ async def put( headers=self._merge_headers(headers), ) as session: response = await session.put(url, data=data, json=json) + await response.read() return response async def patch( @@ -96,4 +100,5 @@ async def patch( headers=self._merge_headers(headers), ) as session: response = await session.patch(url, data=data, json=json) + await response.read() return response diff --git a/src/infra/client/project_resource_impl.py b/src/infra/client/project_resource_impl.py index 495298a..48b4add 100644 --- a/src/infra/client/project_resource_impl.py +++ b/src/infra/client/project_resource_impl.py @@ -4,6 +4,7 @@ from src.core.domain.project import Project from src.core.client.http import HttpClient from src.core.client.project_resource import ProjectResourceClient, ResourceUsageData +from src.core.exceptions import ResourceServerException from src.common.config.resource_server import ResourceServerConfig from src.infra.client.asyncio_http import AioHttpClient from src.infra.client.schemas import ( @@ -33,6 +34,16 @@ def __init__( self.base_url = resource_server_config.SERVER_BASE_URL self.http_client = http_client + async def _request(self, coro) -> None: + try: + response = await coro + except Exception as e: + raise ResourceServerException() from e + if not response.ok: + raise ResourceServerException( + f"리소스 서버 응답 오류: {response.status}" + ) + def _convert_cpu(self, val: float | None) -> str | None: if val is None: return None @@ -45,25 +56,25 @@ def _convert_memory(self, val: float | None) -> str | None: async def create(self, user_id: int, role: str, project: Project) -> None: payload = ExternalProjectCreate( - id=project.id, + id=str(project.id), name=str(project.id), cpu=self._convert_cpu(project.max_cpu), memory=self._convert_memory(project.max_memory), disk=self._convert_memory(project.max_disk), ) - await self.http_client.post( + await self._request(self.http_client.post( self.base_url + ProjectResourceAPIUrls.CREATE_PROJECT, - headers={"X-User-Id": user_id, "X-User-Role": role}, + headers={"X-User-Id": str(user_id), "X-User-Role": role}, json=payload.model_dump(exclude_none=True), - ) + )) async def delete(self, user_id: int, role: str, project: Project) -> None: - await self.http_client.delete( + await self._request(self.http_client.delete( self.base_url + ProjectResourceAPIUrls.DELETE_PROJECT.format( project_id=project.id ), - headers={"X-User-Id": user_id, "X-User-Role": role}, - ) + headers={"X-User-Id": str(user_id), "X-User-Role": role}, + )) async def open_port(self, user_id: int, role: str, project: Project, port: Port) -> None: service_id = f"svc-{project.id}-{port.id}" @@ -76,22 +87,22 @@ async def open_port(self, user_id: int, role: str, project: Project, port: Port) protocol=port.protocol.upper(), service_type="ClusterIP", ) - await self.http_client.post( + await self._request(self.http_client.post( self.base_url + ProjectResourceAPIUrls.OPEN_PROJECT_PORT.format( project_id=project.id ), - headers={"X-User-Id": user_id, "X-User-Role": role}, + headers={"X-User-Id": str(user_id), "X-User-Role": role}, json=payload.model_dump(), - ) + )) async def close_port(self, user_id: int, role: str, project: Project, port: Port) -> None: service_id = f"svc-{project.id}-{port.id}" - await self.http_client.delete( + await self._request(self.http_client.delete( self.base_url + ProjectResourceAPIUrls.CLOSE_PROJECT_PORT.format( project_id=project.id, port_id=service_id ), - headers={"X-User-Id": user_id, "X-User-Role": role}, - ) + headers={"X-User-Id": str(user_id), "X-User-Role": role}, + )) async def update_port( self, @@ -110,13 +121,13 @@ async def update_port( protocol=updated_port.protocol.upper(), service_type="ClusterIP", ) - await self.http_client.put( + await self._request(self.http_client.put( self.base_url + ProjectResourceAPIUrls.UPDATE_PROJECT_PORT.format( project_id=project.id, port_id=original_port.from_port ), - headers={"X-User-Id": user_id, "X-User-Role": role}, + headers={"X-User-Id": str(user_id), "X-User-Role": role}, json=payload.model_dump(exclude_none=True), - ) + )) async def allocate(self, user_id: int, role: str, project: Project) -> None: payload = ExternalResourceUpdate( @@ -124,13 +135,13 @@ async def allocate(self, user_id: int, role: str, project: Project) -> None: memory=self._convert_memory(project.max_memory), disk=self._convert_memory(project.max_disk), ) - await self.http_client.patch( + await self._request(self.http_client.patch( self.base_url + ProjectResourceAPIUrls.UPDATE_PROJECT_RESOURCES.format( project_id=project.id ), - headers={"X-User-Id": user_id, "X-User-Role": role}, + headers={"X-User-Id": str(user_id), "X-User-Role": role}, json=payload.model_dump(exclude_none=True), - ) + )) async def get_usage( self, diff --git a/src/infra/client/schemas.py b/src/infra/client/schemas.py index afd7583..8566031 100644 --- a/src/infra/client/schemas.py +++ b/src/infra/client/schemas.py @@ -4,7 +4,7 @@ class ExternalProjectCreate(BaseModel): - id: int = Field(..., description="프로젝트 ID") + id: str = Field(..., description="프로젝트 ID") name: str = Field(..., description="프로젝트 이름") cpu: str | None = Field("100m", description="CPU Quota (예: 100m)") memory: str | None = Field("32Mi", description="Memory Quota (예: 32Mi)") diff --git a/tests/test_api_available_endpoint.py b/tests/test_api_available_endpoint.py index 50efe9a..45daade 100644 --- a/tests/test_api_available_endpoint.py +++ b/tests/test_api_available_endpoint.py @@ -1,17 +1,18 @@ -import json - import pytest -from src.api.routers.v1.projects import check_project_available_endpoint +from src.api.routers.routes.projects import ( + check_project_available_endpoint, + check_project_owner_endpoint, +) from src.dependencies.auth import UserInfo class StubAvailableUseCase: def __init__(self, result: bool) -> None: self.result = result - self.calls: list[tuple[int, str]] = [] + self.calls: list[tuple[int, int]] = [] - async def __call__(self, project_id: int, user_id: str) -> bool: + async def __call__(self, project_id: int, user_id: int) -> bool: self.calls.append((project_id, user_id)) return self.result @@ -24,26 +25,38 @@ async def test_available_returns_status_true_when_user_has_access() -> None: response = await check_project_available_endpoint( project_id=1, - user=UserInfo(user_id="u-1", role="MEMBER"), + user=UserInfo(user_id=1, role="MEMBER"), usecase=usecase, ) - assert response.status_code == 200 - assert json.loads(response.body) == {"status": True} - assert isinstance(json.loads(response.body)["status"], bool) - assert usecase.calls == [(1, "u-1")] + assert response.message == "프로젝트 접근 가능 여부를 조회했습니다." + assert response.data.status is True + assert usecase.calls == [(1, 1)] -async def test_available_returns_status_false_with_403_when_user_has_no_access() -> None: +async def test_available_returns_status_false_when_user_has_no_access() -> None: usecase = StubAvailableUseCase(result=False) response = await check_project_available_endpoint( project_id=2, - user=UserInfo(user_id="u-2", role="MEMBER"), + user=UserInfo(user_id=2, role="MEMBER"), + usecase=usecase, + ) + + assert response.message == "프로젝트 접근 가능 여부를 조회했습니다." + assert response.data.status is False + assert usecase.calls == [(2, 2)] + + +async def test_owner_endpoint_uses_header_user_id_instead_of_query_user_id() -> None: + usecase = StubAvailableUseCase(result=True) + + response = await check_project_owner_endpoint( + project_id=10, + user=UserInfo(user_id=777, role="OWNER"), usecase=usecase, ) - assert response.status_code == 403 - assert json.loads(response.body) == {"status": False} - assert isinstance(json.loads(response.body)["status"], bool) - assert usecase.calls == [(2, "u-2")] + assert response.message == "프로젝트 소유자 여부를 조회했습니다." + assert response.data.status is True + assert usecase.calls == [(10, 777)] diff --git a/tests/test_api_dns_endpoints.py b/tests/test_api_dns_endpoints.py index 0bbbc75..5b58f04 100644 --- a/tests/test_api_dns_endpoints.py +++ b/tests/test_api_dns_endpoints.py @@ -1,6 +1,6 @@ import pytest -from src.api.routers.v1.dns import ( +from src.api.routers.routes.dns import ( bind_port_to_dns_endpoint, create_dns_endpoint, delete_dns_endpoint, diff --git a/tests/test_api_ports_endpoints.py b/tests/test_api_ports_endpoints.py index 8cbdff3..f0019b4 100644 --- a/tests/test_api_ports_endpoints.py +++ b/tests/test_api_ports_endpoints.py @@ -1,6 +1,6 @@ import pytest -from src.api.routers.v1.ports import ( +from src.api.routers.routes.ports import ( create_project_port_endpoint, delete_project_port_endpoint, list_project_ports_endpoint, diff --git a/tests/test_api_projects_endpoints.py b/tests/test_api_projects_endpoints.py index ac39a9f..4b013b2 100644 --- a/tests/test_api_projects_endpoints.py +++ b/tests/test_api_projects_endpoints.py @@ -2,10 +2,11 @@ import pytest -from src.api.routers.v1.projects import ( +from src.api.routers.routes.projects import ( add_project_member_endpoint, create_project_endpoint, delete_project_endpoint, + get_project_resource_limit_endpoint, get_project_endpoint, list_project_members_endpoint, list_projects_endpoint, @@ -25,6 +26,7 @@ ProjectMemberResponse, ProjectNameUpdate, ProjectOwnerTransfer, + ProjectResourceLimitResponse, ProjectResourceUpdate, ) from src.common.schemas import CursorPage @@ -95,6 +97,31 @@ async def test_list_projects_endpoint_contract() -> None: ] +async def test_get_project_resource_limit_endpoint_contract() -> None: + limits = ProjectResourceLimitResponse( + project_id=1, + max_cpu=2.0, + max_memory=1024.0, + max_disk=2048.0, + ) + usecase = AsyncUseCaseStub(limits) + + response = await get_project_resource_limit_endpoint( + project_id=1, + user=UserInfo(user_id=1, role="OWNER"), + usecase=usecase, + ) + + assert response.message == "프로젝트 최대 리소스 한도를 조회했습니다." + assert response.data.project_id == 1 + assert response.data.max_cpu == 2.0 + assert response.data.max_memory == 1024.0 + assert response.data.max_disk == 2048.0 + assert usecase.calls == [ + ((), {"project_id": 1, "user_id": 1}) + ] + + async def test_get_project_endpoint_contract() -> None: detail = ProjectDetailResponse( id=1, diff --git a/tests/test_project_usecases.py b/tests/test_project_usecases.py index f3bf4fd..68cd9d6 100644 --- a/tests/test_project_usecases.py +++ b/tests/test_project_usecases.py @@ -3,6 +3,7 @@ from src.app.project.exceptions import ( DiskCannotBeReduced, OnlyOwnerCanDeleteProject, + OnlyOwnerCanGetResourceLimit, OnlyOwnerCanUpdateProjectName, OnlyOwnerCanUpdateResource, ProjectLimitExceeded, @@ -14,6 +15,7 @@ from src.app.project.usecase.create_project import CreateProjectUseCase from src.app.project.usecase.delete_project import DeleteProjectUseCase from src.app.project.usecase.get_project import GetProjectUseCase +from src.app.project.usecase.get_project_resource_limit import GetProjectResourceLimitUseCase from src.app.project.usecase.list_project_members import ListProjectMembersUseCase from src.app.project.usecase.list_projects import ListProjectsUseCase from src.app.project.usecase.update_project_name import UpdateProjectNameUseCase @@ -405,6 +407,43 @@ async def test_check_project_available_returns_member_access( assert result is expected +async def test_get_project_resource_limit_raises_when_project_not_found() -> None: + usecase = GetProjectResourceLimitUseCase(uow=FakeUnitOfWork()) + + with pytest.raises(ProjectNotFound): + await usecase(project_id=999, user_id=1) + + +async def test_get_project_resource_limit_raises_when_requester_not_owner() -> None: + project = make_project(1) + members = [make_member(1, 2, role="MEMBER")] + uow = FakeUnitOfWork( + project_repo=FakeProjectRepository([project]), + project_member_repo=FakeProjectMemberRepository(members), + ) + usecase = GetProjectResourceLimitUseCase(uow=uow) + + with pytest.raises(OnlyOwnerCanGetResourceLimit): + await usecase(project_id=1, user_id=2) + + +async def test_get_project_resource_limit_returns_project_limits() -> None: + project = make_project(1, max_cpu=2.0, max_memory=1024.0, max_disk=2048.0) + members = [make_member(1, 1, role="OWNER")] + uow = FakeUnitOfWork( + project_repo=FakeProjectRepository([project]), + project_member_repo=FakeProjectMemberRepository(members), + ) + usecase = GetProjectResourceLimitUseCase(uow=uow) + + result = await usecase(project_id=1, user_id=1) + + assert result.project_id == 1 + assert result.max_cpu == 2.0 + assert result.max_memory == 1024.0 + assert result.max_disk == 2048.0 + + async def test_list_project_members_raises_when_project_not_found() -> None: usecase = ListProjectMembersUseCase( uow=FakeUnitOfWork(),