Add NHN Cloud API integration with async task support
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-01-14 01:29:21 +09:00
parent 256fed485e
commit 8c7739ffad
32 changed files with 4059 additions and 0 deletions

48
.github/workflows/build.yaml vendored Normal file
View File

@ -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

13
.gitignore vendored
View File

@ -130,6 +130,7 @@ celerybeat.pid
# Environments # Environments
.env .env
.env.*
.venv .venv
env/ env/
venv/ venv/
@ -174,3 +175,15 @@ cython_debug/
# PyPI configuration file # PyPI configuration file
.pypirc .pypirc
# IDE
.idea/
.vscode/
*.swp
*.swo
# macOS
.DS_Store
# Keys (do not commit real keys)
keys/*.pem
!keys/.gitkeep

644
API_SPEC.md Normal file
View File

@ -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 | 평촌 |

22
Dockerfile Normal file
View File

@ -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"]

22
manage.py Normal file
View File

@ -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()

0
nhn/__init__.py Normal file
View File

3
nhn/admin.py Normal file
View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
nhn/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class NhnConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "nhn"

28
nhn/authentication.py Normal file
View File

@ -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)

View File

@ -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'],
},
),
]

View File

89
nhn/models.py Normal file
View File

@ -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()

13
nhn/packages/__init__.py Normal file
View File

@ -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",
]

165
nhn/packages/base.py Normal file
View File

@ -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)

220
nhn/packages/compute.py Normal file
View File

@ -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

214
nhn/packages/nks.py Normal file
View File

@ -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)

279
nhn/packages/storage.py Normal file
View File

@ -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}")

123
nhn/packages/token.py Normal file
View File

@ -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

272
nhn/packages/vpc.py Normal file
View File

@ -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

201
nhn/serializers.py Normal file
View File

@ -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,
)

399
nhn/tasks.py Normal file
View File

@ -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

3
nhn/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

52
nhn/urls.py Normal file
View File

@ -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/<str:server_id>/", views.ComputeInstanceDetailView.as_view(), name="compute-instance-detail"),
path("compute/instances/<str:server_id>/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/<str:vpc_id>/", 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/<str:subnet_id>/", 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/<str:task_id>/", 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/<str:cluster_name>/", views.NksClusterDetailView.as_view(), name="nks-cluster-detail"),
path("nks/clusters/<str:cluster_name>/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/<str:container_name>/", views.StorageContainerDetailView.as_view(), name="storage-container-detail"),
]

31
nhn/utils.py Normal file
View File

@ -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

849
nhn/views.py Normal file
View File

@ -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)

0
nhn_prj/__init__.py Normal file
View File

11
nhn_prj/asgi.py Normal file
View File

@ -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()

241
nhn_prj/settings.py Normal file
View File

@ -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,
},
},
}

34
nhn_prj/urls.py Normal file
View File

@ -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<format>/",
schema_view.without_ui(cache_timeout=0),
name="schema-json",
),
]

11
nhn_prj/wsgi.py Normal file
View File

@ -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()

29
requirements.txt Normal file
View File

@ -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

1
version Normal file
View File

@ -0,0 +1 @@
v0.0.1