diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0564553 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,219 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml + +# Dot files +.* diff --git a/.gitignore b/.gitignore index e15106e..e8fee5d 100644 --- a/.gitignore +++ b/.gitignore @@ -195,9 +195,9 @@ cython_debug/ .abstra/ # Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, +# and can be added to the global gitignore or merged into this file. However, if you prefer, # you could uncomment the following to ignore the entire vscode folder # .vscode/ @@ -214,3 +214,8 @@ __marimo__/ # Streamlit .streamlit/secrets.toml + +# Dot files +.* +!.dockerignore +!.gitignore diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..51ec9a0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +FROM python:3.11-slim + +# Set the working directory inside the container +WORKDIR /app + +# Set environment variables +# Prevents Python from writing pyc files to disk +ENV PYTHONDONTWRITEBYTECODE=1 +# Prevents Python from buffering stdout and stderr +ENV PYTHONUNBUFFERED=1 + +# Copy the Django project and install dependencies +COPY requirements.txt /app/ + +# Run this command to install all dependencies +RUN pip install --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt && \ + apt-get update && \ + apt-get install -y curl && \ + rm -rf /var/lib/apt/lists/* + +COPY . /app/ + +# Create non-root user +RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app +USER appuser + +# Schedule health check +HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 CMD ["curl", "--silent", "--fail", "http://localhost:8000/health/"] + +# Run server +ENTRYPOINT ["bash", "/app/entrypoint.sh"] +CMD ["--bind", "0.0.0.0:8000", "--workers", "4", "--threads", "2"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..2a023ec --- /dev/null +++ b/README.md @@ -0,0 +1,163 @@ +# TLE Auth Service + +[![Docker](https://img.shields.io/badge/Docker-Ready-blue)](https://www.docker.com/) +[![Django](https://img.shields.io/badge/Django-4.x-green)](https://www.djangoproject.com/) +[![DRF](https://img.shields.io/badge/DRF-3.x-orange)](https://www.django-rest-framework.org/) + +TLE(Time Limit Exceeded)의 마이크로서비스 아키텍처를 위한 인증 서비스입니다. + +## 개요 + +- **JWT 기반 인증**: 사용자 인증 및 토큰 관리 +- **회원가입/로그인**: 사용자 계정 관리 +- **API 문서화**: Swagger UI 제공 +- **마이크로서비스**: 다른 서비스와의 JWT 토큰 공유 + +### 기술 스택 + +- **Backend**: Django + Django REST Framework +- **Database**: PostgreSQL +- **Authentication**: JWT (JSON Web Token) +- **Documentation**: Swagger UI +- **Containerization**: Docker + Docker Compose + +> 💡 **개발 모드**: `DEBUG=1` 환경변수 설정 시 Django 관리자 페이지와 Swagger UI에 접근할 수 있습니다. + +## 빠른 시작 + +### 사전 요구사항 + +- [Docker](https://www.docker.com/get-started) 20.10+ +- [Docker Compose](https://docs.docker.com/compose/install/) 2.0+ + +### 1. 저장소 클론 + +```bash +git clone +cd auth-service +``` + +### 2. 환경 설정 + +보안 키 파일들을 생성합니다: + +```bash +# 디렉토리 생성 +mkdir -p .secrets + +# JWT 서명과 데이터 암호화를 위한 비밀키 (랜덤 문자열 생성 권장) +echo "your-super-secret-key-here" > .secrets/secret_key.txt + +# PostgreSQL 데이터베이스 비밀번호 +echo "your-postgres-password" > .secrets/postgres_password.txt +``` + +> ⚠️ **보안 주의**: 실제 운영환경에서는 강력한 랜덤 키를 사용하세요. + +### 3. 서비스 실행 + +```bash +# 개발 환경 실행 +docker compose -f docker-compose.develop.yml up -d + +# 로그 확인 +docker compose -f docker-compose.develop.yml logs -f +``` + +> 💡 **개발 모드**: 개발 환경 실행 명령 옵션으로 `-w`를 추가하여 watch mode 기능을 활성화 할 수 있습니다. + +### 4. 접속 확인 + +- **API 서버**: http://localhost (포트 80) +- **Health Check**: http://localhost/health/ +- **Swagger UI**: http://localhost/swagger/ (DEBUG=1일 때) +- **Django Admin**: http://localhost/admin/ (DEBUG=1일 때) + +## 테스트 + +### Docker 컨테이너 테스트 + +```bash +# Django 단위 테스트 +python manage.py test +``` + + + +## 아키텍처 + +### 마이크로서비스 설계 + +TLE 서비스의 모놀리식 아키텍처를 MSA로 전환하는 첫 번째 서비스입니다. + +### JWT 토큰 구조 + + + +- **서명**: `SECRET_KEY`로 HMAC SHA256 알고리즘 사용 +- **저장**: 클라이언트 측 저장 (localStorage, cookie 등) +- **검증**: 다른 마이크로서비스에서 동일한 `SECRET_KEY`로 검증 + +## API 문서 + + + +자세한 API 문서는 Swagger UI에서 확인할 수 있습니다. + +## 개발 가이드 + +### 환경 변수 + +Docker compose를 사용하지 않고 직접 컨테이너를 실행할 경우 필요한 환경변수 입니다. + +| 변수명 | 설명 | 기본값 | 필수 | +| ------------------------ | -------------------------------------------------- | ---------------------------- | --------------------------------------------------- | +| `ALLOWED_HOSTS` | 허용된 호스트명 (JSON 배열, 예: `["example.com"]`) | `["localhost", "127.0.0.1"]` | ❌ (프로덕션 배포 시 도메인 추가 필요) | +| `DEBUG` | 디버그 모드 | `False` | ❌ | +| `SECRET_KEY` | Django, JWT 암호화 키 | | ✅ (`SECRET_KEY_FILE`이 설정되었다면 필수 X) | +| `SECRET_KEY_FILE` | Django, JWT 암호화 키가 저장된 파일 | | ❌ | +| `POSTGRES_HOST` | 데이터베이스 호스트 | | ✅ | +| `POSTGRES_PORT` | 데이터베이스 Port | `5432` | ❌ | +| `POSTGRES_DB` | 데이터베이스 이름 | | ✅ | +| `POSTGRES_USER` | 데이터베이스 사용자명 | | ✅ | +| `POSTGRES_PASSWORD` | 데이터베이스 사용자 비밀번호 | | ✅ (`POSTGRES_PASSWORD_FILE`이 설정되었다면 필수 X) | +| `POSTGRES_PASSWORD_FILE` | 데이터베이스 사용자 비밀번호가 저장된 파일 | | ❌ | + +### 포트 설정 변경 + +`docker-compose.develop.yml`에서 포트 매핑을 수정할 수 있습니다: + +```yaml +services: + tle-auth-service: + ports: + - "8080:8000" # 외부:내부 포트 +``` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/asgi.py b/app/asgi.py new file mode 100644 index 0000000..b6bc9f6 --- /dev/null +++ b/app/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for app project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') + +application = get_asgi_application() diff --git a/app/env.py b/app/env.py new file mode 100644 index 0000000..d98dc7d --- /dev/null +++ b/app/env.py @@ -0,0 +1,229 @@ +""" +환경 변수와 관련된 기능 혹은 유틸리티 모음. +""" + +import json +import os +from dotenv import load_dotenv +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Union + + +TRUTHY_VALUES = ('true', '1', 't', 'y', 'yes', 'on') +FALSY_VALUES = ('false', '0', 'f', 'n', 'no', 'off') + + +JSON_SCALAR = Union[str, int, float, bool, None] +JSON = Union[Dict[str, 'JSON'], List['JSON'], JSON_SCALAR] + + +load_dotenv() + + +def get(key: str, default: Optional[str] = None, required: bool = False) -> Optional[str]: + """환경 변수 값을 가져옵니다. + + Args: + key: 환경 변수 이름 + default: 기본값 (환경 변수가 없을 때 반환) + required: 필수 여부 (True일 때 환경 변수가 없으면 예외 발생) + + Returns: + 환경 변수 값 또는 기본값 + + Raises: + ValueError: required=True이고 환경 변수가 설정되지 않은 경우 + """ + value = os.getenv(key) + retval = default + + if value is not None: + retval = value.strip() + + if required and (retval is None): + raise ValueError( + f'Environment variable "{key}" is required but not set.' + ) + + return retval + + +def get_json(key: str, default: Optional[JSON] = None, required: bool = False) -> Optional[JSON]: + """환경 변수 값을 JSON으로 파싱하여 반환합니다. + + Args: + key: 환경 변수 이름 + default: 기본값 (환경 변수가 없거나 JSON 파싱에 실패할 때 반환) + required: 필수 여부 (True일 때 환경 변수가 없으면 예외 발생) + + Returns: + JSON으로 파싱된 값 또는 기본값 + + Raises: + ValueError: 환경 변수 값이 올바른 JSON이 아니거나 required=True이고 환경 변수가 설정되지 않은 경우 + """ + value = os.getenv(key) + retval = default + + if value is not None: + try: + retval = json.loads(value) + except json.JSONDecodeError: + raise ValueError( + f'Environment variable "{key}" has invalid JSON value.' + ) + + if required and (retval is None): + raise ValueError( + f'Environment variable "{key}" is required but not set.' + ) + + return retval + + +def get_bool(key: str, default: Optional[bool] = None, required: bool = False) -> Optional[bool]: + """환경 변수를 불린 값으로 파싱합니다. + + Args: + key: 환경 변수 이름 + default: 기본값 (환경 변수가 없을 때 반환) + required: 필수 여부 (True일 때 환경 변수가 없으면 예외 발생) + + Returns: + 불린 값 또는 기본값 + + Raises: + ValueError: 잘못된 불린 값이거나 required=True이고 환경 변수가 설정되지 않은 경우 + """ + value = os.getenv(key) + retval = default + + if value is not None: + if is_truthy(value): + retval = True + elif is_falsy(value): + retval = False + else: + raise ValueError( + f'Environment variable "{key}" has invalid boolean value.' + ) + + if required and (retval is None): + raise ValueError( + f'Environment variable "{key}" is required but not set.' + ) + + return retval + + +def get_int(key: str, default: Optional[int] = None, required: bool = False) -> Optional[int]: + """환경 변수를 정수 값으로 파싱합니다. + + Args: + key: 환경 변수 이름 + default: 기본값 (환경 변수가 없을 때 반환) + required: 필수 여부 (True일 때 환경 변수가 없으면 예외 발생) + + Returns: + 정수 값 또는 기본값 + + Raises: + ValueError: 잘못된 정수 값이거나 required=True이고 환경 변수가 설정되지 않은 경우 + """ + value = os.getenv(key) + retval = default + + if value is not None: + try: + retval = int(value) + except ValueError: + raise ValueError( + f'Environment variable "{key}" has invalid integer value.' + ) + + if required and (retval is None): + raise ValueError( + f'Environment variable "{key}" is required but not set.' + ) + + return retval + + +def get_file_content(key: str, default: Optional[str] = None, required: bool = False) -> Optional[str]: + """환경 변수에 지정된 파일 경로의 내용을 읽어옵니다. + + Args: + key: 파일 경로가 저장된 환경 변수 이름 + default: 기본값 (환경 변수가 없을 때 반환) + required: 필수 여부 (True일 때 환경 변수가 없으면 예외 발생) + + Returns: + 파일 내용 또는 기본값 + + Raises: + ValueError: 파일을 찾을 수 없거나 required=True이고 환경 변수가 설정되지 않은 경우 + """ + value = os.getenv(key) + retval = default + + if value is not None: + try: + retval = Path(value).read_text().strip() + except FileNotFoundError: + raise ValueError( + f'Environment variable "{key}" has invalid file path.' + ) + + if required and (retval is None): + raise ValueError( + f'Environment variable "{key}" is required but not set.' + ) + + return retval + + +def is_truthy(value: Optional[str]) -> bool: + """문자열이 참 값인지 확인합니다. + + Args: + value: 확인할 문자열 + + Returns: + 참 값 여부 (true, 1, t, y, yes, on 중 하나인 경우 True) + """ + return _is_one_of(value, TRUTHY_VALUES) + + +def is_falsy(value: Optional[str]) -> bool: + """문자열이 거짓 값인지 확인합니다. + + Args: + value: 확인할 문자열 + + Returns: + 거짓 값 여부 (false, 0, f, n, no, off 중 하나인 경우 True) + """ + return _is_one_of(value, FALSY_VALUES) + + +def _is_one_of(value: Optional[str], iterable: Iterable[str], case_insensitive: bool = True) -> bool: + """문자열이 주어진 값들 중 하나인지 확인합니다. + + Args: + value: 확인할 문자열 + iterable: 비교할 값들의 컬렉션 + case_insensitive: 대소문자 구분 여부 (기본값: True) + + Returns: + 값이 컬렉션에 포함되어 있는지 여부 + + Raises: + TypeError: value가 문자열이 아닌 경우 + """ + if value is None: + return False + if not isinstance(value, str): + raise TypeError(f'Expected str, got {type(value).__name__}') + if case_insensitive: + value = value.strip().lower() + return value in iterable diff --git a/app/settings.py b/app/settings.py new file mode 100644 index 0000000..d2c41a5 --- /dev/null +++ b/app/settings.py @@ -0,0 +1,139 @@ +""" +Django settings for app project. + +Generated by 'django-admin startproject' using Django 5.2.8. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.2/ref/settings/ +""" + +from pathlib import Path + +from app import env + + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = env.get('SECRET_KEY', + default=env.get_file_content('SECRET_KEY_FILE'), + required=True) + + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = env.get_bool('DEBUG', default=False) + +ALLOWED_HOSTS = env.get_json('ALLOWED_HOSTS', + default=['localhost', '127.0.0.1']) + + +# Application definition + +INSTALLED_APPS = [ + "drf_yasg", + "rest_framework", + + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'app.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'app.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/5.2/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql_psycopg2', + 'HOST': env.get("POSTGRES_HOST", required=True), + 'PORT': env.get_int("POSTGRES_PORT", default=5432), + 'NAME': env.get("POSTGRES_DB", required=True), + 'USER': env.get("POSTGRES_USER", required=True), + 'PASSWORD': env.get('POSTGRES_PASSWORD', default=env.get_file_content('POSTGRES_PASSWORD_FILE'), required=True), + } +} + + +# Password validation +# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.2/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.2/howto/static-files/ + +STATIC_URL = 'static/' + +STATIC_ROOT = BASE_DIR / '.static' + + +# Default primary key field type +# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' diff --git a/app/tests.py b/app/tests.py new file mode 100644 index 0000000..5d6269e --- /dev/null +++ b/app/tests.py @@ -0,0 +1,265 @@ +import os +from django.core.files.temp import NamedTemporaryFile +from django.db.utils import DatabaseError +from django.db.utils import InterfaceError +from django.db.utils import OperationalError +from django.test import TestCase +from rest_framework import status +from unittest.mock import patch +from app import env + + +class HealthCheckAPIViewTest(TestCase): + def test_get_200_with_healthy_database(self): + """ + 데이터베이스 연결이 정상일 때 GET /health/ 요청 시 200 OK를 반환하는지 테스트. + """ + response = self.client.get('/health/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + + @patch('app.views.connections') + def test_get_500_with_database_connection_error(self, mock_connections): + """ + 데이터베이스 연결 실패 시 GET /health/ 요청 시 500 에러를 반환하는지 테스트. + """ + # 데이터베이스 연결 실패 시뮬레이션 + mock_connection = mock_connections.__getitem__.return_value + mock_connection.cursor.side_effect = OperationalError("Database connection failed") + + response = self.client.get('/health/') + self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) + + @patch('app.views.connections') + def test_get_500_with_database_error(self, mock_connections): + """ + 데이터베이스 에러 발생 시 GET /health/ 요청 시 500 에러를 반환하는지 테스트. + """ + # 데이터베이스 에러 시뮬레이션 + mock_connection = mock_connections.__getitem__.return_value + mock_connection.cursor.side_effect = DatabaseError("Database error occurred") + + response = self.client.get('/health/') + self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) + + @patch('app.views.connections') + def test_get_500_with_interface_error(self, mock_connections): + """ + 데이터베이스 인터페이스 에러 발생 시 GET /health/ 요청 시 500 에러를 반환하는지 테스트. + """ + # 데이터베이스 인터페이스 에러 시뮬레이션 + mock_connection = mock_connections.__getitem__.return_value + mock_connection.cursor.side_effect = InterfaceError("Database interface error") + + response = self.client.get('/health/') + self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) + + +class EnvModuleTest(TestCase): + def setUp(self): + # 테스트 전 환경 변수 초기화 + self.test_keys = ['TEST_VAR', 'TEST_JSON', + 'TEST_BOOL', 'TEST_INT', 'TEST_FILE'] + for key in self.test_keys: + if key in os.environ: + del os.environ[key] + + def tearDown(self): + # 테스트 후 환경 변수 정리 + for key in self.test_keys: + if key in os.environ: + del os.environ[key] + + def test_get_existing_value(self): + os.environ['TEST_VAR'] = 'test_value' + result = env.get('TEST_VAR') + self.assertEqual(result, 'test_value') + + def test_get_default_value(self): + result = env.get('TEST_VAR', 'default_value') + self.assertEqual(result, 'default_value') + + def test_get_none_when_not_set(self): + result = env.get('TEST_VAR') + self.assertIsNone(result) + + def test_get_required_raises_error(self): + with self.assertRaises(ValueError) as cm: + env.get('TEST_VAR', required=True) + self.assertIn('required but not set', str(cm.exception)) + + def test_get_bool_truthy_values(self): + truthy_values = ['true', '1', 't', 'y', 'yes', 'on', 'TRUE', 'True'] + for value in truthy_values: + os.environ['TEST_BOOL'] = value + result = env.get_bool('TEST_BOOL') + self.assertTrue(result, f'Failed for value: {value}') + + def test_get_bool_falsy_values(self): + falsy_values = ['false', '0', 'f', 'n', 'no', 'off', 'FALSE', 'False'] + for value in falsy_values: + os.environ['TEST_BOOL'] = value + result = env.get_bool('TEST_BOOL') + self.assertFalse(result, f'Failed for value: {value}') + + def test_get_bool_invalid_value(self): + os.environ['TEST_BOOL'] = 'invalid' + with self.assertRaises(ValueError) as cm: + env.get_bool('TEST_BOOL') + self.assertIn('invalid boolean value', str(cm.exception)) + + def test_get_bool_default_value(self): + result = env.get_bool('TEST_BOOL', True) + self.assertTrue(result) + + def test_get_bool_required_raises_error(self): + with self.assertRaises(ValueError): + env.get_bool('TEST_BOOL', required=True) + + def test_get_int_valid_value(self): + os.environ['TEST_INT'] = '42' + result = env.get_int('TEST_INT') + self.assertEqual(result, 42) + + def test_get_int_negative_value(self): + os.environ['TEST_INT'] = '-10' + result = env.get_int('TEST_INT') + self.assertEqual(result, -10) + + def test_get_int_invalid_value(self): + os.environ['TEST_INT'] = 'not_a_number' + with self.assertRaises(ValueError) as cm: + env.get_int('TEST_INT') + self.assertIn('invalid integer value', str(cm.exception)) + + def test_get_int_default_value(self): + result = env.get_int('TEST_INT', 100) + self.assertEqual(result, 100) + + def test_get_int_required_raises_error(self): + with self.assertRaises(ValueError): + env.get_int('TEST_INT', required=True) + + def test_get_file_content_valid_file(self): + with NamedTemporaryFile(mode='w', delete=False) as f: + f.write('test content') + temp_path = f.name + try: + os.environ['TEST_FILE'] = temp_path + result = env.get_file_content('TEST_FILE') + self.assertEqual(result, 'test content') + finally: + os.unlink(temp_path) + + def test_get_file_content_invalid_file(self): + os.environ['TEST_FILE'] = '/nonexistent/file.txt' + with self.assertRaises(ValueError) as cm: + env.get_file_content('TEST_FILE') + self.assertIn('invalid file path', str(cm.exception)) + + def test_get_file_content_default_value(self): + result = env.get_file_content('TEST_FILE', 'default content') + self.assertEqual(result, 'default content') + + def test_get_file_content_required_raises_error(self): + with self.assertRaises(ValueError): + env.get_file_content('TEST_FILE', required=True) + + def test_get_file_content_strips_whitespace(self): + with NamedTemporaryFile(mode='w', delete=False) as f: + f.write(' test content with spaces \n') + temp_path = f.name + try: + os.environ['TEST_FILE'] = temp_path + result = env.get_file_content('TEST_FILE') + self.assertEqual(result, 'test content with spaces') + finally: + os.unlink(temp_path) + + def test_get_file_content_strips_newlines(self): + with NamedTemporaryFile(mode='w', delete=False) as f: + f.write('\n\ntest content\n\n') + temp_path = f.name + try: + os.environ['TEST_FILE'] = temp_path + result = env.get_file_content('TEST_FILE') + self.assertEqual(result, 'test content') + finally: + os.unlink(temp_path) + + def test_get_file_content_strips_tabs(self): + with NamedTemporaryFile(mode='w', delete=False) as f: + f.write('\t\ttest content\t\t') + temp_path = f.name + try: + os.environ['TEST_FILE'] = temp_path + result = env.get_file_content('TEST_FILE') + self.assertEqual(result, 'test content') + finally: + os.unlink(temp_path) + + def test_is_truthy_valid_values(self): + truthy_values = ['true', '1', 't', 'y', 'yes', 'on'] + for value in truthy_values: + self.assertTrue(env.is_truthy(value)) + self.assertTrue(env.is_truthy(value.upper())) + + def test_is_truthy_invalid_values(self): + invalid_values = ['false', '0', 'invalid', None] + for value in invalid_values: + self.assertFalse(env.is_truthy(value)) + + def test_is_falsy_valid_values(self): + falsy_values = ['false', '0', 'f', 'n', 'no', 'off'] + for value in falsy_values: + self.assertTrue(env.is_falsy(value)) + self.assertTrue(env.is_falsy(value.upper())) + + def test_is_falsy_invalid_values(self): + invalid_values = ['true', '1', 'invalid', None] + for value in invalid_values: + self.assertFalse(env.is_falsy(value)) + + def test_get_json_valid_object(self): + os.environ['TEST_JSON'] = '{"key": "value", "number": 42}' + result = env.get_json('TEST_JSON') + self.assertEqual(result, {'key': 'value', 'number': 42}) + + def test_get_json_valid_array(self): + os.environ['TEST_JSON'] = '[1, 2, 3]' + result = env.get_json('TEST_JSON') + self.assertEqual(result, [1, 2, 3]) + + def test_get_json_valid_string(self): + os.environ['TEST_JSON'] = '"test string"' + result = env.get_json('TEST_JSON') + self.assertEqual(result, 'test string') + + def test_get_json_valid_number(self): + os.environ['TEST_JSON'] = '123' + result = env.get_json('TEST_JSON') + self.assertEqual(result, 123) + + def test_get_json_valid_boolean(self): + os.environ['TEST_JSON'] = 'true' + result = env.get_json('TEST_JSON') + self.assertTrue(result) + + def test_get_json_valid_null(self): + os.environ['TEST_JSON'] = 'null' + result = env.get_json('TEST_JSON') + self.assertIsNone(result) + + def test_get_json_invalid_value(self): + os.environ['TEST_JSON'] = 'not a json' + with self.assertRaises(ValueError) as cm: + env.get_json('TEST_JSON') + self.assertIn('invalid JSON value', str(cm.exception)) + + def test_get_json_default_value(self): + result = env.get_json('TEST_JSON', {'default': 'value'}) + self.assertEqual(result, {'default': 'value'}) + + def test_get_json_required_raises_error(self): + with self.assertRaises(ValueError) as cm: + env.get_json('TEST_JSON', required=True) + self.assertIn('required but not set', str(cm.exception)) diff --git a/app/urls.py b/app/urls.py new file mode 100644 index 0000000..8106877 --- /dev/null +++ b/app/urls.py @@ -0,0 +1,42 @@ +""" +URL configuration for app project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.conf import settings +from django.conf.urls.static import static +from django.contrib import admin +from django.urls import path +from django.urls import re_path +import app.views + + +urlpatterns = [ + # Service Status + # - `HEALTHCHECK` support for Docker Container + path('health/', app.views.HealthCheckAPIView.as_view()), +] + +if settings.DEBUG: + urlpatterns += [ + path('admin/', admin.site.urls), + + # Swagger Support + path('swagger/', app.views.schema.with_ui('swagger')), + re_path(r'swagger/(?P\.json|\.yaml)', + app.views.schema.without_ui()), + + # Serve static files only in development + *static(settings.STATIC_URL, document_root=settings.STATIC_ROOT), + ] diff --git a/app/views.py b/app/views.py new file mode 100644 index 0000000..65f59a9 --- /dev/null +++ b/app/views.py @@ -0,0 +1,52 @@ +from django.db import connections +from django.db.utils import OperationalError +from django.http import HttpRequest +from django.http import HttpResponse +from drf_yasg.openapi import Contact +from drf_yasg.openapi import Info +from drf_yasg.utils import swagger_auto_schema +from drf_yasg.views import get_schema_view +from rest_framework import status +from rest_framework.exceptions import APIException +from rest_framework.permissions import AllowAny +from rest_framework.views import APIView + + +schema = get_schema_view( + info=Info( + title="Time Limit Exceeded :: Authentication API", + default_version='1.0.0', + description="", + contact=Contact(email="202115064@sangmyung.kr"), + ), + public=True, + permission_classes=[AllowAny], +) + + +class HealthCheckAPIView(APIView): + """ + Health Check API + + 배포된 컨테이너의 상태 검사를 위한 endpoint. + 컨테이너가 정상적으로 구동되었으면 200 OK를 반환한다. + """ + permission_classes = [AllowAny] + + @swagger_auto_schema( + responses={ + status.HTTP_200_OK: "Container is healthy.", + } + ) + def get(self, request: HttpRequest): + # Database Connectivity Check + try: + with connections['default'].cursor(): + # Just testing the connection + pass + except OperationalError: + raise APIException("Database connection failed.") + except Exception: + raise APIException("Unexpected error during health check.") + + return HttpResponse(status=status.HTTP_200_OK) diff --git a/app/wsgi.py b/app/wsgi.py new file mode 100644 index 0000000..121dd78 --- /dev/null +++ b/app/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for app project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') + +application = get_wsgi_application() diff --git a/docker-compose.develop.yml b/docker-compose.develop.yml new file mode 100644 index 0000000..fa97138 --- /dev/null +++ b/docker-compose.develop.yml @@ -0,0 +1,68 @@ +services: + tle-auth-service: + restart: always + build: + context: . + dockerfile: Dockerfile + depends_on: + tle-auth-database: + condition: service_healthy + restart: true + ports: + - "80:8000" + environment: + SECRET_KEY_FILE: /run/secrets/secret_key + DEBUG: "true" + POSTGRES_HOST: tle-auth-database + POSTGRES_PORT: 5432 + POSTGRES_DB: tle-auth + POSTGRES_USER: tle-auth + POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password + secrets: + - secret_key + - postgres_password + develop: + watch: + - action: rebuild + path: "Dockerfile" + - action: rebuild + path: "docker-compose.develop.yml" + - action: rebuild + path: "requirements.txt" + - action: sync+restart + path: "." + target: "/app/" + initial_sync: true + ignore: + - ".env" + - ".git" + - ".secrets" + - "__pycache__" + - "*.pyc" + - "README.md" + + tle-auth-database: + image: postgres:15-alpine + restart: always + healthcheck: + test: ["CMD-SHELL", "pg_isready -U tle-auth"] + interval: 10s + timeout: 5s + retries: 5 + volumes: + - tle-auth-volume:/var/lib/postgresql/data + environment: + POSTGRES_DB: tle-auth + POSTGRES_USER: tle-auth + POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password + secrets: + - postgres_password + +volumes: + tle-auth-volume: + +secrets: + secret_key: + file: ./.secrets/secret_key.txt + postgres_password: + file: ./.secrets/postgres_password.txt diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..384fb68 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,57 @@ +#!/bin/bash + +# Exit on any error +set -e + + +function main() { + echo "[ENTRYPOINT] Arguments: $@" + + wait_for_db + + if is_debug_mode; then + debug_init + fi + + # Run server + gunicorn app.wsgi:application "$@" +} + + +function debug_init() { + echo "[ENTRYPOINT] Running in DEBUG mode." + + python manage.py migrate --no-input + + # 관리자 페이지, Swagger UI가 설치되어 있으므로, 항상 collect할 static 파일이 존재하는 것으로 가정. + python manage.py collectstatic --no-input +} + + + +function is_debug_mode() { + # app/settings.py의 DEBUG 는 truthy falsy 값을 허용하므로, + # 해당 파싱 방식이 반영될 수 있도록 app/settings.py에서 직접 DEBUG 값을 관측. + echo 'from django.conf import settings; exit(0 if settings.DEBUG else 1);' \ + | python manage.py shell --no-imports +} + + +function wait_for_db() { + local retries=30 + local wait=2 + echo "[ENTRYPOINT] Waiting for database to be ready..." + for i in $(seq 1 $retries); do + if python manage.py migrate --plan > /dev/null 2>&1; then + echo "[ENTRYPOINT] Database is ready." + return 0 + fi + echo "[ENTRYPOINT] Database not ready yet (attempt $i/$retries), waiting $wait seconds..." + sleep $wait + done + echo "[ENTRYPOINT] Database not ready after $((retries * wait)) seconds, exiting." + exit 1 +} + + +main "$@" diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..4931389 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5053aa1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +django +djangorestframework +drf-yasg +gunicorn +python-dotenv +psycopg2-binary