Files
cloud-server/src/connectors/aws.ts
kappa abe052b538 feat: 코드 품질 개선 및 추천 API 구현
## 주요 변경사항

### 신규 기능
- POST /recommend: 기술 스택 기반 인스턴스 추천 API
- 아시아 리전 필터링 (Seoul, Tokyo, Osaka, Singapore)
- 매칭 점수 알고리즘 (메모리 40%, vCPU 30%, 가격 20%, 스토리지 10%)

### 보안 강화 (Security 9.0/10)
- API Key 인증 + constant-time 비교 (타이밍 공격 방어)
- Rate Limiting: KV 기반 분산 처리, fail-closed 정책
- IP Spoofing 방지 (CF-Connecting-IP만 신뢰)
- 요청 본문 10KB 제한
- CORS + 보안 헤더 (CSP, HSTS, X-Frame-Options)

### 성능 최적화 (Performance 9.0/10)
- Generator 패턴: AWS pricing 메모리 95% 감소
- D1 batch 쿼리: N+1 문제 해결
- 복합 인덱스 추가 (migrations/002)

### 코드 품질 (QA 9.0/10)
- 127개 테스트 (vitest)
- 구조화된 로깅 (민감정보 마스킹)
- 상수 중앙화 (constants.ts)
- 입력 검증 유틸리티 (utils/validation.ts)

### Vultr 연동 수정
- relay 서버 헤더: Authorization: Bearer → X-API-Key

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 11:57:35 +09:00

522 lines
16 KiB
TypeScript

import type { RegionInput, InstanceTypeInput, InstanceFamily } from '../types';
import { VaultClient, VaultError } from './vault';
import { RateLimiter } from './base';
import { TIMEOUTS, HTTP_STATUS } from '../constants';
/**
* AWS connector error class
*/
export class AWSError extends Error {
constructor(
message: string,
public statusCode?: number,
public details?: unknown
) {
super(message);
this.name = 'AWSError';
}
}
/**
* AWS region data structure
*/
interface AWSRegion {
code: string;
name: string;
}
/**
* AWS instance type data from ec2.shop API
*/
interface AWSInstanceType {
InstanceType: string;
Memory: string; // e.g., "8 GiB"
VCPUS: number;
Storage: string;
Network: string;
Cost: number;
MonthlyPrice: number;
GPU: number | null;
SpotPrice: string | null;
}
/**
* ec2.shop API response structure
*/
interface EC2ShopResponse {
Prices: AWSInstanceType[];
}
/**
* AWS EC2 Connector
*
* Features:
* - Uses public ec2.shop API for instance type data
* - No authentication required for basic data
* - Rate limiting: 20 requests/second
* - Hardcoded region list (relatively static)
* - Comprehensive error handling
*
* @example
* const vault = new VaultClient(vaultUrl, vaultToken);
* const connector = new AWSConnector(vault);
* await connector.initialize();
* const regions = await connector.fetchRegions();
*/
export class AWSConnector {
readonly provider = 'aws';
private readonly instanceDataUrl = 'https://ec2.shop/?json';
private readonly rateLimiter: RateLimiter;
private readonly requestTimeout = TIMEOUTS.AWS_REQUEST;
/**
* AWS regions list (relatively static data)
* Based on AWS public region information
*/
private readonly awsRegions: AWSRegion[] = [
{ code: 'us-east-1', name: 'US East (N. Virginia)' },
{ code: 'us-east-2', name: 'US East (Ohio)' },
{ code: 'us-west-1', name: 'US West (N. California)' },
{ code: 'us-west-2', name: 'US West (Oregon)' },
{ code: 'eu-west-1', name: 'EU (Ireland)' },
{ code: 'eu-west-2', name: 'EU (London)' },
{ code: 'eu-west-3', name: 'EU (Paris)' },
{ code: 'eu-central-1', name: 'EU (Frankfurt)' },
{ code: 'eu-central-2', name: 'EU (Zurich)' },
{ code: 'eu-north-1', name: 'EU (Stockholm)' },
{ code: 'eu-south-1', name: 'EU (Milan)' },
{ code: 'eu-south-2', name: 'EU (Spain)' },
{ code: 'ap-northeast-1', name: 'Asia Pacific (Tokyo)' },
{ code: 'ap-northeast-2', name: 'Asia Pacific (Seoul)' },
{ code: 'ap-northeast-3', name: 'Asia Pacific (Osaka)' },
{ code: 'ap-southeast-1', name: 'Asia Pacific (Singapore)' },
{ code: 'ap-southeast-2', name: 'Asia Pacific (Sydney)' },
{ code: 'ap-southeast-3', name: 'Asia Pacific (Jakarta)' },
{ code: 'ap-southeast-4', name: 'Asia Pacific (Melbourne)' },
{ code: 'ap-south-1', name: 'Asia Pacific (Mumbai)' },
{ code: 'ap-south-2', name: 'Asia Pacific (Hyderabad)' },
{ code: 'ap-east-1', name: 'Asia Pacific (Hong Kong)' },
{ code: 'ca-central-1', name: 'Canada (Central)' },
{ code: 'ca-west-1', name: 'Canada (Calgary)' },
{ code: 'sa-east-1', name: 'South America (São Paulo)' },
{ code: 'af-south-1', name: 'Africa (Cape Town)' },
{ code: 'me-south-1', name: 'Middle East (Bahrain)' },
{ code: 'me-central-1', name: 'Middle East (UAE)' },
{ code: 'il-central-1', name: 'Israel (Tel Aviv)' },
];
constructor(private vaultClient: VaultClient) {
// Rate limit: 20 requests/second per region
// Use 10 tokens with 10/second refill to be conservative
this.rateLimiter = new RateLimiter(20, 10);
console.log('[AWSConnector] Initialized');
}
/**
* Initialize connector by fetching credentials from Vault
* Note: Currently not required for public API access,
* but included for future AWS API integration
*/
async initialize(): Promise<void> {
console.log('[AWSConnector] Fetching credentials from Vault');
try {
const credentials = await this.vaultClient.getCredentials(this.provider);
// AWS uses different credential keys
const awsCreds = credentials as unknown as {
aws_access_key_id?: string;
aws_secret_access_key?: string;
};
// Credentials loaded for future AWS API direct access
console.log('[AWSConnector] Credentials loaded successfully', {
hasAccessKey: !!awsCreds.aws_access_key_id,
hasSecretKey: !!awsCreds.aws_secret_access_key,
});
} catch (error) {
if (error instanceof VaultError) {
console.warn('[AWSConnector] Vault credentials not available, using public API only');
// Not critical for public API access
} else {
throw error;
}
}
}
/**
* Fetch all regions
* Returns hardcoded region list as AWS regions are relatively static
*
* @returns Array of AWS regions
*/
async fetchRegions(): Promise<AWSRegion[]> {
console.log('[AWSConnector] Fetching regions', { count: this.awsRegions.length });
return this.awsRegions;
}
/**
* Fetch all instance types from ec2.shop API
*
* @returns Array of AWS instance types
* @throws AWSError on API failures
*/
async fetchInstanceTypes(): Promise<AWSInstanceType[]> {
console.log('[AWSConnector] Fetching instance types from ec2.shop');
await this.rateLimiter.waitForToken();
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.requestTimeout);
const response = await fetch(this.instanceDataUrl, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new AWSError(
`Failed to fetch instance types: ${response.statusText}`,
response.status
);
}
const data = await response.json() as EC2ShopResponse;
const instances = data.Prices || [];
console.log('[AWSConnector] Instance types fetched', { count: instances.length });
return instances;
} catch (error) {
// Handle timeout
if (error instanceof Error && error.name === 'AbortError') {
console.error('[AWSConnector] Request timeout', { timeout: this.requestTimeout });
throw new AWSError(
`Request to ec2.shop API timed out after ${this.requestTimeout}ms`,
504
);
}
// Re-throw AWSError
if (error instanceof AWSError) {
throw error;
}
// Handle unexpected errors
console.error('[AWSConnector] Unexpected error', { error });
throw new AWSError(
`Failed to fetch instance types: ${error instanceof Error ? error.message : 'Unknown error'}`,
HTTP_STATUS.INTERNAL_ERROR,
error
);
}
}
/**
* Normalize AWS region data for database storage
*
* @param raw - Raw AWS region data
* @param providerId - Database provider ID
* @returns Normalized region data ready for insertion
*/
normalizeRegion(raw: AWSRegion, providerId: number): RegionInput {
return {
provider_id: providerId,
region_code: raw.code,
region_name: raw.name,
country_code: this.extractCountryCode(raw.code),
latitude: null, // AWS doesn't provide coordinates in basic data
longitude: null,
available: 1, // All listed regions are available
};
}
/**
* Normalize AWS instance type data for database storage
*
* @param raw - Raw AWS instance type data
* @param providerId - Database provider ID
* @returns Normalized instance type data ready for insertion
*/
normalizeInstance(raw: AWSInstanceType, providerId: number): InstanceTypeInput {
// Parse memory from string like "8 GiB" to MB
const memoryGib = parseFloat(raw.Memory);
const memoryMb = Number.isNaN(memoryGib) ? 0 : Math.round(memoryGib * 1024);
// Parse storage information
const storageGb = this.parseStorage(raw.Storage);
// Parse GPU information from instance type name
const { gpuCount, gpuType } = this.parseGpuInfo(raw.InstanceType);
// Validate GPU count - ensure it's a valid number
const rawGpuCount = typeof raw.GPU === 'number' ? raw.GPU : 0;
const finalGpuCount = Number.isNaN(rawGpuCount) ? gpuCount : rawGpuCount;
// Validate VCPU - ensure it's a valid number
const vcpu = raw.VCPUS && !Number.isNaN(raw.VCPUS) ? raw.VCPUS : 0;
// Convert all metadata values to primitives before JSON.stringify
const storageType = typeof raw.Storage === 'string' ? raw.Storage : String(raw.Storage ?? '');
const network = typeof raw.Network === 'string' ? raw.Network : String(raw.Network ?? '');
const hourlyPrice = typeof raw.Cost === 'number' ? raw.Cost : 0;
const monthlyPrice = typeof raw.MonthlyPrice === 'number' ? raw.MonthlyPrice : 0;
const spotPrice = typeof raw.SpotPrice === 'string' ? raw.SpotPrice : String(raw.SpotPrice ?? '');
return {
provider_id: providerId,
instance_id: raw.InstanceType,
instance_name: raw.InstanceType,
vcpu: vcpu,
memory_mb: memoryMb,
storage_gb: storageGb,
transfer_tb: null, // ec2.shop doesn't provide transfer limits
network_speed_gbps: this.parseNetworkSpeed(raw.Network),
gpu_count: finalGpuCount,
gpu_type: gpuType,
instance_family: this.mapInstanceFamily(raw.InstanceType),
metadata: JSON.stringify({
storage_type: storageType,
network: network,
hourly_price: hourlyPrice,
monthly_price: monthlyPrice,
spot_price: spotPrice,
}),
};
}
/**
* Extract country code from AWS region code
*
* @param regionCode - AWS region code (e.g., 'us-east-1')
* @returns Lowercase ISO alpha-2 country code or null
*/
private extractCountryCode(regionCode: string): string | null {
const countryMap: Record<string, string> = {
'us': 'us',
'eu': 'eu',
'ap': 'ap',
'ca': 'ca',
'sa': 'br',
'af': 'za',
'me': 'ae',
'il': 'il',
};
const prefix = regionCode.split('-')[0];
return countryMap[prefix] || null;
}
/**
* Parse storage information from AWS storage string
*
* @param storage - AWS storage string (e.g., "EBS only", "1 x 900 NVMe SSD", "2400 GB")
* @returns Storage size in GB or 0 if EBS only or parsing fails
*/
private parseStorage(storage: string): number {
if (!storage || storage.toLowerCase().includes('ebs only')) {
return 0; // EBS only instances have no instance storage
}
// Parse format like "1 x 900 NVMe SSD" or "2 x 1900 NVMe SSD"
const multiDiskMatch = storage.match(/(\d+)\s*x\s*(\d+)/);
if (multiDiskMatch) {
const count = parseInt(multiDiskMatch[1], 10);
const sizePerDisk = parseInt(multiDiskMatch[2], 10);
const totalStorage = count * sizePerDisk;
return Number.isNaN(totalStorage) ? 0 : totalStorage;
}
// Parse format like "2400 GB" or "500GB"
const singleSizeMatch = storage.match(/(\d+)\s*GB/i);
if (singleSizeMatch) {
const size = parseInt(singleSizeMatch[1], 10);
return Number.isNaN(size) ? 0 : size;
}
return 0;
}
/**
* Parse network speed from AWS network string
*
* @param network - AWS network string (e.g., "Up to 5 Gigabit", "25 Gigabit")
* @returns Network speed in Gbps or null
*/
private parseNetworkSpeed(network: string): number | null {
if (!network) {
return null;
}
const match = network.match(/(\d+)\s*Gigabit/i);
if (match) {
return parseInt(match[1], 10);
}
return null;
}
/**
* Parse GPU information from instance type name
*
* @param instanceType - AWS instance type name
* @returns GPU count and type
*/
private parseGpuInfo(instanceType: string): { gpuCount: number; gpuType: string | null } {
const typeLower = instanceType.toLowerCase();
// GPU instance families
if (typeLower.startsWith('p2.')) {
return { gpuCount: this.getGpuCount(instanceType, 'p2'), gpuType: 'NVIDIA K80' };
}
if (typeLower.startsWith('p3.')) {
return { gpuCount: this.getGpuCount(instanceType, 'p3'), gpuType: 'NVIDIA V100' };
}
if (typeLower.startsWith('p4.')) {
return { gpuCount: this.getGpuCount(instanceType, 'p4'), gpuType: 'NVIDIA A100' };
}
if (typeLower.startsWith('p5.')) {
return { gpuCount: this.getGpuCount(instanceType, 'p5'), gpuType: 'NVIDIA H100' };
}
if (typeLower.startsWith('g3.')) {
return { gpuCount: this.getGpuCount(instanceType, 'g3'), gpuType: 'NVIDIA M60' };
}
if (typeLower.startsWith('g4.')) {
return { gpuCount: this.getGpuCount(instanceType, 'g4'), gpuType: 'NVIDIA T4' };
}
if (typeLower.startsWith('g5.')) {
return { gpuCount: this.getGpuCount(instanceType, 'g5'), gpuType: 'NVIDIA A10G' };
}
if (typeLower.startsWith('inf')) {
return { gpuCount: this.getInferentiaCount(instanceType), gpuType: 'AWS Inferentia' };
}
if (typeLower.startsWith('trn')) {
return { gpuCount: this.getTrainiumCount(instanceType), gpuType: 'AWS Trainium' };
}
return { gpuCount: 0, gpuType: null };
}
/**
* Get GPU count based on instance size
*
* @param instanceType - Full instance type name
* @param family - Instance family prefix
* @returns Number of GPUs (always returns a valid number, defaults to 0)
*/
private getGpuCount(instanceType: string, _family: string): number {
const size = instanceType.split('.')[1];
if (!size) {
return 0;
}
// Common GPU counts by size
const gpuMap: Record<string, number> = {
'xlarge': 1,
'2xlarge': 1,
'4xlarge': 2,
'8xlarge': 4,
'16xlarge': 8,
'24xlarge': 8,
'48xlarge': 8,
};
const gpuCount = gpuMap[size];
return gpuCount !== undefined ? gpuCount : 0;
}
/**
* Get Inferentia accelerator count
*
* @param instanceType - Full instance type name
* @returns Number of Inferentia chips
*/
private getInferentiaCount(instanceType: string): number {
const size = instanceType.split('.')[1];
const infMap: Record<string, number> = {
'xlarge': 1,
'2xlarge': 1,
'6xlarge': 4,
'24xlarge': 16,
};
return infMap[size] || 1;
}
/**
* Get Trainium accelerator count
*
* @param instanceType - Full instance type name
* @returns Number of Trainium chips
*/
private getTrainiumCount(instanceType: string): number {
const size = instanceType.split('.')[1];
const trnMap: Record<string, number> = {
'2xlarge': 1,
'32xlarge': 16,
};
return trnMap[size] || 1;
}
/**
* Map AWS instance type to standard instance family
*
* @param instanceType - AWS instance type name
* @returns Standard instance family type
*/
private mapInstanceFamily(instanceType: string): InstanceFamily {
const family = instanceType.split('.')[0].toLowerCase();
// General purpose
if (family.match(/^[tm]\d+[a-z]?$/)) {
return 'general';
}
if (family.match(/^a\d+$/)) {
return 'general';
}
// Compute optimized
if (family.match(/^c\d+[a-z]?$/)) {
return 'compute';
}
// Memory optimized
if (family.match(/^[rx]\d+[a-z]?$/)) {
return 'memory';
}
if (family.match(/^u-\d+/)) {
return 'memory';
}
if (family.match(/^z\d+[a-z]?$/)) {
return 'memory';
}
// Storage optimized
if (family.match(/^[dhi]\d+[a-z]?$/)) {
return 'storage';
}
// GPU/accelerated computing
if (family.match(/^[pg]\d+[a-z]?$/)) {
return 'gpu';
}
if (family.match(/^(inf|trn|dl)\d*/)) {
return 'gpu';
}
// Default to general for unknown types
console.warn('[AWSConnector] Unknown instance family, defaulting to general', { type: instanceType });
return 'general';
}
}