Add NHN Cloud API integration with async task support
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
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:
48
.github/workflows/build.yaml
vendored
Normal file
48
.github/workflows/build.yaml
vendored
Normal 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
13
.gitignore
vendored
@ -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
644
API_SPEC.md
Normal 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
22
Dockerfile
Normal 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
22
manage.py
Normal 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
0
nhn/__init__.py
Normal file
3
nhn/admin.py
Normal file
3
nhn/admin.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
6
nhn/apps.py
Normal file
6
nhn/apps.py
Normal 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
28
nhn/authentication.py
Normal 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)
|
||||||
36
nhn/migrations/0001_initial.py
Normal file
36
nhn/migrations/0001_initial.py
Normal 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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
nhn/migrations/__init__.py
Normal file
0
nhn/migrations/__init__.py
Normal file
89
nhn/models.py
Normal file
89
nhn/models.py
Normal 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
13
nhn/packages/__init__.py
Normal 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
165
nhn/packages/base.py
Normal 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
220
nhn/packages/compute.py
Normal 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
214
nhn/packages/nks.py
Normal 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
279
nhn/packages/storage.py
Normal 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
123
nhn/packages/token.py
Normal 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
272
nhn/packages/vpc.py
Normal 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
201
nhn/serializers.py
Normal 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
399
nhn/tasks.py
Normal 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
3
nhn/tests.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
52
nhn/urls.py
Normal file
52
nhn/urls.py
Normal 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
31
nhn/utils.py
Normal 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
849
nhn/views.py
Normal 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
0
nhn_prj/__init__.py
Normal file
11
nhn_prj/asgi.py
Normal file
11
nhn_prj/asgi.py
Normal 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
241
nhn_prj/settings.py
Normal 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
34
nhn_prj/urls.py
Normal 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
11
nhn_prj/wsgi.py
Normal 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
29
requirements.txt
Normal 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
|
||||||
Reference in New Issue
Block a user