feat: KRW 가격 지원 및 GPU/G8/VPU 인스턴스 추가
## KRW 가격 기능 - pricing 테이블에 hourly_price_krw, monthly_price_krw 컬럼 추가 - 부가세 10% + 영업이익 10% + 환율 적용 (기본 1450원) - 시간당: 1원 단위 반올림 (최소 1원) - 월간: 100원 단위 반올림 (최소 100원) - 환율/부가세/영업이익률 환경변수로 분리 (배포 없이 변경 가능) ## GPU/G8/VPU 인스턴스 지원 - gpu_instances, gpu_pricing 테이블 추가 - g8_instances, g8_pricing 테이블 추가 - vpu_instances, vpu_pricing 테이블 추가 - Linode/Vultr 커넥터에 GPU 동기화 로직 추가 ## 환경변수 추가 - KRW_EXCHANGE_RATE: 환율 (기본 1450) - KRW_VAT_RATE: 부가세율 (기본 1.1) - KRW_MARKUP_RATE: 영업이익률 (기본 1.1) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
387
GPU_SYNC_EXAMPLE.md
Normal file
387
GPU_SYNC_EXAMPLE.md
Normal file
@@ -0,0 +1,387 @@
|
||||
# GPU Sync Integration Example
|
||||
|
||||
This document shows how to integrate GPU instance syncing into the existing sync service.
|
||||
|
||||
## Modified Sync Flow
|
||||
|
||||
```typescript
|
||||
// src/services/sync.ts
|
||||
|
||||
import { GpuInstanceInput, GpuPricingInput } from '../types';
|
||||
|
||||
// In LinodeSync or similar sync class:
|
||||
|
||||
async syncLinode(providerId: number): Promise<SyncResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// 1. Fetch all instance types
|
||||
const allInstances = await this.connector.fetchInstanceTypes();
|
||||
|
||||
// 2. Separate GPU and regular instances
|
||||
const gpuInstances = allInstances.filter(inst => inst.gpus > 0);
|
||||
const regularInstances = allInstances.filter(inst => inst.gpus === 0);
|
||||
|
||||
// 3. Normalize regular instances (existing logic)
|
||||
const normalizedRegular = regularInstances.map(inst =>
|
||||
this.connector.normalizeInstance(inst, providerId)
|
||||
);
|
||||
|
||||
// 4. Normalize GPU instances (new logic)
|
||||
const normalizedGpu = gpuInstances.map(inst =>
|
||||
this.connector.normalizeGpuInstance(inst, providerId)
|
||||
);
|
||||
|
||||
// 5. Store regular instances (existing)
|
||||
const regularCount = await this.repos.instances.upsertMany(
|
||||
providerId,
|
||||
normalizedRegular
|
||||
);
|
||||
|
||||
// 6. Store GPU instances (new)
|
||||
const gpuCount = await this.repos.gpuInstances.upsertMany(
|
||||
providerId,
|
||||
normalizedGpu
|
||||
);
|
||||
|
||||
// 7. Handle pricing
|
||||
// GPU pricing is embedded in metadata, so we need to extract it
|
||||
await this.syncGpuPricing(providerId, normalizedGpu);
|
||||
|
||||
return {
|
||||
provider: 'linode',
|
||||
success: true,
|
||||
regions_synced: 0, // regions handled separately
|
||||
instances_synced: regularCount,
|
||||
gpu_instances_synced: gpuCount, // new field
|
||||
pricing_synced: 0, // pricing handled separately
|
||||
duration_ms: Date.now() - startTime
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Linode sync failed', { error });
|
||||
return {
|
||||
provider: 'linode',
|
||||
success: false,
|
||||
regions_synced: 0,
|
||||
instances_synced: 0,
|
||||
gpu_instances_synced: 0,
|
||||
pricing_synced: 0,
|
||||
duration_ms: Date.now() - startTime,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// New method to sync GPU pricing
|
||||
private async syncGpuPricing(
|
||||
providerId: number,
|
||||
gpuInstances: GpuInstanceInput[]
|
||||
): Promise<number> {
|
||||
const regions = await this.repos.regions.findByProvider(providerId);
|
||||
const pricingData: GpuPricingInput[] = [];
|
||||
|
||||
// For each GPU instance
|
||||
for (const gpuInstance of gpuInstances) {
|
||||
// Parse metadata to get pricing
|
||||
const metadata = gpuInstance.metadata
|
||||
? JSON.parse(gpuInstance.metadata)
|
||||
: null;
|
||||
|
||||
if (!metadata || !metadata.hourly_price || !metadata.monthly_price) {
|
||||
this.logger.warn('GPU instance missing pricing in metadata', {
|
||||
instance_id: gpuInstance.instance_id
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get DB ID for this GPU instance
|
||||
const dbInstance = await this.repos.gpuInstances.findByInstanceId(
|
||||
providerId,
|
||||
gpuInstance.instance_id
|
||||
);
|
||||
|
||||
if (!dbInstance) {
|
||||
this.logger.warn('GPU instance not found in DB after upsert', {
|
||||
instance_id: gpuInstance.instance_id
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create pricing records for all regions
|
||||
// (Linode prices are consistent across regions)
|
||||
for (const region of regions) {
|
||||
pricingData.push({
|
||||
gpu_instance_id: dbInstance.id,
|
||||
region_id: region.id,
|
||||
hourly_price: metadata.hourly_price,
|
||||
monthly_price: metadata.monthly_price,
|
||||
currency: 'USD',
|
||||
available: 1
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Batch insert pricing
|
||||
return await this.repos.gpuPricing.upsertMany(pricingData);
|
||||
}
|
||||
```
|
||||
|
||||
## Alternative: Separate GPU Sync Method
|
||||
|
||||
If you prefer to keep GPU sync completely separate:
|
||||
|
||||
```typescript
|
||||
// src/services/sync.ts
|
||||
|
||||
/**
|
||||
* Sync GPU instances separately from regular instances
|
||||
*/
|
||||
async syncGpuInstances(providerId: number): Promise<number> {
|
||||
this.logger.info('Syncing GPU instances', { providerId });
|
||||
|
||||
try {
|
||||
// Fetch all instances
|
||||
const allInstances = await this.connector.fetchInstanceTypes();
|
||||
|
||||
// Filter GPU only
|
||||
const gpuInstances = allInstances.filter(inst => inst.gpus > 0);
|
||||
|
||||
this.logger.info('Found GPU instances', { count: gpuInstances.length });
|
||||
|
||||
if (gpuInstances.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Normalize
|
||||
const normalized = gpuInstances.map(inst =>
|
||||
this.connector.normalizeGpuInstance(inst, providerId)
|
||||
);
|
||||
|
||||
// Store
|
||||
const count = await this.repos.gpuInstances.upsertMany(
|
||||
providerId,
|
||||
normalized
|
||||
);
|
||||
|
||||
// Sync pricing
|
||||
await this.syncGpuPricing(providerId, normalized);
|
||||
|
||||
this.logger.info('GPU instances synced', { count });
|
||||
return count;
|
||||
} catch (error) {
|
||||
this.logger.error('GPU sync failed', {
|
||||
providerId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage in Scheduled Sync
|
||||
|
||||
```typescript
|
||||
// In your cron handler or sync orchestrator:
|
||||
|
||||
export default {
|
||||
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) {
|
||||
const repos = new RepositoryFactory(env.DB);
|
||||
|
||||
// Get Linode provider
|
||||
const provider = await repos.providers.findByName('linode');
|
||||
if (!provider) {
|
||||
throw new Error('Linode provider not found');
|
||||
}
|
||||
|
||||
// Initialize connector
|
||||
const vault = new VaultClient(env.VAULT_URL, env.VAULT_TOKEN);
|
||||
const connector = new LinodeConnector(vault, env);
|
||||
await connector.initialize();
|
||||
|
||||
// Sync regular instances
|
||||
await syncService.syncLinode(provider.id);
|
||||
|
||||
// Sync GPU instances (if separate)
|
||||
await syncService.syncGpuInstances(provider.id);
|
||||
|
||||
// Or use combined sync that handles both
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Query Examples
|
||||
|
||||
### Find Available GPU Types
|
||||
```typescript
|
||||
const gpuTypes = await repos.gpuInstances.getAvailableGpuTypes();
|
||||
// Returns: ['NVIDIA A100', 'NVIDIA RTX6000', 'NVIDIA V100']
|
||||
```
|
||||
|
||||
### Search for Specific GPU Configuration
|
||||
```typescript
|
||||
const instances = await repos.gpuInstances.search({
|
||||
providerId: linodeProviderId,
|
||||
minGpuCount: 2,
|
||||
gpuType: 'NVIDIA A100',
|
||||
minMemoryMb: 65536 // 64GB RAM minimum
|
||||
});
|
||||
```
|
||||
|
||||
### Get GPU Pricing for Region
|
||||
```typescript
|
||||
const gpuInstance = await repos.gpuInstances.findByInstanceId(
|
||||
providerId,
|
||||
'g1-gpu-rtx6000-1'
|
||||
);
|
||||
|
||||
if (gpuInstance) {
|
||||
const pricing = await repos.gpuPricing.findByGpuInstance(gpuInstance.id);
|
||||
console.log('Available in regions:', pricing.length);
|
||||
}
|
||||
```
|
||||
|
||||
### Find Cheapest GPU Option
|
||||
```typescript
|
||||
const cheapestGpu = await repos.gpuPricing.searchByPriceRange(
|
||||
undefined, // no min
|
||||
2.0 // max $2/hour
|
||||
);
|
||||
|
||||
// Get instance details
|
||||
for (const price of cheapestGpu) {
|
||||
const instance = await repos.gpuInstances.findById(price.gpu_instance_id);
|
||||
console.log(`${instance.instance_name}: $${price.hourly_price}/hr`);
|
||||
}
|
||||
```
|
||||
|
||||
## API Endpoint Example (Optional)
|
||||
|
||||
```typescript
|
||||
// src/routes/gpu.ts
|
||||
|
||||
import { Router } from 'itty-router';
|
||||
import { Env } from '../types';
|
||||
import { RepositoryFactory } from '../repositories';
|
||||
|
||||
const router = Router({ base: '/gpu' });
|
||||
|
||||
/**
|
||||
* GET /gpu/instances
|
||||
* Query GPU instances with filters
|
||||
*/
|
||||
router.get('/instances', async (request, env: Env) => {
|
||||
const url = new URL(request.url);
|
||||
const gpuType = url.searchParams.get('gpu_type');
|
||||
const minGpuCount = url.searchParams.get('min_gpu_count');
|
||||
const providerId = url.searchParams.get('provider_id');
|
||||
|
||||
const repos = new RepositoryFactory(env.DB);
|
||||
|
||||
const instances = await repos.gpuInstances.search({
|
||||
providerId: providerId ? parseInt(providerId) : undefined,
|
||||
gpuType: gpuType || undefined,
|
||||
minGpuCount: minGpuCount ? parseInt(minGpuCount) : undefined
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({ data: instances }), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /gpu/types
|
||||
* Get available GPU types
|
||||
*/
|
||||
router.get('/types', async (request, env: Env) => {
|
||||
const repos = new RepositoryFactory(env.DB);
|
||||
const types = await repos.gpuInstances.getAvailableGpuTypes();
|
||||
|
||||
return new Response(JSON.stringify({ data: types }), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Test Example
|
||||
```typescript
|
||||
// src/repositories/gpu-instances.test.ts
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { GpuInstancesRepository } from './gpu-instances';
|
||||
import { createMockD1 } from '../test-utils/mock-d1';
|
||||
|
||||
describe('GpuInstancesRepository', () => {
|
||||
let repo: GpuInstancesRepository;
|
||||
let mockDb: D1Database;
|
||||
|
||||
beforeEach(() => {
|
||||
mockDb = createMockD1();
|
||||
repo = new GpuInstancesRepository(mockDb);
|
||||
});
|
||||
|
||||
it('should find GPU instances by type', async () => {
|
||||
const instances = await repo.findByGpuType('NVIDIA A100');
|
||||
expect(instances).toBeDefined();
|
||||
});
|
||||
|
||||
it('should filter by minimum GPU count', async () => {
|
||||
const instances = await repo.search({ minGpuCount: 2 });
|
||||
expect(instances.every(inst => inst.gpu_count >= 2)).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Migration Notes
|
||||
|
||||
### Running the Migration
|
||||
```bash
|
||||
# Local
|
||||
npm run db:init
|
||||
|
||||
# Remote (Production)
|
||||
npm run db:init:remote
|
||||
```
|
||||
|
||||
### Data Validation Query
|
||||
```sql
|
||||
-- Check GPU instances were separated correctly
|
||||
SELECT
|
||||
'Regular' as type,
|
||||
COUNT(*) as count,
|
||||
SUM(CASE WHEN gpu_count > 0 THEN 1 ELSE 0 END) as gpu_count_error
|
||||
FROM instance_types
|
||||
UNION ALL
|
||||
SELECT
|
||||
'GPU' as type,
|
||||
COUNT(*) as count,
|
||||
SUM(CASE WHEN gpu_count = 0 THEN 1 ELSE 0 END) as zero_gpu_error
|
||||
FROM gpu_instances;
|
||||
|
||||
-- Should show:
|
||||
-- Regular: count > 0, gpu_count_error = 0
|
||||
-- GPU: count > 0, zero_gpu_error = 0
|
||||
```
|
||||
|
||||
### Backfill Existing GPU Data (If Needed)
|
||||
```sql
|
||||
-- If you already have GPU instances in instance_types table,
|
||||
-- you can migrate them:
|
||||
INSERT INTO gpu_instances (
|
||||
provider_id, instance_id, instance_name, vcpu, memory_mb,
|
||||
storage_gb, transfer_tb, network_speed_gbps, gpu_count,
|
||||
gpu_type, gpu_memory_gb, metadata, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
provider_id, instance_id, instance_name, vcpu, memory_mb,
|
||||
storage_gb, transfer_tb, network_speed_gbps, gpu_count,
|
||||
gpu_type, NULL as gpu_memory_gb, metadata, created_at, updated_at
|
||||
FROM instance_types
|
||||
WHERE gpu_count > 0;
|
||||
|
||||
-- Then remove from regular instances
|
||||
DELETE FROM instance_types WHERE gpu_count > 0;
|
||||
```
|
||||
Reference in New Issue
Block a user