@meyicloud/meyi-cost-server 1.6.0 → 1.7.0

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/AGENTS.md CHANGED
@@ -3,7 +3,7 @@
3
3
  ## Scope
4
4
 
5
5
  This package is a tenant-aware Express plugin for AWS cost overview, reports,
6
- budgets, alert dismissals, Cost Explorer, and optional CUR/Athena data. It is
6
+ budgets, alert dismissals, and mandatory CUR/Athena data. It is
7
7
  designed to be mounted inside a host backend; it does not own login, tenant
8
8
  onboarding, or the host plugin registry.
9
9
 
@@ -20,7 +20,7 @@ insight-cost-server/
20
20
  | |-- routes/ # Express paths and common error handling
21
21
  | |-- models/ # Immutable customer, SaaS CUR, and status models
22
22
  | |-- repositories/ # Tenant-scoped onboarding persistence reads
23
- | |-- services/ # Cost Explorer, SaaS Athena, context, budgets
23
+ | |-- services/ # CUR/Athena, external analyser, context, budgets
24
24
  | |-- schema/ # Plugin-owned database tables and installation
25
25
  | |-- lib/ # Stateless date, cost, and filter helpers
26
26
  | `-- plugin.js # Dependency composition and lifecycle
package/README.md CHANGED
@@ -32,7 +32,7 @@ and AWS onboarding. The plugin owns cost routes and its budget-related tables.
32
32
  - User-, tenant-, month-, and status-scoped budget-alert dismissals
33
33
  - Tenant-configurable daily, weekly, and monthly AI report schedules
34
34
  - PostgreSQL-backed background report jobs with failure history
35
- - Server-generated PDF reports available through tenant-scoped downloads
35
+ - Analyser-generated Markdown and PDF reports available through tenant-scoped downloads
36
36
 
37
37
  Budgets are application rules stored in PostgreSQL; they are not AWS Budgets
38
38
  resources. The consuming application evaluates them against current cost and
@@ -97,7 +97,7 @@ For a local package installation test:
97
97
 
98
98
  ```bash
99
99
  npm pack
100
- npm install /path/to/meyicloud-meyi-cost-server-1.5.0.tgz
100
+ npm install /path/to/meyicloud-meyi-cost-server-1.6.0.tgz
101
101
  ```
102
102
 
103
103
  ## Publish to npm
@@ -290,8 +290,7 @@ a restarted task can resume future work without relying on in-memory timers.
290
290
  | `COST_AI_ANALYSER_SUBNETS` | Empty | When enabled | Comma-separated private subnet IDs. |
291
291
  | `COST_AI_ANALYSER_SECURITY_GROUPS` | Empty | When enabled | Comma-separated task security groups. |
292
292
  | `COST_AI_ANALYSER_REPORT_BUCKET` | Empty | When enabled | Private S3 Markdown/PDF artifact bucket. |
293
- | `COST_AI_ANALYSER_BEDROCK_REGION` | Empty | When enabled | Bedrock region passed to the analyser task. |
294
- | `COST_AI_ANALYSER_BEDROCK_MODEL_ID` | Empty | When enabled | Model passed only to the analyser task. |
293
+ | `COST_AI_ANALYSER_PROVIDER_SECRET_PREFIX` | Empty | When enabled | Prefix for temporary Secrets Manager entries containing each tenant's active Meyi Connect AI provider configuration. |
295
294
  | `COST_AI_ANALYSER_TARGET_REGIONS` | `AWS_REGION` | No | Customer regions inspected for resource detail. |
296
295
  | `COST_AI_ANALYSER_TOP_N_SERVICES` | `10` | No | Maximum high-cost service agents run per report. |
297
296
  | `COST_AI_ANALYSER_POLL_INTERVAL_MS` | `15000` | No | ECS task status polling interval. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meyicloud/meyi-cost-server",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Tenant-aware AWS CUR and Athena cost plugin server for MeyiConnect",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -22,6 +22,7 @@
22
22
  "@aws-sdk/client-ecs": "^3.850.0",
23
23
  "@aws-sdk/client-glue": "^3.850.0",
24
24
  "@aws-sdk/client-s3": "^3.850.0",
25
+ "@aws-sdk/client-secrets-manager": "^3.850.0",
25
26
  "@aws-sdk/client-sts": "^3.850.0",
26
27
  "@aws-sdk/credential-providers": "^3.850.0",
27
28
  "drizzle-orm": "^0.44.7",
package/src/plugin.js CHANGED
@@ -24,7 +24,7 @@ import { CurDiscoveryService } from "./cur-discovery/cur-discovery.service.js";
24
24
  import { CurDiscoveryWorker } from "./cur-discovery/cur-discovery.worker.js";
25
25
  import { safeSchema } from "./lib/cost-utils.js";
26
26
 
27
- export function createInsightCost({ app, db, apiBaseUri = "/api/v1", logger = console } = {}) {
27
+ export function createInsightCost({ app, db, apiBaseUri = "/api/v1", logger = console, providerResolver = null } = {}) {
28
28
  if (!db) throw new Error("db is required");
29
29
  const schema = safeSchema(process.env.DB_SCHEMA || "meyiconnect");
30
30
  const qSchema = `"${schema}"`;
@@ -51,7 +51,7 @@ export function createInsightCost({ app, db, apiBaseUri = "/api/v1", logger = co
51
51
  const discoveryWorker = new CurDiscoveryWorker({ repository: discoveryRepository, service: discoveryService, logger });
52
52
  const costController = new CostController({ contextService, curProvider, discoveryRepository, logger });
53
53
  const budgetController = new BudgetController({ contextService, budgetService, budgetAlertService });
54
- const costAnalyserService = new CostAnalyserService({ contextService, athenaContextService, logger });
54
+ const costAnalyserService = new CostAnalyserService({ contextService, athenaContextService, providerResolver, logger });
55
55
  const artifactService = new CostReportArtifactService();
56
56
  const costAnalysisReportService = new CostAnalysisReportService({ contextService, repository: costAnalysisReportRepository, artifactService });
57
57
  const costAnalysisReportWorker = new CostAnalysisReportWorker({ repository: costAnalysisReportRepository, analyserService: costAnalyserService, logger });
@@ -78,7 +78,8 @@ export class CostAnalysisReportRepository {
78
78
  async completeExternal(id, result) {
79
79
  await this.db.execute(sql`
80
80
  UPDATE ${sql.raw(this.reports)} SET status = 'COMPLETED', ecs_task_arn = ${result.taskArn},
81
- model_id = ${result.modelId}, data_source = ${result.dataSource}, facts = ${JSON.stringify(result.facts || {})}::jsonb,
81
+ model_id = ${result.modelId}, provider_name = ${result.provider}, provider_source = ${result.source},
82
+ data_source = ${result.dataSource}, facts = ${JSON.stringify(result.facts || {})}::jsonb,
82
83
  result = ${JSON.stringify(result.summary || {})}::jsonb, artifact_bucket = ${result.artifactBucket},
83
84
  markdown_key = ${result.markdownKey}, markdown_size = ${Number(result.markdownSize || 0)},
84
85
  pdf_key = ${result.pdfKey}, pdf_size = ${Number(result.pdfSize || 0)}, pdf_data = NULL,
@@ -104,7 +105,7 @@ export class CostAnalysisReportRepository {
104
105
  async list(tenantId, limit = 100) {
105
106
  return rows(await this.db.execute(sql`
106
107
  SELECT id, schedule_frequency, scheduled_for, period_start, period_end, status,
107
- attempt_count, model_id, data_source, facts, result, artifact_bucket,
108
+ attempt_count, model_id, provider_name, provider_source, data_source, facts, result, artifact_bucket,
108
109
  markdown_key, markdown_size, pdf_key, pdf_size, error,
109
110
  started_at, completed_at, created_at
110
111
  FROM ${sql.raw(this.reports)} WHERE tenant_id = ${tenantId}
@@ -118,7 +119,7 @@ export class CostAnalysisReportRepository {
118
119
  }
119
120
  return rows(await this.db.execute(sql`
120
121
  SELECT id, schedule_frequency, scheduled_for, period_start, period_end, status,
121
- attempt_count, model_id, data_source, facts, result, artifact_bucket,
122
+ attempt_count, model_id, provider_name, provider_source, data_source, facts, result, artifact_bucket,
122
123
  markdown_key, markdown_size, pdf_key, pdf_size, error,
123
124
  started_at, completed_at, created_at
124
125
  FROM ${sql.raw(this.reports)} WHERE tenant_id = ${tenantId} AND id = ${id} LIMIT 1
@@ -26,6 +26,8 @@ export async function installCostAnalysisReportSchema(db, qSchema) {
26
26
  status text NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING', 'RUNNING', 'COMPLETED', 'FAILED')),
27
27
  attempt_count integer NOT NULL DEFAULT 0,
28
28
  model_id text,
29
+ provider_name text,
30
+ provider_source text,
29
31
  data_source text,
30
32
  facts jsonb,
31
33
  result jsonb,
@@ -50,4 +52,6 @@ export async function installCostAnalysisReportSchema(db, qSchema) {
50
52
  await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_ai_reports ADD COLUMN IF NOT EXISTS markdown_key text`));
51
53
  await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_ai_reports ADD COLUMN IF NOT EXISTS markdown_size integer`));
52
54
  await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_ai_reports ADD COLUMN IF NOT EXISTS pdf_key text`));
55
+ await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_ai_reports ADD COLUMN IF NOT EXISTS provider_name text`));
56
+ await db.execute(sql.raw(`ALTER TABLE ${qSchema}.cost_ai_reports ADD COLUMN IF NOT EXISTS provider_source text`));
53
57
  }
@@ -5,11 +5,24 @@ import {
5
5
  StopTaskCommand,
6
6
  } from "@aws-sdk/client-ecs";
7
7
  import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
8
+ import {
9
+ CreateSecretCommand,
10
+ DeleteSecretCommand,
11
+ SecretsManagerClient,
12
+ } from "@aws-sdk/client-secrets-manager";
8
13
  import { truthy } from "../lib/cost-utils.js";
9
14
 
10
15
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
11
16
  const list = (value) => String(value || "").split(",").map((item) => item.trim()).filter(Boolean);
12
17
 
18
+ function normalizeProvider(value) {
19
+ const name = String(value || "").trim().toLowerCase();
20
+ if (name.includes("bedrock")) return "bedrock";
21
+ if (name.includes("anthropic") || name.includes("claude")) return "anthropic";
22
+ if (name.includes("openai") || name.includes("gpt")) return "openai";
23
+ return name;
24
+ }
25
+
13
26
  function required(value, name) {
14
27
  const text = String(value || "").trim();
15
28
  if (!text) throw Object.assign(new Error(`${name} is required for external cost analysis`), { name: "CostAnalyserConfigError", statusCode: 503 });
@@ -29,9 +42,10 @@ async function bodyText(body) {
29
42
  }
30
43
 
31
44
  export class CostAnalyserService {
32
- constructor({ contextService, athenaContextService, logger = console, env = process.env, ecsClient = null, s3Client = null } = {}) {
45
+ constructor({ contextService, athenaContextService, providerResolver = null, logger = console, env = process.env, ecsClient = null, s3Client = null, secretsClient = null } = {}) {
33
46
  this.contextService = contextService;
34
47
  this.athenaContextService = athenaContextService;
48
+ this.providerResolver = providerResolver;
35
49
  this.logger = logger;
36
50
  this.enabled = truthy(env.COST_AI_ENABLED);
37
51
  this.region = String(env.COST_AI_ANALYSER_REGION || env.AWS_REGION || env.AWS_DEFAULT_REGION || "us-east-1");
@@ -43,23 +57,98 @@ export class CostAnalyserService {
43
57
  this.assignPublicIp = truthy(env.COST_AI_ANALYSER_ASSIGN_PUBLIC_IP) ? "ENABLED" : "DISABLED";
44
58
  this.reportBucket = String(env.COST_AI_ANALYSER_REPORT_BUCKET || "").trim();
45
59
  this.targetRegions = String(env.COST_AI_ANALYSER_TARGET_REGIONS || env.AWS_REGION || "us-east-1").trim();
46
- this.bedrockRegion = String(env.COST_AI_ANALYSER_BEDROCK_REGION || "").trim();
47
- this.bedrockModelId = String(env.COST_AI_ANALYSER_BEDROCK_MODEL_ID || "").trim();
60
+ this.providerSecretPrefix = String(env.COST_AI_ANALYSER_PROVIDER_SECRET_PREFIX || "").trim().replace(/\/+$/, "");
48
61
  this.topServices = String(env.COST_AI_ANALYSER_TOP_N_SERVICES || "10").trim();
49
62
  this.pollMs = Math.max(Number(env.COST_AI_ANALYSER_POLL_INTERVAL_MS || 15_000), 2_000);
50
63
  this.timeoutMs = Math.max(Number(env.COST_AI_ANALYSER_TIMEOUT_MS || 45 * 60_000), 60_000);
51
64
  this.ecs = ecsClient || new ECSClient({ region: this.region });
52
65
  this.s3 = s3Client || new S3Client({ region: this.region });
66
+ this.secrets = secretsClient || new SecretsManagerClient({ region: this.region });
67
+ }
68
+
69
+ infrastructureConfigured() {
70
+ return Boolean(this.cluster && this.taskDefinition && this.subnets.length && this.securityGroups.length && this.reportBucket && this.providerSecretPrefix);
71
+ }
72
+
73
+ async resolveProvider(tenantId) {
74
+ if (!this.providerResolver) {
75
+ throw Object.assign(new Error("Meyi Connect AI provider resolution is not configured"), { name: "CostAiProviderResolverError", statusCode: 503 });
76
+ }
77
+ const resolved = await this.providerResolver(tenantId);
78
+ if (!resolved) {
79
+ throw Object.assign(new Error("Configure an active AI provider before enabling AI cost reports"), { name: "CostAiProviderNotConfiguredError", statusCode: 409 });
80
+ }
81
+ const provider = normalizeProvider(resolved.provider);
82
+ if (!["bedrock", "anthropic", "openai"].includes(provider)) {
83
+ throw Object.assign(new Error(`Unsupported AI provider "${resolved.provider}"`), { name: "CostAiProviderConfigError", statusCode: 400 });
84
+ }
85
+ const modelId = required(resolved.modelId, "active AI provider model ID");
86
+ if ((provider === "anthropic" || provider === "openai") && !resolved.apiKey) {
87
+ throw Object.assign(new Error(`${provider} requires an API key in the active AI provider`), { name: "CostAiProviderConfigError", statusCode: 400 });
88
+ }
89
+ if (provider === "bedrock" && !resolved.region) {
90
+ throw Object.assign(new Error("Bedrock requires an AWS region in the active AI provider"), { name: "CostAiProviderConfigError", statusCode: 400 });
91
+ }
92
+ if (Boolean(resolved.accessKeyId) !== Boolean(resolved.secretAccessKey)) {
93
+ throw Object.assign(new Error("The active Bedrock provider must contain both AWS access-key fields"), { name: "CostAiProviderConfigError", statusCode: 400 });
94
+ }
95
+ return { ...resolved, provider, modelId, source: "tenant-configuration" };
53
96
  }
54
97
 
55
- status() {
56
- const configured = Boolean(this.cluster && this.taskDefinition && this.subnets.length && this.securityGroups.length && this.reportBucket && this.bedrockModelId);
98
+ async status(req) {
99
+ const configured = this.infrastructureConfigured();
100
+ if (!this.enabled || !configured) {
101
+ return { enabled: false, providerConfigured: false, provider: null, modelId: null, region: null, source: null, reason: this.enabled ? "ANALYSER_NOT_CONFIGURED" : "COST_AI_DISABLED" };
102
+ }
103
+ try {
104
+ const tenantId = this.contextService.tenantId(req);
105
+ const provider = await this.resolveProvider(tenantId);
106
+ return { enabled: true, providerConfigured: true, provider: provider.provider, modelId: provider.modelId, region: provider.region || null, source: provider.source };
107
+ } catch (error) {
108
+ if (error.name !== "CostAiProviderNotConfiguredError") throw error;
109
+ return { enabled: false, providerConfigured: false, provider: null, modelId: null, region: null, source: null, reason: "AI_PROVIDER_REQUIRED" };
110
+ }
111
+ }
112
+
113
+ async createProviderSecret({ tenantId, reportId, provider }) {
114
+ const response = await this.secrets.send(new CreateSecretCommand({
115
+ Name: `${this.providerSecretPrefix}/${reportId}`,
116
+ Description: "Temporary provider configuration for one Meyi cost-analysis task",
117
+ SecretString: JSON.stringify({
118
+ provider: provider.provider,
119
+ modelId: provider.modelId,
120
+ apiKey: provider.apiKey,
121
+ authType: provider.authType,
122
+ region: provider.region,
123
+ accessKeyId: provider.accessKeyId,
124
+ secretAccessKey: provider.secretAccessKey,
125
+ }),
126
+ Tags: [
127
+ { Key: "Application", Value: "meyi-connect" },
128
+ { Key: "Component", Value: "cost-ai-analyser" },
129
+ { Key: "TenantId", Value: String(tenantId).slice(0, 256) },
130
+ { Key: "ReportId", Value: String(reportId).slice(0, 256) },
131
+ ],
132
+ }));
133
+ return required(response.ARN, "temporary AI provider secret ARN");
134
+ }
135
+
136
+ async deleteProviderSecret(secretArn) {
137
+ if (!secretArn) return;
138
+ try {
139
+ await this.secrets.send(new DeleteSecretCommand({ SecretId: secretArn, ForceDeleteWithoutRecovery: true }));
140
+ } catch (error) {
141
+ if (error?.name === "ResourceNotFoundException") return;
142
+ this.logger.error?.("[Cost AI Reports] Failed to delete temporary provider secret", { secretArn, message: error.message });
143
+ }
144
+ }
145
+
146
+ statusSummary(provider) {
57
147
  return {
58
- enabled: this.enabled && configured,
59
- provider: "meyi-cost-ai-analyser",
60
- modelId: configured ? this.bedrockModelId : null,
61
- region: configured ? this.bedrockRegion : null,
62
- source: "ecs-task",
148
+ provider: provider.provider,
149
+ modelId: provider.modelId,
150
+ region: provider.region || null,
151
+ source: provider.source,
63
152
  };
64
153
  }
65
154
 
@@ -70,7 +159,7 @@ export class CostAnalyserService {
70
159
  if (!this.subnets.length) required("", "COST_AI_ANALYSER_SUBNETS");
71
160
  if (!this.securityGroups.length) required("", "COST_AI_ANALYSER_SECURITY_GROUPS");
72
161
  required(this.reportBucket, "COST_AI_ANALYSER_REPORT_BUCKET");
73
- required(this.bedrockModelId, "COST_AI_ANALYSER_BEDROCK_MODEL_ID");
162
+ required(this.providerSecretPrefix, "COST_AI_ANALYSER_PROVIDER_SECRET_PREFIX");
74
163
  }
75
164
 
76
165
  async readJson(key) {
@@ -80,79 +169,88 @@ export class CostAnalyserService {
80
169
 
81
170
  async execute({ tenantId, reportId, range, frequency, onStarted = null }) {
82
171
  this.validate();
172
+ const provider = await this.resolveProvider(tenantId);
83
173
  const customer = await this.contextService.resolve({ headers: {}, user: { tenant_id: tenantId, tenantId } });
84
174
  const athena = this.athenaContextService.resolve(customer).config;
85
175
  if (!athena.enabled) throw Object.assign(new Error("CUR discovery has not produced a queryable Athena table for this tenant"), { name: "CurDataNotReadyError", statusCode: 503 });
86
176
  const targetRoleArn = required(customer.meta?.payerRoleArn, "customer TARGET_ROLE_ARN");
87
177
  const targetAccountId = required(customer.meta?.managementAccountId || customer.accounts?.[0]?.id, "customer management account ID");
88
178
  const prefix = `tenants/${tenantId}/reports/${reportId}`;
89
- const values = {
90
- REPORT_JOB_ID: reportId,
91
- REPORT_TENANT_ID: tenantId,
92
- REPORT_KEY_PREFIX: prefix,
93
- REPORT_START_DATE: range.Start,
94
- REPORT_END_DATE: range.End,
95
- REPORT_FREQUENCY: frequency,
96
- REPORT_BUCKET: this.reportBucket,
97
- REPORT_BUCKET_REGION: this.region,
98
- TARGET_ACCOUNT_ID: targetAccountId,
99
- TARGET_ROLE_ARN: targetRoleArn,
100
- TARGET_ROLE_EXTERNAL_ID: customer.meta?.externalId,
101
- TARGET_REGIONS: this.targetRegions,
102
- COST_CUR_DATABASE: athena.database,
103
- COST_CUR_TABLE: athena.table,
104
- COST_CUR_WORKGROUP: athena.workgroup,
105
- COST_CUR_OUTPUT_LOCATION: athena.outputLocation,
106
- COST_CUR_REGION: athena.region,
107
- COST_CUR_TENANT_COLUMN: athena.tenantColumn,
108
- COST_CUR_TENANT_PARTITION: athena.tenantPartition || tenantId,
109
- BEDROCK_REGION: this.bedrockRegion,
110
- BEDROCK_MODEL_ID: this.bedrockModelId,
111
- TOP_N_SERVICES: this.topServices,
112
- };
113
- const launched = await this.ecs.send(new RunTaskCommand({
114
- cluster: this.cluster,
115
- taskDefinition: this.taskDefinition,
116
- launchType: "FARGATE",
117
- count: 1,
118
- enableExecuteCommand: false,
119
- networkConfiguration: { awsvpcConfiguration: { subnets: this.subnets, securityGroups: this.securityGroups, assignPublicIp: this.assignPublicIp } },
120
- overrides: { containerOverrides: [{ name: this.containerName, environment: environment(values) }] },
121
- startedBy: `meyi-cost-${reportId}`.slice(0, 36),
122
- }));
123
- if (launched.failures?.length || !launched.tasks?.[0]?.taskArn) {
124
- const reason = launched.failures?.map((item) => item.reason || item.detail).filter(Boolean).join("; ") || "ECS did not return a task ARN";
125
- throw Object.assign(new Error(`Unable to start cost analyser: ${reason}`), { name: "CostAnalyserLaunchError", statusCode: 502 });
126
- }
127
- const taskArn = launched.tasks[0].taskArn;
128
- await onStarted?.(taskArn);
129
- const deadline = Date.now() + this.timeoutMs;
130
- while (Date.now() < deadline) {
131
- const response = await this.ecs.send(new DescribeTasksCommand({ cluster: this.cluster, tasks: [taskArn] }));
132
- const task = response.tasks?.[0];
133
- if (task?.lastStatus === "STOPPED") {
134
- const container = task.containers?.find((item) => item.name === this.containerName) || task.containers?.[0];
135
- let status;
136
- try { status = await this.readJson(`${prefix}/status.json`); } catch { status = null; }
137
- if (Number(container?.exitCode ?? 1) !== 0 || status?.status !== "COMPLETED") {
138
- throw Object.assign(new Error(status?.error || container?.reason || task.stoppedReason || "Cost analyser task failed"), { name: "CostAnalyserTaskError", statusCode: 502, taskArn });
179
+ let providerSecretArn;
180
+ try {
181
+ providerSecretArn = await this.createProviderSecret({ tenantId, reportId, provider });
182
+ const values = {
183
+ REPORT_JOB_ID: reportId,
184
+ REPORT_TENANT_ID: tenantId,
185
+ REPORT_KEY_PREFIX: prefix,
186
+ REPORT_START_DATE: range.Start,
187
+ REPORT_END_DATE: range.End,
188
+ REPORT_FREQUENCY: frequency,
189
+ REPORT_BUCKET: this.reportBucket,
190
+ REPORT_BUCKET_REGION: this.region,
191
+ TARGET_ACCOUNT_ID: targetAccountId,
192
+ TARGET_ROLE_ARN: targetRoleArn,
193
+ TARGET_ROLE_EXTERNAL_ID: customer.meta?.externalId,
194
+ TARGET_REGIONS: this.targetRegions,
195
+ COST_CUR_DATABASE: athena.database,
196
+ COST_CUR_TABLE: athena.table,
197
+ COST_CUR_WORKGROUP: athena.workgroup,
198
+ COST_CUR_OUTPUT_LOCATION: athena.outputLocation,
199
+ COST_CUR_REGION: athena.region,
200
+ COST_CUR_TENANT_COLUMN: athena.tenantColumn,
201
+ COST_CUR_TENANT_PARTITION: athena.tenantPartition || tenantId,
202
+ AI_PROVIDER_SECRET_ARN: providerSecretArn,
203
+ AI_PROVIDER_SECRET_REGION: this.region,
204
+ TOP_N_SERVICES: this.topServices,
205
+ };
206
+ const launched = await this.ecs.send(new RunTaskCommand({
207
+ cluster: this.cluster,
208
+ taskDefinition: this.taskDefinition,
209
+ launchType: "FARGATE",
210
+ count: 1,
211
+ enableExecuteCommand: false,
212
+ networkConfiguration: { awsvpcConfiguration: { subnets: this.subnets, securityGroups: this.securityGroups, assignPublicIp: this.assignPublicIp } },
213
+ overrides: { containerOverrides: [{ name: this.containerName, environment: environment(values) }] },
214
+ startedBy: `meyi-cost-${reportId}`.slice(0, 36),
215
+ }));
216
+ if (launched.failures?.length || !launched.tasks?.[0]?.taskArn) {
217
+ const reason = launched.failures?.map((item) => item.reason || item.detail).filter(Boolean).join("; ") || "ECS did not return a task ARN";
218
+ throw Object.assign(new Error(`Unable to start cost analyser: ${reason}`), { name: "CostAnalyserLaunchError", statusCode: 502 });
219
+ }
220
+ const taskArn = launched.tasks[0].taskArn;
221
+ await onStarted?.(taskArn);
222
+ const deadline = Date.now() + this.timeoutMs;
223
+ while (Date.now() < deadline) {
224
+ const response = await this.ecs.send(new DescribeTasksCommand({ cluster: this.cluster, tasks: [taskArn] }));
225
+ const task = response.tasks?.[0];
226
+ if (task?.lastStatus === "STOPPED") {
227
+ const container = task.containers?.find((item) => item.name === this.containerName) || task.containers?.[0];
228
+ let status;
229
+ try { status = await this.readJson(`${prefix}/status.json`); } catch { status = null; }
230
+ if (Number(container?.exitCode ?? 1) !== 0 || status?.status !== "COMPLETED") {
231
+ throw Object.assign(new Error(status?.error || container?.reason || task.stoppedReason || "Cost analyser task failed"), { name: "CostAnalyserTaskError", statusCode: 502, taskArn });
232
+ }
233
+ return {
234
+ taskArn,
235
+ artifactBucket: this.reportBucket,
236
+ markdownKey: status.markdownKey || `${prefix}/report.md`,
237
+ pdfKey: status.pdfKey || `${prefix}/report.pdf`,
238
+ ticketsKey: status.ticketsKey || `${prefix}/tickets.json`,
239
+ ticketCount: Number(status.summary?.ticketCount || 0),
240
+ markdownSize: Number(status.markdownSize || 0),
241
+ pdfSize: Number(status.pdfSize || 0),
242
+ ...this.statusSummary(provider),
243
+ dataSource: "AWS CUR",
244
+ facts: { currentTotal: Number(status.summary?.totalMonthlyCost || 0), currency: "USD" },
245
+ summary: status.summary || {},
246
+ };
139
247
  }
140
- return {
141
- taskArn,
142
- artifactBucket: this.reportBucket,
143
- markdownKey: status.markdownKey || `${prefix}/report.md`,
144
- pdfKey: status.pdfKey || `${prefix}/report.pdf`,
145
- markdownSize: Number(status.markdownSize || 0),
146
- pdfSize: Number(status.pdfSize || 0),
147
- modelId: this.bedrockModelId,
148
- dataSource: "AWS CUR",
149
- facts: { currentTotal: Number(status.summary?.totalMonthlyCost || 0), currency: "USD" },
150
- summary: status.summary || {},
151
- };
248
+ await sleep(this.pollMs);
152
249
  }
153
- await sleep(this.pollMs);
250
+ await this.ecs.send(new StopTaskCommand({ cluster: this.cluster, task: taskArn, reason: "Meyi cost analysis timeout" }));
251
+ throw Object.assign(new Error("Cost analyser task timed out"), { name: "CostAnalyserTimeoutError", statusCode: 504, taskArn });
252
+ } finally {
253
+ await this.deleteProviderSecret(providerSecretArn);
154
254
  }
155
- await this.ecs.send(new StopTaskCommand({ cluster: this.cluster, task: taskArn, reason: "Meyi cost analysis timeout" }));
156
- throw Object.assign(new Error("Cost analyser task timed out"), { name: "CostAnalyserTimeoutError", statusCode: 504, taskArn });
157
255
  }
158
256
  }
@@ -9,7 +9,8 @@ function reportDto(row) {
9
9
  return {
10
10
  id: row.id, frequency: row.schedule_frequency, scheduledFor: row.scheduled_for,
11
11
  period: { Start: String(row.period_start).slice(0, 10), End: String(row.period_end).slice(0, 10) },
12
- status: row.status, attempts: Number(row.attempt_count || 0), modelId: row.model_id,
12
+ status: row.status, attempts: Number(row.attempt_count || 0), provider: row.provider_name,
13
+ providerSource: row.provider_source, modelId: row.model_id,
13
14
  dataSource: row.data_source, facts: row.facts || null, ...(row.result || {}),
14
15
  pdfReady: row.status === "COMPLETED" && Boolean(row.pdf_key || row.pdf_data || Number(row.pdf_size || 0) > 0),
15
16
  pdfSize: Number(row.pdf_size || 0), error: row.error, startedAt: row.started_at,