From 1e750a863b2a76cf769f77e36a86020a81abd762 Mon Sep 17 00:00:00 2001 From: kappa Date: Mon, 26 Jan 2026 00:35:59 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EC=B6=94=EC=B2=9C=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 삭제된 파일: - src/routes/recommend.ts - src/services/recommendation.ts - src/services/recommendation.test.ts - src/services/stackConfig.ts - src/services/regionFilter.ts 수정된 파일: - src/index.ts: /recommend 라우트 제거 - src/routes/index.ts: handleRecommend export 제거 - src/constants.ts: RECOMMENDATIONS TTL, rate limit 제거 - src/middleware/rateLimit.ts: /recommend 설정 제거 - src/types.ts: 추천 관련 타입 제거 - scripts/e2e-tester.ts: recommend 시나리오 제거 - scripts/api-tester.ts: recommend 테스트 제거 Co-Authored-By: Claude Opus 4.5 --- scripts/api-tester.ts | 127 -------- scripts/e2e-tester.ts | 129 +------- src/constants.ts | 4 - src/index.ts | 7 +- src/middleware/rateLimit.ts | 4 - src/routes/index.ts | 1 - src/routes/recommend.ts | 283 ----------------- src/services/recommendation.test.ts | 474 ---------------------------- src/services/recommendation.ts | 405 ------------------------ src/services/regionFilter.ts | 67 ---- src/services/stackConfig.ts | 93 ------ src/types.ts | 79 ----- 12 files changed, 13 insertions(+), 1660 deletions(-) delete mode 100644 src/routes/recommend.ts delete mode 100644 src/services/recommendation.test.ts delete mode 100644 src/services/recommendation.ts delete mode 100644 src/services/regionFilter.ts delete mode 100644 src/services/stackConfig.ts diff --git a/scripts/api-tester.ts b/scripts/api-tester.ts index a6603b2..1cefd31 100644 --- a/scripts/api-tester.ts +++ b/scripts/api-tester.ts @@ -281,38 +281,6 @@ function validateSyncResponse(data: unknown): boolean | string { return true; } -function validateRecommendResponse(data: unknown): boolean | string { - if (typeof data !== 'object' || data === null) { - return 'Response is not an object'; - } - - const response = data as Record; - - if (!response.success) { - return 'Response success field is false or missing'; - } - - if (!response.data || typeof response.data !== 'object') { - return 'Missing or invalid data field'; - } - - const responseData = response.data as Record; - - if (!Array.isArray(responseData.recommendations)) { - return 'Missing or invalid recommendations array'; - } - - if (!responseData.requirements || typeof responseData.requirements !== 'object') { - return 'Missing or invalid requirements field'; - } - - if (!responseData.metadata || typeof responseData.metadata !== 'object') { - return 'Missing or invalid metadata field'; - } - - return true; -} - // ============================================================ // Test Suites // ============================================================ @@ -484,95 +452,6 @@ async function testSyncEndpoint(): Promise { return tests; } -async function testRecommendEndpoint(): Promise { - console.log(color('\n📍 Testing /recommend', colors.cyan)); - - const tests: TestResult[] = []; - - // Test basic recommendation - tests.push( - await testWithDelay('POST /recommend (nginx+mysql)', '/recommend', { - method: 'POST', - headers: { 'X-API-Key': API_KEY }, - body: { - stack: ['nginx', 'mysql'], - scale: 'small', - }, - expectStatus: 200, - validateResponse: validateRecommendResponse, - }) - ); - - // Test with budget constraint - tests.push( - await testWithDelay('POST /recommend (with budget)', '/recommend', { - method: 'POST', - headers: { 'X-API-Key': API_KEY }, - body: { - stack: ['nginx', 'mysql', 'redis'], - scale: 'medium', - budget_max: 100, - }, - expectStatus: 200, - validateResponse: validateRecommendResponse, - }) - ); - - // Test large scale - tests.push( - await testWithDelay('POST /recommend (large scale)', '/recommend', { - method: 'POST', - headers: { 'X-API-Key': API_KEY }, - body: { - stack: ['nginx', 'nodejs', 'postgresql', 'redis'], - scale: 'large', - }, - expectStatus: 200, - validateResponse: validateRecommendResponse, - }) - ); - - // Test invalid stack (should fail with 400) - tests.push( - await testWithDelay('POST /recommend (invalid stack - error)', '/recommend', { - method: 'POST', - headers: { 'X-API-Key': API_KEY }, - body: { - stack: ['invalid-technology'], - scale: 'small', - }, - expectStatus: 400, // Invalid stack returns 400 Bad Request - }) - ); - - // Test invalid scale (should fail with 400) - tests.push( - await testWithDelay('POST /recommend (invalid scale - error)', '/recommend', { - method: 'POST', - headers: { 'X-API-Key': API_KEY }, - body: { - stack: ['nginx'], - scale: 'invalid', - }, - expectStatus: 400, // Invalid scale returns 400 Bad Request - }) - ); - - // Test without auth (should fail) - tests.push( - await testWithDelay('POST /recommend (no auth - error)', '/recommend', { - method: 'POST', - body: { - stack: ['nginx'], - scale: 'small', - }, - expectStatus: 401, - }) - ); - - return tests; -} - // ============================================================ // Test Runner // ============================================================ @@ -639,12 +518,6 @@ async function runTests(): Promise { allResults.push(...syncResults); } - if (!TARGET_ENDPOINT || TARGET_ENDPOINT === '/recommend') { - const recommendResults = await testRecommendEndpoint(); - recommendResults.forEach(printTestResult); - allResults.push(...recommendResults); - } - const duration = Date.now() - startTime; const passed = allResults.filter(r => r.passed).length; const failed = allResults.filter(r => !r.passed).length; diff --git a/scripts/e2e-tester.ts b/scripts/e2e-tester.ts index 5d07dad..5dc8614 100755 --- a/scripts/e2e-tester.ts +++ b/scripts/e2e-tester.ts @@ -121,119 +121,15 @@ function sleep(ms: number): Promise { // ============================================================ /** - * Scenario 1: WordPress Server Recommendation → Detail Lookup - * - * Flow: - * 1. POST /recommend with WordPress stack (nginx, php-fpm, mysql) - * 2. Extract first recommended instance ID - * 3. GET /instances with instance_id filter - * 4. Validate specs meet requirements - */ -async function scenario1WordPress(context: TestContext, dryRun: boolean): Promise { - console.log('\n▶️ Scenario 1: WordPress Server Recommendation → Detail Lookup'); - - if (dryRun) { - console.log(' [DRY RUN] Would execute:'); - console.log(' 1. POST /recommend with stack: nginx, php-fpm, mysql'); - console.log(' 2. Extract instance_id from first recommendation'); - console.log(' 3. GET /instances?instance_id={id}'); - console.log(' 4. Validate memory >= 3072MB, vCPU >= 2'); - return true; - } - - try { - // Step 1: Request recommendation - logStep(1, 'Request WordPress server recommendation...'); - const recommendResp = await apiRequest('/recommend', { - method: 'POST', - body: JSON.stringify({ - stack: ['nginx', 'php-fpm', 'mysql'], - scale: 'medium', - }), - }); - - logResponse('POST', '/recommend', recommendResp.status, recommendResp.duration); - - if (recommendResp.status !== 200) { - console.log(` ❌ Expected 200, got ${recommendResp.status}`); - return false; - } - - const recommendData = recommendResp.data as { - success: boolean; - data?: { - recommendations?: Array<{ instance: string; provider: string; price: { monthly: number }; region: string }>; - }; - }; - - if (!recommendData.success || !recommendData.data?.recommendations?.[0]) { - console.log(' ❌ No recommendations returned'); - return false; - } - - const firstRec = recommendData.data.recommendations[0]; - console.log(` Recommended: ${firstRec.instance} ($${firstRec.price.monthly}/mo) in ${firstRec.region}`); - - // Step 2: Extract instance identifier (we'll use provider + instance name for search) - const instanceName = firstRec.instance; - const provider = firstRec.provider; - context.recommendedInstanceId = instanceName; - - // Step 3: Fetch instance details - logStep(2, 'Fetch instance details...'); - const detailsResp = await apiRequest( - `/instances?provider=${encodeURIComponent(provider)}&limit=100`, - { method: 'GET' } - ); - - logResponse('GET', '/instances', detailsResp.status, detailsResp.duration); - - if (detailsResp.status !== 200) { - console.log(` ❌ Expected 200, got ${detailsResp.status}`); - return false; - } - - const detailsData = detailsResp.data as { - success: boolean; - data?: { - instances?: Array<{ instance_name: string; vcpu: number; memory_mb: number }>; - }; - }; - - const instance = detailsData.data?.instances?.find((i) => i.instance_name === instanceName); - - if (!instance) { - console.log(` ❌ Instance ${instanceName} not found in details`); - return false; - } - - // Step 4: Validate specs - logStep(3, 'Validate specs meet requirements...'); - const memoryOk = instance.memory_mb >= 3072; // nginx 256 + php-fpm 1024 + mysql 2048 + OS 768 = 4096 - const vcpuOk = instance.vcpu >= 2; - - logValidation(memoryOk, `Memory: ${instance.memory_mb}MB >= 3072MB required`); - logValidation(vcpuOk, `vCPU: ${instance.vcpu} >= 2 required`); - - const passed = memoryOk && vcpuOk; - console.log(` ${passed ? '✅' : '❌'} Scenario ${passed ? 'PASSED' : 'FAILED'} (${recommendResp.duration + detailsResp.duration}ms)`); - return passed; - } catch (error) { - console.log(` ❌ Scenario FAILED with error: ${error instanceof Error ? error.message : String(error)}`); - return false; - } -} - -/** - * Scenario 2: Budget-Constrained Instance Search + * Scenario 1: Budget-Constrained Instance Search * * Flow: * 1. GET /instances?max_price=50&sort_by=price&order=asc * 2. Validate all results <= $50/month * 3. Validate price sorting is correct */ -async function scenario2Budget(context: TestContext, dryRun: boolean): Promise { - console.log('\n▶️ Scenario 2: Budget-Constrained Instance Search'); +async function scenario1Budget(context: TestContext, dryRun: boolean): Promise { + console.log('\n▶️ Scenario 1: Budget-Constrained Instance Search'); if (dryRun) { console.log(' [DRY RUN] Would execute:'); @@ -297,15 +193,15 @@ async function scenario2Budget(context: TestContext, dryRun: boolean): Promise { - console.log('\n▶️ Scenario 3: Cross-Region Price Comparison (Tokyo vs Seoul)'); +async function scenario2RegionCompare(context: TestContext, dryRun: boolean): Promise { + console.log('\n▶️ Scenario 2: Cross-Region Price Comparison (Tokyo vs Seoul)'); if (dryRun) { console.log(' [DRY RUN] Would execute:'); @@ -395,15 +291,15 @@ async function scenario3RegionCompare(context: TestContext, dryRun: boolean): Pr } /** - * Scenario 4: Provider Sync and Data Verification + * Scenario 3: Provider Sync and Data Verification * * Flow: * 1. POST /sync with provider: linode * 2. GET /health to check sync_status * 3. GET /instances?provider=linode to verify data exists */ -async function scenario4ProviderSync(context: TestContext, dryRun: boolean): Promise { - console.log('\n▶️ Scenario 4: Provider Sync and Data Verification'); +async function scenario3ProviderSync(context: TestContext, dryRun: boolean): Promise { + console.log('\n▶️ Scenario 3: Provider Sync and Data Verification'); if (dryRun) { console.log(' [DRY RUN] Would execute:'); @@ -562,10 +458,9 @@ interface ScenarioFunction { } const scenarios: Record = { - wordpress: scenario1WordPress, - budget: scenario2Budget, - region: scenario3RegionCompare, - sync: scenario4ProviderSync, + budget: scenario1Budget, + region: scenario2RegionCompare, + sync: scenario3ProviderSync, ratelimit: scenario5RateLimit, }; diff --git a/src/constants.ts b/src/constants.ts index d40a307..810288b 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -29,8 +29,6 @@ export const CACHE_TTL = { HEALTH: 30, /** Cache TTL for pricing data (1 hour) */ PRICING: 3600, - /** Cache TTL for recommendation results (10 minutes) */ - RECOMMENDATIONS: 600, /** Default cache TTL (5 minutes) */ DEFAULT: 300, } as const; @@ -61,8 +59,6 @@ export const RATE_LIMIT_DEFAULTS = { MAX_REQUESTS_INSTANCES: 100, /** Maximum requests per window for /sync endpoint */ MAX_REQUESTS_SYNC: 10, - /** Maximum requests per window for /recommend endpoint */ - MAX_REQUESTS_RECOMMEND: 50, } as const; // ============================================================ diff --git a/src/index.ts b/src/index.ts index be702a1..9e41a43 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ */ import { Env } from './types'; -import { handleSync, handleInstances, handleHealth, handleRecommend } from './routes'; +import { handleSync, handleInstances, handleHealth } from './routes'; import { authenticateRequest, verifyApiKey, @@ -175,11 +175,6 @@ export default { return addSecurityHeaders(await handleSync(request, env), corsOrigin, requestId); } - // Tech stack recommendation - if (path === '/recommend' && request.method === 'POST') { - return addSecurityHeaders(await handleRecommend(request, env), corsOrigin, requestId); - } - // 404 Not Found return addSecurityHeaders( Response.json( diff --git a/src/middleware/rateLimit.ts b/src/middleware/rateLimit.ts index ce4f8ab..2de6ad2 100644 --- a/src/middleware/rateLimit.ts +++ b/src/middleware/rateLimit.ts @@ -67,10 +67,6 @@ const RATE_LIMITS: Record = { maxRequests: RATE_LIMIT_DEFAULTS.MAX_REQUESTS_SYNC, windowMs: RATE_LIMIT_DEFAULTS.WINDOW_MS, }, - '/recommend': { - maxRequests: RATE_LIMIT_DEFAULTS.MAX_REQUESTS_RECOMMEND, - windowMs: RATE_LIMIT_DEFAULTS.WINDOW_MS, - }, }; /** diff --git a/src/routes/index.ts b/src/routes/index.ts index 36bb910..e2280ef 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -6,4 +6,3 @@ export { handleSync } from './sync'; export { handleInstances } from './instances'; export { handleHealth } from './health'; -export { handleRecommend } from './recommend'; diff --git a/src/routes/recommend.ts b/src/routes/recommend.ts deleted file mode 100644 index 429096e..0000000 --- a/src/routes/recommend.ts +++ /dev/null @@ -1,283 +0,0 @@ -/** - * Recommendation Route Handler - * - * Endpoint for getting cloud instance recommendations based on tech stack. - * Validates request parameters and returns ranked instance recommendations. - */ - -import type { Env, ScaleType } from '../types'; -import { RecommendationService } from '../services/recommendation'; -import { validateStack, STACK_REQUIREMENTS } from '../services/stackConfig'; -import { getGlobalCacheService } from '../services/cache'; -import { logger } from '../utils/logger'; -import { HTTP_STATUS, CACHE_TTL, REQUEST_LIMITS } from '../constants'; -import { - parseJsonBody, - validateStringArray, - validateEnum, - validatePositiveNumber, - createErrorResponse, -} from '../utils/validation'; - -/** - * Request body interface for recommendation endpoint - */ -interface RecommendRequestBody { - stack?: unknown; - scale?: unknown; - budget_max?: unknown; -} - -/** - * Supported scale types - */ -const SUPPORTED_SCALES: readonly ScaleType[] = ['small', 'medium', 'large'] as const; - -/** - * Handle POST /recommend endpoint - * - * @param request - HTTP request object - * @param env - Cloudflare Worker environment bindings - * @returns JSON response with recommendations - * - * @example - * POST /recommend - * { - * "stack": ["nginx", "mysql", "redis"], - * "scale": "medium", - * "budget_max": 100 - * } - */ -export async function handleRecommend(request: Request, env: Env): Promise { - const startTime = Date.now(); - - logger.info('[Recommend] Request received'); - - try { - // 1. Validate request size to prevent memory exhaustion attacks - const contentLength = request.headers.get('content-length'); - if (contentLength) { - const bodySize = parseInt(contentLength, 10); - if (isNaN(bodySize) || bodySize > REQUEST_LIMITS.MAX_BODY_SIZE) { - logger.error('[Recommend] Request body too large', { - contentLength: bodySize, - maxAllowed: REQUEST_LIMITS.MAX_BODY_SIZE, - }); - return Response.json( - { - success: false, - error: { - code: 'PAYLOAD_TOO_LARGE', - message: `Request body exceeds maximum size of ${REQUEST_LIMITS.MAX_BODY_SIZE} bytes`, - details: { - max_size_bytes: REQUEST_LIMITS.MAX_BODY_SIZE, - received_bytes: bodySize, - }, - }, - }, - { status: HTTP_STATUS.PAYLOAD_TOO_LARGE } - ); - } - } - - // 2. Parse request body - const parseResult = await parseJsonBody(request); - if (!parseResult.success) { - logger.error('[Recommend] JSON parsing failed', { - code: parseResult.error.code, - message: parseResult.error.message, - }); - return createErrorResponse(parseResult.error); - } - - const body = parseResult.data; - - // 3. Validate stack parameter - const stackResult = validateStringArray(body.stack, 'stack'); - if (!stackResult.success) { - logger.error('[Recommend] Stack validation failed', { - code: stackResult.error.code, - message: stackResult.error.message, - }); - // Add supported stacks to error details - const enrichedError = { - ...stackResult.error, - details: { - ...((stackResult.error.details as object) || {}), - supported: Object.keys(STACK_REQUIREMENTS), - }, - }; - return createErrorResponse(enrichedError); - } - - const stack = stackResult.data; - - // 4. Validate scale parameter - const scaleResult = validateEnum(body.scale, 'scale', SUPPORTED_SCALES); - if (!scaleResult.success) { - logger.error('[Recommend] Scale validation failed', { - code: scaleResult.error.code, - message: scaleResult.error.message, - }); - return createErrorResponse(scaleResult.error); - } - - const scale = scaleResult.data; - - // 5. Validate budget_max parameter (optional) - let budgetMax: number | undefined; - if (body.budget_max !== undefined) { - const budgetResult = validatePositiveNumber(body.budget_max, 'budget_max'); - if (!budgetResult.success) { - logger.error('[Recommend] Budget validation failed', { - code: budgetResult.error.code, - message: budgetResult.error.message, - }); - return createErrorResponse(budgetResult.error); - } - budgetMax = budgetResult.data; - } - - // 6. Validate stack components against supported technologies - const validation = validateStack(stack); - if (!validation.valid) { - logger.error('[Recommend] Unsupported stack components', { - invalidStacks: validation.invalidStacks, - }); - return createErrorResponse({ - code: 'INVALID_STACK', - message: `Unsupported stacks: ${validation.invalidStacks.join(', ')}`, - details: { - invalid: validation.invalidStacks, - supported: Object.keys(STACK_REQUIREMENTS), - }, - }); - } - - // 7. Initialize cache service and generate cache key - logger.info('[Recommend] Validation passed', { stack, scale, budgetMax }); - - const cacheService = getGlobalCacheService(CACHE_TTL.RECOMMENDATIONS, env.RATE_LIMIT_KV); - - // Generate cache key from sorted stack, scale, and budget - // Sort stack to ensure consistent cache keys regardless of order - const sortedStack = [...stack].sort(); - const cacheKey = cacheService.generateKey({ - endpoint: 'recommend', - stack: sortedStack.join(','), - scale, - budget_max: budgetMax ?? 'none', - }); - - logger.info('[Recommend] Cache key generated', { cacheKey }); - - // 8. Check cache first - interface CachedRecommendation { - recommendations: unknown[]; - stack_analysis: unknown; - metadata: { - cached?: boolean; - cache_age_seconds?: number; - cached_at?: string; - query_time_ms: number; - }; - } - - const cached = await cacheService.get(cacheKey); - - if (cached) { - logger.info('[Recommend] Cache hit', { - cacheKey, - age: cached.cache_age_seconds, - }); - - return Response.json( - { - success: true, - data: { - ...cached.data, - metadata: { - ...cached.data.metadata, - cached: true, - cache_age_seconds: cached.cache_age_seconds, - cached_at: cached.cached_at, - }, - }, - }, - { - status: HTTP_STATUS.OK, - headers: { - 'Cache-Control': `public, max-age=${CACHE_TTL.RECOMMENDATIONS}`, - }, - } - ); - } - - logger.info('[Recommend] Cache miss'); - - // 9. Call recommendation service - const service = new RecommendationService(env.DB); - const result = await service.recommend({ - stack, - scale, - budget_max: budgetMax, - }); - - const duration = Date.now() - startTime; - - logger.info('[Recommend] Recommendation completed', { - duration_ms: duration, - recommendations_count: result.recommendations.length, - }); - - // Prepare response data with metadata - const responseData = { - ...result, - metadata: { - cached: false, - query_time_ms: duration, - }, - }; - - // 10. Store result in cache - try { - await cacheService.set(cacheKey, responseData, CACHE_TTL.RECOMMENDATIONS); - } catch (error) { - // Graceful degradation: log error but don't fail the request - logger.error('[Recommend] Cache write failed', - error instanceof Error ? { message: error.message } : { error: String(error) }); - } - - return Response.json( - { - success: true, - data: responseData, - }, - { - status: HTTP_STATUS.OK, - headers: { - 'Cache-Control': `public, max-age=${CACHE_TTL.RECOMMENDATIONS}`, - }, - } - ); - } catch (error) { - logger.error('[Recommend] Unexpected error', { error }); - - const duration = Date.now() - startTime; - - return Response.json( - { - success: false, - error: { - code: 'INTERNAL_ERROR', - message: 'Recommendation request failed. Please try again later.', - request_id: crypto.randomUUID(), - details: { - duration_ms: duration, - }, - }, - }, - { status: HTTP_STATUS.INTERNAL_ERROR } - ); - } -} diff --git a/src/services/recommendation.test.ts b/src/services/recommendation.test.ts deleted file mode 100644 index b0f25bd..0000000 --- a/src/services/recommendation.test.ts +++ /dev/null @@ -1,474 +0,0 @@ -/** - * Recommendation Service Tests - * - * Tests the RecommendationService class for: - * - Score calculation algorithm - * - Stack validation and requirements calculation - * - Budget filtering - * - Asia-Pacific region filtering - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { RecommendationService } from './recommendation'; -import type { RecommendationRequest } from '../types'; - -/** - * Mock D1Database for testing - */ -const createMockD1Database = () => { - const mockPrepare = vi.fn().mockReturnValue({ - bind: vi.fn().mockReturnThis(), - all: vi.fn().mockResolvedValue({ - results: [], - }), - }); - - return { - prepare: mockPrepare, - dump: vi.fn(), - batch: vi.fn(), - exec: vi.fn(), - }; -}; - -/** - * Mock instance data for testing - */ -const createMockInstanceRow = (overrides = {}) => ({ - id: 1, - instance_id: 'test-instance', - instance_name: 'Standard-2GB', - vcpu: 2, - memory_mb: 2048, - storage_gb: 50, - metadata: null, - provider_name: 'linode', - region_code: 'ap-south-1', - region_name: 'Mumbai', - hourly_price: 0.015, - monthly_price: 10, - ...overrides, -}); - -describe('RecommendationService', () => { - let service: RecommendationService; - let mockDb: ReturnType; - - beforeEach(() => { - mockDb = createMockD1Database(); - service = new RecommendationService(mockDb as any); - }); - - describe('recommend', () => { - it('should validate stack components and throw error for invalid stacks', async () => { - const request: RecommendationRequest = { - stack: ['nginx', 'invalid-stack', 'unknown-tech'], - scale: 'small', - }; - - await expect(service.recommend(request)).rejects.toThrow( - 'Invalid stacks: invalid-stack, unknown-tech' - ); - }); - - it('should calculate resource requirements for valid stack', async () => { - const request: RecommendationRequest = { - stack: ['nginx', 'mysql'], - scale: 'medium', - }; - - // Mock database response with empty results - mockDb.prepare.mockReturnValue({ - bind: vi.fn().mockReturnThis(), - all: vi.fn().mockResolvedValue({ results: [] }), - }); - - const result = await service.recommend(request); - - // nginx (256MB) + mysql (2048MB) + OS overhead (768MB) = 3072MB - expect(result.requirements.min_memory_mb).toBe(3072); - // 3072MB / 2048 = 1.5, rounded up = 2 vCPU - expect(result.requirements.min_vcpu).toBe(2); - expect(result.requirements.breakdown).toHaveProperty('nginx'); - expect(result.requirements.breakdown).toHaveProperty('mysql'); - expect(result.requirements.breakdown).toHaveProperty('os_overhead'); - }); - - it('should return top 5 recommendations sorted by match score', async () => { - const request: RecommendationRequest = { - stack: ['nginx'], - scale: 'small', - }; - - // Mock database with 10 instances - const mockInstances = Array.from({ length: 10 }, (_, i) => - createMockInstanceRow({ - id: i + 1, - instance_name: `Instance-${i + 1}`, - vcpu: i % 4 + 1, - memory_mb: (i % 4 + 1) * 1024, - monthly_price: 10 + i * 5, - }) - ); - - mockDb.prepare.mockReturnValue({ - bind: vi.fn().mockReturnThis(), - all: vi.fn().mockResolvedValue({ results: mockInstances }), - }); - - const result = await service.recommend(request); - - // Should return max 5 recommendations - expect(result.recommendations).toHaveLength(5); - - // Should be sorted by match_score descending - for (let i = 0; i < result.recommendations.length - 1; i++) { - expect(result.recommendations[i].match_score).toBeGreaterThanOrEqual( - result.recommendations[i + 1].match_score - ); - } - - // Should have rank assigned (1-5) - expect(result.recommendations[0].rank).toBe(1); - expect(result.recommendations[4].rank).toBe(5); - }); - - it('should filter instances by budget when budget_max is specified', async () => { - const request: RecommendationRequest = { - stack: ['nginx'], - scale: 'small', - budget_max: 20, - }; - - mockDb.prepare.mockReturnValue({ - bind: vi.fn().mockReturnThis(), - all: vi.fn().mockResolvedValue({ results: [] }), - }); - - await service.recommend(request); - - // Verify SQL includes budget filter - const prepareCall = mockDb.prepare.mock.calls[0][0]; - expect(prepareCall).toContain('pr.monthly_price <= ?'); - }); - }); - - describe('input validation', () => { - it('should reject empty stack array', async () => { - const request: RecommendationRequest = { - stack: [], - scale: 'small', - }; - - await expect(service.recommend(request)).rejects.toThrow( - 'Stack must be a non-empty array' - ); - }); - - it('should reject non-array stack', async () => { - const request = { - stack: 'nginx' as any, - scale: 'small' as any, - }; - - await expect(service.recommend(request as any)).rejects.toThrow( - 'Stack must be a non-empty array' - ); - }); - - it('should reject invalid scale value', async () => { - const request = { - stack: ['nginx'], - scale: 'invalid-scale' as any, - }; - - await expect(service.recommend(request as any)).rejects.toThrow( - 'Invalid scale: invalid-scale' - ); - }); - - it('should reject negative budget_max', async () => { - const request: RecommendationRequest = { - stack: ['nginx'], - scale: 'small', - budget_max: -10, - }; - - await expect(service.recommend(request)).rejects.toThrow( - 'budget_max must be a positive number' - ); - }); - - it('should reject zero budget_max', async () => { - const request: RecommendationRequest = { - stack: ['nginx'], - scale: 'small', - budget_max: 0, - }; - - await expect(service.recommend(request)).rejects.toThrow( - 'budget_max must be a positive number' - ); - }); - - it('should reject excessive budget_max', async () => { - const request: RecommendationRequest = { - stack: ['nginx'], - scale: 'small', - budget_max: 150000, - }; - - await expect(service.recommend(request)).rejects.toThrow( - 'budget_max exceeds maximum allowed value' - ); - }); - - it('should accept valid budget_max at boundary', async () => { - const request: RecommendationRequest = { - stack: ['nginx'], - scale: 'small', - budget_max: 100000, - }; - - mockDb.prepare.mockReturnValue({ - bind: vi.fn().mockReturnThis(), - all: vi.fn().mockResolvedValue({ results: [] }), - }); - - await expect(service.recommend(request)).resolves.toBeDefined(); - }); - }); - - describe('scoreInstance (via recommend)', () => { - it('should score optimal memory fit with high score', async () => { - const request: RecommendationRequest = { - stack: ['nginx'], // min 128MB - scale: 'small', - }; - - // Memory ratio 1.4x (optimal range 1-1.5x): should get 40 points - const mockInstance = createMockInstanceRow({ - memory_mb: 1024, // nginx min is 128MB + 768MB OS = 896MB total, ratio = 1.14x - vcpu: 1, - monthly_price: 10, - }); - - mockDb.prepare.mockReturnValue({ - bind: vi.fn().mockReturnThis(), - all: vi.fn().mockResolvedValue({ results: [mockInstance] }), - }); - - const result = await service.recommend(request); - - // Should have high score for optimal fit - expect(result.recommendations[0].match_score).toBeGreaterThan(70); - expect(result.recommendations[0].pros).toContain('메모리 최적 적합'); - }); - - it('should penalize oversized instances', async () => { - const request: RecommendationRequest = { - stack: ['nginx'], // min 896MB total - scale: 'small', - }; - - // Memory ratio >2x: should get only 20 points for memory - const mockInstance = createMockInstanceRow({ - memory_mb: 4096, // Ratio = 4096/896 = 4.57x (oversized) - vcpu: 2, - monthly_price: 30, - }); - - mockDb.prepare.mockReturnValue({ - bind: vi.fn().mockReturnThis(), - all: vi.fn().mockResolvedValue({ results: [mockInstance] }), - }); - - const result = await service.recommend(request); - - // Should have cons about over-provisioning - expect(result.recommendations[0].cons).toContain('메모리 과다 프로비저닝'); - }); - - it('should give price efficiency bonus for budget-conscious instances', async () => { - const request: RecommendationRequest = { - stack: ['nginx'], - scale: 'small', - budget_max: 100, - }; - - // Price ratio 0.3 (30% of budget): should get 20 points - const mockInstance = createMockInstanceRow({ - memory_mb: 2048, - vcpu: 2, - monthly_price: 30, - }); - - mockDb.prepare.mockReturnValue({ - bind: vi.fn().mockReturnThis(), - all: vi.fn().mockResolvedValue({ results: [mockInstance] }), - }); - - const result = await service.recommend(request); - - // Should have pros about price efficiency - expect(result.recommendations[0].pros).toContain('예산 대비 저렴'); - }); - - it('should give storage bonus for instances with good storage', async () => { - const request: RecommendationRequest = { - stack: ['nginx'], - scale: 'small', - }; - - // Storage >= 80GB: should get 10 points - const mockInstanceWithStorage = createMockInstanceRow({ - memory_mb: 2048, - vcpu: 2, - storage_gb: 100, - monthly_price: 20, - }); - - mockDb.prepare.mockReturnValue({ - bind: vi.fn().mockReturnThis(), - all: vi.fn().mockResolvedValue({ results: [mockInstanceWithStorage] }), - }); - - const result = await service.recommend(request); - - // Should have pros about storage - expect(result.recommendations[0].pros.some((p) => p.includes('스토리지'))).toBe(true); - }); - - it('should note EBS storage separately for instances without storage', async () => { - const request: RecommendationRequest = { - stack: ['nginx'], - scale: 'small', - }; - - // Storage = 0: should have cons - const mockInstanceNoStorage = createMockInstanceRow({ - memory_mb: 2048, - vcpu: 2, - storage_gb: 0, - monthly_price: 20, - }); - - mockDb.prepare.mockReturnValue({ - bind: vi.fn().mockReturnThis(), - all: vi.fn().mockResolvedValue({ results: [mockInstanceNoStorage] }), - }); - - const result = await service.recommend(request); - - // Should have cons about separate storage - expect(result.recommendations[0].cons).toContain('EBS 스토리지 별도'); - }); - }); - - describe('getMonthlyPrice (via scoring)', () => { - it('should extract monthly price from monthly_price column', async () => { - const request: RecommendationRequest = { - stack: ['nginx'], - scale: 'small', - }; - - const mockInstance = createMockInstanceRow({ - monthly_price: 15, - hourly_price: null, - metadata: null, - }); - - mockDb.prepare.mockReturnValue({ - bind: vi.fn().mockReturnThis(), - all: vi.fn().mockResolvedValue({ results: [mockInstance] }), - }); - - const result = await service.recommend(request); - - expect(result.recommendations[0].price.monthly).toBe(15); - }); - - it('should extract monthly price from metadata JSON', async () => { - const request: RecommendationRequest = { - stack: ['nginx'], - scale: 'small', - }; - - const mockInstance = createMockInstanceRow({ - monthly_price: null, - hourly_price: null, - metadata: JSON.stringify({ monthly_price: 25 }), - }); - - mockDb.prepare.mockReturnValue({ - bind: vi.fn().mockReturnThis(), - all: vi.fn().mockResolvedValue({ results: [mockInstance] }), - }); - - const result = await service.recommend(request); - - expect(result.recommendations[0].price.monthly).toBe(25); - }); - - it('should calculate monthly price from hourly price', async () => { - const request: RecommendationRequest = { - stack: ['nginx'], - scale: 'small', - }; - - const mockInstance = createMockInstanceRow({ - monthly_price: null, - hourly_price: 0.02, // 0.02 * 730 = 14.6 - metadata: null, - }); - - mockDb.prepare.mockReturnValue({ - bind: vi.fn().mockReturnThis(), - all: vi.fn().mockResolvedValue({ results: [mockInstance] }), - }); - - const result = await service.recommend(request); - - expect(result.recommendations[0].price.monthly).toBe(14.6); - }); - }); - - describe('queryInstances', () => { - it('should query instances from Asia-Pacific regions', async () => { - const request: RecommendationRequest = { - stack: ['nginx'], - scale: 'small', - }; - - mockDb.prepare.mockReturnValue({ - bind: vi.fn().mockReturnThis(), - all: vi.fn().mockResolvedValue({ results: [] }), - }); - - await service.recommend(request); - - // Verify SQL query structure - const prepareCall = mockDb.prepare.mock.calls[0][0]; - expect(prepareCall).toContain('WHERE p.name IN'); - expect(prepareCall).toContain('AND r.region_code IN'); - expect(prepareCall).toContain('AND it.memory_mb >= ?'); - expect(prepareCall).toContain('AND it.vcpu >= ?'); - }); - - it('should handle database query errors gracefully', async () => { - const request: RecommendationRequest = { - stack: ['nginx'], - scale: 'small', - }; - - mockDb.prepare.mockReturnValue({ - bind: vi.fn().mockReturnThis(), - all: vi.fn().mockRejectedValue(new Error('Database connection failed')), - }); - - await expect(service.recommend(request)).rejects.toThrow( - 'Failed to query instances from database' - ); - }); - }); -}); diff --git a/src/services/recommendation.ts b/src/services/recommendation.ts deleted file mode 100644 index 5395283..0000000 --- a/src/services/recommendation.ts +++ /dev/null @@ -1,405 +0,0 @@ -/** - * Recommendation Service - * - * Provides intelligent instance recommendations based on: - * - Technology stack requirements - * - Deployment scale - * - Budget constraints - * - Asia-Pacific region filtering - */ - -import type { D1Database } from '@cloudflare/workers-types'; -import type { - RecommendationRequest, - RecommendationResponse, - InstanceRecommendation, - ResourceRequirements, - ScaleType, -} from '../types'; -import { validateStack, calculateRequirements } from './stackConfig'; -import { getAsiaRegionCodes, getRegionDisplayName } from './regionFilter'; -import { logger } from '../utils/logger'; - -/** - * Database row interface for instance query results (raw from database) - */ -interface InstanceQueryRowRaw { - id: number; - instance_id: string; - instance_name: string; - vcpu: number; - memory_mb: number; - storage_gb: number; - metadata: string | null; - provider_name: string; - region_code: string; - region_name: string; - hourly_price: number | null; - monthly_price: number | null; -} - -/** - * Database row interface for instance query results (with parsed metadata) - */ -interface InstanceQueryRow { - id: number; - instance_id: string; - instance_name: string; - vcpu: number; - memory_mb: number; - storage_gb: number; - metadata: { monthly_price?: number } | null; - provider_name: string; - region_code: string; - region_name: string; - hourly_price: number | null; - monthly_price: number | null; -} - -/** - * Recommendation Service - * Calculates and ranks cloud instances based on stack requirements - */ -export class RecommendationService { - constructor(private db: D1Database) {} - - /** - * Generate instance recommendations based on stack and scale - * - * Process: - * 1. Validate stack array - * 2. Validate stack components - * 3. Validate scale enum - * 4. Validate budget_max - * 5. Calculate resource requirements - * 6. Query Asia-Pacific instances matching requirements - * 7. Score and rank instances - * 8. Return top 5 recommendations - * - * @param request - Recommendation request with stack, scale, and budget - * @returns Recommendation response with requirements and ranked instances - * @throws Error if validation fails or database query fails - */ - async recommend(request: RecommendationRequest): Promise { - logger.info('[Recommendation] Processing request', { - stack: request.stack, - scale: request.scale, - budget_max: request.budget_max, - }); - - // 1. Validate stack array - if (!Array.isArray(request.stack) || request.stack.length === 0) { - throw new Error('Stack must be a non-empty array'); - } - - // 2. Validate stack components - const validation = validateStack(request.stack); - if (!validation.valid) { - const errorMsg = `Invalid stacks: ${validation.invalidStacks.join(', ')}`; - logger.error('[Recommendation] Stack validation failed', { - invalidStacks: validation.invalidStacks, - }); - throw new Error(errorMsg); - } - - // 3. Validate scale enum - const validScales: ScaleType[] = ['small', 'medium', 'large']; - if (!validScales.includes(request.scale)) { - throw new Error(`Invalid scale: ${request.scale}. Must be one of: ${validScales.join(', ')}`); - } - - // 4. Validate budget_max (if provided) - if (request.budget_max !== undefined) { - if (typeof request.budget_max !== 'number' || request.budget_max <= 0) { - throw new Error('budget_max must be a positive number'); - } - if (request.budget_max > 100000) { - throw new Error('budget_max exceeds maximum allowed value ($100,000)'); - } - } - - // 5. Calculate resource requirements based on stack and scale - const requirements = calculateRequirements(request.stack, request.scale); - logger.info('[Recommendation] Resource requirements calculated', { - min_memory_mb: requirements.min_memory_mb, - min_vcpu: requirements.min_vcpu, - }); - - // 6. Query instances from Asia-Pacific regions - const instances = await this.queryInstances(requirements, request.budget_max); - logger.info('[Recommendation] Found instances', { count: instances.length }); - - // 7. Calculate match scores and sort by score (highest first) - const scored = instances.map(inst => - this.scoreInstance(inst, requirements, request.budget_max) - ); - scored.sort((a, b) => b.match_score - a.match_score); - - // 8. Return top 5 recommendations with rank - const recommendations = scored.slice(0, 5).map((inst, idx) => ({ - ...inst, - rank: idx + 1, - })); - - logger.info('[Recommendation] Generated recommendations', { - count: recommendations.length, - top_score: recommendations[0]?.match_score, - }); - - return { requirements, recommendations }; - } - - /** - * Query instances from Asia-Pacific regions matching requirements - * - * Single query optimization: queries all providers (Linode, Vultr, AWS) in one database call - * - Uses IN clause for provider names and region codes - * - Filters by minimum memory and vCPU requirements - * - Optionally filters by maximum budget - * - Returns up to 50 instances across all providers - * - * @param requirements - Minimum resource requirements - * @param budgetMax - Optional maximum monthly budget in USD - * @returns Array of instance query results - */ - private async queryInstances( - requirements: ResourceRequirements, - budgetMax?: number - ): Promise { - // Collect all providers and their Asia-Pacific region codes - const providers = ['linode', 'vultr', 'aws']; - const allRegionCodes: string[] = []; - - for (const provider of providers) { - const regionCodes = getAsiaRegionCodes(provider); - if (regionCodes.length === 0) { - logger.warn('[Recommendation] No Asia regions found for provider', { - provider, - }); - } - allRegionCodes.push(...regionCodes); - } - - // If no regions found across all providers, return empty - if (allRegionCodes.length === 0) { - logger.error('[Recommendation] No Asia regions found for any provider'); - return []; - } - - // Build single query with IN clauses for providers and regions - const providerPlaceholders = providers.map(() => '?').join(','); - const regionPlaceholders = allRegionCodes.map(() => '?').join(','); - - let sql = ` - SELECT - it.id, - it.instance_id, - it.instance_name, - it.vcpu, - it.memory_mb, - it.storage_gb, - it.metadata, - p.name as provider_name, - r.region_code, - r.region_name, - pr.hourly_price, - pr.monthly_price - FROM instance_types it - JOIN providers p ON it.provider_id = p.id - JOIN regions r ON r.provider_id = p.id - LEFT JOIN pricing pr ON pr.instance_type_id = it.id AND pr.region_id = r.id - WHERE p.name IN (${providerPlaceholders}) - AND r.region_code IN (${regionPlaceholders}) - AND it.memory_mb >= ? - AND it.vcpu >= ? - `; - - const params: (string | number)[] = [ - ...providers, - ...allRegionCodes, - requirements.min_memory_mb, - requirements.min_vcpu, - ]; - - // Add budget filter if specified - if (budgetMax) { - sql += ` AND (pr.monthly_price <= ? OR pr.monthly_price IS NULL)`; - params.push(budgetMax); - } - - // Sort by price (cheapest first) and limit results - sql += ` ORDER BY COALESCE(pr.monthly_price, 9999) ASC LIMIT 50`; - - try { - const result = await this.db.prepare(sql).bind(...params).all(); - - logger.info('[Recommendation] Single query executed for all providers', { - providers, - region_count: allRegionCodes.length, - found: result.results?.length || 0, - }); - - // Parse metadata once during result transformation (not in hot scoring loop) - const rawResults = (result.results as unknown as InstanceQueryRowRaw[]) || []; - const parsedResults: InstanceQueryRow[] = rawResults.map(row => { - let parsedMetadata: { monthly_price?: number } | null = null; - - if (row.metadata) { - try { - parsedMetadata = JSON.parse(row.metadata) as { monthly_price?: number }; - } catch (error) { - logger.warn('[Recommendation] Failed to parse metadata', { - instance_id: row.instance_id, - instance_name: row.instance_name, - }); - // Continue with null metadata - } - } - - return { - ...row, - metadata: parsedMetadata, - }; - }); - - return parsedResults; - } catch (error) { - logger.error('[Recommendation] Query failed', { error }); - throw new Error('Failed to query instances from database'); - } - } - - /** - * Calculate match score for an instance - * - * Scoring algorithm (0-100 points): - * - Memory fit (40 points): How well memory matches requirements - * - Perfect fit (1-1.5x): 40 points - * - Comfortable (1.5-2x): 30 points - * - Oversized (>2x): 20 points - * - vCPU fit (30 points): How well vCPU matches requirements - * - Good fit (1-2x): 30 points - * - Oversized (>2x): 20 points - * - Price efficiency (20 points): Budget utilization - * - Under 50% budget: 20 points - * - Under 80% budget: 15 points - * - Over 80% budget: 10 points - * - Storage bonus (10 points): Included storage - * - ≥80GB: 10 points - * - >0GB: 5 points - * - No storage: 0 points - * - * @param instance - Instance query result - * @param requirements - Resource requirements - * @param budgetMax - Optional maximum budget - * @returns Instance recommendation with score, pros, and cons - */ - private scoreInstance( - instance: InstanceQueryRow, - requirements: ResourceRequirements, - budgetMax?: number - ): InstanceRecommendation { - let score = 0; - const pros: string[] = []; - const cons: string[] = []; - - // Memory score (40 points) - measure fit against requirements - const memoryRatio = instance.memory_mb / requirements.min_memory_mb; - if (memoryRatio >= 1 && memoryRatio <= 1.5) { - score += 40; - pros.push('메모리 최적 적합'); - } else if (memoryRatio > 1.5 && memoryRatio <= 2) { - score += 30; - pros.push('메모리 여유 있음'); - } else if (memoryRatio > 2) { - score += 20; - cons.push('메모리 과다 프로비저닝'); - } - - // vCPU score (30 points) - measure fit against requirements - const vcpuRatio = instance.vcpu / requirements.min_vcpu; - if (vcpuRatio >= 1 && vcpuRatio <= 2) { - score += 30; - pros.push('vCPU 적합'); - } else if (vcpuRatio > 2) { - score += 20; - } - - // Price score (20 points) - budget efficiency - const monthlyPrice = this.getMonthlyPrice(instance); - if (budgetMax && monthlyPrice > 0) { - const priceRatio = monthlyPrice / budgetMax; - if (priceRatio <= 0.5) { - score += 20; - pros.push('예산 대비 저렴'); - } else if (priceRatio <= 0.8) { - score += 15; - pros.push('합리적 가격'); - } else { - score += 10; - } - } else if (monthlyPrice > 0) { - score += 15; // Default score when no budget specified - } - - // Storage score (10 points) - included storage bonus - if (instance.storage_gb >= 80) { - score += 10; - pros.push(`스토리지 ${instance.storage_gb}GB 포함`); - } else if (instance.storage_gb > 0) { - score += 5; - } else { - cons.push('EBS 스토리지 별도'); - } - - // Build recommendation object - return { - rank: 0, // Will be set by caller after sorting - provider: instance.provider_name, - instance: instance.instance_name, - region: `${getRegionDisplayName(instance.region_code)} (${instance.region_code})`, - specs: { - vcpu: instance.vcpu, - memory_mb: instance.memory_mb, - storage_gb: instance.storage_gb || 0, - }, - price: { - monthly: monthlyPrice, - hourly: instance.hourly_price || monthlyPrice / 730, - }, - match_score: Math.min(100, score), // Cap at 100 - pros, - cons, - }; - } - - /** - * Extract monthly price from instance data - * - * Pricing sources: - * 1. Direct monthly_price column (Linode) - * 2. Pre-parsed metadata field (Vultr, AWS) - * 3. Calculate from hourly_price if available - * - * @param instance - Instance query result with pre-parsed metadata - * @returns Monthly price in USD, or 0 if not available - */ - private getMonthlyPrice(instance: InstanceQueryRow): number { - // Direct monthly price (from pricing table) - if (instance.monthly_price) { - return instance.monthly_price; - } - - // Extract from pre-parsed metadata (Vultr, AWS) - if (instance.metadata?.monthly_price) { - return instance.metadata.monthly_price; - } - - // Calculate from hourly price (730 hours per month average) - if (instance.hourly_price) { - return instance.hourly_price * 730; - } - - return 0; - } -} diff --git a/src/services/regionFilter.ts b/src/services/regionFilter.ts deleted file mode 100644 index 484ef3e..0000000 --- a/src/services/regionFilter.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Region Filter Service - * Manages Asia-Pacific region filtering (Seoul, Tokyo, Osaka, Singapore, Hong Kong) - */ - -/** - * Asia-Pacific region codes by provider - * Limited to 5 major cities in East/Southeast Asia - */ -export const ASIA_REGIONS: Record = { - linode: ['jp-tyo-3', 'jp-osa', 'sg-sin-2'], - vultr: ['icn', 'nrt', 'itm'], - aws: ['ap-northeast-1', 'ap-northeast-2', 'ap-northeast-3', 'ap-southeast-1', 'ap-east-1'], -}; - -/** - * Region code to display name mapping - */ -export const REGION_DISPLAY_NAMES: Record = { - // Linode - 'jp-tyo-3': 'Tokyo', - 'jp-osa': 'Osaka', - 'sg-sin-2': 'Singapore', - // Vultr - 'icn': 'Seoul', - 'nrt': 'Tokyo', - 'itm': 'Osaka', - // AWS - 'ap-northeast-1': 'Tokyo', - 'ap-northeast-2': 'Seoul', - 'ap-northeast-3': 'Osaka', - 'ap-southeast-1': 'Singapore', - 'ap-east-1': 'Hong Kong', -}; - -/** - * Check if a region code is in the Asia-Pacific filter list - * - * @param provider - Cloud provider name (case-insensitive) - * @param regionCode - Region code to check (case-insensitive) - * @returns true if region is in Asia-Pacific filter list - */ -export function isAsiaRegion(provider: string, regionCode: string): boolean { - const regions = ASIA_REGIONS[provider.toLowerCase()]; - if (!regions) return false; - return regions.includes(regionCode.toLowerCase()); -} - -/** - * Get all Asia-Pacific region codes for a provider - * - * @param provider - Cloud provider name (case-insensitive) - * @returns Array of region codes, empty if provider not found - */ -export function getAsiaRegionCodes(provider: string): string[] { - return ASIA_REGIONS[provider.toLowerCase()] || []; -} - -/** - * Get display name for a region code - * - * @param regionCode - Region code to look up - * @returns Display name (e.g., "Tokyo"), or original code if not found - */ -export function getRegionDisplayName(regionCode: string): string { - return REGION_DISPLAY_NAMES[regionCode] || regionCode; -} diff --git a/src/services/stackConfig.ts b/src/services/stackConfig.ts deleted file mode 100644 index ecc4c3c..0000000 --- a/src/services/stackConfig.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Stack Configuration Service - * Manages technology stack requirements and resource calculations - */ - -import type { ScaleType, ResourceRequirements } from '../types'; - -/** - * Memory requirements for each stack component (in MB) - */ -export const STACK_REQUIREMENTS: Record = { - nginx: { min: 128, recommended: 256 }, - 'php-fpm': { min: 512, recommended: 1024 }, - mysql: { min: 1024, recommended: 2048 }, - mariadb: { min: 1024, recommended: 2048 }, - postgresql: { min: 1024, recommended: 2048 }, - redis: { min: 256, recommended: 512 }, - elasticsearch: { min: 2048, recommended: 4096 }, - nodejs: { min: 512, recommended: 1024 }, - docker: { min: 1024, recommended: 2048 }, - mongodb: { min: 1024, recommended: 2048 }, -}; - -/** - * Base OS overhead (in MB) - */ -export const OS_OVERHEAD_MB = 768; - -/** - * Validate stack components against supported technologies - * - * @param stack - Array of technology stack components - * @returns Validation result with list of invalid stacks - */ -export function validateStack(stack: string[]): { valid: boolean; invalidStacks: string[] } { - const invalidStacks = stack.filter(s => !STACK_REQUIREMENTS[s.toLowerCase()]); - return { - valid: invalidStacks.length === 0, - invalidStacks, - }; -} - -/** - * Calculate resource requirements based on stack and scale - * - * Memory calculation: - * - small: minimum requirements - * - medium: recommended requirements - * - large: 1.5x recommended requirements - * - * vCPU calculation: - * - 1 vCPU per 2GB memory (rounded up) - * - Minimum 1 vCPU - * - * @param stack - Array of technology stack components - * @param scale - Deployment scale (small/medium/large) - * @returns Calculated resource requirements with breakdown - */ -export function calculateRequirements(stack: string[], scale: ScaleType): ResourceRequirements { - const breakdown: Record = {}; - let totalMemory = 0; - - // Calculate memory for each stack component - for (const s of stack) { - const req = STACK_REQUIREMENTS[s.toLowerCase()]; - if (req) { - let memoryMb: number; - if (scale === 'small') { - memoryMb = req.min; - } else if (scale === 'large') { - memoryMb = Math.ceil(req.recommended * 1.5); - } else { - // medium - memoryMb = req.recommended; - } - breakdown[s] = memoryMb >= 1024 ? `${memoryMb / 1024}GB` : `${memoryMb}MB`; - totalMemory += memoryMb; - } - } - - // Add OS overhead - breakdown['os_overhead'] = `${OS_OVERHEAD_MB}MB`; - totalMemory += OS_OVERHEAD_MB; - - // Calculate vCPU: 1 vCPU per 2GB memory, minimum 1 - const minVcpu = Math.max(1, Math.ceil(totalMemory / 2048)); - - return { - min_memory_mb: totalMemory, - min_vcpu: minVcpu, - breakdown, - }; -} diff --git a/src/types.ts b/src/types.ts index c41c0f0..19c4177 100644 --- a/src/types.ts +++ b/src/types.ts @@ -488,85 +488,6 @@ export interface ApiError { path?: string; } -// ============================================================ -// Recommendation API Types -// ============================================================ - -/** - * Scale type for resource requirements - */ -export type ScaleType = 'small' | 'medium' | 'large'; - -/** - * Request body for instance recommendations - */ -export interface RecommendationRequest { - /** Technology stack components (e.g., ['nginx', 'mysql', 'redis']) */ - stack: string[]; - /** Deployment scale (small/medium/large) */ - scale: ScaleType; - /** Maximum monthly budget in USD (optional) */ - budget_max?: number; -} - -/** - * Calculated resource requirements based on stack and scale - */ -export interface ResourceRequirements { - /** Minimum required memory in MB */ - min_memory_mb: number; - /** Minimum required vCPU count */ - min_vcpu: number; - /** Memory breakdown by component */ - breakdown: Record; -} - -/** - * Individual instance recommendation with scoring - */ -export interface InstanceRecommendation { - /** Recommendation rank (1 = best match) */ - rank: number; - /** Cloud provider name */ - provider: string; - /** Instance type identifier */ - instance: string; - /** Region code */ - region: string; - /** Instance specifications */ - specs: { - /** Virtual CPU count */ - vcpu: number; - /** Memory in MB */ - memory_mb: number; - /** Storage in GB */ - storage_gb: number; - }; - /** Pricing information */ - price: { - /** Monthly price in USD */ - monthly: number; - /** Hourly price in USD */ - hourly: number; - }; - /** Match score (0-100) */ - match_score: number; - /** Advantages of this instance */ - pros: string[]; - /** Disadvantages or considerations */ - cons: string[]; -} - -/** - * Complete recommendation response - */ -export interface RecommendationResponse { - /** Calculated resource requirements */ - requirements: ResourceRequirements; - /** List of recommended instances (sorted by match score) */ - recommendations: InstanceRecommendation[]; -} - // ============================================================ // Anvil Product Types // ============================================================