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

- NHN Cloud API packages: token, vpc, compute, nks, storage
- REST API endpoints with Swagger documentation
- Async task processing for long-running operations
- CORS configuration for frontend integration
- Enhanced logging for debugging API calls

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-14 01:29:21 +09:00
parent 256fed485e
commit 8c7739ffad
32 changed files with 4059 additions and 0 deletions

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

@ -0,0 +1,13 @@
from .token import NHNCloudToken
from .compute import ApiCompute
from .vpc import ApiVpc
from .nks import ApiNks
from .storage import ApiStorageObject
__all__ = [
"NHNCloudToken",
"ApiCompute",
"ApiVpc",
"ApiNks",
"ApiStorageObject",
]

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

@ -0,0 +1,165 @@
"""
NHN Cloud API Base Module
공통 기능을 제공하는 베이스 클래스
"""
import logging
from typing import Optional, Any
from dataclasses import dataclass
from enum import Enum
import requests
logger = logging.getLogger(__name__)
class Region(str, Enum):
"""NHN Cloud 리전"""
KR1 = "kr1" # 판교
KR2 = "kr2" # 평촌
@dataclass
class NHNCloudEndpoints:
"""NHN Cloud API Endpoints"""
# Identity
IDENTITY = "https://api-identity-infrastructure.nhncloudservice.com"
# Compute
COMPUTE_KR1 = "https://kr1-api-instance-infrastructure.nhncloudservice.com"
COMPUTE_KR2 = "https://kr2-api-instance-infrastructure.nhncloudservice.com"
# Image
IMAGE_KR1 = "https://kr1-api-image-infrastructure.nhncloudservice.com"
IMAGE_KR2 = "https://kr2-api-image-infrastructure.nhncloudservice.com"
# Network (VPC)
NETWORK_KR1 = "https://kr1-api-network-infrastructure.nhncloudservice.com"
NETWORK_KR2 = "https://kr2-api-network-infrastructure.nhncloudservice.com"
# Kubernetes (NKS)
NKS_KR1 = "https://kr1-api-kubernetes-infrastructure.nhncloudservice.com"
NKS_KR2 = "https://kr2-api-kubernetes-infrastructure.nhncloudservice.com"
# Object Storage
STORAGE_KR1 = "https://kr1-api-object-storage.nhncloudservice.com/v1"
STORAGE_KR2 = "https://kr2-api-object-storage.nhncloudservice.com/v1"
class NHNCloudAPIError(Exception):
"""NHN Cloud API 에러"""
def __init__(self, message: str, code: Optional[int] = None, details: Optional[dict] = None):
self.message = message
self.code = code
self.details = details or {}
super().__init__(self.message)
class BaseAPI:
"""NHN Cloud API 베이스 클래스"""
DEFAULT_TIMEOUT = 30
def __init__(self, region: str, token: str):
self.region = Region(region.lower()) if isinstance(region, str) else region
self.token = token
self._session = requests.Session()
def _get_headers(self, extra_headers: Optional[dict] = None) -> dict:
"""기본 헤더 생성"""
headers = {
"X-Auth-Token": self.token,
"Content-Type": "application/json",
"Accept": "application/json",
}
if extra_headers:
headers.update(extra_headers)
return headers
def _request(
self,
method: str,
url: str,
params: Optional[dict] = None,
json_data: Optional[dict] = None,
headers: Optional[dict] = None,
timeout: Optional[int] = None,
) -> dict:
"""HTTP 요청 실행"""
# 토큰 앞 8자리만 로깅 (보안)
token_preview = self.token[:8] + "..." if self.token else "None"
logger.info(f"[BaseAPI] 요청 시작 - method={method}, url={url}, token={token_preview}")
if params:
logger.info(f"[BaseAPI] 요청 파라미터 - params={params}")
if json_data:
logger.info(f"[BaseAPI] 요청 바디 - json={json_data}")
try:
response = self._session.request(
method=method,
url=url,
params=params,
json=json_data,
headers=self._get_headers(headers),
timeout=timeout or self.DEFAULT_TIMEOUT,
)
logger.info(f"[BaseAPI] 응답 수신 - method={method}, url={url}, status_code={response.status_code}")
if response.status_code >= 400:
logger.error(f"[BaseAPI] 에러 응답 - status_code={response.status_code}, body={response.text[:500]}")
self._handle_error(response)
if response.text:
return response.json()
return {}
except requests.exceptions.Timeout:
logger.error(f"[BaseAPI] 타임아웃 - url={url}")
raise NHNCloudAPIError("요청 시간이 초과되었습니다.", code=408)
except requests.exceptions.ConnectionError as e:
logger.error(f"[BaseAPI] 연결 오류 - url={url}, error={e}")
raise NHNCloudAPIError("서버에 연결할 수 없습니다.", code=503)
except requests.exceptions.RequestException as e:
logger.error(f"[BaseAPI] 요청 오류 - url={url}, error={e}")
raise NHNCloudAPIError(f"요청 중 오류가 발생했습니다: {e}")
def _handle_error(self, response: requests.Response) -> None:
"""에러 응답 처리"""
try:
error_data = response.json()
if "error" in error_data:
error = error_data["error"]
raise NHNCloudAPIError(
message=error.get("message", "알 수 없는 오류"),
code=error.get("code", response.status_code),
details=error,
)
raise NHNCloudAPIError(
message=f"API 오류: {response.status_code}",
code=response.status_code,
details=error_data,
)
except ValueError:
raise NHNCloudAPIError(
message=f"API 오류: {response.status_code} - {response.text}",
code=response.status_code,
)
def _get(self, url: str, params: Optional[dict] = None, **kwargs) -> dict:
"""GET 요청"""
return self._request("GET", url, params=params, **kwargs)
def _post(self, url: str, json_data: Optional[dict] = None, **kwargs) -> dict:
"""POST 요청"""
return self._request("POST", url, json_data=json_data, **kwargs)
def _put(self, url: str, json_data: Optional[dict] = None, **kwargs) -> dict:
"""PUT 요청"""
return self._request("PUT", url, json_data=json_data, **kwargs)
def _delete(self, url: str, **kwargs) -> dict:
"""DELETE 요청"""
return self._request("DELETE", url, **kwargs)

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

@ -0,0 +1,220 @@
"""
NHN Cloud Compute API Module
인스턴스, 이미지, Flavor, Keypair 관리
"""
import logging
from typing import Optional, List
from .base import BaseAPI, NHNCloudEndpoints, Region
logger = logging.getLogger(__name__)
class ApiCompute(BaseAPI):
"""NHN Cloud Compute API 클래스"""
def __init__(self, region: str, tenant_id: str, token: str):
"""
Args:
region: 리전 (kr1: 판교, kr2: 평촌)
tenant_id: 테넌트 ID
token: API 인증 토큰
"""
super().__init__(region, token)
self.tenant_id = tenant_id
if self.region == Region.KR1:
self.compute_url = NHNCloudEndpoints.COMPUTE_KR1
self.image_url = NHNCloudEndpoints.IMAGE_KR1
else:
self.compute_url = NHNCloudEndpoints.COMPUTE_KR2
self.image_url = NHNCloudEndpoints.IMAGE_KR2
# ==================== Flavor ====================
def get_flavor_list(self) -> dict:
"""Flavor 목록 조회"""
url = f"{self.compute_url}/v2/{self.tenant_id}/flavors"
return self._get(url)
def get_flavor_detail(self, flavor_id: str) -> dict:
"""Flavor 상세 조회"""
url = f"{self.compute_url}/v2/{self.tenant_id}/flavors/{flavor_id}"
return self._get(url)
def get_flavor_id_by_name(self, flavor_name: str) -> Optional[str]:
"""Flavor 이름으로 ID 조회"""
data = self.get_flavor_list()
flavors = data.get("flavors", [])
for flavor in flavors:
if flavor_name in flavor.get("name", ""):
return flavor.get("id")
return None
# ==================== Keypair ====================
def get_keypair_list(self) -> dict:
"""Keypair 목록 조회"""
url = f"{self.compute_url}/v2/{self.tenant_id}/os-keypairs"
return self._get(url)
def get_keypair_info(self, keypair_name: str) -> dict:
"""Keypair 상세 조회"""
url = f"{self.compute_url}/v2/{self.tenant_id}/os-keypairs/{keypair_name}"
return self._get(url)
# ==================== Instance ====================
def get_instance_list(self) -> dict:
"""인스턴스 목록 조회"""
url = f"{self.compute_url}/v2/{self.tenant_id}/servers"
return self._get(url)
def get_instance_list_detail(self) -> dict:
"""인스턴스 상세 목록 조회"""
url = f"{self.compute_url}/v2/{self.tenant_id}/servers/detail"
return self._get(url)
def get_instance_info(self, server_id: str) -> dict:
"""인스턴스 상세 정보 조회"""
url = f"{self.compute_url}/v2/{self.tenant_id}/servers/{server_id}"
return self._get(url)
def get_instance_status(self, server_id: str) -> str:
"""인스턴스 상태 조회"""
data = self.get_instance_info(server_id)
return data.get("server", {}).get("status", "UNKNOWN")
def get_instance_id_by_name(self, instance_name: str) -> Optional[str]:
"""인스턴스 이름으로 ID 조회"""
data = self.get_instance_list()
servers = data.get("servers", [])
for server in servers:
if server.get("name") == instance_name:
return server.get("id")
return None
def create_instance(
self,
name: str,
image_id: str,
flavor_id: str,
subnet_id: str,
keypair_name: str,
volume_size: int = 50,
volume_type: str = "General SSD",
security_groups: Optional[List[str]] = None,
availability_zone: Optional[str] = None,
) -> dict:
"""
인스턴스 생성
Args:
name: 인스턴스 이름
image_id: 이미지 ID
flavor_id: Flavor ID
subnet_id: 서브넷 ID
keypair_name: Keypair 이름
volume_size: 볼륨 크기 (GB, 기본 50)
volume_type: 볼륨 타입 (General SSD, General HDD)
security_groups: 보안 그룹 목록 (기본 ["default"])
availability_zone: 가용 영역
Returns:
dict: 생성된 인스턴스 정보
"""
url = f"{self.compute_url}/v2/{self.tenant_id}/servers"
security_groups = security_groups or ["default"]
sg_list = [{"name": sg} for sg in security_groups]
payload = {
"server": {
"name": name,
"imageRef": image_id,
"flavorRef": flavor_id,
"networks": [{"subnet": subnet_id}],
"key_name": keypair_name,
"max_count": 1,
"min_count": 1,
"block_device_mapping_v2": [
{
"uuid": image_id,
"boot_index": 0,
"volume_size": volume_size,
"volume_type": volume_type,
"device_name": "vda",
"source_type": "image",
"destination_type": "volume",
"delete_on_termination": True,
}
],
"security_groups": sg_list,
}
}
if availability_zone:
payload["server"]["availability_zone"] = availability_zone
logger.info(f"인스턴스 생성 요청: {name}")
return self._post(url, payload)
def delete_instance(self, server_id: str) -> dict:
"""인스턴스 삭제"""
url = f"{self.compute_url}/v2/{self.tenant_id}/servers/{server_id}"
logger.info(f"인스턴스 삭제 요청: {server_id}")
return self._delete(url)
def start_instance(self, server_id: str) -> dict:
"""인스턴스 시작"""
url = f"{self.compute_url}/v2/{self.tenant_id}/servers/{server_id}/action"
return self._post(url, {"os-start": None})
def stop_instance(self, server_id: str) -> dict:
"""인스턴스 정지"""
url = f"{self.compute_url}/v2/{self.tenant_id}/servers/{server_id}/action"
return self._post(url, {"os-stop": None})
def reboot_instance(self, server_id: str, hard: bool = False) -> dict:
"""인스턴스 재부팅"""
url = f"{self.compute_url}/v2/{self.tenant_id}/servers/{server_id}/action"
reboot_type = "HARD" if hard else "SOFT"
return self._post(url, {"reboot": {"type": reboot_type}})
# ==================== Image ====================
def get_image_list(self) -> dict:
"""이미지 목록 조회"""
url = f"{self.image_url}/v2/images"
return self._get(url)
def get_image_info(self, image_id: str) -> dict:
"""이미지 상세 조회"""
url = f"{self.image_url}/v2/images/{image_id}"
return self._get(url)
def get_image_id_by_name(self, image_name: str, exclude_container: bool = True) -> Optional[str]:
"""
이미지 이름으로 ID 조회
Args:
image_name: 이미지 이름 (부분 일치)
exclude_container: 컨테이너 이미지 제외 여부
Returns:
str: 이미지 ID 또는 None
"""
data = self.get_image_list()
images = data.get("images", [])
for image in images:
name = image.get("name", "")
if name.startswith(image_name):
if exclude_container and "Container" in name:
continue
return image.get("id")
return None

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

@ -0,0 +1,214 @@
"""
NHN Cloud NKS (Kubernetes Service) API Module
Kubernetes 클러스터 관리
"""
import logging
from typing import Optional
from dataclasses import dataclass
from .base import BaseAPI, NHNCloudEndpoints, Region
logger = logging.getLogger(__name__)
@dataclass
class NKSNodeImages:
"""NKS 노드 이미지 ID"""
# Ubuntu 20.04 이미지 ID (리전별)
UBUNTU_20_04_KR1 = "1213d033-bdf6-4d73-9763-4e8e57c745fb"
UBUNTU_20_04_KR2 = "dabb6d10-937d-4952-9ce0-1e576e9164e8"
class ApiNks(BaseAPI):
"""NHN Cloud NKS API 클래스"""
def __init__(self, region: str, token: str):
"""
Args:
region: 리전 (kr1: 판교, kr2: 평촌)
token: API 인증 토큰
"""
super().__init__(region, token)
if self.region == Region.KR1:
self.nks_url = NHNCloudEndpoints.NKS_KR1
self.default_node_image = NKSNodeImages.UBUNTU_20_04_KR1
else:
self.nks_url = NHNCloudEndpoints.NKS_KR2
self.default_node_image = NKSNodeImages.UBUNTU_20_04_KR2
def _get_headers(self, extra_headers: Optional[dict] = None) -> dict:
"""NKS API 전용 헤더"""
headers = {
"X-Auth-Token": self.token,
"Accept": "application/json",
"Content-Type": "application/json",
"OpenStack-API-Version": "container-infra latest",
}
if extra_headers:
headers.update(extra_headers)
return headers
# ==================== Cluster ====================
def get_cluster_list(self) -> dict:
"""클러스터 목록 조회"""
url = f"{self.nks_url}/v1/clusters"
return self._get(url)
def get_cluster_info(self, cluster_name: str) -> dict:
"""클러스터 상세 조회"""
url = f"{self.nks_url}/v1/clusters/{cluster_name}"
return self._get(url)
def get_cluster_config(self, cluster_name: str) -> str:
"""클러스터 kubeconfig 조회"""
url = f"{self.nks_url}/v1/clusters/{cluster_name}/config"
data = self._get(url)
return data.get("config", "")
def create_public_cluster(
self,
cluster_name: str,
vpc_id: str,
subnet_id: str,
instance_type: str,
keypair_name: str,
kubernetes_version: str,
external_network_id: str,
external_subnet_id: str,
availability_zone: str,
node_count: int = 1,
boot_volume_size: int = 50,
boot_volume_type: str = "General SSD",
node_image: Optional[str] = None,
) -> dict:
"""
Public 클러스터 생성 (외부 접근 가능)
Args:
cluster_name: 클러스터 이름
vpc_id: VPC ID
subnet_id: 서브넷 ID
instance_type: 인스턴스 타입 (Flavor ID)
keypair_name: Keypair 이름
kubernetes_version: Kubernetes 버전 (예: v1.28.3)
external_network_id: 외부 네트워크 ID
external_subnet_id: 외부 서브넷 ID
availability_zone: 가용 영역 (예: kr-pub-a)
node_count: 노드 수 (기본 1)
boot_volume_size: 부팅 볼륨 크기 (GB, 기본 50)
boot_volume_type: 볼륨 타입 (기본 "General SSD")
node_image: 노드 이미지 ID (기본 Ubuntu 20.04)
Returns:
dict: 생성된 클러스터 정보
"""
url = f"{self.nks_url}/v1/clusters"
payload = {
"cluster_template_id": "iaas_console",
"create_timeout": 60,
"fixed_network": vpc_id,
"fixed_subnet": subnet_id,
"flavor_id": instance_type,
"keypair": keypair_name,
"labels": {
"availability_zone": availability_zone,
"boot_volume_size": str(boot_volume_size),
"boot_volume_type": boot_volume_type,
"ca_enable": "false",
"cert_manager_api": "True",
"clusterautoscale": "nodegroupfeature",
"external_network_id": external_network_id,
"external_subnet_id_list": external_subnet_id,
"kube_tag": kubernetes_version,
"master_lb_floating_ip_enabled": "True",
"node_image": node_image or self.default_node_image,
},
"name": cluster_name,
"node_count": str(node_count),
}
logger.info(f"Public 클러스터 생성 요청: {cluster_name}")
return self._post(url, payload)
def create_private_cluster(
self,
cluster_name: str,
vpc_id: str,
subnet_id: str,
instance_type: str,
keypair_name: str,
kubernetes_version: str,
availability_zone: str,
node_count: int = 1,
boot_volume_size: int = 50,
boot_volume_type: str = "General SSD",
node_image: Optional[str] = None,
) -> dict:
"""
Private 클러스터 생성 (내부 접근만 가능)
Args:
cluster_name: 클러스터 이름
vpc_id: VPC ID
subnet_id: 서브넷 ID
instance_type: 인스턴스 타입 (Flavor ID)
keypair_name: Keypair 이름
kubernetes_version: Kubernetes 버전 (예: v1.28.3)
availability_zone: 가용 영역 (예: kr-pub-a)
node_count: 노드 수 (기본 1)
boot_volume_size: 부팅 볼륨 크기 (GB, 기본 50)
boot_volume_type: 볼륨 타입 (기본 "General SSD")
node_image: 노드 이미지 ID (기본 Ubuntu 20.04)
Returns:
dict: 생성된 클러스터 정보
"""
url = f"{self.nks_url}/v1/clusters"
payload = {
"cluster_template_id": "iaas_console",
"create_timeout": 60,
"fixed_network": vpc_id,
"fixed_subnet": subnet_id,
"flavor_id": instance_type,
"keypair": keypair_name,
"labels": {
"availability_zone": availability_zone,
"boot_volume_size": str(boot_volume_size),
"boot_volume_type": boot_volume_type,
"ca_enable": "false",
"cert_manager_api": "True",
"clusterautoscale": "nodegroupfeature",
"kube_tag": kubernetes_version,
"master_lb_floating_ip_enabled": "False",
"node_image": node_image or self.default_node_image,
},
"name": cluster_name,
"node_count": str(node_count),
}
logger.info(f"Private 클러스터 생성 요청: {cluster_name}")
return self._post(url, payload)
def delete_cluster(self, cluster_name: str) -> dict:
"""클러스터 삭제"""
url = f"{self.nks_url}/v1/clusters/{cluster_name}"
logger.info(f"클러스터 삭제 요청: {cluster_name}")
return self._delete(url)
# ==================== Node Group ====================
def get_nodegroup_list(self, cluster_name: str) -> dict:
"""노드 그룹 목록 조회"""
url = f"{self.nks_url}/v1/clusters/{cluster_name}/nodegroups"
return self._get(url)
def get_nodegroup_info(self, cluster_name: str, nodegroup_name: str) -> dict:
"""노드 그룹 상세 조회"""
url = f"{self.nks_url}/v1/clusters/{cluster_name}/nodegroups/{nodegroup_name}"
return self._get(url)

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

@ -0,0 +1,279 @@
"""
NHN Cloud Object Storage API Module
Object Storage 컨테이너 및 오브젝트 관리
"""
import logging
from typing import Optional, List
import requests
from .base import BaseAPI, NHNCloudEndpoints, Region, NHNCloudAPIError
logger = logging.getLogger(__name__)
class ApiStorageObject(BaseAPI):
"""NHN Cloud Object Storage API 클래스"""
def __init__(self, region: str, token: str, storage_account: str):
"""
Args:
region: 리전 (kr1: 판교, kr2: 평촌)
token: API 인증 토큰
storage_account: 스토리지 계정 (AUTH_...)
"""
super().__init__(region, token)
self.storage_account = storage_account
if self.region == Region.KR1:
self.storage_url = NHNCloudEndpoints.STORAGE_KR1
else:
self.storage_url = NHNCloudEndpoints.STORAGE_KR2
def _get_url(self, container: Optional[str] = None, obj: Optional[str] = None) -> str:
"""URL 생성"""
parts = [self.storage_url, self.storage_account]
if container:
parts.append(container)
if obj:
parts.append(obj)
return "/".join(parts)
def _get_headers(self, extra_headers: Optional[dict] = None) -> dict:
"""Object Storage 전용 헤더"""
headers = {"X-Auth-Token": self.token}
if extra_headers:
headers.update(extra_headers)
return headers
# ==================== Container ====================
def get_container_list(self) -> List[str]:
"""컨테이너 목록 조회"""
url = self._get_url()
try:
response = self._session.get(
url,
headers=self._get_headers(),
timeout=self.DEFAULT_TIMEOUT,
)
if response.status_code >= 400:
self._handle_error(response)
return [c for c in response.text.split("\n") if c]
except requests.exceptions.RequestException as e:
logger.error(f"컨테이너 목록 조회 실패: {e}")
raise NHNCloudAPIError(f"컨테이너 목록 조회 실패: {e}")
def create_container(self, container_name: str) -> dict:
"""
컨테이너 생성
Args:
container_name: 컨테이너 이름
Returns:
dict: 생성 결과
"""
url = self._get_url(container_name)
try:
response = self._session.put(
url,
headers=self._get_headers(),
timeout=self.DEFAULT_TIMEOUT,
)
logger.info(f"컨테이너 생성: {container_name}")
return {"status": response.status_code, "container": container_name}
except requests.exceptions.RequestException as e:
logger.error(f"컨테이너 생성 실패: {e}")
raise NHNCloudAPIError(f"컨테이너 생성 실패: {e}")
def delete_container(self, container_name: str) -> dict:
"""컨테이너 삭제"""
url = self._get_url(container_name)
try:
response = self._session.delete(
url,
headers=self._get_headers(),
timeout=self.DEFAULT_TIMEOUT,
)
logger.info(f"컨테이너 삭제: {container_name}")
return {"status": response.status_code, "container": container_name}
except requests.exceptions.RequestException as e:
logger.error(f"컨테이너 삭제 실패: {e}")
raise NHNCloudAPIError(f"컨테이너 삭제 실패: {e}")
def set_container_public(self, container_name: str, is_public: bool = True) -> dict:
"""
컨테이너 공개/비공개 설정
Args:
container_name: 컨테이너 이름
is_public: 공개 여부 (True: 공개, False: 비공개)
Returns:
dict: 설정 결과
"""
url = self._get_url(container_name)
headers = self._get_headers()
headers["X-Container-Read"] = ".r:*" if is_public else ""
try:
response = self._session.post(
url,
headers=headers,
timeout=self.DEFAULT_TIMEOUT,
)
logger.info(f"컨테이너 공개 설정: {container_name} -> {is_public}")
return {"status": response.status_code, "public": is_public}
except requests.exceptions.RequestException as e:
logger.error(f"컨테이너 공개 설정 실패: {e}")
raise NHNCloudAPIError(f"컨테이너 공개 설정 실패: {e}")
# ==================== Object ====================
def get_object_list(self, container_name: str) -> List[str]:
"""오브젝트 목록 조회"""
url = self._get_url(container_name)
try:
response = self._session.get(
url,
headers=self._get_headers(),
timeout=self.DEFAULT_TIMEOUT,
)
if response.status_code >= 400:
self._handle_error(response)
return [o for o in response.text.split("\n") if o]
except requests.exceptions.RequestException as e:
logger.error(f"오브젝트 목록 조회 실패: {e}")
raise NHNCloudAPIError(f"오브젝트 목록 조회 실패: {e}")
def upload_object(
self,
container_name: str,
object_name: str,
file_path: str,
) -> dict:
"""
오브젝트 업로드
Args:
container_name: 컨테이너 이름
object_name: 오브젝트 이름
file_path: 업로드할 파일 경로
Returns:
dict: 업로드 결과
"""
url = self._get_url(container_name, object_name)
try:
with open(file_path, "rb") as f:
response = self._session.put(
url,
headers=self._get_headers(),
data=f.read(),
timeout=self.DEFAULT_TIMEOUT,
)
logger.info(f"오브젝트 업로드: {container_name}/{object_name}")
return {"status": response.status_code, "object": object_name}
except FileNotFoundError:
raise NHNCloudAPIError(f"파일을 찾을 수 없습니다: {file_path}")
except requests.exceptions.RequestException as e:
logger.error(f"오브젝트 업로드 실패: {e}")
raise NHNCloudAPIError(f"오브젝트 업로드 실패: {e}")
def upload_object_data(
self,
container_name: str,
object_name: str,
data: bytes,
content_type: Optional[str] = None,
) -> dict:
"""
데이터를 오브젝트로 업로드
Args:
container_name: 컨테이너 이름
object_name: 오브젝트 이름
data: 업로드할 데이터
content_type: Content-Type 헤더
Returns:
dict: 업로드 결과
"""
url = self._get_url(container_name, object_name)
headers = self._get_headers()
if content_type:
headers["Content-Type"] = content_type
try:
response = self._session.put(
url,
headers=headers,
data=data,
timeout=self.DEFAULT_TIMEOUT,
)
logger.info(f"오브젝트 업로드: {container_name}/{object_name}")
return {"status": response.status_code, "object": object_name}
except requests.exceptions.RequestException as e:
logger.error(f"오브젝트 업로드 실패: {e}")
raise NHNCloudAPIError(f"오브젝트 업로드 실패: {e}")
def download_object(self, container_name: str, object_name: str) -> bytes:
"""
오브젝트 다운로드
Args:
container_name: 컨테이너 이름
object_name: 오브젝트 이름
Returns:
bytes: 오브젝트 데이터
"""
url = self._get_url(container_name, object_name)
try:
response = self._session.get(
url,
headers=self._get_headers(),
timeout=self.DEFAULT_TIMEOUT,
)
if response.status_code >= 400:
self._handle_error(response)
return response.content
except requests.exceptions.RequestException as e:
logger.error(f"오브젝트 다운로드 실패: {e}")
raise NHNCloudAPIError(f"오브젝트 다운로드 실패: {e}")
def delete_object(self, container_name: str, object_name: str) -> dict:
"""오브젝트 삭제"""
url = self._get_url(container_name, object_name)
try:
response = self._session.delete(
url,
headers=self._get_headers(),
timeout=self.DEFAULT_TIMEOUT,
)
logger.info(f"오브젝트 삭제: {container_name}/{object_name}")
return {"status": response.status_code, "object": object_name}
except requests.exceptions.RequestException as e:
logger.error(f"오브젝트 삭제 실패: {e}")
raise NHNCloudAPIError(f"오브젝트 삭제 실패: {e}")
def get_object_info(self, container_name: str, object_name: str) -> dict:
"""오브젝트 메타데이터 조회"""
url = self._get_url(container_name, object_name)
try:
response = self._session.head(
url,
headers=self._get_headers(),
timeout=self.DEFAULT_TIMEOUT,
)
return dict(response.headers)
except requests.exceptions.RequestException as e:
logger.error(f"오브젝트 정보 조회 실패: {e}")
raise NHNCloudAPIError(f"오브젝트 정보 조회 실패: {e}")

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

@ -0,0 +1,123 @@
"""
NHN Cloud Token Management Module
NHN Cloud API 인증 토큰 관리
"""
import logging
from typing import Optional
from dataclasses import dataclass
import requests
from .base import NHNCloudEndpoints, NHNCloudAPIError
logger = logging.getLogger(__name__)
@dataclass
class TokenResponse:
"""토큰 응답 데이터"""
token: str
tenant_id: str
expires_at: Optional[str] = None
class NHNCloudToken:
"""NHN Cloud 토큰 관리 클래스"""
TOKEN_URL = f"{NHNCloudEndpoints.IDENTITY}/v2.0/tokens"
def __init__(self, tenant_id: str, username: str, password: str):
"""
Args:
tenant_id: NHN Cloud 테넌트 ID
username: NHN Cloud 사용자 이메일
password: NHN Cloud API 비밀번호
"""
self.tenant_id = tenant_id
self.username = username
self.password = password
self._token: Optional[str] = None
def create_token(self) -> TokenResponse:
"""
새 토큰 생성
Returns:
TokenResponse: 생성된 토큰 정보
Raises:
NHNCloudAPIError: 토큰 생성 실패 시
"""
payload = {
"auth": {
"tenantId": self.tenant_id,
"passwordCredentials": {
"username": self.username,
"password": self.password,
},
}
}
logger.info(f"[NHN API] 토큰 생성 요청 - URL={self.TOKEN_URL}, tenant_id={self.tenant_id}, username={self.username}")
try:
response = requests.post(
self.TOKEN_URL,
json=payload,
headers={"Content-Type": "application/json"},
timeout=30,
)
logger.info(f"[NHN API] 토큰 생성 응답 - status_code={response.status_code}")
data = response.json()
if "error" in data:
error = data["error"]
logger.error(f"[NHN API] 토큰 생성 실패 - code={error.get('code')}, message={error.get('message')}, details={error}")
raise NHNCloudAPIError(
message=error.get("message", "토큰 생성 실패"),
code=error.get("code"),
details=error,
)
access = data.get("access", {})
token_info = access.get("token", {})
self._token = token_info.get("id")
token_preview = self._token[:8] + "..." if self._token else "None"
logger.info(f"[NHN API] 토큰 생성 성공 - tenant_id={self.tenant_id}, token={token_preview}, expires={token_info.get('expires')}")
return TokenResponse(
token=self._token,
tenant_id=self.tenant_id,
expires_at=token_info.get("expires"),
)
except requests.exceptions.Timeout as e:
logger.error(f"[NHN API] 토큰 생성 타임아웃 - URL={self.TOKEN_URL}, error={e}")
raise NHNCloudAPIError(f"토큰 생성 요청 타임아웃: {e}")
except requests.exceptions.ConnectionError as e:
logger.error(f"[NHN API] 토큰 생성 연결 실패 - URL={self.TOKEN_URL}, error={e}")
raise NHNCloudAPIError(f"토큰 생성 서버 연결 실패: {e}")
except requests.exceptions.RequestException as e:
logger.error(f"[NHN API] 토큰 생성 요청 실패 - URL={self.TOKEN_URL}, error={e}")
raise NHNCloudAPIError(f"토큰 생성 요청 실패: {e}")
@property
def token(self) -> Optional[str]:
"""현재 토큰 반환"""
return self._token
def get_token(self) -> str:
"""
토큰 반환 (없으면 생성)
Returns:
str: API 토큰
"""
if not self._token:
self.create_token()
return self._token

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

@ -0,0 +1,272 @@
"""
NHN Cloud VPC API Module
VPC, Subnet, Routing Table, Floating IP 관리
"""
import logging
from typing import Optional
from .base import BaseAPI, NHNCloudEndpoints, Region
logger = logging.getLogger(__name__)
class ApiVpc(BaseAPI):
"""NHN Cloud VPC API 클래스"""
def __init__(self, region: str, token: str):
"""
Args:
region: 리전 (kr1: 판교, kr2: 평촌)
token: API 인증 토큰
"""
super().__init__(region, token)
if self.region == Region.KR1:
self.vpc_url = NHNCloudEndpoints.NETWORK_KR1
else:
self.vpc_url = NHNCloudEndpoints.NETWORK_KR2
# ==================== VPC ====================
def get_vpc_list(self) -> dict:
"""VPC 목록 조회"""
url = f"{self.vpc_url}/v2.0/vpcs"
logger.info(f"[NHN API] VPC 목록 조회 요청 - URL={url}")
result = self._get(url)
vpc_count = len(result.get("vpcs", []))
logger.info(f"[NHN API] VPC 목록 조회 완료 - count={vpc_count}")
return result
def get_vpc_info(self, vpc_id: str) -> dict:
"""VPC 상세 조회"""
url = f"{self.vpc_url}/v2.0/vpcs/{vpc_id}"
logger.info(f"[NHN API] VPC 상세 조회 요청 - URL={url}, vpc_id={vpc_id}")
result = self._get(url)
logger.info(f"[NHN API] VPC 상세 조회 완료 - vpc_id={vpc_id}")
return result
def get_vpc_id_by_name(self, vpc_name: str) -> Optional[str]:
"""VPC 이름으로 ID 조회"""
data = self.get_vpc_list()
vpcs = data.get("vpcs", [])
for vpc in vpcs:
if vpc.get("name", "").startswith(vpc_name):
return vpc.get("id")
return None
def create_vpc(self, name: str, cidr: str) -> dict:
"""
VPC 생성
Args:
name: VPC 이름
cidr: CIDR 블록 (예: 10.0.0.0/16)
Returns:
dict: 생성된 VPC 정보
"""
url = f"{self.vpc_url}/v2.0/vpcs"
payload = {"vpc": {"name": name, "cidrv4": cidr}}
logger.info(f"VPC 생성 요청: {name} ({cidr})")
return self._post(url, payload)
def delete_vpc(self, vpc_id: str) -> dict:
"""VPC 삭제"""
url = f"{self.vpc_url}/v2.0/vpcs/{vpc_id}"
logger.info(f"VPC 삭제 요청: {vpc_id}")
return self._delete(url)
# ==================== Subnet ====================
def get_subnet_list(self) -> dict:
"""서브넷 목록 조회"""
url = f"{self.vpc_url}/v2.0/vpcsubnets"
return self._get(url)
def get_subnet_info(self, subnet_id: str) -> dict:
"""서브넷 상세 조회"""
url = f"{self.vpc_url}/v2.0/vpcsubnets/{subnet_id}"
return self._get(url)
def get_subnet_id_by_name(self, subnet_name: str) -> Optional[str]:
"""서브넷 이름으로 ID 조회"""
data = self.get_subnet_list()
subnets = data.get("vpcsubnets", [])
for subnet in subnets:
name = subnet.get("name", "")
if name.startswith(subnet_name) and "rt" not in name:
return subnet.get("id")
return None
def create_subnet(self, vpc_id: str, cidr: str, name: str) -> dict:
"""
서브넷 생성
Args:
vpc_id: VPC ID
cidr: CIDR 블록 (예: 10.0.1.0/24)
name: 서브넷 이름
Returns:
dict: 생성된 서브넷 정보
"""
url = f"{self.vpc_url}/v2.0/vpcsubnets"
payload = {"vpcsubnet": {"vpc_id": vpc_id, "cidr": cidr, "name": name}}
logger.info(f"서브넷 생성 요청: {name} ({cidr})")
return self._post(url, payload)
def delete_subnet(self, subnet_id: str) -> dict:
"""서브넷 삭제"""
url = f"{self.vpc_url}/v2.0/vpcsubnets/{subnet_id}"
logger.info(f"서브넷 삭제 요청: {subnet_id}")
return self._delete(url)
# ==================== Routing Table ====================
def get_routing_table_list(self, detail: bool = True) -> dict:
"""라우팅 테이블 목록 조회"""
url = f"{self.vpc_url}/v2.0/routingtables"
params = {"detail": "true"} if detail else None
return self._get(url, params=params)
def get_routing_table_info(self, routingtable_id: str) -> dict:
"""라우팅 테이블 상세 조회"""
url = f"{self.vpc_url}/v2.0/routingtables/{routingtable_id}"
return self._get(url)
def get_default_routing_table_id(self) -> Optional[str]:
"""기본 라우팅 테이블 ID 조회"""
data = self.get_routing_table_list(detail=False)
tables = data.get("routingtables", [])
return tables[0].get("id") if tables else None
def create_routing_table(self, name: str, vpc_id: str, distributed: bool = True) -> dict:
"""
라우팅 테이블 생성
Args:
name: 라우팅 테이블 이름
vpc_id: VPC ID
distributed: 분산 라우팅 여부 (기본 True)
Returns:
dict: 생성된 라우팅 테이블 정보
"""
url = f"{self.vpc_url}/v2.0/routingtables"
payload = {
"routingtable": {
"name": name,
"vpc_id": vpc_id,
"distributed": str(distributed).lower(),
}
}
logger.info(f"라우팅 테이블 생성 요청: {name}")
return self._post(url, payload)
def delete_routing_table(self, routingtable_id: str) -> dict:
"""라우팅 테이블 삭제"""
url = f"{self.vpc_url}/v2.0/routingtables/{routingtable_id}"
logger.info(f"라우팅 테이블 삭제 요청: {routingtable_id}")
return self._delete(url)
def set_default_routing_table(self, routingtable_id: str) -> dict:
"""라우팅 테이블을 기본으로 설정"""
url = f"{self.vpc_url}/v2.0/routingtables/{routingtable_id}/set_as_default"
return self._put(url)
def attach_gateway_to_routing_table(self, routingtable_id: str, gateway_id: str) -> dict:
"""라우팅 테이블에 인터넷 게이트웨이 연결"""
url = f"{self.vpc_url}/v2.0/routingtables/{routingtable_id}/attach_gateway"
payload = {"gateway_id": gateway_id}
logger.info(f"게이트웨이 연결 요청: {routingtable_id} -> {gateway_id}")
return self._put(url, payload)
def detach_gateway_from_routing_table(self, routingtable_id: str) -> dict:
"""라우팅 테이블에서 인터넷 게이트웨이 분리"""
url = f"{self.vpc_url}/v2.0/routingtables/{routingtable_id}/detach_gateway"
return self._put(url)
def attach_routing_table_to_subnet(self, subnet_id: str, routingtable_id: str) -> dict:
"""서브넷에 라우팅 테이블 연결"""
url = f"{self.vpc_url}/v2.0/vpcsubnets/{subnet_id}/attach_routingtable"
payload = {"routingtable_id": routingtable_id}
return self._put(url, payload)
# ==================== Floating IP ====================
def get_external_network_id(self) -> dict:
"""외부 네트워크 ID 조회"""
url = f"{self.vpc_url}/v2.0/networks"
params = {"router:external": "True"}
return self._get(url, params=params)
def get_floating_ip_list(self) -> dict:
"""Floating IP 목록 조회"""
url = f"{self.vpc_url}/v2.0/floatingips"
return self._get(url)
def create_floating_ip(self, external_network_id: str) -> dict:
"""
Floating IP 생성
Args:
external_network_id: 외부 네트워크 ID
Returns:
dict: 생성된 Floating IP 정보
"""
url = f"{self.vpc_url}/v2.0/floatingips"
payload = {"floatingip": {"floating_network_id": external_network_id}}
logger.info("Floating IP 생성 요청")
return self._post(url, payload)
def delete_floating_ip(self, floating_ip_id: str) -> dict:
"""Floating IP 삭제"""
url = f"{self.vpc_url}/v2.0/floatingips/{floating_ip_id}"
logger.info(f"Floating IP 삭제 요청: {floating_ip_id}")
return self._delete(url)
def attach_floating_ip(self, floating_ip_id: str, port_id: str) -> dict:
"""Floating IP를 포트에 연결"""
url = f"{self.vpc_url}/v2.0/floatingips/{floating_ip_id}"
payload = {"floatingip": {"port_id": port_id}}
return self._put(url, payload)
def detach_floating_ip(self, floating_ip_id: str) -> dict:
"""Floating IP 연결 해제"""
url = f"{self.vpc_url}/v2.0/floatingips/{floating_ip_id}"
payload = {"floatingip": {"port_id": None}}
return self._put(url, payload)
# ==================== Security Group ====================
def get_security_group_list(self) -> dict:
"""보안 그룹 목록 조회"""
url = f"{self.vpc_url}/v2.0/security-groups"
return self._get(url)
def get_security_group_info(self, security_group_id: str) -> dict:
"""보안 그룹 상세 조회"""
url = f"{self.vpc_url}/v2.0/security-groups/{security_group_id}"
return self._get(url)
# ==================== Port (NIC) ====================
def get_port_list(self) -> dict:
"""포트 목록 조회"""
url = f"{self.vpc_url}/v2.0/ports"
return self._get(url)
def get_port_id_by_device(self, device_id: str) -> Optional[str]:
"""디바이스 ID로 포트 ID 조회"""
data = self.get_port_list()
ports = data.get("ports", [])
for port in ports:
if port.get("device_id", "").startswith(device_id):
return port.get("id")
return None