From a1b252c0cac3ed32ba398f5edffa429c7b7f2995 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Tue, 25 Nov 2025 13:11:10 +0900 Subject: [PATCH 01/93] chore: initialize Django project --- app/__init__.py | 0 app/asgi.py | 16 +++++++ app/settings.py | 122 +++++++++++++++++++++++++++++++++++++++++++++++ app/urls.py | 22 +++++++++ app/wsgi.py | 16 +++++++ manage.py | 22 +++++++++ requirements.txt | 1 + 7 files changed, 199 insertions(+) create mode 100644 app/__init__.py create mode 100644 app/asgi.py create mode 100644 app/settings.py create mode 100644 app/urls.py create mode 100644 app/wsgi.py create mode 100755 manage.py create mode 100644 requirements.txt 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/settings.py b/app/settings.py new file mode 100644 index 0000000..b36b757 --- /dev/null +++ b/app/settings.py @@ -0,0 +1,122 @@ +""" +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 + +# 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 = 'django-insecure-dulqz#%k5aq8%cdx02%55tm9+2q1b%qogh%2#6jbx3zn4ab5pb' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + '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.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + + +# 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/' + +# 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/urls.py b/app/urls.py new file mode 100644 index 0000000..93599f7 --- /dev/null +++ b/app/urls.py @@ -0,0 +1,22 @@ +""" +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.contrib import admin +from django.urls import path + +urlpatterns = [ + path('admin/', admin.site.urls), +] 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/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..d3e4ba5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +django From 7db2ad169561ba0fa986e14833df3674ff02bfa3 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Tue, 25 Nov 2025 14:06:20 +0900 Subject: [PATCH 02/93] chore: install DRF and Swagger --- app/settings.py | 3 +++ requirements.txt | 2 ++ 2 files changed, 5 insertions(+) diff --git a/app/settings.py b/app/settings.py index b36b757..8437a84 100644 --- a/app/settings.py +++ b/app/settings.py @@ -31,6 +31,9 @@ # Application definition INSTALLED_APPS = [ + "drf_yasg", + "rest_framework", + 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', diff --git a/requirements.txt b/requirements.txt index d3e4ba5..7c303eb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,3 @@ django +djangorestframework +drf-yasg From 0f2be0fb5a0da485531d91a9a2db5106728a2f61 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Tue, 25 Nov 2025 16:16:29 +0900 Subject: [PATCH 03/93] feat(swagger): add `/swagger/` endpoint for Swagger support --- app/urls.py | 6 ++++++ app/views.py | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 app/views.py diff --git a/app/urls.py b/app/urls.py index 93599f7..a0a2ab8 100644 --- a/app/urls.py +++ b/app/urls.py @@ -16,7 +16,13 @@ """ from django.contrib import admin from django.urls import path +import app.views + urlpatterns = [ path('admin/', admin.site.urls), + + # Swagger Support + path('swagger/', app.views.schema.with_ui('swagger')), + path('swagger/(?P\\.json|\\.yaml)', app.views.schema.without_ui()), ] diff --git a/app/views.py b/app/views.py new file mode 100644 index 0000000..71e5d08 --- /dev/null +++ b/app/views.py @@ -0,0 +1,16 @@ +from drf_yasg.openapi import Contact +from drf_yasg.openapi import Info +from drf_yasg.views import get_schema_view +from rest_framework.permissions import AllowAny + + +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], +) From 5f7eb5b508b034d4d13b78590221ade1d6dfbf63 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Tue, 25 Nov 2025 16:41:22 +0900 Subject: [PATCH 04/93] build(docker): add Dockerfile --- Dockerfile | 27 +++++++++++++++++++++++++++ entrypoint.sh | 4 ++++ 2 files changed, 31 insertions(+) create mode 100644 Dockerfile create mode 100755 entrypoint.sh diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ed9d0bb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM python:3.9 + +# Create the app directory +RUN mkdir /app + +# 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 + +# Upgrade pip +RUN pip install --upgrade pip + +# Copy the Django project and install dependencies +COPY requirements.txt /app/ + +# run this command to install all dependencies +RUN pip install --no-cache-dir -r requirements.txt + +COPY . /app/ + +# Run the server +ENTRYPOINT ["./entrypoint.sh"] diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..c61a75f --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +./manage.py migrate --no-input +./manage.py runserver 0.0.0.0:8000 From 331467588b22541cc6aade41319d63c84835e577 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Tue, 25 Nov 2025 16:52:39 +0900 Subject: [PATCH 05/93] feat: add `/health` endpoint and Docker `HEALTHCHECK` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement GET /health - Add Dockerfile HEALTHCHECK Reason: 컨테이너 생존 여부를 검사하기 위한 수단이 필요하여 구현. --- Dockerfile | 1 + app/urls.py | 4 ++++ app/views.py | 8 ++++++++ healthcheck.sh | 7 +++++++ 4 files changed, 20 insertions(+) create mode 100755 healthcheck.sh diff --git a/Dockerfile b/Dockerfile index ed9d0bb..171dd16 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,4 +24,5 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . /app/ # Run the server +HEALTHCHECK --interval=5s CMD [ "./healthcheck.sh" ] ENTRYPOINT ["./entrypoint.sh"] diff --git a/app/urls.py b/app/urls.py index a0a2ab8..ed31a5c 100644 --- a/app/urls.py +++ b/app/urls.py @@ -22,6 +22,10 @@ urlpatterns = [ path('admin/', admin.site.urls), + # Service Status + # - `HEALTHCHECK` support for Docker Container + path('health/', app.views.health), + # Swagger Support path('swagger/', app.views.schema.with_ui('swagger')), path('swagger/(?P\\.json|\\.yaml)', app.views.schema.without_ui()), diff --git a/app/views.py b/app/views.py index 71e5d08..88781a2 100644 --- a/app/views.py +++ b/app/views.py @@ -1,5 +1,8 @@ +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.permissions import AllowAny @@ -14,3 +17,8 @@ public=True, permission_classes=[AllowAny], ) + + +@swagger_auto_schema(method='get') +def health(request: HttpRequest): + return HttpResponse(status=200) diff --git a/healthcheck.sh b/healthcheck.sh new file mode 100755 index 0000000..8a9ab17 --- /dev/null +++ b/healthcheck.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +if curl --silent --fail http://localhost:8000/health/ > /dev/null; then + exit 0 +else + exit 1 +fi From c5ba2b4ec50b75fb8e4642ebd7497696f9871881 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Wed, 26 Nov 2025 16:13:27 +0900 Subject: [PATCH 06/93] build(docker): change image `python:3.9` to `python:3.11-slim` --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 171dd16..8bb0df2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.9 +FROM python:3.11-slim # Create the app directory RUN mkdir /app From 7082a8da30c3fb5c1cbb94d18637a7e7fee4da91 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Wed, 26 Nov 2025 16:19:13 +0900 Subject: [PATCH 07/93] fix(docker): improve Dockerfile structure and healthcheck configuration --- Dockerfile | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index 8bb0df2..32d31aa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,28 +1,28 @@ FROM python:3.11-slim -# Create the app directory -RUN mkdir /app - # 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 +# Prevents Python from buffering stdout and stderr ENV PYTHONUNBUFFERED=1 # Upgrade pip RUN pip install --upgrade pip -# Copy the Django project and install dependencies -COPY requirements.txt /app/ +# Copy the Django project and install dependencies +COPY requirements.txt /app/ -# run this command to install all dependencies +# Run this command to install all dependencies RUN pip install --no-cache-dir -r requirements.txt COPY . /app/ +# Ensure shell scripts are executable +RUN chmod +x /app/healthcheck.sh /app/entrypoint.sh + # Run the server -HEALTHCHECK --interval=5s CMD [ "./healthcheck.sh" ] +HEALTHCHECK --interval=10s --timeout=3s --start-period=30s --retries=3 CMD [ "/app/healthcheck.sh" ] ENTRYPOINT ["./entrypoint.sh"] From 3f357573f7d7fec589f030d65149c0ce6f28bf5f Mon Sep 17 00:00:00 2001 From: Hepheir Date: Thu, 27 Nov 2025 12:41:43 +0900 Subject: [PATCH 08/93] =?UTF-8?q?fix(views):=20add=20`/health`=20endpoint?= =?UTF-8?q?=EA=B0=80=20GET=20=EC=9A=94=EC=B2=AD=EB=A7=8C=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/views.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/views.py b/app/views.py index 88781a2..4202b21 100644 --- a/app/views.py +++ b/app/views.py @@ -1,5 +1,6 @@ from django.http import HttpRequest from django.http import HttpResponse +from django.views.decorators.http import require_http_methods from drf_yasg.openapi import Contact from drf_yasg.openapi import Info from drf_yasg.utils import swagger_auto_schema @@ -19,6 +20,7 @@ ) +@require_http_methods(["GET"]) @swagger_auto_schema(method='get') def health(request: HttpRequest): return HttpResponse(status=200) From 3944e809c4411dd3eff83373e8f340af247af145 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Thu, 27 Nov 2025 12:42:35 +0900 Subject: [PATCH 09/93] fix(entrypoint): ensure script exits on error by adding `set -e` --- entrypoint.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/entrypoint.sh b/entrypoint.sh index c61a75f..4628bc8 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,4 +1,7 @@ #!/bin/bash +# Exit on any error +set -e + ./manage.py migrate --no-input ./manage.py runserver 0.0.0.0:8000 From c4359e88ea4eafdf7714707ac9c038ef8e1b9ca2 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Thu, 27 Nov 2025 13:01:40 +0900 Subject: [PATCH 10/93] build(docker): use `gunicorn` to run server & removed entrypoint.sh --- Dockerfile | 11 ++++++----- entrypoint.sh | 7 ------- requirements.txt | 1 + 3 files changed, 7 insertions(+), 12 deletions(-) delete mode 100755 entrypoint.sh diff --git a/Dockerfile b/Dockerfile index 32d31aa..a6896ee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,9 +20,10 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . /app/ -# Ensure shell scripts are executable -RUN chmod +x /app/healthcheck.sh /app/entrypoint.sh - -# Run the server +# Schedule health check +RUN chmod +x /app/healthcheck.sh HEALTHCHECK --interval=10s --timeout=3s --start-period=30s --retries=3 CMD [ "/app/healthcheck.sh" ] -ENTRYPOINT ["./entrypoint.sh"] + +# Run server (multi-threaded) +RUN /app/manage.py migrate --no-input +ENTRYPOINT ["gunicorn", "app.wsgi:application", "--bind", "0.0.0.0:8000"] diff --git a/entrypoint.sh b/entrypoint.sh deleted file mode 100755 index 4628bc8..0000000 --- a/entrypoint.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -# Exit on any error -set -e - -./manage.py migrate --no-input -./manage.py runserver 0.0.0.0:8000 diff --git a/requirements.txt b/requirements.txt index 7c303eb..8117762 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ django djangorestframework drf-yasg +gunicorn From b2ff57db84b3ccca540e6fd6a9c44727f396ddb3 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Thu, 27 Nov 2025 13:06:40 +0900 Subject: [PATCH 11/93] fix(docker): add `RUN` command to install `curl` --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index a6896ee..1d090f9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,6 +21,7 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . /app/ # Schedule health check +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* RUN chmod +x /app/healthcheck.sh HEALTHCHECK --interval=10s --timeout=3s --start-period=30s --retries=3 CMD [ "/app/healthcheck.sh" ] From 6848d6d36429d4d02cff6ab092460ab4ba62dc11 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Thu, 27 Nov 2025 13:07:05 +0900 Subject: [PATCH 12/93] build(docker): replace healthcheck script with inline `HEALTHCHECK` command in Dockerfile --- Dockerfile | 3 +-- healthcheck.sh | 7 ------- 2 files changed, 1 insertion(+), 9 deletions(-) delete mode 100755 healthcheck.sh diff --git a/Dockerfile b/Dockerfile index 1d090f9..a2b84a1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,8 +22,7 @@ COPY . /app/ # Schedule health check RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* -RUN chmod +x /app/healthcheck.sh -HEALTHCHECK --interval=10s --timeout=3s --start-period=30s --retries=3 CMD [ "/app/healthcheck.sh" ] +HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 CMD ["curl", "--silent", "http://localhost:8000/health/"] # Run server (multi-threaded) RUN /app/manage.py migrate --no-input diff --git a/healthcheck.sh b/healthcheck.sh deleted file mode 100755 index 8a9ab17..0000000 --- a/healthcheck.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -if curl --silent --fail http://localhost:8000/health/ > /dev/null; then - exit 0 -else - exit 1 -fi From 22f6ee58587989e5f8fa2b9b51fd85dde4f90dc4 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Thu, 27 Nov 2025 13:35:39 +0900 Subject: [PATCH 13/93] =?UTF-8?q?build(docker):=20gunicorn=20=EC=9D=B4=20?= =?UTF-8?q?=EB=A9=80=ED=8B=B0=EC=8A=A4=EB=A0=88=EB=93=9C=EB=A1=9C=20?= =?UTF-8?q?=EB=8F=99=EC=9E=91=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20(4=20workers,=202=20threads)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index a2b84a1..0c6fdfa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,6 +24,7 @@ COPY . /app/ RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 CMD ["curl", "--silent", "http://localhost:8000/health/"] -# Run server (multi-threaded) +# Run server RUN /app/manage.py migrate --no-input -ENTRYPOINT ["gunicorn", "app.wsgi:application", "--bind", "0.0.0.0:8000"] +ENTRYPOINT ["gunicorn", "app.wsgi:application"] +CMD ["--bind", "0.0.0.0:8000", "--workers", "4", "--threads", "2"] From 8578187576848f80bd3075c51a1a368eb534d8b6 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Thu, 27 Nov 2025 13:56:21 +0900 Subject: [PATCH 14/93] build(docker): use non-root user for security best practices --- Dockerfile | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 0c6fdfa..da9591e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,12 +16,16 @@ RUN pip install --upgrade pip COPY requirements.txt /app/ # Run this command to install all dependencies +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* RUN pip install --no-cache-dir -r requirements.txt +# Create non-root user +RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app +USER appuser + COPY . /app/ # Schedule health check -RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 CMD ["curl", "--silent", "http://localhost:8000/health/"] # Run server From c30f0e12cb783ff8f43bfca97d363213bd189dc1 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Thu, 27 Nov 2025 14:03:28 +0900 Subject: [PATCH 15/93] =?UTF-8?q?chore(docker):=20migrate=20=EB=AA=85?= =?UTF-8?q?=EB=A0=B9=EC=9D=84=20=EC=A0=9C=EA=B1=B0=ED=95=98=EB=9D=BC?= =?UTF-8?q?=EB=8A=94=20TODO=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=82=BD?= =?UTF-8?q?=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index da9591e..9f7a087 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,7 +28,10 @@ COPY . /app/ # Schedule health check HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 CMD ["curl", "--silent", "http://localhost:8000/health/"] -# Run server +# TODO: Remove this line when local SQLite DB is not used anymore. +# 로컬 Sqlite3를 사용하지 않게되면 이 명령을 제거할 것. RUN /app/manage.py migrate --no-input + +# Run server ENTRYPOINT ["gunicorn", "app.wsgi:application"] CMD ["--bind", "0.0.0.0:8000", "--workers", "4", "--threads", "2"] From b078b7e2b87e7ddc4092a19caa142f2f3f2b01b5 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Thu, 27 Nov 2025 14:28:33 +0900 Subject: [PATCH 16/93] =?UTF-8?q?chore(gitignore):=20=EC=B5=9C=EC=83=81?= =?UTF-8?q?=EC=9C=84=20=EA=B2=BD=EB=A1=9C=EC=9D=98=20`.`=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EC=8B=9C=EC=9E=91=ED=95=98=EB=8A=94=20=ED=8C=8C=EC=9D=BC/?= =?UTF-8?q?=ED=8F=B4=EB=8D=94=EB=A5=BC=20=EB=AC=B4=EC=8B=9C=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index e15106e..debb5e3 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,7 @@ __marimo__/ # Streamlit .streamlit/secrets.toml + +# Dot files +.* +!.gitignore From f8a2311f8ba8718402bd8a9eaeefea1c6e18c08b Mon Sep 17 00:00:00 2001 From: Hepheir Date: Thu, 27 Nov 2025 14:28:51 +0900 Subject: [PATCH 17/93] chore: add .dockerignore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .gitignore 파일의 내용과 1줄을 제외하고 동일합니다. 해당 1개 줄은 .gitignore에 .dockerignore를 무시하지 않도록 추가하는 한 줄입니다. --- .dockerignore | 219 ++++++++++++++++++++++++++++++++++++++++++++++++++ .gitignore | 1 + 2 files changed, 220 insertions(+) create mode 100644 .dockerignore 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 debb5e3..e8fee5d 100644 --- a/.gitignore +++ b/.gitignore @@ -217,4 +217,5 @@ __marimo__/ # Dot files .* +!.dockerignore !.gitignore From 24dab47f77ae3c21e219989b55304b2db2e44168 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Thu, 27 Nov 2025 19:59:08 +0900 Subject: [PATCH 18/93] =?UTF-8?q?settings:=20`SECRET=5FKEY`,=20`DEBUG`,=20?= =?UTF-8?q?`ALLOWED=5FHOSTS`=20=EC=86=8D=EC=84=B1=EC=9D=84=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EB=B3=80=EC=88=98=EC=97=90=EC=84=9C=20=EB=B6=88?= =?UTF-8?q?=EB=9F=AC=EC=98=A4=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/settings.py | 17 ++++++++++++++--- requirements.txt | 1 + 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/app/settings.py b/app/settings.py index 8437a84..6fff983 100644 --- a/app/settings.py +++ b/app/settings.py @@ -10,7 +10,12 @@ https://docs.djangoproject.com/en/5.2/ref/settings/ """ +from dotenv import load_dotenv from pathlib import Path +import os + +load_dotenv() + # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -20,12 +25,18 @@ # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'django-insecure-dulqz#%k5aq8%cdx02%55tm9+2q1b%qogh%2#6jbx3zn4ab5pb' +SECRET_KEY = os.getenv('SECRET_KEY') + +assert SECRET_KEY is not None, ( + 'SECRET_KEY is not set. ' + 'Please set SECRET_KEY environment variable.' +) + # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +DEBUG = os.getenv('DEBUG', False) -ALLOWED_HOSTS = [] +ALLOWED_HOSTS = list(filter(None, os.getenv('ALLOWED_HOSTS', '').split(','))) # Application definition diff --git a/requirements.txt b/requirements.txt index 8117762..1073972 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ django djangorestframework drf-yasg gunicorn +python-dotenv From 52d0d6646a52d74b8aba87900cd088d2787bc239 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Thu, 27 Nov 2025 20:32:05 +0900 Subject: [PATCH 19/93] =?UTF-8?q?feat:=20static=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swagger UI, Admin 페이지 편의를 위하여 추가 --- app/settings.py | 3 +++ app/urls.py | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/app/settings.py b/app/settings.py index 6fff983..38ffe9e 100644 --- a/app/settings.py +++ b/app/settings.py @@ -130,6 +130,9 @@ 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 diff --git a/app/urls.py b/app/urls.py index ed31a5c..f08ab0c 100644 --- a/app/urls.py +++ b/app/urls.py @@ -14,6 +14,8 @@ 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 import app.views @@ -29,4 +31,7 @@ # Swagger Support path('swagger/', app.views.schema.with_ui('swagger')), path('swagger/(?P\\.json|\\.yaml)', app.views.schema.without_ui()), + + # Static Files Serving + *static(settings.STATIC_URL, document_root=settings.STATIC_ROOT), ] From 85acb0d3e5798b3fef80612e12fe51ee06b3e345 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Thu, 27 Nov 2025 20:33:44 +0900 Subject: [PATCH 20/93] =?UTF-8?q?build(docker):=20entrypoint.sh=20?= =?UTF-8?q?=EC=9D=84=20=EC=82=AC=EC=9A=A9=ED=95=98=EC=97=AC=20`DEBUG`=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20=EC=8B=9C=EC=97=90=EB=A7=8C=20DB=20migrati?= =?UTF-8?q?on=20=EC=88=98=ED=96=89=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 11 ++++------- entrypoint.sh | 13 +++++++++++++ 2 files changed, 17 insertions(+), 7 deletions(-) create mode 100755 entrypoint.sh diff --git a/Dockerfile b/Dockerfile index 9f7a087..1786f4a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,19 +19,16 @@ COPY requirements.txt /app/ RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* RUN pip install --no-cache-dir -r requirements.txt +COPY . /app/ +RUN chmod +x /app/entrypoint.sh + # Create non-root user RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app USER appuser -COPY . /app/ - # Schedule health check HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 CMD ["curl", "--silent", "http://localhost:8000/health/"] -# TODO: Remove this line when local SQLite DB is not used anymore. -# 로컬 Sqlite3를 사용하지 않게되면 이 명령을 제거할 것. -RUN /app/manage.py migrate --no-input - # Run server -ENTRYPOINT ["gunicorn", "app.wsgi:application"] +ENTRYPOINT ["/app/entrypoint.sh"] CMD ["--bind", "0.0.0.0:8000", "--workers", "4", "--threads", "2"] diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..5074a1e --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +# Exit on any error +set -e + +echo "[DEBUG] $@" +if [ "$DEBUG" = "true" ]; then + ./manage.py migrate --no-input + ./manage.py collectstatic --no-input +fi + +# Run server +gunicorn app.wsgi:application "$@" From a20783c0f6151a5856aead7b0b342c18be00853e Mon Sep 17 00:00:00 2001 From: Hepheir Date: Thu, 27 Nov 2025 20:40:34 +0900 Subject: [PATCH 21/93] =?UTF-8?q?refactor:=20`/health/`=20endpoint?= =?UTF-8?q?=EC=97=90=20=EB=8C=80=ED=95=9C=20Swagger=20=EC=A7=80=EC=9B=90?= =?UTF-8?q?=EC=9D=B4=20=EC=A0=95=EC=83=81=EC=A0=81=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=90=98=EA=B3=A0=20=EC=9E=88=EC=A7=80=20=EC=95=8A=EB=8D=98=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95=20(FBV=20->=20CBV=20?= =?UTF-8?q?=EC=A0=84=ED=99=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Function Based View에는 `swagger_auto_schema` 가 정상적으로 적용되지 않는 이슈를 해결했습니다. (#2) --- app/urls.py | 2 +- app/views.py | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/app/urls.py b/app/urls.py index f08ab0c..4f71b52 100644 --- a/app/urls.py +++ b/app/urls.py @@ -26,7 +26,7 @@ # Service Status # - `HEALTHCHECK` support for Docker Container - path('health/', app.views.health), + path('health/', app.views.HealthCheck.as_view()), # Swagger Support path('swagger/', app.views.schema.with_ui('swagger')), diff --git a/app/views.py b/app/views.py index 4202b21..deebb31 100644 --- a/app/views.py +++ b/app/views.py @@ -1,11 +1,11 @@ from django.http import HttpRequest from django.http import HttpResponse -from django.views.decorators.http import require_http_methods 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.permissions import AllowAny +from rest_framework.views import APIView schema = get_schema_view( @@ -20,7 +20,15 @@ ) -@require_http_methods(["GET"]) -@swagger_auto_schema(method='get') -def health(request: HttpRequest): - return HttpResponse(status=200) +class HealthCheck(APIView): + """ + Health Check API + + 배포된 컨테이너의 상태 검사를 위한 endpoint. + 컨테이너가 정상적으로 구동되었으면 200 OK를 반환한다. + """ + permission_classes = [AllowAny] + + @swagger_auto_schema() + def get(self, request: HttpRequest): + return HttpResponse(status=200) From 6ae436f15144cbffd0e746cea562461f62563a18 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Thu, 27 Nov 2025 21:11:39 +0900 Subject: [PATCH 22/93] =?UTF-8?q?refactor:=20`HealthCheck`=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?(->=20`HealthCheckAPIView`)=20&=20=ED=95=98=EB=93=9C=EC=BD=94?= =?UTF-8?q?=EB=94=A9=EB=90=9C=20=EC=83=81=ED=83=9C=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=83=81=EC=88=98=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/urls.py | 2 +- app/views.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/urls.py b/app/urls.py index 4f71b52..67b97fe 100644 --- a/app/urls.py +++ b/app/urls.py @@ -26,7 +26,7 @@ # Service Status # - `HEALTHCHECK` support for Docker Container - path('health/', app.views.HealthCheck.as_view()), + path('health/', app.views.HealthCheckAPIView.as_view()), # Swagger Support path('swagger/', app.views.schema.with_ui('swagger')), diff --git a/app/views.py b/app/views.py index deebb31..e5d345c 100644 --- a/app/views.py +++ b/app/views.py @@ -4,6 +4,7 @@ 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.permissions import AllowAny from rest_framework.views import APIView @@ -20,7 +21,7 @@ ) -class HealthCheck(APIView): +class HealthCheckAPIView(APIView): """ Health Check API @@ -31,4 +32,4 @@ class HealthCheck(APIView): @swagger_auto_schema() def get(self, request: HttpRequest): - return HttpResponse(status=200) + return HttpResponse(status=status.HTTP_200_OK) From b746487e53c4fbab27fccf1b93bfe9c71a3b9f9f Mon Sep 17 00:00:00 2001 From: Hepheir Date: Thu, 27 Nov 2025 21:12:32 +0900 Subject: [PATCH 23/93] =?UTF-8?q?fix(docker):=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EA=B0=80=20200=20OK=EA=B0=80=20=EC=95=84?= =?UTF-8?q?=EB=8B=88=EC=96=B4=EB=8F=84=20healthy=20=EB=A1=9C=20=ED=8C=90?= =?UTF-8?q?=EB=B3=84=EB=90=98=EB=8A=94=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 1786f4a..7ceaa48 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,7 +27,7 @@ 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", "http://localhost:8000/health/"] +HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 CMD ["curl", "--silent", "--fail", "http://localhost:8000/health/"] # Run server ENTRYPOINT ["/app/entrypoint.sh"] From f558b0ef660f18b553018ec9d1f083b9065f4b9b Mon Sep 17 00:00:00 2001 From: Hepheir Date: Thu, 27 Nov 2025 21:33:28 +0900 Subject: [PATCH 24/93] fix(settings): ensure `ALLOWED_HOSTS` is set when `DEBUG` is off --- app/settings.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/settings.py b/app/settings.py index 38ffe9e..9041fa7 100644 --- a/app/settings.py +++ b/app/settings.py @@ -38,6 +38,11 @@ ALLOWED_HOSTS = list(filter(None, os.getenv('ALLOWED_HOSTS', '').split(','))) +assert DEBUG or ALLOWED_HOSTS, ( + 'ALLOWED_HOSTS is not set. ' + 'Please set ALLOWED_HOSTS environment variable.' +) + # Application definition From a8e1ac4676220cd8114f65396032f2d82835186c Mon Sep 17 00:00:00 2001 From: Hepheir Date: Thu, 27 Nov 2025 21:44:10 +0900 Subject: [PATCH 25/93] fix(docker): update entrypoint.sh to use `python` command for manage.py --- entrypoint.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/entrypoint.sh b/entrypoint.sh index 5074a1e..088d9a3 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -5,8 +5,8 @@ set -e echo "[DEBUG] $@" if [ "$DEBUG" = "true" ]; then - ./manage.py migrate --no-input - ./manage.py collectstatic --no-input + python manage.py migrate --no-input + python manage.py collectstatic --no-input fi # Run server From 73229c462eaca03ce7efc7fa179082d193467f6d Mon Sep 17 00:00:00 2001 From: Hepheir Date: Thu, 27 Nov 2025 22:07:37 +0900 Subject: [PATCH 26/93] =?UTF-8?q?fix(urls):=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=82=98=20Swagger,=20static=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=84=9C=EB=B9=99=EC=9D=80=20`DEBUG`=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=EC=97=90=EC=84=9C=EB=A7=8C=20=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/urls.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/app/urls.py b/app/urls.py index 67b97fe..9fa8932 100644 --- a/app/urls.py +++ b/app/urls.py @@ -22,16 +22,20 @@ urlpatterns = [ - path('admin/', admin.site.urls), - # Service Status # - `HEALTHCHECK` support for Docker Container path('health/', app.views.HealthCheckAPIView.as_view()), +] - # Swagger Support - path('swagger/', app.views.schema.with_ui('swagger')), - path('swagger/(?P\\.json|\\.yaml)', app.views.schema.without_ui()), +if settings.DEBUG: + urlpatterns += [ + path('admin/', admin.site.urls), - # Static Files Serving - *static(settings.STATIC_URL, document_root=settings.STATIC_ROOT), -] + # Swagger Support + path(r'swagger/', app.views.schema.with_ui('swagger')), + 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), + ] From 29863e1c9acbddcc37fc0cb323351eb653da7970 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Thu, 27 Nov 2025 22:08:36 +0900 Subject: [PATCH 27/93] =?UTF-8?q?build(docker):=20multi-container=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=EC=9D=84=20=EA=B3=A0=EB=A0=A4=ED=95=98?= =?UTF-8?q?=EC=97=AC=20migration=EC=9D=80=20=EB=B3=84=EB=8F=84=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=ED=96=89=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=AA=85?= =?UTF-8?q?=EB=A0=B9=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- entrypoint.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/entrypoint.sh b/entrypoint.sh index 088d9a3..9569fdd 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -5,7 +5,6 @@ set -e echo "[DEBUG] $@" if [ "$DEBUG" = "true" ]; then - python manage.py migrate --no-input python manage.py collectstatic --no-input fi From bc6c74917c113157a5ea0e959878d7f2d682e787 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Thu, 27 Nov 2025 22:15:33 +0900 Subject: [PATCH 28/93] =?UTF-8?q?refactor:=20entrypoint=20argument=20?= =?UTF-8?q?=EC=B6=9C=EB=A0=A5=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20[DEBUG]=20->=20[ENTRYPOINT]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- entrypoint.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/entrypoint.sh b/entrypoint.sh index 9569fdd..681b9a6 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,9 +1,10 @@ #!/bin/bash +echo "[ENTRYPOINT] $@" + # Exit on any error set -e -echo "[DEBUG] $@" if [ "$DEBUG" = "true" ]; then python manage.py collectstatic --no-input fi From a1abf3fe72fa36018b091ce8ebc3c7dc50918582 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Thu, 27 Nov 2025 22:26:35 +0900 Subject: [PATCH 29/93] =?UTF-8?q?refactor(urls):=20=EC=A0=95=EA=B7=9C?= =?UTF-8?q?=ED=91=9C=ED=98=84=EC=8B=9D=20=EA=B8=B0=EB=B0=98=20url=EC=9D=80?= =?UTF-8?q?=20`re=5Fpath()`=EB=A1=9C=20=EB=A7=A4=ED=95=91=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/urls.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/urls.py b/app/urls.py index 9fa8932..6486ad0 100644 --- a/app/urls.py +++ b/app/urls.py @@ -18,6 +18,7 @@ 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 @@ -32,8 +33,8 @@ path('admin/', admin.site.urls), # Swagger Support - path(r'swagger/', app.views.schema.with_ui('swagger')), - path(r'swagger/(?P\.json|\.yaml)', + 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 From 1460cbf3436b0e178c5fefddef6516930ed66dd0 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Thu, 27 Nov 2025 22:28:55 +0900 Subject: [PATCH 30/93] =?UTF-8?q?docs(views):=20`HealthCheckAPIView`=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=EC=97=90=20=EB=8C=80=ED=95=9C=20=EC=84=A4?= =?UTF-8?q?=EB=AA=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/views.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/views.py b/app/views.py index e5d345c..86163d4 100644 --- a/app/views.py +++ b/app/views.py @@ -30,6 +30,10 @@ class HealthCheckAPIView(APIView): """ permission_classes = [AllowAny] - @swagger_auto_schema() + @swagger_auto_schema( + responses={ + status.HTTP_200_OK: "Container is healthy.", + } + ) def get(self, request: HttpRequest): return HttpResponse(status=status.HTTP_200_OK) From f9550e8f8739c89bbee0543838d9e831e49afdff Mon Sep 17 00:00:00 2001 From: Hepheir Date: Fri, 28 Nov 2025 16:48:07 +0900 Subject: [PATCH 31/93] =?UTF-8?q?test(app):=20add=20`HealthCheckAPIView`?= =?UTF-8?q?=20=EC=97=90=20=EB=8C=80=ED=95=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/tests.py | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 app/tests.py diff --git a/app/tests.py b/app/tests.py new file mode 100644 index 0000000..6855620 --- /dev/null +++ b/app/tests.py @@ -0,0 +1,10 @@ +from django.test import TestCase + + +class HealthCheckAPIViewTest(TestCase): + def test_get_200(self): + """ + GET /health/ 요청 시 200 OK를 반환하는지 테스트. + """ + response = self.client.get('/health/') + self.assertEqual(response.status_code, 200) From 68e81290a21c7148385db037645e639787f8cce4 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Fri, 28 Nov 2025 17:07:24 +0900 Subject: [PATCH 32/93] =?UTF-8?q?chore:=20=EA=B7=A0=EC=9D=BC=ED=95=9C=20?= =?UTF-8?q?=EC=9D=B8=EB=8D=B4=ED=8A=B8=20=EC=A0=81=EC=9A=A9=20(=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=8A=A4=ED=83=80=EC=9D=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/urls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/urls.py b/app/urls.py index 6486ad0..8106877 100644 --- a/app/urls.py +++ b/app/urls.py @@ -35,7 +35,7 @@ # Swagger Support path('swagger/', app.views.schema.with_ui('swagger')), re_path(r'swagger/(?P\.json|\.yaml)', - app.views.schema.without_ui()), + app.views.schema.without_ui()), # Serve static files only in development *static(settings.STATIC_URL, document_root=settings.STATIC_ROOT), From a769a1b7a20ef20a52483eb417c1c3070c468e0b Mon Sep 17 00:00:00 2001 From: Hepheir Date: Fri, 28 Nov 2025 17:18:13 +0900 Subject: [PATCH 33/93] build(docker): replace entrypoint script with inline `ENTRYPOINT` command in Dockerfile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이 과정에서 `collectstatic` 명령도 별도로 해주어야 하는 것으로 바뀌었습니다. 왜 이 커밋을 작성? : settings.py에서 DEBUG = os.getenv('DEBUG', False) 의 값이 문자열일 수 있고, 'true', 't', '1', 'on' 과 같은 truthy 값을 받아야하는 문제가 존재하는 상황. 이를 예외처리하려면 entrypoint.sh 에서 DEBUG 환경변수 값에 따라 조건분기하는 로직에도 영향을 주게되기에, 1. 예외처리를 포기하거나 2. entrypoint.sh 에서 디버그 조건분기를 제거하여 변경사항의 전파와 관리포인트를 최소화하거나 3. 관리 포인트 증가의 리스크를 떠안고 entrypoint.sh 과 settings.py 에 동일한 truthy 검사 로직을 구현하거나 택 1 해야했던 상황이고, 2번을 선택하였다. 그 결과 entrypoint.sh 에는 이 커밋에서 변경사항을 적용한 후 Dockerfile 의 ENTRYPOINT inline 코드와 동일한 코드 밖에 안남아서 entrypoint.sh로 ENTRYPOINT 로직을 분리할 이유가 사라진 것이다. --- Dockerfile | 2 +- entrypoint.sh | 13 ------------- 2 files changed, 1 insertion(+), 14 deletions(-) delete mode 100755 entrypoint.sh diff --git a/Dockerfile b/Dockerfile index 7ceaa48..2557210 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,5 +30,5 @@ USER appuser HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 CMD ["curl", "--silent", "--fail", "http://localhost:8000/health/"] # Run server -ENTRYPOINT ["/app/entrypoint.sh"] +ENTRYPOINT ["gunicorn", "app.wsgi:application"] CMD ["--bind", "0.0.0.0:8000", "--workers", "4", "--threads", "2"] diff --git a/entrypoint.sh b/entrypoint.sh deleted file mode 100755 index 681b9a6..0000000 --- a/entrypoint.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -echo "[ENTRYPOINT] $@" - -# Exit on any error -set -e - -if [ "$DEBUG" = "true" ]; then - python manage.py collectstatic --no-input -fi - -# Run server -gunicorn app.wsgi:application "$@" From 35def212d1591211c9836d909bf8d747b251bf12 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Fri, 28 Nov 2025 17:18:34 +0900 Subject: [PATCH 34/93] =?UTF-8?q?feat:=20settings.py=20=EC=97=90=EC=84=9C?= =?UTF-8?q?=20truthy,=20falsy=20=EA=B0=92=EC=9D=84=20=EA=B5=AC=EB=B6=84?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/settings.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/app/settings.py b/app/settings.py index 9041fa7..c775b89 100644 --- a/app/settings.py +++ b/app/settings.py @@ -14,6 +14,21 @@ from pathlib import Path import os + +def is_truthy(value: str) -> bool: + TRUTHY = ('true', '1', 't', 'y', 'yes', 'on') + FALSY = ('false', '0', 'f', 'n', 'no', 'off') + normalized_value = value.lower() + if normalized_value in FALSY: + return False + if normalized_value in TRUTHY: + return True + raise ValueError( + f'Invalid truthy/falsy value: {value}. ' + f'Expected one of {TRUTHY + FALSY}.' + ) + + load_dotenv() @@ -34,7 +49,7 @@ # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = os.getenv('DEBUG', False) +DEBUG = is_truthy(os.getenv('DEBUG', 'false')) ALLOWED_HOSTS = list(filter(None, os.getenv('ALLOWED_HOSTS', '').split(','))) From 0eee0931bde7f99b989e0a39e2cb5e60cd617f20 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Fri, 28 Nov 2025 17:30:32 +0900 Subject: [PATCH 35/93] chore(settings): improve error message for empty `ALLOWED_HOSTS` --- app/settings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/settings.py b/app/settings.py index c775b89..e7f38d6 100644 --- a/app/settings.py +++ b/app/settings.py @@ -54,8 +54,8 @@ def is_truthy(value: str) -> bool: ALLOWED_HOSTS = list(filter(None, os.getenv('ALLOWED_HOSTS', '').split(','))) assert DEBUG or ALLOWED_HOSTS, ( - 'ALLOWED_HOSTS is not set. ' - 'Please set ALLOWED_HOSTS environment variable.' + 'ALLOWED_HOSTS is not set or empty. ' + 'Please set ALLOWED_HOSTS as a comma-separated list (e.g., "example.com,www.example.com").' ) From dea47228d21c8396f4832c1d973670f43d2373cc Mon Sep 17 00:00:00 2001 From: Hepheir Date: Fri, 28 Nov 2025 17:31:12 +0900 Subject: [PATCH 36/93] fix(docker): remove outdated entrypoint script permission change --- Dockerfile | 1 - 1 file changed, 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 2557210..36273ef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,7 +20,6 @@ RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* RUN pip install --no-cache-dir -r requirements.txt COPY . /app/ -RUN chmod +x /app/entrypoint.sh # Create non-root user RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app From 57c843a0a6583490f9b498ce26dae65dd3afa6bc Mon Sep 17 00:00:00 2001 From: Hepheir Date: Fri, 28 Nov 2025 17:36:33 +0900 Subject: [PATCH 37/93] =?UTF-8?q?fix(settings):=20`is=5Ftruthy()`=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=EA=B0=80=20None=EC=9D=84=20=ED=8F=AC?= =?UTF-8?q?=ED=95=A8=ED=95=9C=20=EC=97=AC=EB=9F=AC=20=ED=83=80=EC=9E=85?= =?UTF-8?q?=EC=9D=84=20=EC=B2=98=EB=A6=AC=ED=95=A0=20=EC=88=98=20=EC=9E=88?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/settings.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/app/settings.py b/app/settings.py index e7f38d6..55151ba 100644 --- a/app/settings.py +++ b/app/settings.py @@ -12,20 +12,29 @@ from dotenv import load_dotenv from pathlib import Path +from typing import Union import os -def is_truthy(value: str) -> bool: +def is_truthy(value: Union[str, int, bool, None]) -> bool: TRUTHY = ('true', '1', 't', 'y', 'yes', 'on') FALSY = ('false', '0', 'f', 'n', 'no', 'off') - normalized_value = value.lower() - if normalized_value in FALSY: + if value is None: return False - if normalized_value in TRUTHY: - return True + if isinstance(value, bool): + return value + if isinstance(value, int): + return bool(value) + if isinstance(value, str): + value = value.strip().lower() + if value in TRUTHY: + return True + if value in FALSY: + return False raise ValueError( f'Invalid truthy/falsy value: {value}. ' - f'Expected one of {TRUTHY + FALSY}.' + 'Please set the value as an interger, or a boolean or a string. ' + f'Allowed string values are: {TRUTHY + FALSY}. ' ) From af185deac2c33d599d9c1a6a172b23570077038c Mon Sep 17 00:00:00 2001 From: Hepheir Date: Fri, 28 Nov 2025 17:43:11 +0900 Subject: [PATCH 38/93] "build(docker): [Revert] replace entrypoint script with inline `ENTRYPOINT` command in Dockerfile" This reverts commit a769a1b7a20ef20a52483eb417c1c3070c468e0b. --- Dockerfile | 2 +- entrypoint.sh | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100755 entrypoint.sh diff --git a/Dockerfile b/Dockerfile index 36273ef..c27e8c1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,5 +29,5 @@ USER appuser HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 CMD ["curl", "--silent", "--fail", "http://localhost:8000/health/"] # Run server -ENTRYPOINT ["gunicorn", "app.wsgi:application"] +ENTRYPOINT ["/app/entrypoint.sh"] CMD ["--bind", "0.0.0.0:8000", "--workers", "4", "--threads", "2"] diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..681b9a6 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +echo "[ENTRYPOINT] $@" + +# Exit on any error +set -e + +if [ "$DEBUG" = "true" ]; then + python manage.py collectstatic --no-input +fi + +# Run server +gunicorn app.wsgi:application "$@" From af36d4cac57f25d07c51295af203a586bee5387a Mon Sep 17 00:00:00 2001 From: Hepheir Date: Fri, 28 Nov 2025 17:54:01 +0900 Subject: [PATCH 39/93] =?UTF-8?q?refactor(docker):=20django=20shell=20?= =?UTF-8?q?=EC=9D=84=20=EC=82=AC=EC=9A=A9=ED=95=98=EC=97=AC=20`DEBUG`=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=EC=9D=B8=EC=A7=80=20=EA=B2=80=EC=82=AC?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- entrypoint.sh | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/entrypoint.sh b/entrypoint.sh index 681b9a6..211e91e 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,13 +1,28 @@ #!/bin/bash -echo "[ENTRYPOINT] $@" -# Exit on any error -set -e +function debug_init() { + echo "[ENTRYPOINT] Running in DEBUG mode." -if [ "$DEBUG" = "true" ]; then + python manage.py migrate --no-input python manage.py collectstatic --no-input -fi +} -# Run server -gunicorn app.wsgi:application "$@" + +function main() { + echo "[ENTRYPOINT] $@" + + # Exit on any error + set -e + + # Check if DEBUG mode is enabled + echo 'from django.conf import settings; exit(0 if settings.DEBUG else 1);' \ + | python manage.py shell --no-imports \ + && debug_init + + # Run server + gunicorn app.wsgi:application "$@" +} + + +main "$@" From d5ff410263d7d3888b659aa36f4b6c06054684ca Mon Sep 17 00:00:00 2001 From: Hepheir Date: Fri, 28 Nov 2025 18:18:48 +0900 Subject: [PATCH 40/93] =?UTF-8?q?build(docker):=20=EA=B0=9C=EB=B0=9C?= =?UTF-8?q?=EC=9A=A9=20docker=20compose=20=EA=B5=AC=EC=84=B1=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.develop.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 docker-compose.develop.yml diff --git a/docker-compose.develop.yml b/docker-compose.develop.yml new file mode 100644 index 0000000..c8dfecb --- /dev/null +++ b/docker-compose.develop.yml @@ -0,0 +1,25 @@ +version: "3.8" + +services: + auth-service: + build: + context: . + dockerfile: Dockerfile + ports: + - "80:8000" + environment: + SECRET_KEY: 'django-insecure-dulqz#%k5aq8%cdx02%55tm9+2q1b%qogh%2#6jbx3zn4ab5pb' + DEBUG: 1 + ALLOWED_HOSTS: '*' + 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 From 346e5a5d0d35cbe70360d247cd4ae09c7bfd5b93 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Fri, 28 Nov 2025 18:51:56 +0900 Subject: [PATCH 41/93] settings: load `SECRET_KEY` from `SECRET_KEY_FILE` file if not set in environment --- app/settings.py | 6 +++++- docker-compose.develop.yml | 10 ++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/app/settings.py b/app/settings.py index 55151ba..25abf66 100644 --- a/app/settings.py +++ b/app/settings.py @@ -51,9 +51,13 @@ def is_truthy(value: Union[str, int, bool, None]) -> bool: # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = os.getenv('SECRET_KEY') +if SECRET_KEY is None and (secret_key_file := os.getenv('SECRET_KEY_FILE')) is not None: + with open(secret_key_file, 'rt') as f: + SECRET_KEY = f.read().strip() + assert SECRET_KEY is not None, ( 'SECRET_KEY is not set. ' - 'Please set SECRET_KEY environment variable.' + 'Please set SECRET_KEY or SECRET_KEY_FILE environment variable.' ) diff --git a/docker-compose.develop.yml b/docker-compose.develop.yml index c8dfecb..d931578 100644 --- a/docker-compose.develop.yml +++ b/docker-compose.develop.yml @@ -8,9 +8,11 @@ services: ports: - "80:8000" environment: - SECRET_KEY: 'django-insecure-dulqz#%k5aq8%cdx02%55tm9+2q1b%qogh%2#6jbx3zn4ab5pb' + SECRET_KEY_FILE: /run/secrets/secret_key DEBUG: 1 - ALLOWED_HOSTS: '*' + ALLOWED_HOSTS: "*" + secrets: + - secret_key develop: watch: - action: rebuild @@ -23,3 +25,7 @@ services: path: "." target: "/app/" initial_sync: true + +secrets: + secret_key: + file: ./.secrets/secret_key.txt From 1ca8e7a719ebc89a09ebfb2ff8a52551de893ba4 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Fri, 28 Nov 2025 19:38:13 +0900 Subject: [PATCH 42/93] =?UTF-8?q?refactor(settings):=20`get=5Fenv()`=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EC=9E=91=EC=84=B1=20=EB=B0=8F=20dotenv=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/settings.py | 73 ++++++++++++++++++++++++++++++++++++++---------- requirements.txt | 1 - 2 files changed, 58 insertions(+), 16 deletions(-) diff --git a/app/settings.py b/app/settings.py index 25abf66..e799ab9 100644 --- a/app/settings.py +++ b/app/settings.py @@ -10,10 +10,9 @@ https://docs.djangoproject.com/en/5.2/ref/settings/ """ -from dotenv import load_dotenv -from pathlib import Path -from typing import Union import os +from pathlib import Path +from typing import Any, Callable, Optional, Union def is_truthy(value: Union[str, int, bool, None]) -> bool: @@ -38,7 +37,47 @@ def is_truthy(value: Union[str, int, bool, None]) -> bool: ) -load_dotenv() +def get_env(key: str, + default: Optional[Any] = None, + file_key: Optional[str] = None, + mapper_fn: Optional[Callable[[Any], Any]] = None, + raise_on_none: bool = False, + raise_message: str = '') -> Any: + """ + Get environment variable value. + + 1. Try to read the value from the environment variable specified by `key`. + 2. If not set, try to read the value from the file specified by `file_key`. + 3. If still not set, use the `default` value. + 4. If still not set, raise an error if `raise_on_none` is `True`. + """ + def inner(): + # 1. try read from key + value = os.getenv(key) + if value is not None: + return value + + # 2. if not set, try read from file + if file_key is not None: + file_path = os.getenv(file_key) + if file_path is not None: + with open(file_path, 'rt') as f: + return f.read().strip() + + # 3. if still not set, try use default + if default is not None: + return default + + # 4. ... raise error + if raise_on_none: + raise ValueError(raise_message) + + return None + + if mapper_fn is None: + return inner() + + return mapper_fn(inner()) # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -49,22 +88,26 @@ def is_truthy(value: Union[str, int, bool, None]) -> bool: # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = os.getenv('SECRET_KEY') - -if SECRET_KEY is None and (secret_key_file := os.getenv('SECRET_KEY_FILE')) is not None: - with open(secret_key_file, 'rt') as f: - SECRET_KEY = f.read().strip() - -assert SECRET_KEY is not None, ( - 'SECRET_KEY is not set. ' - 'Please set SECRET_KEY or SECRET_KEY_FILE environment variable.' +SECRET_KEY = get_env( + key='SECRET_KEY', + file_key='SECRET_KEY_FILE', + raise_on_none=True, + raise_message=( + 'SECRET_KEY is not set. ' + 'Please set SECRET_KEY or SECRET_KEY_FILE environment variable.' + ), ) # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = is_truthy(os.getenv('DEBUG', 'false')) +DEBUG = get_env( + key='DEBUG', + default=False, + mapper_fn=is_truthy, + raise_on_none=False, +) -ALLOWED_HOSTS = list(filter(None, os.getenv('ALLOWED_HOSTS', '').split(','))) +ALLOWED_HOSTS = list(filter(None, get_env('ALLOWED_HOSTS', '').split(','))) assert DEBUG or ALLOWED_HOSTS, ( 'ALLOWED_HOSTS is not set or empty. ' diff --git a/requirements.txt b/requirements.txt index 1073972..8117762 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,3 @@ django djangorestframework drf-yasg gunicorn -python-dotenv From 003dd4b40c8627fa58d5dc61092b5c590cdbd274 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Fri, 28 Nov 2025 19:44:02 +0900 Subject: [PATCH 43/93] =?UTF-8?q?settings:=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=EB=B2=A0=EC=9D=B4=EC=8A=A4=EB=A5=BC=20PostgreSQL=EC=9D=84=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/settings.py | 20 ++++++++++++++++++-- requirements.txt | 1 + 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/app/settings.py b/app/settings.py index e799ab9..37a5c1c 100644 --- a/app/settings.py +++ b/app/settings.py @@ -164,8 +164,24 @@ def inner(): DATABASES = { 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', + 'ENGINE': 'django.db.backends.postgresql_psycopg2', + 'HOST': get_env("POSTGRES_HOST", + raise_on_none=True, + raise_message='POSTGRES_HOST is not set.'), + 'PORT': get_env("POSTGRES_PORT", + default="5432", + mapper_fn=int, + raise_on_none=False), + 'NAME': get_env("POSTGRES_DB", + raise_on_none=True, + raise_message='POSTGRES_DB is not set.'), + 'USER': get_env("POSTGRES_USER", + raise_on_none=True, + raise_message='POSTGRES_USER is not set.'), + 'PASSWORD': get_env("POSTGRES_PASSWORD", + file_key="POSTGRES_PASSWORD_FILE", + raise_on_none=True, + raise_message='POSTGRES_PASSWORD or POSTGRES_PASSWORD_FILE is not set.') } } diff --git a/requirements.txt b/requirements.txt index 8117762..6689c4b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ django djangorestframework drf-yasg gunicorn +psycopg2-binary From f5e1b65f0e98b7e5a94c0d19ceac71c3479b1301 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Fri, 28 Nov 2025 19:44:36 +0900 Subject: [PATCH 44/93] =?UTF-8?q?build(docker):=20docker=20compose=20?= =?UTF-8?q?=EC=97=90=EC=84=9C=20Postgres=20DB=EB=A5=BC=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=EC=9C=BC=EB=A1=9C=20=EA=B5=AC=EC=84=B1=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.develop.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docker-compose.develop.yml b/docker-compose.develop.yml index d931578..70f79d7 100644 --- a/docker-compose.develop.yml +++ b/docker-compose.develop.yml @@ -5,14 +5,22 @@ services: build: context: . dockerfile: Dockerfile + depends_on: + - auth-database ports: - "80:8000" environment: SECRET_KEY_FILE: /run/secrets/secret_key DEBUG: 1 ALLOWED_HOSTS: "*" + POSTGRES_HOST: auth-database + POSTGRES_PORT: 5432 + POSTGRES_DB: auth-database + POSTGRES_USER: tle-auth + POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password secrets: - secret_key + - postgres_password develop: watch: - action: rebuild @@ -26,6 +34,20 @@ services: target: "/app/" initial_sync: true + auth-database: + image: postgres:15-alpine + restart: always + volumes: + - ./.volume:/var/lib/postgresql/data + environment: + POSTGRES_DB: auth-database + POSTGRES_USER: tle-auth + POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password + secrets: + - postgres_password + secrets: secret_key: file: ./.secrets/secret_key.txt + postgres_password: + file: ./.secrets/postgres_password.txt From 9ce235b05defe62d69656a3e6dd73a431aafcaec Mon Sep 17 00:00:00 2001 From: Hepheir Date: Fri, 28 Nov 2025 20:14:23 +0900 Subject: [PATCH 45/93] =?UTF-8?q?docs:=20README.md=20=EC=9E=91=EC=84=B1=20?= =?UTF-8?q?(=EA=B0=9C=EB=B0=9C=20=ED=99=98=EA=B2=BD=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=EB=B0=8F=20=EB=B0=B0=ED=8F=AC=EB=B0=A9=EB=B2=95=20=EA=B0=84?= =?UTF-8?q?=EB=9E=B5=ED=95=98=EA=B2=8C=20=EA=B8=B0=EC=9E=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..6ccc18f --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# TLE Auth Service + +이 저장소는 TLE의 인증을 위한 서비스 개발이 이루어지는 곳입니다. + +TLE Auth Service는 사용자의 인증(JWT)부터, 회원가입, 그 외 기능들을 제공합니다. + +Django 프레임워크와 Django REST Framework를 기반으로 구축되었으며, Swagger UI를 통해 API 문서화가 이루어져 있습니다. + +> 서비스 구동 시 `DEBUG` 환경변수를 `1`로 설저아여 개발용으로 배포할 경우, 일부 Endpoint가 추가되어 Django 관리자 페이지와 Swagger UI에 접근할 수 있게 됩니다. + +## 개발 환경 설정 + +### 사전 요구사항 + +서비스 구동을 위해 다음의 의존성이 필요합니다: + +- Docker +- Docker Compose + +### Secrets 준비 + +- `/.secrets/secret_key.txt` 에 사용자 비밀번호 및 JWT 인코딩을 위한 `SECRET_KEY`를 작성합니다. +- `/.secrets/postgres_password.txt` 에 데이터베이스 비밀번호를 작성합니다. + +위 두 값 모두 임의로 변경하는 것이 가능하며, 변경하는 것이 보안적으로 더 우수합니다. + +### 로컬에 배포 + +```bash +docker compose -f docker-compose.develop.yml up -w +``` + +위 명령을 실행하여 Postgres 데이터베이스와 auth service를 로컬호스트에 배포합니다. + +Compose 구성에서 포트포워딩은 내부 8000 -> 외부 80번으로 매핑되어 있으므로 기존 세팅을 유지할 경우 http://localhost 에 접속하여 서비스에 엑세스 할 수 있습니다. + +혹은 docker-compose.develop.yml 의 `service.auth-service.ports` 를 수정하여 포트포워드 대상 포트를 변경할 수 있습니다. + + +## 여담 + +TLE 서비스의 모놀리식 아키텍쳐를 MSA로 전환하기 위해 첫 번째로 생성하는 서비스입니다. + +JWT: Claim에 사용자 정보를 담아 `SECRET_KEY`로 암호화 하여 생성된 JWT 토큰을 사용자 클라이언트에 저장하고, 이 정보를 다른 마이크로서비스에서 복호화하여 참조할 수 있도록 설계되었습니다. From fc75d379fd84fd757faa5ede9b1c54785fabbabb Mon Sep 17 00:00:00 2001 From: Hepheir Date: Fri, 28 Nov 2025 20:18:57 +0900 Subject: [PATCH 46/93] =?UTF-8?q?docs:=20Amazon=20Q=20Dev=EC=9D=98=20READM?= =?UTF-8?q?E.md=20=EC=B2=A8=EC=82=AD=20(Claude=20Sonnet=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 158 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 138 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 6ccc18f..5c01a69 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,162 @@ # TLE Auth Service -이 저장소는 TLE의 인증을 위한 서비스 개발이 이루어지는 곳입니다. +[![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 Auth Service는 사용자의 인증(JWT)부터, 회원가입, 그 외 기능들을 제공합니다. +TLE(Time Limit Exceeded)의 마이크로서비스 아키텍처를 위한 인증 서비스입니다. -Django 프레임워크와 Django REST Framework를 기반으로 구축되었으며, Swagger UI를 통해 API 문서화가 이루어져 있습니다. +## 개요 -> 서비스 구동 시 `DEBUG` 환경변수를 `1`로 설저아여 개발용으로 배포할 경우, 일부 Endpoint가 추가되어 Django 관리자 페이지와 Swagger UI에 접근할 수 있게 됩니다. +- **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 +``` -- Docker -- Docker Compose +> 💡 **개발 모드**: 개발 환경 실행 명령 옵션으로 `-w`를 추가하여 file watcher 기능을 활성화 할 수 있습니다. -### Secrets 준비 +### 4. 접속 확인 -- `/.secrets/secret_key.txt` 에 사용자 비밀번호 및 JWT 인코딩을 위한 `SECRET_KEY`를 작성합니다. -- `/.secrets/postgres_password.txt` 에 데이터베이스 비밀번호를 작성합니다. +- **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 -docker compose -f docker-compose.develop.yml up -w +# Django 단위 테스트 +python manage.py test +``` + + + +## 아키텍처 + +### 마이크로서비스 설계 + +TLE 서비스의 모놀리식 아키텍처를 MSA로 전환하는 첫 번째 서비스입니다. + +### JWT 토큰 구조 + + + +- **서명**: `SECRET_KEY`로 HMAC SHA256 알고리즘 사용 +- **저장**: 클라이언트 측 저장 (localStorage, cookie 등) +- **검증**: 다른 마이크로서비스에서 동일한 `SECRET_KEY`로 검증 + +## API 문서 + + -혹은 docker-compose.develop.yml 의 `service.auth-service.ports` 를 수정하여 포트포워드 대상 포트를 변경할 수 있습니다. +자세한 API 문서는 Swagger UI에서 확인할 수 있습니다. +## 개발 가이드 -## 여담 +### 환경 변수 -TLE 서비스의 모놀리식 아키텍쳐를 MSA로 전환하기 위해 첫 번째로 생성하는 서비스입니다. +Docker compose를 사용하지 않고 직접 컨테이너를 실행할 경우 필요한 환경변수 입니다. -JWT: Claim에 사용자 정보를 담아 `SECRET_KEY`로 암호화 하여 생성된 JWT 토큰을 사용자 클라이언트에 저장하고, 이 정보를 다른 마이크로서비스에서 복호화하여 참조할 수 있도록 설계되었습니다. +| 변수명 | 설명 | 기본값 | 필수 | +| ------------------------ | ------------------------------------------ | ------- | --------------------------------------------------- | +| `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: + auth-service: + ports: + - "8080:8000" # 외부:내부 포트 +``` From 87275ffb220c6a9c4830fdc2da96daecd12d579d Mon Sep 17 00:00:00 2001 From: Hepheir Date: Fri, 28 Nov 2025 23:48:46 +0900 Subject: [PATCH 47/93] feat(app.env): add environment variable utility functions --- app/env.py | 226 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 app/env.py diff --git a/app/env.py b/app/env.py new file mode 100644 index 0000000..bf0207b --- /dev/null +++ b/app/env.py @@ -0,0 +1,226 @@ +""" +환경 변수와 관련된 기능 혹은 유틸리티 모음. +""" + +import os +from pathlib import Path +from typing import Iterable +from typing import List +from typing import Optional + + +TRUTHY_VALUES = ('true', '1', 't', 'y', 'yes', 'on') +FALSY_VALUES = ('false', '0', 'f', 'n', 'no', 'off') + + +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) + + if value is not None: + return value + + if default is not None: + return default + + if required: + raise ValueError( + f'Environment variable "{key}" is required but not set.' + ) + + return None + + +def get_array(key: str, default: Optional[List[str]] = None, required: bool = False) -> Optional[List[str]]: + """환경 변수를 쉼표로 구분된 배열로 파싱합니다. + + Args: + key: 환경 변수 이름 + default: 기본값 (환경 변수가 없을 때 반환) + required: 필수 여부 (True일 때 환경 변수가 없으면 예외 발생) + + Returns: + 쉼표로 구분된 문자열 배열 + + Raises: + ValueError: required=True이고 환경 변수가 설정되지 않은 경우 + """ + value = os.getenv(key) + + if value is not None: + return [item.strip() for item in value.split(',') if item.strip()] + + if default is not None: + return default + + if required: + raise ValueError( + f'Environment variable "{key}" is required but not set.' + ) + + return None + + +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) + + if value is not None: + if is_truthy(value): + return True + if is_falsy(value): + return False + raise ValueError( + f'Environment variable "{key}" has invalid boolean value: {value}' + ) + + if default is not None: + return default + + if required: + raise ValueError( + f'Environment variable "{key}" is required but not set.' + ) + + return None + + +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) + + if value is not None: + try: + return int(value) + except ValueError: + raise ValueError( + f'Environment variable "{key}" has invalid integer value: {value}' + ) + + if default is not None: + return default + + if required: + raise ValueError( + f'Environment variable "{key}" is required but not set.' + ) + + return None + + +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) + + if value is not None: + try: + return Path(value).read_text() + except FileNotFoundError: + raise ValueError( + f'Environment variable "{key}" has invalid file path: {value}' + ) + + if default is not None: + return default + + if required: + raise ValueError( + f'Environment variable "{key}" is required but not set.' + ) + + return None + + +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 From aa5ef8a0c4523887a38f852b653d3e2e6cc7a3d8 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Fri, 28 Nov 2025 23:55:45 +0900 Subject: [PATCH 48/93] =?UTF-8?q?test(app):=20app/env.py=20=EC=9D=98=20?= =?UTF-8?q?=EC=9C=A0=ED=8B=B8=EB=A6=AC=ED=8B=B0=20=ED=95=A8=EC=88=98?= =?UTF-8?q?=EB=93=A4=EC=97=90=20=EB=8C=80=ED=95=9C=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/tests.py | 159 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/app/tests.py b/app/tests.py index 6855620..8a82aec 100644 --- a/app/tests.py +++ b/app/tests.py @@ -1,4 +1,7 @@ +import os +from django.core.files.temp import NamedTemporaryFile from django.test import TestCase +from app import env class HealthCheckAPIViewTest(TestCase): @@ -8,3 +11,159 @@ def test_get_200(self): """ response = self.client.get('/health/') self.assertEqual(response.status_code, 200) + + +class EnvModuleTest(TestCase): + def setUp(self): + # 테스트 전 환경 변수 초기화 + self.test_keys = ['TEST_VAR', 'TEST_ARRAY', + '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_array_existing_value(self): + os.environ['TEST_ARRAY'] = 'a,b,c' + result = env.get_array('TEST_ARRAY') + self.assertEqual(result, ['a', 'b', 'c']) + + def test_get_array_with_spaces(self): + os.environ['TEST_ARRAY'] = ' a , b , c ' + result = env.get_array('TEST_ARRAY') + self.assertEqual(result, ['a', 'b', 'c']) + + def test_get_array_empty_items(self): + os.environ['TEST_ARRAY'] = 'a,,b,' + result = env.get_array('TEST_ARRAY') + self.assertEqual(result, ['a', 'b']) + + def test_get_array_default_value(self): + result = env.get_array('TEST_ARRAY', ['default']) + self.assertEqual(result, ['default']) + + def test_get_array_required_raises_error(self): + with self.assertRaises(ValueError): + env.get_array('TEST_ARRAY', required=True) + + 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_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)) From 6a1b83991e37580505aefa017326e5beb33ccd21 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 00:40:16 +0900 Subject: [PATCH 49/93] refactor(settings): replace custom environment variable functions with app.env utility methods --- app/settings.py | 113 ++++++------------------------------------------ 1 file changed, 13 insertions(+), 100 deletions(-) diff --git a/app/settings.py b/app/settings.py index 37a5c1c..f8266d3 100644 --- a/app/settings.py +++ b/app/settings.py @@ -10,74 +10,9 @@ https://docs.djangoproject.com/en/5.2/ref/settings/ """ -import os from pathlib import Path -from typing import Any, Callable, Optional, Union - - -def is_truthy(value: Union[str, int, bool, None]) -> bool: - TRUTHY = ('true', '1', 't', 'y', 'yes', 'on') - FALSY = ('false', '0', 'f', 'n', 'no', 'off') - if value is None: - return False - if isinstance(value, bool): - return value - if isinstance(value, int): - return bool(value) - if isinstance(value, str): - value = value.strip().lower() - if value in TRUTHY: - return True - if value in FALSY: - return False - raise ValueError( - f'Invalid truthy/falsy value: {value}. ' - 'Please set the value as an interger, or a boolean or a string. ' - f'Allowed string values are: {TRUTHY + FALSY}. ' - ) - - -def get_env(key: str, - default: Optional[Any] = None, - file_key: Optional[str] = None, - mapper_fn: Optional[Callable[[Any], Any]] = None, - raise_on_none: bool = False, - raise_message: str = '') -> Any: - """ - Get environment variable value. - - 1. Try to read the value from the environment variable specified by `key`. - 2. If not set, try to read the value from the file specified by `file_key`. - 3. If still not set, use the `default` value. - 4. If still not set, raise an error if `raise_on_none` is `True`. - """ - def inner(): - # 1. try read from key - value = os.getenv(key) - if value is not None: - return value - - # 2. if not set, try read from file - if file_key is not None: - file_path = os.getenv(file_key) - if file_path is not None: - with open(file_path, 'rt') as f: - return f.read().strip() - - # 3. if still not set, try use default - if default is not None: - return default - - # 4. ... raise error - if raise_on_none: - raise ValueError(raise_message) - - return None - - if mapper_fn is None: - return inner() - - return mapper_fn(inner()) + +from app import env # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -88,26 +23,16 @@ def inner(): # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = get_env( - key='SECRET_KEY', - file_key='SECRET_KEY_FILE', - raise_on_none=True, - raise_message=( - 'SECRET_KEY is not set. ' - 'Please set SECRET_KEY or SECRET_KEY_FILE environment variable.' - ), -) +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 = get_env( - key='DEBUG', - default=False, - mapper_fn=is_truthy, - raise_on_none=False, -) +DEBUG = env.get_bool('DEBUG', default=False) + +ALLOWED_HOSTS = env.get_array('ALLOWED_HOSTS', default=[]) -ALLOWED_HOSTS = list(filter(None, get_env('ALLOWED_HOSTS', '').split(','))) assert DEBUG or ALLOWED_HOSTS, ( 'ALLOWED_HOSTS is not set or empty. ' @@ -165,23 +90,11 @@ def inner(): DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql_psycopg2', - 'HOST': get_env("POSTGRES_HOST", - raise_on_none=True, - raise_message='POSTGRES_HOST is not set.'), - 'PORT': get_env("POSTGRES_PORT", - default="5432", - mapper_fn=int, - raise_on_none=False), - 'NAME': get_env("POSTGRES_DB", - raise_on_none=True, - raise_message='POSTGRES_DB is not set.'), - 'USER': get_env("POSTGRES_USER", - raise_on_none=True, - raise_message='POSTGRES_USER is not set.'), - 'PASSWORD': get_env("POSTGRES_PASSWORD", - file_key="POSTGRES_PASSWORD_FILE", - raise_on_none=True, - raise_message='POSTGRES_PASSWORD or POSTGRES_PASSWORD_FILE is not set.') + 'HOST': env.get("POSTGRES_HOST", required=True), + 'PORT': env.get_int("POSTGRES_PORT", default=5432, required=True), + 'NAME': env.get("POSTGRES_DB", required=True), + 'USER': env.get("POSTGRES_USER", required=True), + 'PASSWORD': env.get('POSTGRES_PASSWORD', env.get_file_content('POSTGRES_PASSWORD_FILE'), required=True), } } From 378b1f20a3ffadc3250832e09a32d83714cfc4c9 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 00:40:05 +0900 Subject: [PATCH 50/93] fix(docker): change `ENTRYPOINT` to use bash for script execution --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index c27e8c1..868b2f3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,5 +29,5 @@ USER appuser HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 CMD ["curl", "--silent", "--fail", "http://localhost:8000/health/"] # Run server -ENTRYPOINT ["/app/entrypoint.sh"] +ENTRYPOINT ["bash", "/app/entrypoint.sh"] CMD ["--bind", "0.0.0.0:8000", "--workers", "4", "--threads", "2"] From 48d4483dcd5de1eb204310df1991efb06f6d8214 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 00:40:46 +0900 Subject: [PATCH 51/93] fix(docker): update `DEBUG` and `ALLOWED_HOSTS` environment variables for local development --- docker-compose.develop.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.develop.yml b/docker-compose.develop.yml index 70f79d7..625b0ad 100644 --- a/docker-compose.develop.yml +++ b/docker-compose.develop.yml @@ -11,8 +11,8 @@ services: - "80:8000" environment: SECRET_KEY_FILE: /run/secrets/secret_key - DEBUG: 1 - ALLOWED_HOSTS: "*" + DEBUG: true + ALLOWED_HOSTS: "localhost,127.0.0.1" POSTGRES_HOST: auth-database POSTGRES_PORT: 5432 POSTGRES_DB: auth-database From dd6eba6492f1fb8de9eee0e19b12a3ff2d77ded1 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 00:52:05 +0900 Subject: [PATCH 52/93] feat(app.env): load environment variables using dotenv --- app/env.py | 4 ++++ requirements.txt | 1 + 2 files changed, 5 insertions(+) diff --git a/app/env.py b/app/env.py index bf0207b..8546da0 100644 --- a/app/env.py +++ b/app/env.py @@ -3,6 +3,7 @@ """ import os +from dotenv import load_dotenv from pathlib import Path from typing import Iterable from typing import List @@ -13,6 +14,9 @@ FALSY_VALUES = ('false', '0', 'f', 'n', 'no', 'off') +load_dotenv() + + def get(key: str, default: Optional[str] = None, required: bool = False) -> Optional[str]: """환경 변수 값을 가져옵니다. diff --git a/requirements.txt b/requirements.txt index 6689c4b..5053aa1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,5 @@ django djangorestframework drf-yasg gunicorn +python-dotenv psycopg2-binary From 4b9ef3f6628d2e42573ba43b7d312c61ee63b568 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 00:57:22 +0900 Subject: [PATCH 53/93] docs: correct wording for file watcher feature in development mode --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5c01a69..256ff49 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ docker compose -f docker-compose.develop.yml up -d docker compose -f docker-compose.develop.yml logs -f ``` -> 💡 **개발 모드**: 개발 환경 실행 명령 옵션으로 `-w`를 추가하여 file watcher 기능을 활성화 할 수 있습니다. +> 💡 **개발 모드**: 개발 환경 실행 명령 옵션으로 `-w`를 추가하여 watch mode 기능을 활성화 할 수 있습니다. ### 4. 접속 확인 From 73f02335198ef50cb8ebee701a0eb36bc42c1372 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 00:58:11 +0900 Subject: [PATCH 54/93] fix(app.settings): remove `DEBUG` condition from `ALLOWED_HOSTS` assertion --- app/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/settings.py b/app/settings.py index f8266d3..adaca50 100644 --- a/app/settings.py +++ b/app/settings.py @@ -34,7 +34,7 @@ ALLOWED_HOSTS = env.get_array('ALLOWED_HOSTS', default=[]) -assert DEBUG or ALLOWED_HOSTS, ( +assert ALLOWED_HOSTS, ( 'ALLOWED_HOSTS is not set or empty. ' 'Please set ALLOWED_HOSTS as a comma-separated list (e.g., "example.com,www.example.com").' ) From 9342b8aaf2c91e4fbacfc91c1da0762e0b4e840e Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 00:58:24 +0900 Subject: [PATCH 55/93] fix(docker): ensure auth-database dependency has health check and restart policy --- docker-compose.develop.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docker-compose.develop.yml b/docker-compose.develop.yml index 625b0ad..6f81c61 100644 --- a/docker-compose.develop.yml +++ b/docker-compose.develop.yml @@ -6,7 +6,9 @@ services: context: . dockerfile: Dockerfile depends_on: - - auth-database + auth-database: + condition: service_healthy + restart: true ports: - "80:8000" environment: @@ -37,6 +39,11 @@ services: auth-database: image: postgres:15-alpine restart: always + healthcheck: + test: ["CMD-SHELL", "pg_isready -U tle-auth"] + interval: 10s + timeout: 5s + retries: 5 volumes: - ./.volume:/var/lib/postgresql/data environment: From 5fc332ef8251f7771a2cb06fe997fbc0dfb6b16d Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 01:22:14 +0900 Subject: [PATCH 56/93] =?UTF-8?q?refactor(app.settings):=20=EB=AA=85?= =?UTF-8?q?=EB=A3=8C=ED=95=98=EC=A7=80=20=EC=95=8A=EC=9D=80=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=20=EC=9D=B8=EC=9E=90=EB=82=98=20=EC=98=88=EC=99=B8?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EB=AA=85=EB=A3=8C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/settings.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/app/settings.py b/app/settings.py index adaca50..746fb92 100644 --- a/app/settings.py +++ b/app/settings.py @@ -34,10 +34,11 @@ ALLOWED_HOSTS = env.get_array('ALLOWED_HOSTS', default=[]) -assert ALLOWED_HOSTS, ( - 'ALLOWED_HOSTS is not set or empty. ' - 'Please set ALLOWED_HOSTS as a comma-separated list (e.g., "example.com,www.example.com").' -) +if not ALLOWED_HOSTS: + raise ValueError( + 'ALLOWED_HOSTS is not set or empty. ' + 'Please set ALLOWED_HOSTS as a comma-separated list (e.g., "example.com,www.example.com").' + ) # Application definition @@ -91,10 +92,10 @@ 'default': { 'ENGINE': 'django.db.backends.postgresql_psycopg2', 'HOST': env.get("POSTGRES_HOST", required=True), - 'PORT': env.get_int("POSTGRES_PORT", default=5432, 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', env.get_file_content('POSTGRES_PASSWORD_FILE'), required=True), + 'PASSWORD': env.get('POSTGRES_PASSWORD', default=env.get_file_content('POSTGRES_PASSWORD_FILE'), required=True), } } From 87bf64f5a87350463bf4cca7b6e16ba9db430aa9 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 01:23:13 +0900 Subject: [PATCH 57/93] =?UTF-8?q?refactor(docker):=20yaml=20boolean=20?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=B2=98=EB=A6=AC=EB=90=A0=20=EC=88=98=20?= =?UTF-8?q?=EC=9E=88=EB=8A=94=20=EC=86=8C=EC=A7=80=EC=9D=98=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EB=B3=80=EC=88=98=EB=A5=BC=20=EB=AC=B8=EC=9E=90?= =?UTF-8?q?=EC=97=B4=EB=A1=9C=20=EB=B3=80=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.develop.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.develop.yml b/docker-compose.develop.yml index 6f81c61..bddd73d 100644 --- a/docker-compose.develop.yml +++ b/docker-compose.develop.yml @@ -13,7 +13,7 @@ services: - "80:8000" environment: SECRET_KEY_FILE: /run/secrets/secret_key - DEBUG: true + DEBUG: "true" ALLOWED_HOSTS: "localhost,127.0.0.1" POSTGRES_HOST: auth-database POSTGRES_PORT: 5432 From 1a5afb4bb9c691fb99e469e474af4896171fd36c Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 01:25:02 +0900 Subject: [PATCH 58/93] =?UTF-8?q?fix:=20`set=20-e`=20flag=20=EB=A5=BC=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B5=9C=EC=83=81=EB=8B=A8=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99=ED=95=98=EC=97=AC=20error=20chec?= =?UTF-8?q?king=EC=9D=84=20=EB=AA=A8=EB=93=A0=20line=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=B3=B4=EC=9E=A5=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- entrypoint.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/entrypoint.sh b/entrypoint.sh index 211e91e..0f1792f 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,5 +1,8 @@ #!/bin/bash +# Exit on any error +set -e + function debug_init() { echo "[ENTRYPOINT] Running in DEBUG mode." @@ -12,9 +15,6 @@ function debug_init() { function main() { echo "[ENTRYPOINT] $@" - # Exit on any error - set -e - # Check if DEBUG mode is enabled echo 'from django.conf import settings; exit(0 if settings.DEBUG else 1);' \ | python manage.py shell --no-imports \ From e027e45d73a92e19148233af34859bb9c7aab092 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 10:42:33 +0900 Subject: [PATCH 59/93] feat(healthcheck): add database connectivity check to `HealthCheckAPIView` --- app/views.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/views.py b/app/views.py index 86163d4..32251f1 100644 --- a/app/views.py +++ b/app/views.py @@ -1,3 +1,5 @@ +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 @@ -5,6 +7,7 @@ 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 @@ -36,4 +39,11 @@ class HealthCheckAPIView(APIView): } ) def get(self, request: HttpRequest): + # Database Conectivity Check + try: + connection = connections['default'] + connection.cursor() # 연결 시도 + except OperationalError: + raise APIException("Database connection failed.") + return HttpResponse(status=status.HTTP_200_OK) From dd1a97c892a4fe0657cbf36b6f288aca768b0cd0 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 10:43:55 +0900 Subject: [PATCH 60/93] fix(settings): set default `ALLOWED_HOSTS` to include localhost and 127.0.0.1 --- app/settings.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/settings.py b/app/settings.py index 746fb92..5ba647b 100644 --- a/app/settings.py +++ b/app/settings.py @@ -31,7 +31,8 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = env.get_bool('DEBUG', default=False) -ALLOWED_HOSTS = env.get_array('ALLOWED_HOSTS', default=[]) +ALLOWED_HOSTS = env.get_array('ALLOWED_HOSTS', + default=['localhost', '127.0.0.1']) if not ALLOWED_HOSTS: From eb8beb690bd542b5b0d5e01355e41323b96756d2 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 10:44:18 +0900 Subject: [PATCH 61/93] refactor(docker): remove version specification from docker-compose file --- docker-compose.develop.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/docker-compose.develop.yml b/docker-compose.develop.yml index bddd73d..2939c12 100644 --- a/docker-compose.develop.yml +++ b/docker-compose.develop.yml @@ -1,5 +1,3 @@ -version: "3.8" - services: auth-service: build: From ffe5ab1ec2358f7ce0fb10c01e1ae88dd7909614 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 10:57:04 +0900 Subject: [PATCH 62/93] refactor(docker): rename services and update environment variables in docker-compose files --- docker-compose.develop.yml | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/docker-compose.develop.yml b/docker-compose.develop.yml index 2939c12..5eea181 100644 --- a/docker-compose.develop.yml +++ b/docker-compose.develop.yml @@ -1,10 +1,10 @@ services: - auth-service: + tle-auth-service: build: context: . dockerfile: Dockerfile depends_on: - auth-database: + tle-auth-database: condition: service_healthy restart: true ports: @@ -12,10 +12,9 @@ services: environment: SECRET_KEY_FILE: /run/secrets/secret_key DEBUG: "true" - ALLOWED_HOSTS: "localhost,127.0.0.1" - POSTGRES_HOST: auth-database + POSTGRES_HOST: tle-auth-database POSTGRES_PORT: 5432 - POSTGRES_DB: auth-database + POSTGRES_DB: tle-auth POSTGRES_USER: tle-auth POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password secrets: @@ -34,7 +33,7 @@ services: target: "/app/" initial_sync: true - auth-database: + tle-auth-database: image: postgres:15-alpine restart: always healthcheck: @@ -43,14 +42,17 @@ services: timeout: 5s retries: 5 volumes: - - ./.volume:/var/lib/postgresql/data + - tle-auth-volume:/var/lib/postgresql/data environment: - POSTGRES_DB: auth-database + 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 From f34633b132cab8857ecc8eea0fb942451c074851 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 10:59:50 +0900 Subject: [PATCH 63/93] =?UTF-8?q?fix(env):=20[security]=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EB=A9=94=EC=8B=9C=EC=A7=80=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=20=EB=B3=80=EC=88=98=20=EA=B0=92=EC=9D=84=20?= =?UTF-8?q?=EB=85=B8=EC=B6=9C=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/env.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/env.py b/app/env.py index 8546da0..153c377 100644 --- a/app/env.py +++ b/app/env.py @@ -134,7 +134,7 @@ def get_int(key: str, default: Optional[int] = None, required: bool = False) -> return int(value) except ValueError: raise ValueError( - f'Environment variable "{key}" has invalid integer value: {value}' + f'Environment variable "{key}" has invalid integer value:' ) if default is not None: @@ -169,7 +169,7 @@ def get_file_content(key: str, default: Optional[str] = None, required: bool = F return Path(value).read_text() except FileNotFoundError: raise ValueError( - f'Environment variable "{key}" has invalid file path: {value}' + f'Environment variable "{key}" has invalid file path.' ) if default is not None: From 7fc9fa80e41648b122a1e3fd9041188b42070907 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 11:00:40 +0900 Subject: [PATCH 64/93] fix(entrypoint): allow `collectstatic` to continue on failure in `DEBUG` mode --- entrypoint.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/entrypoint.sh b/entrypoint.sh index 0f1792f..9de10f8 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -8,7 +8,7 @@ function debug_init() { echo "[ENTRYPOINT] Running in DEBUG mode." python manage.py migrate --no-input - python manage.py collectstatic --no-input + python manage.py collectstatic --no-input || true } From 08ca2f537ea1bb5d2f9828ea01c8e8ccf00e0c60 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 11:15:01 +0900 Subject: [PATCH 65/93] =?UTF-8?q?test(healthcheck):=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=EB=B2=A0=EC=9D=B4=EC=8A=A4=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=EC=97=90=20=EC=8B=A4=ED=8C=A8=ED=95=98=EB=A9=B4=20`/health/`?= =?UTF-8?q?=20=EA=B0=80=20500=EC=9D=84=20=EB=B0=98=ED=99=98=ED=95=98?= =?UTF-8?q?=EB=8A=94=EC=A7=80=20=EA=B2=80=EC=82=AC=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/tests.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/app/tests.py b/app/tests.py index 8a82aec..c8a8373 100644 --- a/app/tests.py +++ b/app/tests.py @@ -1,16 +1,31 @@ import os from django.core.files.temp import NamedTemporaryFile +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(self): + def test_get_200_with_healthy_database(self): """ - GET /health/ 요청 시 200 OK를 반환하는지 테스트. + 데이터베이스 연결이 정상일 때 GET /health/ 요청 시 200 OK를 반환하는지 테스트. """ response = self.client.get('/health/') - self.assertEqual(response.status_code, 200) + 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) class EnvModuleTest(TestCase): From e2b5004bccf97be0a04d9f8b4d26b76e826fb156 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 11:23:41 +0900 Subject: [PATCH 66/93] refactor(entrypoint): encapsulate `DEBUG` mode check in a separate function --- entrypoint.sh | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/entrypoint.sh b/entrypoint.sh index 9de10f8..9b64178 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -4,6 +4,15 @@ set -e +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 + return $? +} + + function debug_init() { echo "[ENTRYPOINT] Running in DEBUG mode." @@ -15,10 +24,9 @@ function debug_init() { function main() { echo "[ENTRYPOINT] $@" - # Check if DEBUG mode is enabled - echo 'from django.conf import settings; exit(0 if settings.DEBUG else 1);' \ - | python manage.py shell --no-imports \ - && debug_init + if is_debug_mode; then + debug_init + fi # Run server gunicorn app.wsgi:application "$@" From eafe260502254f116c3599932869d59194580d90 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 11:28:03 +0900 Subject: [PATCH 67/93] =?UTF-8?q?docs:=20docker=20compose=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=B3=80=EA=B2=BD=EB=90=9C=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=EB=AA=85=EC=9D=84=20README.md=EC=9D=98=20=ED=8F=AC?= =?UTF-8?q?=ED=8A=B8=20=EC=84=A4=EC=A0=95=20=EB=B3=80=EA=B2=BD=20=EC=84=B9?= =?UTF-8?q?=EC=85=98=EC=97=90=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 256ff49..38fe8b2 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ Docker compose를 사용하지 않고 직접 컨테이너를 실행할 경우 ```yaml services: - auth-service: + tle-auth-service: ports: - "8080:8000" # 외부:내부 포트 ``` From dff722cf85da24401a70e7b06dddc6dacb8884a6 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 11:31:08 +0900 Subject: [PATCH 68/93] chore(app.env): correct typo Conectivity -> Connectivity --- app/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views.py b/app/views.py index 32251f1..fad3145 100644 --- a/app/views.py +++ b/app/views.py @@ -39,7 +39,7 @@ class HealthCheckAPIView(APIView): } ) def get(self, request: HttpRequest): - # Database Conectivity Check + # Database Connectivity Check try: connection = connections['default'] connection.cursor() # 연결 시도 From b29071162feb75347be46e52fbf45c972430212b Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 11:31:41 +0900 Subject: [PATCH 69/93] =?UTF-8?q?chore(app.env):=20=EB=B6=80=EC=9E=90?= =?UTF-8?q?=EC=97=B0=EC=8A=A4=EB=9F=B0=20=EC=98=A4=EB=A5=98=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/env.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/env.py b/app/env.py index 153c377..b6059a1 100644 --- a/app/env.py +++ b/app/env.py @@ -99,7 +99,7 @@ def get_bool(key: str, default: Optional[bool] = None, required: bool = False) - if is_falsy(value): return False raise ValueError( - f'Environment variable "{key}" has invalid boolean value: {value}' + f'Environment variable "{key}" has invalid boolean value.' ) if default is not None: @@ -134,7 +134,7 @@ def get_int(key: str, default: Optional[int] = None, required: bool = False) -> return int(value) except ValueError: raise ValueError( - f'Environment variable "{key}" has invalid integer value:' + f'Environment variable "{key}" has invalid integer value.' ) if default is not None: From 6e33709ecd131b31429b43e79035bc3cafe66b3b Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 11:39:45 +0900 Subject: [PATCH 70/93] =?UTF-8?q?refactor(entrypoint):=20=EB=B6=88?= =?UTF-8?q?=ED=95=84=EC=9A=94=ED=95=9C=20return=20=EB=AC=B8=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bash 함수는 마지막으로 실행한 스크립트의 리턴 코드를 함수의 반환값으로 사용한다. --- entrypoint.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/entrypoint.sh b/entrypoint.sh index 9b64178..a5a6eff 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -9,7 +9,6 @@ function is_debug_mode() { # 해당 파싱 방식이 반영될 수 있도록 app/settings.py에서 직접 DEBUG 값을 관측. echo 'from django.conf import settings; exit(0 if settings.DEBUG else 1);' \ | python manage.py shell --no-imports - return $? } From 37b1520af6be8f03f4c01aea6aed5b4a0cde3e4e Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 11:40:23 +0900 Subject: [PATCH 71/93] =?UTF-8?q?feat(app.env):=20`get=5Farray()`=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=EC=97=90=20`allow=5Fempty`=20=EC=98=B5?= =?UTF-8?q?=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/env.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/app/env.py b/app/env.py index b6059a1..19ee81d 100644 --- a/app/env.py +++ b/app/env.py @@ -47,7 +47,7 @@ def get(key: str, default: Optional[str] = None, required: bool = False) -> Opti return None -def get_array(key: str, default: Optional[List[str]] = None, required: bool = False) -> Optional[List[str]]: +def get_array(key: str, default: Optional[List[str]] = None, required: bool = False, allow_empty: bool = True) -> Optional[List[str]]: """환경 변수를 쉼표로 구분된 배열로 파싱합니다. Args: @@ -62,19 +62,26 @@ def get_array(key: str, default: Optional[List[str]] = None, required: bool = Fa ValueError: required=True이고 환경 변수가 설정되지 않은 경우 """ value = os.getenv(key) + retval = None if value is not None: - return [item.strip() for item in value.split(',') if item.strip()] + retval = [item.strip() for item in value.split(',') if item.strip()] - if default is not None: - return default + if retval is None and default is not None: + retval = default - if required: + if retval is None and required: raise ValueError( f'Environment variable "{key}" is required but not set.' ) - return None + if not retval and not allow_empty: + raise ValueError( + f'Environment variable "{key}" is empty. ' + f'Please set "{key}" as a comma-separated list.' + ) + + return retval def get_bool(key: str, default: Optional[bool] = None, required: bool = False) -> Optional[bool]: From 0204747179e231e3cf356ea0a4ed5183901e353e Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 11:40:45 +0900 Subject: [PATCH 72/93] refactor(settings): enforce non-empty `ALLOWED_HOSTS` configuration --- app/settings.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/app/settings.py b/app/settings.py index 5ba647b..bdebc0f 100644 --- a/app/settings.py +++ b/app/settings.py @@ -32,14 +32,8 @@ DEBUG = env.get_bool('DEBUG', default=False) ALLOWED_HOSTS = env.get_array('ALLOWED_HOSTS', - default=['localhost', '127.0.0.1']) - - -if not ALLOWED_HOSTS: - raise ValueError( - 'ALLOWED_HOSTS is not set or empty. ' - 'Please set ALLOWED_HOSTS as a comma-separated list (e.g., "example.com,www.example.com").' - ) + default=['localhost', '127.0.0.1'], + allow_empty=False) # Application definition From 3ae8000bcb7e6dc0ee58a1b6266ff39d9a5a5e4a Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 14:46:13 +0900 Subject: [PATCH 73/93] =?UTF-8?q?refactor(app.env):=20=ED=99=98=EA=B2=BD?= =?UTF-8?q?=EB=B3=80=EC=88=98=20=ED=8C=8C=EC=8B=B1=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=EC=97=90=EC=84=9C=20early=20return=20=EC=A7=80=EC=96=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/env.py | 65 ++++++++++++++++++++++++------------------------------ 1 file changed, 29 insertions(+), 36 deletions(-) diff --git a/app/env.py b/app/env.py index 19ee81d..d9f797c 100644 --- a/app/env.py +++ b/app/env.py @@ -32,19 +32,17 @@ def get(key: str, default: Optional[str] = None, required: bool = False) -> Opti ValueError: required=True이고 환경 변수가 설정되지 않은 경우 """ value = os.getenv(key) + retval = default if value is not None: - return value + retval = value.strip() - if default is not None: - return default - - if required: + if required and (retval is None): raise ValueError( f'Environment variable "{key}" is required but not set.' ) - return None + return retval def get_array(key: str, default: Optional[List[str]] = None, required: bool = False, allow_empty: bool = True) -> Optional[List[str]]: @@ -62,20 +60,20 @@ def get_array(key: str, default: Optional[List[str]] = None, required: bool = Fa ValueError: required=True이고 환경 변수가 설정되지 않은 경우 """ value = os.getenv(key) - retval = None + retval = default if value is not None: - retval = [item.strip() for item in value.split(',') if item.strip()] + retval = [] + for item in value.strip().split(','): + item = item.strip() + retval.append(item) - if retval is None and default is not None: - retval = default - - if retval is None and required: + if required and (retval is None): raise ValueError( f'Environment variable "{key}" is required but not set.' ) - if not retval and not allow_empty: + if not allow_empty and (retval is not None and not retval): raise ValueError( f'Environment variable "{key}" is empty. ' f'Please set "{key}" as a comma-separated list.' @@ -99,25 +97,24 @@ def get_bool(key: str, default: Optional[bool] = None, required: bool = False) - ValueError: 잘못된 불린 값이거나 required=True이고 환경 변수가 설정되지 않은 경우 """ value = os.getenv(key) + retval = default if value is not None: if is_truthy(value): - return True - if is_falsy(value): - return False - raise ValueError( - f'Environment variable "{key}" has invalid boolean value.' - ) - - if default is not None: - return default + retval = True + elif is_falsy(value): + retval = False + else: + raise ValueError( + f'Environment variable "{key}" has invalid boolean value.' + ) - if required: + if required and (retval is None): raise ValueError( f'Environment variable "{key}" is required but not set.' ) - return None + return retval def get_int(key: str, default: Optional[int] = None, required: bool = False) -> Optional[int]: @@ -135,24 +132,22 @@ def get_int(key: str, default: Optional[int] = None, required: bool = False) -> ValueError: 잘못된 정수 값이거나 required=True이고 환경 변수가 설정되지 않은 경우 """ value = os.getenv(key) + retval = default if value is not None: try: - return int(value) + retval = int(value) except ValueError: raise ValueError( f'Environment variable "{key}" has invalid integer value.' ) - if default is not None: - return default - - if required: + if required and (retval is None): raise ValueError( f'Environment variable "{key}" is required but not set.' ) - return None + return retval def get_file_content(key: str, default: Optional[str] = None, required: bool = False) -> Optional[str]: @@ -170,24 +165,22 @@ def get_file_content(key: str, default: Optional[str] = None, required: bool = F ValueError: 파일을 찾을 수 없거나 required=True이고 환경 변수가 설정되지 않은 경우 """ value = os.getenv(key) + retval = default if value is not None: try: - return Path(value).read_text() + retval = Path(value).read_text() except FileNotFoundError: raise ValueError( f'Environment variable "{key}" has invalid file path.' ) - if default is not None: - return default - - if required: + if required and (retval is None): raise ValueError( f'Environment variable "{key}" is required but not set.' ) - return None + return retval def is_truthy(value: Optional[str]) -> bool: From 4cbca7e0a0c478f872bf9c3e293320cb108a5258 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 14:47:24 +0900 Subject: [PATCH 74/93] =?UTF-8?q?refactor(app.env):=20`get=5Farray()`?= =?UTF-8?q?=EC=9D=98=20=EC=9D=B8=EC=9E=90=EB=AA=85=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?`allow=5Fempty`=20->=20`empty`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/env.py | 4 ++-- app/settings.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/env.py b/app/env.py index d9f797c..9bb1603 100644 --- a/app/env.py +++ b/app/env.py @@ -45,7 +45,7 @@ def get(key: str, default: Optional[str] = None, required: bool = False) -> Opti return retval -def get_array(key: str, default: Optional[List[str]] = None, required: bool = False, allow_empty: bool = True) -> Optional[List[str]]: +def get_array(key: str, default: Optional[List[str]] = None, required: bool = False, empty: bool = True) -> Optional[List[str]]: """환경 변수를 쉼표로 구분된 배열로 파싱합니다. Args: @@ -73,7 +73,7 @@ def get_array(key: str, default: Optional[List[str]] = None, required: bool = Fa f'Environment variable "{key}" is required but not set.' ) - if not allow_empty and (retval is not None and not retval): + if not empty and (retval is not None and not retval): raise ValueError( f'Environment variable "{key}" is empty. ' f'Please set "{key}" as a comma-separated list.' diff --git a/app/settings.py b/app/settings.py index bdebc0f..f486ecf 100644 --- a/app/settings.py +++ b/app/settings.py @@ -33,7 +33,7 @@ ALLOWED_HOSTS = env.get_array('ALLOWED_HOSTS', default=['localhost', '127.0.0.1'], - allow_empty=False) + empty=False) # Application definition From da574ea36e0e22bf1cd0cafda065f1b8109865ff Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 15:15:57 +0900 Subject: [PATCH 75/93] feat(app.env): add `get_json()` support JSON parsed value --- app/env.py | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/app/env.py b/app/env.py index 9bb1603..0d8b042 100644 --- a/app/env.py +++ b/app/env.py @@ -2,18 +2,21 @@ 환경 변수와 관련된 기능 혹은 유틸리티 모음. """ +import json import os from dotenv import load_dotenv from pathlib import Path -from typing import Iterable -from typing import List -from typing import Optional +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() @@ -82,6 +85,26 @@ def get_array(key: str, default: Optional[List[str]] = None, required: bool = Fa return retval +def get_json(key: str, default: Optional[JSON] = None, required: bool = False) -> Optional[JSON]: + 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]: """환경 변수를 불린 값으로 파싱합니다. From 8f4b0d0e3ecd9c9bd0432edd2512972547e8cf20 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 15:19:48 +0900 Subject: [PATCH 76/93] test(app.env): add JSON parsing tests for environment variables --- app/tests.py | 47 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/app/tests.py b/app/tests.py index c8a8373..6ee1206 100644 --- a/app/tests.py +++ b/app/tests.py @@ -31,7 +31,7 @@ def test_get_500_with_database_connection_error(self, mock_connections): class EnvModuleTest(TestCase): def setUp(self): # 테스트 전 환경 변수 초기화 - self.test_keys = ['TEST_VAR', 'TEST_ARRAY', + self.test_keys = ['TEST_VAR', 'TEST_ARRAY', 'TEST_JSON', 'TEST_BOOL', 'TEST_INT', 'TEST_FILE'] for key in self.test_keys: if key in os.environ: @@ -182,3 +182,48 @@ 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)) From 09d90a28c02cd9f3ac0ad7bd8c00dd6c8fada168 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 15:20:48 +0900 Subject: [PATCH 77/93] =?UTF-8?q?refactor(app.settings):=20`ALLOWED=5FHOST?= =?UTF-8?q?S`=20=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=EB=A5=BC=20`env.get?= =?UTF-8?q?=5Fjson()`=EC=9C=BC=EB=A1=9C=20=EB=B6=88=EB=9F=AC=EC=98=A4?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/settings.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/app/settings.py b/app/settings.py index f486ecf..17b7d9b 100644 --- a/app/settings.py +++ b/app/settings.py @@ -31,9 +31,18 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = env.get_bool('DEBUG', default=False) -ALLOWED_HOSTS = env.get_array('ALLOWED_HOSTS', - default=['localhost', '127.0.0.1'], - empty=False) +ALLOWED_HOSTS = env.get_json('ALLOWED_HOSTS', + default=['localhost', '127.0.0.1']) + +if not isinstance(ALLOWED_HOSTS, list): + raise ValueError( + 'Environment variable "ALLOWED_HOSTS" must be a JSON array of strings.' + ) +for allowed_host in ALLOWED_HOSTS: + if not isinstance(allowed_host, str): + raise ValueError( + 'Environment variable "ALLOWED_HOSTS" must be a JSON array of strings.' + ) # Application definition From 1b22925cab7edcefe8233527f46d07051739d9ea Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 15:22:17 +0900 Subject: [PATCH 78/93] refactor(app.env): remove unused `get_array()` function and related tests --- app/env.py | 37 ------------------------------------- app/tests.py | 43 ++++++++++--------------------------------- 2 files changed, 10 insertions(+), 70 deletions(-) diff --git a/app/env.py b/app/env.py index 0d8b042..c4451e9 100644 --- a/app/env.py +++ b/app/env.py @@ -48,43 +48,6 @@ def get(key: str, default: Optional[str] = None, required: bool = False) -> Opti return retval -def get_array(key: str, default: Optional[List[str]] = None, required: bool = False, empty: bool = True) -> Optional[List[str]]: - """환경 변수를 쉼표로 구분된 배열로 파싱합니다. - - Args: - key: 환경 변수 이름 - default: 기본값 (환경 변수가 없을 때 반환) - required: 필수 여부 (True일 때 환경 변수가 없으면 예외 발생) - - Returns: - 쉼표로 구분된 문자열 배열 - - Raises: - ValueError: required=True이고 환경 변수가 설정되지 않은 경우 - """ - value = os.getenv(key) - retval = default - - if value is not None: - retval = [] - for item in value.strip().split(','): - item = item.strip() - retval.append(item) - - if required and (retval is None): - raise ValueError( - f'Environment variable "{key}" is required but not set.' - ) - - if not empty and (retval is not None and not retval): - raise ValueError( - f'Environment variable "{key}" is empty. ' - f'Please set "{key}" as a comma-separated list.' - ) - - return retval - - def get_json(key: str, default: Optional[JSON] = None, required: bool = False) -> Optional[JSON]: value = os.getenv(key) retval = default diff --git a/app/tests.py b/app/tests.py index 6ee1206..fabb810 100644 --- a/app/tests.py +++ b/app/tests.py @@ -31,7 +31,7 @@ def test_get_500_with_database_connection_error(self, mock_connections): class EnvModuleTest(TestCase): def setUp(self): # 테스트 전 환경 변수 초기화 - self.test_keys = ['TEST_VAR', 'TEST_ARRAY', 'TEST_JSON', + self.test_keys = ['TEST_VAR', 'TEST_JSON', 'TEST_BOOL', 'TEST_INT', 'TEST_FILE'] for key in self.test_keys: if key in os.environ: @@ -61,29 +61,6 @@ def test_get_required_raises_error(self): env.get('TEST_VAR', required=True) self.assertIn('required but not set', str(cm.exception)) - def test_get_array_existing_value(self): - os.environ['TEST_ARRAY'] = 'a,b,c' - result = env.get_array('TEST_ARRAY') - self.assertEqual(result, ['a', 'b', 'c']) - - def test_get_array_with_spaces(self): - os.environ['TEST_ARRAY'] = ' a , b , c ' - result = env.get_array('TEST_ARRAY') - self.assertEqual(result, ['a', 'b', 'c']) - - def test_get_array_empty_items(self): - os.environ['TEST_ARRAY'] = 'a,,b,' - result = env.get_array('TEST_ARRAY') - self.assertEqual(result, ['a', 'b']) - - def test_get_array_default_value(self): - result = env.get_array('TEST_ARRAY', ['default']) - self.assertEqual(result, ['default']) - - def test_get_array_required_raises_error(self): - with self.assertRaises(ValueError): - env.get_array('TEST_ARRAY', required=True) - def test_get_bool_truthy_values(self): truthy_values = ['true', '1', 't', 'y', 'yes', 'on', 'TRUE', 'True'] for value in truthy_values: @@ -182,47 +159,47 @@ 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) From 1fd668a45cfe6354f950ad83a8fe967d609db73d Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 15:26:41 +0900 Subject: [PATCH 79/93] =?UTF-8?q?fix(app.views):=20health=20check=EC=8B=9C?= =?UTF-8?q?=20DB=20=EC=BB=A4=EB=84=A5=EC=85=98=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=20=ED=9B=84=20=EC=BB=A4=EB=84=A5=EC=85=98?= =?UTF-8?q?=EC=9D=84=20=EC=A2=85=EB=A3=8C=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 잠재적 DB garbage connection 이슈 보완 --- app/views.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/views.py b/app/views.py index fad3145..513f91c 100644 --- a/app/views.py +++ b/app/views.py @@ -41,8 +41,9 @@ class HealthCheckAPIView(APIView): def get(self, request: HttpRequest): # Database Connectivity Check try: - connection = connections['default'] - connection.cursor() # 연결 시도 + with connections['default'].cursor(): + # Just testing the connection + pass except OperationalError: raise APIException("Database connection failed.") From 63b9cd1389da722b92c51bc113311439cf85a954 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 15:32:19 +0900 Subject: [PATCH 80/93] refactor(app.settings): remove validation for `ALLOWED_HOSTS` environment variable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 잠재적 설정 타입오류는 Django 의 ImproperlyConfigured 오류에 의존하도록 함 --- app/settings.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/app/settings.py b/app/settings.py index 17b7d9b..d2c41a5 100644 --- a/app/settings.py +++ b/app/settings.py @@ -34,16 +34,6 @@ ALLOWED_HOSTS = env.get_json('ALLOWED_HOSTS', default=['localhost', '127.0.0.1']) -if not isinstance(ALLOWED_HOSTS, list): - raise ValueError( - 'Environment variable "ALLOWED_HOSTS" must be a JSON array of strings.' - ) -for allowed_host in ALLOWED_HOSTS: - if not isinstance(allowed_host, str): - raise ValueError( - 'Environment variable "ALLOWED_HOSTS" must be a JSON array of strings.' - ) - # Application definition From 8720421f88498434f58dfbc424a5ea49ee937d52 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 15:39:38 +0900 Subject: [PATCH 81/93] =?UTF-8?q?refactor(entrypoint.sh):=20`collectstatic?= =?UTF-8?q?`=20=EC=98=A4=EB=A5=98=20=EB=B0=9C=EC=83=9D=EC=8B=9C=20?= =?UTF-8?q?=EB=AC=B4=EC=8B=9C=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95=ED=95=98=EA=B3=A0=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=EC=97=90=20=EC=9D=B4=EC=9C=A0=EB=A5=BC=20=EB=AA=85?= =?UTF-8?q?=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- entrypoint.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/entrypoint.sh b/entrypoint.sh index a5a6eff..800ccb8 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -16,7 +16,9 @@ function debug_init() { echo "[ENTRYPOINT] Running in DEBUG mode." python manage.py migrate --no-input - python manage.py collectstatic --no-input || true + + # 관리자 페이지, Swagger UI가 설치되어 있으므로, 항상 collect할 static 파일이 존재하는 것으로 가정. + python manage.py collectstatic --no-input } From d89f9d4042e8077694725c2ee0e968ba861f9056 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 16:25:23 +0900 Subject: [PATCH 82/93] =?UTF-8?q?refactor(entrypoint.sh):=20`main`=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=EA=B0=80=20=EC=B5=9C=EC=83=81=EB=8B=A8?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EA=B0=80=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- entrypoint.sh | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/entrypoint.sh b/entrypoint.sh index 800ccb8..f0c1764 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -4,11 +4,15 @@ set -e -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 main() { + echo "[ENTRYPOINT] Arguments: $@" + + if is_debug_mode; then + debug_init + fi + + # Run server + gunicorn app.wsgi:application "$@" } @@ -22,15 +26,12 @@ function debug_init() { } -function main() { - echo "[ENTRYPOINT] $@" - - if is_debug_mode; then - debug_init - fi - # Run server - gunicorn app.wsgi:application "$@" +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 } From 3353f2f3b341b7c43714889251029a9089f29c50 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 16:28:39 +0900 Subject: [PATCH 83/93] =?UTF-8?q?feat(entrypoint.sh):=20DB=EA=B0=80=20read?= =?UTF-8?q?y=20=EB=90=A0=20=EB=95=8C=EA=B9=8C=EC=A7=80=20=EA=B8=B0?= =?UTF-8?q?=EB=8B=A4=EB=A6=AC=EB=8A=94=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- entrypoint.sh | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/entrypoint.sh b/entrypoint.sh index f0c1764..384fb68 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -7,6 +7,8 @@ set -e function main() { echo "[ENTRYPOINT] Arguments: $@" + wait_for_db + if is_debug_mode; then debug_init fi @@ -35,4 +37,21 @@ function is_debug_mode() { } +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 "$@" From 9e78309e7c1baa554a6ad3f23186c8f8534f485d Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 16:30:03 +0900 Subject: [PATCH 84/93] =?UTF-8?q?feat(app.env):=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=BD=98=ED=85=90=EC=B8=A0=EB=A5=BC=20=EC=9D=BD=EC=96=B4?= =?UTF-8?q?=EC=98=AC=20=EB=95=8C=20strip=20=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/env.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/env.py b/app/env.py index c4451e9..3332ad5 100644 --- a/app/env.py +++ b/app/env.py @@ -155,7 +155,7 @@ def get_file_content(key: str, default: Optional[str] = None, required: bool = F if value is not None: try: - retval = Path(value).read_text() + retval = Path(value).read_text().strip() except FileNotFoundError: raise ValueError( f'Environment variable "{key}" has invalid file path.' From 8b870dd9d40f2a92f5e38d4e5efd0285151bad1a Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 16:30:42 +0900 Subject: [PATCH 85/93] =?UTF-8?q?test(app.env):=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=BD=98=ED=85=90=EC=B8=A0=EB=A5=BC=20=EC=9D=BD=EC=96=B4?= =?UTF-8?q?=EC=98=AC=20=EB=95=8C=20strip=20=ED=95=98=EB=8A=94=EC=A7=80=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC=ED=95=98=EB=8A=94=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/tests.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/app/tests.py b/app/tests.py index fabb810..fde98b5 100644 --- a/app/tests.py +++ b/app/tests.py @@ -138,6 +138,39 @@ 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: From 0698fa04df6b3d46afc1b7d466df8f49106da318 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 16:34:32 +0900 Subject: [PATCH 86/93] build(docker): update develop sync configuration to ignore additional files --- docker-compose.develop.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docker-compose.develop.yml b/docker-compose.develop.yml index 5eea181..273446d 100644 --- a/docker-compose.develop.yml +++ b/docker-compose.develop.yml @@ -32,6 +32,13 @@ services: path: "." target: "/app/" initial_sync: true + ignore: + - ".env" + - ".git" + - ".secrets" + - "__pycache__" + - "*.pyc" + - "README.md" tle-auth-database: image: postgres:15-alpine From 966dd8dad4e6fe027312320583561e86601d700e Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 16:38:55 +0900 Subject: [PATCH 87/93] =?UTF-8?q?build(docker):=20Dockerfile=20=EC=B5=9C?= =?UTF-8?q?=EC=A0=81=ED=99=94=20-=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EC=84=A4=EC=B9=98=20=EA=B3=BC=EC=A0=95=EC=9D=84=20=EB=B3=91?= =?UTF-8?q?=ED=95=A9=ED=95=98=EC=97=AC=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=96=B4=20=EA=B0=9C=EC=88=98=20=EA=B0=90?= =?UTF-8?q?=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 868b2f3..51ec9a0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,15 +9,15 @@ ENV PYTHONDONTWRITEBYTECODE=1 # Prevents Python from buffering stdout and stderr ENV PYTHONUNBUFFERED=1 -# Upgrade pip -RUN pip install --upgrade pip - # Copy the Django project and install dependencies COPY requirements.txt /app/ # Run this command to install all dependencies -RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* -RUN pip install --no-cache-dir -r requirements.txt +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/ From 2e2f272270b7827e4491ed50a66629f78ce187a4 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 16:40:56 +0900 Subject: [PATCH 88/93] =?UTF-8?q?chore(app.env):=20`get=5Fjson()`=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=EC=97=90=20=EB=8C=80=ED=95=9C=20docstring=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/env.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/env.py b/app/env.py index 3332ad5..d98dc7d 100644 --- a/app/env.py +++ b/app/env.py @@ -49,6 +49,19 @@ def get(key: str, default: Optional[str] = None, required: bool = False) -> Opti 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 From 506a12f90a5a41743e1847aeefe3a8741d185574 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 16:43:14 +0900 Subject: [PATCH 89/93] =?UTF-8?q?docs:=20=ED=99=98=EA=B2=BD=EB=B3=80?= =?UTF-8?q?=EC=88=98=20=EC=84=A4=EB=AA=85=EC=97=90=20`ALLOWED=5FHOSTS`=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 38fe8b2..2a023ec 100644 --- a/README.md +++ b/README.md @@ -138,17 +138,18 @@ TLE 서비스의 모놀리식 아키텍처를 MSA로 전환하는 첫 번째 서 Docker compose를 사용하지 않고 직접 컨테이너를 실행할 경우 필요한 환경변수 입니다. -| 변수명 | 설명 | 기본값 | 필수 | -| ------------------------ | ------------------------------------------ | ------- | --------------------------------------------------- | -| `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` | 데이터베이스 사용자 비밀번호가 저장된 파일 | | ❌ | +| 변수명 | 설명 | 기본값 | 필수 | +| ------------------------ | -------------------------------------------------- | ---------------------------- | --------------------------------------------------- | +| `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` | 데이터베이스 사용자 비밀번호가 저장된 파일 | | ❌ | ### 포트 설정 변경 From c0519cc96caa2d6f8b2c3193f87bc087fc891b15 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 16:46:11 +0900 Subject: [PATCH 90/93] =?UTF-8?q?fix(app.views):=20`HealthCheckAPIView`=20?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=97=B0=EA=B2=B0=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=99=B8=EC=9D=98=20`DatabaseError`=20=EB=8F=84=20=EB=8B=A4?= =?UTF-8?q?=EB=A3=A8=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/views.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/views.py b/app/views.py index 513f91c..3ec6e1b 100644 --- a/app/views.py +++ b/app/views.py @@ -1,4 +1,5 @@ from django.db import connections +from django.db.utils import DatabaseError from django.db.utils import OperationalError from django.http import HttpRequest from django.http import HttpResponse @@ -46,5 +47,7 @@ def get(self, request: HttpRequest): pass except OperationalError: raise APIException("Database connection failed.") + except DatabaseError: + raise APIException("Database error occurred.") return HttpResponse(status=status.HTTP_200_OK) From 5abc80d4507496059871373b6bf4a2cc49bfe79d Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 16:52:27 +0900 Subject: [PATCH 91/93] test(app.tests): add tests for handling `DatabaseError` and `InterfaceError` in `HealthCheckAPIView` --- app/tests.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/app/tests.py b/app/tests.py index fde98b5..5d6269e 100644 --- a/app/tests.py +++ b/app/tests.py @@ -1,5 +1,7 @@ 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 @@ -27,6 +29,30 @@ def test_get_500_with_database_connection_error(self, mock_connections): 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): From 211ee0148f6ea1863b0c1da6851e8f5981b897e6 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 16:54:54 +0900 Subject: [PATCH 92/93] fix(app.views): handle unexpected errors in `HealthCheckAPIView` instead of specific `DatabaseError` --- app/views.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/views.py b/app/views.py index 3ec6e1b..65f59a9 100644 --- a/app/views.py +++ b/app/views.py @@ -1,5 +1,4 @@ from django.db import connections -from django.db.utils import DatabaseError from django.db.utils import OperationalError from django.http import HttpRequest from django.http import HttpResponse @@ -47,7 +46,7 @@ def get(self, request: HttpRequest): pass except OperationalError: raise APIException("Database connection failed.") - except DatabaseError: - raise APIException("Database error occurred.") + except Exception: + raise APIException("Unexpected error during health check.") return HttpResponse(status=status.HTTP_200_OK) From e9818f59cb7e52420993619fc0af351543861b94 Mon Sep 17 00:00:00 2001 From: Hepheir Date: Sat, 29 Nov 2025 18:12:23 +0900 Subject: [PATCH 93/93] build(docker-compose): ensure tle-auth-service always restarts --- docker-compose.develop.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.develop.yml b/docker-compose.develop.yml index 273446d..fa97138 100644 --- a/docker-compose.develop.yml +++ b/docker-compose.develop.yml @@ -1,5 +1,6 @@ services: tle-auth-service: + restart: always build: context: . dockerfile: Dockerfile