From 8c7739ffad6e2823382ec9220a20c21e1f5e53c1 Mon Sep 17 00:00:00 2001 From: icurfer Date: Wed, 14 Jan 2026 01:29:21 +0900 Subject: [PATCH] Add NHN Cloud API integration with async task support - NHN Cloud API packages: token, vpc, compute, nks, storage - REST API endpoints with Swagger documentation - Async task processing for long-running operations - CORS configuration for frontend integration - Enhanced logging for debugging API calls Co-Authored-By: Claude Opus 4.5 --- .github/workflows/build.yaml | 48 ++ .gitignore | 13 + API_SPEC.md | 644 +++++++++++++++++++++++++ Dockerfile | 22 + manage.py | 22 + nhn/__init__.py | 0 nhn/admin.py | 3 + nhn/apps.py | 6 + nhn/authentication.py | 28 ++ nhn/migrations/0001_initial.py | 36 ++ nhn/migrations/__init__.py | 0 nhn/models.py | 89 ++++ nhn/packages/__init__.py | 13 + nhn/packages/base.py | 165 +++++++ nhn/packages/compute.py | 220 +++++++++ nhn/packages/nks.py | 214 +++++++++ nhn/packages/storage.py | 279 +++++++++++ nhn/packages/token.py | 123 +++++ nhn/packages/vpc.py | 272 +++++++++++ nhn/serializers.py | 201 ++++++++ nhn/tasks.py | 399 ++++++++++++++++ nhn/tests.py | 3 + nhn/urls.py | 52 ++ nhn/utils.py | 31 ++ nhn/views.py | 849 +++++++++++++++++++++++++++++++++ nhn_prj/__init__.py | 0 nhn_prj/asgi.py | 11 + nhn_prj/settings.py | 241 ++++++++++ nhn_prj/urls.py | 34 ++ nhn_prj/wsgi.py | 11 + requirements.txt | 29 ++ version | 1 + 32 files changed, 4059 insertions(+) create mode 100644 .github/workflows/build.yaml create mode 100644 API_SPEC.md create mode 100644 Dockerfile create mode 100644 manage.py create mode 100644 nhn/__init__.py create mode 100644 nhn/admin.py create mode 100644 nhn/apps.py create mode 100644 nhn/authentication.py create mode 100644 nhn/migrations/0001_initial.py create mode 100644 nhn/migrations/__init__.py create mode 100644 nhn/models.py create mode 100644 nhn/packages/__init__.py create mode 100644 nhn/packages/base.py create mode 100644 nhn/packages/compute.py create mode 100644 nhn/packages/nks.py create mode 100644 nhn/packages/storage.py create mode 100644 nhn/packages/token.py create mode 100644 nhn/packages/vpc.py create mode 100644 nhn/serializers.py create mode 100644 nhn/tasks.py create mode 100644 nhn/tests.py create mode 100644 nhn/urls.py create mode 100644 nhn/utils.py create mode 100644 nhn/views.py create mode 100644 nhn_prj/__init__.py create mode 100644 nhn_prj/asgi.py create mode 100644 nhn_prj/settings.py create mode 100644 nhn_prj/urls.py create mode 100644 nhn_prj/wsgi.py create mode 100644 requirements.txt create mode 100644 version diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..71ec965 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,48 @@ +name: Build and Push Docker Image + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Get version + id: version + run: echo "VERSION=$(cat version)" >> $GITHUB_OUTPUT + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Harbor Registry + uses: docker/login-action@v3 + with: + registry: harbor.icurfer.com + username: ${{ secrets.HARBOR_USERNAME }} + password: ${{ secrets.HARBOR_PASSWORD }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: harbor.icurfer.com/msa-demo/msa-django-nhn:${{ steps.version.outputs.VERSION }} + + - name: Update Kubernetes manifests + run: | + git clone https://${{ secrets.GIT_USERNAME }}:${{ secrets.GIT_TOKEN }}@github.com/${{ github.repository_owner }}/cd-msa-django-nhn.git + cd cd-msa-django-nhn + sed -i "s|harbor.icurfer.com/msa-demo/msa-django-nhn:.*|harbor.icurfer.com/msa-demo/msa-django-nhn:${{ steps.version.outputs.VERSION }}|g" kustomize/overlays/dev/kustomization.yaml + git config user.name "GitHub Actions" + git config user.email "actions@github.com" + git add . + git commit -m "Update image tag to ${{ steps.version.outputs.VERSION }}" || echo "No changes to commit" + git push diff --git a/.gitignore b/.gitignore index 36b13f1..801651f 100644 --- a/.gitignore +++ b/.gitignore @@ -130,6 +130,7 @@ celerybeat.pid # Environments .env +.env.* .venv env/ venv/ @@ -174,3 +175,15 @@ cython_debug/ # PyPI configuration file .pypirc +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# macOS +.DS_Store + +# Keys (do not commit real keys) +keys/*.pem +!keys/.gitkeep diff --git a/API_SPEC.md b/API_SPEC.md new file mode 100644 index 0000000..a162451 --- /dev/null +++ b/API_SPEC.md @@ -0,0 +1,644 @@ +# NHN Cloud API 명세서 + +## 기본 정보 + +- **Base URL**: `http://{server}:8900/api/nhn` +- **Swagger UI**: `http://{server}:8900/swagger/` +- **Content-Type**: `application/json` + +--- + +## 인증 방식 + +모든 API 호출 시 아래 헤더를 포함해야 합니다: + +| 헤더 | 필수 | 설명 | +|------|------|------| +| `X-NHN-Region` | O | NHN Cloud 리전 (`kr1`: 판교, `kr2`: 평촌) | +| `X-NHN-Token` | O | NHN Cloud API 토큰 | +| `X-NHN-Tenant-ID` | △ | 테넌트 ID (Compute API에서 필수) | +| `X-NHN-Storage-Account` | △ | 스토리지 계정 (Storage API에서 필수) | + +--- + +## 1. Token API + +### 1.1 토큰 생성 + +NHN Cloud API 인증 토큰을 발급받습니다. + +``` +POST /api/nhn/token/ +``` + +**Request Body** +```json +{ + "tenant_id": "04a2c5b7de7e4d66b970ad950081a7c3", + "username": "user@example.com", + "password": "your-api-password" +} +``` + +**Response (200 OK)** +```json +{ + "token": "gAAAAABm...", + "tenant_id": "04a2c5b7de7e4d66b970ad950081a7c3", + "expires_at": "2024-01-15T12:00:00Z" +} +``` + +**Error Response (401)** +```json +{ + "error": "인증 실패 메시지", + "code": 401 +} +``` + +--- + +## 2. Compute API + +### 2.1 Flavor 목록 조회 + +``` +GET /api/nhn/compute/flavors/ +``` + +**Headers** +``` +X-NHN-Region: kr2 +X-NHN-Token: {token} +X-NHN-Tenant-ID: {tenant_id} +``` + +**Response (200 OK)** +```json +{ + "flavors": [ + { + "id": "flavor-id-123", + "name": "m2.c2m4", + "vcpus": 2, + "ram": 4096, + "disk": 0 + } + ] +} +``` + +--- + +### 2.2 Keypair 목록 조회 + +``` +GET /api/nhn/compute/keypairs/ +``` + +**Response (200 OK)** +```json +{ + "keypairs": [ + { + "keypair": { + "name": "my-keypair", + "public_key": "ssh-rsa AAAA...", + "fingerprint": "xx:xx:xx..." + } + } + ] +} +``` + +--- + +### 2.3 이미지 목록 조회 + +``` +GET /api/nhn/compute/images/ +``` + +**Response (200 OK)** +```json +{ + "images": [ + { + "id": "image-id-123", + "name": "Ubuntu 20.04", + "status": "active", + "created_at": "2024-01-01T00:00:00Z" + } + ] +} +``` + +--- + +### 2.4 인스턴스 목록 조회 + +``` +GET /api/nhn/compute/instances/ +``` + +**Response (200 OK)** +```json +{ + "servers": [ + { + "id": "server-id-123", + "name": "my-instance", + "status": "ACTIVE", + "addresses": { + "Default Network": [ + {"addr": "192.168.0.10", "version": 4} + ] + }, + "created": "2024-01-01T00:00:00Z" + } + ] +} +``` + +--- + +### 2.5 인스턴스 상세 조회 + +``` +GET /api/nhn/compute/instances/{server_id}/ +``` + +**Response (200 OK)** +```json +{ + "server": { + "id": "server-id-123", + "name": "my-instance", + "status": "ACTIVE", + "flavor": {"id": "flavor-id"}, + "image": {"id": "image-id"}, + "addresses": {...}, + "created": "2024-01-01T00:00:00Z" + } +} +``` + +--- + +### 2.6 인스턴스 생성 + +``` +POST /api/nhn/compute/instances/create/ +``` + +**Request Body** +```json +{ + "name": "my-new-instance", + "image_id": "image-id-123", + "flavor_id": "flavor-id-123", + "subnet_id": "subnet-id-123", + "keypair_name": "my-keypair", + "volume_size": 50, + "security_groups": ["default"], + "availability_zone": "kr-pub-a" +} +``` + +| 필드 | 타입 | 필수 | 설명 | +|------|------|------|------| +| name | string | O | 인스턴스 이름 | +| image_id | string | O | 이미지 ID | +| flavor_id | string | O | Flavor ID | +| subnet_id | string | O | 서브넷 ID | +| keypair_name | string | O | Keypair 이름 | +| volume_size | integer | X | 볼륨 크기 (GB, 기본 50) | +| security_groups | array | X | 보안 그룹 (기본 ["default"]) | +| availability_zone | string | X | 가용 영역 | + +**Response (201 Created)** +```json +{ + "server": { + "id": "new-server-id", + "name": "my-new-instance", + ... + } +} +``` + +--- + +### 2.7 인스턴스 삭제 + +``` +DELETE /api/nhn/compute/instances/{server_id}/ +``` + +**Response (204 No Content)** + +--- + +### 2.8 인스턴스 액션 (시작/정지/재부팅) + +``` +POST /api/nhn/compute/instances/{server_id}/action/ +``` + +**Request Body** +```json +{ + "action": "start" // "start" | "stop" | "reboot" +} +``` + +**Response (200 OK)** +```json +{ + "status": "success", + "action": "start" +} +``` + +--- + +## 3. VPC API + +### 3.1 VPC 목록 조회 + +``` +GET /api/nhn/vpc/ +``` + +**Headers** +``` +X-NHN-Region: kr2 +X-NHN-Token: {token} +``` + +**Response (200 OK)** +```json +{ + "vpcs": [ + { + "id": "vpc-id-123", + "name": "my-vpc", + "cidrv4": "10.0.0.0/16", + "state": "available" + } + ] +} +``` + +--- + +### 3.2 VPC 생성 + +``` +POST /api/nhn/vpc/create/ +``` + +**Request Body** +```json +{ + "name": "my-new-vpc", + "cidr": "10.0.0.0/16" +} +``` + +**Response (201 Created)** +```json +{ + "vpc": { + "id": "new-vpc-id", + "name": "my-new-vpc", + "cidrv4": "10.0.0.0/16" + } +} +``` + +--- + +### 3.3 VPC 상세 조회 + +``` +GET /api/nhn/vpc/{vpc_id}/ +``` + +--- + +### 3.4 VPC 삭제 + +``` +DELETE /api/nhn/vpc/{vpc_id}/ +``` + +**Response (204 No Content)** + +--- + +### 3.5 서브넷 목록 조회 + +``` +GET /api/nhn/subnet/ +``` + +**Response (200 OK)** +```json +{ + "vpcsubnets": [ + { + "id": "subnet-id-123", + "name": "my-subnet", + "vpc_id": "vpc-id-123", + "cidr": "10.0.1.0/24", + "gateway": "10.0.1.1" + } + ] +} +``` + +--- + +### 3.6 서브넷 생성 + +``` +POST /api/nhn/subnet/create/ +``` + +**Request Body** +```json +{ + "vpc_id": "vpc-id-123", + "name": "my-new-subnet", + "cidr": "10.0.1.0/24" +} +``` + +--- + +### 3.7 서브넷 상세 조회 / 삭제 + +``` +GET /api/nhn/subnet/{subnet_id}/ +DELETE /api/nhn/subnet/{subnet_id}/ +``` + +--- + +### 3.8 Floating IP 목록 조회 + +``` +GET /api/nhn/floatingip/ +``` + +**Response (200 OK)** +```json +{ + "floatingips": [ + { + "id": "fip-id-123", + "floating_ip_address": "133.186.xxx.xxx", + "status": "ACTIVE", + "port_id": "port-id-123" + } + ] +} +``` + +--- + +## 4. NKS (Kubernetes) API + +### 4.1 클러스터 목록 조회 + +``` +GET /api/nhn/nks/clusters/ +``` + +**Headers** +``` +X-NHN-Region: kr2 +X-NHN-Token: {token} +``` + +**Response (200 OK)** +```json +{ + "clusters": [ + { + "uuid": "cluster-uuid-123", + "name": "my-cluster", + "status": "CREATE_COMPLETE", + "node_count": 3 + } + ] +} +``` + +--- + +### 4.2 클러스터 상세 조회 + +``` +GET /api/nhn/nks/clusters/{cluster_name}/ +``` + +--- + +### 4.3 클러스터 kubeconfig 조회 + +``` +GET /api/nhn/nks/clusters/{cluster_name}/config/ +``` + +**Response (200 OK)** +```json +{ + "config": "apiVersion: v1\nclusters:\n- cluster:..." +} +``` + +--- + +### 4.4 클러스터 생성 + +``` +POST /api/nhn/nks/clusters/create/ +``` + +**Request Body** +```json +{ + "cluster_name": "my-k8s-cluster", + "vpc_id": "vpc-id-123", + "subnet_id": "subnet-id-123", + "instance_type": "m2.c4m8", + "keypair_name": "my-keypair", + "kubernetes_version": "v1.28.3", + "availability_zone": "kr-pub-a", + "is_public": true, + "external_network_id": "ext-net-id", + "external_subnet_id": "ext-subnet-id", + "node_count": 3, + "boot_volume_size": 50, + "boot_volume_type": "General SSD" +} +``` + +| 필드 | 타입 | 필수 | 설명 | +|------|------|------|------| +| cluster_name | string | O | 클러스터 이름 | +| vpc_id | string | O | VPC ID | +| subnet_id | string | O | 서브넷 ID | +| instance_type | string | O | 인스턴스 타입 (Flavor ID) | +| keypair_name | string | O | Keypair 이름 | +| kubernetes_version | string | O | K8s 버전 (예: v1.28.3) | +| availability_zone | string | O | 가용 영역 | +| is_public | boolean | X | Public 클러스터 여부 (기본 true) | +| external_network_id | string | △ | 외부 네트워크 ID (Public 필수) | +| external_subnet_id | string | △ | 외부 서브넷 ID (Public 필수) | +| node_count | integer | X | 노드 수 (기본 1) | +| boot_volume_size | integer | X | 부팅 볼륨 크기 (기본 50GB) | +| boot_volume_type | string | X | 볼륨 타입 (기본 General SSD) | + +--- + +### 4.5 클러스터 삭제 + +``` +DELETE /api/nhn/nks/clusters/{cluster_name}/ +``` + +**Response (204 No Content)** + +--- + +## 5. Object Storage API + +### 5.1 컨테이너 목록 조회 + +``` +GET /api/nhn/storage/containers/ +``` + +**Headers** +``` +X-NHN-Region: kr2 +X-NHN-Token: {token} +X-NHN-Storage-Account: AUTH_xxxxxxxx +``` + +**Response (200 OK)** +```json +{ + "containers": ["container1", "container2", "container3"] +} +``` + +--- + +### 5.2 컨테이너 생성 + +``` +POST /api/nhn/storage/containers/create/ +``` + +**Request Body** +```json +{ + "name": "my-new-container" +} +``` + +**Response (201 Created)** +```json +{ + "status": 201, + "container": "my-new-container" +} +``` + +--- + +### 5.3 컨테이너 오브젝트 목록 조회 + +``` +GET /api/nhn/storage/containers/{container_name}/ +``` + +**Response (200 OK)** +```json +{ + "objects": ["file1.txt", "file2.png", "folder/file3.json"] +} +``` + +--- + +### 5.4 컨테이너 삭제 + +``` +DELETE /api/nhn/storage/containers/{container_name}/ +``` + +**Response (204 No Content)** + +--- + +## 에러 응답 + +모든 API는 실패 시 아래 형식으로 에러를 반환합니다: + +```json +{ + "error": "에러 메시지", + "code": 400 +} +``` + +| HTTP Status | 설명 | +|-------------|------| +| 400 | 잘못된 요청 (파라미터 오류) | +| 401 | 인증 실패 (토큰 없음/만료) | +| 403 | 권한 없음 | +| 404 | 리소스 없음 | +| 500 | 서버 내부 오류 | + +--- + +## 프론트엔드 사용 예시 (JavaScript) + +```javascript +// 1. 토큰 발급 +const tokenResponse = await fetch('/api/nhn/token/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tenant_id: 'your-tenant-id', + username: 'user@example.com', + password: 'your-password' + }) +}); +const { token, tenant_id } = await tokenResponse.json(); + +// 2. API 호출 (인스턴스 목록) +const instancesResponse = await fetch('/api/nhn/compute/instances/', { + headers: { + 'X-NHN-Region': 'kr2', + 'X-NHN-Token': token, + 'X-NHN-Tenant-ID': tenant_id + } +}); +const instances = await instancesResponse.json(); +``` + +--- + +## 리전 정보 + +| 리전 코드 | 위치 | +|-----------|------| +| kr1 | 판교 | +| kr2 | 평촌 | diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..398e124 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM harbor.icurfer.com/open/python:3.10-slim-bullseye + +WORKDIR /usr/src/app + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +RUN apt-get update && apt-get install -y \ + gcc \ + pkg-config \ + default-libmysqlclient-dev \ + python-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --upgrade pip && pip install -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["gunicorn", "--workers=3", "--bind=0.0.0.0:8000", "nhn_prj.wsgi:application"] diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..d13db4c --- /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", "nhn_prj.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/nhn/__init__.py b/nhn/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nhn/admin.py b/nhn/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/nhn/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/nhn/apps.py b/nhn/apps.py new file mode 100644 index 0000000..4d76ac2 --- /dev/null +++ b/nhn/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class NhnConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "nhn" diff --git a/nhn/authentication.py b/nhn/authentication.py new file mode 100644 index 0000000..7358c35 --- /dev/null +++ b/nhn/authentication.py @@ -0,0 +1,28 @@ +from rest_framework_simplejwt.authentication import JWTAuthentication +from rest_framework_simplejwt.exceptions import InvalidToken + + +class StatelessUser: + """ + Stateless user class for JWT authentication. + Does not require database User model. + """ + + def __init__(self, username): + self.username = username + self.is_authenticated = True + + def __str__(self): + return self.username + + +class StatelessJWTAuthentication(JWTAuthentication): + """ + Custom JWT authentication that extracts user from token's 'name' claim. + """ + + def get_user(self, validated_token): + name = validated_token.get("name") + if not name: + raise InvalidToken("Token does not contain 'name' claim.") + return StatelessUser(username=name) diff --git a/nhn/migrations/0001_initial.py b/nhn/migrations/0001_initial.py new file mode 100644 index 0000000..d460c5a --- /dev/null +++ b/nhn/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.14 on 2026-01-13 16:05 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='AsyncTask', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('task_type', models.CharField(choices=[('instance_create', '인스턴스 생성'), ('instance_delete', '인스턴스 삭제'), ('nks_create', 'NKS 클러스터 생성'), ('nks_delete', 'NKS 클러스터 삭제')], max_length=50)), + ('status', models.CharField(choices=[('pending', '대기중'), ('running', '실행중'), ('success', '성공'), ('failed', '실패')], default='pending', max_length=20)), + ('request_data', models.JSONField(default=dict, help_text='요청 데이터')), + ('result_data', models.JSONField(default=dict, help_text='결과 데이터')), + ('error_message', models.TextField(blank=True, help_text='에러 메시지')), + ('resource_id', models.CharField(blank=True, help_text='생성된 리소스 ID', max_length=255)), + ('resource_name', models.CharField(blank=True, help_text='리소스 이름', max_length=255)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('completed_at', models.DateTimeField(blank=True, null=True)), + ], + options={ + 'verbose_name': '비동기 작업', + 'verbose_name_plural': '비동기 작업들', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/nhn/migrations/__init__.py b/nhn/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nhn/models.py b/nhn/models.py new file mode 100644 index 0000000..557e369 --- /dev/null +++ b/nhn/models.py @@ -0,0 +1,89 @@ +""" +NHN Cloud Async Task Models + +비동기 작업 상태 추적 +""" + +import uuid +from django.db import models + + +class AsyncTask(models.Model): + """비동기 작업 상태 추적 모델""" + + class Status(models.TextChoices): + PENDING = "pending", "대기중" + RUNNING = "running", "실행중" + SUCCESS = "success", "성공" + FAILED = "failed", "실패" + + class TaskType(models.TextChoices): + # Compute + INSTANCE_CREATE = "instance_create", "인스턴스 생성" + INSTANCE_DELETE = "instance_delete", "인스턴스 삭제" + INSTANCE_ACTION = "instance_action", "인스턴스 액션" + # VPC/Network + VPC_CREATE = "vpc_create", "VPC 생성" + VPC_DELETE = "vpc_delete", "VPC 삭제" + SUBNET_CREATE = "subnet_create", "서브넷 생성" + SUBNET_DELETE = "subnet_delete", "서브넷 삭제" + # NKS + NKS_CREATE = "nks_create", "NKS 클러스터 생성" + NKS_DELETE = "nks_delete", "NKS 클러스터 삭제" + # Storage + STORAGE_CREATE = "storage_create", "스토리지 컨테이너 생성" + STORAGE_DELETE = "storage_delete", "스토리지 컨테이너 삭제" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + task_type = models.CharField(max_length=50, choices=TaskType.choices) + status = models.CharField( + max_length=20, choices=Status.choices, default=Status.PENDING + ) + + # 요청 정보 + request_data = models.JSONField(default=dict, help_text="요청 데이터") + + # 결과 정보 + result_data = models.JSONField(default=dict, help_text="결과 데이터") + error_message = models.TextField(blank=True, help_text="에러 메시지") + + # NHN Cloud 리소스 정보 + resource_id = models.CharField(max_length=255, blank=True, help_text="생성된 리소스 ID") + resource_name = models.CharField(max_length=255, blank=True, help_text="리소스 이름") + + # 메타 정보 + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + completed_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ["-created_at"] + verbose_name = "비동기 작업" + verbose_name_plural = "비동기 작업들" + + def __str__(self): + return f"{self.task_type} - {self.status} ({self.id})" + + def mark_running(self): + """작업 시작""" + self.status = self.Status.RUNNING + self.save(update_fields=["status", "updated_at"]) + + def mark_success(self, result_data=None, resource_id=None): + """작업 성공""" + from django.utils import timezone + self.status = self.Status.SUCCESS + if result_data: + self.result_data = result_data + if resource_id: + self.resource_id = resource_id + self.completed_at = timezone.now() + self.save() + + def mark_failed(self, error_message): + """작업 실패""" + from django.utils import timezone + self.status = self.Status.FAILED + self.error_message = error_message + self.completed_at = timezone.now() + self.save() diff --git a/nhn/packages/__init__.py b/nhn/packages/__init__.py new file mode 100644 index 0000000..6ea3a8a --- /dev/null +++ b/nhn/packages/__init__.py @@ -0,0 +1,13 @@ +from .token import NHNCloudToken +from .compute import ApiCompute +from .vpc import ApiVpc +from .nks import ApiNks +from .storage import ApiStorageObject + +__all__ = [ + "NHNCloudToken", + "ApiCompute", + "ApiVpc", + "ApiNks", + "ApiStorageObject", +] diff --git a/nhn/packages/base.py b/nhn/packages/base.py new file mode 100644 index 0000000..a60281c --- /dev/null +++ b/nhn/packages/base.py @@ -0,0 +1,165 @@ +""" +NHN Cloud API Base Module + +공통 기능을 제공하는 베이스 클래스 +""" + +import logging +from typing import Optional, Any +from dataclasses import dataclass +from enum import Enum + +import requests + +logger = logging.getLogger(__name__) + + +class Region(str, Enum): + """NHN Cloud 리전""" + KR1 = "kr1" # 판교 + KR2 = "kr2" # 평촌 + + +@dataclass +class NHNCloudEndpoints: + """NHN Cloud API Endpoints""" + # Identity + IDENTITY = "https://api-identity-infrastructure.nhncloudservice.com" + + # Compute + COMPUTE_KR1 = "https://kr1-api-instance-infrastructure.nhncloudservice.com" + COMPUTE_KR2 = "https://kr2-api-instance-infrastructure.nhncloudservice.com" + + # Image + IMAGE_KR1 = "https://kr1-api-image-infrastructure.nhncloudservice.com" + IMAGE_KR2 = "https://kr2-api-image-infrastructure.nhncloudservice.com" + + # Network (VPC) + NETWORK_KR1 = "https://kr1-api-network-infrastructure.nhncloudservice.com" + NETWORK_KR2 = "https://kr2-api-network-infrastructure.nhncloudservice.com" + + # Kubernetes (NKS) + NKS_KR1 = "https://kr1-api-kubernetes-infrastructure.nhncloudservice.com" + NKS_KR2 = "https://kr2-api-kubernetes-infrastructure.nhncloudservice.com" + + # Object Storage + STORAGE_KR1 = "https://kr1-api-object-storage.nhncloudservice.com/v1" + STORAGE_KR2 = "https://kr2-api-object-storage.nhncloudservice.com/v1" + + +class NHNCloudAPIError(Exception): + """NHN Cloud API 에러""" + + def __init__(self, message: str, code: Optional[int] = None, details: Optional[dict] = None): + self.message = message + self.code = code + self.details = details or {} + super().__init__(self.message) + + +class BaseAPI: + """NHN Cloud API 베이스 클래스""" + + DEFAULT_TIMEOUT = 30 + + def __init__(self, region: str, token: str): + self.region = Region(region.lower()) if isinstance(region, str) else region + self.token = token + self._session = requests.Session() + + def _get_headers(self, extra_headers: Optional[dict] = None) -> dict: + """기본 헤더 생성""" + headers = { + "X-Auth-Token": self.token, + "Content-Type": "application/json", + "Accept": "application/json", + } + if extra_headers: + headers.update(extra_headers) + return headers + + def _request( + self, + method: str, + url: str, + params: Optional[dict] = None, + json_data: Optional[dict] = None, + headers: Optional[dict] = None, + timeout: Optional[int] = None, + ) -> dict: + """HTTP 요청 실행""" + # 토큰 앞 8자리만 로깅 (보안) + token_preview = self.token[:8] + "..." if self.token else "None" + logger.info(f"[BaseAPI] 요청 시작 - method={method}, url={url}, token={token_preview}") + if params: + logger.info(f"[BaseAPI] 요청 파라미터 - params={params}") + if json_data: + logger.info(f"[BaseAPI] 요청 바디 - json={json_data}") + + try: + response = self._session.request( + method=method, + url=url, + params=params, + json=json_data, + headers=self._get_headers(headers), + timeout=timeout or self.DEFAULT_TIMEOUT, + ) + + logger.info(f"[BaseAPI] 응답 수신 - method={method}, url={url}, status_code={response.status_code}") + + if response.status_code >= 400: + logger.error(f"[BaseAPI] 에러 응답 - status_code={response.status_code}, body={response.text[:500]}") + self._handle_error(response) + + if response.text: + return response.json() + return {} + + except requests.exceptions.Timeout: + logger.error(f"[BaseAPI] 타임아웃 - url={url}") + raise NHNCloudAPIError("요청 시간이 초과되었습니다.", code=408) + except requests.exceptions.ConnectionError as e: + logger.error(f"[BaseAPI] 연결 오류 - url={url}, error={e}") + raise NHNCloudAPIError("서버에 연결할 수 없습니다.", code=503) + except requests.exceptions.RequestException as e: + logger.error(f"[BaseAPI] 요청 오류 - url={url}, error={e}") + raise NHNCloudAPIError(f"요청 중 오류가 발생했습니다: {e}") + + def _handle_error(self, response: requests.Response) -> None: + """에러 응답 처리""" + try: + error_data = response.json() + if "error" in error_data: + error = error_data["error"] + raise NHNCloudAPIError( + message=error.get("message", "알 수 없는 오류"), + code=error.get("code", response.status_code), + details=error, + ) + raise NHNCloudAPIError( + message=f"API 오류: {response.status_code}", + code=response.status_code, + details=error_data, + ) + except ValueError: + raise NHNCloudAPIError( + message=f"API 오류: {response.status_code} - {response.text}", + code=response.status_code, + ) + + def _get(self, url: str, params: Optional[dict] = None, **kwargs) -> dict: + """GET 요청""" + return self._request("GET", url, params=params, **kwargs) + + def _post(self, url: str, json_data: Optional[dict] = None, **kwargs) -> dict: + """POST 요청""" + return self._request("POST", url, json_data=json_data, **kwargs) + + def _put(self, url: str, json_data: Optional[dict] = None, **kwargs) -> dict: + """PUT 요청""" + return self._request("PUT", url, json_data=json_data, **kwargs) + + def _delete(self, url: str, **kwargs) -> dict: + """DELETE 요청""" + return self._request("DELETE", url, **kwargs) diff --git a/nhn/packages/compute.py b/nhn/packages/compute.py new file mode 100644 index 0000000..fdcea3e --- /dev/null +++ b/nhn/packages/compute.py @@ -0,0 +1,220 @@ +""" +NHN Cloud Compute API Module + +인스턴스, 이미지, Flavor, Keypair 관리 +""" + +import logging +from typing import Optional, List + +from .base import BaseAPI, NHNCloudEndpoints, Region + +logger = logging.getLogger(__name__) + + +class ApiCompute(BaseAPI): + """NHN Cloud Compute API 클래스""" + + def __init__(self, region: str, tenant_id: str, token: str): + """ + Args: + region: 리전 (kr1: 판교, kr2: 평촌) + tenant_id: 테넌트 ID + token: API 인증 토큰 + """ + super().__init__(region, token) + self.tenant_id = tenant_id + + if self.region == Region.KR1: + self.compute_url = NHNCloudEndpoints.COMPUTE_KR1 + self.image_url = NHNCloudEndpoints.IMAGE_KR1 + else: + self.compute_url = NHNCloudEndpoints.COMPUTE_KR2 + self.image_url = NHNCloudEndpoints.IMAGE_KR2 + + # ==================== Flavor ==================== + + def get_flavor_list(self) -> dict: + """Flavor 목록 조회""" + url = f"{self.compute_url}/v2/{self.tenant_id}/flavors" + return self._get(url) + + def get_flavor_detail(self, flavor_id: str) -> dict: + """Flavor 상세 조회""" + url = f"{self.compute_url}/v2/{self.tenant_id}/flavors/{flavor_id}" + return self._get(url) + + def get_flavor_id_by_name(self, flavor_name: str) -> Optional[str]: + """Flavor 이름으로 ID 조회""" + data = self.get_flavor_list() + flavors = data.get("flavors", []) + + for flavor in flavors: + if flavor_name in flavor.get("name", ""): + return flavor.get("id") + return None + + # ==================== Keypair ==================== + + def get_keypair_list(self) -> dict: + """Keypair 목록 조회""" + url = f"{self.compute_url}/v2/{self.tenant_id}/os-keypairs" + return self._get(url) + + def get_keypair_info(self, keypair_name: str) -> dict: + """Keypair 상세 조회""" + url = f"{self.compute_url}/v2/{self.tenant_id}/os-keypairs/{keypair_name}" + return self._get(url) + + # ==================== Instance ==================== + + def get_instance_list(self) -> dict: + """인스턴스 목록 조회""" + url = f"{self.compute_url}/v2/{self.tenant_id}/servers" + return self._get(url) + + def get_instance_list_detail(self) -> dict: + """인스턴스 상세 목록 조회""" + url = f"{self.compute_url}/v2/{self.tenant_id}/servers/detail" + return self._get(url) + + def get_instance_info(self, server_id: str) -> dict: + """인스턴스 상세 정보 조회""" + url = f"{self.compute_url}/v2/{self.tenant_id}/servers/{server_id}" + return self._get(url) + + def get_instance_status(self, server_id: str) -> str: + """인스턴스 상태 조회""" + data = self.get_instance_info(server_id) + return data.get("server", {}).get("status", "UNKNOWN") + + def get_instance_id_by_name(self, instance_name: str) -> Optional[str]: + """인스턴스 이름으로 ID 조회""" + data = self.get_instance_list() + servers = data.get("servers", []) + + for server in servers: + if server.get("name") == instance_name: + return server.get("id") + return None + + def create_instance( + self, + name: str, + image_id: str, + flavor_id: str, + subnet_id: str, + keypair_name: str, + volume_size: int = 50, + volume_type: str = "General SSD", + security_groups: Optional[List[str]] = None, + availability_zone: Optional[str] = None, + ) -> dict: + """ + 인스턴스 생성 + + Args: + name: 인스턴스 이름 + image_id: 이미지 ID + flavor_id: Flavor ID + subnet_id: 서브넷 ID + keypair_name: Keypair 이름 + volume_size: 볼륨 크기 (GB, 기본 50) + volume_type: 볼륨 타입 (General SSD, General HDD) + security_groups: 보안 그룹 목록 (기본 ["default"]) + availability_zone: 가용 영역 + + Returns: + dict: 생성된 인스턴스 정보 + """ + url = f"{self.compute_url}/v2/{self.tenant_id}/servers" + + security_groups = security_groups or ["default"] + sg_list = [{"name": sg} for sg in security_groups] + + payload = { + "server": { + "name": name, + "imageRef": image_id, + "flavorRef": flavor_id, + "networks": [{"subnet": subnet_id}], + "key_name": keypair_name, + "max_count": 1, + "min_count": 1, + "block_device_mapping_v2": [ + { + "uuid": image_id, + "boot_index": 0, + "volume_size": volume_size, + "volume_type": volume_type, + "device_name": "vda", + "source_type": "image", + "destination_type": "volume", + "delete_on_termination": True, + } + ], + "security_groups": sg_list, + } + } + + if availability_zone: + payload["server"]["availability_zone"] = availability_zone + + logger.info(f"인스턴스 생성 요청: {name}") + return self._post(url, payload) + + def delete_instance(self, server_id: str) -> dict: + """인스턴스 삭제""" + url = f"{self.compute_url}/v2/{self.tenant_id}/servers/{server_id}" + logger.info(f"인스턴스 삭제 요청: {server_id}") + return self._delete(url) + + def start_instance(self, server_id: str) -> dict: + """인스턴스 시작""" + url = f"{self.compute_url}/v2/{self.tenant_id}/servers/{server_id}/action" + return self._post(url, {"os-start": None}) + + def stop_instance(self, server_id: str) -> dict: + """인스턴스 정지""" + url = f"{self.compute_url}/v2/{self.tenant_id}/servers/{server_id}/action" + return self._post(url, {"os-stop": None}) + + def reboot_instance(self, server_id: str, hard: bool = False) -> dict: + """인스턴스 재부팅""" + url = f"{self.compute_url}/v2/{self.tenant_id}/servers/{server_id}/action" + reboot_type = "HARD" if hard else "SOFT" + return self._post(url, {"reboot": {"type": reboot_type}}) + + # ==================== Image ==================== + + def get_image_list(self) -> dict: + """이미지 목록 조회""" + url = f"{self.image_url}/v2/images" + return self._get(url) + + def get_image_info(self, image_id: str) -> dict: + """이미지 상세 조회""" + url = f"{self.image_url}/v2/images/{image_id}" + return self._get(url) + + def get_image_id_by_name(self, image_name: str, exclude_container: bool = True) -> Optional[str]: + """ + 이미지 이름으로 ID 조회 + + Args: + image_name: 이미지 이름 (부분 일치) + exclude_container: 컨테이너 이미지 제외 여부 + + Returns: + str: 이미지 ID 또는 None + """ + data = self.get_image_list() + images = data.get("images", []) + + for image in images: + name = image.get("name", "") + if name.startswith(image_name): + if exclude_container and "Container" in name: + continue + return image.get("id") + return None diff --git a/nhn/packages/nks.py b/nhn/packages/nks.py new file mode 100644 index 0000000..ed9e5f1 --- /dev/null +++ b/nhn/packages/nks.py @@ -0,0 +1,214 @@ +""" +NHN Cloud NKS (Kubernetes Service) API Module + +Kubernetes 클러스터 관리 +""" + +import logging +from typing import Optional +from dataclasses import dataclass + +from .base import BaseAPI, NHNCloudEndpoints, Region + +logger = logging.getLogger(__name__) + + +@dataclass +class NKSNodeImages: + """NKS 노드 이미지 ID""" + # Ubuntu 20.04 이미지 ID (리전별) + UBUNTU_20_04_KR1 = "1213d033-bdf6-4d73-9763-4e8e57c745fb" + UBUNTU_20_04_KR2 = "dabb6d10-937d-4952-9ce0-1e576e9164e8" + + +class ApiNks(BaseAPI): + """NHN Cloud NKS API 클래스""" + + def __init__(self, region: str, token: str): + """ + Args: + region: 리전 (kr1: 판교, kr2: 평촌) + token: API 인증 토큰 + """ + super().__init__(region, token) + + if self.region == Region.KR1: + self.nks_url = NHNCloudEndpoints.NKS_KR1 + self.default_node_image = NKSNodeImages.UBUNTU_20_04_KR1 + else: + self.nks_url = NHNCloudEndpoints.NKS_KR2 + self.default_node_image = NKSNodeImages.UBUNTU_20_04_KR2 + + def _get_headers(self, extra_headers: Optional[dict] = None) -> dict: + """NKS API 전용 헤더""" + headers = { + "X-Auth-Token": self.token, + "Accept": "application/json", + "Content-Type": "application/json", + "OpenStack-API-Version": "container-infra latest", + } + if extra_headers: + headers.update(extra_headers) + return headers + + # ==================== Cluster ==================== + + def get_cluster_list(self) -> dict: + """클러스터 목록 조회""" + url = f"{self.nks_url}/v1/clusters" + return self._get(url) + + def get_cluster_info(self, cluster_name: str) -> dict: + """클러스터 상세 조회""" + url = f"{self.nks_url}/v1/clusters/{cluster_name}" + return self._get(url) + + def get_cluster_config(self, cluster_name: str) -> str: + """클러스터 kubeconfig 조회""" + url = f"{self.nks_url}/v1/clusters/{cluster_name}/config" + data = self._get(url) + return data.get("config", "") + + def create_public_cluster( + self, + cluster_name: str, + vpc_id: str, + subnet_id: str, + instance_type: str, + keypair_name: str, + kubernetes_version: str, + external_network_id: str, + external_subnet_id: str, + availability_zone: str, + node_count: int = 1, + boot_volume_size: int = 50, + boot_volume_type: str = "General SSD", + node_image: Optional[str] = None, + ) -> dict: + """ + Public 클러스터 생성 (외부 접근 가능) + + Args: + cluster_name: 클러스터 이름 + vpc_id: VPC ID + subnet_id: 서브넷 ID + instance_type: 인스턴스 타입 (Flavor ID) + keypair_name: Keypair 이름 + kubernetes_version: Kubernetes 버전 (예: v1.28.3) + external_network_id: 외부 네트워크 ID + external_subnet_id: 외부 서브넷 ID + availability_zone: 가용 영역 (예: kr-pub-a) + node_count: 노드 수 (기본 1) + boot_volume_size: 부팅 볼륨 크기 (GB, 기본 50) + boot_volume_type: 볼륨 타입 (기본 "General SSD") + node_image: 노드 이미지 ID (기본 Ubuntu 20.04) + + Returns: + dict: 생성된 클러스터 정보 + """ + url = f"{self.nks_url}/v1/clusters" + + payload = { + "cluster_template_id": "iaas_console", + "create_timeout": 60, + "fixed_network": vpc_id, + "fixed_subnet": subnet_id, + "flavor_id": instance_type, + "keypair": keypair_name, + "labels": { + "availability_zone": availability_zone, + "boot_volume_size": str(boot_volume_size), + "boot_volume_type": boot_volume_type, + "ca_enable": "false", + "cert_manager_api": "True", + "clusterautoscale": "nodegroupfeature", + "external_network_id": external_network_id, + "external_subnet_id_list": external_subnet_id, + "kube_tag": kubernetes_version, + "master_lb_floating_ip_enabled": "True", + "node_image": node_image or self.default_node_image, + }, + "name": cluster_name, + "node_count": str(node_count), + } + + logger.info(f"Public 클러스터 생성 요청: {cluster_name}") + return self._post(url, payload) + + def create_private_cluster( + self, + cluster_name: str, + vpc_id: str, + subnet_id: str, + instance_type: str, + keypair_name: str, + kubernetes_version: str, + availability_zone: str, + node_count: int = 1, + boot_volume_size: int = 50, + boot_volume_type: str = "General SSD", + node_image: Optional[str] = None, + ) -> dict: + """ + Private 클러스터 생성 (내부 접근만 가능) + + Args: + cluster_name: 클러스터 이름 + vpc_id: VPC ID + subnet_id: 서브넷 ID + instance_type: 인스턴스 타입 (Flavor ID) + keypair_name: Keypair 이름 + kubernetes_version: Kubernetes 버전 (예: v1.28.3) + availability_zone: 가용 영역 (예: kr-pub-a) + node_count: 노드 수 (기본 1) + boot_volume_size: 부팅 볼륨 크기 (GB, 기본 50) + boot_volume_type: 볼륨 타입 (기본 "General SSD") + node_image: 노드 이미지 ID (기본 Ubuntu 20.04) + + Returns: + dict: 생성된 클러스터 정보 + """ + url = f"{self.nks_url}/v1/clusters" + + payload = { + "cluster_template_id": "iaas_console", + "create_timeout": 60, + "fixed_network": vpc_id, + "fixed_subnet": subnet_id, + "flavor_id": instance_type, + "keypair": keypair_name, + "labels": { + "availability_zone": availability_zone, + "boot_volume_size": str(boot_volume_size), + "boot_volume_type": boot_volume_type, + "ca_enable": "false", + "cert_manager_api": "True", + "clusterautoscale": "nodegroupfeature", + "kube_tag": kubernetes_version, + "master_lb_floating_ip_enabled": "False", + "node_image": node_image or self.default_node_image, + }, + "name": cluster_name, + "node_count": str(node_count), + } + + logger.info(f"Private 클러스터 생성 요청: {cluster_name}") + return self._post(url, payload) + + def delete_cluster(self, cluster_name: str) -> dict: + """클러스터 삭제""" + url = f"{self.nks_url}/v1/clusters/{cluster_name}" + logger.info(f"클러스터 삭제 요청: {cluster_name}") + return self._delete(url) + + # ==================== Node Group ==================== + + def get_nodegroup_list(self, cluster_name: str) -> dict: + """노드 그룹 목록 조회""" + url = f"{self.nks_url}/v1/clusters/{cluster_name}/nodegroups" + return self._get(url) + + def get_nodegroup_info(self, cluster_name: str, nodegroup_name: str) -> dict: + """노드 그룹 상세 조회""" + url = f"{self.nks_url}/v1/clusters/{cluster_name}/nodegroups/{nodegroup_name}" + return self._get(url) diff --git a/nhn/packages/storage.py b/nhn/packages/storage.py new file mode 100644 index 0000000..89abd17 --- /dev/null +++ b/nhn/packages/storage.py @@ -0,0 +1,279 @@ +""" +NHN Cloud Object Storage API Module + +Object Storage 컨테이너 및 오브젝트 관리 +""" + +import logging +from typing import Optional, List + +import requests + +from .base import BaseAPI, NHNCloudEndpoints, Region, NHNCloudAPIError + +logger = logging.getLogger(__name__) + + +class ApiStorageObject(BaseAPI): + """NHN Cloud Object Storage API 클래스""" + + def __init__(self, region: str, token: str, storage_account: str): + """ + Args: + region: 리전 (kr1: 판교, kr2: 평촌) + token: API 인증 토큰 + storage_account: 스토리지 계정 (AUTH_...) + """ + super().__init__(region, token) + self.storage_account = storage_account + + if self.region == Region.KR1: + self.storage_url = NHNCloudEndpoints.STORAGE_KR1 + else: + self.storage_url = NHNCloudEndpoints.STORAGE_KR2 + + def _get_url(self, container: Optional[str] = None, obj: Optional[str] = None) -> str: + """URL 생성""" + parts = [self.storage_url, self.storage_account] + if container: + parts.append(container) + if obj: + parts.append(obj) + return "/".join(parts) + + def _get_headers(self, extra_headers: Optional[dict] = None) -> dict: + """Object Storage 전용 헤더""" + headers = {"X-Auth-Token": self.token} + if extra_headers: + headers.update(extra_headers) + return headers + + # ==================== Container ==================== + + def get_container_list(self) -> List[str]: + """컨테이너 목록 조회""" + url = self._get_url() + try: + response = self._session.get( + url, + headers=self._get_headers(), + timeout=self.DEFAULT_TIMEOUT, + ) + if response.status_code >= 400: + self._handle_error(response) + return [c for c in response.text.split("\n") if c] + except requests.exceptions.RequestException as e: + logger.error(f"컨테이너 목록 조회 실패: {e}") + raise NHNCloudAPIError(f"컨테이너 목록 조회 실패: {e}") + + def create_container(self, container_name: str) -> dict: + """ + 컨테이너 생성 + + Args: + container_name: 컨테이너 이름 + + Returns: + dict: 생성 결과 + """ + url = self._get_url(container_name) + try: + response = self._session.put( + url, + headers=self._get_headers(), + timeout=self.DEFAULT_TIMEOUT, + ) + logger.info(f"컨테이너 생성: {container_name}") + return {"status": response.status_code, "container": container_name} + except requests.exceptions.RequestException as e: + logger.error(f"컨테이너 생성 실패: {e}") + raise NHNCloudAPIError(f"컨테이너 생성 실패: {e}") + + def delete_container(self, container_name: str) -> dict: + """컨테이너 삭제""" + url = self._get_url(container_name) + try: + response = self._session.delete( + url, + headers=self._get_headers(), + timeout=self.DEFAULT_TIMEOUT, + ) + logger.info(f"컨테이너 삭제: {container_name}") + return {"status": response.status_code, "container": container_name} + except requests.exceptions.RequestException as e: + logger.error(f"컨테이너 삭제 실패: {e}") + raise NHNCloudAPIError(f"컨테이너 삭제 실패: {e}") + + def set_container_public(self, container_name: str, is_public: bool = True) -> dict: + """ + 컨테이너 공개/비공개 설정 + + Args: + container_name: 컨테이너 이름 + is_public: 공개 여부 (True: 공개, False: 비공개) + + Returns: + dict: 설정 결과 + """ + url = self._get_url(container_name) + headers = self._get_headers() + headers["X-Container-Read"] = ".r:*" if is_public else "" + + try: + response = self._session.post( + url, + headers=headers, + timeout=self.DEFAULT_TIMEOUT, + ) + logger.info(f"컨테이너 공개 설정: {container_name} -> {is_public}") + return {"status": response.status_code, "public": is_public} + except requests.exceptions.RequestException as e: + logger.error(f"컨테이너 공개 설정 실패: {e}") + raise NHNCloudAPIError(f"컨테이너 공개 설정 실패: {e}") + + # ==================== Object ==================== + + def get_object_list(self, container_name: str) -> List[str]: + """오브젝트 목록 조회""" + url = self._get_url(container_name) + try: + response = self._session.get( + url, + headers=self._get_headers(), + timeout=self.DEFAULT_TIMEOUT, + ) + if response.status_code >= 400: + self._handle_error(response) + return [o for o in response.text.split("\n") if o] + except requests.exceptions.RequestException as e: + logger.error(f"오브젝트 목록 조회 실패: {e}") + raise NHNCloudAPIError(f"오브젝트 목록 조회 실패: {e}") + + def upload_object( + self, + container_name: str, + object_name: str, + file_path: str, + ) -> dict: + """ + 오브젝트 업로드 + + Args: + container_name: 컨테이너 이름 + object_name: 오브젝트 이름 + file_path: 업로드할 파일 경로 + + Returns: + dict: 업로드 결과 + """ + url = self._get_url(container_name, object_name) + + try: + with open(file_path, "rb") as f: + response = self._session.put( + url, + headers=self._get_headers(), + data=f.read(), + timeout=self.DEFAULT_TIMEOUT, + ) + logger.info(f"오브젝트 업로드: {container_name}/{object_name}") + return {"status": response.status_code, "object": object_name} + except FileNotFoundError: + raise NHNCloudAPIError(f"파일을 찾을 수 없습니다: {file_path}") + except requests.exceptions.RequestException as e: + logger.error(f"오브젝트 업로드 실패: {e}") + raise NHNCloudAPIError(f"오브젝트 업로드 실패: {e}") + + def upload_object_data( + self, + container_name: str, + object_name: str, + data: bytes, + content_type: Optional[str] = None, + ) -> dict: + """ + 데이터를 오브젝트로 업로드 + + Args: + container_name: 컨테이너 이름 + object_name: 오브젝트 이름 + data: 업로드할 데이터 + content_type: Content-Type 헤더 + + Returns: + dict: 업로드 결과 + """ + url = self._get_url(container_name, object_name) + headers = self._get_headers() + if content_type: + headers["Content-Type"] = content_type + + try: + response = self._session.put( + url, + headers=headers, + data=data, + timeout=self.DEFAULT_TIMEOUT, + ) + logger.info(f"오브젝트 업로드: {container_name}/{object_name}") + return {"status": response.status_code, "object": object_name} + except requests.exceptions.RequestException as e: + logger.error(f"오브젝트 업로드 실패: {e}") + raise NHNCloudAPIError(f"오브젝트 업로드 실패: {e}") + + def download_object(self, container_name: str, object_name: str) -> bytes: + """ + 오브젝트 다운로드 + + Args: + container_name: 컨테이너 이름 + object_name: 오브젝트 이름 + + Returns: + bytes: 오브젝트 데이터 + """ + url = self._get_url(container_name, object_name) + + try: + response = self._session.get( + url, + headers=self._get_headers(), + timeout=self.DEFAULT_TIMEOUT, + ) + if response.status_code >= 400: + self._handle_error(response) + return response.content + except requests.exceptions.RequestException as e: + logger.error(f"오브젝트 다운로드 실패: {e}") + raise NHNCloudAPIError(f"오브젝트 다운로드 실패: {e}") + + def delete_object(self, container_name: str, object_name: str) -> dict: + """오브젝트 삭제""" + url = self._get_url(container_name, object_name) + + try: + response = self._session.delete( + url, + headers=self._get_headers(), + timeout=self.DEFAULT_TIMEOUT, + ) + logger.info(f"오브젝트 삭제: {container_name}/{object_name}") + return {"status": response.status_code, "object": object_name} + except requests.exceptions.RequestException as e: + logger.error(f"오브젝트 삭제 실패: {e}") + raise NHNCloudAPIError(f"오브젝트 삭제 실패: {e}") + + def get_object_info(self, container_name: str, object_name: str) -> dict: + """오브젝트 메타데이터 조회""" + url = self._get_url(container_name, object_name) + + try: + response = self._session.head( + url, + headers=self._get_headers(), + timeout=self.DEFAULT_TIMEOUT, + ) + return dict(response.headers) + except requests.exceptions.RequestException as e: + logger.error(f"오브젝트 정보 조회 실패: {e}") + raise NHNCloudAPIError(f"오브젝트 정보 조회 실패: {e}") diff --git a/nhn/packages/token.py b/nhn/packages/token.py new file mode 100644 index 0000000..0eaca24 --- /dev/null +++ b/nhn/packages/token.py @@ -0,0 +1,123 @@ +""" +NHN Cloud Token Management Module + +NHN Cloud API 인증 토큰 관리 +""" + +import logging +from typing import Optional +from dataclasses import dataclass + +import requests + +from .base import NHNCloudEndpoints, NHNCloudAPIError + +logger = logging.getLogger(__name__) + + +@dataclass +class TokenResponse: + """토큰 응답 데이터""" + token: str + tenant_id: str + expires_at: Optional[str] = None + + +class NHNCloudToken: + """NHN Cloud 토큰 관리 클래스""" + + TOKEN_URL = f"{NHNCloudEndpoints.IDENTITY}/v2.0/tokens" + + def __init__(self, tenant_id: str, username: str, password: str): + """ + Args: + tenant_id: NHN Cloud 테넌트 ID + username: NHN Cloud 사용자 이메일 + password: NHN Cloud API 비밀번호 + """ + self.tenant_id = tenant_id + self.username = username + self.password = password + self._token: Optional[str] = None + + def create_token(self) -> TokenResponse: + """ + 새 토큰 생성 + + Returns: + TokenResponse: 생성된 토큰 정보 + + Raises: + NHNCloudAPIError: 토큰 생성 실패 시 + """ + payload = { + "auth": { + "tenantId": self.tenant_id, + "passwordCredentials": { + "username": self.username, + "password": self.password, + }, + } + } + + logger.info(f"[NHN API] 토큰 생성 요청 - URL={self.TOKEN_URL}, tenant_id={self.tenant_id}, username={self.username}") + + try: + response = requests.post( + self.TOKEN_URL, + json=payload, + headers={"Content-Type": "application/json"}, + timeout=30, + ) + + logger.info(f"[NHN API] 토큰 생성 응답 - status_code={response.status_code}") + + data = response.json() + + if "error" in data: + error = data["error"] + logger.error(f"[NHN API] 토큰 생성 실패 - code={error.get('code')}, message={error.get('message')}, details={error}") + raise NHNCloudAPIError( + message=error.get("message", "토큰 생성 실패"), + code=error.get("code"), + details=error, + ) + + access = data.get("access", {}) + token_info = access.get("token", {}) + self._token = token_info.get("id") + + token_preview = self._token[:8] + "..." if self._token else "None" + logger.info(f"[NHN API] 토큰 생성 성공 - tenant_id={self.tenant_id}, token={token_preview}, expires={token_info.get('expires')}") + + return TokenResponse( + token=self._token, + tenant_id=self.tenant_id, + expires_at=token_info.get("expires"), + ) + + except requests.exceptions.Timeout as e: + logger.error(f"[NHN API] 토큰 생성 타임아웃 - URL={self.TOKEN_URL}, error={e}") + raise NHNCloudAPIError(f"토큰 생성 요청 타임아웃: {e}") + except requests.exceptions.ConnectionError as e: + logger.error(f"[NHN API] 토큰 생성 연결 실패 - URL={self.TOKEN_URL}, error={e}") + raise NHNCloudAPIError(f"토큰 생성 서버 연결 실패: {e}") + except requests.exceptions.RequestException as e: + logger.error(f"[NHN API] 토큰 생성 요청 실패 - URL={self.TOKEN_URL}, error={e}") + raise NHNCloudAPIError(f"토큰 생성 요청 실패: {e}") + + @property + def token(self) -> Optional[str]: + """현재 토큰 반환""" + return self._token + + def get_token(self) -> str: + """ + 토큰 반환 (없으면 생성) + + Returns: + str: API 토큰 + """ + if not self._token: + self.create_token() + return self._token diff --git a/nhn/packages/vpc.py b/nhn/packages/vpc.py new file mode 100644 index 0000000..2a53049 --- /dev/null +++ b/nhn/packages/vpc.py @@ -0,0 +1,272 @@ +""" +NHN Cloud VPC API Module + +VPC, Subnet, Routing Table, Floating IP 관리 +""" + +import logging +from typing import Optional + +from .base import BaseAPI, NHNCloudEndpoints, Region + +logger = logging.getLogger(__name__) + + +class ApiVpc(BaseAPI): + """NHN Cloud VPC API 클래스""" + + def __init__(self, region: str, token: str): + """ + Args: + region: 리전 (kr1: 판교, kr2: 평촌) + token: API 인증 토큰 + """ + super().__init__(region, token) + + if self.region == Region.KR1: + self.vpc_url = NHNCloudEndpoints.NETWORK_KR1 + else: + self.vpc_url = NHNCloudEndpoints.NETWORK_KR2 + + # ==================== VPC ==================== + + def get_vpc_list(self) -> dict: + """VPC 목록 조회""" + url = f"{self.vpc_url}/v2.0/vpcs" + logger.info(f"[NHN API] VPC 목록 조회 요청 - URL={url}") + result = self._get(url) + vpc_count = len(result.get("vpcs", [])) + logger.info(f"[NHN API] VPC 목록 조회 완료 - count={vpc_count}") + return result + + def get_vpc_info(self, vpc_id: str) -> dict: + """VPC 상세 조회""" + url = f"{self.vpc_url}/v2.0/vpcs/{vpc_id}" + logger.info(f"[NHN API] VPC 상세 조회 요청 - URL={url}, vpc_id={vpc_id}") + result = self._get(url) + logger.info(f"[NHN API] VPC 상세 조회 완료 - vpc_id={vpc_id}") + return result + + def get_vpc_id_by_name(self, vpc_name: str) -> Optional[str]: + """VPC 이름으로 ID 조회""" + data = self.get_vpc_list() + vpcs = data.get("vpcs", []) + + for vpc in vpcs: + if vpc.get("name", "").startswith(vpc_name): + return vpc.get("id") + return None + + def create_vpc(self, name: str, cidr: str) -> dict: + """ + VPC 생성 + + Args: + name: VPC 이름 + cidr: CIDR 블록 (예: 10.0.0.0/16) + + Returns: + dict: 생성된 VPC 정보 + """ + url = f"{self.vpc_url}/v2.0/vpcs" + payload = {"vpc": {"name": name, "cidrv4": cidr}} + logger.info(f"VPC 생성 요청: {name} ({cidr})") + return self._post(url, payload) + + def delete_vpc(self, vpc_id: str) -> dict: + """VPC 삭제""" + url = f"{self.vpc_url}/v2.0/vpcs/{vpc_id}" + logger.info(f"VPC 삭제 요청: {vpc_id}") + return self._delete(url) + + # ==================== Subnet ==================== + + def get_subnet_list(self) -> dict: + """서브넷 목록 조회""" + url = f"{self.vpc_url}/v2.0/vpcsubnets" + return self._get(url) + + def get_subnet_info(self, subnet_id: str) -> dict: + """서브넷 상세 조회""" + url = f"{self.vpc_url}/v2.0/vpcsubnets/{subnet_id}" + return self._get(url) + + def get_subnet_id_by_name(self, subnet_name: str) -> Optional[str]: + """서브넷 이름으로 ID 조회""" + data = self.get_subnet_list() + subnets = data.get("vpcsubnets", []) + + for subnet in subnets: + name = subnet.get("name", "") + if name.startswith(subnet_name) and "rt" not in name: + return subnet.get("id") + return None + + def create_subnet(self, vpc_id: str, cidr: str, name: str) -> dict: + """ + 서브넷 생성 + + Args: + vpc_id: VPC ID + cidr: CIDR 블록 (예: 10.0.1.0/24) + name: 서브넷 이름 + + Returns: + dict: 생성된 서브넷 정보 + """ + url = f"{self.vpc_url}/v2.0/vpcsubnets" + payload = {"vpcsubnet": {"vpc_id": vpc_id, "cidr": cidr, "name": name}} + logger.info(f"서브넷 생성 요청: {name} ({cidr})") + return self._post(url, payload) + + def delete_subnet(self, subnet_id: str) -> dict: + """서브넷 삭제""" + url = f"{self.vpc_url}/v2.0/vpcsubnets/{subnet_id}" + logger.info(f"서브넷 삭제 요청: {subnet_id}") + return self._delete(url) + + # ==================== Routing Table ==================== + + def get_routing_table_list(self, detail: bool = True) -> dict: + """라우팅 테이블 목록 조회""" + url = f"{self.vpc_url}/v2.0/routingtables" + params = {"detail": "true"} if detail else None + return self._get(url, params=params) + + def get_routing_table_info(self, routingtable_id: str) -> dict: + """라우팅 테이블 상세 조회""" + url = f"{self.vpc_url}/v2.0/routingtables/{routingtable_id}" + return self._get(url) + + def get_default_routing_table_id(self) -> Optional[str]: + """기본 라우팅 테이블 ID 조회""" + data = self.get_routing_table_list(detail=False) + tables = data.get("routingtables", []) + return tables[0].get("id") if tables else None + + def create_routing_table(self, name: str, vpc_id: str, distributed: bool = True) -> dict: + """ + 라우팅 테이블 생성 + + Args: + name: 라우팅 테이블 이름 + vpc_id: VPC ID + distributed: 분산 라우팅 여부 (기본 True) + + Returns: + dict: 생성된 라우팅 테이블 정보 + """ + url = f"{self.vpc_url}/v2.0/routingtables" + payload = { + "routingtable": { + "name": name, + "vpc_id": vpc_id, + "distributed": str(distributed).lower(), + } + } + logger.info(f"라우팅 테이블 생성 요청: {name}") + return self._post(url, payload) + + def delete_routing_table(self, routingtable_id: str) -> dict: + """라우팅 테이블 삭제""" + url = f"{self.vpc_url}/v2.0/routingtables/{routingtable_id}" + logger.info(f"라우팅 테이블 삭제 요청: {routingtable_id}") + return self._delete(url) + + def set_default_routing_table(self, routingtable_id: str) -> dict: + """라우팅 테이블을 기본으로 설정""" + url = f"{self.vpc_url}/v2.0/routingtables/{routingtable_id}/set_as_default" + return self._put(url) + + def attach_gateway_to_routing_table(self, routingtable_id: str, gateway_id: str) -> dict: + """라우팅 테이블에 인터넷 게이트웨이 연결""" + url = f"{self.vpc_url}/v2.0/routingtables/{routingtable_id}/attach_gateway" + payload = {"gateway_id": gateway_id} + logger.info(f"게이트웨이 연결 요청: {routingtable_id} -> {gateway_id}") + return self._put(url, payload) + + def detach_gateway_from_routing_table(self, routingtable_id: str) -> dict: + """라우팅 테이블에서 인터넷 게이트웨이 분리""" + url = f"{self.vpc_url}/v2.0/routingtables/{routingtable_id}/detach_gateway" + return self._put(url) + + def attach_routing_table_to_subnet(self, subnet_id: str, routingtable_id: str) -> dict: + """서브넷에 라우팅 테이블 연결""" + url = f"{self.vpc_url}/v2.0/vpcsubnets/{subnet_id}/attach_routingtable" + payload = {"routingtable_id": routingtable_id} + return self._put(url, payload) + + # ==================== Floating IP ==================== + + def get_external_network_id(self) -> dict: + """외부 네트워크 ID 조회""" + url = f"{self.vpc_url}/v2.0/networks" + params = {"router:external": "True"} + return self._get(url, params=params) + + def get_floating_ip_list(self) -> dict: + """Floating IP 목록 조회""" + url = f"{self.vpc_url}/v2.0/floatingips" + return self._get(url) + + def create_floating_ip(self, external_network_id: str) -> dict: + """ + Floating IP 생성 + + Args: + external_network_id: 외부 네트워크 ID + + Returns: + dict: 생성된 Floating IP 정보 + """ + url = f"{self.vpc_url}/v2.0/floatingips" + payload = {"floatingip": {"floating_network_id": external_network_id}} + logger.info("Floating IP 생성 요청") + return self._post(url, payload) + + def delete_floating_ip(self, floating_ip_id: str) -> dict: + """Floating IP 삭제""" + url = f"{self.vpc_url}/v2.0/floatingips/{floating_ip_id}" + logger.info(f"Floating IP 삭제 요청: {floating_ip_id}") + return self._delete(url) + + def attach_floating_ip(self, floating_ip_id: str, port_id: str) -> dict: + """Floating IP를 포트에 연결""" + url = f"{self.vpc_url}/v2.0/floatingips/{floating_ip_id}" + payload = {"floatingip": {"port_id": port_id}} + return self._put(url, payload) + + def detach_floating_ip(self, floating_ip_id: str) -> dict: + """Floating IP 연결 해제""" + url = f"{self.vpc_url}/v2.0/floatingips/{floating_ip_id}" + payload = {"floatingip": {"port_id": None}} + return self._put(url, payload) + + # ==================== Security Group ==================== + + def get_security_group_list(self) -> dict: + """보안 그룹 목록 조회""" + url = f"{self.vpc_url}/v2.0/security-groups" + return self._get(url) + + def get_security_group_info(self, security_group_id: str) -> dict: + """보안 그룹 상세 조회""" + url = f"{self.vpc_url}/v2.0/security-groups/{security_group_id}" + return self._get(url) + + # ==================== Port (NIC) ==================== + + def get_port_list(self) -> dict: + """포트 목록 조회""" + url = f"{self.vpc_url}/v2.0/ports" + return self._get(url) + + def get_port_id_by_device(self, device_id: str) -> Optional[str]: + """디바이스 ID로 포트 ID 조회""" + data = self.get_port_list() + ports = data.get("ports", []) + + for port in ports: + if port.get("device_id", "").startswith(device_id): + return port.get("id") + return None diff --git a/nhn/serializers.py b/nhn/serializers.py new file mode 100644 index 0000000..b9f4186 --- /dev/null +++ b/nhn/serializers.py @@ -0,0 +1,201 @@ +""" +NHN Cloud API Serializers + +API 요청/응답 직렬화 +""" + +from rest_framework import serializers + + +# ==================== Token ==================== + + +class TokenRequestSerializer(serializers.Serializer): + """토큰 생성 요청""" + + tenant_id = serializers.CharField( + help_text="NHN Cloud 테넌트 ID", + max_length=64, + ) + username = serializers.EmailField( + help_text="NHN Cloud 사용자 이메일", + ) + password = serializers.CharField( + help_text="NHN Cloud API 비밀번호", + write_only=True, + ) + + +class TokenResponseSerializer(serializers.Serializer): + """토큰 생성 응답""" + + token = serializers.CharField(help_text="API 토큰") + tenant_id = serializers.CharField(help_text="테넌트 ID") + expires_at = serializers.CharField(help_text="만료 시간", allow_null=True) + + +# ==================== Common ==================== + + +class ErrorResponseSerializer(serializers.Serializer): + """에러 응답""" + + error = serializers.CharField(help_text="에러 메시지") + code = serializers.IntegerField(help_text="에러 코드", required=False) + + +# ==================== Compute ==================== + + +class ComputeInstanceSerializer(serializers.Serializer): + """인스턴스 생성 요청""" + + name = serializers.CharField( + help_text="인스턴스 이름", + max_length=255, + ) + image_id = serializers.CharField( + help_text="이미지 ID", + ) + flavor_id = serializers.CharField( + help_text="Flavor ID", + ) + subnet_id = serializers.CharField( + help_text="서브넷 ID", + ) + keypair_name = serializers.CharField( + help_text="Keypair 이름", + ) + volume_size = serializers.IntegerField( + help_text="볼륨 크기 (GB)", + default=50, + min_value=20, + max_value=2048, + ) + volume_type = serializers.CharField( + help_text="볼륨 타입 (General SSD, General HDD)", + default="General SSD", + required=False, + ) + security_groups = serializers.ListField( + child=serializers.CharField(), + help_text="보안 그룹 목록", + required=False, + ) + availability_zone = serializers.CharField( + help_text="가용 영역", + required=False, + ) + + +# ==================== VPC ==================== + + +class VpcSerializer(serializers.Serializer): + """VPC 생성 요청""" + + name = serializers.CharField( + help_text="VPC 이름", + max_length=255, + ) + cidr = serializers.CharField( + help_text="CIDR 블록 (예: 10.0.0.0/16)", + ) + + +class SubnetSerializer(serializers.Serializer): + """서브넷 생성 요청""" + + vpc_id = serializers.CharField( + help_text="VPC ID", + ) + name = serializers.CharField( + help_text="서브넷 이름", + max_length=255, + ) + cidr = serializers.CharField( + help_text="CIDR 블록 (예: 10.0.1.0/24)", + ) + + +# ==================== NKS ==================== + + +class NksClusterSerializer(serializers.Serializer): + """NKS 클러스터 생성 요청""" + + cluster_name = serializers.CharField( + help_text="클러스터 이름", + max_length=255, + ) + vpc_id = serializers.CharField( + help_text="VPC ID", + ) + subnet_id = serializers.CharField( + help_text="서브넷 ID", + ) + instance_type = serializers.CharField( + help_text="인스턴스 타입 (Flavor ID)", + ) + keypair_name = serializers.CharField( + help_text="Keypair 이름", + ) + kubernetes_version = serializers.CharField( + help_text="Kubernetes 버전 (예: v1.28.3)", + ) + availability_zone = serializers.CharField( + help_text="가용 영역 (예: kr-pub-a)", + ) + is_public = serializers.BooleanField( + help_text="Public 클러스터 여부 (외부 접근 가능)", + default=True, + ) + external_network_id = serializers.CharField( + help_text="외부 네트워크 ID (Public 클러스터 필수)", + required=False, + ) + external_subnet_id = serializers.CharField( + help_text="외부 서브넷 ID (Public 클러스터 필수)", + required=False, + ) + node_count = serializers.IntegerField( + help_text="노드 수", + default=1, + min_value=1, + max_value=100, + ) + boot_volume_size = serializers.IntegerField( + help_text="부팅 볼륨 크기 (GB)", + default=50, + min_value=50, + max_value=1000, + ) + boot_volume_type = serializers.CharField( + help_text="볼륨 타입", + default="General SSD", + ) + + def validate(self, data): + """Public 클러스터인 경우 external 관련 필드 필수""" + if data.get("is_public", True): + if not data.get("external_network_id"): + raise serializers.ValidationError( + {"external_network_id": "Public 클러스터에는 외부 네트워크 ID가 필요합니다."} + ) + if not data.get("external_subnet_id"): + raise serializers.ValidationError( + {"external_subnet_id": "Public 클러스터에는 외부 서브넷 ID가 필요합니다."} + ) + return data + + +# ==================== Storage ==================== + + +class StorageContainerSerializer(serializers.Serializer): + """스토리지 컨테이너 생성 요청""" + + name = serializers.CharField( + help_text="컨테이너 이름", + max_length=255, + ) diff --git a/nhn/tasks.py b/nhn/tasks.py new file mode 100644 index 0000000..948df4f --- /dev/null +++ b/nhn/tasks.py @@ -0,0 +1,399 @@ +""" +NHN Cloud Async Task Runner + +Threading 기반 비동기 작업 실행 +""" + +import logging +import threading +from functools import wraps + +import django +from django.db import close_old_connections + +logger = logging.getLogger(__name__) + + +def run_async(func): + """ + 함수를 비동기로 실행하는 데코레이터 + Django DB 연결 관리 포함 + """ + + @wraps(func) + def wrapper(*args, **kwargs): + def run(): + try: + # Django 설정 확인 + django.setup() + # 기존 DB 연결 정리 + close_old_connections() + # 실제 함수 실행 + func(*args, **kwargs) + except Exception as e: + logger.exception(f"Async task error: {e}") + finally: + # DB 연결 정리 + close_old_connections() + + thread = threading.Thread(target=run, daemon=True) + thread.start() + return thread + + return wrapper + + +def execute_async_task(task_id, task_func, *args, **kwargs): + """ + AsyncTask 모델과 연동하여 비동기 작업 실행 + + Args: + task_id: AsyncTask 모델의 ID + task_func: 실행할 함수 (ApiCompute.create_instance 등) + *args, **kwargs: 함수에 전달할 인자 + """ + from .models import AsyncTask + + def run(): + try: + django.setup() + close_old_connections() + + # 작업 시작 + task = AsyncTask.objects.get(id=task_id) + task.mark_running() + logger.info(f"[AsyncTask] 작업 시작: {task_id} ({task.task_type})") + + # 실제 작업 실행 + result = task_func(*args, **kwargs) + + # 결과에서 리소스 ID 추출 + resource_id = None + if isinstance(result, dict): + # 인스턴스 생성 결과 + if "server" in result: + resource_id = result["server"].get("id") + # NKS 클러스터 생성 결과 + elif "uuid" in result: + resource_id = result.get("uuid") + + # 성공 처리 + task.mark_success(result_data=result, resource_id=resource_id) + logger.info(f"[AsyncTask] 작업 완료: {task_id}, resource_id={resource_id}") + + except Exception as e: + logger.exception(f"[AsyncTask] 작업 실패: {task_id}") + try: + task = AsyncTask.objects.get(id=task_id) + task.mark_failed(str(e)) + except Exception: + pass + finally: + close_old_connections() + + thread = threading.Thread(target=run, daemon=True) + thread.start() + logger.info(f"[AsyncTask] 백그라운드 스레드 시작: {task_id}") + return thread + + +def create_instance_async(region, tenant_id, token, instance_data): + """ + 인스턴스 비동기 생성 + + Args: + region: 리전 + tenant_id: 테넌트 ID + token: API 토큰 + instance_data: 인스턴스 생성 데이터 (dict) + + Returns: + AsyncTask: 생성된 작업 객체 + """ + from .models import AsyncTask + from .packages.compute import ApiCompute + + # 작업 레코드 생성 + task = AsyncTask.objects.create( + task_type=AsyncTask.TaskType.INSTANCE_CREATE, + request_data=instance_data, + resource_name=instance_data.get("name", ""), + ) + + # API 객체 생성 + api = ApiCompute(region, tenant_id, token) + + # 비동기 실행 + execute_async_task( + task_id=task.id, + task_func=api.create_instance, + name=instance_data["name"], + image_id=instance_data["image_id"], + flavor_id=instance_data["flavor_id"], + subnet_id=instance_data["subnet_id"], + keypair_name=instance_data.get("keypair_name", ""), + volume_size=instance_data.get("volume_size", 50), + volume_type=instance_data.get("volume_type", "General SSD"), + security_groups=instance_data.get("security_groups"), + availability_zone=instance_data.get("availability_zone"), + ) + + return task + + +def create_nks_cluster_async(region, tenant_id, token, cluster_data): + """ + NKS 클러스터 비동기 생성 + + Args: + region: 리전 + tenant_id: 테넌트 ID + token: API 토큰 + cluster_data: 클러스터 생성 데이터 (dict) + + Returns: + AsyncTask: 생성된 작업 객체 + """ + from .models import AsyncTask + from .packages.nks import ApiNks + + # 작업 레코드 생성 + task = AsyncTask.objects.create( + task_type=AsyncTask.TaskType.NKS_CREATE, + request_data=cluster_data, + resource_name=cluster_data.get("cluster_name", ""), + ) + + # API 객체 생성 + api = ApiNks(region, tenant_id, token) + + # 비동기 실행 + execute_async_task( + task_id=task.id, + task_func=api.create_cluster, + **cluster_data, + ) + + return task + + +# ==================== Instance 비동기 작업 ==================== + + +def delete_instance_async(region, tenant_id, token, server_id, server_name=""): + """인스턴스 비동기 삭제""" + from .models import AsyncTask + from .packages.compute import ApiCompute + + task = AsyncTask.objects.create( + task_type=AsyncTask.TaskType.INSTANCE_DELETE, + request_data={"server_id": server_id}, + resource_name=server_name, + resource_id=server_id, + ) + + api = ApiCompute(region, tenant_id, token) + execute_async_task( + task_id=task.id, + task_func=api.delete_instance, + server_id=server_id, + ) + + return task + + +def instance_action_async(region, tenant_id, token, server_id, action, server_name="", hard=False): + """인스턴스 액션 비동기 실행 (start/stop/reboot)""" + from .models import AsyncTask + from .packages.compute import ApiCompute + + task = AsyncTask.objects.create( + task_type=AsyncTask.TaskType.INSTANCE_ACTION, + request_data={"server_id": server_id, "action": action, "hard": hard}, + resource_name=server_name, + resource_id=server_id, + ) + + api = ApiCompute(region, tenant_id, token) + + if action == "start": + task_func = api.start_instance + elif action == "stop": + task_func = api.stop_instance + elif action == "reboot": + def task_func(server_id): + return api.reboot_instance(server_id, hard=hard) + else: + raise ValueError(f"Invalid action: {action}") + + execute_async_task( + task_id=task.id, + task_func=task_func, + server_id=server_id, + ) + + return task + + +# ==================== VPC 비동기 작업 ==================== + + +def create_vpc_async(region, token, name, cidr): + """VPC 비동기 생성""" + from .models import AsyncTask + from .packages.vpc import ApiVpc + + task = AsyncTask.objects.create( + task_type=AsyncTask.TaskType.VPC_CREATE, + request_data={"name": name, "cidr": cidr}, + resource_name=name, + ) + + api = ApiVpc(region, token) + execute_async_task( + task_id=task.id, + task_func=api.create_vpc, + name=name, + cidr=cidr, + ) + + return task + + +def delete_vpc_async(region, token, vpc_id, vpc_name=""): + """VPC 비동기 삭제""" + from .models import AsyncTask + from .packages.vpc import ApiVpc + + task = AsyncTask.objects.create( + task_type=AsyncTask.TaskType.VPC_DELETE, + request_data={"vpc_id": vpc_id}, + resource_name=vpc_name, + resource_id=vpc_id, + ) + + api = ApiVpc(region, token) + execute_async_task( + task_id=task.id, + task_func=api.delete_vpc, + vpc_id=vpc_id, + ) + + return task + + +# ==================== Subnet 비동기 작업 ==================== + + +def create_subnet_async(region, token, vpc_id, cidr, name): + """서브넷 비동기 생성""" + from .models import AsyncTask + from .packages.vpc import ApiVpc + + task = AsyncTask.objects.create( + task_type=AsyncTask.TaskType.SUBNET_CREATE, + request_data={"vpc_id": vpc_id, "cidr": cidr, "name": name}, + resource_name=name, + ) + + api = ApiVpc(region, token) + execute_async_task( + task_id=task.id, + task_func=api.create_subnet, + vpc_id=vpc_id, + cidr=cidr, + name=name, + ) + + return task + + +def delete_subnet_async(region, token, subnet_id, subnet_name=""): + """서브넷 비동기 삭제""" + from .models import AsyncTask + from .packages.vpc import ApiVpc + + task = AsyncTask.objects.create( + task_type=AsyncTask.TaskType.SUBNET_DELETE, + request_data={"subnet_id": subnet_id}, + resource_name=subnet_name, + resource_id=subnet_id, + ) + + api = ApiVpc(region, token) + execute_async_task( + task_id=task.id, + task_func=api.delete_subnet, + subnet_id=subnet_id, + ) + + return task + + +# ==================== NKS 비동기 작업 ==================== + + +def delete_nks_cluster_async(region, token, cluster_name): + """NKS 클러스터 비동기 삭제""" + from .models import AsyncTask + from .packages.nks import ApiNks + + task = AsyncTask.objects.create( + task_type=AsyncTask.TaskType.NKS_DELETE, + request_data={"cluster_name": cluster_name}, + resource_name=cluster_name, + ) + + api = ApiNks(region, token) + execute_async_task( + task_id=task.id, + task_func=api.delete_cluster, + cluster_name=cluster_name, + ) + + return task + + +# ==================== Storage 비동기 작업 ==================== + + +def create_storage_container_async(region, token, storage_account, container_name): + """스토리지 컨테이너 비동기 생성""" + from .models import AsyncTask + from .packages.storage import ApiStorageObject + + task = AsyncTask.objects.create( + task_type=AsyncTask.TaskType.STORAGE_CREATE, + request_data={"container_name": container_name}, + resource_name=container_name, + ) + + api = ApiStorageObject(region, token, storage_account) + execute_async_task( + task_id=task.id, + task_func=api.create_container, + container_name=container_name, + ) + + return task + + +def delete_storage_container_async(region, token, storage_account, container_name): + """스토리지 컨테이너 비동기 삭제""" + from .models import AsyncTask + from .packages.storage import ApiStorageObject + + task = AsyncTask.objects.create( + task_type=AsyncTask.TaskType.STORAGE_DELETE, + request_data={"container_name": container_name}, + resource_name=container_name, + ) + + api = ApiStorageObject(region, token, storage_account) + execute_async_task( + task_id=task.id, + task_func=api.delete_container, + container_name=container_name, + ) + + return task diff --git a/nhn/tests.py b/nhn/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/nhn/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/nhn/urls.py b/nhn/urls.py new file mode 100644 index 0000000..9036981 --- /dev/null +++ b/nhn/urls.py @@ -0,0 +1,52 @@ +""" +NHN Cloud API URL Configuration +""" + +from django.urls import path + +from . import views + +urlpatterns = [ + # ==================== Token ==================== + path("token/", views.TokenCreateView.as_view(), name="token-create"), + + # ==================== Compute ==================== + path("compute/flavors/", views.ComputeFlavorListView.as_view(), name="compute-flavor-list"), + path("compute/keypairs/", views.ComputeKeypairListView.as_view(), name="compute-keypair-list"), + path("compute/images/", views.ComputeImageListView.as_view(), name="compute-image-list"), + path("compute/instances/", views.ComputeInstanceListView.as_view(), name="compute-instance-list"), + path("compute/instances/create/", views.ComputeInstanceCreateView.as_view(), name="compute-instance-create"), + path("compute/instances//", views.ComputeInstanceDetailView.as_view(), name="compute-instance-detail"), + path("compute/instances//action/", views.ComputeInstanceActionView.as_view(), name="compute-instance-action"), + + # ==================== VPC ==================== + path("vpc/", views.VpcListView.as_view(), name="vpc-list"), + path("vpc/create/", views.VpcCreateView.as_view(), name="vpc-create"), + path("vpc//", views.VpcDetailView.as_view(), name="vpc-detail"), + + # ==================== Subnet ==================== + path("subnet/", views.SubnetListView.as_view(), name="subnet-list"), + path("subnet/create/", views.SubnetCreateView.as_view(), name="subnet-create"), + path("subnet//", views.SubnetDetailView.as_view(), name="subnet-detail"), + + # ==================== Floating IP ==================== + path("floatingip/", views.FloatingIpListView.as_view(), name="floatingip-list"), + + # ==================== Security Group ==================== + path("securitygroup/", views.SecurityGroupListView.as_view(), name="securitygroup-list"), + + # ==================== Async Task ==================== + path("tasks/", views.AsyncTaskListView.as_view(), name="task-list"), + path("tasks//", views.AsyncTaskDetailView.as_view(), name="task-detail"), + + # ==================== NKS ==================== + path("nks/clusters/", views.NksClusterListView.as_view(), name="nks-cluster-list"), + path("nks/clusters/create/", views.NksClusterCreateView.as_view(), name="nks-cluster-create"), + path("nks/clusters//", views.NksClusterDetailView.as_view(), name="nks-cluster-detail"), + path("nks/clusters//config/", views.NksClusterConfigView.as_view(), name="nks-cluster-config"), + + # ==================== Storage ==================== + path("storage/containers/", views.StorageContainerListView.as_view(), name="storage-container-list"), + path("storage/containers/create/", views.StorageContainerCreateView.as_view(), name="storage-container-create"), + path("storage/containers//", views.StorageContainerDetailView.as_view(), name="storage-container-detail"), +] diff --git a/nhn/utils.py b/nhn/utils.py new file mode 100644 index 0000000..375de7d --- /dev/null +++ b/nhn/utils.py @@ -0,0 +1,31 @@ +import requests +from django.conf import settings +import logging + +logger = logging.getLogger(__name__) + + +def verify_token_with_auth_server(token: str): + """ + Verify token with external auth server. + """ + url = settings.AUTH_VERIFY_URL + if not url: + logger.warning("AUTH_VERIFY_URL is not configured.") + return None + + try: + response = requests.post( + url, + json={"token": token}, + headers={"Content-Type": "application/json"}, + timeout=5, + ) + if response.status_code == 200: + return response.json() + else: + logger.error(f"Auth server returned status {response.status_code}") + return None + except requests.RequestException as e: + logger.error(f"Failed to verify token: {e}") + return None diff --git a/nhn/views.py b/nhn/views.py new file mode 100644 index 0000000..c13fdf9 --- /dev/null +++ b/nhn/views.py @@ -0,0 +1,849 @@ +""" +NHN Cloud API Views + +REST API 엔드포인트 정의 +""" + +import logging + +from rest_framework import status +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.permissions import AllowAny, IsAuthenticated +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi + +from .serializers import ( + TokenRequestSerializer, + TokenResponseSerializer, + ComputeInstanceSerializer, + VpcSerializer, + SubnetSerializer, + NksClusterSerializer, + StorageContainerSerializer, + ErrorResponseSerializer, +) +from .packages import NHNCloudToken, ApiCompute, ApiVpc, ApiNks, ApiStorageObject +from .packages.base import NHNCloudAPIError + +logger = logging.getLogger(__name__) + + +# ==================== Common Headers ==================== + +region_header = openapi.Parameter( + "X-NHN-Region", + openapi.IN_HEADER, + description="NHN Cloud 리전 (kr1: 판교, kr2: 평촌)", + type=openapi.TYPE_STRING, + default="kr2", +) + +token_header = openapi.Parameter( + "X-NHN-Token", + openapi.IN_HEADER, + description="NHN Cloud API 토큰", + type=openapi.TYPE_STRING, + required=True, +) + +tenant_header = openapi.Parameter( + "X-NHN-Tenant-ID", + openapi.IN_HEADER, + description="NHN Cloud 테넌트 ID", + type=openapi.TYPE_STRING, + required=True, +) + + +def get_nhn_headers(request): + """요청 헤더에서 NHN Cloud 정보 추출""" + headers = { + "region": request.headers.get("X-NHN-Region", "kr2"), + "token": request.headers.get("X-NHN-Token"), + "tenant_id": request.headers.get("X-NHN-Tenant-ID"), + "storage_account": request.headers.get("X-NHN-Storage-Account"), + } + # 토큰은 앞 8자리만 로깅 (보안) + token_preview = headers["token"][:8] + "..." if headers["token"] else "None" + logger.info(f"[Request Headers] region={headers['region']}, token={token_preview}, tenant_id={headers['tenant_id']}") + return headers + + +# ==================== Token API ==================== + + +class TokenCreateView(APIView): + """토큰 생성 API""" + + authentication_classes = [] # 인증 완전히 건너뜀 + permission_classes = [AllowAny] + + @swagger_auto_schema( + operation_summary="NHN Cloud API 토큰 생성", + operation_description="NHN Cloud API 인증 토큰을 생성합니다.", + request_body=TokenRequestSerializer, + responses={ + 200: TokenResponseSerializer, + 400: ErrorResponseSerializer, + 401: ErrorResponseSerializer, + }, + ) + def post(self, request): + logger.info(f"[Token] 토큰 생성 요청 수신 - client_ip={request.META.get('REMOTE_ADDR')}") + + serializer = TokenRequestSerializer(data=request.data) + if not serializer.is_valid(): + logger.warning(f"[Token] 요청 데이터 유효성 검사 실패: {serializer.errors}") + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + tenant_id = serializer.validated_data["tenant_id"] + username = serializer.validated_data["username"] + logger.info(f"[Token] 토큰 발급 시도 - tenant_id={tenant_id}, username={username}") + + try: + token_manager = NHNCloudToken( + tenant_id=tenant_id, + username=username, + password=serializer.validated_data["password"], + ) + result = token_manager.create_token() + + token_preview = result.token[:8] + "..." if result.token else "None" + logger.info(f"[Token] 토큰 발급 성공 - tenant_id={tenant_id}, token={token_preview}, expires_at={result.expires_at}") + + return Response( + { + "token": result.token, + "tenant_id": result.tenant_id, + "expires_at": result.expires_at, + }, + status=status.HTTP_200_OK, + ) + except NHNCloudAPIError as e: + logger.error(f"[Token] 토큰 발급 실패 - tenant_id={tenant_id}, username={username}, error={e.message}, code={e.code}") + return Response( + {"error": e.message, "code": e.code}, + status=status.HTTP_401_UNAUTHORIZED, + ) + + +# ==================== Compute API ==================== + + +class ComputeFlavorListView(APIView): + """Flavor 목록 조회 API""" + + @swagger_auto_schema( + operation_summary="Flavor 목록 조회", + manual_parameters=[region_header, token_header, tenant_header], + responses={200: "Flavor 목록"}, + ) + def get(self, request): + headers = get_nhn_headers(request) + try: + api = ApiCompute(headers["region"], headers["tenant_id"], headers["token"]) + return Response(api.get_flavor_list()) + except NHNCloudAPIError as e: + return Response({"error": e.message}, status=status.HTTP_400_BAD_REQUEST) + + +class ComputeKeypairListView(APIView): + """Keypair 목록 조회 API""" + + @swagger_auto_schema( + operation_summary="Keypair 목록 조회", + manual_parameters=[region_header, token_header, tenant_header], + responses={200: "Keypair 목록"}, + ) + def get(self, request): + headers = get_nhn_headers(request) + try: + api = ApiCompute(headers["region"], headers["tenant_id"], headers["token"]) + return Response(api.get_keypair_list()) + except NHNCloudAPIError as e: + return Response({"error": e.message}, status=status.HTTP_400_BAD_REQUEST) + + +class ComputeImageListView(APIView): + """이미지 목록 조회 API""" + + @swagger_auto_schema( + operation_summary="이미지 목록 조회", + manual_parameters=[region_header, token_header, tenant_header], + responses={200: "이미지 목록"}, + ) + def get(self, request): + headers = get_nhn_headers(request) + try: + api = ApiCompute(headers["region"], headers["tenant_id"], headers["token"]) + return Response(api.get_image_list()) + except NHNCloudAPIError as e: + return Response({"error": e.message}, status=status.HTTP_400_BAD_REQUEST) + + +class ComputeInstanceListView(APIView): + """인스턴스 목록 조회 API""" + + @swagger_auto_schema( + operation_summary="인스턴스 목록 조회", + manual_parameters=[region_header, token_header, tenant_header], + responses={200: "인스턴스 목록"}, + ) + def get(self, request): + headers = get_nhn_headers(request) + try: + api = ApiCompute(headers["region"], headers["tenant_id"], headers["token"]) + # detail=true 파라미터로 상세 조회 여부 결정 + if request.query_params.get("detail", "true").lower() == "true": + return Response(api.get_instance_list_detail()) + return Response(api.get_instance_list()) + except NHNCloudAPIError as e: + return Response({"error": e.message}, status=status.HTTP_400_BAD_REQUEST) + + +class ComputeInstanceDetailView(APIView): + """인스턴스 상세/삭제 API""" + + @swagger_auto_schema( + operation_summary="인스턴스 상세 조회", + manual_parameters=[region_header, token_header, tenant_header], + responses={200: "인스턴스 상세 정보"}, + ) + def get(self, request, server_id): + headers = get_nhn_headers(request) + try: + api = ApiCompute(headers["region"], headers["tenant_id"], headers["token"]) + return Response(api.get_instance_info(server_id)) + except NHNCloudAPIError as e: + return Response({"error": e.message}, status=status.HTTP_400_BAD_REQUEST) + + @swagger_auto_schema( + operation_summary="인스턴스 삭제 (비동기)", + manual_parameters=[region_header, token_header, tenant_header], + responses={202: "작업 ID 반환"}, + ) + def delete(self, request, server_id): + headers = get_nhn_headers(request) + try: + from .tasks import delete_instance_async + + task = delete_instance_async( + region=headers["region"], + tenant_id=headers["tenant_id"], + token=headers["token"], + server_id=server_id, + server_name=request.query_params.get("name", ""), + ) + return Response( + {"task_id": str(task.id), "status": task.status, "message": "인스턴스 삭제 작업이 시작되었습니다."}, + status=status.HTTP_202_ACCEPTED, + ) + except Exception as e: + logger.exception("인스턴스 삭제 작업 시작 실패") + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + +class ComputeInstanceCreateView(APIView): + """인스턴스 생성 API (비동기)""" + + @swagger_auto_schema( + operation_summary="인스턴스 생성 (비동기)", + manual_parameters=[region_header, token_header, tenant_header], + request_body=ComputeInstanceSerializer, + responses={202: "작업 ID 반환"}, + ) + def post(self, request): + headers = get_nhn_headers(request) + serializer = ComputeInstanceSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + try: + from .tasks import create_instance_async + + # 비동기 작업 시작 + task = create_instance_async( + region=headers["region"], + tenant_id=headers["tenant_id"], + token=headers["token"], + instance_data=serializer.validated_data, + ) + + return Response( + { + "task_id": str(task.id), + "status": task.status, + "message": "인스턴스 생성 작업이 시작되었습니다.", + }, + status=status.HTTP_202_ACCEPTED, + ) + except Exception as e: + logger.exception("인스턴스 생성 작업 시작 실패") + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + +class ComputeInstanceActionView(APIView): + """인스턴스 액션 API (시작/정지/재부팅) - 비동기""" + + @swagger_auto_schema( + operation_summary="인스턴스 액션 (start/stop/reboot) - 비동기", + manual_parameters=[region_header, token_header, tenant_header], + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + "action": openapi.Schema( + type=openapi.TYPE_STRING, + enum=["start", "stop", "reboot"], + description="액션 타입", + ), + }, + required=["action"], + ), + responses={202: "작업 ID 반환"}, + ) + def post(self, request, server_id): + headers = get_nhn_headers(request) + action = request.data.get("action") + + if action not in ["start", "stop", "reboot"]: + return Response( + {"error": "Invalid action. Use: start, stop, reboot"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + from .tasks import instance_action_async + + task = instance_action_async( + region=headers["region"], + tenant_id=headers["tenant_id"], + token=headers["token"], + server_id=server_id, + action=action, + server_name=request.data.get("name", ""), + hard=request.data.get("hard", False), + ) + return Response( + {"task_id": str(task.id), "status": task.status, "action": action, "message": f"인스턴스 {action} 작업이 시작되었습니다."}, + status=status.HTTP_202_ACCEPTED, + ) + except Exception as e: + logger.exception(f"인스턴스 {action} 작업 시작 실패") + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + +# ==================== VPC API ==================== + + +class VpcListView(APIView): + """VPC 목록 조회 API""" + + @swagger_auto_schema( + operation_summary="VPC 목록 조회", + manual_parameters=[region_header, token_header], + responses={200: "VPC 목록"}, + ) + def get(self, request): + logger.info(f"[VPC] VPC 목록 조회 요청") + headers = get_nhn_headers(request) + try: + api = ApiVpc(headers["region"], headers["token"]) + result = api.get_vpc_list() + vpc_count = len(result.get("vpcs", [])) + logger.info(f"[VPC] VPC 목록 조회 성공 - count={vpc_count}") + return Response(result) + except NHNCloudAPIError as e: + logger.error(f"[VPC] VPC 목록 조회 실패 - error={e.message}") + return Response({"error": e.message}, status=status.HTTP_400_BAD_REQUEST) + + +class VpcCreateView(APIView): + """VPC 생성 API (비동기)""" + + @swagger_auto_schema( + operation_summary="VPC 생성 (비동기)", + manual_parameters=[region_header, token_header], + request_body=VpcSerializer, + responses={202: "작업 ID 반환"}, + ) + def post(self, request): + headers = get_nhn_headers(request) + serializer = VpcSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + try: + from .tasks import create_vpc_async + + task = create_vpc_async( + region=headers["region"], + token=headers["token"], + name=serializer.validated_data["name"], + cidr=serializer.validated_data["cidr"], + ) + return Response( + {"task_id": str(task.id), "status": task.status, "message": "VPC 생성 작업이 시작되었습니다."}, + status=status.HTTP_202_ACCEPTED, + ) + except Exception as e: + logger.exception("VPC 생성 작업 시작 실패") + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + +class VpcDetailView(APIView): + """VPC 상세/삭제 API""" + + @swagger_auto_schema( + operation_summary="VPC 상세 조회", + manual_parameters=[region_header, token_header], + responses={200: "VPC 상세 정보"}, + ) + def get(self, request, vpc_id): + logger.info(f"[VPC] VPC 상세 조회 요청 - vpc_id={vpc_id}") + headers = get_nhn_headers(request) + try: + api = ApiVpc(headers["region"], headers["token"]) + result = api.get_vpc_info(vpc_id) + vpc_name = result.get("vpc", {}).get("name", "unknown") + logger.info(f"[VPC] VPC 상세 조회 성공 - vpc_id={vpc_id}, name={vpc_name}") + return Response(result) + except NHNCloudAPIError as e: + logger.error(f"[VPC] VPC 상세 조회 실패 - vpc_id={vpc_id}, error={e.message}") + return Response({"error": e.message}, status=status.HTTP_400_BAD_REQUEST) + + @swagger_auto_schema( + operation_summary="VPC 삭제 (비동기)", + manual_parameters=[region_header, token_header], + responses={202: "작업 ID 반환"}, + ) + def delete(self, request, vpc_id): + logger.info(f"[VPC] VPC 삭제 요청 - vpc_id={vpc_id}") + headers = get_nhn_headers(request) + try: + from .tasks import delete_vpc_async + + task = delete_vpc_async( + region=headers["region"], + token=headers["token"], + vpc_id=vpc_id, + vpc_name=request.query_params.get("name", ""), + ) + logger.info(f"[VPC] VPC 삭제 작업 시작 - vpc_id={vpc_id}, task_id={task.id}") + return Response( + {"task_id": str(task.id), "status": task.status, "message": "VPC 삭제 작업이 시작되었습니다."}, + status=status.HTTP_202_ACCEPTED, + ) + except Exception as e: + logger.exception(f"[VPC] VPC 삭제 작업 시작 실패 - vpc_id={vpc_id}") + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + +class SubnetListView(APIView): + """서브넷 목록 조회 API""" + + @swagger_auto_schema( + operation_summary="서브넷 목록 조회", + manual_parameters=[region_header, token_header], + responses={200: "서브넷 목록"}, + ) + def get(self, request): + headers = get_nhn_headers(request) + try: + api = ApiVpc(headers["region"], headers["token"]) + return Response(api.get_subnet_list()) + except NHNCloudAPIError as e: + return Response({"error": e.message}, status=status.HTTP_400_BAD_REQUEST) + + +class SubnetCreateView(APIView): + """서브넷 생성 API (비동기)""" + + @swagger_auto_schema( + operation_summary="서브넷 생성 (비동기)", + manual_parameters=[region_header, token_header], + request_body=SubnetSerializer, + responses={202: "작업 ID 반환"}, + ) + def post(self, request): + headers = get_nhn_headers(request) + serializer = SubnetSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + try: + from .tasks import create_subnet_async + + task = create_subnet_async( + region=headers["region"], + token=headers["token"], + vpc_id=serializer.validated_data["vpc_id"], + cidr=serializer.validated_data["cidr"], + name=serializer.validated_data["name"], + ) + return Response( + {"task_id": str(task.id), "status": task.status, "message": "서브넷 생성 작업이 시작되었습니다."}, + status=status.HTTP_202_ACCEPTED, + ) + except Exception as e: + logger.exception("서브넷 생성 작업 시작 실패") + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + +class SubnetDetailView(APIView): + """서브넷 상세/삭제 API""" + + @swagger_auto_schema( + operation_summary="서브넷 상세 조회", + manual_parameters=[region_header, token_header], + responses={200: "서브넷 상세 정보"}, + ) + def get(self, request, subnet_id): + headers = get_nhn_headers(request) + try: + api = ApiVpc(headers["region"], headers["token"]) + return Response(api.get_subnet_info(subnet_id)) + except NHNCloudAPIError as e: + return Response({"error": e.message}, status=status.HTTP_400_BAD_REQUEST) + + @swagger_auto_schema( + operation_summary="서브넷 삭제 (비동기)", + manual_parameters=[region_header, token_header], + responses={202: "작업 ID 반환"}, + ) + def delete(self, request, subnet_id): + headers = get_nhn_headers(request) + try: + from .tasks import delete_subnet_async + + task = delete_subnet_async( + region=headers["region"], + token=headers["token"], + subnet_id=subnet_id, + subnet_name=request.query_params.get("name", ""), + ) + return Response( + {"task_id": str(task.id), "status": task.status, "message": "서브넷 삭제 작업이 시작되었습니다."}, + status=status.HTTP_202_ACCEPTED, + ) + except Exception as e: + logger.exception("서브넷 삭제 작업 시작 실패") + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + +class FloatingIpListView(APIView): + """Floating IP 목록 조회 API""" + + @swagger_auto_schema( + operation_summary="Floating IP 목록 조회", + manual_parameters=[region_header, token_header], + responses={200: "Floating IP 목록"}, + ) + def get(self, request): + headers = get_nhn_headers(request) + try: + api = ApiVpc(headers["region"], headers["token"]) + return Response(api.get_floating_ip_list()) + except NHNCloudAPIError as e: + return Response({"error": e.message}, status=status.HTTP_400_BAD_REQUEST) + + +# ==================== Security Group API ==================== + + +class SecurityGroupListView(APIView): + """보안 그룹 목록 조회 API""" + + @swagger_auto_schema( + operation_summary="보안 그룹 목록 조회", + manual_parameters=[region_header, token_header], + responses={200: "보안 그룹 목록"}, + ) + def get(self, request): + headers = get_nhn_headers(request) + try: + api = ApiVpc(headers["region"], headers["token"]) + return Response(api.get_security_group_list()) + except NHNCloudAPIError as e: + return Response({"error": e.message}, status=status.HTTP_400_BAD_REQUEST) + + +# ==================== Async Task API ==================== + + +class AsyncTaskListView(APIView): + """비동기 작업 목록 조회 API""" + + @swagger_auto_schema( + operation_summary="비동기 작업 목록 조회", + responses={200: "작업 목록"}, + ) + def get(self, request): + from .models import AsyncTask + + # 최근 100개만 조회 + tasks = AsyncTask.objects.all()[:100] + data = [ + { + "id": str(task.id), + "task_type": task.task_type, + "status": task.status, + "resource_name": task.resource_name, + "resource_id": task.resource_id, + "error_message": task.error_message, + "created_at": task.created_at.isoformat(), + "completed_at": task.completed_at.isoformat() if task.completed_at else None, + } + for task in tasks + ] + return Response({"tasks": data}) + + +class AsyncTaskDetailView(APIView): + """비동기 작업 상세 조회 API""" + + @swagger_auto_schema( + operation_summary="비동기 작업 상태 조회", + responses={200: "작업 상태"}, + ) + def get(self, request, task_id): + from .models import AsyncTask + + try: + task = AsyncTask.objects.get(id=task_id) + return Response( + { + "id": str(task.id), + "task_type": task.task_type, + "status": task.status, + "resource_name": task.resource_name, + "resource_id": task.resource_id, + "request_data": task.request_data, + "result_data": task.result_data, + "error_message": task.error_message, + "created_at": task.created_at.isoformat(), + "updated_at": task.updated_at.isoformat(), + "completed_at": task.completed_at.isoformat() if task.completed_at else None, + } + ) + except AsyncTask.DoesNotExist: + return Response({"error": "작업을 찾을 수 없습니다."}, status=status.HTTP_404_NOT_FOUND) + + +# ==================== NKS API ==================== + + +class NksClusterListView(APIView): + """NKS 클러스터 목록 조회 API""" + + @swagger_auto_schema( + operation_summary="NKS 클러스터 목록 조회", + manual_parameters=[region_header, token_header], + responses={200: "클러스터 목록"}, + ) + def get(self, request): + headers = get_nhn_headers(request) + try: + api = ApiNks(headers["region"], headers["token"]) + return Response(api.get_cluster_list()) + except NHNCloudAPIError as e: + return Response({"error": e.message}, status=status.HTTP_400_BAD_REQUEST) + + +class NksClusterDetailView(APIView): + """NKS 클러스터 상세/삭제 API""" + + @swagger_auto_schema( + operation_summary="NKS 클러스터 상세 조회", + manual_parameters=[region_header, token_header], + responses={200: "클러스터 상세 정보"}, + ) + def get(self, request, cluster_name): + headers = get_nhn_headers(request) + try: + api = ApiNks(headers["region"], headers["token"]) + return Response(api.get_cluster_info(cluster_name)) + except NHNCloudAPIError as e: + return Response({"error": e.message}, status=status.HTTP_400_BAD_REQUEST) + + @swagger_auto_schema( + operation_summary="NKS 클러스터 삭제 (비동기)", + manual_parameters=[region_header, token_header], + responses={202: "작업 ID 반환"}, + ) + def delete(self, request, cluster_name): + headers = get_nhn_headers(request) + try: + from .tasks import delete_nks_cluster_async + + task = delete_nks_cluster_async( + region=headers["region"], + token=headers["token"], + cluster_name=cluster_name, + ) + return Response( + {"task_id": str(task.id), "status": task.status, "message": "NKS 클러스터 삭제 작업이 시작되었습니다."}, + status=status.HTTP_202_ACCEPTED, + ) + except Exception as e: + logger.exception("NKS 클러스터 삭제 작업 시작 실패") + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + +class NksClusterConfigView(APIView): + """NKS 클러스터 kubeconfig 조회 API""" + + @swagger_auto_schema( + operation_summary="NKS 클러스터 kubeconfig 조회", + manual_parameters=[region_header, token_header], + responses={200: "kubeconfig"}, + ) + def get(self, request, cluster_name): + headers = get_nhn_headers(request) + try: + api = ApiNks(headers["region"], headers["token"]) + config = api.get_cluster_config(cluster_name) + return Response({"config": config}) + except NHNCloudAPIError as e: + return Response({"error": e.message}, status=status.HTTP_400_BAD_REQUEST) + + +class NksClusterCreateView(APIView): + """NKS 클러스터 생성 API (비동기)""" + + @swagger_auto_schema( + operation_summary="NKS 클러스터 생성 (비동기)", + manual_parameters=[region_header, token_header, tenant_header], + request_body=NksClusterSerializer, + responses={202: "작업 ID 반환"}, + ) + def post(self, request): + headers = get_nhn_headers(request) + serializer = NksClusterSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + try: + from .tasks import create_nks_cluster_async + + task = create_nks_cluster_async( + region=headers["region"], + tenant_id=headers["tenant_id"], + token=headers["token"], + cluster_data=serializer.validated_data, + ) + return Response( + {"task_id": str(task.id), "status": task.status, "message": "NKS 클러스터 생성 작업이 시작되었습니다."}, + status=status.HTTP_202_ACCEPTED, + ) + except Exception as e: + logger.exception("NKS 클러스터 생성 작업 시작 실패") + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + +# ==================== Storage API ==================== + +storage_account_header = openapi.Parameter( + "X-NHN-Storage-Account", + openapi.IN_HEADER, + description="NHN Cloud Object Storage 계정 (AUTH_...)", + type=openapi.TYPE_STRING, + required=True, +) + + +class StorageContainerListView(APIView): + """스토리지 컨테이너 목록 조회 API""" + + @swagger_auto_schema( + operation_summary="컨테이너 목록 조회", + manual_parameters=[region_header, token_header, storage_account_header], + responses={200: "컨테이너 목록"}, + ) + def get(self, request): + headers = get_nhn_headers(request) + try: + api = ApiStorageObject( + headers["region"], headers["token"], headers["storage_account"] + ) + containers = api.get_container_list() + return Response({"containers": containers}) + except NHNCloudAPIError as e: + return Response({"error": e.message}, status=status.HTTP_400_BAD_REQUEST) + + +class StorageContainerCreateView(APIView): + """스토리지 컨테이너 생성 API (비동기)""" + + @swagger_auto_schema( + operation_summary="컨테이너 생성 (비동기)", + manual_parameters=[region_header, token_header, storage_account_header], + request_body=StorageContainerSerializer, + responses={202: "작업 ID 반환"}, + ) + def post(self, request): + headers = get_nhn_headers(request) + serializer = StorageContainerSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + try: + from .tasks import create_storage_container_async + + task = create_storage_container_async( + region=headers["region"], + token=headers["token"], + storage_account=headers["storage_account"], + container_name=serializer.validated_data["name"], + ) + return Response( + {"task_id": str(task.id), "status": task.status, "message": "컨테이너 생성 작업이 시작되었습니다."}, + status=status.HTTP_202_ACCEPTED, + ) + except Exception as e: + logger.exception("컨테이너 생성 작업 시작 실패") + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + +class StorageContainerDetailView(APIView): + """스토리지 컨테이너 상세/삭제 API""" + + @swagger_auto_schema( + operation_summary="컨테이너 오브젝트 목록 조회", + manual_parameters=[region_header, token_header, storage_account_header], + responses={200: "오브젝트 목록"}, + ) + def get(self, request, container_name): + headers = get_nhn_headers(request) + try: + api = ApiStorageObject( + headers["region"], headers["token"], headers["storage_account"] + ) + objects = api.get_object_list(container_name) + return Response({"objects": objects}) + except NHNCloudAPIError as e: + return Response({"error": e.message}, status=status.HTTP_400_BAD_REQUEST) + + @swagger_auto_schema( + operation_summary="컨테이너 삭제 (비동기)", + manual_parameters=[region_header, token_header, storage_account_header], + responses={202: "작업 ID 반환"}, + ) + def delete(self, request, container_name): + headers = get_nhn_headers(request) + try: + from .tasks import delete_storage_container_async + + task = delete_storage_container_async( + region=headers["region"], + token=headers["token"], + storage_account=headers["storage_account"], + container_name=container_name, + ) + return Response( + {"task_id": str(task.id), "status": task.status, "message": "컨테이너 삭제 작업이 시작되었습니다."}, + status=status.HTTP_202_ACCEPTED, + ) + except Exception as e: + logger.exception("컨테이너 삭제 작업 시작 실패") + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) diff --git a/nhn_prj/__init__.py b/nhn_prj/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nhn_prj/asgi.py b/nhn_prj/asgi.py new file mode 100644 index 0000000..fb1a25f --- /dev/null +++ b/nhn_prj/asgi.py @@ -0,0 +1,11 @@ +""" +ASGI config for nhn_prj project. +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "nhn_prj.settings") + +application = get_asgi_application() diff --git a/nhn_prj/settings.py b/nhn_prj/settings.py new file mode 100644 index 0000000..76b60d1 --- /dev/null +++ b/nhn_prj/settings.py @@ -0,0 +1,241 @@ +""" +Django settings for nhn_prj project. +""" + +from pathlib import Path +from datetime import timedelta +from dotenv import load_dotenv +import os + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + +load_dotenv(dotenv_path=os.path.join(BASE_DIR, ".env.dev")) + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.environ.get( + "SECRET_KEY", "django-insecure-default-key-change-in-production" +) + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = int(os.environ.get("DEBUG", default=1)) + +ALLOWED_HOSTS = ["*"] + +# Application definition +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "rest_framework_simplejwt", + "drf_yasg", + "corsheaders", + "nhn", +] + +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + "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 = "nhn_prj.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "nhn_prj.wsgi.application" + +# Database +if os.environ.get("SQL_ENGINE"): + DATABASES = { + "default": { + "ENGINE": os.environ.get("SQL_ENGINE", "django.db.backends.sqlite3"), + "NAME": os.environ.get("SQL_DATABASE", BASE_DIR / "db.sqlite3"), + "USER": os.environ.get("SQL_USER", "user"), + "PASSWORD": os.environ.get("SQL_PASSWORD", "password"), + "HOST": os.environ.get("SQL_HOST", "localhost"), + "PORT": os.environ.get("SQL_PORT", "3306"), + } + } +else: + DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } + } + +# Password validation +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 +LANGUAGE_CODE = "ko-kr" +TIME_ZONE = "Asia/Seoul" +USE_I18N = True +USE_TZ = True + +# Static files (CSS, JavaScript, Images) +STATIC_URL = "static/" + +# Default primary key field type +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# CORS settings +CORS_ALLOWED_ORIGINS = [ + "http://localhost:3000", + "http://127.0.0.1:3000", + "http://192.168.0.100:3000", + "https://demo.test", + "http://demo.test", + "https://www.demo.test", + "https://sample.test", + "http://sample.test", + "http://www.sample.test", + "http://auth.sample.test", + "http://blog.sample.test", + "https://www.icurfer.com", + "https://icurfer.com", +] +# 개발 환경에서 모든 origin 허용 (필요시) +CORS_ALLOW_ALL_ORIGINS = bool(DEBUG) + +CORS_ALLOW_CREDENTIALS = True + +# 커스텀 헤더 허용 (X-NHN-Token, X-NHN-Region 등) +CORS_ALLOW_HEADERS = [ + "accept", + "accept-encoding", + "authorization", + "content-type", + "dnt", + "origin", + "user-agent", + "x-csrftoken", + "x-requested-with", + # NHN Cloud 커스텀 헤더 + "x-nhn-token", + "x-nhn-region", + "x-nhn-tenant-id", + "x-nhn-storage-account", +] + +# REST Framework settings +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": [ + "nhn.authentication.StatelessJWTAuthentication", + ], + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.AllowAny", + ], +} + +# JWT settings +ISTIO_JWT = int(os.environ.get("ISTIO_JWT", default=0)) + +if ISTIO_JWT: + # RS256 for Istio + SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=30), + "REFRESH_TOKEN_LIFETIME": timedelta(days=1), + "ALGORITHM": "RS256", + "SIGNING_KEY": open(os.path.join(BASE_DIR, "keys/private.pem")).read(), + "VERIFYING_KEY": open(os.path.join(BASE_DIR, "keys/public.pem")).read(), + "AUTH_HEADER_TYPES": ("Bearer",), + "USER_ID_FIELD": "name", + "USER_ID_CLAIM": "name", + } +else: + # HS256 default + SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=30), + "REFRESH_TOKEN_LIFETIME": timedelta(days=1), + "ALGORITHM": "HS256", + "SIGNING_KEY": SECRET_KEY, + "AUTH_HEADER_TYPES": ("Bearer",), + "USER_ID_FIELD": "name", + "USER_ID_CLAIM": "name", + } + +# Auth server URL +AUTH_VERIFY_URL = os.environ.get("AUTH_VERIFY_URL", "") + +# Logging +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": "[{asctime}] {levelname} {name} {message}", + "style": "{", + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "verbose", + }, + }, + "root": { + "handlers": ["console"], + "level": "INFO", + }, + "loggers": { + "django": { + "handlers": ["console"], + "level": "INFO", + "propagate": False, + }, + "django.request": { + "handlers": ["console"], + "level": "INFO", # ERROR -> INFO로 변경하여 모든 요청 로깅 + "propagate": False, + }, + # NHN 앱 로거 명시적 설정 + "nhn": { + "handlers": ["console"], + "level": "INFO", + "propagate": False, + }, + "nhn.packages": { + "handlers": ["console"], + "level": "INFO", + "propagate": False, + }, + }, +} diff --git a/nhn_prj/urls.py b/nhn_prj/urls.py new file mode 100644 index 0000000..5c3f712 --- /dev/null +++ b/nhn_prj/urls.py @@ -0,0 +1,34 @@ +""" +URL configuration for nhn_prj project. +""" + +from django.contrib import admin +from django.urls import path, include +from rest_framework import permissions +from drf_yasg.views import get_schema_view +from drf_yasg import openapi + +schema_view = get_schema_view( + openapi.Info( + title="NHN API", + default_version="v1", + description="NHN Microservice API Documentation", + ), + public=True, + permission_classes=(permissions.AllowAny,), +) + +urlpatterns = [ + path("admin/", admin.site.urls), + path("api/nhn/", include("nhn.urls")), + path( + "swagger/", + schema_view.with_ui("swagger", cache_timeout=0), + name="schema-swagger-ui", + ), + path( + "swagger/", + schema_view.without_ui(cache_timeout=0), + name="schema-json", + ), +] diff --git a/nhn_prj/wsgi.py b/nhn_prj/wsgi.py new file mode 100644 index 0000000..ef3ce70 --- /dev/null +++ b/nhn_prj/wsgi.py @@ -0,0 +1,11 @@ +""" +WSGI config for nhn_prj project. +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "nhn_prj.settings") + +application = get_wsgi_application() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..721428a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,29 @@ +asgiref==3.8.1 +certifi==2025.1.31 +cffi==1.17.1 +charset-normalizer==3.4.1 +coreapi==2.3.3 +coreschema==0.0.4 +Django==4.2.14 +django-cors-headers==4.7.0 +djangorestframework==3.16.0 +djangorestframework_simplejwt==5.5.0 +drf-yasg==1.21.10 +gunicorn==20.1.0 +idna==3.10 +inflection==0.5.1 +itypes==1.2.0 +Jinja2==3.1.5 +MarkupSafe==3.0.2 +mysqlclient==2.2.7 +packaging==24.2 +pycparser==2.22 +PyJWT==2.9.0 +python-dotenv==1.0.1 +pytz==2025.1 +PyYAML==6.0.2 +requests==2.32.3 +sqlparse==0.5.3 +typing_extensions==4.12.2 +uritemplate==4.1.1 +urllib3==2.3.0 diff --git a/version b/version new file mode 100644 index 0000000..95e94cd --- /dev/null +++ b/version @@ -0,0 +1 @@ +v0.0.1 \ No newline at end of file