## 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>
388 lines
10 KiB
Markdown
388 lines
10 KiB
Markdown
# 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;
|
|
```
|