@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 +2 -2
- package/README.md +3 -4
- package/package.json +2 -1
- package/src/plugin.js +2 -2
- package/src/repositories/cost-analysis-report.repository.js +4 -3
- package/src/schema/cost-analysis-report.schema.js +4 -0
- package/src/services/cost-analyser.service.js +174 -76
- package/src/services/cost-analysis-report.service.js +2 -1
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,
|
|
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/ #
|
|
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
|
-
-
|
|
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.
|
|
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
|
-
| `
|
|
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.
|
|
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},
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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.
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
|
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),
|
|
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,
|