Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ db.sqlite3

# Environments
.env
.env.local
.venv
.codex

# VScode
.vscode
Expand Down
69 changes: 69 additions & 0 deletions test/check_vault_bucket_name.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
from __future__ import annotations

import base64
import json
import os
import sys
from typing import cast

from dotenv import load_dotenv
import oci
from oci.secrets.models import SecretBundle

from wacruit.src.settings import settings


def _required_env(name: str) -> str:
value = os.getenv(name, "").strip()
if not value:
raise ValueError(f"Missing required env: {name}")
return value


def _make_secrets_client() -> oci.secrets.SecretsClient:
try:
signer = oci.auth.signers.InstancePrincipalsSecurityTokenSigner()
return oci.secrets.SecretsClient({}, signer=signer)
except Exception: # pylint: disable=broad-exception-caught
profile = os.getenv("OCI_CLI_PROFILE", "DEFAULT")
config = oci.config.from_file(profile_name=profile)
return oci.secrets.SecretsClient(config)


def main() -> int:
try:
load_dotenv(".env.test")
secret_id = os.getenv("OCI_SECRET_OCID") or settings.oci_secret_ocid
if not secret_id:
raise ValueError(
"Missing OCI secret id. Set OCI_SECRET_OCID in .env.test "
"or run with ENV=dev/prod so settings.oci_secret_ocid is available."
)
key_name = os.getenv("OCI_BUCKET_KEY_NAME", "portfolio_bucket_name")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

기본 키 이름이 StorageConfig와 불일치할 수 있음

기본값 portfolio_bucket_nameStorageConfig에서 사용하는 object_storage_bucket_name과 다릅니다. 이 스크립트가 새로운 스토리지 설정을 검증하기 위한 것이라면 기본값을 object_storage_bucket_name으로 변경하는 것이 일관성 있습니다.

🔧 수정 제안
-        key_name = os.getenv("OCI_BUCKET_KEY_NAME", "portfolio_bucket_name")
+        key_name = os.getenv("OCI_BUCKET_KEY_NAME", "object_storage_bucket_name")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
key_name = os.getenv("OCI_BUCKET_KEY_NAME", "portfolio_bucket_name")
key_name = os.getenv("OCI_BUCKET_KEY_NAME", "object_storage_bucket_name")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/check_vault_bucket_name.py` at line 42, The default key name used when
reading the env var into key_name is inconsistent with StorageConfig; change the
default from "portfolio_bucket_name" to "object_storage_bucket_name" so tests
align with StorageConfig expectations—update the assignment that sets key_name
(the os.getenv call) to use "object_storage_bucket_name" as the fallback and
ensure any related assertions referencing
StorageConfig.object_storage_bucket_name remain consistent.


client = _make_secrets_client()
bundle_response = client.get_secret_bundle(secret_id=secret_id)
if bundle_response is None or bundle_response.data is None:
raise ValueError(f"Secret bundle is empty for {secret_id}")
bundle = cast(SecretBundle, bundle_response.data)
if bundle.secret_bundle_content is None:
raise ValueError(f"Secret bundle content is empty for {secret_id}")
base64_secret_content = bundle.secret_bundle_content.content
decoded = base64.b64decode(base64_secret_content).decode("utf-8")
secret_data = json.loads(decoded)

if key_name not in secret_data:
print(f"[ERROR] Key not found in vault secret: {key_name}")
print(f"Available keys: {sorted(secret_data.keys())}")
return 1

print(f"Vault key found: {key_name}")
print(f"Bucket name: {secret_data[key_name]}")
return 0
except Exception as exc: # pylint: disable=broad-exception-caught
print(f"[ERROR] {exc}")
return 1


if __name__ == "__main__":
sys.exit(main())
62 changes: 62 additions & 0 deletions test/oci_s3_connection_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from __future__ import annotations

import os
import sys

import boto3
from botocore.client import BaseClient
from botocore.config import Config
from botocore.exceptions import ClientError
from dotenv import load_dotenv


def _required_env(name: str) -> str:
value = os.getenv(name, "").strip()
if not value:
raise ValueError(f"Missing required env: {name}")
return value


def _make_s3_client() -> BaseClient:
load_dotenv(".env.test")
access_key = _required_env("OCI_ACCESS_KEY_ID")
secret_key = _required_env("OCI_SECRET_ACCESS_KEY")
endpoint_url = _required_env("OCI_S3_COMPAT_ENDPOINT")
region = os.getenv("OCI_REGION", "ap-chuncheon-1")

# Force path-style for maximum compatibility with OCI S3 endpoint.
config = Config(s3={"addressing_style": "path"})
return boto3.client(
"s3",
region_name=region,
endpoint_url=endpoint_url,
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
config=config,
)


def main() -> int:
try:
s3 = _make_s3_client()
target_bucket = _required_env("S3_PORTFOLIO_BUCKET_NAME")

buckets = s3.list_buckets().get("Buckets", [])
bucket_names = [bucket["Name"] for bucket in buckets]
print("Connected to OCI S3-compatible endpoint")
print(f"Visible buckets: {bucket_names}")

s3.head_bucket(Bucket=target_bucket)
print(f"Target bucket is reachable: {target_bucket}")
return 0
except ValueError as exc:
print(f"[CONFIG ERROR] {exc}")
except ClientError as exc:
print(f"[OCI ERROR] {exc}")
except Exception as exc: # pylint: disable=broad-exception-caught
print(f"[UNEXPECTED ERROR] {exc}")
return 1


if __name__ == "__main__":
sys.exit(main())
52 changes: 40 additions & 12 deletions wacruit/src/apps/portfolio/file/aws/config.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,56 @@
from pydantic import BaseSettings
from pydantic import Field

from wacruit.src.secrets import OCISecretManager
from wacruit.src.settings import settings

_REQUIRED_OBJECT_STORAGE_SECRET_KEYS = {
"object_storage_bucket_name": "bucket_name",
"object_storage_region": "region",
"object_storage_endpoint_url": "endpoint_url",
"object_storage_access_key_id": "access_key_id",
"object_storage_secret_access_key": "secret_access_key",
}

class S3PortfolioConfig(BaseSettings):

class StorageConfig(BaseSettings):
bucket_name: str = ""
region: str = "ap-northeast-2"
endpoint_url: str | None = None
access_key_id: str | None = None
secret_access_key: str | None = None
addressing_style: str = "path"

class Config:
class Config(BaseSettings.Config):
case_sensitive = False
env_prefix = "S3_PORTFOLIO_"

env_prefix = "OBJECT_STORAGE_"
env_file = settings.env_files

def __init__(self):
super().__init__()
aws_secrets = OCISecretManager()
if aws_secrets.is_available():
self.bucket_name = aws_secrets.get_secret("portfolio_bucket_name")
secret_manager = OCISecretManager()
if secret_manager.is_available():
self._load_from_vault(secret_manager)

def _load_from_vault(self, secret_manager: OCISecretManager) -> None:
missing_keys: list[str] = []
for secret_key, attr_name in _REQUIRED_OBJECT_STORAGE_SECRET_KEYS.items():
try:
value = secret_manager.get_secret(secret_key)
except KeyError:
missing_keys.append(secret_key)
continue

if not value:
missing_keys.append(secret_key)
continue

setattr(self, attr_name, value)

@property
def bucket_region(self) -> str:
return "ap-northeast-2"
if missing_keys:
raise ValueError(
"Missing required object storage secret keys in OCI Vault: "
+ ", ".join(missing_keys)
)
Comment on lines 28 to +53

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: OCISecretManager.get_secret 메서드의 에러 처리 확인

ast-grep --pattern $'def get_secret(self, $key) -> str:
  $$$
}'

Repository: wafflestudio/wacruit-server

Length of output: 53


🏁 Script executed:

find . -name "secrets.py" -type f | head -20

Repository: wafflestudio/wacruit-server

Length of output: 95


🏁 Script executed:

rg "class OCISecretManager" -A 20

Repository: wafflestudio/wacruit-server

Length of output: 1436


🏁 Script executed:

cat wacruit/src/apps/portfolio/file/aws/config.py | head -40

Repository: wafflestudio/wacruit-server

Length of output: 1246


🏁 Script executed:

rg "def get_secret" wacruit/src/secrets.py -A 15

Repository: wafflestudio/wacruit-server

Length of output: 602


🏁 Script executed:

cat wacruit/src/secrets.py | tail -50

Repository: wafflestudio/wacruit-server

Length of output: 1742


Vault의 키 누락 시 KeyError 발생 위험

OCISecretManager.get_secret() 메서드는 직접 딕셔너리 접근 (secret_data[key])을 통해 데이터를 반환하며, 요청한 키가 없으면 KeyError를 발생시킵니다. Vault가 사용 가능하다고 해도 (is_available() 반환 True) 특정 키가 누락되어 있으면 애플리케이션 시작 시 실패합니다.

get_secret() 호출을 try-except로 감싸거나 환경 변수 기본값으로 fallback하도록 개선하세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@wacruit/src/apps/portfolio/file/aws/config.py` around lines 20 - 32, The
__init__ in the AWS config currently assumes OCISecretManager.get_secret always
returns the requested keys and will raise KeyError if a secret is missing;
update the __init__ to handle missing secrets by wrapping each call to
OCISecretManager.get_secret (for bucket_name, region, endpoint_url,
access_key_id, secret_access_key) with a try/except KeyError (or using a
.get-like fallback) and fall back to sensible defaults such as environment
variables or None; keep the is_available() check but ensure missing-key handling
prevents startup crashes when a particular secret is absent.



s3_config = S3PortfolioConfig()
storage_config = StorageConfig()
20 changes: 16 additions & 4 deletions wacruit/src/apps/portfolio/file/aws/s3/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,27 @@

import boto3
from botocore.client import BaseClient
from botocore.config import Config

from wacruit.src.utils.singleton import SingletonMeta
from wacruit.src.apps.portfolio.file.aws.config import StorageConfig, storage_config


class S3Client(metaclass=SingletonMeta):
class S3Client:
_client: BaseClient

def __init__(self, region_name: str = "ap-northeast-2"):
self._client = cast(BaseClient, boto3.client("s3", region_name=region_name))
def __init__(self, config: StorageConfig | None = None):
config = config or storage_config
self._client = cast(
BaseClient,
boto3.client(
"s3",
region_name=config.region,
endpoint_url=config.endpoint_url,
aws_access_key_id=config.access_key_id,
aws_secret_access_key=config.secret_access_key,
config=Config(s3={"addressing_style": config.addressing_style}),
),
)

@property
def client(self):
Expand Down
28 changes: 17 additions & 11 deletions wacruit/src/apps/portfolio/file/services.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
from wacruit.src.apps.portfolio.file.aws.config import s3_config
from wacruit.src.apps.portfolio.file.aws.config import storage_config
from wacruit.src.apps.portfolio.file.aws.s3.client import S3Client
from wacruit.src.apps.portfolio.file.aws.s3.method import S3PresignedUrlMethod
from wacruit.src.apps.portfolio.file.aws.s3.utils import delete_object
from wacruit.src.apps.portfolio.file.aws.s3.utils import generate_presigned_post_url
from wacruit.src.apps.portfolio.file.aws.s3.utils import generate_presigned_url
from wacruit.src.apps.portfolio.file.aws.s3.utils import get_list_of_objects
from wacruit.src.apps.portfolio.file.exceptions import NumPortfolioLimitException
from wacruit.src.apps.portfolio.file.exceptions import PortfolioNotFoundException
from wacruit.src.apps.portfolio.file.schemas import PortfolioNameResponse
from wacruit.src.apps.portfolio.file.schemas import PresignedUrlResponse
from wacruit.src.apps.portfolio.file.aws.s3.utils import (
delete_object,
generate_presigned_post_url,
generate_presigned_url,
get_list_of_objects,
)
from wacruit.src.apps.portfolio.file.exceptions import (
NumPortfolioLimitException,
PortfolioNotFoundException,
)
from wacruit.src.apps.portfolio.file.schemas import (
PortfolioNameResponse,
PresignedUrlResponse,
)
from wacruit.src.utils.mixins import LoggingMixin

_1_MIN = 60
Expand All @@ -19,8 +25,8 @@

class PortfolioFileService(LoggingMixin):
def __init__(self):
self._s3_config = s3_config
self._s3_client = S3Client(region_name=self._s3_config.bucket_region)
self._s3_config = storage_config
self._s3_client = S3Client(self._s3_config)
self._num_portfolio_limit = 1

@staticmethod
Expand Down
30 changes: 18 additions & 12 deletions wacruit/src/apps/portfolio/file/services_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,26 @@

from fastapi import Depends

from wacruit.src.apps.portfolio.file.aws.config import s3_config
from wacruit.src.apps.portfolio.file.aws.config import storage_config
from wacruit.src.apps.portfolio.file.aws.s3.client import S3Client
from wacruit.src.apps.portfolio.file.aws.s3.method import S3PresignedUrlMethod
from wacruit.src.apps.portfolio.file.aws.s3.utils import delete_object
from wacruit.src.apps.portfolio.file.aws.s3.utils import generate_presigned_post_url
from wacruit.src.apps.portfolio.file.aws.s3.utils import generate_presigned_url
from wacruit.src.apps.portfolio.file.aws.s3.utils import get_list_of_objects
from wacruit.src.apps.portfolio.file.exceptions import NumPortfolioLimitException
from wacruit.src.apps.portfolio.file.exceptions import PortfolioNotFoundException
from wacruit.src.apps.portfolio.file.aws.s3.utils import (
delete_object,
generate_presigned_post_url,
generate_presigned_url,
get_list_of_objects,
)
from wacruit.src.apps.portfolio.file.exceptions import (
NumPortfolioLimitException,
PortfolioNotFoundException,
)
from wacruit.src.apps.portfolio.file.models import PortfolioFile
from wacruit.src.apps.portfolio.file.repositories import PortfolioFileRepository
from wacruit.src.apps.portfolio.file.schemas import PortfolioFileResponse
from wacruit.src.apps.portfolio.file.schemas import PresignedUrlResponse
from wacruit.src.apps.portfolio.file.schemas import PresignedUrlWithIdResponse
from wacruit.src.apps.portfolio.file.schemas import (
PortfolioFileResponse,
PresignedUrlResponse,
PresignedUrlWithIdResponse,
)
from wacruit.src.apps.recruiting.repositories import RecruitingRepository
from wacruit.src.utils.mixins import LoggingMixin

Expand All @@ -33,8 +39,8 @@ def __init__(
):
self._portfolio_file_repository = portfolio_file_repository
self._recruiting_repository = recruiting_repository
self._s3_config = s3_config
self._s3_client = S3Client(region_name=self._s3_config.bucket_region)
self._s3_config = storage_config
self._s3_client = S3Client(self._s3_config)
self._num_portfolio_limit = 1

@staticmethod
Expand Down
46 changes: 24 additions & 22 deletions wacruit/src/apps/project/services.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,32 @@
from typing import Annotated, cast, TYPE_CHECKING

from fastapi import Depends

from wacruit.src.apps.common.schemas import ListResponse
from wacruit.src.apps.member.repositories import MemberRepository
from wacruit.src.apps.portfolio.file.aws.config import s3_config
from wacruit.src.apps.portfolio.file.aws.config import storage_config
from wacruit.src.apps.portfolio.file.aws.s3.client import S3Client
from wacruit.src.apps.portfolio.file.aws.s3.method import S3PresignedUrlMethod
from wacruit.src.apps.portfolio.file.aws.s3.utils import delete_object
from wacruit.src.apps.portfolio.file.aws.s3.utils import generate_presigned_post_url
from wacruit.src.apps.portfolio.file.aws.s3.utils import generate_presigned_url
from wacruit.src.apps.project.exceptions import GetPresignedURLException
from wacruit.src.apps.project.exceptions import ProjectAlreadyExistsException
from wacruit.src.apps.project.exceptions import ProjectImageNotFoundException
from wacruit.src.apps.project.exceptions import ProjectNotFoundException
from wacruit.src.apps.project.models import Project
from wacruit.src.apps.project.models import ProjectImage
from wacruit.src.apps.project.models import ProjectURL
from wacruit.src.apps.portfolio.file.aws.s3.utils import (
delete_object,
generate_presigned_post_url,
generate_presigned_url,
)
from wacruit.src.apps.project.exceptions import (
GetPresignedURLException,
ProjectAlreadyExistsException,
ProjectImageNotFoundException,
ProjectNotFoundException,
)
from wacruit.src.apps.project.models import Project, ProjectImage, ProjectURL
from wacruit.src.apps.project.repositories import ProjectRepository
from wacruit.src.apps.project.schemas import PresignedUrlWithIdResponse
from wacruit.src.apps.project.schemas import ProjectBriefResponse
from wacruit.src.apps.project.schemas import ProjectCreateRequest
from wacruit.src.apps.project.schemas import ProjectDetailResponse
from wacruit.src.apps.project.schemas import ProjectImageResponse
from wacruit.src.apps.project.schemas import ProjectLinkDto
from wacruit.src.apps.project.schemas import ProjectUpdateRequest
from wacruit.src.apps.project.schemas import (
PresignedUrlWithIdResponse,
ProjectBriefResponse,
ProjectCreateRequest,
ProjectDetailResponse,
ProjectImageResponse,
ProjectLinkDto,
ProjectUpdateRequest,
)

_1_MIN = 60
_10_MIN = 10 * _1_MIN
Expand All @@ -38,8 +40,8 @@ def __init__(
member_repository: MemberRepository = Depends(),
project_repository: ProjectRepository = Depends(),
) -> None:
self._s3_config = s3_config
self._s3_client = S3Client(region_name=self._s3_config.bucket_region)
self._s3_config = storage_config
self._s3_client = S3Client(self._s3_config)
self.member_repository = member_repository
self.project_repository = project_repository

Expand Down
Loading
Loading