refactor: 추천 시스템 제거
삭제된 파일: - 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 <noreply@anthropic.com>
This commit is contained in:
@@ -281,38 +281,6 @@ function validateSyncResponse(data: unknown): boolean | string {
|
|||||||
return true;
|
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<string, unknown>;
|
|
||||||
|
|
||||||
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<string, unknown>;
|
|
||||||
|
|
||||||
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
|
// Test Suites
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -484,95 +452,6 @@ async function testSyncEndpoint(): Promise<TestResult[]> {
|
|||||||
return tests;
|
return tests;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testRecommendEndpoint(): Promise<TestResult[]> {
|
|
||||||
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
|
// Test Runner
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -639,12 +518,6 @@ async function runTests(): Promise<TestReport> {
|
|||||||
allResults.push(...syncResults);
|
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 duration = Date.now() - startTime;
|
||||||
const passed = allResults.filter(r => r.passed).length;
|
const passed = allResults.filter(r => r.passed).length;
|
||||||
const failed = allResults.filter(r => !r.passed).length;
|
const failed = allResults.filter(r => !r.passed).length;
|
||||||
|
|||||||
@@ -121,119 +121,15 @@ function sleep(ms: number): Promise<void> {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scenario 1: WordPress Server Recommendation → Detail Lookup
|
* Scenario 1: Budget-Constrained Instance Search
|
||||||
*
|
|
||||||
* 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<boolean> {
|
|
||||||
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
|
|
||||||
*
|
*
|
||||||
* Flow:
|
* Flow:
|
||||||
* 1. GET /instances?max_price=50&sort_by=price&order=asc
|
* 1. GET /instances?max_price=50&sort_by=price&order=asc
|
||||||
* 2. Validate all results <= $50/month
|
* 2. Validate all results <= $50/month
|
||||||
* 3. Validate price sorting is correct
|
* 3. Validate price sorting is correct
|
||||||
*/
|
*/
|
||||||
async function scenario2Budget(context: TestContext, dryRun: boolean): Promise<boolean> {
|
async function scenario1Budget(context: TestContext, dryRun: boolean): Promise<boolean> {
|
||||||
console.log('\n▶️ Scenario 2: Budget-Constrained Instance Search');
|
console.log('\n▶️ Scenario 1: Budget-Constrained Instance Search');
|
||||||
|
|
||||||
if (dryRun) {
|
if (dryRun) {
|
||||||
console.log(' [DRY RUN] Would execute:');
|
console.log(' [DRY RUN] Would execute:');
|
||||||
@@ -297,15 +193,15 @@ async function scenario2Budget(context: TestContext, dryRun: boolean): Promise<b
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scenario 3: Cross-Region Price Comparison
|
* Scenario 2: Cross-Region Price Comparison
|
||||||
*
|
*
|
||||||
* Flow:
|
* Flow:
|
||||||
* 1. GET /instances?region=ap-northeast-1 (Tokyo)
|
* 1. GET /instances?region=ap-northeast-1 (Tokyo)
|
||||||
* 2. GET /instances?region=ap-northeast-2 (Seoul)
|
* 2. GET /instances?region=ap-northeast-2 (Seoul)
|
||||||
* 3. Compare average prices and instance counts
|
* 3. Compare average prices and instance counts
|
||||||
*/
|
*/
|
||||||
async function scenario3RegionCompare(context: TestContext, dryRun: boolean): Promise<boolean> {
|
async function scenario2RegionCompare(context: TestContext, dryRun: boolean): Promise<boolean> {
|
||||||
console.log('\n▶️ Scenario 3: Cross-Region Price Comparison (Tokyo vs Seoul)');
|
console.log('\n▶️ Scenario 2: Cross-Region Price Comparison (Tokyo vs Seoul)');
|
||||||
|
|
||||||
if (dryRun) {
|
if (dryRun) {
|
||||||
console.log(' [DRY RUN] Would execute:');
|
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:
|
* Flow:
|
||||||
* 1. POST /sync with provider: linode
|
* 1. POST /sync with provider: linode
|
||||||
* 2. GET /health to check sync_status
|
* 2. GET /health to check sync_status
|
||||||
* 3. GET /instances?provider=linode to verify data exists
|
* 3. GET /instances?provider=linode to verify data exists
|
||||||
*/
|
*/
|
||||||
async function scenario4ProviderSync(context: TestContext, dryRun: boolean): Promise<boolean> {
|
async function scenario3ProviderSync(context: TestContext, dryRun: boolean): Promise<boolean> {
|
||||||
console.log('\n▶️ Scenario 4: Provider Sync and Data Verification');
|
console.log('\n▶️ Scenario 3: Provider Sync and Data Verification');
|
||||||
|
|
||||||
if (dryRun) {
|
if (dryRun) {
|
||||||
console.log(' [DRY RUN] Would execute:');
|
console.log(' [DRY RUN] Would execute:');
|
||||||
@@ -562,10 +458,9 @@ interface ScenarioFunction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const scenarios: Record<string, ScenarioFunction> = {
|
const scenarios: Record<string, ScenarioFunction> = {
|
||||||
wordpress: scenario1WordPress,
|
budget: scenario1Budget,
|
||||||
budget: scenario2Budget,
|
region: scenario2RegionCompare,
|
||||||
region: scenario3RegionCompare,
|
sync: scenario3ProviderSync,
|
||||||
sync: scenario4ProviderSync,
|
|
||||||
ratelimit: scenario5RateLimit,
|
ratelimit: scenario5RateLimit,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ export const CACHE_TTL = {
|
|||||||
HEALTH: 30,
|
HEALTH: 30,
|
||||||
/** Cache TTL for pricing data (1 hour) */
|
/** Cache TTL for pricing data (1 hour) */
|
||||||
PRICING: 3600,
|
PRICING: 3600,
|
||||||
/** Cache TTL for recommendation results (10 minutes) */
|
|
||||||
RECOMMENDATIONS: 600,
|
|
||||||
/** Default cache TTL (5 minutes) */
|
/** Default cache TTL (5 minutes) */
|
||||||
DEFAULT: 300,
|
DEFAULT: 300,
|
||||||
} as const;
|
} as const;
|
||||||
@@ -61,8 +59,6 @@ export const RATE_LIMIT_DEFAULTS = {
|
|||||||
MAX_REQUESTS_INSTANCES: 100,
|
MAX_REQUESTS_INSTANCES: 100,
|
||||||
/** Maximum requests per window for /sync endpoint */
|
/** Maximum requests per window for /sync endpoint */
|
||||||
MAX_REQUESTS_SYNC: 10,
|
MAX_REQUESTS_SYNC: 10,
|
||||||
/** Maximum requests per window for /recommend endpoint */
|
|
||||||
MAX_REQUESTS_RECOMMEND: 50,
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Env } from './types';
|
import { Env } from './types';
|
||||||
import { handleSync, handleInstances, handleHealth, handleRecommend } from './routes';
|
import { handleSync, handleInstances, handleHealth } from './routes';
|
||||||
import {
|
import {
|
||||||
authenticateRequest,
|
authenticateRequest,
|
||||||
verifyApiKey,
|
verifyApiKey,
|
||||||
@@ -175,11 +175,6 @@ export default {
|
|||||||
return addSecurityHeaders(await handleSync(request, env), corsOrigin, requestId);
|
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
|
// 404 Not Found
|
||||||
return addSecurityHeaders(
|
return addSecurityHeaders(
|
||||||
Response.json(
|
Response.json(
|
||||||
|
|||||||
@@ -67,10 +67,6 @@ const RATE_LIMITS: Record<string, RateLimitConfig> = {
|
|||||||
maxRequests: RATE_LIMIT_DEFAULTS.MAX_REQUESTS_SYNC,
|
maxRequests: RATE_LIMIT_DEFAULTS.MAX_REQUESTS_SYNC,
|
||||||
windowMs: RATE_LIMIT_DEFAULTS.WINDOW_MS,
|
windowMs: RATE_LIMIT_DEFAULTS.WINDOW_MS,
|
||||||
},
|
},
|
||||||
'/recommend': {
|
|
||||||
maxRequests: RATE_LIMIT_DEFAULTS.MAX_REQUESTS_RECOMMEND,
|
|
||||||
windowMs: RATE_LIMIT_DEFAULTS.WINDOW_MS,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,4 +6,3 @@
|
|||||||
export { handleSync } from './sync';
|
export { handleSync } from './sync';
|
||||||
export { handleInstances } from './instances';
|
export { handleInstances } from './instances';
|
||||||
export { handleHealth } from './health';
|
export { handleHealth } from './health';
|
||||||
export { handleRecommend } from './recommend';
|
|
||||||
|
|||||||
@@ -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<Response> {
|
|
||||||
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<RecommendRequestBody>(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<CachedRecommendation>(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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<typeof createMockD1Database>;
|
|
||||||
|
|
||||||
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'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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<RecommendationResponse> {
|
|
||||||
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<InstanceQueryRow[]> {
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<string, string[]> = {
|
|
||||||
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<string, string> = {
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
@@ -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<string, { min: number; recommended: number }> = {
|
|
||||||
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<string, string> = {};
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
79
src/types.ts
79
src/types.ts
@@ -488,85 +488,6 @@ export interface ApiError {
|
|||||||
path?: string;
|
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<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
// Anvil Product Types
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user