@biznetgio/neo-app 0.4.14 → 0.4.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/deploySource.js +11 -1
- package/src/mcp/tools.js +227 -7
- package/src/pricing.js +77 -0
package/package.json
CHANGED
package/src/deploySource.js
CHANGED
|
@@ -6,6 +6,7 @@ import { basename, join } from 'node:path';
|
|
|
6
6
|
import { existsSync, readFileSync, readdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
7
7
|
import { execFileSync } from 'node:child_process';
|
|
8
8
|
import { tmpdir } from 'node:os';
|
|
9
|
+
import { estimateCost } from './pricing.js';
|
|
9
10
|
|
|
10
11
|
export function sanitizeName(s) {
|
|
11
12
|
return (
|
|
@@ -262,7 +263,16 @@ export async function deploySource(api, { projectId, src, name, port, build, ser
|
|
|
262
263
|
|
|
263
264
|
if (dryRun) {
|
|
264
265
|
for (const p of plan) log(`[dry-run] would ${p}`);
|
|
265
|
-
|
|
266
|
+
// Cost confirmation before a billable deploy (handoff 3.3/3.4). A new service
|
|
267
|
+
// starts on the default compute tier; estimate from the live price book so the
|
|
268
|
+
// user sees the cost in the dry-run. Best-effort — never fail the dry-run on it.
|
|
269
|
+
let estimatedCost;
|
|
270
|
+
try {
|
|
271
|
+
const book = (await api.get('/api/v1/pricing')).data;
|
|
272
|
+
estimatedCost = estimateCost(book, { computeTier: 'micro' });
|
|
273
|
+
log(`[dry-run] estimated cost (default micro compute, ${svcType}): ~${estimatedCost.monthly} ${estimatedCost.currency}/mo (${estimatedCost.hourly}/h) — ${estimatedCost.assumptions}. Use neo_estimate for a specific size.`);
|
|
274
|
+
} catch { /* price book unavailable — skip the estimate, not the dry-run */ }
|
|
275
|
+
return { dryRun: true, plan, service: svc, environment: targetEnv, serviceType: svcType, estimatedCost };
|
|
266
276
|
}
|
|
267
277
|
|
|
268
278
|
if (src.mode === 'dir' || src.mode === 'zip') {
|
package/src/mcp/tools.js
CHANGED
|
@@ -6,6 +6,7 @@ import { resolve } from 'node:path';
|
|
|
6
6
|
import { statSync } from 'node:fs';
|
|
7
7
|
import { deploySource } from '../deploySource.js';
|
|
8
8
|
import { pkgVersion } from '../util.js';
|
|
9
|
+
import { estimateCost } from '../pricing.js';
|
|
9
10
|
|
|
10
11
|
const str = (description) => ({ type: 'string', description });
|
|
11
12
|
const int = (description) => ({ type: 'integer', description });
|
|
@@ -23,6 +24,23 @@ function result(status, data) {
|
|
|
23
24
|
return data;
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
// Credentials are write-only (handoff 4.4/4.5/4.7/5): never hand a raw secret
|
|
28
|
+
// (password, connection string, access/secret key, token) back to the agent.
|
|
29
|
+
// Recursively replace any secret-looking value with a reference marker so the
|
|
30
|
+
// agent still sees the resource's shape and non-secret identifiers.
|
|
31
|
+
const SECRET_KEY = /(password|secret|secretkey|accesskey|connectionstring|connstr|dsn|token|privatekey|apikey)/i;
|
|
32
|
+
function redactSecrets(value) {
|
|
33
|
+
if (Array.isArray(value)) return value.map(redactSecrets);
|
|
34
|
+
if (value && typeof value === 'object') {
|
|
35
|
+
const out = {};
|
|
36
|
+
for (const [k, v] of Object.entries(value)) {
|
|
37
|
+
out[k] = SECRET_KEY.test(k) ? '[redacted — credentials are write-only; bind them to a consumer instead]' : redactSecrets(v);
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
|
|
26
44
|
export const TOOLS = [
|
|
27
45
|
{
|
|
28
46
|
name: 'neo_whoami',
|
|
@@ -65,10 +83,10 @@ export const TOOLS = [
|
|
|
65
83
|
{ type: 'template', create: true, deploy: true, scale: false, logs: true, variables: true, delete: true },
|
|
66
84
|
{ type: 'static_site', create: 'via_folder', deploy: true, scale: false, logs: true, variables: true, delete: true, note: 'neo_deploy_folder serviceType=static_site (auto-detected for static-only folders); auto-detection from a repo/monorepo is still TODO (handoff 6)' },
|
|
67
85
|
{ type: 'worker', create: 'via_folder', deploy: true, scale: true, logs: true, variables: true, delete: true, note: 'neo_deploy_folder serviceType=worker — no public ingress; repo auto-detection still TODO (handoff 6)' },
|
|
68
|
-
{ type: 'database', create:
|
|
69
|
-
{ type: 'queue', create:
|
|
70
|
-
{ type: 'cron', create:
|
|
71
|
-
{ type: 'object_storage', create:
|
|
86
|
+
{ type: 'database', create: true, get: true, list: true, delete: true, note: 'neo_create/list/get/delete_database (0.4.15); update/plan still TODO (handoff 4.4)' },
|
|
87
|
+
{ type: 'queue', create: true, get: true, list: true, delete: true, note: 'neo_create/list/get/delete_queue (0.4.15); update/plan still TODO (handoff 4.5)' },
|
|
88
|
+
{ type: 'cron', create: true, get: true, list: true, delete: true, note: 'neo_create/list/get/delete_cron (0.4.15); manual-run/history still TODO (handoff 4.6)' },
|
|
89
|
+
{ type: 'object_storage', create: true, get: true, list: true, delete: true, note: 'neo_create/list/get/delete_bucket (0.4.15); object put/get still TODO (handoff 4.7)' },
|
|
72
90
|
];
|
|
73
91
|
|
|
74
92
|
return {
|
|
@@ -76,11 +94,11 @@ export const TOOLS = [
|
|
|
76
94
|
controlPlane,
|
|
77
95
|
resourceTypes,
|
|
78
96
|
// Verbs this MCP build exposes today (no create-DB/queue/cron/bucket, no costed plan/apply yet).
|
|
79
|
-
lifecycleVerbs: ['list', 'get', 'deploy', 'scale', 'logs', 'get_variables', 'set_variable', 'delete', 'list_approvals', 'get_approval', 'get_credit', 'list_templates'],
|
|
97
|
+
lifecycleVerbs: ['create', 'list', 'get', 'deploy', 'scale', 'logs', 'get_variables', 'set_variable', 'delete', 'list_approvals', 'get_approval', 'get_credit', 'list_templates'],
|
|
80
98
|
sourceTypes: ['git', 'image', 'dockerfile', 'local', 'template'],
|
|
81
99
|
environments: ['staging', 'production'],
|
|
82
100
|
pricing,
|
|
83
|
-
costConsent: { planApplyEnforced: false, note: 'neo_plan_stack / Approve & Create
|
|
101
|
+
costConsent: { estimateAvailable: true, dryRunIncludesCost: true, planApplyEnforced: false, note: 'neo_estimate + deploy dry-run show cost (0.4.16); server-enforced neo_plan_stack / Approve & Create is still TODO (handoff 3.3-3.5)' },
|
|
84
102
|
minClientVersion: pkgVersion(),
|
|
85
103
|
deprecations: [],
|
|
86
104
|
};
|
|
@@ -243,6 +261,37 @@ export const TOOLS = [
|
|
|
243
261
|
return (await api.get(`/api/v1/orgs/${orgId}/credit`)).data;
|
|
244
262
|
},
|
|
245
263
|
},
|
|
264
|
+
{
|
|
265
|
+
name: 'neo_estimate',
|
|
266
|
+
description:
|
|
267
|
+
'Estimate the hourly and monthly cost (IDR) of a service or resource from the LIVE price book, so you can show the user the cost BEFORE a billable create/deploy (handoff 3.3/3.4). Read-only. Covers compute (a tier, or custom vcpu/memoryMB), persistent storage, and object storage; variable usage (queue messages, cron runs) is excluded and noted. Pass orgId to apply that org\'s price overrides.',
|
|
268
|
+
mutating: false,
|
|
269
|
+
schema: {
|
|
270
|
+
type: 'object',
|
|
271
|
+
properties: {
|
|
272
|
+
computeTier: str('Compute tier key: micro | small | medium | large | xlarge (bundled compute)'),
|
|
273
|
+
vcpu: { type: 'number', description: 'Custom vCPU count (use instead of computeTier)' },
|
|
274
|
+
memoryMB: int('Custom memory in MB (use with vcpu)'),
|
|
275
|
+
storageGB: int('Persistent/database storage in GB (optional)'),
|
|
276
|
+
objectStorageGB: int('Object-storage quota in GB (optional)'),
|
|
277
|
+
replicas: int('Replica count (default 1)'),
|
|
278
|
+
orgId: str('Organization id (optional — applies that org\'s price overrides)'),
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
async call(api, args) {
|
|
282
|
+
// Prefer the org price book (applies overrides); fall back to global defaults.
|
|
283
|
+
let orgId = args.orgId;
|
|
284
|
+
if (!orgId) { try { orgId = await api.resolveOrgId(); } catch { orgId = null; } }
|
|
285
|
+
let book;
|
|
286
|
+
try {
|
|
287
|
+
book = (await api.get(orgId ? `/api/v1/orgs/${orgId}/pricing` : '/api/v1/pricing')).data;
|
|
288
|
+
} catch {
|
|
289
|
+
book = (await api.get('/api/v1/pricing')).data; // org book may 404; global always readable
|
|
290
|
+
}
|
|
291
|
+
const est = estimateCost(book, args);
|
|
292
|
+
return { ...est, priceBook: orgId ? `org:${orgId}` : 'global-defaults' };
|
|
293
|
+
},
|
|
294
|
+
},
|
|
246
295
|
{
|
|
247
296
|
name: 'neo_list_templates',
|
|
248
297
|
description: 'List deployable templates (e.g. wordpress, n8n, redis).',
|
|
@@ -305,7 +354,7 @@ export const TOOLS = [
|
|
|
305
354
|
environment: args.environment || 'production',
|
|
306
355
|
dryRun: !!args.dryRun,
|
|
307
356
|
});
|
|
308
|
-
if (r.dryRun) return { status: 'dry_run', plan: r.plan, service: r.service, environment: r.environment };
|
|
357
|
+
if (r.dryRun) return { status: 'dry_run', plan: r.plan, service: r.service, environment: r.environment, estimatedCost: r.estimatedCost };
|
|
309
358
|
if (r.pending) return { status: 'pending_approval', approvalId: r.approvalId };
|
|
310
359
|
return { status: r.result, url: r.url || undefined, appId: r.appId };
|
|
311
360
|
},
|
|
@@ -327,6 +376,177 @@ export const TOOLS = [
|
|
|
327
376
|
return found;
|
|
328
377
|
},
|
|
329
378
|
},
|
|
379
|
+
// --- Managed resources (handoff 4.4-4.7): create/list/get for DB, Queue, Cron,
|
|
380
|
+
// Object Storage. Credentials in responses are redacted (write-only). A member's
|
|
381
|
+
// create may return pending_approval (202) — surfaced verbatim, not an error. ---
|
|
382
|
+
{
|
|
383
|
+
name: 'neo_create_database',
|
|
384
|
+
description: 'Create a managed database in a project (handoff 4.4). Returns the resource (credentials redacted); poll neo_get_database for readiness, then deploy via REST/portal if needed. May return pending_approval.',
|
|
385
|
+
mutating: true,
|
|
386
|
+
schema: {
|
|
387
|
+
type: 'object',
|
|
388
|
+
properties: {
|
|
389
|
+
projectId: str('Project id'),
|
|
390
|
+
name: str('Database name'),
|
|
391
|
+
engine: { type: 'string', description: 'postgres | mysql (engine)' },
|
|
392
|
+
version: str('Engine version, e.g. "16" (optional)'),
|
|
393
|
+
environment: str('production or staging (optional, default production)'),
|
|
394
|
+
volumeSize: str('Storage size, e.g. "10Gi" (optional)'),
|
|
395
|
+
replicas: int('Read/failover replicas for HA (optional; postgres/CNPG only)'),
|
|
396
|
+
deploymentMode: { type: 'string', description: 'single | ha (optional)' },
|
|
397
|
+
},
|
|
398
|
+
required: ['projectId', 'name', 'engine'],
|
|
399
|
+
},
|
|
400
|
+
async call(api, args) {
|
|
401
|
+
const { projectId, ...body } = args;
|
|
402
|
+
const { status, data } = await api.post(`/api/v1/projects/${projectId}/databases`, body);
|
|
403
|
+
return redactSecrets(result(status, data));
|
|
404
|
+
},
|
|
405
|
+
},
|
|
406
|
+
{
|
|
407
|
+
name: 'neo_list_databases',
|
|
408
|
+
description: 'List managed databases in a project (credentials redacted).',
|
|
409
|
+
mutating: false,
|
|
410
|
+
schema: { type: 'object', properties: { projectId: str('Project id') }, required: ['projectId'] },
|
|
411
|
+
async call(api, args) {
|
|
412
|
+
return redactSecrets((await api.get(`/api/v1/projects/${args.projectId}/databases`)).data);
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
{
|
|
416
|
+
name: 'neo_get_database',
|
|
417
|
+
description: 'Get one managed database by id, including provisioning/readiness state (credentials redacted — bind them to a consumer instead).',
|
|
418
|
+
mutating: false,
|
|
419
|
+
schema: { type: 'object', properties: { databaseId: str('Database id') }, required: ['databaseId'] },
|
|
420
|
+
async call(api, args) {
|
|
421
|
+
return redactSecrets((await api.get(`/api/v1/databases/${args.databaseId}`)).data);
|
|
422
|
+
},
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
name: 'neo_create_queue',
|
|
426
|
+
description: 'Create a managed queue in a project (handoff 4.5). Credentials redacted. May return pending_approval.',
|
|
427
|
+
mutating: true,
|
|
428
|
+
schema: {
|
|
429
|
+
type: 'object',
|
|
430
|
+
properties: {
|
|
431
|
+
projectId: str('Project id'),
|
|
432
|
+
name: str('Queue name'),
|
|
433
|
+
planClass: str('Plan/engine class (optional)'),
|
|
434
|
+
environment: str('production or staging (optional, default production)'),
|
|
435
|
+
volumeSize: str('Storage size, e.g. "10Gi" (optional)'),
|
|
436
|
+
dlqEnabled: { type: 'boolean', description: 'Enable a dead-letter queue (optional)' },
|
|
437
|
+
},
|
|
438
|
+
required: ['projectId', 'name'],
|
|
439
|
+
},
|
|
440
|
+
async call(api, args) {
|
|
441
|
+
const { projectId, ...body } = args;
|
|
442
|
+
const { status, data } = await api.post(`/api/v1/projects/${projectId}/queues`, body);
|
|
443
|
+
return redactSecrets(result(status, data));
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
name: 'neo_list_queues',
|
|
448
|
+
description: 'List managed queues in a project (credentials redacted).',
|
|
449
|
+
mutating: false,
|
|
450
|
+
schema: { type: 'object', properties: { projectId: str('Project id') }, required: ['projectId'] },
|
|
451
|
+
async call(api, args) {
|
|
452
|
+
return redactSecrets((await api.get(`/api/v1/projects/${args.projectId}/queues`)).data);
|
|
453
|
+
},
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
name: 'neo_get_queue',
|
|
457
|
+
description: 'Get one managed queue by id, including readiness (credentials redacted).',
|
|
458
|
+
mutating: false,
|
|
459
|
+
schema: { type: 'object', properties: { queueId: str('Queue id') }, required: ['queueId'] },
|
|
460
|
+
async call(api, args) {
|
|
461
|
+
return redactSecrets((await api.get(`/api/v1/queues/${args.queueId}`)).data);
|
|
462
|
+
},
|
|
463
|
+
},
|
|
464
|
+
{
|
|
465
|
+
name: 'neo_create_cron',
|
|
466
|
+
description: 'Create a scheduled job (cron) in a project (handoff 4.6). Target is either an existing service command (mode=app_command, serviceId+command) or an HTTP endpoint (mode=http_endpoint, url+method). Schedule via scheduleType (cron|interval|once|seconds) + schedule/intervalMinutes. May return pending_approval.',
|
|
467
|
+
mutating: true,
|
|
468
|
+
schema: {
|
|
469
|
+
type: 'object',
|
|
470
|
+
properties: {
|
|
471
|
+
projectId: str('Project id'),
|
|
472
|
+
name: str('Job name'),
|
|
473
|
+
mode: { type: 'string', description: 'app_command | http_endpoint' },
|
|
474
|
+
scheduleType: { type: 'string', description: 'cron | interval | once | seconds' },
|
|
475
|
+
schedule: str('Cron expression (when scheduleType=cron), e.g. "0 * * * *"'),
|
|
476
|
+
intervalMinutes: int('Interval in minutes (when scheduleType=interval)'),
|
|
477
|
+
timezone: str('IANA timezone, e.g. "Asia/Jakarta" (optional)'),
|
|
478
|
+
serviceId: str('Target service id (mode=app_command)'),
|
|
479
|
+
image: str('Image to run (mode=app_command, if not targeting a service)'),
|
|
480
|
+
command: { type: 'array', items: { type: 'string' }, description: 'Command argv (mode=app_command)' },
|
|
481
|
+
url: str('Target URL (mode=http_endpoint)'),
|
|
482
|
+
method: str('HTTP method (mode=http_endpoint), e.g. GET/POST'),
|
|
483
|
+
environment: str('production or staging (optional, default production)'),
|
|
484
|
+
},
|
|
485
|
+
required: ['projectId', 'name', 'mode', 'scheduleType'],
|
|
486
|
+
},
|
|
487
|
+
async call(api, args) {
|
|
488
|
+
const { projectId, ...body } = args;
|
|
489
|
+
const { status, data } = await api.post(`/api/v1/projects/${projectId}/crons`, body);
|
|
490
|
+
return redactSecrets(result(status, data));
|
|
491
|
+
},
|
|
492
|
+
},
|
|
493
|
+
{
|
|
494
|
+
name: 'neo_list_crons',
|
|
495
|
+
description: 'List scheduled jobs (crons) in a project.',
|
|
496
|
+
mutating: false,
|
|
497
|
+
schema: { type: 'object', properties: { projectId: str('Project id') }, required: ['projectId'] },
|
|
498
|
+
async call(api, args) {
|
|
499
|
+
return redactSecrets((await api.get(`/api/v1/projects/${args.projectId}/crons`)).data);
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
{
|
|
503
|
+
name: 'neo_get_cron',
|
|
504
|
+
description: 'Get one scheduled job (cron) by id, including its schedule and last run.',
|
|
505
|
+
mutating: false,
|
|
506
|
+
schema: { type: 'object', properties: { cronId: str('Cron/scheduled-job id') }, required: ['cronId'] },
|
|
507
|
+
async call(api, args) {
|
|
508
|
+
return redactSecrets((await api.get(`/api/v1/crons/${args.cronId}`)).data);
|
|
509
|
+
},
|
|
510
|
+
},
|
|
511
|
+
{
|
|
512
|
+
name: 'neo_create_bucket',
|
|
513
|
+
description: 'Create an S3-compatible object-storage bucket in a project (handoff 4.7). Access keys are provisioned server-side and are NOT returned (write-only). May return pending_approval.',
|
|
514
|
+
mutating: true,
|
|
515
|
+
schema: {
|
|
516
|
+
type: 'object',
|
|
517
|
+
properties: {
|
|
518
|
+
projectId: str('Project id'),
|
|
519
|
+
name: str('Bucket name'),
|
|
520
|
+
region: str('Region (optional; see neo_list_regions on the portal)'),
|
|
521
|
+
acl: { type: 'string', description: 'private | public-read (optional, default private)' },
|
|
522
|
+
quotaGB: int('Storage quota in GB (optional)'),
|
|
523
|
+
},
|
|
524
|
+
required: ['projectId', 'name'],
|
|
525
|
+
},
|
|
526
|
+
async call(api, args) {
|
|
527
|
+
const { projectId, ...body } = args;
|
|
528
|
+
const { status, data } = await api.post(`/api/v1/projects/${projectId}/buckets`, body);
|
|
529
|
+
return redactSecrets(result(status, data));
|
|
530
|
+
},
|
|
531
|
+
},
|
|
532
|
+
{
|
|
533
|
+
name: 'neo_list_buckets',
|
|
534
|
+
description: 'List object-storage buckets in a project.',
|
|
535
|
+
mutating: false,
|
|
536
|
+
schema: { type: 'object', properties: { projectId: str('Project id') }, required: ['projectId'] },
|
|
537
|
+
async call(api, args) {
|
|
538
|
+
return redactSecrets((await api.get(`/api/v1/projects/${args.projectId}/buckets`)).data);
|
|
539
|
+
},
|
|
540
|
+
},
|
|
541
|
+
{
|
|
542
|
+
name: 'neo_get_bucket',
|
|
543
|
+
description: 'Get one object-storage bucket by id (access keys not returned — write-only).',
|
|
544
|
+
mutating: false,
|
|
545
|
+
schema: { type: 'object', properties: { bucketId: str('Bucket id') }, required: ['bucketId'] },
|
|
546
|
+
async call(api, args) {
|
|
547
|
+
return redactSecrets((await api.get(`/api/v1/buckets/${args.bucketId}`)).data);
|
|
548
|
+
},
|
|
549
|
+
},
|
|
330
550
|
// --- Cleanup (handoff 3.6): MCP can delete every resource type it can reach, so
|
|
331
551
|
// an agent never leaks a resource it created. These are DESTRUCTIVE and permanent;
|
|
332
552
|
// the server hard-deletes the record and tears down the running workload. ---
|
package/src/pricing.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Cost estimation from the NEO App price book (handoff 3.3/3.4 — cost consent).
|
|
2
|
+
// Pure + dependency-free so it's trivially testable and reusable by both the
|
|
3
|
+
// neo_estimate MCP tool and the deploy dry-run. The price book is the live
|
|
4
|
+
// `/pricing` response: { prices: [{metric,key,rate,unit,currency,active}], tiers:[{key,vcpu,memoryMB,defaultRate}] }.
|
|
5
|
+
// defaultRate is IDR/MONTH; hourly SKUs prorate over 730 hours/month.
|
|
6
|
+
|
|
7
|
+
export const HOURS_PER_MONTH = 730;
|
|
8
|
+
|
|
9
|
+
// priceOf finds an active price entry for a metric (+ optional tier key), or null.
|
|
10
|
+
function priceOf(prices, metric, key = '') {
|
|
11
|
+
return (prices || []).find((x) => x.metric === metric && (x.key || '') === key && x.active !== false) || null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// The price book is NOT self-describing by metric name: `compute_tier_hour`
|
|
15
|
+
// actually carries unit:"month" with a MONTHLY rate, while `vcpu_hour`/`gb_ram_hour`
|
|
16
|
+
// are genuinely hourly. So always normalize by the entry's `unit`, never the name.
|
|
17
|
+
function hourlyFrom(price) {
|
|
18
|
+
if (!price) return null;
|
|
19
|
+
return /month/i.test(price.unit || '') ? price.rate / HOURS_PER_MONTH : price.rate;
|
|
20
|
+
}
|
|
21
|
+
function monthlyFrom(price) {
|
|
22
|
+
if (!price) return null;
|
|
23
|
+
return /month/i.test(price.unit || '') ? price.rate : price.rate * HOURS_PER_MONTH;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// estimateCost returns { currency, hourly, monthly, breakdown, assumptions } for
|
|
27
|
+
// a spec: { computeTier?, vcpu?, memoryMB?, storageGB?, objectStorageGB?, replicas? }.
|
|
28
|
+
// Compute is a bundled tier (compute_tier_hour, else the tier's defaultRate/730)
|
|
29
|
+
// or custom (vcpu_hour*vCPU + gb_ram_hour*GB-RAM). Storage/object use their SKUs.
|
|
30
|
+
export function estimateCost(book, spec = {}) {
|
|
31
|
+
const prices = book?.prices || [];
|
|
32
|
+
const tiers = book?.tiers || [];
|
|
33
|
+
const replicas = spec.replicas && spec.replicas > 0 ? spec.replicas : 1;
|
|
34
|
+
const breakdown = [];
|
|
35
|
+
let hourly = 0;
|
|
36
|
+
let monthlyExtra = 0; // monthly-only SKUs (storage/object)
|
|
37
|
+
|
|
38
|
+
if (spec.computeTier) {
|
|
39
|
+
let h = hourlyFrom(priceOf(prices, 'compute_tier_hour', spec.computeTier));
|
|
40
|
+
if (h == null) {
|
|
41
|
+
const tier = tiers.find((t) => t.key === spec.computeTier);
|
|
42
|
+
h = tier ? tier.defaultRate / HOURS_PER_MONTH : null; // defaultRate is monthly
|
|
43
|
+
}
|
|
44
|
+
if (h == null) {
|
|
45
|
+
const known = tiers.map((t) => t.key).join(', ') || 'none in price book';
|
|
46
|
+
throw new Error(`unknown compute tier "${spec.computeTier}" — known tiers: ${known}`);
|
|
47
|
+
}
|
|
48
|
+
hourly += h * replicas;
|
|
49
|
+
breakdown.push({ item: `compute ${spec.computeTier}${replicas > 1 ? ` x${replicas}` : ''}`, hourly: h * replicas });
|
|
50
|
+
} else if (spec.vcpu || spec.memoryMB) {
|
|
51
|
+
const vc = hourlyFrom(priceOf(prices, 'vcpu_hour')) || 0;
|
|
52
|
+
const gr = hourlyFrom(priceOf(prices, 'gb_ram_hour')) || 0;
|
|
53
|
+
const h = (vc * (spec.vcpu || 0) + gr * ((spec.memoryMB || 0) / 1024)) * replicas;
|
|
54
|
+
hourly += h;
|
|
55
|
+
breakdown.push({ item: `compute custom (${spec.vcpu || 0} vCPU, ${spec.memoryMB || 0} MB)${replicas > 1 ? ` x${replicas}` : ''}`, hourly: h });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (spec.storageGB) {
|
|
59
|
+
const m = monthlyFrom(priceOf(prices, 'storage_gb_month') || priceOf(prices, 'gb_storage_hour'));
|
|
60
|
+
if (m != null) { monthlyExtra += m * spec.storageGB; breakdown.push({ item: `storage ${spec.storageGB} GB`, monthly: m * spec.storageGB }); }
|
|
61
|
+
}
|
|
62
|
+
if (spec.objectStorageGB) {
|
|
63
|
+
const m = monthlyFrom(priceOf(prices, 'object_gb_month') || priceOf(prices, 'object_gb_hour')) || 0;
|
|
64
|
+
monthlyExtra += m * spec.objectStorageGB;
|
|
65
|
+
breakdown.push({ item: `object storage ${spec.objectStorageGB} GB`, monthly: m * spec.objectStorageGB });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const monthly = hourly * HOURS_PER_MONTH + monthlyExtra;
|
|
69
|
+
const currency = (prices.find((p) => p.currency)?.currency) || 'IDR';
|
|
70
|
+
return {
|
|
71
|
+
currency,
|
|
72
|
+
hourly: Math.round(hourly * 10000) / 10000,
|
|
73
|
+
monthly: Math.round(monthly),
|
|
74
|
+
breakdown,
|
|
75
|
+
assumptions: `${HOURS_PER_MONTH} hours/month; variable usage (queue messages, cron runs, egress) not included — this is an estimate, not a quote`,
|
|
76
|
+
};
|
|
77
|
+
}
|